63 KiB
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:
- Toolchain thrash — two
xcodebuilds (orswift builds) on one machine oversubscribe CPU/IO and slow each other down. - Shared build directory (the sharp edge) — under nvrsion, sessions share one trunk
worktree, so two builds write the same derived-data /
.builddirectory 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 viaclonefile(2)— instant and space-efficient on APFS. On a non-APFS volumeclonefilereturns non-zero and it falls back to a fullcopyItem.- A fresh locally-administered MAC (
VZMACAddress.randomLocallyAdministered) is assigned and written toMACAddress, 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)
- Resolve a restore image (which chooses the guest macOS version — see §4.4): a configured local
.ipsw(MacVMSettings.restoreImagePath), else a configured remote.ipswURL (MacVMSettings.restoreImageURL), elseVZMacOSRestoreImage.fetchLatestSupported(~14 GB, with a determinate progress bar). Downloads are cached keyed by the IPSW filename (which encodes the version/build), not a fixedlatest.ipsw— so when Apple publishes a newer macOS, the fetch path picks it up instead of reusing a stale cached image. - Create a fresh install-time platform (new aux storage bound to the image's hardware model, new
machine identifier), a sparse
Disk.imgofMacVMSettings.baseDiskGiB(default 96 GiB), and install a clean macOS viaVZMacOSInstaller. - The build goes into a temp
base.building/bundle and is published atomically (moved tobase/) so an interrupted install never leaves a half-built base thatensureBaseBundlewould 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 themacvm-spikehelper (§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
VZVirtualMachineViewover 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 exactlyagent(MacVMSettings.defaultSSHUser). scripts/provision-macos-guest.sh— the in-guest provisioner, run asagent: 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 resolvexcodebuild/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 (downloadingRestoreImage → installing → provisioning →
ready) 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):
- Local
.ipsw(MacVMSettings.restoreImagePath) — pins an exact version you downloaded. - Remote
.ipswURL (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. 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/VZVirtualMachineAPI (macOS 12→27) is version-agnostic andVirtualization.frameworkis 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,
buildBaseImagepublishes the clean install and runs three ordered writable-base boots (MacVMEngine+Provision27.swift,+Provision.swift,+MDMPolicyPass.swift):
- Minimal control plane —
VZMacGuestProvisioningOptionscreates theagentaccount 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.- Mode A policy — a second boot reaches the direct
MacVMInstanceover 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.- 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-provisioncustom 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.jsoncarriesMacVMBaseStatus(accountProvisioned,provisioned,agentInstalled,sipDisabled,axAgentReady,mdmEnrolled,profilesInstalled);mdm-state.jsonstays 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 channel —
VZVirtioSocketDeviceConfigurationon every VM; the host reaches the agent viaVZVirtioSocketDevice.connect(toPort: 2035). TheAF_VSOCKdriver ships in the guest kernel, so it works from first boot with nothing to install driver-side. - The exec — the agent's streaming
execop (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 byremoteScriptexactly as before (export env,cdworkdir, run body under/bin/zsh -cso/etc/zshenv's toolchain PATH resolves) — then re-splits the base64 stdout/stderr frames back into newline-delimited lines, so it satisfies the sameProcessHandlecontract a host/container exec does (§2.1).run(…)is the one-shot variantmac_vm_execuses: drain both streams concurrently, return(exitCode, stdout, stderr). - Readiness —
awaitAgentReadypolls the agent's vsockpinguntil 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/listfor 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, soautomode 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 areason(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 asworkspace), 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 (MacVMManager → MacVMEngine) |
|---|---|
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.appbundles the operator supplies. The engine stages each into the provisioning share'suser-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].appin 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 asinstall-packages.sh, which the provisioner runs (provision-macos-guest.sh§7¾). With no installable entry in the catalog today that composer returnsnil, 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
xcodebuildat all (/Library/Developer/CommandLineTools/usr/bin/has none), soxcodebuild -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 metalanswers "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 takemetalwith 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'snucleic-<short>. - Busy ref-counting + idle timer.
ensureRunningmarks a turn in-flight and disarms the idle timer;finishedmarks 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.suspendVMIfCompletedfirstsuspendIfIdles 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 DoneMacVMSettings.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;ensureRunningand a background-wait pin cancel it outright. Container parity:releaseSandboxIfCompletedremoves 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 fromAppStoreat startup alongside the container reconcile. It is also the restart half of the Done reap:AppStore.isLongDonedrops 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),MacVMEngineholds aProcessInfo.beginActivityassertion (.userInitiatedAllowingIdleSystemSleep,.suddenTerminationDisabled,.automaticTerminationDisabled) so the app isn't App-Napped or auto-terminated out from under a running guest (mirrorsContainerEngine's assertion; the runtime is daemonless, so the app process bounds every VM's life). The base-maintenance VM never enters theliveregistry, so the assertion must also coverbaseIsBusy— 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 wheneverliveor 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 →
ssha command in the guest and capture stdout (themac_vm_execpath) → 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 intobase/).NUCLEIC_MACVM_IPSWsupplies 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_clickatx,y. -
mouse_movetox,y;left_click_dragfrom the current point tox,y. -
type— type a UTF-8 string;key— press a chord likecmd+sorcmd+shift+4. -
scroll—scroll_direction+scroll_amount(lines), optionally aimed withx,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,ywhen 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 overlaunch_app: open a well-known browser by short name (chrome,firefox,safari,edge; defaultsafari), resolving the right.app/binary per guest OS so the agent needn't know install paths, then waiting for its window before the screenshot.chromemeans 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. Bothfirefoxandchromeship preinstalled on the Linux base;safariandedgeare macOS-only there. SeeMacVMEngine.knownBrowsers. An optionalurlis 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.normalizedBrowserURLfills in a missing scheme:http://for the loopback host (a dev server on the guest —localhost:3000does what it looks like) andhttps://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(defaulttrue) — halt at the first failing step; the rest are reported as not attempted (stoppedAtError).screenshot_each_step(defaultfalse) — 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 fromallowsMacVMComputer) boots on the main queue with aVZVirtualMachineViewbound 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 captures —
captureForRecognitionreturns 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 againstMacVMEngine.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 sized3840 / backingScalepoints. - Input — synthesized
NSEvents (mouse down/up/moved/dragged, realscrollWheel, keyDown/keyUp with modifiers) delivered straight to the view, which forwards them to the guest's virtual HID. The keycodes come fromMacVMKeyMap(host-side mirror of the agent'sKeyMap). - Apps —
launch_apphas no HID analogue, so it still uses SSHopen -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), thenbase64 -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.allowsMacVMComputergates whether the tool is advertised for a session (it builds on the sameallowsMacVMExecpreconditions — service on, sandboxed, Apple silicon — plus the computer-use opt-in).ClaudeCodeBackend.handleMacVMComputerCallhandles 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_computeris (samerun.allowMacVMComputerguard), because the interactive viewer is the host-sideMacVMComputerSurface(§12.2), which only exists for a computer-use VM. Registered alongside the computer handler inClaudeCodeBackendand pre-allowed so it reaches our handler directly. - Request. The agent must pass truthful
instructions(exactly what the user should do) and areason(why a human is required).ClaudeCodeBackend.handleMacVMOperatorCallenforces both (empty instructions rejected;reasonvalidated by the sharedHostExecPolicy.isAcceptableReason), boots/keeps the VM alive, then surfaces anApprovalRequest(risk: .hostExec, so it's always surfaced and never auto-approved) and suspends onapprovals.waitForDecision— exactly the host_exec seam. - In-app flow.
ApprovalBarrenders aMacVMOperatorRequestCard(instructions + the agent's unverified reason) with Decline / Open viewer & help. "Open viewer & help" hands off toMacVMOperatorAssistController, which brings the live, interactive guest window on-screen (AppStore.presentMacVMOperatorViewer→MacVMComputerSurface.presentOperatorAssist, the user-facing sibling of the diagnosticsetObserverVisible) 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 oneApprovalRequestfor 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):
agentauto-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.- 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). - TCC pre-grant — Screen Recording (
kTCCServiceScreenCapture) for/usr/sbin/screencaptureand 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
- 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). 🟢 - ~14 GB restore-image download on first build unless a local
.ipswis supplied. One-time, but heavy. 🟡 - 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. 🟡
- CPU% resource sampling is unavailable. The framework exposes no CPU metric for Mac guests, so
MacVMResourceSample.cpuPercentis reported as0; memory total is the configured ceiling andusedis left at0(a future revision could SSHvm_statfor a real reading). 🟡 - SSH
sendSignaltargets the localsshclient. A killed one-shot signals the host-sidesshprocess; the remote command may orphan and keep running in the guest until VM teardown. Acceptable because the clone is disposable, but worth noting. 🟡 - 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_execcommand execution still uses SSH-over-NAT (the framework has no Mac-guest exec API). A future revision could add anexecop to the agent to drop the NAT/DHCP dependency and the local-ssh-client indirection entirely. 🟡 - 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
(
xcodebuildwrites 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-VMswift 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-pathto a VM-local dir, or routemac_vm_execthrough the existing host-command concurrency gate (HOST_EXEC_CONCURRENCY) for shared-trunk sessions. 🟡 scrollis 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/ContainerManagermechanism/policy split this subsystem mirrors, and theProcessHandlecontract. - HOST_EXEC_CONCURRENCY — the shared-host hazard the per-session VM
removes, and the
host_execgatemac_vm_execdeliberately 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 whycom.apple.vm.networkingis avoided. scripts/provision-macos-guest.sh— the in-guest provisioner; Phase 7 installs cliclick, enablesagentauto-login, and pre-grants the computer-use TCC entries (§12.4–12.5).