Skip to content

Commit 13262e1

Browse files
authored
feat(cli): add sandbox exec subcommand with TTY support (#752)
1 parent 491c5d8 commit 13262e1

File tree

4 files changed

+217
-5
lines changed

4 files changed

+217
-5
lines changed

crates/openshell-cli/src/main.rs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1229,6 +1229,48 @@ enum SandboxCommands {
12291229
all: bool,
12301230
},
12311231

1232+
/// Execute a command in a running sandbox.
1233+
///
1234+
/// Runs a command inside an existing sandbox using the gRPC exec endpoint.
1235+
/// Output is streamed to the terminal in real-time. The CLI exits with the
1236+
/// remote command's exit code.
1237+
///
1238+
/// 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
1244+
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
1245+
Exec {
1246+
/// Sandbox name (defaults to last-used sandbox).
1247+
#[arg(long, short = 'n', add = ArgValueCompleter::new(completers::complete_sandbox_names))]
1248+
name: Option<String>,
1249+
1250+
/// Working directory inside the sandbox.
1251+
#[arg(long)]
1252+
workdir: Option<String>,
1253+
1254+
/// Timeout in seconds (0 = no timeout).
1255+
#[arg(long, default_value_t = 0)]
1256+
timeout: u32,
1257+
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+
1269+
/// Command and arguments to execute.
1270+
#[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)]
1271+
command: Vec<String>,
1272+
},
1273+
12321274
/// Connect to a sandbox.
12331275
///
12341276
/// When no name is given, reconnects to the last-used sandbox.
@@ -2307,6 +2349,38 @@ async fn main() -> Result<()> {
23072349
}
23082350
let _ = save_last_sandbox(&ctx.name, &name);
23092351
}
2352+
SandboxCommands::Exec {
2353+
name,
2354+
workdir,
2355+
timeout,
2356+
tty,
2357+
no_tty,
2358+
command,
2359+
} => {
2360+
let name = resolve_sandbox_name(name, &ctx.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+
};
2369+
let exit_code = run::sandbox_exec_grpc(
2370+
endpoint,
2371+
&name,
2372+
&command,
2373+
workdir.as_deref(),
2374+
timeout,
2375+
tty_override,
2376+
&tls,
2377+
)
2378+
.await?;
2379+
let _ = save_last_sandbox(&ctx.name, &name);
2380+
if exit_code != 0 {
2381+
std::process::exit(exit_code);
2382+
}
2383+
}
23102384
SandboxCommands::SshConfig { name } => {
23112385
let name = resolve_sandbox_name(name, &ctx.name)?;
23122386
run::print_ssh_config(&ctx.name, &name);

crates/openshell-cli/src/run.rs

Lines changed: 113 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,21 +24,21 @@ use openshell_bootstrap::{
2424
use openshell_core::proto::{
2525
ApproveAllDraftChunksRequest, ApproveDraftChunkRequest, ClearDraftChunksRequest,
2626
CreateProviderRequest, CreateSandboxRequest, DeleteProviderRequest, DeleteSandboxRequest,
27-
GetClusterInferenceRequest, GetDraftHistoryRequest, GetDraftPolicyRequest,
27+
ExecSandboxRequest, GetClusterInferenceRequest, GetDraftHistoryRequest, GetDraftPolicyRequest,
2828
GetGatewayConfigRequest, GetProviderRequest, GetSandboxConfigRequest, GetSandboxLogsRequest,
2929
GetSandboxPolicyStatusRequest, GetSandboxRequest, HealthRequest, ListProvidersRequest,
3030
ListSandboxPoliciesRequest, ListSandboxesRequest, PolicyStatus, Provider,
3131
RejectDraftChunkRequest, Sandbox, SandboxPhase, SandboxPolicy, SandboxSpec, SandboxTemplate,
3232
SetClusterInferenceRequest, SettingScope, SettingValue, UpdateConfigRequest,
33-
UpdateProviderRequest, WatchSandboxRequest, setting_value,
33+
UpdateProviderRequest, WatchSandboxRequest, exec_sandbox_event, setting_value,
3434
};
3535
use openshell_core::settings::{self, SettingValueKind};
3636
use openshell_providers::{
3737
ProviderRegistry, detect_provider_from_command, normalize_provider_type,
3838
};
3939
use owo_colors::OwoColorize;
4040
use std::collections::{HashMap, HashSet, VecDeque};
41-
use std::io::{IsTerminal, Write};
41+
use std::io::{IsTerminal, Read, Write};
4242
use std::path::{Path, PathBuf};
4343
use std::process::Command;
4444
use std::time::{Duration, Instant};
@@ -2693,6 +2693,116 @@ 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+
2700+
/// Execute a command in a running sandbox via gRPC, streaming output to the terminal.
2701+
///
2702+
/// Returns the remote command's exit code.
2703+
pub async fn sandbox_exec_grpc(
2704+
server: &str,
2705+
name: &str,
2706+
command: &[String],
2707+
workdir: Option<&str>,
2708+
timeout_seconds: u32,
2709+
tty_override: Option<bool>,
2710+
tls: &TlsOptions,
2711+
) -> Result<i32> {
2712+
let mut client = grpc_client(server, tls).await?;
2713+
2714+
// Resolve sandbox name to id.
2715+
let sandbox = client
2716+
.get_sandbox(GetSandboxRequest {
2717+
name: name.to_string(),
2718+
})
2719+
.await
2720+
.into_diagnostic()?
2721+
.into_inner()
2722+
.sandbox
2723+
.ok_or_else(|| miette::miette!("sandbox not found"))?;
2724+
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+
}
2733+
2734+
// Read stdin if piped (not a TTY), using spawn_blocking to avoid blocking
2735+
// the async runtime. Cap the read at MAX_STDIN_PAYLOAD + 1 so we never
2736+
// buffer more than the limit into memory.
2737+
let stdin_payload = if !std::io::stdin().is_terminal() {
2738+
tokio::task::spawn_blocking(|| {
2739+
let limit = (MAX_STDIN_PAYLOAD + 1) as u64;
2740+
let mut buf = Vec::new();
2741+
std::io::stdin()
2742+
.take(limit)
2743+
.read_to_end(&mut buf)
2744+
.into_diagnostic()?;
2745+
if buf.len() > MAX_STDIN_PAYLOAD {
2746+
return Err(miette::miette!(
2747+
"stdin payload exceeds {} byte limit; pipe smaller inputs or use `sandbox upload`",
2748+
MAX_STDIN_PAYLOAD
2749+
));
2750+
}
2751+
Ok(buf)
2752+
})
2753+
.await
2754+
.into_diagnostic()?? // first ? unwraps JoinError, second ? unwraps Result
2755+
} else {
2756+
Vec::new()
2757+
};
2758+
2759+
// Resolve TTY mode: explicit --tty / --no-tty wins, otherwise auto-detect.
2760+
let tty = tty_override
2761+
.unwrap_or_else(|| std::io::stdin().is_terminal() && std::io::stdout().is_terminal());
2762+
2763+
// Make the streaming gRPC call.
2764+
let mut stream = client
2765+
.exec_sandbox(ExecSandboxRequest {
2766+
sandbox_id: sandbox.id,
2767+
command: command.to_vec(),
2768+
workdir: workdir.unwrap_or_default().to_string(),
2769+
environment: HashMap::new(),
2770+
timeout_seconds,
2771+
stdin: stdin_payload,
2772+
tty,
2773+
})
2774+
.await
2775+
.into_diagnostic()?
2776+
.into_inner();
2777+
2778+
// Stream output to terminal in real-time.
2779+
let mut exit_code = 0i32;
2780+
let stdout = std::io::stdout();
2781+
let stderr = std::io::stderr();
2782+
2783+
while let Some(event) = stream.next().await {
2784+
let event = event.into_diagnostic()?;
2785+
match event.payload {
2786+
Some(exec_sandbox_event::Payload::Stdout(out)) => {
2787+
let mut handle = stdout.lock();
2788+
handle.write_all(&out.data).into_diagnostic()?;
2789+
handle.flush().into_diagnostic()?;
2790+
}
2791+
Some(exec_sandbox_event::Payload::Stderr(err)) => {
2792+
let mut handle = stderr.lock();
2793+
handle.write_all(&err.data).into_diagnostic()?;
2794+
handle.flush().into_diagnostic()?;
2795+
}
2796+
Some(exec_sandbox_event::Payload::Exit(exit)) => {
2797+
exit_code = exit.exit_code;
2798+
}
2799+
None => {}
2800+
}
2801+
}
2802+
2803+
Ok(exit_code)
2804+
}
2805+
26962806
/// Print a single YAML line with dimmed keys and regular values.
26972807
fn print_yaml_line(line: &str) {
26982808
// Find leading whitespace

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)