/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as fs from 'node:fs'; import * as path from 'node:path'; import { homedir, platform } from 'node:os'; import * as dotenv from 'dotenv'; import process from 'node:process'; import { FatalConfigError, QWEN_DIR, getErrorMessage, Storage, } from '@qwen-code/qwen-code-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 MemoryImportFormat, type MergeStrategy, type SettingsSchema, type SettingDefinition, getSettingsSchema, } from './settingsSchema.js'; import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; import { customDeepMerge, type MergeableObject } from '../utils/deepMerge.js'; import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js'; import { disableExtension } from './extension.js'; function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined { let current: SettingDefinition | undefined = undefined; let currentSchema: SettingsSchema | undefined = getSettingsSchema(); for (const key of path) { if (!currentSchema || !currentSchema[key]) { return undefined; } current = currentSchema[key]; currentSchema = current.properties; } return current?.mergeStrategy; } export type { Settings, MemoryImportFormat }; export const SETTINGS_DIRECTORY_NAME = '.qwen'; 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 MIGRATE_V2_OVERWRITE = true; // Settings version to track migration state export const SETTINGS_VERSION = 2; export const SETTINGS_VERSION_KEY = '$version'; const MIGRATION_MAP: Record = { accessibility: 'ui.accessibility', allowedTools: 'tools.allowed', allowMCPServers: 'mcp.allowed', autoAccept: 'tools.autoAccept', autoConfigureMaxOldSpaceSize: 'advanced.autoConfigureMemory', bugCommand: 'advanced.bugCommand', chatCompression: 'model.chatCompression', checkpointing: 'general.checkpointing', coreTools: 'tools.core', contextFileName: 'context.fileName', customThemes: 'ui.customThemes', customWittyPhrases: 'ui.customWittyPhrases', debugKeystrokeLogging: 'general.debugKeystrokeLogging', disableAutoUpdate: 'general.disableAutoUpdate', disableUpdateNag: 'general.disableUpdateNag', dnsResolutionOrder: 'advanced.dnsResolutionOrder', enablePromptCompletion: 'general.enablePromptCompletion', enforcedAuthType: 'security.auth.enforcedType', excludeTools: 'tools.exclude', excludeMCPServers: 'mcp.excluded', excludedProjectEnvVars: 'advanced.excludedEnvVars', extensionManagement: 'experimental.extensionManagement', extensions: 'extensions', fileFiltering: 'context.fileFiltering', folderTrustFeature: 'security.folderTrust.featureEnabled', folderTrust: 'security.folderTrust.enabled', hasSeenIdeIntegrationNudge: 'ide.hasSeenNudge', hideWindowTitle: 'ui.hideWindowTitle', showStatusInTitle: 'ui.showStatusInTitle', hideTips: 'ui.hideTips', hideBanner: 'ui.hideBanner', hideFooter: 'ui.hideFooter', hideCWD: 'ui.footer.hideCWD', hideSandboxStatus: 'ui.footer.hideSandboxStatus', hideModelInfo: 'ui.footer.hideModelInfo', hideContextSummary: 'ui.hideContextSummary', showMemoryUsage: 'ui.showMemoryUsage', showLineNumbers: 'ui.showLineNumbers', showCitations: 'ui.showCitations', ideMode: 'ide.enabled', includeDirectories: 'context.includeDirectories', loadMemoryFromIncludeDirectories: 'context.loadFromIncludeDirectories', maxSessionTurns: 'model.maxSessionTurns', mcpServers: 'mcpServers', mcpServerCommand: 'mcp.serverCommand', memoryImportFormat: 'context.importFormat', memoryDiscoveryMaxDirs: 'context.discoveryMaxDirs', model: 'model.name', preferredEditor: 'general.preferredEditor', sandbox: 'tools.sandbox', selectedAuthType: 'security.auth.selectedType', shouldUseNodePtyShell: 'tools.shell.enableInteractiveShell', shellPager: 'tools.shell.pager', shellShowColor: 'tools.shell.showColor', skipNextSpeakerCheck: 'model.skipNextSpeakerCheck', summarizeToolOutput: 'model.summarizeToolOutput', telemetry: 'telemetry', theme: 'ui.theme', toolDiscoveryCommand: 'tools.discoveryCommand', toolCallCommand: 'tools.callCommand', usageStatisticsEnabled: 'privacy.usageStatisticsEnabled', useExternalAuth: 'security.auth.useExternal', useRipgrep: 'tools.useRipgrep', vimMode: 'general.vimMode', enableWelcomeBack: 'ui.enableWelcomeBack', approvalMode: 'tools.approvalMode', sessionTokenLimit: 'model.sessionTokenLimit', contentGenerator: 'model.generationConfig', skipLoopDetection: 'model.skipLoopDetection', skipStartupContext: 'model.skipStartupContext', enableOpenAILogging: 'model.enableOpenAILogging', tavilyApiKey: 'advanced.tavilyApiKey', vlmSwitchMode: 'experimental.vlmSwitchMode', visionModelPreview: 'experimental.visionModelPreview', }; export function getSystemSettingsPath(): string { if (process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH']) { return process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH']; } if (platform() === 'darwin') { return '/Library/Application Support/QwenCode/settings.json'; } else if (platform() === 'win32') { return 'C:\\ProgramData\\qwen-code\\settings.json'; } else { return '/etc/qwen-code/settings.json'; } } export function getSystemDefaultsPath(): string { if (process.env['QWEN_CODE_SYSTEM_DEFAULTS_PATH']) { return process.env['QWEN_CODE_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', } export interface CheckpointingSettings { enabled?: boolean; } export interface SummarizeToolOutputSettings { tokenBudget?: number; } export interface AccessibilitySettings { disableLoadingPhrases?: boolean; screenReader?: boolean; } export interface SettingsError { message: string; path: string; } export interface SettingsFile { settings: Settings; originalSettings: Settings; path: string; rawJson?: string; } 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) { current = next as Record; } else { // This path is invalid, so we stop. return; } } current[lastKey] = value; } export function needsMigration(settings: Record): boolean { // Check version field first - if present and matches current version, no migration needed if (SETTINGS_VERSION_KEY in settings) { const version = settings[SETTINGS_VERSION_KEY]; if (typeof version === 'number' && version >= SETTINGS_VERSION) { return false; } } // Fallback to legacy detection: A file needs migration if it contains any // top-level key that is moved to a nested location in V2. const hasV1Keys = Object.entries(MIGRATION_MAP).some(([v1Key, v2Path]) => { if (v1Key === v2Path || !(v1Key in settings)) { return false; } // If a key exists that is both a V1 key and a V2 container (like 'model'), // we need to check the type. If it's an object, it's a V2 container and not // a V1 key that needs migration. if ( KNOWN_V2_CONTAINERS.has(v1Key) && typeof settings[v1Key] === 'object' && settings[v1Key] !== null ) { return false; } return true; }); return hasV1Keys; } function migrateSettingsToV2( flatSettings: Record, ): Record | null { if (!needsMigration(flatSettings)) { return null; } const v2Settings: Record = {}; const flatKeys = new Set(Object.keys(flatSettings)); for (const [oldKey, newPath] of Object.entries(MIGRATION_MAP)) { if (flatKeys.has(oldKey)) { // Safety check: If this key is a V2 container (like 'model') and it's // already an object, it's likely already in V2 format. Skip migration // to prevent double-nesting (e.g., model.name.name). if ( KNOWN_V2_CONTAINERS.has(oldKey) && typeof flatSettings[oldKey] === 'object' && flatSettings[oldKey] !== null && !Array.isArray(flatSettings[oldKey]) ) { // This is already a V2 container, carry it over as-is v2Settings[oldKey] = flatSettings[oldKey]; flatKeys.delete(oldKey); continue; } setNestedProperty(v2Settings, newPath, flatSettings[oldKey]); flatKeys.delete(oldKey); } } // Preserve mcpServers at the top level if (flatSettings['mcpServers']) { v2Settings['mcpServers'] = flatSettings['mcpServers']; flatKeys.delete('mcpServers'); } // Carry over any unrecognized keys for (const remainingKey of flatKeys) { const existingValue = v2Settings[remainingKey]; const newValue = flatSettings[remainingKey]; if ( typeof existingValue === 'object' && existingValue !== null && !Array.isArray(existingValue) && typeof newValue === 'object' && newValue !== null && !Array.isArray(newValue) ) { const pathAwareGetStrategy = (path: string[]) => getMergeStrategyForPath([remainingKey, ...path]); v2Settings[remainingKey] = customDeepMerge( pathAwareGetStrategy, {}, newValue as MergeableObject, existingValue as MergeableObject, ); } else { v2Settings[remainingKey] = newValue; } } // Set version field to indicate this is a V2 settings file v2Settings[SETTINGS_VERSION_KEY] = SETTINGS_VERSION; return v2Settings; } function getNestedProperty( obj: Record, path: string, ): unknown { const keys = path.split('.'); let current: unknown = obj; for (const key of keys) { if (typeof current !== 'object' || current === null || !(key in current)) { return undefined; } current = (current as Record)[key]; } return current; } const REVERSE_MIGRATION_MAP: Record = Object.fromEntries( Object.entries(MIGRATION_MAP).map(([key, value]) => [value, key]), ); // Dynamically determine the top-level keys from the V2 settings structure. const KNOWN_V2_CONTAINERS = new Set( Object.values(MIGRATION_MAP).map((path) => path.split('.')[0]), ); export function migrateSettingsToV1( v2Settings: Record, ): Record { const v1Settings: Record = {}; const v2Keys = new Set(Object.keys(v2Settings)); for (const [newPath, oldKey] of Object.entries(REVERSE_MIGRATION_MAP)) { const value = getNestedProperty(v2Settings, newPath); if (value !== undefined) { v1Settings[oldKey] = value; v2Keys.delete(newPath.split('.')[0]); } } // Preserve mcpServers at the top level if (v2Settings['mcpServers']) { v1Settings['mcpServers'] = v2Settings['mcpServers']; v2Keys.delete('mcpServers'); } // Carry over any unrecognized keys for (const remainingKey of v2Keys) { // Skip the version field - it's only for V2 format if (remainingKey === SETTINGS_VERSION_KEY) { continue; } const value = v2Settings[remainingKey]; if (value === undefined) { continue; } // Don't carry over empty objects that were just containers for migrated settings. if ( KNOWN_V2_CONTAINERS.has(remainingKey) && typeof value === 'object' && value !== null && !Array.isArray(value) && Object.keys(value).length === 0 ) { continue; } v1Settings[remainingKey] = value; } return v1Settings; } function mergeSettings( system: Settings, systemDefaults: Settings, user: Settings, workspace: Settings, isTrusted: boolean, ): Settings { const safeWorkspace = isTrusted ? workspace : ({} as Settings); // Settings are merged with the following precedence (last one wins for // single values): // 1. System Defaults // 2. User Settings // 3. Workspace Settings // 4. System Settings (as overrides) return customDeepMerge( getMergeStrategyForPath, {}, // Start with an empty object systemDefaults, user, safeWorkspace, system, ) as Settings; } export class LoadedSettings { constructor( system: SettingsFile, systemDefaults: SettingsFile, user: SettingsFile, workspace: SettingsFile, isTrusted: boolean, migratedInMemorScopes: Set, ) { this.system = system; this.systemDefaults = systemDefaults; this.user = user; this.workspace = workspace; this.isTrusted = isTrusted; this.migratedInMemorScopes = migratedInMemorScopes; this._merged = this.computeMergedSettings(); } readonly system: SettingsFile; readonly systemDefaults: SettingsFile; readonly user: SettingsFile; readonly workspace: SettingsFile; readonly isTrusted: boolean; readonly migratedInMemorScopes: Set; private _merged: Settings; get merged(): Settings { return this._merged; } private computeMergedSettings(): Settings { return mergeSettings( this.system.settings, this.systemDefaults.settings, this.user.settings, this.workspace.settings, this.isTrusted, ); } forScope(scope: SettingScope): 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}`); } } setValue(scope: SettingScope, key: string, value: unknown): void { const settingsFile = this.forScope(scope); setNestedProperty(settingsFile.settings, key, value); setNestedProperty(settingsFile.originalSettings, key, value); this._merged = this.computeMergedSettings(); saveSettings(settingsFile); } } function findEnvFile(startDir: string): string | null { let currentDir = path.resolve(startDir); while (true) { // prefer gemini-specific .env under QWEN_DIR const geminiEnvPath = path.join(currentDir, QWEN_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(), QWEN_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): 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. 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 process.env['GOOGLE_CLOUD_PROJECT'] = parsedEnv['GOOGLE_CLOUD_PROJECT']; } else { // If not in .env, set to default and override global process.env['GOOGLE_CLOUD_PROJECT'] = 'cloudshell-gca'; } } else { // If no .env file, set to default and override global process.env['GOOGLE_CLOUD_PROJECT'] = 'cloudshell-gca'; } } export function loadEnvironment(settings: Settings): void { const envFilePath = findEnvFile(process.cwd()); if (!isWorkspaceTrusted(settings).isTrusted) { return; } // Cloud Shell environment variable handling if (process.env['CLOUD_SHELL'] === 'true') { setUpCloudShellEnvironment(envFilePath); } 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(QWEN_DIR); for (const key in parsedEnv) { if (Object.hasOwn(parsedEnv, key)) { // 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] = parsedEnv[key]; } } } } 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(); const migratedInMemorScopes = new Set(); // 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 loadAndMigrate = ( filePath: string, scope: SettingScope, ): { 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, }); return { settings: {} }; } let settingsObject = rawSettings as Record; if (needsMigration(settingsObject)) { const migratedSettings = migrateSettingsToV2(settingsObject); if (migratedSettings) { if (MIGRATE_V2_OVERWRITE) { try { fs.renameSync(filePath, `${filePath}.orig`); fs.writeFileSync( filePath, JSON.stringify(migratedSettings, null, 2), 'utf-8', ); } catch (e) { console.error( `Error migrating settings file on disk: ${getErrorMessage( e, )}`, ); } } else { migratedInMemorScopes.add(scope); } settingsObject = migratedSettings; } } else if (!(SETTINGS_VERSION_KEY in settingsObject)) { // No migration needed, but version field is missing - add it for future optimizations settingsObject[SETTINGS_VERSION_KEY] = SETTINGS_VERSION; if (MIGRATE_V2_OVERWRITE) { try { fs.writeFileSync( filePath, JSON.stringify(settingsObject, null, 2), 'utf-8', ); } catch (e) { console.error( `Error adding version to settings file: ${getErrorMessage(e)}`, ); } } } return { settings: settingsObject as Settings, rawJson: content }; } } catch (error: unknown) { settingsErrors.push({ message: getErrorMessage(error), path: filePath, }); } return { settings: {} }; }; const systemResult = loadAndMigrate(systemSettingsPath, SettingScope.System); const systemDefaultsResult = loadAndMigrate( systemDefaultsPath, SettingScope.SystemDefaults, ); const userResult = loadAndMigrate(USER_SETTINGS_PATH, SettingScope.User); let workspaceResult: { settings: Settings; rawJson?: string } = { settings: {} as Settings, rawJson: undefined, }; if (realWorkspaceDir !== realHomeDir) { workspaceResult = loadAndMigrate( workspaceSettingsPath, SettingScope.Workspace, ); } 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, {}, systemSettings, userSettings, ); const isTrusted = isWorkspaceTrusted(initialTrustCheckSettings as Settings).isTrusted ?? true; // Create a temporary merged settings object to pass to loadEnvironment. const tempMergedSettings = mergeSettings( systemSettings, systemDefaultSettings, userSettings, workspaceSettings, isTrusted, ); // loadEnviroment depends on settings so we have to create a temp version of // the settings to avoid a cycle loadEnvironment(tempMergedSettings); // Create LoadedSettings first if (settingsErrors.length > 0) { const errorMessages = settingsErrors.map( (error) => `Error in ${error.path}: ${error.message}`, ); throw new FatalConfigError( `${errorMessages.join('\n')}\nPlease fix the configuration file(s) and try again.`, ); } return new LoadedSettings( { path: systemSettingsPath, settings: systemSettings, originalSettings: systemOriginalSettings, rawJson: systemResult.rawJson, }, { path: systemDefaultsPath, settings: systemDefaultSettings, originalSettings: systemDefaultsOriginalSettings, rawJson: systemDefaultsResult.rawJson, }, { path: USER_SETTINGS_PATH, settings: userSettings, originalSettings: userOriginalSettings, rawJson: userResult.rawJson, }, { path: workspaceSettingsPath, settings: workspaceSettings, originalSettings: workspaceOriginalSettings, rawJson: workspaceResult.rawJson, }, isTrusted, migratedInMemorScopes, ); } export function migrateDeprecatedSettings( loadedSettings: LoadedSettings, workspaceDir: string = process.cwd(), ): void { const processScope = (scope: SettingScope) => { const settings = loadedSettings.forScope(scope).settings; if (settings.extensions?.disabled) { console.log( `Migrating deprecated extensions.disabled settings from ${scope} settings...`, ); for (const extension of settings.extensions.disabled ?? []) { disableExtension(extension, scope, workspaceDir); } const newExtensionsValue = { ...settings.extensions }; newExtensionsValue.disabled = undefined; loadedSettings.setValue(scope, 'extensions', newExtensionsValue); } }; processScope(SettingScope.User); processScope(SettingScope.Workspace); } 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 }); } let settingsToSave = settingsFile.originalSettings; if (!MIGRATE_V2_OVERWRITE) { settingsToSave = migrateSettingsToV1( settingsToSave as Record, ) as Settings; } // Use the format-preserving update function updateSettingsFilePreservingFormat( settingsFile.path, settingsToSave as Record, ); } catch (error) { console.error('Error saving user settings file:', error); throw error; } }