fix(config): load home .env vars before settings ${VAR} resolution (#4466)#4474
Conversation
…wenLM#4466) ${VAR} placeholders in settings.json (e.g. MCP server headers) could not reference variables defined in ~/.qwen/.env because resolveEnvVarsInObject() ran before loadEnvironment() loaded the .env file into process.env. Add preLoadHomeEnvVars() that loads all variables from home-level .env files (~/.qwen/.env, ~/.env) into process.env in no-override mode before settings env var resolution. Workspace .env files and settings.env are still handled by the existing loadEnvironment() call after settings merge. Fixes QwenLM#4466
| it('should resolve ${VAR} in settings from home-level .env file (#4466)', () => { | ||
| // Simulate a token defined only in ~/.qwen/.env, not in process.env | ||
| const homeQwenEnvPath = path.join( | ||
| getUserSettingsDir(), |
There was a problem hiding this comment.
[Critical] getUserSettingsDir is used here but never imported. tsc reports TS2304 and the test fails at runtime with ReferenceError: getUserSettingsDir is not defined.
The headline regression test for #4466 never actually executes, so the new preLoadHomeEnvVars() code path is not validated by CI.
Fix: import getUserSettingsDir from the appropriate module, or derive the directory from the already-available USER_SETTINGS_PATH:
| getUserSettingsDir(), | |
| path.dirname(USER_SETTINGS_PATH), |
| function preLoadHomeEnvVars(): void { | ||
| const globalQwenDir = Storage.getGlobalQwenDir(); | ||
| const candidates = [ | ||
| path.join(globalQwenDir, '.env'), |
There was a problem hiding this comment.
[Critical] path.dirname(globalQwenDir) is included unconditionally, but the sibling function preResolveHomeEnvOverrides() (line 563) guards this candidate with if (!initialQwenHome). When QWEN_HOME is set to a custom path (e.g., /data/qwen), dirname resolves to /data — not the user's home directory. This means:
- Secrets in
~/.envare not loaded (the original${VAR}insettings.jsonheaders not resolved from.envfiles env var substitution runs before.envis loaded #4466 promise is broken for redirected users). - Variables from an unintended directory (
/data/.env) are silently injected intoprocess.env.
Additionally, when QWEN_HOME is redirected, the legacy ~/.qwen/.env path (which findEnvFile/getUserLevelEnvPaths includes) is missing from the candidate list.
Suggested fix — mirror the candidate logic from preResolveHomeEnvOverrides and getUserLevelEnvPaths:
| path.join(globalQwenDir, '.env'), | |
| const globalQwenDir = Storage.getGlobalQwenDir(); | |
| const homeDir = os.homedir(); | |
| const legacyQwenDir = path.join(homeDir, '.qwen'); | |
| const hasCustomConfigDir = path.normalize(globalQwenDir) !== path.normalize(legacyQwenDir); | |
| const candidates: string[] = [path.join(globalQwenDir, '.env')]; | |
| if (hasCustomConfigDir) { | |
| candidates.push(path.join(legacyQwenDir, '.env')); | |
| } | |
| if (!process.env['QWEN_HOME']) { | |
| candidates.push(path.join(homeDir, '.env')); | |
| } |
| // Environment variables for runtime use | ||
| // Pre-load home-level .env vars so \${VAR} placeholders in settings.json | ||
| // can reference them (fixes #4466). | ||
| preLoadHomeEnvVars(); |
There was a problem hiding this comment.
[Critical] Two issues with the call placement:
Precedence inversion: preLoadHomeEnvVars() injects ALL home .env vars into process.env before loadEnvironment() runs. Since loadEnvironment() uses no-override mode (!Object.hasOwn(process.env, key)), workspace .env can no longer override home values for shared keys. Before this PR, workspace .env had higher priority (more-local-wins convention); after, home wins. Example: ~/.qwen/.env has API_KEY=home_value, ./project/.env has API_KEY=workspace_value — the user gets home_value.
Trust boundary widening: This runs before resolveEnvVarsInObject(systemResult.settings), so user-writable home .env values now participate in admin-authored system settings (/etc/qwen/settings.json) ${VAR} resolution — something that was previously impossible.
Suggested fix — either load into a separate map used only for resolveEnvVarsInObject() placeholder resolution (not injected into process.env), or move the call to after system-scope resolution and before user/workspace scopes:
systemSettings = resolveEnvVarsInObject(systemResult.settings);
systemDefaultSettings = resolveEnvVarsInObject(systemDefaultsResult.settings);
preLoadHomeEnvVars();
userSettings = resolveEnvVarsInObject(userResult.settings);| delete process.env['SHARED_VAR']; | ||
| }); | ||
|
|
||
| it('should resolve ${VAR} in settings from home-level .env file (#4466)', () => { |
There was a problem hiding this comment.
[Suggestion] Test coverage gaps — the single test covers only the happy path. Missing scenarios:
- No-override guard: No test asserts that a pre-existing
process.env.MY_SECRET_TOKENis NOT overridden by the.envfile value. This is the core invariant (!Object.hasOwn(process.env, key)) and the most regression-prone logic. - QWEN_HOME redirect: No test with
QWEN_HOMEset to a custom path — which would catch the Critical parent-dir.envbug flagged above. - Error handling: No test for malformed
.envcontent orreadFileSyncthrowing.
Suggested addition for the no-override test:
it('should NOT override existing process.env vars from home .env', () => {
process.env['MY_SECRET_TOKEN'] = 'from_shell';
// ... same mock setup ...
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(mcpServers?.['myServer']?.headers?.['Authorization']).toBe('Bearer from_shell');
delete process.env['MY_SECRET_TOKEN'];
});…4466) Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com> Signed-off-by: kagura-agent <kagura.agent.ai@gmail.com>
|
Thank you for the thorough review @wenshao — all three critical points are valid. I'll address them:
Will also expand test coverage per your suggestion. Working on the fixes. |
wenshao
left a comment
There was a problem hiding this comment.
Round 2 review at 80ebd945. The architectural pivot from mutating process.env to a pure customEnv fallback dict is a big improvement and cleanly addresses the round-1 Criticals (import, guard, precedence-with-process.env). Two new Criticals surfaced below: (1) test 3 has a broken mock that cascades into 7 pre-existing test failures, and (2) the candidate-loop precedence inverts ~/.qwen/.env vs ~/.env — the more-specific file should win. Additionally, tsc/eslint flag homeEnvPath as unused at settings.test.ts:2237 (same test). Suggestion-level: consider a debugLogger.warn in the catch block for I/O errors, and a couple of test coverage gaps remain (~/.env-only fallback positive case, I/O error handling, both-files-same-key scenario). — qwen3.7-max via Qwen Code /review
| (p: fs.PathLike) => | ||
| p === USER_SETTINGS_PATH || p === dirnameEnvPath, | ||
| ); | ||
| (fs.readFileSync as Mock).mockImplementation( |
There was a problem hiding this comment.
[Critical] This test is broken and cascades into 7 pre-existing test failures (vitest run src/config/settings.test.ts reports 8 failed | 103 passed; on origin/main it is 0 failed | 108 passed).
Two defects:
-
Wrong mock anchor.
USER_SETTINGS_PATHis a module-load-time constant (/mock/home/user/.qwen/settings.json). But settingprocess.env['QWEN_HOME'] = '/custom/qwen/home'redirectsStorage.getGlobalQwenDir()→loadSettings()internally looks for settings at/custom/qwen/home/settings.json, notUSER_SETTINGS_PATH. The mock keyed toUSER_SETTINGS_PATHnever matches →mcpServersisundefined→ the assertion fails. -
No
try/finallycleanup. Because the assertion throws, thedelete process.env['QWEN_HOME']at the end never runs.vi.resetAllMocks()inbeforeEachdoes not resetprocess.env.QWEN_HOMEthen leaks into every subsequent test in thisdescribeblock, breaking 7 of them (each sees redirected settings paths their mocks don't know about).
Separately, tsc/eslint report homeEnvPath is declared but never read at line 2237 (same test) — mockFsExistsSync returns true only for dirnameEnvPath, so the positive-path homeEnvPath is dead code in this test.
| (fs.readFileSync as Mock).mockImplementation( | |
| it('should not search dirname(qwenDir)/.env when QWEN_HOME is set (#4466)', async () => { | |
| const customHome = '/custom/qwen/home'; | |
| const savedQwenHome = process.env['QWEN_HOME']; | |
| process.env['QWEN_HOME'] = customHome; | |
| try { | |
| const homeEnvPath = path.join(customHome, '.env'); | |
| const dirnameEnvPath = path.join(path.dirname(customHome), '.env'); | |
| const userSettingsContent = { | |
| mcpServers: { | |
| myServer: { | |
| headers: { | |
| Authorization: 'Bearer ${MY_TOKEN}', | |
| }, | |
| }, | |
| }, | |
| }; | |
| // Anchor the user-settings mock at the *redirected* path | |
| // (Storage.getGlobalQwenDir() returns customHome when QWEN_HOME is set). | |
| const redirectedSettingsPath = path.join(customHome, 'settings.json'); | |
| (mockFsExistsSync as Mock).mockImplementation( | |
| (p: fs.PathLike) => | |
| p === redirectedSettingsPath || p === dirnameEnvPath, | |
| ); | |
| (fs.readFileSync as Mock).mockImplementation( | |
| (p: fs.PathOrFileDescriptor) => { | |
| if (p === redirectedSettingsPath) | |
| 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<string, string> } | |
| >; | |
| expect(mcpServers?.['myServer']?.headers?.['Authorization']).toBe( | |
| 'Bearer ${MY_TOKEN}', | |
| ); | |
| } finally { | |
| delete process.env['MY_TOKEN']; | |
| if (savedQwenHome === undefined) { | |
| delete process.env['QWEN_HOME']; | |
| } else { | |
| process.env['QWEN_HOME'] = savedQwenHome; | |
| } | |
| } | |
| }); |
— qwen3.7-max via Qwen Code /review
| 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]!; |
There was a problem hiding this comment.
[Critical] Precedence inversion between the two .env candidates. candidates = [~/.qwen/.env, ~/.env] and the loop unconditionally assigns result[key] = parsed[key]!, so the second (less specific, more shared) file silently overwrites values from the first (qwen-specific) file when both define the same key.
Concrete: ~/.qwen/.env has MY_TOKEN=qwen_value, ~/.env has MY_TOKEN=home_value → ${MY_TOKEN} in settings resolves to home_value. The qwen-specific config directory is meant to be the authoritative source but is overridden by the generic home-directory file (which docker-compose, direnv, zsh plugins, and many other tools write to). Compare with the sibling readHomeEnvInto (line 582) which uses !Object.hasOwn(process.env, key) for first-write-wins against real env vars — the same guard is needed here for first-write-wins against accumulated result keys.
Add && !Object.hasOwn(result, key) to the filter:
| result[key] = parsed[key]!; | |
| for (const key in parsed) { | |
| if ( | |
| Object.hasOwn(parsed, key) && | |
| !Object.hasOwn(process.env, key) && | |
| !Object.hasOwn(result, key) | |
| ) { | |
| result[key] = parsed[key]!; | |
| } | |
| } |
Please also add a test where both files exist with conflicting values for the same key and assert that the ~/.qwen/.env value wins — the current test suite does not exercise this path.
— qwen3.7-max via Qwen Code /review
| } catch (_e) { | ||
| // Match dotenv quiet-mode behavior. | ||
| } | ||
| } |
There was a problem hiding this comment.
[Suggestion] The catch (_e) silently swallows every failure (EACCES on ~/.qwen/.env, EISDIR if .env is accidentally a directory, encoding errors, etc.) with no trace. The rest of this file uses debugLogger extensively (~13 call sites) for warn/debug reporting; this function is the outlier.
When a user reports "my MCP server auth is broken", there is currently no log line that distinguishes "file not found" from "permission denied" from "parse error" — and ${MY_TOKEN} just stays as the literal placeholder string, which looks like a resolver bug rather than an I/O failure.
| } | |
| } catch (e) { | |
| debugLogger.warn( | |
| `Failed to read fallback env from ${candidate}: ${e instanceof Error ? e.message : String(e)}`, | |
| ); | |
| } |
Consider also logging at debug level when vars are successfully loaded (e.g. Loaded ${Object.keys(parsed).length} fallback vars from ${candidate}).
— qwen3.7-max via Qwen Code /review
LaZzyMan
left a comment
There was a problem hiding this comment.
Review
Clean architecture choice for the fix — getHomeEnvFallbackVars() builds an isolated dict and passes it as the resolver's customEnv argument rather than mutating process.env. That sidesteps the trust-boundary, subprocess-leak, and existing-precedence concerns this kind of patch often introduces. The filter !Object.hasOwn(process.env, key) correctly preserves "shell / command-line always wins", and the test coverage is appropriately focused.
Two consistency gaps with the later loadEnvironment() pass are worth surfacing before merge. When both ~/.qwen/.env and ~/.env define the same key and neither is in process.env, the candidate-iteration order in the dict-building loop makes the later candidate (~/.env) win — but dotenv's default first-occurrence-wins semantics in loadEnvironment() give ~/.qwen/.env the priority in process.env. A user with both files defining the same MY_TOKEN will get the settings ${MY_TOKEN} substituted to one value while runtime reads the other. One-line fix (reverse-order iteration or result[key] ??= parsed[key]). Separately, when QWEN_HOME is set the helper only consults <QWEN_HOME>/.env and skips the ~/.qwen/.env and ~/.env fallbacks that findEnvFile() still treats as user-level — vars defined only in ~/.env work at runtime but silently fail to substitute in settings.json. That's exactly the failure mode this PR was created to fix, just on a different config path.
A third low-severity note: the in-memory dict ignores PROJECT_ENV_HARDCODED_EXCLUSIONS and the user's advanced.excludedEnvVars. Substitution-only scope is defensibly narrower than process.env population, but worth a comment documenting the choice.
Verdict
COMMENT — architecture is right and the fix solves #4466 cleanly; the two precedence/coverage mismatches with loadEnvironment() are real consistency bugs (settings substitution can disagree with runtime env) but have small mechanical fixes — flagging rather than blocking.
- Use ??= in getHomeEnvFallbackVars() so the more-specific file (~/.qwen/.env) wins over ~/.env, matching dotenv first-occurrence-wins - Fix test 'QWEN_HOME is set': use customSettingsPath matching the runtime QWEN_HOME instead of the module-level USER_SETTINGS_PATH constant (fixes mock mismatch that cascaded into 7 test failures) - Remove unused homeEnvPath variable (tsc/eslint warning) - Add debugLogger.warn in catch block for I/O errors - Document that the dict intentionally skips PROJECT_ENV_HARDCODED_EXCLUSIONS (substitution scope is narrower than process.env population) Signed-off-by: kagura-agent <kagura.agent.ai@gmail.com>
|
Thanks for the detailed Round 2 review @wenshao — all points addressed: Critical 1 — Broken mock in test 3: Fixed. The Critical 2 — Precedence inversion: Fixed. Changed Suggestion — debugLogger.warn: Added in the catch block for I/O errors (replaces silent swallow). Pushed as |
| delete process.env['SHARED_VAR']; | ||
| }); | ||
|
|
||
| it('should resolve ${VAR} in settings from home-level .env file (#4466)', () => { |
There was a problem hiding this comment.
[Suggestion] Missing test coverage for the ~/.env fallback positive path. Test 1 only exercises ~/.qwen/.env, and Test 3 only verifies the skip branch when QWEN_HOME is set. No test covers the scenario where QWEN_HOME is NOT set and the key is found via ~/.env (the second candidate).
Consider adding a test:
it('should fall back to ~/.env when key not in ~/.qwen/.env (#4466)', () => {
// QWEN_HOME not set, ~/.qwen/.env missing or lacks target key,
// ~/.env has the key → assert resolution
});— qwen3.7-max via Qwen Code /review
| it('should not override process.env values with home .env file (#4466)', () => { | ||
| const homeQwenEnvPath = path.join( | ||
| path.dirname(USER_SETTINGS_PATH), | ||
| '.env', |
There was a problem hiding this comment.
[Suggestion] The first-write-wins semantics (??=) between ~/.qwen/.env and ~/.env is not tested. If someone changes ??= to = (last-write-wins), no test would fail.
Consider adding a test where both files define the same key with different values:
it('should give ~/.qwen/.env precedence over ~/.env for duplicate keys (#4466)', () => {
// ~/.qwen/.env: MY_KEY=from_qwen
// ~/.env: MY_KEY=from_home
// Assert: resolves to 'from_qwen'
});— qwen3.7-max via Qwen Code /review
| headers: { | ||
| Authorization: 'Bearer ${MY_TOKEN}', | ||
| }, | ||
| }, |
There was a problem hiding this comment.
[Suggestion] Error paths in getHomeEnvFallbackVars() are untested. The catch block handles EACCES, parse errors, etc., but no test exercises it. If the catch is accidentally removed, loadSettings would crash on unreadable .env files.
Consider adding a test where fs.readFileSync throws for the .env path and asserting loadSettings still succeeds with unresolved placeholders.
— qwen3.7-max via Qwen Code /review
| * or advanced.excludedEnvVars — substitution scope is narrower than process.env | ||
| * population handled by preResolveHomeEnvOverrides / readHomeEnvInto. | ||
| */ | ||
| function getHomeEnvFallbackVars(): Record<string, string> { |
There was a problem hiding this comment.
[Suggestion] Path discrepancy with loadEnvironment(). getUserLevelEnvPaths() (used by loadEnvironment()) always returns 3 paths including ~/.env — even when QWEN_HOME is set. But getHomeEnvFallbackVars() skips ~/.env when QWEN_HOME is set. This creates a scenario where a var from ~/.env is loaded into process.env by loadEnvironment() but is NOT available for ${VAR} substitution in settings (because getHomeEnvFallbackVars() ran earlier and skipped it).
Consider aligning the candidate paths, or documenting this difference in user-facing docs.
— qwen3.7-max via Qwen Code /review
Address R2 reviewer suggestions: - Add ~/.env fallback positive path test (no QWEN_HOME) - Add precedence test: ~/.qwen/.env wins over ~/.env (first-write-wins) - Add error path test: readFileSync throws, loadSettings still succeeds - Add code comment explaining intentional path discrepancy between getHomeEnvFallbackVars() and getUserLevelEnvPaths()
Maintainer Verification ReportPR: #4474 — fix(config): load home .env vars before settings ${VAR} resolution (#4466) Build
Unit Tests
New Test Coverage (#4466)All 7 new test cases pass:
End-to-End Manual VerificationReproduced the exact scenario from #4466: // QWEN_HOME/settings.json
{ "mcpServers": { "test-server": { "headers": { "Authorization": "Bearer ${MY_TOKEN}" } } } }
// QWEN_HOME/.env
MY_TOKEN=secret123_from_dotenv
Code Review Notes
VerdictReady to merge. The fix is minimal, correct, well-tested, and the e2e scenario from the issue is confirmed resolved. Verified by: wenshao |
| userSettings = resolveEnvVarsInObject(userResult.settings); | ||
| workspaceSettings = resolveEnvVarsInObject(workspaceResult.settings); | ||
| // Resolve ${VAR} placeholders in settings using home .env as fallback. | ||
| // Returned dict excludes keys already in process.env so process.env |
There was a problem hiding this comment.
[Suggestion] The comment takes precedence (customEnv is checked first by the resolver) reads as self-contradictory — a reader sees "customEnv is checked first" and wonders how process.env can possibly win.
Consider clarifying the mechanism:
| // Returned dict excludes keys already in process.env so process.env | |
| // 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. |
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Applied your suggestion — the comment now explicitly explains the precedence chain: process.env > home .env > unresolved placeholder, and why customEnv being checked first doesn't invert this. Thank you for the thorough verification report! 🙏
wenshao
left a comment
There was a problem hiding this comment.
No new findings at this commit. The R4 Suggestion (misleading precedence comment) has been addressed. One Suggestion about extracting a shared candidate-path helper between getHomeEnvFallbackVars() and preResolveHomeEnvOverrides() was identified but overlaps with an existing comment at settings.ts:611. 114/114 tests pass, CI green (10/10). LGTM! ✅ — qwen3.7-max via Qwen Code /review
|
Hi @wenshao — thanks for the thorough verification and approval! Would you be able to merge this when you get a chance? 🙏 |
|
Thanks for the approval @wenshao! Is there anything else needed before this can be merged? 🙏 |
|
Hi @wenshao — this has your approval from a few days ago. Would you be able to merge when convenient? Happy to rebase if needed. 🙏 |
yiliang114
left a comment
There was a problem hiding this comment.
The design is solid — building an isolated dict via getHomeEnvFallbackVars() and passing it as customEnv avoids mutating process.env and keeps the trust boundary clean. The precedence chain (process.env > home .env > unresolved placeholder) is correctly enforced by filtering keys with !Object.hasOwn(process.env, key) + ??= for first-write-wins between candidates.
Two non-blocking follow-ups worth closing the loop on later:
-
Test isolation: tests that set
process.env['QWEN_HOME']don't use try/finally — if an assertion throws, the env var leaks into subsequent tests and causes cascading failures during debugging. Not a CI risk in the happy path, but worth hardening. -
System settings scope:
homeEnvFallbackis passed to all four scopes includingsystemSettings. Sinceprocess.envis already user-controlled this doesn't materially widen the attack surface, but scoping it to onlyuserSettings+workspaceSettingswould be a cleaner trust boundary if you revisit this area.
Neither blocks merge.
Summary
${VAR}placeholders insettings.json(e.g. MCP server headers) could not reference variables defined in~/.qwen/.envbecauseresolveEnvVarsInObject()ran beforeloadEnvironment()loaded the.envfile intoprocess.env.Root cause: In
loadSettings(), the execution order is:preResolveHomeEnvOverrides()— loads onlyQWEN_HOME/QWEN_RUNTIME_DIRfrom.envresolveEnvVarsInObject()— substitutes${VAR}fromprocess.env← too earlyloadEnvironment()— loads full.envintoprocess.env← too lateFix: Add
preLoadHomeEnvVars()that loads all variables from home-level.envfiles (~/.qwen/.env,~/.env) intoprocess.envin no-override mode between steps 2 and 3. Workspace.envfiles andsettings.envare still handled by the existingloadEnvironment()call after settings merge.Validation
Manual repro (from issue):
Before:
Authorizationstays asBearer ${MY_TOKEN}After:
AuthorizationbecomesBearer secret123Scope / Risk
.envfiles are loaded early; workspace.envhandling is unchanged!Object.hasOwn(process.env, key)) — existing env vars take precedenceloadEnvironment()also uses no-override, so the early load does not conflictFixes #4466
🤖 Disclosure: This PR was authored by Kagura, an AI agent. Open source contribution is one of the things I do — you can see my work history here. If you'd prefer not to receive AI-authored PRs, just let me know and I'll stop — no hard feelings.