From ccb2293109f95546b890ee156fb0fb69bb858a86 Mon Sep 17 00:00:00 2001 From: deepcoldy Date: Tue, 28 Jul 2026 03:19:16 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(auth):=20allowedUsers=20=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E7=AB=9E=E6=80=81=E5=8A=A0=E5=9B=BA=20+=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=89=8B=E6=9C=BA=E5=8F=B7=20owner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 背景 冷启动时 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(fdb105a8) 复现同样 12 失败,与本改动无关 Co-Authored-By: Claude --- src/bot-registry.ts | 19 +++++++++ src/cli.ts | 13 +++++-- src/daemon.ts | 38 +++++++++++++++++- src/dashboard/bot-onboarding.ts | 10 ++++- src/i18n/en.ts | 2 +- src/i18n/zh.ts | 2 +- src/im/lark/client.ts | 69 ++++++++++++++++++++++++++++++++- src/setup/bot-config-editor.ts | 27 +++++++++++-- src/setup/setup-args.ts | 2 +- test/bot-config-editor.test.ts | 38 ++++++++++++++++++ 10 files changed, 206 insertions(+), 14 deletions(-) diff --git a/src/bot-registry.ts b/src/bot-registry.ts index f2a1e0e6f..fe9e1a6f3 100644 --- a/src/bot-registry.ts +++ b/src/bot-registry.ts @@ -999,6 +999,19 @@ export interface BotConfig { workingDir?: string; workingDirs?: string[]; allowedUsers?: string[]; + /** + * Owner's native app-scoped `open_id` (`ou_…`), captured at setup from the + * device-flow scanner identity. UNLIKE `allowedUsers` (which may hold `on_`/ + * email entries needing a contact-API resolve every boot), this is stored raw + * and never resolved — so it survives a contact-API outage. Two uses: + * 1. a fail-safe DM recipient for allowedUsers-resolve failure notices, so + * the owner is reachable even when the resolve that would have produced + * their open_id is the very thing that failed (cold-start race); + * 2. an always-available owner anchor for runtime permission checks. + * Optional: bots created before this field, or via paths without a scanner + * identity, simply have none and fall back to the resolved allowlist. + */ + ownerOpenId?: string; allowedChatGroups?: string[]; /** Oncall bindings: chat_id → default workingDir. Any group member can talk; allowedUsers still gates card buttons / daemon commands. */ oncallChats?: OncallChat[]; @@ -2121,6 +2134,12 @@ export function parseBotConfigsFromText(jsonText: string): BotConfig[] { workingDir: workingDirs?.[0] ?? entry.workingDir, workingDirs, allowedUsers: entry.allowedUsers, + // Only a well-formed native open_id is trusted; anything else (stray on_/ + // email/garbage) is dropped so the fail-safe recipient can never be a + // value that itself needs resolving. + ownerOpenId: typeof entry.ownerOpenId === 'string' && entry.ownerOpenId.startsWith('ou_') + ? entry.ownerOpenId + : undefined, allowedChatGroups, oncallChats, defaultOncall, diff --git a/src/cli.ts b/src/cli.ts index 29a3e961e..6b44ad46a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1039,8 +1039,8 @@ async function resolveScannerAllowedUser( async function promptRequiredOwner(rl: ReturnType): Promise { printInputHelp('管理员 (owner)', [ '必填。至少一个能操作机器人的管理员,多个值用逗号分隔。', - '推荐格式(优先级高到低):完整邮箱(alice@example.com)> union_id(on_xxx,跨应用稳定)> open_id(ou_xxx,仅限同一应用)。', - '注意:必须是完整邮箱,邮箱前缀(如 alice)无法解析、不接受。', + '推荐格式(优先级高到低):完整邮箱(alice@example.com)> union_id(on_xxx,跨应用稳定)> 手机号(大陆号直填 11 位,海外号带 + 区号)> open_id(ou_xxx,仅限同一应用)。', + '注意:邮箱必须完整,邮箱前缀(如 alice)无法解析、不接受。没有企业邮箱可用手机号。', ]); for (;;) { const raw = (await ask(rl, '管理员 (owner): ')).trim(); @@ -1173,6 +1173,11 @@ async function promptBotConfig(rl: ReturnType): Promise< const owner = await resolveScannerAllowedUser(creds.appId, creds.appSecret, creds.userOpenId, creds.brand); if (owner) { bot.allowedUsers = [owner]; + // Persist the native ou_ too. allowedUsers may hold the cross-app-stable + // on_ (union_id) form that needs a contact-API resolve every boot; this + // raw open_id never does, so it stays a valid fail-safe DM recipient even + // when that resolve is the very thing failing (cold-start race). + bot.ownerOpenId = creds.userOpenId; } else { console.log('⚠️ 无法确认扫码人的 open_id 属于当前新应用,请手动填写 owner。'); bot.allowedUsers = await promptRequiredOwner(rl); @@ -1359,8 +1364,8 @@ async function promptEditBotConfig( } printInputHelp('允许的用户', [ - '可选。限制哪些飞书用户可以操作机器人,支持完整邮箱(如 alice@example.com)、union_id(on_xxx)或 open_id(ou_xxx),多个值用逗号分隔。', - '注意:必须是完整邮箱,邮箱前缀(如 alice)无法解析、会被丢弃。', + '可选。限制哪些飞书用户可以操作机器人,支持完整邮箱(如 alice@example.com)、union_id(on_xxx)、手机号(大陆号直填,海外带 + 区号)或 open_id(ou_xxx),多个值用逗号分隔。', + '注意:邮箱必须完整,邮箱前缀(如 alice)无法解析、会被丢弃。', '留空保留当前值;输入 - 清空限制。', ]); input.allowedUsers = await ask(rl, `允许的用户 [${formatOptionalValue(bot.allowedUsers)}]: `); diff --git a/src/daemon.ts b/src/daemon.ts index 80932bbb6..4820c9477 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -3092,9 +3092,23 @@ function notifyAllowedUsersResolveFailure( ): void { logger.error(`[${larkAppId}] ${notice}`); const unique = [...new Set(recipients.filter(u => typeof u === 'string' && u.startsWith('ou_')))]; + // Fail-safe recipient: on a cold start the resolve that would have produced + // the owner's open_id is the very thing that just failed, so `recipients` is + // empty and the owner would hear nothing. Fall back to the static ownerOpenId + // captured at setup — a raw ou_ that never needs resolving, so it survives + // the contact-API outage. Appended (not replacing) so a partial resolve still + // reaches every recovered owner too. + let staticOwner: string | undefined; + try { + staticOwner = getBot(larkAppId).config.ownerOpenId; + } catch { /* bot gone mid-flight — nothing to fall back to */ } + if (staticOwner && staticOwner.startsWith('ou_') && !unique.includes(staticOwner)) { + unique.push(staticOwner); + } if (unique.length === 0) { logger.error( - `[${larkAppId}] allowedUsers resolve failed with no open_id recipient available for DM; ` + + `[${larkAppId}] allowedUsers resolve failed with no open_id recipient available for DM ` + + `(no resolved owner and no static ownerOpenId in bots.json); ` + `check daemon logs and Feishu contact API, then restart this bot.`, ); return; @@ -3125,7 +3139,27 @@ function notifyAllowedUsersResolveFailure( } function scheduleAllowedUsersResolveRetry(larkAppId: string, attempt = 1): void { - if (attempt > 3) return; + if (attempt > 3) { + // Retries exhausted (startup + 3 retries all degraded). Don't just fall + // silent — the owner has been locked out for ~7.5 min and auto-recovery + // won't try again. Emit a terminal notice so they know to intervene. Only + // when the allowlist is still actually broken: a config change or a bot + // teardown mid-retry is not an exhaustion worth alarming on. + try { + const bot = getBot(larkAppId); + const stillConfigured = (bot.config.allowedUsers ?? []).length > 0; + const stillEmpty = (bot.resolvedAllowedUsers ?? []).length === 0; + if (stillConfigured && stillEmpty) { + notifyAllowedUsersResolveFailure( + larkAppId, + `allowedUsers 自动解析在启动后重试 3 次仍失败,运行时白名单为空 —— 期间包括你在内的所有人都会被拒。` + + `请检查网络 / 飞书 contact API 后执行 \`botmux restart\` 重新解析。`, + bot.resolvedAllowedUsers ?? [], + ); + } + } catch { /* bot gone — nothing to report */ } + return; + } const delayMs = attempt === 1 ? 30_000 : attempt === 2 ? 120_000 : 300_000; const timer = setTimeout(() => { void (async () => { diff --git a/src/dashboard/bot-onboarding.ts b/src/dashboard/bot-onboarding.ts index a1faff1b2..f2c7509a3 100644 --- a/src/dashboard/bot-onboarding.ts +++ b/src/dashboard/bot-onboarding.ts @@ -1712,11 +1712,19 @@ export class BotOnboardingManager { // 关键顺序:先确认 owner, 再决定是否落盘 + 终态。completed 必须意味着「bots.json // 里这个 bot 带着至少一个 owner」, 绝不产出空 allowedUsers 的可启动 bot。 let ownerEntry: string | undefined; + // Native ou_ to persist as the resolve-independent fail-safe recipient — + // only when the scanner path verifies it belongs to THIS app (below). + let ownerOpenId: string | undefined; if (result.userOpenId) { // registerApp 返回的 open_id 来自扫码链路; 用新 app 自身凭证验证, 失败不 // fallback 写入该 (常为跨 app 的) ou_——避免把其他 app 视角的 open_id 固化 // 成 owner, 导致 /grant 和授权卡片一直判 non-owner。 ownerEntry = await resolveScannerAllowedUser(result.appId, result.appSecret, result.userOpenId, result.brand); + // Verified against this app (ownerEntry set) → the native open_id is a + // trustworthy owner anchor; store it raw so a boot-time contact-API blip + // can't strip our only DM recipient. (Skip on the email path below: no + // native open_id there, only an on_/email that still needs resolving.) + if (ownerEntry) ownerOpenId = result.userOpenId; } if (!ownerEntry && sessionEmail) { // Web 主路径:创建应用的登录账号邮箱就是 owner(表单第一步已展示并确认), @@ -1727,7 +1735,7 @@ export class BotOnboardingManager { } if (ownerEntry) { - const addedBotIndex = this.persistBot({ ...bot, allowedUsers: [ownerEntry] }); + const addedBotIndex = this.persistBot({ ...bot, allowedUsers: [ownerEntry], ...(ownerOpenId ? { ownerOpenId } : {}) }); if (!activationPending) { await this.runLiveStart(id, result.appId); } diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 6ed74a22c..8b1d08dca 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -732,7 +732,7 @@ export const messages: Record = { 'setup.supported_clis': 'Supported CLIs: 1) claude-code 2) aiden 3) coco 4) codex 5) cursor 6) gemini 7) opencode 8) antigravity 9) mtr 10) hermes 11) codex-app 12) mira 13) seed 14) traex 15) pi 16) copilot 17) oh-my-pi 18) relay', 'setup.prompt_cli_choice': 'CLI adapter [1]: ', 'setup.prompt_working_dir': 'Default working directory [~]: ', - 'setup.prompt_allowed_users': 'Allowed users (emails or open_ids, comma-separated; empty = no restriction): ', + 'setup.prompt_allowed_users': 'Allowed users (emails / mobiles / open_ids, comma-separated; empty = no restriction): ', 'setup.prompt_lang': 'UI language [zh/en, default en]: ', 'setup.section_lark_app_config': '── Lark app config ──', 'setup.section_reconfigure': '── Reconfigure ──', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 3093abb0f..2fcb2b7f8 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -735,7 +735,7 @@ export const messages: Record = { 'setup.supported_clis': '支持的 CLI: 1) claude-code 2) aiden 3) coco 4) codex 5) cursor 6) gemini 7) opencode 8) antigravity 9) mtr 10) hermes 11) codex-app 12) mira 13) seed 14) traex 15) pi 16) copilot 17) oh-my-pi 18) relay', 'setup.prompt_cli_choice': 'CLI 适配器 [1]: ', 'setup.prompt_working_dir': '默认工作目录 [~]: ', - 'setup.prompt_allowed_users': '允许的用户 (邮箱或 open_id,逗号分隔,留空=不限制): ', + 'setup.prompt_allowed_users': '允许的用户 (邮箱/手机号/open_id,逗号分隔,留空=不限制): ', 'setup.prompt_lang': 'UI 语言 / UI language [zh/en, 默认 zh]: ', 'setup.section_lark_app_config': '── 飞书应用配置 ──', 'setup.section_reconfigure': '── 重新配置 ──', diff --git a/src/im/lark/client.ts b/src/im/lark/client.ts index 6e4e80dd6..2fb09d27a 100644 --- a/src/im/lark/client.ts +++ b/src/im/lark/client.ts @@ -13,6 +13,7 @@ import { listObservedBots } from '../../services/observed-bots-store.js'; import { getBotCapability } from '../../services/bot-profile-store.js'; import { resolveTeamRoleFile } from '../../core/role-resolver.js'; import { type Brand, larkHosts, normalizeBrand, sdkDomain } from './lark-hosts.js'; +import { isMobileEntry, normalizeMobileEntry } from '../../setup/bot-config-editor.js'; type LarkRequestParams = Record; @@ -1034,6 +1035,13 @@ export async function resolveAllowedUsersWithMap( const openIds: string[] = []; const emails: string[] = []; const unionIds: string[] = []; + // Mobile entries: keep the raw config string as the map key (exact-match with + // allowedUsers), but remember the normalized (spaces/dashes stripped) form to + // send to the API. batch_get_id accepts `mobiles` under the same + // contact:user.id:readonly scope as emails. Lets phone-registered users with + // no corporate email be an owner. + const mobiles: string[] = []; + const mobileRawByNorm = new Map(); for (const v of raw) { if (v.startsWith('ou_')) { map.set(v, v); @@ -1045,12 +1053,16 @@ export async function resolveAllowedUsersWithMap( // union_id (跨应用稳定):运行时权限/私信/卡片全是 open_id 原生的, // 启动时用本 app 凭证把 on_ 翻成本 app 的 ou_,下游一律照旧用 open_id。 unionIds.push(v); + } else if (isMobileEntry(v)) { + const norm = normalizeMobileEntry(v); + mobiles.push(norm); + mobileRawByNorm.set(norm, v); } else { emails.push(v); } } - if (emails.length > 0 || unionIds.length > 0 || openIds.length > 0) { + if (emails.length > 0 || unionIds.length > 0 || openIds.length > 0 || mobiles.length > 0) { const c = getBotClient(larkAppId); // Literal open_id is app-scoped. Keep it as-is for compatibility, but @@ -1150,6 +1162,61 @@ export async function resolveAllowedUsersWithMap( logger.warn(`resolveAllowedUsers failed: ${err.message}`); } } + + if (mobiles.length > 0) { + // Mirror the email branch exactly (same transient/definitive contract), + // but over the `mobiles` field. Map keys are the RAW config entries (via + // mobileRawByNorm) so exact-match with allowedUsers holds even though the + // API is queried with the normalized number. + try { + const res = await (c as any).contact.v3.user.batchGetId({ + params: { user_id_type: 'open_id' }, + data: { mobiles, include_resigned: false }, + }); + if (res.code !== 0) { + // Whole-request failure — not a per-mobile verdict. Mark every + // requested mobile TRANSIENT so a real owner isn't fail-closed out. + errored = true; + for (const norm of mobiles) { + const rawEntry = mobileRawByNorm.get(norm) ?? norm; + entryStatus.set(rawEntry, 'transient'); + } + logger.warn(`Failed to resolve mobiles to open_ids: ${res.msg} (code: ${res.code})`); + } else { + const userList: any[] = res.data?.user_list ?? []; + // Build normalized(mobile) → user_id, then backfill each raw entry. + const byNorm = new Map(); + for (const item of userList) { + if (item.user_id && item.mobile) byNorm.set(normalizeMobileEntry(String(item.mobile)), item.user_id); + else if (!item.user_id) logger.warn(`Could not resolve mobile: ${item.mobile}`); + } + for (const norm of mobiles) { + const rawEntry = mobileRawByNorm.get(norm) ?? norm; + // API may echo the mobile with/without country code; try the exact + // normalized form first, then a +86-stripped / -added CN variant. + const uid = byNorm.get(norm) + ?? byNorm.get(norm.replace(/^\+86/, '')) + ?? (norm.startsWith('1') ? byNorm.get('+86' + norm) : undefined); + if (uid) { + map.set(rawEntry, uid); + entryStatus.set(rawEntry, 'resolved'); + logger.info(`Resolved ${rawEntry} → ${uid}`); + } else { + // code-0 but this mobile absent from user_list → definitive miss + // (no such user / not visible), same as the email case. + entryStatus.set(rawEntry, 'definitive'); + } + } + } + } catch (err: any) { + errored = true; + for (const norm of mobiles) { + const rawEntry = mobileRawByNorm.get(norm) ?? norm; + entryStatus.set(rawEntry, 'transient'); + } + logger.warn(`resolveAllowedUsers (mobiles) failed: ${err.message}`); + } + } } // 解析不改变顺序:按 allowedUsers 的「原始配置顺序」回填 open_id,使 diff --git a/src/setup/bot-config-editor.ts b/src/setup/bot-config-editor.ts index 7bb96be5f..08ef68f5b 100644 --- a/src/setup/bot-config-editor.ts +++ b/src/setup/bot-config-editor.ts @@ -95,16 +95,37 @@ export function resolveCliId(input: string | undefined): CliId | undefined { /** 完整邮箱(含 @ 和域名)。用于区分"完整邮箱"与"邮箱前缀"——后者解析时会被静默丢弃。 */ const FULL_EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +/** + * 手机号 allowedUsers 条目。飞书 batch_get_id 的 `mobiles` 字段:中国大陆号 + * 可直接填 11 位(无需 +86);非大陆号必须带 `+` 国家/地区码。为避免把邮箱 + * 前缀/随手输入误判成手机号,规则收紧为二选一: + * - `+` 开头的 E.164:`+` 后跟 6–14 位数字(如 +14155550123、+8613011112222) + * - 纯 11 位大陆号,以 1 开头(如 13011112222) + * 允许中间出现空格/连字符,判定前先归一化掉。 + */ +const MOBILE_RE = /^(?:\+\d{6,14}|1\d{10})$/; + +/** 去掉手机号里的空格与连字符(用户可能填 "+86 130-1111-2222"),便于校验/解析。 */ +export function normalizeMobileEntry(entry: string): string { + return entry.trim().replace(/[\s-]/g, ''); +} + +/** 是否是手机号形式的 allowedUsers 条目(已归一化空格/连字符后判定)。 */ +export function isMobileEntry(entry: string): boolean { + return MOBILE_RE.test(normalizeMobileEntry(entry)); +} + /** * 合法的 allowedUsers 条目: * - on_xxx union_id — 跨应用稳定,推荐 * - 完整邮箱 — 人类可读,推荐 + * - 手机号 — 大陆号直填 11 位;海外号带 + 区号。适合无企业邮箱的个人用户 * - ou_xxx open_id — 仅对签发该 ID 的同一应用有效,不推荐跨 bot 复用 - * 裸邮箱前缀(如 "alice")不合法:解析器只认 ou_/on_ 或完整邮箱。 + * 裸邮箱前缀(如 "alice")不合法:解析器只认 ou_/on_、完整邮箱或手机号。 */ export function isValidAllowedUserEntry(entry: string): boolean { const s = entry.trim(); - return s.startsWith('ou_') || s.startsWith('on_') || FULL_EMAIL_RE.test(s); + return s.startsWith('ou_') || s.startsWith('on_') || FULL_EMAIL_RE.test(s) || isMobileEntry(s); } /** 返回非法的 allowedUsers 条目(既不是 ou_ 也不是完整邮箱,典型是裸邮箱前缀)。 */ @@ -419,7 +440,7 @@ export function applyBotConfigEdits>( const invalid = findInvalidAllowedUserEntries(entries); if (invalid.length > 0) { throw new Error( - `allowedUsers 条目必须是完整邮箱(如 alice@example.com)或 open_id(ou_xxx),不能是邮箱前缀: ${invalid.join(', ')}`, + `allowedUsers 条目必须是完整邮箱(如 alice@example.com)、手机号(大陆号直填 11 位,海外号带 + 区号)、union_id(on_xxx)或 open_id(ou_xxx),不能是邮箱前缀: ${invalid.join(', ')}`, ); } out.allowedUsers = entries; diff --git a/src/setup/setup-args.ts b/src/setup/setup-args.ts index 60bf1beed..dbd09b7e4 100644 --- a/src/setup/setup-args.ts +++ b/src/setup/setup-args.ts @@ -318,7 +318,7 @@ export function buildBotFromAddFlags(flags: SetupBotFlags): Record }; const bot = applyBotConfigEdits(base, input); if (!hasOwnerEntry(bot.allowedUsers)) { - throw new Error('--allowed-users 至少需要一个完整邮箱、union_id(on_xxx)或 open_id(ou_xxx)作为 owner。'); + throw new Error('--allowed-users 至少需要一个完整邮箱、手机号(大陆号直填,海外带 + 区号)、union_id(on_xxx)或 open_id(ou_xxx)作为 owner。'); } assertOwnerWhenChatGroups(bot); return bot; diff --git a/test/bot-config-editor.test.ts b/test/bot-config-editor.test.ts index c9aaaa007..1ed20c70a 100644 --- a/test/bot-config-editor.test.ts +++ b/test/bot-config-editor.test.ts @@ -7,8 +7,10 @@ import { botProcessName, findInvalidAllowedUserEntries, hasOwnerEntry, + isMobileEntry, isValidAllowedUserEntry, normalizeBotConfig, + normalizeMobileEntry, parseBotConfigsJson, parseBotSelection, removeBotConfig, @@ -97,6 +99,35 @@ describe('parseBotSelection', () => { }); }); +describe('mobile allowedUsers entries', () => { + it('normalizeMobileEntry strips spaces and dashes', () => { + expect(normalizeMobileEntry('+86 130-1111-2222')).toBe('+8613011112222'); + expect(normalizeMobileEntry(' 130 1111 2222 ')).toBe('13011112222'); + }); + + it('isMobileEntry accepts CN bare 11-digit and + country-code E.164', () => { + expect(isMobileEntry('13011112222')).toBe(true); // CN, no +86 + expect(isMobileEntry('+8613011112222')).toBe(true); // CN with code + expect(isMobileEntry('+14155550123')).toBe(true); // US + expect(isMobileEntry('+86 130-1111-2222')).toBe(true); // spaced/dashed + }); + + it('isMobileEntry rejects things that are not phone numbers', () => { + expect(isMobileEntry('alice')).toBe(false); // bare prefix + expect(isMobileEntry('alice@example.com')).toBe(false); // email + expect(isMobileEntry('12345')).toBe(false); // too short / not CN + expect(isMobileEntry('2011112222')).toBe(false); // 10-digit non-CN, no + + expect(isMobileEntry('ou_abc')).toBe(false); // open_id + expect(isMobileEntry('+123')).toBe(false); // too short for E.164 + }); + + it('isValidAllowedUserEntry treats a valid mobile as valid', () => { + expect(isValidAllowedUserEntry('13011112222')).toBe(true); + expect(isValidAllowedUserEntry('+14155550123')).toBe(true); + expect(findInvalidAllowedUserEntries(['13011112222', 'alice'])).toEqual(['alice']); + }); +}); + describe('applyBotConfigEdits', () => { it('normalizes the custom bot status name', () => { expect(botProcessName({ name: 'botmux-Codex Main' }, 0)).toBe('botmux-Codex-Main'); @@ -197,6 +228,13 @@ describe('applyBotConfigEdits', () => { expect(edited.allowedUsers).toEqual(['alice@example.com', 'ou_abc']); }); + it('accepts mobile numbers in allowedUsers (CN bare 11-digit + E.164)', () => { + const edited = applyBotConfigEdits({ + larkAppId: 'app', larkAppSecret: 'secret', cliId: 'claude-code', + }, { allowedUsers: '13011112222, +14155550123, on_x' }); + expect(edited.allowedUsers).toEqual(['13011112222', '+14155550123', 'on_x']); + }); + it('keeps fields unchanged on empty input and clears optional fields with dash', () => { const updated = applyBotConfigEdits({ larkAppId: 'app', From b9a01c8061d411fa62ae850c5d32155f71113790 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B3=E6=99=97?= Date: Tue, 28 Jul 2026 02:39:32 -0700 Subject: [PATCH 2/4] =?UTF-8?q?fix(auth):=20=E4=BF=AE=E5=A4=8D=E6=89=8B?= =?UTF-8?q?=E6=9C=BA=E5=8F=B7=20owner=20=E5=86=B7=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E8=87=AA=E9=94=81=20+=20dashboard=20=E8=AF=AF=E6=8B=92=20+=20E?= =?UTF-8?q?.164=20=E4=B8=8A=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 双审(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 --- src/daemon.ts | 5 ++-- src/dashboard/bot-onboarding.ts | 15 +++++++++- src/im/lark/client.ts | 33 ++++++++++++++------- src/setup/bot-config-editor.ts | 38 ++++++++++++++++++++++-- src/utils/allowed-users-apply.ts | 5 ++-- test/bot-config-editor.test.ts | 40 +++++++++++++++++++++++++ test/dashboard-bot-onboarding.test.ts | 42 +++++++++++++++++++++++++++ 7 files changed, 161 insertions(+), 17 deletions(-) diff --git a/src/daemon.ts b/src/daemon.ts index 4820c9477..922f1c6c4 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -91,6 +91,7 @@ import { checkAllowedChatGroupsConfig } from './services/allowed-chat-groups.js' import type { Session, VcMeetingImTurnOrigin } from './types.js'; import { ensureCjkFontsInstalled } from './utils/font-installer.js'; import { scrubTmuxServerGlobalEnv } from './setup/ensure-tmux.js'; +import { entryNeedsContactResolve } from './setup/bot-config-editor.js'; import { invalidWorkingDirs } from './utils/working-dir.js'; import { validateWorkingDir } from './core/working-dir.js'; import type { DaemonToWorker, LarkMessage } from './types.js'; @@ -17783,7 +17784,7 @@ export async function startDaemon(botIndex?: number): Promise { // schedule retries. The cache is a persistent sidecar — NOT the dashboard // descriptor, which is overwritten early in boot and deleted on shutdown. const configured = bot.config.allowedUsers ?? bot.resolvedAllowedUsers; - const needsResolve = configured.some(u => u.includes('@') || u.startsWith('on_') || u.startsWith('ou_')); + const needsResolve = configured.some(entryNeedsContactResolve); if (needsResolve) { const previousResolvedMap = readAllowedUsersCache(cfg.larkAppId); try { @@ -17813,7 +17814,7 @@ export async function startDaemon(botIndex?: number): Promise { // entry transient so the pure fn can recover each from cache. const throwStatus = new Map(); for (const e of configured) { - if (e.includes('@') || e.startsWith('on_') || e.startsWith('ou_')) throwStatus.set(e, 'transient'); + if (entryNeedsContactResolve(e)) throwStatus.set(e, 'transient'); } const applied = applyAllowedUsersResolve({ rawEntries: configured, diff --git a/src/dashboard/bot-onboarding.ts b/src/dashboard/bot-onboarding.ts index f2c7509a3..48c114a87 100644 --- a/src/dashboard/bot-onboarding.ts +++ b/src/dashboard/bot-onboarding.ts @@ -4,7 +4,7 @@ import { dirname, join } from 'node:path'; import { readBotsJsonOrEmpty, writeBotsJsonAtomic } from '../setup/bots-store.js'; import { atomicWriteFileSync } from '../utils/atomic-write.js'; import { logger } from '../utils/logger.js'; -import { normalizeBotConfig, findInvalidAllowedUserEntries, hasOwnerEntry } from '../setup/bot-config-editor.js'; +import { normalizeBotConfig, findInvalidAllowedUserEntries, hasOwnerEntry, isMobileEntry, normalizeMobileEntry } from '../setup/bot-config-editor.js'; import { tryRegisterApp, type RegisterAppOptions, type RegisterAppResult } from '../setup/register-app.js'; import { validateCredentials, @@ -2131,6 +2131,19 @@ async function detectUnusableOwnerEntries( } else if (entry.startsWith('on_')) { // union_id:无确凿的跨 app 否定信号, 放行。 continue; + } else if (isMobileEntry(entry)) { + // 手机号走 batch_get_id 的 `mobiles` 字段(与运行时 resolver 同口径), + // 不能落到下面的 emails 分支——否则手机号被当邮箱查, code 0 + 空 user_list + // 会把合法手机号误判为「不在本企业」而拒绝(P2)。判定同 email:成功响应 + // 里没有任何带 user_id 的条目 → 确凿不在本企业。 + const res = await client.contact.v3.user.batchGetId({ + params: { user_id_type: 'open_id' }, + data: { mobiles: [normalizeMobileEntry(entry)], include_resigned: false }, + }); + if (res?.code === 0) { + const list: any[] = res.data?.user_list ?? []; + if (!list.some(u => u?.user_id)) unusable.push(entry); + } } else { const res = await client.contact.v3.user.batchGetId({ params: { user_id_type: 'open_id' }, diff --git a/src/im/lark/client.ts b/src/im/lark/client.ts index 2fb09d27a..dd4a0dfc7 100644 --- a/src/im/lark/client.ts +++ b/src/im/lark/client.ts @@ -13,7 +13,7 @@ import { listObservedBots } from '../../services/observed-bots-store.js'; import { getBotCapability } from '../../services/bot-profile-store.js'; import { resolveTeamRoleFile } from '../../core/role-resolver.js'; import { type Brand, larkHosts, normalizeBrand, sdkDomain } from './lark-hosts.js'; -import { isMobileEntry, normalizeMobileEntry } from '../../setup/bot-config-editor.js'; +import { isMobileEntry, mobileMatchKeys, normalizeMobileEntry } from '../../setup/bot-config-editor.js'; type LarkRequestParams = Record; @@ -1184,19 +1184,32 @@ export async function resolveAllowedUsersWithMap( logger.warn(`Failed to resolve mobiles to open_ids: ${res.msg} (code: ${res.code})`); } else { const userList: any[] = res.data?.user_list ?? []; - // Build normalized(mobile) → user_id, then backfill each raw entry. - const byNorm = new Map(); + // Build a match index from the API echo. The API may echo a mobile + // with or without country code / leading `+`, and Feishu does NOT + // promise a byte-identical echo — so index each echoed number under + // its full symmetric key set (see mobileMatchKeys) rather than a + // single literal form. + const byKey = new Map(); for (const item of userList) { - if (item.user_id && item.mobile) byNorm.set(normalizeMobileEntry(String(item.mobile)), item.user_id); - else if (!item.user_id) logger.warn(`Could not resolve mobile: ${item.mobile}`); + if (item.user_id && item.mobile) { + for (const key of mobileMatchKeys(normalizeMobileEntry(String(item.mobile)))) { + byKey.set(key, item.user_id); + } + } else if (!item.user_id) { + logger.warn(`Could not resolve mobile: ${item.mobile}`); + } } for (const norm of mobiles) { const rawEntry = mobileRawByNorm.get(norm) ?? norm; - // API may echo the mobile with/without country code; try the exact - // normalized form first, then a +86-stripped / -added CN variant. - const uid = byNorm.get(norm) - ?? byNorm.get(norm.replace(/^\+86/, '')) - ?? (norm.startsWith('1') ? byNorm.get('+86' + norm) : undefined); + // Symmetric match: try every equivalent key of the REQUESTED number + // against the echo index. Covers +/no-+ (overseas E.164) and the CN + // bare-11 ↔ 86-prefixed variance in both directions, without relying + // on the API to echo the exact string we sent. + let uid: string | undefined; + for (const key of mobileMatchKeys(norm)) { + uid = byKey.get(key); + if (uid) break; + } if (uid) { map.set(rawEntry, uid); entryStatus.set(rawEntry, 'resolved'); diff --git a/src/setup/bot-config-editor.ts b/src/setup/bot-config-editor.ts index 08ef68f5b..006241eb9 100644 --- a/src/setup/bot-config-editor.ts +++ b/src/setup/bot-config-editor.ts @@ -99,11 +99,12 @@ const FULL_EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; * 手机号 allowedUsers 条目。飞书 batch_get_id 的 `mobiles` 字段:中国大陆号 * 可直接填 11 位(无需 +86);非大陆号必须带 `+` 国家/地区码。为避免把邮箱 * 前缀/随手输入误判成手机号,规则收紧为二选一: - * - `+` 开头的 E.164:`+` 后跟 6–14 位数字(如 +14155550123、+8613011112222) + * - `+` 开头的 E.164:`+` 后跟 6–15 位数字(E.164 规范上限 15 位,如 + * +14155550123、+8613011112222、+123456789012345) * - 纯 11 位大陆号,以 1 开头(如 13011112222) * 允许中间出现空格/连字符,判定前先归一化掉。 */ -const MOBILE_RE = /^(?:\+\d{6,14}|1\d{10})$/; +const MOBILE_RE = /^(?:\+\d{6,15}|1\d{10})$/; /** 去掉手机号里的空格与连字符(用户可能填 "+86 130-1111-2222"),便于校验/解析。 */ export function normalizeMobileEntry(entry: string): string { @@ -115,6 +116,39 @@ export function isMobileEntry(entry: string): boolean { return MOBILE_RE.test(normalizeMobileEntry(entry)); } +/** + * 手机号「匹配键集」:把一个已归一化的手机号展开成一组等价键,用于把飞书 + * `batch_get_id` 响应里回带的号码对回本地请求的号码。**对称设计**——请求侧和 + * 响应侧都用本函数生成键集,任一键相交即视为同一号码,避免依赖「飞书是否逐字 + * echo」这种未承诺的行为: + * - **先剥前导 `+`**:飞书对海外 E.164 号可能回带也可能不回带 `+`,两侧都剥掉 + * `+` 后比较,海外号不再因 `+` 有无而漏配(否则判 definitive → owner 自锁)。 + * - **中国大陆号双向和解**:本地可填 11 位裸号,飞书可能回 `86` 前缀(反之亦然), + * 故 `8613…`↔`13…` 两种形式都进键集。 + */ +export function mobileMatchKeys(normalized: string): string[] { + const bare = normalized.replace(/^\+/, ''); + const keys = new Set([bare]); + if (/^86\d{11}$/.test(bare)) keys.add(bare.slice(2)); // 8613… → 13…(大陆去区号) + if (/^1\d{10}$/.test(bare)) keys.add('86' + bare); // 13… → 8613…(大陆加区号) + return [...keys]; +} + +/** + * 单个 allowedUsers 条目是否需要「启动/刷新时经飞书 contact API 解析成本 app + * 的 open_id」。运行时权限(canTalk/canOperate)只认原生 ou_,故 email / union_id + * (on_) / 手机号 / 甚至字面 ou_(要做跨 app 诊断)都要走一次解析;只有无法寻址 + * 的裸串(邮箱前缀等)不需要。 + * + * 这是解析闸的**唯一真源**——daemon 启动闸、throw 兜底、allowed-users-apply 的 + * needsContactResolve 全都调它,避免「新增一种身份格式后各处平行谓词漏改」导致 + * 该格式在入口校验通过、却在某个解析闸被静默跳过(手机号 owner 冷启动自锁即 + * 此类 bug)。新增身份格式时**只改这里**。 + */ +export function entryNeedsContactResolve(entry: string): boolean { + return entry.includes('@') || entry.startsWith('on_') || entry.startsWith('ou_') || isMobileEntry(entry); +} + /** * 合法的 allowedUsers 条目: * - on_xxx union_id — 跨应用稳定,推荐 diff --git a/src/utils/allowed-users-apply.ts b/src/utils/allowed-users-apply.ts index 2a8349d46..ed6f8a3d9 100644 --- a/src/utils/allowed-users-apply.ts +++ b/src/utils/allowed-users-apply.ts @@ -19,6 +19,7 @@ */ import type { EntryResolveStatus } from '../im/lark/client.js'; +import { entryNeedsContactResolve } from '../setup/bot-config-editor.js'; export interface AllowedUsersResolveResultLike { resolved: string[]; @@ -65,9 +66,9 @@ export interface ApplyAllowedUsersResolveOutput { notice: string | null; } -/** Config entries that require a contact resolve (email / union / literal ou_). */ +/** Config entries that require a contact resolve (email / union / literal ou_ / mobile). */ function needsContactResolve(rawEntries: string[]): boolean { - return rawEntries.some(u => u.includes('@') || u.startsWith('on_') || u.startsWith('ou_')); + return rawEntries.some(entryNeedsContactResolve); } function entryStatusOf( diff --git a/test/bot-config-editor.test.ts b/test/bot-config-editor.test.ts index 1ed20c70a..84a5b04c2 100644 --- a/test/bot-config-editor.test.ts +++ b/test/bot-config-editor.test.ts @@ -5,10 +5,12 @@ import { assertOwnerWhenChatGroups, botProcessEnv, botProcessName, + entryNeedsContactResolve, findInvalidAllowedUserEntries, hasOwnerEntry, isMobileEntry, isValidAllowedUserEntry, + mobileMatchKeys, normalizeBotConfig, normalizeMobileEntry, parseBotConfigsJson, @@ -126,6 +128,44 @@ describe('mobile allowedUsers entries', () => { expect(isValidAllowedUserEntry('+14155550123')).toBe(true); expect(findInvalidAllowedUserEntries(['13011112222', 'alice'])).toEqual(['alice']); }); + + it('isMobileEntry accepts the full 15-digit E.164 upper bound', () => { + // E.164 caps the national+country number at 15 digits. The old /\+\d{6,14}/ + // bound rejected the max-length case; guard against that regression. + expect(isMobileEntry('+123456789012345')).toBe(true); // 15 digits — max E.164 + expect(isMobileEntry('+12345678901234')).toBe(true); // 14 digits + expect(isMobileEntry('+1234567890123456')).toBe(false); // 16 digits — over spec + }); + + it('entryNeedsContactResolve covers every addressable form incl. bare mobile', () => { + // This shared predicate is the SINGLE gate the daemon startup / throw-fallback + // / allowed-users-apply all consult. A bare mobile MUST return true, else a + // mobile-only owner is never resolved to an ou_ and gets fail-closed locked + // out on every cold start (the P1 this fix closes). + expect(entryNeedsContactResolve('13011112222')).toBe(true); // CN bare mobile + expect(entryNeedsContactResolve('+14155550123')).toBe(true); // E.164 mobile + expect(entryNeedsContactResolve('+8613011112222')).toBe(true); // CN +86 + expect(entryNeedsContactResolve('alice@example.com')).toBe(true);// email + expect(entryNeedsContactResolve('on_abc')).toBe(true); // union_id + expect(entryNeedsContactResolve('ou_abc')).toBe(true); // literal ou_ (diag) + expect(entryNeedsContactResolve('alice')).toBe(false); // bare prefix — unaddressable + expect(entryNeedsContactResolve('')).toBe(false); + }); + + it('mobileMatchKeys reconciles +/no-+ and CN bare↔+86 symmetrically', () => { + // Overseas E.164: with or without a leading + must share a key so the API + // echo matches regardless of whether Feishu echoes the +. + expect(mobileMatchKeys('+14155550123')).toContain('14155550123'); + expect(mobileMatchKeys('14155550123')).toContain('14155550123'); + // Request '+14155550123' and echo '14155550123' (+ dropped) intersect: + const req = new Set(mobileMatchKeys('+14155550123')); + expect(mobileMatchKeys('14155550123').some(k => req.has(k))).toBe(true); + // CN bare 11-digit ↔ 86-prefixed both directions: + expect(mobileMatchKeys('13011112222')).toContain('8613011112222'); + expect(mobileMatchKeys('+8613011112222')).toContain('13011112222'); + const cnBare = new Set(mobileMatchKeys('13011112222')); + expect(mobileMatchKeys('+8613011112222').some(k => cnBare.has(k))).toBe(true); + }); }); describe('applyBotConfigEdits', () => { diff --git a/test/dashboard-bot-onboarding.test.ts b/test/dashboard-bot-onboarding.test.ts index 2319e8607..142a0490f 100644 --- a/test/dashboard-bot-onboarding.test.ts +++ b/test/dashboard-bot-onboarding.test.ts @@ -425,6 +425,48 @@ describe('BotOnboardingManager', () => { rmSync(dir, { recursive: true, force: true }); }); + it('submitOwner accepts a resolvable mobile owner and queries the mobiles field (not emails)', async () => { + // Regression for the P2 where detectUnusableOwnerEntries sent a mobile into + // the `emails` query → code 0 + empty user_list → a valid mobile owner was + // wrongly rejected as "unusable". The mobile must go through the `mobiles` + // field, and a resolvable one must be accepted. + const dir = mkdtempSync(join(tmpdir(), 'botmux-onboard-mobile-')); + const manager = new BotOnboardingManager({ + botsJsonPath: join(dir, 'bots.json'), + registerApp: async () => ({ + ok: true, + appId: 'cli_new', + appSecret: 'super-secret-value', + brand: 'feishu', + userOpenId: 'ou_owner', + }), + validateCredentials: async () => ({ ok: true }), + automateOpenPlatform: async () => autoOk(), + renderQrDataUrl: () => 'data:image/svg+xml;base64,qr', + }); + const job = manager.start(); + await job.done; + expect(manager.get(job.id)?.status).toBe('needs_owner'); + + // 该手机号在本企业可解析 → usable → 通过。 + batchGetIdMock.mockResolvedValueOnce({ + code: 0, + data: { user_list: [{ mobile: '13011112222', user_id: 'ou_resolved_mobile' }] }, + }); + const r = await manager.submitOwner(job.id, ['13011112222']); + expect(r.ok).toBe(true); + expect(manager.get(job.id)?.status).toBe('completed'); + + // 关键断言:查询走的是 mobiles 字段(不是 emails),否则合法手机号会被误拒。 + expect(batchGetIdMock).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ mobiles: ['13011112222'] }) }), + ); + const bots = JSON.parse(readFileSync(join(dir, 'bots.json'), 'utf-8')); + expect(bots[0]).toMatchObject({ larkAppId: 'cli_new', allowedUsers: ['13011112222'] }); + + rmSync(dir, { recursive: true, force: true }); + }); + it('restores a needs_owner job after a dashboard restart and then completes it', async () => { const dir = mkdtempSync(join(tmpdir(), 'botmux-onboard-restart-')); const botsJsonPath = join(dir, 'bots.json'); From db936eae709843efe8f92891eddae459359b4b78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B3=E6=99=97?= Date: Tue, 28 Jul 2026 03:01:08 -0700 Subject: [PATCH 3/4] =?UTF-8?q?fix(auth):=20=E4=BF=AE=E5=A4=8D=E6=89=8B?= =?UTF-8?q?=E6=9C=BA=E5=8F=B7=E8=A7=84=E8=8C=83=E5=8C=96=E9=94=AE=E6=8A=8A?= =?UTF-8?q?=E7=BE=8E=E5=9B=BD+1=E5=8F=B7=E4=B8=8E=E4=B8=AD=E5=9B=BD?= =?UTF-8?q?=E8=A3=B8=E5=8F=B7=E6=8A=98=E5=8F=A0=E6=88=90=E5=90=8C=E9=94=AE?= =?UTF-8?q?=E7=9A=84=E8=BA=AB=E4=BB=BD=E7=A2=B0=E6=92=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审(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 --- src/im/lark/client.ts | 33 +++++++++++++++------------------ src/setup/bot-config-editor.ts | 34 ++++++++++++++++++++-------------- test/bot-config-editor.test.ts | 30 ++++++++++++++++-------------- 3 files changed, 51 insertions(+), 46 deletions(-) diff --git a/src/im/lark/client.ts b/src/im/lark/client.ts index dd4a0dfc7..35a75db78 100644 --- a/src/im/lark/client.ts +++ b/src/im/lark/client.ts @@ -13,7 +13,7 @@ import { listObservedBots } from '../../services/observed-bots-store.js'; import { getBotCapability } from '../../services/bot-profile-store.js'; import { resolveTeamRoleFile } from '../../core/role-resolver.js'; import { type Brand, larkHosts, normalizeBrand, sdkDomain } from './lark-hosts.js'; -import { isMobileEntry, mobileMatchKeys, normalizeMobileEntry } from '../../setup/bot-config-editor.js'; +import { canonicalMobileKey, isMobileEntry, normalizeMobileEntry } from '../../setup/bot-config-editor.js'; type LarkRequestParams = Record; @@ -1184,32 +1184,29 @@ export async function resolveAllowedUsersWithMap( logger.warn(`Failed to resolve mobiles to open_ids: ${res.msg} (code: ${res.code})`); } else { const userList: any[] = res.data?.user_list ?? []; - // Build a match index from the API echo. The API may echo a mobile - // with or without country code / leading `+`, and Feishu does NOT - // promise a byte-identical echo — so index each echoed number under - // its full symmetric key set (see mobileMatchKeys) rather than a - // single literal form. + // Index the API echo by a SINGLE canonical E.164 key. The API may echo + // a mobile with or without the leading `+`, and Feishu does NOT promise + // a byte-identical echo — canonicalMobileKey folds each number to one + // stable key (trusting `+` as the country code; only a genuinely-bare + // CN 11-digit number gets an 86 prefix). A single key per number, NOT a + // key SET: a set that stripped `+` and then treated every leading-1 + // number as CN would collide a US `+1 3XX…` with a CN bare `13X…` and + // bind the owner to the wrong person / evict a co-owner on overwrite. const byKey = new Map(); for (const item of userList) { if (item.user_id && item.mobile) { - for (const key of mobileMatchKeys(normalizeMobileEntry(String(item.mobile)))) { - byKey.set(key, item.user_id); - } + byKey.set(canonicalMobileKey(normalizeMobileEntry(String(item.mobile))), item.user_id); } else if (!item.user_id) { logger.warn(`Could not resolve mobile: ${item.mobile}`); } } for (const norm of mobiles) { const rawEntry = mobileRawByNorm.get(norm) ?? norm; - // Symmetric match: try every equivalent key of the REQUESTED number - // against the echo index. Covers +/no-+ (overseas E.164) and the CN - // bare-11 ↔ 86-prefixed variance in both directions, without relying - // on the API to echo the exact string we sent. - let uid: string | undefined; - for (const key of mobileMatchKeys(norm)) { - uid = byKey.get(key); - if (uid) break; - } + // Match the requested number by its canonical key. Covers CN bare-11 + // ↔ +86 in both directions. If Feishu echoed an overseas number with + // the `+` dropped it becomes a safe MISS (definitive → owner falls + // back to email/union_id), never a cross-number mis-bind. + const uid = byKey.get(canonicalMobileKey(norm)); if (uid) { map.set(rawEntry, uid); entryStatus.set(rawEntry, 'resolved'); diff --git a/src/setup/bot-config-editor.ts b/src/setup/bot-config-editor.ts index 006241eb9..e579c886e 100644 --- a/src/setup/bot-config-editor.ts +++ b/src/setup/bot-config-editor.ts @@ -117,21 +117,27 @@ export function isMobileEntry(entry: string): boolean { } /** - * 手机号「匹配键集」:把一个已归一化的手机号展开成一组等价键,用于把飞书 - * `batch_get_id` 响应里回带的号码对回本地请求的号码。**对称设计**——请求侧和 - * 响应侧都用本函数生成键集,任一键相交即视为同一号码,避免依赖「飞书是否逐字 - * echo」这种未承诺的行为: - * - **先剥前导 `+`**:飞书对海外 E.164 号可能回带也可能不回带 `+`,两侧都剥掉 - * `+` 后比较,海外号不再因 `+` 有无而漏配(否则判 definitive → owner 自锁)。 - * - **中国大陆号双向和解**:本地可填 11 位裸号,飞书可能回 `86` 前缀(反之亦然), - * 故 `8613…`↔`13…` 两种形式都进键集。 + * 手机号规范化匹配键:把一个已归一化的手机号折叠成**唯一**的 E.164 数字键,用于 + * 把飞书 `batch_get_id` 响应里回带的号码对回本地请求的号码。请求侧和响应侧都过 + * 本函数,键相等即同一号码。 + * + * 规则(**不能像早期实现那样无脑剥 `+` 再一律当中国裸号**——那会让美国 `+1 3XX…` + * 与中国裸号 `13X…` 折叠成同键,把 owner 绑到错的人 / 顶掉另一个 owner): + * - `+` 开头:`+` 是权威国家码,直接剥掉 `+` 得到「国家码+号码」的 E.164 数字串 + * (`+8613011112222`→`8613011112222`,`+14155550123`→`14155550123`)。 + * - 无 `+` 的纯 11 位、以 1 开头:按配置契约(MOBILE_RE:裸号只接受中国 11 位) + * 判定为中国大陆号,补 `86`(`13011112222`→`8613011112222`),从而与带 `+86` + * 的请求/响应对齐。 + * - 其它(已带 `86` 前缀等):原样。 + * + * 安全取舍:若飞书对海外号回带时**丢掉了 `+`**,海外裸号会被误判成中国号而与请求 + * 不等 → 判 definitive miss(owner 用邮箱/union_id 兜底),这是 fail-closed 的**漏配**; + * 绝不会把两个不同的人折叠成同一 owner(那才是危险的错配)。宁可漏配不可错配。 */ -export function mobileMatchKeys(normalized: string): string[] { - const bare = normalized.replace(/^\+/, ''); - const keys = new Set([bare]); - if (/^86\d{11}$/.test(bare)) keys.add(bare.slice(2)); // 8613… → 13…(大陆去区号) - if (/^1\d{10}$/.test(bare)) keys.add('86' + bare); // 13… → 8613…(大陆加区号) - return [...keys]; +export function canonicalMobileKey(normalized: string): string { + if (normalized.startsWith('+')) return normalized.slice(1); + if (/^1\d{10}$/.test(normalized)) return '86' + normalized; // 中国大陆裸号 → 补国家码 + return normalized; } /** diff --git a/test/bot-config-editor.test.ts b/test/bot-config-editor.test.ts index 84a5b04c2..6984141be 100644 --- a/test/bot-config-editor.test.ts +++ b/test/bot-config-editor.test.ts @@ -5,12 +5,12 @@ import { assertOwnerWhenChatGroups, botProcessEnv, botProcessName, + canonicalMobileKey, entryNeedsContactResolve, findInvalidAllowedUserEntries, hasOwnerEntry, isMobileEntry, isValidAllowedUserEntry, - mobileMatchKeys, normalizeBotConfig, normalizeMobileEntry, parseBotConfigsJson, @@ -152,19 +152,21 @@ describe('mobile allowedUsers entries', () => { expect(entryNeedsContactResolve('')).toBe(false); }); - it('mobileMatchKeys reconciles +/no-+ and CN bare↔+86 symmetrically', () => { - // Overseas E.164: with or without a leading + must share a key so the API - // echo matches regardless of whether Feishu echoes the +. - expect(mobileMatchKeys('+14155550123')).toContain('14155550123'); - expect(mobileMatchKeys('14155550123')).toContain('14155550123'); - // Request '+14155550123' and echo '14155550123' (+ dropped) intersect: - const req = new Set(mobileMatchKeys('+14155550123')); - expect(mobileMatchKeys('14155550123').some(k => req.has(k))).toBe(true); - // CN bare 11-digit ↔ 86-prefixed both directions: - expect(mobileMatchKeys('13011112222')).toContain('8613011112222'); - expect(mobileMatchKeys('+8613011112222')).toContain('13011112222'); - const cnBare = new Set(mobileMatchKeys('13011112222')); - expect(mobileMatchKeys('+8613011112222').some(k => cnBare.has(k))).toBe(true); + it('canonicalMobileKey reconciles CN bare↔+86 WITHOUT colliding US +1 numbers', () => { + const key = (n: string) => canonicalMobileKey(normalizeMobileEntry(n)); + // CN bare 11-digit and its +86 form fold to the same key (both directions). + expect(key('13011112222')).toBe(key('+8613011112222')); + expect(key('13011112222')).toBe(key('8613011112222')); + // Overseas E.164 with + is trusted as-is (country code preserved). + expect(key('+14155550123')).toBe('14155550123'); + // CRITICAL anti-collision: a US +1 3XX number must NOT fold to the same key + // as a CN bare 13X number (both are 11 digits starting with 1). The old + // strip-+-then-assume-CN key SET collided these and bound the owner to the + // wrong person / evicted a co-owner on map overwrite. + expect(key('+13011112222')).not.toBe(key('13011112222')); + // Different real numbers never share a key. + expect(key('+14155550123')).not.toBe(key('+14155550999')); + expect(key('13011112222')).not.toBe(key('13111112222')); }); }); From 620df58c8ed9f095a0d9452cee02c7b2dd469910 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B3=E6=99=97?= Date: Tue, 28 Jul 2026 03:14:51 -0700 Subject: [PATCH 4/4] =?UTF-8?q?docs(auth):=20dashboard/CLI=20owner=20?= =?UTF-8?q?=E6=96=87=E6=A1=88=E8=A1=A5=E5=85=85=E6=89=8B=E6=9C=BA=E5=8F=B7?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E6=94=BE=E5=BC=80=20needs=5Fowner=20?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审(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 --- src/cli.ts | 4 ++-- src/dashboard/bot-onboarding.ts | 6 +++--- src/dashboard/web/bot-onboarding.tsx | 3 ++- src/dashboard/web/i18n.ts | 16 ++++++++-------- src/i18n/en.ts | 4 ++-- src/i18n/zh.ts | 4 ++-- 6 files changed, 19 insertions(+), 18 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 6b44ad46a..39b729ee9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1051,11 +1051,11 @@ async function promptRequiredOwner(rl: ReturnType): Prom } const invalid = findInvalidAllowedUserEntries(entries); if (invalid.length > 0) { - console.log(` ❌ 以下不是完整邮箱、union_id 或 open_id(邮箱前缀不接受): ${invalid.join(', ')}`); + console.log(` ❌ 以下不是完整邮箱、手机号(大陆 11 位 / 海外带 + 国家码)、union_id 或 open_id(邮箱前缀不接受): ${invalid.join(', ')}`); continue; } if (!hasOwnerEntry(entries)) { - console.log(' ❌ 至少需要一个完整邮箱、union_id 或 open_id 作为 owner。'); + console.log(' ❌ 至少需要一个完整邮箱、手机号、union_id 或 open_id 作为 owner。'); continue; } return entries; diff --git a/src/dashboard/bot-onboarding.ts b/src/dashboard/bot-onboarding.ts index 48c114a87..c406ce383 100644 --- a/src/dashboard/bot-onboarding.ts +++ b/src/dashboard/bot-onboarding.ts @@ -1798,10 +1798,10 @@ export class BotOnboardingManager { const entries = rawEntries.map(e => e.trim()).filter(Boolean); const invalid = findInvalidAllowedUserEntries(entries); if (invalid.length > 0) { - return { ok: false, error: 'invalid_entries', message: `不是完整邮箱、union_id(on_) 或 open_id(ou_):${invalid.join(', ')}` }; + return { ok: false, error: 'invalid_entries', message: `不是完整邮箱、手机号(大陆 11 位 / 海外带 + 国家码)、union_id(on_) 或 open_id(ou_):${invalid.join(', ')}` }; } if (!hasOwnerEntry(entries)) { - return { ok: false, error: 'no_owner', message: '至少需要一个完整邮箱、union_id(on_) 或 open_id(ou_) 作为 owner。' }; + return { ok: false, error: 'no_owner', message: '至少需要一个完整邮箱、手机号(大陆 11 位 / 海外带 + 国家码)、union_id(on_) 或 open_id(ou_) 作为 owner。' }; } const appId = typeof pending.larkAppId === 'string' ? pending.larkAppId : ''; @@ -1813,7 +1813,7 @@ export class BotOnboardingManager { return { ok: false, error: 'unusable_owner', - message: `以下身份在当前应用里无法解析(可能是其他应用的 open_id,或邮箱不在本企业):${unusable.join(', ')}。请改用本企业邮箱或 union_id(on_)。`, + message: `以下身份在当前应用里无法解析(可能是其他应用的 open_id,或邮箱/手机号不在本企业):${unusable.join(', ')}。请改用本企业邮箱、手机号或 union_id(on_)。`, }; } diff --git a/src/dashboard/web/bot-onboarding.tsx b/src/dashboard/web/bot-onboarding.tsx index 432657d3b..29511af82 100644 --- a/src/dashboard/web/bot-onboarding.tsx +++ b/src/dashboard/web/bot-onboarding.tsx @@ -338,7 +338,8 @@ function OnboardingJobView(props: { {t('botOnboarding.ownerLabel')} = { 'card.config.text_sent': '✏️ Text-fields card sent to your DM ↓', 'card.config.text_send_fail': '⚠️ Send failed (you may not have a 1:1 with this bot yet)', 'config.label.autoStartPrompt': 'On-join first prompt', - 'cmd.config.allow_usage': 'Usage: /botconfig set allowedUsers confirm\ne.g.: /botconfig set allowedUsers alice@corp.com, ou_xxx confirm', - 'cmd.config.allow_invalid': '⚠️ Invalid entries (need a full email or on_/ou_ prefix): {items}', + 'cmd.config.allow_usage': 'Usage: /botconfig set allowedUsers confirm\ne.g.: /botconfig set allowedUsers alice@corp.com, 13011112222, ou_xxx confirm', + 'cmd.config.allow_invalid': '⚠️ Invalid entries (need a full email, mobile (11-digit CN / +country-code overseas), or on_/ou_ prefix): {items}', 'cmd.config.allow_confirm': '⚠️ About to set the admin list to:\n{list}\n\nThis changes who can manage this bot. To confirm, resend with a trailing `confirm`:\n/botconfig set allowedUsers {list} confirm', 'cmd.config.allow_ok': '✅ Admin list updated ({total} entries, {count} open_ids resolved). Effective immediately.', 'cmd.config.allow_lockout': '⛔ The new list does not include you — that would lock you out (self-lockout guard). Keep yourself in the list and retry.', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 2fcb2b7f8..cc7be589f 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -419,8 +419,8 @@ export const messages: Record = { 'card.config.text_sent': '✏️ 文本设置卡已发到你私聊 ↓', 'card.config.text_send_fail': '⚠️ 发送失败(你可能没和本 bot 开过私聊)', 'config.label.autoStartPrompt': '入群首轮 prompt', - 'cmd.config.allow_usage': '用法:/botconfig set allowedUsers <邮箱/on_/ou_,逗号或空格分隔> 确认\n例:/botconfig set allowedUsers alice@corp.com, ou_xxx 确认', - 'cmd.config.allow_invalid': '⚠️ 非法条目(需完整邮箱或 on_/ou_ 开头):{items}', + 'cmd.config.allow_usage': '用法:/botconfig set allowedUsers <邮箱/手机号/on_/ou_,逗号或空格分隔> 确认\n例:/botconfig set allowedUsers alice@corp.com, 13011112222, ou_xxx 确认', + 'cmd.config.allow_invalid': '⚠️ 非法条目(需完整邮箱、手机号(大陆 11 位 / 海外带 + 国家码)或 on_/ou_ 开头):{items}', 'cmd.config.allow_confirm': '⚠️ 即将把管理员名单改为:\n{list}\n\n这会改变谁能管理本机器人。确认请重发并在结尾加 `确认`:\n/botconfig set allowedUsers {list} 确认', 'cmd.config.allow_ok': '✅ 管理员名单已更新({total} 条,解析出 {count} 个 open_id),立即生效。', 'cmd.config.allow_lockout': '⛔ 新名单不含你自己,会把你踢出管理员(防自锁)。请在名单中保留自己再试。',