Compare commits

...

85 commits

Author SHA1 Message Date
Evgeny Boger
05e8969215 v0.1.25: route guest egress through host HTTP proxy (CONNECT)
Some checks failed
CI / build-and-test (push) Has been cancelled
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>
2026-06-27 18:32:16 +03:00
Evgeny Boger
920bff82d7
Merge pull request #19 from wirenboard/host-proxy-support-v2
Honour host HTTP proxy for guest egress (CONNECT) + verbose startup logging
2026-06-27 17:31:17 +02:00
Evgeny Boger
885aa026d1 vendor: point submodule at merged wb (microsandbox#4)
Update the gitlink from the PR branch head to the wb merge commit
d6b9b11 now that microsandbox#4 is merged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 18:26:25 +03:00
Evgeny Boger
958af6ddc7 proxy: honour host HTTP proxy for guest egress + log it at startup
Behind a corporate/host HTTP proxy, agent-vm could pull its image (the
host-side reqwest clients auto-detect HTTPS_PROXY/HTTP_PROXY) but the
agent *inside* the VM could not reach the internet: the microsandbox
network stack re-originated guest connections directly, bypassing the
proxy. This made agent-vm unusable in proxy-only networks.

Bump the vendor/microsandbox gitlink to the network-crate change that
tunnels guest egress through the host proxy via HTTP CONNECT (preserving
the TLS-intercept MITM and egress policy — see the submodule commit).

On the host side, also surface the detected proxy verbosely at launch:

    ==> Proxy: routing guest egress + image pulls through http://proxy:3128 (HTTP CONNECT)
    ==> Proxy: bypassing (no_proxy) localhost,127.0.0.1,.internal

so the operator can see at a glance that all traffic is routed through
their proxy. The banner reads the same HTTPS_PROXY/HTTP_PROXY/ALL_PROXY/
NO_PROXY the network stack consumes, via the re-exported
microsandbox_network::http_proxy::ProxyConfig::from_env().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:36:18 +03:00
Evgeny Boger
5cf95e20ef release-npm: skip unbuilt platforms in publish's artifact-layout verify
The publish "Verify artifact layout" step globbed npm-dist/agent-vm-*-*/bin
and required agent-vm + msb in each. The arm64 subpackage's bin/ exists in
the checkout (a committed .gitkeep placeholder), so with arm64 not built
this release the step failed: "missing npm-dist/agent-vm-linux-arm64/bin/
agent-vm". Skip any platform dir without a built binary, mirroring the
publish loop's guard. The x64 binary is still verified + chmod'd.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0119b4kV63pJXm5rGeZbz8jf
2026-06-24 01:53:29 +03:00
Evgeny Boger
7e8ad37589 release-npm: build guest agentd in build-agent-vm; make arm64 legs non-blocking
Two fixes so the v0.1.24 release can publish:

1. build-agent-vm was missing the "Build agentd from source (musl)" step
   that build-msb already has. With the SDK's `prebuilt` feature dropped,
   the filesystem crate embeds vendor/microsandbox/build/agentd at compile
   time, so `cargo build -p agent-vm` panics in build.rs ("agentd binary
   not found ... Run `just build-deps`") without it. Add the identical
   musl agentd build before the cargo build (mirrors ci.yml).

2. The arm64 (cross) legs are not yet shippable — the libkrunfw arm64
   kernel-config seed (config-libkrunfw_aarch64.patch) hasn't been ported
   and the arm64 multiarch dev-lib install is flaky. Mark the cross legs
   of build-agent-vm / build-msb / build-libkrunfw / package
   continue-on-error so they fail-tolerated instead of blocking the x64
   release, tolerate the missing arm64 artifact in publish, and skip any
   platform subpackage that has no built binary (its dir exists from the
   checkout but would otherwise publish a broken, binary-less package).
   arm64 is a cpu-gated optionalDependency of the main package, so x64
   installs are unaffected; the legs re-enable automatically once green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0119b4kV63pJXm5rGeZbz8jf
2026-06-24 01:43:34 +03:00
Evgeny Boger
281d82ddb3 v0.1.24: microsandbox 0.5.7 (wb) + 7-feature integration + agentd clean teardown
Headline changes since 0.1.23:
- vendor/microsandbox bumped to the canonical wb line (0.5.7), agentd source
  built from the fork (no upstream prebuilt); SDK prebuilt download dropped.
- agentd exits its init/workload on shutdown instead of reboot(RB_POWER_OFF) —
  teardown ~8.4s -> ~0.45s, data-safe.
- 7-feature integration: GitHub Copilot agent (D1), baked LSP plugins (D2,
  seeded past the persistence symlink), token rotation + single-flight (A1/A2),
  CI smoke (B1), aarch64 binary (B2), DNS family ordering (B3).
- image: reproducible :latest manifest, version-keyed agent layers, WB diag
  CLIs + codestyle-pinned Python linters.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0119b4kV63pJXm5rGeZbz8jf
2026-06-24 01:25:06 +03:00
Evgeny Boger
a8559ed5d4 ci: build guest agentd (musl) before cargo build
Dropping the microsandbox SDK `prebuilt` feature means agent-vm compiles the
guest agentd from the pinned source instead of downloading it, so the
filesystem crate's build.rs now requires vendor/microsandbox/build/agentd to
exist (else it panics "agentd binary not found … run just build-deps"). The CI
build-and-test job ran `cargo build -p agent-vm` with no agentd, so it failed.
Add a step that builds the static musl agentd into build/agentd first (mirrors
the release-npm workflow) + musl-tools.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0119b4kV63pJXm5rGeZbz8jf
2026-06-23 23:16:50 +03:00
Evgeny Boger
d3194bf831 Merge origin/main into bump-microsandbox-wb
Brings in main's image work (reproducible :latest manifest, version-keyed
agent layers, WB diag CLIs + codestyle-pinned Python linters) alongside the
0.5.7 bump + 7-feature integration + agentd-teardown/fork-artifact fixes.

Conflicts resolved in images/Dockerfile:
- header/LABEL: describe the union (4 agents incl. Copilot + diag + codestyle).
- agent installs reordered so Claude precedes the LSP-plugin block (the merge
  had stranded the LSP install ahead of `agent-vm-install claude`, so
  /root/.local/bin/claude was absent and the unguarded seed-stash hard-failed).
- seed-stash made best-effort (skip when no plugins) to match the LSP block's
  soft-fail policy.
- sanity check: keep main's hard-requirements + agent-CLI loop, add copilot.

Verified: image builds (LSP ×4 installed + stashed, Copilot 1.0.64, sanity OK);
boots with D1 copilot + D2 plugins √ enabled; teardown fix intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0119b4kV63pJXm5rGeZbz8jf
2026-06-23 23:13:20 +03:00
Evgeny Boger
ea73a39add agent-vm: drop the stale --mount IRQ-pool advisory note
The note warned that extra `--mount`s could trip
`RegisterNetDevice(IrqsExhausted)` because libkrun's virtio IRQ pool was
tight. That ceiling was the 16-pin IOAPIC MP-table bug, now fixed upstream
(msb_krun 0.1.17 maps the full IOAPIC pin range — verified 22 mounts / 34
virtio devices boot cleanly). The warning now fires far below the real limit
and is just noise, so remove it along with the stale rationale comment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0119b4kV63pJXm5rGeZbz8jf
2026-06-23 22:04:32 +03:00
Evgeny Boger
7d69877c35 agent-vm: repoint vendor/microsandbox to the wb-clean line
wb-clean is now the canonical microsandbox branch (wb is being retired). It
carries the same downstream features as wb but with clean per-feature history,
plus the agentd clean-teardown + fork-artifact-URL fixes (cherry-picked).
Pointer 1a7e803 (old wb) -> a5c830d. No Cargo.lock change (same crate versions).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0119b4kV63pJXm5rGeZbz8jf
2026-06-23 20:44:43 +03:00
Evgeny Boger
0d9d86e19a agent-vm: drop the microsandbox SDK prebuilt feature
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
2026-06-23 19:03:55 +03:00
Evgeny Boger
8be5bc0720 agent-vm: bump vendor/microsandbox — agentd clean teardown + fork artifact URLs
Pulls in fix/agentd-clean-vm-teardown:
- agentd exits its init/workload on shutdown instead of reboot(RB_POWER_OFF),
  which halts on the no-poweroff libkrunfw kernel — teardown ~8.4s → ~0.45s.
- release-artifact URLs resolve from wirenboard/microsandbox, not upstream.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0119b4kV63pJXm5rGeZbz8jf
2026-06-23 19:03:55 +03:00
Evgeny Boger
ef76b22733 D2: seed baked LSP plugins into persistent state on first boot
The image installs the LSP plugins under /root/.claude, but the launcher
symlinks /root/.claude -> /agent-vm-state/claude for persistence, which
shadows the baked tree — the booted guest's `claude plugin list` was empty.

Stash the plugins + settings to /opt/agent-vm/claude-seed (not shadowed) and
ship seed-claude-plugins.sh; the launch prelude runs it before exec'ing the
agent, copying the stash into the state dir once and merging enabledPlugins.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 00:24:42 +03:00
Evgeny Boger
d2de958638 Merge branch 'integration-7feat' into integration-on-bump
# Conflicts:
#	vendor/microsandbox
2026-06-22 21:21:21 +03:00
Evgeny Boger
cd7d81fdec agent-vm: set SecretEntry.on_violation for msb 0.5.7 SDK
The 0.5.7 secrets config gained a per-entry on_violation override
(Option<ViolationAction>). The token-isolation test constructs a
SecretEntry literally, so it must set the new field. None = inherit
the collection-level default. Release build was unaffected (only test
code constructs SecretEntry); cargo test -p agent-vm now compiles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:18:06 +03:00
Evgeny Boger
ea5821594e agent-vm: bump vendor/microsandbox to wb (rebased onto upstream)
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>
2026-06-20 19:58:07 +00:00
Evgeny Boger
a5c0d7ff79
Merge pull request #17 from wirenboard/image-reproducible-manifest
image: make :latest manifest deterministic (stop ~700 MiB hourly re-pull)
2026-06-19 17:56:13 +02:00
Evgeny Boger
aba431cfc8 image: clamp layer mtimes (rewrite-timestamp) + pin sbom off + assert single manifest
Code-review follow-up to the deterministic-manifest change. SOURCE_DATE_EPOCH
only clamps the image CONFIG (created + history); layer file mtimes still
defaulted to build wall-clock, so the manifest digest was reproducible only
while the gha layer cache held. On a cache miss (eviction / size cap /
base-layer invalidation) the apt/curl RUN steps re-ran and emitted layers
with fresh mtimes → new diff_ids → new manifest digest for an unchanged
commit, re-introducing the ~700 MiB consumer re-pull.

- outputs: add `rewrite-timestamp=true` so buildkit clamps every in-layer
  file mtime to SOURCE_DATE_EPOCH (the commit time). Layer digests — and
  thus the manifest digest — are now reproducible independent of cache
  state; the digest moves only on a real content change.
- sbom: false — pin it (defaults off today) so a future buildx default flip
  can't re-wrap the single-platform build in an attestation index whose
  build-time digest would churn :latest hourly. Complements provenance:false.
- "Show pushed digest" now asserts the pushed `:sha-…` is a single image
  manifest, not an index — fails the build if an attestation ever leaks back
  in and silently restores the churn.

Follow-up to #15 / part of #17.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gtxsKVWPrR8K1M1Te57MG
2026-06-19 15:48:27 +00:00
Evgeny Boger
aaf88cfa46 image: make :latest manifest deterministic so unchanged pulls are no-ops
PR #15 stopped the agent layers from rebuilding when no agent released,
but consumers still re-downloaded the whole image (~700 MiB) on every
hourly :latest. Root cause: the image config carried a fresh build
timestamp each hour (the `org.opencontainers.image.created=<hour>`
label + buildkit's own config/history timestamps), so the linux/amd64
manifest digest changed every cron build despite byte-identical layers.
microsandbox keys its fsmeta/VMDK materialization on the manifest
digest (registry/client.rs: `layer_force = force || !fsmeta_valid`,
fsmeta keyed by manifest_digest), and `agent-vm pull` uses
PullPolicy::Always — so a new manifest digest forces re-download +
re-materialize of every layer. Measured: pulling a fresh hourly tag
with byte-identical layers re-fetched 723 MiB; pulling the SAME
manifest again fetched 0.

Make the manifest deterministic so it only moves on real content
change:
- SOURCE_DATE_EPOCH = HEAD commit time, fed to buildkit, clamps the
  config `created` field and history timestamps to a per-commit
  constant. Same commit + same agent versions + same layers => identical
  config => identical manifest digest.
- Drop the per-hour `created` label (it lived in the config and was the
  main churn source). `revision` (commit sha) is stable across a
  commit's hourly builds.
- provenance: false — the attestation manifest embeds build time and
  would churn the multi-arch index digest (and the update banner) even
  with a stable image manifest; we don't consume provenance.

Result: an unchanged hourly :latest keeps the same digest, so
`agent-vm pull`/`setup` is a true no-op (0 bytes) instead of
re-downloading ~700 MiB. A new commit or a changed agent layer moves
the digest as it should.

Follow-up to #15.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gtxsKVWPrR8K1M1Te57MG
2026-06-19 10:41:32 +00:00
Evgeny Boger
ac20c8e746
Merge pull request #15 from wirenboard/image-version-cachebust
image: key agent layers on upstream version (stop hourly ~200 MiB :latest churn)
2026-06-18 19:17:15 +02:00
Evgeny Boger
620db380b9 image: fix agent version sources to match each installer's real channel
Code review of the per-agent cache keys found two sources didn't track
what the installer actually installs — so the key could miss a real
update (stale image) or rebuild for nothing:

- claude: keyed on npm @anthropic-ai/claude-code `latest`, but
  claude.ai/install.sh installs a NATIVE binary from
  downloads.claude.ai/claude-code-releases/latest. npm dist-tags move
  on a separate cadence (currently stable=2.1.170, latest=next=2.1.181),
  so npm `latest` can diverge from the native channel. Switch to the
  native `/latest` endpoint (a plain version string) that install.sh
  itself reads.
- opencode: keyed on github sst/opencode, but opencode.ai/install
  downloads from anomalyco/opencode (the repo was renamed). sst/* only
  resolves today via GitHub's rename redirect. Pin the real repo.

codex was already consistent (install.sh resolves from the same
openai/codex releases/latest endpoint) — unchanged.

Also harden the step: per-source `fail()` messages (the old generic
`::error::` was unreachable under `set -e`, which aborts at the failing
assignment before the validation loop), and a plausibility check that
rejects an HTTP-200-but-garbage body (empty / null / HTML) becoming a
cache key. Dry-run resolves codex=rust-v0.141.0 opencode=v1.17.8
claude=2.1.181.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gtxsKVWPrR8K1M1Te57MG
2026-06-18 10:44:22 +00:00
Evgeny Boger
2943cd940a image: key agent layers on upstream version, not hourly timestamp
The old AGENT_INSTALL_CACHEBUST=<hourly-timestamp> build-arg re-ran
all three agent-installer layers (+ the sanity check) on every hourly
cron build. Because `curl install.sh | bash` isn't reproducible, each
rebuild emitted fresh layer blobs -> a new ~200 MiB-of-agent-layers
image every hour even when no agent version changed, so :latest
consumers re-downloaded those layers hourly for nothing. (Confirmed
on published images: consecutive hourly builds share layers 1-9 but
get fresh digests for every agent layer, incl. the no-fs-change
sanity layer.)

Replace it with a per-agent version cache key: the workflow resolves
each agent's current upstream version (codex -> openai/codex
releases/latest, opencode -> sst/opencode releases/latest, claude ->
npm @anthropic-ai/claude-code latest) and passes AGENT_VERSION_{CODEX,
OPENCODE,CLAUDE}. Each installer RUN references its arg so BuildKit
rebuilds that layer ONLY when the version string changes; an unchanged
hourly build is now a pure cache hit (identical layer digests ->
nothing to re-pull). Resolution failure fails the build rather than
shipping a stale key and skipping a real update.

Also correct the layer-order rationale. #12 ordered the installers
"smallest first"; the real published sizes are codex 95 / opencode 50
/ claude 68 MiB compressed, and codex is also the most frequently
released (multiple stable cuts/day). Since a lower-layer change
cascades up through every layer above it, the topmost agent layer
re-emits on essentially every change -> the biggest/most-frequent
agent (codex) belongs at the BOTTOM. The existing order
(codex -> opencode -> claude) is already optimal across the plausible
frequency range; only the (wrong) comment is fixed, no reorder.

Follow-up to #12.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gtxsKVWPrR8K1M1Te57MG
2026-06-18 09:57:47 +00:00
Evgeny Boger
5c7aea9460
Merge pull request #12 from wirenboard/image-tools-layers
image: WB tooling + diag CLIs, layer reorder for cron-cache wins
2026-06-18 11:06:41 +02:00
Evgeny Boger
d5209f73fe image: WB tooling + diag CLIs, layer reorder for cron-cache wins
- network/diag tools (ssh, ping, ifconfig, sshpass, mtr, dig, nc, tcpdump,
  net-tools, dnsutils, …) merged into the base apt layer (~+38 MiB)
- Python lint pinned to codestyle/python/config/requirements.txt:
  black 24.2.0, isort 5.13.2, pylint 3.3.9, pytest-cov 2.10.1, attrs
  23.1.0, j2cli (~5 MiB)
- wirenboard/codestyle cloned at build to /opt/wirenboard/codestyle
  (--depth=1, ref pinnable via `--build-arg CODESTYLE_REF=…` for
  reproducible builds; default `master`) so the per-project
  `bash ../codestyle/.../linux-devenv.sh` idiom works in-VM without
  re-cloning (<1 MiB)
- agent installers split into three separate RUNs (codex → opencode →
  claude, smallest to largest) so a single release only invalidates one
  layer (~10–70 MiB) instead of three
- agent installers HARD-FAIL by default (production CI must not silently
  ship an image missing an agent). `AGENT_INSTALL_SOFT_FAIL=1` build-arg
  + matching `AGENT_VM_BUILD_SOFT_FAIL_AGENTS` env-var on build.sh
  decouple soft-fail from the CA-detection path — auto-enabled on
  TLS-intercept dev hosts, overridable in either direction. The
  codestyle clone follows the same policy
- shared agent-vm-install helper (curl + run + soft-fail policy in one
  place) replaces three near-identical RUN blocks; the per-installer
  RUNs become one-line invocations
- sanity check exercises every binary directly (`tool --version
  >/dev/null`) instead of `command -v` + echo-substitution which masked
  broken-but-on-PATH binaries; pipe-into-head removed so failures
  propagate
- build-time host CA shim (Dockerfile + images/build.sh): when
  /usr/local/share/ca-certificates/microsandbox-ca.crt exists on the
  host, build.sh passes it as a buildx --secret (content NOT baked into
  the image) plus a CA_SHIM_CACHEBUST=mtime build-arg so the
  conditional Dockerfile shim invalidates correctly; CI builds without
  the host CA file are unchanged (the shim no-ops)

DEFERRED (commented out in Dockerfile for the initial roll-out;
uncomment + restore the corresponding sanity-check probes when WB
engineers actually need the in-VM cross-compile path):

- WB build essentials: build-essential, debhelper/devscripts/equivs/
  dpkg-cross, pkg-config, clang-format/tidy, libgtest/gmock/curl/
  modbus/systemd-dev, gcovr, gdb-multiarch, cmake/ninja-build,
  python3-{cups,libgpiod,pycurl}. ~208 MiB compressed.
- WB cross toolchains: crossbuild-essential-{armhf,arm64},
  qemu-user-static, debootstrap/schroot/sbuild. ~258 MiB compressed.

Smoke-tested in a real microVM (locally-built image, soft-fail path,
image-API contract v1 intact): in-build sanity check passes (`==
sanity OK ==`), all enabled tools present and functional. Final image:
593 MiB compressed, +~38 MiB vs baseline (the diag CLIs added to the
base apt layer; codestyle clone <1 MiB; python pins ~5 MiB; everything
else byte-identical to original).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-13 14:49:58 +00:00
Evgeny Boger
687ebcc6ed
Merge pull request #11 from wirenboard/rewrite-microsandbox
Microsandbox rewrite: libkrun-based microVM runtime
2026-06-10 19:34:56 +02:00
Evgeny Boger
1509b0fb49 Merge remote-tracking branch 'origin/main' into rewrite-microsandbox
# Conflicts:
#	.github/workflows/build-image.yml
#	.github/workflows/release-npm.yml
#	images/Dockerfile
2026-06-10 20:34:06 +03:00
Evgeny Boger
82474aed75 Merge remote-tracking branch 'origin/rewrite-microsandbox' into integration-7feat
# Conflicts:
#	.github/workflows/release-npm.yml
#	crates/agent-vm/src/run.rs
2026-06-02 20:31:42 +00:00
Evgeny Boger
b2cadc56d8 v0.1.23: msb jemalloc allocator (fix host RSS balloon)
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>
2026-06-02 00:45:40 +03:00
Evgeny Boger
c3fda73ab1 release-npm: publish via npm OIDC trusted publishing; restore agentd-from-source
Why: the v0.1.22 publish failed — first npm E404 (NPM_TOKEN expired/
rejected), then ENEEDAUTH (token empty). Move off the long-lived token to
OIDC trusted publishing: npm mints short-lived credentials from the GitHub
Actions id-token (already `id-token: write` at the workflow level), and
provenance is generated automatically.

How (publish job):
  - Drop NODE_AUTH_TOKEN / secrets.NPM_TOKEN from both publish steps.
  - Drop setup-node's `registry-url:` — that input writes an empty
    `//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}` into .npmrc, which
    makes npm error ENEEDAUTH instead of using OIDC. The default registry is
    registry.npmjs.org regardless.
  - `npm install -g npm@latest` so the runner has npm >= 11.5.1 (node 22
    ships npm 10.x, which has no OIDC trusted-publishing support).

Requires a one-time npm-side setup: a trusted publisher configured for BOTH
@wirenboard/agent-vm and @wirenboard/agent-vm-linux-x64 (repo
wirenboard/agent-vm, workflow release-npm.yml). The NPM_TOKEN secret is no
longer used and can be deleted.

Also restores the "Build agentd from source (musl)" step + the msb
`--no-default-features` build that a prior edit had dropped. agentd is
embedded in msb and must ship at the same revision as the producer: the
boot-params side channel (v0.1.22) needs both halves in lockstep. Without
this, msb downloads the stale 0.4.6 prebuilt agentd (no boot-params reader)
and silently drops every mount — for all users, not just non-ASCII paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 20:26:08 +00:00
Evgeny Boger
010c45c190 Merge branch 'd2-lsp-plugins' into integration-7feat 2026-06-01 19:51:37 +00:00
Evgeny Boger
7a21c83b32 Merge branch 'd1-copilot-agent' into integration-7feat
# Conflicts:
#	crates/agent-vm/src/secrets.rs
2026-06-01 14:39:04 +00:00
Evgeny Boger
31fa95b67c v0.1.22: bump for Cyrillic path support
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 11:30:20 +00:00
Evgeny Boger
3d512fa545 Merge fix-cyrillic-cwd: non-ASCII (Cyrillic) project path support
Mirrors a Cyrillic/whitespace project path at its real location instead
of crashing the VMM (cmdline InvalidAscii) or falling back to /workspace:
mount specs ride a boot-params side channel, LANG=C.UTF-8 is pinned, and
the release builds agentd from source so producer+consumer ship together.
Bumps vendor/microsandbox to 62afcdf.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 11:28:24 +00:00
Evgeny Boger
0d5e576e47 Support non-ASCII (Cyrillic) project paths end to end
agent-vm mirrors the host cwd into the guest at the same absolute path,
but a Cyrillic-named directory crashed the VMM at boot: libkrun packs the
guest workdir + env into a printable-ASCII-only kernel command line and
.unwrap()s a non-ASCII byte into a SIGABRT. Three changes make the real
path work instead of falling back to /workspace:

1. Mount specs travel off the cmdline. The bumped submodule moves
   MSB_DIR_MOUNTS/FILE/DISK to a boot-params file on the runtime virtiofs
   share (agentd reads it at init). run.rs hands libkrun a '/' workdir
   placeholder when the real path isn't cmdline-safe and pins the agent's
   real cwd via the byte-safe vsock exec request (attach_with/.cwd on both
   the TTY and streaming paths). A mandatory post-boot fs().exists(real)
   probe fails loud if the bind didn't materialize (chdir failure is
   otherwise silent). resolve_project_guest_path now mirrors the real path
   for non-ASCII/whitespace; only a tmpfs-overlap or a control-char path
   (unframable for boot-params) still falls back to /workspace.

2. UTF-8 locale. The base image ships with no locale (C/POSIX), which
   renders a Cyrillic cwd as `M-P…` meta-escapes in bash/readline AND
   makes filesystem encodings default to ASCII so Python/Node/ripgrep
   mis-handle the path even when it mounted. Pin LANG=C.UTF-8 for every
   guest (GUEST_ALWAYS_ENV in run.rs) and in images/Dockerfile.

3. Ship producer and consumer in lockstep. agentd is embedded in msb and
   was downloaded as a release prebuilt; the new producer needs the new
   consumer, so release-npm.yml's build-msb now builds agentd from the
   pinned source (musl) and links msb with --no-default-features (drops
   the `prebuilt` download). A stale prebuilt would have dropped every
   mount for every user.

Verified: bash/readline renders the real Cyrillic path as glyphs under
C.UTF-8 (pty capture) and as the reported M-P escapes without it; the
boot-params channel carries the path byte-intact and a normal launch
mounts correctly. Full nested-guest agent runs hit this dev box's
overlay-on-virtiofs limit (the probe reports it); validated on a real
host via the path mirror.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 11:28:03 +00:00
Evgeny Boger
05aea4c281 Merge branch 'a2-refresh-single-flight' into integration-7feat 2026-06-01 11:20:51 +00:00
Evgeny Boger
74832b56af Merge branch 'a1-token-rotation-test' into integration-7feat 2026-06-01 11:20:51 +00:00
Evgeny Boger
89b54bdac7 Merge branch 'b3-ipv6-dns-fix' into integration-7feat 2026-06-01 11:20:51 +00:00
Evgeny Boger
45655fb773 Merge branch 'b2-aarch64-binary' into integration-7feat 2026-06-01 11:20:51 +00:00
Evgeny Boger
aa067d8de6 Merge branch 'b1-ci-smoke' into integration-7feat 2026-06-01 11:20:50 +00:00
Evgeny Boger
a1f16291fb D2: install LSP language servers in the image for in-VM code intelligence
Why
---
In-VM Claude has no code intelligence for the languages it edits most:
the guest image ships toolchains but no Language Server Protocol servers,
so completion, diagnostics, hover, and go-to-definition are unavailable
for C/C++, Python, TypeScript, and Go work done inside the sandbox. The
original Bash agent-vm closed this gap in its `setup` step
(claude-vm.sh:353-361), but the microsandbox rewrite — whose image is
Docker-built once rather than provisioned per-setup — dropped it.

This is the D2 item from PLAN.md ("LSP plugins in the image"): port the
four language servers the original's `setup` installed so that an agent
running in the guest gets the same editor-grade intelligence the original
provided, without any host round-trip.

How
---
images/Dockerfile gains an image-build step that installs the four
language servers the original declared — clangd-lsp (C/C++),
pyright-lsp (Python), typescript-lsp (TypeScript), and
gopls-lsp@claude-plugins-official (Go) — and pre-warms them so the first
in-VM invocation is not paying first-run download/extraction cost. Doing
this at build time (rather than per-setup or on first launch) keeps the
fresh-VM-per-launch model fast: the servers are baked into the image
layer and ready the moment the microVM boots, matching PLAN.md's
"Just Dockerfile + pre-warm" scope for this item.

No Rust, run-path, or version changes are involved; the feature is
entirely contained in the image definition, so existing launch behavior
is unchanged except that the language servers are now present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 20:42:00 +00:00
Evgeny Boger
1b0688a3ce feat: add GitHub Copilot as a selectable in-VM agent (PLAN D1)
Why
---
Until now the sandbox only knew how to launch Claude inside the microVM.
PLAN.md item D1 ("Copilot agent") calls for first-class support for GitHub
Copilot so that users who work with Copilot get the same isolated,
credential-scoped VM treatment Claude already enjoys, rather than running
Copilot on the host with broad ambient access. Running Copilot inside the VM
puts its network egress, filesystem view, and credential exposure under the
existing sandbox policy instead of the host environment.

How
---
The change spans the image, the credential layer, and the launch path:

- images/Dockerfile: provision the GitHub Copilot CLI in the guest image
  next to the existing agent tooling, so a pulled image can run Copilot with
  no extra in-VM setup.
- crates/agent-vm/src/host_paths.rs: resolve the host-side location of
  Copilot's credential/config files so secrets discovery knows where to read
  them from.
- crates/agent-vm/src/secrets.rs: teach the secrets layer to discover
  Copilot's credentials on the host and forward them into the guest the same
  way the other agents' tokens are handled, keeping the credential surface
  scoped to the VM. This is the largest part of the change.
- crates/agent-vm/src/run.rs: wire Copilot into the launch path so it can be
  chosen as the agent to run, threading the new credential material through to
  the guest invocation.
- crates/agent-vm/src/main.rs: surface the new agent choice at the CLI layer.

Together these complete PLAN.md item D1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 20:23:56 +00:00
Evgeny Boger
9676f6d9a4 net: keep all DNS address families and order by route preference (B3)
Why: the DNS resolver historically collected only the first IPv4 result
(`filter(|a| a.is_ipv4()).take(1)`), silently discarding every AAAA
record. That made IPv6-only hosts completely unreachable and also broke
dual-stack hosts whenever the A record was stale or misrouted — the
caller had no fallback because the alternate-family addresses had already
been thrown away during resolution. This is the B3 backlog item in
PLAN.md ("resolver dropped AAAA records, breaking IPv6-only hosts").

How: `DnsResolver::resolve` now retains every address returned by
`to_socket_addrs` and returns the full set so the caller can try each in
turn, happy-eyeballs style, instead of being handed a single address. An
empty result is now surfaced as an explicit error rather than an empty
vec. The addresses are stably sorted by family preference: when a usable
global IPv6 route is detected we order IPv6 first, otherwise IPv4 first,
so a host stays reachable even when one family is misconfigured. Route
preference is determined by `have_global_ipv6`, a best-effort probe that
binds a UDP socket and connects it to a public IPv6 DNS address
(2001:4860:4860::8888) to learn the locally selected source address —
this performs no traffic and a wrong answer only changes connection
order, never correctness.

A regression test (tests/ipv6_dns_test.rs) covers the new behavior:
`resolve` must return a non-empty set for a dual-stack name and must
preserve the requested port across every returned address regardless of
the chosen ordering.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 19:33:07 +00:00
Evgeny Boger
0468a2649a B2: ship a native aarch64-linux release binary via npm
Why
---
PLAN.md item "B2 — Cross-arch binaries" (section B, Distribution /
release) calls for cross-arch builds and per-platform npm packaging,
noting that "the package currently bundles a linux-x86_64 binary". As a
result Apple Silicon and ARM Linux users get no native artifact and fall
back to slow x86 emulation (Rosetta / qemu) — a poor fit for a tool whose
job is to launch microVMs quickly. This change delivers the linux/aarch64
half of B2: a genuine aarch64 binary, built, packaged, and selected at
install time, so ARM hosts run native.

How
---
Release workflow (.github/workflows/release-npm.yml): every build job
(build-agent-vm, build-msb, build-libkrunfw) and the package job gain a
linux-arm64 matrix leg. Because GitHub's hosted arm64 Linux runners
aren't on the free tier, the arm64 leg cross-compiles on the x64 runner:
a `cross: true` matrix flag drives a `CROSS` env switch that (a) enables
the arm64 multiarch apt repo and installs the cross linker
(gcc-aarch64-linux-gnu) plus the :arm64 dev libs agent-vm / msb link
against (libcap-ng, libdbus, libsqlite3), and (b) exports
PKG_CONFIG_ALLOW_CROSS / PKG_CONFIG_PATH / PKG_CONFIG_SYSROOT_DIR so the
`pkg-config` crate resolves the target libs. The msb job also sets
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER directly, since it builds
from vendor/microsandbox (a separate workspace not on the superproject's
.cargo config search path). All of these guards are no-ops on the native
x64 leg, so the existing x86_64 path is unchanged. The libkrunfw arm64
leg cross-builds the guest kernel with ARCH=arm64 / CROSS_COMPILE and
fails fast with a clear message if its arm64 kbuild .config seed
(libkrunfw-overrides/config-libkrunfw_aarch64.patch) hasn't been ported
yet, rather than silently shipping a mis-configured kernel. The publish
job downloads the new agent-vm-linux-arm64 artifact alongside the x64 one.

Cross toolchain config (.cargo/config.toml, new): pins the
aarch64-unknown-linux-gnu linker to aarch64-linux-gnu-gcc so a local
cross build reproduces CI; the file documents the matching apt packages.

npm launcher (npm-dist/agent-vm/bin/agent-vm.js,
npm-dist/agent-vm/package.json): un-comment the linux-arm64 entry in the
launcher's PLATFORM_PACKAGES map and add @wirenboard/agent-vm-linux-arm64
to the main package's optionalDependencies, so `npm install` pulls the
arm64 subpackage on ARM hosts and the launcher dispatches to it.

arm64 subpackage scaffold (npm-dist/agent-vm-linux-arm64/, new): mirrors
the x64 subpackage layout — package.json (os linux / cpu arm64),
README.md, and bin/.gitkeep + lib/.gitkeep placeholders — so the
directory exists in the tree for the release workflow to drop the
cross-built binary, msb, and libkrunfw into.

Dispatch test (npm-dist/agent-vm/bin/agent-vm.dispatch.test.js, new):
arm64 dispatch can't be exercised on an x86_64 CI host because node
reports the host's real process.arch. The test re-derives the launcher's
PLATFORM_PACKAGES map + bin-path logic from the source and asserts the
linux-arm64 key resolves to the arm64 subpackage with the same bin/
layout as x64, so a future edit that forgets arm64 fails here instead of
silently falling through to "no prebuilt binary" on ARM hardware.

README (npm-dist/README.md): document that the package now ships both the
linux-x64 and linux-arm64 per-platform subpackages.

macOS / darwin and win32 cross builds remain out of scope for this change
(still commented placeholders in the launcher) and are tracked under the
rest of B2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 18:57:07 +00:00
Evgeny Boger
60a75ae0fc v0.1.21: bump for help review fixes
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>
2026-05-31 18:38:30 +03:00
Evgeny Boger
7ebb0b0a3d help: fix review findings in the regrouped CLI help
Follow-up to the help regroup (f881e07), addressing a code review of
that change.

  - The `--help` example `agent-vm shell -- -c 'cargo test'` was wrong
    and would not run. The launcher already wraps a Shell agent's
    trailing args in `bash -c <shell-escaped args>`, so a literal `-c`
    is double-applied: the guest runs `bash -c "-c 'cargo test'"`, bash
    takes `-c` as the command name -> `bash: -c: command not found`,
    exit 127. Changed to `agent-vm shell -- cargo test`.

  - The Examples/Networking/Environment footer hangs off the single
    `run::Args` shared by claude/codex/opencode/shell, so it renders
    verbatim under every verb. The examples are claude-flavoured
    (`-- --model opus`), which read as authoritative under
    `agent-vm codex --help`. Labelled the block "(claude shown;
    codex/opencode/shell take the same options)" and dropped the
    now-false "command-agnostic" comment.

  - The Environment section omitted two env vars the launch path
    honours: AGENT_VM_STATE_DIR (host_paths::state_root) and
    AGENT_VM_INSECURE_REGISTRY (is_plain_http_registry). Added both.

  - `--publish` value_name was `[BIND:]HOST:GUEST`; the port fields read
    as hostnames. Changed to `[BIND:]HOST_PORT:GUEST_PORT`, matching the
    real parse format and the per-flag long help.

  - Rewrote the networking cheatsheet: dropped the per-row format
    abbreviations (they duplicated the value_names and pushed the
    longest row to ~88 cols, which wrap_help reflows and de-aligns on an
    80-col terminal) and kept the direction arrows. All footer lines are
    now <=76 cols.

  - Cargo.toml `description` said "Sandboxed VMs"; `about` says
    "microVMs". Aligned to "microVMs".

Presentation-only; no flag, env var, or behaviour changed.
`cargo test -p agent-vm`: 123 passed. Rendered `-h`/`--help` for claude
and codex verified against the rebuilt binary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:32:57 +03:00
Evgeny Boger
6513050a31 v0.1.20: cut launch latency — cache gh identity, non-blocking update banner, chrome CA off the hot path
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>
2026-05-31 14:22:34 +00:00
Evgeny Boger
9c97658e6f ci: add a smoke-test workflow that builds and checks the workspace (B1)
Why
---
Until now the only GitHub Actions we ran were the heavyweight, release-
oriented pipelines: build-image.yml (which bakes and pushes the guest OCI
template to GHCR) and release-npm.yml (which cross-compiles the binaries
and publishes the npm packages). Both are gated on tags / specific refs
and take many minutes, so day-to-day pushes and pull requests on the
rewrite branch got no automated feedback at all. A regression that broke
`cargo build` or a unit test could sit undetected on a feature branch
until release time, which is exactly when it is most expensive to find.

PLAN.md item B1 ("CI smoke") calls for a lightweight, always-on check
that gives every push and PR a fast pass/fail signal on the core Rust
workspace, independent of the slow image/release machinery.

How
---
This adds .github/workflows/ci.yml, a dedicated smoke workflow that runs
on push and pull_request so contributors get feedback on the change set
before it is merged, not at release time. It checks out the repository
(with the vendor/microsandbox submodule, since the workspace depends on
the vendored msb crates), sets up the Rust toolchain, and exercises the
agent-vm workspace with the standard cargo checks (build + test) so a
broken compile or a failing unit test fails CI immediately.

It is intentionally scoped as a smoke test rather than a full matrix:
the goal is a quick, reliable gate that catches the common breakages, so
it stays cheap enough to run on every commit and leaves the expensive
cross-compilation and image-publish flows to their existing dedicated
workflows. The two release pipelines are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 14:21:22 +00:00
Evgeny Boger
2e94f3276f secrets: single-flight the in-guest credential refresh (PLAN A2)
Why
---
When an in-guest token expires, the intercept hook rotates it by asking
the host to run the provider CLI (`claude -p hi` for Anthropic, `codex
exec` for OpenAI) and then re-reading the freshly written credential
file. Under load this races: if two requests notice the expired token at
nearly the same time, each independently drives a full host-side
rotation. The result is two concurrent `claude`/`codex` launches for a
single logical refresh -- wasted host CPU, duplicate browser/OAuth churn,
and, worse, two writers racing on the same credential file, which can
leave a half-written token behind and cascade into further refreshes.

How
---
Serialize host rotations per provider with a `RefreshLock` (added in
`secrets.rs`) keyed by a provider-specific lock name -- `REFRESH_LOCK_
ANTHROPIC` and `REFRESH_LOCK_OPENAI`. Both `refresh_anthropic` and
`refresh_openai` now acquire the matching lock before doing any work:

  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"])?;
  }

The first waiter performs the rotation; the second, once it acquires the
lock, sees via `token_recently_rotated` that the credential file was just
rewritten and skips its own host CLI invocation entirely, reusing the
fresh token. The locks are intentionally per-provider so a concurrent
Anthropic refresh never serializes against an OpenAI one (and vice
versa) -- only true duplicates of the same provider's refresh collapse
into a single host launch.

This implements item A2 (refresh single-flight) from PLAN.md: collapse
concurrent credential refreshes for the same provider into one host-side
rotation, with a freshness check so late waiters reuse the just-rotated
token instead of spawning a redundant CLI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 14:12:23 +00:00
Evgeny Boger
0f301a1146 intercept-hook: end-to-end mid-session token rotation across an expiry boundary
Why
---
PLAN.md item A1 ("Real mid-session token rotation") was the last open item
of the old Phase 4. The substitution + OAuth-refresh-hook infrastructure
already existed (file-backed `SecretValue::File` + `_intercept-hook`), but no
run had ever crossed a real token-expiry boundary end-to-end: Claude tokens
expire after ~hours, Codex/ChatGPT after ~24h, so a normal CI run never
exercises the refresh path. Without that coverage we could not claim that an
externally-rotated host token is actually picked up on the next request
without a re-attach — the whole point of host-rooted secrets.

How
---
This extends `crates/agent-vm/src/intercept_hook.rs` to drive and assert the
rotation flow directly, so the expiry boundary is reproduced deterministically
instead of waiting hours for a live token to lapse:

- Synthesize a near-expired bearer in the file-backed secret, issue a request
  through the intercept hook, and assert the hook detects expiry and triggers
  the host-side refresh rather than forwarding the stale token.
- After the simulated host rotation rewrites the credential file, assert the
  next request observes the freshly rotated bearer with no relaunch and no
  re-attach, confirming the file-backed `SecretValue::File` re-read path.
- Cover both the Claude and Codex/ChatGPT shapes so the ~hours and ~24h expiry
  boundaries are both exercised by the same harness.

This closes A1 by making the previously-untested rotation path a repeatable
check, decoupled from real-world token lifetimes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 13:52:08 +00:00
Evgeny Boger
94ae08e7c1 PLAN: replace phase roadmap with remaining-work plan vs main
The old Phase 0-9 roadmap is essentially all shipped, so it no longer
answers the only live question: what's left for the microsandbox rewrite
to fully replace — and then beat — the original Bash agent-vm on main.

Rewrote PLAN.md around that question. Per-phase history is dropped (it
lives in git log + ARCHITECTURE.md); the new structure is:

- "Where the rewrite stands today" — the working/verified surface,
  including the per-launch egress controls that already exceed main.
- A. In-scope work to finish: A1 real mid-session token rotation
  (built, never exercised e2e), A2 refresh single-flight flock
  (confirmed absent in intercept_hook.rs), A3 project-integrity
  snapshot (rewrite fingerprints only the 3 cred files; main also
  covers .git/hooks, .git/config, CLAUDE.md, Makefile), A4 git
  push --dry-run push-access probe (confirmed absent).
- B. Distribution: CI smoke, cross-arch binaries, IPv6 DNS upstream fix.
- C. Beyond main: detached/persistent-VM fast launch.
- D. Per-feature decisions made with the user: port back copilot
  agent+token and LSP plugins in the image; won't-do GitHub App
  per-repo token minting, USB passthrough, dynamic-memory balloon.

Verified candidate gaps against the actual rewrite source rather than
the docs: dropped onboarding-config and .agent-vm.runtime.sh-hook from
the list because secrets.rs / run.rs:891-962 already implement them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 20:31:06 +00:00
Evgeny Boger
13850a81aa Merge branch 'improve-help-output' into rewrite-microsandbox
Regroup and tighten the agent-vm CLI usage output: terse short help,
help_heading groups, wrap_help, value names, examples/networking/env
footers, and a working -V/--version.
2026-05-30 19:08:55 +00:00
Evgeny Boger
f881e073c1 help: regroup and tighten the CLI usage output
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>
2026-05-30 19:08:11 +00:00
Evgeny Boger
63cab08d82 README: document --publish / --auto-publish / --allow-egress / --allow-lan / --allow-host
New "Ports & egress" section between Project hook and Troubleshooting.
Lays out the default-deny baseline (`public_only` policy: only DNS +
Public group), then a one-row-per-flag table covering all five
network surface flags merged this iteration:

- --publish (host → guest port forward)
- --auto-publish (Lima-style guest → host mirror)
- --allow-egress IP|CIDR (per-IP egress)
- --allow-lan (whole Private RFC1918 + CGNAT + ULA)
- --allow-host (gateway IP → host 127.0.0.1, dial via
  host.microsandbox.internal)

Calls out that loopback/link-local/metadata stay denied even
under --allow-lan (disjoint groups by design) and that --allow-host
is the narrowest way to reach a host dev server.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 17:54:38 +00:00
Evgeny Boger
65c6d59a40 run: seed the pulled-digest marker on launch so the update banner can fire
Follow-up to the ghcr.io banner auth fix (v0.1.17). The banner compares the
registry against the digest recorded by our last pull (`pulled_marker`), but
that marker was written *only* by `agent-vm pull`. So anyone who got their
image via a launch's `IfMissing` auto-pull — or via an older agent-vm that
never wrote the marker (the pre-v0.1.17 401 bug) — had no baseline, landed in
`UpdateState::NotCached`, and the banner stayed silent forever until they ran
`agent-vm pull` by hand. Which is the exact "I always had to pull manually"
complaint the banner was meant to retire.

Seed the marker on the launch path, just before the probe, from what
microsandbox actually has cached (`Image::get(ref).manifest_digest()`), but
only when no marker exists yet — an existing marker is the authoritative
record of our last pull. A stale cache then trips the banner on *this* launch,
not the next.

Why `Image::get` is safe here but not in pull.rs: pull.rs uses
PullPolicy::Always, where microsandbox's cached manifest digest can lag a
re-pull under the same tag (the reason pulled_marker.rs exists). The launch
path uses IfMissing and never re-pulls, so the cached digest is accurate.
Verified live that `Image::get(...).manifest_digest()` returns the exact
per-platform digest `image_check::fetch_remote_digest` produces, so the
marker-vs-registry comparison is apples-to-apples:

    seeded pulled-digest baseline from cache  digest=sha256:90cfcbdc...
    registry update probe                     digest=sha256:90cfcbdc...   (UpToDate, no banner — cache current)

Fresh installs: image not cached → no seed → no banner on first run (correct,
the imminent pull lands the current image); armed from the next launch on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 16:51:55 +00:00
Evgeny Boger
0fee58a8b9 v0.1.18: run: add --allow-host for host's 127.0.0.1 via host.microsandbox.internal
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>
2026-05-30 15:11:25 +00:00
Evgeny Boger
0de7159e0e v0.1.17: bump for the ghcr.io update-banner fix
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>
2026-05-30 13:24:29 +00:00
Evgeny Boger
92c9da84cf Merge branch 'investigate-image-check' into rewrite-microsandbox 2026-05-30 13:23:21 +00:00
Evgeny Boger
2a45955818 Merge remote-tracking branch 'origin/rewrite-microsandbox' into rewrite-microsandbox 2026-05-30 13:23:00 +00:00
Evgeny Boger
2d4d5978bf image_check: do the ghcr.io Bearer-token handshake so the update banner fires
The launch-time "newer image available" probe did a plain unauthenticated
GET against the registry manifest endpoint. ghcr.io (and Docker Hub) answer
that with 401 + `WWW-Authenticate: Bearer realm=...` even for public images,
so `remote_manifest_digest` hit `!status.is_success()` and returned `Ok(None)`
— the banner never fired for the default `ghcr.io/...:latest` image, and the
same `fetch_remote_digest` in `agent-vm pull` silently skipped writing the
pulled-digest marker. Net effect: the update check never worked; users always
had to `pull` blind.

Add the standard registry token handshake: on 401, parse the Bearer challenge,
fetch an anonymous token from `realm?service=&scope=`, and retry the manifest
GET with `Authorization: Bearer`. Works for ghcr.io and Docker Hub; offline /
private / malformed-challenge cases still fall through to a quiet `Ok(None)`.

Also:
- Cap the launch-path probe with an overall 5s budget (tokio time feature):
  the handshake is up to three sequential round-trips, each with its own 5s
  per-request timeout, awaited inline before boot — a slow registry must not
  stall launch beyond a single request's worth of wait.
- Drop an empty token instead of sending `Authorization: Bearer ` + re-401.
- Surface the probe outcome via `tracing::debug!` (RUST_LOG=agent_vm=debug).
- Tests for the WWW-Authenticate challenge parser.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:21:39 +00:00
Evgeny Boger
17b6e5bb91 v0.1.16: bump for --publish / --auto-publish / --allow-egress + AGENTS.md
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>
2026-05-30 11:37:12 +00:00
Evgeny Boger
1479f5b8f3 Merge worktree-vm-network-investigation: --publish + --auto-publish + --allow-egress / --allow-lan
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
2026-05-29 17:19:43 +00:00
Evgeny Boger
c60b76597f run: --allow-egress <IP|CIDR> + --allow-lan to open egress per-launch
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>
2026-05-29 15:30:20 +00:00
Evgeny Boger
5a731501d5 run: --publish accepts [::1]:p:p, rejects /udp + bump vendor/microsandbox
Two parser fixes plus the vendor bump:

  IPv6 host bind via docker-style brackets: `[::1]:8080:80`
  and `[::]:5000:5000` now work. Previously the parser split
  the whole body on `:` and any IPv6 form exploded into too
  many parts and got rejected with the generic syntax error,
  despite the underlying PortPublisher accepting IpAddr::V6.

  UDP is now REJECTED with a clear error message. Previously
  `--publish 53:53/udp` parsed, the banner said "Publishing
  …/udp", and PortPublisher::spawn_listener_one silently
  returned None for non-TCP — user got a published port that
  in fact had no listener.

Vendor bump (29ab515) carries the round-2 fixes:
  - IPv6 loopback end-to-end (LoopbackForwardReq gains
    loopback_target; runtime sends right bind/target pair)
  - Supervisor owns `active` across reconnects (no listener
    leaks)
  - Stream errors propagate to supervisor for reconnect
  - Exponential backoff on supervisor restart
  - IdCounter clamps away from PORT_EVENT_BROADCAST_ID
  - ForwarderRegistry::spawn awaits prior task before rebind

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 14:42:54 +00:00
Evgeny Boger
374921cb99
Merge pull request #9 from wirenboard/image-cron-cachebust-rms
ci(build-image): bust cache for agent installers each run
2026-05-29 17:12:42 +03:00
Evgeny Boger
9a3d7465ee
Merge pull request #8 from wirenboard/image-cron-cachebust
ci(build-image): bust cache for agent installers each run
2026-05-29 17:12:35 +03:00
Evgeny Boger
45c208ce31 run: clarify --auto-publish help text + bump vendor/microsandbox
The previous help text claimed "Loopback-only guest binds are NOT
forwarded" — but the agentd-side forwarder added in 18d2c75 does
in fact forward them. Operators reading --help reasonably
expected 127.0.0.1-only guest services to stay private; in
practice agent-vm exposes them on host 127.0.0.1 (every other
process on the host's loopback can reach them).

Updated text describes what actually happens and adds an explicit
security note pointing users at the per-port --publish flag when
they DO want loopback to stay private inside the guest.

Vendor bump picks up the upstream fixes (b7cb641):
  - LoopbackForward family-aware bridge (IPv6 loopback works)
  - Mode-flip reconcile (dev-server bind-address changes)
  - Accept-loop exponential backoff (no EMFILE busy-spin)
  - IdCounter clamp (no slot-boundary id wrap)
  - UDS supervisor (auto-publish recovers from transient errors)
  - spawn_listener_one binds before registering the AbortHandle
  - FLAG_SESSION_START removed from LoopbackForward* RPCs

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 13:41:28 +00:00
Evgeny Boger
e556ad3ad6 ci(build-image): bust cache for the three agent installers each run
Companion to #8. The hourly cron checks out rewrite-microsandbox's
Dockerfile (see the `ref:` override that lives on main's workflow
file), so the `ARG AGENT_INSTALL_CACHEBUST` placeholder right before
the three installer RUNs has to land here for the bust to bite. The
workflow-file edit is also bundled so workflow_dispatch from this
branch and any future merge-back to main behave consistently.

See PR #8 on main for the longer rationale.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 13:17:34 +00:00
Evgeny Boger
9240afbbd4 ci(build-image): bust cache for the three agent installers each run
The hourly cron's whole point per its header comment is to "pick up
new claude-code/codex/opencode releases", but `cache-from: type=gha`
matches each `RUN curl … install.sh | bash` by its (invariant)
instruction string, so the GHA layer cache served the same baked-in
binaries indefinitely — scheduled runs were ~30-60s of cache replay
that never refreshed the agents. claude-code 2.1.156 had been out
for a while and `:latest` was still on an older release.

Pass the per-build timestamp (`steps.ts.outputs.tag`, already
computed for the date-tagged image) as `AGENT_INSTALL_CACHEBUST`,
and reference it via an `ARG` placed right before the three
installer RUNs. That invalidates exactly those layers (and the
sanity-check RUN that follows) while keeping the heavy apt /
chromium / docker / nodejs layers above the ARG cached. Local
`images/build.sh` builds leave the empty default and behave as
before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 13:15:49 +00:00
Evgeny Boger
6d52474eac Bump vendor/microsandbox: auto-publish unit tests
No behaviour change — picks up the new tests for the loopback
forwarder registry and the listener-collapse precedence rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 21:19:27 +00:00
Evgeny Boger
18d2c75330 Bump vendor/microsandbox: agentd loopback forwarder
Picks up the agentd-side TCP forwarder that makes 127.0.0.1-only
guest LISTEN sockets reachable from the host under --auto-publish.
No agent-vm-side code change needed — the auto-publish flag now
just works for loopback binds as well as wildcard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 21:10:29 +00:00
Evgeny Boger
fd696a13ea run: add --auto-publish flag (vendored msb auto-publish)
Bumps vendor/microsandbox to pick up the runtime auto-publish
feature and exposes it as a CLI flag. When set:

  - .network() builder enables auto_publish() so the runtime's
    in-msb poll task spawns once the agent socket is ready.
  - The host process subscribes to sandbox.port_events() and
    prints each Added/Removed mapping to stderr as `==> auto-
    publish: guest :PORT → host 127.0.0.1:PORT`.

All forwarding rides the native smoltcp PortPublisher; no
per-connection python3 tunnel like the previous exec-tunnel
prototype. Loopback-only guest binds are not auto-forwarded
(documented on the flag) because the smoltcp dial target is the
guest's VLAN IP, not loopback — use --publish or run the service
on 0.0.0.0 inside the guest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:01:18 +00:00
Evgeny Boger
ca516fb807 Revert exec-tunnel auto-publish — moving into msb (vendor/microsandbox)
The exec-tunnel implementation lived in agent-vm because it could be
done without upstream changes. Now superseded by a microsandbox-side
native implementation: dynamic PortPublisher add/remove + in-msb
poll loop reading /proc/net/tcp via the in-process agentd client,
emitting PortEvent over the existing agent.sock relay. Each
forwarded byte stays on the native smoltcp path (no python3 fork,
no extra hops), and the feature becomes a generic microsandbox
primitive any consumer can use via NetworkBuilder.auto_publish(...).

The agent-vm replacement (a thin event-subscription that prints
each guest port → host port mapping) lands in a follow-up commit
after the vendor/microsandbox bump.

Reverts:
- 800b51c auto-publish: forward 127.0.0.1 guest binds too, not only 0.0.0.0
- 913d9e0 run: add --auto-publish for Lima-style host ← guest port mirroring

--publish (static smoltcp publish, commit 4407bc2) is untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 23:14:54 +00:00
Evgeny Boger
800b51c411 auto-publish: forward 127.0.0.1 guest binds too, not only 0.0.0.0
Lima itself defaults to loopback-only (skipping 0.0.0.0) because on
its host-network model 0.0.0.0 in the guest is reachable from the
LAN and silently mirroring that on host loopback would change the
security posture. Our setup is the other way around — smoltcp is
in-process inside agent-vm, so 0.0.0.0 in the guest is reachable
only from the agent-vm process anyway. Forwarding both is the same
trust boundary and saves the user from having to remember which
address their dev server happened to pick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:46:05 +00:00
Evgeny Boger
913d9e0066 run: add --auto-publish for Lima-style host ← guest port mirroring
Polls /proc/net/tcp{,6} inside the guest every ~2s over the existing
agentd FsRead channel and diff-drives a per-guest-port host
listener. Each accepted host connection is bridged through a
per-connection in-guest python3 tunneller (exec over agentd) — no
upstream microsandbox changes needed, since the smoltcp PortPublisher
takes ports statically at boot and lives in the msb child process
behind a JSON-config boundary.

Mirrors Lima's default policy: only 0.0.0.0 / [::] binds are
auto-forwarded (loopback-only services stay private). When the
preferred host port is already taken, falls back to ephemeral.
Trade-off vs --publish: each inbound connection forks a python3
process — fine for dev tunnels, not for high-throughput.

Demo:
  agent-vm shell --auto-publish
  # in guest: python3 -m http.server 8080 --bind 0.0.0.0
  # on host: curl http://127.0.0.1:8080/  → reachable within ~2s

New modules:
  proc_net_tcp.rs   — /proc/net/tcp{,6} LISTEN parser (5 tests)
  exec_tunnel.rs    — per-connection python3 bridge
  auto_publish.rs   — discovery + diff + spawn/abort loop

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:15:38 +00:00
Evgeny Boger
4407bc244d run: add --publish HOST:GUEST[/proto] for host → VM port forwarding
The smoltcp networking engine is in-process and never exposes the
guest IP on a host interface, so a `0.0.0.0` listener inside the VM
is unreachable from the host out of the box. microsandbox already
ships a published-port primitive (host listener that forwards
inbound connections into the guest via smoltcp); this just plumbs
it through the agent-vm CLI in docker-compatible syntax:

  agent-vm shell -p 18080:8080         # 127.0.0.1:18080 → guest :8080
  agent-vm shell -p 0.0.0.0:5000:5000  # all host ifaces
  agent-vm shell -p 53:53/udp          # UDP

Guest must listen on 0.0.0.0 / the assigned MSB_NET_IPV4 — a bare
127.0.0.1 bind inside the guest isn't reachable since the smoltcp
dial target is the guest's VLAN address, not loopback. Doc'd on
the flag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:50:43 +00:00
agent-vm
25d75e7181 ci(build-image): build rewrite-microsandbox on scheduled runs
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>
2026-05-25 22:00:54 +00:00
Evgeny Boger
35bdce0cee ci(release-npm): mirror chmod-line-continuation fix 2026-05-25 15:11:35 +03:00
Evgeny Boger
d36c945156 ci(release-npm): mirror parallel-jobs + rust-cache + relax-LTO from rewrite-microsandbox 2026-05-25 15:03:19 +03:00
Evgeny Boger
ca6a2a3d2b rename ghcr image to agent-vm-template (mirror from rewrite-microsandbox) 2026-05-25 14:39:24 +03:00
Evgeny Boger
c87d9bfb99 ci: bump actions to Node-24 majors + dependabot (mirror from rewrite-microsandbox) 2026-05-25 14:27:42 +03:00
Evgeny Boger
f5bacb5f41 ci(release-npm): mirror chmod-after-download fix from rewrite-microsandbox 2026-05-25 13:46:53 +03:00
Evgeny Boger
79d2458d64 ci(release-npm): mirror require-path fix from rewrite-microsandbox 2026-05-25 13:12:15 +03:00
Evgeny Boger
cb692b9e4a ci(release-npm): mirror fix for actions/download-artifact@v5 behaviour
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>
2026-05-25 13:05:25 +03:00
Evgeny Boger
fa839320cf ci: add release-npm + build-image workflows
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>
2026-05-25 12:46:38 +03:00
35 changed files with 4431 additions and 1208 deletions

15
.cargo/config.toml Normal file
View file

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

View file

@ -44,10 +44,26 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: Compute timestamp tag (UTC)
- name: Compute timestamp tag + source epoch
id: ts
run: |
# Immutable hourly pin (:YYYY-MM-DDTHH).
echo "tag=$(date -u +%Y-%m-%dT%H)" >> "$GITHUB_OUTPUT"
# SOURCE_DATE_EPOCH = HEAD commit time. Fed to buildkit (see
# the build step), it clamps the image config's `created`
# field and every history timestamp to a value that is
# CONSTANT across the hourly cron builds of a given commit.
# So two builds with the same commit + same agent versions +
# same layers produce a byte-identical config → an identical
# linux/amd64 manifest digest. microsandbox keys its
# fsmeta/VMDK materialization on THAT digest, so an unchanged
# `:latest` becomes a true no-op pull (0 bytes) instead of
# re-downloading the whole image. A new commit, or any changed
# agent layer, moves the digest as it should. Without this the
# config carried a fresh build timestamp every hour, so every
# cron `:latest` had a new manifest digest and consumers
# re-pulled ~700 MiB of byte-identical layers hourly.
echo "epoch=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
@ -59,8 +75,62 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Resolve agent versions
id: ver
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
fail() { echo "::error::$1"; exit 1; }
# Resolve each agent's current upstream version and feed it to
# the Dockerfile as a per-agent cache key (AGENT_VERSION_*), so
# an installer layer is rebuilt only when its agent actually
# released — not on every hourly cron run. Each source is the
# EXACT one the matching installer reads, so the key tracks
# what actually gets installed:
# - codex: openai/codex releases/latest tag — install.sh's
# own resolver hits the same endpoint.
# - opencode: anomalyco/opencode releases/latest tag — the
# repo opencode.ai/install downloads from (it was
# renamed from sst/opencode; sst/* only resolves
# today via GitHub's rename redirect, so pin the
# real repo).
# - claude: downloads.claude.ai native channel `/latest`
# (a plain version string) — what claude.ai/
# install.sh installs. NOT npm: the npm dist-tags
# (@anthropic-ai/claude-code) move on a separate
# cadence (e.g. stable != latest), so keying on
# npm would miss native releases / rebuild for
# npm-only bumps.
# Any lookup failure fails the build (vs shipping a stale key
# that would skip a real agent update).
codex=$(gh api repos/openai/codex/releases/latest --jq .tag_name) \
|| fail "codex version lookup failed (openai/codex releases/latest)"
opencode=$(gh api repos/anomalyco/opencode/releases/latest --jq .tag_name) \
|| fail "opencode version lookup failed (anomalyco/opencode releases/latest)"
claude=$(curl -fsSL https://downloads.claude.ai/claude-code-releases/latest) \
|| fail "claude version lookup failed (downloads.claude.ai/claude-code-releases/latest)"
# Guard against an HTTP-200-but-garbage response (empty body,
# jq 'null', or an HTML error page) silently becoming a key.
[ -n "$codex" ] && [ "$codex" != null ] || fail "codex version empty/null"
[ -n "$opencode" ] && [ "$opencode" != null ] || fail "opencode version empty/null"
case "$claude" in [0-9]*.[0-9]*.[0-9]*) : ;; *) fail "claude version implausible: '$claude'" ;; esac
{
echo "codex=$codex"
echo "opencode=$opencode"
echo "claude=$claude"
} >> "$GITHUB_OUTPUT"
echo "resolved agent versions: codex=$codex opencode=$opencode claude=$claude"
- name: Build and push
id: build
env:
# Clamps buildkit's image-config `created` + history
# timestamps to the commit time (see "Compute timestamp tag +
# source epoch") so an unchanged build yields an identical
# manifest digest. docker/build-push-action forwards
# SOURCE_DATE_EPOCH from the environment to buildkit.
SOURCE_DATE_EPOCH: ${{ steps.ts.outputs.epoch }}
uses: docker/build-push-action@v7
with:
context: images
@ -78,12 +148,51 @@ jobs:
# past that for binary-heavy content.
# microsandbox's `tar_ingest.rs:427` already accepts the
# `application/vnd.oci.image.layer.v1.tar+zstd` media type.
outputs: type=registry,push=true,compression=zstd,compression-level=3,force-compression=true
#
# `rewrite-timestamp=true` clamps the mtime of every file
# INSIDE each layer tar to SOURCE_DATE_EPOCH (the commit time).
# SOURCE_DATE_EPOCH alone only fixes the image config; layer
# file mtimes default to build wall-clock, so a `cache-from`
# MISS (gha eviction / size cap / a base-layer invalidation)
# would re-run the apt/curl RUN steps and emit layers with
# fresh mtimes → new diff_ids → new manifest digest for an
# unchanged commit, re-introducing the ~700 MiB consumer
# re-pull. Clamping mtimes makes the layer digests — and thus
# the manifest digest — reproducible independent of cache
# state, so the digest only moves on a real content change.
outputs: type=registry,push=true,compression=zstd,compression-level=3,force-compression=true,rewrite-timestamp=true
# Per-agent version cache keys. `Resolve agent versions`
# above looks up each agent's current upstream version; the
# Dockerfile references these in the matching installer RUN,
# so a layer is rebuilt ONLY when that agent actually
# released — not on every hourly cron run. This keeps an
# unchanged hourly build a pure cache hit (identical
# agent-layer digests → `:latest` consumers re-pull nothing)
# instead of re-emitting ~200 MiB of fresh-but-identical
# agent layers every hour. The heavy apt/chromium/docker/
# node layers below the args stay cached regardless.
build-args: |
AGENT_VERSION_CODEX=${{ steps.ver.outputs.codex }}
AGENT_VERSION_OPENCODE=${{ steps.ver.outputs.opencode }}
AGENT_VERSION_CLAUDE=${{ steps.ver.outputs.claude }}
# `cache-from`/`cache-to` keep layer rebuilds fast when only
# the agent-version layers at the top of the Dockerfile
# change (the common hourly case).
cache-from: type=gha
cache-to: type=gha,mode=max
# No provenance/SBOM attestation. Either one makes buildx wrap
# even a single-platform build in an OCI index whose extra
# attestation manifest embeds the build time, so the index
# digest changes every build even when the image content is
# identical — which would keep `:latest`'s digest churning
# hourly (and nag the update banner) despite a stable
# linux/amd64 manifest. We don't consume either anywhere.
# Both are pinned off (provenance defaults to mode=min on push;
# sbom defaults off but is pinned so a future default flip
# can't silently re-introduce the index). The "Show pushed
# digest" step asserts the push stayed a single manifest.
provenance: false
sbom: false
# Moving tags (`:latest`, `:YYYY-MM-DDTHH`) are gated to
# the integration branches (`main`, `rewrite-microsandbox`)
# so workflow_dispatch on a feature branch can verify the
@ -100,15 +209,40 @@ jobs:
${{ env.IMAGE }}:sha-${{ github.sha }}
# Tag with the short SHA too so we can trace any image
# back to the exact Dockerfile commit.
# NOTE: no `org.opencontainers.image.created` label. It was
# set to the per-hour timestamp and lived in the image config,
# so it changed the manifest digest every cron build even with
# identical content (the root cause of the hourly re-pull). The
# config still carries a `created` field, but clamped to the
# commit time via SOURCE_DATE_EPOCH, so it's stable per commit.
# `revision` (the commit sha) is stable across a commit's
# hourly builds, so it doesn't reintroduce churn.
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.created=${{ steps.ts.outputs.tag }}
- name: Show pushed digest
- name: Show pushed digest + assert single manifest
run: |
echo "Pushed ${{ env.IMAGE }}:${{ steps.ts.outputs.tag }}"
echo " digest: ${{ steps.build.outputs.digest }}"
# Guard the digest-stability invariant: the push must be a
# single image manifest, NOT a multi-arch index. An index
# (from a re-enabled provenance/sbom attestation, or a buildx
# default flip) carries a build-time-varying attestation digest
# and would silently restore the hourly `:latest` churn this
# workflow exists to prevent. The immutable `:sha-…` tag is
# always pushed (on every branch), so inspect that.
ref="${IMAGE}:sha-${{ github.sha }}"
if mt=$(docker buildx imagetools inspect --raw "$ref" 2>/dev/null | jq -r '.mediaType'); then
echo " pushed mediaType: $mt"
case "$mt" in
*manifest.list*|*image.index*)
echo "::error::pushed an index ($mt) — an attestation leaked back in; :latest digest will churn hourly. Ensure provenance:false + sbom:false."
exit 1 ;;
esac
else
echo "::warning::could not inspect $ref to assert single-manifest; skipping the check"
fi
retain:
# Delete date-tagged image versions older than 14 days so the

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

@ -0,0 +1,60 @@
name: CI
on:
push:
pull_request:
permissions:
contents: read
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Install stable Rust
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Cache cargo
uses: Swatinem/rust-cache@v2
- name: Install build dependencies
run: |
sudo apt-get update
sudo apt-get install -y pkg-config libcap-ng-dev libdbus-1-dev musl-tools
# The microsandbox SDK embeds the guest agentd binary. agent-vm builds it
# from the pinned source (we dropped the upstream prebuilt download), so
# the static musl agentd must exist at vendor/microsandbox/build/agentd
# before any `cargo build -p agent-vm`. Mirrors the release-npm workflow.
- name: Build guest agentd
run: |
rustup target add x86_64-unknown-linux-musl
cargo build --release \
--manifest-path vendor/microsandbox/crates/agentd/Cargo.toml \
--target-dir vendor/microsandbox/target \
--target x86_64-unknown-linux-musl
mkdir -p vendor/microsandbox/build
cp vendor/microsandbox/target/x86_64-unknown-linux-musl/release/agentd \
vendor/microsandbox/build/agentd
touch vendor/microsandbox/build/agentd
- name: Build
run: cargo build --release -p agent-vm
- name: Test
run: cargo test -p agent-vm
- name: Format check
continue-on-error: true
run: cargo fmt --all -- --check
- name: Clippy
continue-on-error: true
run: cargo clippy -p agent-vm --release -- -D warnings

View file

@ -62,7 +62,22 @@ jobs:
- platform: linux-x64
runner: ubuntu-latest
cargo_target: x86_64-unknown-linux-gnu
# linux-arm64 is cross-compiled on the x64 runner (GitHub's
# hosted arm64 Linux runners aren't on the free tier).
# `gcc-aarch64-linux-gnu` provides the cross linker (wired
# up by the repo .cargo/config.toml); the arm64 dev libs the
# final link needs come in via `dpkg --add-architecture
# arm64` (see the install step).
- platform: linux-arm64
runner: ubuntu-latest
cargo_target: aarch64-unknown-linux-gnu
cross: true
runs-on: ${{ matrix.runner }}
# The arm64 (cross) leg is not yet shippable — its libkrunfw kernel
# config seed hasn't been ported (see build-libkrunfw) and the arm64
# multiarch dev-lib install is still flaky. Let cross legs fail without
# blocking the x64 release; they re-enable automatically once green.
continue-on-error: ${{ matrix.cross || false }}
steps:
- uses: actions/checkout@v5
# No submodules — the agent-vm crate doesn't need
@ -75,12 +90,28 @@ jobs:
submodules: 'recursive'
- name: Install Rust toolchain + linux deps
env:
CROSS: ${{ matrix.cross && '1' || '' }}
run: |
set -euo pipefail
rustup toolchain install stable --profile minimal --no-self-update
rustup target add ${{ matrix.cargo_target }}
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libcap-ng-dev libdbus-1-dev libsqlite3-dev pkg-config
if [[ -n "$CROSS" ]]; then
# Enable the arm64 multiarch repo, then pull the cross
# linker + arm64 builds of the C libs agent-vm /
# microsandbox link (libcap-ng, libdbus, libsqlite3).
# Without the `:arm64` dev libs the final link can't
# resolve -lcap-ng / -ldbus-1 / -lsqlite3 for the target.
sudo dpkg --add-architecture arm64
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
gcc-aarch64-linux-gnu pkg-config \
libcap-ng-dev:arm64 libdbus-1-dev:arm64 libsqlite3-dev:arm64
else
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libcap-ng-dev libdbus-1-dev libsqlite3-dev pkg-config
fi
- name: Cargo cache (rust-cache)
uses: Swatinem/rust-cache@v2
@ -109,8 +140,40 @@ jobs:
cache-all-crates: "true"
cache-workspace-crates: "true"
- name: Build agentd from source (musl) so the SDK embeds the matching guest agent
run: |
# agent-vm depends on the microsandbox SDK with the `prebuilt`
# feature off, so the filesystem crate embeds build/agentd at
# compile time instead of downloading a release prebuilt. Build
# the static-musl guest agent from the pinned source first, or
# filesystem's build.rs panics ("agentd binary not found ... Run
# `just build-deps`"). Mirrors build-msb's identical step.
rustup target add x86_64-unknown-linux-musl
sudo apt-get install -y --no-install-recommends musl-tools
cargo build --release \
--manifest-path vendor/microsandbox/crates/agentd/Cargo.toml \
--target-dir vendor/microsandbox/target \
--target x86_64-unknown-linux-musl
mkdir -p vendor/microsandbox/build
cp vendor/microsandbox/target/x86_64-unknown-linux-musl/release/agentd \
vendor/microsandbox/build/agentd
touch vendor/microsandbox/build/agentd
- name: cargo build --release -p agent-vm
run: cargo build --release --target ${{ matrix.cargo_target }} -p agent-vm
env:
# Cross pkg-config: point at the arm64 .pc files and let
# pkg-config run host→target (the `pkg-config` crate refuses
# cross queries unless PKG_CONFIG_ALLOW_CROSS=1). No-ops on
# the native x64 leg (CROSS unset).
CROSS: ${{ matrix.cross && '1' || '' }}
run: |
set -euo pipefail
if [[ -n "$CROSS" ]]; then
export PKG_CONFIG_ALLOW_CROSS=1
export PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig
export PKG_CONFIG_SYSROOT_DIR=/
fi
cargo build --release --target ${{ matrix.cargo_target }} -p agent-vm
- name: Upload agent-vm binary
uses: actions/upload-artifact@v7
@ -129,7 +192,16 @@ jobs:
- platform: linux-x64
runner: ubuntu-latest
cargo_target: x86_64-unknown-linux-gnu
# linux-arm64 cross-build — see build-agent-vm for the
# rationale (no free hosted arm64 Linux runner).
- platform: linux-arm64
runner: ubuntu-latest
cargo_target: aarch64-unknown-linux-gnu
cross: true
runs-on: ${{ matrix.runner }}
# Cross (arm64) leg is best-effort until arm64 is shippable — see
# build-agent-vm. Fail-tolerated so it never blocks the x64 release.
continue-on-error: ${{ matrix.cross || false }}
env:
# Override vendor/microsandbox's `lto=true, codegen-units=1`
# release profile for CI. Keeps `panic=abort` (changes
@ -138,18 +210,34 @@ jobs:
# runtime cost on a TLS-proxy workload.
CARGO_PROFILE_RELEASE_LTO: "thin"
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16"
# The superproject's .cargo/config.toml sets the aarch64 linker,
# but it is NOT on the config search path when building from
# vendor/microsandbox (a separate workspace), so set the linker
# via env here too. No-op on the native x64 leg.
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
steps:
- uses: actions/checkout@v5
with:
submodules: 'recursive'
- name: Install Rust toolchain + linux deps
env:
CROSS: ${{ matrix.cross && '1' || '' }}
run: |
set -euo pipefail
rustup toolchain install stable --profile minimal --no-self-update
rustup target add ${{ matrix.cargo_target }}
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libcap-ng-dev libdbus-1-dev libsqlite3-dev pkg-config
if [[ -n "$CROSS" ]]; then
sudo dpkg --add-architecture arm64
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
gcc-aarch64-linux-gnu pkg-config \
libcap-ng-dev:arm64 libdbus-1-dev:arm64 libsqlite3-dev:arm64
else
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libcap-ng-dev libdbus-1-dev libsqlite3-dev pkg-config
fi
- name: Cargo cache (rust-cache)
uses: Swatinem/rust-cache@v2
@ -170,10 +258,45 @@ jobs:
cache-all-crates: "true"
cache-workspace-crates: "true"
- name: cargo build --release -p microsandbox-cli --bin msb
- name: Build agentd from source (musl) so msb embeds the matching guest agent
run: |
# agentd (the guest PID-1 agent) is embedded into msb and injected
# at boot. Build it from the pinned source rather than downloading a
# release prebuilt, so the producer (msb) and consumer (agentd)
# always ship in lockstep — e.g. the boot-params side channel needs
# both halves at the same revision; a stale prebuilt agentd would
# silently drop every mount. Static musl: agentd runs before the
# rootfs is fully set up.
rustup target add x86_64-unknown-linux-musl
sudo apt-get install -y --no-install-recommends musl-tools
cargo build --release \
--manifest-path vendor/microsandbox/crates/agentd/Cargo.toml \
--target-dir vendor/microsandbox/target \
--target x86_64-unknown-linux-musl
mkdir -p vendor/microsandbox/build
cp vendor/microsandbox/target/x86_64-unknown-linux-musl/release/agentd \
vendor/microsandbox/build/agentd
touch vendor/microsandbox/build/agentd
- name: cargo build --release -p microsandbox-cli --bin msb
env:
CROSS: ${{ matrix.cross && '1' || '' }}
run: |
set -euo pipefail
# Cross-compiling msb for aarch64 needs pkg-config pointed at the
# arm64 sysroot so the C deps (libcap-ng, dbus) resolve.
if [[ -n "$CROSS" ]]; then
export PKG_CONFIG_ALLOW_CROSS=1
export PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig
export PKG_CONFIG_SYSROOT_DIR=/
fi
# --no-default-features drops the `prebuilt` feature, so the
# filesystem crate embeds the build/agentd compiled just above
# instead of downloading a release prebuilt. net + keyring are the
# other CLI defaults and stay on.
cargo build --release --target ${{ matrix.cargo_target }} \
--manifest-path vendor/microsandbox/Cargo.toml \
--no-default-features --features net,keyring \
-p microsandbox-cli --bin msb
- name: Upload msb binary
@ -199,7 +322,19 @@ jobs:
- platform: linux-x64
runner: ubuntu-latest
arch: x86_64
# linux-arm64: cross-build the kernel with ARCH=arm64 +
# CROSS_COMPILE=aarch64-linux-gnu-. Needs the arm64 kbuild
# .config seed patch (libkrunfw-overrides/
# config-libkrunfw_aarch64.patch); the build step fails fast
# with a clear message if that seed hasn't been ported yet.
- platform: linux-arm64
runner: ubuntu-latest
arch: aarch64
cross: true
runs-on: ${{ matrix.runner }}
# Cross (arm64) leg fails fast until config-libkrunfw_aarch64.patch is
# ported (see the build step). Fail-tolerated so it never blocks x64.
continue-on-error: ${{ matrix.cross || false }}
steps:
- uses: actions/checkout@v5
# Need the patch file + LIBKRUNFW_VERSION constant. Submodules
@ -231,12 +366,20 @@ jobs:
- name: Install kernel build deps
if: steps.lk-cache.outputs.cache-hit != 'true'
env:
CROSS: ${{ matrix.cross && '1' || '' }}
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
build-essential bc flex bison libelf-dev libssl-dev \
kmod cpio bzip2 xz-utils python3 python3-pyelftools \
curl ca-certificates patch
if [[ -n "$CROSS" ]]; then
# Cross toolchain for the arm64 kernel build.
sudo apt-get install -y --no-install-recommends \
gcc-aarch64-linux-gnu
fi
- name: Clone + patch + build libkrunfw
if: steps.lk-cache.outputs.cache-hit != 'true'
@ -244,11 +387,24 @@ jobs:
env:
LK_V: ${{ steps.lkv.outputs.libkrunfw_version }}
ARCH: ${{ matrix.arch }}
CROSS: ${{ matrix.cross && '1' || '' }}
run: |
set -euo pipefail
# The arm64 leg cross-compiles the kernel. It needs an arm64
# kbuild .config seed; bail early with a clear message rather
# than silently emit a kernel built with the wrong config.
if [[ -n "$CROSS" && ! -f "libkrunfw-overrides/config-libkrunfw_${ARCH}.patch" ]]; then
echo "::error::libkrunfw-overrides/config-libkrunfw_${ARCH}.patch is missing — the arm64 libkrunfw kernel config seed has not been ported yet (see B2 notes)."
exit 1
fi
git clone --branch "v${LK_V}" --depth 1 \
https://github.com/containers/libkrunfw.git libkrunfw-src
cd libkrunfw-src
# Cross build env for arm64: make picks up ARCH/CROSS_COMPILE.
if [[ -n "$CROSS" ]]; then
export ARCH=arm64
export CROSS_COMPILE=aarch64-linux-gnu-
fi
# 1) kbuild .config seed (CONFIG_KVM=y + Intel/AMD backends).
patch -p1 < ../libkrunfw-overrides/config-libkrunfw_${ARCH}.patch
# 2) Kernel source patches: libkrunfw's Makefile runs
@ -303,7 +459,15 @@ jobs:
- platform: linux-x64
runner: ubuntu-latest
libkrunfw_arch: x86_64
- platform: linux-arm64
runner: ubuntu-latest
libkrunfw_arch: aarch64
cross: true
runs-on: ${{ matrix.runner }}
# arm64 has no build artifacts until that leg is shippable; its
# download steps fail. Fail-tolerated so it never blocks the x64
# subpackage (see build-agent-vm).
continue-on-error: ${{ matrix.cross || false }}
steps:
- uses: actions/checkout@v5
# We need vendor/microsandbox source to read LIBKRUNFW_VERSION
@ -396,10 +560,26 @@ jobs:
steps:
- uses: actions/checkout@v5
# No `registry-url:` on purpose — that input makes setup-node write
# `//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}` into .npmrc.
# With trusted publishing we have no token, so that line resolves to
# an empty authToken and npm errors `ENEEDAUTH` instead of falling
# back to OIDC. Without it, npm uses the default registry
# (registry.npmjs.org) and the OIDC trusted-publishing flow.
- uses: actions/setup-node@v5
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
# npm trusted publishing (OIDC) needs npm >= 11.5.1; node 22 ships
# npm 10.x. With this + `id-token: write` (set at the workflow level)
# and a trusted publisher configured on npmjs.com for each package,
# `npm publish` exchanges the GitHub OIDC token for short-lived
# registry credentials — no long-lived NPM_TOKEN secret — and attaches
# provenance automatically.
- name: Upgrade npm for OIDC trusted publishing (>= 11.5.1)
run: |
npm install -g npm@latest
npm --version
# Download each platform artifact into its own subpackage
# directory explicitly. `actions/download-artifact@v8` with
@ -419,6 +599,16 @@ jobs:
name: agent-vm-linux-x64
path: npm-dist/agent-vm-linux-x64/
- name: Download linux-arm64 artifact
# arm64 may not have been built this release (cross legs are
# best-effort until shippable). Tolerate a missing artifact; the
# publish loop below skips any platform without a built binary.
continue-on-error: true
uses: actions/download-artifact@v8
with:
name: agent-vm-linux-arm64
path: npm-dist/agent-vm-linux-arm64/
- name: Verify artifact layout + restore executable bits
# `actions/upload-artifact@v7` packs files into a zip that
# doesn't preserve POSIX permissions. After download, the
@ -430,7 +620,14 @@ jobs:
run: |
set -e
for d in npm-dist/agent-vm-*-*/bin; do
test -f "$d/agent-vm" || { echo "missing $d/agent-vm"; exit 1; }
# Skip platforms not built this release (e.g. arm64 while its
# libkrunfw config is unported). The bin/ dir exists from the
# checkout (a .gitkeep placeholder) but carries no binary; the
# publish loop below skips these dirs too.
if [[ ! -f "$d/agent-vm" ]]; then
echo "::notice::$d has no built binary; skipping (platform not built this release)"
continue
fi
test -f "$d/msb" || { echo "missing $d/msb"; exit 1; }
chmod +x "$d/agent-vm" "$d/msb"
done
@ -457,8 +654,9 @@ jobs:
done
- name: Publish platform subpackages first
# No NODE_AUTH_TOKEN — auth is via OIDC trusted publishing (see the
# setup-node note above and the workflow-level `id-token: write`).
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
V: ${{ needs.resolve-version.outputs.version }}
run: |
set -e
@ -471,6 +669,15 @@ jobs:
# — avoiding a split-state where subpackages exist at v but
# the main package never makes it.
for d in npm-dist/agent-vm-*-*; do
# Skip platforms not built this release (e.g. arm64 while its
# libkrunfw config is unported). The subpackage dir exists from
# the checkout but carries no binary; publishing it would ship a
# broken, binary-less package. The main package lists it as a
# cpu-gated optionalDependency, so its absence is harmless.
if [[ ! -f "$d/bin/agent-vm" ]]; then
echo "::notice::$d has no built binary; skipping (platform not built this release)"
continue
fi
# Node's `require` only resolves bare-relative paths
# with a leading `./` — without it the loader treats the
# argument as a node_modules / built-in spec and bails.
@ -484,8 +691,8 @@ jobs:
done
- name: Publish main package
# OIDC trusted publishing — no NODE_AUTH_TOKEN (see above).
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
V: ${{ needs.resolve-version.outputs.version }}
run: |
set -e

5
.gitignore vendored
View file

@ -6,3 +6,8 @@ Cargo.lock.bak
# Project-local agent hook (carries over from the original agent-vm layout).
.claude-vm.runtime.sh
.agent-vm.runtime.sh
# Per-user Claude Code permissions / settings (may carry secrets;
# never commit). Use .claude/settings.json for shared, reviewed
# settings instead.
.claude/settings.local.json

64
AGENTS.md Normal file
View file

@ -0,0 +1,64 @@
# AGENTS.md — conventions for coding agents working on this repo
Things that aren't obvious from the code and that I keep forgetting to
tell you. Read once, then act on them silently.
## After merging a feature branch, bump the workspace version
Every merge into `rewrite-microsandbox` ships with a
`workspace.package.version` bump in the root `Cargo.toml` and a
follow-up `vX.Y.Z: bump for <feature>` commit. Skipping this leaves
the next release boundary ambiguous and means downstream
`agent-vm --version` lies about what's in the binary.
Convention (look at `git log --oneline | grep "^[a-f0-9]* v"`):
```
git merge --no-ff <feature-branch> # produces "Merge ...: ..."
$EDITOR Cargo.toml # version = "0.1.N+1"
git commit -am "v0.1.N+1: bump for <one-line feature>"
```
`Cargo.lock` will need refreshing — run a build after the bump to
update it, then commit the lock alongside the version bump if it
moved (it always does).
## Submodule merges go first
`vendor/microsandbox` is a submodule with its own branches. When a
worktree changes both the agent-vm code and the vendored microsandbox
code, merge inside the submodule **before** merging the superproject —
otherwise the superproject merge will conflict on the gitlink and
you'll have to redo the submodule merge anyway. Pattern:
1. `cd vendor/microsandbox && git merge --no-ff <subm-feature-branch>`
2. `cd ../.. && git add vendor/microsandbox` (bumps the gitlink)
3. `git merge --no-ff <agent-vm-feature-branch>`
(resolves the gitlink conflict to the merge SHA from step 1)
If the feature branch lives in a separate git worktree, the
submodule branches in that worktree's `.git/modules/...` are not
visible from the main worktree. Push them across with
`git -C <worktree>/vendor/microsandbox push <main-worktree>/.git/modules/vendor/microsandbox <branch>:<branch>`
before attempting the submodule merge.
## Don't relocate build output to `/tmp` or `/dev/shm`
If a build is too big, slow, or runs out of inodes, fix the root
cause. Don't sidestep by pointing `CARGO_TARGET_DIR` at tmpfs — that
loses everything on reboot, masks real disk pressure, and the next
agent will spend an hour relinking from cold.
## Don't `rm -rf` state directories from the assistant turn
Claude Code prompts on every `rm -rf` and it's painful. The repo
ships `/tmp/clean-state.sh` for state cleanup — use it, or write a
new short script if it doesn't cover your case. Inline `rm -rf` in
tool calls is a UX papercut for the human, not a safety win.
## When in doubt about scope, read the prior commit messages
Commits on this branch use a multi-paragraph "Why / How" style with
real examples (often live e2e output). Match the style; don't write
single-line commits for non-trivial changes. The commit body is
where future-you (or future-me) recovers the reasoning.

633
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,7 @@ members = ["crates/agent-vm"]
exclude = ["vendor/microsandbox"]
[workspace.package]
version = "0.1.15"
version = "0.1.25"
edition = "2024"
license = "MIT"
repository = "https://github.com/wirenboard/agent-vm"

135
LAUNCH-PROFILE.md Normal file
View file

@ -0,0 +1,135 @@
# agent-vm launch profiling — findings
Host: AMD EPYC, 16 vCPU, nested virt (`/dev/kvm`, `kvm_amd.nested=1`). Image cached.
All headline numbers are **wall-clock**, from interleaved A/Bs (host drift cancels),
`drop_caches` between rounds where noted. Measured with `AGENT_VM_PROFILE=1` plus
sub-timers added to `run.rs` (pre-boot phases + build/spawn+boot+relay) and the
runtime's own `runtime.log` wall timestamps.
## TL;DR
A launch in a GitHub-remote repo broke down (default 2 vCPU / 2 GiB) as:
```
pre: session+reap ............... 0.3 ms
pre: update-check (ghcr HEAD) ... 886 ms ← notify_if_update_available (banner only)
pre: repo-detect ................ 2 ms
pre: secrets (gh auth token) .... 40 ms
pre: gh api user ................ 1290 ms ← v0.1.14 author identity ← THE regression
pre: TOTAL pre-boot ............. 2220 ms ← LARGER than the actual VM boot
create (guest kernel boot) ..... 1500 ms
run (incl. chrome certutil) .... 270 ms
stop / remove .................. 50 ms
TOTAL ~4.1 s
```
**~2.2s of the launch happens before the kernel even starts, and it's two uncached
blocking GitHub/registry network round-trips.** The original `[profile] create` timer
started *after* all of this, which is why earlier profiling missed it entirely.
## The regression: `gh api user` (pre-boot), not the kernel
`discover_host_git_identity()` (added in commit `0c3bb51`, **v0.1.14** — "bake host
gh/git identity into the guest gitconfig", the exact regression window) calls
**`gh api user` first**, an HTTPS round-trip to api.github.com, falling back to the
instant local gitconfig only if it fails. No cache. Measured ~1.261.31s here, every
launch. Adding ~1.29s to a ~1.3s baseline ≈ the reported 2.5s.
The ghcr.io **update-check** (`notify_if_update_available`, commit `bfab9d3`) is a second
per-launch blocking HEAD (~0.89s) purely to print a "newer image available" banner.
### Fixes (implemented on this branch, measured)
1. **Cache the resolved git identity** (`secrets.rs`, 24h TTL, validated strings only,
never tokens; preserves the canonical `gh` identity incl. `gh_login`). Pays the
`gh api user` cost once per day instead of every launch.
2. **Make the update-check non-blocking** (`run.rs`): spawn it concurrently with boot
instead of awaiting it. Banner still prints (during boot); never delays launch.
| | pre-boot | total wall |
|---|---|---|
| before | ~2220 ms | ~4100 ms |
| after, run 1 (cold identity cache) | ~1300 ms | ~3280 ms |
| **after, warm cache** | **~32 ms** | **~1900 ms** |
`gh api user` 1290ms → 22µs; update-check 886ms → ~0. **~2.19s off every launch after
the first.** No downside, no kernel rebuild. This alone undoes the regression.
## Secondary: the ~1.5s guest-kernel boot floor
`create` ≈ guest kernel boot (`build()` is ~3µs; `entering VM → agentd core.ready`).
The console (`hvc0`) attaches ~1.3s in, so early boot isn't visible in `kernel.log`;
the A/B below is the real attribution. Five+1 kernels built from one tree
(configs grepped, not assumed), 10 interleaved rounds @1 GiB, `drop_caches` per round:
| kernel | config | create mean ± sd | Δ |
|---|---|---|---|
| stock | upstream libkrunfw (no KVM, no netfilter) | 1.488 ± 0.10 s | — |
| stock+KVM | + CONFIG_KVM/KVM_INTEL/KVM_AMD | 1.587 ± 0.15 s | **+99 ms (KVM)** |
| heavy_nonf | KVM, netfilter off (+mqueue) | 1.615 ± 0.12 s | ≈ stock+KVM ✓ |
| heavy_legacy | + conntrack/NAT/iptables-legacy/bridge | 1.677 ± 0.03 s | **+62 ms (conntrack)** |
| heavy (current) | + full nf_tables/XT/IPv6/VLAN | 1.680 ± 0.03 s | +3 ms (nf_tables ≈ 0) |
| heavy+deferred | heavy + DEFERRED_STRUCT_PAGE_INIT | 1.632 ± 0.03 s | no help |
**The nested-virt kernel rebuild adds only ~190ms total to boot** (KVM ~100ms,
conntrack/iptables ~62ms, nf_tables ~3ms). So it's a real but *minor* secondary cost:
- **KVM (~100ms)** is *required* for nested virt — irremovable.
- **conntrack/iptables-legacy (~62ms)** is the unavoidable cost of docker bridge+SNAT
(the conntrack hashtable auto-sizes from RAM). With `CONFIG_MODULES is not set` +
`nomodule`, it can't be made a module — it's built-in or absent. Drop it only if you
don't need docker networking by default.
- **nf_tables/XT/IPv6/VLAN (~3ms)** — droppable, but saves essentially nothing. Not
worth the docker-iptables-nft→legacy fallback risk.
### Two levers that do NOT work (verified, don't pursue)
- **`CONFIG_DEFERRED_STRUCT_PAGE_INIT=y`**: no help at 1 GiB, slightly *worse* at 4 GiB.
Its `defer_init()` heuristic only defers past a 128 MB section threshold after low
zones init; a 14 GiB single-node guest has nothing to defer (it targets TB-scale RAM).
- **`split_irqchip`**: ~515ms swing in the runtime's `boot_time_ms` metric but **zero
wall-clock effect** (1.91s vs 1.88s create). `boot_time_ms` excludes ~0.9s of early
boot and is a misleading proxy — rank kernels on wall-clock `create` only. vCPU count:
also negligible (1/2/4 ≈ 1.84/1.84/1.89s).
## Other real levers
- **Guest memory** (real wall-clock, but EPT/page-materialization under nested virt, not
struct-page init): create ≈ 1.49s @1G / 1.681.92s @2G / 2.9s @4G. Lower the default
(`AGENT_VM_MEMORY_GIB`, currently 2) for sessions that don't need 2 GiB (~0.2s+).
- **Chrome-MCP CA `certutil` (~270ms)** in the `run` phase — **fixed on this branch.**
`run.rs` used to run `sudo -u chrome certutil -A …` synchronously in the in-guest
prelude before exec'ing the agent, on *every* launch. But chromium ignores the system
CA bundle (which `update-ca-certificates` already populates at boot) and only honors
its per-user NSS DB, so the CA must be imported there or chrome-devtools-mcp fails
every HTTPS page with `ERR_CERT_AUTHORITY_INVALID`. The cert can't be baked into the
shared image (the CA is generated per-install on the host); the import repeated every
launch only because the NSS DB lives in the ephemeral rootfs. **Fix:** moved the import
into the in-image `agent-vm-chrome-mcp` wrapper (`images/Dockerfile`) — it runs once at
MCP startup, as the `chrome` user that owns the DB, off the launch critical path and
skipped entirely when chrome is unused. Measured: `run` phase 310ms → ~38ms.
**Coupling:** the `run.rs` and Dockerfile changes must ship together — until the
template image is rebuilt with the new wrapper, dropping the prelude import would leave
chrome MCP without the CA.
## Recommended order (by impact × safety)
1. **Cache the git identity** + **non-blocking update-check** — ~2.19s, implemented here,
zero downside, no rebuild. This *is* the regression fix.
2. **Chrome-MCP certutil moved into the image wrapper** — ~270ms off the `run` phase,
implemented on this branch (run.rs + images/Dockerfile; ship together).
3. **Lower default guest memory** if 2 GiB is more than agents need — ~0.2s+.
4. Kernel: leave it. The ~190ms it adds is mostly required (KVM, conntrack). Do **not**
enable deferred-page-init or chase split_irqchip. Optionally drop nf_tables/IPv6 NF to
shave ~3ms only if you also accept iptables-legacy.
## Reproduce
```bash
cd <github-remote repo>
AGENT_VM_PROFILE=1 agent-vm shell true # prints pre-boot phases + create/run/stop
# pre-boot network cost, in isolation:
time gh api user >/dev/null ; time curl -sI https://ghcr.io/v2/wirenboard/agent-vm-template/manifests/latest
# kernel A/B: build variants from libkrunfw-src (one tree, grep each .config), swap the
# .so next to msb, measure interleaved with drop_caches — see measure_variants.sh.
```

858
PLAN.md
View file

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

View file

@ -153,6 +153,28 @@ the launcher sources it inside the guest before exec'ing the agent.
Use for `npm install`, env exports, dev-server startup. Non-zero
exit aborts the launch.
## Ports & egress
The default network policy (`public_only`) lets the guest reach
the public internet plus DNS, and denies everything else
(loopback, RFC1918 LAN, link-local, cloud-metadata, the host).
Open holes per-launch with these flags — they compose:
| flag | what it opens | guest-side address |
|---|---|---|
| `--publish HOST:GUEST[/proto]` | host port `HOST` → guest port `GUEST` (`tcp` default; `/udp` for UDP) | inbound to the guest |
| `--auto-publish` | every `0.0.0.0:*` / `127.0.0.1:*` listener inside the guest is mirrored to the host loopback (Lima-style) | host: `127.0.0.1:<guest-port>` |
| `--allow-egress IP\|CIDR` (repeatable) | one IP or one CIDR through the egress deny | dial directly by IP |
| `--allow-lan` | the whole `DestinationGroup::Private` (10/8, 172.16/12, 192.168/16, 100.64/10, fc00::/7) | dial any LAN IP |
| `--allow-host` | the per-sandbox gateway IP, which the smoltcp stack rewrites to host `127.0.0.1` | `host.microsandbox.internal:<port>` (already in guest `/etc/hosts`) |
Loopback (guest's own `127.0.0.1`), link-local, and cloud metadata
(`169.254.169.254`) stay denied even with `--allow-lan` — they're
disjoint groups by design. `--allow-host` is the narrowest way to
reach a dev server bound to host `127.0.0.1`; `--allow-lan` is the
broadest. A compromised in-guest process gets full access to
whatever you open, so prefer the narrowest flag that fits.
## Troubleshooting
- **`RegisterNetDevice(IrqsExhausted)` at boot** — the userspace split
@ -171,3 +193,6 @@ exit aborts the launch.
- [PLAN.md](PLAN.md) — phased roadmap, what's done, what's deferred.
- [ARCHITECTURE.md](ARCHITECTURE.md) — design notes; why things look
the way they do.
- [AGENTS.md](AGENTS.md) — conventions for coding agents (Claude
Code, Codex, etc.) working on this repo: post-merge version bump,
submodule-merge ordering, what not to do.

View file

@ -1,6 +1,6 @@
[package]
name = "agent-vm"
description = "Sandboxed VMs for AI coding agents, built on microsandbox."
description = "Sandboxed microVMs for AI coding agents, built on microsandbox."
version.workspace = true
edition.workspace = true
license.workspace = true
@ -11,9 +11,15 @@ name = "agent-vm"
path = "src/main.rs"
[dependencies]
microsandbox = { path = "../../vendor/microsandbox/crates/microsandbox" }
tokio = { version = "1.42", features = ["macros", "rt-multi-thread"] }
clap = { version = "4.5", features = ["derive", "env"] }
# default-features = false drops the SDK's `prebuilt` feature, so building
# agent-vm never downloads the upstream msb+libkrunfw bundle — we ship our own
# from-source msb. Keep net + keyring (the SDK's other defaults).
microsandbox = { path = "../../vendor/microsandbox/sdk/rust", default-features = false, features = ["net", "keyring"] }
tokio = { version = "1.42", features = ["macros", "rt-multi-thread", "time"] }
# `wrap_help` makes clap detect the terminal width and wrap long help
# text to it; without it the multi-paragraph `--help` prose renders as
# unbroken one-line-per-paragraph walls.
clap = { version = "4.5", features = ["derive", "env", "wrap_help"] }
anyhow = "1.0"
base64 = "0.22"
sha2 = "0.10"
@ -24,6 +30,7 @@ indicatif = "0.18"
console = "0.16"
reqwest = { version = "0.13", default-features = false, features = ["rustls", "json"] }
libc = "0.2"
ipnetwork = "0.21"
# Integration tests that drive the actual secret-substitution layer
# end-to-end with the hook's anonymisation logic. Kept out of release

View file

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

View file

@ -53,7 +53,24 @@ pub async fn check_for_update(image_ref: &str) -> Result<Option<UpdateState>> {
/// on any failure so callers can decide what to do with the silence.
pub async fn fetch_remote_digest(image_ref: &str) -> Option<String> {
let parsed = ParsedRef::parse(image_ref)?;
remote_manifest_digest(&parsed).await.ok().flatten()
match remote_manifest_digest(&parsed).await {
Ok(Some(d)) => {
tracing::debug!(image = %image_ref, digest = %d, "registry update probe");
Some(d)
}
Ok(None) => {
// Reachable registry, but we couldn't pin a comparable
// digest (no matching platform entry, private image we
// can't auth to, etc.). Stay quiet at launch.
tracing::debug!(image = %image_ref, "registry update probe: no comparable digest");
None
}
Err(e) => {
// Offline / DNS / TLS — expected sometimes; never fatal.
tracing::debug!(image = %image_ref, error = %e, "registry update probe failed");
None
}
}
}
async fn remote_manifest_digest(parsed: &ParsedRef) -> Result<Option<String>> {
@ -79,22 +96,10 @@ async fn remote_manifest_digest(parsed: &ParsedRef) -> Result<Option<String>> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()?;
let resp = client
.get(&url)
.header(
"Accept",
concat!(
"application/vnd.docker.distribution.manifest.v2+json,",
"application/vnd.docker.distribution.manifest.list.v2+json,",
"application/vnd.oci.image.manifest.v1+json,",
"application/vnd.oci.image.index.v1+json",
),
)
.send()
.await?;
if !resp.status().is_success() {
let resp = get_manifest_with_auth(&client, &url).await?;
let Some(resp) = resp else {
return Ok(None);
}
};
let direct_digest = resp
.headers()
.get("docker-content-digest")
@ -129,6 +134,145 @@ async fn remote_manifest_digest(parsed: &ParsedRef) -> Result<Option<String>> {
Ok(direct_digest)
}
/// The Accept set every manifest GET sends — both the v2 single-arch
/// manifest media types and the multi-arch index types, so the registry
/// hands back the index when one exists.
const MANIFEST_ACCEPT: &str = concat!(
"application/vnd.docker.distribution.manifest.v2+json,",
"application/vnd.docker.distribution.manifest.list.v2+json,",
"application/vnd.oci.image.manifest.v1+json,",
"application/vnd.oci.image.index.v1+json",
);
/// GET a manifest, performing the registry token handshake when the
/// registry demands one.
///
/// Token-auth registries (ghcr.io, Docker Hub, …) answer an anonymous
/// manifest GET with `401` and a `WWW-Authenticate: Bearer realm=...,
/// service=...,scope=...` challenge — even for *public* images. The
/// caller must then fetch a bearer token from `realm` and retry. The
/// previous implementation skipped this entirely and treated the `401`
/// as "registry unreachable", so the update-available banner never
/// fired for the default `ghcr.io/...` image.
///
/// Returns `Ok(None)` on any non-success (offline, private image we
/// can't auth to, malformed challenge) so the launch path stays quiet
/// rather than failing.
async fn get_manifest_with_auth(
client: &reqwest::Client,
url: &str,
) -> Result<Option<reqwest::Response>> {
let first = client
.get(url)
.header("Accept", MANIFEST_ACCEPT)
.send()
.await?;
tracing::debug!(%url, status = %first.status(), "manifest GET (unauthenticated)");
if first.status().is_success() {
return Ok(Some(first));
}
if first.status() != reqwest::StatusCode::UNAUTHORIZED {
return Ok(None);
}
// Parse the Bearer challenge and fetch an (anonymous) token.
let Some(challenge) = first
.headers()
.get(reqwest::header::WWW_AUTHENTICATE)
.and_then(|v| v.to_str().ok())
.and_then(BearerChallenge::parse)
else {
return Ok(None);
};
let Some(token) = fetch_bearer_token(client, &challenge).await? else {
return Ok(None);
};
let authed = client
.get(url)
.header("Accept", MANIFEST_ACCEPT)
.bearer_auth(token)
.send()
.await?;
if authed.status().is_success() {
Ok(Some(authed))
} else {
Ok(None)
}
}
/// The pieces of a `WWW-Authenticate: Bearer ...` challenge we need to
/// mint a token.
struct BearerChallenge {
realm: String,
service: Option<String>,
scope: Option<String>,
}
impl BearerChallenge {
/// Parse `Bearer realm="https://ghcr.io/token",service="ghcr.io",
/// scope="repository:owner/name:pull"`. Returns None if it isn't a
/// Bearer challenge or has no `realm` (without which we can't fetch
/// a token).
fn parse(header: &str) -> Option<Self> {
let rest = header.strip_prefix("Bearer ").or_else(|| header.strip_prefix("bearer "))?;
let mut realm = None;
let mut service = None;
let mut scope = None;
// Params are comma-separated `key="value"` pairs. Values are
// quoted and never contain a quote themselves in registry
// challenges, so a simple split on `="` / `"` is enough.
for part in rest.split(',') {
let part = part.trim();
let Some((key, val)) = part.split_once('=') else {
continue;
};
let val = val.trim().trim_matches('"').to_string();
match key.trim() {
"realm" => realm = Some(val),
"service" => service = Some(val),
"scope" => scope = Some(val),
_ => {}
}
}
Some(Self {
realm: realm?,
service,
scope,
})
}
}
/// Exchange a Bearer challenge for an anonymous token at `realm`. The
/// token endpoint returns JSON with either `token` or `access_token`.
async fn fetch_bearer_token(
client: &reqwest::Client,
challenge: &BearerChallenge,
) -> Result<Option<String>> {
let mut query: Vec<(&str, &str)> = Vec::new();
if let Some(service) = &challenge.service {
query.push(("service", service.as_str()));
}
if let Some(scope) = &challenge.scope {
query.push(("scope", scope.as_str()));
}
let resp = client.get(&challenge.realm).query(&query).send().await?;
if !resp.status().is_success() {
return Ok(None);
}
let body: serde_json::Value = match resp.json().await {
Ok(v) => v,
Err(_) => return Ok(None),
};
let token = body
.get("token")
.or_else(|| body.get("access_token"))
.and_then(|v| v.as_str())
// An empty token would just produce `Authorization: Bearer ` and
// a guaranteed re-401; treat it as "no usable token".
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
Ok(token)
}
struct ParsedRef {
host: String,
name: String,
@ -199,4 +343,36 @@ mod tests {
assert_eq!(p.tag, "v1");
assert!(!p.is_insecure);
}
#[test]
fn parses_ghcr_bearer_challenge() {
// The exact header ghcr.io returns for an anonymous manifest GET.
let c = BearerChallenge::parse(
r#"Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:wirenboard/agent-vm-template:pull""#,
)
.unwrap();
assert_eq!(c.realm, "https://ghcr.io/token");
assert_eq!(c.service.as_deref(), Some("ghcr.io"));
assert_eq!(
c.scope.as_deref(),
Some("repository:wirenboard/agent-vm-template:pull")
);
}
#[test]
fn bearer_challenge_realm_only() {
// service/scope are optional; realm is the one hard requirement.
let c = BearerChallenge::parse(r#"Bearer realm="https://auth.docker.io/token""#).unwrap();
assert_eq!(c.realm, "https://auth.docker.io/token");
assert!(c.service.is_none());
assert!(c.scope.is_none());
}
#[test]
fn non_bearer_challenge_rejected() {
// Basic-auth challenge (or a Bearer challenge missing realm) is
// unusable — we can't mint a token, so parse returns None.
assert!(BearerChallenge::parse(r#"Basic realm="registry""#).is_none());
assert!(BearerChallenge::parse(r#"Bearer service="ghcr.io""#).is_none());
}
}

View file

@ -681,14 +681,50 @@ fn write_response(bytes: &[u8]) -> Result<()> {
Ok(())
}
/// Public entry point for an Anthropic OAuth-refresh interception.
///
/// Thin wrapper that injects the real side effects — spawn the host
/// `claude` CLI to rotate the host credential file, then read that
/// file back — and hands the rotated JSON to [`rotate_anthropic`],
/// which holds the actual rotation logic (token-file rewrite +
/// placeholder response synthesis). Keeping the side effects out of
/// `rotate_anthropic` is what makes the rotation path deterministically
/// testable without a live `claude` session or a real expiring token
/// (PLAN.md A1).
fn refresh_anthropic(state_dir: &Path) -> Result<Vec<u8>> {
trigger_host_refresh("claude", &["-p", "hi", "--model", "sonnet"])?;
// Single-flight: serialize host-side rotations for this provider so
// two racing in-guest refreshes don't each spawn `claude -p`. The
// first waiter rotates; the second, on acquiring the lock, finds the
// token file freshly rewritten and skips its own host CLI. The lock
// is Anthropic-specific so a concurrent OpenAI refresh isn't blocked.
let _flight = RefreshLock::acquire(state_dir, secrets::REFRESH_LOCK_ANTHROPIC)?;
if !token_recently_rotated(&secrets::anthropic_token_path(state_dir)) {
trigger_host_refresh("claude", &["-p", "hi", "--model", "sonnet"])?;
}
let host_path = host_claude_creds_path().context("HOME not set")?;
let raw = std::fs::read_to_string(&host_path)
.with_context(|| format!("reading {}", host_path.display()))?;
let json: Value = serde_json::from_str(&raw)
.with_context(|| format!("parsing {}", host_path.display()))?;
// Re-wrap with the concrete path so a parse/extract failure in the pure fn
// still names which exact host file was bad (the pure fn uses a fixed label).
rotate_anthropic(state_dir, &raw)
.with_context(|| format!("rotating Anthropic token from {}", host_path.display()))
}
/// Pure rotation step for Anthropic: parse the (already-rotated) host
/// `.credentials.json` text, rewrite the per-project token file with
/// the fresh real bearer, and synthesize the OAuth refresh response
/// that carries *placeholders* (never the real bearer) back to the
/// in-VM agent.
///
/// Split out from [`refresh_anthropic`] so tests can drive a simulated
/// rotation by passing the rotated-file contents directly, with no host
/// CLI spawn and no `$HOME` credential file. Runtime behavior is
/// identical: `refresh_anthropic` calls this with the bytes it just
/// read from the real host file.
fn rotate_anthropic(state_dir: &Path, host_creds_json: &str) -> Result<Vec<u8>> {
let json: Value = serde_json::from_str(host_creds_json)
.context("parsing rotated host .credentials.json")?;
let oauth = json
.get("claudeAiOauth")
.context("rotated host .credentials.json missing claudeAiOauth")?;
@ -719,22 +755,46 @@ fn refresh_anthropic(state_dir: &Path) -> Result<Vec<u8>> {
Ok(http_200_json(&serde_json::to_vec(&body)?))
}
/// Public entry point for an OpenAI (Codex/ChatGPT) OAuth-refresh
/// interception. Thin wrapper mirroring [`refresh_anthropic`]: spawn
/// the host `codex` CLI to rotate the host auth file, read it back, and
/// hand the contents to [`rotate_openai`] for the testable rotation
/// logic.
fn refresh_openai(state_dir: &Path) -> Result<Vec<u8>> {
trigger_host_refresh(
"codex",
&[
"exec",
"--skip-git-repo-check",
"--dangerously-bypass-approvals-and-sandbox",
"Reply with OK",
],
)?;
// Single-flight (see `refresh_anthropic`): serialize host rotations
// and skip the `codex exec` if the token file was just rewritten by
// the launcher that held the lock before us. OpenAI-specific lock so
// an in-flight Anthropic refresh doesn't serialize against this one.
let _flight = RefreshLock::acquire(state_dir, secrets::REFRESH_LOCK_OPENAI)?;
if !token_recently_rotated(&secrets::openai_token_path(state_dir)) {
trigger_host_refresh(
"codex",
&[
"exec",
"--skip-git-repo-check",
"--dangerously-bypass-approvals-and-sandbox",
"Reply with OK",
],
)?;
}
let host_path = host_codex_auth_path().context("HOME not set")?;
let raw = std::fs::read_to_string(&host_path)
.with_context(|| format!("reading {}", host_path.display()))?;
let json: Value = serde_json::from_str(&raw)
.with_context(|| format!("parsing {}", host_path.display()))?;
// Re-wrap with the concrete path so a parse/extract failure in the pure fn
// still names which exact host file was bad (the pure fn uses a fixed label).
rotate_openai(state_dir, &raw)
.with_context(|| format!("rotating OpenAI token from {}", host_path.display()))
}
/// Pure rotation step for OpenAI: parse the (already-rotated) host
/// `codex auth.json` text, rewrite the per-project token file with the
/// fresh real access token, and synthesize the placeholder-carrying
/// OAuth refresh response. Split out from [`refresh_openai`] for the
/// same deterministic-testability reason as [`rotate_anthropic`].
fn rotate_openai(state_dir: &Path, host_auth_json: &str) -> Result<Vec<u8>> {
let json: Value =
serde_json::from_str(host_auth_json).context("parsing rotated host codex auth.json")?;
let new_access = json
.pointer("/tokens/access_token")
@ -758,13 +818,301 @@ fn refresh_openai(state_dir: &Path) -> Result<Vec<u8>> {
Ok(http_200_json(&serde_json::to_vec(&body)?))
}
/// How recently a token file must have been rewritten for the
/// single-flight waiter to trust it and skip its own host rotation.
///
/// Tied to [`HOST_REFRESH_TIMEOUT`]: a host `claude -p` / `codex exec`
/// is *allowed* to take up to that long, so a fixed 10 s window would
/// silently no-op in exactly the slow-rotation case the optimization is
/// meant to help — the holder finishes after, say, 25 s, and the waiter
/// then sees an mtime older than 10 s and redundantly re-runs the host
/// CLI even though the token it would read is current. Matching the
/// window to the rotation budget means a just-completed slow rotation is
/// still recognized as fresh. This is safe: the file's mtime is only
/// bumped by an actual successful rotation write, and host access tokens
/// live far longer than 90 s, so we never serve a stale token. A small
/// slack is added so a waiter that wakes slightly after the holder
/// returns still counts the rotation as fresh.
const REFRESH_FRESHNESS_WINDOW: std::time::Duration =
HOST_REFRESH_TIMEOUT.saturating_add(std::time::Duration::from_secs(5));
/// True if `path` exists and was modified within
/// [`REFRESH_FRESHNESS_WINDOW`]. Used by the second single-flight
/// waiter to decide it can re-read the just-rotated token file instead
/// of spawning another host CLI. Any error (missing file, clock skew
/// making mtime appear in the future) conservatively returns `false`
/// so we fall back to actually refreshing.
fn token_recently_rotated(path: &Path) -> bool {
let Ok(meta) = std::fs::metadata(path) else {
return false;
};
let Ok(modified) = meta.modified() else {
return false;
};
match modified.elapsed() {
Ok(age) => age <= REFRESH_FRESHNESS_WINDOW,
Err(_) => false,
}
}
/// Advisory cross-process lock serializing host-side OAuth refreshes
/// for one provider within a single project. Held for the duration of
/// one `refresh_anthropic` / `refresh_openai` call so two in-guest
/// agents (or two launchers) racing the *same* provider's token
/// rotation don't each spawn a host `claude -p` / `codex exec`.
///
/// The lock is keyed per provider (see [`secrets::refresh_lock_path_for`]):
/// an Anthropic rotation and an OpenAI rotation touch independent host
/// artifacts, so they hold different lock files and may run
/// concurrently — only same-provider refreshes serialize.
///
/// Uses a non-blocking `flock(LOCK_EX|LOCK_NB)` polled with a deadline
/// — already available via the `libc` dependency, so no new crate. The
/// lock is associated with the open file description and released
/// automatically when the fd is closed on `Drop` (or if the process
/// dies), so a crashed refresh can't wedge future rotations.
struct RefreshLock {
/// `Some` when we actually hold the flock; `None` when [`acquire`]
/// timed out waiting for a wedged-but-live holder and we degraded
/// to proceeding without serialization (see [`acquire`]). The fd is
/// still kept open in that case so `Drop` is uniform, but no
/// `LOCK_UN` is issued.
file: Option<std::fs::File>,
}
impl RefreshLock {
/// Acquire the per-provider refresh lock named `lock_name` (e.g.
/// [`secrets::REFRESH_LOCK_ANTHROPIC`]).
///
/// Bounded wait: a live-but-wedged holder must not block a waiter
/// indefinitely, which would reintroduce the unbounded stall the
/// [`HOST_REFRESH_TIMEOUT`] cap was added to prevent (review #8).
/// We poll `LOCK_EX|LOCK_NB` until the ceiling, then degrade to
/// proceeding *without* the lock — i.e. the pre-feature behavior of
/// just refreshing. That can cost a redundant host CLI spawn in the
/// rare wedged-holder case, but keeps the whole refresh path time
/// bounded, which matters more.
fn acquire(state_dir: &Path, lock_name: &str) -> Result<Self> {
Self::acquire_with_ceiling(state_dir, lock_name, HOST_REFRESH_TIMEOUT)
}
fn acquire_with_ceiling(
state_dir: &Path,
lock_name: &str,
ceiling: std::time::Duration,
) -> Result<Self> {
let path = secrets::refresh_lock_path_for(state_dir, lock_name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating secrets dir {}", parent.display()))?;
}
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&path)
.with_context(|| format!("opening refresh lock {}", path.display()))?;
use std::os::unix::io::AsRawFd as _;
use std::time::Instant;
let fd = file.as_raw_fd();
let start = Instant::now();
// Poll interval is small relative to a host rotation (seconds);
// the extra wakeups over a ~90 s ceiling are negligible.
let poll = std::time::Duration::from_millis(50);
loop {
let rc = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
if rc == 0 {
return Ok(Self { file: Some(file) });
}
let err = std::io::Error::last_os_error();
match err.raw_os_error() {
Some(libc::EINTR) => continue,
// Held by someone else — wait and retry until the ceiling.
Some(libc::EWOULDBLOCK) => {
if start.elapsed() >= ceiling {
// Degrade to no-lock rather than block forever on
// a wedged-but-live holder. `file: None` so Drop
// issues no LOCK_UN we never took.
tracing::warn!(
lock = %path.display(),
"refresh lock contended past {}s; proceeding without single-flight",
ceiling.as_secs(),
);
return Ok(Self { file: None });
}
std::thread::sleep(poll);
continue;
}
_ => {
return Err(anyhow::Error::new(err)
.context(format!("flock(LOCK_EX|LOCK_NB) on {}", path.display())));
}
}
}
}
}
impl Drop for RefreshLock {
fn drop(&mut self) {
use std::os::unix::io::AsRawFd as _;
// Only unlock if we actually acquired it; a timed-out acquire
// never took the lock, so issuing LOCK_UN would be wrong (and
// could release a lock another fd in this process holds, though
// that doesn't happen here).
if let Some(file) = &self.file {
unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_UN) };
}
}
}
#[cfg(test)]
mod refresh_lock_tests {
use super::*;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn token_recently_rotated_window() {
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("anthropic");
// Missing file -> not fresh.
assert!(!token_recently_rotated(&f));
// Just-written file -> fresh.
std::fs::write(&f, b"tok").unwrap();
assert!(token_recently_rotated(&f));
}
/// Two threads contending the same project lock must run their
/// critical sections one at a time. We track the number of holders
/// inside the locked region and assert it never exceeds one.
#[test]
fn contending_threads_serialize() {
let dir = tempfile::tempdir().unwrap();
let state = dir.path().join("proj");
std::fs::create_dir_all(&state).unwrap();
let in_section = Arc::new(AtomicUsize::new(0));
let max_concurrent = Arc::new(AtomicUsize::new(0));
let acquisitions = Arc::new(AtomicUsize::new(0));
let spawn = |state: std::path::PathBuf,
in_section: Arc<AtomicUsize>,
max_concurrent: Arc<AtomicUsize>,
acquisitions: Arc<AtomicUsize>| {
std::thread::spawn(move || {
for _ in 0..20 {
let _guard = RefreshLock::acquire(&state, secrets::REFRESH_LOCK_ANTHROPIC)
.expect("acquire");
acquisitions.fetch_add(1, Ordering::SeqCst);
let now = in_section.fetch_add(1, Ordering::SeqCst) + 1;
// Record the peak observed concurrency.
max_concurrent.fetch_max(now, Ordering::SeqCst);
// Hold briefly to widen the race window.
std::thread::sleep(std::time::Duration::from_millis(1));
in_section.fetch_sub(1, Ordering::SeqCst);
}
})
};
let t1 = spawn(
state.clone(),
in_section.clone(),
max_concurrent.clone(),
acquisitions.clone(),
);
let t2 = spawn(
state.clone(),
in_section.clone(),
max_concurrent.clone(),
acquisitions.clone(),
);
t1.join().unwrap();
t2.join().unwrap();
assert_eq!(acquisitions.load(Ordering::SeqCst), 40);
assert_eq!(
max_concurrent.load(Ordering::SeqCst),
1,
"flock failed to serialize: two holders entered the critical section at once"
);
}
/// Different providers use different lock files, so a holder of the
/// Anthropic lock must not block an OpenAI acquire (and vice versa).
#[test]
fn distinct_providers_do_not_contend() {
let dir = tempfile::tempdir().unwrap();
let state = dir.path().join("proj");
std::fs::create_dir_all(&state).unwrap();
let anthropic = RefreshLock::acquire(&state, secrets::REFRESH_LOCK_ANTHROPIC)
.expect("anthropic acquire");
// Holding the Anthropic lock, an OpenAI acquire must succeed
// immediately (not time out, not degrade) — it's a different file.
let openai = RefreshLock::acquire_with_ceiling(
&state,
secrets::REFRESH_LOCK_OPENAI,
std::time::Duration::from_millis(50),
)
.expect("openai acquire");
// Both genuinely hold their locks.
assert!(openai.file.is_some(), "openai should hold its own lock");
drop(anthropic);
drop(openai);
}
/// A live-but-wedged holder must not block a waiter past the
/// ceiling: the second acquire returns within the bound and degrades
/// to the no-lock state instead of hanging forever.
#[test]
fn bounded_wait_degrades_when_holder_wedged() {
let dir = tempfile::tempdir().unwrap();
let state = dir.path().join("proj");
std::fs::create_dir_all(&state).unwrap();
// First holder keeps the lock for the whole test.
let _held = RefreshLock::acquire(&state, secrets::REFRESH_LOCK_ANTHROPIC)
.expect("first acquire");
let ceiling = std::time::Duration::from_millis(200);
let start = std::time::Instant::now();
let second = RefreshLock::acquire_with_ceiling(
&state,
secrets::REFRESH_LOCK_ANTHROPIC,
ceiling,
)
.expect("second acquire returns Ok (degraded)");
let waited = start.elapsed();
// Returned within a small multiple of the ceiling (not blocked
// indefinitely), and degraded to no-lock.
assert!(
waited < ceiling * 4,
"acquire blocked {waited:?}, expected ~{ceiling:?}"
);
assert!(
second.file.is_none(),
"contended acquire past ceiling must degrade to the no-lock state"
);
}
}
/// Bound on how long we'll wait for a host `claude -p` / `codex exec`
/// to drive a token rotation. A hung host CLI must not keep the in-VM
/// agent's OAuth refresh waiting indefinitely (review #8). 90 s is
/// enough for normal claude/codex round-trips and small enough to
/// surface a problem before the guest agent's own timeout fires.
///
/// Shared so the single-flight lock-wait ceiling
/// ([`RefreshLock::acquire`]) and the freshness window
/// ([`REFRESH_FRESHNESS_WINDOW`]) stay tied to the actual rotation
/// budget rather than drifting from it.
const HOST_REFRESH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(90);
fn trigger_host_refresh(cmd: &str, args: &[&str]) -> Result<()> {
// Bounded wait so a hung host CLI doesn't keep the in-VM agent's
// OAuth refresh waiting indefinitely (review #8). 90 s is enough
// for normal claude/codex round-trips and small enough to surface
// a problem before the guest agent's own timeout fires.
use std::time::{Duration, Instant};
const TIMEOUT: Duration = Duration::from_secs(90);
const TIMEOUT: Duration = HOST_REFRESH_TIMEOUT;
let mut child = Command::new(cmd)
.args(args)
@ -1612,6 +1960,7 @@ mod tests {
query_params: false,
body: false,
},
on_violation: None,
require_tls_identity: true,
}],
on_violation: Default::default(),
@ -1834,3 +2183,231 @@ mod tests {
);
}
}
// ─── mid-session token-rotation regression tests (PLAN.md A1) ───────────
//
// The OAuth-refresh MITM exists but had never been exercised across a
// real token-expiry boundary — true e2e needs a long live session and a
// real expiring token, infeasible in CI / the dev sandbox. Instead we
// drive the rotation logic deterministically: `refresh_{anthropic,openai}`
// are thin wrappers that spawn the host CLI and read the rotated host
// credential file, then delegate to the pure `rotate_{anthropic,openai}`
// step. These tests call the pure step directly with a simulated rotated
// host file, then assert the two invariants that matter:
//
// (1) the per-project token file is rewritten to the NEW real bearer
// (so the proxy substitutes the fresh token on the next request);
// (2) the synthesized HTTP refresh response carries only PLACEHOLDERS
// in access_token / refresh_token — never the real bearer — and is
// a well-formed HTTP/1.1 200 with the expected headers.
#[cfg(test)]
mod rotation_tests {
use super::*;
/// Minimal stdlib temp dir; avoids a dev-dependency. Unique per call
/// via pid + a process-global counter, cleaned up on drop.
struct TmpDir(PathBuf);
impl TmpDir {
fn new(tag: &str) -> Self {
use std::sync::atomic::{AtomicU32, Ordering};
static N: AtomicU32 = AtomicU32::new(0);
let n = N.fetch_add(1, Ordering::Relaxed);
let mut p = std::env::temp_dir();
p.push(format!("agentvm-rot-{tag}-{}-{n}", std::process::id()));
std::fs::create_dir_all(&p).unwrap();
TmpDir(p)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TmpDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// Split a synthesized HTTP/1.1 response into (status_line, headers,
/// body). Asserts a single CRLFCRLF separator exists.
fn split_http(resp: &[u8]) -> (String, String, String) {
let s = std::str::from_utf8(resp).expect("response is UTF-8");
let sep = s.find("\r\n\r\n").expect("response has header/body separator");
let head = &s[..sep];
let body = &s[sep + 4..];
let line_end = head.find("\r\n").unwrap_or(head.len());
(
head[..line_end].to_string(),
head.to_string(),
body.to_string(),
)
}
// ── Anthropic ─────────────────────────────────────────────────
const NEW_BEARER_ANTHROPIC: &str =
"sk-ant-oat01-ROTATED-NEW-anthropic-bearer-value-do-not-leak";
fn rotated_anthropic_creds() -> String {
json!({
"claudeAiOauth": {
"accessToken": NEW_BEARER_ANTHROPIC,
"refreshToken": "sk-ant-ort01-rotated-refresh",
"expiresAt": 9_999_999_999_000i64,
"scopes": ["user:inference", "user:profile"],
}
})
.to_string()
}
#[test]
fn anthropic_rotation_rewrites_token_file_to_new_bearer() {
let tmp = TmpDir::new("anthropic-file");
let resp = rotate_anthropic(tmp.path(), &rotated_anthropic_creds())
.expect("rotate_anthropic should succeed");
assert!(!resp.is_empty(), "response must not be empty");
// (1) Per-project token file rewritten to the NEW real bearer.
let token_file = secrets::anthropic_token_path(tmp.path());
let written = std::fs::read_to_string(&token_file)
.expect("token file should have been written");
assert_eq!(
written, NEW_BEARER_ANTHROPIC,
"anthropic token file must hold the freshly-rotated real bearer"
);
}
#[test]
fn anthropic_rotation_response_carries_placeholders_not_real_bearer() {
let tmp = TmpDir::new("anthropic-resp");
let resp = rotate_anthropic(tmp.path(), &rotated_anthropic_creds())
.expect("rotate_anthropic should succeed");
let (status, headers, body) = split_http(&resp);
// Well-formed status line + headers.
assert_eq!(status, "HTTP/1.1 200 OK", "status line");
assert!(
headers.contains("Content-Type: application/json"),
"Content-Type header present: {headers:?}"
);
assert!(
headers.contains(&format!("Content-Length: {}", body.len())),
"Content-Length matches body ({} bytes): {headers:?}",
body.len()
);
assert!(
headers.contains("Connection: close"),
"Connection: close present: {headers:?}"
);
// (2) The real bearer must NEVER appear anywhere in the response.
assert!(
!String::from_utf8_lossy(&resp).contains(NEW_BEARER_ANTHROPIC),
"real bearer leaked into the refresh response"
);
// Body's token fields are the placeholders, verbatim.
let parsed: Value = serde_json::from_str(&body).expect("body is JSON");
assert_eq!(
parsed["access_token"], secrets::ANTHROPIC_ACCESS_PLACEHOLDER,
"access_token must be the placeholder"
);
assert_eq!(
parsed["refresh_token"], secrets::ANTHROPIC_REFRESH_PLACEHOLDER,
"refresh_token must be the placeholder"
);
assert_eq!(parsed["token_type"], "Bearer");
// expires_in derived from expiresAt: far-future → positive.
assert!(
parsed["expires_in"].as_i64().unwrap() > 0,
"expires_in should be positive"
);
}
// ── OpenAI / Codex ────────────────────────────────────────────
const NEW_BEARER_OPENAI: &str =
"eyJROTATED.openai.access.token.value.do.not.leak";
fn rotated_openai_auth() -> String {
json!({
"tokens": {
"access_token": NEW_BEARER_OPENAI,
"refresh_token": "rotated-openai-refresh",
"id_token": "rotated-openai-id",
},
"OPENAI_API_KEY": null,
})
.to_string()
}
#[test]
fn openai_rotation_rewrites_token_file_to_new_bearer() {
let tmp = TmpDir::new("openai-file");
let resp = rotate_openai(tmp.path(), &rotated_openai_auth())
.expect("rotate_openai should succeed");
assert!(!resp.is_empty());
let token_file = secrets::openai_token_path(tmp.path());
let written = std::fs::read_to_string(&token_file)
.expect("token file should have been written");
assert_eq!(
written, NEW_BEARER_OPENAI,
"openai token file must hold the freshly-rotated real access token"
);
}
#[test]
fn openai_rotation_response_carries_placeholders_not_real_bearer() {
let tmp = TmpDir::new("openai-resp");
let resp = rotate_openai(tmp.path(), &rotated_openai_auth())
.expect("rotate_openai should succeed");
let (status, headers, body) = split_http(&resp);
assert_eq!(status, "HTTP/1.1 200 OK", "status line");
assert!(headers.contains("Content-Type: application/json"));
assert!(headers.contains(&format!("Content-Length: {}", body.len())));
assert!(headers.contains("Connection: close"));
assert!(
!String::from_utf8_lossy(&resp).contains(NEW_BEARER_OPENAI),
"real access token leaked into the refresh response"
);
let parsed: Value = serde_json::from_str(&body).expect("body is JSON");
assert_eq!(parsed["access_token"], secrets::OPENAI_ACCESS_PLACEHOLDER);
assert_eq!(parsed["refresh_token"], secrets::OPENAI_REFRESH_PLACEHOLDER);
assert_eq!(parsed["id_token"], secrets::OPENAI_ID_PLACEHOLDER);
assert_eq!(parsed["token_type"], "Bearer");
}
/// The legacy ChatGPT/Codex shape stores the key flat as
/// `OPENAI_API_KEY` (no `tokens` object). Rotation must pick it up.
#[test]
fn openai_rotation_falls_back_to_flat_api_key() {
let tmp = TmpDir::new("openai-flat");
let auth = json!({ "OPENAI_API_KEY": NEW_BEARER_OPENAI }).to_string();
let resp = rotate_openai(tmp.path(), &auth).expect("rotate_openai should succeed");
let token_file = secrets::openai_token_path(tmp.path());
let written = std::fs::read_to_string(&token_file).unwrap();
assert_eq!(written, NEW_BEARER_OPENAI);
assert!(!String::from_utf8_lossy(&resp).contains(NEW_BEARER_OPENAI));
}
/// Malformed rotated host files surface an error rather than writing
/// a garbage token file or a malformed response.
#[test]
fn rotation_errors_on_malformed_host_file() {
let tmp = TmpDir::new("malformed");
assert!(rotate_anthropic(tmp.path(), "not json").is_err());
assert!(rotate_openai(tmp.path(), "not json").is_err());
assert!(
rotate_anthropic(tmp.path(), &json!({"claudeAiOauth": {}}).to_string()).is_err(),
"missing accessToken must error"
);
assert!(
rotate_openai(tmp.path(), &json!({"tokens": {}}).to_string()).is_err(),
"missing access_token must error"
);
}
}

View file

@ -18,8 +18,23 @@ mod setup;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
// Shown under the top-level `agent-vm --help`, after the command list.
const TOP_AFTER_HELP: &str = "\
Getting started:
agent-vm setup fetch and verify the base image (run once first)
cd ~/your-project
agent-vm claude launch in this project or codex / opencode / copilot / shell
claude, codex, opencode, copilot and shell share the same options;
see `agent-vm claude --help` for mounts, ports, networking and credentials.";
#[derive(Parser)]
#[command(name = "agent-vm", about = "Sandboxed VMs for AI coding agents.")]
#[command(
name = "agent-vm",
version,
about = "Sandboxed microVMs for AI coding agents.",
after_help = TOP_AFTER_HELP
)]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
@ -27,26 +42,28 @@ struct Cli {
#[derive(Subcommand)]
enum Cmd {
/// Build (and verify) the agent-vm base image.
/// Pull and verify the base image (run once first).
Setup(setup::Args),
/// Pull the latest image from the registry into the microsandbox cache.
/// Refresh the cached base image.
Pull(pull::Args),
/// Launch Claude Code in a sandbox mounted at the project's host path.
/// Launch Claude Code in a per-project sandbox.
Claude(run::Args),
/// Launch Codex CLI in a sandbox mounted at the project's host path.
/// Launch Codex CLI in a per-project sandbox.
Codex(run::Args),
/// Launch OpenCode in a sandbox mounted at the project's host path.
/// Launch OpenCode in a per-project sandbox.
Opencode(run::Args),
/// Open a bash shell in a sandbox mounted at the project's host path.
/// Launch GitHub Copilot CLI in a per-project sandbox.
Copilot(run::Args),
/// Open a bash shell in a per-project sandbox.
Shell(run::Args),
/// Exchange a string between the host and the per-project sandbox.
/// See `agent-vm clipboard --help`.
/// Exchange a string between the host and the sandbox.
Clipboard(clipboard::Args),
/// Internal: invoked by msb's interceptor hook for matched OAuth
@ -90,6 +107,7 @@ fn main() -> Result<()> {
Cmd::Claude(args) => exit_with(run::launch(run::Agent::Claude, args).await?),
Cmd::Codex(args) => exit_with(run::launch(run::Agent::Codex, args).await?),
Cmd::Opencode(args) => exit_with(run::launch(run::Agent::Opencode, args).await?),
Cmd::Copilot(args) => exit_with(run::launch(run::Agent::Copilot, args).await?),
Cmd::Shell(args) => exit_with(run::launch(run::Agent::Shell, args).await?),
Cmd::Clipboard(args) => clipboard::run(args),
Cmd::InterceptHook(args) => intercept_hook::run(args).await,

View file

@ -24,11 +24,12 @@ use microsandbox::{Sandbox, sandbox::PullPolicy};
#[derive(ClapArgs)]
pub struct Args {
/// Override the image reference. Defaults to
/// `ghcr.io/wirenboard/agent-vm-template:latest` or the value of
/// `AGENT_VM_IMAGE_TAG`. Use a timestamped tag
/// Override the image reference.
///
/// Defaults to `ghcr.io/wirenboard/agent-vm-template:latest` or the
/// value of `AGENT_VM_IMAGE_TAG`. Use a timestamped tag
/// (`...:YYYY-MM-DDTHH`) to pin a specific build.
#[arg(long, env = "AGENT_VM_IMAGE_TAG")]
#[arg(long, env = "AGENT_VM_IMAGE_TAG", value_name = "REF")]
image: Option<String>,
}

File diff suppressed because it is too large Load diff

View file

@ -20,7 +20,8 @@ use serde_json::Value;
use sha2::{Digest, Sha256};
use crate::host_paths::{
atomic_write, host_claude_creds_path, host_codex_auth_path, host_opencode_auth_path,
atomic_write, host_claude_creds_path, host_codex_auth_path, host_copilot_token_path,
host_opencode_auth_path,
};
// ---------------------------------------------------------------------------
@ -83,6 +84,17 @@ pub const OPENCODE_OPENAI_REFRESH_PLACEHOLDER: &str = "msb-opencode-placeholder-
/// git credential helper sees this string; the proxy substitutes the
/// real bearer on outbound traffic to GitHub.
pub const GH_TOKEN_PLACEHOLDER: &str = "msb-gh-placeholder-v2";
/// Placeholder for the host's GitHub Copilot token. The in-guest
/// Copilot CLI sees this string (exported as `COPILOT_GITHUB_TOKEN`
/// via `/etc/profile.d` and stored in `~/.copilot/config.json`); the
/// proxy substitutes the real GitHub OAuth token on outbound traffic
/// to the Copilot API. Kept distinct from `GH_TOKEN_PLACEHOLDER` so
/// substituting one can't accidentally rewrite the other in unrelated
/// request bytes — even though both happen to resolve to the same
/// host gh token, they're registered against different allow-host
/// sets (GitHub API vs Copilot API). Mirrors the original Bash
/// agent-vm's `placeholder-copilot-token-injected-by-proxy`.
pub const COPILOT_TOKEN_PLACEHOLDER: &str = "msb-copilot-placeholder-v2";
// Hostnames the secret-substitution proxy + interceptor key off. Kept
// here so the launcher (`run.rs`), the hook (`intercept_hook`), and any
@ -105,6 +117,15 @@ pub const GITHUB_CODELOAD_HOST: &str = "codeload.github.com";
pub const GITHUB_RAW_HOST: &str = "raw.githubusercontent.com";
pub const GITHUB_OBJECTS_HOST: &str = "objects.githubusercontent.com";
/// GitHub Copilot API endpoints. The Copilot CLI sends
/// `Authorization: Bearer <token>` to these; the proxy substitutes
/// [`COPILOT_TOKEN_PLACEHOLDER`] for the real GitHub OAuth token.
/// Both are needed: `api.githubcopilot.com` for business/enterprise
/// seats and `api.individual.githubcopilot.com` for individual ones.
/// Mirrors the original Bash agent-vm's credential-proxy domains.
pub const COPILOT_API_HOST: &str = "api.githubcopilot.com";
pub const COPILOT_API_INDIVIDUAL_HOST: &str = "api.individual.githubcopilot.com";
pub const ANTHROPIC_OAUTH_TOKEN_PATH: &str = "/v1/oauth/token";
pub const OPENAI_OAUTH_TOKEN_PATH: &str = "/oauth/token";
@ -125,6 +146,30 @@ pub struct CredsState {
/// on outbound traffic to GitHub. Only `Some` when the user has
/// `gh` logged in *and* `--no-git` was not passed.
pub gh_token_file: Option<PathBuf>,
/// File holding the host's GitHub Copilot token (a GitHub OAuth
/// token with Copilot access). The proxy substitutes
/// `COPILOT_TOKEN_PLACEHOLDER` for this on outbound traffic to the
/// Copilot API hosts. Sourced from the original Bash agent-vm's
/// device-flow cache (`~/.cache/claude-vm/copilot-token.json`) if
/// present, else falls back to the captured `gh auth token` (a gh
/// login with the Copilot scope works against the Copilot API).
///
/// `Some` whenever a usable token was found and either the Copilot
/// agent is being launched (`want_copilot`) or GitHub egress is
/// already enabled for another agent (`use_github`). Crucially this
/// is NOT gated on `--no-git` for a Copilot launch: the Copilot API
/// is not repo-scoped, so `agent-vm copilot` must work even in a
/// non-GitHub project.
///
/// **Known limitation (no in-session refresh):** unlike the
/// Anthropic/OpenAI access tokens, the Copilot token is not
/// round-tripped through `intercept_hook`'s OAuth-refresh path. If
/// the captured token expires mid-session, the proxy keeps
/// substituting the stale value and Copilot requests start failing
/// with 401 until the next `agent-vm` launch re-captures from the
/// host. gh user tokens are typically long-lived, so the practical
/// impact is low; re-launch to recover.
pub copilot_token_file: Option<PathBuf>,
pub snapshot: Option<HostCredsSnapshot>,
}
@ -174,6 +219,71 @@ pub fn gh_token_path(state_dir: &Path) -> PathBuf {
host_secret_dir(state_dir).join("gh")
}
/// Per-provider advisory lock file serializing host-side OAuth refreshes
/// for one provider within a single project. The in-VM agent's
/// `_intercept-hook` acquires an exclusive `flock` on this path before
/// spawning a host `claude -p` / `codex exec` to drive a token rotation,
/// so two racing in-guest refreshes of the *same* provider don't each
/// launch their own host CLI.
///
/// Keyed per provider (`name` is e.g. [`REFRESH_LOCK_ANTHROPIC`]) so a
/// concurrent Anthropic and OpenAI in-guest refresh don't serialize
/// against each other — they rotate independent host credential files
/// and write distinct token files, so there is no shared state to guard
/// across providers. Lives in the host-only [`host_secret_dir`] (never
/// bind-mounted into the guest), alongside the token files the refresh
/// rewrites.
///
/// Note: the launcher's [`refresh`] uses a *different*, single shared
/// lock ([`ProjectRefreshLock`]) because it does read-modify-write on
/// per-project state files shared across all providers (`claude.json`,
/// `claude/settings.json`, `opencode-config/opencode.json`), so its
/// critical section genuinely spans every provider.
pub fn refresh_lock_path_for(state_dir: &Path, name: &str) -> PathBuf {
host_secret_dir(state_dir).join(name)
}
/// Lock basename for the Anthropic in-guest refresh single-flight.
pub const REFRESH_LOCK_ANTHROPIC: &str = ".refresh.anthropic.lock";
/// Lock basename for the OpenAI in-guest refresh single-flight.
pub const REFRESH_LOCK_OPENAI: &str = ".refresh.openai.lock";
#[cfg(test)]
mod refresh_lock_tests {
use super::*;
#[test]
fn per_provider_lock_paths_are_distinct_and_in_secret_dir() {
let state = Path::new("/home/u/.cache/agent-vm/abc123");
let anthropic = refresh_lock_path_for(state, REFRESH_LOCK_ANTHROPIC);
let openai = refresh_lock_path_for(state, REFRESH_LOCK_OPENAI);
// Two providers must not share a lock file.
assert_ne!(anthropic, openai);
// Expected concrete paths in the sibling `<name>.secrets/` dir.
assert_eq!(
anthropic,
Path::new("/home/u/.cache/agent-vm/abc123.secrets/.refresh.anthropic.lock")
);
assert_eq!(
openai,
Path::new("/home/u/.cache/agent-vm/abc123.secrets/.refresh.openai.lock")
);
// Both live in the host-only secrets dir, never under state_dir
// (which is bind-mounted into the guest).
assert_eq!(anthropic.parent(), anthropic_token_path(state).parent());
assert_eq!(openai.parent(), anthropic_token_path(state).parent());
assert!(!anthropic.starts_with(state));
assert!(!openai.starts_with(state));
}
}
/// Per-project location of the Copilot token file the proxy re-reads.
/// Lives in the host-only [`host_secret_dir`], never inside the guest
/// mount (it holds the real GitHub OAuth token).
pub fn copilot_token_path(state_dir: &Path) -> PathBuf {
host_secret_dir(state_dir).join("copilot")
}
/// OpenCode reuses the same OpenAI access token file: both Codex and
/// OpenCode hit api.openai.com / chatgpt.com and the proxy substitutes
/// each provider's distinct placeholder string for the same real
@ -201,6 +311,7 @@ pub fn refresh(
state_dir: &Path,
project_guest_path: &str,
use_github: bool,
want_copilot: bool,
) -> Result<CredsState> {
let _lock = ProjectRefreshLock::acquire(state_dir)
.context("acquiring per-project refresh lock")?;
@ -224,7 +335,7 @@ pub fn refresh(
// First-run bypasses, run regardless of whether the user has host
// credentials for the provider. Without these the in-VM agent
// blocks on a terminal-style wizard at first launch.
write_agent_config_defaults(state_dir, project_guest_path)?;
write_agent_config_defaults(state_dir, project_guest_path, want_copilot)?;
let anthropic_token_file = refresh_anthropic(state_dir).unwrap_or_else(|e| {
tracing::warn!(error = %e, "anthropic credential refresh failed; skipping");
@ -270,6 +381,34 @@ pub fn refresh(
None
};
// D1: capture the host's GitHub Copilot token. Prefer the
// device-flow cache the original Bash agent-vm wrote
// (`~/.cache/claude-vm/copilot-token.json`); fall back to the
// `gh auth token` we just captured (a gh login carries the
// Copilot scope for users with a Copilot seat).
//
// Unlike the gh capture, this is NOT gated on `use_github`. The
// Copilot API is reached with a GitHub OAuth token, but it is not
// repo-scoped the way `api.github.com` push is — so the reason to
// run `agent-vm copilot` (GitHub-backed AI) must not be switched off
// just because the project has no detected GitHub remote or the user
// passed `--no-git`. We capture the token whenever the Copilot agent
// is the one being launched (`want_copilot`), or when GitHub egress
// is already enabled for another agent (`use_github`) so an existing
// gh login still flows through. When `want_copilot && !use_github`
// there is no gh fallback token, so capture succeeds only via the
// device-flow cache; the caller surfaces a clear error if nothing
// was obtained rather than letting the guest send an unsubstituted
// placeholder bearer.
let copilot_token_file = if use_github || want_copilot {
refresh_copilot(state_dir, gh_token_file.as_deref()).unwrap_or_else(|e| {
tracing::warn!(error = %e, "copilot credential capture failed; skipping");
None
})
} else {
None
};
// SHA-256 snapshot of host credential files for post-run mutation
// detection. Phase 4's refresh hook *legitimately* rewrites these;
// anything else doing so is a bug to investigate. See
@ -281,6 +420,7 @@ pub fn refresh(
openai_token_file,
opencode_openai_access_token_file,
gh_token_file,
copilot_token_file,
snapshot,
})
}
@ -311,10 +451,76 @@ pub struct HostGitIdentity {
/// which makes git refuse to commit rather than silently attribute to
/// `agent-vm`.
pub fn discover_host_git_identity() -> Option<HostGitIdentity> {
if let Some(id) = gh_api_user_identity() {
// `gh api user` is an HTTPS round-trip to api.github.com that costs
// ~0.31.3s and runs on the *pre-boot critical path* of every
// launch — yet the answer (your name/email/login) almost never
// changes. Cache the resolved identity with a short TTL so only the
// first launch in the window pays the network cost; subsequent
// launches resolve it instantly. The cache holds only display-level
// strings (already validated by `is_config_safe`), never tokens.
if let Some(id) = read_identity_cache() {
return Some(id);
}
host_git_config_identity()
let id = gh_api_user_identity().or_else(host_git_config_identity);
if let Some(ref id) = id {
write_identity_cache(id);
}
id
}
/// TTL for the cached host git identity. Long enough to take the
/// `gh api user` round-trip off essentially every launch, short enough
/// that switching `gh` accounts / editing `git config user.*` is
/// reflected the same day.
const GIT_IDENTITY_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(24 * 3600);
fn identity_cache_path() -> Option<PathBuf> {
Some(
crate::host_paths::state_root()?
.join("cache")
.join("host-git-identity"),
)
}
/// Read the cached identity if present and fresher than
/// [`GIT_IDENTITY_CACHE_TTL`]. Re-validates with [`is_config_safe`] so a
/// tampered cache file can't smuggle gitconfig sections into the guest.
fn read_identity_cache() -> Option<HostGitIdentity> {
let p = identity_cache_path()?;
let age = std::fs::metadata(&p).ok()?.modified().ok()?.elapsed().ok()?;
if age > GIT_IDENTITY_CACHE_TTL {
return None;
}
let data = std::fs::read_to_string(&p).ok()?;
let mut lines = data.lines();
let name = lines.next()?.to_string();
let email = lines.next()?.to_string();
let gh = lines.next().unwrap_or("");
if name.is_empty() || email.is_empty() || !is_config_safe(&name) || !is_config_safe(&email) {
return None;
}
Some(HostGitIdentity {
name,
email,
gh_login: (!gh.is_empty()).then(|| gh.to_string()),
})
}
/// Persist the resolved identity (best-effort; cache misses are cheap).
fn write_identity_cache(id: &HostGitIdentity) {
let Some(p) = identity_cache_path() else {
return;
};
if let Some(parent) = p.parent() {
let _ = std::fs::create_dir_all(parent);
}
let body = format!(
"{}\n{}\n{}\n",
id.name,
id.email,
id.gh_login.as_deref().unwrap_or("")
);
let _ = atomic_write(&p, body.as_bytes(), 0o600);
}
/// Cap on how long we'll wait for `gh api user`. The call is an HTTPS
@ -523,6 +729,71 @@ fn refresh_gh(state_dir: &Path) -> Result<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`].
@ -575,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"))?;
@ -605,6 +880,24 @@ fn write_agent_config_defaults(state_dir: &Path, project_guest_path: &str) -> Re
std::fs::create_dir_all(&opencode_config_dir)?;
write_default_opencode_config(&opencode_config_dir.join("opencode.json"))?;
// D1: GitHub Copilot CLI reads ~/.copilot/config.json. The
// launcher symlinks /root/.copilot → /agent-vm-state/copilot so
// this file lands at the right path inside the guest. The token
// field is a placeholder; the proxy substitutes the real one
// outbound (see write_default_copilot_config).
//
// Only written when the Copilot agent is actually selected
// (`want_copilot`). Writing the placeholder `github_token` for a
// claude/codex/opencode/shell session would be dead config at best;
// worse, paired with the matching `COPILOT_GITHUB_TOKEN` env it would
// have the guest emit an unsubstituted bearer if anything in the
// session ever hit the Copilot API without a registered secret.
if want_copilot {
let copilot_dir = state_dir.join("copilot");
std::fs::create_dir_all(&copilot_dir)?;
write_default_copilot_config(&copilot_dir.join("config.json"))?;
}
// Persistent per-project bash history. The launcher symlinks
// /root/.bash_history → /agent-vm-state/bash_history; touching
// the file here ensures the symlink target exists on first
@ -1117,6 +1410,43 @@ fn write_default_opencode_config(path: &Path) -> Result<()> {
Ok(())
}
/// Write the GitHub Copilot CLI's `~/.copilot/config.json`. Two
/// purposes, both mirroring the original Bash agent-vm's
/// `_copilot_vm_setup_home`:
///
/// - `trusted_folders = ["/"]` so the CLI never prompts "do you
/// trust this folder?" — the microVM is the sandbox, so trusting
/// every path inside it is correct.
/// - the token field carries [`COPILOT_TOKEN_PLACEHOLDER`], which
/// the proxy substitutes for the real token on outbound traffic.
/// The CLI also honours the `COPILOT_GITHUB_TOKEN` env var (set by
/// the launcher); writing it here too covers config-first reads.
///
/// Merge-on-existing so a user's own settings survive across launches;
/// only the fields we manage are force-set.
fn write_default_copilot_config(path: &Path) -> Result<()> {
let mut config: Value = match std::fs::read_to_string(path) {
Ok(raw) => serde_json::from_str(&raw).unwrap_or(serde_json::json!({})),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => serde_json::json!({}),
Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
};
let obj = config
.as_object_mut()
.context("copilot config.json is not an object")?;
// Trust everything — the VM itself is the security boundary.
obj.insert(
"trusted_folders".into(),
Value::Array(vec![Value::String("/".into())]),
);
// Placeholder token; the proxy swaps it for the real one outbound.
obj.insert(
"github_token".into(),
Value::String(COPILOT_TOKEN_PLACEHOLDER.into()),
);
atomic_write(path, serde_json::to_vec(&config)?.as_slice(), 0o600)?;
Ok(())
}
/// Advisory exclusive flock on `<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
@ -1276,6 +1606,36 @@ mod tests {
}
}
/// D1 regression guard for the per-agent Copilot gating (review
/// finding #5). With neither GitHub egress (`use_github=false`) nor
/// the Copilot agent selected (`want_copilot=false`), `refresh` must
/// not capture a Copilot token — both capture conditions are off, so
/// `copilot_token_file` is `None` by construction, independent of any
/// host/`$HOME`/gh state (this combination skips both `refresh_gh`
/// and `refresh_copilot`). Also re-asserts the security-critical
/// property that the copilot token *path* lives *outside* the
/// guest-bind-mounted state dir, same as the other token files.
#[test]
fn copilot_token_not_captured_without_use_github_or_want_copilot() {
let dir = tempfile::tempdir().unwrap();
let sd = dir.path();
let creds = super::refresh(sd, "/workspace/p", false, false).unwrap();
assert!(
creds.copilot_token_file.is_none(),
"copilot token captured despite use_github=false and want_copilot=false"
);
// Independent of capture: the path the proxy would re-read must
// never be under the guest mount (threat-model invariant).
let cp = copilot_token_path(sd);
assert!(
!cp.starts_with(sd),
"copilot token path {} must not be inside the bind-mounted state dir {}",
cp.display(),
sd.display(),
);
assert_eq!(cp.parent().unwrap().parent(), sd.parent());
}
/// **Placeholder distinctness**. If one placeholder were a
/// substring of another, the secret-substitution proxy would
/// swap the wrong token on outbound bytes — silently corrupting

View file

@ -21,12 +21,13 @@ pub struct Args {
#[arg(long)]
no_verify: bool,
/// Override the image reference. Defaults to
/// `ghcr.io/wirenboard/agent-vm-template:latest`. Source-checkout users
/// who built a local image can point at it
/// Override the image reference.
///
/// Defaults to `ghcr.io/wirenboard/agent-vm-template:latest`.
/// Source-checkout users who built a local image can point at it
/// (`--image localhost:5000/agent-vm-template:latest`) — agent-vm
/// detects local registries and uses plain HTTP.
#[arg(long, env = "AGENT_VM_IMAGE_TAG")]
#[arg(long, env = "AGENT_VM_IMAGE_TAG", value_name = "REF")]
image: Option<String>,
}

View file

@ -3,11 +3,28 @@
# tool itself (that's the Rust binary published as
# @wirenboard/agent-vm on npm).
#
# Contents: Debian 13 slim + the three AI coding agents (Claude
# Code, OpenCode, Codex), minimum dev tooling, and the docker engine
# (docker.io + containerd + runc + fuse-overlayfs). Other heavy
# Contents: Debian 13 slim + the four AI coding agents (Claude
# Code, OpenCode, Codex, GitHub Copilot), the docker engine
# (docker.io + containerd + runc + fuse-overlayfs), network/diagnostic
# CLIs, codestyle-pinned Python linters (black/isort/pylint matching
# `wirenboard/codestyle`), and a clone of `wirenboard/codestyle`
# at /opt/wirenboard/codestyle. Two heavier blocks (WB
# build-essentials family + armhf/arm64 cross toolchains) are
# present in the Dockerfile but COMMENTED OUT for the initial
# roll-out — see the "DEFERRED" RUN blocks below. Other heavy
# extras (LSPs, additional MCP servers, docker-buildx) are
# deliberately deferred.
# deliberately deferred too.
#
# Layer policy: the file is ordered so the LAST RUN steps are the
# agent installers (the most frequently updated content). Each is
# keyed on its agent's upstream version via a per-agent
# `AGENT_VERSION_*` ARG (see below), so the hourly cron rebuilds an
# installer layer ONLY when that agent actually released — an
# unchanged build is a pure cache hit. Everything earlier — Debian
# base, chromium, docker engine, Python lint pins, codestyle clone —
# stays cached across rebuilds, so users pulling `:latest` re-download
# only the layer(s) for whichever agent actually changed (and nothing
# at all when none did).
#
# Published to ghcr.io/wirenboard/agent-vm-template:latest by the
# hourly CI workflow (.github/workflows/build-image.yml). Source-
@ -17,19 +34,96 @@
FROM debian:13-slim
LABEL org.opencontainers.image.title="agent-vm guest template"
LABEL org.opencontainers.image.description="Guest OCI image that the agent-vm tool (npm: @wirenboard/agent-vm) boots inside each per-project microVM. Contains Debian 13 + Claude Code + OpenCode + Codex + minimal dev tooling. Rebuilt hourly to pick up new agent releases. NOT the agent-vm runtime itself — install that via `npm install -g @wirenboard/agent-vm`."
LABEL org.opencontainers.image.description="Guest OCI image that the agent-vm tool (npm: @wirenboard/agent-vm) boots inside each per-project microVM. Contains Debian 13 + Claude Code + OpenCode + Codex + GitHub Copilot + diag tooling (ssh/ping/etc.) + codestyle-pinned Python linters + /opt/wirenboard/codestyle. Rebuilt hourly to pick up new agent releases. NOT the agent-vm runtime itself — install that via `npm install -g @wirenboard/agent-vm`."
# LANG=C.UTF-8: debian:slim ships with NO locale set, leaving the guest in
# C/POSIX — which renders a non-ASCII (e.g. Cyrillic) cwd as `M-P…` meta-
# escapes in bash/readline AND makes locale-driven filesystem encodings
# default to ASCII, so Python/Node/ripgrep mis-handle or error on a non-ASCII
# path even when it mounted fine. C.UTF-8 is always present in glibc (no
# locale-gen needed), ASCII-sorting + English-messages but UTF-8-aware.
# agent-vm's launcher also sets this for every exec; having it in the image
# makes any other use of the image correct too.
ENV DEBIAN_FRONTEND=noninteractive \
HOME=/root \
PATH=/root/.local/bin:/root/.claude/local/bin:/root/.opencode/bin:/usr/local/bin:/usr/bin:/usr/sbin:/bin
PATH=/root/.local/bin:/root/.claude/local/bin:/root/.opencode/bin:/usr/local/bin:/usr/bin:/usr/sbin:/bin \
LANG=C.UTF-8
# Image-API contract version. Bump on BREAKING image changes only
# (new required mount points, changed env-var contracts, removed
# binaries, renamed in-VM paths). Routine agent-version refreshes
# do NOT change this. See `crates/agent-vm/src/defaults.rs` for the
# matching range in the binary.
# and tooling additions (build-essential, ssh, etc.) do NOT change
# this — they're additive. See `crates/agent-vm/src/defaults.rs`
# for the matching range in the binary.
RUN echo 1 > /etc/agent-vm-image-version
# Conditionally trust a host-supplied CA bundle. Only fires when
# `images/build.sh` is invoked on a host that sits behind a TLS-
# intercept proxy (e.g. agent-vm-inside-agent-vm during local dev,
# or a corporate egress MITM). The build script passes the host CA
# via `--secret id=hostca` IF it exists at
# `/usr/local/share/ca-certificates/microsandbox-ca.crt`. In CI
# (no such file → no secret → empty mount), the `-s` test fails
# and this is a no-op — the production image is unchanged.
#
# `--mount=type=secret` keeps the CA file in the buildkit secret
# store, not in the layer; what IS persisted is the merged
# /etc/ssl/certs/ca-certificates.crt. That's fine: the launcher
# re-imports its per-boot CA at /usr/local/share/ca-certificates/
# at runtime anyway, so a stale build-time CA in the bundle is
# inert (and removed if a user runs update-ca-certificates).
#
# `CA_SHIM_CACHEBUST`: buildkit does NOT include secret content in
# the RUN cache key (by design — secrets shouldn't bake into
# images). So toggling secret presence across builds doesn't
# invalidate this layer on its own. `images/build.sh` passes the
# CA file's mtime (`stat -c %Y …`) when the secret is included, so
# any change to the CA file (or going from "secret absent" → "secret
# present") shifts the ARG and rebuilds. In CI (no secret, empty
# arg) the layer also no-ops and stays cached.
ARG CA_SHIM_CACHEBUST=
RUN --mount=type=secret,id=hostca,target=/tmp/hostca.crt,required=false \
: "${CA_SHIM_CACHEBUST}" \
&& if [ -s /tmp/hostca.crt ]; then \
apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& install -d /usr/local/share/ca-certificates \
&& install -m 0644 /tmp/hostca.crt /usr/local/share/ca-certificates/agent-vm-build.crt \
&& update-ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& echo "==> host CA trusted for this build"; \
fi
# Base apt — interpreters, fetchers, search/text utilities, AND
# network/diagnostic CLIs. Bundling these into the existing base
# layer (instead of spinning up a new layer for "ssh + ping") keeps
# the layer count down: every additional layer costs a small
# per-layer materialize overhead in microsandbox even when nothing
# changes, so we only earn a new layer when we can articulate why
# it'd be invalidated independently.
#
# Network/diag tools rationale (engineers debug WB devices over
# SSH and serial; the in-VM agent often needs to confirm a host is
# reachable before chasing logs):
# - iputils-ping, iputils-tracepath, traceroute, mtr-tiny:
# reachability + per-hop latency.
# - net-tools: legacy `ifconfig`, `netstat`, `route` — still
# muscle-memory for most embedded engineers, and many WB docs
# reference them by name.
# - iproute2: modern `ip`, `ss`. Usually preinstalled, listed
# explicitly so an upstream slimming doesn't silently drop it.
# - openssh-client: `ssh`, `scp`, `sftp`, `ssh-keygen`. No server
# — the agent should never accept inbound connections.
# - sshpass: scripted password-auth SSH (lab fixtures, factory
# test rigs); also used by some WB CI scripts.
# - dnsutils: `dig`, `nslookup`, `host`.
# - netcat-openbsd: `nc` for ad-hoc TCP/UDP poking.
# - tcpdump: rare but invaluable when an HTTP MITM looks wrong.
# - gnupg: needed below to add the GitHub CLI apt key (and any
# user-side apt sources `.agent-vm.runtime.sh` adds).
# - less, vim-tiny, file, unzip, zip, rsync, htop, lsof, strace,
# tmux: standard "I'm in a shell, I need a tool" muscle-memory
# set. Each is <5 MB; the bundle is well under 30 MB.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
@ -39,6 +133,13 @@ RUN apt-get update \
bash \
python3 python3-pip \
ripgrep fd-find \
gnupg \
iputils-ping iputils-tracepath traceroute mtr-tiny \
net-tools iproute2 \
openssh-client sshpass \
dnsutils netcat-openbsd tcpdump \
less vim-tiny file unzip zip rsync \
htop lsof strace tmux \
&& rm -rf /var/lib/apt/lists/*
# Chromium for the chrome-devtools MCP server (Phase 7). The MCP
@ -134,6 +235,21 @@ RUN echo 'chrome:x:9999:9999:Chrome MCP:/home/chrome:/bin/bash' >> /etc/passwd \
' echo "agent-vm-chrome-mcp: cannot cd to /home/chrome -- image misconfigured?" >&2' \
' exit 1' \
'}' \
'# Import the microsandbox MITM CA into the chrome-owned NSS DB so' \
'# chromium trusts the intercept proxy. chromium on Linux ignores' \
'# the system CA bundle (which curl/node use), honouring only its' \
'# built-in roots + this per-user NSS DB. Done here, once, at MCP' \
'# startup -- NOT on every agent-vm launch (previously a ~270ms' \
'# certutil fork in the launcher prelude) -- and skipped when' \
'# chrome is unused (the MCP server is only spawned when enabled).' \
'# The DB is chrome-owned (UID 9999), so drop to chrome to write it.' \
'# Non-fatal: a failure should warn, not kill the MCP.' \
'_ca=/usr/local/share/ca-certificates/microsandbox-ca.crt' \
'if [ -f "$_ca" ]; then' \
' sudo -u chrome -n -- certutil -d sql:/home/chrome/.pki/nssdb -A \' \
' -t C,, -n microsandbox -i "$_ca" \' \
' || echo "agent-vm-chrome-mcp: warning: failed to import microsandbox CA (HTTPS may fail ERR_CERT_AUTHORITY_INVALID)" >&2' \
'fi' \
'exec sudo -u chrome -H -n \' \
' --preserve-env=NODE_EXTRA_CA_CERTS,SSL_CERT_FILE,CURL_CA_BUNDLE,REQUESTS_CA_BUNDLE \' \
' --preserve-env=PATH,HTTP_PROXY,HTTPS_PROXY,NO_PROXY,http_proxy,https_proxy,no_proxy \' \
@ -159,27 +275,6 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
# Pre-warm chrome-devtools-mcp's npm cache as the chrome user so the
# first browser tool call in any sandbox doesn't pay the multi-second
# `npx -y` fetch cost. `--help` exits cleanly after install/version-
# check; it's the cheapest exercise of the full install path.
#
# Pinned to a known-good version. The runtime MCP config in
# `crates/agent-vm/src/secrets.rs::write_default_claude_root_state`
# pins the SAME version — bump both together. Without the pin the
# cache stops being useful the first time upstream cuts a release:
# `npx -y` re-resolves `@latest` against the registry and re-
# downloads under the writable upper layer.
#
# Best-effort: `|| true` because npm-registry transient failure or a
# bad `--help` exit code shouldn't fail the whole `agent-vm setup`.
# A missing cache costs a multi-second fetch on first browser tool
# call; failing the image build is much worse.
#
# Must come AFTER the nodejs RUN above — npx didn't exist before it.
RUN sudo -u chrome -H npx -y chrome-devtools-mcp@1.0.1 --help >/dev/null 2>&1 \
|| echo "==> pre-warm skipped (will lazy-fetch chrome-devtools-mcp at first MCP call)"
# Docker engine — dockerd + cli + containerd + runc.
#
# Why each piece:
@ -219,20 +314,411 @@ RUN apt-get update \
&& printf '%s\n' '{' ' "storage-driver": "fuse-overlayfs"' '}' \
> /etc/docker/daemon.json
# Claude Code official installer.
RUN curl -fsSL https://claude.ai/install.sh | bash
# Pre-warm chrome-devtools-mcp's npm cache as the chrome user so the
# first browser tool call in any sandbox doesn't pay the multi-second
# `npx -y` fetch cost. `--help` exits cleanly after install/version-
# check; it's the cheapest exercise of the full install path.
#
# Pinned to a known-good version. The runtime MCP config in
# `crates/agent-vm/src/secrets.rs::write_default_claude_root_state`
# pins the SAME version — bump both together. Without the pin the
# cache stops being useful the first time upstream cuts a release:
# `npx -y` re-resolves `@latest` against the registry and re-
# downloads under the writable upper layer.
#
# Best-effort: `|| true` because npm-registry transient failure or a
# bad `--help` exit code shouldn't fail the whole `agent-vm setup`.
# A missing cache costs a multi-second fetch on first browser tool
# call; failing the image build is much worse.
#
# Must come AFTER the nodejs RUN above — npx didn't exist before it.
RUN sudo -u chrome -H npx -y chrome-devtools-mcp@1.0.1 --help >/dev/null 2>&1 \
|| echo "==> pre-warm skipped (will lazy-fetch chrome-devtools-mcp at first MCP call)"
# Wiren Board C/C++ build essentials — DEFERRED.
#
# These two RUN blocks (build-essential family + armhf/arm64 cross
# toolchains) are commented out for the initial roll-out. They
# account for ~466 MiB compressed (~1.6 GB uncompressed) of the
# original image size estimate; deferring them keeps the first
# tooling-image bump lean while the layer ordering / cron-cache
# wins land. Uncomment to re-enable when WB engineers actually
# need the cross-compile path in-VM (the existing host
# `schroot -c bullseye-amd64-sbuild …` workflow still works).
#
# Sourced from the union of debian/control Build-Depends across
# wb-mqtt-serial, wb-mqtt-mbgate, wb-rules, wb-mqtt-confed and the
# `cpp/local/linux-devenv.sh` / `cpp/ci/clang-*-subdirs.sh` scripts
# in `wirenboard/codestyle`. When uncommenting, also re-add the
# corresponding sanity-check probes for clang-format,
# arm-linux-gnueabihf-gcc, aarch64-linux-gnu-gcc, and
# qemu-arm-static at the bottom of the Dockerfile.
#
# - build-essential: gcc/g++/make/dpkg-dev metapackage.
# - debhelper, devscripts, equivs, dpkg-cross: Debian packaging
# primitives. `debhelper (>= 10)` is mandated by every WB
# debian/control; `equivs` is required for `mk-build-deps -ir`
# (the recommended `linux-devenv.sh` install path).
# - pkg-config: standard build-time discovery.
# - clang-format, clang-tidy: the only formatters/linters WB
# codestyle ships configs for (`cpp/config/.clang-format`,
# `.clang-tidy`). `cpp/ci/clang-format-subdirs.sh` and
# `clang-tidy-subdirs.sh` call them directly.
# - libcurl4-openssl-dev, libgtest-dev, libgmock-dev,
# libmodbus-dev, libsystemd-dev: most-frequent C/C++ deps
# across the WB services (modbus serial gateway, MQTT bridge,
# etc).
# - gcovr: pinned in `python/vscode/.devcontainer/devcontainer.json`
# via the `coverage-gutters` extension; wb-mqtt-serial's
# Build-Depends lists it explicitly.
# - gdb-multiarch: install-listed by `cpp/local/linux-devenv.sh`;
# debugging cross-built armhf/arm64 binaries.
# - cmake, ninja-build: not in every WB repo but common enough
# (firmware repo uses cmake) that excluding them would catch
# people off guard.
# - python3-cups, python3-libgpiod, python3-pycurl: per the
# allowed-c-extensions list in `python/config/pyproject.toml`,
# so pylint can resolve imports in WB Python projects without
# spurious E0401.
# RUN apt-get update \
# && apt-get install -y --no-install-recommends \
# build-essential \
# debhelper devscripts equivs dpkg-cross \
# pkg-config \
# clang-format clang-tidy \
# libcurl4-openssl-dev \
# libgtest-dev libgmock-dev \
# libmodbus-dev \
# libsystemd-dev \
# gcovr \
# gdb-multiarch \
# cmake ninja-build \
# python3-cups python3-libgpiod python3-pycurl \
# && rm -rf /var/lib/apt/lists/*
# Wiren Board cross-build toolchains — armhf + arm64 — DEFERRED.
#
# See the deferment note on the build-essentials block above.
#
# Every flagship WB binary ships for one of these two architectures
# (controllers are armhf, newer hardware is arm64); the canonical
# build command in `codestyle/cpp/vscode/.vscode/tasks.json` is
# `schroot -c bullseye-amd64-sbuild -- DEB_HOST_MULTIARCH=arm-…
# make -j12`, and qemu-user-static is what makes the resulting
# binary runnable on x86_64 for tests.
#
# Kept in a SEPARATE layer from the C/C++ essentials above because
# it's the single biggest discretionary addition (~300 MB
# uncompressed / ~258 MiB compressed). A future Dockerfile.slim
# can keep this commented while uncommenting the build-essentials
# block above.
#
# - crossbuild-essential-armhf, crossbuild-essential-arm64:
# gcc-arm-linux-gnueabihf / gcc-aarch64-linux-gnu meta-packages.
# - qemu-user-static: registers binfmt handlers so an `armhf` or
# `arm64` ELF executes under qemu transparently. Required by
# the `[armhf]/[arm64] Run tests for debug` tasks in codestyle.
# - debootstrap, schroot, sbuild: the canonical WB build path is
# `sbuild` inside a `schroot` named `bullseye-amd64-sbuild`.
# The chroot itself is NOT created here (it's per-host, lives
# at /srv/chroot, and requires the schroot config; the user
# creates it on first need with `sbuild-createchroot`).
# RUN apt-get update \
# && apt-get install -y --no-install-recommends \
# crossbuild-essential-armhf \
# crossbuild-essential-arm64 \
# qemu-user-static \
# debootstrap schroot sbuild \
# && rm -rf /var/lib/apt/lists/*
# Python lint/format toolchain — pinned to the versions in
# `wirenboard/codestyle/python/config/requirements.txt`. Pinning
# matters: codestyle ships a `pyproject.toml` that engineers copy
# into projects, and a black/isort/pylint version skew between
# image and project re-formats files differently → noisy diffs.
#
# `--break-system-packages` because Debian 13 enforces PEP 668 and
# we WANT these system-wide so any in-VM tool finds them. The
# microVM rootfs is throwaway, so there's no system Python we can
# accidentally damage. `--no-cache-dir` shaves ~40 MB off the
# layer (the pip download cache is dead weight in an image).
#
# Versions sourced from
# `codestyle/python/config/requirements.txt` (master). `attrs==23.1.0`
# is per the workaround in `codestyle/python/local/linux-devenv.sh`
# (newer attrs breaks the pinned pylint). `j2cli` is in
# wb-mqtt-serial's Build-Depends and is a one-line install here.
RUN pip3 install --no-cache-dir --break-system-packages \
black==24.2.0 \
isort==5.13.2 \
pylint==3.3.9 \
pytest-cov==2.10.1 \
attrs==23.1.0 \
j2cli
# Shallow-clone `wirenboard/codestyle` so the per-project
# devenv scripts work without each project re-cloning it.
#
# The script `cpp/local/linux-devenv.sh` and its Python sibling
# both expect to be invoked from inside a project as
# `bash ../codestyle/{cpp,python}/local/linux-devenv.sh`, i.e.
# with codestyle as a sibling directory. Having it at a known
# path inside the image lets a project hook (`.agent-vm.runtime.sh`)
# symlink/copy it into place at launch instead of cloning over
# the proxy on every cold start.
#
# `CODESTYLE_REF` build-arg (default `master`) lets a reproducible
# build pin to a specific ref — `git clone --branch` accepts a
# branch or tag name. The hourly CI cron just builds from master,
# matching the previous behavior.
#
# Hard-fails by default: if GitHub is unreachable the resulting
# image is missing the devenv path that several WB workflows rely
# on, and we'd rather not silently ship that. Source-checkout dev
# builds that set `AGENT_INSTALL_SOFT_FAIL=1` (see images/build.sh,
# auto-set on TLS-intercept hosts) downgrade to a warning.
#
# `AGENT_INSTALL_SOFT_FAIL` is also consumed by the three agent
# installers + the sanity check below; declared here at first use
# so a single toggle covers everything that might legitimately fail
# in a dev sandbox but must succeed in CI.
ARG CODESTYLE_REF=master
ARG AGENT_INSTALL_SOFT_FAIL=
RUN git clone --depth=1 --branch "${CODESTYLE_REF}" \
https://github.com/wirenboard/codestyle.git \
/opt/wirenboard/codestyle \
|| { msg="==> wirenboard/codestyle clone FAILED (ref=${CODESTYLE_REF}) — re-clone in-VM with: git clone https://github.com/wirenboard/codestyle.git /opt/wirenboard/codestyle"; \
if [ -n "${AGENT_INSTALL_SOFT_FAIL:-}" ]; then echo "$msg (soft-fail mode)"; else echo "$msg" >&2; exit 1; fi; }
# Per-agent cache keys (the three `ARG AGENT_VERSION_*` below, one
# per installer RUN). The workflow resolves each agent's current
# UPSTREAM version and passes it in, so an installer layer is rebuilt
# only when that agent actually released — not on every hourly cron
# run.
#
# This replaces the old single `AGENT_INSTALL_CACHEBUST=<timestamp>`
# arg, which changed every hour and re-emitted all three
# `curl … install.sh | bash` layers (plus the sanity check) on every
# build. Because those installs aren't reproducible, each rebuild
# produced fresh layer blobs → a brand-new ~200 MiB-of-agent-layers
# image every hour even when no agent version had changed, so
# `:latest` consumers re-downloaded those layers hourly for nothing.
# Keying on the real version makes an unchanged hourly build a pure
# cache hit (identical layer digests → nothing to re-pull). Local
# `images/build.sh` builds leave the defaults empty and get normal
# Docker layer-cache behaviour.
# Shared helper for the three agent installer RUNs below. Downloads
# the upstream `install.sh`, runs it under the requested shell, and
# emits a uniform soft-fail or hard-fail diagnostic depending on
# `AGENT_INSTALL_SOFT_FAIL`. Keeping the policy in one place means
# any future tweak (extra retries, log format, soft-fail discrimina-
# tion) lives in exactly one spot — the three RUNs that call it are
# trivial one-liners.
#
# Why curl-only (no python3-urllib fallback): both curl and python3's
# `ssl` module link against the same system OpenSSL, so a real TLS-
# layer failure (the curl 56 "SSL_read: unexpected eof" we see from
# inside MITM-proxied dev sandboxes) hits the fallback identically.
# `--retry 5 --retry-all-errors --http1.1` covers the transient cases
# that ARE recoverable. The MITM-sandbox case is what
# `AGENT_INSTALL_SOFT_FAIL=1` exists for.
#
# Written via `printf '%s\n'` because debian-slim's `/bin/sh` is
# POSIX dash, no heredoc niceties. The script is small enough that
# adding a layer for it is essentially free (well under 1 KB).
RUN printf '%s\n' \
'#!/bin/sh' \
'# agent-vm-install <name> <shell> <url>' \
'# <name> - agent identifier (codex, opencode, claude)' \
'# <shell> - sh|bash; the interpreter used to run the installer' \
'# <url> - upstream installer URL' \
'# Honors $AGENT_INSTALL_SOFT_FAIL: when non-empty, a download or' \
'# install failure becomes a warning + exit 0 instead of exit 1.' \
'set -eu' \
'name=$1; shell=$2; url=$3' \
'tmp=$(mktemp)' \
'trap "rm -f \"$tmp\"" EXIT INT TERM' \
'if curl -fsSL --retry 5 --retry-all-errors --http1.1 "$url" -o "$tmp" \' \
' && "$shell" "$tmp"; then' \
' exit 0' \
'fi' \
'msg="==> $name installer FAILED — install in-VM with: curl -fsSL $url | $shell"' \
'if [ -n "${AGENT_INSTALL_SOFT_FAIL:-}" ]; then' \
' echo "$msg (soft-fail mode; image will ship without $name)"' \
' exit 0' \
'fi' \
'echo "$msg" >&2' \
'exit 1' \
> /usr/local/bin/agent-vm-install \
&& chmod 0755 /usr/local/bin/agent-vm-install \
&& test -s /usr/local/bin/agent-vm-install
# The three agents each get their own RUN + their own version arg so
# a single agent's release rebuilds only that layer (and, by Docker's
# layer cascade, the layers stacked ABOVE it).
#
# Layer order is deliberate and is NOT by size. A change to any layer
# forces every layer above it to rebuild, so the TOPMOST agent layer
# is re-emitted on essentially every build that changes anything,
# while the BOTTOM agent layer is re-emitted only when it itself
# bumps. The agent that is both largest and most frequently released
# therefore belongs at the BOTTOM, not the top:
# - codex ~95 MiB, multiple stable cuts/day → bottom (first)
# - opencode ~50 MiB, several per week → middle
# - claude ~68 MiB, ~daily → top (last)
# This minimizes expected re-pushed bytes per changed build across
# the plausible release-frequency range; putting the big codex layer
# on top (so it re-pushes on every change) is the worst case. Each
# RUN references its version arg (`: "${AGENT_VERSION_*}"`) so
# BuildKit ties the layer's cache key to the version string and
# rebuilds it ONLY when that string changes.
#
# Agent installers HARD-FAIL by default — if an upstream installer
# is broken or removed, the hourly cron should NOT silently ship an
# image missing an agent. `AGENT_INSTALL_SOFT_FAIL=1` (build-arg,
# auto-set by images/build.sh on TLS-intercept dev hosts; CI never
# sets it) turns the three RUNs into warnings instead.
# Codex CLI official installer.
ARG AGENT_VERSION_CODEX=
RUN : "codex ${AGENT_VERSION_CODEX}" \
&& agent-vm-install codex sh \
https://github.com/openai/codex/releases/latest/download/install.sh
# OpenCode official installer. Installs into ~/.opencode/bin; PATH already
# includes it. Symlink into /usr/local/bin so non-login shells find it too.
RUN curl -fsSL https://opencode.ai/install | bash \
&& ln -sf /root/.opencode/bin/opencode /usr/local/bin/opencode
# The symlink is best-effort — under soft-fail mode the install may have
# been skipped, in which case there's nothing to link.
ARG AGENT_VERSION_OPENCODE=
RUN : "opencode ${AGENT_VERSION_OPENCODE}" \
&& agent-vm-install opencode bash https://opencode.ai/install \
&& { [ -x /root/.opencode/bin/opencode ] \
&& ln -sf /root/.opencode/bin/opencode /usr/local/bin/opencode \
|| true; }
# Codex CLI official installer.
RUN curl -fsSL https://github.com/openai/codex/releases/latest/download/install.sh | sh
# Claude Code official installer. MUST precede the LSP-plugin install below,
# which invokes /root/.local/bin/claude.
ARG AGENT_VERSION_CLAUDE=
RUN : "claude ${AGENT_VERSION_CLAUDE}" \
&& agent-vm-install claude bash https://claude.ai/install.sh
# Sanity check at build time so a broken installer surfaces before we push.
RUN claude --version && opencode --version && codex --version \
&& dockerd --version && docker --version && containerd --version && runc --version
# Claude Code LSP plugins (C/C++, Python, TypeScript, Go) baked in so
# in-VM Claude has code intelligence on first launch. Ports the four
# language servers the original `setup` installed (claude-vm.sh:348-362):
# clangd-lsp / pyright-lsp / typescript-lsp / gopls-lsp from the
# claude-plugins-official marketplace.
#
# Marketplace registration first: `claude plugin marketplace add` clones
# https://github.com/anthropics/claude-plugins-official.git into Claude's
# plugin state, then each `claude plugin install <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; }
# D2 fix: the running guest symlinks /root/.claude -> /agent-vm-state/claude
# (persistence, run.rs), which shadows the plugin tree baked just above so the
# booted guest's `claude plugin list` shows nothing. Stash the plugins +
# settings to a NON-shadowed path and ship a first-boot seed script; the
# launcher prelude calls it before exec'ing the agent.
# Best-effort like the LSP install above: if a transient blip skipped the
# plugins, stash nothing rather than hard-failing the image — the first-boot
# seed then becomes a no-op and plugins lazy-install at runtime.
RUN mkdir -p /opt/agent-vm/claude-seed \
&& if [ -d /root/.claude/plugins ]; then \
cp -a /root/.claude/plugins /opt/agent-vm/claude-seed/plugins; \
[ -f /root/.claude/settings.json ] \
&& cp -a /root/.claude/settings.json /opt/agent-vm/claude-seed/settings.json; \
echo "==> stashed claude LSP plugins for first-boot seeding"; \
else \
echo "==> no /root/.claude/plugins to stash (LSP install skipped); D2 seed is a no-op"; \
fi
COPY seed-claude-plugins.sh /opt/agent-vm/seed-claude-plugins.sh
RUN chmod +x /opt/agent-vm/seed-claude-plugins.sh
# D1: GitHub Copilot CLI. Its own RUN layer so the AGENT_INSTALL_CACHEBUST
# arg above invalidates it on the hourly build like the other agents, and
# so a wrong package/binary name fails the image build loudly (via the
# `copilot --version` smoke check) instead of surfacing as a
# command-not-found inside the guest at `agent-vm copilot` time.
RUN npm install -g @github/copilot && copilot --version
# Sanity check at build time. Every command is invoked directly
# (`tool --version >/dev/null`) so a present-but-broken binary —
# corrupt download, broken symlink target, missing libc symbol —
# fails the build instead of slipping through a `command -v` exists-
# check. `set -e` propagates the first failure.
#
# Agent CLIs (claude/opencode/codex) are HARD-required unless
# `AGENT_INSTALL_SOFT_FAIL` is set, in which case they're SOFT
# (missing → warning, not failure) — matches the policy on the
# installer RUNs above. /opt/wirenboard/codestyle follows the same
# policy (its clone RUN above also honors AGENT_INSTALL_SOFT_FAIL).
RUN set -e \
&& echo "== HARD requirements ==" \
&& dockerd --version >/dev/null \
&& docker --version >/dev/null \
&& containerd --version >/dev/null \
&& runc --version >/dev/null \
&& black --version >/dev/null \
&& isort --version >/dev/null \
&& pylint --version >/dev/null \
&& ssh -V >/dev/null 2>&1 \
&& sshpass -V >/dev/null 2>&1 \
&& ping -V >/dev/null 2>&1 \
&& { if [ -d /opt/wirenboard/codestyle ]; then \
echo " codestyle clone: OK"; \
elif [ -n "${AGENT_INSTALL_SOFT_FAIL:-}" ]; then \
echo " codestyle clone: MISSING (soft-fail mode)"; \
else \
echo " codestyle clone: MISSING — hard sanity failure" >&2; exit 1; \
fi; } \
&& echo "== agent CLIs ==" \
&& for bin in claude opencode codex copilot; do \
if "$bin" --version >/dev/null 2>&1; then \
ver=$("$bin" --version 2>&1 | head -1); \
echo " $bin: $ver"; \
elif [ -n "${AGENT_INSTALL_SOFT_FAIL:-}" ]; then \
echo " $bin: MISSING (soft-fail mode)"; \
else \
echo " $bin: MISSING — hard sanity failure" >&2; \
exit 1; \
fi; \
done \
&& echo "== sanity OK =="
# Smoke entrypoint; the launcher overrides this in Phase 2.
CMD ["/bin/bash"]

View file

@ -125,8 +125,53 @@ build_and_push() {
# win. `registry.insecure=true` lets us push to the loopback HTTP
# registry. We use `compression-level=3` (zstd's default) — the
# bench shows diminishing returns past that for binary-heavy layers.
# If the host is itself behind a TLS-intercept proxy (agent-vm-
# inside-agent-vm during local dev, or a corporate egress MITM),
# the buildkit container's outbound HTTPS sees the proxy's CA and
# curl/apt fail with "unable to verify the legitimacy of the
# server". Detect the host CA and:
# - pass it as a buildx secret (the Dockerfile imports it
# conditionally; no-op when the secret is absent),
# - key the RUN-cache invalidation off its mtime (CA_SHIM_
# CACHEBUST — see Dockerfile comment for why secret content
# alone doesn't invalidate the cache),
# - run the RUN steps in the host network namespace, because
# buildkit's default bridge stack drops some HTTPS connec-
# tions (curl 56 `SSL_read: unexpected eof`) mid-redirect
# through the MITM proxy.
# Production CI has no such host CA and skips all of this.
local extra=()
local host_ca="${AGENT_VM_BUILD_HOST_CA:-/usr/local/share/ca-certificates/microsandbox-ca.crt}"
local mitm_detected=
if [ -f "${host_ca}" ]; then
echo "==> Including host CA ${host_ca} as buildx secret (TLS-intercept proxy detected)"
extra+=(--secret "id=hostca,src=${host_ca}")
extra+=(--build-arg "CA_SHIM_CACHEBUST=$(stat -c %Y "${host_ca}")")
extra+=(--allow "network.host")
extra+=(--network "host")
mitm_detected=1
fi
# AGENT_INSTALL_SOFT_FAIL — independent toggle (not tied to CA
# detection) so a clean-network developer rebuilding during an
# upstream installer outage can opt in, and someone debugging
# installer changes on a MITM host can force hard-fail with
# `AGENT_VM_BUILD_SOFT_FAIL_AGENTS=0`.
#
# Default policy: MITM-detected hosts → soft-fail (the same TLS
# interception that triggers the CA shim also hits curl 56 on
# some GitHub release-asset URLs); clean hosts → hard-fail
# (matching production CI).
local soft_fail="${AGENT_VM_BUILD_SOFT_FAIL_AGENTS:-${mitm_detected}}"
if [ -n "${soft_fail}" ] && [ "${soft_fail}" != "0" ]; then
echo "==> Soft-fail mode enabled for agent installers + codestyle clone (AGENT_VM_BUILD_SOFT_FAIL_AGENTS=0 to disable)"
extra+=(--build-arg "AGENT_INSTALL_SOFT_FAIL=1")
fi
docker buildx build \
-t "${IMAGE_TAG}" \
"${extra[@]}" \
--output "type=registry,push=true,registry.insecure=true,compression=zstd,compression-level=3,force-compression=true" \
-f "${SCRIPT_DIR}/Dockerfile" \
"${SCRIPT_DIR}"

View file

@ -0,0 +1,38 @@
#!/bin/sh
# Seed the image's baked Claude LSP plugins into the persistent state dir on
# first boot.
#
# The image installs the plugins into /root/.claude/plugins at build time, but
# the launcher symlinks /root/.claude -> /agent-vm-state/claude for persistence
# (run.rs), which shadows the baked tree so `claude plugin list` sees nothing.
# This script (called from the launcher prelude before the agent execs) copies
# the stash into the state dir once and merges the enabledPlugins +
# extraKnownMarketplaces keys into the state settings.json, preserving anything
# the user already set. Best-effort: never fails the launch.
#
# Idempotent: a present /agent-vm-state/claude/plugins means we already seeded
# (or the user manages plugins themselves) — do nothing.
SEED=/opt/agent-vm/claude-seed
STATE=/agent-vm-state/claude
[ -d "$SEED/plugins" ] || exit 0
[ -e "$STATE/plugins" ] && exit 0
mkdir -p "$STATE" 2>/dev/null
cp -a "$SEED/plugins" "$STATE/plugins" 2>/dev/null || exit 0
if [ -f "$SEED/settings.json" ] && command -v node >/dev/null 2>&1; then
node -e '
const fs = require("fs");
// With `node -e CODE a b`, user args start at argv[1] (no script path slot).
const [, statePath, seedPath] = process.argv;
const seed = JSON.parse(fs.readFileSync(seedPath, "utf8"));
let st = {};
try { st = JSON.parse(fs.readFileSync(statePath, "utf8")); } catch (e) {}
st.enabledPlugins = Object.assign({}, seed.enabledPlugins || {}, st.enabledPlugins || {});
st.extraKnownMarketplaces = Object.assign({}, seed.extraKnownMarketplaces || {}, st.extraKnownMarketplaces || {});
fs.writeFileSync(statePath, JSON.stringify(st, null, 2));
' "$STATE/settings.json" "$SEED/settings.json" 2>/dev/null || true
fi
exit 0

View file

@ -9,15 +9,14 @@ Templates and tooling for distributing `agent-vm` via npm.
and `execve`s the prebuilt native binary from the matching
per-platform subpackage. Declares per-platform subpackages as
`optionalDependencies` so npm installs only the right one.
- `agent-vm-linux-x64/` — per-platform subpackage. Ships the
prebuilt `bin/agent-vm`, the patched `bin/msb`, and
`lib/libkrunfw.so.5.2.1`. agent-vm finds `msb` and `libkrunfw` via
`current_exe()`-relative paths so a user's separate microsandbox
install never shadows them.
- Future per-platform subpackages: `-linux-arm64`, `-darwin-arm64`,
`-darwin-x64`, `-win32-x64`. Add to the launcher's
`PLATFORM_PACKAGES` map and to the main package's
`optionalDependencies`.
- `agent-vm-linux-x64/`, `agent-vm-linux-arm64/` — per-platform
subpackages. Each ships the prebuilt `bin/agent-vm`, the patched
`bin/msb`, and `lib/libkrunfw.so.5.2.1`. agent-vm finds `msb` and
`libkrunfw` via `current_exe()`-relative paths so a user's separate
microsandbox install never shadows them.
- Future per-platform subpackages: `-darwin-arm64`, `-darwin-x64`,
`-win32-x64`. Add to the launcher's `PLATFORM_PACKAGES` map and to
the main package's `optionalDependencies`.
## How releases happen

View file

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

View file

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

View file

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

View file

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

View file

@ -22,7 +22,8 @@
"node": ">=18"
},
"optionalDependencies": {
"@wirenboard/agent-vm-linux-x64": "0.0.0"
"@wirenboard/agent-vm-linux-x64": "0.0.0",
"@wirenboard/agent-vm-linux-arm64": "0.0.0"
},
"publishConfig": {
"access": "public"

23
profiling/build_legacy.sh Executable file
View file

@ -0,0 +1,23 @@
#!/bin/bash
set -u
cd "$(dirname "$0")"
# wait for the main matrix to finish so we don't thrash CPU
until grep -q "ALL VARIANTS BUILT\|ABORTING" /tmp/variants_build.log 2>/dev/null; do sleep 10; done
SRC=linux-6.12.68; OUT=/tmp/variants
echo "### BUILD heavy_legacy ($(date +%T))"
cp /tmp/cfg_heavy_legacy "$SRC/.config"
( cd "$SRC" && make olddefconfig >/dev/null 2>&1 )
printf " effective: NETFILTER=%s NF_CONNTRACK=%s NF_NAT=%s IP_NF_IPTABLES=%s NF_TABLES=%s BRIDGE=%s IP6_NF=%s\n" \
"$(grep -c '^CONFIG_NETFILTER=y' $SRC/.config)" "$(grep -c '^CONFIG_NF_CONNTRACK=y' $SRC/.config)" \
"$(grep -c '^CONFIG_NF_NAT=y' $SRC/.config)" "$(grep -c '^CONFIG_IP_NF_IPTABLES=y' $SRC/.config)" \
"$(grep -c '^CONFIG_NF_TABLES=y' $SRC/.config)" "$(grep -c '^CONFIG_BRIDGE=y' $SRC/.config)" \
"$(grep -c '^CONFIG_IP6_NF_IPTABLES=y' $SRC/.config)"
t0=$(date +%s)
( cd "$SRC" && rm -f .version && make -j14 KBUILD_BUILD_TIMESTAMP="Tue Feb 17 16:15:12 CET 2026" KBUILD_BUILD_USER=root KBUILD_BUILD_HOST=libkrunfw vmlinux ) >"$OUT/build-heavy_legacy.log" 2>&1
rc=$?; t1=$(date +%s)
[ $rc -ne 0 ] && { echo "BUILD FAILED rc=$rc"; tail -5 "$OUT/build-heavy_legacy.log"; exit 1; }
python3 bin2cbundle.py -t vmlinux "$SRC/vmlinux" kernel.c
cc -fPIC -DABI_VERSION=5 -shared -Wl,-soname,libkrunfw.so.5 -o "$OUT/libkrunfw-heavy_legacy.so.5.2.1" kernel.c
strip "$OUT/libkrunfw-heavy_legacy.so.5.2.1"
echo " -> heavy_legacy built in $((t1-t0))s, $(du -h $OUT/libkrunfw-heavy_legacy.so.5.2.1|cut -f1)"
echo "### HEAVY_LEGACY DONE + ALL BUILDS COMPLETE ($(date +%T))"

45
profiling/build_variants.sh Executable file
View file

@ -0,0 +1,45 @@
#!/bin/bash
# Build libkrunfw .so variants for a boot-time A/B. One shared tree, serial
# incremental builds. Each .so saved to /tmp/variants/.
set -u
cd "$(dirname "$0")"
SRC=linux-6.12.68
OUT=/tmp/variants
mkdir -p "$OUT"
JOBS=14
KFLAGS=(KBUILD_BUILD_TIMESTAMP="Tue Feb 17 16:15:12 CET 2026" KBUILD_BUILD_USER=root KBUILD_BUILD_HOST=libkrunfw)
build_one() {
local name="$1" seed="$2"
echo "===================================================================="
echo "### BUILD $name ($(date +%T))"
cp "$seed" "$SRC/.config" || return 1
( cd "$SRC" && make olddefconfig >/dev/null 2>&1 ) || { echo "olddefconfig FAILED"; return 1; }
# report effective symbols
printf " effective: KVM=%s KVM_INTEL=%s KVM_AMD=%s NETFILTER=%s NF_CONNTRACK=%s NF_TABLES=%s BRIDGE=%s POSIX_MQUEUE=%s DEFERRED=%s\n" \
"$(grep -c '^CONFIG_KVM=y' $SRC/.config)" \
"$(grep -c '^CONFIG_KVM_INTEL=y' $SRC/.config)" \
"$(grep -c '^CONFIG_KVM_AMD=y' $SRC/.config)" \
"$(grep -c '^CONFIG_NETFILTER=y' $SRC/.config)" \
"$(grep -c '^CONFIG_NF_CONNTRACK=y' $SRC/.config)" \
"$(grep -c '^CONFIG_NF_TABLES=y' $SRC/.config)" \
"$(grep -c '^CONFIG_BRIDGE=y' $SRC/.config)" \
"$(grep -c '^CONFIG_POSIX_MQUEUE=y' $SRC/.config)" \
"$(grep -c '^CONFIG_DEFERRED_STRUCT_PAGE_INIT=y' $SRC/.config)"
local t0=$(date +%s)
( cd "$SRC" && rm -f .version && make -j$JOBS "${KFLAGS[@]}" vmlinux ) >"$OUT/build-$name.log" 2>&1
local rc=$?
local t1=$(date +%s)
if [ $rc -ne 0 ]; then echo " BUILD FAILED rc=$rc (see $OUT/build-$name.log)"; tail -5 "$OUT/build-$name.log"; return 1; fi
echo " vmlinux built in $((t1-t0))s"
python3 bin2cbundle.py -t vmlinux "$SRC/vmlinux" kernel.c || { echo "bin2cbundle FAILED"; return 1; }
cc -fPIC -DABI_VERSION=5 -shared -Wl,-soname,libkrunfw.so.5 -o "$OUT/libkrunfw-$name.so.5.2.1" kernel.c || { echo "link FAILED"; return 1; }
strip "$OUT/libkrunfw-$name.so.5.2.1"
echo " -> $OUT/libkrunfw-$name.so.5.2.1 ($(du -h "$OUT/libkrunfw-$name.so.5.2.1" | cut -f1)) total $((t1-t0))s+bundle"
}
for v in stock stock_kvm heavy_nonf heavy heavy_deferred; do
build_one "$v" "/tmp/cfg_$v" || { echo "ABORTING at $v"; exit 1; }
done
echo "### ALL VARIANTS BUILT ($(date +%T))"
ls -la "$OUT"/*.so.5.2.1

48
profiling/measure_variants.sh Executable file
View file

@ -0,0 +1,48 @@
#!/bin/bash
# Wall-clock `create` per libkrunfw variant. Interleaved across variants to
# cancel host drift; drop_caches between rounds; mean/stdev. QUIET host only.
set -u
export PATH="/home/boger/work/board/tmp/agent-vm/.agent-vm-rust/rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin:$PATH"
export AGENT_VM_NO_CHROME_MCP=1 # strip the run-phase certutil; we measure create only
export MSB_PATH=/home/boger/work/board/tmp/agent-vm/vendor/microsandbox/target/release/msb
AGENTVM=/home/boger/work/board/tmp/agent-vm/target/release/agent-vm
LIBSO=/home/boger/work/board/tmp/agent-vm/vendor/microsandbox/target/release/libkrunfw.so.5.2.1
VAR=/tmp/variants
PROJ=/root/profproj
VARIANTS=(stock stock_kvm heavy_nonf heavy_legacy heavy heavy_deferred)
ROUNDS=${1:-10}
cd "$PROJ" || exit 1
swap() { cp "$VAR/libkrunfw-$1.so.5.2.1" "$LIBSO" || exit 1; }
create() { local mem="$1"; AGENT_VM_PROFILE=1 "$AGENTVM" shell --memory "$mem" true 2>&1 \
| sed 's/\x1b\[[0-9;]*m//g' | grep -oE 'create:[[:space:]]+[0-9.]+s' | grep -oE '[0-9.]+'; }
mstat() { tr ' ' '\n' <<<"$1" | awk 'NF{n++;v=$1;s+=v;ss+=v*v;if(mn==""||v<mn)mn=v;if(v>mx)mx=v} END{if(!n){print "no data";exit} m=s/n;d=ss/n-m*m;if(d<0)d=0;printf "mean=%.3f sd=%.3f min=%.3f max=%.3f n=%d",m,sqrt(d),mn,mx,n}'; }
echo "### boot check (each variant must run a command) ###"
for v in "${VARIANTS[@]}"; do
swap "$v"; o=$(AGENT_VM_PROFILE=1 "$AGENTVM" shell echo OK_$v 2>&1)
echo "$o" | grep -q "OK_$v" && echo " $v: boots OK" || { echo " $v: *** FAILED ***"; echo "$o"|tail -3; }
done
declare -A S
echo; echo "### interleaved, 1 GiB / 2 vCPU, $ROUNDS rounds, drop_caches per round ###"
for c in $(seq 1 "$ROUNDS"); do
sync; echo 3 > /proc/sys/vm/drop_caches 2>/dev/null
for v in "${VARIANTS[@]}"; do swap "$v"; S[$v]="${S[$v]:-} $(create 1)"; done
echo " round $c done"
done
declare -A S4
echo; echo "### heavy vs heavy_deferred @ 4 GiB, 6 rounds (does deferred help at more RAM?) ###"
for c in $(seq 1 6); do
sync; echo 3 > /proc/sys/vm/drop_caches 2>/dev/null
for v in heavy heavy_deferred; do swap "$v"; S4[$v]="${S4[$v]:-} $(create 4)"; done
done
echo; echo "================= RESULTS: create wall-seconds @1GiB ================="
for v in "${VARIANTS[@]}"; do printf "%-16s %s\n" "$v" "$(mstat "${S[$v]}")"; done
echo "------- raw @1GiB -------"
for v in "${VARIANTS[@]}"; do printf "%-16s%s\n" "$v" "${S[$v]}"; done
echo; echo "================= @4GiB heavy vs deferred ================="
for v in heavy heavy_deferred; do printf "%-16s %s\n" "$v" "$(mstat "${S4[$v]}")"; done
swap heavy; echo "(restored heavy .so)"

2
vendor/microsandbox vendored

@ -1 +1 @@
Subproject commit e7590e28d53f8816a94ec38cdafd0ab5a4a4b543
Subproject commit d6b9b11732771266169beb4043a4164235a67d64