mirror of
https://github.com/wirenboard/agent-vm.git
synced 2026-07-09 16:00:54 +00:00
merge rewrite-microsandbox-3: npm/ghcr distribution + image-API contract
This commit is contained in:
commit
0ad086e12f
22 changed files with 1433 additions and 158 deletions
147
.github/workflows/build-image.yml
vendored
Normal file
147
.github/workflows/build-image.yml
vendored
Normal 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
302
.github/workflows/release-npm.yml
vendored
Normal 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
1
Cargo.lock
generated
|
|
@ -32,6 +32,7 @@ dependencies = [
|
|||
"reqwest",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
|
|
|
|||
57
README.md
57
README.md
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
36
crates/agent-vm/src/defaults.rs
Normal file
36
crates/agent-vm/src/defaults.rs
Normal 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";
|
||||
143
crates/agent-vm/src/image_api_version.rs
Normal file
143
crates/agent-vm/src/image_api_version.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
10
npm-dist/.gitignore
vendored
Normal 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
52
npm-dist/README.md
Normal 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
|
||||
9
npm-dist/agent-vm-linux-x64/README.md
Normal file
9
npm-dist/agent-vm-linux-x64/README.md
Normal 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>.
|
||||
0
npm-dist/agent-vm-linux-x64/bin/.gitkeep
Normal file
0
npm-dist/agent-vm-linux-x64/bin/.gitkeep
Normal file
0
npm-dist/agent-vm-linux-x64/lib/.gitkeep
Normal file
0
npm-dist/agent-vm-linux-x64/lib/.gitkeep
Normal file
22
npm-dist/agent-vm-linux-x64/package.json
Normal file
22
npm-dist/agent-vm-linux-x64/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
32
npm-dist/agent-vm/README.md
Normal file
32
npm-dist/agent-vm/README.md
Normal 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>.
|
||||
84
npm-dist/agent-vm/bin/agent-vm.js
Executable file
84
npm-dist/agent-vm/bin/agent-vm.js
Executable 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);
|
||||
30
npm-dist/agent-vm/package.json
Normal file
30
npm-dist/agent-vm/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue