Skip to content

feat(exec): mountx/exec — run a command against a driver in a user namespace - #10

Draft
pi0x wants to merge 8 commits into
mainfrom
featexec
Draft

feat(exec): mountx/exec — run a command against a driver in a user namespace#10
pi0x wants to merge 8 commits into
mainfrom
featexec

Conversation

@pi0x

@pi0x pi0x commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

mountx/exec — run a command with an FsDriver grafted onto its filesystem view. New subpath, deliberately not wired into mountx/auto (auto's contract is a mountpoint; this produces a child's exit status).

import { exec } from "mountx/exec";
await exec(driver, ["sh", "-c", "ls -la $MOUNTX_ROOT"]);

The mechanism

FUSE mounted inside an unprivileged user namespace. Not an approximation of a filesystem: what the child sees is FUSE with the kernel's VFS in front of it, so it inherits everything src/fuse/ already passes conformance on, and it serves a static or Go binary the same as any other. The mount is invisible outside the process tree — /proc/self/mounts on the host stays empty — and dies with the namespace.

Needs no root, no fusermount3 and no native addon: inside the namespace the relay is uid 0 with CAP_SYS_ADMIN, so it takes the ordinary root mount path. On a host with no fuse3 package installed at all — this project's dev host — it is the only FUSE route that works.

Why there is a relay process. unshare(CLONE_NEWUSER) requires a single-threaded caller and Node never is; setns(2) has the same rule. So the namespace is entered by a child, /dev/fuse is opened there, and traffic comes back out over a unix socket to a FuseSession in the parent. That is the "relay mode" the roadmap defers, arrived at from the other direction.

The one behaviour change outside src/exec/

FuseSession gains an optional idmap, identity by default — no existing mount changes behaviour.

It exists because every uid/gid on the FUSE wire is named in the id space of the mount's user namespace, and unshare -U -r maps exactly one uid and one gid. A driver reporting host ids (1000) produced INVALID_UID, and the VFS then refused the inode: may_delete() and may_linkat() answer EOVERFLOW, and inode_permission() answers EACCES for a write open. Concretely, a child could not unlink, rmdir, rename, link or truncate anything it had not itself created — files it created were chowned to the caller's 0, which is mapped, which is why the failure looked like a LOOKUP-vs-CREATE distinction.

Ordinary FUSE mounts have s_user_ns == init_user_ns where every host uid maps, so the transport was never affected. Verified: createMemoryDriver({uid: 0, gid: 0}) fixed all six operations with no code change at all, isolating it to the id value.

Consequence, deliberate and documented: a driver with mixed ownership presents as uniformly root-owned inside the namespace. With one mapped identity there is nowhere else for foreign ids to go, and any other answer is an undeletable file.

Verified

  • pnpm lint, pnpm typecheck, pnpm test green — 2904 passed, 276 skipped. pnpm test:rootless green.
  • Writes asserted against the driver, not against exit status: create, nested mkdir, unlink, rmdir, rename, link, truncate, chmod, symlink, readdir — over a file, a directory, a symlink and a nested subtree.
  • Same, run from a real pnpm build against dist/exec/index.mjs. Renaming dist/exec/userns-relay.mjs away makes it fail, proving the built relay is the one doing the work.
  • test/exec/userns.test.ts is currently the only Tier-2 FUSE mount column that runs on this host — no fusermount3 for the rootless column, and the root column needs sudo.

Four details that look arbitrary and are not

  • default_permissions is not in the default mount options. With it on, the kernel checks the driver's ids against a namespace that maps one, renders the owner nobody, and the mount is silently read-only.
  • The child's cwd may never be inside the mountpoint — it deadlocks the relay in D state at fuse_get_req permanently (uv_spawn blocks the calling thread until the child execs; the child's first act is a chdir only that thread could answer). There is a refusal for it; the mountpoint reaches the child as $MOUNTX_ROOT.
  • unshare -U -r -m, short flags. busybox 1.37 has -r and no --map-root-user; bare-Alpine support depends on it.
  • The relay is a separate build entry. It is spawned, not imported, and obuild answers the dynamic import with dist/_chunks/userns.mjs, so it is not a sibling there.

Scope

