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