Repository navigation
feat(sandbox): run workspace.exec in the daemon's mount namespace (ns-exec helper) - #5
mengmengmengqiang wants to merge 3 commits into
Conversation
… an ns-exec helper `workspace.exec` is an Engine exec in the container's initial mount namespace; the daemon is not involved. When the daemon serves from a private mount namespace (the #3/Ephemeral-AI-Lab#499 prototype), a Workspace FUSE mount is absent from that namespace, so the Engine's `bash -c <command>` cannot see the Workspace. This adds a small setns helper so the command runs inside the daemon's namespace while the Engine keeps its Exec entry, streams, cancellation and exit code. - The hidden `layerfs-daemon ns-exec --pid-file <path> [--directory <path>] [--uid <uid> --gid <gid>] -- <command> [args...]` entry reads the daemon pid, opens /proc/<pid>/ns/mnt, joins it with setns(CLONE_NEWNS), re-enters the mount directory in the joined namespace, drops the root entry identity to the ordinary command identity, then execs the command. A missing pid file, an unreadable pid, a missing namespace, or a refused setns/chdir/drop prints a clear message and exits 125; the command is never run in the wrong namespace. - DaemonSetup gains an opt-in `private_mount_ns` flag (config record LFSD\x06). When set, the daemon entry takes unshare(CLONE_NEWNS) + MS_REC|MS_PRIVATE on `/` before the listener or any worker thread, then atomically writes `daemon.pid` into the local directory above the Overlay's directory (/layerfs-local/daemon.pid in a Sandbox). With the flag off no namespace is taken and no pid file is written. - layerfs-api builds the Engine argv: when the Sandbox's record carries the private-namespace pid file, the argv names the helper and the Engine starts it as root (`ExecRequest.root_launcher`), because the ordinary 501:20 command identity can neither open the daemon's namespace nor setns (verified: a non-root `nsenter` gets EPERM; root succeeds). The helper drops back to the command identity before exec. Without the pid file both the argv and the Engine user are unchanged. - Opt-in: `deploy.privateMountNs` or `LAYERFS_PRIVATE_MOUNT_NS=1` at Sandbox create; the pid file path is saved in and reopened from the sandbox record. Tests: - added engine/crates/layerfs-daemon/tests/ns_exec.rs: a real private namespace built with `unshare -m` and a tmpfs visible only there; ns-exec joins it (read and chdir), drops to 65534, refuses a missing/invalid pid file and a missing namespace without running the command, and the daemon writes its pid file only when the private namespace is asked for. - added unit tests: the wrapper argv decision (layerfs-api), the helper's argument parsing (layerfs-daemon) and the config flag/pid path (layerfs-bridge). - touched ExecRequest literals and the internal::exec call sites; the Engine protocol encoding tests still pass. - deleted: none. Not exercised: a real Sandbox end-to-end (a FUSE mount in the private namespace, then exec), and the daemon's full startup under the flag; the Sandbox container's /dev/fuse and Store are not part of this test set. Production LOC: 102763 -> 103008 (delta +245) core 38395 -> 38395 (0), engine 61709 -> 61954 (+245), api 2659 -> 2659 (0); reference absent (retired R9c; last counted 65,417 at tree 498dd19). Method: python3 tools/production_loc.py --root on a `git archive` extract of 582bc78 and on this staged tree. Refs #3, tokenaissance/layerfs#499.
…IGHTS The E2E showed the helper could not open the daemon's namespace: root in a Sandbox holds CAP_SYS_ADMIN but not CAP_SYS_PTRACE, and the daemon is not dumpable (`config.rs` prctl::set_dumpable(false)), so `/proc/<daemon>/ns/mnt` is EACCES for every Sandbox process. The same 0600 file opened fine from outside the container with CAP_SYS_PTRACE, so the kernel's dumpability check is the blocker, not the path. - The daemon, when `private_mount_ns` is on, binds `<local>/ns-exec.sock` (mode 0600, stale path removed, unlinked on drop) and serves exactly one SCM_RIGHTS message carrying the descriptor of its own `/proc/self/ns/mnt` to an admitted peer: uid 0 and the same pid namespace, read from SO_PEERCRED and `/proc/<pid>/ns/pid`. Nothing else is sent; a refused peer is closed without a message (`application/ns_fd.rs`). - `ns-exec` now requires `--socket`, receives that descriptor, joins the namespace, then keeps the existing `--directory` chdir, uid/gid drop and exec. The pid-file route is deleted: it can never work in a Sandbox, so the helper no longer names a path that only returns EACCES. - `DaemonSetup::check` requires the socket path for the private-namespace mode; the pid file stays as a diagnostic marker and is not written when absent. - The public `WorkspaceApi::exec` derives the namespace from the owner's `SandboxCreate.setup` instead of always passing none, so a private-ns Sandbox cannot run that path unwrapped in the initial namespace. - A live Sandbox whose record predates the socket (`nsExecPidFile` without `nsExecSocket`) refuses `workspace.exec` with `Unsupported` and an instruction to close and recreate it, rather than deriving a socket the old daemon never bound or repeating an opaque 125. - One `layerfs_sandbox::DAEMON_ENTRY` constant feeds the create request, the observed-topology check and the helper argv, so the entry path cannot drift. - `MountNamespace` carries a socket and has no empty state: `Some` always wraps and `None` never does. Tests: - ns-exec integration (11): join over a served fd (read and chdir), uid/gid drop, missing socket, a socket that sends nothing, a socket that sends a non-namespace file, no namespace source (integration 125), a peer in another pid namespace refused, socket mode 0600 with a stale path replaced and exactly one message (one byte, one fd, then EOF), a non-root peer refused, and the daemon serving socket and pid file only with the flag. - node unit (3): a saved socket wraps the command end to end (record -> mount_namespace() -> argv), a pid-only record requires recreation, and a plain Sandbox keeps its command. - bridge/api/daemon unit (2/2/7) and the 17 Engine protocol tests pass. - deleted: the pid-file exec tests and the pid-file helper route. Not exercised: a real Sandbox end-to-end; the host wiring and the same E2E rerun belong to the E2E agent. Production LOC: 103008 -> 103183 (delta +175) core 38395 -> 38395 (0), engine 61954 -> 62129 (+175), api 2659 -> 2659 (0); reference absent (retired R9c; last counted 65,417 at tree 498dd19). Method: python3 tools/production_loc.py --root on a `git archive` extract of 8545a28 and on this staged tree. Refs #3, tokenaissance/layerfs#499.
CI failed in "fmt and guards" and in clippy. Formatting itself was already clean; the guard step and the clippy build were not. - clippy: the engine_streams example still built an ExecRequest without the new `root_launcher` field, so `--all-targets` could not compile; the example keeps its previous non-root launch. `ns_exec::parse` returned a four-tuple that tripped `clippy::type_complexity`, so it now returns a named `Parsed` value with the same fields, refusals and order. - guard: product source may not carry inline tests, and a `lib.rs` carries no definitions. The `daemon_setup` unit tests moved unchanged to `tests/private_mount_ns.rs`; the API argv-wrapping proof now runs through the public `WorkspaceApi::exec` against a private-namespace Sandbox; the helper's argv grammar is proven through the real `ns-exec` subprocess (`tests/ns_exec.rs`), where the socket-message and peer-admission proofs already covered the `ns_fd` predicates it used to unit-test. `DAEMON_ENTRY` moved from `lib.rs` to `types/deployment.rs`, re-exported at the same path. No behaviour changes: same argv, same refusals, same launch identity, same configuration bytes and same public paths. Verified: `cargo fmt --all -- --check`, the boundary and fuser guards and their unit tests, `cargo clippy --workspace --exclude layerfs-node --all-targets -- -D warnings`, `cargo check` on the four touched crates, and the daemon/sandbox tests (`ns_exec` 12/12 with FUSE, `engine_protocol` 17/17, `private_mount_ns` 2/2) in the pinned rust:1.85.1 container.
|
Superseded by the upstream PR Ephemeral-AI-Lab#499 (same head commit 386f1b2); this fork PR is kept only as a mirror and is closed so the upstream PR is the single review surface. |
…emetry run (comic3) Adds the qualified Ephemeral-AI-Lab#501-head numbers in the Ephemeral-AI-Lab#499/fork #5 row: early-mount unmount 5034/5056/5123/5175 ms (n=10, 10/10 Join abort), normal unmount 11.5/14.6/17.2/17.8 ms and exec 27.7/33.1/35.2/35.5 ms (n=5, exit 7), control flag-off unmount 5038/5050/5136/5188 ms (n=5). Notes that the telemetry run is on Ephemeral-AI-Lab#501's head, not Ephemeral-AI-Lab#499's, and that Ephemeral-AI-Lab#501 head has no private-ns support (LAYERFS_PRIVATE_MOUNT_NS=1 is a no-op there).
|
Reopened per request: kept as the fork-side mirror of the upstream PR Ephemeral-AI-Lab#499 (same head 386f1b2). |
|
Closing as superseded by the 2.0 exec design. This PR targets |
fd passing (commit
6554e7fd)The helper no longer opens
/proc/<pid>/ns/mnt. The daemon serves its own mount-namespace descriptor over a private Unix socket (ns-exec.sock, mode 0600): the peer must be uid 0 in the same pid namespace, and exactly oneSCM_RIGHTSmessage is sent. The helper receives the fd,setns(CLONE_NEWNS), re-enters--directory, drops to the command uid/gid and execs.CAP_SYS_PTRACE; the earlier E2E returned 10/10 EACCES).MountNamespaceis now socket-only;DaemonSetup::check()requires the socket; old records derive the socket path (a pre-socket daemon never bound it, so exec refuses until the Sandbox is recreated).rust:1.85.1-bookworm --cap-add SYS_ADMIN:cargo check(5 crates) passes; bridge 2, api 2, daemon lib 5,ns_execintegration 11,engine_protocol17 pass.cargo fmt --checkwas not run (rustfmt not installed in that image).E2E passed (2026-10-28)
Separate worker, sealed bundle
build-linux-8fdaaf92b71b4d149bf1c46fb8976edb(head6554e7fd),LAYERFS_PRIVATE_MOUNT_NS=1:workspace.exec10/10 exit 0,pwd=/workspaces/1, marker written/read/committed 10/10; exec p50 54.2 ms (45.9–186.9); unmount p50 15.0 ms (7.2–35.0), 10/10Unmounted; close 10/10Closed.readlink /proc/self/ns/mnt= the daemon'smnt:[4026533424](init ns is a different one with 0/workspacesrows); the FUSE row is visible; uid 501 / gid 20;exit 7probe returns exitCode 7 with stdout/stderr; backgroundprocess.start/cancelwork.Unsupported: Sandbox 1 was created by a daemon that does not serve its private mount namespace; close it and create a new Sandbox ….Earlier pid-file E2E (below) failed 10/10 with EACCES; that route is removed.
E2E result (2026-10-28): the pid-file route could not open the daemon's namespace
The branch was built into a sealed Linux bundle and run against a separate worker with
LAYERFS_PRIVATE_MOUNT_NS=1:workspace.execrefused 10/10: exit 125,layerfs-daemon ns-exec: open mount namespace /proc/6/ns/mnt: Permission denied (os error 13); the command never starts./layerfs-local/daemon.pid = 6; the container's init namespace has no/workspacesrow; the daemon namespace (probed withCAP_SYS_PTRACE) has the FUSE row; early-mount unmount p50 27.8 ms (n=10, max 182.1 ms), no ~5 s stall./workspaces/1and the FUSE row); unmount 5/5 refused after p50 5022 ms — the old wedge is back. The flag isolates cleanly.layerfs-daemon/src/application/config.rs:105,prctl::set_dumpable(false)); the Sandbox hasCAP_SYS_ADMINbut notCAP_SYS_PTRACE, and the Engine topology check rejects adding it. Root in the container is denied too; a dumpable sibling is joinable, and aCAP_SYS_PTRACEprobe can join the daemon.SCM_RIGHTS(commit6554e7fd, section below). The pid-file route is deleted: it cannot work in a Sandbox, so the helper no longer names a path that only returnsEACCES.This PR stays draft until the rebuilt bundle passes the same E2E.
Update: the daemon hands its mount-namespace fd to the helper
Commit
6554e7fdonfeat/ns-exec-helper:layerfs-daemon/src/application/ns_fd.rs, only whenprivate_mount_nsis on): binds<overlay grandparent>/ns-exec.sock(mode 0600, a stale path is removed, the path is unlinked on drop) and serves it from one background thread. On each accepted connection it readsSO_PEERCREDand requires uid 0 and this process's pid namespace (/proc/<pid>/ns/pidcompared with/proc/self/ns/pid), opens/proc/self/ns/mntitself and sends exactly oneSCM_RIGHTSmessage with that one descriptor, then closes. A refused peer is closed without sending anything.application/ns_exec.rs):ns-exec --socket <path> [--directory <path>] [--uid <uid> --gid <gid>] -- <command> [args...]receives the descriptor,setns(CLONE_NEWNS), then keeps the existing--directoryre-entry, uid/gid drop (supplementary groups cleared) andexec. The received raw descriptor is reopened through/proc/self/fdso the owned-file API works without unsafe code, and its label is checked to bemnt:. The pid-file exec route and its--pid-fileflag are deleted; the daemon still writes<local>/daemon.pidas a diagnostic marker for the E2E.["/usr/local/bin/layerfs-daemon", "ns-exec", "--socket", "/layerfs-local/ns-exec.sock", "--directory", …, "--uid", "501", "--gid", "20", "--", "bash", "-c", <command>]; the Engine starts the helper as root (needed forsetns) and the helper drops to the ordinary 501:20 identity. Without the flag the argv and Engine user are byte-for-byte what they were.WorkspaceApi::execnow derives the namespace from the owner'sSandboxCreate.setup, so a private-ns Sandbox cannot run that public path in the initial namespace.DaemonSetup::checkrequires the socket path for the private-namespace mode; the pid file is optional evidence. The socket path is saved in the sandbox record (nsExecSocket), so every exec of a running Sandbox is wrapped consistently.nsExecPidFilewithoutnsExecSocket) is created by a daemon that never bound one.workspace.execrefuses it with codeUnsupportedand a message to close and recreate the Sandbox — no derived socket that cannot work, and no silent initial-namespace run.layerfs_sandbox::DAEMON_ENTRYnow feeds the create request, the observed-topology check and the helper argv, so the three cannot drift.What and why
workspace.execis a Docker Engine exec: the Engine startsbash -c <command>in the container's initial mount namespace, and the filesystem daemon is not involved (engine/crates/layerfs-node/src/exec.rs,engine/crates/layerfs-api/src/sandbox/exec.rs). The private-mount-namespace prototype (644556d0, issues #3/Ephemeral-AI-Lab#499) makes the daemonunshare(CLONE_NEWNS)at startup and mount FUSE only there. That keeps Workspace mounts out of the namespace Engine execs anddocker execenter, but it also meansworkspace.execno longer sees the Workspace.This PR is the follow-up: a small hidden
setnshelper soworkspace.execruns inside the daemon's private mount namespace. The Engine still performs the Exec, streams the output, and owns cancellation and the exit code; only the entry process changes, and only for a Sandbox that opts in.What changed
layerfs-daemon ns-exec(hidden subcommand,application/ns_exec.rs): joins the descriptor the daemon serves, re-enters the mount directory, drops the root entry identity to the ordinary command identity, andexecs the command. Every refusal (missing/refused socket, no descriptor, a non-namespace descriptor,setns/chdir/drop failure) printslayerfs-daemon ns-exec: <reason>and exits 125 without running the command; the host sees the exit code and stderr.application/ns_fd.rs: the 0600 socket and its root + same-pid-namespaceSCM_RIGHTSserver described above.application/cli.rs,layerfs-fuse/src/mount/syscalls.rs,layerfs-bridge/src/daemon_setup.rs):DaemonSetupgains an opt-inprivate_mount_nsflag (config recordLFSD\x06). When set, before the listener and any worker thread, the daemon takesunshare(CLONE_NEWNS)+MS_REC|MS_PRIVATEon/, writesdaemon.pidatomically, and starts the fd socket. When off, no namespace, pid file or socket exists.layerfs-api/src/sandbox/exec.rs,layerfs-sandbox/src/backend/docker/request.rs,layerfs-node/src/{sandbox,discovery,registry,exec}.rs): the wrapper argv is built from the Sandbox's saved socket, andExecRequest.root_launcherstarts the helper as root (the Engine user becomes0:0only for wrapped Execs).deploy.privateMountNsorLAYERFS_PRIVATE_MOUNT_NS=1when the Sandbox starts; off by default.Tests run
Environment: Docker arm64,
rust:1.85.1-bookworm,--cap-add SYS_ADMIN. The committed tree was copied into a Docker volume and every command ran from there, so a stale bind-mount view could not affect the result.The integration test builds a real private namespace with
unshare -m --propagation private, mounts a tmpfs + marker visible only there, and serves that namespace's fd over a test socket; read and chdir succeed only aftersetns, and the three refusal tests run a command that wouldtoucha file and assert it does not exist. The socket tests cover mode 0600, stale-path replacement, exactly oneSCM_RIGHTSmessage (one byte, one fd, then EOF), a non-root peer, and a peer in another pid namespace. TheCAP_SYS_ADMIN/CAP_SYS_PTRACEpremise was checked directly:docker exec -u 501:20 nsenter -t <pid> -mfails withPermission denied, while root succeeds.Sealed E2E bundle:
/Users/reina/Project/.layerfs-worktrees-nsexec/layerfs-nsexec-sealed-200d21420d8f/artifacts/build-linux-e8640431b70544b8bba0cee2e6bae72f(manifest.jsonsha2562f249952f7dfc91f1b0639569085653616dc4536e706106548c00a1a0c2998ae;layerfs-daemonsha256d393f9173fc81823d66114b8fa9d2e9293f2d972386a7742cf03cf1cdb4e76ce;layerfs.nodesha256ed013154d5683be3f2966017b8e54b09bf4b2a53ae3ed7f7337fc5cf23a7c013), built bytools/worktree.py register/prepare/build --platform linux --profile release --jobs 6from a clean detached worktree at6554e7fd.Not exercised
docker execstill cannot see the Workspace mount; onlyworkspace.execis routed through the helper.LAYERFS_PRIVATE_MOUNT_NS=1in the SDK worker and the matching daemon binary installed in the Sandbox image.Limitations
CLONE_NEWUSER; a stricter runtime would refuse startup or exec.nsExecSocket, soworkspace.execrefusesUnsupportedand asks to recreate the Sandbox. Old node with a new container: the old argv names--pid-file, which the new helper no longer accepts, so it exits 125 with a usage message. Both keep the command out of the wrong namespace; neither is silently unwrapped.Refs #3, tokenaissance/layerfs#499
Closes #3.