Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
4577348
test(exec): one filesystem workload in three linkages
pi0 Jul 29, 2026
ab1b28e
feat(exec): spike A, FUSE inside an unprivileged user namespace
pi0 Jul 29, 2026
48fc2a4
feat(exec): a 9P2000.L client small enough to live inside a traced pr…
pi0 Jul 29, 2026
2ed22b3
feat(exec): spike B, an LD_PRELOAD interposer over 9P
pi0 Jul 29, 2026
f482e85
feat(exec): spike C, a seccomp user-notification supervisor over 9P
pi0 Jul 29, 2026
4417045
test(exec): the comparison harness the verdict is drawn from
pi0 Jul 29, 2026
1026166
docs(agents): proot spike findings, and the host facts behind them
pi0 Jul 29, 2026
c3f5fca
feat(exec): mountx/exec, one entry point over two mechanisms
pi0 Jul 29, 2026
ec0e47e
refactor(exec): name the demo runners after their mechanisms
pi0 Jul 29, 2026
f15854c
test(exec): the strategy's decisions, and a real namespace for A
pi0 Jul 29, 2026
307b49e
docs: a page for mountx/exec, beside the transports it is built on
pi0 Jul 29, 2026
92ab02e
docs(agents): record mountx/exec in the code map and the roadmap
pi0 Jul 29, 2026
c86531f
fix(exec): find the relay after the build splits it off
pi0 Jul 29, 2026
c278e8d
feat(exec): the mutating half of 9P2000.L in the embedded client
pi0 Jul 29, 2026
dabffd2
feat(exec): stream the supervisor's I/O, and give it a write path
pi0 Jul 29, 2026
8d7604d
test(exec): a conformance column driven through a traced process
pi0 Jul 29, 2026
a138a68
fix(exec): stop clunking a fid twice when a symlink resolution fails
pi0 Jul 29, 2026
b695786
test(exec): measure write-back in the comparison harness
pi0 Jul 29, 2026
be5cc91
fix(exec): three things the supervisor was quietly getting wrong
pi0 Jul 29, 2026
df5f3e8
refactor(exec): canonical zig formatting, and no dead descriptor state
pi0 Jul 29, 2026
1b42746
Merge branch 'worktree-agent-aad746a2843234c88' into feat/proot
pi0 Jul 29, 2026
f231fee
refactor(exec): name the last demo runner after its mechanism too
pi0 Jul 29, 2026
6bcbd37
Merge branch 'main' into feat/proot
pi0 Jul 29, 2026
0d0f7d1
refactor(exec): take the rejected LD_PRELOAD mechanism off the branch
pi0 Jul 29, 2026
ef80e2f
Merge remote-tracking branch 'origin/feat/proot' into feat/proot
pi0 Jul 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .agents/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,3 +322,35 @@ vers=3,proto=tcp,port=…,mountport=…,nolocks,soft,nobrowse 127.0.0.1:/ ./mnt`
approval), then `sudo umount -f`, then reboot. A hung `umount` does **not** die
on `SIGKILL` — it is parked in the kernel — which is why `run()` in
`src/nfs/mount.ts` settles on its own deadline instead of waiting for `close`.

## Unprivileged interception, no mount (verified 2026-07-29, this Linux host)

Established while spiking the `proot`-style `exec()` work
(`.agents/proot-plan.md`). Kernel 6.12.96 (Debian 13), glibc 2.43, x86-64,
zig 0.16.

- **There is no `fusermount3` on this host at all** — not at `/usr/bin`, not
anywhere on `PATH`. So the rootless FUSE path (`src/fuse/fusermount.ts`)
cannot run here, and `pnpm test:rootless`'s FUSE column skips itself for that
reason rather than for a missing prebuilt.
- **Unprivileged user namespaces work**, and are the way around it:
`unshare -Urm` yields uid 0 with `CapEff: 000001ffffffffff`, `/dev/fuse` is
`crw-rw-rw-`, and `mount -t tmpfs` inside succeeds. A FUSE mount made in
there needs **no helper, no root and no native addon** — it is the ordinary
root path in `src/fuse/mount.ts`, which keys off `getuid() === 0`. The mount
is invisible in the host's `/proc/self/mounts` and dies with the namespace.
- **Node can never enter such a namespace itself.** `unshare(CLONE_NEWUSER)`
and `setns(2)` both require a single-threaded caller; Node has the libuv
threadpool up before user code runs. Any design here needs a child process.
- **`ptrace` is unrestricted** (`/proc/sys/kernel/yama/ptrace_scope` is `0`).
- **seccomp user notification works unprivileged.** A filter installed with
`SECCOMP_FILTER_FLAG_NEW_LISTENER` after `PR_SET_NO_NEW_PRIVS` succeeds, and
`SECCOMP_IOCTL_NOTIF_ADDFD` can inject a descriptor into the tracee. This is
true even though the shell already runs under a seccomp filter of its own
(`Seccomp: 2`, `Seccomp_filters: 1` in `/proc/self/status`) — filters stack.
- `zig cc` cross-compiles the three test linkages used by
`test/exec/compare.sh` with no toolchain beyond zig: dynamic glibc, static
musl (`-target x86_64-linux-musl -static`), and no-libc raw syscalls
(`-target x86_64-linux-none -nostdlib -static -ffreestanding -fno-builtin`;
without the last two flags the compiler emits calls to the `strlen` that
binary deliberately does not have).
368 changes: 368 additions & 0 deletions .agents/proot-plan.md

Large diffs are not rendered by default.

55 changes: 55 additions & 0 deletions .agents/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,38 @@ results.
column (no host could mount 9P when the benchmark suite was last run — see
"Future / deferred", now that one can).

- **`mountx/exec`** (2026-07-29). A `proot`-shaped `exec()`: run a command
with a driver grafted onto its filesystem view at `$MOUNTX_ROOT`, visible
to that process tree and to nothing else on the machine, resolving with
that command's exit status. Three mechanisms were built and measured
(`.agents/proot-plan.md`); two ship behind one picker. `probeExec()`
publishes what each can do here and why not, `exec()` takes the first
usable one in preference order — the **user namespace** (FUSE inside
`unshare -U -r -m`, driver in the parent, traffic relayed over a unix
socket because `unshare(CLONE_NEWUSER)` refuses a threaded caller) where
the kernel's FUSE is usable, the **seccomp** user-notification supervisor
otherwise, which is the case that motivated the question: a container that
withholds `/dev/fuse` withholds it from a namespace root too (`mknod`
answers `EPERM`, verified on `alpine:latest`). Both arrive through
`await import()`; the result is the mechanism's own object with a
`mechanism` discriminant defined on it. `src/exec/probe.ts` is import-light
in `src/nfs/probe.ts`'s sense and names causes a caller can act on rather
than one errno.
Deliberately **outside `mountx/auto`**, whose contract is a mountpoint this
produces none of — the line `mountx/s3` already sits on.
What was deliberately **not** done: **`LD_PRELOAD` was rejected** rather
than finished (it cannot see a Go or static binary by construction, its
symbol surface tracks other projects' releases, a descriptor it creates
does not survive `exec`, and its characteristic failure is a confident
wrong answer — the code stays as the written-up evidence, reachable from
its own runner and the comparison harness and from nothing that ships); no
conformance-matrix column for either mechanism (the `userns` one would
duplicate FUSE's exactly, the `seccomp` one is not ready for it); no
supervisor binary in the npm package, so the seccomp mechanism needs a Zig
toolchain and `$MOUNTX_TRACE`; and no `default_permissions` on the
namespace mount, because the kernel checking a driver's uid against a
namespace that maps exactly one turns every write into `EACCES`.

## Finalized decisions (still binding)

- **Scope:** FUSE (Linux) + NFSv3 loopback transports. WebDAV deferred.
Expand Down Expand Up @@ -171,6 +203,29 @@ rather than by accident.
is also where `src/9p/`'s deferred `trans=fd` would finally earn its keep:
it wants a descriptor the relay already holds, where `mount9p()`'s own
`trans=unix` has nothing to relay to.
- **`mountx/exec`'s seccomp mechanism, past the spike.** It ships as the
second choice and covers the case `userns` cannot, but four things are
open and each is named in `src/exec/seccomp.ts` and
`.agents/proot-plan.md`: **streaming instead of slurping** (a file open
copies the whole file into a `memfd`, which is what buys native
`read`/`lseek`/`mmap` afterwards and is wrong for a large file and for
anything that writes — trapping `read`/`write`/`lseek` per descriptor is
the fix, the way `getdents64` already is); **write-back at all**, since it
is read-only as spiked; **arm64**, which is a second syscall table rather
than a redesign; and **trapping `close`**, which means not sharing a filter
with the tracee, i.e. the `SCM_RIGHTS` shape after all — for which
`native/` already has `recvFd`. Also: the supervisor is not in the npm
package, so the mechanism needs a Zig toolchain and `$MOUNTX_TRACE` today.
- **A conformance-matrix column for `mountx/exec`.** Neither mechanism has
one. The `userns` one would duplicate the FUSE column exactly (what the
child sees _is_ FUSE, through the kernel's VFS), which is an argument for
never writing it rather than for writing it later; the `seccomp` one is
the interesting one and wants the streaming work above first, since a
read-only column would be mostly skips.
- **`mountx/exec` on macOS.** Nothing from any of this transfers: no user
namespaces, no seccomp, and SIP blocks `DYLD_INSERT_LIBRARIES` for exactly
the system binaries anyone would want to run. macOS stays NFS-mount
territory, and the honest answer is that this feature is Linux's.
- **A 9P bench column.** `bench/` has loopback and NFS columns and a
sudo-gated FUSE one; 9P has none yet, and unlike when the transport was
designed, a host that can mount it now exists (`.agents/environment.md`).
Expand Down
Loading
Loading