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(), };