# Nucleic — macOS guest VMs (Virtualization framework)

> The same VM service also boots **Linux** guests (full GUI + computer-use) — see
> [LINUX_VM.md](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](RUNTIME_ARCHITECTURE.md) §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](HOST_EXEC_CONCURRENCY.md) documents the hazard that
follows when two agents run in parallel:

1. **Toolchain thrash** — two `xcodebuild`s (or `swift build`s) 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](RUNTIME_ARCHITECTURE.md) §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](RUNTIME_ARCHITECTURE.md) §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` (`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`):

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 plane** — `VZMacGuestProvisioningOptions` 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 channel** — `VZVirtioSocketDeviceConfiguration` 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)`.
- **Readiness** — `awaitAgentReady` 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:

```sh
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](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](../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](HOST_EXEC_CONCURRENCY.md)): 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](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](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](RUNTIME_ARCHITECTURE.md) §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
  `suspendIfIdle`s 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`.
- `scroll` — `scroll_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 captures** — `captureForRecognition` 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 `NSEvent`s (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`).
- **Apps** — `launch_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.presentMacVMOperatorViewer` → `MacVMComputerSurface.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](MACOS_VM_NATIVE_AGENT.md)); `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 build`s 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](HOST_EXEC_CONCURRENCY.md)) 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](RUNTIME_ARCHITECTURE.md) — the object graph and the `ContainerEngine` /
  `ContainerManager` mechanism/policy split this subsystem mirrors, and the `ProcessHandle` contract.
- [HOST_EXEC_CONCURRENCY](HOST_EXEC_CONCURRENCY.md) — the shared-host hazard the per-session VM
  removes, and the `host_exec` gate `mac_vm_exec` deliberately doesn't need.
- [VSOCK_CONTROL_PLANE](VSOCK_CONTROL_PLANE.md) — 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/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.4–12.5).
