Skip to content

feat(sandbox): run workspace.exec in the daemon's mount namespace (ns-exec helper) - #499

Open
mengmengmengqiang wants to merge 3 commits into
Ephemeral-AI-Lab:mainfrom
tokenaissance:experiment/private-mount-ns
Open

mengmengmengqiang wants to merge 3 commits into
Ephemeral-AI-Lab:mainfrom
tokenaissance:experiment/private-mount-ns

Conversation

@mengmengmengqiang

@mengmengmengqiang mengmengmengqiang commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Status: prototype complete — design decision required. The private mount namespace and the ns-exec fd-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 on main; 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.

LayerFS architecture

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.

Interaction sequence

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").

Cause and fix: private mount namespace

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 Retained at Join. Bottom: the daemon unshares CLONE_NEWNS and 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.

Fix options: recovery, latency, root cause

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 — so umount2 detaches 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/connections is 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:

before mount before unmount unmount
0 ms 300 ms 22 / 19 ms
2500 ms 1500 ms 43 / 15 ms
0 ms 1500 ms 5066 / 5029 ms
2500 ms 300 ms 12 / 9 ms

Change

  • daemon: opt-in private_mount_ns; unshare(CLONE_NEWNS) + MS_REC|MS_PRIVATE on / before any serving thread; writes daemon.pid; serves its mount-namespace fd over /layerfs-local/ns-exec.sock (mode 0600, peer uid 0 in the same pid namespace, one SCM_RIGHTS message, nothing read).
  • helper layerfs-daemon ns-exec --socket … --directory … --uid … --gid … -- bash -c …: receive fd → setns(CLONE_NEWNS) → chdir --directory → drop to the command uid/gid → exec.
  • API: wraps the Engine argv only when the Sandbox record carries the socket; otherwise the argv is byte-identical to before.
  • the pid-file route is removed: the daemon is non-dumpable and the Sandbox has no CAP_SYS_PTRACE, so /proc/<pid>/ns/mnt can never be opened in deployment.

The exec path (fd passing)

workspace.exec is a Docker Engine exec in the container's initial mount namespace; the daemon is not involved. The wrapper starts layerfs-daemon ns-exec as 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 to 501: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):

test n exec unmount
early mount 10 10/10 exit 0, p50 54.2 ms 10/10 Unmounted, p50 15.0 ms (7.2–35.0)
control, flag off 5 5/5 exit 0 5/5 refused, p50 5036 ms (the old wedge)
delayed mount 5 p50 101.5 ms p50 20.0 ms
normal path 5 p50 37.4 ms p50 10.8 ms

The command runs in the daemon's namespace (readlink /proc/self/ns/mnt = the daemon's mnt:[4026533424]), sees the layerfs /workspaces/1 fuse row, runs as 501:20, and passes exit codes/stdout/stderr. A marker written and read back, and committed, in 10/10 runs.

Per-approach performance matrix

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 check for the touched crates passes; bridge 2, api 2, daemon lib 5, ns_exec integration 11, engine_protocol 17 pass in rust:1.85.1-bookworm --cap-add SYS_ADMIN.
  • E2E as above. cargo fmt --check was not run (rustfmt absent from that image).

What this does not prove

  • docker exec into a private-ns Sandbox still cannot see /workspaces/* (by design; only workspace.exec is routed).
  • A Sandbox created before this change is refused clearly (Unsupported: … close it and create a new Sandbox); there is no live migration.
  • macOS and multi-tenant hardening of the socket were not exercised; the socket is root-only in the container's pid namespace.
  • The identity of the external holder remains unknown.

Trade-offs / open question

  1. Wrapped Execs start as root and the helper drops to the command identity before exec; this is a deliberate capability in exchange for not adding CAP_SYS_PTRACE.
  2. docker exec visibility into /workspaces/* is gone for private-ns Sandboxes.
  3. The namespace dies with the daemon (better cleanup, but recovery paths that expect a second process to see the mount no longer work).
  4. Opt-in; the abort path (fix(daemon): end a stalled normal unmount at Join with its own abort write (#426, #460) #496/fix(fuse): bound the receive-loop join by a 1 s grace (#426, #460) #497) stays the safety net.

Related

#426, #460, #466, #496, #497; implementation in tokenaissance#5; design notes in tokenaissance#3.

… 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

Copy link
Copy Markdown
Contributor Author

Prototype for the blocker: tokenaissance#5 implements the setns helper (opt-in daemon private ns + pid file + Engine argv wrapper). Unit and unshare -m integration tests pass; a real Sandbox end-to-end is still missing, so this PR stays draft.

…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.
@mengmengmengqiang mengmengmengqiang changed the title fix(daemon): mount Workspaces in the daemon's own mount namespace (experimental) feat(sandbox): run workspace.exec in the daemon's mount namespace (ns-exec helper) Oct 28, 2026
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).
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