Commit graph

227 commits

Author SHA1 Message Date
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
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
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
agent-vm
65f7d0e624 v0.1.15: bump for split-irqchip + 16K cmdline (--mount cap ~11 → ~100+)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:33:01 +00:00
Evgeny Boger
a850ec8edb Merge irq-multi-mount: lift --mount cap from ~11 to ~100+ on x86_64
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.
2026-05-28 14:30:53 +00:00
Evgeny Boger
4eafe4bbac lift --mount cap from ~11 to ~100+ via split_irqchip + cmdline-size patch
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>
2026-05-28 08:26:28 +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
55548a9717 v0.1.14: bump for host gh/git author identity in guest commits
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 19:31:42 +00:00
agent-vm
0c3bb5156a secrets: bake host gh/git identity into the guest gitconfig
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>
2026-05-27 19:30:07 +00:00
agent-vm
975f91683a v0.1.13: bump for 16 GiB ext4 overlay default
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 19:22:56 +00:00
agent-vm
22e8a1fc9b vendor: bump msb for ext4 overlay default 4 GiB → 16 GiB
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>
2026-05-27 19:22:02 +00:00
agent-vm
7967981222 v0.1.12: bump for /usr/sbin in attach PATH (dockerd discoverable)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 19:15:42 +00:00
agent-vm
73299bc68f agent-vm: include /usr/sbin in attach-time PATH override
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>
2026-05-27 19:15:42 +00:00
agent-vm
57deafa8ce image: put /usr/sbin on PATH so dockerd finds iptables / docker-proxy
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>
2026-05-27 18:39:49 +00:00
agent-vm
4592f0f120 image: symlink dockerd + runc into /usr/local/bin (PATH discoverable)
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>
2026-05-27 18:22:19 +00:00
agent-vm
5ddf26ffd8 v0.1.11: bump for docker engine + libkrunfw netfilter overrides
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>
2026-05-27 18:17:04 +00:00
agent-vm
0d60870acd merge docker-in-vm-explore: in-VM docker engine support
Three libkrunfw kernel-config additions and one image change so
agent-vm can host a working dockerd inside the guest with default
`--network=bridge` networking:

  75297b6  CONFIG_POSIX_MQUEUE=y          — runc/crun mqueue mount
  eadd712  +netfilter + bridge family     — docker0 + iptables NAT
  93a2aab  +nf_tables (NFT_*, NFT_COMPAT) — iptables-nft backend
  ff4f3ae  image: install docker engine    — dockerd + cli + containerd + runc

Verified end-to-end with real dockerd inside a nested microsandbox
guest on the rebuilt libkrunfw: port-publish DNAT, SNAT MASQUERADE,
user-defined networks, embedded DNS, cross-network isolation all work.

Cost summary:
  - libkrunfw.so:  +128 KB compressed (+0.6%)
  - boot time:     within noise (~10 ms / +1%)
  - guest image:   +87 MB compressed pull (+15.4%) / +281 MB on-disk
2026-05-27 18:16:10 +00:00
Evgeny Boger
ff4f3aee34 image: install docker engine (dockerd + cli + containerd + runc)
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>
2026-05-27 16:29:35 +00:00
agent-vm
93a2aabcbf libkrunfw: enable nf_tables backend (iptables-nft / NFT_*)
The previous hunk gave us the iptables-legacy backend, which works
but only after `update-alternatives --set iptables iptables-legacy`
— debian 13 (the base image we ship) defaults `iptables` to
`iptables-nft`. Without `NF_TABLES` + the `NFT_*` family, the
default debian iptables binary errors with
`Failed to initialize nft: Protocol not supported` on every rule.
`NFT_COMPAT` lets the xtables-nft userspace translate
`-j MASQUERADE`-style rules into the corresponding native nft
expression so dockerd's rule set applies cleanly without forcing
the user to switch backends.

Verified in a nested guest: both `iptables` (nft default) and
`iptables-legacy` accept `-A POSTROUTING -j MASQUERADE`.

Cost on top of the netfilter hunk: +8 KB bzImage, +64 KB on the
shipped `.so` (one more 64K page). +3 ms nested boot, within noise.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 20:51:04 +00:00
agent-vm
eadd712f06 libkrunfw: enable netfilter + bridge for docker --network=bridge
dockerd / podman default to bridge networking, which needs the
in-kernel bridge driver to set up `docker0` plus iptables (the
legacy backend here; the nf_tables backend is a follow-up hunk)
for SNAT/MASQUERADE between containers and the outside. Without
these the daemon starts but every container start fails on
`Table does not exist (do you need to insmod?)`.

Verified in a nested guest on the rebuilt libkrunfw:
- `ip link add docker0 type bridge` (was ENODEV)
- `iptables-legacy -t nat -A POSTROUTING -j MASQUERADE`
- `iptables -t nat -L` (no longer "Failed to initialize nft")

Cost on a clean v5.2.1 rebuild: +348 KB bzImage, +0 on the shipped
`.so` (the new code fits within the 64K page padding `bin2cbundle`
already adds). 15-run nested-boot mean +5 ms vs the mqueue commit
— within 1σ noise.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 20:49:28 +00:00
agent-vm
8eff1fadbe v0.1.10: bump for chrome-devtools-mcp telemetry off 2026-05-26 20:13:17 +00:00
agent-vm
6122eb4c0d secrets/test: address code-review nits on telemetry-off guard test
Two small fixes from review:

- Use tempfile::tempdir() instead of hand-rolled temp_dir()+PID+nanos
  (matches existing pattern at project_refresh_lock_blocks_second_acquire).
  RAII cleanup on assertion-panic, guaranteed-unique mkdtemp.
- Print a SKIP diagnostic to stderr when AGENT_VM_NO_CHROME_MCP is set
  in the test environment. The early-return is intentional (function
  under test removes the chrome entry on that path, leaving nothing
  to assert) but a silent return turned the guard into a no-op pass
  for any dev who had the opt-out var exported in their shell.
2026-05-26 20:10:36 +00:00
agent-vm
c13413b24d secrets: disable chrome-devtools-mcp telemetry watchdog
Sets CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS=1 in the auto-written
chrome-devtools MCP entry so the upstream MCP doesn't fork its
detached "telemetry watchdog" node child to ship usage events to
Google's Clearcut endpoint. Drops one idle node process per Claude
session and stops a silent phone-home from a supposedly-sandboxed
environment.

The agent-vm-chrome-mcp wrapper's sudo --preserve-env list already
whitelists this variable, so the env block flows through to the
`chrome` user's process.

Verified inside an agent-vm sandbox:
- baseline: watchdog node child present (PID confirmed via ps)
- with env var: watchdog not spawned; MCP init, tools/list, and a
  real navigate_page tool call all succeed; chromium still
  lazy-launches only on the first browser tool call

Adds a regression-guard unit test so a future refactor of the json!
literal can't silently drop the env block.
2026-05-26 20:10:36 +00:00
agent-vm
75297b6db9 libkrunfw: enable POSIX_MQUEUE for OCI container runtimes
runc/crun unconditionally mount /dev/mqueue from their default OCI
spec; with POSIX_MQUEUE off the guest kernel returns ENODEV and every
container start (docker run, podman, buildkit RUN) fails before user
code ever executes.

Cost measured on a clean v5.2.1 rebuild (Debian 13 gcc 14.2.0):
+4 KB stripped vmlinux, +12 KB bzImage, +64 KB on the shipped .so
(page padding). 15-run nested-VM cold-boot mean unchanged within
noise (1.069s baseline vs 1.057s with mqueue, sd ~25 ms).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 20:10:32 +00:00
agent-vm
10a0707df5 v0.1.9: bump for submodule + extra-mount GitHub scope
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 19:09:09 +00:00
agent-vm
31682fd3c9 feat(run): scan .gitmodules + extra mounts for GitHub scope; print scope at startup
Restores main-branch claude-vm.sh behavior that the Rust rewrite dropped:
submodule URLs and --mount host dirs' remotes/submodules now feed the
per-launch GitHub allow-list, so `git push` from inside a submodule no
longer hits api.github.com anonymous and 401s.

- detect_github_repos(project_dir, extra_mount_dirs): scans the project
  dir + each mount; for each dir, reads `git remote -v` and parses
  `.gitmodules` via `git config -f`.
- parse_extra_mounts moved up so the mount list feeds the scan.
- Startup logs the resolved scope: `==> GitHub repo scope (N): a/b, c/d`.
- parse_github_slug now rejects `.` / `..` segments so a path-traversal
  URL never lands in the allow-list summary or --allowed-repo argv.
- parse_gitmodules_github_slugs uses symlink_metadata so a hostile
  `.gitmodules -> ~/.config/git/config` symlink can't pull in stale
  out-of-tree submodule URLs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 18:42:57 +00:00
agent-vm
e99a02f886 v0.1.8: bump for msb-style per-layer pull progress
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:33:00 +00:00
agent-vm
e9899bacf2 Merge branch 'pull-progress': mirror msb pull's per-layer progress UI
Replaces the two-phase aggregate progress bar in agent-vm pull with the
same per-layer multi-bar layout msb pull uses (one line per layer, no
ETA). Bundles seven correctness fixes flagged during code review — see
the pull-progress commit body for details.
2026-05-25 22:32:41 +00:00
agent-vm
6e593379e8 agent-vm: pull progress now mirrors msb pull (per-layer bars, no ETA)
Rewrite crates/agent-vm/src/pull_progress.rs to render one progress bar
per layer instead of a two-phase aggregate, matching the upstream
PullProgressDisplay in vendor/microsandbox/crates/cli/lib/ui.rs.

While porting, fix seven defects flagged by code review:

  - Per-layer cache-EROFS fast path (registry.rs:701, :753) emits
    LayerMaterializeComplete with no prior Started — bar previously
    stayed magenta "downloading" then jumped straight to ✓. Introduce
    `materialize_styled: Vec<bool>` + ensure_materialize_style() and
    call it from all four materialize-phase arms so the bar always
    transitions through the correct style.

  - Bounded try_send progress channel can drop LayerMaterializeStarted
    under load; the same helper restyles on the next surviving
    materialize event rather than leaving the bar in download style.

  - `style("✓").green()` defaulted to for_stderr=false so the checkmark
    was rendered against stdout's TTY state. Add .for_stderr().

  - `render.await.ok()` silently swallowed JoinError. Add a small
    `await_render` helper that logs panics, and refactor pull.rs /
    setup.rs / run.rs to always await render before propagating pull
    errors so display.finish() runs mp.clear() to restore the terminal.

  - header.enable_steady_tick() fired even when the multi-progress
    draw target was hidden (non-TTY). Guard with `if is_tty`.

  - BRAILLE_TICKS had 11 entries; index 10 duplicated index 0,
    producing a one-frame stutter every full spinner rotation. Drop the
    trailing entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:32:35 +00:00