Skip to content

fix(cli): expand ${VAR} placeholders in .mcp.json like every settings scope - #11703

Open
bluefateludi wants to merge 5 commits into
QwenLM:mainfrom
bluefateludi:fix/mcp-json-env-expansion-11499
Open

bluefateludi wants to merge 5 commits into
QwenLM:mainfrom
bluefateludi:fix/mcp-json-env-expansion-11499

Conversation

@bluefateludi

@bluefateludi bluefateludi commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Makes a project .mcp.json expand $VAR / ${VAR} placeholders in server entries (headers, env, url, httpUrl, command, args) exactly like every settings scope already does, and keeps approval decisions bound to the file's literal text rather than the resolved secret.

Two coordinated changes:

  • The .mcp.json loader resolves placeholders at load time, after Claude transport normalization, using the same precedence as settings scopes: process.env > home ~/.qwen/.env > unresolved placeholder. The loader resolves internally, so every caller (assembly, hot-reload, the mcp commands) inherits the home-.env fallback without signature changes — a token in ~/.qwen/.env now works identically in .mcp.json and .qwen/settings.json.
  • The loader also reports each server's pre-expansion literal config. Assembly registers that literal map per project root, and the approval store hashes the literal (not the live resolved config) for project servers. Settings-sourced servers keep hashing the live config, matching existing behavior.

Why it's needed

