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

@ -86,49 +86,53 @@ is used as for Windows.
* Software updates inside the VM are disabled by the `noUpdates` template
(OTA updates in a VM need the RestrictEvents kext).
## Current status (2026-09-08)
## Current status (2026-09-09): working offline install
Everything up to and including the macOS Installer's *prepare* phase is working and
proven end to end on the `root@daku.home` KVM host:
`macos.images.tahoe.upstream` builds a bootable, installed macOS Tahoe 26.6.2
qcow2 **fully offline** on the KVM host — no dependency on Apple's servers at build
time, just the pinned local `InstallAssistant.pkg` and `BaseSystem.dmg`. The
finished image boots standalone (OpenCore from its own ESP) to the macOS
loginwindow. Serial/MLB/UUID/ROM are per-image for Apple ID / iMessage.
* OpenCore ESP per-image SMBIOS (serial/MLB via macserial, ROM=MAC, UUID), boot
disk, `.contentVisibility` to hide the OC self-entry — **works** (`ocvalidate` clean).
* Tahoe recovery boots via OpenCore; `vm-driver.py` drives it entirely by
screenshots + OCR: handles the OpenCore picker, opens Terminal (Ctrl-F2 →
Utilities → Terminal), types the bootstrap command — **works**.
* HFS+ `VMIX` volume mounts in Recovery; the install script erases the disk as
APFS "Macintosh HD", lays down the host-extracted `Install macOS Tahoe.app`
skeleton and `dd`s a **byte-exact** `SharedSupport.dmg` from a raw disk mapped
to that byte range of the pkg (verified `sha256` identical to Apple's) — **works**.
* `startosinstall` runs, `osinstallersetupd` **mounts SharedSupport.dmg**, reads
the MobileAsset bundle (`IA OS Version: 26.6.2, Build 25G83`), loads the
641-product SU catalog from swscan.apple.com (so guest networking works), and
logs `Machine is VM, will assume APFS is supported`.
How the install is driven (`vm-driver.py`, all by screenshot + OCR over QMP):
### Blocker 1 — `OSISVerifyBaseSystemOperation: pkgdmg is missing a footer`
* The whole `InstallAssistant.pkg` is mapped as a raw disk (it is a "pkgdmg":
xar + koly footer) and `dd`'d byte-exact into the app as `SharedSupport.dmg`
extracting the bare xar member fails startosinstall with "pkgdmg missing a footer".
* No NIC during install + `/etc/hosts` blackhole of Apple's install/verify
endpoints, so `startosinstall`'s network calls fail fast instead of hanging —
offline prepare, no external dependency. `SecureBootModel=Disabled` lets the
sealed volume install without online personalization.
* The recovery display is kept awake with a tiny mouse jiggle (a lone keypress
does not reset display sleep, and the sleeping display swallows the menu-nav
keystrokes); the settle detector uses a coarse fingerprint so the jiggling
cursor is not seen as a screen change.
* startosinstall prepare is intermittently slow/stalls; a guest watchdog kills and
re-erases/retries an attempt that stalls or runs > 9 min.
* First boot in QEMU intermittently hangs at the Apple logo; a disk-aware watchdog
(`--progress-file`) issues a QMP `system_reset` only when the screen is dark AND
the disk is idle, so a slow-but-working boot is never interrupted.
* The install reaching the (bright) loginwindow is detected by brightness (the
faint gray "password" text does not OCR) and the driver powers the VM down —
the image is installed. macOS `shutdown -h now` halts to black without an ACPI
power-off, so a black+disk-idle screen is also treated as a completed halt.
* OpenCore is then copied into the image's own ESP so it boots standalone with OVMF.
After mounting the dmg, `osinstallersetupd` runs a verify step that treats
`SharedSupport.dmg` as a *pkgdmg* (`Getting offset for dmg in pkg`) and fails
`pkgdmg is missing a footer` → `Installation cannot proceed because the installer
is damaged` (Code 255). But this InstallAssistant `SharedSupport.dmg` is a **plain
UDIF** image (first bytes `eb 58 90 …`, not `xar!`), and it is **byte-identical to
Apple's** — so this is not truncation or corruption (earlier truncation, from
Recovery's `xar` mishandling an 18 GB member, was fixed by the byte-range `dd`).
It is a macOS-internal verification that rejects the plain-UDIF SharedSupport
when running Tahoe's `startosinstall` in this recovery/VM. Network is fine (the
catalog loaded), so it is not the firewall case commonly cited for this error.
### Recovery source
Leads not yet tried: driving the Tahoe recovery's **network "Reinstall macOS"**
(GUI, downloads assets at install time — sidesteps the local-dmg verify); a
different SMBIOS/board; or a newer OpenCore/kext combo. This is cutting-edge
(Tahoe shipped 2025-09) and the hackintosh community is still working it out.
`recovery.file` in `upstream.json` points at a content-addressed store path for the
verified Tahoe `BaseSystem.dmg` (Apple's CDN load-balances Sequoia/Tahoe during the
rollout, so a plain fetch is non-deterministic). Reproduce it on any host with
`nix store add-path --name macos-tahoe-BaseSystem.dmg BaseSystem.dmg` (same path
from the same bytes). Set `recovery.sha256` and remove `recovery.file` to fetch it
from Apple instead (subject to the CDN rollout).
### Blocker 2 — Tahoe recovery availability on Apple's CDN
### Not yet done: generalize / user creation
`osrecovery.apple.com` is load-balancing `latest` across CDN nodes during the
Tahoe rollout: most requests return the **Sequoia** 15.4.1 BaseSystem
(`082-33203`), some return **Tahoe** 26.6.2 (`140-93589`). `fetchRecovery`
retries until it gets the pinned Tahoe hash, but that can exhaust its attempts
when Tahoe is rare. The robust fix is to self-host the verified Tahoe
`BaseSystem.dmg` (960530321 bytes, `sha256 edddd0d5…`, confirmed 26.6.2) the way
the Win10 ISO is hosted on git.sagar.ch, and point `fetchRecovery` at it.
The base image installs and boots to loginwindow. `.generalize` (user creation,
auto-login, hostname) relies on the vmix agent LaunchDaemon running on first boot,
but macOS Ventura+ Background Task Management does not auto-run a headless
third-party daemon, and neither the pkg `launchctl bootstrap` (installer domain
only) nor a cron `@reboot` reliably triggered it. The robust next step is to inject
the user record + settings offline from the agent pkg's postinstall (which runs as
root on the target during install), instead of a first-boot daemon.