A second mechanism — a seccomp user-notification supervisor, no device node and no kernel module, which works where /dev/fuse is withheld — was built and measured and is not in this PR. It lives in the history of #9. The API seam here keeps a mechanism discriminant and a per-mechanism probe verdict so it can return without a breaking change.

Not done, recorded in the roadmap: no conformance-matrix column, no bench column, and mountx/auto stays untouched. macOS gets nothing — no user namespaces.

🤖 Generated with Claude Code

pi0 and others added 8 commits July 29, 2026 13:05
…pace

`mountx/exec` grafts an `FsDriver` onto one command's filesystem view at
`$MOUNTX_ROOT`, visible to that process tree and to nothing else on the
machine, and hands back the command's exit status. Deliberately outside
`mountx/auto` for the reason `mountx/s3` is: `auto`'s contract is a
mountpoint and this produces none.

The mechanism is FUSE inside `unshare -U -r -m`. `unshare(CLONE_NEWUSER)`
refuses a threaded caller and Node is never single-threaded, so the
namespace is entered by a child (`userns-relay.ts`) that opens `/dev/fuse`
in there and relays raw traffic back over a unix socket to a `FuseSession`
in this process, where the driver stays. Inside the namespace the relay is
uid 0 with `CAP_SYS_ADMIN`, so it takes the ordinary root mount path — no
`fusermount3`, no setuid bit, no native addon, and no root on the host.

Four things that were each learned the hard way and are documented in the
code: `default_permissions` is not a default mount option (the kernel would
check a driver's uid against a namespace that maps exactly one, rendering
it `nobody` and failing every write); a `cwd` inside the mountpoint is
refused rather than deadlocked on; the `unshare` flags are spelled short
because busybox has `-r` and no `--map-root-user`; and the relay is a build
entry with a candidate-path resolver, because it is spawned rather than
imported and obuild answers a dynamic import with a chunk.

`probe.ts` is import-light in `src/nfs/probe.ts`'s sense — `node:fs` and
nothing else — and `index.ts` keeps a mechanism discriminant and a
per-mechanism probe verdict, so a second mechanism is an added arm rather
than an API change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`strategy.test.ts` is Tier 0 for what `mountx/exec` decides — the preference
order, the reason the mechanism is ruled out (darwin and win32 answered from
any host through the `platform` override, including the refusal to answer
for Linux from a host that cannot read Linux's files), the named-mechanism
path that skips the picker's probe, and `cwdRefusal()`, whose only other way
of being checked costs a hung process.

`userns.test.ts` is Tier 2 and runs under `pnpm test:rootless`, gated on
`usernsExecProbe().usable` and the same raised-threadpool rule the FUSE
rootless file uses. It does not re-test the filesystem — what the command
sees is FUSE, which has its own conformance column — only what is different:
the driver at `$MOUNTX_ROOT`, writes landing in the driver, the mount
staying out of the host's `/proc/self/mounts`, the command's own status
surviving, and a relay failure arriving as an error rather than as a
plausible exit status. The write case asserts by reading the driver back
rather than on the command's exit status, because an earlier version of this
work reported success while discarding every write.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A page of its own beside the S3 gateway, for the reason S3 has one: a
transport is everything between the driver and whatever is going to read it,
and "whatever is going to read it" being one command rather than the machine
does not change what the page has to explain. Guide-level prose first — what
the child sees, what it needs, what the probe says when it cannot — and the
full export surface below it.

The overview and the reference index count one more entry point each, and
the mermaid graph grows the second arm that bypasses `auto`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…record

`AGENTS.md` grows an `src/exec/` section in the code map, an `exec/` entry
under tests, and the `pnpm test:rootless` line that now covers it — the one
Tier-2 mount column that needs neither root nor a `fusermount3`, which is
why it is the one that passes on this dev host.

`.agents/proot-plan.md` is trimmed to what this branch rests on: the measured
three-way comparison's conclusion, the four things mechanism A cost and why
each is a comment in the code, and the bare-Alpine portability findings. The
seccomp user-notification mechanism was built and measured, works where
`/dev/fuse` is withheld, and lives in the history of PR #9 on
`pithings/mountx` rather than here; the rejected `LD_PRELOAD` autopsy stays
there too.

`.agents/environment.md` records the verified host facts that made the
mechanism possible to find at all: no `fusermount3` anywhere, unprivileged
user namespaces working, and Node never being able to enter one itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every uid/gid on the FUSE wire is named in the id space of the *mount's*
user namespace — `fuse_conn.user_ns`, taken from `sb->s_user_ns` at mount
time — not in the server's. For every mount `src/fuse/mount.ts` makes those
are the same space, which is why nothing here had ever needed to say so.

They are not the same for `mountx/exec`, whose mount is made inside an
`unshare -U -r` namespace that maps exactly one uid and one gid, both 0. A
driver's host ids go on that wire as INVALID_UID, and the VFS then refuses
the inode without ever consulting this session: `may_delete()` and
`may_linkat()` answer EOVERFLOW ("Inode writeback is not safe when the uid
or gid are invalid"), taking out unlink, rmdir, rename and link, and
`inode_permission()` answers EACCES via HAS_UNMAPPED_ID() for any write
open. Reads, stat and readdir are untouched, so the mount looks entirely
healthy until something tries to change it.

`FuseSessionOptions.idmap` is the one crossing, the identity by default, so
no existing mount changes behaviour. Three sites use it: `#attrOf` outbound,
and `#claim` and SETATTR's chown inbound. `#claim` needs it most subtly —
it compares the caller against this process, and both sides of that
comparison have to be in one id space or every created file is handed to a
uid that means nothing on the serving side.

`-1` stays `-1`: it is POSIX's "leave this one alone", not an id.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…r holds

A command could remove a file it had created and could not remove one that
was already in the driver: `rm: cannot remove '...': Value too large for
defined data type`, with no unlink ever reaching the driver. Same for rmdir,
rename and hardlink, and a write open on a pre-existing file answered
EACCES. The discriminator was not the operation but how the kernel had
learned of the inode — a CREATE reply had already been chowned to the
caller's 0 by the session's ownership hand-off, so it carried an id the
namespace maps; a LOOKUP reply carried the driver's host uid, which it does
not, and the VFS refuses an inode with an unmapped id before the request is
ever sent.

`usernsIdMap()` is the map `unshare -U -r` actually installs. Outbound is
the constant 0, and there is no other choice: the namespace's whole id space
has one element, and answering the kernel's own `nobody` for foreign ids
reinstates the bug verbatim, since 65534 is unmapped in here too. A driver
over a tree with mixed ownership therefore presents as uniformly root-owned
inside — the truthful rendering of a place with one identity in it and no
`allow_other` to admit a second.

Inbound reads 0 back as the invoking user, which also fixes the quieter half
of this: the hand-off now recognizes the command as the process it already
is, so a file it creates stays owned in the driver by whoever ran mountx
instead of by a uid 0 that means nothing outside the namespace.

Pure and exported, because checking it otherwise costs a namespace, a mount
and a subprocess.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…amespace

The file this belongs in already said it deliberately does not re-test the
filesystem, because FUSE has its own conformance column. That reasoning has
a hole on this host: there is no `fusermount3` and the root column needs
sudo, so this is the only Tier-2 FUSE mount column that actually runs here,
and the id-space bug lived in the gap for as long as it did because of it.

So one case that *is* filesystem behaviour, chosen to be exactly the one no
other column can reach: unlink, rmdir, rename, hardlink, truncate, chmod and
readdir over entries — a file, a directory, a symlink and a whole subtree —
that the driver held *before* the mount. Every assertion reads the driver
back afterwards rather than trusting the command's exit status, which is the
rule this file already had and the reason the original bug was not louder.

Plus the inbound half: a file the command creates is owned in the driver by
whoever ran mountx.

Both fail without the fix, the first with the reported EOVERFLOW.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An invariant, because it is the kind of thing that gets "fixed" in the wrong
place twice: a driver that reports 0 to please one mount is lying to every
other consumer of the same driver.

The exec page gets a section of its own, since uniformly root-owned is
user-visible behaviour with a reason worth stating rather than an
implementation detail, and the FUSE page gets `idmap` in its options table
with the note that mounting with `mount()` never needs it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
mountx Ready Ready Preview, Comment Jul 29, 2026 1:40pm

Request Review

@pi0x pi0x mentioned this pull request Jul 29, 2026
@pi0
pi0 marked this pull request as draft July 29, 2026 23:55
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.

2 participants