Skip to content

feat(trigger-user-auth): 按触发人身份调用 CLI(默认关闭) - #1266

Merged
deepcoldy merged 24 commits into
deepcoldy:masterfrom
xiaoxueSunn:feat/trigger-user-cli-auth
Sep 8, 2026
Merged

feat(trigger-user-auth): 按触发人身份调用 CLI(默认关闭)#1266
deepcoldy merged 24 commits into
deepcoldy:masterfrom
xiaoxueSunn:feat/trigger-user-cli-auth

Conversation

@xiaoxueSunn

@xiaoxueSunn xiaoxueSunn commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

背景

同一个 bot 被多人使用时,botmux 调 lark-cli / bytedcli / git 用的是这台机器登录的是谁,而不是这条消息是谁发的。后果有三个:操作全部归到机器主人名下、别人的权限被无意借用、审计里写错人。

顺带修一个已存在的静默 bug:user token 只按 appId 存一份,同一个 bot 里第二个人 /login直接覆盖第一个人,之后所有人都在用最后授权那个人的权限,没有任何提示。目前没暴露只因实际上只有一个人在 /login

改动

一条规则:CLI 调用用本轮消息发送者自己的凭证,或者没有;唯一允许的降级是「bot 自己的 tenant 身份」(不属于任何人),绝不退回到另一个人的登录态,owner 也不例外

  • token 按 (app, 授权人) 存储user-token-<appId>-<openId>.json。归属以 user_info 回读的真实授权人为准,不信任发起人声明——/login 链接被转给别人点时按真实授权人落盘并明确告知。删掉 FEISHU_USER_ACCESS_TOKEN 全局旁路(设一次就让所有 bot、所有人共用一个身份,且无痕迹)。
  • 凭证走文件不走 env:一个会话是一个长驻 CLI 进程,env 在 spawn 时被内核定死。env 注入会把身份冻结在起会话那个人身上,之后任何人发言都还在用他的权限。文件每次调用重读,换人零成本且不重启会话。
  • 每轮发布身份:挂在 noteTurnReceived(源码注释里明确的 per-message acceptance point)。取不到当轮发送者的 token 时改写文件,不是「不刷新」——留着等于下一条命令用上一个人的身份跑。无人类发送者的轮次(定时/hook/会议/bot 交接)同样不给身份,且不借用「最后一个说话的人」。
  • 凭证绑定轮次:daemon 在消息被接收时发布凭证,但 CLI 有自己的队列。A 的轮次还在跑时 B 发消息,B 的凭证会覆盖文件,A 后续的工具调用就用了 B 的权限——普通的 type-ahead 就能触发。daemon 单独关不掉这个窗口(输入直接进 worker,worker 不等 daemon)。所以两边都声明轮次:凭证带上所属 turnId,worker 发布正在执行的 turnId,不一致就拒绝执行。代价是 A 轮次尾部的调用会失败——这正是这个功能的取舍:命令失败可恢复,用错人的权限不可恢复。
  • git 归因GIT_ASKPASS 问被 wrapper 包过的 bytedcli,自动继承当轮身份。
  • bot 级开关,默认关闭,缺失即关闭。配置里刻意没有「用机器登录态」这个选项,写了会被明确拒绝并说明「永不可用」。
  • 两个 CLI 都支持按人鉴权,机制不同:lark-cli 注入 OAuth user token;bytedcli 走 ByteCloud SSO 设备码流程,每人一个私有 HOME(~/.botmux/data/bytedcli-home/<openId>)。不对称的只是未授权时的兜底:lark-cli 可以降级到 bot 身份,bytedcli 没有 bot 身份,所以即使配 fallback=bot-identity 也返回 fail。
  • bytedcli 存目录不存 JWT:ByteCloud JWT 只活 2 小时,底层登录活约 3 周,bytedcli 内部自动续期。实测跨过期点:01:48:03 过期的 token,01:49 再取拿到 iat=01:48:08 的新 token,无人介入。存 JWT 会让人每 2 小时重扫一次码去续一个根本没过期的东西,所以每轮现取,只有 3 周的登录真到期才打扰人。
  • 无凭证 = 拒绝执行,不是「裸跑」:见下方「一处我此前判断错误」。

一处我此前判断错误(评审重点)

我此前在代码注释和结论里都写过「不给 env,lark-cli 就以 bot 身份跑」。这是错的,而且正是它让这个功能在未授权路径上完全失效。实测:

env -u LARKSUITE_CLI_APP_ID -u LARKSUITE_CLI_USER_ACCESS_TOKEN lark-cli whoami
→ identity: user, onBehalfOf: <本机登录的那个人>

不给任何 env,lark-cli 读的是磁盘登录态(~/Library/Application Support/lark-cli/<appId>_<openId>.enc),以机器主人的身份执行。原 wrapper 在没有凭证文件时直接 exec 真实工具,等于每个未授权的人都在用机器主人的全部权限跑命令——无声无息,审计里还写着机器主人的名字。根因:我最初那次 --as bot 验证跑在已登录的机器上,所以假结论看起来被验证过。

正确做法已实测确认:给 LARKSUITE_CLI_APP_ID + LARKSUITE_CLI_APP_SECRET 才会真的解析成 identity: bot(本机有用户登录也一样),而且此时再加 --as user 返回 token_missing,不会绕回磁盘凭证——所以 bot 身份是个可靠的地板,不是 agent 能绕开的默认值。

现在凭证文件带显式 mode(user / bot / denied)。文件缺失、为空、写了一半、或者从环境变量注入 mode,一律拒绝执行(exit 77),并在 stderr 说清「缺谁的授权 + 发哪条命令去补」——只说「拒绝」的话,命令失败的人只会反复重试同一条。

第二处判断错误:bytedcli「不支持按人鉴权」

同一类错误又犯了一次。我此前写「bytedcli 只有 SSO 个人登录,没有按人鉴权的路径」,并据此让它无条件 fail、还建议把它从默认工具列表里摘掉。这个结论我从来没验证过。 实测:

BYTEDCLI_USER_CLOUD_JWT=<bogus> bytedcli auth userinfo
→ HTTP 401 invalid jwt token        (注入的 JWT 优先,坏 JWT 直接失败,不会静默回落到本机登录态)

HOME=<空目录> bytedcli auth status
→ not logged in                      (本机明明登录着)

所以 bytedcli 既有可用的注入变量,也有真正的 HOME 隔离,还有完整的非阻塞设备码流程(auth login --begin 返回链接 + resume token,--complete 收尾)。缺的只是 botmux 这边没接——是没做,不是做不了。本 PR 已补上。

两次错误的共同点:在一台已登录的机器上观察一下,就把现象当成工具的能力边界。这轮后续的结论(HOME 隔离、JWT 优先级、自动续期)都是在干净环境里实测的。

借鉴与如实说明

git 那三条加固来自读 @byted/orchestra 已发布二进制(仓库无权限),我们原实现有同样暴露:调 bytedcli 前置 GIT_CONFIG_COUNT=0 GIT_SSH_COMMAND=false 防递归、bytedcli 取不到 JWT 时 HTTP 兜底、insteadOf 把 SSH 远端改写成 HTTPS。端点与 host 走配置不写死(本仓库有域名门禁)。

本期不做内核级凭证隔离。 现状是:不开文件沙盒时,agent 与 botmux 跑在同一个系统用户下,有能力直接读取 ~/.botmux/data/ 里其他人的 token 文件。按人存储解决的是归因与覆盖问题,不解决这个。

