Files
nucleic/docs/MACOS_VM.md
T

63 KiB
Raw Blame History

Nucleic — macOS guest VMs (Virtualization framework)

The same VM service also boots Linux guests (full GUI + computer-use) — see LINUX_VM.md. This document covers the macOS-guest specifics; the vsock control plane, the host-side computer-use surface, and the clone/lifecycle policy are shared between the two.

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; recipe-v13 unattended base provisioning is wired end to end. Engine-built bases fail closed until account, agent, policy, the required Xcode/SwiftUI/Metal toolchain, and production-shaped readiness invariants pass. 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(s) (restore/<ipsw>, keyed by filename)      │
        │  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 original /Users/... path (virtiofs)
                        └──────────────────────────────────┘
Type Isolation Responsibility
MacVMEngine actor Mechanism over Virtualization. Owns the base bundle, clones, restore image, persisted identity; boots each clone on its own serial VZVirtualMachine queue; execs over vsock. 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/<UniversalMac_…>.ipsw  cached restore image(s), keyed by IPSW filename (~14 GB each)
└── 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 (which chooses the guest macOS version — see §4.4): a configured local .ipsw (MacVMSettings.restoreImagePath), else a configured remote .ipsw URL (MacVMSettings.restoreImageURL), else VZMacOSRestoreImage.fetchLatestSupported (~14 GB, with a determinate progress bar). Downloads are cached keyed by the IPSW filename (which encodes the version/build), not a fixed latest.ipsw — so when Apple publishes a newer macOS, the fetch path picks it up instead of reusing a stale cached image.
  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.

4.4 Guest macOS version (up to macOS 27)

The guest's macOS version is whatever the restore image is. Three ways to choose it, in precedence order (resolveRestoreImage):

  1. Local .ipsw (MacVMSettings.restoreImagePath) — pins an exact version you downloaded.
  2. Remote .ipsw URL (MacVMSettings.restoreImageURL) — pins a version by URL; the engine downloads + caches it (keyed by filename). VZMacOSRestoreImage.load(from:) is local-file-only (it raises on a remote URL), so Nucleic always downloads to a local file first, then loads it.
  3. latestSupported (default) — the newest macOS the host can run.

The hard constraint: host ≥ guest. A macOS N guest requires a macOS N-or-newer host — the set of runnable guests is gated by the host's installed Virtualization framework (VZMacHardwareModel.isSupported). Because the only supported guest is macOS 27, that makes macOS 27 the minimum and only supported host:

Host Guests it can install/run macOS 27 guest?
macOS 27 27 (the supported guest) yes — the minimum host for a 27 guest

latestSupported is host-capped and always network-fetched, so on a macOS 27 host it returns a 27 image and the filename-keyed cache picks it up automatically. When the host is too old for a requested image, mostFeaturefulSupportedConfiguration is nil / hardwareModel.isSupported is false and buildBaseImage throws a message naming the guest and host versions; a newer-than-host guest that slips past the model check still fails at install with the same hint.

Where per-version IPSWs come from: Apple's mesu/gdmf feeds (look for the VirtualMac2,1 model), or tooling like ipsw.me, the ipsw CLI, or mist-cli. The installed version + build are recorded in the bundle's bundle.json and surfaced as "Installed guest macOS" in Settings.

No macOS 27 deployment-target bump is needed for the VM path itself to install/run a 27 guest — the stable VZMacOSRestoreImage/VZMacOSInstaller/VZVirtualMachine API (macOS 12→27) is version-agnostic and Virtualization.framework is resolved at runtime from the host. (The package nonetheless floors at .macOS(27) because the app uses macOS 27-only features elsewhere.)

