Skip to content

feat(dingtalk): present tool permission requests with native interactive cards - #10457

Merged
BenGuanRan merged 16 commits into
QwenLM:mainfrom
BenGuanRan:feat/dingtalk-permission-cards-10388
Sep 9, 2026
Merged

BenGuanRan merged 16 commits into
QwenLM:mainfrom
BenGuanRan:feat/dingtalk-permission-cards-10388

Conversation

@BenGuanRan

@BenGuanRan BenGuanRan commented Aug 29, 2026 •

Copy link
Copy Markdown
Collaborator

What this PR does

This PR presents attended DingTalk tool-permission requests as native interactive cards. The card offers allow once, deny, and the persistent allow choice only when the original permission request advertises it. Responses are bound to the originating user and live run, settle the original request at most once, and move stale, cancelled, denied, approved, or expired cards into a terminal state.

Existing text commands remain the fallback when permission cards are disabled or card delivery fails. At Channel startup, the already-loaded default general.language setting selects Chinese permission copy for Chinese values and English for all other values. The daemon follows the same startup-snapshot behavior and does not call the per-session language endpoint. Other channels do not gain native permission cards, and permission-policy semantics are unchanged.

Why it's needed

DingTalk already uses native cards for task status and structured questions, but ordinary tool permissions still require users to type /approve, /approve-always, or /deny. A native card makes this security-sensitive interaction clearer and easier to operate while preserving the exact choices supplied by the permission system. Following the existing default language also avoids showing English permission chrome inside a Chinese Channel experience.

Reviewer Test Plan

How to verify

  1. Start an attended DingTalk run with interactive permission cards enabled and trigger an ordinary tool action that requires approval. Confirm one native card appears instead of permission-command text.
  2. Confirm allow once resumes the original request once and marks the card approved, while deny rejects it once and marks the card denied.
  3. Trigger requests with and without a persistent grant. Confirm the persistent choice appears only when advertised and uses the original grant scope.
  4. Use the card's cancel action and let another request time out. Confirm both deny the pending permission before any delayed card update can race with a text approval, and confirm the cards become cancelled and expired respectively.
  5. Attempt a callback as another group member, submit malformed or duplicate values, and submit a stale card. Confirm none can settle the permission.
  6. Disable permission cards or simulate card delivery failure. Confirm the existing /approve, optional /approve-always, and /deny text instructions are delivered.
  7. Start or reload the Channel with default language zh_CN, then with en or auto. Confirm permission card chrome, terminal states, owner-only feedback, and fallback copy are Chinese for zh_CN and English otherwise; no session-language lookup is required.

Evidence (Before & After)

Before: ordinary DingTalk tool permissions are rendered as text commands that the user must type manually, and the permission flow does not consistently follow the configured default language.

After: ordinary attended permissions are rendered as an owner-bound native card with only the choices supplied by the original permission request; terminal cards cannot be reused, and the card plus immediate fallback follow the default Channel language. No live DingTalk screenshot is included because valid app credentials and published-template delivery evidence were not available in the local environment.

Automated evidence: 639 ChannelBase tests, 335 affected DingTalk tests, and 171 affected CLI tests pass. Repository build, typecheck, lint, targeted formatting, and diff checks pass. Review findings covering callback races, Chinese fallback title selection, and underscore locale normalization were fixed with regression coverage; two final clean review passes found no remaining blocking issue.

Tested on

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

Environment (optional)

macOS 14.4.1, Node.js v25.2.1, local package tests and monorepo validation. Real DingTalk delivery was not run.

Risk & Scope

  • Main risk or tradeoff: permission responses can arrive from the card, existing text commands, timeout handling, or outside settlement; all paths share one response promise and terminal card lifecycle, with regression coverage for duplicate and delayed-update races.
  • Not validated / out of scope: real DingTalk delivery with production credentials and a published template; live per-session language switching; unattended or loop runs; native permission cards in CLI, Web, IDE, and other IM adapters.
  • Breaking changes / migration notes: none. Permission cards are enabled by default only when DingTalk interactive cards are configured and can be disabled independently. The locale is a startup snapshot, so changing the default setting takes effect through the existing Channel reload or process restart.

Linked Issues

Closes #10388

中文说明

本 PR 的改动

本 PR 将钉钉中有人值守的工具权限请求展示为原生交互卡片。卡片提供“仅允许一次”“拒绝”,并且仅当原始权限请求明确提供持久授权时才展示该选项。响应会绑定到发起用户和当前运行,同一原始请求最多结算一次,过期、取消、拒绝、允许或超时的卡片都会进入不可再次操作的终态。

当权限卡片被禁用或卡片投递失败时,现有文本命令仍作为回退。Channel 启动时只读取已加载的默认 general.language 配置:中文配置使用中文权限文案,其余配置使用英文。daemon 同样使用启动时快照,不调用 session 级语言接口。其他渠道不会获得原生权限卡片,权限策略语义保持不变。

为什么需要此改动

钉钉渠道已经使用原生卡片展示任务状态和结构化问题,但普通工具权限仍要求用户手动输入 /approve、/approve-always 或 /deny。原生卡片让这一安全敏感交互更清晰、更易操作,同时严格保留权限系统提供的原始选项。沿用已有默认语言也可以避免中文 Channel 体验中出现英文权限卡片文案。

Reviewer 测试计划

如何验证

  1. 启动启用了交互式权限卡片的有人值守钉钉运行,并触发需要审批的普通工具操作。确认只出现一张原生卡片,而不是权限命令文本。
  2. 确认“仅允许一次”只恢复一次原始请求并将卡片标记为已允许;“拒绝”只拒绝一次并将卡片标记为已拒绝。
  3. 分别触发带有和不带有持久授权的请求。确认只有原始请求提供该选项时才显示持久授权,并且沿用原始授权范围。
  4. 使用卡片取消操作,并让另一个请求超时。确认两者都会在任何延迟卡片更新可能与文本批准竞争之前拒绝待处理权限,且卡片分别进入已取消和已过期状态。
  5. 使用其他群成员身份操作、提交畸形或重复值以及提交过期卡片。确认这些操作都不能结算权限。
  6. 禁用权限卡片或模拟卡片投递失败。确认会发送现有的 /approve、可选 /approve-always 和 /deny 文本说明。
  7. 将默认语言设为 zh_CN 后启动或重载 Channel,再分别使用 en 或 auto。确认权限卡片文案、终态、非发起人反馈和回退文案在 zh_CN 下为中文,其他配置下为英文,并且不依赖 session 语言查询。

证据(改动前后)

改动前:普通钉钉工具权限以文本命令展示,用户必须手动输入命令,并且权限流程没有稳定沿用已配置的默认语言。

改动后:普通的有人值守权限以仅原发起人可操作的原生卡片展示,并且只包含原始权限请求提供的选项;终态卡片无法重复使用,卡片和直接回退文案遵循默认 Channel 语言。由于本地环境没有可用的应用凭证和已发布模板投递证据,因此未附真实钉钉截图。

自动化证据:639 个 ChannelBase 测试、335 个受影响钉钉测试和 171 个受影响 CLI 测试通过。仓库构建、类型检查、lint、目标格式检查和差异检查均通过。评审发现的回调竞态、中文回退标题选择和下划线语言配置归一化问题均已修复并补充回归覆盖;最终两轮复核未发现剩余阻断问题。

测试平台

OS 状态
🍏 macOS ✅
🪟 Windows N/A
🐧 Linux N/A

环境(可选)

macOS 14.4.1、Node.js v25.2.1,本地包测试与 monorepo 校验。未运行真实钉钉投递。

风险与范围

  • 主要风险或取舍:权限响应可能来自卡片、现有文本命令、超时处理或外部结算;所有路径共享同一个响应 Promise 和卡片终态生命周期,并通过回归测试覆盖重复响应和延迟更新竞态。
  • 未验证或不在范围内:使用生产凭证和已发布模板的真实钉钉投递;实时 session 语言切换;无人值守或循环运行;CLI、Web、IDE 以及其他 IM 适配器的原生权限卡片。
  • 破坏性变更或迁移说明:无。权限卡片仅在钉钉交互卡片已配置时默认启用,并可独立关闭。语言为启动时快照,修改默认配置后通过现有 Channel 重载或进程重启生效。

关联 Issue

Closes #10388

@github-actions github-actions Bot added the review/self-reported The linked issue was opened by the PR author (self-reported) label Aug 29, 2026
@BenGuanRan
BenGuanRan force-pushed the feat/dingtalk-permission-cards-10388 branch from acf6330 to 779699b Compare August 31, 2026 17:02
@github-actions

Copy link
Copy Markdown
Contributor

Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration.

中文

请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。

@BenGuanRan

Copy link
Copy Markdown
Collaborator Author

@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 31, 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-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 将重新运行。

@qwen-code-dev-bot

qwen-code-dev-bot commented Aug 31, 2026 •

Copy link
Copy Markdown
Collaborator

✅ AutoFix round 6 finished — view run. See this round's report below.

中文说明

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

