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
8 changes: 6 additions & 2 deletions architecture/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -709,7 +709,8 @@ sandbox workload directly. The relay supports:
subsystem. The supervisor owns its retained PTY or pipes, a 1 MiB replay
buffer, and a single stdin lease across client disconnects. Ctrl-C interrupts
the foreground process. For read-only attachments, Ctrl-C only exits the
current viewer.
current viewer. Ctrl-D or Ctrl-P followed by Ctrl-Q detaches without closing
the canonical process stdin.
- Supervised CLI attachment. After an established SSH transport fails, the CLI
remains alive, requests a fresh SSH session from the gateway, and reattaches
to the same canonical main process within a bounded recovery window. It does
Expand All @@ -725,7 +726,10 @@ sandbox workload directly. The relay supports:
which selects `bash -c` instead of `bash -lc`. Note `bash -c` still reads
`BASH_ENV` when the child environment sets it.
- Tar-based file sync.
- Port forwarding where supported by the CLI/TUI surface.
- Port forwarding where supported by the CLI/TUI surface. CLI-owned forwards
disable SSH connection sharing and automatic forking so the spawned process
owns the listener. Background forwards are recorded by PID only after the
listener is reachable; failed startup reaps the child.
- Persistent HTTP and WebSocket service routing through gateway-managed
`ServiceEndpoint` records. `CreateSandboxRequest.service_exposures` registers
named or unnamed endpoints as part of sandbox creation, and the gateway
Expand Down
5 changes: 4 additions & 1 deletion architecture/security-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,10 @@ allowing them to continue under stale authorization. HTTP upgrades switch to
raw relay by default. A `protocol: rest` endpoint can opt in to
`websocket_credential_rewrite` for client-to-server WebSocket text messages
after an allowed `101` upgrade; server-to-client traffic and all other upgraded
protocols remain raw passthrough.
protocols remain raw passthrough. JSON-RPC and MCP endpoints refuse every
request that carries an `Upgrade` header with `403` before forwarding,
whatever the enforcement mode, because their rules apply to individual HTTP
requests and a raw relay would bypass them.

A `protocol: tcp` hostname is a connection-routing constraint, not an
application-authority boundary. Transparent capture validates the approved DNS
Expand Down
2 changes: 1 addition & 1 deletion crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1717,7 +1717,7 @@ enum SandboxCommands {
/// Connect to a sandbox.
///
/// When no name is given, reconnects to the last-used sandbox.
/// Press Ctrl-P Ctrl-Q to disconnect without terminating the main process.
/// Press Ctrl-D or Ctrl-P Ctrl-Q to disconnect without terminating the main process.
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
Connect {
/// Sandbox name (defaults to last-used sandbox).
Expand Down
135 changes: 109 additions & 26 deletions crates/openshell-cli/src/ssh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -640,7 +640,7 @@ async fn sandbox_connect_supervised(
recovery_deadline = Some(Instant::now() + CONNECT_RECOVERY_TIMEOUT);
retry_delay = CONNECT_RETRY_INITIAL_DELAY;
eprintln!(
"Connection to sandbox lost; reconnecting. To disconnect, press Ctrl-P then Ctrl-Q after reattachment; press Ctrl-C while retrying to cancel."
"Connection to sandbox lost; reconnecting. To disconnect, press Ctrl-D or Ctrl-P then Ctrl-Q after reattachment; press Ctrl-C while retrying to cancel."
);
}

Expand Down Expand Up @@ -761,20 +761,11 @@ pub async fn sandbox_forward(

let session = ssh_session_config(server, name, tls, workspace, None).await?;

let mut command = TokioCommand::from(ssh_base_command(&session.proxy_command));
command
.arg("-N")
.arg("-o")
.arg("ExitOnForwardFailure=yes")
.arg("-o")
.arg(format!(
"SetEnv=OPENSHELL_FORWARD_SANDBOX_ID={}",
session.sandbox_id
))
.arg("-L")
.arg(spec.ssh_forward_arg());

command.arg("sandbox");
let mut command = TokioCommand::from(ssh_forward_command(
&session.proxy_command,
&session.sandbox_id,
spec,
));

if background {
command
Expand All @@ -793,34 +784,43 @@ pub async fn sandbox_forward(

if background {
let mut child = command.spawn().into_diagnostic()?;
let pid = child.id().ok_or_else(|| {
miette::miette!("ssh process did not expose a PID for background tracking")
})?;
let Some(pid) = child.id() else {
terminate_owned_forward_child(&mut child).await;
return Err(miette::miette!(
"ssh process did not expose a PID for background tracking"
));
};

if let Err(err) = wait_for_forward_start(&mut child, spec)
.await
.wrap_err("ssh process started but local forward listener was not reachable")
{
terminate_owned_forward_child(&mut child);
terminate_owned_forward_child(&mut child).await;
return Err(err);
}

track_background_forward_or_cleanup(
let tracked = track_background_forward_or_cleanup(
workspace,
name,
port,
pid,
&session.sandbox_id,
&spec.bind_addr,
|| terminate_owned_forward_child(&mut child),
)?;
|| {
let _ = child.start_kill();
},
);
if tracked.is_err() {
let _ = child.wait().await;
}
tracked?;
return Ok(());
}

let status = {
let mut child = command.spawn().into_diagnostic()?;
if let Err(err) = wait_for_forward_start(&mut child, spec).await {
let _ = child.kill().await;
terminate_owned_forward_child(&mut child).await;
return Err(err);
}
eprintln!("{}", foreground_forward_started_message(name, spec));
Expand All @@ -834,6 +834,30 @@ pub async fn sandbox_forward(
Ok(())
}

/// A forward must stay owned by the process we spawn so it can be reaped or
/// tracked by PID. User SSH config may otherwise move it into a persistent mux.
fn ssh_forward_command(proxy_command: &str, sandbox_id: &str, spec: &ForwardSpec) -> Command {
let mut command = ssh_base_command(proxy_command);
command
.arg("-o")
.arg("ControlMaster=no")
.arg("-o")
.arg("ControlPath=none")
.arg("-o")
.arg("ControlPersist=no")
.arg("-o")
.arg("ForkAfterAuthentication=no")
.arg("-N")
.arg("-o")
.arg("ExitOnForwardFailure=yes")
.arg("-o")
.arg(format!("SetEnv=OPENSHELL_FORWARD_SANDBOX_ID={sandbox_id}"))
.arg("-L")
.arg(spec.ssh_forward_arg())
.arg("sandbox");
command
}

/// Wait for the local listener, racing the probe against the `ssh` child
/// exiting. An early exit (e.g. `ExitOnForwardFailure=yes` tearing down the
/// session) means forwarding never came up, so it errors instead of waiting
Expand All @@ -859,7 +883,16 @@ async fn wait_for_forward_start(child: &mut Child, spec: &ForwardSpec) -> Result
))
}
}
}?;

if let Some(status) = child.try_wait().into_diagnostic()? {
return Err(miette::miette!(
"ssh exited with status {status} after local forward listener opened on {}:{}",
forward_probe_host(spec),
spec.port,
));
}
Ok(())
}

/// Poll the local endpoint until a connect succeeds or `wait_for` elapses. The
Expand Down Expand Up @@ -935,9 +968,10 @@ fn forward_probe_host(spec: &ForwardSpec) -> &str {
}
}

/// Best-effort cleanup for the SSH child this process spawned.
fn terminate_owned_forward_child(child: &mut Child) {
let _ = child.start_kill();
/// Best-effort cleanup for the SSH child this process spawned. `kill` waits for
/// the child to exit, so failed startup cannot leave an unreaped process.
async fn terminate_owned_forward_child(child: &mut Child) {
let _ = child.kill().await;
}

/// Track a verified background forward, cleaning it up if PID-file persistence fails.
Expand Down Expand Up @@ -2434,6 +2468,55 @@ mod tests {
assert_eq!(forward_probe_host(&loopback), "127.0.0.1");
}

#[cfg(unix)]
#[test]
fn forward_ssh_command_overrides_user_multiplexing_and_forking() {
let _guard = TEST_ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let dir = tempfile::tempdir().unwrap();
let config = dir.path().join("ssh_config");
fs::write(
&config,
format!(
"Host sandbox\n ControlMaster auto\n ControlPath {}/mux-%C\n ControlPersist yes\n ForkAfterAuthentication yes\n",
dir.path().display()
),
)
.unwrap();

let forward = ssh_forward_command(
"openshell ssh-proxy --sandbox demo",
"sandbox-id",
&ForwardSpec::new(18080),
);
let output = Command::new("ssh")
.arg("-F")
.arg(&config)
.arg("-G")
.args(forward.get_args())
.output()
.unwrap();
assert!(
output.status.success(),
"ssh -G failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let effective = String::from_utf8(output.stdout).unwrap();
assert!(effective.lines().any(|line| line == "controlmaster false"));
assert!(effective.lines().any(|line| line == "controlpersist no"));
assert!(
effective
.lines()
.any(|line| line == "forkafterauthentication no")
);
assert!(
!effective
.lines()
.any(|line| { line.starts_with("controlpath ") && line != "controlpath none" })
);
}

#[tokio::test]
async fn wait_for_forward_listener_accepts_ready_listener() {
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
Expand Down
Loading
Loading