替代方案是 system prompt 里的 <botmux_credentials> 约束块——这是行为约束不是安全控制,挡不住有意的用户,也挡不住 prompt injection。它针对的是最可能真实发生的情况:agent 排查鉴权失败时顺手 grep 数据目录。因此块里同时给出替代动作(凭证已注入、失败就提示 /login),光写禁令会让排查中的 agent 无路可走。

内核隔离已在 macOS 上验证可行(cat / .tmp sidecar / spawn 后新建文件全部 DENIED),留待下一期;Linux 侧因为 credential-only bwrap 顺带带 PID/IPC namespace 隔离,需要先实测副作用。

影响面

  • 默认关闭时行为不变。特意确认过一处:早期实现里 noteTurnReceivedawait 是无条件的,会让每个 bot 的每一轮多让出一个 microtask,重排 single-flight 依赖的并发顺序(daemon-rename-route 因此失败)。已改成同步判断,关闭的功能不改变任何时序。
  • 三处内部 token 消费点各自的正确答案不同:图片下载跟随本轮 sender(本来就有 app→user fallback)、文档评论用订阅创建者(长期任务无当轮 sender)、feed 打标固定 owner(那是 owner 收件箱里的东西,bot 没有收件箱)。
  • feed-group 走 resolveOwnerUserToken:存量安装的 owner token 存在未标注归属的旧文件里,严格按人取会让标签功能升级后静默失效。这道口子只放宽「哪个文件算 owner 的」,不放宽「可以借谁的身份」。owner 本身改为 getOwnerOpenId() 派生,ownerOpenId 只作兜底——那是 bots.json 的原始字段,实际部署里几乎没人填(评审那边 55 个 bot 一个都没有)。
  • 存量已授权的人需要重新 /login 才能拿到新增的文档 scope。这是 OAuth 的性质,不是可以绕过的实现细节。
  • ZDOTDIR 作用于整个 CLI 进程,所以该会话里 spawn 的任何 zsh 都会先读 shim 目录的启动文件。shim 会先 source $HOME 下的同名文件,功能等价;但如果用户的 .zshrc 依赖 ZDOTDIR 指向 $HOME,行为会变。只影响开了 triggerUserAuth 的会话。
  • turn 绑定依赖沙箱才不可绕:agent 自己写 <sid>.turn 就能复活旧凭证,沙箱把它授成 readOnly 挡住了这条路。与本 PR「本期不做内核级隔离」的自述一致,此处写明以免被当成独立保证。

验证

  • npm run buildtsc --noEmitaudit:domains 全部通过
  • 全量单测 21281 passed / 1 failed,唯一失败是 bun-shim-parity——在干净 origin/master(独立 worktree + 真实拷贝的 node_modules,非软链)上同样失败,已直接复现,与本 PR 无关。该用例断言 setTimeout(…, 100)vi.waitFor({timeout: 100}) 的边界相等情形,本质是顺序竞争。
  • 期间另有 codex-app-threads / dsh-question-bridge / platform-tunnel-data-bridge 偶发失败:单跑都通过,且本 PR 未触碰对应文件。dsh-question-bridge 那次我一开始没复现就归因为「无关抖动」,这不严谨——补跑两分支各 3 轮同组合、均 0 失败之后才敢这么写。
  • 真机端到端已验(独立 daemon 挂 business-agent,隔离 HOME,不影响其余 13 个 bot):飞书群里真实消息触发 → wrapper 安装 → 凭证按发起人下发 → lark-cli whoami 返回 identity: user / 代操作用户为发起人 / identitySource: strict_mode(strict_mode 证明用的是注入凭证而非本机磁盘登录态);未授权者同一条命令走 MODE='bot' 兜底;凭证文件里的 BOTMUX_IDENTITY_TURN 与 worker 正在执行的 .turn 完全一致。
  • 真实内核 / 真实 shell 验证:wrapper 经真实 /bin/sh 的 5 种拒绝形态、真实 bash -lc / zsh -lc 登录 shell 的 PATH 竞争(含阴性对照)、真实 bytedcli 的 HOME 隔离与 JWT 自动续期(跨过期点实测)、真实 buildSeatbeltProfile 的跨会话拒读。
  • 新增约 175 个测试。

未验证

  • 多人并发未做真机实测:两个真人在同一会话轮流触发、以及「A 的轮次执行中 B 插话」的轮次绑定,只有单测与构造验证,没有第二个真人配合过。阻碍是 /logincanOperate(只认 allowedUsers),协作者默认发不了。
  • Linux 侧 credential-only 的 PID/IPC namespace 副作用未测(devbox Kerberos 票过期)
  • Windows 为 fail-closed 平台,未在 Windows 上运行
  • 新增的 7 个默认文档 scope 尚未在「后台未启用该 scope 的 app」上验证过是否会让整个授权链接 20043 失败。历史上那次 20043 的根因是 scope 名拼错docs:document.comment 并不存在),「未启用会连带失败」是当时顺手写下的预防性判断、无实测证据。authorize 接口未登录时只返回 302 跳登录页,无法自动化验证这一条。

@xiaoxueSunn
xiaoxueSunn marked this pull request as draft September 5, 2026 14:11
@xiaoxueSunn
xiaoxueSunn marked this pull request as ready for review September 5, 2026 18:04
@deepcoldy

Copy link
Copy Markdown
Owner

感谢这个 PR —— 安全方向的设计和其中两处自我纠错(「不给 env 不等于 bot 身份」「bytedcli 并非不支持按人鉴权」)都很扎实。我们做了自动评审并实际跑了验证,结论是建议修改后再合:2 个阻断项 + 若干小项。以下每条都附了判据,方便你复核。

先说明基线:已在本地把分支 rebase 到最新 origin/master8d2986c71,原基线落后 5 个 commit),零冲突range-diff 17/17 全等。bun run build ✅、tsc --noEmit ✅、PR 相关 14 个测试文件 601 测试全绿、CI 4/4 绿。以下问题都不是 rebase 引入的。


🔴 1. /login 只写 per-person 文件,但一批读者仍走 no-openId 路径 —— 授权了也读不到

探针从生产入口进(generateAuthUrlhandleCallbackUrl),真实回调后盘上只有 user-token-<app>-<openId>.json,然后:

resolveUserToken(app, sec, brand)          => null      ← 无 openId
getTokenStatus(无 openId)                   => 未登录(无 User Token)
getFeedGroupAuthStatus(无 ownerOpenId)      => { authorized: false }

受影响的读者有 5 处,其中 3 处是功能关闭时的默认路径,所以「默认关闭」挡不住:

位置 说明
command-handler.ts:3025 /subscribe-lark-doc 前置检查
im/lark/client.ts:1403 图片/文件下载兜底(feature OFF 分支)
im/lark/doc-comment.ts:373 文档评论 drive API(feature OFF 分支)
dashboard/feed-groups.ts:37 + dashboard-ipc-server.ts:5285 Dashboard 标签,传 cfg.ownerOpenId
im/lark/audio-transcribe.ts:109 downloadMessageResource 没传 senderOpenId(对比 session-manager.ts:654 传了)

触发时序:老 per-app 文件还有效时不炸(legacy 读路径完好);等它过期后本人重新 /login,新 token 进 per-person 文件、no-openId 读者永远 null——人已经授权了、功能仍坏、且零报错。而 /login/login tags/subscribe-lark-doc 三条命令路径现在全都归属 senderId,所以这是每个重新登录的人必踩,不是边缘时序。/subscribe-lark-doc 还会死循环:授权完重试 → 3025 行仍是 null → 再提示授权。

