Skip to content

fix(core): strip Qwen-internal daemon secrets from agent-spawned child env - #7256

Merged
wenshao merged 3 commits into
QwenLM:mainfrom
chinesepowered:fix/shell-strip-internal-secrets-env
Jul 22, 2026
Merged

fix(core): strip Qwen-internal daemon secrets from agent-spawned child env#7256
wenshao merged 3 commits into
QwenLM:mainfrom
chinesepowered:fix/shell-strip-internal-secrets-env

Conversation

@chinesepowered

@chinesepowered chinesepowered commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Fixes #6601. Shell subprocesses (and the monitor tool and stdio MCP servers) spawned by Qwen Code inherited the full daemon process.env, including QWEN_SERVER_TOKEN — the serve-daemon bearer credential. An agent-run command like printenv QWEN_SERVER_TOKEN could therefore read an internal secret it should never see.

This adds a small shared sanitizeChildEnv() in packages/core/src/utils/ that removes Qwen-internal daemon/server secrets from an env before it is handed to a child process, and applies it at the four spawn sites that inherit process.env:

  • services/shellExecutionService.ts — the child_process fallback and the PTY path
  • tools/monitor.ts — the monitor command spawn
  • tools/mcp-client.ts — the stdio MCP transport

The denylist is deliberately narrow — only internal bearer tokens:

INTERNAL_SECRET_ENV_VARS = ['QWEN_SERVER_TOKEN', 'QWEN_DAEMON_TOKEN']

Per the maintainer's design guidance on the issue, it does not reuse the desktop BLOCKED_ENV_VARS denylist, and it intentionally does not strip third-party credentials (GH_TOKEN/GITHUB_TOKEN, AWS_*, NPM_TOKEN, …): the shell tool exists to run whatever the user asks, and real workflows (gh, the AWS CLI, npm publish) legitimately depend on inheriting those. Stripping only Qwen-internal secrets is pure upside with no workflow breakage. sanitizeChildEnv is exported from the package root so the desktop denylists can consolidate onto it as a follow-up.

Why it's needed

QWEN_SERVER_TOKEN is the serve-daemon bearer token (packages/cli/src/serve/auth.ts); QWEN_DAEMON_TOKEN is the channel-daemon worker token (the daemon worker already scrubs both from its own process.env). Leaking either to arbitrary agent-run commands is a defense-in-depth credential-exposure gap — filed and confirmed as P1. This PR covers the four spawn sites in the scope agreed on #6601 — the shell tool's PTY and child_process paths, the monitor tool, and the stdio-MCP transport. Two more same-trust-class paths (tools/tool-registry.ts tool-call/discovery spawns and hooks/hookRunner.ts command hooks) also inherit the full env and are intentionally deferred to a follow-up (see Risk & Scope).

Reviewer Test Plan

How to verify

  • npx vitest run src/utils/sanitize-child-env.test.ts (6/6): asserts the two internal tokens are removed while PATH, GH_TOKEN, GITHUB_TOKEN, AWS_ACCESS_KEY_ID, NPM_TOKEN, HOME survive; input is not mutated; and a guardrail test pins the denylist to exactly the two internal vars so it can't silently grow to include third-party creds.
  • npx vitest run src/services/shellExecutionService.test.ts (132/132): includes a new regression test in both the PTY and child_process describe blocks asserting the spawn env has no QWEN_SERVER_TOKEN/QWEN_DAEMON_TOKEN, while PATH, GH_TOKEN, and the tool's own QWEN_CODE=1 marker are present.
  • npx vitest run src/tools/monitor.test.ts (80/80) and src/tools/mcp-client.test.ts (102/102): unchanged — confirms the one-line env wraps didn't regress those spawn paths.

Evidence (Before & After)

Shell spawn env (printenv QWEN_SERVER_TOKEN in an agent-run command):

  • Before: ...normalizePathEnvForWindows(process.env) → child inherits QWEN_SERVER_TOKEN; the command prints the daemon bearer token.
  • After: ...normalizePathEnvForWindows(sanitizeChildEnv(process.env))QWEN_SERVER_TOKEN/QWEN_DAEMON_TOKEN absent; PATH, GH_TOKEN, AWS_*, NPM_TOKEN still inherited.

Tested on

OS Status
🍏 macOS
🪟 Windows N/A
🐧 Linux N/A

macOS: the four suites above pass locally (320 tests across the changed files). The shell child env is asserted deterministically via the mocked pty/child_process spawn, so no manual QA is required.

Environment (optional)

Node v24; @qwen-code/qwen-code-core workspace; vitest 3.2.

Risk & Scope

  • Main risk or tradeoff: the change removes exactly two Qwen-internal token vars from child envs and nothing else — no third-party credential is stripped, so gh/AWS/npm and similar workflows are unaffected. All existing tests for the four touched spawn paths pass unchanged.
  • Deferred to a follow-up (same trust class, not covered here): tools/tool-registry.ts (the toolCallCommand/toolDiscoveryCommand spawns pass no env, inheriting everything) and hooks/hookRunner.ts (command hooks spread full process.env). The identical one-line sanitizeChildEnv(process.env) wrap applies; kept out of this PR to hold scope to the Shell subprocess inherits sensitive environment variables causing credential exposure #6601-agreed sites.
  • Not validated / out of scope: broader third-party-credential stripping for the sandbox/MCP infrastructure paths stays where it already lives; consolidating the duplicated desktop BLOCKED_ENV_VARS lists onto the shared util is a suggested follow-up, not done here.
  • Breaking changes / migration notes: none.

Linked Issues

Fixes #6601. Implementation follows the design outlined by @doudouOUC on the issue (narrow internal-secrets denylist, applied at the shell/monitor/MCP-stdio spawn sites, third-party creds left inherited).

中文说明

本 PR 的作用

修复 #6601。Qwen Code 启动的 shell 子进程(以及 monitor 工具、stdio MCP 服务)会继承完整的守护进程 process.env,其中包含 QWEN_SERVER_TOKEN——serve 守护进程的 bearer 凭证。因此像 printenv QWEN_SERVER_TOKEN 这样由 agent 执行的命令能读到本不该看到的内部密钥。

本 PR 在 packages/core/src/utils/ 新增一个小的共享 sanitizeChildEnv(),在把 env 交给子进程前移除 Qwen 内部守护/服务密钥,并应用于四处继承 process.env 的 spawn 点:

  • services/shellExecutionService.ts——child_process 兜底路径 PTY 路径
  • tools/monitor.ts——monitor 命令 spawn
  • tools/mcp-client.ts——stdio MCP 传输

denylist 刻意保持狭窄——仅内部 bearer token:

INTERNAL_SECRET_ENV_VARS = ['QWEN_SERVER_TOKEN', 'QWEN_DAEMON_TOKEN']

按照 issue 上维护者的设计意见,它复用桌面端的 BLOCKED_ENV_VARS,也剥离第三方凭证(GH_TOKEN/GITHUB_TOKENAWS_*NPM_TOKEN 等):shell 工具的存在就是为了运行用户所要求的任何命令,而真实工作流(gh、AWS CLI、npm publish)确实依赖继承这些凭证。仅剥离 Qwen 内部密钥是纯收益、不会破坏工作流。sanitizeChildEnv 从包根导出,便于后续将桌面端 denylist 收敛到它之上。

为什么需要

QWEN_SERVER_TOKEN 是 serve 守护进程 bearer token(packages/cli/src/serve/auth.ts);QWEN_DAEMON_TOKEN 是 channel 守护 worker token(守护 worker 已在其自身 process.env 中清除二者)。将任一泄露给任意 agent 执行的命令是纵深防御上的凭证暴露缺口——已被确认为 P1。本 PR 覆盖 #6601 商定范围内的四个 spawn 点——shell 工具的 PTY 与 child_process 路径、monitor 工具,以及 stdio-MCP 传输。另有两处同一信任级别的路径(tools/tool-registry.ts 的 tool-call/discovery spawn,以及 hooks/hookRunner.ts 的命令 hook)同样继承完整 env,特意留待后续 PR 处理(见「风险与范围」)。

复核测试计划

如何验证

  • npx vitest run src/utils/sanitize-child-env.test.ts(6/6):断言两个内部 token 被移除,而 PATHGH_TOKENGITHUB_TOKENAWS_ACCESS_KEY_IDNPM_TOKENHOME 保留;输入不被修改;并有一个护栏测试将 denylist 固定为恰好两个内部变量,防止其悄然扩张纳入第三方凭证。
  • npx vitest run src/services/shellExecutionService.test.ts(132/132):在 PTY 与 child_process 两个 describe 块中各新增一个回归测试,断言 spawn env 中无 QWEN_SERVER_TOKEN/QWEN_DAEMON_TOKEN,而 PATHGH_TOKEN 及工具自身的 QWEN_CODE=1 标记均在。
  • npx vitest run src/tools/monitor.test.ts(80/80)与 src/tools/mcp-client.test.ts(102/102):保持通过——确认单行 env 包装未回归这些 spawn 路径。

证据(修复前后对比)

shell spawn env(agent 执行的命令中 printenv QWEN_SERVER_TOKEN):

  • 修复前:...normalizePathEnvForWindows(process.env) → 子进程继承 QWEN_SERVER_TOKEN;命令打印出守护 bearer token。
  • 修复后:...normalizePathEnvForWindows(sanitizeChildEnv(process.env))QWEN_SERVER_TOKEN/QWEN_DAEMON_TOKEN 不再存在;PATHGH_TOKENAWS_*NPM_TOKEN 仍被继承。

测试环境

系统 状态
🍏 macOS
🪟 Windows N/A
🐧 Linux N/A

macOS:上述四个套件在本地通过(改动文件共 320 个测试)。shell 子进程 env 通过 mock 的 pty/child_process spawn 确定性断言,无需人工 QA。

运行环境(可选)

Node v24;@qwen-code/qwen-code-core 工作区;vitest 3.2。

风险与影响范围

  • 主要风险或权衡:本改动仅从子进程 env 移除恰好两个 Qwen 内部 token 变量,别无其他——不剥离任何第三方凭证,故 gh/AWS/npm 等工作流不受影响。四处受影响 spawn 路径的既有测试均不变通过。
  • 留待后续(同一信任级别,本 PR 未覆盖):tools/tool-registry.tstoolCallCommand/toolDiscoveryCommand 的 spawn 未传 env,继承全部)与 hooks/hookRunner.ts(命令 hook 展开完整 process.env)。同样的一行 sanitizeChildEnv(process.env) 包装即可修复;为将范围收敛到 Shell subprocess inherits sensitive environment variables causing credential exposure #6601 商定的站点而未纳入本 PR。
  • 未验证 / 范围之外:面向 sandbox/MCP 基础设施路径的更广第三方凭证剥离维持原处;将重复的桌面端 BLOCKED_ENV_VARS 收敛到共享 util 为后续建议项,本 PR 未做。
  • 破坏性变更 / 迁移说明:无。

关联 Issue

Fixes #6601。实现遵循 @doudouOUC 在 issue 上给出的设计(狭窄的内部密钥 denylist,应用于 shell/monitor/MCP-stdio spawn 点,第三方凭证保留继承)。

…d env

Shell subprocesses (and the monitor tool and stdio MCP servers) inherited
the full daemon process.env, including QWEN_SERVER_TOKEN (the serve-daemon
bearer credential), so an agent-run command like printenv QWEN_SERVER_TOKEN
could read an internal secret. Add a shared sanitizeChildEnv() that removes
Qwen-internal daemon/server tokens (QWEN_SERVER_TOKEN, QWEN_DAEMON_TOKEN)
before spawning, and apply it at the shell child_process + PTY paths,
monitor.ts, and the mcp-client stdio transport.

The denylist is deliberately narrow: it does NOT strip third-party
credentials (GH_TOKEN, AWS_*, NPM_TOKEN, ...) that real shell workflows
legitimately inherit -- only Qwen-internal secrets. Exported from the
package root so the desktop denylists can consolidate onto it later.

Fixes QwenLM#6601.
@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

Qwen precheck requires maintainer approval before automated triage/review.

Head SHA: 2f2d10f2f38b86e09aac6eda6a97d0e22ca01e01

Reason:

  • prompt_injection:run_gh

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.

@wenshao

wenshao commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Local verification report

Built and ran this PR locally, driving the real ShellExecutionService / createTransport / HookRunner against real spawned child processes — not the mocked spawns the PR's own tests use. A/B is merge-base 0764276 vs PR head 278aa21d, same build, same machine.

Platform: Linux (x86_64, node v22.22.2, vitest 3.2.4). The PR description marks Linux N/A, so this adds the missing Linux coverage.

