Compare commits

..

No commits in common. "main" and "v0.1.15" have entirely different histories.

35 changed files with 1185 additions and 4408 deletions

View file

@ -1,15 +0,0 @@
# Cross-compilation linkers.
#
# The `agent-vm` binary and the patched `msb` link a handful of C
# libraries (libcap-ng, libdbus, libsqlite3) plus the usual libc, so a
# cross build needs the matching cross linker — the default `cc` only
# knows how to emit host (x86_64) objects.
#
# CI (release-npm.yml, build-agent-vm/build-msb aarch64 matrix legs)
# installs `gcc-aarch64-linux-gnu` for the linker and the arm64 dev
# libraries (`libcap-ng-dev:arm64 libdbus-1-dev:arm64
# libsqlite3-dev:arm64`, enabled via `dpkg --add-architecture arm64`)
# so the final link resolves. Locally, install the same Debian/Ubuntu
# packages to reproduce.
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"

View file

@ -44,26 +44,10 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: Compute timestamp tag + source epoch
- name: Compute timestamp tag (UTC)
id: ts
run: |
# Immutable hourly pin (:YYYY-MM-DDTHH).
echo "tag=$(date -u +%Y-%m-%dT%H)" >> "$GITHUB_OUTPUT"
# SOURCE_DATE_EPOCH = HEAD commit time. Fed to buildkit (see
# the build step), it clamps the image config's `created`
# field and every history timestamp to a value that is
# CONSTANT across the hourly cron builds of a given commit.
# So two builds with the same commit + same agent versions +
# same layers produce a byte-identical config → an identical
# linux/amd64 manifest digest. microsandbox keys its
# fsmeta/VMDK materialization on THAT digest, so an unchanged
# `:latest` becomes a true no-op pull (0 bytes) instead of
# re-downloading the whole image. A new commit, or any changed
# agent layer, moves the digest as it should. Without this the
# config carried a fresh build timestamp every hour, so every
# cron `:latest` had a new manifest digest and consumers
# re-pulled ~700 MiB of byte-identical layers hourly.
echo "epoch=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
@ -75,62 +59,8 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Resolve agent versions
id: ver
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
fail() { echo "::error::$1"; exit 1; }
# Resolve each agent's current upstream version and feed it to
# the Dockerfile as a per-agent cache key (AGENT_VERSION_*), so
# an installer layer is rebuilt only when its agent actually
# released — not on every hourly cron run. Each source is the
# EXACT one the matching installer reads, so the key tracks
# what actually gets installed:
# - codex: openai/codex releases/latest tag — install.sh's
# own resolver hits the same endpoint.
# - opencode: anomalyco/opencode releases/latest tag — the
# repo opencode.ai/install downloads from (it was
# renamed from sst/opencode; sst/* only resolves
# today via GitHub's rename redirect, so pin the
# real repo).
# - claude: downloads.claude.ai native channel `/latest`
# (a plain version string) — what claude.ai/
# install.sh installs. NOT npm: the npm dist-tags
# (@anthropic-ai/claude-code) move on a separate
# cadence (e.g. stable != latest), so keying on
# npm would miss native releases / rebuild for
# npm-only bumps.
# Any lookup failure fails the build (vs shipping a stale key
# that would skip a real agent update).
codex=$(gh api repos/openai/codex/releases/latest --jq .tag_name) \
|| fail "codex version lookup failed (openai/codex releases/latest)"
opencode=$(gh api repos/anomalyco/opencode/releases/latest --jq .tag_name) \
|| fail "opencode version lookup failed (anomalyco/opencode releases/latest)"
claude=$(curl -fsSL https://downloads.claude.ai/claude-code-releases/latest) \
|| fail "claude version lookup failed (downloads.claude.ai/claude-code-releases/latest)"
# Guard against an HTTP-200-but-garbage response (empty body,
# jq 'null', or an HTML error page) silently becoming a key.
[ -n "$codex" ] && [ "$codex" != null ] || fail "codex version empty/null"
[ -n "$opencode" ] && [ "$opencode" != null ] || fail "opencode version empty/null"
case "$claude" in [0-9]*.[0-9]*.[0-9]*) : ;; *) fail "claude version implausible: '$claude'" ;; esac
{
echo "codex=$codex"
echo "opencode=$opencode"
echo "claude=$claude"
} >> "$GITHUB_OUTPUT"
echo "resolved agent versions: codex=$codex opencode=$opencode claude=$claude"
- name: Build and push
id: build
env:
# Clamps buildkit's image-config `created` + history
# timestamps to the commit time (see "Compute timestamp tag +
# source epoch") so an unchanged build yields an identical
# manifest digest. docker/build-push-action forwards
# SOURCE_DATE_EPOCH from the environment to buildkit.
SOURCE_DATE_EPOCH: ${{ steps.ts.outputs.epoch }}
uses: docker/build-push-action@v7
with:
context: images
@ -148,51 +78,12 @@ jobs:
# past that for binary-heavy content.
# microsandbox's `tar_ingest.rs:427` already accepts the
# `application/vnd.oci.image.layer.v1.tar+zstd` media type.
#
# `rewrite-timestamp=true` clamps the mtime of every file
# INSIDE each layer tar to SOURCE_DATE_EPOCH (the commit time).
# SOURCE_DATE_EPOCH alone only fixes the image config; layer
# file mtimes default to build wall-clock, so a `cache-from`
# MISS (gha eviction / size cap / a base-layer invalidation)
# would re-run the apt/curl RUN steps and emit layers with
# fresh mtimes → new diff_ids → new manifest digest for an
# unchanged commit, re-introducing the ~700 MiB consumer
# re-pull. Clamping mtimes makes the layer digests — and thus
# the manifest digest — reproducible independent of cache
# state, so the digest only moves on a real content change.
outputs: type=registry,push=true,compression=zstd,compression-level=3,force-compression=true,rewrite-timestamp=true
# Per-agent version cache keys. `Resolve agent versions`
# above looks up each agent's current upstream version; the
# Dockerfile references these in the matching installer RUN,
# so a layer is rebuilt ONLY when that agent actually
# released — not on every hourly cron run. This keeps an
# unchanged hourly build a pure cache hit (identical
# agent-layer digests → `:latest` consumers re-pull nothing)
# instead of re-emitting ~200 MiB of fresh-but-identical
# agent layers every hour. The heavy apt/chromium/docker/
# node layers below the args stay cached regardless.
build-args: |
AGENT_VERSION_CODEX=${{ steps.ver.outputs.codex }}
AGENT_VERSION_OPENCODE=${{ steps.ver.outputs.opencode }}
AGENT_VERSION_CLAUDE=${{ steps.ver.outputs.claude }}
outputs: type=registry,push=true,compression=zstd,compression-level=3,force-compression=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
# No provenance/SBOM attestation. Either one makes buildx wrap
# even a single-platform build in an OCI index whose extra
# attestation manifest embeds the build time, so the index
# digest changes every build even when the image content is
# identical — which would keep `:latest`'s digest churning
# hourly (and nag the update banner) despite a stable
# linux/amd64 manifest. We don't consume either anywhere.
# Both are pinned off (provenance defaults to mode=min on push;
# sbom defaults off but is pinned so a future default flip
# can't silently re-introduce the index). The "Show pushed
# digest" step asserts the push stayed a single manifest.
provenance: false
sbom: false
# Moving tags (`:latest`, `:YYYY-MM-DDTHH`) are gated to
# the integration branches (`main`, `rewrite-microsandbox`)
# so workflow_dispatch on a feature branch can verify the
@ -209,40 +100,15 @@ jobs:
${{ env.IMAGE }}:sha-${{ github.sha }}
# Tag with the short SHA too so we can trace any image
# back to the exact Dockerfile commit.
# NOTE: no `org.opencontainers.image.created` label. It was
# set to the per-hour timestamp and lived in the image config,
# so it changed the manifest digest every cron build even with
# identical content (the root cause of the hourly re-pull). The
# config still carries a `created` field, but clamped to the
# commit time via SOURCE_DATE_EPOCH, so it's stable per commit.
# `revision` (the commit sha) is stable across a commit's
# hourly builds, so it doesn't reintroduce churn.
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 + assert single manifest
- name: Show pushed digest
run: |
echo "Pushed ${{ env.IMAGE }}:${{ steps.ts.outputs.tag }}"
echo " digest: ${{ steps.build.outputs.digest }}"
# Guard the digest-stability invariant: the push must be a
# single image manifest, NOT a multi-arch index. An index
# (from a re-enabled provenance/sbom attestation, or a buildx
# default flip) carries a build-time-varying attestation digest
# and would silently restore the hourly `:latest` churn this
# workflow exists to prevent. The immutable `:sha-…` tag is
# always pushed (on every branch), so inspect that.
ref="${IMAGE}:sha-${{ github.sha }}"
if mt=$(docker buildx imagetools inspect --raw "$ref" 2>/dev/null | jq -r '.mediaType'); then
echo " pushed mediaType: $mt"
case "$mt" in
*manifest.list*|*image.index*)
echo "::error::pushed an index ($mt) — an attestation leaked back in; :latest digest will churn hourly. Ensure provenance:false + sbom:false."
exit 1 ;;
esac
else
echo "::warning::could not inspect $ref to assert single-manifest; skipping the check"
fi
retain:
# Delete date-tagged image versions older than 14 days so the

View file

@ -1,60 +0,0 @@
name: CI
on:
push:
pull_request:
permissions:
contents: read
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Install stable Rust
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Cache cargo
uses: Swatinem/rust-cache@v2
- name: Install build dependencies
run: |
sudo apt-get update
sudo apt-get install -y pkg-config libcap-ng-dev libdbus-1-dev musl-tools
# The microsandbox SDK embeds the guest agentd binary. agent-vm builds it
# from the pinned source (we dropped the upstream prebuilt download), so
# the static musl agentd must exist at vendor/microsandbox/build/agentd
# before any `cargo build -p agent-vm`. Mirrors the release-npm workflow.
- name: Build guest agentd
run: |
rustup target add x86_64-unknown-linux-musl
cargo build --release \
--manifest-path vendor/microsandbox/crates/agentd/Cargo.toml \
--target-dir vendor/microsandbox/target \
--target x86_64-unknown-linux-musl
mkdir -p vendor/microsandbox/build
cp vendor/microsandbox/target/x86_64-unknown-linux-musl/release/agentd \
vendor/microsandbox/build/agentd
touch vendor/microsandbox/build/agentd
- name: Build
run: cargo build --release -p agent-vm
- name: Test
run: cargo test -p agent-vm
- name: Format check
continue-on-error: true
run: cargo fmt --all -- --check
- name: Clippy
continue-on-error: true
run: cargo clippy -p agent-vm --release -- -D warnings

View file

@ -62,22 +62,7 @@ jobs:
- platform: linux-x64
runner: ubuntu-latest
cargo_target: x86_64-unknown-linux-gnu
# linux-arm64 is cross-compiled on the x64 runner (GitHub's
# hosted arm64 Linux runners aren't on the free tier).
# `gcc-aarch64-linux-gnu` provides the cross linker (wired
# up by the repo .cargo/config.toml); the arm64 dev libs the
# final link needs come in via `dpkg --add-architecture
# arm64` (see the install step).
- platform: linux-arm64
runner: ubuntu-latest
cargo_target: aarch64-unknown-linux-gnu
cross: true
runs-on: ${{ matrix.runner }}
# The arm64 (cross) leg is not yet shippable — its libkrunfw kernel
# config seed hasn't been ported (see build-libkrunfw) and the arm64
# multiarch dev-lib install is still flaky. Let cross legs fail without
# blocking the x64 release; they re-enable automatically once green.
continue-on-error: ${{ matrix.cross || false }}
steps:
- uses: actions/checkout@v5
# No submodules — the agent-vm crate doesn't need
@ -90,28 +75,12 @@ jobs:
submodules: 'recursive'
- name: Install Rust toolchain + linux deps
env:
CROSS: ${{ matrix.cross && '1' || '' }}
run: |
set -euo pipefail
rustup toolchain install stable --profile minimal --no-self-update
rustup target add ${{ matrix.cargo_target }}
if [[ -n "$CROSS" ]]; then
# Enable the arm64 multiarch repo, then pull the cross
# linker + arm64 builds of the C libs agent-vm /
# microsandbox link (libcap-ng, libdbus, libsqlite3).
# Without the `:arm64` dev libs the final link can't
# resolve -lcap-ng / -ldbus-1 / -lsqlite3 for the target.
sudo dpkg --add-architecture arm64
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
gcc-aarch64-linux-gnu pkg-config \
libcap-ng-dev:arm64 libdbus-1-dev:arm64 libsqlite3-dev:arm64
else
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libcap-ng-dev libdbus-1-dev libsqlite3-dev pkg-config
fi
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libcap-ng-dev libdbus-1-dev libsqlite3-dev pkg-config
- name: Cargo cache (rust-cache)
uses: Swatinem/rust-cache@v2
@ -140,40 +109,8 @@ jobs:
cache-all-crates: "true"
cache-workspace-crates: "true"
- name: Build agentd from source (musl) so the SDK embeds the matching guest agent
run: |
# agent-vm depends on the microsandbox SDK with the `prebuilt`
# feature off, so the filesystem crate embeds build/agentd at
# compile time instead of downloading a release prebuilt. Build
# the static-musl guest agent from the pinned source first, or
# filesystem's build.rs panics ("agentd binary not found ... Run
# `just build-deps`"). Mirrors build-msb's identical step.
rustup target add x86_64-unknown-linux-musl
sudo apt-get install -y --no-install-recommends musl-tools
cargo build --release \
--manifest-path vendor/microsandbox/crates/agentd/Cargo.toml \
--target-dir vendor/microsandbox/target \
--target x86_64-unknown-linux-musl
mkdir -p vendor/microsandbox/build
cp vendor/microsandbox/target/x86_64-unknown-linux-musl/release/agentd \
vendor/microsandbox/build/agentd
touch vendor/microsandbox/build/agentd
- name: cargo build --release -p agent-vm
env:
# Cross pkg-config: point at the arm64 .pc files and let
# pkg-config run host→target (the `pkg-config` crate refuses
# cross queries unless PKG_CONFIG_ALLOW_CROSS=1). No-ops on
# the native x64 leg (CROSS unset).
CROSS: ${{ matrix.cross && '1' || '' }}
run: |
set -euo pipefail
if [[ -n "$CROSS" ]]; then
export PKG_CONFIG_ALLOW_CROSS=1
export PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig
export PKG_CONFIG_SYSROOT_DIR=/
fi
cargo build --release --target ${{ matrix.cargo_target }} -p agent-vm
run: cargo build --release --target ${{ matrix.cargo_target }} -p agent-vm
- name: Upload agent-vm binary
uses: actions/upload-artifact@v7
@ -192,16 +129,7 @@ jobs:
- platform: linux-x64
runner: ubuntu-latest
cargo_target: x86_64-unknown-linux-gnu
# linux-arm64 cross-build — see build-agent-vm for the
# rationale (no free hosted arm64 Linux runner).
- platform: linux-arm64
runner: ubuntu-latest
cargo_target: aarch64-unknown-linux-gnu
cross: true
runs-on: ${{ matrix.runner }}
# Cross (arm64) leg is best-effort until arm64 is shippable — see
# build-agent-vm. Fail-tolerated so it never blocks the x64 release.
continue-on-error: ${{ matrix.cross || false }}
env:
# Override vendor/microsandbox's `lto=true, codegen-units=1`
# release profile for CI. Keeps `panic=abort` (changes
@ -210,34 +138,18 @@ jobs:
# runtime cost on a TLS-proxy workload.
CARGO_PROFILE_RELEASE_LTO: "thin"
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16"
# The superproject's .cargo/config.toml sets the aarch64 linker,
# but it is NOT on the config search path when building from
# vendor/microsandbox (a separate workspace), so set the linker
# via env here too. No-op on the native x64 leg.
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
steps:
- uses: actions/checkout@v5
with:
submodules: 'recursive'
- name: Install Rust toolchain + linux deps
env:
CROSS: ${{ matrix.cross && '1' || '' }}
run: |
set -euo pipefail
rustup toolchain install stable --profile minimal --no-self-update
rustup target add ${{ matrix.cargo_target }}
if [[ -n "$CROSS" ]]; then
sudo dpkg --add-architecture arm64
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
gcc-aarch64-linux-gnu pkg-config \
libcap-ng-dev:arm64 libdbus-1-dev:arm64 libsqlite3-dev:arm64
else
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libcap-ng-dev libdbus-1-dev libsqlite3-dev pkg-config
fi
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libcap-ng-dev libdbus-1-dev libsqlite3-dev pkg-config
- name: Cargo cache (rust-cache)
uses: Swatinem/rust-cache@v2
@ -258,45 +170,10 @@ jobs:
cache-all-crates: "true"
cache-workspace-crates: "true"
- name: Build agentd from source (musl) so msb embeds the matching guest agent
run: |
# agentd (the guest PID-1 agent) is embedded into msb and injected
# at boot. Build it from the pinned source rather than downloading a
# release prebuilt, so the producer (msb) and consumer (agentd)
# always ship in lockstep — e.g. the boot-params side channel needs
# both halves at the same revision; a stale prebuilt agentd would
# silently drop every mount. Static musl: agentd runs before the
# rootfs is fully set up.
rustup target add x86_64-unknown-linux-musl
sudo apt-get install -y --no-install-recommends musl-tools
cargo build --release \
--manifest-path vendor/microsandbox/crates/agentd/Cargo.toml \
--target-dir vendor/microsandbox/target \
--target x86_64-unknown-linux-musl
mkdir -p vendor/microsandbox/build
cp vendor/microsandbox/target/x86_64-unknown-linux-musl/release/agentd \
vendor/microsandbox/build/agentd
touch vendor/microsandbox/build/agentd
- name: cargo build --release -p microsandbox-cli --bin msb
env:
CROSS: ${{ matrix.cross && '1' || '' }}
run: |
set -euo pipefail
# Cross-compiling msb for aarch64 needs pkg-config pointed at the
# arm64 sysroot so the C deps (libcap-ng, dbus) resolve.
if [[ -n "$CROSS" ]]; then
export PKG_CONFIG_ALLOW_CROSS=1
export PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig
export PKG_CONFIG_SYSROOT_DIR=/
fi
# --no-default-features drops the `prebuilt` feature, so the
# filesystem crate embeds the build/agentd compiled just above
# instead of downloading a release prebuilt. net + keyring are the
# other CLI defaults and stay on.
cargo build --release --target ${{ matrix.cargo_target }} \
--manifest-path vendor/microsandbox/Cargo.toml \
--no-default-features --features net,keyring \
-p microsandbox-cli --bin msb
- name: Upload msb binary
@ -322,19 +199,7 @@ jobs:
- platform: linux-x64
runner: ubuntu-latest
arch: x86_64
# linux-arm64: cross-build the kernel with ARCH=arm64 +
# CROSS_COMPILE=aarch64-linux-gnu-. Needs the arm64 kbuild
# .config seed patch (libkrunfw-overrides/
# config-libkrunfw_aarch64.patch); the build step fails fast
# with a clear message if that seed hasn't been ported yet.
- platform: linux-arm64
runner: ubuntu-latest
arch: aarch64
cross: true
runs-on: ${{ matrix.runner }}
# Cross (arm64) leg fails fast until config-libkrunfw_aarch64.patch is
# ported (see the build step). Fail-tolerated so it never blocks x64.
continue-on-error: ${{ matrix.cross || false }}
steps:
- uses: actions/checkout@v5
# Need the patch file + LIBKRUNFW_VERSION constant. Submodules
@ -366,20 +231,12 @@ jobs:
- name: Install kernel build deps
if: steps.lk-cache.outputs.cache-hit != 'true'
env:
CROSS: ${{ matrix.cross && '1' || '' }}
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
build-essential bc flex bison libelf-dev libssl-dev \
kmod cpio bzip2 xz-utils python3 python3-pyelftools \
curl ca-certificates patch
if [[ -n "$CROSS" ]]; then
# Cross toolchain for the arm64 kernel build.
sudo apt-get install -y --no-install-recommends \
gcc-aarch64-linux-gnu
fi
- name: Clone + patch + build libkrunfw
if: steps.lk-cache.outputs.cache-hit != 'true'
@ -387,24 +244,11 @@ jobs:
env:
LK_V: ${{ steps.lkv.outputs.libkrunfw_version }}
ARCH: ${{ matrix.arch }}
CROSS: ${{ matrix.cross && '1' || '' }}
run: |
set -euo pipefail
# The arm64 leg cross-compiles the kernel. It needs an arm64
# kbuild .config seed; bail early with a clear message rather
# than silently emit a kernel built with the wrong config.
if [[ -n "$CROSS" && ! -f "libkrunfw-overrides/config-libkrunfw_${ARCH}.patch" ]]; then
echo "::error::libkrunfw-overrides/config-libkrunfw_${ARCH}.patch is missing — the arm64 libkrunfw kernel config seed has not been ported yet (see B2 notes)."
exit 1
fi
git clone --branch "v${LK_V}" --depth 1 \
https://github.com/containers/libkrunfw.git libkrunfw-src
cd libkrunfw-src
# Cross build env for arm64: make picks up ARCH/CROSS_COMPILE.
if [[ -n "$CROSS" ]]; then
export ARCH=arm64
export CROSS_COMPILE=aarch64-linux-gnu-
fi
# 1) kbuild .config seed (CONFIG_KVM=y + Intel/AMD backends).
patch -p1 < ../libkrunfw-overrides/config-libkrunfw_${ARCH}.patch
# 2) Kernel source patches: libkrunfw's Makefile runs
@ -459,15 +303,7 @@ jobs:
- platform: linux-x64
runner: ubuntu-latest
libkrunfw_arch: x86_64
- platform: linux-arm64
runner: ubuntu-latest
libkrunfw_arch: aarch64
cross: true
runs-on: ${{ matrix.runner }}
# arm64 has no build artifacts until that leg is shippable; its
# download steps fail. Fail-tolerated so it never blocks the x64
# subpackage (see build-agent-vm).
continue-on-error: ${{ matrix.cross || false }}
steps:
- uses: actions/checkout@v5
# We need vendor/microsandbox source to read LIBKRUNFW_VERSION
@ -560,26 +396,10 @@ jobs:
steps:
- uses: actions/checkout@v5
# No `registry-url:` on purpose — that input makes setup-node write
# `//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}` into .npmrc.
# With trusted publishing we have no token, so that line resolves to
# an empty authToken and npm errors `ENEEDAUTH` instead of falling
# back to OIDC. Without it, npm uses the default registry
# (registry.npmjs.org) and the OIDC trusted-publishing flow.
- uses: actions/setup-node@v5
with:
node-version: '22'
# npm trusted publishing (OIDC) needs npm >= 11.5.1; node 22 ships
# npm 10.x. With this + `id-token: write` (set at the workflow level)
# and a trusted publisher configured on npmjs.com for each package,
# `npm publish` exchanges the GitHub OIDC token for short-lived
# registry credentials — no long-lived NPM_TOKEN secret — and attaches
# provenance automatically.
- name: Upgrade npm for OIDC trusted publishing (>= 11.5.1)
run: |
npm install -g npm@latest
npm --version
registry-url: 'https://registry.npmjs.org'
# Download each platform artifact into its own subpackage
# directory explicitly. `actions/download-artifact@v8` with
@ -599,16 +419,6 @@ jobs:
name: agent-vm-linux-x64
path: npm-dist/agent-vm-linux-x64/
- name: Download linux-arm64 artifact
# arm64 may not have been built this release (cross legs are
# best-effort until shippable). Tolerate a missing artifact; the
# publish loop below skips any platform without a built binary.
continue-on-error: true
uses: actions/download-artifact@v8
with:
name: agent-vm-linux-arm64
path: npm-dist/agent-vm-linux-arm64/
- name: Verify artifact layout + restore executable bits
# `actions/upload-artifact@v7` packs files into a zip that
# doesn't preserve POSIX permissions. After download, the
@ -620,14 +430,7 @@ jobs:
run: |
set -e
for d in npm-dist/agent-vm-*-*/bin; do
# Skip platforms not built this release (e.g. arm64 while its
# libkrunfw config is unported). The bin/ dir exists from the
# checkout (a .gitkeep placeholder) but carries no binary; the
# publish loop below skips these dirs too.
if [[ ! -f "$d/agent-vm" ]]; then
echo "::notice::$d has no built binary; skipping (platform not built this release)"
continue
fi
test -f "$d/agent-vm" || { echo "missing $d/agent-vm"; exit 1; }
test -f "$d/msb" || { echo "missing $d/msb"; exit 1; }
chmod +x "$d/agent-vm" "$d/msb"
done
@ -654,9 +457,8 @@ jobs:
done
- name: Publish platform subpackages first
# No NODE_AUTH_TOKEN — auth is via OIDC trusted publishing (see the
# setup-node note above and the workflow-level `id-token: write`).
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
V: ${{ needs.resolve-version.outputs.version }}
run: |
set -e
@ -669,15 +471,6 @@ jobs:
# — avoiding a split-state where subpackages exist at v but
# the main package never makes it.
for d in npm-dist/agent-vm-*-*; do
# Skip platforms not built this release (e.g. arm64 while its
# libkrunfw config is unported). The subpackage dir exists from
# the checkout but carries no binary; publishing it would ship a
# broken, binary-less package. The main package lists it as a
# cpu-gated optionalDependency, so its absence is harmless.
if [[ ! -f "$d/bin/agent-vm" ]]; then
echo "::notice::$d has no built binary; skipping (platform not built this release)"
continue
fi
# Node's `require` only resolves bare-relative paths
# with a leading `./` — without it the loader treats the
# argument as a node_modules / built-in spec and bails.
@ -691,8 +484,8 @@ jobs:
done
- name: Publish main package
# OIDC trusted publishing — no NODE_AUTH_TOKEN (see above).
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
V: ${{ needs.resolve-version.outputs.version }}
run: |
set -e

