Repository navigation
feat(dingtalk): present tool permission requests with native interactive cards - #10457
Conversation
Co-authored-by: Codex Using gpt-5.6-sol
acf6330 to
779699b
Compare
|
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)为单个提交。 |
|
@qwen-code /takeover |
|
🤝 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 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。本 PR 来自 fork,首轮处理将由下一次定时扫描执行(通常几分钟内)。移除 |
|
🔀 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 将重新运行。 |
|
🤖 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):
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)之后的所有反馈进行分类:
本轮没有提出任何缺陷、建议或问题,因此未做任何代码改动,也没有创建提交。 Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。 🧠 Handled by Qwen Code · model/模型 |
|
🤖 AutoFix ran out of time before finishing (timeout (3600000ms)) (attempt 1/100) — it will retry on the next scan.
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/模型 |
|
🤖 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. Round stopped: the gate's packages/cli test leg fails for environmental reasons this round cannot fix in scopePR #10457 (DingTalk permission cards), same-run verification repair of the BlockerThe verification gate re-runs
All of those are inactive inside the gate. Meanwhile this 64-core host ran at
中文说明🤖 未能为该反馈产生可通过验证的修复(第 2/100 轮)。此项现在需要人工处理;循环保持在线,仍会拾取新反馈与 base 冲突,但不会自行重试此项。 本轮停止:门禁的 packages/cli 测试环节因环境原因失败,本轮无法在范围内修复PR #10457(钉钉权限卡片),针对合并提交 cf8ff569d6 被判"tests failed in 阻塞点验证门禁会以
这些照顾措施在门禁里全部失效。与此同时,这台 64 核宿主机在整个窗口期的
按设计,包测试不参与 A/B 基线对比(run-autofix-review-verification.sh: 证据
Run log: https://github.com/QwenLM/qwen-code/actions/runs/33464526848 🧠 Handled by Qwen Code · model/模型 |
…ion-cards-10388 # Conflicts: # packages/channels/base/src/ChannelBase.ts
|
🤖 Addressed the latest review feedback (round 3/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 3/100 轮)。改动内容与我反驳保留之处如下: Autofix address-review summary — PR #10457Feedback triage
Merge: origin/main → feat/dingtalk-permission-cards-10388
Conflict 1 —
|
…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.
|
@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.
|
🤖 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 Feedback points and decisionsDeterministic rejection — round expands into test-config machinery — FIXEDThe gate rejected the previous round with: Fixed: [Critical] Promoted blocker — gate test legs fail environmentally (rv:5083609366 / ic:5491209528) — ESCALATED, needs a maintainer decisionThis finding genuinely requires changes the bot is not allowed to make, so it What is proven (evidence in the workdir):
Why the bot stops here: the durable fixes live in forbidden areas or need a
Open question for the maintainer: which of (a)/(b)/(c) do you want for the Findings resolved by the preserved commit — re-verified this round (8)The preserved rejected commit already implemented these; each was re-verified
All eight are listed in 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, Maintainer-deferral comment (ic:5498743239) — no code action possibleAsks for (a) maintainer sign-off on the shared ChannelBase change and (b) one Review gap disclosureThe reviewer noted Conflict notesNone ( VerificationCommands actually run this round, in order:
The two failing legs fail for the stripped-environment classes documented in 中文说明Autofix 评审轮次 — 同轮验证修复(PR #10457)本轮摘要:这是一轮同轮验证修复。上一个提交被确定性门禁拒绝,原因是它修改了 反馈点与决定确定性拒绝 — 轮次扩展到 test-config 机制 — 已修复门禁拒绝上一轮的理由是:「本轮修改了 PR 自身从未触及领域的 CI/验证机制: 已修复: [Critical] 升级阻塞项 — 门禁测试环节因环境原因失败(rv:5083609366 / ic:5491209528)— 已升级,需要维护者决定该发现确实需要 bot 不被允许做出的改动,因此按门禁自身的指令保持未解决, 已证实的事实(证据在工作目录):
bot 在此停下的原因: 持久性修复位于禁区或需要政策决定。维护者可选项:
给维护者的开放问题: 门禁干净子进程的环境,您选择 (a)/(b)/(c) 中的哪一种? 保留提交已解决的发现 — 本轮重新核对(8 项)被保留的被拒提交已经实施了这些发现;本轮逐项对照当前代码重新核对
八项全部列入 顺延至下一轮(22 项发现)R1-11、R1-27、R1-34、R1-26、R1-2、R1-28、R1-12、R1-3、R1-5、R1-1、R1-4、 维护者转交评论(ic:5498743239)— 无可执行的代码动作要求(a)维护者对共享 ChannelBase 变更签核,(b)执行一次真实钉钉验证。 审查缺口披露审查者注明 冲突说明无( 验证本轮实际执行的命令,按顺序:
两个失败环节的失败原因是升级项中记录的剥离环境类别(RUNNER_NAME/QWEN_HOME 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/模型 |
Local end-to-end verification — Linux, full DingTalk stack, no networkI 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 Since the description lists Linux as What was actually runNo mocks in the channel path. The unmodified vendor
Arms: base = merge-base The card renderings above are drawn from the exact Coverage against the PR's own reviewer test plan
I also checked the two things most likely to break by accident: a group-chat run (card delivered to 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 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 Suites, lint, typecheck (on
|
| 组件 | 真实之处 |
|---|---|
| 钉钉开放接口 | /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)
|
🤖 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.
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/模型 |
|
🤖 AutoFix ran out of time before finishing (timeout (3600000ms)) (attempt 5/100) — it will retry on the next scan.
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/模型 |
…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
|
@qqqys @wenshao Current head |
|
@qqqys @wenshao Updated current head: |
|
@qqqys @wenshao The current head is This head only merges main's official formatting fix from #11378; its working tree is identical to the previously tested |
|
@qqqys @wenshao The current-head automated review has completed successfully at |
PR 10457 — round 4: full-stack verification of the exact current head
|
| 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 newmaincommits (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/callbackcard taps (the published form-templatecardPrivateDataenvelope, 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
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
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
| 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
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 (onerobot/groupMessages/sendtotal, 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 viarobot/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
permissionCard.enabled: false→ the existing/approve,/approve-always,/denytext- Card OpenAPI returns HTTP 503 on the create → same text fallback, run not stalled
ask_user_questionwithquestionCard.enabled: falseandpermissionCard.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
/approvedoes 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
d2026842merges cleanly into currentmain, and no newmaincommit 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 渲染的,不是钉钉客户端截图;全程未使用真实钉钉凭据,也没有联网。
|
@wenshao Thank you for the full-stack verification on exact head |
|
@qwen-code /triage |
qqqys
left a comment
There was a problem hiding this comment.
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 onrecord.context.owner.id !== callback.actorId, returningforbiddenwith the target on a first offence andignoredviaforbiddenActorson a repeat so a stranger cannot spam the group; theactorIdstill comes from the same verifiedcallbackobject 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 plusrecord.state = 'claimed'beforeexecuteis returned means a second callback, a replay, or a text/approvethat already settled the request cannot settle it again, andreserveTerminalProjectionruns before the claim returns so a delayed card update cannot race the settlement. - The cross-session
/approveguard is still in place aftermainmoved the file.pending.permissionPresentedis set before presentation (ChannelBase.ts:1137), cleared on both failure paths (:1161,:1170) and gatescanEnvelopeAnswerPendingPermission(:3299, also read at:3645) — the line numbers shifted withmain'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
left a comment
There was a problem hiding this comment.
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)属后续跟进事项,不是合入条件。









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.languagesetting 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
/approve, optional/approve-always, and/denytext instructions are delivered.zh_CN, then withenorauto. Confirm permission card chrome, terminal states, owner-only feedback, and fallback copy are Chinese forzh_CNand 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
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
Linked Issues
Closes #10388
中文说明
本 PR 的改动
本 PR 将钉钉中有人值守的工具权限请求展示为原生交互卡片。卡片提供“仅允许一次”“拒绝”,并且仅当原始权限请求明确提供持久授权时才展示该选项。响应会绑定到发起用户和当前运行,同一原始请求最多结算一次,过期、取消、拒绝、允许或超时的卡片都会进入不可再次操作的终态。
当权限卡片被禁用或卡片投递失败时,现有文本命令仍作为回退。Channel 启动时只读取已加载的默认
general.language配置:中文配置使用中文权限文案,其余配置使用英文。daemon 同样使用启动时快照,不调用 session 级语言接口。其他渠道不会获得原生权限卡片,权限策略语义保持不变。为什么需要此改动
钉钉渠道已经使用原生卡片展示任务状态和结构化问题,但普通工具权限仍要求用户手动输入
/approve、/approve-always或/deny。原生卡片让这一安全敏感交互更清晰、更易操作,同时严格保留权限系统提供的原始选项。沿用已有默认语言也可以避免中文 Channel 体验中出现英文权限卡片文案。Reviewer 测试计划
如何验证
/approve、可选/approve-always和/deny文本说明。zh_CN后启动或重载 Channel,再分别使用en或auto。确认权限卡片文案、终态、非发起人反馈和回退文案在zh_CN下为中文,其他配置下为英文,并且不依赖 session 语言查询。证据(改动前后)
改动前:普通钉钉工具权限以文本命令展示,用户必须手动输入命令,并且权限流程没有稳定沿用已配置的默认语言。
改动后:普通的有人值守权限以仅原发起人可操作的原生卡片展示,并且只包含原始权限请求提供的选项;终态卡片无法重复使用,卡片和直接回退文案遵循默认 Channel 语言。由于本地环境没有可用的应用凭证和已发布模板投递证据,因此未附真实钉钉截图。
自动化证据:639 个 ChannelBase 测试、335 个受影响钉钉测试和 171 个受影响 CLI 测试通过。仓库构建、类型检查、lint、目标格式检查和差异检查均通过。评审发现的回调竞态、中文回退标题选择和下划线语言配置归一化问题均已修复并补充回归覆盖;最终两轮复核未发现剩余阻断问题。
测试平台
环境(可选)
macOS 14.4.1、Node.js v25.2.1,本地包测试与 monorepo 校验。未运行真实钉钉投递。
风险与范围
关联 Issue
Closes #10388