Skip to content

#515:VM Sandbox 后端实现(Namespaces、镜像根、Microsandbox / Apple Container / SSH 适配器) - #526

Closed
jiangkoumo wants to merge 3 commits into
Ephemeral-AI-Lab:mainfrom
jiangkoumo:vm515-v2
Closed

jiangkoumo wants to merge 3 commits into
Ephemeral-AI-Lab:mainfrom
jiangkoumo:vm515-v2

Conversation

@jiangkoumo

Copy link
Copy Markdown
Collaborator

本文件是 PR 正文草稿(中文)。分支 vm515-v2,两个提交,基于 B1 a32da5732。


一句话

这是作者 #512 方案稿 的实现:一个 Store 一个 Linux VM,VM 里的 Sandbox 用 Linux namespaces 代替 Docker 容器,宿主机只连每个 VM 里那一个 worker。Docker 仍然是默认后端,不选 vm: 时行为完全不变。

它不替代 #512。 #512 是方案稿(V1–V10 是待定的选择题),本 PR 是按那张表逐条落地的实现记录,§10 的每条选择在下面第 4 节写明。方案稿里的结论、边界和不做的事都不改;本 PR 只把「选了什么、为什么、验证到什么程度」写实。


1. 这个 PR 依赖 B1,平台无关的公共层在 B1

四个 PR 是一组,都基于同一个基点:

项 值
共同基点(B1) 分支 b1-interface,提交 a32da5732,父提交 upstream/main 35d36575c
本 PR 分支 vm515-v2(942cf923e 切出 #515 的文件 + 872409049 修 CI 失败)
原一体化 PR #498(头 9aa10e65c),四个 PR 建好后关闭

统一口径(用户 2026-10-31 定):Linux/macOS 共用的本地进程实现放在 B1——即 engine/crates/layerfs-sandbox/src/backend/native/*(约 1230 行)和 layerfs-node/src 的 local.rs、runtime.rs、discovery.rs 等本地部署接入。这样 B2、B3、#515 三个 PR 只往各自的后端目录里填实现,彼此不叠、两两合并 0 冲突、可以按任意顺序合入(已预演:三者全合后的树与原 #498 头 9aa10e65c 逐字节相同)。B2、B3 的正文同样会写「平台无关部分已在 B1」,它们只剩平台相关代码。

所以本 PR 的体量集中在两块:engine/crates/layerfs-sandbox/src/backend/namespaces/**(新后端,只在 Linux 上编译)与 api/runtime/src/platform/vm/**(宿主侧的 VM 适配器,纯 JavaScript)。

一个运行时的依赖要说清:这个拆分让 backend/linux/{mounts,process}.rs(/proc 与挂载表读取)落在 B2。合并不受影响(两边文件不重叠,零冲突),但单独这个 PR 上跑 root 的 namespaces 生命周期测试会 4 条失败(Unsupported "Linux native process table"),加上 B2 就到 5/5。CI 看不到这个差别(CI 非 root,这些用例提前 return),但想在本机验证 namespaces 真实行为时必须知道。细节见 §5.2。


2. 为什么 Namespaces 放在这个 PR,而不是 B2(Linux 本机)

B2 是「Linux 本机、Docker 里的进程」;Namespaces 后端确实也不碰 Docker,但它有一个 B2 给不了的前提:一个进程独占整台机器的内核。

它要做的事都是整机级的:

  • 以 root 身份创建 mount / PID / IPC namespace、写 cgroup v2 目录、pivot_root、挂 /dev/fuse;
  • 把 user.max_user_namespaces 写成 0。这是整机 sysctl,不能按进程设。不写,命令就能自己 unshare -U 造一个用户 namespace,把 Workspace 的挂载复制进去,从而躲开 daemon 的正常和强制卸载;
  • 每个 Sandbox 的 root 是 overlay,上层必须是本机文件系统;Store 也必须和 daemon 在同一个内核上(SQLite 的 WAL 和目录锁只在一个内核内协调)。

这三条只在「每 Store 一个 VM」里成立。放到 B2,就等于让开发机、CI runner 那种共享内核的 Linux 也走上一条它是错的路径:改整机 sysctl 会波及别人的进程,而共享内核下防不住用户 namespace。

所以在类型上它们是并列的三兄弟,不是父子:SandboxRecord::{Docker, Local, Namespaces},各自只认自己的记录,读到别的变体就拒绝(namespaces_protocol.rs 有这条断言),不会把 Docker 记录解释成 namespace Sandbox。


3. 改了什么(按目录)

相对 B1 a32da5732 共 56 个文件、+8033 / −138(其中测试 13 个、文档 2 个、仓库工具 1 个,其余 40 个是实现文件)。

下面是实现文件的生产 LOC 快照,范围是提交 872409049 的工作树,计数器 tools/production_loc.py --files,只列本 PR 新增或改动的文件;测试在 tests/ 下、不在 src/ 里,不计入。

  engine/crates/layerfs-sandbox/src/            [2519]  (只列本 PR 新增/改动的文件;crate 合计 7916)
    backend/
      helper_spec.rs                                 110   (新的 init 阶段规约:一行一字段)
      mod.rs                                          21   (导出 helper spec 与 namespaces)
      namespaces/                                  [1954]
        mod.rs                                        11   (新)
        paths.rs                                      35   (新:Sandbox 目录布局)
        cgroup.rs                                     62   (新:cgroup v2 与 cgroup.kill)
        create.rs                                    250   (新:intent、overlay 上层、记录)
        host.rs                                       79   (新:root、cgroup v2、/dev/fuse 前置条件)
        provider.rs                                  194   (新:启动、重开、拒绝请求)
        exec.rs                                      103   (新:exec 段入口)
        sandbox.rs                                   284   (新:启动、日志、run、stop、delete、reopen)
        rootfs/                                     [936]
          mod.rs                                       8   (新)
          types.rs                                    22   (新)
          tar.rs                                     304   (新:tar 读取、白化与拒绝)
          archive.rs                                 289   (新:docker-archive 解析与 digest 校验)
          layers.rs                                  192   (新:逐层落盘、符号链接与 device node 拒绝)
          cache.rs                                   121   (新:每个 digest 只解一次,flock + rename)
+   bin/                                             [434]  (新二进制 layerfs-sandbox-helper)
      layerfs-sandbox-helper/main.rs                  45   (新:init / exec 两段入口)
      layerfs-sandbox-helper/init.rs                  88   (新:unshare(mount,pid,ipc) + cgroup)
      layerfs-sandbox-helper/mounts.rs               160   (新:root、Store、/dev、pivot_root)
      layerfs-sandbox-helper/exec.rs                 141   (新:setns、降权、no-new-privs、退出码)
  api/runtime/src/                              [1691]  (只列本 PR 新增/改动的文件;crate 合计 2060)
    index.js                                       115   (按 placement 选平台)
    settings.js                                    117   (placement、VM 尺寸、SSH 设置)
    clients.js                                      63   (deploymentEnv 交给平台)
    status.js                                       26
    volume.js                                       51
    platform/                                       (只列改动的文件)
      shared.js                                     62
      linux/index.js                                34
      macos/index.js                               109
      windows/index.js                             135
      vm/                                         [789]  (新)
        index.js                                   312   (新:五步适配器)
        microsandbox.js                             72   (新:msb)
        apple-container.js                          91   (新:container 1.6.1)
        ssh.js                                     284   (新:任意 Linux 主机)
        cli.js                                      30   (新:外部 CLI 的最小封装)
    project/                                        (只列改动的两个文件)
      init.js                                       80
      remove.js                                     23
    sandbox/                                        (只列改动的三个文件)
      leftovers.js                                  32
      list.js                                       37
      sweep.js                                      18
  api/agent/src/
    transport/index.js                              24   (useWorker 接受 resolver:一个进程一条路由)

其余非 prod 改动:AGENTS.md(+20 行,记录 VM 放置这条平台事实)、api/docs/runtime.md(+244 行,新增「Placement: one Linux VM per Store」与「SSH provider」两节)、tools/worktree_build.py(隔离构建多产出一个 layerfs-sandbox-helper,VM 里要它),以及测试 6 个文件(namespaces_{lifecycle,protocol,rootfs,userns}.rs、tests/support/image.rs 手工造 docker-archive、api/runtime/test/{vm_placement.mjs,fixture/fake-*.cjs,verify/*})。

Production LOC(本 PR 两个提交合计):106022 → 109342(delta +3320)。
按层:core 38395 → 38395 (0)、engine 64938 → 67328 (+2390,其中 layerfs-sandbox 5526 → 7916)、api 2689 → 3619 (+930,其中 @layerfs/runtime +925、@layerfs/agent +5)、dsh 0 → 0;reference 已按 R9c 退役,记为 absent (retired R9c; last counted 65,417 at tree 498dd1917812)。
方法:tools/production_loc.py --root <tree>(blob 3341e4d0),同一个计数器比较两个快照。


4. #515 §10 的 V1–V10:我们选了什么、为什么

ID 问题 我们的选择
V1 接受「每 Store 一个 VM,VM 内用 namespaces,Docker 仍是默认」? 接受
V2 放宽 worker 例外,Linux 也能把 Store 调用发进项目 VM? 接受,规则不变
V3 先做哪些适配器? microsandbox + apple-container + ssh,Daytona/E2B 等未做
V4 用户 namespace 封锁:只整机 sysctl,还是再加 seccomp? 只整机 sysctl(+ 启动时读回校验)
V5 网络:Sandbox 共享 VM 网络,还是各自 netns? 共享 VM 网络
V6 启用 nix 的 sched/mount/user/process,并加 helper 二进制? 接受
V7 Sandbox 镜像改成随 bundle 带 docker-archive,按 digest 只解一次? 接受
V8 会在停止时丢盘的平台:要求持久盘,还是走远程 Store push? 要求持久盘;Store 未推送时拒绝删 VM
V9 设置名 LAYERFS_PLACEMENT、LAYERFS_SANDBOX_BACKEND(另 LAYERFS_VM_*、LAYERFS_SSH_*)
V10 #512 的 Engine API 替身是否放进 tools/? 不放进 tools/,只留该包测试里的窄 fixture

逐条理由:

V1 —— 接受。 #512 的四条结论里第一条是硬事实:VM 之间不共享内核,也不共享磁盘;SQLite 的 WAL 和目录锁只能在一个内核内协调。所以「一个 Sandbox 一个 VM」不可能成立,只能「一个 Store 一个 VM」。Docker 仍是默认后端:placement 不写就是 local,Linux 本机、macOS 的 worker 容器、Windows 的 WSL2 worker 都照旧。

V2 —— 接受,且规则一个字都没改。 以前只有 macOS 和 Windows 需要把 Store 调用发给另一个进程;vm: 放置下 Linux 也一样,因为 Store 在 VM 的盘上。规则依旧是:通道只在本机 loopback(或 provider 的私有通道)、必须带 secret、一次尝试、丢了回复绝不重放。新增的只有「一条进程级路由」,因为现在每个项目有各自的 endpoint:useWorker(resolver) 或该项目的 LAYERFS_WORKER_ENDPOINT,同一个进程里不同项目的调用会各自落到自己的 VM。这是本 PR 在 @layerfs/agent 里唯一的改动(+5 行 prod),也有对应测试。

V3 —— 选了三个适配器。 microsandbox(msb)和 ssh(任意 Linux 主机,含 EC2 / 自建 VM)是 #512 的建议首选,apple-container(container 1.6.1)随后补上;三个都实现了。Daytona、E2B、Vercel Sandbox、Modal 没做:需要账号和账单,而且它们的 runner 是共享内核,需要 V4 的第二条路(seccomp)才能防住用户 namespace,本版明确不支持。

V4 —— 只做整机 sysctl。 worker 启动时把 user.max_user_namespaces 写成 0,然后读回来校验,不是假设成功(prepareGuest)。理由:每个 VM 只服务一个 Store,整机封掉在语义上是正确的,不需要新依赖或 unsafe 例外(seccomp 过滤器需要 BPF,仓库现有 crate 在安全 Rust 里给不了)。代价写清楚了:共享内核的宿主(Daytona 那类)本版不支持,而不是「看起来支持」。

V5 —— 共享 VM 网络。 helper 的 init 阶段只 unshare(CLONE_NEWNS | CLONE_NEWPID | CLONE_NEWIPC),没有 CLONE_NEWNET。理由:最简单,且不损失安全边界——命令最多能到别的 Sandbox 的 daemon 端口,但那条连接要 secret;命令本身没有 capability、不能 mount、不能造用户 namespace。per-Sandbox netns 要用 netlink 起 loopback,属于能后加的独立改动。

V6 —— 接受。 layerfs-sandbox 的 nix 打开 sched/mount/user/process,并加一个 layerfs-sandbox-helper 二进制。原因:setns/unshare 只作用于调用者进程,没法在安全 Rust 里表达成库函数;helper 是单线程两段式(init 与 exec),unsafe 依然为零。不用 nsenter/setpriv,VM 里不需要 util-linux。

V7 —— 接受。 bundle 里带 <bundle>/images/<64 hex>.tar(未压缩的 docker save 归档),后端校验它的 config digest 等于 deploy.image,每个 digest 只解一次到 0555 只读目录,每个 Sandbox 在它上面挂自己的一层可写 overlay。gzip 层会被拒绝并给出理由;bundle 里多于一个镜像直接拒绝(一次部署只有一个镜像)。这样 VM 里不需要任何 Engine。

V8 —— 要求持久盘。 三个 provider 的磁盘在 stop/start 之间都保留,够 Disposable 用。远程 Store push(#506)还不存在,所以没有「先推送再删」的路径:项目还在时删它的 VM 会被拒绝,并说明「这个 Store 只在那块盘上」;删项目才删 VM 和它的 Store 盘。这是事实约束,不是保守。

V9 —— 设置名。 LAYERFS_PLACEMENT=local|vm:<provider>,VM 内的后端 LAYERFS_SANDBOX_BACKEND=namespaces;另有 LAYERFS_VM_{IMAGE,CPUS,MEMORY,STORE_SIZE} 与 LAYERFS_SSH_*(host/user/port/identityFile/storePath 等)。只读 LAYERFS_*;插件自己的前缀(如 dsh 的 DSH_*)由插件自己映射,LayerFS 不认任何插件名。写错的 provider 名会立刻 Unsupported,不会被当成 local 悄悄跑。

V10 —— 不放进 tools/。 #512 那个「用替身回答 Engine API、每个容器就是一组 namespace」的 stand-in 没有进 tools/。理由:它是某个包里「没有 Docker 也能跑」的证明工具,不是仓库级工具;放进 tools/ 会看起来像一个受支持的运行方式,而 CI 根本不跑 JS 测试。取代做法是各 provider 一个窄替身,跟测试放一起:api/runtime/test/fixture/fake-{msb,container,ssh}.cjs(外加既有的 fake-native.cjs)。


5. 怎么验证

5.1 macOS 本机(全部退出码 0)

检查 命令 结果
格式化 cargo +1.85.1 fmt --all -- --check 0 diff
编译 cargo +1.85.1 check --locked --workspace --all-targets 通过
Clippy(宿主) cargo +1.85.1 clippy --locked --workspace --exclude layerfs-node --all-targets -- -D warnings 0 warning
Clippy(Linux 目标) cargo +1.85.1 clippy --locked -p layerfs-sandbox --all-targets --target x86_64-unknown-linux-gnu -- -D warnings 0 warning(这也是 macOS 上唯一能编译到 namespaces_* 测试代码的路径)
边界守卫 python3 -B tools/check_product_boundary.py PASS,1012 个生产文件
fuser 完整性 python3 -B tools/check_fuser_integrity.py errors: [],授权补丁校验通过
工具单测 python3 -B -m unittest discover -s tools -p 'test_*.py' 155 tests OK
VM 放置单测 for c in settings flow two bundle delete hosts apple-container ssh cleanup; do node api/runtime/test/vm_placement.mjs $c; done 9/9 PASS
runtime 套件 node api/runtime/test/runtime.mjs <14 个 case> 14/14 PASS
backend 套件 node api/backend/test/backend.mjs <4 个 case> 4/4 PASS
CLI node --test api/cli/test/cli.test.mjs 75/75 PASS
SSH 故障矩阵 node api/runtime/test/verify/ssh_faults.mjs 5/5 PASS
SDK 脚本 node api/agent/test/{calls,native_deployment,exec_output}.mjs 39 cases / ok / ok
每项目一 worker packaging.mjs 的 worker-per-project case(见风险 7) PASS

5.2 Linux 真机(Debian 13 / aarch64 VM,cargo 1.85.1 --locked)

这一条是本 PR 修的 CI 失败,所以两侧都跑了。

非 root(CI 的身份):

运行 结果
cargo test -p layerfs-sandbox --tests --no-fail-fast 退出码 0,12 个测试二进制、71 个用例全过;namespaces_lifecycle(5) 与 namespaces_userns(1) 按惯例打印 SKIPPED … not root
只跑 namespaces_rootfs(修复前的文件) PermissionDenied at namespaces_rootfs.rs:153:36,与 CI 逐字一致
只跑 namespaces_rootfs(修复后) 12 passed

CI 用的是 python3 -B tools/ci_tests.py targets fast + nextest --profile ci(非 root)。已确认这四个 namespaces_* 目标都在 fast lane、都不在 nightly 列表里,所以它们必须能在非 root 下通过——现在可以。

root(真实的 namespaces 生命周期):这两条限制是事实,不是缺陷,但必须写明:

  1. 需要 B2 的 /proc 实现。 本 PR 单独在树里时,backend/linux/process.rs 是 B1 的占位实现(真正的读取在 B2),所以 lifecycle 会是 1 passed / 4 failed,原因为 LaunchFailure { phase: Start, cause: Io(Unsupported("Linux native process table")) }。用 git merge-tree --write-tree rehearsal-b2 HEAD(→ 5ec79f35,0 冲突)得到的合并树就全过。
  2. runtime root 必须在本地文件系统。 产品有 require_overlay_upper 守卫(overlay / nfs / cifs / 9p / virtiofs 均拒绝),而这个 VM 的 / 本身就是 overlay,所以 fixture 的 TMPDIR 要指到 tmpfs(真实部署里是 Store 那块 ext4)。
树 条件 lifecycle userns
本 PR 单独 root、user.max_user_namespaces=0、tmpfs 1 passed / 4 failed(B2 占位) pass
B2 + 本 PR 同上 5/5 passed(含身份、能力、隔离、cgroup.kill 拆除) pass
B2 + 本 PR runtime root 在 overlay 按设计 Unsupported(…overlayfs…) 同样拒绝

源码里 #[test] 共 71 个(12 个二进制);非 root 一次完整运行里 ... ok 行数也是 71、退出码 0。

5.3 未跑(不当作通过)

  • 真实 VM 的端到端(vm_real.mjs,需要 msb / container 真机):本轮只跑了替身套件。
  • 完整 fast lane(1205 个测试):macOS 上跑不了 Linux 目标的全量链接(缺 x86_64-linux-gnu 的 C 工具链,libsqlite3-sys/zstd-sys/napi 交叉编译失败),这是 B1 就有的限制,留给 Linux 机。
  • 本 PR 单独不能跑 root lifecycle(靠 B2 的 /proc),见上表;不把它当作通过。
  • Durable 存储 profile:按 owner 决定禁用,未执行。

6. 风险与已知限制

  1. 共享内核的宿主不支持。 只有整机 sysctl 的封锁,Daytona 这类 runner 需要 seccomp,本版明确 Unsupported 而不是降级。
  2. Daytona / E2B / Vercel / Modal 未实现,PROVIDERS 只有三个,写别的名字立刻 Unsupported。
  3. Apple Container 的 -p 端口转发曾出现一次失败(Sandbox backends: which agent sandboxes can replace LayerFS's Docker container? Local stand-ins first #512 记录,原因未知);失败会报出来,不会重试也不会假装成功。
  4. 镜像必须是未压缩的 docker save 归档;gzip 层被拒绝(附理由),bundle 里多于一个镜像被拒绝。
  5. 只做 Disposable。 所有 Store 都是 Disposable profile,Durable 按 owner 决定继续禁用。
  6. 性能没有结论。 Sandbox backends: which agent sandboxes can replace LayerFS's Docker container? Local stand-ins first #512 的计时是慢网络下的单样本,不是按测量流程做的测量;本 PR 不声称任何性能数字,也没有拿它做选型依据。
  7. packaging.mjs 在 Node 22 上无法整体解析(文件里有 await using,这是 B1 就有的写法,与本 PR 无关)。本 PR 新增的 worker-per-project case 已单独以等价方式跑过并通过,但在 Node ≥ 24 上才算真正跑过整个文件。
  8. 第一次在共享内核的 CI runner 上跑 namespaces 测试会跳过(生命周期与用户 namespace 两条),这是有意的:它们在非 root 下没有意义,跳过并打印原因,不制造假通过。
  9. root 的 namespaces 生命周期测试依赖 B2。 本 PR 单独在树里时,backend/linux/process.rs 是 B1 的占位实现,Start 会报 Unsupported "Linux native process table"(4/5 失败);B2 合进来后 5/5 通过(§5.2 有实测)。合并不受影响,但“本 PR 独自就能跑完全部测试”不成立。
  10. runtime root 必须在本地文件系统。 require_overlay_upper 会拒绝 overlay / nfs / cifs / 9p / virtiofs 上的 Sandbox 可写层;这是有意的守卫(真实部署放 Store 那块 ext4/xfs/btrfs),但搭测试环境时容易踩到(VM 的 / 往往是 overlay)。

7. 附:本 PR 的 CI 修复(第二个提交)

原 #498 的 fast lane 有一条失败,只在非 root 下出现:

thread 'a_second_sandbox_waits_for_the_unpack_that_is_running' panicked at
engine/crates/layerfs-sandbox/tests/namespaces_rootfs.rs:153:36:
called `Result::unwrap()` on an `Err` value: Os { code: 13, kind: PermissionDenied, … }

原因:那条测试先解一次镜像(发布目录是 0555,这是契约:命令不能写共享镜像),再把它 remove_dir_all 掉,只为了回到「还没发布」的状态。而在一个只读目录里 unlink 是 root 的 DAC 特权,非 root 必然 EACCES——本地一直用 root 跑,所以没暴露。

修的是测试,不是产品:产品那 0600 的 lock 文件和 0555 的镜像根都是刻意的(后端在 VM 里以 root 跑;0555 是「命令不能写共享镜像」的防线)。测试现在直接拿该 digest 的锁,也就是它本来要表达的状态「有一个解包正在进行」:锁被持有时不发布、释放后发布、内容断言不变。同一条测试在 Debian 13 的非 root 用户下,修复前失败、修复后通过(§5.2)。

生产代码 LOC:109342 → 109342(delta 0,只改了一个集成测试)。

…der (Area B1)

Extract the complete Sandbox runtime interface and shared native/node plumbing from 9aa10e6 onto upstream/main (35d3657), implementing the Docker backend and leaving same-signature Unsupported slots for backend/{linux,macos,namespaces}/ so B2, B3 and Ephemeral-AI-Lab#515 only replace their own backend directories.

In layerfs-sandbox:
- Define concrete Runtime (Docker, MacosNative, LinuxNative, Namespaces, Windows) and Sandbox (Docker, Native, Namespaces) enums in backend/runtime.rs.
- Add provider capabilities (including LoopbackNfs, MicroVm, RemoteCloud), LaunchPhase/LaunchFailure, ProviderLaunchOptions (Local, Namespaces, Docker) and SandboxRecord (Local, Namespaces, Docker).
- Add types::Image and cross-backend ExecRequest; move shared json and strings helpers to backend/.
- Implement DockerProvider and DockerInstance over existing Docker Engine lifecycle and streaming execution.
- Include shared backend/native/* lifecycle, identity, listener and streams modules under cfg(any(linux, macos)), with same-signature Unsupported stubs in backend/{linux,macos,namespaces}/.

In layerfs-api:
- Route SandboxApi through Arc<Runtime> via with_runtime and create_provider_file, retaining local resource ownership and command identity validation.
- Share TCP/Noise/Hello and Store install pipeline in sandbox/connection.rs.

In layerfs-node and @layerfs/agent:
- Take verbatim 9aa10e6 shared binding modules (local, discovery, runtime, sandbox, workspace, exec, registry, lib) with a no-op unmount slot for B3.
- Expose deploy.sandbox, deploy.localDir, deploy.sandboxBackend and deploy.imageArchive settings in @layerfs/agent.

Production LOC: 102831 -> 106022 (delta +3191)
First-parent comparison: upstream/main 35d3657 (tree ddea2b5) -> committed tree ae48e29.
Scopes:
  - core:   38395 -> 38395 (delta 0)
  - engine: 61777 -> 64938 (delta +3161)
    * layerfs-sandbox: 3417 -> 5526 (delta +2109)
    * layerfs-api:     1940 -> 2081 (delta +141)
    * layerfs-node:    2744 -> 3655 (delta +911)
  - api:     2659 ->  2689 (delta +30)
    * @layerfs/agent:   849 ->  879 (delta +30)
  - dsh:        0 ->     0 (delta 0)
  - reference: absent (retired R9c; last counted 65,417 at tree 498dd19)
Method: tools/production_loc.py (blob 3341e4d), --root <exact exported tree> --json for both snapshots; first-party production Rust, runtime SQL and JavaScript; tests, docs, tooling, generated and third-party files excluded.
Production LOC: 106022 -> 109342 (delta +3320)
First-parent comparison: b1-interface a32da57 (tree ae48e29) -> rehearsal-515 tree.
Scopes:
  - core:   38395 -> 38395 (delta 0)
  - engine: 64938 -> 67328 (delta +2390; layerfs-sandbox 5526 -> 7916)
  - api:     2689 ->  3619 (delta +930; @layerfs/agent +5, @layerfs/runtime +925)
  - dsh:        0 ->     0 (delta 0)
  - reference: absent (retired R9c; last counted 65,417 at tree 498dd19)
Method: tools/production_loc.py (blob 3341e4d), --root <exact tree> --json.
… privilege

`namespaces_rootfs::a_second_sandbox_waits_for_the_unpack_that_is_running`
published an image and then removed it only to get back to "nothing
published yet". A published image root is 0555, and unlinking inside a
read-only directory is root's DAC privilege, so the CI fast lane (Ubuntu,
nonroot) failed with `PermissionDenied` at namespaces_rootfs.rs:153:36 while
every local root run passed.

The test now takes the digest's own lock, which is the state it is about: an
unpack in progress. Nothing is published while the lock is held, the release
publishes, and the assertion that the archive's content lands is unchanged.
Product stays as it is: the 0600 lock file and the 0555 image root are
deliberate — the backend runs as root in the project VM, and 0555 is the
contract that a command cannot write the shared image.

Swept the other namespaces tests for the same assumption:
  - namespaces_lifecycle (5 tests) and namespaces_userns (1) already report
    "not root" and return; confirmed in a Debian 13 VM as a nonroot user.
  - namespaces_protocol (5) and the other 11 rootfs tests touch no privileged
    path.

Verified in a Debian 13 (aarch64) VM with cargo 1.85.1 --locked:
  - nonroot user: the previous test fails exactly as CI did (os error 13 at
    153:36); this one passes, all 12 rootfs tests pass, lifecycle and userns
    report "SKIPPED ... not root", every binary exits 0.
  - root: all 8 layerfs-sandbox test binaries pass, 56 tests.
Deleted tests: none.

Production LOC: 109342 -> 109342 (delta 0)
Scope: core, engine, api, dsh; the retired reference is absent (R9c).
Method: tools/production_loc.py --root . (combined 109342; core 38395,
engine 67328, api 3619, dsh 0). This commit changes one integration test
outside the production scope, which `--files` does not list.
Copilot AI balanced review requested due to automatic review settings October 31, 2026 14:14

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

yifanxuaaa added a commit that referenced this pull request Oct 31, 2026
B1 (layerfs-connect#45): one Sandbox interface, with Docker moved behind it.
Merged at PR head a32da57. B1 was cut from main 35d3657, before the
#506 Store-backend work on this branch, so the merge ports #506 onto B1's
provider interface:

- PostgreSQL Stores: ProviderLaunchOptions::Docker and DockerRecord carry
  `store_volume: Option<String>` like SandboxRequest; v2's create_postgres
  becomes SandboxApi::create_provider_postgres (Docker launch with no
  volume, no install, starts ready); the provider's startup Hello waits on
  DaemonSetup::opens_existing() as v2's create did. B1's create.rs is
  taken whole, since both v2 edits there now live in provider.rs.
- Node binding: v2's DaemonStore start path runs through
  create_provider_file / create_provider_postgres. A native Sandbox of a
  PostgreSQL Store is refused Unsupported (B1's native daemon opens a
  Store file). Native Sandboxes take v2's shared-Store naming
  (<prefix><store>.<project>-<n>) and the layerfs.store label.
- Two modules named local.rs: B1's native Sandbox module keeps
  `local` (B2, B3 and #526 call crate::local); v2's Store size/heads
  module (#506 E1, E2) is renamed `local_stores`, unchanged otherwise.
- Records save and read an optional `volume`; the creation-intent hint
  names the volume or, without one, the project label.
- client.js keeps v2's `postgresql` deployment flag and B1's native
  deployment; the boundary guard keeps both sides' entries.
- Tests: the node mock Engine keeps v2's version (same behaviour as B1's);
  B1's new tests get `Some(...)` volumes and `postgresql: None`.

Verified on macOS (Rust 1.85.1): workspace check and warning-denying
Clippy, all targets; layerfs-sandbox, layerfs-node and layerfs-api tests
220 passed, 0 failed, 15 ignored; boundary guard PASS and its 34
self-tests; agent calls/exec_output and packaging surface/sandbox/
lifecycle PASS. Not run: Linux build and tests (no Linux C toolchain on
the host, Docker not running), Docker and FUSE E2E, packaging dispatch
(needs the built addon). Disposable only; Durable NOT_RUN, disabled by owner.

Production LOC: 111893 -> 115107 (delta +3214)
First-parent comparison: layerfs_v2_0_0 da70f98 (tree a949ae7) to
prepared merge tree cae5bb4.
Scopes: core 38400 -> 38400 (+0), engine 70228 -> 73412 (+3184),
api 3265 -> 3295 (+30). By crate: layerfs-sandbox +2109, layerfs-node
+927, layerfs-api +148, @layerfs/agent +30.
Method: tools/production_loc.py (blob 686d269)
--root <exported tree> --json for both snapshots; first-party production
Rust, runtime SQL and JavaScript; tests, docs, tooling, generated and
third-party files excluded.
Reference absent (retired R9c; last counted 65,417 at tree 498dd19).
Deleted tests: none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
yifanxuaaa added a commit that referenced this pull request Oct 31, 2026
VM Sandbox backend (#512 proposal, #515): one Linux VM per project whose
Sandboxes are Linux namespaces (namespace backend, image root, the
layerfs-sandbox-helper binary), with Microsandbox, Apple Container and
SSH providers in @layerfs/runtime. Merged at PR head 8724090 onto
the B3 merge a1cd85c. The PR predates #506 and #511 on this branch;
the merge ports it:

- project/remove.js: #511 replaced it with project/delete.js and #506
  moved its volume steps into store/volume.js removeVolume. The VM's
  four platform Store hooks (storeHolders, releaseStoreHolders,
  releaseStore, removeStore) now run inside removeVolume, which takes
  `{ name, volume }`; delete.js, store calls.js and sync.js pass it. On
  local placements the shared hooks issue the same Docker commands as
  before; Windows' releaseStore reads `{ volume }` as the PR made it.
  remove.js stays deleted.
- clients.js: a platform's own deploymentEnv (a `vm:` home) comes first,
  then v2's PostgreSQL environment, then the Docker default.
- sandbox leftovers/list/sweep and volume.js: the VM's no-host-Docker
  guards come first, then v2's shared-Store aware code; both imports
  kept.
- Tests: verify/vm_real.mjs calls project.delete (kind "Deleted"); the
  namespaces Linux tests give their Docker records `Some(...)` volumes.

Gaps: a `vm:` home's deploymentEnv does not cover PostgreSQL Stores, and
v2's shared-Store calls (store.create, load) still make host Docker
volumes, so neither is wired to VM placements.

Verified on macOS (Rust 1.85.1): workspace warning-denying Clippy, all
targets; format check (only the existing minio.rs drift);
layerfs-sandbox tests 56 passed, 0 failed, 1 ignored (namespaces tests
are Linux-only). @layerfs/runtime with the worktree's own agent/backend
linked in a temporary node_modules (removed after): vm_placement all 9
selections PASS (settings, flow, two, bundle, delete, hosts,
apple-container, ssh, cleanup); runtime.mjs all 15 selections PASS.
Boundary guard PASS with self-tests; tools/test_worktree.py OK; Ruff
0.17.2 format check clean. Not run: Linux build and tests (no Linux C
toolchain on the host, Docker not running), verify/vm_real.mjs and
ssh_faults.mjs against real VMs. Disposable only; Durable NOT_RUN,
disabled by owner.

Production LOC: 119822 -> 123141 (delta +3319)
First-parent comparison: layerfs_v2_0_0 a1cd85c (tree 63d9a3c) to
prepared merge tree 01a2878.
Scopes: core 41405 -> 41405 (+0), engine 75122 -> 77512 (+2390),
api 3295 -> 4224 (+929). By crate: layerfs-sandbox +2390 (namespace
backend and the layerfs-sandbox-helper binary), @layerfs/runtime +924,
@layerfs/agent +5.
Method: tools/production_loc.py (blob 686d269)
--root <exported tree> --json for both snapshots; first-party production
Rust, runtime SQL and JavaScript; tests, docs, tooling, generated and
third-party files excluded.
Reference absent (retired R9c; last counted 65,417 at tree 498dd19).
Deleted tests: none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@yifanxuaaa

Copy link
Copy Markdown
Contributor

@jiangkoumo Merged into layerfs_v2_0_0 at this PR's head 872409049e (merge commit 81f8758). On that branch #511 had already replaced project/remove.js with project/delete.js, and #506 had moved its volume steps into store/volume.js, so your four platform Store hooks now run inside removeVolume, and verify/vm_real.mjs calls project.delete. All 9 vm_placement selections pass, as do the namespaces lifecycle, protocol and rootfs tests on Linux (namespaces_userns needs the host kernel, so it was not run). Two gaps remain: a vm: home does not cover PostgreSQL Stores, and the shared-Store calls (store.create, store.load) still make host Docker volumes. Closing here because this PR targets main. Thank you!

已合入 layerfs_v2_0_0,合入的是本 PR 的头 872409049e(合并提交 81f8758)。在该分支上,#511 已用 project/delete.js 取代 project/remove.js,#506 也已把其中删除 volume 的步骤移到 store/volume.js,所以你的四个平台 Store 钩子现在在 removeVolume 中执行,verify/vm_real.mjs 也改为调用 project.delete。vm_placement 的 9 个测试全部通过,Linux 上 namespaces 的 lifecycle、protocol 和 rootfs 测试也通过(namespaces_userns 需要宿主机内核,未运行)。仍有两处缺口:vm: home 还不支持 PostgreSQL Store;共享 Store 的调用(store.create、store.load)仍然创建宿主机上的 Docker volume。本 PR 的目标分支是 main,因此在此关闭。感谢!

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.

3 participants