5
.gitignore vendored
View file

@ -6,8 +6,3 @@ Cargo.lock.bak
# Project-local agent hook (carries over from the original agent-vm layout).
.claude-vm.runtime.sh
.agent-vm.runtime.sh
# Per-user Claude Code permissions / settings (may carry secrets;
# never commit). Use .claude/settings.json for shared, reviewed
# settings instead.
.claude/settings.local.json

View file

@ -1,64 +0,0 @@
# AGENTS.md — conventions for coding agents working on this repo
Things that aren't obvious from the code and that I keep forgetting to
tell you. Read once, then act on them silently.
## After merging a feature branch, bump the workspace version
Every merge into `rewrite-microsandbox` ships with a
`workspace.package.version` bump in the root `Cargo.toml` and a
follow-up `vX.Y.Z: bump for <feature>` commit. Skipping this leaves
the next release boundary ambiguous and means downstream
`agent-vm --version` lies about what's in the binary.
Convention (look at `git log --oneline | grep "^[a-f0-9]* v"`):
```
git merge --no-ff <feature-branch> # produces "Merge ...: ..."
$EDITOR Cargo.toml # version = "0.1.N+1"
git commit -am "v0.1.N+1: bump for <one-line feature>"
```
`Cargo.lock` will need refreshing — run a build after the bump to
update it, then commit the lock alongside the version bump if it
moved (it always does).
## Submodule merges go first
`vendor/microsandbox` is a submodule with its own branches. When a
worktree changes both the agent-vm code and the vendored microsandbox
code, merge inside the submodule **before** merging the superproject —
otherwise the superproject merge will conflict on the gitlink and
you'll have to redo the submodule merge anyway. Pattern:
1. `cd vendor/microsandbox && git merge --no-ff <subm-feature-branch>`
2. `cd ../.. && git add vendor/microsandbox` (bumps the gitlink)
3. `git merge --no-ff <agent-vm-feature-branch>`
(resolves the gitlink conflict to the merge SHA from step 1)
If the feature branch lives in a separate git worktree, the
submodule branches in that worktree's `.git/modules/...` are not
visible from the main worktree. Push them across with
`git -C <worktree>/vendor/microsandbox push <main-worktree>/.git/modules/vendor/microsandbox <branch>:<branch>`
before attempting the submodule merge.
## Don't relocate build output to `/tmp` or `/dev/shm`
If a build is too big, slow, or runs out of inodes, fix the root
cause. Don't sidestep by pointing `CARGO_TARGET_DIR` at tmpfs — that
loses everything on reboot, masks real disk pressure, and the next
agent will spend an hour relinking from cold.
## Don't `rm -rf` state directories from the assistant turn
Claude Code prompts on every `rm -rf` and it's painful. The repo
ships `/tmp/clean-state.sh` for state cleanup — use it, or write a
new short script if it doesn't cover your case. Inline `rm -rf` in
tool calls is a UX papercut for the human, not a safety win.
## When in doubt about scope, read the prior commit messages
Commits on this branch use a multi-paragraph "Why / How" style with
real examples (often live e2e output). Match the style; don't write
single-line commits for non-trivial changes. The commit body is
where future-you (or future-me) recovers the reasoning.

633
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,7 @@ members = ["crates/agent-vm"]
exclude = ["vendor/microsandbox"]
[workspace.package]
version = "0.1.25"
version = "0.1.15"
edition = "2024"
license = "MIT"
repository = "https://github.com/wirenboard/agent-vm"

View file

