Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions crates/reach-cli/src/commands/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,35 @@ pub struct CreateArgs {
#[arg(long)]
pub health_port: Option<u16>,

/// 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)?;
Expand All @@ -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(),
},
};

Expand Down Expand Up @@ -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!(
Expand Down
31 changes: 30 additions & 1 deletion crates/reach-cli/src/docker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -58,6 +64,7 @@ impl Default for SandboxPorts {
vnc: 5900,
novnc: 6080,
health: 8400,
extra: Vec::new(),
}
}
}
Expand Down Expand Up @@ -117,6 +124,10 @@ pub struct SandboxPortMapping {
pub vnc: Option<u16>,
pub novnc: Option<u16>,
pub health: Option<u16>,
/// 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)]
Expand Down Expand Up @@ -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
};

Expand All @@ -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()
Expand Down Expand Up @@ -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(),
})
Expand Down Expand Up @@ -432,14 +456,19 @@ fn extract_ports(ports: &[bollard::models::Port]) -> SandboxPortMapping {
vnc: None,
novnc: None,
health: None,
extra: Vec::new(),
};

for p in ports {
match p.private_port {
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));
}
}
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/reach-cli/tests/docker_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
};
Expand Down
Loading