nvrsion: Add: Adjust vsock Communication in MacVM client and engine code; refactor sidebar to show VM statuses and add “Observe” button for active VMs; add section in website/index.html emphasizing virtualization; adjust macos_vm_exec tool to run without permission in Auto mode.

Nucleic-Promote: 1
Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
2026-07-07 22:06:07 -07:00
co-authored by nucleic
parent ec1bb702cb
commit defe989af1
34 changed files with 3573 additions and 600 deletions
+81 -3
View File
@@ -21,6 +21,7 @@ struct SidebarControlPanel: View {
/// shrinks to fit fewer cards and scrolls within this when there are more (see `SidebarPane`).
let bodyHeight: CGFloat
@Environment(AppStore.self) private var store
@Environment(PanelLayoutStore.self) private var panels
@Environment(\.appPalette) private var palette
@State private var snapshot: ControlSnapshot?
@@ -70,6 +71,8 @@ struct SidebarControlPanel: View {
}
let inFlight = snapshot.autoship.filter { $0.isInFlight }.count
if inFlight > 0 { bits.append("\(inFlight) shipping") }
let vmCount = snapshot.vms.count
if vmCount > 0 { bits.append("\(vmCount) VM\(vmCount == 1 ? "" : "s")") }
return bits.joined(separator: " · ")
}
@@ -100,6 +103,7 @@ struct SidebarControlPanel: View {
if let snapshot {
VStack(alignment: .leading, spacing: 16) {
containerSection(snapshot.container)
if !snapshot.vms.isEmpty { vmSection(snapshot.vms) }
if !snapshot.autoship.isEmpty { autoshipSection(snapshot.autoship) }
locksSection(snapshot.locks)
if !snapshot.activity.isEmpty { activitySection(snapshot.activity) }
@@ -113,10 +117,11 @@ struct SidebarControlPanel: View {
}
}
/// True when nothing is happening beyond the container itself no locks, ships, or activity.
/// True when nothing is happening beyond the container itself no VMs, locks, ships, or activity.
private func isQuiet(_ snapshot: ControlSnapshot) -> Bool {
snapshot.autoship.isEmpty && snapshot.locks.isEmpty && snapshot.activity.isEmpty
&& snapshot.commands.isEmpty && store.deadlockedSessions.isEmpty
snapshot.vms.isEmpty && snapshot.autoship.isEmpty && snapshot.locks.isEmpty
&& snapshot.activity.isEmpty && snapshot.commands.isEmpty
&& store.deadlockedSessions.isEmpty
}
private var quietNote: some View {
@@ -223,6 +228,31 @@ struct SidebarControlPanel: View {
return "\(gb(u.memoryUsedBytes)) / \(gb(u.memoryTotalBytes)) GB"
}
// MARK: - Virtual Machines
/// The per-session macOS guest VMs running right now one card each, showing status, a live
/// CPU/RAM meter, and an "Observe" button that opens the session and hangs the live screen monitor
/// off it. Shown only while at least one VM is up.
@ViewBuilder
private func vmSection(_ vms: [ControlVMEntry]) -> some View {
section("Virtual Machines") {
VStack(alignment: .leading, spacing: 8) {
ForEach(vms) { vm in
MacVMRow(vm: vm, onObserve: { observe(vm) })
}
}
.padding(.vertical, 4)
}
}
/// Open the VM's session and surface its live screen monitor. No-op for a VM we couldn't map back
/// to a live session (the button is hidden in that case, so this is belt-and-suspenders).
private func observe(_ vm: ControlVMEntry) {
guard let sessionID = vm.sessionID else { return }
store.openSessionID = sessionID
panels.revealVMMonitor()
}
// MARK: - Autoship
@ViewBuilder
@@ -524,6 +554,54 @@ private struct ResourceMeter: View {
}
}
/// One running per-session macOS guest VM in the Control panel's Virtual Machines section: an
/// identity row (status dot + session title, with the opaque VM name in the tooltip and the live
/// status as the subtitle) trailed by an "Observe" button, over the CPU/RAM meters once a probe has
/// landed. The dot + subtitle carry status (amber "Booting" vs. green "Running"); "Observe" opens
/// the session and hangs its live screen monitor off the chat. Hidden button when the VM couldn't be
/// mapped back to a live session (nothing to open).
private struct MacVMRow: View {
@Environment(\.appPalette) private var palette
let vm: ControlVMEntry
let onObserve: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Circle().fill(vm.booting ? palette.attention : palette.success)
.frame(width: 8, height: 8)
VStack(alignment: .leading, spacing: 1) {
Text(vm.title).font(.callout).lineLimit(1).truncationMode(.middle)
Text(vm.booting ? "Booting…" : "Running")
.font(.caption2).foregroundStyle(.secondary).lineLimit(1)
}
Spacer(minLength: 8)
if vm.sessionID != nil {
Button("Observe", systemImage: "display") { onObserve() }
.buttonStyle(.borderless).font(.caption).controlSize(.small)
.help("Open this VM's chat and watch its screen in a live monitor")
}
}
// Meters appear once the first background probe lands (nil until then, so no all-zero bars).
if let usage = vm.usage {
VStack(spacing: 4) {
ResourceMeter(label: "CPU", percent: usage.cpuPercent)
ResourceMeter(
label: "RAM", percent: usage.memoryPercent,
detail: Self.memoryDetail(usage))
}
}
}
.help(vm.name)
}
/// "4.2 / 8.0 GB" used vs. total for the RAM meter's tooltip (VM analogue of the container's).
private static func memoryDetail(_ u: MacVMResourceSample) -> String {
func gb(_ bytes: UInt64) -> String { String(format: "%.1f", Double(bytes) / 1_073_741_824) }
return "\(gb(u.memoryUsedBytes)) / \(gb(u.memoryTotalBytes)) GB"
}
}
/// One locked file, rendered as session-style rows one per participant. Each row shows a
/// small lock symbol (green = holding, amber hourglass = waiting) centered against the row,
/// the file name as the title, and the chat that holds (or waits for) it beneath.
@@ -106,6 +106,18 @@ final class PanelLayoutStore {
add(.terminal, to: .bottom)
}
/// Surface a VM Monitor panel used by the Control panel's "Observe" action so the user can watch
/// the just-opened session's macOS guest screen. A monitor already placed anywhere is left where it
/// is (it re-roots onto the newly open chat's VM on its own); otherwise a fresh one is docked in the
/// right column, where a display reads best.
func revealVMMonitor() {
let hasMonitor = PanelSlot.allCases.contains {
working.instances(in: $0).contains { $0.kind == .vmMonitor }
}
guard !hasMonitor else { return }
add(.vmMonitor, to: .right)
}
func remove(_ instanceID: UUID) {
for slot in PanelSlot.allCases {
modify(slot) { $0.removeAll { $0.id == instanceID } }
+60 -22
View File
@@ -402,6 +402,7 @@ private struct SandboxSettingsTab: View {
// toggle is forced on and locked while one exists (reconcileContainerService).
Toggle("Enable container service", isOn: $containerServiceEnabled)
.disabled(store.containerServiceLockedOn)
LearnMoreLink("How agent sandboxing works", url: SupportURL.sandboxes)
if store.containerServiceLockedOn {
HStack(alignment: .firstTextBaseline, spacing: 6) {
@@ -482,14 +483,15 @@ private struct MacVMSettingsTab: View {
Text("Running macOS guests requires Apple silicon.").font(.caption)
}
} else {
Text("Gives each sandboxed agent its own isolated macOS VM (the `mac_vm_exec` "
+ "tool) for Xcode, the simulators, and codesign — so parallel agents run "
+ "Mac builds without colliding on the shared host. Heavy: several GB of RAM "
+ "per VM, plus a one-time base-image install.")
Text("Gives each sandboxed agent its own isolated macOS VM for Xcode, the "
+ "simulators, and codesign — so parallel Mac builds never collide on the "
+ "shared host.")
.settingsCaption()
LearnMoreLink(url: SupportURL.macVMs)
HStack(alignment: .firstTextBaseline, spacing: 6) {
Image(systemName: "internaldrive.fill").foregroundStyle(.orange)
Text("Keep at least **64 GB** of free disk space before enabling. The golden base image, its downloaded restore image, and each per-session VM disk add up fast — running low can stall builds or corrupt a VM mid-run.")
Text("Keep at least **64 GB** of free disk space before enabling — the base "
+ "image, restore image, and per-session VM disks add up fast.")
.font(.caption)
}
}
@@ -498,20 +500,18 @@ private struct MacVMSettingsTab: View {
Toggle("Enable computer use (screen + mouse/keyboard)", isOn: $computerUseByDefault)
.disabled(!serviceEnabled || !supported)
if computerUseByDefault {
Text("Lets agents SEE and drive the VM's GUI (the `mac_vm_computer` tool) as a Mac "
+ "dev simulator. Capture + input run **host-side** through the VM's virtual "
+ "display and virtual keyboard/mouse — no in-guest setup, no permissions, no "
+ "SIP. Works on any installed base. See docs/MACOS_VM.md.")
Text("Lets agents see and drive the VM's screen — clicks, keys, scrolling. Runs "
+ "**host-side**: no in-guest setup, no permissions, works on any base.")
.settingsCaption()
LearnMoreLink(url: SupportURL.macVMComputerUse)
Toggle("Semantic AX agent (advanced — needs SIP disabled once)", isOn: $axAgentEnabled)
.disabled(!serviceEnabled || !supported)
if axAgentEnabled {
Text("Also installs the in-guest NucleicVMAgent for **semantic** control (act on "
+ "a UI element by identity via `ax_*`) and to drive app windows on a macOS 26 "
+ "guest, where the VZ framebuffer bug blanks them in pixel screenshots. This "
+ "is the one path that needs the guest's TCC grants — hence a one-time SIP "
Text("Adds the in-guest agent for **semantic** control act on a UI element by "
+ "identity, and drive app windows on a macOS 26 guest. Needs a one-time SIP "
+ "step after building the base (below).")
.settingsCaption()
LearnMoreLink("Set up AX-based computer use", url: SupportURL.macVMAXAgent)
}
}
}
@@ -539,11 +539,11 @@ private struct MacVMSettingsTab: View {
systemImage: "square.and.arrow.down.on.square")
}
.disabled(!supported || building)
Text("One click: downloads + installs macOS (~14 GB), creates the agent account "
+ "(macOS 27), and installs the dev toolchain (Xcode, node, python) — plus the "
+ "in-guest agent when computer use is on. See docs/MACOS_VM.md. Or point at a "
+ "prebuilt bundle below.")
Text("One click: downloads + installs macOS (~14 GB), creates the agent account, "
+ "and installs the dev toolchain (Xcode, node, python) — plus the in-guest "
+ "agent when computer use is on. Or point at a prebuilt bundle below.")
.settingsCaption()
LearnMoreLink("How base-image builds work", url: SupportURL.macVMBaseImage)
if let baseStatus, baseStatus.installed {
baseStatusView(baseStatus)
}
@@ -560,10 +560,9 @@ private struct MacVMSettingsTab: View {
.onChange(of: restoreImageChoice) { _, newValue in
applyIPSWChoice(newValue)
}
Text("Pick a guest macOS to install, or leave on **Latest supported** for the newest the "
+ "host can run (a macOS N guest needs a macOS N+ host). Entries whose image isn't "
+ "published yet fall back to the latest until their `.ipsw` ships. Override with an "
+ "explicit path or URL under **Advanced**. See docs/MACOS_VM.md.")
Text("Pick a guest macOS to install, or leave on **Latest supported** for the newest "
+ "the host can run (a macOS N guest needs a macOS N+ host). Override with an "
+ "explicit path or URL under **Advanced**.")
.settingsCaption()
DisclosureGroup("Advanced") {
@@ -586,7 +585,7 @@ private struct MacVMSettingsTab: View {
.textFieldStyle(.roundedBorder)
}
Text("A local path or URL takes precedence over the picker above. Leave all empty to "
+ "install the newest macOS the host can run. See docs/MACOS_VM.md.")
+ "install the newest macOS the host can run.")
.settingsCaption()
}
}
@@ -695,6 +694,7 @@ private struct MacVMSettingsTab: View {
+ "3. Reboot, then shut the VM down.\n"
+ "4. Click **Build base image** again to apply the permissions.")
.font(.caption).foregroundStyle(.secondary)
LearnMoreLink("Full setup guide", url: SupportURL.macVMAXAgent)
Button {
Task { await bootRecovery() }
} label: {
@@ -1128,6 +1128,7 @@ private struct ControlSettingsTab: View {
Toggle("Enable Nucleic Control on all projects", isOn: $controlByDefault)
.disabled(!containerServiceEnabled)
LearnMoreLink("What is Nucleic Control?", url: SupportURL.nucleicControl)
}
Section {
@@ -1504,3 +1505,40 @@ private extension View {
.fixedSize(horizontal: false, vertical: true)
}
}
/// Canonical links into the public support site (`nucleic.blakeslee.xyz/support`). The full
/// how-to lives on the web, so a toggle keeps a one-line hint here and defers the detail
/// setup steps, requirements, gotchas to the matching article via `LearnMoreLink`.
private enum SupportURL {
private static let base = "https://nucleic.blakeslee.xyz"
static let home = URL(string: "\(base)/support.html")!
static let nucleicControl = URL(string: "\(base)/support/nucleic-control.html")!
static let sandboxes = URL(string: "\(base)/support/sandboxes.html")!
static let macVMs = URL(string: "\(base)/support/virtual-machines.html")!
static let macVMBaseImage = URL(string: "\(base)/support/macos-vm-base-image.html")!
static let macVMComputerUse = URL(string: "\(base)/support/macos-vm-computer-use.html")!
static let macVMAXAgent = URL(string: "\(base)/support/macos-vm-ax-agent.html")!
}
/// A compact, caption-weight "Learn more" link to a support article. Sits directly under a
/// toggle's short hint, replacing the long inline instructions that used to clutter the UI.
private struct LearnMoreLink: View {
private let label: String
private let url: URL
init(_ label: String = "Learn more", url: URL) {
self.label = label
self.url = url
}
var body: some View {
Link(destination: url) {
HStack(spacing: 3) {
Text(label)
Image(systemName: "arrow.up.right").imageScale(.small)
}
.font(.caption)
}
.fixedSize(horizontal: false, vertical: true)
}
}
+51 -2
View File
@@ -1083,6 +1083,14 @@ public final class AppStore: ConflictArbiter {
private var controlUsageSample: ContainerResourceSample?
private var controlUsageProbe: Task<Void, Never>?
/// Last-known resource samples for the Control panel's per-session macOS-VM meters, keyed by VM
/// name and refreshed *off* the snapshot's critical path same rationale as `controlUsageSample`:
/// the probe SSHes into the guest (a two-sample `top` sleeps ~1 s), so `controlSnapshot` serves the
/// cached reading and a background task keeps it fresh. `macVMUsageProbes` is the per-name
/// single-flight guard, and both are pruned to the currently-running set each snapshot.
private var macVMUsageSamples: [String: MacVMResourceSample] = [:]
private var macVMUsageProbes: Set<String> = []
/// The shared sandbox container's in-flight first-run download (kernel / runtime / image pull +
/// unpack), or `nil` when nothing is downloading the steady state once everything is cached.
/// Observed by the Control panel (which renders a progress bar) and by a busy chat (which names
@@ -3887,13 +3895,17 @@ public final class AppStore: ConflictArbiter {
}
}
// Walk the live controllers once: count Control sessions and build their autoship entries.
// Walk the live controllers once: count Control sessions, build their autoship entries, and
// index every session by its macOS-VM name so a running VM below can show its chat's title
// and be observable (mapping covers all sessions, not just Control ones VMs are per-session).
let controlProjectIDs = Set(projectsByID.values.filter(\.isNucleicControlled).map(\.id))
let nowDate = now()
var activeSessions = 0
var autoship: [AutoshipEntry] = []
var sessionByVMName: [String: (id: SessionID, title: String)] = [:]
for (id, controller) in controllers {
let session = await controller.snapshot.session
sessionByVMName[MacVMManager.vmName(for: session.id)] = (session.id, session.title)
guard controlProjectIDs.contains(session.projectID), !session.archived else { continue }
activeSessions += 1
// Show a session when autoship is armed or the queue has acted on it (so the section
@@ -3921,12 +3933,49 @@ public final class AppStore: ConflictArbiter {
activeSessions: activeSessions, controlProjects: controlProjectIDs.count,
usage: usage)
// Per-session macOS guest VMs. Each running VM becomes a row with its session title, live
// status (booting until it has an IP), and a cached CPU/RAM sample the background probe keeps
// fresh. Only attempted on a host that can run macOS guests; the caches are pruned to the
// running set so a stopped VM's last reading doesn't linger.
var vms: [ControlVMEntry] = []
if macVMSupported, let macVMManager {
let running = await macVMManager.runningVMs()
let liveNames = Set(running.map(\.name))
macVMUsageSamples = macVMUsageSamples.filter { liveNames.contains($0.key) }
macVMUsageProbes = macVMUsageProbes.filter { liveNames.contains($0) }
for entry in running {
let mapped = sessionByVMName[entry.name]
vms.append(ControlVMEntry(
sessionID: mapped?.id, name: entry.name,
title: mapped?.title ?? "macOS VM",
booting: entry.ipAddress == nil,
usage: macVMUsageSamples[entry.name]))
refreshMacVMUsageInBackground(name: entry.name)
}
vms.sort { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }
}
return ControlSnapshot(
container: container, autoship: autoship,
locks: await lockQueueSnapshot(), activity: gitInterceptorEvents,
// The store keeps a deeper history (for the log), but the panel only renders the recent
// slice bounds the per-poll snapshot copy + SwiftUI diffing of this chattier feed.
commands: Array(commandInterceptorEvents.prefix(80)))
commands: Array(commandInterceptorEvents.prefix(80)),
vms: vms)
}
/// Refresh one VM's cached resource sample off the snapshot's critical path. Single-flight per VM
/// (a probe already in flight for `name` is left to finish), so a slow guest `top` can't pile up a
/// new probe on every 2 s poll. Mirrors `refreshControlUsageInBackground` for the container.
private func refreshMacVMUsageInBackground(name: String) {
guard !macVMUsageProbes.contains(name), let macVMManager else { return }
macVMUsageProbes.insert(name)
Task { [weak self] in
let sample = await macVMManager.sampleResourceUsage(name: name)
guard let self else { return }
if let sample { self.macVMUsageSamples[name] = sample }
self.macVMUsageProbes.remove(name)
}
}
/// Refresh the cached container resource sample off the snapshot's critical path. Single-flight:
@@ -986,12 +986,13 @@ public actor ClaudeCodeBackend: AgentBackend {
}
}
/// Gate + run a `mac_vm_exec` call: like `host_exec`, it always surfaces an explicit approval
/// (unless the user chose "Allow for this session"), then boots or reuses **this session's own**
/// isolated macOS VM and runs the command inside it over SSH. `hostWorktree` is shared into the VM
/// so the build sees the repo (guest path `/Volumes/My Shared Files/workspace`). Because each
/// session gets a distinct VM, there is no shared-host concurrency gate parallel agents don't
/// collide the way they do on `host_exec`.
/// Gate + run a `mac_vm_exec` call: it boots or reuses **this session's own** isolated macOS VM and
/// runs the command inside it over SSH. `hostWorktree` is shared into the VM so the build sees the
/// repo (guest path `/Volumes/My Shared Files/workspace`). Because each session gets a distinct VM,
/// there is no shared-host concurrency gate parallel agents don't collide the way they do on
/// `host_exec`. For the same reason the VM is disposable and can't escape onto the shared host,
/// **Auto mode approves every call without prompting**; interactive sessions surface an explicit
/// approval unless the user already chose "Allow for this session".
private func handleMacVMExecCall(
_ call: MCPApprovalServer.MacVMExecCall, hostWorktree: String
) async -> MCPApprovalServer.MacVMExecReply {
@@ -1004,7 +1005,9 @@ public actor ClaudeCodeBackend: AgentBackend {
return .denied(message: HostExecPolicy.missingReasonMessage())
}
if !macVMExecSessionGranted {
// Auto mode waves every macOS-VM command through: the VM is isolated and disposable and can't
// reach the shared host, so there's nothing a per-command prompt would protect.
if !autoApprove, !macVMExecSessionGranted {
let request = ApprovalRequest(
id: .generate(),
sessionID: sessionID ?? SessionID(rawValue: "unknown"),
+39 -1
View File
@@ -242,6 +242,40 @@ public struct ControlContainerInfo: Sendable, Equatable {
}
}
/// One running per-session macOS guest VM as shown in the Control panel's Virtual Machines
/// section: the friendly session **title** as the heading, its live status (booting vs. running),
/// a best-effort CPU/RAM sample, and when we can map it back to a live session the id the
/// "Observe" button needs to open that chat's live VM monitor. VMs are a Control-adjacent resource
/// (one per session, host-side), so the cross-session Control panel is the natural place to surface
/// their status and load alongside the shared container.
public struct ControlVMEntry: Identifiable, Sendable, Equatable {
/// The session this VM belongs to, when it maps to a live controller drives "Observe". `nil`
/// for a running VM whose session isn't loaded (still listed, just not observable from here).
public let sessionID: SessionID?
/// The opaque (randomized) VM name the runtime uses shown as the row's tooltip, not its title.
public let name: String
/// The session's friendly title, or a generic fallback when it can't be mapped.
public let title: String
/// The guest is up but hasn't got a NAT IP yet (still booting) vs. running and SSH-reachable.
public let booting: Bool
/// Live CPU/RAM of the running guest, best-effort. `nil` until the first probe lands (or when a
/// probe fails), so the meters appear once there's a real reading rather than showing zeros.
public let usage: MacVMResourceSample?
public var id: String { name }
public init(
sessionID: SessionID?, name: String, title: String, booting: Bool,
usage: MacVMResourceSample?
) {
self.sessionID = sessionID
self.name = name
self.title = title
self.booting = booting
self.usage = usage
}
}
/// Everything the Control sidebar panel renders, gathered in one call so the view polls a single
/// `@MainActor` entry point (`AppStore.controlSnapshot()`).
public struct ControlSnapshot: Sendable, Equatable {
@@ -251,16 +285,20 @@ public struct ControlSnapshot: Sendable, Equatable {
public let activity: [GitInterceptorEvent]
/// The non-git command feed: `grep`/`cat`/`find`/`npm`/ the interceptor observed, newest-first.
public let commands: [CommandInterceptorEvent]
/// The per-session macOS guest VMs currently running, with status + resource usage. Empty when
/// the host can't run macOS guests or none are up.
public let vms: [ControlVMEntry]
public init(
container: ControlContainerInfo, autoship: [AutoshipEntry],
locks: LockQueueSnapshot, activity: [GitInterceptorEvent],
commands: [CommandInterceptorEvent] = []
commands: [CommandInterceptorEvent] = [], vms: [ControlVMEntry] = []
) {
self.container = container
self.autoship = autoship
self.locks = locks
self.activity = activity
self.commands = commands
self.vms = vms
}
}
@@ -15,7 +15,33 @@ enum MacVMAgentWire {
/// The Nucleic-reserved vsock port the guest agent listens on.
static let port: UInt32 = 2035
/// Protocol version this client speaks; the agent reports its own in the `ping` reply.
static let version = 1
/// v2 adds the streaming ``Exec`` op the vsock replacement for the retired SSH exec path.
/// Mirror of `VMAgentCore.AgentWire.version`; keep the two in lockstep.
static let version = 2
/// The streaming **`exec`** sub-protocol (docs/MACOS_VM_NATIVE_AGENT.md §6), mirror of
/// `VMAgentCore.AgentWire.Exec`. After the single `{"op":"exec","command":}` request line, the
/// (dedicated) connection carries one-JSON-object-per-line *frames* tagged by ``tagKey``. Payload
/// bytes are base64 in ``dataKey``. See ``MacVMExecChannel``.
enum Exec {
static let op = "exec"
static let commandKey = "command"
static let tagKey = "t"
// guest host
static let stdout = "o"
static let stderr = "e"
static let exit = "x"
static let spawnError = "err"
// host guest
static let stdin = "i"
static let stdinEOF = "eof"
static let signal = "sig"
// frame field keys
static let dataKey = "d"
static let codeKey = "code"
static let messageKey = "m"
static let signalKey = "n"
}
/// Default per-request deadline. Generous enough for a full-display SCK screenshot or a deep
/// AX walk; a hung agent surfaces as `.timeout` instead of wedging the tool call.
static let requestTimeout: TimeInterval = 20
@@ -24,7 +50,13 @@ enum MacVMAgentWire {
static func requestLine(op: String, fields: [String: JSONValue] = [:]) -> Data {
var object = fields
object["op"] = .string(op)
var line = (try? JSONValue.object(object).encodedData()) ?? Data("{}".utf8)
return requestLineRaw(object)
}
/// Encode one NDJSON line from a raw object (no forced `op`) used for the streaming exec frames
/// (`{"t":}`), which carry a tag rather than an op. Pure.
static func requestLineRaw(_ fields: [String: JSONValue]) -> Data {
var line = (try? JSONValue.object(fields).encodedData()) ?? Data("{}".utf8)
line.append(0x0A)
return line
}
@@ -147,32 +147,6 @@ extension MacVMEngine {
return mac
}
// MARK: - Host SSH key
/// Ensure the host SSH keypair exists (generating an ed25519 pair with `ssh-keygen` on first use);
/// return the public key text, which provisioning bakes into the base's `agent` account so Nucleic
/// can log in without a password.
@discardableResult
func ensureSSHKey() throws -> String {
let fm = FileManager.default
try fm.createDirectory(
at: sshKeyURL.deletingLastPathComponent(), withIntermediateDirectories: true)
if !fm.fileExists(atPath: sshKeyURL.path) {
let p = Process()
p.executableURL = URL(fileURLWithPath: "/usr/bin/ssh-keygen")
p.arguments = ["-t", "ed25519", "-N", "", "-C", "nucleic-macvm", "-f", sshKeyURL.path]
let pipe = Pipe()
p.standardOutput = pipe
p.standardError = pipe
try p.run()
p.waitUntilExit()
guard p.terminationStatus == 0 else {
throw MacVMError.provisionFailed("ssh-keygen exited \(p.terminationStatus)")
}
}
return try String(contentsOf: sshPublicKeyURL, encoding: .utf8)
.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
#if arch(arm64)
@@ -202,18 +176,21 @@ extension MacVMEngine {
let attachment = try VZDiskImageStorageDeviceAttachment(url: bundle.diskImageURL, readOnly: false)
config.storageDevices = [VZVirtioBlockDeviceConfiguration(attachment: attachment)]
// NAT networking with this clone's pinned MAC (so its DHCP lease is discoverable). NAT needs
// no restricted entitlement deliberately NOT bridged (`com.apple.vm.networking` would get an
// ad-hoc-signed binary killed at launch; see signing/spike.entitlements).
// NAT networking kept **only for the guest's own outbound internet** (brew / npm / xcodebuild
// dependencies). It is NOT part of the hostguest control plane anymore: that is 100% vsock (no
// SSH, no DHCP-lease discovery, no incoming-connection/local-network permission surface). NAT
// needs no restricted entitlement deliberately NOT bridged (`com.apple.vm.networking` would
// get an ad-hoc-signed binary killed at launch; see signing/spike.entitlements). The pinned MAC
// is still used for the best-effort IP shown in the settings panel.
let net = VZVirtioNetworkDeviceConfiguration()
net.macAddress = VZMACAddress(string: mac) ?? VZMACAddress.randomLocallyAdministered()
net.attachment = VZNATNetworkDeviceAttachment()
config.networkDevices = [net]
// vsock channel for the native in-guest agent (docs/MACOS_VM_NATIVE_AGENT.md §3): the host
// reaches the agent via VZVirtioSocketDevice.connect(toPort:), no NAT/DHCP/SSH involved.
// Exactly one socket device per VM (the framework's limit); harmless on a base image that
// doesn't run the agent the engine's probe just falls back to SSH.
// vsock channel for the native in-guest agent (docs/MACOS_VM_NATIVE_AGENT.md §3) the **sole**
// hostguest channel: the host reaches the agent via VZVirtioSocketDevice.connect(toPort:) for
// exec, computer-use, and readiness. Exactly one socket device per VM (the framework's limit);
// harmless on the clean install during a base build, before the agent is installed.
config.socketDevices = [VZVirtioSocketDeviceConfiguration()]
config.graphicsDevices = [Self.makeGraphics()]
@@ -311,7 +288,6 @@ extension MacVMEngine {
}
baseBuilding = true
defer { baseBuilding = false }
try ensureSSHKey()
let fm = FileManager.default
// Reentrancy: if the base is already installed + account-provisioned, skip the multi-GB
@@ -325,7 +301,10 @@ extension MacVMEngine {
"the installed base has no key-authorized `agent` account yet — finish account "
+ "setup first (docs/MACOS_VM.md §4).")
}
try await provisionAndFinalize(bundle: existingBase, base: status)
// Reentrant re-provision: the account already exists, so this is a normal boot (no
// declarative first-boot options).
try await provisionAndFinalize(
bundle: existingBase, base: status, declarativeFirstBoot: false)
return
}
@@ -356,10 +335,11 @@ extension MacVMEngine {
try? fm.removeItem(at: tmpBundle.root)
try fm.createDirectory(at: tmpBundle.root, withIntermediateDirectories: true)
// Set inside the install do-block once the base is published; the toolchain/agent
// provisioning pass then runs AFTER the catch, so its (non-install) errors aren't wrapped
// with the install-failure host/guest hint.
var provisionTarget: (bundle: MacVMBundle, status: MacVMBaseStatus)?
// Set inside the install do-block once the base is published; the provisioning pass then runs
// AFTER the catch, so its (non-install) errors aren't wrapped with the install-failure hint.
// `declarativeFirstBoot` is true for a macOS-27 guest: the provisioning boot IS the first boot
// after restore, so it carries `VZMacGuestProvisioningOptions` to create the `agent` account.
var provisionTarget: (bundle: MacVMBundle, status: MacVMBaseStatus, declarativeFirstBoot: Bool)?
do {
try createInstallPlatform(into: tmpBundle, requirements: requirements)
@@ -382,30 +362,24 @@ extension MacVMEngine {
}
await instance.stop()
// macOS 27 host + 27 guest: declarative first-boot provisioning creates the agent
// account, auto-login, and Remote Login unattended, and authorizes Nucleic's key
// retiring the manual Setup-Assistant step for the latest guests (docs/MACOS_VM.md
// §4.4). Best-effort: a failure leaves the installed base for the manual path.
var accountProvisioned = false
if #available(macOS 27.0, *), osv.majorVersion >= 27 {
accountProvisioned = await firstBootProvisionAccount(config: config, mac: installMAC)
}
// Record the installed guest macOS version + account state, then publish the base FIRST
// (so a later provisioning failure can't discard the multi-GB install) and provision it
// in place afterwards.
// Publish the clean install FIRST (so a later provisioning failure can't discard the
// multi-GB install), then provision it in place. `accountProvisioned` starts false and is
// set true by the provisioning pass once the declarative first boot has created the account.
let installedStatus = MacVMBaseStatus(
installed: true, accountProvisioned: accountProvisioned,
installed: true, accountProvisioned: false,
osVersion: guestVersion, buildVersion: restoreImage.buildVersion)
writeBaseStatus(tmpBundle, installedStatus)
try? fm.removeItem(at: builtBaseDir)
try fm.moveItem(at: tmpBundle.root, to: builtBaseDir)
if accountProvisioned {
provisionTarget = (MacVMBundle(root: builtBaseDir), installedStatus)
if #available(macOS 27.0, *), osv.majorVersion >= 27 {
// macOS 27 guest: the provisioning boot is the first boot after restore and carries
// `VZMacGuestProvisioningOptions` to create the `agent` account + auto-login (no Remote
// Login the control plane is vsock). See `provisionBase` (docs/MACOS_VM.md §4.4).
provisionTarget = (MacVMBundle(root: builtBaseDir), installedStatus, true)
} else {
// 26 / first-boot-failed: no SSH-reachable account yet, so provisioning can't run.
// Leave the installed base for the manual Setup-Assistant path (docs/MACOS_VM.md §4.2).
// 26: no unattended account-creation path (no declarative provisioning, and we don't
// drive Setup Assistant). Leave the installed base for the manual path (§4.2).
baseProgress = MacVMBaseProgress(phase: .ready, fraction: 1)
}
} catch {
@@ -426,9 +400,12 @@ extension MacVMEngine {
throw MacVMError.installFailed(String(describing: error) + hint)
}
// The install + account are published; now provision the toolchain + native agent over SSH.
// The clean install is published; now create the account (declarative first boot) and provision
// the toolchain + native agent all host-side (HID bootstrap) + VirtioFS, no network.
if let target = provisionTarget {
try await provisionAndFinalize(bundle: target.bundle, base: target.status)
try await provisionAndFinalize(
bundle: target.bundle, base: target.status,
declarativeFirstBoot: target.declarativeFirstBoot)
}
}
@@ -1,14 +1,17 @@
import Foundation
/// Computer-use over the macOS guest: the agent SEES the VM's screen (screenshots) and DRIVES its
/// mouse / keyboard / apps, using the VM as its own Mac development simulator. Everything runs in the
/// guest over the existing SSH channel; the framework has no host-side screen/input API for Mac guests.
/// mouse / keyboard / apps, using the VM as its own Mac development simulator. The framework has no
/// host-side screen/input API for Mac guests, so this is done two ways both **network-free**:
/// the **host-side surface** (`VZVirtualMachineView` framebuffer + synthesized HID) the default,
/// needs no guest software, no TCC, no SIP; and
/// the **native in-guest agent** over vsock (AX-semantic ops, and pixel ops via CGEvent).
///
/// **The one governing fact** (see docs/MACOS_VM.md §computer-use): an SSH command lands in launchd's
/// *Background* domain, but `screencapture`/`cliclick` must run inside the console user's *Aqua* GUI
/// session. So every command here is re-dispatched with `launchctl asuser $(id -u) <abs-path>`, and the
/// base image is provisioned with **auto-login** (so an Aqua session exists) plus pre-granted **Screen
/// Recording** + **Accessibility** TCC (the tools no-op / capture black without them).
/// A last-resort fallback drives `screencapture`/`cliclick` inside the guest **over the vsock exec
/// channel** (``run``). Because those tools must run in the console user's *Aqua* GUI session (an exec
/// lands in launchd's *Background* domain), each is re-dispatched with `launchctl asuser $(id -u)
/// <abs-path>`, and it needs Screen Recording + Accessibility TCC which is why the surface/agent
/// paths above are preferred (they avoid those permission prompts entirely).
///
/// Coordinates: the VZ display is 1920×1200 @ 80 PPI (1×, non-Retina), so screenshot pixels map 1:1 to
/// click coordinates the agent targets exactly what it sees, no scaling.
@@ -23,14 +26,14 @@ extension MacVMEngine {
/// captures; `cursor_position` returns the pointer location as text; `wait` pauses then captures.
/// Invalid/missing coordinates yield a `denied`-style text with no image (the caller maps it).
///
/// Routing (docs/MACOS_VM.md §12):
/// 1. `ax_*` semantic actions the **native in-guest agent** (optional AX add-on; needs the
/// agent + its TCC grants, i.e. SIP). No SSH/host equivalent.
/// Routing (docs/MACOS_VM.md §12) every path is network-free:
/// 1. `ax_*` semantic actions the **native in-guest agent** over vsock (needs the AX TCC
/// grants, i.e. SIP). No host equivalent.
/// 2. all **pixel** actions the **host-side surface** (VZVirtualMachineView framebuffer capture
/// + synthesized HID) when the VM has one the default, SIP-free path. `launch_app` has no
/// HID analogue, so it uses SSH `open -a` (which needs no TCC either).
/// 3. legacy fallback (a VM booted without a surface): the native agent's pixel ops, else the SSH
/// `screencapture`/`cliclick` path.
/// HID analogue, so it uses the agent's vsock exec `open -a` (which needs no TCC either).
/// 3. legacy fallback (a VM booted without a surface): the native agent's pixel ops, else the
/// vsock-exec `screencapture`/`cliclick` path.
public func performComputerAction(
name: String, action: String, x: Int?, y: Int?, text: String?,
ref: String? = nil, value: String? = nil,
@@ -55,7 +58,8 @@ extension MacVMEngine {
name: name, action: action, x: x, y: y, text: text,
scrollDirection: scrollDirection, scrollAmount: scrollAmount, durationMs: durationMs)
}
// Legacy fallback: a VM booted without a surface (agent pixel ops, then SSH).
// Legacy fallback: a VM booted without a surface (agent pixel ops, then the vsock-exec
// screencapture/cliclick path).
if let client = await agentClient(name: name),
let native = await performAgentComputerAction(
name: name, client: client, action: action, x: x, y: y, text: text,
@@ -64,7 +68,7 @@ extension MacVMEngine {
{
return native
}
return try await performSSHComputerAction(
return try await performShellComputerAction(
name: name, action: action, x: x, y: y, text: text,
scrollDirection: scrollDirection, scrollAmount: scrollAmount, durationMs: durationMs)
}
@@ -148,9 +152,10 @@ extension MacVMEngine {
}
}
/// Today's SSH + `launchctl asuser` + screencapture/cliclick implementation the fallback path
/// (and the only one on a pre-agent base image).
func performSSHComputerAction(
/// The `launchctl asuser` + screencapture/cliclick implementation, driven over the **vsock exec
/// channel** (``run``) the last-resort fallback for a VM with the agent but no host surface and
/// where the agent's native pixel ops didn't apply. Prefer the surface/agent paths (no TCC).
func performShellComputerAction(
name: String, action: String, x: Int?, y: Int?, text: String?,
scrollDirection: String?, scrollAmount: Int?, durationMs: Int?
) async throws -> (imageBase64: String?, summary: String) {
@@ -26,15 +26,17 @@ extension MacVMEngine {
var axAgentReady: Bool // AX agent installed AND its TCC grants are in place
}
/// Parsed readback of the `SIP= APP= LA= TCC=` line the guest echoes after provisioning.
/// Parsed readback of the `CODE= SIP= APP= LA= TCC=` line the guest bootstrap writes to the
/// STATUS file on the shared directory after provisioning (docs/MACOS_VM.md §4.4).
struct ProvisionStatusFlags: Sendable, Equatable {
var exitCode: Int32 = -1
var sipDisabled = false
var agentApp = false
var launchAgent = false
var tccCount = 0
}
/// Parse the `SIP=1 APP=1 LA=1 TCC=3` readback line (order-independent, tolerant of noise).
/// Parse the `CODE=0 SIP=1 APP=1 LA=1 TCC=3` readback line (order-independent, tolerant of noise).
static func parseProvisionStatus(_ output: String) -> ProvisionStatusFlags {
var flags = ProvisionStatusFlags()
for token in output.split(whereSeparator: { $0 == " " || $0.isNewline }) {
@@ -42,6 +44,7 @@ extension MacVMEngine {
guard parts.count == 2 else { continue }
let value = String(parts[1]).trimmingCharacters(in: .whitespaces)
switch parts[0] {
case "CODE": flags.exitCode = Int32(value) ?? -1
case "SIP": flags.sipDisabled = (value == "1")
case "APP": flags.agentApp = (value == "1")
case "LA": flags.launchAgent = (value == "1")
@@ -115,18 +118,27 @@ extension MacVMEngine {
#if arch(arm64)
extension MacVMEngine {
/// Run the toolchain/agent provisioning pass on an installed, account-provisioned base and fold
/// the result into its `bundle.json`. Best-effort on *content* a SIP-on guest yields an
/// exec-ready-but-not-computer-use base and throws only when the guest was unreachable, leaving
/// the published base intact for a retry (the reentrant "Build again" path).
func provisionAndFinalize(bundle: MacVMBundle, base: MacVMBaseStatus) async throws {
baseProgress = MacVMBaseProgress(phase: .provisioning, fraction: nil)
/// Run the account + toolchain + agent provisioning pass on a freshly installed (or already
/// account-provisioned) base and fold the result into its `bundle.json`. Best-effort on *content*
/// a SIP-on guest yields an exec-ready-but-not-AX base and throws only when the guest never
/// became drivable, leaving the published base intact for a retry (the reentrant "Build again").
///
/// `declarativeFirstBoot` (macOS 27 fresh install) makes this the first boot after restore, so it
/// carries `VZMacGuestProvisioningOptions` to create the `agent` account + auto-login.
func provisionAndFinalize(
bundle: MacVMBundle, base: MacVMBaseStatus, declarativeFirstBoot: Bool
) async throws {
baseProgress = MacVMBaseProgress(
phase: declarativeFirstBoot ? .firstBootSetup : .provisioning, fraction: nil)
do {
// The optional in-guest AX agent is installed only when the user opted into it (the one
// path that still needs SIP); default computer use is host-side and needs nothing here.
// The native agent is the sole hostguest control channel now, so it is ALWAYS installed
// (exec + computer-use ride it). Its AX *TCC grants* remain SIP-gated and are the only part
// `MacVMSettings.axAgentEnabled` affects downstream (see `axAgentReady`).
let result = try await provisionBase(
bundle: bundle, installAgent: MacVMSettings.axAgentEnabled)
bundle: bundle, installAgent: true, declarativeFirstBoot: declarativeFirstBoot)
var status = base
// Reaching a shell + running the bootstrap means the account exists and auto-login works.
status.accountProvisioned = status.accountProvisioned || declarativeFirstBoot
status.provisioned = result.provisioned
status.agentInstalled = result.agentInstalled
status.sipDisabled = result.sipDisabled
@@ -139,21 +151,35 @@ extension MacVMEngine {
}
}
/// Boot the (installed, account-provisioned) base and run the toolchain + agent provisioning over
/// SSH. Throws only when the guest can't be reached (boot/DHCP/sshd failure); a provisioner that
/// runs but can't complete every step (e.g. SIP on no TCC grants) returns partial flags rather
/// than throwing, so the base is still published exec-ready.
func provisionBase(bundle: MacVMBundle, installAgent: Bool) async throws -> ProvisionResult {
/// Provision the base **entirely host-side + VirtioFS, no network** (docs/MACOS_VM.md §4.4):
/// 1. stage the provisioner, the signed agent app, the account password, and a host-composed
/// bootstrap into ONE read-write virtiofs share;
/// 2. boot the base (declaratively creating the account on a fresh macOS-27 install) with a
/// host-side `VZVirtualMachineView` surface bound;
/// 3. drive the surface's synthesized keyboard to open Terminal and launch the staged bootstrap
/// which primes sudo, runs `provision-macos-guest.sh` (dev toolchain + the native agent +,
/// when SIP is off, its TCC grants), writes a `STATUS` readback to the share, and powers off;
/// 4. poll the share for `STATUS` (which also proves the desktop came up), then await shutdown.
///
/// Throws only when the guest never became drivable (no `STATUS` before the deadline); a run that
/// completes with partial results (SIP on no TCC grants) returns partial flags, so the base is
/// still published exec-ready.
func provisionBase(
bundle: MacVMBundle, installAgent: Bool, declarativeFirstBoot: Bool
) async throws -> ProvisionResult {
let user = MacVMSettings.sshUser
let password = try resolveAgentPassword()
let publicKey = try ensureSSHKey()
guard let scriptURL = resolveProvisionScript() else {
throw MacVMError.provisionFailed(
"the bundled provision-macos-guest.sh could not be located")
}
guard let surface = surfaceHost else {
throw MacVMError.provisionFailed(
"base provisioning drives the guest through the host-side display surface, which "
+ "isn't available in this context (headless/spike). Build the base from the app.")
}
// Stage everything the provisioner reads from one virtiofs-shared directory. The script's
// "next to the script" discovery then resolves the pubkey (Phase 2) and the app (Phase 7d).
// 1. Stage everything into one READ-WRITE virtiofs share (the guest writes STATUS back).
let fm = FileManager.default
let stageDir = fm.temporaryDirectory
.appendingPathComponent("nucleic-provision-\(UUID().uuidString)", isDirectory: true)
@@ -162,8 +188,8 @@ extension MacVMEngine {
try? fm.copyItem(
at: scriptURL, to: stageDir.appendingPathComponent("provision-macos-guest.sh"))
try (publicKey + "\n").write(
to: stageDir.appendingPathComponent("id_ed25519.pub"), atomically: true, encoding: .utf8)
try password.write(
to: stageDir.appendingPathComponent("password"), atomically: true, encoding: .utf8)
var agentStaged = false
if installAgent, let app = resolveVMAgentApp() {
@@ -176,75 +202,52 @@ extension MacVMEngine {
atPath: stageDir.appendingPathComponent("NucleicVMAgent.app").path)
}
// Boot the base with the staging share mounted read-only, and wait for SSH.
let bootstrapURL = stageDir.appendingPathComponent(Self.bootstrapScriptName)
try Self.provisionBootstrapScript(user: user).write(
to: bootstrapURL, atomically: true, encoding: .utf8)
try? fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: bootstrapURL.path)
// 2. Boot with the RW share mounted, on the MAIN queue so a host surface can bind.
let mac = Self.randomMAC()
let mount = MacVMSpec.Mount(host: stageDir.path, name: "nucleic-provision", readOnly: true)
let mount = MacVMSpec.Mount(host: stageDir.path, name: "nucleic-provision", readOnly: false)
let config = try Self.makeConfiguration(
bundle: bundle, cpus: MacVMSettings.vmCPUs, memoryGiB: MacVMSettings.vmMemoryGiB,
mac: mac, mounts: [mount])
let instance = MacVMInstance(configuration: config, label: "base-provision")
try await instance.start()
let ip: String
let instance = MacVMInstance(configuration: config, label: "base-provision", mainQueue: true)
do {
ip = try await awaitGuestReady(mac: mac, user: user)
if declarativeFirstBoot, #available(macOS 27.0, *) {
try await instance.startWithProvisioning(
fullName: "Nucleic Agent", username: user, password: password)
} else {
try await instance.start()
}
} catch {
await instance.stop()
throw error
return ProvisionResult(
provisioned: false, agentInstalled: false, sipDisabled: false, axAgentReady: false)
}
let share = "/Volumes/My Shared Files/nucleic-provision"
let pq = Self.shQuote(password)
let surfaceName = "base-provision"
await surface.attach(
name: surfaceName,
virtualMachine: UncheckedSendableBox(value: instance.vm as AnyObject))
// 1. Prime passwordless sudo so the multi-minute brew/toolchain install can't stall on a
// sudo-timestamp expiry (and the script's non-interactive `sudo` never needs a tty). Ensure
// /etc/sudoers actually includes sudoers.d first (guarded, idempotent) so the drop-in is
// honored, then write it.
let primeInner =
"grep -q 'includedir /private/etc/sudoers.d' /etc/sudoers || "
+ "printf '%s\\n' '@includedir /private/etc/sudoers.d' >> /etc/sudoers; "
+ "printf '%s\\n' '\(user) ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/nucleic-provision; "
+ "chmod 0440 /etc/sudoers.d/nucleic-provision"
_ = await runKeySSH(
ip: ip, user: user,
command: "printf '%s\\n' \(pq) | sudo -S sh -c \(Self.shQuote(primeInner))")
// 2. Run the provisioner unattended. Phase 7 skips the computer-use TCC writes when SIP is on.
// 34. Drive HID launch the bootstrap poll STATUS on the share.
baseProgress = MacVMBaseProgress(
phase: installAgent ? .installingAgent : .provisioning, fraction: nil)
let runCmd =
"NUCLEIC_AGENT_PW=\(pq) NUCLEIC_PROVISION_NO_SHUTDOWN=1 /bin/bash "
+ "\(Self.shQuote("\(share)/provision-macos-guest.sh")) "
+ "\(Self.shQuote("\(share)/id_ed25519.pub")) \(pq)"
let (provCode, _) = await runKeySSH(ip: ip, user: user, command: runCmd)
let provisioned = provCode == 0
phase: declarativeFirstBoot ? .firstBootSetup : .installingAgent, fraction: nil)
let statusURL = stageDir.appendingPathComponent("STATUS")
let startedURL = stageDir.appendingPathComponent("STARTED")
let flags = await driveHIDBootstrap(
surface: surface, name: surfaceName, startedURL: startedURL, statusURL: statusURL)
// 3. Read back the computer-use readiness the script could only partially guarantee.
baseProgress = MacVMBaseProgress(phase: .finalizing, fraction: nil)
let tccDB = "/Library/Application Support/com.apple.TCC/TCC.db"
let tccQuery =
"SELECT count(*) FROM access WHERE service IN "
+ "('kTCCServiceScreenCapture','kTCCServiceAccessibility','kTCCServicePostEvent') "
+ "AND auth_value=2;"
let statusCmd =
"SIP=$(csrutil status 2>/dev/null | grep -qi disabled && echo 1 || echo 0); "
+ "APP=$(test -d /Applications/NucleicVMAgent.app && echo 1 || echo 0); "
+ "LA=$(test -f /Library/LaunchAgents/xyz.blakeslee.nucleic.vmagent.plist && echo 1 || echo 0); "
+ "TCC=$(printf '%s\\n' \(pq) | sudo -S sqlite3 \(Self.shQuote(tccDB)) "
+ "\(Self.shQuote(tccQuery)) 2>/dev/null || echo 0); "
+ "echo \"SIP=$SIP APP=$APP LA=$LA TCC=$TCC\""
let (_, statusOut) = await runKeySSH(ip: ip, user: user, command: statusCmd)
let flags = Self.parseProvisionStatus(statusOut)
// 4. Remove the passwordless-sudo drop-in (never ship it in the golden base) and power off
// cleanly. Both `sudo -S` calls pipe the password so they work whether or not the drop-in
// still applies after the `rm`, the shutdown needs the password again.
_ = await runKeySSH(
ip: ip, user: user,
command: "printf '%s\\n' \(pq) | sudo -S rm -f /etc/sudoers.d/nucleic-provision; "
+ "printf '%s\\n' \(pq) | sudo -S shutdown -h now")
await instance.awaitStopped(timeout: 90)
await surface.detach(name: surfaceName)
await instance.awaitStopped(timeout: 120) // the bootstrap powers off when done
guard let flags else {
throw MacVMError.agentUnavailable(
"the base never reached a usable desktop during provisioning (no STATUS readback)")
}
let provisioned = flags.exitCode == 0
let agentInstalled = agentStaged && flags.agentApp && flags.launchAgent
let axAgentReady = agentInstalled && flags.sipDisabled && flags.tccCount >= 3
return ProvisionResult(
@@ -252,38 +255,86 @@ extension MacVMEngine {
sipDisabled: flags.sipDisabled, axAgentReady: axAgentReady)
}
/// Run one command in the guest over SSH with the host identity key (the account is already
/// key-authorized here), returning its exit code + captured stdout. Async/`ChildProcess`-based so
/// a multi-minute provisioner run never blocks the engine actor; both streams are drained so a
/// chatty install can't deadlock on a full pipe. SSH's `ServerAliveInterval` tears down a hung
/// guest on its own, so no separate timeout is needed on this one-time path.
func runKeySSH(ip: String, user: String, command: String) async -> (code: Int32, output: String) {
let args = Self.sshArgs(
ip: ip, user: user, keyPath: sshKeyURL.path, remoteCommand: command)
guard let handle = try? ChildProcess(spec: ProcessSpec(
executable: "/usr/bin/ssh", args: args, cwd: NSTemporaryDirectory(), stdinMode: .closed))
else { return (-1, "") }
async let outLines = Self.drainLines(handle.stdoutLines)
async let errLines = Self.drainLines(handle.stderrLines) // drain to avoid a stalled pipe
let output = await outLines
_ = await errLines
let code = await handle.wait()
return (code, output)
/// The staged bootstrap's filename on the share.
static let bootstrapScriptName = "nucleic-bootstrap.sh"
/// Drive the host-side surface to launch the staged bootstrap, then poll the share for its
/// `STATUS` readback. Re-attempts the launch (open Spotlight Terminal run) until a `STARTED`
/// sentinel proves the bootstrap is running (so we stop typing into a live install), then just
/// waits for `STATUS`. Returns `nil` if neither appears before the deadline.
private func driveHIDBootstrap(
surface: any MacVMSurfaceHost, name: String, startedURL: URL, statusURL: URL
) async -> ProvisionStatusFlags? {
let fm = FileManager.default
let deadline = Date().addingTimeInterval(1800) // 30 min: first login + toolchain install
var running = false
var nextLaunch = Date()
while Date() < deadline {
if let text = try? String(contentsOf: statusURL, encoding: .utf8), text.contains("CODE=") {
return Self.parseProvisionStatus(text)
}
if !running, fm.fileExists(atPath: startedURL.path) {
running = true // the bootstrap is executing stop typing, just wait for STATUS
}
if !running, Date() >= nextLaunch {
await Self.typeBootstrapLaunch(surface: surface, name: name)
nextLaunch = Date().addingTimeInterval(40) // retry login-timing until STARTED appears
}
try? await Task.sleep(nanoseconds: 3_000_000_000)
}
return nil
}
/// Join a line stream into one string (capped so a runaway install log can't balloon memory).
private static func drainLines(
_ stream: AsyncThrowingStream<Data, Error>, cap: Int = 256 * 1024
) async -> String {
var lines: [String] = []
var bytes = 0
do {
for try await line in stream where bytes < cap {
lines.append(String(decoding: line, as: UTF8.self))
bytes += line.count + 1
}
} catch { /* stream ended on error return what we have */ }
return lines.joined(separator: "\n")
/// Synthesize the keystrokes that open Terminal via Spotlight and run the staged bootstrap. Timed
/// generously this is a one-time base build, not a latency-sensitive path.
private static func typeBootstrapLaunch(surface: any MacVMSurfaceHost, name: String) async {
func pause(_ seconds: Double) async {
try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
}
// Dismiss anything focus-stealing, open Spotlight, search + launch Terminal.
await surface.send(name: name, .key(chord: "esc"))
await pause(0.5)
await surface.send(name: name, .key(chord: "cmd+space"))
await pause(1.0)
await surface.send(name: name, .text("Terminal"))
await pause(1.0)
await surface.send(name: name, .key(chord: "return"))
await pause(3.0) // Terminal cold-launch
// Run the staged bootstrap from the share.
let command = "/bin/bash '/Volumes/My Shared Files/nucleic-provision/\(bootstrapScriptName)'"
await surface.send(name: name, .text(command))
await surface.send(name: name, .key(chord: "return"))
}
/// The host-composed bootstrap the guest runs (via HID) network-free: it primes passwordless
/// sudo, runs the provisioner, writes a `CODE= SIP= APP= LA= TCC=` readback to `STATUS` on
/// the share, removes the sudo drop-in, and powers off. Reads the account password from the staged
/// `password` file (no password typing). The `STARTED` sentinel tells the host to stop re-typing.
static func provisionBootstrapScript(user: String) -> String {
let plist = "/Library/LaunchAgents/xyz.blakeslee.nucleic.vmagent.plist"
let tccDB = "/Library/Application Support/com.apple.TCC/TCC.db"
let tccQuery =
"SELECT count(*) FROM access WHERE service IN "
+ "('kTCCServiceScreenCapture','kTCCServiceAccessibility','kTCCServicePostEvent') "
+ "AND auth_value=2;"
return """
#!/bin/bash
SHARE="/Volumes/My Shared Files/nucleic-provision"
touch "$SHARE/STARTED" 2>/dev/null || true
PW="$(cat "$SHARE/password")"
# Prime passwordless sudo so the multi-minute toolchain install can't stall on a tty/timestamp.
printf '%s\\n' "$PW" | sudo -S sh -c 'grep -q "includedir /private/etc/sudoers.d" /etc/sudoers || printf "%s\\n" "@includedir /private/etc/sudoers.d" >> /etc/sudoers; printf "%s\\n" "\(user) ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/nucleic-provision; chmod 0440 /etc/sudoers.d/nucleic-provision' 2>/dev/null
NUCLEIC_AGENT_PW="$PW" NUCLEIC_PROVISION_NO_SHUTDOWN=1 /bin/bash "$SHARE/provision-macos-guest.sh" "" "$PW" > "$SHARE/provision.log" 2>&1
CODE=$?
SIP=$(csrutil status 2>/dev/null | grep -qi disabled && echo 1 || echo 0)
APP=$(test -d /Applications/NucleicVMAgent.app && echo 1 || echo 0)
LA=$(test -f "\(plist)" && echo 1 || echo 0)
TCC=$(sudo sqlite3 "\(tccDB)" "\(tccQuery)" 2>/dev/null || echo 0)
printf 'CODE=%s SIP=%s APP=%s LA=%s TCC=%s\\n' "$CODE" "$SIP" "$APP" "$LA" "$TCC" > "$SHARE/STATUS"
sync
sudo rm -f /etc/sudoers.d/nucleic-provision
sudo shutdown -h now
"""
}
}
#endif
@@ -3,23 +3,21 @@ import Foundation
import Virtualization
#endif
/// macOS 27 **declarative first-boot provisioning** (docs/MACOS_VM.md §4.4): when host AND guest
/// are macOS 27+, `VZMacGuestProvisioningOptions` lets the very first boot after install create the
/// `agent` account, enable auto-login, and turn on Remote Login unattended, no Setup Assistant.
/// `buildBaseImage` then pushes Nucleic's public key in over password-auth SSH and shuts the guest
/// down cleanly, producing an **exec-ready base with zero manual steps**. The toolchain +
/// computer-use provisioning (`scripts/provision-macos-guest.sh`) still runs afterwards, but now
/// over SSH instead of hands-on-the-VM-window.
/// macOS 27 **declarative first-boot provisioning** (docs/MACOS_VM.md §4.4): when host AND guest are
/// macOS 27+, `VZMacGuestProvisioningOptions` lets the very first boot after install create the
/// `agent` account and enable auto-login unattended, no Setup Assistant. **Remote Login is NOT
/// enabled**: the hostguest control plane is vsock-only, so the base never runs sshd. Everything
/// after the account exists (dev toolchain + the native agent + TCC grants) is driven host-side via
/// the display surface + a VirtioFS share (`MacVMEngine+Provision.swift`) no network at any point.
///
/// The framework evaluates the options ONLY on the first boot after restore, so this runs exactly
/// once, between `VZMacOSInstaller` finishing and the base bundle being published. Failure here is
/// deliberately non-fatal: the freshly installed base is still published (a multi-GB install must
/// not be discarded), and the classic manual Setup-Assistant path remains available.
/// The framework evaluates the options ONLY on the first boot after restore, so the provisioning pass
/// carries them exactly once. Failure there is non-fatal: the freshly installed base is still
/// published (a multi-GB install must not be discarded).
extension MacVMEngine {
/// Where the generated agent-account password persists (0600, next to the host SSH key) when
/// no `MacVMSettings.agentPassword` is configured so an unattended build needs no config and
/// later runs (and the operator) can still find the credential.
var agentPasswordURL: URL { sshKeyURL.deletingLastPathComponent().appendingPathComponent("agent-password") }
/// Where the generated agent-account password persists (0600) when `MacVMSettings.agentPassword`
/// isn't configured so an unattended build needs no config and the operator can still find the
/// credential (it's what the provisioning bootstrap uses to prime sudo).
var agentPasswordURL: URL { credentialsDir.appendingPathComponent("agent-password") }
/// The password for the declaratively-created guest account: the configured setting, else the
/// previously generated one, else a fresh random one persisted for reuse.
@@ -43,127 +41,14 @@ extension MacVMEngine {
static func randomPassword() -> String {
(0..<10).map { _ in String(format: "%02x", UInt8.random(in: 0...255)) }.joined()
}
// MARK: - Password-auth SSH (askpass)
/// The one moment Nucleic must SSH with a *password* (the key isn't authorized yet) is right
/// after declarative provisioning. `ssh` refuses to read a password from stdin, so use the
/// OpenSSH askpass hook: a throwaway 0700 script that prints the password, forced via
/// `SSH_ASKPASS_REQUIRE=force` (honored since OpenSSH 8.4; macOS ships newer).
static func askpassScript(password: String) -> String {
"#!/bin/sh\nprintf '%s' \(shQuote(password))\n"
}
/// `ssh` argv for the password-auth path: like ``sshArgs(ip:user:keyPath:remoteCommand:)`` but
/// WITHOUT BatchMode (which forbids password prompts) and pinned to password auth.
static func passwordSSHArgs(ip: String, user: String, remoteCommand: String) -> [String] {
[
"-o", "PreferredAuthentications=password,keyboard-interactive",
"-o", "NumberOfPasswordPrompts=1",
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "LogLevel=ERROR",
"-o", "ConnectTimeout=10",
"\(user)@\(ip)",
remoteCommand,
]
}
/// The in-guest command that authorizes Nucleic's public key for the (fresh) agent account
/// the same thing provisioning Phase 2 does by hand, minus the idempotence it doesn't need on
/// a first boot.
static func authorizeKeyCommand(publicKey: String) -> String {
"mkdir -p ~/.ssh && chmod 700 ~/.ssh && printf '%s\\n' \(shQuote(publicKey))"
+ " >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
}
/// Run one password-auth SSH command against the guest; returns the exit code. Blocking-ish
/// (`Process` + wait) but only used on the one-time base-build path.
func runPasswordSSH(ip: String, user: String, password: String, command: String) throws -> Int32 {
let fm = FileManager.default
let askpass = fm.temporaryDirectory
.appendingPathComponent("nucleic-askpass-\(UUID().uuidString).sh")
try Self.askpassScript(password: password).write(to: askpass, atomically: true, encoding: .utf8)
try fm.setAttributes([.posixPermissions: 0o700], ofItemAtPath: askpass.path)
defer { try? fm.removeItem(at: askpass) }
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
process.arguments = Self.passwordSSHArgs(ip: ip, user: user, remoteCommand: command)
var env = ProcessInfo.processInfo.environment
env["SSH_ASKPASS"] = askpass.path
env["SSH_ASKPASS_REQUIRE"] = "force"
env.removeValue(forKey: "SSH_AUTH_SOCK") // never satisfy auth from the host's agent
process.environment = env
process.standardOutput = Pipe()
process.standardError = Pipe()
try process.run()
process.waitUntilExit()
return process.terminationStatus
}
#if arch(arm64)
/// Boot the freshly installed macOS-27 guest once with declarative provisioning, wait for the
/// account to exist (password SSH answers), authorize the host key, and shut down cleanly.
/// Returns `true` on success; `false` (after force-stopping the boot) leaves the base for the
/// manual path. Only called when `#available(macOS 27)` AND the installed guest is 27+.
@available(macOS 27.0, *)
func firstBootProvisionAccount(
config: VZVirtualMachineConfiguration, mac: String
) async -> Bool {
let username = MacVMSettings.sshUser
guard let password = try? resolveAgentPassword(),
let publicKey = try? ensureSSHKey()
else { return false }
baseProgress = MacVMBaseProgress(phase: .firstBootSetup, fraction: nil)
let instance = MacVMInstance(configuration: config, label: "base-provision")
do {
try await instance.startWithProvisioning(
fullName: "Nucleic Agent", username: username, password: password)
} catch {
return false
}
// First boot runs the provisioning protocol + login; give it a generous deadline before
// each password-SSH probe (which itself has a 10 s connect timeout).
let deadline = Date().addingTimeInterval(600)
var ip: String?
var reachable = false
while Date() < deadline {
if ip == nil { ip = Self.leaseIP(forMAC: mac) }
if let ip, (try? runPasswordSSH(ip: ip, user: username, password: password, command: "true")) == 0 {
reachable = true
break
}
try? await Task.sleep(nanoseconds: 5_000_000_000)
}
guard reachable, let ip else {
await instance.stop()
return false
}
// Authorize the host key (the everyday key-auth exec path), then shut down cleanly so the
// published base clones from a quiesced disk. The account Setup-style first accounts get is
// an administrator, so `sudo -S` with the same password performs the shutdown.
let authorized = (try? runPasswordSSH(
ip: ip, user: username, password: password,
command: Self.authorizeKeyCommand(publicKey: publicKey))) == 0
_ = try? runPasswordSSH(
ip: ip, user: username, password: password,
command: "printf '%s\\n' \(Self.shQuote(password)) | sudo -S shutdown -h now")
await instance.awaitStopped(timeout: 90)
return authorized
}
#endif
}
#if arch(arm64)
extension MacVMInstance {
/// Start the VM with macOS-27 declarative guest provisioning attached (account + auto-login +
/// Remote Login, evaluated by the guest on its first boot after restore). Runs on the VM queue
/// like every VZ call; validation failures (bad username/password per VZError 4000140003)
/// surface as thrown errors.
/// Start the VM with macOS-27 declarative guest provisioning attached (account + auto-login,
/// evaluated by the guest on its first boot after restore). **Remote Login is deliberately left
/// off** the control plane is vsock. Runs on the VM queue like every VZ call; validation failures
/// (bad username/password per VZError 4000140003) surface as thrown errors.
@available(macOS 27.0, *)
func startWithProvisioning(fullName: String, username: String, password: String) async throws {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
@@ -173,7 +58,7 @@ extension MacVMInstance {
provisioning.username = username
provisioning.password = password
provisioning.logsInAutomatically = true
provisioning.enablesRemoteLogin = true
provisioning.enablesRemoteLogin = false
let options = VZMacOSVirtualMachineStartOptions()
do {
// Swift imports the ObjC `setGuestProvisioningOptions:error:` as this throwing
@@ -191,8 +76,8 @@ extension MacVMInstance {
}
/// Boot straight into **macOS recoveryOS** (`startUpFromMacOSRecovery`). Used by the Recovery
/// helper so an operator can `csrutil disable` the base the one computer-use step that can't be
/// scripted over SSH (recoveryOS has no sshd). Runs on the VM queue like every VZ start.
/// helper so an operator can `csrutil disable` the base the one AX-agent step that can't be
/// scripted (recoveryOS runs no agent). Runs on the VM queue like every VZ start.
func startInRecovery() async throws {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
queue.async { [self] in
+147 -90
View File
@@ -6,9 +6,10 @@ import Virtualization
/// In-process **macOS-guest VM runtime** built directly on Apple's `Virtualization` framework the
/// Mac-native counterpart to ``ContainerEngine``. Where the container engine boots Linux guests via
/// the `containerization` framework, this actor boots real macOS guests: it owns a golden **base
/// bundle** (a provisioned macOS install with the dev toolchain + an SSH-reachable `agent` account),
/// bundle** (a provisioned macOS install with the dev toolchain + the native in-guest `agent`),
/// clones it copy-on-write per session, boots each clone on its own serial `VZVirtualMachine` queue,
/// and `exec`s commands inside over SSH so an agent can build/test Mac targets in isolation.
/// and `exec`s commands inside **over vsock** (the native agent's streaming exec) so an agent can
/// build/test Mac targets in isolation no SSH, no NAT, no network in the hostguest control plane.
///
/// **Daemonless ephemeral**, exactly like the container engine: no VM survives the app process, so
/// the `live` registry starts empty each launch and `reconcile` is on-disk GC. The per-session clone
@@ -30,11 +31,12 @@ public actor MacVMEngine {
#endif
/// The clone bundle this VM booted from (its writable disk + aux storage live here).
let bundle: MacVMBundle
/// Locally-administered MAC assigned to this clone, used to find its DHCP lease.
/// Locally-administered MAC assigned to this clone, used only to find its NAT DHCP lease for
/// the settings-panel IP display (the control plane is vsock).
let macAddress: String
/// The account Nucleic SSHes in as.
/// The guest account the native agent runs as (created by declarative provisioning).
let sshUser: String
/// The guest's NAT IP once discovered (`nil` until DHCP + SSH come up).
/// The guest's NAT IP for display, once discovered best-effort and not on any control path.
var ipAddress: String?
/// Configured memory ceiling in bytes, for the resource sample.
let memoryCeilingBytes: UInt64
@@ -45,8 +47,8 @@ public actor MacVMEngine {
var computerUse: Bool = false
}
/// On-disk root for engine artifacts: the cached restore image, the host SSH keypair, the golden
/// base bundle, and per-session clone bundles.
/// On-disk root for engine artifacts: the cached restore image, the agent-account credential, the
/// golden base bundle, and per-session clone bundles.
let storageRoot: URL
/// Live VMs keyed by logical name. Empty at launch (nothing survives the app process).
var live: [String: LiveVM] = [:]
@@ -92,9 +94,9 @@ public actor MacVMEngine {
var baseIsBusy: Bool { baseBuilding || baseRecoveryActive }
/// Host-side computer-use surface (VZVirtualMachineView framebuffer capture + HID injection),
/// injected by the app layer at startup. `nil` in headless/spike contexts computer use then
/// falls back to the SSH path. Only computer-use VMs (main-queue) get a surface bound.
private var surfaceHost: (any MacVMSurfaceHost)?
/// injected by the app layer at startup. `nil` in headless/spike contexts. Bound to computer-use
/// VMs (main-queue) for the agent, and to the base-build VM to drive the HID provisioning bootstrap.
var surfaceHost: (any MacVMSurfaceHost)?
/// Wire the app-layer host-IO surface (called once at startup, like the manager injection).
public func setSurfaceHost(_ host: any MacVMSurfaceHost) { surfaceHost = host }
@@ -138,9 +140,9 @@ public actor MacVMEngine {
var builtBaseDir: URL { storageRoot.appendingPathComponent("base", isDirectory: true) }
/// Cached restore images (`.ipsw`).
var restoreDir: URL { storageRoot.appendingPathComponent("restore", isDirectory: true) }
/// Host SSH keypair whose public half is baked into the base by provisioning.
var sshKeyURL: URL { storageRoot.appendingPathComponent("ssh/id_ed25519") }
var sshPublicKeyURL: URL { sshKeyURL.appendingPathExtension("pub") }
/// Small persisted credentials the base build needs (currently just the generated `agent`-account
/// password used to prime sudo during provisioning). No SSH keys the control plane is vsock.
var credentialsDir: URL { storageRoot.appendingPathComponent("credentials", isDirectory: true) }
/// Per-session clone bundle for `name`.
func instanceBundle(for name: String) -> MacVMBundle {
@@ -195,10 +197,6 @@ public actor MacVMEngine {
/// dedup/ceiling gate, so it never double-boots a name or overshoots the ceiling.
private func performBoot(_ spec: MacVMSpec) async throws -> (name: String, ipAddress: String) {
#if arch(arm64)
// Guarantee the host SSH keypair exists before we try to reach the guest, so `ssh -i <key>`
// never points at a missing file (a prebuilt base can be booted without the in-app build path
// ever having generated it). The guest must authorize this key's public half (provisioning).
try ensureSSHKey()
// Resolve (building if necessary) the golden base, then clone it for this session.
let base = try await ensureBaseBundle()
let bundle = instanceBundle(for: spec.name)
@@ -257,14 +255,17 @@ public actor MacVMEngine {
Task { await self?.handleGuestStopped(name) }
}
// Discover the NAT IP from the DHCP lease file, then wait for sshd to answer. First boot of a
// fresh clone can take a couple of minutes (login window + launchd); reuse is quicker.
// Wait until the in-guest agent answers over vsock the readiness gate. First boot of a fresh
// clone can take a couple of minutes (login window + launchd + the agent's LaunchAgent); reuse
// is quicker.
do {
let ip = try await awaitGuestReady(mac: mac, user: spec.sshUser)
live[spec.name]?.ipAddress = ip
try await awaitAgentReady(name: spec.name)
// Best-effort NAT IP for the settings-panel display only; never gates readiness.
let ip = Self.leaseIP(forMAC: mac) ?? ""
live[spec.name]?.ipAddress = ip.isEmpty ? nil : ip
return (spec.name, ip)
} catch {
// The VM came up but never answered tear it back down so a retry starts clean.
// The VM came up but the agent never answered tear it back down so a retry starts clean.
await instance.stop()
live[spec.name] = nil
syncBackgroundActivity()
@@ -290,14 +291,14 @@ public actor MacVMEngine {
#endif
}
/// Run `argv` inside the named guest over SSH, returning a ``ProcessHandle`` whose stdio is the
/// remote command's stdio (streamed through the local `ssh` client). The agent's mac-VM tool
/// treats it identically to a host or containerized process.
/// Run `argv` inside the named guest **over vsock** (the in-guest agent's streaming `exec` op),
/// returning a ``ProcessHandle`` whose stdio is the remote command's stdio. The agent's mac-VM
/// tool treats it identically to a host or containerized process see ``MacVMExecChannel``.
public func exec(
name: String, workdir: String?, env: [String: String], argv: [String]
) async throws -> any ProcessHandle {
let body = argv.map(Self.shQuote).joined(separator: " ")
return try sshHandle(name: name, workdir: workdir, env: env, remoteBody: body)
return try await execHandle(name: name, workdir: workdir, env: env, remoteBody: body)
}
/// Convenience one-shot used by the `mac_vm_exec` tool: run a shell `command` inside the guest and
@@ -305,7 +306,7 @@ public actor MacVMEngine {
public func run(
name: String, command: String, workdir: String?, env: [String: String] = [:]
) async throws -> (exitCode: Int32, stdout: String, stderr: String) {
let handle = try sshHandle(name: name, workdir: workdir, env: env, remoteBody: command)
let handle = try await execHandle(name: name, workdir: workdir, env: env, remoteBody: command)
// Drain both streams concurrently, then await exit a big build must not deadlock on a full
// stderr pipe while we read stdout (or vice versa).
async let out = Self.collect(handle.stdoutLines)
@@ -323,7 +324,7 @@ public actor MacVMEngine {
func rawStdout(
name: String, command: String, maxBytes: Int = 8 * 1024 * 1024
) async throws -> (text: String, truncated: Bool) {
let handle = try sshHandle(name: name, workdir: nil, env: [:], remoteBody: command)
let handle = try await execHandle(name: name, workdir: nil, env: [:], remoteBody: command)
let result = await Self.collectBounded(handle.stdoutLines, cap: maxBytes)
_ = await handle.wait()
return result
@@ -403,13 +404,67 @@ public actor MacVMEngine {
/// ceiling before booting another (macOS caps simultaneous macOS guests).
public func runningCount() async -> Int { live.count }
/// Best-effort resource sample for the VM panel. CPU is not exposed by the framework for Mac
/// guests, so it's reported as `0`; memory total is the configured ceiling and `used` is best-effort
/// via the guest (left at `0` here a future revision can SSH `vm_stat`). `nil` when not running.
/// Best-effort resource sample for the VM panel. The Virtualization framework exposes no CPU or
/// memory figures for a macOS guest, so we probe from *inside* it over SSH: `vm_stat` for memory
/// (instantaneous and reliable) and a two-sample `top` for CPU (the first `top` sample is
/// meaningless, hence `-l 2`). Both ride one round-trip. Memory total is the configured ceiling
/// (= `hw.memsize` in the guest), so it needs no probe. Returns a ceiling-only sample (used/CPU
/// zero) when the guest isn't reachable yet, and `nil` when the VM isn't running at all.
///
/// This is called off the Control panel's critical path (a background refresh that caches the
/// result), never inline the `top -l 2` read alone sleeps ~1 s in the guest.
public func sampleResourceUsage(name: String) async -> MacVMResourceSample? {
guard let entry = live[name] else { return nil }
let total = entry.memoryCeilingBytes
// No IP yet still booting; skip the SSH probe and report the ceiling only.
guard entry.ipAddress != nil else {
return MacVMResourceSample(cpuPercent: 0, memoryUsedBytes: 0, memoryTotalBytes: total)
}
let probe = "vm_stat; echo __CPU__; top -l 2 -n 0 | awk '/CPU usage/{c=$0} END{print c}'"
let stdout = (try? await run(name: name, command: probe, workdir: nil))?.stdout ?? ""
return Self.parseResourceProbe(stdout, memoryTotal: total)
}
/// Parse the guest resource probe (`vm_stat` + a `top` "CPU usage" line) into a sample. Used
/// memory is `(active + wired + compressed) × page-size`; CPU is `100 idle%` from `top`. Pure
/// and total-driven so it's unit-testable without a live guest a blank/garbled probe yields a
/// ceiling-only sample (zeros) rather than throwing.
static func parseResourceProbe(_ output: String, memoryTotal: UInt64) -> MacVMResourceSample {
var pageSize: UInt64 = 4096
var active: UInt64 = 0, wired: UInt64 = 0, compressed: UInt64 = 0
var cpuPercent = 0.0
for raw in output.split(separator: "\n") {
let line = raw.trimmingCharacters(in: .whitespaces)
// `vm_stat` header carries the page size, e.g. " (page size of 16384 bytes)".
if line.contains("page size of"), let n = Self.trailingUInt(in: line) { pageSize = n }
else if line.hasPrefix("Pages active:"), let n = Self.trailingUInt(in: line) { active = n }
else if line.hasPrefix("Pages wired down:"), let n = Self.trailingUInt(in: line) { wired = n }
else if line.hasPrefix("Pages occupied by compressor:"),
let n = Self.trailingUInt(in: line) { compressed = n }
else if line.contains("CPU usage"), let idle = Self.idlePercent(in: line) {
cpuPercent = max(0, 100 - idle)
}
}
let used = (active &+ wired &+ compressed) &* pageSize
return MacVMResourceSample(
cpuPercent: 0, memoryUsedBytes: 0, memoryTotalBytes: entry.memoryCeilingBytes)
cpuPercent: cpuPercent, memoryUsedBytes: used, memoryTotalBytes: memoryTotal)
}
/// The integer formed by the digits at the end of a `vm_stat` line (e.g. "Pages active: 123456."
/// 123456, "page size of 16384 bytes)" picks up 16384 since it's the only run of digits).
private static func trailingUInt(in line: String) -> UInt64? {
let digits = String(line.unicodeScalars.filter { CharacterSet.decimalDigits.contains($0) })
return UInt64(digits)
}
/// The idle percentage from a `top` "CPU usage: 4.76% user, 9.52% sys, 85.71% idle" line.
private static func idlePercent(in line: String) -> Double? {
guard let range = line.range(of: "% idle") else { return nil }
let number = line[..<range.lowerBound]
.reversed().prefix { $0.isNumber || $0 == "." }.reversed()
return Double(String(number))
}
// MARK: - Host-side computer-use surface (SIP-free framebuffer capture + HID injection)
@@ -482,44 +537,36 @@ public actor MacVMEngine {
}
}
// MARK: - SSH plumbing
// MARK: - vsock exec plumbing
/// Build a `ProcessHandle` for a command run inside the guest over SSH. The local `ssh` client is
/// spawned via a plain host ``ChildProcess`` so all the robust line-buffered stdio + signal
/// plumbing is reused, and the backend decode loop can't tell a guest exec from a host one.
private func sshHandle(
/// Build a `ProcessHandle` for a command run inside the guest **over vsock** a dedicated
/// connection to the in-guest agent's streaming `exec` op (docs/MACOS_VM_NATIVE_AGENT.md §4.4). This
/// replaced the `ssh`-over-NAT handle: no IP discovery, no host-key churn, no Remote Login, no
/// network permission surface. The composed script is identical to what sshd used to run, so the
/// guest's toolchain PATH (via `/etc/zshenv`) resolves the same way.
///
/// Each exec gets 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.
private func execHandle(
name: String, workdir: String?, env: [String: String], remoteBody: String
) throws -> any ProcessHandle {
guard let entry = live[name], let ip = entry.ipAddress else {
throw MacVMError.notRunning(name)
}
) async throws -> any ProcessHandle {
#if arch(arm64)
guard let entry = live[name] else { throw MacVMError.notRunning(name) }
let script = Self.remoteScript(workdir: workdir, env: env, body: remoteBody)
let args = Self.sshArgs(
ip: ip, user: entry.sshUser, keyPath: sshKeyURL.path, remoteCommand: script)
// Spawn `ssh` from a scratch cwd on the host; the working directory that matters is the
// guest-side `cd` baked into `script`.
let spec = ProcessSpec(
executable: "/usr/bin/ssh", args: args, cwd: NSTemporaryDirectory(), stdinMode: .pipe)
return try ChildProcess(spec: spec)
}
/// The `ssh` argv Nucleic uses to reach a guest: identity-only auth against the per-run key, no
/// host-key persistence (a fresh clone rotates its host key, so pinning would spuriously fail),
/// batch mode (never prompt), and a short connect timeout. The remote command is passed as one
/// argument so the guest's login shell runs it verbatim.
static func sshArgs(ip: String, user: String, keyPath: String, remoteCommand: String) -> [String] {
[
"-i", keyPath,
"-o", "IdentitiesOnly=yes",
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "LogLevel=ERROR",
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=10",
"-o", "ServerAliveInterval=15",
"\(user)@\(ip)",
remoteCommand,
]
let instance = entry.instance
let box: UncheckedSendableBox<VZVirtioSocketConnection>
do {
box = try await withAgentTimeout(seconds: 10) {
try await instance.connectAgent(port: MacVMAgentWire.port)
}
} catch {
throw MacVMError.agentUnavailable(
"could not open an exec channel to \"\(name)\": \(error)")
}
return MacVMExecChannel(connection: box, command: script)
#else
throw MacVMError.unavailable(Self.unsupportedReason ?? "requires Apple silicon")
#endif
}
/// Compose the remote shell script: export the extra env, `cd` into the working directory, then
@@ -567,26 +614,45 @@ public actor MacVMEngine {
return out
}
// MARK: - Guest readiness / IP discovery
// MARK: - Guest readiness
/// Poll the vmnet NAT DHCP lease file for `mac`'s address, then wait until sshd answers. Bounded so
/// a guest that never comes up fails cleanly rather than hanging a turn.
func awaitGuestReady(mac: String, user: String) async throws -> String {
let deadline = Date().addingTimeInterval(240) // first boot: login window + launchd + sshd
var ip: String?
/// Wait until the in-guest agent answers a vsock `ping`, bounded. This IS the boot-readiness gate:
/// the hostguest control plane is vsock-only, so "the agent answers" is exactly "the guest is
/// usable for exec + computer-use". No DHCP lease, no sshd, no IP on the critical path.
///
/// On success the live `ping` channel is cached as the VM's control client, so the first
/// computer-use op doesn't pay a second probe. Non-sticky: a failed probe just retries (unlike the
/// lazy ``agentClient(name:)``, whose `.unavailable` is sticky for the VM's lifetime).
func awaitAgentReady(name: String) async throws {
#if arch(arm64)
guard let instance = live[name]?.instance else { throw MacVMError.notRunning(name) }
let deadline = Date().addingTimeInterval(300) // first boot: login window + launchd + agent
while Date() < deadline {
if ip == nil { ip = Self.leaseIP(forMAC: mac) }
if let ip, await probeSSH(ip: ip, user: user) { return ip }
try? await Task.sleep(nanoseconds: 3_000_000_000)
if let box = try? await withAgentTimeout(seconds: 5, {
try await instance.connectAgent(port: MacVMAgentWire.port)
}) {
let client = MacVMAgentClient(connection: box)
if let pong = try? await client.request(op: "ping", timeout: 5),
pong["version"]?.intValue == MacVMAgentWire.version
{
if live[name] != nil { live[name]?.agent = .available(client) }
return
}
await client.close()
}
try? await Task.sleep(nanoseconds: 2_000_000_000)
}
throw MacVMError.sshUnavailable(
ip == nil
? "no DHCP lease appeared for \(mac) within the boot deadline"
: "sshd at \(ip!) never answered within the boot deadline")
throw MacVMError.agentUnavailable(
"the in-guest agent never answered a vsock ping for \"\(name)\" within the boot deadline")
#else
throw MacVMError.unavailable(Self.unsupportedReason ?? "requires Apple silicon")
#endif
}
/// Look up the IPv4 address currently leased to `mac` in `/var/db/dhcpd_leases` (written by
/// Apple's vmnet NAT DHCP server). Reads the file, then delegates to the pure ``parseLeases``.
/// Apple's vmnet NAT DHCP server) **display only** now (the NAT device exists purely for the
/// guest's own outbound internet; the control plane is vsock). Reads the file, then delegates to
/// the pure ``parseLeases``.
static func leaseIP(forMAC mac: String) -> String? {
guard let text = try? String(contentsOfFile: "/var/db/dhcpd_leases", encoding: .utf8)
else { return nil }
@@ -621,15 +687,6 @@ public actor MacVMEngine {
.map { String(format: "%02x", $0) }.joined(separator: ":")
}
/// One quick SSH probe `true` when the guest answers `true` over SSH within the connect timeout.
private func probeSSH(ip: String, user: String) async -> Bool {
let args = Self.sshArgs(ip: ip, user: user, keyPath: sshKeyURL.path, remoteCommand: "true")
guard let handle = try? ChildProcess(spec: ProcessSpec(
executable: "/usr/bin/ssh", args: args, cwd: NSTemporaryDirectory(), stdinMode: .closed))
else { return false }
return await handle.wait() == 0
}
/// A fresh locally-administered MAC (the U/L bit set, multicast bit clear) as a colon string.
static func randomMAC() -> String {
#if arch(arm64)
@@ -0,0 +1,241 @@
import Foundation
import NucleicProtocol
import Virtualization
#if arch(arm64)
/// A ``ProcessHandle`` whose remote process runs **inside a macOS guest, driven entirely over vsock**
/// (docs/MACOS_VM_NATIVE_AGENT.md §6) the direct replacement for the retired `ssh` exec handle.
///
/// It owns one **dedicated** `VZVirtioSocketConnection` to the in-guest agent (port 2035). On init it
/// sends a single `{"op":"exec","command":}` line; from then on the connection carries the streaming
/// exec frames: base64 `stdout`/`stderr` chunks and a final `exit`. The class re-splits those chunks
/// into newline-delimited `Data` lines so the backend decode loop can't tell a guest exec from a host
/// or containerized one the same trick §2.1 of docs/MACOS_VM.md called out for the SSH handle.
///
/// `stdin` (``writeLine``), `closeStdin`, and `sendSignal` travel the other way as hostguest frames.
/// `@unchecked Sendable`: all mutable state is guarded by `lock`, and the fd is full-duplex (the
/// reader task reads while callers write control frames).
final class MacVMExecChannel: ProcessHandle, @unchecked Sendable {
let stdoutLines: AsyncThrowingStream<Data, Error>
let stderrLines: AsyncThrowingStream<Data, Error>
private let stdoutCont: AsyncThrowingStream<Data, Error>.Continuation
private let stderrCont: AsyncThrowingStream<Data, Error>.Continuation
private let connection: UncheckedSendableBox<VZVirtioSocketConnection>
private let fd: Int32
private let lock = NSLock()
// Reader-side line accumulators (touched only on the reader task).
private var stdoutAcc = Data()
private var stderrAcc = Data()
// Guarded by `lock`.
private var finished = false
private var exitCode: Int32?
private var exitWaiters: [CheckedContinuation<Int32, Never>] = []
/// Open an exec channel over `connection`, immediately dispatching `command` (a `/bin/zsh -c`
/// script the engine has already composed with env exports + working directory). The reader task
/// starts pumping frames right away.
init(connection: UncheckedSendableBox<VZVirtioSocketConnection>, command: String) {
self.connection = connection
self.fd = connection.value.fileDescriptor
var out: AsyncThrowingStream<Data, Error>.Continuation!
self.stdoutLines = AsyncThrowingStream { out = $0 }
self.stdoutCont = out
var err: AsyncThrowingStream<Data, Error>.Continuation!
self.stderrLines = AsyncThrowingStream { err = $0 }
self.stderrCont = err
// Nonblocking so the reader task yields (never parks a cooperative thread) between chunks.
let flags = fcntl(fd, F_GETFL, 0)
_ = fcntl(fd, F_SETFL, flags | O_NONBLOCK)
let request = MacVMAgentWire.requestLine(
op: MacVMAgentWire.Exec.op,
fields: [MacVMAgentWire.Exec.commandKey: .string(command)])
writeFrame(request)
Task.detached { [weak self] in await self?.readLoop() }
}
// MARK: - ProcessHandle
/// Informational only real signalling rides ``sendSignal`` frames, not a host pid.
var processID: Int32 { fd }
/// Send one line to the remote process's stdin as an `stdin` frame (newline appended, matching the
/// host pipe's line-oriented `writeLine`).
func writeLine(_ data: Data) throws {
var payload = data
payload.append(0x0A)
sendControl(tag: MacVMAgentWire.Exec.stdin, dataKey: payload.base64EncodedString())
}
func closeStdin() {
sendControl(tag: MacVMAgentWire.Exec.stdinEOF, dataKey: nil)
}
func sendSignal(_ sig: Int32) {
let frame = MacVMAgentWire.requestLineRaw([
MacVMAgentWire.Exec.tagKey: .string(MacVMAgentWire.Exec.signal),
MacVMAgentWire.Exec.signalKey: .number(Double(sig)),
])
writeFrame(frame)
}
func wait() async -> Int32 {
await withCheckedContinuation { (cont: CheckedContinuation<Int32, Never>) in
let alreadyExited: Int32? = lock.withLock {
if let code = exitCode { return code }
exitWaiters.append(cont)
return nil
}
if let alreadyExited { cont.resume(returning: alreadyExited) }
}
}
/// Finish the line streams immediately (the caller is done reading), independent of the guest.
/// Mirrors ``ContainerizedProcessHandle`` a killed exec's streams shouldn't outlive interest.
func forceCloseStreams() {
finish(code: nil)
}
// MARK: - Reader
private func readLoop() async {
var frameBuf = Data()
var scratch = [UInt8](repeating: 0, count: 256 * 1024)
while true {
let count = read(fd, &scratch, scratch.count)
if count > 0 {
frameBuf.append(contentsOf: scratch[0..<count])
// A pathological producer without newlines can't balloon host memory.
if frameBuf.count > 64 * 1024 * 1024 { break }
while let nl = frameBuf.firstIndex(of: 0x0A) {
let frame = frameBuf.subdata(in: frameBuf.startIndex..<nl)
frameBuf.removeSubrange(frameBuf.startIndex...nl)
if handleFrame(frame) { return } // exit frame streams already finished
}
} else if count == 0 {
break // guest closed the connection without an exit frame
} else if errno == EAGAIN || errno == EINTR {
if lock.withLock({ finished }) { return }
try? await Task.sleep(nanoseconds: 15_000_000)
} else {
break // fd error
}
}
// Fell out without an `exit` frame: transport died. Surface a nonzero code.
finish(code: exitCode ?? -1)
}
/// Decode one guesthost frame. Returns `true` once the terminal (`exit`/`spawnError`) frame has
/// been handled and the streams finished.
private func handleFrame(_ data: Data) -> Bool {
guard
let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
let tag = obj[MacVMAgentWire.Exec.tagKey] as? String
else { return false }
switch tag {
case MacVMAgentWire.Exec.stdout:
if let b64 = obj[MacVMAgentWire.Exec.dataKey] as? String,
let bytes = Data(base64Encoded: b64)
{
emitLines(bytes, into: stdoutCont, accumulator: &stdoutAcc)
}
return false
case MacVMAgentWire.Exec.stderr:
if let b64 = obj[MacVMAgentWire.Exec.dataKey] as? String,
let bytes = Data(base64Encoded: b64)
{
emitLines(bytes, into: stderrCont, accumulator: &stderrAcc)
}
return false
case MacVMAgentWire.Exec.spawnError:
let message = obj[MacVMAgentWire.Exec.messageKey] as? String ?? "the guest could not run the command"
emitLines(Data(message.utf8), into: stderrCont, accumulator: &stderrAcc)
finish(code: 127)
return true
case MacVMAgentWire.Exec.exit:
let code = obj[MacVMAgentWire.Exec.codeKey] as? Int ?? 0
finish(code: Int32(code))
return true
default:
return false
}
}
/// Append `bytes` to `accumulator` and yield every complete newline-terminated line to `cont`
/// (newline stripped), holding any partial trailing line for the next chunk.
private func emitLines(
_ bytes: Data, into cont: AsyncThrowingStream<Data, Error>.Continuation,
accumulator: inout Data
) {
accumulator.append(bytes)
while let nl = accumulator.firstIndex(of: 0x0A) {
let line = accumulator.subdata(in: accumulator.startIndex..<nl)
accumulator.removeSubrange(accumulator.startIndex...nl)
cont.yield(line)
}
}
// MARK: - Teardown
/// Finish both streams (flushing any trailing partial line), record the exit code, wake every
/// `wait()`, and close the connection. Idempotent.
private func finish(code: Int32?) {
lock.lock()
if finished {
lock.unlock()
return
}
finished = true
let resolved = code ?? exitCode ?? -1
exitCode = resolved
let waiters = exitWaiters
exitWaiters.removeAll()
lock.unlock()
if !stdoutAcc.isEmpty { stdoutCont.yield(stdoutAcc); stdoutAcc.removeAll() }
if !stderrAcc.isEmpty { stderrCont.yield(stderrAcc); stderrAcc.removeAll() }
stdoutCont.finish()
stderrCont.finish()
for waiter in waiters { waiter.resume(returning: resolved) }
connection.value.close()
}
// MARK: - Writes (host guest)
private func sendControl(tag: String, dataKey: String?) {
var fields: [String: JSONValue] = [MacVMAgentWire.Exec.tagKey: .string(tag)]
if let dataKey { fields[MacVMAgentWire.Exec.dataKey] = .string(dataKey) }
writeFrame(MacVMAgentWire.requestLineRaw(fields))
}
/// Blocking-ish write of a whole control frame (tiny), serialized against other writers. Spins on
/// EAGAIN since the fd is nonblocking; drops the frame if the connection has been torn down.
private func writeFrame(_ data: Data) {
lock.lock()
let done = finished
lock.unlock()
if done { return }
var remaining = data
var spins = 0
while !remaining.isEmpty {
let written = remaining.withUnsafeBytes { raw in write(fd, raw.baseAddress, raw.count) }
if written > 0 {
remaining = remaining.dropFirst(written)
} else if written < 0, errno == EAGAIN || errno == EINTR {
spins += 1
if spins > 1000 { return } // ~1s of a wedged fd give up on this frame
usleep(1000)
} else {
return // fd error the read loop will surface the teardown
}
}
}
}
#endif
@@ -131,6 +131,13 @@ public actor MacVMManager {
await engine.captureScreen(name: name)
}
/// Best-effort CPU/RAM sample of the named running VM for the Control panel's resource meters, or
/// `nil` when it isn't running. No lifecycle effect. Probes the guest over SSH, so callers should
/// refresh it off their critical path (see `AppStore.refreshMacVMUsageInBackground`).
public func sampleResourceUsage(name: String) async -> MacVMResourceSample? {
await engine.sampleResourceUsage(name: name)
}
/// The one-time base-image build progress (download/install/provision), for the settings panel.
public func baseProgress() async -> MacVMBaseProgress? {
await engine.currentBaseProgress()
+5 -4
View File
@@ -97,14 +97,15 @@ public enum MacVMError: Error, Sendable, CustomStringConvertible {
case baseImageMissing(String)
/// Installing macOS from the restore image failed. Carries the underlying message.
case installFailed(String)
/// Provisioning the golden base (dev toolchain, SSH account) failed.
/// Provisioning the golden base (dev toolchain, in-guest agent) failed.
case provisionFailed(String)
/// Cloning the base bundle for a per-session VM failed.
case cloneFailed(String)
/// The guest VM failed to create/start. Carries the underlying message.
case startFailed(String)
/// The guest never became reachable over SSH within the boot deadline.
case sshUnavailable(String)
/// The in-guest vsock agent never became reachable within the boot deadline (the control plane is
/// vsock-only now there is no network fallback).
case agentUnavailable(String)
/// Too many macOS guests are already running (host-resource / configured ceiling).
case concurrencyLimit(Int)
/// The golden base is exclusively in use (a build/provisioning pass or a Recovery session has it
@@ -120,7 +121,7 @@ public enum MacVMError: Error, Sendable, CustomStringConvertible {
case let .provisionFailed(msg): return "Failed to provision the base macOS VM: \(msg)"
case let .cloneFailed(msg): return "Failed to clone the base macOS VM: \(msg)"
case let .startFailed(msg): return "Failed to start the macOS VM: \(msg)"
case let .sshUnavailable(msg): return "The macOS guest never became reachable: \(msg)"
case let .agentUnavailable(msg): return "The macOS guest agent never became reachable: \(msg)"
case let .concurrencyLimit(n): return "The macOS VM limit (\(n)) is already in use."
case let .baseBusy(why): return "The base macOS image is busy: \(why)"
}
+74 -38
View File
@@ -95,18 +95,28 @@ import Testing
#expect(script == "echo hi")
}
@Test func sshArgsAreIdentityOnlyBatchModeWithCommandLast() {
let args = MacVMEngine.sshArgs(
ip: "192.168.64.5", user: "agent", keyPath: "/keys/id_ed25519",
remoteCommand: "echo ok")
#expect(args.contains("BatchMode=yes"))
#expect(args.contains("IdentitiesOnly=yes"))
#expect(args.contains("StrictHostKeyChecking=no"))
#expect(args.contains("[email protected]"))
#expect(args.last == "echo ok")
// The identity file follows a `-i`.
if let i = args.firstIndex(of: "-i") { #expect(args[i + 1] == "/keys/id_ed25519") }
else { Issue.record("expected -i in ssh args") }
// MARK: - vsock exec framing (the SSH exec path was retired for the in-guest agent)
@Test func execRequestLineCarriesOpAndCommand() throws {
let line = MacVMAgentWire.requestLine(
op: MacVMAgentWire.Exec.op,
fields: [MacVMAgentWire.Exec.commandKey: .string("echo ok")])
#expect(line.last == 0x0A) // NDJSON: one object per line
let obj = try JSONValue(parsing: line.dropLast()).objectValue
#expect(obj?["op"]?.stringValue == "exec")
#expect(obj?[MacVMAgentWire.Exec.commandKey]?.stringValue == "echo ok")
}
@Test func execFrameLineHasTagButNoOp() throws {
// Streaming frames carry a `t` tag, not an `op` `requestLineRaw` must not inject one.
let frame = MacVMAgentWire.requestLineRaw([
MacVMAgentWire.Exec.tagKey: .string(MacVMAgentWire.Exec.signal),
MacVMAgentWire.Exec.signalKey: .number(9),
])
let obj = try JSONValue(parsing: frame.dropLast()).objectValue
#expect(obj?["op"] == nil)
#expect(obj?[MacVMAgentWire.Exec.tagKey]?.stringValue == "sig")
#expect(obj?[MacVMAgentWire.Exec.signalKey]?.intValue == 9) // whole number renders as int
}
// MARK: - Spec
@@ -237,18 +247,56 @@ import Testing
#expect(MacVMEngine.scrollDeltas(direction: "right", amount: 2) == (-2, 0))
}
// MARK: - Resource probe parsing (Control panel VM meters)
@Test func parseResourceProbeComputesUsedMemoryAndCPUFromIdle() {
// vm_stat (16 KiB pages) + a `top` "CPU usage" line, as the guest emits them.
let probe = """
Mach Virtual Memory Statistics: (page size of 16384 bytes)
Pages free: 1000.
Pages active: 2000.
Pages inactive: 500.
Pages wired down: 1000.
Pages occupied by compressor: 500.
__CPU__
CPU usage: 4.76% user, 9.52% sys, 85.71% idle
"""
let total: UInt64 = 8 * 1_073_741_824 // 8 GiB ceiling
let sample = MacVMEngine.parseResourceProbe(probe, memoryTotal: total)
// used = (active + wired + compressed) × page size = 3500 × 16384.
#expect(sample.memoryUsedBytes == 3500 * 16384)
#expect(sample.memoryTotalBytes == total)
// CPU = 100 idle, within float tolerance.
#expect(abs(sample.cpuPercent - (100 - 85.71)) < 0.01)
}
@Test func parseResourceProbeDegradesToCeilingOnBlankOutput() {
// A missing/garbled probe must not throw it yields a ceiling-only sample (zeros).
let total: UInt64 = 4 * 1_073_741_824
let sample = MacVMEngine.parseResourceProbe("", memoryTotal: total)
#expect(sample.memoryUsedBytes == 0)
#expect(sample.cpuPercent == 0)
#expect(sample.memoryTotalBytes == total)
#expect(sample.memoryPercent == 0)
}
@Test func parseProvisionStatusReadsGuestReadbackLine() {
let flags = MacVMEngine.parseProvisionStatus("SIP=1 APP=1 LA=1 TCC=3")
// The guest bootstrap writes `CODE= SIP= APP= LA= TCC=` to STATUS on the share.
let flags = MacVMEngine.parseProvisionStatus("CODE=0 SIP=1 APP=1 LA=1 TCC=3")
#expect(flags.exitCode == 0)
#expect(flags.sipDisabled)
#expect(flags.agentApp)
#expect(flags.launchAgent)
#expect(flags.tccCount == 3)
// Order-independent and tolerant of surrounding noise / SIP-on / partial grants.
let partial = MacVMEngine.parseProvisionStatus("noise\nTCC=0 APP=1 SIP=0 LA=0\nmore")
// Order-independent and tolerant of surrounding noise / SIP-on / partial grants / bad code.
let partial = MacVMEngine.parseProvisionStatus("noise\nCODE=1 TCC=0 APP=1 SIP=0 LA=0\nmore")
#expect(partial.exitCode == 1)
#expect(!partial.sipDisabled)
#expect(partial.agentApp)
#expect(!partial.launchAgent)
#expect(partial.tccCount == 0)
// A missing CODE defaults to -1 (never mistaken for success).
#expect(MacVMEngine.parseProvisionStatus("SIP=0").exitCode == -1)
}
// MARK: - Settings (isolated task-local defaults suite)
@@ -427,29 +475,17 @@ import Testing
// MARK: - macOS 27 declarative first-boot provisioning (docs/MACOS_VM.md §4.4)
@Test func passwordSSHArgsAllowPasswordAuthAndCarryCommandLast() {
let args = MacVMEngine.passwordSSHArgs(
ip: "192.168.64.5", user: "agent", remoteCommand: "true")
// BatchMode would forbid password prompts it must NOT appear on this path.
#expect(!args.contains("BatchMode=yes"))
#expect(args.contains("PreferredAuthentications=password,keyboard-interactive"))
#expect(args.contains("[email protected]"))
#expect(args.last == "true")
}
@Test func askpassScriptPrintsTheQuotedPassword() {
let script = MacVMEngine.askpassScript(password: "p'w")
#expect(script.hasPrefix("#!/bin/sh\n"))
#expect(script.contains("printf '%s' 'p'\\''w'"))
}
@Test func authorizeKeyCommandAppendsWithStrictPerms() {
let cmd = MacVMEngine.authorizeKeyCommand(publicKey: "ssh-ed25519 AAAA nucleic-macvm")
#expect(cmd.contains("mkdir -p ~/.ssh"))
#expect(cmd.contains("chmod 700 ~/.ssh"))
#expect(cmd.contains("'ssh-ed25519 AAAA nucleic-macvm'"))
#expect(cmd.contains(">> ~/.ssh/authorized_keys"))
#expect(cmd.contains("chmod 600 ~/.ssh/authorized_keys"))
@Test func provisionBootstrapScriptIsNetworkFreeAndReportsStatus() {
// The host-composed bootstrap the guest runs via HID: reads the password from the share,
// runs the provisioner, and writes a CODE/SIP/APP/LA/TCC readback + powers off. No network.
let script = MacVMEngine.provisionBootstrapScript(user: "agent")
#expect(script.contains("touch \"$SHARE/STARTED\"")) // "running" sentinel
#expect(script.contains("cat \"$SHARE/password\"")) // no typed password
#expect(script.contains("provision-macos-guest.sh")) // runs the provisioner
#expect(script.contains("agent ALL=(ALL) NOPASSWD: ALL")) // primes sudo for the user
#expect(script.contains("> \"$SHARE/STATUS\"")) // writes the readback
#expect(script.contains("shutdown -h now")) // powers off when done
#expect(!script.contains("ssh")) // never touches SSH
}
@Test func randomPasswordIsHexAndLongEnough() {
+58 -47
View File
@@ -257,62 +257,73 @@ recorded in the bundle's `bundle.json` and surfaced as "Installed guest macOS" i
> `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 provisioning (macOS 27) — wired in.** On a macOS 27 host installing a
> macOS 27+ guest, `buildBaseImage` now runs an unattended first boot with
> **`VZMacGuestProvisioningOptions`** (`MacVMEngine+Provision27.swift`, gated
> `#available(macOS 27, *)`): the guest creates the `agent` account (password from
> `MacVMSettings.agentPassword`, else generated and persisted at `macvms/ssh/agent-password`),
> enables **auto-login** and **Remote Login**, then Nucleic authorizes its SSH key over one
> password-auth SSH (askpass) and shuts the guest down — an **exec-ready base with zero manual
> steps**, retiring the Setup-Assistant hand-work for the latest guests. The step is best-effort:
> any failure publishes the installed base anyway and the classic manual path (§4.2) still works.
> `bundle.json` records it as `accountProvisioned`.
> **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`):
>
> **One-click full provisioning — wired in.** After the account exists, `buildBaseImage` runs the
> **toolchain + native-agent provisioning over SSH itself** (`MacVMEngine+Provision.swift`): it boots
> the base with `scripts/provision-macos-guest.sh` + the host key + the embedded `NucleicVMAgent.app`
> staged over a virtiofs share, primes passwordless sudo, runs the provisioner unattended (dev
> toolchain; and — only when the optional **semantic AX agent** is enabled — the agent app +
> LaunchAgent + its TCC grants), reads back readiness, and powers off. So **Settings ▸ Virtual
> Machines ▸ Build base image** produces a fully golden, ready-to-plug-in base in one click, and
> **computer use works on it with no SIP** (host-side virtual IO, §12.2). It's **reentrant**: on an
> already-installed base it re-runs only provisioning — which is how the one-time SIP step (§12.5)
> completes *if you opted into the AX agent*: disable SIP once in Recovery, click Build again, and the
> agent's grants land. `bundle.json` carries the richer `MacVMBaseStatus` (`provisioned`,
> `agentInstalled`, `sipDisabled`, `axAgentReady`). Other 27 additions (DiskImageKit, USB pass-through)
> remain future work.
> 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 +, when SIP is off, the AX TCC grants), 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) — which is how the one-time SIP step (§12.5) completes: disable SIP once in
> Recovery, click Build again, and the agent's AX grants land. `bundle.json` carries `MacVMBaseStatus`
> (`accountProvisioned`, `provisioned`, `agentInstalled`, `sipDisabled`, `axAgentReady`).
---
## 5. Guest interaction — SSH over NAT
## 5. Guest interaction — vsock (native in-guest agent)
**The `Virtualization` framework exposes no host→guest exec API for Mac guests** (unlike a Linux
guest's vsock exec via the containerization framework). Nucleic reaches the guest over **SSH on the
NAT network**:
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.
- **Networking** — `VZNATNetworkDeviceAttachment` (vmnet NAT). No bridged networking, so **no
- **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 §6). 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).
- **IP discovery** — each clone carries a pinned locally-administered MAC. `leaseIP(forMAC:)` parses
`/var/db/dhcpd_leases` (written by Apple's vmnet NAT DHCP server) to map that MAC → the guest's
IPv4. MACs are compared octet-by-octet as integers, because the lease file drops leading zeros
(`2:34:…`) while `VZMACAddress.string` keeps them (`02:34:…`); the newest matching lease wins.
- **Readiness** — `awaitGuestReady` polls for the lease, then probes sshd (`ssh … true`) until it
answers, bounded to **~240 s** (first boot: login window + launchd + sshd). A VM that boots but
never answers is torn back down so a retry starts clean.
- **The exec** — `sshHandle` spawns `/usr/bin/ssh` via `ChildProcess` (§2.1) with identity-only auth
against the per-run key, `BatchMode=yes` (never prompt), no host-key persistence
(`StrictHostKeyChecking=no`, `UserKnownHostsFile=/dev/null` — a fresh clone rotates its host key, so
pinning would spuriously fail), and a short connect timeout. The remote command is composed by
`remoteScript`: export the extra env, `cd` into the workdir, then run the body. `run(…)` is the
one-shot variant used by `mac_vm_exec` — it drains stdout/stderr concurrently (so a big build can't
deadlock on a full pipe) and returns `(exitCode, stdout, stderr)`.
### 5.1 The base's SSH surface
### 5.1 The base's agent surface
Provisioning bakes an **`agent`** account authorized with Nucleic's host public key (whose private
half never leaves `macvms/ssh/id_ed25519`). Toolchain PATH is exported via `/etc/zshenv` so
non-login SSH shells resolve the toolchain without a login shell. The session repo is shared in over
virtiofs (§6).
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).
---
+21
View File
@@ -188,6 +188,27 @@ A `node` is:
"children": [ ] }
```
### 4.4 Streaming exec (protocol v2) — the vsock replacement for SSH exec
`exec` is the one **streaming** op and the transport for `mac_vm_exec` (retiring SSH-over-NAT). Unlike
every request/reply op above, it takes over its connection for the life of the command, so the host
opens a **dedicated** vsock connection per exec (the listener is one thread per connection → concurrent
execs + the control channel never interfere).
- **Request** (one line): `{"op":"exec","command":<shell script>}`. The host composes env exports +
`cd <workdir>` + the body into `command` and the guest runs it under `/bin/zsh -c` (so `/etc/zshenv`'s
toolchain PATH resolves), exactly as sshd used to invoke it.
- **Frames** (one JSON object per line, tagged by `t`), guest→host: `{"t":"o","d":<b64 stdout>}`,
`{"t":"e","d":<b64 stderr>}`, then terminal `{"t":"x","code":<int>}` (or `{"t":"err","m":<msg>}` if the
spawn failed). Payload bytes are **base64** so arbitrary binary output can't break the NDJSON framing.
- **Frames**, host→guest: `{"t":"i","d":<b64 stdin>}`, `{"t":"eof"}`, `{"t":"sig","n":<signal>}`.
Host side is `MacVMExecChannel` (a `ProcessHandle` that re-splits the base64 chunks into newline
lines); guest side is `ConnectionHandler.runExec` (spawns the process, pumps its pipes as frames, polls
the connection for input frames). Constants are mirrored in `MacVMAgentWire.Exec` (host) and
`VMAgentCore.AgentWire.Exec` (guest) — keep them in lockstep. The protocol version is bumped to **2**
because a v1 agent doesn't speak `exec`.
---
## 5. The in-guest agent — `NucleicVMAgent`
@@ -49,8 +49,12 @@ final class AgentListener {
/// writes the one-line JSON reply. Everything for this connection (including the AX element
/// registry) is confined to this thread requests on a connection are strictly serial.
final class ConnectionHandler {
private let fd: Int32
private var lines = LineSplitBuffer()
/// The accepted vsock connection fd. Internal (not private) so the ``runExec`` streamer which
/// takes the connection over for the life of one command can poll and write it directly.
let fd: Int32
/// NDJSON line framer for this connection. Internal so ``runExec`` can keep draining hostguest
/// frames (stdin / signal) from the same buffer after the request line.
var lines = LineSplitBuffer()
/// AX element handles (`ref` live `AXUIElement`) issued by the last dump on this connection.
let axRegistry = AXRegistry()
@@ -74,6 +78,13 @@ final class ConnectionHandler {
lines.append(Data(bytes: scratch, count: n))
while let line = lines.nextLine() {
guard !line.isEmpty else { continue }
// `exec` is the one streaming op: it takes over this connection for the life of the
// command (bidirectional NDJSON frames, §6), then the connection is done exec
// connections are single-use, so return and let `defer` close the fd.
if Self.isExecRequest(line) {
runExec(requestLine: line)
return
}
var reply = handle(requestLine: line)
reply.append(0x0A)
guard writeAll(reply) else { return }
@@ -83,7 +94,16 @@ final class ConnectionHandler {
}
}
private func writeAll(_ data: Data) -> Bool {
/// Peek a framed request line for `"op":"exec"` without disturbing dispatch of every other op.
private static func isExecRequest(_ line: Data) -> Bool {
guard
let parsed = try? JSONSerialization.jsonObject(with: line),
let request = parsed as? [String: Any]
else { return false }
return (request["op"] as? String) == AgentWire.Exec.op
}
func writeAll(_ data: Data) -> Bool {
var remaining = data
while !remaining.isEmpty {
let written = remaining.withUnsafeBytes { raw in
@@ -0,0 +1,165 @@
import Darwin
import Foundation
import VMAgentCore
extension ConnectionHandler {
/// Handle a streaming **`exec`** request (docs/MACOS_VM_NATIVE_AGENT.md §6): spawn the command and
/// pump its stdio over this connection as base64 NDJSON frames until it exits, forwarding any
/// hostguest `stdin`/`signal`/`eof` frames to the child. Owns ``fd`` for the whole run; the caller
/// closes the connection afterwards (exec connections are single-use).
///
/// The command is run under **`/bin/zsh -c`** the guest account's login shell, invoked exactly as
/// sshd used to invoke it, so `/etc/zshenv` (where provisioning appends the toolchain PATH) is
/// sourced and `xcodebuild`/`brew`/`node` resolve. The host has already composed any working
/// directory + env exports into the script.
func runExec(requestLine: Data) {
guard
let parsed = try? JSONSerialization.jsonObject(with: requestLine),
let request = parsed as? [String: Any],
let command = request[AgentWire.Exec.commandKey] as? String
else {
sendExecError("exec requires a \"\(AgentWire.Exec.commandKey)\" string")
return
}
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
process.arguments = ["-c", command]
let outPipe = Pipe(), errPipe = Pipe(), inPipe = Pipe()
process.standardOutput = outPipe
process.standardError = errPipe
process.standardInput = inPipe
// Frame writes come from three places (both pipe-reader queues and this thread's exit frame),
// so serialize them onto the one fd.
let writeLock = NSLock()
func send(_ frame: [String: Any]) {
guard let data = try? JSONSerialization.data(withJSONObject: frame) else { return }
var line = data
line.append(0x0A)
writeLock.lock()
_ = writeAll(line)
writeLock.unlock()
}
// Shared with the reader queues + terminationHandler; guarded because those run off-thread.
let stateLock = NSLock()
var stdoutOpen = true, stderrOpen = true, exited = false, exitCode: Int32 = 0
outPipe.fileHandleForReading.readabilityHandler = { handle in
let chunk = handle.availableData
if chunk.isEmpty {
stateLock.lock(); stdoutOpen = false; stateLock.unlock()
handle.readabilityHandler = nil
} else {
send([AgentWire.Exec.tagKey: AgentWire.Exec.stdout,
AgentWire.Exec.dataKey: chunk.base64EncodedString()])
}
}
errPipe.fileHandleForReading.readabilityHandler = { handle in
let chunk = handle.availableData
if chunk.isEmpty {
stateLock.lock(); stderrOpen = false; stateLock.unlock()
handle.readabilityHandler = nil
} else {
send([AgentWire.Exec.tagKey: AgentWire.Exec.stderr,
AgentWire.Exec.dataKey: chunk.base64EncodedString()])
}
}
process.terminationHandler = { proc in
stateLock.lock(); exited = true; exitCode = proc.terminationStatus; stateLock.unlock()
}
do {
try process.run()
} catch {
outPipe.fileHandleForReading.readabilityHandler = nil
errPipe.fileHandleForReading.readabilityHandler = nil
sendExecError("could not launch the command: \(error)")
return
}
var stdinClosed = false
func closeStdin() {
guard !stdinClosed else { return }
try? inPipe.fileHandleForWriting.close()
stdinClosed = true
}
func handleHostFrame(_ line: Data) {
guard
let parsed = try? JSONSerialization.jsonObject(with: line),
let frame = parsed as? [String: Any],
let tag = frame[AgentWire.Exec.tagKey] as? String
else { return }
switch tag {
case AgentWire.Exec.stdin:
if let b64 = frame[AgentWire.Exec.dataKey] as? String,
let bytes = Data(base64Encoded: b64), !stdinClosed
{
try? inPipe.fileHandleForWriting.write(contentsOf: bytes)
}
case AgentWire.Exec.stdinEOF:
closeStdin()
case AgentWire.Exec.signal:
if let n = frame[AgentWire.Exec.signalKey] as? Int {
kill(process.processIdentifier, Int32(n))
}
default:
break
}
}
// Any hostguest frames pipelined right after the request line are already buffered.
while let line = lines.nextLine() {
if !line.isEmpty { handleHostFrame(line) }
}
// Pump the connection until the process has exited AND both output pipes have drained. A short
// poll tick lets us notice the exit even when the host sends no more input frames.
var scratch = [UInt8](repeating: 0, count: 64 * 1024)
while true {
stateLock.lock()
let finished = exited && !stdoutOpen && !stderrOpen
stateLock.unlock()
if finished { break }
var pfd = pollfd(fd: fd, events: Int16(POLLIN), revents: 0)
let ready = poll(&pfd, 1, 200)
if ready > 0, (pfd.revents & Int16(POLLIN)) != 0 {
let n = read(fd, &scratch, scratch.count)
if n > 0 {
lines.append(Data(bytes: scratch, count: n))
while let line = lines.nextLine() {
if !line.isEmpty { handleHostFrame(line) }
}
} else if n == 0 {
// Host hung up kill the command and stop streaming.
kill(process.processIdentifier, SIGKILL)
break
} else if errno != EINTR && errno != EAGAIN {
kill(process.processIdentifier, SIGKILL)
break
}
}
}
closeStdin()
outPipe.fileHandleForReading.readabilityHandler = nil
errPipe.fileHandleForReading.readabilityHandler = nil
stateLock.lock(); let code = exitCode; stateLock.unlock()
send([AgentWire.Exec.tagKey: AgentWire.Exec.exit, AgentWire.Exec.codeKey: Int(code)])
}
/// Emit a single terminal `spawnError` frame (the exec never started).
private func sendExecError(_ message: String) {
guard
let data = try? JSONSerialization.data(withJSONObject: [
AgentWire.Exec.tagKey: AgentWire.Exec.spawnError,
AgentWire.Exec.messageKey: message,
])
else { return }
var line = data
line.append(0x0A)
_ = writeAll(line)
}
}
@@ -12,7 +12,44 @@ public enum AgentWire {
/// The Nucleic-reserved vsock port the agent listens on and the host connects to.
public static let port: UInt32 = 2035
/// Protocol version reported in the `ping` reply, bumped on incompatible wire changes.
public static let version = 1
/// v2 adds the streaming ``Exec`` op the vsock replacement for the retired SSH exec path.
public static let version = 2
/// The streaming **`exec`** sub-protocol (docs/MACOS_VM_NATIVE_AGENT.md §4.4). Unlike every other
/// op (one request line one reply line), `exec` takes over its vsock connection for the life of
/// the command: after the single request line the connection carries a bidirectional stream of
/// one-JSON-object-per-line *frames*, each tagged by ``tagKey``. The host opens a **dedicated**
/// connection per exec, so a multi-minute build never blocks the control channel and several
/// execs run concurrently (the listener is one thread per connection).
///
/// Payload bytes (`stdout`/`stderr`/`stdin`) are **base64** in ``dataKey`` so arbitrary binary
/// output can't break the NDJSON line framing. The connection closes after the ``exit`` (or
/// ``spawnError``) frame.
public enum Exec {
/// The request op: `{"op":"exec","command":<shell script>}`. The host composes any working
/// directory + env exports into `command` itself, so the guest just runs one script.
public static let op = "exec"
/// Field on the request line carrying the `/bin/zsh -c` script to run.
public static let commandKey = "command"
/// Common frame discriminator key.
public static let tagKey = "t"
// guest host
public static let stdout = "o" // {"t":"o","d":<base64 chunk>}
public static let stderr = "e" // {"t":"e","d":<base64 chunk>}
public static let exit = "x" // {"t":"x","code":<int>}
public static let spawnError = "err" // {"t":"err","m":<string>}
// host guest
public static let stdin = "i" // {"t":"i","d":<base64 chunk>}
public static let stdinEOF = "eof" // {"t":"eof"}
public static let signal = "sig" // {"t":"sig","n":<int>}
/// Frame field keys.
public static let dataKey = "d" // base64 payload (stdout/stderr/stdin frames)
public static let codeKey = "code" // exit status (exit frame)
public static let messageKey = "m" // human message (spawnError frame)
public static let signalKey = "n" // signal number (signal frame)
}
}
/// Accumulates raw bytes and yields complete newline-terminated lines the NDJSON framing both
+36 -56
View File
@@ -59,65 +59,45 @@ fi
echo "▸ This uses sudo for system settings — you may be prompted for the $AGENT_USER password."
sudo -v
# ── Phase 1: enable Remote Login (sshd) ──────────────────────────────────────────────────────────
# Nucleic reaches the guest over SSH; without this the base is unreachable. On recent macOS,
# `systemsetup -setremotelogin on` may require the caller (Terminal) to have Full Disk Access and can
# prompt interactively — if it fails, enable it by hand in System Settings ▸ General ▸ Sharing.
echo "▸ [1/8] Enabling Remote Login (sshd) …"
if sudo systemsetup -setremotelogin on 2>/dev/null; then
echo " ✓ Remote Login on."
# ── Phases 12: legacy SSH access (OFF by default — the control plane is vsock) ───────────────────
# Nucleic reaches the guest entirely over vsock now (exec + computer-use via the native in-guest
# agent installed in Phase 7d); Remote Login and an authorized SSH key are no longer part of the
# control plane and are NOT enabled. Set NUCLEIC_PROVISION_ENABLE_SSH=1 to opt back into the old
# SSH-over-NAT surface (e.g. for manual debugging) — then a public key may be passed as $1.
if [ "${NUCLEIC_PROVISION_ENABLE_SSH:-0}" = "1" ]; then
echo "▸ [1/8] Enabling Remote Login (sshd) [opt-in] …"
if sudo systemsetup -setremotelogin on 2>/dev/null; then
echo " ✓ Remote Login on."
else
echo " ⚠ Could not toggle Remote Login via systemsetup (needs Full Disk Access for Terminal)." >&2
fi
echo "▸ [2/8] Installing Nucleic's host public key into $AGENT_HOME/.ssh/authorized_keys …"
PUBKEY_SRC=""
if [ -n "$PUBKEY_ARG" ] && [ -f "$PUBKEY_ARG" ]; then
PUBKEY_SRC="$PUBKEY_ARG"
else
for cand in \
"$(cd "$(dirname "$0")" && pwd)/id_ed25519.pub" \
"$AGENT_HOME/id_ed25519.pub" \
"$SHARED_WORKSPACE/id_ed25519.pub"; do
[ -f "$cand" ] && { PUBKEY_SRC="$cand"; break; }
done
fi
if [ -n "$PUBKEY_SRC" ] && grep -Eq '^(ssh-ed25519|ssh-rsa|ecdsa-|sk-) ' "$PUBKEY_SRC"; then
SSH_DIR="$AGENT_HOME/.ssh"; AUTH_KEYS="$SSH_DIR/authorized_keys"
mkdir -p "$SSH_DIR"; touch "$AUTH_KEYS"
KEY_LINE="$(tr -d '\r' < "$PUBKEY_SRC")"
grep -qxF "$KEY_LINE" "$AUTH_KEYS" || printf '%s\n' "$KEY_LINE" >> "$AUTH_KEYS"
chmod 700 "$SSH_DIR"; chmod 600 "$AUTH_KEYS"; sudo chown -R "$AGENT_USER":staff "$SSH_DIR"
echo " ✓ key authorized."
else
echo " ⚠ NUCLEIC_PROVISION_ENABLE_SSH=1 but no valid public key found — skipping key authorization." >&2
fi
else
echo " ⚠ Could not toggle Remote Login via systemsetup (needs Full Disk Access for Terminal, or a"
echo " GUI prompt). Enable it manually: System Settings ▸ General ▸ Sharing ▸ Remote Login." >&2
echo "▸ [12/8] Skipping Remote Login + SSH key (vsock-only base; set NUCLEIC_PROVISION_ENABLE_SSH=1 to opt in)."
fi
# ── Phase 2: authorize Nucleic's host public key ─────────────────────────────────────────────────
echo "▸ [2/8] Installing Nucleic's host public key into $AGENT_HOME/.ssh/authorized_keys …"
PUBKEY_SRC=""
if [ -n "$PUBKEY_ARG" ] && [ -f "$PUBKEY_ARG" ]; then
PUBKEY_SRC="$PUBKEY_ARG"
else
# Look in a few obvious places the operator may have staged the key.
for cand in \
"$(cd "$(dirname "$0")" && pwd)/id_ed25519.pub" \
"$(cd "$(dirname "$0")" && pwd)/nucleic-id_ed25519.pub" \
"$AGENT_HOME/id_ed25519.pub" \
"$AGENT_HOME/nucleic-id_ed25519.pub" \
"$SHARED_WORKSPACE/id_ed25519.pub"; do
[ -f "$cand" ] && { PUBKEY_SRC="$cand"; break; }
done
fi
if [ -z "$PUBKEY_SRC" ]; then
echo "✗ No Nucleic public key found. Pass it as the first argument, e.g." >&2
echo " ./provision-macos-guest.sh /path/to/id_ed25519.pub" >&2
echo " The host prints its path (…/Nucleic/macvms/ssh/id_ed25519.pub) in build-macos-base.sh." >&2
exit 1
fi
# Basic shape check so we don't authorize garbage.
if ! grep -Eq '^(ssh-ed25519|ssh-rsa|ecdsa-|sk-) ' "$PUBKEY_SRC"; then
echo "$PUBKEY_SRC doesn't look like an OpenSSH public key." >&2
exit 1
fi
SSH_DIR="$AGENT_HOME/.ssh"
AUTH_KEYS="$SSH_DIR/authorized_keys"
mkdir -p "$SSH_DIR"
touch "$AUTH_KEYS"
# Idempotent: only append if this exact key isn't already authorized.
KEY_LINE="$(tr -d '\r' < "$PUBKEY_SRC")"
if grep -qxF "$KEY_LINE" "$AUTH_KEYS"; then
echo " ✓ key already authorized."
else
printf '%s\n' "$KEY_LINE" >> "$AUTH_KEYS"
echo " ✓ key appended."
fi
# Ownership + perms sshd insists on (StrictModes): 700 dir, 600 file, owned by agent.
chmod 700 "$SSH_DIR"
chmod 600 "$AUTH_KEYS"
sudo chown -R "$AGENT_USER":staff "$SSH_DIR"
# ── Phase 3: Xcode Command Line Tools (the minimum toolchain) ────────────────────────────────────
# The CLT give us clang, git, and (crucially) `xcodebuild`/`xcrun`/`simctl`. FULL Xcode + iOS
# simulators are a separate, larger, Apple-ID-gated download — automated below via `xcodes` but left
+74 -6
View File
@@ -786,6 +786,33 @@
.ncard p{margin:0;color:var(--ink-soft);font-size:13.5px;line-height:1.55}
.native .foot-note{margin:22px 0 0;font-family:var(--mono);font-size:12px;color:var(--ink-faint);text-align:center}
/* ---------- virtualization (the isolation pillar) ---------- */
.virt{padding:56px 0 16px}
.virt .head{max-width:56ch;margin:0 0 30px}
.virt .head h2{font-size:clamp(24px,3.4vw,34px);font-weight:600;letter-spacing:-.025em;margin:8px 0 12px}
.virt .head h2 .em{
background:linear-gradient(100deg,var(--rust),var(--pink) 45%,var(--magenta) 70%,var(--blue));
-webkit-background-clip:text;background-clip:text;color:transparent;
}
.virt .head p{margin:0;color:var(--ink-soft);font-size:15.5px;max-width:54ch}
/* Two-up cards — bigger than the plain feature tiles, to read as an architectural pillar. */
.virt-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:14px}
@media (max-width:760px){.virt-grid{grid-template-columns:1fr}}
.vcard{
position:relative;border:1px solid var(--line);border-radius:16px;background:var(--panel);
padding:24px 24px 26px;overflow:hidden;
}
/* a faint corner wash so the pillar reads a touch richer than the plain cards */
.vcard::after{
content:"";position:absolute;inset:0;pointer-events:none;border-radius:inherit;
background:radial-gradient(120% 130% at 100% 0%, color-mix(in srgb,var(--blue) 7%,transparent), transparent 46%);
}
.vcard .ic{width:26px;height:26px;display:block;margin-bottom:15px}
.vcard h3{font-size:17px;font-weight:600;letter-spacing:-.01em;margin:0 0 7px}
.vcard p{margin:0;color:var(--ink-soft);font-size:14px;line-height:1.6}
.vcard code{font-family:var(--mono);font-size:.88em;color:var(--ink);background:var(--panel-2);border-radius:5px;padding:1px 5px}
.virt .foot-note{margin:24px 0 0;font-family:var(--mono);font-size:12px;color:var(--ink-faint);text-align:center}
/* ---------- trust line (inside the access card) ---------- */
.panel-trust{max-width:80ch;margin:22px auto 0;font-size:12.5px;color:var(--ink-soft);letter-spacing:.01em;line-height:1.6}
@@ -847,6 +874,7 @@
<span class="tag">Beta</span>
</div>
<nav class="nav">
<a class="lk" href="/support.html">Support</a>
<a class="lk" id="nav-ip" href="/iphone.html">iPhone&nbsp;app</a>
<div id="nav-dl" class="dl">
<a class="btn" href="https://updates.nucleic.blakeslee.xyz/beta/latest" target="_blank" rel="noopener noreferrer">Download for Apple&nbsp;Silicon</a>
@@ -1481,6 +1509,45 @@
</div>
</section>
<!-- ===== virtualization ===== -->
<section class="virt" id="virtualization">
<div class="wrap">
<div class="head">
<div class="kicker">Built on virtualization</div>
<h2>Every agent, its own <span class="em">virtual machine</span>.</h2>
<p>Isolation in Nucleic isn't a sandbox profile or a wrapper script — it's virtualization, all the way down. Agents run inside genuine Linux and macOS guests, booted inprocess on Apple's own Virtualization framework. Nothing shares your host's state, and nothing is left running once you quit.</p>
</div>
<div class="virt-grid">
<div class="vcard">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="var(--green)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l8 4.5v9L12 21l-8-4.5v-9z"/><path d="M12 12l8-4.5M12 12v9M12 12L4 7.5"/></svg>
<h3>Native Linux sandboxes</h3>
<p>Each Control session runs in a lightweight Linux VM, built on Apple's Virtualization and Containerization frameworks — the same foundation as Apple's own <code>container</code> CLI. It's daemonless and inprocess: no background service to babysit, and when Nucleic quits, every sandbox is gone.</p>
</div>
<div class="vcard">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="var(--rust)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4.5" width="18" height="12" rx="2"/><path d="M9 20h6M12 16.5V20"/><path d="M7.5 9.5l2 2-2 2M12 13.5h4"/></svg>
<h3>A whole Mac, virtualized</h3>
<p>Hand an agent an entire macOS guest of its own. It builds in Xcode, runs the Simulator, codesigns, even clicks through a live UI — all inside a disposable VM that never touches your host machine.</p>
</div>
<div class="vcard">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="var(--magenta)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/></svg>
<h3>Instant, disposable clones</h3>
<p>Guests spin up as copyonwrite clones of a golden image — booting in seconds and costing almost no disk. Parallel agents each get a fresh environment; none can see or corrupt another's, and any one can be thrown away without a trace.</p>
</div>
<div class="vcard">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="var(--blue)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="9" width="7" height="6" rx="1.5"/><rect x="14.5" y="9" width="7" height="6" rx="1.5"/><path d="M9.5 12h5"/></svg>
<h3>A silent control plane</h3>
<p>Nucleic reaches each sandbox over <code>virtiovsock</code>, a direct VM channel that skips the network stack entirely. No open ports, no firewall or localnetwork prompts — approvals and git events ride a private hosttoguest link.</p>
</div>
</div>
<p class="foot-note">Apple&nbsp;Virtualization.framework <span class="dotsep">·</span> Containerization <span class="dotsep">·</span> virtiovsock <span class="dotsep">·</span> APFS copyonwrite clones <span class="dotsep">·</span> daemonless &amp; inprocess</p>
</div>
</section>
<!-- ===== native to Apple ===== -->
<section class="native" id="native">
<div class="wrap">
@@ -1496,14 +1563,14 @@
<p>Apple&nbsp;Intelligence runs local tasks — classifying turns, summarizing tool calls, auto-triaging your idea inbox — privately, right on your Mac.</p>
</div>
<div class="ncard">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="var(--green)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l8 4.5v9L12 21l-8-4.5v-9z"/><path d="M12 12l8-4.5M12 12v9M12 12L4 7.5"/></svg>
<h3>Native sandboxing</h3>
<p>Sandboxed agents run in real, lightweight VMs — natively, with no extra daemons.</p>
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="var(--green)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="10" width="14" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/><circle cx="12" cy="15" r="1.3"/></svg>
<h3>Secured by hardware</h3>
<p>Device keys live in the Secure&nbsp;Enclave and Keychain — never exported. Pairing and approvals are signed by hardware only you hold.</p>
</div>
<div class="ncard">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="var(--rust)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4.5" width="18" height="12" rx="2"/><path d="M9 20h6M12 16.5V20"/><path d="M7.5 9.5l2 2-2 2M12 13.5h4"/></svg>
<h3>macOS orchestration</h3>
<p>Give an agent a whole Mac of its own. Nucleic spins up an isolated macOS VM where it clicks, types, and runs apps — never touching your host machine.</p>
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="var(--rust)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="6" rx="7" ry="3"/><path d="M5 6v6c0 1.7 3.1 3 7 3s7-1.3 7-3V6"/><path d="M5 12v6c0 1.7 3.1 3 7 3s7-1.3 7-3v-6"/></svg>
<h3>Localfirst, always</h3>
<p>Your code, transcripts, and history stay on your Mac in SQLite and plain text — no cloud sync, nothing to leak, and it works fully offline.</p>
</div>
</div>
<p class="foot-note">macOS&nbsp;27 (public beta) on Apple&nbsp;Silicon <span class="dotsep">·</span> native iPhone remote <span class="dotsep">·</span> signed &amp; notarized</p>
@@ -1583,6 +1650,7 @@
Nucleic
</div>
<div class="foot-links">
<a href="/support.html">Support</a>
<a href="/trusted-tester.html">Trusted testers</a>
<a href="/privacy.html">Privacy</a>
<a href="/terms.html">Terms</a>
+2
View File
@@ -437,6 +437,7 @@
<span class="tag">iPhone</span>
</a>
<nav class="nav">
<a class="lk" href="/support.html">Support</a>
<a class="lk" id="nav-mac" href="/">Mac app</a>
<a class="btn appstore" id="nav-dl" href="https://updates.nucleic.blakeslee.xyz/testflight/beta" target="_blank" rel="noopener noreferrer" title="Join the Nucleic beta on TestFlight">
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#tf-icon"/></svg>
@@ -755,6 +756,7 @@
Nucleic
</a>
<div class="foot-links">
<a href="/support.html">Support</a>
<a href="/privacy.html">Privacy</a>
<a href="/terms.html">Terms</a>
</div>
+2
View File
@@ -175,6 +175,7 @@
Nucleic
</a>
<nav class="nav">
<a class="lk" href="/support.html">Support</a>
<a class="lk" href="/terms.html">Terms</a>
<a class="btn" href="/">Home</a>
</nav>
@@ -384,6 +385,7 @@
</div>
<div class="foot-links">
<a href="/">Home</a>
<a href="/support.html">Support</a>
<a href="/privacy.html">Privacy</a>
<a href="/terms.html">Terms</a>
</div>
+265
View File
@@ -0,0 +1,265 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Support — Nucleic</title>
<meta name="description" content="Help and how-to guides for Nucleic: Nucleic Control, sandboxes, and macOS virtual machines — including setting up AX-based computer use for macOS VMs." />
<meta name="theme-color" content="#faf7f3" />
<meta name="robots" content="index, follow" />
<!-- atom mark, reused as favicon -->
<link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><g fill='none' stroke-width='7'><ellipse cx='50' cy='50' rx='17' ry='40' transform='rotate(80 50 50)' stroke='%237AA7DD'/><ellipse cx='50' cy='50' rx='17' ry='40' transform='rotate(20 50 50)' stroke='%23DF619B'/><ellipse cx='50' cy='50' rx='17' ry='40' transform='rotate(140 50 50)' stroke='%23C5826F'/></g></svg>" />
<!-- Self-hosted fonts (latin subset). The woff2 binaries live in fonts/ and are
preloaded so they download in parallel with the HTML; @font-face is inlined
(no render-blocking stylesheet) and the page never contacts Google. -->
<link rel="preload" href="fonts/space-grotesk.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="fonts/jetbrains-mono.woff2" as="font" type="font/woff2" crossorigin />
<style>
@font-face{font-family:'Space Grotesk';font-style:normal;font-weight:400 700;font-display:swap;src:url(fonts/space-grotesk.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'JetBrains Mono';font-style:normal;font-weight:400 500;font-display:swap;src:url(fonts/jetbrains-mono.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
</style>
<style>
:root{
--ink:#181520;
--ink-soft:#5b5560;
--ink-faint:#8b8590;
--paper:#faf7f3;
--panel:#fffdfb;
--panel-2:#f3ede6;
--line:#e6ded4;
--line-2:#efe9e1;
--blue:#5e9bd8;
--pink:#db5f97;
--magenta:#c264ad;
--rust:#c47e69;
--green:#5fa775;
--amber:#d7a23f;
--sans:"Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
--mono:"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
--maxw:1080px;
--prose:760px;
}
*{box-sizing:border-box}
html{scroll-behavior:smooth}
body{
margin:0;
overflow-x:clip;
font-family:var(--sans);
color:var(--ink);
background:var(--paper);
background-image:radial-gradient(var(--line) 1px, transparent 1px);
background-size:26px 26px;
background-position:-13px -13px;
-webkit-font-smoothing:antialiased;
line-height:1.5;
}
a{color:inherit}
.wrap{max-width:var(--maxw);margin:0 auto;padding:0 24px}
.mono{font-family:var(--mono)}
.kicker{
font-family:var(--mono);
font-size:11.5px;letter-spacing:.16em;text-transform:uppercase;
color:var(--ink-faint);
}
/* ---------- top bar ---------- */
header{
position:sticky;top:0;z-index:20;
backdrop-filter:saturate(1.1) blur(8px);
background:color-mix(in srgb, var(--paper) 82%, transparent);
border-bottom:1px solid var(--line-2);
}
.bar{display:flex;align-items:center;justify-content:space-between;height:62px}
.brand{display:flex;align-items:center;gap:11px;font-weight:600;letter-spacing:-.01em;font-size:17px;text-decoration:none;color:var(--ink)}
.brand .mk{width:26px;height:26px;display:block}
.nav{display:flex;align-items:center;gap:22px;font-size:14px;color:var(--ink-soft)}
.nav a{text-decoration:none}
.nav .lk:hover{color:var(--ink)}
.btn{
font-family:var(--sans);font-size:14px;font-weight:500;
text-decoration:none;cursor:pointer;border:0;
padding:9px 16px;border-radius:10px;
color:#fff;background:var(--ink);
transition:transform .12s ease, opacity .12s ease;
white-space:nowrap;-webkit-tap-highlight-color:transparent;
}
.btn:link,.btn:visited,.btn:hover,.btn:focus,.btn:active{color:#fff}
.btn:hover{opacity:.9;transform:translateY(-1px)}
@media (max-width:600px){ .nav .lk{display:none} }
/* ---------- doc header ---------- */
.doc-hero{padding:60px 0 26px}
.doc-hero .wrap{max-width:var(--prose)}
.doc-hero h1{
font-size:clamp(32px,5vw,48px);line-height:1.04;letter-spacing:-.03em;
font-weight:600;margin:14px 0 0;
}
.doc-hero h1 .em{
background:linear-gradient(100deg,var(--rust),var(--pink) 45%,var(--magenta) 70%,var(--blue));
-webkit-background-clip:text;background-clip:text;color:transparent;
}
.doc-hero .lede{max-width:var(--prose);margin:16px 0 0;font-size:17px;color:var(--ink-soft);line-height:1.6}
/* ---------- support hub ---------- */
main{padding:10px 0 30px}
.hub{max-width:var(--prose);margin:0 auto}
.sup-group{padding:26px 0;border-top:1px solid var(--line-2)}
.sup-group:first-child{border-top:0}
.sup-group .g-head{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;margin:0 0 4px}
.sup-group h2{font-size:21px;font-weight:600;letter-spacing:-.02em;margin:0}
.sup-group .g-sub{color:var(--ink-soft);font-size:15px;margin:6px 0 16px;line-height:1.6}
.art-grid{display:grid;gap:12px}
.art-card{
display:block;text-decoration:none;color:inherit;
border:1px solid var(--line);border-radius:14px;background:var(--panel);
padding:16px 18px;transition:border-color .14s ease, transform .14s ease, box-shadow .14s ease;
}
.art-card:hover{border-color:color-mix(in srgb,var(--magenta) 45%,var(--line));transform:translateY(-1px);box-shadow:0 6px 22px -14px rgba(24,21,32,.28)}
.art-card .t{display:flex;align-items:center;gap:9px;font-size:16px;font-weight:600;letter-spacing:-.01em}
.art-card .t .arw{margin-left:auto;color:var(--ink-faint);transition:transform .14s ease, color .14s ease}
.art-card:hover .t .arw{transform:translateX(3px);color:var(--magenta)}
.art-card p{margin:7px 0 0;color:var(--ink-soft);font-size:14px;line-height:1.55}
.art-card .badge{font-family:var(--mono);font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--magenta);border:1px solid color-mix(in srgb,var(--magenta) 40%,var(--line));border-radius:6px;padding:1px 6px}
.help-cta{
max-width:var(--prose);margin:18px auto 0;
border:1px solid var(--line);border-radius:16px;background:
radial-gradient(120% 140% at 0% 0%, color-mix(in srgb,var(--blue) 8%,transparent), transparent 44%),
radial-gradient(120% 140% at 100% 100%, color-mix(in srgb,var(--pink) 8%,transparent), transparent 44%),
var(--panel);
padding:20px 24px;display:flex;align-items:center;gap:16px;flex-wrap:wrap;
}
.help-cta div{flex:1 1 260px}
.help-cta h2{margin:0 0 4px;font-size:16px;font-weight:600;letter-spacing:-.01em}
.help-cta p{margin:0;color:var(--ink-soft);font-size:14px;line-height:1.55}
.help-cta a{color:var(--ink);font-weight:500}
footer{border-top:1px solid var(--line-2);padding:30px 0 50px;margin-top:20px}
.foot{display:flex;align-items:center;justify-content:space-between;gap:18px;flex-wrap:wrap;color:var(--ink-faint);font-size:13px}
.foot .mono{font-size:12px}
.foot-links{display:flex;gap:18px;flex-wrap:wrap}
.foot-links a{text-decoration:none;color:var(--ink-soft)}
.foot-links a:hover{color:var(--ink)}
.dotsep{color:var(--line)}
</style>
</head>
<body>
<!-- ===== top bar ===== -->
<header>
<div class="wrap bar">
<a class="brand" href="/">
<svg class="mk" viewBox="0 0 100 100" aria-hidden="true">
<g fill="none" stroke-width="7">
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(80 50 50)" stroke="#7AA7DD"/>
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(20 50 50)" stroke="#DF619B"/>
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(140 50 50)" stroke="#C5826F"/>
</g>
</svg>
Nucleic
</a>
<nav class="nav">
<a class="lk" href="/">Home</a>
<a class="btn" href="https://updates.nucleic.blakeslee.xyz/beta/latest" target="_blank" rel="noopener noreferrer">Download</a>
</nav>
</div>
</header>
<!-- ===== title ===== -->
<section class="doc-hero">
<div class="wrap">
<div class="kicker">Support</div>
<h1>Nucleic <span class="em">Help Center</span></h1>
<p class="lede">How-to guides for running agents safely — isolating them in Nucleic Control, sandboxing their work, and giving each one a whole macOS virtual machine of its own. Pick a topic below.</p>
</div>
</section>
<main>
<div class="wrap hub">
<div class="sup-group">
<div class="g-head"><h2>Nucleic Control</h2></div>
<p class="g-sub">The hardened path where Nucleic clones, sandboxes, and observes a project directly — the foundation for unattended work, Autoship, and Orchestra.</p>
<div class="art-grid">
<a class="art-card" href="/support/nucleic-control.html">
<div class="t">What is Nucleic Control?<span class="arw" aria-hidden="true">&rarr;</span></div>
<p>What Control does, how to turn on the container service, and what it unlocks — sandboxed sessions, certain git observation, Autoship, and Orchestra.</p>
</a>
</div>
</div>
<div class="sup-group">
<div class="g-head"><h2>Sandboxes</h2></div>
<p class="g-sub">Every sandboxed session runs in its own lightweight Linux VM, built on Apple's containerization framework — isolated from your host and from other sessions.</p>
<div class="art-grid">
<a class="art-card" href="/support/sandboxes.html">
<div class="t">How agent sandboxing works<span class="arw" aria-hidden="true">&rarr;</span></div>
<p>What the sandbox is, what it isolates, the per-project options (idle timeout, host&nbsp;exec, custom images), and why the Swift toolchain lives in a VM instead.</p>
</a>
</div>
</div>
<div class="sup-group">
<div class="g-head"><h2>Virtual Machines</h2></div>
<p class="g-sub">Give an agent a whole Mac of its own — a per-session macOS VM for Xcode, the simulators, and codesign, so parallel Mac builds never collide on your host.</p>
<div class="art-grid">
<a class="art-card" href="/support/virtual-machines.html">
<div class="t">macOS virtual machines: overview<span class="arw" aria-hidden="true">&rarr;</span></div>
<p>What macOS VMs are, the hardware and disk requirements, how per-session VMs are cloned and torn down, and how many run at once.</p>
</a>
<a class="art-card" href="/support/macos-vm-base-image.html">
<div class="t">Building the base image<span class="arw" aria-hidden="true">&rarr;</span></div>
<p>The one-time golden base install: what the build does, choosing a guest macOS version, and pointing at a prebuilt bundle or a specific restore image.</p>
</a>
<a class="art-card" href="/support/macos-vm-computer-use.html">
<div class="t">Computer use for macOS VMs<span class="arw" aria-hidden="true">&rarr;</span></div>
<p>Let an agent see and drive the VM's screen — clicks, keys, and scrolling. The default path runs host-side with no in-guest setup and no permissions.</p>
</a>
<a class="art-card" href="/support/macos-vm-ax-agent.html">
<div class="t">Setting up AX-based computer use<span class="badge">Advanced</span><span class="arw" aria-hidden="true">&rarr;</span></div>
<p>The optional in-guest agent for semantic control — act on a UI element by identity, and drive app windows on a macOS&nbsp;26 guest. Covers the one-time SIP step.</p>
</a>
</div>
</div>
<div class="help-cta">
<div>
<h2>Can't find what you need?</h2>
<p>These guides grow alongside Nucleic. If something is missing or unclear, email <a href="mailto:[email protected]">[email protected]</a> and we'll help.</p>
</div>
</div>
</div>
</main>
<footer>
<div class="wrap foot">
<div class="brand" style="font-size:15px">
<svg class="mk" viewBox="0 0 100 100" aria-hidden="true">
<g fill="none" stroke-width="7">
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(80 50 50)" stroke="#7AA7DD"/>
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(20 50 50)" stroke="#DF619B"/>
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(140 50 50)" stroke="#C5826F"/>
</g>
</svg>
Nucleic
</div>
<div class="foot-links">
<a href="/">Home</a>
<a href="/support.html">Support</a>
<a href="/privacy.html">Privacy</a>
<a href="/terms.html">Terms</a>
</div>
<div class="mono">© 2026 Andrew Blakeslee&nbsp;Moore · Made with Nucleic</div>
</div>
</footer>
</body>
</html>
+361
View File
@@ -0,0 +1,361 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AX-based computer use for macOS VMs — Nucleic Support</title>
<meta name="description" content="Set up the optional in-guest Accessibility agent for Nucleic's macOS VMs: semantic control by UI element, framebuffer-independent screenshots, and the one-time SIP step." />
<meta name="theme-color" content="#faf7f3" />
<meta name="robots" content="index, follow" />
<!-- atom mark, reused as favicon -->
<link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><g fill='none' stroke-width='7'><ellipse cx='50' cy='50' rx='17' ry='40' transform='rotate(80 50 50)' stroke='%237AA7DD'/><ellipse cx='50' cy='50' rx='17' ry='40' transform='rotate(20 50 50)' stroke='%23DF619B'/><ellipse cx='50' cy='50' rx='17' ry='40' transform='rotate(140 50 50)' stroke='%23C5826F'/></g></svg>" />
<link rel="preload" href="../fonts/space-grotesk.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="../fonts/jetbrains-mono.woff2" as="font" type="font/woff2" crossorigin />
<style>
@font-face{font-family:'Space Grotesk';font-style:normal;font-weight:400 700;font-display:swap;src:url(../fonts/space-grotesk.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'JetBrains Mono';font-style:normal;font-weight:400 500;font-display:swap;src:url(../fonts/jetbrains-mono.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
</style>
<style>
:root{
--ink:#181520;--ink-soft:#5b5560;--ink-faint:#8b8590;
--paper:#faf7f3;--panel:#fffdfb;--panel-2:#f3ede6;--line:#e6ded4;--line-2:#efe9e1;
--blue:#5e9bd8;--pink:#db5f97;--magenta:#c264ad;--rust:#c47e69;--green:#5fa775;--amber:#d7a23f;
--sans:"Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
--mono:"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
--maxw:1080px;--prose:760px;
}
*{box-sizing:border-box}
html{scroll-behavior:smooth}
body{
margin:0;overflow-x:clip;font-family:var(--sans);color:var(--ink);background:var(--paper);
background-image:radial-gradient(var(--line) 1px, transparent 1px);
background-size:26px 26px;background-position:-13px -13px;
-webkit-font-smoothing:antialiased;line-height:1.5;
}
a{color:inherit}
.wrap{max-width:var(--maxw);margin:0 auto;padding:0 24px}
.mono{font-family:var(--mono)}
.kicker{font-family:var(--mono);font-size:11.5px;letter-spacing:.16em;text-transform:uppercase;color:var(--ink-faint)}
/* ---------- top bar ---------- */
header{position:sticky;top:0;z-index:20;backdrop-filter:saturate(1.1) blur(8px);background:color-mix(in srgb, var(--paper) 82%, transparent);border-bottom:1px solid var(--line-2)}
.bar{display:flex;align-items:center;justify-content:space-between;height:62px}
.brand{display:flex;align-items:center;gap:11px;font-weight:600;letter-spacing:-.01em;font-size:17px;text-decoration:none;color:var(--ink)}
.brand .mk{width:26px;height:26px;display:block}
.nav{display:flex;align-items:center;gap:22px;font-size:14px;color:var(--ink-soft)}
.nav a{text-decoration:none}
.nav .lk:hover{color:var(--ink)}
.btn{font-family:var(--sans);font-size:14px;font-weight:500;text-decoration:none;cursor:pointer;border:0;padding:9px 16px;border-radius:10px;color:#fff;background:var(--ink);transition:transform .12s ease, opacity .12s ease;white-space:nowrap;-webkit-tap-highlight-color:transparent}
.btn:link,.btn:visited,.btn:hover,.btn:focus,.btn:active{color:#fff}
.btn:hover{opacity:.9;transform:translateY(-1px)}
@media (max-width:600px){ .nav .lk{display:none} }
/* ---------- doc header ---------- */
.crumbs{max-width:var(--prose);margin:0 auto;padding:26px 0 0;font-family:var(--mono);font-size:12px;color:var(--ink-faint);letter-spacing:.02em}
.crumbs a{text-decoration:none;color:var(--ink-soft)}
.crumbs a:hover{color:var(--ink)}
.crumbs .sep{color:var(--line);margin:0 8px}
.doc-hero{padding:16px 0 22px}
.doc-hero .wrap{max-width:var(--prose)}
.doc-hero h1{font-size:clamp(30px,4.6vw,44px);line-height:1.05;letter-spacing:-.03em;font-weight:600;margin:12px 0 0}
.doc-hero h1 .em{background:linear-gradient(100deg,var(--rust),var(--pink) 45%,var(--magenta) 70%,var(--blue));-webkit-background-clip:text;background-clip:text;color:transparent}
.doc-hero .lede{max-width:var(--prose);margin:16px 0 0;font-size:17px;color:var(--ink-soft);line-height:1.62}
/* ---------- on this page ---------- */
.toc{max-width:var(--prose);margin:22px auto 0;border:1px solid var(--line);border-radius:14px;background:var(--panel);padding:16px 20px}
.toc h2{margin:0 0 10px;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ink-faint);font-weight:600;font-family:var(--mono)}
.toc ol{margin:0;padding-left:0;list-style:none;counter-reset:toc;display:grid;gap:7px}
.toc li{counter-increment:toc;position:relative;padding-left:28px;font-size:14.5px}
.toc li::before{content:counter(toc,decimal-leading-zero);position:absolute;left:0;top:1px;font-family:var(--mono);font-size:11px;color:var(--ink-faint)}
.toc a{text-decoration:none;color:var(--ink-soft)}
.toc a:hover{color:var(--ink)}
/* ---------- prose ---------- */
main{padding:10px 0 30px}
.prose{max-width:var(--prose);margin:0 auto}
.prose section{padding:28px 0;border-top:1px solid var(--line-2)}
.prose h2{font-size:22px;font-weight:600;letter-spacing:-.02em;margin:0 0 6px;scroll-margin-top:80px}
.prose h2 .n{font-family:var(--mono);font-size:13px;color:var(--ink-faint);margin-right:10px;font-weight:500}
.prose h3{font-size:16px;font-weight:600;letter-spacing:-.01em;margin:22px 0 6px;color:var(--ink)}
.prose p{margin:12px 0;color:var(--ink-soft);font-size:15.5px;line-height:1.65}
.prose ul{margin:12px 0;padding-left:22px;color:var(--ink-soft);font-size:15.5px;line-height:1.65}
.prose li{margin:7px 0}
.prose strong{color:var(--ink);font-weight:600}
.prose a{color:var(--ink);text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}
.prose a:hover{text-decoration-color:var(--magenta)}
.prose code{font-family:var(--mono);font-size:.86em;background:var(--panel-2);border:1px solid var(--line-2);border-radius:5px;padding:1px 6px}
.prose .lede{font-size:17px;color:var(--ink);line-height:1.6}
.kbd{font-family:var(--mono);font-size:.82em;background:var(--panel);border:1px solid var(--line);border-bottom-width:2px;border-radius:6px;padding:1px 7px;color:var(--ink);white-space:nowrap}
.ui{font-weight:600;color:var(--ink)}
/* two-path table */
.ftable{width:100%;border-collapse:collapse;margin:16px 0;font-size:14px}
.ftable th,.ftable td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--line-2);vertical-align:top}
.ftable th{font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--ink-faint);font-weight:600}
.ftable td:first-child{font-weight:600;color:var(--ink);white-space:nowrap}
.ftable td:last-child{color:var(--ink-soft)}
/* callouts */
.callout{border-left:3px solid var(--line);border-radius:0 10px 10px 0;padding:14px 16px;margin:18px 0;font-size:14.5px;color:var(--ink-soft);line-height:1.6}
.callout strong{color:var(--ink)}
.callout code{font-family:var(--mono);font-size:.86em;background:var(--panel-2);border:1px solid var(--line-2);border-radius:5px;padding:1px 6px}
.callout.note{border-color:color-mix(in srgb,var(--blue) 60%,var(--line));background:color-mix(in srgb,var(--blue) 6%,var(--panel))}
.callout.tip{border-color:color-mix(in srgb,var(--green) 60%,var(--line));background:color-mix(in srgb,var(--green) 6%,var(--panel))}
.callout.warn{border-color:color-mix(in srgb,var(--amber) 70%,var(--line));background:color-mix(in srgb,var(--amber) 8%,var(--panel))}
.callout .lbl{display:block;font-size:11px;letter-spacing:.1em;text-transform:uppercase;font-family:var(--mono);margin-bottom:4px}
.callout.note .lbl{color:var(--blue)}
.callout.tip .lbl{color:var(--green)}
.callout.warn .lbl{color:color-mix(in srgb,var(--amber) 80%,var(--ink))}
/* numbered steps */
.steps{counter-reset:step;list-style:none;padding:0;margin:16px 0}
.steps>li{counter-increment:step;position:relative;padding:0 0 20px 46px;margin:0}
.steps>li::before{content:counter(step);position:absolute;left:0;top:-2px;width:30px;height:30px;border-radius:9px;background:var(--ink);color:#fff;font-family:var(--mono);font-size:13px;display:flex;align-items:center;justify-content:center}
.steps>li:not(:last-child)::after{content:"";position:absolute;left:14px;top:32px;bottom:6px;width:2px;background:var(--line)}
.steps>li h3{margin:4px 0 4px;font-size:16px}
.steps>li p{margin:6px 0}
/* code block */
pre{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 16px;overflow-x:auto;margin:14px 0}
pre code{font-family:var(--mono);font-size:13px;color:var(--ink);line-height:1.7;background:none;border:0;padding:0}
pre code .c{color:var(--ink-faint)}
/* next / related */
.related{max-width:var(--prose);margin:0 auto;padding:26px 0 0;border-top:1px solid var(--line-2)}
.related h2{font-size:12px;letter-spacing:.12em;text-transform:uppercase;color:var(--ink-faint);font-weight:600;font-family:var(--mono);margin:0 0 12px}
.related a{display:flex;align-items:center;gap:9px;text-decoration:none;color:var(--ink);border:1px solid var(--line);border-radius:12px;padding:13px 16px;margin:8px 0;font-weight:500;font-size:15px;transition:border-color .14s ease}
.related a:hover{border-color:color-mix(in srgb,var(--magenta) 45%,var(--line))}
.related a .arw{margin-left:auto;color:var(--ink-faint)}
footer{border-top:1px solid var(--line-2);padding:30px 0 50px;margin-top:24px}
.foot{display:flex;align-items:center;justify-content:space-between;gap:18px;flex-wrap:wrap;color:var(--ink-faint);font-size:13px}
.foot .mono{font-size:12px}
.foot-links{display:flex;gap:18px;flex-wrap:wrap}
.foot-links a{text-decoration:none;color:var(--ink-soft)}
.foot-links a:hover{color:var(--ink)}
.dotsep{color:var(--line)}
</style>
</head>
<body>
<!-- ===== top bar ===== -->
<header>
<div class="wrap bar">
<a class="brand" href="/">
<svg class="mk" viewBox="0 0 100 100" aria-hidden="true">
<g fill="none" stroke-width="7">
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(80 50 50)" stroke="#7AA7DD"/>
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(20 50 50)" stroke="#DF619B"/>
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(140 50 50)" stroke="#C5826F"/>
</g>
</svg>
Nucleic
</a>
<nav class="nav">
<a class="lk" href="/support.html">Support</a>
<a class="btn" href="/">Home</a>
</nav>
</div>
</header>
<div class="wrap">
<nav class="crumbs" aria-label="Breadcrumb">
<a href="/support.html">Support</a><span class="sep">/</span>
<a href="/support/virtual-machines.html">Virtual Machines</a><span class="sep">/</span>
AX-based computer use
</nav>
</div>
<!-- ===== title ===== -->
<section class="doc-hero">
<div class="wrap">
<div class="kicker">Virtual Machines · Advanced</div>
<h1>Setting up <span class="em">AX-based computer use</span></h1>
<p class="lede">By default, an agent drives a macOS VM host-side — screenshots and clicks with no setup. The optional in-guest agent adds <strong>semantic</strong> control: act on a button by identity instead of by pixel, and keep working when a guest's screenshots come back blank. This guide sets it up.</p>
</div>
</section>
<div class="wrap">
<nav class="toc" aria-label="On this page">
<h2>On this page</h2>
<ol>
<li><a href="#two-paths">Two ways to drive a VM</a></li>
<li><a href="#when">When you actually need this</a></li>
<li><a href="#what">What gets installed</a></li>
<li><a href="#before">Before you start</a></li>
<li><a href="#enable">Turn it on and build the base</a></li>
<li><a href="#sip">The one-time SIP step</a></li>
<li><a href="#verify">Verify it's working</a></li>
<li><a href="#model">How an agent uses it</a></li>
</ol>
</nav>
</div>
<main>
<div class="wrap prose">
<section id="two-paths">
<h2><span class="n">01</span>Two ways to drive a VM</h2>
<p class="lede">Nucleic can let an agent see and operate a macOS VM's screen through the <code>mac_vm_computer</code> tool. There are two paths, and they stack — the semantic path is an add-on to the default, not a replacement.</p>
<table class="ftable">
<thead><tr><th>Path</th><th>What it is</th></tr></thead>
<tbody>
<tr>
<td>Host-side<br>(default)</td>
<td>Screenshots are captured from the VM's virtual display and input is injected into its virtual keyboard and mouse — all <strong>host-side</strong>, in the Virtualization framework. No software runs inside the guest, no permissions are granted, and SIP stays on. This is <a href="/support/macos-vm-computer-use.html">computer use for macOS VMs</a>, and it needs no setup.</td>
</tr>
<tr>
<td>Semantic AX<br>(this guide)</td>
<td>An in-guest agent (<code>NucleicVMAgent</code>) exposes the guest's <strong>accessibility tree</strong> — every control's role, title, value, and frame — so an agent can act on an element <em>by identity</em>. It also captures screenshots from inside the guest, which keeps working when the host-side framebuffer is blank. This is the only path that needs the guest to grant permissions.</td>
</tr>
</tbody>
</table>
<div class="callout note"><span class="lbl">Good to know</span>Turning on the semantic agent doesn't take anything away. The agent still advertises the ordinary screenshot-and-click actions; it just <em>adds</em> the semantic ones on top.</div>
</section>
<section id="when">
<h2><span class="n">02</span>When you actually need this</h2>
<p>The default host-side path is enough for most GUI work. Reach for the semantic agent when one of these applies:</p>
<ul>
<li><strong>You want robust, semantic control.</strong> Instead of guessing pixel coordinates and clicking blind, the agent reads the accessibility tree and acts on a control by its identity — the way professional macOS UI automation works. It's far less fragile than pixel targeting.</li>
<li><strong>Your guest is macOS&nbsp;26 and app windows show up blank in screenshots.</strong> On a macOS&nbsp;26 (Tahoe) guest, a Virtualization-framework framebuffer bug can blank rendered app windows in pixel screenshots. The accessibility tree is produced from the app's semantic model, independent of what's on screen — so semantic control keeps working when the pixels don't.</li>
<li><strong>You're debugging a Mac or iOS app's UI</strong> and want the full accessibility tree — roles, values, focus state — the way a developer expects to inspect it.</li>
</ul>
<div class="callout tip"><span class="lbl">Rule of thumb</span>If your guest is macOS&nbsp;27 and screenshots look fine, the default path is simpler and needs no setup. Enable the semantic agent when you specifically want by-identity control or you're on a macOS&nbsp;26 guest.</div>
</section>
<section id="what">
<h2><span class="n">03</span>What gets installed</h2>
<p>Enabling the semantic agent adds one component to the golden base image: a small, signed app bundle called <strong><code>NucleicVMAgent</code></strong>. Inside each VM it runs as a per-user login agent in the guest's desktop session and listens on a private <code>vsock</code> channel for commands from your Mac. It drives the guest through three native macOS APIs:</p>
<ul>
<li><strong>Accessibility API</strong> — reads the accessibility tree and performs actions on elements (press a button, set a field's value, move focus).</li>
<li><strong>ScreenCaptureKit</strong> — captures screenshots from inside the guest.</li>
<li><strong>CGEvent</strong> — synthesizes raw mouse, keyboard, and scroll input.</li>
</ul>
<p>Because those APIs are privacy-sensitive, the guest account must grant the agent three permissions — <strong>Accessibility</strong>, <strong>Screen&nbsp;Recording</strong>, and input (post-event) rights. Nucleic writes these grants for you during provisioning; the only wrinkle is that on some guests it needs System Integrity Protection (SIP) turned off once to do so, which is the manual step below.</p>
</section>
<section id="before">
<h2><span class="n">04</span>Before you start</h2>
<p>Make sure the basics are in place first:</p>
<ul>
<li><strong>Apple silicon Mac running macOS&nbsp;26 or later.</strong> Running macOS guests requires Apple silicon.</li>
<li><strong>The macOS VM service is on.</strong> In <span class="ui">Settings ▸ Virtual Machines</span>, turn on <span class="ui">Enable macOS VM service</span>. See <a href="/support/virtual-machines.html">the overview</a> for requirements and disk space.</li>
<li><strong>Computer use is on.</strong> In the same section, turn on <span class="ui">Enable computer use (screen&nbsp;+&nbsp;mouse/keyboard)</span>. The semantic-agent toggle only appears once computer use is enabled.</li>
</ul>
<div class="callout warn"><span class="lbl">Heads up</span>The semantic agent is baked into the base image at build time. If you already built a base <em>without</em> it, you'll rebuild the base once after enabling the toggle — that's covered below.</div>
</section>
<section id="enable">
<h2><span class="n">05</span>Turn it on and build the base</h2>
<ol class="steps">
<li>
<h3>Enable the semantic agent</h3>
<p>In <span class="ui">Settings ▸ Virtual Machines</span>, with computer use already on, turn on <span class="ui">Semantic AX agent (advanced — needs SIP disabled once)</span>.</p>
</li>
<li>
<h3>Build (or rebuild) the base image</h3>
<p>Click <span class="ui">Build base image</span> — or <span class="ui">Rebuild / re-provision base image</span> if you already have one. This installs macOS into the golden base, provisions the dev toolchain, and — because the agent is now enabled — installs <code>NucleicVMAgent</code> and attempts to write its permission grants. See <a href="/support/macos-vm-base-image.html">Building the base image</a> for the full walkthrough.</p>
</li>
<li>
<h3>Check readiness</h3>
<p>When the build finishes, the base-image status tells you where you stand. If it reads <span class="ui">Ready — builds + computer use + semantic AX.</span>, you're done — skip to <a href="#verify">Verify</a>. If it still shows a one-time SIP step, continue below.</p>
</li>
</ol>
<div class="callout note"><span class="lbl">macOS 27 host + guest</span>On a macOS&nbsp;27 host with a macOS&nbsp;27 guest, provisioning is fully automated — the permission grants land during the one-click build and there's <strong>no SIP step</strong>. The manual step below is only needed on older guests.</div>
</section>
<section id="sip">
<h2><span class="n">06</span>The one-time SIP step</h2>
<p>On a macOS&nbsp;26 guest, writing the agent's permission grants requires System Integrity Protection to be disabled in the guest once. Nucleic can't script this — turning SIP off can only be done from recoveryOS — so it's a short manual pass. Nucleic shows these exact steps in-app when they're needed:</p>
<ol class="steps">
<li>
<h3>Boot the base into Recovery</h3>
<p>In the base-image status, click <span class="ui">Boot base in Recovery</span>. Nucleic boots the golden base into recoveryOS in a window.</p>
</li>
<li>
<h3>Disable SIP from the guest's Terminal</h3>
<p>In the VM, open <span class="ui">Utilities ▸ Terminal</span> and run:</p>
<pre><code>csrutil disable</code></pre>
</li>
<li>
<h3>Reboot, then shut the VM down</h3>
<p>Reboot the guest so the change takes effect, then shut the VM down and close the window.</p>
</li>
<li>
<h3>Build the base again</h3>
<p>Back in <span class="ui">Settings ▸ Virtual Machines</span>, click <span class="ui">Build base image</span> once more. Provisioning now detects that SIP is off and writes the agent's permission grants. That's the last step — the base is agent-ready.</p>
</li>
</ol>
<div class="callout tip"><span class="lbl">This affects the VM only</span>SIP is disabled <em>inside the disposable guest</em>, not on your Mac. Your host's security is untouched — the default host-side computer-use path never needs SIP at all.</div>
</section>
<section id="verify">
<h2><span class="n">07</span>Verify it's working</h2>
<p>The base-image status line is the quickest signal:</p>
<ul>
<li><span class="ui">Ready — builds + computer use + semantic AX.</span> — the agent is installed and fully granted. You're set.</li>
<li><span class="ui">Ready — builds + computer use.</span> — builds and host-side computer use work, but the semantic agent isn't ready yet. Re-check that the SIP step completed, then rebuild.</li>
</ul>
<p>If an agent gets blank screenshots or clicks that don't register once it's running, the usual causes are:</p>
<ul>
<li><strong>SIP still on in the guest.</strong> Confirm with <code>csrutil status</code> in the guest — it should say disabled.</li>
<li><strong>Grants didn't land.</strong> Rebuild the base after the SIP step so provisioning can write them.</li>
<li><strong>The guest isn't logged in.</strong> The agent runs in the desktop session, so the guest account must be logged in (not sitting at the login window). The base is configured to auto-log-in, so this normally just works.</li>
</ul>
</section>
<section id="model">
<h2><span class="n">08</span>How an agent uses it</h2>
<p>Once the semantic agent is present, the <code>mac_vm_computer</code> tool advertises both pixel actions (screenshot, click, type, key, scroll) and semantic ones. The typical loop is <strong>dump the tree, then act by identity</strong>:</p>
<pre><code><span class="c"># read the frontmost app's accessibility tree</span>
ax_dump → { role:"AXButton", title:"Build", ref:"e17", … }
<span class="c"># act on that element directly — no pixel guessing</span>
ax_press ref=e17</code></pre>
<p>Guidance the agent follows:</p>
<ul>
<li><strong>On a macOS&nbsp;26 guest, prefer <code>ax_dump</code> + act-by-ref</strong> — pixel screenshots may be blank, and the tree keeps working.</li>
<li><strong>Fall back to screenshot + pixel click</strong> when a control has no accessibility action or isn't in the tree.</li>
<li><strong>On a macOS&nbsp;27 guest, either path works</strong> — semantic is more robust, screenshot-first is simpler.</li>
</ul>
<p>You don't drive any of this by hand — this is what the agent does through the tool. Enabling the semantic path just makes the reliable, by-identity actions available to it.</p>
</section>
</div>
<div class="wrap related">
<h2>Related guides</h2>
<a href="/support/macos-vm-computer-use.html">Computer use for macOS VMs<span class="arw" aria-hidden="true">&rarr;</span></a>
<a href="/support/macos-vm-base-image.html">Building the base image<span class="arw" aria-hidden="true">&rarr;</span></a>
<a href="/support/virtual-machines.html">macOS virtual machines: overview<span class="arw" aria-hidden="true">&rarr;</span></a>
</div>
</main>
<footer>
<div class="wrap foot">
<div class="brand" style="font-size:15px">
<svg class="mk" viewBox="0 0 100 100" aria-hidden="true">
<g fill="none" stroke-width="7">
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(80 50 50)" stroke="#7AA7DD"/>
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(20 50 50)" stroke="#DF619B"/>
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(140 50 50)" stroke="#C5826F"/>
</g>
</svg>
Nucleic
</div>
<div class="foot-links">
<a href="/">Home</a>
<a href="/support.html">Support</a>
<a href="/privacy.html">Privacy</a>
<a href="/terms.html">Terms</a>
</div>
<div class="mono">© 2026 Andrew Blakeslee&nbsp;Moore · Made with Nucleic</div>
</div>
</footer>
</body>
</html>
+308
View File
@@ -0,0 +1,308 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Building the base image — Nucleic Support</title>
<meta name="description" content="The one-time golden base install every Nucleic macOS VM is cloned from: what the build does, choosing a guest macOS version, and pinning a specific restore image." />
<meta name="theme-color" content="#faf7f3" />
<meta name="robots" content="index, follow" />
<!-- atom mark, reused as favicon -->
<link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><g fill='none' stroke-width='7'><ellipse cx='50' cy='50' rx='17' ry='40' transform='rotate(80 50 50)' stroke='%237AA7DD'/><ellipse cx='50' cy='50' rx='17' ry='40' transform='rotate(20 50 50)' stroke='%23DF619B'/><ellipse cx='50' cy='50' rx='17' ry='40' transform='rotate(140 50 50)' stroke='%23C5826F'/></g></svg>" />
<link rel="preload" href="../fonts/space-grotesk.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="../fonts/jetbrains-mono.woff2" as="font" type="font/woff2" crossorigin />
<style>
@font-face{font-family:'Space Grotesk';font-style:normal;font-weight:400 700;font-display:swap;src:url(../fonts/space-grotesk.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'JetBrains Mono';font-style:normal;font-weight:400 500;font-display:swap;src:url(../fonts/jetbrains-mono.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
</style>
<style>
:root{
--ink:#181520;--ink-soft:#5b5560;--ink-faint:#8b8590;
--paper:#faf7f3;--panel:#fffdfb;--panel-2:#f3ede6;--line:#e6ded4;--line-2:#efe9e1;
--blue:#5e9bd8;--pink:#db5f97;--magenta:#c264ad;--rust:#c47e69;--green:#5fa775;--amber:#d7a23f;
--sans:"Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
--mono:"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
--maxw:1080px;--prose:760px;
}
*{box-sizing:border-box}
html{scroll-behavior:smooth}
body{
margin:0;overflow-x:clip;font-family:var(--sans);color:var(--ink);background:var(--paper);
background-image:radial-gradient(var(--line) 1px, transparent 1px);
background-size:26px 26px;background-position:-13px -13px;
-webkit-font-smoothing:antialiased;line-height:1.5;
}
a{color:inherit}
.wrap{max-width:var(--maxw);margin:0 auto;padding:0 24px}
.mono{font-family:var(--mono)}
.kicker{font-family:var(--mono);font-size:11.5px;letter-spacing:.16em;text-transform:uppercase;color:var(--ink-faint)}
/* ---------- top bar ---------- */
header{position:sticky;top:0;z-index:20;backdrop-filter:saturate(1.1) blur(8px);background:color-mix(in srgb, var(--paper) 82%, transparent);border-bottom:1px solid var(--line-2)}
.bar{display:flex;align-items:center;justify-content:space-between;height:62px}
.brand{display:flex;align-items:center;gap:11px;font-weight:600;letter-spacing:-.01em;font-size:17px;text-decoration:none;color:var(--ink)}
.brand .mk{width:26px;height:26px;display:block}
.nav{display:flex;align-items:center;gap:22px;font-size:14px;color:var(--ink-soft)}
.nav a{text-decoration:none}
.nav .lk:hover{color:var(--ink)}
.btn{font-family:var(--sans);font-size:14px;font-weight:500;text-decoration:none;cursor:pointer;border:0;padding:9px 16px;border-radius:10px;color:#fff;background:var(--ink);transition:transform .12s ease, opacity .12s ease;white-space:nowrap;-webkit-tap-highlight-color:transparent}
.btn:link,.btn:visited,.btn:hover,.btn:focus,.btn:active{color:#fff}
.btn:hover{opacity:.9;transform:translateY(-1px)}
@media (max-width:600px){ .nav .lk{display:none} }
/* ---------- doc header ---------- */
.crumbs{max-width:var(--prose);margin:0 auto;padding:26px 0 0;font-family:var(--mono);font-size:12px;color:var(--ink-faint);letter-spacing:.02em}
.crumbs a{text-decoration:none;color:var(--ink-soft)}
.crumbs a:hover{color:var(--ink)}
.crumbs .sep{color:var(--line);margin:0 8px}
.doc-hero{padding:16px 0 22px}
.doc-hero .wrap{max-width:var(--prose)}
.doc-hero h1{font-size:clamp(30px,4.6vw,44px);line-height:1.05;letter-spacing:-.03em;font-weight:600;margin:12px 0 0}
.doc-hero h1 .em{background:linear-gradient(100deg,var(--rust),var(--pink) 45%,var(--magenta) 70%,var(--blue));-webkit-background-clip:text;background-clip:text;color:transparent}
.doc-hero .lede{max-width:var(--prose);margin:16px 0 0;font-size:17px;color:var(--ink-soft);line-height:1.62}
/* ---------- on this page ---------- */
.toc{max-width:var(--prose);margin:22px auto 0;border:1px solid var(--line);border-radius:14px;background:var(--panel);padding:16px 20px}
.toc h2{margin:0 0 10px;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ink-faint);font-weight:600;font-family:var(--mono)}
.toc ol{margin:0;padding-left:0;list-style:none;counter-reset:toc;display:grid;gap:7px}
.toc li{counter-increment:toc;position:relative;padding-left:28px;font-size:14.5px}
.toc li::before{content:counter(toc,decimal-leading-zero);position:absolute;left:0;top:1px;font-family:var(--mono);font-size:11px;color:var(--ink-faint)}
.toc a{text-decoration:none;color:var(--ink-soft)}
.toc a:hover{color:var(--ink)}
/* ---------- prose ---------- */
main{padding:10px 0 30px}
.prose{max-width:var(--prose);margin:0 auto}
.prose section{padding:28px 0;border-top:1px solid var(--line-2)}
.prose h2{font-size:22px;font-weight:600;letter-spacing:-.02em;margin:0 0 6px;scroll-margin-top:80px}
.prose h2 .n{font-family:var(--mono);font-size:13px;color:var(--ink-faint);margin-right:10px;font-weight:500}
.prose h3{font-size:16px;font-weight:600;letter-spacing:-.01em;margin:22px 0 6px;color:var(--ink)}
.prose p{margin:12px 0;color:var(--ink-soft);font-size:15.5px;line-height:1.65}
.prose ul{margin:12px 0;padding-left:22px;color:var(--ink-soft);font-size:15.5px;line-height:1.65}
.prose li{margin:7px 0}
.prose strong{color:var(--ink);font-weight:600}
.prose a{color:var(--ink);text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}
.prose a:hover{text-decoration-color:var(--magenta)}
.prose code{font-family:var(--mono);font-size:.86em;background:var(--panel-2);border:1px solid var(--line-2);border-radius:5px;padding:1px 6px}
.prose .lede{font-size:17px;color:var(--ink);line-height:1.6}
.kbd{font-family:var(--mono);font-size:.82em;background:var(--panel);border:1px solid var(--line);border-bottom-width:2px;border-radius:6px;padding:1px 7px;color:var(--ink);white-space:nowrap}
.ui{font-weight:600;color:var(--ink)}
/* two-path table */
.ftable{width:100%;border-collapse:collapse;margin:16px 0;font-size:14px}
.ftable th,.ftable td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--line-2);vertical-align:top}
.ftable th{font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--ink-faint);font-weight:600}
.ftable td:first-child{font-weight:600;color:var(--ink);white-space:nowrap}
.ftable td:last-child{color:var(--ink-soft)}
/* callouts */
.callout{border-left:3px solid var(--line);border-radius:0 10px 10px 0;padding:14px 16px;margin:18px 0;font-size:14.5px;color:var(--ink-soft);line-height:1.6}
.callout strong{color:var(--ink)}
.callout code{font-family:var(--mono);font-size:.86em;background:var(--panel-2);border:1px solid var(--line-2);border-radius:5px;padding:1px 6px}
.callout.note{border-color:color-mix(in srgb,var(--blue) 60%,var(--line));background:color-mix(in srgb,var(--blue) 6%,var(--panel))}
.callout.tip{border-color:color-mix(in srgb,var(--green) 60%,var(--line));background:color-mix(in srgb,var(--green) 6%,var(--panel))}
.callout.warn{border-color:color-mix(in srgb,var(--amber) 70%,var(--line));background:color-mix(in srgb,var(--amber) 8%,var(--panel))}
.callout .lbl{display:block;font-size:11px;letter-spacing:.1em;text-transform:uppercase;font-family:var(--mono);margin-bottom:4px}
.callout.note .lbl{color:var(--blue)}
.callout.tip .lbl{color:var(--green)}
.callout.warn .lbl{color:color-mix(in srgb,var(--amber) 80%,var(--ink))}
/* numbered steps */
.steps{counter-reset:step;list-style:none;padding:0;margin:16px 0}
.steps>li{counter-increment:step;position:relative;padding:0 0 20px 46px;margin:0}
.steps>li::before{content:counter(step);position:absolute;left:0;top:-2px;width:30px;height:30px;border-radius:9px;background:var(--ink);color:#fff;font-family:var(--mono);font-size:13px;display:flex;align-items:center;justify-content:center}
.steps>li:not(:last-child)::after{content:"";position:absolute;left:14px;top:32px;bottom:6px;width:2px;background:var(--line)}
.steps>li h3{margin:4px 0 4px;font-size:16px}
.steps>li p{margin:6px 0}
/* code block */
pre{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 16px;overflow-x:auto;margin:14px 0}
pre code{font-family:var(--mono);font-size:13px;color:var(--ink);line-height:1.7;background:none;border:0;padding:0}
pre code .c{color:var(--ink-faint)}
/* next / related */
.related{max-width:var(--prose);margin:0 auto;padding:26px 0 0;border-top:1px solid var(--line-2)}
.related h2{font-size:12px;letter-spacing:.12em;text-transform:uppercase;color:var(--ink-faint);font-weight:600;font-family:var(--mono);margin:0 0 12px}
.related a{display:flex;align-items:center;gap:9px;text-decoration:none;color:var(--ink);border:1px solid var(--line);border-radius:12px;padding:13px 16px;margin:8px 0;font-weight:500;font-size:15px;transition:border-color .14s ease}
.related a:hover{border-color:color-mix(in srgb,var(--magenta) 45%,var(--line))}
.related a .arw{margin-left:auto;color:var(--ink-faint)}
footer{border-top:1px solid var(--line-2);padding:30px 0 50px;margin-top:24px}
.foot{display:flex;align-items:center;justify-content:space-between;gap:18px;flex-wrap:wrap;color:var(--ink-faint);font-size:13px}
.foot .mono{font-size:12px}
.foot-links{display:flex;gap:18px;flex-wrap:wrap}
.foot-links a{text-decoration:none;color:var(--ink-soft)}
.foot-links a:hover{color:var(--ink)}
.dotsep{color:var(--line)}
</style>
</head>
<body>
<!-- ===== top bar ===== -->
<header>
<div class="wrap bar">
<a class="brand" href="/">
<svg class="mk" viewBox="0 0 100 100" aria-hidden="true">
<g fill="none" stroke-width="7">
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(80 50 50)" stroke="#7AA7DD"/>
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(20 50 50)" stroke="#DF619B"/>
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(140 50 50)" stroke="#C5826F"/>
</g>
</svg>
Nucleic
</a>
<nav class="nav">
<a class="lk" href="/support.html">Support</a>
<a class="btn" href="/">Home</a>
</nav>
</div>
</header>
<div class="wrap">
<nav class="crumbs" aria-label="Breadcrumb">
<a href="/support.html">Support</a><span class="sep">/</span>
<a href="/support/virtual-machines.html">Virtual Machines</a><span class="sep">/</span>
Building the base image
</nav>
</div>
<!-- ===== title ===== -->
<section class="doc-hero">
<div class="wrap">
<div class="kicker">Virtual Machines</div>
<h1>Building the <span class="em">base image</span></h1>
<p class="lede">Before any macOS VM can boot, Nucleic builds a one-time golden base image — the clean, provisioned macOS that every per-session VM is cloned from.</p>
</div>
</section>
<div class="wrap">
<nav class="toc" aria-label="On this page">
<h2>On this page</h2>
<ol>
<li><a href="#what">What the base image is</a></li>
<li><a href="#build">The one-click build</a></li>
<li><a href="#version">Choosing a guest macOS version</a></li>
<li><a href="#advanced">Advanced: pin an image or reuse a base</a></li>
<li><a href="#rebuild">Rebuilding / re-provisioning</a></li>
<li><a href="#after">After it's built</a></li>
</ol>
</nav>
</div>
<main>
<div class="wrap prose">
<section id="what">
<h2><span class="n">01</span>What the base image is</h2>
<p class="lede">The base image is a golden macOS install that Nucleic builds once and stores in its Application Support folder. Every per-session VM is a fast <strong>copy-on-write clone</strong> of it, so the base is built and provisioned a single time and reused for every agent.</p>
<p>Because the clone shares the base's disk blocks until it writes new ones, spinning up a fresh VM for a session is near-instant — the slow, one-time work of installing and provisioning macOS is already done and baked into the base. Everything an agent needs at runtime lives in that one image.</p>
<div class="callout note"><span class="lbl">Prerequisite</span>The macOS VM service must be on before you can build. In <span class="ui">Settings ▸ Virtual Machines</span>, turn on <span class="ui">Enable macOS VM service</span> — see <a href="/support/virtual-machines.html">macOS virtual machines: overview</a> for requirements and disk space.</div>
</section>
<section id="build">
<h2><span class="n">02</span>The one-click build</h2>
<p>Building the base is a single action. In <span class="ui">Settings ▸ Virtual Machines ▸ Base image</span>, click <span class="ui">Build base image</span>. In one step it:</p>
<ol class="steps">
<li>
<h3>Downloads and installs macOS</h3>
<p>Nucleic fetches the restore image (about a <strong>14&nbsp;GB</strong> download) and installs a clean copy of macOS into the golden base.</p>
</li>
<li>
<h3>Creates the agent account</h3>
<p>It provisions the guest account an agent logs into and works from, configured to auto-log-in so a cloned VM lands straight on the desktop.</p>
</li>
<li>
<h3>Installs the dev toolchain</h3>
<p>It installs the developer tooling — <strong>Xcode</strong>, <strong>node</strong>, and <strong>python</strong> — plus the in-guest agent if the semantic AX agent is enabled.</p>
</li>
</ol>
<p>On a macOS&nbsp;27 host with a macOS&nbsp;27 guest, this is fully unattended — you click once and come back to a finished base. On older guests, macOS <strong>Setup Assistant</strong> runs once and you complete it by hand, after which provisioning continues automatically.</p>
</section>
<section id="version">
<h2><span class="n">03</span>Choosing a guest macOS version</h2>
<p>The <span class="ui">Guest macOS image</span> picker chooses which version of macOS goes into the base. It defaults to <span class="ui">Latest supported (default)</span> — the newest macOS your host can run — which is the right choice for most builds.</p>
<p>When you pick a specific version, remember the <strong>host-must-be-at-least-guest</strong> rule: a macOS&nbsp;N guest needs a macOS&nbsp;N-or-newer host. You can't run a guest that's newer than the Mac hosting it.</p>
<div class="callout note"><span class="lbl">Good to know</span>Entries whose restore image hasn't been published yet fall back to the latest available macOS until their image ships. Once the image is out, that version becomes selectable on its own.</div>
</section>
<section id="advanced">
<h2><span class="n">04</span>Advanced: pin a specific image or reuse a prebuilt base</h2>
<p>Under the Base image section's <span class="ui">Advanced</span> disclosure you can override the picker. Three fields are available:</p>
<table class="ftable">
<thead><tr><th>Field</th><th>What it does</th></tr></thead>
<tbody>
<tr>
<td>Prebuilt base bundle</td>
<td>A path to a base someone already built. Point Nucleic at it to <strong>skip install and provisioning entirely</strong> and reuse the finished base as-is.</td>
</tr>
<tr>
<td>Restore image (.ipsw)</td>
<td>A local path to a specific macOS restore image. Pins the base to that <strong>exact macOS version</strong> instead of the newest the host can run.</td>
</tr>
<tr>
<td>Restore image URL</td>
<td>A URL to a specific restore image. Also pins an exact version, fetched from that address.</td>
</tr>
</tbody>
</table>
<p>A local path or URL <strong>takes precedence over the picker</strong>. Leave both empty to install the newest macOS the host can run.</p>
</section>
<section id="rebuild">
<h2><span class="n">05</span>Rebuilding / re-provisioning</h2>
<p>Once a base exists, the button changes to <span class="ui">Rebuild / re-provision base image</span>. Rebuild when you've changed what the base needs to contain — most commonly:</p>
<ul>
<li><strong>After you enable the semantic AX agent</strong> — a rebuild installs <code>NucleicVMAgent</code> into the base. See <a href="/support/macos-vm-ax-agent.html">setting up AX-based computer use</a>.</li>
<li><strong>After completing the one-time SIP step</strong> — a rebuild lets provisioning write the agent's permission grants now that SIP is off in the guest.</li>
</ul>
<div class="callout warn"><span class="lbl">Heads up</span>Keep plenty of free disk space — <strong>at least 64&nbsp;GB</strong>. The base image, the 14&nbsp;GB restore image, and each per-session clone all add up, and a build needs room to work.</div>
</section>
<section id="after">
<h2><span class="n">06</span>After it's built</h2>
<p>The base-image status line reports readiness, so you always know what the current base supports:</p>
<ul>
<li><span class="ui">Ready — builds + computer use.</span> — the base is provisioned and host-side computer use works on any base, with no extra setup.</li>
<li><span class="ui">Ready — builds + computer use + semantic AX.</span> — the optional in-guest agent is installed and fully granted too.</li>
</ul>
<p>From here, turn on what you want an agent to use: <a href="/support/macos-vm-computer-use.html">computer use for macOS VMs</a> for host-side screenshots and clicks, or the <a href="/support/macos-vm-ax-agent.html">semantic AX agent</a> for by-identity control. The base you just built is what every session VM clones from.</p>
</section>
</div>
<div class="wrap related">
<h2>Related guides</h2>
<a href="/support/virtual-machines.html">macOS virtual machines: overview<span class="arw" aria-hidden="true">&rarr;</span></a>
<a href="/support/macos-vm-computer-use.html">Computer use for macOS VMs<span class="arw" aria-hidden="true">&rarr;</span></a>
<a href="/support/macos-vm-ax-agent.html">Setting up AX-based computer use<span class="arw" aria-hidden="true">&rarr;</span></a>
</div>
</main>
<footer>
<div class="wrap foot">
<div class="brand" style="font-size:15px">
<svg class="mk" viewBox="0 0 100 100" aria-hidden="true">
<g fill="none" stroke-width="7">
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(80 50 50)" stroke="#7AA7DD"/>
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(20 50 50)" stroke="#DF619B"/>
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(140 50 50)" stroke="#C5826F"/>
</g>
</svg>
Nucleic
</div>
<div class="foot-links">
<a href="/">Home</a>
<a href="/support.html">Support</a>
<a href="/privacy.html">Privacy</a>
<a href="/terms.html">Terms</a>
</div>
<div class="mono">© 2026 Andrew Blakeslee&nbsp;Moore · Made with Nucleic</div>
</div>
</footer>
</body>
</html>
+301
View File
@@ -0,0 +1,301 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Computer use for macOS VMs — Nucleic Support</title>
<meta name="description" content="Let a Nucleic agent see and drive a macOS VM's screen — clicks, keys, and scrolling — through the mac_vm_computer tool. The default path runs host-side with no in-guest setup." />
<meta name="theme-color" content="#faf7f3" />
<meta name="robots" content="index, follow" />
<!-- atom mark, reused as favicon -->
<link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><g fill='none' stroke-width='7'><ellipse cx='50' cy='50' rx='17' ry='40' transform='rotate(80 50 50)' stroke='%237AA7DD'/><ellipse cx='50' cy='50' rx='17' ry='40' transform='rotate(20 50 50)' stroke='%23DF619B'/><ellipse cx='50' cy='50' rx='17' ry='40' transform='rotate(140 50 50)' stroke='%23C5826F'/></g></svg>" />
<link rel="preload" href="../fonts/space-grotesk.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="../fonts/jetbrains-mono.woff2" as="font" type="font/woff2" crossorigin />
<style>
@font-face{font-family:'Space Grotesk';font-style:normal;font-weight:400 700;font-display:swap;src:url(../fonts/space-grotesk.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'JetBrains Mono';font-style:normal;font-weight:400 500;font-display:swap;src:url(../fonts/jetbrains-mono.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
</style>
<style>
:root{
--ink:#181520;--ink-soft:#5b5560;--ink-faint:#8b8590;
--paper:#faf7f3;--panel:#fffdfb;--panel-2:#f3ede6;--line:#e6ded4;--line-2:#efe9e1;
--blue:#5e9bd8;--pink:#db5f97;--magenta:#c264ad;--rust:#c47e69;--green:#5fa775;--amber:#d7a23f;
--sans:"Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
--mono:"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
--maxw:1080px;--prose:760px;
}
*{box-sizing:border-box}
html{scroll-behavior:smooth}
body{
margin:0;overflow-x:clip;font-family:var(--sans);color:var(--ink);background:var(--paper);
background-image:radial-gradient(var(--line) 1px, transparent 1px);
background-size:26px 26px;background-position:-13px -13px;
-webkit-font-smoothing:antialiased;line-height:1.5;
}
a{color:inherit}
.wrap{max-width:var(--maxw);margin:0 auto;padding:0 24px}
.mono{font-family:var(--mono)}
.kicker{font-family:var(--mono);font-size:11.5px;letter-spacing:.16em;text-transform:uppercase;color:var(--ink-faint)}
/* ---------- top bar ---------- */
header{position:sticky;top:0;z-index:20;backdrop-filter:saturate(1.1) blur(8px);background:color-mix(in srgb, var(--paper) 82%, transparent);border-bottom:1px solid var(--line-2)}
.bar{display:flex;align-items:center;justify-content:space-between;height:62px}
.brand{display:flex;align-items:center;gap:11px;font-weight:600;letter-spacing:-.01em;font-size:17px;text-decoration:none;color:var(--ink)}
.brand .mk{width:26px;height:26px;display:block}
.nav{display:flex;align-items:center;gap:22px;font-size:14px;color:var(--ink-soft)}
.nav a{text-decoration:none}
.nav .lk:hover{color:var(--ink)}
.btn{font-family:var(--sans);font-size:14px;font-weight:500;text-decoration:none;cursor:pointer;border:0;padding:9px 16px;border-radius:10px;color:#fff;background:var(--ink);transition:transform .12s ease, opacity .12s ease;white-space:nowrap;-webkit-tap-highlight-color:transparent}
.btn:link,.btn:visited,.btn:hover,.btn:focus,.btn:active{color:#fff}
.btn:hover{opacity:.9;transform:translateY(-1px)}
@media (max-width:600px){ .nav .lk{display:none} }
/* ---------- doc header ---------- */
.crumbs{max-width:var(--prose);margin:0 auto;padding:26px 0 0;font-family:var(--mono);font-size:12px;color:var(--ink-faint);letter-spacing:.02em}
.crumbs a{text-decoration:none;color:var(--ink-soft)}
.crumbs a:hover{color:var(--ink)}
.crumbs .sep{color:var(--line);margin:0 8px}
.doc-hero{padding:16px 0 22px}
.doc-hero .wrap{max-width:var(--prose)}
.doc-hero h1{font-size:clamp(30px,4.6vw,44px);line-height:1.05;letter-spacing:-.03em;font-weight:600;margin:12px 0 0}
.doc-hero h1 .em{background:linear-gradient(100deg,var(--rust),var(--pink) 45%,var(--magenta) 70%,var(--blue));-webkit-background-clip:text;background-clip:text;color:transparent}
.doc-hero .lede{max-width:var(--prose);margin:16px 0 0;font-size:17px;color:var(--ink-soft);line-height:1.62}
/* ---------- on this page ---------- */
.toc{max-width:var(--prose);margin:22px auto 0;border:1px solid var(--line);border-radius:14px;background:var(--panel);padding:16px 20px}
.toc h2{margin:0 0 10px;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ink-faint);font-weight:600;font-family:var(--mono)}
.toc ol{margin:0;padding-left:0;list-style:none;counter-reset:toc;display:grid;gap:7px}
.toc li{counter-increment:toc;position:relative;padding-left:28px;font-size:14.5px}
.toc li::before{content:counter(toc,decimal-leading-zero);position:absolute;left:0;top:1px;font-family:var(--mono);font-size:11px;color:var(--ink-faint)}
.toc a{text-decoration:none;color:var(--ink-soft)}
.toc a:hover{color:var(--ink)}
/* ---------- prose ---------- */
main{padding:10px 0 30px}
.prose{max-width:var(--prose);margin:0 auto}
.prose section{padding:28px 0;border-top:1px solid var(--line-2)}
.prose h2{font-size:22px;font-weight:600;letter-spacing:-.02em;margin:0 0 6px;scroll-margin-top:80px}
.prose h2 .n{font-family:var(--mono);font-size:13px;color:var(--ink-faint);margin-right:10px;font-weight:500}
.prose h3{font-size:16px;font-weight:600;letter-spacing:-.01em;margin:22px 0 6px;color:var(--ink)}
.prose p{margin:12px 0;color:var(--ink-soft);font-size:15.5px;line-height:1.65}
.prose ul{margin:12px 0;padding-left:22px;color:var(--ink-soft);font-size:15.5px;line-height:1.65}
.prose li{margin:7px 0}
.prose strong{color:var(--ink);font-weight:600}
.prose a{color:var(--ink);text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}
.prose a:hover{text-decoration-color:var(--magenta)}
.prose code{font-family:var(--mono);font-size:.86em;background:var(--panel-2);border:1px solid var(--line-2);border-radius:5px;padding:1px 6px}
.prose .lede{font-size:17px;color:var(--ink);line-height:1.6}
.kbd{font-family:var(--mono);font-size:.82em;background:var(--panel);border:1px solid var(--line);border-bottom-width:2px;border-radius:6px;padding:1px 7px;color:var(--ink);white-space:nowrap}
.ui{font-weight:600;color:var(--ink)}
/* two-path table */
.ftable{width:100%;border-collapse:collapse;margin:16px 0;font-size:14px}
.ftable th,.ftable td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--line-2);vertical-align:top}
.ftable th{font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--ink-faint);font-weight:600}
.ftable td:first-child{font-weight:600;color:var(--ink);white-space:nowrap}
.ftable td:last-child{color:var(--ink-soft)}
/* callouts */
.callout{border-left:3px solid var(--line);border-radius:0 10px 10px 0;padding:14px 16px;margin:18px 0;font-size:14.5px;color:var(--ink-soft);line-height:1.6}
.callout strong{color:var(--ink)}
.callout code{font-family:var(--mono);font-size:.86em;background:var(--panel-2);border:1px solid var(--line-2);border-radius:5px;padding:1px 6px}
.callout.note{border-color:color-mix(in srgb,var(--blue) 60%,var(--line));background:color-mix(in srgb,var(--blue) 6%,var(--panel))}
.callout.tip{border-color:color-mix(in srgb,var(--green) 60%,var(--line));background:color-mix(in srgb,var(--green) 6%,var(--panel))}
.callout.warn{border-color:color-mix(in srgb,var(--amber) 70%,var(--line));background:color-mix(in srgb,var(--amber) 8%,var(--panel))}
.callout .lbl{display:block;font-size:11px;letter-spacing:.1em;text-transform:uppercase;font-family:var(--mono);margin-bottom:4px}
.callout.note .lbl{color:var(--blue)}
.callout.tip .lbl{color:var(--green)}
.callout.warn .lbl{color:color-mix(in srgb,var(--amber) 80%,var(--ink))}
/* numbered steps */
.steps{counter-reset:step;list-style:none;padding:0;margin:16px 0}
.steps>li{counter-increment:step;position:relative;padding:0 0 20px 46px;margin:0}
.steps>li::before{content:counter(step);position:absolute;left:0;top:-2px;width:30px;height:30px;border-radius:9px;background:var(--ink);color:#fff;font-family:var(--mono);font-size:13px;display:flex;align-items:center;justify-content:center}
.steps>li:not(:last-child)::after{content:"";position:absolute;left:14px;top:32px;bottom:6px;width:2px;background:var(--line)}
.steps>li h3{margin:4px 0 4px;font-size:16px}
.steps>li p{margin:6px 0}
/* code block */
pre{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 16px;overflow-x:auto;margin:14px 0}
pre code{font-family:var(--mono);font-size:13px;color:var(--ink);line-height:1.7;background:none;border:0;padding:0}
pre code .c{color:var(--ink-faint)}
/* next / related */
.related{max-width:var(--prose);margin:0 auto;padding:26px 0 0;border-top:1px solid var(--line-2)}
.related h2{font-size:12px;letter-spacing:.12em;text-transform:uppercase;color:var(--ink-faint);font-weight:600;font-family:var(--mono);margin:0 0 12px}
.related a{display:flex;align-items:center;gap:9px;text-decoration:none;color:var(--ink);border:1px solid var(--line);border-radius:12px;padding:13px 16px;margin:8px 0;font-weight:500;font-size:15px;transition:border-color .14s ease}
.related a:hover{border-color:color-mix(in srgb,var(--magenta) 45%,var(--line))}
.related a .arw{margin-left:auto;color:var(--ink-faint)}
footer{border-top:1px solid var(--line-2);padding:30px 0 50px;margin-top:24px}
.foot{display:flex;align-items:center;justify-content:space-between;gap:18px;flex-wrap:wrap;color:var(--ink-faint);font-size:13px}
.foot .mono{font-size:12px}
.foot-links{display:flex;gap:18px;flex-wrap:wrap}
.foot-links a{text-decoration:none;color:var(--ink-soft)}
.foot-links a:hover{color:var(--ink)}
.dotsep{color:var(--line)}
</style>
</head>
<body>
<!-- ===== top bar ===== -->
<header>
<div class="wrap bar">
<a class="brand" href="/">
<svg class="mk" viewBox="0 0 100 100" aria-hidden="true">
<g fill="none" stroke-width="7">
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(80 50 50)" stroke="#7AA7DD"/>
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(20 50 50)" stroke="#DF619B"/>
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(140 50 50)" stroke="#C5826F"/>
</g>
</svg>
Nucleic
</a>
<nav class="nav">
<a class="lk" href="/support.html">Support</a>
<a class="btn" href="/">Home</a>
</nav>
</div>
</header>
<div class="wrap">
<nav class="crumbs" aria-label="Breadcrumb">
<a href="/support.html">Support</a><span class="sep">/</span>
<a href="/support/virtual-machines.html">Virtual Machines</a><span class="sep">/</span>
Computer use
</nav>
</div>
<!-- ===== title ===== -->
<section class="doc-hero">
<div class="wrap">
<div class="kicker">Virtual Machines</div>
<h1>Computer use for <span class="em">macOS VMs</span></h1>
<p class="lede">Beyond running shell commands, an agent can see and operate a macOS VM's screen — clicking, typing, and scrolling through its GUI. The default path needs no setup at all.</p>
</div>
</section>
<div class="wrap">
<nav class="toc" aria-label="On this page">
<h2>On this page</h2>
<ol>
<li><a href="#what">What computer use is</a></li>
<li><a href="#no-setup">Why it needs no setup</a></li>
<li><a href="#enable">Turning it on</a></li>
<li><a href="#actions">What the agent can do</a></li>
<li><a href="#semantic">When to add the semantic agent</a></li>
</ol>
</nav>
</div>
<main>
<div class="wrap prose">
<section id="what">
<h2><span class="n">01</span>What computer use is</h2>
<p class="lede">Computer use lets an agent <strong>see</strong> and drive a macOS VM's GUI through the <code>mac_vm_computer</code> tool — think of the VM as a Mac dev simulator the agent can look at and operate.</p>
<p>It's the visual counterpart to <code>mac_vm_exec</code>, which runs headless shell commands inside the VM. Where <code>mac_vm_exec</code> is for scripting and builds, computer use is for anything with a screen. Reach for it when the agent needs to:</p>
<ul>
<li>Look at or click through an app it just built, to confirm the UI actually behaves.</li>
<li>Drive <strong>Xcode</strong> or the <strong>Simulator</strong> UI — the parts you can't reach from the command line.</li>
<li>Check how something renders — a layout, a web page, a window — by taking an actual screenshot of the VM's display.</li>
</ul>
</section>
<section id="no-setup">
<h2><span class="n">02</span>Why it needs no setup</h2>
<p>In the default path, screen capture and input both happen <strong>host-side</strong>, inside Apple's Virtualization framework. Screenshots come from the VM's virtual display, and clicks and keystrokes are injected into its virtual keyboard and mouse. The agent never reaches into the guest to do any of this — it works the display and input devices from the outside.</p>
<p>That design is why there's nothing to install and nothing to configure inside the VM:</p>
<ul>
<li><strong>Nothing runs inside the guest.</strong> No helper app, no login agent — capture and input live in the framework on your Mac.</li>
<li><strong>The guest grants no permissions.</strong> No <span class="ui">Accessibility</span>, no <span class="ui">Screen Recording</span> — because no in-guest software is asking for them.</li>
<li><strong>System Integrity Protection (SIP) stays on.</strong> The guest keeps its default security posture untouched.</li>
</ul>
<p>Because it depends only on the virtual display and input devices, the default path works on <strong>any installed base image</strong> — there's no special build variant to produce first.</p>
</section>
<section id="enable">
<h2><span class="n">03</span>Turning it on</h2>
<p>In <span class="ui">Settings ▸ Virtual Machines</span>, turn on <span class="ui">Enable computer use (screen&nbsp;+&nbsp;mouse/keyboard)</span>. Sessions then expose the <code>mac_vm_computer</code> tool to the agent.</p>
<p>The first call to the tool boots that session's VM, which takes a minute or two; later calls reuse the already-running VM, so they're fast. You don't launch anything by hand — the agent invokes the tool and the session's VM spins up on demand.</p>
<p>Two things need to be in place first:</p>
<ul>
<li><strong>The macOS VM service is on.</strong> See <a href="/support/virtual-machines.html">macOS virtual machines: overview</a> for requirements and how to enable it.</li>
<li><strong>A base image is built.</strong> Computer use runs against an installed VM, so you need a built base first — see <a href="/support/macos-vm-base-image.html">Building the base image</a>.</li>
</ul>
</section>
<section id="actions">
<h2><span class="n">04</span>What the agent can do</h2>
<p>Through the <code>mac_vm_computer</code> tool, an agent can:</p>
<ul>
<li><strong>Take a screenshot</strong> of the VM's screen.</li>
<li><strong>Move and click the mouse</strong>, including press-and-drag.</li>
<li><strong>Scroll</strong> with a real scroll wheel.</li>
<li><strong>Type text</strong>, with modifier keys for shortcuts.</li>
<li><strong>Launch apps</strong> in the guest.</li>
</ul>
<p>These compose into a simple <strong>screenshot → decide → act</strong> loop: the agent captures the screen, chooses an action, performs it, then captures the screen again to see what changed — the same way a person watches a display and reacts to it.</p>
</section>
<section id="semantic">
<h2><span class="n">05</span>When to add the semantic agent</h2>
<p>The default pixel path is enough for most GUI work. There's also an optional in-guest <strong>semantic AX agent</strong> you can layer on top. Add it when you want to:</p>
<ul>
<li><strong>Act on a UI element by identity</strong> — press a specific button by its role and title — rather than guessing and clicking pixel coordinates.</li>
<li><strong>Keep working on a macOS&nbsp;26 guest</strong>, where app windows can come back blank in host-side screenshots.</li>
</ul>
<p>The two paths differ mainly in setup versus capability:</p>
<table class="ftable">
<thead><tr><th>Path</th><th>Trade-off</th></tr></thead>
<tbody>
<tr>
<td>Host-side pixel<br>(default)</td>
<td><strong>Zero setup.</strong> Screenshot-and-click through the virtual display and input devices — no in-guest software, no permissions, SIP untouched.</td>
</tr>
<tr>
<td>Semantic AX<br>(optional)</td>
<td>Adds <strong>by-identity control</strong> and framebuffer-independent capture, so it keeps working when the pixels are blank — at the cost of a <strong>one-time in-guest setup</strong>.</td>
</tr>
</tbody>
</table>
<div class="callout tip"><span class="lbl">If you want by-identity control</span>The semantic path is an add-on to the default, not a replacement — the ordinary screenshot-and-click actions still work. See <a href="/support/macos-vm-ax-agent.html">Setting up AX-based computer use</a> for what gets installed and the one-time setup.</div>
</section>
</div>
<div class="wrap related">
<h2>Related guides</h2>
<a href="/support/macos-vm-ax-agent.html">Setting up AX-based computer use<span class="arw" aria-hidden="true">&rarr;</span></a>
<a href="/support/virtual-machines.html">macOS virtual machines: overview<span class="arw" aria-hidden="true">&rarr;</span></a>
<a href="/support/macos-vm-base-image.html">Building the base image<span class="arw" aria-hidden="true">&rarr;</span></a>
</div>
</main>
<footer>
<div class="wrap foot">
<div class="brand" style="font-size:15px">
<svg class="mk" viewBox="0 0 100 100" aria-hidden="true">
<g fill="none" stroke-width="7">
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(80 50 50)" stroke="#7AA7DD"/>
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(20 50 50)" stroke="#DF619B"/>
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(140 50 50)" stroke="#C5826F"/>
</g>
</svg>
Nucleic
</div>
<div class="foot-links">
<a href="/">Home</a>
<a href="/support.html">Support</a>
<a href="/privacy.html">Privacy</a>
<a href="/terms.html">Terms</a>
</div>
<div class="mono">© 2026 Andrew Blakeslee&nbsp;Moore · Made with Nucleic</div>
</div>
</footer>
</body>
</html>
+292
View File
@@ -0,0 +1,292 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>What is Nucleic Control? — Nucleic Support</title>
<meta name="description" content="What Nucleic Control is: the hardened path where Nucleic clones, sandboxes, and observes a project directly — unlocking unattended work, Autoship, and Orchestra." />
<meta name="theme-color" content="#faf7f3" />
<meta name="robots" content="index, follow" />
<!-- atom mark, reused as favicon -->
<link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><g fill='none' stroke-width='7'><ellipse cx='50' cy='50' rx='17' ry='40' transform='rotate(80 50 50)' stroke='%237AA7DD'/><ellipse cx='50' cy='50' rx='17' ry='40' transform='rotate(20 50 50)' stroke='%23DF619B'/><ellipse cx='50' cy='50' rx='17' ry='40' transform='rotate(140 50 50)' stroke='%23C5826F'/></g></svg>" />
<link rel="preload" href="../fonts/space-grotesk.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="../fonts/jetbrains-mono.woff2" as="font" type="font/woff2" crossorigin />
<style>
@font-face{font-family:'Space Grotesk';font-style:normal;font-weight:400 700;font-display:swap;src:url(../fonts/space-grotesk.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'JetBrains Mono';font-style:normal;font-weight:400 500;font-display:swap;src:url(../fonts/jetbrains-mono.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
</style>
<style>
:root{
--ink:#181520;--ink-soft:#5b5560;--ink-faint:#8b8590;
--paper:#faf7f3;--panel:#fffdfb;--panel-2:#f3ede6;--line:#e6ded4;--line-2:#efe9e1;
--blue:#5e9bd8;--pink:#db5f97;--magenta:#c264ad;--rust:#c47e69;--green:#5fa775;--amber:#d7a23f;
--sans:"Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
--mono:"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
--maxw:1080px;--prose:760px;
}
*{box-sizing:border-box}
html{scroll-behavior:smooth}
body{
margin:0;overflow-x:clip;font-family:var(--sans);color:var(--ink);background:var(--paper);
background-image:radial-gradient(var(--line) 1px, transparent 1px);
background-size:26px 26px;background-position:-13px -13px;
-webkit-font-smoothing:antialiased;line-height:1.5;
}
a{color:inherit}
.wrap{max-width:var(--maxw);margin:0 auto;padding:0 24px}
.mono{font-family:var(--mono)}
.kicker{font-family:var(--mono);font-size:11.5px;letter-spacing:.16em;text-transform:uppercase;color:var(--ink-faint)}
/* ---------- top bar ---------- */
header{position:sticky;top:0;z-index:20;backdrop-filter:saturate(1.1) blur(8px);background:color-mix(in srgb, var(--paper) 82%, transparent);border-bottom:1px solid var(--line-2)}
.bar{display:flex;align-items:center;justify-content:space-between;height:62px}
.brand{display:flex;align-items:center;gap:11px;font-weight:600;letter-spacing:-.01em;font-size:17px;text-decoration:none;color:var(--ink)}
.brand .mk{width:26px;height:26px;display:block}
.nav{display:flex;align-items:center;gap:22px;font-size:14px;color:var(--ink-soft)}
.nav a{text-decoration:none}
.nav .lk:hover{color:var(--ink)}
.btn{font-family:var(--sans);font-size:14px;font-weight:500;text-decoration:none;cursor:pointer;border:0;padding:9px 16px;border-radius:10px;color:#fff;background:var(--ink);transition:transform .12s ease, opacity .12s ease;white-space:nowrap;-webkit-tap-highlight-color:transparent}
.btn:link,.btn:visited,.btn:hover,.btn:focus,.btn:active{color:#fff}
.btn:hover{opacity:.9;transform:translateY(-1px)}
@media (max-width:600px){ .nav .lk{display:none} }
/* ---------- doc header ---------- */
.crumbs{max-width:var(--prose);margin:0 auto;padding:26px 0 0;font-family:var(--mono);font-size:12px;color:var(--ink-faint);letter-spacing:.02em}
.crumbs a{text-decoration:none;color:var(--ink-soft)}
.crumbs a:hover{color:var(--ink)}
.crumbs .sep{color:var(--line);margin:0 8px}
.doc-hero{padding:16px 0 22px}
.doc-hero .wrap{max-width:var(--prose)}
.doc-hero h1{font-size:clamp(30px,4.6vw,44px);line-height:1.05;letter-spacing:-.03em;font-weight:600;margin:12px 0 0}
.doc-hero h1 .em{background:linear-gradient(100deg,var(--rust),var(--pink) 45%,var(--magenta) 70%,var(--blue));-webkit-background-clip:text;background-clip:text;color:transparent}
.doc-hero .lede{max-width:var(--prose);margin:16px 0 0;font-size:17px;color:var(--ink-soft);line-height:1.62}
/* ---------- on this page ---------- */
.toc{max-width:var(--prose);margin:22px auto 0;border:1px solid var(--line);border-radius:14px;background:var(--panel);padding:16px 20px}
.toc h2{margin:0 0 10px;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ink-faint);font-weight:600;font-family:var(--mono)}
.toc ol{margin:0;padding-left:0;list-style:none;counter-reset:toc;display:grid;gap:7px}
.toc li{counter-increment:toc;position:relative;padding-left:28px;font-size:14.5px}
.toc li::before{content:counter(toc,decimal-leading-zero);position:absolute;left:0;top:1px;font-family:var(--mono);font-size:11px;color:var(--ink-faint)}
.toc a{text-decoration:none;color:var(--ink-soft)}
.toc a:hover{color:var(--ink)}
/* ---------- prose ---------- */
main{padding:10px 0 30px}
.prose{max-width:var(--prose);margin:0 auto}
.prose section{padding:28px 0;border-top:1px solid var(--line-2)}
.prose h2{font-size:22px;font-weight:600;letter-spacing:-.02em;margin:0 0 6px;scroll-margin-top:80px}
.prose h2 .n{font-family:var(--mono);font-size:13px;color:var(--ink-faint);margin-right:10px;font-weight:500}
.prose h3{font-size:16px;font-weight:600;letter-spacing:-.01em;margin:22px 0 6px;color:var(--ink)}
.prose p{margin:12px 0;color:var(--ink-soft);font-size:15.5px;line-height:1.65}
.prose ul{margin:12px 0;padding-left:22px;color:var(--ink-soft);font-size:15.5px;line-height:1.65}
.prose li{margin:7px 0}
.prose strong{color:var(--ink);font-weight:600}
.prose a{color:var(--ink);text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}
.prose a:hover{text-decoration-color:var(--magenta)}
.prose code{font-family:var(--mono);font-size:.86em;background:var(--panel-2);border:1px solid var(--line-2);border-radius:5px;padding:1px 6px}
.prose .lede{font-size:17px;color:var(--ink);line-height:1.6}
.kbd{font-family:var(--mono);font-size:.82em;background:var(--panel);border:1px solid var(--line);border-bottom-width:2px;border-radius:6px;padding:1px 7px;color:var(--ink);white-space:nowrap}
.ui{font-weight:600;color:var(--ink)}
/* two-path table */
.ftable{width:100%;border-collapse:collapse;margin:16px 0;font-size:14px}
.ftable th,.ftable td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--line-2);vertical-align:top}
.ftable th{font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--ink-faint);font-weight:600}
.ftable td:first-child{font-weight:600;color:var(--ink);white-space:nowrap}
.ftable td:last-child{color:var(--ink-soft)}
/* callouts */
.callout{border-left:3px solid var(--line);border-radius:0 10px 10px 0;padding:14px 16px;margin:18px 0;font-size:14.5px;color:var(--ink-soft);line-height:1.6}
.callout strong{color:var(--ink)}
.callout code{font-family:var(--mono);font-size:.86em;background:var(--panel-2);border:1px solid var(--line-2);border-radius:5px;padding:1px 6px}
.callout.note{border-color:color-mix(in srgb,var(--blue) 60%,var(--line));background:color-mix(in srgb,var(--blue) 6%,var(--panel))}
.callout.tip{border-color:color-mix(in srgb,var(--green) 60%,var(--line));background:color-mix(in srgb,var(--green) 6%,var(--panel))}
.callout.warn{border-color:color-mix(in srgb,var(--amber) 70%,var(--line));background:color-mix(in srgb,var(--amber) 8%,var(--panel))}
.callout .lbl{display:block;font-size:11px;letter-spacing:.1em;text-transform:uppercase;font-family:var(--mono);margin-bottom:4px}
.callout.note .lbl{color:var(--blue)}
.callout.tip .lbl{color:var(--green)}
.callout.warn .lbl{color:color-mix(in srgb,var(--amber) 80%,var(--ink))}
/* numbered steps */
.steps{counter-reset:step;list-style:none;padding:0;margin:16px 0}
.steps>li{counter-increment:step;position:relative;padding:0 0 20px 46px;margin:0}
.steps>li::before{content:counter(step);position:absolute;left:0;top:-2px;width:30px;height:30px;border-radius:9px;background:var(--ink);color:#fff;font-family:var(--mono);font-size:13px;display:flex;align-items:center;justify-content:center}
.steps>li:not(:last-child)::after{content:"";position:absolute;left:14px;top:32px;bottom:6px;width:2px;background:var(--line)}
.steps>li h3{margin:4px 0 4px;font-size:16px}
.steps>li p{margin:6px 0}
/* code block */
pre{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 16px;overflow-x:auto;margin:14px 0}
pre code{font-family:var(--mono);font-size:13px;color:var(--ink);line-height:1.7;background:none;border:0;padding:0}
pre code .c{color:var(--ink-faint)}
/* next / related */
.related{max-width:var(--prose);margin:0 auto;padding:26px 0 0;border-top:1px solid var(--line-2)}
.related h2{font-size:12px;letter-spacing:.12em;text-transform:uppercase;color:var(--ink-faint);font-weight:600;font-family:var(--mono);margin:0 0 12px}
.related a{display:flex;align-items:center;gap:9px;text-decoration:none;color:var(--ink);border:1px solid var(--line);border-radius:12px;padding:13px 16px;margin:8px 0;font-weight:500;font-size:15px;transition:border-color .14s ease}
.related a:hover{border-color:color-mix(in srgb,var(--magenta) 45%,var(--line))}
.related a .arw{margin-left:auto;color:var(--ink-faint)}
footer{border-top:1px solid var(--line-2);padding:30px 0 50px;margin-top:24px}
.foot{display:flex;align-items:center;justify-content:space-between;gap:18px;flex-wrap:wrap;color:var(--ink-faint);font-size:13px}
.foot .mono{font-size:12px}
.foot-links{display:flex;gap:18px;flex-wrap:wrap}
.foot-links a{text-decoration:none;color:var(--ink-soft)}
.foot-links a:hover{color:var(--ink)}
.dotsep{color:var(--line)}
</style>
</head>
<body>
<!-- ===== top bar ===== -->
<header>
<div class="wrap bar">
<a class="brand" href="/">
<svg class="mk" viewBox="0 0 100 100" aria-hidden="true">
<g fill="none" stroke-width="7">
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(80 50 50)" stroke="#7AA7DD"/>
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(20 50 50)" stroke="#DF619B"/>
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(140 50 50)" stroke="#C5826F"/>
</g>
</svg>
Nucleic
</a>
<nav class="nav">
<a class="lk" href="/support.html">Support</a>
<a class="btn" href="/">Home</a>
</nav>
</div>
</header>
<div class="wrap">
<nav class="crumbs" aria-label="Breadcrumb">
<a href="/support.html">Support</a><span class="sep">/</span>
Nucleic Control
</nav>
</div>
<!-- ===== title ===== -->
<section class="doc-hero">
<div class="wrap">
<div class="kicker">Nucleic Control</div>
<h1>What is <span class="em">Nucleic Control</span>?</h1>
<p class="lede">Nucleic Control is the hardened path where Nucleic itself clones, sandboxes, and observes a project — the secure, watched counterpart to running an agent from an arbitrary folder, and the foundation for unattended work.</p>
</div>
</section>
<div class="wrap">
<nav class="toc" aria-label="On this page">
<h2>On this page</h2>
<ol>
<li><a href="#what">What Control is</a></li>
<li><a href="#unlocks">What Control unlocks</a></li>
<li><a href="#enable">Turning it on</a></li>
<li><a href="#requirements">Requirements</a></li>
<li><a href="#advanced">Advanced Control options</a></li>
</ol>
</nav>
</div>
<main>
<div class="wrap prose">
<section id="what">
<h2><span class="n">01</span>What Control is</h2>
<p class="lede">When you let Nucleic clone and manage a project — it lives under a Nucleic-managed location instead of a folder you picked — that project runs in <em>Control</em> mode. Control is what makes an agent's work on that project observable and contained rather than best-effort.</p>
<p>Three things define a Control project:</p>
<ul>
<li><strong>Every session runs inside an isolated Linux sandbox.</strong> The agent's tools execute in a per-session container, not loose on your Mac. See <a href="/support/sandboxes.html">how agent sandboxing works</a> for the details.</li>
<li><strong>Git is observed with certainty.</strong> A git interceptor sits ahead of the real <code>git</code> and <code>gh</code> tools and reports each mutating operation — commits, merges, conflicts — back to Nucleic as structured <em>events</em>, not guesses parsed from command output. Nucleic knows what happened because the operation told it, not because it scraped a terminal.</li>
<li><strong>The control plane runs over a private <code>vsock</code> channel</strong> instead of a TCP port. There are no macOS firewall prompts and no exposed ports — the approval and interceptor traffic never touches the network.</li>
</ul>
<p>Running an agent from an arbitrary directory has none of these guarantees: no sandbox, no certain git observation, no private control channel. Control is the counterpart that adds all three.</p>
<div class="callout note"><span class="lbl">The distinction</span>A project is either <strong>Control</strong> (Nucleic clones and manages it, and the guarantees above apply) or an ordinary folder you point an agent at. The features below only exist for Control projects — that's the whole reason to use one.</div>
</section>
<section id="unlocks">
<h2><span class="n">02</span>What Control unlocks</h2>
<p>Because a Control project is sandboxed and its git activity is observed with certainty, Nucleic can safely automate work that would be risky otherwise. Only Control projects can turn these on:</p>
<ul>
<li><strong>Autoship</strong> — a finished agent merges its own branch automatically. Merges are safely queued, conflict-aware, and never happen mid-task, so a completed piece of work lands on its own without you babysitting the merge.</li>
<li><strong>Orchestra</strong> — a standing-consent instruction that lets one agent spin up parallel subagents using pre-allowed spawn tools, so a single request can fan out into coordinated parallel work.</li>
<li><strong>nvrsion</strong> (Beta) — shared-trunk versioning where agents edit one trunk checkout and each edit lands instantly as its own path-scoped commit, instead of juggling branches.</li>
</ul>
<div class="callout tip"><span class="lbl">Why these need Control</span>Autoship and Orchestra hinge on Nucleic knowing exactly what an agent did and being able to contain it. The sandbox and certain git observation of Control are what make unattended merging and parallel spawning safe — which is why they aren't offered for arbitrary folders.</div>
</section>
<section id="enable">
<h2><span class="n">03</span>Turning it on</h2>
<p>Control is built on the container service, so it comes on in two moves — enable the container service first, then turn Control on.</p>
<ol class="steps">
<li>
<h3>Enable the container service</h3>
<p>In <span class="ui">Settings ▸ Sandbox</span>, turn on <span class="ui">Enable container service</span>. This is the sandbox that Control sessions run inside — see the <a href="/support/sandboxes.html">Sandboxes guide</a> for what it does and how it works.</p>
</li>
<li>
<h3>Turn on Nucleic Control</h3>
<p>In <span class="ui">Settings ▸ Control</span>, turn on <span class="ui">Enable Nucleic Control on all projects</span> — or enable Control per project as you clone it, if you'd rather opt in one at a time.</p>
</li>
<li>
<h3>Optionally use nvrsion for new projects</h3>
<p>If you want shared-trunk versioning, also turn on <span class="ui">Use nvrsion for new Control projects</span> (Beta). New Control projects will then be set up on nvrsion.</p>
</li>
</ol>
<div class="callout note"><span class="lbl">If the toggles are greyed out</span>The Control toggles depend on the container service. If it's off, they're disabled and prompt you to turn it on first — enable <span class="ui">Settings ▸ Sandbox ▸ Enable container service</span>, then come back.</div>
</section>
<section id="requirements">
<h2><span class="n">04</span>Requirements</h2>
<p>Control has the same platform floor as the sandbox it runs on:</p>
<ul>
<li><strong>macOS&nbsp;26 (Tahoe) or later on Apple silicon.</strong> Apple's containerization framework — which the sandbox is built on — requires it.</li>
<li><strong>A one-time download on first use.</strong> The first time you use Control, Nucleic downloads and caches a small Linux kernel and the sandbox root image (about&nbsp;14&nbsp;GB). This happens once; later sessions reuse the cached image.</li>
</ul>
<p>For a full picture of what the sandbox is and how the agent's tools run inside it, see <a href="/support/sandboxes.html">how agent sandboxing works</a>. If you also run macOS VMs alongside Control projects, the <a href="/support/virtual-machines.html">macOS virtual machines overview</a> covers that separate service.</p>
</section>
<section id="advanced">
<h2><span class="n">05</span>Advanced Control options</h2>
<p>A few extra toggles live under <span class="ui">Settings ▸ Control</span> for people who go looking. Leave the defaults unless you have a reason to change them:</p>
<ul>
<li><strong>Agent Lifecycle Protection</strong> — guards a running agent's container from being torn down out from under it.</li>
<li><strong>Control plane over vsock</strong> — routes the approval and interceptor traffic over <code>vsock</code> rather than a gateway TCP port. This is the default.</li>
<li><strong>Trace shell commands</strong> — records the shell commands agents run, for observability.</li>
</ul>
</section>
</div>
<div class="wrap related">
<h2>Related guides</h2>
<a href="/support/sandboxes.html">How agent sandboxing works<span class="arw" aria-hidden="true">&rarr;</span></a>
<a href="/support/virtual-machines.html">macOS virtual machines: overview<span class="arw" aria-hidden="true">&rarr;</span></a>
</div>
</main>
<footer>
<div class="wrap foot">
<div class="brand" style="font-size:15px">
<svg class="mk" viewBox="0 0 100 100" aria-hidden="true">
<g fill="none" stroke-width="7">
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(80 50 50)" stroke="#7AA7DD"/>
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(20 50 50)" stroke="#DF619B"/>
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(140 50 50)" stroke="#C5826F"/>
</g>
</svg>
Nucleic
</div>
<div class="foot-links">
<a href="/">Home</a>
<a href="/support.html">Support</a>
<a href="/privacy.html">Privacy</a>
<a href="/terms.html">Terms</a>
</div>
<div class="mono">© 2026 Andrew Blakeslee&nbsp;Moore · Made with Nucleic</div>
</div>
</footer>
</body>
</html>
+326
View File
@@ -0,0 +1,326 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>How agent sandboxing works — Nucleic Support</title>
<meta name="description" content="How Nucleic sandboxes coding agents: each session runs in its own lightweight Linux VM on Apple's containerization framework, isolated from your host and from other sessions." />
<meta name="theme-color" content="#faf7f3" />
<meta name="robots" content="index, follow" />
<!-- atom mark, reused as favicon -->
<link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><g fill='none' stroke-width='7'><ellipse cx='50' cy='50' rx='17' ry='40' transform='rotate(80 50 50)' stroke='%237AA7DD'/><ellipse cx='50' cy='50' rx='17' ry='40' transform='rotate(20 50 50)' stroke='%23DF619B'/><ellipse cx='50' cy='50' rx='17' ry='40' transform='rotate(140 50 50)' stroke='%23C5826F'/></g></svg>" />
<link rel="preload" href="../fonts/space-grotesk.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="../fonts/jetbrains-mono.woff2" as="font" type="font/woff2" crossorigin />
<style>
@font-face{font-family:'Space Grotesk';font-style:normal;font-weight:400 700;font-display:swap;src:url(../fonts/space-grotesk.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'JetBrains Mono';font-style:normal;font-weight:400 500;font-display:swap;src:url(../fonts/jetbrains-mono.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
</style>
<style>
:root{
--ink:#181520;--ink-soft:#5b5560;--ink-faint:#8b8590;
--paper:#faf7f3;--panel:#fffdfb;--panel-2:#f3ede6;--line:#e6ded4;--line-2:#efe9e1;
--blue:#5e9bd8;--pink:#db5f97;--magenta:#c264ad;--rust:#c47e69;--green:#5fa775;--amber:#d7a23f;
--sans:"Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
--mono:"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
--maxw:1080px;--prose:760px;
}
*{box-sizing:border-box}
html{scroll-behavior:smooth}
body{
margin:0;overflow-x:clip;font-family:var(--sans);color:var(--ink);background:var(--paper);
background-image:radial-gradient(var(--line) 1px, transparent 1px);
background-size:26px 26px;background-position:-13px -13px;
-webkit-font-smoothing:antialiased;line-height:1.5;
}
a{color:inherit}
.wrap{max-width:var(--maxw);margin:0 auto;padding:0 24px}
.mono{font-family:var(--mono)}
.kicker{font-family:var(--mono);font-size:11.5px;letter-spacing:.16em;text-transform:uppercase;color:var(--ink-faint)}
/* ---------- top bar ---------- */
header{position:sticky;top:0;z-index:20;backdrop-filter:saturate(1.1) blur(8px);background:color-mix(in srgb, var(--paper) 82%, transparent);border-bottom:1px solid var(--line-2)}
.bar{display:flex;align-items:center;justify-content:space-between;height:62px}
.brand{display:flex;align-items:center;gap:11px;font-weight:600;letter-spacing:-.01em;font-size:17px;text-decoration:none;color:var(--ink)}
.brand .mk{width:26px;height:26px;display:block}
.nav{display:flex;align-items:center;gap:22px;font-size:14px;color:var(--ink-soft)}
.nav a{text-decoration:none}
.nav .lk:hover{color:var(--ink)}
.btn{font-family:var(--sans);font-size:14px;font-weight:500;text-decoration:none;cursor:pointer;border:0;padding:9px 16px;border-radius:10px;color:#fff;background:var(--ink);transition:transform .12s ease, opacity .12s ease;white-space:nowrap;-webkit-tap-highlight-color:transparent}
.btn:link,.btn:visited,.btn:hover,.btn:focus,.btn:active{color:#fff}
.btn:hover{opacity:.9;transform:translateY(-1px)}
@media (max-width:600px){ .nav .lk{display:none} }
/* ---------- doc header ---------- */
.crumbs{max-width:var(--prose);margin:0 auto;padding:26px 0 0;font-family:var(--mono);font-size:12px;color:var(--ink-faint);letter-spacing:.02em}
.crumbs a{text-decoration:none;color:var(--ink-soft)}
.crumbs a:hover{color:var(--ink)}
.crumbs .sep{color:var(--line);margin:0 8px}
.doc-hero{padding:16px 0 22px}
.doc-hero .wrap{max-width:var(--prose)}
.doc-hero h1{font-size:clamp(30px,4.6vw,44px);line-height:1.05;letter-spacing:-.03em;font-weight:600;margin:12px 0 0}
.doc-hero h1 .em{background:linear-gradient(100deg,var(--rust),var(--pink) 45%,var(--magenta) 70%,var(--blue));-webkit-background-clip:text;background-clip:text;color:transparent}
.doc-hero .lede{max-width:var(--prose);margin:16px 0 0;font-size:17px;color:var(--ink-soft);line-height:1.62}
/* ---------- on this page ---------- */
.toc{max-width:var(--prose);margin:22px auto 0;border:1px solid var(--line);border-radius:14px;background:var(--panel);padding:16px 20px}
.toc h2{margin:0 0 10px;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ink-faint);font-weight:600;font-family:var(--mono)}
.toc ol{margin:0;padding-left:0;list-style:none;counter-reset:toc;display:grid;gap:7px}
.toc li{counter-increment:toc;position:relative;padding-left:28px;font-size:14.5px}
.toc li::before{content:counter(toc,decimal-leading-zero);position:absolute;left:0;top:1px;font-family:var(--mono);font-size:11px;color:var(--ink-faint)}
.toc a{text-decoration:none;color:var(--ink-soft)}
.toc a:hover{color:var(--ink)}
/* ---------- prose ---------- */
main{padding:10px 0 30px}
.prose{max-width:var(--prose);margin:0 auto}
.prose section{padding:28px 0;border-top:1px solid var(--line-2)}
.prose h2{font-size:22px;font-weight:600;letter-spacing:-.02em;margin:0 0 6px;scroll-margin-top:80px}
.prose h2 .n{font-family:var(--mono);font-size:13px;color:var(--ink-faint);margin-right:10px;font-weight:500}
.prose h3{font-size:16px;font-weight:600;letter-spacing:-.01em;margin:22px 0 6px;color:var(--ink)}
.prose p{margin:12px 0;color:var(--ink-soft);font-size:15.5px;line-height:1.65}
.prose ul{margin:12px 0;padding-left:22px;color:var(--ink-soft);font-size:15.5px;line-height:1.65}
.prose li{margin:7px 0}
.prose strong{color:var(--ink);font-weight:600}
.prose a{color:var(--ink);text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}
.prose a:hover{text-decoration-color:var(--magenta)}
.prose code{font-family:var(--mono);font-size:.86em;background:var(--panel-2);border:1px solid var(--line-2);border-radius:5px;padding:1px 6px}
.prose .lede{font-size:17px;color:var(--ink);line-height:1.6}
.kbd{font-family:var(--mono);font-size:.82em;background:var(--panel);border:1px solid var(--line);border-bottom-width:2px;border-radius:6px;padding:1px 7px;color:var(--ink);white-space:nowrap}
.ui{font-weight:600;color:var(--ink)}
/* two-path table */
.ftable{width:100%;border-collapse:collapse;margin:16px 0;font-size:14px}
.ftable th,.ftable td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--line-2);vertical-align:top}
.ftable th{font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--ink-faint);font-weight:600}
.ftable td:first-child{font-weight:600;color:var(--ink);white-space:nowrap}
.ftable td:last-child{color:var(--ink-soft)}
/* callouts */
.callout{border-left:3px solid var(--line);border-radius:0 10px 10px 0;padding:14px 16px;margin:18px 0;font-size:14.5px;color:var(--ink-soft);line-height:1.6}
.callout strong{color:var(--ink)}
.callout code{font-family:var(--mono);font-size:.86em;background:var(--panel-2);border:1px solid var(--line-2);border-radius:5px;padding:1px 6px}
.callout.note{border-color:color-mix(in srgb,var(--blue) 60%,var(--line));background:color-mix(in srgb,var(--blue) 6%,var(--panel))}
.callout.tip{border-color:color-mix(in srgb,var(--green) 60%,var(--line));background:color-mix(in srgb,var(--green) 6%,var(--panel))}
.callout.warn{border-color:color-mix(in srgb,var(--amber) 70%,var(--line));background:color-mix(in srgb,var(--amber) 8%,var(--panel))}
.callout .lbl{display:block;font-size:11px;letter-spacing:.1em;text-transform:uppercase;font-family:var(--mono);margin-bottom:4px}
.callout.note .lbl{color:var(--blue)}
.callout.tip .lbl{color:var(--green)}
.callout.warn .lbl{color:color-mix(in srgb,var(--amber) 80%,var(--ink))}
/* numbered steps */
.steps{counter-reset:step;list-style:none;padding:0;margin:16px 0}
.steps>li{counter-increment:step;position:relative;padding:0 0 20px 46px;margin:0}
.steps>li::before{content:counter(step);position:absolute;left:0;top:-2px;width:30px;height:30px;border-radius:9px;background:var(--ink);color:#fff;font-family:var(--mono);font-size:13px;display:flex;align-items:center;justify-content:center}
.steps>li:not(:last-child)::after{content:"";position:absolute;left:14px;top:32px;bottom:6px;width:2px;background:var(--line)}
.steps>li h3{margin:4px 0 4px;font-size:16px}
.steps>li p{margin:6px 0}
/* code block */
pre{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 16px;overflow-x:auto;margin:14px 0}
pre code{font-family:var(--mono);font-size:13px;color:var(--ink);line-height:1.7;background:none;border:0;padding:0}
pre code .c{color:var(--ink-faint)}
/* next / related */
.related{max-width:var(--prose);margin:0 auto;padding:26px 0 0;border-top:1px solid var(--line-2)}
.related h2{font-size:12px;letter-spacing:.12em;text-transform:uppercase;color:var(--ink-faint);font-weight:600;font-family:var(--mono);margin:0 0 12px}
.related a{display:flex;align-items:center;gap:9px;text-decoration:none;color:var(--ink);border:1px solid var(--line);border-radius:12px;padding:13px 16px;margin:8px 0;font-weight:500;font-size:15px;transition:border-color .14s ease}
.related a:hover{border-color:color-mix(in srgb,var(--magenta) 45%,var(--line))}
.related a .arw{margin-left:auto;color:var(--ink-faint)}
footer{border-top:1px solid var(--line-2);padding:30px 0 50px;margin-top:24px}
.foot{display:flex;align-items:center;justify-content:space-between;gap:18px;flex-wrap:wrap;color:var(--ink-faint);font-size:13px}
.foot .mono{font-size:12px}
.foot-links{display:flex;gap:18px;flex-wrap:wrap}
.foot-links a{text-decoration:none;color:var(--ink-soft)}
.foot-links a:hover{color:var(--ink)}
.dotsep{color:var(--line)}
</style>
</head>
<body>
<!-- ===== top bar ===== -->
<header>
<div class="wrap bar">
<a class="brand" href="/">
<svg class="mk" viewBox="0 0 100 100" aria-hidden="true">
<g fill="none" stroke-width="7">
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(80 50 50)" stroke="#7AA7DD"/>
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(20 50 50)" stroke="#DF619B"/>
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(140 50 50)" stroke="#C5826F"/>
</g>
</svg>
Nucleic
</a>
<nav class="nav">
<a class="lk" href="/support.html">Support</a>
<a class="btn" href="/">Home</a>
</nav>
</div>
</header>
<div class="wrap">
<nav class="crumbs" aria-label="Breadcrumb">
<a href="/support.html">Support</a><span class="sep">/</span>
Sandboxes
</nav>
</div>
<!-- ===== title ===== -->
<section class="doc-hero">
<div class="wrap">
<div class="kicker">Sandboxes</div>
<h1>How agent <span class="em">sandboxing</span> works</h1>
<p class="lede">Every sandboxed session runs in its own lightweight Linux VM, built on Apple's containerization framework — isolated from your Mac and from every other session.</p>
</div>
</section>
<div class="wrap">
<nav class="toc" aria-label="On this page">
<h2>On this page</h2>
<ol>
<li><a href="#what">What the sandbox is</a></li>
<li><a href="#isolates">What it isolates</a></li>
<li><a href="#enable">Turning it on</a></li>
<li><a href="#options">Per-project options</a></li>
<li><a href="#toolchain">Why the Swift/Xcode toolchain isn't inside</a></li>
<li><a href="#requirements">Requirements &amp; storage</a></li>
</ol>
</nav>
</div>
<main>
<div class="wrap prose">
<section id="what">
<h2><span class="n">01</span>What the sandbox is</h2>
<p class="lede">When you sandbox a session, Nucleic runs the coding agent inside its own isolated Linux VM — a fresh one per session, spun up on demand and thrown away when it's done.</p>
<p>The VM is built on Apple's in-process <strong>containerization framework</strong> — the same API behind Apple's <code>container</code> CLI. It's <strong>not Docker</strong>, and there's <strong>no background daemon</strong>: everything runs inside the Nucleic app process. Because the VMs are bounded by that process, they're torn down when Nucleic quits and recreated on demand the next time you launch.</p>
<p>The agent itself is spawned as a container process, and its input and output ride a <code>vsock</code> channel back to the host. That means Nucleic drives the sandboxed agent exactly the way it would drive a plain local process — the sandbox is transparent to everything upstream. Your project's git worktree is bind-mounted into the container, so the agent edits the same files you'd expect, just from inside the VM.</p>
<div class="callout note"><span class="lbl">Good to know</span>There's nothing to install and no daemon to keep running. The container service lives and dies with the Nucleic app — quit Nucleic and every sandbox VM goes away with it.</div>
</section>
<section id="isolates">
<h2><span class="n">02</span>What it isolates</h2>
<p>Each sandbox is walled off along three axes — files, network, and the host — so a session can't reach past its own boundary.</p>
<ul>
<li><strong>Files.</strong> Concurrent sessions get separate mounts and cannot corrupt each other's files. Every session sees its own worktree and nothing else.</li>
<li><strong>Network.</strong> The container reaches the network through a NAT'd virtual interface. It has internet <em>egress</em> — enough for <code>git</code>, <code>npm</code>, and agent-provider API calls — but <strong>no inbound exposed ports</strong>. Nothing on your network can reach into the sandbox.</li>
<li><strong>The host.</strong> The control plane — the approval server plus the git and command interceptors — talks to the host over <code>vsock</code>, not over a network socket. Because that channel isn't network traffic, there are <strong>no macOS firewall prompts</strong> to click through.</li>
</ul>
<p>The net effect: a sandboxed agent can do its work and phone out to the services it legitimately needs, while staying isolated from your Mac and from every other running session.</p>
</section>
<section id="enable">
<h2><span class="n">03</span>Turning it on</h2>
<p>Sandboxing is controlled from one place, with a per-project default you can opt into:</p>
<ol class="steps">
<li>
<h3>Enable the container service</h3>
<p>In <span class="ui">Settings ▸ Sandbox</span>, turn on <span class="ui">Enable container service</span>. This starts the containerization layer that hosts the per-session VMs.</p>
</li>
<li>
<h3>Optionally sandbox new projects by default</h3>
<p>Turn on <span class="ui">Sandbox new projects by default</span> so every new project starts sandboxed. You can still flip sandboxing per project afterward.</p>
</li>
</ol>
<div class="callout tip"><span class="lbl">Control keeps it on</span>While any <a href="/support/nucleic-control.html">Nucleic Control</a> project exists, the container service is pinned on and the toggle is locked — Control can't run without it. You'll see <em>"Required by your Nucleic Control projects, so it stays on."</em> Remove the last Control project and the toggle unlocks again.</div>
</section>
<section id="options">
<h2><span class="n">04</span>Per-project options</h2>
<p>Each project's sandbox can be tuned independently. The knobs live in the project's settings:</p>
<table class="ftable">
<thead><tr><th>Option</th><th>What it does</th></tr></thead>
<tbody>
<tr>
<td>Sandboxing</td>
<td>Turn the sandbox on or off for this project.</td>
</tr>
<tr>
<td>Image</td>
<td>Point the sandbox at a custom container <strong>image</strong> instead of the default root image.</td>
</tr>
<tr>
<td>Idle timeout</td>
<td>How long the container can sit idle before it stops to free resources — default <strong>15 minutes</strong>. It reboots quickly on the next turn, so the pause is nearly invisible.</td>
</tr>
<tr>
<td>Host exec</td>
<td>Allow the approval-gated <code>host_exec</code> tool, which lets the sandboxed agent run a command on the host Mac. It's <strong>never auto-approved</strong> — every call is gated on your explicit approval.</td>
</tr>
<tr>
<td>Setup script</td>
<td>A per-project <strong>setup script</strong> that runs at session start — the place to install dependencies before the agent gets going.</td>
</tr>
</tbody>
</table>
<div class="callout note"><span class="lbl">Idle, not gone</span>An idle-stopped container isn't discarded — its state comes back on the next turn after a quick reboot. The timeout just keeps parked sessions from holding resources.</div>
</section>
<section id="toolchain">
<h2><span class="n">05</span>Why the Swift/Xcode toolchain isn't inside</h2>
<p>The sandbox image deliberately <strong>omits the Swift/Xcode toolchain</strong> — the container is Linux, and it simply can't run it. So Mac-only work — Swift builds, <code>xcodebuild</code>, <code>codesign</code>, simulators — has to escape the sandbox. There are two ways out:</p>
<table class="ftable">
<thead><tr><th>Path</th><th>What it is</th></tr></thead>
<tbody>
<tr>
<td>host_exec</td>
<td>Runs the command on the <strong>shared host Mac</strong>. It's concurrency-gated, so parallel agents don't thrash the one host. Good for occasional Mac-side commands, but it shares a single machine across sessions.</td>
</tr>
<tr>
<td>macOS VM<br>(preferred)</td>
<td>Gives each session its <strong>own isolated Mac</strong> through <code>mac_vm_exec</code>. Because every agent gets a separate VM, this is the path to use for <strong>parallel Mac builds</strong> — sessions don't contend for one shared host.</td>
</tr>
</tbody>
</table>
<p>For anything more than a stray Mac-side command — and especially when several agents are building at once — reach for a per-session <a href="/support/virtual-machines.html">macOS VM</a> rather than piling everything onto the shared host through <code>host_exec</code>.</p>
<div class="callout tip"><span class="lbl">Rule of thumb</span>One-off Mac command? <code>host_exec</code> is fine. Real Mac builds, or several agents building in parallel? Give each one its own macOS VM.</div>
</section>
<section id="requirements">
<h2><span class="n">06</span>Requirements &amp; storage</h2>
<p>Sandboxing needs <strong>macOS&nbsp;26 (Tahoe) or later on Apple silicon</strong> — the containerization framework it's built on isn't available before that.</p>
<p>On first use, Nucleic downloads and caches two things under its Application Support folder:</p>
<ul>
<li>A <strong>Linux kernel</strong> — small.</li>
<li>The <strong>sandbox root image</strong> — about <strong>14&nbsp;GB</strong>.</li>
</ul>
<p>That first download is a one-time cost. Later sessions reuse the cache, so spinning up a new sandbox is fast and doesn't re-fetch anything.</p>
</section>
</div>
<div class="wrap related">
<h2>Related guides</h2>
<a href="/support/nucleic-control.html">What is Nucleic Control?<span class="arw" aria-hidden="true">&rarr;</span></a>
<a href="/support/virtual-machines.html">macOS virtual machines: overview<span class="arw" aria-hidden="true">&rarr;</span></a>
</div>
</main>
<footer>
<div class="wrap foot">
<div class="brand" style="font-size:15px">
<svg class="mk" viewBox="0 0 100 100" aria-hidden="true">
<g fill="none" stroke-width="7">
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(80 50 50)" stroke="#7AA7DD"/>
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(20 50 50)" stroke="#DF619B"/>
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(140 50 50)" stroke="#C5826F"/>
</g>
</svg>
Nucleic
</div>
<div class="foot-links">
<a href="/">Home</a>
<a href="/support.html">Support</a>
<a href="/privacy.html">Privacy</a>
<a href="/terms.html">Terms</a>
</div>
<div class="mono">© 2026 Andrew Blakeslee&nbsp;Moore · Made with Nucleic</div>
</div>
</footer>
</body>
</html>
+300
View File
@@ -0,0 +1,300 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>macOS virtual machines: overview — Nucleic Support</title>
<meta name="description" content="Nucleic's macOS VMs give each agent session its own isolated Mac for Xcode, the simulators, and codesign — so parallel Mac builds never collide on your host." />
<meta name="theme-color" content="#faf7f3" />
<meta name="robots" content="index, follow" />
<!-- atom mark, reused as favicon -->
<link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><g fill='none' stroke-width='7'><ellipse cx='50' cy='50' rx='17' ry='40' transform='rotate(80 50 50)' stroke='%237AA7DD'/><ellipse cx='50' cy='50' rx='17' ry='40' transform='rotate(20 50 50)' stroke='%23DF619B'/><ellipse cx='50' cy='50' rx='17' ry='40' transform='rotate(140 50 50)' stroke='%23C5826F'/></g></svg>" />
<link rel="preload" href="../fonts/space-grotesk.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="../fonts/jetbrains-mono.woff2" as="font" type="font/woff2" crossorigin />
<style>
@font-face{font-family:'Space Grotesk';font-style:normal;font-weight:400 700;font-display:swap;src:url(../fonts/space-grotesk.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'JetBrains Mono';font-style:normal;font-weight:400 500;font-display:swap;src:url(../fonts/jetbrains-mono.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
</style>
<style>
:root{
--ink:#181520;--ink-soft:#5b5560;--ink-faint:#8b8590;
--paper:#faf7f3;--panel:#fffdfb;--panel-2:#f3ede6;--line:#e6ded4;--line-2:#efe9e1;
--blue:#5e9bd8;--pink:#db5f97;--magenta:#c264ad;--rust:#c47e69;--green:#5fa775;--amber:#d7a23f;
--sans:"Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
--mono:"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
--maxw:1080px;--prose:760px;
}
*{box-sizing:border-box}
html{scroll-behavior:smooth}
body{
margin:0;overflow-x:clip;font-family:var(--sans);color:var(--ink);background:var(--paper);
background-image:radial-gradient(var(--line) 1px, transparent 1px);
background-size:26px 26px;background-position:-13px -13px;
-webkit-font-smoothing:antialiased;line-height:1.5;
}
a{color:inherit}
.wrap{max-width:var(--maxw);margin:0 auto;padding:0 24px}
.mono{font-family:var(--mono)}
.kicker{font-family:var(--mono);font-size:11.5px;letter-spacing:.16em;text-transform:uppercase;color:var(--ink-faint)}
/* ---------- top bar ---------- */
header{position:sticky;top:0;z-index:20;backdrop-filter:saturate(1.1) blur(8px);background:color-mix(in srgb, var(--paper) 82%, transparent);border-bottom:1px solid var(--line-2)}
.bar{display:flex;align-items:center;justify-content:space-between;height:62px}
.brand{display:flex;align-items:center;gap:11px;font-weight:600;letter-spacing:-.01em;font-size:17px;text-decoration:none;color:var(--ink)}
.brand .mk{width:26px;height:26px;display:block}
.nav{display:flex;align-items:center;gap:22px;font-size:14px;color:var(--ink-soft)}
.nav a{text-decoration:none}
.nav .lk:hover{color:var(--ink)}
.btn{font-family:var(--sans);font-size:14px;font-weight:500;text-decoration:none;cursor:pointer;border:0;padding:9px 16px;border-radius:10px;color:#fff;background:var(--ink);transition:transform .12s ease, opacity .12s ease;white-space:nowrap;-webkit-tap-highlight-color:transparent}
.btn:link,.btn:visited,.btn:hover,.btn:focus,.btn:active{color:#fff}
.btn:hover{opacity:.9;transform:translateY(-1px)}
@media (max-width:600px){ .nav .lk{display:none} }
/* ---------- doc header ---------- */
.crumbs{max-width:var(--prose);margin:0 auto;padding:26px 0 0;font-family:var(--mono);font-size:12px;color:var(--ink-faint);letter-spacing:.02em}
.crumbs a{text-decoration:none;color:var(--ink-soft)}
.crumbs a:hover{color:var(--ink)}
.crumbs .sep{color:var(--line);margin:0 8px}
.doc-hero{padding:16px 0 22px}
.doc-hero .wrap{max-width:var(--prose)}
.doc-hero h1{font-size:clamp(30px,4.6vw,44px);line-height:1.05;letter-spacing:-.03em;font-weight:600;margin:12px 0 0}
.doc-hero h1 .em{background:linear-gradient(100deg,var(--rust),var(--pink) 45%,var(--magenta) 70%,var(--blue));-webkit-background-clip:text;background-clip:text;color:transparent}
.doc-hero .lede{max-width:var(--prose);margin:16px 0 0;font-size:17px;color:var(--ink-soft);line-height:1.62}
/* ---------- on this page ---------- */
.toc{max-width:var(--prose);margin:22px auto 0;border:1px solid var(--line);border-radius:14px;background:var(--panel);padding:16px 20px}
.toc h2{margin:0 0 10px;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ink-faint);font-weight:600;font-family:var(--mono)}
.toc ol{margin:0;padding-left:0;list-style:none;counter-reset:toc;display:grid;gap:7px}
.toc li{counter-increment:toc;position:relative;padding-left:28px;font-size:14.5px}
.toc li::before{content:counter(toc,decimal-leading-zero);position:absolute;left:0;top:1px;font-family:var(--mono);font-size:11px;color:var(--ink-faint)}
.toc a{text-decoration:none;color:var(--ink-soft)}
.toc a:hover{color:var(--ink)}
/* ---------- prose ---------- */
main{padding:10px 0 30px}
.prose{max-width:var(--prose);margin:0 auto}
.prose section{padding:28px 0;border-top:1px solid var(--line-2)}
.prose h2{font-size:22px;font-weight:600;letter-spacing:-.02em;margin:0 0 6px;scroll-margin-top:80px}
.prose h2 .n{font-family:var(--mono);font-size:13px;color:var(--ink-faint);margin-right:10px;font-weight:500}
.prose h3{font-size:16px;font-weight:600;letter-spacing:-.01em;margin:22px 0 6px;color:var(--ink)}
.prose p{margin:12px 0;color:var(--ink-soft);font-size:15.5px;line-height:1.65}
.prose ul{margin:12px 0;padding-left:22px;color:var(--ink-soft);font-size:15.5px;line-height:1.65}
.prose li{margin:7px 0}
.prose strong{color:var(--ink);font-weight:600}
.prose a{color:var(--ink);text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}
.prose a:hover{text-decoration-color:var(--magenta)}
.prose code{font-family:var(--mono);font-size:.86em;background:var(--panel-2);border:1px solid var(--line-2);border-radius:5px;padding:1px 6px}
.prose .lede{font-size:17px;color:var(--ink);line-height:1.6}
.kbd{font-family:var(--mono);font-size:.82em;background:var(--panel);border:1px solid var(--line);border-bottom-width:2px;border-radius:6px;padding:1px 7px;color:var(--ink);white-space:nowrap}
.ui{font-weight:600;color:var(--ink)}
/* two-path table */
.ftable{width:100%;border-collapse:collapse;margin:16px 0;font-size:14px}
.ftable th,.ftable td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--line-2);vertical-align:top}
.ftable th{font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--ink-faint);font-weight:600}
.ftable td:first-child{font-weight:600;color:var(--ink);white-space:nowrap}
.ftable td:last-child{color:var(--ink-soft)}
/* callouts */
.callout{border-left:3px solid var(--line);border-radius:0 10px 10px 0;padding:14px 16px;margin:18px 0;font-size:14.5px;color:var(--ink-soft);line-height:1.6}
.callout strong{color:var(--ink)}
.callout code{font-family:var(--mono);font-size:.86em;background:var(--panel-2);border:1px solid var(--line-2);border-radius:5px;padding:1px 6px}
.callout.note{border-color:color-mix(in srgb,var(--blue) 60%,var(--line));background:color-mix(in srgb,var(--blue) 6%,var(--panel))}
.callout.tip{border-color:color-mix(in srgb,var(--green) 60%,var(--line));background:color-mix(in srgb,var(--green) 6%,var(--panel))}
.callout.warn{border-color:color-mix(in srgb,var(--amber) 70%,var(--line));background:color-mix(in srgb,var(--amber) 8%,var(--panel))}
.callout .lbl{display:block;font-size:11px;letter-spacing:.1em;text-transform:uppercase;font-family:var(--mono);margin-bottom:4px}
.callout.note .lbl{color:var(--blue)}
.callout.tip .lbl{color:var(--green)}
.callout.warn .lbl{color:color-mix(in srgb,var(--amber) 80%,var(--ink))}
/* numbered steps */
.steps{counter-reset:step;list-style:none;padding:0;margin:16px 0}
.steps>li{counter-increment:step;position:relative;padding:0 0 20px 46px;margin:0}
.steps>li::before{content:counter(step);position:absolute;left:0;top:-2px;width:30px;height:30px;border-radius:9px;background:var(--ink);color:#fff;font-family:var(--mono);font-size:13px;display:flex;align-items:center;justify-content:center}
.steps>li:not(:last-child)::after{content:"";position:absolute;left:14px;top:32px;bottom:6px;width:2px;background:var(--line)}
.steps>li h3{margin:4px 0 4px;font-size:16px}
.steps>li p{margin:6px 0}
/* code block */
pre{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 16px;overflow-x:auto;margin:14px 0}
pre code{font-family:var(--mono);font-size:13px;color:var(--ink);line-height:1.7;background:none;border:0;padding:0}
pre code .c{color:var(--ink-faint)}
/* next / related */
.related{max-width:var(--prose);margin:0 auto;padding:26px 0 0;border-top:1px solid var(--line-2)}
.related h2{font-size:12px;letter-spacing:.12em;text-transform:uppercase;color:var(--ink-faint);font-weight:600;font-family:var(--mono);margin:0 0 12px}
.related a{display:flex;align-items:center;gap:9px;text-decoration:none;color:var(--ink);border:1px solid var(--line);border-radius:12px;padding:13px 16px;margin:8px 0;font-weight:500;font-size:15px;transition:border-color .14s ease}
.related a:hover{border-color:color-mix(in srgb,var(--magenta) 45%,var(--line))}
.related a .arw{margin-left:auto;color:var(--ink-faint)}
footer{border-top:1px solid var(--line-2);padding:30px 0 50px;margin-top:24px}
.foot{display:flex;align-items:center;justify-content:space-between;gap:18px;flex-wrap:wrap;color:var(--ink-faint);font-size:13px}
.foot .mono{font-size:12px}
.foot-links{display:flex;gap:18px;flex-wrap:wrap}
.foot-links a{text-decoration:none;color:var(--ink-soft)}
.foot-links a:hover{color:var(--ink)}
.dotsep{color:var(--line)}
</style>
</head>
<body>
<!-- ===== top bar ===== -->
<header>
<div class="wrap bar">
<a class="brand" href="/">
<svg class="mk" viewBox="0 0 100 100" aria-hidden="true">
<g fill="none" stroke-width="7">
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(80 50 50)" stroke="#7AA7DD"/>
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(20 50 50)" stroke="#DF619B"/>
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(140 50 50)" stroke="#C5826F"/>
</g>
</svg>
Nucleic
</a>
<nav class="nav">
<a class="lk" href="/support.html">Support</a>
<a class="btn" href="/">Home</a>
</nav>
</div>
</header>
<div class="wrap">
<nav class="crumbs" aria-label="Breadcrumb">
<a href="/support.html">Support</a><span class="sep">/</span>
Virtual Machines
</nav>
</div>
<!-- ===== title ===== -->
<section class="doc-hero">
<div class="wrap">
<div class="kicker">Virtual Machines</div>
<h1>macOS <span class="em">virtual machines</span></h1>
<p class="lede">Give an agent a whole Mac of its own — a per-session macOS VM for Xcode, the simulators, and codesign, so parallel Mac builds never collide on your host.</p>
</div>
</section>
<div class="wrap">
<nav class="toc" aria-label="On this page">
<h2>On this page</h2>
<ol>
<li><a href="#what">What they are &amp; why</a></li>
<li><a href="#requirements">Requirements</a></li>
<li><a href="#turn-on">Turning it on</a></li>
<li><a href="#lifecycle">How a per-session VM works</a></li>
<li><a href="#concurrency">Concurrency &amp; resources</a></li>
<li><a href="#next">What's next</a></li>
</ol>
</nav>
</div>
<main>
<div class="wrap prose">
<section id="what">
<h2><span class="n">01</span>What they are &amp; why</h2>
<p class="lede">Each macOS VM is a per-session, isolated Mac running on Apple's Virtualization framework — a real, disposable macOS instance where <code>xcodebuild</code>, the simulators (<code>xcrun simctl</code>), and <code>codesign</code> run end-to-end without ever touching your host.</p>
<p>They exist to remove shared-host toolchain thrash. Two agents building on the same host collide and corrupt each other's derived-data build output — one clobbers the other's intermediate files mid-compile, and both builds fail in confusing ways. When each agent gets its own isolated Mac, concurrent builds never collide: every session compiles, simulates, and signs in its own clean environment.</p>
<p>This is exposed to agents as the <code>mac_vm_exec</code> tool — the per-agent alternative to the shared-host <code>host_exec</code>. Where <code>host_exec</code> runs commands directly on your Mac (shared by every session), <code>mac_vm_exec</code> runs them inside that session's own VM.</p>
</section>
<section id="requirements">
<h2><span class="n">02</span>Requirements</h2>
<p>Before you turn this on, make sure the machine can carry it:</p>
<ul>
<li><strong>Apple silicon Mac.</strong> Running macOS guests requires Apple silicon — Intel Macs can't host a macOS VM.</li>
<li><strong>Plenty of RAM.</strong> It's heavy: expect several GB of RAM per running VM, on top of a one-time base-image install.</li>
<li><strong>Free disk space.</strong> The golden base image, its downloaded restore image, and each per-session VM disk add up fast.</li>
<li><strong>Host &ge; guest.</strong> The host macOS version must be greater than or equal to the guest — a macOS&nbsp;26 host can't install a macOS&nbsp;27 guest.</li>
</ul>
<div class="callout warn"><span class="lbl">Disk space</span>Keep at least <strong>64&nbsp;GB free</strong> before enabling macOS VMs. Between the golden base image, its restore image, and every per-session VM disk, space disappears quickly — and running low can stall builds or corrupt a VM mid-run.</div>
</section>
<section id="turn-on">
<h2><span class="n">03</span>Turning it on</h2>
<ol class="steps">
<li>
<h3>Enable the service</h3>
<p>In <span class="ui">Settings ▸ Virtual Machines</span>, turn on <span class="ui">Enable macOS VM service</span>.</p>
</li>
<li>
<h3>Optionally expose it to sessions</h3>
<p>Turn on <span class="ui">Expose to sandboxed sessions by default</span> so sessions get the <code>mac_vm_exec</code> tool automatically, without opting in per session.</p>
</li>
<li>
<h3>Build the one-time base image</h3>
<p>Before any VM can boot, you must build the golden base image once — this installs macOS and provisions the dev toolchain. See <a href="/support/macos-vm-base-image.html">Building the base image</a> for the full walkthrough.</p>
</li>
</ol>
<div class="callout note"><span class="lbl">Good to know</span>The base image is a one-time build. Every per-session VM is cloned from it, so you only pay the macOS install cost once — not on every session.</div>
</section>
<section id="lifecycle">
<h2><span class="n">04</span>How a per-session VM works</h2>
<p>The first time a session calls <code>mac_vm_exec</code>, Nucleic spins up a VM just for that session:</p>
<ul>
<li><strong>Clone the golden base.</strong> Nucleic makes a copy-on-write clone of the base image — fast and space-efficient, since unchanged blocks are shared with the base rather than copied.</li>
<li><strong>Boot and connect.</strong> It boots the clone and connects to it over SSH.</li>
<li><strong>Share the repo.</strong> That session's repository is shared into the VM over virtiofs at <code>/Volumes/My Shared Files/workspace</code>, so the agent builds the exact working tree it's editing.</li>
</ul>
<p>When the VM goes idle (default <strong>15 minutes</strong>), it stops to free RAM — but its disk persists, so the next turn reboots quickly instead of re-cloning from scratch. When the session ends or is interrupted, the clone is deleted. Any orphaned clones left behind by a crash are cleaned up on the next app launch.</p>
</section>
<section id="concurrency">
<h2><span class="n">05</span>Concurrency &amp; resources</h2>
<p>macOS caps how many macOS guests can run at once — about <strong>2</strong> on recent releases. Nucleic works within that limit rather than fighting it:</p>
<ul>
<li><strong>Max concurrent VMs.</strong> When the cap is reached, extra agents <em>queue</em> for a VM rather than failing — a session waits its turn for a slot instead of erroring out.</li>
<li><strong>CPUs.</strong> A per-session ceiling on virtual CPUs (default <strong>4</strong>).</li>
<li><strong>Memory.</strong> A per-session memory ceiling (default <strong>8&nbsp;GB</strong>).</li>
</ul>
<div class="callout tip"><span class="lbl">Tuning</span>Raise <span class="ui">CPUs</span> and <span class="ui">Memory</span> for heavier builds, but remember each running VM holds that memory for as long as it's booted — balance the per-VM ceilings against how many run at once.</div>
</section>
<section id="next">
<h2><span class="n">06</span>What's next</h2>
<p>Now that you know what macOS VMs are, these guides take you the rest of the way:</p>
<ul>
<li><strong><a href="/support/macos-vm-base-image.html">Building the base image</a></strong> — the required one-time build that every per-session VM is cloned from.</li>
<li><strong><a href="/support/macos-vm-computer-use.html">Computer use for macOS VMs</a></strong> — letting an agent see and drive the VM's screen with screenshots, clicks, and typing.</li>
<li><strong><a href="/support/macos-vm-ax-agent.html">Setting up AX-based computer use</a></strong> — the advanced, semantic accessibility agent for by-identity control of the guest.</li>
</ul>
</section>
</div>
<div class="wrap related">
<h2>Related guides</h2>
<a href="/support/macos-vm-base-image.html">Building the base image<span class="arw" aria-hidden="true">&rarr;</span></a>
<a href="/support/macos-vm-computer-use.html">Computer use for macOS VMs<span class="arw" aria-hidden="true">&rarr;</span></a>
<a href="/support/macos-vm-ax-agent.html">Setting up AX-based computer use<span class="arw" aria-hidden="true">&rarr;</span></a>
</div>
</main>
<footer>
<div class="wrap foot">
<div class="brand" style="font-size:15px">
<svg class="mk" viewBox="0 0 100 100" aria-hidden="true">
<g fill="none" stroke-width="7">
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(80 50 50)" stroke="#7AA7DD"/>
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(20 50 50)" stroke="#DF619B"/>
<ellipse cx="50" cy="50" rx="17" ry="40" transform="rotate(140 50 50)" stroke="#C5826F"/>
</g>
</svg>
Nucleic
</div>
<div class="foot-links">
<a href="/">Home</a>
<a href="/support.html">Support</a>
<a href="/privacy.html">Privacy</a>
<a href="/terms.html">Terms</a>
</div>
<div class="mono">© 2026 Andrew Blakeslee&nbsp;Moore · Made with Nucleic</div>
</div>
</footer>
</body>
</html>
+2
View File
@@ -169,6 +169,7 @@
Nucleic
</a>
<nav class="nav">
<a class="lk" href="/support.html">Support</a>
<a class="lk" href="/privacy.html">Privacy</a>
<a class="btn" href="/">Home</a>
</nav>
@@ -329,6 +330,7 @@
</div>
<div class="foot-links">
<a href="/">Home</a>
<a href="/support.html">Support</a>
<a href="/privacy.html">Privacy</a>
<a href="/terms.html">Terms</a>
</div>
+2
View File
@@ -184,6 +184,7 @@
Nucleic
</a>
<nav class="nav">
<a class="lk" href="/support.html">Support</a>
<a class="lk" href="/privacy.html">Privacy</a>
<a class="btn" href="/">Home</a>
</nav>
@@ -334,6 +335,7 @@
</div>
<div class="foot-links">
<a href="/">Home</a>
<a href="/support.html">Support</a>
<a href="/privacy.html">Privacy</a>
<a href="/terms.html">Terms</a>
</div>