1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
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.
|