/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as fs from 'node:fs'; import * as path from 'node:path'; import { platform } from 'node:os'; import * as dotenv from 'dotenv'; import process from 'node:process'; import { CoreEvent, FatalConfigError, GEMINI_DIR, getErrorMessage, Storage, coreEvents, homedir, type AdminControlsSettings, } from '@google/gemini-cli-core'; import stripJsonComments from 'strip-json-comments'; import { DefaultLight } from '../ui/themes/default-light.js'; import { DefaultDark } from '../ui/themes/default.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; import { type Settings, type MergedSettings, type MemoryImportFormat, type MergeStrategy, type SettingsSchema, type SettingDefinition, getSettingsSchema, } from './settingsSchema.js'; export { type Settings, type MergedSettings, type MemoryImportFormat, type MergeStrategy, type SettingsSchema, type SettingDefinition, getSettingsSchema, }; import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; import { customDeepMerge } from '../utils/deepMerge.js'; import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js'; import { validateSettings, formatValidationError, } from './settings-validation.js'; export function getMergeStrategyForPath( path: string[], ): MergeStrategy | undefined { let current: SettingDefinition | undefined = undefined; let currentSchema: SettingsSchema | undefined = getSettingsSchema(); let parent: SettingDefinition | undefined = undefined; for (const key of path) { if (!currentSchema || !currentSchema[key]) { // Key not found in schema - check if parent has additionalProperties if (parent?.additionalProperties?.mergeStrategy) { return parent.additionalProperties.mergeStrategy; } return undefined; } parent = current; current = currentSchema[key]; currentSchema = current.properties; } return current?.mergeStrategy; } export const USER_SETTINGS_PATH = Storage.getGlobalSettingsPath(); export const USER_SETTINGS_DIR = path.dirname(USER_SETTINGS_PATH); export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE']; const AUTH_ENV_VAR_WHITELIST = [ 'GEMINI_API_KEY', 'GOOGLE_API_KEY', 'GOOGLE_CLOUD_PROJECT', 'GOOGLE_CLOUD_LOCATION', ]; /** * Sanitizes an environment variable value to prevent shell injection. * Restricts values to a safe character set: alphanumeric, -, _, ., / */ export function sanitizeEnvVar(value: string): string { return value.replace(/[^a-zA-Z0-9\-_./]/g, ''); } export function getSystemSettingsPath(): string { if (process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']) { return process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']; } if (platform() === 'darwin') { return '/Library/Application Support/GeminiCli/settings.json'; } else if (platform() === 'win32') { return 'C:\\ProgramData\\gemini-cli\\settings.json'; } else { return '/etc/gemini-cli/settings.json'; } } export function getSystemDefaultsPath(): string { if (process.env['GEMINI_CLI_SYSTEM_DEFAULTS_PATH']) { return process.env['GEMINI_CLI_SYSTEM_DEFAULTS_PATH']; } return path.join( path.dirname(getSystemSettingsPath()), 'system-defaults.json', ); } export type { DnsResolutionOrder } from './settingsSchema.js'; export enum SettingScope { User = 'User', Workspace = 'Workspace', System = 'System', SystemDefaults = 'SystemDefaults', // Note that this scope is not supported in the settings dialog at this time, // it is only supported for extensions. Session = 'Session', } /** * A type representing the settings scopes that are supported for LoadedSettings. */ export type LoadableSettingScope = | SettingScope.User | SettingScope.Workspace | SettingScope.System | SettingScope.SystemDefaults; /** * The actual values of the loadable settings scopes. */ const _loadableSettingScopes = [ SettingScope.User, SettingScope.Workspace, SettingScope.System, SettingScope.SystemDefaults, ]; /** * A type guard function that checks if `scope` is a loadable settings scope, * and allows promotion to the `LoadableSettingsScope` type based on the result. */ export function isLoadableSettingScope( scope: SettingScope, ): scope is LoadableSettingScope { return _loadableSettingScopes.includes(scope); } export interface CheckpointingSettings { enabled?: boolean; } export interface SummarizeToolOutputSettings { tokenBudget?: number; } export interface AccessibilitySettings { enableLoadingPhrases?: boolean; screenReader?: boolean; } export interface SessionRetentionSettings { /** Enable automatic session cleanup */ enabled?: boolean; /** Maximum age of sessions to keep (e.g., "30d", "7d", "24h", "1w") */ maxAge?: string; /** Alternative: Maximum number of sessions to keep (most recent) */ maxCount?: number; /** Minimum retention period (safety limit, defaults to "1d") */ minRetention?: string; } export interface SettingsError { message: string; path: string; severity: 'error' | 'warning'; } export interface SettingsFile { settings: Settings; originalSettings: Settings; path: string; rawJson?: string; readOnly?: boolean; } function setNestedProperty( obj: Record, path: string, value: unknown, ) { const keys = path.split('.'); const lastKey = keys.pop(); if (!lastKey) return; let current: Record = obj; for (const key of keys) { if (current[key] === undefined) { current[key] = {}; } const next = current[key]; if (typeof next === 'object' && next !== null) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion current = next as Record; } else { // This path is invalid, so we stop. return; } } current[lastKey] = value; } export function getDefaultsFromSchema( schema: SettingsSchema = getSettingsSchema(), ): Settings { const defaults: Record = {}; for (const key in schema) { const definition = schema[key]; if (definition.properties) { defaults[key] = getDefaultsFromSchema(definition.properties); } else if (definition.default !== undefined) { defaults[key] = definition.default; } } return defaults as Settings; } export function mergeSettings( system: Settings, systemDefaults: Settings, user: Settings, workspace: Settings, isTrusted: boolean, ): MergedSettings { const safeWorkspace = isTrusted ? workspace : ({} as Settings); const schemaDefaults = getDefaultsFromSchema(); // Settings are merged with the following precedence (last one wins for // single values): // 1. Schema Defaults (Built-in) // 2. System Defaults // 3. User Settings // 4. Workspace Settings // 5. System Settings (as overrides) // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return customDeepMerge( getMergeStrategyForPath, schemaDefaults, systemDefaults, user, safeWorkspace, system, ) as MergedSettings; } /** * Creates a fully populated MergedSettings object for testing purposes. * It merges the provided overrides with the default settings from the schema. * * @param overrides Partial settings to override the defaults. * @returns A complete MergedSettings object. */ export function createTestMergedSettings( overrides: Partial = {}, ): MergedSettings { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return customDeepMerge( getMergeStrategyForPath, getDefaultsFromSchema(), overrides, ) as MergedSettings; } /** * An immutable snapshot of settings state. * Used with useSyncExternalStore for reactive updates. */ export interface LoadedSettingsSnapshot { system: SettingsFile; systemDefaults: SettingsFile; user: SettingsFile; workspace: SettingsFile; isTrusted: boolean; errors: SettingsError[]; merged: MergedSettings; } export class LoadedSettings { constructor( system: SettingsFile, systemDefaults: SettingsFile, user: SettingsFile, workspace: SettingsFile, isTrusted: boolean, errors: SettingsError[] = [], ) { this.system = system; this.systemDefaults = systemDefaults; this.user = user; this._workspaceFile = workspace; this.isTrusted = isTrusted; this.workspace = isTrusted ? workspace : this.createEmptyWorkspace(workspace); this.errors = errors; this._merged = this.computeMergedSettings(); this._snapshot = this.computeSnapshot(); } readonly system: SettingsFile; readonly systemDefaults: SettingsFile; readonly user: SettingsFile; workspace: SettingsFile; isTrusted: boolean; readonly errors: SettingsError[]; private _workspaceFile: SettingsFile; private _merged: MergedSettings; private _snapshot: LoadedSettingsSnapshot; private _remoteAdminSettings: Partial | undefined; get merged(): MergedSettings { return this._merged; } setTrusted(isTrusted: boolean): void { if (this.isTrusted === isTrusted) { return; } this.isTrusted = isTrusted; this.workspace = isTrusted ? this._workspaceFile : this.createEmptyWorkspace(this._workspaceFile); this._merged = this.computeMergedSettings(); coreEvents.emitSettingsChanged(); } private createEmptyWorkspace(workspace: SettingsFile): SettingsFile { return { ...workspace, settings: {}, originalSettings: {}, }; } private computeMergedSettings(): MergedSettings { const merged = mergeSettings( this.system.settings, this.systemDefaults.settings, this.user.settings, this.workspace.settings, this.isTrusted, ); // Remote admin settings always take precedence and file-based admin settings // are ignored. const adminSettingSchema = getSettingsSchema().admin; if (adminSettingSchema?.properties) { const adminSchema = adminSettingSchema.properties; const adminDefaults = getDefaultsFromSchema(adminSchema); // The final admin settings are the defaults overridden by remote settings. // Any admin settings from files are ignored. // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion merged.admin = customDeepMerge( (path: string[]) => getMergeStrategyForPath(['admin', ...path]), adminDefaults, this._remoteAdminSettings?.admin ?? {}, ) as MergedSettings['admin']; } return merged; } private computeSnapshot(): LoadedSettingsSnapshot { const cloneSettingsFile = (file: SettingsFile): SettingsFile => ({ path: file.path, rawJson: file.rawJson, settings: structuredClone(file.settings), originalSettings: structuredClone(file.originalSettings), }); return { system: cloneSettingsFile(this.system), systemDefaults: cloneSettingsFile(this.systemDefaults), user: cloneSettingsFile(this.user), workspace: cloneSettingsFile(this.workspace), isTrusted: this.isTrusted, errors: [...this.errors], merged: structuredClone(this._merged), }; } // Passing this along with getSnapshot to useSyncExternalStore allows for idiomatic reactivity on settings changes // React will pass a listener fn into this subscribe fn // that listener fn will perform an object identity check on the snapshot and trigger a React re render if the snapshot has changed subscribe(listener: () => void): () => void { coreEvents.on(CoreEvent.SettingsChanged, listener); return () => coreEvents.off(CoreEvent.SettingsChanged, listener); } getSnapshot(): LoadedSettingsSnapshot { return this._snapshot; } forScope(scope: LoadableSettingScope): SettingsFile { switch (scope) { case SettingScope.User: return this.user; case SettingScope.Workspace: return this.workspace; case SettingScope.System: return this.system; case SettingScope.SystemDefaults: return this.systemDefaults; default: throw new Error(`Invalid scope: ${scope}`); } } private isPersistable(settingsFile: SettingsFile): boolean { return !settingsFile.readOnly; } setValue(scope: LoadableSettingScope, key: string, value: unknown): void { const settingsFile = this.forScope(scope); // Clone value to prevent reference sharing const valueToSet = typeof value === 'object' && value !== null ? structuredClone(value) : value; setNestedProperty(settingsFile.settings, key, valueToSet); if (this.isPersistable(settingsFile)) { // Use a fresh clone for originalSettings to ensure total independence setNestedProperty( settingsFile.originalSettings, key, structuredClone(valueToSet), ); saveSettings(settingsFile); } this._merged = this.computeMergedSettings(); this._snapshot = this.computeSnapshot(); coreEvents.emitSettingsChanged(); } setRemoteAdminSettings(remoteSettings: AdminControlsSettings): void { const admin: Settings['admin'] = {}; const { strictModeDisabled, mcpSetting, cliFeatureSetting } = remoteSettings; if (Object.keys(remoteSettings).length === 0) { this._remoteAdminSettings = { admin }; this._merged = this.computeMergedSettings(); return; } admin.secureModeEnabled = !strictModeDisabled; admin.mcp = { enabled: mcpSetting?.mcpEnabled, config: mcpSetting?.mcpConfig?.mcpServers, }; admin.extensions = { enabled: cliFeatureSetting?.extensionsSetting?.extensionsEnabled, }; admin.skills = { enabled: cliFeatureSetting?.unmanagedCapabilitiesEnabled, }; this._remoteAdminSettings = { admin }; this._merged = this.computeMergedSettings(); } } function findEnvFile(startDir: string): string | null { let currentDir = path.resolve(startDir); while (true) { // prefer gemini-specific .env under GEMINI_DIR const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env'); if (fs.existsSync(geminiEnvPath)) { return geminiEnvPath; } const envPath = path.join(currentDir, '.env'); if (fs.existsSync(envPath)) { return envPath; } const parentDir = path.dirname(currentDir); if (parentDir === currentDir || !parentDir) { // check .env under home as fallback, again preferring gemini-specific .env const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env'); if (fs.existsSync(homeGeminiEnvPath)) { return homeGeminiEnvPath; } const homeEnvPath = path.join(homedir(), '.env'); if (fs.existsSync(homeEnvPath)) { return homeEnvPath; } return null; } currentDir = parentDir; } } export function setUpCloudShellEnvironment( envFilePath: string | null, isTrusted: boolean, isSandboxed: boolean, ): void { // Special handling for GOOGLE_CLOUD_PROJECT in Cloud Shell: // Because GOOGLE_CLOUD_PROJECT in Cloud Shell tracks the project // set by the user using "gcloud config set project" we do not want to // use its value. So, unless the user overrides GOOGLE_CLOUD_PROJECT in // one of the .env files, we set the Cloud Shell-specific default here. let value = 'cloudshell-gca'; if (envFilePath && fs.existsSync(envFilePath)) { const envFileContent = fs.readFileSync(envFilePath); const parsedEnv = dotenv.parse(envFileContent); if (parsedEnv['GOOGLE_CLOUD_PROJECT']) { // .env file takes precedence in Cloud Shell value = parsedEnv['GOOGLE_CLOUD_PROJECT']; if (!isTrusted && isSandboxed) { value = sanitizeEnvVar(value); } } } process.env['GOOGLE_CLOUD_PROJECT'] = value; } export function loadEnvironment( settings: Settings, workspaceDir: string, isWorkspaceTrustedFn = isWorkspaceTrusted, ): void { const envFilePath = findEnvFile(workspaceDir); const trustResult = isWorkspaceTrustedFn(settings, workspaceDir); const isTrusted = trustResult.isTrusted ?? false; // Check settings OR check process.argv directly since this might be called // before arguments are fully parsed. This is a best-effort sniffing approach // that happens early in the CLI lifecycle. It is designed to detect the // sandbox flag before the full command-line parser is initialized to ensure // security constraints are applied when loading environment variables. const args = process.argv.slice(2); const doubleDashIndex = args.indexOf('--'); const relevantArgs = doubleDashIndex === -1 ? args : args.slice(0, doubleDashIndex); const isSandboxed = !!settings.tools?.sandbox || relevantArgs.includes('-s') || relevantArgs.includes('--sandbox'); if (trustResult.isTrusted !== true && !isSandboxed) { return; } // Cloud Shell environment variable handling if (process.env['CLOUD_SHELL'] === 'true') { setUpCloudShellEnvironment(envFilePath, isTrusted, isSandboxed); } if (envFilePath) { // Manually parse and load environment variables to handle exclusions correctly. // This avoids modifying environment variables that were already set from the shell. try { const envFileContent = fs.readFileSync(envFilePath, 'utf-8'); const parsedEnv = dotenv.parse(envFileContent); const excludedVars = settings?.advanced?.excludedEnvVars || DEFAULT_EXCLUDED_ENV_VARS; const isProjectEnvFile = !envFilePath.includes(GEMINI_DIR); for (const key in parsedEnv) { if (Object.hasOwn(parsedEnv, key)) { let value = parsedEnv[key]; // If the workspace is untrusted but we are sandboxed, only allow whitelisted variables. if (!isTrusted && isSandboxed) { if (!AUTH_ENV_VAR_WHITELIST.includes(key)) { continue; } // Sanitize the value for untrusted sources value = sanitizeEnvVar(value); } // If it's a project .env file, skip loading excluded variables. if (isProjectEnvFile && excludedVars.includes(key)) { continue; } // Load variable only if it's not already set in the environment. if (!Object.hasOwn(process.env, key)) { process.env[key] = value; } } } } catch (_e) { // Errors are ignored to match the behavior of `dotenv.config({ quiet: true })`. } } } /** * Loads settings from user and workspace directories. * Project settings override user settings. */ export function loadSettings( workspaceDir: string = process.cwd(), ): LoadedSettings { let systemSettings: Settings = {}; let systemDefaultSettings: Settings = {}; let userSettings: Settings = {}; let workspaceSettings: Settings = {}; const settingsErrors: SettingsError[] = []; const systemSettingsPath = getSystemSettingsPath(); const systemDefaultsPath = getSystemDefaultsPath(); // Resolve paths to their canonical representation to handle symlinks const resolvedWorkspaceDir = path.resolve(workspaceDir); const resolvedHomeDir = path.resolve(homedir()); let realWorkspaceDir = resolvedWorkspaceDir; try { // fs.realpathSync gets the "true" path, resolving any symlinks realWorkspaceDir = fs.realpathSync(resolvedWorkspaceDir); } catch (_e) { // This is okay. The path might not exist yet, and that's a valid state. } // We expect homedir to always exist and be resolvable. const realHomeDir = fs.realpathSync(resolvedHomeDir); const workspaceSettingsPath = new Storage( workspaceDir, ).getWorkspaceSettingsPath(); const load = (filePath: string): { settings: Settings; rawJson?: string } => { try { if (fs.existsSync(filePath)) { const content = fs.readFileSync(filePath, 'utf-8'); const rawSettings: unknown = JSON.parse(stripJsonComments(content)); if ( typeof rawSettings !== 'object' || rawSettings === null || Array.isArray(rawSettings) ) { settingsErrors.push({ message: 'Settings file is not a valid JSON object.', path: filePath, severity: 'error', }); return { settings: {} }; } // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const settingsObject = rawSettings as Record; // Validate settings structure with Zod const validationResult = validateSettings(settingsObject); if (!validationResult.success && validationResult.error) { const errorMessage = formatValidationError( validationResult.error, filePath, ); settingsErrors.push({ message: errorMessage, path: filePath, severity: 'warning', }); } return { settings: settingsObject as Settings, rawJson: content }; } } catch (error: unknown) { settingsErrors.push({ message: getErrorMessage(error), path: filePath, severity: 'error', }); } return { settings: {} }; }; const systemResult = load(systemSettingsPath); const systemDefaultsResult = load(systemDefaultsPath); const userResult = load(USER_SETTINGS_PATH); let workspaceResult: { settings: Settings; rawJson?: string } = { settings: {} as Settings, rawJson: undefined, }; if (realWorkspaceDir !== realHomeDir) { workspaceResult = load(workspaceSettingsPath); } const systemOriginalSettings = structuredClone(systemResult.settings); const systemDefaultsOriginalSettings = structuredClone( systemDefaultsResult.settings, ); const userOriginalSettings = structuredClone(userResult.settings); const workspaceOriginalSettings = structuredClone(workspaceResult.settings); // Environment variables for runtime use systemSettings = resolveEnvVarsInObject(systemResult.settings); systemDefaultSettings = resolveEnvVarsInObject(systemDefaultsResult.settings); userSettings = resolveEnvVarsInObject(userResult.settings); workspaceSettings = resolveEnvVarsInObject(workspaceResult.settings); // Support legacy theme names if (userSettings.ui?.theme === 'VS') { userSettings.ui.theme = DefaultLight.name; } else if (userSettings.ui?.theme === 'VS2015') { userSettings.ui.theme = DefaultDark.name; } if (workspaceSettings.ui?.theme === 'VS') { workspaceSettings.ui.theme = DefaultLight.name; } else if (workspaceSettings.ui?.theme === 'VS2015') { workspaceSettings.ui.theme = DefaultDark.name; } // For the initial trust check, we can only use user and system settings. const initialTrustCheckSettings = customDeepMerge( getMergeStrategyForPath, getDefaultsFromSchema(), systemDefaultSettings, userSettings, systemSettings, ); const isTrusted = isWorkspaceTrusted(initialTrustCheckSettings as Settings, workspaceDir) .isTrusted ?? false; // Create a temporary merged settings object to pass to loadEnvironment. const tempMergedSettings = mergeSettings( systemSettings, systemDefaultSettings, userSettings, workspaceSettings, isTrusted, ); // loadEnvironment depends on settings so we have to create a temp version of // the settings to avoid a cycle loadEnvironment(tempMergedSettings, workspaceDir); // Check for any fatal errors before proceeding const fatalErrors = settingsErrors.filter((e) => e.severity === 'error'); if (fatalErrors.length > 0) { const errorMessages = fatalErrors.map( (error) => `Error in ${error.path}: ${error.message}`, ); throw new FatalConfigError( `${errorMessages.join('\n')}\nPlease fix the configuration file(s) and try again.`, ); } const loadedSettings = new LoadedSettings( { path: systemSettingsPath, settings: systemSettings, originalSettings: systemOriginalSettings, rawJson: systemResult.rawJson, readOnly: true, }, { path: systemDefaultsPath, settings: systemDefaultSettings, originalSettings: systemDefaultsOriginalSettings, rawJson: systemDefaultsResult.rawJson, readOnly: true, }, { path: USER_SETTINGS_PATH, settings: userSettings, originalSettings: userOriginalSettings, rawJson: userResult.rawJson, readOnly: false, }, { path: workspaceSettingsPath, settings: workspaceSettings, originalSettings: workspaceOriginalSettings, rawJson: workspaceResult.rawJson, readOnly: false, }, isTrusted, settingsErrors, ); // Automatically migrate deprecated settings when loading. migrateDeprecatedSettings(loadedSettings); return loadedSettings; } /** * Migrates deprecated settings to their new counterparts. * * TODO: After a couple of weeks (around early Feb 2026), we should start removing * the deprecated settings from the settings files by default. * * @returns true if any changes were made and need to be saved. */ export function migrateDeprecatedSettings( loadedSettings: LoadedSettings, removeDeprecated = false, ): boolean { let anyModified = false; const systemWarnings: Map = new Map(); /** * Helper to migrate a boolean setting and track it if it's deprecated. */ const migrateBoolean = ( settings: Record, oldKey: string, newKey: string, prefix: string, foundDeprecated?: string[], ): boolean => { let modified = false; const oldValue = settings[oldKey]; const newValue = settings[newKey]; if (typeof oldValue === 'boolean') { if (foundDeprecated) { foundDeprecated.push(prefix ? `${prefix}.${oldKey}` : oldKey); } if (typeof newValue === 'boolean') { // Both exist, trust the new one if (removeDeprecated) { delete settings[oldKey]; modified = true; } } else { // Only old exists, migrate to new (inverted) settings[newKey] = !oldValue; if (removeDeprecated) { delete settings[oldKey]; } modified = true; } } return modified; }; const processScope = (scope: LoadableSettingScope) => { const settingsFile = loadedSettings.forScope(scope); const settings = settingsFile.settings; const foundDeprecated: string[] = []; // Migrate general settings const generalSettings = settings.general as | Record | undefined; if (generalSettings) { const newGeneral = { ...generalSettings }; let modified = false; modified = migrateBoolean( newGeneral, 'disableAutoUpdate', 'enableAutoUpdate', 'general', foundDeprecated, ) || modified; modified = migrateBoolean( newGeneral, 'disableUpdateNag', 'enableAutoUpdateNotification', 'general', foundDeprecated, ) || modified; if (modified) { loadedSettings.setValue(scope, 'general', newGeneral); if (!settingsFile.readOnly) { anyModified = true; } } } // Migrate ui settings const uiSettings = settings.ui as Record | undefined; if (uiSettings) { const newUi = { ...uiSettings }; // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const accessibilitySettings = newUi['accessibility'] as | Record | undefined; if (accessibilitySettings) { const newAccessibility = { ...accessibilitySettings }; if ( migrateBoolean( newAccessibility, 'disableLoadingPhrases', 'enableLoadingPhrases', 'ui.accessibility', foundDeprecated, ) ) { newUi['accessibility'] = newAccessibility; loadedSettings.setValue(scope, 'ui', newUi); if (!settingsFile.readOnly) { anyModified = true; } } } } // Migrate context settings const contextSettings = settings.context as | Record | undefined; if (contextSettings) { const newContext = { ...contextSettings }; // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const fileFilteringSettings = newContext['fileFiltering'] as | Record | undefined; if (fileFilteringSettings) { const newFileFiltering = { ...fileFilteringSettings }; if ( migrateBoolean( newFileFiltering, 'disableFuzzySearch', 'enableFuzzySearch', 'context.fileFiltering', foundDeprecated, ) ) { newContext['fileFiltering'] = newFileFiltering; loadedSettings.setValue(scope, 'context', newContext); if (!settingsFile.readOnly) { anyModified = true; } } } } // Migrate tools settings const toolsSettings = settings.tools as Record | undefined; if (toolsSettings) { if (toolsSettings['approvalMode'] !== undefined) { foundDeprecated.push('tools.approvalMode'); const generalSettings = (settings.general as Record | undefined) || {}; const newGeneral = { ...generalSettings }; // Only set defaultApprovalMode if it's not already set if (newGeneral['defaultApprovalMode'] === undefined) { newGeneral['defaultApprovalMode'] = toolsSettings['approvalMode']; loadedSettings.setValue(scope, 'general', newGeneral); if (!settingsFile.readOnly) { anyModified = true; } } if (removeDeprecated) { const newTools = { ...toolsSettings }; delete newTools['approvalMode']; loadedSettings.setValue(scope, 'tools', newTools); if (!settingsFile.readOnly) { anyModified = true; } } } } // Migrate experimental agent settings const experimentalModified = migrateExperimentalSettings( settings, loadedSettings, scope, removeDeprecated, foundDeprecated, ); if (experimentalModified) { if (!settingsFile.readOnly) { anyModified = true; } } if (settingsFile.readOnly && foundDeprecated.length > 0) { systemWarnings.set(scope, foundDeprecated); } }; processScope(SettingScope.User); processScope(SettingScope.Workspace); processScope(SettingScope.System); processScope(SettingScope.SystemDefaults); if (systemWarnings.size > 0) { for (const [scope, flags] of systemWarnings) { const scopeName = scope === SettingScope.SystemDefaults ? 'system default' : scope.toLowerCase(); coreEvents.emitFeedback( 'warning', `The ${scopeName} configuration contains deprecated settings: [${flags.join(', ')}]. These could not be migrated automatically as system settings are read-only. Please update the system configuration manually.`, ); } } return anyModified; } export function saveSettings(settingsFile: SettingsFile): void { try { // Ensure the directory exists const dirPath = path.dirname(settingsFile.path); if (!fs.existsSync(dirPath)) { fs.mkdirSync(dirPath, { recursive: true }); } const settingsToSave = settingsFile.originalSettings; // Use the format-preserving update function updateSettingsFilePreservingFormat( settingsFile.path, settingsToSave as Record, ); } catch (error) { coreEvents.emitFeedback( 'error', 'There was an error saving your latest settings changes.', error, ); } } export function saveModelChange( loadedSettings: LoadedSettings, model: string, ): void { try { loadedSettings.setValue(SettingScope.User, 'model.name', model); } catch (error) { coreEvents.emitFeedback( 'error', 'There was an error saving your preferred model.', error, ); } } function migrateExperimentalSettings( settings: Settings, loadedSettings: LoadedSettings, scope: LoadableSettingScope, removeDeprecated: boolean, foundDeprecated?: string[], ): boolean { const experimentalSettings = settings.experimental as | Record | undefined; if (experimentalSettings) { const agentsSettings = { ...(settings.agents as Record | undefined), }; const agentsOverrides = { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion ...((agentsSettings['overrides'] as Record) || {}), }; let modified = false; const migrateExperimental = ( oldKey: string, migrateFn: (oldValue: Record) => void, ) => { const old = experimentalSettings[oldKey]; if (old) { foundDeprecated?.push(`experimental.${oldKey}`); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion migrateFn(old as Record); modified = true; } }; // Migrate codebaseInvestigatorSettings -> agents.overrides.codebase_investigator migrateExperimental('codebaseInvestigatorSettings', (old) => { const override = { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion ...(agentsOverrides['codebase_investigator'] as | Record | undefined), }; if (old['enabled'] !== undefined) override['enabled'] = old['enabled']; const runConfig = { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion ...(override['runConfig'] as Record | undefined), }; if (old['maxNumTurns'] !== undefined) runConfig['maxTurns'] = old['maxNumTurns']; if (old['maxTimeMinutes'] !== undefined) runConfig['maxTimeMinutes'] = old['maxTimeMinutes']; if (Object.keys(runConfig).length > 0) override['runConfig'] = runConfig; if (old['model'] !== undefined || old['thinkingBudget'] !== undefined) { const modelConfig = { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion ...(override['modelConfig'] as Record | undefined), }; if (old['model'] !== undefined) modelConfig['model'] = old['model']; if (old['thinkingBudget'] !== undefined) { const generateContentConfig = { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion ...(modelConfig['generateContentConfig'] as | Record | undefined), }; const thinkingConfig = { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion ...(generateContentConfig['thinkingConfig'] as | Record | undefined), }; thinkingConfig['thinkingBudget'] = old['thinkingBudget']; generateContentConfig['thinkingConfig'] = thinkingConfig; modelConfig['generateContentConfig'] = generateContentConfig; } override['modelConfig'] = modelConfig; } agentsOverrides['codebase_investigator'] = override; }); // Migrate cliHelpAgentSettings -> agents.overrides.cli_help migrateExperimental('cliHelpAgentSettings', (old) => { const override = { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion ...(agentsOverrides['cli_help'] as Record | undefined), }; if (old['enabled'] !== undefined) override['enabled'] = old['enabled']; agentsOverrides['cli_help'] = override; }); if (modified) { agentsSettings['overrides'] = agentsOverrides; loadedSettings.setValue(scope, 'agents', agentsSettings); if (removeDeprecated) { const newExperimental = { ...experimentalSettings }; delete newExperimental['codebaseInvestigatorSettings']; delete newExperimental['cliHelpAgentSettings']; loadedSettings.setValue(scope, 'experimental', newExperimental); } return true; } } return false; }