No description
Find a file
Thomas SIMON 20395a33e1 add conf
2026-07-16 22:24:18 +11:00
.gitignore ignore everything but the build script and readmes 2026-07-15 19:21:23 +11:00
build-vm.sh add conf 2026-07-16 22:24:18 +11:00
README.md sync README with the current build (dwm, 13 steps, autologin) 2026-07-15 19:59:34 +11:00
USER_README.md autologin arch on tty1 2026-07-15 19:30:37 +11:00

agent-vm

Spin up fresh, disposable Arch Linux coding VMs — fast. Each VM is built from scratch with today's packages, but the entire system is assembled on-disk from the host (no slow "boot the VM and run pacman over SSH" phase). The VM's very first boot is already a finished, up-to-date dev environment.

One script, build-vm.sh, does everything.

Why it's fast and fresh

Goal How
Fresh (packages change daily) Every build installs current versions from your host's pacman cache/DB — no frozen base image
No download Packages come from the host's local cache (/var/cache/pacman/pkg), which you keep current with pacman -Syu
No provisioning boot The rootfs is built against a loop-mounted raw image with pacman --root / chroot / systemctl --root — the VM is never booted to install anything (converted to qcow2 at the end)
No slow keyring init The host's already-initialized /etc/pacman.d/gnupg is copied in, skipping pacman-key's gpg keygen
No locale-gen Uses the built-in C.UTF-8 locale
No bootloader QEMU boots the kernel directly (-kernel/-initrd), so there's no ESP/GRUB/systemd-boot to install
Lean image linux-firmware is omitted — a virtio VM needs no device firmware (~300 MB saved)
Built in RAM The rootfs is assembled on a tmpfs inside the build's private mount namespace, so pacman/mkinitcpio I/O never touches the SSD; only the final qcow2 is written to disk

Net result: a complete VM in well under a minute, entirely offline.

Requirements

  • An Arch Linux host (the build sources packages, keyring, and sync DB from it). Keep the package cache current: sudo pacman -Syu.
  • root (via sudo) — the build uses losetup, mkfs, mount, and chroot.
  • Packages on the host: qemu (provides qemu-system-x86_64, qemu-img), e2fsprogs (mkfs.ext4), util-linux (losetup, unshare, mount), and psmisc (fuser, for clean unmounts).
  • An SSH keypair at ~/.ssh/id_ed25519.pub (optional — copied in for key login).
  • Hardware virtualization (/dev/kvm).

Optional host bits, copied in if present and skipped with a warning if not:

  • /usr/local/bin/dwm and /usr/local/bin/dwm-grid — the VM's window manager is your local dwm build, not a repo package. Without it the VM has no WM (startx will fail); build and make install dwm on the host first.
  • ~/.local/bin/claude — Claude Code's binary is copied in (no credentials; see below).
  • ~/.config/alacritty/alacritty.toml — carried in so the VM's terminal matches yours.

Usage

Building needs root; launching (with the GUI) does not. Run the build under sudo, then launch the VM as your normal user so the window opens on your display.

sudo ./build-vm.sh <vm-name>          # build a fresh VM disk (root)
./build-vm.sh <vm-name>               # launch it — GUI, NO sudo
sudo ./build-vm.sh <vm-name> --run    # build, then auto-launch (drops to you for the GUI)

Each VM lives in its own subfolder under ~/prog/agent-vm/<name>/. Building chowns that folder to you, so the no-sudo launch just works. KVM runs non-root because /dev/kvm is world-accessible (0666).

Examples

sudo ./build-vm.sh coder          # build "coder"
./build-vm.sh coder               # boot it (no sudo) — console; run `startx` inside for dwm

sudo ./build-vm.sh scratch --run  # build + boot in one go

To rebuild a VM fresh (picking up newer packages), delete its folder first:

rm -rf ~/prog/agent-vm/coder && sudo ./build-vm.sh coder

Accessing a running VM

Ports are forwarded from the host to the guest:

Host Guest Purpose
localhost:2222 :22 SSH
localhost:8080 :80 Web
ssh -p 2222 arch@localhost          # key login (password auth is disabled)

Credentials: user arch / password arch, root / root. The arch user has passwordless sudo and is in the docker group. SSH accepts your key only (password auth off, and root login refused); the passwords are only for su and for logging in on a tty other than tty1 — tty1 autologins as arch, so the GUI console needs no password.

Configuration

Override via environment variables:

Variable Default Meaning
DISK_SIZE 40G virtual disk size
VM_CPUS 8 vCPUs
VM_MEM 4096 RAM (MB)
HOST_CACHE /var/cache/pacman/pkg package source
DISK_SIZE=60G VM_MEM=8192 VM_CPUS=8 sudo ./build-vm.sh big --run

What's in the VM

Fresh Arch with a coding-oriented toolset (edit the PACKAGES array to change it):

  • Desktop: Xorg, dwm + dwm-grid (copied from the host, not a package), dmenu, alacritty, xclip, xdotool, maim, Iosevka fonts
  • Dev: base-devel, git, cmake, bear, zig, nodejs/npm, vim, nano, plus fzf, jq, ranger, lf, ncdu, mc, unzip, wget, rsync, age
  • Containers: docker, docker-compose, lazydocker (service enabled)
  • AI/LLM: opencode, plus Claude Code (binary copied from the host — you log in inside the VM; credentials are deliberately not copied, since the OAuth refresh token is single-use and sharing it would log the host out)
  • Misc: firefox, htop/btop, fastfetch

