Skip to content

feat: delegate a subagent turn to an external agent over ACP (Claude Code first) - #11003

Open
wenshao wants to merge 5 commits into
QwenLM:mainfrom
wenshao:feat/claude-code-subagent
Open

feat: delegate a subagent turn to an external agent over ACP (Claude Code first)#11003
wenshao wants to merge 5 commits into
QwenLM:mainfrom
wenshao:feat/claude-code-subagent

Conversation

@wenshao

@wenshao wenshao commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Lets a subagent definition delegate its turn to an external coding agent instead of running it in-process. A definition declares an executor block naming a command; the turn is then driven over ACP in that external process, and everything that happens is re-published as the same agent events the in-process path emits, so the existing transcript writer, permission bridge, virtual subagent sessions and Web Shell subagent panel all work unchanged. Claude Code is the first and only external agent wired up.

A definition that declares an executor now fails loudly when the host cannot honour it. Previously there was no such concept at all, and the measured behaviour of an unrecognized definition field was to run the work in-process anyway — successfully, silently, and billed to the wrong provider.

Why it's needed

Running a second vendor's agent from Qwen Code was only possible by handing the whole session to a different backend, which needs workspace identity to be re-keyed from cwd to (cwd, backend) in both the daemon registry and the Web Shell session catalog. Delegating instead keeps the parent Qwen session authoritative: no change to workspace identity, no change to the bridge's one-channel-per-runtime invariants, and permissions, todos and artifacts stay owned by the parent.

Agent definition files in this repo already mirror Claude Code's .claude/agents/<name>.md schema verbatim so a user can drop one into .qwen/agents/. This extends that compatibility from the definition layer to the execution layer.

The silent-substitution problem is concrete and was measured on the shipped CLI, not theorised: given a definition carrying an unknown executor field, qwen -p completed the task, created the requested file, reported subtype: "success", and emitted nothing on stdout, stderr or the debug log to indicate the field had been ignored. The subagent's metadata reported model: qwen3.8-max-2026-09-02 and its transcript showed read_file, write_file, run_shell_command and glob — all Qwen tools — with 118,209 tokens billed to the Qwen provider. A user who asked for Claude got Qwen and had no way to tell.

Reviewer Test Plan

How to verify

Build, then delegate to a definition that names the Claude Code ACP adapter:

npm run build
mkdir -p /tmp/xagent/.qwen/agents && cd /tmp/xagent
cat > .qwen/agents/claude-worker.md <<'EOF'
---
name: claude-worker
description: Delegates to Claude Code over ACP.
executor:
  kind: acp
  command: npx
  args: ["-y", "@agentclientprotocol/claude-agent-acp"]
---
You are a precise worker. Do exactly what is asked.
EOF

QWEN_RUNTIME_DIR=/tmp/xagent/runtime \
  node <repo>/packages/cli/dist/index.js \
  -p "Use the claude-worker subagent to create impl.txt containing exactly: IMPL_OK" \
  --approval-mode yolo --output-format json

Confirm three things. impl.txt contains IMPL_OK. The subagent's .meta.json under $QWEN_RUNTIME_DIR/projects/<cwd>/subagents/<sessionId>/ reports model: external-acp:npx rather than a Qwen model id. The subagent's .jsonl transcript contains Claude tool names and zero occurrences of write_file.

Then confirm the refusal path: remove the executor injection (or run the shipped CLI) and the same definition must not silently run in-process. With this PR the definition errors with registered no external agent executor and AgentHeadless.create is never called — that non-call is asserted directly in subagent-manager.test.ts.

Unit tests: cd packages/cli && npx vitest run src/external-agents/ and cd packages/core && npx vitest run src/subagents/ src/agents/runtime/agent-headless.test.ts src/agents/runtime/agent-core.test.ts.

Evidence (Before & After)

Same fixture, same prompt, same machine. Before is the shipped CLI 0.23.0; after is this branch's local build.

Observed Before (shipped 0.23.0) After (this branch)
Task outcome subtype: "success", file created subtype: "success", file created
External agent process none in the process tree adapter ran; its own stderr forwarded as [external-agent claude-worker] [session/query] … apiType=native
.meta.json model qwen3.8-max-2026-09-02 external-acp:node
Subagent transcript tool names read_file ×2, write_file ×2, run_shell_command ×2, glob ×2 Bash; write_file count 0
Token accounting 118,209 tokens on the Qwen provider subagent reasoning not on the Qwen provider
Signal that executor was ignored none anywhere n/a — it is honoured

Raw before/after artifacts are in the branch author's spike notes; the after-run transcript and .meta.json are reproducible with the command above.

Web Shell

Headless Playwright capture of the Web Shell served by qwen serve over a workspace whose .qwen/agents/claude-worker.md carries the executor block. The prompt asks for the claude-worker subagent; the turn runs in the external Claude Code process.

Web Shell prompt typed

Web Shell subagent

Web Shell final state

Web Shell final state

That run was verified independently of the images rather than taken on trust from them: ws-shots.txt contains exactly WS_OK, and the subagent metadata for the session reports persistedCliFlags.model: "external-acp:node", agentType: "claude-worker", status: "completed", with Bash three times in its transcript and zero qwen write_file calls. external-acp:node is the label this PR's executor reports through getCore().modelConfig.model; a run that had silently fallen back to in-process would report a Qwen model id there instead.

A pre-existing environment caveat, visible in the browser console during capture and unrelated to this PR: GET /standalone/sessions returned 503 The Conversations runtime is owned by another daemon. It affects the standalone-session list, not the session flow shown above.

Terminal

The same delegation driven through the interactive CLI, captured with the repo's own terminal-capture harness (node-pty → xterm.js → Playwright headless). Included because it shows the tool-level output more legibly than the Web Shell transcript view.

prompt typed

delegation result

Full scrollback

full scrollback

Its run was verified the same way: shots.txt contains exactly SHOTS_OK and the subagent metadata reports model: "external-acp:node", status: "completed".

Still not captured: the approval dialog for an external agent's tool call. It needs the permission E2E (test-plan groups 4 and 5), which is outstanding and is the security-relevant gap.

Images are hosted on the assets-pr11003 branch of the author's fork under pr11003/.

Tested on

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

Environment

Local build via npm run build / npm run bundle, run as node packages/cli/dist/index.js. Claude Code 2.1.259 and @agentclientprotocol/claude-agent-acp 0.73.0. ~/.claude/settings.json on the test machine sets permissions.defaultMode: "auto", which is the configuration the permission-mode derivation is designed to override.

Risk & Scope

  • Main risk or tradeoff: the qwen/* extension methods are unimplemented by any external agent, so the seven session-scoped diagnostic routes return 500 for one. That does not apply to this PR's delegation path — a delegated subagent is a task inside a Qwen session, not a foreign session — but it does bound what a future peer-backend design could reuse.
  • The core SubagentExecutor interface widens createAgentHeadless's return type from a concrete class to an interface. All seven production call sites were updated; AgentHeadless declares implements, so drift fails at compile time.
  • Token statistics report 0. The adapter exposes only a context-window gauge (usage_update {size, used}), which is a level, not a per-turn delta; feeding a level into the accumulating statistics would inflate totals past the window size and trip the workflow budget gate early. The consequence is that an external subagent does not advance QWEN_CODE_MAX_TOKENS_PER_WORKFLOW. That gate is opt-in and defaults to unbounded, and the agent-count and max_turns / max_time_minutes bounds still apply.
  • Approval dialogs use the info confirmation variant, so the Web Shell shows the tool title and the option names but not a rendered file diff. The edit / exec variants are a follow-up.
  • executor.command names an executable from a project-level file. This is the same trust model the existing mcpServers and hooks command fields already carry, so it introduces no new boundary; the parser rebuilds the spec from known fields only, so unrecognized keys cannot reach the spawn site.
  • Not validated / out of scope: Windows and Linux; other external agents (Codex, Gemini); direct conversation with a delegated agent (the panel is watch-and-stop by existing design); rich edit/exec approval rendering; per-turn token accounting.
  • Breaking changes / migration notes: none. Absent an executor block the in-process path is byte-for-byte unchanged, including the system prompt — AgentCore.buildChatSystemPrompt now delegates to an extracted renderSubagentSystemPrompt with identical behaviour.

Linked Issues

None. Design rationale, the measured ACP behaviour this is built against, and the rejected peer-backend alternative are in docs/design/claude-code-web-shell-backend.md, added here.

中文说明

这个 PR 做了什么

让 subagent 定义可以把一轮任务委派给外部 coding agent,而不在进程内执行。定义里声明一个 executor 块指名命令;该轮任务就通过 ACP 在那个外部进程里驱动,发生的一切再以与进程内路径相同的 agent 事件重新发布,因此既有的 transcript writer、权限桥、virtual subagent session 与 Web Shell 的 subagent 面板全部无需改动即可工作。Claude Code 是第一个也是目前唯一接入的外部 agent。

声明了 executor 的定义,在宿主无法满足它时会显式报错。此前根本没有这个概念,而未识别的定义字段的实测行为是照样在进程内把活干完 —— 成功、静默、并且记账到错误的 provider。

为什么需要

要从 Qwen Code 里跑另一家厂商的 agent,过去只能把整个会话交给另一个后端,而那需要把 workspace 身份从 cwd 重新键为 (cwd, backend),且 daemon 注册表与 Web Shell 会话目录两层都要改。改为委派则让父 Qwen 会话保持权威:不动 workspace 身份,不动 bridge 的 one-channel-per-runtime 不变量,权限、todos、artifacts 仍归父会话所有。

本仓库的 agent 定义文件已经逐字镜像 Claude Code 的 .claude/agents/<name>.md schema,用户可以直接把文件丢进 .qwen/agents/。这个 PR 把该兼容性从定义层延伸到执行层。

静默替换问题是具体的、且在已发布 CLI 上实测过,不是推测:给一个带未知 executor 字段的定义,qwen -p 会完成任务、创建所要求的文件、报告 subtype: "success",并且在 stdout、stderr、debug log 上都不发出任何"该字段被忽略了"的信号。subagent 的元数据报告 model: qwen3.8-max-2026-09-02,transcript 里是 read_filewrite_filerun_shell_commandglob —— 全是 Qwen 的工具 —— 118,209 个 token 记在 Qwen provider 账上。要 Claude 的用户拿到的是 Qwen,而且无从察觉。

评审测试计划

如何验证

构建后,委派给一个指名 Claude Code ACP adapter 的定义(命令见英文正文)。确认三件事:impl.txt 内容为 IMPL_OK$QWEN_RUNTIME_DIR/projects/<cwd>/subagents/<sessionId>/ 下的 .meta.json 报告 model: external-acp:npx 而非 Qwen 的 model id;subagent 的 .jsonl transcript 含 Claude 工具名且 write_file 出现 0 次。

再确认拒绝路径:去掉 executor 注入(或跑已发布 CLI),同一定义不得静默在进程内运行。带本 PR 时该定义会以 registered no external agent executor 报错,且 AgentHeadless.create 从不被调用 —— 这个"未被调用"在 subagent-manager.test.ts 里被直接断言。

证据(前后对比)

同一 fixture、同一 prompt、同一台机器。Before 是已发布的 CLI 0.23.0,After 是本分支的本地构建。对照表见英文正文:任务结果两边都成功;外部 agent 进程 Before 无、After 有(adapter 自己的 stderr 被转发为 [external-agent claude-worker] [session/query] … apiType=native);.meta.json 的 model 从 qwen3.8-max-2026-09-02 变为 external-acp:node;transcript 工具名从四个 Qwen 工具变为 Bashwrite_file 计数为 0;token 记账从 118,209 全在 Qwen provider 变为 subagent 推理不在 Qwen provider 上;"executor 被忽略"的信号从"任何地方都没有"变为不适用(它被履行了)。

Web Shell:用无头 Playwright 截取,daemon 为 qwen serve,工作区的 .qwen/agents/claude-worker.mdexecutor 块。prompt 要求使用 claude-worker subagent,该轮在外部 Claude Code 进程中执行。

Web Shell prompt typed

Web Shell subagent

Web Shell 最终状态

Web Shell final state

该次运行经过独立于图片的核验:ws-shots.txt 内容确为 WS_OK,该会话的 subagent 元数据报 persistedCliFlags.model: "external-acp:node"agentType: "claude-worker"status: "completed",transcript 中 Bash 三次、qwen write_file 零次external-acp:node 正是本 PR 执行器通过 getCore().modelConfig.model 上报的标签;若静默回退到进程内,那里会是 Qwen 的 model id。

一处既有的环境干扰(与本 PR 无关,截取时在浏览器控制台可见):GET /standalone/sessions 返回 503 The Conversations runtime is owned by another daemon。它影响 standalone 会话列表,不影响上面展示的会话流程。

终端:同一次委派经交互式 CLI 驱动,用仓库自带的 terminal-capture(node-pty → xterm.js → Playwright headless)截取。附上是因为它比 Web Shell 的 transcript 视图更清晰地展示工具级输出。

prompt typed

delegation result

完整回滚缓冲

full scrollback

其运行同样经过核验:shots.txt 内容确为 SHOTS_OK,subagent 元数据报 model: "external-acp:node"status: "completed"

仍未截取:外部 agent 工具调用的审批对话框。它依赖权限 E2E(测试计划组 4 与组 5),该项尚未完成,是本 PR 安全攸关的缺口。

图片托管在作者 fork 的 assets-pr11003 分支 pr11003/ 下。

测试环境

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

本地构建(npm run build / npm run bundle),以 node packages/cli/dist/index.js 运行。Claude Code 2.1.259、@agentclientprotocol/claude-agent-acp 0.73.0。测试机的 ~/.claude/settings.json 设了 permissions.defaultMode: "auto",而这正是权限模式推导要覆盖掉的配置。

风险与范围

  • 主要风险/取舍:任何外部 agent 都不实现 qwen/* 扩展方法,因此七条 session 级诊断路由对它会返回 500。这不适用于本 PR 的委派路径 —— 被委派的 subagent 是 Qwen 会话内的一个任务,不是一个外部会话 —— 但它界定了将来"对等后端"设计能复用的范围。
  • core 的 SubagentExecutor 接口把 createAgentHeadless 的返回类型从具体类放宽为接口。七处生产调用点全部更新;AgentHeadless 声明了 implements,因此漂移会在编译期失败。
  • token 统计报 0。adapter 只暴露上下文窗口量规(usage_update {size, used}),那是水位而非每轮增量;把水位喂进累加式统计会让总量膨胀到超过窗口大小并过早触发 workflow 预算闸门。后果是外部 subagent 不推进 QWEN_CODE_MAX_TOKENS_PER_WORKFLOW。该闸门是 opt-in 且默认无上界,且 agent 数量上限与 max_turns / max_time_minutes 仍然生效。
  • 审批对话框使用 info 确认变体,因此 Web Shell 显示工具标题与选项名,但不渲染文件 diff。edit / exec 变体留作后续。
  • executor.command 指名一个来自项目级文件的可执行文件。这与既有 mcpServershooks 的 command 字段是同一套信任模型,因此没有引入新边界;解析器只从已知字段重建 spec,未识别的键到不了 spawn 点。
  • 未验证/超出范围:Windows 与 Linux;其他外部 agent(Codex、Gemini);与被委派 agent 直接对话(面板按既有设计就是"观察 + 停止");富 edit/exec 审批渲染;每轮 token 记账。
  • 破坏性变更/迁移说明:无。不带 executor 块时进程内路径逐字节不变,包括 system prompt —— AgentCore.buildChatSystemPrompt 现在委托给抽出的 renderSubagentSystemPrompt,行为一致。

关联 Issue

无。设计依据、本 PR 所依据的 ACP 实测行为、以及被否决的"对等后端"替代方案都在本 PR 新增的 docs/design/claude-code-web-shell-backend.md 里。

A subagent definition can now declare an `executor` block naming an external
agent process, and `createAgentHeadless` dispatches it to a host-registered
factory instead of always building an in-process `AgentHeadless`. This is the
seam for running a foreign coding agent as a delegated subagent: it needs no
change to workspace identity or to the bridge's one-channel-per-runtime
invariants, which is what makes it cheaper than exposing a foreign agent as a
peer session backend.

`AgentHeadless` now implements a `SubagentExecutor` interface narrowed to the
members production callers actually use (enumerated with call sites in the
design doc), and `AgentCore.buildChatSystemPrompt` is extracted as
`renderSubagentSystemPrompt` so an executor that never builds an `AgentCore`
still produces a byte-identical prompt. The extraction is a verbatim move; the
private method now delegates.

A definition that declares an executor while the host registered none fails
loudly rather than running in-process. Silent substitution is the shipped CLI's
behaviour today and was measured: the task completes successfully, the file is
written, and every token is billed to the qwen provider, with no signal on
stdout, stderr or the debug log that the executor was ignored. The block is also
re-validated at the dispatch point, because session-level subagents are injected
as plain objects by `loadSessionSubagents` and bypass frontmatter parsing
entirely — without that second check an arbitrarily shaped executor would reach
the host's spawn call.

Core gains no ACP dependency. The executor is injected through
`Config.setExternalAgentExecutor`, following the existing
`setSessionWorkflowEnabledProvider` / `setMcpBudgetEventCallback` inversion, and
the types a host needs are exposed on a narrow `./subagentRuntime` subpath rather
than the package root — the agent-runtime event contract should not become a
permanent public commitment as a side effect.

Design, evidence and the measured ACP behaviour this is built against:
docs/design/claude-code-web-shell-backend.md

Verification: 13 new tests — 9 for parseAgentExecutor covering each drop rule
plus the no-pass-through-of-unknown-keys property, and 4 for the dispatch
including the load-bearing assertion that AgentHeadless.create is NOT called
when the executor is missing or invalid. Core subagents and agent-runtime suites
357/357. Repo-wide typecheck, ESLint and Prettier clean. npm run build and
npm run bundle exit 0, with the new subpath's dist target confirmed emitted.
No behaviour change to the in-process path.
…t over ACP

Implements the host side of the seam added previously: an ExternalAgentExecutor
that spawns the declared command, speaks ACP to it, and re-publishes what happens
as AgentEventEmitter events, so the existing JSONL transcript writer,
SubAgentTracker permission bridge, virtual subagent sessions and Web Shell panel
all work unchanged. Injected from loadCliConfig through a lazy import so the ACP
dependency stays off the startup path for the common case where no subagent
declares an executor.

The permission mode is derived from the definition and never inherited from the
external agent's own config. With ~/.claude/settings.json at
`defaultMode: "auto"`, a Write was measured to execute with no permission request
emitted at all, so resolvePermissionMode always resolves to a mode and defaults
to the one that asks rather than declining to choose.

Also adds `dispose?()` to SubagentExecutor and composes it into the dispose that
createAgentHeadless returns. Without that the external agent process outlives its
subagent: the existing cleanup only unregisters hooks and stops the per-agent
ToolRegistry. Confirmed reaped after a real run.

Verified end to end against Claude Code. Delegating "create impl.txt containing
IMPL_OK" produces the file, and the subagent's .meta.json reports
`model: external-acp:node` with a `Bash` tool call and zero qwen `write_file`
calls. The baseline run of the same fixture on the shipped CLI instead reported
`model: qwen3.8-max-2026-09-02` with `write_file` twice and billed all 118k
tokens to the qwen provider — it silently ran the work itself.

Known limits, recorded in the executor header and the design doc: token
statistics report 0 because the adapter only exposes a context-window gauge
rather than per-turn deltas, so an external subagent does not advance
QWEN_CODE_MAX_TOKENS_PER_WORKFLOW; approval dialogs use the `info` confirmation
variant, so the Web Shell shows the tool title but not a rendered file diff.

Verification: 11 new unit tests covering the permission-mode fail-safe, the
outcome-to-option mapping and the model label; core subagent and agent-runtime
suites 357/357; repo typecheck, ESLint and Prettier clean; build and bundle
exit 0.
@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

@wenshao

wenshao commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

E2E report — re-verified against the committed code

The before/after table in the PR description was measured on a build that predates the last fix in cbd124f3ac (the TOOL_RESULT tool-name correlation). Re-ran the same fixture against a fresh npm run build of the committed source. Direction of every assertion is unchanged, and the re-run adds one data point that validates that fix directly.

Fixture: .qwen/agents/claude-worker.md with executor: { kind: acp, command: node, args: [<abs path to @agentclientprotocol/claude-agent-acp/dist/index.js>] }.

QWEN_RUNTIME_DIR=<fixture>/runtime \
  node packages/cli/dist/index.js \
  -p "Use the claude-worker subagent to create a file named impl.txt in the current directory containing exactly: IMPL_OK" \
  --approval-mode yolo --output-format json
Check Result
Exit / outcome 0, subtype: success, is_error: false, 6 turns
impl.txt IMPL_OK
3A.2 .meta.json persistedCliFlags.model external-acp:node (baseline: qwen3.8-max-2026-09-02)
3A.3 Claude tool names in subagent transcript "Bash" × 2 (baseline: 0)
3A.4 qwen write_file in subagent transcript 0 (baseline: 2)
external_tool placeholder in transcript 0 — confirms TOOL_RESULT now reports the same name TOOL_CALL emitted, rather than the fallback
External process actually ran adapter's own stderr forwarded: [external-agent claude-worker] [session/query] … apiType=native
Child reaped after the run pgrep -fl claude-agent-acp → none (validates the dispose?() composition)

Baseline column is the shipped CLI 0.23.0 run of the identical fixture, recorded during the pre-implementation dry run.

Still outstanding

  • Web Shell UI screenshots. Not captured; the Evidence section of the description says so rather than substituting text for images.
  • Permission E2E (test-plan groups 4 and 5). resolvePermissionMode's fail-safe is covered by unit tests only. It has not been verified end to end that a delegated Claude run actually surfaces session/request_permission through the parent session's approval UI, nor that the local ~/.claude/settings.json defaultMode: "auto" is overridden in practice. This is the security-relevant gap and should be closed before merge.
  • Groups 6 (cancel) and 7 (daemon + Web Shell data plane) not run.
  • Second and third self-audit passes on cbd124f3ac not completed; the first reverse pass found the tool-name correlation bug, and a fix resets the clean-pass count.

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR — the write-up is unusually thorough, which made the gate quick to run.

Template looks good ✓ — every required heading is present, including the Risk & Scope bullets and the full Chinese translation.

Problem. This is a feat, not a fix, so the bar is "is the capability gap real" rather than "where is the reproduction". It is real: the repo already mirrors Claude Code's .claude/agents/*.md frontmatter schema verbatim (agent-frontmatter-schema.ts carries claudePermissionModeToApprovalMode, the permission-mode enum, parseAgentMcpServers, parseAgentHooks), so definitions are already droppable into .qwen/agents/ — execution was the missing half.

One honest note on the before/after table. The "silent substitution" measurement injects an executor field the shipped CLI has no concept of, so 0.23.0 ignoring it is the documented behaviour of a deliberately lenient frontmatter parser rather than a defect in its own right. The measurement is still worth having — it is the evidence for the fail-loud choice (refuse rather than fall back), which I think is the right call and is asserted directly in subagent-manager.test.ts. But the motivating gap is the absent capability, not a bug in the shipped build, and the PR reads more strongly once it says so.

Direction. Aligned, and the rejected alternative is argued rather than asserted. Re-keying workspace identity from cwd to (cwd, backend) across both the daemon registry and the Web Shell session catalog is a far larger blast radius than delegating one turn inside an existing Qwen session, and keeping permissions, todos and artifacts owned by the parent is the right invariant to protect. The repo already treats "drive an external coding agent over ACP" as a first-party concept (packages/qwen-live/src/adaptor/acp-adaptor.ts), so this extends an existing direction instead of opening a new one. Claude Code's CHANGELOG has no direct reference to cross-vendor delegation, but subagent execution is a heavily-worked area there, so the surface is clearly relevant.

Size. Core paths are touched (packages/core/src/**, plus a cross-package seam into packages/cli). Of the 2462 changed lines: 1257 production code, 300 test, 905 docs (the design doc). You hold admin on this repo, so per AGENTS.md the two-tier core gate is maintainer-exempt and I am not escalating — the numbers are recorded so the size is visible, not as a block. For reference, the same diff from an external contributor would have carried both the 500+ maintainer-awareness flag and the 1000+ split advisory.

Approach. The scope feels right and the seam is genuinely narrow: one SubagentExecutor interface that AgentHeadless now implements, one injected factory on Config, and a subpath export so the event contract stays out of the package root. I verified the widening claim rather than taking it on faith — all six getCore() call sites (agent.ts 3061, 3295, 3296, 3313, 4144 and background-agent-resume.ts 1151) read exactly the three members SubagentExecutorCore declares, and there is no instanceof AgentHeadless anywhere in the tree, so nothing silently changes branch.

Two scope questions, neither a blocker. First, the design doc spends a good deal of its 905 lines on the rejected peer-backend alternative — useful rationale, but docs/design/ is tracked forever, so it is worth deciding deliberately whether the rejected design should be carried in full or summarised. Second, the file is named claude-code-web-shell-backend.md while the shipped feature is subagent delegation; a future reader looking for "how do external subagents work" will not find it under that name.

Risk. No match against the revert-history high-risk path patterns. The elevated risk in this PR sits entirely in the new CLI executor, and it comes out in Stage 2 — the core side is clean.

Moving on to code review. 🔍

中文说明

感谢贡献 —— PR 说明写得非常充分,让准入检查跑得很快。

模板 完整 ✓ —— 所有必需小节都在,包括 Risk & Scope 的三个要点和完整的中文翻译。

问题。 这是一个 feat 而不是 fix,所以判断标准是"能力缺口是否真实存在",而不是"复现在哪里"。缺口是真实的:本仓库已经逐字镜像了 Claude Code 的 .claude/agents/*.md frontmatter schema(agent-frontmatter-schema.ts 里有 claudePermissionModeToApprovalMode、permission-mode 枚举、parseAgentMcpServersparseAgentHooks),所以定义文件本来就能直接放进 .qwen/agents/ —— 缺的是执行这一半。

关于前后对照表,有一点需要直说。"静默替换"的实测方式是注入了一个已发布 CLI 根本没有概念的 executor 字段,因此 0.23.0 忽略它,是那个刻意宽松的 frontmatter 解析器的既定行为,本身并不构成缺陷。这个实测仍然有价值 —— 它是"显式失败"这个设计选择(拒绝执行而不是回退)的证据,而我认为这个选择是对的,并且在 subagent-manager.test.ts 里被直接断言了。但真正驱动本 PR 的缺口是能力缺失,不是已发布版本里的 bug;把这层说清楚,PR 的论证会更有力。

方向。 对齐,而且被否决的替代方案是有论证的,不是随口一提。把 workspace 身份从 cwd 重新键为 (cwd, backend),需要同时改动 daemon 注册表和 Web Shell 会话目录两层,影响面远大于"在一个既有 Qwen 会话里委派一轮任务";并且让权限、todos、artifacts 继续归父会话所有,是要保护的正确不变量。本仓库已经把"通过 ACP 驱动外部 coding agent"当作一方概念(packages/qwen-live/src/adaptor/acp-adaptor.ts),所以这是延伸既有方向,而不是新开一个方向。Claude Code 的 CHANGELOG 没有跨厂商委派的直接记录,但 subagent 执行在那里是被大量打磨的区域,说明这个面确实相关。

规模。 触及了核心路径(packages/core/src/**,外加一条跨包到 packages/cli 的接缝)。2462 行改动中:生产代码 1257 行测试 300 行文档 905 行(设计文档)。你在这个仓库有 admin 权限,因此按 AGENTS.md,核心的两级门禁对 maintainer 豁免,我不做上报 —— 列出数字只是让规模可见,不是拦截。作为参照:同样的 diff 如果来自外部贡献者,会同时触发 500+ 生产行的维护者知会,以及 1000+ 的拆分建议。

方案。 范围合理,接缝确实很窄:一个 SubagentExecutor 接口(AgentHeadless 现在 implements 它)、一个注入到 Config 的工厂、以及一个 subpath export,让事件契约不进入包根入口。我没有直接采信"调用点全部更新"的说法,而是自己核过 —— 全部六处 getCore() 调用点(agent.ts 的 3061、3295、3296、3313、4144 和 background-agent-resume.ts 的 1151)读取的成员,正好就是 SubagentExecutorCore 声明的那三个;整个代码树里没有任何 instanceof AgentHeadless,所以不会有地方静默改变分支。

两个范围问题,都不是阻塞项。第一,设计文档 905 行里有相当篇幅在写被否决的"对等后端"方案 —— 作为决策依据有价值,但 docs/design/ 是永久纳入版本控制的,所以值得明确决定:被否决的设计要完整保留,还是摘要即可。第二,文件名是 claude-code-web-shell-backend.md,而实际交付的能力是 subagent 委派;将来有人想查"外部 subagent 是怎么工作的",用这个名字找不到。

风险。 没有命中 revert 历史的高风险路径模式。本 PR 真正抬高的风险全部集中在新增的 CLI executor 里,会在 Stage 2 提出 —— core 这一侧是干净的。

进入代码审查 🔍

Qwen Code · qwen3.8-max-2026-09-02

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Code review

I read the diff against my own baseline for this problem (narrow executor interface in core, transport in the host package, strict validation of anything that names a process, re-publish foreign events onto the existing emitter). The PR's shape matches that baseline closely, and the core half is better than what I would have written — the findings below are all in the new CLI executor, packages/cli/src/external-agents/acp-subagent-executor.ts.

1. Critical — the spawned child inherits the parent's full environment, including Qwen-internal secrets

In create():

const child = spawn(params.spec.command, params.spec.args ?? [], {
  cwd, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true,
  env: process.env,
});

packages/core/src/utils/sanitize-child-env.ts states the invariant this breaks: QWEN_SERVER_TOKEN, QWEN_DAEMON_TOKEN, QWEN_CODE_EXTERNAL_TOOL_GUARD_TOKEN and QWEN_CODE_PRIVATE_ACP_CAPABILITY "must never be inherited by a child process the agent launches on the user's behalf — shell commands, the monitor tool, or a stdio MCP server … leaking them to arbitrary agent-run commands is a credential-exposure gap (issue #6601)". The same docstring says the private ACP capability is "never handed to the ACP child: … every spawn path explicitly scrubs it". This is a new ACP child spawn path, and it does not scrub.

The threat model is the one #6601 was filed for: executor.command comes from a project-level .qwen/agents/*.md file, so a repository a user merely cloned can name the executable that receives the daemon bearer token. packages/core/src/tools/mcp-client.ts:2564 does the equivalent spawn with sanitizeChildEnv(process.env).

To be fair about the PR body's argument: it says executor.command carries the same trust model as the existing mcpServers and hooks command fields. For which executable runs that is accurate, and the parser's rebuild-from-known-fields is well tested. But mcpServers also sanitizes what the child inherits, and this path does not — so the parity claim covers the command and not the env. One-call fix: env: sanitizeChildEnv(process.env).

2. Critical — the handshake has no deadline and no exit racer, so a silently-booting or cleanly-exiting child hangs create() forever

const spawnFailure = new Promise<never>((_resolve, reject) => {
  child.once('error', (error) => { reject(new Error(`… failed to spawn …`)); });
});

await Promise.race([this.requireConnection().initialize({}), spawnFailure]);

Only the error event can reject. Two reachable hangs follow:

  • The command exists, spawns, stays alive, and never speaks ACP — command: cat, an adapter blocked on a missing credential, a wrapper script. Neither racer settles. This one hangs regardless of what the SDK does, because there is no timeout anywhere on the path.
  • The command spawns and then exits non-zero. Node emits exit, not error, for a process that spawned successfully, so the most likely real-world failure — npx -y <pkg> unable to resolve offline, or the adapter erroring during boot — does not reject spawnFailure. Whether the SDK then rejects the pending initialize when stdout ends I could not verify statically (the SDK is not installed in this checkout), so I am not asserting this half with certainty; the missing deadline makes the point without it.

Either way a hung create() surfaces as an Agent tool call that never returns and never errors.

The repo has already learned this. packages/qwen-live/src/adaptor/acp-adaptor.ts:574-582 races initialize against both handshakeDeadline() (INIT_TIMEOUT_MS = 10_000) and an exitPromise rejecting on error and exit: "Race the handshake against both a timeout and child exit — a crash-on-boot must fail in milliseconds, not after 10s", "a child that dies before initialize completes must fail preflight immediately". This file reproduces the error-only half of qwen-live's comment verbatim ("A spawn failure (ENOENT) emits 'error' and never 'exit'…") without the exit rejection or the deadline. Both races here — initialize and newSession — need them.

Same root cause, worth fixing together: no child.on('exit') is attached after the handshake either, so a mid-turn crash has nothing to convert into an ERROR event or a rejected prompt(). qwen-live attaches a post-handshake exit handler that closes sessions and cancels parked permissions. Here pendingPermissions is drained only by killChild(), which nothing calls when the child dies on its own — so an approval dialog can stay parked against a process that no longer exists.

3. Critical — the permission-answer fallback can grant more than the user approved

respond: async (outcome) => {
  const wantKind = optionKindForOutcome(outcome);
  const match = options.find((option) => option.kind === wantKind) ?? options[0];
  
  pending?.resolve(outcome === ToolConfirmationOutcome.Cancel ? undefined : match?.optionId);
}

When the external agent offers no option of the wanted kind, this answers with options[0] — whatever the agent happened to list first. Approve "proceed once" against an offered set of [allow_always, reject_once] and the answer sent back is allow_always: a one-time approval silently becomes session-persistent. Cancel is handled correctly, and optionKindForOutcome is well tested including the ModifyWithEditor → reject case, but the selection fallback has no test.

It also contradicts the posture the file argues for elsewhere — resolvePermissionMode "never declines to choose … failing safe towards asking is the whole point", and parseAgentExecutor drops the whole field because "running with silently-truncated arguments is worse than not running". The same reasoning says: when the wanted kind is absent, fall back to a rejection, or to the least-permissive offered option — never to the first element. I cannot prove reachability statically (it depends on the adapter omitting allow_once for some tool kinds, and kind is compared strictly against an unknown, so an absent kind always takes the fallback), but the fix is a few characters and the downside is a permission grant the user did not make.

4. Suggestion — stderr forwarding should reuse createStderrForwarder

child.stderr?.on('data', (chunk: string) => {
  for (const line of chunk.split('\n')) { …process.stderr.write(`[external-agent ${params.name}] ${trimmed}\n`); }
});

@qwen-code/acp-bridge — already a packages/cli dependency — exports createStderrForwarder({ prefix }) for exactly this job. It buffers across chunks, splits on newline, and runs every line through redactLogCredentials before writing. The inline version does neither, so a credential the external agent echoes to stderr reaches the terminal and any captured log unredacted (where the repo's established ACP-child handling redacts it), and a line arriving across two chunks prints as two fragments. Reusing it also picks up the 64 KiB force-flush cap and the onDiagnosticLine hook.

5. Suggestion — executeExternalInputs diverges from the contract it implements, on two counters

AgentHeadless.executeExternalInputs delegates to execute(), which resets finalText and, by default, the execution stats. The ACP version resets neither:

  • finalText accumulates. The finishing-inputs path (agent.ts:3488) and the resume path (background-agent-resume.ts:1256) read getFinalText() for the turn's result, so a resident external subagent reports the previous turn's text concatenated with the new one.
  • options.resetStats is ignored. agent.ts:3495 calls execute(turnContextState, signal) with no third argument — that resets stats for AgentHeadless and accumulates here, so reported tool-call counts mean different things depending on which executor ran.

Both are one-liners, and cumulative counting may genuinely be what you want for an external agent. But then it is a deliberate difference from the interface's in-process implementation and deserves a comment saying so, rather than being incidental.

6. Suggestion — the lazy-import comment does not describe the code

packages/cli/src/config/config.ts says the import is lazy "to keep the ACP executor and its dependency off the CLI startup path for the overwhelmingly common case where no subagent declares an executor" — but the await import() sits unconditionally in loadCliConfig, so it runs on every invocation and the ACP SDK loads on every startup. Moving it inside the factory gets the described behaviour:

config.setExternalAgentExecutor({
  create: async (params) =>
    (await import('../external-agents/acp-subagent-executor.js'))
      .acpExternalAgentExecutor.create(params),
});

Either way, the comment should match reality — AGENTS.md asks comments to carry a non-obvious why, and this one currently states one that isn't true.

One smaller consistency note: TOOL_WAITING_APPROVAL hardcodes name: 'external_tool', while the tool_call branch goes to the trouble of recovering the real name from _meta.claudeCode.toolName. The approval dialog therefore names the tool generically even when the adapter told us what it was.

What I would keep exactly as it is

Worth saying plainly, since everything above lands in one file. The core seam is careful and I found nothing wrong in it: dispatch re-validates the executor block at the consumption point because session-level configs bypass frontmatter parsing, with a test for precisely that; the SubagentError re-throw stops the executor path being misreported as "Failed to create AgentHeadless"; dispose is composed so the child is reaped before hook cleanup while the cleanup still always runs; the permission mode is set at newSession rather than a later set_mode, closing the auto-approving window; and parseAgentExecutor rebuilding the spec from known fields only — with tests asserting env, cwd and shell cannot ride along — is the right instinct.

Runtime flow

sequenceDiagram
    participant P1 as Agent tool
    participant P2 as SubagentManager
    participant P3 as Config
    participant P4 as AcpSubagentExecutor
    participant P5 as External agent child
    participant P6 as AgentEventEmitter
    P1->>P2: createAgentHeadless(config)
    P2->>P2: parseAgentExecutor, re-validate at consumption point
    P2->>P3: getExternalAgentExecutor()
    P3-->>P2: factory injected by the CLI
    P2->>P4: create(params)
    P4->>P5: spawn command and args
    P4->>P5: initialize, raced against spawn error only
    P4->>P5: newSession with permissionMode in _meta
    P2-->>P1: subagent plus composed dispose
    P1->>P4: execute(context)
    P4->>P4: renderSubagentSystemPrompt
    P4->>P5: session prompt with system prompt and task
    P5-->>P4: session update chunks and tool calls
    P4->>P6: STREAM_TEXT, TOOL_CALL, TOOL_RESULT
    P5->>P4: session request permission
    P4->>P6: TOOL_WAITING_APPROVAL
    P6-->>P4: respond(outcome)
    P4-->>P5: selected optionId, or options 0 as fallback
    P5-->>P4: stopReason
    P4->>P6: FINISH
    P1->>P4: dispose, kills child then runCleanup
Loading

The two arrows that carry findings 2 and 3 are the initialize race and the fallback optionId.

Files changed (18)
File What changed
docs/design/claude-code-web-shell-backend.md New 905-line design doc: the delegation design, measured ACP behaviour, the rejected peer-backend alternative, and the per-member justification for the executor interface
packages/cli/src/config/config.ts Injects the ACP executor factory into Config at load time (finding 6 — the import is unconditional)
packages/cli/src/external-agents/acp-subagent-executor.ts New 694-line ACP client executor: spawn, handshake, prompt, session-update to agent-event mapping, permission bridging (findings 1-5)
packages/cli/src/external-agents/acp-subagent-executor.test.ts Tests the three pure helpers — permission-mode resolution, outcome to option-kind mapping, model label. No coverage of spawn, handshake, event mapping or the option fallback
packages/cli/vitest.config.ts Alias so CLI tests can resolve the new core subpath export
packages/core/package.json Adds the ./subagentRuntime subpath export, following the existing transcriptRecords pattern
packages/core/src/agents/background-agent-resume.ts Three type widenings from AgentHeadless to SubagentExecutor
packages/core/src/agents/runtime/agent-core.ts Extracts renderSubagentSystemPrompt as a stateless function; buildChatSystemPrompt now delegates to it with identical behaviour
packages/core/src/agents/runtime/agent-headless.ts Declares implements SubagentExecutor so drift fails at compile time
packages/core/src/agents/runtime/subagent-executor.ts New interface file: the executor contract, the narrowed core view, and the host factory type
packages/core/src/config/config.ts Adds the injected external executor field with setter and getter
packages/core/src/subagent-runtime.ts New bounded public surface re-exported at the subpath, deliberately kept out of the package root
packages/core/src/subagents/agent-frontmatter-schema.test.ts Thorough tests for parseAgentExecutor, including that unrecognized keys cannot reach the spawn site
packages/core/src/subagents/agent-frontmatter-schema.ts Adds parseAgentExecutor, rebuilding the spec from known fields only
packages/core/src/subagents/subagent-manager.test.ts The load-bearing refusal tests — including that AgentHeadless.create is never called when no executor is registered
packages/core/src/subagents/subagent-manager.ts Dispatch to the injected executor, re-validation at the consumption point, composed dispose, SubagentError re-throw, frontmatter wiring
packages/core/src/subagents/types.ts Adds SubagentExecutorSpec and the optional executor field on SubagentConfig
packages/core/src/tools/agent/agent.ts Four type widenings from AgentHeadless to SubagentExecutor

Testing

This is an unattended CI run, so I did not build or execute anything from this branch — the evidence below is the PR's own CI, read through the API for commit cbd124f3aca41166cf33fcbc06f96f75864062f5, fetched once with no polling. The unit suite was still running when I fetched; the table carries its real in-progress state rather than a guess, and the finalize job rewrites the region once CI settles. Bot orchestration jobs (triage, review-pr, ack-review-request and friends) are excluded from the table as noise, not as a judgement.

Nothing is red so far. What is not verified: whether the delegated path works end to end. The unit tests cover three pure functions and the manager dispatch — genuinely the right things to pin, and the refusal assertions are the load-bearing ones — but no automated check in this PR exercises a spawned ACP child. So the handshake behaviour in finding 2, the option-fallback behaviour in finding 3, and the central claim that the adapter honours _meta.permissionMode at newSession instead of inheriting ~/.claude/settings.json are all currently resting on the author's macOS-only measurement. The PR body itself records Windows and Linux as not tested and the Web Shell screenshots as outstanding.

Final CI results for cbd124f (auto-updated by the triage finalize job after CI completed):

Check Conclusion
Dependency CVE audit ❌ failure
Lint & Static (ubuntu-latest, Node 22.x) ❌ failure
Test (ubuntu-latest, Node 22.x) ❌ failure
Classify PR ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
Integration Tests (no-AK, No Sandbox) ✅ success
OpenTUI no-flicker gate ✅ success
Secret scan (TruffleHog) ✅ success
TUI parity snapshots (ink vs opentui) ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success

One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。

Sandboxed verification would settle this, and it is the only thing that can: @qwen-code /verify — that a delegated turn actually runs in the external process and reports external-acp:<cmd> rather than a Qwen model id, that the refusal path holds end to end, and above all that the child honours the derived permissionMode and asks instead of self-approving under a defaultMode: "auto" local config. That last one is the security-critical claim in the PR and nothing in the diff or the suite substantiates it — resolvePermissionMode being correct does not prove the adapter reads _meta.permissionMode. The same run would also settle whether findings 2 and 3 are reachable in practice rather than in principle. Since this branch is macOS-verified only, @qwen-code /tmux on Linux would additionally cover the approval-dialog surface the PR body lists as outstanding.

中文说明

代码审查。 我按自己的独立方案(core 里放一个窄接口、传输层放在宿主包、凡是指名进程的字段都严格校验、把外部事件重新发布到既有 emitter)对照读了 diff。本 PR 的结构与这个基线高度吻合,core 那一半写得比我会写的更好 —— 下面的问题全部集中在新增的 CLI executor packages/cli/src/external-agents/acp-subagent-executor.ts 里。

1. 严重 —— 子进程继承了父进程的完整环境变量,包括 Qwen 内部密钥。 create()spawn(..., { env: process.env })packages/core/src/utils/sanitize-child-env.ts 明确写了这条不变量:QWEN_SERVER_TOKENQWEN_DAEMON_TOKENQWEN_CODE_EXTERNAL_TOOL_GUARD_TOKENQWEN_CODE_PRIVATE_ACP_CAPABILITY "绝不能被 agent 代表用户启动的子进程继承 —— shell 命令、monitor 工具、或 stdio MCP server —— 泄露给任意由 agent 启动的命令是一个凭据暴露缺口(issue #6601)";同一份注释还写了私有 ACP capability "绝不交给 ACP 子进程:……每条 spawn 路径都显式清理它"。这是一条新的 ACP 子进程 spawn 路径,而它没有清理。威胁模型正是 #6601 针对的那个:executor.command 来自项目级 .qwen/agents/*.md,所以用户仅仅 clone 下来的仓库就能指名那个拿到 daemon bearer token 的可执行文件。mcp-client.ts:2564 的同类 spawn 用的是 sanitizeChildEnv(process.env)。关于 PR 说明里的论证需要公允地说:它讲 executor.command 与既有 mcpServershooks 的 command 字段是同一套信任模型 —— 对"运行哪个可执行文件"这一点是准确的,解析器"只从已知字段重建"也有充分测试。但 mcpServers 同时还净化了"子进程继承什么",而这条路径没有 —— 所以这个等价性论证覆盖了 command,没有覆盖 env。修复只需一次调用:env: sanitizeChildEnv(process.env)

2. 严重 —— 握手既没有超时也没有 exit 竞争者,因此静默启动或正常退出的子进程会让 create() 永久挂住。 spawnFailure 只在 error 事件上 reject,于是有两种可达的挂死:命令存在、启动成功、一直存活、但从不说 ACP(command: cat、等待缺失凭据的 adapter、阻塞的包装脚本)—— 两个竞争者都不会 settle,这一种与 SDK 行为无关,因为整条路径上没有任何超时;以及命令启动成功后非零退出 —— Node 对成功 spawn 的进程发的是 exit 而不是 error,所以最可能出现的真实故障(离线时 npx -y <pkg> 解析失败、adapter 启动即报错)不会 reject spawnFailure。至于 stdout 结束后 SDK 是否会 reject 挂起的 initialize,我无法静态核实(这个 checkout 里没有安装该 SDK),所以这一半我不做确定性断言 —— 缺失的超时已经足以说明问题。两种情况的表现都是一个永不返回、也永不报错的 Agent 工具调用。仓库里已经学到过这个教训:packages/qwen-live/src/adaptor/acp-adaptor.ts:574-582initialize 同时与 handshakeDeadline()INIT_TIMEOUT_MS = 10_000)和一个在 error exit 上都 reject 的 exitPromise 竞争,注释写着"把握手同时与超时和子进程退出竞争 —— 启动即崩溃必须在毫秒级失败,而不是 10 秒后"、"在 initialize 完成前就死掉的子进程必须立刻让预检失败"。本文件逐字复刻了 qwen-live 那段注释里只讲 error 的一半,却没有 exit 的 reject,也没有超时。这里的两处竞争(initializenewSession)都需要补上。同一根因、建议一起修:握手之后也没有挂 child.on('exit'),所以回合中途崩溃没有任何东西把它转成 ERROR 事件或让 prompt() reject。qwen-live 在握手成功后会挂一个 exit 处理器去关闭 session 并取消停放的权限请求;而这里 pendingPermissions 只由 killChild() 清理,子进程自己死掉时没人调用它 —— 于是审批对话框可能一直停放在一个已经不存在的进程上。

3. 严重 —— 权限应答的回退可能授予超出用户批准范围的权限。 respondoptions.find((option) => option.kind === wantKind) ?? options[0]:当外部 agent 没有提供所需 kind 的选项时,代码静默用 options[0] 应答,也就是 agent 恰好排在第一位的那个。对着 [allow_always, reject_once] 这样的选项集批准"仅此次",回传的答案就是 allow_always —— 一次性批准静默变成会话级持久批准。Cancel 处理是正确的,optionKindForOutcome 也有充分测试(含 ModifyWithEditor 映射为拒绝),但这个选项选择的回退没有任何测试。它也与本文件其他地方主张的姿态相矛盾 —— resolvePermissionMode "绝不拒绝做出选择……失败时倒向询问正是全部意义所在",parseAgentExecutor 丢弃整个字段是因为"带着被静默截断的参数运行,比根本不运行更糟"。同样的推理意味着:所需 kind 不存在时,应回退到拒绝,或回退到所提供选项中最不宽松的那个 —— 绝不是第一个元素。可达性我无法静态证明(取决于 adapter 是否在某些工具类型上不提供 allow_once;另外 kind 是与 unknown 做严格比较,所以 kind 缺失时必然走回退),但修复只需几个字符,而代价是一次用户并未做出的权限授予。

4. 建议 —— stderr 转发应复用 createStderrForwarder @qwen-code/acp-bridge(已经是 packages/cli 的依赖)导出的 createStderrForwarder({ prefix }) 正是干这件事的:跨 chunk 缓冲、按换行切分、并在写出前对每一行执行 redactLogCredentials。内联版本两者都没做,因此外部 agent 回显到 stderr 的凭据会未经脱敏地到达终端和任何被捕获的日志(而仓库既有的 ACP 子进程处理是脱敏的),并且跨两个 chunk 到达的一行会被打成两段碎片。复用它还能顺带获得 64 KiB 强制刷新上限和 onDiagnosticLine 钩子。

5. 建议 —— executeExternalInputs 在两个计数器上偏离了它实现的契约。 AgentHeadless.executeExternalInputs 委托给 execute(),后者会重置 finalText,并(默认)重置执行统计。ACP 版本两者都不重置:finalText 会累积 —— finishing-inputs 路径(agent.ts:3488)和 resume 路径(background-agent-resume.ts:1256)都用 getFinalText() 取本回合结果,所以常驻的外部 subagent 会把上一回合的文本和新回合的文本拼在一起上报;options.resetStats 被忽略 —— agent.ts:3495 调用 execute(turnContextState, signal) 时不传第三个参数,对 AgentHeadless 是重置统计,对这里是累积,于是上报的工具调用计数含义随执行器不同而不同。两处都是一行的事,而且对外部 agent 采用累积计数可能确实是你想要的 —— 但那样它就是与接口进程内实现之间的一个刻意差异,值得写一条注释说明,而不是顺手为之。

6. 建议 —— 懒加载的注释与代码不符。 packages/cli/src/config/config.ts 说这个 import 是懒加载,"以便在绝大多数没有 subagent 声明 executor 的场景下,把 ACP executor 及其依赖挡在 CLI 启动路径之外" —— 但 await import() 无条件地待在 loadCliConfig 里,每次调用都会执行,ACP SDK 每次启动都会加载。把 import 移进工厂里才能得到注释描述的行为。无论选哪种,注释都应与事实一致 —— AGENTS.md 要求注释承载不显然的 why,而这一条现在陈述的是一个不成立的 why。

另有一处小的一致性问题:TOOL_WAITING_APPROVAL 硬编码了 name: 'external_tool',而 tool_call 分支特意从 _meta.claudeCode.toolName 恢复了真实工具名。于是即便 adapter 已经告诉了我们是什么工具,审批对话框仍然用通用名称显示它。

我会原样保留的部分。 上面所有问题都落在同一个文件里,所以这一点值得明说:core 侧的接缝很用心,我没找到问题 —— 分发在消费点重新校验 executor 块(因为 session 级配置绕过了 frontmatter 解析),并且恰好有针对这一点的测试;SubagentError 的重新抛出避免了 executor 路径被误报为 "Failed to create AgentHeadless";dispose 被组合成先回收子进程、再跑 hook 清理,且清理总会执行;permission mode 在 newSession 时设置而不是之后的 set_mode,堵住了自动批准的窗口;parseAgentExecutor 只从已知字段重建 spec,并有测试断言 envcwdshell 无法夹带 —— 这些都是正确的直觉。

运行时流程。 见上方时序图(图内文字保持英文)。承载第 2、3 两条问题的是 initialize 竞争和回退 optionId 这两个箭头。

测试。 这是无人值守的 CI 运行,所以我没有构建或执行本分支的任何代码 —— 下面的证据是 PR 自己的 CI,通过 API 针对 commit cbd124f3aca41166cf33fcbc06f96f75864062f5 一次性读取,没有轮询。取数时单元测试仍在运行;表格如实记录 in-progress 状态而不是猜测结果,CI 落定后 finalize 任务会重写该区域。机器人编排任务(triagereview-prack-review-request 等)被排除在表格之外,是去噪,不是判断。

目前没有红灯。未验证的是:委派路径端到端是否真的工作。单元测试覆盖了三个纯函数和 manager 分发 —— 这确实该钉住,拒绝路径的断言也是最吃重的那些 —— 但本 PR 没有任何自动化检查真正启动过一个 ACP 子进程。因此第 2 条的握手行为、第 3 条的选项回退行为,以及"adapter 在 newSession 时履行 _meta.permissionMode、而不是继承 ~/.claude/settings.json"这个核心主张,目前全部依赖作者在 macOS 单平台上的实测。PR 说明自己也记录了 Windows 与 Linux 未测试、Web Shell 截图尚未提供。

沙箱化验证能解决这个问题,而且只有它能:@qwen-code /verify —— 验证一轮委派任务确实运行在外部进程里并上报 external-acp:<cmd> 而不是 Qwen 的 model id;拒绝路径端到端成立;最要紧的是子进程确实履行推导出的 permissionMode,在本地配置为 defaultMode: "auto" 时会询问而不是自我批准。最后这一条是本 PR 中安全攸关的主张,而 diff 和测试套件都没有支撑它 —— resolvePermissionMode 正确,并不等于 adapter 会读取 _meta.permissionMode。同一次运行也能确定第 2、3 条在实践中(而不只是在原理上)是否可达。由于本分支只在 macOS 上验证过,在 Linux 上跑 @qwen-code /tmux 还能覆盖 PR 说明中列为尚未提供的审批对话框界面。

Qwen Code · qwen3.8-max-2026-09-02

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 2/5 — the core seam is the best part of this PR and I would merge it unchanged; three concrete defects in the new CLI executor block it as-is, and all three have cheap fixes.

Stepping back. My independent baseline for this problem was: a narrow executor interface in core, transport in the host package, strict validation of anything naming a process, and foreign events re-published onto the existing emitter. The PR arrives at the same shape, and on the core side it is more careful than my baseline would have been — re-validating at the consumption point because session-level configs bypass frontmatter parsing, composing dispose so the child is reaped without leaking hook entries, setting the permission mode at newSession so there is no auto-approving window. I verified the interface-widening claim independently rather than trusting it: six getCore() call sites, three members, no instanceof AgentHeadless anywhere. That all holds.

Where I found a materially simpler path it missed is the transport file. This repo has already solved "spawn an external coding agent and drive it over ACP" twice — packages/qwen-live/src/adaptor/acp-adaptor.ts for the client side, and @qwen-code/acp-bridge's spawnChannel for the spawn, stderr-forwarding and child-env-scrubbing primitives. The new executor writes a third copy, and the three findings that matter are exactly the three things both existing implementations already handle: a handshake deadline plus an exit racer, credential redaction on forwarded stderr, and a sanitized child environment. That is the pattern worth naming — not "this file is too long", but "the lifecycle lessons this repo already paid for were not carried over". The file even quotes qwen-live's spawn-error comment verbatim while omitting the exit half of the race it belongs to, which suggests the precedent was read but not followed through.

Does it solve something users care about? Yes, and the direction argument is the strongest part of the PR body — re-keying workspace identity from cwd to (cwd, backend) across the daemon registry and the Web Shell catalog really is a much worse trade than delegating one turn inside a session the parent still owns. Is every change necessary? Yes; I found no drive-by refactor or formatting churn, and the in-process path is untouched absent an executor block. The only scope question is whether the rejected peer-backend design belongs in a tracked doc at full length.

The six-months test is what tips this from "defer" to "request changes" for me. The core interface I would thank you for. The executor I would quietly curse, because the PR body says Codex and Gemini are the intended next external agents — and whoever adds them will copy this file, propagating the missing timeout, the unredacted stderr and the unsanitized env into every adapter that follows. Fixing it now is three small edits; fixing it after two more adapters exist is a refactor.

Finding 1 is the one that is not a judgement call. sanitize-child-env.ts states that those four variables must never reach a child the agent launches on the user's behalf, names issue #6601, and says explicitly that the private ACP capability is never handed to the ACP child because every spawn path scrubs it. This is a new ACP spawn path taking its command from a project-level file, and it passes process.env through. mcp-client.ts does the equivalent spawn with sanitizeChildEnv. The PR body's parity argument with mcpServers is right about which executable runs and silent about what that executable inherits.

I am not requesting changes because I ran out of reasons to say no — I have three specific ones, each with a named in-repo precedent and a small diff. Nothing here argues against the feature or the design; I want this to land.

@wenshao — requested changes on the three Criticals in Stage 2. The sanitizeChildEnv call and the handshake deadline plus exit racer are both a few lines each and already precedented in-tree; the permission fallback is a change from options[0] to the least-permissive offered option. Findings 4-6 are suggestions and can ride along or go to a follow-up as you prefer. CI was still running when I reviewed, so I have not read a green suite on this commit — worth a @qwen-code /verify before merge regardless, since the claim that the adapter honours _meta.permissionMode rather than inheriting ~/.claude/settings.json is the security-critical part of the design and no automated check in the PR touches a real spawned child.

中文说明

信心:2/5 —— core 接缝是本 PR 最好的部分,我可以原样合入;新增 CLI executor 里的三个具体缺陷使它按现状无法合入,而三个都有很便宜的修法。

退一步看整体。我对这个问题的独立基线方案是:core 里一个窄 executor 接口、传输层放在宿主包、凡是指名进程的字段严格校验、外部事件重新发布到既有 emitter。本 PR 得到了同样的结构,而且在 core 一侧比我的基线更用心 —— 在消费点重新校验(因为 session 级配置绕过 frontmatter 解析)、组合 dispose 使子进程被回收且不泄漏 hook 条目、在 newSession 时设置 permission mode 从而不存在自动批准的窗口。接口放宽这个主张我是自己核过的,没有直接采信:六处 getCore() 调用点、三个成员、整棵树里没有任何 instanceof AgentHeadless。这些都成立。

我发现的、它没走到的更简路径在传输层文件上。本仓库已经两次解决过"启动一个外部 coding agent 并通过 ACP 驱动它"—— 客户端一侧是 packages/qwen-live/src/adaptor/acp-adaptor.ts,spawn、stderr 转发与子进程 env 清理这些原语是 @qwen-code/acp-bridgespawnChannel。新 executor 写了第三份副本,而真正要紧的三条问题,恰好就是这两个既有实现都已经处理好的三件事:握手超时加 exit 竞争者、转发 stderr 时的凭据脱敏、以及净化后的子进程环境。值得点出的是这个模式 —— 不是"这个文件太长",而是"这个仓库已经付过学费的生命周期教训没有被带过来"。文件甚至逐字引用了 qwen-live 的 spawn-error 注释,却省掉了它所隶属的那场竞争里的 exit 一半,这说明先例被读过,但没有被贯彻。

它是否解决了用户在意的问题?是。而且方向论证是 PR 说明里最强的部分 —— 把 workspace 身份从 cwd 重新键为 (cwd, backend),还要同时改 daemon 注册表和 Web Shell 会话目录,确实比"在父会话仍然拥有的会话里委派一轮任务"这个取舍糟糕得多。每一处改动都是必要的吗?是;我没有发现顺手重构或格式化噪声,且不带 executor 块时进程内路径完全未动。唯一的范围问题是:被否决的对等后端设计是否值得在纳入版本控制的文档里完整保留。

真正让我从"暂缓"倾向"要求修改"的是六个月后的维护视角。core 接口我会感谢你。executor 我会暗暗抱怨,因为 PR 说明写了 Codex 和 Gemini 是接下来打算接入的外部 agent —— 而接手的人会照抄这个文件,把缺失的超时、未脱敏的 stderr 和未净化的 env 一并带进之后每一个 adapter。现在修是三处小改动;等再多两个 adapter 之后修,就是一次重构。

第 1 条不是判断题。sanitize-child-env.ts 写明这四个变量绝不能到达 agent 代表用户启动的子进程,点名了 issue #6601,并明确说私有 ACP capability 绝不交给 ACP 子进程,因为每条 spawn 路径都会清理它。而这是一条新的 ACP spawn 路径,命令来自项目级文件,却把 process.env 原样传了过去。mcp-client.ts 的同类 spawn 用的是 sanitizeChildEnv。PR 说明中与 mcpServers 的等价性论证,讲清了运行哪个可执行文件,但对那个可执行文件继承什么保持沉默。

我要求修改,不是因为我找不出理由说不 —— 我有三条具体的,每条都有仓库内点得出来的先例和一个很小的 diff。这里没有任何内容是在反对这个能力或这个设计;我希望它合入。

@wenshao —— 就 Stage 2 的三条严重问题请求修改。sanitizeChildEnv 调用、以及握手超时加 exit 竞争者,各自都只有几行,而且在树内已有先例;权限回退是从 options[0] 改成所提供选项中最不宽松的那个。第 4-6 条是建议,可以一起改,也可以按你的偏好放到后续 PR。我审查时 CI 仍在运行,所以我没有读到这个 commit 上的绿色套件 —— 无论如何,合并前值得跑一次 @qwen-code /verify:adapter 会履行 _meta.permissionMode 而不是继承 ~/.claude/settings.json,这是本设计中安全攸关的部分,而 PR 里没有任何自动化检查真正接触过被 spawn 出来的子进程。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at cbd124f3aca41166cf33fcbc06f96f75864062f5 · 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.

Requesting changes on three Critical findings in the new ACP executor — full detail, with the in-repo precedent for each, is in the Stage 2 comment above.

  1. spawn(..., { env: process.env }) hands the child Qwen-internal secrets (QWEN_SERVER_TOKEN, QWEN_DAEMON_TOKEN, QWEN_CODE_EXTERNAL_TOOL_GUARD_TOKEN, QWEN_CODE_PRIVATE_ACP_CAPABILITY). sanitize-child-env.ts states these must never reach a child the agent launches on the user's behalf (issue #6601) and that the private ACP capability is never handed to an ACP child; mcp-client.ts:2564 does the equivalent spawn with sanitizeChildEnv. The command comes from a project-level agent file, so a cloned repository can name the executable that receives them. Fix: env: sanitizeChildEnv(process.env).

  2. The handshake has no deadline and no exit racer. spawnFailure rejects only on the error event, so a command that spawns, stays alive and never speaks ACP leaves create() pending forever — an Agent tool call that never returns and never errors. qwen-live/src/adaptor/acp-adaptor.ts:574-582 already races initialize against both handshakeDeadline() and an exitPromise rejecting on error and exit. Both races here (initialize, newSession) need it, and no post-handshake exit handler exists either, so a mid-turn crash leaves parked permissions attached to a dead process.

  3. options.find(o => o.kind === wantKind) ?? options[0] can answer a permission request with a more permissive option than the user approved — "proceed once" against [allow_always, reject_once] sends allow_always. It contradicts the fail-safe posture the same file argues for in resolvePermissionMode and parseAgentExecutor, and it is untested. Fall back to a rejection, or to the least-permissive offered option.

Suggestions 4-6 (reuse createStderrForwarder for redaction and cross-chunk line buffering; executeExternalInputs diverging from AgentHeadless on finalText and resetStats; the lazy-import comment describing behaviour the code does not have) are non-blocking — ride along or defer as you prefer.

None of this is an argument against the feature or the design. The core seam is careful and I verified the interface-widening claim independently: six getCore() call sites, exactly the three members SubagentExecutorCore declares, no instanceof AgentHeadless anywhere. I want this to land — the three fixes are small and all three are already precedented in-tree.

CI was still running at cbd124f3aca41166cf33fcbc06f96f75864062f5 when I reviewed, so I have not read a green suite on this commit.

中文说明

就新增 ACP executor 里的三条严重问题请求修改 —— 完整细节与每条对应的仓库内先例见上方的 Stage 2 评论。

  1. spawn(..., { env: process.env }) 把 Qwen 内部密钥(QWEN_SERVER_TOKENQWEN_DAEMON_TOKENQWEN_CODE_EXTERNAL_TOOL_GUARD_TOKENQWEN_CODE_PRIVATE_ACP_CAPABILITY)交给了子进程。sanitize-child-env.ts 写明这些变量绝不能到达 agent 代表用户启动的子进程(issue #6601),并说明私有 ACP capability 绝不交给 ACP 子进程;mcp-client.ts:2564 的同类 spawn 用的是 sanitizeChildEnv。命令来自项目级 agent 文件,因此一个被 clone 下来的仓库就能指名那个接收它们的可执行文件。修法:env: sanitizeChildEnv(process.env)

  2. 握手既无超时也无 exit 竞争者。 spawnFailure 只在 error 事件上 reject,所以一个启动成功、保持存活、却从不说 ACP 的命令会让 create() 永久挂起 —— 一个永不返回也永不报错的 Agent 工具调用。qwen-live/src/adaptor/acp-adaptor.ts:574-582 已经让 initialize 同时与 handshakeDeadline() 和一个在 error exit 上都 reject 的 exitPromise 竞争。这里两处竞争(initializenewSession)都需要,而且握手之后也没有 exit 处理器,因此回合中途崩溃会把停放的权限请求留在一个已死的进程上。

  3. options.find(o => o.kind === wantKind) ?? options[0] 可能用一个比用户批准范围更宽松的选项去应答权限请求 —— 对着 [allow_always, reject_once] 选择"仅此次",回传的是 allow_always。这与同一文件在 resolvePermissionModeparseAgentExecutor 中主张的失败即安全姿态相矛盾,且没有测试。应回退到拒绝,或回退到所提供选项中最不宽松的那个。

建议 4-6(复用 createStderrForwarder 以获得脱敏与跨 chunk 的行缓冲;executeExternalInputsfinalTextresetStats 上偏离 AgentHeadless;懒加载注释描述了代码并不具备的行为)不阻塞合并 —— 一起改或后续处理都可以。

这些都不是在反对这个能力或这个设计。core 接缝很用心,而且接口放宽这个主张我是独立核过的:六处 getCore() 调用点,正好是 SubagentExecutorCore 声明的那三个成员,整棵树里没有任何 instanceof AgentHeadless。我希望它合入 —— 三处修改都很小,而且三条在树内都已有先例。

我审查时 CI 仍在 cbd124f3aca41166cf33fcbc06f96f75864062f5 上运行,所以我没有读到这个 commit 的绿色套件。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at cbd124f3aca41166cf33fcbc06f96f75864062f5 · 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.

Partially reviewed — gaps disclosed.

1 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • R1-12 unconditional await import of the ACP executor in loadCliConfig with a false lazy-import comment — already reported (triage review 5109679716, Suggestion 6)

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5.

中文说明

仅完成部分审查,审查缺口已披露。

本轮确认的 1 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

未审查:反向审计——在 5 轮的反审轮数上限内未收敛。

— qwen3.8-max via Qwen Code /review (v0.23.0)

cwd,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
env: process.env,

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.

[Critical] R1-56: The spawned child inherits the parent's full environment — env: process.env — including the Qwen-internal secrets that sanitize-child-env.ts exists to scrub: QWEN_SERVER_TOKEN (the serve-daemon bearer token), QWEN_DAEMON_TOKEN, QWEN_CODE_EXTERNAL_TOOL_GUARD_TOKEN, and QWEN_CODE_PRIVATE_ACP_CAPABILITY. The command comes from a project-level .qwen/agents/*.md file, so a repository a user merely clones can name the executable that receives them. The invariant doc states these variables must never reach a child the agent launches on the user's behalf (issue #6601) and that the private ACP capability is never handed to an ACP child; the equivalent MCP spawn already sanitizes (mcp-client.ts:2564). This was raised as Critical 1 in the earlier triage review (review 5109679716) and is still present at HEAD. Use env: sanitizeChildEnv(process.env).

Witness:

acp-subagent-executor.ts:191-195  spawn(params.spec.command, params.spec.args ?? [], { cwd, stdio, windowsHide: true, env: process.env })
sanitize-child-env.ts:37-39  'QWEN_SERVER_TOKEN', 'QWEN_DAEMON_TOKEN', 'QWEN_CODE_EXTERNAL_TOOL_GUARD_TOKEN'
mcp-client.ts:2564  ...normalizePathEnvForWindows(sanitizeChildEnv(process.env)),
Suggested change
env: process.env,
env: sanitizeChildEnv(process.env),

The fix must keep all four variables out: sanitize-child-env.ts documents that QWEN_CODE_PRIVATE_ACP_CAPABILITY is never handed to an ACP child and that every spawn path explicitly scrubs it (sanitize-child-env.ts:16-20). Please add a dispatch test asserting the spawned child's env lacks QWEN_SERVER_TOKEN and QWEN_CODE_PRIVATE_ACP_CAPABILITY, and confirm it goes red when sanitizeChildEnv is removed.

中文说明

被 spawn 的子进程继承了父进程的完整环境变量(env: process.env),包括 sanitize-child-env.ts 专门要清理的 Qwen 内部密钥:QWEN_SERVER_TOKEN(serve daemon 的 bearer token)、QWEN_DAEMON_TOKENQWEN_CODE_EXTERNAL_TOOL_GUARD_TOKENQWEN_CODE_PRIVATE_ACP_CAPABILITY。命令来自项目级 .qwen/agents/*.md 文件,因此用户仅仅 clone 下来的仓库就能指名那个接收这些密钥的可执行文件。不变量注释写明这些变量绝不能到达 agent 代表用户启动的子进程(issue #6601),且私有 ACP capability 绝不交给 ACP 子进程;同类的 MCP spawn 已经做了净化(mcp-client.ts:2564)。此问题在之前的 triage 评审(review 5109679716)中作为 Critical 1 提出,HEAD 上仍然存在。修法:env: sanitizeChildEnv(process.env)。修复必须让四个变量全部不可见(约束见 sanitize-child-env.ts:16-20)。请补一个 dispatch 测试,断言子进程环境中不含 QWEN_SERVER_TOKEN 与 QWEN_CODE_PRIVATE_ACP_CAPABILITY,并确认移除 sanitizeChildEnv 后该测试变红。

— qwen3.8-max via Qwen Code /review (v0.23.0)

}

try {
if (config.executor) {

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.

[Critical] R1-1: [certifies-falsely] [new-surface] The executor branch spawns a definition-supplied external command with no folder-trust gate, unlike the hooks branch directly above it (subagent-manager.ts:934 ignores project-agent hooks when the folder is untrusted) and MCP discovery (mcp-client-manager.ts:1066/:2134 return early). Project agents load regardless of trust, the Agent tool has no shouldConfirmExecute, and the CLI registers the executor unconditionally — so opening a malicious repo without trusting it and invoking an agent whose definition declares executor: {kind: acp, command: '/bin/sh', args: ['-c', '<payload>']} runs the repo-chosen command on the host with no trust prompt. The types.ts claim that the executor command carries 'the same trust model as the existing mcpServers and hooks command fields' is contradicted by those very gates. Gate the executor branch the same way hooks are gated: refuse with a SubagentError when config.level === 'project' and the folder is untrusted.

Witness:

PROBE (unmodified PR, isTrustedFolder()=false, level=project):
  DISPATCHED: executor.create called 1x in UNTRUSTED folder
PROBE (with hooks-style gate applied):
  REFUSED: Subagent "helper" is a project agent in an untrusted folder; refusing to run its executor (repo-supplied command execution).

The gate must match the existing one at subagent-manager.ts:934 (config.level === 'project' && !runtimeContext.isTrustedFolder()), and session/user-level agents must keep working. Please add a subagent-manager.test.ts case: project-level config with an executor, isTrustedFolder() mocked false, asserting createAgentHeadless rejects and the executor's create is never called — and confirm it goes red when the gate is removed.

中文说明

executor 分支在没有任何文件夹信任闸门的情况下 spawn 定义中指名的外部命令,而紧邻其上的 hooks 分支有(subagent-manager.ts:934 在未信任文件夹中忽略项目级 agent 的 hooks),MCP 发现也有(mcp-client-manager.ts:1066/:2134 提前返回)。项目级 agent 无论是否信任都会加载,Agent 工具没有 shouldConfirmExecute,CLI 又无条件注册了执行器——因此打开一个未信任的恶意仓库、调用声明了 executor: {kind: acp, command: '/bin/sh', args: ['-c', '<payload>']} 的 agent,就会在宿主上执行仓库指名的命令,没有任何信任提示。types.ts 中"与既有 mcpServers 和 hooks command 字段同一信任模型"的说法恰被这些闸门本身否定。修法:按 hooks 的方式给 executor 分支加闸——config.level === 'project' 且文件夹未信任时以 SubagentError 拒绝。探针证据:未修改的 PR 在未信任文件夹中 DISPATCHED;加上 hooks 式闸门后 REFUSED。约束:闸门须与 subagent-manager.ts:934 一致,session/user 级 agent 必须继续可用。请补测试(项目级 + executor + 未信任,断言拒绝且 executor.create 未被调用),并确认移除闸门后测试变红。

— qwen3.8-max via Qwen Code /review (v0.23.0)

const subagent = await externalExecutor.create({
spec: executorSpec,
name: config.name,
...(config.approvalMode

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.

[Critical] R1-2: [certifies-falsely] [new-surface] The dispatch spreads the definition's raw approvalMode/permissionMode into the external executor params, bypassing the trust degradation the in-process path applies: resolveSubagentApprovalMode (agent.ts:377-390) degrades Yolo/AutoEdit/Auto declared by untrusted-repo agents to the parent's mode. The CLI maps the raw values verbatim (yoloauto, bypassPermissionsbypassPermissions, acp-subagent-executor.ts resolvePermissionMode), so a repo agent file declaring permissionMode: bypassPermissions (a valid frontmatter value) plus an executor gets a self-approving external session in a folder the trust system explicitly did not grant that autonomy. Derive the approval intent from the trust-resolved runtime context instead of the raw declaration.

Witness:

PROBE (unmodified PR, isTrustedFolder=false):
  executor received approvalMode="yolo" permissionMode="bypassPermissions" while isTrustedFolder=false (parent resolved mode=default)
PROBE (implied fix — resolved mode instead of raw fields):
  executor received approvalMode="default" permissionMode=undefined

The degradation to reproduce is agent.ts:383-390, and the CLI's fail-safe (absent/unrecognized modes map to 'default') must be preserved. Please add a subagent-manager.test.ts case: isTrustedFolder false, config declares approvalMode 'yolo' with an executor; assert create.mock.calls[0][0].approvalMode is not the raw 'yolo' — and confirm it goes red when the fix is removed.

中文说明

dispatch 把定义里原始的 approvalMode/permissionMode 直接展开传给外部执行器,绕过了进程内路径的信任降级:resolveSubagentApprovalMode(agent.ts:377-390)会把未信任仓库 agent 声明的 Yolo/AutoEdit/Auto 降级为父会话的模式。CLI 逐字映射原始值(yoloautobypassPermissionsbypassPermissions),因此一个声明了 permissionMode: bypassPermissions(合法 frontmatter 值)加 executor 的仓库 agent 文件,会在一个信任体系明确未授予该自主权的文件夹里得到自我批准的外部会话。修法:从信任解析后的 runtime context 推导审批意图,而不是原始声明。探针证据:未修改的 PR 在 isTrustedFolder=false 时执行器收到 approvalMode=yolo permissionMode=bypassPermissions;采用解析后模式后变为 default/undefined。约束:需复刻 agent.ts:383-390 的降级,且保留 CLI 的失败即询问(缺省/未识别 → default)。请补测试并确认移除修复后变红。

— qwen3.8-max via Qwen Code /review (v0.23.0)

// Set at creation rather than via a later session/set_mode so there is
// no window in which the agent runs under its own auto-approving
// local default.
_meta: {

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.

[Critical] R1-4: [certifies-falsely] [new-surface] The derived permission mode is sent under session/new's _meta, which the Claude ACP adapter never reads in any published version — so the session silently inherits ~/.claude/settings.json permissions.defaultMode, inverting the module header's guarantee ('The permission mode is derived from the subagent definition, never inherited from the external agent's own local config'). I fetched the real adapter from npm in both versions the executor can launch: v0.73.0 (the spike version) and v0.74.0 (what the unpinned npx -y resolves to). Both build creationOpts only from params._meta?.claudeCode?.options?.resume and compute initialPermissionMode = creationOpts.permissionMode ?? <mode resolved from local settings>; grep across both adapters finds zero reads of _meta.permissionMode. Your own design doc records the spike-verified acceptance location as creationOpts.permissionMode (doc lines 151, 796, 883) with session/set_mode as the measured fallback — neither is used. On any machine with defaultMode: 'auto' (which the doc's spike measured on the author's machine) every tool call self-approves and no permission request ever reaches the approval bridge — including for definitions that declare nothing, which the committed test says 'must therefore resolve to a mode that asks'. Send the mode where the spike verified it.

Witness:

@agentclientprotocol/claude-agent-acp v0.73.0 dist/acp-agent.js:868-875 / :5205-5206 (v0.74.0 :869-881 / :5439-5440 identical shape):
  newSession -> createSession(params, { resume: params._meta?.claudeCode?.options?.resume })
  const permissionMode = resolvePermissionMode(settingsManager.getSettings().permissions?.defaultMode, this.logger);
  const initialPermissionMode = creationOpts.permissionMode ?? permissionMode;
grep _meta.permissionMode across both adapters: 0 reads

witness: not run end-to-end — executing the adapter requires an authenticated Claude CLI, which this machine does not have; the verdict rests on the shipped adapter source itself (the authority on which location it honours), fetched in both published versions.

The reply side must stay intact: the adapter validates that permission answers select an offered optionId (the onRequestPermission round-trip depends on it) — changing where the mode is set must not touch the options/optionId flow. Please add a wire-level test (fake ACP server) asserting creationOpts.permissionMode on session/new, and confirm it goes red if the field moves back to _meta.

中文说明

推导出的权限模式被放在 session/new_meta 里,而 Claude ACP adapter 在任何已发布版本中都不读取它——于是会话静默继承 ~/.claude/settings.jsonpermissions.defaultMode,与模块头部"权限模式取自 subagent 定义、绝不继承外部 agent 本机配置"的保证正好相反。我从 npm 拉取了执行器可能启动的两个版本实测:v0.73.0(spike 版本)与 v0.74.0(未锁版本的 npx -y 实际解析到的版本)。两者都只用 params._meta?.claudeCode?.options?.resume 构造 creationOpts,并以 initialPermissionMode = creationOpts.permissionMode ?? <本机设置解析出的模式> 计算初始模式;两个版本中 _meta.permissionMode 的读取次数为 0。设计文档自己记录了 spike 验证过的接收位置是 creationOpts.permissionMode(doc 151、796、883 行),并记录了 session/set_mode 作为实测可用的兜底——两者都没有被使用。在任何设置了 defaultMode: 'auto'(文档 spike 在作者机器上实测到过)的机器上,每个工具调用都会自我批准,权限请求永远不会到达审批桥——包括对那些什么都没声明的定义(已提交的测试说它"必须解析到一个会询问的模式")。修法:把模式放到 spike 验证过的位置。端到端无法在本机执行(需要已认证的 Claude CLI),结论基于两个已发布版本的 adapter 源码本身。约束:改动不得触碰 options/optionId 应答往返(adapter 会校验应答必须选择一个被提供的 optionId)。请补 wire 级测试(假 ACP server)断言 session/new 携带 creationOpts.permissionMode,字段移回 _meta 时测试变红。

— qwen3.8-max via Qwen Code /review (v0.23.0)

permissionMode: string | undefined,
approvalMode: string | undefined,
): string {
const declared = (permissionMode ?? approvalMode ?? '').trim().toLowerCase();

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.

[Critical] R1-39: [certifies-falsely] [new-surface] resolvePermissionMode prefers the raw permissionMode over approvalMode, which inverts the loader contract. The loader bridges permissionMode only when approvalMode is unset (subagent-manager.ts:1779-1782) — an explicit approvalMode is authoritative, pinned by subagent-manager.test.ts:864 ('should prefer explicit approvalMode over permissionMode bridge'); types.ts:150-153 documents 'if both permissionMode and approvalMode are present in frontmatter, approvalMode wins'; and the SubagentExecutor contract says the executor MUST derive the foreign agent's permission mode from approvalMode (subagent-executor.ts:124-131). Dispatch forwards both fields, so a definition declaring permissionMode: bypassPermissions + approvalMode: plan runs in-process as plan but externally as bypassPermissions; permissionMode: auto + approvalMode: default escalates to Claude auto. The new test 'prefers permissionMode over approvalMode' pins the inversion on the false premise that approvalMode is always the bridged fallback. Invert the precedence.

Witness:

PROBE BASELINE (params {permissionMode:'bypassPermissions', approvalMode:'plan'} — the loader-resolved shape of a both-declared definition):
  session_new_meta: {"permissionMode":"bypassPermissions"}   resolve('bypassPermissions','plan') -> 'bypassPermissions'
PROBE FIXED ((approvalMode ?? permissionMode ?? '')):
  session_new_meta: {"permissionMode":"plan"}   resolve('bypassPermissions','plan') -> 'plan'; single-declaration cases unchanged

Note the bridge is lossy — claudePermissionModeToApprovalMode('auto') returns 'auto-edit' (pinned at agent-frontmatter-schema.test.ts:54) — so a fix that routes through the bridged value must keep that calibration, not re-widen it. Please replace the 'prefers permissionMode over approvalMode' test with the loader-precedence assertion (('bypassPermissions','plan') → 'plan') and confirm reverting the precedence turns it red.

中文说明

resolvePermissionMode 优先取原始 permissionMode 而非 approvalMode,与加载器契约相反。加载器只在 approvalMode 未设置时才桥接 permissionMode(subagent-manager.ts:1779-1782)——显式 approvalMode 是权威,subagent-manager.test.ts:864 已钉住;types.ts:150-153 写明两者同时存在时 approvalMode 获胜;SubagentExecutor 契约也要求执行器必须从 approvalMode 推导外部 agent 的权限模式(subagent-executor.ts:124-131)。dispatch 同时转发两个字段,因此声明 permissionMode: bypassPermissions + approvalMode: plan 的定义在进程内按 plan 运行、在外部却按 bypassPermissions 运行;permissionMode: auto + approvalMode: default 会升级为 Claude auto。新测试 'prefers permissionMode over approvalMode' 以"approvalMode 总是桥接兜底"这一错误前提钉住了这个倒置。修法:颠倒优先级。探针证据:基线 session_new_meta 为 bypassPermissions;改为 (approvalMode ?? permissionMode) 后为 plan,单声明场景不变。约束:桥接是有损的('auto' → 'auto-edit',agent-frontmatter-schema.test.ts:54 已钉住),经由桥接值的修复必须保持该校准。请把上述测试替换为加载器优先级断言,并确认还原优先级后测试变红。

— qwen3.8-max via Qwen Code /review (v0.23.0)

}
this.pendingPermissions.clear();
if (this.child.exitCode === null && this.child.signalCode === null) {
this.child.kill('SIGTERM');

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.

[Suggestion] R1-38: dispose()/killChild() sends a single SIGTERM with no exit verification and no SIGKILL escalation, so the reap guarantee ('reaps the child') silently fails for any child that survives SIGTERM. The definition-declared command is arbitrary (see the trust-gate finding), and may trap SIGTERM or be slow to process it (blocked in a long-running child of its own): dispose() returns having only signalled, the parent proceeds as if the agent were reaped, and the external process keeps executing file/shell tools in the workspace after 'cancel'. The probe constructed exactly this child and watched it outlive dispose(). After SIGTERM, await the child's 'exit' with a bounded grace period and escalate to kill('SIGKILL') if it has not exited.

Witness:

PROBE (child with process.on('SIGTERM', () => {}) and 200ms heartbeats):
  intact PR:  dispose() returned; heartbeats 2 -> 6 -> 10;  child still alive after dispose = true
  with 2s-grace + SIGKILL escalation: heartbeats freeze at 12;  child still alive after dispose = false;  dispose stayed time-bounded (~2s)

The composed dispose awaits subagent.dispose?.() before runCleanup() (subagent-manager.ts:1013-1024), so any added wait must be time-bounded to avoid stalling hook/registry cleanup. Please add a test: a SIGTERM-ignoring child receives SIGKILL from dispose(); the current single-signal code must fail it.

中文说明

dispose()/killChild() 只发一次 SIGTERM,不验证退出、也不升级为 SIGKILL,因此对任何能在 SIGTERM 下存活的孩子,"回收子进程"的保证静默失效。定义指名的命令是任意的(见信任闸门问题),可能捕获 SIGTERM 或处理缓慢(阻塞在自己的长任务里):dispose() 只是发了信号就返回,父流程以为 agent 已被回收,而外部进程在"取消"之后继续在工作区执行文件/shell 工具。探针构造了这样一个孩子并看着它活得比 dispose() 更久。修法:SIGTERM 后带时限等待 'exit',超时未退出则升级 kill('SIGKILL')。约束:组合 dispose 在 runCleanup() 之前 await subagent.dispose?.()(subagent-manager.ts:1013-1024),新增等待必须有界,避免拖住 hook/registry 清理。请补测试:忽略 SIGTERM 的孩子应收到 dispose() 的 SIGKILL,当前单信号实现应失败。

— qwen3.8-max via Qwen Code /review (v0.23.0)

execute(
context: ContextState,
externalSignal?: AbortSignal,
options?: { resetStats?: boolean },

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.

[Suggestion] R1-44: The contract's options.resetStats is honoured only by the in-process executor; AcpSubagentExecutor.execute() never accepts it — a dead switch. Four call sites pass {resetStats: false} for continuation turns that must accumulate (agent.ts:1924/:3492, background-agent-resume.ts:1260/:1841); in-process, AgentHeadless.execute then skips resetExecutionStats (const resetStats = options.resetStats !== false, agent-headless.ts:235) so totalDurationMs spans the whole chain. The external executor instead resets startedAtMs = Date.now() unconditionally while this.round += 1 and tool counters accumulate — so after a stop-hook-blocked continuation (or any multi-execute() sequence) getExecutionSummary() reports rounds: N with a duration covering only the last turn. Those numbers feed getCompletionStats → registry.complete/fail (the Web Shell background panel) and getAgentMetaTerminalSummary → persisted agent meta: multi-turn external runs publish under-reported, internally inconsistent duration stats that the in-process executor would not. Accept options?: { resetStats?: boolean } in AcpSubagentExecutor.execute and mirror the in-process default.

Witness:

PROBE (two execute() calls ~300ms apart against the real executor):
  summary_after_first:  {rounds: 1, totalDurationMs: 301}
  summary_after_second: {rounds: 2, totalDurationMs: 301}   <- duration covers only the last turn (accumulating behaviour would read >=600ms)
in-process: agent-headless.ts:235  const resetStats = options.resetStats !== false;

The mirror must be exact: when options.resetStats !== false reset startedAtMs/round/tool counters; when false keep them (still reset per-turn finalText/terminateMode as today). Please add a test: two execute() calls, the second with {resetStats: false}, asserting rounds === 2 with totalDurationMs spanning both turns; restoring the unconditional startedAtMs reset must turn it red.

中文说明

契约中的 options.resetStats 只有进程内执行器履行;AcpSubagentExecutor.execute() 从不接受它——一个死开关。四个调用点为必须累计的续接回合传 {resetStats: false}(agent.ts:1924/:3492、background-agent-resume.ts:1260/:1841);进程内 AgentHeadless.execute 此时跳过 resetExecutionStats(const resetStats = options.resetStats !== false,agent-headless.ts:235),使 totalDurationMs 覆盖整条链。外部执行器却无条件重置 startedAtMs = Date.now(),同时 this.round += 1、工具计数持续累积——于是在停止钩子阻塞后的续接(或任何多次 execute() 序列)之后,getExecutionSummary() 报告 rounds: N 而时长只覆盖最后一回合。这些数字喂给 getCompletionStats → registry.complete/fail(Web Shell 后台面板)和 getAgentMetaTerminalSummary → 持久化的 agent meta:多回合外部运行会发布少报且自相矛盾的时长统计,进程内执行器不会如此。修法:让 AcpSubagentExecutor.execute 接受 options?: { resetStats?: boolean } 并镜像进程内默认值。请补测试:两次 execute(),第二次传 {resetStats: false},断言 rounds === 2 且 totalDurationMs 覆盖两个回合;恢复无条件重置后应变红。

— qwen3.8-max via Qwen Code /review (v0.23.0)

__dirname,
'../core/src/utils/envVarResolver.ts',
),
'@qwen-code/qwen-code-core/subagentRuntime': path.resolve(

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.

[Suggestion] R1-52: The new @qwen-code/qwen-code-core/subagentRuntime subpath gets a vitest alias and a package.json exports entry, but no explicit paths mapping in packages/cli/tsconfig.json. The wildcard '@qwen-code/qwen-code-core/' → '../core/src/' yields '../core/src/subagentRuntime' — a path that does not exist (the file is subagent-runtime.ts) — so CLI typechecking resolves the executor contract through the BUILT core dist instead of source. The three existing camelCase subpaths the CLI imports (subSessionConstants, transcriptRecords, noFollowOpen — tsconfig.json:11-19) all carry explicit source mappings precisely because the wildcard cannot reach kebab-case filenames, and AGENTS.md names this stale-dist hazard explicitly. Concrete trigger: a developer edits the executor contract in packages/core and runs CLI typecheck/IDE checks without rebuilding core — acp-subagent-executor.ts typechecks clean against stale dist declarations while vitest (aliased to source) sees the new contract, so the mismatch surfaces only later in CI/preflight. Add the explicit mapping.

Witness:

tsc -p packages/cli --noEmit --traceResolution:
  Module name '@qwen-code/qwen-code-core/subagentRuntime', matched pattern '@qwen-code/qwen-code-core/*'.
  Trying substitution '../core/src/*', candidate module location: '../core/src/subagentRuntime'.
  Directory '.../packages/core/src/subagentRuntime' does not exist, skipping all lookups in it.
  Using 'exports' subpath './subagentRuntime' with target './dist/src/subagent-runtime.d.ts'.
With the mapping added: resolves to .../packages/core/src/subagent-runtime.ts (source)

The new entry must precede the wildcard (precedent: '@qwen-code/qwen-code-core/transcriptRecords': ['../core/src/utils/transcript-records.ts'], tsconfig.json:14-16). The fix lives in packages/cli/tsconfig.json, not the anchored file: add "@qwen-code/qwen-code-core/subagentRuntime": ["../core/src/subagent-runtime.ts"] to compilerOptions.paths.

中文说明

新的 @qwen-code/qwen-code-core/subagentRuntime 子路径有了 vitest 别名和 package.json exports 条目,却没有 packages/cli/tsconfig.json 里的显式 paths 映射。通配符 '@qwen-code/qwen-code-core/' → '../core/src/' 会替换出不存在的 '../core/src/subagentRuntime'(文件是 subagent-runtime.ts)——于是 CLI 类型检查经由已构建的 core dist 而非源码解析执行器契约。CLI 引入的三个既有 camelCase 子路径(subSessionConstants、transcriptRecords、noFollowOpen——tsconfig.json:11-19)都带显式源码映射,正因为通配符够不到 kebab-case 文件名;AGENTS.md 也明确点名了这个陈旧 dist 隐患。具体触发:开发者在 packages/core 修改执行器契约后不重建 core 就跑 CLI 类型检查/IDE 检查——acp-subagent-executor.ts 对陈旧 dist 声明通过类型检查,而 vitest(别名指向源码)看到的是新契约,不一致要到 CI/preflight 才暴露。修法:补显式映射(在通配符之前)。证据为 tsc --traceResolution 两只手臂的输出(见英文部分)。

— qwen3.8-max via Qwen Code /review (v0.23.0)

config.name,
);
}
const subagent = await externalExecutor.create({

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.

[Suggestion] R1-55: Workflow schema mode (agent(type, {schema})) can dispatch an executor-based definition, but the external agent can structurally never satisfy schema capture — and there is no fail-fast guard. augmented spreads the definition verbatim (workflow-orchestrator.ts:933-946), so createAgentHeadless takes the external path; the orchestrator injects ToolNames.STRUCTURED_OUTPUT into the definition's tools (:923-931) and attaches schema listeners matching only that tool name (:1084, :1546) — but the external agent runs its own tool namespace (Claude's Bash/Edit/…, never the host's SyntheticOutputTool; it also receives mcpServers: []). So schemaState.attempts stays 0 and, after the external turn runs to completion — minutes of work, tokens, and file mutations in the isolated worktree whose cleanup only runs on the success-capture branch — the orchestrator throws 'subagent completed without calling structured_output (no validation attempt — model produced plain-text content)' (:1187), misattributing a structural incompatibility as a content failure. The file already fail-fasts on an analogous incompatible combination (workingDir + isolation, :960-967). Before createAgentHeadless, when opts.schema !== undefined and the resolved definition carries executor, throw immediately with an explicit 'schema mode is not supported for external-executor agents' error.

Witness:

grep 'executor' packages/core/src/agents/runtime/workflow-orchestrator.ts -> 0 matches (no strip, no guard)
workflow-orchestrator.ts:1084 attachSchemaListeners(eventEmitter, schemaState);
workflow-orchestrator.ts:1546 if (evt.name !== targetTool) return;   // targetTool = ToolNames.STRUCTURED_OUTPUT
workflow-orchestrator.ts:1187 '(no validation attempt — model produced plain-text content).'
executor tool names derive from _meta.claudeCode.toolName — the foreign agent's namespace has no structured_output

witness: not run — a faithful probe would need the orchestrator's real dispatch without stubbing createAgentHeadless (the branch under test); settled by the reads/greps above.

The existing schema terminal-error strings are pinned as upstream-verbatim (workflow-orchestrator.ts:1180-1183) — the new error must be distinct and must not rewrite those. Please add a workflow-orchestrator test: a schema-mode dispatch of an executor-carrying config rejects before execute() is invoked; removing the guard must turn it red.

中文说明

Workflow 的 schema 模式(agent(type, {schema}))可以分派带执行器的定义,但外部 agent 在结构上永远无法满足 schema 捕获——而且没有快速失败守卫。augmented 逐字展开定义(workflow-orchestrator.ts:933-946),因此 createAgentHeadless 走外部路径;编排器把 ToolNames.STRUCTURED_OUTPUT 注入定义的 tools(:923-931)并挂上只匹配该工具名的 schema 监听器(:1084、:1546)——但外部 agent 运行自己的工具命名空间(Claude 的 Bash/Edit 等,永远不会有宿主的 SyntheticOutputTool;它还收到 mcpServers: [])。于是 schemaState.attempts 恒为 0,在外部回合完整跑完之后——数分钟的工作、token、以及隔离 worktree 中的文件变更(其清理只在成功捕获分支执行)——编排器抛出 'subagent completed without calling structured_output (no validation attempt — model produced plain-text content)'(:1187),把结构性不兼容误报为内容失败。该文件已对类似的不兼容组合做快速失败(workingDir + isolation,:960-967)。修法:在 createAgentHeadless 之前,当 opts.schema !== undefined 且解析出的定义携带 executor 时,立即抛出显式错误。约束:既有 schema 终止错误字符串被钉为上游逐字(:1180-1183),新错误必须与之不同。请补测试:schema 模式分派携带执行器的配置应在 execute() 被调用前拒绝,移除守卫后变红。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment on lines +627 to +628
const toolCall = isRecord(params['toolCall']) ? params['toolCall'] : {};
const callId = asString(toolCall['toolCallId']) ?? `perm-${Date.now()}`;

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.

[Suggestion] R1-16: pendingPermissions is keyed by callId and .set() silently overwrites, so two concurrent permission requests sharing a callId strand the first parked resolver. There are two collision routes. (1) An empty-string toolCallId passes the SDK validation (zToolCallId is z.string(), which accepts '') and asString('') returns '', so both requests get callId '' and the collision is unconditional — the ?? perm-${Date.now()} fallback never even fires. I drove two back-to-back session/request_permission requests with toolCallId '' through the real executor: both TOOL_WAITING_APPROVAL events carried callId '', the second .set overwrote the first parked resolver, and after answering both events with ProceedOnce exactly ONE ACP request was answered (p2) — the first never received a response and blocks indefinitely, and the first event's respond settled the SECOND request, so the approval dialog can resolve the wrong tool call. (2) When the id is genuinely absent (a shape reachable once the SDK schema drifts — this file's own stated premise), the perm-${Date.now()} fallback is only unique per millisecond, so a same-millisecond pair collides the same way. Key the map by a per-request unique id (a monotonic counter or randomUUID()), carrying the adapter's toolCallId only for event pairing, and treat empty/non-string ids as missing so they also get counter ids — or, if a callId is already parked, immediately answer the new request with {outcome: 'cancelled'} instead of overwriting.

Witness:

PROBE (real executor, two back-to-back session/request_permission with toolCallId ''):
  event callIds: ["",""]
  ADAPTER RESPONSES: one response for two requests ({"outcome":{"outcome":"selected","optionId":"a"}} for p2), none for p1
  -> the first request never answered; the first event's respond settled the second request
SDK: zToolCallId = z.string() accepts '' — the empty id reaches the handler

Please add a test: two concurrent requestPermission calls with empty toolCallId must both resolve (both parked entries answerable) — removing the unique keying leaves the first promise unsettled and must turn the test red.

中文说明

pendingPermissions 以 callId 为键,.set() 会静默覆盖,因此共享同一 callId 的两个并发权限请求会搁浅第一个停放的 resolver。有两条碰撞路径。(1) 空字符串 toolCallId 能通过 SDK 校验(zToolCallId 是 z.string(),接受 ''),且 asString('') 返回 '',于是两个请求都得到 callId '',碰撞无条件发生——?? perm-${Date.now()} 兜底甚至不会触发。实测:对真实执行器背靠背发送两个 toolCallId 为 '' 的 session/request_permission,两个 TOOL_WAITING_APPROVAL 事件的 callId 都是 '',第二次 .set 覆盖了第一个停放的 resolver,两个事件都用 ProceedOnce 应答后,只有一个 ACP 请求得到应答(p2)——第一个永远收不到响应、无限阻塞,而且第一个事件的 respond 结算的是第二个请求,即审批对话框可能解析错误的工具调用。(2) 当 id 真正缺失时(SDK schema 漂移即可达——这正是本文件自述的前提),perm-${Date.now()} 兜底只在毫秒内唯一,同毫秒的一对请求同样碰撞。修法:以每请求唯一 id(单调计数器或 randomUUID())为键,adapter 的 toolCallId 仅用于事件配对;把空/非字符串 id 视为缺失、同样分配计数器 id——或者,若某 callId 已停放,立即以 {outcome: 'cancelled'} 应答新请求而不是覆盖。请补测试:两个空 toolCallId 的并发 requestPermission 必须都能被解析(两个停放条目都可应答),移除唯一键后第一个 promise 永不 settle、测试应变红。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Three criticals, all in the spawned-child path.

The child inherited the parent's full environment. `executor.command` comes from
a project-level `.qwen/agents/*.md`, so a repository a user merely cloned picks
the executable that would receive the daemon bearer token and other Qwen-internal
secrets. Now spawned with `sanitizeChildEnv(process.env)`, the same treatment
`mcp-client.ts` gives its MCP child. The parity claim in the PR body covered
which executable runs, not what that executable inherits; the env is now at
parity too.

The handshake had no deadline and raced only the `error` event. A command that
spawns, stays alive and never speaks ACP hung `create()` forever — surfacing as
an Agent tool call that never returns and never errors — and a child that spawns
then exits non-zero (`npx -y` unable to resolve offline, an adapter erroring
during boot) emits `exit` rather than `error`, so it waited out a deadline that
did not exist. Both handshake races now also reject on `exit` and on a 10s
deadline, matching qwen-live's. A post-handshake `exit` handler drains parked
approvals and emits an ERROR event, so a mid-turn crash cannot leave an approval
dialog waiting on a process that no longer exists.

The permission-answer fallback could grant more than the user approved: when the
external agent offered no option of the wanted kind it answered `options[0]`, so
approving "proceed once" against an offered set of `[allow_always, reject_once]`
sent back `allow_always`, silently widening a one-time approval to the whole
session. It now denies, which is the same fail-safe posture the file already
argues for in `resolvePermissionMode` and `parseAgentExecutor`.

Also: stderr goes through acp-bridge's `createStderrForwarder`, so lines are
buffered across chunks and run through `redactLogCredentials` rather than echoed
raw; the approval dialog reports the real tool name recovered from
`_meta.claudeCode.toolName` instead of a generic placeholder; and the executor
registration is now a thunk, so the dynamic import happens on first use — which
is what the accompanying comment claimed but the previous unconditional
`await import()` did not actually do.

Verification: repo typecheck, ESLint and Prettier clean after a full rebuild on
the rebased base; cli executor tests 11/11; core subagent and agent-runtime
suites 357/357.
@wenshao

wenshao commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

Review fixes pushed — d74ea3bedb

All three criticals fixed, plus suggestions 4, 6 and 7. Suggestion 5 is deferred with reasoning below.

Conflict resolution note. The branch had a merge commit from the author (7691d4c310, merging main) that landed while a local rebase was in flight. git push --force-with-lease correctly refused, so the rebase was discarded and the fix commit was cherry-picked onto the remote state instead. The merge commit is preserved and this push was a fast-forward — nothing was rewritten.

1. Critical — child inherited the parent's full environment → fixed

env: sanitizeChildEnv(process.env), matching mcp-client.ts:2564. sanitizeChildEnv was already public via packages/core/src/index.ts:478, so no new core export was needed.

The correction to the PR body's argument is accepted: the parity claim with mcpServers/hooks covered which executable runs, not what that executable inherits. mcpServers sanitizes the child env and this path did not. It does now.

2. Critical — handshake had no deadline and no exit racer → fixed

Both handshake races (initialize and newSession) now reject on exit as well as error, and against a shared INIT_TIMEOUT_MS = 10_000 deadline — matching qwen-live's handshakeDeadline() + exitPromise posture. The file had reproduced qwen-live's error-only comment verbatim without its exit half; that is now correct rather than quoted.

Also added the post-handshake child.once('exit') handler the review identified as the same root cause: it drains pendingPermissions (resolving each to undefined, i.e. a cancelled outcome that grants nothing) and emits an ERROR event when the child dies mid-turn, so an approval dialog can no longer stay parked against a process that no longer exists.

3. Critical — permission fallback could grant more than approved → fixed

?? options[0] is gone. When the wanted kind is not offered the executor now looks for a reject* option and answers with that; if none exists it resolves undefined, which becomes a cancelled outcome. Either path grants nothing. The agreed reasoning is in the code comment: this is the same fail-safe posture resolvePermissionMode and parseAgentExecutor already argue for, and the previous fallback contradicted it.

Reachability remains unproven statically, as the review said — but the fix is cheap and the downside was a permission grant the user did not make, so it is not worth leaving.

4. Suggestion — stderr forwarding → fixed

Now uses createStderrForwarder({ prefix }) from @qwen-code/acp-bridge/spawnChannel (already a cli dependency, already used by run-qwen-serve.ts:2255), so lines are buffered across chunks and pass through redactLogCredentials. The inline version did neither, and the credential-echo concern was real.

6. Suggestion — lazy-import comment did not match the code → fixed by making the code match

The reviewer is right that await import() sitting unconditionally in loadCliConfig loaded the ACP SDK on every startup, contradicting the comment. Rather than weaken the comment, the registration is now a thunk — setExternalAgentExecutor({ create: (params) => import(...).then(m => m.acpExternalAgentExecutor.create(params)) }) — so the import happens on the first subagent that actually declares an executor. The comment now describes what the code does.

7. Minor — approval dialog showed a generic tool name → fixed

TOOL_WAITING_APPROVAL now recovers the name the same way the tool_call branch does (_meta.claudeCode.toolName, falling back to kind, then to external_tool).

5. Suggestion — executeExternalInputs counter divergence → deferred, not disputed

The observation is correct: AgentHeadless.executeExternalInputs delegates to execute(), which resets finalText and by default resets stats; the ACP version does neither, so a resident external subagent would concatenate the previous turn's text into getFinalText(), and resetStats is ignored.

Deferred rather than fixed because the right answer depends on whether an external agent's turns should be accounted per-turn or per-session, and executeExternalInputs on this path is only reachable through the adapter's private _session/steering extension — which no test in this PR exercises. Changing the counters without an executed path to verify against risks picking the wrong semantics. Tracked as a follow-up; a comment stating the deliberate divergence should land with it.

Stage 1 framing note — accepted

The reviewer is right that 0.23.0 ignoring an unknown executor field is the documented behaviour of a deliberately lenient frontmatter parser, not a defect in the shipped build. The measurement still earns its place as the evidence for the fail-loud choice, but the motivating gap is the absent capability. The PR body should say so; it currently leans on the measurement more than it should.

Verification on the merged base

Full npm run build and repo-wide npm run typecheck clean after rebasing onto the author's merge of main (the first typecheck run on the merged base surfaced six stale-dist errors in unrelated files — PRIVATE_CONVERSATIONS_RUNTIME_*, stripGeneratedAttachmentTokens, readAgentMetaAsync — all resolved by the rebuild, none in this PR's files). ESLint clean. cli executor tests 11/11, core subagent and agent-runtime suites 357/357.

Still outstanding, unchanged from the previous comment: Web Shell screenshots, the permission E2E (groups 4 and 5 — the security-relevant claim that the derived permissionMode actually overrides a local defaultMode: "auto" is still unit-test-only), and groups 6 and 7.

…gent

# Conflicts:
#	packages/cli/vitest.config.ts
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

🖼️ web-shell visual preview

Rendered against a mock daemon (no real backend): the PR base vs this PR head 9ccbd85. Only screenshots that changed are shown (flows below, if any, are head-only) — refreshes on every push.

Screenshots · before / after

terminal-turn-error-copy-narrow-dark before/after

workflow-page-history-dark before/after

workflow-page-history-light before/after

workflow-page-running-dark before/after

workflow-page-running-light before/after

workflow-page-saved-dark before/after

workflow-page-saved-detail-dark before/after

workflow-page-saved-detail-light before/after

workflow-page-saved-light before/after

Full-resolution recordings (.webm) are attached to the workflow run.

Qwen Code · web-shell visuals

@wenshao

wenshao commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

Evidence screenshots added

These are terminal captures, not Web Shell browser screenshots. The browser route was attempted first and is not available in this environment: driving a desktop browser here requires the node_repl MCP tool, which is not installed, and installing it requires restarting the CLI. Rather than leave the PR with no images, this uses the repo's own terminal-capture harness (node-pty → xterm.js → Playwright headless), which produces real PNGs of an actual run.

The Web Shell surface for a delegated subagent is SubagentDetail.tsx, which this PR does not modify — it consumes the virtual subagent session that already exists. So the terminal capture shows the part this PR actually changes: the delegation itself.

What was run

A .qwen/agents/claude-worker.md definition carrying an executor block, in an interactive session:

Use the claude-worker subagent to create shots.txt containing exactly SHOTS_OK

Result

prompt typed

delegation result

Full scrollback

full scrollback

The run was verified independently of the screenshots

Captured at 39.9s, 4 frames. Checked against the filesystem and the subagent metadata rather than taken on trust from the image:

  • .qwen/spike/shots/shots.txt exists and contains exactly SHOTS_OK
  • …/subagents/<sessionId>/agent-claude-worker-call_ddb82cff….meta.json reports persistedCliFlags.model: "external-acp:node" and status: "completed"

external-acp:node is the label this PR's executor reports through getCore().modelConfig.model. A run that had silently fallen back to in-process would report a Qwen model id there instead, which is exactly the substitution the fail-loud path exists to prevent.

Images are hosted on the assets-pr11003 branch of the author's fork under pr11003/, per the usual convention for locally-captured evidence.

Not yet captured

The Web Shell subagent panel rendering this delegation, and the approval dialog for an external agent's tool call. Both need a browser session; the approval dialog additionally needs the permission E2E (test-plan groups 4 and 5), which is still outstanding and is the security-relevant gap noted in the previous comment.

The capture scenario is not committed: it hardcodes an absolute repo path, so it is not portable as written. It should be parameterised before it lands.

wenshao added a commit to wenshao/qwen-code that referenced this pull request Sep 4, 2026
Headless Playwright capture of the Web Shell driving the same delegation:
a session prompt asks for the claude-worker subagent, the turn runs in the
external Claude Code process, and the requested file is produced.

Verified independently of the images: ws-shots.txt contains WS_OK, and the
subagent metadata for that session reports model external-acp:node,
agentType claude-worker, status completed, with Bash x3 in its transcript
and zero qwen write_file calls.

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

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

Partially reviewed — gaps disclosed.

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Deferred under the convergence posture (round 2, not a blocker) — recorded, not requested in this round:

  • packages/cli/src/external-agents/acp-subagent-executor.ts:436 — [review] System prompt re-sent as user text on every execute() — stop-hook continuations inject the entire rendered prompt N extra times (probe-verified); code unchanged since …
  • packages/core/src/subagents/subagent-manager.ts:1053 — [review] The new SubagentError pass-through guard has no witnessing test — guard-deletion mutant ships green (mutation probe); code unchanged since round 1 (code-age rule)
  • docs/design/claude-code-web-shell-backend.md:5 — [review] All '实测/已核实' evidence points at a git-ignored spike file absent from the tree — Decision 4's security premise ships unfalsifiable
  • docs/design/claude-code-web-shell-backend.md:298 — [review] Q4 boundary-sync obligation (multi-agent-coordination.md update + acceptance) not fulfilled — the feature doc still routes cross-vendor coordination to Herdr
  • docs/design/claude-code-web-shell-backend.md:376 — [review] §8.5-2 'only 4 ClientSideConnection sites' enumeration falsified by this commit's fifth (sweep: 5 production sites)
  • packages/cli/src/external-agents/acp-subagent-executor.ts:493 — [review] executeExternalInputs never emits EXTERNAL_MESSAGE — injected send_message turns absent from the JSONL transcript (probe-verified); code unchanged since round 1 (code-…
  • docs/design/claude-code-web-shell-backend.md:24 — [review] Unpinned npx -y adapter spawn vs the certified v0.73.0 envelope — registry latest is 0.74.0 (measured), proven to ignore the delivered permission mode; any R1-4 fix can be silently …
  • packages/cli/src/external-agents/acp-subagent-executor.ts:599 — [review] terminateModeForStopReason fails open — unknown/absent stopReason maps to GOAL, certifying incomplete work as finished (probe flip); code unchanged since round 1 (code…
  • packages/cli/src/external-agents/acp-subagent-executor.ts:496 — [review] A steering extMethod rejection propagates and tears down the whole subagent instead of falling back to prompt() (probe flip); code unchanged since round 1 (code-age ru…
  • packages/core/src/subagents/subagent-manager.ts:1015 — [review] Composed dispose propagates executor dispose() failures — workflow-orchestrator.ts:1223 awaits it bare in finally, converting success into error or masking the real failure (pr…
  • packages/core/src/agents/runtime/subagent-executor.ts:142 — [review] taskName populated by every dispatch caller, read by no implementation — dead field on the new public subagentRuntime surface, sixth member of R1-3's unconsumed-config cla…
  • packages/core/src/subagents/subagent-manager.ts:1010 — [review] Dispatch test never pins the runtimeContext identity — the one-word shadow mutant compiles and passes all four tests (mutation probe); code unchanged since round 1 (code-age ru…
中文说明

仅完成部分审查,审查缺口已披露。

未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

收敛姿态下延后(第 2 轮,非阻断)——已记录,本轮不要求修改:共 12 条(原文未翻译,列表见上方英文部分)。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment on lines +1816 to +1817
const executorRaw = frontmatter['executor'];
const executor = parseAgentExecutor(executorRaw);

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.

[Critical] R2-1: [certifies-falsely] [new-surface] A malformed executor block in a frontmatter agent file is dropped at parse time with only a debugLogger.warn (a no-op unless QWEN_DEBUG_LOG_FILE is set), so the definition silently runs in-process — the exact silent substitution this PR exists to prevent. The consumption-point fail-loud check never engages, because the drop removes config.executor before dispatch looks at it. A user who writes .qwen/agents/claude-worker.md with a typo'd block — executor: { kind: 'ACP', command: 'npx' } (case), a missing kind, or args: '-y' (string instead of array) — gets the turn run in-process: the task completes under Qwen's model, billed to the Qwen provider, with nothing on stdout/stderr/debug log. The user asked for Claude and got Qwen with no way to notice — every step of the PR's own narrated motivating incident, unchanged. The asymmetry is pinned by this diff's own test: the identical malformed block injected via loadSessionSubagents fails loudly with /failed validation/, while the frontmatter path silently substitutes.

Witness:

PROBE (unmodified PR): frontmatter 'kind: ACP' typo -> parsed.executor=undefined, AgentHeadless.create called 1x (in-process)
identical block via session injection -> rejects /failed validation/
FIXED (raw block carried through) -> rejects /failed validation/, AgentHeadless.create 0x

Fix: throw SubagentError(INVALID_CONFIG) in parseSubagentContent when executorRaw !== undefined && executor === undefined (the existing catch wraps it as 'Failed to parse subagent file'), or carry the raw block through so the consumption-point re-validation throws. Note types.ts:50-52 documents the opposite lenient-drop intent ('so a bad executor block cannot kill the agent definition') and must be updated in the same change; the consumption-point re-validation at subagent-manager.ts:965-975 must remain the session-injection path's guard. Please add a test where a frontmatter definition declaring executor: { kind: 'acp', command: ' ' } rejects and mockAgentHeadlessCreate is not called, and confirm removing the fix turns it red.

中文说明

frontmatter agent 文件里格式错误的 executor 块在解析期被整体丢弃,只有一条 debugLogger.warn(未设 QWEN_DEBUG_LOG_FILE 时是空操作),于是该定义静默地在进程内运行——恰是本 PR 要杜绝的静默替换。消费点的显式报错检查永远不会触发,因为丢弃发生在 dispatch 读取 config.executor 之前。用户写 .qwen/agents/claude-worker.md 时打错了块——executor: { kind: 'ACP', command: 'npx' }(大小写)、缺 kind、或 args: '-y'(字符串而非数组)——该回合就会在进程内执行:任务在 Qwen 模型下完成、记账到 Qwen provider,stdout/stderr/debug log 全部无声。用户要的是 Claude,拿到的却是 Qwen,且无从察觉——本 PR 自述动机事故里的每一步,原样重演。这种不对称被本 diff 自己的测试钉住:同样格式的块经 loadSessionSubagents 注入会以 /failed validation/ 显式报错,而 frontmatter 路径却静默替换。

探针证据(未修改的 PR):frontmatter 'kind: ACP' 拼写错误 -> parsed.executor=undefined,AgentHeadless.create 被调用 1 次(进程内);同一块经 session 注入 -> 以 /failed validation/ 拒绝;修复(原样携带块)后 -> 以 /failed validation/ 拒绝,AgentHeadless.create 0 次。

修法:在 parseSubagentContent 中当 executorRaw !== undefined && executor === undefined 时抛 SubagentError(INVALID_CONFIG),或原样携带该块让消费点再校验抛错。约束:types.ts:50-52 记录了相反的宽容丢弃意图,须同步更新;subagent-manager.ts:965-975 的消费点再校验须保留。请补测试:声明 executor: { kind: 'acp', command: ' ' } 的 frontmatter 定义必须被拒绝且 mockAgentHeadlessCreate 未被调用,并确认移除修复后测试变红。

— qwen3.8-max via Qwen Code /review (v0.23.0)

}

function methodNotFound(method: string): Error {
return Object.assign(new Error(`method not found: ${method}`), {

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.

[Critical] R2-5: [certifies-falsely] [new-surface] methodNotFound builds a plain Error with an attached code property, but the ACP SDK only preserves RequestError instances — every throw is serialized to the agent as -32603 Internal error instead of the -32601 method-not-found it is named for, defeating -32601-keyed graceful fallbacks. The executor is agent-agnostic by design (kind: 'acp' + arbitrary command; the file header's own rationale is adapter drift): any ACP agent the user names sends fs/read_text_file/fs/write_text_file or an unknown ext method, the handler throws via methodNotFound, and Connection.#tryCallRequestHandler (dist/acp.js:783-811) checks error instanceof RequestError — false — and wraps it. The SDK's own acp.test.js pins -32601 for exactly this shape, and design doc §决策5/Q2 treats the semantics as load-bearing. The extMethod handler additionally throws methodNotFound('extMethod'), discarding the real method name.

Witness:

WIRE PROBE (unmodified PR): agent request fs/read_text_file ->
  response {"code":-32603,"message":"Internal error","data":{"details":"method not found: fs/read_text_file"}}
FIXED (RequestError.methodNotFound):
  response {"code":-32601,"message":"\"Method not found\": fs/read_text_file"}
Suggested change
return Object.assign(new Error(`method not found: ${method}`), {
function methodNotFound(method: string): RequestError {
return RequestError.methodNotFound(method);
}

The fix must throw a real RequestError instance, not a duck-typed error with a code field (dist/acp.js:788-790 repackages anything else into -32603) — RequestError is importable from @agentclientprotocol/sdk alongside the existing ClientSideConnection import — and thread the real method name through the extMethod handler. Please add a test routing an fs/read_text_file (or ext-method) request through buildClient() via a real connection pair, asserting the caller receives code === -32601; the current helper observes -32603, so removing the fix turns it red.

中文说明

methodNotFound 构造的是附带 code 属性的普通 Error,但 ACP SDK 只保留 RequestError 实例——每次抛出都被序列化为 -32603 Internal error,而不是它名义上的 -32601 method-not-found,破坏了以 -32601 为键的优雅降级。执行器在设计上是 agent 无关的(kind: 'acp' + 任意命令;文件头部自述的前提就是 adapter 漂移):用户指名的任何 ACP agent 发送 fs/read_text_file/fs/write_text_file 或未知 ext 方法时,处理器经 methodNotFound 抛出,Connection.#tryCallRequestHandler(dist/acp.js:783-811)检查 error instanceof RequestError 为假,随即重新包装。SDK 自己的 acp.test.js 恰为这一形态钉住了 -32601,设计文档 §决策5/Q2 也视该语义为要害。extMethod 处理器还会抛出 methodNotFound('extMethod'),丢掉了真实方法名。

线缆探针(未修改的 PR):agent 请求 fs/read_text_file -> 应答 code=-32603 'Internal error';改用 RequestError.methodNotFound 后 -> code=-32601。

修法:必须抛出真正的 RequestError 实例(dist/acp.js:788-790 会把其他一切重新包装为 -32603),并把真实方法名传给 extMethod 处理器。请补测试:经真实连接对向 buildClient() 路由一个 fs/read_text_file(或 ext 方法)请求,断言调用方收到 code === -32601;当前实现观测到 -32603,移除修复后测试变红。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment on lines +388 to +389
this.pendingPermissions.clear();
if (this.executing) {

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.

[Critical] R2-14: (fix-induced) [fails-closed] [new-surface] onChildExit — added by d74ea3bedb — emits AgentEventType.ERROR from the child's 'exit' handler, but ERROR is Node's 'error' event and AgentEventEmitter delegates straight to EventEmitter.emit, which throws ERR_UNHANDLED_ERROR when no listener is attached. Production wiring attaches no ERROR subscriber to the emitters executor-backed background/resume/workflow subagents receive: background emitters get only TOOL_CALL/USAGE_METADATA (agent.ts:3345-3346, background-agent-resume.ts:1173-1174) plus the transcript writer's five events (agent-transcript.ts:864-868 — no ERROR); the one ERROR listener (agent.ts:1541) is bound to the parent tool emitter, not the runtime emitter; workflow emitters get only TOOL_CALL/TOOL_RESULT (workflow-orchestrator.ts:1545-1552). A mid-turn child crash therefore surfaces as an uncaught exception from inside the exit handler instead of a handled failure — the fix for R1-18's silence introduced a crash on the shapes where nobody listens. Foreground is protected (setupEventListeners attaches to the executor's own emitter, agent.ts:3062-3069).

Witness:

PROBE (real executor, fake adapter that exits(3) on session/prompt, 6 iterations per arm):
  ARM A (background shape, no ERROR listener): A[0..5]: UNCAUGHT(1) Error [ERR_UNHANDLED_ERROR]: Unhandled error.
  ARM B (ERROR listener attached):             B[0..5]: UNCAUGHT(0) - UNHANDLED_REJECTIONS(0) -

Fix: emit a non-'error'-named event for child death, attach a no-op ERROR subscriber wherever the runtime emitter is handed to an executor, or guard the emit with a listenerCount check — consistent with how the codebase handles EventEmitter 'error' semantics elsewhere. Please add a test that crashes the child mid-turn with the production background emitter wiring (no ERROR listener) and asserts no uncaught exception and the turn settles; removing the guard must turn it red.

中文说明

d74ea3bedb 新增的 onChildExit 从子进程的 'exit' 处理器中发出 AgentEventType.ERROR,但 ERROR 就是 Node 的 'error' 事件,而 AgentEventEmitter 直接委托给 EventEmitter.emit——没有监听器时会抛 ERR_UNHANDLED_ERROR。生产接线不会给 executor 承载的后台/恢复/工作流 subagent 的 emitter 挂 ERROR 订阅:后台 emitter 只有 TOOL_CALL/USAGE_METADATA(agent.ts:3345-3346、background-agent-resume.ts:1173-1174)加 transcript writer 的五个事件(agent-transcript.ts:864-868,无 ERROR);唯一的 ERROR 监听器(agent.ts:1541)挂在父工具 emitter 上;工作流 emitter 只有 TOOL_CALL/TOOL_RESULT(workflow-orchestrator.ts:1545-1552)。因此回合中途子进程崩溃会以 exit 处理器内的未捕获异常呈现,而不是被处理的失败——R1-18 静默问题的修复在无人监听的形态上引入了崩溃。前台有保护(setupEventListeners 挂在执行器自己的 emitter 上,agent.ts:3062-3069)。

探针证据:真实执行器 + 在 session/prompt 时 exit(3) 的假 adapter,每臂 6 次迭代:ARM A(后台形态、无 ERROR 监听)6/6 出现 UNCAUGHT(1) ERR_UNHANDLED_ERROR;ARM B(挂上 ERROR 监听)6/6 干净。

修法:子进程死亡改发非 'error' 命名的事件,或在凡把 runtime emitter 交给执行器处挂一个空操作 ERROR 订阅,或以 listenerCount 守卫发送。请补测试:按生产后台接线(无 ERROR 监听)让子进程回合中途崩溃,断言无未捕获异常且回合并结算;移除守卫后应变红。

— qwen3.8-max via Qwen Code /review (v0.23.0)

// same fail-safe posture as `resolvePermissionMode` and
// `parseAgentExecutor`. Resolving undefined when no reject option
// exists yields a cancelled outcome, which also grants nothing.
const deny = options.find((option) =>

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.

[Suggestion] R2-2: (fix-induced) The deny fallback in respond() — the fix for R1-15, introduced by d74ea3bedb — has no test: nothing invokes respond() or drives onRequestPermission (acp-subagent-executor.test.ts covers only the three pure helpers), so replacing the deny fallback with options[0] ships green. R1-15's comment explicitly requested this test as the fix's acceptance criterion ('respond(ProceedOnce) with options [{optionId r, kind reject_once}] must resolve cancelled, not optionId r — and confirm removing the fallback turns it red'); the code fix landed without it. Measured: the fallback→options[0] mutant passes 11/11 tests, and the sibling mutant removing the pendingPermissions release loop in onChildExit also passes 11/11 — so a regression of the exact escalation R1-15 described (a user's 'proceed once' answered as allow_always against [allow_always, reject_once]) would reach production with every suite green.

Witness:

MUTANT (deny fallback -> options[0]):            Test Files 1 passed | Tests 11 passed (11)
SIBLING MUTANT (onChildExit release loop removed): Test Files 1 passed | Tests 11 passed (11)

Fix: add a test that constructs the executor (or extracts the respond path), emits TOOL_WAITING_APPROVAL with options [{kind:'allow_always'},{kind:'reject_once'}], invokes respond(ToolConfirmationOutcome.ProceedOnce), and asserts the resolved outcome is cancelled/the reject option, never allow_always; plus a case where the wanted kind IS offered. That test must go red when the deny fallback is replaced by an options[0] fallback.

中文说明

respond() 里的拒绝兜底——即 R1-15 的修复、由 d74ea3bedb 引入——没有任何测试:没有代码调用 respond() 或驱动 onRequestPermissionacp-subagent-executor.test.ts 只覆盖三个纯函数),因此把拒绝兜底替换成 options[0] 也能全绿通过。R1-15 的评论已明确把这个测试作为修复的验收标准('respond(ProceedOnce) 对 [{optionId r, kind reject_once}] 必须 resolve cancelled 而非 optionId r,并确认移除兜底后变红');代码修了,测试没跟上。实测:兜底→options[0] 的变异体 11/11 通过;移除 onChildExitpendingPermissions 释放循环的同源变异体同样 11/11 通过——R1-15 描述的那个升级(用户对 [allow_always, reject_once] 点'仅此次'却被应答 allow_always)回归时,所有套件仍是绿的。

变异证据:兜底→options[0] 变异体 Tests 11 passed;移除 onChildExit 释放循环变异体 Tests 11 passed。

修法:补一个测试——构造执行器(或抽出 respond 路径),以 [{kind:'allow_always'},{kind:'reject_once'}] 发出 TOOL_WAITING_APPROVAL,调用 respond(ProceedOnce),断言解析结果为 cancelled/拒绝选项,绝不是 allow_always;再加一个所需 kind 存在时的用例。该测试在兜底被替换为 options[0] 时必须变红。

— qwen3.8-max via Qwen Code /review (v0.23.0)

}

try {
if (config.executor) {

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.

[Critical] R1-1: [certifies-falsely] [new-surface] Still standing (round-2 re-check; this file is unchanged since the round-1 review commit 7691d4c3). The executor branch spawns a definition-supplied external command with no folder-trust gate, unlike the hooks branch directly above it (subagent-manager.ts:934 ignores project-agent hooks when the folder is untrusted) and MCP discovery (mcp-client-manager.ts:1066/:2134 return early). Project agents load regardless of trust, the Agent tool has no shouldConfirmExecute, and the CLI registers the executor unconditionally — so opening a malicious repo without trusting it and invoking an agent whose definition declares executor: { kind: acp, command: '/bin/sh', args: ['-c', '<payload>'] } runs the repo-chosen command on the host with no trust prompt. The types.ts claim that the executor command carries 'the same trust model as the existing mcpServers and hooks command fields' is contradicted by those very gates.

Witness:

Round-1 PROBE (isTrustedFolder()=false, level=project): DISPATCHED: executor.create called 1x in UNTRUSTED folder
Round-2 PROBE at HEAD 9ccbd859: executor.create called 1x untrusted, while addAgentHooks called 0x (hooks gated)

Fix: gate the executor branch the same way hooks are gated — refuse with a SubagentError when config.level === 'project' && !runtimeContext.isTrustedFolder() (the gate must match subagent-manager.ts:934, and session/user-level agents must keep working). It must throw, not drop the executor and fall back to in-process. Please add a subagent-manager.test.ts case: project-level config with an executor, isTrustedFolder() mocked false, asserting createAgentHeadless rejects and the executor's create is never called — and confirm it goes red when the gate is removed.

中文说明

仍然成立(第 2 轮复核;自第 1 轮审查提交 7691d4c3 以来此文件未变)。executor 分支在没有任何文件夹信任闸门的情况下 spawn 定义中指名的外部命令,而紧邻其上的 hooks 分支有(subagent-manager.ts:934 在未信任文件夹中忽略项目级 agent 的 hooks),MCP 发现也有(mcp-client-manager.ts:1066/:2134 提前返回)。项目级 agent 无论是否信任都会加载,Agent 工具没有 shouldConfirmExecute,CLI 又无条件注册了执行器——因此打开一个未信任的恶意仓库、调用声明 executor: { kind: acp, command: '/bin/sh', args: ['-c', '<payload>'] } 的 agent,就会在宿主上执行仓库指名的命令,没有任何信任提示。types.ts 中'与既有 mcpServers 和 hooks command 字段同一信任模型'的说法恰被这些闸门本身否定。

探针证据:第 1 轮(isTrustedFolder()=false、level=project):DISPATCHED,executor.create 在未信任文件夹中被调用 1 次;第 2 轮在 HEAD 9ccbd85 复测:executor.create 未信任下被调用 1 次,而 addAgentHooks 为 0 次(hooks 有闸)。

修法:按 hooks 的方式给 executor 分支加闸——config.level === 'project' && !runtimeContext.isTrustedFolder() 时以 SubagentError 拒绝(闸门须与 :934 一致,session/user 级 agent 必须继续可用),必须抛错而不是丢弃 executor 回退进程内。请补测试并确认移除闸门后变红。

— qwen3.8-max via Qwen Code /review (v0.23.0)

// Already-classified errors carry an accurate message; re-wrapping them
// under "Failed to create AgentHeadless" would misreport the executor
// path, which never constructs an AgentHeadless at all.
if (error instanceof SubagentError) {

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.

[Suggestion] R1-24: Still standing (round-2 re-check; the wrap is unchanged since the round-1 review commit). The re-throw passes through SubagentError only, but the most common executor failures are plain Errors: the host create throws plain Errors for spawn ENOENT, handshake deadline, mid-handshake exit and missing sessionId (acp-subagent-executor.ts:218-235, 285-300, 336-348). Those are still wrapped as 'Failed to create AgentHeadless: …' with INVALID_CONFIG, for an agent that never constructs an AgentHeadless — the exact misreport the added comment says it is avoiding, and a misclassification that sends users debugging a missing external binary down the in-process path.

Witness:

Round-2 PROBE (real dispatch path, registered executor whose create rejects with the exact spawn-failure message):
  INTACT (HEAD): { message: 'Failed to create AgentHeadless: external agent "external-agent" failed to spawn npx: spawn npx ENOENT', code: 'INVALID_CONFIG' }
  FIXED (executor-aware .catch wrap): { message: 'Failed to start external agent "external-agent": … failed to spawn npx: spawn npx ENOENT' }, all 5 dispatch tests green

Fix: make the wrap executor-aware — an accurate message when config.executor is set (e.g. 'Failed to start external agent …') — or rethrow executor-path errors unwrapped, or classify them as SubagentErrors at the source. Please add a case to the external dispatch describe: a mocked executor whose create rejects with plain Error('boom'); assert the rejection message does not contain 'AgentHeadless' — and confirm it goes red against the current wrap.

中文说明

仍然成立(第 2 轮复核;自第 1 轮审查提交以来该包装未变)。这个重新抛出只对 SubagentError 直通,但最常见的执行器失败是普通 Error:宿主 create 对 spawn ENOENT、握手超时、握手中途退出与缺失 sessionId 抛出的都是普通 Error(acp-subagent-executor.ts:218-235, 285-300, 336-348)。它们仍会被包装成 'Failed to create AgentHeadless: …' 并标为 INVALID_CONFIG——而这个 agent 根本不构造 AgentHeadless——恰是新增注释说要避免的误报,并且这个误分类会把用户引向进程内路径去排查一个缺失的外部二进制。

探针证据:真实分发路径、以确切 spawn 失败消息拒绝的已注册执行器——HEAD 原样:'Failed to create AgentHeadless: …' + INVALID_CONFIG;执行器感知包装后:'Failed to start external agent …',5 个 dispatch 测试保持绿色。

修法:让包装感知执行器——config.executor 已设时给出准确消息(如 'Failed to start external agent …')——或对执行器路径错误不包装直接重抛,或在源头归类为 SubagentError。请补测试:mock 的 executor create 以普通 Error('boom') 拒绝,断言拒绝消息不含 'AgentHeadless',并确认当前包装下变红。

— qwen3.8-max via Qwen Code /review (v0.23.0)

}
this.pendingPermissions.clear();
if (this.child.exitCode === null && this.child.signalCode === null) {
this.child.kill('SIGTERM');

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.

[Suggestion] R1-38: Still standing (round-2 re-check; killChild is unchanged by the fix commit). dispose()/killChild() sends a single SIGTERM with no exit verification and no SIGKILL escalation, so the reap guarantee ('reaps the child', 'Kills the external agent process') silently fails for any child that survives SIGTERM. The definition-declared command is arbitrary (see R1-1) and may trap SIGTERM or be slow to process it (blocked in a long-running child of its own): dispose() returns having only signalled, the parent proceeds as if the agent were reaped, and the external process keeps executing file/shell tools in the workspace after 'cancel'.

Witness:

Round-1 PROBE (child with process.on('SIGTERM', () => {}) and 200ms heartbeats):
  intact PR: dispose() returned; heartbeats 2 -> 6 -> 10; child still alive after dispose = true
  with 2s-grace + SIGKILL escalation: heartbeats freeze at 12; child still alive after dispose = false; dispose stayed time-bounded (~2s)
Round-2: killChild is byte-identical at HEAD 9ccbd859

Fix: after SIGTERM, await the child's 'exit' with a bounded grace period (2-5 s) and escalate to kill('SIGKILL') if it has not exited. The composed dispose awaits subagent.dispose?.() before runCleanup() (subagent-manager.ts:1013-1024), so any added wait must be time-bounded to avoid stalling hook/registry cleanup. Please add a test: a SIGTERM-ignoring child receives SIGKILL from dispose(); the current single-signal code must fail it.

中文说明

仍然成立(第 2 轮复核;修复提交未改动 killChild)。dispose()/killChild() 只发一次 SIGTERM,不验证退出、也不升级为 SIGKILL,因此对任何能在 SIGTERM 下存活的孩子,'回收子进程'的保证静默失效。定义指名的命令是任意的(见 R1-1),可能捕获 SIGTERM 或处理缓慢(阻塞在自己的长任务里):dispose() 只是发了信号就返回,父流程以为 agent 已被回收,而外部进程在 'cancel' 之后继续在工作区执行文件/shell 工具。

探针证据:带 process.on('SIGTERM', () => {}) 与 200ms 心跳的孩子——原样 PR:dispose() 返回、心跳 2 -> 6 -> 10、dispose 后孩子仍存活;加 2 秒宽限 + SIGKILL 升级后:心跳停在 12、孩子不再存活、dispose 保持有界(约 2 秒)。第 2 轮:killChild 在 HEAD 9ccbd85 逐字节未变。

修法:SIGTERM 后带时限等待孩子的 'exit',超时未退出则升级 kill('SIGKILL')。组合 dispose 在 runCleanup() 之前 await subagent.dispose?.()subagent-manager.ts:1013-1024),新增等待必须有界,避免拖住 hook/registry 清理。请补测试:忽略 SIGTERM 的孩子应收到 dispose() 的 SIGKILL,当前单信号实现应失败。

— qwen3.8-max via Qwen Code /review (v0.23.0)

execute(
context: ContextState,
externalSignal?: AbortSignal,
options?: { resetStats?: boolean },

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.

[Suggestion] R1-44: Still standing (round-2 re-check; execute()'s signature is unchanged by the fix commit). The contract's options.resetStats is honoured only by the in-process executor; AcpSubagentExecutor.execute() never accepts it — a dead switch. Four call sites pass {resetStats: false} for continuation turns that must accumulate (agent.ts:1924/:3492, background-agent-resume.ts:1260/:1841); in-process, AgentHeadless.execute then skips resetExecutionStats (const resetStats = options.resetStats !== false, agent-headless.ts:235) so totalDurationMs spans the whole chain. The external executor instead resets startedAtMs = Date.now() unconditionally while this.round += 1 and tool counters accumulate — so after a stop-hook-blocked continuation (or any multi-execute() sequence) getExecutionSummary() reports rounds: N with a duration covering only the last turn. These numbers feed getCompletionStatsregistry.complete/fail (the Web Shell background panel) and getAgentMetaTerminalSummary → persisted agent meta: multi-turn external runs publish under-reported, internally inconsistent duration stats that the in-process executor would not.

Witness:

Round-1 PROBE (two execute() calls ~300ms apart against the real executor):
  summary_after_first:  {rounds: 1, totalDurationMs: 301}
  summary_after_second: {rounds: 2, totalDurationMs: 301}   <- duration covers only the last turn
  in-process reference: agent-headless.ts:235  const resetStats = options.resetStats !== false;
Round-2: the signature is unchanged at HEAD 9ccbd859

Fix: accept options?: { resetStats?: boolean } in AcpSubagentExecutor.execute and mirror the in-process default exactly — when options.resetStats !== false reset startedAtMs/round/tool counters; when false keep them (still reset per-turn finalText/terminateMode as today). Please add a test: two execute() calls, the second with {resetStats: false}, asserting rounds === 2 with totalDurationMs spanning both turns; restoring the unconditional startedAtMs reset must turn it red.

中文说明

仍然成立(第 2 轮复核;修复提交未改动 execute() 的签名)。契约中的 options.resetStats 只有进程内执行器履行;AcpSubagentExecutor.execute() 从不接受它——一个死开关。四个调用点为必须累计的续接回合传 {resetStats: false}agent.ts:1924/:3492background-agent-resume.ts:1260/:1841);进程内 AgentHeadless.execute 此时跳过 resetExecutionStatsconst resetStats = options.resetStats !== falseagent-headless.ts:235),使 totalDurationMs 覆盖整条链。外部执行器却无条件重置 startedAtMs = Date.now(),同时 this.round += 1、工具计数持续累积——于是在停止钩子阻塞后的续接(或任何多次 execute() 序列)之后,getExecutionSummary() 报告 rounds: N 而时长只覆盖最后一回合。这些数字喂给 getCompletionStatsregistry.complete/fail(Web Shell 后台面板)和 getAgentMetaTerminalSummary → 持久化的 agent meta:多回合外部运行会发布少报且自相矛盾的时长统计,进程内执行器不会如此。

探针证据:对真实执行器相隔约 300ms 的两次 execute()——第一次后 {rounds: 1, totalDurationMs: 301};第二次后 {rounds: 2, totalDurationMs: 301}(时长只覆盖最后一回合)。第 2 轮:签名在 HEAD 9ccbd85 未变。

修法:让 AcpSubagentExecutor.execute 接受 options?: { resetStats?: boolean } 并精确镜像进程内默认——options.resetStats !== false 时重置 startedAtMs/round/工具计数;为 false 时保留(仍按现状每回合重置 finalText/terminateMode)。请补测试,恢复无条件重置后应变红。

— qwen3.8-max via Qwen Code /review (v0.23.0)

__dirname,
'../core/src/utils/conversations-runtime-marker.ts',
),
'@qwen-code/qwen-code-core/subagentRuntime': path.resolve(

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.

[Suggestion] R1-52: Still standing (round-2 re-check; vitest.config.ts and packages/cli/tsconfig.json are unchanged in this respect). The new @qwen-code/qwen-code-core/subagentRuntime subpath gets a vitest alias and a package.json exports entry, but no explicit paths mapping in packages/cli/tsconfig.json. The wildcard '@qwen-code/qwen-code-core/*''../core/src/*' yields '../core/src/subagentRuntime' — a path that does not exist (the file is subagent-runtime.ts) — so CLI typechecking resolves the executor contract through the BUILT core dist instead of source. The three existing camelCase subpaths the CLI imports (subSessionConstants, transcriptRecords, noFollowOpentsconfig.json:11-19) all carry explicit source mappings precisely because the wildcard cannot reach kebab-case filenames, and AGENTS.md names this stale-dist hazard explicitly. Concrete trigger: a developer edits the executor contract in packages/core and runs CLI typecheck/IDE checks without rebuilding core — acp-subagent-executor.ts typechecks clean against stale dist declarations while vitest (aliased to source) sees the new contract, so the mismatch surfaces only later in CI/preflight.

Witness:

tsc -p packages/cli --noEmit --traceResolution:
  Module name '@qwen-code/qwen-code-core/subagentRuntime', matched pattern '@qwen-code/qwen-code-core/*'.
  Directory '.../packages/core/src/subagentRuntime' does not exist, skipping all lookups in it.
  Using 'exports' subpath './subagentRuntime' with target './dist/src/subagent-runtime.d.ts'.
With the mapping added: resolves to .../packages/core/src/subagent-runtime.ts (source)

Fix: the fix lives in packages/cli/tsconfig.json, not the anchored file — add "@qwen-code/qwen-code-core/subagentRuntime": ["../core/src/subagent-runtime.ts"] to compilerOptions.paths, before the wildcard (precedent: '@qwen-code/qwen-code-core/transcriptRecords': ['../core/src/utils/transcript-records.ts'], tsconfig.json:14-16), and confirm tsc --traceResolution resolves the subpath to source.

中文说明

仍然成立(第 2 轮复核;vitest.config.tspackages/cli/tsconfig.json 在这一点上未变)。新的 @qwen-code/qwen-code-core/subagentRuntime 子路径有了 vitest 别名和 package.json exports 条目,却没有 packages/cli/tsconfig.json 里的显式 paths 映射。通配符 '@qwen-code/qwen-code-core/*''../core/src/*' 会替换出不存在的 '../core/src/subagentRuntime'(文件是 subagent-runtime.ts)——于是 CLI 类型检查经由已构建的 core dist 而非源码解析执行器契约。CLI 引入的三个既有 camelCase 子路径(subSessionConstantstranscriptRecordsnoFollowOpen——tsconfig.json:11-19)都带显式源码映射,正因为通配符够不到 kebab-case 文件名;AGENTS.md 也明确点名了这个陈旧 dist 隐患。具体触发:开发者在 packages/core 修改执行器契约后不重建 core 就跑 CLI 类型检查/IDE 检查——acp-subagent-executor.ts 对陈旧 dist 声明通过类型检查,而 vitest(别名指向源码)看到的是新契约,不一致要到 CI/preflight 才暴露。

证据:tsc --traceResolution 两只手臂的输出——未加映射时回落到 dist 的 subagent-runtime.d.ts;加上映射后解析到源码。

修法:修复落在 packages/cli/tsconfig.json 而非锚定文件——在 compilerOptions.paths 的通配符之前加入 "@qwen-code/qwen-code-core/subagentRuntime": ["../core/src/subagent-runtime.ts"](先例:tsconfig.json:14-16transcriptRecords),并确认 tsc --traceResolution 把该子路径解析到源码。

— qwen3.8-max via Qwen Code /review (v0.23.0)

config.name,
);
}
const subagent = await externalExecutor.create({

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.

[Suggestion] R1-55: Still standing (round-2 re-check; the file is unchanged since the round-1 review commit). Workflow schema mode (agent(type, {schema})) can dispatch an executor-based definition, but the external agent can structurally never satisfy schema capture — and there is no fail-fast guard. augmented spreads the definition verbatim (workflow-orchestrator.ts:933-946), so createAgentHeadless takes the external path; the orchestrator injects ToolNames.STRUCTURED_OUTPUT into the definition's tools (:923-931) and attaches schema listeners matching only that tool name (:1084, :1546) — but the external agent runs its own tool namespace (Claude's Bash/Edit/…, never the host's SyntheticOutputTool; it also receives mcpServers: []). So schemaState.attempts stays 0 and, after the external turn runs to completion — minutes of work, tokens, and file mutations in the isolated worktree whose cleanup only runs on the success-capture branch — the orchestrator throws 'subagent completed without calling structured_output (no validation attempt — model produced plain-text content)' (:1187), misattributing a structural incompatibility as a content failure. The file already fail-fasts on an analogous incompatible combination (workingDir + isolation, :960-967).

Witness:

grep 'executor' packages/core/src/agents/runtime/workflow-orchestrator.ts -> 0 matches (no strip, no guard)
workflow-orchestrator.ts:1084 attachSchemaListeners(eventEmitter, schemaState);
workflow-orchestrator.ts:1546 if (evt.name !== targetTool) return;   // targetTool = ToolNames.STRUCTURED_OUTPUT
workflow-orchestrator.ts:1187 '(no validation attempt — model produced plain-text content).'

Fix: before createAgentHeadless, when opts.schema !== undefined and the resolved definition carries executor, throw immediately with an explicit 'schema mode is not supported for external-executor agents' error. The existing schema terminal-error strings are pinned as upstream-verbatim (workflow-orchestrator.ts:1180-1183) — the new error must be distinct and must not rewrite those. Please add a workflow-orchestrator test: a schema-mode dispatch of an executor-carrying config rejects before execute() is invoked; removing the guard must turn it red.

中文说明

仍然成立(第 2 轮复核;自第 1 轮审查提交以来此文件未变)。Workflow 的 schema 模式(agent(type, {schema}))可以分派带执行器的定义,但外部 agent 在结构上永远无法满足 schema 捕获——而且没有快速失败守卫。augmented 逐字展开定义(workflow-orchestrator.ts:933-946),因此 createAgentHeadless 走外部路径;编排器把 ToolNames.STRUCTURED_OUTPUT 注入定义的 tools(:923-931)并挂上只匹配该工具名的 schema 监听器(:1084:1546)——但外部 agent 运行自己的工具命名空间(Claude 的 Bash/Edit 等,永远不会有宿主的 SyntheticOutputTool;它还收到 mcpServers: [])。于是 schemaState.attempts 恒为 0,在外部回合完整跑完之后——数分钟的工作、token、以及隔离 worktree 中的文件变更(其清理只在成功捕获分支执行)——编排器抛出 'subagent completed without calling structured_output (no validation attempt — model produced plain-text content)'(:1187),把结构性不兼容误报为内容失败。该文件已对类似的不兼容组合做快速失败(workingDir + isolation:960-967)。

证据:workflow-orchestrator.ts 中 grep 'executor' 0 命中(无剥离、无守卫);:1084/:1546/:1187 见英文引用。

修法:在 createAgentHeadless 之前,当 opts.schema !== undefined 且解析出的定义携带 executor 时,立即抛出显式的 'schema mode is not supported for external-executor agents' 错误。既有 schema 终止错误字符串被钉为上游逐字(:1180-1183),新错误必须与之不同。请补测试,移除守卫后应变红。

— qwen3.8-max via Qwen Code /review (v0.23.0)

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.

3 participants