Skip to content

fix(sandbox): 沙盒 bot 读得到插件 registry,读路径不再被写锁挡死 - #1173

Open
xu4wang wants to merge 1 commit into
deepcoldy:masterfrom
xu4wang:fix/sandbox-plugin-registry-read
Open

fix(sandbox): 沙盒 bot 读得到插件 registry,读路径不再被写锁挡死#1173
xu4wang wants to merge 1 commit into
deepcoldy:masterfrom
xu4wang:fix/sandbox-plugin-registry-read

Conversation

@xu4wang

@xu4wang xu4wang commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

问题

沙盒 bot 跑 botmux plugin list 必挂:

EPERM: operation not permitted, open '~/.botmux/plugins-registry.json.lock'

在一台真机上撞到的(沙盒 bot 被问「你装了哪些插件」,答不出来)。两个原因叠加,缺一不可:

  1. fs 策略里没有这个文件。 ~/.botmux 是 deny-by-default,只开了 binclaude-plugin 两个只读洞(fs-policy.tscommonHomeBaseline),plugins-registry.json 不在其中。
  2. 读路径要写。 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 进新 tmpfsENOENT)。按 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 —— 锁建不出来时,读路径降级为不加锁、不迁移的读。

两种"写不动"的形态都覆盖(评审指出我原来只覆盖了一种):

  • seatbelt:锁建不出来 —— EPERM/EACCES/EROFS
  • bwrap~/.botmux 是 ro-bind 注册表时自动生成的可写 tmpfs,锁建得出来、迁移的 rename 落到只读绑定上才炸(EBUSY)。这个 errno 挂在 .cause 上 —— 迁移把错误重新包了一层,顶层 .codeundefined,所以按 error.code 判会一个都匹配不到。
  • EEXIST(有人持锁,withFileLockSync 自己会等)与 FILE_LOCK_TIMEOUT(忙)行为完全不变 —— 那两种情况确实存在写者,重试才是对的。
  • 只对写不动降级。 unsafe_plugin_private_dir(有人把私有目录换成了符号链接)这类布局/安全错误,与非法记录一样裹在同一个 plugin_mcp_registry_migration_failed: 前缀里,只有 cause 上的 errno 能把它们分开 —— 它们必须继续上抛。
  • 写路径(writePluginRegistry / upsertInstalledPlugin / removeInstalledPlugin)保持 fail-loud。 它们在这里真的做不了事,静默空写比 EPERM 糟得多。

降级读有两处刻意设计,都写在代码注释里:

  • 不跑迁移,改为把 legacy 记录投影成公开形状。 一个没有写权限、做不了迁移的读者,不该反而拿到迁移正要移走的私有字段。校验与迁移路径逐条相同(name 不匹配、非 contribution、带私有字段一律抛),只是不写盘。
  • 不复用 parsePluginRegistryexistsSync 探测。 它对读不到的文件同样返回 false,会把被拒绝的 registry 报成「没装插件」—— 和干净机器的输出一模一样。只有 ENOENT 算空,其余读失败一律上抛。这条是这个 PR 里我最在意的一处:静默的零比报错难查得多。

验证

tsc --noEmit exit 0。

基线(先验基线,再做变异):plugin-registry-sandbox-read / fs-policy / plugin-manifest-store / plugin-mcp-gateway 四个文件 vitest 157/157 全绿;新文件 bun test 7/7(+2 条 source-lock)。

新测试用文件权限复现沙盒条件(~/.botmux 置成 0o500:可读可遍历、不可写),比起 mock 更接近真实策略;其中一条会先断言「锁文件确实建不出来」,避免测试在条件没生效的情况下假绿。

十一处变异,每处都从提交态出发、跑完 git checkout -- srcgit diff --quiet 自检已复原:

