a73x

09776b98

test: the macOS guest's lifecycle is a provisioning script

a73x   2026-09-04 12:50

Commit message
test: the macOS guest's lifecycle is a provisioning script

test/mac.sh cloned, booted and stopped a tart guest itself, and reached
it only through the host's NAT — an ssh-through-ssh string in every
command it ran there. A gate that owns a VM's lifecycle cannot be
pointed at a Mac somebody already has, so the lifecycle moves out.

test/provision-mac.sh runs from the Linux box: it clones the hand-made
base image on a tart host, boots the clone with --net-bridged so the
guest lands on the LAN, polls `tart ip --resolver=arp` (6 s when
measured), installs this box's public key through the host's own, and
verifies a DIRECT ssh from here. Bridged and not NAT is what removes the
hop: this box reaches the guest, and so can the Linux VM the cross-OS
gate uses, which is what lets the two boxes dial each other.

One line on stdout — `export MAC_BOX=admin@IP` — and everything else on
stderr, so `eval "$(test/provision-mac.sh)"` is the whole recipe.
`--down` takes the clone away again.

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

test/provision-mac.sh
Old New
@@ -0,0 +1,194 @@
1 #!/bin/sh
2 # test/provision-mac.sh — make the macOS box the gates take by name.
3 #
4 # `make mac` and `make xos` grade a Mac they are TOLD about (MAC_BOX). This
5 # is what produces one: a pristine macOS virtual machine, on the LAN, that
6 # this box can ssh into directly. Run it from here, and eval its one line:
7 #
8 # eval "$(test/provision-mac.sh)" # export MAC_BOX=admin@192.168.0.x
9 # MAC_BUILDER=squirtle make xos
10 # test/provision-mac.sh --down # when the run is over
11 #
12 # The clone lives on a tart HOST — a Mac with tart installed, named by
13 # --host or by $MAC_BUILDER, since the machine that builds is normally the
14 # machine that has the VMs. Everything here is on the host EXCEPT the last
15 # step, which proves the guest answers THIS box.
16 #
17 # Bridged, not NAT. `tart run --net-bridged=en0` puts the guest on the same
18 # LAN as everything else (measured 2026-09-04: it took 192.168.0.170), so
19 # this box reaches it without a hop through the host and the Linux VM the
20 # cross-OS gate uses can see its port 22. Under tart's default NAT the guest
21 # is reachable only from the host, which is why both gates used to spell an
22 # ssh-through-ssh string for every command they ran there.
23 #
24 # The lifecycle is HERE and not in a gate on purpose. The pristine base
25 # image is a human action (test/vm.sh's rule) and so is the decision to
26 # spend a machine; a gate that cloned a VM for itself would be a gate that
27 # could not be pointed at a Mac somebody already had.
28 #
29 # The base image is made ONCE, by hand, on the host:
30 #
31 # brew trust cirruslabs/cli && brew install cirruslabs/cli/tart
32 # tart clone ghcr.io/cirruslabs/macos-tahoe-base:latest mux-mac-base
33 # tart run --no-graphics mux-mac-base & # user admin, password admin
34 # tart exec mux-mac-base sh -c 'mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo "PUBKEY" >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys'
35 # tart stop mux-mac-base
36 #
37 # PUBKEY is the HOST's own ~/.ssh/id_ed25519.pub, so the host can ssh into
38 # any clone without a password; `tart exec` needs the guest agent the
39 # cirruslabs images ship. That key is what step 4 below rides to install
40 # THIS box's key, and it is the only reason the host is in the picture after
41 # the boot.
42 set -eu
43
44 usage() {
45 echo "usage: test/provision-mac.sh [--host SSHNAME] [--name VM] [--iface en0] [--down]"
46 echo " --host the Mac holding tart and the base image (default: \$MAC_BUILDER)"
47 echo " --name the clone to make and boot (default: mux-mac)"
48 echo " --iface the host interface to bridge onto (default: en0)"
49 echo " --down stop and delete the clone, and print nothing"
50 }
51
52 HOST=${MAC_BUILDER:-}
53 NAME=mux-mac
54 IFACE=en0
55 DOWN=no
56 VM_BASE=mux-mac-base
57 GUSER="admin"
58
59 while [ $# -gt 0 ]; do
60 case "$1" in
61 --host) [ $# -ge 2 ] || { usage >&2; exit 2; }; HOST=$2; shift 2 ;;
62 --name) [ $# -ge 2 ] || { usage >&2; exit 2; }; NAME=$2; shift 2 ;;
63 --iface) [ $# -ge 2 ] || { usage >&2; exit 2; }; IFACE=$2; shift 2 ;;
64 --down) DOWN=yes; shift ;;
65 -h|--help) usage; exit 0 ;;
66 *) echo "provision-mac: unknown argument '$1'" >&2; usage >&2; exit 2 ;;
67 esac
68 done
69
70 [ -n "$HOST" ] || {
71 echo "provision-mac: no tart host. Pass --host SSHNAME or set MAC_BUILDER." >&2
72 exit 2
73 }
74
75 # Everything this script says goes to STDERR. Stdout carries exactly one
76 # line, the `export MAC_BOX=...` a caller evals, so a message that wandered
77 # onto it would be evaluated as shell.
78 say() { echo "provision-mac: $*" >&2; }
79
80 SSH_OPTS="-o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new"
81 # The guest's host key is new on every clone, so this box neither records it
82 # nor checks it; LogLevel=ERROR keeps ssh from announcing the new key on
83 # stderr every time. The GATE re-learns that key deliberately, because mux's
84 # own entry dial spawns a plain ssh that reads the real known_hosts.
85 GUEST_OPTS="-o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR"
86
87 # hssh SECONDS — a script on stdin, on the tart host, under /bin/sh.
88 # /bin/sh and not the host's login zsh: an options string pasted into a
89 # command line there arrives as one argument, because zsh does not
90 # word-split an unquoted variable.
91 hssh() {
92 _ht=$1
93 # shellcheck disable=SC2086 # SSH_OPTS is words on purpose
94 timeout "$_ht" ssh $SSH_OPTS "$HOST" /bin/sh -s
95 }
96
97 if [ "$DOWN" = yes ]; then
98 hssh 180 >/dev/null 2>&1 <<H || true
99 export PATH=/opt/homebrew/bin:\$PATH
100 tart stop $NAME 2>/dev/null || true
101 tart delete $NAME 2>/dev/null || true
102 H
103 say "$NAME stopped and deleted on $HOST"
104 exit 0
105 fi
106
107 # ---- 1: the host has tart and the base image --------------------------
108 hssh 60 >/dev/null <<H || {
109 export PATH=/opt/homebrew/bin:\$PATH
110 command -v tart >/dev/null || exit 1
111 tart list 2>/dev/null | awk '{print \$2}' | grep -qx $VM_BASE
112 H
113 echo "provision-mac: $HOST has no tart VM called $VM_BASE (or no tart at all)." >&2
114 echo " Make it once by hand; the recipe is this script's header." >&2
115 exit 1
116 }
117
118 # THIS box's public key is what the guest must end up trusting. Refused
119 # rather than generated: a key made behind somebody's back is a key they do
120 # not know they are using.
121 [ -f "$HOME/.ssh/id_ed25519.pub" ] || {
122 echo "provision-mac: this box has no ~/.ssh/id_ed25519.pub to give the guest." >&2
123 echo " Make one with: ssh-keygen -t ed25519" >&2
124 exit 1
125 }
126 PUB=$(cat "$HOME/.ssh/id_ed25519.pub")
127
128 # ---- 2: a fresh clone, booted on the LAN ------------------------------
129 say "cloning $VM_BASE to $NAME on $HOST and booting it bridged on $IFACE"
130 hssh 600 >/dev/null <<H || { say "could not clone and boot $NAME on $HOST"; exit 1; }
131 export PATH=/opt/homebrew/bin:\$PATH
132 tart stop $NAME >/dev/null 2>&1 || true
133 tart delete $NAME >/dev/null 2>&1 || true
134 tart clone $VM_BASE $NAME || exit 1
135 nohup tart run --no-graphics --net-bridged=$IFACE $NAME >/tmp/$NAME.log 2>&1 &
136 sleep 1
137 exit 0
138 H
139
140 # ---- 3: its address, off the host's own arp table ---------------------
141 # --resolver=arp because a bridged guest takes its lease from the LAN's DHCP
142 # server and not from tart, so tart has no lease file to read and learns the
143 # address the same way anything else on the wire does. Polled on the HOST
144 # rather than one ssh per tick: a tick costs a whole connection setup from
145 # here, and the address arrived in 6 s when this was measured (2026-09-04).
146 IP=$(hssh 120 <<H
147 export PATH=/opt/homebrew/bin:\$PATH
148 _i=0
149 while [ \$_i -lt 60 ]; do
150 _ip=\$(tart ip $NAME --resolver=arp 2>/dev/null) && [ -n "\$_ip" ] && { echo "\$_ip"; exit 0; }
151 sleep 1; _i=\$((_i + 1))
152 done
153 exit 1
154 H
155 ) || { say "$NAME took no address within 60 s (host log: /tmp/$NAME.log)"; exit 1; }
156 say "$NAME is at $IP"
157
158 # ---- 4: the guest trusts this box, through the host's own key ---------
159 # The host is the only machine the fresh clone already trusts, so the key
160 # that lets everything else in rides in over the host's. Idempotent, because
161 # a re-provision of a clone that survived is an ordinary thing to do.
162 _i=0
163 while :; do
164 if hssh 60 >/dev/null 2>&1 <<H
165 export PATH=/opt/homebrew/bin:\$PATH
166 ssh $GUEST_OPTS -i \$HOME/.ssh/id_ed25519 $GUSER@$IP /bin/sh -s <<'G'
167 set -eu
168 mkdir -p \$HOME/.ssh
169 chmod 700 \$HOME/.ssh
170 touch \$HOME/.ssh/authorized_keys
171 chmod 600 \$HOME/.ssh/authorized_keys
172 grep -qxF '$PUB' \$HOME/.ssh/authorized_keys ||
173 printf '%s\n' '$PUB' >> \$HOME/.ssh/authorized_keys
174 G
175 H
176 then break; fi
177 _i=$((_i + 1))
178 [ "$_i" -lt 40 ] || { say "$NAME at $IP never answered the host's ssh within 120 s"; exit 1; }
179 sleep 3
180 done
181
182 # ---- 5: and this box can reach it DIRECTLY ----------------------------
183 # The claim the gates rest on, made here where it can still be fixed rather
184 # than in the middle of a run. -n on the ssh: `timeout` runs its child in a
185 # background process group, and an ssh reading a terminal's stdin from there
186 # is stopped by SIGTTIN where the timeout's SIGTERM cannot reach it.
187 # shellcheck disable=SC2086 # GUEST_OPTS is words on purpose
188 GARCH=$(timeout 30 ssh -n $GUEST_OPTS "$GUSER@$IP" 'uname -m') || {
189 say "$GUSER@$IP does not answer ssh from this box — is the LAN bridged onto $IFACE?"
190 exit 1
191 }
192 [ "$GARCH" = "arm64" ] || { say "$GUSER@$IP says it is $GARCH, and the gates want an Apple-silicon Mac"; exit 1; }
193 say "$GUSER@$IP answers this box directly and is an $GARCH macOS"
194 echo "export MAC_BOX=$GUSER@$IP"