macOS: guest agent, online templates, virtio-fs shares, persistent home volume

Apple's built-in QEMU guest agent (AppleQEMUGuestAgent, launched by launchd
when a virtio console port org.qemu.guest_agent.0 appears; guest-exec as root)
is attached by vmix run --macos and the NixOS module. AppleVirtIO.kext on x86
Tahoe drives virtio-fs, block, console, input, net — verified in QEMU.

- customizeImage: `bootScript` — online step through the guest agent (driver
  mode qga): boot the image, run the script as root with the VMIX volume, shut
  down through the agent. `as_user` runs commands in the logged-in session.
- templates.software: pkg/app (offline in the PE), script/homebrew (online).
- templates.profile.settings: widgets, wallpaper (pinned desktoppr — Apple
  Events need TCC consent that a headless session cannot give), dock apps,
  autohide, dark mode, hidden files.
- generalize: persistHome (fstab LABEL=vmix-home /Users), hideWidgets offline.
- formatVolume: formats a blank disk image as APFS by booting the PE (~35 s);
  idempotent.
- NixOS module: macos.guestAgent (/run/vmix/qga-<name>.sock), shares via
  virtiofsd + vhost-user-fs (Apple automount tag for the first share, others
  mounted through the agent), macos.homeDisk (created + formatted on first
  start, virtio-blk), SPICE keeps -vga vmware for macOS.
- CLI: vmix run --macos --share DIR --home FILE --qga PATH.
- qemu.nix helpers; README section.

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-09 21:47:41 -03:00
parent 50ad8d521c
commit 0f9373263d
13 changed files with 600 additions and 19 deletions

View file

