- 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>
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>
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>
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>
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>
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>
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>
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>
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.
The launch verbs (claude/codex/opencode/shell) share one `run::Args`,
and every flag carried its full multi-sentence explanation as the
*short* help. clap renders the first doc paragraph as `-h` text, so
`-h` was a wall: 13 flags, each 3-8 lines of run-on prose. Worse, the
crate built clap without `wrap_help`, so `--help` printed each long
paragraph as one unwrapped line — the terminal soft-wrapped it, but
copy/paste and narrow widths showed a single giant line per flag.
Split short vs long help, and group the flags:
- Every option now leads with a terse one-line summary (the `-h`
text); the original detail is kept as follow-on paragraphs that
only show under `--help`.
- Flags are bucketed with `help_heading`: Sandbox resources /
GitHub access / Mounts & ports / Network egress / Image.
- Added the `wrap_help` clap feature so `--help` prose wraps to the
terminal width instead of emitting one line per paragraph.
- Real value names: <GIB>, <N>, <HOST[:GUEST]>, <[BIND:]HOST:GUEST>,
<IP|CIDR>, <OWNER/REPO>, <REF>.
- `--help` gains an Examples block, a host<->guest Networking
cheatsheet, and an Environment section (vars verified against the
code).
Top level: wire up `-V/--version` (it errored before), add a
"Getting started" footer, make the subcommand descriptions terse and
parallel, and drop the redundant "See `clipboard --help`" line.
setup/pull `--image` get the same terse-first-line split.
Pure presentation change — no flag, env var, or behaviour added or
removed. `cargo test -p agent-vm`: 123 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New "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>
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>
New `--allow-host` flag opens DestinationGroup::Host in the egress
policy, which is the group addr_classify reserves for the per-
sandbox gateway IP (the one resolve_host_dst rewrites to the
host's 127.0.0.1 at dial time). End result: a service bound to
`127.0.0.1:8080` on the host is reachable from inside the guest
as `host.microsandbox.internal:8080` — same name agentd already
puts in `/etc/hosts`, no extra setup.
Scope: opens the gateway IPs (v4 + v6) only. Not the host's LAN
IP, not the guest's own loopback, not metadata. Composes with
--allow-egress / --allow-lan / --publish / --auto-publish.
Security note in --help mirrors --allow-lan's: anything bound to
host loopback (admin UIs, dev DBs, TCP-exposed Docker socket)
becomes reachable to a possibly-compromised in-guest process.
Vendor bump (microsandbox 2dbfa38) carries the
allow_egress_group(Host)-opens-gateway-only test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Workspace version bump after merging `investigate-image-check`: the
launch-time "newer image available" probe now performs the OCI registry
Bearer-token handshake, so the banner actually fires against the default
ghcr.io image (it was silently 401'ing and never showing). Also bounds the
probe with an overall 5s budget so the extra round-trips can't stall boot.
Cargo.lock refreshed by the post-bump build.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Workspace version bump for the merged
`worktree-vm-network-investigation` branch (Lima-style host ← guest
port mirroring + per-launch egress policy hole-punching).
New AGENTS.md captures the conventions I keep forgetting:
- bump workspace version after every feature merge
- merge submodule before superproject when both move
- don't relocate build output to tmpfs
- don't `rm -rf` inline from tool calls (use scripts)
- commit-message style on this branch is multi-paragraph Why/How
README now points at AGENTS.md alongside PLAN.md / ARCHITECTURE.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings in:
- `--publish HOST:GUEST[/proto]` for host → VM port forwarding (incl. [::1]
IPv6 bind form; /udp rejected).
- `--auto-publish` for Lima-style host ← guest port mirroring (mirrors
every 0.0.0.0:* and 127.0.0.1:* listener inside the guest to the host).
- `--allow-egress <IP|CIDR>` (repeatable) and `--allow-lan` to punch
per-launch holes through the default `public_only` egress policy.
Vendor submodule bumped to f56b822 (merge of auto-publish into
agent-vm-secret-file).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
# Conflicts:
# vendor/microsandbox
Two new CLI flags to punch holes through the default `public_only`
policy without forcing users to rebuild a full NetworkPolicy:
--allow-egress 10.100.1.75
--allow-egress 10.100.1.0/24 # CIDR
--allow-egress fd00::1/128 # IPv6
--allow-egress [::1] # bare IP gets a /32 or /128
--allow-lan # opens the entire Private group
Repeatable; per-IP and group flags compose. Loopback, link-local,
and metadata groups stay denied even under --allow-lan — Private
is intentionally disjoint from those.
5 unit tests for the parser (bare v4, v4 CIDR, bare v6, v6 CIDR,
rejects garbage). Live e2e:
baseline (no flag):
169.254.169.254:80 ECONNREFUSED
10.100.1.75:80 ECONNREFUSED
--allow-egress 169.254.169.254 --allow-lan:
169.254.169.254:80 OPEN
10.100.1.75:80 OPEN
192.168.1.1:80 OPEN
127.0.0.1:80 ECONNREFUSED (loopback group not opened)
--allow-egress 10.100.1.0/24:
10.100.1.75:80 OPEN
10.100.2.1:80 ECONNREFUSED (out of CIDR)
Vendor bump (f230c29) carries the matching NetworkBuilder helpers
and the new microsandbox::microsandbox_network re-export so we can
name DestinationGroup without a direct path dep.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two 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>
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>
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>
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>
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>
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>
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>
Two coordinated changes that together raise the per-sandbox virtio
device cap on x86_64 by ~10x:
1. Enable msb_krun's userspace split irqchip in the runtime
(vendor/microsandbox bump). Lifts the libkrun virtio-IRQ allocator
ceiling from IRQ 15 (KVM in-kernel IOAPIC, 24 pins) to IRQ 223
(userspace IOAPIC, 256 pins). Requires the wirenboard/libkrun fork
(pinned in Cargo.toml [patch.crates-io] at 38e46f2) with three
correctness fixes on top of upstream msb_krun 0.1.13.
2. Patch libkrunfw to bump x86 COMMAND_LINE_SIZE 2048 → 16384.
New `libkrunfw-overrides/cmdline-size_x86_64.patch` lifts the cap
so the assembled cmdline (~36 bytes per virtio_mmio device) doesn't
silently truncate past ~10 user mounts.
CI workflow hardening that landed alongside:
- Cache key now keyed per-arch (an aarch64 patch edit no longer
invalidates the x86_64 cache).
- `config-libkrunfw*` skip pattern uses a bare suffix so future
`config-libkrunfw-tdx_${ARCH}.patch` / `-sev_` variants are also
excluded from the kernel source-patch pipeline.
User-facing knobs (`--mount` doc in run.rs, README troubleshooting,
ARCHITECTURE/PLAN narrative) updated to reflect the new ceiling.
Two coordinated changes that together raise the per-sandbox virtio
device cap on x86_64 by ~10x:
1. **Enable msb_krun's userspace split irqchip** in the runtime
(vendor/microsandbox submodule bump). KVM's in-kernel IOAPIC is
hardcoded to 24 pins, of which libkrun's allocator hands out only
IRQs 5..15 — saturated by a typical agent-vm config (rootfs, upper,
runtime fs, network, vsock, console, balloon, rng) plus a couple of
`--mount`s, so an extra mount has historically tripped
`RegisterNetDevice(IrqsExhausted)` at boot. The userspace IOAPIC
exposes 256 pins, lifting the libkrun allocator ceiling to
IRQ_MAX_SPLIT = 223. Requires a libkrun fork (pinned in
Cargo.toml) with three correctness fixes on top of upstream 0.1.13;
without them, enabling split_irqchip crashes the VMM during boot or
silently truncates the kernel cmdline.
2. **Patch libkrunfw to bump x86 COMMAND_LINE_SIZE 2048 → 16384**.
Each virtio_mmio device adds ~36 bytes of cmdline; past ~10 user
mounts the assembled cmdline crosses the stock 2048 cap, the kernel
silently truncates the tail (which includes virtio-console), and
the guest hangs in early boot with `kernel.log` stuck at 0 bytes.
New `libkrunfw-overrides/cmdline-size_x86_64.patch` lifts the cap;
CI workflow picks it up via libkrunfw's `patches/0*.patch` glob
(the `0999-overrides-` prefix sorts strictly after libkrunfw's own
numbered patches).
CI workflow hardening that landed alongside:
- cache key now keyed per-arch via `*_${ARCH}.patch` glob (an
aarch64 patch edit no longer invalidates the x86_64 cache);
- `config-libkrunfw*` skip pattern uses a bare suffix so future
`config-libkrunfw-tdx_${ARCH}.patch` / `-sev_` variants are also
excluded from the kernel source-patch pipeline.
User-facing knobs (`--mount` doc in run.rs, README troubleshooting
guide, ARCHITECTURE/PLAN narrative) updated to reflect the new
practical ceiling.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
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>
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>
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>
In-VM commits previously carried the hardcoded `agent-vm <agent-vm@msb.local>`
placeholder. Resolve the real author identity on the host (`gh api user`
first, then `git config --global user.name/email`) and write it into the
guest's `~/.gitconfig` so `git commit` inside the sandbox attributes to
the user.
The gh path also synthesizes the `<id>+<login>@users.noreply.github.com`
address when email privacy hides the public email, and the guest's
`gh-config/hosts.yml` `user:` field gets the real login.
If neither source yields anything usable, the `[user]` section is
omitted entirely — git refuses to commit rather than silently
mis-attribute.
Defensive guards:
- 3s timeout on `gh api user`, 1s on `git config` (bounded launch even
on flaky networks / hung credential helpers).
- Reject name/email/login containing ASCII control bytes — prevents
`\n[core]\n\tpager=…` gitconfig injection and YAML injection in
hosts.yml.
- `gh_login` validated against GitHub's `[A-Za-z0-9-]` charset.
- `as_u64` for user id (was `as_i64` with an incorrect comment about
the 2^53 limit).
- `String::from_utf8_lossy` for `git config` output so non-UTF-8
`user.name` values don't silently drop the identity.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Picks up wirenboard/microsandbox@1e9864e 'image/ext4: bump default
overlay size 4 GiB → 16 GiB'. Quadruples the writable overlay
ceiling for newly-created sandboxes — existing sandboxes keep their
4 GiB upper.ext4 since the create path skips re-format when the
upper already exists.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
run.rs hardcodes a PATH and forces it onto the guest at attach time
(comment explains: the image's ENV PATH is in /root's shell rc files,
but attach() execve's directly past the shell). That hardcoded value
was missing /usr/sbin, which silently undid the image-side PATH fix
in commit 57deafa — users in a fresh `agent-vm shell` see
`dockerd: command not found` even though the latest agent-vm-template
image has /usr/sbin on its ENV PATH and dockerd at /usr/sbin/dockerd.
Add /usr/sbin to the hardcoded list. Keep these in sync with
images/Dockerfile's ENV PATH (noted in the new comment).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous fix symlinked /usr/sbin/{dockerd,runc} into
/usr/local/bin so users could invoke `dockerd` directly, but that
was whack-a-mole — dockerd itself does PATH lookup at runtime for
its helper binaries:
- `iptables` (from `iptables` pkg, /usr/sbin/iptables): dockerd
aborts with "failed to register \"bridge\" driver: failed to
create NAT chain DOCKER: iptables not found".
- `docker-proxy` (from docker.io pkg, /usr/sbin/docker-proxy):
daemon.json validation fails with "userland-proxy is enabled,
but userland-proxy-path is not set" (the daemon resolves a
default path via PATH lookup before parsing the config).
- `runc` (also /usr/sbin/runc): containerd-shim PATH-looks it up
when starting containers.
Symlinking each one would be unending whack-a-mole as new helpers
get added in future docker.io revs. Adding /usr/sbin to PATH covers
all of them in one line; drop the redundant dockerd/runc symlinks
from the previous fix.
Verified end-to-end inside an agent-vm session built from the
broken (v0.1.11) image: with `PATH=$PATH:/usr/sbin dockerd &` the
daemon starts cleanly, `docker run hello-world` prints the welcome
message, `docker run alpine sh -c 'echo running uid=$(id -u)'`
runs a real container (uid=0, kernel 6.12.68), iptables NAT chain
DOCKER is populated, docker0 bridge comes up.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
debian ships dockerd at /usr/sbin/dockerd and runc at /usr/sbin/runc,
neither of which are on the image's minimal PATH
(/root/.local/bin:/root/.claude/local/bin:/root/.opencode/bin:/usr/local/bin:/usr/bin:/bin).
Without the symlinks:
- the build-time sanity check `dockerd --version` exits 127 (broke
CI on the v0.1.11 image build);
- users invoking `dockerd &` from the in-VM shell would get
"dockerd: command not found";
- containerd's PATH-lookup of runc when spawning containers would
also miss (containerd binary is at /usr/bin/containerd which IS on
PATH, but it inherits PATH from its parent).
Cheapest fix: two symlinks into /usr/local/bin, leaving /usr/sbin off
PATH to keep sysadmin binaries from polluting the agent's namespace.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Image gains dockerd/cli/containerd/runc/fuse-overlayfs/iptables;
kernel gains POSIX_MQUEUE + netfilter + nf_tables. See merge
commit 0d60870 for the cost/verification summary.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pairs with the libkrunfw-overrides commits (POSIX_MQUEUE +
netfilter + nf_tables) — those make the kernel container-capable;
this gives the guest image the userspace to drive it.
What's installed: docker.io, docker-cli, containerd, runc,
fuse-overlayfs, iptables. No docker-buildx — the +20 MB compressed
isn't worth it for the small in-VM builds an agent would do, and
the legacy builder is a fine fallback. Storage driver pinned to
fuse-overlayfs in /etc/docker/daemon.json because overlay2 fails
on overlay-on-overlay (libkrun rootfs is already overlay).
Cost on top of the current shipped template (568 MB compressed,
1.81 GB on-disk per `docker manifest inspect`):
+87 MB compressed pull (+15.4%)
+281 MB on-disk (+15.5%)
Daemon is NOT auto-started; users invoke `dockerd &` themselves
(or wire it into `.agent-vm.runtime.sh`). Most sessions don't
touch docker, so paying ~50 MB RSS at every boot would be wasteful.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>