kit

kit
git clone https://git.ryansepassi.com/git/kit.git
Log | Files | Refs | README

windows_vm.sh (40504B)


      1 #!/usr/bin/env bash
      2 # Provision and drive a Windows 11 ARM64 VM under QEMU+hvf on Apple Silicon, and
      3 # run kit-produced Windows executables inside it over SSH.
      4 #
      5 # On Apple Silicon there is no hardware acceleration for x86_64, so a single
      6 # hvf-accelerated Windows 11 ARM64 VM serves both Windows targets: aarch64-windows
      7 # binaries run natively and x86_64-windows binaries run via the in-box x64
      8 # emulator (Prism). The `run x64` and `run aarch64` paths therefore target the
      9 # same VM.
     10 #
     11 # The Windows install media is supplied by the user (see KIT_WINDOWS_ISO). The
     12 # virtio-win network driver and OpenSSH server are fetched/bundled and installed
     13 # unattended, so first boot needs no Windows Update and no interactive setup.
     14 
     15 set -eu
     16 
     17 ROOT="$(cd "$(dirname "$0")/.." && pwd)"
     18 TEMPLATE_DIR="$ROOT/scripts/win"
     19 VM_ROOT="${KIT_WINDOWS_VM_DIR:-$ROOT/build/windows-vm}"
     20 XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}"
     21 CACHE_ROOT="${KIT_WINDOWS_CACHE_DIR:-$XDG_CACHE_HOME/kit}"
     22 QEMU_SHARE="${KIT_QEMU_SHARE:-/opt/homebrew/share/qemu}"
     23 QEMU_BIN="${KIT_WINDOWS_QEMU:-qemu-system-aarch64}"
     24 
     25 # Guest / VM settings.
     26 SSH_USER="${KIT_WINDOWS_SSH_USER:-kit}"
     27 SSH_PASS="${KIT_WINDOWS_SSH_PASS:-kit}"
     28 VM_PORT="${KIT_WINDOWS_VM_PORT:-2227}"
     29 VM_MEM="${KIT_WINDOWS_MEM:-6144}"
     30 VM_CPUS="${KIT_WINDOWS_CPUS:-4}"
     31 VM_DISK_SIZE="${KIT_WINDOWS_DISK_SIZE:-64G}"
     32 # QEMU display backend. Headless (none) for automated install/boot + screendump;
     33 # the console commands switch this to a visible window (cocoa on macOS) so the
     34 # install can be driven interactively.
     35 DISPLAY_BACKEND="${KIT_WINDOWS_VM_DISPLAY:-none}"
     36 WIN_EDITION="${KIT_WINDOWS_EDITION:-Windows 11 Pro}"
     37 # Generic Windows 11 Pro install key (no activation; lets Setup skip the key UI).
     38 PRODUCT_KEY="${KIT_WINDOWS_PRODUCT_KEY:-VK7JG-NPHTM-C97JM-9MPGT-3V66T}"
     39 
     40 # Pinned guest helpers.
     41 VIRTIO_VERSION="${KIT_VIRTIO_WIN_VERSION:-0.1.266}"
     42 VIRTIO_SHA256="${KIT_VIRTIO_WIN_SHA256:-57b0f6dc8dc92dc2ae8621f8b1bfbd8a873de9bedc788c4c4b305ea28acc77cd}"
     43 OPENSSH_VERSION="${KIT_OPENSSH_VERSION:-10.0.0.0p2-Preview}"
     44 OPENSSH_SHA256="${KIT_OPENSSH_SHA256:-698c6aec31c1dd0fb996206e8741f4531a97355686b5431ef347d531b07fcd42}"
     45 
     46 POWERSHELL="${KIT_WINDOWS_VM_POWERSHELL:-powershell.exe}"
     47 
     48 # Durable VM state lives in the cache dir so `make clean` (which wipes build/)
     49 # cannot destroy it: the disks, UEFI vars, seed ISO, and — critically — the SSH
     50 # key the golden disk's authorized_keys is baked to. Only ephemeral per-run state
     51 # (pidfile, QMP socket, logs, screenshots) lives under build/.
     52 CACHE_VM="${KIT_WINDOWS_VM_CACHE:-$CACHE_ROOT/windows-vm}"
     53 DISK_PATH="$CACHE_VM/win11-arm64.qcow2"
     54 # Durable "golden" disk: a fully-installed, bootstrapped, cleanly-shut-down image
     55 # so the long install runs once; the working DISK_PATH is restored from it cheaply.
     56 GOLDEN_PATH="${KIT_WINDOWS_GOLDEN:-$CACHE_VM/win11-arm64-golden.qcow2}"
     57 PROVISIONED_MARKER="$CACHE_VM/win11-arm64.provisioned"
     58 VARS_PATH="$CACHE_VM/aarch64-vars.fd"
     59 SEED_PATH="$CACHE_VM/kit-seed.iso"
     60 SSH_KEY_DEFAULT="$CACHE_VM/ssh/id_ed25519"
     61 QMP_SOCK="$VM_ROOT/run/qmp.sock"
     62 PID_FILE="$VM_ROOT/run/qemu.pid"
     63 QEMU_LOG="$VM_ROOT/run/qemu.log"
     64 SHOT_DIR="$VM_ROOT/screenshots"
     65 
     66 usage() {
     67   cat <<EOF
     68 usage: scripts/windows_vm.sh <command> [args...]
     69 
     70 provisioning (single Windows 11 ARM64 VM, serves both arches):
     71   doctor                 print tool / firmware / media / VM status
     72   fetch-virtio           download + verify the pinned virtio-win driver ISO
     73   fetch-openssh          download + verify the pinned Win32-OpenSSH ARM64 zip
     74   seed                   (re)build the unattended seed ISO (autounattend + bootstrap)
     75   prepare                restore golden disk if cached, else stage for install
     76   install                unattended install once, then cache the golden disk
     77   console-install        interactive install in a QEMU window (you drive Setup);
     78                          keeps the seed unless KIT_WINDOWS_NO_SEED=1
     79   console                open a window on the installed VM (manual inspection)
     80   firstboot              finish a fresh install: boot it (no install CD) so the
     81                          first-logon bootstrap runs, then cache the golden disk
     82   boot                   boot the installed VM headless in the background
     83   wait-ssh               wait until the VM answers SSH, then print guest info
     84   ssh [cmd...]           ssh into the running VM
     85   screenshot [name]      capture the VM framebuffer to build/windows-vm/screenshots
     86   snapshot               cache the current (stopped) disk as the golden disk
     87   reset                  restore the working disk from the golden disk
     88   stop                   power down the running VM
     89 
     90 execution (used by the COFF/PE smoke tests):
     91   smoke <arch>           run a small probe in the VM
     92   run <arch> exe [args]  upload exe to the VM, run it, then remove it
     93   run-batch <arch> <dir> <res>
     94                          upload a staging dir's *.exe + run-remote.ps1, run it,
     95                          and bring per-binary res\<id>.{rc,out,err} back into
     96                          <res> (used by test/lib/exec_vm.sh)
     97 
     98 arches:  x64 | x86_64 | amd64 | aarch64 | arm64 | aa64
     99 
    100 env:
    101   KIT_WINDOWS_ISO          path to the Windows 11 ARM64 install ISO (required to
    102                            install; else auto-discovered in $CACHE_ROOT)
    103   KIT_WINDOWS_VM_PORT      host SSH port forwarded to the VM (default $VM_PORT)
    104   KIT_WINDOWS_MEM/CPUS     guest memory MiB / vcpus (default $VM_MEM / $VM_CPUS)
    105   KIT_WINDOWS_VM_X64       SSH destination override for the x64 lane
    106   KIT_WINDOWS_VM_AARCH64   SSH destination override for the arm64 lane
    107   KIT_WINDOWS_VM_*_PORT    optional SSH port overrides
    108   KIT_WINDOWS_VM_SSH_KEY   SSH private key (default $SSH_KEY_DEFAULT)
    109   KIT_WINDOWS_VM_SSH_OPTS  extra ssh options
    110   KIT_WINDOWS_VM_KEEP      keep uploaded temp dirs when set non-empty
    111 EOF
    112 }
    113 
    114 die() {
    115   printf 'windows-vm: %s\n' "$*" >&2
    116   exit 1
    117 }
    118 
    119 canon_arch() {
    120   case "${1:-}" in
    121     x64|x86_64|amd64) echo x64 ;;
    122     aarch64|arm64|aa64) echo aarch64 ;;
    123     *) die "unknown arch '${1:-}'" ;;
    124   esac
    125 }
    126 
    127 # ---------------------------------------------------------------------------
    128 # media discovery + fetch
    129 # ---------------------------------------------------------------------------
    130 
    131 sha256_file() {
    132   if command -v shasum >/dev/null 2>&1; then
    133     shasum -a 256 "$1" | awk '{ print $1 }'
    134   elif command -v sha256sum >/dev/null 2>&1; then
    135     sha256sum "$1" | awk '{ print $1 }'
    136   else
    137     die "missing shasum or sha256sum"
    138   fi
    139 }
    140 
    141 verify_sha256() {
    142   local path=$1 expect=$2 got
    143   [ -n "$expect" ] || return 0
    144   got="$(sha256_file "$path")"
    145   [ "$got" = "$expect" ] ||
    146     die "checksum mismatch for $(basename "$path"): got $got want $expect"
    147 }
    148 
    149 win_iso() {
    150   if [ -n "${KIT_WINDOWS_ISO:-}" ]; then
    151     printf '%s\n' "$KIT_WINDOWS_ISO"
    152     return 0
    153   fi
    154   local d
    155   for d in "$CACHE_ROOT"/Win11*[Aa]rm64*.iso "$CACHE_ROOT"/*[Aa]rm64*.iso; do
    156     [ -f "$d" ] && { printf '%s\n' "$d"; return 0; }
    157   done
    158   return 1
    159 }
    160 
    161 virtio_iso() {
    162   if [ -n "${KIT_VIRTIO_WIN_ISO:-}" ]; then
    163     printf '%s\n' "$KIT_VIRTIO_WIN_ISO"
    164   else
    165     printf '%s\n' "$CACHE_ROOT/virtio-win/virtio-win-$VIRTIO_VERSION.iso"
    166   fi
    167 }
    168 
    169 openssh_zip() {
    170   if [ -n "${KIT_OPENSSH_ZIP:-}" ]; then
    171     printf '%s\n' "$KIT_OPENSSH_ZIP"
    172   else
    173     printf '%s\n' "$CACHE_ROOT/openssh/OpenSSH-ARM64-$OPENSSH_VERSION.zip"
    174   fi
    175 }
    176 
    177 fetch_virtio() {
    178   local out url
    179   out="$(virtio_iso)"
    180   if [ -f "$out" ]; then
    181     verify_sha256 "$out" "$VIRTIO_SHA256"
    182     printf 'virtio-win present: %s\n' "$out"
    183     return 0
    184   fi
    185   command -v curl >/dev/null 2>&1 || die "curl not found"
    186   url="https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/archive-virtio/virtio-win-$VIRTIO_VERSION-1/virtio-win-$VIRTIO_VERSION.iso"
    187   mkdir -p "$(dirname "$out")"
    188   printf 'download: %s\n' "$url"
    189   curl -fL --retry 3 --retry-delay 2 -o "$out.part" "$url"
    190   mv "$out.part" "$out"
    191   verify_sha256 "$out" "$VIRTIO_SHA256"
    192   printf 'verified: %s\n' "$out"
    193 }
    194 
    195 fetch_openssh() {
    196   local out url
    197   out="$(openssh_zip)"
    198   if [ -f "$out" ]; then
    199     verify_sha256 "$out" "$OPENSSH_SHA256"
    200     printf 'openssh present: %s\n' "$out"
    201     return 0
    202   fi
    203   command -v curl >/dev/null 2>&1 || die "curl not found"
    204   url="https://github.com/PowerShell/Win32-OpenSSH/releases/download/$OPENSSH_VERSION/OpenSSH-ARM64.zip"
    205   mkdir -p "$(dirname "$out")"
    206   printf 'download: %s\n' "$url"
    207   curl -fL --retry 3 --retry-delay 2 -o "$out.part" "$url"
    208   mv "$out.part" "$out"
    209   verify_sha256 "$out" "$OPENSSH_SHA256"
    210   printf 'verified: %s\n' "$out"
    211 }
    212 
    213 # ---------------------------------------------------------------------------
    214 # provisioning artifacts
    215 # ---------------------------------------------------------------------------
    216 
    217 ensure_ssh_key() {
    218   local key="${KIT_WINDOWS_VM_SSH_KEY:-$SSH_KEY_DEFAULT}"
    219   if [ ! -f "$key" ]; then
    220     mkdir -p "$(dirname "$key")"
    221     ssh-keygen -q -t ed25519 -N "" -C "kit-windows-vm" -f "$key"
    222   fi
    223   printf '%s\n' "$key"
    224 }
    225 
    226 create_disk() {
    227   command -v qemu-img >/dev/null 2>&1 || die "qemu-img not found"
    228   mkdir -p "$(dirname "$DISK_PATH")"
    229   if [ -f "$DISK_PATH" ]; then
    230     printf 'disk already present: %s\n' "$DISK_PATH"
    231   else
    232     qemu-img create -f qcow2 "$DISK_PATH" "$VM_DISK_SIZE" >/dev/null
    233     printf 'disk created: %s (%s)\n' "$DISK_PATH" "$VM_DISK_SIZE"
    234   fi
    235 }
    236 
    237 # Save the cleanly-shut-down working disk to the durable golden cache.
    238 cache_golden() {
    239   vm_running && die "stop the VM before caching the golden disk"
    240   [ -f "$DISK_PATH" ] || die "no working disk to cache: $DISK_PATH"
    241   command -v qemu-img >/dev/null 2>&1 || die "qemu-img not found"
    242   mkdir -p "$(dirname "$GOLDEN_PATH")"
    243   printf 'cache golden disk: %s -> %s\n' "$DISK_PATH" "$GOLDEN_PATH"
    244   qemu-img convert -O qcow2 -c "$DISK_PATH" "$GOLDEN_PATH.tmp"
    245   mv "$GOLDEN_PATH.tmp" "$GOLDEN_PATH"
    246   touch "$PROVISIONED_MARKER"
    247   printf 'golden cached. boot with: scripts/windows_vm.sh boot\n'
    248 }
    249 
    250 # Restore the working disk from the golden cache (cheap; no reinstall).
    251 restore_golden() {
    252   [ -f "$GOLDEN_PATH" ] || die "no golden disk cached: $GOLDEN_PATH (run install first)"
    253   vm_running && die "stop the VM before restoring the golden disk"
    254   command -v qemu-img >/dev/null 2>&1 || die "qemu-img not found"
    255   mkdir -p "$(dirname "$DISK_PATH")"
    256   printf 'restore golden disk: %s -> %s\n' "$GOLDEN_PATH" "$DISK_PATH"
    257   qemu-img convert -O qcow2 "$GOLDEN_PATH" "$DISK_PATH.tmp"
    258   mv "$DISK_PATH.tmp" "$DISK_PATH"
    259   touch "$PROVISIONED_MARKER"
    260 }
    261 
    262 firmware_code() { printf '%s\n' "$QEMU_SHARE/edk2-aarch64-code.fd"; }
    263 firmware_vars_template() { printf '%s\n' "$QEMU_SHARE/edk2-arm-vars.fd"; }
    264 
    265 ensure_firmware_vars() {
    266   local code tmpl
    267   code="$(firmware_code)"
    268   tmpl="$(firmware_vars_template)"
    269   [ -f "$code" ] || die "missing QEMU firmware code: $code"
    270   [ -f "$tmpl" ] || die "missing QEMU firmware vars template: $tmpl"
    271   mkdir -p "$(dirname "$VARS_PATH")"
    272   # Keep the vars file across boots: it holds the "Windows Boot Manager" NVRAM
    273   # entry created during install. Only seed it from the pristine template when
    274   # it does not exist yet.
    275   if [ ! -f "$VARS_PATH" ]; then
    276     cp "$tmpl" "$VARS_PATH"
    277   fi
    278 }
    279 
    280 # Reset UEFI NVRAM to the pristine template. Used at the start of an install so a
    281 # prior attempt's stale boot entries (e.g. a "Windows Boot Manager" pointing at a
    282 # now-wiped GPT partition) cannot derail the boot order; the install itself
    283 # writes the correct entry, which then persists for later boots.
    284 reset_firmware_vars() {
    285   local code tmpl
    286   code="$(firmware_code)"
    287   tmpl="$(firmware_vars_template)"
    288   [ -f "$code" ] || die "missing QEMU firmware code: $code"
    289   [ -f "$tmpl" ] || die "missing QEMU firmware vars template: $tmpl"
    290   mkdir -p "$(dirname "$VARS_PATH")"
    291   cp "$tmpl" "$VARS_PATH"
    292 }
    293 
    294 build_seed() {
    295   local key pub seeddir
    296   command -v hdiutil >/dev/null 2>&1 || die "hdiutil not found (macOS host required)"
    297   key="$(ensure_ssh_key)"
    298   pub="$(cat "$key.pub")"
    299   fetch_virtio >/dev/null
    300   fetch_openssh >/dev/null
    301   [ -r "$TEMPLATE_DIR/autounattend.xml.in" ] || die "missing $TEMPLATE_DIR/autounattend.xml.in"
    302   [ -r "$TEMPLATE_DIR/bootstrap.ps1.in" ] || die "missing $TEMPLATE_DIR/bootstrap.ps1.in"
    303 
    304   mkdir -p "$CACHE_VM"
    305   seeddir="$CACHE_VM/seed-contents"
    306   rm -rf "$seeddir"
    307   mkdir -p "$seeddir/kit"
    308 
    309   # autounattend.xml lives at the media root so Windows Setup auto-detects it.
    310   sed -e "s|@WIN_EDITION@|$WIN_EDITION|g" \
    311       -e "s|@PRODUCT_KEY@|$PRODUCT_KEY|g" \
    312       -e "s|@KIT_USER@|$SSH_USER|g" \
    313       -e "s|@KIT_PASS@|$SSH_PASS|g" \
    314       "$TEMPLATE_DIR/autounattend.xml.in" > "$seeddir/autounattend.xml"
    315 
    316   # bootstrap.ps1 + the OpenSSH bundle live under \kit on the seed media.
    317   # Substitute the public key via a temp file to keep slashes/+ literal.
    318   awk -v pub="$pub" -v user="$SSH_USER" '
    319     { gsub(/@SSH_PUBKEY@/, pub); gsub(/@KIT_USER@/, user); print }
    320   ' "$TEMPLATE_DIR/bootstrap.ps1.in" > "$seeddir/kit/bootstrap.ps1"
    321   cp "$(openssh_zip)" "$seeddir/kit/OpenSSH-ARM64.zip"
    322 
    323   rm -f "$SEED_PATH"
    324   hdiutil makehybrid -quiet -iso -joliet -default-volume-name KITSEED \
    325     -o "$SEED_PATH" "$seeddir"
    326   printf 'seed ready: %s\n' "$SEED_PATH"
    327 }
    328 
    329 prepare() {
    330   ensure_firmware_vars
    331   ensure_ssh_key >/dev/null
    332   if [ -f "$GOLDEN_PATH" ]; then
    333     restore_golden
    334     printf 'prepared from golden disk. boot with: scripts/windows_vm.sh boot\n'
    335   else
    336     create_disk
    337     build_seed
    338     printf 'prepared for install. install with: scripts/windows_vm.sh install\n'
    339   fi
    340 }
    341 
    342 # ---------------------------------------------------------------------------
    343 # QMP control (python handles the capabilities handshake)
    344 # ---------------------------------------------------------------------------
    345 
    346 qmp() {
    347   # qmp <json-command> [<json-command> ...] : run commands over the QMP socket.
    348   [ -S "$QMP_SOCK" ] || { printf 'windows-vm: QMP socket not present (%s)\n' "$QMP_SOCK" >&2; return 1; }
    349   python3 - "$QMP_SOCK" "$@" <<'PY'
    350 import socket, sys, json
    351 sock = sys.argv[1]
    352 cmds = sys.argv[2:]
    353 s = socket.socket(socket.AF_UNIX)
    354 s.settimeout(10)
    355 s.connect(sock)
    356 f = s.makefile('rwb', buffering=0)
    357 f.readline()  # greeting
    358 f.write(b'{"execute":"qmp_capabilities"}\n'); f.readline()
    359 for c in cmds:
    360     f.write((c + "\n").encode())
    361     print(f.readline().decode().strip())
    362 PY
    363 }
    364 
    365 screenshot() {
    366   local name="${1:-shot}" base ppm png
    367   mkdir -p "$SHOT_DIR"
    368   base="$SHOT_DIR/$name"
    369   png="$base.png"
    370   ppm="$base.ppm"
    371   rm -f "$png" "$ppm"
    372   # QEMU 6+ supports PNG output directly; fall back to PPM + pnmtopng.
    373   if qmp "{\"execute\":\"screendump\",\"arguments\":{\"filename\":\"$png\",\"format\":\"png\"}}" 2>/dev/null \
    374        | grep -q '"return"' && [ -s "$png" ]; then
    375     printf '%s\n' "$png"
    376     return 0
    377   fi
    378   qmp "{\"execute\":\"screendump\",\"arguments\":{\"filename\":\"$ppm\"}}" >/dev/null 2>&1 || true
    379   if [ -s "$ppm" ] && command -v pnmtopng >/dev/null 2>&1; then
    380     pnmtopng "$ppm" > "$png" 2>/dev/null && { rm -f "$ppm"; printf '%s\n' "$png"; return 0; }
    381   fi
    382   [ -s "$ppm" ] && { printf '%s\n' "$ppm"; return 0; }
    383   return 1
    384 }
    385 
    386 vm_running() {
    387   # True if the background VM (pidfile) or an interactive console VM (foreground,
    388   # no pidfile) is running. The process check keeps `snapshot`/`stop` safe after
    389   # an interactive `console-install`, which launches QEMU without a pidfile.
    390   # Anchor on argv[0] (`^kit-qemu-win`) so this matches only the QEMU process,
    391   # not management scripts whose command line happens to mention "kit-qemu-win".
    392   if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then return 0; fi
    393   pgrep -f '^kit-qemu-win' >/dev/null 2>&1
    394 }
    395 
    396 guest_shutdown() {
    397   # Clean ACPI shutdown so the golden disk is not crash-consistent (avoids a
    398   # chkdsk/repair pass on next boot). Prefer an in-guest shutdown, fall back to
    399   # the ACPI power button over QMP.
    400   local i
    401   ssh_setup aarch64 2>/dev/null && \
    402     ssh "${SSH_ARGS[@]}" "$SSH_DEST" "shutdown /s /t 0 /f" >/dev/null 2>&1 || true
    403   qmp '{"execute":"system_powerdown"}' >/dev/null 2>&1 || true
    404   for i in $(seq 1 120); do
    405     vm_running || { rm -f "$PID_FILE"; return 0; }
    406     sleep 2
    407   done
    408   return 1
    409 }
    410 
    411 powerdown() {
    412   if vm_running; then
    413     qmp '{"execute":"system_powerdown"}' >/dev/null 2>&1 || true
    414     printf 'sent ACPI powerdown; waiting for shutdown...\n'
    415     local i
    416     for i in $(seq 1 60); do
    417       vm_running || { printf 'vm stopped\n'; rm -f "$PID_FILE"; return 0; }
    418       sleep 2
    419     done
    420     printf 'still running; killing\n'
    421     kill "$(cat "$PID_FILE")" 2>/dev/null || true
    422   else
    423     printf 'no running vm\n'
    424   fi
    425   rm -f "$PID_FILE"
    426 }
    427 
    428 # ---------------------------------------------------------------------------
    429 # QEMU launch
    430 # ---------------------------------------------------------------------------
    431 
    432 base_qemu_args() {
    433   # Emits the device set shared by install and normal boots.
    434   local code
    435   code="$(firmware_code)"
    436   QEMU_ARGS=(
    437     -name kit-win11-arm64
    438     -L "$QEMU_SHARE"
    439     -machine virt
    440     -accel hvf
    441     -cpu host
    442     -m "$VM_MEM"
    443     -smp "$VM_CPUS"
    444     -drive "if=pflash,format=raw,readonly=on,file=$code"
    445     -drive "if=pflash,format=raw,file=$VARS_PATH"
    446     -device ramfb
    447     -device "qemu-xhci,id=xhci"
    448     -device usb-kbd
    449     -device usb-tablet
    450     -nic "user,model=virtio-net-pci,hostfwd=tcp:127.0.0.1:$VM_PORT-:22"
    451     -drive "if=none,id=sysdisk,file=$DISK_PATH,format=qcow2,cache=writeback,discard=unmap"
    452     -device "nvme,drive=sysdisk,serial=kitwin,bootindex=0"
    453     -display "$DISPLAY_BACKEND"
    454     -qmp "unix:$QMP_SOCK,server,nowait"
    455     -serial "file:$VM_ROOT/run/serial.log"
    456   )
    457 }
    458 
    459 launch_qemu() {
    460   # launch_qemu : start QEMU in the background using the assembled QEMU_ARGS.
    461   command -v "$QEMU_BIN" >/dev/null 2>&1 || die "$QEMU_BIN not found on PATH"
    462   mkdir -p "$VM_ROOT/run"
    463   rm -f "$QMP_SOCK"
    464   : > "$QEMU_LOG"
    465   # Launch under a distinct argv[0] so a broad `pkill -f qemu-system-aarch64`
    466   # (e.g. from a concurrent FreeBSD VM workflow sharing this repo) cannot match
    467   # and kill this VM. QEMU resolves its data dir from the real executable path,
    468   # not argv[0], and we pass -L explicitly, so the rename is invisible to QEMU.
    469   ( exec -a kit-qemu-win "$QEMU_BIN" "${QEMU_ARGS[@]}" ) >>"$QEMU_LOG" 2>&1 &
    470   echo $! > "$PID_FILE"
    471   printf 'qemu pid %s; log %s\n' "$(cat "$PID_FILE")" "$QEMU_LOG"
    472   # Wait for the QMP socket so callers can drive the monitor.
    473   local i
    474   for i in $(seq 1 30); do
    475     [ -S "$QMP_SOCK" ] && return 0
    476     vm_running || { tail -20 "$QEMU_LOG" >&2; die "qemu exited during startup"; }
    477     sleep 1
    478   done
    479   die "QMP socket did not appear"
    480 }
    481 
    482 # Attach the install + virtio + seed media as USB mass-storage (in-box usbstor
    483 # driver) so UEFI can boot the installer and Setup can read the seed + drivers.
    484 # The system NVMe disk is bootindex=0; the install CD is bootindex=1 so the empty
    485 # disk falls through to the CD on the first boot, but once Setup writes the
    486 # Windows Boot Manager to NVMe every later reboot boots the installed OS instead
    487 # of looping back into the installer.
    488 attach_install_media() {
    489   local iso="$1" virtio="$2" seed="${3:-}"
    490   QEMU_ARGS+=(
    491     -device "usb-storage,drive=installcd,bootindex=1"
    492     -drive "if=none,id=installcd,format=raw,media=cdrom,readonly=on,file=$iso"
    493     -device "usb-storage,drive=virtiocd"
    494     -drive "if=none,id=virtiocd,format=raw,media=cdrom,readonly=on,file=$virtio"
    495   )
    496   [ -n "$seed" ] && QEMU_ARGS+=(
    497     -device "usb-storage,drive=seedcd"
    498     -drive "if=none,id=seedcd,format=raw,media=cdrom,readonly=on,file=$seed"
    499   )
    500 }
    501 
    502 install_vm() {
    503   local iso virtio seed code
    504   iso="$(win_iso)" || die "no Windows ISO; set KIT_WINDOWS_ISO or place Win11*Arm64*.iso in $CACHE_ROOT"
    505   [ -f "$iso" ] || die "Windows ISO not found: $iso"
    506   vm_running && die "a VM is already running (pid $(cat "$PID_FILE")); stop it first"
    507   if [ -f "$GOLDEN_PATH" ]; then
    508     die "golden disk already exists ($GOLDEN_PATH); 'boot' to use it, 'reset' to revert to it, or remove it to reinstall"
    509   fi
    510 
    511   # Start each install from a fresh blank disk: a prior attempt may have left a
    512   # half-installed disk, and autounattend wipes disk 0 regardless.
    513   rm -f "$DISK_PATH"
    514   create_disk
    515   reset_firmware_vars
    516   build_seed
    517   virtio="$(virtio_iso)"
    518   seed="$SEED_PATH"
    519   [ -f "$virtio" ] || die "virtio-win ISO missing: $virtio (run fetch-virtio)"
    520 
    521   base_qemu_args
    522   attach_install_media "$iso" "$virtio" "$seed"
    523 
    524   printf 'installing Windows from: %s\n' "$iso"
    525   printf '  edition : %s\n' "$WIN_EDITION"
    526   printf '  virtio  : %s\n' "$virtio"
    527   printf '  seed    : %s\n' "$seed"
    528   printf '  ssh     : %s@127.0.0.1:%s\n' "$SSH_USER" "$VM_PORT"
    529   launch_qemu
    530 
    531   # Dismiss the one-time "Press any key to boot from CD or DVD" prompt. Send Enter
    532   # only until Setup starts writing the NVMe disk (proof it booted past the
    533   # prompt), then stop — otherwise a later Enter lands on the install-progress
    534   # screen's Cancel button and pops a "quit?" dialog. Watching disk growth makes
    535   # this robust to a slow firmware POST without overshooting into Setup's UI.
    536   ( local i sz
    537     for i in $(seq 1 180); do
    538       sz=$(stat -f %z "$DISK_PATH" 2>/dev/null || echo 0)
    539       [ "$sz" -gt 2000000 ] && break
    540       qmp '{"execute":"send-key","arguments":{"keys":[{"type":"qcode","data":"ret"}]}}' >/dev/null 2>&1 || true
    541       sleep 2
    542     done ) &
    543 
    544   # Phase 1 (apply): Setup writes the image to the NVMe disk, then reboots and
    545   # loops on the install CD. Detect "image applied + settled" by the disk size
    546   # leveling off, then stop and hand off to firstboot_vm (which boots the
    547   # installed OS without the install CD).
    548   printf 'applying the Windows image (long); switching to first boot when done...\n'
    549   local cur=0 last=-1 stable=0 i=0
    550   while [ $i -lt 3600 ]; do
    551     vm_running || die "qemu exited during image apply (see $QEMU_LOG)"
    552     cur=$(stat -f %z "$DISK_PATH" 2>/dev/null || echo 0)
    553     if [ "$cur" -gt 9000000000 ]; then
    554       if [ "$cur" -eq "$last" ]; then stable=$((stable + 1)); else stable=0; fi
    555       [ "$stable" -ge 3 ] && break
    556     fi
    557     last=$cur
    558     [ $((i % 120)) -eq 0 ] && screenshot "apply-$i" >/dev/null 2>&1 || true
    559     sleep 20
    560     i=$((i + 20))
    561   done
    562   [ "$cur" -gt 9000000000 ] ||
    563     die "image apply did not complete (disk only $cur bytes; see $SHOT_DIR and $QEMU_LOG)"
    564   printf 'image applied (%s bytes on disk); stopping apply phase\n' "$cur"
    565   kill "$(cat "$PID_FILE" 2>/dev/null)" 2>/dev/null || true
    566   for _ in $(seq 1 30); do vm_running || break; sleep 1; done
    567   rm -f "$PID_FILE"
    568   firstboot_vm
    569 }
    570 
    571 boot_vm() {
    572   vm_running && { printf 'vm already running (pid %s)\n' "$(cat "$PID_FILE")"; return 0; }
    573   if [ ! -f "$DISK_PATH" ]; then
    574     [ -f "$GOLDEN_PATH" ] || die "no installed disk and no golden cache; run install first"
    575     restore_golden
    576   fi
    577   ensure_firmware_vars
    578   base_qemu_args
    579   launch_qemu
    580   printf 'booted installed VM (pid %s)\n' "$(cat "$PID_FILE")"
    581 }
    582 
    583 # Interactive install: open a real QEMU window and let the user drive Setup
    584 # (press a key at the boot prompt, click through any screens). Runs in the
    585 # foreground. The autounattend seed is still attached by default so the kit user,
    586 # OpenSSH, virtio NIC, and SSH key are configured automatically; set
    587 # KIT_WINDOWS_NO_SEED=1 for a fully manual install. When Windows is installed and
    588 # shut down, run `snapshot` to cache it as the golden disk.
    589 console_install() {
    590   local iso virtio seed=""
    591   iso="$(win_iso)" || die "no Windows ISO; set KIT_WINDOWS_ISO or place Win11*Arm64*.iso in $CACHE_ROOT"
    592   [ -f "$iso" ] || die "Windows ISO not found: $iso"
    593   vm_running && die "the headless VM is running (pid $(cat "$PID_FILE")); stop it first: scripts/windows_vm.sh stop"
    594   if [ -f "$GOLDEN_PATH" ]; then
    595     die "golden disk already exists ($GOLDEN_PATH); 'console' boots it, 'reset' reverts to it, or remove it to reinstall"
    596   fi
    597   command -v "$QEMU_BIN" >/dev/null 2>&1 || die "$QEMU_BIN not found on PATH"
    598   rm -f "$DISK_PATH"
    599   create_disk
    600   reset_firmware_vars
    601   virtio="$(virtio_iso)"
    602   [ -f "$virtio" ] || die "virtio-win ISO missing: $virtio (run fetch-virtio)"
    603   if [ -z "${KIT_WINDOWS_NO_SEED:-}" ]; then build_seed; seed="$SEED_PATH"; fi
    604   DISPLAY_BACKEND="${KIT_WINDOWS_VM_DISPLAY:-cocoa}"
    605   mkdir -p "$VM_ROOT/run"
    606   rm -f "$QMP_SOCK"
    607   base_qemu_args
    608   attach_install_media "$iso" "$virtio" "$seed"
    609   cat <<MSG
    610 Interactive Windows install — a QEMU window will open.
    611   * Press a key at "Press any key to boot from CD or DVD" to boot the installer.$(
    612     [ -n "$seed" ] && printf '\n  * The autounattend seed then drives Setup (kit user, OpenSSH, virtio NIC, SSH key).' \
    613                    || printf '\n  * No seed attached: install Windows manually; SSH must be set up afterwards.')
    614   * When Windows reaches the desktop, shut it down (Start > Power > Shut down).
    615   * Then cache it as the golden disk:  scripts/windows_vm.sh snapshot
    616 MSG
    617   exec -a kit-qemu-win "$QEMU_BIN" "${QEMU_ARGS[@]}"
    618 }
    619 
    620 # Open a window on the installed VM for manual inspection / interactive driving.
    621 # Runs in the foreground (cocoa). Before the install is finalized (no golden disk
    622 # yet) it boots like a first boot: reset NVRAM (so the firmware enumerates the
    623 # populated disk and boots Windows' ARM fallback loader) and attach the virtio +
    624 # seed media so the first-logon bootstrap can run or be inspected. Once a golden
    625 # disk exists it boots clean (no CDs, keeping NVRAM).
    626 console_boot() {
    627   vm_running && die "a VM is already running (pid $(cat "$PID_FILE")); stop it first"
    628   command -v "$QEMU_BIN" >/dev/null 2>&1 || die "$QEMU_BIN not found on PATH"
    629   DISPLAY_BACKEND="${KIT_WINDOWS_VM_DISPLAY:-cocoa}"
    630   mkdir -p "$VM_ROOT/run"
    631   rm -f "$QMP_SOCK"
    632   if [ -f "$GOLDEN_PATH" ] || [ -f "$PROVISIONED_MARKER" ]; then
    633     [ -f "$DISK_PATH" ] || restore_golden
    634     ensure_firmware_vars
    635     base_qemu_args
    636     printf 'Opening a window on the installed VM. Close it or shut down the guest to exit.\n'
    637   else
    638     [ -f "$DISK_PATH" ] || die "no installed disk; run console-install first"
    639     local virtio
    640     virtio="$(virtio_iso)"
    641     [ -f "$SEED_PATH" ] || build_seed
    642     reset_firmware_vars
    643     base_qemu_args
    644     [ -f "$virtio" ] && QEMU_ARGS+=(
    645       -device "usb-storage,drive=virtiocd"
    646       -drive "if=none,id=virtiocd,format=raw,media=cdrom,readonly=on,file=$virtio"
    647     )
    648     QEMU_ARGS+=(
    649       -device "usb-storage,drive=seedcd"
    650       -drive "if=none,id=seedcd,format=raw,media=cdrom,readonly=on,file=$SEED_PATH"
    651     )
    652     printf 'Opening a window on the (not-yet-finalized) VM: fresh NVRAM + virtio/seed attached.\n'
    653     printf 'Diagnostics inside the guest: C:\\kit-bootstrap.log, C:\\kit-ready.txt, services.msc (sshd), firewall.\n'
    654   fi
    655   exec -a kit-qemu-win "$QEMU_BIN" "${QEMU_ARGS[@]}"
    656 }
    657 
    658 # First boot of a freshly-installed disk. The Windows installer applies the image
    659 # and then reboots; if the bootable install CD is still in the boot path the
    660 # emulated firmware loops back into Setup instead of booting the new OS (its NVRAM
    661 # "Windows Boot Manager" entry does not persist under QEMU). So we reset NVRAM
    662 # (forcing a fresh enumeration that finds Windows' ARM fallback loader,
    663 # \EFI\Boot\bootaa64.efi) and boot WITHOUT the install CD but WITH the virtio +
    664 # seed media, so the FirstLogon bootstrap can install the NIC driver + OpenSSH.
    665 # On success the disk is cleanly shut down and cached as the golden disk.
    666 firstboot_vm() {
    667   local virtio
    668   vm_running && die "a VM is already running (pid $(cat "$PID_FILE")); stop it first"
    669   [ -f "$DISK_PATH" ] || die "no installed disk; run install or console-install first"
    670   [ -f "$GOLDEN_PATH" ] && die "golden disk already exists ($GOLDEN_PATH); use boot/reset"
    671   virtio="$(virtio_iso)"
    672   [ -f "$virtio" ] || die "virtio-win ISO missing: $virtio (run fetch-virtio)"
    673   [ -f "$SEED_PATH" ] || build_seed
    674   reset_firmware_vars
    675   base_qemu_args
    676   QEMU_ARGS+=(
    677     -device "usb-storage,drive=virtiocd"
    678     -drive "if=none,id=virtiocd,format=raw,media=cdrom,readonly=on,file=$virtio"
    679     -device "usb-storage,drive=seedcd"
    680     -drive "if=none,id=seedcd,format=raw,media=cdrom,readonly=on,file=$SEED_PATH"
    681   )
    682   printf 'first boot (no install CD): finishing setup + first-logon bootstrap...\n'
    683   launch_qemu
    684   wait_ssh 2400
    685   printf 'bootstrap complete; shutting down cleanly to cache the golden disk...\n'
    686   if guest_shutdown; then
    687     cache_golden
    688   else
    689     printf 'WARNING: clean shutdown timed out; golden NOT cached.\n'
    690     printf 'Run: scripts/windows_vm.sh stop && scripts/windows_vm.sh snapshot\n'
    691   fi
    692 }
    693 
    694 # ---------------------------------------------------------------------------
    695 # SSH plumbing (also used by run/smoke)
    696 # ---------------------------------------------------------------------------
    697 
    698 vm_dest() {
    699   case "$(canon_arch "$1")" in
    700     x64)
    701       if [ -n "${KIT_WINDOWS_VM_X64:-${KIT_WINDOWS_VM_AMD64:-}}" ]; then
    702         printf '%s\n' "${KIT_WINDOWS_VM_X64:-$KIT_WINDOWS_VM_AMD64}"; return 0
    703       fi ;;
    704     aarch64)
    705       if [ -n "${KIT_WINDOWS_VM_AARCH64:-${KIT_WINDOWS_VM_ARM64:-}}" ]; then
    706         printf '%s\n' "${KIT_WINDOWS_VM_AARCH64:-$KIT_WINDOWS_VM_ARM64}"; return 0
    707       fi ;;
    708   esac
    709   # Fall back to the locally provisioned VM (one VM serves both arches).
    710   if [ -f "$DISK_PATH" ]; then printf '%s@127.0.0.1\n' "$SSH_USER"; fi
    711 }
    712 
    713 vm_port() {
    714   case "$(canon_arch "$1")" in
    715     x64) [ -n "${KIT_WINDOWS_VM_X64_PORT:-${KIT_WINDOWS_VM_AMD64_PORT:-}}" ] &&
    716          { printf '%s\n' "${KIT_WINDOWS_VM_X64_PORT:-$KIT_WINDOWS_VM_AMD64_PORT}"; return 0; } ;;
    717     aarch64) [ -n "${KIT_WINDOWS_VM_AARCH64_PORT:-${KIT_WINDOWS_VM_ARM64_PORT:-}}" ] &&
    718          { printf '%s\n' "${KIT_WINDOWS_VM_AARCH64_PORT:-$KIT_WINDOWS_VM_ARM64_PORT}"; return 0; } ;;
    719   esac
    720   if [ -f "$DISK_PATH" ]; then printf '%s\n' "$VM_PORT"; fi
    721 }
    722 
    723 ssh_setup() {
    724   local arch="$1" port key opts dest
    725   dest="$(vm_dest "$arch")"
    726   [ -n "$dest" ] || die "no VM configured for $(canon_arch "$arch")"
    727   SSH_DEST="$dest"
    728   SSH_ARGS=()
    729   port="$(vm_port "$arch")"
    730   key="${KIT_WINDOWS_VM_SSH_KEY:-}"
    731   if [ -z "$key" ] && [ -f "$SSH_KEY_DEFAULT" ]; then key="$SSH_KEY_DEFAULT"; fi
    732   opts="${KIT_WINDOWS_VM_SSH_OPTS:-}"
    733   if [ -n "$opts" ]; then
    734     # Intentional word-splitting of a user-provided ssh option string.
    735     # shellcheck disable=SC2206
    736     SSH_ARGS=($opts)
    737   fi
    738   [ -n "$key" ] && SSH_ARGS=("${SSH_ARGS[@]}" -i "$key")
    739   # Use -o Port= (not -p) so SSH_ARGS works verbatim for both ssh and scp.
    740   [ -n "$port" ] && SSH_ARGS=("${SSH_ARGS[@]}" -o "Port=$port")
    741   SSH_ARGS=("${SSH_ARGS[@]}"
    742     -o BatchMode=yes
    743     -o StrictHostKeyChecking=no
    744     -o UserKnownHostsFile=/dev/null
    745     -o LogLevel=ERROR
    746     -o ConnectTimeout=8)
    747 }
    748 
    749 remote_ps() {
    750   # Send the PowerShell program as -EncodedCommand (UTF-16LE base64). The guest's
    751   # default ssh shell is cmd, and ssh flattens the remote argv into one string, so
    752   # a bare -Command "<script>" lets cmd mis-parse the script's pipes/quotes (e.g.
    753   # `| Out-Null`). Base64 has no shell-special characters, so it travels intact.
    754   # stdin (used by the upload step) still flows through to the PowerShell process.
    755   local enc full
    756   # Silence the progress stream so it does not show up as CLIXML noise on stderr.
    757   full='$ProgressPreference = "SilentlyContinue"; '"$1"
    758   enc=$(printf '%s' "$full" | iconv -t UTF-16LE | base64 | tr -d '\n')
    759   ssh "${SSH_ARGS[@]}" "$SSH_DEST" "$POWERSHELL" -NoProfile -ExecutionPolicy Bypass -EncodedCommand "$enc"
    760 }
    761 
    762 ps_sq() { printf '%s' "$1" | sed "s/'/''/g"; }
    763 b64_arg() { printf '%s' "$1" | base64 | tr -d '\n'; }
    764 
    765 ps_arg_array() {
    766   local first=1 arg enc
    767   printf '@('
    768   for arg in "$@"; do
    769     enc="$(b64_arg "$arg")"
    770     if [ "$first" -eq 0 ]; then printf ','; fi
    771     first=0
    772     printf "'%s'" "$enc"
    773   done
    774   printf ')'
    775 }
    776 
    777 ps_env_assignments() {
    778   local names name val
    779   names="${KIT_WINDOWS_VM_ENV_VARS:-KIT_WIN_PROBE}"
    780   for name in $names; do
    781     if [ -n "${!name+x}" ]; then
    782       val="${!name}"
    783       printf '$env:%s = '\''%s'\''; ' "$name" "$(ps_sq "$val")"
    784     fi
    785   done
    786 }
    787 
    788 remote_mkdir() {
    789   local token ps
    790   token="kit-vm-$(date +%Y%m%d%H%M%S)-$$-$RANDOM"
    791   ps="\$ErrorActionPreference='Stop'; \$d=Join-Path \$env:TEMP '$(ps_sq "$token")'; New-Item -ItemType Directory -Force -Path \$d | Out-Null; [Console]::Out.Write(\$d)"
    792   remote_ps "$ps"
    793 }
    794 
    795 remote_cleanup() {
    796   local dir=$1 ps
    797   [ -n "${KIT_WINDOWS_VM_KEEP:-}" ] && return 0
    798   ps="\$d='$(ps_sq "$dir")'; if (Test-Path -LiteralPath \$d) { Remove-Item -LiteralPath \$d -Recurse -Force }"
    799   remote_ps "$ps" >/dev/null 2>&1 || true
    800 }
    801 
    802 run_exe() {
    803   local arch="$1" exe="$2" destdir base dest_fwd run_ps args_ps env_ps rc
    804   shift 2
    805   [ -f "$exe" ] || die "exe not found: $exe"
    806   command -v ssh >/dev/null 2>&1 || die "ssh not found"
    807   command -v scp >/dev/null 2>&1 || die "scp not found"
    808   command -v base64 >/dev/null 2>&1 || die "base64 not found"
    809   ssh_setup "$arch"
    810   destdir="$(remote_mkdir)"
    811   base="$(basename "$exe")"
    812   # Upload over scp (Win32-OpenSSH ships the sftp subsystem). scp needs a
    813   # forward-slash remote path; the run step below uses the native backslash path.
    814   dest_fwd="${destdir//\\//}/$base"
    815   if ! scp "${SSH_ARGS[@]}" "$exe" "$SSH_DEST:$dest_fwd" >/dev/null 2>&1; then
    816     remote_cleanup "$destdir"
    817     die "scp upload failed: $exe -> $dest_fwd"
    818   fi
    819   args_ps="$(ps_arg_array "$@")"
    820   env_ps="$(ps_env_assignments)"
    821   run_ps="\$ErrorActionPreference='Stop'; ${env_ps}\$exe=Join-Path '$(ps_sq "$destdir")' '$(ps_sq "$base")'; \$argv_b64=$args_ps; \$argv=@(); foreach (\$a in \$argv_b64) { \$argv += [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(\$a)) }; & \$exe @argv; \$code=\$LASTEXITCODE; if (\$null -eq \$code) { \$code=0 }; exit \$code"
    822   set +e
    823   remote_ps "$run_ps"
    824   rc=$?
    825   set -e
    826   remote_cleanup "$destdir"
    827   return "$rc"
    828 }
    829 
    830 # run-batch ARCH STAGEDIR RESULTSDIR
    831 #   Run a whole staging dir in one VM session and bring per-binary results back.
    832 #   Pure transport: upload the *.exe + run-remote.ps1 entry script (authored by
    833 #   the caller; it writes res\<id>.{rc,out,err} per binary), run it, then bring
    834 #   the res\ dir back, flattened into RESULTSDIR. One upload+run per arch.
    835 #   Mirrors run_exe's ssh/scp plumbing. The VM must already be reachable.
    836 run_batch() {
    837   local arch="$1" stage="$2" resultsdir="$3" destdir dest_fwd run_ps tmp
    838   [ -d "$stage" ] || die "run-batch: no such staging dir: $stage"
    839   [ -f "$stage/run-remote.ps1" ] || die "run-batch: missing $stage/run-remote.ps1"
    840   [ -n "$resultsdir" ] || die "run-batch: results dir required"
    841   command -v ssh >/dev/null 2>&1 || die "ssh not found"
    842   command -v scp >/dev/null 2>&1 || die "scp not found"
    843   mkdir -p "$resultsdir"
    844   ssh_setup "$arch"
    845   destdir="$(remote_mkdir)"
    846   dest_fwd="${destdir//\\//}"
    847   # Some kit-produced test exes trip a Windows Defender PUA heuristic, which
    848   # blocks the launch (and would otherwise corrupt exit-code capture). Exclude
    849   # the temp tree the binaries are uploaded into BEFORE the upload, so neither
    850   # the on-write nor the on-execute scan quarantines them. Best-effort: requires
    851   # an admin session + Defender, but works even with Tamper Protection on
    852   # (path exclusions are still honored, unlike disabling real-time monitoring).
    853   remote_ps "try { Add-MpPreference -ExclusionPath \$env:TEMP -ErrorAction Stop } catch {}" >/dev/null 2>&1 || true
    854   # Upload only the executables + runner; the host-side bookkeeping stays home.
    855   if ! scp "${SSH_ARGS[@]}" "$stage"/*.exe "$stage/run-remote.ps1" \
    856         "$SSH_DEST:$dest_fwd/" >/dev/null 2>&1; then
    857     remote_cleanup "$destdir"
    858     die "scp upload failed -> $dest_fwd"
    859   fi
    860   # Run the runner from the upload dir (it Set-Location's to $PSScriptRoot and
    861   # writes res\<id>.{rc,out,err} per binary).
    862   run_ps="\$ErrorActionPreference='Continue'; & (Join-Path '$(ps_sq "$destdir")' 'run-remote.ps1')"
    863   remote_ps "$run_ps" >/dev/null 2>&1 || true
    864   # Bring res\ back and flatten it into RESULTSDIR.
    865   tmp="$(mktemp -d)"
    866   scp -r "${SSH_ARGS[@]}" "$SSH_DEST:$dest_fwd/res" "$tmp/" >/dev/null 2>&1 || true
    867   [ -d "$tmp/res" ] && cp "$tmp/res/"* "$resultsdir/" 2>/dev/null || true
    868   rm -rf "$tmp"
    869   remote_cleanup "$destdir"
    870 }
    871 
    872 smoke_arch() {
    873   local arch="$1" ps
    874   ssh_setup "$arch"
    875   ps="\$ErrorActionPreference='Stop'; cmd.exe /c ver; [Console]::WriteLine('PROCESSOR_ARCHITECTURE=' + \$env:PROCESSOR_ARCHITECTURE); [Console]::WriteLine('PROCESSOR_ARCHITEW6432=' + \$env:PROCESSOR_ARCHITEW6432)"
    876   remote_ps "$ps"
    877 }
    878 
    879 wait_ssh() {
    880   local timeout="${1:-600}" arch=aarch64 i=0 shot
    881   ssh_setup "$arch"
    882   printf 'waiting for ssh on %s (port %s), up to %ss\n' "$SSH_DEST" "$(vm_port "$arch")" "$timeout"
    883   i=0
    884   while [ "$i" -lt "$timeout" ]; do
    885     # Probe with a bare single-token command: Windows sshd wraps the remote
    886     # command in `cmd /c "..."`, which mangles a nested `cmd /c ver` into a
    887     # dangling quote. A lone builtin like `ver` round-trips cleanly.
    888     if ssh "${SSH_ARGS[@]}" "$SSH_DEST" ver 2>/dev/null; then
    889       printf 'ssh ready after ~%ss\n' "$i"
    890       remote_ps "[Console]::WriteLine('arch=' + \$env:PROCESSOR_ARCHITECTURE)" 2>/dev/null || true
    891       return 0
    892     fi
    893     if ! vm_running; then die "qemu exited while waiting for ssh (see $QEMU_LOG)"; fi
    894     # Capture a screenshot every ~60s so a stalled install is debuggable.
    895     if [ $((i % 60)) -eq 0 ]; then
    896       shot="$(screenshot "wait-$i" 2>/dev/null || true)"
    897       [ -n "$shot" ] && printf '  [%ss] screenshot: %s\n' "$i" "$shot"
    898     fi
    899     sleep 10
    900     i=$((i + 10))
    901   done
    902   screenshot "wait-timeout" >/dev/null 2>&1 || true
    903   die "ssh did not become ready within ${timeout}s (see $QEMU_LOG and $SHOT_DIR)"
    904 }
    905 
    906 ssh_arch() {
    907   local arch=aarch64
    908   if [ $# -ge 1 ] && case "${1:-}" in x64|x86_64|amd64|aarch64|arm64|aa64) true ;; *) false ;; esac; then
    909     arch="$1"; shift
    910   fi
    911   ssh_setup "$arch"
    912   if [ $# -eq 0 ]; then
    913     exec ssh "${SSH_ARGS[@]}" "$SSH_DEST"
    914   fi
    915   exec ssh "${SSH_ARGS[@]}" "$SSH_DEST" "$@"
    916 }
    917 
    918 # ---------------------------------------------------------------------------
    919 # doctor / status
    920 # ---------------------------------------------------------------------------
    921 
    922 doctor() {
    923   local iso
    924   printf 'host: %s/%s\n' "$(uname -s 2>/dev/null)" "$(uname -m 2>/dev/null)"
    925   printf 'repo: %s\n' "$ROOT"
    926   printf 'vm dir: %s\n' "$VM_ROOT"
    927   printf 'cache: %s\n' "$CACHE_ROOT"
    928   printf 'hvf: %s\n' "$(sysctl -n kern.hv_support 2>/dev/null || echo '?')"
    929   for tool in "$QEMU_BIN" qemu-img ssh base64 hdiutil python3 pnmtopng curl; do
    930     if command -v "$tool" >/dev/null 2>&1; then
    931       printf '  OK      %s (%s)\n' "$tool" "$(command -v "$tool")"
    932     else
    933       printf '  MISSING %s\n' "$tool"
    934     fi
    935   done
    936   printf 'firmware:\n'
    937   printf '  %-7s %s\n' code "$(firmware_code)$([ -f "$(firmware_code)" ] && echo '' || echo ' (MISSING)')"
    938   printf '  %-7s %s\n' vars "$(firmware_vars_template)$([ -f "$(firmware_vars_template)" ] && echo '' || echo ' (MISSING)')"
    939   printf 'media:\n'
    940   if iso="$(win_iso)"; then printf '  windows  %s\n' "$iso"; else printf '  windows  (none; set KIT_WINDOWS_ISO or drop Win11*Arm64*.iso in %s)\n' "$CACHE_ROOT"; fi
    941   printf '  virtio   %s%s\n' "$(virtio_iso)" "$([ -f "$(virtio_iso)" ] && echo '' || echo ' (run fetch-virtio)')"
    942   printf '  openssh  %s%s\n' "$(openssh_zip)" "$([ -f "$(openssh_zip)" ] && echo '' || echo ' (run fetch-openssh)')"
    943   printf 'artifacts:\n'
    944   printf '  disk     %s%s\n' "$DISK_PATH" "$([ -f "$DISK_PATH" ] && echo '' || echo ' (not created)')"
    945   printf '  golden   %s%s\n' "$GOLDEN_PATH" "$([ -f "$GOLDEN_PATH" ] && echo ' (installed)' || echo ' (none; run install)')"
    946   printf '  seed     %s%s\n' "$SEED_PATH" "$([ -f "$SEED_PATH" ] && echo '' || echo ' (not built)')"
    947   printf '  ssh key  %s%s\n' "$SSH_KEY_DEFAULT" "$([ -f "$SSH_KEY_DEFAULT" ] && echo '' || echo ' (not generated)')"
    948   printf 'vm: %s\n' "$(vm_running && echo "running (pid $(cat "$PID_FILE"))" || echo 'stopped')"
    949   printf '  x64      dest=%s port=%s\n' "$(vm_dest x64)" "$(vm_port x64)"
    950   printf '  aarch64  dest=%s port=%s\n' "$(vm_dest aarch64)" "$(vm_port aarch64)"
    951 }
    952 
    953 cmd="${1:-}"
    954 case "$cmd" in
    955   doctor) doctor ;;
    956   fetch-virtio) fetch_virtio ;;
    957   fetch-openssh) fetch_openssh ;;
    958   seed) build_seed ;;
    959   prepare) prepare ;;
    960   install) install_vm ;;
    961   console-install) console_install ;;
    962   console) console_boot ;;
    963   firstboot) firstboot_vm ;;
    964   boot) boot_vm ;;
    965   wait-ssh) wait_ssh "${2:-600}" ;;
    966   ssh) shift; ssh_arch "$@" ;;
    967   screenshot) screenshot "${2:-shot}" ;;
    968   snapshot) cache_golden ;;
    969   reset) restore_golden ;;
    970   stop) powerdown ;;
    971   smoke) [ $# -eq 2 ] || { usage; exit 2; }; smoke_arch "$2" ;;
    972   run) [ $# -ge 3 ] || { usage; exit 2; }; arch="$2"; exe="$3"; shift 3; run_exe "$arch" "$exe" "$@" ;;
    973   run-batch) [ $# -eq 4 ] || { usage; exit 2; }; run_batch "$2" "$3" "$4" ;;
    974   -h|--help|help|"") usage ;;
    975   *) usage; exit 2 ;;
    976 esac