Merge branch 'integration-7feat' into integration-on-bump

# Conflicts:
#	vendor/microsandbox
This commit is contained in:
Evgeny Boger 2026-06-22 21:21:21 +03:00
commit d2de958638
18 changed files with 1682 additions and 780 deletions

15
.cargo/config.toml Normal file
View file

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

44
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,44 @@
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
- name: Build
run: cargo build --release -p agent-vm
- name: Test
run: cargo test -p agent-vm
- name: Format check
continue-on-error: true
run: cargo fmt --all -- --check
- name: Clippy
continue-on-error: true
run: cargo clippy -p agent-vm --release -- -D warnings

View file

@ -62,6 +62,16 @@ 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 }}
steps:
- uses: actions/checkout@v5
@ -75,12 +85,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
@ -110,7 +136,20 @@ jobs:
cache-workspace-crates: "true"
- 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,6 +168,12 @@ 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 }}
env:
# Override vendor/microsandbox's `lto=true, codegen-units=1`
@ -138,18 +183,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
@ -191,7 +252,17 @@ jobs:
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
@ -224,6 +295,15 @@ 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 }}
steps:
- uses: actions/checkout@v5
@ -256,12 +336,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'
@ -269,11 +357,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
@ -328,6 +429,9 @@ jobs:
- platform: linux-x64
runner: ubuntu-latest
libkrunfw_arch: x86_64
- platform: linux-arm64
runner: ubuntu-latest
libkrunfw_arch: aarch64
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
@ -460,6 +564,12 @@ jobs:
name: agent-vm-linux-x64
path: npm-dist/agent-vm-linux-x64/
- name: Download linux-arm64 artifact
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

858
PLAN.md
View file

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

View file

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

View file

