# 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//: 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 `/` 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/.`; 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/ dirs.