Skip to content

fix(lark): 权限自愈只申请缺失的 scope,不再全量导入完整清单 - #1042

Merged
deepcoldy merged 2 commits into
deepcoldy:masterfrom
le0tan:fix/scope-autofix-minimal-manifest
Aug 28, 2026
Merged

fix(lark): 权限自愈只申请缺失的 scope,不再全量导入完整清单#1042
deepcoldy merged 2 commits into
deepcoldy:masterfrom
le0tan:fix/scope-autofix-minimal-manifest

Conversation

@le0tan

@le0tan le0tan commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

背景 / 问题

checkRequiredScopessrc/im/lark/event-dispatcher.ts)用缺失的 scope当触发器,但 tryAutoFixScopesautomateOpenPlatformSetup没有传 scopeManifest。于是 automation 回退到 readDefaultScopeManifest() 读取整份 src/setup/lark-scopes.json(171 tenant + 130 user = 301 项,含大量 botmux 自身根本不校验的 calendar / docs / sheets / wiki / vc 等权限),再以 operation:'add' 一次性申请。

实际后果:"缺 2 项 im:feed_group_v1:read/write" 会触发把整份清单里能映射的权限(如日志所见约 299 项)全部追加进应用,并创建 + 发布一个新 app version。即"用缺失项当触发器,却拿完整清单当申请集合",造成权限过度申请与不必要发版。

改动

  • open-platform-automation.ts
    • 新增纯函数 filterScopeManifest(manifest, wantedNames):按名字裁剪清单,tenant / user 分桶归属沿用 manifest(不自己猜 bucket——im:feed_group_v1:* 只在 user 桶、im:resource 只在 tenant 桶,同名权限也可能同时落两桶)。
    • 导出原 readDefaultScopeManifest 供调用方复用。
  • event-dispatcher.ts
    • tryAutoFixScopesmissingCritical + missingOptional 的名字裁出精简 manifest 再传给 automation,申请集合 = 缺失集合。
    • 99991672「鸡生蛋」路径(应用连 self_manage 都没有、查不到 scope 列表)改为传完整 BOTMUX_REQUIRED_SCOPES(而非仅 self_manage),保留"一次补齐、下次重启即自检通过"的原意,同样不再过度申请整份清单。

影响面

  • im/lark启动权限自检链路(brand=feishu)。
  • VC 事件自动订阅 ensureVcMeetingEventsSubscribed 与 setup / onboarding 建应用链路不传 scopeManifest、行为不变(建应用本就该申请完整清单)。
  • scope/update 仍是 operation:'add'不会删除应用已有权限。
  • 本次不改"无变更也发版"这一独立问题(版本发布没有短路判断),只收敛权限申请范围,避免扩大改动面。

验证

  • bun run build 通过(tsc + 打包)。
  • bun run test 中相关用例全绿:
    • test/scope-optional-autofix.test.ts(14)— 新增源级断言:tryAutoFixScopes 现在传入 filterScopeManifest(readDefaultScopeManifest(), wantedScopeNames)
    • test/setup-open-platform-automation.test.ts(90,含新增 filterScopeManifest 5 例:分桶归属、双桶保留、不点名不申请、不存在落空、空列表)。
  • 仓库其余失败用例在本机 clean 工作树上同样失败(/private/var realpath、loopback 绑定、FNM symlink 轮转等 macOS 环境相关),与本改动无关。

该路径为线上开放平台真实写操作,纯单测无法覆盖,合并后建议在一次真实"缺可选 scope"的重启中观察日志确认申请集合已收敛。

checkRequiredScopes 用「缺失项」当触发器,但 tryAutoFixScopes 调
automateOpenPlatformSetup 时没传 scopeManifest,导致 automation 回退到
readDefaultScopeManifest() 的整份清单(300+ 项),把日历/文档/表格等
botmux 自己都不校验的权限一并 operation:'add' 申请进去——补一个
im:feed_group_v1:read 会连带申请一大批无关权限,还伴随一次发版。

