Find a file
Evgeny Boger 0468a2649a B2: ship a native aarch64-linux release binary via npm
Why
---
PLAN.md item "B2 — Cross-arch binaries" (section B, Distribution /
release) calls for cross-arch builds and per-platform npm packaging,
noting that "the package currently bundles a linux-x86_64 binary". As a
result Apple Silicon and ARM Linux users get no native artifact and fall
back to slow x86 emulation (Rosetta / qemu) — a poor fit for a tool whose
job is to launch microVMs quickly. This change delivers the linux/aarch64
half of B2: a genuine aarch64 binary, built, packaged, and selected at
install time, so ARM hosts run native.

How
---
Release workflow (.github/workflows/release-npm.yml): every build job
(build-agent-vm, build-msb, build-libkrunfw) and the package job gain a
linux-arm64 matrix leg. Because GitHub's hosted arm64 Linux runners
aren't on the free tier, the arm64 leg cross-compiles on the x64 runner:
a `cross: true` matrix flag drives a `CROSS` env switch that (a) enables
the arm64 multiarch apt repo and installs the cross linker
(gcc-aarch64-linux-gnu) plus the :arm64 dev libs agent-vm / msb link
against (libcap-ng, libdbus, libsqlite3), and (b) exports
PKG_CONFIG_ALLOW_CROSS / PKG_CONFIG_PATH / PKG_CONFIG_SYSROOT_DIR so the
`pkg-config` crate resolves the target libs. The msb job also sets
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER directly, since it builds
from vendor/microsandbox (a separate workspace not on the superproject's
.cargo config search path). All of these guards are no-ops on the native
x64 leg, so the existing x86_64 path is unchanged. The libkrunfw arm64
leg cross-builds the guest kernel with ARCH=arm64 / CROSS_COMPILE and
fails fast with a clear message if its arm64 kbuild .config seed
(libkrunfw-overrides/config-libkrunfw_aarch64.patch) hasn't been ported
yet, rather than silently shipping a mis-configured kernel. The publish
job downloads the new agent-vm-linux-arm64 artifact alongside the x64 one.

Cross toolchain config (.cargo/config.toml, new): pins the
aarch64-unknown-linux-gnu linker to aarch64-linux-gnu-gcc so a local
cross build reproduces CI; the file documents the matching apt packages.

npm launcher (npm-dist/agent-vm/bin/agent-vm.js,
npm-dist/agent-vm/package.json): un-comment the linux-arm64 entry in the
launcher's PLATFORM_PACKAGES map and add @wirenboard/agent-vm-linux-arm64
to the main package's optionalDependencies, so `npm install` pulls the
arm64 subpackage on ARM hosts and the launcher dispatches to it.

arm64 subpackage scaffold (npm-dist/agent-vm-linux-arm64/, new): mirrors
the x64 subpackage layout — package.json (os linux / cpu arm64),
README.md, and bin/.gitkeep + lib/.gitkeep placeholders — so the
directory exists in the tree for the release workflow to drop the
cross-built binary, msb, and libkrunfw into.

Dispatch test (npm-dist/agent-vm/bin/agent-vm.dispatch.test.js, new):
arm64 dispatch can't be exercised on an x86_64 CI host because node
reports the host's real process.arch. The test re-derives the launcher's
PLATFORM_PACKAGES map + bin-path logic from the source and asserts the
linux-arm64 key resolves to the arm64 subpackage with the same bin/
layout as x64, so a future edit that forgets arm64 fails here instead of
silently falling through to "no prebuilt binary" on ARM hardware.

README (npm-dist/README.md): document that the package now ships both the
linux-x64 and linux-arm64 per-platform subpackages.

