macOS Tahoe VM images (OpenCore/QEMU), Apple-ID compatible, VNC

Add a macOS image pipeline mirroring the Windows one: unattended install,
generalization and user creation, driven end to end in QEMU on a KVM host.

lib/images/macos:
- makeOpenCore: OSX-KVM OpenCore ESP with a config.plist rewritten per image —
  SMBIOS model + serial/MLB (macserial) + UUID + ROM=en0 MAC (built-in NIC pinned
  to PciRoot(0x0)/Pci(0x12,0x0)) for Apple ID / iMessage / App Store; boot disk;
  OpenCore self-entry hidden. ident.nix derives MAC+UUID from a seed so the NixOS
  module and CLI know the NIC MAC at eval time.
- makeImage: one QEMU session driven by vm-driver.py (QMP + screenshot settle
  detection + OCR of the menu bar) — boots the recovery via OpenCore, opens
  Terminal (Ctrl-F2 -> Utilities -> Terminal), types the bootstrap command;
  vmix-install.sh erases the disk as APFS, lays down the host-extracted installer
  app skeleton + a byte-exact SharedSupport.dmg (raw disk mapped to that byte range
  of the pkg, dd'd in — Recovery's xar truncates an 18 GB member), runs
  startosinstall with the vmix agent pkg. OpenCore is then copied into the image's
  ESP so it boots standalone with OVMF.
- customizeImage / templates: boot the image with a FAT-then-HFS+ VMIX volume; the
  vmix agent (LaunchDaemon) runs a script as root, records status and powers off —
  the macOS counterpart of Windows Audit Mode. generalize creates the admin user +
  auto-login (kcpassword), suppresses Setup Assistant, sets hostname/timezone,
  grows APFS, and assigns a fresh SMBIOS identity. Templates: noUpdates,
  performance, remoteAccess (ssh + screen sharing).
- fetchRecovery: Apple recovery BaseSystem, retried until the pinned Tahoe build
  (osrecovery load-balances Sequoia/Tahoe during the rollout). makeAgentPkg builds
  a distribution flat pkg on Linux (xar+bom+cpio) for startosinstall --installpackage.

CLI: vmix build/copy/run for macOS (run --macos --vnc, reads the image's MAC from
its ESP), and a `vmix macserial` helper. NixOS module: disks.os.file carrying
_vmixOsType="macos" auto-enables the macOS QEMU profile (AppleSMC+OSK, Skylake
CPU spoof, AHCI system disk, VMware SVGA, pinned NIC); macos.{enable,cpu,mac}.

Status: proven through the installer prepare phase (SharedSupport.dmg mounts,
version 26.6.2 read, SU catalog loads). Two blockers remain, documented in
lib/images/macos/README.md: (1) startosinstall's OSISVerifyBaseSystemOperation
rejects the byte-identical plain-UDIF SharedSupport as "pkgdmg missing a footer"
in this Tahoe recovery/VM; (2) Apple's CDN unreliably serves the Tahoe recovery
during rollout (self-hosting the verified BaseSystem is the robust fix).

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-08 16:03:52 -03:00
parent 6a62a649bd
commit 242e48a5fc
35 changed files with 1842 additions and 36 deletions

134
lib/images/macos/README.md Normal file
View file