变异 结果
摘掉 fs-policy 的只读洞 2 red(darwin + linux)
去掉降级(锁失败一律上抛) 3 red
降级读改用 parsePluginRegistryexistsSync 吞掉不可读) 2 red
降级读不做公开投影(legacy 原样透出) 1 red
把降级也套到写路径上 1 red
列表退回直接用 readGlobalConfig() 1 red
分类器把 deny 也当成「空集」 1 red
迁移写不动时不降级(只认锁错误) 1 red
迁移判定读顶层 .code 而非 .cause.code 1 red
降级不校验 cause(把布局/安全错误一起吞掉) 1 red
分类器退回只认 EPERM/EACCES(漏掉 Linux 的 ENOENT 1 red

一处留给维护者定的取舍

只读洞放在 commonHomeBaseline,因此它和现有的 ~/.botmux/bin~/.botmux/claude-plugin 一样对 no-transport(apiOnly)轮次也生效 —— baseline 规则不经 dropAuthority,且比 authority-root 的整目录 deny 更深,按最深前缀胜出。我按既有两个洞的先例对齐了行为;如果希望 no-transport 轮次一个洞都不开,把这条改成 larkTransport 门控的 internal push 即可,我照改。

@xu4wang
xu4wang requested a review from deepcoldy as a code owner September 1, 2026 10:46
@xu4wang
xu4wang force-pushed the fix/sandbox-plugin-registry-read branch from e407bc7 to 3c72f0b Compare September 1, 2026 11:19
@xu4wang

xu4wang commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

已追推(--force-with-lease,新 head 3c72f0b84),PR 描述同步更新了。

追加的一处:botmux plugin list 现在区分「读到了、没启用」与「读不到启用状态」。

原因是我回头验「这个 PR 到底买到了什么」时发现只做了一半:启用集合不在 registry 里,而在 ~/.botmux/config.json,那个文件沙盒里刻意不暴露(可能存放语音凭据,readRawConfig 对 EPERM/EACCES 是有意静默的)。所以只把 registry 打开之后,列表会把每个插件都印成没有 enabled 标记 —— 用一个没读到的文件断言「未启用」,和本 PR 主体反对的那条 existsSync 毛病是同一个形状。现在未知时印 enabled? 并附一行原因。

config.json 本身不打算暴露,这条只是不再把「读不到」说成「没有」。

验证同前:先验基线(四文件 vitest 154/154),变异从 5 处加到 7 处,新增两条各 1 red——列表退回直接用 readGlobalConfig();分类器把 deny 也当成空集。cli.ts 的 plugin 分支没法单独 import,所以那两条是 source-lock,但钉的是语义不是标识符拼写(正则匹配「deny errno ⇒ undefined」的形状 + 断言列表分支里不再出现 readGlobalConfig()),正确的重构不会被自己的守卫判红。

@deepcoldy

Copy link
Copy Markdown
Owner

先说结论:改动方向和分层都对,我倾向可合。三处改动各自成立,七处变异我逐条复现,全部真红(不是惰性编辑)。下面是复审中发现的一处平台覆盖缺口和三处未被任何测试钉住的分支,都附了可复现证据,供你判断是否补。

(本轮基于最新 origin/master c756d151f 本地 rebase 后审阅,无冲突;tsc --noEmit exit 0、bun run build 绿。)


一、Linux/bwrap 下降级读不会触发——沙盒里锁是建得出来

PR 把「读路径要写锁」和「fs 策略没开洞」当成缺一不可的两个原因。这在 macOS/seatbelt 下成立;但在 Linux/bwrap 下我实测不成立:bwrap 的根是全新 tmpfs,~/.botmux 本身没有任何规则(deny-by-default,不是被 ro-bind 的),它只是 --ro-bind 注册表文件时自动生成的挂载点父目录,因此可写

用本 PR 编译出的真实策略跑真实 dist/ 代码(transport / no-transport 两种模式结果一致):

LOCK CREATE: succeeds -> NORMAL (migrating) path runs

后果分两种情况:

  • 已迁移的注册表(现网绝大多数):正常路径跑通,readPluginRegistry 返回正确结果 → 本 PR 在 Linux 上确实修好了,但生效的是 fs-policy 那个洞plugin-registry-store.ts 的降级读没参与
  • legacy 内联 descriptor 的注册表:正常路径会尝试迁移、走 atomicWriteFileSyncrename 落到 ro-bind 上,直接抛错
readPluginRegistry THREW: plugin_mcp_registry_migration_failed:
  EBUSY: resource busy or locked, rename '.../plugins-registry.json.<pid>.<rnd>.tmp'
  -> '.../plugins-registry.json'

也就是说降级读想兜住的那类场景,在 Linux 上换了个 errno(EBUSY,来自 rename 而非锁)从旁边漏过去了——isUnwritableLockError 只认 EPERM/EACCES/EROFS,且这个错误根本不是从 withFileLockSync 抛出来的,而是从回调内部的迁移抛出来的,readPluginRegistry 的 catch 接得到但判定为「非不可写」→ 原样上抛。

补充一个对定性有影响的对照:pre-PR 的 Linux 行为不是 EPERM 崩溃,而是静默零(注册表在沙盒里根本不可见 → existsSync false → plugins = [],和干净机器逐字相同)。这恰好就是你在 PR 里最在意的那个失败形态。所以:

  • 你修掉了 Linux 上的静默零(靠 fs-policy 的洞),这个价值是真的;
  • 但 legacy 注册表在 Linux 沙盒下会从「静默零」变成「抛 EBUSY」——比静默零好(不再撒谎),却不是降级读承诺的「读得到」。

建议(二选一,都不阻断合入):

  1. isUnwritableLockError 扩一个 EBUSY,或在迁移失败时对「无写权限」的读者也回落到 readPluginRegistryReadOnly()
  2. 或者仅在 PR 描述/注释里把适用平台讲清(降级读主要覆盖 seatbelt 形态),避免后来人以为 Linux 也走这条路。

我倾向 1:legacy 记录只要还可能存在,Linux 沙盒 bot 问「我装了哪些插件」就会拿到一个 EBUSY 而不是答案。


二、三处分支没有任何测试钉住(变异全绿)

这三处都验证过不是惰性编辑——我构造了能区分的输入,证明变异后行为真的变了,只是现有用例观测不到。

# 变异 4 条用例 实测行为差异(证明非惰性)
G1 isUnwritableLockError 改成 return true 全绿 活锁持有者场景:正确码 30s 后抛 FILE_LOCK_TIMEOUT;变异后静默降级返回数据。你在注释里专门论证了「EEXIST/FILE_LOCK_TIMEOUT 必须保持重试」——这条论证本身没有被试对象
G2 删掉降级读里的 invalid_plugin_mcp_contribution 校验 全绿 喂一条 privateRef + env 并存的记录:正确码抛错;变异后原样吐出 {"env":{"TOKEN":"s3cret"}}——即降级读的防泄漏校验无覆盖
G3 删掉降级读里的 mcp.name !== record.id 守卫 全绿 同一函数内第三处守卫,同样无用例

G2 尤其值得补:降级读是新增的、绕开迁移的读路径,它的私有字段校验是这条路径上唯一的防泄漏闸,现在完全靠代码审阅保证。加一条「legacy 之外的非法形状必须抛」的用例即可闭合,成本很低。


三、已核验没问题的部分

  • 七处变异逐条复现,全部真红,且红在预期用例上:摘 fs-policy 洞(darwin+linux 2 红)、去降级(2 红)、降级改用 parsePluginRegistry(1 红,报「did NOT throw — silent zero」)、降级不做公开投影(1 红,报「command LEAKED」)、降级套到写路径(1 红)、列表退回 readGlobalConfig()(1 红)、分类器 deny 当空集(1 红)。每次变异后 git checkout -- srcgit diff --quiet 自检复原。
  • 安全边界~/.botmux/plugins-registry.json精确文件规则,实测同目录邻居(config.jsonbots.json.dashboard-secretplugins/*/private/mcp.json)在两个平台都没有跟着开;公开投影确实剥掉 command/env。只读授权范围我认为是合适的。
  • 写路径 fail-loud 三个入口都验过确实抛 EACCES/EPERM。
  • CIbuild(真门禁)绿;bun-test 78 条失败全部与本 PR 无关(tmux/hermes/connector-store/dashboard 等),其中没有一条命中 plugin/registry/fs-policy/sandbox。

四、一个给维护者的提醒(非本 PR 问题)

test/plugin-registry-sandbox-read.test.ts 里 4 条用例依赖 chmod 0o500 生效,以 root 跑必然失败(root 绕过 DAC,锁文件照样建得出来,测试前置条件不成立)。我以 nobody 重跑同样代码:4/4 通过。用文件权限复现沙盒条件比 mock 更真实,这个取舍我赞成;只是如果 CI 或谁的本地环境是 root,会看到 4 条假红。可以考虑在 beforeEachif (process.getuid?.() === 0) return ctx.skip(),省得后来人误判。

(另:test/plugin-mcp-sandbox.test.ts 有 2 条失败,我在干净 origin/master c756d151f 上复现了同样的 2 条——先于本 PR 存在,不算回归。)


以上是自动评审的初步意见,最终以维护者审阅为准。第二部分的三条补测我认为性价比最高(尤其 G2),第一部分的 Linux 覆盖则请你判断是要扩 errno 还是收窄描述。

@deepcoldy

Copy link
Copy Markdown
Owner

复审补充(自动评审,最终以维护者审阅为准):

我独立复跑了首审的全部结论,均属实,不重复。以下是这轮的增量发现。

一、首审 EBUSY 建议的修正

首审建议「把 isUnwritableLockError 扩一个 EBUSY」——按字面写接不住isUnwritableLockError 查的是错误顶层 .code,而迁移失败抛的是包装过的 new Error('plugin_mcp_registry_migration_failed:EBUSY: …', { cause }),顶层 .codeundefined,EBUSY 挂在 .cause.code 上。只往 errno 列表加 EBUSY 不会生效。

用本 PR 编译出的真实策略 + 真实 dist 在 bwrap 实测(transport / no-transport 一致):

  • 建得出来~/.botmux 是 ro-bind 注册表时自动生成的可写 tmpfs 父目录)→ 降级读不触发;
  • 已迁移注册表:正常路径读通(修好它的是 fs-policy 的洞,降级读没参与);
  • legacy 注册表:迁移 rename 落到 ro-bind 上抛 plugin_mcp_registry_migration_failed:EBUSY

要兜住 legacy 这类,得在 readPluginRegistry 的 catch 里单独识别「迁移因不可写而失败」error.message 前缀 + error.cause?.code ∈ {EBUSY, EACCES, EPERM, EROFS})再回落 readPluginRegistryReadOnly();且只对「不可写」回落,invalid_plugin_mcp_contribution 这类真非法记录仍要抛。

二、新发现:enabled? 分类器只在 macOS 生效,Linux 上静默退回误导形态

readEnabledPluginIdsOrUnknown 只 catch EPERM/EACCES。但 Linux/bwrap 下 config.json 根本没 bind 进沙盒 → readFileSyncENOENT → 不被 catch → 落到 readGlobalConfig()existsSync false → {} → 空 Set。

实测(真实 bwrap):

CLASSIFIER: Set(0) -> plugins listed with NO enabled flag and NO enabled? note
raw readFileSync config.json: ENOENT

即 Linux(daemon 实际跑的平台)上 plugin list 会把每个插件印成没有 enabled 标记、也不印 enabled?——正是作者设计 enabled? 要避免的「用没读到的文件断言未启用」,只是它只在 macOS 的拒绝形态(EPERM)下触发,接不住 Linux 的缺失形态(ENOENT)。不是回归(pre-PR 更糟),但属于功能在生产平台上静默不生效。修起来不简单:Linux 下 ENOENT 本身有歧义(沙盒藏了 vs 真没配置),可能需要沙盒侧给信号或换存在性表达,请维护者定夺。

三、已独立核验(与首审一致)

  • G1/G2/G3 三处变异:逐条复现「全绿 + 非惰性」。G2 喂 privateRef+env 并存记录,变异后原样吐出 {"env":{"TOKEN":"s3cret"}}——降级读唯一的防泄漏闸无覆盖,最该补一条用例。
  • 七处变异抽验(摘 fs-policy 洞 2 红、去降级 3 红)真红。
  • 安全边界:精确文件洞,两平台邻居(config.json / bots.json / .dashboard-secret / plugins/*/private/mcp.json)均未跟着开;公开投影剥净 command/env。
  • tsc --noEmit 0、bun run build 绿;fs-policy / plugin-manifest-store / plugin-mcp-gateway(-installer) 148/148、fs-policy-bwrap e2e 14/14。
  • plugin-mcp-sandbox.test.ts 2 条失败在干净 origin/master 81d008406(build 出 dist 后)同样复现,先于本 PR。
  • chmod 0o500 四条用例:root 跑假红(root 绕过 DAC),nobody 跑 9/9 绿。

结论:倾向可合,不阻断。 建议作者至少补 G2 那条防泄漏用例(成本极低);EBUSY 回落与 enabled? 的 Linux 形态请维护者定夺。

@deepcoldy

Copy link
Copy Markdown
Owner

更正我上一条里的一个错误建议,以及附议复审补充的一处新发现。

更正:我说的「扩一个 EBUSY」按字面写是接不住的

上一条我建议「把 isUnwritableLockError 扩一个 EBUSY」。这个写法无效,我实测确认:

message      = plugin_mcp_registry_migration_failed:EBUSY: resource busy or locked, rename '...'
顶层 .code   = undefined
.cause.code  = "EBUSY"

isUnwritableLockError 读的是顶层 .code,而 migrateLegacyPluginMcpDescriptors 的 catch 把原始错误重新包了一层
new Error('plugin_mcp_registry_migration_failed:…', { cause: error })),顶层 .codeundefined,EBUSY 挂在 .cause.code 上。所以往那个谓词里加 'EBUSY' 一行都不会生效

