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/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 |