Verdict: the fix does what it claims, and the design call is the right one. I'd merge it. Four follow-up notes below, one of which I'd like to see addressed before or right after merge (§1).


1. Does it actually fix the leak? Yes — on every path it touches

I exported all seven vars into the parent process, then let the service spawn a real child that reports its own environment.

shell child env A/B

Both spawn paths behave identically, before and after:

QWEN_SERVER_TOKEN QWEN_DAEMON_TOKEN GH_TOKEN / GITHUB_TOKEN / AWS_ACCESS_KEY_ID / NPM_TOKEN / OPENAI_API_KEY PATH QWEN_CODE=1
merge-base leaked leaked inherited ok ok
PR head stripped stripped inherited ok ok

The stdio-MCP path (mcp-client.ts:2152) behaves the same — verified by spawning a real MCP child that dumps its env (screenshot §4, panel A). The monitor.ts site I verified at the source + emitted-dist level rather than by driving the tool.

The "don't strip third-party credentials" decision holds up in practice: gh, the AWS CLI and npm publish still see everything they need. Given the shell tool exists to run arbitrary user commands, I agree this is the correct scope — a broader denylist would break real workflows for no gain against an attacker who can already run commands.

Also worth noting: sanitizeChildEnv returns a fresh copy, so replacing { ...process.env } with sanitizeChildEnv(process.env) in mcp-client.ts preserves the no-aliasing property that line was relying on.

2. Are the new tests real guardrails? Partly

I reverted only the fix call sites and re-ran the PR's own suites.

test guardrail A/B

  • 320/320 green as submitted — matches the description exactly (6 + 132 + 80 + 102).
  • Revert shellExecutionService.ts2 failures. Those two tests are genuine guardrails.
  • Revert monitor.ts and mcp-client.tsstill 182/182 green.

So two of the four patched spawn sites have no regression coverage at all. A future refactor can silently drop either sanitizeChildEnv wrap and CI stays green. Two assertions in the shape of the existing shell ones would close this cheaply.

npx eslint on all six changed files: clean. packages/core builds clean.

3. Threat model — worth stating precisely

I checked how the token gets into the daemon's env in the first place: no production code writes QWEN_SERVER_TOKEN/QWEN_DAEMON_TOKEN into process.env (only tests do). It arrives because the operator exports it, which is exactly what the serve docs instruct. And run-qwen-serve.ts — the process that actually spawns agent children — never scrubs its own process.env (scrubDaemonWorkerEnv() lives in channel/daemon-worker.ts:655, a different process).

So the realistic scenario is: operator sets the documented env var, starts qwen serve, and every agent-run command inherits the daemon's bearer token for the process lifetime. That's a real gap and this PR closes it for the shell/monitor/MCP paths.

4. What the PR leaves open

other spawn paths

(a) hooks/hookRunner.ts:586 still leaks both tokens — with this PR applied. Same package, same { ...process.env, ... } shape, and command hooks fire automatically on tool events with no user confirmation. This looks like the same bug at a site the PR didn't enumerate; a fifth one-line wrap would cover it. Two others in the same family: tools/tool-registry.ts:63/:568 (spawn() with no env at all) and tools/computer-use/client.ts:149 (a second stdio-MCP transport, explicitly opting into full inheritance).

(b) serve/routes/a2ui-action.ts:45 has the identical hole this PR is fixing elsewhere — its SCRUBBED_STDIO_ENV_KEYS contains only QWEN_SERVER_TOKEN, not QWEN_DAEMON_TOKEN. Since the two hold the same secret value (channel-worker-supervisor.ts:348-351 copies the daemon bearer verbatim into QWEN_DAEMON_TOKEN), that set is missing exactly the var this PR added. Related: acp-bridge/src/spawnChannel.ts:269 is a third partial list, and it includes QWEN_CODE_SIMPLE, which the new shared list drops. Good candidates for the consolidation the PR already flags as follow-up.

(c) The denylist is exact-case. On Windows env keys are case-insensitive, but { ...env } produces a plain object, so delete sanitized['QWEN_SERVER_TOKEN'] won't match a Qwen_Server_Token key (panel D). Niche, but this code runs inside normalizePathEnvForWindows(...) — a helper that exists precisely because this repo already hit env-key casing problems on Windows. A case-insensitive delete would be a two-line change. I could not test on Windows.

(d) Defense-in-depth, not a boundary — and the PR is right to say so. With the fix applied, the sanitized child still recovers the token from /proc/<parent>/environ on Linux (panel C), since it runs as the same uid. Worth keeping in mind when closing #6601: this raises the bar meaningfully, but it doesn't make the token unreachable from an agent-run command. The PR body's "defense-in-depth" framing is accurate and shouldn't be upgraded to "leak closed".

Nit

The test plan references src/utils/sanitizeChildEnv.test.ts; the actual file is src/utils/sanitize-child-env.test.ts.


Summary: correct, narrowly-scoped, no regressions, verified on Linux against real child processes. §1 (hookRunner) is the one I'd want tracked — it's the same defect the PR is fixing, one file away. §2 (missing coverage on two of four sites) is cheap insurance. The rest can be follow-ups.

中文版

本地验证报告

我在本地构建并运行了本 PR,用真实的子进程驱动 ShellExecutionService / createTransport / HookRunner——而非 PR 自带测试所使用的 mock spawn。A/B 对比为 merge-base 0764276PR head 278aa21d,同一构建、同一机器。

平台:Linux(x86_64,node v22.22.2,vitest 3.2.4)。PR 描述中 Linux 标为 N/A,本报告补上这块覆盖。

结论:修复确实达成了它声称的效果,设计取舍也是对的。我倾向合并。 下面有四点后续建议,其中 §1 希望在合并前后尽快处理。


1. 真的修好了吗?在它改动的每条路径上都修好了

我把七个变量导出到父进程,再让 service 真实 spawn 一个子进程来报告自身环境。

两条 spawn 路径的前后表现完全一致:

QWEN_SERVER_TOKEN QWEN_DAEMON_TOKEN GH_TOKEN / GITHUB_TOKEN / AWS_ACCESS_KEY_ID / NPM_TOKEN / OPENAI_API_KEY PATH QWEN_CODE=1
merge-base 泄露 泄露 继承 正常 正常
PR head 已剥离 已剥离 继承 正常 正常

stdio-MCP 路径(mcp-client.ts:2152)表现相同——通过真实 spawn 一个会转储自身 env 的 MCP 子进程验证(截图 §4,面板 A)。monitor.ts 这处我是在源码与产物 dist 层面确认的,未实际驱动该工具。

「不剥离第三方凭证」的决策在实践中站得住:gh、AWS CLI、npm publish 仍能拿到所需的一切。鉴于 shell 工具的存在意义就是执行任意用户命令,我认同这个范围——更宽的 denylist 会破坏真实工作流,而对一个已经能执行命令的攻击者并无收益。

另外:sanitizeChildEnv 返回的是全新副本,因此在 mcp-client.ts 中用 sanitizeChildEnv(process.env) 替换 { ...process.env } 保留了该行原本依赖的「不与 process.env 别名共享」性质。

2. 新增测试是真正的护栏吗?只有一半是

我仅回退修复的调用点,再跑 PR 自己的测试套件:

  • 按提交状态 320/320 全绿——与描述完全吻合(6 + 132 + 80 + 102)。
  • 回退 shellExecutionService.ts2 个失败。这两个测试是真护栏。
  • 同时回退 monitor.tsmcp-client.ts仍然 182/182 全绿

也就是说,四个被修补的 spawn 点中有两个完全没有回归覆盖。日后重构可以悄悄去掉任一处 sanitizeChildEnv 包装而 CI 仍然全绿。仿照现有 shell 测试补两条断言即可低成本补上。

对六个改动文件跑 npx eslint:干净。packages/core 构建干净。

3. 威胁模型——值得说准确

我核查了 token 最初是如何进入守护进程 env 的:没有任何生产代码把 QWEN_SERVER_TOKEN/QWEN_DAEMON_TOKEN 写入 process.env(只有测试会)。它之所以存在,是因为运维按 serve 文档的指引导出了它。而真正 spawn agent 子进程的 run-qwen-serve.ts 从不清理自己的 process.envscrubDaemonWorkerEnv() 位于 channel/daemon-worker.ts:655,属于另一个进程)。

因此现实场景是:运维设置了文档要求的环境变量,启动 qwen serve,于是在该进程生命周期内,每一条 agent 执行的命令都继承了守护进程的 bearer token。这是真实存在的缺口,本 PR 为 shell/monitor/MCP 路径关闭了它。

4. 本 PR 未覆盖的部分

(a) hooks/hookRunner.ts:586 在本 PR 生效后仍然泄露两个 token。 同一个包、同样的 { ...process.env, ... } 形态,而且 command hook 会在工具事件上自动触发、无需用户确认。这看起来就是同一个 bug 出现在 PR 未列举的位置;再加一行包装即可覆盖。同族的还有两处:tools/tool-registry.ts:63/:568spawn() 完全没有 env 参数)与 tools/computer-use/client.ts:149第二条 stdio-MCP 传输,显式选择完整继承)。

(b) serve/routes/a2ui-action.ts:45 存在与本 PR 正在修复的完全相同的漏洞——它的 SCRUBBED_STDIO_ENV_KEYS 只含 QWEN_SERVER_TOKEN,不含 QWEN_DAEMON_TOKEN。由于两者承载同一个密钥值channel-worker-supervisor.ts:348-351 把守护 bearer 原样复制进 QWEN_DAEMON_TOKEN),该集合恰好缺了本 PR 新增的那个变量。相关地,acp-bridge/src/spawnChannel.ts:269 是第三份局部列表,且它包含新共享列表所遗漏的 QWEN_CODE_SIMPLE。这些都是 PR 已标注为后续项的「收敛」工作的好候选。

(c) denylist 区分大小写。 Windows 上环境变量名不区分大小写,但 { ...env } 产生的是普通对象,因此 delete sanitized['QWEN_SERVER_TOKEN'] 匹配不到 Qwen_Server_Token 这样的键(面板 D)。虽属边角,但这段代码正运行在 normalizePathEnvForWindows(...) 内部——而该 helper 的存在恰恰是因为本仓库已经在 Windows 上踩过环境变量大小写的坑。改成大小写不敏感的删除只需两行。我无法在 Windows 上实测。

(d) 这是纵深防御而非安全边界——PR 这样表述是对的。 修复生效后,被净化的子进程仍可在 Linux 上从 /proc/<父进程>/environ 取回 token(面板 C),因为二者同 uid 运行。在关闭 #6601 时值得留意:本 PR 显著抬高了门槛,但并未让该 token 对 agent 执行的命令不可达。PR 描述中「纵深防御」的措辞是准确的,不宜升级为「已封堵泄露」。

小问题

测试计划中写的是 src/utils/sanitizeChildEnv.test.ts,实际文件名为 src/utils/sanitize-child-env.test.ts


总结: 修复正确、范围克制、无回归,并已在 Linux 上针对真实子进程验证。§1(hookRunner)是我最希望被跟踪的一点——同样的缺陷,就在隔壁文件。§2(四个点里两个缺覆盖)是廉价的保险。其余可作为后续项。

@chinesepowered

Copy link
Copy Markdown
Contributor Author

Thank you for the exceptionally thorough Linux verification against real spawned children — the A/B on all four paths and the /proc/<parent>/environ note are exactly the right lens. Addressed the two things you flagged for this PR:

§2 — coverage on the two uncovered spawn sites (done, pushed). Added a guardrail test at each site, in the shape of the existing shell ones:

  • monitor.test.ts → asserts the spawned monitor's env has neither QWEN_SERVER_TOKEN nor QWEN_DAEMON_TOKEN, while PATH and the QWEN_CODE=1 marker survive.
  • mcp-client.test.ts → asserts the stdio transport's env strips both internal tokens while keeping GH_TOKEN.

Both proven as real guardrails the same way you did it — reverting just the sanitizeChildEnv wrap at each site fails the corresponding new test; restoring makes them green. Full files: monitor 81/81, mcp-client 103/103. New head is 92e7036.

Nit — test-plan filename. Fixed in the PR body: src/utils/sanitizeChildEnv.test.tssrc/utils/sanitize-child-env.test.ts.

