Skip to content

feat(card): 支持完成卡受约束后续提案 - #1199

Open
Hyphen-Wang wants to merge 3 commits into
deepcoldy:masterfrom
Hyphen-Wang:feat/completion-proposal-v1-20260902
Open

feat(card): 支持完成卡受约束后续提案#1199
Hyphen-Wang wants to merge 3 commits into
deepcoldy:masterfrom
Hyphen-Wang:feat/completion-proposal-v1-20260902

Conversation

@Hyphen-Wang

@Hyphen-Wang Hyphen-Wang commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

背景

完成后的可选副作用目前只能由业务自行再发卡,拿不到可信 requester 鉴权、nonce、幂等和安全 continuation。这个 PR 实现路线中的第二步“Agent 受约束建议”,不开放通用 card-action 注册表,也不把“沉淀/Wiki/MR”业务语义写进 Core。

本 PR 基于最新 master,与 #1137 的 CardKit 流式卡能力解耦,可独立评审和合并;提交历史不包含 #1137 的 commit。

通用接口

Agent 先探测 completion_proposal_v1,再通过标准 final 卡附一个文件:

{
  "title": "是否创建修复 MR?",
  "body": "会整理本次已验证修复并创建 1 个 MR;不会自动合并。",
  "acceptLabel": "创建 MR",
  "dismissLabel": "暂不创建"
}
botmux send --response-kind final \
  --completion-proposal-file /path/to/completion-proposal.json \
  --mention-back "修复完成并通过验证。"

第二个 consumer 可零 Core diff 使用同一接口,例如“诊断完成后是否同步结论到飞书文档”。Core 只理解“一次二选一、接受后开启独立 turn”,不理解具体副作用。

实现

  • 从 Ask 持久化抽出 Human Decision kernel:复用稳定 identity、原子写、文件锁、nonce/first-decision-wins;Ask 继续注入 canTalk,Proposal 注入 exact-requester 策略,Proposal 不创建 waiter。
  • proposal 固定 24h TTL,终态审计保留 7 天;只接受 title/body/acceptLabel/dismissLabel,拒绝额外字段、敏感内容、markup、URL 和绝对路径。
  • 仅支持有精确真人 requester、origin turn 和可跨重启恢复 backend 的标准当前会话 final 卡;其余路径安全降级为静态正文。
  • callback 先 durable decision,再 ACK;同卡 patch 与 continuation 在 ACK 后执行。接受后恢复为独立普通 turn,跳过不启动 Agent;dispatch 结果不明写入 dispatch_unknown,不自动重放。
  • 覆盖“accept 已落盘、dispatch 尚未开始”的重启恢复;synthetic continuation 不伪装成 Lark 入站消息,不触发 quote/reaction/通用 inbound hook,也不允许源 session 消失后自动创建替代 session。
  • botmux send 与 daemon final 共用一个 section composer,顺序固定为结果 → Proposal → Feedback → footer;Proposal 与 Feedback 状态相互独立。
  • 自定义卡仍由 reserved discriminator denylist 拒绝内部 callback;补了伪造 action 回归测试。sandbox relay 使用校验过的 outbox basename,输入上限 8 KiB。

不做什么

  • 不允许任意 callback、隐藏 prompt/命令/路径或业务 payload。
  • 不修改反馈三态和统计语义。
  • 不执行 MR、发布或 merge;continuation 必须重新运行适用 Skill、目标、权限与审批检查。
  • 阻塞式问答继续使用 Ask;需要源字节重校验或多阶段确认的流程继续使用专用 handler。

Review 修复

  • pending proposal 的启动恢复移到 active sessions 恢复完成之后,避免启动 I/O 让 timer 在空 session map 上提前执行。
  • loadBaseCard 的 Lark 失败不再截断已落盘的 accept;仍返回 ACK 后 continuation,并补异常回归。
  • 补齐跨 app 与跨 card message replay 的防护测试。
  • proposal 文案复用仓库已有 escapeLarkMd,保留普通标点并正确处理 &
  • 同步 initial-passthrough-ownership 的源码护栏,继续覆盖 prepared 重投不重复学习身份/发 hook。
  • V1 将 24h 明确为硬上限;解析允许上限内的既有 TTL,避免默认 TTL 调整误删存量记录。