补充一个存量数据点:我们这边 bots.json 里 55 个 bot 没有一个配了 ownerOpenId(该字段直接读 bots.json,无派生),开了 feed-group 标签的那几个也没有 —— Dashboard 徽标那条会直接走 undefined

修法建议:改读者,不建议双写 per-app。 双写有两个实际问题:① refreshToken 只写 per-person,双写出来的 per-app 副本几小时后就是陈旧快照;② feed-group 是 owner 收件箱侧边栏,双写等于用「最后登录那个人」的 token 去操作 owner 的侧边栏,多人 bot 上会把分组建到错的人那里。具体改动都很小:3025 传 message.senderId;1403 无条件传 senderOpenId;373 去掉 feature 门控(订阅记录的 ownerOpenId 是 master 上就有的老字段);feed-group 改用 getOwnerOpenId(),解析不出时写操作报错而不是借别人的 token。

另外建议补一条回归测试:「/login 之后,no-openId 读者仍能拿到 token」。我们试着给 /login 加双写兼容做变异,user-token-* 那 29 个测试全绿 —— 说明这条路径目前完全没有覆盖。

🔴 2. tmux / zellij / zmx 后端会把 identity wrapper 从 PATH 里丢掉,静默回退机器身份

worker.ts:14475 把 wrapper 目录 prepend 到 childEnv.PATH,但 tmux 系 pane 走 shellCommandArgv,shell 带 -i(zsh 是 -l -i先 source rcfile,rcfile 常见地重建 PATH(fnm / nvm / pnpm),之后 shellWrapperScripttmux-backend.ts:783只重新 prepend botmuxBinDir 一个目录,identity 目录没有同等待遇。buildBotmuxEnvAssignments 的注释本身就写着「Everything else (PATH, HOME, NVM, locale, …) comes from the user's rcfile」。

用真实 shell 复现(含阴性对照):

rcfile 重建 PATH  → !!! REAL lark-cli RAN (machine identity) !!!   exit=0    ← fail-open
rcfile 不动 PATH  → identity wrapper: refused                       exit=77   ← 对照成立

这正是 refuse instead of falling back to the machine login 那个 commit 要消灭的行为,却在受支持的后端上 fail-open 了。同类还有 herdr(自己拼 export PATH)和远端后端(riff/mojo,wrapper 目录在本地、远端根本不存在)。我们 fleet 目前 54 pty + 1 riff + 0 tmux,所以今天没有线上影响,但配置一改就会静默失效。

建议:pane 脚本给 identity dir 同等的重新 prepend;或者在不支持的后端上拒绝启用并明确报错(远端后端可以只在文档写明「本期仅 pty/direct-spawn 支持」)—— 关键是不要静默。

🟡 小项

  • doc-comment.ts 两处 driveApiCall 漏传 fileTokenForIdentityresolveDocFile 里的 wiki get_node(162 行附近)和 hydrateTruncatedReplies(671 行附近)。feature ON 时这两处 actingUserOpenIdundefined → user token 取不到 → 静默回退 tenant,归因就丢了。PR 自己的注释写了「a missed call site cannot silently revert」,可以顺手补上。
  • lookupAuthorizedUserNameuser-token.ts:262)brand 硬编码 'feishu',调用方 turn-cli-identity.ts:192 没传;同族的 listAuthorizedUsers 两个调用点都正确传了 normalizeBrand(cfg.brand)。Lark 品牌 bot 的拒绝文案会丢人名(退化成「你」),不影响安全语义。
  • renderGitAskpassScript 上方有两段逐字重复的 JSDoc,前一段还缺 exchangeUrl 的说明,删掉前一段即可。
  • dashboard-ipc-server.ts:5264 生成授权链接也传了空的 cfg.ownerOpenId,导致 pending.openId 缺失 —— dashboard 这条路径就没有「链接被转给别人点」的 mismatch 校验(命令路径是有的)。和 feed-group 的 owner 解析一起改就行。
  • turn 绑定可被 agent 自己写 turn 文件绕过(实测伪造 <sid>.turn 就复活了旧凭证)。沙箱把它授成 readOnly 挡住了,这与 PR 自述「本期不做内核级隔离」一致、不算问题,只是建议在文档里写明「turn 绑定依赖沙箱才不可绕」。

已实测确认为真、不需要再自证的部分

  • wrapper 用真实 /bin/sh 跑通 5 项:轮次匹配放行 / 轮次不匹配拒 77 / 文件缺失拒 77 / 空文件拒 77 / 截断文件拒 77。注入 BOTMUX_IDENTITY_MODE=user(哪怕自带 token)绕不过,清空 SESSION_DATA_DIR 也只会拒绝 —— 绝不裸跑真实工具,你那个核心修正确实成立。
  • 「关闭时不引入 await 边界」经变异证实是承重的:把同步闸改成无条件 awaitdaemon-rename-route 立刻转红,还原后复绿。这条不是空话。
  • 凭证边界 prompt 块两条路径都覆盖(claude 家族走 buildArgs,其余 inline-prompt CLI 走 buildNewTopicPrompt)。
  • 沙箱 grant 逐文件授权、不授 cli-identity/ 父目录、readOnly —— 设计正确。

以上是自动评审的初步意见,可能有误判,最终以维护者审阅为准。阻断项主要是 1 和 2;其余小项顺手修即可。辛苦!

@deepcoldy

Copy link
Copy Markdown
Owner

二轮复审(f28547290,新增 4 个 commit)。先说结论:F5(login shell 里 wrapper 被挤掉)已经修好了,而且修法比我们上轮建议的更根本;F1(/login 后 no-openId 读者读不到 token)原样未动,仍是唯一阻断项。

基线:master 未动,仍是 8d2986c71。rebase 零冲突,原 17 个 commit range-diff 全等,新增 4 个。bun run build ✅、tsc --noEmit ✅、PR 相关测试文件全绿(本机有一处红,见下,是环境问题不是回归)。


✅ F5 已修 —— 并且我们上轮的诊断只挖到一半

我们上轮报的是「tmux pane 的 rcfile 重建 PATH 之后只重新 prepend ~/.botmux/bin」。你在真机上独立发现的根因比这深一层:agent 的工具调用本来就走 login shell(bash -lc / zsh -lc)→ /etc/zprofilepath_helper 重建 PATH 并把原有内容追加在后面,wrapper 目录从第 1 位掉到第 12 位。

这条链路 pty 后端同样走 —— 所以我们上轮那句「fleet 0 台 tmux ⟹ 今天没有线上影响」是错的,54 台 pty 一直都是坏的。这个更正记在我们头上,顺着 tmux 那条线查完就收工,没回头问 pty 会不会也中招。

installLoginShellPathShim 的修法我们两边都用真实 shell 独立验证过,都带阴性对照:

  • 有 shim:bash -lc / zsh -lc / bash -c / zsh -c 四种形态都解析到 wrapper
  • 无 shim:解析到真实工具(对照成立,测试不是因为别的原因通过的)
  • 额外一点:本机 /etc/profile 对 root 是覆盖式 PATH= 赋值(比 macOS path_helper 的追加式更狠,继承的 PATH 整个丢掉),shim 照样有效 —— 因为它是从 BOTMUX_IDENTITY_BIN 重建而不依赖继承值。比测试断言覆盖的场景更 robust。

