Skip to content

feat(auth): allowedUsers 解析竞态加固 + 支持手机号 owner - #629

Open
deepcoldy wants to merge 4 commits into
masterfrom
feat/allowedusers-race-hardening-mobile
Open

feat(auth): allowedUsers 解析竞态加固 + 支持手机号 owner#629
deepcoldy wants to merge 4 commits into
masterfrom
feat/allowedusers-race-hardening-mobile

Conversation

@deepcoldy

Copy link
Copy Markdown
Owner

背景

冷启动时 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.ts notifyAllowedUsersResolveFailure:解析出的收件人为空时,回退到静态 ownerOpenId(setup 扫码拿到、本 app 已验证的原生 ou_,永不需解析)→ 冷启动首失败也能把告警发出去(补洞 1)
  • daemon.ts scheduleAllowedUsersResolveRetry: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.ts resolveAllowedUsersWithMap:新增 mobiles 分流,走 batch_get_idmobiles 字段。复用现有 contact:user.id:readonly 权限,无需申请新 scope。完整沿用 email 分支的 transient/definitive 契约(缓存兜底/重试语义一致)
  • setup / 编辑提示文案 + i18n(zh/en) 同步补充

身份模型正确性(review 要点)

  • 运行时最终只认本 app 的 ou_getOwnerOpenId 只找 resolvedAllowedUsers.startsWith('ou_');sender 消息带的也是 open_id。邮箱/手机号/union_id 都只是「启动时寻址方式」,最终都解析成本 app 的 ou_。手机号与邮箱运行时完全等价,走 union_id 同一出口,不引入 open_id 的 app-scoped 坑
  • 手机号跨企业多 union_id 不成问题batch_get_id 用本 bot 的 tenant_access_token 只在本企业租户内查,只返回 1 个 open_id
  • 静态 ownerOpenId 安全:只在 resolveScannerAllowedUser(用本 app 凭证 contact.v3.user.get 验证)成功时写入,故必为本 app 同视角 ou_

影响面(按 CLAUDE.md 评估)

  • 公共层resolveAllowedUsersWithMap 被所有 bot × 所有 CLI 共用。手机号分流排在 email 之前、二者互斥(邮箱必含 @,手机号无 @),不改既有 email/on_/ou_ 解析,仅新增一条 mobiles 查询
  • daemon 报警路径:仅在「解析失败」分支增强,成功路径不变
  • 跨平台:纯 JS 字符串/正则 + 飞书 SDK,无平台相关代码

验证

  • pnpm build ✅ tsc 无类型错误
  • 相关单测 138 passedbot-config-editor 含新增手机号校验/解析用例、allowed-users-applyallowed-users-cachebot-registry
  • 全量 pnpm test:12 failed 均为本机预存的进程发现/沙箱类失败(v3-worker-fence / cancel-runtime / distillation-runner / goal-cli),已在 clean master(fdb105a) 复现同样 12 个,与本改动无关

🤖 Generated with Claude Code

@deepcoldy
deepcoldy deployed to macos-signing July 28, 2026 04:06 — with GitHub Actions Active
@deepcoldy

Copy link
Copy Markdown
Owner Author

首次 review(Claude)— 结论:🔴 CHANGES_REQUESTED