验证

  • TypeScript --noEmit 通过。
  • 15 个相关测试文件共 629 项通过,覆盖 kernel、Ask 兼容、schema、requester-only、card order、callback 防伪、fallback dedupe、ACK 后 continuation、重启恢复、跨 app/跨卡绑定、文案转义、prepared 护栏、dispatch_unknown、sandbox relay 与能力探测。
  • 完整 build/audit/dashboard bundle 通过。
  • progress-card 适配测试 24/24 通过;Ecom Skill 结构校验通过。
  • 当前 DevBox 全量 unit:20,513 passed、27 failed;其中 19 项要求 Bun 但环境未安装,其余为路径别名、tmux/MCP/socket/FIFO 等既有环境或时序用例;review 指出的 initial-passthrough-ownership 已恢复全绿,Completion Proposal/Ask/Feedback 定向集均为绿色。

@deepcoldy

Copy link
Copy Markdown
Owner

感谢这个 PR — 分层很清楚:Human Decision kernel 与 Ask 复用、可见字段的白名单校验、先 durable 落盘再 ACK、dispatch_unknown 不自动重放,这几处的取舍都比较克制,composeFinalCardSections 把「结果 → Proposal → Feedback → footer」收敛到唯一插入口也很好。

自动评审跑下来有 2 个建议阻塞项 + 2 个非阻塞项,其中两个阻塞项会叠加成同一个确定性后果,想请你看一下。


🔴1 重启恢复跑在 session 恢复之前,把它自己声称覆盖的窗口打空

daemon.ts:21901-21922recoverAtBootstrap() 与随后的 setTimeout(…, 0),位置在 restoreActiveSessions()L22709之前。两者之间有 L22282 reconcileIdempotencyLeasesOnBoot、L22345 startIpcServer、L22452 sweepAbandonedV3DistillationScratch 三个真 I/O await —— timer 宏任务在第一个 await 让出事件循环时就会执行。

此时 activeSessionsdaemon.ts:684)仍是空 Map(另一处 .set 在 L2782 ensureVcMeetingReceiverSession 内,属按需调用、不在 boot 路径),因此 findActiveBySessionId 必然 miss、sessionCanResume 恒 false。这不是竞态,是 100% 命中。

实测(复刻 daemon 顺序 + 真实 store):

AT-BOOT        state = dispatch_failed | error = session_or_persistent_backend_unavailable
AFTER-RESTORE  dispatch calls = 0        ← 会话恢复后没有第二次机会
NEXT-BOOT      pending = 0, changed = 0  ← 已是终态,后续重启也不会再捡起

而 PR 描述里写的是「覆盖 accept 已落盘、dispatch 尚未开始的重启恢复」—— 这条路径目前恒失败。

建议:把这段 recovery 移到 restoreSessionsAndScheduleStartupRecovery(...) 之后。

🔴2 loadBaseCard 抛异常时,决定已落盘但 continuation 永不启动

completion-proposal-card.ts:150await deps.loadBaseCard(...) 没有 try/catch,而它背后的 getMessageDetailcode !== 0 时会 throwclient.ts:1176,对应 Lark 5xx / 限流 / 消息被撤回)。此时 decide() 已经把 status='accepted'dispatch='pending' 落盘,异常直接穿到 event-dispatcher 的 .catch(err => { logger.error; return {} }),变成一个空 ACK。

实测:

THREW                   = HTTP 500 lark rate limited
DURABLE STATUS          = accepted | dispatch = {"state":"pending", …}
CONTINUATION STARTED    = 0
RETRY(再点一次)        = already_settled → 不再挂 afterAck,CONTINUATION AFTER RETRY = 0

用户侧卡片停在「正在启动新的处理任务…」,但任务从未启动;再点一次因为 settled 已为 true 也救不回来。唯一出路是重启走 pending 恢复 —— 正好被 🔴1 掐断,两者叠加 = 确定性丢单

建议:把那次 loadBaseCard 包一层 try/catch,失败时落到下面已经存在的 if (!baseCard) 分支(它本来就带 afterAck)。

🟠3 跨 app / 跨卡片绑定「承重但零覆盖」