Declarative first boot + mandatory MDM + post-policy provisioning. On a macOS 27 host installing a macOS 27+ guest, buildBaseImage publishes the clean install and runs three ordered writable-base boots (MacVMEngine+Provision27.swift, +Provision.swift, +MDMPolicyPass.swift):

  1. Minimal control planeVZMacGuestProvisioningOptions creates the agent account and auto-login. The host-side surface types a bootstrap from Apple's safe VirtioFS automount; it installs only durable sudo, nash, NucleicVMAgent.app, and its LaunchAgent, then powers off. No custom mount, toolchain download, optional app, or TCC database write occurs here.
  2. Mode A policy — a second boot reaches the direct MacVMInstance over the new vsock agent. It derives the host-facing NAT gateway, starts the persisted local MDM identity/server, stages enrollment artifacts through the automount, and drives only recognized User-Approved Device Management states. Both built-in policies must acknowledge and appear under their exact profile identifiers before this boot powers off.
  3. Full provisioning and readiness — the complete guest provisioner installs the toolchain, apps, packages, and optional SIP-off semantic-AX rows. A final maintenance boot then exercises the real custom-tagged workspace mount through the agent. It never clicks a Network Volumes dialog: MDM policy must already make the production-shaped mount/read succeed.

The automount is deliberately the only staging path in all pre-readiness phases. The former /Volumes/nucleic-provision custom remount touched Network Volumes before policy existed and has been removed from production. Guest downloads still use NAT during the full toolchain phase, and MDM itself uses guest-to-host HTTPS; “network-free” now applies only to the irreducible first boot.

Every phase is fail-closed. Recipe v13 is stamped only when the account, complete CLI toolchain, native agent, policy lane, Xcode license/first-launch setup, Metal compile, SwiftUI typecheck, and production-shaped agent/mount probe all succeed. Engine-built bases missing any invariant—or carrying an older recipe—are re-provisioned and refused at clone admission in the meantime. bundle.json carries MacVMBaseStatus (accountProvisioned, provisioned, agentInstalled, sipDisabled, axAgentReady, mdmEnrolled, profilesInstalled); mdm-state.json stays beside it and is never copied to clones.


5. Guest interaction — vsock (native in-guest agent)

Apple's Virtualization framework exposes no host→guest exec API for Mac guests, so historically Nucleic reached the guest over SSH on the NAT network. That is retired: the entire host↔guest control plane is now vsock, through the native in-guest agent (NucleicVMAgent, port 2035 — docs/MACOS_VM_NATIVE_AGENT.md). vsock is a private hypervisor channel — no IP, no sshd, no host-key churn, and crucially no incoming-connection / local-network permission prompt in the guest — which is exactly why it's a more fluid experience than SSH-over-NAT.

  • The channelVZVirtioSocketDeviceConfiguration on every VM; the host reaches the agent via VZVirtioSocketDevice.connect(toPort: 2035). The AF_VSOCK driver ships in the guest kernel, so it works from first boot with nothing to install driver-side.
  • The exec — the agent's streaming exec op (docs/MACOS_VM_NATIVE_AGENT.md §4.4). Each exec opens its own vsock connection (the agent listener is one thread per connection), so a multi-minute build never blocks the control channel and concurrent execs don't interfere. MacVMExecChannel (host) sends {"op":"exec","command":…} — the command composed by remoteScript exactly as before (export env, cd workdir, run body under /bin/zsh -c so /etc/zshenv's toolchain PATH resolves) — then re-splits the base64 stdout/stderr frames back into newline-delimited lines, so it satisfies the same ProcessHandle contract a host/container exec does (§2.1). run(…) is the one-shot variant mac_vm_exec uses: drain both streams concurrently, return (exitCode, stdout, stderr).
  • ReadinessawaitAgentReady polls the agent's vsock ping until it answers (bounded ~300 s: first boot = login window + launchd + the agent's LaunchAgent). "Agent answers" ≡ "guest usable"; no DHCP lease or sshd on the critical path. A VM that boots but whose agent never answers is torn back down so a retry starts clean.
  • Networking — a VZNATNetworkDeviceAttachment (vmnet NAT) is still attached, but only for the guest's own outbound internet (brew / npm / xcodebuild dependencies). It carries no host↔guest control traffic. Its DHCP lease (leaseIP(forMAC:) parsing /var/db/dhcpd_leases) is read best-effort just to show the guest IP in the settings panel. No bridged networking, so no restricted entitlement (§7).