On the rest, all correct and I'll track them as follow-ups rather than widen this PR:

  • §1 hooks/hookRunner.ts:586 — agreed it's the same defect one file over, and command hooks firing without user confirmation makes it arguably the sharper one. It's a natural fast-follow because the one-line fix imports the sanitizeChildEnv helper this PR introduces — so I'll open it immediately once this lands.
  • §4(b) a2ui-action.ts SCRUBBED_STDIO_ENV_KEYS missing QWEN_DAEMON_TOKEN, plus the tool-registry.ts / computer-use/client.ts sites and the three partial denylists (spawnChannel.ts dropping QWEN_CODE_SIMPLE) — these are the consolidation the PR already flags; I'd do them as a focused follow-up that unifies onto the shared list, so the QWEN_DAEMON_TOKEN gap and the case-insensitive-delete point (§4c) get fixed in one reviewable place rather than scattered here.
  • §4(d) — fully agree; keeping the PR's "defense-in-depth" framing as-is, not upgrading to "leak closed."

@wenshao

wenshao commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Review

Overview

Adds a shared sanitizeChildEnv() that strips the two Qwen-internal bearer tokens (QWEN_SERVER_TOKEN, QWEN_DAEMON_TOKEN) from the env handed to agent-spawned children, applied at four spawn sites: the shell tool's PTY and child_process paths, the monitor tool, and the core stdio MCP transport. The scope matches the design agreed on #6601 (narrow internal-secrets denylist; third-party credentials like GH_TOKEN/AWS_*/NPM_TOKEN deliberately stay inherited).

Overall: correct, well-scoped, and well-tested at the right observable (the actual spawn-call env). One test-hygiene issue worth fixing before merge, and two spawn sites in the same trust class that this PR leaves uncovered — one of which was listed in the issue's own analysis.

Verified

  • sanitizeChildEnv returns a fresh shallow copy and doesn't mutate its input; ordering with normalizePathEnvForWindows is fine (on win32 it copies again; on other platforms it returns the same object, which is already a private copy).
  • The mcp-client.ts change preserves the previous { ...process.env } copy semantics, and mcpServerConfig.env still spreads after sanitization, so a server config can deliberately re-provide a var — a reasonable explicit opt-in.
  • Nothing downstream re-introduces the tokens: getShellContextEnvVars() only injects session/agent/prompt/trace IDs, and the QWEN_CODE/TERM/pager overlays are unrelated.
  • No legitimate child depends on inheriting these vars: the channel worker receives QWEN_DAEMON_TOKEN explicitly (channel-worker-supervisor.ts builds its own env and deletes both tokens first), the daemon worker scrubs both from its own process.env after reading them, and env-snapshot.ts only reports presence. So stripping is behavior-safe.
  • Denylist completeness: the two entries are exactly the internal bearer tokens defined in packages/cli/src/serve/channel-worker-env.ts; I didn't find a third.
  • All four touched suites pass at the PR head (fresh npm ci worktree): sanitize-child-env 6/6, shellExecutionService 132/132, monitor 81/81, mcp-client 103/103 — 322 total.
  • A/B check: reverting just the two shellExecutionService.ts fix lines (back to normalizePathEnvForWindows(process.env)) makes exactly the two new regression tests fail and nothing else; restoring the fix goes back to green. The tests genuinely pin the behavior.

