13 KiB
Linux VMs
The VM service (Sources/NucleicCore/MacVM/, see MACOS_VM.md) boots Linux guests as
well as macOS ones, directly on Apple's Virtualization framework. A Linux guest is a full GUI VM the
agent can both exec into (mac_vm_exec) and see + drive (mac_vm_computer) — a real Linux
desktop, isolated per session, the counterpart to the macOS guest.
It is a sibling of the macOS path, not a fork: MacVMEngine / MacVMManager / MacVMSpec are one
service that branches on a guest-OS kind (GuestOS.macOS / .linux). The whole control plane — vsock
exec (MacVMExecChannel), the host-side computer-use surface (MacVMSurfaceHost /
VZVirtualMachineView), NAT networking, clone-on-boot, the idle/concurrency policy, and the
awaitAgentReady vsock gate — is shared unchanged. (The types keep their MacVM* names for now; a
neutral VMEngine rename is a planned follow-up.)
Why Linux is smoother than a Mac guest
Linux under the Virtualization framework exposes flexible virtio/USB devices the macOS guest can't use,
and we lean into exactly the rough spots the Mac path fights (MacVMEngine+LinuxConfig.swift):
| Concern | macOS guest | Linux guest |
|---|---|---|
| Output | VZMacGraphicsDeviceConfiguration (fixed; capture can come back blank) |
VZVirtioGraphicsDeviceConfiguration (virtio-gpu scanout — reliable framebuffer capture) |
| Pointer | VZMacTrackpadConfiguration (relative) |
VZUSBScreenCoordinatePointingDeviceConfiguration (absolute — injected clicks land 1:1 with screenshot pixels) |
| Keyboard | VZMacKeyboardConfiguration |
VZUSBKeyboardConfiguration |
| Permissions | Screen Recording + Accessibility TCC (fallback path); SIP for the AX agent | none — computer-use is ordinary virtual hardware |
| Console | — | VZVirtioConsoleDeviceSerialPortConfiguration → a per-clone console.log for boot diagnostics |
| Platform / boot | VZMacPlatformConfiguration + VZMacOSBootLoader + restore-image install |
VZGenericPlatformConfiguration + VZLinuxBootLoader (external kernel + initrd) |
Because capture + input are 100% host-side over virtio-gpu + USB HID, a Linux guest needs no in-guest
agent for computer-use at all — only for exec.
Control plane — the Linux vsock agent
guest/nucleic-linux-agent/ is a small, dependency-free static C binary that mirrors the macOS
agent's wire protocol (VMAgentCore.AgentWire / MacVMAgentWire) byte-for-byte: NDJSON over
AF_VSOCK port 2035. It implements just the two ops the host reaches in for:
ping→{ok, version:2, os:"linux", displays, readiness}(readiness is always-true — Linux computer-use needs no in-guest permission).exec→ the streaming shell-exec sub-protocolMacVMExecChannelspeaks: run/bin/sh -c <command>, stream base64o/e/xframes, honor hosti/eof/sigframes.
The AX-semantic ops (ax_dump, ax_press, …) are macOS-only and have no Linux equivalent; the engine
short-circuits them with a "use screenshot + pixel actions" message for Linux guests.
Build + test (in the Linux build container / CI):
guest/nucleic-linux-agent/build.sh # → static arm64 binary
cc -O2 -pthread -o /tmp/t guest/nucleic-linux-agent/test/loopback_test.c && /tmp/t # protocol round-trip
Base image — the fully-automated two-phase build
macOS has no Linux filesystem tooling, so the ext4 root disk is assembled inside the guest across
two in-framework boots (MacVMEngine+LinuxBase.swift, MacVMEngine+LinuxProvision.swift). The host
only downloads (and version-caches) three artifacts: an uncompressed arm64 kernel Image (gunzipped
host-side — modern Ubuntu arm64 vmlinuz is an EFI-zboot self-decompressing PE, unwrapped to a raw
Image the way a gzip vmlinuz is gunzipped, since VZLinuxBootLoader needs a raw Image), a matching
initrd, and the Ubuntu root filesystem tarball. Pinned Ubuntu 26.04 LTS (resolute) arm64 defaults
ship in MacVMSettings, so it builds with zero configuration — the latest LTS, and it ships GNOME 50
/ Mutter, the Wayland compositor the in-guest semantic agent targets
(docs/LINUX_VM_SEMANTIC_AGENT.md); a GNOME bump is a deliberate port.
- Bootstrap boot — the kernel + a tiny busybox initramfs (
guest/linux-base/bootstrap/init, built byscripts/build-linux-bootstrap.sh) boot with the empty root disk attached and the provisioning payload (rootfs tarball, the agent binary, provisioning script + systemd units) handed in as a raw tar on a second virtio-blk disk (/dev/vdb)./initauto-detects the two disks (the payload disk carries austarmagic; the other is root),tar -x's the payload, formats the root disk, unpacks the rootfs, installs the agent + a first-boot unit + fstab + DHCP, and powers off. (The payload rides a block device, not virtiofs, because modern Ubuntu kernels shipvirtiofsas a module that isn't present in the busybox initramfs;virtio_blkis built-in.) - Provision boot — the installed rootfs boots;
nucleic-firstboot.servicerunsprovision-linux-guest.shonce:aptinstalls a GNOME Wayland session (gnome-shell/ Mutter +gdm3auto-login + GTK4 apps) so the surface has a live screen, turns the AT-SPI accessibility bus on system-wide (toolkit-accessibility, the semantic agent's precondition), and installs the dev toolchain (build tools, git, node, python + the AT-SPI GI bindings) plus Firefox (the real.debfrom Mozilla's APT repo, not the Ubuntu snap) as a reference browser. It installs a/etc/sudoers.d/nucleic-agentdrop-in so the auto-loginnucleicaccount has passwordless sudo, then writes/var/lib/nucleic/provisioned; the host waits on that over the agent, then powers off to seal the base.
Session VMs are instant copy-on-write clones of the sealed base (clonefile(2) on the rootfs disk),
exactly like the macOS path.
storageRoot/
linux-base/ golden Linux base bundle (vmlinux, initrd.img, cmdline, Disk.img, bundle.json)
linux-artifacts/ version-keyed cache: kernel, initrd, rootfs tarball, bootstrap.cpio.gz
instances/<name>/ per-session clones (nucleic-lin-<short>)
Prebuilt artifacts
-
The bootstrap initramfs is built on-demand on the Mac (
scripts/build-linux-bootstrap.shneeds only a busybox download +ar/tar/gzip/python3, all present with the Xcode command line tools). -
The agent binaries (static-C
nucleic-linux-agent, Rust AT-SPInucleic-a11y-agent— see docs/LINUX_VM_SEMANTIC_AGENT.md) are not host-side artifacts at all: they arrive inside the rootfs.naros.ymlcross-builds both for arm64 + amd64 (zig/musl), packages them as debs, publishes them to the hosted apt repo, and bakes them into the rootfs from that same pool (os/mkimage/profiles/vm.naros-pkgs,vm-desktop.naros-pkgs). PinninglinuxRootfsImagetherefore pins the agents too, and the phase-1 bootstrap installs no agent of its own.Previously the Mac host also pulled
ghcr.io/abkslm/nucleic-{linux,a11y}-agent:latestand had the bootstrap overlay them onto the unpacked rootfs. That gave one agent two independently-moving sources — a VM could ship the deb build and boot the GHCR build — so the overlay was collapsed and apt is now the single source of truth. -
CI:
.github/workflows/linux-vm-agents.ymlno longer distributes the agents; it builds them on a native arm64 runner purely as a compile check against a stock toolchain (naros.yml's build is zig/musl cross), and still unwraps + publishes the pinned kernel (scripts/publish-linux-vm-kernel.sh→nucleic-linux-vm-kernelon GHCR).
Debugging a failed base build
The build's only always-available window into the guest is the serial console log — the host
keeps bootstrap.log (phase 1) and provision.log (phase 2) in the bundle, and each session clone
gets its own console.log. The debug system is built around pushing guest-side state out through
that channel:
nucleic-modsetup.sh(every boot, sysinit) prepares the external kernel's modules and reports loudly:[nucleic-modsetup]lines in the journal and on the serial console record the depmod release, the naros identity-tag alias, and whether a vsock transport loaded. narOS tagsutsname.release(uname -r→…-generic-naros26.07), so the script aliases/lib/modules/<tagged>onto the real module directory itself rather than depending onnaros-identity-modules.serviceordering — losing that race once left depmod with no directory, nomodules.dep, an agent that couldn't bind AF_VSOCK, and a 5-minute opaque host timeout.nucleic-bootdebug.service(every boot, ~15s after multi-user) writes a boot-health snapshot to the console: oneOKline when the agent is active and/dev/vsockexists; otherwise the module dir listing, vsock/lsmod state, the nucleic units' states, and a journal tail. This is what turns "the agent never became reachable" into a cause you can read inprovision.log.debug-report.txt— every provision-boot failure writes this into the bundle (writeLinuxDebugReport): the failing phase, the full artifact recipe (kernel/initrd/rootfs/ modules-manifest/agent refs + the kernel release actually booted), and sanitized tails of both boot logs. The thrown error quotes the guest's last console lines and the report path.- Verbose mode —
defaults writethenucleic.linuxvm.baseBuildDebugkey (MacVMSettings.linuxBaseBuildDebug) to true and rebuild: the provision boot dropsquiet(loglevel=7,systemd.show_status=1,systemd.journald.forward_to_console=1) soprovision.logrecords the whole boot, and the payload bakes/etc/nucleic-debugsonucleic-bootdebugemits its full snapshot even on healthy boots.
The agent unit itself sets StartLimitIntervalSec=0: it restarts every second until vsock is up
instead of tripping systemd's default 5-starts/10s limit and staying dead forever.
File sharing + host-path re-creation
The session's repo is a virtiofs share (tag nucleicshare). Unlike macOS there is no automount:
the exec path mounts it on demand at /mnt/nucleic, and each share is a subdirectory
/mnt/nucleic/<name> (the run's worktree is workspace, so the default workdir is
/mnt/nucleic/workspace).
Because that guest path differs from the host path, build artifacts that hardcode an absolute path
(SwiftPM .build, compile_commands.json, .o debug maps, caches) would break. So once the agent is
ready the engine re-creates each share's original host path inside the guest by bind-mounting
the share there — a real directory backed by the same virtiofs inode, matching the container's
identical-path model — falling back to a symlink if the bind can't be made
(MacVMEngine+HostPaths.swift, MacVMSpec.Mount.recreateHostPath, default on). The step is
idempotent and best-effort; a share that can't be reconstructed is logged and skipped. macOS uses a
symlink instead — see MACOS_VM.md §6.1.
Lifecycle control — linux_vm_control
The Linux sibling of mac_vm_control: a non-gated MCP tool that lets the agent manage the VM it
booted — op one of status / stop / suspend / resume / restart / kill, acting on the
session's own disposable Linux VM. Advertised (and pre-allowed) whenever the Linux VM is usable
(allowLinuxVMExec or allowLinuxVMComputer). Shares
MCPApprovalServer.VMControlCall/VMControlReply and ClaudeCodeBackend.performVMControl with the
macOS tool — see MACOS_VM.md §8.3 for the full op table and rationale.
Settings
MacVMSettings (in Project.swift), all off/pinned by default:
linuxServiceEnabled— master switch (sibling ofserviceEnabled).linuxExposeByDefault,linuxComputerUseByDefault— per-session exposure.linuxKernelURL/linuxInitrdURL/linuxRootfsURL— artifact sources (default: Ubuntu 26.04 LTS arm64).linuxBasePrebuiltPath— clone a prebuilt base bundle instead of building.linuxBaseBuildDebug— verbose base-build diagnostics (see §Debugging a failed base build).
Try it (macvm-spike)
The spike drives the real engine end-to-end (Apple silicon, signed with the virtualization entitlement):
swift build --product macvm-spike
codesign --force --sign - --entitlements signing/spike.entitlements .build/.../macvm-spike
NUCLEIC_LINUXVM=1 NUCLEIC_LINUXVM_BUILD_BASE=1 .build/.../macvm-spike # one-time base build
NUCLEIC_LINUXVM=1 .build/.../macvm-spike # clone → boot → exec → screenshot → teardown
Status / remaining
Landed: the guest-OS kind + Linux VZ config, the vsock agent (protocol-tested), the two-phase base
build + bootstrap, computer-use reuse, and MacVMManager plumbing. The end-to-end base build (booting
real VMs + the apt desktop install) requires validation on a real Mac and is expected to need
iteration on the desktop/session specifics. The app-surface wiring — the SettingsView "Build Linux
base image" panel and per-session OS selection that exposes mac_vm_exec/mac_vm_computer against a
Linux VM — mirrors the existing macOS wiring and is the remaining integration step.