fix(sandbox): 沙盒 bot 读得到插件 registry,读路径不再被写锁挡死 - #1173
Conversation
e407bc7 to
3c72f0b
Compare
|
已追推( 追加的一处: 原因是我回头验「这个 PR 到底买到了什么」时发现只做了一半:启用集合不在 registry 里,而在
验证同前:先验基线(四文件 vitest 154/154),变异从 5 处加到 7 处,新增两条各 1 red——列表退回直接用 |
|
先说结论:改动方向和分层都对,我倾向可合。三处改动各自成立,七处变异我逐条复现,全部真红(不是惰性编辑)。下面是复审中发现的一处平台覆盖缺口和三处未被任何测试钉住的分支,都附了可复现证据,供你判断是否补。 (本轮基于最新 一、Linux/bwrap 下降级读不会触发——沙盒里锁是建得出来的PR 把「读路径要写锁」和「fs 策略没开洞」当成缺一不可的两个原因。这在 macOS/seatbelt 下成立;但在 Linux/bwrap 下我实测不成立:bwrap 的根是全新 tmpfs, 用本 PR 编译出的真实策略跑真实 后果分两种情况:
也就是说降级读想兜住的那类场景,在 Linux 上换了个 errno(EBUSY,来自 rename 而非锁)从旁边漏过去了—— 补充一个对定性有影响的对照:pre-PR 的 Linux 行为不是 EPERM 崩溃,而是静默零(注册表在沙盒里根本不可见 →
建议(二选一,都不阻断合入):
我倾向 1:legacy 记录只要还可能存在,Linux 沙盒 bot 问「我装了哪些插件」就会拿到一个 EBUSY 而不是答案。 二、三处分支没有任何测试钉住(变异全绿)这三处都验证过不是惰性编辑——我构造了能区分的输入,证明变异后行为真的变了,只是现有用例观测不到。
G2 尤其值得补:降级读是新增的、绕开迁移的读路径,它的私有字段校验是这条路径上唯一的防泄漏闸,现在完全靠代码审阅保证。加一条「legacy 之外的非法形状必须抛」的用例即可闭合,成本很低。 三、已核验没问题的部分
四、一个给维护者的提醒(非本 PR 问题)
(另: 以上是自动评审的初步意见,最终以维护者审阅为准。第二部分的三条补测我认为性价比最高(尤其 G2),第一部分的 Linux 覆盖则请你判断是要扩 errno 还是收窄描述。 |
|
复审补充(自动评审,最终以维护者审阅为准): 我独立复跑了首审的全部结论,均属实,不重复。以下是这轮的增量发现。 一、首审 EBUSY 建议的修正首审建议「把 用本 PR 编译出的真实策略 + 真实 dist 在 bwrap 实测(transport / no-transport 一致):
要兜住 legacy 这类,得在 二、新发现:
|
|
更正我上一条里的一个错误建议,以及附议复审补充的一处新发现。 更正:我说的「扩一个
|
| 后端 | config.json 在沙盒里 | errno | 分类器 | 结果 |
|---|---|---|---|---|
| macOS / seatbelt | 文件在,读被 deny 规则拒 | EPERM |
接住 | ✅ 印 enabled? |
| Linux / bwrap | 全新 tmpfs,压根没 bind 进来 | ENOENT |
接不住 | ❌ 落到 readGlobalConfig() → 空 Set |
bwrap 内实测:
config.json 路径 : /tmp/bwhome/.botmux/config.json
读它抛了吗 : true errno = "ENOENT"
⟹ 落到 readGlobalConfig() -> 空 Set -> 不印 enabled?
也就是说在 daemon 实际运行的 Linux 上,列表会把每个插件印成没有 enabled 标记、也不印 enabled? ——正是你在 PR 描述里明确要避免的「用一个没读到的文件断言未启用」。不是回归(改前更糟),但这个特性在生产平台上静默不生效。
ENOENT 确实有歧义(沙盒藏了 vs 真的没配置过),所以修法请你判断——一个可能的方向是:ENOENT 且注册表读得到(说明确实在沙盒里)时才判 unknown。
与我上一条一致、无需改动的部分
七处变异全真红、安全边界两平台邻居均未开、build 绿、CI 78 红与本 PR 无关——这些复审独立复跑过,结论一致。三条零覆盖分支(尤其 G2 防泄漏校验)的建议仍然成立。
以上仍是自动评审意见,最终以维护者审阅为准。
沙盒 bot 跑 `botmux plugin list` 必挂:
EPERM: operation not permitted, open '~/.botmux/plugins-registry.json.lock'
两个原因叠加,缺一不可:
1. `~/.botmux` 在沙盒 fs 策略里是 deny-by-default,只开了 `bin` 与
`claude-plugin` 两个只读洞,`plugins-registry.json` 不在其中;
2. `readPluginRegistry()` 是纯读语义,却走 `withFileLockSync` —— 因为它顺带做
legacy 迁移(把内联的 MCP descriptor 搬进 per-plugin 私有文件并重写
registry),所以那把锁本身是必要的,不能直接摘。
于是即使把文件读权限开出去,读路径仍会先去建锁文件而 EPERM。
三处一起补:
- fs-policy:把 `~/.botmux/plugins-registry.json` 作为只读洞暴露。该文件按契约
不含密钥 —— `assertPublicPluginRegistry` 拒绝持有
`command`/`env`/`url`/`headers` 的记录,真实 descriptor 存在插件自己的
`private/mcp.json`(继续随 `~/.botmux` 一起 deny)。
- registry store:读路径在**写不动**的时候降级为不加锁、不迁移的读。两种形态都
覆盖:seatbelt 下锁**建不出来**(EPERM/EACCES/EROFS);bwrap 下 `~/.botmux` 是
ro-bind 自动生成的可写 tmpfs,锁**建得出来**、迁移的 rename 落到只读绑定上才炸
(EBUSY)。后者的 errno 挂在 `.cause` 上(迁移把错误重新包了一层),顶层
`.code` 是 undefined,所以必须按 `cause.code` 判。写路径
(write/upsert/remove)保持 fail-loud,静默空写比 EPERM 糟得多。
- `plugin list`:启用状态来自 `~/.botmux/config.json`,那个文件沙盒里**刻意不
暴露**(可能存放语音凭据),`readGlobalConfig()` 在那里返回 `{}` —— 与「读到了、
一个都没启用」无法区分。只把 registry 打开的话,列表会把每个插件都印成没有
`enabled` 标记,等于用一个没读到的文件断言"未启用"。改为区分二者,未知时印
`enabled?` 并说明原因。
降级读的三处刻意设计:
- 不跑迁移,改为把 legacy 记录投影成公开形状:一个没有写权限、做不了迁移的读者,
不该反而拿到迁移正要移走的私有字段。校验与迁移路径逐条相同,非法记录照样抛错。
- 只对**写不动**降级。`unsafe_plugin_private_dir` 这类布局/安全错误与非法记录一样
裹在同一个 `plugin_mcp_registry_migration_failed:` 前缀里,只有 `cause` 上的
errno 能把它们分开 —— 它们必须继续上抛,否则把唯一那道防泄漏闸也吞了。
- 不复用 `parsePluginRegistry` 的 `existsSync` 探测 —— 它对**读不到**的文件同样
返回 false,会把被拒绝的 registry 报成「没装插件」,和干净机器的输出一模一样。
只有 ENOENT 算空,其余读失败一律上抛。
`enabled?` 的分类器按**读失败**判定,不按 errno:两个后端藏这个文件的方式不同,
seatbelt 留着文件拒绝读(EPERM),bwrap 压根不把它 bind 进新 tmpfs(ENOENT)。
按 errno 过滤会导致这个特性只在 macOS 生效、在 daemon 实际运行的 Linux 上静默失效。
|
两处发现都成立,已改并追推( 一、Linux/bwrap:锁建得出来,降级读从旁边漏过去采纳。而且你那条自我更正是对的:往 改法按你更正后的方向,单独识别「迁移因写不动而失败」: function isUnwritableMigrationError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
if (!error.message.startsWith('plugin_mcp_registry_migration_failed:')) return false;
const code = (error.cause as NodeJS.ErrnoException | undefined)?.code;
return code === 'EBUSY' || code === 'EPERM' || code === 'EACCES' || code === 'EROFS';
}要求
🪤 顺带一个坑,写测试时踩到的:冻结 二、
|
| 变异 | 结果 |
|---|---|
| 迁移写不动时不降级(只认锁错误) | 1 red |
迁移判定读顶层 .code 而非 .cause.code |
1 red |
降级不校验 cause(把布局/安全错误一起吞掉) |
1 red |
分类器退回只认 EPERM/EACCES(漏掉 ENOENT) |
1 red |
每轮都从提交态出发、git checkout -- src 后 git diff --quiet 自检已复原;先验基线再变异。
3c72f0b to
c04fea2
Compare
问题
沙盒 bot 跑
botmux plugin list必挂:在一台真机上撞到的(沙盒 bot 被问「你装了哪些插件」,答不出来)。两个原因叠加,缺一不可:
~/.botmux是 deny-by-default,只开了bin与claude-plugin两个只读洞(fs-policy.ts的commonHomeBaseline),plugins-registry.json不在其中。readPluginRegistry()是纯读语义,却包在withFileLockSync里 —— 而这把锁不是多余的:readPluginRegistryUnlocked()=migrateLegacyPluginMcpDescriptors(parsePluginRegistry()),它顺带做 legacy 迁移(把内联的 MCP descriptor 搬进插件自己的private/mcp.json并重写 registry)。所以锁不能直接摘。于是即使只把文件读权限开出去,读路径仍会先去建锁文件,然后 EPERM。两侧必须一起补。
改动
plugin list—— 区分「读到了、没启用」与「读不到启用状态」。启用集合来自
~/.botmux/config.json,那个文件沙盒里刻意不暴露(可能存放语音凭据),readGlobalConfig()在那里静默返回{}。只把 registry 打开的话,列表会把每个插件都印成没有enabled标记 —— 等于用一个没读到的文件断言「未启用」,和下面 registry 那条existsSync的毛病是同一个。改成未知时印enabled?并附一行原因。分类器按「读失败」判,不按 errno(评审指出的第二处):两个后端藏这个文件的方式不一样 —— seatbelt 留着文件、拒绝读(
EPERM),bwrap 压根不把它 bind 进新 tmpfs(ENOENT)。按 errno 过滤会让这个特性只在 macOS 生效,而在 daemon 实际运行的 Linux 上静默失效。代价是「装了插件但还没有 config 文件」的机器现在印enabled?而不是裸 id —— 那句话仍然为真(确实没有启用,而我们没有断言任何东西),只是不够具体;在「用一个没读到的文件断言未启用」面前,这是该倒向的一边。fs-policy.ts—— 把~/.botmux/plugins-registry.json作为只读洞暴露,紧挨现有那两个洞。依据是这个文件按契约不含密钥:
assertPublicPluginRegistry()拒绝持有command/env/url/headers的记录落盘,真实 descriptor 存在插件自己的private/mcp.json,那个继续随~/.botmux一起 deny。只读是全部授权,install/enable 仍然只能宿主侧做。plugin-registry-store.ts—— 锁建不出来时,读路径降级为不加锁、不迁移的读。两种"写不动"的形态都覆盖(评审指出我原来只覆盖了一种):
EPERM/EACCES/EROFS。~/.botmux是 ro-bind 注册表时自动生成的可写 tmpfs,锁建得出来、迁移的 rename 落到只读绑定上才炸(EBUSY)。这个 errno 挂在.cause上 —— 迁移把错误重新包了一层,顶层.code是undefined,所以按error.code判会一个都匹配不到。EEXIST(有人持锁,withFileLockSync自己会等)与FILE_LOCK_TIMEOUT(忙)行为完全不变 —— 那两种情况确实存在写者,重试才是对的。unsafe_plugin_private_dir(有人把私有目录换成了符号链接)这类布局/安全错误,与非法记录一样裹在同一个plugin_mcp_registry_migration_failed:前缀里,只有cause上的 errno 能把它们分开 —— 它们必须继续上抛。writePluginRegistry/upsertInstalledPlugin/removeInstalledPlugin)保持 fail-loud。 它们在这里真的做不了事,静默空写比 EPERM 糟得多。降级读有两处刻意设计,都写在代码注释里:
parsePluginRegistry的existsSync探测。 它对读不到的文件同样返回false,会把被拒绝的 registry 报成「没装插件」—— 和干净机器的输出一模一样。只有ENOENT算空,其余读失败一律上抛。这条是这个 PR 里我最在意的一处:静默的零比报错难查得多。验证
tsc --noEmitexit 0。基线(先验基线,再做变异):
plugin-registry-sandbox-read/fs-policy/plugin-manifest-store/plugin-mcp-gateway四个文件 vitest 157/157 全绿;新文件bun test7/7(+2 条 source-lock)。新测试用文件权限复现沙盒条件(
~/.botmux置成0o500:可读可遍历、不可写),比起 mock 更接近真实策略;其中一条会先断言「锁文件确实建不出来」,避免测试在条件没生效的情况下假绿。十一处变异,每处都从提交态出发、跑完
git checkout -- src并git diff --quiet自检已复原:parsePluginRegistry(existsSync吞掉不可读)readGlobalConfig().code而非.cause.codecause(把布局/安全错误一起吞掉)EPERM/EACCES(漏掉 Linux 的ENOENT)一处留给维护者定的取舍
只读洞放在
commonHomeBaseline,因此它和现有的~/.botmux/bin、~/.botmux/claude-plugin一样对 no-transport(apiOnly)轮次也生效 —— baseline 规则不经dropAuthority,且比 authority-root 的整目录 deny 更深,按最深前缀胜出。我按既有两个洞的先例对齐了行为;如果希望 no-transport 轮次一个洞都不开,把这条改成larkTransport门控的internalpush 即可,我照改。