Repository navigation
feat(sandbox): run workspace.exec in the daemon's mount namespace (ns-exec helper) - #5
Open
mengmengmengqiang wants to merge 3 commits into
Open
mengmengmengqiang wants to merge 3 commits into
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.
mengmengmengqiang
marked this pull request as draft
October 28, 2026 20:00
…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.
mengmengmengqiang
marked this pull request as ready for review
October 28, 2026 20:54
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.
Collaborator
Author
|
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. |
mengmengmengqiang
added a commit
that referenced
this pull request
Oct 28, 2026
…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).
Collaborator
Author
|
Reopened per request: kept as the fork-side mirror of the upstream PR Ephemeral-AI-Lab#499 (same head 386f1b2). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.