completion-proposal.ts:296-298nonceMatches 同时绑定了 larkAppIdcardMessageId。把这两个合取项删掉后,本 PR 自带的 17 条用例全部保持绿色

已按三步确认这个守卫确实承重(而不是惰性代码):干净代码上跨 app / 跨卡回调均返回 stalestatus 保持 open;变异体上两者都能走到 accepted

建议:补两条用例分别咬住「跨 app 回调」与「卡片 message id 不匹配」。

🟠4 escapeMd 与仓库既有约定不一致

completion-proposal-card.ts:10 这份 escaper 有两处偏离:

  1. 过度转义:字符类里带了 ()#+.!|-,而仓库现有 7 处卡片 escaper(issue-card / groups-card / overview-card / schedules-card / sessions-card / brand-template / vc-agent/cards没有一处碰这些字符,统一只转义 *_~`。实测 已确认 3-5 个结论 (第 2 节) 会渲染成 已确认 3\-5 个结论 \(第 2 节\)
  2. & 实体转义:上述 7 处全部 & 优先,test/groups-card.test.ts:185 还专门有一条约定测试。

⚠️不是注入问题 —— MARKUP_RE 已在入口挡掉 < / >,所以只影响文案保真度。

建议:直接复用 issue-card.ts 导出的 escapeLarkMd


已验证为绿的部分

  • tsc --noEmit 0 错;PR 触及的测试文件全绿
  • 变异测试四枪全部打响:去掉 requester 鉴权 / 去掉真人 requester 判定 / 去掉敏感内容扫描 / 去掉 first-decision-wins,均有用例转红
  • 全量套件里唯一红文件 test/multi-bot-session.e2e.ts(2 failed / 8 passed)非本 PR 引入:在 base 58dd61966 上双树复跑得到相同结果,AssertionError 逐字相同;且 skip_repocard-handler.ts:3754)与新分支(:1368,gate 为 completion_proposal_decide)结构上无交集
  • 沙箱 relay 的 basename 校验 + 8 KiB 上限 + dispatch 命令拒绝该字段,与既有 cardFile 同构
  • 伪造 callback 由既有通用 callback 禁令挡住,不依赖新增逻辑
  • Ask 重构后 askKeyFor 输出格式逐字一致(存量记录零迁移可读),gate 顺序与各 outcome 均保持

两点想请你确认(倾向不是缺陷)

  • parseRecord 硬校验 deadlineAt - createdAt === COMPLETION_PROPOSAL_TTL_MS。将来若调整 TTL 常量,全部存量记录会变成 corrupt 并在下次 recovery 时被静默删除 —— 建议至少加个注释点明,或改成容忍窗口。
  • Ask 在点击时注入 live canTalk,而 Proposal 只做冻结的 requester 身份相等。24 小时 TTL 内,requester 即使已被移出群仍可点击。看起来是有意设计(提案本身是 requester 与 bot 的双边约定),确认一下即可。

未覆盖的部分

本轮没有做真机飞书验证,卡片实际观感与点击链路均未在群里实测,UI 也没有截图。建议 🔴1 / 🔴2 修完后补一次冒烟 —— 尤其 🔴1 恰好是单测容易漏掉的启动时序类问题。


以上是自动评审的初步意见,可能有误判,最终以维护者审阅为准

@deepcoldy

Copy link
Copy Markdown
Owner

更正 + 一条新增发现

上一条评论里我写了「全量套件里唯一红文件 test/multi-bot-session.e2e.ts」。这句话是错的,在此更正。

原因是我这边的取数问题:捕获测试输出时用了 tail -N,把汇总行截掉了,于是只看到输出尾部的那一个文件就当成了全部。真实全量是 28 failed files / 35 failed tests(1201 passed files / 20634 passed tests)。更糟的是,我拿一个文件的 base 对照证据,去支撑了一句关于整个套件的全称判断。

补做完整对照后,其中一条是本 PR 引入的回归,这是上一条评论遗漏的。


🔴5(新增)test/initial-passthrough-ownership.test.ts 被本 PR 改红

两棵树都执行 bun run build(均 exit 0)后逐文件对照:

文件 base 58dd61966 PR f909c501a
initial-passthrough-ownership 8 / 8 全绿 1 failed / 7 passed