macOS / darwin and win32 cross builds remain out of scope for this change
(still commented placeholders in the launcher) and are tracked under the
rest of B2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 18:57:07 +00:00
.cargo B2: ship a native aarch64-linux release binary via npm 2026-05-31 18:57:07 +00:00
.github B2: ship a native aarch64-linux release binary via npm 2026-05-31 18:57:07 +00:00
bin fix: address remaining code-review findings (sweep) 2026-05-24 13:40:20 +03:00
crates/agent-vm help: regroup and tighten the CLI usage output 2026-05-30 19:08:11 +00:00
images ci(build-image): bust cache for the three agent installers each run 2026-05-29 13:17:34 +00:00
libkrunfw-overrides Merge irq-multi-mount: lift --mount cap from ~11 to ~100+ on x86_64 2026-05-28 14:30:53 +00:00
npm-dist B2: ship a native aarch64-linux release binary via npm 2026-05-31 18:57:07 +00:00
vendor v0.1.18: run: add --allow-host for host's 127.0.0.1 via host.microsandbox.internal 2026-05-30 15:11:25 +00:00
.gitignore run: clarify --auto-publish help text + bump vendor/microsandbox 2026-05-29 13:41:28 +00:00
.gitmodules Phase 0: scaffold microsandbox-based rewrite 2026-05-17 17:05:07 +03:00
AGENTS.md v0.1.16: bump for --publish / --auto-publish / --allow-egress + AGENTS.md 2026-05-30 11:37:12 +00:00
ARCHITECTURE.md lift --mount cap from ~11 to ~100+ via split_irqchip + cmdline-size patch 2026-05-28 08:26:28 +00:00
Cargo.lock help: regroup and tighten the CLI usage output 2026-05-30 19:08:11 +00:00
Cargo.toml v0.1.18: run: add --allow-host for host's 127.0.0.1 via host.microsandbox.internal 2026-05-30 15:11:25 +00:00
LICENSE first commit 2026-01-28 01:15:09 +01:00
PLAN.md PLAN: replace phase roadmap with remaining-work plan vs main 2026-05-30 20:31:06 +00:00
README.md README: document --publish / --auto-publish / --allow-egress / --allow-lan / --allow-host 2026-05-30 17:54:38 +00:00

agent-vm

Run Claude Code / Codex / OpenCode inside a per-project libkrun microVM, booting in ~2 seconds, with:

  • Host OAuth tokens never enter the VM. The TLS-intercept proxy in microsandbox substitutes the real bearer for a placeholder on the way out. OAuth refresh is MITM'd so multi-hour sessions survive token rotation.
  • Per-launch GitHub repo allow-list. Auto-detected from git remote -v; extend with --repo OWNER/NAME. gh pr create, git push etc. are filtered at the proxy — off-list calls get a 403 before they reach GitHub.
  • Sandbox is the boundary. Root inside the VM, project bind-mounted at its host path, --dangerously-skip-permissions set by default (the microVM is the only thing actually keeping the agent on rails).

This is the Rust rewrite of the original Bash wirenboard/agent-vm on top of microsandbox. Living on rewrite-microsandbox until v1.

Requirements

  • Linux with /dev/kvm (rw) — your user must be in the kvm group: sudo usermod -aG kvm $USER and re-login.
  • Node.js 18+ (already there if you use Claude Code / Codex CLI / OpenCode — they're all npm-distributed).

~/.microsandbox/lib/libkrunfw.so.5.x auto-installs on first launch.

Quick start

npm install -g @wirenboard/agent-vm        # or: npx @wirenboard/agent-vm <cmd>

agent-vm setup            # pulls the latest image from ghcr.io and verifies it boots

cd ~/your-project
agent-vm claude           # or codex / opencode / shell

The npm package bundles a prebuilt agent-vm binary, the patched msb, and libkrunfw. agent-vm finds them via current_exe()-relative paths, so a user's separate ~/.microsandbox/bin/msb (if any) never shadows the patched build.

Build from source

git clone -b rewrite-microsandbox https://github.com/wirenboard/agent-vm
cd agent-vm
git submodule update --init vendor/microsandbox
sudo apt-get install -y libcap-ng-dev libdbus-1-dev pkg-config
cargo build --release -p agent-vm
cargo build --release --manifest-path vendor/microsandbox/Cargo.toml \
    -p microsandbox-cli --bin msb
./target/release/agent-vm setup       # uses the locally-built msb sibling

agent-vm setup pulls ghcr.io/wirenboard/agent-vm-template:latest by default; pass --image localhost:5000/agent-vm-template:latest to use a local image you've built via images/build.sh.

Subcommands

claude | codex | opencode | shell   launch an agent in a per-project sandbox
pull                                refresh the cached image
setup                               pull base image + verify boot
clipboard {get,put} [--sys]         exchange a string with the project sandbox

Image release cadence

The base OCI image (ghcr.io/wirenboard/agent-vm-template:latest) is rebuilt hourly by CI, picking up the latest Claude Code, Codex CLI, and OpenCode releases automatically. Pin a specific build with --image ghcr.io/wirenboard/agent-vm-template:YYYY-MM-DDTHH (date tags are immutable; the last 14 days are retained).

The agent-vm binary and the image are version-locked through an image-API-version integer (/etc/agent-vm-image-version inside the image). Mismatch → clean error at launch instead of mysterious in-VM failures.

Each launcher accepts:

flag what
--memory N VM memory GiB (default 2)
--cpus N vCPUs (default 2)
--image REF override the OCI image
--no-update-check skip the registry HEAD on launch
--no-git skip gh/git auth injection (still respects --repo)
--repo OWNER/NAME add to the GitHub allow-list (repeatable)
--mount HOST[:GUEST] extra bind mount (one virtio-fs each, ~210 mount headroom)

Trailing args go to the agent: agent-vm claude -p "say hi", agent-vm shell -- -c 'cargo test'.

Env-var knobs (all opt-in; set to any value, empty included):

var what
RUST_LOG tracing filter; default warn. e.g. RUST_LOG=agent_vm=debug
AGENT_VM_PROFILE print per-phase wall-time (create/run/stop/remove)
AGENT_VM_DEBUG_CONFIG dump the SandboxConfig JSON before boot
AGENT_VM_NO_CHROME_MCP skip the Chrome DevTools MCP entirely (no entry in claude.json, no chrome-user setup at boot)
AGENT_VM_IMAGE_TAG override the OCI image (same as --image)
AGENT_VM_MEMORY_GIB / AGENT_VM_CPUS same as --memory / --cpus

Chrome DevTools MCP

The image ships chromium and a chrome-devtools MCP entry pinned to chrome-devtools-mcp@1.0.1. To keep chromium's nested user-namespace sandbox active (we'd rather not pass --no-sandbox) the MCP runs as a dedicated chrome user via a sudo wrapper at /usr/local/bin/agent-vm-chrome-mcp. The launcher installs the per-boot microsandbox MITM CA into chrome's NSS DB at startup so chromium accepts the intercepted TLS chain without --acceptInsecureCerts (which would trust any untrusted cert).

