Headline changes since 0.1.24:
- network: all guest egress (HTTPS/HTTP/image pulls) is re-originated through
the configured host HTTP proxy via CONNECT, honouring HTTPS_PROXY/HTTP_PROXY/
ALL_PROXY and NO_PROXY, while preserving the TLS-intercept egress policy.
- fails closed: a configured-but-unreachable proxy errors instead of silently
dialing direct.
- security: reject control chars/whitespace in the CONNECT target host and in
parsed SNI, closing a guest-controlled CRLF/header-injection vector.
- run: verbose startup banner reporting the proxy route (and NO_PROXY bypass).
- vendor/microsandbox bumped to wb merge of microsandbox#4.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The SDK's default `prebuilt` feature made `cargo build -p agent-vm` download the
upstream msb+libkrunfw bundle from superradcompany. agent-vm ships its own
from-source msb, so disable it (default-features = false, keep net + keyring).
Builds now require a local build/agentd (`just build-agentd`), matching the
release workflow; Cargo.lock loses the download-only deps.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0119b4kV63pJXm5rGeZbz8jf
Bumps the vendor/microsandbox submodule from the old wirenboard fork
(702f52a) to the wb branch — all downstream agent-vm features re-ported
onto current upstream microsandbox (sdk/rust @ upstream/main 69803ab3).
See wirenboard/microsandbox @ wb.
Two adaptations forced by upstream API/layout changes:
- Cargo.toml: the SDK crate moved crates/microsandbox -> sdk/rust.
- run.rs: Image::get now takes a LocalBackend handle (seed_pulled_marker).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bumps the vendored msb to use jemalloc as its global allocator. On
many-core hosts glibc's per-thread malloc arenas (8*ncpu, 64 MiB each)
are retained and never returned to the OS under the sandbox supervisor's
Rust worker-thread churn (virtio-fs / block / blocking pools), ballooning
host RSS to 10-20+ GiB even for a `--memory 4` sandbox — the guest RAM is
a separate, correctly-capped mapping. jemalloc (background_threads on)
purges freed pages back to the OS, so host RSS tracks the live set.
Submodule bump: vendor/microsandbox -> msb-jemalloc-v0123 (jemalloc-only
diff on the v0.1.22 base). Verified: a fresh sandbox under heavy file/exec
churn held 0 glibc 64-MiB arenas at ~300 MiB RSS vs a live glibc sandbox
at 408 arenas / 5.2 GiB.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ships the follow-up to the v0.1.x `--help` regroup: a code review of
that change surfaced one functional doc bug and several accuracy /
clarity gaps, all fixed in the merged `help: fix review findings`
commit.
- `--help` example `agent-vm shell -- -c 'cargo test'` was wrong (the
launcher already wraps a Shell agent's trailing args in `bash -c`,
so the literal `-c` double-applied -> `bash: -c: command not found`).
- the shared launch footer is now labelled "(claude shown;
codex/opencode/shell take the same options)" instead of claiming to
be command-agnostic while showing only claude examples.
- Environment section gained AGENT_VM_STATE_DIR and
AGENT_VM_INSECURE_REGISTRY (both honoured on the launch path).
- `--publish` value_name is now `[BIND:]HOST_PORT:GUEST_PORT`.
- networking cheatsheet rewritten to fit 80 cols under wrap_help.
- crate `description` aligned to "microVMs".
Presentation-only; no flag, env var, or behaviour changed.
`cargo build -p agent-vm` on the merged tree: clean; binary reports
0.1.21. `cargo test -p agent-vm`: 123 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Profiling (LAUNCH-PROFILE.md) found the launch was dominated by PRE-BOOT host
work, not the VM boot. Three fixes, ~2.5s -> ~1.8s on a warm launch:
- secrets.rs: cache the host git identity (24h TTL, validated strings only).
`gh api user` was an uncached ~1.29s HTTPS round-trip on every launch's
critical path (added in v0.1.14) — the main 1.3s->2.5s regression.
- run.rs: spawn the ghcr.io update-banner check instead of awaiting it
(~0.9s off the hot path; banner still fires, just doesn't block boot).
- run.rs + images/Dockerfile: move the chrome-MCP MITM-CA `certutil` import
out of the per-launch in-guest prelude into the agent-vm-chrome-mcp wrapper.
It now runs once at MCP startup (as the chrome user that owns the NSS DB),
off the launch path and skipped when chrome is unused (~270ms). Verified
end-to-end: the proxy serves a `microsandbox CA`-signed cert that validates
against the CA the wrapper imports.
Also: kernel A/B showed the nested-virt libkrunfw rebuild adds only ~190ms
(KVM ~100, conntrack ~62); DEFERRED_STRUCT_PAGE_INIT and split_irqchip have no
wall-clock effect. Build/measure scripts under profiling/.
NOTE: the Dockerfile (wrapper) and binary (prelude removal) must ship together
— the image must be rebuilt to :latest with the new wrapper before this binary
is published, or chrome MCP loses its CA.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The launch verbs (claude/codex/opencode/shell) share one `run::Args`,
and every flag carried its full multi-sentence explanation as the
*short* help. clap renders the first doc paragraph as `-h` text, so
`-h` was a wall: 13 flags, each 3-8 lines of run-on prose. Worse, the
crate built clap without `wrap_help`, so `--help` printed each long
paragraph as one unwrapped line — the terminal soft-wrapped it, but
copy/paste and narrow widths showed a single giant line per flag.
Split short vs long help, and group the flags:
- Every option now leads with a terse one-line summary (the `-h`
text); the original detail is kept as follow-on paragraphs that
only show under `--help`.
- Flags are bucketed with `help_heading`: Sandbox resources /
GitHub access / Mounts & ports / Network egress / Image.
- Added the `wrap_help` clap feature so `--help` prose wraps to the
terminal width instead of emitting one line per paragraph.
- Real value names: <GIB>, <N>, <HOST[:GUEST]>, <[BIND:]HOST:GUEST>,
<IP|CIDR>, <OWNER/REPO>, <REF>.
- `--help` gains an Examples block, a host<->guest Networking
cheatsheet, and an Environment section (vars verified against the
code).
Top level: wire up `-V/--version` (it errored before), add a
"Getting started" footer, make the subcommand descriptions terse and
parallel, and drop the redundant "See `clipboard --help`" line.
setup/pull `--image` get the same terse-first-line split.
Pure presentation change — no flag, env var, or behaviour added or
removed. `cargo test -p agent-vm`: 123 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New `--allow-host` flag opens DestinationGroup::Host in the egress
policy, which is the group addr_classify reserves for the per-
sandbox gateway IP (the one resolve_host_dst rewrites to the
host's 127.0.0.1 at dial time). End result: a service bound to
`127.0.0.1:8080` on the host is reachable from inside the guest
as `host.microsandbox.internal:8080` — same name agentd already
puts in `/etc/hosts`, no extra setup.
Scope: opens the gateway IPs (v4 + v6) only. Not the host's LAN
IP, not the guest's own loopback, not metadata. Composes with
--allow-egress / --allow-lan / --publish / --auto-publish.
Security note in --help mirrors --allow-lan's: anything bound to
host loopback (admin UIs, dev DBs, TCP-exposed Docker socket)
becomes reachable to a possibly-compromised in-guest process.
Vendor bump (microsandbox 2dbfa38) carries the
allow_egress_group(Host)-opens-gateway-only test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Workspace version bump after merging `investigate-image-check`: the
launch-time "newer image available" probe now performs the OCI registry
Bearer-token handshake, so the banner actually fires against the default
ghcr.io image (it was silently 401'ing and never showing). Also bounds the
probe with an overall 5s budget so the extra round-trips can't stall boot.
Cargo.lock refreshed by the post-bump build.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Workspace version bump for the merged
`worktree-vm-network-investigation` branch (Lima-style host ← guest
port mirroring + per-launch egress policy hole-punching).
New AGENTS.md captures the conventions I keep forgetting:
- bump workspace version after every feature merge
- merge submodule before superproject when both move
- don't relocate build output to tmpfs
- don't `rm -rf` inline from tool calls (use scripts)
- commit-message style on this branch is multi-paragraph Why/How
README now points at AGENTS.md alongside PLAN.md / ARCHITECTURE.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings in:
- `--publish HOST:GUEST[/proto]` for host → VM port forwarding (incl. [::1]
IPv6 bind form; /udp rejected).
- `--auto-publish` for Lima-style host ← guest port mirroring (mirrors
every 0.0.0.0:* and 127.0.0.1:* listener inside the guest to the host).
- `--allow-egress <IP|CIDR>` (repeatable) and `--allow-lan` to punch
per-launch holes through the default `public_only` egress policy.
Vendor submodule bumped to f56b822 (merge of auto-publish into
agent-vm-secret-file).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
# Conflicts:
# vendor/microsandbox
Two new CLI flags to punch holes through the default `public_only`
policy without forcing users to rebuild a full NetworkPolicy:
--allow-egress 10.100.1.75
--allow-egress 10.100.1.0/24 # CIDR
--allow-egress fd00::1/128 # IPv6
--allow-egress [::1] # bare IP gets a /32 or /128
--allow-lan # opens the entire Private group
Repeatable; per-IP and group flags compose. Loopback, link-local,
and metadata groups stay denied even under --allow-lan — Private
is intentionally disjoint from those.
5 unit tests for the parser (bare v4, v4 CIDR, bare v6, v6 CIDR,
rejects garbage). Live e2e:
baseline (no flag):
169.254.169.254:80 ECONNREFUSED
10.100.1.75:80 ECONNREFUSED
--allow-egress 169.254.169.254 --allow-lan:
169.254.169.254:80 OPEN
10.100.1.75:80 OPEN
192.168.1.1:80 OPEN
127.0.0.1:80 ECONNREFUSED (loopback group not opened)
--allow-egress 10.100.1.0/24:
10.100.1.75:80 OPEN
10.100.2.1:80 ECONNREFUSED (out of CIDR)
Vendor bump (f230c29) carries the matching NetworkBuilder helpers
and the new microsandbox::microsandbox_network re-export so we can
name DestinationGroup without a direct path dep.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two coordinated changes that together raise the per-sandbox virtio
device cap on x86_64 by ~10x:
1. Enable msb_krun's userspace split irqchip in the runtime
(vendor/microsandbox bump). Lifts the libkrun virtio-IRQ allocator
ceiling from IRQ 15 (KVM in-kernel IOAPIC, 24 pins) to IRQ 223
(userspace IOAPIC, 256 pins). Requires the wirenboard/libkrun fork
(pinned in Cargo.toml [patch.crates-io] at 38e46f2) with three
correctness fixes on top of upstream msb_krun 0.1.13.
2. Patch libkrunfw to bump x86 COMMAND_LINE_SIZE 2048 → 16384.
New `libkrunfw-overrides/cmdline-size_x86_64.patch` lifts the cap
so the assembled cmdline (~36 bytes per virtio_mmio device) doesn't
silently truncate past ~10 user mounts.
CI workflow hardening that landed alongside:
- Cache key now keyed per-arch (an aarch64 patch edit no longer
invalidates the x86_64 cache).
- `config-libkrunfw*` skip pattern uses a bare suffix so future
`config-libkrunfw-tdx_${ARCH}.patch` / `-sev_` variants are also
excluded from the kernel source-patch pipeline.
User-facing knobs (`--mount` doc in run.rs, README troubleshooting,
ARCHITECTURE/PLAN narrative) updated to reflect the new ceiling.
Two coordinated changes that together raise the per-sandbox virtio
device cap on x86_64 by ~10x:
1. **Enable msb_krun's userspace split irqchip** in the runtime
(vendor/microsandbox submodule bump). KVM's in-kernel IOAPIC is
hardcoded to 24 pins, of which libkrun's allocator hands out only
IRQs 5..15 — saturated by a typical agent-vm config (rootfs, upper,
runtime fs, network, vsock, console, balloon, rng) plus a couple of
`--mount`s, so an extra mount has historically tripped
`RegisterNetDevice(IrqsExhausted)` at boot. The userspace IOAPIC
exposes 256 pins, lifting the libkrun allocator ceiling to
IRQ_MAX_SPLIT = 223. Requires a libkrun fork (pinned in
Cargo.toml) with three correctness fixes on top of upstream 0.1.13;
without them, enabling split_irqchip crashes the VMM during boot or
silently truncates the kernel cmdline.
2. **Patch libkrunfw to bump x86 COMMAND_LINE_SIZE 2048 → 16384**.
Each virtio_mmio device adds ~36 bytes of cmdline; past ~10 user
mounts the assembled cmdline crosses the stock 2048 cap, the kernel
silently truncates the tail (which includes virtio-console), and
the guest hangs in early boot with `kernel.log` stuck at 0 bytes.
New `libkrunfw-overrides/cmdline-size_x86_64.patch` lifts the cap;
CI workflow picks it up via libkrunfw's `patches/0*.patch` glob
(the `0999-overrides-` prefix sorts strictly after libkrunfw's own
numbered patches).
CI workflow hardening that landed alongside:
- cache key now keyed per-arch via `*_${ARCH}.patch` glob (an
aarch64 patch edit no longer invalidates the x86_64 cache);
- `config-libkrunfw*` skip pattern uses a bare suffix so future
`config-libkrunfw-tdx_${ARCH}.patch` / `-sev_` variants are also
excluded from the kernel source-patch pipeline.
User-facing knobs (`--mount` doc in run.rs, README troubleshooting
guide, ARCHITECTURE/PLAN narrative) updated to reflect the new
practical ceiling.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Image gains dockerd/cli/containerd/runc/fuse-overlayfs/iptables;
kernel gains POSIX_MQUEUE + netfilter + nf_tables. See merge
commit 0d60870 for the cost/verification summary.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Rewrite crates/agent-vm/src/pull_progress.rs to render one progress bar
per layer instead of a two-phase aggregate, matching the upstream
PullProgressDisplay in vendor/microsandbox/crates/cli/lib/ui.rs.
While porting, fix seven defects flagged by code review:
- Per-layer cache-EROFS fast path (registry.rs:701, :753) emits
LayerMaterializeComplete with no prior Started — bar previously
stayed magenta "downloading" then jumped straight to ✓. Introduce
`materialize_styled: Vec<bool>` + ensure_materialize_style() and
call it from all four materialize-phase arms so the bar always
transitions through the correct style.
- Bounded try_send progress channel can drop LayerMaterializeStarted
under load; the same helper restyles on the next surviving
materialize event rather than leaving the bar in download style.
- `style("✓").green()` defaulted to for_stderr=false so the checkmark
was rendered against stdout's TTY state. Add .for_stderr().
- `render.await.ok()` silently swallowed JoinError. Add a small
`await_render` helper that logs panics, and refactor pull.rs /
setup.rs / run.rs to always await render before propagating pull
errors so display.finish() runs mp.clear() to restore the terminal.
- header.enable_steady_tick() fired even when the multi-progress
draw target was hidden (non-TTY). Guard with `if is_tty`.
- BRAILLE_TICKS had 11 entries; index 10 duplicated index 0,
producing a one-frame stutter every full spinner rotation. Drop the
trailing entry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
82d97fc fixed shell-passthrough so `agent-vm shell ls /tmp` runs
`bash -c 'ls /tmp'` instead of bash trying to exec ls as a script
(exit 126: "cannot execute binary file"). Bump for release.
Before: `agent-vm shell ls` invoked `bash -O histappend ls`. bash
treats the first non-option positional as a script filename and
PATH-searches for it, lands on `/usr/bin/ls`, hits the ELF magic,
and exits 126 with `/usr/bin/ls: cannot execute binary file`.
Now: when `Agent::Shell` has any trailing args, shell-escape and
join them into a single `-c` command. `agent-vm shell ls -la /tmp`
becomes `bash -O histappend -c "'ls' '-la' '/tmp'"`. No-arg
`agent-vm shell` keeps its interactive-bash path. Other agents
(claude/codex/opencode) are unchanged.
Cargo.lock: workspace agent-vm version bumped 0.1.4 -> 0.1.5 to
match Cargo.toml (stale lockfile entry refreshed on first build).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Before: `ProjectSession::sandbox_name` was just `agent-vm-<hash>` and
`Sandbox::builder(...)` was called with `.replace()`. Launching a
second `agent-vm` in the same dir would SIGTERM/SIGKILL the first
one's VMM (the second create's "replace" path) — the first attach
saw "agent stream closed (VMM/agentd disconnected)" and died.
Now:
- session.rs: sandbox name carries the launcher PID
(`agent-vm-<hash>-<pid>`) so two concurrent invocations boot
independent VMs. State dir is still hash-keyed, so per-project
bind-mounted state (claude/, codex/, opencode/, bash_history) is
still shared across launches.
- run.rs: drop `.replace()` on the builder (no name collision left to
override), and add `reap_stale_project_sandboxes` which scans
`~/.microsandbox/sandboxes/agent-vm-<hash>-*` before each launch
and `Sandbox::remove`s any entry whose PID-suffix is no longer in
/proc/. That replaces the implicit GC the old deterministic name
used to get from `.replace()`. It also pre-empts the new failure
mode where a stale entry happens to match our own PID (PID reuse)
and `Sandbox::create` would otherwise hard-error with "already
exists".
- secrets.rs: `secrets::refresh` is now serialized across concurrent
launchers in the same project via an exclusive flock on
`<state_dir>/.refresh.lock` (RAII guard). Several files under
state_dir (claude.json, claude/settings.json,
opencode-config/opencode.json) are read-modify-write — without the
lock, two simultaneous launches in the same project would both
read the same baseline and the later write would silently clobber
the earlier launch's mutation (lost project-trust flag, lost
onboarding state, etc.).
- run.rs: `Agent::Shell::default_args` now carries `-O histappend`
so two concurrent interactive shells *append* (not overwrite) the
shared bind-mounted `~/.bash_history` on exit.
Adds three unit tests: sandbox-name format encodes PID; refresh
lock blocks a second LOCK_EX while the first guard is alive;
existing session/secrets suites still pass (87/87).
Adds `libc = "0.2"` as a direct dep for `flock(2)` (already present
transitively).
The Node 20 → 24 cutover is coming (forced June 2 2026, removal
Sept 16 2026). Every action we use already has a Node-24
major released — we were pinned to stale ones, hence the deprecation
warnings in the prior runs:
actions/cache@v4 → @v5
actions/upload-artifact@v4 → @v7
actions/download-artifact@v5 → @v8
actions/github-script@v7 → @v9
docker/build-push-action@v6 → @v7
docker/login-action@v3 → @v4
docker/setup-buildx-action@v3 → @v4
The big leaps (artifact actions v4→v7, github-script v7→v9) are
because the maintainers cut MULTIPLE majors between Node 20 and
Node 24; we're just catching up.
Also enable Dependabot for github-actions on a weekly cadence so
we don't drift again. Submodule + Rust deps explicitly NOT
auto-updated — they need careful audits.
Bumps to v0.1.3 to exercise the upgraded actions end-to-end.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bumps the submodule pointer to fd1b31b, which is the tip of
`agent-vm-secret-file` after folding in two review-driven fix commits:
c5e3004 secrets/handler: fix state machine bypasses + cross-chunk
boundary + keep-alive reset
fd1b31b runtime: harden stderr tee lifecycle + idempotent exit-log
Closes 13 of the 15 findings the max-effort review produced on the
prior submodule tip (db98655) — including the four CONFIRMED-via-
probe state-machine bypasses that re-introduced the ECONNRESET
false-positive the original narrowing was meant to eliminate, the
keep-alive security regression where request 2's partial headers
skipped the violation scan entirely, and the stderr-tee deadlock vs
VMM thread when host stderr is wedged. Full rationale in the vendor
commits' messages.
Cargo.lock bump (0.1.0 → 0.1.2) absorbed from the previous
rewrite-microsandbox merge.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the build-from-source quickstart (Rust toolchain + docker +
local registry container + manual `images/build.sh`) with a single
`npm install -g @wirenboard/agent-vm` and a `ghcr.io/wirenboard/
agent-vm:latest` image pulled from CI.
## What ships via npm
`npm-dist/` scaffolds the established per-platform-subpackage
pattern (esbuild / ruff / biome):
- `@wirenboard/agent-vm` — JS launcher that detects
`${platform}-${arch}` at runtime and execs the right native binary
from the matching subpackage. `optionalDependencies` lists each
platform.
- `@wirenboard/agent-vm-linux-x64` — bundles a prebuilt `agent-vm`,
the patched `msb`, and `lib/libkrunfw.so.5.2.1`.
Smoke-tested locally via `npm link`: launcher resolves the subpackage
correctly and execs the bundled binary; `MSB_PATH=/usr/bin/cat
agent-vm pull` correctly rejects an unpatched msb with the
actionable "reinstall agent-vm" hint.
## msb discovery (msb_install.rs rewrite)
`point_at_workspace_msb` → `point_at_msb` with a fallback chain:
1. `MSB_PATH` env (explicit override).
2. `<exe-dir>/msb` (npm bundle layout — sibling of agent-vm).
3. `<workspace>/vendor/microsandbox/target/release/msb` (dev mode).
`msb --version` is verified to contain `+agent-vm` — refuses to
launch if a user's separate microsandbox install has somehow ended
up on our discovered path. Catches the bug where shadow installs
would silently lose SecretValue::File and the request-interceptor
hook, producing inscrutable in-VM failures instead of one clear
"reinstall" message.
## Image flow (separate cadence)
- Default image ref: `ghcr.io/wirenboard/agent-vm:latest`.
- `images/build.sh` and local-registry flow stay as the
source-checkout dev path (pass `--image localhost:5000/...`).
- `agent-vm setup` becomes "pull + verify"; no more docker/registry
dependency for end users.
- New `image_api_version` module reads `/etc/agent-vm-image-version`
inside the booted sandbox and requires it within
`MIN_SUPPORTED_IMAGE_API..=MAX_SUPPORTED_IMAGE_API` (both 1
initially). Out-of-range → actionable error pointing at the right
side (binary vs image) to update.
- Image registry hostname heuristic (`is_plain_http_registry`)
keeps `.insecure()` for localhost-style refs but drops it for
ghcr.io / docker.io / public registries that require HTTPS.
## CI workflows
- `.github/workflows/release-npm.yml` — on `v*.*.*` tag, cross-
compile binaries per platform (linux-x64 only initially; arm64 /
darwin commented out, ready to enable when microsandbox grows
those backends), bundle into subpackages, publish all packages
to npm with provenance. Subpackages publish before the main
package so optionalDependencies resolve.
- `.github/workflows/build-image.yml` — hourly cron + push-to-
images/ trigger. Builds Dockerfile, tags `latest` + timestamped
`YYYY-MM-DDTHH` + `sha-<sha>`, pushes to ghcr.io. Retention
policy prunes hourly tags older than 14 days.
## README
Quickstart leads with `npm install -g`. Source-build instructions
moved to a dedicated section. Image-cadence section explains the
hourly rebuild + pinnable date tags + image-API contract.
## Tests (84/84 pass)
- msb_install: 4 (version-marker accept/reject + env-override behaviour).
- pull: 2 (local-registry detection: localhost/127.0.0.1/0.0.0.0/
*.local/*.localhost vs ghcr.io/docker.io/example.com).
- image_api_version: 5 (parse trim, parse rejects garbage,
in-range accept, too-new with actionable hint, too-old with hint).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
msb's Interceptor handles one request per TLS/TCP connection. After
the first dispatch on a streaming rule the state goes to
State::Disabled and every subsequent chunk on the same connection
returns Verdict::Forward — which means the secret-substitution layer's
output (real GitHub token already swapped into the Authorization
header) flows STRAIGHT to upstream, bypassing the hook entirely.
Real-world impact: gh repo clone of a non-allow-listed private repo
SUCCEEDED. libcurl (git's HTTP backend) reuses TLS connections
aggressively. The first GET /info/refs request gets stripped by the
hook (no auth → 401 from GitHub), but libcurl's follow-up retry /
the POST /git-upload-pack request rides the same connection — the
hook is now Disabled, the real token reaches GitHub, clone proceeds.
The fix: inject `Connection: close` into every request the hook
emits, both Authenticated (allow-listed) and Anonymous (third-party)
paths. The server closes the TCP after responding; libcurl opens a
fresh connection for the next request; the fresh connection creates
a fresh Interceptor that re-evaluates the policy.
Also strips hop-by-hop headers (Connection / Keep-Alive /
Proxy-Connection) so we emit exactly one Connection: close header
regardless of what the guest sent.
Tests added:
- connection_close_injected_when_header_absent
- connection_close_replaces_existing_keep_alive
- connection_close_preserves_body_verbatim
- anonymous_passthrough_forces_connection_close (e2e: secrets →
hook simulation; real token must not reach wire AND Connection:
close must be present)
- authenticated_passthrough_forces_connection_close (e2e: same for
the allow-listed path — token allowed through, but connection
still torn down)
- keep_alive_second_request_bytes_contain_real_token_pre_hook
(hypothesis-confirmation test: documents the lower-level leak in
the secrets layer alone — the Connection: close fix is what makes
it unexploitable)
- private_repo_clone_does_not_leak_real_token_through_pipeline
(full end-to-end: build the exact bytes a git clone sends, run
through real SecretsHandler + real hook helpers, assert no token
in the wire bytes)
- allowlisted_repo_clone_does_pass_real_token_through_pipeline
(counterpart: token must reach upstream when the repo IS in the
allow-list)
agent-vm/Cargo.toml gains a dev-dep on microsandbox-network so the
e2e tests can drive the real SecretsHandler.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
OpenCode is the third v1 agent. Phase 4 covered Claude + Codex; this
finishes the auth/secret story.
**OpenCode auth.**
- `secrets::refresh_opencode` derives an OpenCode-shaped auth.json
from the host's `~/.codex/auth.json` (same OpenAI account, different
on-disk format). It writes `<state>/opencode/auth.json` with
`{openai:{type:oauth, refresh:<placeholder>, access:<synthetic JWT>,
expires:<far-future>, accountId:<from id_token>}}`.
- The `access` JWT is a *placeholder* — OpenCode parses it
client-side (only `chatgpt_account_id` and `exp`) and sends it as
`Authorization: Bearer <jwt>` on api.openai.com / chatgpt.com
requests. The proxy substitutes the placeholder JWT for the real
OpenAI access token (same on-disk file as Codex uses).
- `accountId` is pulled from the host Codex JWT's id_token payload via
`decode_id_token_account` (adds `base64 = "0.22"`).
- New constants: `OPENCODE_OPENAI_ACCESS_PLACEHOLDER` (a synthetic
alg-none JWT, deliberately minimal — see upstream issue #8 in PLAN
about long-placeholder boot failures) and
`OPENCODE_OPENAI_REFRESH_PLACEHOLDER`.
- `CredsState` grows `opencode_openai_access_token_file: Option<PathBuf>`
pointing at the same host-only file as `openai_token_file`. The
launcher uses this to register a third proxy-substitution entry
in `run.rs` keyed off the OpenCode placeholder.
- `host_paths::host_opencode_auth_path` (`$HOME/.local/share/opencode/
auth.json`) added for symmetry with the Claude/Codex helpers and
consumed by the snapshot below.
**Security snapshot.**
- `snapshot_host_creds()` SHA-256s the three host credential files at
launcher start. `verify_snapshot(&snap)` re-hashes after the sandbox
exits and prints a one-line warning naming each file that changed.
- The Phase 4 OAuth refresh hook *legitimately* rewrites these mid-
session; the warning surfaces anything else that mutates them — a
bug to investigate.
- Wired into `run.rs` after the `Sandbox::remove` call, non-fatal.
- `CredsState::snapshot: Option<HostCredsSnapshot>` carries the
pre-run state between the two calls.
Verified end-to-end on this host:
- `agent-vm opencode run "..."` reaches api.openai.com — the real
OpenAI returns model-level errors (auth was accepted), proving the
synthetic-JWT placeholder substitution works on the wire.
- `agent-vm claude -p "Say PHASE5_OK"` still returns a real response;
no false-positive snapshot warning.
- 6/6 cargo tests still green.
PLAN.md: new upstream issue #8 documenting the long-placeholder
sandbox-boot failure that constrained the JWT design choice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The big architectural payoff of microsandbox. Real OAuth access tokens
stay on the host; the in-VM agent only ever sees placeholder strings
in its credentials.json files, env vars, and outgoing request headers.
The substitution into the real token happens at the network layer in
microsandbox's TLS-intercept proxy.
Two-layer placeholder dance per provider:
1. agent-vm reads host ~/.claude/.credentials.json and
~/.codex/auth.json at every launch, extracts the access token, and
passes it to microsandbox as a SecretValue::Static(...) on the
sandbox builder with allow_host for the API + OAuth endpoints.
2. agent-vm writes a "placeholder credentials" JSON into the per-project
state dir using a stable placeholder string and the host file's
non-secret fields (expiresAt, scopes, account_id, etc.) so the
in-VM agent sees a plausible shape.
3. microsandbox's TLS intercept splices the real token in on the way
out — the guest never holds the real value.
What landed:
- vendor/microsandbox submodule bumped to agent-vm-secret-file branch
(commits b87d6bb + b95614c): adds SecretValue { Static, File } enum
with bare-string + NUL-sentinel wire format so existing prebuilt msb
daemons keep working with Static values. File variant is forward-
looking infrastructure for Phase 4.
- crates/agent-vm/src/secrets.rs: snapshots host creds, writes
placeholder credentials files atomically (0600), redacts in Debug.
- crates/agent-vm/src/run.rs: wires .network(|n| n.tls(...).secret(...))
per provider with the right allow-host lists, and sets IS_SANDBOX=1
so Claude Code's "don't run as root" guard yields.
- AGENT_VM_DEBUG_CONFIG=1 dumps the effective SandboxConfig JSON for
debugging.
- serde_json bumped to "preserve_order" so the dumped config is
diffable in a sensible order.
Smoke verified end-to-end up to the network layer (TLS cert chain
shows CN=microsandbox CA on api.anthropic.com requests, debug config
dump confirms the secret value reaches msb, the substitute path is
exercised). The final "Anthropic returns a real response" leg can't
be verified on this nested test host because of an outer credential
bridge; structurally equivalent to the Bash agent-vm's
credential-proxy flow.
Phase 4 will rebuild ~/.microsandbox/bin/msb from our fork to
activate SecretValue::File, add the inotify/proactive refresh loop,
and short-circuit the platform.claude.com/v1/oauth/token endpoint
so long sessions survive token rotation.
User feedback: re-checking the registry on every launch (PullPolicy::
Always) made fast launches feel surprising — when there *was* a new
image, the next innocuous `agent-vm shell` blew up into a multi-minute
pull. Move pulls onto an explicit `agent-vm pull` verb and just notify
on launch when the registry has something newer.
- pull.rs: new `agent-vm pull` subcommand. Image::remove(force=true)
the cached entry first (PullPolicy::Always empirically re-fetches
layer blobs but does NOT update the cached manifest digest record,
so subsequent Image::get returns the stale digest forever) then
boot a throwaway sandbox to trigger a fresh pull, then stop+remove.
- image_check.rs: cheap manifest probe. Compare microsandbox's cached
per-platform manifest digest against the registry's. Critical
subtlety: microsandbox stores the per-platform (linux/amd64) digest
while a HEAD on a tag returns the multi-arch *index* digest. A
`docker tag X Y; docker push Y` churns the index digest but reuses
the per-platform manifest, so naively comparing the two would warn
forever. We GET the index, find the linux/amd64 entry, take its
digest, compare that.
- run.rs: switch PullPolicy back to IfMissing, call
notify_if_update_available before create. Failures (registry down,
etc.) are silently swallowed so offline launches still work.
- setup.rs: chain an explicit pull_image() call after build.sh so
setup itself updates the cache.
- Cargo.toml: add reqwest + serde_json.
Verified end-to-end: (1) churn list digest only -> no banner.
(2) Real Dockerfile change -> "A newer image is available (cached X,
registry Y)" banner. (3) After `agent-vm pull` -> banner gone.
A cold-cache or new-tag launch downloads ~350 MiB of compressed layers
plus a 1-2 minute EROFS materialize step, and until now produced no
output between "Booting sandbox..." and the agent actually starting.
That looked indistinguishable from a hang for the first ~2 minutes.
Wire microsandbox's create_with_pull_progress into both the launcher
(run.rs) and the setup verify path (setup.rs). A small renderer
(pull_progress.rs) collapses microsandbox's per-layer event stream into:
- a manifest-resolve spinner
- one overall byte-progress bar (sized from Resolved.total_download_bytes)
- one layer-count bar for the materialize step
- a spinner for the stitching tail (fsmeta + VMDK writes)
Per-layer bars are deliberately omitted; the OCI image has 7+ small
layers and a wall of per-layer bars is louder than useful.
indicatif's default ProgressDrawTarget::stderr() correctly detects when
stderr isn't a TTY (CI, pipes, smoke tests under `sg`/`sudo -c`) and
silently no-ops there, so this never produces noisy output for scripts.
Verified rendering under a pty (script -qc) and silence under a pipe.
Without this the microsandbox stack is silent because no one initializes a
subscriber. Defaults to `warn` so normal output stays clean; bump via
`RUST_LOG=agent_vm=debug,microsandbox=info` (or whatever) to see boot/init
timing and any other tracing emissions from the SDK.
Add the launcher subcommands that boot a per-project microsandbox VM,
mount the working directory at /workspace, and attach to the requested
agent. Wiring is end-to-end verified via `agent-vm shell`; the live
Anthropic API smoke from PLAN.md's DoD is intentionally deferred to
Phase 3 (host-OAuth) — see ARCHITECTURE.md "What Phase 2 deliberately
doesn't do".
- session.rs: project hash (SHA256-12 of canonical cwd), state dir under
${AGENT_VM_STATE_DIR-${XDG_STATE_HOME-$HOME/.local/state}/agent-vm}/<hash>/,
deterministic sandbox name `agent-vm-<hash>` so re-invoking in the same
project replaces the prior VM via `.replace()`.
- run.rs: one bind for cwd → /workspace, one bind for state-dir →
/agent-vm-state, plus rootfs-patched symlinks for ~/.claude and
~/.local/share/opencode and CODEX_HOME=/agent-vm-state/codex (codex's
binary lives under /root/.codex/packages so a symlink would shadow it).
This keeps virtio device count under libkrun's IRQ cap.
- TTY-conditional dispatch: attach() when stdin is a real terminal,
exec_with(..., cwd=/workspace) otherwise so pipes/redirects/sg/sudo -c
smoke tests still work (attach requires /dev/tty).
- Credentials: ANTHROPIC_API_KEY / OPENAI_API_KEY forwarded via .env();
refreshable host-rooted secrets land in Phase 3.
- Knobs via env vars for now (AGENT_VM_IMAGE_TAG, AGENT_VM_MEMORY_MIB,
AGENT_VM_CPUS); promoting to clap flags is Phase 5 polish.
Docs updated together with the code per working-agreement #5: README
status list moved Phase 2 to done; PLAN.md notes the deferred live smoke;
ARCHITECTURE.md grew a Phase 2 section covering the mount cap, the
TTY/exec branch, the explicit PATH, and the deliberate env-var-only auth.
Add the minimal Debian 13 image with Claude Code, OpenCode, and Codex
preinstalled, and wire `agent-vm setup` to build/push/verify it via a host-
local Docker registry.
- images/Dockerfile: Debian 13 slim + node22 + ripgrep/fd/jq/git + the three
agent CLIs installed via their canonical installer scripts so we track
upstream release channels instead of pinning npm package versions.
- images/build.sh: ensure an `agent-vm-registry` (registry:2) container is
running on 127.0.0.1:5000, docker build, docker push.
- agent-vm setup: clap-based subcommand that shells to images/build.sh, then
boots `localhost:5000/agent-vm:latest` under microsandbox (insecure
registry) and runs the three `--version` commands. --no-verify and
--image/AGENT_VM_IMAGE_TAG escape hatches included.
- ARCHITECTURE.md: Phase 1 section with the registry-vs-Bind/qcow2 decision,
the deliberately minimal package list, and why the build step is shelled
out to Bash while verification stays in Rust.
End-to-end run: image builds, pushes to the local registry, sandbox boots
from it, and reports Claude 2.1.143 / OpenCode 1.15.3 / codex-cli 0.130.0.
Start a from-scratch rewrite of agent-vm on top of microsandbox. The original
Bash/Python tree is removed on this branch and continues to live on main until
the rewrite reaches v1.
This phase only sets up the build:
- PLAN.md and ARCHITECTURE.md document the phased roadmap and the
design-decision log; ARCHITECTURE grows after each phase.
- microsandbox added as a git submodule at vendor/microsandbox so the Phase 3
SecretValue::File extension can branch the fork in place.
- Cargo workspace at the root with crates/agent-vm/ as the binary crate.
- Hello-world main.rs that boots an alpine sandbox via the microsandbox SDK
to prove the wiring is correct end-to-end (cargo check passes).