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>
Active development happens on `rewrite-microsandbox`; `main` lags
behind. The hourly schedule fires only from the default branch
(GitHub Actions hard constraint), so until now the cron was
checking out `main`, building its stale Dockerfile, and pushing
that as `:latest` — clobbering the zstd-compressed image
published from rewrite-microsandbox.
Two changes here:
1. The workflow file itself is brought in line with rewrite-
microsandbox's version: zstd outputs, moving-tag gating
(allowing both main and rewrite-microsandbox), and the
`agent-vm-template` package name in the retain script.
2. On scheduled runs, `actions/checkout` is pointed at
`rewrite-microsandbox` explicitly. workflow_dispatch and push
events still build whatever ref triggered them. A new
`Resolve build SHA` step captures the actually-checked-out
commit so the `:sha-…` tag and the
`org.opencontainers.image.revision` label reflect what was
built, not main's stale tip.
Net effect: `:latest` stops oscillating between gzip (from main)
and zstd (from rewrite-microsandbox).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The retain script queries package `agent-vm`, but the GHCR package
was renamed to `agent-vm-template` in ca6a2a3. The script has been
returning HttpError 404 ("Package not found") on every build since,
so 14-day pruning has not actually run.
Switch to the real name so the cleanup actually fires.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Cuts the OCI→EROFS conversion on `agent-vm setup` by ~24× on
binary-heavy content (benched at 856 MB of /usr/lib: 143s → 5s).
- `images/build.sh`: docker build → buildx with
`--output type=registry,compression=zstd,compression-level=3,
force-compression=true`
- `.github/workflows/build-image.yml`: same flags via the
`outputs:` input of docker/build-push-action@v7, and gate the
moving `:latest` + `:YYYY-MM-DDTHH` tags to the integration
branches (`main`, `rewrite-microsandbox`) so feature-branch
workflow_dispatch can verify safely.
No microsandbox-side changes — `tar_ingest.rs:427` already
accepts the `application/vnd.oci.image.layer.v1.tar+zstd`
media type.
Verified end-to-end on the branch: build-image run #26417234380
pushed ghcr.io/.../agent-vm-template:sha-ce6b9ca with all 12
layers tagged `application/vnd.oci.image.layer.v1.tar+zstd`
(568 MB compressed).
The previous commit gated `:latest` and `:YYYY-MM-DDTHH` to `main`
only, but `rewrite-microsandbox` is the active integration branch
for the microsandbox-rewrite work (release-npm.yml:96 documents
that release dispatches happen from there). Restricting moving
tags to `main` alone means a dispatch on rewrite-microsandbox
produces only `:sha-…` — fine for ad-hoc verification, wrong for
the canonical image build that the binary release pins to.
Allow both branches.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds out-of-the-box nested KVM support (Docker-in-Docker, qemu inside
the guest) and reroutes msb's writable state off ~/.microsandbox/ into
$XDG_STATE_HOME/agent-vm/msb-home/. See 6b2fdea for the full commit
message.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mirror the gzip→zstd compression change from images/build.sh into
the GitHub Actions image build, so the published
ghcr.io/.../agent-vm-template image carries zstd layers that
microsandbox can ingest ~24× faster on a cold `agent-vm pull`.
`docker/build-push-action@v7` doesn't expose compression flags
directly, but it forwards `outputs:` straight to buildx. So:
outputs: type=registry,push=true,compression=zstd,
compression-level=3,force-compression=true
replaces the previous `push: true`. `force-compression=true` is
the important bit — without it, only the layers we add get
zstd-compressed and the base-image layers stay gzip.
Also gate `:latest` and `:YYYY-MM-DDTHH` (the moving tags) to
pushes on `main`. The immutable `:sha-<sha>` is still pushed
unconditionally. This lets `workflow_dispatch` on a feature
branch produce a verifiable image (inspect the `:sha-…` manifest)
without overwriting whatever's currently on `:latest`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two coupled changes to make nested KVM (Docker-in-Docker, qemu, etc.)
work inside the agent-vm guest without manual setup:
1. CI rebuilds libkrunfw with CONFIG_KVM=y instead of pulling the
upstream release. Upstream's config-libkrunfw_x86_64 has
`# CONFIG_KVM is not set` — the guest kernel ships only the
paravirt-guest helpers and /dev/kvm never appears. We clone
libkrunfw at LIBKRUNFW_VERSION, apply
libkrunfw-overrides/config-libkrunfw_x86_64.patch (enable KVM,
KVM_INTEL, KVM_AMD; olddefconfig pulls in the rest), build, and
bundle the resulting .so into the per-platform npm subpackage.
Result cached on (version, arch, patch-hash, workflow-hash) so the
+5min build only runs on a real change. CI sanity-checks
`nm vmlinux | grep kvm_dev_ioctl` so a silent patch failure becomes
a CI error instead of a runtime mystery.
2. agent-vm pins MSB_HOME to its own state dir at startup so msb's
writable state (db, cache, sandboxes, logs, tls/CA) lives under
$XDG_STATE_HOME/agent-vm/msb-home/ instead of ~/.microsandbox/.
The bundled `msb` + `libkrunfw` are read-only and discovered via
MSB_PATH + msb's existing "sibling-of-msb / ../lib/" resolution,
so no copy or sync into MSB_HOME is needed. Side-effect: a user's
stale upstream libkrunfw.so.X.Y.Z at ~/.microsandbox/lib/ can
never shadow ours.
Drops the `ensure_runtime_installed` / `microsandbox::setup::install`
download path entirely — the npm bundle is the source of truth.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`agent-vm setup` calls `images/build.sh`, which built and pushed via
the legacy `docker build` + `docker push` path — leaving every layer
gzip-compressed. The OCI→EROFS conversion on the user's first launch
then spends ~95% of its CPU time in single-threaded gzip decode.
Bench against /usr/lib (856 MB) — see crates/agent-vm/examples/
erofs_bench.rs:
gzip layers: ingest = 143.08s (6.0 MB/s effective uncompressed)
zstd layers: ingest = 5.01s (171.0 MB/s effective uncompressed)
≈24× faster end-to-end pull pipeline with no microsandbox changes —
`tar_ingest.rs:427` already accepts `application/vnd.oci.image.layer
.v1.tar+zstd` automatically.
Switch the script to `docker buildx build --output type=registry,
compression=zstd,compression-level=3,force-compression=true,push=
true`:
- `--output type=registry,push=true` replaces the build + separate
push, because buildx with `--push` cannot also carry `--output`
attributes, but `type=registry,push=true` is equivalent and lets
us attach the compression options to the same step.
- `force-compression=true` re-emits base-image layers as zstd too;
without it, only the layers we add get re-compressed and the rest
stay gzip, halving the win.
- `compression-level=3` is zstd's default — bench shows diminishing
returns past that for binary-heavy layers.
- `registry.insecure=true` is needed because we push to a loopback
HTTP registry; `--push`-via-buildx doesn't inherit the daemon's
`insecure-registries` config.
Adds preflight checks: bail with a clear message if `docker` or
`docker buildx` aren't present (modern Docker bundles buildx; older
installs need an upgrade).
Co-Authored-By: Claude Opus 4.7 <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>
This reverts commit 90376ec.
git-restore-mtime ran cleanly but cargo still recompiled every
path-dep workspace crate on the next warm run. rust-cache's own
README documents the limitation: cargo's fingerprint check for
path-dep crates forces a rebuild regardless of mtime — likely
dep-info absolute-path canonicalization or build-script reruns.
The remaining wins (parallel jobs, relaxed LTO on msb,
Swatinem/rust-cache with workspace crates + save-if=true) still
get us 11m31s -> ~3m wall-clock cold and don't get hurt by warm
also being ~3m.
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).
actions/checkout sets every source file's mtime to "now". cargo's
fingerprint fast-path compares source mtime to .fingerprint
timestamps in target/; with the rust-cache-restored target/, sources
look newer than fingerprints and cargo recompiles every path-dep
crate + the final binary. ~90s/job wasted.
git-restore-mtime (from MestreLion/git-tools) walks git log and sets
each file's mtime to its last-touching commit date, so fingerprints
match between runs.
Also bumps `fetch-depth: 0` on both checkouts so git-restore-mtime
has the full history it needs (default depth=1 only sees one commit).
rust-cache@v2 defaults to cache-workspace-crates=false. It strips
target/release/{deps,build}/* entries belonging to path = "..."
crates before saving, so a warm-cache run still recompiles the
9 vendor/microsandbox workspace crates (~28s) and re-links the
agent-vm binary under LTO (~70s).
Set cache-all-crates and cache-workspace-crates to true so the
full target/ is preserved. Trades ~600 MB of cache space per
job for actually-warm builds.
rust-cache@v2 defaults to save-if: github.ref == default_branch.
Releases fire from v*.*.* tags or workflow_dispatch on
rewrite-microsandbox; neither is main, so the cache never
persisted and every run was effectively cold. set save-if: true
so subsequent runs can restore.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three speedups stacked, expected to roughly halve wall time
(~10–11 min cold / ~5–7 warm → ~5 cold / ~2 warm):
1. **Split into parallel jobs.** `build-platform` was building
agent-vm + msb sequentially on one runner; now `build-agent-vm`
and `build-msb` are independent matrix jobs that run on
separate runners. A new `package` job downloads both artifacts,
fetches libkrunfw inline (~10s curl, not worth its own job),
and assembles the per-platform npm subpackage.
2. **Swatinem/rust-cache@v2** replaces our hand-rolled
actions/cache step. Independent caches per build job (workspace
root vs vendor/microsandbox), keyed automatically on rustc
version + Cargo.lock + a salt — drops the manual rustc-hash
step we wired in earlier this session. msb job also adds
`env-vars: CARGO_PROFILE_RELEASE_LTO ...` so a relax-LTO flip
never reuses a fat-LTO build's stale rlibs.
3. **Relax msb LTO in CI.** vendor/microsandbox's release profile
is `lto=true, codegen-units=1` (~3–4 min single-threaded link).
For a TLS proxy the runtime perf gain is negligible — override
via env at the job level:
CARGO_PROFILE_RELEASE_LTO=thin
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16
Keep panic=abort and strip=true (those change behaviour /
reduce size, worth keeping).
`publish` now depends on `package` instead of `build-platform`.
The final artifact path is unchanged (`agent-vm-${platform}/{bin,lib}`)
so the publish steps need no changes.
v0.1.5 cuts a release to exercise the new layout end-to-end.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restores the MCP relay endpoint to the Anthropic placeholder's
allowed_hosts list. Previously added in dbe9210 and inadvertently
reverted by 31b24cc's stale-working-tree commit-a; not picked back
up by the subsequent rename / version-bump commits.
Without it, Claude Code's MCP traffic trips the secrets-handler
violation scan (the Anthropic placeholder appearing on a host
that's not in this secret's allow_host list) and the conn drops —
breaking MCP entirely for the in-VM agent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Picks up `de9a1b2` which adds the missing doc comment that was
tripping the crate-level `#![warn(missing_docs)]` and printing a
warning during every release build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The name `agent-vm` was overloaded: it's both the runtime tool
(Rust binary on npm) AND the OCI image the tool boots inside each
microVM. Users seeing `ghcr.io/wirenboard/agent-vm:latest` would
reasonably guess that's where the *runtime* lives, but it's
actually the guest rootfs.
Rename the ghcr package to `agent-vm-template` and add OCI labels
(`org.opencontainers.image.title` / `description`) that explicitly
say "guest template image — install the runtime via npm". The
description shows on the ghcr.io package page.
Also rename the local-registry tag (`localhost:5000/agent-vm-template:latest`)
for consistency with the dev workflow.
Touches:
- DEFAULT_IMAGE_REF and every help/doctring referencing the ghcr
image (setup.rs, run.rs, pull.rs, image_api_version.rs,
image_check.rs).
- images/Dockerfile — new header + OCI labels.
- images/build.sh — local registry tag.
- .github/workflows/build-image.yml — IMAGE env.
- README.md + npm-dist/README.md.
- image_check tests updated to assert the new name.
Bumps to v0.1.4 so npm + ghcr converge on the new name.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Workflow log shows v0.1.1 published cleanly with sigstore-signed
provenance + valid integrity, but the actual
agent-vm-linux-x64-0.1.1.tgz blob 404s on origin (Cloudflare
forwarded). 25 minutes post-publish, still missing. Likely an
npm-side upload glitch on the 36MB tarball. Re-publishing as a
fresh version is simpler than waiting on npm support.
v0.1.0: binaries lack execute bit. Deprecated.
v0.1.1: subpackage tarball missing on origin. Deprecated.
v0.1.2: ↑ this should be the first usable version.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v0.1.0 was published but the binaries inside the linux-x64
subpackage tarball landed at 0644 (no execute) — actions/
upload-artifact@v4 zips up the files without preserving POSIX
permissions, and npm publishes the round-tripped result as-is.
Users on v0.1.0 hit EACCES from the launcher's spawnSync.
Fix: add an explicit `chmod +x` step in the publish job after the
artifact download. Bump to v0.1.1 since npm versions are immutable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Node's `require()` only treats a path as relative when prefixed with
`./`. Bare `npm-dist/...` is interpreted as a node_modules /
built-in module spec and throws 'Cannot find module'. The
version-rewrite step above already used `fs.readFileSync` (which IS
cwd-relative) so it worked; this step used `require` and didn't.
Add the `./` prefix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same change as rewrite-microsandbox@643b249. Keep main's
workflow file in sync so GH Actions has a current version on the
default branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
actions/download-artifact@v5 with `pattern:` + a single matched
artifact does NOT wrap into a per-artifact subdir (behaviour change
from v4) — files land flat at `path:`. With one platform that meant
bin/ + lib/ ended up at npm-dist/{bin,lib} instead of
npm-dist/agent-vm-linux-x64/{bin,lib}, and the verify step
short-circuited the whole publish.
Switch to one download step per platform with explicit `name:` +
`path:`. Robust to v5's single-vs-multiple-artifact behaviour, and
forces an explicit step per added platform (a soft reminder when
extending the matrix).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Land the workflow files on main so GitHub indexes them. The actual
release work lives on rewrite-microsandbox; main carries only the
CI plumbing so tag pushes / hourly crons / workflow_dispatch all
find a registered workflow to fire.
- release-npm.yml: fires on v*.*.* tag push (or manual dispatch).
- build-image.yml: hourly cron + on push to images/** (only fires
here once images/ exists on main; until then, only manual
dispatch from a feature branch).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The four recent chrome-MCP commits (f40e6df / ce745ec / 43203fe and
this one's predecessor) left three documents describing an obsolete
shape:
- **PLAN.md Phase 7** still showed the original naïve MCP entry
(`command: "npx"`, args `[..., "chrome-devtools-mcp@latest", ...]`)
and a sketchy "in the rewrite" bullet list. Replaced with what
actually shipped: the two failure modes (sandbox-as-root + MITM CA
not in chrome's NSS DB), the three layers of fix (image:
chrome-user/NSS DB/sudo/wrapper/pre-warm; launcher: certutil
prelude with on-failure warning; secrets: pinned `@1.0.1` MCP
entry under the wrapper), and the AGENT_VM_NO_CHROME_MCP opt-out
semantics (gates both halves, cleans up empty mcpServers).
- **ARCHITECTURE.md "Image content"** still listed Chromium and
Chrome DevTools MCP as "explicitly skipped in v1". Moved them
into the in-scope list with a paragraph noting the chrome-user /
NSS DB / wrapper / pre-warm setup. Also added gh (Phase 6, was
missing) and rewrote the misleading image-size figures (chromium
blew past the prior ~1.5 GB estimate).
- **README.md** had no mention of chrome MCP, AGENT_VM_NO_CHROME_MCP,
or any of the env-var family (RUST_LOG / AGENT_VM_PROFILE /
AGENT_VM_DEBUG_CONFIG). Added a small env-var table and a "Chrome
DevTools MCP" section explaining the wrapper / NSS-DB design and
the opt-out — enough that a user doesn't have to grep the source
to understand what the chrome user in their VM is doing.
No code changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure refactor. The same predicate was evaluated twice — once before
constructing the tokio runtime (to gate `point_at_msb` and thus the
unsafe set_var) and once inside the async block (to gate
`ensure_runtime_installed`). Same answer both times since `cli` is
not mutated. Capture the bool once.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Folds in 13 findings from the second-pass max-effort review of
ce745ec. (The 14th — "var_os().is_some() opt-out semantics flipped" —
is actually a false positive: both old and new code treat
AGENT_VM_NO_CHROME_MCP=anything as opt-out. The unconventional
"empty value also opts out" behavior is consistent with
AGENT_VM_PROFILE / AGENT_VM_DEBUG_CONFIG elsewhere; documented
inline so the next reviewer doesn't re-flag it.)
**run.rs**
- `sandbox_log_dir()` replaces `runtime_log_path()`. Returns the log
*directory* (containing `runtime.log` + `kernel.log` +
`boot-error.json` — verified all three are written by vendor's
`vm.rs::setup_kernel_log` and `boot_error.rs`). The previous helper
pointed at `runtime.log` only, which is zero bytes for the most
common boot-stage failures where the cause sits in kernel.log or
boot-error.json instead. The doc comment now lists all three files
and acknowledges that the helper deliberately diverges from
upstream's `microsandbox_utils::resolve_home()` in one place: when
`MSB_HOME` and `HOME` are both unset, we canonicalize the relative
`./.microsandbox` fallback against the current working directory
so the hint string is absolute (upstream's fallback ships into a
subprocess where CWD is more controlled; embedding `.`-prefixed
paths into launcher-side error messages is confusing).
- Error-context wording changed from `(see <path>/runtime.log for the
in-VM tail)` to `(full logs: <dir>)`. Vendor's `Sandbox::create`
already inlines the runtime.log tail into BootError messages via
`tail_runtime_log`; the prior wording duplicated the suggestion
("look at the file you just saw the tail of"). The new wording
positions the directory pointer as the *full* log location, useful
in both the boot-failure case (vendor already showed you a tail)
and the post-boot attach failure case (vendor didn't).
- Certutil prelude no longer silently swallows failures. Sudo /
certutil stderr is captured to a per-invocation tempfile; on
non-zero exit the launcher prints a 4-line warning that names the
user-visible symptom (`HTTPS in chrome-devtools MCP will fail with
ERR_CERT_AUTHORITY_INVALID`) and tail-prefixes the stderr so the
user can see the actual sudoers / NSS error. Without this, every
failure mode (dropped sudoers, world-writable sudoers, locked NSS
DB, missing chrome user, unreadable CA file) was invisible from
the launcher — chrome MCP boots normally, then every navigate
silently fails with an opaque CDP error.
- AGENT_VM_NO_CHROME_MCP opt-out semantics commented inline at the
point where the env-var is consulted, so the "set with any value
including empty = opt out" rule (consistent with the rest of the
agent-vm env-var family) doesn't re-trip future reviewers.
**secrets.rs**
- `OPENCODE_OPENAI_ACCESS_PLACEHOLDER` JWT signature length fixed:
was `msb-opencode-placeholder-a-v2` (29 chars, `len % 4 == 1`
which is structurally impossible for unpadded base64url and would
trip any strict JWT parser); now `msb-opencode-placeholder-av2`
(28 chars, `% 4 == 0`, valid). Sibling `OPENAI_ID_PLACEHOLDER` sig
is already 28 chars — the rename in the previous commit produced a
29-char OPENCODE sig by accident. `placeholders_are_pairwise_distinct`
test continues to pass.
- Always-own-the-mcpServers-key block now tolerates a pre-existing
`mcpServers: null` (resets to `{}` rather than bailing) and cleans
up an empty `mcpServers: {}` left over after opt-out. With this,
`AGENT_VM_NO_CHROME_MCP=1` from a fresh state dir produces a
`claude.json` with the `mcpServers` key absent (the same shape it
had pre-Phase-7), rather than the empty placeholder map that
surprised users opting out from day one.
- Pinned `chrome-devtools-mcp@1.0.1` in the MCP config (was
`@latest`). The image's pre-warm step now also pins the same
version; without coupled pins, `npx -y @latest` re-resolves
against the registry on every launch and invalidates the pre-warm
cache as soon as upstream cuts a release. Bump both together when
you want to track a newer release.
**Dockerfile**
- Wrapper script: replace `cd /home/chrome` with `cd /home/chrome
2>/dev/null || { echo "...image misconfigured?" >&2; exit 1; }`.
Without this, a missing /home/chrome (derivative image strips it,
runtime mount fails) silently exits the wrapper from claude's MCP
transport perspective with no actionable breadcrumb.
- Wrapper script doc: drop the false claim that agentd boot-sets
`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` (it doesn't — vendor
agentd/lib/tls.rs sets only the CA env vars). The proxy env vars
remain in the preserve list because user MCP `env:` blocks or
`.agent-vm.runtime.sh` may set them, but the *reason* is
documented honestly.
- Wrapper script doc: explicitly note that USER and LOGNAME are NOT
in the preserve list, because sudo's default `env_reset` policy
re-derives them from the target passwd entry; adding them would
forward root's USER into the chrome session.
- Pre-warm RUN now `chrome-devtools-mcp@1.0.1 ... 2>&1 || echo
"==> pre-warm skipped"`. Was an unguarded `npx -y @latest --help
>/dev/null` that hard-failed the entire `agent-vm setup` on any
npm registry blip — the opposite of its stated intent of
insulating launches from registry availability.
Verified e2e in a fresh sandbox:
- pre-warm cache at /home/chrome/.npm/_npx/... is hit by the runtime
npx invocation (same hash for matching @1.0.1 pin); reports
app-version=1.0.1
- chromium runs as chrome (UID 9999) with its sandbox active; NSS
DB trusts only the microsandbox CA with attributes `C,,`
- navigate_page https://example.com + take_snapshot return real
"Example Domain" content
- AGENT_VM_NO_CHROME_MCP=1: jq 'has("mcpServers")' returns false
(key entirely absent, not empty {})
- 73 cargo unit tests pass (placeholders_are_pairwise_distinct
green with the new 28-char OPENCODE sig)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Top fixes from the max-effort review:
**Correctness:**
1. setup.rs::verify_image now calls image_api_version::check() and
runs each agent --version separately. Old code did
`cat ... && claude --version && ...` so a non-zero exit could
mean any of four causes; error message blamed only the API file.
New code names which check failed.
2. main.rs runs `point_at_msb` BEFORE constructing the tokio
runtime. setenv() is not thread-safe; the previous code ran
`unsafe { set_var }` after `#[tokio::main]` had already spawned
workers, technically UB if a worker read env concurrently
(reqwest / sea-orm do this on first use). Switched to plain
`fn main` + manual Runtime::new() so the env mutation happens
single-threaded.
3. release-npm.yml cache key now includes a hash of `rustc -V`.
Without this, an ubuntu-latest rustc rollover (we hit this
exact bug locally this session) reused cached rlibs from the
prior toolchain → undefined-symbol link errors.
4. release-npm.yml's subpackage publish loop is now idempotent:
skips already-published versions instead of erroring. Avoids
the split-state where a transient failure leaves subpackages
at v but the main package never published.
5. build-image.yml's retention switched from
`actions/delete-package-versions@v5`'s `min-versions-to-keep:
350` (which breaks if cron is paused for days) to an explicit
age-based prune via actions/github-script — deletes versions
older than 14 days regardless of build cadence.
**UX:**
6. pull.rs adds `AGENT_VM_INSECURE_REGISTRY=1` env-var escape hatch
so airgapped/intranet plain-HTTP registries
(`registry.corp.example:5000`) can opt in. The hostname
heuristic stays narrow (localhost/127.0.0.1/0.0.0.0/*.local/
*.localhost) for safety.
7. msb_install.rs: MSB_PATH set-but-file-missing now falls back to
the sibling-of-current_exe() if one exists (with a warn) and
gives a much clearer error otherwise. The vanilla-msb rejection
message now detects MSB_PATH presence and tells the user to
unset it instead of generically suggesting "set MSB_PATH
explicitly" (the LAST thing they need to hear when they just set
it).
8. setup.rs warns when running from a source checkout (manifest_dir
has images/Dockerfile) but the submodule isn't initialised —
instead of silently pulling ghcr.io and confusing the dev who
just edited images/Dockerfile.
9. npm-dist/agent-vm/bin/agent-vm.js' missing-optional error lists
network-flake as the top cause (it is) plus --no-optional and
inconsistent lockfile pinning. Previously blamed --no-optional
as the most likely cause, which it usually isn't.
10. README files for both npm packages so npmjs.com renders
something useful.
11. release-npm.yml's libkrunfw symlink step replaced the
`( cd && ln && ln )` subshell with absolute-path `ln -sf`s.
Subshell `set -e` propagation is bash-version-sensitive; the
new form is unconditional.
84/84 → 85/85 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Long Claude Code sessions in agent-vm (the failure mode users see
as "every 10 seconds, Retrying in 17s · attempt 6/10" until the
session is dead) were hitting a false positive in the secret-
substitution layer. When a session's prompt body contained the
literal name of any non-Anthropic placeholder string (e.g.
investigation sessions reading other sessions' jsonl), the scan
flagged the request, dropped the conn mid-upload, and undici
reported it as ECONNRESET. Retries hit the same content, same
violation; the session became permanently wedged from that turn on.
This commit pairs with the vendor change `secrets/handler: scope
violation scan to header bytes only`, which restricts the scan to
credential-bearing positions (Authorization / X-*-Key headers, URL
query params on the request line) and leaves user-supplied body
content alone. See the bumped submodule for full rationale.
Local changes:
* **secrets.rs**: add ANTHROPIC_MCP_PROXY_HOST. Claude Code's MCP
relay endpoint sends the same Anthropic access token but wasn't
in the placeholder's allowed_hosts list, so every MCP request
tripped the violation scan (also surfaced as ECONNRESET to the
MCP client). Allowing this host fixes MCP for in-VM Claude.
* **run.rs**: thread that new host into the network builder, and
add a focused post-mortem hint when `attach()` returns an error.
The hint points at the per-sandbox log files
(`~/.microsandbox/sandboxes/<name>/logs/{runtime.log,
msb.stderr.log,msb-exit.log}`) and suggests `dmesg -T | tail`
for host-OOM-kill cases. Surfaces what the vendor's `runtime:
tee msb stderr` change makes available.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Folds in fourteen findings from the code review of the previous two
commits. Most are 'the commit advertised something it didn't actually
deliver' — straightforward to make honest.
**Observability commit was a no-op — revert it.**
- `main.rs` filter went back to the simple `warn` default. The previous
`microsandbox_network::proxy=info,microsandbox_network::tls::proxy=info`
targeted modules that (a) only emit `tracing::debug!` (so info
filtered them out) and (b) run in the `msb sandbox` subprocess whose
own tracing init reads no env at all. Net behaviour: zero proxy
logs surfaced; the doc-comment claim was wishful thinking.
- `run.rs` post-mortem hint trimmed to one accurate line. Dropped the
pointers to `msb.stderr.log` and `msb-exit.log` (vendor `spawn.rs`
inherits stderr instead of teeing; `handle.rs::wait` writes no exit
log). What remains: a single `(see <runtime.log> for the in-VM tail)`
appended via `with_context`. Avoids the duplicated error output the
old code produced — anyhow's main termination prints the chain once
rather than the previous "eprintln before return then re-print on
exit" combo.
- `runtime_log_path()` replaces `msb_sandbox_log_dir`; comment no
longer claims to "mirror" upstream, and the final fallback matches
`microsandbox_utils::resolve_home()` (`./.microsandbox`) rather than
the previous incorrect `/var/lib/microsandbox`. Hint now also fires
in the non-TTY branch's `with_context` and `anyhow::bail!` paths,
not just TTY — CI users are the ones most likely to need the
pointer.
**Chrome MCP cleanups.**
- `certutil` trust string `-t TC` → `-t C,,`. The previous form set
the SSL column to `TC` (T = trusted issuer of *client* certs, C =
trusted issuer of server certs). We only want server-cert trust;
the T silently broadened scope. `C,,` is the textbook idiom.
- Wrapper's `--preserve-env` allow-list expanded to forward the env
vars chrome-devtools-mcp itself documents
(`CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS`, `CI`, `DEBUG`), agentd-
set proxy config (`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` plus
lowercase variants), and basic locale (`TZ`, `LANG`, `LC_ALL`,
`TMPDIR`). Without these, user MCP-config `env:` blocks and
documented opt-outs were silently stripped by sudo's `env_reset`.
- Wrapper now `cd /home/chrome` before exec'ing. Inherited cwd was
`/workspace` (the project bind-mount), which the chrome user (UID
9999) cannot write — any MCP tool emitting a relative path
(`./trace.json`, `./screenshot.png`, `--logFile=./debug.log`)
EACCES'd silently. `/home/chrome` is chrome-owned and writable.
- Pre-warm `chrome-devtools-mcp@latest` into chrome's npm cache at
image build time (`/home/chrome/.npm/_npx/...`). Previously every
first MCP call paid a multi-second `npx -y` fetch and broke
entirely when registry.npmjs.org was unreachable. The image gains
~21 MiB; cold launches drop the fetch.
- Bash prelude's certutil block is now gated on `AGENT_VM_NO_CHROME_MCP`
not being set. Previously the env-var opt-out only suppressed the
MCP entry in `claude.json`; the sudo→chrome→certutil round-trip
ran on every launch regardless, exposing the opt-out user to the
exact sudoers/NSS failure modes they were trying to avoid.
**Sticky opt-out fixed.** `write_default_claude_root_state` now
always owns the `chrome-devtools` key: `AGENT_VM_NO_CHROME_MCP=1`
explicitly `mcp.remove("chrome-devtools")`s a previously-written
entry instead of leaving it sticky in the on-disk merged JSON.
**OPENCODE placeholder rename completed.** `OPENCODE_OPENAI_ACCESS_PLACEHOLDER`
JWT signature is now `msb-opencode-placeholder-a-v2` (was
`MSB_OPENCODE_v1`), matching the rename rationale spelled out on
`OPENAI_ID_PLACEHOLDER`: non-token-shaped sentinels so Anthropic
doesn't flag transcripts that contain them.
**Stale literals swept.** `ARCHITECTURE.md` reads the new names at
all five reference sites. Six tests in `intercept_hook.rs` that
hardcoded `MSB_PLACEHOLDER_GH_TOKEN_v1[_xxxxxxxx]` as a standalone
fixture now use `secrets::GH_TOKEN_PLACEHOLDER` directly — so a
future placeholder shape change is exercised by the existing
regression assertions instead of slipping past them.
End-to-end verified inside a fresh sandbox:
- `claude mcp list` → ✓ Connected
- `navigate_page` https://example.com + `take_snapshot` returns the
real "Example Domain" content
- NSS DB lists only the microsandbox CA with trust `C,,` (not `CT,,`)
- `chrome-devtools-mcp` package present in /home/chrome/.npm/_npx/
out of the box (no per-launch fetch)
- `AGENT_VM_NO_CHROME_MCP=1`: claude.json mcpServers is `{}` (entry
removed, not just absent on new files); NSS DB is empty (certutil
prelude skipped)
- Wrapper preserves CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS, CI,
DEBUG, HTTP_PROXY, TZ, NODE_EXTRA_CA_CERTS across the sudo hop
- 73 cargo unit tests pass (incl. the intercept_hook tests now using
the constant)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Eliminates the runtime dependency on superradcompany/microsandbox's
release tarball — we already vendor libkrunfw as a sub-submodule,
so building it ourselves is straightforward and removes:
- a foot-gun if upstream prunes / renames a release asset,
- the inability to patch libkrunfw if we ever need to,
- an asymmetry where we own the patched msb but borrow the .so.
Trade-off: a kernel build is ~10-15 min cold on the GH runner. Cache
keyed on the libkrunfw sub-submodule SHA makes that a one-time cost
per submodule bump — every subsequent release-npm run is a ~5 s
restore.
Steps:
1. Read `LIBKRUNFW_VERSION` / `LIBKRUNFW_ABI` from `vendor/
microsandbox/crates/utils/lib/lib.rs` (single source of truth).
2. Restore `vendor/microsandbox/vendor/libkrunfw/libkrunfw.so.<ver>`
from cache if present.
3. On miss: install kernel build deps (bc/bison/flex/libelf-dev/
libssl-dev/kmod/cpio/python3-pyelftools/xz-utils) and run
`make -j$(nproc)` in the libkrunfw tree. Cross-compile for arm64
when matrix target ≠ host.
4. Copy the .so + recreate `libkrunfw.so.<ABI>` and `libkrunfw.so`
symlinks into `npm-dist/agent-vm-$platform/lib/`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the placeholder libkrunfw step with a concrete fetch of
the upstream microsandbox release tarball at:
https://github.com/superradcompany/microsandbox/releases/download/v$MSB_VERSION/microsandbox-$os-$arch.tar.gz
(public release; verified 17 MiB, contains msb + libkrunfw.so.5.2.1).
The tarball's msb is discarded — we ship our own patched build — and
just libkrunfw* lands in `npm-dist/agent-vm-$platform/lib/`.
Versions come from the vendored source of truth (no duplication):
- `msb_version` ← `vendor/microsandbox/Cargo.toml` (workspace root,
since the cli crate uses `version.workspace = true`).
- `libkrunfw_version` / `libkrunfw_abi` ← `vendor/microsandbox/
crates/utils/lib/lib.rs` constants.
After extraction the workflow recreates the ABI symlinks that the
SDK's `setup::install` would normally make
(`libkrunfw.so.<ABI> -> libkrunfw.so.<VERSION>`,
`libkrunfw.so -> libkrunfw.so.<ABI>`), mirroring
`vendor/microsandbox/crates/microsandbox/lib/setup/download.rs::libkrunfw_symlinks`.
Smoke-tested locally: `curl -fsSL` follows GitHub's S3 redirect,
tarball extracts cleanly, glob finds libkrunfw.so.5.2.1 at the top
level (no nested prefix).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>