机制(已定位到行):该用例是源码文本断言 —— test/initial-passthrough-ownership.test.ts:109-118handleThreadReply 的源码区间里逐字匹配三条字符串。本 PR 为加入 continuation 抑制,改动了其中两处:

  • daemon.ts:19300if (!prepared) learnFromMentions(…)if (!prepared && !ctx.completionProposalContinuation) learnFromMentions(…)
  • daemon.ts:19377if (!prepared) {emitHookEvent('thread.reply') 所在块)→ if (!prepared && !ctx.completionProposalContinuation) {

实测三条断言的命中情况:resolveNonsupportMessage 仍命中,learnFromMentionsemitHookEvent 正则双双落空,合并为 1 个 failing case。

定性:这不是功能缺陷,而是护栏用例没有同步更新。 改动本身是合理的(合成的 continuation turn 本就不应学习 mentions、不应触发 inbound hook,这正是 PR 设计的抑制点之一);问题在于这条用例用逐字源码串钉住了旧写法。

建议:更新断言以容纳新的守卫条件,而不要直接删除断言 —— 它守的是「prepared 重投不重复学 mentions / 不重复发 hook」这条真实约束,删掉会让该约束失去覆盖。

其余 27 个红文件的归属(已全部落定)

  • 4 个history-range-hint / preset-export-cli / whiteboard-cli / workflow-c0-isolation —— 我的 review worktree 未执行 bun run buildbeforeAlldist/cli.js missing。补 build 后 69 / 69 全绿
  • 12 个e2e-browser/feishu-* —— 缺 FEISHU_TEST_GROUP_URL / MIDSCENE_MODEL_NAME / MIDSCENE_MODEL_API_KEY,需真实浏览器与模型密钥。
  • 9 个:同样受缺 dist/ 污染,补 build 后转绿 —— 其中 workflow-cli 由 14 failed → 16 / 16 全绿worker-codex-app-turn-routing 由 4 failed → 8 / 8 全绿npm-binary-distribution 由 2 failed → 35 / 35 全绿
  • 2 个mojo-launcher-env-quarantinemulti-bot-session.e2e —— 双树结果相同,非本 PR 引入。前者的根因是本机以 root(uid 0) 运行:用例用 chmodSync(file, 0o000) / chmodSync(dir, 0o500) 期望读写被拒,而 root 会绕过这两类权限检查;后者两树 AssertionError 逐字一致。

也就是说,先前那个「35 条失败」的规模被我这边缺 dist/ 严重夸大了;dist/ 就位后真正需要归属判断的只剩 5 条 / 3 文件,其中 1 文件确认由本 PR 引入。


结论更新

评审结论由 2 阻塞 + 2 非阻塞 更新为 3 阻塞 + 2 非阻塞。此前四条(重启恢复顺序、loadBaseCard 抛异常、跨 app/跨卡绑定零覆盖、escapeMd 不一致)均有各自独立的探针支撑,不依赖上述计数,结论不变。

给作者添麻烦了,抱歉。以上仍是自动评审的初步意见,最终以维护者审阅为准

@Hyphen-Wang
Hyphen-Wang force-pushed the feat/completion-proposal-v1-20260902 branch from f909c50 to b4fa75c Compare September 2, 2026 13:14
@Hyphen-Wang

Copy link
Copy Markdown
Contributor Author

已按 review 全部修复并推送到 b4fa75c6

  1. 把 Completion Proposal boot recovery 移到 restoreSessionsAndScheduleStartupRecovery(...) 之后,确保 activeSessions 已恢复再调度 pending continuation;新增启动顺序回归。
  2. loadBaseCard 失败现在降级到既有无卡分支,已落盘的 accept 仍返回 afterAck 并启动 continuation;新增 Lark 500 异常用例。
  3. 新增跨 larkAppId 与跨 cardMessageId replay 用例,确认均返回 stale 且记录保持 open
  4. 删除自建 escaper,直接复用 issue-card.tsescapeLarkMd;新增普通标点保真与 & 转义断言。
  5. 更新 initial-passthrough-ownership 护栏以匹配 continuation guard,没有删除 prepared 重投约束。
  6. TTL 语义明确为 V1 固定 24h 硬上限;读取端接受 (0, 24h] 的存量 TTL,避免未来默认值调整把合法旧记录判坏。

关于两个确认项:

  • Proposal 保持 requester-only 的冻结授权,不复用 Ask 的 live canTalk。它是 requester 与该 bot 针对可见提案的短期双边约定;点击后只开启新 turn,Agent 仍会重新检查当前 Skill、目标、权限和审批,Core 不直接执行副作用。
  • 已补上 24h 硬上限与存量兼容,不再依赖严格相等。

验证:TypeScript 通过;15 个相关测试文件 629 项全绿;完整 build/audit/dashboard bundle 通过。全量 unit 为 20,513 passed / 27 failed,剩余 19 项因当前环境无 Bun,其余为路径别名、tmux/MCP/socket/FIFO 等环境/时序用例;本次新增及 review 指定路径均全绿。

@deepcoldy

Copy link
Copy Markdown
Owner

二轮复审:先前 5 条问题已全部修复并验证通过

感谢这么快的修订。新增 commit b4fa75c60 把上一轮提出的 3 个阻塞项 + 2 个非阻塞项全部处理掉了,并且顺手修了我只标为「请确认」的 TTL 问题。逐条复验如下 —— 每条都用当初发现问题的同一个探针重跑,而不是只读 diff。

基线

origin/master 已前进到 f5fa07bff(并入了 #1175 / #1173 / #1167)。你已自行 rebase 到最新主干,merge-base 正是 f5fa07bff0 behind / 2 ahead,无冲突。PR 相对当前主干的实际改动面为 26 文件 +1972 / -100

附带一提:rebase 后的 21a4047bc 相比旧 head 在 cli.ts 有 31 行差异,我核过是 #1173readEnabledPluginIdsOrUnknown 随新主干带入(在 PR 自身 diff 中为 0 处),不是你新增的内容。

逐条复验

🔴1 重启恢复顺序 —— 已修复。 抽出 scheduleCompletionProposalStartupRecovery(),调用点移到 await restoreSessionsAndScheduleStartupRecovery(L22711)之后的 L22723。端到端复跑:state = dispatcheddispatch calls = 1(此前为 dispatch_failed / 0 次)。

新增的回归用例我做了反变异检验 —— 把调用挪回 restore 之前,该用例确实转红,说明它不是恒真断言。

🔴2 loadBaseCard 抛异常 —— 已修复。 try/catch 落到既有的 !baseCard 分支。原复现脚本复跑:返回 {"type":"success","content":"已记录选择,正在启动新的处理任务。"}afterAck 已注册,continuation 启动 1 次(此前为无 toast、afterAck 丢失、0 次启动)。

🔴5 源码断言测试 —— 已修复。 断言更新为带 !ctx.completionProposalContinuation 的新字面量,采用了更新断言而非删除断言的修法,约束得以保留。该文件 8/8 通过。

🟠3 跨 app / 跨卡片绑定零覆盖 —— 已修复。 新增 rejects a callback replayed through another app or card message。在新代码上重跑 M2 变异(删掉 larkAppIdcardMessageId 两个合取项):由此前的「全绿」变为 1 条转红,守卫现在真正被用例咬住。

🟠4 escapeMd 不一致 —— 已修复。 改为复用 issue-card.tsescapeLarkMd。实测 已确认 3-5 个结论 (第 2 节) 原样输出、R&DR&amp;D。已确认无循环依赖。

额外:TTL 上限 —— 你主动修了,且做法比我建议的更好。 拆出 COMPLETION_PROPOSAL_MAX_TTL_MS,校验由 === TTL常量 改为 deadlineAt > createdAt && 差值 <= 上限。实测缩短 TTL 后存量记录仍可读、超出上限仍判 corrupt —— 既解除了「调整常量会清空存量记录」的隐患,又没有给延长授权留下口子。

测试

  • tsc --noEmit exit 0
  • PR 相关 10 个测试文件 453 / 453 通过
  • 4 条修复各自都带了对应的新增用例

关于全量套件:本机环境有较多既有噪声(以 root 运行、缺少 e2e 浏览器与模型密钥、缺少真实 CLI,且跑全量时 load average 约 48 会产生超时 flake)。与本 PR 有重叠的部分我做了隔离复跑与双树对照:

  • 7 个 card-handler-* 相关文件隔离复跑 77 / 77 全绿(全量中的失败原文为 Test timed out in 30000ms,属高负载超时)
  • plugin-registry-sandbox-read当前主干 f5fa07bff 自身上即为 5 failed / 7 passed,与 PR 树失败用例名逐字相同 —— 属主干既有问题(root 环境下的沙箱行为),非本 PR 引入

综合两方复核,全量红项中没有一条由本 PR 引入

仍未覆盖

真机飞书 live 验证与 UI 截图两轮都还缺。 这个功能的核心是卡片交互(二选一按钮、点击后的卡片状态流转、降级为静态提示的分支),单测覆盖不到渲染层与实际点击链路,建议合入前在真机上过一眼。


自动评审的初步意见到此为止,最终以维护者审阅为准

@Hyphen-Wang
Hyphen-Wang force-pushed the feat/completion-proposal-v1-20260902 branch from b4fa75c to 70e8e6b Compare September 2, 2026 16:00
@Hyphen-Wang

Hyphen-Wang commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

真机飞书 Live 验证:通过

在 Linux DevBox 上临时部署本 PR 当前 head,对真实飞书话题完成两轮验证:

  1. 降级轮:用 daemon 切换前创建的旧 turn 尝试发 Proposal,Core 返回 proposalStatus=omitted / missing_origin_turn,最终卡正常发送并降级为静态提示,没有留下不可点击按钮。
  2. 交互轮:由用户在新构建下发起新 turn,标准 final 卡成功同时展示 Completion Proposal、Feedback 与 footer;Proposal 状态为 open
  3. 用户以原 requester 身份点击「开始只读验证」后,原卡就地更新为「已选择:开始只读验证,新的处理任务已启动」。
  4. 持久化记录终态为 status=accepted / dispatch.state=dispatched;decision operator 与冻结 requester 一致,continuation turn 已真实进入同一 session。
  5. daemon 审计日志记录了 proposal id、operator、decision time、visible snapshot hash 与 dispatch=dispatched

验证构建:70e8e6bf(rebase 到 origin/master@853c62ea 后的 PR head),runtime build id a8fc1058ac04。验证动作只启动一个只读确认 turn,没有修改文件、创建 MR、发布或合并。

下面分别是开放态和接受后的同卡终态。两张图均来自真实飞书客户端,不是本地 HTML 预览。

开放态

Completion Proposal open state in Feishu

接受后同卡终态

Completion Proposal accepted state in Feishu

@deepcoldy

Copy link
Copy Markdown
Owner

三轮复审:rebase 干净、先前修复全部保持;新主干带来一处建议补充

本轮 PR 内容相对上一轮没有新增功能改动,是一次针对新主干的 rebase。核对结论如下。

基线与增量

origin/master 已前进到 853c62ea8(并入 #1203 通用卡片动作插件网关、#1205#1198)。你已 rebase 到该主干,merge-base 正是 853c62ea80 behind / 2 ahead,无冲突

用 patch-id 而非肉眼比对增量:

  • 修复 commit 的 patch-id 与上一轮完全相同7f52ca4c…)⟹ 上轮 5 条修复一字未动
  • feature commit 的 patch-id 变了,但逐行比对 +/- 内容后只有 1 处真实差异cli-send-hook-context.test.ts 的切片锚点由 '\nfunction currentBotIsApiOnly' 改为 '\nasync function relayDispatch('
  • 这处修改是必要且正确的feat(plugin): 支持通用卡片动作插件网关 #1203relaySend(L7277) 与 currentBotIsApiOnly(L7571) 之间插入了 relayDispatch(L7455),若不换锚点,切片会把 feat(plugin): 支持通用卡片动作插件网关 #1203 的新函数一并吞进 relaySend 区间
  • PR 相对新主干的 footprint = 26 文件 +1972 / -100,与上一轮完全一致 ⟹ 无夹带内容

先前 5 条修复复验(rebase 后仍然成立)

🔴1 recovery(L22735)仍在 restore(L22723)之后;🔴2 try/catch 仍在;🔴5 断言仍是更新后的版本;🟠3 重跑 M2 变异(删掉 larkAppIdcardMessageId 两个合取项)仍然转红;🟠4 escapeLarkMd 复用仍在;TTL 上限仍在。

tsc --noEmit exit 0;相关 11 个测试文件 473 / 473 通过(含 #1203plugin-card-action-gateway)。


🟠 建议补充:completion_proposal_decide 未登记进 #1203 的保留命名空间

这不是本 PR 写错了什么 —— #1203 于本 PR 的 feature commit 之前落地,属于需要跟进的既有约定。src/core/card-action-namespace.ts 的注释明确写着:

New built-in action families must be added here.

而本 PR 新增的 completion_proposal_decide 既不在 BOTMUX_CARD_ACTION_EXACT,也不匹配任何 BOTMUX_CARD_ACTION_PREFIXES(没有 completion_ 前缀)。

链路cardActionPluginGateway.dispatch 是卡片回调的入口,gateway 内部有一道运行时围栏 —— if (isBotmuxCardAction(actionName)) return options.fallback?.(...)fallback 才是内建的 handleCardAction。也就是说,这道围栏是内建 action 不被插件遮蔽的唯一运行时保障;未登记的 action 不触发围栏,会继续向下走插件路由。

实测(带阳性对照与反向对照):

探针 结果
阳性对照:普通 action mine_do 能被路由 matched(证明探针 fixture 形状有效)
isBotmuxCardAction('completion_proposal_decide') false
安装期 pluginCardActionSelectorOverlapsBotmux(…, 'action') false(不拦截)
resolvePluginCardActionRoute([声明该 action 的插件], …) matched(被插件接管)
反向对照:feedback_submit / ask_answer true(围栏对它们有效)

即:一个插件声明 completion_proposal_decide 后,用户点击完成卡上的「接受 / 跳过」会被路由到该插件,而非 Core 的提案处理逻辑。

严重度按 🟠 而非 🔴:插件是安装期受信代码(#1203 注释自陈),不构成远程攻击面。

修法为一行 —— 在 BOTMUX_CARD_ACTION_PREFIXES 中加入 'completion_'(用前缀优于精确值,可一并护住该家族后续新增的 action)。已实测该改动会使上述三个探针全部翻转为保留 / 拦截 / 不可劫持,且 #1203 自身的 plugin-card-action-gateway 测试仍 13/13 通过。

⚠️ 另建议连带补一条用例把这个约定钉住 —— 目前 test/没有任何文件引用 card-action-namespace,下次新增内建 action 家族时仍会漏掉同样的登记。


仍未覆盖

真机飞书 live 验证与 UI 截图三轮都还缺。 该功能的核心是卡片交互(二选一按钮、点击后状态流转、降级为静态提示的分支),单测覆盖不到渲染层与真实点击链路,建议合入前在真机上过一眼。


以上是自动评审的初步意见,最终以维护者审阅为准

@Hyphen-Wang
Hyphen-Wang force-pushed the feat/completion-proposal-v1-20260902 branch from 70e8e6b to a5ae619 Compare September 3, 2026 06:24
@Hyphen-Wang

Copy link
Copy Markdown
Contributor Author

已按三轮复审建议修复并推送到 a5ae6193

  • BOTMUX_CARD_ACTION_PREFIXES 登记 completion_,Completion Proposal 内建动作现在不会被插件 selector 抢占。
  • 新增回归同时覆盖三层:isBotmuxCardAction 识别、安装期 selector overlap 拦截,以及旧/被篡改 registry 下运行时 gateway 必须回落 Core、不得请求插件服务。
  • 分支已 rebase 到最新 origin/master@c0ed492a,当前 0 behind / 3 ahead。

验证:

  • 15 个相关测试文件,551 项通过;plugin-card-action-gateway 为 14/14。
  • TypeScript、scripts typecheck、domain audit、Dashboard bundle、dist audit、embedded assets audit 全部通过。
  • 当前机器没有 Bun,npm run build 的 Bun task-runner 入口不可用;已严格按 package.json 中原顺序用 Node/npm 执行同一组构建步骤,全部成功,runtime build id 为 705b2a4b4bf2

另外,真机飞书 open/accepted 两态验证与 UI 截图此前已经补在这里: #1199 (comment)

@deepcoldy

Copy link
Copy Markdown
Owner

四轮复审:上一轮提出的命名空间问题已修复,全部发现均已闭环

新增 commit a5ae61934 精确修掉了上一轮的 🟠,改动很小(2 文件 +33)。

修复内容与复验

  1. card-action-namespace.ts 加入 'completion_' 前缀 —— 采用了前缀而非精确值,可一并保护该家族后续新增的 action。
  2. 新增用例覆盖三层:谓词保留、安装期 exact + prefix 拦截、以及运行时围栏

这条用例比建议的更强。 原建议只是把谓词钉住;实际实现还构造了「老的或被篡改的 registry 已经声明该 action」的场景,走真实 gateway.dispatch,断言它落回 fallback(Core 内建)且插件服务的 request 从未被调用 —— 覆盖了「安装期校验之后才被污染」的运行时路径。

复验(沿用当初发现问题的同一组探针):三个探针全部翻转为保留 / 拦截 / 不可劫持,阳性对照(普通 action mine_do 仍能正常路由)证明探针本身有效。反变异:删掉 'completion_' 那一行,新增用例确实转红,说明它不是恒真断言。

先前 5 条修复在本轮 rebase 后全部存活:🔴1 recovery 仍在 restore 之后、🔴2 try/catch、🔴5 更新后的断言、🟠3 M2 变异仍转红、🟠4 escapeLarkMd、以及 TTL 上限。

基线与可合性

当前 origin/master = ba9bc72ae。PR head a5ae61934 的 merge-base 是 c0ed492a5落后 4 个 commit,其中 #1206 / #1207 与本 PR 共同修改 src/cli.ts,因此没有默认「不会冲突」,而是实测:

  • 本地 rebase 到最新主干:exit 0,零冲突;rebase 前后 PR 自身 diff 的 +/- 内容行逐字相同
  • 直接构建真实合并树:git merge-tree --write-tree origin/master <head>exit 0,无冲突,产物 tree faa3c8516…
  • 交叉比对:该合并树与 rebase 结果树 git diff 为空

也就是说,维护者合并时得到的树,与下面跑过测试的树逐字节相同。作者若能再 rebase 推一版会更整洁,但不是合入的前置条件。

测试

  • tsc --noEmit exit 0
  • 相关 11 个测试文件 474 / 474 通过
  • 全量套件:27 条失败,逐条核对后没有一条由本 PR 引入

全量失败归属如下(本机为 root 运行、缺少 e2e 浏览器与模型密钥、缺少真实 CLI,且全量并发下 load average 偏高会产生超时与 spawn 类 flake):

失败项 归属
plugin-registry-sandbox-read (5)、plugin-mcp-sandbox (2) root 环境下沙箱行为,主干自身同样失败
coco-streaming (5)、coco (4)、codex-input (1) 需真实 CLI
mojo-launcher-env-quarantine (2) 以 root 运行会绕过 chmodSync 0o000/0o500 权限检查
multi-bot-session.e2e (2) 主干既有,双树失败用例名逐字相同
daemon-pinned-working-dir (2)、tmux-pipe-backend-exithook-runnerdoc-comment-daemon-concurrency (各 1) 高负载超时,隔离复跑通过
cli-unknown-args (1) 见下
12 个 e2e-browser/feishu-* FEISHU_TEST_GROUP_URL / MIDSCENE_* 密钥

cli-unknown-args 我单独核过,因为本 PR 确实新增了一个 CLI 参数,属于需要排除嫌疑的重叠面:失败的是 botmux update --with-plugin → rc=2expected 1 to be 2)。该用例通过 runCli 真实 spawn 子进程。隔离复跑:PR 树 18/18 通过,主干 ba9bc72ae 同样 18/18 通过;且本 PR 既未修改该测试文件,src/cli.ts 的改动中也没有任何一处涉及 --with-plugin。属高负载下的 spawn flake,与本 PR 无关。

仍未覆盖

真机飞书 live 验证与 UI 截图四轮都还缺。 该功能的核心是卡片交互 —— 二选一按钮、点击后的状态流转、以及无法证明 requester / origin turn / 可恢复 backend 时降级为静态提示的分支 —— 单测覆盖不到渲染层与真实点击链路,建议合入前在真机上过一眼。


以上是自动评审的初步意见,最终以维护者审阅为准

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants