macOS VM provisioning on Apple Silicon, from IPSW to SSH-ready in one command.
This spec documents the working implementation as of Feb 2026. It covers architecture, every gotcha encountered, and decisions made. Use it as a blueprint if you're building something similar.
ClawOS provisions a fully-configured macOS VM from a single command:
sudo createClawOS --config claw.ymlThe output is a .clawbundle directory containing a bootable macOS VM with:
- An admin user account with SSH key authentication
- Homebrew, GUI apps, npm tools — whatever you configure
- VNC (Screen Sharing) enabled
- A SwiftUI control panel for managing the VM
No pre-baked images. No third-party registries. Fresh IPSW restore every time.
Disk injection (Stage 4) requires root for:
hdiutil attach/diskutiloperations on the VM disk imagechownto set file ownership (UID 0 for LaunchDaemons, UID 501 for user files)- Writing to dslocal (
/private/var/db/dslocal/nodes/Default/users/) - Setting permissions on SSH host keys (0600)
After injection completes, ClawOS restores the bundle ownership back to $SUDO_USER so the GUI app can access it without root.
| Layer | Choice | Why |
|---|---|---|
| VM Runtime | Virtualization.framework | Apple-native, no deps, supports macOS guests on Apple Silicon |
| Language | Swift | VZ.framework is Swift/ObjC native |
| CLI parsing | swift-argument-parser | --help generation, type-safe flags |
| YAML parsing | Yams | claw.yml is nested with arrays — hand-rolling is fragile |
| SSH | /usr/bin/ssh shell-out |
Zero deps, inherits host SSH agent, no libssh2 needed |
| UI | SwiftUI | Control panel, live metrics, VM display via VZVirtualMachineView |
Starting point: Apple's official sample project: "Running macOS in a Virtual Machine on Apple Silicon". We kept the Xcode project and restructured it.
vm:
cpu: 4 # vCPU count (clamped to host physical cores)
ram: 8GB # Parsed: "8GB" → 8589934592 bytes
disk: 100GB # Sparse disk, only uses space as needed
macos: latest # "latest" uses VZMacOSRestoreImage.fetchLatestSupported()
display: # Specific versions query ipsw.me API
width: 1920
height: 1080
dpi: 144
access:
username: admin
password: admin
ssh_key: ~/.ssh/id_ed25519.pub # Public key → authorized_keys
vnc_password: clawos # Max 8 chars (macOS limitation)
tools:
homebrew: true
packages: [git, node, python3, wget] # brew install
casks: [visual-studio-code, google-chrome] # brew install --cask
npm_global: # npm install -g
- "@anthropic-ai/claude-code"
- "@google/generative-ai"
- "@openai/codex"
- "openclaw@latest"
- "pnpm"
custom: # Shell commands, run in order
- "git clone https://github.com/openclaw/openclaw.git ~/openclaw"
- "cd ~/openclaw && pnpm install --frozen-lockfile"
- "cd ~/openclaw && pnpm build"Size parsing: "8GB" → uppercase → match suffix → multiply. Supports TB/GB/MB/KB.
PHASE 1 — OFFLINE (no VM running)
Stage 1: RESOLVE
Find IPSW URL for the requested macOS version.
Stage 2: DOWNLOAD
Download IPSW with SHA256 verification. Cached in ~/ClawOS/Cache/.
Stage 3: RESTORE
Create disk image, run VZMacOSInstaller.
Stage 4: INJECT
Mount disk offline, write user account, SSH, VNC, Setup Assistant bypass.
PHASE 2 — ONLINE (VM running)
Stage 5: BOOT
Start VM, discover IP, wait for SSH.
Stage 6: PROVISION
SSH in, run generated provisioning script.
Stage 7: VERIFY
Check every tool is installed, write manifest.json.
Stage 8: DONE
Print connection info.
Two paths:
macos: latest — Uses VZMacOSRestoreImage.fetchLatestSupported(). Apple's own API. Returns a URL, build number, and supported hardware model. This is the simplest and most reliable path.
macos: "15.2" — Queries the ipsw.me REST API:
GET https://api.ipsw.me/v4/device/VirtualMac2,1
Parses the response to find the firmware matching the requested version. Returns URL, SHA256, size, and build number.
Important: The ipsw.me API uses VirtualMac2,1 as the device identifier for Apple Silicon VMs.
- Standard
URLSessiondownload task - SHA256 verification via
CryptoKitafter download - IPSWs are cached at
~/ClawOS/Cache/restore-<version>-<build>.ipsw - Re-runs skip download if cache hit exists
- Progress callback for CLI/UI display
macOS 16+ (Tahoe): Uses Apple Sparse Image Format (ASIF). Created via:
try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false)
// VZ creates the ASIF automatically during restoremacOS 15 and earlier: Uses RAW disk images. Created with dd or truncated file:
FileManager.default.createFile(atPath: diskURL.path, contents: nil)
let handle = try FileHandle(forWritingTo: diskURL)
try handle.truncate(atOffset: diskSizeBytes)- Load the IPSW →
VZMacOSRestoreImage - Extract
mostFeaturefulSupportedConfiguration→ hardware model - Create
VZMacPlatformConfigurationwith hardware model + machine identifier + aux storage - Build
VZVirtualMachineConfiguration(CPU, RAM, display, network, storage) - Run
VZMacOSInstaller(virtualMachine:restoringFrom:)with progress callback - Save hardware model + machine identifier to
config.jsonin bundle (needed for boot)
After VZMacOSInstaller completes, you must wait ~3 seconds before touching the disk image. VZ holds a lock on the file. Attempting to mount immediately will fail.
try await Task.sleep(nanoseconds: 3_000_000_000)This is where 80% of the bugs lived. Every subsection below contains a lesson learned the hard way.
ASIF (macOS 16+):
diskutil image attach disk.img -nomount -plistReturns a plist with the physical device path (e.g., /dev/disk44).
RAW (macOS 15-):
hdiutil attach disk.img -owners on -nomount -plistReturns plist with system-entities → dev-entry.
The VM disk contains an APFS container with System and Data volumes. You need the Data volume (that's where /private/var/db/, /Users/, /Library/ live via firmlinks).
THE DANGER: If you scan /Volumes looking for a volume with /private/var/db, you might match your HOST's Data volume and write to it. This is catastrophic.
SOLUTION — Volume Snapshot Approach:
1. Snapshot /Volumes BEFORE mount → Set A
2. Run `diskutil mountDisk /dev/diskNN`
3. Wait 3 seconds (APFS synthesized volumes need time)
4. Snapshot /Volumes AFTER mount → Set B
5. New volumes = B - A (these are OURS, not the host's)
6. Among new volumes, find the one with /private/var/db → Data volume
APFS SYNTHESIZED DISKS: When you attach an APFS disk image, macOS creates a "synthesized" disk. Physical disk is /dev/disk44, but the APFS container creates /dev/disk45 with the actual volumes. diskutil list disk44 only shows the physical partition map — it won't show the APFS volumes. You must use diskutil mountDisk which handles APFS container synthesis automatically.
FALLBACK: If no new volumes appear after mountDisk, use diskutil apfs list -plist to find APFS volumes on the synthesized disk and mount them individually.
CRITICAL: After mounting, you MUST enable ownership on the volume or chown silently does nothing:
diskutil enableOwnership /dev/diskNN # on the device
diskutil enableOwnership "/Volumes/Data Vol" # on the mount pointWithout this, all files appear as owned by the current user regardless of what chown sets.
Setup Assistant is aggressive. A single marker file is not enough on macOS 26. We use 6 layers:
-
.AppleSetupDone— Primary marker. Must be root-owned, mode0400:<mount>/private/var/db/.AppleSetupDone -
.SetupRegComplete— Legacy marker:<mount>/Library/Receipts/.SetupRegComplete -
.skipbuddy— User template marker:<mount>/Library/User Template/English.lproj/.skipbuddy <mount>/Library/User Template/Non_localized.lproj/.skipbuddy -
Per-user
com.apple.SetupAssistant.plist— AllDidSee*keys set totrue:<mount>/Users/<username>/Library/Preferences/com.apple.SetupAssistant.plistKeys:
DidSeeCloudSetup,DidSeeSiriSetup,DidSeePrivacy,DidSeeAppearanceSetup,DidSeeAccessibility,DidSeeScreenTime,DidSeeTouchIDSetup,DidSeeTrueTonePrivacy,DidSeeActivationLock,LastSeenCloudProductVersion: "99.99" -
System-wide MDM-style managed plist — MUST be in
/Library/Preferences/(NOT per-user):<mount>/Library/Preferences/com.apple.SetupAssistant.managed.plistKeys:
SkipAppearance,SkipCloudSetup,SkipiCloudStorageSetup,SkipPrivacySetup,SkipSiriSetup,SkipTrueTone,SkipScreenTime,SkipTouchIDSetupBUG WE HIT: Initially wrote this to the per-user preferences directory. Setup Assistant checks the SYSTEM
/Library/Preferences/location, not per-user. Must be root:wheel owned. -
System-wide
com.apple.SetupAssistant.plist— SameDidSee*keys at system level:<mount>/Library/Preferences/com.apple.SetupAssistant.plist -
Firstboot script kill loop — Even with all offline markers, Setup Assistant can still appear on macOS 26. The firstboot LaunchDaemon kills it repeatedly for 15 seconds:
for i in $(seq 1 15); do killall "Setup Assistant" 2>/dev/null || true sleep 1 done &
Plus writes all the
defaults writekeys from inside the running VM.
Write a binary plist to:
<mount>/private/var/db/dslocal/nodes/Default/users/<username>.plist
The plist structure (all values are arrays — dslocal convention):
name: [username]
realname: [RealName]
uid: ["501"]
gid: ["20"]
home: [/Users/username]
shell: [/bin/zsh]
generateduid: [UUID]
passwd: ["********"]
ShadowHashData: [<binary plist with SALTED-SHA512-PBKDF2>]
authentication_authority: [";ShadowHash;HASHLIST:<SALTED-SHA512-PBKDF2>"]
_writers_passwd: [username]
_writers_hint: [username]
...
ShadowHashData is a nested binary plist:
{
"SALTED-SHA512-PBKDF2": {
"entropy": <derived key, 128 bytes>,
"iterations": 39527,
"salt": <random salt, 32 bytes>
}
}
Generated using CCKeyDerivationPBKDF with kCCPRFHmacAlgSHA512.
Admin group: Update <mount>/private/var/db/dslocal/nodes/Default/groups/admin.plist:
- Add username to
GroupMembershiparray - Add user GUID to
groupmembersarray - Add username to
usersarray
DO NOT create com.apple.access_ssh group. Its absence means ALL users can SSH.
Creating it RESTRICTS SSH to only listed users.
Ownership: dslocal files must be root:wheel (0:0), mode 0600. User home must be 501:20.
-
launchd disabled.plist override — Write
com.openssh.sshd: falseto:<mount>/private/var/db/com.apple.xpc.launchd/disabled.plist(
falsemeans "not disabled" = enabled) -
Backup LaunchDaemon — Custom
com.clawos.sshd.plistthat runs/usr/sbin/sshd -D:<mount>/Library/LaunchDaemons/com.clawos.sshd.plistThis is a safety net if the system sshd doesn't start.
-
Firstboot script — Runs
systemsetup -setremotelogin onfrom inside the VM. This is the definitive online method.
SSH host keys: Pre-generate ssh_host_{rsa,ecdsa,ed25519}_key offline via ssh-keygen.
Private keys must be 0600, public keys 0644, owned by root:wheel.
sshd_config: Write to <mount>/private/etc/ssh/sshd_config:
Port 22
PermitRootLogin yes
PubkeyAuthentication yes
PasswordAuthentication yes # Hardened to "no" in provisioning Stage 6
AuthorizedKeysFile .ssh/authorized_keys
UsePAM yes
To skip the login screen entirely, write the XOR-encoded password to:
<mount>/private/etc/kcpassword
XOR key: [0x7D, 0x89, 0x52, 0x23, 0xD2, 0xBC, 0xDD, 0xEA, 0xA3, 0xB9, 0x1F]
Algorithm:
- UTF-8 encode password + null terminator
- Pad to multiple of 12 bytes with zeros
- XOR each byte with the repeating key
Also write autoLoginUser to:
<mount>/Library/Preferences/com.apple.loginwindow.plist
A shell script + LaunchDaemon that runs once at first boot as root:
<mount>/Library/Scripts/clawos-firstboot.sh
<mount>/Library/LaunchDaemons/com.clawos.firstboot.plist
The firstboot script handles everything that can't be done offline:
- Step 0: Kill Setup Assistant aggressively (killall loop + defaults write)
- Step 1: Create user via
dsclif offline dslocal injection didn't work - Step 2: Add to admin group via
dseditgroup - Step 3: Enable SSH via
systemsetup -setremotelogin on - Step 4: Plant SSH authorized_keys
- Step 5: Enable VNC / Screen Sharing
- Step 6: Configure passwordless sudo (
/etc/sudoers.d/clawos-<username>) - Step 7: Write
PROVISIONING_READYmarker, then self-destruct
CRITICAL: The PROVISIONING_READY marker MUST be written BEFORE launchctl bootout.
bootout kills the running script. Anything after it never executes.
echo "PROVISIONING_READY" # ← BEFORE self-destruct
rm -f /Library/LaunchDaemons/com.clawos.firstboot.plist
launchctl bootout system/com.clawos.firstboot 2>/dev/null || trueLaunchDaemon ownership: MUST be root:wheel, mode 0644. launchd refuses to load
LaunchDaemons not owned by root.
LESSON: diskutil image has NO detach subcommand. Only attach exists.
You must use hdiutil detach for both ASIF and RAW formats:
hdiutil detach /dev/diskNN -forceFallback: diskutil unmountDisk force /dev/diskNN
FileManager.setAttributes with .ownerAccountName FAILS on foreign APFS volumes.
Always use /usr/sbin/chown with numeric UID:GID:
/usr/sbin/chown 0:0 /path # root:wheel
/usr/sbin/chown 501:20 /path # first user:staffLoad hardware model + machine identifier from config.json in the bundle.
Reconstruct VZVirtualMachineConfiguration, create VZVirtualMachine, call start().
The VM gets an IP via DHCP from the host's vmnet framework. Two discovery methods:
-
DHCP lease file: Parse
/var/db/dhcpd_leases. Match by MAC address (which we set during VM creation). This is fast but sometimes stale. -
ARP scan fallback: Ping the broadcast address
192.168.64.255, then parse the ARP table (arp -a -n). Check SSH port 22 on each candidate. First responder wins.
Poll every 5 seconds. The firstboot script needs time to:
- Boot macOS
- Run the firstboot LaunchDaemon
- Set up sudo
- Kill Setup Assistant
- Start sshd
- Write
PROVISIONING_READY
We wait up to 60 seconds for the PROVISIONING_READY marker in /var/log/clawos-firstboot.log
before starting provisioning. This ensures sudo is configured (needed for brew install).
DO NOT pipe scripts via ssh user@host bash -s < script.sh or echo script | ssh ... bash -s.
Child processes (git, brew, curl) inherit stdin and consume remaining script text. Homebrew's install script reads from stdin. Git operations read from stdin. This causes random lines of your script to be eaten, producing bizarre failures where bash tries to execute fragments of your own script as commands.
SOLUTION: Write the script to a remote temp file, then execute the file:
// Encode script as base64, write to remote temp file
let b64 = Data(script.utf8).base64EncodedString()
ssh.execute("echo '\(b64)' | base64 -d > /tmp/provision.sh && chmod +x /tmp/provision.sh")
// Execute as a file (stdin is /dev/null — no corruption)
ssh.execute("bash /tmp/provision.sh")DO NOT use set -euo pipefail at the script top. One failed step kills everything.
Instead, wrap each step in a subshell:
echo "=== [3/10] Installing brew packages ==="
(
set -e
eval "$(/opt/homebrew/bin/brew shellenv)"
brew install git node python3 wget
)
if [ $? -ne 0 ]; then
echo "=== STEP_FAILED 3/10 Installing brew packages ==="
FAILURES=$((FAILURES + 1))
else
echo "=== STEP_COMPLETE 3/10 ==="
fi- Set hostname
- Install Xcode Command Line Tools (required before Homebrew)
- Install Homebrew (clean
/opt/homebrewfirst to prevent stale git locks) brew installpackagesbrew install --caskGUI appsnpm install -gglobals- Custom commands (each as its own step)
- Enable Screen Sharing / VNC
- SSH hardening (disable password auth)
Homebrew requires Xcode CLT. Install non-interactively:
sudo touch /tmp/.com.apple.dt.CommandLineTools.installondemand.in-progress
CLT=$(softwareupdate -l | grep -o '.*Command Line Tools.*' | head -1 | sed 's/^ *//')
sudo softwareupdate -i "$CLT" --verbose
sudo rm -f /tmp/.com.apple.dt.CommandLineTools.installondemand.in-progressThe .installondemand.in-progress touch file makes softwareupdate list CLT as available.
If a previous brew install was interrupted, /opt/homebrew/.git/index.lock persists
and all subsequent installs fail. Always clean before install:
sudo rm -rf /opt/homebrew
NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"The kickstart Perl script is broken on macOS 26 (Tahoe):
Can't call method "print" on an undefined value at kickstart line 695
Fallback approach:
launchctl load -w /System/Library/LaunchDaemons/com.apple.screensharing.plist
defaults write /Library/Preferences/com.apple.RemoteManagement VNCLegacyConnectionsEnabled -bool true
defaults write /Library/Preferences/com.apple.RemoteManagement LoadRemoteManagementPreferences -bool trueEach custom command runs as its own provisioning step with:
- brew PATH sourced (
eval "$(/opt/homebrew/bin/brew shellenv)") - Its own subshell for error isolation
- Individual STEP_COMPLETE/STEP_FAILED markers
SSH in and check each configured tool:
| Tool type | Verification method |
|---|---|
| brew packages | which <pkg> && <pkg> --version |
| CLI casks (e.g., VS Code) | which code && code --version |
| GUI casks (e.g., Chrome) | test -d '/Applications/Google Chrome.app' && defaults read '.../Info' CFBundleShortVersionString |
| npm CLI tools (e.g., claude) | which claude && claude --version |
| npm libraries (e.g., @google/generative-ai) | npm list -g @google/generative-ai |
Important: Prepend /opt/homebrew/bin to PATH in all verification commands.
Brew-installed tools aren't in the default PATH for non-login SSH sessions.
Writes manifest.json to the VM bundle:
{
"tools": [
{"name": "Homebrew", "version": "5.0.14", "installed": true},
{"name": "git", "version": "git version 2.53.0", "installed": true},
...
],
"timestamp": "2026-02-21T21:31:26Z",
"vmName": "claw"
}~/ClawOS/VMs/my-vm.clawbundle/
disk.img Sparse APFS disk image (ASIF on macOS 16+, RAW on 15-)
aux.img EFI auxiliary storage (NVRAM, boot config)
config.json Hardware model + machine identifier (needed for boot)
manifest.json Installed tools with versions
Tabs:
- Display — Live VM screen via
VZVirtualMachineView - Metrics — CPU, memory, disk, network (SSH-polled every 2s)
- Logs — Scrolling provisioning output
- Connect — SSH/VNC commands with copy buttons
- Tools — Manifest viewer (tool name, version, installed status)
- Provisioning — Stage-by-stage progress during build
Status bar: green/orange/red indicator, IP address, Start/Stop/Restart buttons.
The app uses NavigationSplitView with a sidebar listing all .clawbundle directories.
Sources/
CLI/
CreateCommand.swift @main ParsableCommand, orchestrates pipeline
ConfigParser.swift YAML → ClawConfig via Yams
Core/
IPSWResolver.swift Resolve IPSW URL (VZ API or ipsw.me)
IPSWDownloader.swift URLSession download + SHA256
VMRestorer.swift VZMacOSInstaller wrapper
VMConfigBuilder.swift Build VZVirtualMachineConfiguration
DiskInjector.swift Offline disk injection engine (~820 lines)
PasswordHasher.swift SHA512-PBKDF2 for dslocal ShadowHashData
VMRunner.swift VM lifecycle + IP discovery
SSHProvisioner.swift SSH command/script execution
ProvisionScriptGenerator.swift Generate provision.sh from config
ToolVerifier.swift Verify tools, write manifest
VMBundle.swift Bundle paths + listing
Logger.swift Stage-aware logging
Models/
ClawConfig.swift Codable struct mirroring claw.yml
PipelineStage.swift Stage enum + progress tracking
VMState.swift @Observable VM state for UI
UI/
ClawOSApp.swift @main SwiftUI App
ControlPanelView.swift Status bar + tab container
VMDisplayView.swift VZVirtualMachineView wrapper
MetricsView.swift CPU/RAM/disk/network gauges
LogStreamView.swift Scrolling log
QuickConnectView.swift SSH/VNC copy buttons
ToolsManifestView.swift Installed tools list
ProvisioningProgressView.swift Stage progress
BundleListView.swift Sidebar bundle list
com.apple.security.virtualization
com.apple.security.network.client
com.apple.security.network.server
com.apple.security.files.user-selected.read-write
com.apple.security.device.audio-input (microphone passthrough)| Requirement | Minimum |
|---|---|
| Host Mac | Apple Silicon (M1+) |
| Host OS | macOS 15+ (macOS 16+ for ASIF disk format) |
| Xcode | 16+ |
| Free disk | ~60 GB (IPSW ~13 GB + VM disk) |
| Host RAM | 16 GB (to give VM 8 GB comfortably) |
| SSH key | ~/.ssh/id_ed25519 key pair |
| Issue | Root cause | Fix |
|---|---|---|
| Host volume targeted during injection | /Volumes scan matches host Data volume |
Volume snapshot (before/after mount diff) |
diskutil image detach doesn't exist |
Only attach subcommand exists |
Use hdiutil detach for both ASIF and RAW |
chown silently ignored |
Ownership not enabled on volume | diskutil enableOwnership after mount |
FileManager.setAttributes fails |
Doesn't work on foreign APFS volumes | Shell out to /usr/sbin/chown with numeric UID:GID |
| LaunchDaemon refused by launchd | Not owned by root:wheel | chown 0:0, chmod 644 |
| Setup Assistant still appears | Offline markers insufficient on macOS 26 | 6 offline layers + firstboot kill loop |
| Managed plist ignored | Written to per-user dir instead of /Library/Preferences/ |
Write to system-wide location, root-owned |
bash -s script corruption |
Child processes consume stdin | Write script to remote temp file, execute as file |
set -euo pipefail kills pipeline |
One step failure aborts all remaining | Per-step subshell with error isolation |
| Homebrew fails on re-run | Stale .git/index.lock from interrupted install |
sudo rm -rf /opt/homebrew before install |
PROVISIONING_READY never written |
launchctl bootout kills script first |
Write marker BEFORE self-destruct |
| Provisioning runs before sudo ready | Firstboot not complete | Wait for PROVISIONING_READY in firstboot log |
| VNC kickstart broken on macOS 26 | Perl script error in kickstart | Fallback to defaults write + launchctl |
| npm tools not found during verify | brew PATH not in SSH session | Prepend /opt/homebrew/bin in all verification |
| google-chrome shows MISSING | It's a .app, not a CLI binary |
Check /Applications/Google Chrome.app |
@google/generative-ai shows MISSING |
It's a library, not a CLI | Use npm list -g instead of which |
| APFS volumes not found after attach | Synthesized disks not in diskutil list |
Use diskutil mountDisk, then diskutil apfs list fallback |
- Intel Mac support (VZ macOS VMs = Apple Silicon only)
- Snapshots / cloning (fresh restore by design)
- Multiple concurrent VMs
- iCloud login automation
- Windows / Linux guests
- Custom IPSW path (always downloads from Apple)