@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 needed (PR #10457)

Triaged all feedback newer than the last evaluation (2026-08-29T04:24:27Z):

  • Issue-level comment [ic:5481964814] (serve daemon A/B report) — this is an automated A/B verification report, not a review finding. It compared the PR base against this PR's head (432d03f) across 12 scenarios and reported ✅ no response changes. There is nothing to fix.
  • Reviews — none received this round.
  • Inline comments — none received this round.
  • Failed checks / still-red checks — none. All CI checks are green or routinely skipped.

No defects, suggestions, or questions were raised, so no code changes were made and no commit was created.

中文说明

Autofix 评审轮次 — 无需处理(PR #10457)

已对上次评估(2026-08-29T04:24:27Z)之后的所有反馈进行分类:

  • Issue 级评论 [ic:5481964814](serve daemon A/B 报告)——这是一条自动化 A/B 验证报告,并非评审发现。该报告将 PR 基线与本 PR 的 head(432d03f)在 12 个场景下进行了对比,结果为 ✅ 响应无变化。没有需要修复的内容。
  • 评审(Reviews)——本轮未收到任何评审。
  • 行内评论(Inline comments)——本轮未收到任何行内评论。
  • 失败的检查 / 持续失败的检查——无。所有 CI 检查均为通过或常规跳过状态。

本轮没有提出任何缺陷、建议或问题,因此未做任何代码改动,也没有创建提交。

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


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

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 AutoFix ran out of time before finishing (timeout (3600000ms)) (attempt 1/100) — it will retry on the next scan.

⚠️ This change was NOT pushed — any commit referenced below was made only in the runner workspace and has been discarded. What the agent reported:
Qwen failed during address-review: timeout (3600000ms).

See the Qwen Autofix agent step logs for model/tool output.

中文说明

🤖 AutoFix 在完成前耗尽了时间(timeout (3600000ms))(第 1/100 次尝试)—— 将在下次扫描时重试。

Run log: https://github.com/QwenLM/qwen-code/actions/runs/33463984189


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

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Could not produce a passing fix for this feedback (round 2/100). This item now needs a human; the loop stays engaged and still picks up new feedback and base conflicts, but will not retry this item on its own.

⚠️ This change was NOT pushed — any commit referenced below was made only in the runner workspace and has been discarded. What the agent reported:

Round stopped: the gate's packages/cli test leg fails for environmental reasons this round cannot fix in scope

PR #10457 (DingTalk permission cards), same-run verification repair of the
rejection "tests failed in packages/cli" charged to merge commit cf8ff569d6.

Blocker

The verification gate re-runs
npm run test --workspace packages/cli -- --changed origin/main --passWithNoTests
inside an env -i clean child whose allowlist (qwen-autofix.yml, the
review-address "Verification gate" step) is PATH, HOME, RUNNER_TEMP, WORKDIR,
BRANCH, GITHUB_OUTPUT, CI, KISS_AUDIT, FOOTPRINT_ENFORCE — it does NOT pass
RUNNER_NAME. Every slow-runner accommodation this repo merged for shared
ECS hosts (#10552 "ci: stabilize tests under shared ECS host contention",
#10667) keys on RUNNER_NAME starting with ecs-qwen-:

  • packages/cli/vitest.config.ts: testTimeout 15000→60000, hookTimeout set,
    maxWorkers full-core-count→25%
  • serve/live test budgets, e.g. packages/cli/src/commands/serve.test.ts:1045
    const ecs = process.env['RUNNER_NAME']?.startsWith('ecs-qwen-') with
    startupMs = ecs ? 60_000 : 30_000

All of those are inactive inside the gate. Meanwhile this 64-core host ran at
load average ~150–207 for the whole window (uptime: 148.66, 167.84, 186.57,
later 206.90, 187.69, 184.49). Consequences, both measured:

  1. Cold serve startup via the dev entrypoint takes 44s — probe output:
    `qwen serve: startup timing: processToListenMs=43666 runQwenServeToListenMs=313
中文说明

🤖 未能为该反馈产生可通过验证的修复(第 2/100 轮)。此项现在需要人工处理;循环保持在线,仍会拾取新反馈与 base 冲突,但不会自行重试此项。

⚠️ 此改动未被推送 —— 下文引用的任何提交都只存在于 runner 工作区,已被丢弃。以下是 agent 的报告:

本轮停止:门禁的 packages/cli 测试环节因环境原因失败,本轮无法在范围内修复

PR #10457(钉钉权限卡片),针对合并提交 cf8ff569d6 被判"tests failed in
packages/cli"的同轮验证修复(same-run verification repair)。

阻塞点

验证门禁会以 env -i 干净子进程重跑
npm run test --workspace packages/cli -- --changed origin/main --passWithNoTests,
该子进程的环境变量白名单(qwen-autofix.yml 中 review-address 的
"Verification gate" 步骤)只有 PATH、HOME、RUNNER_TEMP、WORKDIR、BRANCH、
GITHUB_OUTPUT、CI、KISS_AUDIT、FOOTPRINT_ENFORCE —— 没有传入
RUNNER_NAME。而本仓库为共享 ECS 宿主机合并的所有慢速 runner 照顾措施
(#10552 "ci: stabilize tests under shared ECS host contention"、#10667)
都以 RUNNER_NAME 以 ecs-qwen- 开头为条件:

  • packages/cli/vitest.config.ts:testTimeout 15000→60000、设置 hookTimeout、
    maxWorkers 从全核数→25%
  • serve/实时测试的预算,例如 packages/cli/src/commands/serve.test.ts:1045
    const ecs = process.env['RUNNER_NAME']?.startsWith('ecs-qwen-'),
    startupMs = ecs ? 60_000 : 30_000

这些照顾措施在门禁里全部失效。与此同时,这台 64 核宿主机在整个窗口期的
负载均值约为 150–207(uptime:148.66、167.84、186.57,随后
206.90、187.69、184.49)。以下为实测后果:

  1. 通过 dev 入口冷启动 serve 需要 44 秒 —— 探针输出:
    qwen serve: startup timing: processToListenMs=43666 runQwenServeToListenMs=31367
    —— 超过非 ECS 的 30 秒预算,因此 serve.test.ts 的 "reaches listening
    through the dev entrypoint without loading interactive Ink internals first"
    在本机必然失败。本机单独运行该测试同样失败,且该文件与 origin/main
    逐字节一致。
  2. maxWorkers 默认为核数(约 60 个 worker),在饱和宿主机上,非 ECS 的
    15 秒单测上限会让负载压到的任何重型测试超时:
    server-default-bridge-wiring.test.ts(7/7 全部超时)、
    server.test.ts(1163 个中 1 个)、
    workspace-channel-management.test.ts(15 个中 1 个)、
    fast-path-open.test.ts(首个测试超时后状态泄漏,连带 5 个失败)、
    run-qwen-serve-live.test.ts(30 秒预算,3 个失败)。

按设计,包测试不参与 A/B 基线对比(run-autofix-review-verification.sh:
"package tests ... A/B-exempt ... Their rejections stay charged to the
round"),因此尽管本轮改动的文件并未参与这些失败,该环境性失败仍被记在
本轮头上:六个失败套件全部与 origin/main 逐字节一致,唯一例外是本 PR 在
workspace-channel-management.test.ts 中新增的 14 行(该新测试单独运行通过)。

证据

  • 门禁 junit(被拒运行的 packages/cli/junit.xml):6 个失败套件、19 个失败
    测试,全部与 serve 相关,全部是超时或超时连锁。
  • 在

Run log: https://github.com/QwenLM/qwen-code/actions/runs/33464526848


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

…ion-cards-10388

# Conflicts:
#	packages/channels/base/src/ChannelBase.ts
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

Autofix address-review summary — PR #10457

Feedback triage

feedback.md contained no new findings: no reviews, no inline comments, no issue-level comments, no failed or still-red checks. With --conflict true, this round's work is merging origin/main into the PR branch and resolving the conflicts.

Merge: origin/main → feat/dingtalk-permission-cards-10388

origin/main advanced from the branch's last merge base (f8609836) to 56f92c84. One textual conflict and two semantic conflicts appeared, all in the permission-prompt area where this PR and main PR #10614 ("Improve fallback permission and error messages") evolved the same code:

Conflict 1 — formatPermissionRequest (textual)

  • main unified the task/non-task message layouts into one format that adds Tool: / Action: / Parameters: context lines and surfaces the permission options' own labels.
  • PR localized the fallback message text through PERMISSION_COPY (en/zh).

Resolution keeps main's unified format and routes every fixed string through PERMISSION_COPY, adding request/tool/action/parameters keys (with zh translations 请求:/工具:/操作:/参数:) for main's new lines. The now-unused command key was replaced by action (zh value 操作: unchanged, en updated to Action:).

Conflict 2 — duplicate permissionOptionLabel methods (semantic, would not compile)

Both sides added a permissionOptionLabel method with different signatures: main's (option, fallback) label resolver and the PR's (name, kind) zh-label translator for card decisions.

Resolution keeps main's method and makes it locale-aware through a new localizedPermissionOptionLabel, which translates stock option names on zh channels: Allow/Allow once → 仅允许本次, Deny/Reject → 拒绝, and always-allow options matched by option id plus stock name prefix → 始终允许此项目 / 始终允许此用户 / 始终允许. This preserves the PR's zh localization under main's name-first label policy and extends it to the Reject stock name, which is what the CLI actually sends today (basicPermissionOptions in permissionUtils.ts) and which the PR's original map did not cover. The PR's duplicate method was deleted; its card-decision code now reuses main's new approvalOption/denialOption helpers.

Conflict 3 — test helper defaults (semantic)

main changed the shared emitPermission test helper's stock option names (Allow once→Allow, Deny→Reject) and added _meta.toolName, which three PR-side test expectations written against the old names/hardcoded labels no longer matched. Updated those three expectations to the name-first behavior; all PR zh assertions were kept as-is and pass under the resolution.

Supporting reuse

The card-presentation title now reuses main's permissionTitle (localized Tool use / 工具调用 fallback) instead of duplicating the sanitize/fallback logic.

Everything main brought in outside this area (status-card network recovery #10357, hooks trust-boundary fixes #10427, DWS reactions #10610, qwen-live, OpenTUI batch 4, etc.) merged cleanly and was verified by the package test suites below.

Verification

  • npm run build — passed (exit 0)
  • vitest run src/ChannelBase.test.ts (packages/channels/base) — 641 passed
  • npx vitest run (packages/channels/dingtalk, all 13 test files) — 508 passed
  • vitest run channel CLI tests (daemon-worker, runtime, start, config-utils in packages/cli/src/commands/channel) — 213 passed (4 files)
  • vitest run src/serve/routes/workspace-channel-management.test.ts (packages/cli) — 15 passed
  • npm run typecheck — passed
  • npm run lint — passed (the commit additionally passed the repository's pre-commit lint-staged gate: prettier + eslint --fix --max-warnings 0 over all staged files)
  • Mutation probes (zh localization layer added this round):
    • Disabled the locale !== 'zh' gate in localizedPermissionOptionLabel → the zh permission tests in ChannelBase.test.ts FAILED as expected (labels fell back to English); restored, re-ran → green.
    • Removed the proceed_always_project translation branch → the zh card test expecting 始终允许此项目 FAILED as expected; restored, re-ran → green.
中文说明

Autofix address-review 总结 — PR #10457

反馈分类

feedback.md 中没有新的发现:没有审查意见、没有行内评论、没有 issue 级评论、没有失败或仍然失败的检查项。由于 --conflict true,本轮的工作是将 origin/main 合并进 PR 分支并解决冲突。

合并:origin/main → feat/dingtalk-permission-cards-10388

origin/main 从该分支上次的合并基点(f8609836)前进到了 56f92c84。出现了 1 处文本冲突和 2 处语义冲突,全部位于权限提示区域——本 PR 与 main 上的 PR #10614("改进权限与错误回退消息")在同一块代码上各自演进:

冲突 1 — formatPermissionRequest(文本冲突)

  • main 将任务/非任务两种消息布局统一为一种格式,新增 Tool: / Action: / Parameters: 上下文行,并展示权限选项自身的标签。
  • 本 PR 通过 PERMISSION_COPY(en/zh)将回退消息文本本地化。

解决方案保留 main 的统一格式,并将所有固定文案经由 PERMISSION_COPY 输出;为 main 新增的几行补充了 request/tool/action/parameters 键(中文翻译为 请求:/工具:/操作:/参数:)。不再使用的 command 键由 action 取代(中文值 操作: 不变,英文更新为 Action:)。

冲突 2 — 重复的 permissionOptionLabel 方法(语义冲突,无法编译)

双方各自添加了签名不同的 permissionOptionLabel 方法:main 的是 (option, fallback) 标签解析器,本 PR 的是用于卡片决策的 (name, kind) 中文标签翻译器。

解决方案保留 main 的方法,并通过新增的 localizedPermissionOptionLabel 使其具备语言感知能力:在 zh 渠道下翻译标准选项名:Allow/Allow once → 仅允许本次,Deny/Reject → 拒绝,以及按选项 id 加标准名前缀匹配的 always-allow 选项 → 始终允许此项目 / 始终允许此用户 / 始终允许。这在 main 的"选项名优先"标签策略下保留了本 PR 的中文本地化,并将其扩展到 Reject 这个标准名——它是 CLI 如今实际下发的名称(见 permissionUtils.ts 中的 basicPermissionOptions),而本 PR 原有的映射表并未覆盖。本 PR 的重复方法被删除;其卡片决策代码改为复用 main 新增的 approvalOption/denialOption 辅助方法。

冲突 3 — 测试辅助函数默认值(语义冲突)

main 修改了共享测试辅助函数 emitPermission 的标准选项名(Allow once→Allow、Deny→Reject)并添加了 _meta.toolName,导致本 PR 侧 3 处基于旧名称/硬编码标签编写的测试期望不再匹配。已将这 3 处期望更新为"名称优先"的行为;本 PR 的所有中文断言保持原样,并在该解决方案下全部通过。

顺带的复用

卡片展示的标题改为复用 main 的 permissionTitle(本地化的 Tool use / 工具调用 回退),不再重复 sanitize/回退逻辑。

main 带入的该区域之外的所有内容(状态卡片网络恢复 #10357、hooks 信任边界修复 #10427、DWS 表情回应 #10610、qwen-live、OpenTUI 第 4 批等)均干净合并,并由下文的各包测试套件验证。

验证

  • npm run build — 通过(exit 0)
  • vitest run src/ChannelBase.test.ts(packages/channels/base)— 641 通过
  • npx vitest run(packages/channels/dingtalk,共 13 个测试文件)— 508 通过
  • vitest run channel CLI 测试(packages/cli/src/commands/channel 下的 daemon-worker、runtime、start、config-utils)— 213 通过(4 个文件)
  • vitest run src/serve/routes/workspace-channel-management.test.ts(packages/cli)— 15 通过
  • npm run typecheck — 通过
  • npm run lint — 通过(提交时还通过了仓库的 pre-commit lint-staged 门禁:对全部暂存文件运行 prettier 与 eslint --fix --max-warnings 0)
  • 变异探针(针对本轮新增的中文本地化层):
    • 禁用 localizedPermissionOptionLabel 中的 locale !== 'zh' 判断 → ChannelBase.test.ts 中的中文权限测试按预期失败(标签回退为英文);恢复后重跑 → 通过。
    • 删除 proceed_always_project 翻译分支 → 期望 始终允许此项目 的中文卡片测试按预期失败;恢复后重跑 → 通过。

Base-conflict check · 基分支冲突检查: conflicted with main — resolved in this push. · 与 main 有冲突——已在本次推送中解决。

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

@BenGuanRan
BenGuanRan marked this pull request as ready for review September 1, 2026 18:27
…gs (QwenLM#10457)

- ChannelBase: translate the stock prefix of scoped always-allow labels
  but keep the command/tool scope suffix (exec/mcp confirmations always
  generate suffixed names, and zh channels dropped them)
- packages/cli: slow-host test budgets now also key on measured host
  saturation (isSlowTestHost), because the review-address verification
  gate re-runs tests through an env -i child that does not carry
  RUNNER_NAME; raise the dev-entrypoint startup budget accordingly
- packages/cli: llm/AppContainer/live-serve tests point HOME at a
  scratch directory; the gate inherits a HOME the test process cannot
  write to, which crashed best-effort ~/.qwen writes

Adds mutation-witnessed tests for permission-card controller guards
(question_id, checkbox payloads, hasBusinessPayload gate, isCancel,
requestId action, per-actor forbidden dedupe) and the zh user-scope
always-allow branch.
@wenshao

wenshao commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

The verification gate rejected the previous commit because
packages/cli/vitest.config.ts is test-config machinery this PR never
touched. Restore that file exactly to its pre-round state; the rest of
the rejected commit (the zh always-allow scope-suffix fix and the
per-file test accommodations) is preserved.

The environmental gate-leg blocker the reverted change tried to work
around — the gate's env -i clean child stripping RUNNER_NAME and
QWEN_HOME while passing through a HOME the tests cannot write to — is
escalated to the maintainer in the round summary instead.
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

Autofix review round — same-run verification repair (PR #10457)

Round summary: this is a same-run verification repair round. The previous
commit was rejected by the deterministic gate because it modified
packages/cli/vitest.config.ts — test-config machinery in an area the PR
itself never touched. Per the repair directive, the rejected commit
(d8d018d8) is preserved and this round adds exactly one follow-up commit
(d4fc363e) that reverts that file to its exact pre-round state. The
environmental gate-leg blocker the reverted change tried to work around is
escalated to the maintainer below (it cannot be fixed inside this PR without a
maintainer decision). No other findings were implemented this round; the
per-round batch is fully consumed by the repair.

Feedback points and decisions

Deterministic rejection — round expands into test-config machinery — FIXED

The gate rejected the previous round with:
"This round modified CI/verification machinery in area(s) the PR itself never
touched: test-config packages/cli/vitest.config.ts. … Revert these files; if
the feedback genuinely requires them, escalate it to a maintainer as an open
question instead of implementing it."

Fixed: packages/cli/vitest.config.ts is restored byte-for-byte to its
pre-round state (git diff 8a96a48a55 -- packages/cli/vitest.config.ts is
empty), so the round diff
(origin/feat/dingtalk-permission-cards-10388...HEAD) no longer contains any
vitest.config.* file and cannot classify as test-config. Everything else
from the preserved commit stays: the zh always-allow scope-suffix fix
(ChannelBase.ts) and the per-file test accommodations in packages/cli test
files (ordinary test code, not machinery).

[Critical] Promoted blocker — gate test legs fail environmentally (rv:5083609366 / ic:5491209528) — ESCALATED, needs a maintainer decision

This finding genuinely requires changes the bot is not allowed to make, so it
is left UNRESOLVED with this open question, per the gate's own directive.

What is proven (evidence in the workdir):

  1. The gate launches its verification through an env -i clean child whose
    allowlist (qwen-autofix.yml) is PATH, HOME, RUNNER_TEMP, WORKDIR, BRANCH,
    GITHUB_OUTPUT, CI, KISS_AUDIT, FOOTPRINT_ENFORCE. RUNNER_NAME and
    QWEN_HOME are stripped.
  2. packages/cli leg without RUNNER_NAME: reproduced pre-fix on the untouched
    tree as Test Files 10 failed | 51 passed (61) (workdir leg-before3.log).
    Failure classes: Test timed out in 15000ms (global testTimeout ceiling),
    Hook timed out in 10000ms, unwritable-HOME EACCES mkdir ~/.qwen, and
    load-contention assertion flakes (host: 64 cores, load 114–207). The
    previous round fixed this via isSlowTestHost() (RUNNER_NAME prefix OR
    loadavg saturation) in packages/cli/vitest.config.ts — the change the
    gate rejected — plus per-file accommodations; the leg then passed 62/62
    files (workdir leg-final.log).
  3. Post-revert recurrence (this round): the gate-shaped packages/cli leg
    surrogate fails again — fast-path-open.test.ts 4 failures with the exact
    pre-fix signature, and the run aborts on a coverage/.tmp ENOENT race
    (workdir repair-cli-leg.log).
  4. New this round — the same environmental class also breaks the
    packages/channels/base leg.
    Gate-shaped surrogate:
    Test Files 1 failed | 1 passed (2), 6 failures, every one
    EACCES: permission denied, mkdir '/home/github-runner/.qwen/channels/…'
    in PairingStore.ensureDir (workdir repair-channels-legs.log). Root cause:
    with QWEN_HOME stripped, getGlobalQwenDir() (packages/channels/base/src/
    paths.ts) falls back to $HOME/.qwen, and the HOME the gate passes through
    is not writable by the test process. The full channels/base suite passes in
    an environment where QWEN_HOME is set (previous round: 1185/1185), so this
    is purely the stripped-env class, not a code defect.
  5. The channels/dingtalk leg passes gate-shaped (5 files, this round).

Why the bot stops here: the durable fixes live in forbidden areas or need a
policy call. Options for the maintainer:

  • (a) Workflow-side env (recommended, smallest): add RUNNER_NAME and
    QWEN_HOME (pointed at a writable scratch dir, e.g. under RUNNER_TEMP) to
    the gate's env -i allowlist in .github/workflows/qwen-autofix.yml. This
    deterministically fixes the EACCES class for every package and restores the
    existing RUNNER_NAME-keyed slow-host budgets where the variable exists —
    zero PR diff growth. (Note: on hosts where RUNNER_NAME is empty entirely,
    the timeout class remains — see (b).)
  • (b) Authorize the test-config accommodation the gate rejected: the
    loadavg-based isSlowTestHost() fallback in the packages' vitest.config.*
    files. It is the only place a worker-count/timeout policy can cover
    saturated non-ECS hosts (per-file vi.setConfig cannot express
    maxWorkers, and the Session.test.ts load-contention failure needs exactly
    that).
  • (c) Direct the bot to harden individual test files (scratch QWEN_HOME in
    the ChannelBase.test.ts pairing tests; per-file timeout budgets in
    ~5 packages/cli files). In-bounds, but spreads changes across large test
    files unrelated to DingTalk permission cards and still cannot fix the
    maxWorkers contention class — not recommended.

Open question for the maintainer: which of (a)/(b)/(c) do you want for the
gate's clean-child environment? Until then this Critical stays unresolved; the
PR carries no machinery changes.

Findings resolved by the preserved commit — re-verified this round (8)

The preserved rejected commit already implemented these; each was re-verified
against the current tree by reading the code (locations in parentheses), and
each stays green with this round's revert (the revert touches none of these
files):

  • R1-33 (rc:3908905696) zh always-allow labels keep the scope suffix —
    localizedScopedAlwaysLabel (ChannelBase.ts:290), tests assert
    始终允许此项目:git status / 始终允许此用户:run_shell_command for card
    labels and the /approve-always fallback (ChannelBase.test.ts:1903-1998).
  • R1-13 (rc:3908905636) proceed_always_user branch witnessed —
    ChannelBase.test.ts user-scope cases incl. the unsuffixed
    始终允许此用户 case.
  • R1-30 (rc:3908905760) question_id pinned —
    permission-card-controller.test.ts:126.
  • R1-20 (rc:3908905747) checkbox-shaped decision values —
    accepts checkbox-shaped decision submissions
    (permission-card-controller.test.ts:423).
  • R1-21 (rc:3908905753) hasBusinessPayload === false gate —
    permission-card-controller.test.ts:461.
  • R1-22 (rc:3908905793) isCancel disjunct — denies through the template built-in cancel flag alone (permission-card-controller.test.ts:485).
  • R1-9 (rc:3908905784) requestId-shaped action — accepts a submit delivered with the requestId action id (permission-card-controller.test.ts:469).
  • R1-32 (rc:3908905801) per-actor forbidden dedupe — intruder-2 case
    (permission-card-controller.test.ts:277).

All eight are listed in resolved-comments.txt.

Deferred to the next round (22 findings)

R1-11, R1-27, R1-34, R1-26, R1-2, R1-28, R1-12, R1-3, R1-5, R1-1, R1-4, R1-14,
R1-19, R1-6, R1-35, R1-15, R1-8, R1-36, R1-31, R1-23, R1-29, R1-37 — all valid,
none declined; this repair round's single-commit scope leaves no room for a
finding batch. Each has a reply in comment-replies.json and its thread stays
open.

Maintainer-deferral comment (ic:5498743239) — no code action possible

Asks for (a) maintainer sign-off on the shared ChannelBase change and (b) one
real DingTalk verification pass. Both are human actions; carried forward for
the maintainer together with the open question above.

Review gap disclosure

The reviewer noted build-and-test — Integration Tests (CLI, No Sandbox) was
skipped in CI and not run locally. Unchanged this round; flagged for the
maintainer alongside the items above.

Conflict notes

None (--conflict false; the branch already contains current main via the
2026-08-31 merge).

Verification

Commands actually run this round, in order:

  • git restore --source=8a96a48a55 -- packages/cli/vitest.config.ts, then
    git diff 8a96a48a55 -- packages/cli/vitest.config.ts — empty (file
    byte-identical to the pre-round state); round diff verified free of any
    vitest.config.* path.
  • npm run build — passed (exit 0).
  • npm run typecheck — passed (exit 0).
  • npm run lint — passed (exit 0).
  • npx prettier --check packages/cli/vitest.config.ts — clean.
  • Focused vitest, packages/cli src/test-utils/slow-test-host.test.ts —
    passed (exit 0); confirms the retained isSlowTestHost module and its
    consumers are unaffected by the revert.
  • Gate-shaped leg surrogate
    env -i PATH HOME CI npm run test --workspace packages/channels/dingtalk --if-present -- --changed origin/main --passWithNoTests
    — passed, 5 files (workdir repair-channels-legs.log).
  • Gate-shaped leg surrogate, same form for packages/channels/base — FAILED,
    environmental: 6 × EACCES mkdir /home/github-runner/.qwen/channels/…
    (QWEN_HOME stripped by the env -i form; full suite is green when QWEN_HOME
    is set). See escalation above (workdir repair-channels-legs.log).
  • Gate-shaped leg surrogate, same form for packages/cli — FAILED,
    environmental: fast-path-open.test.ts 4 failures matching the pre-fix
    signature, run aborted by a coverage/.tmp ENOENT race (workdir
    repair-cli-leg.log). See escalation above.
  • Mutation probes: not applicable — the round's commit is a pure revert adding
    no guard or branch; the reverted guard's witnesses were probed in the
    preserved round and are unaffected by this round.

The two failing legs fail for the stripped-environment classes documented in
the escalation (RUNNER_NAME/QWEN_HOME removal + non-writable passed-through
HOME), not for any code change in this round; the workflow's independent CI
remains the final verification gate.

中文说明

Autofix 评审轮次 — 同轮验证修复(PR #10457)

本轮摘要:这是一轮同轮验证修复。上一个提交被确定性门禁拒绝,原因是它修改了
packages/cli/vitest.config.ts——一个本 PR 自身从未触及的领域的 test-config 机制。
按照修复指令,被拒提交(d8d018d8)予以保留,本轮仅追加一个后续提交
(d4fc363e),把该文件逐字节恢复到轮前状态。被回退改动试图绕过的门禁测试环节
环境性阻塞项,升级给维护者处理(见下,不经维护者决定无法在本 PR 内修复)。
本轮未实施任何其他发现;每轮批次上限已被修复工作占满。

反馈点与决定

确定性拒绝 — 轮次扩展到 test-config 机制 — 已修复

门禁拒绝上一轮的理由是:「本轮修改了 PR 自身从未触及领域的 CI/验证机制:
test-config packages/cli/vitest.config.ts。……请回退这些文件;若反馈确实需要它们,
请作为开放问题升级给维护者,而不是直接实施。」

已修复:packages/cli/vitest.config.ts 已逐字节恢复到轮前状态
(git diff 8a96a48a55 -- packages/cli/vitest.config.ts 为空),因此轮次差异
(origin/feat/dingtalk-permission-cards-10388...HEAD)不再包含任何
vitest.config.* 文件,无法再被归类为 test-config。保留提交中的其余内容全部保留:
zh 始终允许范围后缀修复(ChannelBase.ts)以及 packages/cli 测试文件中的按文件
测试适配(普通测试代码,非机制)。

[Critical] 升级阻塞项 — 门禁测试环节因环境原因失败(rv:5083609366 / ic:5491209528)— 已升级,需要维护者决定

该发现确实需要 bot 不被允许做出的改动,因此按门禁自身的指令保持未解决,
并附以下开放问题。

已证实的事实(证据在工作目录):

  1. 门禁通过 env -i 干净子进程启动验证,其白名单(qwen-autofix.yml)为
    PATH、HOME、RUNNER_TEMP、WORKDIR、BRANCH、GITHUB_OUTPUT、CI、KISS_AUDIT、
    FOOTPRINT_ENFORCE。RUNNER_NAME 与 QWEN_HOME 被剥离。
  2. 缺少 RUNNER_NAME 的 packages/cli 环节:修复前在未改动树上复现为
    Test Files 10 failed | 51 passed (61)(工作目录 leg-before3.log)。
    失败类别:Test timed out in 15000ms(全局 testTimeout 上限)、
    Hook timed out in 10000ms、HOME 不可写的 EACCES mkdir ~/.qwen、
    以及负载争用断言抖动(主机:64 核,负载 114–207)。上一轮通过在
    packages/cli/vitest.config.ts 中引入 isSlowTestHost()(RUNNER_NAME 前缀
    或负载饱和度)修复——即门禁拒绝的改动——外加按文件适配;随后该环节 62/62
    文件通过(工作目录 leg-final.log)。
  3. 回退后复发(本轮): 门禁形态的 packages/cli 环节替身再次失败——
    fast-path-open.test.ts 4 个失败、与修复前特征完全一致,且运行因
    coverage/.tmp ENOENT 争用中止(工作目录 repair-cli-leg.log)。
  4. 本轮新发现——同一环境类别同样破坏 packages/channels/base 环节。
    门禁形态替身:Test Files 1 failed | 1 passed (2),6 个失败,全部是
    PairingStore.ensureDir 中的
    EACCES: permission denied, mkdir '/home/github-runner/.qwen/channels/…'
    (工作目录 repair-channels-legs.log)。根因:QWEN_HOME 被剥离后,
    getGlobalQwenDir()(packages/channels/base/src/paths.ts)回退到
    $HOME/.qwen,而门禁透传的 HOME 对测试进程不可写。channels/base 全套件
    在设置了 QWEN_HOME 的环境中通过(上一轮:1185/1185),因此这纯属
    剥离环境类别,不是代码缺陷。
  5. channels/dingtalk 环节以门禁形态通过(5 个文件,本轮)。

bot 在此停下的原因: 持久性修复位于禁区或需要政策决定。维护者可选项:

  • (a) 工作流侧环境(推荐,最小改动): 在
    .github/workflows/qwen-autofix.yml 门禁 env -i 白名单中加入
    RUNNER_NAME 与 QWEN_HOME(指向可写临时目录,如 RUNNER_TEMP 之下)。
    这确定性地修复所有包的 EACCES 类别,并在该变量存在的地方恢复既有的
    按 RUNNER_NAME 的慢速主机预算——PR 差异零增长。(注意:在 RUNNER_NAME
    完全为空的主机上,超时类别仍在——见 (b)。)
  • (b) 批准门禁拒绝的 test-config 适配: 即在各包的 vitest.config.*
    中使用基于负载的 isSlowTestHost() 回退。这是 worker 数/超时策略能够
    覆盖饱和非 ECS 主机的唯一位置(按文件的 vi.setConfig 无法表达
    maxWorkers,而 Session.test.ts 的负载争用失败恰恰需要它)。
  • (c) 指示 bot 加固单个测试文件(在 ChannelBase.test.ts 配对测试中使用
    临时 QWEN_HOME;在约 5 个 packages/cli 文件中使用按文件超时预算)。
    在允许边界内,但会把改动扩散到与钉钉权限卡片无关的大型测试文件,
    且仍无法修复 maxWorkers 争用类别——不推荐。

给维护者的开放问题: 门禁干净子进程的环境,您选择 (a)/(b)/(c) 中的哪一种?
在此之前该 Critical 保持未解决;PR 不携带任何机制改动。

保留提交已解决的发现 — 本轮重新核对(8 项)

被保留的被拒提交已经实施了这些发现;本轮逐项对照当前代码重新核对
(位置见括号),且它们在本轮回退下保持绿色(回退未触及这些文件中的任何一个):

  • R1-33(rc:3908905696)zh 始终允许标签保留范围后缀——
    localizedScopedAlwaysLabel(ChannelBase.ts:290),测试断言卡片标签与
    /approve-always 回退中的 始终允许此项目:git status /
    始终允许此用户:run_shell_command(ChannelBase.test.ts:1903-1998)。
  • R1-13(rc:3908905636)proceed_always_user 分支有见证——
    ChannelBase.test.ts 用户作用域用例,含无后缀的 始终允许此用户 用例。
  • R1-30(rc:3908905760)question_id 已固定——
    permission-card-controller.test.ts:126。
  • R1-20(rc:3908905747)复选框形态决策值——
    accepts checkbox-shaped decision submissions
    (permission-card-controller.test.ts:423)。
  • R1-21(rc:3908905753)hasBusinessPayload === false 门控——
    permission-card-controller.test.ts:461。
  • R1-22(rc:3908905793)isCancel 析取项——denies through the template built-in cancel flag alone(permission-card-controller.test.ts:485)。
  • R1-9(rc:3908905784)requestId 形态动作——accepts a submit delivered with the requestId action id(permission-card-controller.test.ts:469)。
  • R1-32(rc:3908905801)按演员禁止反馈去重——intruder-2 用例
    (permission-card-controller.test.ts:277)。

八项全部列入 resolved-comments.txt。

顺延至下一轮(22 项发现)

R1-11、R1-27、R1-34、R1-26、R1-2、R1-28、R1-12、R1-3、R1-5、R1-1、R1-4、
R1-14、R1-19、R1-6、R1-35、R1-15、R1-8、R1-36、R1-31、R1-23、R1-29、R1-37
——全部有效,无一拒绝;本修复轮的单提交范围没有给发现批次留出空间。
每项均在 comment-replies.json 中回复,线程保持打开。

维护者转交评论(ic:5498743239)— 无可执行的代码动作

要求(a)维护者对共享 ChannelBase 变更签核,(b)执行一次真实钉钉验证。
两者均为人工动作;与上方开放问题一并转交维护者。

审查缺口披露

审查者注明 build-and-test — Integration Tests (CLI, No Sandbox) 在 CI 中被
跳过且未在本地运行。本轮无变化;与上述事项一并提请维护者注意。

冲突说明

无(--conflict false;分支已通过 2026-08-31 的合并包含当前 main)。

验证

本轮实际执行的命令,按顺序:

  • git restore --source=8a96a48a55 -- packages/cli/vitest.config.ts,随后
    git diff 8a96a48a55 -- packages/cli/vitest.config.ts —— 为空(文件与
    轮前状态逐字节一致);确认轮次差异不含任何 vitest.config.* 路径。
  • npm run build — 通过(exit 0)。
  • npm run typecheck — 通过(exit 0)。
  • npm run lint — 通过(exit 0)。
  • npx prettier --check packages/cli/vitest.config.ts — 干净。
  • 聚焦 vitest,packages/cli src/test-utils/slow-test-host.test.ts —
    通过(exit 0);确认保留的 isSlowTestHost 模块及其消费者不受回退影响。
  • 门禁形态环节替身
    env -i PATH HOME CI npm run test --workspace packages/channels/dingtalk --if-present -- --changed origin/main --passWithNoTests
    — 通过,5 个文件(工作目录 repair-channels-legs.log)。
  • 门禁形态环节替身,packages/channels/base 同形态 — 失败,环境原因:
    6 × EACCES mkdir /home/github-runner/.qwen/channels/…
    (env -i 形态剥离了 QWEN_HOME;设置 QWEN_HOME 时全套件为绿)。
    见上方升级项(工作目录 repair-channels-legs.log)。
  • 门禁形态环节替身,packages/cli 同形态 — 失败,环境原因:
    fast-path-open.test.ts 4 个失败、与修复前特征一致,运行因
    coverage/.tmp ENOENT 争用中止(工作目录 repair-cli-leg.log)。
    见上方升级项。
  • 突变探针:不适用——本轮提交为纯回退,未新增任何守卫或分支;
    被回退守卫的见证已在保留轮中做过探针,且不受本轮影响。

两个失败环节的失败原因是升级项中记录的剥离环境类别(RUNNER_NAME/QWEN_HOME
被移除 + 透传的 HOME 不可写),而非本轮任何代码改动;工作流的独立 CI 仍是
最终验证门禁。

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

🧵 Resolved all 8 selected review thread(s). · 已关闭全部选中的 8 条评审线程。

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

wenshao pushed a commit to wenshao/qwen-code that referenced this pull request Sep 2, 2026
@wenshao

wenshao commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Local end-to-end verification — Linux, full DingTalk stack, no network

I built a real verification environment for this PR and ran the whole feature against it. Verdict: the feature does what the description says, and I found nothing blocking. 40 live end-to-end checks pass with 0 failures, on the current head d4fc363 (and the run was repeated from scratch after the head moved from 8a96a48). Everything below is measured, not read off the diff.

Since the description lists Linux as N/A, this covers that column.

What was actually run

No mocks in the channel path. The unmodified vendor dingtalk-stream-sdk-nodejs DWClient, inside a real qwen serve --channel dingtalk daemon built from each arm's own bundle, talking to a local stand-in for DingTalk:

Piece How it is real
DingTalk open API /etc/hosts points api.dingtalk.com + oapi.dingtalk.com at 127.0.0.1; a self-signed CA (trusted via NODE_EXTRA_CA_CERTS) serves HTTPS on :443 and answers gettoken, gateway/connections/open, card/instances/createAndDeliver, card/instances (PUT), card/streaming, robot/*
Stream channel a real WebSocket gateway pushes SYSTEM CONNECTED/REGISTERED then CALLBACK frames on /v1.0/im/bot/messages/get and /v1.0/card/instances/callback
Model a scripted OpenAI-compatible server that emits a run_shell_command needing approval (touch PERMIT_10457.txt), then a final answer
Oracles the exact HTTP bodies the daemon sent, and whether the tool's file actually appeared on disk

Arms: base = merge-base 56f92c8, PR = d4fc363, plus a third deliberately broken build used as a negative control (below).

before and after

permission card en and zh

The card renderings above are drawn from the exact cardParamMap the daemon put on the wire — they are the harness's approximation of the DingTalk form card, not screenshots of a DingTalk client.

Coverage against the PR's own reviewer test plan

Test-plan step Result
1. One native card instead of permission-command text Card delivered with the question template; no /approve text is sent at all while the card is live
2. Allow once resumes exactly once / deny rejects exactly once Both settle once, exactly one terminal projection each; touch file appears for allow, never appears for deny
3. Persistent choice only when advertised, with the original scope With an exec confirmation: 3 options incl. Always Allow in project: touch *; selecting it makes a second identical call raise no new card. With permissions.ask forcing ask: 2 options, and a hand-crafted allow_always submission on that card is refused — no escalation to a grant that was never offered
4. Cancel + timeout both deny before any delayed update can race a text approval Cancel → cancelled, timeout (timeoutMs: 6000, fired at ~6031 ms) → expired; tool never ran; a late /approve cannot resurrect the expired request; stopping the run from the status card also cancels the pending permission card
5. Other members, malformed, duplicate, stale cannot settle Non-owner callback refused (and notified once, not twice); 6 malformed shapes refused (empty form, unknown value, two values, extra field, wrong field name, no business payload); unknown outTrackId ignored; a terminal card cannot be resubmitted; a non-owner /approve text command is also refused
6. Cards disabled or delivery failing → text fallback Both paths fall back, and the fallback is byte-for-byte identical to main (247 bytes)
7. Language follows the startup default, no session lookup Verified across 6 language settings through both wiring paths (serve --channel daemon worker and channel start); across the whole run the daemon only ever saw /capabilities, /session, /prompt and /permission — no language lookup

e2e results

I also checked the two things most likely to break by accident: a group-chat run (card delivered to IM_GROUP, group-scoped owner-only notice, other members refused), and ask_user_question — it still renders as a question card and still settles through its own controller, so routing permission callbacks first did not swallow it.

Did the tests earn it?

Mutation matrix on the PR's own suites — 8/8 mutants killed: dropping the owner binding, letting terminal cards be re-claimed, dropping the offered-decisions whitelist, accepting extra form fields, reverting the duplicate-response race fix, ignoring the locale option, dropping the underscore normalisation, and reverting the new scope-suffix preservation.

Stronger: I rebuilt a third full bundle with the owner-binding block physically deleted and re-ran the live check. An outsider's callback then settled the permission and the card went approved — so the live check discriminates the guard rather than merely observing green.

Suites, lint, typecheck (on d4fc363, Linux)

ChannelBase.test.ts 645/645 · packages/channels/dingtalk 512/512 · affected CLI suites 174/174 · eslint --max-warnings 0 clean on every changed source file · tsc --noEmit clean for channels/base and channels/dingtalk. (A repo-wide packages/cli typecheck is noisy in my linked worktree because an optional dep is absent — every error is under src/ui/opentui/, none touches a file this PR changes.)

Non-blocking findings

1. The shipped default language never reaches the card. resolveChannelLocale() reads the raw general.language value, but the CLI's own i18n resolves auto through QWEN_CODE_LANG || LANG and lets QWEN_CODE_LANG override the setting entirely. Measured on a live daemon: with language: "auto" (the schema default) and LANG=zh_CN.UTF-8 the permission card is English, while the CLI's own resolver maps that same value to Chinese (resolveSupportedLanguage('zh_CN.UTF-8') === 'zh'); same divergence with QWEN_CODE_LANG=zh and no setting. Since the stated motivation is "avoid showing English permission chrome inside a Chinese Channel experience", a user who never opened the settings dialog does not get the benefit. Reusing resolveLanguageSetting() + detectSystemLanguage() would close it.

2. zh-TW gets Simplified copy. language.startsWith('zh-') maps Traditional Chinese to the zh strings, though the CLI ships zh-TW as a separate locale (its own resolver keeps zh-TW distinct).

3. The conversation is now mixed-language. Under language: "zh" the permission card is Chinese, but the status card (Running · 0s) and the ask_user_question card (Submitted.) are still English. Out of scope for this PR, but the goal is only half reached.

locale matrix

4. A text approval terminalises the card as "expired". If the owner types /approve while the card is live, the request is approved and the tool runs — but the card is projected as expired with "This permission request is no longer available." Accurate about the card, misleading about the outcome. A "resolved elsewhere" wording, or reusing the approved copy, would read better.

5. Upgrade note worth a line in the description. interactiveCards.permissionCard.enabled defaults to true, so every existing deployment that already configured interactiveCards switches from text commands to cards on upgrade with no config change. That is presumably intended, but it is a behaviour change for current users, not an opt-in.

One open question

The permission card reuses the published question template (c2a6355b-…) and introduces two card_status values the question card never emits — approved and denied (the question card only ever sends pending, submitted, expired, cancelled). The design note says "protocol card states such as pending, approved and expired remain unchanged", but approved/denied are new to that template. My harness cannot answer whether the published template renders them as a terminal, non-actionable card — this is exactly the "real delivery with a published template" item you already listed as not validated. Worth one screenshot from a real workspace before merge, or mapping the two new states onto submitted/cancelled.

Housekeeping

The PR is currently CONFLICTING against main — a single file, packages/cli/src/ui/AppContainer.test.tsx.

For the description's "Tested on" table: 🐧 Linux ✅ (this run).

中文说明

本地端到端验证 —— Linux、完整钉钉链路、全程零外网

我为这个 PR 搭了一套真实验证环境,把整个特性跑了一遍。结论:功能与描述一致,未发现阻塞问题。 40 项端到端实测检查全部通过、0 失败,基于当前 head d4fc363(head 从 8a96a48 变更后我重新完整跑了一遍)。以下结论全部来自实测,不是读 diff 得出的。

描述里 Linux 一栏是 N/A,这轮正好补上。

实际跑的是什么

channel 链路上没有任何 mock。未经修改的官方 dingtalk-stream-sdk-nodejs DWClient,跑在由各臂自己的 bundle 构建出来的真实 qwen serve --channel dingtalk daemon 里,对接本地伪造的钉钉服务:

组件 真实之处
钉钉开放接口 /etc/hosts 把 api.dingtalk.com、oapi.dingtalk.com 指向 127.0.0.1;自签 CA(经 NODE_EXTRA_CA_CERTS 信任)在 :443 提供 HTTPS,实现 gettoken、gateway/connections/open、card/instances/createAndDeliver、card/instances(PUT)、card/streaming、robot/*
流式通道 真实 WebSocket 网关先推 SYSTEM CONNECTED/REGISTERED,再推 /v1.0/im/bot/messages/get 与 /v1.0/card/instances/callback 的 CALLBACK 帧
模型 脚本化的 OpenAI 兼容服务,先发一个需要审批的 run_shell_command(touch PERMIT_10457.txt),再给最终回答
判据 daemon 实际发出的 HTTP body 字节,以及工具是否真的在磁盘上落了文件

两臂:base = merge-base 56f92c8,PR = d4fc363,另有一个故意改坏的第三份构建作为反向对照(见下)。

上方卡片截图是按 daemon 实际发出的 cardParamMap 渲染的,属于 harness 对钉钉表单卡片的近似还原,不是钉钉客户端截图。

对照 PR 自带的 Reviewer 测试计划

测试步骤 结果
1. 只出现原生卡片而非权限命令文本 卡片以 question 模板下发;卡片存续期间完全没有发送 /approve 文本
2. 允许一次 / 拒绝各只结算一次 均只结算一次、各自只有一次终态投影;允许时 touch 文件出现,拒绝时始终不出现
3. 仅在提供时展示持久授权,且沿用原始范围 exec 确认下为 3 个选项(含 Always Allow in project: touch *),选中后同一会话第二次相同调用不再产生新卡片;用 permissions.ask 强制 ask 时只有 2 个选项,此时手工构造的 allow_always 提交被拒绝——不能提权到从未提供过的授权
4. 取消与超时都在延迟更新可能与文本批准竞争之前拒绝 取消 → cancelled;超时(timeoutMs: 6000,实测约 6031 ms 触发)→ expired;工具从未执行;迟到的 /approve 无法复活已失效请求;从状态卡停止运行同样会取消待处理权限卡
5. 他人 / 畸形 / 重复 / 过期均不能结算 非发起人回调被拒(且只提醒一次,不重复);6 种畸形载荷被拒(空表单、未知值、两个值、多余字段、字段名错误、无业务载荷);未知 outTrackId 被忽略;终态卡片无法重复提交;非发起人的 /approve 文本命令同样被拒
6. 关闭卡片或投递失败时回退文本 两条路径都回退,且回退文案与 main 逐字节一致(247 字节)
7. 语言取启动时默认值,不做 session 查询 覆盖 6 种语言配置,且两条接线路径(serve --channel daemon worker 与 channel start)都验证过;整轮运行中 daemon 只收到 /capabilities、/session、/prompt、/permission,没有任何语言查询

另外我特意验了两处最容易被顺手改坏的地方:群聊运行(卡片投递到 IM_GROUP、群内发出仅发起人可操作的提示、其他成员被拒),以及 ask_user_question——它仍然渲染为 question 卡片、仍由自己的 controller 结算,说明把权限回调放在路由最前面并没有把它吞掉。

测试本身立得住吗

对 PR 自带用例做变异测试,8/8 变异体全部被杀:去掉发起人绑定、允许终态卡片被重新认领、去掉「仅接受已提供选项」白名单、允许表单夹带多余字段、回退重复响应竞态修复、忽略 locale 选项、去掉下划线归一化、回退新增的授权范围后缀保留。

更强的一层:我另外完整构建了第三份 bundle,把发起人绑定那段代码物理删除后重跑实测——外部用户的回调随即结算了权限、卡片进入 approved。这证明我的实测检查是在鉴别那道守卫,而不只是在观察绿色。

套件、lint、typecheck(d4fc363,Linux)

ChannelBase.test.ts 645/645 · packages/channels/dingtalk 512/512 · 受影响 CLI 套件 174/174 · 所有改动源文件 eslint --max-warnings 0 干净 · channels/base 与 channels/dingtalk tsc --noEmit 干净。(我这套 link 出来的 worktree 里跑全量 packages/cli typecheck 会因缺少一个可选依赖而报错,全部集中在 src/ui/opentui/,没有一条落在本 PR 改动的文件上。)

非阻塞问题

1. 出厂默认语言传不到卡片。 resolveChannelLocale() 读的是 general.language 原始值,而 CLI 自身的 i18n 会通过 QWEN_CODE_LANG || LANG 解析 auto,并且 QWEN_CODE_LANG 优先级高于配置项。实测:language: "auto"(schema 默认值)配合 LANG=zh_CN.UTF-8 时权限卡片是英文,而 CLI 自己的解析器对同一个值给出的是中文(resolveSupportedLanguage('zh_CN.UTF-8') === 'zh');QWEN_CODE_LANG=zh 而未写配置时是同样的分叉。既然动机是「避免中文 Channel 体验里出现英文权限文案」,那么从未打开过设置对话框的用户就享受不到这个改动。复用 resolveLanguageSetting() + detectSystemLanguage() 即可闭合。

2. zh-TW 拿到的是简体文案。 language.startsWith('zh-') 把繁体中文映射到 zh 文案,而 CLI 本身是把 zh-TW 作为独立语言发布的。

3. 会话现在是中英混排。 language: "zh" 下权限卡片是中文,但状态卡(Running · 0s)和 ask_user_question 卡片(Submitted.)仍是英文。这在本 PR 范围之外,但目标只完成了一半。

4. 文本批准会把卡片置为「已失效」。 卡片还在时发起人输入 /approve,请求确实被批准、工具确实执行了,但卡片被投影为 expired,文案是 "This permission request is no longer available."。对卡片而言准确,对结果而言容易误解。改成「已在别处处理」之类的措辞,或复用已授权文案,读起来会更准。

5. 升级影响建议在描述里补一句。 interactiveCards.permissionCard.enabled 默认 true,因此所有已经配置了 interactiveCards 的现网部署,升级后无需改任何配置就会从文本命令切换到卡片。这大概率是有意为之,但对现有用户来说是行为变更,而不是可选开启。

一个待确认的问题

权限卡片复用的是已发布的 question 模板(c2a6355b-…),并引入了两个 question 卡片从不发送的 card_status 取值——approved 与 denied(question 卡片只会发 pending、submitted、expired、cancelled)。设计文档写的是「pending、approved、expired 等协议卡片状态保持不变」,但对这个模板来说 approved/denied 是新增值。我的 harness 无法回答已发布模板是否会把它们渲染成不可再操作的终态——这正好是你已经列为「未验证」的那条「使用已发布模板的真实投递」。建议合入前补一张真实工作区的截图,或者把这两个新状态映射到 submitted/cancelled。

其他

PR 目前对 main 有冲突,只有一个文件:packages/cli/src/ui/AppContainer.test.tsx。

描述里的「Tested on」表格可以补上:🐧 Linux ✅(本轮)。

🤖 Verified with Claude Code — Claude Opus 5 (1M context)

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 AutoFix ran out of time before finishing (idle-timeout (no output for 1200000ms — the sandbox likely hung at startup)) (attempt 5/100) — it will retry on the next scan.

⚠️ This change was NOT pushed — any commit referenced below was made only in the runner workspace and has been discarded. What the agent reported:
Qwen failed during address-review: idle-timeout (no output for 1200000ms — the sandbox likely hung at startup).

See the Qwen Autofix agent step logs for model/tool output.

中文说明

🤖 AutoFix 在完成前耗尽了时间(idle-timeout (no output for 1200000ms — the sandbox likely hung at startup))(第 5/100 次尝试)—— 将在下次扫描时重试。

Run log: https://github.com/QwenLM/qwen-code/actions/runs/33594258815


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

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 AutoFix ran out of time before finishing (timeout (3600000ms)) (attempt 5/100) — it will retry on the next scan.

⚠️ This change was NOT pushed — any commit referenced below was made only in the runner workspace and has been discarded. What the agent reported:
Qwen failed during address-review: timeout (3600000ms).

See the Qwen Autofix agent step logs for model/tool output.

中文说明

🤖 AutoFix 在完成前耗尽了时间(timeout (3600000ms))(第 5/100 次尝试)—— 将在下次扫描时重试。

Run log: https://github.com/QwenLM/qwen-code/actions/runs/33601284863


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

…0457)

A timed-out permission card fired respond('deny') and then suspended on
the terminal card projection, leaving the denial promise without a
handler for the whole window. When the bridge rejected inside it, the
rejection escaped as a process-level unhandledRejection before the
trailing catch ever attached, and Node logged a second
rejectionHandled warning once the late await picked it up.

Attach the handler where the promise is created so the expiry denial is
reported as soon as it fails. The claimed-state assignment stays ahead
of the call, and respond still precedes the projection.
…ion-cards-10388

# Conflicts:
#	packages/cli/src/ui/AppContainer.test.tsx
@BenGuanRan
BenGuanRan dismissed stale reviews from qqqys and wenshao via d77ce7c September 8, 2026 09:19
@BenGuanRan
BenGuanRan requested review from qqqys and wenshao September 8, 2026 09:20
@BenGuanRan

Copy link
Copy Markdown
Collaborator Author

@qqqys @wenshao Current head d77ce7ce535f0d787ed9a8dc5325b23029c47181 is now based on latest main (7dedcbfd71aebb67b8be63fb9e06f7db2cb1605f) and resolves the conflicts introduced when #10504 landed. The resolution preserves both features: permission-card/locale wiring from this PR and dynamic lifecycle displayLanguage wiring from #10504. Fresh combined-tree verification passed: DingTalk 470/470, channel-base 698/698, CLI channel startup 161/161, targeted ESLint, full build, and full typecheck. Auto-merge remains enabled and the automatic exact-head review is queued. Could you please review/approve this current head and, if no current Critical remains, explicitly dismiss the obsolete bot CHANGES_REQUESTED review 5085748831 anchored to the old d4fc... head?

@BenGuanRan

Copy link
Copy Markdown
Collaborator Author

@qqqys @wenshao Updated current head: 04f4e58d0ee2baf83789898d7c3fcc32168ac99e, based on latest main bb79319ce5e0e2bbe64307c50dc3621621afc25c. The prior current-head CI had exactly one deterministic failure: the daemon capability registry had 159 tags while the mainline index still documented 158; upstream #11368 has now fixed that count, and this head includes it. I reproduced the contract failure locally before syncing main and verified the same test green afterward (2/2). Fresh combined-tree evidence is also green: full build/typecheck, DingTalk 470/470, channel-base 698/698, and CLI targeted tests 163/163. Auto-merge remains enabled and exact-head CI/review are running. Could you please review/approve this current head and, if no current Critical remains, explicitly dismiss the obsolete bot CHANGES_REQUESTED review 5085748831 anchored to d4fc...?

@BenGuanRan

Copy link
Copy Markdown
Collaborator Author

@qqqys @wenshao The current head is d20268420e824bf24385d89fab5affb04f2989ef. All current-head CI lanes are green, including the full Linux unit suite, Lint & Static, no-AK integration, web-shell E2E, SDK Java, Live Host, Serve A/B, and TUI parity. There are 0 unresolved Critical threads; the remaining 22 unresolved threads are deferred non-blocking Suggestions. Auto-merge is enabled.

This head only merges main's official formatting fix from #11378; its working tree is identical to the previously tested 99974ec951 head. The current-head automated review is still running. Once it completes without a new Critical, could you please review this exact head and, if comfortable, approve and clear the stale bot Changes Requested review 5085748831 anchored to the old d4fc... head?

@BenGuanRan

Copy link
Copy Markdown
Collaborator Author

@qqqys @wenshao The current-head automated review has completed successfully at d20268420e824bf24385d89fab5affb04f2989ef (review 5144140358). It posted COMMENTED with only Suggestion-level findings: GraphQL now shows 0 unresolved Critical threads and 23 unresolved threads total, all non-blocking Suggestions. The full exact-head check rollup is terminal with 51 pass/skipping and 0 non-passing; gh pr checks --required reports no required checks. Auto-merge remains enabled. Could you please review/approve this exact head and explicitly clear the stale bot Changes Requested review 5085748831 anchored to d4fc... if comfortable?

wenshao added a commit to wenshao/qwen-code that referenced this pull request Sep 8, 2026
@wenshao

wenshao commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

PR 10457 — round 4: full-stack verification of the exact current head d2026842

Verdict: the feature works end to end and I found nothing that should block the merge. 49 container runs of the bundled release CLI against a local, network-isolated DingTalk stack; every permission decision path, every terminal state, both locales, and both fallback paths behave as the description says. An 11-cell mutation matrix run on the same live stack shows the guards this PR adds are load-bearing, and it also disproves the stated consequence of one open Suggestion.

My rounds 1–3 verified heads d4fc363, 0de5699b and the round-3 merge tree. Since round 3 the branch has only merged main — no new PR code — so this round re-verifies the current head and adds coverage the earlier rounds did not have: a live matrix against the 23 open Suggestion threads.


1. What was verified, and that it is the tree that will merge

PR head d20268420e824bf24385d89fab5affb04f2989ef
merge-base with main eb891d6b1cc98f94d6c27d5a75fd06e9765e8b4b
main at verification time 70cf3633950b90c0ddc82b9fa4ee8791d2c373e3 (12 commits ahead of the head's last merge)
git merge-tree --write-tree origin/main <head> 446f460ba6fb507d63f9f09e2cc0aed693e565b7, no conflict

Two checks make the tested artifact the right one:

  • git diff --name-only <merge-base>..origin/main ∩ files this PR touches is empty — none of the 12 new main commits (125 files) go near this PR's surface.
  • git diff --name-only <merge-tree> <head-tree> ∩ files this PR touches is empty — every file this PR touches is byte-identical between the merge result and the tree I built and ran.

Unit suites on the built head, run from each package directory:

suite files tests result
packages/channels/base 21 1361 pass
packages/channels/dingtalk 14 671 pass
packages/cli (src/commands/channel, src/test-utils) 22 410 pass

2. The rig

npm ci && npm run build && npm run bundle, then node dist/cli.js channel start dt inside a docker run --network none container whose /etc/hosts points api.dingtalk.com and oapi.dingtalk.com at 127.0.0.1. The whole surface is served locally over a self-signed CA:

  • HTTPS 443 — oapi/gettoken, v1.0/gateway/connections/open, the three Card OpenAPI endpoints, the proactive robot-message endpoints
  • WS 8899 — the DingTalk Stream gateway, pushing both inbound messages and real /v1.0/card/instances/callback card taps (the published form-template cardPrivateData envelope, not a synthetic shape)
  • HTTP 8080 — sessionWebhook, i.e. the permission text fallback
  • HTTP 8081 — an OpenAI-compatible endpoint that emits real tool_calls

Nothing is stubbed inside the product: the Stream SDK handshake, DingtalkChannel, DingtalkInteractionPresenter, PermissionCardController, the ACP bridge and the tool scheduler all run in their shipped form.

The oracle for "did the tool actually run" is a filesystem sentinel the shell tool appends to — never a log line.

One environment note that matters for reading the results: a channel with no approvalMode set runs in auto mode, where the persistent option is never advertised and in-workspace edits self-approve. The runs below set approvalMode: "default" so the three-way allow-once / allow-always / deny card is reachable at all.


3. Behaviour results

3.1 Before / after

before/after

Same tool call, same channel config. Base eb891d6b sends the permission-command text; the head delivers a native card whose options are exactly the ones the original permission request advertised.

3.2 Locale follows the startup snapshot

locale

general.language: "Chinese" selects the Chinese card chrome; en (and every other value) selects English. Worth calling out because it is the thing commit d8d018d85c was written for: the persistent option keeps its upstream scope suffix in both locales — Always Allow in project: echo * becomes 始终允许此项目:echo *, not a bare 始终允许.

3.3 All four terminal states reached live

terminal states

tap / event card becomes tool ran?
allow_once (zh, 仅允许本次) approved / 已授权。 yes — sentinel written
allow_always on write_file approved yes, and the second identical call raised no second card
deny (zh, 拒绝) denied / 已拒绝授权。 no
card cancel action (user_cancel) cancelled no
controller timeout (timeoutMs: 6000) expired, request denied no
/stop while the card is pending cancelled no

3.4 Owner binding, and hostile callbacks

owner binding

In a shared group session (sessionScope: single, groupPolicy: open) a second group member cannot settle the permission:

  • tapping the card → forbidden, one owner-only feedback message, the second attempt by the same actor is silently ignored (one robot/groupMessages/send total, not two)
  • typing /approve → No pending permission request for this chat., permission untouched
  • the feedback message is locale-correct on both branches: group/zh via robot/groupMessages/send, 1:1/en via robot/oToMessages/batchSend

Malformed and stale submissions from the owner are also rejected without settling: an extra form field, two selected values, an unadvertised value (proceed_once), and an unknown outTrackId all no-op; a replay of the now-terminal card after approval produces no further card update. Only the well-formed submission settles.

3.5 The text path is still there when the card is not

fallbacks

  • permissionCard.enabled: false → the existing /approve, /approve-always, /deny text
  • Card OpenAPI returns HTTP 503 on the create → same text fallback, run not stalled
  • ask_user_question with questionCard.enabled: false and permissionCard.enabled: true → stays a question (/approve Submit, /deny Cancel); it does not degrade into an allow/deny permission card
  • with cards disabled, a non-owner's /approve does still settle in a shared session — the owner binding is scoped to card-presented permissions and does not regress shared-session text answering (verified identical on base)
  • owner answers by text while the card is pending → permission approved, tool runs, card moves to expired

4. Mutation matrix on the live stack

11 mutant bundles, each reversing exactly one behaviour, built by clone-and-patch of the head dist/ (a patch that fails to apply is a hard error, so an unapplied patch cannot masquerade as a survivor). Each was run through the scenario that should notice.

# reverted behaviour thread live result
M1 owner-binding clause in canEnvelopeAnswerPendingPermission R1-1 killed — non-owner's /approve settles the permission and the tool runs (2/2 runs)
M2 isUserQuestion exclusion in tryPresentPermission R1-2 killed — ask_user_question renders as an allow/deny permission card
M3 permissionPresented = false reset on the unsupported path R1-12 killed — shared-session members lose the text /approve
M4 permission-before-question routing narrowed to if (permissionResult) R1-14 killed — the question card's Submit is dropped, card stays pending
M5 locale: this.locale adapter wiring R1-4 killed — Chinese channel renders Permission required / Submit
M10 cardTarget() mapping in presentPermission R1-35 killed — 1:1 card create fails, degrades to text
M7 try/catch around the terminal card update R1-23 killed on the log criterion, but the stated consequence does not reproduce — see below
M6 accepted ? … : 'expired' ternary R1-8 not decided — the window is a sub-millisecond in-process race I cannot drive from the wire
M11 respondToUserInput double-response contract (PR's own change) not decided — same window
M9 state !== 'pending' guard in expire() R1-31 not decided — a genuine timing race; head itself produces both outcomes across repeats (4 runs: 3 × expired, 1 × approved), so this scenario has no discriminating power
M8 per-run scoping of cancelRun R1-37 not exercised — I could not get two concurrent attended runs to both reach a permission prompt; base behaves the same way, so this is not a PR-introduced limit

On R1-23 specifically: the thread says removing the try/catch "turns a card-update API failure during terminal projection into an unhandled promise rejection in the daemon worker". Live, with the Card OpenAPI returning 503 while the timeout fires, the mutant does not crash and does not produce an unhandled rejection — the presenter's projection chain (enqueue's .then(() => undefined, () => undefined)) absorbs it, and the CLI additionally installs a global unhandledRejection handler. The real difference is narrower and purely diagnostic: head logs permission card finalization failed: … HTTP 503, the mutant logs nothing. Still worth keeping the catch, but the severity in that thread overstates it.


5. Two observations, neither blocking

O1 — allow_always on run_shell_command does not suppress the next identical command. This is pre-existing, not this PR. Tapping Always Allow in project: echo * approves the first call, and the second sh -c 'echo …' raises a fresh permission card. I ran the same thing down the text path on both trees: /approve-always on the head with cards disabled and on base eb891d6b produce byte-identical behaviour — first call approved, second call re-prompts. The card faithfully forwards the option id the permission system advertised; whatever does not persist is upstream of this PR. (write_file / Allow All Edits, which is session-scoped, does suppress the second call — verified.)

O2 — a Card OpenAPI outage at settle time leaves a visually stale card. If the terminal PUT /v1.0/card/instances fails, the permission is still settled correctly and the tool still runs, but the card keeps showing pending with Submit enabled. A later tap on it is correctly ignored (the record is gone), so this is cosmetic, and it matches the design doc's "failed card updates never reopen or retry a settled permission". Recording it because an operator seeing a stuck card during a DingTalk incident may reasonably think the run is blocked when it is not.


6. On the 23 open Suggestion threads

Every one I could drive from the wire describes a real, load-bearing guard — M1–M5, M10 all changed observable behaviour when reverted, so the underlying mechanisms work; the threads are about test coverage, not about defects. Nothing in them is a behavioural bug at this head. One (R1-23) overstates its failure mode, as shown above. R1-6's documentation gap (permissionCard missing from docs/users/features/channels/dingtalk.md, default-enabled) is the one I would most like to see landed, and it is a docs-only follow-up.

7. Merge assessment

  • the exact head d2026842 merges cleanly into current main, and no new main commit touches this PR's surface
  • the feature does what the description says, on the real stack, in both locales, with the fallbacks intact
  • the security-relevant properties — owner binding, single settlement, terminal cards not reusable, unadvertised options rejected — all hold live, and their guards are demonstrably load-bearing
  • 2442 unit tests across the three affected packages pass on the built head
  • the open threads are Suggestion-level test-coverage requests, not defects

From my side this is ready to merge. The remaining blocker is the stale CHANGES_REQUESTED review 5085748831 anchored to the old commit d4fc363e, not anything in the code.

Evidence: images, per-run JSON records (every Card OpenAPI request, every cardParamMap, every webhook/robot body, the model-call ledger and the sentinel contents) and the complete harness are on assets-pr10457 at f99d0f0 — imgs/r4-*, out-r4/, harnesses-r4/. The card pictures are the harness's rendering of the captured cardParamMap, not screenshots of a DingTalk client; no real DingTalk credentials or network access were used.

中文说明

PR 10457 — 第 4 轮:对当前 head d2026842 的全栈验证

结论:功能端到端可用,没有发现应当阻塞合并的问题。 用打包后的发行版 CLI 在网络隔离的本地钉钉栈上跑了 49 次容器运行,覆盖全部授权决策路径、全部终态、两种语言、两条回退链路,行为与 PR 描述一致。在同一套真实栈上跑了 11 个变异体,证明本 PR 新增的守卫都是承重的;同时证伪了一条未决 Suggestion 所声称的故障后果。

我在第 1–3 轮分别验证了 d4fc363、0de5699b 以及第 3 轮的合并树。第 3 轮之后分支只合并了 main,没有新的 PR 代码,因此本轮重新验证当前 head,并补上前几轮没有的覆盖面:针对 23 条未决 Suggestion 线程的真机矩阵。

1. 验证对象,以及它就是会被合入的那棵树

PR head d20268420e824bf24385d89fab5affb04f2989ef
与 main 的 merge-base eb891d6b1cc98f94d6c27d5a75fd06e9765e8b4b
验证时的 main 70cf363395(比 head 上次合并领先 12 个提交)
git merge-tree --write-tree origin/main <head> 446f460b,无冲突

两项检查确认测的就是对的产物:

  • git diff --name-only <merge-base>..origin/main 与本 PR 改动文件的交集为空——12 个新 main 提交(125 个文件)都没碰本 PR 的面。
  • git diff --name-only <merge-tree> <head-tree> 与本 PR 改动文件的交集为空——本 PR 改的每个文件在合并结果和我构建运行的树之间逐字节相同。

在构建后的 head 上、从各自包目录运行单测:channels/base 21 文件 1361 例通过;channels/dingtalk 14 文件 671 例通过;cli 的 src/commands/channel + src/test-utils 22 文件 410 例通过。

2. 验证装置

npm ci && npm run build && npm run bundle,然后在 docker run --network none 容器里跑 node dist/cli.js channel start dt,容器 /etc/hosts 把 api.dingtalk.com、oapi.dingtalk.com 指到 127.0.0.1,用自签 CA 在本地提供全部端点:

  • HTTPS 443:oapi/gettoken、v1.0/gateway/connections/open、三个 Card OpenAPI、主动消息端点
  • WS 8899:钉钉 Stream 网关,既推入站消息也推真实的 /v1.0/card/instances/callback 卡片回调(用已发布表单模板的 cardPrivateData 结构,不是自造形状)
  • HTTP 8080:sessionWebhook,即权限文本回退
  • HTTP 8081:OpenAI 兼容端点,吐真实 tool_calls

产品侧没有任何桩:Stream SDK 握手、DingtalkChannel、DingtalkInteractionPresenter、PermissionCardController、ACP bridge 与工具调度器都以发布形态运行。

「工具到底跑没跑」的判据是 shell 工具追加写入的文件哨兵,不是日志行。

一条影响结果解读的环境事实:未设置 approvalMode 的 channel 走 auto 模式,此时持久授权选项根本不会被提供,工作区内的编辑也会自动放行。下面的运行都设了 approvalMode: "default",三选一(仅允许本次 / 始终允许 / 拒绝)的卡片才可达。

3. 行为结果

3.1 改动前后(图 1)同一个工具调用、同一份配置:base eb891d6b 发权限命令文本,head 下发原生卡片,且选项与原始权限请求提供的完全一致。

3.2 语言跟随启动快照(图 2)general.language: "Chinese" 选中文文案,en(及其他值)选英文。特别值得看的是 d8d018d85c 要修的那点:持久选项在两种语言下都保留上游的作用域后缀——Always Allow in project: echo * 变成 始终允许此项目:echo *,而不是光秃秃的 始终允许。

3.3 四个终态全部真机到达(图 3)

操作 / 事件 卡片终态 工具跑了吗
allow_once(仅允许本次) approved / 已授权。 跑了——哨兵写入
write_file 上的 allow_always approved 跑了,且第二次同样调用没有再弹卡片
deny(拒绝) denied / 已拒绝授权。 没跑
卡片取消操作(user_cancel) cancelled 没跑
控制器超时(timeoutMs: 6000) expired,请求被拒 没跑
卡片待处理时发 /stop cancelled 没跑

3.4 发起人绑定与恶意回调(图 4)在共享群会话(sessionScope: single、groupPolicy: open)下,第二个群成员无法结算权限:点卡片得到 forbidden 并收到一条仅发起人可操作的提示,同一人的第二次尝试被静默忽略(全程只有一次 robot/groupMessages/send);发 /approve 得到「No pending permission request for this chat.」,权限不受影响。提示文案两条分支的语言都正确:群聊/中文走 robot/groupMessages/send,单聊/英文走 robot/oToMessages/batchSend。

来自发起人本人的畸形与过期提交同样不会结算:多一个表单字段、勾选两个值、提交未提供的值(proceed_once)、未知 outTrackId 全部无效;批准后再重放这张终态卡片也不产生任何卡片更新。只有格式正确的那次提交生效。

3.5 没有卡片时文本链路仍在(图 5)

  • permissionCard.enabled: false → 仍是现有的 /approve、/approve-always、/deny 文本
  • Card OpenAPI 创建返回 HTTP 503 → 同样的文本回退,运行没有卡死
  • ask_user_question 在 questionCard.enabled: false 且 permissionCard.enabled: true 时仍然是提问(/approve Submit、/deny Cancel),不会退化成允许/拒绝的权限卡片
  • 关闭卡片后,非发起人的 /approve 在共享会话里依然能结算——发起人绑定只作用于以卡片呈现的权限,没有回归共享会话的文本应答(与 base 实测一致)
  • 卡片待处理时发起人用文本批准 → 权限通过、工具运行、卡片转为 expired

4. 真机变异矩阵

11 个变异体,各自只反转一处行为,用克隆 + 打补丁的方式改 head 的 dist/(补丁匹配不上直接报错,所以「补丁没打上」不可能被误读成「变异体存活」)。每个都跑应当察觉它的场景。

# 被反转的行为 对应线程 真机结果
M1 canEnvelopeAnswerPendingPermission 的发起人绑定子句 R1-1 杀死——非发起人 /approve 结算成功且工具运行(2/2 次复现)
M2 tryPresentPermission 的 isUserQuestion 排除 R1-2 杀死——ask_user_question 变成允许/拒绝权限卡片
M3 unsupported 路径上的 permissionPresented = false 复位 R1-12 杀死——共享会话成员失去文本 /approve
M4 权限优先于提问的路由收窄为 if (permissionResult) R1-14 杀死——提问卡片的提交被丢弃,卡片停在 pending
M5 adapter 的 locale: this.locale 接线 R1-4 杀死——中文 channel 渲染出 Permission required / Submit
M10 presentPermission 里的 cardTarget() 映射 R1-35 杀死——单聊卡片创建失败,退化为文本
M7 终态卡片更新外层的 try/catch R1-23 按日志判据杀死,但所声称的后果不复现——见下
M6 accepted ? … : 'expired' 三元 R1-8 未判定——窗口是亚毫秒级的进程内竞态,从链路侧驱动不到
M11 respondToUserInput 的重复响应契约 (PR 自身的改动) 未判定——同一个窗口
M9 expire() 中的 state !== 'pending' 守卫 R1-31 未判定——真实时序竞态,head 自己在重复运行中就会给出两种结果(4 次:3 次 expired、1 次 approved),该场景没有辨别力
M8 cancelRun 的按 run 作用域 R1-37 未触及——无法让两个并发有人值守运行同时到达权限提示;base 表现相同,因此不是本 PR 引入的限制

关于 R1-23: 该线程称去掉 try/catch 会「在 daemon worker 里把卡片更新失败变成未处理的 Promise rejection」。真机上,在超时触发的同时让 Card OpenAPI 返回 503,变异体既没有崩溃也没有产生未处理 rejection——presenter 的投影链(enqueue 的 .then(() => undefined, () => undefined))把它吸收了,而且 CLI 本身还注册了全局 unhandledRejection 处理器。真实差异要窄得多,纯粹是可观测性:head 会打印 permission card finalization failed: … HTTP 503,变异体什么都不打。这个 catch 仍然值得保留,但该线程给出的严重性被高估了。

5. 两条观察,都不阻塞

O1 — 对 run_shell_command 使用 allow_always 不会豁免下一次相同命令;这是既有行为,不是本 PR 引入的。 点「始终允许此项目:echo *」后第一次调用通过,第二次 sh -c 'echo …' 仍会弹出新卡片。我在两棵树上跑了同样场景的文本链路:head(关闭卡片)与 base eb891d6b 的 /approve-always 行为逐条相同——第一次通过、第二次重新询问。卡片忠实转发了权限系统提供的 optionId,不生效的那部分在本 PR 之上游。(write_file 的 Allow All Edits 是会话级的,确实豁免了第二次调用,已实测。)

O2 — 结算时刻 Card OpenAPI 故障会留下一张视觉上过期的卡片。 若终态 PUT /v1.0/card/instances 失败,权限仍正确结算、工具仍会运行,但卡片会停留在 pending 且提交按钮可用。此后再点它会被正确忽略(记录已移除),因此只是观感问题,也符合设计文档「卡片更新失败绝不重开或重试已结算的权限」。之所以记录,是因为钉钉故障期间运维看到一张卡住的卡片,可能误以为运行被阻塞而实际并没有。

6. 关于 23 条未决 Suggestion

凡是我能从链路侧驱动的,描述的都是真实且承重的守卫——M1–M5、M10 反转后都改变了可观测行为,说明底层机制是有效的;这些线程说的是测试覆盖,不是缺陷。在当前 head 上它们都不构成行为缺陷。其中一条(R1-23)夸大了故障形态,如上所述。我最希望落地的是 R1-6 的文档缺口(默认开启的 permissionCard 没有写进 docs/users/features/channels/dingtalk.md),那是一个纯文档的后续项。

7. 合并意见

  • 当前 head d2026842 能干净合入当前 main,且没有任何新 main 提交触碰本 PR 的面
  • 功能在真实栈上、两种语言下、回退链路完好的前提下达到了描述的效果
  • 安全相关性质——发起人绑定、单次结算、终态卡片不可复用、拒绝未提供的选项——真机全部成立,且其守卫可证承重
  • 三个受影响包共 2442 个单测在构建后的 head 上通过
  • 未决线程都是 Suggestion 级别的测试覆盖诉求,不是缺陷

从我这边看可以合并。剩下的阻塞是锚定在旧提交 d4fc363e 上的过期 CHANGES_REQUESTED 评审 5085748831,与代码无关。

证据: 图片、逐次运行的 JSON 记录(每一次 Card OpenAPI 请求、每一份 cardParamMap、每一条 webhook/主动消息正文、模型调用流水与哨兵内容)以及完整装置都在 assets-pr10457 分支的 f99d0f0:imgs/r4-*、out-r4/、harnesses-r4/。卡片图是装置按抓到的 cardParamMap 渲染的,不是钉钉客户端截图;全程未使用真实钉钉凭据,也没有联网。

@BenGuanRan

Copy link
Copy Markdown
Collaborator Author

@wenshao Thank you for the full-stack verification on exact head d20268420e824bf24385d89fab5affb04f2989ef and the explicit ready-to-merge verdict in #10457 (comment). Since the current-head bot review also completed with 0 Critical and all checks are green, could you please submit an APPROVED review on this exact head and dismiss the stale bot Changes Requested review 5085748831 anchored to d4fc363e? Auto-merge is already enabled.

@wenshao

wenshao commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qqqys qqqys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Read-only review pass at head d2026842. My approval on 0de5699b was dismissed by the pushes since, so this is a fresh pass on the new head rather than a carry-over: I re-read the recorded blocker list from the PR's live data, re-verified the one Critical in the code at this commit, and checked what actually moved. The delta is five merges of main plus one formatting-only commit, the security-bearing surface is intact, and I found no Critical. Approving.

What moved since the dismissed approval, measured rather than assumed. Six commits landed: five merges of origin/main and 99974ec9 — style(core): format Anthropic SSE retry test, whose entire content is packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts at +8/-10, one Prettier reflow of a fetch: vi.fn().mockResolvedValue(new Response(...)) member chain with an identical token sequence and no expression change. Comparing blob identities between 0de5699b and this head for the files that carry the risk: permission-card-controller.ts and interactive-card-types.ts are byte-identical, while DingtalkAdapter.ts, ChannelBase.ts and interaction-presenter.ts differ — and they differ because main moved under them, not because this PR's contribution changed, which the PR's own hunks confirm. The diff against main also shrank by one file (30 → 29 changed, +2976/-45), consistent with the formatting having landed on main independently.

R2-1 (the only inline Critical ever filed here — expire() fired respond('deny') with no rejection handler and then suspended on finalize()'s awaited card projection, so a bridge rejection inside that window escaped as a process-level unhandledRejection) is fixed at this head, and I re-read it rather than trusting the blob match. permission-card-controller.ts:215-224 now reads:

private async expire(record: PermissionCardRecord): Promise<void> {
  if (record.state !== 'pending') return;
  this.reserveTerminalProjection(record);
  record.state = 'claimed';
  const response = record.context.respond('deny').catch((error) => {
    this.options.onError?.('expired permission cancellation', error);
  });
  await this.finalize(record, 'expired');
  await response;
}

The handler is attached at creation, the prescribed error label is used, record.state = 'claimed' is still assigned before the respond call (the constraint the report named as load-bearing, since onSettled skips re-finalization only in that state), the respond-before-finalize ordering is kept, and the state !== 'pending' early return makes a repeated expiry a no-op.

The security-bearing wiring survived the merges. Three properties matter for a permission card and I checked each at this head:

  • Identity binding, with the identity not attacker-chosen. claim() still gates on record.context.owner.id !== callback.actorId, returning forbidden with the target on a first offence and ignored via forbiddenActors on a repeat so a stranger cannot spam the group; the actorId still comes from the same verified callback object the adapter already feeds the status and question controllers (const permissionResult = this.permissionCardController?.claim(callback);), not from card form data.
  • At-most-once settlement. The record.state !== 'pending' gate plus record.state = 'claimed' before execute is returned means a second callback, a replay, or a text /approve that already settled the request cannot settle it again, and reserveTerminalProjection runs before the claim returns so a delayed card update cannot race the settlement.
  • The cross-session /approve guard is still in place after main moved the file. pending.permissionPresented is set before presentation (ChannelBase.ts:1137), cleared on both failure paths (:1161, :1170) and gates canEnvelopeAnswerPendingPermission (:3299, also read at :3645) — the line numbers shifted with main's changes, the mechanism did not. This is the guard the maintainer mutation-tested as the only thing stopping a non-initiator in a shared session scope from settling a card-presented permission by typing /approve.

Routing still preserves the stronger result — a non-ignored permission result returns immediately, and the fallthrough is questionCardController?.claim(callback) ?? permissionResult ?? { kind: 'ignored', … }, so a forbidden outcome is not flattened into a generic ignored by the sibling controller — and the controller's onError still writes through sanitizeLogText(String(error), 300), with card-creation failure discarding the record and reporting unsupported so the text-command fallback engages, and the expiry timer unref'd.

The recorded backlog and the rest of the record. The three later rounds are all Suggestions-only, the newest (16:06Z, at this head) confirming twelve already-reported Suggestions and adding one inline Suggestion about the new suite-level home isolation overriding HOME while Storage.getGlobalQwenDir() consults QWEN_HOME first — a test-hygiene point, not a defect in shipped behaviour, and not a gate. The maintainer ran a round-4 full-stack verification of this exact head (49 container runs) with the verdict that the feature works end to end and nothing should block the merge, and submitted an APPROVED review on d2026842 at 03:01Z. The non-blocking items he and the rounds have banked remain worth a follow-up issue rather than another round here: the card defaults to enabled and permissions now auto-deny at timeoutMs where main waits indefinitely, the card carries less context than the text path it replaces, zh-TW receives Simplified copy, a text /approve during a live card executes but projects the card as expired, the card reuses the published question template while emitting two statuses it has never carried, and the permissionPresented guard has no dedicated unit test.

CI at this head. 26 checks pass and 44 are skipped, with nothing failing, cancelled or pending — the full Linux unit suite, Lint & Static, the no-AK integration lane, web-shell E2E, Serve A/B, TUI parity and the visual capture among them. mergeable currently reads UNKNOWN while GitHub recomputes the branch after the merges, and reviewDecision still carries the stale change-request from rounds that have since been superseded; both are states to re-check at merge time rather than findings, and nothing in CI is attributable to a defect introduced by this PR.


中文说明:本次在 head d2026842 上评审,结论为 APPROVE。我先前对 0de5699b 的批准因其后的推送被撤销,因此这是一次针对新 head 的全新复核,而不是沿用旧结论:我从 PR 的实时数据重新读取了阻塞项清单,在当前提交上重新核实了那个唯一的 Critical,并查清了究竟有什么变动。增量是五次 main 合并加一个纯格式化提交,承载风险的面完好,未发现 Critical。自批准被撤销以来究竟动了什么——是测出来的而非假设的。 共六个提交:五次 origin/main 合并,以及 99974ec9(style(core): format Anthropic SSE retry test),其全部内容就是 packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts 的 +8/-10,即一条 fetch: vi.fn().mockResolvedValue(new Response(...)) 成员链的 Prettier 重排,token 序列完全相同、无任何表达式变更。对承载风险的文件比较两个 head 的 blob 标识:permission-card-controller.ts 与 interactive-card-types.ts 逐字节相同,而 DingtalkAdapter.ts、ChannelBase.ts、interaction-presenter.ts 不同——它们不同是因为 main 在其下移动,而不是本 PR 的贡献发生变化,本 PR 自己的 hunk 也证实了这一点。对 main 的 diff 还少了一个文件(改动文件 30 → 29,+2976/-45),与该格式化已独立落入 main 相符。R2-1(本 PR 唯一被提出的 inline Critical——expire() 调用 respond('deny') 时未附加拒绝处理器,随后挂起在 finalize() 已被 await 的卡片投影上,因此该窗口内的一次桥接拒绝会以进程级 unhandledRejection 逸出)在本 head 已修复,且我是重读代码确认的,而非依赖 blob 相同。 permission-card-controller.ts:215-224 现在是:处理器在创建处附加、使用了处方指定的错误标签、record.state = 'claimed' 仍在 respond 调用之前赋值(这是报告点名承重的约束,因为 onSettled 只在该状态下跳过重复终结)、respond→finalize 的顺序保留,且 state !== 'pending' 的提前返回使重复过期成为空操作。承载安全的接线在合并后依然完好。 权限卡片有三条关键性质,我在本 head 逐条核对:身份绑定,且身份不由攻击者选择——claim() 仍以 record.context.owner.id !== callback.actorId 设门,首次违规返回带 target 的 forbidden,同一 actor 再次违规经 forbiddenActors 返回 ignored 以免陌生人刷屏;actorId 仍来自适配器已经喂给状态卡与问题卡的同一个已验证 callback 对象(const permissionResult = this.permissionCardController?.claim(callback);),而非卡片表单数据。至多结算一次——record.state !== 'pending' 的门加上返回 execute 前置 claimed,使第二次回调、重放,或已经结算该请求的文本 /approve 都无法再次结算;reserveTerminalProjection 在 claim 返回前调用,延迟的卡片更新无法与结算竞争。跨会话 /approve 守卫在 main 移动该文件后仍在位——pending.permissionPresented 在展示前置位(ChannelBase.ts:1137),在两条失败路径上清除(:1161、:1170),并在 canEnvelopeAnswerPendingPermission 处设门(:3299,另见 :3645);行号随 main 的改动位移,机制未变。这正是维护者做过变异测试、认定为"在共享会话作用域下唯一阻止非发起人用 /approve 结算已由卡片展示的权限"的那个守卫。路由仍保留更强的结果——非 ignored 的权限结果立即返回,回落链为 questionCardController?.claim(callback) ?? permissionResult ?? { kind: 'ignored', … },因此 forbidden 不会被同级控制器压平成普通的 ignored;控制器的 onError 仍经 sanitizeLogText(String(error), 300) 输出,建卡失败会 discard 记录并返回 unsupported 以回落文本命令,过期计时器已 unref。既有记录与其余部分。 其后三轮均只有 Suggestion,最新一轮(16:06,针对本 head)确认了十二条已报告过的 Suggestion,并新增一条 inline Suggestion,指出新的套件级 home 隔离只覆盖了 HOME,而 Storage.getGlobalQwenDir() 会先读 QWEN_HOME——这是测试卫生问题,不是已发布行为的缺陷,也不构成门禁。维护者对本 head 做了第 4 轮全栈验证(49 次容器运行),结论是特性端到端可用、没有应当阻塞合入的问题,并已于 03:01 对 d2026842 提交 APPROVED。他与各轮记录下的非阻塞项仍宜另开后续 Issue 而非在本 PR 再跑一轮:卡片默认开启且权限现在会在 timeoutMs 自动拒绝(main 是无限等待)、卡片携带的上下文少于它所替代的文本路径、zh-TW 拿到简体文案、卡片存活时的文本 /approve 会执行但把卡片投影为 expired、卡片复用已发布的 question 模板却发出它从未携带过的两个状态,以及 permissionPresented 守卫缺专门单测。CI:本 head 上 26 项通过、44 项跳过,无失败、无取消、无 Pending——其中包含完整的 Linux 单元套件、Lint & Static、no-AK 集成通道、web-shell E2E、Serve A/B、TUI parity 与视觉采集。mergeable 目前显示 UNKNOWN(合并后 GitHub 正在重算分支),reviewDecision 仍带着已被后续轮次取代的过期"要求修改";两者都是合入时需复查的状态而非发现,CI 中也没有可归因于本 PR 缺陷的失败。

@qqqys qqqys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Read-only review pass at head d2026842 — the same head I approved two minutes ago in review 5149338969, and nothing has moved since: no new commit, no new review, no new inline or issue comment. I re-read the blocker list and the fix from the PR's live data rather than carrying the earlier pass over, and the conclusion is unchanged, so this is an approval again. The detailed evidence is in that review; this one records the re-check and the two things that did change.

Live re-read of the recorded blockers. The PR has exactly one Critical in its whole history — R2-1, filed 2026-09-02 against permission-card-controller.ts:222, reporting that expire() fired record.context.respond('deny') with no rejection handler and then suspended on finalize()'s awaited card projection, so a bridge rejection inside that window escaped as a process-level unhandledRejection. At this head the fix is in place and I read it again rather than trusting the earlier pass:

private async expire(record: PermissionCardRecord): Promise<void> {
  if (record.state !== 'pending') return;
  this.reserveTerminalProjection(record);
  record.state = 'claimed';
  const response = record.context.respond('deny').catch((error) => {
    this.options.onError?.('expired permission cancellation', error);
  });
  await this.finalize(record, 'expired');
  await response;
}

The handler is attached at creation, record.state = 'claimed' is still assigned before the respond call — the constraint the report named as load-bearing, since onSettled skips re-finalization only in that state — the respond-before-finalize ordering is kept, and the state !== 'pending' early return makes a repeated expiry a no-op. claim() at :142-150 also still holds the two properties the card's safety rests on: the record.state !== 'pending' gate that makes settlement at-most-once, and the record.context.owner.id !== callback.actorId binding with the forbiddenActors de-escalation, where actorId comes from the same verified callback object the adapter feeds the status and question controllers rather than from card form data.

The latest completed review round (2026-09-08T16:06Z, at this head) is Suggestions-only, and the maintainer submitted an APPROVED review on this exact commit at 03:01Z after a full-stack verification of it.

What did change in the last two minutes. mergeable resolved from UNKNOWN to MERGEABLE, so the branch state after the five merges of main is now computed and clean, and one more check completed: CI at this head is 27 passing and 62 skipped with nothing failing, cancelled or pending.

The one thing still outstanding is not a code matter. reviewDecision still reads CHANGES_REQUESTED from bot rounds that have since been superseded by Suggestions-only passes; two approvals on this exact commit — the maintainer's and mine — do not clear another reviewer's standing request, so that marker has to be dismissed before the branch can merge. The non-blocking items already banked on this PR (the card defaulting to enabled with permissions auto-denying at timeoutMs where main waits indefinitely, the card carrying less context than the text path it replaces, zh-TW receiving Simplified copy, a text /approve during a live card executing while the card projects as expired, the card reusing the published question template while emitting two statuses it has never carried, the permissionPresented guard having no dedicated unit test, and the new suite-level home isolation overriding HOME while Storage.getGlobalQwenDir() reads QWEN_HOME first) are follow-up material, not merge conditions.


中文说明:本次在 head d2026842 上评审,结论为 APPROVE。这与我两分钟前在评审 5149338969 中批准的是同一个 head,其间没有任何变动:无新提交、无新评审、无新的 inline 或 issue 评论。我从 PR 的实时数据重新读取了阻塞项清单与修复代码,而不是沿用上一轮的结论,判断未变,因此再次批准;详细证据在上述评审中,本篇只记录本次复核与确实变化的两点。对已记录阻塞项的实时重读。 本 PR 整个历史上只有一个 Critical——R2-1,于 2026-09-02 针对 permission-card-controller.ts:222 提出,指 expire() 调用 record.context.respond('deny') 时未附加拒绝处理器,随后挂起在 finalize() 已被 await 的卡片投影上,因此该窗口内的一次桥接拒绝会以进程级 unhandledRejection 逸出。在当前 head 上该修复在位,且我是重新读代码确认的:处理器在创建处附加;record.state = 'claimed' 仍在 respond 调用之前赋值(这是报告点名承重的约束,因为 onSettled 只在该状态下跳过重复终结);respond→finalize 的顺序保留;state !== 'pending' 的提前返回使重复过期成为空操作。:142-150 的 claim() 也仍然保有卡片安全性所依赖的两条性质:使结算至多一次的 record.state !== 'pending' 门,以及 record.context.owner.id !== callback.actorId 的身份绑定与 forbiddenActors 降级处理——其中 actorId 来自适配器已经喂给状态卡与问题卡的同一个已验证 callback 对象,而非卡片表单数据。最后一个完成的评审轮次(2026-09-08T16:06,针对本 head)只有 Suggestion,维护者也在全栈验证本提交后于 03:01 对同一 commit 提交了 APPROVED。最近两分钟确实变化的部分。 mergeable 已从 UNKNOWN 解析为 MERGEABLE,因此五次 main 合并之后的分支状态已算出且干净;另有一项检查完成:本 head 的 CI 为 27 项通过、62 项跳过,无失败、无取消、无 Pending。唯一仍未解决的事项不属于代码问题。 reviewDecision 仍显示 CHANGES_REQUESTED,来自此后已被"仅 Suggestion"轮次取代的机器人评审;在同一 commit 上的两份批准——维护者的与我的——都不会清除另一名评审者仍然生效的"要求修改",因此合入前需要先撤销该标记。本 PR 上已记录的非阻塞项(卡片默认开启且权限会在 timeoutMs 自动拒绝、而 main 是无限等待;卡片携带的上下文少于它所替代的文本路径;zh-TW 拿到简体文案;卡片存活时的文本 /approve 会执行但把卡片投影为 expired;卡片复用已发布的 question 模板却发出它从未携带过的两个状态;permissionPresented 守卫缺专门单测;以及新的套件级 home 隔离只覆盖 HOME、而 Storage.getGlobalQwenDir() 会先读 QWEN_HOME)属后续跟进事项,不是合入条件。

@wenshao
wenshao dismissed a stale review September 9, 2026 05:50

fixed

@BenGuanRan
BenGuanRan added this pull request to the merge queue Sep 9, 2026
Merged via the queue into QwenLM:main with commit cf987cd Sep 9, 2026
120 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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.

feat(dingtalk): present tool permission requests with native interactive cards

5 participants