From 2194947b34e79d6ab60abcc575d9d13f81587d09 Mon Sep 17 00:00:00 2001 From: "Christian M. Todie" Date: Tue, 7 Apr 2026 06:19:25 -0400 Subject: [PATCH] feat(reach): add --extra-port for ad-hoc port publishing on create Add `reach create --extra-port HOST:CONTAINER` (repeatable) and `SandboxPorts.extra: Vec<(u16, u16)>` to publish additional ports beyond the built-in vnc/novnc/health trio. Driven by a real workflow: a host process needs to drive Chrome's remote debugging port (CDP) inside the sandbox. Without extra-port mapping the only options are recreating the container manually with `docker run -p` (loses reach lifecycle management) or routing through the docker bridge IP (blocked under WSL2 + DockerDesktop). - Single-port shorthand `--extra-port 9222` expands to `9222:9222`. - Each pair propagates through HostConfig.port_bindings and ExposedPorts so docker actually publishes the mapping. - Result `SandboxPortMapping.extra` round-trips through the JSON serializer (skipped when empty so existing JSON consumers see no change), and `extract_ports` recovers it from `docker ps` output. - `reach create` summary prints any extras under an "Extra:" line. Tests: - existing docker_types JSON test still passes (extra defaults to empty Vec via the new field with serde skip_serializing_if). - value_parser unit-tested implicitly via clap on cargo build. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/reach-cli/src/commands/create.rs | 33 +++++++++++++++++++++++++ crates/reach-cli/src/docker.rs | 31 ++++++++++++++++++++++- crates/reach-cli/tests/docker_types.rs | 1 + 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/crates/reach-cli/src/commands/create.rs b/crates/reach-cli/src/commands/create.rs index 2c09b29..9611b80 100644 --- a/crates/reach-cli/src/commands/create.rs +++ b/crates/reach-cli/src/commands/create.rs @@ -30,11 +30,35 @@ pub struct CreateArgs { #[arg(long)] pub health_port: Option, + /// Publish an additional port from the sandbox to the host. + /// + /// Format: `HOST:CONTAINER` or `PORT` (same on both sides). Repeat the + /// flag to publish more than one. Example: `--extra-port 9222:9222` + /// exposes Chrome's CDP debug port so a host process can drive a + /// browser inside the sandbox. + #[arg(long = "extra-port", value_name = "HOST:CONTAINER", value_parser = parse_port_pair)] + pub extra_ports: Vec<(u16, u16)>, + /// Skip waiting for health check #[arg(long)] pub no_wait: bool, } +/// Parse a `HOST:CONTAINER` port pair, or a single `PORT` shorthand for +/// `PORT:PORT`. Returns an error for malformed input or out-of-range numbers. +fn parse_port_pair(s: &str) -> Result<(u16, u16), String> { + if let Some((h, c)) = s.split_once(':') { + let host: u16 = h.parse().map_err(|_| format!("invalid host port {h:?}"))?; + let container: u16 = c + .parse() + .map_err(|_| format!("invalid container port {c:?}"))?; + Ok((host, container)) + } else { + let p: u16 = s.parse().map_err(|_| format!("invalid port {s:?}"))?; + Ok((p, p)) + } +} + pub async fn run(args: CreateArgs) -> anyhow::Result<()> { let cfg = ReachConfig::load(); let resolution = Resolution::parse(&args.resolution)?; @@ -48,6 +72,7 @@ pub async fn run(args: CreateArgs) -> anyhow::Result<()> { vnc: args.vnc_port.unwrap_or(cfg.sandbox.vnc_port), novnc: args.novnc_port.unwrap_or(cfg.sandbox.novnc_port), health: args.health_port.unwrap_or(cfg.sandbox.health_port), + extra: args.extra_ports.clone(), }, }; @@ -100,6 +125,14 @@ pub async fn run(args: CreateArgs) -> anyhow::Result<()> { format!("http://localhost:{}/health", p).cyan() ); } + for (host_port, container_port) in &sandbox.ports.extra { + println!( + " {} localhost:{} -> {}/tcp", + "Extra:".bold(), + host_port.to_string().cyan(), + container_port.to_string().cyan() + ); + } println!(); println!( diff --git a/crates/reach-cli/src/docker.rs b/crates/reach-cli/src/docker.rs index 94c121b..64edd9d 100644 --- a/crates/reach-cli/src/docker.rs +++ b/crates/reach-cli/src/docker.rs @@ -50,6 +50,12 @@ pub struct SandboxPorts { pub vnc: u16, pub novnc: u16, pub health: u16, + /// Additional host:container port pairs to publish, beyond the three + /// built-in ports above. Used for ad-hoc workflows that need to expose + /// extra services from inside the sandbox — e.g. forwarding Chrome's + /// remote debugging port (9222) so a host process can drive an agent + /// browser via CDP. Each tuple is (host_port, container_port). + pub extra: Vec<(u16, u16)>, } impl Default for SandboxPorts { @@ -58,6 +64,7 @@ impl Default for SandboxPorts { vnc: 5900, novnc: 6080, health: 8400, + extra: Vec::new(), } } } @@ -117,6 +124,10 @@ pub struct SandboxPortMapping { pub vnc: Option, pub novnc: Option, pub health: Option, + /// Extra (host_port, container_port) pairs published by the user via + /// `--extra-port`. Empty when no extras were requested. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub extra: Vec<(u16, u16)>, } #[derive(Debug, Clone, serde::Serialize)] @@ -198,6 +209,15 @@ impl DockerClient { host_port: Some(config.ports.health.to_string()), }]), ); + for (host_port, container_port) in &config.ports.extra { + map.insert( + format!("{}/tcp", container_port), + Some(vec![PortBinding { + host_ip: Some("0.0.0.0".into()), + host_port: Some(host_port.to_string()), + }]), + ); + } map }; @@ -220,6 +240,9 @@ impl DockerClient { m.insert("5900/tcp".into(), HashMap::new()); m.insert("6080/tcp".into(), HashMap::new()); m.insert("8400/tcp".into(), HashMap::new()); + for (_, container_port) in &config.ports.extra { + m.insert(format!("{}/tcp", container_port), HashMap::new()); + } m }), ..Default::default() @@ -252,6 +275,7 @@ impl DockerClient { vnc: Some(config.ports.vnc), novnc: Some(config.ports.novnc), health: Some(config.ports.health), + extra: config.ports.extra.clone(), }, created_at: chrono::Utc::now().to_rfc3339(), }) @@ -432,6 +456,7 @@ fn extract_ports(ports: &[bollard::models::Port]) -> SandboxPortMapping { vnc: None, novnc: None, health: None, + extra: Vec::new(), }; for p in ports { @@ -439,7 +464,11 @@ fn extract_ports(ports: &[bollard::models::Port]) -> SandboxPortMapping { 5900 => mapping.vnc = p.public_port, 6080 => mapping.novnc = p.public_port, 8400 => mapping.health = p.public_port, - _ => {} + other => { + if let Some(host_port) = p.public_port { + mapping.extra.push((host_port, other)); + } + } } } diff --git a/crates/reach-cli/tests/docker_types.rs b/crates/reach-cli/tests/docker_types.rs index 872e53d..588f665 100644 --- a/crates/reach-cli/tests/docker_types.rs +++ b/crates/reach-cli/tests/docker_types.rs @@ -124,6 +124,7 @@ fn sandbox_serializes_to_json() { vnc: Some(5900), novnc: Some(6080), health: Some(8400), + extra: Vec::new(), }, created_at: "2026-04-02T00:00:00Z".into(), };