a73x

3ae9ab34

docs: step 2 plan for the platform layer, and the forkPty amendment to the spec

a73x   2026-09-03 07:18

Commit message
docs: step 2 plan for the platform layer, and the forkPty amendment to the spec

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SakwJEwD9dXBoRP5kWbemW

docs/superpowers/plans/2026-09-03-macos-port-step2-platform-layer.md
Old New
@@ -0,0 +1,1643 @@
1 # macOS port, step 2: the platform layer — Implementation Plan
2
3 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5 **Goal:** Every Linux-specific call in `src/` moves behind `src/os/` (one backend per side), the harness stops spelling `/proc`, and a source ban keeps it that way — with Linux behaviour unchanged and `make ci` green at every commit.
6
7 **Architecture:** Two new table rows, `server_os` and `client_os`, each a root file that is the interface and a `_linux.zig` child that spells the syscalls; the root selects the child by `switch (builtin.os.tag)`. `spawn` moves into the folder. `forkDaemon`'s fork and the peer-credential read leave `main.zig`. The shared root (`xdg`, `sockpath`) respells its Linux-isms in portable `std.c`. `test/e2e_lib.sh` gains named oracle helpers. build.zig gains folder rule 7 and a target word for the QUIC deps.
8
9 **Tech Stack:** Zig 0.15.2 (vendored at `deps/zig/zig`, system zig will not build this), POSIX sh for the harness, cmake for `deps/quic`.
10
11 **Spec:** `docs/superpowers/specs/2026-09-03-macos-port-design.md`
12
13 ## Global Constraints
14
15 - Build with `deps/zig/zig` only. `make check` (fmt + unit tests + shell syntax + comment-claim refs) before every commit; `make ci` before delivery. Capture `$?` before piping.
16 - Linux behaviour does not change in this plan. Any test that passed before must pass after; no test is deleted, only respelled.
17 - No line outside `src/os/` may spell `std.os.linux`, `/proc`, `memfd`, `close_range`, `exit_group`, `PEERCRED`, `TIOCSPTLCK` or `TIOCGPTN` once Task 9 lands — comments included. Earlier tasks reword the comments they touch to name the operation (`server_os.anonFd`, `pid_alive`) instead of the mechanism.
18 - A file belongs to exactly one module. `src/os/` imports nothing of ours.
19 - Commit subjects are `type: what changed`, `type` one of `feat fix refactor test docs build chore`, no scope. Trailer lines: `Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>` and `Claude-Session: https://claude.ai/code/session_01SakwJEwD9dXBoRP5kWbemW`.
20 - Never `cat` `src/server/server.zig`, `src/tui/interact.zig` or `docs/decisions.md`; `grep -n` then `sed -n 'A,Bp'`.
21 - Every hand-run rig exports an isolated `XDG_STATE_HOME` and `XDG_RUNTIME_DIR`.
22 - A unit test that writes to stdout wedges `zig build test` silently. Tests print to stderr or nothing.
23 - Line numbers below are from HEAD `7b207001`; re-grep the anchor symbol before editing.
24
25 ---
26
27 ## File structure
28
29 | File | Responsibility |
30 |---|---|
31 | `src/os/server_os.zig` (new) | The daemon-side platform interface: one `pub fn` per operation, doc comment naming the failure it prevents, `impl` switch. `selfImageStale` lives here in full. Unit tests for every operation. |
32 | `src/os/server_os_linux.zig` (new) | Linux spellings only. `@cImport` of `pty.h`. No doc prose beyond why a spelling is what it is. |
33 | `src/os/client_os.zig` (new) | Wall/askpass-side interface: `peerCred`, `parentOf`, `winSize`, `openPtyPair`. Tests. |
34 | `src/os/client_os_linux.zig` (new) | Linux spellings. |
35 | `src/os/spawn.zig` (moved from `src/cli/`) | Unchanged contract; the `/proc` fallback arm is spelled per OS. |
36 | `src/server/pty.zig` | Loses its `@cImport`; calls `server_os`. |
37 | `src/cli/main.zig` | `forkDaemon` keeps the log/argv/poll policy, hands the fork to `server_os.forkDetached`; `peerPid` becomes a call to `server_os.peerCred`. |
38 | `src/server/server.zig` | `send`, the two `memfd_create` sites, `selfImageStale`, `PendingUpgrade.memfd` → `carrier`. |
39 | `src/server/quic_server.zig` | `initFromFd`'s `getsockopt`. |
40 | `src/client/askpass.zig` | `peerCred`, `parentOf`, `getpid`, `geteuid` sites. |
41 | `src/tui/interact.zig` | `ttySize`, `ptsPair`, `setTtySize`. |
42 | `src/tui/wallview.zig` | The askpass gate reads `sockpath.runtimeDir()`. |
43 | `src/xdg.zig`, `src/sockpath.zig` | `kill(pid,0)`, comptime `max_sun_path`, `runtimeDir()`. |
44 | `build.zig` | Two rows, `spawn` path, import grants, folder rule 7, rule 5/6 folder lists, `use_lld` gated off Darwin, `quicDeps` target word. |
45 | `deps/quic/build-deps.sh` | Target word `native|musl|aarch64-macos`; `uname`-gated `sha256sum`/`nproc`. |
46 | `Makefile` | `MUX_TARGET ?= x86_64-linux-musl` for `install`/`release`. |
47 | `test/e2e_lib.sh` | Oracle helpers; `timeout` shim. |
48 | `test/e2e_0{1,3,4,6,9}_*.sh`, `e2e_14`, `e2e_16`, `soak.sh`, `vm.sh` | Pins call helpers. |
49 | `CLAUDE.md`, `docs/decisions.md` | Table row, rule 7, the dated decision. |
50
51 ---
52
53 ### Task 1: The `src/os/` folder, two rows, and `spawn` moves in
54
55 **Files:**
56 - Create: `src/os/server_os.zig`, `src/os/server_os_linux.zig`, `src/os/client_os.zig`, `src/os/client_os_linux.zig`
57 - Move: `src/cli/spawn.zig` → `src/os/spawn.zig`
58 - Modify: `build.zig:117-270` (mod_table), `build.zig` rule 5 and 6 folder lists (~line 355-380), `CLAUDE.md` layout table
59
60 **Interfaces:**
61 - Produces: modules `server_os` and `client_os`, importable by the rows granted below. Both roots export `pub const impl` and a first operation each so the wiring is exercised: `server_os.getpid() std.posix.pid_t` and `client_os.getpid() std.posix.pid_t`. (Later tasks add the rest; `getpid` stays because Task 7's callers use it.)
62
63 - [ ] **Step 1: Write the four files**
64
65 `src/os/server_os.zig`:
66
67 ```zig
68 //! The daemon's platform layer: every call whose spelling or existence
69 //! differs by OS, behind one name each. This root is the CONTRACT — a doc
70 //! comment per operation says what it guarantees and which failure it
71 //! prevents — and a child per OS spells the syscalls. A build for an OS
72 //! with no child is a compile error here, never a runtime surprise.
73 //!
74 //! Imports nothing of ours: the daemon, the pty and the CLI entry import
75 //! this, and folder rule 7 (build.zig) bans the raw spellings everywhere
76 //! else, so a new Linux-ism has one place to go.
77 const std = @import("std");
78 const builtin = @import("builtin");
79
80 pub const impl = switch (builtin.os.tag) {
81 .linux => @import("server_os_linux.zig"),
82 else => @compileError("mux has no server platform arm for " ++ @tagName(builtin.os.tag)),
83 };
84
85 /// This process's pid, for the pid-named directories the daemon's
86 /// successor reaps (`xdg.reapDeadPid`).
87 pub fn getpid() std.posix.pid_t {
88 return impl.getpid();
89 }
90
91 test "server_os: the arm compiles and answers for the process it is in" {
92 try std.testing.expect(getpid() > 0);
93 }
94
95 // Forces semantic analysis of every pub decl under `zig build test`, so an
96 // unreferenced operation must at least compile for this OS.
97 test {
98 std.testing.refAllDeclsRecursive(@This());
99 }
100 ```
101
102 `src/os/server_os_linux.zig`:
103
104 ```zig
105 //! Linux arm of `server_os`. Spellings only; the contract is in the root.
106 const std = @import("std");
107
108 pub fn getpid() std.posix.pid_t {
109 return std.os.linux.getpid();
110 }
111 ```
112
113 `src/os/client_os.zig`:
114
115 ```zig
116 //! The wall's and askpass's platform layer: the few calls the client side
117 //! makes that differ by OS. Same shape as `server_os` — this root is the
118 //! contract, a child per OS spells it — and deliberately a SEPARATE row:
119 //! the client never links a fork or a pty, and an app that links the
120 //! engine and a client must not either.
121 const std = @import("std");
122 const builtin = @import("builtin");
123
124 pub const impl = switch (builtin.os.tag) {
125 .linux => @import("client_os_linux.zig"),
126 else => @compileError("mux has no client platform arm for " ++ @tagName(builtin.os.tag)),
127 };
128
129 /// This process's pid, for `mux-ask-PID.sock` and the hub's banner.
130 pub fn getpid() std.posix.pid_t {
131 return impl.getpid();
132 }
133
134 test "client_os: the arm compiles and answers for the process it is in" {
135 try std.testing.expect(getpid() > 0);
136 }
137
138 test {
139 std.testing.refAllDeclsRecursive(@This());
140 }
141 ```
142
143 `src/os/client_os_linux.zig`:
144
145 ```zig
146 //! Linux arm of `client_os`. Spellings only; the contract is in the root.
147 const std = @import("std");
148
149 pub fn getpid() std.posix.pid_t {
150 return std.os.linux.getpid();
151 }
152 ```
153
154 - [ ] **Step 2: Move spawn**
155
156 ```bash
157 git mv src/cli/spawn.zig src/os/spawn.zig
158 ```
159
160 - [ ] **Step 3: Wire the table**
161
162 In `build.zig`'s `mod_table`, before the `pty` row, add:
163
164 ```zig
165 // The platform layer, one row per side (docs/superpowers/specs/
166 // 2026-09-03-macos-port-design.md). Leaves: they import nothing of ours,
167 // and folder rule 7 below bans every raw OS spelling outside src/os/.
168 .{ .name = "server_os", .path = "src/os/server_os.zig", .link_libc = true },
169 .{ .name = "client_os", .path = "src/os/client_os.zig", .link_libc = true },
170 ```
171
172 Change the `spawn` row's path to `"src/os/spawn.zig"`.
173
174 Add `"server_os"` to the `imports` of rows `pty`, `daemon`, `mux`; add `"client_os"` to rows `client`, `wall`. (The grant check requires the root file to `@import` the name; Tasks 2–7 add those imports. Until then the build's grant check will FAIL — so in this task add the grants only for `spawn`'s existing importers, which do not change, and add each grant in the task that first imports it. Concretely: this task changes ONLY the `spawn` path and adds the two new rows.)
175
176 In `source_bans`, rules 5 and 6: add `"src/os"` to their `folders` lists (rule 5's shell ban and rule 6's fork ban apply to the new folder like any other). Rule 6's `except` stays `src/cli/main.zig` until Task 3 moves the fork.
177
178 - [ ] **Step 4: Update CLAUDE.md's layout table**
179
180 In the `## Layout` table add a row after `src/cli/`:
181
182 ```
183 | `src/os/` | `server_os`(`server_os.zig`) — `server_os_linux` · `client_os`(`client_os.zig`) — `client_os_linux` · `spawn` — the platform layer, one row per side so the client never links a fork or a pty; imports nothing of ours (spec 2026-09-03) |
184 ```
185
186 and in the prose paragraph that says `spawn` lives under `src/cli/` because it asks the OS whether it has a terminal, replace with: "`spawn` lives under `src/os/` with the rest of the platform layer: asking the OS for a terminal is platform code, and rule 4 forbids a client module from doing it."
187
188 - [ ] **Step 5: Build and test**
189
190 Run: `make check 2>&1 | tail -5; echo rc=${PIPESTATUS[0]}` (zsh: `make check > /tmp/c.log 2>&1; echo rc=$?; tail -5 /tmp/c.log`)
191 Expected: rc=0. If the grant check fatals on a stale `spawn` grant, the path change was missed.
192
193 - [ ] **Step 6: Commit**
194
195 ```bash
196 git add src/os build.zig CLAUDE.md
197 git commit -m "refactor: a platform layer under src/os with one row per side"
198 ```
199
200 ---
201
202 ### Task 2: The pty goes through `server_os`
203
204 **Files:**
205 - Modify: `src/os/server_os.zig`, `src/os/server_os_linux.zig`
206 - Modify: `src/server/pty.zig:1-160` (header, `spawnArgv`, `mode`, `fgPgid`, `resize`) and the test at `pty.zig:483-500`
207 - Modify: `build.zig` — grant `server_os` to row `pty`
208
209 **Interfaces:**
210 - Produces:
211 - `server_os.Winsize = std.posix.winsize`
212 - `server_os.ForkedPty = struct { pid: std.posix.pid_t, master: std.posix.fd_t }`
213 - `server_os.forkPty(ws: Winsize) error{ForkPtyFailed}!ForkedPty` — returns `pid == 0` in the child, like forkpty
214 - `server_os.exitNow(code: u8) noreturn`
215 - `server_os.closeFrom(first: std.posix.fd_t) void`
216 - `server_os.PtyMode = struct { icanon: bool, echo: bool }`
217 - `server_os.ptyMode(master) !PtyMode`
218 - `server_os.ptyFgPgid(master) error{IoctlFailed}!std.posix.pid_t`
219 - `server_os.setWinsize(master, ws: Winsize) error{IoctlFailed}!void`
220
221 - [ ] **Step 1: Write the failing test in `server_os.zig`**
222
223 Append before the `refAllDeclsRecursive` test:
224
225 ```zig
226 test "server_os.closeFrom: a fd below the floor survives and one above does not" {
227 // pipe(2) sets no CLOEXEC, so a child that did not close would still
228 // hold pipe[1]. Asked through /dev/fd, which both OSes have.
229 const pipe = try std.posix.pipe();
230 defer std.posix.close(pipe[0]);
231 defer std.posix.close(pipe[1]);
232 var cmd_buf: [96]u8 = undefined;
233 const cmd = try std.fmt.bufPrintZ(&cmd_buf, "test -e /dev/fd/{d} && exit 3; exit 0", .{pipe[1]});
234 const argv = [_:null]?[*:0]const u8{ "/bin/sh", "-c", cmd.ptr };
235 const ws: Winsize = .{ .row = 24, .col = 80, .xpixel = 0, .ypixel = 0 };
236 const f = try forkPty(ws);
237 if (f.pid == 0) {
238 closeFrom(3);
239 std.posix.execveZ(argv[0].?, &argv, std.c.environ) catch {};
240 exitNow(127);
241 }
242 defer std.posix.close(f.master);
243 const r = std.posix.waitpid(f.pid, 0);
244 try std.testing.expect(std.posix.W.IFEXITED(r.status));
245 try std.testing.expectEqual(@as(u32, 0), std.posix.W.EXITSTATUS(r.status));
246 }
247
248 test "server_os.setWinsize then ptyMode: the master answers about the line discipline" {
249 const ws: Winsize = .{ .row = 31, .col = 101, .xpixel = 0, .ypixel = 0 };
250 const argv = [_:null]?[*:0]const u8{ "/bin/sh", "-c", "stty -echo; sleep 5" };
251 const f = try forkPty(ws);
252 if (f.pid == 0) {
253 std.posix.execveZ(argv[0].?, &argv, std.c.environ) catch {};
254 exitNow(127);
255 }
256 defer {
257 std.posix.kill(f.pid, std.posix.SIG.KILL) catch {};
258 _ = std.posix.waitpid(f.pid, 0);
259 std.posix.close(f.master);
260 }
261 // A fresh pty echoes; the shell turns it off. Polled, because nothing
262 // notifies a mode change.
263 var waited: usize = 0;
264 while (waited < 100) : (waited += 1) {
265 const m = try ptyMode(f.master);
266 if (!m.echo) break;
267 std.Thread.sleep(50 * std.time.ns_per_ms);
268 }
269 try std.testing.expect(!(try ptyMode(f.master)).echo);
270 // The foreground group is the shell itself while `sleep` is its child
271 // in the same group: fgPgid equals the pid forkPty returned.
272 try std.testing.expectEqual(f.pid, try ptyFgPgid(f.master));
273 try setWinsize(f.master, .{ .row = 10, .col = 40, .xpixel = 0, .ypixel = 0 });
274 }
275 ```
276
277 - [ ] **Step 2: Run to verify it fails**
278
279 Run: `deps/zig/zig build test -Dtest-filter="server_os" 2>&1 | tail -5`
280 Expected: compile error, `forkPty` not found.
281
282 - [ ] **Step 3: Add the operations to the root**
283
284 In `src/os/server_os.zig`, after `getpid`:
285
286 ```zig
287 pub const Winsize = std.posix.winsize;
288 pub const ForkedPty = struct { pid: std.posix.pid_t, master: std.posix.fd_t };
289
290 /// Fork with a fresh pty as the child's controlling terminal, sized before
291 /// the shell's first read so no program sees a 0x0 grid. Returns pid 0 in
292 /// the child, exactly as forkpty(3) does, so the child code that resets
293 /// signals and injects env stays where the fork is visible (pty.zig).
294 pub fn forkPty(ws: Winsize) error{ForkPtyFailed}!ForkedPty {
295 return impl.forkPty(ws);
296 }
297
298 /// A child's bail-out. Never `std.process.exit`: under link_libc that is
299 /// exit(3), which runs atexit and flushes stdio buffers the child inherited
300 /// from the parent — so the parent's pending bytes would be written twice.
301 pub fn exitNow(code: u8) noreturn {
302 impl.exitNow(code);
303 }
304
305 /// The fd barrier: every descriptor at or above `first` is closed in the
306 /// child before exec. CLOEXEC is set fd by fd, and an upgrade clears every
307 /// one and must seal them again — two hand-kept lists that would have to
308 /// agree, or the manifest carrier with the QUIC key bytes rides into the
309 /// shell. This needs no list.
310 pub fn closeFrom(first: std.posix.fd_t) void {
311 impl.closeFrom(first);
312 }
313
314 /// The two line-discipline bits that decide who echoes a keystroke, read
315 /// off the MASTER. Polled — the kernel notifies nobody when a mode changes.
316 pub const PtyMode = struct { icanon: bool, echo: bool };
317 pub fn ptyMode(master: std.posix.fd_t) !PtyMode {
318 return impl.ptyMode(master);
319 }
320
321 /// Foreground process group of the pty. Equal to the session's child pid
322 /// means no foreground job: the kernel's "command returned" with zero shell
323 /// cooperation, which is `mux a`'s `pgid` mechanism.
324 pub fn ptyFgPgid(master: std.posix.fd_t) error{IoctlFailed}!std.posix.pid_t {
325 return impl.ptyFgPgid(master);
326 }
327
328 /// Resize the pty; the kernel raises SIGWINCH in the session.
329 pub fn setWinsize(master: std.posix.fd_t, ws: Winsize) error{IoctlFailed}!void {
330 return impl.setWinsize(master, ws);
331 }
332 ```
333
334 - [ ] **Step 4: Spell them in the Linux arm**
335
336 Replace `src/os/server_os_linux.zig` with:
337
338 ```zig
339 //! Linux arm of `server_os`. Spellings only; the contract is in the root.
340 const std = @import("std");
341 const root = @import("server_os.zig");
342 const c = @cImport({
343 @cInclude("pty.h");
344 @cInclude("sys/ioctl.h");
345 });
346
347 pub fn getpid() std.posix.pid_t {
348 return std.os.linux.getpid();
349 }
350
351 pub fn forkPty(ws: root.Winsize) error{ForkPtyFailed}!root.ForkedPty {
352 var master: c_int = undefined;
353 var cws: c.struct_winsize = .{ .ws_row = ws.row, .ws_col = ws.col, .ws_xpixel = 0, .ws_ypixel = 0 };
354 const pid = c.forkpty(&master, null, null, &cws);
355 if (pid < 0) return error.ForkPtyFailed;
356 return .{ .pid = pid, .master = master };
357 }
358
359 pub fn exitNow(code: u8) noreturn {
360 std.os.linux.exit_group(code);
361 }
362
363 pub fn closeFrom(first: std.posix.fd_t) void {
364 // ENOSYS (pre-5.9 kernel) leaves the CLOEXEC flags to do the work alone.
365 _ = std.os.linux.syscall3(.close_range, @intCast(first), std.math.maxInt(u32), 0);
366 }
367
368 pub fn ptyMode(master: std.posix.fd_t) !root.PtyMode {
369 // On Linux the master shares one termios with the slave, so what the
370 // session did with tcsetattr is one syscall away.
371 const t = try std.posix.tcgetattr(master);
372 return .{ .icanon = t.lflag.ICANON, .echo = t.lflag.ECHO };
373 }
374
375 pub fn ptyFgPgid(master: std.posix.fd_t) error{IoctlFailed}!std.posix.pid_t {
376 var pgid: c.pid_t = 0;
377 if (c.ioctl(master, c.TIOCGPGRP, &pgid) < 0) return error.IoctlFailed;
378 return @intCast(pgid);
379 }
380
381 pub fn setWinsize(master: std.posix.fd_t, ws: root.Winsize) error{IoctlFailed}!void {
382 var cws: c.struct_winsize = .{ .ws_row = ws.row, .ws_col = ws.col, .ws_xpixel = 0, .ws_ypixel = 0 };
383 if (c.ioctl(master, c.TIOCSWINSZ, &cws) < 0) return error.IoctlFailed;
384 }
385 ```
386
387 - [ ] **Step 5: Run the new tests**
388
389 Run: `deps/zig/zig build test -Dtest-filter="server_os" 2>&1 | tail -5`
390 Expected: pass. If `stty -echo` leaves echo on, the shell was not given the pty as its controlling tty — check `forkpty` returned the child's pid, not 0, to the parent.
391
392 - [ ] **Step 6: Rewire `pty.zig`**
393
394 Header: delete the `@cImport` block (lines 5-9) and add `const server_os = @import("server_os");`. Update the `//!` header's "forkpty with the user's shell" to "a pty forked through `server_os.forkPty` with the user's shell".
395
396 In `spawnArgv`: replace the `ws` declaration and the `forkpty` call:
397
398 ```zig
399 const ws: server_os.Winsize = .{ .row = opts.rows, .col = opts.cols, .xpixel = 0, .ypixel = 0 };
400 if (opts.argv[0] == null) return error.EmptyArgv;
401 const f = try server_os.forkPty(ws);
402 const pid = f.pid;
403 const master = f.master;
404 ```
405
406 In the child block: `std.os.linux.exit_group(126)` → `server_os.exitNow(126)`; the `close_range` line and its four-line comment →
407
408 ```zig
409 server_os.closeFrom(3);
410 ```
411
412 (the rationale now lives on `closeFrom` in the root); `std.os.linux.exit_group(127)` → `server_os.exitNow(127)`. The comment "exit_group, never std.process.exit — see spawn.zig's fork child" becomes "`exitNow`, never `std.process.exit` — see `server_os.exitNow` for why".
413
414 `Mode` and `mode()`:
415
416 ```zig
417 pub const Mode = server_os.PtyMode;
418 pub fn mode(self: *const Pty) !Mode {
419 return server_os.ptyMode(self.master);
420 }
421 ```
422
423 `fgPgid`: body becomes `return server_os.ptyFgPgid(self.master);`. `resize`: body becomes `return server_os.setWinsize(self.master, .{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 });`.
424
425 Test "a daemon fd without CLOEXEC still does not reach the shell": `/proc/self/fd/{d}` → `/dev/fd/{d}`, and the comment "The child looks for its own copy" gains ": through /dev/fd, which every OS mux runs on has".
426
427 - [ ] **Step 7: Grant and check**
428
429 `build.zig`: row `pty` gets `.imports = &.{"server_os"}`.
430
431 Run: `make check > /tmp/c.log 2>&1; echo rc=$?; tail -5 /tmp/c.log`
432 Expected: rc=0.
433
434 Run: `E2E_ONLY=05_session make e2e > /tmp/e.log 2>&1; echo rc=$?; tail -3 /tmp/e.log`
435 Expected: rc=0 (sessions, exits and resizes through the real binary).
436
437 - [ ] **Step 8: Commit**
438
439 ```bash
440 git add src/os src/server/pty.zig build.zig
441 git commit -m "refactor: the pty forks, seals and resizes through server_os"
442 ```
443
444 ---
445
446 ### Task 3: The daemon fork moves to `server_os.forkDetached`
447
448 **Files:**
449 - Modify: `src/os/server_os.zig`, `src/os/server_os_linux.zig`
450 - Modify: `src/cli/main.zig:1271-1356` (`forkDaemon`), header exemption line ~4
451 - Modify: `build.zig` rule 6 `except`, and grant `server_os` to row `mux`
452
453 **Interfaces:**
454 - Produces: `server_os.forkDetached(exe: [*:0]const u8, argv: [*:null]const ?[*:0]const u8, stdin_fd: fd_t, out_fd: fd_t) error{ForkFailed}!std.posix.pid_t` — the child becomes a session leader, dup2s `stdin_fd` to 0 and `out_fd` to 1 and 2, execs `exe`, and exits 127 without atexit if the exec fails. Returns the child's pid to the parent only.
455
456 - [ ] **Step 1: Write the failing test in `server_os.zig`**
457
458 ```zig
459 test "server_os.forkDetached: the child is a session leader writing to the fd it was given" {
460 // Asked of the OS: the child prints its own session id and pid; a
461 // detached daemon is its own session leader, so they are equal.
462 const pipe = try std.posix.pipe();
463 defer std.posix.close(pipe[0]);
464 const devnull = try std.fs.cwd().openFile("/dev/null", .{});
465 defer devnull.close();
466 const argv = [_:null]?[*:0]const u8{ "/bin/sh", "-c", "ps -o sid= -p $$ | tr -d ' '; echo $$" };
467 const pid = try forkDetached("/bin/sh", &argv, devnull.handle, pipe[1]);
468 std.posix.close(pipe[1]);
469 var buf: [64]u8 = undefined;
470 var n: usize = 0;
471 while (true) {
472 const got = try std.posix.read(pipe[0], buf[n..]);
473 if (got == 0) break;
474 n += got;
475 }
476 _ = std.posix.waitpid(pid, 0);
477 var lines = std.mem.tokenizeScalar(u8, buf[0..n], '\n');
478 const sid = lines.next() orelse return error.NoOutput;
479 const shpid = lines.next() orelse return error.NoOutput;
480 try std.testing.expectEqualStrings(shpid, sid);
481 try std.testing.expectEqual(pid, try std.fmt.parseInt(std.posix.pid_t, shpid, 10));
482 }
483 ```
484
485 - [ ] **Step 2: Run to verify it fails**
486
487 Run: `deps/zig/zig build test -Dtest-filter="forkDetached" 2>&1 | tail -3`
488 Expected: compile error, `forkDetached` not found.
489
490 - [ ] **Step 3: Root and arm**
491
492 Root:
493
494 ```zig
495 /// The repository's ONE fork that is not a pty: `mux d start -d`. The child
496 /// becomes a session leader, wires stdin to `stdin_fd` and both stdout and
497 /// stderr to `out_fd`, and execs `exe` with `argv` — a fresh image, because
498 /// `std.debug.MemoryAccessor` caches the pid it reads memory through and a
499 /// Debug child that kept running would inspect the parent and panic
500 /// (decisions.md, 2026-08-28). A failed exec exits 127 with no atexit.
501 /// Returns the child's pid; the parent decides how long to wait for it.
502 pub fn forkDetached(
503 exe: [*:0]const u8,
504 argv: [*:null]const ?[*:0]const u8,
505 stdin_fd: std.posix.fd_t,
506 out_fd: std.posix.fd_t,
507 ) error{ForkFailed}!std.posix.pid_t {
508 return impl.forkDetached(exe, argv, stdin_fd, out_fd);
509 }
510 ```
511
512 Linux arm:
513
514 ```zig
515 pub fn forkDetached(
516 exe: [*:0]const u8,
517 argv: [*:null]const ?[*:0]const u8,
518 stdin_fd: std.posix.fd_t,
519 out_fd: std.posix.fd_t,
520 ) error{ForkFailed}!std.posix.pid_t {
521 const pid = std.posix.fork() catch return error.ForkFailed;
522 if (pid != 0) return pid;
523 _ = std.os.linux.setsid();
524 std.posix.dup2(stdin_fd, std.posix.STDIN_FILENO) catch exitNow(127);
525 std.posix.dup2(out_fd, std.posix.STDOUT_FILENO) catch exitNow(127);
526 std.posix.dup2(out_fd, std.posix.STDERR_FILENO) catch exitNow(127);
527 std.posix.execveZ(exe, argv, std.c.environ) catch exitNow(127);
528 unreachable;
529 }
530 ```
531
532 - [ ] **Step 4: Run the test**
533
534 Run: `deps/zig/zig build test -Dtest-filter="forkDetached" 2>&1 | tail -3`
535 Expected: pass.
536
537 - [ ] **Step 5: Rewire `forkDaemon`**
538
539 In `src/cli/main.zig`, add `const server_os = @import("server_os");` near the other imports. Replace from `const pid = std.posix.fork() catch {` through the end of the `if (pid == 0) { ... }` block with:
540
541 ```zig
542 const pid = server_os.forkDetached(exe_z.ptr, argv.ptr, devnull.handle, log.handle) catch {
543 if (progress.tty) progress.emit("\n");
544 return error.SpawnFailed;
545 };
546 ```
547
548 Keep the comment above it that says daemon-mode code owns the fork path, rewritten: "The fork itself is `server_os.forkDetached`; this function owns what goes INTO it — the log, the argv and the deadline — and folder rule 6 names that file as the one fork."
549
550 `build.zig`: rule 6 `.except = "src/os/server_os_linux.zig"`; row `mux` gains `"server_os"` in `imports`. CLAUDE.md: the invariant paragraph "The daemon starts itself, and it execs THIS image" says `main.forkDaemon` is the only `posix.fork`; change to "`server_os_linux.forkDetached` is the only `posix.fork` under `src/`, and `main.forkDaemon` is its one caller".
551
552 - [ ] **Step 6: Check and the boot leg**
553
554 Run: `make check > /tmp/c.log 2>&1; echo rc=$?; tail -3 /tmp/c.log` → rc=0.
555 Run: `E2E_ONLY=01_boot make e2e > /tmp/e.log 2>&1; echo rc=$?; tail -3 /tmp/e.log` → rc=0 (the `-d` start, `comm` and `exe` pins).
556
557 - [ ] **Step 7: Commit**
558
559 ```bash
560 git add src/os src/cli/main.zig build.zig CLAUDE.md
561 git commit -m "refactor: the daemon fork is server_os.forkDetached, and rule 6 names it"
562 ```
563
564 ---
565
566 ### Task 4: Peer credentials and the parent walk
567
568 **Files:**
569 - Modify: `src/os/server_os.zig`, `src/os/server_os_linux.zig`, `src/os/client_os.zig`, `src/os/client_os_linux.zig`
570 - Modify: `src/cli/main.zig:693-701` (`peerPid`) and its test at ~2021
571 - Modify: `src/client/askpass.zig:150-156, 298-304, 480-485, 509-527, 730-736`
572 - Modify: `build.zig` grant `client_os` to row `client`
573
574 **Interfaces:**
575 - Produces: `server_os.PeerCred = struct { uid: std.posix.uid_t, pid: std.posix.pid_t }`, `server_os.peerCred(fd) ?PeerCred`; `client_os.PeerCred` (same shape), `client_os.peerCred(fd) ?PeerCred`, `client_os.parentOf(pid) std.posix.pid_t` (0 for unknown or ≤0), `client_os.geteuid() std.posix.uid_t`.
576
577 - [ ] **Step 1: Failing tests**
578
579 In `server_os.zig`:
580
581 ```zig
582 test "server_os.peerCred: the kernel names the peer of a socketpair as this process" {
583 var sp: [2]std.posix.fd_t = undefined;
584 try std.testing.expectEqual(@as(c_int, 0), std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &sp));
585 defer std.posix.close(sp[0]);
586 defer std.posix.close(sp[1]);
587 const cred = peerCred(sp[0]) orelse return error.NoCred;
588 try std.testing.expectEqual(getpid(), cred.pid);
589 try std.testing.expectEqual(std.c.geteuid(), cred.uid);
590 }
591 ```
592
593 In `client_os.zig`:
594
595 ```zig
596 test "client_os.peerCred and parentOf: asked of the OS, not a fixture" {
597 var sp: [2]std.posix.fd_t = undefined;
598 try std.testing.expectEqual(@as(c_int, 0), std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &sp));
599 defer std.posix.close(sp[0]);
600 defer std.posix.close(sp[1]);
601 const cred = peerCred(sp[0]) orelse return error.NoCred;
602 try std.testing.expectEqual(getpid(), cred.pid);
603 try std.testing.expectEqual(geteuid(), cred.uid);
604 try std.testing.expectEqual(std.c.getppid(), parentOf(getpid()));
605 try std.testing.expectEqual(@as(std.posix.pid_t, 0), parentOf(0));
606 }
607 ```
608
609 - [ ] **Step 2: Verify they fail** — `deps/zig/zig build test -Dtest-filter="peerCred" 2>&1 | tail -3`, expect compile errors.
610
611 - [ ] **Step 3: Roots**
612
613 `server_os.zig`:
614
615 ```zig
616 /// Who is on the other end of a unix socket, or null when the kernel will
617 /// not say (across a pid namespace, for one); callers then rely on socket
618 /// shutdown. The daemon uses the pid to wait for a client that vanished.
619 pub const PeerCred = struct { uid: std.posix.uid_t, pid: std.posix.pid_t };
620 pub fn peerCred(fd: std.posix.socket_t) ?PeerCred {
621 return impl.peerCred(fd);
622 }
623 ```
624
625 `client_os.zig`:
626
627 ```zig
628 /// Who is on the other end of the askpass socket. The 0700 runtime
629 /// directory is the boundary and mux takes it as found; where it is not
630 /// private, the uid here is what stops another local user raising a prompt
631 /// and reading the answer, and the pid is what attributes a prompt to the
632 /// ssh THIS wall spawned.
633 pub const PeerCred = struct { uid: std.posix.uid_t, pid: std.posix.pid_t };
634 pub fn peerCred(fd: std.posix.socket_t) ?PeerCred {
635 return impl.peerCred(fd);
636 }
637
638 /// The parent of `pid`, or 0 when the OS will not say or `pid` is not
639 /// positive. One step of the walk from an askpass helper up to the ssh a
640 /// dial spawned.
641 pub fn parentOf(pid: std.posix.pid_t) std.posix.pid_t {
642 if (pid <= 0) return 0;
643 return impl.parentOf(pid);
644 }
645
646 /// The effective uid, for the askpass caller check above.
647 pub fn geteuid() std.posix.uid_t {
648 return impl.geteuid();
649 }
650 ```
651
652 - [ ] **Step 4: Linux arms**
653
654 `server_os_linux.zig`:
655
656 ```zig
657 pub fn peerCred(fd: std.posix.socket_t) ?root.PeerCred {
658 const Ucred = extern struct { pid: std.posix.pid_t, uid: std.posix.uid_t, gid: std.posix.gid_t };
659 var cred: Ucred = undefined;
660 std.posix.getsockopt(fd, std.posix.SOL.SOCKET, std.posix.SO.PEERCRED, std.mem.asBytes(&cred)) catch return null;
661 if (cred.pid <= 0) return null;
662 return .{ .uid = cred.uid, .pid = cred.pid };
663 }
664 ```
665
666 `client_os_linux.zig` (add `const root = @import("client_os.zig");`):
667
668 ```zig
669 pub fn peerCred(fd: std.posix.socket_t) ?root.PeerCred {
670 const Ucred = extern struct { pid: std.posix.pid_t, uid: std.posix.uid_t, gid: std.posix.gid_t };
671 var cred: Ucred = undefined;
672 std.posix.getsockopt(fd, std.posix.SOL.SOCKET, std.posix.SO.PEERCRED, std.mem.asBytes(&cred)) catch return null;
673 return .{ .uid = cred.uid, .pid = cred.pid };
674 }
675
676 /// `/proc/<pid>/stat` field 4. Parsed from the LAST ')' rather than by
677 /// counting spaces: field 2 is the executable's name, unquoted, and a
678 /// program free to call itself `a b) c` is a program free to move every
679 /// field after it.
680 pub fn parentOf(pid: std.posix.pid_t) std.posix.pid_t {
681 var path_buf: [64]u8 = undefined;
682 const path = std.fmt.bufPrint(&path_buf, "/proc/{d}/stat", .{pid}) catch return 0;
683 var stat_buf: [512]u8 = undefined;
684 const f = std.fs.cwd().openFile(path, .{}) catch return 0;
685 defer f.close();
686 const n = f.read(&stat_buf) catch return 0;
687 const text = stat_buf[0..n];
688 const close = std.mem.lastIndexOfScalar(u8, text, ')') orelse return 0;
689 var it = std.mem.tokenizeScalar(u8, text[close + 1 ..], ' ');
690 _ = it.next() orelse return 0; // the run state
691 const ppid = it.next() orelse return 0;
692 return std.fmt.parseInt(std.posix.pid_t, ppid, 10) catch 0;
693 }
694
695 pub fn geteuid() std.posix.uid_t {
696 return std.os.linux.geteuid();
697 }
698 ```
699
700 - [ ] **Step 5: Tests pass** — `deps/zig/zig build test -Dtest-filter="peerCred" 2>&1 | tail -3`.
701
702 - [ ] **Step 6: Rewire callers**
703
704 `main.zig` `peerPid`:
705
706 ```zig
707 /// The peer's pid, or null when the kernel cannot expose it; callers then
708 /// rely on socket shutdown.
709 fn peerPid(fd: std.posix.socket_t) ?std.posix.pid_t {
710 const cred = server_os.peerCred(fd) orelse return null;
711 return cred.pid;
712 }
713 ```
714
715 Its test: `std.os.linux.socketpair(...)` → `std.c.socketpair(...)` with `expectEqual(@as(c_int, 0), ...)`, and `std.os.linux.getpid()` → `server_os.getpid()`.
716
717 `askpass.zig`: add `const client_os = @import("client_os");`. Delete the local `Ucred` type (grep `const Ucred`), `peerCred` and `parentOf`; in `start` the path uses `client_os.getpid()`; in `serve`:
718
719 ```zig
720 const cred = client_os.peerCred(c) orelse return;
721 if (cred.uid != client_os.geteuid()) return;
722 var p: Prompt = .{ .ssh_pid = dialOwner(cred.pid, client_os.getpid(), client_os.parentOf) };
723 ```
724
725 The comment on `serve`'s uid line ("The 0700 runtime directory is the boundary…") moves to `client_os.peerCred`'s doc (it is there already); leave one line: "// Both checks are `client_os.peerCred`'s to explain."
726
727 The test "askpass.parentOf: the field it reads is the one the OS calls ppid" moves to `client_os.zig` (it is Step 1's test); delete it here. Any remaining `std.os.linux.getppid`/`getpid`/`geteuid` in askpass tests → `std.c.*`.
728
729 `build.zig`: row `client` gets `"client_os"` in `imports`.
730
731 - [ ] **Step 7: Check and the askpass leg**
732
733 `make check` rc=0; `E2E_ONLY=15_askpass make e2e` rc=0.
734
735 - [ ] **Step 8: Commit**
736
737 ```bash
738 git add src/os src/cli/main.zig src/client/askpass.zig build.zig
739 git commit -m "refactor: peer credentials and the parent walk go through the os rows"
740 ```
741
742 ---
743
744 ### Task 5: The upgrade manifest carrier is `server_os.anonFd`
745
746 **Files:**
747 - Modify: `src/os/server_os.zig`, `src/os/server_os_linux.zig`
748 - Modify: `src/server/server.zig:514-524` (`PendingUpgrade`), `:2258-2274`, `:2980-2994`, `:3008-3011` (comment), and every `memfd` in `server.zig` (grep)
749 - Modify: `src/server/server_test_upgrade.zig` (8 sites, grep `memfd_create`), `src/server/upgrade.zig` comments (grep `memfd`)
750 - Modify: `build.zig` grant `server_os` to row `daemon`
751
752 **Interfaces:**
753 - Produces: `server_os.anonFd(name: [*:0]const u8) error{CarrierFailed}!std.posix.fd_t` — an fd no path names once it returns, private to this uid, readable and writable, that survives an exec of this process (no CLOEXEC).
754
755 - [ ] **Step 1: Failing test**
756
757 ```zig
758 test "server_os.anonFd: no path names it, and it is not CLOEXEC" {
759 const fd = try anonFd("mux-test-carrier");
760 defer std.posix.close(fd);
761 const st = try std.posix.fstat(fd);
762 try std.testing.expectEqual(@as(@TypeOf(st.nlink), 0), st.nlink);
763 const flags = try std.posix.fcntl(fd, std.posix.F.GETFD, 0);
764 try std.testing.expectEqual(@as(usize, 0), flags & std.posix.FD_CLOEXEC);
765 try std.posix.lseek_SET(fd, 0);
766 _ = try std.posix.write(fd, "abc");
767 try std.posix.lseek_SET(fd, 0);
768 var buf: [3]u8 = undefined;
769 try std.testing.expectEqual(@as(usize, 3), try std.posix.read(fd, &buf));
770 try std.testing.expectEqualStrings("abc", &buf);
771 }
772 ```
773
774 - [ ] **Step 2: Fails** — `deps/zig/zig build test -Dtest-filter="anonFd" 2>&1 | tail -3`.
775
776 - [ ] **Step 3: Root and arm**
777
778 Root:
779
780 ```zig
781 /// The upgrade manifest's carrier across `mux d upgrade`'s exec: an fd that
782 /// no path names once this returns, readable only by this uid, and NOT
783 /// CLOEXEC because the candidate must inherit it. It carries the QUIC arm's
784 /// raw key bytes, which is why "no path" is the property and not a nicety —
785 /// and why `closeFrom` seals it away from every session shell.
786 pub fn anonFd(name: [*:0]const u8) error{CarrierFailed}!std.posix.fd_t {
787 return impl.anonFd(name);
788 }
789 ```
790
791 Linux arm:
792
793 ```zig
794 pub fn anonFd(name: [*:0]const u8) error{CarrierFailed}!std.posix.fd_t {
795 return std.posix.memfd_create(std.mem.span(name), 0) catch error.CarrierFailed;
796 }
797 ```
798
799 - [ ] **Step 4: Passes** — same filter.
800
801 - [ ] **Step 5: Rewire the daemon**
802
803 `server.zig`: add `const server_os = @import("server_os");`. `PendingUpgrade`:
804
805 ```zig
806 // What the run loop needs to exec: the candidate's path and the carrier
807 // holding the manifest (`server_os.anonFd`). Set by validateUpgrade +
808 // writeManifestTo.
809 const PendingUpgrade = struct {
810 path: []const u8,
811 carrier: std.posix.fd_t,
812 };
813 ```
814
815 Rename every `.memfd` field use to `.carrier` (grep `\.memfd`). At the `upgrade_req` site:
816
817 ```zig
818 // Accepted: write the manifest to its carrier (not CLOEXEC —
819 // the new binary must inherit it), reply, and arm the exec.
820 const carrier = server_os.anonFd("mux-upgrade") catch return self.refuseUpgrade(i, "carrier");
821 self.writeManifestTo(carrier, self.version) catch {
822 std.posix.close(carrier);
823 return self.refuseUpgrade(i, "manifest");
824 };
825 ```
826
827 and rename the local through the rest of that handler. In `checkManifestResume`:
828
829 ```zig
830 const carrier = server_os.anonFd("mux-upgrade") catch
831 return a.dupe(u8, "check: cannot create the manifest carrier") catch null;
832 defer std.posix.close(carrier);
833 self.writeManifestTo(carrier, my_version) catch
834 ```
835
836 and the `fd_str` line uses `carrier`. The `clearCloexec` comment: "the manifest memfd" → "the manifest carrier". Run `grep -n 'memfd' src/server/server.zig src/server/upgrade.zig src/server/server_*.zig` and reword every production-line comment hit to "carrier"; in tests replace `std.posix.memfd_create("...", 0)` with `server_os.anonFd("...")` (the child files import through `daemon`'s grant: `const server_os = @import("server_os");`).
837
838 `build.zig`: row `daemon` gets `"server_os"`.
839
840 - [ ] **Step 6: Check, the upgrade unit tests and the upgrade leg**
841
842 `make check` rc=0. `E2E_ONLY=14_upgrade make e2e` rc=0. (Its `memfd:mux-upgrade` fd-leak pin still greps `/proc` until Task 11; it must still pass now because the Linux carrier IS a memfd.)
843
844 - [ ] **Step 7: Commit**
845
846 ```bash
847 git add src/os src/server build.zig
848 git commit -m "refactor: the upgrade manifest rides server_os.anonFd"
849 ```
850
851 ---
852
853 ### Task 6: `selfImageStale` by identity, not by a kernel suffix
854
855 **Files:**
856 - Modify: `src/os/server_os.zig` (root-only implementation), `src/os/spawn.zig`
857 - Modify: `src/server/server.zig:3190-3208` (`selfImageStale` and its comment), the `Server.init` site (grep `pub fn init(`) to call `server_os.noteBootImage()`
858
859 **Interfaces:**
860 - Produces: `server_os.noteBootImage() void` (idempotent; records dev+ino of the running image), `server_os.selfImageStale() bool`.
861
862 - [ ] **Step 1: Failing test**
863
864 ```zig
865 test "server_os.selfImageStale: a rename over the image's path is stale, an untouched path is not" {
866 // The test binary cannot be renamed under itself safely, so the rule is
867 // exercised on a copy in a temp dir through the same two functions with
868 // the path named explicitly.
869 var tmp = std.testing.tmpDir(.{});
870 defer tmp.cleanup();
871 try tmp.dir.writeFile(.{ .sub_path = "img", .data = "v1" });
872 var pbuf: [std.fs.max_path_bytes]u8 = undefined;
873 const path = try tmp.dir.realpath("img", &pbuf);
874 var ident = try imageIdent(path);
875 try std.testing.expect(!staleAgainst(ident, path));
876 try tmp.dir.writeFile(.{ .sub_path = "img.new", .data = "v2" });
877 try tmp.dir.rename("img.new", "img");
878 try std.testing.expect(staleAgainst(ident, path));
879 ident = try imageIdent(path);
880 try std.testing.expect(!staleAgainst(ident, path));
881 try tmp.dir.deleteFile("img");
882 try std.testing.expect(staleAgainst(ident, path));
883 }
884 ```
885
886 - [ ] **Step 2: Fails** — filter `selfImageStale`.
887
888 - [ ] **Step 3: Root implementation (no arm: it is the same rule on every OS)**
889
890 ```zig
891 /// Identity of a file: the pair a rename-over changes and a rebuild in
892 /// place does not.
893 const ImageIdent = struct { dev: u64, ino: u64 };
894
895 fn imageIdent(path: []const u8) !ImageIdent {
896 const st = try std.fs.cwd().statFile(path);
897 return .{ .dev = 0, .ino = st.inode };
898 }
899
900 /// Stale when the path now names a different inode than `at_boot`, or
901 /// nothing at all: `make install` and `mux d upgrade HOST` both rename a
902 /// new file over the running image, and the daemon keeps executing the
903 /// old one. Unknown is reported not-stale — a wall must not dress a
904 /// healthy box in a warning because a stat was refused.
905 fn staleAgainst(at_boot: ImageIdent, path: []const u8) bool {
906 const now = imageIdent(path) catch return true;
907 return now.ino != at_boot.ino;
908 }
909
910 var boot_image: ?struct { ident: ImageIdent, path: [std.fs.max_path_bytes]u8, len: usize } = null;
911
912 /// Record the running image's identity. Called once at daemon start; a
913 /// later call is a no-op, so the comparison is always against boot.
914 pub fn noteBootImage() void {
915 if (boot_image != null) return;
916 var buf: [std.fs.max_path_bytes]u8 = undefined;
917 const p = std.fs.selfExePath(&buf) catch return;
918 const ident = imageIdent(p) catch return;
919 var rec: @TypeOf(boot_image.?) = .{ .ident = ident, .path = undefined, .len = p.len };
920 @memcpy(rec.path[0..p.len], p);
921 boot_image = rec;
922 }
923
924 /// Has the file at the running image's path been replaced since boot.
925 /// Read fresh per ask: a rename lands under a running daemon at any moment,
926 /// and one stat per `sessions_req` is nothing.
927 pub fn selfImageStale() bool {
928 noteBootImage();
929 const b = boot_image orelse return false;
930 return staleAgainst(b.ident, b.path[0..b.len]);
931 }
932 ```
933
934 Note `std.fs.File.Stat` carries `inode` and not `dev` in 0.15.2; the `dev` field stays zero and the comparison is by inode, which a rename-over on one filesystem always changes. If `statFile` returns a `dev` in this std, fill and compare it too.
935
936 - [ ] **Step 4: Passes** — filter `selfImageStale`.
937
938 - [ ] **Step 5: Rewire the daemon and `spawn`**
939
940 `server.zig`: delete the private `selfImageStale` and its comment block; every call becomes `server_os.selfImageStale()`. In `Server.init` (the daemon's constructor), add `server_os.noteBootImage();` as the first statement, with the comment "// Before any request can ask: the comparison is against the image that BOOTED, not the first one asked about."
941
942 `src/os/spawn.zig`: the `/proc/self/exe` fallback becomes per-OS:
943
944 ```zig
945 /// The kernel's link to the running image, used only when the resolved
946 /// path is no longer executable — Linux keeps a live link after a rename-
947 /// over. On an OS with no such link the fallback is the resolved path
948 /// itself, and an exec of a replaced image fails where it always would.
949 pub const self_exe: []const u8 = switch (builtin.os.tag) {
950 .linux => "/proc/self/exe",
951 else => "",
952 };
953 ```
954
955 with `const builtin = @import("builtin");`, and `execOrLink` returns `if (self_exe.len == 0) resolved else self_exe` in the fallback arm. The two tests keep their assertions; the second one wraps its `(deleted)` expectation in `if (builtin.os.tag == .linux)`.
956
957 - [ ] **Step 6: Check and the hosts leg (the `stale` word)**
958
959 `make check` rc=0. `E2E_ONLY=09_hosts make e2e` rc=0 — its stale pin at ~1657 renames a binary over the daemon's and reads `stale` off the wall.
960
961 - [ ] **Step 7: Commit**
962
963 ```bash
964 git add src/os src/server/server.zig
965 git commit -m "refactor: a stale daemon image is an inode that moved, not a kernel suffix"
966 ```
967
968 ---
969
970 ### Task 7: The mechanical spellings — `sendNoSig`, `sockType`, `getpid`, `geteuid`, `socketpair`, `ioctl`
971
972 **Files:**
973 - Modify: `src/os/server_os.zig`, `src/os/server_os_linux.zig`, `src/os/client_os.zig`, `src/os/client_os_linux.zig`
974 - Modify: `src/server/server.zig:177-186` (`Sink.send`), `src/server/quic_server.zig:318-338`, `src/server/server_agent.zig:131-136`, `src/server/shellint.zig:175-179,444-448`, `src/server/server_test_await.zig:372-376`, `src/server/server_test_harness.zig:221-227`
975 - Modify: `src/cli/webhub_main.zig:174-178`, `src/cli/muxa.zig:648-707` (three socketpairs), `src/cli/main.zig:2100-2108`
976 - Modify: `src/dial.zig:136-142`, `src/link.zig:305-310`, `src/engine/protocol.zig:2570-2576`, `src/xdg.zig:412-418`, `src/sockpath.zig:172-178`
977 - Modify: `src/tui/interact.zig:628-640` (`ttySize`), `:3626-3645` (`ptsPair`, `setTtySize`)
978 - Modify: `build.zig` grant `client_os` to row `wall`
979
980 **Interfaces:**
981 - Produces: `server_os.sendNoSig(fd, bytes) std.posix.SendError!usize` (non-blocking, never raises SIGPIPE); `server_os.sockType(fd) error{NotASocket}!u32`; `client_os.winSize(fd) ?std.posix.winsize`; `client_os.setWinSize(fd, ws) error{Unsupported}!void`; `client_os.openPtyPair() error{Unsupported}!struct { master: fd_t, slave: fd_t }`.
982 - Every test-only `std.os.linux.socketpair` becomes `std.c.socketpair`; every `std.os.linux.getpid/geteuid/getppid` becomes the row's function where a row is imported and `std.c.*` in tests.
983
984 - [ ] **Step 1: Failing tests**
985
986 `server_os.zig`:
987
988 ```zig
989 test "server_os.sendNoSig: a closed peer is an error, not a signal" {
990 var sp: [2]std.posix.fd_t = undefined;
991 try std.testing.expectEqual(@as(c_int, 0), std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &sp));
992 defer std.posix.close(sp[0]);
993 std.posix.close(sp[1]);
994 // With SIGPIPE at its default this send would kill the test binary.
995 try std.testing.expectError(error.BrokenPipe, sendNoSig(sp[0], "x"));
996 try std.testing.expectEqual(@as(u32, std.posix.SOCK.STREAM), try sockType(sp[0]));
997 }
998 ```
999
1000 `client_os.zig`:
1001
1002 ```zig
1003 test "client_os.winSize reads what setWinSize wrote, off a real pty" {
1004 const p = try openPtyPair();
1005 defer std.posix.close(p.master);
1006 defer std.posix.close(p.slave);
1007 try setWinSize(p.master, .{ .row = 17, .col = 91, .xpixel = 0, .ypixel = 0 });
1008 const ws = winSize(p.slave) orelse return error.NoSize;
1009 try std.testing.expectEqual(@as(u16, 91), ws.col);
1010 try std.testing.expectEqual(@as(u16, 17), ws.row);
1011 }
1012 ```
1013
1014 - [ ] **Step 2: Fail** — filters `sendNoSig`, `winSize`.
1015
1016 - [ ] **Step 3: Roots**
1017
1018 `server_os.zig`:
1019
1020 ```zig
1021 /// A non-blocking send that cannot raise SIGPIPE: a client that hung up
1022 /// mid-frame is an error the pump handles, never a signal that ends the
1023 /// daemon. The daemon also ignores SIGPIPE process-wide; this is the half
1024 /// that does not depend on the order of that ignore against a fork.
1025 pub fn sendNoSig(fd: std.posix.socket_t, bytes: []const u8) std.posix.SendError!usize {
1026 return impl.sendNoSig(fd, bytes);
1027 }
1028
1029 /// The socket type of an fd, for refusing to adopt a stream fd as the
1030 /// QUIC listener across an upgrade: a stream fd would accept a handshake
1031 /// and then lose every packet to recvfrom.
1032 pub fn sockType(fd: std.posix.fd_t) error{NotASocket}!u32 {
1033 return impl.sockType(fd);
1034 }
1035 ```
1036
1037 `client_os.zig`:
1038
1039 ```zig
1040 /// This terminal's size, or null when `fd` is not a terminal. The 0x0 case
1041 /// and the daemon's floor are the caller's to judge (`interact.ttySize`).
1042 pub fn winSize(fd: std.posix.fd_t) ?std.posix.winsize {
1043 return impl.winSize(fd);
1044 }
1045
1046 /// Size a pty. Test-only in practice, but a contract because the wall's
1047 /// own `ttySize` is judged against it.
1048 pub fn setWinSize(fd: std.posix.fd_t, ws: std.posix.winsize) error{Unsupported}!void {
1049 return impl.setWinSize(fd, ws);
1050 }
1051
1052 /// A real master/slave pty pair, the OS answering about the OS. Test-only:
1053 /// the wall never opens a pty, it lives in one.
1054 pub fn openPtyPair() error{Unsupported}!struct { master: std.posix.fd_t, slave: std.posix.fd_t } {
1055 return impl.openPtyPair();
1056 }
1057 ```
1058
1059 - [ ] **Step 4: Linux arms**
1060
1061 `server_os_linux.zig`:
1062
1063 ```zig
1064 pub fn sendNoSig(fd: std.posix.socket_t, bytes: []const u8) std.posix.SendError!usize {
1065 return std.posix.send(fd, bytes, std.posix.MSG.DONTWAIT | std.posix.MSG.NOSIGNAL);
1066 }
1067
1068 pub fn sockType(fd: std.posix.fd_t) error{NotASocket}!u32 {
1069 var t: i32 = undefined;
1070 var len: std.posix.socklen_t = @sizeOf(@TypeOf(t));
1071 const rc = std.os.linux.getsockopt(fd, std.os.linux.SOL.SOCKET, std.os.linux.SO.TYPE, @ptrCast(&t), &len);
1072 if (std.os.linux.E.init(rc) != .SUCCESS) return error.NotASocket;
1073 return @intCast(t);
1074 }
1075 ```
1076
1077 `client_os_linux.zig`:
1078
1079 ```zig
1080 pub fn winSize(fd: std.posix.fd_t) ?std.posix.winsize {
1081 var ws: std.posix.winsize = undefined;
1082 if (std.os.linux.ioctl(fd, std.os.linux.T.IOCGWINSZ, @intFromPtr(&ws)) != 0) return null;
1083 return ws;
1084 }
1085
1086 pub fn setWinSize(fd: std.posix.fd_t, ws: std.posix.winsize) error{Unsupported}!void {
1087 if (std.os.linux.ioctl(fd, std.os.linux.T.IOCSWINSZ, @intFromPtr(&ws)) != 0) return error.Unsupported;
1088 }
1089
1090 pub fn openPtyPair() error{Unsupported}!struct { master: std.posix.fd_t, slave: std.posix.fd_t } {
1091 const master = std.posix.open("/dev/ptmx", .{ .ACCMODE = .RDWR }, 0) catch return error.Unsupported;
1092 errdefer std.posix.close(master);
1093 var unlock: c_int = 0;
1094 if (std.os.linux.ioctl(master, std.os.linux.T.IOCSPTLCK, @intFromPtr(&unlock)) != 0) return error.Unsupported;
1095 var idx: c_uint = 0;
1096 if (std.os.linux.ioctl(master, std.os.linux.T.IOCGPTN, @intFromPtr(&idx)) != 0) return error.Unsupported;
1097 var name_buf: [32]u8 = undefined;
1098 const name = std.fmt.bufPrint(&name_buf, "/dev/pts/{d}", .{idx}) catch return error.Unsupported;
1099 const slave = std.posix.open(name, .{ .ACCMODE = .RDWR, .NOCTTY = true }, 0) catch return error.Unsupported;
1100 return .{ .master = master, .slave = slave };
1101 }
1102 ```
1103
1104 - [ ] **Step 5: Pass** — both filters.
1105
1106 - [ ] **Step 6: Rewire every site**
1107
1108 - `server.zig` `Sink.send`: `.socket => |fd| server_os.sendNoSig(fd, bytes),`.
1109 - `quic_server.zig` `initFromFd`: replace the `sock_type` block with `if ((server_os.sockType(fd) catch return error.NotAUdpSocket) != std.posix.SOCK.DGRAM) return error.NotAUdpSocket;` (add the import; it is a child of `daemon`).
1110 - `server_agent.zig`, `shellint.zig` (both sites), `server_test_await.zig`: `std.os.linux.getpid()` → `server_os.getpid()`.
1111 - `server_test_harness.zig` `connectedPair`: `std.c.socketpair(...)`, and `if (rc != 0) return std.posix.unexpectedErrno(std.posix.errno(rc));`.
1112 - `webhub_main.zig`: `client_os.getpid()` — `webhub_main` is a child of `mux`, which imports `client`; import `client_os` through the `mux` row: add `"client_os"` to row `mux`'s imports.
1113 - `muxa.zig` three sites, `main.zig:2025`, `dial.zig:139`, `link.zig:308`, `protocol.zig:2573`: `std.os.linux.socketpair` → `std.c.socketpair`, expectations `@as(c_int, 0)`. muxa's comment "`std.os.linux` because `std.posix` has no socketpair on 0.15.2" → "`std.c` because `std.posix` has no socketpair on 0.15.2".
1114 - `main.zig:2104`, `sockpath.zig:175`: `std.os.linux.geteuid()` → `std.c.geteuid()`. `xdg.zig:415`: `std.c.getpid()`.
1115 - `interact.zig` `ttySize`: the ioctl lines become `const ws = client_os.winSize(fd) orelse return null;` (keep the `isatty` guard and the floor). `ptsPair` body: `return client_os.openPtyPair();`. `setTtySize`: `return client_os.setWinSize(master, .{ .col = cols, .row = rows, .xpixel = 0, .ypixel = 0 });`. Add the import; `build.zig` row `wall` gets `"client_os"`.
1116
1117 - [ ] **Step 7: The ban is now satisfiable — prove it by grep**
1118
1119 Run: `grep -rn 'std\.os\.linux' src --include=*.zig | grep -v '^src/os/'`
1120 Expected: no output. If anything remains, respell it as above.
1121
1122 - [ ] **Step 8: Check and the two legs that watch sockets**
1123
1124 `make check` rc=0. `E2E_ONLY=06_web make e2e` rc=0; `E2E_ONLY=10_agent make e2e` rc=0.
1125
1126 - [ ] **Step 9: Commit**
1127
1128 ```bash
1129 git add src build.zig
1130 git commit -m "refactor: every remaining std.os.linux spelling goes through an os row or std.c"
1131 ```
1132
1133 ---
1134
1135 ### Task 8: The shared root — `reapDeadPid`, `max_sun_path`, `runtimeDir`
1136
1137 **Files:**
1138 - Modify: `src/xdg.zig:178-184`, `src/sockpath.zig:8-12, 44-55`, `src/tui/wallview.zig:1866-1870` (grep `XDG_RUNTIME_DIR`), `src/cli/muxa.zig:966` (grep `XDG_RUNTIME_DIR`)
1139
1140 **Interfaces:**
1141 - Produces: `sockpath.runtimeDir() ?[]const u8` — the directory the default socket and the askpass socket live in, or null with no fallback on Linux.
1142
1143 - [ ] **Step 1: Failing tests**
1144
1145 `sockpath.zig`, beside `sockPathFrom`'s tests:
1146
1147 ```zig
1148 test "sockpath.max_sun_path is the kernel's field less its NUL, not a number of ours" {
1149 try std.testing.expectEqual(@sizeOf(@FieldType(std.posix.sockaddr.un, "path")) - 1, max_sun_path);
1150 }
1151 ```
1152
1153 `xdg.zig`, extend the existing `reapDeadPid` test (grep `dead_dir`): after the reap, assert the `ours` entry is still present and add
1154
1155 ```zig
1156 // A pid that is alive but is not a mux — pid 1 — keeps its entry. The
1157 // liveness question is `kill(pid, 0)`, which answers for every process
1158 // this uid may signal and EPERM for the ones it may not; both are alive.
1159 var b4: [48]u8 = undefined;
1160 const init_dir = try std.fmt.bufPrint(&b4, "mux-agent-{d}-abc", .{@as(u32, 1)});
1161 try tmp.dir.makePath(init_dir);
1162 reapDeadPid(tmp.dir, "mux-agent-");
1163 try tmp.dir.access(init_dir, .{});
1164 ```
1165
1166 (match the existing test's calling convention for `reapDeadPid`; grep the signature).
1167
1168 - [ ] **Step 2: Fail** — the `max_sun_path` test fails to compile if the constant is still a literal typed `comptime_int` equal to 107? It passes trivially on Linux (108−1). Make the test meaningful: assert `max_sun_path == 107` only under `if (builtin.os.tag == .linux)` AND that the comptime derivation is the source — i.e. after Step 3 the literal is gone. The reap test fails before Step 3 only if `kill(1, 0)` differs from `access("/proc/1")` — it does not. So this task's proof is the grep in Step 5, and the tests are the pin against regression.
1169
1170 - [ ] **Step 3: Respell**
1171
1172 `sockpath.zig`:
1173
1174 ```zig
1175 /// The usable bytes of `sockaddr_un.sun_path`: the field less the NUL.
1176 /// Derived from the kernel's own struct rather than spelled — 108 on
1177 /// Linux, 104 on the BSDs — and private, because every binary that once
1178 /// re-compared it grew its own wording for the same refusal.
1179 const max_sun_path = @sizeOf(@FieldType(std.posix.sockaddr.un, "path")) - 1;
1180 ```
1181
1182 ```zig
1183 /// The directory the default daemon socket and every per-wall socket live
1184 /// in, or null. On Linux that is `$XDG_RUNTIME_DIR` and there is NO
1185 /// fallback: a guess cannot make two binaries agree on one daemon, so the
1186 /// caller names it with --sock. Another OS spells its own default here,
1187 /// once, so the daemon, the client and the askpass listener agree by
1188 /// construction.
1189 pub fn runtimeDir() ?[]const u8 {
1190 return switch (builtin.os.tag) {
1191 .linux => std.posix.getenv("XDG_RUNTIME_DIR"),
1192 else => @compileError("mux has no default runtime directory for " ++ @tagName(builtin.os.tag)),
1193 };
1194 }
1195
1196 pub fn defaultSockPath(alloc: std.mem.Allocator) ![]const u8 {
1197 return sockPathFrom(alloc, runtimeDir());
1198 }
1199 ```
1200
1201 (add `const builtin = @import("builtin");`).
1202
1203 `wallview.zig` ~1868: `std.posix.getenv("XDG_RUNTIME_DIR")` → `client.sockpath.runtimeDir()` (or however `sockpath` is reached there; `wall` imports `client`, and `client` imports `sockpath` — if `client.zig` does not re-export it, add `pub const sockpath = @import("sockpath");` beside its other re-exports). `muxa.zig:966` keeps its wording (`mux a` speaks JSON) but reads `sockpath.runtimeDir()`.
1204
1205 `xdg.zig` `reapDeadPid`:
1206
1207 ```zig
1208 const pid = std.fmt.parseInt(u32, rest[0..n], 10) catch continue;
1209 // `kill(pid, 0)`: alive, or alive-but-not-ours (EPERM), keep; ESRCH
1210 // is the one answer that means the pid is gone. A live pid's entry
1211 // stays even when it is no longer a mux.
1212 const alive = if (std.posix.kill(@intCast(pid), 0)) true else |err| err == error.PermissionDenied;
1213 if (alive) continue;
1214 ```
1215
1216 Reword `xdg.zig`'s and CLAUDE.md's "whose pid `/proc` no longer has" to "whose pid the OS no longer has".
1217
1218 - [ ] **Step 4: Pass** — `deps/zig/zig build test -Dtest-filter="sockpath" 2>&1 | tail -3`, `-Dtest-filter="reapDeadPid"`.
1219
1220 - [ ] **Step 5: Grep**
1221
1222 Run: `grep -rn 'XDG_RUNTIME_DIR' src --include=*.zig | grep -v 'sockpath.zig' | grep -v '^\s*//'` — expected: only string literals in user-facing messages (the two refusal sentences), no `getenv`.
1223
1224 - [ ] **Step 6: Check and the askpass leg** — `make check` rc=0; `E2E_ONLY=15_askpass make e2e` rc=0.
1225
1226 - [ ] **Step 7: Commit**
1227
1228 ```bash
1229 git add src/xdg.zig src/sockpath.zig src/tui/wallview.zig src/cli/muxa.zig src/client/client.zig CLAUDE.md
1230 git commit -m "refactor: the shared root asks the OS in portable words, and sockpath owns the runtime dir"
1231 ```
1232
1233 ---
1234
1235 ### Task 9: Folder rule 7, and the comments that would trip it
1236
1237 **Files:**
1238 - Modify: `build.zig` `source_bans` (~line 340-385)
1239 - Modify: every file `zig build check` names
1240 - Modify: `CLAUDE.md` Layout prose (rules paragraph)
1241
1242 - [ ] **Step 1: Add the rule**
1243
1244 After rule 6 in `source_bans`:
1245
1246 ```zig
1247 .{
1248 .rule = "7",
1249 .folders = &.{ "src", "src/engine", "src/client", "src/tui", "src/server", "src/cli" },
1250 // The raw spellings the platform layer exists to hold. `src/os/` is
1251 // absent from the list on purpose: its children may spell anything,
1252 // and its roots have no reason to. Comments count, as they do for
1253 // rule 4 — a comment naming a Linux mechanism is one that goes
1254 // stale the day a second arm exists.
1255 .needles = &.{ "std.os.linux", "/proc", "memfd", "close_range", "exit_group", "peercred", "tiocsptlck", "tiocgptn" },
1256 .why = "a call whose spelling differs by OS belongs in src/os/, behind a " ++
1257 "server_os or client_os operation whose doc names what it guarantees; " ++
1258 "everything else builds for every OS from the same line",
1259 },
1260 ```
1261
1262 (needles are matched lower-cased, hence `peercred`.)
1263
1264 - [ ] **Step 2: Run the gate and fix every hit**
1265
1266 Run: `deps/zig/zig build check 2>&1 | grep 'folder rule 7' | head`
1267 For each `path:line`, reword the comment to name the operation: `/proc` → "the OS" or the helper (`pid_alive`, `client_os.parentOf`); `memfd` → "the manifest carrier (`server_os.anonFd`)"; `exit_group` → "`server_os.exitNow`". Repeat until the command prints nothing. Do not write a `folder rule 7 exemption:` line anywhere; there is no legitimate one yet.
1268
1269 - [ ] **Step 3: Prove the rule fires**
1270
1271 ```bash
1272 printf 'const x = "/proc/self";\n' >> src/xdg.zig
1273 deps/zig/zig build check 2>&1 | grep -c 'folder rule 7 broken'
1274 git checkout -- src/xdg.zig
1275 ```
1276 Expected: `1`, then a clean tree (`git status --short src/xdg.zig` empty).
1277
1278 - [ ] **Step 4: CLAUDE.md**
1279
1280 In the Layout section's rules paragraph, after rule 6's sentence, add: "Rule 7 is the platform ban: `std.os.linux`, `/proc`, `memfd`, `close_range`, `exit_group`, `PEERCRED` and the two Linux-only pty ioctls may appear only under `src/os/`, comments included — the roots `server_os.zig` and `client_os.zig` are the contract and their `_linux` children the spellings (spec 2026-09-03)."
1281
1282 - [ ] **Step 5: `make check`** rc=0.
1283
1284 - [ ] **Step 6: Commit**
1285
1286 ```bash
1287 git add build.zig src CLAUDE.md
1288 git commit -m "build: folder rule 7 keeps every OS-specific spelling under src/os"
1289 ```
1290
1291 ---
1292
1293 ### Task 10: Harness oracle helpers in `e2e_lib.sh`
1294
1295 **Files:**
1296 - Modify: `test/e2e_lib.sh` (add a `# ---- the OS oracle ----` section after `real_pid`)
1297 - Modify: `test/e2e_01_boot.sh` (all `/proc`, `stat -c`, `sha256sum` sites: lines 57-65, 535-541, 787-800, 904, 1044-1059, 1152-1178, 926-949), `test/e2e_03_side.sh:196-222`, `test/e2e_04_handoff.sh:91, 208-211, 218, 482`, `test/e2e_lib.sh:303,324,333` (`--ppid`)
1298
1299 **Interfaces:**
1300 - Produces (all POSIX sh, all print to stdout, all exit 1 when the OS will not say):
1301 - `pid_alive PID` — exit 0 iff the pid exists
1302 - `pid_exe PID` — the fully resolved path of the running image
1303 - `pid_comm PID` — the process name as the OS reports it
1304 - `pid_args PID` — the argv, space-joined
1305 - `pid_children PID` — child pids, one per line
1306 - `pid_fd_count PID` — number of open fds
1307 - `pid_fd_targets PID` — one line per fd, what it points at (`socket:[ino]`, `/dev/ptmx`, `/memfd:name`, a path)
1308 - `pid_holds_unix_sock PID PATH` — exit 0 iff `PID` has `PATH` open as a unix socket
1309 - `pid_rss_kb PID`
1310 - `udp_local_bound HEXADDR` — exit 0 iff a UDP socket is bound at `HEXADDR` (the `/proc/net/udp` spelling `0100007F:1F90`)
1311 - `file_mode PATH` (octal, `600`), `file_size PATH` (bytes), `sha256_of PATH` (hex only)
1312 - `timeout` — defined as a function ONLY when no `timeout` binary exists, dispatching to `gtimeout`
1313
1314 - [ ] **Step 1: The helpers**
1315
1316 Add to `test/e2e_lib.sh` after `real_pid`:
1317
1318 ```sh
1319 # ---- the OS oracle ------------------------------------------------------
1320 # "Ask the OS about the OS, not the daemon" (CLAUDE.md). Every pin that
1321 # reads a pid, an fd table or a bound port goes through these names, so
1322 # the spelling lives in one place per OS. The Linux arm is /proc; another
1323 # OS adds a `case "$(uname)"` arm here and NOTHING in a group file changes.
1324 # Each prints to stdout and returns 1 when the OS will not say.
1325 _os=$(uname)
1326 pid_alive() { kill -0 "$1" 2>/dev/null || [ -d "/proc/$1" ]; }
1327 pid_exe() { readlink -f "/proc/$1/exe" 2>/dev/null; }
1328 pid_comm() { cat "/proc/$1/comm" 2>/dev/null; }
1329 pid_args() { tr '\0' ' ' < "/proc/$1/cmdline" 2>/dev/null; }
1330 pid_children() { ps -o pid= --ppid "$1" 2>/dev/null | tr -d ' '; }
1331 pid_fd_count() { find "/proc/$1/fd" -mindepth 1 2>/dev/null | wc -l | tr -d ' '; }
1332 pid_fd_targets() { readlink "/proc/$1"/fd/* 2>/dev/null; }
1333 pid_holds_unix_sock() {
1334 _ino=$(awk -v p="$2" '$NF == p {print $7}' /proc/net/unix | head -1)
1335 [ -n "$_ino" ] && pid_fd_targets "$1" | grep -qx "socket:\[$_ino\]"
1336 }
1337 pid_rss_kb() { awk '/VmRSS/{print $2}' "/proc/$1/status" 2>/dev/null || echo 0; }
1338 udp_local_bound() { awk -v h="$1" '$2==h{f=1} END{exit !f}' /proc/net/udp; }
1339 file_mode() { stat -c %a "$1"; }
1340 file_size() { stat -c %s "$1"; }
1341 sha256_of() { sha256sum "$1" | cut -d' ' -f1; }
1342 # GNU timeout is a binary here; a box without one names gtimeout, and a
1343 # group file keeps spelling `timeout`.
1344 command -v timeout >/dev/null 2>&1 || timeout() { gtimeout "$@"; }
1345 ```
1346
1347 (`_os` is set now so the step-3 arms have it; on Linux it is unused. If `make check`'s shell gate flags an unused variable, drop the line and add it with the first `case`.)
1348
1349 - [ ] **Step 2: A self-test of the oracle, run by the runner before any group**
1350
1351 Append to `test/e2e_lib.sh`:
1352
1353 ```sh
1354 # The oracle's own pin, off-origin on every dimension: a child that is not
1355 # pid 1, holding MORE than the three fds a fixture would, on a socket path
1356 # it did not inherit. A helper that answered "yes" for pid 1 or for fd
1357 # count 3 would pass a fixture and fail a daemon.
1358 oracle_selftest() {
1359 _osock="$OUT.oracle.sock"
1360 _opid=$(sh -c 'exec 5>/dev/null 6>/dev/null; sleep 30' & echo $!)
1361 _okid=$(sh -c "sleep 30 & echo \$!")
1362 pid_alive "$_opid" || { echo "e2e FAIL: oracle: pid_alive says a live sleep is dead"; exit 1; }
1363 [ "$(pid_comm "$_opid")" = sh ] || { echo "e2e FAIL: oracle: pid_comm of a sh is $(pid_comm "$_opid")"; exit 1; }
1364 pid_args "$_opid" | grep -q 'sleep 30' || { echo "e2e FAIL: oracle: pid_args lost the argv"; exit 1; }
1365 [ "$(pid_fd_count "$_opid")" -ge 5 ] || { echo "e2e FAIL: oracle: pid_fd_count under 5 for a shell holding fd 5 and 6"; exit 1; }
1366 [ "$(pid_exe "$_opid")" = "$(readlink -f "$(command -v sh)")" ] || { echo "e2e FAIL: oracle: pid_exe is $(pid_exe "$_opid")"; exit 1; }
1367 [ "$(pid_rss_kb "$_opid")" -gt 0 ] || { echo "e2e FAIL: oracle: pid_rss_kb is 0"; exit 1; }
1368 [ "$(file_mode "$OUT")" = "$(stat -c %a "$OUT")" ] || { echo "e2e FAIL: oracle: file_mode"; exit 1; }
1369 kill "$_opid" "$_okid" 2>/dev/null; wait "$_opid" 2>/dev/null
1370 _i=0; while pid_alive "$_opid" && [ "$_i" -lt 50 ]; do sleep 0.05; _i=$((_i+1)); done
1371 pid_alive "$_opid" && { echo "e2e FAIL: oracle: pid_alive says a killed sleep lives"; exit 1; }
1372 rm -f "$_osock"
1373 ok "oracle: the OS answers the helpers by name"
1374 }
1375 ```
1376
1377 and in `test/e2e.sh`, right after `. "$E2E_DIR/e2e_lib.sh"` and the hermetic setup (grep `OUT=`), add `oracle_selftest`. The runner's scenario-count pin at the bottom of `e2e.sh` goes up by one; edit that number.
1378
1379 - [ ] **Step 3: Rewrite the group pins**
1380
1381 Apply, file by file, then diff-read each hunk:
1382
1383 `e2e_lib.sh:303,324,333`: `$(ps -o pid= --ppid "$1" 2>/dev/null)` → `$(pid_children "$1")`.
1384
1385 `e2e_01_boot.sh`: `stat -c %a X` → `file_mode X`; `stat -c %s X` → `file_size X`; `sha256sum "$KEYOUT"` → `sha256_of "$KEYOUT"` (the comparison `SUM1 = SUM2` is unchanged); line 539 `grep -qi " $QHEX " /proc/net/udp` → `udp_local_bound "$QHEX"` (and `QHEX` must be the `ADDR:PORT` hex the existing `udp_local_bound` at 787 expects — read both sites, they already agree on spelling; delete the local `udp_local_bound` at 787 since the lib defines it); line 541 and 800 diagnostic `grep -i ... /proc/net/udp` → `pid_fd_targets` is not the answer here; keep them as `cat /proc/net/udp 2>/dev/null | grep -i "$QHEX" || true` wrapped in a lib helper `udp_table` (`udp_table() { cat /proc/net/udp; }`) — add it to the oracle section; line 904 `cat "/proc/$SPID/comm"` → `pid_comm "$SPID"`; lines 1052-1055 → `pid_holds_unix_sock "$ESPID" "$ESOWN"`; 1059 → `pid_comm`; 1154, 1178 → `pid_fd_count "$HFDPID"`; 1156's message keeps its words minus `/proc`.
1386
1387 `e2e_03_side.sh:198` → `PAEXE=$(pid_exe "$PAPID")`; 209 → `pid_comm`; 222 → `pid_args`. Comments at 196, 206, 212 reworded to say "the resolved image" / "the OS's name for the process".
1388
1389 `e2e_04_handoff.sh:91` (inside the ssh shim's heredoc): `$(cat /proc/$$/comm)` → `$(ps -o comm= -p $$)` and `$(cat /proc/$PPID/comm)` → `$(ps -o comm= -p $PPID)` (the shim runs as its own `sh`, and `ps -o comm=` is the same answer on both OSes); 208, 211, 482 → `file_mode`; 218 → `udp_local_bound "$HHEXUDP"`.
1390
1391 - [ ] **Step 4: Run the touched groups**
1392
1393 ```bash
1394 for g in 01_boot 03_side 04_handoff; do E2E_ONLY=$g make e2e > /tmp/e.$g.log 2>&1; echo "$g rc=$?"; done
1395 ```
1396 Expected: three `rc=0`. Then `grep -n '/proc\|stat -c\|sha256sum\|--ppid' test/e2e_01_boot.sh test/e2e_03_side.sh test/e2e_04_handoff.sh test/e2e_lib.sh | grep -v '^\S*:[0-9]*:\s*#'` — expected: only the oracle section's own lines.
1397
1398 - [ ] **Step 5: Commit**
1399
1400 ```bash
1401 git add test/e2e_lib.sh test/e2e.sh test/e2e_01_boot.sh test/e2e_03_side.sh test/e2e_04_handoff.sh
1402 git commit -m "test: the e2e pins ask the OS through named oracle helpers"
1403 ```
1404
1405 ---
1406
1407 ### Task 11: The remaining pins — upgrade, hosts, web, push, soak, vm
1408
1409 **Files:**
1410 - Modify: `test/e2e_06_web.sh:539-541`, `test/e2e_09_hosts.sh:1293-1360, 276, 351`, `test/e2e_14_upgrade.sh:170, 219, 492-505`, `test/e2e_16_push.sh:160-161`, `test/soak.sh:103-131`, `test/vm.sh:146, 157` (these run on the remote Linux VM through `vssh` and STAY as `/proc` — the VM is Linux; add a comment saying so), `test/wan.sh:586, 1174` (Python and a comment; the Python `ps --ppid` → `ps -o pid= --ppid` stays Linux — wan.sh's boxes are Linux; comment it)
1411
1412 - [ ] **Step 1: Rewrite**
1413
1414 `e2e_06_web.sh:539`: `[ ! -e "/proc/$DWSHELL" ]` → `! pid_alive "$DWSHELL"`; 541 → `pid_args "$DWSHELL" 2>/dev/null`.
1415
1416 `e2e_09_hosts.sh:1347-1354`, the environ walk: replace the loop with
1417
1418 ```sh
1419 # Every `mux d start` on the box, by argv — `environ` is not readable on
1420 # every OS and the socket path is in the argv anyway (`--sock PATH`).
1421 for _np in $(ps -Ao pid= | tr -d ' '); do
1422 pid_args "$_np" 2>/dev/null | grep -q "mux d start.*$HRUN" || continue
1423 ...the existing body that records/kills the stray...
1424 done
1425 ```
1426
1427 where `$HRUN` is whatever variable the existing loop matched the environ's runtime dir against (read lines 1293-1360: the loop looks for a daemon born under that box's runtime dir; the argv carries `--sock` under that dir, so the match is on the sock path). 276, 351: `sha256_of`.
1428
1429 `e2e_14_upgrade.sh:170`: `readlink "/proc/$UPXPID/exe"` → `pid_exe "$UPXPID"` (note `pid_exe` is `readlink -f`; `$UPBIN` must be a resolved path — check how it is spelled and `readlink -f` it once where it is set). 219 → `pid_args "$(real_pid "$D69PID")"`. 492: `ls -l "/proc/$UPNEWSH/fd" | grep -c -E 'socket:|memfd:|/dev/ptmx'` → `pid_fd_targets "$UPNEWSH" | grep -c -E 'socket:|memfd:|/dev/ptmx'`; 496, 505 diagnostics → `pid_fd_targets`. 502 → `pid_fd_targets "$(real_pid "$D71PID")" | grep -c 'memfd:mux-upgrade'`. The `memfd:` needle in a SHELL file is fine (rule 7 scans `src/`), but the comment at 480 should say "the manifest carrier" so a macOS arm reading `lsof` output knows what it is looking for.
1430
1431 `e2e_16_push.sh:160-161`: `pid_exe "$PUSHDPID"`.
1432
1433 `soak.sh:103, 110, 131`: `pid_rss_kb "$PDPID"`, `pid_fd_count "$PDPID"` (soak.sh sources e2e_lib? grep; if not, source it the way e2e.sh does, or copy the three one-liners under the same names with a comment pointing at the lib).
1434
1435 - [ ] **Step 2: Run**
1436
1437 ```bash
1438 for g in 06_web 09_hosts 14_upgrade 16_push; do E2E_ONLY=$g make e2e > /tmp/e.$g.log 2>&1; echo "$g rc=$?"; done
1439 ```
1440 Four `rc=0`. Then `grep -rn '/proc' test/*.sh | grep -v 'e2e_lib.sh\|vm.sh\|wan.sh' | grep -v ':\s*#'` — expected: nothing.
1441
1442 - [ ] **Step 3: Commit**
1443
1444 ```bash
1445 git add test
1446 git commit -m "test: the upgrade, hosts, web, push and soak pins read the OS through the oracle"
1447 ```
1448
1449 ---
1450
1451 ### Task 12: Build and deps take a target word; the linker is gated
1452
1453 **Files:**
1454 - Modify: `build.zig:16-41` (`quicDeps`), every `use_lld = true` (lines 680, 865, 871, 876, 881, 886, 950, 971)
1455 - Modify: `deps/quic/build-deps.sh:25-31, 35, 52-60, 73, 88, 109, 124`
1456 - Modify: `Makefile:43-44, 68-70` (install/release), and `deps:` target
1457
1458 - [ ] **Step 1: `quicDeps` picks a word from the target**
1459
1460 ```zig
1461 fn quicDeps(b: *std.Build, target: std.Build.ResolvedTarget) struct { dir: []const u8, step: *std.Build.Step } {
1462 // One word per prefix, shared with build-deps.sh, `make deps`,
1463 // `make clean-deps` and wan.sh's musl cross-build: `native` for the
1464 // host's own libc, `musl` for the static release, and the target
1465 // triple for any cross target — so a third OS is one more `case` arm
1466 // in the script and nothing here.
1467 const t = target.result;
1468 const name = if (t.abi == .musl)
1469 "musl"
1470 else if (t.os.tag == builtin.os.tag and t.cpu.arch == builtin.cpu.arch)
1471 "native"
1472 else
1473 b.fmt("{s}-{s}", .{ @tagName(t.cpu.arch), @tagName(t.os.tag) });
1474 const run = b.addSystemCommand(&.{ "deps/quic/build-deps.sh", name });
1475 ...
1476 ```
1477
1478 (keep the rest; `builtin` is `@import("builtin")` at the top of build.zig — add if absent).
1479
1480 - [ ] **Step 2: Gate LLD**
1481
1482 Add one helper and use it at each of the eight sites:
1483
1484 ```zig
1485 /// Zig 0.15's self-hosted x86_64 ELF linker can't handle the .sframe
1486 /// sections gcc >= 16's crt1.o emits, so ELF goes through LLD. LLD does
1487 /// not link Mach-O, and Zig's own linker does — so Darwin is the one
1488 /// target that must NOT ask for it.
1489 fn linkerFor(c: *std.Build.Step.Compile) void {
1490 c.use_llvm = true;
1491 c.use_lld = !c.rootModuleTarget().os.tag.isDarwin();
1492 }
1493 ```
1494
1495 Replace each `X.use_llvm = true; X.use_lld = true;` pair with `linkerFor(X);` (keep the wsclient comment at 940 that explains its exception).
1496
1497 - [ ] **Step 3: `build-deps.sh`**
1498
1499 ```sh
1500 T="${1:-native}"
1501 case "$T" in
1502 native | musl | aarch64-macos) ;;
1503 *) echo "usage: $0 [native|musl|aarch64-macos]" >&2; exit 2 ;;
1504 esac
1505 ```
1506
1507 Default `ZIG` stays; after the `zigcc-musl` wrapper add:
1508
1509 ```sh
1510 cat > "$W/bin/zigcc-aarch64-macos" <<EOF
1511 #!/bin/sh
1512 exec $ZIG cc -target aarch64-macos "\$@"
1513 EOF
1514 ```
1515
1516 `sha256sum -c -` → a function at the top:
1517
1518 ```sh
1519 case "$(uname)" in
1520 Darwin) sha_check() { shasum -a 256 -c - >/dev/null; }; NJOBS=$(sysctl -n hw.ncpu) ;;
1521 *) sha_check() { sha256sum -c - >/dev/null; }; NJOBS=$(nproc) ;;
1522 esac
1523 ```
1524
1525 and `sha256sum -c - >/dev/null` → `sha_check`, `-j"$(nproc)"` → `-j"$NJOBS"` (both sites). `XTRA`:
1526
1527 ```sh
1528 XTRA=""
1529 WOLF_XTRA=""
1530 case "$T" in
1531 musl) XTRA="-DCMAKE_SYSTEM_NAME=Linux -DCMAKE_SYSTEM_PROCESSOR=x86_64" ;;
1532 aarch64-macos)
1533 # Cross to Darwin: find nothing on the host (ngtcp2 found the host's
1534 # libwolfssl.so before this fence), and no system CA path — mux is
1535 # PSK-only and the CA path wants Security.framework (measured
1536 # 2026-09-03, see docs/decisions.md).
1537 XTRA="-DCMAKE_SYSTEM_NAME=Darwin -DCMAKE_SYSTEM_PROCESSOR=arm64 -DCMAKE_FIND_ROOT_PATH=$OUT -DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=ONLY -DCMAKE_FIND_ROOT_PATH_MODE_INCLUDE=ONLY -DCMAKE_FIND_ROOT_PATH_MODE_PACKAGE=ONLY"
1538 WOLF_XTRA="-DWOLFSSL_SYS_CA_CERTS=no" ;;
1539 esac
1540 ```
1541
1542 and pass `$WOLF_XTRA` on the wolfSSL cmake line. The `-march=x86_64_v3` stays in `zigcc-native` only (an x86 host); a Darwin native arm is step 3's.
1543
1544 - [ ] **Step 4: Makefile**
1545
1546 ```make
1547 # The target the installed and released binaries are built for. Static
1548 # musl on Linux, for the reason above; another OS names its triple here.
1549 MUX_TARGET ?= x86_64-linux-musl
1550 ```
1551
1552 and `-Dtarget=x86_64-linux-musl` → `-Dtarget=$(MUX_TARGET)` in `install:` and `release:`; `RELTAR`'s `x86_64-linux-musl` → `$(MUX_TARGET)`.
1553
1554 - [ ] **Step 5: Prove nothing moved on Linux**
1555
1556 ```bash
1557 deps/zig/zig build --summary none 2>&1 | tail -2; echo rc=$?
1558 deps/zig/zig build -Dtarget=x86_64-linux-musl -p /tmp/claude-1000/xver-musl --summary none 2>&1 | tail -2
1559 ls deps/quic/out
1560 sh -n deps/quic/build-deps.sh && echo syntax-ok
1561 ```
1562 Expected: both builds rc=0 with no dep rebuild (`out/native` and `out/musl` untouched, mtimes unchanged: `stat -c %Y deps/quic/out/*/lib/libngtcp2.a` before and after), `syntax-ok`.
1563
1564 - [ ] **Step 6: `make check`** rc=0. **Commit**
1565
1566 ```bash
1567 git add build.zig deps/quic/build-deps.sh Makefile
1568 git commit -m "build: the QUIC prefix and the linker follow the target, not the host"
1569 ```
1570
1571 ---
1572
1573 ### Task 13: The delivery gate and the record
1574
1575 **Files:**
1576 - Modify: `docs/decisions.md` (append), `README.md` if it names `/proc` or `XDG_RUNTIME_DIR` behaviour (grep)
1577
1578 - [ ] **Step 1: The full gate**
1579
1580 ```bash
1581 make ci > /tmp/ci.log 2>&1; echo rc=$?; tail -5 /tmp/ci.log
1582 ```
1583 Expected: rc=0. If a leg fails, the fix is a `--fixup` on the task's commit.
1584
1585 - [ ] **Step 2: Cross-version**
1586
1587 ```bash
1588 make xversion-build > /tmp/xv.log 2>&1; echo rc=$?
1589 make xversion > /tmp/xv2.log 2>&1; echo rc=$?; tail -5 /tmp/xv2.log
1590 ```
1591 Expected: rc=0 both — the wire and the manifest did not change shape (the old side is `../mux-xver-old`, per the memory note; build its prefix first).
1592
1593 - [ ] **Step 3: The record**
1594
1595 Append to `docs/decisions.md`:
1596
1597 ```
1598 ## 2026-09-03 — the platform layer (macOS port, step 2)
1599
1600 Every OS-specific spelling under src/ now lives in src/os/, one row per
1601 side (server_os, client_os), gated by folder rule 7. Measured before the
1602 design: zig 0.15.2 cross-compiles C to aarch64-macos without an SDK and
1603 links Mach-O with its own linker (LLD refuses); ngtcp2 + wolfSSL
1604 cross-build with WOLFSSL_SYS_CA_CERTS=no and a CMAKE_FIND_ROOT_PATH fence;
1605 std.posix already emulates SOCK_CLOEXEC/accept4 on Darwin; ghostty-vt's
1606 three C++ deps call apple_sdk.addPaths, which finds the HOST libc on a
1607 Linux host — the cross-compile blocker that leaves the build-host decision
1608 open. Two behaviours changed shape on Linux without changing outcome: the
1609 stale-image verdict is now an inode compare against the image at boot
1610 (was the kernel's ` (deleted)` suffix), and reapDeadPid asks kill(pid,0)
1611 (was access(/proc/PID)). Spec: docs/superpowers/specs/2026-09-03-macos-
1612 port-design.md.
1613 ```
1614
1615 - [ ] **Step 4: Autosquash and final check**
1616
1617 ```bash
1618 git rebase -i --autosquash main # not interactive here: use GIT_SEQUENCE_EDITOR=true git rebase -i --autosquash <base>
1619 git log --oneline <base>..HEAD
1620 make check > /tmp/c.log 2>&1; echo rc=$?
1621 ```
1622 Expected: one commit per task, subjects as written above, rc=0.
1623
1624 - [ ] **Step 5: Commit the record**
1625
1626 ```bash
1627 git add docs/decisions.md README.md
1628 git commit -m "docs: record the platform layer and the macOS measurements"
1629 ```
1630
1631 ---
1632
1633 ## Self-review against the spec
1634
1635 - **`src/os/` layer, two rows, `spawn` moved, `forkDaemon` and peer-cred out of `main.zig`:** Tasks 1, 3, 4.
1636 - **Every named operation:** `exitNow` `closeFrom` (2), `forkPty` (2; the spec's `openPty`+`becomeSession` is folded into one `forkPty` because forkpty(3) exists on both OSes and the Darwin header gap is a one-line `extern` in the macOS arm — recorded as a spec amendment in the spec file), `ptyMode` `ptyFgPgid` (2), `anonFd` (5), `selfImageStale` (6), `sendNoSig` `peerCred` `forkDetached` `sockType` (3, 4, 7); client `peerCred` `parentOf` `winSize` `openPtyPair` (4, 7); `sockpath.runtimeDir` and `spawn.selfExe` switches (8, 6).
1637 - **Rule 7 with comments counted:** Task 9.
1638 - **Harness oracle, environ walk rewritten, fixture off-origin:** Tasks 10, 11.
1639 - **QUIC target word, wolfSSL flags, `use_lld` gate, `MUX_TARGET`:** Task 12.
1640 - **`make ci` and `make xversion` green:** Task 13.
1641 - **Not in this plan, by the spec:** the `_macos.zig` arms, the two hardware probes, the harness's Darwin `case` arms, the build-host decision.
1642
1643 Type consistency: `Winsize` is `std.posix.winsize` throughout (fields `row col xpixel ypixel`); `PeerCred` is `{uid, pid}` on both rows; `ForkedPty` is `{pid, master}`; the harness helper names match between Task 10's definitions and Task 11's uses.
docs/superpowers/specs/2026-09-03-macos-port-design.md
Old New
@@ -141,9 +141,13 @@ the entry back toward parse-and-dispatch.
141 - `closeFrom(first_fd)` — the fd barrier. Everything at or above `first_fd` 141 - `closeFrom(first_fd)` — the fd barrier. Everything at or above `first_fd`
142 is closed in the child before exec, so the upgrade manifest's key bytes 142 is closed in the child before exec, so the upgrade manifest's key bytes
143 and an adopted listener cannot ride into a shell. 143 and an adopted listener cannot ride into a shell.
144 - `openPty(winsize) !struct{master, slave_path}` and `becomeSession(slave)` 144 - `forkPty(winsize) !struct{pid, master}` — forkpty(3) behind one name,
145 — the two halves of what `forkpty` did, so the fork itself stays in the 145 returning pid 0 in the child exactly as forkpty does, so the child code
146 child code that resets signals and injects env. 146 that resets signals and injects env stays in `pty.zig` where the fork is
147 visible. (Amended 2026-09-03 while planning: the first draft split this
148 into `openPty` plus `becomeSession`; forkpty exists on both OSes and the
149 Darwin header gap is one `extern "c" fn forkpty` line in the macOS arm,
150 so hand-rolling the session-leader dance bought nothing.)
147 - `ptyMode(master) !Mode` and `ptyFgPgid(master) !pid` — the line 151 - `ptyMode(master) !Mode` and `ptyFgPgid(master) !pid` — the line
148 discipline and foreground group. On Linux they read the master. The 152 discipline and foreground group. On Linux they read the master. The
149 Darwin arm is written after the probe below decides whether the master 153 Darwin arm is written after the probe below decides whether the master