A checked-in .mcp.json that keeps its secret in the environment is the only safe way to configure an authenticated server, but today the placeholder text is sent verbatim: {"Authorization": "Bearer ${MY_TOKEN}"} with MY_TOKEN set in the environment reaches the endpoint as the literal string ${MY_TOKEN} and answers 401, so the server shows as Disconnected with no hint at the cause. The byte-identical entry in .qwen/settings.json works, because settings scopes run the env resolver and the .mcp.json path never does — assembleMcpServers() layers .mcp.json on top of the already-resolved settings map, so nothing downstream could catch it either (#11499; settings-scope resolution itself was #4466/#4474).

The approval-hash half closes the side effect the triage note flagged: once expansion happens at load time, hashing the live config would bind a stored approval to the secret's value — rotating MY_TOKEN, or a teammate cloning the repo with their own token, would flip an approved server back to pending and re-prompt, although the file never changed. #4615's intent is that editing the file re-triggers approval. Binding to the pre-expansion literal keeps approvals stable across environment changes while a real file edit still reverts the server to pending. (Workspace .qwen/settings.json is already in the value-bound position today; this PR does not change that — noted as an existing wart, deliberately not widened or fixed here.)

The untrusted-file angle is unchanged: resolveEnvVarsInString() already refuses Qwen-internal secrets (isInternalSecretEnvVar), bounding .mcp.json exactly as workspace settings and extensions already are, and the approval dialog / ServerDetailStep never render headers/env, so expansion puts no secret on screen.

Reviewer Test Plan

How to verify

cd packages/cli && npx vitest run src/config/mcpJson.test.ts src/config/mcpServers.test.ts src/config/mcpApprovals.test.ts src/commands/mcp/approve.test.ts src/config/hot-reload.test.ts src/commands/mcp/list.test.ts — 111 tests, all green (16 new). On main, the new expansion tests fail (headers keep the literal ${MY_TOKEN}).

Behavior to confirm, per the issue's reproduction: a .mcp.json with "headers": {"Authorization": "Bearer ${MY_TOKEN}"} and MY_TOKEN in the environment now receives the real token at the endpoint; the same token defined only in ~/.qwen/.env also resolves; an undefined variable stays literal (no error, no empty substitution); a Claude-style {"type": "http", "url": "https://host/${VAR}/mcp"} expands after transport normalization (the placeholder travels into httpUrl).

Approval binding: approve a server, then change only the env value behind its placeholder — the approval holds (approved, no re-prompt); edit the .mcp.json entry itself — the server returns to pending. Both pinned by unit tests.

Mutation checks: disabling the expansion (return the literal config) turns exactly the 4 expansion tests red; binding the hash to the live config instead of the literal turns exactly the rotation-stability test red — the suite pins both halves of the change.

Evidence (Before & After)

Before (main): .mcp.json Bearer ${MY_TOKEN} → endpoint receives Bearer ${MY_TOKEN} → 401 (issue report); new tests red.

After (this branch, commit a49d892):

Test Files  6 passed (6)
     Tests  111 passed (111)

Tested on

OS Status
🍏 macOS ⚠️
🪟 Windows
🐧 Linux ⚠️

Environment (optional)

vitest unit tests on Windows x64, Node v22, npm workspaces install.

Risk & Scope

  • Main risk or tradeoff: approvals stored before this change were hashed from the literal-only config (no expansion existed), so those hashes still match the literal binding — no migration needed. Approvals stored by an intermediate build that expanded without the literal binding (none released) would re-prompt once.
  • Not validated / out of scope: workspace .qwen/settings.json remains value-bound in approvals (pre-existing, unchanged); making .mcp.json resolution participate in settings hot-reload env precedence is untouched.
  • Breaking changes / migration notes: none expected — configs without placeholders load byte-identically (resolver passes plain values through), and unresolved placeholders keep today's literal behavior.

Linked Issues

Fixes #11499

中文说明

本 PR 做了什么

让项目 .mcp.json 中的 $VAR / ${VAR} 占位符(headers、env、url、httpUrl、command、args)像所有 settings 作用域一样展开,并让审批决定绑定到文件的字面文本而非展开后的密钥值。

两个配套改动:

  • .mcp.json loader 在加载时、Claude 传输形态归一化之后展开占位符,优先级与 settings 作用域一致:process.env > home ~/.qwen/.env > 未解析占位符保持原样。由 loader 内部展开,所有调用方(组装、热重载、mcp 命令)无需改签名即继承 home .env 回退 —— 放在 ~/.qwen/.env 的 token 现在在 .mcp.json.qwen/settings.json 里行为一致。
  • loader 同时上报每个 server 展开前的字面配置;组装层按项目根注册该字面映射,审批存储对 project server 改为哈希字面配置(而非展开后的运行时配置)。settings 来源的 server 仍哈希运行时配置,维持既有行为。

为什么需要

把密钥放在环境变量里、.mcp.json 按名引用,是配置带认证 server 的唯一安全方式;但现在占位符文本被原样发送:{"Authorization": "Bearer ${MY_TOKEN}"}MY_TOKEN 已设置的情况下仍以字面量 ${MY_TOKEN} 到达端点并得到 401,server 显示 Disconnected 且毫无线索。字节相同的条目放在 .qwen/settings.json 却能工作 —— 因为 settings 作用域跑了 env 解析器而 .mcp.json 路径从未跑过,且 assembleMcpServers().mcp.json 叠加在已解析的 settings 之上,下游也无法补救(#11499;settings 作用域的解析本身来自 #4466/#4474)。

审批哈希一半是三角旗备注指出的副作用:一旦加载期展开,哈希运行时配置会把已存审批绑到密钥的上 —— 轮换 MY_TOKEN 或同事用自己的 token 克隆仓库都会让已批准的 server 回到 pending 重新弹窗,尽管文件从未改过。#4615 的本意是改文件才触发重新审批。绑定展开前字面配置让审批在环境变化下保持稳定,真实文件编辑仍会回到 pending。(workspace .qwen/settings.json 今天就已处于按值绑定状态;本 PR 不改变它 —— 作为既有瑕疵记录,刻意不在本 PR 扩大或修复。)

不可信文件的角度没有变化:resolveEnvVarsInString() 已拒绝 Qwen 内部密钥(isInternalSecretEnvVar),.mcp.json 的边界与 workspace settings、extension 完全一致;审批弹窗与 ServerDetailStep 从不渲染 headers/env,展开不会把密钥带上屏幕。

审阅者测试计划

如何验证

cd packages/cli && npx vitest run src/config/mcpJson.test.ts src/config/mcpServers.test.ts src/config/mcpApprovals.test.ts src/commands/mcp/approve.test.ts src/config/hot-reload.test.ts src/commands/mcp/list.test.ts —— 111 个测试全绿(新增 16 个)。在 main 上新增的展开测试为红(headers 保持字面 ${MY_TOKEN})。

按 issue 复现步骤确认的行为:.mcp.json"headers": {"Authorization": "Bearer ${MY_TOKEN}"} 且环境有 MY_TOKEN 时端点收到真实 token;只定义在 ~/.qwen/.env 的 token 同样展开;未定义变量保持字面(不报错、不替换为空);Claude 形态 {"type": "http", "url": "https://host/${VAR}/mcp"} 在传输归一化后展开(占位符随 httpUrl 携带)。

审批绑定:批准一个 server 后仅改占位符背后的环境值 —— 审批保持(approved,不重新弹窗);编辑 .mcp.json 条目本身 —— server 回到 pending。两者均有单元测试钉住。

变异验证:关闭展开(返回字面配置)恰好使 4 个展开测试变红;把哈希绑定改为运行时配置恰好使轮换稳定性测试变红 —— 测试套件同时钉住改动的两半。

证据(改前/改后)

改前(main):.mcp.jsonBearer ${MY_TOKEN} → 端点收到 Bearer ${MY_TOKEN} → 401(issue 报告);新增测试为红。

改后(本分支,提交 a49d892):

Test Files  6 passed (6)
     Tests  111

passed (111)

测试平台

OS 状态
🍏 macOS ⚠️
🪟 Windows
🐧 Linux ⚠️

环境(可选)

Windows x64 上的 vitest 单元测试,Node v22,npm workspaces 安装。

风险与范围

  • 主要风险或取舍:本 PR 之前存储的审批哈希来自纯字面配置(当时不存在展开),与字面绑定仍然匹配 —— 无需迁移。若存在"已展开但未做字面绑定"的中间版本(未发布过)存的审批会重新弹一次。
  • 未验证 / 范围之外:workspace .qwen/settings.json 的审批仍是按值绑定(既有行为,未改);.mcp.json 解析参与 settings 热重载的环境优先级不在本 PR 范围。
  • 破坏性变更 / 迁移说明:无 —— 不含占位符的配置加载结果字节不变(解析器对普通值原样透传),未解析占位符保持今天的字面行为。

关联 Issue

Fixes #11499

Fireworks' OpenAI-compatible endpoint accepts the non-standard
reasoning_content field on input but rejects the additional reasoning
field with HTTP 400 (Extra inputs are not permitted, field:
'messages[N].reasoning'). The default provider mirrors
reasoning_content into reasoning for any model whose name contains
'qwen3' — a family match that does not establish the endpoint accepts
the extra field — so Qwen3 thinking models on Fireworks fail every
multi-turn continuation that replays a thinking turn, including
tool-call continuation (issue QwenLM#11657).

Add a Fireworks provider, detected by api.fireworks.ai hostname in
determineProvider (following the Cerebras precedent from QwenLM#11049), that
undoes the mirror at the outbound request boundary. Only the exact
mirror copy (reasoning === reasoning_content) is dropped; a distinct
explicit reasoning field and reasoning_content itself — which Fireworks
documents for reasoning replay — are preserved. Conversation history is
never mutated and non-Fireworks endpoints keep the existing mirroring.
… scope

A project .mcp.json keeping its secret in the environment sent the
placeholder text instead of the secret: headers like
'Authorization: Bearer ${MY_TOKEN}' reached the endpoint verbatim and
answered 401, while the byte-identical entry in .qwen/settings.json
worked — loadProjectMcpServers() never called the env resolver, and
assembleMcpServers() layers .mcp.json on top of the already-resolved
settings map, so nothing downstream could catch it either (issue
QwenLM#11499).

Resolve placeholders at load time, after Claude transport
normalization, with the same precedence settings scopes use
(process.env > home ~/.qwen/.env > unresolved placeholder) — the loader
resolves internally so every caller inherits the home fallback without
signature changes. The resolver's isInternalSecretEnvVar guard bounds
the untrusted-file angle exactly as it already is for workspace
settings and extensions.

Approval hashing: expansion would bind a stored approval to the
secret's value — rotating MY_TOKEN or a teammate cloning the repo with
their own token would flip an approved server back to pending although
the file never changed. QwenLM#4615's intent is that editing the file
re-triggers approval, so the loader also reports each server's
pre-expansion literal config, assembly registers it per project root,
and the approval store hashes that literal for project servers (the
live config remains the binding for settings-sourced servers, matching
existing behavior).
@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

Qwen precheck requires maintainer approval before automated triage/review.

Head SHA: a73077b9b39b0ecee13e566aae5c2dff363eac2f

Reason:

  • secret_value:assignment

A maintainer with write access can inspect the PR and manually request a run with @qwen-code /triage or @qwen-code /review. A new push requires a fresh precheck.

@qqqys qqqys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: a name collision between .mcp.json and workspace settings silently unbinds the #4615 approval gate

Reviewed at head a49d89211f5d5b65bc4791869a54c9fb6a8736ee.

The env-expansion half looks right to me, and binding a project server's approval to the pre-expansion literal is a sound idea. The problem is that the literal lookup is keyed by server name alone, while the server that actually executes may come from a different file.

Location

packages/cli/src/config/mcpApprovals.ts:36-43, consumed by getState (:200) and setState (:222), together with packages/cli/src/config/mcpServers.ts:50-55:

function approvalConfigHash(projectRoot, serverName, config) {
  const literal = getProjectMcpLiteralSource(projectRoot)?.[serverName];
  return hashMcpServerConfig(literal ?? config);
}

Mechanism

assembleMcpServers merges {...belowProject, ...projectResult.servers, ...aboveProject, ...cliMcpServers}, so a settings entry with scope: 'workspace' (or 'system') overrides the same-named .mcp.json entry and is what runs. setProjectMcpLiteralSource(cwd, projectResult.literalServers) nevertheless stores the full .mcp.json literal map, including that overridden entry. approvalConfigHash then finds a literal for the name and returns its hash — with no scope or provenance check — so the executing workspace server's approval is bound to a config from a file it does not come from.

This is an explicitly supported configuration, not a misuse: packages/core/src/config/mcp-server-config.ts:12-16 documents that 'workspace' and 'system' rank above a .mcp.json, and isGatedMcpScope (:47-49) gates 'workspace' as well as 'project', so the overriding entry does go through this hash.

Trigger

A project shipping both .mcp.json and .qwen/settings.json that declare an MCP server under the same name. The user approves it once through the normal startup dialog.

Impact

Any later edit to the workspace entry — command, args, url, headers, i.e. precisely the fields configHash.ts calls behavioral — leaves getState returning 'approved', because the hash is computed from the unrelated .mcp.json literal and never changes. The server connects and executes a configuration the user never saw a prompt for. configHash.ts states that "Approval binding is security-sensitive", and this fails open in exactly that direction; #4615's invariant ("editing the file re-triggers approval") is silently defeated for the colliding server.

The inverse symptom also appears: editing the non-executing .mcp.json entry spuriously flips the workspace server back to pending.

Base arm

This is a regression introduced by this diff, not a pre-existing hole. On main, getState computes hashMcpServerConfig(config) over the live config, so any behavioral edit to the executing entry changes the digest and returns pending.

I verified the delta mechanically against the real hashMcpServerConfig from packages/core, with the head's approvalConfigHash and assembleMcpServers merge order, and with the source text of both arms asserted against the blobs rather than transcribed freehand:

arm after approve after editing the executing workspace entry
base (main) approved pending — gate holds
head (this PR) approved approved — gate bypassed

hash(approvalConfigHash) is identical across the edit on the head arm (true) and differs on the base arm (false), and the head's digest equals hashMcpServerConfig(projectLiteral) — confirming the binding is to the project literal. A negative control with a non-colliding name returns pending on both arms, so the harness is sensitive to the collision specifically rather than reporting the head as unconditionally broken.

Fix direction

Only consult the literal when the config being hashed genuinely came from .mcp.json. Either gate the lookup on provenance (config.scope === 'project'), or — more robustly — have assembly record literals only for the names that win the merge, so an overridden project entry never populates the map. The first is the smaller change; the second also removes the stale-entry class of bug for free.

Worth fixing in the same pass, though it is not itself blocking: the approvals file is keyed by normalizeProjectRoot(projectRoot) (path.resolve, lowercased on win32) while projectMcpLiteralSources is keyed by the raw cwd, so the two maps can disagree about identity. Today the miss direction is benign (it falls back to hashing the live config, i.e. re-prompts), but the asymmetry means whether this fix applies at all depends on the caller's spelling of the root.

Not covered by this comment

Everything else in the diff — the expansion precedence, the isInternalSecretEnvVar denylist, the literal/resolved pairing in loadProjectMcpServers, and the test additions — I read and did not find blocking. I did not review the openaiContentGenerator/provider/fireworks.* files, which appear to belong to #11701 and ride along because this branch is stacked on it; a maintainer may want this branch rebased so the two land independently.


中文摘要approvalConfigHash 只按 服务器名.mcp.json 的字面量,没有校验 scope 与来源。而 assembleMcpServersscope: 'workspace'/'system' 的设置项会覆盖同名的 .mcp.json 条目并真正执行,字面量表却仍保存被覆盖的那一份;isGatedMcpScope 同样对 'workspace' 生效。于是当项目同时存在同名的 .mcp.json.qwen/settings.json MCP 条目时,实际执行的 workspace 服务器的批准被绑定到了另一个文件的配置上——之后修改该 workspace 条目的 command/args/url/headers 都不会使 getState 回到 pending#4615 的"改文件即重新批准"被静默绕过,且方向是 fail-open。maingetState 哈希的是实时配置,任何行为性修改都会重新要求批准,因此这是本 PR 引入的回归。修复方向:仅在配置确实来自 .mcp.json 时才使用字面量(按 config.scope === 'project' 判断,或只记录在合并中胜出的条目)。

@bluefateludi

Copy link
Copy Markdown
Contributor Author

Thanks @qqqys — confirmed and fixed in 39b7012.

approvalConfigHash now consults the .mcp.json literal only when the config being hashed is itself scope: 'project'. A workspace/system entry that overrides a same-named .mcp.json server hashes its own live config, so a behavioral edit to the executing entry re-prompts exactly as on main, and editing the non-executing .mcp.json entry no longer flips the workspace server to pending.

Regression added in mcpApprovals.test.ts: a registered literal plus a scope: 'workspace' override under the same name — after approving, editing the executing workspace entry must return pending. Mutation-verified: removing the scope gate turns that test red; the other literal-binding tests (rotation-stable, file-edit re-prompt) stay green.

Also took the non-blocking root-key asymmetry in the same pass: the literal-source map is now keyed through the same path.resolve + win32 lowercase folding as the approvals file, so a caller spelling the root with different casing no longer silently misses the literal.

@qqqys qqqys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: the new ./environment.js import breaks two ACP suites at collection time — QWEN_DIR is not in their core mock

Reviewed at head 39b701210aee6a525fcd363e7de3ec6b7e99d9d8. State read immediately before posting: state=open, merged=false, draft=false.

First: the Critical from my previous review is fixed — thank you

My earlier CHANGES_REQUESTED (review 5186310856, anchored at a49d89211f5d) reported the approval-hash name collision that unbound the #4615 gate for a workspace/system entry overriding a same-named .mcp.json server. That is resolved at this head, and I verified it against the blobs rather than the description:

  • mcpApprovals.ts:43-55 — the literal lookup is now gated on provenance: config.scope === 'project' ? getProjectMcpLiteralSource(normalizeProjectRoot(projectRoot))?.[serverName] : undefined, then hashMcpServerConfig(literal ?? config). A 'workspace'/'system' override therefore hashes its own live config, so a behavioral edit to the executing entry re-prompts exactly as on main, and editing the non-executing .mcp.json entry no longer flips it to pending. Both symptoms I filed are closed.
  • The gate cannot silently disable literal binding for genuine project servers: mcpJson.ts:126 and :130 stamp scope: 'project' on the literal and the resolved entry, so the winning branch is the one that still consults the literal.
  • The root-key asymmetry is closed symmetrically — literalSourceRootKey (mcpServers.ts:68-71, path.resolve + win32 lowercase) is applied on both the write (:93) and the read (:107), matching normalizeProjectRoot. Double-normalizing at the call site is idempotent, so that is harmless.
  • Consumer set is complete: getProjectMcpLiteralSource has exactly one production consumer (mcpApprovals.ts:50) and setProjectMcpLiteralSource exactly one production caller (mcpServers.ts:57).
  • mcpApprovals.test.ts:741 (does not bind a workspace-scope override to the same-named .mcp.json literal) asserts the right thing — registers the project literal under the colliding name, approves a scope: 'workspace' server, edits its httpUrl, expects pending — and the neighbouring cases keep the project-scope side honest (rotation-stable, file-edit re-prompts, no-literal parity), so it is sensitive to the collision specifically.

That review row is anchored at a commit that is no longer head, so please read this one as superseding it.

New blocking issue

Location. packages/cli/src/config/mcpJson.ts:15, added by this PR:

import { getHomeEnvFallbackVars } from './environment.js';

Mechanism. packages/cli/src/config/environment.ts:11 imports QWEN_DIR from @qwen-code/qwen-code-core and :31 reads it at module scope:

export const SETTINGS_DIRECTORY_NAME = QWEN_DIR;

acpAgent.test.ts:248 (and acpAgent.worktree.test.ts:116) mock that package with an explicit allow-list object literalasync (importOriginal) => ({ SessionSourceService: …, INVOCATION_CONTEXT_META_KEY: …, … }) — which cherry-picks named exports and never spreads ...await importOriginal(). QWEN_DIR is not among them; it appears 0 times in all 32,468 lines of acpAgent.test.ts. So the moment mcpJson.ts enters the module graph, evaluating environment.ts:31 reads a name the mock does not define and vitest aborts collection:

Error: [vitest] No "QWEN_DIR" export is defined on the "@qwen-code/qwen-code-core" mock.
 ❯ src/config/environment.ts:31:40
     31| export const SETTINGS_DIRECTORY_NAME = QWEN_DIR;
 ❯ src/config/mcpJson.ts:15:1

Trigger. Unconditional — no .mcp.json, no env var, no timing. Just collecting either suite.

Impact. Two previously-passing suites fail to collect in the Test (ubuntu-latest, Node 22.x) lane (job 103581922361), which is red at this head:

Failed Suites 2
 FAIL  src/acp-integration/acpAgent.test.ts
 FAIL  src/acp-integration/acpAgent.worktree.test.ts
Test Files  2 failed | 1044 passed (1046)
     Tests  30183 passed | 88 skipped (30271)

They contribute 0 tests, so the ACP agent surface loses its whole regression net while the lane is red. This is a deterministic module-load error, not a flake — the lane runs vitest run --retry=2 and failed through the retries.

Base arm. main's mcpJson.ts does not import ./environment.js, so environment.ts is not in these suites' collection graph and both pass. Independent control: #11708 sits on the same base 3b2283ee0d3b, does not touch mcpJson.ts, and has Test (ubuntu-latest, Node 22.x) = success — so this is introduced here, not pre-existing on main.

Fix direction. Two reasonable options; the choice is yours:

  1. Smallest: add QWEN_DIR to the core mock in both suites (one line each, matching how serve/workspace-service/__tests__/facade.test.ts already does it). This unblocks CI immediately, but leaves the new module-scope coupling in place, so any future suite that mocks core with an allow-list and transitively imports mcpJson.ts hits the same wall. Note that only one cli test in the repo lists QWEN_DIR: today, so there is no broad convention that would have caught this.
  2. More robust: keep environment.ts out of mcpJson.ts's module graph — e.g. source the home-.env fallback from the @qwen-code/qwen-code-core/envVarResolver entry point already imported on line 14, or move getHomeEnvFallbackVars into a module that does not evaluate QWEN_DIR at import time. A lazy await import('./environment.js') would also work but forces loadProjectMcpServers async, which is a wider change than this PR needs.

One note so the two red lanes are not conflated

Lint & Static (ubuntu-latest, Node 22.x) is also red at this head, but not for anything in this diff — it is the lint-gate-freshness check reporting that eslint.config.js moved on main (1e768979c5a6, #11625) after this branch last incorporated it, and that lane checks out the branch head alone. Merging or rebasing current main clears it. Only the Test lane failure above is attributable to the change.

Scope of this comment

Everything else in the diff at this head — the expansion precedence, the isInternalSecretEnvVar denylist, the literal/resolved pairing in loadProjectMcpServers, and the approval-hash fix described above — I read and did not find blocking. As before, I did not review openaiContentGenerator/provider/fireworks.*, which belong to #11701 and ride along because this branch is stacked on it. This comment carries no approval and no merge recommendation; as of the state read immediately before posting there is no APPROVED review row from ci-bot or a maintainer on this PR, so the report/approve gate is not open on my side either.


中文摘要:先说好消息——上一轮 CHANGES_REQUESTED(review 5186310856,锚定在 a49d89211f5d)指出的批准哈希同名冲突已在当前 head 修复,我按 blob 逐条核对:mcpApprovals.ts:43-55 现在用 config.scope === 'project' 限定字面量查询,mcpJson.ts:126/130 确实给字面量与解析后的条目都打了 scope: 'project'(所以正常项目服务器仍走字面量绑定),literalSourceRootKey 在写入(mcpServers.ts:93)与读取(:107)两侧对称生效,root key 大小写不对称也一并修好了;mcpApprovals.test.ts:741 的回归测试断言方向正确,且相邻用例守住了 project 侧行为。那条 review 锚定的已不是 head,请以本条为准。

新的阻塞问题:本 PR 在 mcpJson.ts:15 新增了 import { getHomeEnvFallbackVars } from './environment.js',而 environment.ts:31模块作用域读取 QWEN_DIR(来自 @qwen-code/qwen-code-core)。acpAgent.test.ts:248acpAgent.worktree.test.ts:116 对该包的 mock 是显式白名单对象字面量(没有 ...await importOriginal() 展开),全文 32468 行里 QWEN_DIR 出现 0 次。于是 mcpJson.ts 一旦进入模块图,收集阶段就会抛 No "QWEN_DIR" export is defined on the mock,两个原本通过的 suite 直接收集失败:Test Files 2 failed | 1044 passed (1046)Test (ubuntu-latest, Node 22.x) 变红(job 103581922361),ACP agent 这块失去全部回归覆盖。这是确定性的模块加载错误,不是 flake——该 lane 跑的是 vitest run --retry=2,重试后仍失败。基线侧:mainmcpJson.ts 不 import ./environment.js;同一 base 3b2283ee0d3b 上未改 mcpJson.ts#11708 该 lane 为 success,可作为独立对照,说明是本 PR 引入。修复方向二选一:(1)最小改动——在两个 suite 的 core mock 里补上 QWEN_DIR(各一行,可参照 serve/workspace-service/__tests__/facade.test.ts),但模块作用域耦合仍在;(2)更稳——让 environment.ts 不进入 mcpJson.ts 的模块图,例如改从第 14 行已引入的 @qwen-code/qwen-code-core/envVarResolver 取该 fallback,或把 getHomeEnvFallbackVars 移到不在 import 期求值 QWEN_DIR 的模块。另外提醒:Lint & Static 也红,但与本 diff 无关——那是 lint-gate 新鲜度检查在提示 eslint.config.jsmain 上(1e768979c5a6#11625)已变动、而该 lane 只 checkout 分支 head,合并或 rebase 最新 main 即可消除;只有上面的 Test lane 失败归因于本次改动。本条评论不含 approve,也不构成合入建议;按发帖前即时读到的状态,本 PR 尚无 ci-bot 或 maintainer 的 APPROVED review,因此报告/approve 闸门在我这侧也未打开。

@bluefateludi

Copy link
Copy Markdown
Contributor Author

Thanks for the precise diagnosis — fixed in a73077b, along your option 2 (the more robust one).

Fix. getHomeEnvFallbackVars now lives in its own module, src/config/home-env-fallback.ts, and mcpJson.ts imports it from there instead of ./environment.js. The new module imports only node:fs, node:path, dotenv, and { getErrorMessage, Storage } from core — nothing evaluates QWEN_DIR (or anything else) at module scope, so importing mcpJson.ts no longer pulls the settings-environment graph into any consumer. The function body is byte-identical to the environment.ts export (same candidates, same ??= first-wins, same onReadError reporting), so resolution behavior is unchanged; environment.ts keeps its own export for its existing consumers (settings.ts, settings-cache.ts) untouched.

Why a copy rather than re-exporting from environment.ts. The module-scope SETTINGS_DIRECTORY_NAME = QWEN_DIR evaluation is the coupling; any import path through environment.ts — even a pure export { getHomeEnvFallbackVars } from re-export — keeps mcpJson.ts's graph dependent on it, which is exactly the wall the allow-list mocks hit. Sourcing from @qwen-code/qwen-code-core/envVarResolver wasn't available because that entry exports only the resolvers, not the fallback loader; a lazy await import would have forced loadProjectMcpServers async, a wider change than the review itself scoped out. Two definitions of a 25-line pure reader felt acceptable against those alternatives; if maintainers prefer, consolidating environment.ts to import from the new module is a trivial follow-up (its other imports are unaffected — QWEN_DIR stays for SETTINGS_DIRECTORY_NAME).

Verification.

  • Both suites collect and run again: acpAgent.worktree.test.ts 3/3 green; acpAgent.test.ts 706 passed with 2 Windows-local failures that are not from this diff — "keeps source identity … after a live cwd change" and "rejects a standalone directory identity replaced during Config relocation" both fail at the branch's merge-base 3b2283ee0d with the PR's changes fully stashed (they assert workspaceCwd: '/tmp' / directory-inode identity, which path.resolve and fs.rename semantics on win32 diverge on; CI's ubuntu lane is the authoritative run for these).
  • Mutation: reverting just the import line back to ./environment.js reproduces the exact collection failure (No "QWEN_DIR" export is defined on the mock); restored, the suites pass. Breaking the new module's own import also turns mcpJson.test.ts red at collection, so the new edge is load-bearing both ways.
  • The PR's own suites stay green: mcpJson / mcpServers / mcpApprovals 58/58. Build + typecheck clean.

Note on the branch head. Between the review and this fix, @shaojinwen merged main into the branch (123d2e4) — thank you; that also clears the lint-gate-freshness lane the review called out as unrelated to this diff. This fix is rebased on top of that merge, so the stacked-on-#11701 overlap the review noted is partially resolved as well (the Fireworks commits are now in main via the merge).

@qqqys qqqys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The blocking issue is fixed at a73077b9b39b — verified from head blobs and a green Test lane; I found nothing new that blocks

Re-reviewed at head a73077b9b39b0ecee13e566aae5c2dff363eac2f. Lifecycle read immediately before posting: state=open, merged=false, draft=false.

The Critical from review 5187616313 is resolved

My previous CHANGES_REQUESTED (review 5187616313, anchored at 39b701210aee) reported that the new import { getHomeEnvFallbackVars } from './environment.js' at mcpJson.ts:15 pulled environment.ts:31's module-scope export const SETTINGS_DIRECTORY_NAME = QWEN_DIR into the collection graph of acpAgent.test.ts and acpAgent.worktree.test.ts, whose allow-list core mocks never define QWEN_DIR, so both suites failed to collect and Test (ubuntu-latest, Node 22.x) went red.

You took option 2. I checked it against the head blobs rather than the description:

  • Fixed at the mechanism, not just at the symptom. mcpJson.ts:15 now reads import { getHomeEnvFallbackVars } from './home-env-fallback.js'; (blob acad58ee14e87a1e99ac0e47) and ./environment.js is gone from the file's import list, so environment.ts is out of the graph. The part that makes this robust rather than local: the new home-env-fallback.ts (59 lines, read in full) has no module-scope use of any core exportStorage.getGlobalQwenDir() and getErrorMessage(e) are both inside the function body. An allow-list mock throws when a missing export is accessed, which is exactly why the original failure pointed at environment.ts:31:40 and not at the import line, so importing this module cannot throw at collection time.
  • The two names it does use are safe in those suites anyway. Storage is defined in both core mocks (acpAgent.test.ts:757, acpAgent.worktree.test.ts:213). getErrorMessage is not — 0 hits across 32,554 + 603 lines — but it is reached only inside the catch, and neither suite mentions loadProjectMcpServers, mcpJson or home-env-fallback at all (0 hits), so that call site is never entered there.
  • The copy is faithful and environment.ts is untouched. home-env-fallback.ts:26-58 has the same candidate order, the same QWEN_HOME skip, the same ??= first-wins and the same onReadError text as environment.ts:222-254, and environment.ts's blob is 60f21f6ae00b at both 39b701210aee and head, so settings.ts / settings-cache.ts see no change. Your reasoning for a copy over a re-export is right — any import path through environment.ts keeps the module-scope evaluation, which is the wall. Non-blocking and yours to call: two definitions of one resolver can drift, so if it is ever consolidated, pointing environment.ts at the new module keeps a single source.
  • The main merge did not silently change what I had cleared. 123d2e45ce8a landed between my review and this fix, so I compared blobs instead of trusting the diff: mcpApprovals.ts (2c4e7bb8fd15) and mcpServers.ts (5fc641216853) are byte-identical between 39b701210aee and head. Only mcpJson.ts changed, and home-env-fallback.ts is the sole addition.
  • Empirically. Test (ubuntu-latest, Node 22.x) at head is success (job 103618891196, 20:47:33Z → 21:27:42Z — a 40-minute run, not a skip), against Test Files 2 failed | 1044 passed (1046) at 39b701210aee. Lint & Static is success as well: the merge cleared the freshness gate I had flagged as unrelated to your diff.

Coverage at this head

The diff is now 8 files — the stacked #11701 Fireworks files dropped out through the merge — of which 4 are production (home-env-fallback.ts new, mcpJson.ts, mcpApprovals.ts, mcpServers.ts) and 4 are tests. I read both production files the fix commit touches in full; the other two are blob-identical to the head my previous review cleared, where the expansion precedence, the isInternalSecretEnvVar denylist and the literal/resolved pairing in loadProjectMcpServers were already non-blocking. So no file in the current list is unreviewed, and I found no new blocking issue.

One thing worth recording because it is the part that usually gets lost in a move like this: mcpJson.test.ts contains no vi.mock at all, so falls back to the home ~/.qwen/.env for vars not in process.env drives the relocated function for real — QWEN_HOME pointed at a temp dir, MY_MCP_TOKEN=tok-from-home-env written into its .env, and loadProjectMcpServers asserted to produce Authorization: Bearer tok-from-home-env. The moved code is executed by a test in the lane that just went green, not merely relocated.

Posture, scoped to this read

As of the state read immediately before posting: this PR has 2 review rows, both mine, both CHANGES_REQUESTED, both anchored at commits that are no longer head (5186310856 @ a49d89211f5d, 5187616313 @ 39b701210aee); there is no APPROVED row from ci-bot or from a maintainer, and precheck-pr reports that maintainer approval is required before automated triage/review runs. So the report/approve gate is not open on my side, and this comment carries no approval and no merge recommendation. What it does record is that both findings those two rows report are resolved at a73077b9b39b, so neither row describes the PR as it now stands.

I have not dismissed them myself — withdrawing a blocking review is not something this review pass is authorized to do — so I am flagging it instead: mergeable_state reads blocked at this head solely because of those two stale rows, and a maintainer who agrees they are stale can dismiss them.


中文摘要:在 head a73077b9b39b0ecee13e566aae5c2dff363eac2f 复核(发帖前即时读到 state=openmerged=falsedraft=false)。上一轮 CHANGES_REQUESTED(review 5187616313,锚定 39b701210aee)指出的阻塞问题已修复,我按 head blob 逐条核对而非依赖描述:mcpJson.ts:15 已改为 from './home-env-fallback.js'(blob acad58ee14e87a1e99ac0e47),./environment.js 已从该文件 import 列表消失,environment.ts 不再进入模块图;更关键的是新模块 home-env-fallback.ts(59 行,全文读过)在模块作用域不使用任何 core 导出——Storage.getGlobalQwenDir()getErrorMessage(e) 都在函数体内,而白名单 mock 是在访问缺失导出时才抛错(这正是当初报错指向 environment.ts:31:40 而非 import 行的原因),所以 import 该模块不可能在收集期抛错。两个 suite 的 core mock 本身也确实定义了 StorageacpAgent.test.ts:757acpAgent.worktree.test.ts:213);getErrorMessage 没有(32554 + 603 行内 0 命中),但它只在 catch 内被访问,而两个 suite 完全没有引用 loadProjectMcpServers / mcpJson / home-env-fallback(0 命中),因此那条路径不会被进入。副本与原函数一致(候选顺序、QWEN_HOME 跳过、??= 先到先得、onReadError 文案均相同),且 environment.ts 的 blob 在 39b701210aee 与 head 同为 60f21f6ae00bsettings.ts / settings-cache.ts 不受影响;你选择复制而非 re-export 的理由成立(任何经过 environment.ts 的 import 路径都会保留模块作用域求值)。非阻塞一点:同一解析器存在两份定义、日后可能漂移,若要合并,让 environment.ts 反向 import 新模块即可保持单一来源。另外 123d2e45ce8amain 并入分支,所以我用 blob 对比而不是看 diff:mcpApprovals.ts2c4e7bb8fd15)与 mcpServers.ts5fc641216853)在两个 head 之间逐字节相同,说明合并未悄悄改动我此前已放行的代码。实测侧:head 上 Test (ubuntu-latest, Node 22.x)success(job 103618891196,20:47:33Z → 21:27:42Z,是 40 分钟的真实运行而非 skip),而 39b701210aee 上是 Test Files 2 failed | 1044 passed (1046)Lint & Static 也已转绿——正如你所说,合并 main 清掉了我标注为与本 diff 无关的新鲜度门槛。

当前 head 的覆盖情况:diff 现为 8 个文件(叠在 #11701 上的 Fireworks 文件因合并而退出 diff),其中 4 个生产文件(新增 home-env-fallback.tsmcpJson.tsmcpApprovals.tsmcpServers.ts)、4 个测试文件。本次 fix commit 触及的两个生产文件我全文读过,另两个与我上一轮已放行的 head 逐字节相同(当时已认定展开优先级、isInternalSecretEnvVar 拒绝名单、loadProjectMcpServers 中字面量/解析结果配对均不阻塞),因此当前文件清单没有未审阅项,也没有发现新的阻塞问题。值得记录的一点:mcpJson.test.ts 完全没有 vi.mock,所以 falls back to the home ~/.qwen/.env for vars not in process.env 这条用例是真实驱动被搬移的函数(QWEN_HOME 指向临时目录、写入 MY_MCP_TOKEN=tok-from-home-env、断言 loadProjectMcpServers 产出 Authorization: Bearer tok-from-home-env),即搬移后的代码在刚刚转绿的 lane 里被测试执行,而不只是换了位置。

发帖前即时读到的状态:本 PR 共 2 条 review,都是我提交的 CHANGES_REQUESTED,且都锚定在已非 head 的提交上(5186310856 @ a49d89211f5d5187616313 @ 39b701210aee);没有来自 ci-bot 或 maintainer 的 APPROVED,且 precheck-pr 显示需要 maintainer 批准后才会跑自动 triage/review。因此报告/approve 闸门在我这侧未打开,本条评论不含 approve,也不构成合入建议;它记录的是这两条 review 所指的问题在 a73077b9b39b 均已解决,因此两条都已不再描述当前 PR。我没有自行 dismiss——撤回阻塞性 review 超出本次 review 的授权范围——所以在此明确标出:当前 head 的 mergeable_state 之所以是 blocked,只因这两条已过期的 review;若 maintainer 认同其已过期,可以 dismiss。

@bluefateludi

Copy link
Copy Markdown
Contributor Author

Thank you for the thorough re-review — and for checking the fix against the head blobs and the actual 40-minute Test lane rather than the description; the note that Storage is present in both suites' mocks while getErrorMessage is only reached inside the (never-entered) catch is exactly the verification we'd have wanted.

On the non-blocking point: agreed that two copies of one resolver can drift. We've left environment.ts untouched in this round to keep the fix minimal; pointing environment.ts at home-env-fallback.ts for a single source is a one-line follow-up we're happy to make here if maintainers prefer, or to fold into whatever consolidation pass comes later — the semantics are identical, so there's no urgency either way.

Also noting for whoever holds the dismiss button: per your read, both CHANGES_REQUESTED rows are anchored at non-head commits whose findings are resolved, and mergeable_state: blocked rests on those two stale rows alone. We won't touch the reviews ourselves.

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Traced the expansion and the approval binding at the PR head. No blocking issues found.

  • The expansion applies the same precedence as the settings scopes (process.env → home .env → unresolved placeholder), runs after Claude-shape normalization so both the raw and normalized field names are covered, and the literal record keeps the same normalized-but-unresolved shape, so the two configs differ only in expansion — which is exactly what the approval hash needs to stay stable across secret rotation.
  • The binding is correctly scoped: approvalConfigHash consults the literal only when config.scope === 'project', so a settings-scoped override of a same-named .mcp.json entry keeps binding to the executing config — a settings server can never silently ride on a literal from a file it did not come from. The root key folds win32 drive-letter case the same way mcpApprovals' normalizeProjectRoot does, so a differently-cased caller cannot miss the literal.
  • resolveEnvVarsInObject still refuses Qwen-internal secrets (isInternalSecretEnvVar), so the untrusted-file angle is bounded the same way it already is for workspace settings, and expansion happens at load time without spawning or connecting — the approval gate (#4615) is untouched.
  • home-env-fallback.ts is deliberately a standalone copy (the comment says why: importing the settings-environment module would evaluate QWEN_DIR at module scope and break allow-listed core mocks), and its ??= first-wins plus !Object.hasOwn(process.env, key) matches the documented precedence.
  • The core ./envVarResolver subpath is a real export-map entry (packages/core/package.json), not a deep import, so this does not depend on dist internals.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

${VAR} placeholders in .mcp.json are not expanded, so headers are sent literally

6 participants