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:
Git Sagar 2026-09-09 02:49:21 -03:00
parent 242e48a5fc
commit 58a317f5d2
9 changed files with 415 additions and 128 deletions

View file

@ -81,6 +81,25 @@ class QMP:
def screendump(self, path):
self.cmd('screendump', filename=path)
def system_reset(self):
self.cmd('system_reset')
def system_powerdown(self):
self.cmd('system_powerdown')
_jig = 0
def jiggle(self):
# tiny absolute (usb-tablet) pointer move to keep the display awake: a real
# HID event, but < 2 screen px so it does not change the settle fingerprint
self._jig = 16060 if self._jig < 16030 else 16000
try:
self.cmd('input-send-event', events=[
{'type': 'abs', 'data': {'axis': 'x', 'value': self._jig}},
{'type': 'abs', 'data': {'axis': 'y', 'value': 16000}}])
except Exception: # noqa: BLE001
self.send_key('shift')
def send_key(self, *keys, hold=80):
self.cmd('send-key', keys=[{'type': 'qcode', 'data': k} for k in keys], **{'hold-time': hold})
time.sleep(0.15)
@ -120,11 +139,18 @@ class Screen:
self.qmp.screendump(self.tmp)
with open(self.tmp, 'rb') as f:
data = f.read()
h = hashlib.sha256(data).hexdigest()
self.img = Image.open(io.BytesIO(data)) if Image else None
# fingerprint from a coarse, quantized grayscale thumbnail so the moving
# mouse cursor (keepalive jiggle) does not count as a screen change
if self.img is not None:
px = self.img.convert('L').resize((48, 36))
fp = bytes(b & 0xF0 for b in px.getdata())
h = hashlib.sha256(fp).hexdigest()
else:
h = hashlib.sha256(data).hexdigest()
if h != self.last_hash:
self.last_hash = h
self.stable_since = time.time()
self.img = Image.open(io.BytesIO(data)) if Image else None
return self.img
def stable_for(self):
@ -155,6 +181,13 @@ class Screen:
self.log(f'ocr failed: {e}')
return ''
def mean(self):
if self.img is None:
return 128
px = self.img.convert('L').resize((32, 24))
d = list(px.getdata())
return sum(d) / len(d)
def is_blank(self):
# black/uniform screen (firmware, boot): nothing to act on
if self.img is None:
@ -228,6 +261,16 @@ def open_terminal(qmp, log):
qmp.send_key('ret')
def disk_idle(args):
"""True if the system disk has had no writes recently (guest not doing I/O)."""
if not args.progress_file:
return True
try:
return (time.time() - os.path.getmtime(args.progress_file)) > args.disk_idle
except OSError:
return True
def run_install(args, proc, qmp, log):
"""Drive the install VM to completion.
@ -251,6 +294,11 @@ def run_install(args, proc, qmp, log):
last_periodic = 0
last_progress = start
blind_done = False
resets = 0
blank_since = None
login_since = None
recovery_start = None
last_term_action = 0
while True:
rc = proc.poll()
if rc is not None:
@ -274,16 +322,54 @@ def run_install(args, proc, qmp, log):
if now - last_periodic > args.periodic:
last_periodic = now
screen.save('periodic')
# keep the recovery display awake until the command is typed (mouse jiggle)
if typed_at is None and now - last_term_action > 8:
qmp.jiggle()
if now - start < args.min_boot or screen.stable_for() < args.settle:
continue
if screen.is_blank():
last_progress = now
if blank_since is None:
blank_since = now
if typed_at is None:
# recovery display asleep — jiggle the mouse to wake it, wait for UI
qmp.jiggle()
continue
# macOS `shutdown -h now` halts the guest to a black screen without an
# ACPI power-off, so QEMU never exits. Once we have handed off (command
# typed), a long pure-black screen means the agent finished and halted.
elif typed_at is not None and now - blank_since > args.halt_timeout and disk_idle(args):
screen.save('halt')
log(f'guest halted (black {now - blank_since:.0f}s, disk idle); killing QEMU, readback will validate')
proc.kill()
try:
proc.wait(timeout=10)
except Exception: # noqa: BLE001
pass
return 0
continue
blank_since = None
top = screen.menubar_text()
body = screen.ocr()
log(f'settled: menubar={top.strip()!r} body~={" ".join(body.split())[:80]!r}')
# Boot-hang watchdog: a dark screen (Apple logo / black) frozen for a long
# time with no menu bar is a stuck (re)boot — kick it with a system reset.
# Never fires on the bright, static Terminal of the prepare phase.
if 'terminal' not in top and 'utilities' not in top and screen.mean() < 40 \
and screen.stable_for() > args.stall_reset and disk_idle(args) and resets < args.max_resets:
resets += 1
screen.save('stall-reset')
log(f'boot hung ({screen.stable_for():.0f}s frozen, dark, disk idle), system_reset #{resets}')
try:
qmp.system_reset()
except Exception as e: # noqa: BLE001
log(f'system_reset failed: {e}')
screen.stable_since = time.time()
last_progress = now
continue
# OpenCore boot picker — always handle it (the install reboots many times)
if 'terminal' not in top and 'utilities' not in top and any(k in body for k in PICKER_BODY):
screen.save('picker')
@ -293,9 +379,53 @@ def run_install(args, proc, qmp, log):
screen.stable_since = time.time()
continue
# once the bootstrap command is typed, only the picker (above) matters
if typed_at is not None:
# After the install, the loginwindow/desktop is a BRIGHT gray screen, unlike
# the dark install/boot screens (Apple logo). The vmix agent powers the VM
# off if it runs (cron/daemon); if BTM blocks it, we power down here so the
# build still completes with a bootable, installed image. Brightness is a
# far more reliable signal than OCR of the faint "password" text.
bright = screen.mean() > 80
loginish = (typed_at is not None and bright and 'terminal' not in top
and 'utilities' not in top and not any(k in body for k in PICKER_BODY))
if loginish:
if login_since is None:
login_since = now
log('bright post-install screen (loginwindow/desktop) — OS installed; grace before powerdown')
elif now - login_since > args.login_grace:
screen.save('loginwindow')
log(f'loginwindow persisted {now - login_since:.0f}s, powering down (install complete)')
try:
qmp.system_powerdown()
except Exception as e: # noqa: BLE001
log(f'powerdown failed: {e}')
for _ in range(90):
if proc.poll() is not None:
return 0
time.sleep(1)
proc.kill()
return 0
continue
else:
login_since = None
# once the bootstrap command is typed, only the picker (above) and an
# unexpected return to Recovery matter (post-prepare reboot landed on the
# recovery instead of the installer — restart the install then).
if typed_at is not None:
if ('utilities' in top or 'recovery' in top):
if recovery_start is None:
recovery_start = now
if now - typed_at > 120 and now - recovery_start > 45:
log('unexpectedly back at Recovery after install started — restarting install')
typed_at = None
terminal_attempts = 0
recovery_start = None
# fall through to the recovery/terminal handling below
else:
continue
else:
recovery_start = None
continue
if 'terminal' in top:
screen.save('terminal')
@ -309,6 +439,7 @@ def run_install(args, proc, qmp, log):
screen.save('recovery')
terminal_attempts += 1
log(f'recovery window (attempt {terminal_attempts}), opening Terminal')
last_term_action = now
open_terminal(qmp, log)
if terminal_attempts >= 3:
time.sleep(8)
@ -339,30 +470,95 @@ def run_install(args, proc, qmp, log):
def run_boot(args, proc, qmp, log):
"""Wait for the VM to power itself off (customize/generalize/first-boot),
handling the OpenCore picker and kicking a hung boot with a system reset."""
PICKER_BODY = ('base system', 'macos installer', 'macintosh hd', 'rel-1', 'rel-0')
screen = Screen(qmp, args.debug_dir, log)
start = time.time()
last_periodic = 0
resets = 0
blank_since = None
login_since = None
while True:
rc = proc.poll()
if rc is not None:
return rc
if time.time() - start > args.timeout:
try:
screen.grab()
screen.save('timeout')
screen.grab(); screen.save('timeout')
except Exception: # noqa: BLE001
pass
log('timeout reached, killing QEMU')
proc.kill()
return 124
time.sleep(args.interval)
if time.time() - last_periodic > args.periodic:
last_periodic = time.time()
try:
screen.grab()
except Exception as e: # noqa: BLE001
log(f'screendump failed ({e})')
time.sleep(2)
continue
now = time.time()
if now - last_periodic > args.periodic:
last_periodic = now
screen.save('periodic')
if now - start < args.min_boot or screen.stable_for() < args.settle:
continue
if screen.is_blank():
if blank_since is None:
blank_since = now
elif now - blank_since > args.halt_timeout and disk_idle(args):
screen.save('halt')
log(f'guest halted (black {now - blank_since:.0f}s, disk idle); killing QEMU')
proc.kill()
try:
proc.wait(timeout=10)
except Exception: # noqa: BLE001
pass
return 0
continue
blank_since = None
top = screen.menubar_text()
body = screen.ocr()
if 'terminal' not in top and 'utilities' not in top and any(k in body for k in PICKER_BODY):
screen.save('picker')
log('OpenCore boot picker, pressing Return')
qmp.send_key('ret')
screen.stable_since = time.time()
login_since = None
continue
# bright post-boot screen (loginwindow/desktop) => booted; power down if the
# agent did not (so customize/generalize completes even if BTM blocks it)
if screen.mean() > 80 and 'terminal' not in top and 'utilities' not in top:
if login_since is None:
login_since = now
log('bright screen (loginwindow/desktop) after boot; grace before powerdown')
elif now - login_since > args.login_grace:
screen.save('loginwindow')
log(f'loginwindow persisted {now - login_since:.0f}s, powering down')
try:
qmp.system_powerdown()
except Exception as e: # noqa: BLE001
log(f'powerdown failed: {e}')
for _ in range(90):
if proc.poll() is not None:
return 0
time.sleep(1)
proc.kill()
return 0
continue
else:
login_since = None
if 'terminal' not in top and 'utilities' not in top and screen.mean() < 40 \
and screen.stable_for() > args.stall_reset and disk_idle(args) and resets < args.max_resets:
resets += 1
screen.save('stall-reset')
log(f'boot hung ({screen.stable_for():.0f}s frozen, dark, disk idle), system_reset #{resets}')
try:
screen.grab()
screen.save('periodic')
qmp.system_reset()
except Exception as e: # noqa: BLE001
log(f'screendump failed ({e})')
log(f'system_reset failed: {e}')
screen.stable_since = time.time()
def main():
@ -376,6 +572,12 @@ def main():
p.add_argument('--settle', type=float, default=12.0, help='seconds a screen must be unchanged to act on it')
p.add_argument('--min-boot', type=float, default=45.0, help='seconds before the first action')
p.add_argument('--max-actions', type=int, default=8)
p.add_argument('--stall-reset', type=float, default=360.0, help='reset the VM if a non-Terminal screen is frozen this long (boot hang)')
p.add_argument('--max-resets', type=int, default=6)
p.add_argument('--halt-timeout', type=float, default=150.0, help='after the bootstrap, a pure-black screen this long means the guest halted (macOS shutdown does not ACPI-power-off QEMU)')
p.add_argument('--progress-file', default=None, help='a file (the system disk) whose mtime shows guest activity; resets/halt only fire when it is also idle, so a slow-but-working boot is never interrupted')
p.add_argument('--disk-idle', type=float, default=90.0, help='seconds of no writes to --progress-file that count as idle')
p.add_argument('--login-grace', type=float, default=240.0, help='seconds to wait at the loginwindow for the agent to power off before the driver powers down itself')
p.add_argument('--command', default='diskutil mount VMIX;sh /Volumes/VMIX/run.sh')
p.add_argument('qemu', nargs=argparse.REMAINDER)
args = p.parse_args()