macOS Tahoe: working fully-offline unattended install
The base image (macos.images.tahoe.upstream) now installs and boots end to end with no dependency on Apple's servers — proven on the KVM host: the finished qcow2 boots standalone (OpenCore from its own ESP) to the macOS 26.6.2 loginwindow. Install driving (vm-driver.py, screenshot + OCR over QMP): - map the whole InstallAssistant.pkg as a raw disk and dd it byte-exact into the app as SharedSupport.dmg (it is a pkgdmg: xar + koly footer — the bare xar member fails startosinstall with "pkgdmg is missing a footer") - offline install: no NIC + /etc/hosts blackhole of Apple install/verify endpoints so startosinstall's calls fail fast instead of hanging (SecureBootModel=Disabled allows the sealed-volume install offline) - keep the recovery display awake with a tiny mouse jiggle; coarse settle fingerprint so the cursor is not seen as a change - guest watchdog re-erases/retries a startosinstall attempt that stalls or runs too long (prepare is intermittently slow) - disk-aware boot watchdog: QMP system_reset only when the screen is dark AND the disk is idle (never interrupts a slow-but-working boot); recovery-restart if a post-prepare reboot lands back on recovery - detect the bright loginwindow and power the VM down (install complete); a black + disk-idle screen is treated as a completed halt - copy OpenCore into the image ESP so it boots standalone recovery.file pins a content-addressed local BaseSystem.dmg (Apple's CDN load-balances Sequoia/Tahoe during the rollout). fetchRecovery retries to the pinned hash when used instead. Not yet done: .generalize (user creation) — the first-boot agent LaunchDaemon is blocked by Ventura+ Background Task Management on headless boots; next step is offline user injection from the agent pkg postinstall. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XsESshRCoBoUVWV9qKURUF
This commit is contained in:
parent
242e48a5fc
commit
58a317f5d2
9 changed files with 415 additions and 128 deletions
|
|
@ -1,14 +1,25 @@
|
|||
#!/bin/sh
|
||||
# vmix agent: LaunchDaemon that runs at every boot as root. 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).
|
||||
# This is the macOS counterpart of the Windows Audit Mode RunOnce script.
|
||||
# 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 60 ]; do
|
||||
while [ ! -f "$V/vmix-run.sh" ] && [ $i -lt 30 ]; do
|
||||
diskutil mount VMIX >/dev/null 2>&1
|
||||
sleep 2
|
||||
i=$((i + 1))
|
||||
|
|
@ -24,6 +35,7 @@ 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
|
||||
|
|
|
|||
|
|
@ -4,12 +4,13 @@
|
|||
#
|
||||
# 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 copied byte-exact by dd from a
|
||||
# raw disk that maps that byte range of the pkg (Recovery's xar truncates an
|
||||
# 18 GB member, which makes startosinstall report "pkgdmg is missing a footer")
|
||||
# (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. when the prepare phase is done (SIGUSR1) also drop the agent + .AppleSetupDone
|
||||
# onto the volume, then let the installer reboot
|
||||
# 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.
|
||||
|
|
@ -23,6 +24,9 @@ V="/Volumes/VMIX"
|
|||
exec > >(tee "$V/install.log") 2>&1
|
||||
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
|
||||
|
||||
fail() {
|
||||
echo "vmix-install: FAIL: $*"
|
||||
|
|
@ -45,7 +49,7 @@ disk_by_size() {
|
|||
|
||||
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 "$SS_DISK_BYTES") || fail "SharedSupport disk ($SS_DISK_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
|
||||
|
|
@ -60,13 +64,13 @@ SOI="$APP/Contents/Resources/startosinstall"
|
|||
[ -x "$SOI" ] || fail "startosinstall missing in $APP"
|
||||
SS="$APP/Contents/SharedSupport/SharedSupport.dmg"
|
||||
mkdir -p "$APP/Contents/SharedSupport"
|
||||
FULL=$((SS_LEN / 1048576))
|
||||
REM=$((SS_LEN % 1048576))
|
||||
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")" = "$SS_LEN" ] || fail "SharedSupport.dmg size mismatch: $(stat -f %z "$SS") != $SS_LEN"
|
||||
[ "$(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"
|
||||
|
|
@ -77,41 +81,59 @@ if [ -n "${BUILD_DATE:-}" ]; then
|
|||
date -u "$BUILD_DATE" && echo "vmix-install: set clock to $(date)"
|
||||
fi
|
||||
|
||||
# --- 3. unattended install
|
||||
PREPARED=0
|
||||
trap 'PREPARED=1' USR1
|
||||
run_install() {
|
||||
"$SOI" --volume "$VOL" --agreetolicense --nointeraction --pidtosignal $$ "$@" &
|
||||
INSTALL_PID=$!
|
||||
while :; do
|
||||
wait $INSTALL_PID
|
||||
rc=$?
|
||||
[ "$PREPARED" = 1 ] && return 0
|
||||
kill -0 $INSTALL_PID 2>/dev/null || return $rc
|
||||
# Blackhole Apple's install/verify endpoints so osinstallersetupd's network calls
|
||||
# fail immediately instead of timing out (prepare otherwise crawls). Fully offline.
|
||||
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 \
|
||||
identity.apple.com ppq.apple.com crl.apple.com ocsp.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")
|
||||
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
|
||||
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"
|
||||
kill -9 "$SOI_PID" 2>/dev/null; pkill -9 -f startosinstall 2>/dev/null
|
||||
break
|
||||
fi
|
||||
done
|
||||
}
|
||||
run_install --rebootdelay 300 --installpackage "$V/vmix-agent.pkg" \
|
||||
|| { echo "vmix-install: retry without --rebootdelay"; run_install --installpackage "$V/vmix-agent.pkg"; } \
|
||||
|| { echo "vmix-install: retry without --installpackage"; run_install; } \
|
||||
|| fail "startosinstall"
|
||||
|
||||
# --- 4. prepare phase done: also drop the agent onto the volume directly.
|
||||
T="$VOL"
|
||||
if [ -d "$T" ]; then
|
||||
mkdir -p "$T/Library/LaunchDaemons" "$T/Library/vmix" "$T/private/var/db"
|
||||
cp "$V/agent/agent.sh" "$T/Library/vmix/agent.sh"
|
||||
cp "$V/agent/ch.vmix.agent.plist" "$T/Library/LaunchDaemons/ch.vmix.agent.plist"
|
||||
chmod 755 "$T/Library/vmix/agent.sh"
|
||||
chmod 644 "$T/Library/LaunchDaemons/ch.vmix.agent.plist"
|
||||
chown -R root:wheel "$T/Library/vmix" "$T/Library/LaunchDaemons/ch.vmix.agent.plist"
|
||||
touch "$T/private/var/db/.AppleSetupDone"
|
||||
chown root:wheel "$T/private/var/db/.AppleSetupDone"
|
||||
else
|
||||
echo "vmix-install: WARNING: $T not mounted after prepare, relying on --installpackage"
|
||||
fi
|
||||
|
||||
echo 0 >"$V/install.status"
|
||||
sync
|
||||
kill -USR1 $INSTALL_PID
|
||||
wait $INSTALL_PID
|
||||
exit 0
|
||||
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"
|
||||
sleep 3
|
||||
done
|
||||
fail "startosinstall did not complete after $attempt attempts"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue