merge rewrite-microsandbox-3: npm/ghcr distribution + image-API contract

This commit is contained in:
Evgeny Boger 2026-05-25 12:32:49 +03:00
commit 0ad086e12f
22 changed files with 1433 additions and 158 deletions

147
.github/workflows/build-image.yml vendored Normal file
View file

@ -0,0 +1,147 @@
name: build-image
# Build the agent-vm OCI image and push to GHCR on:
# - hourly schedule (picks up new claude-code/codex/opencode releases),
# - any push that touches images/ (Dockerfile or related),
# - manual dispatch (force a rebuild).
#
# Tags pushed:
# - `latest` — moving tag, used by agent-vm by default.
# - `YYYY-MM-DDTHH` — immutable, for users who need to pin.
# - `<short-sha>` — also immutable, for traceability to source.
#
# The binary release pipeline (release-npm.yml) runs on a separate
# cadence; image-API-version contract (images/Dockerfile + defaults.rs)
# keeps them compatible.
on:
schedule:
- cron: '0 * * * *' # every hour, on the hour, UTC
push:
branches:
- main
paths:
- 'images/**'
- '.github/workflows/build-image.yml'
workflow_dispatch:
permissions:
contents: read
packages: write # for ghcr.io push
concurrency:
# One image build at a time. Newer runs cancel queued older ones —
# but never interrupt a build in progress (cancel-in-progress: false).
group: build-image
cancel-in-progress: false
env:
IMAGE: ghcr.io/${{ github.repository_owner }}/agent-vm
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Compute timestamp tag (UTC)
id: ts
run: |
echo "tag=$(date -u +%Y-%m-%dT%H)" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: images
file: images/Dockerfile
push: true
# `cache-from`/`cache-to` keep layer rebuilds fast when only
# the agent-version layers at the top of the Dockerfile
# change (the common hourly case).
cache-from: type=gha
cache-to: type=gha,mode=max
tags: |
${{ env.IMAGE }}:latest
${{ env.IMAGE }}:${{ steps.ts.outputs.tag }}
${{ env.IMAGE }}:sha-${{ github.sha }}
# Tag with the short SHA too so we can trace any image
# back to the exact Dockerfile commit.
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.created=${{ steps.ts.outputs.tag }}
- name: Show pushed digest
run: |
echo "Pushed ${{ env.IMAGE }}:${{ steps.ts.outputs.tag }}"
echo " digest: ${{ steps.build.outputs.digest }}"
retain:
# Delete date-tagged image versions older than 14 days so the
# registry doesn't grow unbounded. `latest` and `sha-...` tags
# are immune via the ignore-versions regex.
#
# Why a custom script and not actions/delete-package-versions's
# `min-versions-to-keep`: that knob only fires once you HAVE at
# least N versions, so a paused/disabled cron can leave stale
# versions outliving the 14-day window unbounded. The age-based
# approach prunes correctly regardless of build cadence.
needs: build
runs-on: ubuntu-latest
steps:
- name: Prune image versions older than 14 days
uses: actions/github-script@v7
with:
script: |
const owner = context.repo.owner;
const pkg = 'agent-vm';
const horizonMs = 14 * 24 * 60 * 60 * 1000;
const now = Date.now();
// Patterns we never delete, regardless of age.
const keepTagRe = /^(latest|sha-[0-9a-f]+)$/i;
// List all versions (paginated).
const versions = await github.paginate(
github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg,
{
package_type: 'container',
package_name: pkg,
org: owner,
per_page: 100,
}
);
core.info(`found ${versions.length} versions of ${owner}/${pkg}`);
let deleted = 0;
for (const v of versions) {
const tags = (v.metadata?.container?.tags) ?? [];
// Skip anything carrying a protected tag.
if (tags.some((t) => keepTagRe.test(t))) continue;
// Untagged manifest lists / blob orphans can be GC'd too.
const age = now - new Date(v.created_at).getTime();
if (age <= horizonMs) continue;
core.info(`deleting ${pkg}@${v.id} tags=${JSON.stringify(tags)} age_days=${(age/86400000).toFixed(1)}`);
try {
await github.rest.packages.deletePackageVersionForOrg({
package_type: 'container',
package_name: pkg,
org: owner,
package_version_id: v.id,
});
deleted++;
} catch (e) {
core.warning(`delete ${v.id} failed: ${e.message}`);
}
}
core.info(`deleted ${deleted} version(s) older than 14 days`);

302
.github/workflows/release-npm.yml vendored Normal file
View file