整条链也核过了:worker 设三个变量 → child-env 白名单让它们过 tmux pane 的 scrub → codex 用 shell_environment_policy.set 逐键透传(没用 inherit="all",面窄,这个选择是对的)→ shim 在 path_helper 之后重新 prepend。installedAny=false 和 shim 安装失败都有显式告警,静默 fail-open 那条堵上了 —— 这一条尤其重要,因为它正是真机上最难被发现的形态。沙箱侧也没有新缺口(seatbelt subpath / bwrap --ro-bind 覆盖 .bin 下的 shell/ 子树,且 shim 安装早于 buildFsPolicy,bind 时文件已存在)。

一个 watch-item(不阻断)ZDOTDIR 作用于整个 CLI 进程,所以这个会话里 CLI spawn 的任何 zsh 都会改读 shim 目录的启动文件。shim 会先 source $HOME 下的同名文件,功能等价;但如果用户的 .zshrc 里有依赖 ZDOTDIR 指向 $HOME 的逻辑,行为会变。只影响开了 triggerUserAuth 的会话,可以接受 —— 建议在 PR 描述里点一句就行。

🔴 F1 仍未修 —— 唯一阻断项

在新 head 上重跑了探针,输出与上轮逐字相同

/login 回调后落盘:user-token-<app>-<openId>.json (只有这一个)
resolveUserToken(app, sec, brand)      => null
getTokenStatus(无 openId)               => 未登录(无 User Token)
getFeedGroupAuthStatus(无 ownerOpenId)  => { authorized: false }

5 个读者逐字未改:command-handler.ts:3061client.ts:1403doc-comment.ts:373dashboard-ipc-server.ts:5285audio-transcribe.ts:109。其中 3 个在功能关闭时的默认路径上,所以「默认关闭」挡不住。上一条评论里的修法建议仍然适用(改读者、不双写),另外建议补一条「/login 之后 no-openId 读者仍可用」的回归测试 —— 我们试过给 /login 加双写做变异,user-token-* 那 29 个测试全绿,说明这条路径目前零覆盖,光看 diff 判断不出来改没改对。

上一条里的 F2/F3/F4/F6/F7/F8 本轮也都还没动。

🟡 新加的阴性对照测试建议调整断言

test/cli-identity.test.ts:252「without the shim, bash resolves the real tool」在我们机器上红、CI 上绿1 failed | 54 passed)。