If the CA install fails (e.g. someone broke the in-image sudoers rule) the launcher prints a warning naming the symptom — without it, every HTTPS navigate would silently return ERR_CERT_AUTHORITY_INVALID. Set AGENT_VM_NO_CHROME_MCP=1 to skip the whole setup.

Credentials

Reads from the host:

  • ~/.claude/.credentials.json (Claude)
  • ~/.codex/auth.json (Codex, OpenCode)
  • gh auth token (git/gh)

The guest gets placeholder strings; the proxy substitutes on the wire. Real tokens live in ${XDG_STATE_HOME}/agent-vm/<hash>.secrets/ (0700) on the host, outside the bind mount the guest sees. A SHA-256 snapshot of the three credential files is taken at launch and re-checked on exit; unexpected mutations print a warning.

For Claude/Codex, when the in-VM agent's bearer expires the hook MITMs the OAuth refresh, runs claude -p/codex exec on the host to rotate, and feeds the new placeholder back to the guest — no re-attach required.

Project hook

If the project root contains an executable .agent-vm.runtime.sh, the launcher sources it inside the guest before exec'ing the agent. Use for npm install, env exports, dev-server startup. Non-zero exit aborts the launch.

Ports & egress

The default network policy (public_only) lets the guest reach the public internet plus DNS, and denies everything else (loopback, RFC1918 LAN, link-local, cloud-metadata, the host). Open holes per-launch with these flags — they compose:

flag what it opens guest-side address
--publish HOST:GUEST[/proto] host port HOST → guest port GUEST (tcp default; /udp for UDP) inbound to the guest
--auto-publish every 0.0.0.0:* / 127.0.0.1:* listener inside the guest is mirrored to the host loopback (Lima-style) host: 127.0.0.1:<guest-port>
--allow-egress IP|CIDR (repeatable) one IP or one CIDR through the egress deny dial directly by IP
--allow-lan the whole DestinationGroup::Private (10/8, 172.16/12, 192.168/16, 100.64/10, fc00::/7) dial any LAN IP
--allow-host the per-sandbox gateway IP, which the smoltcp stack rewrites to host 127.0.0.1 host.microsandbox.internal:<port> (already in guest /etc/hosts)

Loopback (guest's own 127.0.0.1), link-local, and cloud metadata (169.254.169.254) stay denied even with --allow-lan — they're disjoint groups by design. --allow-host is the narrowest way to reach a dev server bound to host 127.0.0.1; --allow-lan is the broadest. A compromised in-guest process gets full access to whatever you open, so prefer the narrowest flag that fits.

Troubleshooting

  • RegisterNetDevice(IrqsExhausted) at boot — the userspace split irqchip raises the cap to ~219 IRQs, so this should only happen with hundreds of --mounts or on a host whose KVM lacks KVM_CAP_SPLIT_IRQCHIP (pre-Linux 4.7 or some nested-virt / seccomp-restricted setups). Drop a --mount to recover.
  • handshake read id_offset: timed outfree -h; the VM needs more memory than is available. Try --memory 1.
  • GitHub 403 from the proxy — repo isn't in the allow-list. Pass --repo OWNER/NAME or run from a project with the right remote.

See also

  • PLAN.md — phased roadmap, what's done, what's deferred.
  • ARCHITECTURE.md — design notes; why things look the way they do.
  • AGENTS.md — conventions for coding agents (Claude Code, Codex, etc.) working on this repo: post-merge version bump, submodule-merge ordering, what not to do.