fix(approval): harden native approval handling#571
Conversation
将 gap-01 从分析升级为可执行设计:14 项决策(D1-D14)、9 个新模块的职责分工、 Capability SDK 工厂参数、5 个 native runtime sub-adapter 详解、6 类数据流场景 (含 dedupeKey 去重、both-surface 同步、点击解码到上游 resolve、过期/取消/兜底)、 卡片模板字段与五态 mockup(pending / approved / rejected / expired / plugin-DM)、 10 项错误处理矩阵、115+ 测试用例规划、3 阶段实施路径。 按钮 payload 直接复用上游 /approve 命令字面量,与 Telegram 同 pattern; 卡片模板 ID 内置常量、零 env 配置,对齐 D9 用户零部署摩擦原则。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
主要改动(PR#489 对比 + 真机回调 payload 实证驱动): 1. 按钮 payload 编码(D15 新增):三按钮共享 actionId "approval",decision/id 走 cardPrivateData.params 结构化字段。原因——真机回调 (2026-03-21) 证明 DingTalk 会在 actionId 末尾自动追加 button index(btn_approve → btn_approve0), v1 设想的"/approve <id> <decision>" 字符串字面量方案不可行。 2. /approve 命令必须早期 intercept(D2 重写 + 新增 §6.8):PR#489 揭示 Plugin Approval 的 waitDecision 持有 session lock,若 /approve 走正常 inbound dispatch 会 acquireSessionLock 死锁。channel 端必须在入口 regex match 后直接调 resolveApprovalOverGateway,不进 reply 派发。 3. card-callback-service 必须扩展(D16 新增):CardCallbackAnalysis 加 cardPrivateData 字段,analyzeCardCallback 抽出 params。v1 误判此文件不修改。 4. 前置依赖(D17 新增):peerDependency bump 到 openclaw>=2026.4.7;PR#480 (CardBtn[] + sendCardRequest) 必须先合。阶段 0 章节明示。 5. 用户确认 callback userId === staffId(不是 unionId),§4.2 approver schema 不变;§11.2 风险表删除该假设的风险条目。 保留的设计(优于 PR#489 之处): - approver schema + authorizeActorAction(PR#489 完全无权限检查) - finalizeActiveApprovalCardsForAccount(PR#489 停机留鬼按钮) - markdown 兜底 error-recovery - both surface(PR#489 仅 origin,CLI 触发场景无法投递) - ~115 test case 覆盖目标 修订面:D2 重写,新增 D15/D16/D17;新增 §1.2 按钮编码与解码、§6.8 命令早期 intercept、§10 阶段 0 前置依赖;§3.3 / §5.2 / §6.3 / §7.2 / §8 / §9 / §10 阶段 2-3 / §11.2 / §12 全部对齐 v2 状态。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
按用户拍板的"#489 工程骨架 + 我们的目标边界"重新对齐: 采纳 PR#489(D18/D4 v1/D9 修订/D2 强化): - D18 无本地 approval-store——依赖上游 core activeEntries Map 管 pending 生命周期;transport.deliverPending 返回的 entry 由 core 自动带回给 updateEntry,channel 端不复制一份状态 - D4 v1 仅 origin-only(approver-DM 投递推迟到 v2 且 config-gated)。 原因:DM 双投递依赖机器人主动 DM 的企业权限、staffId 可达性、失败 兜底,v1 直接上风险大 - D9 模板 ID 保留 env 覆盖能力——v2 写"纯常量无 env"过于绝对,真机调试 / 私有部署仍需 env 灵活性,与现有 DINGTALK_CARD_TEMPLATE_ID 同模式 - D2 命令早期 intercept 强化为不可妥协的实施约束(session lock 死锁) 保留本设计(D7/§6.6/§9/§10): - approver schema + authorizeActorAction:PR#489 漏掉的安全闭环 - 非 approver 点击拒绝 + 私聊提示 - 测试矩阵 + 分阶段实施 放弃/推迟(D13/v2 future): - finalize-on-stop(依赖 store 故删除) - 默认 origin+DM 双投递 - interactions sub-adapter(v1 仅 4 子 adapter;v2 启用 DM 后再加) 模块层面:删除 approval-store.ts / approval-cancel.ts,新增 approval-command-intercept.ts;从 9 文件减到 8 文件,业务代码 ~1170 行 → ~830 行。测试 ~115 case → ~110 case。 各章节修订面:决策表新增 D18/D19,D4/D9/D13 重写;§3.1 拓扑图、§3.2 模块表、§3.3 接触面、§4.1 SDK 工厂参数表、§4.2 schema、§5 全部 sub- adapter 实现、§6.1 target resolver、§6.2 投递场景、§6.3 click flow、 §6.4 同步流(单卡)、§6.6 边界场景、§6.7 兜底、§7.8 mockup 标 v2、§8 错误矩阵、§9.1/9.3 测试、§10 阶段 1/2 + 新增 v2 future 节、§11.1/11.2、 §12 PR#489 引用同步对齐。 PR#489 当前 CONFLICTING(基于 4 月旧 main),不可直接合并;本设计需基于 当前 v3.6.3 main 重新实现,§12 已注明。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
8 处修订(按 review 顺序):
主要问题:
1. §6.8 /approve early intercept 漏 approver 校验(resolveApprovalOverGateway
只连 gateway 不校验 senderId)。修复:intercept 内必须自调
capability.authorizeActorAction,不通过则给发起者私聊"无权批准"。否则
任何能发 /approve 的人都能批准,与按钮路径形成安全漏洞。
2. §6.3 step 4 不再写死 approvalKind="exec"。改为按
approvalId.startsWith("plugin:") 派发;§11.2 加 unprefixed plugin id
边界风险条目。
3. 顶部 page-subtitle / 总览段 / 核心原则 callout 同步 v3 状态——删除
"origin+DM 两端"、"5 子 adapter 全部实现"、"无环境变量配置"等与 D4/D9/
D18 矛盾的旧表述。
4. §5.1 shouldHandle 收紧为四连判:isConfigured && turnSourceChannel ===
"dingtalk" && turnSourceTo 可解析 && hasApprovers。原"为空也处理"与
v1 origin-only 直接冲突。
5. §5.3 transport 类型对齐:prepareTarget 用 plannedTarget.target.to(不
是裸 plannedTarget.to);deliverPending 明确"transport 内部自闭环 catch
+ fallback + return null",不在 catch 内调 observe.onDeliveryError(那
是 runtime 捕获 throw 后才触发的钩子)。
6. §10 PR-1 措辞修正——原"PR-1 后用户已能在钉钉手敲 /approve 完成审批"
过度承诺;PR-1 无 nativeRuntime/render,approval id 可见性仍依赖外部
界面(WebUI/CLI),完整 UX 在 PR-2。
小修正:
- §6.7 删 store.register(entry) 残留(D18 冲突)
- §6.3 ASCII 把 actionId 形式由旧 "/approve abc allow-once" 改为真机实测
的 cardPrivateData {actionIds:["approval0"], params:{...}} decision
payload 结构
- §6.8 alias 显式列范围(8 个别名 + 两种顺序),不再宣称"完整支持"
- 所有 catch 文案 "⏰ 已过期或已关闭" → "ℹ️ 已处理或已过期"(更准确,
覆盖 already-resolved / not-found / expired 三种情况);§6.3 step 5
catch 内明确 return,避免后续 step 6 用 resolved 终态覆盖
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6 处事实约束 + 1 处架构重构: 事实约束: 1. 5→4 sub-adapter 残留全部清理(页眉决策表 D1、§3.1 拓扑图、§3.2 模块表) 2. §8 错误矩阵 createAndDeliver 失败行改"transport 自闭环 log + fallback", 与 §5.3 deliverPending 的"内部 catch + return null"语义一致;不再宣称 调 observe.onDeliveryError(那是 runtime 捕获 throw 才触发的钩子) 3. §6.8 /approve unauthorized 分支明确用 sendProactiveTextOrMarkdown (channel 直接路径),禁用 dispatchReplyText——后者会经过 SDK reply dispatcher 重新触发 session lock,正是 early intercept 要避免的坑 5. PR #480 状态从"必须先合并"改为"已合并 (MERGED) 需复用契约" 6. D17 + 阶段 0 SDK 基线扩成"三件套":peerDep + pnpm-lock.yaml + tsconfig path 优先级;说明 node_modules/openclaw 当前仍 2026.3.28 type-check 会 卡住的具体原因 架构重构(统一 resolver 抽象): 4. 新增 D20/D21 两条决策,引入两个新模块: - approval-resolver.ts:单点收敛 kind 推导 / authorize / allowPluginFallback / resolveMethod / resolveApprovalOverGateway 调用 / 错误分类 - approval-command-parser.ts:纯解析 /approve 两种顺序 + 8 alias,含上游 commands-approve.ts alias 对照测试 - approval-callback-handler.ts 与 approval-command-intercept.ts 都收敛到 调 resolver.resolveApproval;权限策略 / plugin fallback / 错误文案不分叉 - kind 推导按上游 Slack/Telegram allowPluginFallback 经验: plugin: 前缀 → resolveMethod plugin; 无前缀 + 两边授权 → resolveMethod exec + allowPluginFallback true; 无前缀 + 仅 plugin → resolveMethod plugin; 无前缀 + 仅 exec → resolveMethod exec; 都未授权 → unauthorized 各章节修订面:决策表新增 D20/D21;§3.1 拓扑图加 resolver/parser;§3.2 模块表 加两行 + 重写 callback-handler/command-intercept;§6.3 step 3-7 全部收敛到 resolver;§6.8 inbound 内联代码大幅瘦身(薄壳调 intercept→parser→resolver) + 新增 intercept 内部伪代码;§9.1 测试加 resolver/parser 两个文件 + intercept 独立 test,case 数从 ~110 升到 ~135;§10 阶段 1 强调 resolver 在 PR-1 就建好。 模块数:8 → 9;业务代码 ~830 行 → ~970 行(多 ~140 行换来跨路径行为一致 + 单点修改 + 测试聚焦)。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
用户拍板的架构 pivot——卡片模式下不要新建独立审批卡片(太突兀), 按钮挂到 agent 正在 stream 的那张 AI Card 上;markdown 模式走 /approve 文字命令路径。 D 级决策变化: - D9 删除"新建 approval 专用模板",复用 PR #480 已合并的 AI Card v2 + CardBtn[] 能力。移除 BUILTIN_APPROVAL_CARD_TEMPLATE_ID 常量、env、 card-template-approval-v1.json 源、阶段 0 模板上传任务。 - D10 路由策略改为按 card-run-registry 实际状态决定(不读 messageType config),cover 所有 runtime 降级场景:messageType=card 但 createAndDeliver 失败、plugin approval 无 reply card、approval 来得太晚 card 已 FINISHED 等。 - D14 终态展示改为"对原 agent card 做字段级 patch",agent card 自身状态机 不被 approval 触碰。 - D18 entry 结构改为 { approvalId, accountId, mode: "card"|"markdown", outTrackId?: string }(outTrackId 仅 card 路径有值,指向原 agent card)。 - D22 新增:agent-card-coalesce 模型(按钮注入原 card 的 patch 模型)。 - D23 新增:approval 期间隐藏 btn_stop,resolved 后恢复(让用户继续可中止 agent)。 模块变化: - 删除 approval-card-template.ts - 新增 approval-card-locator.ts(按 sessionKey 查 card-run-registry,决定 card vs markdown 路由) - approval-card-render.ts → approval-card-patcher.ts(在原 agent card 上做 按钮注入/清理 patch,而非完整卡片渲染) - approval-fallback-render.ts → approval-markdown-render.ts(markdown 路径 主路径,不再叫 fallback) 数据流变化: - §5.3 transport.prepareTarget 内嵌 card-locator 查询,输出 route 字段 - transport.deliverPending 按 route 分支:card → patcher PUT 更新 agent card; markdown → sendProactiveTextOrMarkdown 发独立消息 - transport.updateEntry 按 entry.mode 分支:card 用 patcher 改原 card; markdown no-op(钉钉消息不能 edit) - §6.2 投递场景重写为 5 个:群聊 card / 私聊 card / 配置 card 但降级 / plugin approval 无 card / CLI 触发 turnSource 非 dingtalk - §6.4 同步流改为按 entry.mode 分支 - §6.7 失败处理重写:card 失败不降级 markdown(避免双消息) §7 卡片设计: - 7.1 改为"复用 AI Card v2 字段映射",含终态指示位三候选(contentKey append / blockList system block / 新增 approvalStatus) - 7.3 加双状态机协同说明(approval lifecycle vs card lifecycle) - 7.4-7.8 旧 mockup 加 v3.3 重画提醒,PR-2 真机回归补新 mockup 阶段调整: - 阶段 0 删除模板上传任务,替换为 AI Card v2 字段能力确认 - 阶段 1 增加 approval-card-locator 实现(markdown 路径也需 locator 返 null) - 阶段 2 渲染层改用 patcher + markdown-render 测试: - 新增 approval-card-locator.test.ts、approval-card-patcher.test.ts、 approval-markdown-render.test.ts - 删除 approval-card-template.test.ts、approval-fallback-render.test.ts 模块从 9 → 10;业务代码 ~970 → ~900 行(patch 比 render 简单); 测试代码预计 ~1800 行。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
7 处修订(核心是失败降级策略):
1. 【失败降级策略修订】card patch 明确失败时降级到 markdown,而非 v3.3 的
"静默 pending"。理由:v3.3 选择不降级会导致用户连 approval id 都看不到
无法用 /approve 命令兜底,等于双重失败。分两种亚态:
- 明确失败(HTTP 4xx/5xx 已收到 / 模板字段不支持 / agent card 已 FINISHED)
→ 降级 markdown,entry.mode 最终为 "markdown"
- 模糊失败(请求超时但可能已成功 / 网络中断)→ return null 不重发,避免
"实际成功 + 重发" 双消息
markdown 路径失败不再降级(本来就是最低保障)
2. 错误矩阵 / §6.7 / §9.3 多处 createAndDeliver 失败 fallback / approval-card
模板未发布等过时表述统一清理,对齐 v3.3 删模板 + v3.4 降级策略
3. card-run-registry 当前无 sessionKey 查询 API(核实
src/card/card-run-registry.ts:91-145 只有 outTrackId / conversation /
owner 查询)。spec 明确新增 resolveActiveCardRunBySession(accountId,
sessionKey),含 state ∈ {PROCESSING, INPUTING} 过滤。§3.3 接触面表加
card-run-registry 改动面。
4. /approve alias 补全。核实上游
openclaw/src/auto-reply/reply/commands-approve.ts:19-30 实际 10 个 alias
(v3.1 漏列 allowonce 和 allowalways 两个无连字符形式)。channel 端 regex
完整支持 10 个 alias × 2 种顺序 = 20 个合法 case。测试 case 数 16 → 20;
CI 断言改为"上游 alias 集合 = channel 端 regex 覆盖集"强相等。
5. 删除 resolveApproveCommandBehavior 字段提及。核实上游工厂
createApproverRestrictedNativeApprovalCapability(openclaw/src/plugin-sdk/
approval-delivery-helpers.ts:30)参数集不接受该字段;且 channel 端
/approve 走早期 intercept 不依赖上游 dispatcher,本字段无意义。
6. CardBtn 类型来源修订。核实当前 main 的 src/card/card-template.ts:12 仅暴露
template key 常量,PR #480 引入了 ButtonGroup 资产但没暴露 TS 类型。
approval 模块内自定义 CardBtn 接口(text/color/status/event 四字段),
PR-2 实施时附 fixture 验证 v2 模板 btns key 契约。
7. §1.1 / §3.3 等多处 v3.2 残留同步:
- 删"不为 markdown 模式做主路径文案"条目(v3.3 起 markdown 就是主路径)
- reply-strategy 关系澄清:文件本身仍不动,但 approval card patch 会通过
card-run-registry 找到 reply-strategy-card 创建的 active card
各章节修订面:决策表无新增 D;§1.1 删一条;§3.2 模块表 card-locator/patcher
描述补 card-run-registry API 依赖 + CardBtn 类型来源说明;§3.3 加 card-run-
registry 改动面;§4.1 通过 §10 PR-1 条目澄清 resolveApproveCommandBehavior
不需要;§5.3 deliverPending 加 card 失败降级分支;§6.7 重写失败处理;§8 错误
矩阵 createAndDeliver/markdown 分行重列;§9.3 测试场景换成"明确失败降级 +
模糊失败不降级"两个对照 case。
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
用户在 DingTalk 开发者平台实际配置 v2 模板后揭示 5 处事实修订: 1. 模板新增两个变量(用户实测确认): - approve_btns (按钮组,template 内置 3 按钮定义) - show_approve_btns (Boolean,按钮组可见性条件) 2. 三按钮独立命名 actionId,与 OpenClaw 语义对齐: - allow-once / allow-always / deny 重要修正:DingTalk 仅在 actionId 重名时才加 button index 后缀(消歧用)。 独立命名时原样回传,无后缀。 之前 v3.2 看到的 "approve0/1/2" 是因为 3 按钮都叫 approve 触发的消歧。 3. 按钮在 v2 模板内置("按钮组来源:指定"),channel 不再构造 CardBtn[]: - approval-card-patcher 大幅简化(从 ~150 行降到 ~80 行) - approval 模块不再需要自定义 CardBtn 类型(v3.4 的关心点失效) - 三按钮的 actionId / 颜色 / 回传参数都固化在模板里 4. 回传参数仅 params.action(值 = decision),不带 approval id: - 必须通过 outTrackId 反查 approval id 5. 新增 D24 approval id 反查机制(用户拍板 A 方案): - 给 CardRunRecord 加 pendingApprovalId?: string 字段 - 新增 markCardRunPendingApproval / clearCardRunPendingApproval 两个 API - patch 时 set,callback 反查,resolved/expired 时 clear - 不引入独立 approval-store,与 D18 边界一致 - 一卡只能挂 1 个 approval(pendingApprovalId 单字段,agent 串行约束) 各章节修订面: - D15 大改(按钮独立命名 + 无后缀 + params.action) - 新增 D24(approvalId 反查机制) - D23 实施细节修订(show_approve_btns 与 hasAction 是独立变量) - §1.2 button payload 编码/解码完整重写,对齐实测 schema - §3.2 approval-card-patcher 简化,加 v1 终态展示限制说明 - §3.3 card-run-registry 改动面加 D24 字段与 API;types 行删 CardBtn - §5.2 buildPendingPayload 删 buttons 输出 - §5.3 deliverPending(card) 简化为 toggle 变量 + 注册反查映射 - §6.3 click flow 重写解码 + 加反查步骤 - §7.1 模板字段映射改成对照用户实配 schema + v1 终态展示限制坦率说明 - §7.2 按钮配置重写为"template 内置 + channel 仅 toggle" 模块结构:保持 10 个文件,但 approval-card-patcher 简化 ~70 行。 v3.5 业务代码 ~830 行(v3.4 ~900,简化 ~70);测试代码 ~1700 行。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
用户重审 approval 完整流程时发现 v3.5 改动遗漏了 §6 数据流里多处描述: 1. §6.2 场景 A deliverPending 描述还在 PUT btns + hasAction:"true"(v3.3 按钮 channel 端构造模型)。v3.5 应该是: - PUT show_approve_btns="true" + hasAction="false" - markCardRunPendingApproval(outTrackId, approvalId) 按钮在 v2 模板内置,channel 不传按钮定义。 2. §6.4 同步流 applyResolvedPatch 描述还在"清按钮 + 写终态指示" (依赖 statusFooter 之类的字段)。v3.5 应该是: - PUT show_approve_btns="false" + hasAction=cardStillActive - clearCardRunPendingApproval(outTrackId) 明确说明 v1 不写终态文字(用户感知"按钮消失=已处理",§7.1 v1 限制)。 3. §6.5 表头改为 "card 按钮 actionId / params.action / 等价命令 / agent 行为"四列,体现 v3.5 三按钮独立 actionId + params.action 编码。 4. §6.6 重复点击:重写为 D24 反查模型 - 先查 pendingApprovalId - 命中 → 调 resolver → already-resolved catch → PUT show_approve_btns=false - 未命中 → 直接 PUT show_approve_btns=false 不调上游 5. §6.6 重启后点旧卡片:no-pending-approval 路径 - 反查 null → PUT show_approve_btns=false return - 不调上游 resolve(无 approvalId 可传) - 上游 approval 由 TTL 兜底 6. §6.6 上游过期事件触达:PUT 字段同步成 show_approve_btns + hasAction + clearCardRunPendingApproval,不再用旧的 status/btns:"[]" 整体语义对齐用户描述的两条路径: - markdown 路径:发文字消息引导用户敲 /approve <id> <decision> - card 路径:approve_btns 模板内置,patch 时 show_approve_btns=true 显示 按钮 → 等审批 → resolved 时 show_approve_btns=false 隐藏按钮 → agent 继续执行任务 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
提交用户实配后从开发者平台导出的 AI Card v3.0.0 schema 完整低代码 JSON 到 docs/assets/card-template-v3.json(267KB),含: - approve_btns 按钮组定义(template 内置 3 按钮:allow-once / allow-always / deny,actionId + params 全部固化) - show_approve_btns Boolean 变量(按钮组可见性条件) - 现有 v2 字段保留(content / blockList / quoteContent / hasAction / statusLine / copy_content) spec 修订: - §3.1 ASCII 拓扑加资产引用 - §7.1 加 docs/assets/card-template-v3.json 锚点链接 - §10 阶段 0 把"v2 模板能力确认"替换为"v3 schema 部署任务"——发布到 open-dev、拿 templateId、写到 card-template.ts(替代 vs 并存由 PR-2 实施时定)、真机回归 show_approve_btns toggle Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
第六轮 review 反馈,1 个结构性修订 + 4 处实施 blocker:
(2) D24 重构(结构性):v3.5 用 card-run-registry 内存反查 approvalId 的
方案受重启 / 多 worker / TTL 影响——registry 是进程内 Map,
src/card/card-run-registry.ts:1-9 明确多进程约束。v3.6 改为:
- v3 模板再加 approval_id 字符串变量
- 三按钮 params 加 id 参数绑定到 approval_id 变量
- channel 端 PUT 时一起设 approval_id="<id>"
- callback 自带 params.id 为主链路
- registry pendingApprovalId 字段降级为 fallback(应对老卡片/异常)
(3) §10 阶段 0 措辞收紧:v3 模板"必须替换"默认 templateId(不再写"替代
还是并存")。v3 是 v2 字段超集向后兼容,并存边界不清。
(4) §3.3 加 src/card-service.ts 改动面:
- createAICard 必须显式 show_approve_btns:"false" + approval_id:""
- finalize / stop / 错误路径同样确保 false
- 否则 v3 模板默认值会让 agent reply 一上线就显示未绑 approval 的按钮
(5) §10 阶段 2 把 src/card-callback-service.ts 扩展明确标为 BLOCKER:
- 当前接口只暴露 actionId,approval handler 拿不到 params.action /
params.id
- 必须扩展 CardCallbackAnalysis 加 cardPrivateData 字段才能工作
(6) v3.3 残留清理:
- 顶部核心原则段 "复用 CardBtn[] 能力" → 改为 v3 模板字段超集
- D9 "复用 PR #480 CardBtn" → 改为 v3 模板字段超集
- D22 "btns: JSON.stringify(approvalButtons)" → 改为 show_approve_btns
+ approval_id 三变量 toggle
- 错误矩阵 "actionIds[0] 不是 approval 前缀" → 改为不在
{allow-once, allow-always, deny}
- §12 "实施时复用 CardBtn 类型" → 改为按 v3 模板新字段实施
数据流一致性修订(同步 D24 v3.6 主链路):
- §1.2 解码段:先读 params.id(主),缺失再 fallback 到 registry
- §3.2 patcher 描述:applyPendingPatch 也写 approval_id 变量
- §6.2 场景 A:PUT 命令加 approval_id 字段
- §6.3 ASCII 流:payload params 含 id 字段
- §6.6 重复点击描述:主链路读 params.id,缺失 fallback 查 registry
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
用户回去给 v3 schema 实际加了 approveId 变量(截图证实)+ 在 deny 按钮配了 第 2 个回传参数(参数名=approveId, 参数类型=变量, 参数值=approveId)。 命名修正: - 变量名是 approveId(camelCase),不是 v3.6 spec 假设的 approval_id - 回传参数名也是 approveId(v3.6 spec 假设 id) - schema id 已从 876de.schema 变到 05061.schema 修订面: - 全局批量替换 approval_id → approveId,params.id → params.approveId - D24 按钮 params 描述细化为"参数名=approveId, 类型=变量, 值=approveId" - §10 阶段 0 描述同步用户实际进展: - 用户已配齐 approveId 变量 ✓ - 待重新 export 更新 docs/assets/card-template-v3.json - 待发布 v3 schema 拿正式 templateId - 待替换 src/card/card-template.ts 默认 templateId - §1.2 解码片段:cpd.params?.id → cpd.params?.approveId - §6.2 / §6.3 callback params 结构同步 隐式假设:用户截图仅展示 deny 按钮配齐 approveId 参数;spec 假设 allow-once / allow-always 也同样配齐(用户应在 PR-2 前置阶段确认 其它两按钮也配了相同 params2)。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
用户已在开发者平台发布 v3 schema,拿到正式 templateId: 58f73932-fc3b-46ae-8e90-93313e405061.schema spec 修订: - D9 行:把这个 templateId 作为权威值列出,明确 src/card/card-template.ts:6 的 BUILTIN_DINGTALK_CARD_TEMPLATE_ID 默认值需替换为该 v3 ID - §10 阶段 0 任务:把 "拿到正式 templateId" 改为 "用户已完成 ✓,正式 templateId = 58f73932-...05061.schema",并显式给出新旧 ID 对照(旧 v2 675cde2f-... 替换为新 v3 58f73932-...) PR-2 实施时实现层只需做: - 替换 src/card/card-template.ts:6 默认 templateId 常量 - createAICard 调用时 cardParamMap 显式带 show_approve_btns:"false" + approveId:"" (D24 v3.6) - 扩展 src/card-callback-service.ts CardCallbackAnalysis 加 cardPrivateData 字段 (D16 BLOCKER) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
用户重新 export 的最新 v3 schema(268KB,approve-release_1779121432897.json) 覆盖 docs/assets/card-template-v3.json。 新版相对于之前提交的差异(用户实配进展): - 新增 approveId 字符串变量(D24 v3.6 主链路载体) - 三按钮(allow-once / allow-always / deny)回传参数 2 都加了 approveId 绑定(参数名=approveId, 类型=变量, 值=approveId) - 用户确认三按钮均配齐,spec 删除 PR-2 前置 "其它两按钮也配齐" 的 caveat PR-2 实施前置 BLOCKER 状态更新: - ✓ approveId 变量已加 - ✓ 三按钮 params 已绑定 - ✓ v3 模板已发布(templateId 58f73932-fc3b-46ae-8e90-93313e405061.schema) - ✓ JSON 资产已同步 - ⏳ 待 PR-2 实施:替换 src/card/card-template.ts:6 默认值 + 扩 card-callback-service.ts CardCallbackAnalysis + 改 card-service.ts createAICard 显式带 show_approve_btns/approveId Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
第八轮 review 反馈,全部聚焦实施层一致性:
(1) D24 主链路全局收敛:清理 §1.2 / D15 / §6.3 / §3.1 等多处 v3.5 老语义
残留 "approvalId 不在 payload 必须 outTrackId 反查",统一为
"params.approveId 主链路 + registry pendingApprovalId fallback"。
params.id → params.approveId(v3.7 已部分修,v3.8 收尾)。
(2) v1 limitation 备注(不改实现):上游 ExecApprovalRequest /
PluginApprovalRequest 的 allowedDecisions 字段允许 per-request 限制可选
decision(ask=always 时 exec 只允许 allow-once+deny)。v3 模板固定 3
按钮全显示,点击不支持的 decision 会被上游拒绝并刷"已处理或已过期"。
§11.1 加 limitation 条目,指明未来若要支持需 v3 模板加 3 个 boolean
变量分别控制按钮显示 + channel 端按 allowedDecisions 设变量。
(3) markdown 路径强制 forceMarkdown:true:核实 src/send-service.ts:371-393,
messageType=card 配置下 sendProactiveTextOrMarkdown 默认走
sendProactiveCardText 把 markdown 包成 AI Card。D10 / §6.2 场景 B / §6.8
intercept unauthorized 提示等所有调用点改成显式传 { forceMarkdown:true,
accountId, log }。
(4) sub-adapter 数量描述统一:上游类型实际 3 必需(availability/
presentation/transport)+ 2 可选(interactions?/observe?)。v1 实现 4 个
= 3 必需 + observe,省略 interactions。§5 标题从 "5 个 Sub-Adapter
详解" 改为 "Sub-Adapter 详解(v1 实现 4 个)",正文同步。
(5) entry.state → record.card?.state:核实 src/card/card-run-registry.ts:13
+ src/types.ts:689——CardRunRecord 顶层无 state 字段,state 在
record.card?.state。spec 多处 entry.state ∈ {PROCESSING, INPUTING} 改
成 record.card?.state ∈ {PROCESSING, INPUTING};建议加 helper
isActiveCardRun(record) 封装。
(6) /approve intercept 插入点精确化:spec 之前 "command dispatch 之后、
reply 派发之前" 模糊。v3.8 明确为 "access control + content extract +
senderId 确定之后;早于本地 command dispatch (src/inbound-handler.ts:874)
+ 早于 sub-agent targeted command 递归 + 早于 acquireSessionLock
(src/inbound-handler.ts:2053)"。否则 @agent /approve 被 sub-agent 吞、
或 session lock 死锁。
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sync the OpenClaw peer and host manifest baseline to >=2026.4.7, align zod with the installed SDK package, and update message tool discovery to the current presentation capability contract. BREAKING CHANGE: openclaw peer baseline is now 2026.4.7+.
Add DingTalk execApprovals types and strict schema, and preserve the field through the default-account resolveDingTalkAccount path.
Add normalized approver config helpers and the DingTalk /approve parser aligned with the upstream approve command aliases.
Reuse the OpenClaw native origin-target helper and keep DingTalk-specific user/group target normalization local.
Add the DingTalk approval resolver, authorization method derivation, and gateway error classification. Exec resolution follows the current OpenClaw SDK contract by omitting resolveMethod.
Add approval-oriented active card helpers using the real DingTalk AI card status constants and the existing in-process card-run registry.
Add a narrow session-based card locator over the card-run registry for native approval presentation routing.
Add the early /approve command handler that parses decisions, resolves approvals through the gateway resolver, and sends Markdown-only direct hints for actionable failures.
Configure the OpenClaw approver-restricted native approval capability for DingTalk origin-only delivery without nativeRuntime yet.
Attach DingTalk approvalCapability and intercept /approve messages before routing/session-lock dispatch so approval resolution cannot deadlock behind the waiting agent turn.
The previous baseline `>=2026.4.7` was a false floor: this PR's code uses
three upstream SDK APIs that landed after 2026.4.7. Aligning the declared
peer floor with the actual minimum compatible version, and pinning the
locked install via `pnpm.overrides` so a fresh `pnpm install` resolves to
that floor instead of drifting to whichever 2026.5.x is latest on npm.
Real minimum derived from API availability:
- `"presentation"` channel-message capability — added in 2026.4.21
(openclaw `fd0970c077 refactor(channels): decouple presentation rendering`)
- `createChannelNativeOriginTargetResolver({ normalizeTarget })` option —
added in 2026.4.27 (`e27c32b9b0 refactor(plugin-sdk): publish route helpers`)
- `PluginApprovalRequestPayload.allowedDecisions` field — added in 2026.5.7
(`f011d6bc0a Fix repeated Codex native approval prompts after allow-always`)
max(4.21, 4.27, 5.7) = **2026.5.7**.
Changes:
- `package.json`: `peerDependencies.openclaw`, `openclaw.compat.pluginApi`,
`openclaw.build.openclawVersion`, `openclaw.install.minHostVersion` all
raised to `2026.5.7`; added `pnpm.overrides.openclaw: "2026.5.7"`.
- `pnpm-lock.yaml`: installed `openclaw@2026.5.7` (was 2026.5.18 on the
earlier commit `44afa9a`, which let the lockfile drift past the declared
floor).
- `tests/unit/plugin-manifest.test.ts` + `tests/unit/sdk-import-structure.test.ts`:
hard-coded `2026.4.7` strings synced to `2026.5.7`.
Note: this commit does not yet make CI green. `openclaw@2026.5.x` pulls
`undici@8.x`, which declares `engines.node: ">=22.19.0"` and uses
`webidl.util.markAsUncloneable`; CI is still on Node 20. Bumping the CI
workflow Node version is a follow-up.
`openclaw@2026.5.7` (this PR's pinned baseline) pulls `undici@8.x`, which declares `engines.node: ">=22.19.0"` and uses `webidl.util.markAsUncloneable` — a Node 21.3+ API. The previous `node-version: 20` therefore fails 28 test files with `TypeError: webidl.util.markAsUncloneable is not a function` at undici CacheStorage construction. Node 22 aligns CI with the upstream openclaw `engines.node: ">=22.16.0"` floor and satisfies undici 8.x's stricter `>=22.19.0` (setup-node@v4 with `node-version: 22` currently resolves to 22.20+). `npm-publish.yml` and `clawhub-publish.yml` are already on Node 24, so they were unaffected. `docs-vercel.yml` is left on Node 20 because its build path does not import undici and currently passes; a separate cleanup can unify it.
Implements plan Task 21 (previously deferred). Creates
`tests/integration/approval-end-to-end.test.ts` covering the 12 scenarios
spec §9.3 + plan §Task 21 enumerate:
21a · Multi-approver race — 1st wins, 2nd APPROVAL_ALREADY_RESOLVED still
re-PATCHes the card to cleared state
21b · Self-approval in DM — approver clicks own request, no private hint
21c · Non-approver click — private hint, no gateway call, no card PUT
21d · Upstream expired event — transport.updateEntry phase=expired drives
applyExpiredPatch
21e · Card PUT explicit failure (HTTP 400) — downgrade to markdown delivery
21f · Card PUT ambiguous failure (ETIMEDOUT) — return null, no markdown
duplication
21g · /approve command — bypasses session lock, calls gateway directly
21h · No approvers configured — availability.shouldHandle=false
21i · turnSourceChannel != dingtalk (CLI trigger) — shouldHandle=false
21j · Channel restart, click old button — APPROVAL_NOT_FOUND →
applyExpiredPatch writes only the three template variables (no
terminal status text)
21k · Exec invalid-decision (APPROVAL_ALLOW_ALWAYS_UNAVAILABLE) — no PUT,
private hint asks to re-pick
21l · Plugin invalid-decision (allowedDecisions=['allow-once']) — no PUT,
private hint includes allowed list
Plan deviation: original plan called for one commit per sub-task
(21a–21l TDD red→green); since the source is already implemented, this
lands as a single coverage-completion commit. `mocks` are held via
`vi.hoisted` so `getAccessToken` returns the mocked token reliably for
the callback-handler's static-imported helper (the previous
`vi.fn().mockResolvedValue(...)` inside a factory raced with the
production import and returned `undefined`).
Plan file updated: Task 21 status DEFERRED → DONE with the deviation
documented inline.
- card-service: add approvalParamsForTerminal helper consulting card-run-registry.pendingApprovalId; commitAICardBlocks and finalizeStoppedAICard skip clearing show_approve_btns/approveId while an approval is in flight, letting approval-card-patcher remain the single writer of those keys until applyResolvedPatch / applyExpiredPatch fires. - approval-card-locator: emit a debug log when the active card is busy with a different pendingApprovalId so the markdown fallback path is observable from runtime logs. - approval-command-parser: export APPROVE_COMMAND_RE; intercept and inbound-handler share the single regex source instead of three inline duplicates. - card-service.test: two regression cases covering terminal-clear and pending-preserve branches.
Release notes belong in the dedicated release prep PR, not the feature PR. Revert package.json to 3.6.3, point latest.md back to v3.6.3.md, and remove v3.6.4.md.
Greptile Summary本 PR 实现了 GAP-01:将 DingTalk channel 接入 OpenClaw 原生审批能力(exec approval + plugin approval),新增
Confidence Score: 4/5整体改动结构清晰、测试覆盖充分,存在一处卡片投递时 token 获取失败不会触发 markdown 降级的问题,修复后可安全合入。
src/approval/approval-native-runtime.ts 的
|
| Filename | Overview |
|---|---|
| src/approval/approval-native-runtime.ts | 新增 DingTalk 审批 native runtime 四个 sub-adapter;deliverPending 中 getAccessToken 位于 try 块外,HTTP 4xx token 错误会跳过 markdown 降级路径直接抛出 |
| src/approval/approval-resolver.ts | 新增审批 resolve 核心逻辑;鉴权门正确置于 plugin: 前缀判断之前,gateway error 分类完整,无明显问题 |
| src/approval/approval-callback-handler.ts | 按钮回调入口,先 resolve 再 best-effort patch 卡片,gateway-error 保留 pending 语义,逻辑清晰 |
| src/approval/approval-command-intercept.ts | /approve 命令拦截;APPROVE_COMMAND_RE 正斜杠可选问题已在上轮 review 讨论,本次无新问题 |
| src/approval/approval-card-locator.ts | 正确拒绝已有不同 pendingApprovalId 的 active card,并发场景下自动降级 markdown,逻辑正确 |
| src/card-service.ts | 三处(createAICard/commit/finalizeStopped)写入 APPROVAL_CARD_INITIAL,approvalParamsForTerminal 在 pending 时返回 {} 避免覆盖,设计合理 |
| src/gateway/channel-gateway.ts | 在 TOPIC_CARD listener 中先调 tryHandleApprovalCallback,handled=true 提前返回,stop button 等普通回调正常穿透,无问题 |
| src/inbound-handler.ts | 在 session lock 前早期拦截 /approve 命令,符合绕过 session lock 的设计意图 |
| src/card/card-run-registry.ts | 新增 isActiveCardRun/resolveActiveCardRunBySession/markCardRunPendingApproval/clearCardRunPendingApproval,复用现有 card 状态,无独立 store 引入 |
| tests/integration/approval-end-to-end.test.ts | 12 个端到端场景覆盖多 approver 竞争/非 approver/expired/markdown 降级等,但未覆盖 getAccessToken 在 deliverPending 中抛出的场景 |
Sequence Diagram
sequenceDiagram
participant SDK as OpenClaw SDK
participant Runtime as DingTalkApprovalNativeRuntime
participant Locator as approval-card-locator
participant Patcher as approval-card-patcher
participant DT as DingTalk API
SDK->>Runtime: availability.shouldHandle()
Runtime-->>SDK: true/false
SDK->>Runtime: transport.prepareTarget()
Runtime->>Locator: findActiveAgentCard(sessionKey, approvalId)
Locator-->>Runtime: card outTrackId 或 null
Runtime-->>SDK: "target{route:card|markdown}"
SDK->>Runtime: transport.deliverPending()
alt "route = card"
Runtime->>DT: getAccessToken() 在 try 块外
Runtime->>Patcher: applyPendingPatch(outTrackId, approvalId)
Patcher->>DT: "updateCardVariables(show_approve_btns=true)"
alt HTTP 4xx
Runtime->>DT: sendProactiveTextOrMarkdown(markdown)
end
else "route = markdown"
Runtime->>DT: sendProactiveTextOrMarkdown(markdown)
end
note over DT: 用户点击审批按钮
DT->>Gateway: TOPIC_CARD callback
Gateway->>CallbackHandler: tryHandleApprovalCallback()
CallbackHandler->>Resolver: resolveApproval(approvalId, decision, senderId)
Resolver->>SDK: resolveApprovalOverGateway()
SDK-->>Resolver: ok / error
Resolver-->>CallbackHandler: ResolverResult
CallbackHandler->>Patcher: patchCardBestEffort(applyResolvedPatch)
Patcher->>DT: "updateCardVariables(show_approve_btns=false)"
note over DT: 用户发送 /approve 命令
DT->>InboundHandler: handleDingTalkMessage()
InboundHandler->>Intercept: tryInterceptApproveCommand() session lock 前
Intercept->>Resolver: resolveApproval(approvalId, decision, senderId)
Resolver->>SDK: resolveApprovalOverGateway()
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 1
src/approval/approval-native-runtime.ts:153-173
`deliverPending` 中 `getAccessToken` 调用在 `try` 块之外:若 token 获取因 HTTP 4xx 失败,`shouldFallbackToMarkdown` 本可返回 `true` 并走 markdown 兜底,但由于异常在 `try` 之前抛出,catch 分支不会执行,markdown 降级路径被完全绕过,上游直接收到异常,审批通知静默丢失。将 `getAccessToken` 移入 `try` 块可在卡片接口 4xx 故障时正确触发 markdown 降级。
```suggestion
if (preparedTarget.route === "card" && preparedTarget.activeCardOutTrackId) {
try {
const token = await getAccessToken(dtConfig, log);
await applyPendingPatch(
preparedTarget.activeCardOutTrackId,
pendingPayload.approvalId,
token,
dtConfig,
);
return {
mode: "card",
approvalId: pendingPayload.approvalId,
accountId: preparedTarget.accountId,
outTrackId: preparedTarget.activeCardOutTrackId,
};
} catch (error) {
if (!shouldFallbackToMarkdown(error)) {
return null;
}
}
}
```
Reviews (2): Last reviewed commit: "fix(approval): declare execApprovals in ..." | Re-trigger Greptile
| updateEntry: async ({ cfg, entry, payload, phase }) => { | ||
| if (entry.mode !== "card") { | ||
| return; | ||
| } | ||
| const dtConfig = getConfig(cfg, entry.accountId); | ||
| const log = getLogger(entry.accountId); | ||
| const token = await getAccessToken(dtConfig, log); | ||
| const record = resolveCardRun(entry.outTrackId); | ||
| const cardStillActive = record ? isActiveCardRun(record) : false; | ||
| if (phase === "resolved" && payload.phase === "resolved") { | ||
| await applyResolvedPatch( | ||
| entry.outTrackId, | ||
| payload.decision, | ||
| token, | ||
| cardStillActive, | ||
| dtConfig, | ||
| ); | ||
| return; | ||
| } | ||
| await applyExpiredPatch(entry.outTrackId, token, cardStillActive, dtConfig); | ||
| }, |
There was a problem hiding this comment.
updateEntry 中 getAccessToken、applyResolvedPatch 或 applyExpiredPatch 抛出异常时,错误会直接向上游 SDK 传播,没有任何 catch 处理。这与 approval-callback-handler.ts 中对同类操作使用 patchCardBestEffort(吞掉失败、仅打日志)的设计模式不一致。若 token 获取或卡片 patch 在审批已经通过后失败,上游可能无法区分是"卡片更新失败"还是"审批本身失败",影响可观测性和重试语义。建议在 updateEntry 内部加 try/catch,捕获后仅记录 warn 日志,保持 best-effort 语义与其他路径对齐。
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/approval/approval-native-runtime.ts
Line: 190-210
Comment:
`updateEntry` 中 `getAccessToken`、`applyResolvedPatch` 或 `applyExpiredPatch` 抛出异常时,错误会直接向上游 SDK 传播,没有任何 catch 处理。这与 `approval-callback-handler.ts` 中对同类操作使用 `patchCardBestEffort`(吞掉失败、仅打日志)的设计模式不一致。若 token 获取或卡片 patch 在审批已经通过后失败,上游可能无法区分是"卡片更新失败"还是"审批本身失败",影响可观测性和重试语义。建议在 `updateEntry` 内部加 `try/catch`,捕获后仅记录 warn 日志,保持 best-effort 语义与其他路径对齐。
How can I resolve this? If you propose a fix, please make it concise.The plugin manifest's channelConfigs.dingtalk.schema is the source of truth for openclaw's host-side strict config validation (config validate, config set --strict-json, gateway restart). It is hand-maintained and must mirror the runtime zod schema in src/config-schema.ts. This PR added execApprovals to the runtime zod schema (and config.ts resolve) but missed the static manifest, so host-side validation rejected any execApprovals key as "additional property" while still accepting all legacy fields. gateway start was lenient enough to mask the gap; gateway restart and config set --strict-json surfaced it. Mirror execApprovals at all three manifest sites that other fields use: - channelConfigs.dingtalk.schema.properties.execApprovals - channelConfigs.dingtalk.schema.properties.accounts.additionalProperties.properties.execApprovals - channelConfigs.dingtalk.uiHints.execApprovals
| if (preparedTarget.route === "card" && preparedTarget.activeCardOutTrackId) { | ||
| const token = await getAccessToken(dtConfig, log); | ||
| try { | ||
| await applyPendingPatch( | ||
| preparedTarget.activeCardOutTrackId, | ||
| pendingPayload.approvalId, | ||
| token, | ||
| dtConfig, | ||
| ); | ||
| return { | ||
| mode: "card", | ||
| approvalId: pendingPayload.approvalId, | ||
| accountId: preparedTarget.accountId, | ||
| outTrackId: preparedTarget.activeCardOutTrackId, | ||
| }; | ||
| } catch (error) { | ||
| if (!shouldFallbackToMarkdown(error)) { | ||
| return null; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
deliverPending 中 getAccessToken 调用在 try 块之外:若 token 获取因 HTTP 4xx 失败,shouldFallbackToMarkdown 本可返回 true 并走 markdown 兜底,但由于异常在 try 之前抛出,catch 分支不会执行,markdown 降级路径被完全绕过,上游直接收到异常,审批通知静默丢失。将 getAccessToken 移入 try 块可在卡片接口 4xx 故障时正确触发 markdown 降级。
| if (preparedTarget.route === "card" && preparedTarget.activeCardOutTrackId) { | |
| const token = await getAccessToken(dtConfig, log); | |
| try { | |
| await applyPendingPatch( | |
| preparedTarget.activeCardOutTrackId, | |
| pendingPayload.approvalId, | |
| token, | |
| dtConfig, | |
| ); | |
| return { | |
| mode: "card", | |
| approvalId: pendingPayload.approvalId, | |
| accountId: preparedTarget.accountId, | |
| outTrackId: preparedTarget.activeCardOutTrackId, | |
| }; | |
| } catch (error) { | |
| if (!shouldFallbackToMarkdown(error)) { | |
| return null; | |
| } | |
| } | |
| } | |
| if (preparedTarget.route === "card" && preparedTarget.activeCardOutTrackId) { | |
| try { | |
| const token = await getAccessToken(dtConfig, log); | |
| await applyPendingPatch( | |
| preparedTarget.activeCardOutTrackId, | |
| pendingPayload.approvalId, | |
| token, | |
| dtConfig, | |
| ); | |
| return { | |
| mode: "card", | |
| approvalId: pendingPayload.approvalId, | |
| accountId: preparedTarget.accountId, | |
| outTrackId: preparedTarget.activeCardOutTrackId, | |
| }; | |
| } catch (error) { | |
| if (!shouldFallbackToMarkdown(error)) { | |
| return null; | |
| } | |
| } | |
| } |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/approval/approval-native-runtime.ts
Line: 153-173
Comment:
`deliverPending` 中 `getAccessToken` 调用在 `try` 块之外:若 token 获取因 HTTP 4xx 失败,`shouldFallbackToMarkdown` 本可返回 `true` 并走 markdown 兜底,但由于异常在 `try` 之前抛出,catch 分支不会执行,markdown 降级路径被完全绕过,上游直接收到异常,审批通知静默丢失。将 `getAccessToken` 移入 `try` 块可在卡片接口 4xx 故障时正确触发 markdown 降级。
```suggestion
if (preparedTarget.route === "card" && preparedTarget.activeCardOutTrackId) {
try {
const token = await getAccessToken(dtConfig, log);
await applyPendingPatch(
preparedTarget.activeCardOutTrackId,
pendingPayload.approvalId,
token,
dtConfig,
);
return {
mode: "card",
approvalId: pendingPayload.approvalId,
accountId: preparedTarget.accountId,
outTrackId: preparedTarget.activeCardOutTrackId,
};
} catch (error) {
if (!shouldFallbackToMarkdown(error)) {
return null;
}
}
}
```
How can I resolve this? If you propose a fix, please make it concise.Real-device validation showed no [DingTalk][Approval] log lines fired, indicating our nativeRuntime adapters never engaged. Add explicit INFO logs at each decision point so subsequent reproduction tells us exactly where the chain breaks (e.g. shouldHandle skipped, prepareTarget chose markdown route, etc.). - availability.shouldHandle: log accept/skip with reason (native-delivery-disabled / turnSourceChannel-mismatch / missing-turnSourceTo / no-approvers) - presentation.buildPendingPayload: log approval id + kind on entry - transport.prepareTarget: log route=card|markdown with outTrackId or no-active-card sessionKey context INFO-level so they remain useful as long-term observability.
Real-device diagnostics showed our nativeRuntime adapters (shouldHandle / buildPendingPayload / prepareTarget) never fired even with execApprovals.approvers + approvals.exec.enabled set. Root cause: upstream's startChannelApprovalHandlerBootstrap subscribes via watchChannelRuntimeContexts and waits for a registration of CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY on the channel runtime. Bundled channels (telegram/discord/slack/matrix/qqbot) all call registerChannelRuntimeContext from their monitor startup. The DingTalk plugin lacked this call entirely, so the bootstrap waited indefinitely and Path 2 (native card-button delivery) never engaged. Add the registration to startAccount, mirroring telegram's pattern: - Skip when execApprovals.isNativeDeliveryEnabled is false to avoid spurious bootstrap on accounts that do not opt in. - Skip when ctx.channelRuntime is undefined (no-op guard, the SDK field is optional). - Pass abortSignal so context is released on account stop/restart. - Log INFO whether registration happened or was skipped so we can verify from gateway logs. The bootstrap will now engage on the next gateway restart and the nativeRuntime adapters will receive approval events.
User reported buttons disappear shortly after appearing. Timeline:
applyPendingPatch (T) -> agent stream -> Card finalized state=FINISHED
(T+8s) -> buttons gone. Need to know whether approvalParamsForTerminal
returned preserve ({}) or clear (APPROVAL_CARD_INITIAL) at finalize.
Add INFO log inside approvalParamsForTerminal recording outTrackId,
the resolved pendingApprovalId, and the chosen action (preserve|clear).
DingTalk hard constraint: action buttons (including the v3 template's approve_btns ButtonGroup) only render while the card is in PROCESSING/INPUTING. flowStatus=3 (FINISHED) hides every action button regardless of show_approve_btns. Real-device verification showed the three approval buttons appearing for ~8s and then vanishing the moment commitAICardBlocks ran with the agent's "approval required" tool result. Defer the finalize until the approval resolves or expires: - card-run-registry: new deferredFinalize flag with mark/clear helpers. - card-service.commitAICardBlocks: when pendingApprovalId is set, omit flowStatus=3 and APPROVAL_CARD_INITIAL from the PUT, skip the local state=FINISHED + removePendingCard transition, and mark the run deferredFinalize so the patcher can complete it later. - approval-card-patcher.applyResolvedPatch / applyExpiredPatch: when the run is flagged deferredFinalize, append flowStatus=3 to the PUT, transition the in-memory card.state to FINISHED, and clear both the pendingApprovalId and deferredFinalize flags. - inbound-handler: hold the card-run record past the reply finally block while pendingApprovalId or deferredFinalize is set so the patcher can still resolve it. 30-minute TTL sweep handles abandons. - tests/approval-card-patcher: cover the deferred-finalize branch (flowStatus=3 added) and the in-memory card.state transition. If the approver never responds upstream emits an expired event after the 30-minute TTL; applyExpiredPatch then finalizes the deferred card, so cards do not hang indefinitely.
Buttons still disappeared after the previous defer-finalize fix because commitAICardBlocks calls finalizeAICardStreamingLifecycleIfNeeded before the deferred-aware updateCardVariables. That helper PUTs /v1.0/card/streaming with isFinalize=true, closing DingTalk's streaming lifecycle. The DingTalk client treats that lifecycle close as another "card finished" signal independent of flowStatus, and hides every action button (including approve_btns) regardless of show_approve_btns. - commitAICardBlocks: when approvalPending, also skip finalizeAICardStreamingLifecycleIfNeeded so the card's streaming lifecycle stays open. - finalizeAICardStreamingLifecycleIfNeeded: export so the approval patcher can close the lifecycle from the deferred-finalize completion path. - approval-card-patcher.completeDeferredFinalize: now async; call finalizeAICardStreamingLifecycleIfNeeded on the in-memory card before transitioning state to FINISHED so DingTalk sees both flowStatus=3 and isFinalize=true at resolve/expire time.
Real-device validation surfaced three classes of issue beyond the initial defer-finalize patch; address them together so the buttons stay visible, terminal patches complete cleanly, and the existing process-block markdown bug no longer leaks raw <font> tags whenever an approval text contains code fences. CR-1 narrow deferred-finalize ownership to card-service - New export completeDeferredAICardFinalize(outTrackId, log) owns the full deferred-finalize completion: close DingTalk streaming lifecycle, transition the in-memory card.state to FINISHED, drop the pending-card persistence row, clear deferredFinalize, and remove the card-run registry entry. approval-card-patcher delegates to it rather than mutating record.card directly. CR-2 close pending-mark / commit race - applyPendingPatch now markCardRunPendingApproval BEFORE the PUT so any commitAICardBlocks that fires concurrently sees the pending state and defers flowStatus=3. On PUT failure we clear the mark and, if commit already deferred, rescue the card by terminalizing via applyExpiredPatch. CR-3 active terminal cleanup - completeDeferredAICardFinalize calls removeCardRun once the card transitions to FINISHED, so resolve/expire terminations cleanly reclaim the registry slot instead of waiting on the 30-minute TTL sweep. inbound-handler still keeps the record alive while pending or deferred — terminal completion now actively closes it. Fence-safe process-block wrap - wrapProcessBlockMarkdown was splitting multi-line ```lang ... ``` fences across separate <font sizeToken=...> wrappers, breaking DingTalk markdown parsing whenever an approval reply text contained code fences and leaving raw <font> markup on the card. The wrap now toggles fence state and emits fence delimiters / body lines under a plain `> ` blockquote prefix, only wrapping outside-fence prose with the small-grey font tokens. Tests - approval-card-patcher: pre-mark ordering, PUT-failure rollback, rescue path when commit deferred mid-PUT, terminal completion is delegated to completeDeferredAICardFinalize. - card-service: commitAICardBlocks defers (no flowStatus=3, state stays INPUTING, deferredFinalize set) when pending; finalizes normally otherwise. completeDeferredAICardFinalize transitions state + removes registry entry; no-op when no defer flag. - card-draft-controller: fence-integrity regression covering the approval-required reply payload shape. Housekeeping - .gitignore: exclude pnpm pack tarball output (*.tgz) so local deploy artifacts don't leak into commits.
Real-device validation showed a follow-up bug: when the user clicked an approval button after upstream had already moved past the approval (askFallback resolved it, expired, etc.), the gateway returned INVALID_REQUEST with message "unknown or expired approval id". Our resolver only matched gatewayCode === "APPROVAL_NOT_FOUND" so it fell through to gateway-error, and the operator saw a misleading "审批暂时处理失败,请稍后重试" hint that suggested transient retry. Replace the gatewayCode equality check with the upstream isApprovalNotFoundError helper, which already canonicalises all three not-found surfaces: - gatewayCode=APPROVAL_NOT_FOUND - gatewayCode=INVALID_REQUEST with details.reason=APPROVAL_NOT_FOUND - gatewayCode=INVALID_REQUEST with message "unknown or expired approval id" The hint flow then runs applyExpiredPatch (clears buttons + completes the deferred finalize) instead of telling the operator to retry. Tests cover both newly recognised surfaces plus the legacy code path.
Real-device validation showed that even after the native button group attaches to the card, the body still rendered the upstream tool-result text — "Approval required. Run: ```txt /approve <id> allow-once``` …" — which duplicates the button labels in technical markdown and reads poorly when the buttons render natively. When deliverPending takes the card route, also write a concise body that names the command awaiting approval and points at the buttons: 🔒 该命令需要您的审批 ``` ls /root ``` 工作目录:/root/.openclaw/workspace 过期时间:30 分钟 _请通过下方按钮批准或拒绝_ Implementation: - approval-markdown-render: new buildExecApprovalCardBody and buildPluginApprovalCardBody helpers. They reuse the same payload source as the markdown-fallback builders but drop the decision block (the buttons render those decisions). - approval-native-runtime: presentation.buildPendingPayload returns the new cardBodyMarkdown alongside markdownText. The DingTalk pending payload type carries both, so the card route overrides the body while the markdown fallback keeps its existing /approve command listing. - approval-card-patcher.applyPendingPatch: optional cardBodyMarkdown argument; when present, PUT blockList (single type:0 markdown block), content (streaming key), and copy_content alongside the show_approve_btns / approveId / hasAction triple. Tests: extend approval-card-patcher and approval-native-runtime suites for the new pathway; verify body keys are omitted when cardBodyMarkdown is not supplied.
结论:暂停本 PR —— 根因是上游 openclaw bug,不在本插件可修范围经过真机调试 + 四轮上游 openclaw 探针,gap #1 原生审批「点按钮永远 前因后果现象:DingTalk 审批卡片正常投递,但点 Allow/Deny 按钮永远返回 排查中被证伪的假设:
真正的根因:
关键证据(上游 host openclaw 探针,真机 2026-05-20)修复方向(归属上游 openclaw)
|
背景
GAP-01 是
docs/features-gap.html列出的 P0 缺口 #1:DingTalk channel 接入 OpenClaw 原生审批能力(exec approval + plugin approval)。Spec v3.12 + plan v6(26 task)与 21 commit 的实现都在本 PR 内,review 反馈也已合入。范围
ChannelApprovalCapability与 native runtime 4 sub-adapter(availability / presentation / transport / observe;interactions 留待 v2)。show_approve_btns / hasAction / approveId三个变量 PUT)。/approve <id> <decision>命令模板。/approve命令两条入口都收敛到approval-resolver,再调上游resolveApprovalOverGateway。关键改动
src/approval/域:config / schema / parser / target-resolver / resolver / runtime / capability / card-locator / card-patcher / card-state / callback-handler / command-intercept / markdown-render(13 个模块,~900 LOC)。card-run-registry加isActiveCardRun/resolveActiveCardRunBySession/markCardRunPendingApproval/clearCardRunPendingApproval,approval 复用现有 card 状态,不引入独立 store。card-callback-service.analyzeCardCallback输出cardPrivateData.{actionIds, params},让 approval handler 解出approveId/action。card-service在 createAICard / commit / finalizeStopped 三处都写入APPROVAL_CARD_INITIAL,模板初始化对齐。channel.ts挂approvalCapability(thin layer 守住);channel-gateway在 TOPIC_CARD listener 接入 approval 分支;inbound-handler在 session lock 前早期拦截/approve命令(绕过 agent session lock)。channels.dingtalk.execApprovals.{ enabled, approvers },空时回退commands.ownerAllowFrom。openclawpeerDep + lockfile + manifest + tsconfig;message-tool capability 从cards改为presentation(对齐上游 deprecation)。上游 openclaw 依赖(
>=2026.5.7)本 PR 用到的三个 SDK API 各自引入版本:
"presentation"channel-message capability2026.4.21createChannelNativeOriginTargetResolver({ normalizeTarget })2026.4.27PluginApprovalRequestPayload.allowedDecisions2026.5.7取 max =
2026.5.7。peerDep 抬到>=2026.5.7,并用pnpm.overrides把实装也 pin 在这个版本(避免 fresh install 漂到更新的 5.7)。CI Node 22
ci-tests.yml从node-version: 20升到22。openclaw@2026.5.7拉的undici@8.x自报engines.node: ">=22.19.0"并使用webidl.util.markAsUncloneable(Node 21.3+ API),Node 20 上会报TypeError。22 与上游 openclaw 自身的engines.node: ">=22.16.0"对齐,且setup-node@v4在22下当前拿到 22.20+,满足 undici 8.x。npm-publish.yml/clawhub-publish.yml早已 Node 24,无需动;docs-vercel.yml仍在 20,build 路径不触发 undici,本 PR 不顺带改。Review 反馈修复(已合入)
approval-resolver.deriveGatewayParams把鉴权门挪到plugin:前缀短路之前,防止未授权用户通过/approve plugin:<id>绕过 channel approver 名单(004ea4f)。findActiveAgentCard拒绝有不同pendingApprovalId的 active card,第二个 approval 自动走 markdown 路径;dedupeKey 同时加${request.id}(004ea4f)。resolveApproval再 best-effort patch 卡片,即使 token / 卡片 patch 失败也不阻断上游 resolve;空approvalId时仅在params.action明确是审批动作才处理,避免误吞普通按钮(8c77f05)。/approve在gateway-error都保留 pending + 私聊"稍后重试"提示,不再激进 expire 卡片(8c77f05)。shouldFallbackToMarkdown单一 helper 替换冗余双分支(004ea4f)。允许一次 / 总是允许 / 拒绝,与 user docs 对齐(004ea4f)。isPluginAuthorizedSender共享 exec 名单这一 v2 占位加显式注释(004ea4f)。tests/integration/approval-end-to-end.test.ts),覆盖 multi-approver 竞争 / self-approval DM / 非 approver 点击 / 上游 expired event / card patch 明确失败降级 markdown / 模糊失败 return null //approve命令绕过 session lock / 未配 approvers / 非 dingtalk 触发 / 重启后旧按钮 not-found / exec & plugin invalid-decision —— 共 12 个 spec §9.3 列出的关键场景(3b1b023)。Deferred / 后续
文档
docs/user/features/exec-approval.md— 用户启用 / 交互 / 限制 / FAQdocs/user/reference/configuration.md—execApprovals.{enabled, approvers}schemadocs/releases/v3.6.4.md— 本期 release notesdocs/features/2026-05-18-gap-01-approval-native-design.html— spec v3.12docs/plans/2026-05-19-gap-01-approval-native.md— 26 task plan v6验证 TODO
已本地验证
pnpm test:124 files passed,1265 tests passedpnpm run type-check:通过pnpm run lint:0 errors,87 warnings(全是 pre-existing,无新增)git diff --check:通过真机验证
环境准备
~/.openclaw/openclaw.json插件路径指向当前 worktreepnpm run build:runtime&&openclaw gateway restartopenclaw channels status --probe --json确认running=true && connected=true(首次 probe 若撞上 restart 报 1006 abnormal closure,稍后再 probe)channels.dingtalk.execApprovals.approvers: ["<staffId-A>", "<staffId-B>"],并准备一个非 approver 账号主路径 — 验证 approval 三按钮、markdown 模板、
/approve命令在常规分支上行为正确rm类) → AI Card 出现 3 按钮 → 点"允许一次" → 按钮消失,agent 继续流式,命令执行invalid-decision提示并列出可选 decisions/approve <id> ...命令模板的 markdown → approver 发/approve <id> allow-once→ 上游 resolve/approve <id> allow-once→ 命令早期拦截 resolve,agent 输出不打断异常路径 — 验证 harden 改动的兜底与重试语义
hasAction跟随 card 流式状态恢复openclaw gateway restart后点 restart 前发出的旧按钮 → 收到not-found兜底,卡片落 cleared 态,不留死按钮回归 — 验证相邻既有路径不受 approval 改动影响
approveId/show_approve_btns不影响渲染actionId=btn_stop不被 approval 分支误吞/approve不误命中普通文本dmPolicy/groupPolicy/commands.ownerAllowFrom既有语义不变;execApprovals.approvers与 owner 名单独立(owner 不自动是 approver)观测与收尾
tail -n 0 -f ~/.openclaw/logs/gateway.log,关注[DingTalk][Approval]/[DingTalk][Approval][DeliveryError]~/.openclaw/openclaw.json临时改动并 restart