@ -681,14 +681,50 @@ fn write_response(bytes: &[u8]) -> Result<()> {
Ok(())
}
/// Public entry point for an Anthropic OAuth-refresh interception.
///
/// Thin wrapper that injects the real side effects — spawn the host
/// `claude` CLI to rotate the host credential file, then read that
/// file back — and hands the rotated JSON to [`rotate_anthropic`],
/// which holds the actual rotation logic (token-file rewrite +
/// placeholder response synthesis). Keeping the side effects out of
/// `rotate_anthropic` is what makes the rotation path deterministically
/// testable without a live `claude` session or a real expiring token
/// (PLAN.md A1).
fn refresh_anthropic(state_dir: &Path) -> Result<Vec<u8>> {
trigger_host_refresh("claude", &["-p", "hi", "--model", "sonnet"])?;
// Single-flight: serialize host-side rotations for this provider so
// two racing in-guest refreshes don't each spawn `claude -p`. The
// first waiter rotates; the second, on acquiring the lock, finds the
// token file freshly rewritten and skips its own host CLI. The lock
// is Anthropic-specific so a concurrent OpenAI refresh isn't blocked.
let _flight = RefreshLock::acquire(state_dir, secrets::REFRESH_LOCK_ANTHROPIC)?;
if !token_recently_rotated(&secrets::anthropic_token_path(state_dir)) {
trigger_host_refresh("claude", &["-p", "hi", "--model", "sonnet"])?;
}
let host_path = host_claude_creds_path().context("HOME not set")?;
let raw = std::fs::read_to_string(&host_path)
.with_context(|| format!("reading {}", host_path.display()))?;
let json: Value = serde_json::from_str(&raw)
.with_context(|| format!("parsing {}", host_path.display()))?;
// Re-wrap with the concrete path so a parse/extract failure in the pure fn
// still names which exact host file was bad (the pure fn uses a fixed label).
rotate_anthropic(state_dir, &raw)
.with_context(|| format!("rotating Anthropic token from {}", host_path.display()))
}
/// Pure rotation step for Anthropic: parse the (already-rotated) host
/// `.credentials.json` text, rewrite the per-project token file with
/// the fresh real bearer, and synthesize the OAuth refresh response
/// that carries *placeholders* (never the real bearer) back to the
/// in-VM agent.
///
/// Split out from [`refresh_anthropic`] so tests can drive a simulated
/// rotation by passing the rotated-file contents directly, with no host
/// CLI spawn and no `$HOME` credential file. Runtime behavior is
/// identical: `refresh_anthropic` calls this with the bytes it just
/// read from the real host file.
fn rotate_anthropic(state_dir: &Path, host_creds_json: &str) -> Result<Vec<u8>> {
let json: Value = serde_json::from_str(host_creds_json)
.context("parsing rotated host .credentials.json")?;
let oauth = json
.get("claudeAiOauth")
.context("rotated host .credentials.json missing claudeAiOauth")?;
@ -719,22 +755,46 @@ fn refresh_anthropic(state_dir: &Path) -> Result<Vec<u8>> {
Ok(http_200_json(&serde_json::to_vec(&body)?))
}
/// Public entry point for an OpenAI (Codex/ChatGPT) OAuth-refresh
/// interception. Thin wrapper mirroring [`refresh_anthropic`]: spawn
/// the host `codex` CLI to rotate the host auth file, read it back, and
/// hand the contents to [`rotate_openai`] for the testable rotation
/// logic.
fn refresh_openai(state_dir: &Path) -> Result<Vec<u8>> {
trigger_host_refresh(
"codex",
&[
"exec",
"--skip-git-repo-check",
"--dangerously-bypass-approvals-and-sandbox",
"Reply with OK",
],
)?;
// Single-flight (see `refresh_anthropic`): serialize host rotations
// and skip the `codex exec` if the token file was just rewritten by
// the launcher that held the lock before us. OpenAI-specific lock so
// an in-flight Anthropic refresh doesn't serialize against this one.
let _flight = RefreshLock::acquire(state_dir, secrets::REFRESH_LOCK_OPENAI)?;
if !token_recently_rotated(&secrets::openai_token_path(state_dir)) {
trigger_host_refresh(
"codex",
&[
"exec",
"--skip-git-repo-check",
"--dangerously-bypass-approvals-and-sandbox",
"Reply with OK",
],
)?;
}
let host_path = host_codex_auth_path().context("HOME not set")?;
let raw = std::fs::read_to_string(&host_path)
.with_context(|| format!("reading {}", host_path.display()))?;
let json: Value = serde_json::from_str(&raw)
.with_context(|| format!("parsing {}", host_path.display()))?;
// Re-wrap with the concrete path so a parse/extract failure in the pure fn
// still names which exact host file was bad (the pure fn uses a fixed label).
rotate_openai(state_dir, &raw)
.with_context(|| format!("rotating OpenAI token from {}", host_path.display()))
}
/// Pure rotation step for OpenAI: parse the (already-rotated) host
/// `codex auth.json` text, rewrite the per-project token file with the
/// fresh real access token, and synthesize the placeholder-carrying
/// OAuth refresh response. Split out from [`refresh_openai`] for the
/// same deterministic-testability reason as [`rotate_anthropic`].
fn rotate_openai(state_dir: &Path, host_auth_json: &str) -> Result<Vec<u8>> {
let json: Value =
serde_json::from_str(host_auth_json).context("parsing rotated host codex auth.json")?;
let new_access = json
.pointer("/tokens/access_token")
@ -758,13 +818,301 @@ fn refresh_openai(state_dir: &Path) -> Result<Vec<u8>> {
Ok(http_200_json(&serde_json::to_vec(&body)?))
}
/// How recently a token file must have been rewritten for the
/// single-flight waiter to trust it and skip its own host rotation.
///
/// Tied to [`HOST_REFRESH_TIMEOUT`]: a host `claude -p` / `codex exec`
/// is *allowed* to take up to that long, so a fixed 10 s window would
/// silently no-op in exactly the slow-rotation case the optimization is
/// meant to help — the holder finishes after, say, 25 s, and the waiter
/// then sees an mtime older than 10 s and redundantly re-runs the host
/// CLI even though the token it would read is current. Matching the
/// window to the rotation budget means a just-completed slow rotation is
/// still recognized as fresh. This is safe: the file's mtime is only
/// bumped by an actual successful rotation write, and host access tokens
/// live far longer than 90 s, so we never serve a stale token. A small
/// slack is added so a waiter that wakes slightly after the holder
/// returns still counts the rotation as fresh.
const REFRESH_FRESHNESS_WINDOW: std::time::Duration =
HOST_REFRESH_TIMEOUT.saturating_add(std::time::Duration::from_secs(5));
/// True if `path` exists and was modified within
/// [`REFRESH_FRESHNESS_WINDOW`]. Used by the second single-flight
/// waiter to decide it can re-read the just-rotated token file instead
/// of spawning another host CLI. Any error (missing file, clock skew
/// making mtime appear in the future) conservatively returns `false`
/// so we fall back to actually refreshing.
fn token_recently_rotated(path: &Path) -> bool {
let Ok(meta) = std::fs::metadata(path) else {
return false;
};
let Ok(modified) = meta.modified() else {
return false;
};
match modified.elapsed() {
Ok(age) => age <= REFRESH_FRESHNESS_WINDOW,
Err(_) => false,
}
}
/// Advisory cross-process lock serializing host-side OAuth refreshes
/// for one provider within a single project. Held for the duration of
/// one `refresh_anthropic` / `refresh_openai` call so two in-guest
/// agents (or two launchers) racing the *same* provider's token
/// rotation don't each spawn a host `claude -p` / `codex exec`.
///
/// The lock is keyed per provider (see [`secrets::refresh_lock_path_for`]):
/// an Anthropic rotation and an OpenAI rotation touch independent host
/// artifacts, so they hold different lock files and may run
/// concurrently — only same-provider refreshes serialize.
///
/// Uses a non-blocking `flock(LOCK_EX|LOCK_NB)` polled with a deadline
/// — already available via the `libc` dependency, so no new crate. The
/// lock is associated with the open file description and released
/// automatically when the fd is closed on `Drop` (or if the process
/// dies), so a crashed refresh can't wedge future rotations.
struct RefreshLock {
/// `Some` when we actually hold the flock; `None` when [`acquire`]
/// timed out waiting for a wedged-but-live holder and we degraded
/// to proceeding without serialization (see [`acquire`]). The fd is
/// still kept open in that case so `Drop` is uniform, but no
/// `LOCK_UN` is issued.
file: Option<std::fs::File>,
}
impl RefreshLock {
/// Acquire the per-provider refresh lock named `lock_name` (e.g.
/// [`secrets::REFRESH_LOCK_ANTHROPIC`]).
///
/// Bounded wait: a live-but-wedged holder must not block a waiter
/// indefinitely, which would reintroduce the unbounded stall the
/// [`HOST_REFRESH_TIMEOUT`] cap was added to prevent (review #8).
/// We poll `LOCK_EX|LOCK_NB` until the ceiling, then degrade to
/// proceeding *without* the lock — i.e. the pre-feature behavior of
/// just refreshing. That can cost a redundant host CLI spawn in the
/// rare wedged-holder case, but keeps the whole refresh path time
/// bounded, which matters more.
fn acquire(state_dir: &Path, lock_name: &str) -> Result<Self> {
Self::acquire_with_ceiling(state_dir, lock_name, HOST_REFRESH_TIMEOUT)
}
fn acquire_with_ceiling(
state_dir: &Path,
lock_name: &str,
ceiling: std::time::Duration,
) -> Result<Self> {
let path = secrets::refresh_lock_path_for(state_dir, lock_name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating secrets dir {}", parent.display()))?;
}
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&path)
.with_context(|| format!("opening refresh lock {}", path.display()))?;
use std::os::unix::io::AsRawFd as _;
use std::time::Instant;
let fd = file.as_raw_fd();
let start = Instant::now();
// Poll interval is small relative to a host rotation (seconds);
// the extra wakeups over a ~90 s ceiling are negligible.
let poll = std::time::Duration::from_millis(50);
loop {
let rc = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
if rc == 0 {
return Ok(Self { file: Some(file) });
}
let err = std::io::Error::last_os_error();
match err.raw_os_error() {
Some(libc::EINTR) => continue,
// Held by someone else — wait and retry until the ceiling.
Some(libc::EWOULDBLOCK) => {
if start.elapsed() >= ceiling {
// Degrade to no-lock rather than block forever on
// a wedged-but-live holder. `file: None` so Drop
// issues no LOCK_UN we never took.
tracing::warn!(
lock = %path.display(),
"refresh lock contended past {}s; proceeding without single-flight",
ceiling.as_secs(),
);
return Ok(Self { file: None });
}
std::thread::sleep(poll);
continue;
}
_ => {
return Err(anyhow::Error::new(err)
.context(format!("flock(LOCK_EX|LOCK_NB) on {}", path.display())));
}
}
}
}
}
impl Drop for RefreshLock {
fn drop(&mut self) {
use std::os::unix::io::AsRawFd as _;
// Only unlock if we actually acquired it; a timed-out acquire
// never took the lock, so issuing LOCK_UN would be wrong (and
// could release a lock another fd in this process holds, though
// that doesn't happen here).
if let Some(file) = &self.file {
unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_UN) };
}
}
}
#[cfg(test)]
mod refresh_lock_tests {
use super::*;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn token_recently_rotated_window() {
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("anthropic");
// Missing file -> not fresh.
assert!(!token_recently_rotated(&f));
// Just-written file -> fresh.
std::fs::write(&f, b"tok").unwrap();
assert!(token_recently_rotated(&f));
}
/// Two threads contending the same project lock must run their
/// critical sections one at a time. We track the number of holders
/// inside the locked region and assert it never exceeds one.
#[test]
fn contending_threads_serialize() {
let dir = tempfile::tempdir().unwrap();
let state = dir.path().join("proj");
std::fs::create_dir_all(&state).unwrap();
let in_section = Arc::new(AtomicUsize::new(0));
let max_concurrent = Arc::new(AtomicUsize::new(0));
let acquisitions = Arc::new(AtomicUsize::new(0));
let spawn = |state: std::path::PathBuf,
in_section: Arc<AtomicUsize>,
max_concurrent: Arc<AtomicUsize>,
acquisitions: Arc<AtomicUsize>| {
std::thread::spawn(move || {
for _ in 0..20 {
let _guard = RefreshLock::acquire(&state, secrets::REFRESH_LOCK_ANTHROPIC)
.expect("acquire");
acquisitions.fetch_add(1, Ordering::SeqCst);
let now = in_section.fetch_add(1, Ordering::SeqCst) + 1;
// Record the peak observed concurrency.
max_concurrent.fetch_max(now, Ordering::SeqCst);
// Hold briefly to widen the race window.
std::thread::sleep(std::time::Duration::from_millis(1));
in_section.fetch_sub(1, Ordering::SeqCst);
}
})
};
let t1 = spawn(
state.clone(),
in_section.clone(),
max_concurrent.clone(),
acquisitions.clone(),
);
let t2 = spawn(
state.clone(),
in_section.clone(),
max_concurrent.clone(),
acquisitions.clone(),
);
t1.join().unwrap();
t2.join().unwrap();
assert_eq!(acquisitions.load(Ordering::SeqCst), 40);
assert_eq!(
max_concurrent.load(Ordering::SeqCst),
1,
"flock failed to serialize: two holders entered the critical section at once"
);
}
/// Different providers use different lock files, so a holder of the
/// Anthropic lock must not block an OpenAI acquire (and vice versa).
#[test]
fn distinct_providers_do_not_contend() {
let dir = tempfile::tempdir().unwrap();
let state = dir.path().join("proj");
std::fs::create_dir_all(&state).unwrap();
let anthropic = RefreshLock::acquire(&state, secrets::REFRESH_LOCK_ANTHROPIC)
.expect("anthropic acquire");
// Holding the Anthropic lock, an OpenAI acquire must succeed
// immediately (not time out, not degrade) — it's a different file.
let openai = RefreshLock::acquire_with_ceiling(
&state,
secrets::REFRESH_LOCK_OPENAI,
std::time::Duration::from_millis(50),
)
.expect("openai acquire");
// Both genuinely hold their locks.
assert!(openai.file.is_some(), "openai should hold its own lock");
drop(anthropic);
drop(openai);
}
/// A live-but-wedged holder must not block a waiter past the
/// ceiling: the second acquire returns within the bound and degrades
/// to the no-lock state instead of hanging forever.
#[test]
fn bounded_wait_degrades_when_holder_wedged() {
let dir = tempfile::tempdir().unwrap();
let state = dir.path().join("proj");
std::fs::create_dir_all(&state).unwrap();
// First holder keeps the lock for the whole test.
let _held = RefreshLock::acquire(&state, secrets::REFRESH_LOCK_ANTHROPIC)
.expect("first acquire");
let ceiling = std::time::Duration::from_millis(200);
let start = std::time::Instant::now();
let second = RefreshLock::acquire_with_ceiling(
&state,
secrets::REFRESH_LOCK_ANTHROPIC,
ceiling,
)
.expect("second acquire returns Ok (degraded)");
let waited = start.elapsed();
// Returned within a small multiple of the ceiling (not blocked
// indefinitely), and degraded to no-lock.
assert!(
waited < ceiling * 4,
"acquire blocked {waited:?}, expected ~{ceiling:?}"
);
assert!(
second.file.is_none(),
"contended acquire past ceiling must degrade to the no-lock state"
);
}
}
/// Bound on how long we'll wait for a host `claude -p` / `codex exec`
/// to drive a token rotation. A hung host CLI must not keep the in-VM
/// agent's OAuth refresh waiting indefinitely (review #8). 90 s is
/// enough for normal claude/codex round-trips and small enough to
/// surface a problem before the guest agent's own timeout fires.
///
/// Shared so the single-flight lock-wait ceiling
/// ([`RefreshLock::acquire`]) and the freshness window
/// ([`REFRESH_FRESHNESS_WINDOW`]) stay tied to the actual rotation
/// budget rather than drifting from it.
const HOST_REFRESH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(90);
fn trigger_host_refresh(cmd: &str, args: &[&str]) -> Result<()> {
// Bounded wait so a hung host CLI doesn't keep the in-VM agent's
// OAuth refresh waiting indefinitely (review #8). 90 s is enough
// for normal claude/codex round-trips and small enough to surface
// a problem before the guest agent's own timeout fires.
use std::time::{Duration, Instant};
const TIMEOUT: Duration = Duration::from_secs(90);
const TIMEOUT: Duration = HOST_REFRESH_TIMEOUT;
let mut child = Command::new(cmd)
.args(args)
@ -1835,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"
);
}
}

View file

@ -23,9 +23,9 @@ 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 / shell
agent-vm claude launch in this project or codex / opencode / copilot / shell
claude, codex, opencode and shell share the same options;
claude, codex, opencode, copilot and shell share the same options;
see `agent-vm claude --help` for mounts, ports, networking and credentials.";
#[derive(Parser)]
@ -57,6 +57,9 @@ enum Cmd {
/// Launch OpenCode in a per-project sandbox.
Opencode(run::Args),
/// Launch GitHub Copilot CLI in a per-project sandbox.
Copilot(run::Args),
/// Open a bash shell in a per-project sandbox.
Shell(run::Args),
@ -104,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,

View file

@ -141,6 +141,7 @@ pub enum Agent {
Claude,
Codex,
Opencode,
Copilot,
Shell,
}
@ -150,6 +151,7 @@ impl Agent {
Agent::Claude => "claude",
Agent::Codex => "codex",
Agent::Opencode => "opencode",
Agent::Copilot => "copilot",
Agent::Shell => "bash",
}
}
@ -169,6 +171,14 @@ 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 => &[],
}
}
@ -176,9 +186,9 @@ impl Agent {
// 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 four launch
// verbs (claude/codex/opencode/shell), so this footer can't vary per
// verb — the examples use `claude` and the header says 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
@ -188,7 +198,8 @@ Examples (claude shown; codex/opencode/shell take the same options):
Trailing args go to the agent. Run with --help for networking, security, and env details.";
// Fuller footer shown under `--help`. Same single-`Args` constraint.
// 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
@ -519,8 +530,33 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
eprintln!("==> GitHub repo scope: <none> (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
@ -663,6 +699,12 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
"/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 <state>/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
@ -734,7 +776,8 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
// the body's refresh_token is a placeholder, not a header.
let has_creds = creds.anthropic_token_file.is_some()
|| creds.openai_token_file.is_some()
|| creds.gh_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;
@ -746,6 +789,17 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
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();
@ -843,6 +897,23 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
.allow_host(GITHUB_OBJECTS_HOST)
});
}
// D1: GitHub Copilot CLI sends `Authorization: Bearer
// <COPILOT_TOKEN_PLACEHOLDER>` 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<String> = vec![
self_path.to_string_lossy().to_string(),
@ -920,6 +991,28 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
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!(
"==> Booting sandbox from {image} ({memory_mib} MiB, {cpus} vCPU; first run pulls layers, otherwise ~3s)"
@ -1072,7 +1165,7 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
// 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.
// (Importing the microsandbox MITM CA into the `chrome` user's NSS
// 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
@ -1081,26 +1174,18 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
// 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.)
let project_guest_path_escaped = shell_escape(&project_guest_path);
let prelude = format!(
"sed -i '/^nameserver .*:/d' /etc/resolv.conf 2>/dev/null || true\n\
[ -t 0 ] || exec < /dev/null\n\
_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,
// 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<String> = vec!["-c".into(), shell_line];
@ -1723,6 +1808,66 @@ fn parse_github_slug(url: &str) -> Option<String> {
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 <host>` 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";
/// 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\
[ -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
@ -1753,6 +1898,92 @@ 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'"));
}
/// 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]

View file

@ -20,7 +20,8 @@ use serde_json::Value;
use sha2::{Digest, Sha256};
use crate::host_paths::{
atomic_write, host_claude_creds_path, host_codex_auth_path, host_opencode_auth_path,
atomic_write, host_claude_creds_path, host_codex_auth_path, host_copilot_token_path,
host_opencode_auth_path,
};
// ---------------------------------------------------------------------------
@ -83,6 +84,17 @@ pub const OPENCODE_OPENAI_REFRESH_PLACEHOLDER: &str = "msb-opencode-placeholder-
/// git credential helper sees this string; the proxy substitutes the
/// real bearer on outbound traffic to GitHub.
pub const GH_TOKEN_PLACEHOLDER: &str = "msb-gh-placeholder-v2";
/// Placeholder for the host's GitHub Copilot token. The in-guest
/// Copilot CLI sees this string (exported as `COPILOT_GITHUB_TOKEN`
/// via `/etc/profile.d` and stored in `~/.copilot/config.json`); the
/// proxy substitutes the real GitHub OAuth token on outbound traffic
/// to the Copilot API. Kept distinct from `GH_TOKEN_PLACEHOLDER` so
/// substituting one can't accidentally rewrite the other in unrelated
/// request bytes — even though both happen to resolve to the same
/// host gh token, they're registered against different allow-host
/// sets (GitHub API vs Copilot API). Mirrors the original Bash
/// agent-vm's `placeholder-copilot-token-injected-by-proxy`.
pub const COPILOT_TOKEN_PLACEHOLDER: &str = "msb-copilot-placeholder-v2";
// Hostnames the secret-substitution proxy + interceptor key off. Kept
// here so the launcher (`run.rs`), the hook (`intercept_hook`), and any
@ -105,6 +117,15 @@ pub const GITHUB_CODELOAD_HOST: &str = "codeload.github.com";
pub const GITHUB_RAW_HOST: &str = "raw.githubusercontent.com";
pub const GITHUB_OBJECTS_HOST: &str = "objects.githubusercontent.com";
/// GitHub Copilot API endpoints. The Copilot CLI sends
/// `Authorization: Bearer <token>` to these; the proxy substitutes
/// [`COPILOT_TOKEN_PLACEHOLDER`] for the real GitHub OAuth token.
/// Both are needed: `api.githubcopilot.com` for business/enterprise
/// seats and `api.individual.githubcopilot.com` for individual ones.
/// Mirrors the original Bash agent-vm's credential-proxy domains.
pub const COPILOT_API_HOST: &str = "api.githubcopilot.com";
pub const COPILOT_API_INDIVIDUAL_HOST: &str = "api.individual.githubcopilot.com";
pub const ANTHROPIC_OAUTH_TOKEN_PATH: &str = "/v1/oauth/token";
pub const OPENAI_OAUTH_TOKEN_PATH: &str = "/oauth/token";
@ -125,6 +146,30 @@ pub struct CredsState {
/// on outbound traffic to GitHub. Only `Some` when the user has
/// `gh` logged in *and* `--no-git` was not passed.
pub gh_token_file: Option<PathBuf>,
/// File holding the host's GitHub Copilot token (a GitHub OAuth
/// token with Copilot access). The proxy substitutes
/// `COPILOT_TOKEN_PLACEHOLDER` for this on outbound traffic to the
/// Copilot API hosts. Sourced from the original Bash agent-vm's
/// device-flow cache (`~/.cache/claude-vm/copilot-token.json`) if
/// present, else falls back to the captured `gh auth token` (a gh
/// login with the Copilot scope works against the Copilot API).
///
/// `Some` whenever a usable token was found and either the Copilot
/// agent is being launched (`want_copilot`) or GitHub egress is
/// already enabled for another agent (`use_github`). Crucially this
/// is NOT gated on `--no-git` for a Copilot launch: the Copilot API
/// is not repo-scoped, so `agent-vm copilot` must work even in a
/// non-GitHub project.
///
/// **Known limitation (no in-session refresh):** unlike the
/// Anthropic/OpenAI access tokens, the Copilot token is not
/// round-tripped through `intercept_hook`'s OAuth-refresh path. If
/// the captured token expires mid-session, the proxy keeps
/// substituting the stale value and Copilot requests start failing
/// with 401 until the next `agent-vm` launch re-captures from the
/// host. gh user tokens are typically long-lived, so the practical
/// impact is low; re-launch to recover.
pub copilot_token_file: Option<PathBuf>,
pub snapshot: Option<HostCredsSnapshot>,
}
@ -174,6 +219,71 @@ pub fn gh_token_path(state_dir: &Path) -> PathBuf {
host_secret_dir(state_dir).join("gh")
}
/// Per-provider advisory lock file serializing host-side OAuth refreshes
/// for one provider within a single project. The in-VM agent's
/// `_intercept-hook` acquires an exclusive `flock` on this path before
/// spawning a host `claude -p` / `codex exec` to drive a token rotation,
/// so two racing in-guest refreshes of the *same* provider don't each
/// launch their own host CLI.
///
/// Keyed per provider (`name` is e.g. [`REFRESH_LOCK_ANTHROPIC`]) so a
/// concurrent Anthropic and OpenAI in-guest refresh don't serialize
/// against each other — they rotate independent host credential files
/// and write distinct token files, so there is no shared state to guard
/// across providers. Lives in the host-only [`host_secret_dir`] (never
/// bind-mounted into the guest), alongside the token files the refresh
/// rewrites.
///
/// Note: the launcher's [`refresh`] uses a *different*, single shared
/// lock ([`ProjectRefreshLock`]) because it does read-modify-write on
/// per-project state files shared across all providers (`claude.json`,
/// `claude/settings.json`, `opencode-config/opencode.json`), so its
/// critical section genuinely spans every provider.
pub fn refresh_lock_path_for(state_dir: &Path, name: &str) -> PathBuf {
host_secret_dir(state_dir).join(name)
}
/// Lock basename for the Anthropic in-guest refresh single-flight.
pub const REFRESH_LOCK_ANTHROPIC: &str = ".refresh.anthropic.lock";
/// Lock basename for the OpenAI in-guest refresh single-flight.
pub const REFRESH_LOCK_OPENAI: &str = ".refresh.openai.lock";
#[cfg(test)]
mod refresh_lock_tests {
use super::*;
#[test]
fn per_provider_lock_paths_are_distinct_and_in_secret_dir() {
let state = Path::new("/home/u/.cache/agent-vm/abc123");
let anthropic = refresh_lock_path_for(state, REFRESH_LOCK_ANTHROPIC);
let openai = refresh_lock_path_for(state, REFRESH_LOCK_OPENAI);
// Two providers must not share a lock file.
assert_ne!(anthropic, openai);
// Expected concrete paths in the sibling `<name>.secrets/` dir.
assert_eq!(
anthropic,
Path::new("/home/u/.cache/agent-vm/abc123.secrets/.refresh.anthropic.lock")
);
assert_eq!(
openai,
Path::new("/home/u/.cache/agent-vm/abc123.secrets/.refresh.openai.lock")
);
// Both live in the host-only secrets dir, never under state_dir
// (which is bind-mounted into the guest).
assert_eq!(anthropic.parent(), anthropic_token_path(state).parent());
assert_eq!(openai.parent(), anthropic_token_path(state).parent());
assert!(!anthropic.starts_with(state));
assert!(!openai.starts_with(state));
}
}
/// Per-project location of the Copilot token file the proxy re-reads.
/// Lives in the host-only [`host_secret_dir`], never inside the guest
/// mount (it holds the real GitHub OAuth token).
pub fn copilot_token_path(state_dir: &Path) -> PathBuf {
host_secret_dir(state_dir).join("copilot")
}
/// OpenCode reuses the same OpenAI access token file: both Codex and
/// OpenCode hit api.openai.com / chatgpt.com and the proxy substitutes
/// each provider's distinct placeholder string for the same real
@ -201,6 +311,7 @@ pub fn refresh(
state_dir: &Path,
project_guest_path: &str,
use_github: bool,
want_copilot: bool,
) -> Result<CredsState> {
let _lock = ProjectRefreshLock::acquire(state_dir)
.context("acquiring per-project refresh lock")?;
@ -224,7 +335,7 @@ pub fn refresh(
// First-run bypasses, run regardless of whether the user has host
// credentials for the provider. Without these the in-VM agent
// blocks on a terminal-style wizard at first launch.
write_agent_config_defaults(state_dir, project_guest_path)?;
write_agent_config_defaults(state_dir, project_guest_path, want_copilot)?;
let anthropic_token_file = refresh_anthropic(state_dir).unwrap_or_else(|e| {
tracing::warn!(error = %e, "anthropic credential refresh failed; skipping");
@ -270,6 +381,34 @@ pub fn refresh(
None
};
// D1: capture the host's GitHub Copilot token. Prefer the
// device-flow cache the original Bash agent-vm wrote
// (`~/.cache/claude-vm/copilot-token.json`); fall back to the
// `gh auth token` we just captured (a gh login carries the
// Copilot scope for users with a Copilot seat).
//
// Unlike the gh capture, this is NOT gated on `use_github`. The
// Copilot API is reached with a GitHub OAuth token, but it is not
// repo-scoped the way `api.github.com` push is — so the reason to
// run `agent-vm copilot` (GitHub-backed AI) must not be switched off
// just because the project has no detected GitHub remote or the user
// passed `--no-git`. We capture the token whenever the Copilot agent
// is the one being launched (`want_copilot`), or when GitHub egress
// is already enabled for another agent (`use_github`) so an existing
// gh login still flows through. When `want_copilot && !use_github`
// there is no gh fallback token, so capture succeeds only via the
// device-flow cache; the caller surfaces a clear error if nothing
// was obtained rather than letting the guest send an unsubstituted
// placeholder bearer.
let copilot_token_file = if use_github || want_copilot {
refresh_copilot(state_dir, gh_token_file.as_deref()).unwrap_or_else(|e| {
tracing::warn!(error = %e, "copilot credential capture failed; skipping");
None
})
} else {
None
};
// SHA-256 snapshot of host credential files for post-run mutation
// detection. Phase 4's refresh hook *legitimately* rewrites these;
// anything else doing so is a bug to investigate. See
@ -281,6 +420,7 @@ pub fn refresh(
openai_token_file,
opencode_openai_access_token_file,
gh_token_file,
copilot_token_file,
snapshot,
})
}
@ -589,6 +729,71 @@ fn refresh_gh(state_dir: &Path) -> Result<Option<PathBuf>> {
Ok(Some(token_file))
}
/// Parse the original Bash agent-vm's Copilot token cache file. The
/// cache is JSON `{"access_token": "<gho_…>"}` (see
/// `copilot_token.py`); return the non-empty `access_token` string.
/// Split out so it can be unit-tested without touching `$HOME`.
fn extract_copilot_token(raw: &str) -> Option<String> {
let json: Value = serde_json::from_str(raw).ok()?;
let tok = json.get("access_token").and_then(|v| v.as_str())?.trim();
if tok.is_empty() {
None
} else {
Some(tok.to_string())
}
}
/// Capture the host's GitHub Copilot token into a 0600 file under
/// `<state>.secrets/copilot`. The proxy substitutes
/// [`COPILOT_TOKEN_PLACEHOLDER`] for this file's content on outbound
/// traffic to the Copilot API.
///
/// Two sources, in priority order:
/// 1. The device-flow cache the original Bash agent-vm wrote at
/// `~/.cache/claude-vm/copilot-token.json` (JSON
/// `{"access_token": …}`). Reusing it means an existing host
/// Copilot login is honoured without re-running the OAuth device
/// flow inside this Rust launcher.
/// 2. The `gh auth token` we already captured this launch
/// (`gh_token_file`). A gh user OAuth token carries the Copilot
/// scope for accounts with a Copilot seat, so it works against
/// `api.githubcopilot.com`.
///
/// Returns `None` (non-fatal) when neither source yields a token —
/// Copilot is then simply unavailable in the guest, same as the
/// original's "could not obtain Copilot token" warning.
fn refresh_copilot(state_dir: &Path, gh_token_file: Option<&Path>) -> Result<Option<PathBuf>> {
// 1. Device-flow cache from the original Bash agent-vm.
if let Some(cache) = host_copilot_token_path() {
match std::fs::read_to_string(&cache) {
Ok(raw) => {
if let Some(token) = extract_copilot_token(&raw) {
let token_file = copilot_token_path(state_dir);
atomic_write(&token_file, token.as_bytes(), 0o600)?;
return Ok(Some(token_file));
}
// Present but unparseable/empty → fall through to gh.
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e).with_context(|| format!("reading {}", cache.display())),
}
}
// 2. Fall back to the gh token captured this launch.
if let Some(gh_file) = gh_token_file {
let token = std::fs::read_to_string(gh_file)
.with_context(|| format!("reading {}", gh_file.display()))?;
let token = token.trim();
if !token.is_empty() {
let token_file = copilot_token_path(state_dir);
atomic_write(&token_file, token.as_bytes(), 0o600)?;
return Ok(Some(token_file));
}
}
Ok(None)
}
/// SHA-256 the three host credential files. Files that don't exist or
/// can't be read are recorded as `None` — only files that successfully
/// hash become anchors for [`verify_snapshot`].
@ -641,7 +846,11 @@ fn hash_file(path: &Path) -> Option<String> {
/// trust/approval settings) into the per-project state dir. Idempotent
/// across launches; merges instead of overwrites so user tweaks
/// survive.
fn write_agent_config_defaults(state_dir: &Path, project_guest_path: &str) -> Result<()> {
fn write_agent_config_defaults(
state_dir: &Path,
project_guest_path: &str,
want_copilot: bool,
) -> Result<()> {
let claude_dir = state_dir.join("claude");
std::fs::create_dir_all(&claude_dir)?;
write_default_claude_settings(&claude_dir.join("settings.json"))?;
@ -671,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
@ -1183,6 +1410,43 @@ fn write_default_opencode_config(path: &Path) -> Result<()> {
Ok(())
}
/// Write the GitHub Copilot CLI's `~/.copilot/config.json`. Two
/// purposes, both mirroring the original Bash agent-vm's
/// `_copilot_vm_setup_home`:
///
/// - `trusted_folders = ["/"]` so the CLI never prompts "do you
/// trust this folder?" — the microVM is the sandbox, so trusting
/// every path inside it is correct.
/// - the token field carries [`COPILOT_TOKEN_PLACEHOLDER`], which
/// the proxy substitutes for the real token on outbound traffic.
/// The CLI also honours the `COPILOT_GITHUB_TOKEN` env var (set by
/// the launcher); writing it here too covers config-first reads.
///
/// Merge-on-existing so a user's own settings survive across launches;
/// only the fields we manage are force-set.
fn write_default_copilot_config(path: &Path) -> Result<()> {
let mut config: Value = match std::fs::read_to_string(path) {
Ok(raw) => serde_json::from_str(&raw).unwrap_or(serde_json::json!({})),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => serde_json::json!({}),
Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
};
let obj = config
.as_object_mut()
.context("copilot config.json is not an object")?;
// Trust everything — the VM itself is the security boundary.
obj.insert(
"trusted_folders".into(),
Value::Array(vec![Value::String("/".into())]),
);
// Placeholder token; the proxy swaps it for the real one outbound.
obj.insert(
"github_token".into(),
Value::String(COPILOT_TOKEN_PLACEHOLDER.into()),
);
atomic_write(path, serde_json::to_vec(&config)?.as_slice(), 0o600)?;
Ok(())
}
/// Advisory exclusive flock on `<state_dir>/.refresh.lock`. Held for
/// the duration of [`refresh`] so two concurrent `agent-vm` launchers
/// in the same project don't interleave reads/writes of the shared
@ -1342,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

View file

@ -3,8 +3,8 @@
# 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
# Contents: Debian 13 slim + the four AI coding agents (Claude
# Code, OpenCode, Codex, GitHub Copilot), minimum dev tooling, and the docker engine
# (docker.io + containerd + runc + fuse-overlayfs). Other heavy
# extras (LSPs, additional MCP servers, docker-buildx) are
# deliberately deferred.
@ -17,7 +17,7 @@
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 + minimal dev tooling. Rebuilt hourly to pick up new agent releases. NOT the agent-vm runtime itself — install that via `npm install -g @wirenboard/agent-vm`."
# LANG=C.UTF-8: debian:slim ships with NO locale set, leaving the guest in
# C/POSIX — which renders a non-ASCII (e.g. Cyrillic) cwd as `M-P…` meta-
@ -257,6 +257,52 @@ ARG AGENT_INSTALL_CACHEBUST=
# Claude Code official installer.
RUN curl -fsSL https://claude.ai/install.sh | bash
# Claude Code LSP plugins (C/C++, Python, TypeScript, Go) baked in so
# in-VM Claude has code intelligence on first launch. Ports the four
# language servers the original `setup` installed (claude-vm.sh:348-362):
# clangd-lsp / pyright-lsp / typescript-lsp / gopls-lsp from the
# claude-plugins-official marketplace.
#
# Marketplace registration first: `claude plugin marketplace add` clones
# https://github.com/anthropics/claude-plugins-official.git into Claude's
# plugin state, then each `claude plugin install <name>@<marketplace>`
# resolves against it. The bare repo URL is what the original git-cloned
# by hand; `marketplace add` lets the CLI manage the clone + index for us.
#
# Best-effort per the chrome-mcp pre-warm style above: a transient
# registry/clone blip during the hourly build must not fail the whole
# image. Each step is guarded with `|| echo skipped`; a missing plugin
# costs in-VM code intelligence for that language, not a broken image —
# Claude still boots and the user can re-run `claude plugin install`.
#
# Must come AFTER the Claude install above — `claude` didn't exist before
# it. node/npm (from the nodejs RUN) and git (from the base apt RUN) are
# both already present, which the marketplace clone + plugin resolution
# need.
#
# Invoke claude by ABSOLUTE PATH (/root/.local/bin/claude): the installer
# drops the binary there but the only ENV PATH in this file is
# /usr/local/cargo/bin:$PATH, and Docker RUN uses a non-login /bin/sh that
# never sources the shell profile the installer edits. A bare `claude`
# would hit command-not-found (rc 127), every `|| echo skipped` guard would
# fire, and the RUN would still exit 0 — baking a GREEN image with ZERO LSP
# plugins. The absolute path matches the `--version` sanity check below and
# the OpenCode block's /root/.opencode/bin/opencode.
#
# A `claude plugin list` runs after the loop so the build log shows which
# plugins actually landed — without it the best-effort guards would hide a
# total no-op behind a clean build. If `marketplace add` ever rejects the
# `owner/repo` shorthand, swap in the explicit URL
# https://github.com/anthropics/claude-plugins-official.git.
RUN { /root/.local/bin/claude plugin marketplace add anthropics/claude-plugins-official \
|| echo "==> LSP marketplace add skipped (will lazy-add at first plugin use)"; } \
&& for lsp in clangd-lsp pyright-lsp typescript-lsp gopls-lsp; do \
/root/.local/bin/claude plugin install "${lsp}@claude-plugins-official" \
|| echo "==> LSP plugin ${lsp} skipped (install at runtime if needed)"; \
done \
&& echo "==> LSP plugins installed at build time:" \
&& { /root/.local/bin/claude plugin list || true; }
# 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 \
@ -265,8 +311,15 @@ RUN curl -fsSL https://opencode.ai/install | bash \
# Codex CLI official installer.
RUN curl -fsSL https://github.com/openai/codex/releases/latest/download/install.sh | 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 so a broken installer surfaces before we push.
RUN claude --version && opencode --version && codex --version \
RUN claude --version && opencode --version && codex --version && copilot --version \
&& dockerd --version && docker --version && containerd --version && runc --version
# Smoke entrypoint; the launcher overrides this in Phase 2.

View file

@ -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

View file

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

View file

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

View file

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

View file

@ -22,7 +22,7 @@ const fs = require("node:fs");
const PLATFORM_PACKAGES = {
"linux-x64": "@wirenboard/agent-vm-linux-x64",
// "linux-arm64": "@wirenboard/agent-vm-linux-arm64",
"linux-arm64": "@wirenboard/agent-vm-linux-arm64",
// "darwin-arm64": "@wirenboard/agent-vm-darwin-arm64",
// "darwin-x64": "@wirenboard/agent-vm-darwin-x64",
// "win32-x64": "@wirenboard/agent-vm-win32-x64",

View file

@ -22,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"