wirenboard-agent-vm/README.md
Evgeny Boger 4eafe4bbac lift --mount cap from ~11 to ~100+ via split_irqchip + cmdline-size patch
Two coordinated changes that together raise the per-sandbox virtio
device cap on x86_64 by ~10x:

1. **Enable msb_krun's userspace split irqchip** in the runtime
   (vendor/microsandbox submodule bump). KVM's in-kernel IOAPIC is
   hardcoded to 24 pins, of which libkrun's allocator hands out only
   IRQs 5..15 — saturated by a typical agent-vm config (rootfs, upper,
   runtime fs, network, vsock, console, balloon, rng) plus a couple of
   `--mount`s, so an extra mount has historically tripped
   `RegisterNetDevice(IrqsExhausted)` at boot. The userspace IOAPIC
   exposes 256 pins, lifting the libkrun allocator ceiling to
   IRQ_MAX_SPLIT = 223. Requires a libkrun fork (pinned in
   Cargo.toml) with three correctness fixes on top of upstream 0.1.13;
   without them, enabling split_irqchip crashes the VMM during boot or
   silently truncates the kernel cmdline.

2. **Patch libkrunfw to bump x86 COMMAND_LINE_SIZE 2048 → 16384**.
   Each virtio_mmio device adds ~36 bytes of cmdline; past ~10 user
   mounts the assembled cmdline crosses the stock 2048 cap, the kernel
   silently truncates the tail (which includes virtio-console), and
   the guest hangs in early boot with `kernel.log` stuck at 0 bytes.
   New `libkrunfw-overrides/cmdline-size_x86_64.patch` lifts the cap;
   CI workflow picks it up via libkrunfw's `patches/0*.patch` glob
   (the `0999-overrides-` prefix sorts strictly after libkrunfw's own
   numbered patches).

CI workflow hardening that landed alongside:
 - cache key now keyed per-arch via `*_${ARCH}.patch` glob (an
   aarch64 patch edit no longer invalidates the x86_64 cache);
 - `config-libkrunfw*` skip pattern uses a bare suffix so future
   `config-libkrunfw-tdx_${ARCH}.patch` / `-sev_` variants are also
   excluded from the kernel source-patch pipeline.

User-facing knobs (`--mount` doc in run.rs, README troubleshooting
guide, ARCHITECTURE/PLAN narrative) updated to reflect the new
practical ceiling.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 08:26:28 +00:00

173 lines
6.8 KiB
Markdown

# 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](https://github.com/wirenboard/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`](https://github.com/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
```bash
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
```bash
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.
## Troubleshooting
- **`RegisterNetDevice(IrqsExhausted)` at boot** — the userspace split
irqchip raises the cap to ~219 IRQs, so this should only happen with
hundreds of `--mount`s 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 out`** — `free -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](PLAN.md) — phased roadmap, what's done, what's deferred.
- [ARCHITECTURE.md](ARCHITECTURE.md) — design notes; why things look
the way they do.