Files
nucleic/docs/MACOS_VM.md
T
abkslmandnucleic 7ca898bd3f Add Virtualization Framework Support
Nucleic-Session: 4FCF4F8B-A7C3-42F6-BE13-1979080F61C7
Co-authored-by: Nucleic <[email protected]>
2026-07-06 01:55:41 -07:00

37 KiB
Raw Blame History

Nucleic — macOS guest VMs (Virtualization framework)

Per-session, isolated macOS virtual machines built directly on Apple's Virtualization framework — the Mac-native counterpart to the Linux container sandbox. Where the container gives an agent an isolated Linux userland (see RUNTIME_ARCHITECTURE §3, ContainerEngine / ContainerManager, and containers/nucleic-sandbox/Dockerfile), the macOS VM gives it an isolated Mac: a real, disposable macOS instance where xcodebuild, xcrun simctl simulator runs, and codesign can execute end-to-end without touching the one shared host.

Status: implemented (base build partly manual). The engine, manager, spec, settings, the mac_vm_exec MCP tool, and the macvm-spike prover exist and are wired end to end; producing the golden base image is a one-time, partly-hand-driven install (Setup Assistant has no unattended path outside MDM — §4). Apple-silicon only.


1. Motivation

The sandbox container deliberately omits the Swift/Xcode toolchain (containers/nucleic-sandbox/Dockerfile: "The Swift/Xcode toolchain is deliberately ABSENT"). Mac-only work therefore escapes the sandbox through the host_exec MCP tool, which runs on the one shared macOS host. HOST_EXEC_CONCURRENCY documents the hazard that follows when two agents run in parallel:

  1. Toolchain thrash — two xcodebuilds (or swift builds) on one machine oversubscribe CPU/IO and slow each other down.
  2. Shared build directory (the sharp edge) — under nvrsion, sessions share one trunk worktree, so two builds write the same derived-data / .build directory and corrupt each other. Not merely slow — wrong.

host_exec mitigates (2) with an advisory, user-gated concurrency gate (a slot registry + FIFO wait queue). That's a coordination band-aid on a fundamentally shared resource: only one build can make progress at a time, and every collision costs a prompt.

The macOS VM removes the shared resource. Each agent session gets its own isolated macOS VM, so parallel agents run Mac builds/tests concurrently without colliding — no host thrash, no shared build directory, no concurrency prompt. It is to Mac work what the per-session container is to Linux work.

                       ┌───────────────────────── the hazard ─────────────────────────┐
   host_exec  ─────▶   │   ONE shared macOS host   ◀── agent A build                   │
   (today, shared)     │   shared derived-data     ◀── agent B build  → corruption     │
                       └───────────────────────────────────────────────────────────────┘

                       ┌───── agent A ─────┐   ┌───── agent B ─────┐   (isolated)
   mac_vm_exec ─────▶  │  macOS VM (clone) │   │  macOS VM (clone) │   independent disks,
   (this doc)          │  own Disk.img     │   │  own Disk.img     │   own derived-data
                       └───────────────────┘   └───────────────────┘

2. Object graph / architecture