根因不是你的代码:这台机器以 root 运行,/etc/profile 有一个 root 专属分支把 PATH 覆盖成不含 /usr/local/* 的固定串,于是 bash -lc 之后 realDirwrapperDir 都不在 PATH 里,command -v faketool 什么都找不到,execFileSync 直接非零退出。断言要的是「解析到 realDir」,实际是「什么都解析不到」—— 阴性对照自己的前提在这台机器上不成立。

建议把断言从「解析到 realDir」弱化成「wrapper 没有赢」(catch 住 command-not-found 的 throw,视为合法阴性,断 expect(resolved).not.toBe(wrapperPath)),而不是加前置条件 skip。理由是 botmux 的 fleet 自己就跑 root —— 这不是假想环境,是这个项目的实际运行环境,skip 会让阴性对照恰好在最该跑的机器上不跑。弱化之后两种环境都成立(正常机器解析到 realDir、锁 PATH 的机器解析为空,都满足「wrapper 没赢」),而它要防的那个回归(无 shim 时 wrapper 反而赢了 = 阳性测试失去意义)照样能抓住。

/login scope 改动

7 个 scope 名逐个对过 src/setup/lark-scopes.json,全是真的;DEFAULT_SCOPES ∪ extraScopes 去重,/subscribe-lark-doc/login tags 两条专项流程不受影响;--scope 走同一份目录校验。只读、排除 contact/calendar 的取舍我们认可。存量已授权的人需要重新 /login 才能拿到新 scope,这是 OAuth 的性质,只是值得在描述里提一句。

「drop the guessy notice」删得对 —— 那个通知在 agent 还没决定用哪个工具时就触发、还是一次性的,而 wrapper 的 stderr 是逐工具、逐次、当场的精确信号,通知确实是它的劣化副本。


以上仍是自动评审的初步意见最终以维护者审阅为准。目前只剩 F1 一个阻断项,其余是建议。辛苦!

xiaoxueSunn and others added 24 commits September 7, 2026 22:06
原来 token 只按 appId 存一份,同一个 bot 里第二个人 /login 会直接覆盖第一个
人,之后所有人的操作都用最后授权那个人的权限——不报错,只是审计里写错了人。
目前没暴露只因实际只有一个人在 /login。

- 文件名加 openId:user-token-<appId>-<openId>.json
- resolveUserToken/getTokenStatus/getFeedGroupAuthStatus 接受可选 openId;
  按人取时只认属于那个人的文件,未标注归属的旧文件一律不认领(无法证明属于谁)
- 归属以 user_info 回读的真实授权人为准,不信任发起人声明:/login 链接被转给
  别人点时按真实授权人落盘并明确告知,绝不把 B 的凭证存到 A 名下
- refresh 写回原文件并保留 openId/userName,不落到 per-app 路径覆盖别的东西
- 新增 listAuthorizedUsers(),只报归属与有效期,不返回任何 token 内容
- openId 过 [A-Za-z0-9_-] 校验后才参与拼路径,挡住 ../ 逃出 token 目录
- 删掉 FEISHU_USER_ACCESS_TOKEN 全局覆盖:设一次就让所有 bot、所有会话、所有
  人共用一个身份,且绕过时日志里没有任何痕迹

原有 17 个 user-token 测试全过(两处断言按新语义改写:env 旁路已移除、回调多
一次 user_info 调用),新增 10 个 per-user 测试。

Co-Authored-By: Claude Code <noreply@anthropic.com>
/login 现在把发起人的 open_id 带进 OAuth state,回调按人落盘(回调本身仍用
user_info 复核真实授权人)。/login status 也改成按人查——报你自己授权了没,
别人的授权状态与你无关,也不该让你看见。

同时处理一个升级陷阱:飞书「消息分组」标签是 owner 自己收件箱侧边栏里的东西,
bot 没有收件箱,所以它只能用 owner 本人的 token。但存量安装的 owner token 就
存在未标注归属的 per-app 文件里,严格按人取会让标签功能升级后静默失效。为此
新增 resolveOwnerUserToken():优先 owner 自己的文件,回落到未标注归属的旧文件。

这道口子只对 owner 开,且只放宽「哪个文件算 owner 的」,不放宽「可以借谁的身份」
——另一个具名用户的文件依然拒绝。ownerOpenId 来自 bot 配置而非消息,因此不存在
借用别人凭证的路径。

- /login、/login tags、/subscribe-lark-doc 的授权链接都带上发起人
- feed-group 的 tagger / dashboard / IPC 三处改用 owner 口径
- getFeedGroupAuthStatus 同步该口径,否则升级后徽标会莫名变「未授权」

新增 3 个 owner-fallback 测试,user-token 全部 30 个测试通过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
新增 services/trigger-user-auth.ts 承载这条规则:CLI 调用用本轮消息发送者自己的
凭证,或者没有;唯一允许的降级是「bot 自己的 tenant 身份」(不属于任何人),
绝不退回到另一个人的登录态,owner 也不例外。

因此配置里刻意没有 device / machine-login 这个选项,而且写了它会被明确拒绝并
说明「永不可用」——留一个静默归一化会让操作者以为自己配上了。同理 tools 里出现
未知工具直接抛错:静默丢弃会让人相信边界已生效,实际没有,比没这个功能更糟。

记录两个 CLI 的能力不对称:lark-cli 有 --as bot(零登录可用),bytedcli 只有
SSO 个人登录,没有 service account 也没有 AK/SK。所以对 bytedcli,即使配了
fallback=bot-identity,unauthorizedOutcomeFor 也返回 fail——报 bot-identity 只会
产生一次注定失败的调用,还附带一个误导性的原因。

至于某条 lark-cli 命令到底能不能用 bot 身份,不在这里判断:CLI 自己会声明
(不支持时回 "--as bot is not supported, this command only supports: user")。
手工维护「哪些命令需要真人」的清单会过期、会漏。

- bots.json 加 triggerUserAuth 字段,缺失即关闭
- /botconfig set 与 Dashboard 走同一个 parser(照 mojo 的模式),两个门不会漂移,
  拒绝原因原样透出
- effect: next-session

新增 16 个策略测试;bot-config-store / bot-registry 现有 219 个测试全过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
凭证走文件而不是环境变量,这是本功能能成立的前提:一个会话是一个长驻 CLI 进程,
它的环境在 spawn 那一刻就被内核定死,daemon 之后改不了,子进程也改不了父进程的。
env 注入等于把身份冻结在起会话那个人身上,之后任何人发言都还在用他的权限。文件
则是每次 CLI 调用都重新读,所以生效的身份永远是 daemon 为当轮写的那一份,换人
零成本且不用重启会话(重启会丢掉 CLI 全部上下文,实际不可接受)。

- core/cli-identity.ts:身份文件读写 + wrapper 生成。值用单引号包裹,含引号/换行
  的一律拒绝而不是清洗——被改过的 token 就是不能用的 token,静默截断会在很久之后
  变成一个莫名的鉴权失败。7 个恶意值跑真实 /bin/sh 验证原样往返。
- core/turn-cli-identity.ts:策略 + token 存储 + 身份文件三者的汇合点。取不到当轮
  发送者的 token 时**删除**旧文件,绝不留着——留着就等于下一条命令用上一个人的
  身份跑,而且没有任何信号。无人类发送者的轮次(定时/hook/会议/bot 交接)同样删除,
  不会去拿会话创建者或 owner 的凭证来补。
- 挂在 noteTurnReceived:它是源码注释里明确的 per-message acceptance point,每个
  inbound turn 都经过,是唯一能保证「没有一轮带着上一个人的凭证跑」的位置。
- 发送者取自 daemon 自己的 per-turn 记录(pickTurnReplyTarget),worker 只能提供
  turnId,无法选择这个 id 指向哪个人。
- wrapper 装在 per-session 目录而不是共享 ~/.botmux/bin:装在后者会让本机所有 bot
  连没开这个功能的、以及操作者自己的 shell 都被 shadow。
- 真实二进制从即将交给子进程的 PATH 里解析并排除 wrapper 目录,构造上不可能自引用。
- 会话关闭时清掉全部身份文件:里面是活的 user token。
- 需要授权的提示每会话每工具只发一次。

新增 38 个测试(cli-identity 27 + turn-cli-identity 11),本功能累计 67 个全过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
三处内部直接用 user token 的地方,加了 openId 维度后必须回答「用谁的」,漏掉会
静默失效。逐个查完发现它们的正确答案各不相同,也都已经有 fallback,所以不是重写:

- 图片/文件下载(client.ts):现有逻辑本来就是「先 app token,400/403 才回退
  user token」,bot 能下的就用 bot 身份。只把回退时那个 token 改成**发送者本人**的
  ——他刚发的附件,用他的凭证下载天然正确,归因也归到他。
- 文档评论(doc-comment.ts):订阅是长期任务,没有「当轮发送者」——触发的评论可能
  来自任何人,包括从没授权过这个 bot 的人。所以身份固定为**订阅创建者**
  (DocSubscription.ownerOpenId,字段本来就有),不取自评论人。
- feed 打标:已在上一个 commit 处理(owner 个人功能)。

doc-comment 的身份解析放在 driveApiCall 内部集中做,而不是把 userOpenId 穿过全部
9 个导出函数和约 20 个调用点:后者漏一个就静默退回 bot 级查找,而且看不出来。

开关关闭时三处都走原逻辑,一行行为不变。

user-token / doc-comment / cli-identity 相关 145 个测试全过,tsc 0 错误。

Co-Authored-By: Claude Code <noreply@anthropic.com>
沙盒是 deny-by-default,所以 wrapper 要读的身份文件本来是被拒的。按**单个文件**
授予只读,绝不授予 cli-identity/ 父目录:那个目录里放着所有并发会话的文件,每份
都是不同的人的活 token,父目录授权等于让会话 A 能读会话 B 的凭证——正是这个功能
要防的跨人泄漏,而且对 spawn 之后新建的文件会 fail open。

只读也是刻意的:daemon 写、CLI 只读。可写就等于让 agent 自己发布身份,冒充任何
它能说出名字的人。no-transport(core-only)轮次一律不给——那种模式本身就是要关掉
飞书凭证面,给它 user token 等于把这道边界重新打开。

沙盒会 existence-filter 掉不存在的路径,所以 spawn 前预建空身份文件,否则 wrapper
读不到 daemon 之后写入的内容,会话会静默地不带发送者身份跑。空内容是正确初值:
第一轮解析出身份前本来就没有身份,wrapper 对空文件和不存在同样处理。

另外加 tokenStoreProtection(),如实说明保护程度而不是给个安心话术:token 是 0600,
但 agent 与 botmux 跑在同一个系统用户下,**不开文件沙盒时它有能力直接读取
~/.botmux/data/ 里其他人的 token 文件**。按人存储解决的是归因和覆盖问题;要让隔离
由操作系统强制,必须开 sandbox。

这条只记日志、不拦:拒绝启动会把人从一个无论如何都更好的改动上推开,而声称我们
没有的隔离比两者都糟。措辞里写清 agent 实际能做什么,光说「不够安全」操作者无法
据此判断。

fs-policy 新增 4 个测试(含「会话 A 读不到会话 B 的身份」),本功能相关 154 个
测试全过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
git 走 HTTPS 推 Codebase 时用的是 Codebase JWT,由 GIT_ASKPASS 提供,它不读前面
那些身份 env。不做这块的话,替别人干的活推上去仍然挂着机器的身份——而「这个 MR
是谁提的」恰恰是这个功能最需要修的归因。

helper 问的是**被 wrapper 包过的** bytedcli,因此自动继承当轮身份:不用维护第二条
凭证链路,helper 自己也不需要知道当前是谁在说话。

- 每次调用现取 JWT,不落盘、不进 URL、不打日志。JWT 拼进 remote URL 会留在
  .git/config 里,而且会出现在 git 打印的任何报错里。
- 解析真实响应结构 {"status":…,"data":{"jwt":"…"}}(实测 bytedcli 确认,字段是 jwt
  不是 token)。
- 取不到凭证时返回空串,git 报成鉴权失败——而不是抛一个看起来像 botmux bug 的
  shell 错误。
- 只在 bytedcli 已被 wrap 时安装:否则根本没法签 JWT,装一个必然失败的 helper 只会
  把「没有凭证」变成一个难懂的 git 报错。

新增 6 个测试(含真实 /bin/sh 跑 askpass、以及「不得把 token 放进 URL」),
cli-identity 共 33 个全过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
Dashboard 放在「安全」tab,与 sandbox / Codex 凭证 / 授权管理并列——它们是同一类
设置:一个会话以谁的身份运行。形态照 SandboxSection:主开关 + 开启后才出现的细项;
工具用复选框而不是两个独立 toggle,因为它们是一个功能的作用范围,不是两个能力。
后端复用 codexAuthSync 那条三层链路,与 /botconfig set 共用 applyConfigField,
两个门的校验不会漂移,拒绝原因原样透出。

新增 services/credential-bearing-mcp.ts:找出自带凭证的 MCP server。这类 server
用自己的 app 凭证直连、从不 exec 被包装的 CLI,所以这套策略根本触达不到它——agent
仍可通过它以与当轮发送者无关的身份读写飞书。

按「声明了凭证形态的 env key」检测,而不是按已知 server 名单:名单在别人写出新
server 的那一刻就过期了,而且「botmux 没认出来」不是一条安全性质。APP_ID 不算凭证,
把它算进去会产生噪音、训练人忽略这条警告。

只警告不拦:硬拦会破坏合法用法(自配的飞书客户端有真实用途,botmux 也不拥有它),
但沉默比两者都糟——操作者会以为边界是完整的。假装覆盖了某样东西,比没覆盖更危险。

Dashboard 上把两条边界提示按 warning 样式显示(token 存储保护程度、自带凭证 MCP),
不混在普通 help text 里:不能让人一眼扫过「agent 能读到别人的 token」这句话。

/status 增加当前身份行,只报**问的这个人自己**的授权状态——谁还授权过是成员关系,
群成员没有理由从 /status 得知。

顺带修掉一处自己引入的问题:askpass 测试里写死了内部域名,被仓库自带的
audit:domains 守卫拦下,已改成 example.com。

新增 11 个 MCP 检测测试;npm run build 通过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
全量单测暴露两类问题,都是我引入的。

一、关闭状态下也改变了轮次时序(真 bug,不只是测试问题)

noteTurnReceived 里我加的 `await refreshTurnCliIdentity(...)` 是无条件的,于是
**每个 bot 的每一轮**都会在后续同步记账之前多让出一个 microtask,重排了
single-flight 路径依赖的并发顺序——daemon-rename-route 的「并发 get-or-create
串行化」因此失败。

一个关闭的功能不该改变任何时序。改成先做同步的启用判断,只有真正开启的 bot 才
进入 await。

二、测试 mock 跟着实现签名更新

- feed-group 三个测试文件 mock 的是 resolveUserToken,但实现已改用
  resolveOwnerUserToken(标签是 owner 自己收件箱里的东西),于是打到了未 mock 的
  真实函数。
- command-handler 三处断言按旧的 3 参 generateAuthUrl 写死;新增的第五参正是本次
  的重点,断言改成显式盯住发起人 open_id,而不是放宽成 any。

daemon-rename-route 79、command-handler 302、feed-group 三个文件、以及本功能自身
的测试合计 491 个全过。

另外确认 test/bun-shim-parity.test.ts 的 1 个失败在 origin/master 上同样存在
(已用干净 worktree 对比),与本改动无关,不在此处理。

Co-Authored-By: Claude Code <noreply@anthropic.com>
从 @byted/orchestra 二进制里读到的三条实践,都是我们原实现有同样暴露的:

1. 调 bytedcli 前置 GIT_CONFIG_COUNT=0 GIT_SSH_COMMAND=false。这个 helper 本身
   是被 git 调起来的,而 bytedcli 又会 shell out 到 git 取仓库上下文——不重置的话
   内层 git 会重新读到调用我们的那份 credential 配置,可能递归,也可能悄悄走 SSH
   用机器身份完成认证。我们原来的 askpass 有完全相同的风险。
2. bytedcli 取不到 JWT 时,用 ByteCloud JWT 直接换 Codebase JWT 兜底。bytedcli
   缺失或正在升级时,原实现会让每次 push 变成一个没有解释的鉴权失败。
3. url.<host>.insteadOf 把 SSH 远端改写成 HTTPS。SSH 是这条链路上最安静的逃逸口:
   仓库一旦是 SSH clone 的,就继续用机器的 key,推送落在机器身份下,而且不报错。

两个新配置项 gitHost / gitTokenExchangeUrl 走配置而不是写死:端点是部署相关的,
而且本仓库有域名门禁,不允许把私有主机名写进源码。校验也因此收紧——host 必须是
裸主机名(它同时被插进 git config key 和 shell 脚本),exchange URL 必须 https
(它承载的是活的 JWT)。

git 配置通过 GIT_CONFIG_* env 下发,只作用于本会话的 git,不碰用户自己的
~/.gitconfig。

新增 7 个测试(含用桩 curl 跑真实 sh 验证兜底链路、以及 host 注入用例),
相关 230 个测试全过,build 与域名门禁通过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
本期不做内核级隔离,所以这段提示词是唯一的防线——那就必须写清楚它防什么、
不防什么。

内容四条:本会话的凭证由 botmux 每轮注入(不需要自己找)、data/ 下别人的
user-token-* 不得读取列举复制输出、任何 token 不得进消息/日志/文档/提交、
鉴权失败时如实上报并提示 /login 而不是翻找其他凭证。

第一条和第四条是刻意加的:光说「不许读」会让一个正在排查鉴权失败的 agent
无路可走——而这恰恰是它最可能去翻 data 目录的时刻。给出替代路径才真正减少
翻找。

两条 prompt 链路共用同一个 buildCredentialBoundaryBlock:claude 系走
--append-system-prompt,codex/gemini 等走 session-manager 内联。只加一边的话,
另一边的 CLI 会完全没有约束——而这一期它是唯一防线,缺一边就是个静默的洞。

明确边界(注释和 commit 都写了):这是行为约束不是安全控制,挡不住有意的用户,
也挡不住 prompt injection。它针对的是最可能真实发生的那种情况——agent 排查问题
时顺手 grep 数据目录。实测过内核隔离能挡住这些(cat/tmp sidecar/后建文件全部
DENIED),但那条留到下一期。

关闭时不产生任何提示词;bot 配置读不到时按未开启处理,绝不凭空声称有边界。

新增 9 个测试;相关 87 个测试全过,build 与域名门禁通过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
The /login prompt hardcoded the manual paste-back steps: 'the browser will
show ERR_CONNECTION_REFUSED, this is normal', 'copy the URL from the address
bar, press F12 if you cannot see it'. With oauthRedirectBase configured the
Dashboard completes the exchange on its own, so those steps describe a failure
that never happens and send people hunting through DevTools for nothing.

Branch the prompt on resolveOAuthRedirectUri(). The auto path keeps one
fallback line about pasting the address bar, so a user who lands on an error
page still has a way forward instead of a dead end.

Applies to all three prompts (/login, /login tags, /subscribe-lark-doc), which
had drifted into three copies of the same hardcoded steps.

Co-Authored-By: Claude Code <noreply@anthropic.com>
… login

Four fixes from PR review, the first of which defeated the feature entirely.

**No credentials no longer means "run as the machine account."** The wrapper
exec'd the real tool whenever no identity file was published, on the premise
that lark-cli with no env runs as the bot. It does not. Measured:

  env -u LARKSUITE_CLI_APP_ID -u LARKSUITE_CLI_USER_ACCESS_TOKEN lark-cli whoami
  => identity: user, onBehalfOf: <the operator>

lark-cli falls back to the operator's on-disk login, so every unauthorized
turn ran with the machine owner's full permissions — silently, and under their
name in the audit trail. The earlier `--as bot` check that seemed to confirm
the premise had itself run on a logged-in machine.

Running as the bot needs LARKSUITE_CLI_APP_ID + LARKSUITE_CLI_APP_SECRET, which
does resolve `identity: bot` even with a user logged in on disk, and `--as user`
on top of it then fails token_missing rather than reaching back to those
credentials. So the file now carries an explicit mode: user, bot, or denied.
Anything else — absent, empty, truncated mid-write, or an env-injected marker —
is refused with exit 77. A denial names whose authorization is missing and says
to send /login, because a bare refusal just gets retried.

**A turn with no sender no longer borrows the last human who spoke.**
`?? ds.session.lastCallerOpenId` meant a scheduled run or hook executed with the
credentials of whoever last talked to the session.

**A failed authorization read-back no longer guesses.** When user_info could not
say who authorized, the token was filed under whoever requested the link — but a
/login link can be forwarded, so that guess files B's credentials under A, after
which A's every command runs as B. Now nothing is saved and the user is told to
retry. Covers non-2xx, code != 0, missing open_id, and network failure.

**/status reports each tool separately.** One combined verdict claimed "you are
authorized" for bytedcli on the strength of a Lark token, which cannot
authenticate it.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…ere issued for

The daemon publishes an identity when a message is ACCEPTED, but the CLI runs
turns off its own queue. So while A's turn is still executing, B's message can
arrive, overwrite the identity file, and A's remaining tool calls run with B's
permissions — the exact misattribution this feature exists to prevent, reached
by ordinary type-ahead rather than anything adversarial.

The daemon cannot close this alone: input goes straight to the worker over IPC,
and the worker does not wait for the daemon before starting a queued turn.
Publishing on the worker's turn-start signal would shrink the window, not
remove it.

So both sides now state a turn and the wrapper compares them. Each identity
carries the turn it was published for; the worker publishes the turn it is
actually executing (riding writeCliPidMarker, which every site that changes
currentBotmuxTurnId already calls — including the ones that bypass the normal
queue); a mismatch refuses with exit 77 and says to retry after the current
turn. Enforced only when both sides state a turn, so identities published
before this change and sessions with no live turn still run.

The cost is deliberate: tool calls in the tail of A's turn fail once B has
spoken. A failed command is recoverable; one that silently ran as the wrong
person is not.

The turn file needs its own sandbox grant and placeholder — the fs-policy
allow-list existence-filters at spawn, so without both a sandboxed session
would read nothing and refuse every governed command. Verified against the
repo's own buildSeatbeltProfile with canonicalized paths: this session's turn
and identity files are readable, another session's are denied by the OS.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Closes the gap left by the earlier claim that bytedcli "only has SSO personal
login" and therefore could not run as the triggering person. That was wrong,
and I never tested it: bytedcli has a complete non-blocking device-code flow,
and the env vars botmux already injects are ones the binary really reads.