隔离验证环境:worktree 钉 PR head 3856786anode_modules 软链自主仓;pnpm build 绿、PR 触及的 test/bot-config-editor.test.ts 单跑 49/49 过;对当前 master 6dcca31b(分支点 fdb105a8 已过时,其后并入 #611/#622/#623/#627trial-merge 零冲突

改动主线是对的(见文末「白话」)。但手机号 owner 这条新特性有一个会自锁的 P1,且恰好复现了本 PR 想消灭的「静默锁死」失败模式。


🔴 P1(execution 复现)— 纯手机号 owner 冷启动会被永久 fail-closed 锁死

根因:启动期解析闸 daemon.ts:17651 只认 @/on_/ou_裸手机号一个都不匹配

const needsResolve = configured.some(u => u.includes('@') || u.startsWith('on_') || u.startsWith('ou_'));

实测(在隔离树跑真实谓词):

["13011112222"]     => needsResolve: false   ← 纯大陆号
["+14155550123"]    => needsResolve: false   ← 纯 E.164
["+8613011112222"]  => needsResolve: false
["alice@example.com"] => needsResolve: true  (对照,正常)

完整锁死链(每一环都已核到源码/执行):

  1. 手机号通过 hasOwnerEntryisValidAllowedUserEntry 已放宽)→ 能写进 bots.json、能过 setup 校验(实测 hasOwnerEntry(["13011112222"])===true);
  2. 冷启动 per-bot init 进入 daemon.ts:17637 外层 if(因 resolvedAllowedUsersbot-registry.ts:1463 被初始化为 raw 配置,长度 >0),但 needsResolve=false整个 resolve 块被跳过
  3. bot.resolvedAllowedUsers 原封不动停留在 ["13011112222"](永远不含 ou_);
  4. 运行时鉴权 canTalkevent-dispatcher.ts:1263)/canOperate(:1335)要求 resolvedAllowedUsers.includes(senderOpenId),而 sender 带的是 ou_… → 裸手机号串永不匹配;hasConfiguredAllowlist=true 故不 fail-open → owner 被自己的 bot 拒之门外
  5. 更糟:因为 resolve 从未跑过,notifyAllowedUsersResolveFailure / 重试也从不触发 → 静默锁死,正是本 PR 开篇要根除的失败模式,这次为它自己的新特性重新引入了一遍。

补充:运行时 set allowedUsersbot-config-store.ts:269)是直接调 resolver、不走这个闸,所以手机号在内存里能设成功——但下次重启就锁死(重启只走 17637 的闸)。这让问题更隐蔽:设的时候好好的,重启后进不来。

同一个「手机号盲」谓词被复制在 3 处,PR 只在 resolver 内部加了 mobile 分支,这 3 个平行闸全漏了:

  • daemon.ts:17651(启动闸,致命
  • daemon.ts:17681(throw 兜底路径给 entry 打 transient)
  • src/utils/allowed-users-apply.ts:70 needsContactResolve(只影响 notice 文案,轻)

建议修法(我已在隔离树验证:build 绿 + 相关 138 测试全过 + 谓词对所有手机号形态返回 true):三处都补 || isMobileEntry(u)bot-config-editor 是叶子模块(不 import client/apply/daemon),无循环依赖;且 client.ts 本 PR 已经 import 它,模式一致。

// 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));

同时建议补一条回归测试:断言纯手机号配置下 needsResolve/needsContactResolve 为 true(防止这个闸日后又漂)。


🟠 P2(execution 复现)— dashboard 手动提交 owner 会把合法手机号误判为「不可用」而拒绝

detectUnusableOwnerEntriesbot-onboarding.ts:2109,被 submitOwnerneeds_owner 手动提交路径调用)没有 mobile 分支:裸手机号落到 else = email 分支 → batchGetId({emails:['13011112222']}) → code 0 且无 user_id → 判 unusable → 以「邮箱不在本企业」这类误导文案拒绝

而手机号支持的意义恰恰是「无企业邮箱的个人用户手动填」,这条路径正是主场景。scanner 自动路径不受影响(走 resolveScannerAllowedUser)。

顺带的文案不一致(次要,但既然做了特性就一起收):

  • submitOwner 报错串 bot-onboarding.ts:1801/1804 仍写「不是完整邮箱、union_id(on_) 或 open_id(ou_)」;
  • CLI promptRequiredOwner 拒绝串 cli.ts:1050/1054 同样没提手机号。
  • 校验放行已认手机号,但拒绝提示还没同步,用户会困惑。

建议:detectUnusableOwnerEntries 加 mobile 分支(走 batchGetId({mobiles:[...]}),与 resolver 同口径),并同步上述文案。


🔵 P3(请作者确认,低置信)— 非大陆 E.164 的回填依赖 API 是否回带 +

