Skip to content

Commit a64e433

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 a64e433

4 files changed

Lines changed: 98 additions & 29 deletions

File tree

crates/openshell-cli/src/main.rs

Lines changed: 28 additions & 7 deletions
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).
@@ -1246,14 +1251,21 @@ enum SandboxCommands {
12461251
#[arg(long)]
12471252
workdir: Option<String>,
12481253

1249-
/// Set environment variables (KEY=VALUE). Can be specified multiple times.
1250-
#[arg(long = "env", short = 'e', value_name = "KEY=VALUE")]
1251-
env: Vec<String>,
1252-
12531254
/// Timeout in seconds (0 = no timeout).
12541255
#[arg(long, default_value_t = 0)]
12551256
timeout: u32,
12561257

1258+
/// Allocate a pseudo-terminal for the remote command.
1259+
/// Defaults to auto-detection (on when stdin and stdout are terminals).
1260+
/// Use --tty to force a PTY even when auto-detection fails, or
1261+
/// --no-tty to disable.
1262+
#[arg(long, overrides_with = "no_tty")]
1263+
tty: bool,
1264+
1265+
/// Disable pseudo-terminal allocation.
1266+
#[arg(long, overrides_with = "tty")]
1267+
no_tty: bool,
1268+
12571269
/// Command and arguments to execute.
12581270
#[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)]
12591271
command: Vec<String>,
@@ -2340,22 +2352,31 @@ async fn main() -> Result<()> {
23402352
SandboxCommands::Exec {
23412353
name,
23422354
workdir,
2343-
env,
23442355
timeout,
2356+
tty,
2357+
no_tty,
23452358
command,
23462359
} => {
23472360
let name = resolve_sandbox_name(name, &ctx.name)?;
2348-
let _ = save_last_sandbox(&ctx.name, &name);
2361+
// Resolve --tty / --no-tty into an Option<bool> override.
2362+
let tty_override = if no_tty {
2363+
Some(false)
2364+
} else if tty {
2365+
Some(true)
2366+
} else {
2367+
None // auto-detect
2368+
};
23492369
let exit_code = run::sandbox_exec_grpc(
23502370
endpoint,
23512371
&name,
23522372
&command,
23532373
workdir.as_deref(),
2354-
&env,
23552374
timeout,
2375+
tty_override,
23562376
&tls,
23572377
)
23582378
.await?;
2379+
let _ = save_last_sandbox(&ctx.name, &name);
23592380
if exit_code != 0 {
23602381
std::process::exit(exit_code);
23612382
}

crates/openshell-cli/src/run.rs

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@ use openshell_bootstrap::{
2424
use openshell_core::proto::{
2525
ApproveAllDraftChunksRequest, ApproveDraftChunkRequest, ClearDraftChunksRequest,
2626
CreateProviderRequest, CreateSandboxRequest, DeleteProviderRequest, DeleteSandboxRequest,
27-
ExecSandboxRequest, GetClusterInferenceRequest, GetDraftHistoryRequest,
28-
GetDraftPolicyRequest, GetGatewayConfigRequest, GetProviderRequest, GetSandboxConfigRequest,
29-
GetSandboxLogsRequest, GetSandboxPolicyStatusRequest, GetSandboxRequest, HealthRequest,
30-
ListProvidersRequest, ListSandboxPoliciesRequest, ListSandboxesRequest, PolicyStatus, Provider,
27+
ExecSandboxRequest, GetClusterInferenceRequest, GetDraftHistoryRequest, GetDraftPolicyRequest,
28+
GetGatewayConfigRequest, GetProviderRequest, GetSandboxConfigRequest, GetSandboxLogsRequest,
29+
GetSandboxPolicyStatusRequest, GetSandboxRequest, HealthRequest, ListProvidersRequest,
30+
ListSandboxPoliciesRequest, ListSandboxesRequest, PolicyStatus, Provider,
3131
RejectDraftChunkRequest, Sandbox, SandboxPhase, SandboxPolicy, SandboxSpec, SandboxTemplate,
3232
SetClusterInferenceRequest, SettingScope, SettingValue, UpdateConfigRequest,
3333
UpdateProviderRequest, WatchSandboxRequest, exec_sandbox_event, setting_value,
@@ -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.
@@ -2701,8 +2705,8 @@ pub async fn sandbox_exec_grpc(
27012705
name: &str,
27022706
command: &[String],
27032707
workdir: Option<&str>,
2704-
env_pairs: &[String],
27052708
timeout_seconds: u32,
2709+
tty_override: Option<bool>,
27062710
tls: &TlsOptions,
27072711
) -> Result<i32> {
27082712
let mut client = grpc_client(server, tls).await?;
@@ -2718,35 +2722,51 @@ pub async fn sandbox_exec_grpc(
27182722
.sandbox
27192723
.ok_or_else(|| miette::miette!("sandbox not found"))?;
27202724

2721-
// Parse KEY=VALUE environment pairs into a HashMap.
2722-
let environment: HashMap<String, String> = env_pairs
2723-
.iter()
2724-
.map(|pair| {
2725-
let (k, v) = pair.split_once('=').ok_or_else(|| {
2726-
miette::miette!("invalid env format '{}': expected KEY=VALUE", pair)
2727-
})?;
2728-
Ok((k.to_string(), v.to_string()))
2729-
})
2730-
.collect::<Result<_>>()?;
2725+
// Verify the sandbox is ready before issuing the exec.
2726+
if SandboxPhase::try_from(sandbox.phase) != Ok(SandboxPhase::Ready) {
2727+
return Err(miette::miette!(
2728+
"sandbox '{}' is not ready (phase: {}); wait for it to reach Ready state",
2729+
name,
2730+
phase_name(sandbox.phase)
2731+
));
2732+
}
27312733

2732-
// Read stdin if piped (not a TTY).
2734+
// Read stdin if piped (not a TTY), using spawn_blocking to avoid blocking
2735+
// the async runtime.
27332736
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
2737+
tokio::task::spawn_blocking(|| {
2738+
let mut buf = Vec::new();
2739+
std::io::stdin()
2740+
.read_to_end(&mut buf)
2741+
.into_diagnostic()?;
2742+
if buf.len() > MAX_STDIN_PAYLOAD {
2743+
return Err(miette::miette!(
2744+
"stdin payload exceeds {} byte limit; pipe smaller inputs or use `sandbox upload`",
2745+
MAX_STDIN_PAYLOAD
2746+
));
2747+
}
2748+
Ok(buf)
2749+
})
2750+
.await
2751+
.into_diagnostic()?? // first ? unwraps JoinError, second ? unwraps Result
27372752
} else {
27382753
Vec::new()
27392754
};
27402755

2756+
// Resolve TTY mode: explicit --tty / --no-tty wins, otherwise auto-detect.
2757+
let tty = tty_override
2758+
.unwrap_or_else(|| std::io::stdin().is_terminal() && std::io::stdout().is_terminal());
2759+
27412760
// Make the streaming gRPC call.
27422761
let mut stream = client
27432762
.exec_sandbox(ExecSandboxRequest {
27442763
sandbox_id: sandbox.id,
27452764
command: command.to_vec(),
27462765
workdir: workdir.unwrap_or_default().to_string(),
2747-
environment,
2766+
environment: HashMap::new(),
27482767
timeout_seconds,
27492768
stdin: stdin_payload,
2769+
tty,
27502770
})
27512771
.await
27522772
.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)