难堪的是:这个 code=undefined 就打印在我上一条引用的那段输出里,我照抄了结论却没读自己的证据。给你添麻烦了。

真要兜住的话,得在 readPluginRegistry 的 catch 里单独识别「迁移因不可写而失败」——按 message 前缀 + cause.code ∈ {EBUSY, EACCES, EPERM, EROFS} 判定后再回落到 readPluginRegistryReadOnly();且只对不可写回落,真正的非法记录仍须上抛(否则会把 invalid_plugin_mcp_contribution 一起吞掉,正好毁掉你最在意的那条防线)。这个取舍请你定。

附议:enabled? 分类器在 Linux 上不生效(复审发现,我独立复现)

同一个根因的第二个后果,我上一条漏了。readEnabledPluginIdsOrUnknown 只 catch EPERM/EACCES,但两个后端下「读不到 config.json」的形态不一样

后端 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 上静默失效。
@xu4wang

xu4wang commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

两处发现都成立,已改并追推(--force-with-lease,新 head c04fea26b),PR 描述同步更新。谢谢在 bwrap 里真跑了一遍——这两个缺口我在 macOS 上是看不见的。

一、Linux/bwrap:锁建得出来,降级读从旁边漏过去

采纳。而且你那条自我更正是对的:往 isUnwritableLockErrorEBUSY 一行都不会生效,因为 migrateLegacyPluginMcpDescriptors 把错误重新包了一层,顶层 .codeundefined,errno 在 .cause 上。

