a73x

docs/superpowers/plans/2026-09-03-macos-port-step3-darwin-arm.md

Ref:   Size: 52.7 KiB   History

# macOS Port, Step 3: the Darwin Arm — Implementation Plan

> **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.

**Goal:** `mux` builds, passes its unit suite and serves a real session on an Apple Silicon Mac, from the same tree that ships the Linux binary.

**Architecture:** Two new child files, `src/os/server_os_macos.zig` and `src/os/client_os_macos.zig`, fill the `.macos` arm of the two contract roots that step 2 left as a compile error. `sockpath.runtimeDir` gains its Darwin spelling. A shadow-SDK shim under `deps/` works around the one toolchain defect found on hardware (below), the Makefile learns it is on Darwin, the shell oracle grows a `Darwin` arm, and `test/mac.sh` drives the Mac over ssh the way `test/vm.sh` drives the VM.

**Tech Stack:** Zig 0.15.2 (vendored, `deps/zig/zig` on BOTH boxes), `@cImport` of `util.h` / `sys/un.h` / `sys/sysctl.h` for the libc calls `std.c` does not declare on Darwin, POSIX sh + lsof/ps/stat(1) BSD spellings in the harness.

**Spec:** `docs/superpowers/specs/2026-09-03-macos-port-design.md` — sections "Step 3: the macOS arm", "Testing", "Deferred", plus the amendment "Hardware findings, 2026-09-03" that this plan's rulings come from.

## Global Constraints

- **The root is the contract, the arm is spellings only.** Every operation in `server_os_macos.zig` and `client_os_macos.zig` has the exact signature the root declares; no new `pub` name appears in an arm that the root does not call. Rules the root owns (server `peerCred` pid ≤ 0 → null, born-stale image, `openPtyPair` test-only) stay in the root.
- **Folder rule 7 stands:** `std.os.linux`, `/proc`, `memfd`, `close_range`, `exit_group`, `so.peercred`, `so_peercred`, `iocsptlck`, `iocgptn`, `nosignal` appear under `src/os/` only. A Darwin arm that needs none of them still lives there.
- **Rule 6 (`posix.fork(`) gains a second excepted file** and nothing else: `src/os/server_os_macos.zig`. The `except` field becomes a list.
- **The Linux outcome does not change.** `make ci` green on Linux after every task; no Linux syscall, flag or message changes. A test that must differ by OS branches on `builtin.os.tag` inside the test, never by deleting the Linux assertion.
- **Ask the OS about the OS.** Every Darwin test asserts against a real child, pty, socket or file; a Darwin oracle helper is graded by `oracle_selftest` exactly as the Linux one is.
- **The hardware is `squirtle`** (Apple M1, macOS 26.6.2, Xcode 26.x, ssh key auth from this box; brew at `/opt/homebrew/bin`, NOT on the non-interactive ssh PATH). The repo is at `~/code/rad/mux` there, pushed from here with `git push squirtle:code/rad/mux <branch>` (the remote has `receive.denyCurrentBranch=updateInstead`, so push the branch and `git checkout` it there). It may be unreachable for stretches; a task whose verification needs it records `BLOCKED: squirtle unreachable` rather than skipping the check. Every ssh is `ssh -o BatchMode=yes squirtle '…'`, and every remote command that builds spells `export PATH=$HOME/code/rad/mux/deps/mac-sdk/bin:/opt/homebrew/bin:$PATH` first (Task 1 creates that dir).
- **Toolchain defect, stated once:** Xcode 26.4+ ships a macOS SDK whose `libSystem.B.tbd` lists `arm64e-macos` and no `arm64-macos`; zig 0.15.2's Mach-O linker matches only the latter and every libSystem symbol goes undefined, for the BUILD RUNNER too, so `zig build` cannot even start (Codeberg ziglang/zig#31658; upstream fix is in 0.16, not in any 0.15 release, and ghostty pins 0.15). Measured 2026-09-03 on squirtle: native `zig build-exe h.zig -lc` fails; `-target aarch64-macos` (zig's bundled stub) links and runs; brew zig 0.16 links. The workaround is a shadow SDK: a directory of symlinks to the real SDK with `usr/lib/libSystem{,.B}.tbd` replaced by zig's own `lib/libc/darwin/libSystem.tbd`, reached through an `xcrun` shim on PATH that answers `--show-sdk-path` with the shadow. Proven: with the shim, the full `zig build` on squirtle compiles ghostty and its three C++ deps and stops at exactly the three step-3 `@compileError`s.
- **Focused unit runs are `deps/zig/zig build test`** (whole suite); there is no `-Dtest-filter`, and a standalone `zig test FILE` does not link on either box.
- **Commit subjects are `type: what changed`**, no scope; the types are `feat fix refactor test docs build chore`.

---

## Rulings carried from the hardware probes (2026-09-03)

1. **Build host: native on the Mac, over ssh.** Cross-compiling from Linux stays blocked by ghostty's `apple_sdk.addPaths` resolving the host libc; a native build needs only the shadow-SDK shim. The shim retires itself: it passes `xcrun` through untouched when the real SDK's stub lists `arm64-macos`.
2. **The pty master answers on Darwin.** `tcgetattr`, `TIOCGPGRP`, `TIOCSWINSZ` and `TIOCGWINSZ` on the master all returned 0 against a forked child with the slave as its controlling tty. The Linux arm's shape is reused; nothing reopens the slave by name.
3. **Runtime dir: `$XDG_RUNTIME_DIR` when set, else `/tmp/mux-<uid>`, created `0700`.** The spec's three candidates all overflow the 104-byte `sun_path` at the longest name mux creates, which is `<dir>/mux-agent-<pid>-<12 hex>/agent-<32-char session>.sock` = dir + 68 + pid digits. Measured on squirtle: `$TMPDIR` is 48 bytes (121 total), `~/Library/Caches/mux` 34 (107), `~/.local/state/mux/run` 36 (109); `/tmp/mux-501` is 12 (85). `/tmp` is `/private/tmp`, sticky and world-writable, so the per-uid subdirectory carries the privacy the way tmux's `/tmp/tmux-UID` does, and the same owner-and-mode check guards it. Honouring `XDG_RUNTIME_DIR` first is what keeps every isolated rig (`make e2e`, `soak`, hand rigs) isolated on both OSes from one line.
4. **Upgrade carrier on Darwin: `mkstemp` in `/tmp`, unlinked before return, mode `0600`.** Private by file mode rather than by anonymity; the unlink window is the microsecond between the two calls and the file is empty then.
5. **Darwin `sendNoSig` sets `SO_NOSIGPIPE` on the fd per call** before a plain `send`. No adopt-socket hook in the roots: one `setsockopt` on a socket that already has the option is a no-op, and the two accept sites stay outside the platform rows.
6. **`forkDetached`'s test asserts a NEW session, not a leader pid.** macOS `ps` prints no numeric session id; `ps -o sess=` prints an opaque token that is equal within a session on both OSes. The Linux-only `sid == $$` claim stays under `if (builtin.os.tag == .linux)`.

---

## File Structure

| File | Responsibility |
|---|---|
| `deps/mac-sdk.sh` (new) | Builds `deps/mac-sdk/{sdk,bin/xcrun}` on a Darwin host; no-op when the real SDK is linkable; prints nothing on success. |
| `deps/quic/build-deps.sh` | Already patched in the tree (uncommitted): repo zig first, `-march` only on x86_64, Darwin native fences. Task 1 commits it. |
| `Makefile` | Darwin detection: `MUX_TARGET` default, PATH prefix for the shim, `mac-sdk` target, `mac` target for the journey. |
| `.gitignore` | `deps/mac-sdk` |
| `src/sockpath.zig` | Darwin `runtimeDir` arm + `ensureRuntimeDir`-style guard; the "unset" message names the right variable per OS. |
| `src/os/server_os_macos.zig` (new) | Darwin spellings of the 14 server operations. |
| `src/os/client_os_macos.zig` (new) | Darwin spellings of the 9 client operations. |
| `src/os/server_os.zig`, `src/os/client_os.zig` | `.macos` import arm; two test bodies branch on OS. |
| `build.zig` | rule 6 `except` list; nothing else. |
| `test/os_oracle.sh` | `case "$(uname)"` Darwin arm for every helper; `now_ms`. |
| `test/e2e_lib.sh`, six `e2e_*.sh`, `test/throughput.sh` | `date +%s%N` → `now_ms`. |
| `test/mac.sh` (new) | The `make mac` journey against squirtle. |
| `docs/decisions.md`, `README.md`, `CLAUDE.md` | The record. |

---

### Task 1: The shadow-SDK shim, the Makefile on Darwin, and the dep-script fix

**Files:**
- Create: `deps/mac-sdk.sh`
- Modify: `Makefile` (top, `install`, new targets), `.gitignore`
- Commit (already modified in tree): `deps/quic/build-deps.sh`

**Interfaces:**
- Produces: `deps/mac-sdk/bin/xcrun` on PATH makes `zig build` link on squirtle. Every later task's remote command exports that PATH.

- [ ] **Step 1: Write `deps/mac-sdk.sh`**

```sh
#!/bin/sh
# deps/mac-sdk.sh — a shadow macOS SDK for zig 0.15.2, built on a Darwin host.
#
# Xcode 26.4 and later ship a libSystem.B.tbd whose `targets:` line lists
# arm64e-macos and no arm64-macos. zig 0.15.2's Mach-O linker matches the
# bare arm64 slice only, so against that SDK every libSystem symbol is
# undefined — for the build runner too, which is why `zig build` cannot
# even start (ziglang/zig#31658 on Codeberg; fixed in 0.16, which ghostty's
# pin cannot use). zig's own lib/libc/darwin/libSystem.tbd is what a
# `-target aarch64-macos` build links against, and it works. This script
# builds an SDK that is the real one in every path but usr/lib/libSystem*,
# where zig's stub stands in, and an `xcrun` shim that answers
# --show-sdk-path with it — zig finds the SDK by running `xcrun` off PATH
# (std/zig/system/darwin.zig), and so does ghostty's apple_sdk helper.
#
# Self-retiring: when the real SDK's stub lists arm64-macos again, no shadow
# is built and the shim, if a stale one is on PATH, passes straight through.
# Idempotent and cheap: the marker check is the first thing it does.
#
#   ./deps/mac-sdk.sh            builds deps/mac-sdk/{sdk,bin/xcrun}
set -eu
[ "$(uname)" = Darwin ] || exit 0
SELF="$(cd "$(dirname "$0")" && pwd)"
OUT="$SELF/mac-sdk"
ZIG_STUB="$SELF/zig/lib/libc/darwin/libSystem.tbd"
REAL="$(/usr/bin/xcrun --sdk macosx --show-sdk-path)"
if grep -m1 '^targets:' "$REAL/usr/lib/libSystem.B.tbd" | grep -q 'arm64-macos'; then
    rm -rf "$OUT"      # the SDK is linkable again; leave nothing to shadow it
    exit 0
fi
[ -f "$ZIG_STUB" ] || { echo "deps/mac-sdk: no zig stub at $ZIG_STUB (is deps/zig in place?)" >&2; exit 1; }
# Marker: the shim exists AND points at this SDK. A new Xcode moves REAL.
if [ -x "$OUT/bin/xcrun" ] && [ "$(readlink "$OUT/sdk/usr/include")" = "$REAL/usr/include" ]; then
    exit 0
fi
rm -rf "$OUT"
mkdir -p "$OUT/sdk/usr/lib" "$OUT/bin"
for e in "$REAL"/*; do b=$(basename "$e"); [ "$b" = usr ] || ln -s "$e" "$OUT/sdk/$b"; done
for e in "$REAL"/usr/*; do b=$(basename "$e"); [ "$b" = lib ] || ln -s "$e" "$OUT/sdk/usr/$b"; done
for e in "$REAL"/usr/lib/*; do
    b=$(basename "$e")
    case "$b" in libSystem.tbd | libSystem.B.tbd) ;; *) ln -s "$e" "$OUT/sdk/usr/lib/$b" ;; esac
done
cp "$ZIG_STUB" "$OUT/sdk/usr/lib/libSystem.tbd"
cp "$ZIG_STUB" "$OUT/sdk/usr/lib/libSystem.B.tbd"
cat > "$OUT/bin/xcrun" <<EOF
#!/bin/sh
# Shim from deps/mac-sdk.sh: only --show-sdk-path is answered here.
case "\$*" in
    *--show-sdk-path*) [ -d "$OUT/sdk" ] && { echo "$OUT/sdk"; exit 0; } ;;
esac
exec /usr/bin/xcrun "\$@"
EOF
chmod +x "$OUT/bin/xcrun"
echo "deps/mac-sdk: shadow SDK over $REAL (zig 0.15.2 cannot link the Xcode 26.4+ stub)" >&2
```

- [ ] **Step 2: Makefile — Darwin wiring**

At the top, after the `ZIG ?=` line:

```make
# On a Mac the pinned zig cannot link against Xcode 26.4+'s SDK (see
# deps/mac-sdk.sh). The shim dir goes FIRST on PATH for every recipe here,
# and `mac-sdk` builds it; both are no-ops on Linux and on a Mac whose SDK
# is linkable. MUX_TARGET follows the host: a Mac installs a Mac binary.
UNAME_S := $(shell uname -s)
ifeq ($(UNAME_S),Darwin)
export PATH := $(CURDIR)/deps/mac-sdk/bin:/opt/homebrew/bin:$(PATH)
MUX_TARGET ?= aarch64-macos
endif
MUX_TARGET ?= x86_64-linux-musl
```

Remove the existing `MUX_TARGET ?= x86_64-linux-musl` line further down (the `?=` above now owns it). Add:

```make
mac-sdk:
	sh ./deps/mac-sdk.sh

# The `make vm` of the macOS port: user journeys on the Mac, over ssh.
mac: build
	sh ./test/mac.sh
```

Make `build`, `check`, `test`, `install` depend on `mac-sdk`:

```make
build: mac-sdk
	$(ZIG) build
```

(and the same `: mac-sdk` prerequisite on `check`, `test`, `install`, `throughput`). `.PHONY` gains `mac-sdk mac`.

- [ ] **Step 3: `.gitignore`** — add `deps/mac-sdk` under the `deps/zig` block with a one-line comment (`# Shadow SDK from deps/mac-sdk.sh, Darwin only.`).

- [ ] **Step 4: Verify on Linux**

Run: `sh -n deps/mac-sdk.sh && make build >/dev/null && echo ok` → `ok`; `sh deps/mac-sdk.sh; echo rc=$?` → `rc=0` with no output (the `uname` gate). `make check` green.

- [ ] **Step 5: Verify on squirtle** (skip with `BLOCKED` if unreachable)

```sh
git push squirtle:code/rad/mux HEAD:refs/heads/macos-step3 && ssh -o BatchMode=yes squirtle 'cd ~/code/rad/mux && git checkout -q macos-step3 && export PATH=$HOME/code/rad/mux/deps/mac-sdk/bin:/opt/homebrew/bin:$PATH && make mac-sdk && ls -l deps/mac-sdk/bin/xcrun && xcrun --show-sdk-path && rm -rf .zig-cache && make build 2>&1 | grep -c "no server platform arm\|no client platform arm\|no default runtime directory"'
```

Expected: the shim path printed as the SDK, and the final count is `3` — the three step-3 compile errors and NO `undefined symbol` line (`grep -c "undefined symbol"` → `0`).

- [ ] **Step 6: Commit**

```sh
git add deps/mac-sdk.sh deps/quic/build-deps.sh Makefile .gitignore
git commit -m "build: a Mac builds natively through a shadow SDK, and the dep script follows the host"
```

---

### Task 2: `sockpath.runtimeDir` on Darwin

**Files:**
- Modify: `src/sockpath.zig` (`runtimeDir`, the "unset" message near line 37, tests)

**Interfaces:**
- Produces: `runtimeDir()` returns `?[]const u8` on Darwin exactly as on Linux; new `pub fn runtimeDirFrom(env: ?[]const u8, uid: std.posix.uid_t, buf: *[32]u8) ?[]const u8` (pure, tested on Linux) and `fn ensureOwnedDir(path: []const u8, uid: std.posix.uid_t) bool` (creates `0700` if missing; then `lstat`: directory, not a symlink, owner `uid`, mode `0700`).

- [ ] **Step 1: Failing tests** (append to `src/sockpath.zig`)

```zig
test "runtimeDirFrom: the env var wins on every OS, and Darwin falls back to /tmp/mux-UID" {
    var buf: [32]u8 = undefined;
    try std.testing.expectEqualStrings("/run/user/7", runtimeDirFrom("/run/user/7", 501, &buf).?);
    if (builtin.os.tag == .linux) {
        try std.testing.expect(runtimeDirFrom(null, 501, &buf) == null);
    } else {
        try std.testing.expectEqualStrings("/tmp/mux-501", runtimeDirFrom(null, 501, &buf).?);
    }
}

test "ensureOwnedDir: creates 0700, accepts its own creation, refuses a symlink and a group-readable dir" {
    var tmp = testtmp.init();
    defer tmp.deinit();
    var b: [std.fs.max_path_bytes]u8 = undefined;
    const uid = std.c.geteuid();
    const fresh = try std.fmt.bufPrint(&b, "{s}/rt", .{tmp.path()});
    try std.testing.expect(ensureOwnedDir(fresh, uid));
    const st = try std.posix.fstatat(std.posix.AT.FDCWD, fresh, 0);
    try std.testing.expectEqual(@as(u32, 0o700), @as(u32, @intCast(st.mode & 0o777)));
    try std.testing.expect(ensureOwnedDir(fresh, uid)); // second call is a check, not a mkdir
    var b2: [std.fs.max_path_bytes]u8 = undefined;
    const loose = try std.fmt.bufPrint(&b2, "{s}/loose", .{tmp.path()});
    try std.posix.mkdir(loose, 0o750);
    try std.testing.expect(!ensureOwnedDir(loose, uid));
    var b3: [std.fs.max_path_bytes]u8 = undefined;
    const link = try std.fmt.bufPrint(&b3, "{s}/link", .{tmp.path()});
    try std.posix.symlink(fresh, link);
    try std.testing.expect(!ensureOwnedDir(link, uid));
}
```

`testtmp` is already a test import of `sockpath` (check `build.zig`'s row; add `.test_imports = &.{"testtmp"}` if it is not).

- [ ] **Step 2: Run** `deps/zig/zig build test 2>&1 | tail -5` → fails: `runtimeDirFrom` undefined.

- [ ] **Step 3: Implement**

```zig
/// The directory the default daemon socket and every per-wall socket live
/// in, or null. `$XDG_RUNTIME_DIR` wins on every OS, because that is how
/// every isolated rig (make e2e, soak, a hand rig) keeps its sockets apart
/// from the user's. Linux has NO fallback: a guess cannot make two binaries
/// agree on one daemon, so the caller names it with --sock. Darwin falls
/// back to /tmp/mux-<uid>, created 0700 and checked on every ask — the
/// spec's three candidates ($TMPDIR, ~/Library/Caches, ~/.local/state) all
/// overflow sun_path at the longest name mux creates (dir + 68 + pid
/// digits against 103; measured 2026-09-03, docs/decisions.md), and /tmp
/// is sticky and world-writable, so the per-uid directory is what carries
/// the privacy, as tmux's /tmp/tmux-UID does.
pub fn runtimeDir() ?[]const u8 {
    const env = std.posix.getenv("XDG_RUNTIME_DIR");
    if (builtin.os.tag == .linux) return env;
    const uid = std.c.geteuid();
    const dir = runtimeDirFrom(env, uid, &darwin_dir_buf) orelse return null;
    if (env == null and !ensureOwnedDir(dir, uid)) return null;
    return dir;
}

var darwin_dir_buf: [32]u8 = undefined;

fn runtimeDirFrom(env: ?[]const u8, uid: std.posix.uid_t, buf: *[32]u8) ?[]const u8 {
    if (env) |e| return e;
    if (builtin.os.tag == .linux) return null;
    return std.fmt.bufPrint(buf, "/tmp/mux-{d}", .{uid}) catch null;
}

/// True when PATH is a directory this uid owns with mode 0700 and no
/// symlink in the last step — the check tmux makes of /tmp/tmux-UID, for
/// the same reason: in a sticky world-writable /tmp, another uid can plant
/// a symlink or a loose directory at our name before we get there, and a
/// socket bound through either is theirs to connect to.
fn ensureOwnedDir(path: []const u8, uid: std.posix.uid_t) bool {
    std.posix.mkdir(path, 0o700) catch |e| if (e != error.PathAlreadyExists) return false;
    const st = std.posix.fstatat(std.posix.AT.FDCWD, path, std.posix.AT.SYMLINK_NOFOLLOW) catch return false;
    return std.posix.S.ISDIR(st.mode) and st.uid == uid and (st.mode & 0o777) == 0o700;
}
```

Update the "unset" message (line ~37) so on Darwin it reads `"{s}: no runtime directory: XDG_RUNTIME_DIR is unset and /tmp/mux-<uid> is not a 0700 directory owned by you (name a socket with --sock)\n"`; keep the Linux text byte-for-byte.

- [ ] **Step 4: Run** `deps/zig/zig build test 2>&1 | tail -3` → pass; `make check` green.

- [ ] **Step 5: Commit** — `git commit -m "feat: the Darwin runtime dir is XDG_RUNTIME_DIR, else /tmp/mux-UID checked like tmux does"`

---

### Task 3: `server_os_macos.zig`

**Files:**
- Create: `src/os/server_os_macos.zig`
- Modify: `src/os/server_os.zig` (import arm; the `forkDetached` test), `build.zig` (rule 6 `except` list)

**Interfaces:**
- Consumes: every `pub fn` the root calls through `impl`: `getpid peerCred sendNoSigNoWait sockType forkPty exitNow forkDetached closeFrom ptyMode ptyFgPgid setWinsize anonFd`. (`noteBootImage`/`selfImageStale` live in the root.)

- [ ] **Step 1: The arm**

```zig
//! Darwin arm of `server_os`. Spellings only; the contract is in the root.
//! Three behaviours differ from Linux on purpose and are recorded in
//! docs/decisions.md (2026-09-03): `closeFrom` walks the fd table,
//! `anonFd` is an unlinked mkstemp file, and `sendNoSigNoWait` sets
//! SO_NOSIGPIPE on the fd per call because Darwin has no MSG_NOSIGNAL.
const std = @import("std");
const root = @import("server_os.zig");
const c = @cImport({
    @cInclude("util.h"); // forkpty
    @cInclude("sys/ioctl.h");
    @cInclude("sys/socket.h");
    @cInclude("sys/un.h"); // LOCAL_PEERPID
    @cInclude("unistd.h"); // getpeereid, getdtablesize
    @cInclude("stdlib.h"); // mkstemp
});

pub fn getpid() std.posix.pid_t {
    return std.c.getpid();
}

pub fn peerCred(fd: std.posix.socket_t) ?root.PeerCred {
    var uid: c.uid_t = undefined;
    var gid: c.gid_t = undefined;
    if (c.getpeereid(fd, &uid, &gid) != 0) return null;
    var pid: c.pid_t = 0;
    var len: c.socklen_t = @sizeOf(c.pid_t);
    // SOL_LOCAL is 0 on Darwin; LOCAL_PEERPID answers the peer's pid for a
    // unix socket the way SO_PEERCRED's pid field does on Linux.
    if (c.getsockopt(fd, 0, c.LOCAL_PEERPID, &pid, &len) != 0) return null;
    return .{ .uid = uid, .pid = pid };
}

fn noSigPipe(fd: std.posix.socket_t) void {
    const on: c_int = 1;
    _ = c.setsockopt(fd, c.SOL_SOCKET, c.SO_NOSIGPIPE, &on, @sizeOf(c_int));
}

pub fn sendNoSigNoWait(fd: std.posix.socket_t, bytes: []const u8) std.posix.SendError!usize {
    noSigPipe(fd);
    return std.posix.send(fd, bytes, std.posix.MSG.DONTWAIT);
}

pub fn sockType(fd: std.posix.fd_t) error{NotASocket}!u32 {
    var t: c_int = undefined;
    var len: c.socklen_t = @sizeOf(c_int);
    if (c.getsockopt(fd, c.SOL_SOCKET, c.SO_TYPE, &t, &len) != 0) return error.NotASocket;
    return @intCast(t);
}

pub fn forkPty(ws: root.Winsize) error{ForkPtyFailed}!root.ForkedPty {
    var master: c_int = undefined;
    var cws: c.struct_winsize = .{ .ws_row = ws.row, .ws_col = ws.col, .ws_xpixel = 0, .ws_ypixel = 0 };
    const pid = c.forkpty(&master, null, null, &cws);
    if (pid < 0) return error.ForkPtyFailed;
    return .{ .pid = pid, .master = master };
}

pub fn exitNow(code: u8) noreturn {
    std.c._exit(code);
}

pub fn forkDetached(
    exe: [*:0]const u8,
    argv: [*:null]const ?[*:0]const u8,
    stdin_fd: std.posix.fd_t,
    out_fd: std.posix.fd_t,
) error{ForkFailed}!std.posix.pid_t {
    const pid = std.posix.fork() catch return error.ForkFailed;
    if (pid != 0) return pid;
    _ = std.c.setsid();
    std.posix.dup2(stdin_fd, std.posix.STDIN_FILENO) catch exitNow(127);
    std.posix.dup2(out_fd, std.posix.STDOUT_FILENO) catch exitNow(127);
    std.posix.dup2(out_fd, std.posix.STDERR_FILENO) catch exitNow(127);
    std.posix.execveZ(exe, argv, std.c.environ) catch exitNow(127);
    unreachable;
}

pub fn closeFrom(first: std.posix.fd_t) void {
    // No close_range on Darwin: one close per slot up to the table size,
    // which is thousands of cheap EBADFs once per session start. Between
    // fork and exec, so nothing else is opening fds underneath the walk.
    var fd: std.posix.fd_t = first;
    const top: std.posix.fd_t = c.getdtablesize();
    while (fd < top) : (fd += 1) _ = std.c.close(fd);
}

pub fn ptyMode(master: std.posix.fd_t) std.posix.TermiosGetError!root.PtyMode {
    // Measured 2026-09-03: Darwin's master answers tcgetattr for the slave's
    // line discipline, so this is the Linux shape and not a reopen by name.
    const t = try std.posix.tcgetattr(master);
    return .{ .icanon = t.lflag.ICANON, .echo = t.lflag.ECHO };
}

pub fn ptyFgPgid(master: std.posix.fd_t) error{IoctlFailed}!std.posix.pid_t {
    var pgid: c.pid_t = 0;
    if (c.ioctl(master, c.TIOCGPGRP, &pgid) < 0) return error.IoctlFailed;
    return @intCast(pgid);
}

pub fn setWinsize(master: std.posix.fd_t, ws: root.Winsize) error{IoctlFailed}!void {
    var cws: c.struct_winsize = .{ .ws_row = ws.row, .ws_col = ws.col, .ws_xpixel = 0, .ws_ypixel = 0 };
    if (c.ioctl(master, c.TIOCSWINSZ, &cws) < 0) return error.IoctlFailed;
}

pub fn anonFd(name: [*:0]const u8) error{CarrierFailed}!std.posix.fd_t {
    // No memfd on Darwin. A 0600 file this uid creates and unlinks before
    // anyone could open it by name is private by mode where memfd is
    // private by having no name; the window is the two calls below, on an
    // empty file. /tmp rather than the runtime dir because this file
    // imports nothing of ours and must not learn the socket directory.
    var tmpl: [64]u8 = undefined;
    const t = std.fmt.bufPrintZ(&tmpl, "/tmp/mux-{s}-XXXXXX", .{std.mem.span(name)}) catch return error.CarrierFailed;
    const fd = c.mkstemp(t.ptr);
    if (fd < 0) return error.CarrierFailed;
    std.posix.unlink(std.mem.sliceTo(t, 0)) catch {
        std.posix.close(fd);
        return error.CarrierFailed;
    };
    // mkstemp opens O_CLOEXEC on modern Darwin; the candidate must inherit it.
    const flags = std.c.fcntl(fd, std.posix.F.GETFD, @as(c_int, 0));
    _ = std.c.fcntl(fd, std.posix.F.SETFD, flags & ~@as(c_int, std.posix.FD_CLOEXEC));
    return fd;
}
```

- [ ] **Step 2: Root import arm** in `src/os/server_os.zig`:

```zig
pub const impl = switch (builtin.os.tag) {
    .linux => @import("server_os_linux.zig"),
    .macos => @import("server_os_macos.zig"),
    else => @compileError("mux has no server platform arm for " ++ @tagName(builtin.os.tag)),
};
```

- [ ] **Step 3: The `forkDetached` test** — replace its script and assertions:

```zig
    // `sess` is the one column both ps's print: the numeric sid on procps
    // (== the leader's pid) and an opaque token on Darwin, equal within a
    // session on both. A new session is the claim; the leader pid is a
    // Linux-only strengthening.
    const argv = [_:null]?[*:0]const u8{ "/bin/sh", "-c", "ps -o sess= -p $$ | tr -d ' '; ps -o sess= -p $PPID | tr -d ' '; echo $$" };
    …
    const sess = lines.next() orelse return error.NoOutput;
    const parent_sess = lines.next() orelse return error.NoOutput;
    const shpid = lines.next() orelse return error.NoOutput;
    try std.testing.expect(!std.mem.eql(u8, sess, parent_sess));
    if (builtin.os.tag == .linux) try std.testing.expectEqualStrings(shpid, sess);
    try std.testing.expectEqual(pid, try std.fmt.parseInt(std.posix.pid_t, shpid, 10));
```

Bump `buf` to `[128]u8`.

- [ ] **Step 4: Rule 6 in `build.zig`** — `except: ?[]const u8` → `except: []const []const u8 = &.{}`; the check at line ~457 becomes a loop (`for (ban.except) |ex| if (std.mem.eql(u8, path, ex)) { skip = true; }`); rule 6's entry: `.except = &.{ "src/os/server_os_linux.zig", "src/os/server_os_macos.zig" }`. Update the rule's `why` with one sentence: "one fork per OS arm, and the arm's file is named here so a third file that forks is caught".

- [ ] **Step 5: Linux verify** — `deps/zig/zig build test 2>&1 | tail -3` pass; `make check` green (rule 6 now walks a list; `test/bans.sh` still plants its needle and reads the fatal).

- [ ] **Step 6: squirtle verify** — push, then:

```sh
ssh -o BatchMode=yes squirtle 'cd ~/code/rad/mux && git checkout -q macos-step3 && export PATH=$HOME/code/rad/mux/deps/mac-sdk/bin:/opt/homebrew/bin:$PATH && make build 2>&1 | grep -c "no client platform arm"'
```

Expected `1` or more and zero `no server platform arm` lines. (The client arm is Task 4; the build still cannot link.)

- [ ] **Step 7: Commit** — `git commit -m "feat: the daemon's Darwin arm"`

---

### Task 4: `client_os_macos.zig`

**Files:**
- Create: `src/os/client_os_macos.zig`
- Modify: `src/os/client_os.zig` (import arm)

- [ ] **Step 1: The arm**

```zig
//! Darwin arm of `client_os`. Spellings only; the contract is in the root.
const std = @import("std");
const root = @import("client_os.zig");
const c = @cImport({
    @cInclude("util.h"); // openpty (test-only through the root)
    @cInclude("sys/ioctl.h");
    @cInclude("sys/socket.h");
    @cInclude("sys/un.h");
    @cInclude("sys/sysctl.h"); // kinfo_proc for parentOf
    @cInclude("unistd.h");
});

pub fn getpid() std.posix.pid_t {
    return std.c.getpid();
}

pub fn peerCred(fd: std.posix.socket_t) ?root.PeerCred {
    var uid: c.uid_t = undefined;
    var gid: c.gid_t = undefined;
    if (c.getpeereid(fd, &uid, &gid) != 0) return null;
    var pid: c.pid_t = 0;
    var len: c.socklen_t = @sizeOf(c.pid_t);
    if (c.getsockopt(fd, 0, c.LOCAL_PEERPID, &pid, &len) != 0) return null;
    return .{ .uid = uid, .pid = pid };
}

pub fn sendNoSig(fd: std.posix.socket_t, bytes: []const u8) std.posix.SendError!usize {
    const on: c_int = 1;
    _ = c.setsockopt(fd, c.SOL_SOCKET, c.SO_NOSIGPIPE, &on, @sizeOf(c_int));
    return std.posix.send(fd, bytes, 0);
}

/// sysctl KERN_PROC_PID: the kernel's own record of the process, the
/// Darwin answer to /proc/PID/stat. A pid the kernel no longer has is 0,
/// as on Linux.
pub fn parentOf(pid: std.posix.pid_t) std.posix.pid_t {
    var mib = [_]c_int{ c.CTL_KERN, c.KERN_PROC, c.KERN_PROC_PID, pid };
    var kp: c.struct_kinfo_proc = undefined;
    var len: usize = @sizeOf(c.struct_kinfo_proc);
    if (c.sysctl(&mib, mib.len, &kp, &len, null, 0) != 0 or len == 0) return 0;
    return kp.kp_eproc.e_ppid;
}

pub fn geteuid() std.posix.uid_t {
    return std.c.geteuid();
}

pub fn winSize(fd: std.posix.fd_t) ?std.posix.winsize {
    var ws: std.posix.winsize = undefined;
    if (c.ioctl(fd, c.TIOCGWINSZ, &ws) != 0) return null;
    return ws;
}

pub fn setWinSize(fd: std.posix.fd_t, ws: std.posix.winsize) error{Unsupported}!void {
    var w = ws;
    if (c.ioctl(fd, c.TIOCSWINSZ, &w) != 0) return error.Unsupported;
}

pub fn openPtyPair() error{Unsupported}!root.PtyPair {
    var master: c_int = undefined;
    var slave: c_int = undefined;
    if (c.openpty(&master, &slave, null, null, null) != 0) return error.Unsupported;
    return .{ .master = master, .slave = slave };
}
```

- [ ] **Step 2: Root import arm** — add `.macos => @import("client_os_macos.zig"),` beside the Linux line.

- [ ] **Step 3: Linux verify** — `deps/zig/zig build test 2>&1 | tail -3`; `make check`.

- [ ] **Step 4: squirtle verify — the first link**

```sh
ssh -o BatchMode=yes squirtle 'cd ~/code/rad/mux && git checkout -q macos-step3 && export PATH=$HOME/code/rad/mux/deps/mac-sdk/bin:/opt/homebrew/bin:$PATH && make build 2>&1 | tail -20; ls -l zig-out/bin/mux && ./zig-out/bin/mux --version'
```

Expected: a `mux` binary that prints its version. Whatever else fails here (a ghostty link line, a framework, a `std.c` name Darwin lacks) is THIS task's to fix, and each fix is one line in the report so Task 8 can record it.

- [ ] **Step 5: Commit** — `git commit -m "feat: the client's Darwin arm, and mux links on a Mac"`

---

### Task 5: `zig build test` green on the Mac

**Files:**
- Modify: whatever the run names. Expected: `src/os/spawn.zig` (nothing — its Linux branch is gated), `src/client/client.zig:2397` (`readlink /dev/fd/2` is not a symlink on Darwin: the test wants the fd's TARGET; use `ls -l /dev/fd/2 | sed 's/.*-> //'` on Linux vs … simplest portable form is `test -w /dev/fd/2 && echo open > FILE` if the assertion is only "stderr was the pipe"; read the test and keep its claim), `src/sockpath.zig:150` (already gated), any test spelling `/proc` inside a `test` block.

- [ ] **Step 1: Run on squirtle**

```sh
ssh -o BatchMode=yes squirtle 'cd ~/code/rad/mux && export PATH=$HOME/code/rad/mux/deps/mac-sdk/bin:/opt/homebrew/bin:$PATH && deps/zig/zig build test 2>&1 | grep -v "^\s*$" | tail -40'
```

- [ ] **Step 2: Fix each failure at its cause**, one commit per distinct cause when they are unrelated, keeping every Linux assertion. A unit test that cannot hold on Darwin because the OS has no such fact (not because the spelling differs) is gated on `builtin.os.tag == .linux` with a one-line comment saying which fact Darwin lacks.

- [ ] **Step 3: Both suites green** — squirtle `zig build test` exit 0 (capture `$?` on the ssh, not the tail); Linux `make check` green.

- [ ] **Step 4: Commit(s)** — `test: the unit suite holds on Darwin` (or one per cause).

---

### Task 6: The oracle's Darwin arm and the harness's GNU spellings

**Files:**
- Modify: `test/os_oracle.sh`, `test/e2e_lib.sh` (`now_ms`, `_os` marker → the first real `case`), `test/e2e_01_boot.sh`, `e2e_04_handoff.sh`, `e2e_10_agent.sh`, `e2e_11_select.sh`, `e2e_14_upgrade.sh`, `test/throughput.sh` (`date +%s%N` → `now_ms`), `test/e2e_03_side.sh`, `e2e_14_upgrade.sh`, `e2e_16_push.sh` (`readlink -f` → `real_path`).

**Interfaces:**
- Produces: `now_ms` (epoch milliseconds), `real_path PATH` (canonical path), and a Darwin arm for every existing helper. Group files call only names in `os_oracle.sh`.

- [ ] **Step 1: The Darwin arm.** Restructure `os_oracle.sh` as ONE `case "$(uname)"` with the whole Linux block in the `*)` arm unchanged, and this `Darwin)` arm above it:

```sh
Darwin)
    # lsof is the fd oracle (no /proc): -Fn prints one `n<target>` line per
    # fd, -Ft its type, -a ANDs the filters. ps's BSD columns answer the
    # rest. Each helper asks the kernel (lsof and ps both read it through
    # libproc), never a daemon.
    pid_alive() { kill -0 "$1" 2>/dev/null; }
    pid_exe() { lsof -p "$1" -a -d txt -Fn 2>/dev/null | sed -n 's/^n//p' | head -1; }
    pid_comm() { basename "$(ps -o comm= -p "$1" 2>/dev/null)"; }
    pid_args() { ps -o args= -p "$1" 2>/dev/null; }
    pid_children() { pgrep -P "$1" 2>/dev/null; }
    pid_fd_count() { lsof -p "$1" -Ff 2>/dev/null | grep -c '^f[0-9]' || true; }
    pid_fd_targets() { lsof -p "$1" -Fn 2>/dev/null | sed -n 's/^n//p'; }
    pid_holds_fd_kind() {
        case "$2" in
            socket)    lsof -p "$1" -a -U -Ff 2>/dev/null | grep -c '^f[0-9]' || true ;;
            carrier)   lsof -p "$1" -Fn 2>/dev/null | grep -c '^n/tmp/mux-.*XXXXXX\|^n/private/tmp/mux-' || true ;;
            ptymaster) lsof -p "$1" -Fn 2>/dev/null | grep -c '^n/dev/ptmx' || true ;;
            *) echo "pid_holds_fd_kind: no such fd kind '$2'" >&2; return 1 ;;
        esac
    }
    pid_holds_unix_sock() { lsof -p "$1" -a -U -Fn 2>/dev/null | grep -qx "n$2"; }
    pid_rss_kb() { ps -o rss= -p "$1" 2>/dev/null | tr -d ' ' || echo 0; }
    udp_local_bound() { lsof -iUDP -P -n -Fn 2>/dev/null | grep -q "^n.*:$(printf '%d' "0x${1##*:}")$"; }
    udp_table() { lsof -iUDP -P -n 2>/dev/null; }
    file_mode() { stat -f %Lp "$1"; }
    file_size() { stat -f %z "$1"; }
    sha256_of() { shasum -a 256 "$1" | cut -d' ' -f1; }
    real_path() { python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$1"; }
    now_ms() { python3 -c 'import time; print(int(time.time()*1000))'; }
    ;;
```

and in the Linux arm add `real_path() { readlink -f "$1"; }` and `now_ms() { echo $(( $(date +%s%N) / 1000000 )); }`. The `udp_local_bound` Linux helper takes the hex form; the Darwin one is handed the same argument and converts. Read the two callers in `e2e_lib.sh` before settling the argument shape — the group file must not know which OS it is on.

The unlinked carrier: lsof prints an unlinked file's last path; verify the exact spelling in Task 6 Step 4 on the Mac and fix the pattern to what it prints.

- [ ] **Step 2: `now_ms` and `real_path` in the group files** — replace each `date +%s%N` arithmetic and each `readlink -f` with the helper. Keep the Linux output identical (ms, not ns — adjust the arithmetic at each site).

- [ ] **Step 3: Linux gate** — `make check` (bans.sh, `sh -n` on every script) and `E2E_ONLY=01_boot make e2e`, `E2E_ONLY=14_upgrade make e2e`, `E2E_ONLY=03_side make e2e` green; then the whole `make e2e` once.

- [ ] **Step 4: Darwin oracle self-test on squirtle**

```sh
ssh -o BatchMode=yes squirtle 'cd ~/code/rad/mux && export PATH=/opt/homebrew/bin:$PATH && brew list coreutils >/dev/null 2>&1 || brew install coreutils; sh -c ". test/os_oracle.sh; . test/e2e_lib.sh 2>/dev/null; OUT=/tmp/oracle TIME_SCALE=1 oracle_selftest && echo ORACLE_OK"'
```

(`oracle_selftest` needs `defer_kill`, `wait_until`, `OUT`; if sourcing the lib whole is not possible without its argv, extract the self-test into `test/oracle_selftest.sh` that both `e2e_lib.sh` and this call source — a refactor, not a second copy.) Expected `ORACLE_OK`. `gtimeout` comes from coreutils; the existing `timeout` fallback covers it.

- [ ] **Step 5: Commit** — `test: the OS oracle answers on Darwin, and the harness spells time and paths through it`

---

### Task 7: `test/mac.sh` — the `make mac` journey, against a macOS GUEST on the Mac

**Ruling (user, 2026-09-03):** the gate drives a macOS virtual machine on
squirtle, not the host, so every run starts from a pristine image the way
`make vm` starts from a scorched-earth reset. The host builds (it holds the
toolchain and the shadow SDK); the guest only RUNS the built binaries, which
is the stronger claim — a clean macOS with no developer tooling.

**The VM is a human/host action, never harness code** (the `vm.sh` rule).
Tooling: Cirrus Labs `tart` (installed 2026-09-03 on squirtle, `brew trust
cirruslabs/cli && brew install cirruslabs/cli/tart`), base image
`ghcr.io/cirruslabs/macos-tahoe-base:latest` (macOS 26; pulled once, ~28 GB).
The pristine VM is made ONCE by hand and documented in `test/mac.sh`'s header:

```sh
tart clone ghcr.io/cirruslabs/macos-tahoe-base:latest mux-mac-base
tart run --no-graphics mux-mac-base &            # user admin, password admin
tart exec mux-mac-base sh -c 'mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo "PUBKEY" >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys'
tart stop mux-mac-base
```

(`PUBKEY` is the key squirtle's own `~/.ssh/id_*.pub` holds, so the host can
ssh into the guest without a password; `tart exec` needs the guest agent the
cirruslabs images ship.) `mux-mac-base` is never run again by the harness:
each run clones it.

**Files:**
- Create: `test/mac.sh`
- Modify: `Makefile` (`mac` target from Task 1 already points here), `README.md` (one paragraph under the macOS section: the base-VM recipe above)

**Interfaces:**
- Consumes: `MUX_MAC` (default `squirtle`), the repo at `~/code/rad/mux` on the host, `deps/mac-sdk/bin` on the host PATH, `tart` on the host, the pristine `mux-mac-base` VM, `test/os_oracle.sh` (its Darwin arm runs INSIDE the guest).

- [ ] **Step 1: The script**, shaped like `test/vm.sh` (same header contract; `set -eu`; BatchMode; every claim asserted by a separate ssh reading the guest through the oracle). All ssh to the guest goes THROUGH the host: `ssh squirtle "ssh -o BatchMode=yes -o StrictHostKeyChecking=no admin@$GIP '…'"`, one helper `gssh` that quotes once. Legs, each a named step with its own failure line:

1. `push` — `git push "$MAC:code/rad/mux" HEAD:refs/heads/mac-journey`, remote `git checkout -q mac-journey && git reset -q --hard mac-journey`.
2. `build` — on the host: `make build` and `make check` (rc captured); `deps/zig/zig build test` (rc captured) — the unit suite needs the toolchain and so runs on the host.
3. `vm` — on the host: `tart stop mux-mac 2>/dev/null; tart delete mux-mac 2>/dev/null; tart clone mux-mac-base mux-mac; nohup tart run --no-graphics mux-mac >/tmp/mux-mac.log 2>&1 &`; poll `tart ip mux-mac` up to 120 s; poll `gssh true` up to 120 s. Refuse with a clear line if `mux-mac-base` does not exist (`tart list`), pointing at the header recipe.
4. `install` — `scp` (through the host) `zig-out/bin/mux`, `zig-out/bin/ptyclient` and `test/os_oracle.sh` into the guest's `~/mux-e2e/`; `gssh '~/mux-e2e/mux --version'` prints the version; assert `file_mode` of the binary is `755` via the oracle sourced in the guest.
5. `daemon` — in the guest, with `XDG_RUNTIME_DIR` UNSET (this is the one place the Darwin fallback `/tmp/mux-<uid>` is exercised end to end; the Task 2 ⚠️ item): `~/mux-e2e/mux d start -d`; then assert via the oracle IN the guest: `pid_holds_unix_sock DPID /tmp/mux-501/muxd.sock` (uid 501 is `admin`; read it with `id -u`), `pid_exe DPID` equals `real_path ~/mux-e2e/mux`, and `file_mode /tmp/mux-$(id -u)` is `700`.
6. `session` — `~/mux-e2e/mux a …` one-shot (the `mux a` verb README documents) runs `echo mac-$$`; read the pid off the reply; assert it is a child of the daemon via `pid_children`.
7. `attach` — `~/mux-e2e/ptyclient` drives `~/mux-e2e/mux` on a real pty in the guest, types `printf JOURNEY-%s\\n ok`, waits for `JOURNEY-ok` in the snapshot (mirror the e2e lib's wait shape), detaches with the chord.
8. `stop` — `mux d stop`; `pid_alive DPID` false within 3 s; the socket path gone.
9. Cleanup trap: `tart stop mux-mac` on the host, always; the clone is left for inspection and replaced by the next run's clone.

- [ ] **Step 2: Run it** — `make mac` from this box; every leg prints its name and `ok`; exit 0. Record the wall-clock of the `vm` leg (clone + boot) in the report.

- [ ] **Step 3: Commit** — `test: make mac drives a pristine macOS guest on the Mac the way make vm drives the VM`

---

### Task 8: `make e2e` on the Mac — the verdict, and the record

**Files:**
- Modify: `docs/decisions.md`, `README.md`, `CLAUDE.md`

- [ ] **Step 1: Run the e2e suite on squirtle** with the shim PATH and `brew`'s coreutils present:

```sh
ssh -o BatchMode=yes squirtle 'cd ~/code/rad/mux && export PATH=$HOME/code/rad/mux/deps/mac-sdk/bin:/opt/homebrew/bin:$PATH && make e2e 2>&1 | tail -40; echo rc=$?'
```

Then per group: `for g in $(ls test/e2e_[0-9]*_*.sh | sed 's,test/e2e_,,;s,\.sh,,'); do E2E_ONLY=$g make e2e >/tmp/e2e-$g.log 2>&1; echo "$g rc=$?"; done`. Fix a group only when the cause is a harness spelling (one more oracle helper, a BSD flag); a group that fails on a PRODUCT difference is recorded, not patched here.

- [ ] **Step 2: `docs/decisions.md`** — one dated entry (2026-09-03, "macOS port step 3: the Darwin arm"), plain sentences: the Xcode 26.4 stub finding with the measurements from the Global Constraints; the pty-master probe result; the sun_path measurements and the `/tmp/mux-UID` ruling; the four deliberate Darwin differences (`closeFrom`, `anonFd`, `sendNoSig` per call, the `sess` test); every fix Task 5 made; the per-group e2e verdict table; what is NOT done (x86_64-macos, universal binary, cross-compile).

- [ ] **Step 3: `README.md`** — a "macOS" section: aarch64 only, `make build` on the Mac needs Xcode CLT + cmake (brew), the shadow-SDK note in one paragraph, the runtime dir spelling, `make install` puts an aarch64 binary in `~/.local/bin`.

- [ ] **Step 4: `CLAUDE.md`** — the toolchain block gains the Mac line (`deps/zig` on the Mac is the aarch64 0.15.2 tarball; `make mac-sdk`; `MUX_TARGET` follows the host); the layout table's `src/os/` row names the two `_macos` children; rule 6's excepted files are two; the invariants gain one line under the sun_path bullet about `/tmp/mux-UID`. Re-measure the `wc -l` figures the reading section quotes.

- [ ] **Step 5: `make ci` on Linux**, then commit — `docs: the Darwin arm, its measurements and the Mac e2e verdict`

---

### Task 9: `test/xos.sh` — cross-OS journeys: a Mac client on a Linux daemon, a Linux client on a Mac daemon

**Ruling (user, 2026-09-04):** "once macos builds we'll also need to test
macos client to linux daemon and vice versa." The macOS port is a second
ARM of one product, and the wire is the claim step 3 has not graded: every
pin so far runs one OS on both ends. This task attaches across the seam in
both directions, over both transports, and pins that a cross-arch
`mux d upgrade HOST` is refused before a byte moves.

**Execution order:** run this task BEFORE Task 8, so Task 8's
`docs/decisions.md` entry records the cross-OS verdict beside the Mac e2e
table. The number is 9 because it was added after the plan was cut.

**The fixture (a human/eitri action, never harness code, the `vm.sh` rule):**

- The MAC is the HOST `squirtle` (`$MUX_MAC`, arm64, macOS 26): it holds
  the toolchain and builds; `make install` there puts an aarch64 `mux` in
  `~/.local/bin`, which is where `client.handoff`'s appended PATH finds it
  for a non-login ssh (`local_bin_append`). The macOS GUEST is not used:
  it holds no key for any Linux box and a pristine guest must stay that way.
- The LINUX daemon box is `mux-lan` (`$MUX_LAN`, default
  `ubuntu@192.168.0.37`): an eitri VM on host `onyx` with its `lan`
  network, x86_64 Ubuntu, reachable by plain ssh from BOTH this box and
  the Mac (squirtle's `~/.ssh/id_ed25519.pub` was authorized by cloud-init,
  this box's key appended after; the `mux-vm-gate` cert is refused there).
  Made 2026-09-04 with eitri `vm_create {name: mux-lan, host: onyx,
  network: lan}`; the DHCP lease can move, `vm_info` re-reads it.
- This box is the Linux CLIENT and the orchestrator: `zig-out/bin/mux`
  and `zig-out/bin/ptyclient` from this tree drive the Linux→Mac legs.
- Reachability measured 2026-09-04: squirtle cannot ssh INTO this box
  (publickey denied) or the e2e VM (charizard's private network), which is
  why a LAN box exists. The Mac's non-interactive ssh PATH is
  `~/.cargo/bin:/usr/bin:/bin:/usr/sbin:/sbin` — no brew, no
  `~/.local/bin` — so every harness ssh onto the Mac spells
  `$HOME/.local/bin/mux` and the repo's shim PATH itself.

**Files:**
- Create: `test/xos.sh`
- Modify: `Makefile` (`xos` target, beside `vm` and `mac`, never inside `ci`), `README.md` (one paragraph under the macOS section naming the gate and its two boxes), `CLAUDE.md` (the toolchain block's `make vm` line gains `xos`)

**Interfaces:**
- Consumes: `MUX_MAC` (default `squirtle`), `MUX_LAN` (default `ubuntu@192.168.0.37`), the repo at `~/code/rad/mux` on the Mac, `deps/mac-sdk/bin` on the Mac PATH, `test/os_oracle.sh` (its Linux arm runs on `mux-lan`, its Darwin arm on the Mac), `test/vm.sh`'s reset block and `test/mac.sh`'s `hssh`/push legs as the shapes to copy, `mux a`'s `--quic HOST --key PATH` spelling, `main.zig`'s refusal line `mux d upgrade: HOST is ARCH and this image is ARCH; refusing to push a binary that cannot run there`.
- Produces: `make xos`, exit 0 with `xos OK (N legs ...)`; the verdict Task 8 records.

- [ ] **Step 1: The script**, shaped like `test/mac.sh` (`set -eu`; BatchMode
  everywhere the harness itself sshes; `ok`/`fail` with a literal leg-count
  pin; every claim about a box asserted by a SEPARATE ssh reading that box
  through the oracle, never by trusting the connection under test). Two
  helpers, `mssh SECONDS` (script on stdin, on the Mac, under `/bin/sh -s`,
  every inner ssh takes `-n` — `mac.sh`'s rule) and `lssh SECONDS` (the
  same onto `$MUX_LAN`). Legs, each named:

1. `push` — `mac.sh`'s push leg verbatim (`--force` to `mac-journey`,
   detach first, `HOST_SHA` equals `HEAD_SHA`).
2. `build` — on the Mac: `make build` and `make install` under the shim
   PATH (`export PATH=$HOME/code/rad/mux/deps/mac-sdk/bin:/opt/homebrew/bin:$PATH`);
   `$HOME/.local/bin/mux --version` equals this tree's `zig-out/bin/mux --version`.
   Here: `$(ZIG) build -Dtarget=x86_64-linux-musl -Doptimize=ReleaseSafe -p dist/xosgate`
   (the Makefile does it, `vm`'s spelling — static musl because `mux-lan`
   is a VM on a host whose CPU this box did not build for; the laptop was
   bricked by a native push once, `docs/decisions.md` 2026-09-01), and
   this box's own `zig build` for the Debug client and `ptyclient`.
3. `reset` — on `mux-lan`, `vm.sh`'s scorched-earth block verbatim
   (kill every mux, remove `~/.local/bin/mux`, state, cache, config and
   `$XDG_RUNTIME_DIR/mux*`, `enable-linger`, the bashrc PATH line); then
   `scp` the musl `mux` to `~/.local/bin/mux` and `test/os_oracle.sh` to
   `~/xos/`; `~/.local/bin/mux --version` equals the tree's. On the MAC
   nothing is killed and nothing under `~` is removed — it is the user's
   machine, not a throwaway: the leg REFUSES with a clear line if
   `pgrep -x mux` finds a mux there already ("stop it or run on another
   Mac"), and asserts `/tmp/mux-$(id -u)` is absent, so the daemon the
   Linux→Mac legs meet is the one they start. Both boxes: an isolated
   `XDG_STATE_HOME` for every mux the HARNESS runs (`~/xos/state` there,
   `$SCRATCH/state` here), because every attaching mux writes its daemon
   into the hosts file (CLAUDE.md) — the daemons themselves run under
   each box's DEFAULT runtime dir, since that is where the entry dial's
   `mux d endpoint --start` will look.
4. `keys` — one 32-byte QUIC key for the run: `mux d keygen` into
   `$SCRATCH/key` here, then installed 0600 at `~/xos/key` on both remote
   boxes. Never `~/.config/mux/key` on the Mac (the user's). Every daemon
   below starts with `--key ~/xos/key`, every QUIC client dials with
   `--key`/`MUX_KEY_FILE`.
5. `mac-to-linux` — on `mux-lan`:
   `MUX_SHELL_INTEGRATION=1 ~/.local/bin/mux d start -d --quic 0.0.0.0 --key ~/xos/key`;
   read `LPID` (`pgrep -x mux`). On the Mac, `zig-out/bin/ptyclient` (built by
   leg 2) drives `$HOME/.local/bin/mux $MUX_LAN` on a real pty with
   `XDG_STATE_HOME=$HOME/xos/state`: `expect \x1b[?1049h`, settle,
   `send printf "XOS-M2L-%s-%s\\n" $(uname -m) $$\n`, `expect XOS-M2L-x86_64-`,
   detach with `\x1cd`, `waitexit`. The marker carries the SESSION shell's
   `uname -m`, so `x86_64` on a Mac's screen is the cross-OS fact itself.
   Read the shell pid off the capture; assert on `mux-lan` via the oracle
   that `pid_children LPID` lists it; assert the capture holds NO
   `landed over ssh` line (the entry dial fetched the daemon's QUIC
   coordinates over ssh and moved onto QUIC — inbound UDP to a LAN VM
   works, `mux-test-boxes`' rule), and via the oracle
   `udp_local_bound LPID 4433` on `mux-lan`. `mux d dump` on `mux-lan`
   still holds the marker after the detach.
6. `mac-to-linux-quic` — on the Mac, no ssh in the path:
   `MUX_KEY_FILE=$HOME/xos/key $HOME/.local/bin/mux a run --quic 192.168.0.37 --key $HOME/xos/key --timeout 20000 'echo m2lq-$$'`
   (`mux a` joins the session leg 5 made; `--quic HOST --key PATH` is its
   own spelling): reply carries `"mechanism":"marks"`, `"exit_code":0`,
   and a pid that `pid_children LPID` on `mux-lan` lists. This is the
   Darwin wolfSSL build's PSK handshake against the musl build's, in one
   round trip, with no ssh to fall back to.
7. `linux-to-mac` — from HERE: `zig-out/bin/ptyclient` drives
   `zig-out/bin/mux $MUX_MAC` with `XDG_STATE_HOME=$SCRATCH/state`; the
   entry dial runs `ssh squirtle 'PATH=...; mux d endpoint --start'`, which
   is the daemon's ONLY starter on the Mac in this gate (`asked` is the
   entry dial's alone). Marker `XOS-L2M-%s-%s` with `$(uname -m)` → expect
   `XOS-L2M-arm64-`. Then on the Mac via the oracle's Darwin arm (the repo
   checkout at `mac-journey` has it): `MPID` is `pgrep -x mux`,
   `pid_holds_unix_sock MPID /tmp/mux-$(id -u)/muxd.sock` (no
   `XDG_RUNTIME_DIR` on an ssh login — the Task 2 fallback, exercised by a
   REAL `mux HOST`), `pid_exe MPID` equals `$HOME/.local/bin/mux` resolved
   with `cd -P`/`pwd -P` (mac.sh's spelling), `pid_children MPID` lists the
   shell pid. The daemon has no `--quic` here because the entry dial
   started it bare, so this attach is the ssh-handoff transport: assert
   the capture DOES say the attach landed over ssh, or — if the daemon
   announces a QUIC arm anyway — record which and assert that one; the
   implementer reads the announce and pins what it says, with the reason in
   the leg's comment.
8. `linux-to-mac-quic` — on the Mac, stop the bare daemon
   (`$HOME/.local/bin/mux d stop`, `pid_alive MPID` false within 3 s) and
   start `MUX_SHELL_INTEGRATION=1 $HOME/.local/bin/mux d start -d --quic 0.0.0.0 --key $HOME/xos/key`;
   from HERE `MUX_KEY_FILE=$SCRATCH/key zig-out/bin/mux a run --quic 192.168.0.47 --key $SCRATCH/key --timeout 20000 'echo l2mq-$$'`
   after a pty attach `zig-out/bin/mux quic://192.168.0.47` (with the key
   in `MUX_KEY_FILE`) has made a session: the reply's pid is a child of
   the new `MPID`. **This is the leg that asks whether macOS delivers
   inbound UDP 4433 to a daemon a non-GUI ssh session started.** If the
   application firewall eats it, the leg fails with the dial's own words;
   record that in the report as a product/platform finding, do NOT loosen
   the leg — a Mac daemon nobody can dial over QUIC is a fact the user
   needs, not a harness problem.
9. `upgrade-refused` — both ways, with the daemons of legs 5 and 8 up.
   From HERE: `zig-out/bin/mux d upgrade $MUX_MAC --allow-same-version`
   exits nonzero and stderr matches
   `is arm64 and this image is x86_64; refusing to push a binary that cannot run there`;
   on the Mac, `pgrep -x mux` is still `MPID` and `pid_exe` unchanged.
   From the MAC: `$HOME/.local/bin/mux d upgrade $MUX_LAN --allow-same-version`
   exits nonzero, stderr `is x86_64 and this image is arm64; refusing …`;
   on `mux-lan`, `LPID` and its `pid_exe` unchanged. The preflight's
   `uname -m` is the gate and nothing is streamed: assert the installed
   binary's sha on each box (`shasum -a 256` / `sha256sum`, through the
   oracle if it has a helper, else the two spellings in a `case`) equals
   what leg 3 / leg 2 installed.
10. `stop` — `mux d stop` on both boxes with the isolated key/state;
    `pid_alive` false within 3 s each; the Mac's `/tmp/mux-$(id -u)`
    socket gone; `mux-lan`'s `$XDG_RUNTIME_DIR/muxd.sock` gone.
11. Cleanup trap: stop any daemon the run started on either box, always;
    remove `~/xos` on `mux-lan`; on the Mac remove `~/xos` only (never
    `~/.local/bin/mux`, which `make install` owns and the user may want).

- [ ] **Step 2: Run it** — `make xos` from this box, twice; every leg
  prints its name and `ok`; exit 0. Record per-leg wall-clock and which
  transport each attach landed on in the report. A leg that fails on a
  PRODUCT difference (leg 8's firewall question is the expected one) is
  reported with the box's own words and left RED — the user decides.

- [ ] **Step 3: Commit** — `test: make xos attaches a Mac client to a Linux daemon and a Linux client to a Mac daemon, over ssh and QUIC`

---

## Self-review

- **Spec coverage:** Step 3's two arm files (Tasks 3, 4), harness `case "$(uname)"` arms (Task 6), the dep-script arm (Task 1, already in tree), the two hardware probes (answered, recorded in rulings and Task 8), the three named Darwin differences (Task 3 doc header + Task 8), the `runtimeDir` spelling (Task 2), the Testing section's gate — `make ci` on the Mac is Task 8's run, the oracle's own test is Task 6 Step 4, the `make vm`-shaped journey is Task 7. The Deferred build-host decision is ruling 1.
- **Placeholders:** none; every step has its code or its exact command. Task 5 is a discovery task by design and says what "fix" means.
- **Type consistency:** `runtimeDirFrom(env, uid, buf)` (Task 2) is used only inside `sockpath.zig`; `now_ms`/`real_path` (Task 6) are the only new harness names and Task 7 uses `real_path`; the rule 6 `except` list type (Task 3) is the one `build.zig` change. Arm signatures were copied from the roots' `pub fn` lines on 2026-09-03.