@@ -24,21 +24,21 @@ use openshell_bootstrap::{
2424use 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} ;
3535use openshell_core:: settings:: { self , SettingValueKind } ;
3636use openshell_providers:: {
3737 ProviderRegistry , detect_provider_from_command, normalize_provider_type,
3838} ;
3939use owo_colors:: OwoColorize ;
4040use std:: collections:: { HashMap , HashSet , VecDeque } ;
41- use std:: io:: { IsTerminal , Write } ;
41+ use std:: io:: { IsTerminal , Read , Write } ;
4242use std:: path:: { Path , PathBuf } ;
4343use std:: process:: Command ;
4444use 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.
26972807fn print_yaml_line ( line : & str ) {
26982808 // Find leading whitespace
0 commit comments