@ -0,0 +1,134 @@
# vmix macOS images
Unattended macOS (Tahoe / 26) VM images, built the same way as the Windows
images: `makeImage` installs the OS once, templates customize it by booting it,
`.generalize` creates the user and seals the image.
```
vmix build --image macos.images.tahoe.basic \
--generalize username=sagar,password=secret,hostname=MAC,timezone=Europe/Zurich
vmix run ./result --macos --vnc :10 --mem 8192 # VNC on port 5910
```
## How it works
| step | what happens |
|---|---|
| `fetchRecovery` | BaseSystem.dmg from Apple's recovery servers (fixed-output, pinned by sha256) |
| `installerPayload` | takes the App Store `InstallAssistant.pkg` (18 GB, pinned) apart on Linux: the app skeleton (pbzx/cpio) and the byte offset of `SharedSupport.dmg` |
| `makeOpenCore` | OSX-KVM's OpenCore ESP with a config.plist rewritten for this image: SMBIOS model, serial + MLB (`macserial`), UUID and ROM = NIC MAC (derived from a seed), NIC marked built-in |
| `makeImage` | one QEMU session: Recovery boots via OpenCore → `vm-driver.py` opens Terminal with keystrokes (Ctrl-F2 menu navigation, screen-settle detection + OCR of the menu bar) and types `sh /Volumes/VMIX/run.sh``vmix-install.sh` erases the disk, rebuilds `Install macOS Tahoe.app` (skeleton + `SharedSupport.dmg` copied from a raw disk mapped straight out of the pkg), runs `startosinstall --installpackage vmix-agent.pkg` → installer reboots through its phases → first boot runs the **vmix agent** which powers off. OpenCore is then copied into the image's EFI partition, so it boots standalone with OVMF |
| `customizeImage` | boots the image with a FAT volume `VMIX`; the agent (LaunchDaemon `ch.vmix.agent`) runs `vmix-run.sh` as root, writes `vmix-run.status`/`.log` back and shuts down |
| `templates.generalize` | user (admin) + auto-login (`/etc/kcpassword`), Setup Assistant suppressed, hostname, timezone, no sleep, APFS grown to the disk, then the agent removes itself; a fresh SMBIOS identity is written to the ESP |
The vmix agent replaces Windows' Audit Mode RunOnce; `.AppleSetupDone` replaces
the OOBE unattend. Everything on the host side runs inside `__noChroot`
derivations (KVM + `/tmp`), exactly like the Windows builders.
## Generalize options
`username password fullName autoLogon hostname locale timezone delayOobeRun`
as for Windows (`bgColor` is accepted but ignored), plus the SMBIOS identity:
`model serial mlb uuid mac seed`. Anything unset is generated: serial/MLB by
macserial (random per build), MAC and UUID deterministically from `seed`
(default `hostname-username`). `vmix macserial --model MacPro7,1` prints a
ready-to-paste set.
`delay-oobe-run=true` creates no user and re-arms Setup Assistant for the first
real boot.
## Apple ID / iMessage
The image satisfies what Dortania lists for iServices: unique serial + MLB for a
Tahoe-supported model (`MacPro7,1` by default; `iMac20,1/2`,
`MacBookPro16,x` also work), SystemUUID, ROM equal to en0's MAC, and en0 marked
built-in (the NIC is pinned to `PciRoot(0x0)/Pci(0x12,0x0)`). The NixOS module
and `vmix run --macos` use the MAC recorded in the image (`EFI/vmix/vmix.json`).
Give each deployed VM its own generalized image (different `seed`, or explicit
`serial=`/`mlb=`) — two VMs with the same identity will be blocked.
## Runtime
* `vmix run <qcow2> --macos [--vnc :N] [--mac ..]`
* NixOS module: `disks.os.file = vmixLib.macos.images.tahoe.basic.generalize {...}`
is auto-detected (`_vmixOsType = "macos"`): Skylake-Client CPU spoof, AppleSMC,
USB keyboard/tablet, AHCI system disk, VMware SVGA, pinned NIC with the image's MAC.
`macos.cpu`, `macos.mac`, `macos.enable` override the defaults.
* `vmix copy` writes the image to a disk but cannot grow APFS from Linux
(`diskutil apfs resizeContainer disk0s2 0` in macOS afterwards).
## Debugging a build
Screenshots (`NNN-<state>.png`), `driver.log` and the QMP socket of every VM
session are in `/tmp/vmix-macos/<image name>/` on the build host. The guest logs
(`install.log`, `vmix-run.log`, `vmix-agent.log`) are printed at the end of the
build. Pass `vncDisplay = ":10"` to `makeImage`/`customizeImage` (or
`--generalize vncDisplay=:10`) to watch live; with a `DISPLAY` an SDL window
is used as for Windows.
## Updating pins (`upstream.json`)
* installer: URL + SRI hash of a newer `InstallAssistant.pkg`
(`nix store prefetch-file --name InstallAssistant.pkg <url>`; Mr. Macintosh's
database lists Apple's URLs)
* recovery: Apple serves the current build for the board id, so the sha256
changes with each point release — copy the "got:" hash from the failed build
* opencore: OSX-KVM `OpenCore.qcow2` at a commit; OpenCorePkg release zip (macserial/ocvalidate)
## Known limits
* The Recovery bootstrap depends on keyboard navigation of the Recovery UI
(Ctrl-F2 → Utilities → Terminal). It self-corrects with screenshots + OCR and
falls back to a blind sequence, but a Recovery UI change would need
`vm-driver.py` adjusted.
* Hosts must run KVM with an AVX2-capable CPU (Intel or AMD; the guest sees a
Skylake). `sandbox = relaxed` and the `kvm` system feature, 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)
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:
* 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`.
### Blocker 1 — `OSISVerifyBaseSystemOperation: pkgdmg is missing a footer`
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.
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.
### Blocker 2 — Tahoe recovery availability on Apple's CDN
`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.