vmix.nix/lib/images/macos
Git Sagar 58a317f5d2 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
2026-09-10 13:33:38 -03:00
..
guest macOS Tahoe: working fully-offline unattended install 2026-09-10 13:33:38 -03:00
helpers macOS Tahoe: working fully-offline unattended install 2026-09-10 13:33:38 -03:00
tahoe macOS Tahoe: working fully-offline unattended install 2026-09-10 13:33:38 -03:00
templates macOS Tahoe VM images (OpenCore/QEMU), Apple-ID compatible, VNC 2026-09-10 13:33:38 -03:00
default.nix macOS Tahoe VM images (OpenCore/QEMU), Apple-ID compatible, VNC 2026-09-10 13:33:38 -03:00
README.md macOS Tahoe: working fully-offline unattended install 2026-09-10 13:33:38 -03:00
upstream.json macOS Tahoe: working fully-offline unattended install 2026-09-10 13:33:38 -03:00

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.shvmix-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-09): working offline install

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.

How the install is driven (vm-driver.py, all by screenshot + OCR over QMP):

  • 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.

Recovery source

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).

Not yet done: generalize / user creation

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.