summaryrefslogtreecommitdiff
path: root/9harness/docs
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-21 14:23:27 -0300
committerGabriel Schneider <[email protected]>2026-09-21 15:20:40 -0300
commitb4db588dd5b92d647b661c2dc17b40925af92348 (patch)
tree7a036e500251d7f3f958854dcf3a8ead375bbc63 /9harness/docs
parent0d7e295efee1fca0935cf4a8bee9629c007dd2b6 (diff)
downloadcloud9-b4db588dd5b92d647b661c2dc17b40925af92348.tar.gz
cloud9-b4db588dd5b92d647b661c2dc17b40925af92348.zip
9harness: the harness fs daemon
The last item of the 9P plan, replacing zmxify's introspection half: a read-only, fresh-from-disk 9P view of every agent harness's state on this machine, posted as `harness` like any other service, so a shell inside a 9ns --mntgen mount reads it at /mnt/9p/harness with no setup. /pid /uptime /claude/{projects,history,skills} /codex/{sessions,session-index,history} /omp /hermes /dsh the mirrors /skills/{claude,codex,omp} Nothing is cached: a lookup, getattr or readdir walks the real filesystem, so a transcript grows as its harness writes it and a new session appears as soon as its file lands. Writes answer EPERM, and no name that looks like a credential, key, token or auth store is ever answered at any depth. Three findings from the adversarial pass, each with its regression: - The read path composed <base>/<rel> and opened it in one call, which follows symlinks. A name swapped for a link between the walk and the read served bytes from outside every pinned root (proved against /etc/passwd). Every stat, read and readdir now resolves through openIn, which walks from the base one component at a time with O_NOFOLLOW, and O_PATH for the intermediates, so no component can redirect the walk. O_PATH also keeps a fifo in a root from parking the daemon in open(); a read refuses anything but a regular file. - Joining a child onto an empty relative path returned an uncopied scratch slice, so every file at the top of a mirror root (/hermes/x, /dsh/x) listed but read back uninitialized stack bytes. - A directory past the comptime caps was served short, and a short listing cannot be told from a small directory. The caps answer NFILE now. Staging also stops at the first record that does not fit instead of packing a shorter one behind it, which dropped that entry from the listing across the read boundary. Suites: 13/13 unit (fake HOME, never the live roots), 36/0 end-to-end including the mntgen money shot and the live ~/.claude/.credentials.json proved unreachable, 131/131 programs-test.
Diffstat (limited to '9harness/docs')
-rw-r--r--9harness/docs/DESIGN.md225
1 files changed, 225 insertions, 0 deletions
diff --git a/9harness/docs/DESIGN.md b/9harness/docs/DESIGN.md
new file mode 100644
index 0000000..1b5f33e
--- /dev/null
+++ b/9harness/docs/DESIGN.md
@@ -0,0 +1,225 @@
+# 9harness design
+
+9harness is the harness fs daemon: a long-running 9P2000 server that
+replaces the `zmxify` rc script's introspection half with a unified,
+file-shaped, always-fresh view of every AI-agent harness's state on the
+machine — Claude Code (`~/.claude`), Codex (`~/.codex`), omp (`~/.omp`),
+hermes (`~/.hermes`) and dsh (`~/.dsh`), plus their skills. Everything is
+read-only and read live from disk at request time; nothing is cached, so
+a session transcript grows as its harness writes it and a new session
+appears as soon as its file lands.
+
+One binary, hosted Linux only (it posts through `cloud9.post`). The
+backend is `src/tree.zig`, a `cloud9.fs.Server` backend run by
+`cloud9.serve.Runner`; the CLI is `src/main.zig`. Like its siblings
+(9proc, 9ns) the build is a fragment in `build.zig` wired into the root
+behind `-D9harness` (steps `9harness`, `9harness-test`, `9harness-itest`;
+installed by the plain `zig build` next to the other programs).
+
+## The tree (v1 contract)
+
+```
+/ read-only root (dr-xr-xr-x)
+/pid the daemon's pid, one line
+/uptime seconds since start, one line
+/claude/
+ projects/ mirror of ~/.claude/projects/<project-dir>/: one dir
+ per project, its *.jsonl session transcripts as plain
+ readable files, memory/ subdirs included
+ history ~/.claude/history.jsonl (global prompt history)
+ skills/ mirror of ~/.claude/skills
+/codex/
+ sessions/ mirror of ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
+ session-index ~/.codex/session_index.jsonl
+ history ~/.codex/history.jsonl
+/omp/ mirror of ~/.omp/agent (history.db, agent.db, models.db,
+ ... served as raw readable blobs; sqlite parsing is a
+ later step — v1 is files)
+/hermes/ mirror of ~/.hermes (state.db raw, logs/)
+/dsh/ mirror of ~/.dsh (profiles/, storages/, ... whatever it holds)
+/skills/ union view: plain dirs claude/, codex/, omp/ mirroring each
+ harness's skills dir; a harness without one simply has no entry
+```
+
+Mirroring is lazy: nothing is walked at startup. A lookup, getattr or
+readdir stats and lists the real files at request time. Unknown files and
+directories appear as themselves, readable. A file that vanishes between
+lookup and read answers ENOENT cleanly; a file that appears is visible on
+the next request.
+
+Writes, creates, removes and setattrs answer EPERM (create/remove/wstat
+are not declared as backend features, so the engine refuses them itself;
+the write and setattr ops answer `E.PERM`, as does an open for write).
+Modes are reported read-only regardless of the real bits: directories
+`0o555`, files `0o444`. Sizes and mtimes are real.
+
+The engine's known fidelity gap applies: directory records carry fixed
+modes and length 0, so `9p ls -l` shows placeholders; `9p stat` is the
+accurate one.
+
+## The exclusions (the security boundary)
+
+The daemon must never become a credential reader for anything that
+mounts it. The rule is absolute — `excluded()` in tree.zig, unit-tested —
+and applies to every path component of every subtree, by lookup *and* by
+readdir, so an excluded name neither resolves nor lists:
+
+- names containing `credentials`, `token`, `auth` or `secret`
+ (case-insensitive substrings; "auth" also hides "author-notes" — the
+ price of never guessing wrong);
+- names ending `.key`, `.pem` or `.env`;
+- the config/settings files of the mirrored harnesses, which may embed
+ API keys: `settings.json`, `settings.yaml`, `settings.local.json`,
+ `config.yml`, `config.yaml`, `config.toml`, `models.yml`, `models.yaml`
+ (Claude Code's settings.json is not in the tree anyway: /claude serves
+ only projects/, skills/ and history.jsonl);
+- `.ssh`, and `id_rsa`/`id_dsa`/`id_ecdsa`/`id_ed25519` prefixes.
+
+Symlinks are never served, at any depth: they are an escape hatch around
+the root pinning (a symlink out of `~/.claude` would make the daemon read
+outside the five roots). A symlink answers ENOENT and is not listed.
+
+Path resolution is the mechanism that makes that true, and it is not
+string composition. `openIn` walks from the pinned base one component at
+a time, each opened with `O_NOFOLLOW`: the intermediate components with
+`O_PATH|O_DIRECTORY` (so a component swapped for a symlink fails with
+ENOTDIR rather than redirecting the walk) and the last with the caller's
+flags. Every stat, read and readdir goes through it. Composing
+`<base>/<rel>` and opening that in one call is what the first version
+did, and it was wrong in a way a test could not see: only the final
+component's symlink was refused, so a name replaced between the walk and
+the read — the plain TOCTOU — served bytes from outside every root, and
+a directory above it could redirect the whole path at any time. `O_PATH`
+also means a fifo or device left in a harness root is stat'd without its
+open ever blocking; a read refuses anything that is not a regular file.
+
+The base itself comes from $HOME or `--root NAME=PATH` at startup and is
+resolved normally (it is configuration, and may legitimately be a link).
+Relative paths are composed only from remembered (root, rel) pairs plus
+single-segment, engine-vetted names. Client bytes never form a path:
+names with `/` or NUL are refused before the filesystem is touched, `.`
+and `..` are refused as components inside the walk, and `..` as a lookup
+resolves through the backend's own parent map, never the kernel's.
+
+## Backend shape
+
+`Harness` (tree.zig) is the backend of `fs.Server(Harness, opts)` on
+`serve.Runner` (main.zig): allocation-free request paths, comptime caps,
+`Io` passed explicitly, static memory in .bss (the path table and the
+per-connection buffers; ~3 MB of tables, untouched pages cost nothing).
+
+- **Node ids** pack `Node{idx: u8, kind: u8, serial: u48}` in the u64,
+ zmx-style. Static skeleton nodes (facts, the harness dirs, skills) are
+ `kind = .top`; every mirrored file is `kind = .path` with
+ `serial = (slot, generation)` of its table entry. Two walks of the same
+ file find the same entry, so their node ids — and qid paths — are
+ equal; an entry freed by its last release takes a new generation, so a
+ stale forged node id never resolves.
+- **The path table** remembers at most 4096 remembered (root, rel) pairs,
+ each with a Wyhash of the pair for dedupe and a reference count. The
+ backend declares `features = .{ .references = true }`, so the engine
+ asks lookups for `.` too and pays every reference back with exactly one
+ `release`; an entry's refcount reaching zero frees it. Readdir records
+ name entries without references (a listing does not pin), so a later
+ walk of the same name finds the same entry and the same node id. A full
+ table answers `E.NFILE` — on a lookup, and on a listing that cannot
+ name all its entries (see Readdir).
+- **Fresh reads**: a read opens the file, `readPositionalAll`s the window
+ at the request's offset, and closes it — every read is against the live
+ file, and the engine handles offsets, so `cat` of a 5 MB transcript
+ through a mntgen mount works.
+- **Readdir** collects a directory's surviving names, sorts them for
+ cross-read cursor stability, and stages the engine's records
+ (`node:u64le dir:u8 len:u8 name`), skipping `req.off` records. The
+ comptime caps (1024 names, 128 KiB of name bytes, the path table) are
+ **loud**: a directory that would not fit answers `E.NFILE` instead of a
+ short listing, because a short listing cannot be told apart from a
+ small directory and is therefore a wrong answer, not a limitation. The
+ staging buffer filling is different and normal — it ends that read at
+ the last record that fit, and the next read continues from there;
+ staging stops at the first record that does not fit rather than packing
+ a later, shorter one behind it, which would drop that entry from the
+ listing entirely. A directory that changes between the reads of one
+ listing may shift its cursor — the fresh-tree trade, accepted in v1.
+- **Locking**: one mutex spans `handle` and the reply (the answer's bytes
+ point into shared staging buffers), so the backend is serialized across
+ connections. v1 accepts this: it is an introspection fs, not a
+ throughput service.
+
+## CLI and process model
+
+```
+usage: 9harness [--unix PATH | --tcp IP:PORT | --fd N] [--no-post]
+ [--name NAME] [--root NAME=PATH]...
+```
+
+- Default: post itself under the name `harness` via
+ `serve.Runner.listenPosted`, so it appears at
+ `$XDG_RUNTIME_DIR/9p/harness` and every interactive fish (self-wrapped
+ in `9ns --mntgen`) sees it at `/mnt/9p/harness` with zero
+ configuration. `stop()` unposts; the stale-socket protocol covers a
+ killed daemon.
+- `--unix`/`--tcp` listen forms (beside the post, or with `--no-post`),
+ `--name` to post under another name, `--root NAME=PATH` to pin any of
+ the five roots elsewhere (defaults are `$HOME/.<name>`; the tests use
+ fake homes and never the live roots).
+- `--fd N` serves exactly one 9P session over the connected stream on
+ descriptor N (socket activation, `9ns --spawn` handoffs), driving the
+ engine directly; it posts nothing.
+- SIGTERM/SIGINT stop cleanly (unpost); SIGPIPE is ignored.
+- No daemonization in v1: run it in a zmx session, the zmx way —
+ `zmx run harness -d 9harness`.
+
+## Integration test plan (`test/e2e.sh`)
+
+Part A runs entirely on fixtures (a fake home pinned by `--root`, a
+scratch `XDG_RUNTIME_DIR` registry, plan9port's `9p` as the client):
+
+1. the posted name is listed in the registry;
+2. `9p ls /` shows the roots; a fixture transcript reads back
+ byte-identical (`cmp` against the source file); the skills union
+ mirrors the harness tree;
+3. credentials-shaped files are unreachable by both listing and reading,
+ at several depths, in every root;
+4. a file appended after the daemon started is visible immediately, and
+ a new session directory appears in the next listing;
+5. the pid fact answers the daemon's pid; writes answer EPERM;
+6. the `--unix` and `--fd` listen forms serve;
+7. SIGTERM unposts the name.
+
+Part B is the money shot, read-only against the real roots and the real
+`$XDG_RUNTIME_DIR/9p` — only when the `harness` name is free (a live
+daemon owning the name skips it, not fails): the posted name in the real
+registry, `9p` walks of the live `~/.claude`, a real transcript
+byte-identical through the tree, the live `~/.claude/.credentials.json`
+unreachable, the `9ns --mntgen` mount of `/mnt/9p/harness` with
+`head` of `/claude/history`, and the stop unposting the real name.
+
+Unit tests (tree.zig) cover the exclusions (including the predicate
+itself, spelled out), path-composition safety (slashes, NULs, symlinks,
+the `..` chain, NOTDIR), node id packing (same file equal, distinct
+files differ, the union identity, id recycling through release), the
+vanishing file (ENOENT on lookup, getattr and read), EPERM on
+write/setattr/open-for-write, and live visibility — all against a fake
+HOME under a test tmp dir, never the real harness roots.
+
+## Verification
+
+- `zig build` (installs `9harness` beside 9ns, 9proc-demo, 9web).
+- `zig build test` — the library's 80 tests stay green.
+- `zig build 9harness-test` — the tree's unit tests.
+- `zig build 9harness-itest` — `test/e2e.sh` (fixture + real registry).
+- `zig build 9proc-check-freestanding` — the daemon is hosted-only but
+ must not break the root module.
+- `zig build programs-test` / `programs-itest` — the umbrella steps,
+ which include 9harness's.
+
+## Out of scope for v1 (documented, not hidden)
+
+- Write paths (create/write/setattr stay EPERM), resume/attach
+ automation (the zmxify re-exec half), sqlite parsing (omp/hermes
+ sessions stay raw blobs), cross-session search, any caching or
+ invalidation layer.
+- Streaming/parked reads (a read answers at once; the transcripts are
+ plain files), and union-directory semantics beyond the plain
+ /skills/<harness> dirs.