Skip to content

ci(bun-test): 补 bubblewrap 安装步骤,并修好它暴露的三个既有失败 - #1304

Open
deepcoldy wants to merge 3 commits into
masterfrom
ci/bun-test-bwrap
Open

ci(bun-test): 补 bubblewrap 安装步骤,并修好它暴露的三个既有失败#1304
deepcoldy wants to merge 3 commits into
masterfrom
ci/bun-test-bwrap

Conversation

@deepcoldy

Copy link
Copy Markdown
Owner

问题

test 腿(3 路 shard,#1288 之后)有一步 best-effort 安装 bubblewrap 并放开 userns 限制,bun-test 腿没有。job 之间不共享文件系统,这一步必须各写各的——所以 bun-test 完全靠 runner 镜像自带 bwrap,抽到不带的就红

后果不是「少测了点东西」,而是红得像代码回归:bwrap 缺席时 prepareDirectSandbox() 返回 null,test/sandbox-shim-compiled-form.test.ts 里的 overlayTargets() 直接 return [],4 个 toEqual([launcher]) 断言必挂。看日志像是 overlay 逻辑坏了,实际只是沙箱起不来。

⚠️ 与 vitest 那侧不同,bun test 这边没有宿主级 skip gate:那组用例的 describe.skipIf 只挡非 linux,在 linux runner 上照跑,bwrap 一缺就只能失败(而不是 skip)。

怎么发现的

#1301(bun 升 1.4.2)的 bun-test 腿红了,形状很像升级引起的——同一条腿在它的 base(master d47b5f0ef)是 success,到 PR 就 fail,两次唯一差别就是 bun 版本。查下来不是:

两次 CI 日志里 "bwrap missing" 出现次数:
  master d47b5f0ef 那次: 0 次  → 通过
  PR #1301 那次:        5 次  → 4 个用例失败
同代码、同 job 定义,差的只是 runner 状态。

实测

本机阳性/阴性对照(其余条件不变,只切 bwrap 可见性):

bwrap 可用      → 17 pass / 0 fail
bwrap 藏掉      → 13 pass / 4 fail   ← 与 CI 失败形态逐字一致

排除 bun 版本这个变量(把 bwrap 藏掉,只换 bun):

bun 1.4.2 → 13 pass / 4 fail
bun 1.4.1 → 13 pass / 4 fail   ← 旧版本同样红 ⟹ 与版本无关

步骤内容与 test 腿那份逐字节相同(yaml 解析后对比结果 IDENTICAL),不是重新手写的近似版本。

⚠️ 构造阴性对照时踩了个坑,记下来给后来人:第一版探针没生效——我在父 shell 里改 PATH 验证「藏掉了」,但源码是 spawnSync('sh', ['-c', 'command -v bwrap'])子 shell 重新读 PATH,实际根本没藏住,跑出 17 pass / 0 fail。那个绿看起来正好像「与 bwrap 无关」的证据。重建 PATH 并先确认子 shell 打出 NOT FOUND 之后,读数才可用。

影响面

只动 .github/workflows/ci.ymlbun-test job,加一步;不碰任何源码、不改其它 job、不改 bun 版本(本分支从 master 出,bun-version 仍是 1.4.1,升级在 #1301)。best-effort 写法(每条都带 || true),装不上也不阻塞,与 test 腿的语义一致。

⚠️ 这个 PR 不能保证 bun-test 必然全绿

该腿另有一个独立成因test/tmux-startup-storm-recovery.test.ts 会撞 720s 单文件墙被 SIGKILL(spawnSync tmux ETIMEDOUT)。证据是 master 738da7fa4bwrap 正常(missing 计数 0)仍单独因它红。本机直跑该文件 2 个用例 6.13s 全过,所以是 CI 环境下第二个用例卡住,跟 bwrap 无关,也不在本 PR 范围内。

也就是说:本 PR 消掉两个成因里的一个。另一个要单独查。

`test` 腿有一步 best-effort 安装 bubblewrap 并放开 userns 限制,
`bun-test` 腿没有。job 之间不共享文件系统,所以这一步必须各写各的,
否则 bun-test 完全靠 runner 镜像自带 bwrap——抽到不带的就红。

后果不只是"少测了点东西",而是**红得像代码回归**:bwrap 缺席时
`prepareDirectSandbox()` 返回 null,test/sandbox-shim-compiled-form.test.ts
里的 `overlayTargets()` 直接返回 `[]`,4 个断言 `toEqual([launcher])` 必挂。

⚠️ 与 vitest 不同,`bun test` 这边没有宿主级 skip gate:那组用例的
`describe.skipIf` 只挡非 linux,在 linux runner 上照跑,bwrap 一缺就只能失败。

实测:
- 同一段 commit 范围的两次 CI,一次日志里 `bwrap missing` 出现 5 次并失败,
  另一次 0 次并通过——同代码同 job 定义,差的只是 runner 状态
- 本机对照:bwrap 可用 17 pass / 0 fail;把 bwrap 从子进程 PATH 藏掉
  13 pass / 4 fail(与 CI 失败形态逐字一致)
- 该失败与 bun 版本无关:1.4.1 与 1.4.2 在藏掉 bwrap 时同样 13/4
- 步骤内容与 `test` 腿的那份逐字节相同(yaml 解析后对比 IDENTICAL)

注意这**不能让 bun-test 必然全绿**:该腿另有一个独立成因,
test/tmux-startup-storm-recovery.test.ts 会撞 720s 单文件墙被 SIGKILL
(master 738da7f 上 bwrap 正常仍因它单独红)。那个另算,不在本 PR 范围。
#1304 给 bun-test 腿补上 bubblewrap 之后,三个此前静默 skip 的沙箱套件
第一次真正执行,随即暴露三个既有缺陷。三个是三种不同的病,逐个修:

① + ② fs-policy-bwrap.e2e / plugin-mcp-sandbox —— 同一个清理 bug

沙箱把 deny-mask 源 chmod 成 0o000(设计如此:让非 root 列不出内容)。
但 0o000 的**目录不能被穿越**,而 rm -r 必须穿越进去才能 unlink,于是
rmSync(force:true) 抛 EACCES —— force 只吞 ENOENT,不吞 EACCES。

为什么长期没暴露:root 有 CAP_DAC_OVERRIDE 直接绕过 DAC,删得掉;只有
普通 uid 会挂,而 GitHub runner 正是普通 uid。实测 Node 与 Bun 表现
完全一致(都 EACCES)⟹ 这是清理 bug,不是运行时差异,不能用 bun-only
skip 掩盖。表现为「所有真实用例全绿、却挂在一个 unnamed 钩子上」。

修法:删除前深度优先 chmod-then-descend 恢复穿越权限(0o000 目录必须
先 chmod 才能 readdir,所以顺序不能反),抽成
test/helpers/rm-sandbox-scratch.ts 供两处共用;用 lstat 避免顺着符号
链接走出 scratch 树。

③ session-store-bwrap —— Bun 下这个场景根本不存在

它验证的是「关掉最后一个连接 → SQLite 删除 -wal/-shm → 重开得到新
inode」,以此证明目录 bind 优于单文件 bind。实测关闭最后一个连接后:
    node:sqlite (Node)              → -wal 被删     ← 前提成立
    node:sqlite (Bun 1.4.2)         → -wal 保留
    bun:sqlite  (Bun,即生产路径)    → -wal 保留
sqlite-compat.ts 明确规定 Bun 下使用 bun:sqlite,故第三行才是上线形态。
sidecar 不被删除即没有新 inode,末尾 toBe('v2') 在 Bun 下会**空过**——
绿着却不再检验 bind 形状。因此不放宽断言(那等于删除保护),改按仓库
既有约定 it.skipIf(isBunRuntime()) 标为 Node-only,并在注释写明三行
实测与「为何不是放宽断言」。Node 侧仍真实执行(vitest 下 1 passed)。

验证(均在**非 root** 下进行,root 复现不出来),在一棵真实
bun install 的树上(非 worktree 的 symlink 形态):
- 阴性对照 撤掉修复 → 0 pass / 3 fail(EACCES ×3)
- 阳性     带上修复 → 3 pass / 0 fail
- 三文件合跑        → 17 pass / 1 skip / 0 fail
- vitest/Node 侧    → 3 pass,且 Node-only 那条仍为 1 passed 非 skipped

同类隐患已逐个实测排除(未按形状盲改):另有 5 个测试文件同样创建
0o000,mojo-process-tree / mojo-containment / managed-origin-capability /
plugin-registry-sandbox-read / mojo-launcher-env-quarantine 在非 root 下
全绿(154 + 41 pass, 0 fail),其 0o000 不位于随后被删除的树内,无需改动。
@deepcoldy deepcoldy changed the title ci(bun-test): 给 bun-test 腿补上 bubblewrap 安装步骤 ci(bun-test): 补 bubblewrap 安装步骤,并修好它暴露的三个既有失败 Sep 7, 2026
@deepcoldy

Copy link
Copy Markdown
Owner Author

追加:按评审意见选择 (b),先修好被唤醒的三个既有失败

第一版只补了 bubblewrap 安装步骤,CI 的 bun-test 仍红——但红的东西全换了

补 bwrap 前:sandbox-shim-compiled-form(4例) + tmux-startup-storm-recovery
补 bwrap 后:fs-policy-bwrap.e2e + session-store-bwrap + plugin-mcp-sandbox
两个集合完全不相交 ⟹ 不是「没修好」,是「修好了 A,露出了 B」

这三个套件此前在 bun 腿上从未真正执行过(把 bwrap 藏掉复跑:0 pass / 20 skip / 0 fail)。本次一并修好,三个是三种不同的病:

① + ② fs-policy-bwrap.e2e / plugin-mcp-sandbox —— 同一个清理 bug

沙箱把 deny-mask 源 chmod 成 0o000(设计如此,让非 root 列不出内容)。但 0o000 的目录不能被穿越,而 rm -r 必须穿越进去才能 unlink,于是 rmSync(force:true)EACCES——force 只吞 ENOENT,不吞 EACCES。

为什么长期没暴露:root 有 CAP_DAC_OVERRIDE 直接绕过 DAC 检查,删得掉;只有普通 uid 会挂,而 GitHub runner 正是普通 uid。本地用 setpriv 切成 nobody 才复现。

关键判据:Node 与 Bun 表现完全一致(都 EACCES)⟹ 这是清理 bug,不是运行时差异,所以不能用 bun-only skip 掩盖。表现为「所有真实用例全绿,却挂在一个 unnamed 钩子上」——看起来像套件坏了,其实只有 teardown 坏。

修法:删除前深度优先 chmod-then-descend 恢复穿越权限(0o000 目录必须先 chmod 才能 readdir,顺序不能反),抽成 test/helpers/rm-sandbox-scratch.ts 共用;用 lstat 避免顺着符号链接走出 scratch 树。

session-store-bwrap —— Bun 下这个场景根本不存在

它验证「关掉最后一个连接 → SQLite 删除 -wal/-shm → 重开得到新 inode」,以此证明目录 bind 优于单文件 bind。实测关闭最后一个连接后:

node:sqlite (Node)              → -wal 被删     ← 前提成立
node:sqlite (Bun 1.4.2)         → -wal 保留
bun:sqlite  (Bun,即生产路径)    → -wal 保留

src/services/sqlite-compat.ts 明确规定 Bun 下故意使用 bun:sqlite,故第三行才是真实上线形态。sidecar 不被删除即没有新 inode,末尾 toBe('v2') 在 Bun 下会「空过」——绿着却不再检验 bind 形状。

所以没有放宽断言(那等于删除保护),而是按仓库既有约定 it.skipIf(isBunRuntime()) 标为 Node-only,注释写明三行实测与「为何不是放宽断言」。Node 侧仍真实执行(vitest 下 1 passed,不是 skipped)。

验证

均在非 root 下进行(root 复现不出来),且在一棵真实 bun install 的树上(不是 worktree 的 symlink 形态):

阴性对照 撤掉修复 → 0 pass / 3 fail(EACCES ×3)
阳性     带上修复 → 3 pass / 0 fail
三文件合跑        → 17 pass / 1 skip / 0 fail
vitest/Node 侧    → 3 pass;Node-only 那条仍 1 passed 非 skipped
tsc --noEmit      → rc=0

同类隐患已逐个实测排除(未按形状盲改):全仓另有 5 个测试文件同样创建 0o000——mojo-process-tree / mojo-containment / managed-origin-capability / plugin-registry-sandbox-read / mojo-launcher-env-quarantine,在非 root 下全绿(154 + 41 pass, 0 fail),它们的 0o000 不位于随后被删除的树内,无需改动。

两个排查陷阱(留给后来人)

  1. 本地 worktree 的 node_modules 是 symlink 到 canonical 时,沙箱内 zod 解析不到,报 MCP error -32000: Connection closed 掩盖真错。这是本地环境问题不是缺陷:真实 install 的树上完全不复现,CI 也是真实 install。差点被当成第三个 bug。
  2. 对照基线必须与被测树构建状态一致/tmp 副本没建 dist/ 时,existsSync(dist/cli.js) 门会让整个文件 skip,读数变成「14 pass / 3 skip / 1 fail」,看着像 PR 引入了新失败。补 bun run build 后才对得上。

已 rebase 到最新 master cfc425a00#1221 期间合入,重写了 hook-runner.test.ts),新 base 上复跑仍是 17 pass / 1 skip / 0 fail。

上一轮修复后 bun-test 从 3 个红文件降到 1 个,剩下的是
plugin-mcp-sandbox 里 "resolves bare `node` under a hostile symlink-form
host PATH" 一条,退出码 127(command not found)。

原因不是清理,也不是环境缺失,而是**前提在 bun 下不成立**:该用例保护的
修复是「PATH 前置 dirname(realpath(process.execPath))」,随后探针断言沙箱
里 bare `node` 跑得起来。在 `bun test` 下 process.execPath 是 **bun 二进制**,
于是沙箱被授予的是 bun 的目录,而探针去找一个从未被放进去的 `node` ⟹ 127。

不改成探测 `bun`:这条保护的 shim 里写死 `exec node`(botmuxShimExecLine),
换成 bun 形状就是在断言生产从不做的事,等于把回归保护换成装饰。按同批
session-store-bwrap 的处理方式标 it.skipIf(isBunRuntime()),注释写明原因。

为什么本机一直看不出来:dev 机有 /usr/bin/node,而该 fixture 特意在 PATH 里
保留 /usr/bin(好让宿主找得到 bwrap),于是 bare node 恰好被解析到;CI runner
的 node 在 /opt/hostedtoolcache/node/... 不在 /usr/bin,所以只在 CI 上红。

实测(真实 install 树、非 root):
- 阳性对照:用 unshare + mount --bind /dev/null 挡掉 /usr/bin/node 后,
  旧版复现出与 CI 逐字相同的 `Received: 127`
- 修复后本机常态:2 pass / 1 skip / 0 fail(CI 上绿的那两条仍绿)
- vitest/Node 侧:3 passed,被 skip 的那条在 Node 下仍真实执行
@deepcoldy

Copy link
Copy Markdown
Owner Author

第二轮 CI 结果 + 最后一条修复

第二轮 bun-test红文件 3 → 1fs-policy-bwrap.e2esession-store-bwrap 已在 CI 上确认转绿;plugin-mcp-sandbox 里两条 EACCES 用例也 pass 了——清理修复真实生效。

剩下的一条是 resolves bare node under a hostile symlink-form host PATHexit 127。它此前一直被 EACCES 的噪音盖住,不是本次引入。

根因:又一个「前提在 bun 下不成立」

该用例保护的修复是「PATH 前置 dirname(realpath(process.execPath))」,随后探针断言沙箱内 bare node 跑得起来。bun testprocess.execPath 是 bun 二进制(实测 /root/.bun/bin/bun)⟹ 沙箱被授予的是 bun 的目录,而探针去找一个从没被放进去的 node ⟹ 127。

没有改成探测 bun:这条保护的 shim 里写死 exec nodebotmuxShimExecLine),换成 bun 形状就是在断言生产从不做的事,保护会退化成装饰。所以与 session-store-bwrap 同样处理:it.skipIf(isBunRuntime()) + 注释写明理由。

为什么本机看不出来:dev 机有 /usr/bin/node,而该 fixture 特意在 PATH 里保留 /usr/bin(好让宿主找到 bwrap),bare node 恰好被解析到;CI runner 的 node 在 /opt/hostedtoolcache/node/...,不在 /usr/bin这类用例上「本机全绿」没有证明力。

验证

阳性对照(unshare + mount --bind /dev/null /usr/bin/node,旧版)
  → 复现出与 CI 逐字相同的 Received: 127
修复后·本机常态(真实 install 树、非 root)
  → plugin-mcp-sandbox 2 pass / 1 skip / 0 fail(CI 上绿的两条仍绿)
三个文件合跑(bun 腿)   → 16 pass / 2 skip / 0 fail
三个文件合跑(vitest/Node)→ 18 passed,0 skip   ← 两条 Node-only 仍真实执行
tsc --noEmit → rc=0

覆盖没有净损失:bun 腿 skip 掉的 2 条,正是它无法真实验证的 2 条;Node 腿 18 条全跑。

⚠️ 两个废对照(留档)

  1. 第一版对照根本没执行屏蔽(我在注释里写了个没实现的 bwrap 技巧),跑出全绿——那种绿看起来正好像结论成立。用探针前必须先证它能打响。
  2. 第二版真挡掉 /usr/bin/node过严:把 CI 上本来绿的两条也打红了(CI 有 node,只是不在 /usr/bin)。所以它只用于证 bare-node 这一条,其余以 CI 为准。

对照要只差一个变量,既要能打响,也要不误伤本该绿的部分

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.

1 participant