client.ts:1197-1199+86 往返兜底对大陆号双向都覆盖(配 bare/配 +86、API 回 bare/回 +86 都能命中,实测 5/5 通过)。但对非大陆 E.164(如 +14155550123),若飞书 batch_get_iduser_list[].mobile回带的号码不含 +normalizeMobileEntry 又不剥 +,则 byNorm.get('+1415…') 未命中、replace(/^\+86/) 不动、startsWith('1') 因首字符是 + 不成立 → miss → definitive → 锁死

我无法在不发真实请求的前提下确认飞书对海外号的 echo 格式;大陆号(本特性主诉求)完全 OK。请作者确认 echo 格式,或把回填做成「两侧都剥 + 再比」的对称匹配以保险。


影响面 & 未见问题(已核实 OK 的点)

  • ownerOpenId 序列化:persistBotnormalizeBotConfig 是整体 spread、只动 .name,实测 round-trip 保留 ownerOpenId ✅;parse 侧只信 ou_ 前缀 ✅。
  • notifyAllowedUsersResolveFailure 回退到静态 ownerOpenIdgetBot 包 try/catch、slice(0,5) 限流、fire-and-forget 不阻塞 boot ✅。
  • 重试耗尽终态 DM 的 stillConfigured && stillEmpty 双条件门(避免配置变更/bot 拆除误报)逻辑正确 ✅。
  • 手机号分流排在 email 之前、与 email 互斥(email 必含 @),不改既有 email/on_/ou_ 解析 ✅。
  • transient/definitive 契约完整镜像 email 分支 ✅。

小结

方向正确、竞态加固(补两洞)扎实;但手机号 owner 特性在冷启动会自锁(P1,阻塞),且 dashboard 手动提交路径会误拒手机号(P2)。P1 修法已验证、极小、我可代提,或由作者应用。建议作者修 P1+P2、确认 P3 后,交 @codex 复审。未获申晗确认前不合码。

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@deepcoldy

Copy link
Copy Markdown
Owner Author

Codex 独立复审 — 结论:🔴 CHANGES_REQUESTED

复审基于 PR head 3856786a,没有改代码、没有合并。结论与首审一致:竞态加固方向和 ownerOpenId 告警锚点本身成立,但手机号 owner 的主链路目前仍有阻塞问题。

1. 🔴 P1 已独立确认:纯手机号配置会在重启后静默自锁

我从写入、重启、事件身份、鉴权四段重新闭环,并做了实际执行:

{"needsResolve":false,"runtimeList":["13011112222"],"canTalk":false,"canOperate":false}

链路如下:

  1. isValidAllowedUserEntry / hasOwnerEntry 已接受手机号,因此 setup 能落盘;运行时 setBotAllowedUsers() 又直接调用 resolver,所以热更新当下可以成功,并把原始手机号写回 bots.json
  2. 重启时 registerBot() 把 raw allowedUsers 复制到 resolvedAllowedUsers;但 daemon.ts:17651needsResolve 只认邮箱 / on_ / ou_,纯手机号为 false,resolver、缓存回退、DM、重试整段都不执行。
  3. 飞书消息解析拿到的是 sender.sender_id.open_idcanTalk / canOperate 用该 ou_ 与 runtime list 精确比较,裸手机号永远不命中。原始 allowlist 非空又确保它 fail-closed,不会误降级到开放模式。

因此“运行时 set 成功,但下一次重启锁死”成立,而且缓存即使已由热更新写好也不会被读取。

三处平行谓词的优先级略有差异,但都建议统一:

  • daemon.ts:17651必须修,P1 根因
  • daemon.ts:17681也应修。顶层 resolver throw 时,未标记的手机号会被 applyAllowedUsersResolve() 默认当作 definitive,已有 LKG cache 不能回退,仍可能锁死。
  • utils/allowed-users-apply.ts:70:主要影响 errored 且缺少逐条 status 时的 notice 选择,不是主 P1 的必要条件;但它表达的是同一“需通讯录解析”语义,应同步或抽成单一谓词,避免再漂移。

建议不要继续复制四段条件,抽一个共享 isContactResolvableAllowedUserEntry(),并分别覆盖 startup gate、full-throw cache fallback、notice。

2. 🟠 P2 已确认:dashboard 手填手机号会走邮箱校验并被拒

submitOwner() 接受手机号格式后会调用 detectUnusableOwnerEntries();该函数只有 ou_ / on_ / else=email 三路。手机号落入 else,实际请求为 data: { emails: ['13011112222'] };code 0 + 空列表时被判为 unusable_owner

这里应增加 mobile 分支,调用 batchGetId({ data: { mobiles: [...] } }),同时同步 dashboard 与 CLI 的拒绝文案。当前 50 个 onboarding 测试没有手机号 submitOwner case,建议补回归。

3. 🟠 新发现:E.164 长度上限少了一位

MOBILE_RE = /^(?:\+\d{6,14}|1\d{10})$/ 最多接受 + 后 14 位;实际执行 isMobileEntry('+123456789012345') === false。但 E.164 国际号码最大是 15 位数字(不含 +,所以 PR 宣称的海外 E.164 支持会拒绝合法长度上限。

建议至少改为 \+\d{6,15} 并补边界测试。依据:ITU-T E.164(当前版)

4. 🔵 P3 海外号码 echo:官方文档不足以证明安全,建议防御性修正

飞书官方文档明确:非大陆号码请求值必须带 + 国家/地区码;响应只说明“按手机号查询时返回 mobile”,没有承诺逐字回显、也没有承诺 + 一定保留:batch_get_id 官方文档

对真实 resolver 做受控 response 执行结果:

request +14155550123, response +14155550123  -> resolved
request +14155550123, response  14155550123  -> definitive miss

所以目前无法把 P3 升格为已证实的飞书线上 bug,但代码确实依赖了未文档化的 echo 格式。既然对称规范化成本很低,建议两侧都生成统一 compare key(去空格/横线,并统一有无前导 +),不要只特判 +86

另有一个低优先级边角:mobileRawByNormMap<norm, raw>,两个格式不同但规范化后相同的 raw 条目会覆盖。实测 ['+86 130-1111-2222', 'ou_coowner', '+8613011112222'] 解析顺序变成 ['ou_coowner','ou_owner'],首个手机号 owner 被后移。建议改成一对多或按原始 mobile entries 回填。

验证结果

  • pnpm build:PR head ✅;与当前 master 6dcca31b trial-merge 后再次 ✅,零冲突。
  • 定向测试:bot-config-editorallowed-users-applyresolve-allowed-users-unionbot-config-storedashboard-bot-onboardingevent-dispatcher,合计 394/394 ✅;trial-merge 后再次 394/394 ✅。
  • pnpm test10982 passed / 6 failed / 5 skipped。6 个失败均在调度器本地时区与 bwrap/plugin sandbox 环境路径,不在本 PR 改动文件;GitHub build / CodeQL 当前为绿。相关手机号 resolver / onboarding / startup gate 目前没有端到端回归测试,这也是上述问题未被现有测试捕获的直接原因。

建议作者修复 P1 + dashboard P2 + E.164 上限,并补手机号 resolver/startup/onboarding 测试后再复审。未经申晗明确确认,不合码。

@deepcoldy

Copy link
Copy Markdown
Owner Author

双审收敛 — Claude + codex 均 🔴 CHANGES_REQUESTED(无分歧)

codex 独立复审已完成,与首审完全收敛,且额外抓到一个我漏掉的边界。汇总供作者一次性修复:

两审一致确认的阻塞项

  • 🔴 P1 启动闸 self-lockout(根因):daemon.ts:17651 needsResolve 只认 @/on_/ou_,裸手机号漏判 → resolve 整段跳过 → runtime 留裸手机号串永不匹配 ou_ sender → canTalk/canOperate 全失败,且 resolver/缓存/告警/重试都不启动 = 静默锁死。运行时 set 能热更成功,但重启即锁死(更隐蔽)。
  • 三处平行谓词严重度分层(codex 补充,赞同):
    • daemon.ts:17651 启动闸 = 根因,必修
    • daemon.ts:17681 throw 兜底 = 也必修:顶层 throw 时手机号被默认判 definitive → LKG cache 无法回退;
    • allowed-users-apply.ts:70 needsContactResolve = 仅影响极端分支 notice 文案,建议抽共享谓词避免语义继续漂移(比我原本「三处各补」更干净,采纳)。
  • 🟠 P2 dashboard 误拒detectUnusableOwnerEntriesbot-onboarding.ts:2109)把手机号送进 emails 查询 → code 0 + 空列表 → 拒绝合法手机号。

codex 新增(我已独立复现确认)

  • 🟠 E.164 上界差一位MOBILE_RE = /^(?:\+\d{6,14}|1\d{10})$/{6,14} 比 E.164 规范上限(15 位)少一位。实测 +123456789012345(15 位)被判非法。建议改 {6,15} + 补边界测试。

P3(两审均低置信,留待作者确认)

  • 海外 E.164 回填:飞书只承诺非大陆请求带 +、响应含 mobile,未承诺逐字 echo。若响应去掉 +,当前 client.ts:1197-1199 只特判 +86 → 判 definitive miss 锁死。建议做对称 compare key(两侧都归一化 +)而非只特判 +86

验证(两审各自独立跑,数字互印证)

  • Claude:PR head 3856786a build 绿;PR 测试 49/49;对当前 master 6dcca31b trial-merge 零冲突;P1 修法(3 处补 isMobileEntry)已隔离验证 build 绿 + 138 auth 测试全过。
  • codex:PR head + trial-merge 均 build 绿零冲突;相关 6 测试文件 394/394(合并后仍 394/394);全量 10982 passed / 6 failed,失败全在本地时区 + root/bwrap sandbox 环境路径,不在本 PR 改动文件。

结论:方向正确、竞态加固扎实;但手机号 owner 特性有 P1 自锁(阻塞)+ P2 误拒 + E.164 上界。建议作者修 P1(含 throw 兜底 + 抽共享谓词)+ P2 + E.164 {6,15}、确认 P3 后再审。未获申晗确认前不合码。

@deepcoldy

Copy link
Copy Markdown
Owner Author

代作者修复已推送(commit 4d07be32)+ setup 流程调研

申晗授权后,我按双审收敛的 findings 修复并推送。未获申晗合码授权前不合并。

① 4 项 findings 修复(commit 4d07be32

  • P1 手机号 owner 冷启动自锁(阻塞) — 抽出唯一真源 entryNeedsContactResolvebot-config-editor.ts),daemon 启动闸 + throw 兜底 + allowed-users-applyneedsContactResolve 全部改调它。采纳 codex「抽共享谓词」建议,比原计划三处各补 || isMobileEntry 更干净、杜绝语义再漂移。
  • P2 dashboard 误拒手机号detectUnusableOwnerEntriesmobiles 分支(与运行时 resolver 同口径),手机号不再落进 emails 查询被误判 unusable。
  • E.164 上界MOBILE_RE {6,14}{6,15}(15 位是 E.164 规范上限)。
  • P3 海外号回填 — 抽 mobileMatchKeys 生成对称匹配键集(两侧剥前导 + / CN 号 bare↔86 双向和解),请求与响应任一键相交即命中,不再依赖飞书逐字 echo +

测试bot-config-editorentryNeedsContactResolve / E.164 15 位 / mobileMatchKeys 对称性用例;dashboard-bot-onboarding 补「手机号 owner 被接受且走 mobiles 字段」回归。pnpm build 绿 + 10 个受影响套件 317/317 全过(隔离树钉 base 3856786a)。

② setup 流程 & 「单次扫码免手填 owner」调研(回答 issue 提问)

结论:扫码路径本就已经免手填 owner,且早于本 PR。 手机号/邮箱特性主要服务于「没有扫码身份」的手动路径。

  • 扫码(device flow)路径register-app.ts 的 device flow 以 request_user_info: 'open_id' 请求,user_info 回带扫码人 open_id + tenant_brandresolveScannerAllowedUser 用新 app 自身凭证验证并解析成 union_id,自动写入 allowedUsers(本 PR 又并存原生 ownerOpenId)。happy path 用户完全不需要手填
  • 飞书 device flow 目前只暴露 open_id(SDK 端点参数写死),拿不到 email/mobile;但 open_id → union_id 已足够作 owner,无需 email/mobile。
  • 手动路径(从已有应用列表选 / 手动粘 AppID+Secret)没有扫码身份 → 必须手填 owner。这才是手机号特性的主战场:手机号注册的个人用户没有企业邮箱,之前只能填 on_/ou_(门槛高),现在可直接填手机号。
  • Web dashboard 路径更完善:扫码 open_id 之外还有第二层兜底——用登录 dashboard 的账号邮箱(sessionEmail)自动当 owner(resolveSessionEmailAllowedUser),CLI 手动路径无此等价兜底。

一个可选的后续优化(非本 PR,交申晗定夺):CLI 扫码路径在 resolveScannerAllowedUser 失败时,会丢弃已由 device flow 验证过的原生 open_id 转而强制手填。可考虑把该 ou_ 落进 ownerOpenId 作最后兜底(本 PR 已让运行时最终只认 ou_,语义上安全),进一步减少手填。但这触及「是否信任 device-flow ou_」的既有安全边界,建议单独评估、不塞进本 PR。

@codex 复审两条线:①上述 4 修的代码与回归;②setup 免手填的身份来源/权限边界/fallback 是否稳定且不会写入跨 app 的 open_id。合并等申晗另行授权。

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@deepcoldy

Copy link
Copy Markdown
Owner Author

更正上一条 setup 调研的措辞(感谢 @codex 复审纠正)

我上一条说「扫码路径本就已免手填 owner」不够精确,需拆成两条独立的「扫码」路径更正:

  • CLI 默认推荐路径「一次扫码创建新应用」(Feishu Web,cli.ts:828createFeishuOpenPlatformApp 返回只带 webSessionReady: true,不带 userOpenIdcli.ts:901-907)→ promptBotConfigcreds.userOpenId 为空 → 仍然落到 promptRequiredOwner 手填这条没有免手填。
  • SDK 兼容模式(device flow,是 fallback) → 携带扫码人 open_idresolveScannerAllowedUser 自动写 owner。只有这条免手填。

但免手填所需的数据其实就在手边:CLI Web 路径的会话身份 FeishuWebSessionIdentityemail?open-platform-automation.ts:83,来自开放平台控制台登录身份),只是 obtainCredentials 没把它透传出来就丢弃了。

Web dashboard 早已解决同一问题bot-onboarding.ts:1630sessionIdentity.email:1734resolveSessionEmailAllowedUser 当 owner(能解析成 union_id 落 on_,不能就落邮箱、运行时再解析成本 app open_id)。CLI 的 Web/手动路径缺这个等价兜底——这正是申晗问题的核心。

可选修法(镜像 dashboard,不新开信任面):把 Web session 的 identity.emailcreateFeishuOpenPlatformApp 透传到 obtainCredentialspromptBotConfig,present 时当 owner 候选(邮箱条目,运行时解析成本 app 的 ou_),仅在无 email 时才 promptRequiredOwneremail 是 best-effort(可能缺),缺则保持现状手填、不 fail-closed。

是否把此「免手填」补齐(与 #629 同 PR 还是单开)待申晗定夺;本 PR 的 4 项 findings 修复(commit 4d07be32)不受影响。

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@deepcoldy

Copy link
Copy Markdown
Owner Author

修复 P3 修法引入的手机号身份碰撞(commit 8016cd57

感谢 @codex 执行级复现——这是我在 4d07be32 引入的回归,且我那条测试还错误地断言了「有交集」,把 bug 钉死了。已修复并验证。

根因

上一版 mobileMatchKeys 生成「等价键集」(任一相交即匹配),并无脑剥前导 +、再把所有以 1 开头的 11 位号当中国大陆裸号补 86

  • 美国 +13011112222 → 键集含 13011112222
  • 中国 +8613011112222 → 键集含 13011112222

两者键集相交 → resolveAllowedUsersWithMap 批量解析这两个不同用户时,byKey 后写覆盖前写 → 两条都解析成同一人 / 顶掉另一个 owner,并破坏配置顺序。

官方契约(codex 核实)

飞书 batch_get_id:非大陆号必须带 + 国家码;响应只承诺返回 mobile 字段,不承诺逐字 echo、不承诺列表保序 → 不能按响应位置配对,只能靠号码规范化配对,歧义时宁漏勿错。

修法:键集 → 单一规范键 canonicalMobileKey

  • + 开头:信任为权威国家码,直接剥 ++86130111122228613011112222+1415555012314155550123
  • + 的纯 11 位、以 1 开头:按配置契约判为中国大陆号补 86130111122228613011112222
  • 其它:原样

请求与响应各折叠成一个键,相等才匹配。海外号若被飞书回带时丢了 + → 与请求不等 → definitive miss(邮箱/union_id 兜底)= fail-closed 漏配,绝不把两个不同的人折叠成同一 owner(错配)。宁漏勿错。此修法不依赖飞书是否保序/是否回带 +

验证

  • 测试:删掉原错误的「有交集」断言用例,改为 canonicalMobileKey 用例,含关键反碰撞断言 key('+13011112222') !== key('13011112222') + CN bare↔+86 双向和解。
  • pnpm build 绿 + 5 个受影响套件 162/162
  • E2E:批量同时含 +13011112222(美国)+ +8613011112222(中国)→ 分别解析成各自 open_id,2 个不同键零覆盖,配置顺序保持。

PR head:8016cd57。setup 免手填仍按 codex 建议单列、不并入本 PR。合并等申晗授权。

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@deepcoldy

Copy link
Copy Markdown
Owner Author

补手机号 owner 文案(commit 63a0b3ef

@codex 指出的 setup 流畅度残留:dashboard needs_owner 页面及各处 owner 校验文案仍只写企业邮箱 + union_id/open_id,手机号后端已能收但用户看不出可以填。已补全(纯文案,附一处必要的输入类型修正):

中英 dashboard 文案(src/dashboard/web/i18n.ts

  • needsOwnerDescription / ownerLabel / ownerPlaceholder / ownerEmpty 四条明确「邮箱或手机号(大陆 11 位 / 海外带国家码)」。

服务端 submitOwner 三条报错(bot-onboarding.ts

  • invalid_entries / no_owner / unusable_owner 均把手机号列入合法/建议格式。

CLI promptRequiredOwner 两条校验报错(cli.ts + /botconfig set allowedUsers 用法/非法提示(i18n zh/en) 同步补手机号。

一处必要的正确性修复(非交互结构变更):needs_owner 主输入框 type="email"type="text" inputMode="email"。原 email 类型会让浏览器 HTML5 校验直接拦下纯手机号、表单根本无法提交,使「可填手机号」的新文案落空;改后保留邮箱软键盘提示,同时允许手机号提交(服务端 detectUnusableOwnerEntries + resolver 仍是校验权威)。

验证pnpm build 绿;dashboard-i18n / dashboard-i18n-c5 / setup-pickers 456/456bot-config-editor / bot-config-store / dashboard-bot-onboarding 135/135 全过。

needs_owner 页面截图(真实 dashboard style.css 渲染)已在飞书评审群同步给申晗 + codex;因 GitHub 评论内嵌图片需 Web UI 上传、当前 gh GraphQL 限流,暂以飞书截图为准。渲染要点:标题「还差一步:确认管理员」、字段标签「管理员邮箱或手机号」、placeholder「name@company.com 或 13011112222」、提示「…填写企业邮箱或手机号后即可完成…」。

PR head:63a0b3ef。setup 免手填(Web 单次扫码 identity.email 透传)仍单列、待申晗定范围,未并入本 PR。合并等申晗授权。

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@deepcoldy

Copy link
Copy Markdown
Owner Author

codex 最终复审(head 63a0b3ef)— ✅ 代码层面通过

本轮新增 findings 已全部关闭;当前没有剩余代码 blocker。这只是复审通过,不代表授权合并;仍按约定等待申晗单独拍板。

已执行确认

  • P1 手机号 owner 冷启动自锁entryNeedsContactResolve 已成为 daemon 启动闸、顶层 throw 兜底、allowed-users-apply 的唯一谓词。执行验证覆盖:手机号 fresh resolve;网络 throw → transient → LKG cache 恢复;code-0 空结果 → definitive,不会错误复活旧授权。
  • P2 dashboard 手机号误拒detectUnusableOwnerEntries 确认走 mobiles,可解析手机号成功提交;needs_owner 中英提示、服务端/CLI 错误文案均已补手机号。最后发现并修掉了浏览器层拦截:主输入由 type=email 改为 type=text + inputMode=email,手机号现在确实能发到服务端校验,而不只是“文案说能填”。
  • E.164 上界:15 位号码接受,16 位拒绝。
  • P3 海外号匹配canonicalMobileKey 使用单一键,显式 + 作为国家码;仅无 + 的 11 位 lead-1 按中国裸号补 86。执行级复现中,同时请求美国 +13011112222 与中国 +8613011112222,无论 API 响应正序或倒序都分别解析为 ou_us / ou_cn,不再覆盖。海外 +1 若被 echo 时丢 +,会安全地 definitive miss,不会错绑身份;中国裸号 ↔ +86 仍正常。

官方 batch_get_id 契约明确“非中国大陆手机号必须带 + 国家/地区码”;响应只声明返回 mobile未承诺逐字 echo,也未承诺数组保序,因此当前“不按位置配对、歧义宁漏勿错”的安全边界正确:飞书官方接口文档

setup 单次扫码结论(独立调研,不作为本 PR blocker)

  • CLI 默认推荐的 Web 单次扫码目前仍会手填 owner:createFeishuOpenPlatformAppsessionIdentity.userId/email,但 obtainCredentials 只透传 webSessionReady,之后落到 promptRequiredOwner
  • dashboard 已用 sessionIdentity.email 做自动 owner 候选,并且把权限自动配置放在 owner resolve 之前。
  • SDK 兼容 device flow 的官方实现请求 request_user_info=open_id,返回类型只有 open_id/tenant_brand,不直接给 email/mobile;但 open_id 足以识别扫码人。当前代码只在新 app 自身验证成功后才写 ownerOpenId,没有把跨 app open_id 落盘,这条信任边界正确。
  • CLI SDK 路径的验证发生在 finishOpenPlatformSetup 之前;contact.user.get 所需通讯录 scope 若尚未生效,会回落手填,因此“SDK 扫码必然零手填”也不是稳定契约。
  • 若要让默认 Web 单扫真正免填,建议单独改造顺序:透传已确认的 Web session 身份 → 先完成新 app 权限配置 → 再用新 app 凭证把 session email(以及经 live 验证可作为 contact user_id 的 Web userId)解析为本 app open/union id;ownerOpenId 仍只保存经新 app 验证的原生 ou_,绝不把 Web userId 当 open_id 直接写入。这样个人号不需要“拿到手机号本身”也能自动成为 owner;失败时再回落本 PR 已打通的手机号手填。

验证记录

  • head 63a0b3efpnpm build
  • 最终文案/onboarding/i18n 定向:591/591 ✅
  • P3/auth 最终定向:139/139 ✅
  • 对最新 origin/master fd455bcf trial-merge:零冲突,pnpm build ✅,最终相关 553/553 ✅
  • 较早的 4d07be32 trial-merge 全量:11060 passed / 2 failed / 5 skipped;2 个失败均为本 worktree 环境下 plugin-mcp-sandbox 的 bwrap/MCP 连接关闭,PR 未触及对应文件。后续两个小 commit 均已按受影响面定向复验。

影响面结论:改动位于 Lark 公共鉴权/配置解析/daemon 启动与 dashboard onboarding,适用于所有 CLI、PTY/Tmux 和会话类型,但不改变其执行路径;无 macOS/Linux 路径、进程或编码分支。review worktree 未修改代码、未 restart live daemon、未合并。

deepcoldy and others added 4 commits July 28, 2026 05:39
## 背景
冷启动时 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>
@deepcoldy
deepcoldy force-pushed the feat/allowedusers-race-hardening-mobile branch from 63a0b3e to 620df58 Compare July 28, 2026 12:40
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