Skip to content

Commit fcc2672

Browse files
committed
fix(cli): add TTY support, phase check, and hardening to sandbox exec
Add missing --tty/--no-tty flag required by the issue spec, plumbed through proto, CLI, and server-side PTY allocation over SSH. Harden the implementation with a client-side sandbox phase check, a 4 MiB stdin size limit to prevent OOM, and spawn_blocking for stdin reads to avoid blocking the async runtime. Move save_last_sandbox after exec succeeds for consistency. Closes #750
1 parent 5090fed commit fcc2672

File tree

4 files changed

+98
-8
lines changed

4 files changed

+98
-8
lines changed

crates/openshell-cli/src/main.rs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1236,6 +1236,11 @@ enum SandboxCommands {
12361236
/// remote command's exit code.
12371237
///
12381238
/// For interactive shell sessions, use `sandbox connect` instead.
1239+
///
1240+
/// Examples:
1241+
/// openshell sandbox exec --name my-sandbox -- ls -la /workspace
1242+
/// openshell sandbox exec -n my-sandbox --workdir /app -- python script.py
1243+
/// echo "hello" | openshell sandbox exec -n my-sandbox -- cat
12391244
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
12401245
Exec {
12411246
/// Sandbox name (defaults to last-used sandbox).
@@ -1254,6 +1259,17 @@ enum SandboxCommands {
12541259
#[arg(long, default_value_t = 0)]
12551260
timeout: u32,
12561261

1262+
/// Allocate a pseudo-terminal for the remote command.
1263+
/// Defaults to auto-detection (on when stdin and stdout are terminals).
1264+
/// Use --tty to force a PTY even when auto-detection fails, or
1265+
/// --no-tty to disable.
1266+
#[arg(long, overrides_with = "no_tty")]
1267+
tty: bool,
1268+
1269+
/// Disable pseudo-terminal allocation.
1270+
#[arg(long, overrides_with = "tty")]
1271+
no_tty: bool,
1272+
12571273
/// Command and arguments to execute.
12581274
#[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)]
12591275
command: Vec<String>,
@@ -2342,20 +2358,31 @@ async fn main() -> Result<()> {
23422358
workdir,
23432359
env,
23442360
timeout,
2361+
tty,
2362+
no_tty,
23452363
command,
23462364
} => {
23472365
let name = resolve_sandbox_name(name, &ctx.name)?;
2348-
let _ = save_last_sandbox(&ctx.name, &name);
2366+
// Resolve --tty / --no-tty into an Option<bool> override.
2367+
let tty_override = if no_tty {
2368+
Some(false)
2369+
} else if tty {
2370+
Some(true)
2371+
} else {
2372+
None // auto-detect
2373+
};
23492374
let exit_code = run::sandbox_exec_grpc(
23502375
endpoint,
23512376
&name,
23522377
&command,
23532378
workdir.as_deref(),
23542379
&env,
23552380
timeout,
2381+
tty_override,
23562382
&tls,
23572383
)
23582384
.await?;
2385+
let _ = save_last_sandbox(&ctx.name, &name);
23592386
if exit_code != 0 {
23602387
std::process::exit(exit_code);
23612388
}

crates/openshell-cli/src/run.rs

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2693,6 +2693,10 @@ pub async fn sandbox_get(server: &str, name: &str, tls: &TlsOptions) -> Result<(
26932693
Ok(())
26942694
}
26952695

2696+
/// Maximum stdin payload size (4 MiB). Prevents the CLI from reading unbounded
2697+
/// data into memory before the server rejects an oversized message.
2698+
const MAX_STDIN_PAYLOAD: usize = 4 * 1024 * 1024;
2699+
26962700
/// Execute a command in a running sandbox via gRPC, streaming output to the terminal.
26972701
///
26982702
/// Returns the remote command's exit code.
@@ -2703,6 +2707,7 @@ pub async fn sandbox_exec_grpc(
27032707
workdir: Option<&str>,
27042708
env_pairs: &[String],
27052709
timeout_seconds: u32,
2710+
tty_override: Option<bool>,
27062711
tls: &TlsOptions,
27072712
) -> Result<i32> {
27082713
let mut client = grpc_client(server, tls).await?;
@@ -2718,26 +2723,55 @@ pub async fn sandbox_exec_grpc(
27182723
.sandbox
27192724
.ok_or_else(|| miette::miette!("sandbox not found"))?;
27202725

2726+
// Verify the sandbox is ready before issuing the exec.
2727+
if SandboxPhase::try_from(sandbox.phase) != Ok(SandboxPhase::Ready) {
2728+
return Err(miette::miette!(
2729+
"sandbox '{}' is not ready (phase: {}); wait for it to reach Ready state",
2730+
name,
2731+
phase_name(sandbox.phase)
2732+
));
2733+
}
2734+
27212735
// Parse KEY=VALUE environment pairs into a HashMap.
27222736
let environment: HashMap<String, String> = env_pairs
27232737
.iter()
27242738
.map(|pair| {
27252739
let (k, v) = pair.split_once('=').ok_or_else(|| {
2726-
miette::miette!("invalid env format '{}': expected KEY=VALUE", pair)
2740+
miette::miette!(
2741+
"invalid env format '{}': expected KEY=VALUE (use KEY= for empty value)",
2742+
pair
2743+
)
27272744
})?;
27282745
Ok((k.to_string(), v.to_string()))
27292746
})
27302747
.collect::<Result<_>>()?;
27312748

2732-
// Read stdin if piped (not a TTY).
2749+
// Read stdin if piped (not a TTY), using spawn_blocking to avoid blocking
2750+
// the async runtime.
27332751
let stdin_payload = if !std::io::stdin().is_terminal() {
2734-
let mut buf = Vec::new();
2735-
std::io::stdin().read_to_end(&mut buf).into_diagnostic()?;
2736-
buf
2752+
tokio::task::spawn_blocking(|| {
2753+
let mut buf = Vec::new();
2754+
std::io::stdin()
2755+
.read_to_end(&mut buf)
2756+
.into_diagnostic()?;
2757+
if buf.len() > MAX_STDIN_PAYLOAD {
2758+
return Err(miette::miette!(
2759+
"stdin payload exceeds {} byte limit; pipe smaller inputs or use `sandbox upload`",
2760+
MAX_STDIN_PAYLOAD
2761+
));
2762+
}
2763+
Ok(buf)
2764+
})
2765+
.await
2766+
.into_diagnostic()?? // first ? unwraps JoinError, second ? unwraps Result
27372767
} else {
27382768
Vec::new()
27392769
};
27402770

2771+
// Resolve TTY mode: explicit --tty / --no-tty wins, otherwise auto-detect.
2772+
let tty = tty_override
2773+
.unwrap_or_else(|| std::io::stdin().is_terminal() && std::io::stdout().is_terminal());
2774+
27412775
// Make the streaming gRPC call.
27422776
let mut stream = client
27432777
.exec_sandbox(ExecSandboxRequest {
@@ -2747,6 +2781,7 @@ pub async fn sandbox_exec_grpc(
27472781
environment,
27482782
timeout_seconds,
27492783
stdin: stdin_payload,
2784+
tty,
27502785
})
27512786
.await
27522787
.into_diagnostic()?

crates/openshell-server/src/grpc.rs

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1043,6 +1043,7 @@ impl OpenShell for OpenShellService {
10431043
.map_err(|e| Status::invalid_argument(format!("command construction failed: {e}")))?;
10441044
let stdin_payload = req.stdin;
10451045
let timeout_seconds = req.timeout_seconds;
1046+
let request_tty = req.tty;
10461047
let sandbox_id = sandbox.id;
10471048
let handshake_secret = self.state.config.ssh_handshake_secret.clone();
10481049

@@ -1056,6 +1057,7 @@ impl OpenShell for OpenShellService {
10561057
&command_str,
10571058
stdin_payload,
10581059
timeout_seconds,
1060+
request_tty,
10591061
&handshake_secret,
10601062
)
10611063
.await
@@ -3716,6 +3718,7 @@ async fn stream_exec_over_ssh(
37163718
command: &str,
37173719
stdin_payload: Vec<u8>,
37183720
timeout_seconds: u32,
3721+
request_tty: bool,
37193722
handshake_secret: &str,
37203723
) -> Result<(), Status> {
37213724
let command_preview: String = command.chars().take(120).collect();
@@ -3764,8 +3767,13 @@ async fn stream_exec_over_ssh(
37643767
}
37653768
};
37663769

3767-
let exec =
3768-
run_exec_with_russh(local_proxy_port, command, stdin_payload.clone(), tx.clone());
3770+
let exec = run_exec_with_russh(
3771+
local_proxy_port,
3772+
command,
3773+
stdin_payload.clone(),
3774+
request_tty,
3775+
tx.clone(),
3776+
);
37693777

37703778
let exec_result = if timeout_seconds == 0 {
37713779
exec.await
@@ -3843,6 +3851,7 @@ async fn run_exec_with_russh(
38433851
local_proxy_port: u16,
38443852
command: &str,
38453853
stdin_payload: Vec<u8>,
3854+
request_tty: bool,
38463855
tx: mpsc::Sender<Result<ExecSandboxEvent, Status>>,
38473856
) -> Result<i32, Status> {
38483857
// Defense-in-depth: validate command at the transport boundary even though
@@ -3886,6 +3895,22 @@ async fn run_exec_with_russh(
38863895
.await
38873896
.map_err(|e| Status::internal(format!("failed to open ssh channel: {e}")))?;
38883897

3898+
// Request a PTY before exec when the client asked for terminal allocation.
3899+
if request_tty {
3900+
channel
3901+
.request_pty(
3902+
false,
3903+
"xterm-256color",
3904+
0, // col_width — 0 lets the server decide
3905+
0, // row_height — 0 lets the server decide
3906+
0, // pix_width
3907+
0, // pix_height
3908+
&[],
3909+
)
3910+
.await
3911+
.map_err(|e| Status::internal(format!("failed to allocate PTY: {e}")))?;
3912+
}
3913+
38893914
channel
38903915
.exec(true, command.as_bytes())
38913916
.await

proto/openshell.proto

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,9 @@ message ExecSandboxRequest {
247247

248248
// Optional stdin payload passed to the command.
249249
bytes stdin = 6;
250+
251+
// Request a pseudo-terminal for the remote command.
252+
bool tty = 7;
250253
}
251254

252255
// One stdout chunk from a sandbox exec.

0 commit comments

Comments
 (0)