5.1 The base's agent surface

Provisioning bakes an auto-login agent account whose Aqua session runs the NucleicVMAgent LaunchAgent (vsock listener). Toolchain PATH is exported via /etc/zshenv so the agent's /bin/zsh -c exec resolves the toolchain without a login shell. The session repo is shared in over virtiofs (§6). The account has passwordless sudo via /etc/sudoers.d/nucleic-agent, allowing non-interactive agent turns to perform system-level work. No SSH key, no Remote Login (set NUCLEIC_PROVISION_ENABLE_SSH=1 when provisioning to opt into the legacy sshd surface for manual debugging).


6. File sharing — virtiofs at the original path

The session's repo/worktree is exposed as a custom-tagged VZSingleDirectoryShare. After the native guest agent becomes ready, Nucleic creates a clone of the host workdir path inside the guest and runs:

sudo mount_virtiofs nucleic-mount-0 /Users/<host-user>/.../<worktree>

Consequently the guest sees a real virtiofs mount at exactly the same absolute path as the host. There is no project symlink and no project copy under /Volumes/My Shared Files. Auxiliary mounts that opt out of host-path recreation may still use Apple's macOSGuestAutomountTag.

A share may also name an explicit custom mountpoint (MacVMSpec.Mount.guestPath), which likewise implies a custom-tagged single-directory device but mounts it there instead of at the host path. The base-image build is the reason it exists: Apple's automount root contains spaces, and the provisioning share's path is consumed by dozens of guest shell steps, so the build re-mounts it at a space-free path (§4.4).

Mounting is part of VM readiness, not best-effort setup. Nucleic safely migrates the precise legacy symlink it previously created, refuses to hide unrelated symlinks or non-empty guest directories, and fails boot if a required share cannot be mounted. The operation recognizes an already-mounted path, which makes it safe after suspend/resume.

Linux retains its separate multi-directory virtiofs device plus identical-path bind mount; see LINUX_VM.md.


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 through the native vsock agent, 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.

8.3 Lifecycle control — mac_vm_control

An agent's VM boots implicitly on the first mac_vm_exec/mac_vm_computer call and is stopped automatically (the idle timer) and torn down at session end — so an agent never has to manage it. But a long-running agent may want to reclaim host resources or reset a wedged guest mid-session, so a companion tool mac_vm_control (qualified mcp__nucleic__mac_vm_control) exposes the lifecycle the manager already owns. One op:

op Effect (MacVMManagerMacVMEngine)
status Read-only: reports running / suspended / stopped (disk clone present) / absent.
stop Power off, keep the disk clone (frees host RAM; the next VM command reboots it).
suspend Pause, save the runtime state to disk, power off — frees the guest's RAM and its slot (falls back to a RAM-pause if the save can't be made).
resume Restore a suspended guest from its saved state. Reuse (ensureRunning) also auto-resumes, so suspend is transparent to a later exec.
restart Stop → reboot in place (reclaims the RAM a long-running VM holds).
kill Stop and delete the disk clone (the next VM command boots a fresh one).

Returns {ok, state, message}. Unlike mac_vm_exec, it is not approval-gated: the ops only change the power state of the agent's own disposable VM (nothing on the host, nothing shared), so — like the computer-use tools — exposure is the opt-in boundary and there's no per-op prompt. It is advertised (and pre-allowed) whenever the session can use the macOS VM at all, i.e. allowMacVMExec or allowMacVMComputer. The Linux VM has an identical sibling, linux_vm_control (see LINUX_VM.md); both share MCPApprovalServer.VMControlCall/VMControlReply and ClaudeCodeBackend.performVMControl.

