Skip to content

Commit 073aad1

Browse files
todieclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 1874977 commit 073aad1

3 files changed

Lines changed: 64 additions & 1 deletion

File tree

crates/reach-cli/src/commands/create.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@ pub struct CreateArgs {
3030
#[arg(long)]
3131
pub health_port: Option<u16>,
3232

33+
/// Publish an additional port from the sandbox to the host.
34+
///
35+
/// Format: `HOST:CONTAINER` or `PORT` (same on both sides). Repeat the
36+
/// flag to publish more than one. Example: `--extra-port 9222:9222`
37+
/// exposes Chrome's CDP debug port so a host process can drive a
38+
/// browser inside the sandbox.
39+
#[arg(long = "extra-port", value_name = "HOST:CONTAINER", value_parser = parse_port_pair)]
40+
pub extra_ports: Vec<(u16, u16)>,
41+
3342
/// Skip waiting for health check
3443
#[arg(long)]
3544
pub no_wait: bool,
@@ -46,6 +55,21 @@ pub struct CreateArgs {
4655
pub persist_profile: Option<String>,
4756
}
4857

58+
/// Parse a `HOST:CONTAINER` port pair, or a single `PORT` shorthand for
59+
/// `PORT:PORT`. Returns an error for malformed input or out-of-range numbers.
60+
fn parse_port_pair(s: &str) -> Result<(u16, u16), String> {
61+
if let Some((h, c)) = s.split_once(':') {
62+
let host: u16 = h.parse().map_err(|_| format!("invalid host port {h:?}"))?;
63+
let container: u16 = c
64+
.parse()
65+
.map_err(|_| format!("invalid container port {c:?}"))?;
66+
Ok((host, container))
67+
} else {
68+
let p: u16 = s.parse().map_err(|_| format!("invalid port {s:?}"))?;
69+
Ok((p, p))
70+
}
71+
}
72+
4973
pub async fn run(args: CreateArgs) -> anyhow::Result<()> {
5074
let cfg = ReachConfig::load();
5175
let resolution = Resolution::parse(&args.resolution)?;
@@ -68,6 +92,7 @@ pub async fn run(args: CreateArgs) -> anyhow::Result<()> {
6892
vnc: args.vnc_port.unwrap_or(cfg.sandbox.vnc_port),
6993
novnc: args.novnc_port.unwrap_or(cfg.sandbox.novnc_port),
7094
health: args.health_port.unwrap_or(cfg.sandbox.health_port),
95+
extra: args.extra_ports.clone(),
7196
},
7297
profile,
7398
};
@@ -132,6 +157,14 @@ pub async fn run(args: CreateArgs) -> anyhow::Result<()> {
132157
format!("http://localhost:{}/health", p).cyan()
133158
);
134159
}
160+
for (host_port, container_port) in &sandbox.ports.extra {
161+
println!(
162+
" {} localhost:{} -> {}/tcp",
163+
"Extra:".bold(),
164+
host_port.to_string().cyan(),
165+
container_port.to_string().cyan()
166+
);
167+
}
135168

136169
println!();
137170
println!(

crates/reach-cli/src/docker.rs

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@ pub struct SandboxPorts {
8383
pub vnc: u16,
8484
pub novnc: u16,
8585
pub health: u16,
86+
/// Additional host:container port pairs to publish, beyond the three
87+
/// built-in ports above. Used for ad-hoc workflows that need to expose
88+
/// extra services from inside the sandbox — e.g. forwarding Chrome's
89+
/// remote debugging port (9222) so a host process can drive an agent
90+
/// browser via CDP. Each tuple is (host_port, container_port).
91+
pub extra: Vec<(u16, u16)>,
8692
}
8793

8894
impl Default for SandboxPorts {
@@ -91,6 +97,7 @@ impl Default for SandboxPorts {
9197
vnc: 5900,
9298
novnc: 6080,
9399
health: 8400,
100+
extra: Vec::new(),
94101
}
95102
}
96103
}
@@ -151,6 +158,10 @@ pub struct SandboxPortMapping {
151158
pub vnc: Option<u16>,
152159
pub novnc: Option<u16>,
153160
pub health: Option<u16>,
161+
/// Extra (host_port, container_port) pairs published by the user via
162+
/// `--extra-port`. Empty when no extras were requested.
163+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
164+
pub extra: Vec<(u16, u16)>,
154165
}
155166

156167
#[derive(Debug, Clone, serde::Serialize)]
@@ -236,6 +247,15 @@ impl DockerClient {
236247
host_port: Some(config.ports.health.to_string()),
237248
}]),
238249
);
250+
for (host_port, container_port) in &config.ports.extra {
251+
map.insert(
252+
format!("{}/tcp", container_port),
253+
Some(vec![PortBinding {
254+
host_ip: Some("0.0.0.0".into()),
255+
host_port: Some(host_port.to_string()),
256+
}]),
257+
);
258+
}
239259
map
240260
};
241261

@@ -278,6 +298,9 @@ impl DockerClient {
278298
m.insert("5900/tcp".into(), HashMap::new());
279299
m.insert("6080/tcp".into(), HashMap::new());
280300
m.insert("8400/tcp".into(), HashMap::new());
301+
for (_, container_port) in &config.ports.extra {
302+
m.insert(format!("{}/tcp", container_port), HashMap::new());
303+
}
281304
m
282305
}),
283306
..Default::default()
@@ -310,6 +333,7 @@ impl DockerClient {
310333
vnc: Some(config.ports.vnc),
311334
novnc: Some(config.ports.novnc),
312335
health: Some(config.ports.health),
336+
extra: config.ports.extra.clone(),
313337
},
314338
created_at: chrono::Utc::now().to_rfc3339(),
315339
})
@@ -590,14 +614,19 @@ fn extract_ports(ports: &[bollard::models::Port]) -> SandboxPortMapping {
590614
vnc: None,
591615
novnc: None,
592616
health: None,
617+
extra: Vec::new(),
593618
};
594619

595620
for p in ports {
596621
match p.private_port {
597622
5900 => mapping.vnc = p.public_port,
598623
6080 => mapping.novnc = p.public_port,
599624
8400 => mapping.health = p.public_port,
600-
_ => {}
625+
other => {
626+
if let Some(host_port) = p.public_port {
627+
mapping.extra.push((host_port, other));
628+
}
629+
}
601630
}
602631
}
603632

crates/reach-cli/tests/docker_types.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ fn sandbox_serializes_to_json() {
124124
vnc: Some(5900),
125125
novnc: Some(6080),
126126
health: Some(8400),
127+
extra: Vec::new(),
127128
},
128129
created_at: "2026-04-02T00:00:00Z".into(),
129130
};

0 commit comments

Comments
 (0)