# 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 (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](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 /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; `exec`s 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](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 SDK or deployment-target bump is needed** 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 stays at `.macOS(26)`.
>
> **Declarative first boot + host-side (HID) bootstrap — network-free.** On a macOS 27 host
> installing a macOS 27+ guest, `buildBaseImage` publishes the clean install, then runs ONE
> provisioning boot that does everything host-side + VirtioFS, **with no network at any point**
> (`MacVMEngine+Provision27.swift` + `MacVMEngine+Provision.swift`):
>
> 1. **Account** — the boot carries **`VZMacGuestProvisioningOptions`** (gated `#available(macOS 27,
>    *)`) to create the `agent` account with **auto-login**. **Remote Login is deliberately NOT
>    enabled** — the control plane is vsock. The password is `MacVMSettings.agentPassword`, else
>    generated and persisted at `macvms/credentials/agent-password`.
> 2. **HID bootstrap** — because a brand-new guest has no vsock listener yet (the agent is what we're
>    installing), the host binds a `VZVirtualMachineView` **surface** (framebuffer + synthesized HID —
>    needs no guest software) and, after auto-login, types one short command to run a staged bootstrap
>    script from a **read-write virtiofs share**. This is the only irreducible bootstrap step; vsock
>    can't help until the agent exists.
> 3. **Provision over VirtioFS** — the bootstrap primes passwordless sudo and runs
>    `scripts/provision-macos-guest.sh` (dev toolchain + the embedded `NucleicVMAgent.app` + its
>    LaunchAgent + the AX TCC grants for the optional semantic agent, written automatically), writes a
>    `STATUS` readback to the share, and
>    powers off. The host **polls the `STATUS` file** (also its proof the desktop came up), then awaits
>    shutdown — no host↔guest network, ever.
>
> The native agent is now installed on **every** base (it is the sole exec + computer-use channel), so
> `mac_vm_exec` and computer use both ride vsock. Best-effort: any failure publishes the installed base
> anyway. **Reentrant**: on an already-installed base it re-runs only provisioning (a normal boot, no
> declarative options), so clicking Build again is safe and idempotent. `bundle.json` carries
> `MacVMBaseStatus` (`accountProvisioned`, `provisioned`, `agentInstalled`, `sipDisabled`,
> `axAgentReady`).

---

## 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).
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 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.

### 6.1 Host-path re-creation

Because the guest sees the repo at a *different* path than the host, any build artifact that captured
an absolute path — SwiftPM's `.build`, Xcode DerivedData, `compile_commands.json`, `.o` debug maps,
generated project references, package caches — points at a directory that doesn't exist in the guest,
and the build fails. To fix this the engine re-creates each share's **original host path** inside the
guest once the agent is ready (`MacVMEngine+HostPaths.swift`, driven by
`MacVMSpec.Mount.recreateHostPath`, default on). The mechanism is per-OS:

* **macOS** — the guest **symlinks** the original host path → `/Volumes/My Shared Files/<name>`
  (macOS only automounts virtiofs at that fixed location; arbitrary mount points aren't supported).
  The provisioned `agent` account's passwordless sudo creates the parent chain + the link, and only a
  path that's absent or already a symlink is touched — a real directory is never clobbered.
* **Linux** — the guest **bind-mounts** the share at the identical path, so it's a real directory
  backed by the same virtiofs inode (matching the container's identical-path model), falling back to a
  symlink if the bind can't be made (see [LINUX_VM.md](LINUX_VM.md)).

The step is idempotent (safe on every boot / clone restart) and best-effort — a share that can't be
reconstructed logs and is skipped; the build just falls back to the guest share path.

---

## 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 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](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/freeze in place (`VZVirtualMachine.pause` — keeps RAM, halts CPU). |
| `resume` | Thaw a suspended guest (`VZVirtualMachine.resume`). 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`.

---

## 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

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 tools the *guest fetches and installs
  itself* (`MacVMPackage.catalog`). Currently **Google Chrome** (latest stable, a public universal DMG,
  fully unattended) and **Xcode** (a grayed-out placeholder — a macOS 27 guest needs an Apple-ID-gated
  Xcode *beta* that can't be fetched unattended, so it's scaffolding for a future automatable path).
  `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¾).

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).

---

## 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.
- **Teardown.** `teardown(session)` stops the VM and deletes its clone bundle
  (`instances/<name>/`); `restart(session)` stops-then-reboots in place to reclaim RAM (skipped while
  a turn is in flight).
- **Launch reconcile.** On launch `reconcile(activeSessions:)` → `reconcileDisk(keepNames:)` GCs
  stale clone bundles, keeping only the active sessions'. Daemonless, so this is pure on-disk GC (no
  running VM survives the process). Wired from `AppStore` at startup alongside the container
  reconcile.
- **Anti-nap.** While at least one VM is live, `MacVMEngine` holds a `ProcessInfo.beginActivity`
  assertion (`.userInitiatedAllowingIdleSystemSleep`, `.suddenTerminationDisabled`,
  `.automaticTerminationDisabled`) so the app isn't App-Napped or auto-terminated out from under a
  running guest (mirrors `ContainerEngine`'s assertion; the runtime is daemonless, so the app process
  bounds every VM's life).

### 10.1 The concurrent-guest ceiling

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

---

## 11. Spike

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

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

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

---

## 12. Computer use (screen + input)

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

### 12.1 Actions

One tool, an `action` discriminator:

- `screenshot` — capture the current screen (returned as an image block).
- `left_click` / `right_click` / `double_click` at `x,y`.
- `mouse_move` to `x,y`; `left_click_drag` from the current point to `x,y`.
- `type` — type a UTF-8 string; `key` — press a chord like `cmd+s` or `cmd+shift+4`.
- `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`).
- `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. On the Linux
  base only `firefox` ships preinstalled. See `MacVMEngine.knownBrowsers`.
- `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

**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.
- **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.

On the supported **macOS 27** flow this is fully automated: the one-click declarative base build
(`MacVMEngine+Provision27.swift`) writes the AX permission grants **automatically** during
provisioning — **no manual SIP toggle, no `csrutil`, no recoveryOS pass**. The grants live in the
**system** db `/Library/Application Support/com.apple.TCC/TCC.db`; provisioning writes them
(named-column, `auth_value=2` allowed) and bounces `tccd`, then records `axAgentReady` in
`bundle.json`'s `MacVMBaseStatus` (`accountProvisioned`, `provisioned`, `agentInstalled`,
`sipDisabled`, `axAgentReady`).

If AX ops fail on a live base, check (in the guest) that the TCC rows exist and are allowed
(`sudo sqlite3 "…/TCC.db" "SELECT service,client,auth_value FROM access;"`) and that `agent` is
auto-logged-in (an Aqua session, not the login window).

---

## 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). With the native
   agent present, `scroll` is a real CGEvent wheel event; without it, scrolling is coarse and
   content-dependent (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).