The free-when-idle duty. Because concurrent macOS guests are hard-capped (maxConcurrentVMs, §10.1), an agent that parks an idle VM blocks every other agent's boot — so the duty to free it is stated as MANDATORY, and MCPApprovalServer.macVMFreeWhenIdleNotice is appended to every macOS-VM tool description (mac_vm_exec, mac_vm_computer, mac_vm_computer_batch, mac_vm_clear_notifications, mac_vm_request_operator, mac_vm_control), not just the lifecycle tool: the agent has to see it wherever it touches the VM, not only if it happens to read mac_vm_control. It says: the moment you stop actively using the VM — including mid-turn, before reading files or running container work — suspend (need it again), stop (state is disposable), or kill (done with it). SessionController.sandboxBuildGuidance repeats it in the system prompt. The Linux tools deliberately carry the opposite wording, since Linux guests are uncapped (LINUX_VM.md §Lifecycle control).


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).
bundledAppPaths unset Host paths of user-provided .app bundles to bake into the base's /Applications (§9.1).
selectedPackages unset Ids of the common packages (MacVMPackage) to install into the base (§9.1).

9.1 Baking in apps & common packages

Every base unconditionally gets the verified Homebrew dev toolchain (provision-macos-guest.sh §4), the host's full Xcode, and Chrome for Testing (§7⅝) — the latter is what the chrome browser target resolves to for computer-use (knownBrowsers, §12). Chrome for Testing rather than stock Chrome because it is a pinned build with auto-update stripped out, so a disposable clone can't have Chrome updating (or nagging) under an agent mid-session, and a run is reproducible against a known version. Neither is an operator choice, so neither has a Settings control.

On top of that, two ways to customize what every session clone ships with, both surfaced under Settings → "macOS virtual machines":

  • Included apps (bundledAppPaths) — local .app bundles the operator supplies. The engine stages each into the provisioning share's user-apps/ and the provisioner copies them into the guest's /Applications (provision-macos-guest.sh §7½).
  • Common packages (selectedPackages) — curated, well-known optional tools the guest fetches and installs itself (MacVMPackage.catalog). Currently only Xcode, and it's grayed out because Xcode is no longer optional: Apple gates its download behind an Apple ID, so the base build copies the host's /Applications/Xcode[-beta].app in instead (see "Xcode and the Metal compiler" below). MacVMPackage.installShell(forIDs:) is the single source of truth for the guest-side install shell: the engine writes it into the share as install-packages.sh, which the provisioner runs (provision-macos-guest.sh §7¾). With no installable entry in the catalog today that composer returns nil, so the step is skipped.

Both also install after the fact without a full rebuild, via the same Settings controls: MacVMEngine.installApps… / installPackages… (MacVMEngine+InstallApps.swift / MacVMEngine+InstallPackages.swift) run over the native agent's vsock exec, either into a running session clone ("Install/Copy to running VMs") or into the golden base ("Import" — boot writable → install → power off clean, so future clones inherit it).

Carry-forward across a base replacement. Whatever apps/packages actually land in the base — staged at build time or injected after the fact — are recorded in its bundle.json (MacVMBaseStatus.installedAppPaths / installedPackageIDs). When a from-scratch rebuild replaces the base (a new OS install into a fresh bundle, which removes the old builtBaseDir), buildBaseImage reads the old base's recorded set before removing it and hands it to the provisioning pass, which stages the union of the live Settings selection and the carried set. So an app the user had in the old base survives the update automatically even if it's since been dropped from the "Included apps" list (e.g. its source bundle moved, so bundledAppPaths filtered it out). Best-effort: a carried app whose source .app no longer exists on the host is skipped, exactly as at first install.

9.2 Xcode and the Metal compiler

Every base bakes in the host's own full Xcode. MacVMEngine.preferredHostXcode() picks /Applications/Xcode.app, else Xcode-beta.app (identified by the xcodebuild inside it), and addingHostXcode appends it to the staged "Included apps" — unless the operator already staged an Xcode*.app of their own, who wins. It is deliberately not recorded in installedAppPaths: it's re-derived from the host on every build rather than an operator choice to carry forward.