@ -1,135 +0,0 @@
# agent-vm launch profiling — findings
Host: AMD EPYC, 16 vCPU, nested virt (`/dev/kvm`, `kvm_amd.nested=1`). Image cached.
All headline numbers are **wall-clock**, from interleaved A/Bs (host drift cancels),
`drop_caches` between rounds where noted. Measured with `AGENT_VM_PROFILE=1` plus
sub-timers added to `run.rs` (pre-boot phases + build/spawn+boot+relay) and the
runtime's own `runtime.log` wall timestamps.
## TL;DR
A launch in a GitHub-remote repo broke down (default 2 vCPU / 2 GiB) as:
```
pre: session+reap ............... 0.3 ms
pre: update-check (ghcr HEAD) ... 886 ms ← notify_if_update_available (banner only)
pre: repo-detect ................ 2 ms
pre: secrets (gh auth token) .... 40 ms
pre: gh api user ................ 1290 ms ← v0.1.14 author identity ← THE regression
pre: TOTAL pre-boot ............. 2220 ms ← LARGER than the actual VM boot
create (guest kernel boot) ..... 1500 ms
run (incl. chrome certutil) .... 270 ms
stop / remove .................. 50 ms
TOTAL ~4.1 s
```
**~2.2s of the launch happens before the kernel even starts, and it's two uncached
blocking GitHub/registry network round-trips.** The original `[profile] create` timer
started *after* all of this, which is why earlier profiling missed it entirely.
## The regression: `gh api user` (pre-boot), not the kernel
`discover_host_git_identity()` (added in commit `0c3bb51`, **v0.1.14** — "bake host
gh/git identity into the guest gitconfig", the exact regression window) calls
**`gh api user` first**, an HTTPS round-trip to api.github.com, falling back to the
instant local gitconfig only if it fails. No cache. Measured ~1.261.31s here, every
launch. Adding ~1.29s to a ~1.3s baseline ≈ the reported 2.5s.
The ghcr.io **update-check** (`notify_if_update_available`, commit `bfab9d3`) is a second
per-launch blocking HEAD (~0.89s) purely to print a "newer image available" banner.
### Fixes (implemented on this branch, measured)
1. **Cache the resolved git identity** (`secrets.rs`, 24h TTL, validated strings only,
never tokens; preserves the canonical `gh` identity incl. `gh_login`). Pays the
`gh api user` cost once per day instead of every launch.
2. **Make the update-check non-blocking** (`run.rs`): spawn it concurrently with boot
instead of awaiting it. Banner still prints (during boot); never delays launch.
| | pre-boot | total wall |
|---|---|---|
| before | ~2220 ms | ~4100 ms |
| after, run 1 (cold identity cache) | ~1300 ms | ~3280 ms |
| **after, warm cache** | **~32 ms** | **~1900 ms** |
`gh api user` 1290ms → 22µs; update-check 886ms → ~0. **~2.19s off every launch after
the first.** No downside, no kernel rebuild. This alone undoes the regression.
## Secondary: the ~1.5s guest-kernel boot floor
`create` ≈ guest kernel boot (`build()` is ~3µs; `entering VM → agentd core.ready`).
The console (`hvc0`) attaches ~1.3s in, so early boot isn't visible in `kernel.log`;
the A/B below is the real attribution. Five+1 kernels built from one tree
(configs grepped, not assumed), 10 interleaved rounds @1 GiB, `drop_caches` per round:
| kernel | config | create mean ± sd | Δ |
|---|---|---|---|
| stock | upstream libkrunfw (no KVM, no netfilter) | 1.488 ± 0.10 s | — |
| stock+KVM | + CONFIG_KVM/KVM_INTEL/KVM_AMD | 1.587 ± 0.15 s | **+99 ms (KVM)** |
| heavy_nonf | KVM, netfilter off (+mqueue) | 1.615 ± 0.12 s | ≈ stock+KVM ✓ |
| heavy_legacy | + conntrack/NAT/iptables-legacy/bridge | 1.677 ± 0.03 s | **+62 ms (conntrack)** |
| heavy (current) | + full nf_tables/XT/IPv6/VLAN | 1.680 ± 0.03 s | +3 ms (nf_tables ≈ 0) |
| heavy+deferred | heavy + DEFERRED_STRUCT_PAGE_INIT | 1.632 ± 0.03 s | no help |
**The nested-virt kernel rebuild adds only ~190ms total to boot** (KVM ~100ms,
conntrack/iptables ~62ms, nf_tables ~3ms). So it's a real but *minor* secondary cost:
- **KVM (~100ms)** is *required* for nested virt — irremovable.
- **conntrack/iptables-legacy (~62ms)** is the unavoidable cost of docker bridge+SNAT
(the conntrack hashtable auto-sizes from RAM). With `CONFIG_MODULES is not set` +
`nomodule`, it can't be made a module — it's built-in or absent. Drop it only if you
don't need docker networking by default.
- **nf_tables/XT/IPv6/VLAN (~3ms)** — droppable, but saves essentially nothing. Not
worth the docker-iptables-nft→legacy fallback risk.
### Two levers that do NOT work (verified, don't pursue)
- **`CONFIG_DEFERRED_STRUCT_PAGE_INIT=y`**: no help at 1 GiB, slightly *worse* at 4 GiB.
Its `defer_init()` heuristic only defers past a 128 MB section threshold after low
zones init; a 14 GiB single-node guest has nothing to defer (it targets TB-scale RAM).
- **`split_irqchip`**: ~515ms swing in the runtime's `boot_time_ms` metric but **zero
wall-clock effect** (1.91s vs 1.88s create). `boot_time_ms` excludes ~0.9s of early
boot and is a misleading proxy — rank kernels on wall-clock `create` only. vCPU count:
also negligible (1/2/4 ≈ 1.84/1.84/1.89s).
## Other real levers
- **Guest memory** (real wall-clock, but EPT/page-materialization under nested virt, not
struct-page init): create ≈ 1.49s @1G / 1.681.92s @2G / 2.9s @4G. Lower the default
(`AGENT_VM_MEMORY_GIB`, currently 2) for sessions that don't need 2 GiB (~0.2s+).
- **Chrome-MCP CA `certutil` (~270ms)** in the `run` phase — **fixed on this branch.**
`run.rs` used to run `sudo -u chrome certutil -A …` synchronously in the in-guest
prelude before exec'ing the agent, on *every* launch. But chromium ignores the system
CA bundle (which `update-ca-certificates` already populates at boot) and only honors
its per-user NSS DB, so the CA must be imported there or chrome-devtools-mcp fails
every HTTPS page with `ERR_CERT_AUTHORITY_INVALID`. The cert can't be baked into the
shared image (the CA is generated per-install on the host); the import repeated every
launch only because the NSS DB lives in the ephemeral rootfs. **Fix:** moved the import
into the in-image `agent-vm-chrome-mcp` wrapper (`images/Dockerfile`) — it runs once at
MCP startup, as the `chrome` user that owns the DB, off the launch critical path and
skipped entirely when chrome is unused. Measured: `run` phase 310ms → ~38ms.
**Coupling:** the `run.rs` and Dockerfile changes must ship together — until the
template image is rebuilt with the new wrapper, dropping the prelude import would leave
chrome MCP without the CA.
## Recommended order (by impact × safety)
1. **Cache the git identity** + **non-blocking update-check** — ~2.19s, implemented here,
zero downside, no rebuild. This *is* the regression fix.
2. **Chrome-MCP certutil moved into the image wrapper** — ~270ms off the `run` phase,
implemented on this branch (run.rs + images/Dockerfile; ship together).
3. **Lower default guest memory** if 2 GiB is more than agents need — ~0.2s+.
4. Kernel: leave it. The ~190ms it adds is mostly required (KVM, conntrack). Do **not**
enable deferred-page-init or chase split_irqchip. Optionally drop nf_tables/IPv6 NF to
shave ~3ms only if you also accept iptables-legacy.
## Reproduce
```bash
cd <github-remote repo>
AGENT_VM_PROFILE=1 agent-vm shell true # prints pre-boot phases + create/run/stop
# pre-boot network cost, in isolation:
time gh api user >/dev/null ; time curl -sI https://ghcr.io/v2/wirenboard/agent-vm-template/manifests/latest
# kernel A/B: build variants from libkrunfw-src (one tree, grep each .config), swap the
# .so next to msb, measure interleaved with drop_caches — see measure_variants.sh.
```

814
PLAN.md
View file

@ -1,155 +1,711 @@
# agent-vm — PLAN
# agent-vm rewrite — PLAN
Roadmap for the Rust + [microsandbox](https://github.com/wirenboard/microsandbox)
rewrite of `agent-vm`. The old phase-by-phase roadmap (Phases 09) has been
retired now that the rewrite is feature-usable; per-phase history lives in
`git log` and the design rationale in `ARCHITECTURE.md`. This file tracks only
**what is left to do** to fully match — and then beat — the original Bash
`agent-vm` that still lives on `main`.
Living roadmap for rewriting `agent-vm` on top of
[microsandbox](https://github.com/wirenboard/microsandbox). The plan is locked
*now* but is updated as phases land. Each phase ends at a stop point so we can
inspect, adjust, then proceed.
## Where the rewrite stands today
The architecture details and design rationale live in `ARCHITECTURE.md` and are
written after each phase, not up front.
Working and verified for daily use:
## Why a rewrite
- **Agents:** `claude`, `codex`, `opencode`, `shell` (per-project microVM,
~1.5 s launch, project bind-mounted at its host path).
- **Host-rooted secrets:** real Claude/Codex/OpenCode tokens never enter the
VM; the microsandbox TLS-intercept proxy substitutes a placeholder for the
real bearer on the way out. Tokens live host-side outside the guest mount.
- **OAuth refresh MITM** for Claude + Codex (file-backed `SecretValue::File`
+ `_intercept-hook`), so an externally-rotated host token is picked up on the
next request without a relaunch.
- **gh / git** auth reused from the host with a **per-launch GitHub repo
allow-list** enforced at the proxy (off-list push → clean 403).
- **Security snapshot** of the three credential files (SHA-256 at launch,
re-checked on exit).
- **DX:** `--mount`, `clipboard get/put`, `agent-vm-ccusage`, Chrome DevTools
MCP (chromium as a dedicated user with the MITM CA trusted in its NSS DB).
- **Image + distribution:** `setup` (Docker build + boot-verify), `pull` +
per-launch update banner, image-API-version lock, bundled patched `msb` +
libkrunfw, auto-install of the runtime.
- **Network egress** flags `--publish` / `--auto-publish` / `--allow-egress` /
`--allow-lan` / `--allow-host` — this **already exceeds** the original, which
had no per-launch egress controls.
The existing `agent-vm` (Bash, 2.4kloc + Python helpers, Lima full VMs) is
mature but heavy: 30-second cold start, 16 GB disk template, host-side `mitm`
chain, balloon daemon, custom GitHub App. microsandbox boots microVMs in
~100 ms from OCI images, has a first-class Rust SDK, and ships TLS interception
+ placeholder-substituted secrets at the network layer. Most of `agent-vm`'s
infrastructure becomes either unnecessary or moves into a small Rust binary.
Both the original and the rewrite are **fresh-VM-per-launch**; the rewrite is
*not* missing any persistent-VM lifecycle the original had (see C1 — that's a
new capability, not a regression).
## v1 scope (in)
## A. In-scope work to finish
- Subcommands: `setup`, `claude`, `codex`, `opencode`, `shell`.
- Project working directory mounted into the sandbox at the project's host
path (with `/workspace` fallback for tmpfs-rooted paths).
- Per-project session persistence for `~/.claude/`, `~/.codex/`,
`~/.local/share/opencode/` under `${XDG_STATE_HOME}/agent-vm/<project-hash>/`.
- Host-rooted credentials with refresh: real tokens never enter the VM; host
`claude -p` / `codex exec` are used to rotate; the VM picks up the new token
on the next request without restarting the sandbox. Covers Claude, Codex,
and OpenCode (which auths against OpenAI like Codex does).
- Pre-baked Debian-based OCI image with the three agent CLIs and dev tools.
- Interactive attach for the agent TUIs.
- Host `gh` / `git` auth reused inside the guest with **per-launch repo
allow-list**: agents can `git push` and `gh pr create` only to repos
derived from the cwd's remote(s) plus `--repo owner/name` overrides; the
request-interceptor hook returns synthesized 403s for any other repo.
- Security snapshot of host credential files: detect unexpected mutations
that aren't from the Phase 4 refresh hook.
- `--mount HOST:GUEST` for additional host directories.
- Clipboard bridge between host and guest.
- `agent-vm-ccusage` wrapper that unions per-project Claude session dirs.
- Chrome DevTools MCP — Chromium in the image, MCP config injected into the
agents' settings (so the in-VM agent can drive a real headless browser).
These are within the agreed v1 scope and either unverified or incomplete.
(A5 onboarding config and A6 `.agent-vm.runtime.sh` hook were on this list but
are **already implemented**`secrets.rs` force-sets `hasCompletedOnboarding`
/ `hasCompletedProjectOnboarding` / per-folder trust, and `run.rs:891-962`
sources the project hook before exec — so they're dropped, not pending.)
## v1 scope (out, may revisit)
- **A1 — Real mid-session token rotation (untested).** The substitution +
refresh-hook infrastructure exists but no run has crossed a real
token-expiry boundary end-to-end (Claude ~hours, Codex/ChatGPT ~24 h). Drive
a long session through at least one rotation without a re-attach. *(was the
last open item of the old Phase 4.)* Effort: M (mostly a long live session).
- **A2 — Refresh single-flight.** Confirmed absent — `intercept_hook.rs` has no
flock, so two racing in-guest refreshes each spawn a host-side `claude -p` /
`codex exec`. Add a `<state>.secrets/.refresh.lock` flock. *(old Phase 4
"Design" item, never implemented.)* Effort: S.
- **A3 — Project-integrity security snapshot.** Confirmed gap: the rewrite's
`snapshot_host_creds` / `verify_snapshot` (`secrets.rs:529,542`) fingerprint
**only the three credential files**. The original's
`_claude_vm_security_snapshot` / `_check` (claude-vm.sh:1560) also
fingerprints the **project repo**`.git/config`, `.git/hooks/*`,
`CLAUDE.md`, `Makefile`, the runtime hook — to catch an off-rails agent
tampering with git hooks or build files. Extend the snapshot to cover those
and warn on unexpected change. Effort: M.
- **A4 — Push-access probe.** Confirmed gap: no `git push --dry-run` anywhere
in the rewrite; the allow-list is built from static `git remote -v` parsing.
The original probes with `git push --dry-run`
(`_claude_vm_check_push_access`:1413) to confirm real push rights before
trusting a remote. Decide whether to add the live probe (it costs a network
round-trip per launch). Effort: S.
- GitHub App device flow + per-repo scoped tokens (we reuse the user's
existing `gh` auth instead — see "v1 scope (in)" above).
- GitHub Copilot CLI subcommand and Copilot token acquisition.
- USB passthrough.
- Dynamic memory / virtio-balloon daemon.
- `AI_HTTPS_PROXY` upstream proxy chaining.
- Apple Silicon / macOS-VZ specifics.
- WSL2-on-Windows specifics.
- Setup-time `--minimal` / `--disk` flags (image is built once, fixed shape).
## B. Distribution / release (old Phase 9 leftovers)
## Phased roadmap
- **B1 — CI smoke workflow.** GitHub Actions: build the image, run
`agent-vm setup --no-verify`, then `agent-vm shell -- -c 'echo ok'`. Green
on at least linux-amd64.
- **B2 — Cross-arch binaries.** macOS / aarch64 builds + per-platform npm
packaging (the package currently bundles a linux-x86_64 binary).
- **B3 — IPv6 DNS workaround → upstream fix.** Replace the per-launch
`sed`-out-the-v6-nameserver hack with either a real fix to the v6 gateway
DNS path in microsandbox or a `network.dns(disable_ipv6)` knob (upstream
issue #5).
Each row is one PR; we stop after each phase, fill in `ARCHITECTURE.md`, then
the user signs off on the next. Per-phase status updates land in this file as
each phase ships.
## C. Improvements beyond `main` (optional, product call)
### Phase 0 — Scaffolding [done — commits `cb4be40`, `4462180`]
- **C1 — Detached / persistent-VM fast launch.** Neither original nor rewrite
has this. Boot once per project, attach per invocation: ~1.5 s → ~1050 ms.
Pulls in lifecycle subcommands (`ps` / `stop` / `restart`), attach-if-exists
reuse, idle-timeout cleanup, and an in-VM-state-persistence policy call.
*(was the deferred old Phase 8.)* Effort: L.
- Worktree on `rewrite-microsandbox`.
- microsandbox added as a git submodule at `vendor/microsandbox` (tracking
`wirenboard/microsandbox @ main`; we'll branch off here in Phase 3).
- Cargo workspace at the worktree root; `crates/agent-vm/` binary crate.
- Hello-world `main.rs`: `Sandbox::builder("hello").image("alpine").create()`,
run `echo`, stop.
- `cargo check -p agent-vm` succeeds.
## D. Original-only features — decisions made
**Done when:** scaffold compiles, PLAN and ARCHITECTURE files exist, submodule
is registered. Verified end-to-end on KVM: 2.7 s round-trip for boot/exec/
teardown with the alpine image cached.
Decided 2026-05-30 with the user, per-feature.
### Phase 1 — OCI image [done — commit `d23c421`]
### Will port back (now roadmap items)
- `images/Dockerfile`: Debian 13 slim + `ca-certificates curl wget git jq bash
python3 python3-pip ripgrep fd-find nodejs(22)` + the three agent CLIs
(`claude.ai/install.sh`, `opencode.ai/install`, `openai/codex install.sh`).
Deliberately minimal: no Docker, Chromium, LSP plugins, mitmproxy, gh,
Copilot — those stay deferred per the v1-scope-out list.
- `images/build.sh` ensures a host-local `registry:2` container
(`agent-vm-registry` on `127.0.0.1:5000`) is running, then builds and pushes
`localhost:5000/agent-vm:latest`. (The original plan said "no registry push";
changed to registry push because microsandbox's image-cache and snapshot
semantics are keyed off OCI references — see ARCHITECTURE.md "Image
distribution" for the rationale.)
- `agent-vm setup` is a clap subcommand that shells out to `images/build.sh`,
then verifies the freshly pushed image by booting it under microsandbox and
running `claude --version && opencode --version && codex --version`.
`--no-verify` and `--image`/`AGENT_VM_IMAGE_TAG` escape hatches included.
- **D1 — `copilot` agent + Copilot token.** Add an `agent-vm copilot`
subcommand, route Copilot token acquisition through the same host-rooted /
proxy-substituted secret flow as the other agents, and install the Copilot
CLI in the image. Original: `copilot_token.py`, `_copilot_vm_write_token`
(claude-vm.sh:1269), `_copilot_vm_setup_home` (:1345),
`_claude_vm_get_copilot_token` (:1537). Effort: M.
- **D2 — LSP plugins in the image.** Install the four language servers the
original's `setup` adds — `clangd-lsp`, `pyright-lsp`, `typescript-lsp`,
`gopls-lsp@claude-plugins-official` (claude-vm.sh:353-361) — at image-build
time so in-VM Claude has code intelligence for C/C++, Python, TS, Go. Just
Dockerfile + pre-warm. Effort: S.
**Done when:** `agent-vm setup` builds the image and the verify sandbox
reports the three agent versions. Result: Claude 2.1.143, OpenCode 1.15.3,
codex-cli 0.130.0.
### Won't do (confirmed non-goals)
### Phase 2 — Launcher MVP [done — wiring complete; live API smoke deferred to Phase 3]
- **GitHub App per-repo token minting** (`github_app_token_demo.py`,
`_claude_vm_get_github_token`). The proxy allow-list already constrains
pushes to cwd-derived repos; per-repo minting would add a GitHub App + device
flow for marginal extra scoping. Keep `gh auth token` + allow-list.
- **USB passthrough** (`--usb`, `_agent_vm_usb_*` + qemu wrapper). libkrun is a
minimal VMM with no qemu-style device-passthrough path. Hard architectural
non-goal.
- **Dynamic memory / balloon** (`balloon-daemon.py`, `memory` subcommand,
`--max-memory`). Short-lived 2 GB microVMs torn down per launch don't squat
host RAM the way persistent 16 GB Lima VMs did, so ballooning is moot.
- clap-based subcommand parser: `setup | claude | codex | opencode | shell`.
- Project hash + state dir helper (`${XDG_STATE_HOME:-~/.local/state}/agent-vm
/<hash>/`).
- Mount `cwd` at `/workspace` inside the sandbox.
- Persist `~/.claude` and `~/.local/share/opencode` via rootfs-patched
symlinks into a single `/agent-vm-state` bind mount; redirect `~/.codex`
via `CODEX_HOME` (its binary lives under that path, so a symlink would
shadow it). One bind for project, one for state — total two virtio mounts
on top of the OCI rootfs, well under libkrun's IRQ cap.
- TTY-conditional dispatch: `attach()` when stdin is a real terminal,
`exec_with(...)` otherwise (handles pipes, redirects, smoke tests under
`sg`/`sudo -c`, CI).
- Credentials: env-var only (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`) so the
launcher is independent of the refresh machinery.
Obsolete-by-architecture (no decision needed): setup `--minimal` / `--disk`
(image is Docker-built once, not provisioned per-setup), `--max-memory` (tied
to the balloon).
**Done when:** `cd repo && agent-vm claude -p "say hi"` returns a real Claude
response from inside the sandbox.
## Discovered upstream issues (still open)
**Actual outcome:** All wiring verified via `agent-vm shell` (workspace
round-trip, persistence across reboots, agent CLIs resolvable on PATH,
CODEX_HOME redirect, env propagation). The live API smoke was deferred — see
ARCHITECTURE.md "What Phase 2 deliberately doesn't do". Phase 3's host-OAuth
work closes the gap naturally.
Carried over from the old plan; the IRQ/split-irqchip one (#3) is resolved.
### Phase 2.x — Post-MVP polish [done — commits `7608f27`..`d3914b9`]
1. `PullPolicy::Always` doesn't refresh the cached manifest digest — worked
around with our own marker file.
2. `LayerDownloadProgress` events elided for fast registries.
4. No `Image::resolve(reference) -> RemoteRef` helper; we do raw-HTTP HEAD to
ask the registry what's current.
5. IPv6 gateway DNS unresponsive in at least one libkrun config (see B3).
6. `exec_with`'s `StdinMode::Null` doesn't read as `/dev/null` to every client
(codex blocks); worked around in the bash prelude.
7. High-level `exec_with` is buffer-until-exit only; switched to
`exec_stream_with`.
8. Long secret placeholders (>~few hundred bytes) break sandbox boot at the
runtime handshake; keep synthetic JWTs minimal.
A series of small fixes landed between Phase 2 and Phase 3, all triggered by
real testing on the user's laptop. Listed here so the next reader knows
they're in already and doesn't redo the work.
- **`RUST_LOG` wiring** (`7608f27`). `tracing-subscriber` initialized in
`main`, defaults to `warn`. The microsandbox stack is silent otherwise.
- **Auto-recover the local registry container** (`a57ed6d`). `build.sh`'s
`ensure_registry` was rewritten as a state machine that handles every
`docker ps` state, polls `/v2/` after start, and recreates from scratch
if a stale container is running with no port mapping. Plus per-phase
banners so long waits don't look like a hang.
- **Mirror the host project path inside the guest** (`92ff582`). `cwd` is
bind-mounted at the same absolute path, so anything the agent emits
(compiler errors, stack traces, file:line references) is interpretable on
the host. Paths under tmpfs mount points (`/tmp`, `/run`, `/dev/shm`,
`/var/run`) fall back to `/workspace` with a warning, because the guest
tmpfs-mounts them at boot and wipes any patch-created mount point.
- **`AGENT_VM_PROFILE=1`** (`127f6b3`). Prints per-phase wall-time
(create / run / stop / remove) for the launcher. Confirmed total is
~1.5 s, dominated by VM boot (~1.0 s of libkrun kernel boot).
- **Pull progress bar** (`2489168`..`66ed8f3`..`3ffd6b6`..`984680a`).
Two-phase indicatif renderer: spinner with text during download, real
byte-weighted bar during materialize. Single line, single spinner, ETA
based on materialize-only rate (no more "29 minute" → "17 second" jumps).
- **`agent-vm pull` + per-launch update-available banner**
(`bfab9d3`..`d3914b9`). Pulls are explicit; `agent-vm shell` only does a
cheap manifest-digest HEAD against the registry and prints a banner when
the per-platform digest differs from what we last pulled. The "what we
last pulled" digest is tracked in our own marker file
(`~/.local/state/agent-vm/pulled-digests/<hash>`), atomically written
only after a successful pull, so an interrupted pull never leaves the
microsandbox cache in an empty or stale state.
### Phase 3 — Static host-rooted secrets [done — submodule branch `agent-vm-secret-file`, committed `8cc036b`]
The big architectural payoff of moving to microsandbox: real tokens
never enter the VM.
What shipped:
- **Upstream extension** on a vendor/microsandbox branch:
`SecretValue { Static(String), File(PathBuf) }` with a bare-string
wire format for Static (backward-compatible with prebuilt `msb`) and a
NUL-prefixed sentinel for File (deferred Phase 4 plumbing). 250
microsandbox-network tests green; new tests cover both wire formats.
- **agent-vm secrets module**: per-launch snapshot of
`~/.claude/.credentials.json` and `~/.codex/auth.json`, atomic write
of placeholder credentials files into the per-project state dir.
- **Launcher wiring**: TLS interception enabled, file-backed allow-host
configured for both providers (Anthropic + OpenAI), real access
tokens passed via `SecretValue::Static(...)` (Phase 3) — File
variant is forward-looking infra used only by Phase 4 once a patched
msb ships.
- `IS_SANDBOX=1` set so Claude Code's "don't run as root" guard yields
to the fact that the microVM *is* the security boundary.
What we deliberately punted:
- **Refresh**. Long sessions will eventually 401. Phase 4 handles it.
- **`~/.microsandbox/bin/msb` rebuild**. Required for `SecretValue::File`
to behave (without it, an old msb substitutes the literal sentinel
string). Phase 4 ships the replacement and switches to File-backed
secrets.
- **OAuth refresh endpoint MITM** (forging
`platform.claude.com/v1/oauth/token` responses from the host file).
Original Bash agent-vm does this; Phase 4 here.
**Done when:** inside the guest, `cat /proc/1/environ | tr '\0' '\n' |
grep -i token` shows only placeholders, while a Claude API request
through `api.anthropic.com` goes through the microsandbox CA-signed
intercept proxy with the placeholder substituted in the Authorization
header. **Actual:** verified at the network layer (TLS cert chain shows
`CN=microsandbox CA`, debug config dump shows the substituted real
token); the final "Anthropic returns a real response" leg can't be
verified on the nested test host because of an outer credential bridge
that itself substitutes placeholders. Structurally equivalent to the
original Bash agent-vm's credential-proxy flow.
### Phase 4 — Refresh semantics [done — committed `8e262c0`..`85ffd34`; Codex+Claude verified end-to-end against real credentials, leaks fixed]
Tokens rotate; long-running sandbox sessions must survive that without
re-attaching. Phase 3 makes the access token swappable in principle;
this phase teaches agent-vm to actually do the swap, with the simplest
moving parts that work.
Design:
- **Rebuild `~/.microsandbox/bin/msb` from our fork** so
`SecretValue::File` actually re-reads the host token file on every
connection-setup. With this in place, the proxy always picks up the
current host file content without any host-side daemon.
- **MITM the OAuth refresh endpoint** (`platform.claude.com/v1/oauth/
token`, `auth.openai.com/oauth/token`). When the in-VM agent tries
to refresh:
1. agent-vm spawns a host-side `claude -p "ping" --model sonnet` /
`codex exec --skip-git-repo-check "Reply OK"` to trigger the
host CLI to rotate its credential file (this is what the
original Bash agent-vm does).
2. Re-reads the host file.
3. Synthesizes the refresh-endpoint response from the host's new
`accessToken` / `expiresAt`, but with placeholder strings for
the body's `access_token` field — so the in-VM agent's local
credentials file is updated to a placeholder, not the real
token. Same shape as the rest of the substitution flow.
4. The next API request from the in-VM agent uses the new
placeholder, which `SecretValue::File` swaps for the real
newly-rotated token. No restart, no manual intervention.
- **Single-flight** for the host-CLI invocation so two concurrent
in-VM refresh attempts don't fire two host-side `claude -p`
processes at once.
What we **don't** need (per discussion): a proactive "token nearing
expiry" timer. The guest's own refresh attempt at 401-time is the
trigger, and the MITM handles it. If the user already ran `claude` on
the host between refreshes and the host file is fresh, the in-VM
substitution picks it up on the next request without any of this
machinery firing — `SecretValue::File` is the whole story for the
externally-rotated case.
**Done when:** a multi-hour session crosses a token rotation
end-to-end without the agent seeing an auth error and without manual
intervention.
**Verification session (2026-05-21):** stood the runtime back up on a
fresh host (installed `libkrunfw` bundle + the patched
`0.4.6+agent-vm.phase4` msb), rebuilt the image, and ran the launcher
against a real host Claude credential. Findings:
- **`images/build.sh` registry bug fixed.** Docker 29.x emits a stray
blank line to *stdout* on `docker inspect` of a missing container, so
`state=$(... || echo missing)` became `"\nmissing"` and never matched
the `missing` case — the script tried to `docker start` a nonexistent
container. Now whitespace-stripped with an empty→missing fallback.
- **Real-token leak into the guest found + fixed.** The token files
were written under `state_dir`, which is bind-mounted into the guest
at `/agent-vm-state`, so `cat /agent-vm-state/tokens/anthropic`
returned the host bearer. Moved to a host-only sibling
`<hash>.secrets/` (0700), never mounted; added a guard test. See
ARCHITECTURE.md "Token files live outside the guest bind mount".
- **Network layer verified.** Inside the guest, `credentials.json` and
PID1 environ show only placeholders; `api.anthropic.com`'s server
cert is issued by `CN=microsandbox CA` (traffic goes through the
intercept proxy). The final real-response leg still can't be checked
here — this is a doubly-nested host whose *outer* agent-vm bridge
already replaced the host token with its own placeholder
(`sk-ant-oat01-placeholder-proxy-managed`) and doesn't re-intercept
the nested VM's egress, so Anthropic returns 401. Same documented
limitation as Phase 3.
- **Codex path not exercisable here:** no `~/.codex/auth.json` on this
host, so the OpenAI/Codex websocket flow (the original stop point)
can't be authenticated. The `chatgpt.com` WebSocket support
(`inject_basic_auth(false)` + zero-copy fast path) is in place and
the codex CLI (now 0.133.0) is present in the verified image, but a
host with real Codex credentials is needed to confirm it end-to-end.
**Verification session (2026-05-24):** the user ran `codex login` to
populate `~/.codex/auth.json`, and we drove the full Codex flow until
it actually returned a real gpt-5.5 response. Three additional fixes
landed:
- **`id_token` JWT leak in `<state>/codex/auth.json`.** `secrets.rs`
was substituting `access_token` and `refresh_token` but leaving the
OpenAI `id_token` JWT verbatim — that JWT decodes to user email,
chatgpt account id, plan type, org list, user_id. Replaced the
static `OPENAI_ID_PLACEHOLDER` string with a structurally valid
alg-none JWT carrying clearly-fake fields, so codex 0.133's
client-side JWT parse succeeds and no PII enters the guest. Leak
grep for the host email, real access/refresh/id token prefixes
inside the guest mount: all absent.
- **IPv6 nameserver in `/etc/resolv.conf` hung codex's resolver.**
microsandbox's agentd writes both v4 and v6 gateway DNS at boot. In
this nested-libkrun config the v6 gateway times out on UDP/53
queries; glibc's `getaddrinfo` silently skips it and uses v4, but
codex's Rust async resolver returns `EAI_AGAIN` and fails. `getent
hosts chatgpt.com` returned immediately while codex hung at "failed
to lookup address information". `run.rs` now wraps the agent
command in a tiny bash prelude that `sed`s the colon-bearing
nameserver line out of `/etc/resolv.conf` before exec.
- **codex 0.133 `exec` blocks on stdin unless it's `/dev/null`.**
`exec_with`'s `StdinMode::Null` was not enough; codex waited
indefinitely for what it thought was unbounded interactive input.
Backgrounding (`&` in bash) worked because that auto-redirects
stdin. The prelude now does `[ -t 0 ] || exec < /dev/null` so
interactive TTY launches are unaffected.
- **Streaming output** in non-TTY mode (switched the launcher from
`exec_with` to `exec_stream_with`). Long-running agent commands
used to look completely silent until exit; now stdout/stderr stream
live and partial output survives Ctrl-C / timeout. Independent of
the codex fix but uncovered by the same debugging.
End-to-end on a real `gpt-5.5` host credential: `agent-vm codex exec
--skip-git-repo-check "Reply with: CODEX_DIRECT_OK"` returns
`CODEX_DIRECT_OK` in ~7 s (boot 2.4 s + run 4.3 s), with no host
token, refresh token, id_token JWT or user PII anywhere under
`/agent-vm-state`. Claude path was re-tested and still hits the
documented nested-host 401; that's not a regression — same outer-
bridge limit as Phase 3.
**Still untested before Phase 4 can claim its "Done when":**
- **Actual mid-session rotation.** The substitution + refresh-hook
*infrastructure* is verified, but no run has yet crossed a real
token-expiry boundary and survived. The OpenAI ChatGPT access token
lives ~24 h; we need a long-running session that goes through at
least one rotation event without re-attaching. The hook code path
(host CLI rotates → re-read file → synthesize placeholder response
→ guest writes placeholder → next request uses fresh real token)
has not actually fired in anger.
- **Single-flight on the host-CLI invocation.** Listed under
"Design" but not yet implemented in `intercept_hook.rs` — two
concurrent in-guest refresh attempts could each spawn `claude -p`
or `codex exec` on the host. Host CLI's own file lock prevents
corruption, so the worst case is one extra rotation invocation,
but it's worth a `<state>.secrets/.refresh.lock` flock once we see
it bite.
### Phase 5 — OpenCode auth + security snapshot [done — commit `f66a0b6`]
Two small completions of the auth/secret story:
**OpenCode auth.** Phase 4 covered Claude and Codex; OpenCode is the
third in-scope agent and was deferred. OpenCode authenticates against
OpenAI but uses its own file shape — original is at
`claude-vm.sh:996` (`_opencode_vm_build_oauth_auth_json`):
`{type:"oauth", refresh, access, expires, accountId}` where `access`
is a JWT with `iss/aud/exp/scp/chatgpt_account_id/chatgpt_plan_type`.
Extend `secrets.rs`:
- Read `~/.local/share/opencode/auth.json` if it exists; otherwise derive
the account/email/plan fields from `~/.codex/auth.json` (same OpenAI
account, different on-disk format).
- Write a placeholder `<state>/opencode/auth.json` whose `access` is a
synthetic alg-none JWT carrying placeholder-only payload fields (same
pattern as Phase 4's `OPENAI_ID_PLACEHOLDER`). `refresh` is a static
placeholder string.
- Register the real OpenAI access token as a second `SecretValue::File`
entry keyed off a distinct placeholder so api.openai.com /
chatgpt.com requests from OpenCode get substituted (same allow-host
set as Codex).
**Security snapshot.** Cheap safety net for "did agent-vm itself, or a
bug in the refresh hook, mutate my host tokens in some way I didn't
expect?" At launcher start, take SHA-256 of
`~/.claude/.credentials.json`, `~/.codex/auth.json`,
`~/.local/share/opencode/auth.json`; on sandbox exit, re-hash and warn
if any of them changed outside the Phase 4 refresh-hook path (which we
*do* expect to mutate them). Original is `claude-vm.sh:1560`
(`_claude_vm_security_snapshot/check`).
**Done when:** OpenCode authenticates to OpenAI through the proxy on a
real host (analogous to the Codex e2e in Phase 4 verification 2026-05-
24); the security snapshot fires on a synthetic mid-run mutation.
### Phase 6 — gh / git credential injection + per-launch repo allow-list [done — commits `396011b`, `4479b1f`, `29c0ccc`, `62c9eb6` (and upstream `vendor/microsandbox@deeda39`)]
Without this the in-VM agent can read the project but can't `git push`,
can't `gh pr create`, can't fetch a private dependency from GitHub.
With it, agents become useful for actual development work.
**Design:**
1. **Reuse host gh auth — don't mint new tokens.** Read `gh auth token`
(or parse `~/.config/gh/hosts.yml`) on the host at launch. Register
it as a `SecretValue::File` (same primitive Phase 4 uses for
Claude/Codex) so a host-side `gh auth refresh` propagates to the
guest without a relaunch. The in-VM `gh` / `git` see a placeholder
token; microsandbox's TLS-intercept proxy substitutes on the way
out. Allow-host set: `api.github.com`, `github.com`, `codeload.
github.com`, `raw.githubusercontent.com`, `objects.githubusercontent.
com`, plus the SSH endpoint for HTTPS pushes that the gh credential
helper handles.
2. **Per-launch repo allow-list — enforced at the proxy.** A real gh
OAuth token typically has `repo` scope (read+write to every repo
the user can see). We don't want an off-rails agent pushing to all
of them. So:
- Build the allow-list at launch:
- Parse `git remote -v` in the cwd (logic ports from
`_claude_vm_parse_github_remote` at `claude-vm.sh:1432`).
- Append any `--repo owner/name` overrides from the CLI
(repeatable).
- `--no-git` skips the whole gh path (no token, no hosts.yml,
no allow-list).
- Extend the request-interceptor hook (`crates/agent-vm/src/
intercept_hook.rs`) with a third route family: `api.github.com`
and friends. Phase 4's hook fires on `(host, method, path
prefix)`; for GitHub we match on host=`api.github.com`,
any-method, all paths, and inside the hook reject anything
whose path doesn't start with `/repos/<allowed-owner>/<allowed-
repo>/`, `/user`, `/user/repos`, `/orgs/<allowed-org>/`,
`/notifications` (read-only), etc. Denied requests return a
synthesized 403 with a clear body so the in-VM `gh`/`git`
surfaces a comprehensible error instead of a hang or 5xx.
- `codeload.github.com` / `raw.githubusercontent.com` filter on
`/<allowed-owner>/<allowed-repo>/...` similarly.
3. **In-guest config injection.** Write `~/.gitconfig` (uses the gh
credential helper that forwards to placeholder token) and
`~/.config/gh/hosts.yml` (placeholder token, real user). Same shape
as `_claude_vm_inject_git_credentials` + `_inject_gh_credentials`
at `claude-vm.sh:682,706`.
4. **CLI flags.** `--no-git` (skip everything), `--repo OWNER/NAME`
(repeatable; allow-list addition).
**Open question:** the OAuth proxy hook in Phase 4 sees buffered
plaintext HTTP request bytes via stdin — that's what we need for
path-based filtering too. Confirm `intercept/handler.rs` rule
matching supports "any method, any path" wildcards or extend it.
**What we explicitly are NOT doing** (per user direction):
- No GitHub App device flow.
- No per-repo scoped token minting.
- No Copilot CLI / Copilot API plumbing.
**Done when:** `agent-vm claude -p "...do work then commit and push..."`
in a real GitHub project lands a commit on the remote; an agent attempt
to push to a *different* repo gets a clean 403 from the proxy hook
rather than reaching GitHub.
### Phase 7 — DX additions: `--mount`, clipboard, ccusage, Chrome DevTools MCP [done — commits `5c5bf22`, `f40e6df`, `ce745ec`, `43203fe`]
A grab-bag of original-agent-vm capabilities the user wants in v1.
Each is independent and lands as its own PR per the working agreement.
**`--mount HOST:GUEST`** (repeatable). Pass extra host directories
through to the guest as bind mounts. The launcher already mounts the
project at its host path + the per-project state dir at
`/agent-vm-state`; add user-supplied extras. Originally we worried
about libkrun's tight virtio-IRQ cap (~11 IRQs with the in-kernel
IOAPIC) and capped/warned on extras; that ceiling went away when we
flipped on `msb_krun`'s userspace split irqchip in the runtime
(`vendor/microsandbox/crates/runtime/lib/vm.rs`), raising the cap to
~219 — see Discovered Upstream Issue #3 for the history.
**Clipboard bridge.** Original `clipboard-pty.py` does a live PTY
bridge; that's more than we need. v1 design: a per-project
`<state>/clipboard.{txt,png}` bind-mounted into the guest at a known
path (e.g. `/agent-vm-state/clipboard.*`), plus:
- In-guest helper `/usr/local/bin/agent-vm-clip` (baked into the image)
that reads/writes those files.
- Host-side subcommand: `agent-vm clipboard get|put` which exchanges
with the host clipboard via `xclip` / `wl-copy` / `pbpaste`,
resolving the active sandbox's state dir from cwd. Defer live two-way
sync — the file-based pull/push covers "agent emits a code block,
I copy it into another app" and vice versa.
**`agent-vm-ccusage` wrapper.** Port verbatim from `bin/ccusage` in
the original (4 lines): set `CLAUDE_CONFIG_DIR` to the comma-joined
union of `~/.claude` + every per-project session dir under
`${XDG_STATE_HOME}/agent-vm`, then `exec npx ccusage@latest`. Ship as
a separate shell script in `bin/` and reference from README.
**Chrome DevTools MCP.** Original at `claude-vm.sh:385` installs
Chromium into the image with `google-chrome` symlinks, then writes the
MCP server entry into `~/.claude.json`. The naive port (`command:
"npx"`, args `["-y", "chrome-devtools-mcp@latest", "--headless=true",
"--isolated=true"]`) reported `✓ Connected` to `claude mcp list` but
every tool call returned either `Protocol error
(Target.setDiscoverTargets): Target closed` or
`net::ERR_CERT_AUTHORITY_INVALID`. Two distinct root causes (both
fixed across `f40e6df`, `ce745ec`, `43203fe`):
1. **Chromium refuses to initialize its user-namespace sandbox as
root.** The browser dies before the CDP pipe is read. Common
workaround is `--no-sandbox`; we'd rather keep chromium's nested
sandbox active (defence in depth against untrusted content the
agent navigates to).
2. **Chromium on Linux ignores `/etc/ssl/certs/ca-certificates.crt`
and only honours its built-in root store + the per-user NSS DB**,
so the microsandbox MITM CA isn't trusted by chromium even though
curl/openssl trust it. Common workaround is `--acceptInsecureCerts`
(puppeteer-level "trust everything"); we'd rather scope trust to
just our CA.
What shipped in the rewrite, end to end:
- **Image** (`images/Dockerfile`): chromium + `google-chrome` symlinks;
added `sudo` and `libnss3-tools`; dedicated `chrome` user (UID 9999,
/home/chrome) baked via direct `/etc/passwd`/`/etc/shadow`/`/etc/group`
edits (debian-slim has no `useradd`); empty NSS DB at
`/home/chrome/.pki/nssdb` initialized at image-build time; sudoers
drop-in `root ALL=(chrome) NOPASSWD: ALL`; wrapper script at
`/usr/local/bin/agent-vm-chrome-mcp` that re-execs the MCP under
`sudo -u chrome -H -n` with an explicit env allow-list
(`NODE_EXTRA_CA_CERTS` / `SSL_CERT_FILE` / `CURL_CA_BUNDLE` /
`REQUESTS_CA_BUNDLE` / `PATH` / `HTTP(S)_PROXY` / `NO_PROXY` /
`CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS` / `CI` / `DEBUG` / `TZ` /
`LANG` / `LC_ALL` / `TMPDIR`) and `cd /home/chrome` before exec
(chrome can't write `/workspace`); pre-warm RUN that bakes
`chrome-devtools-mcp@1.0.1` into `/home/chrome/.npm/_npx/` so first
launch is a cache hit (best-effort `|| echo skipped` so a transient
registry blip doesn't fail the whole `agent-vm setup`).
- **Launcher** (`crates/agent-vm/src/run.rs`): bash prelude runs
`certutil -A -t C,, -n microsandbox -i /usr/local/share/ca-certificates/microsandbox-ca.crt`
per boot (the CA is per-boot — agentd writes it into the guest, so
it can't be baked into the image). Trust string is `C,,`
(server-cert only — `T` would add client-cert signing too). On
failure the launcher prints a 4-line warning naming the symptom
(`HTTPS in chrome-devtools MCP will fail with
ERR_CERT_AUTHORITY_INVALID`) and prefixed sudo/certutil stderr,
rather than the original silent `|| true`.
- **MCP config** (`crates/agent-vm/src/secrets.rs::write_default_claude_root_state`):
`command: "/usr/local/bin/agent-vm-chrome-mcp"`, `args: ["npx",
"-y", "chrome-devtools-mcp@1.0.1", "--headless=true",
"--isolated=true"]`. Pinned to the SAME version as the image's
pre-warm — bump both together. Codex / OpenCode are unchanged
(they don't get the entry — Phase 7 scope was Claude-only).
- **`AGENT_VM_NO_CHROME_MCP` opt-out** (any value, including empty,
consistent with `AGENT_VM_PROFILE` / `AGENT_VM_DEBUG_CONFIG`):
removes the `chrome-devtools` key from a merged claude.json (sticky
fix), drops `mcpServers` entirely if it ends up empty, AND skips the
per-boot certutil/sudo prelude so opted-out users don't pay for or
trip the chrome-user setup. Both gates check the same env var so
the opt-out is honoured everywhere.
**Done when:** `--mount` round-trips a file into a guest path of the
user's choice; `agent-vm clipboard put` then in-guest `cat
/agent-vm-state/clipboard.txt` shows the same string; `agent-vm-
ccusage` reports usage across all project sessions; in-VM Claude
opens a real URL via the MCP and screenshots it (verified e2e: a
JSON-RPC `navigate_page` https://example.com followed by
`take_snapshot` returns the real "Example Domain" content, with
chromium running as `chrome` UID 9999, sandbox active, and the NSS
DB listing only the `microsandbox` CA with attributes `C,,`).
### Phase 8 — Fast-launch (deferred — wrong instrument for the job)
Originally framed around `Sandbox::from_snapshot(...)` on the assumption
that microsandbox snapshots checkpoint VM memory (à la Firecracker's
snapshot/restore). Confirmed reading
`vendor/microsandbox/crates/microsandbox/lib/snapshot/mod.rs` that they
do **not**: a snapshot captures the stopped sandbox's writable upper
filesystem layer plus the metadata that pins the immutable lower
(image). Booting `from_snapshot` still goes through the full libkrun
kernel boot (~1.0 s) — the snapshot only saves the EROFS materialize
step on a re-pull, which we already pay only on explicit `agent-vm pull`
(rare). So filesystem snapshots are the wrong instrument for cutting
launch time.
The real lever for fast launches is **detached mode**: boot a sandbox
once per project, leave it running, attach for each subsequent
`agent-vm <agent>` invocation. Microsandbox exposes
`create_detached`, `start_detached`, and `Sandbox::get(name)` already.
Round-trip drops from ~1.5 s to ~1050 ms but pulls in:
- New lifecycle subcommands (`agent-vm ps`, `agent-vm stop`,
`agent-vm restart`).
- A reuse strategy for per-project sandbox names (we already have
`agent-vm-<hash>`; just need `.replace()` to flip to "attach if
exists, create-detached otherwise").
- Idle-timeout cleanup so abandoned sandboxes don't squat memory.
- A policy decision: does state inside the VM (`/tmp`, `/var/log`)
persist between agent invocations? Today every launch is a fresh
VM, so this is a behaviour change.
Deferred pending a clear product call. The architectural payoff of
microsandbox is keeping tokens out of the VM (Phase 3), and current
1.5 s launch is acceptable.
### Phase 9 — Distribution + polish + docs [partially done — commit `f6020f1`; CI workflow + cross-arch binaries still pending]
The "ready to share with a teammate" phase.
- **Auto-install of the microsandbox runtime.** The agent-vm binary ships
on its own; `~/.microsandbox/{bin/msb, lib/libkrunfw.so.5.2.1}` are
needed but not bundled. Wrap `microsandbox::setup::install()` so first
run downloads them automatically if missing. Verify version matches the
prebuilt the binary was built against.
- **CLI flag promotion.** `--memory N`, `--cpus N`, `--image REF`,
`--no-update-check`. Today these are env-var only (`AGENT_VM_*`).
- **`.agent-vm.runtime.sh` project hook.** Script executed in the guest
immediately before the agent starts, for project-local setup
(`npm install`, `docker compose up`, etc.).
- **README rewrite.** Install, prereqs, setup, usage, troubleshooting,
the registry/marker/snapshot internals at a high level.
- **CI smoke test.** GitHub Actions workflow that builds the image, runs
`agent-vm setup --no-verify`, and `agent-vm shell -- -c 'echo ok'`.
- **macOS/aarch64 binary.** Cross-compile or native-build on each
platform microsandbox supports.
- **Upstream-fix or formalize the IPv6 DNS workaround.** The launcher
currently sed's the v6 nameserver out of `/etc/resolv.conf` every
launch (see Phase 4 verification 2026-05-24, upstream issue #5).
Either get the v6 gateway DNS path working in microsandbox or expose
a config flag — landing one of those lets us drop the bash prelude
back to just the stdin redirect.
**Done when:** README is publishable, CI smoke green on at least linux-amd64,
binary works from a fresh checkout on a host where microsandbox runtime is
not pre-installed.
## Discovered upstream issues
Things we worked around during Phase 2.x that should eventually be filed
or fixed in `wirenboard/microsandbox`:
1. **`PullPolicy::Always` doesn't refresh the cached manifest digest.**
It re-fetches layer blobs correctly, but `Image::persist`'s fast-path
detection skips the DB update under the same reference even when the
per-platform manifest digest changed. We work around it with our own
marker file rather than `Image::remove` (because remove + re-pull
opens an empty-cache window).
2. **`LayerDownloadProgress` events are often elided** for fast registries
(we never see them with localhost). Only `LayerDownloadComplete` fires.
Not exactly a bug, but undocumented and bit us when we tried to drive a
download-bytes bar.
3. **libkrun virtio IRQ cap is low** with the default in-kernel IOAPIC
(~11 IRQs handed to virtio-mmio total on x86_64), so a config with
the OCI overlay's 2-device cost + virtio-net + vsock + console + a
couple of bind mounts saturates it and any extra `--mount` trips
`RegisterNetDevice(IrqsExhausted)` at boot. *Resolved* by enabling
`msb_krun`'s userspace split irqchip via `MachineBuilder::split_irqchip(true)`
in `vendor/microsandbox/crates/runtime/lib/vm.rs` — that swaps in a
userspace IOAPIC with ~219 usable IRQs at the cost of one extra
msb_krun worker thread. **Also required bumping `msb_krun` 0.1.12 →
0.1.13**: 0.1.12's userspace IOAPIC used a `u32` IRR (pins ≥32
silently dropped) and had an integer-underflow bug in the
redirection-table register-index reads/writes that crashed the VMM
mid-boot once the guest started programming RTEs. Both are fixed in
0.1.13. Verified e2e: 8 user --mount entries → guest brings up 19
virtio devices on IO-APIC pins 5..23. No effect on aarch64 / riscv64.
4. **Manifest media-type assumptions.** Microsandbox stores the
per-platform manifest digest; a registry HEAD on a tag returns the
multi-arch index digest by default. Either would be fine to use, but
the SDK doesn't expose either as "ask the registry what's there now"
so we end up doing raw HTTP. A `Image::resolve(reference) -> RemoteRef`
helper would clean this up.
5. **IPv6 gateway DNS is unresponsive in at least one libkrun config.**
`agentd` writes both v4 and v6 gateway nameservers into
`/etc/resolv.conf` (`crates/agentd/lib/network.rs:556`), but UDP/53
queries to the v6 gateway time out while v4 works. glibc's
`getaddrinfo` hides this by skipping the broken resolver; strict
async resolvers (codex / hickory-style) hang with `EAI_AGAIN`. We
work around it in agent-vm by sed'ing colon-bearing nameserver
lines out of `/etc/resolv.conf` before exec'ing the agent. Right
fix is either (a) make the v6 gateway DNS path actually work, or
(b) expose a `network.dns(|d| d.disable_ipv6(true))` knob so the
guest only sees v4 nameservers.
6. **`exec_with` default `StdinMode::Null` doesn't read as `/dev/null`
to every client.** codex 0.133's `exec` subcommand blocks
indefinitely on what it considers an open stdin pipe under
`StdinMode::Null`, but reads EOF correctly when we explicitly
redirect to `/dev/null` from inside the bash wrapper. Suggests the
fd that gets handed to the in-guest process is something other than
`/dev/null` (maybe a closed pipe, maybe a pipe that hasn't been
closed on the sender side). Worth tracing what `StdinMode::Null`
ends up as inside the guest.
7. **High-level `exec_with` is buffer-until-exit only.** It returns a
completed `ExecOutput`, so a hung child plus an external timeout
leaves the caller with zero observable output — making "is it
stuck or just slow?" indistinguishable. We switched to
`exec_stream_with` (which exists and works), but the wrapper API
should probably stream by default and offer a `.collect()` adapter
for the rare buffer-it-all case.
8. **Long secret placeholders break sandbox boot.** Registering a
~480-byte placeholder string (a JWT-shaped synthetic with full
OpenAI auth claims) caused `runtime error: handshake read
id_offset: timed out before relay sent bytes` at sandbox create
time, before agentd ever runs in the guest. Same setup with the
placeholder shrunk to ~150 bytes boots fine. Boot failure happens
long before the substitution proxy is exercised, so the limit must
sit in the config-delivery / runtime-handshake path rather than the
secret scanner itself. Worth tracing — Phase 5 works around it by
keeping OpenCode's synthetic JWT minimal (3-claim payload, short
sig), but anything in agent-vm or downstream that wants placeholders
above a few hundred bytes will silently fail.
## Working agreements
1. **One feature = one PR.** Stop after each; the user signs off.
2. **ARCHITECTURE.md is the source of truth for the *why*.** Every nontrivial
design choice gets a short subsection: chosen / rejected / why.
3. **microsandbox changes go into the submodule**, on a branch of
`wirenboard/microsandbox`, never vendored copies. Merge the submodule
branch before the superproject (see AGENTS.md).
4. **Bump the workspace version on every merge** into `rewrite-microsandbox`
(see AGENTS.md).
5. **Don't relocate build output to tmpfs.** Fix the root cause.
6. **Don't touch the old Bash `agent-vm`** on `main` until v1 ships from this
1. **One phase = one PR.** Stop after each.
2. **ARCHITECTURE.md is the source of truth for the *why*.** Every major
design choice in a phase gets a short subsection: what was chosen, what was
rejected, why.
3. **Don't touch old `agent-vm`** (Bash, Python helpers) on the rewrite
branch. The old tree stays on `main` until v1 is shipped from the new
branch.
4. **microsandbox changes go into the submodule, not vendored copies.** If we
need to fork, we do it on a branch of `wirenboard/microsandbox` so the
diff stays reviewable upstream.
5. **Every phase updates three docs together.** PLAN.md gets the status
marker and any plan corrections. ARCHITECTURE.md gets the new design
subsection. README.md status list moves the phase from pending to done.
The commit message references the phase number.

View file

@ -153,28 +153,6 @@ the launcher sources it inside the guest before exec'ing the agent.
Use for `npm install`, env exports, dev-server startup. Non-zero
exit aborts the launch.
## Ports & egress
The default network policy (`public_only`) lets the guest reach
the public internet plus DNS, and denies everything else
(loopback, RFC1918 LAN, link-local, cloud-metadata, the host).
Open holes per-launch with these flags — they compose:
| flag | what it opens | guest-side address |
|---|---|---|
| `--publish HOST:GUEST[/proto]` | host port `HOST` → guest port `GUEST` (`tcp` default; `/udp` for UDP) | inbound to the guest |
| `--auto-publish` | every `0.0.0.0:*` / `127.0.0.1:*` listener inside the guest is mirrored to the host loopback (Lima-style) | host: `127.0.0.1:<guest-port>` |
| `--allow-egress IP\|CIDR` (repeatable) | one IP or one CIDR through the egress deny | dial directly by IP |
| `--allow-lan` | the whole `DestinationGroup::Private` (10/8, 172.16/12, 192.168/16, 100.64/10, fc00::/7) | dial any LAN IP |
| `--allow-host` | the per-sandbox gateway IP, which the smoltcp stack rewrites to host `127.0.0.1` | `host.microsandbox.internal:<port>` (already in guest `/etc/hosts`) |
Loopback (guest's own `127.0.0.1`), link-local, and cloud metadata
(`169.254.169.254`) stay denied even with `--allow-lan` — they're
disjoint groups by design. `--allow-host` is the narrowest way to
reach a dev server bound to host `127.0.0.1`; `--allow-lan` is the
broadest. A compromised in-guest process gets full access to
whatever you open, so prefer the narrowest flag that fits.
## Troubleshooting
- **`RegisterNetDevice(IrqsExhausted)` at boot** — the userspace split
@ -193,6 +171,3 @@ whatever you open, so prefer the narrowest flag that fits.
- [PLAN.md](PLAN.md) — phased roadmap, what's done, what's deferred.
- [ARCHITECTURE.md](ARCHITECTURE.md) — design notes; why things look
the way they do.
- [AGENTS.md](AGENTS.md) — conventions for coding agents (Claude
Code, Codex, etc.) working on this repo: post-merge version bump,
submodule-merge ordering, what not to do.

View file

@ -1,6 +1,6 @@
[package]
name = "agent-vm"
description = "Sandboxed microVMs for AI coding agents, built on microsandbox."
description = "Sandboxed VMs for AI coding agents, built on microsandbox."
version.workspace = true
edition.workspace = true
license.workspace = true
@ -11,15 +11,9 @@ name = "agent-vm"
path = "src/main.rs"
[dependencies]
# default-features = false drops the SDK's `prebuilt` feature, so building
# agent-vm never downloads the upstream msb+libkrunfw bundle — we ship our own
# from-source msb. Keep net + keyring (the SDK's other defaults).
microsandbox = { path = "../../vendor/microsandbox/sdk/rust", default-features = false, features = ["net", "keyring"] }
tokio = { version = "1.42", features = ["macros", "rt-multi-thread", "time"] }
# `wrap_help` makes clap detect the terminal width and wrap long help
# text to it; without it the multi-paragraph `--help` prose renders as
# unbroken one-line-per-paragraph walls.
clap = { version = "4.5", features = ["derive", "env", "wrap_help"] }
microsandbox = { path = "../../vendor/microsandbox/crates/microsandbox" }
tokio = { version = "1.42", features = ["macros", "rt-multi-thread"] }
clap = { version = "4.5", features = ["derive", "env"] }
anyhow = "1.0"
base64 = "0.22"
sha2 = "0.10"
@ -30,7 +24,6 @@ indicatif = "0.18"
console = "0.16"
reqwest = { version = "0.13", default-features = false, features = ["rustls", "json"] }
libc = "0.2"
ipnetwork = "0.21"
# Integration tests that drive the actual secret-substitution layer
# end-to-end with the hook's anonymisation logic. Kept out of release

View file

@ -47,16 +47,6 @@ pub fn host_opencode_auth_path() -> Option<PathBuf> {
Some(PathBuf::from(std::env::var_os("HOME")?).join(".local/share/opencode/auth.json"))
}
/// `$HOME/.cache/claude-vm/copilot-token.json` — the GitHub Copilot
/// token cache the original Bash agent-vm's `copilot_token.py` writes
/// after its OAuth device flow (JSON `{"access_token": "<gho_…>"}`).
/// Same not-found convention as the other host credential helpers:
/// callers treat a missing file as "no cached Copilot token" and fall
/// back to the captured `gh auth token`.
pub fn host_copilot_token_path() -> Option<PathBuf> {
Some(PathBuf::from(std::env::var_os("HOME")?).join(".cache/claude-vm/copilot-token.json"))
}
/// Write `data` to `path` atomically (write a sibling tmp file, then
/// `rename`) with the given Unix mode. The tmp file uses a fixed
/// extension so a crashed run leaves an obvious orphan rather than a

View file

@ -53,24 +53,7 @@ pub async fn check_for_update(image_ref: &str) -> Result<Option<UpdateState>> {
/// on any failure so callers can decide what to do with the silence.
pub async fn fetch_remote_digest(image_ref: &str) -> Option<String> {
let parsed = ParsedRef::parse(image_ref)?;
match remote_manifest_digest(&parsed).await {
Ok(Some(d)) => {
tracing::debug!(image = %image_ref, digest = %d, "registry update probe");
Some(d)
}
Ok(None) => {
// Reachable registry, but we couldn't pin a comparable
// digest (no matching platform entry, private image we
// can't auth to, etc.). Stay quiet at launch.
tracing::debug!(image = %image_ref, "registry update probe: no comparable digest");
None
}
Err(e) => {
// Offline / DNS / TLS — expected sometimes; never fatal.
tracing::debug!(image = %image_ref, error = %e, "registry update probe failed");
None
}
}
remote_manifest_digest(&parsed).await.ok().flatten()
}
async fn remote_manifest_digest(parsed: &ParsedRef) -> Result<Option<String>> {
@ -96,10 +79,22 @@ async fn remote_manifest_digest(parsed: &ParsedRef) -> Result<Option<String>> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()?;
let resp = get_manifest_with_auth(&client, &url).await?;
let Some(resp) = resp else {
let resp = client
.get(&url)
.header(
"Accept",
concat!(
"application/vnd.docker.distribution.manifest.v2+json,",
"application/vnd.docker.distribution.manifest.list.v2+json,",
"application/vnd.oci.image.manifest.v1+json,",
"application/vnd.oci.image.index.v1+json",
),
)
.send()
.await?;
if !resp.status().is_success() {
return Ok(None);
};
}
let direct_digest = resp
.headers()
.get("docker-content-digest")
@ -134,145 +129,6 @@ async fn remote_manifest_digest(parsed: &ParsedRef) -> Result<Option<String>> {
Ok(direct_digest)
}
/// The Accept set every manifest GET sends — both the v2 single-arch
/// manifest media types and the multi-arch index types, so the registry
/// hands back the index when one exists.
const MANIFEST_ACCEPT: &str = concat!(
"application/vnd.docker.distribution.manifest.v2+json,",
"application/vnd.docker.distribution.manifest.list.v2+json,",
"application/vnd.oci.image.manifest.v1+json,",
"application/vnd.oci.image.index.v1+json",
);
/// GET a manifest, performing the registry token handshake when the
/// registry demands one.
///
/// Token-auth registries (ghcr.io, Docker Hub, …) answer an anonymous
/// manifest GET with `401` and a `WWW-Authenticate: Bearer realm=...,
/// service=...,scope=...` challenge — even for *public* images. The
/// caller must then fetch a bearer token from `realm` and retry. The
/// previous implementation skipped this entirely and treated the `401`
/// as "registry unreachable", so the update-available banner never
/// fired for the default `ghcr.io/...` image.
///
/// Returns `Ok(None)` on any non-success (offline, private image we
/// can't auth to, malformed challenge) so the launch path stays quiet
/// rather than failing.
async fn get_manifest_with_auth(
client: &reqwest::Client,
url: &str,
) -> Result<Option<reqwest::Response>> {
let first = client
.get(url)
.header("Accept", MANIFEST_ACCEPT)
.send()
.await?;
tracing::debug!(%url, status = %first.status(), "manifest GET (unauthenticated)");
if first.status().is_success() {
return Ok(Some(first));
}
if first.status() != reqwest::StatusCode::UNAUTHORIZED {
return Ok(None);
}
// Parse the Bearer challenge and fetch an (anonymous) token.
let Some(challenge) = first
.headers()
.get(reqwest::header::WWW_AUTHENTICATE)
.and_then(|v| v.to_str().ok())
.and_then(BearerChallenge::parse)
else {
return Ok(None);
};
let Some(token) = fetch_bearer_token(client, &challenge).await? else {
return Ok(None);
};
let authed = client
.get(url)
.header("Accept", MANIFEST_ACCEPT)
.bearer_auth(token)
.send()
.await?;
if authed.status().is_success() {
Ok(Some(authed))
} else {
Ok(None)
}
}
/// The pieces of a `WWW-Authenticate: Bearer ...` challenge we need to
/// mint a token.
struct BearerChallenge {
realm: String,
service: Option<String>,
scope: Option<String>,
}
impl BearerChallenge {
/// Parse `Bearer realm="https://ghcr.io/token",service="ghcr.io",
/// scope="repository:owner/name:pull"`. Returns None if it isn't a
/// Bearer challenge or has no `realm` (without which we can't fetch
/// a token).
fn parse(header: &str) -> Option<Self> {
let rest = header.strip_prefix("Bearer ").or_else(|| header.strip_prefix("bearer "))?;
let mut realm = None;
let mut service = None;
let mut scope = None;
// Params are comma-separated `key="value"` pairs. Values are
// quoted and never contain a quote themselves in registry
// challenges, so a simple split on `="` / `"` is enough.
for part in rest.split(',') {
let part = part.trim();
let Some((key, val)) = part.split_once('=') else {
continue;
};
let val = val.trim().trim_matches('"').to_string();
match key.trim() {
"realm" => realm = Some(val),
"service" => service = Some(val),
"scope" => scope = Some(val),
_ => {}
}
}
Some(Self {
realm: realm?,
service,
scope,
})
}
}
/// Exchange a Bearer challenge for an anonymous token at `realm`. The
/// token endpoint returns JSON with either `token` or `access_token`.
async fn fetch_bearer_token(
client: &reqwest::Client,
challenge: &BearerChallenge,
) -> Result<Option<String>> {
let mut query: Vec<(&str, &str)> = Vec::new();
if let Some(service) = &challenge.service {
query.push(("service", service.as_str()));
}
if let Some(scope) = &challenge.scope {
query.push(("scope", scope.as_str()));
}
let resp = client.get(&challenge.realm).query(&query).send().await?;
if !resp.status().is_success() {
return Ok(None);
}
let body: serde_json::Value = match resp.json().await {
Ok(v) => v,
Err(_) => return Ok(None),
};
let token = body
.get("token")
.or_else(|| body.get("access_token"))
.and_then(|v| v.as_str())
// An empty token would just produce `Authorization: Bearer ` and
// a guaranteed re-401; treat it as "no usable token".
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
Ok(token)
}
struct ParsedRef {
host: String,
name: String,
@ -343,36 +199,4 @@ mod tests {
assert_eq!(p.tag, "v1");
assert!(!p.is_insecure);
}
#[test]
fn parses_ghcr_bearer_challenge() {
// The exact header ghcr.io returns for an anonymous manifest GET.
let c = BearerChallenge::parse(
r#"Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:wirenboard/agent-vm-template:pull""#,
)
.unwrap();
assert_eq!(c.realm, "https://ghcr.io/token");
assert_eq!(c.service.as_deref(), Some("ghcr.io"));
assert_eq!(
c.scope.as_deref(),
Some("repository:wirenboard/agent-vm-template:pull")
);
}
#[test]
fn bearer_challenge_realm_only() {
// service/scope are optional; realm is the one hard requirement.
let c = BearerChallenge::parse(r#"Bearer realm="https://auth.docker.io/token""#).unwrap();
assert_eq!(c.realm, "https://auth.docker.io/token");
assert!(c.service.is_none());
assert!(c.scope.is_none());
}
#[test]
fn non_bearer_challenge_rejected() {
// Basic-auth challenge (or a Bearer challenge missing realm) is
// unusable — we can't mint a token, so parse returns None.
assert!(BearerChallenge::parse(r#"Basic realm="registry""#).is_none());
assert!(BearerChallenge::parse(r#"Bearer service="ghcr.io""#).is_none());
}
}

View file

@ -681,50 +681,14 @@ fn write_response(bytes: &[u8]) -> Result<()> {
Ok(())
}
/// Public entry point for an Anthropic OAuth-refresh interception.
///
/// Thin wrapper that injects the real side effects — spawn the host
/// `claude` CLI to rotate the host credential file, then read that
/// file back — and hands the rotated JSON to [`rotate_anthropic`],
/// which holds the actual rotation logic (token-file rewrite +
/// placeholder response synthesis). Keeping the side effects out of
/// `rotate_anthropic` is what makes the rotation path deterministically
/// testable without a live `claude` session or a real expiring token
/// (PLAN.md A1).
fn refresh_anthropic(state_dir: &Path) -> Result<Vec<u8>> {
// Single-flight: serialize host-side rotations for this provider so
// two racing in-guest refreshes don't each spawn `claude -p`. The
// first waiter rotates; the second, on acquiring the lock, finds the
// token file freshly rewritten and skips its own host CLI. The lock
// is Anthropic-specific so a concurrent OpenAI refresh isn't blocked.
let _flight = RefreshLock::acquire(state_dir, secrets::REFRESH_LOCK_ANTHROPIC)?;
if !token_recently_rotated(&secrets::anthropic_token_path(state_dir)) {
trigger_host_refresh("claude", &["-p", "hi", "--model", "sonnet"])?;
}
trigger_host_refresh("claude", &["-p", "hi", "--model", "sonnet"])?;
let host_path = host_claude_creds_path().context("HOME not set")?;
let raw = std::fs::read_to_string(&host_path)
.with_context(|| format!("reading {}", host_path.display()))?;
// Re-wrap with the concrete path so a parse/extract failure in the pure fn
// still names which exact host file was bad (the pure fn uses a fixed label).
rotate_anthropic(state_dir, &raw)
.with_context(|| format!("rotating Anthropic token from {}", host_path.display()))
}
/// Pure rotation step for Anthropic: parse the (already-rotated) host
/// `.credentials.json` text, rewrite the per-project token file with
/// the fresh real bearer, and synthesize the OAuth refresh response
/// that carries *placeholders* (never the real bearer) back to the
/// in-VM agent.
///
/// Split out from [`refresh_anthropic`] so tests can drive a simulated
/// rotation by passing the rotated-file contents directly, with no host
/// CLI spawn and no `$HOME` credential file. Runtime behavior is
/// identical: `refresh_anthropic` calls this with the bytes it just
/// read from the real host file.
fn rotate_anthropic(state_dir: &Path, host_creds_json: &str) -> Result<Vec<u8>> {
let json: Value = serde_json::from_str(host_creds_json)
.context("parsing rotated host .credentials.json")?;
let json: Value = serde_json::from_str(&raw)
.with_context(|| format!("parsing {}", host_path.display()))?;
let oauth = json
.get("claudeAiOauth")
.context("rotated host .credentials.json missing claudeAiOauth")?;
@ -755,46 +719,22 @@ fn rotate_anthropic(state_dir: &Path, host_creds_json: &str) -> Result<Vec<u8>>
Ok(http_200_json(&serde_json::to_vec(&body)?))
}
/// Public entry point for an OpenAI (Codex/ChatGPT) OAuth-refresh
/// interception. Thin wrapper mirroring [`refresh_anthropic`]: spawn
/// the host `codex` CLI to rotate the host auth file, read it back, and
/// hand the contents to [`rotate_openai`] for the testable rotation
/// logic.
fn refresh_openai(state_dir: &Path) -> Result<Vec<u8>> {
// Single-flight (see `refresh_anthropic`): serialize host rotations
// and skip the `codex exec` if the token file was just rewritten by
// the launcher that held the lock before us. OpenAI-specific lock so
// an in-flight Anthropic refresh doesn't serialize against this one.
let _flight = RefreshLock::acquire(state_dir, secrets::REFRESH_LOCK_OPENAI)?;
if !token_recently_rotated(&secrets::openai_token_path(state_dir)) {
trigger_host_refresh(
"codex",
&[
"exec",
"--skip-git-repo-check",
"--dangerously-bypass-approvals-and-sandbox",
"Reply with OK",
],
)?;
}
trigger_host_refresh(
"codex",
&[
"exec",
"--skip-git-repo-check",
"--dangerously-bypass-approvals-and-sandbox",
"Reply with OK",
],
)?;
let host_path = host_codex_auth_path().context("HOME not set")?;
let raw = std::fs::read_to_string(&host_path)
.with_context(|| format!("reading {}", host_path.display()))?;
// Re-wrap with the concrete path so a parse/extract failure in the pure fn
// still names which exact host file was bad (the pure fn uses a fixed label).
rotate_openai(state_dir, &raw)
.with_context(|| format!("rotating OpenAI token from {}", host_path.display()))
}
/// Pure rotation step for OpenAI: parse the (already-rotated) host
/// `codex auth.json` text, rewrite the per-project token file with the
/// fresh real access token, and synthesize the placeholder-carrying
/// OAuth refresh response. Split out from [`refresh_openai`] for the
/// same deterministic-testability reason as [`rotate_anthropic`].
fn rotate_openai(state_dir: &Path, host_auth_json: &str) -> Result<Vec<u8>> {
let json: Value =
serde_json::from_str(host_auth_json).context("parsing rotated host codex auth.json")?;
let json: Value = serde_json::from_str(&raw)
.with_context(|| format!("parsing {}", host_path.display()))?;
let new_access = json
.pointer("/tokens/access_token")
@ -818,301 +758,13 @@ fn rotate_openai(state_dir: &Path, host_auth_json: &str) -> Result<Vec<u8>> {
Ok(http_200_json(&serde_json::to_vec(&body)?))
}
/// How recently a token file must have been rewritten for the
/// single-flight waiter to trust it and skip its own host rotation.
///
/// Tied to [`HOST_REFRESH_TIMEOUT`]: a host `claude -p` / `codex exec`
/// is *allowed* to take up to that long, so a fixed 10 s window would
/// silently no-op in exactly the slow-rotation case the optimization is
/// meant to help — the holder finishes after, say, 25 s, and the waiter
/// then sees an mtime older than 10 s and redundantly re-runs the host
/// CLI even though the token it would read is current. Matching the
/// window to the rotation budget means a just-completed slow rotation is
/// still recognized as fresh. This is safe: the file's mtime is only
/// bumped by an actual successful rotation write, and host access tokens
/// live far longer than 90 s, so we never serve a stale token. A small
/// slack is added so a waiter that wakes slightly after the holder
/// returns still counts the rotation as fresh.
const REFRESH_FRESHNESS_WINDOW: std::time::Duration =
HOST_REFRESH_TIMEOUT.saturating_add(std::time::Duration::from_secs(5));
/// True if `path` exists and was modified within
/// [`REFRESH_FRESHNESS_WINDOW`]. Used by the second single-flight
/// waiter to decide it can re-read the just-rotated token file instead
/// of spawning another host CLI. Any error (missing file, clock skew
/// making mtime appear in the future) conservatively returns `false`
/// so we fall back to actually refreshing.
fn token_recently_rotated(path: &Path) -> bool {
let Ok(meta) = std::fs::metadata(path) else {
return false;
};
let Ok(modified) = meta.modified() else {
return false;
};
match modified.elapsed() {
Ok(age) => age <= REFRESH_FRESHNESS_WINDOW,
Err(_) => false,
}
}
/// Advisory cross-process lock serializing host-side OAuth refreshes
/// for one provider within a single project. Held for the duration of
/// one `refresh_anthropic` / `refresh_openai` call so two in-guest
/// agents (or two launchers) racing the *same* provider's token
/// rotation don't each spawn a host `claude -p` / `codex exec`.
///
/// The lock is keyed per provider (see [`secrets::refresh_lock_path_for`]):
/// an Anthropic rotation and an OpenAI rotation touch independent host
/// artifacts, so they hold different lock files and may run
/// concurrently — only same-provider refreshes serialize.
///
/// Uses a non-blocking `flock(LOCK_EX|LOCK_NB)` polled with a deadline
/// — already available via the `libc` dependency, so no new crate. The
/// lock is associated with the open file description and released
/// automatically when the fd is closed on `Drop` (or if the process
/// dies), so a crashed refresh can't wedge future rotations.
struct RefreshLock {
/// `Some` when we actually hold the flock; `None` when [`acquire`]
/// timed out waiting for a wedged-but-live holder and we degraded
/// to proceeding without serialization (see [`acquire`]). The fd is
/// still kept open in that case so `Drop` is uniform, but no
/// `LOCK_UN` is issued.
file: Option<std::fs::File>,
}
impl RefreshLock {
/// Acquire the per-provider refresh lock named `lock_name` (e.g.
/// [`secrets::REFRESH_LOCK_ANTHROPIC`]).
///
/// Bounded wait: a live-but-wedged holder must not block a waiter
/// indefinitely, which would reintroduce the unbounded stall the
/// [`HOST_REFRESH_TIMEOUT`] cap was added to prevent (review #8).
/// We poll `LOCK_EX|LOCK_NB` until the ceiling, then degrade to
/// proceeding *without* the lock — i.e. the pre-feature behavior of
/// just refreshing. That can cost a redundant host CLI spawn in the
/// rare wedged-holder case, but keeps the whole refresh path time
/// bounded, which matters more.
fn acquire(state_dir: &Path, lock_name: &str) -> Result<Self> {
Self::acquire_with_ceiling(state_dir, lock_name, HOST_REFRESH_TIMEOUT)
}
fn acquire_with_ceiling(
state_dir: &Path,
lock_name: &str,
ceiling: std::time::Duration,
) -> Result<Self> {
let path = secrets::refresh_lock_path_for(state_dir, lock_name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating secrets dir {}", parent.display()))?;
}
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&path)
.with_context(|| format!("opening refresh lock {}", path.display()))?;
use std::os::unix::io::AsRawFd as _;
use std::time::Instant;
let fd = file.as_raw_fd();
let start = Instant::now();
// Poll interval is small relative to a host rotation (seconds);
// the extra wakeups over a ~90 s ceiling are negligible.
let poll = std::time::Duration::from_millis(50);
loop {
let rc = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
if rc == 0 {
return Ok(Self { file: Some(file) });
}
let err = std::io::Error::last_os_error();
match err.raw_os_error() {
Some(libc::EINTR) => continue,
// Held by someone else — wait and retry until the ceiling.
Some(libc::EWOULDBLOCK) => {
if start.elapsed() >= ceiling {
// Degrade to no-lock rather than block forever on
// a wedged-but-live holder. `file: None` so Drop
// issues no LOCK_UN we never took.
tracing::warn!(
lock = %path.display(),
"refresh lock contended past {}s; proceeding without single-flight",
ceiling.as_secs(),
);
return Ok(Self { file: None });
}
std::thread::sleep(poll);
continue;
}
_ => {
return Err(anyhow::Error::new(err)
.context(format!("flock(LOCK_EX|LOCK_NB) on {}", path.display())));
}
}
}
}
}
impl Drop for RefreshLock {
fn drop(&mut self) {
use std::os::unix::io::AsRawFd as _;
// Only unlock if we actually acquired it; a timed-out acquire
// never took the lock, so issuing LOCK_UN would be wrong (and
// could release a lock another fd in this process holds, though
// that doesn't happen here).
if let Some(file) = &self.file {
unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_UN) };
}
}
}
#[cfg(test)]
mod refresh_lock_tests {
use super::*;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn token_recently_rotated_window() {
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("anthropic");
// Missing file -> not fresh.
assert!(!token_recently_rotated(&f));
// Just-written file -> fresh.
std::fs::write(&f, b"tok").unwrap();
assert!(token_recently_rotated(&f));
}
/// Two threads contending the same project lock must run their
/// critical sections one at a time. We track the number of holders
/// inside the locked region and assert it never exceeds one.
#[test]
fn contending_threads_serialize() {
let dir = tempfile::tempdir().unwrap();
let state = dir.path().join("proj");
std::fs::create_dir_all(&state).unwrap();
let in_section = Arc::new(AtomicUsize::new(0));
let max_concurrent = Arc::new(AtomicUsize::new(0));
let acquisitions = Arc::new(AtomicUsize::new(0));
let spawn = |state: std::path::PathBuf,
in_section: Arc<AtomicUsize>,
max_concurrent: Arc<AtomicUsize>,
acquisitions: Arc<AtomicUsize>| {
std::thread::spawn(move || {
for _ in 0..20 {
let _guard = RefreshLock::acquire(&state, secrets::REFRESH_LOCK_ANTHROPIC)
.expect("acquire");
acquisitions.fetch_add(1, Ordering::SeqCst);
let now = in_section.fetch_add(1, Ordering::SeqCst) + 1;
// Record the peak observed concurrency.
max_concurrent.fetch_max(now, Ordering::SeqCst);
// Hold briefly to widen the race window.
std::thread::sleep(std::time::Duration::from_millis(1));
in_section.fetch_sub(1, Ordering::SeqCst);
}
})
};
let t1 = spawn(
state.clone(),
in_section.clone(),
max_concurrent.clone(),
acquisitions.clone(),
);
let t2 = spawn(
state.clone(),
in_section.clone(),
max_concurrent.clone(),
acquisitions.clone(),
);
t1.join().unwrap();
t2.join().unwrap();
assert_eq!(acquisitions.load(Ordering::SeqCst), 40);
assert_eq!(
max_concurrent.load(Ordering::SeqCst),
1,
"flock failed to serialize: two holders entered the critical section at once"
);
}
/// Different providers use different lock files, so a holder of the
/// Anthropic lock must not block an OpenAI acquire (and vice versa).
#[test]
fn distinct_providers_do_not_contend() {
let dir = tempfile::tempdir().unwrap();
let state = dir.path().join("proj");
std::fs::create_dir_all(&state).unwrap();
let anthropic = RefreshLock::acquire(&state, secrets::REFRESH_LOCK_ANTHROPIC)
.expect("anthropic acquire");
// Holding the Anthropic lock, an OpenAI acquire must succeed
// immediately (not time out, not degrade) — it's a different file.
let openai = RefreshLock::acquire_with_ceiling(
&state,
secrets::REFRESH_LOCK_OPENAI,
std::time::Duration::from_millis(50),
)
.expect("openai acquire");
// Both genuinely hold their locks.
assert!(openai.file.is_some(), "openai should hold its own lock");
drop(anthropic);
drop(openai);
}
/// A live-but-wedged holder must not block a waiter past the
/// ceiling: the second acquire returns within the bound and degrades
/// to the no-lock state instead of hanging forever.
#[test]
fn bounded_wait_degrades_when_holder_wedged() {
let dir = tempfile::tempdir().unwrap();
let state = dir.path().join("proj");
std::fs::create_dir_all(&state).unwrap();
// First holder keeps the lock for the whole test.
let _held = RefreshLock::acquire(&state, secrets::REFRESH_LOCK_ANTHROPIC)
.expect("first acquire");
let ceiling = std::time::Duration::from_millis(200);
let start = std::time::Instant::now();
let second = RefreshLock::acquire_with_ceiling(
&state,
secrets::REFRESH_LOCK_ANTHROPIC,
ceiling,
)
.expect("second acquire returns Ok (degraded)");
let waited = start.elapsed();
// Returned within a small multiple of the ceiling (not blocked
// indefinitely), and degraded to no-lock.
assert!(
waited < ceiling * 4,
"acquire blocked {waited:?}, expected ~{ceiling:?}"
);
assert!(
second.file.is_none(),
"contended acquire past ceiling must degrade to the no-lock state"
);
}
}
/// Bound on how long we'll wait for a host `claude -p` / `codex exec`
/// to drive a token rotation. A hung host CLI must not keep the in-VM
/// agent's OAuth refresh waiting indefinitely (review #8). 90 s is
/// enough for normal claude/codex round-trips and small enough to
/// surface a problem before the guest agent's own timeout fires.
///
/// Shared so the single-flight lock-wait ceiling
/// ([`RefreshLock::acquire`]) and the freshness window
/// ([`REFRESH_FRESHNESS_WINDOW`]) stay tied to the actual rotation
/// budget rather than drifting from it.
const HOST_REFRESH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(90);
fn trigger_host_refresh(cmd: &str, args: &[&str]) -> Result<()> {
// Bounded wait so a hung host CLI doesn't keep the in-VM agent's
// OAuth refresh waiting indefinitely (review #8). 90 s is enough
// for normal claude/codex round-trips and small enough to surface
// a problem before the guest agent's own timeout fires.
use std::time::{Duration, Instant};
const TIMEOUT: Duration = HOST_REFRESH_TIMEOUT;
const TIMEOUT: Duration = Duration::from_secs(90);
let mut child = Command::new(cmd)
.args(args)
@ -1960,7 +1612,6 @@ mod tests {
query_params: false,
body: false,
},
on_violation: None,
require_tls_identity: true,
}],
on_violation: Default::default(),
@ -2183,231 +1834,3 @@ mod tests {
);
}
}
// ─── mid-session token-rotation regression tests (PLAN.md A1) ───────────
//
// The OAuth-refresh MITM exists but had never been exercised across a
// real token-expiry boundary — true e2e needs a long live session and a
// real expiring token, infeasible in CI / the dev sandbox. Instead we
// drive the rotation logic deterministically: `refresh_{anthropic,openai}`
// are thin wrappers that spawn the host CLI and read the rotated host
// credential file, then delegate to the pure `rotate_{anthropic,openai}`
// step. These tests call the pure step directly with a simulated rotated
// host file, then assert the two invariants that matter:
//
// (1) the per-project token file is rewritten to the NEW real bearer
// (so the proxy substitutes the fresh token on the next request);
// (2) the synthesized HTTP refresh response carries only PLACEHOLDERS
// in access_token / refresh_token — never the real bearer — and is
// a well-formed HTTP/1.1 200 with the expected headers.
#[cfg(test)]
mod rotation_tests {
use super::*;
/// Minimal stdlib temp dir; avoids a dev-dependency. Unique per call
/// via pid + a process-global counter, cleaned up on drop.
struct TmpDir(PathBuf);
impl TmpDir {
fn new(tag: &str) -> Self {
use std::sync::atomic::{AtomicU32, Ordering};
static N: AtomicU32 = AtomicU32::new(0);
let n = N.fetch_add(1, Ordering::Relaxed);
let mut p = std::env::temp_dir();
p.push(format!("agentvm-rot-{tag}-{}-{n}", std::process::id()));
std::fs::create_dir_all(&p).unwrap();
TmpDir(p)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TmpDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// Split a synthesized HTTP/1.1 response into (status_line, headers,
/// body). Asserts a single CRLFCRLF separator exists.
fn split_http(resp: &[u8]) -> (String, String, String) {
let s = std::str::from_utf8(resp).expect("response is UTF-8");
let sep = s.find("\r\n\r\n").expect("response has header/body separator");
let head = &s[..sep];
let body = &s[sep + 4..];
let line_end = head.find("\r\n").unwrap_or(head.len());
(
head[..line_end].to_string(),
head.to_string(),
body.to_string(),
)
}
// ── Anthropic ─────────────────────────────────────────────────
const NEW_BEARER_ANTHROPIC: &str =
"sk-ant-oat01-ROTATED-NEW-anthropic-bearer-value-do-not-leak";
fn rotated_anthropic_creds() -> String {
json!({
"claudeAiOauth": {
"accessToken": NEW_BEARER_ANTHROPIC,
"refreshToken": "sk-ant-ort01-rotated-refresh",
"expiresAt": 9_999_999_999_000i64,
"scopes": ["user:inference", "user:profile"],
}
})
.to_string()
}
#[test]
fn anthropic_rotation_rewrites_token_file_to_new_bearer() {
let tmp = TmpDir::new("anthropic-file");
let resp = rotate_anthropic(tmp.path(), &rotated_anthropic_creds())
.expect("rotate_anthropic should succeed");
assert!(!resp.is_empty(), "response must not be empty");
// (1) Per-project token file rewritten to the NEW real bearer.
let token_file = secrets::anthropic_token_path(tmp.path());
let written = std::fs::read_to_string(&token_file)
.expect("token file should have been written");
assert_eq!(
written, NEW_BEARER_ANTHROPIC,
"anthropic token file must hold the freshly-rotated real bearer"
);
}
#[test]
fn anthropic_rotation_response_carries_placeholders_not_real_bearer() {
let tmp = TmpDir::new("anthropic-resp");
let resp = rotate_anthropic(tmp.path(), &rotated_anthropic_creds())
.expect("rotate_anthropic should succeed");
let (status, headers, body) = split_http(&resp);
// Well-formed status line + headers.
assert_eq!(status, "HTTP/1.1 200 OK", "status line");
assert!(
headers.contains("Content-Type: application/json"),
"Content-Type header present: {headers:?}"
);
assert!(
headers.contains(&format!("Content-Length: {}", body.len())),
"Content-Length matches body ({} bytes): {headers:?}",
body.len()
);
assert!(
headers.contains("Connection: close"),
"Connection: close present: {headers:?}"
);
// (2) The real bearer must NEVER appear anywhere in the response.
assert!(
!String::from_utf8_lossy(&resp).contains(NEW_BEARER_ANTHROPIC),
"real bearer leaked into the refresh response"
);
// Body's token fields are the placeholders, verbatim.
let parsed: Value = serde_json::from_str(&body).expect("body is JSON");
assert_eq!(
parsed["access_token"], secrets::ANTHROPIC_ACCESS_PLACEHOLDER,
"access_token must be the placeholder"
);
assert_eq!(
parsed["refresh_token"], secrets::ANTHROPIC_REFRESH_PLACEHOLDER,
"refresh_token must be the placeholder"
);
assert_eq!(parsed["token_type"], "Bearer");
// expires_in derived from expiresAt: far-future → positive.
assert!(
parsed["expires_in"].as_i64().unwrap() > 0,
"expires_in should be positive"
);
}
// ── OpenAI / Codex ────────────────────────────────────────────
const NEW_BEARER_OPENAI: &str =
"eyJROTATED.openai.access.token.value.do.not.leak";
fn rotated_openai_auth() -> String {
json!({
"tokens": {
"access_token": NEW_BEARER_OPENAI,
"refresh_token": "rotated-openai-refresh",
"id_token": "rotated-openai-id",
},
"OPENAI_API_KEY": null,
})
.to_string()
}
#[test]
fn openai_rotation_rewrites_token_file_to_new_bearer() {
let tmp = TmpDir::new("openai-file");
let resp = rotate_openai(tmp.path(), &rotated_openai_auth())
.expect("rotate_openai should succeed");
assert!(!resp.is_empty());
let token_file = secrets::openai_token_path(tmp.path());
let written = std::fs::read_to_string(&token_file)
.expect("token file should have been written");
assert_eq!(
written, NEW_BEARER_OPENAI,
"openai token file must hold the freshly-rotated real access token"
);
}
#[test]
fn openai_rotation_response_carries_placeholders_not_real_bearer() {
let tmp = TmpDir::new("openai-resp");
let resp = rotate_openai(tmp.path(), &rotated_openai_auth())
.expect("rotate_openai should succeed");
let (status, headers, body) = split_http(&resp);
assert_eq!(status, "HTTP/1.1 200 OK", "status line");
assert!(headers.contains("Content-Type: application/json"));
assert!(headers.contains(&format!("Content-Length: {}", body.len())));
assert!(headers.contains("Connection: close"));
assert!(
!String::from_utf8_lossy(&resp).contains(NEW_BEARER_OPENAI),
"real access token leaked into the refresh response"
);
let parsed: Value = serde_json::from_str(&body).expect("body is JSON");
assert_eq!(parsed["access_token"], secrets::OPENAI_ACCESS_PLACEHOLDER);
assert_eq!(parsed["refresh_token"], secrets::OPENAI_REFRESH_PLACEHOLDER);
assert_eq!(parsed["id_token"], secrets::OPENAI_ID_PLACEHOLDER);
assert_eq!(parsed["token_type"], "Bearer");
}
/// The legacy ChatGPT/Codex shape stores the key flat as
/// `OPENAI_API_KEY` (no `tokens` object). Rotation must pick it up.
#[test]
fn openai_rotation_falls_back_to_flat_api_key() {
let tmp = TmpDir::new("openai-flat");
let auth = json!({ "OPENAI_API_KEY": NEW_BEARER_OPENAI }).to_string();
let resp = rotate_openai(tmp.path(), &auth).expect("rotate_openai should succeed");
let token_file = secrets::openai_token_path(tmp.path());
let written = std::fs::read_to_string(&token_file).unwrap();
assert_eq!(written, NEW_BEARER_OPENAI);
assert!(!String::from_utf8_lossy(&resp).contains(NEW_BEARER_OPENAI));
}
/// Malformed rotated host files surface an error rather than writing
/// a garbage token file or a malformed response.
#[test]
fn rotation_errors_on_malformed_host_file() {
let tmp = TmpDir::new("malformed");
assert!(rotate_anthropic(tmp.path(), "not json").is_err());
assert!(rotate_openai(tmp.path(), "not json").is_err());
assert!(
rotate_anthropic(tmp.path(), &json!({"claudeAiOauth": {}}).to_string()).is_err(),
"missing accessToken must error"
);
assert!(
rotate_openai(tmp.path(), &json!({"tokens": {}}).to_string()).is_err(),
"missing access_token must error"
);
}
}

View file

@ -18,23 +18,8 @@ mod setup;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
// Shown under the top-level `agent-vm --help`, after the command list.
const TOP_AFTER_HELP: &str = "\
Getting started:
agent-vm setup fetch and verify the base image (run once first)
cd ~/your-project
agent-vm claude launch in this project or codex / opencode / copilot / shell
claude, codex, opencode, copilot and shell share the same options;
see `agent-vm claude --help` for mounts, ports, networking and credentials.";
#[derive(Parser)]
#[command(
name = "agent-vm",
version,
about = "Sandboxed microVMs for AI coding agents.",
after_help = TOP_AFTER_HELP
)]
#[command(name = "agent-vm", about = "Sandboxed VMs for AI coding agents.")]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
@ -42,28 +27,26 @@ struct Cli {
#[derive(Subcommand)]
enum Cmd {
/// Pull and verify the base image (run once first).
/// Build (and verify) the agent-vm base image.
Setup(setup::Args),
/// Refresh the cached base image.
/// Pull the latest image from the registry into the microsandbox cache.
Pull(pull::Args),
/// Launch Claude Code in a per-project sandbox.
/// Launch Claude Code in a sandbox mounted at the project's host path.
Claude(run::Args),
/// Launch Codex CLI in a per-project sandbox.
/// Launch Codex CLI in a sandbox mounted at the project's host path.
Codex(run::Args),
/// Launch OpenCode in a per-project sandbox.
/// Launch OpenCode in a sandbox mounted at the project's host path.
Opencode(run::Args),
/// Launch GitHub Copilot CLI in a per-project sandbox.
Copilot(run::Args),
/// Open a bash shell in a per-project sandbox.
/// Open a bash shell in a sandbox mounted at the project's host path.
Shell(run::Args),
/// Exchange a string between the host and the sandbox.
/// Exchange a string between the host and the per-project sandbox.
/// See `agent-vm clipboard --help`.
Clipboard(clipboard::Args),
/// Internal: invoked by msb's interceptor hook for matched OAuth
@ -107,7 +90,6 @@ fn main() -> Result<()> {
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::Copilot(args) => exit_with(run::launch(run::Agent::Copilot, 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,

View file

@ -24,12 +24,11 @@ use microsandbox::{Sandbox, sandbox::PullPolicy};
#[derive(ClapArgs)]
pub struct Args {
/// Override the image reference.
///
/// Defaults to `ghcr.io/wirenboard/agent-vm-template:latest` or the
/// value of `AGENT_VM_IMAGE_TAG`. Use a timestamped tag
/// Override the image reference. Defaults to
/// `ghcr.io/wirenboard/agent-vm-template: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", value_name = "REF")]
#[arg(long, env = "AGENT_VM_IMAGE_TAG")]
image: Option<String>,
}

File diff suppressed because it is too large Load diff

View file

@ -20,8 +20,7 @@ use serde_json::Value;
use sha2::{Digest, Sha256};
use crate::host_paths::{
atomic_write, host_claude_creds_path, host_codex_auth_path, host_copilot_token_path,
host_opencode_auth_path,
atomic_write, host_claude_creds_path, host_codex_auth_path, host_opencode_auth_path,
};
// ---------------------------------------------------------------------------
@ -84,17 +83,6 @@ pub const OPENCODE_OPENAI_REFRESH_PLACEHOLDER: &str = "msb-opencode-placeholder-
/// git credential helper sees this string; the proxy substitutes the
/// real bearer on outbound traffic to GitHub.
pub const GH_TOKEN_PLACEHOLDER: &str = "msb-gh-placeholder-v2";
/// Placeholder for the host's GitHub Copilot token. The in-guest
/// Copilot CLI sees this string (exported as `COPILOT_GITHUB_TOKEN`
/// via `/etc/profile.d` and stored in `~/.copilot/config.json`); the
/// proxy substitutes the real GitHub OAuth token on outbound traffic
/// to the Copilot API. Kept distinct from `GH_TOKEN_PLACEHOLDER` so
/// substituting one can't accidentally rewrite the other in unrelated
/// request bytes — even though both happen to resolve to the same
/// host gh token, they're registered against different allow-host
/// sets (GitHub API vs Copilot API). Mirrors the original Bash
/// agent-vm's `placeholder-copilot-token-injected-by-proxy`.
pub const COPILOT_TOKEN_PLACEHOLDER: &str = "msb-copilot-placeholder-v2";
// Hostnames the secret-substitution proxy + interceptor key off. Kept
// here so the launcher (`run.rs`), the hook (`intercept_hook`), and any
@ -117,15 +105,6 @@ pub const GITHUB_CODELOAD_HOST: &str = "codeload.github.com";
pub const GITHUB_RAW_HOST: &str = "raw.githubusercontent.com";
pub const GITHUB_OBJECTS_HOST: &str = "objects.githubusercontent.com";
/// GitHub Copilot API endpoints. The Copilot CLI sends
/// `Authorization: Bearer <token>` to these; the proxy substitutes
/// [`COPILOT_TOKEN_PLACEHOLDER`] for the real GitHub OAuth token.
/// Both are needed: `api.githubcopilot.com` for business/enterprise
/// seats and `api.individual.githubcopilot.com` for individual ones.
/// Mirrors the original Bash agent-vm's credential-proxy domains.
pub const COPILOT_API_HOST: &str = "api.githubcopilot.com";
pub const COPILOT_API_INDIVIDUAL_HOST: &str = "api.individual.githubcopilot.com";
pub const ANTHROPIC_OAUTH_TOKEN_PATH: &str = "/v1/oauth/token";
pub const OPENAI_OAUTH_TOKEN_PATH: &str = "/oauth/token";
@ -146,30 +125,6 @@ pub struct CredsState {
/// on outbound traffic to GitHub. Only `Some` when the user has
/// `gh` logged in *and* `--no-git` was not passed.
pub gh_token_file: Option<PathBuf>,
/// File holding the host's GitHub Copilot token (a GitHub OAuth
/// token with Copilot access). The proxy substitutes
/// `COPILOT_TOKEN_PLACEHOLDER` for this on outbound traffic to the
/// Copilot API hosts. Sourced from the original Bash agent-vm's
/// device-flow cache (`~/.cache/claude-vm/copilot-token.json`) if
/// present, else falls back to the captured `gh auth token` (a gh
/// login with the Copilot scope works against the Copilot API).
///
/// `Some` whenever a usable token was found and either the Copilot
/// agent is being launched (`want_copilot`) or GitHub egress is
/// already enabled for another agent (`use_github`). Crucially this
/// is NOT gated on `--no-git` for a Copilot launch: the Copilot API
/// is not repo-scoped, so `agent-vm copilot` must work even in a
/// non-GitHub project.
///
/// **Known limitation (no in-session refresh):** unlike the
/// Anthropic/OpenAI access tokens, the Copilot token is not
/// round-tripped through `intercept_hook`'s OAuth-refresh path. If
/// the captured token expires mid-session, the proxy keeps
/// substituting the stale value and Copilot requests start failing
/// with 401 until the next `agent-vm` launch re-captures from the
/// host. gh user tokens are typically long-lived, so the practical
/// impact is low; re-launch to recover.
pub copilot_token_file: Option<PathBuf>,
pub snapshot: Option<HostCredsSnapshot>,
}
@ -219,71 +174,6 @@ pub fn gh_token_path(state_dir: &Path) -> PathBuf {
host_secret_dir(state_dir).join("gh")
}
/// Per-provider advisory lock file serializing host-side OAuth refreshes
/// for one provider within a single project. The in-VM agent's
/// `_intercept-hook` acquires an exclusive `flock` on this path before
/// spawning a host `claude -p` / `codex exec` to drive a token rotation,
/// so two racing in-guest refreshes of the *same* provider don't each
/// launch their own host CLI.
///
/// Keyed per provider (`name` is e.g. [`REFRESH_LOCK_ANTHROPIC`]) so a
/// concurrent Anthropic and OpenAI in-guest refresh don't serialize
/// against each other — they rotate independent host credential files
/// and write distinct token files, so there is no shared state to guard
/// across providers. Lives in the host-only [`host_secret_dir`] (never
/// bind-mounted into the guest), alongside the token files the refresh
/// rewrites.
///
/// Note: the launcher's [`refresh`] uses a *different*, single shared
/// lock ([`ProjectRefreshLock`]) because it does read-modify-write on
/// per-project state files shared across all providers (`claude.json`,
/// `claude/settings.json`, `opencode-config/opencode.json`), so its
/// critical section genuinely spans every provider.
pub fn refresh_lock_path_for(state_dir: &Path, name: &str) -> PathBuf {
host_secret_dir(state_dir).join(name)
}
/// Lock basename for the Anthropic in-guest refresh single-flight.
pub const REFRESH_LOCK_ANTHROPIC: &str = ".refresh.anthropic.lock";
/// Lock basename for the OpenAI in-guest refresh single-flight.
pub const REFRESH_LOCK_OPENAI: &str = ".refresh.openai.lock";
#[cfg(test)]
mod refresh_lock_tests {
use super::*;
#[test]
fn per_provider_lock_paths_are_distinct_and_in_secret_dir() {
let state = Path::new("/home/u/.cache/agent-vm/abc123");
let anthropic = refresh_lock_path_for(state, REFRESH_LOCK_ANTHROPIC);
let openai = refresh_lock_path_for(state, REFRESH_LOCK_OPENAI);
// Two providers must not share a lock file.
assert_ne!(anthropic, openai);
// Expected concrete paths in the sibling `<name>.secrets/` dir.
assert_eq!(
anthropic,
Path::new("/home/u/.cache/agent-vm/abc123.secrets/.refresh.anthropic.lock")
);
assert_eq!(
openai,
Path::new("/home/u/.cache/agent-vm/abc123.secrets/.refresh.openai.lock")
);
// Both live in the host-only secrets dir, never under state_dir
// (which is bind-mounted into the guest).
assert_eq!(anthropic.parent(), anthropic_token_path(state).parent());
assert_eq!(openai.parent(), anthropic_token_path(state).parent());
assert!(!anthropic.starts_with(state));
assert!(!openai.starts_with(state));
}
}
/// Per-project location of the Copilot token file the proxy re-reads.
/// Lives in the host-only [`host_secret_dir`], never inside the guest
/// mount (it holds the real GitHub OAuth token).
pub fn copilot_token_path(state_dir: &Path) -> PathBuf {
host_secret_dir(state_dir).join("copilot")
}
/// OpenCode reuses the same OpenAI access token file: both Codex and
/// OpenCode hit api.openai.com / chatgpt.com and the proxy substitutes
/// each provider's distinct placeholder string for the same real
@ -311,7 +201,6 @@ pub fn refresh(
state_dir: &Path,
project_guest_path: &str,
use_github: bool,
want_copilot: bool,
) -> Result<CredsState> {
let _lock = ProjectRefreshLock::acquire(state_dir)
.context("acquiring per-project refresh lock")?;
@ -335,7 +224,7 @@ pub fn refresh(
// First-run bypasses, run regardless of whether the user has host
// credentials for the provider. Without these the in-VM agent
// blocks on a terminal-style wizard at first launch.
write_agent_config_defaults(state_dir, project_guest_path, want_copilot)?;
write_agent_config_defaults(state_dir, project_guest_path)?;
let anthropic_token_file = refresh_anthropic(state_dir).unwrap_or_else(|e| {
tracing::warn!(error = %e, "anthropic credential refresh failed; skipping");
@ -381,34 +270,6 @@ pub fn refresh(
None
};
// D1: capture the host's GitHub Copilot token. Prefer the
// device-flow cache the original Bash agent-vm wrote
// (`~/.cache/claude-vm/copilot-token.json`); fall back to the
// `gh auth token` we just captured (a gh login carries the
// Copilot scope for users with a Copilot seat).
//
// Unlike the gh capture, this is NOT gated on `use_github`. The
// Copilot API is reached with a GitHub OAuth token, but it is not
// repo-scoped the way `api.github.com` push is — so the reason to
// run `agent-vm copilot` (GitHub-backed AI) must not be switched off
// just because the project has no detected GitHub remote or the user
// passed `--no-git`. We capture the token whenever the Copilot agent
// is the one being launched (`want_copilot`), or when GitHub egress
// is already enabled for another agent (`use_github`) so an existing
// gh login still flows through. When `want_copilot && !use_github`
// there is no gh fallback token, so capture succeeds only via the
// device-flow cache; the caller surfaces a clear error if nothing
// was obtained rather than letting the guest send an unsubstituted
// placeholder bearer.
let copilot_token_file = if use_github || want_copilot {
refresh_copilot(state_dir, gh_token_file.as_deref()).unwrap_or_else(|e| {
tracing::warn!(error = %e, "copilot credential capture failed; skipping");
None
})
} else {
None
};
// SHA-256 snapshot of host credential files for post-run mutation
// detection. Phase 4's refresh hook *legitimately* rewrites these;
// anything else doing so is a bug to investigate. See
@ -420,7 +281,6 @@ pub fn refresh(
openai_token_file,
opencode_openai_access_token_file,
gh_token_file,
copilot_token_file,
snapshot,
})
}
@ -451,76 +311,10 @@ pub struct HostGitIdentity {
/// which makes git refuse to commit rather than silently attribute to
/// `agent-vm`.
pub fn discover_host_git_identity() -> Option<HostGitIdentity> {
// `gh api user` is an HTTPS round-trip to api.github.com that costs
// ~0.31.3s and runs on the *pre-boot critical path* of every
// launch — yet the answer (your name/email/login) almost never
// changes. Cache the resolved identity with a short TTL so only the
// first launch in the window pays the network cost; subsequent
// launches resolve it instantly. The cache holds only display-level
// strings (already validated by `is_config_safe`), never tokens.
if let Some(id) = read_identity_cache() {
if let Some(id) = gh_api_user_identity() {
return Some(id);
}
let id = gh_api_user_identity().or_else(host_git_config_identity);
if let Some(ref id) = id {
write_identity_cache(id);
}
id
}
/// TTL for the cached host git identity. Long enough to take the
/// `gh api user` round-trip off essentially every launch, short enough
/// that switching `gh` accounts / editing `git config user.*` is
/// reflected the same day.
const GIT_IDENTITY_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(24 * 3600);
fn identity_cache_path() -> Option<PathBuf> {
Some(
crate::host_paths::state_root()?
.join("cache")
.join("host-git-identity"),
)
}
/// Read the cached identity if present and fresher than
/// [`GIT_IDENTITY_CACHE_TTL`]. Re-validates with [`is_config_safe`] so a
/// tampered cache file can't smuggle gitconfig sections into the guest.
fn read_identity_cache() -> Option<HostGitIdentity> {
let p = identity_cache_path()?;
let age = std::fs::metadata(&p).ok()?.modified().ok()?.elapsed().ok()?;
if age > GIT_IDENTITY_CACHE_TTL {
return None;
}
let data = std::fs::read_to_string(&p).ok()?;
let mut lines = data.lines();
let name = lines.next()?.to_string();
let email = lines.next()?.to_string();
let gh = lines.next().unwrap_or("");
if name.is_empty() || email.is_empty() || !is_config_safe(&name) || !is_config_safe(&email) {
return None;
}
Some(HostGitIdentity {
name,
email,
gh_login: (!gh.is_empty()).then(|| gh.to_string()),
})
}
/// Persist the resolved identity (best-effort; cache misses are cheap).
fn write_identity_cache(id: &HostGitIdentity) {
let Some(p) = identity_cache_path() else {
return;
};
if let Some(parent) = p.parent() {
let _ = std::fs::create_dir_all(parent);
}
let body = format!(
"{}\n{}\n{}\n",
id.name,
id.email,
id.gh_login.as_deref().unwrap_or("")
);
let _ = atomic_write(&p, body.as_bytes(), 0o600);
host_git_config_identity()
}
/// Cap on how long we'll wait for `gh api user`. The call is an HTTPS
@ -729,71 +523,6 @@ fn refresh_gh(state_dir: &Path) -> Result<Option<PathBuf>> {
Ok(Some(token_file))
}
/// Parse the original Bash agent-vm's Copilot token cache file. The
/// cache is JSON `{"access_token": "<gho_…>"}` (see
/// `copilot_token.py`); return the non-empty `access_token` string.
/// Split out so it can be unit-tested without touching `$HOME`.
fn extract_copilot_token(raw: &str) -> Option<String> {
let json: Value = serde_json::from_str(raw).ok()?;
let tok = json.get("access_token").and_then(|v| v.as_str())?.trim();
if tok.is_empty() {
None
} else {
Some(tok.to_string())
}
}
/// Capture the host's GitHub Copilot token into a 0600 file under
/// `<state>.secrets/copilot`. The proxy substitutes
/// [`COPILOT_TOKEN_PLACEHOLDER`] for this file's content on outbound
/// traffic to the Copilot API.
///
/// Two sources, in priority order:
/// 1. The device-flow cache the original Bash agent-vm wrote at
/// `~/.cache/claude-vm/copilot-token.json` (JSON
/// `{"access_token": …}`). Reusing it means an existing host
/// Copilot login is honoured without re-running the OAuth device
/// flow inside this Rust launcher.
/// 2. The `gh auth token` we already captured this launch
/// (`gh_token_file`). A gh user OAuth token carries the Copilot
/// scope for accounts with a Copilot seat, so it works against
/// `api.githubcopilot.com`.
///
/// Returns `None` (non-fatal) when neither source yields a token —
/// Copilot is then simply unavailable in the guest, same as the
/// original's "could not obtain Copilot token" warning.
fn refresh_copilot(state_dir: &Path, gh_token_file: Option<&Path>) -> Result<Option<PathBuf>> {
// 1. Device-flow cache from the original Bash agent-vm.
if let Some(cache) = host_copilot_token_path() {
match std::fs::read_to_string(&cache) {
Ok(raw) => {
if let Some(token) = extract_copilot_token(&raw) {
let token_file = copilot_token_path(state_dir);
atomic_write(&token_file, token.as_bytes(), 0o600)?;
return Ok(Some(token_file));
}
// Present but unparseable/empty → fall through to gh.
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e).with_context(|| format!("reading {}", cache.display())),
}
}
// 2. Fall back to the gh token captured this launch.
if let Some(gh_file) = gh_token_file {
let token = std::fs::read_to_string(gh_file)
.with_context(|| format!("reading {}", gh_file.display()))?;
let token = token.trim();
if !token.is_empty() {
let token_file = copilot_token_path(state_dir);
atomic_write(&token_file, token.as_bytes(), 0o600)?;
return Ok(Some(token_file));
}
}
Ok(None)
}
/// SHA-256 the three host credential files. Files that don't exist or
/// can't be read are recorded as `None` — only files that successfully
/// hash become anchors for [`verify_snapshot`].
@ -846,11 +575,7 @@ fn hash_file(path: &Path) -> Option<String> {
/// trust/approval settings) into the per-project state dir. Idempotent
/// across launches; merges instead of overwrites so user tweaks
/// survive.
fn write_agent_config_defaults(
state_dir: &Path,
project_guest_path: &str,
want_copilot: bool,
) -> Result<()> {
fn write_agent_config_defaults(state_dir: &Path, project_guest_path: &str) -> Result<()> {
let claude_dir = state_dir.join("claude");
std::fs::create_dir_all(&claude_dir)?;
write_default_claude_settings(&claude_dir.join("settings.json"))?;
@ -880,24 +605,6 @@ fn write_agent_config_defaults(
std::fs::create_dir_all(&opencode_config_dir)?;
write_default_opencode_config(&opencode_config_dir.join("opencode.json"))?;
// D1: GitHub Copilot CLI reads ~/.copilot/config.json. The
// launcher symlinks /root/.copilot → /agent-vm-state/copilot so
// this file lands at the right path inside the guest. The token
// field is a placeholder; the proxy substitutes the real one
// outbound (see write_default_copilot_config).
//
// Only written when the Copilot agent is actually selected
// (`want_copilot`). Writing the placeholder `github_token` for a
// claude/codex/opencode/shell session would be dead config at best;
// worse, paired with the matching `COPILOT_GITHUB_TOKEN` env it would
// have the guest emit an unsubstituted bearer if anything in the
// session ever hit the Copilot API without a registered secret.
if want_copilot {
let copilot_dir = state_dir.join("copilot");
std::fs::create_dir_all(&copilot_dir)?;
write_default_copilot_config(&copilot_dir.join("config.json"))?;
}
// Persistent per-project bash history. The launcher symlinks
// /root/.bash_history → /agent-vm-state/bash_history; touching
// the file here ensures the symlink target exists on first
@ -1410,43 +1117,6 @@ fn write_default_opencode_config(path: &Path) -> Result<()> {
Ok(())
}
/// Write the GitHub Copilot CLI's `~/.copilot/config.json`. Two
/// purposes, both mirroring the original Bash agent-vm's
/// `_copilot_vm_setup_home`:
///
/// - `trusted_folders = ["/"]` so the CLI never prompts "do you
/// trust this folder?" — the microVM is the sandbox, so trusting
/// every path inside it is correct.
/// - the token field carries [`COPILOT_TOKEN_PLACEHOLDER`], which
/// the proxy substitutes for the real token on outbound traffic.
/// The CLI also honours the `COPILOT_GITHUB_TOKEN` env var (set by
/// the launcher); writing it here too covers config-first reads.
///
/// Merge-on-existing so a user's own settings survive across launches;
/// only the fields we manage are force-set.
fn write_default_copilot_config(path: &Path) -> Result<()> {
let mut config: Value = match std::fs::read_to_string(path) {
Ok(raw) => serde_json::from_str(&raw).unwrap_or(serde_json::json!({})),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => serde_json::json!({}),
Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
};
let obj = config
.as_object_mut()
.context("copilot config.json is not an object")?;
// Trust everything — the VM itself is the security boundary.
obj.insert(
"trusted_folders".into(),
Value::Array(vec![Value::String("/".into())]),
);
// Placeholder token; the proxy swaps it for the real one outbound.
obj.insert(
"github_token".into(),
Value::String(COPILOT_TOKEN_PLACEHOLDER.into()),
);
atomic_write(path, serde_json::to_vec(&config)?.as_slice(), 0o600)?;
Ok(())
}
/// Advisory exclusive flock on `<state_dir>/.refresh.lock`. Held for
/// the duration of [`refresh`] so two concurrent `agent-vm` launchers
/// in the same project don't interleave reads/writes of the shared
@ -1606,36 +1276,6 @@ mod tests {
}
}
/// D1 regression guard for the per-agent Copilot gating (review
/// finding #5). With neither GitHub egress (`use_github=false`) nor
/// the Copilot agent selected (`want_copilot=false`), `refresh` must
/// not capture a Copilot token — both capture conditions are off, so
/// `copilot_token_file` is `None` by construction, independent of any
/// host/`$HOME`/gh state (this combination skips both `refresh_gh`
/// and `refresh_copilot`). Also re-asserts the security-critical
/// property that the copilot token *path* lives *outside* the
/// guest-bind-mounted state dir, same as the other token files.
#[test]
fn copilot_token_not_captured_without_use_github_or_want_copilot() {
let dir = tempfile::tempdir().unwrap();
let sd = dir.path();
let creds = super::refresh(sd, "/workspace/p", false, false).unwrap();
assert!(
creds.copilot_token_file.is_none(),
"copilot token captured despite use_github=false and want_copilot=false"
);
// Independent of capture: the path the proxy would re-read must
// never be under the guest mount (threat-model invariant).
let cp = copilot_token_path(sd);
assert!(
!cp.starts_with(sd),
"copilot token path {} must not be inside the bind-mounted state dir {}",
cp.display(),
sd.display(),
);
assert_eq!(cp.parent().unwrap().parent(), sd.parent());
}
/// **Placeholder distinctness**. If one placeholder were a
/// substring of another, the secret-substitution proxy would
/// swap the wrong token on outbound bytes — silently corrupting

View file

@ -21,13 +21,12 @@ pub struct Args {
#[arg(long)]
no_verify: bool,
/// Override the image reference.
///
/// Defaults to `ghcr.io/wirenboard/agent-vm-template:latest`.
/// Source-checkout users who built a local image can point at it
/// Override the image reference. Defaults to
/// `ghcr.io/wirenboard/agent-vm-template:latest`. Source-checkout users
/// who built a local image can point at it
/// (`--image localhost:5000/agent-vm-template:latest`) — agent-vm
/// detects local registries and uses plain HTTP.
#[arg(long, env = "AGENT_VM_IMAGE_TAG", value_name = "REF")]
#[arg(long, env = "AGENT_VM_IMAGE_TAG")]
image: Option<String>,
}

View file

@ -3,28 +3,11 @@
# tool itself (that's the Rust binary published as
# @wirenboard/agent-vm on npm).
#
# Contents: Debian 13 slim + the four AI coding agents (Claude
# Code, OpenCode, Codex, GitHub Copilot), the docker engine
# (docker.io + containerd + runc + fuse-overlayfs), network/diagnostic
# CLIs, codestyle-pinned Python linters (black/isort/pylint matching
# `wirenboard/codestyle`), and a clone of `wirenboard/codestyle`
# at /opt/wirenboard/codestyle. Two heavier blocks (WB
# build-essentials family + armhf/arm64 cross toolchains) are
# present in the Dockerfile but COMMENTED OUT for the initial
# roll-out — see the "DEFERRED" RUN blocks below. Other heavy
# Contents: Debian 13 slim + the three AI coding agents (Claude
# Code, OpenCode, Codex), minimum dev tooling, and the docker engine
# (docker.io + containerd + runc + fuse-overlayfs). Other heavy
# extras (LSPs, additional MCP servers, docker-buildx) are
# deliberately deferred too.
#
# Layer policy: the file is ordered so the LAST RUN steps are the
# agent installers (the most frequently updated content). Each is
# keyed on its agent's upstream version via a per-agent
# `AGENT_VERSION_*` ARG (see below), so the hourly cron rebuilds an
# installer layer ONLY when that agent actually released — an
# unchanged build is a pure cache hit. Everything earlier — Debian
# base, chromium, docker engine, Python lint pins, codestyle clone —
# stays cached across rebuilds, so users pulling `:latest` re-download
# only the layer(s) for whichever agent actually changed (and nothing
# at all when none did).
# deliberately deferred.
#
# Published to ghcr.io/wirenboard/agent-vm-template:latest by the
# hourly CI workflow (.github/workflows/build-image.yml). Source-
@ -34,96 +17,19 @@
FROM debian:13-slim
LABEL org.opencontainers.image.title="agent-vm guest template"
LABEL org.opencontainers.image.description="Guest OCI image that the agent-vm tool (npm: @wirenboard/agent-vm) boots inside each per-project microVM. Contains Debian 13 + Claude Code + OpenCode + Codex + GitHub Copilot + diag tooling (ssh/ping/etc.) + codestyle-pinned Python linters + /opt/wirenboard/codestyle. Rebuilt hourly to pick up new agent releases. NOT the agent-vm runtime itself — install that via `npm install -g @wirenboard/agent-vm`."
LABEL org.opencontainers.image.description="Guest OCI image that the agent-vm tool (npm: @wirenboard/agent-vm) boots inside each per-project microVM. Contains Debian 13 + Claude Code + OpenCode + Codex + minimal dev tooling. Rebuilt hourly to pick up new agent releases. NOT the agent-vm runtime itself — install that via `npm install -g @wirenboard/agent-vm`."
# LANG=C.UTF-8: debian:slim ships with NO locale set, leaving the guest in
# C/POSIX — which renders a non-ASCII (e.g. Cyrillic) cwd as `M-P…` meta-
# escapes in bash/readline AND makes locale-driven filesystem encodings
# default to ASCII, so Python/Node/ripgrep mis-handle or error on a non-ASCII
# path even when it mounted fine. C.UTF-8 is always present in glibc (no
# locale-gen needed), ASCII-sorting + English-messages but UTF-8-aware.
# agent-vm's launcher also sets this for every exec; having it in the image
# makes any other use of the image correct too.
ENV DEBIAN_FRONTEND=noninteractive \
HOME=/root \
PATH=/root/.local/bin:/root/.claude/local/bin:/root/.opencode/bin:/usr/local/bin:/usr/bin:/usr/sbin:/bin \
LANG=C.UTF-8
PATH=/root/.local/bin:/root/.claude/local/bin:/root/.opencode/bin:/usr/local/bin:/usr/bin:/usr/sbin:/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
# and tooling additions (build-essential, ssh, etc.) do NOT change
# this — they're additive. See `crates/agent-vm/src/defaults.rs`
# for the matching range in the binary.
# 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
# Conditionally trust a host-supplied CA bundle. Only fires when
# `images/build.sh` is invoked on a host that sits behind a TLS-
# intercept proxy (e.g. agent-vm-inside-agent-vm during local dev,
# or a corporate egress MITM). The build script passes the host CA
# via `--secret id=hostca` IF it exists at
# `/usr/local/share/ca-certificates/microsandbox-ca.crt`. In CI
# (no such file → no secret → empty mount), the `-s` test fails
# and this is a no-op — the production image is unchanged.
#
# `--mount=type=secret` keeps the CA file in the buildkit secret
# store, not in the layer; what IS persisted is the merged
# /etc/ssl/certs/ca-certificates.crt. That's fine: the launcher
# re-imports its per-boot CA at /usr/local/share/ca-certificates/
# at runtime anyway, so a stale build-time CA in the bundle is
# inert (and removed if a user runs update-ca-certificates).
#
# `CA_SHIM_CACHEBUST`: buildkit does NOT include secret content in
# the RUN cache key (by design — secrets shouldn't bake into
# images). So toggling secret presence across builds doesn't
# invalidate this layer on its own. `images/build.sh` passes the
# CA file's mtime (`stat -c %Y …`) when the secret is included, so
# any change to the CA file (or going from "secret absent" → "secret
# present") shifts the ARG and rebuilds. In CI (no secret, empty
# arg) the layer also no-ops and stays cached.
ARG CA_SHIM_CACHEBUST=
RUN --mount=type=secret,id=hostca,target=/tmp/hostca.crt,required=false \
: "${CA_SHIM_CACHEBUST}" \
&& if [ -s /tmp/hostca.crt ]; then \
apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& install -d /usr/local/share/ca-certificates \
&& install -m 0644 /tmp/hostca.crt /usr/local/share/ca-certificates/agent-vm-build.crt \
&& update-ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& echo "==> host CA trusted for this build"; \
fi
# Base apt — interpreters, fetchers, search/text utilities, AND
# network/diagnostic CLIs. Bundling these into the existing base
# layer (instead of spinning up a new layer for "ssh + ping") keeps
# the layer count down: every additional layer costs a small
# per-layer materialize overhead in microsandbox even when nothing
# changes, so we only earn a new layer when we can articulate why
# it'd be invalidated independently.
#
# Network/diag tools rationale (engineers debug WB devices over
# SSH and serial; the in-VM agent often needs to confirm a host is
# reachable before chasing logs):
# - iputils-ping, iputils-tracepath, traceroute, mtr-tiny:
# reachability + per-hop latency.
# - net-tools: legacy `ifconfig`, `netstat`, `route` — still
# muscle-memory for most embedded engineers, and many WB docs
# reference them by name.
# - iproute2: modern `ip`, `ss`. Usually preinstalled, listed
# explicitly so an upstream slimming doesn't silently drop it.
# - openssh-client: `ssh`, `scp`, `sftp`, `ssh-keygen`. No server
# — the agent should never accept inbound connections.
# - sshpass: scripted password-auth SSH (lab fixtures, factory
# test rigs); also used by some WB CI scripts.
# - dnsutils: `dig`, `nslookup`, `host`.
# - netcat-openbsd: `nc` for ad-hoc TCP/UDP poking.
# - tcpdump: rare but invaluable when an HTTP MITM looks wrong.
# - gnupg: needed below to add the GitHub CLI apt key (and any
# user-side apt sources `.agent-vm.runtime.sh` adds).
# - less, vim-tiny, file, unzip, zip, rsync, htop, lsof, strace,
# tmux: standard "I'm in a shell, I need a tool" muscle-memory
# set. Each is <5 MB; the bundle is well under 30 MB.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
@ -133,13 +39,6 @@ RUN apt-get update \
bash \
python3 python3-pip \
ripgrep fd-find \
gnupg \
iputils-ping iputils-tracepath traceroute mtr-tiny \
net-tools iproute2 \
openssh-client sshpass \
dnsutils netcat-openbsd tcpdump \
less vim-tiny file unzip zip rsync \
htop lsof strace tmux \
&& rm -rf /var/lib/apt/lists/*
# Chromium for the chrome-devtools MCP server (Phase 7). The MCP
@ -235,21 +134,6 @@ RUN echo 'chrome:x:9999:9999:Chrome MCP:/home/chrome:/bin/bash' >> /etc/passwd \
' echo "agent-vm-chrome-mcp: cannot cd to /home/chrome -- image misconfigured?" >&2' \
' exit 1' \
'}' \
'# Import the microsandbox MITM CA into the chrome-owned NSS DB so' \
'# chromium trusts the intercept proxy. chromium on Linux ignores' \
'# the system CA bundle (which curl/node use), honouring only its' \
'# built-in roots + this per-user NSS DB. Done here, once, at MCP' \
'# startup -- NOT on every agent-vm launch (previously a ~270ms' \
'# certutil fork in the launcher prelude) -- and skipped when' \
'# chrome is unused (the MCP server is only spawned when enabled).' \
'# The DB is chrome-owned (UID 9999), so drop to chrome to write it.' \
'# Non-fatal: a failure should warn, not kill the MCP.' \
'_ca=/usr/local/share/ca-certificates/microsandbox-ca.crt' \
'if [ -f "$_ca" ]; then' \
' sudo -u chrome -n -- certutil -d sql:/home/chrome/.pki/nssdb -A \' \
' -t C,, -n microsandbox -i "$_ca" \' \
' || echo "agent-vm-chrome-mcp: warning: failed to import microsandbox CA (HTTPS may fail ERR_CERT_AUTHORITY_INVALID)" >&2' \
'fi' \
'exec sudo -u chrome -H -n \' \
' --preserve-env=NODE_EXTRA_CA_CERTS,SSL_CERT_FILE,CURL_CA_BUNDLE,REQUESTS_CA_BUNDLE \' \
' --preserve-env=PATH,HTTP_PROXY,HTTPS_PROXY,NO_PROXY,http_proxy,https_proxy,no_proxy \' \
@ -275,6 +159,27 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
# Pre-warm chrome-devtools-mcp's npm cache as the chrome user so the
# first browser tool call in any sandbox doesn't pay the multi-second
# `npx -y` fetch cost. `--help` exits cleanly after install/version-
# check; it's the cheapest exercise of the full install path.
#
# Pinned to a known-good version. The runtime MCP config in
# `crates/agent-vm/src/secrets.rs::write_default_claude_root_state`
# pins the SAME version — bump both together. Without the pin the
# cache stops being useful the first time upstream cuts a release:
# `npx -y` re-resolves `@latest` against the registry and re-
# downloads under the writable upper layer.
#
# Best-effort: `|| true` because npm-registry transient failure or a
# bad `--help` exit code shouldn't fail the whole `agent-vm setup`.
# A missing cache costs a multi-second fetch on first browser tool
# call; failing the image build is much worse.
#
# Must come AFTER the nodejs RUN above — npx didn't exist before it.
RUN sudo -u chrome -H npx -y chrome-devtools-mcp@1.0.1 --help >/dev/null 2>&1 \
|| echo "==> pre-warm skipped (will lazy-fetch chrome-devtools-mcp at first MCP call)"
# Docker engine — dockerd + cli + containerd + runc.
#
# Why each piece:
@ -314,411 +219,20 @@ RUN apt-get update \
&& printf '%s\n' '{' ' "storage-driver": "fuse-overlayfs"' '}' \
> /etc/docker/daemon.json
# Pre-warm chrome-devtools-mcp's npm cache as the chrome user so the
# first browser tool call in any sandbox doesn't pay the multi-second
# `npx -y` fetch cost. `--help` exits cleanly after install/version-
# check; it's the cheapest exercise of the full install path.
#
# Pinned to a known-good version. The runtime MCP config in
# `crates/agent-vm/src/secrets.rs::write_default_claude_root_state`
# pins the SAME version — bump both together. Without the pin the
# cache stops being useful the first time upstream cuts a release:
# `npx -y` re-resolves `@latest` against the registry and re-
# downloads under the writable upper layer.
#
# Best-effort: `|| true` because npm-registry transient failure or a
# bad `--help` exit code shouldn't fail the whole `agent-vm setup`.
# A missing cache costs a multi-second fetch on first browser tool
# call; failing the image build is much worse.
#
# Must come AFTER the nodejs RUN above — npx didn't exist before it.
RUN sudo -u chrome -H npx -y chrome-devtools-mcp@1.0.1 --help >/dev/null 2>&1 \
|| echo "==> pre-warm skipped (will lazy-fetch chrome-devtools-mcp at first MCP call)"
# Wiren Board C/C++ build essentials — DEFERRED.
#
# These two RUN blocks (build-essential family + armhf/arm64 cross
# toolchains) are commented out for the initial roll-out. They
# account for ~466 MiB compressed (~1.6 GB uncompressed) of the
# original image size estimate; deferring them keeps the first
# tooling-image bump lean while the layer ordering / cron-cache
# wins land. Uncomment to re-enable when WB engineers actually
# need the cross-compile path in-VM (the existing host
# `schroot -c bullseye-amd64-sbuild …` workflow still works).
#
# Sourced from the union of debian/control Build-Depends across
# wb-mqtt-serial, wb-mqtt-mbgate, wb-rules, wb-mqtt-confed and the
# `cpp/local/linux-devenv.sh` / `cpp/ci/clang-*-subdirs.sh` scripts
# in `wirenboard/codestyle`. When uncommenting, also re-add the
# corresponding sanity-check probes for clang-format,
# arm-linux-gnueabihf-gcc, aarch64-linux-gnu-gcc, and
# qemu-arm-static at the bottom of the Dockerfile.
#
# - build-essential: gcc/g++/make/dpkg-dev metapackage.
# - debhelper, devscripts, equivs, dpkg-cross: Debian packaging
# primitives. `debhelper (>= 10)` is mandated by every WB
# debian/control; `equivs` is required for `mk-build-deps -ir`
# (the recommended `linux-devenv.sh` install path).
# - pkg-config: standard build-time discovery.
# - clang-format, clang-tidy: the only formatters/linters WB
# codestyle ships configs for (`cpp/config/.clang-format`,
# `.clang-tidy`). `cpp/ci/clang-format-subdirs.sh` and
# `clang-tidy-subdirs.sh` call them directly.
# - libcurl4-openssl-dev, libgtest-dev, libgmock-dev,
# libmodbus-dev, libsystemd-dev: most-frequent C/C++ deps
# across the WB services (modbus serial gateway, MQTT bridge,
# etc).
# - gcovr: pinned in `python/vscode/.devcontainer/devcontainer.json`
# via the `coverage-gutters` extension; wb-mqtt-serial's
# Build-Depends lists it explicitly.
# - gdb-multiarch: install-listed by `cpp/local/linux-devenv.sh`;
# debugging cross-built armhf/arm64 binaries.
# - cmake, ninja-build: not in every WB repo but common enough
# (firmware repo uses cmake) that excluding them would catch
# people off guard.
# - python3-cups, python3-libgpiod, python3-pycurl: per the
# allowed-c-extensions list in `python/config/pyproject.toml`,
# so pylint can resolve imports in WB Python projects without
# spurious E0401.
# RUN apt-get update \
# && apt-get install -y --no-install-recommends \
# build-essential \
# debhelper devscripts equivs dpkg-cross \
# pkg-config \
# clang-format clang-tidy \
# libcurl4-openssl-dev \
# libgtest-dev libgmock-dev \
# libmodbus-dev \
# libsystemd-dev \
# gcovr \
# gdb-multiarch \
# cmake ninja-build \
# python3-cups python3-libgpiod python3-pycurl \
# && rm -rf /var/lib/apt/lists/*
# Wiren Board cross-build toolchains — armhf + arm64 — DEFERRED.
#
# See the deferment note on the build-essentials block above.
#
# Every flagship WB binary ships for one of these two architectures
# (controllers are armhf, newer hardware is arm64); the canonical
# build command in `codestyle/cpp/vscode/.vscode/tasks.json` is
# `schroot -c bullseye-amd64-sbuild -- DEB_HOST_MULTIARCH=arm-…
# make -j12`, and qemu-user-static is what makes the resulting
# binary runnable on x86_64 for tests.
#
# Kept in a SEPARATE layer from the C/C++ essentials above because
# it's the single biggest discretionary addition (~300 MB
# uncompressed / ~258 MiB compressed). A future Dockerfile.slim
# can keep this commented while uncommenting the build-essentials
# block above.
#
# - crossbuild-essential-armhf, crossbuild-essential-arm64:
# gcc-arm-linux-gnueabihf / gcc-aarch64-linux-gnu meta-packages.
# - qemu-user-static: registers binfmt handlers so an `armhf` or
# `arm64` ELF executes under qemu transparently. Required by
# the `[armhf]/[arm64] Run tests for debug` tasks in codestyle.
# - debootstrap, schroot, sbuild: the canonical WB build path is
# `sbuild` inside a `schroot` named `bullseye-amd64-sbuild`.
# The chroot itself is NOT created here (it's per-host, lives
# at /srv/chroot, and requires the schroot config; the user
# creates it on first need with `sbuild-createchroot`).
# RUN apt-get update \
# && apt-get install -y --no-install-recommends \
# crossbuild-essential-armhf \
# crossbuild-essential-arm64 \
# qemu-user-static \
# debootstrap schroot sbuild \
# && rm -rf /var/lib/apt/lists/*
# Python lint/format toolchain — pinned to the versions in
# `wirenboard/codestyle/python/config/requirements.txt`. Pinning
# matters: codestyle ships a `pyproject.toml` that engineers copy
# into projects, and a black/isort/pylint version skew between
# image and project re-formats files differently → noisy diffs.
#
# `--break-system-packages` because Debian 13 enforces PEP 668 and
# we WANT these system-wide so any in-VM tool finds them. The
# microVM rootfs is throwaway, so there's no system Python we can
# accidentally damage. `--no-cache-dir` shaves ~40 MB off the
# layer (the pip download cache is dead weight in an image).
#
# Versions sourced from
# `codestyle/python/config/requirements.txt` (master). `attrs==23.1.0`
# is per the workaround in `codestyle/python/local/linux-devenv.sh`
# (newer attrs breaks the pinned pylint). `j2cli` is in
# wb-mqtt-serial's Build-Depends and is a one-line install here.
RUN pip3 install --no-cache-dir --break-system-packages \
black==24.2.0 \
isort==5.13.2 \
pylint==3.3.9 \
pytest-cov==2.10.1 \
attrs==23.1.0 \
j2cli
# Shallow-clone `wirenboard/codestyle` so the per-project
# devenv scripts work without each project re-cloning it.
#
# The script `cpp/local/linux-devenv.sh` and its Python sibling
# both expect to be invoked from inside a project as
# `bash ../codestyle/{cpp,python}/local/linux-devenv.sh`, i.e.
# with codestyle as a sibling directory. Having it at a known
# path inside the image lets a project hook (`.agent-vm.runtime.sh`)
# symlink/copy it into place at launch instead of cloning over
# the proxy on every cold start.
#
# `CODESTYLE_REF` build-arg (default `master`) lets a reproducible
# build pin to a specific ref — `git clone --branch` accepts a
# branch or tag name. The hourly CI cron just builds from master,
# matching the previous behavior.
#
# Hard-fails by default: if GitHub is unreachable the resulting
# image is missing the devenv path that several WB workflows rely
# on, and we'd rather not silently ship that. Source-checkout dev
# builds that set `AGENT_INSTALL_SOFT_FAIL=1` (see images/build.sh,
# auto-set on TLS-intercept hosts) downgrade to a warning.
#
# `AGENT_INSTALL_SOFT_FAIL` is also consumed by the three agent
# installers + the sanity check below; declared here at first use
# so a single toggle covers everything that might legitimately fail
# in a dev sandbox but must succeed in CI.
ARG CODESTYLE_REF=master
ARG AGENT_INSTALL_SOFT_FAIL=
RUN git clone --depth=1 --branch "${CODESTYLE_REF}" \
https://github.com/wirenboard/codestyle.git \
/opt/wirenboard/codestyle \
|| { msg="==> wirenboard/codestyle clone FAILED (ref=${CODESTYLE_REF}) — re-clone in-VM with: git clone https://github.com/wirenboard/codestyle.git /opt/wirenboard/codestyle"; \
if [ -n "${AGENT_INSTALL_SOFT_FAIL:-}" ]; then echo "$msg (soft-fail mode)"; else echo "$msg" >&2; exit 1; fi; }
# Per-agent cache keys (the three `ARG AGENT_VERSION_*` below, one
# per installer RUN). The workflow resolves each agent's current
# UPSTREAM version and passes it in, so an installer layer is rebuilt
# only when that agent actually released — not on every hourly cron
# run.
#
# This replaces the old single `AGENT_INSTALL_CACHEBUST=<timestamp>`
# arg, which changed every hour and re-emitted all three
# `curl … install.sh | bash` layers (plus the sanity check) on every
# build. Because those installs aren't reproducible, each rebuild
# produced fresh layer blobs → a brand-new ~200 MiB-of-agent-layers
# image every hour even when no agent version had changed, so
# `:latest` consumers re-downloaded those layers hourly for nothing.
# Keying on the real version makes an unchanged hourly build a pure
# cache hit (identical layer digests → nothing to re-pull). Local
# `images/build.sh` builds leave the defaults empty and get normal
# Docker layer-cache behaviour.
# Shared helper for the three agent installer RUNs below. Downloads
# the upstream `install.sh`, runs it under the requested shell, and
# emits a uniform soft-fail or hard-fail diagnostic depending on
# `AGENT_INSTALL_SOFT_FAIL`. Keeping the policy in one place means
# any future tweak (extra retries, log format, soft-fail discrimina-
# tion) lives in exactly one spot — the three RUNs that call it are
# trivial one-liners.
#
# Why curl-only (no python3-urllib fallback): both curl and python3's
# `ssl` module link against the same system OpenSSL, so a real TLS-
# layer failure (the curl 56 "SSL_read: unexpected eof" we see from
# inside MITM-proxied dev sandboxes) hits the fallback identically.
# `--retry 5 --retry-all-errors --http1.1` covers the transient cases
# that ARE recoverable. The MITM-sandbox case is what
# `AGENT_INSTALL_SOFT_FAIL=1` exists for.
#
# Written via `printf '%s\n'` because debian-slim's `/bin/sh` is
# POSIX dash, no heredoc niceties. The script is small enough that
# adding a layer for it is essentially free (well under 1 KB).
RUN printf '%s\n' \
'#!/bin/sh' \
'# agent-vm-install <name> <shell> <url>' \
'# <name> - agent identifier (codex, opencode, claude)' \
'# <shell> - sh|bash; the interpreter used to run the installer' \
'# <url> - upstream installer URL' \
'# Honors $AGENT_INSTALL_SOFT_FAIL: when non-empty, a download or' \
'# install failure becomes a warning + exit 0 instead of exit 1.' \
'set -eu' \
'name=$1; shell=$2; url=$3' \
'tmp=$(mktemp)' \
'trap "rm -f \"$tmp\"" EXIT INT TERM' \
'if curl -fsSL --retry 5 --retry-all-errors --http1.1 "$url" -o "$tmp" \' \
' && "$shell" "$tmp"; then' \
' exit 0' \
'fi' \
'msg="==> $name installer FAILED — install in-VM with: curl -fsSL $url | $shell"' \
'if [ -n "${AGENT_INSTALL_SOFT_FAIL:-}" ]; then' \
' echo "$msg (soft-fail mode; image will ship without $name)"' \
' exit 0' \
'fi' \
'echo "$msg" >&2' \
'exit 1' \
> /usr/local/bin/agent-vm-install \
&& chmod 0755 /usr/local/bin/agent-vm-install \
&& test -s /usr/local/bin/agent-vm-install
# The three agents each get their own RUN + their own version arg so
# a single agent's release rebuilds only that layer (and, by Docker's
# layer cascade, the layers stacked ABOVE it).
#
# Layer order is deliberate and is NOT by size. A change to any layer
# forces every layer above it to rebuild, so the TOPMOST agent layer
# is re-emitted on essentially every build that changes anything,
# while the BOTTOM agent layer is re-emitted only when it itself
# bumps. The agent that is both largest and most frequently released
# therefore belongs at the BOTTOM, not the top:
# - codex ~95 MiB, multiple stable cuts/day → bottom (first)
# - opencode ~50 MiB, several per week → middle
# - claude ~68 MiB, ~daily → top (last)
# This minimizes expected re-pushed bytes per changed build across
# the plausible release-frequency range; putting the big codex layer
# on top (so it re-pushes on every change) is the worst case. Each
# RUN references its version arg (`: "${AGENT_VERSION_*}"`) so
# BuildKit ties the layer's cache key to the version string and
# rebuilds it ONLY when that string changes.
#
# Agent installers HARD-FAIL by default — if an upstream installer
# is broken or removed, the hourly cron should NOT silently ship an
# image missing an agent. `AGENT_INSTALL_SOFT_FAIL=1` (build-arg,
# auto-set by images/build.sh on TLS-intercept dev hosts; CI never
# sets it) turns the three RUNs into warnings instead.
# Codex CLI official installer.
ARG AGENT_VERSION_CODEX=
RUN : "codex ${AGENT_VERSION_CODEX}" \
&& agent-vm-install codex sh \
https://github.com/openai/codex/releases/latest/download/install.sh
# Claude Code official installer.
RUN curl -fsSL https://claude.ai/install.sh | bash
# OpenCode official installer. Installs into ~/.opencode/bin; PATH already
# includes it. Symlink into /usr/local/bin so non-login shells find it too.
# The symlink is best-effort — under soft-fail mode the install may have
# been skipped, in which case there's nothing to link.
ARG AGENT_VERSION_OPENCODE=
RUN : "opencode ${AGENT_VERSION_OPENCODE}" \
&& agent-vm-install opencode bash https://opencode.ai/install \
&& { [ -x /root/.opencode/bin/opencode ] \
&& ln -sf /root/.opencode/bin/opencode /usr/local/bin/opencode \
|| true; }
RUN curl -fsSL https://opencode.ai/install | bash \
&& ln -sf /root/.opencode/bin/opencode /usr/local/bin/opencode
# Claude Code official installer. MUST precede the LSP-plugin install below,
# which invokes /root/.local/bin/claude.
ARG AGENT_VERSION_CLAUDE=
RUN : "claude ${AGENT_VERSION_CLAUDE}" \
&& agent-vm-install claude bash https://claude.ai/install.sh
# Codex CLI official installer.
RUN curl -fsSL https://github.com/openai/codex/releases/latest/download/install.sh | sh
# Claude Code LSP plugins (C/C++, Python, TypeScript, Go) baked in so
# in-VM Claude has code intelligence on first launch. Ports the four
# language servers the original `setup` installed (claude-vm.sh:348-362):
# clangd-lsp / pyright-lsp / typescript-lsp / gopls-lsp from the
# claude-plugins-official marketplace.
#
# Marketplace registration first: `claude plugin marketplace add` clones
# https://github.com/anthropics/claude-plugins-official.git into Claude's
# plugin state, then each `claude plugin install <name>@<marketplace>`
# resolves against it. The bare repo URL is what the original git-cloned
# by hand; `marketplace add` lets the CLI manage the clone + index for us.
#
# Best-effort per the chrome-mcp pre-warm style above: a transient
# registry/clone blip during the hourly build must not fail the whole
# image. Each step is guarded with `|| echo skipped`; a missing plugin
# costs in-VM code intelligence for that language, not a broken image —
# Claude still boots and the user can re-run `claude plugin install`.
#
# Must come AFTER the Claude install above — `claude` didn't exist before
# it. node/npm (from the nodejs RUN) and git (from the base apt RUN) are
# both already present, which the marketplace clone + plugin resolution
# need.
#
# Invoke claude by ABSOLUTE PATH (/root/.local/bin/claude): the installer
# drops the binary there but the only ENV PATH in this file is
# /usr/local/cargo/bin:$PATH, and Docker RUN uses a non-login /bin/sh that
# never sources the shell profile the installer edits. A bare `claude`
# would hit command-not-found (rc 127), every `|| echo skipped` guard would
# fire, and the RUN would still exit 0 — baking a GREEN image with ZERO LSP
# plugins. The absolute path matches the `--version` sanity check below and
# the OpenCode block's /root/.opencode/bin/opencode.
#
# A `claude plugin list` runs after the loop so the build log shows which
# plugins actually landed — without it the best-effort guards would hide a
# total no-op behind a clean build. If `marketplace add` ever rejects the
# `owner/repo` shorthand, swap in the explicit URL
# https://github.com/anthropics/claude-plugins-official.git.
RUN { /root/.local/bin/claude plugin marketplace add anthropics/claude-plugins-official \
|| echo "==> LSP marketplace add skipped (will lazy-add at first plugin use)"; } \
&& for lsp in clangd-lsp pyright-lsp typescript-lsp gopls-lsp; do \
/root/.local/bin/claude plugin install "${lsp}@claude-plugins-official" \
|| echo "==> LSP plugin ${lsp} skipped (install at runtime if needed)"; \
done \
&& echo "==> LSP plugins installed at build time:" \
&& { /root/.local/bin/claude plugin list || true; }
# D2 fix: the running guest symlinks /root/.claude -> /agent-vm-state/claude
# (persistence, run.rs), which shadows the plugin tree baked just above so the
# booted guest's `claude plugin list` shows nothing. Stash the plugins +
# settings to a NON-shadowed path and ship a first-boot seed script; the
# launcher prelude calls it before exec'ing the agent.
# Best-effort like the LSP install above: if a transient blip skipped the
# plugins, stash nothing rather than hard-failing the image — the first-boot
# seed then becomes a no-op and plugins lazy-install at runtime.
RUN mkdir -p /opt/agent-vm/claude-seed \
&& if [ -d /root/.claude/plugins ]; then \
cp -a /root/.claude/plugins /opt/agent-vm/claude-seed/plugins; \
[ -f /root/.claude/settings.json ] \
&& cp -a /root/.claude/settings.json /opt/agent-vm/claude-seed/settings.json; \
echo "==> stashed claude LSP plugins for first-boot seeding"; \
else \
echo "==> no /root/.claude/plugins to stash (LSP install skipped); D2 seed is a no-op"; \
fi
COPY seed-claude-plugins.sh /opt/agent-vm/seed-claude-plugins.sh
RUN chmod +x /opt/agent-vm/seed-claude-plugins.sh
# D1: GitHub Copilot CLI. Its own RUN layer so the AGENT_INSTALL_CACHEBUST
# arg above invalidates it on the hourly build like the other agents, and
# so a wrong package/binary name fails the image build loudly (via the
# `copilot --version` smoke check) instead of surfacing as a
# command-not-found inside the guest at `agent-vm copilot` time.
RUN npm install -g @github/copilot && copilot --version
# Sanity check at build time. Every command is invoked directly
# (`tool --version >/dev/null`) so a present-but-broken binary —
# corrupt download, broken symlink target, missing libc symbol —
# fails the build instead of slipping through a `command -v` exists-
# check. `set -e` propagates the first failure.
#
# Agent CLIs (claude/opencode/codex) are HARD-required unless
# `AGENT_INSTALL_SOFT_FAIL` is set, in which case they're SOFT
# (missing → warning, not failure) — matches the policy on the
# installer RUNs above. /opt/wirenboard/codestyle follows the same
# policy (its clone RUN above also honors AGENT_INSTALL_SOFT_FAIL).
RUN set -e \
&& echo "== HARD requirements ==" \
&& dockerd --version >/dev/null \
&& docker --version >/dev/null \
&& containerd --version >/dev/null \
&& runc --version >/dev/null \
&& black --version >/dev/null \
&& isort --version >/dev/null \
&& pylint --version >/dev/null \
&& ssh -V >/dev/null 2>&1 \
&& sshpass -V >/dev/null 2>&1 \
&& ping -V >/dev/null 2>&1 \
&& { if [ -d /opt/wirenboard/codestyle ]; then \
echo " codestyle clone: OK"; \
elif [ -n "${AGENT_INSTALL_SOFT_FAIL:-}" ]; then \
echo " codestyle clone: MISSING (soft-fail mode)"; \
else \
echo " codestyle clone: MISSING — hard sanity failure" >&2; exit 1; \
fi; } \
&& echo "== agent CLIs ==" \
&& for bin in claude opencode codex copilot; do \
if "$bin" --version >/dev/null 2>&1; then \
ver=$("$bin" --version 2>&1 | head -1); \
echo " $bin: $ver"; \
elif [ -n "${AGENT_INSTALL_SOFT_FAIL:-}" ]; then \
echo " $bin: MISSING (soft-fail mode)"; \
else \
echo " $bin: MISSING — hard sanity failure" >&2; \
exit 1; \
fi; \
done \
&& echo "== sanity OK =="
# Sanity check at build time so a broken installer surfaces before we push.
RUN claude --version && opencode --version && codex --version \
&& dockerd --version && docker --version && containerd --version && runc --version
# Smoke entrypoint; the launcher overrides this in Phase 2.
CMD ["/bin/bash"]

View file

@ -125,53 +125,8 @@ build_and_push() {
# win. `registry.insecure=true` lets us push to the loopback HTTP
# registry. We use `compression-level=3` (zstd's default) — the
# bench shows diminishing returns past that for binary-heavy layers.
# If the host is itself behind a TLS-intercept proxy (agent-vm-
# inside-agent-vm during local dev, or a corporate egress MITM),
# the buildkit container's outbound HTTPS sees the proxy's CA and
# curl/apt fail with "unable to verify the legitimacy of the
# server". Detect the host CA and:
# - pass it as a buildx secret (the Dockerfile imports it
# conditionally; no-op when the secret is absent),
# - key the RUN-cache invalidation off its mtime (CA_SHIM_
# CACHEBUST — see Dockerfile comment for why secret content
# alone doesn't invalidate the cache),
# - run the RUN steps in the host network namespace, because
# buildkit's default bridge stack drops some HTTPS connec-
# tions (curl 56 `SSL_read: unexpected eof`) mid-redirect
# through the MITM proxy.
# Production CI has no such host CA and skips all of this.
local extra=()
local host_ca="${AGENT_VM_BUILD_HOST_CA:-/usr/local/share/ca-certificates/microsandbox-ca.crt}"
local mitm_detected=
if [ -f "${host_ca}" ]; then
echo "==> Including host CA ${host_ca} as buildx secret (TLS-intercept proxy detected)"
extra+=(--secret "id=hostca,src=${host_ca}")
extra+=(--build-arg "CA_SHIM_CACHEBUST=$(stat -c %Y "${host_ca}")")
extra+=(--allow "network.host")
extra+=(--network "host")
mitm_detected=1
fi
# AGENT_INSTALL_SOFT_FAIL — independent toggle (not tied to CA
# detection) so a clean-network developer rebuilding during an
# upstream installer outage can opt in, and someone debugging
# installer changes on a MITM host can force hard-fail with
# `AGENT_VM_BUILD_SOFT_FAIL_AGENTS=0`.
#
# Default policy: MITM-detected hosts → soft-fail (the same TLS
# interception that triggers the CA shim also hits curl 56 on
# some GitHub release-asset URLs); clean hosts → hard-fail
# (matching production CI).
local soft_fail="${AGENT_VM_BUILD_SOFT_FAIL_AGENTS:-${mitm_detected}}"
if [ -n "${soft_fail}" ] && [ "${soft_fail}" != "0" ]; then
echo "==> Soft-fail mode enabled for agent installers + codestyle clone (AGENT_VM_BUILD_SOFT_FAIL_AGENTS=0 to disable)"
extra+=(--build-arg "AGENT_INSTALL_SOFT_FAIL=1")
fi
docker buildx build \
-t "${IMAGE_TAG}" \
"${extra[@]}" \
--output "type=registry,push=true,registry.insecure=true,compression=zstd,compression-level=3,force-compression=true" \
-f "${SCRIPT_DIR}/Dockerfile" \
"${SCRIPT_DIR}"

View file

@ -1,38 +0,0 @@
#!/bin/sh
# Seed the image's baked Claude LSP plugins into the persistent state dir on
# first boot.
#
# The image installs the plugins into /root/.claude/plugins at build time, but
# the launcher symlinks /root/.claude -> /agent-vm-state/claude for persistence
# (run.rs), which shadows the baked tree so `claude plugin list` sees nothing.
# This script (called from the launcher prelude before the agent execs) copies
# the stash into the state dir once and merges the enabledPlugins +
# extraKnownMarketplaces keys into the state settings.json, preserving anything
# the user already set. Best-effort: never fails the launch.
#
# Idempotent: a present /agent-vm-state/claude/plugins means we already seeded
# (or the user manages plugins themselves) — do nothing.
SEED=/opt/agent-vm/claude-seed
STATE=/agent-vm-state/claude
[ -d "$SEED/plugins" ] || exit 0
[ -e "$STATE/plugins" ] && exit 0
mkdir -p "$STATE" 2>/dev/null
cp -a "$SEED/plugins" "$STATE/plugins" 2>/dev/null || exit 0
if [ -f "$SEED/settings.json" ] && command -v node >/dev/null 2>&1; then
node -e '
const fs = require("fs");
// With `node -e CODE a b`, user args start at argv[1] (no script path slot).
const [, statePath, seedPath] = process.argv;
const seed = JSON.parse(fs.readFileSync(seedPath, "utf8"));
let st = {};
try { st = JSON.parse(fs.readFileSync(statePath, "utf8")); } catch (e) {}
st.enabledPlugins = Object.assign({}, seed.enabledPlugins || {}, st.enabledPlugins || {});
st.extraKnownMarketplaces = Object.assign({}, seed.extraKnownMarketplaces || {}, st.extraKnownMarketplaces || {});
fs.writeFileSync(statePath, JSON.stringify(st, null, 2));
' "$STATE/settings.json" "$SEED/settings.json" 2>/dev/null || true
fi
exit 0

View file

@ -9,14 +9,15 @@ Templates and tooling for distributing `agent-vm` via npm.
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/`, `agent-vm-linux-arm64/` — per-platform
subpackages. Each 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: `-darwin-arm64`, `-darwin-x64`,
`-win32-x64`. Add to the launcher's `PLATFORM_PACKAGES` map and to
the main package's `optionalDependencies`.
- `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

View file

@ -1,9 +0,0 @@
# @wirenboard/agent-vm-linux-arm64
Prebuilt `agent-vm` binary + patched `msb` + libkrunfw for linux/arm64.
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/arm64 hosts.
Source: <https://github.com/wirenboard/agent-vm>.

View file

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

View file

@ -1,90 +0,0 @@
#!/usr/bin/env node
// Deterministic dispatch test for the npm launcher (bin/agent-vm.js).
//
// Why this exists: the launcher's job is to map the running
// platform/arch to a per-platform subpackage and then build the
// `bin/agent-vm` path inside it. arm64 was added to that mapping in
// B2, but arm64 dispatch cannot be exercised on an x86_64 CI host —
// `node` reports the host's real process.arch, so simply running the
// launcher there only ever exercises the linux-x64 leg. This test
// closes that gap without a real arm64 runtime by re-deriving the
// mapping + path logic from the launcher source and asserting the
// linux-arm64 key resolves to the expected package and to the same
// bin/ layout as linux-x64.
//
// Run standalone: `node bin/agent-vm.dispatch.test.js` (no test
// harness / deps — matches the repo convention of sanity-checking the
// launcher with plain `node`/`node --check`; there is no Node test
// runner or root package.json in this repo).
//
// It must stay in lockstep with the real launcher: it extracts the
// live PLATFORM_PACKAGES object out of agent-vm.js (rather than
// hard-coding a copy) so a future edit to the mapping that forgets
// arm64 — or renames the package — fails here instead of silently
// shipping a launcher that falls through to "unsupported platform"
// on arm64 hardware.
"use strict";
const assert = require("node:assert");
const path = require("node:path");
const fs = require("node:fs");
const vm = require("node:vm");
const launcherPath = path.join(__dirname, "agent-vm.js");
const src = fs.readFileSync(launcherPath, "utf8");
// Pull the `PLATFORM_PACKAGES = { ... };` object-literal out of the
// launcher source and evaluate just that literal in an isolated VM
// context. We deliberately do NOT `require()` the launcher: it runs
// its dispatch + process.exit() at module load, so requiring it would
// terminate this test process.
const m = src.match(/const\s+PLATFORM_PACKAGES\s*=\s*(\{[\s\S]*?\});/);
assert.ok(m, "could not locate PLATFORM_PACKAGES object literal in agent-vm.js");
const PLATFORM_PACKAGES = vm.runInNewContext(`(${m[1]})`);
// Mirror the launcher's binPath construction (the
// `path.join(dir, "bin", "agent-vm"+ext)` line) so we assert the SAME
// layout the launcher actually uses.
function binPathFor(pkgDir, platform) {
const ext = platform === "win32" ? ".exe" : "";
return path.join(pkgDir, "bin", `agent-vm${ext}`);
}
// 1) The new arm64 key must resolve to the arm64 subpackage.
assert.strictEqual(
PLATFORM_PACKAGES["linux-arm64"],
"@wirenboard/agent-vm-linux-arm64",
"linux-arm64 must map to @wirenboard/agent-vm-linux-arm64",
);
// 2) x64 must still resolve (guards against an accidental clobber).
assert.strictEqual(
PLATFORM_PACKAGES["linux-x64"],
"@wirenboard/agent-vm-linux-x64",
"linux-x64 must map to @wirenboard/agent-vm-linux-x64",
);
// 3) Both linux platforms must produce the identical bin/ layout
// (only the package dir differs) — the arm64 subpackage ships its
// binary at bin/agent-vm exactly like x64 (see its package.json
// `files` list + the bin/.gitkeep placeholder).
const x64Bin = binPathFor("/pkg/agent-vm-linux-x64", "linux");
const arm64Bin = binPathFor("/pkg/agent-vm-linux-arm64", "linux");
assert.strictEqual(path.basename(x64Bin), "agent-vm");
assert.strictEqual(path.basename(arm64Bin), "agent-vm");
assert.strictEqual(
path.relative("/pkg/agent-vm-linux-x64", x64Bin),
path.relative("/pkg/agent-vm-linux-arm64", arm64Bin),
"arm64 and x64 must use the same bin/ layout",
);
// 4) A genuinely unsupported platform key must be absent so the
// launcher hits its "no prebuilt binary" error path cleanly.
assert.strictEqual(
PLATFORM_PACKAGES["sunos-sparc"],
undefined,
"unsupported platform keys must be absent (no fall-through entry)",
);
console.log("agent-vm dispatch test: OK (linux-x64, linux-arm64)");

View file

@ -22,7 +22,7 @@ const fs = require("node:fs");
const PLATFORM_PACKAGES = {
"linux-x64": "@wirenboard/agent-vm-linux-x64",
"linux-arm64": "@wirenboard/agent-vm-linux-arm64",
// "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",

View file

@ -22,8 +22,7 @@
"node": ">=18"
},
"optionalDependencies": {
"@wirenboard/agent-vm-linux-x64": "0.0.0",
"@wirenboard/agent-vm-linux-arm64": "0.0.0"
"@wirenboard/agent-vm-linux-x64": "0.0.0"
},
"publishConfig": {
"access": "public"

View file

@ -1,23 +0,0 @@
#!/bin/bash
set -u
cd "$(dirname "$0")"
# wait for the main matrix to finish so we don't thrash CPU
until grep -q "ALL VARIANTS BUILT\|ABORTING" /tmp/variants_build.log 2>/dev/null; do sleep 10; done
SRC=linux-6.12.68; OUT=/tmp/variants
echo "### BUILD heavy_legacy ($(date +%T))"
cp /tmp/cfg_heavy_legacy "$SRC/.config"
( cd "$SRC" && make olddefconfig >/dev/null 2>&1 )
printf " effective: NETFILTER=%s NF_CONNTRACK=%s NF_NAT=%s IP_NF_IPTABLES=%s NF_TABLES=%s BRIDGE=%s IP6_NF=%s\n" \
"$(grep -c '^CONFIG_NETFILTER=y' $SRC/.config)" "$(grep -c '^CONFIG_NF_CONNTRACK=y' $SRC/.config)" \
"$(grep -c '^CONFIG_NF_NAT=y' $SRC/.config)" "$(grep -c '^CONFIG_IP_NF_IPTABLES=y' $SRC/.config)" \
"$(grep -c '^CONFIG_NF_TABLES=y' $SRC/.config)" "$(grep -c '^CONFIG_BRIDGE=y' $SRC/.config)" \
"$(grep -c '^CONFIG_IP6_NF_IPTABLES=y' $SRC/.config)"
t0=$(date +%s)
( cd "$SRC" && rm -f .version && make -j14 KBUILD_BUILD_TIMESTAMP="Tue Feb 17 16:15:12 CET 2026" KBUILD_BUILD_USER=root KBUILD_BUILD_HOST=libkrunfw vmlinux ) >"$OUT/build-heavy_legacy.log" 2>&1
rc=$?; t1=$(date +%s)
[ $rc -ne 0 ] && { echo "BUILD FAILED rc=$rc"; tail -5 "$OUT/build-heavy_legacy.log"; exit 1; }
python3 bin2cbundle.py -t vmlinux "$SRC/vmlinux" kernel.c
cc -fPIC -DABI_VERSION=5 -shared -Wl,-soname,libkrunfw.so.5 -o "$OUT/libkrunfw-heavy_legacy.so.5.2.1" kernel.c
strip "$OUT/libkrunfw-heavy_legacy.so.5.2.1"
echo " -> heavy_legacy built in $((t1-t0))s, $(du -h $OUT/libkrunfw-heavy_legacy.so.5.2.1|cut -f1)"
echo "### HEAVY_LEGACY DONE + ALL BUILDS COMPLETE ($(date +%T))"

View file

@ -1,45 +0,0 @@
#!/bin/bash
# Build libkrunfw .so variants for a boot-time A/B. One shared tree, serial
# incremental builds. Each .so saved to /tmp/variants/.
set -u
cd "$(dirname "$0")"
SRC=linux-6.12.68
OUT=/tmp/variants
mkdir -p "$OUT"
JOBS=14
KFLAGS=(KBUILD_BUILD_TIMESTAMP="Tue Feb 17 16:15:12 CET 2026" KBUILD_BUILD_USER=root KBUILD_BUILD_HOST=libkrunfw)
build_one() {
local name="$1" seed="$2"
echo "===================================================================="
echo "### BUILD $name ($(date +%T))"
cp "$seed" "$SRC/.config" || return 1
( cd "$SRC" && make olddefconfig >/dev/null 2>&1 ) || { echo "olddefconfig FAILED"; return 1; }
# report effective symbols
printf " effective: KVM=%s KVM_INTEL=%s KVM_AMD=%s NETFILTER=%s NF_CONNTRACK=%s NF_TABLES=%s BRIDGE=%s POSIX_MQUEUE=%s DEFERRED=%s\n" \
"$(grep -c '^CONFIG_KVM=y' $SRC/.config)" \
"$(grep -c '^CONFIG_KVM_INTEL=y' $SRC/.config)" \
"$(grep -c '^CONFIG_KVM_AMD=y' $SRC/.config)" \
"$(grep -c '^CONFIG_NETFILTER=y' $SRC/.config)" \
"$(grep -c '^CONFIG_NF_CONNTRACK=y' $SRC/.config)" \
"$(grep -c '^CONFIG_NF_TABLES=y' $SRC/.config)" \
"$(grep -c '^CONFIG_BRIDGE=y' $SRC/.config)" \
"$(grep -c '^CONFIG_POSIX_MQUEUE=y' $SRC/.config)" \
"$(grep -c '^CONFIG_DEFERRED_STRUCT_PAGE_INIT=y' $SRC/.config)"
local t0=$(date +%s)
( cd "$SRC" && rm -f .version && make -j$JOBS "${KFLAGS[@]}" vmlinux ) >"$OUT/build-$name.log" 2>&1
local rc=$?
local t1=$(date +%s)
if [ $rc -ne 0 ]; then echo " BUILD FAILED rc=$rc (see $OUT/build-$name.log)"; tail -5 "$OUT/build-$name.log"; return 1; fi
echo " vmlinux built in $((t1-t0))s"
python3 bin2cbundle.py -t vmlinux "$SRC/vmlinux" kernel.c || { echo "bin2cbundle FAILED"; return 1; }
cc -fPIC -DABI_VERSION=5 -shared -Wl,-soname,libkrunfw.so.5 -o "$OUT/libkrunfw-$name.so.5.2.1" kernel.c || { echo "link FAILED"; return 1; }
strip "$OUT/libkrunfw-$name.so.5.2.1"
echo " -> $OUT/libkrunfw-$name.so.5.2.1 ($(du -h "$OUT/libkrunfw-$name.so.5.2.1" | cut -f1)) total $((t1-t0))s+bundle"
}
for v in stock stock_kvm heavy_nonf heavy heavy_deferred; do
build_one "$v" "/tmp/cfg_$v" || { echo "ABORTING at $v"; exit 1; }
done
echo "### ALL VARIANTS BUILT ($(date +%T))"
ls -la "$OUT"/*.so.5.2.1

View file

@ -1,48 +0,0 @@
#!/bin/bash
# Wall-clock `create` per libkrunfw variant. Interleaved across variants to
# cancel host drift; drop_caches between rounds; mean/stdev. QUIET host only.
set -u
export PATH="/home/boger/work/board/tmp/agent-vm/.agent-vm-rust/rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin:$PATH"
export AGENT_VM_NO_CHROME_MCP=1 # strip the run-phase certutil; we measure create only
export MSB_PATH=/home/boger/work/board/tmp/agent-vm/vendor/microsandbox/target/release/msb
AGENTVM=/home/boger/work/board/tmp/agent-vm/target/release/agent-vm
LIBSO=/home/boger/work/board/tmp/agent-vm/vendor/microsandbox/target/release/libkrunfw.so.5.2.1
VAR=/tmp/variants
PROJ=/root/profproj
VARIANTS=(stock stock_kvm heavy_nonf heavy_legacy heavy heavy_deferred)
ROUNDS=${1:-10}
cd "$PROJ" || exit 1
swap() { cp "$VAR/libkrunfw-$1.so.5.2.1" "$LIBSO" || exit 1; }
create() { local mem="$1"; AGENT_VM_PROFILE=1 "$AGENTVM" shell --memory "$mem" true 2>&1 \
| sed 's/\x1b\[[0-9;]*m//g' | grep -oE 'create:[[:space:]]+[0-9.]+s' | grep -oE '[0-9.]+'; }
mstat() { tr ' ' '\n' <<<"$1" | awk 'NF{n++;v=$1;s+=v;ss+=v*v;if(mn==""||v<mn)mn=v;if(v>mx)mx=v} END{if(!n){print "no data";exit} m=s/n;d=ss/n-m*m;if(d<0)d=0;printf "mean=%.3f sd=%.3f min=%.3f max=%.3f n=%d",m,sqrt(d),mn,mx,n}'; }
echo "### boot check (each variant must run a command) ###"
for v in "${VARIANTS[@]}"; do
swap "$v"; o=$(AGENT_VM_PROFILE=1 "$AGENTVM" shell echo OK_$v 2>&1)
echo "$o" | grep -q "OK_$v" && echo " $v: boots OK" || { echo " $v: *** FAILED ***"; echo "$o"|tail -3; }
done
declare -A S
echo; echo "### interleaved, 1 GiB / 2 vCPU, $ROUNDS rounds, drop_caches per round ###"
for c in $(seq 1 "$ROUNDS"); do
sync; echo 3 > /proc/sys/vm/drop_caches 2>/dev/null
for v in "${VARIANTS[@]}"; do swap "$v"; S[$v]="${S[$v]:-} $(create 1)"; done
echo " round $c done"
done
declare -A S4
echo; echo "### heavy vs heavy_deferred @ 4 GiB, 6 rounds (does deferred help at more RAM?) ###"
for c in $(seq 1 6); do
sync; echo 3 > /proc/sys/vm/drop_caches 2>/dev/null
for v in heavy heavy_deferred; do swap "$v"; S4[$v]="${S4[$v]:-} $(create 4)"; done
done
echo; echo "================= RESULTS: create wall-seconds @1GiB ================="
for v in "${VARIANTS[@]}"; do printf "%-16s %s\n" "$v" "$(mstat "${S[$v]}")"; done
echo "------- raw @1GiB -------"
for v in "${VARIANTS[@]}"; do printf "%-16s%s\n" "$v" "${S[$v]}"; done
echo; echo "================= @4GiB heavy vs deferred ================="
for v in heavy heavy_deferred; do printf "%-16s %s\n" "$v" "$(mstat "${S4[$v]}")"; done
swap heavy; echo "(restored heavy .so)"

2
vendor/microsandbox vendored

@ -1 +1 @@
Subproject commit d6b9b11732771266169beb4043a4164235a67d64
Subproject commit e7590e28d53f8816a94ec38cdafd0ab5a4a4b543