macOS: drive the install and all customization from a Recovery "PE", no GUI

Replace the screenshot/OCR/keystroke driving of Apple's Recovery with a
"PE": BaseSystem.dmg (a journaled HFS+ volume, writable from Linux) with one
LaunchDaemon added (makeRecoveryPE) that runs /Volumes/VMIX/run.sh as root at
boot, records the status and powers off. launchd loads it alongside its signed
cache (verified on Tahoe 26.6.2); same idea as AutoNBI/Imagr NetBoot images.

- makeImage: the PE runs vmix-install.sh (erase, installer app, SharedSupport
  pkgdmg, startosinstall). Progress is read from the serial console
  (boot-args serial=3 -v, VMIX-* markers) and screenshots (brightness only).
  Fully offline; prepare now takes ~5 min instead of ~10.
- customizeImage: boots the PE with the image attached and runs the template
  offline against the mounted System/Data volumes; OpenCore ScanPolicy
  restricted to HFS+/SATA so only the PE can boot. One PE boot ~30 s. The
  installed macOS is never booted for customization, so nothing depends on
  launchd/BTM approval or a first-boot agent (removed).
- templates rewritten for offline use: generalize creates the user with
  dscl -f (admin, home, auto-login kcpassword, Setup Assistant suppression,
  hostname, locale, timezone, keyboard type, container resize); remote-access,
  no-updates, performance edit the target's plists.
- makeBootDisk: build-time OpenCore variant (serial console, ScanPolicy).
- vm-driver.py rewritten: passive observation only (serial markers, kernel
  boots, panics, brightness), disk+serial-aware hang watchdog, reboot-death
  reset, halt/loginwindow detection. No OCR/tesseract.