改法按你更正后的方向,单独识别「迁移因写不动而失败」:

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';
}

要求 cause.code 存在,正好就是把「写不动」和「记录非法」分开的那道线 —— invalid_plugin_mcp_contribution / invalid_legacy_plugin_mcp_descriptor 裹在同一个前缀里但不带 errno,于是继续上抛。你担心的「把防泄漏闸一起吞掉」不会发生,而且我把它钉住了:

  • 新用例:私有目录被换成符号链接 ⇒ unsafe_plugin_private_dir 必须上抛、不得降级。
  • 变异:把谓词改成 return true(不校验 cause)⇒ 那条红。

🪤 顺带一个坑,写测试时踩到的:冻结 plugins/<id>/private 复现不出迁移失败 —— assertPrivateStorageLayout 会把已存在的私有目录 chmod0o700,写就成功了,测试变成在测 happy path。第一版我就是这样,三个变异全绿才发现。正确做法是冻结父目录、让 private 不存在:mkdir 进不去,也没人能 chmod 一个从未创建的目录。

二、enabled? 分类器在 Linux 上不生效

采纳,这条比第一条更该修 —— 它让整个特性在 daemon 真正运行的平台上静默失效。

改成按「读失败」判,不按 errno

try { readFileSync(globalConfigPath(), 'utf-8'); }
catch { return undefined; }

