diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index 2354df55a63..08ea1c6e68d 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -2147,6 +2147,256 @@ describe('Settings Loading and Merging', () => { delete process.env['SHARED_VAR']; }); + it('should resolve ${VAR} in settings from home-level .env file (#4466)', () => { + const homeQwenEnvPath = path.join( + path.dirname(USER_SETTINGS_PATH), + '.env', + ); + const userSettingsContent = { + mcpServers: { + myServer: { + headers: { + Authorization: 'Bearer ${MY_SECRET_TOKEN}', + }, + }, + }, + }; + + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => p === USER_SETTINGS_PATH || p === homeQwenEnvPath, + ); + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + if (p === homeQwenEnvPath) + return 'MY_SECRET_TOKEN=secret_from_dotenv'; + return '{}'; + }, + ); + + delete process.env['MY_SECRET_TOKEN']; + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + const mcpServers = settings.merged.mcpServers as Record< + string, + { headers?: Record } + >; + expect(mcpServers?.['myServer']?.headers?.['Authorization']).toBe( + 'Bearer secret_from_dotenv', + ); + + delete process.env['MY_SECRET_TOKEN']; + }); + + it('should not override process.env values with home .env file (#4466)', () => { + const homeQwenEnvPath = path.join( + path.dirname(USER_SETTINGS_PATH), + '.env', + ); + const userSettingsContent = { + mcpServers: { + myServer: { + headers: { + Authorization: 'Bearer ${MY_SECRET_TOKEN}', + }, + }, + }, + }; + + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => p === USER_SETTINGS_PATH || p === homeQwenEnvPath, + ); + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + if (p === homeQwenEnvPath) return 'MY_SECRET_TOKEN=from_dotenv'; + return '{}'; + }, + ); + + process.env['MY_SECRET_TOKEN'] = 'from_process_env'; + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + const mcpServers = settings.merged.mcpServers as Record< + string, + { headers?: Record } + >; + expect(mcpServers?.['myServer']?.headers?.['Authorization']).toBe( + 'Bearer from_process_env', + ); + + delete process.env['MY_SECRET_TOKEN']; + }); + + it('should not search dirname(qwenDir)/.env when QWEN_HOME is set (#4466)', () => { + const customHome = '/custom/qwen/home'; + process.env['QWEN_HOME'] = customHome; + const customSettingsPath = path.join(customHome, 'settings.json'); + const dirnameEnvPath = path.join(path.dirname(customHome), '.env'); + const userSettingsContent = { + mcpServers: { + myServer: { + headers: { + Authorization: 'Bearer ${MY_TOKEN}', + }, + }, + }, + }; + + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => p === customSettingsPath || p === dirnameEnvPath, + ); + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === customSettingsPath) + return JSON.stringify(userSettingsContent); + if (p === dirnameEnvPath) return 'MY_TOKEN=should_not_be_found'; + return '{}'; + }, + ); + + delete process.env['MY_TOKEN']; + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + const mcpServers = settings.merged.mcpServers as Record< + string, + { headers?: Record } + >; + expect(mcpServers?.['myServer']?.headers?.['Authorization']).toBe( + 'Bearer ${MY_TOKEN}', + ); + + delete process.env['MY_TOKEN']; + delete process.env['QWEN_HOME']; + }); + + it('should resolve ${VAR} from ~/.env when QWEN_HOME is not set (#4466)', () => { + const homeEnvPath = path.join( + path.dirname(path.dirname(USER_SETTINGS_PATH)), + '.env', + ); + const userSettingsContent = { + mcpServers: { + myServer: { + headers: { + Authorization: 'Bearer ${HOME_ENV_TOKEN}', + }, + }, + }, + }; + + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => p === USER_SETTINGS_PATH || p === homeEnvPath, + ); + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + if (p === homeEnvPath) return 'HOME_ENV_TOKEN=from_home_env'; + return '{}'; + }, + ); + + delete process.env['HOME_ENV_TOKEN']; + delete process.env['QWEN_HOME']; + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + const mcpServers = settings.merged.mcpServers as Record< + string, + { headers?: Record } + >; + expect(mcpServers?.['myServer']?.headers?.['Authorization']).toBe( + 'Bearer from_home_env', + ); + + delete process.env['HOME_ENV_TOKEN']; + }); + + it('should prefer ~/.qwen/.env over ~/.env for the same key (first-write-wins) (#4466)', () => { + const qwenEnvPath = path.join(path.dirname(USER_SETTINGS_PATH), '.env'); + const homeEnvPath = path.join( + path.dirname(path.dirname(USER_SETTINGS_PATH)), + '.env', + ); + const userSettingsContent = { + mcpServers: { + myServer: { + headers: { + Authorization: 'Bearer ${PRECEDENCE_TOKEN}', + }, + }, + }, + }; + + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => + p === USER_SETTINGS_PATH || p === qwenEnvPath || p === homeEnvPath, + ); + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + if (p === qwenEnvPath) return 'PRECEDENCE_TOKEN=from_qwen_dir'; + if (p === homeEnvPath) return 'PRECEDENCE_TOKEN=from_home_dir'; + return '{}'; + }, + ); + + delete process.env['PRECEDENCE_TOKEN']; + delete process.env['QWEN_HOME']; + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + const mcpServers = settings.merged.mcpServers as Record< + string, + { headers?: Record } + >; + expect(mcpServers?.['myServer']?.headers?.['Authorization']).toBe( + 'Bearer from_qwen_dir', + ); + + delete process.env['PRECEDENCE_TOKEN']; + }); + + it('should succeed with unresolved placeholder when .env read throws (#4466)', () => { + const qwenEnvPath = path.join(path.dirname(USER_SETTINGS_PATH), '.env'); + const userSettingsContent = { + mcpServers: { + myServer: { + headers: { + Authorization: 'Bearer ${ERROR_TOKEN}', + }, + }, + }, + }; + + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => p === USER_SETTINGS_PATH || p === qwenEnvPath, + ); + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + if (p === qwenEnvPath) throw new Error('EACCES: permission denied'); + return '{}'; + }, + ); + + delete process.env['ERROR_TOKEN']; + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + const mcpServers = settings.merged.mcpServers as Record< + string, + { headers?: Record } + >; + expect(mcpServers?.['myServer']?.headers?.['Authorization']).toBe( + 'Bearer ${ERROR_TOKEN}', + ); + + delete process.env['ERROR_TOKEN']; + }); + it('should correctly merge dnsResolutionOrder with workspace taking precedence', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const userSettingsContent = { diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index a24c37d23b6..9a0d0e01ebb 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -596,6 +596,50 @@ export function resetHomeEnvBootstrapForTesting(): void { homeEnvBootstrapped = false; } +/** + * Collects environment variables from user-level `.env` files and returns + * them as a plain dictionary **without** mutating `process.env`. + * + * Candidates are iterated most-specific-first (`~/.qwen/.env` before + * `~/.env`). `??=` ensures the first file to define a key wins, matching + * dotenv's first-occurrence-wins semantics used elsewhere. + * + * Note: this dict intentionally does NOT filter PROJECT_ENV_HARDCODED_EXCLUSIONS + * or advanced.excludedEnvVars — substitution scope is narrower than process.env + * population handled by preResolveHomeEnvOverrides / readHomeEnvInto. + */ +function getHomeEnvFallbackVars(): Record { + const globalQwenDir = Storage.getGlobalQwenDir(); + const candidates = [path.join(globalQwenDir, '.env')]; + // When QWEN_HOME is set, skip ~/.env to avoid surprise cross-contamination + // from a shared home .env. getUserLevelEnvPaths() always includes ~/.env + // because loadEnvironment() populates process.env independently — the two + // scopes are intentionally different. + if (!process.env['QWEN_HOME']) { + candidates.push(path.join(path.dirname(globalQwenDir), '.env')); + } + + const result: Record = {}; + for (const candidate of candidates) { + if (!fs.existsSync(candidate)) { + continue; + } + try { + const parsed = dotenv.parse(fs.readFileSync(candidate, 'utf-8')); + for (const key in parsed) { + if (Object.hasOwn(parsed, key) && !Object.hasOwn(process.env, key)) { + result[key] ??= parsed[key]!; + } + } + } catch (e) { + debugLogger.warn( + `Failed to read home .env candidate ${candidate}: ${getErrorMessage(e)}`, + ); + } + } + return result; +} + /** * Surfaces a one-shot warning when QWEN_HOME has been redirected but the * user hasn't migrated their existing global state. Auto-copying OAuth @@ -1032,11 +1076,25 @@ export function loadSettings( 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); + // Resolve ${VAR} placeholders in settings using home .env as fallback. + // getHomeEnvFallbackVars() excludes keys already in process.env, so + // effective precedence is: process.env > home .env > unresolved placeholder. + // The resolver checks customEnv before process.env, but since customEnv + // never contains a process.env key, process.env always wins. + const homeEnvFallback = getHomeEnvFallbackVars(); + systemSettings = resolveEnvVarsInObject( + systemResult.settings, + homeEnvFallback, + ); + systemDefaultSettings = resolveEnvVarsInObject( + systemDefaultsResult.settings, + homeEnvFallback, + ); + userSettings = resolveEnvVarsInObject(userResult.settings, homeEnvFallback); + workspaceSettings = resolveEnvVarsInObject( + workspaceResult.settings, + homeEnvFallback, + ); // Support legacy theme names if (userSettings.ui?.theme === 'VS') {