Should fix

  1. shellExecutionService.test.ts — the two new tests pollute process.env for every later test in the file. The file's restore pattern is by reference: beforeEach saves originalProcessEnv = process.env and afterEach does process.env = originalProcessEnv. The new tests mutate keys in place (process.env['QWEN_SERVER_TOKEN'] = 'serve-secret', process.env['PATH'] = '/usr/bin', …), so the mutations land on the saved object and survive the restore — after these tests run, PATH=/usr/bin, GH_TOKEN=gh-abc, and both fake tokens persist in the real env for the rest of the worker process. It happens to be benign today (everything is mocked and the sanitizer strips the tokens), but it's order-dependent fragility, and the file already has a convention for this — replacement, as in setupConflictingPathEnv:

    process.env = {
      ...originalProcessEnv,
      QWEN_SERVER_TOKEN: 'serve-secret',
      QWEN_DAEMON_TOKEN: 'daemon-secret',
      GH_TOKEN: 'gh-abc',
      PATH: '/usr/bin',
    };

    (The monitor test does this correctly with try/finally, and the mcp-client test already uses the replacement pattern — it's only the two shell tests.)

Follow-up gaps (non-blocking, but worth deciding on the record)

  1. tool-registry.ts still leaks both tokens. The issue's stage-2 analysis listed five affected paths including this one; the PR covers four. Both spawn(callCommand, [this.toolName]) (~L63) and the discovery spawn (~L568) pass no env option, so the child inherits the full process.env implicitly. These run user-configured toolCallCommand/toolDiscoveryCommand — the same trust class as a stdio MCP server. The fix is the same one-liner ({ env: sanitizeChildEnv(process.env) }). If it's deliberately deferred, the PR text should say so — the current "the other child-process spawn paths already sanitize; the shell/monitor/stdio-MCP paths were the outliers" isn't accurate for this path.

  2. hooks/hookRunner.ts (~L587) spreads full process.env into user-configured hook commands. Hooks run automatically on tool events and are configurable at workspace scope, so this is arguably closer to the MCP-stdio trust class than the shell tool is. Outside the scope the maintainer enumerated, so fine as a follow-up — flagging so it doesn't get lost.

    (The remaining env-inheriting spawns — computer-use client, git worktree service, browser launcher, file-search crawler — launch Qwen's own infrastructure or trusted binaries; leaving them is reasonable.)

Nits

  • The INTERNAL_SECRET_ENV_VARS strings duplicate the canonical QWEN_SERVER_TOKEN_ENV/QWEN_DAEMON_TOKEN_ENV constants in packages/cli/src/serve/channel-worker-env.ts. The dependency direction (cli → core) means core can't import them today, but the suggested follow-up consolidation could move the canonical constants into core so cli/acp-bridge/desktop all reference one source of truth.
  • The guardrail test pinning the denylist to exactly two entries is a nice touch — it turns any future scope creep into an explicit, reviewable test change.

…tion tests

The file restores process.env by reference in afterEach, so in-place key
mutations leaked into later tests. Use the replacement pattern already used
by setupConflictingPathEnv.
@chinesepowered

Copy link
Copy Markdown
Contributor Author

Thanks for the second pass and the completeness check on the denylist. Addressed both actionable points:

Should-fix — process.env pollution in the two shell tests (done, 2f2d10f). Switched both from in-place mutation to the replacement pattern (process.env = { ...originalProcessEnv, … }), matching setupConflictingPathEnv. Full file still 132/132; the leak into later tests is gone.

Follow-up gap #2 — PR text accuracy. You're right, the "other spawn paths already sanitize" line was wrong for tool-registry.ts. Corrected the description: it now states the PR covers the four #6601-agreed sites and explicitly lists tools/tool-registry.ts (tool-call/discovery spawns with no env) and hooks/hookRunner.ts (command hooks) as same-trust-class paths intentionally deferred to a follow-up, rather than implying they're already covered.

On the deferred paths themselves: hookRunner.ts (§3) is the one I'll open right after this merges — its fix imports the sanitizeChildEnv helper this PR introduces, so it's naturally a fast-follow. I'll fold tool-registry.ts (both spawn sites) into that same follow-up since it's the identical one-liner and the same trust class. The channel-worker-env.ts constant-consolidation nit (moving the canonical QWEN_*_TOKEN_ENV into core so cli/acp-bridge/desktop share one source) I'll keep as the separate consolidation PR the body already flags, to avoid widening the follow-up into a cross-package refactor.

Nothing else changed at the four covered sites — the fix commits are untouched, so your A/B verification of the head still holds.

@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.

⚠️ Downgraded from Approve to Comment: CI failing: review-pr, review-config. Reviewed.

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

✅ Local verification — real build & tests

I built and ran this PR from source in an isolated worktree off the PR head (2f2d10f) on Linux / Node v22.22 / vitest 3.2.4. Beyond re-running the suites in the test plan, I added a RED/GREEN negative control and a real (nothing-mocked) child-process E2E so the evidence discriminates the fix rather than just showing "green is green". Verdict: verified — the fix does exactly what it claims, and reverting it reproduces the leak.

1 · Unit suites from the PR test plan — all green

Suite Result
utils/sanitize-child-env.test.ts 6 / 6
services/shellExecutionService.test.ts 132 / 132
tools/monitor.test.ts 81 / 81
tools/mcp-client.test.ts 103 / 103
Total 322 / 322

2 · RED negative control — proves the tests catch the bug

Surgically reverting just the fix (sed 's/sanitizeChildEnv(process.env)/process.env/' on the three spawn-site files) makes all four spawn-site sanitization tests fail with expected 'serve-secret' to be undefined, while the util's own 6 tests still pass — i.e. the tests genuinely discriminate the change.

verification summary

3 · Real child-process E2E — the actual leak, and its fix

The unit tests assert against a mocked spawn. To confirm real behavior I drove ShellExecutionService.execute() with nothing mocked — a genuine shell is spawned and the agent-run command echoes the env it inherited. Both the PTY and child_process paths behaved identically:

  • Before (fix reverted): the command reads back the live daemon tokens — SERVER=[LEAKED_SERVER_TOKEN_…], DAEMON=[LEAKED_DAEMON_TOKEN_…].
  • After (this PR): SERVER=[] / DAEMON=[], while GH_TOKEN, QWEN_CODE=1, and PATH are all preserved.

real child-process before/after

4 · CI gates on the changed files

  • eslint --max-warnings 0 → exit 0 (all 6 changed files)
  • tsc --noEmit --strict on the new util → exit 0

Minor note for the author (non-blocking)

The test plan lists monitor.test.ts as 80/80 and mcp-client.test.ts as 102/102 "unchanged", but the 2nd commit adds a sanitization test to each, so the real counts are 81 and 103. Just a stale number in the description — the added coverage is correct and welcome.

The deferred scope (tools/tool-registry.ts, hooks/hookRunner.ts) is clearly called out in the PR and is a reasonable follow-up; the narrow internal-only denylist with the guardrail test is the right call. LGTM from my side.

中文说明

✅ 本地验证 —— 真实构建与测试

我在独立 worktree 中基于 PR head(2f2d10f)从源码构建并运行了本 PR,环境为 Linux / Node v22.22 / vitest 3.2.4。除了复跑测试计划中的套件外,我还补做了 RED/GREEN 反向对照 和一个 真实(无任何 mock)的子进程 E2E,以便证据能够真正区分「是否修复」,而非仅仅「绿就是绿」。结论:已验证 —— 修复完全符合其声明,且回退修复即可复现泄露。

1 · 测试计划中的单测套件 —— 全部通过

套件 结果
utils/sanitize-child-env.test.ts 6 / 6
services/shellExecutionService.test.ts 132 / 132
tools/monitor.test.ts 81 / 81
tools/mcp-client.test.ts 103 / 103
合计 322 / 322

2 · RED 反向对照 —— 证明测试确实能抓到该缺陷

仅对修复做外科式回退(对三个 spawn 站点文件执行 sed 's/sanitizeChildEnv(process.env)/process.env/')后,四个 spawn 站点的清理测试全部失败,报错均为 expected 'serve-secret' to be undefined;而 util 自身的 6 个测试仍然通过 —— 即这些测试确实能区分本次改动,而非恒绿。(见上方第一张截图)

3 · 真实子进程 E2E —— 真实的泄露与其修复

单测断言的是被 mock 的 spawn。为确认真实行为,我以 完全不 mock 的方式驱动了 ShellExecutionService.execute() —— 真正 spawn 一个 shell,由 agent 执行的命令回显它继承到的环境变量。PTY 路径与 child_process 路径表现一致:

  • 修复前(回退修复): 命令能读回存活的守护 token —— SERVER=[LEAKED_SERVER_TOKEN_…]DAEMON=[LEAKED_DAEMON_TOKEN_…]
  • 修复后(本 PR): SERVER=[] / DAEMON=[],同时 GH_TOKENQWEN_CODE=1PATH 均被保留。

(见上方第二张截图)

4 · 改动文件上的 CI 关卡

  • eslint --max-warnings 0 → 退出码 0(全部 6 个改动文件)
  • 对新 util 执行 tsc --noEmit --strict → 退出码 0

给作者的小提示(不阻塞合并)

测试计划将 monitor.test.ts 记为 80/80mcp-client.test.ts 记为 102/102 且标注「unchanged」,但第 2 个 commit 实际上给二者各新增了一个清理测试,因此真实数量是 81103。只是描述里的数字略有陈旧 —— 新增覆盖本身是正确且值得肯定的。

被推迟的范围(tools/tool-registry.tshooks/hookRunner.ts)在 PR 中已明确说明,作为后续跟进是合理的;采用狭窄的「仅内部密钥」denylist 并配合护栏测试是正确的取舍。在我这边 LGTM。


🤖 Generated with Claude Code — Claude Opus 4.8 (1M context)

@wenshao

wenshao commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓ — all required sections present, bilingual, with a clear reviewer test plan.

Problem: Observed and confirmed. Issue #6601 is a P1 security bug with a clear reproduction — printenv QWEN_SERVER_TOKEN in an agent-run shell command leaks the serve-daemon bearer token. The daemon worker already scrubs both tokens from its own process.env, but the four spawn sites in core (shell PTY, shell child_process, monitor, stdio MCP) still pass the full env through. This is a real credential-exposure gap, not theoretical hardening.

Direction: Aligned. Stripping Qwen-internal daemon secrets from child process env is a defense-in-depth measure that follows the same pattern already used by the daemon worker (daemon-worker.ts deletes both tokens) and the desktop sandbox (BLOCKED_ENV_VARS). The narrow denylist (only QWEN_SERVER_TOKEN and QWEN_DAEMON_TOKEN) is the right call — third-party creds like GH_TOKEN and AWS_* are legitimately needed by shell workflows.

Size: 57 production lines (5 files) + 187 test lines (4 files) = 244 total. Well within bounds. Core paths touched (packages/core/src/**), but the change is a focused security fix, not a structural refactor.

Approach: The scope feels right. One shared utility (sanitizeChildEnv) + four one-line wraps at the spawn sites is the minimal change that solves the problem. The deferred sites (tool-registry.ts, hookRunner.ts) are documented and reasonable to split out. The guardrail test pinning the denylist to exactly two entries is a nice touch — prevents silent scope creep. No unrelated changes or drive-by refactors in the diff.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓——所有必填段落齐全,双语,附有清晰的复核测试计划。

问题: 已观测并确认。Issue #6601 是一个 P1 安全 bug,有明确复现——在 agent 执行的 shell 命令中运行 printenv QWEN_SERVER_TOKEN 会泄露 serve 守护进程的 bearer token。守护 worker 已在其自身 process.env 中清除两个 token,但 core 中的四个 spawn 点(shell PTY、shell child_process、monitor、stdio MCP)仍然透传完整 env。这是真实的凭证暴露缺口,而非理论性加固。

方向: 对齐。从子进程 env 中剥离 Qwen 内部守护密钥是纵深防御措施,遵循守护 worker(daemon-worker.ts 删除两个 token)和桌面端沙箱(BLOCKED_ENV_VARS)已有的相同模式。狭窄的 denylist(仅 QWEN_SERVER_TOKENQWEN_DAEMON_TOKEN)是正确选择——GH_TOKENAWS_* 等第三方凭证是 shell 工作流正常所需的。

规模: 57 行生产代码(5 个文件)+ 187 行测试代码(4 个文件)= 共 244 行。远在限制之内。触及核心路径(packages/core/src/**),但改动是聚焦的安全修复,非结构性重构。

方案: 范围合理。一个共享工具函数(sanitizeChildEnv)+ 四处一行包装是最小的解决方案。延迟处理的站点(tool-registry.tshookRunner.ts)已记录在案,拆分合理。将 denylist 固定为恰好两个条目的护栏测试是亮点——防止静默范围扩张。diff 中无无关改动或顺手重构。

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

Reviewed at 2f2d10f2f38b86e09aac6eda6a97d0e22ca01e01 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal: given "shell subprocesses inherit QWEN_SERVER_TOKEN/QWEN_DAEMON_TOKEN", I'd create a small shared utility in packages/core/src/utils/ that strips exactly those two vars from a shallow copy of process.env, wrap it at the four spawn sites (shell PTY, shell child_process, monitor, stdio MCP), export it from the package root, and add unit + regression tests. Keep the denylist narrow — no third-party creds.

Comparison with the diff: the PR matches this proposal almost line-for-line. The implementation is clean and minimal:

  • sanitize-child-env.ts (45 lines): well-documented, exports both the denylist constant and the function. Shallow-copies the input, deletes the two keys, returns the copy. No mutation of the source.
  • Four one-line wraps at the spawn sites — each replaces process.env (or { ...process.env }) with sanitizeChildEnv(process.env) in the existing env spread. The mcp-client.ts change is equivalent ({ ...process.env }sanitizeChildEnv(process.env) — both shallow-copy, plus sanitization).
  • Exported from index.ts in alphabetical order, following the existing pattern.
  • Tests: 6 utility tests (strips secrets, preserves third-party creds, no mutation, fresh object, no-op, guardrail pinning the denylist to exactly 2 entries) + 4 regression tests at the spawn sites (PTY, child_process, monitor, MCP stdio).

No critical blockers. No AGENTS.md violations. File naming (kebab-case.ts), colocated tests, ESM imports — all follow conventions. The guardrail test that pins INTERNAL_SECRET_ENV_VARS to exactly ['QWEN_DAEMON_TOKEN', 'QWEN_SERVER_TOKEN'] is a good safeguard against silent scope creep.

Unit tests (all pass on the PR branch):

Suite Result
sanitize-child-env.test.ts 6/6 ✅
shellExecutionService.test.ts 132/132 ✅
monitor.test.ts 81/81 ✅
mcp-client.test.ts 103/103 ✅

Real-Scenario Testing

Set QWEN_SERVER_TOKEN=test-secret-token-12345 and QWEN_DAEMON_TOKEN=test-daemon-secret-67890 in the environment, then asked the agent to run printenv QWEN_SERVER_TOKEN.

Before (installed qwen v0.20.0 — without fix)

$ qwen -p 'Run this exact shell command and show me the raw output: printenv QWEN_SERVER_TOKEN' --yolo

Raw output:

test-secret-token-12345

⚠️  Note: this is a secret token now visible in the conversation transcript.
If this session is logged or shared, consider rotating the token.

The daemon bearer token leaks to the agent-run command.

After (this PR via npm run dev — with fix)

$ npm run dev -- -p 'Run this exact shell command and show me the raw output: printenv QWEN_SERVER_TOKEN' --yolo

`QWEN_SERVER_TOKEN` is not set in the current environment — `printenv` returned
empty output with exit code 1, which indicates the variable doesn't exist.

The token is stripped — printenv finds nothing.

The fix works as advertised: internal daemon secrets are removed from the child env while the shell command still runs normally.

中文说明

代码审查

独立方案: 针对"shell 子进程继承 QWEN_SERVER_TOKEN/QWEN_DAEMON_TOKEN"的问题,我会在 packages/core/src/utils/ 创建一个小的共享工具函数,从 process.env 的浅拷贝中精确剥离这两个变量,在四个 spawn 点(shell PTY、shell child_process、monitor、stdio MCP)包装它,从包根导出,并添加单元测试和回归测试。denylist 保持狭窄——不剥离第三方凭证。

与 diff 的比较: PR 几乎逐行匹配此方案。实现干净且最小化:

  • sanitize-child-env.ts(45 行):文档完善,导出 denylist 常量和函数。浅拷贝输入,删除两个 key,返回拷贝。不修改源对象。
  • 四处一行包装——每处将 env 展开中的 process.env(或 { ...process.env })替换为 sanitizeChildEnv(process.env)
  • 按字母顺序从 index.ts 导出,遵循既有模式。
  • 测试:6 个工具函数测试 + 4 个 spawn 点回归测试。

无关键阻塞项。无 AGENTS.md 违规。文件命名、共置测试、ESM 导入均符合规范。将 denylist 固定为恰好两个条目的护栏测试是防止静默范围扩张的良好保障。

单元测试(PR 分支全部通过): 共 322 个测试通过。

真实场景测试

在环境中设置 QWEN_SERVER_TOKEN=test-secret-token-12345,然后让 agent 执行 printenv QWEN_SERVER_TOKEN

  • 修复前(已安装 qwen v0.20.0):token 泄露到 agent 执行的命令中 ❌
  • 修复后(本 PR 通过 npm run dev):token 被剥离,printenv 找不到任何内容 ✅

修复按预期工作:内部守护密钥从子进程 env 中移除,shell 命令仍正常运行。

Qwen Code · qwen3.8-max-preview

Reviewed at 2f2d10f2f38b86e09aac6eda6a97d0e22ca01e01 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 5/5 — clean security fix, verified end-to-end, would merge without hesitation.

This is exactly the kind of PR you want to see for a P1 credential-exposure bug: narrow scope, clear reproduction, minimal code, comprehensive tests, and a before/after that proves it works. The sanitizeChildEnv utility is 15 lines of logic, the four spawn-site wraps are one line each, and the guardrail test pins the denylist so it can't silently grow. The deferred sites (tool-registry.ts, hookRunner.ts) are documented and reasonable to split out.

The before/after tmux test confirms the fix: printenv QWEN_SERVER_TOKEN leaks the bearer token on v0.20.0, and returns empty on the PR branch. All 322 unit tests pass. No regressions in the four touched spawn paths.

If I had to maintain this in six months, I'd thank the author — the code is self-documenting, the denylist is pinned, and the utility is reusable for the follow-up sites.

中文说明

置信度:5/5 ——干净的安全修复,端到端验证通过,毫不犹豫地合并。

这正是 P1 凭证暴露 bug 应有的 PR 样子:范围窄、复现清晰、代码最小化、测试全面、before/after 证明有效。sanitizeChildEnv 工具函数仅 15 行逻辑,四处 spawn 点包装各一行,护栏测试固定 denylist 防止静默扩张。延迟处理的站点已记录在案,拆分合理。

before/after tmux 测试确认修复有效:v0.20.0 上 printenv QWEN_SERVER_TOKEN 泄露 bearer token,PR 分支上返回空。全部 322 个单元测试通过,四处受影响 spawn 路径无回归。

Qwen Code · qwen3.8-max-preview

Reviewed at 2f2d10f2f38b86e09aac6eda6a97d0e22ca01e01 · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot 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.

LGTM, looks ready to ship. ✅

@wenshao
wenshao added this pull request to the merge queue Jul 22, 2026
Merged via the queue into QwenLM:main with commit 760ffd7 Jul 22, 2026
65 of 67 checks passed
@chinesepowered

Copy link
Copy Markdown
Contributor Author

Thanks for the merge! As promised, the follow-up is up: #7527 routes the three remaining agent-launched child processes through sanitizeChildEnvhooks/hookRunner.ts (spreads ...process.env) and the two tools/tool-registry.ts spawns for the tool-call and tool-discovery commands, which pass no env option at all and so inherit implicitly. Same narrow denylist, no changes to sanitizeChildEnv itself, with fail-before/pass-after tests on both files.

chiga0 pushed a commit that referenced this pull request Jul 23, 2026
…d env (#7256)

* fix(core): strip Qwen-internal daemon secrets from agent-spawned child env

Shell subprocesses (and the monitor tool and stdio MCP servers) inherited
the full daemon process.env, including QWEN_SERVER_TOKEN (the serve-daemon
bearer credential), so an agent-run command like printenv QWEN_SERVER_TOKEN
could read an internal secret. Add a shared sanitizeChildEnv() that removes
Qwen-internal daemon/server tokens (QWEN_SERVER_TOKEN, QWEN_DAEMON_TOKEN)
before spawning, and apply it at the shell child_process + PTY paths,
monitor.ts, and the mcp-client stdio transport.

The denylist is deliberately narrow: it does NOT strip third-party
credentials (GH_TOKEN, AWS_*, NPM_TOKEN, ...) that real shell workflows
legitimately inherit -- only Qwen-internal secrets. Exported from the
package root so the desktop denylists can consolidate onto it later.

Fixes #6601.

* test(core): cover daemon-secret stripping on monitor and mcp-client spawn sites

* test(core): replace process.env instead of mutating in shell sanitization tests

The file restores process.env by reference in afterEach, so in-place key
mutations leaked into later tests. Use the replacement pattern already used
by setupConflictingPathEnv.
yiliang114 added a commit to he-yufeng/qwen-code that referenced this pull request Jul 23, 2026
)

* fix(cli): correct queued message display style and ordering

Mid-turn steer messages (user input queued while the model is
responding) had two display bugs:

1. They rendered with notification styling (● icon) instead of
   user-input styling (> prefix) because accept() added them to
   UI history as MessageType.NOTIFICATION.

2. They appeared below the model's reply because accept() was
   only called in the finally block after the entire response
   stream completed, appending the user message after all model
   response items.

Fix: use MessageType.USER with sentToModel: true for steer
messages, and settle the steer input on the first stream event
(after the user-content push lands but before model-response
events are committed to UI history). Pass steer inputs through
to recursive sendMessageStream calls so all takeSteerInput paths
benefit from early settlement. Add a WeakSet guard to
settleSteerInput for idempotency across recursive invocations.

* test(core): add ordering test for early steer settlement

Verify that accept() is called after the first stream event is
pulled but before subsequent events reach the consumer, pinning
the settle-before-content timing that ensures queued user
messages render above the model's reply.

* fix(cli): use sentToModel: false for steer messages, address review

- Use sentToModel: false instead of true: steer messages are injected
  into an existing tool-result turn, not standalone user turns.
  sentToModel: true would make isRealUserTurn() count them as real
  turns, inflating the rewind turn index.
- Remove unnecessary as HistoryItemWithoutId cast.
- Add post-cleanup assertion in ordering test to verify the WeakSet
  guard prevents double-settlement.

* fix(cli): align resumed mid-turn steer display with live session (#7381)

Resume path now renders mid_turn_user_message as MessageType.USER with
sentToModel: false, matching the live-session styling. Add a comment
documenting the intentional sentToModel: false choice.

* fix(cli): exclude steer messages from user-turn filters (#7381)

Steer messages (sentToModel: false) were counted as real user turns by
five downstream consumers that filter on type === 'user' without checking
sentToModel, breaking cancel auto-restore, telemetry turn count, prompt
recall, away-recap thresholds, and resume collapse boundaries.

Add sentToModel !== false guards at each site.

* test(cli): add coverage for sentToModel !== false guards (#7381)

* test(cli): add coverage for sentToModel !== false guard in input-history filter (#7381)

* test(cli): add coverage for sentToModel !== false guard in YOLO turn-count telemetry (#7381)

* fix(cli): restore corrupted docs and classify steer items as synthetic (#7381)

* fix(docs): restore corrupted autogenerated input names in GitHub Action docs (#7381)

* fix(cli): deduplicate findLastUserItemIndex and add steerInput forwarding test (#7381)

* fix(cli): keep code-block copy numbering continuous across steer items (#7381)

* test(core): add Hook continuation steerInput forwarding test

Verify that steerInput is forwarded through the Stop-hook
continuation path and settled early on the first content event
of the continuation turn, matching the existing Steer
continuation coverage.

* fix(cli): sync selection test fixtures with ink FrameCell/ReadonlyFrame types (#7381)

* fix(core): align cron day wildcard semantics (#7464)

Co-authored-by: destire-mio <248462155+destire-mio@users.noreply.github.com>

* feat(core): keep completed background agents resident (#7426)

* feat(core): keep background agents resident

* fix(core): harden background continuation boundaries

* docs(core): move per-spawn cleanup comment to subagentDispose

The comment describing the per-spawn cleanup (which stays undefined on
the fork-resume path) had drifted above the launchModel declaration,
where it no longer applied and could mislead readers. Relocate it to the
subagentDispose assignment in the non-fork branch it actually documents.

* fix(core): close finishing window and release resident on error in background GOAL path

- Non-worktree GOAL completion drained the message queue but never called
  registry.beginFinishing(), unlike the worktree path. A send_message racing
  the terminal transition could be accepted (status still running,
  finishingAgents empty) and then orphaned by complete(). Call beginFinishing()
  after the empty drain to reject the racing message instead.
- The completion catch block never reset keepResident, so a throw from
  patchAgentMeta/registry.complete left the runtime resident but finalized as
  failed — a zombie that cleanupRuntime never reclaimed. Reset keepResident in
  the catch so the finally block disposes it.

---------

Co-authored-by: Claude <noreply@anthropic.com>

* ci(autofix): continue environment-specific fixes (#7444)

* ci(autofix): continue environment-specific fixes

* docs(autofix): align verification wording

* docs(autofix): require bundle before integration tests

* docs(autofix): scope surrogate verification rules

* docs(autofix): require focused tests before integration checks

* docs(autofix): clarify review verification guidance

* fix(acp-bridge): close prompt-terminal follow-ups from the PR #7400 self-review (#7453)

* fix(acp-bridge): close prompt-terminal follow-ups from PR #7400 self-review

Keep a removed RUNNING prompt visible to the teardown flush via a removed flag so its terminal still publishes when the session closes before the agent cooperates; gate broadcastTurnError's session turn-state mutation to running prompts; propagate the typed PromptDeadlineExceededError from the pre-dispatch abort check; document the deadline FIFO-release overlap trade-off, the trailing prompt_cancelled after flush, and the result.then/finally ordering invariant; route the dedup log to the debug channel; drop the prompt-deadline re-export that pulled the bridge into a leaf module.

Fixes #7451

* test(acp-bridge): cover promote-then-remove-then-settle duplicate completed guard (#7453)

---------

Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com>

* fix(core): strip Qwen-internal daemon secrets from agent-spawned child env (#7256)

* fix(core): strip Qwen-internal daemon secrets from agent-spawned child env

Shell subprocesses (and the monitor tool and stdio MCP servers) inherited
the full daemon process.env, including QWEN_SERVER_TOKEN (the serve-daemon
bearer credential), so an agent-run command like printenv QWEN_SERVER_TOKEN
could read an internal secret. Add a shared sanitizeChildEnv() that removes
Qwen-internal daemon/server tokens (QWEN_SERVER_TOKEN, QWEN_DAEMON_TOKEN)
before spawning, and apply it at the shell child_process + PTY paths,
monitor.ts, and the mcp-client stdio transport.

The denylist is deliberately narrow: it does NOT strip third-party
credentials (GH_TOKEN, AWS_*, NPM_TOKEN, ...) that real shell workflows
legitimately inherit -- only Qwen-internal secrets. Exported from the
package root so the desktop denylists can consolidate onto it later.

Fixes #6601.

* test(core): cover daemon-secret stripping on monitor and mcp-client spawn sites

* test(core): replace process.env instead of mutating in shell sanitization tests

The file restores process.env by reference in afterEach, so in-place key
mutations leaked into later tests. Use the replacement pattern already used
by setupConflictingPathEnv.

* docs(core): align JSDoc @param names with actual function signatures (#7492)

Fix 6 instances where JSDoc @param tags had drifted from their
corresponding function signatures — parameters were renamed, removed,
or undocumented over time but the doc blocks were not updated.

Closes #7446

* feat(serve): support forced MCP reconnects (#7488)

* feat(serve): support forced MCP reconnects

* test(serve): cover forced MCP reconnect options

---------

Co-authored-by: 克竟 <dingbingzhi.dbz@alibaba-inc.com>

* fix(cli): insert newline on Shift+Enter and stop streaming thinking-block flicker (#7397)

* fix(cli): re-push Kitty keyboard flags onto the alternate screen in VP mode

In VP mode the app renders on the alternate screen (`alternateScreen: true`),
but the Kitty keyboard progressive-enhancement flags were pushed only once at
startup on the main screen. The Kitty spec tracks these flags per screen
buffer, so the alternate screen's stack stays empty and the terminal never
reports modifiers: Shift+Enter arrives as a bare Enter (submit) or, when the
terminal emits an ESC-prefixed variant, as an orphaned Escape that trips the
empty-buffer double-Esc rewind prompt — so Shift+Enter can never insert a
newline in VP mode even on Kitty-capable terminals (e.g. cmux).

Re-push the flags onto the alternate screen right after Ink enters it (Ink
writes the enter-alt-screen sequence synchronously inside render(), so the
push is correctly ordered). Ink discards the alternate screen and its flag
stack on unmount, leaving the startup main-screen push balanced by the
existing disableKittyProtocol() on cleanup.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): stabilize streaming thinking block height to stop flicker

The pending "Thinking…" block renders the tail of the reasoning stream in a
content-sized box. As the model emits paragraph separators, a blank line
enters and leaves the tail window (and `trimEnd` drops trailing blanks), so the
visible line count oscillates and the block flickers 2→3→5 rows during
streaming.

Track the tallest height the block has reached for the current thought and
never render fewer rows than that (capped at the streaming window size),
padding at the top so the newest line stays pinned to the bottom. The tracker
resets when streaming ends or when the buffer shrinks (a new thought replaced
it), so height is monotonic within a thought without leaking across thoughts.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): decode xterm modifyOtherKeys Shift/Ctrl/Alt+Enter so it inserts a newline

Terminals such as Ghostty report Shift+Enter as the xterm modifyOtherKeys
sequence `ESC [ 27 ; <mods> ; <key> ~` (e.g. `ESC [ 27 ; 2 ; 13 ~`) when the
Kitty keyboard protocol is not negotiated — which is the default, since Kitty
detection does not always succeed. Two bugs kept this from inserting a newline:

1. The CSI-u parser read the leading `27` marker as the key code (matching the
   Escape key code 27) instead of the real key code in the third parameter, so
   with Kitty enabled Shift+Enter was mistaken for Escape and tripped the
   double-Esc rewind prompt.
2. The reassembly path that stitches readline's shredded CSI fragments back
   together was gated behind `kittyProtocolEnabled`, so with Kitty disabled the
   `ESC [ 27 ; 2 ;` head plus the stray `13~` tail leaked into the composer as
   literal text and no newline was inserted.

Decode the third parameter as the real key code for the `27;…~` form, and route
those sequences through the reassembly buffer even when Kitty is off (only the
`ESC [ 27` marker opts in, so keys readline already parses cleanly are
untouched). Shift/Ctrl/Alt+Enter now insert a newline in both VP and non-VP
mode regardless of Kitty negotiation.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): anchor VP viewport to the top until a conversation turn exists

On a fresh VP-mode session the virtualized list holds the banner plus startup
notices (tips / MOTD / info), so it is longer than one item. Keying the initial
scroll anchor off list length alone selected scroll-to-end, which pinned the
banner to the bottom of the full-height viewport and left the top half of the
screen blank.

Anchor to the top until there is an actual conversation turn (a user/user_shell
history item or a pending response), then resume scroll-to-end so the latest
output stays in view. Startup notices no longer count as content that forces
bottom alignment.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): stabilize streaming thinking window against availableTerminalHeight drift

The grow-only streaming thinking window still flickered because its line cap was
derived from availableTerminalHeight. While a thought streams the terminal keeps
constrainHeight on, so availableTerminalHeight (and the derived maxLines) drifts
up and down as sibling pending content grows, and the grow-only clamp
`min(maxLines, …)` shrank the block whenever it dipped.

Use a constant window height (MAX_STREAMING_THINKING_VISUAL_LINES) for the
pending window instead. The window is only a few lines, so a fixed cap cannot
meaningfully overflow (VP scrolls anyway), and the height stays stable while
still growing monotonically within a thought.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* Revert "fix(cli): anchor VP viewport to the top until a conversation turn exists"

This reverts commit fbe86a9e159b75ea1f5b689cc327599c9dc91090.

* fix(cli): guard modifyOtherKeys detection against keypresses without a sequence

The modifyOtherKeys prefix check ran on every keypress, but some synthetic
keypresses (and the useKeypress test harness) emit a key with no `sequence`,
so `key.sequence.startsWith(...)` threw an unhandled rejection. Use optional
chaining so a missing sequence is simply not a modifyOtherKeys start.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(cli): mock pushKittyProtocolFlags in gemini.test.tsx kitty mock

The kittyProtocolDetector mock omitted the newly added pushKittyProtocolFlags
export. Add it so the mock stays in sync with the real module and a VP-mode
startup path exercised through this suite cannot hit an undefined call.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(web-shell): open singleton subagent details (#7495)

Co-authored-by: ytahdn <ytahdn@gmail.com>

* fix(web-shell): avoid redundant git status requests (#7496)

Co-authored-by: ytahdn <ytahdn@gmail.com>

* fix(agent): ignore empty working_dir placeholders (#7343)

* fix(agent): ignore empty working_dir placeholders

* test(agent): align empty working_dir expectations

* feat(prompts): allow overriding core identity via QWEN_SYSTEM_IDENTITY_MD (#7478)

* feat(prompts): update prompts.ts for QWEN_SYSTEM_IDENTITY_MD

* feat(prompts): update prompts.test.ts for QWEN_SYSTEM_IDENTITY_MD

* fix(prompts): address CR on QWEN_SYSTEM_IDENTITY_MD

Keep getDefaultCoreIdentitySentence private, fail loud on path
resolution errors, use trimEnd, and resolve identity only on the
default-prompt branch.

* test(prompts): align identity override tests with CR feedback

Sample default identity from live prompt, cover trimEnd trailing
whitespace, and assert homedir resolution failures throw.

---------

Co-authored-by: 易良 <1204183885@qq.com>

* fix(cli): yield to single-slot background agents (#7258)

Co-authored-by: hogeheer <267467744+hogeheer499-commits@users.noreply.github.com>

* docs(autofix): require evidenced pre-commit verification, not a bare "verified" (#7486)

* docs(autofix): require evidenced pre-commit verification, not a bare "verified"

The skill already said to run build/typecheck/lint/Vitest before
committing, but softly — and #7408 committed a fix with a TS error the
gate then rejected while its summary claimed "verified all 3 commits".
A self-assessment the gate contradicts wastes a whole round.

Strengthens the address-review contract from "run the checks" to:
- actually run them, do not assert them from reading the diff;
- if typecheck or a touched-package test fails, do NOT commit — treat
  the feedback as unresolved (failure.md);
- end address-summary.md with a `## Verification` section listing each
  command run and its result; a bare "verified" is not acceptable.

The framing is structural, not etiquette: the deterministic gate re-runs
the same commands and discards the round on any failure, so skipping them
only moves the rejection later. Pinned by a test so it cannot soften back.

This is the checkable half of "audit before committing" — the
undirected/reverse-audit-until-clean practice does not transfer to an
unsupervised agent (no verifiable stopping condition, and it worsens the
timeouts seen on large PRs), but "run the gate's own checks first and
show the evidence" does.

* fix(autofix): clarify Verification section precedes collapsed Chinese translation (#7486)

---------

Co-authored-by: wenshao <wenshao@example.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com>

* feat(autofix): stop a PR that fails to push for N rounds in a row (#7482)

* feat(autofix): stop a PR that fails to push for N rounds in a row

Under takeover the round cap is 100, which is right for a PR that needs
many PRODUCTIVE rounds. It is wrong for one that fails every round: #6723
ran 7 consecutive failed rounds (3 agent timeouts at 50 min, 4 gate
rejections whose fix broke tests) over 8 hours, heading for round 100,
because it is a 5700-line, 47-file, 5-day-old PR racing a fast-moving
main — every round re-resolves a conflict it cannot finish or that fails
the gate. Retrying at the same per-round budget will not converge; a
human has to rebase or split it.

Adds CONSECUTIVE_FAILURE_CAP (5), distinct from the total round cap. The
handoff step already runs only when a round did NOT push, so it counts
the unbroken run of prior failure markers — stopping at the first push
("Addressed the latest review feedback") or legitimate no-op ("no
changes needed"), either of which proves progress and resets the streak.
At the cap it forces the terminal round even under takeover, with a
handoff that names the real fix (rebase/split, then /retry). Cause-
agnostic: a timeout and a gate rejection both count.

* fix(autofix): address review feedback on consecutive-failure circuit breaker (#7482)

- Fix misleading comment: the walk is oldest-first (API order) with
  reset-on-success, not newest-first with early stop
- Prefer the already-fetched ic.json over a redundant gh api call,
  falling back to the API only when the file is missing
- Filter eval markers by re-arm window (win=) so pre-re-arm failures
  do not immediately re-terminate a re-armed PR
- Add test coverage for the MARK_ROUND == MAX_ROUNDS guard and for
  window-scoped streak counting

* fix(autofix): exempt transient model errors from consecutive-failure breaker (#7482)

---------

Co-authored-by: wenshao <wenshao@example.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>

* feat(core): restore background agent roster (#7459)

* feat(core): restore background agent roster

* fix(web-shell): add list_agents to TOOL_DISPLAY_NAMES

The new list_agents core wire tool was added to core's ToolNames but not
to the web-shell TOOL_DISPLAY_NAMES map, causing toolFormatting.drift.test.ts
to fail (expected ['list_agents'] to deeply equal []). Add the missing
'ListAgents' display-name entry so the browser panel shows a friendly name
instead of the raw wire name and the drift guard passes.

* fix(cli): reload old-session background agents on failed resume rollback

When /resume fails after core has swapped but before the UI swap, the catch
block rolls core back to the old session via startNewSession(oldSessionId).
However the forward path already called resetBackgroundStateForSessionSwitch,
which cleared the old session's in-memory background agents. The rollback did
not reload them, so list_agents returned empty for the old session (whose
sidecars are still on disk) until the next process start or successful resume.

Reload the old session's paused background agents after rolling core back, so
the restored roster matches on-disk state. Placed after startNewSession so the
loadPausedBackgroundAgents current-session guard is satisfied; best-effort via
.catch so it never blocks the rollback path.

* fix(web-shell): add zh translation for list_agents tool name

The toolFormatting test 'has a zh translation for every tool in the
display-name map' failed with expected ['list_agents'] to deeply equal []
because list_agents was added to TOOL_DISPLAY_NAMES without a matching
toolName.list_agents zh-CN entry. Add the translation to restore parity.

* fix(cli): resolve CI failures for background-agent roster restore

- Add toolDisplayName.ListAgents translations (en, zh, zh-TW, ca) so the
  new list_agents tool has a zh entry; fixes i18n/index.test.ts.
- Add loadPausedBackgroundAgents and consumePendingRecoveredAgentsNotice
  to the acpAgent worktree test config mock, which loadSession now calls
  via #restoreBackgroundAgentsOnResume; fixes acpAgent.worktree.test.ts.

* refactor(core): extract incompatible-isolation blocked reason to a const

Move the incompatible-isolation blocked-reason string out of an inline
literal into a module-level INCOMPATIBLE_ISOLATION_BLOCKED_REASON const,
matching its four sibling reasons so the text is discoverable by
constant-name grep and edited alongside the others.

* fix(core): preserve retained activity state on failed agent revive

Address review feedback on the background-agent roster restore:

- On a failed completed-agent revive, restore UI state with a non-empty
  guard instead of `??`. Because `restorePausedEntry` resets the paused
  entry's `recentActivities` to `[]`, the previous `failedEntry?.field ??
  completedEntry.field` kept that empty array and dropped the pre-revive
  snapshot (the UI Progress section rendered empty). Applied consistently
  to pendingMessages, recentActivities, and pendingApprovals.

Add regression coverage for previously untested paths:

- failed revive preserves pre-revive recentActivities
- terminal-agent cap admits only the newest MAX_RETAINED_TERMINAL_AGENTS
  completed sidecars on restore
- /resume rollback reloads the old session's background agents
- headless resume prepends the recovered-agents notice to the prompt

* test(cli): cover interrupted-turn continuation not consuming recovered-agents notice

Add ACP and headless regression tests asserting an interrupted-turn
continuation does not consume the one-shot recovered-agents notice
(the !isContinue / !continueInterrupted guards), so it is delivered on
the user's next ordinary prompt. Mirrors the existing slash-command
coverage.

---------

Co-authored-by: Claude <noreply@anthropic.com>

* feat(cli): support custom skill directories via settings (#7395)

* feat(cli): support custom skill directories via settings (#7394)

Add skills.directories setting that accepts an array of additional
directory paths to scan for skills (SKILL.md files). Paths support
~ expansion. Directories are scanned recursively at user level,
after the default ~/.qwen/skills/ directory.

Example settings.json:
{
  "skills": {
    "directories": ["~/.agent/skills", "~/.claude/skills"]
  }
}

Changes:
- settingsSchema.ts: add skills.directories array setting
- core Config: add customSkillDirs param and getCustomSkillDirs()
- SkillManager: append custom dirs to user-level skill base dirs
- CLI config: read skills.directories and pass to core Config

* fix(cli): regenerate settings schema for skills.directories (#7394)

* fix(core): address review feedback for custom skill directories (#7395)

- Use optional chaining for getCustomSkillDirs() to prevent TypeError
  on partial Config mocks (workspace-skill-management, workspace-skills-status)
- Reuse expandHomeDir utility instead of inline tilde expansion
- Fix inaccurate 'scanned recursively' wording to 'one level deep'
- Correct JSDoc: paths are raw, expansion happens in SkillManager
- Trim whitespace from custom dir entries in CLI layer
- Add tests for custom dir expansion, dedup, and partial config safety

* fix(core): address review feedback for custom skill directories (#7395)

* fix(core): address review feedback for custom skill directories (#7395)

* test(core): add relative path resolution test for custom skill dirs (#7395)

* fix(cli): add Array.isArray guard for skills.directories and safe mode test (#7395)

* fix(skills): address review feedback on custom skill directories (#7395)

- Add bare mode test for skills.directories guard
- Include resolved absolute path in relative directory warning
- Clarify that dedup applies to default user dirs, not bundled skills
- Regenerate settings schema

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com>
Co-authored-by: Qwen Code Autofix <qwen-code-autofix@users.noreply.github.com>

* fix(core): add image modality support for qwen3.8-max and kimi-k3 models (#7491)

* fix(core): add image modality support for qwen3.8-max models

qwen3.8-max-preview supports image input but was falling through to the
catch-all text-only rule because no pattern matched it. This caused the
vision bridge to unnecessarily transcribe images via a secondary model
instead of sending them directly to the primary model.

* fix(core): also add image modality for kimi-k3

Kimi K3 officially supports image + video input but was falling through
to the catch-all text-only rule, same issue as qwen3.8-max.

* fix(dingtalk): preserve non-bot mention context (#7473)

* fix(dingtalk): preserve non-bot mention context

* test(dingtalk): cover plural mentions, staffId fallback, and edge cases (#7473)

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>

* fix(core): harden the usage salvage around session deletion (#7425)

Post-merge review follow-ups on #7391 (three findings):

- Salvage the archived transcript in the active-branch deletion too:
  when both copies co-exist (an interrupted archive) and the fresh
  active transcript carries no telemetry, the archived copy holds the
  session's usage history and was deleted unsalvaged. The dedup guard
  makes the extra call a no-op whenever the active copy already wrote.
- Enforce the "never blocks deletion" contract at the call site: a
  salvageUsageBestEffort wrapper catches and warns, so the guarantee is
  structural rather than an implementation detail of
  persistUsageBeforeTranscriptDeletion. The new failure-tolerance test
  (salvage rejects -> deletion still succeeds) fails without the
  wrapper — the bare await let the rejection escape through
  removeSessionFiles' rethrowing catch.
- Clear the salvage module mock in beforeEach so the wiring test's
  invocationCallOrder assertions can never read stale calls.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(core): make fork subagents discoverable (#7460)

* test(core): cover Shell truncation without an artifact (#7470)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(ci): autofix route checks existing labels on non-trigger label events (#7481)

* fix(ci): autofix route checks existing labels on non-trigger label events

When triage adds multiple labels in sequence, per-issue concurrency
cancels earlier runs. If the last label is not a trigger label
(e.g. scope/build-system), the surviving run skips the issue phase
even though the issue already has autofix/approved +
status/ready-for-agent.

Before ignoring a non-trigger label event, check ISSUE_LABELS_JSON
for both required labels. If present and the issue is open, proceed
with the issue phase. Trust was already established when the trigger
labels were applied (both require triage+ permission).

* fix(ci): require trusted sender for label fallback

* feat(cli): preserve semantic text when copying VP selections (#7286)

* docs(cli): define semantic copy fidelity scope

* docs(cli): address semantic frame review gaps

* docs(cli): preserve soft-wrap source separators

* feat(cli): preserve semantic selection copy

* fix(cli): address semantic copy review findings

* fix(cli): preserve clipped semantic boundaries

* fix(cli): limit separator carrier joiner to visible width in wrap metadata

The greedy /\s+/ match in wrapTextWithMetadata could capture more
source whitespace than the separator carrier row actually consumed
(e.g. a tab following a space), causing duplicated whitespace in
semantic copy. Limit the match to visibleLine.length characters and
add a mixed space/tab regression test.

---------

Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com>

* test(core): stub the registry methods agent.ts actually calls (#7538)

The shared stubRegistry in agent.test.ts was missing six methods that
agent.ts reaches: bridgeApprovalEvents, getQueuedCount,
registerResidentAgent, restartCompletedAgent, unregisterResidentAgent and
waitForMessages.

That is not a benign omission. The background body wraps its work in a
try/catch that routes any throw into registry.fail(), so a missing method
never surfaces as 'not a function' — it silently converts a successful
run into a failed one. On the GOAL completion path
unregisterResidentAgent is called immediately before complete(), so the
TypeError replaced the completion entirely:

  registry.fail('fork-...', 'registry2.unregisterResidentAgent is not a
  function', ...)

That is what broke 'runs a non-interactive fork through the background
registry' on main. #7460 added the registry.complete assertion, which
exposed the incomplete stub — before it, nothing checked whether the
background body finished successfully and the TypeError was swallowed.

Stub all six with their real return shapes (unregisterResidentAgent
returns boolean, bridgeApprovalEvents returns the unsubscribe callback
agent.ts later invokes, waitForMessages resolves to a list) and assert
registry.fail was not called before asserting completion, so a future
gap reports the actual error instead of 'complete: 0 calls'.

* perf(startup): lazy-load Google GenAI SDK on first use (#7512)

* perf(startup): lazy-load Google GenAI SDK on first use

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address PR review feedback (#7512)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address PR review feedback (#7512)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(vscode): use file picker image paths for vision input (#7493)

* fix(vscode): use image paths from file picker

* fix(vscode): keep image picker paths raw

* fix(vscode): resolve image picker paths on submit

* fix(vscode): send picked images as vision context

* fix(vscode): encode prompt image file URIs

* fix(vscode): address image path review comments

* test(vscode): cover image file reference edge cases

* fix(cli): open the actual serve fallback port (#7501)

* fix(cli): open actual serve fallback port

* test(cli): match serve URL to fallback listener

* docs(cli): clarify serve listen error handling

---------

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>

* fix(ci): don't let one failing scenario sink the whole visual preview (#7511)

The web-shell visuals render runs every screenshot and flow in a single
`test:e2e:visuals`, and that step had no `continue-on-error`, while the compose
and upload steps had no `if: always()`. So one failing or timing-out scenario
failed the job, the artifact was never uploaded, and the publish workflow had
nothing to post — the entire preview vanished even when every other scenario
passed and its PNG was already on disk. A flow (a long multi-click sequence) is
the most fragile scenario kind, so the fragile one silently takes down the
deterministic screenshots. PR #7498 hit exactly this: 29 scenarios passed, one
new channel-management flow timed out, and the PR got no preview and no comment
at all.

Make the after-capture step `continue-on-error` so the passing captures survive
and the later steps still compose and upload them. The publish job only runs on
a `success` conclusion, so the job must stay green — but a masked failure must
not read as a clean preview. Ship the step's real `.outcome` (which
continue-on-error does NOT mask, unlike `.conclusion`) to the publisher as
`render-status.txt`, and have the comment builder use it: an empty preview whose
render failed says "one or more scenarios failed to render" and is explicitly
NOT the reassuring green check or the coverage-gap prompt (both imply the render
ran); a partial preview is labelled partial above the shots that did render. A
missing status file (older run) defaults to complete, so this only ever adds a
warning, never suppresses a real preview.

The failing scenario still needs fixing — it's now surfaced in the comment
rather than by silently deleting everyone else's preview.

Co-authored-by: wenshao <wenshao@example.com>

* feat(web-shell): add selective shadow DOM isolation (#7551)

Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com>

* feat(web-shell): add renderChatHeader slot for custom session header (#7553)

* fix(cli): say review coverage gaps in the author's units, not chunk ids (#7550)

The posted review body rendered coverage disclosures with the run's own
bookkeeping as subjects: bare chunk ids, unsorted, one per subject. On a
run that certified nothing (PR #7268) the body enumerated all 49 chunk ids
across two sentences while opening with "Reviewed. Suggestions are
inline." — the opener certified the exact thing every following sentence
took back, and nothing on the PR page maps a chunk id to code.

Three changes, all render-time — the structural entries, the caps, the
caller-echo dedup and the stderr remediation still key on chunk ids, which
is where the id is the selector a reader can act on:

- Coverage now returns the plan's chunk→files table (DiffChunk.files was
  already in the plan JSON; the coverage type slice dropped it).
- compose-review renders chunk gaps through describeChunkGap: every
  planned chunk collapses to "the entire diff", a narrow gap with known
  files names the files, and anything wider is counted against the plan's
  total. Applied to the receipt sentence, the uncoverable sentence (bare
  CLI entries only — caller-authored entries render verbatim) and the
  grouped per-cause sentences.
- The COMMENT opener may no longer say "Reviewed." over a disclosure set
  that denies it: when no chunk is both covered and undisclosed — or no
  chunk universe could be read at all — it opens with a zero-certified
  warning instead. A rewritten launch demonstrably read its chunk, so
  coverage alone is not the test; certified is covered with no disclosure
  against it.

Co-authored-by: verify <verify@local>

* fix(autofix): retry a skipped-Prepare instead of stranding the PR terminal (#7490)

* fix(autofix): retry a skipped-Prepare instead of stranding the PR terminal

A base/infra failure BEFORE the agent runs was misread as an agent crash
and terminated the PR forever. When an early step fails — installing or
building the trusted base, checkout, node setup — the `Prepare branch and
feedback` step is skipped, so NEWEST is empty, and the report step's
"crashed before reading feedback" branch fired: MARK_ROUND=MAX_ROUNDS,
terminal, scan skips it on every future tick.

Observed: a web-shell TypeScript break on `main` failed `Install
dependencies and build` (which builds the trusted base) across a whole
scan batch, and SIX healthy PRs were stranded terminal at round=100 in
one run — including ones at round 9 and 11 that had nothing to do with
the break. `round=100` there is a terminal sentinel, not 100 attempts.

NEWEST-empty now splits on steps.prepare.outcome:
- 'skipped' (an earlier step failed, the agent never ran) is infra/base
  and transient: retry with a sentinel ts so the feedback stays live,
  incrementing the round so a PERSISTENTLY broken base is still bounded
  and stops at the cap (recoverable with /retry).
- 'success'/'failure' (Prepare ran, no feedback produced) is a genuine
  pre-read agent crash: unchanged terminal behaviour.

This is the reverse of the asymmetry #7482 addresses: that bounds a
crash AFTER reading that retried forever; this stops a transient failure
BEFORE reading from going terminal after one.

* docs(autofix): note a pre-Prepare cancel also retries intentionally (#7490)

* fix(autofix): also retry a cancelled/empty prepare outcome, not just skipped

A previous review comment on this PR noted that a job cancelled before
Prepare should retry too. It was right about the intent but the code did
not do it: `steps.prepare.outcome` is 'cancelled' for a cancel and '' for
a job that stopped before Prepare entered the step context — both DISTINCT
from 'skipped', so `== 'skipped'` sent them to the terminal branch, the
same over-termination this PR exists to fix.

Match on "not a real Prepare run" (`!= 'success' && != 'failure'`)
instead, so skipped, cancelled, and empty all retry; only a Prepare that
actually ran to a verdict (success/failure) with no feedback stays
terminal — the genuine pre-read agent crash. Test extended to drive the
cancelled and empty cases (retry) and both real-run outcomes (terminal);
mutation-verified that reverting to `== 'skipped'` reddens the cancelled
case.

* test(autofix): update the pre-read-crash case for the broadened retry

The prior commit broadened NEWEST-empty retry to skipped/cancelled/empty
but left the older 'replays the handoff decision' test asserting the old
terminal behaviour for an unset PREPARE_OUTCOME (which now retries). That
test's terminal cases now set PREPARE_OUTCOME=success/failure explicitly —
the only outcomes that still terminate — so it exercises the genuine
pre-read agent crash rather than the infra/cancel path.

* test(autofix): anchor the skipped-Prepare extraction past the CONSEC block

CI reddened `retries a skipped-Prepare` after main's consecutive-failure
cap (#7482) merged into this branch: that block was inserted between this
decision block and the report `{`, and it calls `gh api`. The test's
`{`-anchored regex over-captured through it, so the extracted script ran
the unstubbed `gh api` and failed. Anchor the end on the same
`# Consecutive-failure` comment the sibling gate-crash test already uses,
so the extraction stops at this decision block's own closing `fi`.

* fix(autofix): exempt skipped-Prepare from the consecutive-failure breaker

A broken base build skips Prepare, producing no API error file — so the
consecutive-failure breaker ran on the new retry path and, after 5
scans, re-introduced the exact mass-stranding this PR exists to prevent.
Exempt pre-agent infra failures (skipped/cancelled/empty outcome) from
the breaker, mirroring the transient 429/5xx exemption: same failure
class (not the PR's fault, self-heals, hits the whole batch). The round
cap + sentinel-ts /retry recovery already bounds a persistently broken
base.

Also trim "checkout" from the retry headlines (checkout failures do not
land in this branch) and hoist the duplicated MARK_TS assignment.

* fix(autofix): reset the consecutive-failure streak on prior infra-failure markers

The streak walker counted prior infra-failure headlines ("AutoFix could
not start —…") as failures, inflating the consecutive-failure count on
subsequent rounds.  A PR with 3 real agent failures, then 3 rounds of
base-build infra failures, then 1 more real failure would trip the
cap-5 breaker even though only 4 rounds were the PR's fault.

Add the two infra-failure headline patterns as reset strings in the
streak walker, alongside the existing push and no-op resets.  The
genuine agent-crash headline ("AutoFix could not start evaluation —…")
is deliberately excluded — it is a real failure and must still count.

* fix(autofix): clarify infra-failure headlines and else-branch comment (#7490)

Address review nits: the retry headline now mentions cancelled runs,
the cap headline says 'reached the round cap' instead of overstating
'could not start for N rounds', the else-branch comment says 'prepare
itself crashed' instead of 'agent crash', and the streak-reset pattern
is simplified now that both infra headlines share the same prefix.

---------

Co-authored-by: wenshao <wenshao@example.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>

* fix(cli): keep role codenames and brief paths out of the posted review body (#7560)

The posted body still carried two operator registers #7550 left in place:
roster role subjects rendered their internal codenames ("Agent 1c:
Cross-file tracer", "Test coverage matrix (whole-diff)"), and an unread
brief's disclosure interpolated its filesystem path. And when verify and
the reverse audit failed the same way, the body said it twice, in two
near-identical sentences.

- Every Brief now carries a publicLabel — the dimension said as what it
  checks ("the cross-file consistency pass") — and coverage's structural
  disclosures carry it as publicSubject beside the internal subject, plus
  a path-free publicReason for unread briefs. The internal label and the
  path stay on stderr, where they are the selector an operator acts on;
  every dedup and certification check still keys on the internal subject.
- compose-review renders the public fields and groups by the reason the
  body PRINTS, so two unread briefs share one path-free sentence instead
  of repeating it per role.
- verificationGaps merges verify and reverse-audit failures of the same
  delivery shape into one sentence with both subjects and both
  consequences; mixed shapes keep their precise per-role texts, and the
  per-role rebuild commands stay on stderr either way.

Co-authored-by: verify <verify@local>

* fix(autofix): retry an agent timeout instead of advancing past its feedback (#7563)

A timeout evaluated NOTHING — the agent ran out of budget before finishing,
so nothing was committed and the feedback is unaddressed. It was treated as
an evaluated verdict (real ts, watermark advances), which strands that
feedback: the next scan sees "nothing new" and never retries. Observed on
#7471 (round 13/100), a heavily-reviewed 1871-line PR: rounds 11 and 13
timed out, but round 12 pushed — so a timeout is transient far more often
than not, and advancing past it left the round-13 feedback unhandled.

run-agent.mjs now drops an `agent-timeout` signal on result.timedOut, and
the handoff routes it like a pre-verdict crash: sentinel ts (feedback stays
live) and a retry, with a headline that names the real fix at the cap
(split the PR or raise the budget). A PR that PERSISTENTLY times out is
bounded by the round cap and the consecutive-failure cap, so this cannot
loop forever — it just stops treating a one-off budget blip as a verdict.

The loop guard stays terminal (a tool-call loop is a real defect, not a
budget blip). An API error still routes to its own model-key handoff; the
timeout signal is written only when NOT an API error.

Co-authored-by: wenshao <wenshao@example.com>

* feat(serve): add workspace-level generation (#7552)

* feat(serve): add workspace-level generation

* docs(serve): document workspace generation capability

* fix(serve): align workspace generation contracts

---------

Co-authored-by: ytahdn <ytahdn@gmail.com>

* ci: matrix ECS runner update + sudo install + repository_dispatch trigger (#7513)

* ci: matrix ECS runner update with sudo install

- Use matrix strategy (ecs-update-sg, ecs-update-64c) to update both
  physical ECS hosts in parallel (fail-fast: false).
- Always use sudo npm install -g so the package lands in /usr/local
  (system-wide PATH) instead of the runner user's home directory.
- Move concurrency to job level (matrix context not available at
  workflow level per actionlint).
- Add repository_dispatch trigger for release-driven updates.
- Register new runner labels in actionlint.yaml.

* fix(ci): use dispatch version for runner update

---------

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>

* fix(web-shell): include managed id in artifact open requests (#7570)

Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com>

* feat(serve): persist workspace channel configuration (#7514)

* feat(serve): persist workspace channel configuration

* fix(serve): harden channel settings snapshots

* fix(serve): validate startup channel names

* fix(serve): reserve all channel name

---------

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>

* fix(sdk-python): require canonical form in validate_session_id (#7532)

uuid.UUID() accepts several non-canonical spellings — braced
{...}, urn:uuid:..., and dash-less hex — so validate_session_id let them
through after the RFC 4122 variant check. The value is then forwarded to
the CLI verbatim as --session-id/--resume, producing a malformed session
id downstream rather than a clear error at the SDK boundary.

Reject anything whose canonical form differs from the input. Case is
deliberately not part of the comparison: UUID() lowercases, and an
all-uppercase spelling is still valid canonical input.

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>

* fix(web-shell): sync background agent status (#7561)

* fix(web-shell): sync background agent status

* fix(web-shell): harden background agent reconciliation

---------

Co-authored-by: ytahdn <ytahdn@gmail.com>

* feat(core): propagate trusted daemon invocation context (#7279)

* feat(core): propagate trusted daemon invocation context

* test(cli): update ACP startup expectation

* refactor(core): centralize ACP capability env key

* test(cli): update worktree ACP core mock

* test(integration): run daemon context smoke on PRs

* test(ci): update no-AK smoke expectation

* test(core): cover invocation context isolation

* fix(cli): compare ACP capability safely

* fix(docs): restore GitHub action input names

* fix(core): sanitize private ACP capability from child env

* fix(core): reuse private ACP capability env constant

* test(cli): cover malformed trusted invocation context

* test(acp-bridge): assert exact child environment

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: 易良 <1204183885@qq.com>

* fix(feishu): await stream cancels in media download teardown (#7465)

* fix(feishu): await stream cancels in media download teardown

downloadMedia left two reject paths' stream teardown unawaited:

- the oversize-stream path called reader.cancel() without awaiting, so a
  cancel error during teardown became an unhandled rejection (fatal under
  Node's default --unhandled-rejections=throw);
- the Content-Length reject path returned without cancelling resp.body,
  leaving the connection pinned until GC.

Both were already fixed for the sibling DingTalk downloader in #7361 (which
was itself modelled on this Feishu code), so this brings Feishu to parity.
Adds a regression test that pins the reader.cancel() await via a rejecting
cancel, plus an assertion that the Content-Length path releases the body.

* test(feishu): cover a rejecting body.cancel() on the Content-Length path

Mirrors the existing reader.cancel() teardown test for the other reject
path, per review feedback. Removing the await on resp.body?.cancel()
flips execution onto the 'rejected: size ... exceeds' branch and the
test fails.

* fix(autofix): make the review-address report wrapper lines bilingual (#7569)

The agent's address-summary.md / no-action.md already ends with a
collapsed Chinese translation, but the workflow-appended wrapper lines
around it — the "Addressed/Reviewed the latest feedback" lead-in, the
"Base-conflict check" line, and the "Re-review when you have a moment"
footer — were English-only and sat outside that block. So the posted
comment was only half translated, unlike the takeover-ack comments
(full collapsed Chinese block) and the "model/模型" sign-off in this
same report (already inline-bilingual).

Give each wrapper line an inline Chinese translation, matching the
model/模型 idiom. The English halves are preserved verbatim — the
streak-reset detector globs on "Addressed the latest review feedback"
and "no changes needed", and a test extracts these lines — so behaviour
is unchanged and old English-only comments still match. A new test pins
each English-Chinese pair so a future reword that drops the Chinese
fails. The terminal handoff/failure comment is left English-only for
now (SKILL.md keeps it so by design); that is a separate change.

Co-authored-by: wenshao <wenshao@example.com>

* feat(cli): post the review body bilingually when the PR description is Chinese (#7564)

When the PR author writes Chinese, the posted /review body was
English-only. fetch-pr now records whether the PR description contains
Han characters (prDescriptionHasHan, detected from the same gh pr view
call and stamped into the plan report), and compose-review renders the
body bilingually off that flag: the English body leads, the complete
Chinese version rides collapsed in a <details><summary>中文说明</summary>
block, and the model footer stays outside the fold. The signal is the
CLI's own — the caller cannot toggle the register of a certified body —
and a local plan has no field, so nothing changes for terminal-only
reviews.

Every deterministic body fragment carries an en/zh pair end to end:
compose-review's clause templates and describeChunkGap phrases, the
coverage disclosures (reasons, publicLabel role subjects via a new
publicLabelZh, the path-free unread-brief reason) and the Step 4/5 gap
texts including the combined same-shape sentence. Fragments with no
deterministic translation — model-written findings, caller echoes,
interpolated errors — ride verbatim in both halves. verificationGaps now
returns structural {subject, reason, subjectZh, reasonZh} entries, which
also removes compose-review's last recover-the-boundary-from-prose parse.

SKILL.md instructs the same format for the model-authored inline
comments: English finding first (marker and suggestion block stay in the
English half — tooling filters on them), full Chinese translation
collapsed beneath, footer last.

Co-authored-by: verify <verify@local>

* feat(autofix): auto-rerun a check that died on infrastructure, once (#7562)

* feat(autofix): auto-rerun a check that died on infrastructure, once

A failed check can be red because the machine died, not the code — a
self-hosted runner losing the server, the disk filling. #7490's E2E
failed with "runner lost communication with the server" and went green
on a rerun. The scan now reruns such a check's failed jobs automatically.

Detection is a conservative annotation whitelist (INFRA_FAILURE_SIGNATURES)
— only unambiguous machine failures, never a test-level timeout, which
could be a real regression. The one-shot guard is run_attempt, not a
marker: a run already retried to attempt 2 and still infra-failing is
persistent, so it is left for a human; after a rerun the attempt
increments, so the next scan will not rerun it. Every step is fail-safe
(any API error → no rerun), it runs only when the PR actually has a
failed check, and the gate carries the same review-address carve-out as
the other check selectors so the loop never reruns its own runs.

This is the transient-infra sibling of #7554 (stale-base): that merges
current main when a check is base-inherited; this reruns when a check
died on the runner. Neither touches a check that is a genuine failure.

Note: rerun-failed-jobs needs the PAT to hold `actions: write`.

* fix(autofix): use POSIX ERE groups in infra-failure regex, cover all signatures in tests (#7562)

* fix(autofix): also treat a git fetch/clone transport death as infra

#6506's checkout died mid-transfer — "fetch-pack: invalid index-pack
output" and "RPC failed; curl 92 ... CANCEL" — which then hung the job
into the 20m limit. That is infra, not the PR (it only touches a doc),
and a re-run made it green. But the infra-signature whitelist did not
cover it, so the auto-rerun did not fire and it waited on a human.

Add `invalid index-pack output` and `RPC failed` — the two canonical
git-transport-death phrases — to INFRA_FAILURE_SIGNATURES. A co-present
job-timeout line does not block the match (one matching line classifies
the run), and a BARE timeout with no transport signature is still left
alone, since it can be a real regression. Both new signatures are pinned
in the test's per-signature loop, plus a case on #6506's real composite
annotation and a bare-timeout-is-not-rerun guard.

* fix(autofix): paginate annotations and filter Autofix runs in infra-rerun loop (#7562)

---------

Co-authored-by: wenshao <wenshao@example.com>
Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>

* fix(serve): detect stale SSE cursors across daemon restarts via epoch token; preserve turn attribution and surface compaction failures in replay (#7458)

* fix(daemon): epoch-token restart detection, compaction attribution, and degraded-snapshot signaling (DAEMON-001/007/008)

* fix(acp-bridge): field-level turn attribution merge and replayDegraded bridge test (#7458)

* fix(serve): skip bus epoch lookup for virtual subagent SSE streams (#7458)

The REST SSE route looked up the bus epoch for every session id, but
virtual subagent sessions ride their own bus and their compound ids are
not in the bridge's byId map, so the lookup threw and aborted the
subscription — breaking subagent event streams. Skip the lookup for the
virtual path and degrade a torn-down real session to a headerless stream
(mirrors the /acp route). Also bumps the daemon browser SDK bundle budget
(167KB -> 168KB) for the epoch fields and declares eventEpoch on
DaemonSession so the create/attach path drops its inline type cast.

* fix(serve): stamp eventEpoch on accepted continuations and surface replayDegraded in the SDK (#7458)

Address three review suggestions:
- POST /session/:id/continue now returns eventEpoch alongside lastEventId,
  mirroring the prompt 202 envelope so continuation-seeded SSE cursors
  detect daemon restarts (DAEMON-001)
- DaemonSessionClient exposes replayDegraded from the load response so SDK
  consumers can prefer the full transcript over a degraded snapshot
- add /acp dispatch-level regression test for the degraded-snapshot stderr
  breadcrumb (fires only when snapshot.degraded is set)

* test(cli): fix load-reply race in the degraded-breadcrumb transport test

Await each session/load reply frame before opening the session stream so
the GET cannot race conn.ownSession() into a 403; addresses the review
Critical on the deg-0 arm.

* fix(serve): allow and expose X-Qwen-Event-Epoch in CORS headers

Cross-origin SSE clients must send the epoch header through preflight and
read it from the response, or stale-cursor detection (DAEMON-001) is
silently disabled for every CORS client.

---------

Co-authored-by: qwen-code-bot <qwen-code-bot@users.noreply.github.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Qwen Autofix <qwen-autofix[bot]@users.noreply.github.com>

* feat(core): Align GenAI telemetry with ARMS (#7536)

* feat(core): align GenAI telemetry with ARMS

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(core): remove estimated token usage splits

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(core): address GenAI telemetry review feedback

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>

* fix(serve): avoid TOCTOU race dropping live sessions from list response (#7556)

* Initial plan

* fix(serve): avoid TOCTOU race dropping live sessions from list response

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: 易良 <1204183885@qq.com>

* fix(cli): prevent monitor turns after task_stop (#7573)

---------

Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com>
Co-authored-by: Qwen Code Autofix <qwen-code-autofix@users.noreply.github.com>
Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
Co-authored-by: Qwen Code Autofix <qwen-code-autofix[bot]@users.noreply.github.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: destire-mio <qppque@gmail.com>
Co-authored-by: destire-mio <248462155+destire-mio@users.noreply.github.com>
Co-authored-by: Dragon <52599892+DragonnZhang@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: 易良 <1204183885@qq.com>
Co-authored-by: jinye <djy1989418@126.com>
Co-authored-by: chinesepowered <nlai@rediffmail.com>
Co-authored-by: ovochouovo <18212194+ovochouovo@users.noreply.github.com>
Co-authored-by: Edenman <67549719+BZ-D@users.noreply.github.com>
Co-authored-by: 克竟 <dingbingzhi.dbz@alibaba-inc.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: ytahdn <1294726970@qq.com>
Co-authored-by: ytahdn <ytahdn@gmail.com>
Co-authored-by: Truraly <94105924+Truraly@users.noreply.github.com>
Co-authored-by: zjgzx1988 <zjgzx1988@hotmail.com>
Co-authored-by: hogeheer499-commits <hogeheer499@gmail.com>
Co-authored-by: hogeheer <267467744+hogeheer499-commits@users.noreply.github.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: wenshao <wenshao@example.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: Nothing Chan <chenliu.cl@alibaba-inc.com>
Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com>
Co-authored-by: yuanyuanAli <135116774+yuanyuanAli@users.noreply.github.com>
Co-authored-by: verify <verify@local>
Co-authored-by: qqqys <qys177@gmail.com>
Co-authored-by: callmeYe <512217680@qq.com>
Co-authored-by: Qwen Autofix <qwen-autofix[bot]@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
rkfshakti pushed a commit to rkfshakti/qwen-code that referenced this pull request Jul 23, 2026
QwenLM#7527)

* fix(core): strip daemon secrets from hook and tool-discovery child env

Follow-up to QwenLM#7256, which introduced sanitizeChildEnv and applied it to
the shell, monitor, and MCP stdio spawn paths. Three agent-launched child
processes were left inheriting the full environment:

- hooks/hookRunner.ts spreads process.env into the hook command's env
- tools/tool-registry.ts spawns the configured tool-call command and the
  tool-discovery command with no env option, so both inherit implicitly

All three run user- or config-supplied commands on the agent's behalf and
have no need for QWEN_SERVER_TOKEN / QWEN_DAEMON_TOKEN, so they are the
same credential-exposure gap QwenLM#6601 describes. Route each through
sanitizeChildEnv(process.env); benign inherited env is unchanged.

* fix(core): normalize Windows PATH on the tool-registry child env

Both tool-registry spawns previously passed no env option, so Node
inherited the parent environment natively and Windows resolved its
case-insensitive PATH keys itself. Passing env explicitly gives that up:
on Windows process.env can carry both Path and PATH, and the child may
pick the wrong one.

Route them through normalizePathEnvForWindows, matching the shell
(shellExecutionService.ts) and MCP stdio (mcp-client.ts) spawn sites that
already pair it with sanitizeChildEnv. It returns env untouched off
win32, so nothing changes on other platforms.

hookRunner.ts is left as-is: it already built an explicit env before this
branch, so it does not regain inheritance semantics here.

---------

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
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.

Shell subprocess inherits sensitive environment variables causing credential exposure

4 participants