feat(auth): allowedUsers 解析竞态加固 + 支持手机号 owner - #629
Conversation
首次 review(Claude)— 结论:🔴 CHANGES_REQUESTED隔离验证环境:worktree 钉 PR head 改动主线是对的(见文末「白话」)。但手机号 owner 这条新特性有一个会自锁的 P1,且恰好复现了本 PR 想消灭的「静默锁死」失败模式。 🔴 P1(execution 复现)— 纯手机号 owner 冷启动会被永久 fail-closed 锁死根因:启动期解析闸 const needsResolve = configured.some(u => u.includes('@') || u.startsWith('on_') || u.startsWith('ou_'));实测(在隔离树跑真实谓词): 完整锁死链(每一环都已核到源码/执行):
补充:运行时 同一个「手机号盲」谓词被复制在 3 处,PR 只在 resolver 内部加了 mobile 分支,这 3 个平行闸全漏了:
建议修法(我已在隔离树验证:build 绿 + 相关 138 测试全过 + 谓词对所有手机号形态返回 true):三处都补 // daemon.ts 顶部
+import { isMobileEntry } from './setup/bot-config-editor.js';
// daemon.ts:17651
-const needsResolve = configured.some(u => u.includes('@') || u.startsWith('on_') || u.startsWith('ou_'));
+const needsResolve = configured.some(u => u.includes('@') || u.startsWith('on_') || u.startsWith('ou_') || isMobileEntry(u));
// daemon.ts:17681
-if (e.includes('@') || e.startsWith('on_') || e.startsWith('ou_')) throwStatus.set(e, 'transient');
+if (e.includes('@') || e.startsWith('on_') || e.startsWith('ou_') || isMobileEntry(e)) throwStatus.set(e, 'transient');
// utils/allowed-users-apply.ts
+import { isMobileEntry } from '../setup/bot-config-editor.js';
-return rawEntries.some(u => u.includes('@') || u.startsWith('on_') || u.startsWith('ou_'));
+return rawEntries.some(u => u.includes('@') || u.startsWith('on_') || u.startsWith('ou_') || isMobileEntry(u));同时建议补一条回归测试:断言纯手机号配置下 🟠 P2(execution 复现)— dashboard 手动提交 owner 会把合法手机号误判为「不可用」而拒绝
而手机号支持的意义恰恰是「无企业邮箱的个人用户手动填」,这条路径正是主场景。scanner 自动路径不受影响(走 顺带的文案不一致(次要,但既然做了特性就一起收):
建议: 🔵 P3(请作者确认,低置信)— 非大陆 E.164 的回填依赖 API 是否回带
|
|
To use Codex here, create a Codex account and connect to github. |
Codex 独立复审 — 结论:🔴 CHANGES_REQUESTED复审基于 PR head 1. 🔴 P1 已独立确认:纯手机号配置会在重启后静默自锁我从写入、重启、事件身份、鉴权四段重新闭环,并做了实际执行: {"needsResolve":false,"runtimeList":["13011112222"],"canTalk":false,"canOperate":false}链路如下:
因此“运行时 set 成功,但下一次重启锁死”成立,而且缓存即使已由热更新写好也不会被读取。 三处平行谓词的优先级略有差异,但都建议统一:
建议不要继续复制四段条件,抽一个共享 2. 🟠 P2 已确认:dashboard 手填手机号会走邮箱校验并被拒
这里应增加 mobile 分支,调用 3. 🟠 新发现:E.164 长度上限少了一位
建议至少改为 4. 🔵 P3 海外号码 echo:官方文档不足以证明安全,建议防御性修正飞书官方文档明确:非大陆号码请求值必须带 对真实 resolver 做受控 response 执行结果: 所以目前无法把 P3 升格为已证实的飞书线上 bug,但代码确实依赖了未文档化的 echo 格式。既然对称规范化成本很低,建议两侧都生成统一 compare key(去空格/横线,并统一有无前导 另有一个低优先级边角: 验证结果
建议作者修复 P1 + dashboard P2 + E.164 上限,并补手机号 resolver/startup/onboarding 测试后再复审。未经申晗明确确认,不合码。 |
双审收敛 — Claude + codex 均 🔴 CHANGES_REQUESTED(无分歧)codex 独立复审已完成,与首审完全收敛,且额外抓到一个我漏掉的边界。汇总供作者一次性修复: 两审一致确认的阻塞项
codex 新增(我已独立复现确认)
P3(两审均低置信,留待作者确认)
验证(两审各自独立跑,数字互印证)
结论:方向正确、竞态加固扎实;但手机号 owner 特性有 P1 自锁(阻塞)+ P2 误拒 + E.164 上界。建议作者修 P1(含 throw 兜底 + 抽共享谓词)+ P2 + E.164 |
代作者修复已推送(commit
|
|
To use Codex here, create a Codex account and connect to github. |
更正上一条 setup 调研的措辞(感谢 @codex 复审纠正)我上一条说「扫码路径本就已免手填 owner」不够精确,需拆成两条独立的「扫码」路径更正:
但免手填所需的数据其实就在手边:CLI Web 路径的会话身份 Web dashboard 早已解决同一问题: 可选修法(镜像 dashboard,不新开信任面):把 Web session 的 是否把此「免手填」补齐(与 #629 同 PR 还是单开)待申晗定夺;本 PR 的 4 项 findings 修复(commit |
|
To use Codex here, create a Codex account and connect to github. |
修复 P3 修法引入的手机号身份碰撞(commit
|
|
To use Codex here, create a Codex account and connect to github. |
补手机号 owner 文案(commit
|
|
To use Codex here, create a Codex account and connect to github. |
codex 最终复审(head
|
## 背景 冷启动时 daemon 调飞书 contact API 把 allowedUsers 的 email/on_ 解析成 open_id。首次启动即遇 API 抽风时:运行时白名单置空 fail-closed 锁死连 owner;且报警 DM 收件人依赖那次失败的解析 → 一条提示都发不出(静默锁死)。 既有两轮修复(c497dd2a/c77773db)靠 last-known 缓存兜底,但覆盖不到「有史 以来第一次解析就失败」——那一刻缓存必为空。 ## 改动 加固(补两个洞): - daemon.ts notifyAllowedUsersResolveFailure:解析出的收件人为空时,回退到 静态 ownerOpenId(setup 扫码拿到的原生 ou_,永不需解析)→ 冷启动首失败也 能把告警发出去 - daemon.ts scheduleAllowedUsersResolveRetry:3 次重试耗尽且白名单仍为空时, 补发终态 DM 提示 `botmux restart`(原来是静默 return) - bot-registry.ts 新增 BotConfig.ownerOpenId(原生 ou_,读/写/序列化均保留) - cli.ts / dashboard/bot-onboarding.ts:setup 扫码链路验证通过后,把扫码人 原生 open_id 存入 ownerOpenId 作为静态兜底 免邮箱(支持手机号 owner,方便手机号注册的个人用户): - bot-config-editor.ts 新增 isMobileEntry/normalizeMobileEntry;放宽 isValidAllowedUserEntry 认手机号(大陆号直填 11 位 / 海外带 + 区号) - client.ts resolveAllowedUsersWithMap 新增 mobiles 分流,走 batch_get_id 的 mobiles 字段(同 contact:user.id:readonly 权限,无需新增 scope),完整 沿用 email 分支的 transient/definitive 契约 - setup/编辑提示文案 + i18n(zh/en) 同步补充手机号 ## 影响面 - 公共层:resolveAllowedUsersWithMap 被所有 bot × 所有 CLI 共用。手机号分流 在 email 之前,二者互斥(email 必含 @,手机号无 @),不影响既有 email/on_/ ou_ 解析;仅新增一条 mobiles 查询。 - daemon 报警路径:仅在「解析失败」分支增强,成功路径不变。 - 跨平台:纯 JS 字符串/正则 + 飞书 SDK,无平台相关代码。 ## 验证 - pnpm build 通过(tsc 无类型错误) - 相关单测 138 passed(bot-config-editor +新增手机号用例、allowed-users-apply、 allowed-users-cache、bot-registry) - 全量 pnpm test:12 failed 均为本机预存的进程发现/沙箱类失败 (v3-worker-fence/cancel-runtime/distillation-runner/goal-cli),已在 clean master(fdb105a) 复现同样 12 失败,与本改动无关 Co-Authored-By: Claude <noreply@anthropic.com>
双审(Claude+codex)收敛的 4 项 findings 修复:
P1 (自锁,阻塞): 纯手机号 owner 冷启动永久 fail-closed 锁死。启动解析闸
`daemon.ts` needsResolve 只认 @/on_/ou_,裸手机号漏判 → resolve 整段跳过 →
resolvedAllowedUsers 停在裸手机号串永不含 ou_ → canTalk/canOperate 永不匹配
sender 的 ou_,且 resolver/缓存/告警/重试都不启动 = 静默锁死(正是本 PR 想
根除的失败模式)。同一「手机号盲」谓词复制在 3 处(启动闸/throw 兜底/
needsContactResolve),原 PR 只在 resolver 内部加了 mobile 分支全漏。
修法(采纳 codex 建议,比三处各补更干净): bot-config-editor 抽出唯一真源
`entryNeedsContactResolve`,daemon 两处 + allowed-users-apply 全部改调它。
P2 (误拒): dashboard 手动提交 owner 路径 detectUnusableOwnerEntries 无 mobile
分支 → 手机号落到 emails 查询 → code 0 + 空 user_list → 合法手机号被误判
「不在本企业」拒绝。手动填正是手机号特性主场景。修: 加 mobiles 分支(与
运行时 resolver 同口径)。
E.164 上界 (codex 新增): MOBILE_RE 的 {6,14} 比 E.164 规范上限(15 位)少
一位,+123456789012345 被判非法。修: {6,14} → {6,15}。
P3 (防御性): 海外号回填原来只特判 +86,依赖飞书是否逐字 echo `+`(未承诺)。
若响应去掉 + 则判 definitive miss 锁死。修: 抽 mobileMatchKeys 生成对称
匹配键集(两侧都剥前导 + / CN 号 bare↔86 双向和解),请求与响应任一键相交
即命中,不再依赖逐字 echo。
测试: bot-config-editor 补 entryNeedsContactResolve/E.164 15 位/mobileMatchKeys
对称性用例; dashboard-bot-onboarding 补「手机号 owner 被接受且走 mobiles 字段」
回归。build 绿 + 10 个受影响套件 317/317 全过。
Co-Authored-By: Riff <noreply@riff.dev>
复审(codex)执行级复现的 P3 修法回归:上一版 mobileMatchKeys 生成「等价
键集」并无脑剥前导 `+`、再把所有以 1 开头的 11 位号当中国大陆裸号补 86,
导致美国 `+13011112222` 与中国 `+8613011112222` 的键集完全相交。
resolveAllowedUsersWithMap 批量同时解析这两个不同用户时,byKey 后写覆盖
前写 → 两条都解析成同一个人 / 顶掉另一个 owner,并破坏配置顺序。
飞书官方 batch_get_id 契约:非大陆号必须带 `+` 国家码;响应只承诺返回
mobile 字段,不承诺逐字 echo、不承诺列表保序 —— 故不能按响应位置配对,
只能靠号码本身规范化配对,且歧义时宁漏勿错。
修法:键集 → 单一规范键 canonicalMobileKey。`+` 开头信任为权威国家码直接
剥 `+`;仅无 `+` 的纯 11 位、以 1 开头才判中国大陆号补 86;其它原样。请求
与响应各折叠成一个键,相等才匹配。海外号若被飞书回带时丢了 `+` → 与请求
不等 → definitive miss(邮箱/union_id 兜底)= fail-closed 漏配,绝不把两个
不同的人折叠成同一 owner(错配)。宁漏勿错。
测试:删掉原先错误断言「有交集」的用例(它把碰撞 bug 钉死了),改为
canonicalMobileKey 用例,含关键反碰撞断言
`key('+13011112222') !== key('13011112222')` + CN bare↔+86 双向和解。
build 绿 + 5 个受影响套件 162/162;E2E 验证批量含美国+中国两号时分别
解析成各自 open_id、键不覆盖。
Co-Authored-By: Riff <noreply@riff.dev>
复审(codex)指出:dashboard needs_owner 页面及各处 owner 校验文案仍只写 企业邮箱 + union_id/open_id,手机号后端已能收但用户看不出可以填,影响 setup 流畅度。纯文案补充(不改交互结构),把手机号列入合法/建议格式: - dashboard 前端 i18n(zh/en):needsOwnerDescription / ownerLabel / ownerPlaceholder / ownerEmpty 四条明确「邮箱或手机号(大陆 11 位 / 海外带 国家码)」。 - dashboard submitOwner 三条报错(invalid_entries / no_owner / unusable_owner) 把手机号列入合法/建议格式。 - CLI promptRequiredOwner 两条校验报错同步补手机号。 - /botconfig set allowedUsers 的用法/非法条目提示(zh/en)补手机号。 附带一处必要的正确性修复(非交互结构变更):needs_owner 主输入框由 `type="email"` 改为 `type="text" inputMode="email"`。原 email 类型会让浏览器 HTML5 校验直接拦下纯手机号、表单根本无法提交,使「可填手机号」的新文案 落空;改后保持邮箱软键盘提示,同时允许手机号提交(服务端 detectUnusableOwnerEntries + resolver 仍是真正的校验权威)。 验证:pnpm build 绿;dashboard-i18n / dashboard-i18n-c5 / setup-pickers 456/456,bot-config-editor / bot-config-store / dashboard-bot-onboarding 135/135 全过。needs_owner 页面截图见 PR 汇总。 Co-Authored-By: Riff <noreply@riff.dev>
63a0b3e to
620df58
Compare
背景
冷启动时 daemon 调飞书 contact API 把
allowedUsers里的 email/on_ 解析成本 app 视角的 open_id。首次启动即遇 contact API 抽风时会锁死:运行时白名单被置空 → fail-closed 拒绝所有人(含 owner);且报警 DM 的收件人 open_id 恰恰依赖那次失败的解析 → 一条提示都发不出(静默锁死)。既有两轮修复(c497dd2a / c77773d)靠 last-known 持久化缓存兜底,但覆盖不到「有史以来第一次解析就失败」——那一刻缓存必为空,无货可兜。实测案例:bot
cli_aaec50d34d78dbfb冷启动 19:13:31 锁死、30s 后 retry 才自愈并首次落盘缓存。改动
① 加固竞态(补两个洞)
daemon.tsnotifyAllowedUsersResolveFailure:解析出的收件人为空时,回退到静态ownerOpenId(setup 扫码拿到、本 app 已验证的原生 ou_,永不需解析)→ 冷启动首失败也能把告警发出去(补洞 1)daemon.tsscheduleAllowedUsersResolveRetry:3 次重试耗尽且白名单仍为空时,补发终态 DM「请botmux restart」(原来是静默 return,补洞 2)bot-registry.ts:新增BotConfig.ownerOpenId(原生 ou_,读/写/序列化均保留;只接受ou_前缀,杜绝存入需再解析的值)cli.ts/dashboard/bot-onboarding.ts:setup 扫码链路验证通过后(resolveScannerAllowedUser用新 app 自身凭证确认该 open_id 本 app 可见),把原生 open_id 存入ownerOpenId。只在成功分支写入,保证存的一定是本 app 同视角 ou_,不会踩 99992361② 支持手机号 owner(免邮箱,方便手机号注册的个人用户)
bot-config-editor.ts:新增isMobileEntry/normalizeMobileEntry;放宽isValidAllowedUserEntry认手机号(大陆号直填 11 位 / 海外带+区号;容忍空格连字符)client.tsresolveAllowedUsersWithMap:新增mobiles分流,走batch_get_id的mobiles字段。复用现有contact:user.id:readonly权限,无需申请新 scope。完整沿用 email 分支的 transient/definitive 契约(缓存兜底/重试语义一致)身份模型正确性(review 要点)
getOwnerOpenId只找resolvedAllowedUsers里.startsWith('ou_');sender 消息带的也是 open_id。邮箱/手机号/union_id 都只是「启动时寻址方式」,最终都解析成本 app 的 ou_。手机号与邮箱运行时完全等价,走 union_id 同一出口,不引入 open_id 的 app-scoped 坑batch_get_id用本 bot 的 tenant_access_token 只在本企业租户内查,只返回 1 个 open_idownerOpenId安全:只在resolveScannerAllowedUser(用本 app 凭证contact.v3.user.get验证)成功时写入,故必为本 app 同视角 ou_影响面(按 CLAUDE.md 评估)
resolveAllowedUsersWithMap被所有 bot × 所有 CLI 共用。手机号分流排在 email 之前、二者互斥(邮箱必含@,手机号无@),不改既有 email/on_/ou_ 解析,仅新增一条 mobiles 查询验证
pnpm build✅ tsc 无类型错误bot-config-editor含新增手机号校验/解析用例、allowed-users-apply、allowed-users-cache、bot-registry)pnpm test:12 failed 均为本机预存的进程发现/沙箱类失败(v3-worker-fence/cancel-runtime/distillation-runner/goal-cli),已在 clean master(fdb105a) 复现同样 12 个,与本改动无关🤖 Generated with Claude Code