改动:
- open-platform-automation 新增纯函数 filterScopeManifest:按名字裁剪
  清单,tenant/user 分桶归属沿用 manifest(不自己猜 bucket),并导出
  readDefaultScopeManifest 供调用方复用。
- tryAutoFixScopes 用 missingCritical+missingOptional 的名字裁出精简
  manifest 再传给 automation,申请集合与缺失集合一致。
- 99991672「鸡生蛋」路径改为传完整 BOTMUX_REQUIRED_SCOPES(而非仅
  self_manage),保留「一次补齐、下次重启即自检通过」的原意,同样不
  再过度申请。

影响面:仅 im/lark 的启动权限自检链路(feishu)。VC 事件自动订阅
(ensureVcMeetingEventsSubscribed)与 setup/onboarding 建应用链路不传
scopeManifest、行为不变。scope/update 仍是 operation:'add',不会删除
应用已有权限。

验证:bun run build 通过;bun run test 中
test/scope-optional-autofix.test.ts(14)、
test/setup-open-platform-automation.test.ts(90,含新增 filterScopeManifest
5 例)全绿。仓库其余失败用例在本机 clean 工作树上同样失败(/private/var
realpath、loopback 绑定、FNM symlink 轮转等环境相关),与本改动无关。
@deepcoldy

Copy link
Copy Markdown
Owner

自动评审初步意见(首审)

先说结论:方向和实现我认为是对的,问题定位(「用缺失项当触发器,却拿完整清单当申请集合」)和修法都很准确,尤其是 filterScopeManifest 坚持分桶归属沿用 manifest、不自己猜 bucket 这一点。下面只有一条措辞层面的合前建议,不是阻断项。

复核过的部分(都实测跑过,不是推理)

  • 申请集合规模:301 项 → 2 项(只缺 im:feed_group_v1:read/write 时);99991672 路径 301 → 17(tenant 11 + user 6)。
  • 分桶的必要性确认成立:im:feed_group_v1:* 在 manifest 里只在 user 桶im:resource 只在 tenant 桶,且 tenant∩user 有 121 项重叠——注释里那句「不自己猜 bucket」是对的。
  • operation:'add' 确为增量(探针实测 scope/update body 只含那 2 个 user id),裁剪不会删应用已有权限;buildAppVersionCreatePayload 不带 scopes,可见范围也另走 visible/online 且 fail-closed——裁剪对发版内容无副作用。
  • 另外 3 个 automateOpenPlatformSetup 调用点(cli.ts / dashboard.ts / bot-onboarding.ts)与 VC 的 ensureVcMeetingEventsSubscribed 确实都不传 scopeManifest,行为逐字未变,影响面描述准确。
  • tsc --noEmit 净、bun run build 绿、相关用例 146/147 绿(唯一失败是 collectBotmuxRedirectUrls 那条,在我这台机器的 clean master 上同样红,属环境基线,与本 PR 无关)。
  • 反向变异 3 处全部变红,新测试确实有牙:破坏分桶归属 → 2 红;把 filterScopeManifest 改成 passthrough → 5 红;在调用点删掉 scopeManifest(即还原原缺陷)→ 1 红。

🟡 一条合前建议:scopeCount === 0 分支的措辞现在会反过来说

event-dispatcher.ts 的成功文案是:

const scopeDetail = result.scopeCount > 0
  ? `${result.scopeCount} 项权限已导入…`
  : '所有必需权限已在应用清单中';

裁剪之后 scopeCount 的语义变了:过去是「整份清单导入了多少」,现在是「缺失项里成功了多少」。于是 0 从「本来就齐」变成了「一项都没申请上」,两种相反状态共用同一句话。

在同一个租户 catalog(暴露了别的权限、但不暴露 im:feed_group_v1:*——也就是代码自己注释里承认的「有的租户目录下个别权限不可授予」)下实测对照:

