From 212217a380c33a03cc2d1869ac8b4ab207cbc4b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Thu, 23 Jul 2026 10:19:09 +0200 Subject: [PATCH 1/3] feat(cli): add --output json/yaml to sandbox get, status, and sandbox create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add structured output support (JSON and YAML) to sandbox lifecycle commands: sandbox get, status, and sandbox create. Introduce ProgressOutput enum to cleanly separate interactive, plain, and silent display modes during sandbox provisioning. Signed-off-by: Roland Huß --- crates/openshell-cli/src/main.rs | 47 +- crates/openshell-cli/src/run.rs | 546 +++++++++++++++--- .../sandbox_create_lifecycle_integration.rs | 78 ++- .../sandbox_name_fallback_integration.rs | 41 +- docs/sandboxes/manage-gateways.mdx | 6 + docs/sandboxes/manage-sandboxes.mdx | 12 + 6 files changed, 622 insertions(+), 108 deletions(-) diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index a44b0eafa8..20686dd45f 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -582,7 +582,11 @@ enum Commands { /// Show gateway status and information. #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] - Status, + Status { + /// Output format. + #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table)] + output: OutputFormat, + }, /// Manage inference configuration. #[command(after_help = INFERENCE_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)] @@ -1434,6 +1438,10 @@ enum SandboxCommands { #[arg(long, value_parser = ["manual", "auto"], default_value = "manual")] approval_mode: String, + /// Output format. + #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table, conflicts_with_all = ["editor", "command", "no_keep"])] + output: OutputFormat, + /// Command to run after "--" (defaults to an interactive shell). #[arg(last = true, allow_hyphen_values = true)] command: Vec, @@ -1447,8 +1455,12 @@ enum SandboxCommands { name: Option, /// Print only the active policy YAML (same policy as the default view; stdout only). - #[arg(long)] + #[arg(long, conflicts_with = "output")] policy_only: bool, + + /// Output format. + #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table)] + output: OutputFormat, }, /// List sandboxes. @@ -2270,11 +2282,17 @@ async fn main() -> Result<()> { // ----------------------------------------------------------- // Top-level status // ----------------------------------------------------------- - Some(Commands::Status) => { + Some(Commands::Status { output }) => { if let Ok(ctx) = resolve_gateway(&cli.gateway, &cli.gateway_endpoint) { let mut tls = tls.with_gateway_name(&ctx.name); let auth_error = apply_auth_with_status(&mut tls, &ctx.name); - run::gateway_status(&ctx.name, &ctx.endpoint, &tls, auth_error.as_deref()).await?; + run::gateway_status(&ctx.name, &ctx.endpoint, output.as_str(), &tls, auth_error.as_deref()).await?; + } else if openshell_cli::output::print_output_single( + output.as_str(), + &(), + |()| serde_json::json!({"status": "not_configured"}), + )? { + // Structured output handled. } else { println!("{}", "Gateway Status".cyan().bold()); println!(); @@ -2885,6 +2903,7 @@ async fn main() -> Result<()> { labels, envs, approval_mode, + output, command, } => { // Resolve --tty / --no-tty into an Option override. @@ -2971,6 +2990,7 @@ async fn main() -> Result<()> { labels: labels_map, environment: env_map, approval_mode: &approval_mode, + output: output.as_str(), }, &cli.workspace, &tls, @@ -3030,10 +3050,21 @@ async fn main() -> Result<()> { | SandboxCommands::Download { .. } => { unreachable!() } - SandboxCommands::Get { name, policy_only } => { + SandboxCommands::Get { + name, + policy_only, + output, + } => { let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?; - run::sandbox_get(endpoint, &name, policy_only, &cli.workspace, &tls) - .await?; + run::sandbox_get( + endpoint, + &name, + policy_only, + output.as_str(), + &cli.workspace, + &tls, + ) + .await?; } SandboxCommands::List { limit, @@ -3883,7 +3914,7 @@ mod tests { .expect("global gateway flag should parse with subcommands"); assert_eq!(cli.gateway.as_deref(), Some("demo")); - assert!(matches!(cli.command, Some(Commands::Status))); + assert!(matches!(cli.command, Some(Commands::Status { .. }))); } #[test] diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index a2ad725a4c..572e1601b3 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -51,18 +51,18 @@ use openshell_core::proto::{ ExposeServiceRequest, GetDraftHistoryRequest, GetDraftPolicyRequest, GetGatewayConfigRequest, GetGatewayInfoRequest, GetInferenceRouteRequest, GetProviderProfileRequest, GetProviderRefreshStatusRequest, GetProviderRequest, GetSandboxConfigRequest, - GetSandboxLogsRequest, GetSandboxPolicyStatusRequest, GetSandboxRequest, GetServiceRequest, - GpuResourceRequirements, HealthRequest, ImportProviderProfilesRequest, - LintProviderProfilesRequest, ListProviderProfilesRequest, ListProvidersRequest, - ListSandboxPoliciesRequest, ListSandboxProvidersRequest, ListSandboxesRequest, - ListServicesRequest, PolicySource, PolicyStatus, Provider, ProviderCredentialRefreshStatus, - ProviderCredentialRefreshStrategy, ProviderProfile, ProviderProfileDiagnostic, - ProviderProfileImportItem, RejectDraftChunkRequest, ResourceRequirements, - RevokeSshSessionRequest, RotateProviderCredentialRequest, Sandbox, SandboxPhase, SandboxPolicy, - SandboxSpec, SandboxTemplate, ServiceEndpointResponse, ServiceStatus, SetInferenceRouteRequest, - SettingScope, TcpForwardFrame, TcpForwardInit, TcpRelayTarget, UpdateConfigRequest, - UpdateProviderProfilesRequest, UpdateProviderRequest, WatchSandboxRequest, exec_sandbox_event, - setting_value, tcp_forward_init, + GetSandboxConfigResponse, GetSandboxLogsRequest, GetSandboxPolicyStatusRequest, + GetSandboxRequest, GetServiceRequest, GpuResourceRequirements, HealthRequest, + ImportProviderProfilesRequest, LintProviderProfilesRequest, ListProviderProfilesRequest, + ListProvidersRequest, ListSandboxPoliciesRequest, ListSandboxProvidersRequest, + ListSandboxesRequest, ListServicesRequest, PolicySource, PolicyStatus, Provider, + ProviderCredentialRefreshStatus, ProviderCredentialRefreshStrategy, ProviderProfile, + ProviderProfileDiagnostic, ProviderProfileImportItem, RejectDraftChunkRequest, + ResourceRequirements, RevokeSshSessionRequest, RotateProviderCredentialRequest, Sandbox, + SandboxPhase, SandboxPolicy, SandboxSpec, SandboxTemplate, ServiceEndpointResponse, + ServiceStatus, SetInferenceRouteRequest, SettingScope, TcpForwardFrame, TcpForwardInit, + TcpRelayTarget, UpdateConfigRequest, UpdateProviderProfilesRequest, UpdateProviderRequest, + WatchSandboxRequest, exec_sandbox_event, setting_value, tcp_forward_init, }; use openshell_core::settings; use openshell_core::{ObjectId, ObjectName, ObjectWorkspace}; @@ -122,27 +122,55 @@ struct ComputeDriverCapabilitiesView { driver_version: String, } +enum ProgressOutput { + Interactive(ProvisioningDisplay), + Plain, + Silent, +} + +impl ProgressOutput { + fn as_interactive_mut(&mut self) -> Option<&mut ProvisioningDisplay> { + match self { + Self::Interactive(d) => Some(d), + _ => None, + } + } + + fn as_interactive(&self) -> Option<&ProvisioningDisplay> { + match self { + Self::Interactive(d) => Some(d), + _ => None, + } + } + + fn is_plain(&self) -> bool { + matches!(self, Self::Plain) + } +} + /// Show gateway status. #[allow(clippy::branches_sharing_code)] pub async fn gateway_status( gateway_name: &str, server: &str, + output: &str, tls: &TlsOptions, auth_preparation_error: Option<&str>, ) -> Result<()> { - println!("{}", "Server Status".cyan().bold()); - println!(); - println!(" {} {}", "Gateway:".dimmed(), gateway_name); - println!(" {} {}", "Server:".dimmed(), server); - - // Try to connect and get health - match grpc_client(server, tls).await { + // Build status data before any output. + let is_bearer = tls.is_bearer_auth(); + let (status_str, version, error, http_status, authentication): ( + &str, + Option, + Option, + Option, + GatewayAuthenticationState, + ) = match grpc_client(server, tls).await { Ok(mut client) => match client.health(HealthRequest {}).await { Ok(response) => { let health = response.into_inner(); - println!(" {} {}", "Status:".dimmed(), "Connected".green()); - let authentication = match auth_preparation_error { - Some(error) => GatewayAuthenticationState::Failed(error.to_string()), + let auth = match auth_preparation_error { + Some(e) => GatewayAuthenticationState::Failed(e.to_string()), None => gateway_authentication_state( client .get_gateway_info(GetGatewayInfoRequest {}) @@ -152,52 +180,125 @@ pub async fn gateway_status( server, ), }; - print_gateway_authentication_state(&authentication); - println!(" {} {}", "Version:".dimmed(), health.version); + ("connected", Some(health.version), None, None, auth) } Err(e) => { - let authentication = auth_preparation_error.map_or_else( + let auth = auth_preparation_error.map_or_else( || { GatewayAuthenticationState::Unverified( "gRPC health check failed".to_string(), ) }, - |error| GatewayAuthenticationState::Failed(error.to_string()), + |err| GatewayAuthenticationState::Failed(err.to_string()), ); - if let Some(status) = http_health_check(server, tls).await? { - if status.is_success() { - println!(" {} {}", "Status:".dimmed(), "Connected (HTTP)".yellow()); - println!(" {} {}", "HTTP: ".dimmed(), status); - println!(" {} {}", "gRPC error:".dimmed(), e); - } else { - println!(" {} {}", "Status:".dimmed(), "Error".red()); - println!(" {} {}", "HTTP:".dimmed(), status); - println!(" {} {}", "gRPC error:".dimmed(), e); - } - } else { - println!(" {} {}", "Status:".dimmed(), "Error".red()); - println!(" {} {}", "Error:".dimmed(), e); - } - print_gateway_authentication_state(&authentication); + http_health_check(server, tls).await?.map_or_else( + || ("error", None, Some(e.to_string()), None, auth.clone()), + |http| { + let hs = Some(http.to_string()); + if http.is_success() { + ("connected_http", None, Some(e.to_string()), hs, auth.clone()) + } else { + ("error", None, Some(e.to_string()), hs, auth.clone()) + } + }, + ) } }, Err(e) => { - let authentication = auth_preparation_error.map_or_else( + let auth = auth_preparation_error.map_or_else( || GatewayAuthenticationState::Unverified("gateway unreachable".to_string()), - |error| GatewayAuthenticationState::Failed(error.to_string()), + |err| GatewayAuthenticationState::Failed(err.to_string()), ); - if let Some(status) = http_health_check(server, tls).await? { - if status.is_success() { - println!(" {} {}", "Status:".dimmed(), "Connected (HTTP)".yellow()); - println!(" {} {}", "HTTP:".dimmed(), status); + http_health_check(server, tls).await?.map_or_else( + || { + ( + "disconnected", + None, + Some(e.to_string()), + None, + auth.clone(), + ) + }, + |http| { + let hs = Some(http.to_string()); + if http.is_success() { + ( + "connected_http", + None, + Some(e.to_string()), + hs, + auth.clone(), + ) + } else { + ( + "disconnected", + None, + Some(e.to_string()), + hs, + auth.clone(), + ) + } + }, + ) + } + }; + + let json_data = status_to_json( + gateway_name, + server, + is_bearer, + status_str, + &version, + &error, + &http_status, + &authentication, + ); + if crate::output::print_output_single(output, &json_data, Clone::clone)? { + return Ok(()); + } + + // Human-readable output. + println!("{}", "Server Status".cyan().bold()); + println!(); + println!(" {} {}", "Gateway:".dimmed(), gateway_name); + println!(" {} {}", "Server:".dimmed(), server); + + match status_str { + "connected" => { + println!(" {} {}", "Status:".dimmed(), "Connected".green()); + print_gateway_authentication_state(&authentication); + if let Some(ref v) = version { + println!(" {} {}", "Version:".dimmed(), v); + } + } + "connected_http" => { + println!(" {} {}", "Status:".dimmed(), "Connected (HTTP)".yellow()); + if let Some(ref hs) = http_status { + println!(" {} {}", "HTTP: ".dimmed(), hs); + } + if let Some(ref e) = error { + println!(" {} {}", "gRPC error:".dimmed(), e); + } + print_gateway_authentication_state(&authentication); + } + "error" => { + println!(" {} {}", "Status:".dimmed(), "Error".red()); + if let Some(ref hs) = http_status { + println!(" {} {}", "HTTP:".dimmed(), hs); + if let Some(ref e) = error { println!(" {} {}", "gRPC error:".dimmed(), e); - } else { - println!(" {} {}", "Status:".dimmed(), "Disconnected".red()); - println!(" {} {}", "HTTP:".dimmed(), status); - println!(" {} {}", "Error:".dimmed(), e); } - } else { - println!(" {} {}", "Status:".dimmed(), "Disconnected".red()); + } else if let Some(ref e) = error { + println!(" {} {}", "Error:".dimmed(), e); + } + print_gateway_authentication_state(&authentication); + } + _ => { + println!(" {} {}", "Status:".dimmed(), "Disconnected".red()); + if let Some(ref hs) = http_status { + println!(" {} {}", "HTTP:".dimmed(), hs); + } + if let Some(ref e) = error { println!(" {} {}", "Error:".dimmed(), e); } print_gateway_authentication_state(&authentication); @@ -293,6 +394,60 @@ fn print_gateway_authentication_state(state: &GatewayAuthenticationState) { } } +fn authentication_state_to_json(state: &GatewayAuthenticationState) -> serde_json::Value { + match state { + GatewayAuthenticationState::Authenticated(provider) => serde_json::json!({ + "status": "authenticated", + "provider": provider, + }), + GatewayAuthenticationState::NotRequired(mode) => serde_json::json!({ + "status": "not_required", + "mode": mode, + }), + GatewayAuthenticationState::Failed(error) => serde_json::json!({ + "status": "failed", + "error": error, + }), + GatewayAuthenticationState::Unverified(reason) => serde_json::json!({ + "status": "unverified", + "reason": reason, + }), + } +} + +#[allow(clippy::too_many_arguments)] +fn status_to_json( + gateway_name: &str, + server: &str, + is_bearer: bool, + status: &str, + version: &Option, + error: &Option, + http_status: &Option, + authentication: &GatewayAuthenticationState, +) -> serde_json::Value { + let mut obj = serde_json::json!({ + "gateway": gateway_name, + "server": server, + "status": status, + "authentication": authentication_state_to_json(authentication), + }); + let map = obj.as_object_mut().expect("json! returns object"); + if is_bearer { + map.insert("auth".into(), serde_json::json!("edge_bearer")); + } + if let Some(v) = version { + map.insert("version".into(), serde_json::json!(v)); + } + if let Some(hs) = http_status { + map.insert("http_status".into(), serde_json::json!(hs)); + } + if let Some(e) = error { + map.insert("error".into(), serde_json::json!(e)); + } + obj +} + fn gateway_service_status_name(status: i32) -> &'static str { match ServiceStatus::try_from(status) { Ok(ServiceStatus::Healthy) => "healthy", @@ -1618,6 +1773,7 @@ pub struct SandboxCreateConfig<'a> { pub labels: HashMap, pub environment: HashMap, pub approval_mode: &'a str, + pub output: &'a str, } impl Default for SandboxCreateConfig<'_> { @@ -1641,6 +1797,7 @@ impl Default for SandboxCreateConfig<'_> { labels: HashMap::new(), environment: HashMap::new(), approval_mode: "manual", + output: "table", } } } @@ -1672,6 +1829,7 @@ pub async fn sandbox_create( labels, environment, approval_mode, + output, } = config; if editor.is_some() && !command.is_empty() { @@ -1821,23 +1979,36 @@ pub async fn sandbox_create( } } + let structured_output = output != "table"; + // Set up display — interactive terminals get a step-based checklist with - // spinners; non-interactive (pipes / CI) get timestamped lines. - let mut display = if interactive { - Some(ProvisioningDisplay::new()) + // spinners; non-interactive (pipes / CI) get timestamped lines; + // structured output suppresses all stdout progress. + let mut display = if structured_output { + ProgressOutput::Silent + } else if interactive { + ProgressOutput::Interactive(ProvisioningDisplay::new()) } else { - None + ProgressOutput::Plain }; - // Print header - print_sandbox_header(&sandbox, display.as_ref()); - - // Set initial active step on the spinner. - if let Some(d) = display.as_mut() { - d.set_active_step(ProvisioningStep::RequestingSandbox); + if structured_output { + eprintln!("Provisioning sandbox (structured output on stdout)..."); } else { - let ts = format_timestamp(Duration::ZERO); - println!(" {} Requesting compute...", ts.dimmed()); + // Print header + print_sandbox_header(&sandbox, display.as_interactive()); + + // Set initial active step on the spinner. + match &mut display { + ProgressOutput::Interactive(d) => { + d.set_active_step(ProvisioningStep::RequestingSandbox); + } + ProgressOutput::Plain => { + let ts = format_timestamp(Duration::ZERO); + println!(" {} Requesting compute...", ts.dimmed()); + } + ProgressOutput::Silent => {} + } } // Non-interactive mode: track start time for timestamps. @@ -1871,6 +2042,7 @@ pub async fn sandbox_create( .into_inner(); let mut last_phase = sandbox.phase(); + let mut last_sandbox = sandbox.clone(); let mut last_error_reason = String::new(); let mut last_condition_message = ready_false_condition_message(sandbox.status.as_ref()); // Track whether we have seen a non-Ready phase during the watch. @@ -1897,10 +2069,12 @@ pub async fn sandbox_create( resource_requirements.as_ref(), last_condition_message.as_deref(), ); - if let Some(d) = display.as_mut() { + if let Some(d) = display.as_interactive_mut() { d.finish_error(&timeout_message); } - println!(); + if display.is_plain() { + println!(); + } return Err(miette::miette!(timeout_message)); } @@ -1916,10 +2090,12 @@ pub async fn sandbox_create( resource_requirements.as_ref(), last_condition_message.as_deref(), ); - if let Some(d) = display.as_mut() { + if let Some(d) = display.as_interactive_mut() { d.finish_error(&timeout_message); } - println!(); + if display.is_plain() { + println!(); + } return Err(miette::miette!(timeout_message)); } }; @@ -1929,6 +2105,7 @@ pub async fn sandbox_create( Some(openshell_core::proto::sandbox_stream_event::Payload::Sandbox(s)) => { let phase = SandboxPhase::try_from(s.phase()).unwrap_or(SandboxPhase::Unknown); last_phase = s.phase(); + last_sandbox = s.clone(); if let Some(message) = ready_false_condition_message(s.status.as_ref()) { last_condition_message = Some(message); } @@ -1956,7 +2133,7 @@ pub async fn sandbox_create( // Only accept Ready as terminal after we've observed a // non-Ready phase, proving the controller has reconciled. if saw_non_ready && phase == SandboxPhase::Ready { - if let Some(d) = display.as_mut() { + if let Some(d) = display.as_interactive_mut() { d.clear(); } break; @@ -1970,7 +2147,24 @@ pub async fn sandbox_create( } Some(openshell_core::proto::sandbox_stream_event::Payload::Event(ev)) => { let extends_timeout = is_provisioning_progress_event(&ev); - if handle_platform_progress_event(&ev, &mut display, provision_start) { + // Silent mode suppresses all progress output; only update + // the deadline when applicable. + let handled = match &mut display { + ProgressOutput::Interactive(d) => { + let mut opt = Some(std::mem::replace(d, ProvisioningDisplay::new())); + let h = handle_platform_progress_event(&ev, &mut opt, provision_start); + if let Some(inner) = opt { + *d = inner; + } + h + } + ProgressOutput::Plain => { + let mut opt: Option = None; + handle_platform_progress_event(&ev, &mut opt, provision_start) + } + ProgressOutput::Silent => false, + }; + if handled { if extends_timeout { provisioning_idle_deadline = Instant::now() + provision_timeout; } @@ -1980,19 +2174,21 @@ pub async fn sandbox_create( provisioning_idle_deadline = Instant::now() + provision_timeout; } - if let Some(d) = display.as_mut() { - // Unknown events: show as detail on the current spinner. - if !ev.message.is_empty() { - d.set_active_detail(&ev.message); - } + if let Some(d) = display.as_interactive_mut() + && !ev.message.is_empty() + { + d.set_active_detail(&ev.message); } } Some(openshell_core::proto::sandbox_stream_event::Payload::Warning(w)) => { - if let Some(d) = display.as_mut() { - d.println(&format!(" {} {}", "!".yellow().bold(), w.message.yellow())); - } else { - let ts = format_timestamp(provision_start.elapsed()); - eprintln!(" {} {} {}", ts.dimmed(), "WARN".yellow(), w.message); + match &display { + ProgressOutput::Interactive(d) => { + d.println(&format!(" {} {}", "!".yellow().bold(), w.message.yellow())); + } + ProgressOutput::Plain | ProgressOutput::Silent => { + let ts = format_timestamp(provision_start.elapsed()); + eprintln!(" {} {} {}", ts.dimmed(), "WARN".yellow(), w.message); + } } } Some(openshell_core::proto::sandbox_stream_event::Payload::DraftPolicyUpdate(_)) @@ -2005,7 +2201,7 @@ pub async fn sandbox_create( // If we exited the loop without hitting the Ready break, finish the display. let final_phase = SandboxPhase::try_from(last_phase).unwrap_or(SandboxPhase::Unknown); if final_phase != SandboxPhase::Ready - && let Some(d) = display.as_mut() + && let Some(d) = display.as_interactive_mut() { if final_phase == SandboxPhase::Error { let msg = if last_error_reason.is_empty() { @@ -2115,6 +2311,11 @@ pub async fn sandbox_create( ); } + if structured_output { + crate::output::print_output_single(output, &last_sandbox, sandbox_to_json)?; + return Ok(()); + } + if let Some(editor) = editor { let ssh_gateway_name = effective_tls.gateway_name().unwrap_or(gateway_name); sandbox_connect_editor( @@ -2453,6 +2654,7 @@ pub async fn sandbox_get( server: &str, name: &str, policy_only: bool, + output: &str, workspace: &str, tls: &TlsOptions, ) -> Result<()> { @@ -2494,6 +2696,11 @@ pub async fn sandbox_get( return Ok(()); } + let detail_json = sandbox_detail_to_json(&sandbox, &config)?; + if crate::output::print_output_single(output, &detail_json, Clone::clone)? { + return Ok(()); + } + println!("{}", "Sandbox:".cyan().bold()); println!(); let id = if sandbox.object_id().is_empty() { @@ -3289,6 +3496,48 @@ fn sandbox_to_json(sandbox: &Sandbox) -> serde_json::Value { }) } +fn sandbox_detail_to_json( + sandbox: &Sandbox, + config: &GetSandboxConfigResponse, +) -> Result { + let mut value = sandbox_to_json(sandbox); + let obj = value + .as_object_mut() + .expect("sandbox_to_json returns object"); + + let policy_source = if config.policy_source == PolicySource::Global as i32 { + "global" + } else { + "sandbox" + }; + obj.insert("policy_source".into(), serde_json::json!(policy_source)); + + let policy_from_global = config.policy_source == PolicySource::Global as i32; + let revision = if policy_from_global { + if config.global_policy_version > 0 { + Some(config.global_policy_version) + } else if config.version > 0 { + Some(config.version) + } else { + None + } + } else if config.version > 0 { + Some(config.version) + } else { + None + }; + obj.insert("revision".into(), serde_json::json!(revision)); + + let policy_json = match config.policy.as_ref() { + Some(p) => openshell_policy::sandbox_policy_to_json_value(p) + .wrap_err("failed to convert policy to JSON")?, + None => serde_json::Value::Null, + }; + obj.insert("policy".into(), policy_json); + + Ok(value) +} + pub async fn sandbox_provider_list( server: &str, name: &str, @@ -6685,7 +6934,7 @@ pub async fn gateway_settings_get(server: &str, json: bool, tls: &TlsOptions) -> fn settings_to_json_sandbox( name: &str, workspace: &str, - response: &openshell_core::proto::GetSandboxConfigResponse, + response: &GetSandboxConfigResponse, ) -> serde_json::Value { let policy_source = if response.policy_source == PolicySource::Global as i32 { "global" @@ -8144,11 +8393,11 @@ mod tests { PROGRESS_STEP_STARTING_SANDBOX, }; use openshell_core::proto::{ - GpuResourceRequirements, PolicyStatus, Provider, ProviderCredentialRefresh, - ProviderCredentialRefreshStatus, ProviderCredentialRefreshStrategy, - ProviderCredentialTokenGrant, ProviderProfile, ProviderProfileCredential, - ResourceRequirements, SandboxCondition, SandboxPolicyRevision, SandboxStatus, - datamodel::v1::ObjectMeta, + GetSandboxConfigResponse, GpuResourceRequirements, PolicySource, PolicyStatus, Provider, + ProviderCredentialRefresh, ProviderCredentialRefreshStatus, + ProviderCredentialRefreshStrategy, ProviderCredentialTokenGrant, ProviderProfile, + ProviderProfileCredential, ResourceRequirements, Sandbox, SandboxCondition, SandboxPhase, + SandboxPolicyRevision, SandboxStatus, datamodel::v1::ObjectMeta, }; #[test] @@ -10248,4 +10497,125 @@ mod tests { "raw milliseconds field should not exist" ); } + + #[test] + fn sandbox_detail_to_json_includes_policy_fields() { + let mut sandbox = Sandbox { + metadata: Some(ObjectMeta { + id: "sb-123".to_string(), + name: "test-sb".to_string(), + resource_version: 5, + created_at_ms: 1_609_459_200_000, + ..Default::default() + }), + ..Default::default() + }; + sandbox.set_phase(SandboxPhase::Ready as i32); + sandbox.set_current_policy_version(2); + + let config = GetSandboxConfigResponse { + policy_source: PolicySource::Global as i32, + global_policy_version: 3, + ..Default::default() + }; + + let json = super::sandbox_detail_to_json(&sandbox, &config).unwrap(); + + assert_eq!(json["id"], "sb-123"); + assert_eq!(json["name"], "test-sb"); + assert_eq!(json["phase"], "Ready"); + assert_eq!(json["policy_source"], "global"); + assert_eq!(json["revision"], 3); + assert!(json["policy"].is_null()); + } + + #[test] + fn sandbox_detail_to_json_sandbox_source_without_policy() { + let sandbox = Sandbox { + metadata: Some(ObjectMeta { + id: "sb-456".to_string(), + name: "no-policy-sb".to_string(), + ..Default::default() + }), + ..Default::default() + }; + let config = GetSandboxConfigResponse { + policy_source: PolicySource::Sandbox as i32, + version: 0, + ..Default::default() + }; + + let json = super::sandbox_detail_to_json(&sandbox, &config).unwrap(); + + assert_eq!(json["policy_source"], "sandbox"); + assert!(json["revision"].is_null()); + assert!(json["policy"].is_null()); + } + + #[test] + fn status_to_json_connected() { + let auth = GatewayAuthenticationState::Authenticated("mTLS transport"); + let json = super::status_to_json( + "my-gw", + "http://127.0.0.1:8090", + false, + "connected", + &Some("1.2.3".to_string()), + &None, + &None, + &auth, + ); + + assert_eq!(json["gateway"], "my-gw"); + assert_eq!(json["server"], "http://127.0.0.1:8090"); + assert_eq!(json["status"], "connected"); + assert_eq!(json["version"], "1.2.3"); + assert_eq!(json["authentication"]["status"], "authenticated"); + assert!(json.get("auth").is_none()); + assert!(json.get("error").is_none()); + assert!(json.get("http_status").is_none()); + } + + #[test] + fn status_to_json_disconnected_with_error() { + let auth = + GatewayAuthenticationState::Unverified("gateway unreachable".to_string()); + let json = super::status_to_json( + "broken-gw", + "http://10.0.0.1:8090", + false, + "disconnected", + &None, + &Some("connection refused".to_string()), + &None, + &auth, + ); + + assert_eq!(json["status"], "disconnected"); + assert_eq!(json["error"], "connection refused"); + assert_eq!(json["authentication"]["status"], "unverified"); + assert!(json.get("version").is_none()); + } + + #[test] + fn status_to_json_connected_http_with_bearer() { + let auth = GatewayAuthenticationState::Failed("token expired".to_string()); + let json = super::status_to_json( + "edge-gw", + "https://edge.example.com", + true, + "connected_http", + &None, + &Some("gRPC unavailable".to_string()), + &Some("200 OK".to_string()), + &auth, + ); + + assert_eq!(json["status"], "connected_http"); + assert_eq!(json["auth"], "edge_bearer"); + assert_eq!(json["error"], "gRPC unavailable"); + assert_eq!(json["http_status"], "200 OK"); + assert_eq!(json["authentication"]["status"], "failed"); + assert!(json.get("version").is_none()); + } } diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index fda3d529f3..f42880d9f6 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -726,7 +726,7 @@ struct TestServer { endpoint: String, tls: TlsOptions, openshell: TestOpenShell, - _dir: TempDir, + dir: TempDir, } async fn run_server() -> TestServer { @@ -775,7 +775,7 @@ async fn run_server() -> TestServer { endpoint, tls, openshell, - _dir: dir, + dir, } } @@ -1908,3 +1908,77 @@ async fn sandbox_create_env_rejects_invalid_key_name() { "error should mention invalid key, got: {msg}" ); } + +async fn run_cli_sandbox_create( + server: &TestServer, + name: &str, + extra_args: &[&str], +) -> std::process::Output { + let xdg_dir = tempfile::tempdir().unwrap(); + let tls_dir = xdg_dir.path().join("openshell/gateways/openshell/mtls"); + fs::create_dir_all(&tls_dir).unwrap(); + for filename in ["ca.crt", "tls.crt", "tls.key"] { + fs::copy(server.dir.path().join(filename), tls_dir.join(filename)).unwrap(); + } + + tokio::process::Command::new(env!("CARGO_BIN_EXE_openshell")) + .args([ + "--gateway", + "openshell", + "--gateway-endpoint", + &server.endpoint, + "sandbox", + "create", + "--name", + name, + "--no-tty", + "--no-auto-providers", + ]) + .args(extra_args) + .env("XDG_CONFIG_HOME", xdg_dir.path()) + .env("HOME", xdg_dir.path()) + .env("OPENSHELL_PROVISION_TIMEOUT", "5") + .output() + .await + .unwrap() +} + +#[tokio::test] +async fn sandbox_create_json_stdout_is_parseable_with_progress_events() { + let server = run_server().await; + server + .openshell + .state + .vm_slow_progress_before_ready + .store(true, Ordering::SeqCst); + + let result = run_cli_sandbox_create(&server, "json-progress", &["--output=json"]).await; + assert!( + result.status.success(), + "sandbox create failed:\n{}", + String::from_utf8_lossy(&result.stderr) + ); + let stdout = String::from_utf8(result.stdout).expect("stdout should be UTF-8"); + serde_json::from_str::(&stdout) + .unwrap_or_else(|err| panic!("stdout should contain only JSON: {err}\n{stdout}")); +} + +#[tokio::test] +async fn sandbox_create_yaml_stdout_is_parseable_with_progress_events() { + let server = run_server().await; + server + .openshell + .state + .vm_slow_progress_before_ready + .store(true, Ordering::SeqCst); + + let result = run_cli_sandbox_create(&server, "yaml-progress", &["--output=yaml"]).await; + assert!( + result.status.success(), + "sandbox create failed:\n{}", + String::from_utf8_lossy(&result.stderr) + ); + let stdout = String::from_utf8(result.stdout).expect("stdout should be UTF-8"); + serde_yml::from_str::(&stdout) + .unwrap_or_else(|err| panic!("stdout should contain only YAML: {err}\n{stdout}")); +} diff --git a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs index 699749f0d1..5fa2c97029 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -687,9 +687,16 @@ async fn run_server() -> TestServer { async fn sandbox_get_sends_correct_name() { let ts = run_server().await; - run::sandbox_get(&ts.endpoint, "my-sandbox", false, "default", &ts.tls) - .await - .expect("sandbox_get should succeed"); + run::sandbox_get( + &ts.endpoint, + "my-sandbox", + false, + "table", + "default", + &ts.tls, + ) + .await + .expect("sandbox_get should succeed"); let recorded = ts.openshell.state.last_get_name.lock().await.clone(); assert_eq!( @@ -704,9 +711,16 @@ async fn sandbox_get_sends_correct_name() { async fn sandbox_get_policy_only_round_trip() { let ts = run_server().await; - run::sandbox_get(&ts.endpoint, "my-sandbox", true, "default", &ts.tls) - .await - .expect("sandbox_get with policy_only should succeed"); + run::sandbox_get( + &ts.endpoint, + "my-sandbox", + true, + "table", + "default", + &ts.tls, + ) + .await + .expect("sandbox_get with policy_only should succeed"); let recorded = ts.openshell.state.last_get_name.lock().await.clone(); assert_eq!(recorded.as_deref(), Some("my-sandbox")); @@ -730,7 +744,7 @@ async fn sandbox_get_with_persisted_last_sandbox() { assert_eq!(resolved, "persisted-sb"); // Call sandbox_get with the resolved name. - run::sandbox_get(&ts.endpoint, &resolved, false, "default", &ts.tls) + run::sandbox_get(&ts.endpoint, &resolved, false, "table", "default", &ts.tls) .await .expect("sandbox_get should succeed"); @@ -883,9 +897,16 @@ async fn explicit_name_takes_precedence_over_persisted() { // Persist one name, but supply a different one explicitly. save_last_sandbox("my-cluster", "default", "old-sandbox").expect("save should succeed"); - run::sandbox_get(&ts.endpoint, "explicit-sandbox", false, "default", &ts.tls) - .await - .expect("sandbox_get should succeed"); + run::sandbox_get( + &ts.endpoint, + "explicit-sandbox", + false, + "table", + "default", + &ts.tls, + ) + .await + .expect("sandbox_get should succeed"); let recorded = ts.openshell.state.last_get_name.lock().await.clone(); assert_eq!( diff --git a/docs/sandboxes/manage-gateways.mdx b/docs/sandboxes/manage-gateways.mdx index 91cfdfad28..ff1d0ccd3e 100644 --- a/docs/sandboxes/manage-gateways.mdx +++ b/docs/sandboxes/manage-gateways.mdx @@ -126,6 +126,12 @@ passed the gateway authentication layer. If authentication fails while status remains connected, run `openshell gateway login ` to refresh the stored credentials. +For automation or scripting, use `--output json` or `--output yaml` to get machine-readable output: + +```shell +openshell status --output json +``` + Use `openshell gateway info` when you need elevated runtime details such as initialized compute drivers and driver-reported capability versions: diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index 05418f8f7d..f3f36ecc9e 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -20,6 +20,12 @@ Create a sandbox with a single command. For example, to create a sandbox with Cl openshell sandbox create -- claude ``` +For automation, use `--output json` or `--output yaml` to get machine-readable sandbox metadata after creation: + +```shell +openshell sandbox create --output json +``` + Every sandbox requires a gateway. Register or select one before running sandbox commands: ```shell @@ -309,6 +315,12 @@ Get detailed information about a specific sandbox. The output lists **Policy sou openshell sandbox get my-sandbox ``` +For automation, use `--output json` or `--output yaml` to get machine-readable sandbox details: + +```shell +openshell sandbox get my-sandbox --output json +``` + Print only that policy YAML for scripting (same effective policy, no metadata): ```shell From 94914bbdc54cff34a55968cfe157cd26d6306d85 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Fri, 24 Jul 2026 11:29:30 +0200 Subject: [PATCH 2/3] fix(cli): reject structured create output with side effects Signed-off-by: Evan Lezar --- crates/openshell-cli/src/main.rs | 30 ++++++++++++++++++++++++++++-- crates/openshell-cli/src/run.rs | 19 +++++++++---------- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 20686dd45f..00942d79ec 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1439,7 +1439,7 @@ enum SandboxCommands { approval_mode: String, /// Output format. - #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table, conflicts_with_all = ["editor", "command", "no_keep"])] + #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table, conflicts_with_all = ["editor", "command", "no_keep", "forward"])] output: OutputFormat, /// Command to run after "--" (defaults to an interactive shell). @@ -2286,7 +2286,14 @@ async fn main() -> Result<()> { if let Ok(ctx) = resolve_gateway(&cli.gateway, &cli.gateway_endpoint) { let mut tls = tls.with_gateway_name(&ctx.name); let auth_error = apply_auth_with_status(&mut tls, &ctx.name); - run::gateway_status(&ctx.name, &ctx.endpoint, output.as_str(), &tls, auth_error.as_deref()).await?; + run::gateway_status( + &ctx.name, + &ctx.endpoint, + output.as_str(), + &tls, + auth_error.as_deref(), + ) + .await?; } else if openshell_cli::output::print_output_single( output.as_str(), &(), @@ -5056,6 +5063,25 @@ mod tests { ); } + #[test] + fn sandbox_create_output_conflicts_with_side_effect_args() { + for (label, extra_args) in [ + ("--editor", &["--editor", "code"][..]), + ("trailing command", &["--", "claude"][..]), + ("--no-keep", &["--no-keep"][..]), + ("--forward", &["--forward", "8080"][..]), + ] { + let args = ["openshell", "sandbox", "create", "--output", "json"] + .into_iter() + .chain(extra_args.iter().copied()); + let result = Cli::try_parse_from(args); + assert!( + result.is_err(), + "structured output should conflict with {label}" + ); + } + } + #[test] fn sandbox_create_resource_flags_parse() { let cli = Cli::try_parse_from([ diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 572e1601b3..9fddfcc7f5 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -196,7 +196,13 @@ pub async fn gateway_status( |http| { let hs = Some(http.to_string()); if http.is_success() { - ("connected_http", None, Some(e.to_string()), hs, auth.clone()) + ( + "connected_http", + None, + Some(e.to_string()), + hs, + auth.clone(), + ) } else { ("error", None, Some(e.to_string()), hs, auth.clone()) } @@ -230,13 +236,7 @@ pub async fn gateway_status( auth.clone(), ) } else { - ( - "disconnected", - None, - Some(e.to_string()), - hs, - auth.clone(), - ) + ("disconnected", None, Some(e.to_string()), hs, auth.clone()) } }, ) @@ -10578,8 +10578,7 @@ mod tests { #[test] fn status_to_json_disconnected_with_error() { - let auth = - GatewayAuthenticationState::Unverified("gateway unreachable".to_string()); + let auth = GatewayAuthenticationState::Unverified("gateway unreachable".to_string()); let json = super::status_to_json( "broken-gw", "http://10.0.0.1:8090", From 0a9154743ee44b0c361349c1536ec62d85f9612c Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Fri, 24 Jul 2026 11:37:20 +0200 Subject: [PATCH 3/3] fix(cli): suppress upload ssh stdout Signed-off-by: Evan Lezar --- crates/openshell-cli/src/ssh.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/openshell-cli/src/ssh.rs b/crates/openshell-cli/src/ssh.rs index 50bc2bbee9..a5734df277 100644 --- a/crates/openshell-cli/src/ssh.rs +++ b/crates/openshell-cli/src/ssh.rs @@ -786,7 +786,7 @@ async fn ssh_tar_upload( "mkdir -p {escaped_dest} && cat | tar xf - -C {escaped_dest}", )) .stdin(Stdio::piped()) - .stdout(Stdio::inherit()) + .stdout(Stdio::null()) .stderr(Stdio::inherit()); let mut child = ssh.spawn().into_diagnostic()?;