mirror of
https://github.com/wirenboard/agent-vm.git
synced 2026-07-09 16:00:54 +00:00
Compare commits
85 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05e8969215 | ||
|
|
920bff82d7 | ||
|
|
885aa026d1 | ||
|
|
958af6ddc7 | ||
|
|
5cf95e20ef | ||
|
|
7e8ad37589 | ||
|
|
281d82ddb3 | ||
|
|
a8559ed5d4 | ||
|
|
d3194bf831 | ||
|
|
ea73a39add | ||
|
|
7d69877c35 | ||
|
|
0d9d86e19a | ||
|
|
8be5bc0720 | ||
|
|
ef76b22733 | ||
|
|
d2de958638 | ||
|
|
cd7d81fdec | ||
|
|
ea5821594e | ||
|
|
a5c0d7ff79 | ||
|
|
aba431cfc8 | ||
|
|
aaf88cfa46 | ||
|
|
ac20c8e746 | ||
|
|
620db380b9 | ||
|
|
2943cd940a | ||
|
|
5c7aea9460 | ||
|
|
d5209f73fe | ||
|
|
687ebcc6ed | ||
|
|
1509b0fb49 | ||
|
|
82474aed75 | ||
|
|
b2cadc56d8 | ||
|
|
c3fda73ab1 | ||
|
|
010c45c190 | ||
|
|
7a21c83b32 | ||
|
|
31fa95b67c | ||
|
|
3d512fa545 | ||
|
|
0d5e576e47 | ||
|
|
05aea4c281 | ||
|
|
74832b56af | ||
|
|
89b54bdac7 | ||
|
|
45655fb773 | ||
|
|
aa067d8de6 | ||
|
|
a1f16291fb | ||
|
|
1b0688a3ce | ||
|
|
9676f6d9a4 | ||
|
|
0468a2649a | ||
|
|
60a75ae0fc | ||
|
|
7ebb0b0a3d | ||
|
|
6513050a31 | ||
|
|
9c97658e6f | ||
|
|
2e94f3276f | ||
|
|
0f301a1146 | ||
|
|
94ae08e7c1 | ||
|
|
13850a81aa | ||
|
|
f881e073c1 | ||
|
|
63cab08d82 | ||
|
|
65c6d59a40 | ||
|
|
0fee58a8b9 | ||
|
|
0de7159e0e | ||
|
|
92c9da84cf | ||
|
|
2a45955818 | ||
|
|
2d4d5978bf | ||
|
|
17b6e5bb91 | ||
|
|
1479f5b8f3 | ||
|
|
c60b76597f | ||
|
|
5a731501d5 | ||
|
|
374921cb99 | ||
|
|
9a3d7465ee | ||
|
|
45c208ce31 | ||
|
|
e556ad3ad6 | ||
|
|
9240afbbd4 | ||
|
|
6d52474eac | ||
|
|
18d2c75330 | ||
|
|
fd696a13ea | ||
|
|
ca516fb807 | ||
|
|
800b51c411 | ||
|
|
913d9e0066 | ||
|
|
4407bc244d | ||
|
|
25d75e7181 | ||
|
|
35bdce0cee | ||
|
|
d36c945156 | ||
|
|
ca6a2a3d2b | ||
|
|
c87d9bfb99 | ||
|
|
f5bacb5f41 | ||
|
|
79d2458d64 | ||
|
|
cb692b9e4a | ||
|
|
fa839320cf |
35 changed files with 4431 additions and 1208 deletions
15
.cargo/config.toml
Normal file
15
.cargo/config.toml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# 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"
|
||||
142
.github/workflows/build-image.yml
vendored
142
.github/workflows/build-image.yml
vendored
|
|
@ -44,10 +44,26 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Compute timestamp tag (UTC)
|
||||
- name: Compute timestamp tag + source epoch
|
||||
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
|
||||
|
|
@ -59,8 +75,62 @@ 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
|
||||
|
|
@ -78,12 +148,51 @@ 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.
|
||||
outputs: type=registry,push=true,compression=zstd,compression-level=3,force-compression=true
|
||||
#
|
||||
# `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 }}
|
||||
# `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
|
||||
|
|
@ -100,15 +209,40 @@ 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
|
||||
- name: Show pushed digest + assert single manifest
|
||||
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
|
||||
|
|
|
|||
60
.github/workflows/ci.yml
vendored
Normal file
60
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
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
|
||||
231
.github/workflows/release-npm.yml
vendored
231
.github/workflows/release-npm.yml
vendored
|
|
@ -62,7 +62,22 @@ 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
|
||||
|
|
@ -75,12 +90,28 @@ 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 }}
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libcap-ng-dev libdbus-1-dev libsqlite3-dev pkg-config
|
||||
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
|
||||
|
||||
- name: Cargo cache (rust-cache)
|
||||
uses: Swatinem/rust-cache@v2
|
||||
|
|
@ -109,8 +140,40 @@ 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
|
||||
run: cargo build --release --target ${{ matrix.cargo_target }} -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
|
||||
|
||||
- name: Upload agent-vm binary
|
||||
uses: actions/upload-artifact@v7
|
||||
|
|
@ -129,7 +192,16 @@ 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
|
||||
|
|
@ -138,18 +210,34 @@ 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 }}
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libcap-ng-dev libdbus-1-dev libsqlite3-dev pkg-config
|
||||
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
|
||||
|
||||
- name: Cargo cache (rust-cache)
|
||||
uses: Swatinem/rust-cache@v2
|
||||
|
|
@ -170,10 +258,45 @@ jobs:
|
|||
cache-all-crates: "true"
|
||||
cache-workspace-crates: "true"
|
||||
|
||||
- name: cargo build --release -p microsandbox-cli --bin msb
|
||||
- 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
|
||||
|
|
@ -199,7 +322,19 @@ 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
|
||||
|
|
@ -231,12 +366,20 @@ 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'
|
||||
|
|
@ -244,11 +387,24 @@ 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
|
||||
|
|
@ -303,7 +459,15 @@ 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
|
||||
|
|
@ -396,10 +560,26 @@ 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'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
# 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
|
||||
|
||||
# Download each platform artifact into its own subpackage
|
||||
# directory explicitly. `actions/download-artifact@v8` with
|
||||
|
|
@ -419,6 +599,16 @@ 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
|
||||
|
|
@ -430,7 +620,14 @@ jobs:
|
|||
run: |
|
||||
set -e
|
||||
for d in npm-dist/agent-vm-*-*/bin; do
|
||||
test -f "$d/agent-vm" || { echo "missing $d/agent-vm"; exit 1; }
|
||||
# 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/msb" || { echo "missing $d/msb"; exit 1; }
|
||||
chmod +x "$d/agent-vm" "$d/msb"
|
||||
done
|
||||
|
|
@ -457,8 +654,9 @@ 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
|
||||
|
|
@ -471,6 +669,15 @@ 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.
|
||||
|
|
@ -484,8 +691,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
5
.gitignore
vendored
|
|
@ -6,3 +6,8 @@ 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
|
||||
|
|
|
|||
64
AGENTS.md
Normal file
64
AGENTS.md
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# 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
633
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -6,7 +6,7 @@ members = ["crates/agent-vm"]
|
|||
exclude = ["vendor/microsandbox"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.15"
|
||||
version = "0.1.25"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/wirenboard/agent-vm"
|
||||
|
|
|
|||
135
LAUNCH-PROFILE.md
Normal file
135
LAUNCH-PROFILE.md
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
# 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.26–1.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 1–4 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.68–1.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.
|
||||
```
|
||||
858
PLAN.md
858
PLAN.md
|
|
@ -1,711 +1,155 @@
|
|||
# agent-vm rewrite — PLAN
|
||||
|
||||
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.
|
||||
|
||||
The architecture details and design rationale live in `ARCHITECTURE.md` and are
|
||||
written after each phase, not up front.
|
||||
|
||||
## Why a rewrite
|
||||
|
||||
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.
|
||||
|
||||
## v1 scope (in)
|
||||
|
||||
- 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).
|
||||
|
||||
## v1 scope (out, may revisit)
|
||||
|
||||
- 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).
|
||||
|
||||
## Phased roadmap
|
||||
|
||||
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.
|
||||
|
||||
### Phase 0 — Scaffolding [done — commits `cb4be40`, `4462180`]
|
||||
|
||||
- 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.
|
||||
|
||||
**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.
|
||||
|
||||
### Phase 1 — OCI image [done — commit `d23c421`]
|
||||
|
||||
- `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.
|
||||
|
||||
**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.
|
||||
|
||||
### Phase 2 — Launcher MVP [done — wiring complete; live API smoke deferred to Phase 3]
|
||||
|
||||
- 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.
|
||||
|
||||
**Done when:** `cd repo && agent-vm claude -p "say hi"` returns a real Claude
|
||||
response from inside the sandbox.
|
||||
|
||||
**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.
|
||||
|
||||
### Phase 2.x — Post-MVP polish [done — commits `7608f27`..`d3914b9`]
|
||||
|
||||
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 ~10–50 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.
|
||||
# agent-vm — PLAN
|
||||
|
||||
Roadmap for the Rust + [microsandbox](https://github.com/wirenboard/microsandbox)
|
||||
rewrite of `agent-vm`. The old phase-by-phase roadmap (Phases 0–9) 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`.
|
||||
|
||||
## Where the rewrite stands today
|
||||
|
||||
Working and verified for daily use:
|
||||
|
||||
- **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.
|
||||
|
||||
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).
|
||||
|
||||
## A. In-scope work to finish
|
||||
|
||||
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.)
|
||||
|
||||
- **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.
|
||||
|
||||
## B. Distribution / release (old Phase 9 leftovers)
|
||||
|
||||
- **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).
|
||||
|
||||
## C. Improvements beyond `main` (optional, product call)
|
||||
|
||||
- **C1 — Detached / persistent-VM fast launch.** Neither original nor rewrite
|
||||
has this. Boot once per project, attach per invocation: ~1.5 s → ~10–50 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.
|
||||
|
||||
## D. Original-only features — decisions made
|
||||
|
||||
Decided 2026-05-30 with the user, per-feature.
|
||||
|
||||
### Will port back (now roadmap items)
|
||||
|
||||
- **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.
|
||||
|
||||
### Won't do (confirmed non-goals)
|
||||
|
||||
- **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.
|
||||
|
||||
Obsolete-by-architecture (no decision needed): setup `--minimal` / `--disk`
|
||||
(image is Docker-built once, not provisioned per-setup), `--max-memory` (tied
|
||||
to the balloon).
|
||||
|
||||
## Discovered upstream issues (still open)
|
||||
|
||||
Carried over from the old plan; the IRQ/split-irqchip one (#3) is resolved.
|
||||
|
||||
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.
|
||||
|
||||
## Working agreements
|
||||
|
||||
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
|
||||
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
|
||||
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.
|
||||
|
|
|
|||
25
README.md
25
README.md
|
|
@ -153,6 +153,28 @@ 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
|
||||
|
|
@ -171,3 +193,6 @@ exit aborts the launch.
|
|||
- [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.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "agent-vm"
|
||||
description = "Sandboxed VMs for AI coding agents, built on microsandbox."
|
||||
description = "Sandboxed microVMs for AI coding agents, built on microsandbox."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
|
@ -11,9 +11,15 @@ name = "agent-vm"
|
|||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
microsandbox = { path = "../../vendor/microsandbox/crates/microsandbox" }
|
||||
tokio = { version = "1.42", features = ["macros", "rt-multi-thread"] }
|
||||
clap = { version = "4.5", features = ["derive", "env"] }
|
||||
# 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"] }
|
||||
anyhow = "1.0"
|
||||
base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
|
|
@ -24,6 +30,7 @@ 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
|
||||
|
|
|
|||
|
|
@ -47,6 +47,16 @@ 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
|
||||
|
|
|
|||
|
|
@ -53,7 +53,24 @@ 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)?;
|
||||
remote_manifest_digest(&parsed).await.ok().flatten()
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn remote_manifest_digest(parsed: &ParsedRef) -> Result<Option<String>> {
|
||||
|
|
@ -79,22 +96,10 @@ async fn remote_manifest_digest(parsed: &ParsedRef) -> Result<Option<String>> {
|
|||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()?;
|
||||
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() {
|
||||
let resp = get_manifest_with_auth(&client, &url).await?;
|
||||
let Some(resp) = resp else {
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
let direct_digest = resp
|
||||
.headers()
|
||||
.get("docker-content-digest")
|
||||
|
|
@ -129,6 +134,145 @@ 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,
|
||||
|
|
@ -199,4 +343,36 @@ 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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -681,14 +681,50 @@ 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>> {
|
||||
trigger_host_refresh("claude", &["-p", "hi", "--model", "sonnet"])?;
|
||||
// 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"])?;
|
||||
}
|
||||
|
||||
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()))?;
|
||||
let json: Value = serde_json::from_str(&raw)
|
||||
.with_context(|| format!("parsing {}", 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 oauth = json
|
||||
.get("claudeAiOauth")
|
||||
.context("rotated host .credentials.json missing claudeAiOauth")?;
|
||||
|
|
@ -719,22 +755,46 @@ fn refresh_anthropic(state_dir: &Path) -> 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>> {
|
||||
trigger_host_refresh(
|
||||
"codex",
|
||||
&[
|
||||
"exec",
|
||||
"--skip-git-repo-check",
|
||||
"--dangerously-bypass-approvals-and-sandbox",
|
||||
"Reply with OK",
|
||||
],
|
||||
)?;
|
||||
// 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",
|
||||
],
|
||||
)?;
|
||||
}
|
||||
|
||||
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()))?;
|
||||
let json: Value = serde_json::from_str(&raw)
|
||||
.with_context(|| format!("parsing {}", 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 new_access = json
|
||||
.pointer("/tokens/access_token")
|
||||
|
|
@ -758,13 +818,301 @@ fn refresh_openai(state_dir: &Path) -> 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 = Duration::from_secs(90);
|
||||
const TIMEOUT: Duration = HOST_REFRESH_TIMEOUT;
|
||||
|
||||
let mut child = Command::new(cmd)
|
||||
.args(args)
|
||||
|
|
@ -1612,6 +1960,7 @@ mod tests {
|
|||
query_params: false,
|
||||
body: false,
|
||||
},
|
||||
on_violation: None,
|
||||
require_tls_identity: true,
|
||||
}],
|
||||
on_violation: Default::default(),
|
||||
|
|
@ -1834,3 +2183,231 @@ 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,8 +18,23 @@ 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", about = "Sandboxed VMs for AI coding agents.")]
|
||||
#[command(
|
||||
name = "agent-vm",
|
||||
version,
|
||||
about = "Sandboxed microVMs for AI coding agents.",
|
||||
after_help = TOP_AFTER_HELP
|
||||
)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
cmd: Cmd,
|
||||
|
|
@ -27,26 +42,28 @@ struct Cli {
|
|||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Build (and verify) the agent-vm base image.
|
||||
/// Pull and verify the base image (run once first).
|
||||
Setup(setup::Args),
|
||||
|
||||
/// Pull the latest image from the registry into the microsandbox cache.
|
||||
/// Refresh the cached base image.
|
||||
Pull(pull::Args),
|
||||
|
||||
/// Launch Claude Code in a sandbox mounted at the project's host path.
|
||||
/// Launch Claude Code in a per-project sandbox.
|
||||
Claude(run::Args),
|
||||
|
||||
/// Launch Codex CLI in a sandbox mounted at the project's host path.
|
||||
/// Launch Codex CLI in a per-project sandbox.
|
||||
Codex(run::Args),
|
||||
|
||||
/// Launch OpenCode in a sandbox mounted at the project's host path.
|
||||
/// Launch OpenCode in a per-project sandbox.
|
||||
Opencode(run::Args),
|
||||
|
||||
/// Open a bash shell in a sandbox mounted at the project's host path.
|
||||
/// Launch GitHub Copilot CLI in a per-project sandbox.
|
||||
Copilot(run::Args),
|
||||
|
||||
/// Open a bash shell in a per-project sandbox.
|
||||
Shell(run::Args),
|
||||
|
||||
/// Exchange a string between the host and the per-project sandbox.
|
||||
/// See `agent-vm clipboard --help`.
|
||||
/// Exchange a string between the host and the sandbox.
|
||||
Clipboard(clipboard::Args),
|
||||
|
||||
/// Internal: invoked by msb's interceptor hook for matched OAuth
|
||||
|
|
@ -90,6 +107,7 @@ 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,
|
||||
|
|
|
|||
|
|
@ -24,11 +24,12 @@ 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")]
|
||||
#[arg(long, env = "AGENT_VM_IMAGE_TAG", value_name = "REF")]
|
||||
image: Option<String>,
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -20,7 +20,8 @@ use serde_json::Value;
|
|||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::host_paths::{
|
||||
atomic_write, host_claude_creds_path, host_codex_auth_path, host_opencode_auth_path,
|
||||
atomic_write, host_claude_creds_path, host_codex_auth_path, host_copilot_token_path,
|
||||
host_opencode_auth_path,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -83,6 +84,17 @@ 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
|
||||
|
|
@ -105,6 +117,15 @@ 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";
|
||||
|
||||
|
|
@ -125,6 +146,30 @@ 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>,
|
||||
}
|
||||
|
||||
|
|
@ -174,6 +219,71 @@ 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
|
||||
|
|
@ -201,6 +311,7 @@ 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")?;
|
||||
|
|
@ -224,7 +335,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)?;
|
||||
write_agent_config_defaults(state_dir, project_guest_path, want_copilot)?;
|
||||
|
||||
let anthropic_token_file = refresh_anthropic(state_dir).unwrap_or_else(|e| {
|
||||
tracing::warn!(error = %e, "anthropic credential refresh failed; skipping");
|
||||
|
|
@ -270,6 +381,34 @@ 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
|
||||
|
|
@ -281,6 +420,7 @@ pub fn refresh(
|
|||
openai_token_file,
|
||||
opencode_openai_access_token_file,
|
||||
gh_token_file,
|
||||
copilot_token_file,
|
||||
snapshot,
|
||||
})
|
||||
}
|
||||
|
|
@ -311,10 +451,76 @@ pub struct HostGitIdentity {
|
|||
/// which makes git refuse to commit rather than silently attribute to
|
||||
/// `agent-vm`.
|
||||
pub fn discover_host_git_identity() -> Option<HostGitIdentity> {
|
||||
if let Some(id) = gh_api_user_identity() {
|
||||
// `gh api user` is an HTTPS round-trip to api.github.com that costs
|
||||
// ~0.3–1.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() {
|
||||
return Some(id);
|
||||
}
|
||||
host_git_config_identity()
|
||||
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);
|
||||
}
|
||||
|
||||
/// Cap on how long we'll wait for `gh api user`. The call is an HTTPS
|
||||
|
|
@ -523,6 +729,71 @@ 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`].
|
||||
|
|
@ -575,7 +846,11 @@ 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) -> Result<()> {
|
||||
fn write_agent_config_defaults(
|
||||
state_dir: &Path,
|
||||
project_guest_path: &str,
|
||||
want_copilot: bool,
|
||||
) -> Result<()> {
|
||||
let claude_dir = state_dir.join("claude");
|
||||
std::fs::create_dir_all(&claude_dir)?;
|
||||
write_default_claude_settings(&claude_dir.join("settings.json"))?;
|
||||
|
|
@ -605,6 +880,24 @@ fn write_agent_config_defaults(state_dir: &Path, project_guest_path: &str) -> Re
|
|||
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
|
||||
|
|
@ -1117,6 +1410,43 @@ 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
|
||||
|
|
@ -1276,6 +1606,36 @@ 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
|
||||
|
|
|
|||
|
|
@ -21,12 +21,13 @@ 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")]
|
||||
#[arg(long, env = "AGENT_VM_IMAGE_TAG", value_name = "REF")]
|
||||
image: Option<String>,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,11 +3,28 @@
|
|||
# tool itself (that's the Rust binary published as
|
||||
# @wirenboard/agent-vm on npm).
|
||||
#
|
||||
# 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
|
||||
# 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
|
||||
# extras (LSPs, additional MCP servers, docker-buildx) are
|
||||
# deliberately deferred.
|
||||
# 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).
|
||||
#
|
||||
# Published to ghcr.io/wirenboard/agent-vm-template:latest by the
|
||||
# hourly CI workflow (.github/workflows/build-image.yml). Source-
|
||||
|
|
@ -17,19 +34,96 @@
|
|||
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 + 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`."
|
||||
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`."
|
||||
|
||||
# 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
|
||||
PATH=/root/.local/bin:/root/.claude/local/bin:/root/.opencode/bin:/usr/local/bin:/usr/bin:/usr/sbin:/bin \
|
||||
LANG=C.UTF-8
|
||||
|
||||
# Image-API contract version. Bump on BREAKING image changes only
|
||||
# (new required mount points, changed env-var contracts, removed
|
||||
# binaries, renamed in-VM paths). Routine agent-version refreshes
|
||||
# do NOT change this. See `crates/agent-vm/src/defaults.rs` for the
|
||||
# matching range in the binary.
|
||||
# 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.
|
||||
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 \
|
||||
|
|
@ -39,6 +133,13 @@ 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
|
||||
|
|
@ -134,6 +235,21 @@ 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 \' \
|
||||
|
|
@ -159,27 +275,6 @@ 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:
|
||||
|
|
@ -219,20 +314,411 @@ RUN apt-get update \
|
|||
&& printf '%s\n' '{' ' "storage-driver": "fuse-overlayfs"' '}' \
|
||||
> /etc/docker/daemon.json
|
||||
|
||||
# Claude Code official installer.
|
||||
RUN curl -fsSL https://claude.ai/install.sh | bash
|
||||
# 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
|
||||
|
||||
# OpenCode official installer. Installs into ~/.opencode/bin; PATH already
|
||||
# includes it. Symlink into /usr/local/bin so non-login shells find it too.
|
||||
RUN curl -fsSL https://opencode.ai/install | bash \
|
||||
&& ln -sf /root/.opencode/bin/opencode /usr/local/bin/opencode
|
||||
# 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; }
|
||||
|
||||
# Codex CLI official installer.
|
||||
RUN curl -fsSL https://github.com/openai/codex/releases/latest/download/install.sh | sh
|
||||
# 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
|
||||
|
||||
# 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
|
||||
# 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 =="
|
||||
|
||||
# Smoke entrypoint; the launcher overrides this in Phase 2.
|
||||
CMD ["/bin/bash"]
|
||||
|
|
|
|||
|
|
@ -125,8 +125,53 @@ 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}"
|
||||
|
|
|
|||
38
images/seed-claude-plugins.sh
Normal file
38
images/seed-claude-plugins.sh
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
#!/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
|
||||
|
|
@ -9,15 +9,14 @@ 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/` — 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`.
|
||||
- `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`.
|
||||
|
||||
## How releases happen
|
||||
|
||||
|
|
|
|||
9
npm-dist/agent-vm-linux-arm64/README.md
Normal file
9
npm-dist/agent-vm-linux-arm64/README.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# @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>.
|
||||
0
npm-dist/agent-vm-linux-arm64/bin/.gitkeep
Normal file
0
npm-dist/agent-vm-linux-arm64/bin/.gitkeep
Normal file
0
npm-dist/agent-vm-linux-arm64/lib/.gitkeep
Normal file
0
npm-dist/agent-vm-linux-arm64/lib/.gitkeep
Normal file
22
npm-dist/agent-vm-linux-arm64/package.json
Normal file
22
npm-dist/agent-vm-linux-arm64/package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
90
npm-dist/agent-vm/bin/agent-vm.dispatch.test.js
Normal file
90
npm-dist/agent-vm/bin/agent-vm.dispatch.test.js
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
#!/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)");
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@
|
|||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@wirenboard/agent-vm-linux-x64": "0.0.0"
|
||||
"@wirenboard/agent-vm-linux-x64": "0.0.0",
|
||||
"@wirenboard/agent-vm-linux-arm64": "0.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
|
|
|||
23
profiling/build_legacy.sh
Executable file
23
profiling/build_legacy.sh
Executable file
|
|
@ -0,0 +1,23 @@
|
|||
#!/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))"
|
||||
45
profiling/build_variants.sh
Executable file
45
profiling/build_variants.sh
Executable file
|
|
@ -0,0 +1,45 @@
|
|||
#!/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
|
||||
48
profiling/measure_variants.sh
Executable file
48
profiling/measure_variants.sh
Executable file
|
|
@ -0,0 +1,48 @@
|
|||
#!/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
2
vendor/microsandbox
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit e7590e28d53f8816a94ec38cdafd0ab5a4a4b543
|
||||
Subproject commit d6b9b11732771266169beb4043a4164235a67d64
|
||||
Loading…
Add table
Add a link
Reference in a new issue