scopeCount skippedScopeCount 实际打出的文案
master(全量 manifest) 5 296 5 项权限已导入(296 项跳过)
本 PR(精简 manifest) 0 2 所有必需权限已在应用清单中

即恰恰在「想补的那两项一项都没补上」时,日志反而报「已齐全」,并且照样发了一个新版本。建议在 skippedScopeCount > 0 时改说成「N 项在租户目录中不可授予」之类,别让它落到「已齐全」那句。

补充两点让优先级更清楚:① 这条路径是 silent: true,只进日志不发 DM,所以影响是「排查时被日志误导」而非「误告知管理员」;② master 在同场景下文案也不算准确(5 项已导入(296 项跳过) 同样看不出目标那两项没成),所以这不是你引入的新缺陷,只是裁剪让它更容易被踩到。如果你倾向于保持本 PR 改动面最小、把它留到单独 PR,我也认为完全合理——只是希望它别被忘掉。

顺带一个观察(不属于本 PR,无需处理

readDefaultScopeManifest() 在编译态(bun --compile 单文件二进制)会抛 找不到 botmux lark-scopes.json——/$bunfs 下没有这个 JSON。我实测确认master 同样会抛,而且本 PR 因为把 manifest 解析提到了任何网络写操作之前,抛得更早(writesBefore=[],master 那时已经写过一次 safe_setting/update)。所以这是既有缺陷,本 PR 反而略微改善,列在这里只是留个记录。


以上是自动评审的初步意见,最终以维护者审阅为准。感谢这个 PR——「300+ 项过度申请 + 顺带发版」这个问题定位得很到位。

@deepcoldy

Copy link
Copy Markdown
Owner

复审收敛 + 更正我上一条评论里的一处错误

复审已完成,双审一致:无阻断项,方向和实现都对。但我要先更正自己上一条评论没写、却在评审内部判错的一点,因为它把风险方向说反了:

更正:不存在「99991672 路径从 1 项扩大到 17 项」这回事。
我原先据「master 那里传的是 [{name: SELF_MANAGE_SCOPE}] 一项」推出「本 PR 扩到 17 项、可能整批被拒」。追到实际消费点后确认这是错的——master 的那个实参只喂 totalMissing 日志和成功 DM 的文案,从不参与请求集合;automateOpenPlatformSetup 内部照样 options.scopeManifest ?? readDefaultScopeManifest() 落回全量 301。所以真实对比是 301 → ≤17(tenant 11 + user 6),整批被拒风险严格下降。我原来担心的回归方向不存在,这一点本 PR 是纯改善。教训记在我这边:调用点传了什么参数,不等于那个值真的被当成请求集合用。

复核确认的其余部分

  • 🟡 那条文案建议维持非阻断:同场景 master 更糟——它会把 5 项无关权限真的写进应用、还称「5 项已导入」;本 PR 只是措辞不准。且该分支仅在「缺的权限根本不在租户 catalog」时走到,此时任何自动路径都救不了。
  • 编译态 readDefaultScopeManifest() 抛错属既有缺陷,复验通过:master 的抛点在 redirect 白名单写入之后(会先真写一次),本 PR 在任何网络调用前就抛(零写入),两版都被同一个 catch 接住、daemon 不崩。
  • BOTMUX_REQUIRED_SCOPES 13 个名字全部存在于 lark-scopes.json,无静默丢弃;scope/update 失败是非致命的(catch → warning → 照常发版 → 下次重启重试),与 master 同语义。
  • 独立环境二次验证:tsc --noEmit 0 错、bun run build 绿、相关用例仅剩那条 master 基线失败(collectBotmuxRedirectUrls 收到本机 tunnel URL,master 同断言同样红)。

两条非阻断建议(都可留作后续 PR,不必卡这个 PR)

  1. 文案scopeCount === 0 && skippedScopeCount > 0 时改说「N 项权限不在当前租户目录,无法自动导入」。补一处我上条评论漏说的:这句 scopeDetail非 silent 的 critical 路径会进管理员 DM,和同一条消息开头的「✅ 已自动修复了缺失的权限」自相矛盾——不过触发前提是 critical 项整个不在 catalog,比 silent 的 feed_group 场景更罕见。
  2. 测试:99991672 分支改用 requiredNow 这处目前零覆盖(新测试只盖了 filterScopeManifest 与主调用点的 plumbing)。仿照现有 source-region 断言补一条即可,一个 it 的事。

另外顺带修正一处我们复核中的表述以免误导:scopeCount === 0 在 master 上并非完全不可达——catalog 非空但与整份 manifest 无交集时同样会走到(实测 scopeCount=0 skipped=301)。只是触发条件比本 PR 苛刻得多,所以准确说法是本 PR 让这个分支的可达性大幅变宽,而不是「新造出一个分支」。结论不变。


以上仍为自动评审意见,最终以维护者审阅为准。就我们两轮复核而言,这个 PR 无需改动即可合并,上面两条留作后续即可。再次感谢,这个问题定位得很准。

@deepcoldy

Copy link
Copy Markdown
Owner

已按维护者授权直接在本分支补上两条建议(86d0865b7

维护者确认「帮作者优化后再复审、没问题就合」,所以我用 maintainer push 权限把上面那两条非阻断建议直接实现在你的分支上了,你不需要再动手。你原来的 b499b3b91 一字未改,新增的是一个独立 commit,方便你单独 review 或 revert。

改了什么

scopeCount === 0 按三种互相矛盾的成因分开措辞

原来是单条三元表达式,0 一律说「所有必需权限已在应用清单中」。裁剪之后 0 最常见的成因恰恰是「一项都没补上」,于是它会在全部失败时报「已齐全」。现在按可诊断性分成三支:

成因 判据 文案
开放平台整批拒绝 scopeWarning 有值 0 项权限已导入——开放平台拒绝了本次权限申请(…)
不在该租户权限目录 skippedScopeCount > 0 0 项权限已导入——这 N 项不在当前租户的开放平台权限目录中
确实无事可做 两者皆无 所有必需权限已在应用清单中(保留原文案)

同时新增 autoFixEffective:一项都没落地时日志降为 warn 且不再说 succeeded,管理员 DM 的标题也从「✅ 已自动修复了缺失的权限」改成「⚠️ 没能申请成功,需要你手动开通」——这是我第二条评论里提到的自相矛盾,DM 会让管理员以为不用管,而这条路径的下游(缺 critical scope)恰恰最需要人工介入。

② 补 99991672 分支的测试断言

这处改用完整 BOTMUX_REQUIRED_SCOPES 之前零覆盖。补断言时也把「为什么这次必须传对」写进了注释:过去传 self_manage 一项只喂日志/DM 文案、不参与请求集合,现在申请集合由这份名单裁出,传错就会让下次重启的自检依然缺权限。

③ 额外补了一条行为测试(不只是源码文本断言)

真实跑 automation 取回 scopeCount / skippedScopeCount / scopeWarning 三元组,验证那三种成因确实可区分——否则调用方无论怎么写文案都只能靠猜。四个分支:成功落地 {2,0,false}、不在目录 {0,2,false}、被拒 {0,0,true}、无事可做 {0,0,false}

验证

  • tsc --noEmit 0 错;bun run build 绿。
  • test/scope-optional-autofix.test.ts 18 例全绿;相关三文件 134/135,唯一失败是 collectBotmuxRedirectUrls 那条环境基线用例(本机 tunnel URL 注入,未改动的 master 上同断言同样失败)。
  • 四种成因逐一执行真实文案逻辑确认输出正确(不是读代码推断):
(1) applied          | INFO / ✅ 已自动修复    | 2 项权限已导入
(2) not-in-catalog   | WARN / ⚠️ 没能申请成功  | 0 项权限已导入——这 2 项不在当前租户的开放平台权限目录中…
(3) rejected         | WARN / ⚠️ 没能申请成功  | 0 项权限已导入——开放平台拒绝了本次权限申请(code=1 …)
(4) nothing-missing  | INFO / ✅ 已自动修复    | 所有必需权限已在应用清单中
  • 反向变异确认新测试有牙:还原旧的单条三元文案 → 2 例变红;把 99991672 改回只传 self_manage → 1 例变红。

接下来会再走一轮复审,通过后由维护者合并。如果你对以上任何一处改法有不同看法,直接说,我改回去或按你的想法调整都可以——毕竟是你的 PR。

裁剪申请集合后 scopeCount 的语义变了:从「整份清单导入了多少」变成「缺失
的那几项里成功了多少」。于是 0 不再等于「本来就齐」,最常见的成因反而是
「一项都没补上」——而文案仍是单条三元表达式,恰在全部失败时输出「所有必需
权限已在应用清单中」。这句同时进 daemon 日志和管理员 DM,DM 开头还写着
「✅ 已自动修复了缺失的权限」,自相矛盾,管理员会据此以为不用管;而走到这条
路径的下游(缺 critical scope)恰恰最需要人工介入。

改动:
- 按三种互相矛盾的成因分开措辞:scopeWarning 有值 = 开放平台整批拒绝;
  skippedScopeCount > 0 = 这些名字不在该租户的权限目录中、不可授予;两者
  皆无才是真的「所有必需权限已在应用清单中」。
- 新增 autoFixEffective:一项都没落地时日志降为 warn 且不再说 succeeded,
  管理员 DM 标题改为「没能申请成功,需要你手动开通」。
- 补 99991672「鸡生蛋」分支的测试断言。该分支改用完整 BOTMUX_REQUIRED_SCOPES
  之前没有任何覆盖:过去传 self_manage 一项只喂日志/DM 文案、不参与请求集合,
  现在申请集合由这份名单裁出,传错就会让下次重启的自检依然缺权限。
- 新增行为测试验证三种 scopeCount===0 的成因在 automation 结果里确实可区分
  (真实跑 automation 取 scopeCount/skippedScopeCount/scopeWarning 三元组),
  否则调用方无论怎么写文案都只能靠猜。

验证:
- tsc --noEmit 0 错;bun run build 绿。
- test/scope-optional-autofix.test.ts 18 例全绿;
  test/setup-open-platform-automation.test.ts 新增 1 例行为测试(4 个成因分支)。
  相关三文件 134/135,唯一失败为 collectBotmuxRedirectUrls 那条环境基线用例
  (本机 tunnel URL 注入,未改动的 master 上同断言同样失败)。
- 四种成因逐一执行真实文案逻辑确认:已落地 → INFO/「N 项权限已导入」;
  不在目录 → WARN/「这 N 项不在当前租户的开放平台权限目录中」;被拒 →
  WARN/「开放平台拒绝了本次权限申请」;无事可做 → INFO/「所有必需权限已在
  应用清单中」。
- 反向变异确认新测试有牙:还原旧的单条三元文案 → 2 例变红;把 99991672 改回
  只传 self_manage → 1 例变红。
@deepcoldy
deepcoldy force-pushed the fix/scope-autofix-minimal-manifest branch from 86d0865 to 61ab9a6 Compare August 27, 2026 18:20
@deepcoldy

Copy link
Copy Markdown
Owner

补一处小修(已 amend 进 61ab9a6c4,force-push 带 --force-with-lease):

复审指出 dmAdmin 走的是 sendUserMessage(..., 'text'),飞书 text 类型消息不渲染 markdown,所以我上一版在 DM 里写的 **没能申请成功** 会把星号字面显示出来。既然那个加粗是我这次新加的,就不留一个已知的显示瑕疵——已去掉。仅此一行文案改动,逻辑与测试均未变。

(同文件里既有的 **免审批** 等属存量写法,不在本 PR 范围,未动。)

重验:tsc --noEmit 0 错、bun run build 绿、test/scope-optional-autofix.test.ts + test/setup-open-platform-automation.test.ts 108/109(唯一失败仍是 collectBotmuxRedirectUrls 那条环境基线用例,未改动的 master 上同断言同样失败)。CI 已因 force-push 重跑。

两轮复审均无保留意见,接下来交由维护者决定合入。

@deepcoldy

Copy link
Copy Markdown
Owner

补一条合并前的知情项:VC(视频会议)权限不再被「顺带」申请

维护者问到会议权限自检,顺着查出本 PR 有一个副作用,PR 描述和此前复审都没提到。不是缺陷、不影响功能正确性,但值得写进记录,免得日后有人困惑。

现象:master 上自愈申请的是全量 301 项,而那 301 项里恰好包含全部 4 个 VC 权限(vc:meeting.bot.join:write / vc:meeting.meetingevent:read / vc:meeting.message:write / vc:meeting.bot.realtime:write)。所以过去某个 bot 只要因为别的原因触发过一次自愈,VC 权限会被顺带一起申请进去——纯属搭便车,不是设计意图。

同一租户 catalog 下实测对照:

实际申请 其中 VC 权限
master(全量 manifest) 9 项 4 个全在
本 PR(裁到缺失项) 2 项 0 个

为什么这仍然是对的:VC 权限不在 BOTMUX_REQUIRED_SCOPES 里,本就不属于自愈的目标集合;「补 A 权限时顺带申请一批 B 权限」正是这个 PR 要消灭的过度申请。VC 有自己独立的就绪自检(checkRequiredScopes 里的 vcMeetingAgent 分支),缺失时会 logger.error + 私信管理员附开通深链,不会静默失效

真实影响:已开通 VC 权限的存量 bot 不受影响(operation:'add' 增量语义,不删任何已有权限)。变化的是今后新建、或尚未开通 VC 权限的 bot——以前可能不知不觉就搭车拿到了,以后需要管理员按私信里的深链点一次。

如果希望 VC 权限也能自动补:正确做法是按 vcMeetingAgentConfigActive 的条件把那几项显式加进自愈的申请集合,而不是依赖「全量申请」搭便车。那属于独立的新增能力,不应塞进本 PR(会扩大改动面,也超出原意),建议另开 PR。

@deepcoldy
deepcoldy merged commit 1630fc2 into deepcoldy:master Aug 28, 2026
2 checks passed
@github-actions

Copy link
Copy Markdown

🚀 Released in v3.18.0

deepcoldy added a commit that referenced this pull request Aug 29, 2026
与 master 上的 #1042(权限自愈只申请缺失 scope)语义合并:

- `tryAutoFixScopes` 同时保留 #1042 的 `scopeManifest`(裁成缺失项)与
  #1044 的 `grantedScopeNames`(按 tenant/user 分桶做差)。两者叠加安全:
  缺失判定用的扁平集合是分桶集合的超集,本路径上二次差集恒为空操作。
- 日志保留 #1042 的三成因措辞、`autoFixEffective` 降级与 privilegeRange
  明细,并融入 #1044 的 `versionDetail`(跳过发版 vs 未解析到 versionId)。
- 修合并新引入的缺口:`narrowRequiredPrivilegeRanges` 会真发
  `privilege/update`,属一次落地变更,必须计入 `mutated`,否则「只收敛了
  权限数据范围」的那一轮会走无变更短路、不发版,改动留在草稿里不生效。
  短路返回补 `privilegeRangeCount` / `privilegeRangeWarning`(类型要求)。
- 新增回归用例锁死上一条(反向变异删掉置位即转红)。

验证:tsc 净、bun run build exit 0、全量单测对同期 master 基线零回归
(合并树失败集是基线的子集,5 files/7 tests ⊂ 6 files/8 tests)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
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