Conversation
…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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Closed
pi0
marked this pull request as draft
July 29, 2026 23:55
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.
mountx/exec— run a command with anFsDrivergrafted onto its filesystem view. New subpath, deliberately not wired intomountx/auto(auto's contract is a mountpoint; this produces a child's exit status).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/mountson the host stays empty — and dies with the namespace.Needs no root, no
fusermount3and no native addon: inside the namespace the relay is uid 0 withCAP_SYS_ADMIN, so it takes the ordinary root mount path. On a host with nofuse3package 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/fuseis opened there, and traffic comes back out over a unix socket to aFuseSessionin the parent. That is the "relay mode" the roadmap defers, arrived at from the other direction.The one behaviour change outside
src/exec/FuseSessiongains an optionalidmap, identity by default — no existing mount changes behaviour.It exists because every
uid/gidon the FUSE wire is named in the id space of the mount's user namespace, andunshare -U -rmaps exactly one uid and one gid. A driver reporting host ids (1000) producedINVALID_UID, and the VFS then refused the inode:may_delete()andmay_linkat()answerEOVERFLOW, andinode_permission()answersEACCESfor a write open. Concretely, a child could notunlink,rmdir,rename,linkor truncate anything it had not itself created — files it created were chowned to the caller's0, which is mapped, which is why the failure looked like a LOOKUP-vs-CREATE distinction.Ordinary FUSE mounts have
s_user_ns == init_user_nswhere 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 testgreen — 2904 passed, 276 skipped.pnpm test:rootlessgreen.mkdir,unlink,rmdir,rename,link, truncate,chmod, symlink, readdir — over a file, a directory, a symlink and a nested subtree.pnpm buildagainstdist/exec/index.mjs. Renamingdist/exec/userns-relay.mjsaway makes it fail, proving the built relay is the one doing the work.test/exec/userns.test.tsis currently the only Tier-2 FUSE mount column that runs on this host — nofusermount3for the rootless column, and the root column needs sudo.Four details that look arbitrary and are not
default_permissionsis not in the default mount options. With it on, the kernel checks the driver's ids against a namespace that maps one, renders the ownernobody, and the mount is silently read-only.cwdmay never be inside the mountpoint — it deadlocks the relay inDstate atfuse_get_reqpermanently (uv_spawnblocks the calling thread until the child execs; the child's first act is achdironly 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-rand no--map-root-user; bare-Alpine support depends on it.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/fuseis 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/autostays untouched. macOS gets nothing — no user namespaces.🤖 Generated with Claude Code