29 KiB
Nucleic — Native in-guest automation agent (design)
Superseded as the default; now an optional add-on. Since the host-side virtual-IO path landed (MACOS_VM.md §12.2 — capture the guest framebuffer via
VZVirtualMachineView+ injectNSEventHID, all host-side with no guest TCC), that is the default computer-use path. This native agent is retained as an opt-in (MacVMSettings.axAgentEnabled, OFF by default) for its enduring win: semantic, framebuffer-independent Accessibility control (ax_*— read the accessibility tree, act on an element by identity), which is more robust than pixel targeting and lets you inspect an app's AX tree when debugging. Only this agent needs the guest's TCC grants — which the supported macOS 27 one-click base build writes automatically (no manual step; MACOS_VM.md §12.5). The pixel ops it also implements are now redundant with the host-side surface.
A native in-guest agent that observes and controls a macOS guest VM through Apple's frameworks
directly — the Accessibility API (AXUIElement) for semantic control, ScreenCaptureKit for
capture, CGEvent for raw input — reached from the host over vsock. It replaces the current
mac_vm_computer transport (SSH → launchctl asuser → screencapture/cliclick → base64), which is
a stack of shims over those same frameworks.
Status: implemented through §13 phases 1–4 (code + provisioning), pending real-guest
validation (§13 phase 5 / §14). What exists: the guest agent (guest/NucleicVMAgent — its own
SwiftPM package, macOS 27 floor — vsock listener, all §4 ops), the host side (MacVMAgentClient +
the VZVirtioSocketDevice config + the ping-probe/fallback routing in MacVMEngine+ComputerAgent),
the extended mac_vm_computer tool surface (ax_* actions + ref/value), the app-bundle build
(scripts/build-vm-agent.sh), and provisioning Phase 7d (scripts/provision-macos-guest.sh). The
empirical claims to validate on a real guest (guest-side vsock listen, and AX/CGEvent behaviour) are
called out in §9 and §10 — until then the SSH path remains the proven default and the agent is
strictly additive. Companion to MACOS_VM (which this augments) and
VSOCK_CONTROL_PLANE (Nucleic's existing vsock work for the Linux container).
1. Why — the CLI shims are the wrong layer
mac_vm_computer today (see MACOS_VM §12) reaches the guest over SSH and drives it with
CLI tools that are themselves thin wrappers over Apple frameworks:
| Concern | Today (janky) | Native agent |
|---|---|---|
| Input | cliclick over SSH, launchctl asuser, coordinate clicks |
Accessibility API (AXUIElement) — act on a control by identity; CGEvent for raw fallback |
| Observe | screencapture CLI + base64 over SSH |
AXUIElement tree (semantic) + ScreenCaptureKit (pixels), in-process |
| Transport | SSH over NAT + /var/db/dhcpd_leases IP discovery |
vsock — a VM device channel, no NAT/DHCP/SSH, no macOS network prompts |
Three concrete wins, not just tidiness:
-
Semantic control. Instead of "the model looks at a screenshot, guesses
(x, y), and clicks blind," the agent reads the accessibility tree — every control's role, title, value, frame, and available actions — and acts on an element by identity (AXPressa button, set a field'sAXValue). This is how professional macOS UI automation works, and it's dramatically more reliable than pixel targeting. -
The Accessibility tree is framebuffer-independent — a robustness property of AX (§9). The AX tree is vended by AppKit from the app's semantic view model, in-process, independent of compositing and of any pixel readback — so an AX-driven agent observes and drives app UIs from the app's own state rather than from a captured image. This makes control robust even when a capture API returns nothing useful (a compositor hiccup, an off-screen or occluded window), and it's what lets you inspect an app's live AX tree when debugging. It's fundamentally more reliable than guessing coordinates off a screenshot.
-
vsock, not SSH. No NAT/DHCP/lease-parsing, no SSH keys, no
launchctl asusergymnastics, no macOS incoming-connection prompts — a VM device channel handled byVirtualization.framework(docs/VSOCK_CONTROL_PLANE.mdmoved the Linux container's control plane onto vsock for the same reason).
What it does not solve (honesty): TCC is still required — the agent needs Accessibility and Screen Recording grants (§8); an active Aqua/GUI session is still required (AX, SCK and CGEvent all need WindowServer), so auto-login and a per-user LaunchAgent stay.
2. Architecture
Claude agent
│ calls the mac_vm_computer tool (now with AX-semantic actions — §7)
▼
MCPApprovalServer ──▶ ClaudeCodeBackend.handleMacVMComputerCall
│ │
│ ▼
│ MacVMManager ──▶ MacVMEngine
│ │ preferred: native agent over vsock
│ │ fallback: SSH + screencapture/cliclick (today's path, §10)
│ ▼
│ VZVirtioSocketDevice.connect(toPort:) ── host end of vsock ──┐
▼ │
(image + AX tree flow back up as MCP content the model sees) ▼
┌──── macOS guest (the clone) ──────────────┐
│ NucleicVMAgent (LaunchAgent, Aqua session)│
│ AF_VSOCK listener :port │
│ • AXUIElement (observe + control) │
│ • ScreenCaptureKit (capture) │
│ • CGEvent (raw mouse/key/scroll) │
└─────────────────────────────────────────────┘
The native path and the SSH/CLI path coexist: the engine prefers the agent (a vsock ping
succeeds), and transparently falls back to today's SSH path when the agent isn't present or reachable,
so nothing regresses and the base image can adopt the agent incrementally.
3. Transport — vsock
Apple's Virtualization framework gives the host a direct socket into the guest, no IP stack involved.
3.1 Host side (MacVMEngine)
- Config: add exactly one
VZVirtioSocketDeviceConfiguration()toconfig.socketDevicesinbuildConfiguration(currently the mac-VM config has no socket device). - Runtime: after boot, on the VM's serial queue,
vm.socketDevices.first as? VZVirtioSocketDevice. - Connect:
func connect(toPort: UInt32) async throws -> VZVirtioSocketConnection. Apple: "If the guest operating system doesn't listen for connections to the specified port, this method does nothing" — so the guest must be listening first (the agent's listener, §5). The returnedVZVirtioSocketConnectionexposes a real POSIXfileDescriptor(write to send, read to receive; it becomes-1when closed) and aclose(). Wrap the fd inDispatchIO(idiomatic for a socket) or a nonblockingDispatchSource; keep a strong reference to the connection while I/O is in flight. - One device per VM. A
VZVirtioSocketDeviceon macOS can't be told which VM connected on the host side (host CID is fixed at 2, guests at 3), but since Nucleic owns one device per VM instance, that's moot.
3.2 Guest side (NucleicVMAgent)
Standard BSD vsock server (#include <sys/vsock.h>; AF_VSOCK is available inside a macOS guest —
on the host socket(AF_VSOCK, …) fails ENODEV, which is why the host reaches it only via
VZVirtioSocketDevice):
int s = socket(AF_VSOCK, SOCK_STREAM, 0);
struct sockaddr_vm addr = {0};
addr.svm_len = sizeof(addr); // macOS-specific BSD length field (Linux has none)
addr.svm_family = AF_VSOCK;
addr.svm_cid = VMADDR_CID_ANY; // listen on any CID
addr.svm_port = NUCLEIC_AGENT_PORT;
bind(s, (struct sockaddr *)&addr, sizeof(addr));
listen(s, 4);
int conn = accept(s, NULL, NULL); // the host's connect(toPort:) lands here
- Port: a fixed Nucleic-reserved port (e.g.
2035), a shared constant on both sides. - ⚠ Verify on a real guest (§10): Apple's
Virtualizationdocs describe the host-connects / guest-listens topology in guest-OS-agnostic terms, and<sys/vsock.h>exists on macOS, but no single Apple doc names "macOS guestlisten()+ hostconnect(toPort:)" — smoke-test it on the target guest build before building on top. (Linux guests are proven; the macOS-guest listen path is the one inference.) If it doesn't hold, the reverse topology — hostVZVirtioSocketListener+ guest connects toVMADDR_CID_HOST— is the documented fallback.
3.3 Framing
Newline-delimited JSON (NDJSON): one request object per line, one reply per line — the same framing
Nucleic already uses for agent stdio (LineSplitter). Screenshots ride as base64 in the JSON (simple,
matches the existing image path); a length-prefixed binary frame is a future efficiency option if
base64 overhead on a screenshot→act loop matters.
4. Wire protocol (host ⇄ agent)
Requests carry an op; replies carry ok plus op-specific fields (or ok:false, error). Handles to
UI elements are opaque ref strings the agent assigns (§6.2).
op |
Request fields | Reply | Backed by |
|---|---|---|---|
ping |
— | {ok, version, displays:[…]} |
capability negotiation |
screenshot |
display?, format:"jpeg" |
{ok, image:<b64>, width, height, scale} |
ScreenCaptureKit (§7) |
ax_dump |
target:"frontmost"|pid, maxDepth? |
{ok, tree:<node>} |
AXUIElement (§6) |
ax_element_at |
x, y |
{ok, node} |
AXUIElementCopyElementAtPosition |
ax_action |
ref, action:"AXPress"|… |
{ok} |
AXUIElementPerformAction |
ax_set_value |
ref, value |
{ok} |
AXUIElementSetAttributeValue |
ax_focus |
ref |
{ok} |
set kAXFocusedAttribute |
click |
x, y, button?, count? |
{ok} |
CGEvent (§8) |
move |
x, y |
{ok} |
CGEvent |
drag |
fromX,fromY,toX,toY |
{ok} |
CGEvent |
type |
text |
{ok} |
CGEvent keyboard |
key |
chord:"cmd+s" |
{ok} |
CGEvent + flags |
scroll |
dx, dy, x?, y? |
{ok} |
CGEvent scroll wheel (real scroll, unlike the cliclick arrow-key hack). dx/dy are the gesture TOTAL, delivered as a paced stream of 2-line notches the way hardware reports one — a single outsized event is coalesced down to ~one notch by any app that animates its scrolling. x/y move the pointer first: a wheel event routes by pointer position, not by coordinate |
launch_app |
name |
{ok} |
NSWorkspace.openApplication |
A node is:
{ "ref": "e17", "role": "AXButton", "subrole": "AXCloseButton", "title": "Build",
"value": null, "enabled": true, "focused": false,
"frame": { "x": 812, "y": 44, "w": 74, "h": 22 },
"actions": ["AXPress", "AXShowMenu"],
"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 intocommandand 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
A small Swift program, packaged as a signed .app bundle (the correct TCC shape — §8), run as a
per-user LaunchAgent under the agent account so it lives in the Aqua GUI session (AX, SCK and
CGEvent all require an active WindowServer session). Responsibilities:
- Bind the
AF_VSOCKlistener (§3.2); accept loop; per-connection NDJSON request loop. - Implement the §4 ops with
ApplicationServices/AXUIElement(§6),ScreenCaptureKit(§7), andCoreGraphics/CGEvent(§8). - On start, self-check
AXIsProcessTrusted()and Screen-Recording authorization; report readiness in thepingreply so the host can surface a clear "agent present but not TCC-granted" state. LSUIElement(no Dock icon). Small, single-purpose, no network egress.
It is not an MCP server itself — it speaks the private §4 protocol to MacVMEngine, which bridges
it to the mac_vm_computer MCP tool. The host keeps ownership of VM lifecycle, gating, and the
approval boundary; the agent is a dumb, well-scoped actuator.
6. Accessibility — observe + control
Framework: ApplicationServices (re-exports HIServices). All calls are synchronous cross-process
IPC into the target app, governed by AXUIElementSetMessagingTimeout (default ~6 s).
6.1 Reading the tree
- Root:
AXUIElementCreateApplication(pid)for the frontmost app (NSWorkspace.shared.frontmostApplication?.processIdentifier), orAXUIElementCreateSystemWide()for hit-testing. - Per node:
AXUIElementCopyAttributeValueforkAXRole,kAXSubrole,kAXTitle,kAXValue,kAXEnabled,kAXFocused;kAXPosition/kAXSizecome back as anAXValueRefbox — unwrap withAXValueGetValue(_, .cgPoint/.cgSize, _)(AX uses top-left-origin screen coords). Actions viaAXUIElementCopyActionNames. RecursekAXChildren. - Cost control: a full walk of a rich app is thousands of IPC round-trips. Batch with
AXUIElementCopyMultipleAttributeValues, capmaxDepth, dedupe by element identity, and set a tight messaging timeout so a hung app can't wedge the walker.
6.2 Element handles (ref)
AXUIElementRefs go stale as the UI mutates, so the agent keeps a per-connection registry mapping a
short ref id → the live AXUIElementRef it handed out in the last ax_dump/ax_element_at. Refs are
valid until the next dump (or a short TTL); the host's convention is dump → act on a ref → dump
again. A stale ref returns kAXErrorInvalidUIElement, which the host surfaces as "re-dump and retry."
6.3 Control
- Press/menu:
AXUIElementPerformAction(el, kAXPressAction)(alsokAXShowMenu,kAXConfirm,kAXCancel,kAXIncrement/kAXDecrement). TreatkAXErrorCannotCompleteas possibly succeeded — apps often run modal processing inside the action callback and don't return within the AX timeout. - Type into a field: set
kAXFocused = true, thenkAXValue = "<text>"(checkAXUIElementIsAttributeSettablefirst). Some non-AppKit fields rejectAXValuewrites — fall back to focusing the element then CGEvent keystrokes (§8). - Prefer AX actions over CGEvent coordinate clicks wherever a control exposes an action: AX drives
the app in-process and is fully framebuffer-independent (§9); a CGEvent click at
kAXPositionis closer to the pixel world (still geometry-correct, but less robust).
7. Screen capture — ScreenCaptureKit
For pixels: SCShareableContent.current → SCContentFilter (full display or a specific SCWindow) →
SCScreenshotManager.captureImage(contentFilter:configuration:) (macOS 14+) → CGImage → JPEG.
In-process, no screencapture CLI, no launchctl asuser, no base64-over-SSH. Needs Screen Recording
TCC + an Aqua session. Capture depends on the compositor rendering the window, so AX (§6), not SCK,
is the robust "look" primitive whenever a control exposes a semantic identity — pixels are the
fallback for custom-drawn UI.
8. Low-level input — CGEvent
When AX has no action for a target (custom-drawn UI): CGEvent(mouseEventSource:mouseType:…) +
.post(tap: .cghidEventTap) for clicks/moves/drags at screen coordinates; CGEvent(keyboardEventSource: virtualKey:keyDown:) with .flags for keystrokes and chords; CGEvent(scrollWheelEvent2Source:…) for
real scroll (a proper wheel event — the current cliclick path fakes scroll with arrow keys). CGEvent
needs Accessibility TCC and the Aqua session. Note CGEvent clicks target pixel coordinates, so they lean
on window geometry being correct but are less framebuffer-independent than AX actions; prefer AX.
9. Framebuffer independence (the enduring robustness property — HIGH confidence)
The Accessibility tree is architecturally independent of the rendered framebuffer: AppKit vends it
from the view's semantic model (roles/titles/values inferred from NSAccessibility conformance),
accessibilityFrame is a layout-geometry computation (not a pixel readback), and
AXUIElementPerformAction invokes the control's action callback in the app process — no rendering,
no synthetic on-screen click required. NSAccessibilityElement can even back UI with no NSView at
all, proving AX is decoupled from any drawn surface.
Consequently AX observe+control is driven by the app's own state, not by a captured image. It stays
correct where a capture API is unreliable — an occluded or off-screen window, a compositor hiccup —
because CGWindowListCopyWindowInfo bounds and the window server's geometry remain valid even when the
pixels don't. That decoupling is exactly what makes semantic control more robust than pixel targeting.
Caveats (none framebuffer-related, but real): lazy/virtualized views (table cells) may be absent
from the tree until laid out; non-AppKit apps (Electron/Chromium, Java, GL/game UIs) expose poor or
empty AX trees regardless of platform — Chromium needs AXManualAccessibility/--force-renderer- accessibility, Electron app.setAccessibilitySupportEnabled(true); and some toolkits return
kAXErrorCannotComplete/kAXErrorAPIDisabled intermittently — budget retries and agent-restart
handling. Still verify empirically on a real guest — the architecture is confirmed but should be
smoke-tested.
10. Tool surface & fallback
mac_vm_computer gains AX-semantic actions alongside today's pixel actions
(MACOS_VM §12.1):
ax_dump→ returns the front app's element tree (roles/titles/values/frames/actions) as text the model reads — the semantic "look," and the framebuffer-independent one. Optionally also returns a screenshot.ax_press/ax_set_value/ax_focus(byref) → act on a control by identity.ax_element_at(x,y) → resolve the element under a point.- Existing
screenshot/left_click/type/key/scroll— now routed through the agent's ScreenCaptureKit/CGEvent when present (cleaner, andscrollbecomes real).
Guidance to the model: prefer ax_dump + act-by-ref; fall back to screenshot + pixel click when a
control has no AX action or isn't in the tree.
Fallback / capability negotiation. MacVMEngine tries a vsock ping when it needs the agent; on
success it routes ops natively, on failure (no agent baked in, not launched, not TCC-granted, or vsock
listen unsupported) it uses today's SSH + screencapture/cliclick path unchanged. Per-op fallback is
possible (e.g. native input but SSH screenshot) but the simplest first cut is all-or-nothing by ping.
AX-semantic ops have no SSH equivalent, so ax_dump/ax_* are only advertised when the agent is
present.
11. TCC, packaging, provisioning
- TCC: the agent needs three distinct grants, each a separate
TCC.dbservice:kTCCServiceAccessibility(AXUIElement read/control —AXIsProcessTrusted),kTCCServicePostEvent(CGEvent input synthesis — surfaced under the "Accessibility" list but a distinct row;CGPreflightPostEventAccess/CGRequestPostEventAccess), andkTCCServiceScreenCapture(ScreenCaptureKit —CGPreflight/CGRequestScreenCaptureAccess). A proper signed.appbundle with a native Mach-O main executable is the correct shape (TCC mis-handles script-based executables, and recent macOS wants a real bundle for Screen Recording, attributed to the responsible process) — an improvement over granting a bare CLI. Pre-grant all three to the agent's bundle-id / code requirement via the same direct system-TCC.dbwrite the provisioner does automatically on the supported macOS 27 base build (MACOS_VM §12.5); no other unattended path exists (PPPC can't silently grant Screen Recording;tccutilcan't grant). Note a grant is often not picked up until the process restarts (esp. Screen Recording), so the LaunchAgent should be bounced after provisioning writes the rows. If a grant is missing at runtime the agent reports it in thepingreply (viaAXIsProcessTrusted+ theCGPreflight*checks) so the host can surface an actionable "agent present but not authorized" state rather than silently failing. - Packaging: a real signed
.appbundle is mandatory-in-practice, not just tidy — TCC mis-handles bare (non-bundled) executables for Screen Recording (they may not appear in the Screen Recording settings list and can be declined even with aauth_value=2row), so a real bundle attributed to the responsible process is the correct shape. So: shipNucleicVMAgent.appwith aCFBundleIdentifier, anNSScreenCaptureUsageDescription, and hardened runtime (codesign --options runtime). Sign with a stable identity (Developer ID, or a self-signed cert baked into the image) — not ad-hoc: TCC keys the grant to the app's designated requirement (thecsreqblob), and an ad-hoc signature changes identity every build, so a pre-inserted TCC row stops matching after a rebuild. Notarization is not required (the app is never quarantined inside the guest); strip any quarantine xattr (xattr -dr com.apple.quarantine) when baking it in. Install to e.g./Applications/NucleicVMAgent.app+ a LaunchAgent at/Library/LaunchAgents/xyz.blakeslee.nucleic.vmagent.plist(ProgramArguments→ the bundle'sContents/MacOS/NucleicVMAgent,RunAtLoad,KeepAlive,LimitLoadToSessionType=Aqua,AssociatedBundleIdentifiers) so it runs in the auto-login Aqua session. This extendsscripts/provision-macos-guest.shPhase 7. The TCC pre-grant rows useclient_type=0(bundle id),auth_value=2, and the app'scsreqblob (codesign -d -r- … | csreq -r- -b …) in the systemTCC.db; reboot sotccdreloads. - macOS 27 note: on the supported macOS 27 host+guest,
VZMacGuestProvisioningOptions(gated#available(macOS 27, *)) injects the account + enables auto-login declaratively during the one-click base build, and the same pass writes this agent's TCC grants — so there is no manual Setup-Assistant or SIP step. See MACOS_VM §4.4.
12. Fit with the existing code (as built)
guest/NucleicVMAgent— the agent, as its own SwiftPM package (not a target of the main one): it's a separate package because it runs inside the guest and SwiftPM has no per-target platform floors. Both it and the main package floor at macOS 27. Targets:CVsock(C shim — Swift's Darwin overlay doesn't surfacesockaddr_vm),VMAgentCore(pure wire/keymap logic, unit-tested), and theNucleicVMAgentexecutable (ApplicationServices,ScreenCaptureKit,CoreGraphics,AppKit).scripts/build-vm-agent.shwraps it into the signed.app(+ the LaunchAgent plist).MacVMEngine—buildConfigurationattaches oneVZVirtioSocketDeviceConfiguration;MacVMAgentClient.swiftholds the NDJSON client (an actor over the vsock fd), the wire constants (MacVMAgentWire, port 2035 — mirrored inVMAgentCore.AgentWire), and theping-probe capability cache per live VM (LiveVM.agent: unprobed → available/unavailable; a transport failure re-probes once, an absent agent stays absent for the VM's lifetime).MacVMEngine+ComputerAgent— routesperformComputerActionthrough the agent when present (action ran natively ⇒ never re-run over SSH; capture-only fallbacks after), SSH/cliclick otherwise; implements theax_*ops (no SSH fallback for those).MCPApprovalServer— themac_vm_computerschema carries the AX actions +ref/valuefields; the AX tree returns as text content (and screenshots as image content, as before).scripts/provision-macos-guest.sh— Phase 7d installs the agent app + LaunchAgent + its three TCC grants (bundle-id-keyed rows), written automatically during the macOS 27 base build; it skips gracefully when the app isn't staged.- Shipped embedded + auto-installed.
scripts/package-app.shbakes the signedNucleicVMAgent.app(+ its LaunchAgent plist + the provisioner) into the host app underContents/Resources/macvm/(signed inside-out, so its designated requirement is stable across builds — the TCC rows key on it). The one-click Build base image then stages it into the guest and runs Phase 7d over SSH (MacVMEngine+Provision.swift, resolved viaresolveVMAgentApp(); dev/SwiftPM runs fall back to building it from source). No separatescripts/build-vm-agent.shstep for the operator. MacVMSettings— no new toggle: the agent is auto-preferred when present (the ping probe is the switch), and the SSH path engages transparently otherwise. Whether the base installs the agent is gated on the existing computer-use opt-in.
13. Rollout plan (phased, each independently landable)
- ✅ Transport core —
NucleicVMAgentskeleton with the vsock listener +ping/screenshot; hostVZVirtioSocketDeviceconfig + client. Code landed; the real-guest round-trip that proves §3.2 is still owed (see 5). SSH path stays the default until this is proven. - ✅ Input + capture over the agent —
click/move/type/key/scroll(real scroll) + SCKscreenshot;mac_vm_computerprefers the agent, SSH fallback intact. - ✅ Accessibility —
ax_dump/ax_element_at/ax_action/ax_set_value+ the tool-schema actions + model guidance. This is where the semantic + framebuffer-independent wins land. - ✅ Provisioning + base image — the agent, LaunchAgent, and TCC grants in
provision-macos-guest.shPhase 7d (agent app staged fromscripts/build-vm-agent.shoutput). - ⬜ Validate on a real guest — smoke-test the vsock round-trip (§3.2), then confirm AX observe+control works on a macOS 27 guest (§9).
14. Open questions / verify-on-real-VM
- Guest-side vsock
listen()on a macOS guest + hostconnect(toPort:)— strongly inferred, not single-doc-confirmed (§3.2). Smoke-test; reverse topology is the fallback. - AX + CGEvent behaviour on a real macOS 27 guest — architecture confirmed (§9), but validate on the target build.
- AX ref lifetime / staleness policy — dump-then-act TTL vs. re-resolving handles.
SCScreenshotManageravailability (macOS 14+) and behaviour on the target guest.- Non-AppKit app coverage (Electron/Chromium/Java) — may need per-app AX-enable toggles; document the gaps.
15. Cross-references
- MACOS_VM — the macOS-VM subsystem this augments (§12 computer-use, §4.4 guest versions, §12.5 TCC reality).
- VSOCK_CONTROL_PLANE — Nucleic's existing vsock control plane for the Linux container (the pattern this reuses for the macOS VM).
- Apple:
VZVirtioSocketDevice/VZVirtioSocketConnection/VZVirtioSocketDeviceConfiguration;AXUIElement(ApplicationServices/HIServices);ScreenCaptureKit(SCScreenshotManager);CGEvent.