diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..ce486c6 --- /dev/null +++ b/.cargo/config.toml @@ -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" diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml index a4a8e7e..36938d2 100644 --- a/.github/workflows/build-image.yml +++ b/.github/workflows/build-image.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bb4c23f --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release-npm.yml b/.github/workflows/release-npm.yml index cd33bbd..288e908 100644 --- a/.github/workflows/release-npm.yml +++ b/.github/workflows/release-npm.yml @@ -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 diff --git a/.gitignore b/.gitignore index f502c6f..6eb5430 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..fe955d9 --- /dev/null +++ b/AGENTS.md @@ -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 ` 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 # produces "Merge ...: ..." +$EDITOR Cargo.toml # version = "0.1.N+1" +git commit -am "v0.1.N+1: bump for " +``` + +`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 ` +2. `cd ../.. && git add vendor/microsandbox` (bumps the gitlink) +3. `git merge --no-ff ` + (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 /vendor/microsandbox push /.git/modules/vendor/microsandbox :` +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. diff --git a/Cargo.lock b/Cargo.lock index 796c687..b1328aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,13 +21,14 @@ dependencies = [ [[package]] name = "agent-vm" -version = "0.1.15" +version = "0.1.25" dependencies = [ "anyhow", "base64", "clap", "console", "indicatif", + "ipnetwork", "libc", "microsandbox", "microsandbox-network", @@ -128,9 +129,9 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "asn1-rs" -version = "0.6.2" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" dependencies = [ "asn1-rs-derive", "asn1-rs-impl", @@ -138,15 +139,15 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror 1.0.69", + "thiserror 2.0.18", "time", ] [[package]] name = "asn1-rs-derive" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", @@ -360,6 +361,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] @@ -406,6 +408,15 @@ dependencies = [ "virtue", ] +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -538,12 +549,6 @@ dependencies = [ "shlex", ] -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - [[package]] name = "cfg-if" version = "1.0.4" @@ -638,6 +643,7 @@ dependencies = [ "anstyle", "clap_lex", "strsim", + "terminal_size", ] [[package]] @@ -1039,9 +1045,9 @@ dependencies = [ [[package]] name = "der-parser" -version = "9.0.0" +version = "10.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" dependencies = [ "asn1-rs", "displaydoc", @@ -1171,9 +1177,9 @@ dependencies = [ [[package]] name = "docker_credential" -version = "1.3.3" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4564c274ebf369f501de192b02a0b81a5c4bda375abfe526aa70fc702fa6fa0" +checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d" dependencies = [ "base64", "serde", @@ -1394,6 +1400,15 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures" version = "0.3.32" @@ -1641,6 +1656,11 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -1759,6 +1779,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "httlib-hpack" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40cf60e5e8567c6ff914a590f1452821de9377a560338a562e570a6ff052aae3" +dependencies = [ + "httlib-huffman", +] + +[[package]] +name = "httlib-huffman" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a9fcbcc408c5526c3ab80d534e5c86e7967c1fb7aa0a8c76abd1edc27deb877" + [[package]] name = "http" version = "1.4.0" @@ -1869,7 +1904,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2", "system-configuration", "tokio", "tower-service", @@ -2072,6 +2107,26 @@ dependencies = [ "syn", ] +[[package]] +name = "inotify" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1" +dependencies = [ + "bitflags 2.11.1", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.1.4" @@ -2109,22 +2164,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys 0.3.1", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", -] - [[package]] name = "jni" version = "0.22.4" @@ -2134,7 +2173,7 @@ dependencies = [ "cfg-if", "combine", "jni-macros", - "jni-sys 0.4.1", + "jni-sys", "log", "simd_cesu8", "thiserror 2.0.18", @@ -2155,15 +2194,6 @@ dependencies = [ "syn", ] -[[package]] -name = "jni-sys" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" -dependencies = [ - "jni-sys 0.4.1", -] - [[package]] name = "jni-sys" version = "0.4.1" @@ -2211,6 +2241,7 @@ version = "10.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" dependencies = [ + "aws-lc-rs", "base64", "getrandom 0.2.17", "js-sys", @@ -2252,6 +2283,26 @@ version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.11.1", + "libc", +] + [[package]] name = "kvm-bindings" version = "0.14.0" @@ -2404,6 +2455,15 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "lru" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -2452,12 +2512,14 @@ dependencies = [ [[package]] name = "microsandbox" -version = "0.4.6" +version = "0.5.7" dependencies = [ "astral-tokio-tar", "async-compression", + "base64", "bytes", "chrono", + "ciborium", "crossterm", "dbus", "dirs", @@ -2467,15 +2529,19 @@ dependencies = [ "hex", "keyring", "libc", + "microsandbox-agent-client", "microsandbox-db", "microsandbox-filesystem", "microsandbox-image", + "microsandbox-metrics", "microsandbox-migration", "microsandbox-network", "microsandbox-protocol", "microsandbox-runtime", + "microsandbox-types", "microsandbox-utils", - "nix 0.30.1", + "nix 0.31.3", + "notify", "rand 0.10.1", "reqwest", "scopeguard", @@ -2487,14 +2553,27 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "tokio-tungstenite", "tracing", "typed-builder", "which", ] +[[package]] +name = "microsandbox-agent-client" +version = "0.5.7" +dependencies = [ + "ciborium", + "microsandbox-protocol", + "serde", + "thiserror 2.0.18", + "tokio", + "tracing", +] + [[package]] name = "microsandbox-db" -version = "0.4.6" +version = "0.5.7" dependencies = [ "async-trait", "sea-orm", @@ -2505,7 +2584,7 @@ dependencies = [ [[package]] name = "microsandbox-filesystem" -version = "0.4.6" +version = "0.5.7" dependencies = [ "libc", "microsandbox-utils", @@ -2517,7 +2596,7 @@ dependencies = [ [[package]] name = "microsandbox-image" -version = "0.4.6" +version = "0.5.7" dependencies = [ "astral-tokio-tar", "async-compression", @@ -2526,12 +2605,13 @@ dependencies = [ "libc", "microsandbox-utils", "oci-client", - "oci-spec", + "oci-spec 0.10.0", "rustls-pemfile", "scopeguard", "serde", "serde_json", "sha2 0.11.0", + "tar", "thiserror 2.0.18", "tokio", "tokio-util", @@ -2539,16 +2619,27 @@ dependencies = [ "xattr", ] +[[package]] +name = "microsandbox-metrics" +version = "0.5.7" +dependencies = [ + "chrono", + "libc", + "thiserror 2.0.18", + "tracing", +] + [[package]] name = "microsandbox-migration" -version = "0.4.6" +version = "0.5.7" dependencies = [ "sea-orm-migration", + "serde_json", ] [[package]] name = "microsandbox-network" -version = "0.4.6" +version = "0.5.7" dependencies = [ "base64", "bytes", @@ -2558,9 +2649,11 @@ dependencies = [ "futures", "hickory-client", "hickory-proto", + "httlib-hpack", + "httparse", "ipnetwork", "libc", - "lru", + "lru 0.18.0", "microsandbox-protocol", "microsandbox-utils", "msb_krun", @@ -2574,7 +2667,7 @@ dependencies = [ "rustls-pemfile", "serde", "smoltcp", - "socket2 0.5.10", + "socket2", "system-configuration", "thiserror 2.0.18", "time", @@ -2585,19 +2678,21 @@ dependencies = [ [[package]] name = "microsandbox-protocol" -version = "0.4.6" +version = "0.5.7" dependencies = [ "chrono", "ciborium", + "microsandbox-types", "serde", "serde_bytes", + "strum 0.28.0", "thiserror 2.0.18", "tokio", ] [[package]] name = "microsandbox-runtime" -version = "0.4.6" +version = "0.5.7" dependencies = [ "bytes", "chrono", @@ -2606,11 +2701,13 @@ dependencies = [ "libc", "microsandbox-db", "microsandbox-filesystem", + "microsandbox-metrics", "microsandbox-network", "microsandbox-protocol", + "microsandbox-types", "microsandbox-utils", "msb_krun", - "nix 0.30.1", + "nix 0.31.3", "rustls", "sea-orm", "serde", @@ -2621,15 +2718,25 @@ dependencies = [ "tracing", ] +[[package]] +name = "microsandbox-types" +version = "0.5.7" +dependencies = [ + "chrono", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.18", +] + [[package]] name = "microsandbox-utils" -version = "0.4.6" +version = "0.5.7" dependencies = [ "dirs", "libc", "reflink-copy", "scopeguard", - "ureq", ] [[package]] @@ -2668,8 +2775,9 @@ dependencies = [ [[package]] name = "msb_krun" -version = "0.1.13" -source = "git+https://github.com/wirenboard/libkrun?rev=38e46f2#38e46f2ea49fba33814191b7e16756d6801a8d11" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a554acf513be16b336fbcd4a9bab29a78b06a5ddf94e917481cfeafc1078f5" dependencies = [ "crossbeam-channel", "kvm-bindings", @@ -2687,8 +2795,9 @@ dependencies = [ [[package]] name = "msb_krun_arch" -version = "0.1.13" -source = "git+https://github.com/wirenboard/libkrun?rev=38e46f2#38e46f2ea49fba33814191b7e16756d6801a8d11" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d270d914cee3125cf71734b9a5aa60fe7d9648f09511999199424e0fe1a88c6" dependencies = [ "kvm-bindings", "kvm-ioctls", @@ -2702,13 +2811,15 @@ dependencies = [ [[package]] name = "msb_krun_arch_gen" -version = "0.1.13" -source = "git+https://github.com/wirenboard/libkrun?rev=38e46f2#38e46f2ea49fba33814191b7e16756d6801a8d11" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8372d1e4e8c853e20ffb5568a4cc3f930a2bf1ce25bc346812ac7b84e84639d4" [[package]] name = "msb_krun_cpuid" -version = "0.1.13" -source = "git+https://github.com/wirenboard/libkrun?rev=38e46f2#38e46f2ea49fba33814191b7e16756d6801a8d11" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "179542321455c65a8e4b14c09bc8443227a89cd46ef2c9c06f0ee1ab11f510ea" dependencies = [ "kvm-bindings", "kvm-ioctls", @@ -2717,8 +2828,9 @@ dependencies = [ [[package]] name = "msb_krun_devices" -version = "0.1.13" -source = "git+https://github.com/wirenboard/libkrun?rev=38e46f2#38e46f2ea49fba33814191b7e16756d6801a8d11" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67350e76fd466e1a089b89fa548ba85a92f237c2d3dab179ba675cfd18eb9a39" dependencies = [ "bitflags 1.3.2", "capng", @@ -2730,7 +2842,7 @@ dependencies = [ "libc", "libloading", "log", - "lru", + "lru 0.16.4", "msb_krun_arch", "msb_krun_hvf", "msb_krun_polly", @@ -2744,8 +2856,9 @@ dependencies = [ [[package]] name = "msb_krun_hvf" -version = "0.1.13" -source = "git+https://github.com/wirenboard/libkrun?rev=38e46f2#38e46f2ea49fba33814191b7e16756d6801a8d11" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13eaca12857149c3552007a18c7f863ccc6b4dddcf2863c0bcc8fbc2aa2d4c6e" dependencies = [ "crossbeam-channel", "libloading", @@ -2755,8 +2868,9 @@ dependencies = [ [[package]] name = "msb_krun_kernel" -version = "0.1.13" -source = "git+https://github.com/wirenboard/libkrun?rev=38e46f2#38e46f2ea49fba33814191b7e16756d6801a8d11" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "514f7493860978b08819fdcc4ef3d1887fd4d2b5e26ad0a0eabcec005410df94" dependencies = [ "msb_krun_utils", "vm-memory 0.16.2", @@ -2764,8 +2878,9 @@ dependencies = [ [[package]] name = "msb_krun_polly" -version = "0.1.13" -source = "git+https://github.com/wirenboard/libkrun?rev=38e46f2#38e46f2ea49fba33814191b7e16756d6801a8d11" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08ebbb0336b9cd8dcffa9c47d01a729de4f83c39fc099620f4f9c4d8ef69f460" dependencies = [ "libc", "msb_krun_utils", @@ -2773,16 +2888,18 @@ dependencies = [ [[package]] name = "msb_krun_smbios" -version = "0.1.13" -source = "git+https://github.com/wirenboard/libkrun?rev=38e46f2#38e46f2ea49fba33814191b7e16756d6801a8d11" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f25cb991386c6aca7c74d0bd8b327a807290830e439fbb473c5e1b62a7c66d32" dependencies = [ "vm-memory 0.16.2", ] [[package]] name = "msb_krun_utils" -version = "0.1.13" -source = "git+https://github.com/wirenboard/libkrun?rev=38e46f2#38e46f2ea49fba33814191b7e16756d6801a8d11" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7104f87f235ef5cc320ffa760b7cd3635960b58cdc5e1ec89df852fc538f042" dependencies = [ "bitflags 1.3.2", "crossbeam-channel", @@ -2795,8 +2912,9 @@ dependencies = [ [[package]] name = "msb_krun_vmm" -version = "0.1.13" -source = "git+https://github.com/wirenboard/libkrun?rev=38e46f2#38e46f2ea49fba33814191b7e16756d6801a8d11" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6742a7fba174849b924a370bfba0fe7be8435253bff26303aec34ac89641ae63" dependencies = [ "bzip2", "crossbeam-channel", @@ -2855,6 +2973,18 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -2865,6 +2995,33 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.11.1", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.11.1", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2972,24 +3129,25 @@ dependencies = [ [[package]] name = "oci-client" -version = "0.16.1" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b7f8deaffcd3b0e3baf93dddcab3d18b91d46dc37d38a8b170089b234de5bb3" +checksum = "5261a7fb43d9c53b8e63e6d5e86860719dad253d015d022066c72d585125aed8" dependencies = [ "bytes", "chrono", "futures-util", + "hex", "http", "http-auth", "jsonwebtoken", "lazy_static", - "oci-spec", + "oci-spec 0.9.0", "olpc-cjson", "regex", "reqwest", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", "thiserror 2.0.18", "tokio", "tracing", @@ -3009,15 +3167,32 @@ dependencies = [ "serde", "serde_json", "strum 0.27.2", - "strum_macros", + "strum_macros 0.27.2", + "thiserror 2.0.18", +] + +[[package]] +name = "oci-spec" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df6f876ad774d6a676f7e968f5c3edacc32f90e65fe680a8b686235396556fb" +dependencies = [ + "const_format", + "derive_builder", + "getset", + "regex", + "serde", + "serde_json", + "strum 0.27.2", + "strum_macros 0.27.2", "thiserror 2.0.18", ] [[package]] name = "oid-registry" -version = "0.7.1" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" dependencies = [ "asn1-rs", ] @@ -3338,7 +3513,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.3", + "socket2", "thiserror 2.0.18", "tokio", "tracing", @@ -3376,7 +3551,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2", "tracing", "windows-sys 0.60.2", ] @@ -3490,9 +3665,9 @@ checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rcgen" -version = "0.13.2" +version = "0.14.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" dependencies = [ "pem", "ring", @@ -3598,7 +3773,7 @@ dependencies = [ "quinn", "rustls", "rustls-pki-types", - "rustls-platform-verifier 0.7.0", + "rustls-platform-verifier", "serde", "serde_json", "serde_urlencoded", @@ -3632,7 +3807,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -3740,27 +3915,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-platform-verifier" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" -dependencies = [ - "core-foundation 0.10.1", - "core-foundation-sys", - "jni 0.21.1", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework 3.7.0", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.61.2", -] - [[package]] name = "rustls-platform-verifier" version = "0.7.0" @@ -3769,7 +3923,7 @@ checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", - "jni 0.22.4", + "jni", "log", "once_cell", "rustls", @@ -3797,7 +3951,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -4258,16 +4412,6 @@ dependencies = [ "managed", ] -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "socket2" version = "0.6.3" @@ -4532,6 +4676,15 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", +] + [[package]] name = "strum_macros" version = "0.27.2" @@ -4544,6 +4697,18 @@ dependencies = [ "syn", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -4626,6 +4791,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -4743,7 +4918,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.3", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] @@ -4780,6 +4955,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4936,6 +5123,22 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "sha1", + "thiserror 2.0.18", +] + [[package]] name = "typed-builder" version = "0.23.2" @@ -5030,6 +5233,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -5042,36 +5251,6 @@ version = "0.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" -[[package]] -name = "ureq" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" -dependencies = [ - "base64", - "flate2", - "log", - "percent-encoding", - "rustls", - "rustls-pki-types", - "rustls-platform-verifier 0.6.2", - "ureq-proto", - "utf8-zero", - "webpki-roots 1.0.7", -] - -[[package]] -name = "ureq-proto" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" -dependencies = [ - "base64", - "http", - "httparse", - "log", -] - [[package]] name = "url" version = "2.5.8" @@ -5084,12 +5263,6 @@ dependencies = [ "serde", ] -[[package]] -name = "utf8-zero" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -5528,15 +5701,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - [[package]] name = "windows-sys" version = "0.48.0" @@ -5582,21 +5746,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - [[package]] name = "windows-targets" version = "0.48.5" @@ -5654,12 +5803,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -5678,12 +5821,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -5702,12 +5839,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -5738,12 +5869,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -5762,12 +5887,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -5786,12 +5905,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -5810,12 +5923,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -5945,9 +6052,9 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "x509-parser" -version = "0.16.0" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" dependencies = [ "asn1-rs", "data-encoding", @@ -5957,7 +6064,7 @@ dependencies = [ "oid-registry", "ring", "rusticata-macros", - "thiserror 1.0.69", + "thiserror 2.0.18", "time", ] @@ -5989,10 +6096,11 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "yasna" -version = "0.5.2" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" dependencies = [ + "bit-vec", "time", ] @@ -6239,3 +6347,58 @@ dependencies = [ "quote", "syn", ] + +[[patch.unused]] +name = "msb_krun" +version = "0.1.13" +source = "git+https://github.com/wirenboard/libkrun?rev=38e46f2#38e46f2ea49fba33814191b7e16756d6801a8d11" + +[[patch.unused]] +name = "msb_krun_arch" +version = "0.1.13" +source = "git+https://github.com/wirenboard/libkrun?rev=38e46f2#38e46f2ea49fba33814191b7e16756d6801a8d11" + +[[patch.unused]] +name = "msb_krun_arch_gen" +version = "0.1.13" +source = "git+https://github.com/wirenboard/libkrun?rev=38e46f2#38e46f2ea49fba33814191b7e16756d6801a8d11" + +[[patch.unused]] +name = "msb_krun_cpuid" +version = "0.1.13" +source = "git+https://github.com/wirenboard/libkrun?rev=38e46f2#38e46f2ea49fba33814191b7e16756d6801a8d11" + +[[patch.unused]] +name = "msb_krun_devices" +version = "0.1.13" +source = "git+https://github.com/wirenboard/libkrun?rev=38e46f2#38e46f2ea49fba33814191b7e16756d6801a8d11" + +[[patch.unused]] +name = "msb_krun_hvf" +version = "0.1.13" +source = "git+https://github.com/wirenboard/libkrun?rev=38e46f2#38e46f2ea49fba33814191b7e16756d6801a8d11" + +[[patch.unused]] +name = "msb_krun_kernel" +version = "0.1.13" +source = "git+https://github.com/wirenboard/libkrun?rev=38e46f2#38e46f2ea49fba33814191b7e16756d6801a8d11" + +[[patch.unused]] +name = "msb_krun_polly" +version = "0.1.13" +source = "git+https://github.com/wirenboard/libkrun?rev=38e46f2#38e46f2ea49fba33814191b7e16756d6801a8d11" + +[[patch.unused]] +name = "msb_krun_smbios" +version = "0.1.13" +source = "git+https://github.com/wirenboard/libkrun?rev=38e46f2#38e46f2ea49fba33814191b7e16756d6801a8d11" + +[[patch.unused]] +name = "msb_krun_utils" +version = "0.1.13" +source = "git+https://github.com/wirenboard/libkrun?rev=38e46f2#38e46f2ea49fba33814191b7e16756d6801a8d11" + +[[patch.unused]] +name = "msb_krun_vmm" +version = "0.1.13" +source = "git+https://github.com/wirenboard/libkrun?rev=38e46f2#38e46f2ea49fba33814191b7e16756d6801a8d11" diff --git a/Cargo.toml b/Cargo.toml index c768ef1..5608ca3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/LAUNCH-PROFILE.md b/LAUNCH-PROFILE.md new file mode 100644 index 0000000..24c5ebf --- /dev/null +++ b/LAUNCH-PROFILE.md @@ -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 +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. +``` diff --git a/PLAN.md b/PLAN.md index 302c504..8c313c0 100644 --- a/PLAN.md +++ b/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//`. -- 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 - //`). -- 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/`), 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 - `.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 `/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 `.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 `/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///`, `/user`, `/user/repos`, `/orgs//`, - `/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 - `///...` 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 -`/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 ` 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-`; 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 `.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. diff --git a/README.md b/README.md index 264437a..317fe4b 100644 --- a/README.md +++ b/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:` | +| `--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:` (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. diff --git a/crates/agent-vm/Cargo.toml b/crates/agent-vm/Cargo.toml index d4f8d6e..86ca1db 100644 --- a/crates/agent-vm/Cargo.toml +++ b/crates/agent-vm/Cargo.toml @@ -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 diff --git a/crates/agent-vm/src/host_paths.rs b/crates/agent-vm/src/host_paths.rs index 1fda32e..56ab537 100644 --- a/crates/agent-vm/src/host_paths.rs +++ b/crates/agent-vm/src/host_paths.rs @@ -47,6 +47,16 @@ pub fn host_opencode_auth_path() -> Option { 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": ""}`). +/// 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 { + 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 diff --git a/crates/agent-vm/src/image_check.rs b/crates/agent-vm/src/image_check.rs index 3ae7a9e..47f0969 100644 --- a/crates/agent-vm/src/image_check.rs +++ b/crates/agent-vm/src/image_check.rs @@ -53,7 +53,24 @@ pub async fn check_for_update(image_ref: &str) -> Result> { /// on any failure so callers can decide what to do with the silence. pub async fn fetch_remote_digest(image_ref: &str) -> Option { 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> { @@ -79,22 +96,10 @@ async fn remote_manifest_digest(parsed: &ParsedRef) -> Result> { 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> { 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> { + 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, + scope: Option, +} + +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 { + 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> { + 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()); + } } diff --git a/crates/agent-vm/src/intercept_hook.rs b/crates/agent-vm/src/intercept_hook.rs index cc1eb5c..13c8f5f 100644 --- a/crates/agent-vm/src/intercept_hook.rs +++ b/crates/agent-vm/src/intercept_hook.rs @@ -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> { - 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> { + 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> { 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> { - 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> { + 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> { 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, +} + +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::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 { + 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, + max_concurrent: Arc, + acquisitions: Arc| { + 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" + ); + } +} diff --git a/crates/agent-vm/src/main.rs b/crates/agent-vm/src/main.rs index 16f09ed..7370e1e 100644 --- a/crates/agent-vm/src/main.rs +++ b/crates/agent-vm/src/main.rs @@ -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, diff --git a/crates/agent-vm/src/pull.rs b/crates/agent-vm/src/pull.rs index eba4926..0164f31 100644 --- a/crates/agent-vm/src/pull.rs +++ b/crates/agent-vm/src/pull.rs @@ -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, } diff --git a/crates/agent-vm/src/run.rs b/crates/agent-vm/src/run.rs index 1f84626..7bf7709 100644 --- a/crates/agent-vm/src/run.rs +++ b/crates/agent-vm/src/run.rs @@ -23,6 +23,26 @@ use crate::session::ProjectSession; /// a host project rooted here and fall back to `/workspace` instead. const TMPFS_GUEST_PREFIXES: &[&str] = &["/tmp", "/run", "/dev/shm", "/var/run"]; +/// Environment variables agent-vm injects into *every* guest, regardless of +/// agent or project. Listed in one place so the set is discoverable and +/// guard-testable. +/// +/// - `IS_SANDBOX=1`: Claude Code refuses to run as root with +/// `--dangerously-skip-permissions` unless this is set. The microVM is our +/// security boundary, so the in-guest CLI's extra guard is redundant; same +/// var the original Bash agent-vm used. +/// - `LANG=C.UTF-8`: the base image ships with no locale (`LANG`/`LC_*` +/// empty → C/POSIX). That breaks non-ASCII paths two ways: bash/readline +/// draws a Cyrillic cwd as `M-P…` meta-escapes instead of glyphs, and +/// 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`), ASCII- +/// sorting + English-messages but UTF-8-aware — the right neutral sandbox +/// default. We don't propagate the host's `$LANG` (that locale may not +/// exist in the guest image and would silently fall back to C). Also +/// pinned in `images/Dockerfile` for non-agent-vm uses of the image. +const GUEST_ALWAYS_ENV: &[(&str, &str)] = &[("IS_SANDBOX", "1"), ("LANG", "C.UTF-8")]; + fn guest_path_is_safe(project: &Path) -> bool { let s = match project.to_str() { Some(s) => s, @@ -33,6 +53,66 @@ fn guest_path_is_safe(project: &Path) -> bool { .any(|p| s == *p || s.starts_with(&format!("{p}/"))) } +/// Whether `s` is safe to place on the guest **kernel command line**. +/// +/// libkrun packs the guest workdir (`KRUN_WORKDIR=`) into the kernel +/// command line, which its `Cmdline` builder validates as printable ASCII +/// only (`valid_char` accepts `' '..='~'`) and `.unwrap()`s — a non-ASCII +/// byte panics the VMM (`InvalidAscii`) before boot, and a space is +/// mis-tokenized by the guest kernel (`/proc/cmdline` splits on whitespace). +/// So a path that isn't printable, non-space ASCII (`is_ascii_graphic`, +/// `0x21..=0x7e`) can't be the `KRUN_WORKDIR` value. +/// +/// Note the *mount* specs no longer ride the cmdline — they travel via the +/// boot-params side channel (see [`microsandbox`]'s runtime), so the project +/// itself is still mirrored at its real (possibly Cyrillic) path. This +/// predicate only governs the `KRUN_WORKDIR` placeholder: when it fails we +/// hand libkrun `/` and pin the agent's real cwd via the exec request +/// instead (see [`launch`]). +fn guest_path_is_cmdline_safe(s: &str) -> bool { + s.bytes().all(|b| b.is_ascii_graphic()) +} + +/// Whether `s` can be carried into the guest as a mount point at all. +/// +/// Mount specs travel via the boot-params side channel, framed as +/// `KEY\tVALUE\n` lines. That transport is byte-transparent for everything +/// except a control character — a TAB or newline in the path would break +/// the framing, and other control bytes have no business in a mount point. +/// Such a path can't be mirrored and falls back to `/workspace`. +fn guest_path_is_mountable(s: &str) -> bool { + !s.chars().any(|c| c.is_control()) +} + +/// Decide the in-guest path to mirror the project at, plus a one-line +/// reason when we had to fall back to `/workspace` (for the launch +/// notice). The host bind always targets the real `project_dir` +/// regardless; only the *guest-visible* path changes. +/// +/// A non-ASCII or whitespace path is mirrored at its **real** location: +/// the mount spec rides the boot-params side channel (not the cmdline), +/// the mount-point dir is baked byte-for-byte into the rootfs, and the +/// agent's cwd is delivered over the byte-safe exec channel. Only two +/// things still force `/workspace`: a path under a guest tmpfs mount (see +/// [`guest_path_is_safe`]) would be wiped at boot, and a path with control +/// characters (see [`guest_path_is_mountable`]) can't be framed for the +/// side channel. +fn resolve_project_guest_path( + project_dir: &Path, + host_path: &str, +) -> (String, Option<&'static str>) { + if !guest_path_is_safe(project_dir) { + ("/workspace".to_string(), Some("is under a tmpfs mount")) + } else if !guest_path_is_mountable(host_path) { + ( + "/workspace".to_string(), + Some("contains control characters that can't be carried into the guest"), + ) + } else { + (host_path.to_string(), None) + } +} + /// Absolute directories that must exist inside the guest rootfs before /// microsandbox can mount the project at its host path. Returns paths from /// shallowest to deepest, e.g. for `/home/boger/work/foo`: @@ -61,6 +141,7 @@ pub enum Agent { Claude, Codex, Opencode, + Copilot, Shell, } @@ -70,6 +151,7 @@ impl Agent { Agent::Claude => "claude", Agent::Codex => "codex", Agent::Opencode => "opencode", + Agent::Copilot => "copilot", Agent::Shell => "bash", } } @@ -89,37 +171,94 @@ impl Agent { match self { Agent::Claude => &["--dangerously-skip-permissions"], Agent::Shell => &["-O", "histappend"], + // Copilot CLI's `--allow-all-tools` disables its in-VM + // "may I run this?" confirmations. The microVM is the + // security boundary, so the extra prompts add no + // protection and break non-interactive / agent-mode + // flows — same reasoning as `--dangerously-skip-permissions` + // for Claude. Drop it (`-> &[]`) if the user already + // passed it; the filter in `launch` handles that. + Agent::Copilot => &["--allow-all-tools"], Agent::Codex | Agent::Opencode => &[], } } } +// Footer shown under `-h` (the short summary). A few high-value +// examples plus a pointer to `--help`. Printed verbatim by clap, so +// this is exactly what the user sees. One `Args` backs all five launch +// verbs (claude/codex/opencode/copilot/shell), so this footer can't vary +// per verb — the examples use `claude` and the header says so. +const LAUNCH_AFTER_HELP: &str = "\ +Examples (claude shown; codex/opencode/shell take the same options): + agent-vm claude launch Claude Code in the current project + agent-vm shell open a bash shell instead + agent-vm claude -p 8080:3000 publish guest :3000 to host 127.0.0.1:8080 + agent-vm claude -- --model opus forward args to the agent (after --) + +Trailing args go to the agent. Run with --help for networking, security, and env details."; + +// Fuller footer shown under `--help`. Same single-`Args` constraint: +// claude/codex/opencode/copilot/shell all share this. +const LAUNCH_AFTER_LONG_HELP: &str = "\ +Examples (claude shown; codex/opencode/shell take the same options): + agent-vm claude launch in the current project + agent-vm shell open a bash shell instead + agent-vm shell -- cargo test run one command, then exit + agent-vm claude -- --model opus --resume forward args to the agent + agent-vm claude --memory 8 --cpus 4 a bigger sandbox + agent-vm claude --mount ~/ref -p 3000:3000 extra mount + publish a port + agent-vm claude --repo owner/other-repo widen the GitHub allow-list + +Networking (deny-by-default; flags compose): + --publish host → guest open an inbound port to a guest service + --auto-publish guest → host mirror guest listeners onto host loopback + --allow-egress guest → IP/LAN reach one IP or subnet + --allow-lan guest → LAN reach the whole private range + --allow-host guest → host reach the host's 127.0.0.1 services + +Environment: + AGENT_VM_MEMORY_GIB / AGENT_VM_CPUS same as --memory / --cpus + AGENT_VM_IMAGE_TAG same as --image + AGENT_VM_INSECURE_REGISTRY allow plain-HTTP registry pulls + AGENT_VM_STATE_DIR override the per-project state dir + AGENT_VM_PROFILE print per-phase boot timings + AGENT_VM_DEBUG_CONFIG dump the SandboxConfig JSON before boot + AGENT_VM_NO_CHROME_MCP skip the Chrome DevTools MCP setup + RUST_LOG tracing filter (e.g. agent_vm=debug)"; + #[derive(ClapArgs)] +#[command(after_help = LAUNCH_AFTER_HELP, after_long_help = LAUNCH_AFTER_LONG_HELP)] pub struct Args { - /// Sandbox memory in GiB. - #[arg(long, env = "AGENT_VM_MEMORY_GIB", default_value_t = 2)] + /// Sandbox memory, in GiB. + #[arg(long, env = "AGENT_VM_MEMORY_GIB", default_value_t = 2, + value_name = "GIB", help_heading = "Sandbox resources")] memory: u32, /// vCPU count for the sandbox. - #[arg(long, env = "AGENT_VM_CPUS", default_value_t = 2)] + #[arg(long, env = "AGENT_VM_CPUS", default_value_t = 2, + value_name = "N", help_heading = "Sandbox resources")] cpus: u8, - /// Don't inject host gh/git credentials into the guest. With this - /// set, no gh auth flows through the proxy and the guest agent - /// can't `git push` / `gh pr create` etc. Useful for one-off + /// Don't inject host gh/git credentials into the guest. + /// + /// With this set, no gh auth flows through the proxy and the guest + /// agent can't `git push` / `gh pr create` etc. Useful for one-off /// throwaway sessions on a repo you don't trust the agent with. - #[arg(long = "no-git", default_value_t = false)] + #[arg(long = "no-git", default_value_t = false, help_heading = "GitHub access")] no_git: bool, - /// Add a GitHub `owner/repo` slug to the per-launch allow-list - /// (repeatable). The cwd's `git remote -v` GitHub entries are - /// always included; use this to widen for cross-repo work. - #[arg(long = "repo")] + /// Add a repo to the GitHub allow-list (repeatable). + /// + /// The cwd's `git remote -v` GitHub entries are always included; + /// use this to widen the allow-list for cross-repo work. + #[arg(long = "repo", value_name = "OWNER/REPO", help_heading = "GitHub access")] repo: Vec, - /// Extra host directories to bind into the guest. Format: - /// `HOST[:GUEST]`. `GUEST` defaults to `HOST` (mirror at the - /// same absolute path). Repeatable. + /// Bind an extra host directory into the guest (repeatable). + /// + /// Format `HOST[:GUEST]`; `GUEST` defaults to `HOST` (mirror at the + /// same absolute path). /// /// Each `--mount` consumes one virtio-fs device. The microsandbox /// runtime enables msb_krun's userspace split irqchip, which on @@ -129,22 +268,113 @@ pub struct Args { /// (shared with rootfs, network, vsock, console, and any /// `--volume` disks — call it ~210 user mounts for the common /// config). You can stop worrying about it for typical workloads. - #[arg(long = "mount")] + #[arg(long = "mount", value_name = "HOST[:GUEST]", help_heading = "Mounts & ports")] mount: Vec, - /// Override the OCI image reference. Default: - /// `ghcr.io/wirenboard/agent-vm-template:latest`. Use a timestamped tag - /// (`...:YYYY-MM-DDTHH`) to pin a reproducible image. - #[arg(long, env = "AGENT_VM_IMAGE_TAG")] + /// Publish a guest TCP port to the host, docker-style (repeatable). + /// + /// Format `[HOST_BIND:]HOST_PORT:GUEST_PORT`. HOST_BIND defaults to + /// `127.0.0.1` — pass `0.0.0.0:HOST_PORT:GUEST_PORT` to expose on + /// every host interface. + /// + /// The guest service must listen on `0.0.0.0` (or the assigned + /// guest IP from `MSB_NET_IPV4`); a bare `127.0.0.1` bind inside + /// the guest is not reachable because the smoltcp dial target is + /// the guest's assigned VLAN address, not loopback. + #[arg(long = "publish", short = 'p', value_name = "[BIND:]HOST_PORT:GUEST_PORT", + help_heading = "Mounts & ports")] + publish: Vec, + + /// Auto-forward every guest listener onto the host (Lima-style). + /// + /// The runtime polls `/proc/net/tcp{,6}` inside the guest every ~2s + /// and mirrors each detected wildcard (`0.0.0.0`/`[::]`) OR + /// loopback (`127.0.0.1`/`[::1]`) TCP LISTEN socket onto a + /// host listener at `127.0.0.1:` (or an ephemeral + /// host port if the preferred one is taken). Loopback-only + /// guest services are reachable via an in-guest agentd + /// forwarder (`eth0_ip:port → 127.0.0.1:port`) — so anything + /// listening inside the guest, whether on the wildcard + /// interface or just loopback, becomes reachable on host + /// `127.0.0.1`. agent-vm prints each new mapping to stderr as + /// the runtime emits `PortEvent`s. Off by default. + /// + /// Security note: with this flag, every TCP service that + /// becomes reachable inside the guest is also reachable from + /// other processes on the host's loopback. If you don't want + /// that, omit `--auto-publish` and use `--publish` to expose + /// only the specific ports you mean to share. + #[arg(long = "auto-publish", default_value_t = false, help_heading = "Mounts & ports")] + auto_publish: bool, + + /// Allow guest egress to one IP or CIDR (repeatable). + /// + /// Examples: `--allow-egress 10.100.1.75` (single host), + /// `--allow-egress 10.100.1.0/24` (CIDR), + /// `--allow-egress fd00::1/128` (IPv6). + /// + /// The default policy (`NetworkPolicy::public_only`) only + /// allows DNS and the `Public` destination group, so RFC1918 + /// (10/8, 172.16/12, 192.168/16, 100.64/10), loopback, link- + /// local, and metadata addresses are all denied with + /// ECONNREFUSED. Use this flag to reach a specific dev box on + /// the same LAN as the host. Use `--allow-lan` instead if you + /// want to open the entire Private group at once. + #[arg(long = "allow-egress", value_name = "IP|CIDR", help_heading = "Network egress")] + allow_egress: Vec, + + /// Allow guest egress to the whole private LAN. + /// + /// Switches the egress policy from `public_only` to `non_local` + /// — adds the entire `DestinationGroup::Private` (10/8, + /// 172.16/12, 192.168/16, 100.64/10, fc00::/7) to the allow + /// list. Coarser than `--allow-egress `; useful for + /// "trust everything on my LAN". Loopback, link-local, and + /// metadata are still denied. + /// + /// Security note: a compromised in-guest process gets full + /// access to every device on your LAN with this flag. Prefer + /// `--allow-egress ` for production-ish uses. + #[arg(long = "allow-lan", default_value_t = false, help_heading = "Network egress")] + allow_lan: bool, + + /// Allow the guest to reach the host's 127.0.0.1 services. + /// + /// The smoltcp stack rewrites the per-sandbox gateway IP + /// (resolves as `host.microsandbox.internal` inside the guest) + /// to host's loopback, so e.g. a dev server bound to + /// `127.0.0.1:8080` on the host becomes reachable from the guest + /// at `host.microsandbox.internal:8080`. Adds the + /// `DestinationGroup::Host` (the gateway IP only) to the allow + /// list; loopback, link-local, metadata, and the wider LAN + /// remain denied. + /// + /// Security note: anything bound to the host's loopback — + /// including admin UIs, dev DBs, the Docker socket if it's + /// listening on a TCP port — becomes reachable from a possibly- + /// compromised in-guest process. Use only when you actually need + /// it. + #[arg(long = "allow-host", default_value_t = false, help_heading = "Network egress")] + allow_host: bool, + + /// Override the OCI image reference. + /// + /// Default: `ghcr.io/wirenboard/agent-vm-template:latest`. Use a + /// timestamped tag (`...:YYYY-MM-DDTHH`) to pin a reproducible image. + #[arg(long, env = "AGENT_VM_IMAGE_TAG", value_name = "REF", help_heading = "Image")] image: Option, - /// Don't HEAD the registry for a newer manifest digest at - /// launch (skips the "==> A newer image is available …" banner). - /// Useful in CI and on flaky networks. - #[arg(long = "no-update-check", default_value_t = false)] + /// Skip the launch-time registry update check. + /// + /// Don't HEAD the registry for a newer manifest digest (skips the + /// "==> A newer image is available …" banner). Useful in CI and on + /// flaky networks. + #[arg(long = "no-update-check", default_value_t = false, help_heading = "Image")] no_update_check: bool, - /// Args forwarded verbatim to the in-sandbox agent command. Use `--` if + /// Args passed verbatim to the agent; use -- before any agent flags. + /// + /// Forwarded verbatim to the in-sandbox agent command. Use `--` if /// any argument starts with `-` to keep clap from claiming it. #[arg(trailing_var_arg = true, allow_hyphen_values = true, num_args = 0..)] agent_args: Vec, @@ -199,14 +429,11 @@ pub async fn launch(agent: Agent, args: Args) -> Result { .project_dir .to_str() .context("project path contains non-UTF-8 bytes; not supported")?; - let project_guest_path = if guest_path_is_safe(&session.project_dir) { - host_path.to_string() - } else { - eprintln!( - "==> Project path {host_path} is under a tmpfs mount; mounting at /workspace instead" - ); - "/workspace".to_string() - }; + let (project_guest_path, remap_reason) = + resolve_project_guest_path(&session.project_dir, host_path); + if let Some(reason) = remap_reason { + eprintln!("==> Project path {host_path} {reason}; mounting at /workspace instead"); + } let mut patch_builder_steps = mkdir_chain(Path::new(&project_guest_path)); // PullPolicy::IfMissing keeps the slow part (pull + materialize) off // every launch. We separately HEAD the manifest at the registry via @@ -214,7 +441,18 @@ pub async fn launch(agent: Agent, args: Args) -> Result { // image available — the user runs `agent-vm pull` explicitly to // fetch it. if !args.no_update_check { - notify_if_update_available(image.as_str()).await; + // The update banner is purely informational, so keep it OFF the + // launch critical path: seed the baseline pulled-digest marker + // (so the banner has something to compare against on this launch, + // not just future ones) and probe the registry in the background. + // The banner prints if the ~0.9s ghcr.io round-trip resolves + // during boot; otherwise it's simply skipped and the next launch + // catches up. Previously this was awaited and blocked every boot. + let img = image.clone(); + tokio::spawn(async move { + seed_pulled_marker_if_absent(&img).await; + notify_if_update_available(&img).await; + }); } // Snapshot host credentials into per-project token files and place @@ -230,27 +468,13 @@ pub async fn launch(agent: Agent, args: Args) -> Result { // this folder?" wizard on first launch in each project. // Phase 7 (moved up): parse `--mount HOST[:GUEST]` so the // GitHub repo scan below can also walk each mount's remote + - // submodules — matches main-branch claude-vm.sh behavior. The - // libkrun IRQ pool is *tight* — empirically the project bind + - // state bind + network + agentd + OCI overlay already saturate - // it on this build, so any extra mount tends to trip a - // confusing `RegisterNetDevice(IrqsExhausted)` at boot. We let - // the user try and surface a friendly suggestion if libkrun - // rejects the config; we don't pre-cap because libkrun - // configurations vary. See discovered upstream issue #3. + // submodules — matches main-branch claude-vm.sh behavior. let extra_mounts = parse_extra_mounts(&args.mount).context("parsing --mount")?; for em in &extra_mounts { if !em.host.exists() { anyhow::bail!("--mount host path {:?} does not exist", em.host); } } - if !extra_mounts.is_empty() { - eprintln!( - "==> Note: {} --mount arg(s) — libkrun's virtio IRQ pool is tight; if you see \ - RegisterNetDevice(IrqsExhausted) at boot, drop a mount or pass --no-git to free a slot.", - extra_mounts.len() - ); - } // Phase 6: build the per-launch GitHub repo allow-list from the // cwd's `git remote -v` + its `.gitmodules` submodules + the @@ -292,8 +516,33 @@ pub async fn launch(agent: Agent, args: Args) -> Result { eprintln!("==> GitHub repo scope: (no api.github.com access)"); } - let creds = crate::secrets::refresh(&session.state_dir, &project_guest_path, use_github) - .context("snapshotting host credentials")?; + // D1: the Copilot API is reached with a GitHub OAuth token, but + // unlike the gh CLI / repo-push path it is not repo-scoped — so + // Copilot's token capture and egress must follow the *selected + // agent*, not `use_github`. A user running `agent-vm copilot` in a + // non-GitHub project (or with `--no-git`) still expects Copilot to + // work; conversely a claude/codex/opencode/shell session in a + // GitHub repo should NOT get Copilot egress opened or a duplicate + // gh token written to a copilot secret file it never uses. + let want_copilot = matches!(agent, Agent::Copilot); + + let creds = + crate::secrets::refresh(&session.state_dir, &project_guest_path, use_github, want_copilot) + .context("snapshotting host credentials")?; + + // D1: when Copilot is the selected agent but no usable token could + // be captured, fail loudly here. Otherwise the guest would send + // `Authorization: Bearer msb-copilot-placeholder-v2` to the Copilot + // API with no registered substitution entry — the proxy drops the + // connection (violation scan) or GitHub returns a confusing 401. + if want_copilot && creds.copilot_token_file.is_none() { + anyhow::bail!( + "no GitHub Copilot token found on the host. Sign in on the host \ + (e.g. `gh auth login` with a Copilot seat, or run the Copilot \ + device-flow login that writes ~/.cache/claude-vm/copilot-token.json) \ + and retry, or pick another agent." + ); + } // RAII guard so the Phase-5 host-cred mutation check runs on // *every* exit path from launch() — including `?` propagation @@ -360,13 +609,27 @@ pub async fn launch(agent: Agent, args: Args) -> Result { } let is_local_registry = crate::pull::is_plain_http_registry(&image); + // `.workdir()` becomes libkrun's `KRUN_WORKDIR`, which rides the + // printable-ASCII-only kernel command line. When the real project path + // isn't cmdline-safe (non-ASCII / whitespace) we hand libkrun `/` and + // pin the agent's real cwd via the exec request below instead — the + // exec cwd travels over the byte-safe vsock channel, and the project is + // still bind-mounted (and the agent still runs) at its true path. The + // mount spec itself reaches the guest via the boot-params side channel, + // not the cmdline. (`KRUN_WORKDIR` only sets PID-1's initial chdir, + // which agentd overrides per-exec, so the placeholder is invisible.) + let krun_workdir = if guest_path_is_cmdline_safe(&project_guest_path) { + project_guest_path.clone() + } else { + "/".to_string() + }; let mut builder = Sandbox::builder(&session.sandbox_name) .image(image.as_str()) .registry(|r| if is_local_registry { r.insecure() } else { r }) .pull_policy(PullPolicy::IfMissing) .cpus(cpus) .memory(memory_mib) - .workdir(project_guest_path.clone()) + .workdir(krun_workdir) .volume(project_guest_path.clone(), |m| m.bind(&session.project_dir)) .volume("/agent-vm-state", |m| m.bind(&session.state_dir)); // Phase 7: extra `--mount HOST[:GUEST]` binds. Each gets its own @@ -422,6 +685,12 @@ pub async fn launch(agent: Agent, args: Args) -> Result { "/root/.config/opencode", true, ) + // D1: GitHub Copilot CLI reads/writes ~/.copilot/ + // (config.json with trusted_folders + the placeholder + // token, plus its session state). secrets::refresh + // writes the config under /copilot; this exposes + // it at the standard path inside the guest. + .symlink("/agent-vm-state/copilot", "/root/.copilot", true) // Phase 6: gh/git config sits at /root/.gitconfig and // /root/.config/gh. write_guest_gh_config writes both // into state_dir; these symlinks expose them at the @@ -443,6 +712,67 @@ pub async fn launch(agent: Agent, args: Args) -> Result { }) .env("CODEX_HOME", "/agent-vm-state/codex"); + // Parse `--publish` into PublishedPort entries up front so we can + // wire them into the network builder below (the same place that + // sets up secrets/intercept). Done outside the conditional so it + // bails early on syntax errors regardless of cred state. + let publish_ports = parse_publish_args(&args.publish).context("parsing --publish")?; + for p in &publish_ports { + eprintln!( + "==> Publishing host {}:{}/{} → guest :{}", + p.host_bind, + p.host_port, + match p.protocol { + PublishProto::Tcp => "tcp", + PublishProto::Udp => "udp", + }, + p.guest_port, + ); + } + + // Parse --allow-egress entries into IpNetwork values. Same + // up-front-bail rule as --publish: syntax errors fail before + // we boot the sandbox. + let allow_egress_cidrs = + parse_allow_egress(&args.allow_egress).context("parsing --allow-egress")?; + for cidr in &allow_egress_cidrs { + eprintln!("==> Egress policy: allowing {cidr}"); + } + if args.allow_lan { + eprintln!( + "==> Egress policy: --allow-lan enabled (Private RFC1918 + 100.64/10 + fc00::/7 reachable)" + ); + } + if args.allow_host { + eprintln!( + "==> Egress policy: --allow-host enabled (host.microsandbox.internal → host 127.0.0.1 reachable)" + ); + } + + // Surface the host HTTP proxy when one is set. Host-side image pulls + // already honour it (reqwest auto-detects the env), and guest egress now + // tunnels through it via HTTP CONNECT — the microsandbox network stack + // reads the same `HTTPS_PROXY`/`HTTP_PROXY`/`ALL_PROXY`/`NO_PROXY` at boot. + // Be loud about it so the operator knows all traffic is routed there. + if let Some(proxy) = microsandbox::microsandbox_network::http_proxy::ProxyConfig::from_env() { + match (proxy.https_display(), proxy.http_display()) { + (Some(s), Some(h)) if s == h => { + eprintln!("==> Proxy: routing guest egress + image pulls through {s} (HTTP CONNECT)"); + } + (https, http) => { + if let Some(s) = https { + eprintln!("==> Proxy: HTTPS/TLS egress + image pulls via {s} (HTTP CONNECT)"); + } + if let Some(h) = http { + eprintln!("==> Proxy: plain-HTTP egress via {h} (HTTP CONNECT)"); + } + } + } + if let Some(no_proxy) = proxy.no_proxy_display() { + eprintln!("==> Proxy: bypassing (no_proxy) {no_proxy}"); + } + } + // For each provider with a host credential file, register a // SecretValue::File secret keyed on the placeholder string the // guest will send, then register the OAuth refresh endpoint as a @@ -454,21 +784,63 @@ pub async fn launch(agent: Agent, args: Args) -> Result { // the VM at all (microsandbox's violation detector would block it // otherwise), even though substitution there is a no-op because // the body's refresh_token is a placeholder, not a header. - if creds.anthropic_token_file.is_some() + let has_creds = creds.anthropic_token_file.is_some() || creds.openai_token_file.is_some() || creds.gh_token_file.is_some() - { + || creds.copilot_token_file.is_some(); + let auto_publish = args.auto_publish; + let allow_lan = args.allow_lan; + let allow_host = args.allow_host; + let has_egress_overrides = !allow_egress_cidrs.is_empty() || allow_lan || allow_host; + if has_creds || !publish_ports.is_empty() || auto_publish || has_egress_overrides { use crate::secrets::*; let anthropic = creds.anthropic_token_file.clone(); let openai = creds.openai_token_file.clone(); let opencode = creds.opencode_openai_access_token_file.clone(); let gh = creds.gh_token_file.clone(); let has_gh = gh.is_some(); + // D1 least-privilege: only wire the Copilot secret (and thus + // open Copilot-API egress) when Copilot is the selected agent. + // `creds.copilot_token_file` can be `Some` for a claude/codex + // session too (gh-fallback capture when `use_github`), but a + // non-Copilot launch must not carry a Copilot egress allow-host + // or a duplicated gh token in a secret it never uses. + let copilot = if want_copilot { + creds.copilot_token_file.clone() + } else { + None + }; let allowed_repos_for_hook = allowed_repos.clone(); let self_path = std::env::current_exe().context("std::env::current_exe")?; let state_dir = session.state_dir.clone(); + let publish_ports_for_net = publish_ports.clone(); + let allow_egress_for_net = allow_egress_cidrs.clone(); builder = builder.network(move |mut n| { n = n.tls(|t| t); + for p in &publish_ports_for_net { + let host_bind = p.host_bind; + n = match p.protocol { + PublishProto::Tcp => n.port_bind(host_bind, p.host_port, p.guest_port), + PublishProto::Udp => n.port_udp_bind(host_bind, p.host_port, p.guest_port), + }; + } + if auto_publish { + n = n.auto_publish(); + } + if allow_lan { + use microsandbox::microsandbox_network::policy::DestinationGroup; + n = n.allow_egress_group(DestinationGroup::Private); + } + if allow_host { + use microsandbox::microsandbox_network::policy::DestinationGroup; + n = n.allow_egress_group(DestinationGroup::Host); + } + for cidr in &allow_egress_for_net { + n = n.allow_egress_cidr(*cidr); + } + if !has_creds { + return n; + } // We only ever substitute into Authorization: Bearer headers. // Explicitly disable basic_auth so the proxy's per-chunk fast // path can short-circuit when the placeholder isn't present @@ -535,6 +907,23 @@ pub async fn launch(agent: Agent, args: Args) -> Result { .allow_host(GITHUB_OBJECTS_HOST) }); } + // D1: GitHub Copilot CLI sends `Authorization: Bearer + // ` to the Copilot API; the + // proxy swaps the placeholder for the real GitHub OAuth + // token. Copilot streams responses, so disable basic_auth + // to keep the per-chunk fast path (same reasoning as the + // Anthropic/OpenAI secrets above — only the bearer header + // ever needs substitution here). + if let Some(file) = copilot { + n = n.secret(|s| { + s.env("MSB_AGENT_VM_COPILOT_UNUSED") + .value(file) + .placeholder(COPILOT_TOKEN_PLACEHOLDER) + .inject_basic_auth(false) + .allow_host(COPILOT_API_HOST) + .allow_host(COPILOT_API_INDIVIDUAL_HOST) + }); + } n.intercept(|i| { let mut hook_argv: Vec = vec![ self_path.to_string_lossy().to_string(), @@ -605,12 +994,34 @@ pub async fn launch(agent: Agent, args: Args) -> Result { "/root/.local/bin:/root/.claude/local/bin:/root/.opencode/bin:/usr/local/bin:/usr/bin:/usr/sbin:/bin", ); - // Claude Code refuses to run as root with --dangerously-skip-permissions - // unless this env var is set. The whole point of running it in a - // microVM is that the sandbox IS our security boundary, so the - // in-guest CLI's extra guard would just block us from getting work - // done. Same env var the original Bash agent-vm used. - builder = builder.env("IS_SANDBOX", "1"); + // Environment injected into every guest regardless of agent/project. + // Kept as one list so the set is discoverable and guard-testable (see + // `guest_always_env_pins_utf8_locale_and_sandbox_flag`). + for (key, value) in GUEST_ALWAYS_ENV { + builder = builder.env(*key, *value); + } + + // D1: GitHub Copilot CLI reads its token from COPILOT_GITHUB_TOKEN. + // Hand it the placeholder; the credential proxy substitutes the real + // GitHub OAuth token for it on the wire to the Copilot API. We set + // it via env (not just ~/.copilot/config.json) because attach()'s + // execve never sources /etc/profile.d, unlike the original Bash + // agent-vm. + // + // Only set when Copilot is the selected agent. Exporting the + // placeholder for a claude/codex/opencode/shell session would have + // the guest emit `Authorization: Bearer msb-copilot-placeholder-v2` + // to the Copilot API with no registered substitution entry (the + // copilot secret is gated on the same condition above), which the + // proxy drops as a violation or GitHub rejects with a 401. By here, + // a Copilot launch is guaranteed to have a captured token (we bailed + // earlier otherwise), so the placeholder always resolves. + if want_copilot { + builder = builder.env( + "COPILOT_GITHUB_TOKEN", + crate::secrets::COPILOT_TOKEN_PLACEHOLDER, + ); + } let profile = env::var("AGENT_VM_PROFILE").is_ok(); eprintln!( @@ -638,6 +1049,27 @@ pub async fn launch(agent: Agent, args: Args) -> Result { if profile { eprintln!("[profile] create: {:?}", t_create.elapsed()); } + // When the project path isn't cmdline-safe we handed libkrun the `/` + // workdir placeholder, so create-time validation only confirmed that + // `/` exists — not that the real (non-ASCII) mount point materialized. + // agentd's per-exec `chdir` ignores failure (it would silently drop the + // agent into `/`), so verify the real path is present now over the + // byte-safe fs channel and fail loudly otherwise. ASCII paths were + // already validated at create time (workdir == the real path). + if !guest_path_is_cmdline_safe(&project_guest_path) + && !sandbox + .fs() + .exists(&project_guest_path) + .await + .unwrap_or(false) + { + let _ = sandbox.stop().await; + anyhow::bail!( + "project path {project_guest_path} did not appear inside the guest \ + (the bind mount failed to materialize); full logs: {}", + sandbox_log_dir(&session.sandbox_name).display() + ); + } // Confirm the image's API contract matches what this binary // expects. Out-of-range → clear actionable error instead of @@ -647,6 +1079,41 @@ pub async fn launch(agent: Agent, args: Args) -> Result { .await .context("verifying image-API contract version")?; + // Subscribe to the runtime's auto-publish event stream and + // surface each mapping to the user. Spawned regardless of + // whether auto-publish is enabled — when disabled the runtime + // never emits events, so the subscriber just idles. Cheap. + if args.auto_publish { + eprintln!("==> auto-publish: watching guest LISTEN sockets via /proc/net/tcp{{,6}}"); + let sb_for_events = sandbox.clone(); + tokio::spawn(async move { + let mut events = sb_for_events.port_events().await; + use microsandbox::protocol::network::PortEvent; + while let Some(event) = events.recv().await { + match event { + PortEvent::Added { + host_bind, + host_port, + guest_port, + } => { + eprintln!( + "==> auto-publish: guest :{guest_port} → host {host_bind}:{host_port}" + ); + } + PortEvent::Removed { + host_bind, + host_port, + guest_port, + } => { + eprintln!( + "==> auto-publish: guest :{guest_port} closed (released {host_bind}:{host_port})" + ); + } + } + } + }); + } + let inner_cmd = agent.command(); // Prepend agent-vm's default flags (e.g. --dangerously-skip-permissions // for Claude) unless the user already provided them. @@ -708,90 +1175,40 @@ pub async fn launch(agent: Agent, args: Args) -> Result { // docker compose up, env-var exports). Runs once per launch with // PWD set to the project dir; non-zero exit aborts the launch // with the same exit code. - // 3. Adds the microsandbox MITM CA to the `chrome` user's NSS DB - // so chromium (launched by chrome-devtools-mcp under that user - // via /usr/local/bin/agent-vm-chrome-mcp) verifies the - // intercepted TLS chain instead of failing every HTTPS page - // with ERR_CERT_AUTHORITY_INVALID. The CA file - // `/usr/local/share/ca-certificates/microsandbox-ca.crt` is - // written into the guest by agentd at boot, so this can't be - // baked into the image — the per-boot CA is what we have to - // inject. Trust string is `-t C,,` (trusted issuer of *server* - // certs only; the leading `T` would also mark it as a trusted - // issuer of client certs, which we don't want). Only injected - // when the chrome MCP is enabled: gating both here and in - // `secrets::write_default_claude_root_state` on the same env - // var keeps the opt-out actually opt-out — no sudo, no - // certutil fork. - let project_guest_path_escaped = shell_escape(&project_guest_path); - // Env-var semantics: `AGENT_VM_NO_CHROME_MCP` set to *any* value - // (including empty) opts out. Matches `AGENT_VM_PROFILE` / - // `AGENT_VM_DEBUG_CONFIG` in the same codebase. Unconventional vs - // "VAR=0 means off" — documented here so the next reader doesn't - // re-flag it. - let chrome_mcp_prelude = if std::env::var_os("AGENT_VM_NO_CHROME_MCP").is_some() { - String::new() - } else { - // Image-fresh NSS DB starts empty on every launch (the sandbox - // name carries the launcher PID — see session.rs — so every - // invocation creates a fresh rootfs upper layer rather than - // reusing a prior one), so `certutil -A` always runs against - // the same baseline; no chance of accumulating duplicate trust - // entries across boots. - // - // Failure modes worth surfacing (vs silently swallowing with - // `|| true` like the original Phase 7 patch): sudoers - // dropped/world-writable, NSS DB corrupted, CA file missing - // because agentd's TLS init didn't run, or chrome user - // removed in a downstream image. Without a warning, every - // chrome MCP HTTPS request would return - // `ERR_CERT_AUTHORITY_INVALID` with no breadcrumb back to the - // launcher. Stderr is captured in a temp file and tail'd on - // failure so the user sees the actual certutil/sudo error - // rather than a generic "non-zero exit". - String::from( - "if [ -f /usr/local/share/ca-certificates/microsandbox-ca.crt ] \\\n\ - && [ -d /home/chrome/.pki/nssdb ]; then\n\ - \t_cu_err=$(mktemp)\n\ - \tif ! sudo -u chrome -n -- certutil -d sql:/home/chrome/.pki/nssdb -A \\\n\ - \t\t-t C,, -n microsandbox \\\n\ - \t\t-i /usr/local/share/ca-certificates/microsandbox-ca.crt 2>\"$_cu_err\"; then\n\ - \t\techo \"==> warning: failed to install microsandbox CA into chrome NSS DB;\" >&2\n\ - \t\techo \"==> HTTPS in chrome-devtools MCP will fail with ERR_CERT_AUTHORITY_INVALID.\" >&2\n\ - \t\techo \"==> certutil/sudo stderr:\" >&2\n\ - \t\tsed 's/^/==> /' \"$_cu_err\" >&2\n\ - \tfi\n\ - \trm -f \"$_cu_err\"\n\ - fi\n", - ) - }; - let prelude = format!( - "sed -i '/^nameserver .*:/d' /etc/resolv.conf 2>/dev/null || true\n\ - [ -t 0 ] || exec < /dev/null\n\ - {chrome_mcp_prelude}\ - _hook={path}/.agent-vm.runtime.sh\n\ - if [ -f \"$_hook\" ]; then\n\ - \techo \"==> sourcing $_hook\" >&2\n\ - \tcd {path} && . \"$_hook\" || {{ rc=$?; echo \"==> .agent-vm.runtime.sh failed (exit $rc)\" >&2; exit $rc; }}\n\ - fi", - path = project_guest_path_escaped, + // Importing the microsandbox MITM CA into the `chrome` user's NSS + // DB — needed because chromium on Linux ignores the system CA bundle + // and honours only its per-user NSS DB, so the chrome-devtools MCP + // would otherwise fail every HTTPS page with ERR_CERT_AUTHORITY_INVALID + // — used to run here, a ~270ms `certutil` fork on *every* launch's + // critical path. It now lives in the in-image `agent-vm-chrome-mcp` + // wrapper, so it runs once when the chrome MCP actually starts: off + // the launch path, and skipped entirely when chrome is unused. The CA + // is per-install (not bakeable into the shared image); see + // images/Dockerfile. So no chrome prelude is injected here anymore — + // we pass an empty string for it. + // + // Assemble the in-guest `bash -c` line via `build_agent_shell_line`, + // which is unit-tested directly. The IPv6-nameserver strip is the + // `STRIP_IPV6_NAMESERVERS` const (see its doc comment / PLAN.md B3). + let shell_line = build_agent_shell_line( + &project_guest_path, + "", + inner_cmd, + &inner_args, ); - let prelude = prelude.as_str(); - let mut shell_line = String::from(prelude); - shell_line.push_str("; exec "); - shell_line.push_str(&shell_escape(inner_cmd)); - for a in &inner_args { - shell_line.push(' '); - shell_line.push_str(&shell_escape(a)); - } let cmd = "bash"; let agent_args: Vec = vec!["-c".into(), shell_line]; let t_run = Instant::now(); let exit = if std::io::stdin().is_terminal() { eprintln!("==> Attaching to {inner_cmd}"); + // Pin the agent's cwd to the real project path via the exec request + // (vsock, byte-safe), NOT libkrun's `KRUN_WORKDIR` — which may be the + // ASCII `/` placeholder for a non-ASCII project. `attach()` alone + // leaves cwd unset, falling back to that placeholder; `attach_with` + // lets us set it, matching the streaming path below. sandbox - .attach(cmd, agent_args) + .attach_with(cmd, |a| a.args(agent_args).cwd(project_guest_path.clone())) .await .with_context(|| { format!( @@ -1003,11 +1420,149 @@ fn pid_alive(pid: u32) -> bool { } /// One `--mount HOST[:GUEST]` argument resolved into separate paths. +#[derive(Debug)] struct ExtraMount { host: PathBuf, guest: PathBuf, } +/// One `--publish [HOST_BIND:]HOST_PORT:GUEST_PORT[/proto]` entry, parsed. +#[derive(Clone, Debug)] +struct PublishPort { + host_bind: std::net::IpAddr, + host_port: u16, + guest_port: u16, + protocol: PublishProto, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PublishProto { + Tcp, + /// Reserved for when the underlying PortPublisher gains UDP + /// support. Parser currently rejects `/udp` so this variant + /// is unreachable from user input, but kept so the eventual + /// enable change is a single-site edit. + #[allow(dead_code)] + Udp, +} + +/// Parse `--publish` entries. Accepts docker-style: +/// `HOST_PORT:GUEST_PORT` → 127.0.0.1 bind, TCP +/// `HOST_IP:HOST_PORT:GUEST_PORT` → explicit IPv4 bind +/// `[HOST_IP6]:HOST_PORT:GUEST_PORT` → explicit IPv6 bind (bracket form) +/// any of the above + `/tcp` (UDP rejected — not implemented) +/// +/// The connection enters the smoltcp in-process stack as a dial to +/// the guest's assigned MSB_NET_IPV4 (or v6) on `GUEST_PORT`, so the +/// in-guest service has to listen on `0.0.0.0`/`::` (or that exact +/// guest IP) — a bare `127.0.0.1` bind inside the guest is not +/// reachable from the publisher. +/// +/// `/udp` is parsed but REJECTED with a clear error: the underlying +/// `PortPublisher::spawn_listener_one` short-circuits non-TCP ports +/// silently, so silently accepting `/udp` would leave the user with +/// a "published" port that has no listener. When UDP support lands +/// upstream, drop the rejection here. +fn parse_publish_args(raw: &[String]) -> Result> { + use std::net::IpAddr; + let mut out = Vec::with_capacity(raw.len()); + for entry in raw { + let (body, proto) = match entry.rsplit_once('/') { + Some((b, p)) if matches!(p, "tcp" | "udp" | "TCP" | "UDP") => { + (b, p.to_ascii_lowercase()) + } + _ => (entry.as_str(), "tcp".to_string()), + }; + if proto == "udp" { + anyhow::bail!( + "--publish {entry:?}: UDP is not yet supported by the underlying smoltcp \ + PortPublisher; remove `/udp` to publish a TCP port instead" + ); + } + let protocol = PublishProto::Tcp; + + // Split off an IPv6 bracketed prefix first (docker convention) + // so `[::1]:8080:80` doesn't trip the generic colon split. + let (host_bind, rest) = if let Some(after_bracket) = body.strip_prefix('[') { + let (v6_str, after) = after_bracket.split_once("]:").ok_or_else(|| { + anyhow::anyhow!( + "--publish {entry:?}: bracketed IPv6 must be `[ADDR]:HOST_PORT:GUEST_PORT`" + ) + })?; + let addr = v6_str.parse::().with_context(|| { + format!("--publish {entry:?}: HOST_BIND {v6_str:?} is not an IPv6") + })?; + (Some(IpAddr::V6(addr)), after) + } else { + (None, body) + }; + + let parts: Vec<&str> = rest.split(':').collect(); + let (host_bind, host_port, guest_port) = match (host_bind, parts.as_slice()) { + (Some(bind), [h, g]) => ( + bind, + parse_port(entry, "HOST_PORT", h)?, + parse_port(entry, "GUEST_PORT", g)?, + ), + (None, [h, g]) => ( + IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), + parse_port(entry, "HOST_PORT", h)?, + parse_port(entry, "GUEST_PORT", g)?, + ), + (None, [ip, h, g]) => ( + ip.parse::().with_context(|| { + format!("--publish {entry:?}: HOST_BIND {ip:?} is not an IP") + })?, + parse_port(entry, "HOST_PORT", h)?, + parse_port(entry, "GUEST_PORT", g)?, + ), + _ => anyhow::bail!( + "--publish {entry:?} must be [HOST_BIND:]HOST_PORT:GUEST_PORT or \ + [IPv6_BIND]:HOST_PORT:GUEST_PORT" + ), + }; + if host_port == 0 || guest_port == 0 { + anyhow::bail!("--publish {entry:?}: port 0 is not allowed"); + } + out.push(PublishPort { + host_bind, + host_port, + guest_port, + protocol, + }); + } + Ok(out) +} + +fn parse_port(entry: &str, field: &str, s: &str) -> Result { + s.parse::() + .with_context(|| format!("--publish {entry:?}: {field} {s:?} is not a u16")) +} + +/// Parse `--allow-egress` entries. Each entry is an IP literal or +/// a CIDR (e.g. `10.100.1.75` or `10.100.1.0/24`). A bare IP is +/// expanded to a /32 (v4) or /128 (v6) CIDR — that matches the +/// shape the policy builder's `Destination::Cidr` expects. +fn parse_allow_egress(raw: &[String]) -> Result> { + let mut out = Vec::with_capacity(raw.len()); + for entry in raw { + // Try CIDR first (foo/N); fall back to bare IP. + let cidr = if entry.contains('/') { + entry + .parse::() + .with_context(|| format!("--allow-egress {entry:?}: not a valid CIDR"))? + } else { + let ip: std::net::IpAddr = entry.parse().with_context(|| { + format!("--allow-egress {entry:?}: not an IP address or CIDR") + })?; + // /32 for v4, /128 for v6 — single-host rule. + ipnetwork::IpNetwork::from(ip) + }; + out.push(cidr); + } + Ok(out) +} + /// Parse the raw `--mount` argv strings into `(host, guest)` pairs. /// `HOST` alone defaults `GUEST` to the same absolute path (mirror). /// `HOST:GUEST` lets you remap. Errors clearly on absolute-path @@ -1030,6 +1585,17 @@ fn parse_extra_mounts(raw: &[String]) -> Result> { if !guest.is_absolute() { anyhow::bail!("--mount guest path {guest_s:?} must be absolute"); } + // The guest mount point reaches agentd via the boot-params side + // channel (not the kernel command line), so non-ASCII and spaces + // are fine — but a control character (TAB/newline) would break the + // `KEY\tVALUE\n` framing, so reject those. See + // `guest_path_is_mountable`. + if !guest_path_is_mountable(&guest_s) { + anyhow::bail!( + "--mount guest path {guest_s:?} contains control characters that can't be \ + carried into the guest; pass a guest path without tabs/newlines" + ); + } // Canonicalize host so we follow symlinks; the bind target // needs to be a real path on the host. let host = host @@ -1252,6 +1818,77 @@ fn parse_github_slug(url: &str) -> Option { Some(format!("{owner}/{repo}")) } +/// Bash snippet (one logical line) that removes **only** IPv6 `nameserver` +/// entries from the guest's `/etc/resolv.conf`. +/// +/// microsandbox's agentd writes both an IPv4 and an IPv6 gateway-DNS +/// `nameserver` line at boot. The IPv6 entry is unresponsive in at least +/// one nested-libkrun config (the gateway times out on v6 DNS queries — +/// PLAN.md item B3 / upstream microsandbox issue #5). glibc's +/// `getaddrinfo` quietly falls through to the working v4 server, but a +/// strict async resolver (codex's hickory) returns `EAI_AGAIN` +/// ("Try again") and hangs at startup with "failed to lookup address +/// information", even though `getent hosts ` resolves instantly. +/// +/// The sed address `/^nameserver .*:/` deletes a line iff it starts with +/// `nameserver ` *and* its value contains a colon. Every IPv6 literal +/// contains a colon; no IPv4 dotted-quad does — so v4 `nameserver` lines, +/// `# comments` (the `^nameserver` anchor won't match a leading `#`), +/// `search` and `options` lines are all left intact. `2>/dev/null +/// || true` keeps a read-only or absent resolv.conf from aborting the +/// prelude. +/// +/// This is the cheaper of the two B3 options: a microsandbox-side +/// `network.dns(disable_ipv6)` knob would mean a submodule change to +/// agentd's resolv.conf writer; stripping one line in the launcher +/// prelude is self-contained and has no upstream-merge dependency. +const STRIP_IPV6_NAMESERVERS: &str = + "sed -i '/^nameserver .*:/d' /etc/resolv.conf 2>/dev/null || true"; + +/// Seed the image's baked Claude LSP plugins into the persistent state dir on +/// first boot (PLAN.md D2). The image installs them under `/root/.claude`, but +/// the persistence symlink (`/root/.claude -> /agent-vm-state/claude`) shadows +/// that tree so the booted guest's `claude plugin list` is empty. The image +/// ships `/opt/agent-vm/seed-claude-plugins.sh`, which copies the stash into +/// the state dir once. Guarded on the script's presence so older images (no +/// stash) are an inert no-op, and idempotent so it only does work on first boot. +const SEED_CLAUDE_PLUGINS: &str = + "[ -x /opt/agent-vm/seed-claude-plugins.sh ] && /opt/agent-vm/seed-claude-plugins.sh || true"; + +/// Build the `bash -c` line that runs inside the guest: the prelude +/// (IPv6-nameserver strip, stdin redirect, optional chrome-CA install, +/// optional project runtime hook) followed by `exec`'ing the chosen +/// agent with its args. Pure and string-only so it can be unit-tested +/// without booting a sandbox — the launch path calls exactly this, so +/// the tested behavior and the live behavior cannot drift. +fn build_agent_shell_line( + project_guest_path: &str, + chrome_mcp_prelude: &str, + inner_cmd: &str, + inner_args: &[String], +) -> String { + let path = shell_escape(project_guest_path); + let prelude = format!( + "{STRIP_IPV6_NAMESERVERS}\n\ + {SEED_CLAUDE_PLUGINS}\n\ + [ -t 0 ] || exec < /dev/null\n\ + {chrome_mcp_prelude}\ + _hook={path}/.agent-vm.runtime.sh\n\ + if [ -f \"$_hook\" ]; then\n\ + \techo \"==> sourcing $_hook\" >&2\n\ + \tcd {path} && . \"$_hook\" || {{ rc=$?; echo \"==> .agent-vm.runtime.sh failed (exit $rc)\" >&2; exit $rc; }}\n\ + fi", + ); + let mut shell_line = prelude; + shell_line.push_str("; exec "); + shell_line.push_str(&shell_escape(inner_cmd)); + for a in inner_args { + shell_line.push(' '); + shell_line.push_str(&shell_escape(a)); + } + shell_line +} + /// Single-quote `s` for use as a single argv element in a `bash -c` /// line. Embedded single quotes are split out with the standard /// `'\''` trick. Adequate for forwarding arbitrary user-supplied agent @@ -1282,6 +1919,102 @@ mod tests { assert_eq!(shell_escape(""), "''"); } + // ── IPv6 resolv.conf strip (PLAN.md B3 / upstream issue #5) ─── + + #[test] + fn build_agent_shell_line_starts_with_v6_strip_and_execs() { + let line = build_agent_shell_line("/work/proj", "", "claude", &[]); + // The prelude opens with the IPv6-nameserver strip, sourced from + // the single `STRIP_IPV6_NAMESERVERS` const so the live launch + // path and this test can't drift. + assert!(line.starts_with(STRIP_IPV6_NAMESERVERS), "got: {line}"); + assert!(line.starts_with("sed -i '/^nameserver .*:/d' /etc/resolv.conf")); + assert!(line.contains("exec 'claude'")); + } + + #[test] + fn build_agent_shell_line_includes_chrome_prelude_and_args() { + let line = + build_agent_shell_line("/p", "CHROME_CA_STUFF\n", "codex", &["exec".into()]); + assert!(line.contains("CHROME_CA_STUFF")); + assert!(line.contains("exec 'codex' 'exec'")); + } + + #[test] + fn build_agent_shell_line_seeds_claude_plugins_before_exec() { + // D2: the prelude seeds the baked LSP plugins into the persistent + // state dir, and must do so before the agent execs. + let line = build_agent_shell_line("/work/proj", "", "claude", &[]); + let seed = line.find(SEED_CLAUDE_PLUGINS).expect("seed step present"); + let exec = line.find("exec 'claude'").expect("exec present"); + assert!(seed < exec, "seed must run before exec; got: {line}"); + } + + /// Guard the exact `sed` program in `STRIP_IPV6_NAMESERVERS`. The + /// regex is load-bearing (see the const's doc comment): an accidental + /// edit that dropped the `^` anchor or the `:` class would silently + /// start eating IPv4 nameservers or comment lines, leaving the guest + /// with no DNS at all. + #[test] + fn strip_ipv6_nameservers_snippet_is_the_expected_sed() { + assert_eq!( + STRIP_IPV6_NAMESERVERS, + "sed -i '/^nameserver .*:/d' /etc/resolv.conf 2>/dev/null || true", + ); + } + + /// Reimplement the `sed '/^nameserver .*:/d'` predicate in Rust and + /// assert it removes **only** IPv6 `nameserver` lines: IPv4 + /// nameservers, comments, `search` and `options` must survive. This + /// exercises the regex *intent* without needing a live VM (the real + /// strip runs inside the guest, which we can't boot here). + #[test] + fn sed_predicate_removes_only_ipv6_nameservers() { + // Mirror `sed` address `/^nameserver .*:/`: delete iff the line + // begins exactly with "nameserver " (so a leading '#' comment is + // NOT matched — the `^` anchors before `nameserver`) AND a colon + // appears somewhere after that prefix (i.e. in the value). + fn deleted_by_sed(line: &str) -> bool { + match line.strip_prefix("nameserver ") { + Some(value) => value.contains(':'), + None => false, + } + } + + let resolv = "\ +# generated by agentd +nameserver 10.0.2.3 +nameserver 192.168.1.1 +nameserver fec0::3 +nameserver fe80::1%eth0 +nameserver 2001:4860:4860::8888 +# nameserver 2001:db8::1 +search lan example.com +options ndots:2 timeout:1"; + + let kept: Vec<&str> = resolv.lines().filter(|l| !deleted_by_sed(l)).collect(); + + // IPv4 nameservers survive. + assert!(kept.contains(&"nameserver 10.0.2.3")); + assert!(kept.contains(&"nameserver 192.168.1.1")); + // Comments survive, including a commented-out IPv6 nameserver. + assert!(kept.contains(&"# generated by agentd")); + assert!(kept.contains(&"# nameserver 2001:db8::1")); + // `search` / `options` survive even though `options` contains a + // colon (the `^nameserver` anchor protects them). + assert!(kept.contains(&"search lan example.com")); + assert!(kept.contains(&"options ndots:2 timeout:1")); + + // Every IPv6 nameserver line is gone (global, link-local with a + // zone id, and ULA forms all carry a colon). + assert!(!kept.iter().any(|l| l.starts_with("nameserver fec0::3"))); + assert!(!kept.iter().any(|l| l.starts_with("nameserver fe80::1"))); + assert!(!kept.iter().any(|l| l.starts_with("nameserver 2001:"))); + + // No surviving line is an (uncommented) IPv6 nameserver. + assert!(!kept.iter().any(|l| deleted_by_sed(l))); + } + // ── parse_github_slug ──────────────────────────────────────── #[test] @@ -1411,6 +2144,231 @@ mod tests { assert!(r.is_err()); } + #[test] + fn parse_extra_mounts_allows_non_ascii_guest_rejects_control_chars() { + // A non-ASCII guest mount point now travels via the boot-params + // side channel (not the cmdline), so it is accepted and mirrored. + // Host `/` exists so canonicalize succeeds. + let parsed = parse_extra_mounts(&["/:/монтаж".into()]).expect("non-ASCII guest is ok"); + assert_eq!(parsed[0].guest, std::path::Path::new("/монтаж")); + // Plain ASCII guest path still works. + assert!(parse_extra_mounts(&["/:/mnt/ref".into()]).is_ok()); + // A control char (TAB) would break the KEY\tVALUE boot-params + // framing, so it's rejected with guidance. + let err = parse_extra_mounts(&["/:/mnt/a\tb".into()]) + .expect_err("control char in guest path must be rejected") + .to_string(); + assert!( + err.contains("control characters"), + "error should call out control characters, got: {err}" + ); + } + + // ── guest-path predicates / resolve_project_guest_path ─────── + + #[test] + fn guest_path_is_cmdline_safe_accepts_plain_ascii_only() { + // This predicate now governs only the KRUN_WORKDIR placeholder + // (the one path that still rides the cmdline). Plain ASCII passes. + assert!(guest_path_is_cmdline_safe("/home/boger/work/agent-vm")); + assert!(guest_path_is_cmdline_safe("/workspace")); + // '=' is safe in a path (the kernel splits a KEY=value token only + // on its first '='); only whitespace and non-ASCII are unsafe. + assert!(guest_path_is_cmdline_safe("/home/a=b/c-d.e_f+g")); + // Cyrillic/emoji (non-ASCII), space, tab, DEL are all NOT + // cmdline-safe → the workdir falls back to the "/" placeholder. + assert!(!guest_path_is_cmdline_safe("/home/boger/проект-тест")); + assert!(!guest_path_is_cmdline_safe("/home/boger/😀proj")); + assert!(!guest_path_is_cmdline_safe("/home/My Project")); + assert!(!guest_path_is_cmdline_safe("/home/x\ty")); + assert!(!guest_path_is_cmdline_safe("/home/x\u{7f}y")); + } + + #[test] + fn guest_path_is_mountable_allows_non_ascii_and_space_not_control() { + // Mount points travel via the byte-transparent boot-params side + // channel, so non-ASCII and spaces are mountable. + assert!(guest_path_is_mountable("/home/boger/проект-тест")); + assert!(guest_path_is_mountable("/home/boger/😀proj")); + assert!(guest_path_is_mountable("/home/My Project")); + assert!(guest_path_is_mountable("/home/boger/work")); + // Control characters (TAB/newline/DEL) break the KEY\tVALUE\n + // framing and are rejected. + assert!(!guest_path_is_mountable("/home/x\ty")); + assert!(!guest_path_is_mountable("/home/x\ny")); + assert!(!guest_path_is_mountable("/home/x\u{7f}y")); + } + + #[test] + fn guest_always_env_pins_utf8_locale_and_sandbox_flag() { + let map: std::collections::HashMap<&str, &str> = + GUEST_ALWAYS_ENV.iter().copied().collect(); + // Claude Code's root-guard bypass must stay set. + assert_eq!(map.get("IS_SANDBOX"), Some(&"1")); + // A UTF-8 locale must be pinned: without it the guest is C/POSIX, + // which renders a Cyrillic cwd as `M-P…` escapes and makes the + // agents' filesystem encoding ASCII (mishandling non-ASCII paths). + // Removing or non-UTF-8-ing this regresses Cyrillic-path support. + let lang = map.get("LANG").expect("guest LANG must be pinned to a UTF-8 locale"); + assert!( + lang.to_ascii_lowercase().replace('-', "").contains("utf8"), + "guest LANG must be a UTF-8 locale, got {lang:?}" + ); + } + + #[test] + fn resolve_project_guest_path_mirrors_real_path_and_only_remaps_unmountable() { + // Plain ASCII path is mirrored 1:1 with no remap notice. + let (guest, reason) = + resolve_project_guest_path(Path::new("/home/boger/proj"), "/home/boger/proj"); + assert_eq!(guest, "/home/boger/proj"); + assert!(reason.is_none()); + + // Cyrillic, emoji, and space are now mirrored at their REAL path — + // the mount spec rides the side channel and the cwd the exec + // channel, so there's no /workspace fallback and no remap notice. + for p in ["/home/boger/проект", "/home/boger/😀p", "/home/My Project"] { + let (guest, reason) = resolve_project_guest_path(Path::new(p), p); + assert_eq!(guest, p, "{p:?} should be mirrored at its real path"); + assert!(reason.is_none(), "{p:?} should not report a remap reason"); + } + + // A path under a guest tmpfs mount still remaps (would be wiped at + // boot), regardless of being otherwise ASCII-clean. + let (guest, reason) = resolve_project_guest_path(Path::new("/tmp/proj"), "/tmp/proj"); + assert_eq!(guest, "/workspace"); + assert!(reason.is_some_and(|r| r.contains("tmpfs"))); + + // A control character in the path can't be framed for the side + // channel → defensive /workspace fallback. + let (guest, reason) = + resolve_project_guest_path(Path::new("/home/a\tb"), "/home/a\tb"); + assert_eq!(guest, "/workspace"); + assert!(reason.is_some_and(|r| r.contains("control characters"))); + } + + // ── parse_publish_args ─────────────────────────────────────── + + #[test] + fn parse_publish_args_two_part_defaults_to_loopback_tcp() { + let p = parse_publish_args(&["8080:80".into()]).expect("ok"); + assert_eq!(p.len(), 1); + assert_eq!( + p[0].host_bind, + std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST) + ); + assert_eq!(p[0].host_port, 8080); + assert_eq!(p[0].guest_port, 80); + assert_eq!(p[0].protocol, PublishProto::Tcp); + } + + #[test] + fn parse_publish_args_three_part_with_bind() { + let p = parse_publish_args(&["0.0.0.0:5000:5000".into()]).expect("ok"); + assert_eq!( + p[0].host_bind, + std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED) + ); + assert_eq!(p[0].host_port, 5000); + assert_eq!(p[0].guest_port, 5000); + } + + #[test] + fn parse_publish_args_explicit_tcp_suffix() { + let p = parse_publish_args(&["8080:80/tcp".into()]).expect("ok"); + assert_eq!(p[0].protocol, PublishProto::Tcp); + } + + /// UDP must be REJECTED with a clear error. Previously the parser + /// accepted it, the stderr banner said "Publishing …/udp", and + /// `PortPublisher::spawn_listener_one` silently dropped it — user + /// got a "successful" publish that actually had no listener. + #[test] + fn parse_publish_args_rejects_udp() { + let err = parse_publish_args(&["53:53/udp".into()]) + .expect_err("UDP must be rejected until upstream supports it") + .to_string(); + assert!( + err.contains("UDP is not yet supported"), + "error should mention UDP unsupported, got: {err}" + ); + } + + /// IPv6 host bind must work via the docker-style `[ADDR]:p:p` + /// bracket form. Previously the parser split the whole body on + /// `:` and IPv6 was unreachable. + #[test] + fn parse_publish_args_ipv6_bracket_bind() { + let p = parse_publish_args(&["[::1]:8080:80".into()]).expect("ok"); + assert_eq!(p[0].host_bind, std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST)); + assert_eq!(p[0].host_port, 8080); + assert_eq!(p[0].guest_port, 80); + } + + #[test] + fn parse_publish_args_ipv6_bracket_wildcard() { + let p = parse_publish_args(&["[::]:5000:5000".into()]).expect("ok"); + assert_eq!( + p[0].host_bind, + std::net::IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED) + ); + } + + #[test] + fn parse_publish_args_ipv6_bracket_missing_closer_is_rejected() { + // `[::1` without `]:` should be a clear error, not a silent + // misparse. + assert!(parse_publish_args(&["[::1:8080:80".into()]).is_err()); + } + + // ── parse_allow_egress ─────────────────────────────────────── + + #[test] + fn parse_allow_egress_accepts_bare_ipv4() { + let r = parse_allow_egress(&["10.100.1.75".into()]).expect("ok"); + assert_eq!(r.len(), 1); + // Bare IPv4 → /32 single-host CIDR. + assert_eq!(r[0].prefix(), 32); + assert_eq!(r[0].network().to_string(), "10.100.1.75"); + } + + #[test] + fn parse_allow_egress_accepts_ipv4_cidr() { + let r = parse_allow_egress(&["10.100.1.0/24".into()]).expect("ok"); + assert_eq!(r.len(), 1); + assert_eq!(r[0].prefix(), 24); + } + + #[test] + fn parse_allow_egress_accepts_ipv6_cidr() { + let r = parse_allow_egress(&["fd00::/64".into()]).expect("ok"); + assert_eq!(r.len(), 1); + assert_eq!(r[0].prefix(), 64); + } + + #[test] + fn parse_allow_egress_accepts_bare_ipv6() { + let r = parse_allow_egress(&["fd00::1".into()]).expect("ok"); + assert_eq!(r[0].prefix(), 128); + } + + #[test] + fn parse_allow_egress_rejects_garbage() { + assert!(parse_allow_egress(&["not-an-ip".into()]).is_err()); + assert!(parse_allow_egress(&["10.0.0.1/99".into()]).is_err()); + assert!(parse_allow_egress(&["".into()]).is_err()); + } + + #[test] + fn parse_publish_args_rejects_bad_input() { + assert!(parse_publish_args(&["80".into()]).is_err()); + assert!(parse_publish_args(&["a:b".into()]).is_err()); + assert!(parse_publish_args(&["0:80".into()]).is_err()); + assert!(parse_publish_args(&["80:0".into()]).is_err()); + assert!(parse_publish_args(&["999999:80".into()]).is_err()); + assert!(parse_publish_args(&["1:2:3:4".into()]).is_err()); + } + // ── mkdir_chain ────────────────────────────────────────────── #[test] @@ -1506,10 +2464,60 @@ mod tests { } } +/// Seed the pulled-digest marker from microsandbox's cache when we have +/// no record yet, so the update banner has a baseline to diff against on +/// this very launch. +/// +/// The marker (what the banner compares to the registry) was historically +/// written *only* by `agent-vm pull`. A user who acquired the image via a +/// launch's `IfMissing` auto-pull — or via an older agent-vm that never +/// wrote it — had no baseline, so the banner could never fire. Here, if +/// the image is already cached and unmarked, we record its per-platform +/// manifest digest. Then a stale cache trips the banner on the next probe +/// (i.e. immediately, since the call below seeds before we probe). +/// +/// Safe on the launch path: `IfMissing` never re-pulls, so `Image::get`'s +/// digest is accurate (the re-pull staleness that makes pull.rs avoid +/// `Image::get` — see pulled_marker.rs — can't apply here). Verified +/// empirically that `Image::get(...).manifest_digest()` is the same +/// per-platform digest `image_check::fetch_remote_digest` returns, so the +/// comparison is apples-to-apples. Only ever *seed* — never overwrite an +/// existing marker, which is the authoritative record of our last pull. +async fn seed_pulled_marker_if_absent(image: &str) { + if crate::pulled_marker::read(image).is_some() { + return; + } + // Not cached yet (genuine first run) → Image::get errors → nothing to + // seed, and there's correctly nothing newer to flag: the imminent + // IfMissing pull lands the current image. + // + // Upstream's SDK now routes image lookups through a LocalBackend handle; + // open the default one (best-effort — a failure here just skips seeding). + let Ok(local) = microsandbox::LocalBackend::new().await else { + return; + }; + if let Ok(handle) = microsandbox::Image::get(&local, image).await + && let Some(digest) = handle.manifest_digest() + { + match crate::pulled_marker::write(image, digest) { + Ok(()) => tracing::debug!(image, digest, "seeded pulled-digest baseline from cache"), + Err(e) => tracing::warn!(error = %e, "failed to seed pulled-digest marker"), + } + } +} + async fn notify_if_update_available(image: &str) { use crate::image_check::{UpdateState, check_for_update}; - match check_for_update(image).await { - Ok(Some(UpdateState::UpdateAvailable { cached, remote })) => { + // The probe does up to three sequential registry round-trips for a + // token-auth registry (manifest GET → 401 → token → authed GET), + // each carrying its own 5s per-request timeout. This runs inline on + // the launch hot path before boot, so cap the whole thing: a slow or + // flaky registry must never delay launch by more than a single + // request's worth of wait. The banner is best-effort — on timeout we + // simply stay quiet and continue with the cached image. + let probe = tokio::time::timeout(UPDATE_PROBE_BUDGET, check_for_update(image)); + match probe.await { + Ok(Ok(Some(UpdateState::UpdateAvailable { cached, remote }))) => { eprintln!( "==> A newer image is available in the registry (cached {cached}, registry {remote})" ); @@ -1518,8 +2526,15 @@ async fn notify_if_update_available(image: &str) { ); } // UpToDate / NotCached: nothing to say. - // None / Err: registry unreachable etc. — stay quiet. + // Ok(Err)/None: registry unreachable etc. — stay quiet. + // Err(Elapsed): probe exceeded the budget — stay quiet. _ => {} } } +/// Wall-clock budget for the launch-path update probe. Bounds the worst +/// case across all of the probe's registry round-trips so a slow or +/// unreachable registry can't stall boot; matches a single request's +/// per-request timeout in `image_check`. +const UPDATE_PROBE_BUDGET: std::time::Duration = std::time::Duration::from_secs(5); + diff --git a/crates/agent-vm/src/secrets.rs b/crates/agent-vm/src/secrets.rs index a57502d..6406b99 100644 --- a/crates/agent-vm/src/secrets.rs +++ b/crates/agent-vm/src/secrets.rs @@ -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 ` 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, + /// 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, pub snapshot: Option, } @@ -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 `.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 { 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 { - 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 { + 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 { + 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> { Ok(Some(token_file)) } +/// Parse the original Bash agent-vm's Copilot token cache file. The +/// cache is JSON `{"access_token": ""}` (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 { + 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 +/// `.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> { + // 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 { /// 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 `/.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 diff --git a/crates/agent-vm/src/setup.rs b/crates/agent-vm/src/setup.rs index 2826a62..813a453 100644 --- a/crates/agent-vm/src/setup.rs +++ b/crates/agent-vm/src/setup.rs @@ -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, } diff --git a/images/Dockerfile b/images/Dockerfile index db6bb3c..a0bce03 100644 --- a/images/Dockerfile +++ b/images/Dockerfile @@ -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=` +# 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 ' \ + '# - agent identifier (codex, opencode, claude)' \ + '# - sh|bash; the interpreter used to run the installer' \ + '# - 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 @` +# 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"] diff --git a/images/build.sh b/images/build.sh index c51612e..2ccccd2 100755 --- a/images/build.sh +++ b/images/build.sh @@ -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}" diff --git a/images/seed-claude-plugins.sh b/images/seed-claude-plugins.sh new file mode 100644 index 0000000..b3f58c6 --- /dev/null +++ b/images/seed-claude-plugins.sh @@ -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 diff --git a/npm-dist/README.md b/npm-dist/README.md index d23c39f..a4d8f46 100644 --- a/npm-dist/README.md +++ b/npm-dist/README.md @@ -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 diff --git a/npm-dist/agent-vm-linux-arm64/README.md b/npm-dist/agent-vm-linux-arm64/README.md new file mode 100644 index 0000000..d3a6b1a --- /dev/null +++ b/npm-dist/agent-vm-linux-arm64/README.md @@ -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: . diff --git a/npm-dist/agent-vm-linux-arm64/bin/.gitkeep b/npm-dist/agent-vm-linux-arm64/bin/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/npm-dist/agent-vm-linux-arm64/lib/.gitkeep b/npm-dist/agent-vm-linux-arm64/lib/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/npm-dist/agent-vm-linux-arm64/package.json b/npm-dist/agent-vm-linux-arm64/package.json new file mode 100644 index 0000000..39bec8e --- /dev/null +++ b/npm-dist/agent-vm-linux-arm64/package.json @@ -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" + } +} diff --git a/npm-dist/agent-vm/bin/agent-vm.dispatch.test.js b/npm-dist/agent-vm/bin/agent-vm.dispatch.test.js new file mode 100644 index 0000000..3451741 --- /dev/null +++ b/npm-dist/agent-vm/bin/agent-vm.dispatch.test.js @@ -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)"); diff --git a/npm-dist/agent-vm/bin/agent-vm.js b/npm-dist/agent-vm/bin/agent-vm.js index 84608d0..dcad421 100755 --- a/npm-dist/agent-vm/bin/agent-vm.js +++ b/npm-dist/agent-vm/bin/agent-vm.js @@ -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", diff --git a/npm-dist/agent-vm/package.json b/npm-dist/agent-vm/package.json index 131f3f8..c6f9631 100644 --- a/npm-dist/agent-vm/package.json +++ b/npm-dist/agent-vm/package.json @@ -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" diff --git a/profiling/build_legacy.sh b/profiling/build_legacy.sh new file mode 100755 index 0000000..b435520 --- /dev/null +++ b/profiling/build_legacy.sh @@ -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))" diff --git a/profiling/build_variants.sh b/profiling/build_variants.sh new file mode 100755 index 0000000..1f38c64 --- /dev/null +++ b/profiling/build_variants.sh @@ -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 diff --git a/profiling/measure_variants.sh b/profiling/measure_variants.sh new file mode 100755 index 0000000..83eeccc --- /dev/null +++ b/profiling/measure_variants.sh @@ -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==""||vmx)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)" diff --git a/vendor/microsandbox b/vendor/microsandbox index e7590e2..d6b9b11 160000 --- a/vendor/microsandbox +++ b/vendor/microsandbox @@ -1 +1 @@ -Subproject commit e7590e28d53f8816a94ec38cdafd0ab5a4a4b543 +Subproject commit d6b9b11732771266169beb4043a4164235a67d64