Nucleic-Session: 4FCF4F8B-A7C3-42F6-BE13-1979080F61C7 Co-authored-by: Nucleic <[email protected]>
37 KiB
Nucleic — macOS guest VMs (Virtualization framework)
Per-session, isolated macOS virtual machines built directly on Apple's Virtualization
framework — the Mac-native counterpart to the Linux container sandbox. Where the container gives an
agent an isolated Linux userland (see RUNTIME_ARCHITECTURE §3,
ContainerEngine / ContainerManager, and containers/nucleic-sandbox/Dockerfile), the macOS VM
gives it an isolated Mac: a real, disposable macOS instance where xcodebuild, xcrun simctl
simulator runs, and codesign can execute end-to-end without touching the one shared host.
Status: implemented (base build partly manual). The engine, manager, spec, settings, the
mac_vm_exec MCP tool, and the macvm-spike prover exist and are wired end to end; producing the
golden base image is a one-time, partly-hand-driven install (Setup Assistant has no unattended
path outside MDM — §4). Apple-silicon only.
1. Motivation
The sandbox container deliberately omits the Swift/Xcode toolchain
(containers/nucleic-sandbox/Dockerfile: "The Swift/Xcode toolchain is deliberately ABSENT").
Mac-only work therefore escapes the sandbox through the host_exec MCP tool, which runs on the one
shared macOS host. HOST_EXEC_CONCURRENCY documents the hazard that
follows when two agents run in parallel:
- 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 (restore/latest.ipsw) │
│ isSupported / unsupportedReason gate on `#if arch(arm64)`. │
└───────────────────────────┬──────────────────────────────────────────┘
│ ssh over NAT (spawns /usr/bin/ssh via ChildProcess)
▼
┌──── macOS guest (the clone) ────┐
│ agent@<nat-ip>, sshd, toolchain │
│ repo at /Volumes/My Shared Files/workspace (virtiofs)
└──────────────────────────────────┘
| Type | Isolation | Responsibility |
|---|---|---|
MacVMEngine |
actor |
Mechanism over Virtualization. Owns the base bundle, clones, restore image, host SSH key; boots each clone on its own serial VZVirtualMachine queue; execs over SSH. Daemonless. |
MacVMEngine+Base |
extension | MacVMBundle on-disk layout, base resolution (prebuilt-or-built), CoW cloning, the VZ config builder, and the one-time buildBaseImage() install flow. |
MacVMManager |
actor |
Policy: per-session naming, ref-counted idle timers, teardown, launch reconcile, and the concurrent-guest ceiling. Injected like ContainerManager. |
MacVMSpec |
value | MacVMSpec (name, cpus, memoryGiB, mounts, workdir, env, idleTimeout, sshUser), plus MacVMError, MacVMResourceSample, MacVMBaseProgress, MacVMEntry. |
MacVMInstance |
@unchecked Sendable box |
Wraps one VZVirtualMachine + its serial queue; bridges VZ's completion-handler API to async. Confines all non-Sendable VZ state. Compiled only under #if arch(arm64). |
2.1 Why an SSH exec still satisfies the ProcessHandle contract
ContainerEngine adapts a container exec's vsock stdio to ProcessHandle
(ContainerizedProcessHandle) so a backend drives a guest exec exactly like a host spawn
(RUNTIME_ARCHITECTURE §3). The macOS VM does the analogous thing without a
custom handle at all: it spawns the host /usr/bin/ssh client through the ordinary
ChildProcess (ProcessSpec, stdinMode: .pipe). The remote command's stdio is the local ssh
process's stdio, so the same robust line-buffered plumbing and signal delivery are reused, and the
backend decode loop is substrate-agnostic — it can't tell a guest exec from a host or container
one. This is the key that lets mac_vm_exec slot into the existing exec machinery with almost no new
surface.
2.2 Daemonless ⇒ ephemeral
Exactly like ContainerEngine: no VM survives the app process. MacVMEngine.live (the registry of
running guests, keyed by logical name) starts empty each launch. The per-session clone
persists on disk (its CoW Disk.img + aux storage), so an idle-stopped or app-restarted VM reboots
fast without re-installing macOS, but the running set is bounded by the app process. reconcile is
therefore pure on-disk GC — there is no daemon holding phantom VMs.
3. Storage layout
Storage root: ~/Library/Application Support/Nucleic/macvms/
(MacVMEngine.defaultStorageRoot). Subdirectories:
macvms/
├── base/ the golden base bundle (built here when not prebuilt)
│ ├── HardwareModel VZMacHardwareModel data
│ ├── MachineIdentifier VZMacMachineIdentifier data
│ ├── AuxiliaryStorage NVRAM (per-VM writable — each clone gets its own copy)
│ ├── Disk.img the system disk (CoW-cloned per session)
│ ├── MACAddress (clones only) the pinned NAT MAC, for lease lookup
│ └── bundle.json {installed, provisioned} metadata
├── instances/<name>/ per-session clone bundles (same layout; GC'd on reconcile)
├── restore/latest.ipsw cached macOS restore image (~14 GB) when fetched
└── ssh/id_ed25519[.pub] host SSH keypair; the .pub is baked into the base's agent account
MacVMBundle (MacVMEngine+Base) models this layout; isComplete is true when
HardwareModel, MachineIdentifier, AuxiliaryStorage, and Disk.img all exist. The base bundle
follows Apple's "Running macOS in a virtual machine on Apple silicon" sample; MACAddress and
bundle.json are Nucleic additions.
3.1 Copy-on-write cloning
cloneBase produces a per-session clone from the golden base:
- The small identity files (
HardwareModel,MachineIdentifier) are plain copies. AuxiliaryStorage(NVRAM) is copied per clone — it must be independently writable.Disk.img(multi-GB) is cloned copy-on-write 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: a configured local
.ipsw(MacVMSettings.restoreImagePath), elseVZMacOSRestoreImage.fetchLatestSupporteddownloaded torestore/latest.ipsw(~14 GB, with a determinate progress bar viaMacVMBaseProgress). - 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.
5. Guest interaction — SSH over NAT
The Virtualization framework exposes no host→guest exec API for Mac guests (unlike a Linux
guest's vsock exec via the containerization framework). Nucleic reaches the guest over SSH on the
NAT network:
- Networking —
VZNATNetworkDeviceAttachment(vmnet NAT). No bridged networking, so no restricted entitlement (§7). - IP discovery — each clone carries a pinned locally-administered MAC.
leaseIP(forMAC:)parses/var/db/dhcpd_leases(written by Apple's vmnet NAT DHCP server) to map that MAC → the guest's IPv4. MACs are compared octet-by-octet as integers, because the lease file drops leading zeros (2:34:…) whileVZMACAddress.stringkeeps them (02:34:…); the newest matching lease wins. - Readiness —
awaitGuestReadypolls for the lease, then probes sshd (ssh … true) until it answers, bounded to ~240 s (first boot: login window + launchd + sshd). A VM that boots but never answers is torn back down so a retry starts clean. - The exec —
sshHandlespawns/usr/bin/sshviaChildProcess(§2.1) with identity-only auth against the per-run key,BatchMode=yes(never prompt), no host-key persistence (StrictHostKeyChecking=no,UserKnownHostsFile=/dev/null— a fresh clone rotates its host key, so pinning would spuriously fail), and a short connect timeout. The remote command is composed byremoteScript: export the extra env,cdinto the workdir, then run the body.run(…)is the one-shot variant used bymac_vm_exec— it drains stdout/stderr concurrently (so a big build can't deadlock on a full pipe) and returns(exitCode, stdout, stderr).
5.1 The base's SSH surface
Provisioning bakes an agent account authorized with Nucleic's host public key (whose private
half never leaves macvms/ssh/id_ed25519). Toolchain PATH is exported via /etc/zshenv so
non-login SSH shells resolve the toolchain without a login shell. The session repo is shared in over
virtiofs (§6).
6. File sharing — virtiofs automount
The session's repo/worktree is surfaced to the guest as a virtiofs share. Unlike the Linux
container's identical-path bind mounts, a macOS guest auto-mounts virtiofs shares under
/Volumes/My Shared Files/<name>/ (VZVirtioFileSystemDeviceConfiguration with the
macOSGuestAutomountTag), so the guest path is derived from the share name, not the host path.
mac_vm_exec shares the run's worktree under the fixed name workspace, so the agent's default
working directory is /Volumes/My Shared Files/workspace. No in-guest mount action is required —
the automount tag does it.
7. Entitlements
The only entitlement required is com.apple.security.virtualization — already carried by the app
and by signing/spike.entitlements. It covers macOS guests exactly as it covers the existing Linux
VMs; no new entitlement, no provisioning profile (see signing/README.md and
signing/spike.entitlements).
NAT networking is deliberately chosen partly because it needs no extra entitlement. Bridged
networking would require the restricted com.apple.vm.networking, which — under an ad-hoc
signature — causes the kernel to kill the binary at launch (observed: immediate exit 137, no
output; documented inline in signing/spike.entitlements). Avoiding it keeps the ad-hoc-signed
macvm-spike and the Developer-ID app on the same, unrestricted footing.
8. Agent tool surface — mac_vm_exec
A new MCP tool mac_vm_exec (qualified mcp__nucleic__mac_vm_exec), modeled on host_exec and
registered in MCPApprovalServer (registerMacVMExec / MacVMExecCall / MacVMExecReply).
- Advertised only on opt-in. The tool appears in
tools/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 over SSH, and return{exit_code, stdout, stderr}. The first call boots a fresh clone (a couple of minutes); later calls in the session reuse the running VM. The idle timer is re-armed after the turn.
8.1 Gating
SessionController.allowsMacVMExec is true iff MacVMSettings.serviceEnabled && the session is
sandboxed && Apple silicon (MacVMManager.isSupported). It sets RunSpec.allowMacVMExec, which
ClaudeCodeBackend uses both to register the handler (only when a MacVMManager is wired) and to
pre-allow the qualified tool name. Sandboxed is required because a non-sandboxed session already has
the host toolchain via host_exec; the VM's value is isolation for the sandboxed case.
8.2 System-prompt guidance — prefer it over host_exec
When the tool is exposed, SessionController.sandboxBuildGuidance appends guidance telling the agent
it has an isolated Mac and to PREFER mac_vm_exec over host_exec for Mac-only work (Xcode
builds, xcrun simctl runs, codesign) — precisely because the VM is per-agent and can't collide
with another session the way the shared-host host_exec can. There is no shared-host concurrency
gate on mac_vm_exec (contrast HOST_EXEC_CONCURRENCY): each session's VM
is independent, so the collision the gate exists to prevent cannot occur.
9. Settings
MacVMSettings (UserDefaults, in Sources/NucleicCore/Project.swift):
| Key | Default | Meaning |
|---|---|---|
serviceEnabled |
off | Master switch. Off → mac_vm_exec is never exposed, no VM ever boots. Off by default because a macOS guest is heavy (several GB host RAM each + a large one-time base install). |
exposeByDefault |
off | When on, sandboxed sessions expose mac_vm_exec by default (only meaningful while the service is on). |
basePrebuiltPath |
unset | Path to a user-provided golden base bundle → clone directly, skip install/provision (§4.3). |
restoreImagePath |
unset | Path to a local .ipsw → skip the ~14 GB fetch when building the base. |
vmCPUs |
4 | Per-guest vCPU ceiling (clamped at boot). |
vmMemoryGiB |
8 | Per-guest memory ceiling — macOS wants more than a container to keep Xcode/simulator responsive. |
baseDiskGiB |
96 | System-disk size the base is installed into; clones inherit it CoW. |
maxConcurrentVMs |
2 | Concurrent running-guest ceiling — macOS caps simultaneous macOS guests (§10.1). |
sshUser |
agent |
The in-guest account Nucleic SSHes in as (baked into the base by provisioning). |
10. Lifecycle
Lifecycle mirrors the container manager (RUNTIME_ARCHITECTURE §3,
ContainerManager):
- Per-session naming.
MacVMManager.vmName(for:)→nucleic-mac-<sessionID.short>+ContainerManager.channelSuffix(reusing the container convention so several installed Nucleic builds don't collide on the shared on-disk clone store). Lowercased, matching the disk-GC convention. It's the sibling of a container'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. - 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. - Anti-nap. While at least one VM is live,
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).
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— approximated via arrow keys (see the note below); there's no pixel-precise scroll primitive in the input path, so a scroll maps to repeated arrow-key presses in the intended direction. Treat it as coarse.launch_app— bring up an app by name (open -a).cursor_position— read back where the pointer is.wait— sleep briefly to let the UI settle between actions.
Every acting variant follows the act with a fresh screenshot, so the model always closes the loop on what it actually did.
12.2 How it works
Each screen/input command is dispatched into the console user's Aqua GUI session — not just any SSH shell — because screencapture and input synthesis only work against a live window server. The engine composes, over the same SSH channel as §5:
launchctl asuser $(id -u) <ABSOLUTE-PATH> …
- Screenshots —
/usr/sbin/screencapture -x -t png <file>(-x= no shutter sound), thenbase64 -i <file>to bring the PNG back over SSH. The base64 payload is uncapped (a full-screen PNG is large, and the model needs the whole frame), decoded host-side into the MCP image block. - Input —
/usr/local/bin/cliclick(called at exactly that path — see §12.4) with its verbs:c:/dc:/rc:(click/double/right-click),m:(move),dd:/du:(drag down/up),t:(type),kp:/kd:/ku:(key press/down/up),p(print cursor position). - Apps —
/usr/bin/open -a <name>.
Because the VZ display is 1920×1200 @ 80 PPI — a 1× (non-Retina) display — screenshot pixels
map 1:1 to click coordinates. There is no backing-scale factor to divide by: an object the
model sees at pixel (x, y) is clicked at cliclick c:x,y with no scaling. This is a deliberate
config choice; a 2× display would force the loop to halve every coordinate.
12.3 Gating & approval
Unlike mac_vm_exec (§8), computer use is not per-action approval-gated — a tight
screenshot→act→screenshot loop with a prompt per click would be unusable, and the VM is a disposable
sandbox, so the blast radius is contained to the guest. Instead it's opt-in at the settings level:
MacVMSettings.computerUseByDefault— the master opt-in (surfaced as Settings ▸ Virtual Machines ▸ "Enable computer use").SessionController.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.4 Base-image prerequisites
Computer use needs three things baked into the golden base, all set up by
scripts/provision-macos-guest.sh Phase 7 (§4.2):
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 / SIP reality (honest)
Pre-granting TCC unattended is the sharp edge, and there's no clean path:
- The grants live in the system db
/Library/Application Support/com.apple.TCC/TCC.db, which SIP protects from writes. tccutilcan only reset, never add, a grant. A PPPC/MDM profile can pre-approve Accessibility but can never silently grant Screen Recording — Apple forces a manual user click forkTCCServiceScreenCapture.- So the only unattended path is SIP disabled in the guest, then a direct
sqlite3INSERT into the system TCC.db (named-column,client_type=1path-scoped,auth_value=2allowed), followed bykillall tccd. Provisioning does exactly this — but detects SIP state and skips gracefully when SIP is on rather than failing. csrutil disableis a one-time MANUAL step: it must run from recoveryOS, which has no SSH and no scripting hook, so a human boots the base VM into recoveryOS once, runscsrutil disable, and re-runs the provisioner. There is no way around this on stock macOS.
If screenshots come back black or clicks no-op on a live base, check (in the guest) that SIP is
disabled (csrutil status), the TCC rows exist and are allowed
(sudo sqlite3 "…/TCC.db" "SELECT service,client,auth_value FROM access;"), and agent is
auto-logged-in (an Aqua session, not the login window).
12.6 ⚠ macOS 26 (Tahoe) framebuffer bug — use a Sequoia base for app debugging
There is a confirmed open Virtualization bug on macOS 26 (Tahoe) guests (trycua/cua #912): guest app windows do not render into the framebuffer. Screenshots show the desktop and Dock, but application windows come back blank — which specifically defeats the app-debugging use case (you can launch an app but can't see or drive its UI). This is a guest-side rendering defect, not a Nucleic issue, and there is no known workaround on Tahoe.
Recommendation: for computer-use / app-debugging work, build the base from a macOS 15 (Sequoia)
restore image until the bug is fixed. Supply a Sequoia .ipsw via MacVMSettings.restoreImagePath
(§9) so buildBaseImage installs Sequoia instead of fetching the latest (Tahoe) image. Headless
mac_vm_exec work is unaffected by the bug; only the on-screen computer-use path needs Sequoia.
13. Limitations & future work
- Base build/provisioning is partly manual. Setup Assistant has no unattended path outside MDM/DEP, so producing the golden base needs a human once (§4.2). The GUI window helper for the Setup-Assistant phase is the rough edge. 🟡
- ~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 26 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. SSH-over-NAT is used because the framework has no Mac-guest exec
API today; a future revision could move guest interaction onto vsock (as the container control
plane did — see VSOCK_CONTROL_PLANE) to drop the NAT/DHCP dependency and
the local-
ssh-client indirection entirely. 🟡 - 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. 🟡 - Computer use needs a one-time manual
csrutil disable. The unattended TCC pre-grant for Screen Recording + Accessibility (§12.4–12.5) requires SIP off, andcsrutil disablecan only run from recoveryOS, which has no SSH — so it's a human step, once, per base. Provisioning detects SIP and skips the grant gracefully when it's on, but computer use won't work until it's done. 🟡 - macOS 26 (Tahoe) guest app windows don't render into the framebuffer — a confirmed open
Virtualization bug (trycua/cua #912): screenshots show
the desktop/Dock but blank app windows, defeating app debugging via computer use specifically.
Workaround: build the base from a macOS 15 (Sequoia)
.ipsw(restoreImagePath, §9, §12.6) until it's fixed. Headlessmac_vm_execis unaffected. 🔴 scrollis approximated with arrow keys (§12.1). The computer-use input path has no pixel-precise scroll primitive, so scrolling is coarse and content-dependent (works for lists and text views, less so for canvases). 🟡
14. Cross-references
- RUNTIME_ARCHITECTURE — the object graph and the
ContainerEngine/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).