Skip to content

fix(channels): manage DingTalk interactive card config - #8517

Merged
wenshao merged 20 commits into
QwenLM:mainfrom
BenGuanRan:fix/dingtalk-interactive-card-management
Aug 7, 2026
Merged

fix(channels): manage DingTalk interactive card config#8517
wenshao merged 20 commits into
QwenLM:mainfrom
BenGuanRan:fix/dingtalk-interactive-card-management

Conversation

@BenGuanRan

@BenGuanRan BenGuanRan commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

This PR exposes DingTalk interactiveCards as a manageable nested object in the daemon channel catalog, validates its nested boolean/object/positive-timeout values before persistence, mirrors the descriptor contract in the TypeScript SDK, and makes Web Shell preserve object-valued configuration that it does not render.

It also documents the nested descriptor metadata and the requirement for clients to preserve unsupported field kinds.

Why it's needed

The DingTalk runtime adapter already supports interactiveCards, but the daemon management descriptor did not advertise it. As a result, PUT /workspace/channels/:name rejected valid configuration with channel_settings_invalid_config: Channel field "interactiveCards" is not manageable. before settings could be persisted or an active worker reloaded.

Reviewer Test Plan

How to verify

  1. Start qwen serve with an isolated workspace containing a configured but stopped DingTalk channel.
  2. Confirm GET /workspace/channel-types advertises interactiveCards with kind: "object" and nested statusCard / questionCard properties.
  3. Send PUT /workspace/channels/:name with interactiveCards.enabled, status-card enablement, question-card enablement, and a positive timeoutMs.
  4. Confirm the response is HTTP 200, GET /workspace/channels returns the same object, and the workspace settings file persists it.
  5. Send malformed nested values such as a string boolean, zero timeout, or an unknown nested key and confirm the daemon returns channel_settings_invalid_config without writing the file.

Relevant verification completed locally:

  • Channel Base: build; 19 test files / 971 tests passed.
  • DingTalk channel: build; 10 test files / 249 tests passed.
  • CLI: channel registry/settings tests, 43 tests passed; typecheck and build passed.
  • TypeScript SDK: build and typecheck passed; 31 test files / 1444 tests passed.
  • Web Shell: channel editor tests, 23 tests passed; build and typecheck passed.
  • Local daemon HTTP E2E: catalog GET returned the object descriptor, config PUT returned 200, follow-up GET matched, and the settings file contained the persisted object.

Evidence (Before & After)

Before: the daemon returned HTTP 400 with channel_settings_invalid_config and Channel field "interactiveCards" is not manageable.

After: the local daemon HTTP E2E returned HTTP 200 for the same management route and verified the exact nested object through both the follow-up API response and persisted workspace settings.

Tested on

OS Status
🍏 macOS ✅ tested
🪟 Windows ⚠️ not tested locally
🐧 Linux ⚠️ not tested locally

Environment (optional)

Local macOS workspace using an isolated QWEN_HOME, an ephemeral loopback daemon port, and a stopped DingTalk channel so no real credentials or external DingTalk connection were required.

Risk & Scope

  • Main risk or tradeoff: The new generic object descriptor validation rejects unknown nested keys by design; adding future DingTalk interactive-card fields requires advertising them in the descriptor.
  • Not validated / out of scope: Real DingTalk worker connection, message delivery, and card rendering were not exercised because the reported failure occurred in daemon configuration validation before worker reload.
  • Breaking changes / migration notes: None. Existing configurations without interactiveCards remain valid, and Web Shell preserves object-valued configuration that it cannot edit.

Linked Issues

Fixes #8515
Related to #6443

中文说明

此 PR 做了什么

此 PR 在 daemon 频道目录中将钉钉 interactiveCards 暴露为可管理的嵌套对象,在持久化前校验其中的布尔值、对象和正数超时字段,同步 TypeScript SDK 的描述符契约,并让 Web Shell 保留自身不渲染的对象类型配置。

同时补充了嵌套描述符元数据,以及客户端必须保留不支持字段类型的协议说明。

为什么需要此改动

钉钉运行时适配器已经支持 interactiveCards,但 daemon 管理描述符没有声明该字段。因此,PUT /workspace/channels/:name 会在配置持久化或活动 Worker 重载前,直接以 channel_settings_invalid_config: Channel field "interactiveCards" is not manageable. 拒绝有效配置。

Reviewer 测试计划

如何验证

  1. 使用隔离工作空间启动 qwen serve,其中包含一个已配置但未启动的钉钉频道。
  2. 确认 GET /workspace/channel-typesinteractiveCards 声明为 kind: "object",并包含嵌套的 statusCardquestionCard 属性。
  3. 调用 PUT /workspace/channels/:name,写入 interactiveCards.enabled、状态卡开关、问题卡开关和正数 timeoutMs
  4. 确认接口返回 HTTP 200,GET /workspace/channels 返回相同对象,且工作空间配置文件完成持久化。
  5. 分别提交字符串布尔值、零超时或未知嵌套字段等非法值,确认 daemon 返回 channel_settings_invalid_config,并且不写入配置文件。

本地已完成以下验证:

  • Channel Base:构建通过;19 个测试文件、971 个测试通过。
  • 钉钉频道:构建通过;10 个测试文件、249 个测试通过。
  • CLI:频道目录与配置专项测试 43 个通过;typecheck 和构建通过。
  • TypeScript SDK:构建和 typecheck 通过;31 个测试文件、1444 个测试通过。
  • Web Shell:频道编辑器专项测试 23 个通过;构建和 typecheck 通过。
  • 本机 daemon HTTP E2E:频道目录 GET 返回对象描述符,配置 PUT 返回 200,后续 GET 内容一致,并确认配置文件已持久化该对象。

前后证据

改动前:daemon 返回 HTTP 400、channel_settings_invalid_config,错误为 Channel field "interactiveCards" is not manageable.

改动后:本机 daemon HTTP E2E 对同一管理接口返回 HTTP 200,并通过后续 API 响应和工作空间配置文件确认嵌套对象完全一致。

测试平台

操作系统 状态
🍏 macOS ✅ 已测试
🪟 Windows ⚠️ 本地未测试
🐧 Linux ⚠️ 本地未测试

环境(可选)

本地 macOS 工作空间使用隔离的 QWEN_HOME、随机回环端口以及未启动的钉钉频道,因此无需真实凭证,也不会连接外部钉钉服务。

风险与范围

  • 主要风险或权衡:新的通用对象描述符校验会按设计拒绝未知嵌套字段;未来新增钉钉交互卡字段时,需要同步在描述符中声明。
  • 未验证或不在范围内:未验证真实钉钉 Worker 连接、消息投递和卡片渲染,因为本次问题发生在 Worker 重载前的 daemon 配置校验阶段。
  • 破坏性变更或迁移说明:无。未配置 interactiveCards 的现有配置仍然有效,Web Shell 会保留自身无法编辑的对象类型配置。

关联 Issue

Fixes #8515
Related to #6443

@github-actions github-actions Bot added the review/self-reported The linked issue was opened by the PR author (self-reported) label Aug 4, 2026

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for the PR, @BenGuanRan!

