Repository navigation
feat(sandbox): run workspace.exec in the daemon's mount namespace (ns-exec helper) - #499
Open
mengmengmengqiang wants to merge 3 commits into
Open
mengmengmengqiang wants to merge 3 commits into
mengmengmengqiang wants to merge 3 commits into
Conversation
This was referenced Oct 28, 2026
… 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.
Contributor
Author
|
Prototype for the blocker: tokenaissance#5 implements the |
…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
force-pushed
the
experiment/private-mount-ns
branch
from
October 28, 2026 21:01
644556d to
6554e7f
Compare
mengmengmengqiang
marked this pull request as ready for review
October 28, 2026 21:01
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.
mengmengmengqiang
added a commit
to tokenaissance/layerfs
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).
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.
Status: prototype complete — design decision required. The private mount namespace and the
ns-execfd-passing helper are implemented and E2E-verified on a real Sandbox (fork tokenaissance#5). The remaining question is whether upstream wants this direction — commands running in the daemon's namespace — or whether the abort path in #496/#497 stays the answer.Branch
experiment/private-mount-ns(6554e7fd), based onmain; it now contains the full implementation (private ns + exec helper).Visual evidence walk-through
Start with the architecture and the unmount sequence so the change has a frame, then the cause/fix panel and the options board. All measured on macOS 26.5.1, OrbStack (kernel
7.2.9-orbstack-00391), Docker 29.8.2.Where the change lives. The daemon/FUSE pair is the adapter band; the private namespace is a startup property of the daemon, and the exec helper is an adapter that joins it — no dependency direction or crate boundary changes.
One bash call from create to unmount. This prototype removes the condition in step 13 entirely: with no external reference, the loop join follows the detach in milliseconds. Step 8 (
workspace.exec) reaches the mount through the fd-passing helper (see "The exec path").Top: the mount outlives the detach even though we cannot find the holder in any namespace we can enumerate, so the loops stay blocked and the entry ends as
RetainedatJoin. Bottom: the daemon unsharesCLONE_NEWNSand makes propagation private before mounting, so no external namespace can take a copy; measured early-mount unmount median 22 ms. The red line is the answer: the helper takes the daemon's namespace fd over a socket and runs the command there.Where this sits among the options: the private-namespace card is marked measured · prototype (exec prototype ready in fork #5); recovery (#496) and the 1 s grace (#497) stay as the safety net.
Problem
#496/#497 recover from the Join stall and bound its cost, but the stall still happens: on this machine a mount created immediately after
sandbox.create()and kept ~1.5 s is held by something we could not identify — every namespace we can enumerate has already lost the mount row — soumount2detaches while the FUSE receive loops stay blocked.Where the reference is not: not in any fd/cwd, and not in the Docker VM's mount table for other processes; by the time the drain runs, the mount row is already gone while the loops stay blocked. (The daemon's own fusectl means the init namespace's
/sys/fs/fuse/connectionsis not a reliable observation point, so we do not use it as evidence.) We could not identify the holder; the mechanism is inferred from timing and from the fix's effect. 2×2 timing (delay before mount / delay before unmount; two runs each) isolates the window:Change
private_mount_ns;unshare(CLONE_NEWNS)+MS_REC|MS_PRIVATEon/before any serving thread; writesdaemon.pid; serves its mount-namespace fd over/layerfs-local/ns-exec.sock(mode 0600, peer uid 0 in the same pid namespace, oneSCM_RIGHTSmessage, nothing read).layerfs-daemon ns-exec --socket … --directory … --uid … --gid … -- bash -c …: receive fd →setns(CLONE_NEWNS)→ chdir--directory→ drop to the command uid/gid → exec.CAP_SYS_PTRACE, so/proc/<pid>/ns/mntcan never be opened in deployment.The exec path (fd passing)
workspace.execis a Docker Engine exec in the container's initial mount namespace; the daemon is not involved. The wrapper startslayerfs-daemon ns-execas root (the command identity cannot open the namespace), which receives the daemon's mount-ns fd over the socket,setns, re-enters the workspace directory, drops to501:20, and execs the user command. The Engine entry stays, so output streaming, cancellation and exit codes are unchanged.Measurement
Isolated worker (private ns, mount/unmount only): early-mount unmount median 22 ms (n=20).
E2E on a real Sandbox (sealed bundle
8fdaaf92,LAYERFS_PRIVATE_MOUNT_NS=1):Unmounted, p50 15.0 ms (7.2–35.0)The command runs in the daemon's namespace (
readlink /proc/self/ns/mnt= the daemon'smnt:[4026533424]), sees thelayerfs /workspaces/1 fuserow, runs as501:20, and passes exit codes/stdout/stderr. A marker written and read back, and committed, in 10/10 runs.Per-approach matrix: this PR's row (private ns + fd passing) is early-mount unmount p50 15.0 ms with exec 10/10; the #501 row is the telemetry PR on its own head (no private ns, no 1 s grace), so its ~5 s is the recover path, not a regression.
Tests
cargo checkfor the touched crates passes; bridge 2, api 2, daemon lib 5,ns_execintegration 11,engine_protocol17 pass inrust:1.85.1-bookworm --cap-add SYS_ADMIN.cargo fmt --checkwas not run (rustfmt absent from that image).What this does not prove
docker execinto a private-ns Sandbox still cannot see/workspaces/*(by design; onlyworkspace.execis routed).Unsupported: … close it and create a new Sandbox); there is no live migration.Trade-offs / open question
CAP_SYS_PTRACE.docker execvisibility into/workspaces/*is gone for private-ns Sandboxes.Related
#426, #460, #466, #496, #497; implementation in tokenaissance#5; design notes in tokenaissance#3.