This is what gives a guest a metal compiler, and there is no alternative:

  • The Command Line Tools ship no xcodebuild at all (/Library/Developer/CommandLineTools/usr/bin/ has none), so xcodebuild -downloadComponent MetalToolchain — Apple's only supported Metal installer since Xcode 16 — is unreachable in a CLT-only guest.
  • Even where the toolchain asset is installed, DEVELOPER_DIR=…/CommandLineTools xcrun -f metal answers "unable to find utility metal". The compiler resolves only when a full Xcode is the selected developer dir, so removing Xcode after the download would take metal with it.

provision-macos-guest.sh §7⅞ then finishes the job once per base rather than once per clone: xcode-select onto it, accept the license, -runFirstLaunch, -downloadComponent MetalToolchain (falling back to -downloadPlatform macOS), and finally a verification — resolve metal through xcrun and compile a probe kernel, because -downloadComponent can exit 0 with an unusable toolchain.

The license step matters as much as the download: a copied Xcode refuses every xcodebuild/xcrun call with "You have not agreed to the Xcode license agreements" until root records the agreement, and an exec-only agent has no TTY to answer that prompt in. §7⅞ tries sudo xcodebuild -license accept, then — if the license is still outstanding — drives the interactive sudo xcodebuild -license with its confirmations pre-fed on stdin (q, agree), bounded by run_timeout. Acceptance is checked functionally after each attempt, by running xcodebuild -version as the agent (exactly what a build hits), because -license accept can exit 0 having recorded nothing. It also runs DevToolsSecurity -enable and adds the agent to the _developer group, so the first debug/test run in a clone doesn't pop the "Developer Tools Access needs to take control" authentication panel. Xcode is a base-publication invariant, not a best-effort extra. Host-side staging errors propagate; the guest copies the bundle with ditto (preserving symlinks, modes, and signatures), and §7⅞ aborts the build if Xcode is missing/incomplete, license or first-launch setup fails, Metal cannot compile a probe, or the selected macOS SDK cannot typecheck a minimal import SwiftUI view. The same pass resolves the core Homebrew tools promised by the base. This keeps failures in the one-time base build instead of cloning them into every agent session. Budget roughly 6 GB of base disk for this (Xcode ≈ 3.6 GB, the Metal toolchain asset ≈ 2.5 GB).


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.
  • Done → suspend, then reap. When a chat reaches Done, AppStore.suspendVMIfCompleted first suspendIfIdles its guests — instant and reversible, freeing their RAM and their concurrency slot while a follow-up still thaws them transparently — and then arms the Done reap (MacVMManager.scheduleDoneReap). If the chat is still Done MacVMSettings.doneReapGraceSeconds (300 s) later, the reap stops each guest and deletes its clone, reclaiming the disk too. Without it a suspended clone lived forever: launch reconcile keeps every live session's clone, and the idle timer deliberately spares a disk-suspended guest so resume keeps working. The reap re-checks Done when it fires, not just when armed, so a follow-up turn that never touches a VM can't have its guest deleted underneath it; ensureRunning and a background-wait pin cancel it outright. Container parity: releaseSandboxIfCompleted removes a Done chat's container immediately — a container costs seconds to recreate, a guest clone costs a cold boot, hence the grace.
  • 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. It is also the restart half of the Done reap: AppStore.isLongDone drops any chat that has been Done longer than the grace from the VM keep-list, so quitting during the grace window doesn't grant its clone permanent amnesty (every later launch would otherwise re-keep it).
  • Anti-nap. While at least one VM is live or the base is busy (a build / provision / Recovery / app-injection pass — baseIsBusy), 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). The base-maintenance VM never enters the live registry, so the assertion must also cover baseIsBusy — otherwise an auto-rebuild that fires at launch (e.g. right after an update, while the relaunched app is still backgrounded) would be App-Napped and its main-queue provisioning VM + HID frozen, wedging the build at "Waiting for the guest desktop". syncBackgroundActivity() is therefore re-run whenever live or a base-busy flag changes.

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.

  • scrollscroll_direction + scroll_amount (lines), optionally aimed with x,y. A real wheel gesture on the surface and native-agent paths; only the last-resort SSH/cliclick fallback approximates it with arrow keys (see the note below).

    The amount is the gesture TOTAL and is delivered as a paced stream of ~2-line notches (MacVMEngine.scrollTicks, scrollLinesPerTick, scrollTickIntervalMillis) rather than one event, because an app with smooth scrolling starts a short animation per wheel event and coalesces whatever arrives mid-flight — so a single outsized event scrolled about one notch's worth and silently dropped the rest. That was the "scrolling isn't consistently picked up" failure.

    Two more things a scroll needs and a click doesn't. A wheel event carries no coordinates — the guest routes it to whatever view its own pointer is over — so the pointer is moved to the target (x,y when given, else the last known cursor) before the notches, or the scroll lands on whatever was last clicked. And because a swallowed scroll is invisible (an unchanged screenshot looks the same as "already at the bottom"), the surface path waits out a typical scroll animation (scrollAnimationSettleNanos), checks whether the framebuffer moved at all, re-sends the gesture once if it didn't, and otherwise says so in the summary instead of silently returning a stale-looking frame.

  • launch_app — bring up an app by name (open -a).

  • open_browser — a shortcut over launch_app: open a well-known browser by short name (chrome, firefox, safari, edge; default safari), resolving the right .app/binary per guest OS so the agent needn't know install paths, then waiting for its window before the screenshot. chrome means Chrome for Testing on macOS (§9.1) and Debian's chromium on the Linux base — Google publishes Chrome for Testing for linux64 (x86_64) only and the Linux guest is arm64, so there is no arm64 CfT build to bake; chromium is the same engine built natively. Both firefox and chrome ship preinstalled on the Linux base; safari and edge are macOS-only there. See MacVMEngine.knownBrowsers. An optional url is the page to start on — passed to the browser as its first document (open -a <app> <url> on macOS, an argv on Linux), so the agent lands on the page instead of driving the address bar, and a second call with the browser already up opens a new tab. MacVMEngine .normalizedBrowserURL fills in a missing scheme: http:// for the loopback host (a dev server on the guest — localhost:3000 does what it looks like) and https:// for anything else. Omit it to get the browser's own start page.

  • 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. Because a guest repaints asynchronously, an immediate grab would race the paint and hand back the pre-action frame — so each acting variant settles before capturing: the host-side surface path grabs a reference frame before injecting the input, then waits (a short floor, then polling the cheap framebuffer up to a cap) until the frame actually changes; the vsock capture paths (native agent / in-guest screencapture) use one fixed delay since re-grabbing to detect a change is too costly there. See MacVMEngine.settledSurfaceCapture / actionNeedsSettle. wait and step_delay_ms remain for cases needing extra settling beyond that (a long window animation).

12.1.1 Batching: mac_vm_computer_batch

When the agent has already seen its targets and knows the next few moves (close five windows by clicking each close button; click → type → return to fill a field), the single-action loop wastes a turn per action. mac_vm_computer_batch (mcp__nucleic__mac_vm_computer_batch) takes an ordered steps array — each element the exact same shape as a single mac_vm_computer action, ax_* verbs included — and runs them in order against the one booted VM in a single call. Options:

  • stop_on_error (default true) — halt at the first failing step; the rest are reported as not attempted (stoppedAtError).
  • screenshot_each_step (default false) — attach a screenshot after every step; otherwise only the final state's screenshot is returned, keeping the reply small (the common case — the agent already planned from the last screenshot and just needs to see where it ended up).
  • step_delay_ms — pause after each step so the UI can settle (e.g. a closing window's animation).

The reply is a text log of every step's outcome in order followed by the attached screenshot(s). It rides on the same computer-use opt-in and is likewise not per-action gated (the VM sandbox is the boundary). The Linux VM has the identical linux_vm_computer_batch (pixel actions only; no ax_*). Both are handled by ClaudeCodeBackend.performComputerBatch, which boots/reuses the VM once and loops performComputerAction over the steps. Prefer the single-action tool when the agent must see a result before choosing the next move.

12.2 How it works

The default path is host-side virtual IO — no in-guest software, no permissions, no SIP. Apple's Virtualization framework exposes the guest's display and keyboard/pointer as ordinary virtual hardware, which Nucleic drives entirely from the host:

  • A computer-use VM (MacVMSpec.computerUse, set from allowsMacVMComputer) boots on the main queue with a VZVirtualMachineView bound to it (MacVMComputerSurface, injected into the engine at app startup). macOS caps concurrent macOS guests at 2, so main-queue VMs are not a scale concern.
  • Screenshots — the view's framebuffer via cacheDisplay, normalized to a 1920×1200 JPEG (the guest display size), so image pixels map 1:1 to input coordinates.
  • Recognition capturescaptureForRecognition returns the same grab unnormalized and lossless (PNG at the guest's native 3840×2400), for the one consumer that is an OCR engine rather than a model looking at a picture: the MDM approval loop (§9.1). Normalizing halves every glyph and the JPEG rings its edges, which is what made Vision substitute characters there. Because this image is not 1:1 with input coordinates, clicks derived from it map through Vision's normalized box against MacVMEngine.surfaceInputSize — never through the capture's pixel size. To keep the grab native on a non-Retina host too, the off-screen HID window is sized 3840 / backingScale points.
  • Input — synthesized NSEvents (mouse down/up/moved/dragged, real scrollWheel, keyDown/keyUp with modifiers) delivered straight to the view, which forwards them to the guest's virtual HID. The keycodes come from MacVMKeyMap (host-side mirror of the agent's KeyMap).
  • Appslaunch_app has no HID analogue, so it still uses SSH open -a (which needs no TCC).

Because the guest grants nothing, this works on any installed base — no auto-login, no cliclick, no TCC, no SIP. Proven end-to-end by macvm-spike's NUCLEIC_MACVM_SPIKE_HOSTIO=1 mode. The VZVirtualMachineView is dispatched by MacVMEngine.performSurfaceComputerAction; ax_* semantic actions go to the optional in-guest agent (§12.5), and the legacy SSH+cliclick path below is a fallback for a VM booted without a surface.

Legacy fallback (SSH + screencapture/cliclick). 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 jpg <file> (-x = no shutter sound), then base64 -i <file> to bring the image back over SSH. JPEG, not PNG: it's returned on every action in a screenshot→act loop, so JPEG keeps the payload ~10× smaller (negligible loss for GUI targeting; the display is 1× so no rescaling is needed). The base64 is drained with a generous byte ceiling (a pathological oversize capture is discarded rather than truncated), then handed to the MCP image content block (mimeType: image/jpeg).
  • 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.3a Operator hand-off — mac_vm_request_operator

Some GUI steps a computer-use agent hits genuinely require a human: a CAPTCHA, a login / 2FA prompt, an interactive OS dialog. For these the agent can hand the wheel to the user via a second gated tool, mac_vm_request_operator (mcp__nucleic__mac_vm_request_operator), modeled on host_exec:

  • Gating. It rides on the computer-use opt-in — advertised whenever mac_vm_computer is (same run.allowMacVMComputer guard), because the interactive viewer is the host-side MacVMComputerSurface (§12.2), which only exists for a computer-use VM. Registered alongside the computer handler in ClaudeCodeBackend and pre-allowed so it reaches our handler directly.
  • Request. The agent must pass truthful instructions (exactly what the user should do) and a reason (why a human is required). ClaudeCodeBackend.handleMacVMOperatorCall enforces both (empty instructions rejected; reason validated by the shared HostExecPolicy.isAcceptableReason), boots/keeps the VM alive, then surfaces an ApprovalRequest (risk: .hostExec, so it's always surfaced and never auto-approved) and suspends on approvals.waitForDecision — exactly the host_exec seam.
  • In-app flow. ApprovalBar renders a MacVMOperatorRequestCard (instructions + the agent's unverified reason) with Decline / Open viewer & help. "Open viewer & help" hands off to MacVMOperatorAssistController, which brings the live, interactive guest window on-screen (AppStore.presentMacVMOperatorViewerMacVMComputerSurface.presentOperatorAssist, the user-facing sibling of the diagnostic setObserverVisible) and floats a walkthrough popup over it.
  • Resolution. The popup — not the "Open viewer" button — resolves the still-pending approval: I did it / Couldn't do it.allow(updatedInput: {completed, note}); closing the popup → .deny. The backend maps that to the tool result {completed, note} the agent reads, then the viewer returns off-screen. Because the handler blocks on one ApprovalRequest for the whole hand-off, the agent's call stays suspended until the user is genuinely done.

12.4 Base-image prerequisites

The default host-side path (§12.2) needs NONE of this — it captures/injects host-side, so any installed base works. The prerequisites below apply only to the legacy SSH+cliclick fallback and the optional in-guest AX agent (§12.5), both of which run inside the guest. All are 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 reality (honest) — only for the optional AX agent

This section no longer applies to default computer use. Host-side virtual IO (§12.2) needs no guest TCC at all. TCC matters only when you opt into the semantic AX agent (MacVMSettings.axAgentEnabled) — the in-guest NucleicVMAgent that adds Accessibility control by reading/driving the guest with Apple's frameworks inside the VM, so it needs the guest's TCC grants.

Provisioning does NOT grant these, and cannot. This section previously claimed the macOS 27 declarative build writes them automatically "no manual SIP toggle, no csrutil, no recoveryOS pass". That was never true in practice: the writes were gated on SIP being disabled, csrutil disable is reachable only from recoveryOS, and the recoveryOS entry point was unreachable code — so the branch never executed and axAgentReady was permanently false. The gate, the dead recoveryOS plumbing, and the SIP-conditional writes have all been removed (scripts/provision-macos-guest.sh Phase 7c/7d) rather than left as a conditional that reads like a supported option.

SIP stays enabled on the base. That is a hard invariant, not a default. The AX agent's grants therefore have exactly one supported route: an MDM PPPC payload (com.apple.TCC.configuration-profile-policy) granting kTCCServiceAccessibility, delivered alongside the other policy profiles once enrollment works. Apple permits that service; it can never silently grant kTCCServiceScreenCapture, which is why the AX agent's screen-capture ops stay unavailable — and why they don't matter for default computer use, which reads the framebuffer host-side (§12.2).

sipDisabled and axAgentReady survive in bundle.json's MacVMBaseStatus as recorded state, but both are now always false; nothing branches on them.


13. Limitations & future work

  1. Base build/provisioning is one-click on macOS 27. On a macOS 27 host + 27 guest, Build base image does the whole thing hands-off: install, declarative account (VZMacGuestProvisioningOptions, §4.4), toolchain provisioning, and — for the optional semantic AX agent — its TCC grants (MacVMEngine+Provision.swift / MacVMEngine+Provision27.swift), all with no manual SIP/recovery step. Default computer use runs host-side and needs no guest grants at all (§12.2). 🟢
  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 27 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 (partly done). Computer-use now rides vsock when the native agent is baked in (MACOS_VM_NATIVE_AGENT); mac_vm_exec command execution still uses SSH-over-NAT (the framework has no Mac-guest exec API). A future revision could add an exec op to the agent 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. scroll is approximated with arrow keys on the SSH fallback path (§12.1). The surface and native-agent paths send real, paced wheel events; only the last-resort cliclick fallback is coarse and content-dependent there (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).