Enabled services: sshd, systemd-networkd (DHCP), systemd-resolved, getty@tty1 (with an autologin drop-in for arch), docker.

The VM boots to a console, not a desktop — X is not started automatically. Run startx on tty1 to get dwm. The guest's own ~/README.md (this repo's USER_README.md, installed at build time) documents the keybindings.

How the build works (13 steps)

All against a loop-mounted raw image on a tmpfs — the VM never boots during the build.

  1. Disk — create a sparse raw image in RAM (tmpfs), attach it with losetup, format the whole device as ext4 (no partition table → the VM uses root=/dev/vda).
  2. Skeleton + binds — create runtime dirs and rbind-mount /proc /sys /dev /run so pacman's install scriptlets can chroot (see Safety below).
  3. Seed pacman — copy the host's gnupg keyring, mirrorlist, and sync DBs into the image (fresh, and skips the slow keyring init).
  4. Installpacman --root <mnt> --cachedir <host cache> -S <packages>: today's versions, zero download. The mkinitcpio hook is stubbed out for the duration (step 5 rebuilds the initramfs anyway).
  5. initramfs — write a virtio mkinitcpio.conf (no autodetect) and regenerate the single default preset, so the image can see the QEMU virtio disk.
  6. Identity — fstab (root by UUID), hostname, hosts, C.UTF-8, UTC, empty machine-id (systemd generates a unique one on first boot).
  7. Users — create arch (wheel + docker), set passwords, NOPASSWD sudo.
  8. SSH — install your pubkey as authorized_keys, disable password auth and root login.
  9. Network + services — systemd-networkd DHCP profile, then systemctl --root enable ….
  10. Claude Code — copy the host's claude binary in and put ~/.local/bin on PATH (no credentials — log in inside the VM).
  11. Host binaries — copy dwm and dwm-grid from the host's /usr/local/bin.
  12. Console + session — tty1 autologin drop-in, ~/.xinitrc (background, dwm status clock, exec dwm), the host's alacritty config, and USER_README.md as the guest's ~/README.md; then chown -R the whole home to uid 1000.
  13. Extract + finalize — copy vmlinuz/initramfs.img out of the image (for -kernel boot), kill the chroot's gpg-agent, unmount, qemu-img convert the raw image into a compact sparse qcow2, then detach the loop and drop the tmpfs.

Then QEMU boots it directly: -kernel vmlinuz -initrd initramfs.img -append "root=/dev/vda rw console=tty0" with virtio disk/net/rng, GTK display, and the port forwards.

Files produced per VM

~/prog/agent-vm/<name>/
├── <name>.qcow2      # the VM disk
├── vmlinuz           # kernel, for -kernel direct boot
└── initramfs.img     # virtio initramfs

Safety (important)

The build mounts host filesystems into the image to run install scriptlets. On systemd, mounts are shared by default, so a naive recursive unmount can propagate back to the host and tear down real API filesystems like /dev/shm and /sys/fs/cgroup — which breaks host GUI apps and systemd. This script guards against that with two independent layers:

  1. It re-execs the entire build inside a private mount namespace (unshare --mount --propagation private), so nothing it mounts is even visible to the host, and leaked mounts vanish when the process exits.
  2. It marks every bind --make-rslave, so propagation can only flow host→target, never target→host (the same technique arch-chroot uses).

Do not remove either guard.

Notes & limitations

  • Arch host only — the whole model depends on the host's pacman cache/keyring.
  • Disk size: each VM is ~45 GB (qcow2 stays sparse).
  • Networking: QEMU user-mode (SLIRP) — outbound works; the guest is reachable from the host only via the forwarded ports.
  • Delete a VM: stop it, then rm -rf ~/prog/agent-vm/<name>.
  • The kernel/initramfs are stored per-VM alongside the disk; deleting the folder removes everything for that VM.

Troubleshooting

A build was interrupted / leftover loop device. The build uses losetup and cleans up after itself even on error (it kills the chroot's gpg-agent, unmounts, and detaches the loop). If a build was hard-killed and you see a leftover loop still attached to a .build.raw (deleted) file:

losetup -a | grep agent-vm                 # see any strays
# a lingering gpg-agent from the killed build can hold the mount — kill it first:
sudo pkill -9 gpg-agent dirmngr keyboxd
sudo losetup -d /dev/loopN                  # then detach (or `losetup -D` for all)

Also remove the half-built folder: sudo rm -rf ~/prog/agent-vm/<name>.

This project used qemu-nbd in an earlier version; it was replaced with loop devices because an nbd daemon that dies uncleanly (e.g. when the build's mount namespace tears down) wedges the device into an apparently in use state that only a module reload clears. Loop devices have no daemon and can't wedge.

GUI window doesn't open on --run: launch it yourself as your user (not sudo): ./build-vm.sh <name>. If DISPLAY still isn't found, run echo $DISPLAY in your desktop session and pass it: DISPLAY=:0 ./build-vm.sh <name>.