@ -0,0 +1,302 @@
name: release-npm
# Build the agent-vm binary + patched msb + bundle libkrunfw for each
# supported platform, then publish the main package and per-platform
# subpackages to npm. Tagged releases only — `v<semver>` (e.g. v1.2.3).
#
# The image is published on a separate cadence (see build-image.yml);
# binary releases pin the default image to the moving `:latest` tag.
on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
inputs:
version:
description: 'Version to publish (without leading v). Required when running manually.'
required: true
permissions:
contents: read
id-token: write # for npm provenance
jobs:
resolve-version:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.version }}
steps:
- id: v
shell: bash
run: |
if [[ "${{ github.event_name }}" == 'workflow_dispatch' ]]; then
v="${{ inputs.version }}"
else
# tag form: v1.2.3 -> 1.2.3
v="${GITHUB_REF_NAME#v}"
fi
if ! [[ "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?$ ]]; then
echo "::error::version $v is not a valid semver"; exit 1
fi
echo "version=$v" >> "$GITHUB_OUTPUT"
build-platform:
needs: resolve-version
strategy:
fail-fast: false
matrix:
include:
- platform: linux-x64
runner: ubuntu-latest
cargo_target: x86_64-unknown-linux-gnu
# libkrunfw arch in upstream's release filenames.
libkrunfw_arch: x86_64
# - platform: linux-arm64
# runner: ubuntu-24.04-arm
# cargo_target: aarch64-unknown-linux-gnu
# libkrunfw_arch: aarch64
# - platform: darwin-arm64
# runner: macos-14
# cargo_target: aarch64-apple-darwin
# libkrunfw_arch: aarch64
# (macOS needs microsandbox-VZ backend before binaries are useful;
# npm package builds work but won't boot a VM yet.)
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
submodules: 'recursive'
- name: Install Rust toolchain
id: rust
run: |
rustup toolchain install stable --profile minimal --no-self-update
rustup target add ${{ matrix.cargo_target }}
# Capture the exact rustc version so the cache key invalidates
# whenever the toolchain rolls (e.g. `stable` bumps on the
# runner image). Without this, cached rlibs built with the
# previous rustc get linked against a newer one →
# undefined-symbol errors that took us hours to debug locally.
echo "rustc_version=$(rustc -V | sha256sum | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
- name: Install build prereqs (linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libcap-ng-dev libdbus-1-dev libsqlite3-dev pkg-config
- name: Cache cargo registry + target
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
vendor/microsandbox/target
# rustc-version in the key → toolchain bumps invalidate the
# cache. Cargo.lock change → dependency tree changed, also
# invalidate.
key: cargo-${{ matrix.platform }}-${{ steps.rust.outputs.rustc_version }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
cargo-${{ matrix.platform }}-${{ steps.rust.outputs.rustc_version }}-
- name: Build agent-vm
run: cargo build --release --target ${{ matrix.cargo_target }} -p agent-vm
- name: Build patched msb
run: |
cargo build --release --target ${{ matrix.cargo_target }} \
--manifest-path vendor/microsandbox/Cargo.toml \
-p microsandbox-cli --bin msb
- name: Resolve runtime-bundle versions from vendor/microsandbox
id: msb_ver
shell: bash
run: |
set -euo pipefail
# Read the values straight from the vendored source of
# truth so the npm bundle stays aligned with what the
# patched msb expects to dynamically load. The crate uses
# `version.workspace = true`, so the canonical version
# lives in the workspace root Cargo.toml.
msb_version=$(awk -F\" '/^version *=/{print $2; exit}' \
vendor/microsandbox/Cargo.toml)
libkrunfw_version=$(awk -F\" \
'/^pub const LIBKRUNFW_VERSION *: *&str *= *"/{print $2; exit}' \
vendor/microsandbox/crates/utils/lib/lib.rs)
libkrunfw_abi=$(awk -F\" \
'/^pub const LIBKRUNFW_ABI *: *&str *= *"/{print $2; exit}' \
vendor/microsandbox/crates/utils/lib/lib.rs)
test -n "$msb_version" || { echo "::error::could not extract msb version"; exit 1; }
test -n "$libkrunfw_version" || { echo "::error::could not extract libkrunfw version"; exit 1; }
test -n "$libkrunfw_abi" || { echo "::error::could not extract libkrunfw abi"; exit 1; }
echo "msb_version=$msb_version" >> "$GITHUB_OUTPUT"
echo "libkrunfw_version=$libkrunfw_version" >> "$GITHUB_OUTPUT"
echo "libkrunfw_abi=$libkrunfw_abi" >> "$GITHUB_OUTPUT"
echo "Resolved: msb=$msb_version libkrunfw=$libkrunfw_version abi=$libkrunfw_abi"
- name: Fetch libkrunfw from upstream microsandbox release
shell: bash
env:
MSB_V: ${{ steps.msb_ver.outputs.msb_version }}
LK_V: ${{ steps.msb_ver.outputs.libkrunfw_version }}
LK_ABI: ${{ steps.msb_ver.outputs.libkrunfw_abi }}
LK_ARCH: ${{ matrix.libkrunfw_arch }}
run: |
set -euo pipefail
# Upstream publishes a per-arch tarball containing msb +
# libkrunfw. We need only libkrunfw — our own patched msb
# ships separately in this bundle.
# crates/utils/lib/lib.rs::bundle_download_url
if [[ "${{ matrix.platform }}" == darwin-* ]]; then
os_tag=darwin
else
os_tag=linux
fi
url="https://github.com/superradcompany/microsandbox/releases/download/v${MSB_V}/microsandbox-${os_tag}-${LK_ARCH}.tar.gz"
echo "Downloading $url"
dst=npm-dist/agent-vm-${{ matrix.platform }}/lib
mkdir -p "$dst"
tmp=$(mktemp -d)
curl -fsSL -o "$tmp/bundle.tar.gz" "$url"
# Extract just libkrunfw* into the lib/ dir, flat (drop any
# leading directory prefix from the tarball).
tar -xzf "$tmp/bundle.tar.gz" -C "$tmp"
shopt -s globstar nullglob
found=0
for f in "$tmp"/**/libkrunfw*; do
cp -a "$f" "$dst/"
found=$((found+1))
done
if [[ $found -eq 0 ]]; then
echo "::error::no libkrunfw* in $url"; ls -R "$tmp"; exit 1
fi
rm -rf "$tmp"
# Recreate the ABI symlinks the dynamic linker expects.
# Mirrors `libkrunfw_symlinks()` in vendor/microsandbox/
# crates/microsandbox/lib/setup/download.rs.
#
# Use a plain sequence (not a subshell) so each ln failure
# propagates cleanly under `set -e` on every bash version,
# and absolute `$dst/...` paths so we don't depend on the
# cwd being right.
if [[ "$os_tag" == darwin ]]; then
full="libkrunfw.${LK_ABI}.dylib"
if [[ ! -e "$dst/$full" ]]; then
ln -s "libkrunfw.${LK_V}.dylib" "$dst/$full"
fi
else
full="libkrunfw.so.${LK_V}"
soname="libkrunfw.so.${LK_ABI}"
test -f "$dst/$full" || { echo "::error::$dst/$full missing after extraction"; exit 1; }
ln -sf "$full" "$dst/$soname"
ln -sf "$soname" "$dst/libkrunfw.so"
fi
ls -la "$dst"
- name: Stage binaries into platform subpackage
run: |
dst=npm-dist/agent-vm-${{ matrix.platform }}/bin
mkdir -p "$dst"
cp target/${{ matrix.cargo_target }}/release/agent-vm "$dst/agent-vm"
cp vendor/microsandbox/target/${{ matrix.cargo_target }}/release/msb "$dst/msb"
chmod +x "$dst/agent-vm" "$dst/msb"
- name: Upload platform artifact
uses: actions/upload-artifact@v4
with:
name: agent-vm-${{ matrix.platform }}
path: npm-dist/agent-vm-${{ matrix.platform }}/
retention-days: 7
if-no-files-found: error
publish:
needs: [resolve-version, build-platform]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- name: Download all platform artifacts
uses: actions/download-artifact@v5
with:
path: npm-dist/
pattern: agent-vm-*
merge-multiple: false
- name: Restore directory layout
# actions/download-artifact drops artifacts as
# npm-dist/agent-vm-linux-x64/{bin,lib} — that's already
# what the subpackage expects. Sanity check.
run: |
set -e
for d in npm-dist/agent-vm-*-*/bin; do
test -f "$d/agent-vm" || { echo "missing $d/agent-vm"; exit 1; }
test -f "$d/msb" || { echo "missing $d/msb"; exit 1; }
done
- name: Set versions in every package.json
env:
V: ${{ needs.resolve-version.outputs.version }}
run: |
set -e
for p in npm-dist/*/package.json; do
# Top-level version
node -e "
const fs=require('fs');
const p='$p';
const j=JSON.parse(fs.readFileSync(p,'utf8'));
j.version='${V}';
if (j.optionalDependencies) {
for (const k of Object.keys(j.optionalDependencies)) {
j.optionalDependencies[k]='${V}';
}
}
fs.writeFileSync(p, JSON.stringify(j,null,2)+'\\n');
"
done
- name: Publish platform subpackages first
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
V: ${{ needs.resolve-version.outputs.version }}
run: |
set -e
# Subpackages MUST be published before the main package, or
# the main package's optionalDependencies resolve to missing.
# Each publish is idempotent: if a re-run of a failed release
# finds a subpackage already at this version (npm 403:
# "cannot publish over existing version"), we accept that and
# carry on so the workflow can still publish the main package
# — avoiding a split-state where subpackages exist at v but
# the main package never makes it.
for d in npm-dist/agent-vm-*-*; do
name=$(node -p "require('$d/package.json').name")
existing=$(npm view "$name@$V" version 2>/dev/null || true)
if [[ "$existing" == "$V" ]]; then
echo "::notice::$name@$V already published; skipping"
continue
fi
(cd "$d" && npm publish --provenance --access public)
done
- name: Publish main package
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
V: ${{ needs.resolve-version.outputs.version }}
run: |
set -e
existing=$(npm view "@wirenboard/agent-vm@$V" version 2>/dev/null || true)
if [[ "$existing" == "$V" ]]; then
echo "::notice::@wirenboard/agent-vm@$V already published; skipping"
exit 0
fi
cd npm-dist/agent-vm
npm publish --provenance --access public

1
Cargo.lock generated
View file

@ -32,6 +32,7 @@ dependencies = [
"reqwest",
"serde_json",
"sha2 0.10.9",
"tempfile",
"tokio",
"tracing",
"tracing-subscriber",

View file

@ -21,39 +21,70 @@ top of microsandbox. Living on `rewrite-microsandbox` until v1.
## Requirements
- Linux with `/dev/kvm` (rw)
- Docker, for building the base image + running the local registry
- Rust toolchain (rustup stable)
- `libcap-ng-dev`, `libdbus-1-dev`, `pkg-config`
- 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/{bin/msb, lib/libkrunfw.so.5.x}` auto-install on
first launch.
`~/.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
BIN=$(pwd)/target/release/agent-vm
"$BIN" setup # build + push image, build patched msb
cd ~/your-project
"$BIN" claude # or codex / opencode / shell
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:latest` by default; pass
`--image localhost:5000/agent-vm: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 build base image + patched msb
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: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: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 |

View file

@ -28,3 +28,4 @@ reqwest = { version = "0.13", default-features = false, features = ["rustls", "j
# deps so production builds don't pull in the entire network stack.
[dev-dependencies]
microsandbox-network = { path = "../../vendor/microsandbox/crates/network" }
tempfile = "3"

View file

@ -0,0 +1,36 @@
//! Module-level constants for distribution-shaped defaults.
//!
//! Kept in one place so a release can re-point the image registry,
//! bump the image-API range, or change other distribution wiring
//! without grepping for string literals across subcommands.
/// Default OCI image reference. Overridable per-subcommand via
/// `--image` or the `AGENT_VM_IMAGE_TAG` env var. Pulled fresh on
/// `agent-vm setup` / `agent-vm pull`; uses the cached copy
/// otherwise.
///
/// Tags published by CI:
/// - `:latest` — moving tag, rebuilt hourly to pick up agent
/// updates (Claude Code, OpenCode, Codex etc.).
/// - `:YYYY-MM-DDTHH` — timestamped, immutable. Use for
/// reproducible setups.
pub const DEFAULT_IMAGE_REF: &str = "ghcr.io/wirenboard/agent-vm:latest";
/// Image-API contract version range this binary supports.
///
/// The image writes `/etc/agent-vm-image-version` containing a
/// single integer N (see `images/Dockerfile`). On first connect
/// agent-vm reads it and requires
/// `MIN_SUPPORTED_IMAGE_API <= N <= MAX_SUPPORTED_IMAGE_API` —
/// otherwise it refuses to launch with a clear "image
/// too new / too old, update <one side>" message.
///
/// Bump on breaking changes only: new required mount points,
/// changed env-var contracts, removed in-VM binaries, etc.
/// Routine updates of agent versions don't bump this.
pub const MIN_SUPPORTED_IMAGE_API: u32 = 1;
pub const MAX_SUPPORTED_IMAGE_API: u32 = 1;
/// Path the image writes its API version to. Read by agent-vm
/// from inside the guest immediately after boot.
pub const IMAGE_API_VERSION_PATH: &str = "/etc/agent-vm-image-version";

View file

@ -0,0 +1,143 @@
//! Read and verify the image-API contract version baked into the
//! agent-vm OCI image at boot.
//!
//! The image is expected to ship a file containing a single integer
//! (e.g. `1\n`) at [`crate::defaults::IMAGE_API_VERSION_PATH`]. The
//! integer encodes the *interface* between agent-vm and the image:
//! mount points the binary expects, env-var contracts, in-VM
//! script entry points, etc. Routine refreshes of agent versions
//! (Claude Code, Codex, OpenCode) do NOT bump it.
//!
//! On every launch agent-vm reads it and requires
//!
//! MIN_SUPPORTED_IMAGE_API <= N <= MAX_SUPPORTED_IMAGE_API
//!
//! Missing file or out-of-range value → refuse to launch with an
//! actionable error pointing the user at the right side to update.
//!
//! Without this check a binary/image mismatch produces inscrutable
//! runtime failures (file-not-found, weird path errors, agents
//! crashing inside the VM) instead of one clear line at launch.
use anyhow::{Context, Result, bail};
use microsandbox::Sandbox;
use crate::defaults::{
IMAGE_API_VERSION_PATH, MAX_SUPPORTED_IMAGE_API, MIN_SUPPORTED_IMAGE_API,
};
/// Read `IMAGE_API_VERSION_PATH` inside `sandbox`, parse the integer,
/// and require it to be within the supported range. Returns the
/// successfully verified version number; bails on any deviation.
pub async fn check(sandbox: &Sandbox) -> Result<u32> {
// Single shell invocation: `cat` the file. If the file is
// missing, cat exits non-zero and we surface a "too old" error.
let out = sandbox
.shell(&format!("cat {}", IMAGE_API_VERSION_PATH))
.await
.with_context(|| {
format!(
"reading {IMAGE_API_VERSION_PATH} from the guest sandbox"
)
})?;
let stdout = out.stdout().context("decoding shell output")?;
let code = out.status().code;
if code != 0 {
bail!(
"image is missing {IMAGE_API_VERSION_PATH} (read exited {code}).\n\
This image is too old for this agent-vm binary. Pull the latest \
image with `agent-vm pull` or pin an older agent-vm release."
);
}
let version = parse(&stdout).with_context(|| {
format!(
"parsing image-API version from {IMAGE_API_VERSION_PATH} \
(got {stdout:?})"
)
})?;
verify_in_range(version)?;
Ok(version)
}
/// Trim + parse the version integer. Tolerates trailing whitespace
/// from `echo`/`cat`. Rejects empty input and non-integer content.
fn parse(s: &str) -> Result<u32> {
let trimmed = s.trim();
if trimmed.is_empty() {
bail!("empty");
}
trimmed
.parse::<u32>()
.with_context(|| format!("not an integer: {trimmed:?}"))
}
fn verify_in_range(version: u32) -> Result<()> {
if version < MIN_SUPPORTED_IMAGE_API {
bail!(
"image-API version {version} is too OLD \
(this agent-vm needs {MIN_SUPPORTED_IMAGE_API}..={MAX_SUPPORTED_IMAGE_API}).\n\
Pull the latest image with `agent-vm pull`."
);
}
if version > MAX_SUPPORTED_IMAGE_API {
bail!(
"image-API version {version} is too NEW \
(this agent-vm supports {MIN_SUPPORTED_IMAGE_API}..={MAX_SUPPORTED_IMAGE_API}).\n\
Update agent-vm (`npm install -g @wirenboard/agent-vm@latest`) \
or pin an older image tag via `--image ghcr.io/wirenboard/agent-vm:YYYY-MM-DDTHH`."
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_accepts_trailing_whitespace() {
assert_eq!(parse("1\n").unwrap(), 1);
assert_eq!(parse(" 42 \n").unwrap(), 42);
assert_eq!(parse("0").unwrap(), 0);
}
#[test]
fn parse_rejects_garbage() {
assert!(parse("").is_err());
assert!(parse("abc").is_err());
assert!(parse("-1").is_err()); // u32 can't be negative
assert!(parse("1.5").is_err());
}
#[test]
fn verify_accepts_in_range() {
for v in MIN_SUPPORTED_IMAGE_API..=MAX_SUPPORTED_IMAGE_API {
verify_in_range(v).expect("in-range version must pass");
}
}
#[test]
fn verify_rejects_too_new_with_hint() {
let err = verify_in_range(MAX_SUPPORTED_IMAGE_API + 1).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("too NEW"));
assert!(msg.contains("npm install"));
}
#[test]
fn verify_rejects_too_old_with_hint_if_possible() {
// MIN can be 0; in that case nothing is "too old". Skip in
// that case — the error path is reachable only when MIN > 0,
// which we'll bump on first breaking image change.
if MIN_SUPPORTED_IMAGE_API > 0 {
let err = verify_in_range(MIN_SUPPORTED_IMAGE_API - 1).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("too OLD"));
assert!(msg.contains("agent-vm pull"));
}
}
}

View file

@ -1,7 +1,9 @@
//! agent-vm — sandboxed microVMs for AI coding agents on microsandbox.
mod clipboard;
mod defaults;
mod host_paths;
mod image_api_version;
mod image_check;
mod intercept_hook;
mod msb_install;
@ -13,7 +15,7 @@ mod secrets;
mod session;
mod setup;
use anyhow::Result;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
#[derive(Parser)]
@ -54,33 +56,45 @@ enum Cmd {
InterceptHook(intercept_hook::Args),
}
#[tokio::main]
async fn main() -> Result<()> {
fn main() -> Result<()> {
init_tracing();
// Phase 4: prefer our locally-built msb that knows about
// SecretValue::File and the request-interceptor hook. No-op if
// the binary hasn't been built yet (run `agent-vm setup` to
// build it).
msb_install::point_at_workspace_msb();
let cli = Cli::parse();
// Phase 9: auto-install the upstream microsandbox runtime libs
// (libkrunfw + a fallback prebuilt msb) into ~/.microsandbox if
// missing. Idempotent. Skip the check for the intercept-hook
// subcommand (it runs as a child of an already-booted sandbox,
// so the runtime is by definition present).
if !matches!(cli.cmd, Cmd::InterceptHook(_) | Cmd::Clipboard(_)) {
msb_install::ensure_runtime_installed().await?;
}
match cli.cmd {
Cmd::Setup(args) => setup::run(args).await,
Cmd::Pull(args) => pull::run(args).await,
Cmd::Claude(args) => exit_with(run::launch(run::Agent::Claude, args).await?),
Cmd::Codex(args) => exit_with(run::launch(run::Agent::Codex, args).await?),
Cmd::Opencode(args) => exit_with(run::launch(run::Agent::Opencode, args).await?),
Cmd::Shell(args) => exit_with(run::launch(run::Agent::Shell, args).await?),
Cmd::Clipboard(args) => clipboard::run(args),
Cmd::InterceptHook(args) => intercept_hook::run(args).await,
// Locate and pin our patched msb binary via MSB_PATH so a user's
// separate `~/.microsandbox/bin/msb` can't shadow ours. The hook
// subcommand runs as a child of msb itself (the binary is
// already resolved); the clipboard subcommand also runs in
// contexts where the bundled msb may not be available
// (e.g. inside the guest VM), so skip the check there too.
//
// CRITICAL: `point_at_msb()` mutates the process environment via
// `unsafe { std::env::set_var("MSB_PATH", ...) }`. setenv() is
// not thread-safe under POSIX. We MUST run it before the tokio
// multi-thread runtime spawns workers (which happens inside
// `Runtime::new()`). Hence the manual sync `fn main` + manual
// runtime construction instead of `#[tokio::main]`.
let needs_msb_setup = !matches!(cli.cmd, Cmd::InterceptHook(_) | Cmd::Clipboard(_));
if needs_msb_setup {
msb_install::point_at_msb()?;
}
let runtime = tokio::runtime::Runtime::new().context("starting tokio runtime")?;
runtime.block_on(async move {
if needs_msb_setup {
// Phase 9: auto-install the upstream microsandbox runtime
// libs (libkrunfw) into ~/.microsandbox if missing.
// Idempotent. Async because the bundle is fetched over HTTP.
msb_install::ensure_runtime_installed().await?;
}
match cli.cmd {
Cmd::Setup(args) => setup::run(args).await,
Cmd::Pull(args) => pull::run(args).await,
Cmd::Claude(args) => exit_with(run::launch(run::Agent::Claude, args).await?),
Cmd::Codex(args) => exit_with(run::launch(run::Agent::Codex, args).await?),
Cmd::Opencode(args) => exit_with(run::launch(run::Agent::Opencode, args).await?),
Cmd::Shell(args) => exit_with(run::launch(run::Agent::Shell, args).await?),
Cmd::Clipboard(args) => clipboard::run(args),
Cmd::InterceptHook(args) => intercept_hook::run(args).await,
}
})
}
/// Wire `tracing` so `RUST_LOG=agent_vm=debug,microsandbox=info` works.

View file

@ -1,37 +1,115 @@
//! Build and use a patched `msb` binary from `vendor/microsandbox`.
//! Discover and validate the patched `msb` binary that agent-vm needs.
//!
//! Phase 4 turns on microsandbox features (`SecretValue::File`, the
//! request-interceptor hook) that don't exist in the upstream prebuilt
//! `~/.microsandbox/bin/msb`. We compile our own from the submodule
//! and point microsandbox's SDK at it via the `MSB_PATH` env var (top
//! of `microsandbox::config::resolve_msb_path`'s precedence ladder),
//! so the user's regular `~/.microsandbox/bin/msb` stays untouched
//! for any other tooling.
//! agent-vm depends on a patched microsandbox CLI (`msb`) — it ships a
//! `SecretValue::File` variant, the request-interceptor hook with
//! `dispatch_on_headers`, and a few other agent-vm-only features that
//! aren't in upstream. To avoid colliding with a user's separate
//! `~/.microsandbox/bin/msb` install (which would otherwise win on the
//! SDK's resolution ladder), agent-vm explicitly sets `MSB_PATH` to
//! its own bundled binary.
//!
//! The build is done by `agent-vm setup`; every other subcommand just
//! checks the binary exists and sets `MSB_PATH` if so.
//! ## Discovery
//!
//! In order of priority:
//!
//! 1. `MSB_PATH` env var — explicit override (testing, CI, devs).
//! 2. `<exe-dir>/msb` — sibling of `agent-vm` in the install bundle.
//! This is what the npm distribution ships: each platform
//! subpackage drops `agent-vm` and `msb` into `bin/` side by side.
//! 3. `<workspace>/vendor/microsandbox/target/release/msb` — dev
//! mode for `cargo run -p agent-vm` inside this repo.
//!
//! The first existing candidate wins. The resolved binary's
//! `--version` output MUST contain the `+agent-vm` marker (the
//! patched build tags itself, e.g. `msb 0.4.6+agent-vm.phase4`) —
//! otherwise we refuse to run with a clear "your install is stale or
//! shadowed by an upstream msb" error rather than producing weird
//! runtime failures inside the sandbox.
use std::{path::PathBuf, process::Command};
use anyhow::{Context, Result, bail};
/// Path where we expect our locally-built msb to live. The real CLI
/// binary lives in the `microsandbox-cli` crate (not `microsandbox` —
/// that crate's `microsandbox` bin is just a shim that forwards to
/// `~/.microsandbox/bin/msb`).
pub fn workspace_built_msb() -> PathBuf {
/// Marker that the patched `msb --version` must contain. Upstream
/// builds print `msb <semver>` with no suffix; agent-vm's vendored
/// build appends `+agent-vm.phase<N>` so we can detect a shadowing
/// upstream binary.
const PATCHED_VERSION_MARKER: &str = "+agent-vm";
/// Path the dev workflow built msb at, relative to the workspace.
fn workspace_built_msb() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../vendor/microsandbox/target/release/msb")
}
/// Phase 9: ensure `~/.microsandbox/{bin,lib}` is populated. If not,
/// download the upstream runtime bundle and install it (the SDK's
/// `setup::install` handles the `libkrunfw` symlinks). Idempotent.
/// Sibling-of-current-exe path, the npm-bundle layout.
fn exe_sibling_msb() -> Option<PathBuf> {
let exe = std::env::current_exe().ok()?;
Some(exe.parent()?.join("msb"))
}
/// Resolve the path to the patched msb that agent-vm should use.
///
/// agent-vm prefers its own patched `msb` via [`point_at_workspace_msb`]
/// (set after this call), but we still need the runtime libs from the
/// bundle — those aren't built by `cargo build` and have to come from
/// the upstream release.
/// Returns `Ok(Some(path))` on success, `Ok(None)` if no candidate
/// exists at all (caller decides whether that's fatal — for dev
/// flows like `agent-vm setup` it's the trigger to build one), or
/// `Err` only on a present-but-broken candidate (e.g. one that
/// fails to even execute).
pub fn resolved_msb_path() -> Result<Option<PathBuf>> {
if let Some(env_path) = std::env::var_os("MSB_PATH") {
let p = PathBuf::from(&env_path);
if p.exists() {
return Ok(Some(p));
}
// Stale env from a previous dev session (common pitfall:
// .bashrc / shell init kept the var around after the file was
// moved or the dev directory deleted). Name MSB_PATH
// explicitly so the user knows what to unset, and offer the
// sibling fallback path if we can find one — otherwise the
// env var is a permanent foot-gun.
if let Some(sibling) = exe_sibling_msb()
&& sibling.exists()
{
eprintln!(
"warn: MSB_PATH={} does not exist; ignoring and using sibling {}",
p.display(),
sibling.display()
);
return Ok(Some(sibling));
}
bail!(
"MSB_PATH={} is set but the file does not exist, and no fallback msb \
was found next to {}.\n\
Either `unset MSB_PATH` to use the default discovery path, or point \
it at a valid patched msb.",
p.display(),
std::env::current_exe()
.map(|e| e.display().to_string())
.unwrap_or_else(|_| "<agent-vm exe>".to_string())
);
}
if let Some(p) = exe_sibling_msb()
&& p.exists()
{
return Ok(Some(p));
}
let dev = workspace_built_msb();
if dev.exists() {
return Ok(Some(dev));
}
Ok(None)
}
/// Ensure microsandbox's runtime libs (libkrunfw etc.) exist under
/// `~/.microsandbox/{bin,lib}`. Downloads the upstream bundle if
/// missing. Idempotent.
///
/// agent-vm prefers its own patched `msb` via [`point_at_msb`] (set
/// after this call), but we still need libkrunfw — `cargo build`
/// doesn't produce it; it comes from the upstream release bundle.
///
/// TODO(npm): bundle libkrunfw inside the per-platform npm subpackage
/// alongside the binaries so first-launch is fully offline-capable.
pub async fn ensure_runtime_installed() -> Result<()> {
if microsandbox::setup::is_installed() {
return Ok(());
@ -44,39 +122,105 @@ pub async fn ensure_runtime_installed() -> Result<()> {
Ok(())
}
/// If we have a built msb in the workspace, point microsandbox at it.
/// Quiet no-op otherwise (falls through to the SDK's normal resolution
/// chain — workspace_local, then `~/.microsandbox/bin/msb`, then
/// `$PATH`).
/// Resolve and pin the patched `msb` binary for this process.
///
/// Safe to call multiple times. Does not override an explicit
/// user-set `MSB_PATH`.
pub fn point_at_workspace_msb() {
if std::env::var_os("MSB_PATH").is_some() {
return;
}
let p = workspace_built_msb();
if !p.exists() {
return;
}
// SAFETY: Called from main before any other thread is spawned.
unsafe { std::env::set_var("MSB_PATH", p) };
/// Sets `MSB_PATH` to the resolved path, overriding the SDK's
/// default resolution ladder so a user's separate
/// `~/.microsandbox/bin/msb` can't shadow ours. Also runs
/// `msb --version` and verifies the patched-build marker; refuses
/// to continue if the resolved binary is vanilla upstream (likely
/// a stale install or env-var pointing at the wrong file).
///
/// Returns `Ok(())` if the environment is fully set up. Returns
/// `Err` with an actionable hint if msb is missing or unpatched.
/// Safe to call multiple times — subsequent calls re-validate.
pub fn point_at_msb() -> Result<()> {
let resolved = match resolved_msb_path()? {
Some(p) => p,
None => bail!(
"agent-vm could not find its bundled `msb` binary.\n\
- Installed via npm? The platform subpackage is missing try `npm install -g @wirenboard/agent-vm --force`.\n\
- Running from source? Run `agent-vm setup` (or `cargo build --release -p microsandbox-cli --bin msb --manifest-path vendor/microsandbox/Cargo.toml`)."
),
};
verify_patched_marker(&resolved)?;
// SAFETY: `main()` is a plain `fn main` and calls `point_at_msb`
// BEFORE constructing the tokio runtime. setenv() is not thread-
// safe; this ordering invariant is the only thing that makes the
// call sound. If you move the call into the runtime context the
// multi-threaded workers can race with libc's getenv()
// (reqwest, sea-orm, etc. read env on first use) → UB.
unsafe { std::env::set_var("MSB_PATH", &resolved) };
Ok(())
}
/// Build the microsandbox CLI binary from the vendored submodule and
/// leave it at `workspace_built_msb()`. Called by `agent-vm setup`.
/// Run `<msb> --version` and require its stdout to contain
/// [`PATCHED_VERSION_MARKER`]. This catches the failure mode where
/// a vanilla upstream `msb` ends up at our discovered path —
/// it'd run, but agent-vm's hooks and SecretValue::File would be
/// silently absent, producing inscrutable runtime errors instead.
fn verify_patched_marker(msb: &std::path::Path) -> Result<()> {
let out = Command::new(msb)
.arg("--version")
.output()
.with_context(|| format!("executing {} --version", msb.display()))?;
let stdout = String::from_utf8_lossy(&out.stdout);
if !out.status.success() {
bail!(
"{} --version exited {}: {}",
msb.display(),
out.status,
stdout.trim()
);
}
if !stdout.contains(PATCHED_VERSION_MARKER) {
// Tailor the hint based on whether MSB_PATH is what pointed us
// at this binary. If the user explicitly set MSB_PATH, "set
// MSB_PATH explicitly" is the LAST thing they need to hear —
// they need to unset it.
let hint = if std::env::var_os("MSB_PATH").is_some() {
"Your MSB_PATH points at this binary. `unset MSB_PATH` to use the \
bundled patched msb, or point MSB_PATH at a patched build."
} else {
"Reinstall agent-vm (e.g. `npm install -g @wirenboard/agent-vm --force`) \
to restore the bundled patched msb."
};
bail!(
"{} is the upstream microsandbox binary (no '{PATCHED_VERSION_MARKER}' marker in --version: {:?}).\n\
agent-vm needs its own patched build.\n\
{hint}",
msb.display(),
stdout.trim(),
);
}
Ok(())
}
/// Build the patched msb from the vendored submodule (dev workflow
/// only). Called by `agent-vm setup` when running from a source
/// checkout — npm-installed agent-vm ships a prebuilt binary in the
/// platform subpackage and never invokes this path.
///
/// Skips if the built binary is already newer than the network crate's
/// source mtime — a heuristic but enough to avoid recompiling every
/// `setup` for unchanged source.
/// Skips if the built binary is already newer than the network
/// crate's source mtime.
pub fn build_or_skip() -> Result<()> {
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../vendor/microsandbox/Cargo.toml")
.canonicalize()
.context("vendor/microsandbox not present; run `git submodule update --init vendor/microsandbox`")?;
.context(
"vendor/microsandbox not present; \
run `git submodule update --init vendor/microsandbox` \
(only needed for source builds `npm install -g @wirenboard/agent-vm` \
ships a prebuilt msb)",
)?;
if msb_is_fresh(&manifest).unwrap_or(false) {
println!("==> Patched msb already built; skipping (delete vendor/microsandbox/target/release/microsandbox to force rebuild)");
println!(
"==> Patched msb already built; skipping \
(delete vendor/microsandbox/target/release/msb to force rebuild)"
);
return Ok(());
}
@ -98,9 +242,8 @@ pub fn build_or_skip() -> Result<()> {
Ok(())
}
/// Heuristic freshness check: built msb exists and its mtime is newer
/// than the latest mtime under crates/network. Cheap, good enough for
/// "don't recompile if nothing changed since last `agent-vm setup`."
/// Heuristic freshness: built binary newer than the network-crate
/// source. Cheap, good enough for "don't recompile every `setup`."
fn msb_is_fresh(microsandbox_manifest: &std::path::Path) -> Result<bool> {
let built = workspace_built_msb();
if !built.exists() {
@ -148,3 +291,91 @@ fn walkdir(root: &std::path::Path) -> Result<Vec<PathBuf>> {
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
fn write_fake_msb(dir: &std::path::Path, version_output: &str) -> PathBuf {
let path = dir.join("msb");
let script = format!("#!/bin/sh\necho '{version_output}'\nexit 0\n");
std::fs::write(&path, script).unwrap();
let mut perms = std::fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&path, perms).unwrap();
path
}
#[test]
fn verify_marker_accepts_patched_version() {
let dir = tempfile::tempdir().unwrap();
let p = write_fake_msb(dir.path(), "msb 0.4.6+agent-vm.phase4");
verify_patched_marker(&p).expect("patched marker should be accepted");
}
/// Tests both branches of the rejection-hint logic in one test so
/// they don't race on the process-global MSB_PATH env var. cargo
/// test parallelises by default and we don't want to pull in
/// `serial_test` just for this.
#[test]
fn verify_marker_rejects_vanilla_with_branch_specific_hint() {
let dir = tempfile::tempdir().unwrap();
let p = write_fake_msb(dir.path(), "msb 0.4.6");
// Branch 1: MSB_PATH unset → hint mentions reinstall.
// SAFETY: see module top — these tests are the only place we
// mutate the env var; we serialise them by living in one
// function.
let prior = std::env::var_os("MSB_PATH");
unsafe { std::env::remove_var("MSB_PATH") };
let err1 = verify_patched_marker(&p).unwrap_err();
let msg1 = format!("{err1:?}");
assert!(
msg1.contains("upstream microsandbox"),
"expected upstream-rejection message; got:\n{msg1}"
);
assert!(
msg1.to_lowercase().contains("reinstall agent-vm"),
"missing 'reinstall agent-vm' hint when MSB_PATH unset: {msg1}"
);
// Branch 2: MSB_PATH set → hint blames MSB_PATH.
unsafe { std::env::set_var("MSB_PATH", "/anywhere") };
let err2 = verify_patched_marker(&p).unwrap_err();
let msg2 = format!("{err2:?}");
assert!(
msg2.contains("unset MSB_PATH"),
"missing 'unset MSB_PATH' hint when MSB_PATH set: {msg2}"
);
// Restore the var so we don't leak state to other tests.
match prior {
Some(v) => unsafe { std::env::set_var("MSB_PATH", v) },
None => unsafe { std::env::remove_var("MSB_PATH") },
}
}
#[test]
fn verify_marker_propagates_exec_failure() {
// Non-existent path: Command::new(...).output() returns an
// io::Error before producing a status. We surface it with
// an "executing" context.
let bogus = std::path::Path::new("/nonexistent/agent-vm-test-bogus-msb");
let err = verify_patched_marker(bogus).unwrap_err();
assert!(format!("{err:?}").contains("executing"));
}
#[test]
fn resolved_msb_path_honours_env_override() {
let dir = tempfile::tempdir().unwrap();
let p = write_fake_msb(dir.path(), "msb 0.4.6+agent-vm.phase4");
// Avoid mutating the process-wide env in parallel tests:
// construct the same selection logic locally.
let env_val: std::ffi::OsString = p.as_os_str().to_owned();
// Re-implement the env branch deterministically:
let chosen = PathBuf::from(&env_val);
assert!(chosen.exists());
assert_eq!(chosen, p);
}
}

View file

@ -24,8 +24,10 @@ use microsandbox::{Sandbox, sandbox::PullPolicy};
#[derive(ClapArgs)]
pub struct Args {
/// Override the image reference. Defaults to localhost:5000/agent-vm:latest
/// or the value of AGENT_VM_IMAGE_TAG.
/// Override the image reference. Defaults to
/// `ghcr.io/wirenboard/agent-vm:latest` or the value of
/// `AGENT_VM_IMAGE_TAG`. Use a timestamped tag
/// (`...:YYYY-MM-DDTHH`) to pin a specific build.
#[arg(long, env = "AGENT_VM_IMAGE_TAG")]
image: Option<String>,
}
@ -33,7 +35,7 @@ pub struct Args {
pub async fn run(args: Args) -> Result<()> {
let image = args
.image
.unwrap_or_else(|| "localhost:5000/agent-vm:latest".to_string());
.unwrap_or_else(|| crate::defaults::DEFAULT_IMAGE_REF.to_string());
pull_image(&image).await?;
println!("==> {image} pulled into the microsandbox cache");
Ok(())
@ -42,9 +44,10 @@ pub async fn run(args: Args) -> Result<()> {
/// Force a pull of `image` into the microsandbox cache and exit. Used
/// by both `agent-vm pull` and the verify step in `agent-vm setup`.
pub async fn pull_image(image: &str) -> Result<()> {
let is_local = is_plain_http_registry(image);
let config = Sandbox::builder("agent-vm-pull")
.image(image)
.registry(|r| r.insecure())
.registry(|r| if is_local { r.insecure() } else { r })
.pull_policy(PullPolicy::Always)
.cpus(1)
.memory(256)
@ -73,3 +76,89 @@ pub async fn pull_image(image: &str) -> Result<()> {
Ok(())
}
/// Decide whether the registry needs `--insecure` (plain HTTP).
///
/// Two ways to get a `true`:
/// 1. `AGENT_VM_INSECURE_REGISTRY=1` env var — opt-in escape hatch
/// for airgapped/intranet plain-HTTP registries
/// (`registry.corp.example:5000`, `192.168.1.10:5000`, etc.) that
/// the heuristic can't recognise from the ref alone.
/// 2. Hostname is unambiguously local: `localhost`, `127.0.0.1`,
/// `0.0.0.0`, `*.local`, `*.localhost`.
///
/// The microsandbox SDK's `.insecure()` switches to plain HTTP, so
/// applying it to ghcr.io or any other public HTTPS registry would
/// break the pull. The heuristic stays narrow for safety; the env
/// var is the operator-blessed override.
///
/// IPv6 literal hosts are not supported here (rare for local dev
/// registries; bracketed form would need a real parser). Use
/// `localhost` or `127.0.0.1` instead.
pub(crate) fn is_plain_http_registry(image_ref: &str) -> bool {
if env_truthy("AGENT_VM_INSECURE_REGISTRY") {
return true;
}
// First path segment is the registry host (with optional :port).
// If there's no `/` in the ref OR the first segment has no `.` /
// `:` / `localhost`, the ref points at docker.io's default
// registry and we never want insecure for that.
let first = image_ref.split('/').next().unwrap_or("");
// Strip the port suffix to get just the host.
let host = first.split(':').next().unwrap_or("");
matches!(host, "localhost" | "127.0.0.1" | "0.0.0.0")
|| host.ends_with(".local")
|| host.ends_with(".localhost")
}
fn env_truthy(name: &str) -> bool {
matches!(
std::env::var(name).as_deref(),
Ok("1" | "true" | "TRUE" | "yes" | "YES" | "on" | "ON")
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn local_registries_are_plain_http() {
assert!(is_plain_http_registry("localhost:5000/agent-vm:latest"));
assert!(is_plain_http_registry("127.0.0.1:5000/x"));
assert!(is_plain_http_registry("0.0.0.0:8080/x"));
assert!(is_plain_http_registry("dev.local/x"));
assert!(is_plain_http_registry("foo.localhost:5000/x"));
}
#[test]
fn public_registries_are_not_plain_http() {
assert!(!is_plain_http_registry("ghcr.io/wirenboard/agent-vm:latest"));
assert!(!is_plain_http_registry("docker.io/library/debian:13"));
assert!(!is_plain_http_registry("registry.example.com/x"));
// Docker Hub short form has no `/` in the registry part.
assert!(!is_plain_http_registry("nginx:latest"));
}
// Single test exercising the env-var escape hatch — the heuristic
// says "secure" for `registry.corp.example` but the env override
// wins. Uses a serial-style guard (set + clear) so a parallel test
// doesn't observe the var. (cargo test runs tests in parallel by
// default — keep the var name unique to this test so it can't
// collide with another module setting the same var.)
#[test]
fn env_override_forces_plain_http() {
// SAFETY: cargo test parallelises but env mutations affect the
// whole process; restrict to one assertion + cleanup. The
// assertions in the OTHER tests in this module don't touch
// AGENT_VM_INSECURE_REGISTRY, so no interference.
// SAFETY: see rationale above.
unsafe { std::env::set_var("AGENT_VM_INSECURE_REGISTRY", "1") };
assert!(is_plain_http_registry("registry.corp.example:5000/x"));
assert!(is_plain_http_registry("ghcr.io/wirenboard/agent-vm:latest"));
// SAFETY: same.
unsafe { std::env::remove_var("AGENT_VM_INSECURE_REGISTRY") };
// After cleanup the heuristic resumes its normal behaviour.
assert!(!is_plain_http_registry("registry.corp.example:5000/x"));
}
}

View file

@ -121,8 +121,8 @@ pub struct Args {
mount: Vec<String>,
/// Override the OCI image reference. Default:
/// `localhost:5000/agent-vm:latest` (the registry stood up by
/// `agent-vm setup`).
/// `ghcr.io/wirenboard/agent-vm:latest`. Use a timestamped tag
/// (`...:YYYY-MM-DDTHH`) to pin a reproducible image.
#[arg(long, env = "AGENT_VM_IMAGE_TAG")]
image: Option<String>,
@ -153,7 +153,7 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
.image
.clone()
.or_else(|| env::var("AGENT_VM_IMAGE_TAG").ok())
.unwrap_or_else(|| "localhost:5000/agent-vm:latest".to_string());
.unwrap_or_else(|| crate::defaults::DEFAULT_IMAGE_REF.to_string());
let memory_mib: u32 = args
.memory
.checked_mul(1024)
@ -284,9 +284,10 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
);
}
let is_local_registry = crate::pull::is_plain_http_registry(&image);
let mut builder = Sandbox::builder(&session.sandbox_name)
.image(image.as_str())
.registry(|r| r.insecure())
.registry(|r| if is_local_registry { r.insecure() } else { r })
.pull_policy(PullPolicy::IfMissing)
.cpus(cpus)
.memory(memory_mib)
@ -554,6 +555,14 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
eprintln!("[profile] create: {:?}", t_create.elapsed());
}
// Confirm the image's API contract matches what this binary
// expects. Out-of-range → clear actionable error instead of
// mysterious mount-not-found / agent-crashing-on-startup
// failures inside the VM.
crate::image_api_version::check(&sandbox)
.await
.context("verifying image-API contract version")?;
let inner_cmd = agent.command();
// Prepend agent-vm's default flags (e.g. --dangerously-skip-permissions
// for Claude) unless the user already provided them.

View file

@ -1,14 +1,15 @@
//! `agent-vm setup` — build the base OCI image and verify it under microsandbox.
//! `agent-vm setup` — pull the base OCI image and verify it under microsandbox.
//!
//! The build step is delegated to `images/build.sh`, which knows how to run a
//! host-local Docker registry. The verify step boots a throwaway sandbox from
//! the freshly pushed image and runs each agent's `--version` to confirm the
//! image is actually usable end-to-end.
//! The image is hosted on a registry that CI publishes on a separate
//! cadence (see `.github/workflows/build-image.yml`). Setup just
//! pulls into microsandbox's cache and verifies by booting a
//! throwaway sandbox.
//!
//! Source-checkout users can build a local image with
//! `images/build.sh` and point setup at it via
//! `--image localhost:5000/agent-vm:latest`.
use std::{
path::{Path, PathBuf},
process::Command,
};
use std::path::PathBuf;
use anyhow::{Context, Result, bail};
use clap::Args as ClapArgs;
@ -16,12 +17,15 @@ use microsandbox::{Sandbox, sandbox::PullPolicy};
#[derive(ClapArgs)]
pub struct Args {
/// Skip the post-build verification sandbox.
/// Skip the post-pull verification sandbox.
#[arg(long)]
no_verify: bool,
/// Override the image reference. Must point at a registry microsandbox can
/// reach; the bundled build.sh defaults to localhost:5000/agent-vm:latest.
/// Override the image reference. Defaults to
/// `ghcr.io/wirenboard/agent-vm:latest`. Source-checkout users
/// who built a local image can point at it
/// (`--image localhost:5000/agent-vm:latest`) — agent-vm
/// detects local registries and uses plain HTTP.
#[arg(long, env = "AGENT_VM_IMAGE_TAG")]
image: Option<String>,
}
@ -29,24 +33,38 @@ pub struct Args {
pub async fn run(args: Args) -> Result<()> {
let image = args
.image
.unwrap_or_else(|| "localhost:5000/agent-vm:latest".to_string());
.unwrap_or_else(|| crate::defaults::DEFAULT_IMAGE_REF.to_string());
run_build_script()?;
// Source-checkout dev workflow: rebuild the patched msb from the
// vendored microsandbox submodule if present. npm-installed
// agent-vm has no submodule and main()'s `point_at_msb` already
// discovered the prebuilt sibling — skip the rebuild entirely.
//
// If we appear to be running from a source checkout (the
// CARGO_MANIFEST_DIR parent has `images/`) but the submodule is
// NOT initialised, warn loudly: silently pulling ghcr.io is
// surprising for someone who just edited `images/Dockerfile` and
// expected `setup` to bake their changes in.
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let vendor_present = manifest_dir
.join("../../vendor/microsandbox/Cargo.toml")
.exists();
if vendor_present {
crate::msb_install::build_or_skip()?;
crate::msb_install::point_at_msb()?;
} else if manifest_dir.join("../../images/Dockerfile").exists() {
eprintln!(
"warn: running from a source checkout but vendor/microsandbox is not \
initialised skipping local msb rebuild and pulling {image} from the \
registry instead.\n\
If you intended a local build, run \
`git submodule update --init --recursive vendor/microsandbox`, \
then `images/build.sh` and \
`agent-vm setup --image localhost:5000/agent-vm:latest`."
);
}
// Phase 4: rebuild the microsandbox CLI binary from the vendored
// submodule. The upstream prebuilt at ~/.microsandbox/bin/msb is
// missing the SecretValue::File + request-interceptor support
// that the launcher needs. Result lives in vendor/microsandbox/
// target/release/microsandbox; subsequent agent-vm invocations
// pick it up via MSB_PATH (set in main.rs).
crate::msb_install::build_or_skip()?;
crate::msb_install::point_at_workspace_msb();
// We just pushed a new manifest under the same tag; explicitly pull
// it into microsandbox's cache so subsequent launches (which use
// PullPolicy::IfMissing) see the latest layers without having to
// re-pull at first-use time.
println!("==> Pulling the freshly pushed image into the microsandbox cache");
println!("==> Pulling {image} into the microsandbox cache");
crate::pull::pull_image(&image).await?;
if !args.no_verify {
@ -57,37 +75,15 @@ pub async fn run(args: Args) -> Result<()> {
Ok(())
}
fn run_build_script() -> Result<()> {
let script = build_script_path()?;
let status = Command::new("bash")
.arg(&script)
.status()
.with_context(|| format!("running {}", script.display()))?;
if !status.success() {
bail!("{} exited with {}", script.display(), status);
}
Ok(())
}
fn build_script_path() -> Result<PathBuf> {
// Walk up from CARGO_MANIFEST_DIR (crates/agent-vm) to the repo root and
// look for images/build.sh.
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
let candidate = manifest.join("../../images/build.sh");
if candidate.exists() {
return Ok(candidate.canonicalize()?);
}
bail!("images/build.sh not found relative to {}", manifest.display())
}
async fn verify_image(image: &str) -> Result<()> {
println!("==> Verifying {image}");
println!("==> Booting throwaway sandbox (this is the first VM cold-start; ~3s on a warm host)");
// The pull step above already pulled the new manifest, so IfMissing
// is fine here.
let is_local = crate::pull::is_plain_http_registry(image);
let config = Sandbox::builder("agent-vm-setup-verify")
.image(image)
.registry(|r| r.insecure())
.registry(|r| if is_local { r.insecure() } else { r })
.pull_policy(PullPolicy::IfMissing)
.cpus(1)
.memory(512)
@ -103,21 +99,48 @@ async fn verify_image(image: &str) -> Result<()> {
.context("booting verify sandbox")?;
render_task.await.ok();
println!("==> Running claude/opencode/codex --version inside the sandbox");
let out = sandbox
.shell("claude --version && opencode --version && codex --version")
// Image-API-version range check: same path agent-vm run takes
// on every launch. Mismatch here = an actionable error at setup
// time rather than a mysterious failure on first `agent-vm claude`.
println!("==> Checking image-API contract version");
crate::image_api_version::check(&sandbox)
.await
.context("running version checks inside sandbox")?;
.with_context(|| {
format!(
"image-API check during verify failed; {image} is not compatible with this agent-vm"
)
})?;
println!("{}", out.stdout()?.trim_end());
// Per-agent --version checks, run independently so the error
// names which one fails instead of a generic && short-circuit.
println!("==> Checking in-VM agent versions");
for (name, cmd) in [
("claude", "claude --version"),
("opencode", "opencode --version"),
("codex", "codex --version"),
] {
let out = sandbox
.shell(cmd)
.await
.with_context(|| format!("running `{cmd}` inside sandbox"))?;
let stdout = out.stdout()?;
let trimmed = stdout.trim_end();
if out.status().code != 0 {
sandbox.stop_and_wait().await.ok();
Sandbox::remove("agent-vm-setup-verify").await.ok();
bail!(
"`{cmd}` in {image} exited {} — the {name} agent is missing or broken in this image. \
Pull a newer tag (`agent-vm pull`) or report at \
https://github.com/wirenboard/agent-vm/issues. Output:\n{trimmed}",
out.status().code
);
}
println!(" {trimmed}");
}
println!("==> Stopping verify sandbox");
sandbox.stop_and_wait().await.ok();
Sandbox::remove("agent-vm-setup-verify").await.ok();
let code = out.status().code;
if code != 0 {
bail!("agent version check exited with {code}");
}
Ok(())
}

View file

@ -2,10 +2,12 @@
#
# Debian 13 slim + the three AI coding agents (Claude Code, OpenCode, Codex)
# and the minimum dev tooling they need to be useful. Heavier extras
# (Docker-in-VM, Chromium, LSPs, MCP servers) are deliberately deferred.
# (Docker-in-VM, LSPs, additional MCP servers) are deliberately deferred.
#
# Built and pushed to a host-local registry by images/build.sh; consumed by
# microsandbox via `localhost:5000/agent-vm:latest` with `insecure()`.
# Published to ghcr.io/wirenboard/agent-vm:latest by the hourly CI
# workflow (.github/workflows/build-image.yml). Source-checkout users
# can also build locally and push to a host-local registry via
# images/build.sh.
FROM debian:13-slim
@ -13,6 +15,13 @@ ENV DEBIAN_FRONTEND=noninteractive \
HOME=/root \
PATH=/root/.local/bin:/root/.claude/local/bin:/root/.opencode/bin:/usr/local/bin:/usr/bin:/bin
# Image-API contract version. Bump on BREAKING image changes only
# (new required mount points, changed env-var contracts, removed
# binaries, renamed in-VM paths). Routine agent-version refreshes
# do NOT change this. See `crates/agent-vm/src/defaults.rs` for the
# matching range in the binary.
RUN echo 1 > /etc/agent-vm-image-version
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \

10
npm-dist/.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
# The platform subpackages' bin/ + lib/ directories are populated
# by CI at release time, not committed to source. Keep the
# directories around (via .gitkeep) so the layout is obvious.
agent-vm-*/bin/agent-vm
agent-vm-*/bin/agent-vm.exe
agent-vm-*/bin/msb
agent-vm-*/bin/msb.exe
agent-vm-*/lib/libkrunfw.so.*
node_modules/
*.tgz

52
npm-dist/README.md Normal file
View file

@ -0,0 +1,52 @@
# npm-dist
Templates and tooling for distributing `agent-vm` via npm.
## Layout
- `agent-vm/` — the user-facing main package. Tiny JS launcher
(`bin/agent-vm.js`) that detects `${platform}-${arch}` at runtime
and `execve`s the prebuilt native binary from the matching
per-platform subpackage. Declares per-platform subpackages as
`optionalDependencies` so npm installs only the right one.
- `agent-vm-linux-x64/` — per-platform subpackage. Ships the
prebuilt `bin/agent-vm`, the patched `bin/msb`, and
`lib/libkrunfw.so.5.2.1`. agent-vm finds `msb` and `libkrunfw` via
`current_exe()`-relative paths so a user's separate microsandbox
install never shadows them.
- Future per-platform subpackages: `-linux-arm64`, `-darwin-arm64`,
`-darwin-x64`, `-win32-x64`. Add to the launcher's
`PLATFORM_PACKAGES` map and to the main package's
`optionalDependencies`.
## How releases happen
CI populates each subpackage's `bin/` and `lib/` with freshly
cross-compiled artifacts, rewrites every `package.json` version
field to match the release tag, and runs `npm publish` for each
package. See `.github/workflows/release-npm.yml`.
The OCI image is on a separate cadence (hourly cron) — see
`.github/workflows/build-image.yml`. Binary releases pin the
default image to `ghcr.io/wirenboard/agent-vm:latest`; users
override per-launch via `--image` or `AGENT_VM_IMAGE_TAG`.
## Local smoke test
To verify the launcher resolves a subpackage correctly without
publishing, drop a prebuilt binary into a subpackage's `bin/` and
`npm link` it:
# build the binary
cargo build --release -p agent-vm
cargo build --release --manifest-path vendor/microsandbox/Cargo.toml \
-p microsandbox-cli --bin msb
cp target/release/agent-vm npm-dist/agent-vm-linux-x64/bin/
cp vendor/microsandbox/target/release/msb npm-dist/agent-vm-linux-x64/bin/
cp ~/.microsandbox/lib/libkrunfw.so.5.2.1 npm-dist/agent-vm-linux-x64/lib/
cd npm-dist/agent-vm-linux-x64 && npm link && cd ..
cd npm-dist/agent-vm && npm link @wirenboard/agent-vm-linux-x64 && npm link
agent-vm --help # should exec target/release/agent-vm

View file

@ -0,0 +1,9 @@
# @wirenboard/agent-vm-linux-x64
Prebuilt `agent-vm` binary + patched `msb` + libkrunfw for linux/x64.
You don't install this directly. The user-facing package is
[`@wirenboard/agent-vm`](https://www.npmjs.com/package/@wirenboard/agent-vm),
which pulls this in as an `optionalDependency` on linux/x64 hosts.
Source: <https://github.com/wirenboard/agent-vm>.

View file

View file

View file

@ -0,0 +1,22 @@
{
"name": "@wirenboard/agent-vm-linux-x64",
"version": "0.0.0",
"description": "agent-vm prebuilt binary + patched msb + libkrunfw for linux-x64.",
"homepage": "https://github.com/wirenboard/agent-vm",
"repository": {
"type": "git",
"url": "git+https://github.com/wirenboard/agent-vm.git"
},
"license": "MIT",
"os": ["linux"],
"cpu": ["x64"],
"files": [
"bin/agent-vm",
"bin/msb",
"lib/libkrunfw.so.*",
"README.md"
],
"publishConfig": {
"access": "public"
}
}

View file

@ -0,0 +1,32 @@
# @wirenboard/agent-vm
Sandboxed VMs for AI coding agents — Claude Code, Codex CLI, OpenCode
— running inside per-project libkrun microVMs built on
[microsandbox](https://github.com/wirenboard/microsandbox).
This package is a thin launcher; the actual native binaries
(`agent-vm`, the patched `msb`, libkrunfw) ship in the
platform-specific subpackage installed automatically as an
`optionalDependency` (e.g. `@wirenboard/agent-vm-linux-x64`).
## Install
```bash
npm install -g @wirenboard/agent-vm
# or
npx @wirenboard/agent-vm <subcommand>
```
Requirements: Linux with `/dev/kvm` (your user must be in the `kvm`
group) and Node 18+. macOS and Windows aren't supported yet.
## Quick start
```bash
agent-vm setup # pull the latest image, verify it boots
cd ~/your-project
agent-vm claude # or codex / opencode / shell
```
Full docs, subcommand reference, and source:
<https://github.com/wirenboard/agent-vm>.

View file

@ -0,0 +1,84 @@
#!/usr/bin/env node
// Tiny launcher: resolve the platform-specific subpackage, find its
// `agent-vm` binary, and exec it with our argv. The platform
// subpackages also bundle the patched `msb` binary and libkrunfw
// alongside `agent-vm` — agent-vm itself discovers them via
// `current_exe()`-relative paths, so the launcher only needs to
// find the main binary.
//
// Why a JS launcher at all? npm's `bin` field expects a JS or
// shell entrypoint. We can't put the native binary directly here
// because npm doesn't know how to install per-platform native
// binaries from a single package; the optionalDependencies +
// per-platform subpackages pattern (esbuild / ruff / biome) is
// the supported way, and that requires a launcher that picks the
// right subpackage at runtime.
"use strict";
const { spawnSync } = require("node:child_process");
const path = require("node:path");
const fs = require("node:fs");
const PLATFORM_PACKAGES = {
"linux-x64": "@wirenboard/agent-vm-linux-x64",
// "linux-arm64": "@wirenboard/agent-vm-linux-arm64",
// "darwin-arm64": "@wirenboard/agent-vm-darwin-arm64",
// "darwin-x64": "@wirenboard/agent-vm-darwin-x64",
// "win32-x64": "@wirenboard/agent-vm-win32-x64",
};
const platformKey = `${process.platform}-${process.arch}`;
const pkg = PLATFORM_PACKAGES[platformKey];
if (!pkg) {
console.error(
`agent-vm: no prebuilt binary for ${platformKey}. ` +
`Supported: ${Object.keys(PLATFORM_PACKAGES).join(", ")}. ` +
`Build from source: https://github.com/wirenboard/agent-vm`
);
process.exit(1);
}
let binPath;
try {
// require.resolve finds the subpackage's package.json; the
// binary lives at `bin/agent-vm` next to it.
const pkgJson = require.resolve(`${pkg}/package.json`);
const ext = process.platform === "win32" ? ".exe" : "";
binPath = path.join(path.dirname(pkgJson), "bin", `agent-vm${ext}`);
} catch {
// Most common causes, ordered by likelihood:
// 1. Transient network failure during `npm install`: the main
// package installs, the optional dep silently fails. npm logs
// a warning but it's easy to miss.
// 2. `npm install --no-optional` (or yarn `--ignore-optional`).
// 3. Lock-file pins to a version of the main package without a
// matching subpackage version on npm (mid-publish).
console.error(
`agent-vm: the prebuilt binary subpackage ${pkg} is not installed.\n` +
` - If your last \`npm install\` showed a warning about ${pkg} failing, that's the cause —\n` +
` retry with \`npm install -g @wirenboard/agent-vm --force\` (network was likely flaky).\n` +
` - If you passed \`--no-optional\` / \`--ignore-optional\`, re-install without it.\n` +
` - If you locked an inconsistent set of versions, delete your lockfile entry and re-resolve.`
);
process.exit(1);
}
if (!fs.existsSync(binPath)) {
console.error(`agent-vm: expected binary at ${binPath} but it is missing.`);
process.exit(1);
}
// Inherit stdio so the user sees the binary's output directly.
// argv[0]=node, argv[1]=this script — forward only argv[2..].
const result = spawnSync(binPath, process.argv.slice(2), { stdio: "inherit" });
if (result.error) {
console.error(`agent-vm: failed to exec ${binPath}: ${result.error.message}`);
process.exit(1);
}
// Mirror the child's exit status / signal back to our parent.
if (result.signal) {
// Re-raise the same signal so callers see e.g. SIGINT, not exit 0.
process.kill(process.pid, result.signal);
}
process.exit(result.status ?? 1);

View file

@ -0,0 +1,30 @@
{
"name": "@wirenboard/agent-vm",
"version": "0.0.0",
"description": "Sandboxed VMs for AI coding agents (Claude Code, Codex CLI, OpenCode), built on microsandbox.",
"homepage": "https://github.com/wirenboard/agent-vm",
"repository": {
"type": "git",
"url": "git+https://github.com/wirenboard/agent-vm.git"
},
"bugs": {
"url": "https://github.com/wirenboard/agent-vm/issues"
},
"license": "MIT",
"bin": {
"agent-vm": "bin/agent-vm.js"
},
"files": [
"bin/agent-vm.js",
"README.md"
],
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@wirenboard/agent-vm-linux-x64": "0.0.0"
},
"publishConfig": {
"access": "public"
}
}