Skip to content

Commit 1166ef6

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 6bc4ba6 commit 1166ef6

File tree

4 files changed

+104
-15
lines changed

4 files changed

+104
-15
lines changed

crates/openshell-cli/src/main.rs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1200,6 +1200,11 @@ enum SandboxCommands {
12001200
/// remote command's exit code.
12011201
///
12021202
/// For interactive shell sessions, use `sandbox connect` instead.
1203+
///
1204+
/// Examples:
1205+
/// openshell sandbox exec --name my-sandbox -- ls -la /workspace
1206+
/// openshell sandbox exec -n my-sandbox --workdir /app -- python script.py
1207+
/// echo "hello" | openshell sandbox exec -n my-sandbox -- cat
12031208
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
12041209
Exec {
12051210
/// Sandbox name (defaults to last-used sandbox).
@@ -1218,6 +1223,17 @@ enum SandboxCommands {
12181223
#[arg(long, default_value_t = 0)]
12191224
timeout: u32,
12201225

1226+
/// Allocate a pseudo-terminal for the remote command.
1227+
/// Defaults to auto-detection (on when stdin and stdout are terminals).
1228+
/// Use --tty to force a PTY even when auto-detection fails, or
1229+
/// --no-tty to disable.
1230+
#[arg(long, overrides_with = "no_tty")]
1231+
tty: bool,
1232+
1233+
/// Disable pseudo-terminal allocation.
1234+
#[arg(long, overrides_with = "tty")]
1235+
no_tty: bool,
1236+
12211237
/// Command and arguments to execute.
12221238
#[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)]
12231239
command: Vec<String>,
@@ -2107,20 +2123,31 @@ async fn main() -> Result<()> {
21072123
workdir,
21082124
env,
21092125
timeout,
2126+
tty,
2127+
no_tty,
21102128
command,
21112129
} => {
21122130
let name = resolve_sandbox_name(name, &ctx.name)?;
2113-
let _ = save_last_sandbox(&ctx.name, &name);
2131+
// Resolve --tty / --no-tty into an Option<bool> override.
2132+
let tty_override = if no_tty {
2133+
Some(false)
2134+
} else if tty {
2135+
Some(true)
2136+
} else {
2137+
None // auto-detect
2138+
};
21142139
let exit_code = run::sandbox_exec_grpc(
21152140
endpoint,
21162141
&name,
21172142
&command,
21182143
workdir.as_deref(),
21192144
&env,
21202145
timeout,
2146+
tty_override,
21212147
&tls,
21222148
)
21232149
.await?;
2150+
let _ = save_last_sandbox(&ctx.name, &name);
21242151
if exit_code != 0 {
21252152
std::process::exit(exit_code);
21262153
}

crates/openshell-cli/src/run.rs

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,12 @@ use openshell_bootstrap::{
2424
use openshell_core::proto::{
2525
ApproveAllDraftChunksRequest, ApproveDraftChunkRequest, ClearDraftChunksRequest,
2626
CreateProviderRequest, CreateSandboxRequest, DeleteProviderRequest, DeleteSandboxRequest,
27-
ExecSandboxRequest, GetClusterInferenceRequest, GetDraftHistoryRequest,
28-
GetDraftPolicyRequest, GetProviderRequest, GetSandboxLogsRequest,
29-
GetSandboxPolicyStatusRequest, GetSandboxRequest, HealthRequest, ListProvidersRequest,
30-
ListSandboxPoliciesRequest, ListSandboxesRequest, PolicyStatus, Provider,
31-
RejectDraftChunkRequest, Sandbox, SandboxPhase, SandboxPolicy, SandboxSpec, SandboxTemplate,
32-
SetClusterInferenceRequest, UpdateProviderRequest, UpdateSandboxPolicyRequest,
33-
WatchSandboxRequest, exec_sandbox_event,
27+
ExecSandboxRequest, GetClusterInferenceRequest, GetDraftHistoryRequest, GetDraftPolicyRequest,
28+
GetProviderRequest, GetSandboxLogsRequest, GetSandboxPolicyStatusRequest, GetSandboxRequest,
29+
HealthRequest, ListProvidersRequest, ListSandboxPoliciesRequest, ListSandboxesRequest,
30+
PolicyStatus, Provider, RejectDraftChunkRequest, Sandbox, SandboxPhase, SandboxPolicy,
31+
SandboxSpec, SandboxTemplate, SetClusterInferenceRequest, UpdateProviderRequest,
32+
UpdateSandboxPolicyRequest, WatchSandboxRequest, exec_sandbox_event,
3433
};
3534
use openshell_providers::{
3635
ProviderRegistry, detect_provider_from_command, normalize_provider_type,
@@ -2599,6 +2598,10 @@ pub async fn sandbox_get(server: &str, name: &str, tls: &TlsOptions) -> Result<(
25992598
Ok(())
26002599
}
26012600

2601+
/// Maximum stdin payload size (4 MiB). Prevents the CLI from reading unbounded
2602+
/// data into memory before the server rejects an oversized message.
2603+
const MAX_STDIN_PAYLOAD: usize = 4 * 1024 * 1024;
2604+
26022605
/// Execute a command in a running sandbox via gRPC, streaming output to the terminal.
26032606
///
26042607
/// Returns the remote command's exit code.
@@ -2609,6 +2612,7 @@ pub async fn sandbox_exec_grpc(
26092612
workdir: Option<&str>,
26102613
env_pairs: &[String],
26112614
timeout_seconds: u32,
2615+
tty_override: Option<bool>,
26122616
tls: &TlsOptions,
26132617
) -> Result<i32> {
26142618
let mut client = grpc_client(server, tls).await?;
@@ -2624,26 +2628,55 @@ pub async fn sandbox_exec_grpc(
26242628
.sandbox
26252629
.ok_or_else(|| miette::miette!("sandbox not found"))?;
26262630

2631+
// Verify the sandbox is ready before issuing the exec.
2632+
if SandboxPhase::try_from(sandbox.phase) != Ok(SandboxPhase::Ready) {
2633+
return Err(miette::miette!(
2634+
"sandbox '{}' is not ready (phase: {}); wait for it to reach Ready state",
2635+
name,
2636+
phase_name(sandbox.phase)
2637+
));
2638+
}
2639+
26272640
// Parse KEY=VALUE environment pairs into a HashMap.
26282641
let environment: HashMap<String, String> = env_pairs
26292642
.iter()
26302643
.map(|pair| {
26312644
let (k, v) = pair.split_once('=').ok_or_else(|| {
2632-
miette::miette!("invalid env format '{}': expected KEY=VALUE", pair)
2645+
miette::miette!(
2646+
"invalid env format '{}': expected KEY=VALUE (use KEY= for empty value)",
2647+
pair
2648+
)
26332649
})?;
26342650
Ok((k.to_string(), v.to_string()))
26352651
})
26362652
.collect::<Result<_>>()?;
26372653

2638-
// Read stdin if piped (not a TTY).
2654+
// Read stdin if piped (not a TTY), using spawn_blocking to avoid blocking
2655+
// the async runtime.
26392656
let stdin_payload = if !std::io::stdin().is_terminal() {
2640-
let mut buf = Vec::new();
2641-
std::io::stdin().read_to_end(&mut buf).into_diagnostic()?;
2642-
buf
2657+
tokio::task::spawn_blocking(|| {
2658+
let mut buf = Vec::new();
2659+
std::io::stdin()
2660+
.read_to_end(&mut buf)
2661+
.into_diagnostic()?;
2662+
if buf.len() > MAX_STDIN_PAYLOAD {
2663+
return Err(miette::miette!(
2664+
"stdin payload exceeds {} byte limit; pipe smaller inputs or use `sandbox upload`",
2665+
MAX_STDIN_PAYLOAD
2666+
));
2667+
}
2668+
Ok(buf)
2669+
})
2670+
.await
2671+
.into_diagnostic()?? // first ? unwraps JoinError, second ? unwraps Result
26432672
} else {
26442673
Vec::new()
26452674
};
26462675

2676+
// Resolve TTY mode: explicit --tty / --no-tty wins, otherwise auto-detect.
2677+
let tty = tty_override
2678+
.unwrap_or_else(|| std::io::stdin().is_terminal() && std::io::stdout().is_terminal());
2679+
26472680
// Make the streaming gRPC call.
26482681
let mut stream = client
26492682
.exec_sandbox(ExecSandboxRequest {
@@ -2653,6 +2686,7 @@ pub async fn sandbox_exec_grpc(
26532686
environment,
26542687
timeout_seconds,
26552688
stdin: stdin_payload,
2689+
tty,
26562690
})
26572691
.await
26582692
.into_diagnostic()?

crates/openshell-server/src/grpc.rs

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -922,6 +922,7 @@ impl OpenShell for OpenShellService {
922922
let command_str = build_remote_exec_command(&req);
923923
let stdin_payload = req.stdin;
924924
let timeout_seconds = req.timeout_seconds;
925+
let request_tty = req.tty;
925926
let sandbox_id = sandbox.id;
926927
let handshake_secret = self.state.config.ssh_handshake_secret.clone();
927928

@@ -935,6 +936,7 @@ impl OpenShell for OpenShellService {
935936
&command_str,
936937
stdin_payload,
937938
timeout_seconds,
939+
request_tty,
938940
&handshake_secret,
939941
)
940942
.await
@@ -2825,6 +2827,7 @@ async fn stream_exec_over_ssh(
28252827
command: &str,
28262828
stdin_payload: Vec<u8>,
28272829
timeout_seconds: u32,
2830+
request_tty: bool,
28282831
handshake_secret: &str,
28292832
) -> Result<(), Status> {
28302833
info!(
@@ -2869,8 +2872,13 @@ async fn stream_exec_over_ssh(
28692872
}
28702873
};
28712874

2872-
let exec =
2873-
run_exec_with_russh(local_proxy_port, command, stdin_payload.clone(), tx.clone());
2875+
let exec = run_exec_with_russh(
2876+
local_proxy_port,
2877+
command,
2878+
stdin_payload.clone(),
2879+
request_tty,
2880+
tx.clone(),
2881+
);
28742882

28752883
let exec_result = if timeout_seconds == 0 {
28762884
exec.await
@@ -2948,6 +2956,7 @@ async fn run_exec_with_russh(
29482956
local_proxy_port: u16,
29492957
command: &str,
29502958
stdin_payload: Vec<u8>,
2959+
request_tty: bool,
29512960
tx: mpsc::Sender<Result<ExecSandboxEvent, Status>>,
29522961
) -> Result<i32, Status> {
29532962
let stream = TcpStream::connect(("127.0.0.1", local_proxy_port))
@@ -2977,6 +2986,22 @@ async fn run_exec_with_russh(
29772986
.await
29782987
.map_err(|e| Status::internal(format!("failed to open ssh channel: {e}")))?;
29792988

2989+
// Request a PTY before exec when the client asked for terminal allocation.
2990+
if request_tty {
2991+
channel
2992+
.request_pty(
2993+
false,
2994+
"xterm-256color",
2995+
0, // col_width — 0 lets the server decide
2996+
0, // row_height — 0 lets the server decide
2997+
0, // pix_width
2998+
0, // pix_height
2999+
&[],
3000+
)
3001+
.await
3002+
.map_err(|e| Status::internal(format!("failed to allocate PTY: {e}")))?;
3003+
}
3004+
29803005
channel
29813006
.exec(true, command.as_bytes())
29823007
.await

proto/openshell.proto

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

244244
// Optional stdin payload passed to the command.
245245
bytes stdin = 6;
246+
247+
// Request a pseudo-terminal for the remote command.
248+
bool tty = 7;
246249
}
247250

248251
// One stdout chunk from a sandbox exec.

0 commit comments

Comments
 (0)