The subsystem mirrors the container subsystem's mechanism / policy split (RUNTIME_ARCHITECTURE §3): a low-level engine actor that drives the framework, and a policy actor above it that owns per-session lifecycle. All under Sources/NucleicCore/MacVM/.

        ┌───────────── SessionController (actor, one per session) ─────────────┐
        │  allowsMacVMExec = serviceEnabled && isSandboxed && Apple silicon     │
        │  → RunSpec.allowMacVMExec, appends mac_vm_exec system-prompt guidance │
        └───────────────────────────┬──────────────────────────────────────────┘
                                     │ drives (via ClaudeCodeBackend.handleMacVMExecCall)
                                     ▼
        ┌──────────── MacVMManager (actor) — POLICY ────────────┐   one shared instance,
        │  • per-session naming  nucleic-mac-<short><channel>   │   injected by NucleicApp
        │  • busy ref-counts + idle timers (stop, keep clone)   │   (like ContainerManager)
        │  • teardown / launch reconcile (on-disk GC)           │
        │  • concurrent-guest ceiling (maxConcurrentVMs, dflt 2)│
        └───────────────────────────┬───────────────────────────┘
                                     │ drives
                                     ▼
        ┌──────────── MacVMEngine (actor) — MECHANISM ─────────────────────────┐
        │  Apple `Virtualization` (VZ*). Daemonless/ephemeral: `live` empty at  │
        │  launch, reconcile = on-disk GC. Owns:                                │
        │    • the golden BASE bundle  (base/)                                  │
        │    • per-session CoW CLONES  (instances/<name>/)                      │
        │    • host SSH keypair        (ssh/id_ed25519[.pub])                   │
        │    • cached restore image    (restore/latest.ipsw)                    │
        │  isSupported / unsupportedReason gate on `#if arch(arm64)`.           │
        └───────────────────────────┬──────────────────────────────────────────┘
                                     │ ssh over NAT (spawns /usr/bin/ssh via ChildProcess)
                                     ▼
                        ┌──── macOS guest (the clone) ────┐
                        │  agent@<nat-ip>, sshd, toolchain │
                        │  repo at /Volumes/My Shared Files/workspace (virtiofs)
                        └──────────────────────────────────┘
Type Isolation Responsibility
MacVMEngine actor Mechanism over Virtualization. Owns the base bundle, clones, restore image, host SSH key; boots each clone on its own serial VZVirtualMachine queue; execs over SSH. Daemonless.
MacVMEngine+Base extension MacVMBundle on-disk layout, base resolution (prebuilt-or-built), CoW cloning, the VZ config builder, and the one-time buildBaseImage() install flow.
MacVMManager actor Policy: per-session naming, ref-counted idle timers, teardown, launch reconcile, and the concurrent-guest ceiling. Injected like ContainerManager.
MacVMSpec value MacVMSpec (name, cpus, memoryGiB, mounts, workdir, env, idleTimeout, sshUser), plus MacVMError, MacVMResourceSample, MacVMBaseProgress, MacVMEntry.
MacVMInstance @unchecked Sendable box Wraps one VZVirtualMachine + its serial queue; bridges VZ's completion-handler API to async. Confines all non-Sendable VZ state. Compiled only under #if arch(arm64).

2.1 Why an SSH exec still satisfies the ProcessHandle contract

ContainerEngine adapts a container exec's vsock stdio to ProcessHandle (ContainerizedProcessHandle) so a backend drives a guest exec exactly like a host spawn (RUNTIME_ARCHITECTURE §3). The macOS VM does the analogous thing without a custom handle at all: it spawns the host /usr/bin/ssh client through the ordinary ChildProcess (ProcessSpec, stdinMode: .pipe). The remote command's stdio is the local ssh process's stdio, so the same robust line-buffered plumbing and signal delivery are reused, and the backend decode loop is substrate-agnostic — it can't tell a guest exec from a host or container one. This is the key that lets mac_vm_exec slot into the existing exec machinery with almost no new surface.

2.2 Daemonless ⇒ ephemeral

Exactly like ContainerEngine: no VM survives the app process. MacVMEngine.live (the registry of running guests, keyed by logical name) starts empty each launch. The per-session clone persists on disk (its CoW Disk.img + aux storage), so an idle-stopped or app-restarted VM reboots fast without re-installing macOS, but the running set is bounded by the app process. reconcile is therefore pure on-disk GC — there is no daemon holding phantom VMs.


3. Storage layout

Storage root: ~/Library/Application Support/Nucleic/macvms/ (MacVMEngine.defaultStorageRoot). Subdirectories:

macvms/
├── base/                       the golden base bundle (built here when not prebuilt)
│   ├── HardwareModel           VZMacHardwareModel data
│   ├── MachineIdentifier       VZMacMachineIdentifier data
│   ├── AuxiliaryStorage        NVRAM (per-VM writable — each clone gets its own copy)
│   ├── Disk.img                the system disk (CoW-cloned per session)
│   ├── MACAddress              (clones only) the pinned NAT MAC, for lease lookup
│   └── bundle.json             {installed, provisioned} metadata
├── instances/<name>/           per-session clone bundles (same layout; GC'd on reconcile)
├── restore/latest.ipsw         cached macOS restore image (~14 GB) when fetched
└── ssh/id_ed25519[.pub]        host SSH keypair; the .pub is baked into the base's agent account

MacVMBundle (MacVMEngine+Base) models this layout; isComplete is true when HardwareModel, MachineIdentifier, AuxiliaryStorage, and Disk.img all exist. The base bundle follows Apple's "Running macOS in a virtual machine on Apple silicon" sample; MACAddress and bundle.json are Nucleic additions.

3.1 Copy-on-write cloning

cloneBase produces a per-session clone from the golden base:

  • The small identity files (HardwareModel, MachineIdentifier) are plain copies.
  • AuxiliaryStorage (NVRAM) is copied per clone — it must be independently writable.
  • Disk.img (multi-GB) is cloned copy-on-write via clonefile(2) — instant and space-efficient on APFS. On a non-APFS volume clonefile returns non-zero and it falls back to a full copyItem.
  • A fresh locally-administered MAC (VZMACAddress.randomLocallyAdministered) is assigned and written to MACAddress, so each clone's DHCP lease is uniquely findable (§5).

The base stays pristine; the clone is what the session boots and mutates. On idle-restart / relaunch an existing clone is reused as-is (its identity + MAC persist on disk).

3.2 The VZ config builder

buildConfiguration assembles the VZVirtualMachineConfiguration: reconstruct the Mac platform from the bundle's persisted identity + this bundle's aux storage; VZMacOSBootLoader; CPU/memory clamped to the framework's allowed range (and never more than host cores 1); the bundle's Disk.img as a VZVirtioBlockDevice; NAT networking with the clone's pinned MAC; a display + keyboard + trackpad (so the same config can be shown in a window while provisioning the base — Setup Assistant needs a display; clones run headless and never present the view); entropy + memory-balloon devices; and, when mounts are present, a virtiofs share (§6).


4. Base image (install + provision, or prebuilt)

Everything expensive happens once, to produce the golden base every session clones. ensureBaseBundle resolves the base without ever kicking off the heavy build implicitly — order is: a configured prebuilt bundle (MacVMSettings.basePrebuiltPath) if present, else the engine's own built base under base/, else throw .baseImageMissing pointing at the explicit build path. A missing base is never a surprise on an agent's first turn.

4.1 Install (MacVMEngine.buildBaseImage)

  1. Resolve a restore image: a configured local .ipsw (MacVMSettings.restoreImagePath), else VZMacOSRestoreImage.fetchLatestSupported downloaded to restore/latest.ipsw (~14 GB, with a determinate progress bar via MacVMBaseProgress).
  2. Create a fresh install-time platform (new aux storage bound to the image's hardware model, new machine identifier), a sparse Disk.img of MacVMSettings.baseDiskGiB (default 96 GiB), and install a clean macOS via VZMacOSInstaller.
  3. The build goes into a temp base.building/ bundle and is published atomically (moved to base/) so an interrupted install never leaves a half-built base that ensureBaseBundle would treat as usable.

After this, the guest sits at Setup Assistant — a clean install, not yet usable.

4.2 Provision (turning the clean install into the golden base)

This is where honesty matters: there is no unattended macOS-guest install path outside MDM/DEP. Setup Assistant must be completed by a human, once, in a display session. The orchestration:

  • scripts/build-macos-base.sh — HOST-side orchestration: builds + ad-hoc-codesigns the macvm-spike helper (§8), runs the clean install (§4.1), then prints the manual next steps.
  • The manual step: boot the base bundle in a window (Apple's VZVirtualMachineView over the same base bundle — a small GUI helper, since the headless engine path can't show Setup Assistant), complete Setup Assistant, and create a local admin account whose short name is exactly agent (MacVMSettings.defaultSSHUser).
  • scripts/provision-macos-guest.sh — the in-guest provisioner, run as agent: enable Remote Login (sshd), authorize Nucleic's host public key (macvms/ssh/id_ed25519.pub), install the dev toolchain (Xcode CLT / full Xcode + the iOS/other simulators, Homebrew node/npm + python/pip), export the toolchain PATH into /etc/zshenv (so non-login SSH shells resolve xcodebuild/node/python — §6), then shut the guest down clean.

Once the guest powers off, base/ is golden and MacVMEngine.ensureRunning will clone it and SSH straight in. MacVMBaseProgress (downloadingRestoreImageinstallingprovisioningready) surfaces the phases to the Virtual Machines settings panel.

4.3 Prebuilt bypass

A user who already has a provisioned base can set MacVMSettings.basePrebuiltPath to its bundle directory. When it's complete, the engine clones it directly and skips install + provision entirely — useful for sharing one prepared base across machines, or for CI-produced bases.


5. Guest interaction — SSH over NAT

The Virtualization framework exposes no host→guest exec API for Mac guests (unlike a Linux guest's vsock exec via the containerization framework). Nucleic reaches the guest over SSH on the NAT network:

  • NetworkingVZNATNetworkDeviceAttachment (vmnet NAT). No bridged networking, so no restricted entitlement (§7).
  • IP discovery — each clone carries a pinned locally-administered MAC. leaseIP(forMAC:) parses /var/db/dhcpd_leases (written by Apple's vmnet NAT DHCP server) to map that MAC → the guest's IPv4. MACs are compared octet-by-octet as integers, because the lease file drops leading zeros (2:34:…) while VZMACAddress.string keeps them (02:34:…); the newest matching lease wins.
  • ReadinessawaitGuestReady polls for the lease, then probes sshd (ssh … true) until it answers, bounded to ~240 s (first boot: login window + launchd + sshd). A VM that boots but never answers is torn back down so a retry starts clean.
  • The execsshHandle spawns /usr/bin/ssh via ChildProcess (§2.1) with identity-only auth against the per-run key, BatchMode=yes (never prompt), no host-key persistence (StrictHostKeyChecking=no, UserKnownHostsFile=/dev/null — a fresh clone rotates its host key, so pinning would spuriously fail), and a short connect timeout. The remote command is composed by remoteScript: export the extra env, cd into the workdir, then run the body. run(…) is the one-shot variant used by mac_vm_exec — it drains stdout/stderr concurrently (so a big build can't deadlock on a full pipe) and returns (exitCode, stdout, stderr).

5.1 The base's SSH surface

Provisioning bakes an agent account authorized with Nucleic's host public key (whose private half never leaves macvms/ssh/id_ed25519). Toolchain PATH is exported via /etc/zshenv so non-login SSH shells resolve the toolchain without a login shell. The session repo is shared in over virtiofs (§6).


6. File sharing — virtiofs automount

The session's repo/worktree is surfaced to the guest as a virtiofs share. Unlike the Linux container's identical-path bind mounts, a macOS guest auto-mounts virtiofs shares under /Volumes/My Shared Files/<name>/ (VZVirtioFileSystemDeviceConfiguration with the macOSGuestAutomountTag), so the guest path is derived from the share name, not the host path. mac_vm_exec shares the run's worktree under the fixed name workspace, so the agent's default working directory is /Volumes/My Shared Files/workspace. No in-guest mount action is required — the automount tag does it.


7. Entitlements

The only entitlement required is com.apple.security.virtualization — already carried by the app and by signing/spike.entitlements. It covers macOS guests exactly as it covers the existing Linux VMs; no new entitlement, no provisioning profile (see signing/README.md and signing/spike.entitlements).

NAT networking is deliberately chosen partly because it needs no extra entitlement. Bridged networking would require the restricted com.apple.vm.networking, which — under an ad-hoc signature — causes the kernel to kill the binary at launch (observed: immediate exit 137, no output; documented inline in signing/spike.entitlements). Avoiding it keeps the ad-hoc-signed macvm-spike and the Developer-ID app on the same, unrestricted footing.


8. Agent tool surface — mac_vm_exec

A new MCP tool mac_vm_exec (qualified mcp__nucleic__mac_vm_exec), modeled on host_exec and registered in MCPApprovalServer (registerMacVMExec / MacVMExecCall / MacVMExecReply).

  • Advertised only on opt-in. The tool appears in tools/list for a session only when that session registered a handler — i.e. it opted into the macOS VM. Otherwise it's invisible.
  • Always approval-gated, never auto-approved. Like host_exec, it's pre-allowed so the call reaches Nucleic's handler directly (bypassing Claude's permission path, so auto mode can't auto-approve it), and the handler is the sole gate: every call surfaces an explicit approval unless the user chose "Allow for this session". It requires a reason (the schema demands it; empty/vague is refused at the choke point) that's shown on the approval.
  • On approval (ClaudeCodeBackend.handleMacVMExecCall): boot or reuse this session's own VM (MacVMManager.ensureRunning, sharing the worktree in as workspace), run the command over SSH, and return {exit_code, stdout, stderr}. The first call boots a fresh clone (a couple of minutes); later calls in the session reuse the running VM. The idle timer is re-armed after the turn.

8.1 Gating

SessionController.allowsMacVMExec is true iff MacVMSettings.serviceEnabled && the session is sandboxed && Apple silicon (MacVMManager.isSupported). It sets RunSpec.allowMacVMExec, which ClaudeCodeBackend uses both to register the handler (only when a MacVMManager is wired) and to pre-allow the qualified tool name. Sandboxed is required because a non-sandboxed session already has the host toolchain via host_exec; the VM's value is isolation for the sandboxed case.

8.2 System-prompt guidance — prefer it over host_exec

When the tool is exposed, SessionController.sandboxBuildGuidance appends guidance telling the agent it has an isolated Mac and to PREFER mac_vm_exec over host_exec for Mac-only work (Xcode builds, xcrun simctl runs, codesign) — precisely because the VM is per-agent and can't collide with another session the way the shared-host host_exec can. There is no shared-host concurrency gate on mac_vm_exec (contrast HOST_EXEC_CONCURRENCY): each session's VM is independent, so the collision the gate exists to prevent cannot occur.


9. Settings

MacVMSettings (UserDefaults, in Sources/NucleicCore/Project.swift):

Key Default Meaning
serviceEnabled off Master switch. Off → mac_vm_exec is never exposed, no VM ever boots. Off by default because a macOS guest is heavy (several GB host RAM each + a large one-time base install).
exposeByDefault off When on, sandboxed sessions expose mac_vm_exec by default (only meaningful while the service is on).
basePrebuiltPath unset Path to a user-provided golden base bundle → clone directly, skip install/provision (§4.3).
restoreImagePath unset Path to a local .ipsw → skip the ~14 GB fetch when building the base.
vmCPUs 4 Per-guest vCPU ceiling (clamped at boot).
vmMemoryGiB 8 Per-guest memory ceiling — macOS wants more than a container to keep Xcode/simulator responsive.
baseDiskGiB 96 System-disk size the base is installed into; clones inherit it CoW.
maxConcurrentVMs 2 Concurrent running-guest ceiling — macOS caps simultaneous macOS guests (§10.1).
sshUser agent The in-guest account Nucleic SSHes in as (baked into the base by provisioning).

10. Lifecycle

Lifecycle mirrors the container manager (RUNTIME_ARCHITECTURE §3, ContainerManager):

  • Per-session naming. MacVMManager.vmName(for:)nucleic-mac-<sessionID.short> + ContainerManager.channelSuffix (reusing the container convention so several installed Nucleic builds don't collide on the shared on-disk clone store). Lowercased, matching the disk-GC convention. It's the sibling of a container's nucleic-<short>.
  • Busy ref-counting + idle timer. ensureRunning marks a turn in-flight and disarms the idle timer; finished marks it done and re-arms it. When the timer fires with no turn in flight, the VM is stopped (freeing its several GB of host RAM) but the on-disk clone is kept, so the next turn reboots it without re-installing. Default idle timeout 900 s.
  • Teardown. teardown(session) stops the VM and deletes its clone bundle (instances/<name>/); restart(session) stops-then-reboots in place to reclaim RAM (skipped while a turn is in flight).
  • Launch reconcile. On launch reconcile(activeSessions:)reconcileDisk(keepNames:) GCs stale clone bundles, keeping only the active sessions'. Daemonless, so this is pure on-disk GC (no running VM survives the process). Wired from AppStore at startup alongside the container reconcile.
  • Anti-nap. While at least one VM is live, MacVMEngine holds a ProcessInfo.beginActivity assertion (.userInitiatedAllowingIdleSystemSleep, .suddenTerminationDisabled, .automaticTerminationDisabled) so the app isn't App-Napped or auto-terminated out from under a running guest (mirrors ContainerEngine's assertion; the runtime is daemonless, so the app process bounds every VM's life).

10.1 The concurrent-guest ceiling

Unique to macOS guests: macOS caps how many macOS guests run at once (historically 2 on recent releases). MacVMManager.ensureRunning enforces MacVMSettings.maxConcurrentVMs before a genuinely-new boot: if the running count is already at the limit it throws MacVMError.concurrencyLimit rather than letting VZVirtualMachine.start reject the boot opaquely. An already-live VM is idempotent and always allowed through (the check gates only new boots). This turns a fan-out of agents into a queue — the caller can surface "another agent is using the Macs, retry" — instead of a mystifying boot failure. The single injected MacVMManager (NucleicApp) is what makes this ceiling app-wide rather than per-session.


11. Spike

Sources/macvm-spike/main.swift (swift build --product macvm-spike, ad-hoc codesigned with signing/spike.entitlements) is the standalone de-risking prover, sibling of container-spike. It drives the real MacVMEngine end to end:

  • default mode — clone the golden base → boot → ssh a command in the guest and capture stdout (the mac_vm_exec path) → teardown. Passes iff the command exits 0 with the expected marker.
  • NUCLEIC_MACVM_BUILD_BASE=1 — run the one-time base install instead (fetch/point-to the restore image and install macOS into base/). NUCLEIC_MACVM_IPSW supplies a local .ipsw.

It uses the app's default storage root, so a base built/provisioned by an earlier run (or the Settings action) is found and reused. scripts/build-macos-base.sh is the operator-facing wrapper around it.


12. Computer use (screen + input)

Beyond mac_vm_exec's headless SSH command channel, the guest can be driven as a GUI Mac dev simulator — the agent takes a screenshot, decides, acts (click/type/scroll/launch), takes another screenshot, and loops. This is exposed as the mac_vm_computer MCP tool (mcp__nucleic__mac_vm_computer), a computer-use loop where each action returns the resulting screenshot as an MCP image content block the model sees directly — the same shape as Anthropic's computer-use tool, but pointed at the VM instead of the host.

12.1 Actions

One tool, an action discriminator:

  • screenshot — capture the current screen (returned as an image block).
  • left_click / right_click / double_click at x,y.
  • mouse_move to x,y; left_click_drag from the current point to x,y.
  • type — type a UTF-8 string; key — press a chord like cmd+s or cmd+shift+4.
  • scrollapproximated via arrow keys (see the note below); there's no pixel-precise scroll primitive in the input path, so a scroll maps to repeated arrow-key presses in the intended direction. Treat it as coarse.
  • launch_app — bring up an app by name (open -a).
  • cursor_position — read back where the pointer is.
  • wait — sleep briefly to let the UI settle between actions.

Every acting variant follows the act with a fresh screenshot, so the model always closes the loop on what it actually did.

12.2 How it works

Each screen/input command is dispatched into the console user's Aqua GUI session — not just any SSH shell — because screencapture and input synthesis only work against a live window server. The engine composes, over the same SSH channel as §5:

launchctl asuser $(id -u) <ABSOLUTE-PATH> …
  • Screenshots/usr/sbin/screencapture -x -t png <file> (-x = no shutter sound), then base64 -i <file> to bring the PNG back over SSH. The base64 payload is uncapped (a full-screen PNG is large, and the model needs the whole frame), decoded host-side into the MCP image block.
  • Input/usr/local/bin/cliclick (called at exactly that path — see §12.4) with its verbs: c:/dc:/rc: (click/double/right-click), m: (move), dd:/du: (drag down/up), t: (type), kp:/kd:/ku: (key press/down/up), p (print cursor position).
  • Apps/usr/bin/open -a <name>.

Because the VZ display is 1920×1200 @ 80 PPI — a 1× (non-Retina) display — screenshot pixels map 1:1 to click coordinates. There is no backing-scale factor to divide by: an object the model sees at pixel (x, y) is clicked at cliclick c:x,y with no scaling. This is a deliberate config choice; a 2× display would force the loop to halve every coordinate.

12.3 Gating & approval

Unlike mac_vm_exec (§8), computer use is not per-action approval-gated — a tight screenshot→act→screenshot loop with a prompt per click would be unusable, and the VM is a disposable sandbox, so the blast radius is contained to the guest. Instead it's opt-in at the settings level:

  • MacVMSettings.computerUseByDefault — the master opt-in (surfaced as Settings ▸ Virtual Machines ▸ "Enable computer use").
  • SessionController.allowsMacVMComputer gates whether the tool is advertised for a session (it builds on the same allowsMacVMExec preconditions — service on, sandboxed, Apple silicon — plus the computer-use opt-in).
  • ClaudeCodeBackend.handleMacVMComputerCall handles the calls (booting/reusing this session's VM exactly like the exec path, then dispatching the action).

12.4 Base-image prerequisites

Computer use needs three things baked into the golden base, all set up by scripts/provision-macos-guest.sh Phase 7 (§4.2):

  1. agent auto-login — so a headless clone boots straight into an Aqua session (no login window ⇒ no window server ⇒ black screenshots, no-op clicks). Requires FileVault OFF.
  2. cliclick at /usr/local/bin/cliclick — Homebrew installs it under /opt/homebrew/bin, but the engine calls the canonical /usr/local/bin/cliclick, so provisioning copies it there (a copy, not a symlink, so the code signature and the TCC-attributed path are a real file).
  3. TCC pre-grant — Screen Recording (kTCCServiceScreenCapture) for /usr/sbin/screencapture and Accessibility (kTCCServiceAccessibility) for /usr/local/bin/cliclick, written into the system TCC.db.

12.5 The TCC / SIP reality (honest)

Pre-granting TCC unattended is the sharp edge, and there's no clean path:

  • The grants live in the system db /Library/Application Support/com.apple.TCC/TCC.db, which SIP protects from writes.
  • tccutil can only reset, never add, a grant. A PPPC/MDM profile can pre-approve Accessibility but can never silently grant Screen Recording — Apple forces a manual user click for kTCCServiceScreenCapture.
  • So the only unattended path is SIP disabled in the guest, then a direct sqlite3 INSERT into the system TCC.db (named-column, client_type=1 path-scoped, auth_value=2 allowed), followed by killall tccd. Provisioning does exactly this — but detects SIP state and skips gracefully when SIP is on rather than failing.
  • csrutil disable is a one-time MANUAL step: it must run from recoveryOS, which has no SSH and no scripting hook, so a human boots the base VM into recoveryOS once, runs csrutil disable, and re-runs the provisioner. There is no way around this on stock macOS.

If screenshots come back black or clicks no-op on a live base, check (in the guest) that SIP is disabled (csrutil status), the TCC rows exist and are allowed (sudo sqlite3 "…/TCC.db" "SELECT service,client,auth_value FROM access;"), and agent is auto-logged-in (an Aqua session, not the login window).

12.6 ⚠ macOS 26 (Tahoe) framebuffer bug — use a Sequoia base for app debugging

There is a confirmed open Virtualization bug on macOS 26 (Tahoe) guests (trycua/cua #912): guest app windows do not render into the framebuffer. Screenshots show the desktop and Dock, but application windows come back blank — which specifically defeats the app-debugging use case (you can launch an app but can't see or drive its UI). This is a guest-side rendering defect, not a Nucleic issue, and there is no known workaround on Tahoe.

Recommendation: for computer-use / app-debugging work, build the base from a macOS 15 (Sequoia) restore image until the bug is fixed. Supply a Sequoia .ipsw via MacVMSettings.restoreImagePath (§9) so buildBaseImage installs Sequoia instead of fetching the latest (Tahoe) image. Headless mac_vm_exec work is unaffected by the bug; only the on-screen computer-use path needs Sequoia.


13. Limitations & future work

  1. Base build/provisioning is partly manual. Setup Assistant has no unattended path outside MDM/DEP, so producing the golden base needs a human once (§4.2). The GUI window helper for the Setup-Assistant phase is the rough edge. 🟡
  2. ~14 GB restore-image download on first build unless a local .ipsw is supplied. One-time, but heavy. 🟡
  3. The 2-concurrent-macOS-guest OS cap (§10.1) is enforced from a configured default; the exact ceiling on macOS 26 needs verification, and the flag should track it if a future OS lifts it. 🟡
  4. CPU% resource sampling is unavailable. The framework exposes no CPU metric for Mac guests, so MacVMResourceSample.cpuPercent is reported as 0; memory total is the configured ceiling and used is left at 0 (a future revision could SSH vm_stat for a real reading). 🟡
  5. SSH sendSignal targets the local ssh client. A killed one-shot signals the host-side ssh process; the remote command may orphan and keep running in the guest until VM teardown. Acceptable because the clone is disposable, but worth noting. 🟡
  6. vsock as a future transport. SSH-over-NAT is used because the framework has no Mac-guest exec API today; a future revision could move guest interaction onto vsock (as the container control plane did — see VSOCK_CONTROL_PLANE) to drop the NAT/DHCP dependency and the local-ssh-client indirection entirely. 🟡
  7. Residual shared-source collision under nvrsion. Each session gets its own VM, so the per-VM isolation removes the shared-host-toolchain thrash and the shared-DerivedData corruption (xcodebuild writes DerivedData to a VM-local ~/Library/Developer/Xcode/DerivedData, so two agents building the same source in different VMs do not collide). The one residual case: a tool that writes its build products into the source tree — notably SwiftPM's .build/ — under an nvrsion project, where sessions share one trunk worktree and the VM shares that live host directory read-write into each guest. Two concurrent in-VM swift builds there would write the same .build/. Non-nvrsion sessions each have their own worktree, so they're unaffected; and the dominant Xcode case is VM-local. Mitigation if it bites: redirect SwiftPM's --scratch-path to a VM-local dir, or route mac_vm_exec through the existing host-command concurrency gate (HOST_EXEC_CONCURRENCY) for shared-trunk sessions. 🟡
  8. Computer use needs a one-time manual csrutil disable. The unattended TCC pre-grant for Screen Recording + Accessibility (§12.412.5) requires SIP off, and csrutil disable can only run from recoveryOS, which has no SSH — so it's a human step, once, per base. Provisioning detects SIP and skips the grant gracefully when it's on, but computer use won't work until it's done. 🟡
  9. macOS 26 (Tahoe) guest app windows don't render into the framebuffer — a confirmed open Virtualization bug (trycua/cua #912): screenshots show the desktop/Dock but blank app windows, defeating app debugging via computer use specifically. Workaround: build the base from a macOS 15 (Sequoia) .ipsw (restoreImagePath, §9, §12.6) until it's fixed. Headless mac_vm_exec is unaffected. 🔴
  10. scroll is approximated with arrow keys (§12.1). The computer-use input path has no pixel-precise scroll primitive, so scrolling is coarse and content-dependent (works for lists and text views, less so for canvases). 🟡

14. Cross-references

  • RUNTIME_ARCHITECTURE — the object graph and the ContainerEngine / ContainerManager mechanism/policy split this subsystem mirrors, and the ProcessHandle contract.
  • HOST_EXEC_CONCURRENCY — the shared-host hazard the per-session VM removes, and the host_exec gate mac_vm_exec deliberately doesn't need.
  • VSOCK_CONTROL_PLANE — the container control-plane-over-vsock work, a model for the possible future vsock guest transport (§13.6).
  • containers/nucleic-sandbox/Dockerfile — the Linux sandbox image that deliberately omits Swift/Xcode, the omission that makes this subsystem necessary.
  • signing/README.md & signing/spike.entitlements — the virtualization entitlement (shared with the Linux VMs) and why com.apple.vm.networking is avoided.
  • scripts/provision-macos-guest.sh — the in-guest provisioner; Phase 7 installs cliclick, enables agent auto-login, and pre-grants the computer-use TCC entries (§12.412.5).