mirror of
https://github.com/wirenboard/agent-vm.git
synced 2026-07-10 00:14:30 +00:00
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>
This commit is contained in:
parent
4407bc244d
commit
913d9e0066
5 changed files with 513 additions and 0 deletions
154
crates/agent-vm/src/auto_publish.rs
Normal file
154
crates/agent-vm/src/auto_publish.rs
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
//! Lima-style auto-port-forwarding for agent-vm.
|
||||
//!
|
||||
//! Polls `/proc/net/tcp{,6}` inside the guest over the agentd
|
||||
//! channel and diff-drives a per-guest-port host listener. The host
|
||||
//! listener forwards each accepted connection through a
|
||||
//! per-connection in-guest python3 tunneller (see [`crate::exec_tunnel`]).
|
||||
//!
|
||||
//! Mirrors Lima's default policy: only `0.0.0.0` / `[::]` binds are
|
||||
//! auto-forwarded — loopback-only guest services stay private. To
|
||||
//! reach a guest 127.0.0.1 service from the host, use `--publish`
|
||||
//! (native, declarative) instead.
|
||||
//!
|
||||
//! Cancellation: the discovery task aborts itself when reading
|
||||
//! `/proc/net/tcp` errors repeatedly (sandbox shutting down). On
|
||||
//! ctrl-C the parent runtime drops the tokio handle and all spawned
|
||||
//! listeners terminate naturally.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use microsandbox::Sandbox;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::exec_tunnel;
|
||||
use crate::proc_net_tcp::{ListenEntry, parse_listen_v4, parse_listen_v6};
|
||||
|
||||
/// Poll interval — matches Lima's default cadence and is short
|
||||
/// enough that an HTTP server started inside the VM appears on the
|
||||
/// host within a couple seconds.
|
||||
const POLL_INTERVAL: Duration = Duration::from_millis(2000);
|
||||
|
||||
/// Max consecutive read errors before we give up — sandbox is
|
||||
/// almost certainly shutting down. 5 × 2s = ~10s grace.
|
||||
const MAX_CONSECUTIVE_ERRORS: u32 = 5;
|
||||
|
||||
/// Spawn the auto-port-forwarding background task.
|
||||
pub fn spawn(sandbox: Sandbox) {
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = run(sandbox).await {
|
||||
tracing::debug!(?e, "auto-publish loop exited");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn run(sandbox: Sandbox) -> Result<()> {
|
||||
let mut active: BTreeMap<u16, ForwardedPort> = BTreeMap::new();
|
||||
let mut consecutive_errors = 0u32;
|
||||
loop {
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
|
||||
let tcp4 = match sandbox.fs().read_to_string("/proc/net/tcp").await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
consecutive_errors += 1;
|
||||
tracing::debug!(?e, errors = consecutive_errors, "read /proc/net/tcp failed");
|
||||
if consecutive_errors >= MAX_CONSECUTIVE_ERRORS {
|
||||
return Ok(());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let tcp6 = sandbox
|
||||
.fs()
|
||||
.read_to_string("/proc/net/tcp6")
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
consecutive_errors = 0;
|
||||
|
||||
let wanted: std::collections::BTreeSet<u16> = parse_listen_v4(&tcp4)
|
||||
.into_iter()
|
||||
.chain(parse_listen_v6(&tcp6))
|
||||
.filter(|e| should_forward(*e))
|
||||
.map(|e| e.port)
|
||||
.collect();
|
||||
|
||||
// ADD: ports newly seen in wanted but not in active.
|
||||
let new_ports: Vec<u16> = wanted
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|p| !active.contains_key(p))
|
||||
.collect();
|
||||
for port in new_ports {
|
||||
match bind_host_for(port).await {
|
||||
Ok((host_port, listener)) => {
|
||||
let task = exec_tunnel::spawn_listener(
|
||||
listener,
|
||||
sandbox.clone(),
|
||||
"127.0.0.1".to_string(),
|
||||
port,
|
||||
);
|
||||
eprintln!(
|
||||
"==> auto-publish: guest :{port} → host 127.0.0.1:{host_port}"
|
||||
);
|
||||
active.insert(port, ForwardedPort { host_port, task });
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(guest_port = port, ?e, "failed to bind any host port");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// REMOVE: previously-active ports that disappeared from wanted.
|
||||
let stale: Vec<u16> = active
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|p| !wanted.contains(p))
|
||||
.collect();
|
||||
for port in stale {
|
||||
if let Some(fp) = active.remove(&port) {
|
||||
fp.task.abort();
|
||||
eprintln!(
|
||||
"==> auto-publish: guest :{port} closed (released host 127.0.0.1:{})",
|
||||
fp.host_port
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ForwardedPort {
|
||||
host_port: u16,
|
||||
task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
/// Decide whether to auto-forward this listener. Mirrors Lima's
|
||||
/// default: only forward wildcard binds; loopback-only services
|
||||
/// stay private to the guest.
|
||||
fn should_forward(entry: ListenEntry) -> bool {
|
||||
match entry.addr {
|
||||
IpAddr::V4(a) => a.is_unspecified(),
|
||||
IpAddr::V6(a) => a.is_unspecified(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to bind `127.0.0.1:guest_port` first (so the host port
|
||||
/// mirrors the guest port — Lima's behavior); if that's taken,
|
||||
/// fall back to an OS-assigned ephemeral port.
|
||||
async fn bind_host_for(guest_port: u16) -> Result<(u16, tokio::net::TcpListener)> {
|
||||
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), guest_port);
|
||||
if let Ok(l) = tokio::net::TcpListener::bind(addr).await {
|
||||
let p = l.local_addr()?.port();
|
||||
return Ok((p, l));
|
||||
}
|
||||
// Ephemeral.
|
||||
let l = tokio::net::TcpListener::bind(SocketAddr::new(
|
||||
IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
0,
|
||||
))
|
||||
.await?;
|
||||
let p = l.local_addr()?.port();
|
||||
Ok((p, l))
|
||||
}
|
||||
179
crates/agent-vm/src/exec_tunnel.rs
Normal file
179
crates/agent-vm/src/exec_tunnel.rs
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
//! Host → guest port forwarding via per-connection in-guest tunnellers.
|
||||
//!
|
||||
//! Avoids touching microsandbox's `PortPublisher` (which would need a
|
||||
//! runtime add/remove API and cross-process IPC into the `msb` child).
|
||||
//! Instead, for each accepted host connection we `exec` a tiny python3
|
||||
//! script inside the guest that bridges its stdin/stdout to a TCP
|
||||
//! socket on the guest loopback, then pipe bytes between the host
|
||||
//! TcpStream and the exec session's stdin/stdout over the existing
|
||||
//! agentd channel.
|
||||
//!
|
||||
//! Trade-offs vs the native smoltcp `--publish` path:
|
||||
//! - one python3 process per inbound connection (heavy: tens of ms
|
||||
//! startup, ~10 MiB RSS each) — fine for dev tunnels, bad for
|
||||
//! high-throughput traffic.
|
||||
//! - bytes traverse: host TcpStream → agent.sock → agentd → python3
|
||||
//! stdin → 127.0.0.1:guest_port. Extra hops vs smoltcp's direct
|
||||
//! `inbound_relay → tcp::Socket` path.
|
||||
//!
|
||||
//! Upside: zero changes to the microsandbox SDK, and the guest's
|
||||
//! 127.0.0.1 listener is reachable (smoltcp publish dials the guest
|
||||
//! VLAN IP, so `127.0.0.1`-only services are unreachable that way).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use microsandbox::Sandbox;
|
||||
use microsandbox::sandbox::exec::{ExecEvent, ExecSink};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// Spawn the accept loop for a pre-bound listener. Each accepted
|
||||
/// connection is bridged through a freshly-exec'd python3 tunneller
|
||||
/// inside the guest.
|
||||
///
|
||||
/// Returns a `JoinHandle` for the listener loop so the auto-publish
|
||||
/// discovery loop can `.abort()` it when the guest port disappears.
|
||||
pub fn spawn_listener(
|
||||
listener: TcpListener,
|
||||
sandbox: Sandbox,
|
||||
guest_host: String,
|
||||
guest_port: u16,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let (stream, peer) = match listener.accept().await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::warn!(?e, "auto-publish accept failed");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let sb = sandbox.clone();
|
||||
let g_host = guest_host.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = bridge_one(sb, stream, &g_host, guest_port).await {
|
||||
tracing::debug!(?peer, ?e, "auto-publish bridge ended");
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn bridge_one(
|
||||
sandbox: Sandbox,
|
||||
stream: TcpStream,
|
||||
guest_host: &str,
|
||||
guest_port: u16,
|
||||
) -> Result<()> {
|
||||
let script = build_tunnel_script(guest_host, guest_port);
|
||||
let mut handle = sandbox
|
||||
.exec_stream_with("python3", |e| {
|
||||
e.args(["-u", "-c", script.as_str()]).stdin_pipe()
|
||||
})
|
||||
.await
|
||||
.context("starting in-guest tunneller")?;
|
||||
let stdin_sink = handle
|
||||
.take_stdin()
|
||||
.context("tunneller stdin pipe missing")?;
|
||||
let stdin_sink = Arc::new(Mutex::new(stdin_sink));
|
||||
|
||||
let (mut host_rx, mut host_tx) = stream.into_split();
|
||||
|
||||
// host → guest: read TcpStream, push to python stdin
|
||||
let sink_for_writer = stdin_sink.clone();
|
||||
let host_to_guest = tokio::spawn(async move {
|
||||
let mut buf = [0u8; 16384];
|
||||
loop {
|
||||
match host_rx.read(&mut buf).await {
|
||||
Ok(0) | Err(_) => {
|
||||
// half-close: tell python EOF so it shutdown(SHUT_WR)s
|
||||
let _ = close_sink(&sink_for_writer).await;
|
||||
return;
|
||||
}
|
||||
Ok(n) => {
|
||||
let sink = sink_for_writer.lock().await;
|
||||
if sink.write(&buf[..n]).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// guest → host: drain ExecEvent::Stdout, push to TcpStream
|
||||
let mut exited = false;
|
||||
while let Some(event) = handle.recv().await {
|
||||
match event {
|
||||
ExecEvent::Stdout(data) => {
|
||||
if host_tx.write_all(&data).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
ExecEvent::Exited { .. } | ExecEvent::Failed(_) => {
|
||||
exited = true;
|
||||
break;
|
||||
}
|
||||
ExecEvent::Stderr(data) => {
|
||||
tracing::debug!(stderr = %String::from_utf8_lossy(&data), "tunneller stderr");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = host_tx.shutdown().await;
|
||||
host_to_guest.abort();
|
||||
if !exited {
|
||||
let _ = handle.kill().await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn close_sink(sink: &Arc<Mutex<ExecSink>>) -> Result<(), microsandbox::MicrosandboxError> {
|
||||
sink.lock().await.close().await
|
||||
}
|
||||
|
||||
/// Python3 bidirectional bridge: stdin ↔ socket. Half-close aware so
|
||||
/// HTTP/1.1 keep-alive clients (and other protocols that hold the
|
||||
/// read half open after sending a request) don't deadlock.
|
||||
fn build_tunnel_script(host: &str, port: u16) -> String {
|
||||
format!(
|
||||
r#"
|
||||
import socket, sys, threading
|
||||
s = socket.create_connection(("{host}", {port}))
|
||||
def s2o():
|
||||
try:
|
||||
while True:
|
||||
d = s.recv(16384)
|
||||
if not d:
|
||||
break
|
||||
sys.stdout.buffer.write(d)
|
||||
sys.stdout.buffer.flush()
|
||||
finally:
|
||||
try:
|
||||
sys.stdout.buffer.flush()
|
||||
except Exception:
|
||||
pass
|
||||
def i2s():
|
||||
try:
|
||||
while True:
|
||||
d = sys.stdin.buffer.read1(16384)
|
||||
if not d:
|
||||
break
|
||||
try:
|
||||
s.sendall(d)
|
||||
except Exception:
|
||||
break
|
||||
finally:
|
||||
try:
|
||||
s.shutdown(socket.SHUT_WR)
|
||||
except Exception:
|
||||
pass
|
||||
t = threading.Thread(target=s2o, daemon=True)
|
||||
t.start()
|
||||
i2s()
|
||||
t.join(timeout=5)
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
|
@ -1,12 +1,15 @@
|
|||
//! agent-vm — sandboxed microVMs for AI coding agents on microsandbox.
|
||||
|
||||
mod auto_publish;
|
||||
mod clipboard;
|
||||
mod defaults;
|
||||
mod exec_tunnel;
|
||||
mod host_paths;
|
||||
mod image_api_version;
|
||||
mod image_check;
|
||||
mod intercept_hook;
|
||||
mod msb_install;
|
||||
mod proc_net_tcp;
|
||||
mod pull;
|
||||
mod pull_progress;
|
||||
mod pulled_marker;
|
||||
|
|
|
|||
160
crates/agent-vm/src/proc_net_tcp.rs
Normal file
160
crates/agent-vm/src/proc_net_tcp.rs
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
//! Parse Linux `/proc/net/tcp{,6}` listen entries.
|
||||
//!
|
||||
//! Lines look like:
|
||||
//!
|
||||
//! ```text
|
||||
//! sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode ...
|
||||
//! 0: 0100007F:2382 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1089 1 ...
|
||||
//! ```
|
||||
//!
|
||||
//! - `local_address` is `HEX_IP:HEX_PORT` (each IPv4 byte
|
||||
//! reversed within the 4-byte word — little-endian as written by
|
||||
//! the kernel formatter).
|
||||
//! - `st = 0A` is TCP_LISTEN.
|
||||
//!
|
||||
//! For `/proc/net/tcp6` the IP is 32 hex chars (16 bytes) in the
|
||||
//! same per-word endianness convention.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
|
||||
/// One listening socket: bind address + port.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct ListenEntry {
|
||||
pub addr: IpAddr,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
/// Parse a single `/proc/net/tcp` body (skipping the header row).
|
||||
/// Returns only LISTEN state entries.
|
||||
pub fn parse_listen_v4(body: &str) -> BTreeSet<ListenEntry> {
|
||||
let mut out = BTreeSet::new();
|
||||
for line in body.lines().skip(1) {
|
||||
if let Some(entry) = parse_v4_line(line) {
|
||||
out.insert(entry);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn parse_listen_v6(body: &str) -> BTreeSet<ListenEntry> {
|
||||
let mut out = BTreeSet::new();
|
||||
for line in body.lines().skip(1) {
|
||||
if let Some(entry) = parse_v6_line(line) {
|
||||
out.insert(entry);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn parse_v4_line(line: &str) -> Option<ListenEntry> {
|
||||
let fields: Vec<&str> = line.split_whitespace().collect();
|
||||
// sl, local, remote, st, ...
|
||||
if fields.len() < 4 || fields[3] != "0A" {
|
||||
return None;
|
||||
}
|
||||
let (ip_hex, port_hex) = fields[1].split_once(':')?;
|
||||
if ip_hex.len() != 8 {
|
||||
return None;
|
||||
}
|
||||
let raw = u32::from_str_radix(ip_hex, 16).ok()?;
|
||||
// kernel writes in native (little-endian on x86_64) byte order:
|
||||
// 0100007F → bytes [01, 00, 00, 7F] → 127.0.0.1 read big-endian.
|
||||
let bytes = raw.to_be_bytes();
|
||||
let addr = IpAddr::V4(Ipv4Addr::new(bytes[3], bytes[2], bytes[1], bytes[0]));
|
||||
let port = u16::from_str_radix(port_hex, 16).ok()?;
|
||||
Some(ListenEntry { addr, port })
|
||||
}
|
||||
|
||||
fn parse_v6_line(line: &str) -> Option<ListenEntry> {
|
||||
let fields: Vec<&str> = line.split_whitespace().collect();
|
||||
if fields.len() < 4 || fields[3] != "0A" {
|
||||
return None;
|
||||
}
|
||||
let (ip_hex, port_hex) = fields[1].split_once(':')?;
|
||||
if ip_hex.len() != 32 {
|
||||
return None;
|
||||
}
|
||||
// The address is four little-endian u32 words concatenated.
|
||||
// Convert each word to big-endian bytes so an all-zero v6
|
||||
// address shows up as 0::0 and ::1 maps correctly.
|
||||
let mut bytes = [0u8; 16];
|
||||
for i in 0..4 {
|
||||
let word = u32::from_str_radix(&ip_hex[i * 8..(i + 1) * 8], 16).ok()?;
|
||||
let be = word.to_be_bytes();
|
||||
bytes[i * 4] = be[3];
|
||||
bytes[i * 4 + 1] = be[2];
|
||||
bytes[i * 4 + 2] = be[1];
|
||||
bytes[i * 4 + 3] = be[0];
|
||||
}
|
||||
let addr = IpAddr::V6(Ipv6Addr::from(bytes));
|
||||
let port = u16::from_str_radix(port_hex, 16).ok()?;
|
||||
Some(ListenEntry { addr, port })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
|
||||
const SAMPLE_TCP4: &str = " sl local_address rem_address st\n\
|
||||
0: 0100007F:2382 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1089 1\n\
|
||||
1: 00000000:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 42 1\n\
|
||||
2: 0100007F:0050 0100007F:8000 01 00000000:00000000 00:00000000 00000000 0 0 99 1\n";
|
||||
|
||||
#[test]
|
||||
fn parses_v4_loopback_and_wildcard() {
|
||||
let s = parse_listen_v4(SAMPLE_TCP4);
|
||||
assert!(s.contains(&ListenEntry {
|
||||
addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
port: 0x2382,
|
||||
}));
|
||||
assert!(s.contains(&ListenEntry {
|
||||
addr: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
|
||||
port: 0x1F90,
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_non_listen_states() {
|
||||
let s = parse_listen_v4(SAMPLE_TCP4);
|
||||
// st=01 (ESTABLISHED) for the third row must not appear.
|
||||
assert!(!s.contains(&ListenEntry {
|
||||
addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
port: 0x0050,
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_v6_unspecified() {
|
||||
let sample = " sl local_address remote_address st\n\
|
||||
0: 00000000000000000000000000000000:1F90 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 99 1\n";
|
||||
let s = parse_listen_v6(sample);
|
||||
assert!(s.contains(&ListenEntry {
|
||||
addr: IpAddr::V6(Ipv6Addr::UNSPECIFIED),
|
||||
port: 0x1F90,
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_v6_loopback() {
|
||||
// ::1 = 00000000000000000000000001000000 in kernel little-endian-per-u32 form
|
||||
// (last 4 bytes are 01 00 00 00 in memory → ::1)
|
||||
let sample = " sl local_address remote_address st\n\
|
||||
0: 00000000000000000000000001000000:1F90 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 99 1\n";
|
||||
let s = parse_listen_v6(sample);
|
||||
assert!(
|
||||
s.contains(&ListenEntry {
|
||||
addr: IpAddr::V6(Ipv6Addr::LOCALHOST),
|
||||
port: 0x1F90,
|
||||
}),
|
||||
"got {s:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_or_garbage_returns_empty_set() {
|
||||
assert!(parse_listen_v4("").is_empty());
|
||||
assert!(parse_listen_v4("garbage garbage garbage\n").is_empty());
|
||||
}
|
||||
}
|
||||
|
|
@ -141,6 +141,18 @@ pub struct Args {
|
|||
#[arg(long = "publish", short = 'p')]
|
||||
publish: Vec<String>,
|
||||
|
||||
/// Auto-detect new `0.0.0.0` / `[::]` TCP listeners inside the
|
||||
/// guest and mirror each on `127.0.0.1:<same port>` on the host
|
||||
/// (Lima-style). Polls `/proc/net/tcp{,6}` over the agentd
|
||||
/// channel every ~2s; spawns a tokio TCP listener per detected
|
||||
/// port and bridges each accepted connection through a
|
||||
/// per-connection in-guest python3 tunneller. Loopback-only
|
||||
/// guest binds (`127.0.0.1`) are intentionally NOT forwarded
|
||||
/// (privacy / Lima parity). Heavy on overhead — fine for
|
||||
/// dev tunnels, use `--publish` for high-throughput.
|
||||
#[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.
|
||||
|
|
@ -669,6 +681,11 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
|
|||
.await
|
||||
.context("verifying image-API contract version")?;
|
||||
|
||||
if args.auto_publish {
|
||||
eprintln!("==> auto-publish: polling /proc/net/tcp every ~2s for new 0.0.0.0 listeners");
|
||||
crate::auto_publish::spawn(sandbox.clone());
|
||||
}
|
||||
|
||||
let inner_cmd = agent.command();
|
||||
// Prepend agent-vm's default flags (e.g. --dangerously-skip-permissions
|
||||
// for Claude) unless the user already provided them.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue