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
138 lines
8.3 KiB
Markdown
138 lines
8.3 KiB
Markdown
# 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-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.
|