关于 ENOENT 的歧义(你建议由维护者定夺)——我选了「一律算未知」,理由和代价都写在注释里:

  • 两个后端藏这个文件的方式本质不同(seatbelt 拒读 / bwrap 不 bind),任何 errno 过滤都必然只覆盖一半;
  • 你提的「ENOENT 且注册表读得到才判 unknown」我试着推过,但分不开:走到这段代码时注册表一定读得到(plugins.length > 0 才进循环),干净机器上「装了插件、还没 config」同样满足这个条件;
  • 代价是这种机器现在印 enabled? 而不是裸 id。那句话仍然为真(确实没有启用,而我们没有断言任何东西),只是不够具体。在「用一个没读到的文件断言未启用」面前,我认为该倒向这一边。

如果维护者更看重那台机器的输出精度,我可以改成需要沙盒侧给一个明确信号(现在没有可靠的:IS_SANDBOX 只在 root + Claude 系 CLI 时注入,不是 fs 沙盒的标记)。

三、G2 那条零覆盖

也补了:privateRefenv 并存的记录 ⇒ 降级读必须抛 invalid_plugin_mcp_contribution,不得原样吐出 env。这是降级读唯一的防泄漏闸,你说得对,最该有用例。

现在的数字

tsc --noEmit exit 0;四个文件 vitest 157/157变异 7 → 11,新增四条各 1 red:

变异 结果
迁移写不动时不降级(只认锁错误) 1 red
迁移判定读顶层 .code 而非 .cause.code 1 red
降级不校验 cause(把布局/安全错误一起吞掉) 1 red
分类器退回只认 EPERM/EACCES(漏掉 ENOENT 1 red

每轮都从提交态出发、git checkout -- srcgit diff --quiet 自检已复原;先验基线再变异。

@xu4wang
xu4wang force-pushed the fix/sandbox-plugin-registry-read branch from 3c72f0b to c04fea2 Compare September 1, 2026 14:30
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