The PR body doesn't follow the repository's PR template — none of the required headings are present (it uses ## Summary / ## Problem / ## Testing instead). Please restructure the body using .github/pull_request_template.md:

  • ## What this PR does and ## Why it's needed — your Summary/Problem content maps onto these
  • ## Reviewer Test Plan, with ### How to verify, ### Evidence (Before & After), and the ### Tested on table
  • ## Risk & Scope — main risk/tradeoff, what was not validated, breaking changes
  • ## Linked Issues — keep the Fixes #8515 closing keyword
  • The <details> 中文说明 section translating the body paragraph by paragraph

The underlying change and the linked issue look well scoped — this is a formatting gate, not a rejection of the approach. Once the body follows the template, re-trigger with @qwen-code /triage.

中文说明

感谢贡献,@BenGuanRan

PR 描述没有遵循仓库的 PR 模板——所需的章节标题全部缺失(当前使用的是 ## Summary / ## Problem / ## Testing)。请按照 .github/pull_request_template.md 重新组织 PR 描述:

  • ## What this PR does## Why it's needed——现有的 Summary/Problem 内容可放入这两节
  • ## Reviewer Test Plan,包含 ### How to verify### Evidence (Before & After) 以及 ### Tested on 表格
  • ## Risk & Scope——主要风险/权衡、未验证的内容、破坏性变更说明
  • ## Linked Issues——保留 Fixes #8515 关闭关键词
  • <details> 中文说明部分,逐段翻译英文正文,不要省略

改动本身和关联 issue 的方向都很清晰——这只是格式上的门槛,不是对方案的否定。更新 PR 描述后,可用 @qwen-code /triage 重新触发审查。

Qwen Code · qwen3.8-max

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

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

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

🖼️ web-shell visual preview

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

Screenshots · before / after

ℹ️ No screenshot changed against the PR base — but this PR edits 2 render-shaping files:

  • packages/web-shell/client/components/channels/ChannelEditorDialog.tsx
  • packages/web-shell/client/i18n.tsx

Either the change has no visual effect (logic, plumbing, a state the scenarios never reach), or no scenario renders this UI — in which case the preview cannot see it, and an empty result is a coverage gap rather than a clean bill of health. To make it visible, add a scenario to packages/web-shell/client/e2e/visuals/screenshots.spec.ts that seeds whatever state the UI is gated on; it then appears here as a head-only (NEW) capture.

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

Qwen Code · web-shell visuals

@BenGuanRan

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ✅ passed — merge-ready (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 1364 passed · 0 failed · 1364 total

中文 — 判定:✅ 通过 · 可合入(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:1364 通过 · 0 失败 · 1364 总计

Verification report

PR 8517 Deep Verification — fix(channels): manage DingTalk interactive card config

Verdict: merge-ready — 1364/1364 scripted assertions passed (0 unexpected failures). Breakdown: store A/B harness 44 (22 head + 22 base) · daemon HTTP E2E 10 · proto/runtime-consistency probes 5 · mutation classifications 8 · targeted gate tests 1297 (CLI 54 + dingtalk 249 + channel-base 971 + web-shell 23).
Verified head: de65e6bbf6c80b387f386142e9e93b4fdd4ce66b (merge commit f6b99255, base tip 8566385a).

中文摘要
  • 结论merge-ready。全部 1364 个脚本化断言通过,无意外失败。
  • A/B 结论:中心声明得证。同一份合法 interactiveCards 配置:head 构建接受(HTTP 200 语义)、完整持久化到快照与工作区配置文件、密钥保留;base 控制端以原始 issue 的报错 Channel field "interactiveCards" is not manageable.channel_settings_invalid_config)拒绝,且不落盘。两侧各 22/22 断言通过(base 的"按预期拒绝"本身计为通过)。解析探针证实 base 臂确实加载了 base 构建的 dingtalk/channel-base 包(控制树自包含,无 workspace 链接串扰)。
  • 校验矩阵:11 种畸形嵌套值全部被拒(字符串布尔、零/负超时、未知嵌套键、深层未知键、null、数组、非对象嵌套),错误信息带完整嵌套路径,且配置文件字节不变;边界值(timeoutMs=1、0.5、空对象、部分嵌套)按预期接受。__proto__ 嵌套键被拒且无原型污染。daemon 校验与运行时解析器 parseDingtalkInteractiveCardConfig 对全部接受形状一致。
  • 真实 daemon E2E(head,真实 qwen serve 子进程 + 回环 HTTP):目录 GET 返回嵌套 object 描述符;PUT 200;后续 GET 原样返回(Web Shell 保全所依赖的路径);落盘一致;Web Shell 形状的保存(以 instance.config 为种子)在编辑其他字段时保全对象;省略该字段的 PUT 按替换语义将其移除(与协议文档新增的"客户端保全义务"一致);畸形 PUT 返回 400 + channel_settings_invalid_config 且不落盘;无 token 请求 401。10/10 通过。
  • 空转/变异检查:7 个变异中 6 个被对应测试以预期断言杀死(M1 删 object 分支、M2 删 exclusiveMinimum、M4 删未知键拒绝、M5 删 dingtalk 描述符、M6/M7 删 Web Shell 两处 object 守卫);唯一存活者 M3(删除嵌套 required 循环)经分类为当前死代码(无任何描述符把嵌套属性标为 required),非本 PR 行为缺陷。
  • 门禁:CLI store+registry+routes 54/54;dingtalk 249/249;channel-base 971/971;web-shell 频道编辑器 23/23 —— 与作者上报数字一致,且全部在 Linux 容器内复现(作者本地仅测 macOS)。
  • Findings:3 条均为信息级/微瑕,见正文(嵌套 required 死分支、嵌套环境引用报错用叶子键名——当前不可达、替换语义下省略即删除——文档已声明)。无阻塞项。
  • 未覆盖:真实钉钉 worker 连接/消息投递/卡片渲染(PR 已声明超范围;worker 重载路径仅在解析器层面验证);SDK 1444 项测试未重跑(本次改动为纯类型手工镜像,逐字段核对一致);仓库级门禁未跑(仅目标工作区)。

Central claim + A/B

Central claim: DingTalk interactiveCards becomes a manageable nested-object field — PUT /workspace/channels/:name accepts it, validates nested values, persists it — where base rejected it with channel_settings_invalid_config: Channel field "interactiveCards" is not manageable. (the issue #8515 error).

Secondary claims: (1) malformed nested values are rejected without writing the settings file; (2) Web Shell preserves object-valued config it does not render.

Control construction (named precisely): base channels packages (channel-base, channel-dingtalk) were rebuilt at HEAD^1 in a scratch worktree (git worktree add tmp/base-tree HEAD^1, tsc --build). The CLI side differs from head by exactly one file: channel-settings-store.ts at base, transpiled with esbuild (imports preserved, type-only imports erased) and dropped into a copy of the head dist. The control tree's node_modules/@qwen-code links point channel-base/channel-dingtalk at the base builds and everything else at head (unchanged by this PR; no dependency-manifest changes). The PR's effective change to the control is therefore exactly the PR's hunks. A per-arm resolution probe (assertion resolution-probe, PASS on both arms) prints the realpaths the harness actually loads, guarding against workspace-link confounds.

Witness: evidence/01-ab-store-head-vs-base.png (live run of both arms). Raw logs: logs/ab-head.log, logs/ab-base.log.

cell oracle head base (control)
catalog descriptor dingtalk field interactiveCards kind object, nested enabled/statusCard/questionCard, timeoutMs.exclusiveMinimum=0 PASS (advertised) PASS (absent, as expected)
upsert valid object accepted; snapshot + settings file carry exact object; secret preserved PASS — persisted PASS — rejected not manageable, file untouched
11 malformed shapes (string bool, nested string bool, timeout 0/-5, unknown key, deep unknown key, null enabled, non-record nested, array, null, string) channel_settings_invalid_config, file byte-identical PASS ×11 (full nested paths in messages) PASS ×11 (all rejected as not manageable, file untouched)
5 boundary accepts (timeout 1, 0.5, minimal, {}, nested partial) accepted + persisted PASS ×5 PASS ×5 (base rejects, as expected)
plain upsert without cards succeeds, no interactiveCards appears PASS PASS (identical)
unmanaged field unchanged / changed tolerated / rejected (pre-existing fallback intact) PASS / PASS PASS / PASS
__proto__ nested key rejected, Object.prototype unpolluted PASS (×2)

Counts: head 22/22, base 22/22 (expectation-encoded: base rejections are passing control assertions). The flip is load-bearing: the identical request body is rejected on base and persisted on head.

Real daemon HTTP E2E (head)

The issue scenario was driven against a real qwen serve child process (built bundle, isolated QWEN_HOME, ephemeral loopback port, bearer token): witness evidence/02-daemon-http-e2e-on-head.png, log logs/daemon-e2e.log, harness daemon-e2e.mjs. 10/10:

cell result
daemon boots on ephemeral loopback port PASS
no-token request → 401 PASS
GET /workspace/channel-types advertises nested object descriptor PASS
PUT /workspace/channels/bot valid cards → 200, response instance.config.interactiveCards exact PASS
follow-up GET /workspace/channels roundtrip (what Web Shell seeds from) PASS
workspace settings file contains the persisted object PASS
Web-Shell-shaped save (seed from instance.config + edit another field) preserves the object PASS
PUT omitting the object removes it (replacement semantics; the docs' client-preserve duty is real) PASS
timeoutMs: 0 → 400 channel_settings_invalid_config, file untouched PASS
unknown nested key → 400, not manageable, file untouched PASS

Consistency probe: every shape the daemon validator accepts round-trips through the pre-existing runtime parser parseDingtalkInteractiveCardConfig (including {} → defaults and timeoutMs: 0.5), so a reloaded worker will accept everything the management API persists.

Web Shell preservation (secondary claim 2)

Static chain verified end to end: daemon GET projects non-secret config values as-is (instanceFrom), buildChannelUpsertRequest seeds config from instance.config and skips object fields in the overlay loop (so the stored object survives), createChannelEditorDraft keeps object values out of the typed draft, renderField returns null for them. The daemon-side half of this chain is proven live by the E2E cells above (roundtrip + Web-Shell-shaped save); the client-side half by the PR's tests plus mutations M6/M7 below.

Corrections

None (first verification round; no prior report).

Findings (informational; none blocking)

  1. Nested required enforcement is dead code today, and unpinned (severity: info). Mutation M3 deletes the entire nested-required loop in assertDescriptorValue and all 42 store tests stay green (42/42). Classification: dead code rather than an ordinary coverage gap — no descriptor in the repo declares required: true on a nested object property (dingtalk's three object descriptors don't; no other channel declares kind: 'object'), so the loop can never throw today. It is a forward-looking hook for future descriptors; if it is meant to stay, a test with a synthetic required-nested descriptor would pin it. Not a merge condition.
  2. Nested env-reference errors would report the leaf key, not the full path (severity: nit, unreachable today). The invalidEnvironment throw uses field.key where the rest of the new code uses path; a nested string property carrying an $ENV_REF would produce Channel field "timeoutMs" ... instead of the dotted path. Unreachable with current descriptors (nested fields are boolean/number only), so flagged only for the next descriptor author.
  3. Replacement semantics: omitting interactiveCards deletes it (severity: info, by design). Proven live (E2E cell omit-means-replace): a PUT whose config omits the object removes it, because upsert replaces the channel entry. This is pre-existing store semantics and the PR documents the client-side duty to preserve unrendered kinds (docs/developers/qwen-serve-protocol.md); Web Shell demonstrably honors it. Third-party API clients should be aware.

Mutation matrix (vacuity + pinning)

Witness: evidence/03-mutation-matrix.png; rerunnable via mutation-matrix.sh (applies each mutant by exact string replacement, runs the target suite, restores sha-verified). Positive control first: unmutated head is green (43/43 store+registry).

mutant change target suite result
M1 delete object branch in assertDescriptorValue store tests KILLEDpersists DingTalk interactive card configuration... fails with Channel field "interactiveCards" has an invalid value.
M2 drop exclusiveMinimum clause store tests KILLEDrejects ... non-positive question timeout fails: promise resolved instead of rejecting
M3 delete nested required loop only full store suite SURVIVED (42/42 green) — dead code, see Finding 1
M4 drop unknown-nested-key throw store tests KILLEDrejects ... unknown nested field fails: promise resolved instead of rejecting
M5 delete interactiveCards descriptor from the dingtalk plugin (+ dist rebuild) registry + store tests KILLED — registry toContainEqual fails and persist test fails with the issue's own error: Channel field "interactiveCards" is not manageable.
M6 drop object skip in buildChannelUpsertRequest (web-shell) preserves hidden public settings... KILLED — seeded object is deleted from the request, toEqual fails
M7 drop object skip in renderField (web-shell) does not render object metadata as a text field KILLEDexpected <input data-slot="input" .../> to be null

Note on M1: the four rejects... tests stay green under it (they pin the error code, which the generic fallback also produces); the persists test is the one that pins the branch. All other mutants were killed by exactly the test whose name matches their behavior.

Targeted gates

gate result
CLI channel-settings-store.test.ts + channel-registry.test.ts + workspace-channel-management.test.ts 54/54 (42 + 1 + 11)
channels/dingtalk full suite 249/249
channels/base full suite 971/971
web-shell channel editor suites (2 files) 23/23; full client/components/channels/ dir 57/57 on post-mutation re-run

Gate liveness is proven by the mutation matrix itself: the same runners went red on six mutants. All suites ran on Linux (node:22-bookworm CI container), closing the author's "Linux not tested" column. Pre-existing failures: none observed on either arm. Repo-wide gates were not run (targeted workspaces only). The SDK change is a hand-maintained type mirror (no codegen in the repo); it matches channel-base field-for-field (object union member, properties, exclusiveMinimum), and the wire shape it describes was observed live in the E2E catalog GET.

Not covered

  • Live DingTalk worker: connection, message delivery, card rendering, and a worker reload with the new config were not exercised (no credentials/network in the sandbox; PR declares this out of scope). The reload path is covered only at the parser level (parseDingtalkInteractiveCardConfig accepts every daemon-accepted shape). This reproduces the wire/config handling, not a live card round-trip.
  • Base arm at the HTTP layer: the store-level A/B proves base rejection, and the route layer is thin, pre-existing, and unchanged (ERROR_STATUS mapping verified by reading; route suite green at head); a base daemon boot was not run.
  • SDK test suite (author-reported 31 files / 1444 tests) not re-run — the SDK delta is type-only; typecheck-level correctness is implied by the mirror diff, not re-measured here.
  • CLI typecheck/build not re-run separately (the pre-built head bundle was used for the daemon E2E and the harness drove compiled dist directly).
  • Per-commit attribution is trivially complete: single commit de65e6b, reachable locally, matching the metadata snapshot's commits array exactly.
  • Windows behavior (author marked untested); this round covers Linux only.
  • A hypothetical future descriptor with a top-level required object field would bypass Web Shell client-side isMissingField (returns false for object kinds) and surface as a daemon-side 400 instead — no such descriptor exists today, so this was not pursued.

Methodology

Environment: CI verify container (node:22-bookworm), merge-ref checkout at depth 2 (HEAD merge commit, HEAD^1 base tip, HEAD^2 PR head), npm ci + npm run build pre-run. The store harness (ab-harness.mjs) drives the real compiled WorkspaceChannelSettingsStore and channel registry (no stubs) against throwaway workspaces with isolated QWEN_HOME, one fresh settings file per cell, asserting snapshot content, on-disk file bytes, and error code+message per arm. The control differs from head by exactly the PR's hunks (base-rebuilt channel packages + single-module base transpile of the store), with realpath resolution probes quoted per arm. The daemon E2E (daemon-e2e.mjs) spawns the built bundle as a real child process and speaks loopback HTTP with bearer auth. Mutations were applied as exact string replacements on source, run through each package's own vitest, and restored sha-verified (backup/shas.txt all OK, git status clean at exit). Raw logs in logs/, harnesses in the artifact root, evidence PNGs in evidence/.

Evidence images

01-ab-store-head-vs-base

02-daemon-http-e2e-on-head

03-mutation-matrix

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

Built the PR base vs this PR head 737d559, drove a fixed endpoint set against each, and diffed the JSON responses. Only fields that changed are shown.

No response changes against the PR base across 4 scenario(s).

Qwen Code · serve A/B

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Re-run on 737d559d — since the last pass the #8440 merge conflict was merged out (two main merges, branch is MERGEABLE again) and one substantive commit landed: 55cca44c, the round-11 hardening. The gate still passes.

  • Template: all required sections present ✓.
  • Problem: an observed bug with concrete evidence, not theory. fix(channels): expose DingTalk interactiveCards through the daemon management API #8515 (self-reported by the author, already labeled review/self-reported) reproduces the exact channel_settings_invalid_config: Channel field "interactiveCards" is not manageable. 400 with steps and payload, and the maintainer's merge-base A/B confirmed the 400 → 200 flip on the prior head.
  • Direction: aligned. The DingTalk runtime adapter already parses interactiveCards; the management descriptor just never advertised it, so the daemon rejected valid configuration. This closes a real config-management gap rather than adding new surface. CHANGELOG has no direct reference, but the area is clearly relevant.
  • Size: cross-package (channels/base, channels/dingtalk, cli, sdk-typescript, web-shell, webui) → core under Stage 0. ≈630 production-logic lines + ≈2,100 test lines + 47 docs lines across 25 files. fix type → no hard block; 500+ production lines carries a maintainer-awareness flag — delivered: @wenshao validated this PR in depth on 24c358ec and approved the current head before triggering this re-run.
  • Approach: scope is still right. The increment since the last pass is exactly the round-11 review feedback: the validateConfig rejection-leak fix plus descriptor-gate and type-contract hardening. No drive-by changes.
  • Risk: no high-risk path matches — no elevated risk signals. main has moved one test-only commit (9e1b1eb) ahead of the branch since the last merge; no conflict.

Moving on to code review. 🔍

中文说明

737d559d 上重新运行 —— 距上次审查,#8440 带来的合并冲突已通过两次合入 main 解决(分支重新 MERGEABLE),并新增了一个实质性 commit:55cca44c(第 11 轮加固)。准入门仍然通过。

  • 模板:所有必需章节齐全 ✓。
  • 问题:有具体证据的已观测 bug,不是理论问题。fix(channels): expose DingTalk interactiveCards through the daemon management API #8515(作者自报,已打 review/self-reported 标签)复现了确切的 channel_settings_invalid_config: Channel field "interactiveCards" is not manageable. 400,附带步骤和 payload;维护者在 merge-base 上的 A/B 对照已确认 400 → 200 的转变。
  • 方向:对齐。钉钉运行时适配器早已能解析 interactiveCards,只是管理描述符从未声明它,导致 daemon 拒绝有效配置。此 PR 关闭真实存在的配置管理缺口,而非新增功能面。CHANGELOG 无直接引用,但该领域显然相关。
  • 规模:跨包改动(channels/base、channels/dingtalk、cli、sdk-typescript、web-shell、webui)→ 按 Stage 0 属于核心。约 630 行生产逻辑 + 约 2,100 行测试 + 47 行文档,共 25 个文件。fix 类型 → 不硬阻断;500+ 生产行需维护者关注 —— 已满足:@wenshao 已在 24c358ec 上深入验证本 PR,并在触发本次重审前批准了当前 head。
  • 方案:范围仍然合理。上次审查以来的增量恰好是第 11 轮评审反馈:validateConfig rejection 泄漏修复 + 描述符门控与类型契约加固。无顺手改动。
  • 风险:未命中任何高风险路径 —— 无升级风险信号。自上次合并后 main 又前进了一个仅测试的 commit(9e1b1eb);无冲突。

进入代码审查。🔍

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Code review

Full diff re-read at this head; the close read this pass is the increment since the last review — 55cca44c (+231/−19, 12 files), the only substantive commit after the main merges. No blocking issues anywhere.

Increment findings:

  • Critical fix — validateConfig rejection leak (correct). A non-async validateConfig that returns a rejected Promise passed the registry's synchronous-function guard, and the backstop threw a clean 400 without ever attaching a handler — on Node 22's default --unhandled-rejections=throw that dangling rejection terminates the daemon. The fix attaches a no-op catch to the Promise before the backstop throws. Verified in context: the call sits in a try/catch (sync throws → clean 400), non-string returns hit the backstop message, and the registry rejects async/non-function validateConfig at registration — so the only reachable leak was a sync function deliberately returning a Promise, which is now contained. The regression test pins all three outcomes: clean 400, zero unhandledRejection events, settings file untouched.
  • Union contract split (mirrored correctly). envResolvable is now advertised only on string/secret; boolean/string-list/record get a new plain-value member with envResolvable?: never, and enum flips to never too — in both mirrors (channels/base and sdk-typescript), re-exported through the channel-base index, SDK daemon/root indexes, and both webui re-export files. The type-level mirror-identity test pins the two contracts remain identical.
  • Gate hardening (sound). label must be a non-empty string, description/default strings when present, exclusiveMinimum restricted to number fields and finite, enum default must be one of the declared options. ~100 new registry test cases plus the wire-shape assertion updated to match. All 8 builtins pass unchanged.

The rest of the implementation was reviewed pass-by-pass at 24c358ec and independently validated by the maintainer on real surfaces (25-scenario daemon HTTP A/B, 400-payload differential fuzz with 0 violations, Chromium editor round-trip showing the web-shell hunk prevents data loss, 19/19 mutants killed). Nothing outside the increment has changed in this PR's files since.

Files changed (25 of 25 shown)
File What changed
docs/developers/qwen-serve-protocol.md Documents nested properties, exclusiveMinimum, the preserve rule, wholesale-replace semantics
docs/users/features/channels/dingtalk.md User docs for interactiveCards with config example and timeout cap
packages/channels/base/src/index.ts Exports the descriptor union members incl. the new plain-value member
packages/channels/base/src/types.ts Discriminated descriptor union; object kind with nested properties; envResolvable narrowed to string/secret
packages/channels/dingtalk/src/index.ts Advertises interactiveCards with the nested statusCard and questionCard tree
packages/channels/dingtalk/src/interactive-card-types.ts Shared timeout constants; clamps timeoutMs at the setTimeout maximum
packages/channels/dingtalk/src/interactive-card-types.test.ts Pins descriptor-parser compatibility and the clamp
packages/cli/src/commands/channel/channel-registry.ts Fail-closed descriptor validation; kind-specific property checks; strips management and keeps the runtime alive
packages/cli/src/commands/channel/channel-registry.test.ts ~40 malformed-descriptor cases plus catalog assertions for the new field
packages/cli/src/commands/channel/channel-registry-builtins.test.ts Invalid built-in survives without management; builtins keep their labels
packages/cli/src/commands/channel/channel-descriptor-sdk-mirror.test.ts Type-level identity between channel-base and SDK descriptors plus wire-shape check
packages/cli/src/serve/channel-settings-store.ts Recursive nested validation, preserved-unchanged objects, reserved-key scans, validateConfig containment incl. rejection-leak fix
packages/cli/src/serve/channel-settings-store.test.ts Persistence, preservation, rejection, reserved keys at depth, rejected-Promise leak coverage
packages/cli/src/serve/routes/workspace-channel-management.test.ts Asserts the exact nested descriptor shape on the wire
packages/cli/src/serve/server.test.ts skipIf guard on IPv6 loopback tests for containers without ::1
packages/sdk-typescript/src/daemon/types.ts SDK mirror of the descriptor union incl. plain-value member
packages/sdk-typescript/src/daemon/index.ts Re-exports the new SDK types
packages/sdk-typescript/src/index.ts Re-exports the new SDK types
packages/web-shell/client/components/channels/ChannelEditorDialog.tsx Skips rendering object fields; maps the outOfRange validation message
packages/web-shell/client/components/channels/channel-editor-state.ts Draft, validation, and upsert skip object fields; exclusiveMinimum client check
packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx Round-trip, render-skip, and outOfRange coverage
packages/web-shell/client/components/channels/channel-editor-state.test.ts Object-field exclusion and numeric-range validation coverage
packages/web-shell/client/i18n.tsx EN and ZH strings for the outOfRange message
packages/webui/src/daemon-react-sdk.ts Re-exports the new SDK types for webui
packages/webui/src/daemon/index.ts Re-exports the new SDK types for webui

Test evidence — this head's own CI, read via the API

Evidence carried: the PR's own CI check results on this commit, fetched through the checks API. No PR code was built or executed in this environment (unattended run). Every pull_request-event workflow run on this head completed green, zero pending.

Check Conclusion
Qwen Code CI — Test (ubuntu-latest, Node 22.x) ✅ success
Qwen Code CI — Test (windows/macos), Integration Tests (CLI, No Sandbox) ⏭️ skipped (whole job, structural fork-CI limit)
Serve A/B (ubuntu-latest, Node 22.x) ✅ success
Real daemon E2E ✅ success
Live Host (macos-latest) ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success
Capture web-shell visuals ✅ success
Qwen Live Host CI / SDK Java / Web-shell Visuals ✅ success

The Windows/macOS Test jobs and the no-sandbox integration suite are skipped whole (no steps execute) — a structural fork-CI limitation, identical on every fork PR, and the premise of a CHANGES_REQUESTED review on the earlier head that no longer applies: the round-11 report records a local integration-suite run at 183 tests passed (author's claim, not independently re-run here), and the behavioural jobs that do run on fork heads — Serve A/B, Real daemon E2E, Live Host, web-shell E2E — are all green on this commit.

The central behavioural claim (PUT accepts, validates, and persists interactiveCards; malformed payloads rejected without touching the file) was A/B-proven by the maintainer's real-environment validation on the prior head; on this exact head a sandboxed verification run is in flight now: run 31193106797. It will settle the round-trip and rejection behaviour against the final code, including the rejection-leak fix.

中文说明

代码审查

已在本 head 上重读完整 diff;本轮精读的是上次审查以来的增量 —— 55cca44c(+231/−19,12 个文件),即 main 合并之后唯一的实质性 commit。各处均无阻塞问题。

增量结论:

  • 严重修复 —— validateConfig rejection 泄漏(正确)。 非 async 的 validateConfig 返回 rejected Promise 时能通过注册守卫的同步函数检查,而兜底抛出干净 400 时从未挂 handler —— Node 22 默认 --unhandled-rejections=throw 会因此杀掉 daemon。修复在兜底抛出前给 Promise 挂空 catch。已结合上下文核实:调用包在 try/catch 中(同步抛出 → 干净 400),非字符串返回走兜底消息,且注册阶段已拒绝 async/非函数 validateConfig —— 因此唯一可达的泄漏路径是同步函数刻意返回 Promise,现已被兜住。回归测试同时钉住三个结果:干净 400、零 unhandledRejection、配置文件未被改动。
  • 联合类型契约拆分(镜像正确)。 envResolvable 现在只在 string/secret 上声明;boolean/string-list/record 使用新增的 plain-value 成员(envResolvable?: never),enum 也改为 never —— 两处镜像(channels/base 与 sdk-typescript)同步更新,并经 channel-base index、SDK daemon/根 index 与 webui 两处再导出文件导出。类型级镜像一致性测试钉住两份契约完全一致。
  • 门控加固(合理)。 label 必须为非空字符串,description/default 若存在必须为字符串,exclusiveMinimum 仅限 number 字段且必须有限,enum 的 default 必须是已声明选项之一。新增约 100 个注册表测试用例,wire-shape 断言同步更新。全部 8 个内建频道不受影响。

实现的其余部分已在 24c358ec 逐段审查,并由维护者在真实界面独立验证(25 场景 daemon HTTP A/B、400 payload 差分模糊测试 0 违规、Chromium 编辑器往返证明 web-shell 改动防止数据丢失、变异测试 19/19 全部杀死)。此后本 PR 文件中除增量外无任何改动。

测试证据 —— 本 head 自身 CI,经 API 读取

本节证据:本 commit 自身 CI 检查结果(经 checks API 获取)。本环境未构建或执行任何 PR 代码(无人值守运行)。本 head 上所有 pull_request 事件工作流运行均为绿色,无待处理项。

Windows/macOS 测试与无沙盒集成套件为整任务跳过(无步骤执行)—— fork PR 的结构性 CI 限制,每个 fork PR 均如此;这也正是早前 head 上那条 CHANGES_REQUESTED 评审的前提,现已不再适用:第 11 轮报告记录了本地集成套件运行(183 个测试通过,为作者自述,未在此独立复跑),而 fork head 上确实运行的行为类任务 —— Serve A/B、Real daemon E2E、Live Host、web-shell E2E —— 在本 commit 上全部为绿。

核心行为主张(PUT 接受、校验并持久化 interactiveCards;非法 payload 被拒绝且不触碰文件)已由维护者在上一 head 的真实环境 A/B 证实;针对本 head,沙盒验证运行正在进行中(run 31193106797),将在最终代码上确认往返与拒绝行为(含 rejection 泄漏修复)。

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — the increment since the last pass is small, correct, and pinned by tests; all CI on this head is green with nothing pending; and the maintainer validated the PR deeply and approved this exact commit. The one standing reservation is coverage, not code: the Windows/macOS test legs and the no-sandbox integration suite never execute on fork heads (structural), so those surfaces rest on the behavioural CI jobs, the author's local runs, and the sandboxed verify still in flight.

My independent proposal for this problem — advertise the field, add a nested-object descriptor kind, validate recursively, mirror the SDK contract, and stop the editor from destroying values it cannot render — is what the PR ships, and the increment since the last reviewed head stays inside that shape. The critical fix in 55cca44c is the kind of hardening that earns its lines: a sync validateConfig returning a rejected Promise would have crashed the daemon on Node 22, and the fix contains it without changing the clean-400 contract — with a test that would fail loudly if the handler ever regressed. The union split removing envResolvable from kinds that can never resolve env references is a contract tightening, mirrored exactly in the SDK and pinned by the identity test. No bloat, no drive-bys, every hunk traces to the stated goal or to review feedback.

On the process question: a cross-package change at ~630 production lines is escalated for maintainer awareness under Stage 0, which would normally cap this at 3/5 and defer. That awareness has been delivered in the strongest form the gate can ask for — @wenshao rebuilt both arms, drove the real daemon, editor, and registry through 25 A/B scenarios plus a 400-payload fuzz, re-reviewed the increment, and approved this exact commit before triggering this run. Deferring back to the person already standing in the PR with an approval would be ceremony, not protection, so I am treating the escalation as satisfied and approving. Flagging the reasoning here so the deviation from the mechanical cap is explicit and auditable.

Actions taken: approving pinned to the reviewed commit; dismissing the bot's stale CHANGES_REQUESTED from the earlier head (its premise — the integration suite "not run locally" — was resolved by the round-11 local run and is moot anyway, since that job skips structurally on every fork PR).

Merge note: the branch is MERGEABLE again after the takeover loop merged out the #8440 conflict; main has moved one test-only commit ahead since, with no conflict. All pull_request-event CI on this head is complete and green, so the approval is posted now rather than deferred. The sandboxed verify run will post its own report when it lands.

中文说明

置信度:4/5 —— 上次审查以来的增量小、正确且有测试钉住;本 head 的全部 CI 为绿且无待处理项;维护者已深入验证并批准了本 commit。唯一保留项是覆盖面而非代码:Windows/macOS 测试腿与无沙盒集成套件在 fork head 上从不执行(结构性限制),这些表面依赖行为类 CI 任务、作者的本地运行,以及仍在进行的沙盒 verify。

我对该问题的独立方案 —— 声明字段、新增嵌套对象描述符类型、递归校验、镜像 SDK 契约、阻止编辑器破坏其无法渲染的值 —— 与本 PR 的实现一致,且上次审查以来的增量没有超出该范围。55cca44c 中的严重修复是值回票价的加固:同步 validateConfig 返回 rejected Promise 本会在 Node 22 上杀掉 daemon,修复在不改变干净 400 契约的前提下将其兜住 —— 并附带一个在 handler 回归时会响亮失败的测试。将 envResolvable 从永远无法解析环境引用的类型中移除的联合类型拆分是一次契约收紧,在 SDK 中精确镜像并由一致性测试钉住。无堆砌、无顺手改动,每个改动块都能追溯到既定目标或评审反馈。

关于流程问题:约 630 行生产逻辑的跨包改动按 Stage 0 需提请维护者关注,通常会将本 PR 限制在 3/5 并转交维护者。但该关注已以门控所能要求的最强形式落实 —— @wenshao 重建了两侧构建,驱动真实 daemon、编辑器与注册表完成 25 个 A/B 场景与 400 payload 模糊测试,复审了增量,并在触发本次运行前批准了本 commit。把问题再转交给已经带着批准意见站在 PR 里的人只是仪式而非保护,因此我视该升级请求已完成并予以批准。在此明确写下推理过程,使这一偏离机械上限的决定公开可审。

已执行操作: 批准钉在所审查的 commit 上;驳回本 bot 在早前 head 上的过期 CHANGES_REQUESTED(其前提 —— 集成套件"未在本地运行" —— 已由第 11 轮本地运行解决,且该任务在所有 fork PR 上本就被结构性跳过,前提本身已不成立)。

合并提示: takeover 循环合入解决 #8440 冲突后,分支重新 MERGEABLE;此后 main 又前进了一个仅测试的 commit,无冲突。本 head 上所有 pull_request 事件 CI 已完成且全绿,因此直接发布批准而非延迟。沙盒 verify 运行结束后会自行发布报告。

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Triage re-run completed without a new review.

The bot already has a review of its own on de65e6bbf6c80b387f386142e9e93b4fdd4ce66b, which still stands.

机器人在 de65e6bbf6c80b387f386142e9e93b4fdd4ce66b 上已有自己的评审,且仍然有效。

The stage comments above were updated with the latest result. View workflow run.

上方各阶段评论已更新为最新结果。查看工作流运行

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed. Suggestions are inline. 2 Suggestion-level finding(s) could not be anchored to a changed line and were dropped; nothing further to act on here. Test Plan (not a blocker): 43 tests passed — this review observed 971, 249, 1444 passed; 23 tests passed — this review observed 971, 249, 1444 passed.

中文说明

已审查。 建议见行内评论。 2 条建议级发现无法锚定到改动行,已丢弃;此处无需进一步处理。 Test Plan(非阻断):43 tests passed — this review observed 971, 249, 1444 passed; 23 tests passed — this review observed 971, 249, 1444 passed

— qwen3.7-max via Qwen Code /review (v0.21.4)

Comment thread packages/cli/src/serve/channel-settings-store.test.ts
Comment thread packages/cli/src/serve/channel-settings-store.ts Outdated
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review timed out. Qwen review timed out after 10800 seconds (of the 180-minute budget). For large PRs, retry with a longer timeout by commenting: @qwen-code /review --timeout=360. See workflow logs.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed — no blockers. Suggestions are inline. Test Plan (not a blocker): 43 tests passed — this review observed 971, 249, 1444, 2776 passed; 23 tests passed — this review observed 971, 249, 1444, 2776 passed.

中文说明

已审查——无阻断问题。 建议见行内评论。 Test Plan(非阻断):43 tests passed — this review observed 971, 249, 1444, 2776 passed; 23 tests passed — this review observed 971, 249, 1444, 2776 passed

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

Comment thread packages/cli/src/serve/channel-settings-store.ts
Comment thread packages/cli/src/serve/channel-settings-store.ts Outdated
Comment thread packages/channels/base/src/types.ts Outdated
Comment thread packages/cli/src/serve/channel-settings-store.ts Outdated
Comment thread packages/channels/dingtalk/src/index.ts Outdated
Comment thread packages/channels/dingtalk/src/index.ts
Comment thread packages/channels/base/src/types.ts Outdated
Comment thread packages/cli/src/serve/channel-settings-store.ts Outdated
@wenshao

wenshao commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /takeover

@qwen-code-dev-bot qwen-code-dev-bot added the autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+) label Aug 5, 2026
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached. This is a fork PR, so the first round comes from the next scheduled scan (usually within minutes). Remove the autofix/takeover label (or comment @qwen-code /takeover stop) to release.

中文说明

🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。本 PR 来自 fork,首轮处理将由下一次定时扫描执行(通常几分钟内)。移除 autofix/takeover 标签(或评论 @qwen-code /takeover stop)即可释放。

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed. Suggestions are inline. Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally. Test Plan (not a blocker): 43 tests passed — this review observed 971, 250, 17117, 1444, 2776 passed; 23 tests passed — this review observed 971, 250, 17117, 1444, 2776 passed.

中文说明

已审查。 建议见行内评论。 未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。 Test Plan(非阻断):43 tests passed — this review observed 971, 250, 17117, 1444, 2776 passed; 23 tests passed — this review observed 971, 250, 17117, 1444, 2776 passed

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

Comment thread packages/cli/src/commands/channel/channel-registry.ts
Comment thread packages/cli/src/commands/channel/channel-registry.ts Outdated
Comment thread packages/cli/src/commands/channel/channel-registry.ts Outdated
Comment thread packages/cli/src/commands/channel/channel-registry.ts Outdated
Comment thread packages/cli/src/commands/channel/channel-registry.ts
@BenGuanRan

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ✅ passed — merge-ready (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 1592 passed · 0 failed · 1592 total

中文 — 判定:✅ 通过 · 可合入(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:1592 通过 · 0 失败 · 1592 总计

Verification report

PR 8517 Deep Verification (round 2) — fix(channels): manage DingTalk interactive card config

Verdict: merge-ready — 1592/1592 scripted assertions passed (0 unexpected failures). Breakdown: store A/B harness 205 (106 head + 99 base) · registry isolation harness 29 (variants a/b/c) · daemon HTTP E2E 34 · mutation classifications 12 (9 killed-as-predicted + 1 predicted survivor + positive control + sha-restore) · targeted gate tests 1311 (CLI 67 + dingtalk 250 + channel-base 971 + web-shell 23) · SDK-mirror identity check 1.
Verified head: 70d5e0175bbdc866f14a0b0063046b34eb48776d (merge commit e049bd71, base tip 67d12871).

中文摘要
  • 结论merge-ready。1592 个脚本化断言全部通过,无意外失败。这是第二轮验证(上一轮验证的是第 1 个提交 de65e6b;此后 PR 新增 3 个提交,其中 2 个在 depth-2 浅克隆中不可单独到达,本轮验证的是聚合 diff HEAD^1..HEAD)。
  • A/B 结论(见「Central claim + A/B」表,见证图 01-ab-store-head-vs-base.png):中心声明在新 head 上再次得证。同一份合法 interactiveCards 配置:head 接受(HTTP 200 语义)、完整持久化到快照与工作区配置文件、密钥保留;base 控制端以原始 issue 的报错 Channel field "interactiveCards" is not manageable. 拒绝。两侧各 106/106 与 99/99 断言通过(base 的"按预期拒绝/按预期透明通过"均为编码预期,计为通过)。
  • 本轮新增行为(提交 3/4 的增量)
    1. 描述符隔离:内置频道加载时校验 management 描述符;非法者(required object、保留键、嵌套 secret、嵌套 envResolvable、重复键)被跳过并向 stderr 输出带频道名的诊断,其余 7 个频道类型不受影响;store 对被隔离类型的 PUT 以 channel_settings_unmanageable 失败关闭且不落盘;合法对照变体正常注册(见「Registry isolation」,图 03-isolation-variants.png)。
    2. 嵌套未知键保全:与已存配置逐字节一致的未知嵌套键允许通过(客户端保全社会义务的嵌套版本),新增或被修改的未知嵌套键以完整嵌套路径拒绝;即使被保全的 blob 也会过校验(base 对已存 blob 是完全透明、不校验的——更严格,见 Findings 2)。
    3. 错误信息全部带完整嵌套路径(上一轮 Finding 2 已修复并被新测试钉死)。
  • 真实 daemon E2E(head,真实 qwen serve 子进程 + 回环 HTTP,图 02-daemon-http-e2e-on-head.png):34/34,含 issue 场景全链路(目录 GET → PUT 200 → 后续 GET 一致 → 落盘一致)、嵌套保全社会三格(新未知键 400 全路径 / 磁盘遗留键原样回传 200 / 改动遗留键 400)、省略即删除、timeoutMs=0 拒绝且不落盘、无 token 401、陈旧 revision 409。
  • 变异矩阵(图 04-mutation-matrix.png):9 个变异按预期被杀、1 个按预期存活(M8b,草稿侧 object 跳过无测试钉住,分类为覆盖缺口)。上一轮唯一存活者 M3(嵌套 required 循环)本轮被新增测试 rejects an omitted required nested descriptor property without writing 杀死——上一轮 Finding 1 的"死代码且无测试"状态已被提交 2 解决。
  • 门禁(图 05-targeted-gates.png):CLI store+registry+builtins+routes 67/67;dingtalk 250/250;channel-base 971/971;web-shell 频道编辑器 23/23。全部 Linux 容器复现(作者仅测 macOS)。SDK 类型镜像与 channel-base 逐字段一致(脚本比对)。
  • Findings:4 条均信息级(PR 正文未随提交 3/4 更新、base 透明 blob 语义差异、M8b 覆盖缺口、嵌套 required 仍无生产描述符使用),见正文。无阻塞项。
  • 未覆盖:真实钉钉 worker 连接/投递/渲染;提交 2/3 的逐提交归因(浅克隆不可达,仅验证聚合 diff);SDK 1444 项测试未重跑;Windows;base 臂 HTTP 层。

Previous-finding status (follow-up round)

Round 1 verified head de65e6b (commit 1 of 4). Commits 2–4 (5dbaa65, bbbd2c8, 70d5e01) landed since; the last two are not individually reachable in this depth-2 checkout, so every measurement below was re-run at the new head — nothing carried forward by shortcut.

# round-1 finding severity status at new head
1 Nested required enforcement is dead code, unpinned (no descriptor used it, no test pinned it) info superseded — commit 2 added a test-only descriptor with a required nested property and a test that pins the loop; my mutant M3 (delete the nested-required call) is now KILLED by exactly that test. The loop is still unused by every shipped descriptor (dingtalk's nested properties are none of them required; the registry permits required only on nested value fields), but it is no longer unpinned.
2 Nested env-reference errors report the leaf key, not the full path nit fixedassertDescriptorValue now uses path everywhere; A/B cell env-ref-nested asserts Channel field "interactiveCards.statusCard.enabled" does not support environment references. on head, and the new test reports the full path for nested environment references pins it. Additionally the registry now refuses nested envResolvable fields outright, so no future descriptor can reach the path at all.
3 Replacement semantics: omitting interactiveCards deletes it (by design) info stands — re-measured live (E2E cell D12: PUT without the field returns 200 and the object is gone from response and file). Pre-existing store semantics, documented in docs/developers/qwen-serve-protocol.md.

Round 1's "Not covered" item — a hypothetical future descriptor with a top-level required object field bypassing Web Shell's client-side isMissingField — is now closed by construction: the registry rejects required-object descriptors at registration (Channel field "<path>" cannot be a required object.), and the type system encodes required?: false for object fields.

Central claim + A/B

Central claim (unchanged from round 1, re-proven at the new head): DingTalk interactiveCards is a manageable nested-object field — PUT /workspace/channels/:name accepts it, validates nested values, persists it — where base rejected it with channel_settings_invalid_config: Channel field "interactiveCards" is not manageable. (issue #8515).

New secondary claims this round: (N1) invalid management descriptors are isolated at registry load without harming other channels; (N2) unknown nested keys are preserved when byte-identical to stored config and rejected otherwise; (N3) every nested error carries its full dotted path.

Control construction: base channel packages (channel-base, channel-dingtalk) rebuilt at HEAD^1 (tsc --build) in a scratch worktree; the CLI side of the control is a copy of the head dist/ with exactly two files swapped for esbuild-transpiled base versions (channel-settings-store.ts, channel-registry.ts — both import channel-base type-only, so no runtime import changes). The control's node_modules/@qwen-code/channel-base and channel-dingtalk symlink into the base tree; all other channel packages and core link to the head tree (unchanged by this PR; lockfile untouched). Per-arm resolution probes assert the realpaths actually loaded (PASS on both arms; quoted in logs/ab-{head,base}.log). Confound check: the base-vs-head channel-base dist delta is type declarations only (diff -r shows only types.d.ts differs), so base dingtalk resolving channel-base from the root node_modules carries zero runtime difference. The control differs from head by exactly the PR's hunks.

Witness: evidence/01-ab-store-head-vs-base.png (live run of both arms). Raw logs: logs/ab-head.log, logs/ab-base.log. Harness: ab-harness.mjs (expectation-encoded per arm; base rejections and base opaque-pass-throughs are passing control assertions).

cell head base (control)
catalog descriptor interactiveCards kind object, nested enabled/statusCard/questionCard, timeoutMs.exclusiveMinimum=0 PASS (advertised) PASS (absent, as expected)
upsert valid full object (the issue scenario) PASS — persisted to snapshot + file, secret preserved PASS — rejected, exact issue error Channel field "interactiveCards" is not manageable., file untouched
4 boundary accepts ({enabled:false}, {}, timeoutMs 1, timeoutMs 0.5) PASS ×4 — persisted PASS ×4 (rejected, as expected)
plain upsert without cards PASS (no field appears) PASS (identical)
8 malformed shapes (non-object, string bool ×2, timeout 0/−5, non-object nested, null enabled, array) PASS ×8 — rejected with full nested paths, file byte-identical PASS ×8 (rejected as not manageable, file untouched)
2 new unknown nested keys (top + deep) PASS — interactiveCards.unexpected / interactiveCards.statusCard.mystery not manageable PASS (rejected)
nested env reference $STATUS_CARD PASS — full path interactiveCards.statusCard.enabled in the message (round-1 finding 2 fixed) PASS (rejected)
__proto__ / constructor nested keys in PUT payload PASS ×2 — rejected as unknown nested keys; Object.prototype unpolluted PASS ×2 (rejected)
preserved unknown nested key, unchanged (seeded on disk) PASS — accepted and persisted (nested preservation) PASS — accepted opaquely (pre-existing top-level preservation; never validated — see Finding 2)
preserved unknown nested key, changed PASS — rejected, path interactiveCards.questionCard.legacyFlag, file untouched PASS (deepEqual fails → rejected)
preserved deep unknown key (inside statusCard), unchanged PASS — accepted PASS — accepted opaquely
preserved-but-invalid blob (seeded timeoutMs: -5) PASS — rejected (has an invalid value.): validation beats preservation PASS — accepted opaquely (base never inspected stored blobs)
top-level preserved unknown field, unchanged / changed (pre-existing semantics through the refactor) PASS / PASS PASS / PASS (behavior identical on both arms — refactor is preservation-clean)
parser consistency: every daemon-accepted shape through parseDingtalkInteractiveCardConfig (incl. preserved-unknown-key shapes) PASS 7/7 + default cells ({}→enabled true/270000 per docs; absent→enabled false) n/a (nothing accepted)

Counts: head 106/106, base 99/99. The flip is load-bearing at the new head exactly as in round 1, and the new cells show how the semantics matured: base treats a stored interactiveCards as an opaque blob; head validates the known structure and preserves only unknown parts verbatim.

Registry isolation (new delta, commit 4)

ensureBuiltins now validates each loaded plugin's management descriptor (assertManagementDescriptor) and on failure logs [channel-registry] Invalid management metadata in "<name>" channel: <reason> and skips registration; registerPlugin (extension path) validates and throws. Rules: duplicate keys per level, reserved keys (__proto__/constructor/prototype), nested secrets, nested envResolvable, required objects, envResolvable objects — each pinned by the PR's registry tests (gate section).

Witness: evidence/03-isolation-variants.png, logs logs/isolation-{a,b,c}.log, harness isolation-harness.mjs. The harness runs against a physical copy of the head CLI dist whose @qwen-code/channel-dingtalk symlink points at scratch stub packages (realpath probe PASS in every variant), so the registry's real builtin loop executes against the stub while the seven other channel packages load for real.

variant descriptor fault result
a settings object with required: true dingtalk absent from catalog; other 7 types present; exact stderr diagnostic; getPlugin('dingtalk') undefined; store PUT for type dingtalk fails closed with channel_settings_unmanageable ("does not provide safe management metadata") and no file write; bystander github upsert (enum fields + secret replace) persists fine. 13/13
b nested property key __proto__ dingtalk isolated with Channel field "cards.__proto__" cannot use a reserved key.; others present. 7/7
c valid object descriptor (positive control) dingtalk registers, no isolation diagnostic, stub descriptor usable end to end through the store ({settings:{on:true}} persisted) — proves variants a/b exclusion is isolation, not an import failure. 9/9

Blast radius of isolation (traced by reading, exercised where marked): instances of an isolated type still list via GET /workspace/channels with a minimal {type} projection (instanceFrom fallback); startup channel creation fails as "Unknown channel type" (pre-existing path for unknown types); extension channels with invalid descriptors are skipped with a log line (loadChannelsFromExtensions wraps registerPlugin in try/catch — code-read only).

Nested preservation semantics (new delta, commit 3)

assertPreservedUnknownField replaces the old inline checks at both levels: an unknown key (top-level or nested) passes iff it exists in the previous config and is deep-equal; otherwise not manageable with the full path. Proven live over real HTTP (E2E cells D9–D11) and at store level (A/B cells above). Consequences verified:

  • Web Shell's round-trip duty now holds for nested keys it cannot render (D10: a disk-seeded legacyFlag survives an API round-trip unchanged).
  • New or modified unknown nested keys are rejected (D9/D11) — the client cannot smuggle config in under the preservation rule.
  • Preservation never exempts known fields from validation: a stored timeoutMs: -5 blob is rejected on head even when byte-identical (A/B cell preserved-invalid-blob), aligning the management path with the runtime parser, which always rejected it.

Real daemon HTTP E2E (head)

Witness: evidence/02-daemon-http-e2e-on-head.png, log logs/daemon-e2e.log, harness daemon-e2e.mjs (spawns the built bundle as a real child process; isolated QWEN_HOME; ephemeral loopback port parsed from the listening on line; bearer auth). 34/34:

cell result
D1 daemon boots on ephemeral loopback port PASS
D2 no-token request → 401 PASS
D3 GET /workspace/channel-types advertises nested object descriptor (kind/properties/exclusiveMinimum) PASS
D4 seeded snapshot: config projects without the secret; secrets.clientSecret = {present:true}; revision present PASS
D5 PUT /workspace/channels/bot valid cards → 200; response instance.config.interactiveCards exact PASS
D6 follow-up GET round-trip (Web Shell's seed path) PASS
D7 workspace settings file contains the exact object PASS
D8 Web-Shell-shaped save (seed from GET, edit clientId) → object preserved PASS
D9 new unknown nested key → 400 channel_settings_invalid_config, message Channel field "interactiveCards.sneakyNew" is not manageable., file untouched PASS
D10 disk-seeded legacy nested key, PUT unchanged → 200, key survives PASS
D11 changed legacy key → 400 with path interactiveCards.questionCard.legacyFlag, file untouched PASS
D12 PUT omitting the object → 200, object removed (replacement semantics, re-measured) PASS
D13 timeoutMs: 0 → 400, message Channel field "interactiveCards.questionCard.timeoutMs" has an invalid value., file untouched PASS
D14 stale expectedRevision → 409 channel_settings_conflict (concurrency gate intact) PASS

Corrections

  • To the round-1 report's scope: round 1 stated "11 种畸形嵌套值全部被拒(含未知嵌套键)". That was true at commit 1; commits 3–4 refined the rule. At the verified head, an unknown nested key that is byte-identical to the stored config is accepted (nested preservation duty); only new or changed unknown nested keys are rejected. Everything else round 1 reported holds.
  • To the PR body (see Finding 1): the description still describes commit-1 semantics.

Findings (informational; none blocking)

  1. The PR body is stale relative to commits 3–4 (severity: info, description not code). "Risk & Scope" says "validation rejects unknown nested keys by design" and Reviewer Test Plan step 5 says an unknown nested key yields channel_settings_invalid_config — both hold only for keys not already stored. A reviewer following step 5 will still see 400 (the key they send is new), but the complementary behavior (unchanged stored unknown keys round-trip with 200, E2E D10) is undocumented in the body. The docs/developers/qwen-serve-protocol.md addition does state the client-preserve duty; only the PR text lags. No code change needed.
  2. Strictness tightening vs base for already-stored blobs (severity: info). Base round-tripped a stored interactiveCards blob opaquely and unvalidated when byte-identical (even timeoutMs: -5 — A/B cell preserved-invalid-blob(base-opaque)); head validates it and rejects. Since base never allowed creating the field through the management API, such blobs can only exist via manual edits of settings.json; a future PUT touching such a channel now 400s until the invalid nested value is fixed. This aligns the management path with the runtime parser (which always threw on timeoutMs: -5), so it is a consistency improvement; noted for the migration column.
  3. Mutation survivor M8b: the draft-side object skip is unpinned (severity: info, completeness). Deleting if (field.kind === 'object') continue; in createChannelEditorDraft survives 23/23. Classification: ordinary coverage gap, not dead code — the user-visible property (saved objects survive an edit-and-save round-trip) is still enforced by the request-build-side skip, which M8 kills via preserves hidden public settings and stored secrets when editing. The draft-side skip keeps object values out of the typed string/boolean draft; a fixture asserting createChannelEditorDraft(...).values carries no interactiveCards key would pin it. Not a merge condition.
  4. Nested required value properties: pinned but unused in production (severity: info). Round-1 finding 1's loop is now test-pinned (M3 KILLED by the new test), but no shipped descriptor declares a required nested property, and only test-only descriptors exercise it. Harmless forward hook; recorded for the next descriptor author.

Mutation matrix (vacuity + pinning)

Witness: evidence/04-mutation-matrix.png; rerunnable via mutation-matrix.sh + apply-mutation.mjs + mutants/*.{old,new} (exact string replacement, one occurrence asserted, sha-verified restore). Positive control first: unmutated head green on all three target suites. Restore verified: SHA-CHECK: OK, git status clean.

mutant change target suite result
M1 disable the object branch in assertDescriptorValue store (46) KILLED (8 fail) — first: persists DingTalk interactive card configuration through management metadata
M2 drop the exclusiveMinimum clause store KILLEDrejects ... 'non-positive question timeout'
M3 delete the nested assertRequiredFields call (round-1's survivor) store KILLEDrejects an omitted required nested descriptor property without writing (the PR's new test; round-1 finding 1 resolved)
M4 make assertPreservedUnknownField always accept store KILLED (3 fail) — exactly the three preservation tests: pre-existing top-level rejects 'unknown config field', only preserves unknown nested legacy fields when they are unchanged, rejects ... 'unknown nested field'
M5 remove builtin validation/isolation (plain registry.set) registry + builtins (10) KILLED — builtins test fails expected [...] to not include 'dingtalk' (a real assertion failure, not an import break)
M6 disable the reserved-key rule registry + builtins KILLED (3 fail) — invalid-reserved-field-key, invalid-reserved-property-key, plus the catalog-shape test collaterally (invalid test plugins stay registered and pollute the catalog list)
M7 disable the required-object rule registry + builtins KILLED (3 fail) — builtins isolation test, invalid-required-object, same collateral catalog test
M8 drop object skip in buildChannelUpsertRequest web-shell (23) KILLEDpreserves hidden public settings and stored secrets when editing
M8b drop object skip in createChannelEditorDraft web-shell SURVIVED (23/23) — predicted: the request-build skip still protects the observable behavior; classified as coverage gap (Finding 3)
M9 drop return null for object fields in renderField web-shell KILLEDdoes not render object metadata as a text field

Every killed mutant died on the assertion its behavior predicts (attribution checked per mutant log, logs/mutant-M*.log); M6/M7 additionally kill the pre-existing catalog-shape test collaterally, which is noted rather than counted as independent pinning.

Targeted gates

Witness: evidence/05-targeted-gates.png. Gate liveness is proven by the matrix itself — the same runners went red on nine mutants.

gate result
CLI channel-settings-store.test.ts + channel-registry.test.ts + channel-registry-builtins.test.ts + workspace-channel-management.test.ts 67/67
channels/dingtalk full suite 250/250 (was 249 at round 1; +1 is the new descriptor↔parser compatibility test)
channels/base full suite 971/971
web-shell channel editor suites (2 files) 23/23
SDK type mirror (DaemonChannelConfigFieldDescriptor block vs channel-base) identical modulo name prefix (scripted field-by-field comparison)

All suites ran on Linux (node:22-bookworm CI container), closing the author's "Linux not tested" column. Repo-wide gates were not run (targeted workspaces only). Pre-existing failures: none observed on either arm.

Not covered

  • Live DingTalk worker: connection, message delivery, card rendering, and a worker reload carrying the new config were not exercised (no credentials/network in the sandbox; PR declares this out of scope). The reload path is covered only at the parser level (parseDingtalkInteractiveCardConfig accepts every daemon-accepted shape, including preserved-unknown-key shapes, and defaults match docs/users/features/channels/dingtalk.md). This reproduces the config handling, not a live card round-trip.
  • Per-commit attribution for commits 2–4: the checkout is depth-2 (git rev-list HEAD^1..HEAD^2 yields only 70d5e01); the metadata snapshot lists 4 commits. The aggregate HEAD^1..HEAD diff was verified; which of commits 3/4 introduced which hunk was not separately established.
  • Base arm at the HTTP layer: the store-level A/B proves base behavior, and the route layer (ERROR_STATUS mapping, sendManagementError) is pre-existing and unchanged — read and exercised at head via the route suite + E2E; no base daemon was booted.
  • SDK test suite (author-reported 31 files / 1444 tests) not re-run — the SDK delta is type-only and verified identical field-by-field against channel-base.
  • Extension-channel isolation at runtime: the registerPlugin throw + the catch in loadChannelsFromExtensions were verified by reading and by the registry rule tests, not by loading a real extension.
  • Windows behavior (author marked untested); this round covers Linux only.
  • No regex/scanner over unbounded untrusted text is introduced by this diff, so no adversarial-length ladder was run.

Methodology

Environment: CI verify container (node:22-bookworm), merge-ref checkout at depth 2 (HEAD merge commit e049bd71, HEAD^1 base tip 67d12871, HEAD^2 PR head 70d5e017), npm ci + npm run build + bundle pre-run (bundle freshness confirmed by grepping the new markers in dist/chunks). Follow-up round: round-1's report was treated as untrusted input and every measurement re-executed at the new head. The store A/B harness (ab-harness.mjs) drives the real compiled WorkspaceChannelSettingsStore + registry (no stubs) against throwaway workspaces with isolated QWEN_HOME, one fresh settings file per cell, asserting snapshot content, on-disk file bytes, error code+message, and prototype-pollution state; the control tree (base-rebuilt channel packages + two-file base transpile of the CLI dist) was realpath-probed per arm, and the base/head channel-base runtime delta was proven type-only by diff -r on dist. The isolation harness (isolation-harness.mjs) swaps the dingtalk package under a copied head dist for scratch stubs carrying faulty/valid descriptors and asserts catalog membership, stderr diagnostics, fail-closed store behavior, and an unaffected github bystander. The daemon E2E (daemon-e2e.mjs) spawns the built bundle and speaks loopback HTTP with bearer auth. Mutations were applied as exact-count string replacements on source, run through each package's own vitest, and restored sha-verified (all OK; git status clean at exit). Raw logs in logs/, harnesses and mutant definitions in the artifact root, evidence PNGs in evidence/.

Evidence images

01-ab-store-head-vs-base

02-daemon-http-e2e-on-head

03-isolation-variants

04-mutation-matrix

05-targeted-gates

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

@wenshao

wenshao commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Review: fix(channels): manage DingTalk interactive card config

Overview

The PR does four things: (1) adds an object field kind with nested properties to the channel management descriptor contract (channel-base + SDK mirror), (2) advertises DingTalk interactiveCards through it, (3) generalizes assertDescriptorValue in the settings store to validate nested objects recursively — including extending the existing "preserve-if-unchanged" rule for unmanageable keys down into nested records — and (4) hardens channel-registry with descriptor self-validation (reserved keys, duplicates, no nested secrets/env-refs, no required objects).

The core design is sound. The recursive validator reuses the existing top-level semantics faithfully, assertRequiredFields / assertPreservedUnknownField are good factorings of previously inline logic, the table-driven tests are thorough, and every rejection test asserts the settings file is byte-identical afterwards. Docs are updated on both the protocol and user side.

Comments below, roughly in severity order.


1. A bad management descriptor now takes the whole channel offline, not just its management surface

ensureBuiltins skips registry.set(...) when assertManagementDescriptor throws. But registry is not management-only — getPlugin() is what packages/cli/src/commands/channel/runtime.ts uses to actually construct and start channels. So a purely cosmetic descriptor defect (a duplicate key, a stray required: true on an object) would make DingTalk fail to start at all for every user, with only a process.stderr.write line explaining why. channel-registry-builtins.test.ts pins this behavior in place.

Failing closed on the management surface is right; failing closed on the runtime is a much larger blast radius than the defect warrants. Suggest registering the plugin with management stripped instead:

try {
  assertManagementDescriptor(result.value.plugin);
  registry.set(type, plugin);
} catch (error) {
  process.stderr.write(`[channel-registry] Invalid management metadata in "${name}" channel: ${msg}\n`);
  registry.set(type, { ...plugin, management: undefined });  // runtime keeps working, manageable: false
}

supportedChannelCatalog already derives manageable: management !== undefined, so the catalog stays correct and PUT still rejects with channel_settings_unmanageable — but existing configured channels keep running. The test would then assert manageable === false rather than absence from the catalog.

2. registerPlugin now throws for third-party plugins — the PR says "Breaking changes: None"

registerPlugin is the external-plugin entry point (runtime.ts:142). Adding assertManagementDescriptor(plugin) there means any out-of-tree plugin whose descriptor uses a reserved key, declares a duplicate field, or marks an object envResolvable now hard-fails plugin load where it previously worked. That may well be the intent, but it contradicts the PR body and deserves a line in the description (and arguably the same isolate-and-warn treatment as the built-ins, for consistency).

3. Wire compatibility: object is a new kind on an existing capability with no feature tag

channel_management is { since: 'v1' } and is not bumped here. Consider what an already-shipped client does with the new descriptor: renderField in ChannelEditorDialog.tsx has no object case before this PR, so it falls through to the generic text input; initialFieldValue returns '' for a non-string value; and on save assignField hits if (!value) { delete config[field.key]; return; }. The daemon only validates keys present in the request, so the omission is accepted and interactiveCards is silently deleted from settings.json — interactive cards turn themselves off the next time someone edits the channel in an older Web Shell or via an older SDK-built client.

The new normative sentence in qwen-serve-protocol.md ("Clients that do not render an advertised field kind must preserve its existing config value") is the right rule, but it is unenforceable against clients that already exist. Two options worth considering:

  • Advertise a tag (e.g. channel_management_object_fields) so a client can detect that the daemon may send kinds it cannot round-trip, and the daemon can omit object descriptors for clients that do not negotiate it; or
  • Make the store treat an omitted key that exists in previous and is not representable by the requesting client as preserve-rather-than-delete. (Harder — the store has no notion of the client — so the capability tag is probably the cleaner lever.)

At minimum this should be called out as a compatibility note in the PR body, since the Web Shell ships with the daemon but the SDK does not.

4. SDK type change is source-breaking for TypeScript consumers

DaemonChannelConfigFieldDescriptor is exported from packages/sdk-typescript/src/index.ts. It changes from an interface to a union type alias, which breaks:

  • interface Mine extends DaemonChannelConfigFieldDescriptor {} and declaration merging;
  • object literals built with a widened kind: DaemonChannelConfigFieldKind (no longer assignable to either arm);
  • narrowing/annotating the object arm at all, since DaemonChannelConfigValueFieldDescriptor, DaemonChannelConfigObjectFieldDescriptor, and DaemonChannelConfigNestedFieldDescriptor are not exported.

Please export all three (and the channel-base equivalents) — a consumer writing function render(f: DaemonChannelConfigObjectFieldDescriptor) currently has no way to name the type. The interface → union change should also be noted as a type-level breaking change.

5. exclusiveMinimum is on the wrong half of the union

It sits on ChannelConfigFieldDescriptorBase, so { kind: 'string', exclusiveMinimum: 5 } and { kind: 'object', exclusiveMinimum: 0 } typecheck and are silently ignored by assertDescriptorValue. Since the type is already split into variants, this is cheap to make impossible — move it onto a number-specific arm (or intersect it in) so the descriptor can't express nonsense. Same for options/default, though those were already shared before this PR.

6. Web Shell doesn't enforce exclusiveMinimum client-side

validateChannelEditorDraft only checks Number.isFinite(Number(value)) for kind: 'number'. A future top-level number field with exclusiveMinimum gets a server-side 400 channel_settings_invalid_config instead of inline validation. Not exercised today (the only exclusiveMinimum is nested and unrendered), but it's a two-line addition while the code is fresh.

7. Web Shell silently hides configuration it can't edit

renderField returning null and the two continues in channel-editor-state.ts are correct for the preservation goal (buildChannelUpsertRequest spreads ...instance.config, so the value round-trips intact — good, and the editor-state test covers it). But the user-visible result is that a DingTalk channel with interactiveCards configured shows an editor that gives no indication the channel has settings the dialog isn't showing. A read-only "configured in settings.json" row would prevent someone concluding the feature isn't configured. Fine to defer, but worth an issue.

Related: docs/users/features/channels/dingtalk.md is a good addition, but it doesn't say that interactive cards are settings.json/API-only and not editable in the Web Shell.

8. Smaller things

  • Dead default parameters. assertManagementField(field, path = field.key, nested = false) is only ever called from assertManagementFields with both arguments explicitly. Same for the nested = false default on assertManagementFields at the one top-level call site — that one is used, the field-level ones aren't.
  • Reserved-key set is now defined three times. RESERVED_FIELD_KEYS (channel-registry.ts), UNSAFE_OBJECT_KEYS (channel-settings-store.ts:74), and an inline ['__proto__', 'constructor', 'prototype'] in channel-editor-state.ts:148. Worth hoisting one shared constant.
  • Two hand-mirrored descriptor definitions with no conformance test. packages/channels/base/src/types.ts and packages/sdk-typescript/src/daemon/types.ts now duplicate ~40 lines of union structure. Drift here means the SDK silently misdescribes the wire format. The duplication predates this PR, but the PR roughly triples the surface — a type-level assertion test in the SDK (assign one to the other) would be cheap insurance.
  • Replace-not-merge semantics for object values. nextConfig = { ...options.config } replaces the object wholesale, so a client that PUTs interactiveCards: { enabled: false } loses a previously stored questionCard.timeoutMs. Consistent with top-level behavior and implied by the new "clients must preserve" rule, but it's worth stating explicitly in qwen-serve-protocol.md — "preserve" is easy to read as "the daemon merges".
  • Pre-existing malformed values become unsaveable. A stored interactiveCards that was previously unknown-and-preserved but doesn't satisfy the new descriptor (e.g. "interactiveCards": "on") will now fail every save of that channel with Channel field "interactiveCards" has an invalid value., even when the user is only editing clientId. Such a config already fails at worker start, so the practical impact is low — but the error surfaces in a confusing place.
  • Descriptor is stricter than the runtime parser. parseDingtalkInteractiveCardConfig tolerates unknown nested keys; the descriptor rejects them unless echoed byte-identically. That's a defensible choice (and the new "keeps management descriptor values compatible with the runtime parser" test guards the other direction nicely) — just note that adding a field to the adapter without updating the descriptor now produces a confusing ... is not manageable on save rather than being ignored.

9. Test coverage

Good: table-driven registry rejection cases, nested required/env-ref/unknown-key/invalid-value cases, no-write assertions, the descriptor↔parser compatibility test, and the Web Shell round-trip assertion.

Gaps worth closing:

  • No route-level test in workspace-channel-management.ts for the object descriptor — the GET-catalog/PUT round trip was only verified manually. A test there would also lock in the JSON shape of properties on the wire.
  • No test for exclusiveMinimum on a top-level number field (only reached via the nested DingTalk timeout).
  • No test that a channel with a configured object value can be saved through buildChannelUpsertRequest without an instance (create flow) — the interesting negative case.
  • No test that a plugin isolated by item 1 above is still (or is no longer) startable, which is exactly the behavior worth pinning.

Summary

Correctness of the validator itself looks right to me — I didn't find a soundness hole in the nested traversal, and the prototype-pollution surface is covered (unknown __proto__-style nested keys land in assertPreservedUnknownField and are rejected unless already stored). The things I'd want addressed before merge are (1) the runtime blast radius of descriptor isolation and (3)/(4) the undeclared compatibility breaks — the rest are follow-ups.

中文摘要

整体设计合理,嵌套校验逻辑忠实复用了顶层语义,测试也很扎实。合并前建议处理三点:

  1. 描述符非法会整体注销插件getPlugin() 同时服务于运行时(runtime.ts),描述符里一个纯元数据错误会让 DingTalk 完全无法启动。建议改成剥离 management 后仍然注册,manageable: false 即可。
  2. registerPlugin 现在会抛错:第三方插件描述符不合规将直接加载失败,与 PR 描述里的「无破坏性变更」不符,至少要在描述中说明。
  3. 协议兼容objectchannel_management 上的新 kind,但没有新增 capability tag。旧客户端会走 renderField 的文本框兜底,assignField 在空值时 delete config[key],导致保存时静默删除 interactiveCards。文档里的规范句对已发布的客户端没有约束力,建议加 feature tag。
  4. SDK 类型破坏DaemonChannelConfigFieldDescriptor 由 interface 变为联合类型别名,且两个成员类型与嵌套类型都未导出,消费者无法标注对象分支。

其余为改进项:exclusiveMinimum 应挂在 number 分支、Web Shell 缺少对应的前端校验与「该字段无法在此编辑」提示、保留字集合重复定义三处、SDK 与 channel-base 类型手工镜像缺少一致性测试,以及路由层缺少 object 描述符的端到端测试。

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Triage re-run completed without a new review.

⚠️ The bot has neither a verdict nor a deferral on 70d5e0175bbdc866f14a0b0063046b34eb48776d — no APPROVED, CHANGES_REQUESTED, or COMMENTED review of its own. A DISMISSED one does not count: dismiss_stale_reviews voids the bot's approval on every push, which is exactly when a fresh one is needed. If this re-run was meant to review or approve, it did not, and an approval left by another account is a separate vote that does not count as the bot's own.

⚠️ 机器人在 70d5e0175bbdc866f14a0b0063046b34eb48776d既没有裁决也没有 defer —— 没有属于它自己的 APPROVEDCHANGES_REQUESTEDCOMMENTED 评审。DISMISSED 不算:dismiss_stale_reviews 会在每次推送时作废机器人的批准,而那恰恰是需要一次新批准的时刻。如果这次重跑本应评审或批准,那么它没有做到;而其他账号留下的批准是另一张票,不能算作机器人自己的。

The stage comments above were updated with the latest result. View workflow run.

上方各阶段评论已更新为最新结果。查看工作流运行

@qwen-code-dev-bot

qwen-code-dev-bot commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

AutoFix round 12 finishedview run. See this round's report below.

中文说明

AutoFix 第 12 轮已完成 —— 查看运行。本轮报告见下方。

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed. Suggestions are inline. Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally. Test Plan (not a blocker): 43 tests passed — this review observed 971, 250, 1444, 2776 passed; 23 tests passed — this review observed 971, 250, 1444, 2776 passed.

中文说明

已审查。 建议见行内评论。 未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。 Test Plan(非阻断):43 tests passed — this review observed 971, 250, 1444, 2776 passed; 23 tests passed — this review observed 971, 250, 1444, 2776 passed

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

Comment on lines +226 to +229
if (!property) {
assertPreservedUnknownField(path, key, nestedValue, previousRecord);
continue;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The new nested preserve path accepts unknown nested keys named __proto__ / constructor / prototype when their value deep-equals the stored one (assertPreservedUnknownField only deep-compares against previous and applies no reserved-key check), but the persistence layer (applyUpdates in jsonc-editor.ts) unconditionally skips those keys at every depth — so an accepted upsert is silently stripped on write, and the identical re-submission one round-trip later is rejected with is not manageable. — Failure scenario: a settings.json hand-authored with an own constructor key inside e.g. channels.bot.interactiveCards.questionCard — a byte-identical upsert is accepted (HTTP 200), the write silently drops the key, and resubmitting the same payload (e.g. from a stale instance snapshot) then fails with Channel field "interactiveCards.questionCard.constructor" is not manageable. — accept → silent data loss → reject-on-resubmit for an unchanged value. Probe-verified for all three reserved keys against the real store + persistence layer; the top-level equivalent predates this diff, but this PR's new nested preservation path extends it inside object fields.

Reject reserved keys instead of preserving them, at the top of assertPreservedUnknownField (covers both call sites; UNSAFE_OBJECT_KEYS already exists in this file):

function assertPreservedUnknownField(
  path: string | undefined,
  key: string,
  value: unknown,
  previous: Record<string, unknown>,
): void {
  const fieldPath = path ? `${path}.${key}` : key;
  if (UNSAFE_OBJECT_KEYS.has(key)) {
    throw invalidConfig(`Channel field "${fieldPath}" is not manageable.`);
  }
  if (Object.hasOwn(previous, key) && isDeepStrictEqual(previous[key], value)) {
    return;
  }
  throw invalidConfig(`Channel field "${fieldPath}" is not manageable.`);
}
中文说明

[建议] 新增的嵌套保留路径会接受名为 __proto__ / constructor / prototype 的未知嵌套键——只要其值与已存储的值深度相等(assertPreservedUnknownField 只与 previous 做深度比较,没有保留键检查),但持久化层(jsonc-editor.ts 中的 applyUpdates)会在每一层无条件跳过这些键——因此一次被接受的 upsert 会在写入时被静默剥离,而一轮之后原样重新提交同样的负载则会被以 is not manageable 拒绝。 — 失败场景:手工编辑的 settings.json 中,channels.bot.interactiveCards.questionCard 内带有自有的 constructor 键——逐字节相同的 upsert 会被接受(HTTP 200),写入时却静默丢弃该键;再次提交相同负载(例如来自过期的实例快照)则会得到 Channel field "interactiveCards.questionCard.constructor" is not manageable. ——接受 → 静默数据丢失 → 对未变更的值在重新提交时拒绝。已通过探针在真实 store + 持久化层上对三个保留键全部复现;顶层等价行为在本 diff 之前已存在,但本 PR 新增的嵌套保留路径将其扩展进了对象字段内部。

建议改为拒绝保留键而不是保留它们:在 assertPreservedUnknownField 顶部增加检查(同时覆盖两个调用点;UNSAFE_OBJECT_KEYS 在本文件中已存在),见上方代码示例。

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@T17.txt

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ⚠️ not run — skipped - workflow run

Skipped because the PR has merge conflicts, so refs/pull/8517/merge is unavailable — resolve conflicts and re-run.

中文 — 判定:⚠️ 未运行 · 已跳过

跳过原因:the PR has merge conflicts, so refs/pull/8517/merge is unavailable — resolve conflicts and re-run。

Qwen Code · sandboxed verification

@wenshao

wenshao commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

Test Plan (not a blocker): 43 tests passed — this review observed 971, 251, 1455, 2865, 444, 113, 197, 57, 282, 17, 134, 71, 475 passed; 23 tests passed — this review observed 971, 251, 1455, 2865, 444, 113, 197, 57, 282, 17, 134, 71, 475 passed.

中文说明

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

Test Plan(非阻断):43 tests passed — this review observed 971, 251, 1455, 2865, 444, 113, 197, 57, 282, 17, 134, 71, 475 passed; 23 tests passed — this review observed 971, 251, 1455, 2865, 444, 113, 197, 57, 282, 17, 134, 71, 475 passed

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

const crossFieldError = plugin.management.validateConfig?.(nextConfig);
let crossFieldError: unknown;
try {
crossFieldError = plugin.management.validateConfig?.(nextConfig);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] The new validateConfig backstop leaks a dangling rejected Promise that terminates the daemon. A non-async validateConfig that returns a rejecting Promise passes the registry guard (constructor.name === 'Function', not 'AsyncFunction'), so the plugin keeps its management. On any managed upsert this line receives the rejected Promise, skips the catch below (no synchronous throw), and throws the clean 'Channel validateConfig must return a string error message.' 400 WITHOUT ever attaching a handler to the Promise. The fast path that serves a normal qwen serve (cli.ts routes to tryRunServeFastPath before gemini.tsx is imported; fast-path runs runQwenServe + blockForever without main()) installs NO unhandledRejection handler anywhere in the serve subsystem, so Node 22's default --unhandled-rejections=throw terminates the daemon process. Probe-verified twice at this commit: a real registerPlugin + WorkspaceChannelSettingsStore.upsert run reported Unhandled Rejection: Error: network down ❯ WorkspaceChannelSettingsStore.upsert channel-settings-store.ts:470, and a two-arm mirror showed the process exits 1 with ERR_UNHANDLED_REJECTION when no handler exists. Test 'promise-validate-config' pins only the resolved-Promise half of this backstop. — Failure scenario: an extension plugin registers validateConfig: () => checkRemote(config) where checkRemote rejects (e.g. network failure) → the gate admits it, management intact → the first managed upsert of that channel returns the clean 400 AND leaves the rejection unhandled → the daemon process dies → every channel and workspace served by it goes down.

Suggested change
crossFieldError = plugin.management.validateConfig?.(nextConfig);
crossFieldError = plugin.management.validateConfig?.(nextConfig);
if (crossFieldError instanceof Promise) {
void crossFieldError.catch(() => {});
}

The probe-verified one-line defuse removed the leak with all 65 store tests green; also add a rejected-Promise variant of promise-validate-config to pin the rejecting half.

中文说明

[严重] 新的 validateConfig 兜底检查会泄漏悬空的 rejected Promise,导致 daemon 进程终止。非 async 但返回 rejecting Promise 的 validateConfig 能通过注册守卫(constructor.name === 'Function' 而非 'AsyncFunction'),插件因此保留 management。任何受管 upsert 时,这一行会收到该 rejected Promise:由于没有同步抛出,下方 catch 被跳过,随后抛出干净的 'Channel validateConfig must return a string error message.' 400,但始终没有给该 Promise 挂任何 handler。普通 qwen serve 走的 fast path(cli.ts 在导入 gemini.tsx 之前就路由到 tryRunServeFastPath;fast-path 直接执行 runQwenServe + blockForever,不经过 main())在整个 serve 子系统中都没有安装 unhandledRejection 处理器,因此 Node 22 默认的 --unhandled-rejections=throw 会终止 daemon 进程。已在本提交上用探针两次验证:真实的 registerPlugin + WorkspaceChannelSettingsStore.upsert 运行报告 Unhandled Rejection: Error: network down ❯ WorkspaceChannelSettingsStore.upsert channel-settings-store.ts:470;双臂镜像实验显示无 handler 时进程以 exit 1(ERR_UNHANDLED_REJECTION)退出。测试 'promise-validate-config' 只固定了该兜底 resolved-Promise 的一半。— 失败场景:扩展插件注册 validateConfig: () => checkRemote(config),其中 checkRemote 会 reject(如网络故障)→ 守卫放行、management 保留 → 该频道首次受管 upsert 返回干净 400 的同时留下未处理的 rejection → daemon 进程终止 → 该 daemon 服务的所有频道与工作空间全部下线。建议修复见上方 suggestion 代码块:在抛出前给 thenable 挂空 catch 消除悬空 rejection(探针验证:该单行修复消除泄漏,全部 65 个 store 测试通过),并为 promise-validate-config 增加 rejected-Promise 变体以固定 rejecting 一半。

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

Comment on lines +81 to +83
if (!nested) {
allowedKeys.add('envResolvable');
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The walker admits envResolvable on top-level number fields, but both mirrored type contracts declare envResolvable?: never on number descriptors (channels/base types.ts:417-421; sdk-typescript daemon/types.ts:3133-3137), and the runtime gate assertManagementField never rejects it either (it rejects envResolvable only when nested or on kind 'object'). Probe-verified at this commit: an untyped plugin field { key: 'timeout', kind: 'number', envResolvable: true } registers unstripped, passes this walker, and the store then rejects every $VAR the advertised affordance invites ('has an invalid value') — a permanently inert UI surface and a wire shape the SDK contract declares impossible. The divergence was created by this PR's own final commit 24c358ec3 ('align descriptor type contracts with runtime validation'), which gave number its never without updating the gate or this walker. No builtin declares envResolvable on a number field — latent contract gap on the untrusted-extension surface. — Failure scenario: an extension registers a number field with envResolvable: true → the catalog advertises it → web-shell renders the env-reference affordance → every save with $VAR is rejected — the affordance can never succeed.

Suggested change
if (!nested) {
allowedKeys.add('envResolvable');
}
if (!nested && descriptor.kind !== 'number') {
allowedKeys.add('envResolvable');
}

Plus the matching gate rule in assertManagementField (reject envResolvable on number fields) — combinable with the exclusiveMinimum-on-non-number finding below as one kind-specific-property pass.

中文说明

[建议] 该 walker 允许顶层 number 字段携带 envResolvable,但两侧镜像类型契约都在 number 描述符上声明 envResolvable?: never(channels/base types.ts:417-421;sdk-typescript daemon/types.ts:3133-3137),且运行时关卡 assertManagementField 也从不拒绝它(只在 nested 或 kind 为 'object' 时拒绝 envResolvable)。已在本提交上探针验证:未类型化插件字段 { key: 'timeout', kind: 'number', envResolvable: true } 注册后不被剥离、通过该 walker,而 store 随后会拒绝该 affordance 引导用户输入的每一个 $VAR'has an invalid value')——一个永久无效的 UI 表面,以及 SDK 契约声明为不可能的线路形态。该分歧正是本 PR 最后一个提交 24c358ec3('align descriptor type contracts with runtime validation')自己造成的:它把 number 改成了 never,却没有同步更新关卡和这个 walker。当前没有内建频道在 number 字段上声明 envResolvable——属于不可信扩展表面的潜在契约缺口。— 失败场景:扩展注册携带 envResolvable: true 的 number 字段 → 目录对外声明 → web-shell 渲染环境变量引用输入 → 每次填入 $VAR 的保存都被拒绝——该 affordance 永远无法成功。建议修复见上方 suggestion 代码块,并在 assertManagementField 中加上对应关卡规则(拒绝 number 字段的 envResolvable)——可与下方「非 number 字段携带 exclusiveMinimum」的发现合并为一次 kind 专属属性校验。

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

Comment on lines +59 to +63
if (!FIELD_KINDS.has(field.kind)) {
throw new Error(
`Channel field "${path}" declares an unknown kind "${field.kind}".`,
);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The new gate validates kind, key, reserved keys, options, exclusiveMinimum, required, envResolvable, and properties — but never type-checks label (required string on both wire contracts) or description (optional string), while this PR's own mirror walker asserts typeof descriptor.label === 'string' for builtins only (channel-descriptor-sdk-mirror.test.ts:60). An untyped extension plugin registering { key: 'token', kind: 'secret', label: { en: 'Token' } } (a plausible i18n attempt) keeps management and is advertised manageable; the catalog serves the non-string label over the wire. In web-shell, extension types have no FIELD_LABEL_KEYS entry, so fieldLabel/fieldDescription return the raw value and FieldShell renders it as a React child — 'Objects are not valid as a React child'. Blast radius verified larger than the dialog: there is NO ErrorBoundary anywhere in components/channels and App.tsx wraps the panels in none; the nearest boundary is the root web-shell-root ErrorBoundary, so the ENTIRE web-shell UI drops into the error fallback and every retry reproduces it. All 8 builtins use string labels — latent, requiring an untyped extension plugin. Distinct from R7-11, which covers enum option-entry shape. — Failure scenario: an extension declares label: { en: 'Token' } (or description: [...]) on a management field → the gate admits it, the catalog serves it → opening the channel editor renders the object as a React child → the crash propagates to the root ErrorBoundary → the whole web-shell drops into the error fallback.

Suggested fix in assertManagementField:

if (typeof field.label !== 'string' || field.label.length === 0) {
  throw new Error(`Channel field "${path}" must declare a string label.`);
}
if (field.description !== undefined && typeof field.description !== 'string') {
  throw new Error(`Channel field "${path}" must declare a string description.`);
}
中文说明

[建议] 新关卡校验了 kind、key、保留键、options、exclusiveMinimumrequiredenvResolvableproperties——但从不检查 label(两侧线路契约均为必填 string)或 description(可选 string)的类型,而本 PR 自己的镜像 walker 只对内建频道断言 typeof descriptor.label === 'string'(channel-descriptor-sdk-mirror.test.ts:60)。未类型化的扩展插件注册 { key: 'token', kind: 'secret', label: { en: 'Token' } }(一种合理的 i18n 写法)后会保留 management 并被标为可管理;目录会把该非字符串 label 原样发往线上。在 web-shell 中,扩展类型没有 FIELD_LABEL_KEYS 条目,fieldLabel/fieldDescription 会返回原始值,FieldShell 把它作为 React 子节点渲染——抛出 'Objects are not valid as a React child'。影响范围经核实比对话框更大:components/channels 中没有任何 ErrorBoundary,App.tsx 也未包裹面板;最近的边界是根级 web-shell-root ErrorBoundary,因此整个 web-shell UI 都会落入错误回退页,且每次重试都会复现。8 个内建频道的 label 都是字符串——属于潜在问题,需要未类型化扩展插件触发。与 R7-11(enum 选项条目形态)不同。— 失败场景:扩展在管理字段上声明 label: { en: 'Token' }(或 description: [...])→ 关卡放行、目录对外提供 → 打开频道编辑器时对象被当作 React 子节点渲染 → 崩溃一路传到根 ErrorBoundary → 整个 web-shell 落入错误回退页。建议修复见上方代码块:在 assertManagementField 中补充 label/description 的字符串校验。

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

Comment on lines +83 to +85
field.kind === 'number' &&
field.exclusiveMinimum !== undefined &&
!Number.isFinite(field.exclusiveMinimum)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] exclusiveMinimum is inspected only when kind === 'number' (this finiteness check); on every other kind it is silently admitted, although both wire contracts declare exclusiveMinimum only on the number descriptor (channels/base types.ts:417-424; sdk types.ts:3133-3140) and the mirror walker asserts it undefined on non-number fields for builtins (channel-descriptor-sdk-mirror.test.ts:87-92). An untyped extension registering { key: 'retentionDays', kind: 'string', exclusiveMinimum: 0 } passes registration, and the catalog serves a shape the SDK contract declares impossible; the store applies the bound only in its number arm (channel-settings-store.ts:271-275), so the advertised minimum is never enforced — upserts store values the plugin author declared invalid, with no diagnostic at registration or save. Mirror image of the envResolvable-on-number finding above; no builtin declares exclusiveMinimum outside number — latent. — Failure scenario: an untyped extension declares exclusiveMinimum on a string/boolean/enum/object field → registration passes, the catalog advertises the bound → the store's non-number arms ignore it → configs with values the author declared invalid are persisted silently, and SDK consumers see a property the contract says cannot exist there.

Suggested fix — add the symmetric rejection, combinable with the fix above as one kind-specific-property pass:

if (field.kind !== 'number' && field.exclusiveMinimum !== undefined) {
  throw new Error(
    `Channel field "${path}" can only declare exclusiveMinimum on number fields.`,
  );
}
中文说明

[建议] exclusiveMinimum 只在 kind === 'number' 时被检查(即此有限性检查);在其他任何 kind 上都会被静默接受——尽管两侧线路契约都只在 number 描述符上声明 exclusiveMinimum(channels/base types.ts:417-424;sdk types.ts:3133-3140),且镜像 walker 对内建频道断言非 number 字段上它必须为 undefined(channel-descriptor-sdk-mirror.test.ts:87-92)。未类型化扩展注册 { key: 'retentionDays', kind: 'string', exclusiveMinimum: 0 } 会通过注册,目录会对外提供 SDK 契约声明为不可能的形态;而 store 只在 number 分支应用该边界(channel-settings-store.ts:271-275),因此声明的下限永远不会被执行——upsert 会持久化插件作者声明为非法的值,注册和保存时都没有任何诊断。与上方「number 携带 envResolvable」的发现互为镜像;当前没有内建频道在 number 之外声明 exclusiveMinimum——属于潜在问题。— 失败场景:未类型化扩展在 string/boolean/enum/object 字段上声明 exclusiveMinimum → 注册通过、目录对外声明该边界 → store 的非 number 分支忽略它 → 作者声明为非法的值被静默持久化,SDK 消费方还会看到契约中不应存在的属性。建议修复见上方代码块:补上对称的拒绝规则,可与上面的修复合并为一次 kind 专属属性校验。

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

Comment on lines +91 to +92
if (field.kind === 'enum') {
if (!Array.isArray(field.options) || field.options.length === 0) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The gate validates key/kind/reserved keys/options/exclusiveMinimum/required/envResolvable/properties but never reads field.default — while both wire contracts declare default?: string (channels/base types.ts:396; sdk-typescript daemon/types.ts:3112) and the web-shell editor consumes it for enum prefill: initialFieldValue (channel-editor-state.ts:78) returns field.default ?? field.options?.[0]?.value ?? ''. Probe-verified at this commit: an untyped plugin registering { key: 'mode', kind: 'enum', options: [...], default: 42 } passes the gate and the walker (allowedKeys includes 'default' unchecked); for a non-string default, isMissingField treats the prefilled draft as present so a required enum passes client validation, then assignField coerces the non-string to '' and deletes the key from the upsert payload — the store then rejects the save with 'Channel field "mode" is required.'; an optional enum's advertised default is silently never applied; an out-of-options string default ('turbo') is rejected by the store with 'has an invalid value'. Both existing builtin defaults (gitlab/github groupPolicy: 'open') pass the proposed rule unchanged — latent, same gate-vs-wire-contract class as the findings above. — Failure scenario: an untyped extension declares default: 42 (or {}, or a string not among its options) on an enum field → the gate and walker admit it → the editor prefills a non-string draft and passes client validation for a required field → assignField deletes the key → the save fails late with 'is required', or the advertised default is never applied, or an out-of-options default is rejected — every path fails confusingly instead of fail-closed at registration.

Suggested fix in assertManagementField:

if (field.default !== undefined && typeof field.default !== 'string') {
  throw new Error(`Channel field "${path}" must declare a string default.`);
}
if (
  field.kind === 'enum' &&
  field.default !== undefined &&
  !field.options.some((option) => option.value === field.default)
) {
  throw new Error(
    `Channel field "${path}" declares a default that is not one of its options.`,
  );
}

Probe-verified: this fix flips both probe arms through the gate's documented fail-closed strip path.

中文说明

[建议] 关卡校验了 key/kind/保留键/options/exclusiveMinimum/required/envResolvable/properties,但从不读取 field.default——而两侧线路契约都声明 default?: string(channels/base types.ts:396;sdk-typescript daemon/types.ts:3112),且 web-shell 编辑器会用它做 enum 预填:initialFieldValue(channel-editor-state.ts:78)返回 field.default ?? field.options?.[0]?.value ?? ''。已在本提交上探针验证:未类型化插件注册 { key: 'mode', kind: 'enum', options: [...], default: 42 } 能通过关卡与 walker(allowedKeys 包含未检查的 'default');对非字符串 default,isMissingField 把预填草稿视为已填写,required enum 通过客户端校验,随后 assignField 把非字符串强制转为 '' 并从 upsert 载荷中删除该键——store 接着以 'Channel field "mode" is required.' 拒绝保存;optional enum 声明的 default 会被静默忽略、从不生效;不在选项内的字符串 default('turbo')则被 store 以 'has an invalid value' 拒绝。现有两个内建 default(gitlab/github 的 groupPolicy: 'open')都能原样通过建议的规则——属于潜在问题,与上面的发现同属「关卡与线路契约不一致」类别。— 失败场景:未类型化扩展在 enum 字段上声明 default: 42(或 {}、或不在选项中的字符串)→ 关卡和 walker 放行 → 编辑器预填非字符串草稿且 required 字段通过客户端校验 → assignField 删除该键 → 保存以 'is required' 迟到失败,或声明的 default 从不生效,或越界 default 被拒绝——所有路径都是令人困惑的迟到失败,而非注册时 fail-closed。建议修复见上方代码块:在 assertManagementField 中拒绝非字符串 default,并要求 enum 的 default 必须是其选项之一(探针验证:该修复使两个探针臂都走关卡文档声明的 fail-closed 剥离路径)。

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

Comment on lines +3126 to +3128
kind: 'enum';
required?: boolean;
envResolvable?: boolean;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] Both mirrored unions — as rewritten by this PR's own final commit 24c358ec3 ('align descriptor type contracts with runtime validation') — advertise envResolvable?: boolean on the enum variant (and on the boolean/string-list/record members of the value variant), while giving number/object envResolvable?: never. But the store's per-kind validity arms can never honor an env reference for those kinds: for enum, $VAR never equals an option value (field.options?.some(...) fails); for boolean, typeof value === 'boolean' fails for any env-reference string; string-list/record require array/record shapes. The env gate passes when the flag is truthy, then the kind arm rejects — a guaranteed 'has an invalid value' for every input the advertised affordance invites. A plugin author coding against the SDK types this PR exports gets an affordance that can never save. Completing the alignment this same commit started for number/object would mean never here too (or teach assertDescriptorValue to honor env references for these kinds, if that affordance is intended). No builtin declares envResolvable on these kinds (all 12 are string/secret) — latent. — Failure scenario: a plugin author follows the exported SDK types and declares { key: 'mode', kind: 'enum', envResolvable: true, options: [...] } → gate and walker admit it, the catalog advertises it → the user enters $MODE in the editor → the env gate passes but the enum arm rejects ($MODE is not an option value) → every save fails with 'has an invalid value' — the advertised affordance is unsatisfiable by construction.

Suggested change
kind: 'enum';
required?: boolean;
envResolvable?: boolean;
kind: 'enum';
required?: boolean;
envResolvable?: never;

Apply the same change to the boolean/string-list/record members here and in the channel-base mirror, plus a gate rule rejecting envResolvable on those kinds (all combinable with the two kind-specific-property findings above).

中文说明

[建议] 两侧镜像联合类型——经由本 PR 自己的最后一个提交 24c358ec3('align descriptor type contracts with runtime validation')重写——在 enum 变体(以及 value 变体的 boolean/string-list/record 成员)上声明 envResolvable?: boolean,却给 number/object 以 envResolvable?: never。但 store 的各 kind 校验分支永远无法为这些 kind 兑现环境变量引用:对 enum,$VAR 永远不等于任何选项值(field.options?.some(...) 失败);对 boolean,任何环境引用字符串都无法通过 typeof value === 'boolean';string-list/record 则要求数组/record 形态。env 关卡在 flag 为真时放行,随后 kind 分支拒绝——advertised affordance 引导的每一个输入都必然得到 'has an invalid value'。按照本 PR 导出的 SDK 类型编码的插件作者会得到一个永远无法保存的 affordance。把同一个提交为 number/object 开启的对齐做完,就意味着这里也应是 never(或者,如果确实想要这个 affordance,就让 assertDescriptorValue 学会为这些 kind 兑现 env 引用)。当前没有内建频道在这些 kind 上声明 envResolvable(12 个全是 string/secret)——属于潜在问题。— 失败场景:插件作者遵循导出的 SDK 类型声明 { key: 'mode', kind: 'enum', envResolvable: true, options: [...] } → 关卡和 walker 放行、目录对外声明 → 用户在编辑器里输入 $MODE → env 关卡放行但 enum 分支拒绝($MODE 不是选项值)→ 每次保存都以 'has an invalid value' 失败——该 affordance 在构造上就不可能成功。建议修复见上方 suggestion 代码块:对本文件及 channel-base 镜像中的 boolean/string-list/record 成员做同样修改,并在关卡中拒绝这些 kind 的 envResolvable(可与上面两条 kind 专属属性的发现合并处理)。

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

@qwen-code-ci-bot
qwen-code-ci-bot dismissed their stale review August 7, 2026 09:44

Stale: the template failure this review flagged was fixed in a later commit; the current PR body carries all required sections (verified at 24c358e).

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, looks ready to ship. ✅ Full staged review above; approving pinned to this exact commit. The post-conflict head (after the takeover loop merges main) gets a fresh pass.

@qwen-code-ci-bot
qwen-code-ci-bot dismissed their stale review August 7, 2026 09:44

Stale: the integration-suite gap is a structural skip for fork PR CI. Behaviour on this head is covered by the sandboxed verify passes (1,592 assertions), green Serve A/B + Real daemon E2E + web-shell E2E checks, and the maintainer real-environment validation posted in this thread.

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

Qwen Code resolved the merge conflicts and pushed the branch update.

Merge summary — PR #8517 ← origin/main

Root cause. feat(channels): support group pairing (#8440) (commit 3edecac11) landed on main after this PR's last sync. Both PRs append tests at the same anchor — the tail of the describe('ChannelEditorDialog') block in ChannelEditorDialog.test.tsx — so git could not interleave the two additive batches. That was the only conflicted file.

Textual, not semantic. Each side only added independent tests; neither touched the other's lines. The resolution is a strict union — verified to have zero deletion lines against both parents:

// PR #8517 tests (kept):
it('re-sends the stored object config when editing an existing instance', ...)
it('shows the out-of-range message for a number at the exclusive minimum', ...)
// main #8440 tests (kept):
it('shows pairing affordance from a descriptor-driven groupPolicy draft', ...)
it('shows pairing management when only group pairing is enabled', ...)

ChannelEditorDialog.tsx auto-merged cleanly: the PR's kind === 'object' skip in renderField + outOfRange message live in different hunks from #8440's groupPolicy visibility logic; both are present.

What is load-bearing.

  • GROUP_POLICY_DESCRIPTOR (main) spreads ...DINGTALK.fields, which now includes the PR's interactiveCards object field. That is harmless only because renderField returns null for kind === 'object' — removing that skip leaks an object field into every descriptor spreading DINGTALK.
  • feat(channels): support group pairing #8440's one-line store change survived auto-merge: groupPolicy: new Set(['disabled', 'allowlist', 'pairing', 'open']) in channel-settings-store.ts. Dropping 'pairing' breaks the new group-pairing tests.
  • feat(channels): support group pairing #8440 changed the revokePairingApproval prop to take a DaemonChannelPairingRevocationRequest; both sides' tests pass bare vi.fn() mocks, so no collision.

What I could not verify. No build/typecheck/tests were run (per instructions). The 16 other both-sides files auto-merged; I audited each residual diff against origin/main and it equals exactly the PR's own changes (post-merge stat identical to the pre-merge PR diff: 25 files, +2488/−78), including UNSAFE_OBJECT_KEYS now imported from channel-registry.ts. CI must confirm.

中文说明

冲突原因。 main 上的 feat(channels): support group pairing (#8440)3edecac11)与本 PR 都在 ChannelEditorDialog.test.tsx 同一 describe 块末尾追加测试,git 无法交错这两批纯新增内容。这是唯一冲突文件。

纯文本冲突,非语义冲突。 双方各自新增互不依赖的测试,未改动对方代码。解决方式为严格取并集,已验证相对两个父提交均无删除行:保留本 PR 两个测试(object 配置原样回传、exclusiveMinimum 越界提示)与 main 两个测试(groupPolicy 配对入口、仅群组配对时的管理面板)。组件文件自动合并干净:本 PR 的 object 跳过与 outOfRange 校验,同 #8440 的 groupPolicy 显示逻辑分属不同代码块,合并后俱在。

关键约束。 main 的 GROUP_POLICY_DESCRIPTOR 展开 DINGTALK.fields,现含本 PR 的 interactiveCards object 字段——仅因 renderField 对 object 返回 null 才无害;channel-settings-store.ts#8440groupPolicy 合法值 'pairing' 已保留;revokePairingApproval 新签名与双方 mock 均兼容。

未验证项。 按指令未运行构建/类型检查/测试。其余 16 个双方修改的文件均自动合并,逐一核对后残留差异恰为本 PR 自身改动(合并后统计与合并前一致:25 文件,+2488/−78)。正确性由 PR CI 保障。

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🔀 Base updated: red check(s) [Test (ubuntu-latest, Node 22.x)] pass on current main — merged current main via update-branch; CI will re-run.

中文说明

🔀 已更新 base:红色检查 [Test (ubuntu-latest, Node 22.x)] 在当前 main 上通过 —— 已通过 update-branch 合入当前 main,CI 将重新运行。

…tor gate gaps (QwenLM#8517)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@wenshao
wenshao enabled auto-merge August 7, 2026 11:21
wenshao
wenshao previously approved these changes Aug 7, 2026
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Addressed the latest review feedback (round 11/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 11/100 轮)。改动内容与我反驳保留之处如下:

Autofix review round — PR #8517

Feedback triage and decisions

[Critical] rc:3734831264 — dangling rejected Promise from validateConfig backstop terminates the daemon → FIXED. Verified in code: a non-async validateConfig returning a rejecting Promise passes the registry's synchronous-function guard, the backstop throws the clean 400 without ever attaching a handler, and the serve fast path installs no unhandledRejection handler, so Node 22's default --unhandled-rejections=throw kills the daemon. The fix attaches a no-op catch to a Promise result before the backstop throws. Added a regression test that registers a plugin returning Promise.reject(...) and pins both the clean 400 and the absence of any unhandledRejection (the pattern follows the existing acp-bridge dangling-rejection test). All 66 store tests green. This also covers non-blocking observation 2 in the maintainer validation comment.

[Suggestion] rc:3734831273 — walker/gate admit envResolvable on number fields → FIXED. The mirror walker now admits envResolvable only on top-level string/secret fields, and the registration gate rejects it on every other kind (combined with the next finding into one kind-specific-property pass).

[Suggestion] rc:3734831278 — gate never type-checks label/description → FIXED. assertManagementField now requires a non-empty string label and, when present, a string description, failing closed (management stripped, runtime channel survives) for untyped extension plugins. All 8 builtins use string labels and pass unchanged.

[Suggestion] rc:3734831280 — exclusiveMinimum silently admitted on non-number kinds → FIXED. The gate now rejects exclusiveMinimum on every kind except number, mirroring the existing finiteness check; both wire contracts declare it only on the number descriptor.

[Suggestion] rc:3734831283 — gate never reads field.default → FIXED. The gate now rejects non-string default values and, for enum fields, defaults that are not one of the declared options. Both existing builtin defaults (github/gitlab groupPolicy: 'open') pass unchanged, probe-style: invalid descriptors now fail closed at registration instead of failing confusingly at save.

[Suggestion] rc:3734831289 — union contracts advertise an unsatisfiable envResolvable affordance on enum/boolean/string-list/record → FIXED. Completed the alignment that commit 24c358ec3 started for number/object: in both mirrors (channels/base and sdk-typescript) the value descriptor was split so only string/secret keep envResolvable?: boolean (new ChannelConfigPlainValueFieldDescriptor / DaemonChannelConfigPlainValueFieldDescriptor for boolean/string-list/record), enum now declares envResolvable?: never, and the new member types are exported everywhere the sibling members are (channel-base index, SDK daemon index, SDK root index, webui re-exports). The type-level mirror-identity test confirms the two contracts remain exactly identical. No builtin declares envResolvable on these kinds (all 12 are string/secret), so no built-in surface changes.

CHANGES_REQUESTED review — "Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally" → ADDRESSED. Ran the suite locally against the fresh bundle (see Verification). The review's "Test Plan" count notes were informational (non-blocking).

Issue-level comment by @wenshao (maintainer validation) → no action required. Recommends merge with no blocking issues; non-blocking observations 1 (non-integer timeoutMs) and 3 (live worker reload out of scope) are left as recorded. Observation 2 is resolved by the Critical fix above.

Deferred non-Critical feedback (issuecomment-5215283061) → untouched per Critical-only mode rules; it remains open for human follow-up.

Failed CI checks → investigated. Every check except one was CANCELLED (superseded by re-runs). The single FAILURE, Test (ubuntu-latest, Node 22.x), was reproduced locally by running that job's full step list; see the environment note under Verification. No CI logs were accessible from this runner, so the diagnosis comes from local reproduction.

Changes

Commit 55cca44c56 (12 files, +231/−19):

  • packages/cli/src/serve/channel-settings-store.ts — attach a handler to a Promise returned by validateConfig before the backstop throws.
  • packages/cli/src/serve/channel-settings-store.test.ts — rejected-Promise regression test pinning the clean 400 and no leaked rejection.
  • packages/cli/src/commands/channel/channel-registry.ts — gate: string label/description/default checks; exclusiveMinimum only on number; envResolvable only on top-level string/secret; enum default must be one of its options.
  • packages/cli/src/commands/channel/channel-registry.test.ts — 9 new fail-closed gate cases.
  • packages/cli/src/commands/channel/channel-descriptor-sdk-mirror.test.ts — walker admits envResolvable only on top-level string/secret.
  • packages/channels/base/src/types.ts + packages/sdk-typescript/src/daemon/types.ts — split value descriptor; envResolvable?: never on enum/boolean/string-list/record in both mirrors.
  • Export sites: packages/channels/base/src/index.ts, packages/sdk-typescript/src/daemon/index.ts, packages/sdk-typescript/src/index.ts, packages/webui/src/daemon/index.ts, packages/webui/src/daemon-react-sdk.ts.

No conflict resolution was needed (--conflict false).

Verification

All commands actually run, in order:

  • npm run build — passed
  • npm run typecheck — passed (all packages incl. sdk and web-shell)
  • npx prettier --write on the 12 changed files — applied, stable
  • npx eslint on the 12 changed files — passed
  • npm run lint (full repo: eslint . && eslint integration-tests) — passed
  • vitest (packages/cli): channel-registry + builtins + sdk-mirror + channel-settings-store — 4 files, 110 passed
  • vitest (packages/cli): src/commands/channel + channel-management-service + server — 19 files, 1246 passed
  • vitest (packages/channels/base) — 19 files, 1004 passed
  • vitest (packages/web-shell) — 172 files, 2875 passed
  • vitest (packages/webui) — 33 files, 444 passed
  • npm run bundle — passed
  • npm run test:integration:cli:sandbox:none — 32 files passed, 183 tests passed, 18 skipped, 1 environmental failure (see note)
  • CI Test-job guards: npm run check:lockfile, npm run check:desktop-isolation, npm run check:voice-guard-sync, npm run check-i18n, npm run check:serve-fast-path-bundle — all passed
  • npm run generate:settings-schema + git status — schema up-to-date (no diff)
  • npm run generate:notices --workspace=qwen-code-vscode-ide-companion + git status — NOTICES up-to-date (no diff)
  • .github/scripts helper tests (node --test, 13 files per ci.yml) — 206 passed, 0 failed
  • npm run test:ci (full unit suite) — see environment note

Environment note (test:ci and the one integration failure). This self-hosted runner's autofix session exports QWEN_HOME, SANDBOX, QWEN_CODE_CUSTOM_SANDBOX_IMAGE, and a root-owned, non-writable HOME=/home/github-runner, and those leak into test subprocesses. A first npm run test:ci run therefore failed 22 test files across packages/cli, packages/core, and packages/vscode-ide-companion — every one with EACCES/home-path errors in code paths this PR does not touch (config-dir, ~/.env, storage, logger, IDE lockfiles). Re-running exactly those files in a CI-equivalent clean environment (HOME = fresh writable dir, QWEN_HOME/SANDBOX/custom-image vars unset, empty API keys, NO_COLOR=true): packages/core 12 files / 786 tests passed; packages/cli 8 files / 605 tests passed; vscode-ide-companion ide-server passed. The CLI suite's single integration failure (qwen-config-dir.test.ts 1d, EACCES mkdir '/home/github-runner/.qwen') was proven the same way: with a writable HOME the whole file passes 7/7. One residual failure, AuthDialog > drives API key provider steps from endpoint options metadata, is a pre-existing stale test on main (the Grok preset added 2026-07-14 in #6805 shifted the provider list, and the test's one-step-down navigation was never updated); it is skipped in CI by its own CI=true guard (isUnreliableTuiInputEnvironment), which is why CI has never seen it, and it is unrelated to this PR — fixed nowhere in this round to keep the diff in scope. With those environment artifacts removed, the full suite is green at this commit. The workflow's independent CI remains the final verification gate.

中文说明

Autofix 评审轮次 — PR #8517

反馈分类与决定

[严重] rc:3734831264 — validateConfig 兜底泄漏悬空 rejected Promise 导致 daemon 终止 → 已修复。 已在代码中核实:非 async 的 validateConfig 返回 reject 的 Promise 时能通过注册守卫的同步函数检查,兜底逻辑抛出干净的 400 时从未给该 Promise 挂 handler,而 serve fast path 没有安装任何 unhandledRejection 处理器,因此 Node 22 默认的 --unhandled-rejections=throw 会杀死 daemon。修复方式是在兜底抛出前给 Promise 结果挂一个空 catch。新增回归测试:注册一个返回 Promise.reject(...) 的插件,同时固定干净的 400 与无任何 unhandledRejection(模式沿用 acp-bridge 既有的悬空 rejection 测试写法)。全部 66 个 store 测试通过。此项同时覆盖维护者验证评论中的非阻断观察项 2。

[建议] rc:3734831273 — walker/关卡允许 number 字段携带 envResolvable → 已修复。 镜像 walker 现在只在顶层 string/secret 字段上接受 envResolvable,注册关卡在其余所有 kind 上拒绝它(与下一条合并为一次 kind 专属属性校验)。

[建议] rc:3734831278 — 关卡从不检查 label/description 类型 → 已修复。 assertManagementField 现在要求非空字符串 label,且 description 若存在必须为字符串;未类型化扩展插件将 fail-closed(剥离 management、运行时通道保留)。8 个内建频道的 label 均为字符串,原样通过。

[建议] rc:3734831280 — exclusiveMinimum 在非 number kind 上被静默接受 → 已修复。 关卡现在拒绝 number 以外所有 kind 上的 exclusiveMinimum,与既有的有限性检查互为镜像;两侧线路契约都只在 number 描述符上声明该属性。

[建议] rc:3734831283 — 关卡从不读取 field.default → 已修复。 关卡现在拒绝非字符串的 default,且对 enum 字段要求 default 必须是其选项之一。两个既有内建 default(github/gitlab 的 groupPolicy: 'open')原样通过;非法描述符现在在注册期 fail-closed,而不是在保存时以令人困惑的方式迟到失败。

[建议] rc:3734831289 — 联合类型契约在 enum/boolean/string-list/record 上声明了永远无法兑现的 envResolvable affordance → 已修复。 完成了提交 24c358ec3 为 number/object 开启的对齐:在两侧镜像(channels/basesdk-typescript)中拆分 value 描述符——只有 string/secret 保留 envResolvable?: boolean(boolean/string-list/record 由新增的 ChannelConfigPlainValueFieldDescriptor / DaemonChannelConfigPlainValueFieldDescriptor 承载),enum 改为 envResolvable?: never,新成员类型在所有兄弟成员导出的位置同步导出(channel-base index、SDK daemon index、SDK 根 index、webui 两处再导出)。类型级镜像一致性测试确认两侧契约保持完全一致。没有任何内建频道在这些 kind 上声明 envResolvable(12 个全部是 string/secret),因此内建表面无任何变化。

CHANGES_REQUESTED 评审 — “Integration Tests (CLI, No Sandbox) 在 CI 被跳过且本地未运行” → 已处理。 已在本地对新鲜 bundle 运行该套件(见 Verification)。评审中的 “Test Plan” 计数说明为信息性(非阻断)。

@wenshao 的 issue 级评论(维护者验证)→ 无需处理。 结论为建议合并且无阻断问题;非阻断观察项 1(非整数 timeoutMs)与 3(活跃 worker 重载超出本 PR 范围)按原记录保留。观察项 2 由上方的 Critical 修复解决。

延后的非 Critical 反馈(issuecomment-5215283061)→ 不处理,遵循 Critical-only 模式规则,保持开放留待人工跟进。

失败的 CI 检查 → 已调查。 除一个检查外全部为 CANCELLED(被重新运行取代)。唯一的 FAILURE Test (ubuntu-latest, Node 22.x) 已通过本地完整复现该 job 的步骤列表来排查;见 Verification 中的环境说明。本 runner 无法访问 CI 日志,因此诊断来自本地复现。

变更内容

提交 55cca44c56(12 个文件,+231/−19):

  • packages/cli/src/serve/channel-settings-store.ts — 在兜底抛出前给 validateConfig 返回的 Promise 挂 handler。
  • packages/cli/src/serve/channel-settings-store.test.ts — rejected-Promise 回归测试,固定干净 400 且无泄漏的 rejection。
  • packages/cli/src/commands/channel/channel-registry.ts — 关卡:label/description/default 字符串校验;exclusiveMinimum 仅限 number;envResolvable 仅限顶层 string/secret;enum 的 default 必须是其选项之一。
  • packages/cli/src/commands/channel/channel-registry.test.ts — 9 个新的 fail-closed 关卡用例。
  • packages/cli/src/commands/channel/channel-descriptor-sdk-mirror.test.ts — walker 只在顶层 string/secret 上接受 envResolvable
  • packages/channels/base/src/types.ts + packages/sdk-typescript/src/daemon/types.ts — 拆分 value 描述符;两侧镜像中 enum/boolean/string-list/record 改为 envResolvable?: never
  • 导出位置:packages/channels/base/src/index.tspackages/sdk-typescript/src/daemon/index.tspackages/sdk-typescript/src/index.tspackages/webui/src/daemon/index.tspackages/webui/src/daemon-react-sdk.ts

无需冲突解决(--conflict false)。

验证

实际执行的命令(按顺序):

  • npm run build — 通过
  • npm run typecheck — 通过(所有包,含 sdk 与 web-shell)
  • 对 12 个改动文件执行 npx prettier --write — 已应用且稳定
  • 对 12 个改动文件执行 npx eslint — 通过
  • npm run lint(全仓:eslint . && eslint integration-tests)— 通过
  • vitest(packages/cli):channel-registry + builtins + sdk-mirror + channel-settings-store — 4 个文件,110 通过
  • vitest(packages/cli):src/commands/channel + channel-management-service + server — 19 个文件,1246 通过
  • vitest(packages/channels/base)— 19 个文件,1004 通过
  • vitest(packages/web-shell)— 172 个文件,2875 通过
  • vitest(packages/webui)— 33 个文件,444 通过
  • npm run bundle — 通过
  • npm run test:integration:cli:sandbox:none — 32 个文件通过,183 个测试通过,18 跳过,1 个环境性失败(见说明)
  • CI Test job 守卫:npm run check:lockfilenpm run check:desktop-isolationnpm run check:voice-guard-syncnpm run check-i18nnpm run check:serve-fast-path-bundle — 全部通过
  • npm run generate:settings-schema + git status — schema 为最新(无 diff)
  • npm run generate:notices --workspace=qwen-code-vscode-ide-companion + git status — NOTICES 为最新(无 diff)
  • .github/scripts helper 测试(node --test,按 ci.yml 的 13 个文件)— 206 通过,0 失败
  • npm run test:ci(完整单测套件)— 见环境说明

环境说明(test:ci 与唯一一个集成测试失败)。 本自托管 runner 的 autofix 会话导出了 QWEN_HOMESANDBOXQWEN_CODE_CUSTOM_SANDBOX_IMAGE,且 HOME=/home/github-runner 为 root 所有、不可写,这些都会泄漏进测试子进程。因此第一次 npm run test:ci 在 packages/cli、packages/core、packages/vscode-ide-companion 中有 22 个测试文件失败——全部是本 PR 未触碰的代码路径上的 EACCES/home 路径错误(config-dir、~/.env、storage、logger、IDE lockfile)。用 CI 等价的干净环境(HOME 指向全新可写目录,取消 QWEN_HOME/SANDBOX/custom-image 变量,清空 API key,NO_COLOR=true)重跑这些文件:packages/core 12 个文件 / 786 个测试通过;packages/cli 8 个文件 / 605 个测试通过;vscode-ide-companion 的 ide-server 通过。CLI 集成套件的唯一失败(qwen-config-dir.test.ts 1d,EACCES mkdir '/home/github-runner/.qwen')也用同样方式证实:HOME 可写时整个文件 7/7 通过。剩余一个失败 AuthDialog > drives API key provider steps from endpoint options metadata 是 main 上的既有陈旧测试(2026-07-14 #6805 新增 Grok preset 使 provider 列表顺序变化,测试的「下移一步」导航从未更新);它被自身的 CI=true 守卫(isUnreliableTuiInputEnvironment)在 CI 中跳过,因此 CI 从未见过它,且与本 PR 无关——为保持 diff 范围,本轮不在任何地方修复它。剔除这些环境因素后,完整套件在本提交上为绿色。工作流的独立 CI 仍是最终验证关卡。

Deferred non-Critical feedback

Critical-only mode is active after 10 change-producing rounds. The workflow excluded the non-Critical feedback below from this round's actionable sections; the items remain open for human follow-up. Maintainer feedback is deferred only after its author has used 2 regular feedback batches in this window's Critical-only tail; authors at that budget, if any, are named below. (@qwen-code /retry starts a fresh counting window.)

中文说明

完成 10 个产生改动的轮次后进入仅处理 Critical 的模式。本轮可执行区域已排除下方非 Critical 反馈;这些条目保持开放,留待人工跟进。维护者反馈仅在其本人于本窗口 Critical-only 阶段已使用 2 批常规反馈预算后才会延后;达到预算的作者(如有)在下方点名。(评论 @qwen-code /retry 可开启新的计数窗口。)

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。

⚠️ The branch received new commits while this round ran; they were merged into this push, but this round's verification predates that merge — re-check anything that landed mid-run. · 本轮运行期间分支收到了新的提交;本次推送已将其合并,但本轮验证在合并之前完成——请复查运行期间落地的改动。

Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed. Suggestions are inline. Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally. Test Plan (not a blocker): 43 tests passed — this review observed 1012, 297, 18227, 1464, 2875, 444, 113, 205, 59, 282, 17, 134, 71, 475 passed; 23 tests passed — this review observed 1012, 297, 18227, 1464, 2875, 444, 113, 205, 59, 282, 17, 134, 71, 475 passed.

中文说明

已审查。 建议见行内评论。 未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。 Test Plan(非阻断):43 tests passed — this review observed 1012, 297, 18227, 1464, 2875, 444, 113, 205, 59, 282, 17, 134, 71, 475 passed; 23 tests passed — this review observed 1012, 297, 18227, 1464, 2875, 444, 113, 205, 59, 282, 17, 134, 71, 475 passed

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

Comment on lines +122 to +131
if (
field.options.some(
(option) =>
typeof option?.value !== 'string' || option.value.length === 0,
)
) {
throw new Error(
`Channel field "${path}" must declare non-empty string option values.`,
);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The new fail-closed gate validates each enum option's value but never its label — yet the same gate rejects the identical malformed shape one level up (a field-level label must be a non-empty string), and both wire contracts type options as { value: string; label: string } with label required, so a descriptor with a malformed option label keeps its management metadata and is served to clients verbatim. Probe-verified at this commit: a plugin with options: [{ value: 'fast', label: { en: 'Fast' } }] registers with management intact and the malformed option is served in the catalog, while the identical shape at field level is stripped. — Failure scenario: an untyped JS extension channel plugin (registered via registerPlugin, where TS types are not enforced) declares an enum or record field with an object-valued option label → passes the gate → GET /workspace/channel-types serves it → ChannelEditorDialog.tsx renders option.label directly as a React child (the enum branch's <SelectItem>{option.label}</SelectItem>; the record branch's displayLabel fallback; the FIELD_LABEL_KEYS i18n mitigation covers built-in types only) → opening that channel's editor throws React's "Objects are not valid as a React child" (no ErrorBoundary inside components/channels); a missing label renders blank options. Built-in plugins are TS-typed, so only third-party extension descriptors can trigger this.

Suggested change
if (
field.options.some(
(option) =>
typeof option?.value !== 'string' || option.value.length === 0,
)
) {
throw new Error(
`Channel field "${path}" must declare non-empty string option values.`,
);
}
if (
field.options.some(
(option) =>
typeof option?.value !== 'string' ||
option.value.length === 0 ||
typeof option.label !== 'string' ||
option.label.length === 0,
)
) {
throw new Error(
`Channel field "${path}" must declare non-empty string option values and labels.`,
);
}
中文说明

[建议] 新的 fail-closed 关卡校验了每个 enum 选项的 value,却从不校验其 label——同一关卡在上一层级会拒绝完全相同的畸形形态(字段级 label 必须是非空字符串),且两侧线路契约都将 options 定义为 { value: string; label: string }label 为必需),因此携带畸形选项 label 的描述符会保留 management 元数据并原样暴露给客户端。已在本提交上通过探针验证:携带 options: [{ value: 'fast', label: { en: 'Fast' } }] 的插件注册后 management 原样保留,畸形选项出现在目录中;而字段级的相同形态则会被剥离。— 失败场景:未类型化的 JS 扩展频道插件(经 registerPlugin 注册,不强制 TS 类型)声明带有对象值选项 label 的 enum 或 record 字段 → 通过关卡 → GET /workspace/channel-types 将其暴露 → ChannelEditorDialog.tsxoption.label 直接作为 React 子节点渲染(enum 分支的 <SelectItem>{option.label}</SelectItem>;record 分支的 displayLabel 兜底;FIELD_LABEL_KEYS 的 i18n 缓解仅覆盖内建类型)→ 打开该频道的编辑器时抛出 React 的 "Objects are not valid as a React child"(components/channels 内部没有 ErrorBoundary);缺失的 label 则渲染为空白选项。内建插件均有 TS 类型约束,因此只有第三方扩展描述符可能触发此问题。建议修复见上方 suggestion 代码块:在选项校验中同时拒绝非字符串/空 label,并同步更新错误消息。

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

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下:

Autofix review round: no action taken

PR: #8517 · Branch: fix/dingtalk-interactive-card-management · Base: main · Conflicts: none

Nothing in this round required a code change:

  • Reviews: none newer than the last evaluation from trusted maintainers or the automated reviewer.
  • Inline comments: none actionable in this round.
  • Issue-level comments: none.
  • Failed checks: none.
  • Still-red checks: none persisting from before the last evaluation.

Critical-only mode is active (after 10 change-producing rounds). The two deferred non-Critical items listed in this round's feedback — the automated reviewer's review and inline finding rc:3735915307 on packages/cli/src/commands/channel/channel-registry.ts:131 — are an audit record per the workflow rules, not actionable work: they were not implemented, their threads were not resolved, and no comment replies were written. They remain open for human follow-up.

No commits were made; the PR head is unchanged by this round.

中文说明

Autofix 评审轮次:未采取任何操作

PR: #8517 · 分支: fix/dingtalk-interactive-card-management · 目标分支: main · 冲突:

本轮没有任何需要修改代码的事项:

  • 评审(Reviews): 自上次评估以来,受信任的维护者或自动评审机器人没有新的评审。
  • 行内评论(Inline comments): 本轮没有可执行的行内评论。
  • Issue 级评论: 无。
  • 失败的检查(Failed checks): 无。
  • 持续失败的检查(Still-red checks): 自上次评估以来没有遗留的失败检查。

当前处于仅处理 Critical 的模式(在 10 个产生改动的轮次之后)。本轮反馈中列出的两个被延后的非 Critical 条目——自动评审机器人的评审,以及针对 packages/cli/src/commands/channel/channel-registry.ts:131 的行内发现 rc:3735915307——按照工作流规则仅作为审计记录存在,不属于本轮可执行的工作:未实施这些改动,未解析(resolve)对应的讨论线程,也未撰写任何评论回复。这些条目保持开放,留待人工跟进。

本轮未产生任何提交;PR 的 head 提交保持不变。

Deferred non-Critical feedback

Critical-only mode is active after 10 change-producing rounds. The workflow excluded the non-Critical feedback below from this round's actionable sections; the items remain open for human follow-up. Maintainer feedback is deferred only after its author has used 2 regular feedback batches in this window's Critical-only tail; authors at that budget, if any, are named below. (@qwen-code /retry starts a fresh counting window.)

中文说明

完成 10 个产生改动的轮次后进入仅处理 Critical 的模式。本轮可执行区域已排除下方非 Critical 反馈;这些条目保持开放,留待人工跟进。维护者反馈仅在其本人于本窗口 Critical-only 阶段已使用 2 批常规反馈预算后才会延后;达到预算的作者(如有)在下方点名。(评论 @qwen-code /retry 可开启新的计数窗口。)

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max

@wenshao

wenshao commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ✅ passed — merge-ready (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 2703 passed · 0 failed · 2703 total

中文 — 判定:✅ 通过 · 可合入(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:2703 通过 · 0 失败 · 2703 总计

Verification report

PR 8517 Deep Verification (round 3) — fix(channels): manage DingTalk interactive card config

Verdict: merge-ready — 2703/2703 scripted assertions passed (0 unexpected failures). Breakdown: store A/B harness 220 (134 head + 86 base) · registry strip-isolation harness 59 (variants a/b/c/d) · record-guard probe 5 · real daemon HTTP E2E 38 · mutation matrix 24 (8 mutants × occurrence-assert + result + sha-restore) · targeted gate tests 2357 (CLI store+registry+builtins+routes 122 + CLI server+SDK-mirror 887 + dingtalk 297 + channel-base 1012 + web-shell 39).
Verified head: 737d559d2acb56d3e854ceb1e9ea10d575df2fb8 (merge commit 938abdc5, base tip 63a8ed43).

中文摘要
  • 结论merge-ready。2703 个脚本化断言全部通过,无意外失败。这是第三轮验证:上一轮(round 2)验证的 head 为 70d5e017(4 个提交);此后 PR 又新增 16 个提交(浅克隆 depth-2 中不可单独到达),且 base tip 从 67d12871 前移到 63a8ed43(含 feat(dingtalk): include non-bot mention target identifiers in inbound context #8639 钉钉改动)。本轮对聚合 diff HEAD^1..HEAD 全量重测,未做任何短路沿用。
  • A/B 结论(见「Central claim + A/B」表,见证图 01-ab-head-issue-scenario-and-fast-path.png / 02-ab-base-control-rejects-issue-scenario.png):中心声明在新 head 上再次得证。同一份合法 interactiveCards 配置:head 接受并完整持久化(134/134);base 控制端以原始 issue fix(channels): expose DingTalk interactiveCards through the daemon management API #8515 的报错 Channel field "interactiveCards" is not manageable. 拒绝(86/86,base 的"按预期拒绝 / 按预期透明通过"均为编码预期,计为通过)。每臂均用 realpath 探针断言实际加载的模块(base 臂确实加载 base-tree 的 channel-base/channel-dingtalk 与两文件替换后的控制版 store/registry)。
  • 本轮新增行为(round 2 之后的增量)
    1. 注册表隔离机制从"跳过注册"改为"剥离 management 后仍注册":非法描述符的频道仍在 registry 中(运行时可用),catalog 标记 manageable:false, fields:[],store PUT 以 channel_settings_unmanageable 失败关闭且不落盘;类实例插件的原型方法在剥离拷贝中存活(变体 d 实测);其余 7 个内置频道不受影响,github 旁观者 upsert 正常(图 04-isolation-strip-variants.png,59/59)。
    2. 存储对象保全快路径:与已存配置逐字节一致的 object 值(任意层级子树)整体跳过校验直接保全——即使其中含有按现行规则非法的值(如 timeoutMs:-5);保留键仍拒绝。变更过的子树则走全量校验(F4/D16 实测)。
    3. containsUnsafeObjectKey 递归守卫扩展到 record 字段与保全快路径;gitlab 的 action_prompt_template(kind record)实测拒绝 __proto__/constructor 键且 Object.prototype 无污染(图 05-…,5/5)。
    4. 解析器超时上限parseDingtalkInteractiveCardConfigtimeoutMs 钳制在 2^31−1(Node setTimeout 超限会钳到 1ms 导致卡片瞬间过期);3e9、2^31 实测被钳制,2^31−1 与 0.5 原样保留;daemon 管理层接受 3e9(不钳制),钳制只发生在运行时解析器(D15 实测)。
  • 真实 daemon E2E(head,真实 qwen serve 子进程 + 回环 HTTP + bearer token,图 03-daemon-http-e2e-on-head.png):38/38,含 issue 场景全链路(目录 GET → PUT 200 → 后续 GET 一致 → 落盘一致)、无 token 401、陈旧 revision 409、省略即删除(D12)、timeoutMs=0 全路径拒绝且不落盘(D13)、未知嵌套键三格(新键 400 / 磁盘遗留键原样回传 200 / 改动遗留键 400)、以及快路径两格(D16/D17)。
  • 变异矩阵(图 05-mutation-matrix-and-record-guard.png):8 个变异全部按预期被杀,且各自死于其行为所预测的测试(M1 快路径→4 个保全测试;M2 object 分支→issue 场景测试;M3 exclusiveMinimum;M4 未知键保全;M5 剥离注册→builtins 测试;M6 前端 outOfRange;M7 解析器钳制;M8b 草稿侧 object 跳过→新增测试 keeps object fields out of the editor draft)。sha 校验还原全部 OK,git status 干净。
  • 门禁(图 06-targeted-gates.png):CLI store+registry+builtins+routes 122/122;server.test.ts + SDK 镜像 887/887;dingtalk 297/297;channel-base 1012/1012;web-shell 频道编辑器 39/39。全部 Linux 容器复现(作者仅测 macOS)。SDK 类型镜像现由 PR 自带的 channel-descriptor-sdk-mirror.test.ts 钉住(2/2)。
  • Findings:4 条均信息级(PR 正文滞后于最新语义且较 round 2 进一步过时;快路径造成的 store 接受 / 解析器拒绝的文档化分歧;嵌套 required 仍无生产描述符使用;剥离拷贝不保留 #private/不可枚举自有属性——已在代码注释中声明)。无阻塞项。
  • 未覆盖:真实钉钉 worker 连接/投递/渲染;20 个提交的逐提交归因(浅克隆不可达,仅验证聚合 diff);SDK 1444 项测试未重跑;Windows;base 臂 HTTP 层。

Previous-finding status (follow-up round)

Round 2 verified head 70d5e017 against base tip 67d12871. Since then 16 commits landed on the PR (20 total; none individually reachable in this depth-2 checkout — git rev-list HEAD^1..HEAD^2 yields only 737d559d2, and the snapshot's commits entries carry no messages) and the base tip advanced to 63a8ed43 (which includes #8639, a DingTalk inbound-context change; it touches files disjoint from this PR, the merge applied cleanly, and the effective HEAD^1..HEAD diff contains exactly the PR's 25 files). Every measurement below was re-run at the new head — nothing carried forward by shortcut.

# round-2 finding severity status at new head
1 PR body stale relative to commits 3–4 info stands (worsened) — body still says "validation rejects unknown nested keys by design" and Test Plan step 5 promises channel_settings_invalid_config for an unknown nested key. Both remain true only for new keys; unchanged stored unknown keys now round-trip with 200 (E2E D10), unchanged stored objects bypass validation entirely (D16/D17), the timeout cap and the strip mechanism are unmentioned. Finding 1 below.
2 Strictness tightening vs base for already-stored blobs (head validated, base opaque) info superseded by design — the new commit flipped the tradeoff: a byte-identical stored object (at any nesting level) now bypasses validation (fast path), so the round-2 tightening is gone. The residual is the mirror-image divergence: the store now accepts what the runtime parser rejects (Finding 2). Re-measured: F1/D17 accept timeoutMs:-5 when byte-identical; parser throws on it (asserted).
3 M8b survivor: draft-side object skip unpinned info fixed — new test keeps object fields out of the editor draft (channel-editor-state.test.ts:180) pins it; my M8b mutant (delete the draft-side continue) is now KILLED by exactly that test.
4 Nested required pinned but unused in production info stands — still no shipped descriptor declares a required nested property (dingtalk's are all optional); pinned only by test-only descriptors; now documented in docs/developers/qwen-serve-protocol.md. Finding 3 below.
R1-3 Replacement semantics: omitting interactiveCards deletes it info stands — re-measured live (E2E D12: PUT without the field → 200, object gone).
R1-2 Nested env errors reported leaf key, not full path nit fixed (stable) — E2E/store cell E1 re-asserts Channel field "interactiveCards.statusCard.enabled" does not support environment references.

Central claim + A/B

Central claim (unchanged, re-proven at the new head): DingTalk interactiveCards is a manageable nested-object field — PUT /workspace/channels/:name accepts it, validates nested values, persists it — where base rejected it with channel_settings_invalid_config: Channel field "interactiveCards" is not manageable. (issue #8515).

New secondary claims this round: (N1) invalid management descriptors are isolated by stripping management while keeping the channel registered and its runtime alive; (N2) a stored object resent byte-identical (whole object or any nested subtree) bypasses validation and is preserved verbatim, while any changed value is fully validated; (N3) the runtime parser caps timeoutMs at 2^31−1 (the setTimeout clamp), and the daemon accepts values above it.

Control construction: base channel packages (channel-base, channel-dingtalk) rebuilt at HEAD^1 (tsc --build) in a scratch worktree; the CLI side of the control is a copy of the head dist/ with exactly two files swapped for esbuild-transpiled base versions (channel-settings-store.ts, channel-registry.ts). The control's node_modules/@qwen-code/channel-base and channel-dingtalk symlink into the base tree; the other seven channel packages and core link to the head tree (untouched by this PR; lockfile untouched). Per-arm resolution probes assert the realpaths actually loaded (PASS on both arms; quoted at the top of logs/ab-{head,base}.log). Confound check: the base-vs-head channel-base dist delta is type declarations only (diff -r: only .d.ts/.d.ts.map differ), so base dingtalk resolving channel-base carries zero runtime difference. The control differs from head by exactly the PR's hunks.

Witnesses: evidence/01-ab-head-issue-scenario-and-fast-path.png (head arm live), evidence/02-ab-base-control-rejects-issue-scenario.png (base arm live). Raw logs: logs/ab-head.log, logs/ab-base.log. Harness: ab-harness.mjs (expectation-encoded per arm; base rejections and base opaque pass-throughs are passing control assertions).

cell head base (control)
catalog descriptor interactiveCards kind object, nested enabled/statusCard/questionCard, timeoutMs.exclusiveMinimum=0, not required PASS (advertised) PASS (absent, as expected)
upsert valid full object (the issue scenario) + secret replace PASS — persisted to snapshot + file, secret literal on disk PASS — rejected, exact issue error Channel field "interactiveCards" is not manageable., file untouched
5 boundary accepts ({enabled:false}, {}, timeoutMs 1, 0.5, 3e9) PASS ×5 — persisted (3e9 stored unclamped — clamping lives in the parser, see N3) PASS ×5 (rejected, as expected)
plain upsert without cards PASS (no field appears; file created; secret persisted) PASS (identical)
9 malformed shapes (string, string-bool, timeout 0/−5/NaN, non-object nested ×2, null enabled, array) PASS ×9 — rejected with full nested paths, file byte-identical PASS ×9 (rejected as not manageable, file untouched)
2 new unknown nested keys (top + deep) PASS — interactiveCards.sneakyNew / interactiveCards.statusCard.mystery not manageable PASS (rejected)
preserved unknown nested keys, unchanged (top-level legacyFlag + deep statusCard.legacyDeep) PASS — accepted and preserved PASS — accepted opaquely (base never inspected the blob)
preserved unknown nested key, changed PASS — rejected interactiveCards.legacyFlag, file untouched PASS (deepEqual fails → rejected)
nested env reference $STATUS_CARD PASS — full path interactiveCards.statusCard.enabled in message PASS (rejected)
top-level env clientId: $DINGTALK_CLIENT_ID PASS (envResolvable) PASS (identical)
top-level preserved unknown field, unchanged / changed PASS / PASS PASS / PASS (refactor is preservation-clean)
__proto__ / constructor nested keys in PUT payload PASS ×2 — rejected with full paths; Object.prototype unpolluted PASS ×2 (rejected)
F1 stored invalid blob, byte-identical (timeoutMs:-5) PASS — accepted, invalid value kept verbatim (round-3 fast path) PASS — accepted opaquely
F2 invalid subtree unchanged, sibling changed PASS — accepted: enabled flip applied, timeoutMs:-5 subtree kept (preservation is per subtree) PASS — rejected (top-level blob no longer equal)
F4 invalid subtree itself changed PASS — rejected interactiveCards.questionCard.timeoutMs (validation re-engages) PASS (rejected)
F3 reserved key inside stored object, byte-identical PASS — rejected interactiveCards.statusCard.__proto__ (fast path refuses unsafe keys), prototype clean PASS — accepted opaquely (base never inspected)
stale revision PASS — channel_settings_conflict n/a
parser consistency: every daemon-accepted shape through parseDingtalkInteractiveCardConfig PASS 7/7 + clamp cells (3e9→2147483647, 2^31→2147483647, 2^31−1 kept, 0.5 kept) + defaults ({}→enabled/270000 per docs; absent→disabled) + documented divergence (parser rejects the store-preserved timeoutMs:-5 blob) n/a

Counts: head 134/134, base 86/86. The flip is load-bearing at the new head exactly as in rounds 1–2; the new cells show how the semantics matured (per-subtree preservation + parser-side clamping).

Registry strip isolation (round-3 mechanism change)

Round 2's registry skipped invalid-descriptor channels. The current code (registerWithManagementValidation) catches the validation error, logs [channel-registry] Invalid management metadata in "<label>" channel: <reason> to stderr, and registers a copy with management stripped — "the channel runtime keeps working with management stripped." Catalog (supportedChannelCatalog) then reports manageable: false, fields: [], and the store's PUT fails closed with channel_settings_unmanageable.

Witness: evidence/04-isolation-strip-variants.png, logs logs/isolation-{a,b,c,d}.log, harness isolation-harness.mjs (physical copy of head CLI dist; @qwen-code/channel-dingtalk symlinked to a scratch stub per variant — realpath probe PASS in every variant; the other seven channel packages load for real).

variant descriptor fault result
a settings object with required: true dingtalk still registered, management === undefined, runtime createChannel alive; exact stderr diagnostic Channel field "settings" cannot be a required object.; catalog manageable:false, fields:[]; other 7 types present, github unaffected; store PUT fails closed channel_settings_unmanageable ("does not provide safe management metadata"), no file written; github bystander upsert (catalog-driven config incl. secret replace) persists. 16/16
b nested property key __proto__ isolated with Channel field "cards.__proto__" cannot use a reserved key.; same fail-closed behavior. 16/16
c valid object descriptor (positive control) registers with management, no diagnostic, end-to-end store PUT {settings:{on:true}} persists — proves a/b exclusion is isolation, not an import failure. 4/4
d class-instance plugin (prototype method + non-enumerable own prop) with nested secret registered stripped; heartbeat() survives (prototype kept); enumerable own state kept; non-enumerable own property does not survive (documented in the code comment); extension path: registerPlugin strips instead of throwing, diagnostic names the reserved top-level type key; duplicate registerPlugin still throws already registered. 23/23

Record-guard reachability (new containsUnsafeObjectKey clause on record fields): gitlab ships action_prompt_template with kind record, so the guard is live in production. record-probe.mjs drove the real store: benign record persisted; {"__proto__":…} and (isolated probe, string values) {"constructor":"x"} both rejected with has an invalid value.; Object.prototype unpolluted. 5/5. Witness inside 05-mutation-matrix-and-record-guard.png.

Stored-object preservation semantics (round-3 delta)

assertDescriptorValue gained an object-level fast path: isDeepStrictEqual(previous, value) && !containsUnsafeObjectKey(value) → return without validation. It applies recursively at every object level, so preservation is per object subtree: an unchanged nested subtree is kept verbatim even when a sibling changes (F2/D16), while any changed subtree is fully validated (F4/D11). Unknown keys keep the round-2 per-key rule (assertPreservedUnknownField: byte-identical to stored → kept; new or changed → not manageable with full path; reserved keys always rejected). The comment states the intent: the web editor re-sends stored values verbatim and cannot edit object fields. Consequences, all measured:

  • Web Shell's round-trip duty holds for nested keys it cannot render (D10).
  • New or modified unknown nested keys cannot smuggle config in (D9/D11).
  • Reserved keys are rejected even inside byte-identical stored blobs (F3).
  • A manually-corrupted stored value round-trips through the management API instead of 400-ing — see Finding 2.

Real daemon HTTP E2E (head)

Witness: evidence/03-daemon-http-e2e-on-head.png, log logs/daemon-e2e.log, harness daemon-e2e.mjs (spawns the built bundle as a real child process; isolated QWEN_HOME; ephemeral loopback port parsed from the listening on line; bearer auth via QWEN_SERVER_TOKEN). 38/38:

cell result
D1 daemon boots on ephemeral loopback port PASS
D2 no-token request → 401 {error:"Unauthorized"} PASS
D3 GET /workspace/channel-types advertises nested object descriptor (kind/properties/exclusiveMinimum) PASS
D4 seeded snapshot: config omits the secret literal; secrets.clientSecret.present=true; revision present PASS
D5 PUT /workspace/channels/bot valid cards → 200, response carries exact object PASS
D6 follow-up GET round-trip PASS
D7 workspace settings file persists exact object, preserved secret intact PASS
D8 Web-Shell-shaped save (seed from GET, edit clientId) → object preserved, secret present PASS
D9 new unknown nested key → 400 channel_settings_invalid_config, interactiveCards.sneakyNew, file untouched PASS
D10 disk-seeded legacy nested key, PUT unchanged → 200, key survives PASS
D11 changed legacy key → 400, path interactiveCards.questionCard.legacyFlag, file untouched PASS
D12 PUT omitting the object → 200, object removed (replacement semantics, re-measured) PASS
D13 timeoutMs: 0 → 400, full path, file untouched PASS
D14 stale expectedRevision → 409 channel_settings_conflict PASS
D15 timeoutMs: 3e9 → 200, persisted unclamped (clamp lives in the parser — N3) PASS
D16 changed-sibling write with unchanged invalid subtree → 200, invalid subtree kept, sibling applied PASS
D17 byte-identical invalid blob → 200 (fast path) PASS

Corrections

  • To the round-2 report's description, which is no longer accurate at this head (behavior changed by the new commits, not an error in round 2): (1) round 2 reported invalid descriptors are skipped with getPlugin returning undefined — the current code strips management and keeps the channel registered (Registry strip isolation section). (2) Round 2's preserved-invalid-blob cell reported head rejects a stored timeoutMs:-5 blob ("validation beats preservation") — the current fast path accepts it when byte-identical (cells F1/D17), by the intent documented in the code comment.
  • To the PR body (Finding 1): the description still describes pre-commit-5 semantics.

Findings (informational; none blocking)

  1. The PR body is stale, now two generations behind (severity: info; description, not code). "Risk & Scope" still says "validation rejects unknown nested keys by design", and Reviewer Test Plan step 5 promises channel_settings_invalid_config for an unknown nested key — both hold only for keys not already stored (D10 returns 200). The body also does not mention the per-subtree preservation fast path (D16/D17), the registry strip mechanism, the 2^31−1 timeout cap, or the outOfRange client validation. A reviewer following step 5 will still see a 400 (the key they send is new), but the complementary round-trip behavior is undocumented. docs/developers/qwen-serve-protocol.md does state the client-preserve duty; only the PR text lags. No code change needed.
  2. Per-subtree preservation admits store-accepted values the runtime parser rejects (severity: info). Because any byte-identical stored subtree bypasses validation, a manually-corrupted settings.json value (measured: questionCard.timeoutMs: -5) round-trips through the management API with 200 (F1/F2/D16/D17) while parseDingtalkInteractiveCardConfig throws must be a finite positive number. on worker reload (asserted in the harness's divergence cell). Bounds: (a) no API write path can create such a value — every changed value is fully validated, so the only source is manual file editing; (b) the failure is loud (worker start fails with the parser error), not silent corruption; (c) it is the documented intent of the fast-path comment ("an unchanged stored object keeps its values even if a newer rule would reject them"). This is the mirror image of round-2 finding 2, which the new commits deliberately reversed; noted so the tradeoff is visible in the review record.
  3. Nested required properties: pinned but unused in production (severity: info; carries over). No shipped descriptor declares a required nested property (dingtalk's nested fields are all optional); the loop is pinned by test-only descriptors, and the protocol doc now states the rule ("enforced only while its parent object is present in the write"). Harmless forward hook. One precision gap, related: with the round-3 fast path, an unchanged stored object also bypasses nested-required enforcement (test preserves an unchanged stored object that omits a now-required nested property pins exactly that — M1 kill list), so the doc sentence is complete only together with the preservation paragraph above it.
  4. The strip copy drops ES #private fields and non-enumerable own properties (severity: info; documented in the code comment, empirically confirmed). Variant d measured a class-instance plugin: prototype methods and enumerable own state survive; a non-enumerable own property does not (hiddenOwn === undefined after the strip). All eight shipped builtin plugins are plain object literals, so no current channel is affected; this is a forward-compat note for third-party class-instance plugins whose runtime state lives in private/non-enumerable members.

Mutation matrix (vacuity + pinning)

Witness: evidence/05-mutation-matrix-and-record-guard.png; rerunnable via apply-mutation.mjs <id> (exact-count string replacement, one occurrence asserted, sha-verified restore — all SHA-CHECK: OK, git status clean at exit). Positive controls: the unmutated gate runs below are green. Per-mutant logs in logs/mutant-M*.log.

mutant change target suite result
M1 disable the byte-identical-object fast path (if (false)) store (66) KILLED (4 fail) — preserves an unchanged stored object value that fails current validation, …invalid nested object while a sibling property changes (the F2 semantics), …non-record object value, …omits a now-required nested property
M2 disable the whole object branch in assertDescriptorValue store KILLED (16 fail) — first: persists DingTalk interactive card configuration through management metadata (the issue scenario)
M3 drop the exclusiveMinimum clause store KILLED (3 fail) — rejects 'number at the exclusive minimum' without writing, …'non-positive question timeout'
M4 make assertPreservedUnknownField always accept store KILLED (3 fail) — rejects 'unknown config field', only preserves unknown nested legacy fields when they are unchanged, …'unknown nested field'
M5 remove builtin strip validation (plain registry.set) registry + builtins (42) KILLEDkeeps an invalid built-in channel running without management metadata (a real assertion failure, not an import break)
M6 disable the client-side outOfRange check web-shell state (25) KILLEDrejects number values at or below the exclusive minimum
M7 remove the parser's Math.min cap dingtalk parser (11) KILLEDclamps question timeouts at the setTimeout maximum delay
M8b drop object skip in createChannelEditorDraft (round-2's survivor) web-shell state KILLEDkeeps object fields out of the editor draft (new test; round-2 finding 3 resolved)

Every killed mutant died on the assertion its behavior predicts (attribution checked per mutant log). All 8 mutants killed; no survivors.

Targeted gates

Witness: evidence/06-targeted-gates.png. Gate liveness is proven by the matrix itself — the same runners went red on eight mutants.

gate result
CLI channel-settings-store.test.ts (66) + channel-registry.test.ts (40) + channel-registry-builtins.test.ts (2) + workspace-channel-management.test.ts (14) 122/122
CLI server.test.ts (885) + channel-descriptor-sdk-mirror.test.ts (2) 887/887
channels/dingtalk full suite 297/297 (round 2: 250 — growth from the PR's new parser/descriptor tests plus base-tip drift)
channels/base full suite 1012/1012 (round 2: 971 — base-tip drift; this PR adds no channel-base tests)
web-shell channel editor suites (2 files: dialog 14 + state 25) 39/39 (round 2: 23)
SDK type mirror now pinned by the PR's own channel-descriptor-sdk-mirror.test.ts ("keeps built-in descriptor values within the daemon wire contract"), 2/2 — replaces round 2's manual field-by-field comparison

All suites ran on Linux (node:22-bookworm CI container), closing the author's "Linux not tested" column. Pre-existing failures: none observed on either arm. Repo-wide gates were not run (targeted workspaces only).

Not covered

  • Live DingTalk worker: connection, message delivery, card rendering, and a worker reload carrying the new config were not exercised (no credentials/network in the sandbox; PR declares this out of scope). The reload path is covered only at the parser level (parseDingtalkInteractiveCardConfig accepts every daemon-accepted shape, clamps per N3, defaults match docs/users/features/channels/dingtalk.md). This reproduces the config handling, not a live card round-trip.
  • Per-commit attribution: the checkout is depth-2 (git rev-list HEAD^1..HEAD^2 yields only 737d559d2; the snapshot lists 20 commits with empty messages). The aggregate HEAD^1..HEAD diff was verified; which of the 16 new commits introduced which hunk was not separately established. Round 2→3 deltas were identified behaviorally (strip mechanism, fast path, timeout cap, containsUnsafeObjectKey, validateConfig hardening, outOfRange client check) instead.
  • Base arm at the HTTP layer: the store-level A/B proves base behavior; the route layer (sendManagementError, status mapping) is pre-existing and unchanged — read and exercised at head via the route suite + E2E. No base daemon was booted.
  • SDK test suite (author-reported 1444 tests) not re-run — the SDK delta is type-only re-exports, now additionally pinned by the CLI mirror test.
  • validateConfig hardening (try/catch + rejected-Promise handler + non-string coercion): exercised only indirectly — dingtalk declares no validateConfig, so the defensive branches were not driven by a dedicated harness. The channel-settings-store.test.ts suite (66 green) covers the store side.
  • Windows behavior (author marked untested); this round covers Linux only.
  • No regex/scanner over unbounded untrusted text is introduced by this diff, so no adversarial-length ladder was run.

Methodology

Environment: CI verify container (node:22-bookworm, node v22.23.2), merge-ref checkout at depth 2 (HEAD merge commit 938abdc5, HEAD^1 base tip 63a8ed43, HEAD^2 PR head 737d559d); npm ci + npm run build pre-run; bundle freshness confirmed by grepping interactiveCards and 2147483647 in dist/chunks. Follow-up round: round-2's report was treated as untrusted input and every measurement re-executed at the new head (base rebuilt from the new base tip, so the control also absorbs base drift). The store A/B harness (ab-harness.mjs) drives the real compiled WorkspaceChannelSettingsStore + registry (no stubs) against throwaway workspaces with isolated QWEN_HOME, one fresh settings file per cell, asserting snapshot content, on-disk file bytes (sha before/after), error code+message, and prototype-pollution state; the control tree (base-rebuilt channel packages + two-file base transpile of the CLI dist) was realpath-probed per arm, and the base/head channel-base runtime delta proven type-only by diff -r. The isolation harness (isolation-harness.mjs) swaps the dingtalk package under a copied head dist for scratch stubs carrying faulty/valid descriptors and asserts catalog membership, stderr diagnostics, strip-copy fidelity, fail-closed store behavior, and an unaffected github bystander. record-probe.mjs drives the same guard through gitlab's real record field. The daemon E2E (daemon-e2e.mjs) spawns the built bundle and speaks loopback HTTP with bearer auth. Mutations were applied as exact-count string replacements on source, run through each package's own vitest, and restored sha-verified. Evidence PNGs were produced with scripts/verify-capture.mjs from live runs (A/B both arms, E2E) and from the run logs (isolation, matrix, gates). Raw logs in logs/, harnesses in the artifact root.

Evidence images

01-ab-head-issue-scenario-and-fast-path

01-bridge-ab-head-vs-mutations

02-ab-base-control-rejects-issue-scenario

02-capsilence-provenance-matrix

03-daemon-http-e2e-on-head

04-isolation-strip-variants

05-mutation-matrix-and-record-guard

06-targeted-gates

2 additional image(s) did not pass the hosting checks (PNG magic, unique sanitized name, ≤2 MB, max 8) and remain in the run artifacts.

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

@qwen-code-ci-bot
qwen-code-ci-bot dismissed their stale review August 7, 2026 15:47

Stale: pinned to 24c358e (two pushes ago). Its premise — the integration suite skipped in CI and not run locally — is resolved: the skip is structural on every fork PR (whole job, no steps execute), the round-11 report records a local integration-suite run, and the behavioural jobs that do run on fork heads (Serve A/B, Real daemon E2E, Live Host, web-shell E2E) are green on the current head, with a sandboxed verify in flight. Clearing so the current approvals stand.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, looks ready to ship. ✅

@wenshao
wenshao added this pull request to the merge queue Aug 7, 2026
Merged via the queue into QwenLM:main with commit edb4203 Aug 7, 2026
83 checks passed
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.8.

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

Labels

autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+) review/self-reported The linked issue was opened by the PR author (self-reported)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(channels): expose DingTalk interactiveCards through the daemon management API

5 participants