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>
This commit is contained in:
Evgeny Boger 2026-05-28 15:01:18 +00:00
parent ca516fb807
commit fd696a13ea
2 changed files with 54 additions and 2 deletions

View file

@ -141,6 +141,19 @@ pub struct Args {
#[arg(long = "publish", short = 'p')]
publish: Vec<String>,
/// Lima-style host ← guest auto-port-forwarding. The runtime
/// polls `/proc/net/tcp{,6}` inside the guest every ~2s and
/// mirrors each detected wildcard (`0.0.0.0`/`[::]`) TCP LISTEN
/// socket onto a host listener at `127.0.0.1:<same port>` (or
/// an ephemeral host port if the preferred one is taken).
/// Loopback-only guest binds are NOT forwarded — the smoltcp
/// dial target is the guest's VLAN address, so a guest service
/// bound to `127.0.0.1` only refuses the connection. agent-vm
/// prints each new mapping to stderr as the runtime emits
/// `PortEvent`s. Off by default.
#[arg(long = "auto-publish", default_value_t = false)]
auto_publish: bool,
/// Override the OCI image reference. Default:
/// `ghcr.io/wirenboard/agent-vm-template:latest`. Use a timestamped tag
/// (`...:YYYY-MM-DDTHH`) to pin a reproducible image.
@ -468,7 +481,8 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
let has_creds = creds.anthropic_token_file.is_some()
|| creds.openai_token_file.is_some()
|| creds.gh_token_file.is_some();
if has_creds || !publish_ports.is_empty() {
let auto_publish = args.auto_publish;
if has_creds || !publish_ports.is_empty() || auto_publish {
use crate::secrets::*;
let anthropic = creds.anthropic_token_file.clone();
let openai = creds.openai_token_file.clone();
@ -488,6 +502,9 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
PublishProto::Udp => n.port_udp_bind(host_bind, p.host_port, p.guest_port),
};
}
if auto_publish {
n = n.auto_publish();
}
if !has_creds {
return n;
}
@ -669,6 +686,41 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
.await
.context("verifying image-API contract version")?;
// Subscribe to the runtime's auto-publish event stream and
// surface each mapping to the user. Spawned regardless of
// whether auto-publish is enabled — when disabled the runtime
// never emits events, so the subscriber just idles. Cheap.
if args.auto_publish {
eprintln!("==> auto-publish: watching guest LISTEN sockets via /proc/net/tcp{{,6}}");
let sb_for_events = sandbox.clone();
tokio::spawn(async move {
let mut events = sb_for_events.port_events().await;
use microsandbox::protocol::network::PortEvent;
while let Some(event) = events.recv().await {
match event {
PortEvent::Added {
host_bind,
host_port,
guest_port,
} => {
eprintln!(
"==> auto-publish: guest :{guest_port} → host {host_bind}:{host_port}"
);
}
PortEvent::Removed {
host_bind,
host_port,
guest_port,
} => {
eprintln!(
"==> auto-publish: guest :{guest_port} closed (released {host_bind}:{host_port})"
);
}
}
}
});
}
let inner_cmd = agent.command();
// Prepend agent-vm's default flags (e.g. --dangerously-skip-permissions
// for Claude) unless the user already provided them.

2
vendor/microsandbox vendored

@ -1 +1 @@
Subproject commit 1e9864e0203fc03d2b91794990445649c69943d4
Subproject commit 1d2c98425ae23efea047411e82c3ecb148f72782