- OpenCore: four SMBIOS DIMMs for MacPro7,1 (no "Memory Modules
  Misconfigured" warning).
- tools/soak.sh: repeatability harness.

Verified on daku: base install 23 min end to end; basic + generalize in three
~30 s PE boots; the result auto-logs into the desktop with the created user.

Root cause of the "first-boot hang" (from the serial log): the guest's restart
path panics (IOPlatformHaltRestartAction -> AppleSMC, SMCWDT smcWriteKey
kSMCBadCommand, nested panic) because the pinned OSX-KVM Lilu disables itself
on macOS 26, so VirtualSMC never loads. Handled by the driver (reset within
60 s); kext update to follow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsESshRCoBoUVWV9qKURUF
This commit is contained in:
Git Sagar 2026-09-09 11:21:24 -03:00
parent 58a317f5d2
commit 8dc8f4265d
24 changed files with 802 additions and 977 deletions

View file

@ -1,42 +0,0 @@
#!/bin/sh
# vmix agent: LaunchDaemon that runs at every boot as root (installed by the vmix
# agent pkg via startosinstall --installpackage). If a volume named VMIX carrying
# vmix-run.sh is attached, run it, record the result on the volume and power off.
# Without the volume it is a no-op (normal boot). Counterpart of the Windows Audit
# Mode RunOnce script; the generalize step removes it once the image is sealed.
LOG=/var/log/vmix-agent.log
exec >>"$LOG" 2>&1
echo "=== vmix agent: $(date) ==="
# The agent pkg bootstraps this daemon during the OS install (to approve it past
# Background Task Management, so launchd runs it at first boot). Don't do the job
# in that installer environment — only on the installed system's first boot.
if pgrep -x bootinstalld >/dev/null 2>&1 || pgrep -qx "Installer Progress" 2>/dev/null \
|| [ -d /System/Volumes/Update/mnt1 ]; then
echo "vmix agent: OS installer is running, skipping"
exit 0
fi
# let DiskArbitration settle so the VMIX volume is mountable
sleep 5
V=/Volumes/VMIX
i=0
while [ ! -f "$V/vmix-run.sh" ] && [ $i -lt 30 ]; do
diskutil mount VMIX >/dev/null 2>&1
sleep 2
i=$((i + 1))
done
if [ ! -f "$V/vmix-run.sh" ]; then
echo "vmix agent: no VMIX volume, normal boot"
exit 0
fi
echo "vmix agent: running vmix-run.sh"
cd "$V" || exit 1
sh "$V/vmix-run.sh" >"$V/vmix-run.log" 2>&1
rc=$?
echo "vmix agent: vmix-run.sh exited $rc"
echo "$rc" >"$V/vmix-run.status"
cp "$LOG" "$V/vmix-agent.log" 2>/dev/null
cp /var/log/vmix-agent-install.log "$V/vmix-agent-install.log" 2>/dev/null
sync
sleep 2
diskutil unmount force "$V" >/dev/null 2>&1
shutdown -h now

View file

@ -3,17 +3,17 @@
<plist version="1.0">
<dict>
<key>Label</key>
<string>ch.vmix.agent</string>
<string>ch.vmix.pe</string>
<key>ProgramArguments</key>
<array>
<string>/bin/sh</string>
<string>/Library/vmix/agent.sh</string>
<string>/bin/bash</string>
<string>/usr/libexec/vmix/pe.sh</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>/var/log/vmix-agent.log</string>
<string>/dev/console</string>
<key>StandardErrorPath</key>
<string>/var/log/vmix-agent.log</string>
<string>/dev/console</string>
</dict>
</plist>

View file

@ -0,0 +1,51 @@
# vmix PE helpers, sourced by run.sh scripts running in the recovery.
# Expects V=/Volumes/VMIX (set by pe.sh) and VOLUME_NAME from vmix.conf.
V=${V:-/Volumes/VMIX}
[ -f "$V/vmix.conf" ] && . "$V/vmix.conf"
VOLUME_NAME=${VOLUME_NAME:-Macintosh HD}
pe_log() { echo "VMIX: $*"; }
pe_fail() { echo "VMIX-FAIL: $*"; exit 1; }
# Mount the installed system's APFS volume group (System read-only, Data rw) and
# export SYS / DATA mount points plus SYS_ID / DATA_ID device identifiers.
pe_mount_target() {
local list; list=$(diskutil list)
DATA_ID=$(echo "$list" | awk -v n="APFS Volume $VOLUME_NAME - Data" 'index($0, n) {print $NF; exit}')
SYS_ID=$(echo "$list" | awk -v n="APFS Volume $VOLUME_NAME " '!/ - Data/ && index($0, n) {print $NF; exit}')
[ -n "$DATA_ID" ] && [ -n "$SYS_ID" ] || { pe_log "target volumes not found"; echo "$list"; return 1; }
diskutil mount "$SYS_ID" >/dev/null 2>&1 || true
diskutil mount "$DATA_ID" >/dev/null 2>&1 || true
SYS=$(diskutil info "$SYS_ID" | sed -n 's/^ *Mount Point: *//p')
DATA=$(diskutil info "$DATA_ID" | sed -n 's/^ *Mount Point: *//p')
[ -d "$DATA/private/var/db" ] || { pe_log "Data volume not mounted (SYS=[$SYS] DATA=[$DATA])"; return 1; }
pe_log "target mounted: SYS=[$SYS] DATA=[$DATA]"
export SYS DATA SYS_ID DATA_ID
}
pe_unmount_target() {
sync
diskutil unmount "$DATA_ID" >/dev/null 2>&1 || true
diskutil unmount "$SYS_ID" >/dev/null 2>&1 || true
}
# plist helpers on files of the (offline) target: create the file if missing.
pe_plist_set() { # FILE KEYPATH TYPE VALUE (TYPE: string|bool|integer|float)
local f=$1 k=$2 t=$3 v=$4
[ -f "$f" ] || plutil -create xml1 "$f"
plutil -replace "$k" "-$t" "$v" "$f"
}
pe_plist_dict() { # FILE KEYPATH — make sure a dictionary exists at KEYPATH
local f=$1 k=$2
[ -f "$f" ] || plutil -create xml1 "$f"
plutil -extract "$k" xml1 -o /dev/null "$f" >/dev/null 2>&1 || plutil -insert "$k" -dictionary "$f"
}
# launchd service override on the target (disabled.plist): pe_service LABEL true|false
pe_service_disabled() {
local f="$DATA/private/var/db/com.apple.xpc.launchd/disabled.plist"
mkdir -p "$(dirname "$f")"
pe_plist_set "$f" "$1" bool "$2"
}
# version of the installed system
pe_target_version() { plutil -extract ProductVersion raw -o - "$SYS/System/Library/CoreServices/SystemVersion.plist" 2>/dev/null; }
pe_target_build() { plutil -extract ProductBuildVersion raw -o - "$SYS/System/Library/CoreServices/SystemVersion.plist" 2>/dev/null; }

40
lib/images/macos/guest/pe.sh Executable file
View file

@ -0,0 +1,40 @@
#!/bin/bash
# vmix PE hook. Runs as root from launchd when the patched Recovery boots
# (injected by makeRecoveryPE). If a VMIX volume is attached it runs
# /Volumes/VMIX/run.sh, records the exit status on the volume and powers off;
# without one it does nothing and the recovery behaves normally.
# Everything printed here goes to /dev/console, i.e. the host's serial log.
exec >/dev/console 2>&1
echo "VMIX-PE: hook started $(date) uid=$(id -u)"
V=/Volumes/VMIX
i=0
while [ ! -f "$V/run.sh" ] && [ $i -lt 90 ]; do
diskutil mount VMIX >/dev/null 2>&1
sleep 2; i=$((i + 1))
done
if [ ! -f "$V/run.sh" ]; then
echo "VMIX-PE: no VMIX volume, leaving the recovery alone"
exit 0
fi
echo "VMIX-PE: VMIX mounted after $i retries"
caffeinate -dimsu -t 86400 >/dev/null 2>&1 &
[ -f "$V/vmix.conf" ] && . "$V/vmix.conf"
# certificate checks need a sane clock; a fresh VM RTC can be off
[ -n "${BUILD_DATE:-}" ] && date -u "$BUILD_DATE" >/dev/null 2>&1 && echo "VMIX-PE: clock set to $(date -u)"
export V
cd "$V"
echo "VMIX-PE: running run.sh"
/bin/bash "$V/run.sh" 2>&1 | tee "$V/vmix-run.log"
rc=${PIPESTATUS[0]}
echo "$rc" > "$V/vmix-run.status"
echo "VMIX-PE: run.sh exited $rc"
if [ -f "$V/vmix-reboot" ]; then
rm -f "$V/vmix-reboot"; sync
echo "VMIX-PE: rebooting as requested"
reboot
exit 0
fi
sync; sleep 1
diskutil unmount force "$V" >/dev/null 2>&1
echo "VMIX-PE-DONE rc=$rc"
shutdown -h now

View file

@ -1,88 +1,65 @@
#!/bin/sh
# vmix: automated macOS install. Runs inside macOS Recovery's Terminal, started
# by vm-driver.py which types "sh /Volumes/VMIX/run.sh" for us.
#
# 1. erase the target disk (found by size) as APFS "Macintosh HD"
# 2. rebuild "Install macOS <name>.app": app skeleton from installer-app.tar
# (host-extracted Payload) + SharedSupport.dmg = the WHOLE InstallAssistant.pkg
# dd'd byte-exact from a raw disk (Apple's own postinstall hardlinks the pkg
# there: it is a "pkgdmg" whose koly footer points at the dmg inside; the bare
# xar member fails startosinstall with "pkgdmg is missing a footer")
# 3. startosinstall unattended, with the vmix agent pkg as --installpackage
# 4. startosinstall reboots itself into the install phase; the vmix agent pkg
# installs during that phase and runs on the installed system's first boot
#
# On first boot of the installed system the agent runs /Volumes/VMIX/vmix-run.sh
# and powers off, which ends the QEMU session on the host.
# macOS Recovery invokes us as `sh` (bash in POSIX mode, no process substitution);
# re-exec once under bash so `>(tee ...)` and other bashisms work.
if [ -z "${VMIX_REEXEC:-}" ]; then VMIX_REEXEC=1 exec bash "$0" "$@"; fi
V="/Volumes/VMIX"
# tee to the Terminal (visible in host screenshots) and to a log on the volume
exec > >(tee "$V/install.log") 2>&1
#!/bin/bash
# vmix unattended macOS install, run by the PE hook (pe.sh) as root in the
# Recovery with /Volumes/VMIX mounted (V). Needs vmix.conf: TARGET_BYTES,
# PKG_BYTES, PKG_DISK_BYTES, APP_NAME, VOLUME_NAME.
# 1. find the target disk and the SharedSupport (InstallAssistant.pkg) disk by size
# 2. erase the target as APFS, unpack the installer app, dd the whole pkg into it
# as SharedSupport.dmg (a "pkgdmg", startosinstall checks its koly footer)
# 3. startosinstall prepares, then reboots itself into the install phase; the
# installed system's first boot ends at the loginwindow (the host powers off)
# Never returns on success; a return means failure (the PE records the status).
set -x
. "$V/vmix.conf"
# keep the recovery display awake so the host driver can watch the screen
caffeinate -dimsu -t 86400 >/dev/null 2>&1 &
pmset -a displaysleep 0 sleep 0 >/dev/null 2>&1 || true
. "$V/pe-lib.sh"
fail() {
echo "vmix-install: FAIL: $*"
cp /var/log/install.log "$V/system-install.log" 2>/dev/null || true
echo 1 >"$V/install.status"
echo "VMIX-FAIL: $*"
cp /var/log/install.log "$V/system-install.log" 2>/dev/null
sync
sleep 2
shutdown -h now 2>/dev/null || halt 2>/dev/null || true
exit 1
}
# each boot into the PE with the install still pending is one attempt
ATTEMPT=$(( $(cat "$V/install.attempt" 2>/dev/null || echo 0) + 1 ))
echo "$ATTEMPT" > "$V/install.attempt"; sync
echo "VMIX-INSTALL: attempt $ATTEMPT (boot into the PE)"
[ "$ATTEMPT" -le 3 ] || fail "the installer keeps coming back to the PE ($ATTEMPT boots)"
# whole-disk identifier (diskN) whose size in bytes is exactly $1
# --- 1. disks by exact size
disk_by_size() {
for d in $(diskutil list | grep -oE '^/dev/disk[0-9]+'); do
s=$(diskutil info "$d" | sed -n 's/.*Disk Size:.*(\([0-9][0-9]*\) Bytes).*/\1/p')
[ "$s" = "$1" ] && { echo "${d#/dev/}"; return 0; }
for d in $(diskutil list | grep -oE '^/dev/disk[0-9]+' | sort -u); do
if [ "$(diskutil info "$d" | sed -n 's/.*Disk Size:.*(\([0-9]*\) Bytes).*/\1/p')" = "$1" ]; then
echo "${d#/dev/}"; return 0
fi
done
return 1
}
TARGET=$(disk_by_size "$TARGET_BYTES") || fail "target disk of $TARGET_BYTES bytes not found"
SSDISK=$(disk_by_size "$PKG_DISK_BYTES") || fail "SharedSupport disk of $PKG_DISK_BYTES bytes not found"
echo "VMIX-INSTALL: target=$TARGET sharedsupport=$SSDISK"
echo "vmix-install: $(date) app=$APP_NAME volume=$VOLUME_NAME"
TARGET=$(disk_by_size "$TARGET_BYTES") || fail "target disk ($TARGET_BYTES bytes) not found"
SSDISK=$(disk_by_size "$PKG_DISK_BYTES") || fail "installer pkg disk ($PKG_DISK_BYTES bytes) not found"
echo "vmix-install: target=$TARGET sharedsupport=$SSDISK"
# --- 1. erase the target disk as an APFS volume
diskutil eraseDisk APFS "$VOLUME_NAME" GPT "$TARGET" || fail "eraseDisk $TARGET"
# --- 2. target volume + installer app (the pkg payload skeleton + SharedSupport.dmg)
VOL="/Volumes/$VOLUME_NAME"
[ -d "$VOL" ] || fail "$VOL not mounted"
# --- 2. rebuild the installer app on the target volume
tar -xf "$V/installer-app.tar" -C "$VOL" || fail "untar installer-app.tar"
APP="$VOL/$APP_NAME"
SOI="$APP/Contents/Resources/startosinstall"
[ -x "$SOI" ] || fail "startosinstall missing in $APP"
SS="$APP/Contents/SharedSupport/SharedSupport.dmg"
mkdir -p "$APP/Contents/SharedSupport"
FULL=$((PKG_BYTES / 1048576))
REM=$((PKG_BYTES % 1048576))
dd if="/dev/r$SSDISK" of="$SS" bs=1048576 count=$FULL || fail "dd SharedSupport.dmg"
if [ "$REM" -gt 0 ]; then
dd if="/dev/r$SSDISK" bs=1048576 skip=$FULL count=1 2>/dev/null | dd bs=1 count=$REM >>"$SS" || fail "dd SharedSupport.dmg tail"
fi
[ "$(stat -f %z "$SS")" = "$PKG_BYTES" ] || fail "SharedSupport.dmg size mismatch: $(stat -f %z "$SS") != $PKG_BYTES"
tail -c 512 "$SS" | grep -qa koly || fail "SharedSupport.dmg has no UDIF koly footer"
chflags -h norestricted "$SS" 2>/dev/null || true
echo "vmix-install: app=$APP SharedSupport.dmg=$(stat -f %z "$SS") bytes"
prepare_target() {
diskutil eraseDisk APFS "$VOLUME_NAME" GPT "$TARGET" || fail "eraseDisk"
[ -d "$VOL" ] || fail "$VOL not mounted after erase"
tar -xf "$V/installer-app.tar" -C "$VOL" || fail "untar installer app"
[ -x "$APP/Contents/Resources/startosinstall" ] || fail "startosinstall missing from $APP"
mkdir -p "$APP/Contents/SharedSupport"
FULL=$(( PKG_BYTES / 1048576 )); REM=$(( PKG_BYTES % 1048576 ))
echo "VMIX-INSTALL: copying SharedSupport.dmg ($PKG_BYTES bytes) from /dev/r$SSDISK"
dd if="/dev/r$SSDISK" of="$SS" bs=1048576 count=$FULL || fail "dd SharedSupport"
[ "$REM" -gt 0 ] && { dd if="/dev/r$SSDISK" bs=1048576 skip=$FULL count=1 | dd bs=1 count=$REM >> "$SS"; } || true
[ "$(stat -f %z "$SS")" = "$PKG_BYTES" ] || fail "SharedSupport.dmg size $(stat -f %z "$SS") != $PKG_BYTES"
tail -c 512 "$SS" | grep -qa koly || fail "SharedSupport.dmg has no koly footer"
chflags -h norestricted "$SS" 2>/dev/null || true
sync
}
prepare_target
SOI="$APP/Contents/Resources/startosinstall"
echo "VMIX-INSTALL: app ready, clock $(date -u)"
# macOS certificate validation needs a sane clock; a fresh VM RTC can be wrong.
echo "vmix-install: guest clock is $(date) (UTC $(date -u))"
if [ -n "${BUILD_DATE:-}" ]; then
date -u "$BUILD_DATE" && echo "vmix-install: set clock to $(date)"
fi
# Blackhole Apple's install/verify endpoints so osinstallersetupd's network calls
# fail immediately instead of timing out (prepare otherwise crawls). Fully offline.
# Offline install: no NIC is attached. Blackhole Apple's install/verify endpoints
# too, so osinstallersetupd's requests fail immediately instead of timing out.
for d in swscan.apple.com swcdn.apple.com swdist.apple.com swquery.apple.com \
gs.apple.com gsa.apple.com gdmf.apple.com mesu.apple.com xp.apple.com \
albert.apple.com captive.apple.com deviceservices-external.apple.com \
@ -90,50 +67,34 @@ for d in swscan.apple.com swcdn.apple.com swdist.apple.com swquery.apple.com \
ocsp2.apple.com valid.apple.com; do
echo "127.0.0.1 $d" >> /etc/hosts
done
echo "vmix-install: blackholed Apple install endpoints for a fast offline prepare"
# --- 3. unattended install. startosinstall prepares then reboots the machine
# itself into the install phase. Prepare intermittently stalls (~46% — an online
# verify/personalization step through the VM's NAT), so a watchdog kills and
# retries startosinstall if the target volume makes no write progress for a while.
# The vmix agent pkg installs during the install phase and runs on first boot.
# quote args properly — $VOL contains a space ("Macintosh HD")
# --- 3. startosinstall prepares (~5 min) then reboots the machine itself into the
# install phase; it never returns on success. Prepare is intermittently slow in
# QEMU, so an attempt that stalls or runs too long is killed and retried on a
# freshly erased target.
run_soi() { "$SOI" --volume "$VOL" --agreetolicense --nointeraction --rebootdelay 5 "$@"; }
free_kb() { df -k "$VOL" 2>/dev/null | awk 'NR==2 {print $4}'; }
attempt=0
while [ "$attempt" -lt 10 ]; do
attempt=$((attempt + 1))
echo "vmix-install: startosinstall attempt $attempt"
if [ "$attempt" -eq 1 ]; then
run_soi --installpackage "$V/vmix-agent.pkg" 2>&1 &
else
# a stalled attempt leaves the volume dirty; re-erase and rebuild for a clean retry
diskutil eraseDisk APFS "$VOLUME_NAME" GPT "$TARGET" || fail "eraseDisk on retry"
tar -xf "$V/installer-app.tar" -C "$VOL" || fail "untar on retry"
mkdir -p "$APP/Contents/SharedSupport"
dd if="/dev/r$SSDISK" of="$SS" bs=1048576 count=$FULL 2>/dev/null
[ "$REM" -gt 0 ] && dd if="/dev/r$SSDISK" bs=1048576 skip=$FULL count=1 2>/dev/null | dd bs=1 count=$REM >>"$SS" 2>/dev/null
chflags -h norestricted "$SS" 2>/dev/null || true
run_soi --installpackage "$V/vmix-agent.pkg" 2>&1 &
fi
try=0
while [ "$try" -lt 6 ]; do
try=$((try + 1))
[ "$try" -gt 1 ] && prepare_target
echo "VMIX-INSTALL: startosinstall try $try"
run_soi 2>&1 &
SOI_PID=$!
# watchdog: kill startosinstall if free space stalls for ~4 min OR the attempt
# simply takes too long (prepare is intermittently slow; healthy = a few minutes)
last=$(free_kb); stalled=0; elapsed=0
while kill -0 "$SOI_PID" 2>/dev/null; do
sleep 30; elapsed=$((elapsed + 30))
now=$(free_kb)
if [ "$now" = "$last" ]; then stalled=$((stalled + 30)); else stalled=0; last=$now; fi
if [ "$stalled" -ge 240 ] || [ "$elapsed" -ge 540 ]; then
echo "vmix-install: prepare too slow (stalled=${stalled}s elapsed=${elapsed}s), killing to retry"
[ $((elapsed % 120)) -eq 0 ] && echo "VMIX-INSTALL: prepare running ${elapsed}s (stalled ${stalled}s)"
if [ "$stalled" -ge 240 ] || [ "$elapsed" -ge 600 ]; then
echo "VMIX-INSTALL: prepare too slow (stalled=${stalled}s elapsed=${elapsed}s), killing to retry"
kill -9 "$SOI_PID" 2>/dev/null; pkill -9 -f startosinstall 2>/dev/null
break
fi
done
wait "$SOI_PID" 2>/dev/null
# on success startosinstall reboots the machine and we never get here
echo "vmix-install: startosinstall attempt $attempt ended without rebooting"
echo "VMIX-INSTALL: startosinstall try $try ended without rebooting"
sleep 3
done
fail "startosinstall did not complete after $attempt attempts"
fail "startosinstall did not complete after $try tries"