@ -9,6 +9,9 @@ Modes
(bright) loginwindow / Setup Assistant the driver powers it down
or halts on its own.
boot boot an installed image and wait for it to halt or reach the loginwindow.
qga boot an installed image with Apple's QEMU guest agent attached
(--qga-sock), wait for it, run --qga-command as root through it
(guest-exec), then shut the guest down. Used for online templates.
Observation is passive: the serial console (boot-args serial=3: the PE's
"VMIX-*" markers, kernel boots, panics) and screenshots over QMP (mean
@ -101,6 +104,44 @@ class QMP:
self.cmd('system_powerdown')
class QGA:
"""Minimal QEMU guest agent client over the unix socket QEMU serves."""
def __init__(self, path):
self.path = path
def cmd(self, name, timeout=30, **args):
s = socket.socket(socket.AF_UNIX)
s.settimeout(timeout)
try:
s.connect(self.path)
s.sendall((json.dumps({'execute': name, 'arguments': args}) + '\n').encode())
buf = b''
while b'\n' not in buf:
d = s.recv(65536)
if not d:
raise OSError('guest agent closed the connection')
buf += d
finally:
s.close()
r = json.loads(buf.split(b'\n', 1)[0])
if 'error' in r:
raise RuntimeError(r['error'])
return r.get('return')
def ping(self):
try:
return self.cmd('guest-ping', timeout=5) == {}
except Exception: # noqa: BLE001
return False
def exec_start(self, command):
return self.cmd('guest-exec', path='/bin/bash', arg=['-c', command], **{'capture-output': True})['pid']
def exec_status(self, pid):
return self.cmd('guest-exec-status', pid=pid)
class Screen:
"""Screenshots over QMP with a coarse change fingerprint (cursor-insensitive)."""
@ -227,6 +268,104 @@ def disk_idle(args):
return True
def run_qga(args, proc, qmp, screen, serial, log):
"""Online template: wait for the guest agent, run the command, shut down."""
import base64
qga = QGA(args.qga_sock)
start = time.time()
last_periodic = 0
while not qga.ping():
rc = proc.poll()
if rc is not None:
log(f'QEMU exited with {rc} before the guest agent came up')
return rc or 3
now = time.time()
if now - start > args.start_timeout:
try:
screen.grab()
screen.save('no-agent')
except Exception: # noqa: BLE001
pass
log(f'guest agent not reachable within {args.start_timeout:.0f}s')
proc.kill()
return 3
for line in serial.poll():
if any(m in line for m in PANIC_MARKS):
log('serial: ' + line.strip()[:200])
if now - last_periodic > args.periodic:
last_periodic = now
try:
screen.grab()
screen.save('periodic')
except Exception: # noqa: BLE001
pass
time.sleep(3)
log(f'guest agent up after {time.time() - start:.0f}s: {qga.cmd("guest-info").get("version")}')
time.sleep(args.qga_settle) # let the login session / volumes settle
try:
pid = qga.exec_start(args.qga_command)
except Exception as e: # noqa: BLE001
log(f'guest-exec failed: {e}')
proc.kill()
return 3
log(f'running command as root (pid {pid}): {args.qga_command[:160]}')
t0 = time.time()
while True:
rc = proc.poll()
if rc is not None:
log(f'QEMU exited with {rc} while the command was running')
return rc or 3
if time.time() - start > args.timeout:
log('timeout reached, killing QEMU')
proc.kill()
return 124
try:
st = qga.exec_status(pid)
except Exception as e: # noqa: BLE001
log(f'guest-exec-status failed: {e}')
time.sleep(5)
continue
if st.get('exited'):
break
now = time.time()
if now - last_periodic > args.periodic:
last_periodic = now
try:
screen.grab()
screen.save('periodic')
except Exception: # noqa: BLE001
pass
time.sleep(3)
out = base64.b64decode(st.get('out-data', '')).decode('utf-8', 'replace')
err = base64.b64decode(st.get('err-data', '')).decode('utf-8', 'replace')
code = st.get('exitcode', st.get('signal'))
log(f'command finished in {time.time() - t0:.0f}s, exit {code}')
for line in (out + err).splitlines()[-200:]:
log('guest: ' + line[:220])
try:
screen.grab()
screen.save('after-command')
except Exception: # noqa: BLE001
pass
log('shutting the guest down')
try:
qga.exec_start('sync; /sbin/shutdown -h now')
except Exception as e: # noqa: BLE001
log(f'guest shutdown failed ({e}), ACPI powerdown')
try:
qmp.system_powerdown()
except Exception: # noqa: BLE001
pass
for _ in range(180):
if proc.poll() is not None:
log('QEMU exited')
return 0 if code == 0 else 4
time.sleep(1)
log('guest did not power off, killing QEMU')
proc.kill()
return 0 if code == 0 else 4
def drive(args, proc, qmp, screen, serial, log):
start = time.time()
last_periodic = 0
@ -275,11 +414,12 @@ def drive(args, proc, qmp, screen, serial, log):
screen.save('panic')
except Exception: # noqa: BLE001
pass
if args.mode == 'pe' or panics > args.max_resets:
if panics > args.max_resets:
log(f'kernel panic #{panics}, giving up')
proc.kill()
return 3
# XNU reboots by itself after a panic; only reset if no kernel comes back
# XNU reboots by itself after a panic; only reset if no kernel comes back.
# (In pe mode the PE then runs run.sh again — templates are idempotent.)
log(f'kernel panic #{panics}, waiting for the guest to reboot')
serial.reboot_at = now
continue
@ -287,7 +427,7 @@ def drive(args, proc, qmp, screen, serial, log):
# The guest asked for a reboot but no kernel came back: macOS' restart
# path panics in QEMU (AppleSMC watchdog keys, see README); reset now
# instead of waiting for the frozen-screen watchdog.
if serial.reboot_at and now - serial.reboot_at > args.reboot_timeout and args.mode != 'pe':
if serial.reboot_at and now - serial.reboot_at > args.reboot_timeout:
resets += 1
try:
screen.grab()
@ -382,7 +522,10 @@ def drive(args, proc, qmp, screen, serial, log):
def main():
p = argparse.ArgumentParser()
p.add_argument('--mode', choices=['pe', 'install', 'boot'], required=True)
p.add_argument('--mode', choices=['pe', 'install', 'boot', 'qga'], required=True)
p.add_argument('--qga-sock', default=None, help='guest agent unix socket (mode qga)')
p.add_argument('--qga-command', default=None, help='bash command to run as root through the guest agent (mode qga)')
p.add_argument('--qga-settle', type=float, default=20.0, help='seconds to wait after the agent answers before running the command')
p.add_argument('--name', default='macos')
p.add_argument('--debug-dir', default=None)
p.add_argument('--serial-log', default=None, help='file QEMU writes the serial console to')
@ -422,7 +565,10 @@ def main():
screen = Screen(qmp, debug_dir, log)
serial = Serial(args.serial_log)
try:
rc = drive(args, proc, qmp, screen, serial, log)
if args.mode == 'qga':
rc = run_qga(args, proc, qmp, screen, serial, log)
else:
rc = drive(args, proc, qmp, screen, serial, log)
finally:
if args.serial_log and os.path.exists(args.serial_log):
try: