feat: delegate a subagent turn to an external agent over ACP (Claude Code first) - #11003
feat: delegate a subagent turn to an external agent over ACP (Claude Code first)#11003wenshao wants to merge 5 commits into
Conversation
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.
E2E report — re-verified against the committed codeThe before/after table in the PR description was measured on a build that predates the last fix in Fixture:
Baseline column is the shipped CLI 0.23.0 run of the identical fixture, recorded during the pre-implementation dry run. Still outstanding
|
|
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 One honest note on the before/after table. The "silent substitution" measurement injects an Direction. Aligned, and the rejected alternative is argued rather than asserted. Re-keying workspace identity from Size. Core paths are touched ( Approach. The scope feels right and the seam is genuinely narrow: one 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 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 的三个要点和完整的中文翻译。 问题。 这是一个 关于前后对照表,有一点需要直说。"静默替换"的实测方式是注入了一个已发布 CLI 根本没有概念的 方向。 对齐,而且被否决的替代方案是有论证的,不是随口一提。把 workspace 身份从 规模。 触及了核心路径( 方案。 范围合理,接缝确实很窄:一个 两个范围问题,都不是阻塞项。第一,设计文档 905 行里有相当篇幅在写被否决的"对等后端"方案 —— 作为决策依据有价值,但 风险。 没有命中 revert 历史的高风险路径模式。本 PR 真正抬高的风险全部集中在新增的 CLI executor 里,会在 Stage 2 提出 —— core 这一侧是干净的。 进入代码审查 🔍 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
Code reviewI 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, 1. Critical — the spawned child inherits the parent's full environment, including Qwen-internal secretsIn const child = spawn(params.spec.command, params.spec.args ?? [], {
cwd, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true,
env: process.env,
});
The threat model is the one #6601 was filed for: To be fair about the PR body's argument: it says 2. Critical — the handshake has no deadline and no exit racer, so a silently-booting or cleanly-exiting child hangs
|
| 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_TOKEN、QWEN_DAEMON_TOKEN、QWEN_CODE_EXTERNAL_TOOL_GUARD_TOKEN、QWEN_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 与既有 mcpServers、hooks 的 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-582 让 initialize 同时与 handshakeDeadline()(INIT_TIMEOUT_MS = 10_000)和一个在 error 和 exit 上都 reject 的 exitPromise 竞争,注释写着"把握手同时与超时和子进程退出竞争 —— 启动即崩溃必须在毫秒级失败,而不是 10 秒后"、"在 initialize 完成前就死掉的子进程必须立刻让预检失败"。本文件逐字复刻了 qwen-live 那段注释里只讲 error 的一半,却没有 exit 的 reject,也没有超时。这里的两处竞争(initialize 和 newSession)都需要补上。同一根因、建议一起修:握手之后也没有挂 child.on('exit'),所以回合中途崩溃没有任何东西把它转成 ERROR 事件或让 prompt() reject。qwen-live 在握手成功后会挂一个 exit 处理器去关闭 session 并取消停放的权限请求;而这里 pendingPermissions 只由 killChild() 清理,子进程自己死掉时没人调用它 —— 于是审批对话框可能一直停放在一个已经不存在的进程上。
3. 严重 —— 权限应答的回退可能授予超出用户批准范围的权限。 respond 里 options.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,并有测试断言 env、cwd、shell 无法夹带 —— 这些都是正确的直觉。
运行时流程。 见上方时序图(图内文字保持英文)。承载第 2、3 两条问题的是 initialize 竞争和回退 optionId 这两个箭头。
测试。 这是无人值守的 CI 运行,所以我没有构建或执行本分支的任何代码 —— 下面的证据是 PR 自己的 CI,通过 API 针对 commit cbd124f3aca41166cf33fcbc06f96f75864062f5 一次性读取,没有轮询。取数时单元测试仍在运行;表格如实记录 in-progress 状态而不是猜测结果,CI 落定后 finalize 任务会重写该区域。机器人编排任务(triage、review-pr、ack-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
|
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 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 — 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 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. 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 中文说明信心:2/5 —— core 接缝是本 PR 最好的部分,我可以原样合入;新增 CLI executor 里的三个具体缺陷使它按现状无法合入,而三个都有很便宜的修法。 退一步看整体。我对这个问题的独立基线方案是:core 里一个窄 executor 接口、传输层放在宿主包、凡是指名进程的字段严格校验、外部事件重新发布到既有 emitter。本 PR 得到了同样的结构,而且在 core 一侧比我的基线更用心 —— 在消费点重新校验(因为 session 级配置绕过 frontmatter 解析)、组合 我发现的、它没走到的更简路径在传输层文件上。本仓库已经两次解决过"启动一个外部 coding agent 并通过 ACP 驱动它"—— 客户端一侧是 它是否解决了用户在意的问题?是。而且方向论证是 PR 说明里最强的部分 —— 把 workspace 身份从 真正让我从"暂缓"倾向"要求修改"的是六个月后的维护视角。core 接口我会感谢你。executor 我会暗暗抱怨,因为 PR 说明写了 Codex 和 Gemini 是接下来打算接入的外部 agent —— 而接手的人会照抄这个文件,把缺失的超时、未脱敏的 stderr 和未净化的 env 一并带进之后每一个 adapter。现在修是三处小改动;等再多两个 adapter 之后修,就是一次重构。 第 1 条不是判断题。 我要求修改,不是因为我找不出理由说不 —— 我有三条具体的,每条都有仓库内点得出来的先例和一个很小的 diff。这里没有任何内容是在反对这个能力或这个设计;我希望它合入。 @wenshao —— 就 Stage 2 的三条严重问题请求修改。 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
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.
-
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.tsstates 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:2564does the equivalent spawn withsanitizeChildEnv. 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). -
The handshake has no deadline and no
exitracer.spawnFailurerejects only on theerrorevent, so a command that spawns, stays alive and never speaks ACP leavescreate()pending forever — an Agent tool call that never returns and never errors.qwen-live/src/adaptor/acp-adaptor.ts:574-582already racesinitializeagainst bothhandshakeDeadline()and anexitPromiserejecting onerrorandexit. Both races here (initialize,newSession) need it, and no post-handshakeexithandler exists either, so a mid-turn crash leaves parked permissions attached to a dead process. -
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]sendsallow_always. It contradicts the fail-safe posture the same file argues for inresolvePermissionModeandparseAgentExecutor, 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 评论。
-
spawn(..., { env: process.env })把 Qwen 内部密钥(QWEN_SERVER_TOKEN、QWEN_DAEMON_TOKEN、QWEN_CODE_EXTERNAL_TOOL_GUARD_TOKEN、QWEN_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)。 -
握手既无超时也无
exit竞争者。spawnFailure只在error事件上 reject,所以一个启动成功、保持存活、却从不说 ACP 的命令会让create()永久挂起 —— 一个永不返回也永不报错的 Agent 工具调用。qwen-live/src/adaptor/acp-adaptor.ts:574-582已经让initialize同时与handshakeDeadline()和一个在error和exit上都 reject 的exitPromise竞争。这里两处竞争(initialize、newSession)都需要,而且握手之后也没有 exit 处理器,因此回合中途崩溃会把停放的权限请求留在一个已死的进程上。 -
options.find(o => o.kind === wantKind) ?? options[0]可能用一个比用户批准范围更宽松的选项去应答权限请求 —— 对着[allow_always, reject_once]选择"仅此次",回传的是allow_always。这与同一文件在resolvePermissionMode和parseAgentExecutor中主张的失败即安全姿态相矛盾,且没有测试。应回退到拒绝,或回退到所提供选项中最不宽松的那个。
建议 4-6(复用 createStderrForwarder 以获得脱敏与跨 chunk 的行缓冲;executeExternalInputs 在 finalText 与 resetStats 上偏离 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
left a comment
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
[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)),
| 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_TOKEN、QWEN_CODE_EXTERNAL_TOOL_GUARD_TOKEN 和 QWEN_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) { |
There was a problem hiding this comment.
[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 |
There was a problem hiding this comment.
[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 (yolo → auto, bypassPermissions → bypassPermissions, 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 逐字映射原始值(yolo → auto、bypassPermissions → bypassPermissions),因此一个声明了 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: { |
There was a problem hiding this comment.
[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.json 的 permissions.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(); |
There was a problem hiding this comment.
[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'); |
There was a problem hiding this comment.
[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 }, |
There was a problem hiding this comment.
[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( |
There was a problem hiding this comment.
[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({ |
There was a problem hiding this comment.
[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)
| const toolCall = isRecord(params['toolCall']) ? params['toolCall'] : {}; | ||
| const callId = asString(toolCall['toolCallId']) ?? `perm-${Date.now()}`; |
There was a problem hiding this comment.
[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.
Review fixes pushed —
|
…gent # Conflicts: # packages/cli/vitest.config.ts
🖼️ web-shell visual previewRendered against a mock daemon (no real backend): the PR base vs this PR head Screenshots · before / afterFull-resolution recordings (.webm) are attached to the workflow run. — Qwen Code · web-shell visuals |
Evidence screenshots addedThese 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 The Web Shell surface for a delegated subagent is What was runA ResultThe run was verified independently of the screenshotsCaptured at 39.9s, 4 frames. Checked against the filesystem and the subagent metadata rather than taken on trust from the image:
Images are hosted on the Not yet capturedThe 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. |
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
left a comment
There was a problem hiding this comment.
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 unfalsifiabledocs/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 Herdrdocs/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)
| const executorRaw = frontmatter['executor']; | ||
| const executor = parseAgentExecutor(executorRaw); |
There was a problem hiding this comment.
[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}`), { |
There was a problem hiding this comment.
[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"}
| 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)
| this.pendingPermissions.clear(); | ||
| if (this.executing) { |
There was a problem hiding this comment.
[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) => |
There was a problem hiding this comment.
[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() 或驱动 onRequestPermission(acp-subagent-executor.test.ts 只覆盖三个纯函数),因此把拒绝兜底替换成 options[0] 也能全绿通过。R1-15 的评论已明确把这个测试作为修复的验收标准('respond(ProceedOnce) 对 [{optionId r, kind reject_once}] 必须 resolve cancelled 而非 optionId r,并确认移除兜底后变红');代码修了,测试没跟上。实测:兜底→options[0] 的变异体 11/11 通过;移除 onChildExit 中 pendingPermissions 释放循环的同源变异体同样 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) { |
There was a problem hiding this comment.
[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) { |
There was a problem hiding this comment.
[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'); |
There was a problem hiding this comment.
[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 }, |
There was a problem hiding this comment.
[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 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.
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/: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:多回合外部运行会发布少报且自相矛盾的时长统计,进程内执行器不会如此。
探针证据:对真实执行器相隔约 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( |
There was a problem hiding this comment.
[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, 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.
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.ts 与 packages/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 子路径(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 两只手臂的输出——未加映射时回落到 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-16 的 transcriptRecords),并确认 tsc --traceResolution 把该子路径解析到源码。
— qwen3.8-max via Qwen Code /review (v0.23.0)
| config.name, | ||
| ); | ||
| } | ||
| const subagent = await externalExecutor.create({ |
There was a problem hiding this comment.
[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)












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
executorblock 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
cwdto(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>.mdschema 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
executorfield,qwen -pcompleted the task, created the requested file, reportedsubtype: "success", and emitted nothing on stdout, stderr or the debug log to indicate the field had been ignored. The subagent's metadata reportedmodel: qwen3.8-max-2026-09-02and its transcript showedread_file,write_file,run_shell_commandandglob— 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:
Confirm three things.
impl.txtcontainsIMPL_OK. The subagent's.meta.jsonunder$QWEN_RUNTIME_DIR/projects/<cwd>/subagents/<sessionId>/reportsmodel: external-acp:npxrather than a Qwen model id. The subagent's.jsonltranscript contains Claude tool names and zero occurrences ofwrite_file.Then confirm the refusal path: remove the
executorinjection (or run the shipped CLI) and the same definition must not silently run in-process. With this PR the definition errors withregistered no external agent executorandAgentHeadless.createis never called — that non-call is asserted directly insubagent-manager.test.ts.Unit tests:
cd packages/cli && npx vitest run src/external-agents/andcd 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.
subtype: "success", file createdsubtype: "success", file created[external-agent claude-worker] [session/query] … apiType=native.meta.jsonmodelqwen3.8-max-2026-09-02external-acp:noderead_file×2,write_file×2,run_shell_command×2,glob×2Bash;write_filecount 0executorwas ignoredRaw before/after artifacts are in the branch author's spike notes; the after-run transcript and
.meta.jsonare reproducible with the command above.Web Shell
Headless Playwright capture of the Web Shell served by
qwen serveover a workspace whose.qwen/agents/claude-worker.mdcarries theexecutorblock. The prompt asks for theclaude-workersubagent; the turn runs in the external Claude Code process.Web Shell final state
That run was verified independently of the images rather than taken on trust from them:
ws-shots.txtcontains exactlyWS_OK, and the subagent metadata for the session reportspersistedCliFlags.model: "external-acp:node",agentType: "claude-worker",status: "completed", withBashthree times in its transcript and zero qwenwrite_filecalls.external-acp:nodeis the label this PR's executor reports throughgetCore().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/sessionsreturned 503The 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-captureharness (node-pty → xterm.js → Playwright headless). Included because it shows the tool-level output more legibly than the Web Shell transcript view.Full scrollback
Its run was verified the same way:
shots.txtcontains exactlySHOTS_OKand the subagent metadata reportsmodel: "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-pr11003branch of the author's fork underpr11003/.Tested on
Environment
Local build via
npm run build/npm run bundle, run asnode packages/cli/dist/index.js. Claude Code 2.1.259 and@agentclientprotocol/claude-agent-acp0.73.0.~/.claude/settings.jsonon the test machine setspermissions.defaultMode: "auto", which is the configuration the permission-mode derivation is designed to override.Risk & Scope
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.SubagentExecutorinterface widenscreateAgentHeadless's return type from a concrete class to an interface. All seven production call sites were updated;AgentHeadlessdeclaresimplements, so drift fails at compile time.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 advanceQWEN_CODE_MAX_TOKENS_PER_WORKFLOW. That gate is opt-in and defaults to unbounded, and the agent-count andmax_turns/max_time_minutesbounds still apply.infoconfirmation variant, so the Web Shell shows the tool title and the option names but not a rendered file diff. Theedit/execvariants are a follow-up.executor.commandnames an executable from a project-level file. This is the same trust model the existingmcpServersandhookscommand 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.edit/execapproval rendering; per-turn token accounting.executorblock the in-process path is byte-for-byte unchanged, including the system prompt —AgentCore.buildChatSystemPromptnow delegates to an extractedrenderSubagentSystemPromptwith 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>.mdschema,用户可以直接把文件丢进.qwen/agents/。这个 PR 把该兼容性从定义层延伸到执行层。静默替换问题是具体的、且在已发布 CLI 上实测过,不是推测:给一个带未知
executor字段的定义,qwen -p会完成任务、创建所要求的文件、报告subtype: "success",并且在 stdout、stderr、debug log 上都不发出任何"该字段被忽略了"的信号。subagent 的元数据报告model: qwen3.8-max-2026-09-02,transcript 里是read_file、write_file、run_shell_command、glob—— 全是 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 的.jsonltranscript 含 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 工具变为Bash且write_file计数为 0;token 记账从 118,209 全在 Qwen provider 变为 subagent 推理不在 Qwen provider 上;"executor 被忽略"的信号从"任何地方都没有"变为不适用(它被履行了)。Web Shell:用无头 Playwright 截取,daemon 为
qwen serve,工作区的.qwen/agents/claude-worker.md带executor块。prompt 要求使用claude-workersubagent,该轮在外部 Claude Code 进程中执行。Web Shell 最终状态
该次运行经过独立于图片的核验:
ws-shots.txt内容确为WS_OK,该会话的 subagent 元数据报persistedCliFlags.model: "external-acp:node"、agentType: "claude-worker"、status: "completed",transcript 中Bash三次、qwenwrite_file零次。external-acp:node正是本 PR 执行器通过getCore().modelConfig.model上报的标签;若静默回退到进程内,那里会是 Qwen 的 model id。一处既有的环境干扰(与本 PR 无关,截取时在浏览器控制台可见):
GET /standalone/sessions返回 503The Conversations runtime is owned by another daemon。它影响 standalone 会话列表,不影响上面展示的会话流程。终端:同一次委派经交互式 CLI 驱动,用仓库自带的
terminal-capture(node-pty → xterm.js → Playwright headless)截取。附上是因为它比 Web Shell 的 transcript 视图更清晰地展示工具级输出。完整回滚缓冲
其运行同样经过核验:
shots.txt内容确为SHOTS_OK,subagent 元数据报model: "external-acp:node"、status: "completed"。仍未截取:外部 agent 工具调用的审批对话框。它依赖权限 E2E(测试计划组 4 与组 5),该项尚未完成,是本 PR 安全攸关的缺口。
图片托管在作者 fork 的
assets-pr11003分支pr11003/下。测试环境
本地构建(
npm run build/npm run bundle),以node packages/cli/dist/index.js运行。Claude Code 2.1.259、@agentclientprotocol/claude-agent-acp0.73.0。测试机的~/.claude/settings.json设了permissions.defaultMode: "auto",而这正是权限模式推导要覆盖掉的配置。风险与范围
qwen/*扩展方法,因此七条 session 级诊断路由对它会返回 500。这不适用于本 PR 的委派路径 —— 被委派的 subagent 是 Qwen 会话内的一个任务,不是一个外部会话 —— 但它界定了将来"对等后端"设计能复用的范围。SubagentExecutor接口把createAgentHeadless的返回类型从具体类放宽为接口。七处生产调用点全部更新;AgentHeadless声明了implements,因此漂移会在编译期失败。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指名一个来自项目级文件的可执行文件。这与既有mcpServers、hooks的 command 字段是同一套信任模型,因此没有引入新边界;解析器只从已知字段重建 spec,未识别的键到不了 spawn 点。edit/exec审批渲染;每轮 token 记账。executor块时进程内路径逐字节不变,包括 system prompt ——AgentCore.buildChatSystemPrompt现在委托给抽出的renderSubagentSystemPrompt,行为一致。关联 Issue
无。设计依据、本 PR 所依据的 ACP 实测行为、以及被否决的"对等后端"替代方案都在本 PR 新增的
docs/design/claude-code-web-shell-backend.md里。