Measured, in a clean environment rather than on my logged-in machine:

  BYTEDCLI_USER_CLOUD_JWT=<bogus> bytedcli auth userinfo
  => HTTP 401 invalid jwt token          (not a silent fall back to my login)

  HOME=<empty dir> bytedcli auth status
  => not logged in                       (while I am logged in on this machine)

So HOME is a real isolation boundary, and each person gets their own under
~/.botmux/data/bytedcli-home/<openId>. /login bytedcli starts the flow and
returns the link; /login bytedcli done resumes it with a token kept inside that
person's own HOME, so nobody can complete somebody else's login.

We store the directory, not the JWT. The ByteCloud JWT lives ~2 hours while the
login behind it lives ~3 weeks, and bytedcli refreshes it internally — verified
by crossing an expiry: a token that expired at 01:48:03 was replaced by a fresh
one (iat 01:48:08) with no human involved. Storing the JWT would have sent
everyone back to a QR code every two hours to renew something still valid.
So the JWTs are minted per turn, and a person is interrupted only when the
3-week login genuinely ends — which lands on the existing refusal path.

A refusal now names the right command: telling a refused bytedcli user to send
/login would have sent them to authorize Feishu again and fail identically.

The per-person HOMEs are deny-by-default under the file sandbox and stay that
way: the daemon mints the JWTs, so the agent never needs to read them.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…en_ids at people

Both found by running the real publication path against business-agent's actual
config on an isolated daemon, rather than against test fixtures.

A bytedcli refusal said "需要「…」本人的飞书授权" — the wrong identity provider.
bytedcli authenticates against ByteCloud, so this sends the reader off to
authorize Feishu, which cannot fix it. Same class of mistake as naming the wrong
/login command, in the sentence right above that command.

And the name it used comes from a stored Lark token, which someone being refused
for lack of authorization generally does not have — so the fallback printed the
raw open_id: 「ou_42edf6b433d3daf02916a039e41531ef」本人. That path is the common
one here, not an edge case. With no name we now address the reader directly
("你自己的 ByteCloud 授权") instead of reading an id back at them.

Co-Authored-By: Claude Code <noreply@anthropic.com>
'你自己的ByteCloud授权' ran the Latin name straight into the Chinese text.
Visible in the real refusal on the E2E daemon.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The feature did not work at all in production, and every unit test passed
anyway. Found only by running it against a real bot with a real message.

Prepending the wrapper dir to the child's PATH is not enough. The agent's tool
calls run through a LOGIN shell (`bash -lc` / `zsh -lc`), which sources
/etc/zprofile, which runs /usr/libexec/path_helper. That helper rebuilds PATH
from /etc/paths + /etc/paths.d and APPENDS whatever was already there. Measured
on this machine: the wrapper dir went from position 1 to position 12, while
/opt/homebrew/bin — owned by /etc/paths.d/homebrew — landed at 10. So the real
lark-cli won every time, and calls ran as the machine account. Against the real
binary:

  zsh -lc 'lark-cli whoami'   before: identity: user  (the operator)
                              after:  identity: bot   (appId cli_aacc…)

The fix is our own startup file, re-prepending after path_helper has had its
say: ZDOTDIR for zsh (.zprofile — a non-interactive login shell skips .zshrc,
verified), BASH_ENV for bash. Both are per-process, so no dotfile is touched
and no other shell on the machine changes. Each shim sources the user's real
startup file first, so their PATH edits still apply and ours goes back in front
after them.

Why the tests missed it: they called the wrapper script directly, proving it
BEHAVES correctly, never that the agent's invocation REACHES it. The new tests
run actual bash and zsh login shells, and include the negative control — the
same shell without the shim resolves the real tool — so the assertion cannot
pass for the wrong reason.

Also: when no wrapper could be installed at all, the session ran completely
unprotected in silence. That is what made this so hard to see — the agent
reported the machine identity as "normal". It now warns explicitly.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…; drop the guessy notice

Two problems, both found by running the feature against a real bot.

