diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-19 23:28:22 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-19 23:28:22 -0300 |
| commit | ba996acfcad1698adbf4a1834fe50e73b1c6cab9 (patch) | |
| tree | 282ba00ce5b10d7416aecb9f2f0f0a439340a57d /9ns/README.md | |
| parent | b05abcba3ea09ea106ad28364c6e40a3ec31b890 (diff) | |
| download | cloud9-ba996acfcad1698adbf4a1834fe50e73b1c6cab9.tar.gz cloud9-ba996acfcad1698adbf4a1834fe50e73b1c6cab9.zip | |
Rename programs: 9player -> 9ns, introspect -> 9proc, app -> web (9web)
Directories, binaries, build options (-D9ns, -D9proc), step names, module
name (9proc), thread and fs names, env var NINEPLAYER_MOUNT -> NINE_MOUNT,
docs and test scripts. Browser assets move to web/static.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to '9ns/README.md')
| -rw-r--r-- | 9ns/README.md | 156 |
1 files changed, 156 insertions, 0 deletions
diff --git a/9ns/README.md b/9ns/README.md new file mode 100644 index 0000000..04edffb --- /dev/null +++ b/9ns/README.md @@ -0,0 +1,156 @@ +# 9ns + +Mount a 9P2000 file tree into a fresh mount namespace and run a program in it, +as a plain user, without touching the host's mount table. + +```sh +9ns --unix /run/user/1000/acme -- fish # a shell that sees the tree at /mnt/9p +9ns --tcp 127.0.0.1:564 -- claude # an agent that sees it too +9ns --spawn '9proc-demo --stdio' -- bash # start the server yourself, talk over a socketpair +``` + +Inside, the tree is ordinary files: `ls`, `cat`, `echo x > ctl`, editors, +`find`, `rsync`, whatever. `$NINE_MOUNT` tells programs where it is +(default `/mnt/9p`). When the program exits, 9ns exits with its status +and the namespace, mount and connection disappear. + +## How it works + +The kernel's own `9p` filesystem cannot be mounted inside an unprivileged user +namespace, so 9ns is a small FUSE server that speaks 9P2000 to the real +server. There is no libfuse and no libc: `src/fuse.zig` implements the subset +of the kernel FUSE protocol needed, straight from `linux/fuse.h`. + +``` + program (fish/bash/claude) 9ns (parent) 9P server + in a new user+mount namespace │ + /mnt/9p ── FUSE ──▶ kernel ────▶│ fuse.zig ─▶ bridge.zig ─▶ nine.zig ──▶ unix / tcp / socketpair + │ (framing) (translation) (cloud9 Client) +``` + +1. The parent connects to the 9P server (version + attach) so failures are + reported before anything is forked. +2. The child does `unshare(CLONE_NEWUSER|CLONE_NEWNS)`, maps its own uid/gid, + makes every mount private, opens `/dev/fuse` (it must be opened inside the + new user namespace), mounts it on the mountpoint and passes the descriptor + back to the parent over `SCM_RIGHTS`, then execs the program. +3. The parent serves FUSE requests by translating them into 9P transactions + (`Twalk`, `Topen`, `Tread`, `Twrite`, `Tcreate`, `Tremove`, `Tstat`, + `Twstat`) until the child exits or the namespace disappears. + +Files are opened with `FOPEN_DIRECT_IO`, so synthetic files that report length +0 (the 9P convention for control files) still read correctly, and `O_TRUNC` +travels inside the 9P open mode (`OTRUNC`) rather than as a separate +truncate. Repeated lookups of the same qid map to the same inode. + +If `/mnt/9p` does not exist and cannot be created (the normal case), 9ns +mounts a tmpfs over `/mnt` *inside the namespace only* and bind-mounts every +existing entry of `/mnt` back into it, so nothing is hidden. Pass `--mount DIR` +to use any other directory. + +## Building and testing + +9ns lives in the [cloud9](../) repository as `cloud9/9ns/`, beside +the 9P2000 protocol library it is built on, and is wired into cloud9's +`build.zig` through the fragment `9ns/build.zig`. Everything is run from +the cloud9 root with Zig 0.16: + +```sh +zig build # zig-out/bin/{9ns,9proc-demo,9web,cloud9-probe} +zig build 9ns # build and install only zig-out/bin/9ns +zig build 9ns-test # unit tests (protocol structs, session, bridge, namespace helpers) +zig build 9ns-itest # integration tests: real namespaces, real FUSE, + # 9proc-demo over unix/tcp/socketpair, and plan9port's + # ramfs when /usr/lib/plan9/bin/ramfs is installed +zig build 9ns-adv # adversarial suites: hostile 9P servers, FUSE semantics, + # namespace/signal edge cases, stress (several minutes) +zig build -Doptimize=ReleaseSafe +zig build -D9ns=false # leave 9ns out (the default on non-Linux targets) +``` + +The integration suites mount the `9proc-demo` server (`../9proc`), +so they need `-D9proc=true` (the default on Linux), unprivileged user +namespaces (`kernel.unprivileged_userns_clone=1` on distributions that have +the knob), `/dev/fuse` and Python 3; they skip themselves otherwise. +`zig build programs-test` and `programs-itest` run the unit and integration +steps of every program in the repository. + +## Usage + +``` +9ns [options] -- PROGRAM [ARGS...] + +Transport (exactly one): + --unix PATH Unix stream socket + --tcp IP:PORT TCP (IPv4/IPv6 literal) + --fd N an already-connected inherited descriptor + --spawn CMD run CMD via /bin/sh -c with a socketpair on its stdin/stdout + +Options: + --mount PATH mountpoint inside the new namespace (default /mnt/9p) + --uname NAME 9P user name (default $USER) + --aname NAME 9P tree to attach (default "") + --msize BYTES maximum 9P message size to request (default 131072, max 16 MiB) + --cache SECONDS attr/entry cache validity, fractional allowed (default 1) + --no-direct-io let the kernel cache file pages (trusts stat length) + --debug trace FUSE and 9P operations on stderr + --help, --version +``` + +PROGRAM defaults to `$SHELL`. Exit status is the program's (`128+signal` if it +was killed); 125 means 9ns itself failed (usage, connect, namespace, +mount); 126/127 are exec failures as usual. + +## 9proc-demo: a demo 9P server + +`9proc-demo` is a single-binary 9P2000 server whose file tree is the binary +itself: build-time facts, `comptime` reflection and live runtime state. It is +the demo of the [9proc library](../9proc) (`../9proc/demo/main.zig`), +built and installed by `zig build 9proc`. + +``` +/README +/build/{zig_version,target,optimize,time,change} captured by build.zig (jj change id, UTC time) +/comptime/types/<T>/{name,size,align,fields} @sizeOf/@alignOf/@typeInfo, generated at comptime +/comptime/decls pub declarations of the server module +/runtime/{pid,ppid,uptime,argv,cwd,env,clients} +/runtime/fn/<name> reading calls a Zig function (hostname, now, random, uname, fib30); + the directory is generated from @typeInfo of the Fns struct +/runtime/ctl write "fib N" | "add A B" | "echo TEXT" | "sleep-ms N", read the result +/scratch/ in-memory read/write tree +``` + +`/runtime/env` exposes the server's whole environment, so serve 9proc-demo on +a Unix socket or loopback only. + +```sh +zig-out/bin/9proc-demo --unix /tmp/intro.sock & +zig-out/bin/9ns --unix /tmp/intro.sock -- sh -c ' + cat $NINE_MOUNT/build/zig_version; echo + cat $NINE_MOUNT/comptime/types/Qid/fields + echo "fib 20" > $NINE_MOUNT/runtime/ctl; cat $NINE_MOUNT/runtime/ctl' +``` + +The same command with `claude -p "explore /mnt/9p ..."` as the program gives an +agent a live, file-shaped view into a running process; that is the intended +use. + +## Limitations + +* One 9P request is in flight at a time; a server read that blocks (event + files) stalls the mount until it returns. +* Base 9P2000 only: no symlinks, ownership, xattrs or locks. Every file is + reported as owned by the invoking user. Cross-directory rename is `EXDEV`. +* No PID namespace and no `/proc` remount. `--tcp` takes IP literals only + (no libc, no resolver). +* Linux only. + +## Relation to cloud9 + +9ns consumes cloud9 as the module `cloud9` and keeps all mounting, +namespace and process policy on its side, which is what cloud9's design asks +of applications. It ships from the cloud9 repository as the sibling directory +`9ns/` (sources in `src/`, suites in `test/`, this README and +`docs/DESIGN.md`) with a build fragment that the root `build.zig` enables with +`-D9ns` on Linux targets; nothing in the code depends on that layout. +`docs/DESIGN.md` has the full module contracts. |
