Files
nucleic/docs/LINUX_VM.md
T

14 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-protocol MacVMExecChannel speaks: run /bin/sh -c <command>, stream base64 o/e/x frames, honor host i/eof/sig frames.

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.

  1. Bootstrap boot — the kernel + a tiny busybox initramfs (guest/linux-base/bootstrap/init, built by scripts/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). /init auto-detects the two disks (the payload disk carries a ustar magic; 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 ship virtiofs as a module that isn't present in the busybox initramfs; virtio_blk is built-in.)
  2. Provision boot — the installed rootfs boots; nucleic-firstboot.service runs provision-linux-guest.sh once: apt installs a GNOME Wayland session (gnome-shell / Mutter + gdm3 auto-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 .deb from Mozilla's APT repo, not the Ubuntu snap) as a reference browser. It installs a /etc/sudoers.d/nucleic-agent drop-in so the auto-login nucleic account 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.sh needs only a busybox download plus macOS base utilities; it does not require Python, cpio, Homebrew, or the Xcode command-line tools).

  • The agent binaries (static-C nucleic-linux-agent, Rust AT-SPI nucleic-a11y-agent — see docs/LINUX_VM_SEMANTIC_AGENT.md) are not host-side artifacts at all: they arrive inside the rootfs. naros.yml cross-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). Pinning linuxRootfsImage therefore 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:latest and 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.yml no 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.shnucleic-linux-vm-kernel on 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 tags utsname.release (uname -r…-generic-naros26.07), so the script aliases /lib/modules/<tagged> onto the real module directory itself rather than depending on naros-identity-modules.service ordering — losing that race once left depmod with no directory, no modules.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: one OK line when the agent is active and /dev/vsock exists; 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 in provision.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 modedefaults write the nucleic.linuxvm.baseBuildDebug key (MacVMSettings.linuxBaseBuildDebug) to true and rebuild: the provision boot drops quiet (loglevel=7, systemd.show_status=1, systemd.journald.forward_to_console=1) so provision.log records the whole boot, and the payload bakes /etc/nucleic-debug so nucleic-bootdebug emits 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.

One deliberate divergence: the prose. maxConcurrentVMs is a macOS-guest ceiling (macOS itself limits simultaneous macOS guests — MACOS_VM.md §10.1), and Linux guests don't share it. So the macOS tools carry MCPApprovalServer.macVMFreeWhenIdleNotice — a MANDATORY "free the VM the moment you stop actively using it" duty, since one agent parking an idle macOS VM blocks every other agent's boot — while linux_vm_control says the opposite out loud ("NOT capped the way macOS VMs are"). Borrowing the macOS scarcity prose here would be false and would buy nothing but suspend/resume churn.

Settings

MacVMSettings (in Project.swift), all off/pinned by default:

  • linuxServiceEnabled — master switch (sibling of serviceEnabled).
  • 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.