**codex strips the identity vars before any shell command runs.** Codex does
not hand its own environment to the shell subprocesses it spawns — our own
comment says so (codex.ts:227) — and the `inherit="all"` that compensates is
added only under read isolation, which this bot did not have. So the chain was:
worker sets BOTMUX_IDENTITY_BIN / ZDOTDIR / BASH_ENV, tmux forwards them (fixed
in the previous commit), codex drops them, and `lark-cli` resolves the machine
owner's login. Three separate filters between the worker and the tool; this is
the third. GIT_ASKPASS rode the same path, so git attribution had never worked
in a tmux session either.

Declared per key via `shell_environment_policy.set` rather than
`inherit="all"`: the need is exactly these variables, and inheriting would hand
every shell command the whole worker environment.

**The chat notice guessed, and guessed wrong.** It fired at message-acceptance,
before the agent had chosen a tool, and named every governed tool without
credentials — ask for lark-cli, get told to authorize bytedcli too. Being
once-per-session, it then stayed quiet on the one occasion it would have been
right: when a bytedcli call is actually refused. Removed rather than narrowed.
The wrapper's stderr already says which tool was refused and which command
authorizes it, at the moment of refusal, every time — the notice was a worse
copy of information we already produce at the right time.

Co-Authored-By: Claude Code <noreply@anthropic.com>
CI runners do not all ship zsh; spawnSync ENOENT failed the suite. The bash
case still proves the mechanism.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…gin --scope

"Read the doc I just linked" is table stakes for an assistant and did not work:
the default scope set was three IM scopes picked when the only consumer was
chat-image download, so an authorized person still hit
`99991679 missing_scope` on their first document.

Seven read-only scopes now ship in the default set — docx + legacy docs bodies,
drive metadata and search, wiki resolution, and sheets. They are one closed
loop rather than a pick-and-mix: a link opens, a name is findable, a wiki URL
resolves to its token, and docs and sheets both read. Dropping any one produces
a plausible-but-broken assistant — docx without wiki fails on the wiki links
most internal documents actually use.

Read-only on purpose. A write scope turns "the agent misread something" into
"the agent edited your document", and nothing in this path writes. Contact
(the whole company directory), calendar and file download are excluded for the
same reason: not needed to read a document.

For everything else there is `/login --scope <names>`. Feishu already returns a
structured `missing_scopes` array, so the names can be copied straight from the
refusal — no hand-kept mapping, no giant default set that makes people approve
permissions they will never use. Names are validated against the same
lark-scopes.json catalog `botmux setup` uses, because a typo does not degrade:
it makes the whole authorize URL fail with 20043 and the person is handed a
link that simply will not open. (That has happened here before — 487e297, a
bare `docs:document.comment`, which is not a real scope. Writing these tests I
reached for `docx:document`, which is not one either; the catalog caught it.)

The agent gets one prompt line for the case too, because missing_scope is not
"not authorized": sending someone through a plain /login re-grants the same
scopes and fails identically.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Per-person storage was only half done. `/login` writes
`user-token-<app>-<openId>.json`, but five consumers looked tokens up WITHOUT
the openId and therefore found nothing — the person authorizes, is told it
worked, and the feature still reports "not logged in" with no error anywhere.

Nor did it wait for the feature to be enabled: three of the five are on the
default path. The legacy per-app file masks it until that file expires, so the
breakage lands the first time someone re-authorizes — routine, not an edge case.
`/subscribe-lark-doc` loops outright: authorize, retry, be asked to authorize.

Fixed at the readers rather than by double-writing a per-app copy. A copy goes
stale within hours (refreshToken only rewrites the per-person file), and for
feed-group — the owner's own sidebar — it would operate that sidebar with
whoever logged in last.

  client.ts            pass senderOpenId unconditionally, not only when the
                       policy is on: the openId is just a lookup key, and with
                       the policy off it selects the same person it always meant
  command-handler.ts   /subscribe-lark-doc precheck keys on message.senderId,
                       the same person its authorize link is generated for
  doc-comment.ts       resolve by subscription owner in both branches; the
                       ungated one was a keyless lookup that silently degraded
                       to the tenant identity, so attribution was quietly wrong
                       while the API kept answering
  feed-groups.ts       derive the owner via getOwnerOpenId(); `ownerOpenId` is a
                       raw bots.json field almost nobody sets (0 of 55 bots in
                       the reviewer's fleet), so it was passing undefined
  dashboard-ipc        same for the badge and for the authorize link, which
                       additionally restores the "was this link forwarded to
                       someone else?" check the command path already had
  audio-transcribe     thread the speaker through downloadMessageResource

Tests cover the contract — a token written the way /login writes it is readable
by the callers that consume it — rather than any one call site's arguments,
which a refactor would drift away from. The reviewer noted this path had zero
coverage: a double-write mutation left all 29 existing tests green.

Also from review: lookupAuthorizedUserName now gets the bot's brand (a Lark-brand
bot lost the person's name from refusals), a duplicated JSDoc block above
renderGitAskpassScript is gone, hydrateTruncatedReplies passes
fileTokenForIdentity, and the wiki get_node call documents why it cannot.

The negative-control shell test now asserts "the wrapper did not win" instead of
"resolved the real tool". botmux's fleet runs as root, where /etc/profile
overwrites PATH and neither directory survives — the old assertion failed on
exactly the machines the control matters most on, and skipping there would have
switched it off precisely when it should run.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…g it

The availability probe ran a real shell per shell per worker, on top of the
login shell each case already spawns. bun-test then failed a DIFFERENT test on
every CI run — child-env's node-pty child got empty output, tmux-startup-storm
was SIGKILLed — while master stayed green and each failing test passed alone.
That shape is resource exhaustion, not an assertion, and these were the newest
process-spawning tests in the suite.

A PATH lookup answers the same question for free.

Co-Authored-By: Claude Code <noreply@anthropic.com>
rebase 到含 deepcoldy#998 的主干时,opening 的分块从 `parts.push(文本)` 改成
`blocks.push({key, text})`,凭证边界块要跟着改并新增 `credentials` key。
这个落点原先零测试覆盖——`credential-boundary-prompt.test.ts` 的文件头写着
覆盖两条 prompt 路径,实际只 import 了 buildBotmuxSystemPromptText,
inline 路径(codex/gemini 等)一条断言都没有。删掉 push 全套测试照样绿。

补三条走 buildNewTopicPrompt 的断言:开启时含 <botmux_credentials>、
关闭时不含、拿不到 bot 配置时不含。变异验证:删掉 push 立刻转红。

`credentials` 刻意不进 ENVELOPE_KEYS。当前这个选择是惰性的——hook 模式要求
supportsInvisiblePromptHook(仅 claude-code),而 claude-code 有
injectsSessionContext、根本不走这个分支,所以 hook 模式下永不产生
credentials 块(已实测)。仍然保留在 PTY 文本,是为了将来别的 CLI 具备
hook 能力时不至于静默丢块:envelope 读不到时不会报错,只是约束消失。

Co-Authored-By: Claude Code <noreply@anthropic.com>
@deepcoldy
deepcoldy force-pushed the feat/trigger-user-cli-auth branch from ec7ce3c to 0b44d97 Compare September 8, 2026 05:19

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

三轮 review + 双人交叉复审收敛,0 阻断。本次已在维护者侧解决与主干的两处冲突(claude-code.ts 的 buildArgs 参数列表、session-manager.ts 因 #998 分块重构),并补上 inline prompt 路径的凭证边界块测试(原先该落点零覆盖)。CI 9/9 绿。

@deepcoldy
deepcoldy merged commit 61dadb0 into deepcoldy:master Sep 8, 2026
9 checks passed
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

🚀 Released in v3.20.0

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