Files
2026-10-01 09:32:38 -07:00

8543 lines
285 KiB
Rust

// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! CLI command implementations.
pub use crate::commands::common::{
PolicyGetView, parse_credential_expiry_cli_value, parse_env_pairs, parse_key_value_pairs,
parse_secret_material_env_pairs, warn_credential_env_vars,
};
use crate::commands::common::{
ProvisioningDisplay, ProvisioningStep, confirm_global_setting_delete,
confirm_global_setting_takeover, format_epoch_ms, format_setting_value, format_timestamp,
format_timestamp_ms, handle_platform_progress_event, is_provisioning_progress_event,
non_empty_or, parse_cli_setting_value, parse_duration_to_ms, phase_name,
print_policy_merge_warnings, print_sandbox_header, print_sandbox_policy,
provisioning_timeout_message, ready_false_condition_message, scrub_git_env, short_hash,
truncate_status_field,
};
pub use crate::commands::gateway::{
gateway_add, gateway_info, gateway_info_not_configured, gateway_list, gateway_login,
gateway_logout, gateway_remove, gateway_select, gateway_status, gateway_use,
};
use crate::commands::provider::fetch_provider_profile_catalog;
pub use crate::commands::provider::{
ProviderCreateCredentialSource, ProviderCreateOptions, ProviderRefreshConfigInput,
ProviderUpdateOptions, ensure_required_providers, provider_create,
provider_create_with_options, provider_delete, provider_get, provider_list,
provider_list_profiles, provider_list_profiles_text, provider_profile_delete,
provider_profile_describe, provider_profile_describe_text, provider_profile_export,
provider_profile_export_text, provider_profile_import, provider_profile_lint,
provider_profile_update, provider_refresh_config, provider_refresh_delete,
provider_refresh_status, provider_rotate, provider_update, sandbox_provider_attach,
sandbox_provider_detach, sandbox_provider_list,
};
pub use crate::commands::provider_readiness::{ProviderWaitOptions, sandbox_provider_status};
use crate::color::Colorize;
use crate::policy_update::build_policy_update_plan;
use crate::tls::{TlsOptions, grpc_client};
use futures::StreamExt;
use miette::{IntoDiagnostic, Result, WrapErr, miette};
use openshell_bootstrap::{
GatewayMetadata, clear_last_sandbox_if_matches, get_gateway_metadata, save_last_sandbox,
};
use openshell_core::net::set_tcp_nodelay_best_effort;
use openshell_core::proto::{
ApproveAllDraftChunksRequest, ApproveDraftChunkRequest, BeginRootfsTarStagingRequest,
ClearDraftChunksRequest, CreateSandboxRequest, CreateSandboxTemplateRequest,
CreateSshSessionRequest, DeleteSandboxRequest, DeleteSandboxTemplateRequest,
DeleteServiceRequest, DeletionOutcome, EndpointResult, EndpointStatus, ExecSandboxRequest,
ExposeServiceRequest, GetCurrentUserRequest, GetDraftHistoryRequest, GetDraftPolicyRequest,
GetGatewayConfigRequest, GetSandboxConfigRequest, GetSandboxConfigResponse,
GetSandboxLogsRequest, GetSandboxPolicyStatusRequest, GetSandboxRequest,
GetSandboxTemplateRequest, GetServiceRequest, GpuResourceRequirements,
ListSandboxPoliciesRequest, ListSandboxTemplatesRequest, ListSandboxesRequest,
ListServicesRequest, PolicySource, PolicyStatus, RejectDraftChunkRequest, ResourceRequirements,
RevokeSshSessionRequest, Sandbox, SandboxCondition, SandboxPhase, SandboxPolicy,
SandboxResources, SandboxRestartPolicy, SandboxServiceExposure, SandboxServiceLevel,
SandboxSpec, SandboxStartup, SandboxTemplate, SandboxWorkloadConfig, SandboxWorkloadTemplate,
SandboxWorkloadTemplateSpec, ServiceAuthorizationMode, ServiceEndpointResponse, SettingScope,
StartSandboxRequest, StopSandboxRequest, TcpForwardFrame, TcpForwardInit, TcpRelayTarget,
UpdateConfigRequest, WatchSandboxRequest, exec_sandbox_event, tcp_forward_init,
};
use openshell_core::settings;
use openshell_core::{ObjectId, ObjectName, ObjectWorkspace};
use prost::Message;
use std::borrow::Cow;
use std::collections::HashMap;
use std::io::{ErrorKind, IsTerminal, Read, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};
use tonic::{Code, Status};
const PROVISIONAL_CONTAINER_EXIT_RECONCILIATION_TIMEOUT: Duration = Duration::from_secs(5);
fn proto_timestamp_ms(timestamp: Option<&prost_types::Timestamp>) -> i64 {
timestamp
.and_then(|value| openshell_core::time::timestamp_to_millis(value).ok())
.unwrap_or_default()
}
fn proto_execution_timeout(timeout_seconds: u32) -> Result<Option<prost_types::Duration>> {
if timeout_seconds == 0 {
return Ok(None);
}
openshell_core::time::duration_from_std(Duration::from_secs(timeout_seconds.into()))
.map(Some)
.into_diagnostic()
}
// Re-export SSH functions for backward compatibility
pub use crate::ssh::{Editor, print_ssh_config};
pub use crate::ssh::{
sandbox_connect, sandbox_connect_editor, sandbox_exec, sandbox_forward, sandbox_ssh_proxy,
sandbox_ssh_proxy_by_name, sandbox_sync_down, sandbox_sync_up, sandbox_sync_up_files,
};
pub use openshell_core::forward::{
ForwardSpec, find_forward_by_port, list_forwards, stop_forward, stop_forwards_for_sandbox,
};
#[derive(Debug, PartialEq, Eq)]
enum SandboxUploadPlan {
GitAware {
base_dir: PathBuf,
files: Vec<String>,
},
Regular,
GitFilteredEmpty,
}
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)
}
}
fn aggregate_delete_failures(resource: &str, failures: &[String]) -> Result<()> {
if failures.is_empty() {
Ok(())
} else {
Err(miette!(
"failed to delete {} {}{}: {}",
failures.len(),
resource,
if failures.len() == 1 { "" } else { "s" },
failures.join(", ")
))
}
}
#[derive(Debug, Clone)]
struct CurrentUserView {
subject: String,
display_name: Option<String>,
roles: Vec<String>,
scopes: Vec<String>,
identity_provider: String,
}
/// Show the identity validated by the selected gateway.
pub async fn whoami(server: &str, tls: &TlsOptions, output: &str) -> Result<()> {
let mut client = grpc_client(server, tls).await?;
let identity = client
.get_current_user(GetCurrentUserRequest {})
.await
.map_err(|err| match err.code() {
Code::Unimplemented => miette!("whoami is not supported by this gateway version"),
Code::Unauthenticated => miette!("whoami requires authentication: {err}"),
_ => miette!("get_current_user failed: {err}"),
})?
.into_inner();
let view = CurrentUserView {
subject: identity.subject,
display_name: (!identity.display_name.is_empty()).then_some(identity.display_name),
roles: identity.roles,
scopes: identity.scopes,
identity_provider: identity.identity_provider,
};
print_current_user(&view, output)
}
fn print_current_user(view: &CurrentUserView, output: &str) -> Result<()> {
if crate::output::print_output_single(output, view, current_user_to_json)? {
return Ok(());
}
println!("{}", "Current User".cyan().bold());
println!();
println!(" {} {}", "Subject:".dimmed(), view.subject);
if let Some(display_name) = &view.display_name {
println!(" {} {}", "Name:".dimmed(), display_name);
}
println!(" {} {}", "Provider:".dimmed(), view.identity_provider);
println!(" {} {}", "Roles:".dimmed(), view.roles.join(", "));
println!(" {} {}", "Scopes:".dimmed(), view.scopes.join(", "));
Ok(())
}
fn current_user_to_json(view: &CurrentUserView) -> serde_json::Value {
serde_json::json!({
"subject": &view.subject,
"display_name": &view.display_name,
"roles": &view.roles,
"scopes": &view.scopes,
"identity_provider": &view.identity_provider,
})
}
/// Validate system prerequisites for running a gateway.
///
/// Checks Docker connectivity and reports the result. Returns exit code 0
/// if all checks pass, 1 otherwise.
pub fn doctor_check() -> Result<()> {
use std::io::Write;
let mut stdout = std::io::stdout().lock();
writeln!(stdout, "Checking system prerequisites...\n").into_diagnostic()?;
// --- Docker connectivity ---
write!(stdout, " Docker ............. ").into_diagnostic()?;
stdout.flush().into_diagnostic()?;
let output = Command::new("docker")
.args(["info", "--format", "{{.ServerVersion}}"])
.output()
.into_diagnostic()
.wrap_err("failed to execute docker info")?;
if output.status.success() {
let version = String::from_utf8_lossy(&output.stdout);
let version_str = version.trim();
writeln!(stdout, "ok (version {version_str})").into_diagnostic()?;
// --- DOCKER_HOST ---
write!(stdout, " DOCKER_HOST ........ ").into_diagnostic()?;
match std::env::var("DOCKER_HOST") {
Ok(val) => writeln!(stdout, "{val}").into_diagnostic()?,
Err(_) => writeln!(stdout, "(not set, using default socket)").into_diagnostic()?,
}
writeln!(stdout, "\nAll checks passed.").into_diagnostic()?;
return Ok(());
}
writeln!(stdout, "FAILED").into_diagnostic()?;
writeln!(stdout).into_diagnostic()?;
let stderr = String::from_utf8_lossy(&output.stderr);
Err(miette::miette!("docker info failed: {}", stderr.trim()))
}
fn sandbox_should_persist(keep: bool, forward: Option<&ForwardSpec>, expose: Option<u16>) -> bool {
keep || forward.is_some() || expose.is_some()
}
fn has_main_process_result(sandbox: &Sandbox) -> bool {
let Some(status) = sandbox.status.as_ref() else {
return false;
};
if status.exit_code.is_none() {
return false;
}
let phase = SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown);
phase != SandboxPhase::Error
|| status.conditions.iter().any(|condition| {
condition.r#type == "Ready"
&& condition.status.eq_ignore_ascii_case("false")
&& condition.reason == "MainProcessFailed"
})
}
fn is_provisional_container_exit(sandbox: &Sandbox) -> bool {
let phase = SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown);
phase == SandboxPhase::Error
&& sandbox.status.as_ref().is_some_and(|status| {
status.exit_code.is_none()
&& status.conditions.iter().any(|condition| {
condition.r#type == "Ready"
&& condition.status.eq_ignore_ascii_case("false")
&& condition.reason == "ContainerExited"
})
})
}
fn build_sandbox_resource_limits(
cpu: Option<&str>,
memory: Option<&str>,
) -> Result<Option<prost_types::Struct>> {
use prost_types::{Struct, Value, value::Kind};
fn string_value(value: String) -> Value {
Value {
kind: Some(Kind::StringValue(value)),
}
}
let mut limits = std::collections::BTreeMap::new();
if let Some(cpu) = cpu {
limits.insert("cpu".to_string(), string_value(validate_cpu_quantity(cpu)?));
}
if let Some(memory) = memory {
limits.insert(
"memory".to_string(),
string_value(validate_memory_quantity(memory)?),
);
}
if limits.is_empty() {
return Ok(None);
}
let mut fields = std::collections::BTreeMap::new();
fields.insert(
"limits".to_string(),
Value {
kind: Some(Kind::StructValue(Struct { fields: limits })),
},
);
Ok(Some(Struct { fields }))
}
fn parse_driver_config_json(value: &str) -> Result<prost_types::Struct> {
let parsed: serde_json::Value = serde_json::from_str(value)
.into_diagnostic()
.wrap_err("--driver-config-json must be valid JSON")?;
let serde_json::Value::Object(fields) = parsed else {
return Err(miette!(
"--driver-config-json must be a JSON object keyed by driver name"
));
};
openshell_core::proto_struct::json_object_to_struct(fields)
.into_diagnostic()
.wrap_err("--driver-config-json contains a value that cannot be represented")
}
fn validate_cpu_quantity(value: &str) -> Result<String> {
let value = value.trim();
if value.is_empty() {
return Err(miette!("--cpu must not be empty"));
}
if let Some(millicores) = value.strip_suffix('m') {
if millicores.is_empty() || !millicores.bytes().all(|b| b.is_ascii_digit()) {
return Err(miette!(
"invalid --cpu value '{value}': expected positive cores or millicores, for example 2, 0.5, or 500m"
));
}
let millicores = millicores.parse::<u64>().into_diagnostic()?;
if millicores == 0 {
return Err(miette!("--cpu must be greater than zero"));
}
return Ok(value.to_string());
}
let cores = value.parse::<f64>().map_err(|_| {
miette!(
"invalid --cpu value '{value}': expected positive cores or millicores, for example 2, 0.5, or 500m"
)
})?;
if !cores.is_finite() || cores <= 0.0 {
return Err(miette!("--cpu must be greater than zero"));
}
Ok(value.to_string())
}
fn validate_memory_quantity(value: &str) -> Result<String> {
let value = value.trim();
if value.is_empty() {
return Err(miette!("--memory must not be empty"));
}
let number_end = value
.find(|ch: char| !ch.is_ascii_digit())
.unwrap_or(value.len());
let (number, suffix) = value.split_at(number_end);
if number.is_empty()
|| !matches!(
suffix,
"" | "Ki" | "Mi" | "Gi" | "Ti" | "Pi" | "Ei" | "K" | "M" | "G" | "T" | "P" | "E"
)
{
return Err(miette!(
"invalid --memory value '{value}': expected positive bytes or a quantity such as 512Mi, 4Gi, or 8G"
));
}
let amount = number.parse::<u128>().into_diagnostic()?;
if amount == 0 {
return Err(miette!("--memory must be greater than zero"));
}
Ok(value.to_string())
}
async fn finalize_sandbox_create_session(
server: &str,
sandbox_name: &str,
persist: bool,
session_result: Result<i32>,
workspace: &str,
tls: &TlsOptions,
gateway: &str,
) -> Result<i32> {
if persist {
return session_result;
}
let names = [sandbox_name.to_string()];
if let Err(err) = sandbox_delete(
server,
&names,
false,
workspace,
tls,
gateway,
SandboxDeleteOptions::default(),
)
.await
{
if let Ok(exit_code) = session_result.as_ref() {
return Err(miette::miette!(
"sandbox command exited with status {exit_code}, but ephemeral cleanup failed: {err}"
));
}
eprintln!("Failed to delete sandbox {sandbox_name}: {err}");
}
session_result
}
/// Configuration for creating a sandbox via the CLI.
///
/// Infrastructure parameters (`server`, `gateway_name`, `tls`) remain positional
/// on the function signature, following the `provider_refresh_config(server, input, tls)`
/// precedent. This struct captures sandbox-specific options.
#[derive(Debug)]
pub struct SandboxCreateConfig<'a> {
pub name: Option<&'a str>,
pub template: Option<&'a str>,
pub from: Option<&'a str>,
pub uploads: &'a [(String, Option<String>, bool)],
pub keep: bool,
pub gpu_requirements: Option<GpuResourceRequirements>,
pub cpu: Option<&'a str>,
pub memory: Option<&'a str>,
pub driver_config_json: Option<&'a str>,
pub editor: Option<Editor>,
pub providers: &'a [String],
pub policy: Option<&'a str>,
pub forward: Option<ForwardSpec>,
pub expose: Option<u16>,
pub expose_authorization_mode: ServiceAuthorizationMode,
pub command: &'a [String],
pub tty_override: Option<bool>,
pub auto_providers_override: Option<bool>,
pub labels: HashMap<String, String>,
pub environment: HashMap<String, String>,
pub approval_mode: &'a str,
pub output: &'a str,
pub detach: bool,
pub suppress_credential_warnings: bool,
pub restart_policy: &'a str,
}
impl Default for SandboxCreateConfig<'_> {
fn default() -> Self {
Self {
name: None,
template: None,
from: None,
uploads: &[],
keep: false,
gpu_requirements: None,
cpu: None,
memory: None,
driver_config_json: None,
editor: None,
providers: &[],
policy: None,
forward: None,
expose: None,
expose_authorization_mode: ServiceAuthorizationMode::Strip,
command: &[],
tty_override: None,
auto_providers_override: None,
labels: HashMap::new(),
environment: HashMap::new(),
approval_mode: "manual",
output: "table",
detach: false,
suppress_credential_warnings: false,
restart_policy: "never",
}
}
}
/// Create a sandbox with default settings.
pub async fn sandbox_create(
server: &str,
gateway_name: &str,
config: SandboxCreateConfig<'_>,
workspace: &str,
tls: &TlsOptions,
) -> Result<i32> {
let SandboxCreateConfig {
name,
template,
from,
uploads,
keep,
gpu_requirements,
cpu,
memory,
driver_config_json,
editor,
providers,
policy,
forward,
expose,
expose_authorization_mode,
command,
tty_override,
auto_providers_override,
labels,
environment,
approval_mode,
output,
detach,
suppress_credential_warnings,
restart_policy,
} = config;
if editor.is_some() && !command.is_empty() {
return Err(miette::miette!(
"--editor cannot be used with a trailing command; use `openshell sandbox connect <name> --editor ...` after the sandbox is ready"
));
}
if !uploads.is_empty() && !command.is_empty() {
return Err(miette::miette!(
"--upload cannot be combined with a trailing main command yet because uploads complete after the canonical process starts"
));
}
if output != "table" && !command.is_empty() && !detach {
return Err(miette::miette!(
"structured output cannot be combined with an attached trailing command; use table output to stream the command or add --detach"
));
}
if expose == Some(0) {
return Err(miette::miette!("--expose port must be in 1..=65535"));
}
// Check port availability *before* creating the sandbox so we don't
// leave an orphaned sandbox behind when the forward would fail.
if let Some(ref spec) = forward {
openshell_core::forward::check_port_available(spec)?;
}
let mut client = grpc_client(server, tls).await.wrap_err_with(|| {
format!(
"failed to connect to gateway '{gateway_name}' at {server}. \
Start the gateway service with the installed package manager, \
or register a different endpoint with `openshell gateway add <endpoint>`."
)
})?;
let effective_server = server.to_string();
let effective_tls = tls.clone();
// Provider profiles are import-only, so the catalog lives on the gateway.
// Its only consumer is the credential warning, which is advisory: an
// unreachable catalog degrades the warning to its generic form rather than
// blocking sandbox creation. Nothing else derives authority from it.
let profile_catalog = fetch_provider_profile_catalog(&mut client, workspace)
.await
.unwrap_or_default();
warn_credential_env_vars(&environment, &profile_catalog, suppress_credential_warnings);
if template.is_some()
&& (from.is_some()
|| gpu_requirements.is_some()
|| cpu.is_some()
|| memory.is_some()
|| driver_config_json.is_some()
|| !environment.is_empty())
{
return Err(miette::miette!(
"--template cannot be combined with inline workload flags"
));
}
// Resolve the --from flag into a container image reference, or stage a
// rootfs tar on the gateway and retain its staging token. Template creates
// resolve workload shape on the gateway and skip --from.
let (image, rootfs_tar_token): (Option<String>, Option<String>) = if template.is_some() {
(None, None)
} else {
match from {
Some(val) => {
let resolved = resolve_from(val)?;
match resolved {
ResolvedSource::Image(img) => (Some(img), None),
ResolvedSource::RootfsTar { path } => {
let token =
stage_rootfs_tar(gateway_name, &mut client, workspace, &path).await?;
(None, Some(token))
}
}
}
None => (None, None),
}
};
let configured_providers =
ensure_required_providers(&mut client, providers, auto_providers_override, workspace)
.await?;
let policy = load_sandbox_policy(policy)?;
let resource_limits = if template.is_none() {
build_sandbox_resource_limits(cpu, memory)?
} else {
None
};
let mut driver_config = if template.is_none() {
driver_config_json
.map(parse_driver_config_json)
.transpose()?
} else {
None
};
if let Some(token) = &rootfs_tar_token {
driver_config = Some(merge_rootfs_tar_driver_config(driver_config, token)?);
}
let inline_template = if image.is_some()
|| resource_limits.is_some()
|| driver_config.is_some()
|| rootfs_tar_token.is_some()
{
Some(SandboxTemplate {
image: image.unwrap_or_default(),
resources: resource_limits,
driver_config,
..SandboxTemplate::default()
})
} else {
None
};
let resource_requirements = gpu_requirements.map(|gpu| ResourceRequirements { gpu: Some(gpu) });
let main_terminal = tty_override
.unwrap_or_else(|| std::io::stdin().is_terminal() && std::io::stdout().is_terminal());
// Forward the command as-is. When empty, the gateway persists it empty and
// the supervisor resolves the default login shell against the sandbox image
// (bash when present, otherwise /bin/sh on minimal images like Alpine).
// Baking a shell here would force a shell the image may not ship.
let main_command = command.to_vec();
let persist = sandbox_should_persist(keep, forward.as_ref(), expose);
let create_detaches = detach
|| (persist
&& command.is_empty()
&& (!std::io::stdin().is_terminal() || !std::io::stdout().is_terminal()));
let await_main_process_attachment = output == "table" && editor.is_none() && !create_detaches;
let annotations = if persist {
HashMap::new()
} else {
HashMap::from([(
"openshell.nvidia.com/retention".to_string(),
"ephemeral".to_string(),
)])
};
let request = CreateSandboxRequest {
request_id: String::new(),
spec: Some(SandboxSpec {
resource_requirements,
environment: if template.is_none() {
environment
} else {
HashMap::new()
},
policy,
providers: configured_providers,
template: inline_template,
command: main_command,
tty: main_terminal,
restart_policy: match restart_policy {
"never" => SandboxRestartPolicy::Never as i32,
"on-failure" => SandboxRestartPolicy::OnFailure as i32,
"always" => SandboxRestartPolicy::Always as i32,
value => return Err(miette::miette!("invalid restart policy '{value}'")),
},
..SandboxSpec::default()
}),
name: name.unwrap_or_default().to_string(),
labels,
annotations,
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
await_main_process_attachment,
workload_template: template.unwrap_or_default().to_string(),
service_exposures: expose
.map(|target_port| SandboxServiceExposure {
service: String::new(),
target_port: u32::from(target_port),
authorization_mode: expose_authorization_mode as i32,
})
.into_iter()
.collect(),
};
let response = match client.create_sandbox(request).await {
Ok(resp) => resp,
Err(status) if status.code() == Code::AlreadyExists => {
return Err(miette::miette!(
"{}\n\nhint: delete it first with: openshell sandbox delete <name>\n or use a different name",
status.message()
));
}
Err(status) => return Err(miette::miette!(status.to_string())),
};
let response = response.into_inner();
let service_urls = response
.service_urls
.into_iter()
.map(|(service, url)| (service, service_url_for_gateway(&url, &effective_server)))
.collect::<HashMap<_, _>>();
let sandbox = response
.sandbox
.ok_or_else(|| miette::miette!("sandbox missing from response"))?;
let interactive = std::io::stdout().is_terminal();
let sandbox_name = if sandbox.object_name().is_empty() {
"unknown".to_string()
} else {
sandbox.object_name().to_string()
};
// Record this sandbox as the last-used for the active gateway only when it
// is expected to persist beyond the initial session.
if persist && let Some(gateway) = effective_tls.gateway_name() {
let _ = save_last_sandbox(gateway, workspace, &sandbox_name);
}
// Persist `--approval-mode` as a sandbox-scoped setting now that the
// sandbox exists. `manual` is the implicit default (no setting needed);
// any other value is written so it survives sandbox restarts and can be
// flipped later via `openshell settings set <name> proposal_approval_mode`.
// If the write fails the sandbox still runs in default `manual` — surface
// the recovery command so the user can retry.
if approval_mode != "manual" {
let setting = parse_cli_setting_value(settings::PROPOSAL_APPROVAL_MODE_KEY, approval_mode)?;
match client
.update_config(UpdateConfigRequest {
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
setting_key: settings::PROPOSAL_APPROVAL_MODE_KEY.to_string(),
setting_value: Some(setting),
..Default::default()
})
.await
{
Ok(_) => {}
Err(status) => {
eprintln!(
"{} failed to set approval mode '{approval_mode}' on sandbox '{sandbox_name}': {}\n retry with: openshell settings set {sandbox_name} proposal_approval_mode {approval_mode}",
"warning:".yellow().bold(),
status.message(),
);
}
}
}
let structured_output = output != "table";
// Set up display — interactive terminals get a step-based checklist with
// 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 {
ProgressOutput::Plain
};
if structured_output {
eprintln!("Provisioning sandbox (structured output on stdout)...");
} else {
// 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.
let provision_start = Instant::now();
// Don't use stop_on_terminal on the server — the Kubernetes CRD may
// briefly report a stale Ready status before the controller reconciles
// a newly created sandbox. Instead we handle termination client-side:
// we wait until we have observed at least one non-Ready phase followed
// by Ready (a genuine Provisioning → Ready transition).
let sandbox_name = sandbox.object_name().to_string();
let sandbox_workspace = sandbox.object_workspace().to_string();
let mut stream = client
.watch_sandbox(WatchSandboxRequest {
sandbox: sandbox_name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
sandbox_workspace.clone(),
)),
follow_status: true,
follow_logs: true,
follow_events: true,
log_tail_lines: 200,
event_tail: 50,
stop_on_terminal: false,
since_time: None,
log_sources: vec!["gateway".to_string()],
log_min_level: String::new(),
resume_after_cursor: String::new(),
})
.await
.into_diagnostic()?
.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.
let mut saw_non_ready = SandboxPhase::try_from(sandbox.phase()) != Ok(SandboxPhase::Ready);
let provision_timeout = Duration::from_secs(
std::env::var("OPENSHELL_PROVISION_TIMEOUT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(300),
);
let mut provisioning_idle_deadline = Instant::now() + provision_timeout;
// The compute driver can publish ContainerExited while the supervisor's
// authoritative canonical-process result is waiting for the same gateway
// state lock. Keep watching briefly so the provisional error cannot race
// ephemeral cleanup, but retain a deadline for containers that exit before
// the supervisor can report a result.
let mut provisional_container_exit_deadline: Option<Instant> = None;
// Track whether we saw the gateway become ready (from log messages).
let mut saw_gateway_ready = false;
loop {
// Timeout only when provisioning goes idle. VM first-create can spend
// longer than the default timeout pulling and preparing large images,
// but only recognized progress events extend the idle deadline. Logs
// and generic status churn must not keep a stuck sandbox alive forever.
let now = Instant::now();
let mut remaining = provisioning_idle_deadline.saturating_duration_since(now);
if let Some(deadline) = provisional_container_exit_deadline {
remaining = remaining.min(deadline.saturating_duration_since(now));
}
if remaining.is_zero() {
if provisional_container_exit_deadline.is_some() {
break;
}
let timeout_message = provisioning_timeout_message(
provision_timeout.as_secs(),
resource_requirements.as_ref(),
last_condition_message.as_deref(),
);
if let Some(d) = display.as_interactive_mut() {
d.finish_error(&timeout_message);
}
if display.is_plain() {
println!();
}
return Err(miette::miette!(timeout_message));
}
let maybe_item = tokio::time::timeout(remaining, stream.next()).await;
let item = match maybe_item {
Ok(Some(item)) => item,
Ok(None) => break, // stream ended
Err(_elapsed) if provisional_container_exit_deadline.is_some() => break,
Err(_elapsed) => {
// Timeout fired — the stream was idle for too long.
let timeout_message = provisioning_timeout_message(
provision_timeout.as_secs(),
resource_requirements.as_ref(),
last_condition_message.as_deref(),
);
if let Some(d) = display.as_interactive_mut() {
d.finish_error(&timeout_message);
}
if display.is_plain() {
println!();
}
return Err(miette::miette!(timeout_message));
}
};
let evt = item.into_diagnostic()?;
match evt.payload {
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);
}
if phase != SandboxPhase::Ready {
saw_non_ready = true;
}
let main_process_result = has_main_process_result(&s);
if matches!(
phase,
SandboxPhase::Completed | SandboxPhase::Error | SandboxPhase::Stopped
) && main_process_result
{
if let Some(d) = display.as_interactive_mut() {
d.clear();
}
break;
}
// Capture infrastructure error reasons only after excluding a
// canonical-command result, which must attach and drain output.
if phase == SandboxPhase::Error
&& let Some(status) = &s.status
{
for condition in &status.conditions {
if condition.r#type == "Ready"
&& condition.status.eq_ignore_ascii_case("false")
{
last_error_reason =
format!("{}: {}", condition.reason, condition.message);
}
}
if is_provisional_container_exit(&s) {
provisional_container_exit_deadline.get_or_insert_with(|| {
Instant::now() + PROVISIONAL_CONTAINER_EXIT_RECONCILIATION_TIMEOUT
});
continue;
}
break;
}
// 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_interactive_mut() {
d.clear();
}
break;
}
}
Some(openshell_core::proto::sandbox_stream_event::Payload::Log(line)) => {
// Detect gateway readiness from log messages.
if !saw_gateway_ready && line.message.contains("listening") {
saw_gateway_ready = true;
}
}
Some(openshell_core::proto::sandbox_stream_event::Payload::Event(ev)) => {
let extends_timeout = is_provisioning_progress_event(&ev);
// Silent mode suppresses all progress output; only update
// the deadline when applicable.
let handled = match &mut display {
ProgressOutput::Interactive(d) => {
handle_platform_progress_event(&ev, Some(d), provision_start)
}
ProgressOutput::Plain => {
handle_platform_progress_event(&ev, None, provision_start)
}
ProgressOutput::Silent => false,
};
if handled {
if extends_timeout {
provisioning_idle_deadline = Instant::now() + provision_timeout;
}
continue;
}
if extends_timeout {
provisioning_idle_deadline = Instant::now() + provision_timeout;
}
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)) => {
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(_))
| None => {
// Draft policy updates are handled in the draft panel, not during provisioning.
}
}
}
// 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);
let final_has_main_process_result = has_main_process_result(&last_sandbox);
if !(matches!(
final_phase,
SandboxPhase::Ready | SandboxPhase::Completed | SandboxPhase::Stopped
) || final_phase == SandboxPhase::Error && final_has_main_process_result)
&& let Some(d) = display.as_interactive_mut()
{
if final_phase == SandboxPhase::Error {
let msg = if last_error_reason.is_empty() {
"Sandbox entered error phase".to_string()
} else {
format!("Error: {last_error_reason}")
};
d.finish_error(&msg);
} else {
d.finish_error("Provisioning stream ended unexpectedly");
}
}
drop(display);
let _ = std::io::stdout().flush();
let _ = std::io::stderr().flush();
match final_phase {
SandboxPhase::Ready => {
drop(stream);
drop(client);
let upload_count = uploads.len();
for (idx, (local_path, sandbox_path, git_ignore)) in uploads.iter().enumerate() {
let dest = sandbox_path.as_deref();
let dest_display = dest.unwrap_or("~");
if upload_count > 1 {
eprintln!(
" {} Uploading [{}/{}] files to {dest_display}...",
"\u{2022}".dimmed(),
idx + 1,
upload_count,
);
} else {
eprintln!(
" {} Uploading files to {dest_display}...",
"\u{2022}".dimmed(),
);
}
let local = Path::new(local_path);
match sandbox_upload_plan(local, *git_ignore)? {
SandboxUploadPlan::GitAware { base_dir, files } => {
sandbox_sync_up_files(
&effective_server,
&sandbox_name,
&base_dir,
&files,
local,
dest,
&effective_tls,
workspace,
)
.await?;
}
SandboxUploadPlan::GitFilteredEmpty => {
eprintln!(
" {} .gitignore filtering excluded all files in {}; uploading unfiltered",
"⚠".yellow().bold(),
local.display(),
);
sandbox_sync_up(
&effective_server,
&sandbox_name,
local,
dest,
&effective_tls,
workspace,
)
.await?;
}
SandboxUploadPlan::Regular => {
sandbox_sync_up(
&effective_server,
&sandbox_name,
local,
dest,
&effective_tls,
workspace,
)
.await?;
}
}
eprintln!(" {} Files uploaded", "\u{2713}".green().bold());
}
// If --forward was requested, start the background port forward
// *before* running the command so that long-running processes
// (e.g. a web gateway) are reachable immediately.
if let Some(ref spec) = forward {
sandbox_forward(
&effective_server,
&sandbox_name,
spec,
true, // background
&effective_tls,
workspace,
)
.await?;
eprintln!(
" {} Forwarding port {} to sandbox {sandbox_name} in the background\n",
"\u{2713}".green().bold(),
spec.port,
);
eprintln!(" Access at: {}", spec.access_url());
eprintln!(
" Stop with: openshell forward stop {} {sandbox_name}",
spec.port,
);
}
if let Some(target_port) = expose
&& !structured_output
{
eprintln!(
" {} Exposed sandbox {sandbox_name} service on 127.0.0.1:{target_port}",
"\u{2713}".green().bold(),
);
if let Some(url) = service_urls.get("").filter(|url| !url.is_empty()) {
eprintln!(
" Access at: {}",
service_url_for_gateway(url, &effective_server)
);
}
}
if structured_output {
let mut value = sandbox_to_json(&last_sandbox);
if let Some(object) = value.as_object_mut() {
object.insert("service_urls".to_string(), serde_json::json!(service_urls));
}
crate::output::print_output_single(output, &value, Clone::clone)?;
return Ok(0);
}
if let Some(editor) = editor {
let ssh_gateway_name = effective_tls.gateway_name().unwrap_or(gateway_name);
sandbox_connect_editor(
&effective_server,
ssh_gateway_name,
&sandbox_name,
editor,
&effective_tls,
workspace,
)
.await?;
return Ok(0);
}
// An explicit trailing command is foreground regardless of TTY
// detection. Only --detach opts out. Scratch shells retain the
// non-interactive implicit-detach behavior.
if detach
|| (persist
&& command.is_empty()
&& (!std::io::stdin().is_terminal() || !std::io::stdout().is_terminal()))
{
return Ok(0);
}
let connect_result = if persist {
sandbox_connect(&effective_server, &sandbox_name, &effective_tls, workspace).await
} else {
crate::ssh::sandbox_connect_without_exec(
&effective_server,
&sandbox_name,
&effective_tls,
workspace,
)
.await
};
finalize_sandbox_create_session(
&effective_server,
&sandbox_name,
persist,
connect_result,
workspace,
&effective_tls,
gateway_name,
)
.await
}
SandboxPhase::Completed | SandboxPhase::Stopped | SandboxPhase::Error
if final_has_main_process_result =>
{
drop(stream);
drop(client);
if detach {
return Ok(0);
}
let connect_result = crate::ssh::sandbox_connect_terminal_main(
&effective_server,
&sandbox_name,
&effective_tls,
workspace,
)
.await;
finalize_sandbox_create_session(
&effective_server,
&sandbox_name,
persist,
connect_result,
workspace,
&effective_tls,
gateway_name,
)
.await
}
SandboxPhase::Error => {
drop(stream);
drop(client);
let provisioning_timed_out = last_sandbox
.status
.as_ref()
.and_then(|status| status.provisioning.as_ref())
.is_some_and(|record| record.timeout_time.is_some());
let create_result = if provisioning_timed_out {
Err(miette::miette!(
"{last_error_reason}\nSandbox '{sandbox_name}' was retained. Inspect it with `openshell sandbox get {sandbox_name}`; repair its configuration, then run `openshell sandbox start {sandbox_name}` after cleanup completes."
))
} else if last_error_reason.is_empty() {
Err(miette::miette!(
"sandbox entered error phase while provisioning"
))
} else {
Err(miette::miette!(
"sandbox entered error phase while provisioning: {}",
last_error_reason
))
};
finalize_sandbox_create_session(
&effective_server,
&sandbox_name,
persist
|| last_sandbox
.status
.as_ref()
.and_then(|status| status.provisioning.as_ref())
.is_some_and(|record| record.timeout_time.is_some()),
create_result,
workspace,
&effective_tls,
gateway_name,
)
.await
}
_ => Err(miette::miette!(
"sandbox provisioning stream ended before reaching terminal phase"
)),
}
}
/// Resolved source for the `--from` flag on `sandbox create`.
#[derive(Debug)]
enum ResolvedSource {
/// A ready-to-use container image reference.
Image(String),
/// A flat rootfs tar archive (`.tar`, `.tar.gz`, `.tgz`) to stage for the
/// VM compute driver.
RootfsTar { path: PathBuf },
}
/// Classify the `--from` value into an image reference or a rootfs tar to stage
/// for the VM driver.
///
/// Resolution order:
/// 1. Existing file with `.tar`, `.tar.gz`, or `.tgz` extension → rootfs tar archive.
/// 2. Local Dockerfile and directory paths → an actionable build-and-tag error.
/// 3. Other explicit local paths → an actionable error.
/// 4. Any other value is passed through as an explicit image reference.
fn resolve_from(value: &str) -> Result<ResolvedSource> {
let path = Path::new(value);
if path.is_file() && filename_looks_like_rootfs_tar(path) {
let tar_path = path
.canonicalize()
.into_diagnostic()
.wrap_err_with(|| format!("failed to resolve path: {}", path.display()))?;
return Ok(ResolvedSource::RootfsTar { path: tar_path });
}
if value_looks_like_local_path(value) {
if !path.exists() && filename_looks_like_rootfs_tar(path) {
return Err(miette::miette!(
"local --from path does not exist: {}",
path.display()
));
}
let build_context = if path.is_dir() {
path.display().to_string()
} else {
path.parent()
.map(|p| p.display().to_string())
.filter(|p| !p.is_empty())
.unwrap_or_else(|| ".".to_string())
};
return Err(miette::miette!(
"'--from' no longer builds local Dockerfiles or directories: {}\n\
Build and tag the image with the container engine used by the gateway, then pass the resulting image reference:\n \
docker build -t <image> {} # Docker gateway\n \
podman build -t <image> {} # Podman gateway\n \
openshell sandbox create --from <image>\n\
If the gateway cannot access the selected engine's local image store (for example, a remote or Kubernetes gateway), push the image to a registry that the gateway can pull from.",
path.display(),
build_context,
build_context,
));
}
Ok(ResolvedSource::Image(value.to_string()))
}
#[allow(clippy::case_sensitive_file_extension_comparisons)] // already lowercased
fn filename_looks_like_rootfs_tar(path: &Path) -> bool {
let name = path
.file_name()
.map(|n| n.to_string_lossy())
.unwrap_or_default();
let lower = name.to_lowercase();
lower.ends_with(".tar.gz") || lower.ends_with(".tar") || lower.ends_with(".tgz")
}
fn value_looks_like_local_path(value: &str) -> bool {
let path = Path::new(value);
path.is_absolute()
|| matches!(value, "." | "..")
|| value.starts_with("./")
|| value.starts_with("../")
|| value.starts_with("~/")
|| value_looks_like_bare_dockerfile_name(value)
}
fn value_looks_like_bare_dockerfile_name(value: &str) -> bool {
!value.contains('/')
&& !value.contains(':')
&& Path::new(value)
.file_name()
.is_some_and(|name| name.to_string_lossy().to_lowercase().contains("dockerfile"))
}
fn rootfs_tar_sources_supported_for_gateway(metadata: Option<&GatewayMetadata>) -> bool {
!metadata.is_some_and(|metadata| metadata.is_remote)
}
/// Ask the gateway for a staging slot, then copy the archive into it.
///
/// The gateway owns the destination: it allocates a request-scoped directory
/// and returns a single-use token. We never name a path of our own choosing,
/// so a request cannot reach for another caller's archive or an arbitrary host
/// file. Returns the token to pass on `CreateSandbox`.
async fn stage_rootfs_tar(
gateway_name: &str,
client: &mut crate::tls::GrpcClient,
workspace: &str,
tar_path: &Path,
) -> Result<String> {
let metadata = get_gateway_metadata(gateway_name);
if !rootfs_tar_sources_supported_for_gateway(metadata.as_ref()) {
return Err(miette!(
"local rootfs tar sources are only supported for local gateways; gateway '{}' is remote",
gateway_name
));
}
let file_name = tar_path
.file_name()
.ok_or_else(|| miette!("rootfs tar path has no filename"))?
.to_string_lossy()
.into_owned();
let source_meta = tokio::fs::metadata(tar_path)
.await
.into_diagnostic()
.wrap_err_with(|| format!("failed to read {}", tar_path.display()))?;
// The gateway rejects a driver that cannot take rootfs tar sources, and an
// archive over its configured limit, before allocating anything.
let slot = client
.begin_rootfs_tar_staging(BeginRootfsTarStagingRequest {
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
file_name,
size_bytes: source_meta.len(),
})
.await
.into_diagnostic()
.wrap_err("failed to allocate a rootfs tar staging slot on the gateway")?
.into_inner();
let staged_path = PathBuf::from(&slot.upload_path);
eprintln!(
"Staging rootfs tar {} for gateway '{}'",
tar_path.display().to_string().cyan(),
gateway_name,
);
// Enforced while streaming, so an archive that grows after the size check
// above still cannot exceed the limit.
if let Err(err) = copy_with_byte_limit(tar_path, &staged_path, slot.max_bytes).await {
// The staging directory belongs to the gateway, which reclaims it when
// the slot expires. Removing it here would reach into its state.
return Err(miette!(
"failed to stage rootfs tar to {}: {err}",
staged_path.display()
));
}
eprintln!();
Ok(slot.staging_token)
}
/// Copy `src` to `dst`, aborting if total bytes written exceeds `limit`.
/// A limit of 0 disables enforcement.
async fn copy_with_byte_limit(
src: &Path,
dst: &Path,
limit: u64,
) -> std::result::Result<(), String> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut reader = tokio::fs::File::open(src)
.await
.map_err(|e| format!("open source: {e}"))?;
let mut writer = tokio::fs::File::create(dst)
.await
.map_err(|e| format!("create destination: {e}"))?;
let mut buf = vec![0u8; 64 * 1024];
let mut total: u64 = 0;
loop {
let n = reader
.read(&mut buf)
.await
.map_err(|e| format!("read: {e}"))?;
if n == 0 {
break;
}
total += n as u64;
if limit > 0 && total > limit {
return Err(format!(
"{} exceeds the {} byte limit",
src.display(),
limit
));
}
writer
.write_all(&buf[..n])
.await
.map_err(|e| format!("write: {e}"))?;
}
Ok(())
}
/// `driver_config` key for the VM compute driver. The gateway forwards only
/// `template.driver_config.<driver_name>` to the selected driver, so VM
/// settings must be nested under this key or they are dropped.
const VM_DRIVER_CONFIG_KEY: &str = "vm";
/// VM `driver_config` field naming the gateway-issued staging slot. The
/// gateway swaps it for the resolved archive path before the driver sees it.
const ROOTFS_TAR_TOKEN_FIELD: &str = "rootfs_tar_staging_token";
/// Merge the staging token into `driver_config.vm`, preserving any VM settings
/// the caller already supplied through `--driver-config-json`.
fn merge_rootfs_tar_driver_config(
base: Option<prost_types::Struct>,
staging_token: &str,
) -> Result<prost_types::Struct> {
use prost_types::{Struct, Value, value::Kind};
let mut config = base.unwrap_or_default();
let vm = config
.fields
.entry(VM_DRIVER_CONFIG_KEY.to_string())
.or_insert_with(|| Value {
kind: Some(Kind::StructValue(Struct::default())),
});
let Some(Kind::StructValue(vm_config)) = vm.kind.as_mut() else {
return Err(miette!(
"--driver-config-json '{VM_DRIVER_CONFIG_KEY}' must be an object"
));
};
if vm_config.fields.contains_key(ROOTFS_TAR_TOKEN_FIELD) {
return Err(miette!(
"--driver-config-json already sets {VM_DRIVER_CONFIG_KEY}.{ROOTFS_TAR_TOKEN_FIELD}; \
remove it or drop the rootfs tar from --from"
));
}
vm_config.fields.insert(
ROOTFS_TAR_TOKEN_FIELD.to_string(),
Value {
kind: Some(Kind::StringValue(staging_token.to_string())),
},
);
Ok(config)
}
/// Load sandbox policy YAML.
///
/// Resolution order: `--policy` flag > `OPENSHELL_SANDBOX_POLICY` env var.
/// Returns `None` when no policy source is configured, allowing the server
/// to apply its own default.
fn load_sandbox_policy(cli_path: Option<&str>) -> Result<Option<SandboxPolicy>> {
openshell_policy::load_sandbox_policy(cli_path)
}
/// Sync files to or from a sandbox.
///
/// Dispatches to `sandbox_sync_up` or `sandbox_sync_down` based on the
/// `--up` / `--down` flags.
pub async fn sandbox_sync_command(
server: &str,
name: &str,
up: Option<&str>,
down: Option<&str>,
dest: Option<&str>,
tls: &TlsOptions,
workspace: &str,
) -> Result<()> {
match (up, down) {
(Some(local_path), None) => {
let local = Path::new(local_path);
if !local.exists() {
return Err(miette::miette!(
"local path does not exist: {}",
local.display()
));
}
let dest_display = dest.unwrap_or("~");
eprintln!("Syncing {} -> sandbox:{}", local.display(), dest_display);
sandbox_sync_up(server, name, local, dest, tls, workspace).await?;
eprintln!("{} Sync complete", "✓".green().bold());
}
(None, Some(sandbox_path)) => {
let local_dest = dest.unwrap_or(".");
eprintln!("Syncing sandbox:{sandbox_path} -> {local_dest}");
sandbox_sync_down(server, name, sandbox_path, local_dest, tls, workspace).await?;
eprintln!("{} Sync complete", "✓".green().bold());
}
_ => {
return Err(miette::miette!(
"specify either --up <local-path> or --down <sandbox-path>"
));
}
}
Ok(())
}
pub async fn sandbox_get(
server: &str,
name: &str,
policy_only: bool,
output: &str,
workspace: &str,
tls: &TlsOptions,
) -> Result<()> {
let mut stdout = Vec::new();
sandbox_get_to_writer(
server,
name,
policy_only,
output,
workspace,
tls,
&mut stdout,
)
.await?;
if !stdout.is_empty() {
std::io::stdout()
.lock()
.write_all(&stdout)
.into_diagnostic()?;
}
Ok(())
}
/// Fetch a sandbox by name.
///
/// Policy always comes from [`GetSandboxConfig`] (effective active policy, sandbox
/// or global). With `policy_only`, prints only that YAML to stdout; otherwise
/// prints sandbox metadata and the same policy with formatted YAML.
#[doc(hidden)]
pub async fn sandbox_get_to_writer<W>(
server: &str,
name: &str,
policy_only: bool,
output: &str,
workspace: &str,
tls: &TlsOptions,
stdout: &mut W,
) -> Result<()>
where
W: Write + Send,
{
let mut client = grpc_client(server, tls).await?;
let response = client
.get_sandbox(GetSandboxRequest {
name: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
})
.await
.into_diagnostic()?;
let sandbox = response
.into_inner()
.sandbox
.ok_or_else(|| miette::miette!("sandbox missing from response"))?;
let config_result = client
.get_sandbox_config(GetSandboxConfigRequest {
name: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
})
.await;
let config = match config_result {
Ok(response) => response.into_inner(),
Err(_) if !policy_only && configuration_failure_message(&sandbox).is_some() => {
// An invalid desired policy must not hide the status needed to
// repair it. Keep payload-only reads strict.
GetSandboxConfigResponse {
configuration_error: configuration_failure_message(&sandbox)
.unwrap_or_default()
.to_string(),
..Default::default()
}
}
Err(error) => return Err(error).into_diagnostic(),
};
if policy_only {
let Some(ref policy) = config.policy else {
return Err(miette::miette!(
"no active policy configured for this sandbox"
));
};
let yaml_str = openshell_policy::serialize_sandbox_policy(policy)
.wrap_err("failed to serialize policy to YAML")?;
stdout.write_all(yaml_str.as_bytes()).into_diagnostic()?;
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() {
"unknown"
} else {
sandbox.object_id()
};
let name = if sandbox.object_name().is_empty() {
"unknown"
} else {
sandbox.object_name()
};
println!(" {} {}", "Id:".dimmed(), id);
println!(" {} {}", "Name:".dimmed(), name);
println!(" {} {}", "Phase:".dimmed(), phase_name(sandbox.phase()));
if let Some(status) = sandbox.status.as_ref() {
for condition in &status.conditions {
if matches!(
condition.r#type.as_str(),
"ConfigurationReady" | "DesiredConfigurationReady"
) && condition.status.eq_ignore_ascii_case("false")
{
println!(
" {} {}: {}",
"Configuration:".dimmed(),
condition.reason,
condition.message
);
}
}
}
if let Some(exit_code) = sandbox.status.as_ref().and_then(|status| status.exit_code) {
println!(" {} {}", "Exit Code:".dimmed(), exit_code);
}
println!(
" {} {}",
"Resource version:".dimmed(),
sandbox.metadata.as_ref().map_or(0, |m| m.resource_version)
);
println!(
" {} {}",
"Restart policy:".dimmed(),
sandbox
.spec
.as_ref()
.map_or("never", |spec| { restart_policy_name(spec.restart_policy) })
);
if let Some(status) = sandbox.status.as_ref() {
println!(
" {} {}",
"Main process instance:".dimmed(),
if status.main_process_instance_id.is_empty() {
"-"
} else {
&status.main_process_instance_id
}
);
println!(
" {} {}",
"Last exit code:".dimmed(),
status
.exit_code
.map_or_else(|| "-".to_string(), |code| code.to_string())
);
println!(" {} {}", "Restart count:".dimmed(), status.restart_count);
println!(
" {} {}",
"Next restart:".dimmed(),
status.next_restart_time.as_ref().map_or_else(
|| "-".to_string(),
|time| format_epoch_ms(proto_timestamp_ms(Some(time))),
)
);
}
// Display labels if present
if let Some(metadata) = &sandbox.metadata
&& !metadata.labels.is_empty()
{
println!(" {} ", "Labels:".dimmed());
let mut labels: Vec<_> = metadata.labels.iter().collect();
labels.sort_by_key(|(k, _)| *k);
for (key, value) in labels {
println!(" {key}: {value}");
}
}
if let Some(metadata) = &sandbox.metadata
&& !metadata.annotations.is_empty()
{
println!(" {} ", "Annotations:".dimmed());
let mut annotations: Vec<_> = metadata.annotations.iter().collect();
annotations.sort_by_key(|(k, _)| *k);
for (key, value) in annotations {
println!(" {key}: {value}");
}
}
if let Some(provenance) = &sandbox.created_from_workload_template {
println!(
" {} {}@{}",
"Workload template:".dimmed(),
provenance.name,
provenance.resource_version
);
}
if let Some(status) = &sandbox.status
&& !status.conditions.is_empty()
{
println!(" {}", "Conditions:".dimmed());
for condition in &status.conditions {
for (index, line) in sandbox_condition_display_lines(condition)
.iter()
.enumerate()
{
let prefix = if index == 0 { "- " } else { " " };
println!(" {prefix}{line}");
}
}
}
if let Some(status) = &sandbox.status
&& !status.endpoint_statuses.is_empty()
{
println!(" {}", "Tool server connections:".dimmed());
for endpoint in &status.endpoint_statuses {
for (index, line) in endpoint_status_display_lines(endpoint).iter().enumerate() {
let prefix = if index == 0 { "- " } else { " " };
println!(" {prefix}{line}");
}
}
println!(
" Results come from observed MCP over HTTP traffic. They do not check current availability or tool-call success."
);
}
let policy_from_global = config.policy_source == PolicySource::Global as i32;
println!(
" {} {}",
"Policy source:".dimmed(),
if policy_from_global {
"global"
} else {
"sandbox"
}
);
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
};
if let Some(rev) = revision {
println!(" {} {}", "Revision:".dimmed(), rev);
}
if let Some(ref policy) = config.policy {
println!();
print_sandbox_policy(policy);
}
Ok(())
}
fn local_terminal_size() -> Option<(u32, u32)> {
crossterm::terminal::size()
.ok()
.map(|(cols, rows)| (u32::from(cols), u32::from(rows)))
}
const MAX_EXEC_REQUEST_BYTES: usize = 1024 * 1024;
const MAX_EXEC_STDIN_BYTES: usize = 4 * 1024 * 1024;
/// How long `sandbox exec` waits for piped stdin to reach EOF before it starts
/// the command and streams the rest of the input as it arrives.
///
/// Small pipes such as `echo x | openshell sandbox exec ...` close well within
/// this window and keep using the single-request path that older gateways
/// need. A pipe that stays open (a CI runner, a supervisor, a harness that
/// never closes stdin) must not block the command: after the grace period the
/// command starts and stdin is forwarded until EOF or until the command exits.
const EXEC_STDIN_UNARY_GRACE: Duration = Duration::from_millis(200);
/// Piped stdin as collected by [`collect_piped_stdin`].
enum PipedStdin {
/// stdin reached EOF within the grace period; `prefix` holds all of it.
Complete(Vec<u8>),
/// stdin is still open. `prefix` is what arrived so far; `rest` delivers
/// the remaining chunks until EOF.
Open {
prefix: Vec<u8>,
rest: tokio::sync::mpsc::Receiver<std::io::Result<Vec<u8>>>,
},
}
/// Read a pipe on a detached OS thread and hand its bytes over in chunks.
///
/// A plain `std::thread` rather than `spawn_blocking`, so runtime shutdown
/// never waits on a thread parked in `read(2)`. The thread exits at EOF, on a
/// read error, or when the receiver is dropped.
fn spawn_piped_stdin_reader(
mut reader: impl Read + Send + 'static,
) -> tokio::sync::mpsc::Receiver<std::io::Result<Vec<u8>>> {
let (tx, rx) = tokio::sync::mpsc::channel::<std::io::Result<Vec<u8>>>(64);
std::thread::spawn(move || {
let mut buf = [0u8; 4096];
loop {
match reader.read(&mut buf) {
Ok(0) => return,
Err(error) if error.kind() == ErrorKind::Interrupted => {}
Err(error) => {
let _ = tx.blocking_send(Err(error));
return;
}
Ok(n) => {
if tx.blocking_send(Ok(buf[..n].to_vec())).is_err() {
return;
}
}
}
}
});
rx
}
/// Collect piped stdin until EOF or until `grace` elapses, whichever comes
/// first. Input beyond `limit` bytes is rejected with the upload hint.
async fn collect_piped_stdin(
mut rx: tokio::sync::mpsc::Receiver<std::io::Result<Vec<u8>>>,
grace: Duration,
limit: usize,
) -> Result<PipedStdin> {
let deadline = tokio::time::Instant::now() + grace;
let mut prefix = Vec::new();
loop {
match tokio::time::timeout_at(deadline, rx.recv()).await {
Ok(Some(Ok(chunk))) => {
prefix.extend_from_slice(&chunk);
if prefix.len() > limit {
return Err(piped_stdin_limit_error());
}
}
Ok(Some(Err(error))) => return Err(error).into_diagnostic(),
Ok(None) => return Ok(PipedStdin::Complete(prefix)),
Err(_elapsed) => return Ok(PipedStdin::Open { prefix, rest: rx }),
}
}
}
fn piped_stdin_limit_error() -> miette::Report {
miette::miette!("piped stdin exceeds the 4 MiB limit; use `sandbox upload` for larger input")
}
/// Execute a command in a running sandbox via gRPC, streaming output to the terminal.
///
/// Returns the remote command's exit code, or an error if the event stream
/// closes before the command reports an exit status.
#[allow(clippy::too_many_arguments, clippy::implicit_hasher)]
pub async fn sandbox_exec_grpc(
server: &str,
name: &str,
command: &[String],
workdir: Option<&str>,
timeout_seconds: u32,
tty_override: Option<bool>,
environment: &HashMap<String, String>,
no_login_shell: bool,
tls: &TlsOptions,
workspace: &str,
) -> Result<i32> {
let mut client = grpc_client(server, tls).await?;
// Resolve sandbox name to id.
let sandbox = client
.get_sandbox(GetSandboxRequest {
name: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
})
.await
.into_diagnostic()?
.into_inner()
.sandbox
.ok_or_else(|| miette::miette!("sandbox not found"))?;
// Verify the sandbox is ready before issuing the exec.
if SandboxPhase::try_from(sandbox.phase()) != Ok(SandboxPhase::Ready) {
return Err(miette::miette!(
"sandbox '{}' is not ready (phase: {}); wait for it to reach Ready state",
name,
phase_name(sandbox.phase())
));
}
// Resolve TTY mode: explicit --tty / --no-tty wins, otherwise auto-detect.
let stdin_is_terminal = std::io::stdin().is_terminal();
let tty = tty_override.unwrap_or_else(|| stdin_is_terminal && std::io::stdout().is_terminal());
// Preserve unary exec for small pipes, including older gateways whose
// interactive RPC closes the SSH channel when stdin reaches EOF. Retain
// the existing 4 MiB input cap because the supervisor's process stdin
// queue is unbounded; larger input should use file upload instead.
//
// Never block on stdin EOF before starting the command: a pipe that stays
// open (CI runners, harnesses) would otherwise hang the exec forever
// without the gateway ever seeing the request. After a short grace period
// the command starts and the remaining input streams until EOF.
let (stdin_prefix, stdin_rest) = if stdin_is_terminal {
(Vec::new(), None)
} else {
match collect_piped_stdin(
spawn_piped_stdin_reader(std::io::stdin()),
EXEC_STDIN_UNARY_GRACE,
MAX_EXEC_STDIN_BYTES,
)
.await?
{
PipedStdin::Complete(prefix) => (prefix, None),
PipedStdin::Open { prefix, rest } => (prefix, Some(rest)),
}
};
let (cols, rows) = if tty {
local_terminal_size().unwrap_or((80, 24))
} else {
(0, 0)
};
let mut request = ExecSandboxRequest {
request_id: String::new(),
sandbox: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
command: command.to_vec(),
workdir: workdir.unwrap_or_default().to_string(),
environment: environment.clone(),
execution_timeout: proto_execution_timeout(timeout_seconds)?,
stdin: stdin_prefix,
tty,
cols,
rows,
no_login_shell,
};
let stdin_for_size_check = std::mem::take(&mut request.stdin);
let start_request_bytes = request.encoded_len();
request.stdin = stdin_for_size_check;
if start_request_bytes > MAX_EXEC_REQUEST_BYTES {
return Err(miette::miette!(
"exec command or environment exceeds the gateway's 1 MiB message limit"
));
}
if (tty && stdin_is_terminal)
|| stdin_rest.is_some()
|| request.encoded_len() > MAX_EXEC_REQUEST_BYTES
{
return sandbox_exec_streaming_grpc(
client,
&sandbox,
command,
workdir,
timeout_seconds,
environment,
no_login_shell,
tty,
stdin_is_terminal,
std::mem::take(&mut request.stdin),
stdin_rest,
)
.await;
}
// Make the streaming gRPC call.
let mut stream = client
.exec_sandbox(request)
.await
.into_diagnostic()?
.into_inner();
// Stream output to terminal in real-time.
let mut exit_code = 0i32;
let mut exit_seen = false;
let stdout = std::io::stdout();
let stderr = std::io::stderr();
while let Some(event) = stream.next().await {
let event = event.into_diagnostic()?;
match event.payload {
Some(exec_sandbox_event::Payload::Stdout(out)) => {
let mut handle = stdout.lock();
handle.write_all(&out.data).into_diagnostic()?;
handle.flush().into_diagnostic()?;
}
Some(exec_sandbox_event::Payload::Stderr(err)) => {
let mut handle = stderr.lock();
handle.write_all(&err.data).into_diagnostic()?;
handle.flush().into_diagnostic()?;
}
Some(exec_sandbox_event::Payload::Exit(exit)) => {
exit_code = exit.exit_code;
exit_seen = true;
}
None => {}
}
}
// A stream that closes without an Exit event means we never observed the
// command's outcome. The server treats the same condition as a relay
// failure; mirror that here so exit 0 stays meaningful.
if !exit_seen {
return Err(miette::miette!(
"sandbox exec relay closed before the command reported an exit status"
));
}
Ok(exit_code)
}
pub async fn service_forward_tcp(
server: &str,
name: &str,
local: Option<&str>,
target_host: &str,
target_port: u16,
tls: &TlsOptions,
workspace: &str,
) -> Result<()> {
let (bind_addr, bind_port) = parse_tcp_forward_spec(local, target_port)?;
let mut client = grpc_client(server, tls).await?;
fetch_ready_sandbox_for_forward(&mut client, name, workspace).await?;
let listener = tokio::net::TcpListener::bind((bind_addr.as_str(), bind_port))
.await
.into_diagnostic()
.wrap_err_with(|| format!("failed to bind local forward on {bind_addr}:{bind_port}"))?;
let local_addr = listener
.local_addr()
.into_diagnostic()
.wrap_err("failed to read local forward address")?;
eprintln!(
"{} Forwarding {} -> {}:{} in sandbox {} via gRPC",
"✓".green().bold(),
local_addr,
target_host,
target_port,
name,
);
let sandbox_name = name.to_string();
let sandbox_workspace = workspace.to_string();
let (fatal_tx, mut fatal_rx) = tokio::sync::mpsc::channel::<String>(1);
let mut health_check = tokio::time::interval(Duration::from_secs(2));
health_check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tokio::select! {
Some(reason) = fatal_rx.recv() => {
return Err(miette::miette!("service forward stopped: {reason}"));
}
_ = health_check.tick() => {
fetch_ready_sandbox_for_forward(&mut client, name, workspace).await?;
}
accepted = listener.accept() => {
let (socket, peer) = accepted
.into_diagnostic()
.wrap_err("failed to accept local forward connection")?;
set_tcp_nodelay_best_effort(&socket);
let mut client = client.clone();
let sandbox_name = sandbox_name.clone();
let sandbox_workspace = sandbox_workspace.clone();
let target_host = target_host.to_string();
let service_id = format!("service-forward:{name}:{target_host}:{target_port}");
let fatal_tx = fatal_tx.clone();
tokio::spawn(async move {
let token = match create_forward_session_token(
&mut client,
&sandbox_name,
&sandbox_workspace,
).await {
Ok(token) => token,
Err(err) => {
tracing::warn!(peer = %peer, error = %err, "service forward session creation failed");
if err.fatal {
let _ = fatal_tx.send(err.message).await;
}
return;
}
};
if let Err(err) = forward_one_tcp_connection(
&mut client,
socket,
sandbox_name,
sandbox_workspace,
target_host,
target_port,
service_id,
token.clone(),
)
.await
{
tracing::warn!(peer = %peer, error = %err, "service forward connection failed");
if err.fatal {
let _ = fatal_tx.send(err.message).await;
}
}
let _ = client
.revoke_ssh_session(RevokeSshSessionRequest { allow_missing: true, token })
.await;
});
}
}
}
}
async fn create_forward_session_token(
client: &mut crate::tls::GrpcClient,
sandbox_name: &str,
workspace: &str,
) -> std::result::Result<String, ForwardTcpConnectionError> {
let response = client
.create_ssh_session(CreateSshSessionRequest {
sandbox: sandbox_name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
})
.await
.map_err(ForwardTcpConnectionError::from_status)?;
Ok(response.into_inner().token)
}
async fn fetch_ready_sandbox_for_forward(
client: &mut crate::tls::GrpcClient,
name: &str,
workspace: &str,
) -> Result<Sandbox> {
let response = match client
.get_sandbox(GetSandboxRequest {
name: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
})
.await
{
Ok(response) => response,
Err(status) if status.code() == Code::NotFound => {
return Err(miette::miette!(
"sandbox '{name}' no longer exists; stopping service forward"
));
}
Err(status) => return Err(status).into_diagnostic(),
};
let sandbox = response
.into_inner()
.sandbox
.ok_or_else(|| miette::miette!("sandbox '{name}' not found"))?;
if SandboxPhase::try_from(sandbox.phase()) != Ok(SandboxPhase::Ready) {
return Err(miette::miette!(
"sandbox '{}' is no longer ready (phase: {}); stopping service forward",
name,
phase_name(sandbox.phase())
));
}
Ok(sandbox)
}
#[derive(Debug)]
struct ForwardTcpConnectionError {
message: String,
fatal: bool,
}
impl ForwardTcpConnectionError {
fn transient(message: impl Into<String>) -> Self {
Self {
message: message.into(),
fatal: false,
}
}
fn from_status(status: Status) -> Self {
let fatal = matches!(status.code(), Code::NotFound | Code::FailedPrecondition);
Self {
message: status.to_string(),
fatal,
}
}
}
impl std::fmt::Display for ForwardTcpConnectionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for ForwardTcpConnectionError {}
fn parse_tcp_forward_spec(local: Option<&str>, default_port: u16) -> Result<(String, u16)> {
let Some(spec) = local else {
return Ok(("127.0.0.1".to_string(), default_port));
};
if let Some(pos) = spec.rfind(':') {
let addr = &spec[..pos];
let port_str = &spec[pos + 1..];
if let Ok(port) = port_str.parse::<u16>() {
if addr.is_empty() {
return Err(miette::miette!("bind address is required before ':'"));
}
return Ok((addr.to_string(), port));
}
}
let port: u16 = spec.parse().map_err(|_| {
miette::miette!("invalid local forward spec '{spec}': expected [bind_address:]port")
})?;
Ok(("127.0.0.1".to_string(), port))
}
#[allow(clippy::too_many_arguments)] // one connection's sandbox, target, and authorization context
async fn forward_one_tcp_connection(
client: &mut crate::tls::GrpcClient,
socket: tokio::net::TcpStream,
sandbox_name: String,
workspace: String,
target_host: String,
target_port: u16,
service_id: String,
authorization_token: String,
) -> std::result::Result<(), ForwardTcpConnectionError> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio_stream::wrappers::ReceiverStream;
let (tx, rx) = tokio::sync::mpsc::channel::<TcpForwardFrame>(16);
tx.send(TcpForwardFrame {
payload: Some(openshell_core::proto::tcp_forward_frame::Payload::Init(
TcpForwardInit {
sandbox: sandbox_name,
workspace: workspace.clone(),
service_id,
target: Some(tcp_forward_init::Target::Tcp(TcpRelayTarget {
host: target_host,
port: u32::from(target_port),
})),
authorization_token,
},
)),
})
.await
.map_err(|_| ForwardTcpConnectionError::transient("failed to initialize forward stream"))?;
let mut response = match client.forward_tcp(ReceiverStream::new(rx)).await {
Ok(response) => response.into_inner(),
Err(status) => {
let err = ForwardTcpConnectionError::from_status(status);
drain_and_shutdown_local_socket(socket).await;
return Err(err);
}
};
let (mut local_read, mut local_write) = socket.into_split();
let to_gateway = tokio::spawn(async move {
let mut buf = vec![0u8; 64 * 1024];
loop {
let n = local_read.read(&mut buf).await?;
if n == 0 {
break;
}
if tx
.send(TcpForwardFrame {
payload: Some(openshell_core::proto::tcp_forward_frame::Payload::Data(
buf[..n].to_vec(),
)),
})
.await
.is_err()
{
break;
}
}
Ok::<(), std::io::Error>(())
});
while let Some(frame) = response
.message()
.await
.map_err(ForwardTcpConnectionError::from_status)?
{
let Some(openshell_core::proto::tcp_forward_frame::Payload::Data(data)) = frame.payload
else {
continue;
};
if data.is_empty() {
continue;
}
local_write
.write_all(&data)
.await
.map_err(|err| ForwardTcpConnectionError::transient(err.to_string()))?;
}
let _ = local_write.shutdown().await;
to_gateway.abort();
Ok(())
}
async fn drain_and_shutdown_local_socket(mut socket: tokio::net::TcpStream) {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut buf = [0u8; 4096];
while matches!(
tokio::time::timeout(Duration::from_millis(25), socket.read(&mut buf)).await,
Ok(Ok(n)) if n != 0
) {}
let _ = socket.shutdown().await;
}
struct RawModeGuard;
impl Drop for RawModeGuard {
fn drop(&mut self) {
let _ = crossterm::terminal::disable_raw_mode();
}
}
#[cfg(unix)]
struct TaskGuard(tokio::task::JoinHandle<()>);
#[cfg(unix)]
impl Drop for TaskGuard {
fn drop(&mut self) {
self.0.abort();
}
}
#[allow(clippy::too_many_arguments)]
async fn sandbox_exec_streaming_grpc(
mut client: crate::tls::GrpcClient,
sandbox: &Sandbox,
command: &[String],
workdir: Option<&str>,
timeout_seconds: u32,
environment: &HashMap<String, String>,
no_login_shell: bool,
tty: bool,
stdin_is_terminal: bool,
stdin_prefix: Vec<u8>,
stdin_rest: Option<tokio::sync::mpsc::Receiver<std::io::Result<Vec<u8>>>>,
) -> Result<i32> {
#[cfg(unix)]
use openshell_core::proto::ExecSandboxWindowResize;
use openshell_core::proto::{ExecSandboxInput, exec_sandbox_input};
use tokio_stream::wrappers::ReceiverStream;
let (cols, rows) = if tty {
local_terminal_size().unwrap_or((80, 24))
} else {
(0, 0)
};
let (input_tx, input_rx) = tokio::sync::mpsc::channel::<ExecSandboxInput>(64);
// Send the start message with exec metadata.
input_tx
.send(ExecSandboxInput {
payload: Some(exec_sandbox_input::Payload::Start(ExecSandboxRequest {
request_id: String::new(),
sandbox: sandbox.object_name().to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
(sandbox.object_workspace()).to_string(),
)),
command: command.to_vec(),
workdir: workdir.unwrap_or_default().to_string(),
environment: environment.clone(),
no_login_shell,
execution_timeout: proto_execution_timeout(timeout_seconds)?,
stdin: Vec::new(),
tty,
cols,
rows,
})),
})
.await
.into_diagnostic()?;
let mut stream = client
.exec_sandbox_interactive(ReceiverStream::new(input_rx))
.await
.into_diagnostic()?
.into_inner();
// Raw mode is only appropriate for an interactive terminal, not a pipe.
let raw_guard = if tty && stdin_is_terminal {
crossterm::terminal::enable_raw_mode().into_diagnostic()?;
Some(RawModeGuard)
} else {
None
};
// Stdin reader on a detached OS thread. Using std::thread (not
// spawn_blocking) so the tokio runtime shutdown doesn't wait for a
// thread blocked on stdin.read(). The thread exits when the channel
// closes (blocking_send returns Err) or stdin hits EOF.
let stdin_tx = input_tx.clone();
let (stdin_result_tx, mut stdin_result_rx) = tokio::sync::oneshot::channel();
if let Some(mut rest) = stdin_rest {
// Piped stdin that was still open when the command started: the
// reader thread from `spawn_piped_stdin_reader` already owns stdin,
// so forward its chunks here after the collected prefix. The 4 MiB
// cap covers the prefix and the streamed remainder together.
tokio::spawn(async move {
let mut forwarded = 0usize;
let result = async {
for chunk in stdin_prefix.chunks(4096) {
forwarded += chunk.len();
if stdin_tx
.send(ExecSandboxInput {
payload: Some(exec_sandbox_input::Payload::Stdin(chunk.to_vec())),
})
.await
.is_err()
{
return Ok(());
}
}
while let Some(chunk) = rest.recv().await {
let chunk = chunk?;
forwarded += chunk.len();
if forwarded > MAX_EXEC_STDIN_BYTES {
return Err(std::io::Error::new(
ErrorKind::InvalidInput,
piped_stdin_limit_error().to_string(),
));
}
if stdin_tx
.send(ExecSandboxInput {
payload: Some(exec_sandbox_input::Payload::Stdin(chunk)),
})
.await
.is_err()
{
return Ok(());
}
}
Ok(())
}
.await;
let _ = stdin_result_tx.send(result);
});
} else {
std::thread::spawn(move || {
let mut stdin = std::io::stdin().lock();
let mut buf = [0u8; 4096];
let result = (|| {
for chunk in stdin_prefix.chunks(buf.len()) {
if stdin_tx
.blocking_send(ExecSandboxInput {
payload: Some(exec_sandbox_input::Payload::Stdin(chunk.to_vec())),
})
.is_err()
{
return Ok(());
}
}
loop {
match stdin.read(&mut buf) {
Ok(0) => return Ok(()),
Err(error) if error.kind() == ErrorKind::Interrupted => {}
Err(error) => return Err(error),
Ok(n) => {
if stdin_tx
.blocking_send(ExecSandboxInput {
payload: Some(exec_sandbox_input::Payload::Stdin(
buf[..n].to_vec(),
)),
})
.is_err()
{
return Ok(());
}
}
}
}
})();
let _ = stdin_result_tx.send(result);
});
}
// SIGWINCH handler: forward terminal resize events.
#[cfg(unix)]
let resize_task = if tty && stdin_is_terminal {
let resize_tx = input_tx.clone();
Some(tokio::spawn(async move {
let mut sig =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::window_change())
.expect("failed to register SIGWINCH handler");
while sig.recv().await.is_some() {
if let Some((cols, rows)) = local_terminal_size() {
let msg = ExecSandboxInput {
payload: Some(exec_sandbox_input::Payload::Resize(
ExecSandboxWindowResize { cols, rows },
)),
};
if resize_tx.send(msg).await.is_err() {
break;
}
}
}
}))
} else {
None
};
#[cfg(unix)]
let _resize_guard = resize_task.map(TaskGuard);
// Keep a sender until the reader confirms clean EOF. On a read error,
// cancel the response stream before the gateway can treat channel EOF as
// successful completion of a partial command.
let mut pipe_input_tx = Some(input_tx);
let mut exit_code = 0i32;
let mut exit_seen = false;
let stdout = std::io::stdout();
let stderr = std::io::stderr();
let mut stdin_reader_done = false;
loop {
let event = tokio::select! {
result = &mut stdin_result_rx, if !stdin_reader_done => {
stdin_reader_done = true;
match result.into_diagnostic()? {
Ok(()) => {
let sender = pipe_input_tx.take().expect("stdin sender is held until EOF");
drop(sender);
}
Err(error) => {
let sender = pipe_input_tx.take().expect("stdin sender is held until EOF");
// A clean request EOF would make the gateway execute
// the truncated input. An invalid frame makes the
// gateway abort the command instead.
let abort = ExecSandboxInput { payload: None };
if tokio::time::timeout(Duration::from_secs(5), sender.send(abort))
.await
.is_err()
{
// Keep the request body open if a blocked remote
// stdin prevents delivery of the abort frame.
std::mem::forget(sender);
}
drop(stream);
return Err(error).into_diagnostic();
}
}
continue;
}
event = stream.next() => event,
};
let Some(event) = event else { break };
let event = event.into_diagnostic()?;
match event.payload {
Some(exec_sandbox_event::Payload::Stdout(out)) => {
let mut handle = stdout.lock();
handle.write_all(&out.data).into_diagnostic()?;
handle.flush().into_diagnostic()?;
}
Some(exec_sandbox_event::Payload::Stderr(err)) => {
let mut handle = stderr.lock();
handle.write_all(&err.data).into_diagnostic()?;
handle.flush().into_diagnostic()?;
}
Some(exec_sandbox_event::Payload::Exit(exit)) => {
exit_code = exit.exit_code;
exit_seen = true;
break;
}
None => {}
}
}
// Drop the raw mode guard to restore the terminal before returning.
drop(raw_guard);
if !stdin_reader_done && let Ok(result) = stdin_result_rx.try_recv() {
result.into_diagnostic()?;
}
// A stream that closes without an Exit event means we never observed the
// command's outcome. Treat it as a relay failure rather than reporting a
// successful (0) exit.
if !exit_seen {
return Err(miette::miette!(
"sandbox exec relay closed before the command reported an exit status"
));
}
Ok(exit_code)
}
/// List sandboxes.
#[allow(clippy::too_many_arguments)]
pub async fn sandbox_list(
server: &str,
page_size: i32,
page_token: &str,
ids_only: bool,
names_only: bool,
label_selector: Option<&str>,
output: &str,
workspace: &str,
all_workspaces: bool,
tls: &TlsOptions,
) -> Result<()> {
let mut client = grpc_client(server, tls).await?;
let response = client
.list_sandboxes(ListSandboxesRequest {
page_size,
page_token: page_token.to_string(),
label_selector: label_selector.unwrap_or("").to_string(),
workspace_scope: Some(if all_workspaces {
openshell_core::proto::all_workspaces_selector()
} else {
openshell_core::proto::workspace_selector(workspace)
}),
})
.await
.into_diagnostic()?;
let response = response.into_inner();
let next_page_token = response.next_page_token;
let sandboxes = response.sandboxes;
if crate::output::print_paginated_output_collection(
output,
"sandboxes",
&sandboxes,
&next_page_token,
sandbox_to_json,
)? {
return Ok(());
}
crate::output::print_next_page_token(&next_page_token);
if sandboxes.is_empty() {
if !ids_only && !names_only {
println!("No sandboxes found.");
}
return Ok(());
}
if ids_only {
for sandbox in sandboxes {
println!("{}", sandbox.object_id());
}
return Ok(());
}
if names_only {
for sandbox in &sandboxes {
if all_workspaces {
println!("{}/{}", sandbox.object_workspace(), sandbox.object_name());
} else {
println!("{}", sandbox.object_name());
}
}
return Ok(());
}
// Calculate column widths
let name_width = sandboxes
.iter()
.map(|s| s.object_name().len())
.max()
.unwrap_or(4)
.max(4);
let created_width = 19; // "YYYY-MM-DD HH:MM:SS"
let ws_width = if all_workspaces {
sandboxes
.iter()
.map(|s| s.object_workspace().len())
.max()
.unwrap_or(9)
.max(9)
} else {
0
};
// Print header
if all_workspaces {
println!(
"{:<ws_width$} {:<name_width$} {:<created_width$} {}",
"WORKSPACE".bold(),
"NAME".bold(),
"CREATED".bold(),
"PHASE".bold(),
);
} else {
println!(
"{:<name_width$} {:<created_width$} {}",
"NAME".bold(),
"CREATED".bold(),
"PHASE".bold(),
);
}
// Print rows
for sandbox in sandboxes {
let phase = phase_name(sandbox.phase());
let phase_colored = match SandboxPhase::try_from(sandbox.phase()) {
Ok(SandboxPhase::Ready | SandboxPhase::Completed) => phase.green().to_string(),
Ok(SandboxPhase::Error) => phase.red().to_string(),
Ok(SandboxPhase::Stopped)
if sandbox
.status
.as_ref()
.is_some_and(|status| status.exit_code.is_some()) =>
{
phase.red().to_string()
}
Ok(SandboxPhase::Provisioning) => phase.yellow().to_string(),
Ok(SandboxPhase::Deleting) => phase.dimmed().to_string(),
_ => phase.to_string(),
};
let created = format_epoch_ms(
sandbox
.metadata
.as_ref()
.map_or(0, |m| proto_timestamp_ms(m.created_time.as_ref())),
);
if all_workspaces {
println!(
"{:<ws_width$} {:<name_width$} {:<created_width$} {}",
sandbox.object_workspace().to_string(),
sandbox.object_name().to_string(),
created,
phase_colored,
);
} else {
println!(
"{:<name_width$} {:<created_width$} {}",
sandbox.object_name().to_string(),
created,
phase_colored,
);
}
}
Ok(())
}
fn configuration_failure_message(sandbox: &Sandbox) -> Option<&str> {
sandbox
.status
.as_ref()?
.configuration_admission
.as_ref()
.map(|admission| admission.error.as_str())
.filter(|message| !message.is_empty())
}
fn sandbox_to_json(sandbox: &Sandbox) -> serde_json::Value {
use openshell_core::proto::ConfigurationAdmissionState;
let meta = sandbox.metadata.as_ref();
let labels = meta.map_or_else(|| serde_json::json!({}), |m| serde_json::json!(m.labels));
let annotations = meta.map_or_else(
|| serde_json::json!({}),
|m| serde_json::json!(m.annotations),
);
let created_from_workload_template =
sandbox
.created_from_workload_template
.as_ref()
.map(|provenance| {
serde_json::json!({
"name": provenance.name,
"resource_version": provenance.resource_version,
})
});
let conditions = sandbox.status.as_ref().map_or_else(Vec::new, |status| {
status
.conditions
.iter()
.map(sandbox_condition_to_json)
.collect::<Vec<_>>()
});
let endpoint_statuses = sandbox.status.as_ref().map_or_else(Vec::new, |status| {
status
.endpoint_statuses
.iter()
.map(|endpoint| {
serde_json::json!({
"endpoint_id": endpoint.endpoint_id,
"host": endpoint.host,
"ports": endpoint.ports,
"path": endpoint.path,
"last_result": endpoint_result_name(endpoint.last_result()),
"last_reported_at": endpoint.last_reported_time.as_ref().map(ToString::to_string).unwrap_or_default(),
})
})
.collect::<Vec<_>>()
});
let admission = sandbox
.status
.as_ref()
.and_then(|status| status.configuration_admission.as_ref())
.map(|admission| {
serde_json::json!({
"state": match ConfigurationAdmissionState::try_from(admission.state) {
Ok(ConfigurationAdmissionState::Pending) => "pending",
Ok(ConfigurationAdmissionState::Accepted) => "accepted",
Ok(ConfigurationAdmissionState::Rejected) => "rejected",
_ => "unknown",
},
"error": admission.error,
"policy_version": admission.policy_version,
"policy_hash": admission.policy_hash,
"config_revision": admission.config_revision,
"provider_env_revision": admission.provider_env_revision,
})
});
let provisioning = sandbox.status.as_ref().and_then(|status| status.provisioning.as_ref())
.map(|record| serde_json::json!({
"attempt_id": record.attempt_id,
"configuration_change_id": record.configuration_change_id,
"configuration_change_time": record.configuration_change_time.as_ref().map(ToString::to_string),
"first_rejection_time": record.first_rejection_time.as_ref().map(ToString::to_string),
"deadline": record.deadline.as_ref().map(ToString::to_string),
"timeout_time": record.timeout_time.as_ref().map(ToString::to_string),
"cleanup_completed_time": record.cleanup_completed_time.as_ref().map(ToString::to_string),
"cleanup_error": record.cleanup_error,
"cleanup_retry_time": record.cleanup_retry_time.as_ref().map(ToString::to_string),
}));
serde_json::json!({
"id": sandbox.object_id(),
"name": sandbox.object_name(),
"workspace": sandbox.object_workspace(),
"labels": labels,
"annotations": annotations,
"resource_version": meta.map_or(0, |m| m.resource_version),
"created_at": format_epoch_ms(meta.map_or(0, |m| proto_timestamp_ms(m.created_time.as_ref()))),
"phase": phase_name(sandbox.phase()),
"current_policy_version": sandbox.current_policy_version(),
"exit_code": sandbox.status.as_ref().and_then(|status| status.exit_code),
"conditions": conditions,
"endpoint_statuses": endpoint_statuses,
"configuration_admission": admission,
"provisioning": provisioning,
"created_from_workload_template": created_from_workload_template,
})
}
fn sandbox_condition_to_json(condition: &SandboxCondition) -> serde_json::Value {
let transition_time = condition
.transition_time
.as_ref()
.map(ToString::to_string)
.unwrap_or_default();
serde_json::json!({
"type": condition.r#type,
"status": condition.status,
"reason": condition.reason,
"message": condition.message,
"last_transition_time": transition_time,
})
}
fn sandbox_condition_display_lines(condition: &SandboxCondition) -> Vec<String> {
let reason = if condition.reason.is_empty() {
String::new()
} else {
format!(" ({})", condition.reason)
};
let message = if condition.message.is_empty() {
String::new()
} else {
format!(" - {}", condition.message)
};
let mut lines = vec![format!(
"{}: {}{reason}{message}",
condition.r#type, condition.status
)];
if let Some(transition_time) = &condition.transition_time {
lines.push(format!("Last transition: {transition_time}"));
}
lines
}
// These names are part of the CLI JSON/YAML contract, independent of the
// generated Rust enum's Debug output and protobuf's prefixed wire names.
fn endpoint_result_name(result: EndpointResult) -> &'static str {
match result {
EndpointResult::Unspecified => "Unspecified",
EndpointResult::NoObservedExchange => "NoObservedExchange",
EndpointResult::HttpResponseReceived => "HttpResponseReceived",
EndpointResult::PolicyDenied => "PolicyDenied",
EndpointResult::CredentialUnavailable => "CredentialUnavailable",
EndpointResult::TlsFailed => "TlsFailed",
EndpointResult::TransportFailed => "TransportFailed",
EndpointResult::UpstreamRejected => "UpstreamRejected",
}
}
fn endpoint_status_display_lines(endpoint: &EndpointStatus) -> Vec<String> {
let ports = endpoint
.ports
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(", ");
let result = match endpoint.last_result() {
EndpointResult::Unspecified => "No result provided.",
EndpointResult::NoObservedExchange => "No exchange observed.",
EndpointResult::HttpResponseReceived => "Server returned an HTTP response below 400.",
EndpointResult::PolicyDenied => "Blocked by OpenShell policy.",
EndpointResult::CredentialUnavailable => {
"Required OpenShell-managed credential was unavailable."
}
EndpointResult::TlsFailed => "TLS connection failed.",
EndpointResult::TransportFailed => "Connection failed before an HTTP response arrived.",
EndpointResult::UpstreamRejected => "Server rejected the request (HTTP 400 or higher).",
};
// The gateway supplies acceptance time, which can follow the actual
// exchange. An absent timestamp means there is no accepted observation.
let reported_at = endpoint
.last_reported_time
.as_ref()
.map_or_else(|| "no report yet".to_string(), ToString::to_string);
vec![
format!(
"{} (ports: {ports}; path: {})",
endpoint.host, endpoint.path
),
format!("Last result: {result}"),
format!("Reported at (gateway acceptance): {reported_at}"),
]
}
fn restart_policy_name(policy: i32) -> &'static str {
match SandboxRestartPolicy::try_from(policy) {
Ok(SandboxRestartPolicy::OnFailure) => "on-failure",
Ok(SandboxRestartPolicy::Always) => "always",
Ok(SandboxRestartPolicy::Unspecified | SandboxRestartPolicy::Never) | Err(_) => "never",
}
}
fn sandbox_detail_to_json(
sandbox: &Sandbox,
config: &GetSandboxConfigResponse,
) -> Result<serde_json::Value> {
let mut value = sandbox_to_json(sandbox);
let obj = value
.as_object_mut()
.expect("sandbox_to_json returns object");
let restart_policy = sandbox
.spec
.as_ref()
.map_or("never", |spec| restart_policy_name(spec.restart_policy));
obj.insert("restart_policy".into(), serde_json::json!(restart_policy));
if let Some(status) = sandbox.status.as_ref() {
obj.insert(
"main_process_instance_id".into(),
serde_json::json!(status.main_process_instance_id),
);
obj.insert("exit_code".into(), serde_json::json!(status.exit_code));
obj.insert(
"restart_count".into(),
serde_json::json!(status.restart_count),
);
obj.insert(
"next_restart_at_ms".into(),
serde_json::json!(proto_timestamp_ms(status.next_restart_time.as_ref())),
);
obj.insert(
"main_process_started_at_ms".into(),
serde_json::json!(proto_timestamp_ms(
status.main_process_started_time.as_ref()
)),
);
}
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)
}
#[allow(clippy::too_many_arguments, clippy::implicit_hasher)]
pub async fn sandbox_template_create(
server: &str,
name: &str,
image: Option<&str>,
cpu: Option<&str>,
memory: Option<&str>,
gpu_requirements: Option<GpuResourceRequirements>,
driver_config_json: Option<&str>,
ready_within: Option<&str>,
max_burst: Option<u32>,
labels: HashMap<String, String>,
annotations: HashMap<String, String>,
environment: HashMap<String, String>,
output: &str,
workspace: &str,
tls: &TlsOptions,
suppress_credential_warnings: bool,
) -> Result<()> {
let resources = if cpu.is_some() || memory.is_some() || gpu_requirements.is_some() {
Some(SandboxResources {
cpu: cpu
.map(validate_cpu_quantity)
.transpose()?
.unwrap_or_default(),
memory: memory
.map(validate_memory_quantity)
.transpose()?
.unwrap_or_default(),
gpu: gpu_requirements,
})
} else {
None
};
let driver_config = driver_config_json
.map(parse_driver_config_json)
.transpose()?;
let desired_service_level = build_template_service_level(ready_within, max_burst)?;
let mut client = grpc_client(server, tls).await?;
let profile_catalog = fetch_provider_profile_catalog(&mut client, workspace)
.await
.unwrap_or_default();
warn_credential_env_vars(&environment, &profile_catalog, suppress_credential_warnings);
let response = client
.create_sandbox_template(CreateSandboxTemplateRequest {
request_id: String::new(),
template: Some(SandboxWorkloadTemplate {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: String::new(),
name: name.to_string(),
created_time: openshell_core::time::timestamp_from_millis(0).ok(),
labels,
resource_version: 0,
annotations,
workspace: workspace.to_string(),
deletion_time: None,
}),
spec: Some(SandboxWorkloadTemplateSpec {
workload: Some(SandboxWorkloadConfig {
image: image.unwrap_or_default().to_string(),
environment,
resources,
}),
driver_config,
desired_service_level,
}),
}),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
})
.await
.into_diagnostic()?;
let template = response
.into_inner()
.template
.ok_or_else(|| miette!("sandbox template missing from response"))?;
if crate::output::print_output_single(output, &template, sandbox_template_to_json)? {
return Ok(());
}
println!(
"{} Created sandbox template {}",
"✓".green().bold(),
template.object_name().bold()
);
Ok(())
}
fn build_template_service_level(
ready_within: Option<&str>,
max_burst: Option<u32>,
) -> Result<Option<SandboxServiceLevel>> {
if ready_within.is_none() && max_burst.is_none() {
return Ok(None);
}
let ready_within = ready_within
.map(parse_duration_to_ms)
.transpose()?
.map(|ms| {
if ms <= 0 {
Err(miette!("--ready-within must be greater than zero"))
} else {
Ok(duration_ms_to_proto(ms))
}
})
.transpose()?;
Ok(Some(SandboxServiceLevel {
startup: Some(SandboxStartup {
ready_within,
max_burst: max_burst.unwrap_or_default(),
}),
}))
}
fn duration_ms_to_proto(ms: i64) -> prost_types::Duration {
prost_types::Duration {
seconds: ms / 1_000,
nanos: i32::try_from((ms % 1_000) * 1_000_000)
.expect("duration millisecond remainder fits in protobuf nanos"),
}
}
pub async fn sandbox_template_get(
server: &str,
name: &str,
output: &str,
workspace: &str,
tls: &TlsOptions,
) -> Result<()> {
let mut client = grpc_client(server, tls).await?;
let response = client
.get_sandbox_template(GetSandboxTemplateRequest {
name: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
})
.await
.into_diagnostic()?;
let template = response
.into_inner()
.template
.ok_or_else(|| miette!("sandbox template missing from response"))?;
if crate::output::print_output_single(output, &template, sandbox_template_to_json)? {
return Ok(());
}
print_sandbox_template_detail(&template);
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn sandbox_template_list(
server: &str,
page_size: i32,
page_token: &str,
label_selector: Option<&str>,
names_only: bool,
output: &str,
workspace: &str,
all_workspaces: bool,
tls: &TlsOptions,
) -> Result<()> {
let mut client = grpc_client(server, tls).await?;
let response = client
.list_sandbox_templates(ListSandboxTemplatesRequest {
page_size,
page_token: page_token.to_string(),
workspace_scope: Some(if all_workspaces {
openshell_core::proto::all_workspaces_selector()
} else {
openshell_core::proto::workspace_selector(workspace)
}),
label_selector: label_selector.unwrap_or_default().to_string(),
})
.await
.into_diagnostic()?;
let response = response.into_inner();
let next_page_token = response.next_page_token;
let templates = response.templates;
if crate::output::print_paginated_output_collection(
output,
"templates",
&templates,
&next_page_token,
sandbox_template_to_json,
)? {
return Ok(());
}
crate::output::print_next_page_token(&next_page_token);
if templates.is_empty() {
if !names_only {
println!("No sandbox templates found.");
}
return Ok(());
}
if names_only {
for template in &templates {
if all_workspaces {
println!("{}/{}", template.object_workspace(), template.object_name());
} else {
println!("{}", template.object_name());
}
}
return Ok(());
}
print_sandbox_template_table(&templates, all_workspaces);
Ok(())
}
pub(crate) fn deletion_completed(outcome: i32) -> Result<bool> {
use openshell_core::proto::DeletionOutcome;
match DeletionOutcome::try_from(outcome) {
Ok(DeletionOutcome::Completed) => Ok(true),
Ok(DeletionOutcome::AlreadyAbsent) => Ok(false),
_ => Err(miette!(
"gateway returned an unsupported deletion outcome: {outcome}"
)),
}
}
pub async fn sandbox_template_delete(
server: &str,
names: &[String],
workspace: &str,
tls: &TlsOptions,
) -> Result<()> {
let mut client = grpc_client(server, tls).await?;
for name in names {
let response = client
.delete_sandbox_template(DeleteSandboxTemplateRequest {
request_id: String::new(),
allow_missing: true,
name: name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
})
.await
.into_diagnostic()?;
if deletion_completed(response.into_inner().outcome)? {
println!("{} Deleted sandbox template {name}", "✓".green().bold());
} else {
println!("Sandbox template {name} not found.");
}
}
Ok(())
}
fn sandbox_template_to_json(template: &SandboxWorkloadTemplate) -> serde_json::Value {
let mut obj = serde_json::Map::new();
obj.insert("id".to_string(), serde_json::json!(template.object_id()));
obj.insert(
"name".to_string(),
serde_json::json!(template.object_name()),
);
obj.insert(
"workspace".to_string(),
serde_json::json!(template.object_workspace()),
);
if let Some(metadata) = &template.metadata {
if metadata.resource_version != 0 {
obj.insert(
"resource_version".to_string(),
serde_json::json!(metadata.resource_version),
);
}
if metadata.created_time.is_some() {
obj.insert(
"created_at".to_string(),
serde_json::json!(format_epoch_ms(proto_timestamp_ms(
metadata.created_time.as_ref()
))),
);
}
if !metadata.labels.is_empty() {
obj.insert("labels".to_string(), serde_json::json!(metadata.labels));
}
if !metadata.annotations.is_empty() {
obj.insert(
"annotations".to_string(),
serde_json::json!(metadata.annotations),
);
}
}
if let Some(spec) = &template.spec {
if let Some(workload) = &spec.workload {
obj.insert("image".to_string(), serde_json::json!(workload.image));
if !workload.environment.is_empty() {
obj.insert(
"environment".to_string(),
serde_json::json!(workload.environment),
);
}
if let Some(resources) = &workload.resources {
let mut resources_json = serde_json::Map::new();
if !resources.cpu.is_empty() {
resources_json.insert("cpu".to_string(), serde_json::json!(resources.cpu));
}
if !resources.memory.is_empty() {
resources_json
.insert("memory".to_string(), serde_json::json!(resources.memory));
}
if let Some(gpu) = &resources.gpu {
let value = gpu
.count
.map_or_else(|| serde_json::json!("default"), serde_json::Value::from);
resources_json.insert("gpu".to_string(), value);
}
if !resources_json.is_empty() {
obj.insert(
"resources".to_string(),
serde_json::Value::Object(resources_json),
);
}
}
}
if let Some(driver_config) = &spec.driver_config {
obj.insert(
"driver_config".to_string(),
openshell_core::proto_struct::struct_to_json_value(driver_config),
);
}
if let Some(service_level) = &spec.desired_service_level
&& let Some(startup) = &service_level.startup
{
let mut startup_json = serde_json::Map::new();
if let Some(ready_within) = &startup.ready_within {
startup_json.insert(
"ready_within_ms".to_string(),
serde_json::json!(duration_to_ms(ready_within)),
);
}
if startup.max_burst != 0 {
startup_json.insert(
"max_burst".to_string(),
serde_json::json!(startup.max_burst),
);
}
if !startup_json.is_empty() {
obj.insert(
"startup".to_string(),
serde_json::Value::Object(startup_json),
);
}
}
}
serde_json::Value::Object(obj)
}
fn print_sandbox_template_detail(template: &SandboxWorkloadTemplate) {
println!("{}", "Sandbox template:".cyan().bold());
println!();
println!(" {} {}", "Name:".dimmed(), template.object_name());
println!(
" {} {}",
"Workspace:".dimmed(),
template.object_workspace()
);
if let Some(metadata) = &template.metadata {
println!(" {} {}", "Id:".dimmed(), metadata.id);
println!(
" {} {}",
"Resource version:".dimmed(),
metadata.resource_version
);
if metadata.created_time.is_some() {
println!(
" {} {}",
"Created:".dimmed(),
format_epoch_ms(proto_timestamp_ms(metadata.created_time.as_ref()))
);
}
let labels = labels_display(&metadata.labels);
println!(
" {} {}",
"Labels:".dimmed(),
non_empty_or(&labels, "<none>")
);
}
if let Some(spec) = &template.spec
&& let Some(workload) = &spec.workload
{
println!(
" {} {}",
"Image:".dimmed(),
non_empty_or(&workload.image, "<default>")
);
println!(
" {} {}",
"Environment:".dimmed(),
workload.environment.len()
);
if let Some(resources) = &workload.resources {
println!(
" {} {}",
"CPU:".dimmed(),
non_empty_or(&resources.cpu, "<default>")
);
println!(
" {} {}",
"Memory:".dimmed(),
non_empty_or(&resources.memory, "<default>")
);
println!(
" {} {}",
"GPU:".dimmed(),
template_resources_gpu_display(resources).unwrap_or_else(|| "<none>".to_string())
);
}
}
if let Some(startup) = template_startup(template) {
println!(
" {} {}",
"Ready within:".dimmed(),
startup
.ready_within
.as_ref()
.map_or_else(|| "<default>".to_string(), duration_display)
);
println!(
" {} {}",
"Max burst:".dimmed(),
if startup.max_burst == 0 {
"<default>".to_string()
} else {
startup.max_burst.to_string()
}
);
}
}
fn print_sandbox_template_table(templates: &[SandboxWorkloadTemplate], show_workspace: bool) {
let name_width = templates
.iter()
.map(|template| template.object_name().len())
.max()
.unwrap_or(4)
.max(4);
let workspace_width = if show_workspace {
templates
.iter()
.map(|template| template.object_workspace().len())
.max()
.unwrap_or(9)
.max(9)
} else {
0
};
let image_width = templates
.iter()
.map(|template| template_image(template).len())
.max()
.unwrap_or(5)
.clamp(5, 48);
if show_workspace {
println!(
"{:<workspace_width$} {:<name_width$} {:<image_width$} {:<10} {:<10} {:<5} {:<12} {:<5} {}",
"WORKSPACE".bold(),
"NAME".bold(),
"IMAGE".bold(),
"CPU".bold(),
"MEMORY".bold(),
"GPU".bold(),
"READY".bold(),
"BURST".bold(),
"LABELS".bold(),
);
} else {
println!(
"{:<name_width$} {:<image_width$} {:<10} {:<10} {:<5} {:<12} {:<5} {}",
"NAME".bold(),
"IMAGE".bold(),
"CPU".bold(),
"MEMORY".bold(),
"GPU".bold(),
"READY".bold(),
"BURST".bold(),
"LABELS".bold(),
);
}
for template in templates {
let resources = template_resources(template);
let cpu = resources
.map(|resources| resources.cpu.as_str())
.filter(|cpu| !cpu.is_empty())
.unwrap_or("-");
let memory = resources
.map(|resources| resources.memory.as_str())
.filter(|memory| !memory.is_empty())
.unwrap_or("-");
let gpu = resources
.and_then(template_resources_gpu_display)
.unwrap_or_else(|| "-".to_string());
let image = truncate_status_field(&template_image(template), image_width);
let startup = template_startup(template);
let ready = startup
.and_then(|startup| startup.ready_within.as_ref())
.map_or_else(|| "-".to_string(), duration_display);
let burst = startup
.map(|startup| startup.max_burst)
.filter(|burst| *burst != 0)
.map_or_else(|| "-".to_string(), |burst| burst.to_string());
let labels = template
.metadata
.as_ref()
.map_or_else(String::new, |metadata| labels_display(&metadata.labels));
if show_workspace {
println!(
"{:<workspace_width$} {:<name_width$} {:<image_width$} {:<10} {:<10} {:<5} {:<12} {:<5} {}",
template.object_workspace(),
template.object_name(),
image,
cpu,
memory,
gpu,
ready,
burst,
labels,
);
} else {
println!(
"{:<name_width$} {:<image_width$} {:<10} {:<10} {:<5} {:<12} {:<5} {}",
template.object_name(),
image,
cpu,
memory,
gpu,
ready,
burst,
labels,
);
}
}
}
fn template_image(template: &SandboxWorkloadTemplate) -> String {
template
.spec
.as_ref()
.and_then(|spec| spec.workload.as_ref())
.map_or_else(
|| "<default>".to_string(),
|workload| non_empty_or(&workload.image, "<default>").to_string(),
)
}
fn template_resources(template: &SandboxWorkloadTemplate) -> Option<&SandboxResources> {
template
.spec
.as_ref()
.and_then(|spec| spec.workload.as_ref())
.and_then(|workload| workload.resources.as_ref())
}
fn template_resources_gpu_display(resources: &SandboxResources) -> Option<String> {
if let Some(gpu) = &resources.gpu {
return Some(
gpu.count
.map_or_else(|| "default".to_string(), |count| count.to_string()),
);
}
None
}
fn template_startup(template: &SandboxWorkloadTemplate) -> Option<&SandboxStartup> {
template
.spec
.as_ref()
.and_then(|spec| spec.desired_service_level.as_ref())
.and_then(|service_level| service_level.startup.as_ref())
}
fn duration_to_ms(duration: &prost_types::Duration) -> i64 {
duration.seconds.saturating_mul(1_000) + i64::from(duration.nanos / 1_000_000)
}
fn duration_display(duration: &prost_types::Duration) -> String {
let total_ms = duration_to_ms(duration);
if total_ms % 3_600_000 == 0 {
format!("{}h", total_ms / 3_600_000)
} else if total_ms % 60_000 == 0 {
format!("{}m", total_ms / 60_000)
} else if total_ms % 1_000 == 0 {
format!("{}s", total_ms / 1_000)
} else {
format!("{total_ms}ms")
}
}
fn labels_display(labels: &HashMap<String, String>) -> String {
let mut pairs = labels
.iter()
.map(|(key, value)| format!("{key}={value}"))
.collect::<Vec<_>>();
pairs.sort();
pairs.join(", ")
}
/// Controls whether sandbox deletion waits for durable absence.
#[derive(Debug, Clone, Copy)]
pub struct SandboxDeleteOptions {
/// Wait for terminal absence after deletion is acknowledged.
pub wait: bool,
/// Override the per-sandbox lifecycle timeout. The environment/default applies when unset.
pub timeout: Option<Duration>,
}
impl Default for SandboxDeleteOptions {
fn default() -> Self {
Self {
wait: true,
timeout: None,
}
}
}
/// Delete a sandbox by name, or all sandboxes when `all` is true.
pub async fn sandbox_delete(
server: &str,
names: &[String],
all: bool,
workspace: &str,
tls: &TlsOptions,
gateway: &str,
options: SandboxDeleteOptions,
) -> Result<()> {
let mut client = grpc_client(server, tls).await?;
let names_to_delete: Vec<String> = if all {
let mut page_token = String::new();
let mut sandboxes = Vec::new();
loop {
let response = client
.list_sandboxes(ListSandboxesRequest {
page_size: 1000,
page_token,
label_selector: String::new(),
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
})
.await
.into_diagnostic()?
.into_inner();
sandboxes.extend(response.sandboxes);
if response.next_page_token.is_empty() {
break;
}
page_token = response.next_page_token;
}
if sandboxes.is_empty() {
println!("No sandboxes to delete.");
return Ok(());
}
sandboxes
.into_iter()
.map(|s| s.object_name().to_string())
.collect()
} else {
names.to_vec()
};
let mut failures = Vec::new();
for name in &names_to_delete {
// Stop any background port forwards for this sandbox before deleting.
if let Ok(stopped) = stop_forwards_for_sandbox(workspace, name) {
for port in stopped {
eprintln!(
"{} Stopped forward of port {port} for sandbox {name}",
"✓".green().bold(),
);
}
}
let response = match client
.delete_sandbox(DeleteSandboxRequest {
request_id: String::new(),
allow_missing: true,
name: name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
})
.await
{
Ok(response) => response,
Err(status) => {
eprintln!(
"{} Failed to delete sandbox {name}: {status}",
"!".red().bold()
);
failures.push(name.clone());
continue;
}
};
let deletion = response.into_inner();
if deletion.outcome() != DeletionOutcome::Unspecified {
clear_last_sandbox_if_matches(gateway, workspace, name);
}
match deletion.outcome() {
DeletionOutcome::Completed => println!("{} Deleted sandbox {name}", "✓".green().bold()),
DeletionOutcome::Accepted if !options.wait => println!(
"{} Sandbox {name} deletion accepted; cleanup is pending",
"✓".green().bold()
),
DeletionOutcome::Accepted => {
if let Err(err) = wait_for_sandbox_deleted(
&mut client,
name,
workspace,
&deletion.sandbox_id,
options.timeout,
)
.await
{
eprintln!(
"{} Failed to verify sandbox {name} deletion: {err}",
"!".red().bold()
);
failures.push(format!("{name}: {err}"));
continue;
}
println!("{} Deleted sandbox {name}", "✓".green().bold());
}
DeletionOutcome::AlreadyAbsent => {
println!("{} Sandbox {name} already deleted", "✓".green().bold());
}
DeletionOutcome::Unspecified => {
eprintln!(
"{} Unsupported deletion outcome for sandbox {name}",
"!".red().bold()
);
failures.push(name.clone());
}
}
}
aggregate_delete_failures("sandbox", &failures)
}
fn lifecycle_timeout(explicit: Option<Duration>) -> Duration {
explicit.unwrap_or_else(|| {
Duration::from_secs(
std::env::var("OPENSHELL_LIFECYCLE_TIMEOUT")
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(300),
)
})
}
async fn wait_for_sandbox_deleted(
client: &mut crate::tls::GrpcClient,
name: &str,
workspace: &str,
expected_sandbox_id: &str,
timeout_override: Option<Duration>,
) -> Result<()> {
const INITIAL_POLL_INTERVAL: Duration = Duration::from_millis(100);
const MAX_POLL_INTERVAL: Duration = Duration::from_secs(2);
let timeout = lifecycle_timeout(timeout_override);
let deadline = Instant::now()
.checked_add(timeout)
.ok_or_else(|| miette!("sandbox deletion timeout is too large"))?;
let timeout_error = || {
miette!(
"timed out after {}s waiting for sandbox {name} deletion to complete; deletion continues asynchronously",
timeout.as_secs()
)
};
let mut poll_interval = INITIAL_POLL_INTERVAL;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(timeout_error());
}
match tokio::time::timeout(
remaining,
client.get_sandbox(GetSandboxRequest {
name: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
}),
)
.await
{
Ok(Ok(response))
if response.get_ref().sandbox.as_ref().is_some_and(|sandbox| {
!expected_sandbox_id.is_empty() && sandbox.object_id() != expected_sandbox_id
}) =>
{
return Ok(());
}
Ok(Ok(_)) => {}
Ok(Err(status)) if status.code() == Code::NotFound => return Ok(()),
Ok(Err(status)) => {
return Err(miette!(
"failed to verify deletion of sandbox {name}: {status}"
));
}
Err(_) => return Err(timeout_error()),
}
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(timeout_error());
}
tokio::time::sleep(poll_interval.min(remaining)).await;
poll_interval = poll_interval.saturating_mul(2).min(MAX_POLL_INTERVAL);
}
}
/// Stop a sandbox while retaining its persistent workspace.
pub async fn sandbox_stop(
server: &str,
name: &str,
workspace: &str,
tls: &TlsOptions,
) -> Result<()> {
if let Ok(stopped) = stop_forwards_for_sandbox(workspace, name) {
for port in stopped {
eprintln!(
"{} Stopped forward of port {port} for sandbox {name}",
"✓".green().bold(),
);
}
}
let mut client = grpc_client(server, tls).await?;
let sandbox = client
.stop_sandbox(StopSandboxRequest {
request_id: String::new(),
name: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
})
.await
.into_diagnostic()?
.into_inner()
.sandbox
.ok_or_else(|| miette!("gateway returned no sandbox after stop"))?;
wait_for_lifecycle_phase(&mut client, sandbox, SandboxPhase::Stopped).await?;
println!("{} Stopped sandbox {name}", "✓".green().bold());
Ok(())
}
/// Start a stopped sandbox and wait until it is ready.
pub async fn sandbox_start(
server: &str,
name: &str,
workspace: &str,
tls: &TlsOptions,
) -> Result<()> {
let mut client = grpc_client(server, tls).await?;
let sandbox = client
.start_sandbox(StartSandboxRequest {
request_id: String::new(),
name: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
})
.await
.into_diagnostic()?
.into_inner()
.sandbox
.ok_or_else(|| miette!("gateway returned no sandbox after start"))?;
wait_for_lifecycle_phase(&mut client, sandbox, SandboxPhase::Ready).await?;
println!("{} Started sandbox {name}", "✓".green().bold());
Ok(())
}
async fn wait_for_lifecycle_phase(
client: &mut crate::tls::GrpcClient,
sandbox: Sandbox,
target: SandboxPhase,
) -> Result<Sandbox> {
let current = SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown);
if current == target {
return Ok(sandbox);
}
if current == SandboxPhase::Error {
let detail = ready_false_condition_message(sandbox.status.as_ref())
.unwrap_or_else(|| "sandbox entered Error".to_string());
return Err(miette!("{detail} while waiting for {target:?}"));
}
let timeout = Duration::from_secs(
std::env::var("OPENSHELL_LIFECYCLE_TIMEOUT")
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(300),
);
let sandbox_name = sandbox.object_name().to_string();
let workspace = sandbox.object_workspace().to_string();
let mut stream = client
.watch_sandbox(WatchSandboxRequest {
sandbox: sandbox_name,
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace.clone())),
follow_status: true,
follow_logs: false,
follow_events: false,
log_tail_lines: 0,
event_tail: 0,
stop_on_terminal: false,
since_time: None,
log_sources: Vec::new(),
log_min_level: String::new(),
resume_after_cursor: String::new(),
})
.await
.into_diagnostic()?
.into_inner();
let deadline = Instant::now() + timeout;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(miette!(
"timed out after {}s waiting for sandbox to reach {target:?}",
timeout.as_secs()
));
}
let event = tokio::time::timeout(remaining, stream.next())
.await
.map_err(|_| {
miette!(
"timed out after {}s waiting for sandbox to reach {target:?}",
timeout.as_secs()
)
})?
.ok_or_else(|| miette!("sandbox watch ended before reaching {target:?}"))?
.into_diagnostic()?;
if let Some(openshell_core::proto::sandbox_stream_event::Payload::Sandbox(sandbox)) =
event.payload
{
let phase = SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown);
if phase == target {
return Ok(sandbox);
}
if phase == SandboxPhase::Error {
let detail = ready_false_condition_message(sandbox.status.as_ref())
.unwrap_or_else(|| "sandbox entered Error".to_string());
return Err(miette!(detail));
}
}
}
}
pub async fn service_expose(
server: &str,
sandbox: &str,
service: &str,
target_port: u16,
authorization_mode: ServiceAuthorizationMode,
workspace: &str,
tls: &TlsOptions,
) -> Result<()> {
let response = expose_service_endpoint(
server,
sandbox,
service,
target_port,
authorization_mode,
workspace,
tls,
)
.await?;
if service.is_empty() {
println!(
"{} Exposed sandbox {} -> 127.0.0.1:{}",
"✓".green().bold(),
sandbox.bold(),
target_port,
);
} else {
println!(
"{} Exposed service {} on sandbox {} -> 127.0.0.1:{}",
"✓".green().bold(),
service.bold(),
sandbox.bold(),
target_port,
);
}
if !response.url.is_empty() {
let url = service_url_for_gateway(&response.url, server);
println!(" URL: {}", url.cyan());
}
Ok(())
}
async fn expose_service_endpoint(
server: &str,
sandbox: &str,
service: &str,
target_port: u16,
authorization_mode: ServiceAuthorizationMode,
workspace: &str,
tls: &TlsOptions,
) -> Result<ServiceEndpointResponse> {
let mut client = grpc_client(server, tls).await?;
client
.expose_service(ExposeServiceRequest {
request_id: String::new(),
sandbox: sandbox.to_string(),
name: service.to_string(),
target_port: u32::from(target_port),
domain: true,
authorization_mode: authorization_mode as i32,
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
})
.await
.map_err(service_expose_status_error)
.map(tonic::Response::into_inner)
}
fn service_expose_status_error(status: Status) -> miette::Report {
service_status_error("expose service", "sandbox:write", status)
}
#[allow(clippy::too_many_arguments)] // user-facing CLI command
pub async fn service_list(
server: &str,
sandbox: Option<&str>,
page_size: i32,
page_token: &str,
workspace: &str,
all_workspaces: bool,
output: &str,
tls: &TlsOptions,
) -> Result<()> {
let mut client = grpc_client(server, tls).await?;
let response = client
.list_services(ListServicesRequest {
sandbox: sandbox.unwrap_or_default().to_string(),
page_size,
page_token: page_token.to_string(),
workspace_scope: Some(if all_workspaces {
openshell_core::proto::all_workspaces_selector()
} else {
openshell_core::proto::workspace_selector(workspace)
}),
})
.await
.map_err(|status| service_status_error("list services", "sandbox:read", status))?
.into_inner();
let next_page_token = response.next_page_token.clone();
let services = response
.services
.iter()
.filter_map(|response| service_endpoint_to_json(response, server))
.collect::<Vec<_>>();
if crate::output::print_paginated_output_collection(
output,
"services",
&services,
&next_page_token,
Clone::clone,
)? {
return Ok(());
}
crate::output::print_next_page_token(&next_page_token);
if response.services.is_empty() {
if let Some(sandbox) = sandbox {
println!("No services exposed for sandbox {sandbox}.");
} else {
println!("No services exposed.");
}
return Ok(());
}
print_service_endpoint_table(&response.services, server, all_workspaces);
Ok(())
}
pub async fn service_get(
server: &str,
sandbox: &str,
service: &str,
workspace: &str,
tls: &TlsOptions,
) -> Result<()> {
let mut client = grpc_client(server, tls).await?;
let response = client
.get_service(GetServiceRequest {
sandbox: (sandbox).to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
name: service.to_string(),
})
.await
.map_err(|status| service_status_error("get service", "sandbox:read", status))?
.into_inner();
print_service_endpoint_table(&[response], server, false);
Ok(())
}
pub async fn service_delete(
server: &str,
sandbox: &str,
service: &str,
workspace: &str,
tls: &TlsOptions,
) -> Result<()> {
let mut client = grpc_client(server, tls).await?;
let response = client
.delete_service(DeleteServiceRequest {
request_id: String::new(),
allow_missing: false,
sandbox: sandbox.to_string(),
name: service.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
})
.await
.map_err(|status| service_status_error("delete service", "sandbox:write", status))?
.into_inner();
if !deletion_completed(response.outcome)? {
return Err(miette!("delete service failed: service endpoint not found"));
}
if service.is_empty() {
println!(
"{} Deleted exposed sandbox {}",
"✓".green().bold(),
sandbox.bold(),
);
} else {
println!(
"{} Deleted service {} on sandbox {}",
"✓".green().bold(),
service.bold(),
sandbox.bold(),
);
}
Ok(())
}
fn service_status_error(action: &str, required_scope: &str, status: Status) -> miette::Report {
let message = status.message();
match status.code() {
Code::PermissionDenied => {
miette!("{action} failed: permission denied (requires {required_scope})")
}
Code::Unauthenticated => miette!("{action} failed: authentication required"),
Code::NotFound if message == "sandbox not found" => {
miette!("{action} failed: sandbox not found")
}
Code::NotFound if message == "service endpoint not found" => {
miette!("{action} failed: service endpoint not found")
}
Code::InvalidArgument if !message.is_empty() => {
miette!("{action} failed: invalid request: {message}")
}
_ => miette!("{action} failed: {status}"),
}
}
fn print_service_endpoint_table(
services: &[ServiceEndpointResponse],
gateway_endpoint: &str,
all_workspaces: bool,
) {
let rows = services
.iter()
.filter_map(|response| {
let endpoint = response.endpoint.as_ref()?;
let workspace = endpoint
.metadata
.as_ref()
.map_or("", |m| m.workspace.as_str());
let service = service_display_name(&endpoint.name).to_string();
let target = format!("127.0.0.1:{}", endpoint.target_port);
let authorization = service_authorization_mode_name(endpoint.authorization_mode);
let url = if response.url.is_empty() {
String::new()
} else {
service_url_for_gateway(&response.url, gateway_endpoint)
};
Some((
workspace.to_string(),
endpoint.sandbox.clone(),
service,
target,
authorization,
url,
))
})
.collect::<Vec<_>>();
if rows.is_empty() {
return;
}
let ws_width = if all_workspaces {
rows.iter()
.map(|(ws, _, _, _, _, _)| ws.len())
.max()
.unwrap_or(9)
.max(9)
} else {
0
};
let sandbox_width = rows
.iter()
.map(|(_, sandbox, _, _, _, _)| sandbox.len())
.max()
.unwrap_or(7)
.max(7);
let service_width = rows
.iter()
.map(|(_, _, service, _, _, _)| service.len())
.max()
.unwrap_or(7)
.max(7);
let target_width = rows
.iter()
.map(|(_, _, _, target, _, _)| target.len())
.max()
.unwrap_or(6)
.max(6);
if all_workspaces {
println!(
"{:<ws_width$} {:<sandbox_width$} {:<service_width$} {:<target_width$} {:<20} {}",
"WORKSPACE".bold(),
"SANDBOX".bold(),
"SERVICE".bold(),
"TARGET".bold(),
"AUTHORIZATION".bold(),
"URL".bold(),
);
} else {
println!(
"{:<sandbox_width$} {:<service_width$} {:<target_width$} {:<20} {}",
"SANDBOX".bold(),
"SERVICE".bold(),
"TARGET".bold(),
"AUTHORIZATION".bold(),
"URL".bold(),
);
}
for (workspace, sandbox, service, target, authorization, url) in rows {
if all_workspaces {
println!(
"{workspace:<ws_width$} {sandbox:<sandbox_width$} {service:<service_width$} {target:<target_width$} {authorization:<20} {url}"
);
} else {
println!(
"{sandbox:<sandbox_width$} {service:<service_width$} {target:<target_width$} {authorization:<20} {url}"
);
}
}
}
fn service_endpoint_to_json(
response: &ServiceEndpointResponse,
gateway_endpoint: &str,
) -> Option<serde_json::Value> {
let endpoint = response.endpoint.as_ref()?;
let workspace = endpoint
.metadata
.as_ref()
.map_or("", |metadata| metadata.workspace.as_str());
let url = if response.url.is_empty() {
String::new()
} else {
service_url_for_gateway(&response.url, gateway_endpoint)
};
Some(serde_json::json!({
"workspace": workspace,
"sandbox": endpoint.sandbox,
"service": endpoint.name,
"target_port": endpoint.target_port,
"authorization_mode": service_authorization_mode_name(endpoint.authorization_mode),
"url": url,
}))
}
fn service_display_name(service: &str) -> &str {
if service.is_empty() { "-" } else { service }
}
fn service_authorization_mode_name(mode: i32) -> &'static str {
match ServiceAuthorizationMode::try_from(mode).unwrap_or(ServiceAuthorizationMode::Strip) {
ServiceAuthorizationMode::BearerPassthrough => "bearer_passthrough",
ServiceAuthorizationMode::Unspecified | ServiceAuthorizationMode::Strip => "strip",
}
}
/// Read gcloud Application Default Credentials from disk.
///
/// Returns `(client_id, client_secret, refresh_token)`.
///
/// Checks `GOOGLE_APPLICATION_CREDENTIALS` first; falls back to
/// `$CLOUDSDK_CONFIG/application_default_credentials.json` when set, then to
/// `~/.config/gcloud/application_default_credentials.json`.
fn service_url_for_gateway(service_url: &str, gateway_endpoint: &str) -> String {
let (Ok(mut service_url), Ok(gateway_endpoint)) = (
url::Url::parse(service_url),
url::Url::parse(gateway_endpoint),
) else {
return service_url.to_string();
};
if service_url
.set_port(gateway_endpoint.port_or_known_default())
.is_err()
{
return service_url.to_string();
}
service_url.to_string()
}
// ---------------------------------------------------------------------------
// Workspace commands
// ---------------------------------------------------------------------------
pub async fn workspace_create(
server: &str,
name: &str,
label_args: &[String],
tls: &TlsOptions,
) -> Result<()> {
use openshell_core::proto::CreateWorkspaceRequest;
let labels = label_args
.iter()
.filter_map(|arg| {
let (k, v) = arg.split_once('=')?;
Some((k.to_string(), v.to_string()))
})
.collect::<HashMap<String, String>>();
let mut client = grpc_client(server, tls).await?;
let response = client
.create_workspace(CreateWorkspaceRequest {
request_id: String::new(),
name: name.to_string(),
labels,
})
.await
.into_diagnostic()?;
let workspace = response
.into_inner()
.workspace
.ok_or_else(|| miette!("workspace missing from response"))?;
println!(
"{} Created workspace {}",
"✓".green().bold(),
workspace.object_name().bold()
);
Ok(())
}
pub async fn workspace_get(server: &str, name: &str, tls: &TlsOptions) -> Result<()> {
use openshell_core::proto::GetWorkspaceRequest;
let mut client = grpc_client(server, tls).await?;
let response = client
.get_workspace(GetWorkspaceRequest {
name: name.to_string(),
})
.await
.into_diagnostic()?;
let workspace = response
.into_inner()
.workspace
.ok_or_else(|| miette!("workspace missing from response"))?;
println!("{}", "Workspace:".cyan().bold());
println!();
println!(" {} {}", "Name:".dimmed(), workspace.object_name());
if let Some(meta) = &workspace.metadata {
println!(" {} {}", "Id:".dimmed(), meta.id);
println!(
" {} {}",
"Resource version:".dimmed(),
meta.resource_version
);
if meta.created_time.is_some() {
println!(
" {} {}",
"Created:".dimmed(),
format_epoch_ms(proto_timestamp_ms(meta.created_time.as_ref()))
);
}
if !meta.labels.is_empty() {
println!(
" {} {}",
"Labels:".dimmed(),
meta.labels
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join(", ")
);
}
}
Ok(())
}
pub async fn workspace_list(
server: &str,
page_size: i32,
page_token: &str,
label_selector: &str,
output: &str,
tls: &TlsOptions,
) -> Result<()> {
use openshell_core::proto::ListWorkspacesRequest;
let mut client = grpc_client(server, tls).await?;
let response = client
.list_workspaces(ListWorkspacesRequest {
page_size,
page_token: page_token.to_string(),
label_selector: label_selector.to_string(),
})
.await
.into_diagnostic()?;
let response = response.into_inner();
let next_page_token = response.next_page_token;
let workspaces = response.workspaces;
if crate::output::print_paginated_output_collection(
output,
"workspaces",
&workspaces,
&next_page_token,
workspace_to_json,
)? {
return Ok(());
}
crate::output::print_next_page_token(&next_page_token);
if workspaces.is_empty() {
println!("No workspaces found.");
return Ok(());
}
let name_width = workspaces
.iter()
.map(|w| w.object_name().len())
.max()
.unwrap_or(4)
.max(4);
println!(
"{:<name_width$} {:<12} {:<20} {}",
"NAME".bold(),
"STATUS".bold(),
"CREATED".bold(),
"LABELS".bold(),
);
for workspace in &workspaces {
let status = workspace_phase_display(workspace);
let created = workspace.metadata.as_ref().map_or_else(String::new, |m| {
format_epoch_ms(proto_timestamp_ms(m.created_time.as_ref()))
});
let labels = workspace.metadata.as_ref().map_or_else(String::new, |m| {
m.labels
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join(", ")
});
println!(
"{:<name_width$} {:<12} {:<20} {}",
workspace.object_name(),
status,
created,
labels,
);
}
Ok(())
}
pub async fn workspace_delete(server: &str, names: &[String], tls: &TlsOptions) -> Result<()> {
use openshell_core::proto::DeleteWorkspaceRequest;
let mut client = grpc_client(server, tls).await?;
for name in names {
let response = client
.delete_workspace(DeleteWorkspaceRequest {
request_id: String::new(),
allow_missing: false,
name: name.clone(),
})
.await
.into_diagnostic()?;
if deletion_completed(response.into_inner().outcome)? {
println!("{} Deleted workspace {name}", "✓".green().bold());
} else {
println!("{} Workspace {name} not found", "!".yellow());
}
}
Ok(())
}
pub async fn workspace_member_add(
server: &str,
workspace: &str,
subject: &str,
role: &str,
tls: &TlsOptions,
) -> Result<()> {
use openshell_core::proto::{AddWorkspaceMemberRequest, WorkspaceRole};
let role_val = match role.to_lowercase().as_str() {
"user" => WorkspaceRole::User,
"admin" => WorkspaceRole::Admin,
_ => {
return Err(miette!(
"invalid role '{}': must be 'user' or 'admin'",
role
));
}
};
let mut client = grpc_client(server, tls).await?;
let response = client
.add_workspace_member(AddWorkspaceMemberRequest {
request_id: String::new(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
principal_subject: subject.to_string(),
role: role_val.into(),
})
.await
.into_diagnostic()?;
let member = response
.into_inner()
.member
.ok_or_else(|| miette!("member missing from response"))?;
println!(
"{} Added {} to workspace {} as {}",
"✓".green().bold(),
member.principal_subject.bold(),
workspace.bold(),
role,
);
Ok(())
}
pub async fn workspace_member_remove(
server: &str,
workspace: &str,
subject: &str,
tls: &TlsOptions,
) -> Result<()> {
use openshell_core::proto::RemoveWorkspaceMemberRequest;
let mut client = grpc_client(server, tls).await?;
let response = client
.remove_workspace_member(RemoveWorkspaceMemberRequest {
request_id: String::new(),
allow_missing: true,
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
principal_subject: subject.to_string(),
})
.await
.into_diagnostic()?;
if deletion_completed(response.into_inner().outcome)? {
println!(
"{} Removed {} from workspace {}",
"✓".green().bold(),
subject.bold(),
workspace.bold(),
);
} else {
println!(
"{} Member {} not found in workspace {}",
"!".yellow(),
subject,
workspace,
);
}
Ok(())
}
pub async fn workspace_member_list(
server: &str,
workspace: &str,
page_size: i32,
page_token: &str,
output: &str,
tls: &TlsOptions,
) -> Result<()> {
use openshell_core::proto::ListWorkspaceMembersRequest;
let mut client = grpc_client(server, tls).await?;
let response = client
.list_workspace_members(ListWorkspaceMembersRequest {
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
page_size,
page_token: page_token.to_string(),
})
.await
.into_diagnostic()?;
let response = response.into_inner();
let next_page_token = response.next_page_token;
let members = response.members;
if crate::output::print_paginated_output_collection(
output,
"members",
&members,
&next_page_token,
workspace_member_to_json,
)? {
return Ok(());
}
crate::output::print_next_page_token(&next_page_token);
if members.is_empty() {
println!("No members found in workspace {workspace}.");
return Ok(());
}
let subject_width = members
.iter()
.map(|m| m.principal_subject.len())
.max()
.unwrap_or(7)
.max(7);
println!("{:<subject_width$} {}", "SUBJECT".bold(), "ROLE".bold());
for member in &members {
let role_str = workspace_member_role_name(member.role);
println!("{:<subject_width$} {}", member.principal_subject, role_str);
}
Ok(())
}
fn workspace_member_role_name(role: i32) -> &'static str {
use openshell_core::proto::WorkspaceRole;
match WorkspaceRole::try_from(role) {
Ok(WorkspaceRole::Admin) => "admin",
Ok(WorkspaceRole::User) => "user",
_ => "unknown",
}
}
fn workspace_member_to_json(member: &openshell_core::proto::WorkspaceMember) -> serde_json::Value {
serde_json::json!({
"subject": member.principal_subject,
"role": workspace_member_role_name(member.role),
})
}
fn workspace_phase_str(workspace: &openshell_core::proto::Workspace) -> &'static str {
use openshell_core::proto::datamodel::v1::WorkspacePhase;
let phase = workspace
.status
.as_ref()
.and_then(|s| WorkspacePhase::try_from(s.phase).ok())
.unwrap_or(WorkspacePhase::Active);
match phase {
WorkspacePhase::Terminating => "Terminating",
_ => "Active",
}
}
fn workspace_phase_display(workspace: &openshell_core::proto::Workspace) -> String {
let s = workspace_phase_str(workspace);
if s == "Terminating" {
s.yellow().to_string()
} else {
s.to_string()
}
}
fn workspace_to_json(workspace: &openshell_core::proto::Workspace) -> serde_json::Value {
let mut obj = serde_json::Map::new();
if let Some(meta) = &workspace.metadata {
obj.insert("name".to_string(), serde_json::json!(meta.name));
obj.insert("id".to_string(), serde_json::json!(meta.id));
obj.insert(
"resource_version".to_string(),
serde_json::json!(meta.resource_version),
);
if meta.created_time.is_some() {
obj.insert(
"created_at".to_string(),
serde_json::json!(format_epoch_ms(proto_timestamp_ms(
meta.created_time.as_ref()
))),
);
}
if !meta.labels.is_empty() {
obj.insert("labels".to_string(), serde_json::json!(meta.labels));
}
}
obj.insert(
"status".to_string(),
serde_json::json!(workspace_phase_str(workspace)),
);
serde_json::Value::Object(obj)
}
pub fn git_repo_root(local_path: &Path) -> Result<PathBuf> {
let git_dir = if local_path.is_dir() {
local_path
} else {
local_path
.parent()
.ok_or_else(|| miette::miette!("path has no parent: {}", local_path.display()))?
};
let mut command = Command::new("git");
scrub_git_env(&mut command);
let output = command
.args(["rev-parse", "--show-toplevel"])
.current_dir(git_dir)
.output()
.into_diagnostic()
.wrap_err("failed to run git rev-parse")?;
if !output.status.success() {
return Err(miette::miette!(
"git rev-parse --show-toplevel failed with status {}",
output.status
));
}
let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
if root.is_empty() {
return Err(miette::miette!(
"git rev-parse returned empty repository root"
));
}
Ok(PathBuf::from(root))
}
pub fn git_sync_files(local_path: &Path) -> Result<(PathBuf, Vec<String>)> {
let repo_root = std::fs::canonicalize(git_repo_root(local_path)?)
.into_diagnostic()
.wrap_err("failed to canonicalize git repository root")?;
let local_path = if local_path.is_absolute() {
local_path.to_path_buf()
} else {
std::env::current_dir()
.into_diagnostic()
.wrap_err("failed to resolve current directory")?
.join(local_path)
};
let local_path = std::fs::canonicalize(local_path)
.into_diagnostic()
.wrap_err("failed to canonicalize local upload path")?;
let relative_path = local_path
.strip_prefix(&repo_root)
.into_diagnostic()
.wrap_err_with(|| {
format!(
"local path '{}' is not inside git repository '{}'",
local_path.display(),
repo_root.display()
)
})?;
let is_file = local_path.is_file();
let base_dir = if is_file {
local_path
.parent()
.map(Path::to_path_buf)
.ok_or_else(|| miette::miette!("path has no parent: {}", local_path.display()))?
} else {
local_path.clone()
};
let pathspec = if relative_path.as_os_str().is_empty() {
None
} else {
Some(relative_path.to_string_lossy().into_owned())
};
let mut command = Command::new("git");
scrub_git_env(&mut command);
let output = command
.args(["ls-files", "-co", "--exclude-standard", "-z"])
.args(pathspec.as_deref())
.current_dir(&repo_root)
.output()
.into_diagnostic()
.wrap_err("failed to run git ls-files")?;
if !output.status.success() {
return Err(miette::miette!(
"git ls-files failed with status {}",
output.status
));
}
let mut files = Vec::new();
for entry in output.stdout.split(|byte| *byte == 0) {
if entry.is_empty() {
continue;
}
let repo_relative = Path::new(std::str::from_utf8(entry).into_diagnostic()?);
let path = if is_file {
repo_relative
.file_name()
.map(PathBuf::from)
.ok_or_else(|| {
miette::miette!("path has no file name: {}", repo_relative.display())
})?
} else if relative_path.as_os_str().is_empty() {
repo_relative.to_path_buf()
} else {
repo_relative
.strip_prefix(relative_path)
.into_diagnostic()?
.to_path_buf()
};
if path.as_os_str().is_empty() {
continue;
}
files.push(path.to_string_lossy().into_owned());
}
Ok((base_dir, files))
}
fn sandbox_upload_plan(local_path: &Path, git_ignore: bool) -> Result<SandboxUploadPlan> {
let metadata = std::fs::symlink_metadata(local_path).map_err(|err| {
if err.kind() == ErrorKind::NotFound {
miette::miette!("local path does not exist: {}", local_path.display())
} else {
miette::miette!(
"failed to inspect local upload path: {}",
local_path.display()
)
}
})?;
if git_ignore
&& !metadata.file_type().is_symlink()
&& let Ok((base_dir, files)) = git_sync_files(local_path)
{
if files.is_empty() {
return Ok(SandboxUploadPlan::GitFilteredEmpty);
}
return Ok(SandboxUploadPlan::GitAware { base_dir, files });
}
Ok(SandboxUploadPlan::Regular)
}
/// Upload a local path to a sandbox.
///
/// Symlink sources, including dangling links, bypass Git-aware filtering so
/// the tar upload preserves the link instead of dereferencing its target.
pub async fn sandbox_upload(
server: &str,
name: &str,
local_path: &Path,
sandbox_path: Option<&str>,
git_ignore: bool,
tls: &TlsOptions,
workspace: &str,
) -> Result<()> {
let upload_plan = sandbox_upload_plan(local_path, git_ignore)?;
let dest_display = sandbox_path.unwrap_or("~");
eprintln!(
"Uploading {} -> sandbox:{}",
local_path.display(),
dest_display
);
match upload_plan {
SandboxUploadPlan::GitAware { base_dir, files } => {
sandbox_sync_up_files(
server,
name,
&base_dir,
&files,
local_path,
sandbox_path,
tls,
workspace,
)
.await?;
}
SandboxUploadPlan::GitFilteredEmpty => {
eprintln!(
"{} .gitignore filtering excluded all files in {}; uploading unfiltered",
"⚠".yellow().bold(),
local_path.display(),
);
sandbox_sync_up(server, name, local_path, sandbox_path, tls, workspace).await?;
}
SandboxUploadPlan::Regular => {
sandbox_sync_up(server, name, local_path, sandbox_path, tls, workspace).await?;
}
}
eprintln!("{} Upload complete", "✓".green().bold());
Ok(())
}
// ---------------------------------------------------------------------------
// Sandbox policy commands
// ---------------------------------------------------------------------------
pub async fn sandbox_policy_set_global(
server: &str,
policy_path: &str,
yes: bool,
wait: bool,
_timeout_secs: u64,
_workspace: &str,
tls: &TlsOptions,
) -> Result<()> {
if wait {
return Err(miette::miette!(
"--wait is only supported for sandbox-scoped policy updates"
));
}
confirm_global_setting_takeover("policy", yes)?;
let policy = load_sandbox_policy(Some(policy_path))?
.ok_or_else(|| miette::miette!("No policy loaded from {policy_path}"))?;
let mut client = grpc_client(server, tls).await?;
let response = client
.update_config(UpdateConfigRequest {
policy: Some(policy),
global: true,
..Default::default()
})
.await
.into_diagnostic()?
.into_inner();
eprintln!(
"{} Global policy configured (hash: {}, settings revision: {})",
"✓".green().bold(),
if response.policy_hash.len() >= 12 {
&response.policy_hash[..12]
} else {
&response.policy_hash
},
response.settings_revision,
);
Ok(())
}
pub async fn sandbox_settings_get(
server: &str,
name: &str,
json: bool,
workspace: &str,
tls: &TlsOptions,
) -> Result<()> {
let mut client = grpc_client(server, tls).await?;
let response = client
.get_sandbox_config(GetSandboxConfigRequest {
name: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
})
.await
.into_diagnostic()?
.into_inner();
if json {
let obj = settings_to_json_sandbox(name, workspace, &response);
println!("{}", serde_json::to_string_pretty(&obj).into_diagnostic()?);
return Ok(());
}
let policy_source = if response.policy_source == PolicySource::Global as i32 {
"global"
} else {
"sandbox"
};
println!("Sandbox: {name}");
println!("Config Rev: {}", response.config_revision);
println!("Policy Source: {policy_source}");
println!("Policy Hash: {}", response.policy_hash);
if response.settings.is_empty() {
println!("Settings: No settings available.");
return Ok(());
}
println!("Settings:");
let mut keys: Vec<_> = response.settings.keys().cloned().collect();
keys.sort();
for key in keys {
if let Some(setting) = response.settings.get(&key) {
let scope = match SettingScope::try_from(setting.scope) {
Ok(SettingScope::Global) => "global",
Ok(SettingScope::Sandbox) => "sandbox",
_ => "unset",
};
println!(
" {} = {} ({})",
key,
format_setting_value(setting.value.as_ref()),
scope
);
}
}
Ok(())
}
pub async fn gateway_settings_get(server: &str, json: bool, tls: &TlsOptions) -> Result<()> {
let mut client = grpc_client(server, tls).await?;
let response = client
.get_gateway_config(GetGatewayConfigRequest {})
.await
.into_diagnostic()?
.into_inner();
if json {
let obj = settings_to_json_global(&response);
println!("{}", serde_json::to_string_pretty(&obj).into_diagnostic()?);
return Ok(());
}
println!("Scope: global");
println!("Settings Rev: {}", response.settings_revision);
if response.settings.is_empty() {
println!("Settings: No settings available.");
return Ok(());
}
println!("Settings:");
let mut keys: Vec<_> = response.settings.keys().cloned().collect();
keys.sort();
for key in keys {
if let Some(setting) = response.settings.get(&key) {
println!(" {} = {}", key, format_setting_value(Some(setting)));
}
}
Ok(())
}
fn settings_to_json_sandbox(
name: &str,
workspace: &str,
response: &GetSandboxConfigResponse,
) -> serde_json::Value {
let policy_source = if response.policy_source == PolicySource::Global as i32 {
"global"
} else {
"sandbox"
};
let mut settings = serde_json::Map::new();
let mut keys: Vec<_> = response.settings.keys().cloned().collect();
keys.sort();
for key in keys {
if let Some(setting) = response.settings.get(&key) {
let scope = match SettingScope::try_from(setting.scope) {
Ok(SettingScope::Global) => "global",
Ok(SettingScope::Sandbox) => "sandbox",
_ => "unset",
};
settings.insert(
key,
serde_json::json!({
"value": format_setting_value(setting.value.as_ref()),
"scope": scope,
}),
);
}
}
serde_json::json!({
"sandbox": name,
"workspace": workspace,
"config_revision": response.config_revision,
"policy_source": policy_source,
"policy_hash": response.policy_hash,
"settings": settings,
})
}
fn settings_to_json_global(
response: &openshell_core::proto::GetGatewayConfigResponse,
) -> serde_json::Value {
let mut settings = serde_json::Map::new();
let mut keys: Vec<_> = response.settings.keys().cloned().collect();
keys.sort();
for key in keys {
if let Some(setting) = response.settings.get(&key) {
settings.insert(key, serde_json::json!(format_setting_value(Some(setting))));
}
}
serde_json::json!({
"scope": "global",
"settings_revision": response.settings_revision,
"settings": settings,
})
}
pub async fn gateway_setting_set(
server: &str,
key: &str,
value: &str,
yes: bool,
_workspace: &str,
tls: &TlsOptions,
) -> Result<()> {
let setting_value = parse_cli_setting_value(key, value)?;
confirm_global_setting_takeover(key, yes)?;
let mut client = grpc_client(server, tls).await?;
let response = client
.update_config(UpdateConfigRequest {
setting_key: key.to_string(),
setting_value: Some(setting_value),
global: true,
..Default::default()
})
.await
.into_diagnostic()?
.into_inner();
println!(
"{} Set global setting {}={} (revision {})",
"✓".green().bold(),
key,
value,
response.settings_revision
);
Ok(())
}
pub async fn sandbox_setting_set(
server: &str,
name: &str,
key: &str,
value: &str,
workspace: &str,
tls: &TlsOptions,
) -> Result<()> {
let setting_value = parse_cli_setting_value(key, value)?;
let mut client = grpc_client(server, tls).await?;
let response = client
.update_config(UpdateConfigRequest {
sandbox: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
setting_key: key.to_string(),
setting_value: Some(setting_value),
..Default::default()
})
.await
.into_diagnostic()?
.into_inner();
println!(
"{} Set sandbox setting {}={} for {} (revision {})",
"✓".green().bold(),
key,
value,
name,
response.settings_revision
);
Ok(())
}
pub async fn gateway_setting_delete(
server: &str,
key: &str,
yes: bool,
_workspace: &str,
tls: &TlsOptions,
) -> Result<()> {
confirm_global_setting_delete(key, yes)?;
let mut client = grpc_client(server, tls).await?;
let response = client
.update_config(UpdateConfigRequest {
setting_key: key.to_string(),
delete_setting: true,
global: true,
..Default::default()
})
.await
.into_diagnostic()?
.into_inner();
if response.deleted {
println!(
"{} Deleted global setting {} (revision {})",
"✓".green().bold(),
key,
response.settings_revision
);
} else {
println!("{} Global setting {} not found", "!".yellow(), key);
}
Ok(())
}
pub async fn sandbox_setting_delete(
server: &str,
name: &str,
key: &str,
workspace: &str,
tls: &TlsOptions,
) -> Result<()> {
let mut client = grpc_client(server, tls).await?;
let response = client
.update_config(UpdateConfigRequest {
sandbox: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
setting_key: key.to_string(),
delete_setting: true,
..Default::default()
})
.await
.into_diagnostic()?
.into_inner();
if response.deleted {
println!(
"{} Deleted sandbox setting {} for {} (revision {})",
"✓".green().bold(),
key,
name,
response.settings_revision
);
} else {
println!(
"{} Sandbox setting {} not found for {}",
"!".yellow(),
key,
name,
);
}
Ok(())
}
pub async fn sandbox_policy_set(
server: &str,
name: &str,
policy_path: &str,
wait: bool,
timeout_secs: u64,
workspace: &str,
tls: &TlsOptions,
) -> Result<()> {
let policy = load_sandbox_policy(Some(policy_path))?
.ok_or_else(|| miette::miette!("No policy loaded from {policy_path}"))?;
let mut client = grpc_client(server, tls).await?;
// Get current version so we can detect no-ops.
let current_version = client
.get_sandbox_policy_status(GetSandboxPolicyStatusRequest {
sandbox: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
version: 0,
global: false,
})
.await
.ok()
.and_then(|r| r.into_inner().revision)
.map_or(0, |r| r.version);
let response = client
.update_config(UpdateConfigRequest {
sandbox: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
policy: Some(policy),
..Default::default()
})
.await
.into_diagnostic()?;
let resp = response.into_inner();
if resp.version == current_version {
eprintln!(
"{} Policy unchanged (version {}, hash: {})",
"·".dimmed(),
resp.version,
&resp.policy_hash[..12]
);
return Ok(());
}
eprintln!(
"{} Policy version {} submitted (hash: {})",
"✓".green().bold(),
resp.version,
&resp.policy_hash[..12]
);
if !wait {
return Ok(());
}
// Poll for status until loaded, failed, or timeout.
let deadline = Instant::now() + Duration::from_secs(timeout_secs);
loop {
if Instant::now() > deadline {
eprintln!(
"{} Timeout waiting for policy version {} to load",
"✗".red().bold(),
resp.version
);
std::process::exit(124);
}
tokio::time::sleep(Duration::from_secs(1)).await;
let status_resp = client
.get_sandbox_policy_status(GetSandboxPolicyStatusRequest {
sandbox: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
version: resp.version,
global: false,
})
.await
.into_diagnostic()?;
let inner = status_resp.into_inner();
if let Some(rev) = &inner.revision {
let status = PolicyStatus::try_from(rev.status).unwrap_or(PolicyStatus::Unspecified);
match status {
PolicyStatus::Loaded => {
eprintln!(
"{} Policy version {} loaded (active version: {})",
"✓".green().bold(),
rev.version,
inner.active_version
);
return Ok(());
}
PolicyStatus::Failed => {
eprintln!(
"{} Policy version {} failed to load: {}",
"✗".red().bold(),
rev.version,
rev.load_error
);
std::process::exit(1);
}
PolicyStatus::Superseded => {
eprintln!(
"{} Policy version {} was superseded (active version: {})",
"⚠".yellow().bold(),
rev.version,
inner.active_version
);
return Ok(());
}
_ => {} // still pending, keep polling
}
}
}
}
/// Preview or atomically submit explicitly scoped incremental policy operations.
#[allow(clippy::too_many_arguments)]
pub async fn sandbox_policy_update(
server: &str,
name: &str,
add_endpoints: &[String],
remove_endpoints: &[String],
add_deny: &[String],
add_allow: &[String],
remove_rules: &[String],
binaries: &[String],
rule_name: Option<&str>,
any_binary: bool,
endpoint_path: Option<&str>,
dry_run: bool,
wait: bool,
timeout_secs: u64,
workspace: &str,
tls: &TlsOptions,
) -> Result<()> {
if dry_run && wait {
return Err(miette!("--wait cannot be combined with --dry-run"));
}
let plan = build_policy_update_plan(
add_endpoints,
remove_endpoints,
add_deny,
add_allow,
remove_rules,
binaries,
rule_name,
any_binary,
endpoint_path,
)?;
let mut client = grpc_client(server, tls).await?;
let current = client
.get_sandbox_config(GetSandboxConfigRequest {
name: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
})
.await
.into_diagnostic()?
.into_inner();
if current.policy_source == PolicySource::Global as i32 {
return Err(miette!(
"policy is managed globally; delete the global policy before using `openshell policy update`"
));
}
let merged = openshell_policy::merge_policy(
current.policy.clone().unwrap_or_default(),
&plan.preview_operations,
)
.map_err(|error| miette!("{error}"))?;
if dry_run {
eprintln!(
"{} Dry run preview for {} incremental policy operation(s)",
"✓".green().bold(),
plan.preview_operations.len()
);
print_policy_merge_warnings(&merged.warnings);
print_sandbox_policy(&merged.policy);
return Ok(());
}
let current_version = current.version;
let current_hash = current.policy_hash.clone();
let response = client
.update_config(UpdateConfigRequest {
sandbox: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
merge_operations: plan.merge_operations,
..Default::default()
})
.await
.into_diagnostic()?
.into_inner();
print_policy_merge_warnings(&merged.warnings);
if response.version == current_version && response.policy_hash == current_hash {
eprintln!(
"{} Policy unchanged (version {}, hash: {})",
"·".dimmed(),
response.version,
short_hash(&response.policy_hash)
);
return Ok(());
}
eprintln!(
"{} Policy version {} submitted (hash: {})",
"✓".green().bold(),
response.version,
short_hash(&response.policy_hash)
);
if !wait {
return Ok(());
}
let deadline = Instant::now() + Duration::from_secs(timeout_secs);
loop {
if Instant::now() > deadline {
eprintln!(
"{} Timeout waiting for policy version {} to load",
"✗".red().bold(),
response.version
);
std::process::exit(124);
}
tokio::time::sleep(Duration::from_secs(1)).await;
let status_resp = client
.get_sandbox_policy_status(GetSandboxPolicyStatusRequest {
sandbox: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
version: response.version,
global: false,
})
.await
.into_diagnostic()?;
let inner = status_resp.into_inner();
if let Some(rev) = &inner.revision {
let status = PolicyStatus::try_from(rev.status).unwrap_or(PolicyStatus::Unspecified);
match status {
PolicyStatus::Loaded => {
eprintln!(
"{} Policy version {} loaded (active version: {})",
"✓".green().bold(),
rev.version,
inner.active_version
);
return Ok(());
}
PolicyStatus::Failed => {
eprintln!(
"{} Policy version {} failed to load: {}",
"✗".red().bold(),
rev.version,
rev.load_error
);
std::process::exit(1);
}
PolicyStatus::Superseded => {
eprintln!(
"{} Policy version {} was superseded (active version: {})",
"⚠".yellow().bold(),
rev.version,
inner.active_version
);
return Ok(());
}
_ => {}
}
}
}
}
pub async fn sandbox_policy_get(
server: &str,
name: &str,
version: u32,
view: PolicyGetView,
output: &str,
workspace: &str,
tls: &TlsOptions,
) -> Result<()> {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
sandbox_policy_get_to_writer(
server,
name,
version,
view,
output,
workspace,
tls,
(&mut stdout, &mut stderr),
)
.await?;
{
let mut terminal_stdout = std::io::stdout().lock();
terminal_stdout.write_all(&stdout).into_diagnostic()?;
}
{
let mut terminal_stderr = std::io::stderr().lock();
terminal_stderr.write_all(&stderr).into_diagnostic()?;
}
Ok(())
}
#[doc(hidden)]
#[allow(clippy::too_many_arguments)]
pub async fn sandbox_policy_get_to_writer<W, E>(
server: &str,
name: &str,
version: u32,
view: PolicyGetView,
output: &str,
workspace: &str,
tls: &TlsOptions,
writers: (&mut W, &mut E),
) -> Result<()>
where
W: Write + Send,
E: Write + Send,
{
if version == 0 {
return sandbox_policy_get_effective_to_writer(
server, name, view, output, workspace, tls, writers,
)
.await;
}
let (stdout, stderr) = writers;
let mut client = grpc_client(server, tls).await?;
let status_resp = client
.get_sandbox_policy_status(GetSandboxPolicyStatusRequest {
sandbox: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
version,
global: false,
})
.await
.into_diagnostic()?;
let inner = status_resp.into_inner();
if let Some(rev) = inner.revision {
let status = PolicyStatus::try_from(rev.status).unwrap_or(PolicyStatus::Unspecified);
match output {
"json" => {
let obj = policy_revision_to_json(
"sandbox",
Some(name),
Some(inner.active_version),
&rev,
status,
view,
)?;
writeln!(
stdout,
"{}",
serde_json::to_string_pretty(&obj).into_diagnostic()?
)
.into_diagnostic()?;
return Ok(());
}
"table" => {}
_ => return Err(miette!("unsupported output format: {output}")),
}
writeln!(stdout, "Version: {}", rev.version).into_diagnostic()?;
writeln!(stdout, "Hash: {}", rev.policy_hash).into_diagnostic()?;
writeln!(stdout, "Status: {status:?}").into_diagnostic()?;
writeln!(stdout, "Active: {}", inner.active_version).into_diagnostic()?;
if let Some(created_time) = rev.created_time.as_ref() {
writeln!(
stdout,
"Created: {} ms",
proto_timestamp_ms(Some(created_time))
)
.into_diagnostic()?;
}
if let Some(loaded_time) = rev.loaded_time.as_ref() {
writeln!(
stdout,
"Loaded: {} ms",
proto_timestamp_ms(Some(loaded_time))
)
.into_diagnostic()?;
}
if !rev.load_error.is_empty() {
writeln!(stdout, "Error: {}", rev.load_error).into_diagnostic()?;
}
if view.includes_policy() {
if let Some(ref policy) = rev.policy {
writeln!(stdout, "---").into_diagnostic()?;
let policy = policy_for_view(policy, view);
let yaml_str = openshell_policy::serialize_sandbox_policy(policy.as_ref())
.wrap_err("failed to serialize policy to YAML")?;
write!(stdout, "{yaml_str}").into_diagnostic()?;
} else {
writeln!(stderr, "Policy payload not available for this version")
.into_diagnostic()?;
}
}
} else {
writeln!(stderr, "No policy history found for sandbox '{name}'").into_diagnostic()?;
}
Ok(())
}
async fn sandbox_policy_get_effective_to_writer<W, E>(
server: &str,
name: &str,
view: PolicyGetView,
output: &str,
workspace: &str,
tls: &TlsOptions,
writers: (&mut W, &mut E),
) -> Result<()>
where
W: Write + Send,
E: Write + Send,
{
let (stdout, _stderr) = writers;
let mut client = grpc_client(server, tls).await?;
let config = client
.get_sandbox_config(GetSandboxConfigRequest {
name: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
})
.await
.into_diagnostic()?
.into_inner();
let policy = config
.policy
.as_ref()
.ok_or_else(|| miette!("no active policy configured for sandbox '{name}'"))?;
let policy_source =
PolicySource::try_from(config.policy_source).unwrap_or(PolicySource::Sandbox);
let policy_source_label = match policy_source {
PolicySource::Global => "global",
PolicySource::Sandbox => "sandbox",
PolicySource::Unspecified => "unspecified",
};
let version = if policy_source == PolicySource::Global && config.global_policy_version > 0 {
config.global_policy_version
} else {
config.version
};
match output {
"json" => {
let mut obj = serde_json::Map::new();
obj.insert("scope".to_string(), serde_json::json!("sandbox"));
obj.insert("sandbox".to_string(), serde_json::json!(name));
obj.insert("version".to_string(), serde_json::json!(version));
obj.insert("active_version".to_string(), serde_json::json!(version));
obj.insert("hash".to_string(), serde_json::json!(config.policy_hash));
obj.insert("status".to_string(), serde_json::json!("effective"));
obj.insert(
"config_revision".to_string(),
serde_json::json!(config.config_revision),
);
obj.insert(
"policy_source".to_string(),
serde_json::json!(policy_source_label),
);
if config.global_policy_version > 0 {
obj.insert(
"global_policy_version".to_string(),
serde_json::json!(config.global_policy_version),
);
}
if view.includes_policy() {
let policy = policy_for_view(policy, view);
obj.insert(
"policy".to_string(),
openshell_policy::sandbox_policy_to_json_value(policy.as_ref())?,
);
}
writeln!(
stdout,
"{}",
serde_json::to_string_pretty(&serde_json::Value::Object(obj)).into_diagnostic()?
)
.into_diagnostic()?;
}
"table" => {
writeln!(stdout, "Version: {version}").into_diagnostic()?;
writeln!(stdout, "Hash: {}", config.policy_hash).into_diagnostic()?;
writeln!(stdout, "Status: Effective").into_diagnostic()?;
writeln!(stdout, "Source: {policy_source_label}").into_diagnostic()?;
writeln!(stdout, "Config rev: {}", config.config_revision).into_diagnostic()?;
if config.global_policy_version > 0 {
writeln!(stdout, "Global: {}", config.global_policy_version)
.into_diagnostic()?;
}
if view.includes_policy() {
writeln!(stdout, "---").into_diagnostic()?;
let policy = policy_for_view(policy, view);
let yaml_str = openshell_policy::serialize_sandbox_policy(policy.as_ref())
.wrap_err("failed to serialize policy to YAML")?;
write!(stdout, "{yaml_str}").into_diagnostic()?;
}
}
_ => return Err(miette!("unsupported output format: {output}")),
}
Ok(())
}
pub async fn sandbox_policy_get_global(
server: &str,
version: u32,
view: PolicyGetView,
output: &str,
_workspace: &str,
tls: &TlsOptions,
) -> Result<()> {
let mut client = grpc_client(server, tls).await?;
let status_resp = client
.get_sandbox_policy_status(GetSandboxPolicyStatusRequest {
version,
global: true,
sandbox: String::new(),
workspace_scope: None,
})
.await
.into_diagnostic()?;
let inner = status_resp.into_inner();
if let Some(rev) = inner.revision {
let status = PolicyStatus::try_from(rev.status).unwrap_or(PolicyStatus::Unspecified);
match output {
"json" => {
let obj = policy_revision_to_json("global", None, None, &rev, status, view)?;
println!("{}", serde_json::to_string_pretty(&obj).into_diagnostic()?);
return Ok(());
}
"table" => {}
_ => return Err(miette!("unsupported output format: {output}")),
}
println!("Scope: global");
println!("Version: {}", rev.version);
println!("Hash: {}", rev.policy_hash);
println!("Status: {status:?}");
if let Some(created_time) = rev.created_time.as_ref() {
println!(
"Created: {} ms",
proto_timestamp_ms(Some(created_time))
);
}
if let Some(loaded_time) = rev.loaded_time.as_ref() {
println!("Loaded: {} ms", proto_timestamp_ms(Some(loaded_time)));
}
if view.includes_policy() {
if let Some(ref policy) = rev.policy {
println!("---");
let policy = policy_for_view(policy, view);
let yaml_str = openshell_policy::serialize_sandbox_policy(policy.as_ref())
.wrap_err("failed to serialize policy to YAML")?;
print!("{yaml_str}");
} else {
eprintln!("Policy payload not available for this version");
}
}
} else {
eprintln!("No global policy history found");
}
Ok(())
}
fn policy_status_json_name(status: PolicyStatus) -> &'static str {
match status {
PolicyStatus::Unspecified => "unspecified",
PolicyStatus::Pending => "pending",
PolicyStatus::Loaded => "loaded",
PolicyStatus::Failed => "failed",
PolicyStatus::Superseded => "superseded",
}
}
fn policy_revision_to_json(
scope: &str,
sandbox: Option<&str>,
active_version: Option<u32>,
rev: &openshell_core::proto::SandboxPolicyRevision,
status: PolicyStatus,
view: PolicyGetView,
) -> Result<serde_json::Value> {
let mut obj = serde_json::Map::new();
obj.insert("scope".to_string(), serde_json::json!(scope));
if let Some(sandbox) = sandbox {
obj.insert("sandbox".to_string(), serde_json::json!(sandbox));
}
obj.insert("version".to_string(), serde_json::json!(rev.version));
obj.insert("hash".to_string(), serde_json::json!(rev.policy_hash));
obj.insert(
"status".to_string(),
serde_json::json!(policy_status_json_name(status)),
);
if let Some(active_version) = active_version {
obj.insert(
"active_version".to_string(),
serde_json::json!(active_version),
);
}
if rev.created_time.is_some() {
obj.insert(
"created_at_ms".to_string(),
serde_json::json!(proto_timestamp_ms(rev.created_time.as_ref())),
);
}
if rev.loaded_time.is_some() {
obj.insert(
"loaded_at_ms".to_string(),
serde_json::json!(proto_timestamp_ms(rev.loaded_time.as_ref())),
);
}
if !rev.load_error.is_empty() {
obj.insert("load_error".to_string(), serde_json::json!(rev.load_error));
}
if !rev.provenance.is_empty() {
obj.insert("provenance".to_string(), serde_json::json!(rev.provenance));
}
if view.includes_policy() {
let policy = match rev.policy.as_ref() {
Some(policy) => {
let policy = policy_for_view(policy, view);
openshell_policy::sandbox_policy_to_json_value(policy.as_ref())?
}
None => serde_json::Value::Null,
};
obj.insert("policy".to_string(), policy);
}
Ok(serde_json::Value::Object(obj))
}
fn policy_for_view(policy: &SandboxPolicy, view: PolicyGetView) -> Cow<'_, SandboxPolicy> {
if view != PolicyGetView::Base {
return Cow::Borrowed(policy);
}
let mut base_policy = policy.clone();
base_policy
.network_policies
.retain(|name, _| !openshell_policy::is_provider_rule_name(name));
Cow::Owned(base_policy)
}
pub async fn sandbox_policy_list(
server: &str,
name: &str,
page_size: i32,
page_token: &str,
output: &str,
workspace: &str,
tls: &TlsOptions,
) -> Result<()> {
let mut client = grpc_client(server, tls).await?;
let resp = client
.list_sandbox_policies(ListSandboxPoliciesRequest {
sandbox: name.to_string(),
page_size,
page_token: page_token.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
global: false,
})
.await
.into_diagnostic()?;
let resp = resp.into_inner();
let next_page_token = resp.next_page_token;
let revisions = resp.revisions;
let structured = policy_revision_list_json("sandbox", Some(name), &revisions)?;
if crate::output::print_paginated_output_collection(
output,
"revisions",
&structured,
&next_page_token,
Clone::clone,
)? {
return Ok(());
}
crate::output::print_next_page_token(&next_page_token);
if revisions.is_empty() {
eprintln!("No policy history found for sandbox '{name}'");
return Ok(());
}
print_policy_revision_table(&revisions);
Ok(())
}
pub async fn sandbox_policy_list_global(
server: &str,
page_size: i32,
page_token: &str,
output: &str,
_workspace: &str,
tls: &TlsOptions,
) -> Result<()> {
let mut client = grpc_client(server, tls).await?;
let resp = client
.list_sandbox_policies(ListSandboxPoliciesRequest {
page_size,
page_token: page_token.to_string(),
global: true,
sandbox: String::new(),
workspace_scope: None,
})
.await
.into_diagnostic()?;
let resp = resp.into_inner();
let next_page_token = resp.next_page_token;
let revisions = resp.revisions;
let structured = policy_revision_list_json("global", None, &revisions)?;
if crate::output::print_paginated_output_collection(
output,
"revisions",
&structured,
&next_page_token,
Clone::clone,
)? {
return Ok(());
}
crate::output::print_next_page_token(&next_page_token);
if revisions.is_empty() {
eprintln!("No global policy history found");
return Ok(());
}
print_policy_revision_table(&revisions);
Ok(())
}
fn policy_revision_list_json(
scope: &str,
sandbox: Option<&str>,
revisions: &[openshell_core::proto::SandboxPolicyRevision],
) -> Result<Vec<serde_json::Value>> {
revisions
.iter()
.map(|revision| {
let status =
PolicyStatus::try_from(revision.status).unwrap_or(PolicyStatus::Unspecified);
policy_revision_to_json(
scope,
sandbox,
None,
revision,
status,
PolicyGetView::Metadata,
)
})
.collect()
}
fn print_policy_revision_table(revisions: &[openshell_core::proto::SandboxPolicyRevision]) {
println!(
"{:<8} {:<14} {:<12} {:<24} ERROR",
"VERSION", "HASH", "STATUS", "CREATED"
);
for rev in revisions {
let status = PolicyStatus::try_from(rev.status).unwrap_or(PolicyStatus::Unspecified);
let hash_short = if rev.policy_hash.len() >= 12 {
&rev.policy_hash[..12]
} else {
&rev.policy_hash
};
let error_short = if rev.load_error.len() > 40 {
truncate_status_field(&rev.load_error, 40)
} else {
rev.load_error.clone()
};
println!(
"{:<8} {:<14} {:<12} {:<24} {}",
rev.version,
hash_short,
format!("{status:?}"),
proto_timestamp_ms(rev.created_time.as_ref()),
error_short,
);
}
}
// ---------------------------------------------------------------------------
// Sandbox logs command
// ---------------------------------------------------------------------------
#[allow(clippy::too_many_arguments)] // user-facing CLI command
pub async fn sandbox_logs(
server: &str,
name: &str,
lines: u32,
tail: bool,
since: Option<&str>,
sources: &[String],
level: &str,
workspace: &str,
tls: &TlsOptions,
) -> Result<()> {
let mut client = grpc_client(server, tls).await?;
// Normalize "all" to empty list (server treats empty as "no filter").
let source_filter: Vec<String> = sources
.iter()
.filter(|s| s.as_str() != "all")
.cloned()
.collect();
let since_ms = if let Some(s) = since {
let dur_ms = parse_duration_to_ms(s)?;
let now_ms = i64::try_from(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.into_diagnostic()?
.as_millis(),
)
.into_diagnostic()?;
now_ms - dur_ms
} else {
0
};
if tail {
// Streaming mode: use WatchSandbox.
let mut stream = client
.watch_sandbox(WatchSandboxRequest {
sandbox: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
follow_status: false,
follow_logs: true,
follow_events: false,
log_tail_lines: lines,
event_tail: 0,
stop_on_terminal: false,
since_time: openshell_core::time::optional_timestamp_from_legacy_millis(since_ms)
.into_diagnostic()?,
log_sources: source_filter,
log_min_level: level.to_uppercase(),
resume_after_cursor: String::new(),
})
.await
.into_diagnostic()?
.into_inner();
while let Some(event) = stream.next().await {
let event = event.into_diagnostic()?;
if let Some(openshell_core::proto::sandbox_stream_event::Payload::Log(log)) =
event.payload
{
print_log_line(&log);
}
}
} else {
// One-shot mode: use GetSandboxLogs.
let resp = client
.get_sandbox_logs(GetSandboxLogsRequest {
sandbox: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
lines,
since_time: openshell_core::time::optional_timestamp_from_legacy_millis(since_ms)
.into_diagnostic()?,
sources: source_filter,
min_level: level.to_uppercase(),
})
.await
.into_diagnostic()?;
let inner = resp.into_inner();
if since_ms > 0 && inner.buffer_total > 0 {
eprintln!(
"Warning: log buffer contains only the last {} lines; --since results may be incomplete.",
inner.buffer_total
);
}
for log in &inner.logs {
print_log_line(log);
}
}
Ok(())
}
fn print_log_line(log: &openshell_core::proto::SandboxLogLine) {
println!("{}", format_log_line(log));
}
fn format_log_line(log: &openshell_core::proto::SandboxLogLine) -> String {
let source = if log.source.is_empty() {
"gateway"
} else {
&log.source
};
let timestamp_ms = proto_timestamp_ms(log.event_time.as_ref());
let secs = timestamp_ms / 1000;
let millis = timestamp_ms % 1000;
if log.fields.is_empty() {
format!(
"[{secs}.{millis:03}] [{source:<7}] [{:<5}] [{}] {}",
log.level, log.target, log.message
)
} else {
let mut fields_str = String::new();
let mut entries: Vec<_> = log.fields.iter().collect();
entries.sort_by_key(|(k, _)| k.as_str());
for (k, v) in entries {
if !fields_str.is_empty() {
fields_str.push(' ');
}
fields_str.push_str(k);
fields_str.push('=');
fields_str.push_str(v);
}
format!(
"[{secs}.{millis:03}] [{source:<7}] [{:<5}] [{}] {} {}",
log.level, log.target, log.message, fields_str
)
}
}
// ---------------------------------------------------------------------------
// Network rule commands
// ---------------------------------------------------------------------------
/// Show network rules for a sandbox.
pub async fn sandbox_draft_get(
server: &str,
name: &str,
status_filter: Option<&str>,
workspace: &str,
tls: &TlsOptions,
) -> Result<()> {
let mut client = grpc_client(server, tls).await?;
let response = client
.get_draft_policy(GetDraftPolicyRequest {
sandbox: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
status_filter: status_filter.unwrap_or("").to_string(),
})
.await
.into_diagnostic()?;
let inner = response.into_inner();
if inner.chunks.is_empty() {
println!("No network rules for sandbox '{name}'");
return Ok(());
}
println!(
"{} (version {}, {} chunk{})",
"Network Rules:".cyan().bold(),
inner.draft_version,
inner.chunks.len(),
if inner.chunks.len() == 1 { "" } else { "s" }
);
println!();
for chunk in &inner.chunks {
let status_colored = match chunk.status.as_str() {
"pending" => chunk.status.yellow().to_string(),
"approved" => chunk.status.green().to_string(),
"rejected" => chunk.status.red().to_string(),
_ => chunk.status.clone(),
};
println!(" {} {}", "Chunk:".dimmed(), chunk.id);
println!(" {} {}", "Status:".dimmed(), status_colored);
println!(" {} {}", "Rule:".dimmed(), chunk.rule_name);
if !chunk.binary.is_empty() {
println!(" {} {}", "Binary:".dimmed(), chunk.binary);
}
println!(
" {} {:.0}%",
"Confidence:".dimmed(),
chunk.confidence * 100.0
);
println!(" {} {}", "Rationale:".dimmed(), chunk.rationale);
if !chunk.security_notes.is_empty() {
println!(
" {} {}",
"Security:".dimmed(),
chunk.security_notes.yellow()
);
}
if !chunk.validation_result.is_empty() {
println!(
" {} {}",
"Prover:".dimmed(),
chunk.validation_result.cyan()
);
}
if !chunk.application_error.is_empty() {
println!(
" {} {}",
"Application:".dimmed(),
chunk.application_error.red()
);
}
if !chunk.candidate_effective_policy_hash.is_empty() {
println!(
" {} {}",
"Candidate:".dimmed(),
&chunk.candidate_effective_policy_hash
[..12.min(chunk.candidate_effective_policy_hash.len())]
);
}
if let Some(ref rule) = chunk.proposed_rule {
println!(" {} {}", "Endpoints:".dimmed(), format_endpoints(rule));
if !rule.binaries.is_empty() {
let bins: Vec<&str> = rule.binaries.iter().map(|b| b.path.as_str()).collect();
println!(" {} {}", "Binaries:".dimmed(), bins.join(", "));
}
}
if chunk.hit_count > 1 {
println!(
" {} {} (first seen {}, last seen {})",
"Hits:".dimmed(),
chunk.hit_count,
format_epoch_ms(proto_timestamp_ms(chunk.first_seen_time.as_ref())),
format_epoch_ms(proto_timestamp_ms(chunk.last_seen_time.as_ref())),
);
}
println!();
}
Ok(())
}
/// Approve a network rule.
pub async fn sandbox_draft_approve(
server: &str,
name: &str,
chunk_id: &str,
workspace: &str,
tls: &TlsOptions,
) -> Result<()> {
let mut client = grpc_client(server, tls).await?;
let review_token = client
.get_draft_policy(GetDraftPolicyRequest {
sandbox: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
status_filter: String::new(),
})
.await
.into_diagnostic()?
.into_inner()
.chunks
.into_iter()
.find(|chunk| chunk.id == chunk_id)
.ok_or_else(|| miette::miette!("draft chunk '{chunk_id}' not found"))?
.review_token;
let response = client
.approve_draft_chunk(ApproveDraftChunkRequest {
request_id: String::new(),
sandbox: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
chunk_id: chunk_id.to_string(),
review_token,
})
.await
.into_diagnostic()?;
let inner = response.into_inner();
println!(
"{} Chunk approved. Policy version: {}, hash: {}",
"OK".green().bold(),
inner.policy_version,
&inner.policy_hash[..12.min(inner.policy_hash.len())]
);
Ok(())
}
/// Reject a network rule.
pub async fn sandbox_draft_reject(
server: &str,
name: &str,
chunk_id: &str,
reason: &str,
workspace: &str,
tls: &TlsOptions,
) -> Result<()> {
let mut client = grpc_client(server, tls).await?;
client
.reject_draft_chunk(RejectDraftChunkRequest {
request_id: String::new(),
sandbox: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
chunk_id: chunk_id.to_string(),
reason: reason.to_string(),
})
.await
.into_diagnostic()?;
println!("{} Chunk rejected.", "OK".green().bold());
Ok(())
}
/// Approve all pending network rules.
pub async fn sandbox_draft_approve_all(
server: &str,
name: &str,
include_security_flagged: bool,
workspace: &str,
tls: &TlsOptions,
) -> Result<()> {
let mut client = grpc_client(server, tls).await?;
let approvals = client
.get_draft_policy(GetDraftPolicyRequest {
sandbox: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
status_filter: "pending".to_string(),
})
.await
.into_diagnostic()?
.into_inner()
.chunks
.into_iter()
.map(|chunk| openshell_core::proto::DraftChunkApproval {
chunk_id: chunk.id,
review_token: chunk.review_token,
})
.collect();
let response = client
.approve_all_draft_chunks(ApproveAllDraftChunksRequest {
request_id: String::new(),
sandbox: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
include_security_flagged,
approvals,
})
.await
.into_diagnostic()?;
let inner = response.into_inner();
println!(
"{} {} chunk(s) approved, {} skipped. Policy version: {}",
"OK".green().bold(),
inner.chunks_approved,
inner.chunks_skipped,
inner.policy_version,
);
Ok(())
}
/// Clear all pending network rules.
pub async fn sandbox_draft_clear(
server: &str,
name: &str,
workspace: &str,
tls: &TlsOptions,
) -> Result<()> {
let mut client = grpc_client(server, tls).await?;
let response = client
.clear_draft_chunks(ClearDraftChunksRequest {
request_id: String::new(),
sandbox: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
})
.await
.into_diagnostic()?;
let inner = response.into_inner();
println!(
"{} {} pending chunk(s) cleared.",
"OK".green().bold(),
inner.chunks_cleared,
);
Ok(())
}
/// Show network rule history.
pub async fn sandbox_draft_history(
server: &str,
name: &str,
workspace: &str,
tls: &TlsOptions,
) -> Result<()> {
let mut client = grpc_client(server, tls).await?;
let response = client
.get_draft_history(GetDraftHistoryRequest {
sandbox: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
})
.await
.into_diagnostic()?;
let inner = response.into_inner();
if inner.entries.is_empty() {
println!("No rule history for sandbox '{name}'");
return Ok(());
}
println!("{}", "Rule History:".cyan().bold());
println!();
for entry in &inner.entries {
let event_colored = match entry.event_type.as_str() {
"proposed" => entry.event_type.yellow().to_string(),
"approved" => entry.event_type.green().to_string(),
"rejected" => entry.event_type.red().to_string(),
_ => entry.event_type.clone(),
};
println!(
" {} {} [{}] {}",
format_timestamp_ms(proto_timestamp_ms(entry.event_time.as_ref())).dimmed(),
event_colored,
entry.chunk_id.get(..8).unwrap_or(&entry.chunk_id),
entry.description,
);
}
Ok(())
}
/// Format a `NetworkPolicyRule`'s endpoints as a compact string.
fn format_endpoints(rule: &openshell_core::proto::NetworkPolicyRule) -> String {
rule.endpoints
.iter()
.map(format_endpoint)
.collect::<Vec<_>>()
.join(", ")
}
/// Render an endpoint as `host:port [layer, …allows…, …denies…]` so a reader
/// can tell L4-only access apart from a method/path-scoped L7 grant. The L7
/// fields (`protocol: rest`, `rules`, `access`) materially change what gets
/// allowed; surfacing them in the default text output is what makes
/// `openshell rule get` useful for approval review.
fn format_endpoint(endpoint: &openshell_core::proto::NetworkEndpoint) -> String {
let host_port = if endpoint.port > 0 {
format!("{}:{}", endpoint.host, endpoint.port)
} else {
endpoint.host.clone()
};
let mut tags: Vec<String> = Vec::new();
let layer_tag = if endpoint.protocol.eq_ignore_ascii_case("rest") {
"L7 rest"
} else if endpoint.protocol.is_empty() {
"L4"
} else {
endpoint.protocol.as_str()
};
tags.push(layer_tag.to_string());
if endpoint.access != 0 {
tags.push(format!(
"access={}",
openshell_policy::network_access_preset_to_str(endpoint.access).unwrap_or("unknown")
));
}
for r in &endpoint.rules {
if let Some(allow) = &r.allow {
let method = non_empty_or(&allow.method, "*");
let path = non_empty_or(&allow.path, "*");
tags.push(format!("allow {method} {path}"));
}
}
for r in &endpoint.deny_rules {
let method = non_empty_or(&r.method, "*");
let path = non_empty_or(&r.path, "*");
tags.push(format!("deny {method} {path}"));
}
format!("{host_port} [{}]", tags.join(", "))
}
#[cfg(test)]
mod tests {
use super::{
PolicyGetView, ProvisioningStep, build_sandbox_resource_limits, format_endpoint,
format_log_line, git_sync_files, has_main_process_result, parse_cli_setting_value,
parse_credential_expiry_cli_value, parse_driver_config_json,
parse_secret_material_env_pairs, policy_revision_list_json, policy_revision_to_json,
proto_execution_timeout, provisioning_timeout_message, ready_false_condition_message,
resolve_from, rootfs_tar_sources_supported_for_gateway, sandbox_should_persist,
sandbox_upload_plan, service_endpoint_to_json, service_expose_status_error,
service_url_for_gateway, workspace_member_to_json,
};
#[test]
fn zero_exec_timeout_is_omitted() {
assert!(proto_execution_timeout(0).unwrap().is_none());
assert_eq!(
proto_execution_timeout(30).unwrap().unwrap(),
prost_types::Duration {
seconds: 30,
nanos: 0,
}
);
}
use crate::TEST_ENV_LOCK;
use crate::commands::common::{
parse_credential_expiry_pairs, parse_credential_pairs, progress_step_from_metadata,
};
use crate::test_utils::EnvVarGuard;
use openshell_bootstrap::GatewayMetadata;
use std::fs;
use std::path::Path;
use std::process::Command;
use tonic::Status;
use openshell_core::progress::{
PROGRESS_STEP_PULLING_IMAGE, PROGRESS_STEP_REQUESTING_SANDBOX,
PROGRESS_STEP_STARTING_SANDBOX,
};
use openshell_core::proto::{
EndpointResult, EndpointStatus, GetSandboxConfigResponse, GpuResourceRequirements,
PolicySource, PolicyStatus, ResourceRequirements, Sandbox, SandboxCondition, SandboxPhase,
SandboxPolicy, SandboxPolicyRevision, SandboxResources, SandboxRestartPolicy, SandboxSpec,
SandboxStatus, SandboxWorkloadConfig, SandboxWorkloadTemplate,
SandboxWorkloadTemplateProvenance, SandboxWorkloadTemplateSpec, ServiceAuthorizationMode,
ServiceEndpoint, ServiceEndpointResponse, WorkspaceMember, WorkspaceRole,
datamodel::v1::ObjectMeta,
};
#[test]
fn policy_revision_json_includes_revision_provenance() {
let revision = SandboxPolicyRevision {
version: 2,
policy_hash: "hash".to_string(),
provenance: std::collections::HashMap::from([(
"openshell.nvidia.com/policy-signature".to_string(),
"signed".to_string(),
)]),
..Default::default()
};
let json = policy_revision_to_json(
"sandbox",
Some("example"),
Some(2),
&revision,
PolicyStatus::Pending,
PolicyGetView::Metadata,
)
.unwrap();
assert_eq!(
json["provenance"]["openshell.nvidia.com/policy-signature"],
"signed"
);
}
#[test]
fn policy_list_json_reuses_metadata_contract() {
let load_error = "policy failed after checking café.example/非常に長いパス";
let revisions = vec![SandboxPolicyRevision {
version: 7,
policy_hash: "0123456789abcdef".to_string(),
status: PolicyStatus::Failed as i32,
load_error: load_error.to_string(),
created_time: openshell_core::time::timestamp_from_millis(100).ok(),
loaded_time: openshell_core::time::timestamp_from_millis(200).ok(),
policy: Some(SandboxPolicy::default()),
provenance: std::collections::HashMap::from([(
"source".to_string(),
"provider-composition".to_string(),
)]),
}];
let values = policy_revision_list_json("sandbox", Some("dev"), &revisions)
.expect("policy list JSON");
assert_eq!(
values[0],
serde_json::json!({
"scope": "sandbox",
"sandbox": "dev",
"version": 7,
"hash": "0123456789abcdef",
"status": "failed",
"created_at_ms": 100,
"loaded_at_ms": 200,
"load_error": load_error,
"provenance": {"source": "provider-composition"},
})
);
assert!(values[0].get("policy").is_none());
assert!(values[0].get("active_version").is_none());
let unknown = policy_revision_list_json(
"global",
None,
&[SandboxPolicyRevision {
version: 8,
status: 999,
..Default::default()
}],
)
.expect("global policy list JSON");
assert_eq!(unknown[0]["scope"], "global");
assert_eq!(unknown[0]["status"], "unspecified");
assert!(unknown[0].get("sandbox").is_none());
}
#[test]
fn policy_revision_table_handles_unicode_load_errors() {
// Stored diagnostics can contain Unicode paths. Byte 40 splits the
// character in the 40-scalar case; longer errors must also remain safe.
let revisions = [
String::new(),
"a".repeat(40),
"a".repeat(41),
format!("{}é", "a".repeat(39)),
format!("{}éz", "a".repeat(39)),
]
.into_iter()
.map(|load_error| SandboxPolicyRevision {
version: 1,
status: PolicyStatus::Failed as i32,
load_error,
..Default::default()
})
.collect::<Vec<_>>();
super::print_policy_revision_table(&revisions);
}
#[test]
fn service_endpoint_json_has_raw_fields_and_normalized_url() {
let response = ServiceEndpointResponse {
endpoint: Some(ServiceEndpoint {
metadata: Some(ObjectMeta {
workspace: "team-a".to_string(),
..Default::default()
}),
sandbox: "api".to_string(),
name: String::new(),
target_port: 8080,
authorization_mode: ServiceAuthorizationMode::BearerPassthrough as i32,
..Default::default()
}),
url: "https://api.openshell.localhost:3000/".to_string(),
};
let value = service_endpoint_to_json(&response, "https://gateway.example:17670")
.expect("service endpoint JSON");
assert_eq!(
value,
serde_json::json!({
"workspace": "team-a",
"sandbox": "api",
"service": "",
"target_port": 8080,
"authorization_mode": "bearer_passthrough",
"url": "https://api.openshell.localhost:17670/",
})
);
assert!(service_endpoint_to_json(&ServiceEndpointResponse::default(), "unused").is_none());
}
#[test]
fn workspace_member_json_uses_stable_role_names() {
for (role, expected) in [
(WorkspaceRole::Admin as i32, "admin"),
(WorkspaceRole::User as i32, "user"),
(999, "unknown"),
] {
let value = workspace_member_to_json(&WorkspaceMember {
metadata: Some(ObjectMeta {
id: "internal-id".to_string(),
..Default::default()
}),
principal_subject: "oidc-subject".to_string(),
role,
});
assert_eq!(
value,
serde_json::json!({"subject": "oidc-subject", "role": expected})
);
assert!(!value.to_string().contains("internal-id"));
}
}
#[test]
fn parse_credential_pairs_accepts_key_value_form() {
let parsed = parse_credential_pairs(&["API_KEY=abc123".to_string()]).expect("parse");
assert_eq!(parsed.get("API_KEY"), Some(&"abc123".to_string()));
}
#[test]
fn parse_credential_pairs_reads_value_from_environment_for_key_only_form() {
let _guard = EnvVarGuard::set("NAV_PARSE_CREDENTIAL_TEST_KEY", "from-env");
let parsed =
parse_credential_pairs(&["NAV_PARSE_CREDENTIAL_TEST_KEY".to_string()]).expect("parse");
assert_eq!(
parsed.get("NAV_PARSE_CREDENTIAL_TEST_KEY"),
Some(&"from-env".to_string())
);
}
#[test]
fn parse_credential_pairs_rejects_missing_environment_for_key_only_form() {
let _guard = EnvVarGuard::unset("NAV_PARSE_CREDENTIAL_MISSING");
let err = parse_credential_pairs(&["NAV_PARSE_CREDENTIAL_MISSING".to_string()])
.expect_err("missing env should error");
assert!(err.to_string().contains(
"requires local env var 'NAV_PARSE_CREDENTIAL_MISSING' to be set to a non-empty value"
));
}
#[test]
fn parse_credential_pairs_rejects_empty_environment_for_key_only_form() {
let _guard = EnvVarGuard::set("NAV_PARSE_CREDENTIAL_EMPTY", "");
let err = parse_credential_pairs(&["NAV_PARSE_CREDENTIAL_EMPTY".to_string()])
.expect_err("empty env should error");
assert!(err.to_string().contains(
"requires local env var 'NAV_PARSE_CREDENTIAL_EMPTY' to be set to a non-empty value"
));
}
#[test]
fn parse_secret_material_env_pairs_reads_value_from_named_environment_variable() {
let _guard = EnvVarGuard::set("NAV_PARSE_SME_NAMED", "pem-material");
let parsed =
parse_secret_material_env_pairs(&["private_key=NAV_PARSE_SME_NAMED".to_string()])
.expect("parse");
assert_eq!(parsed.get("private_key"), Some(&"pem-material".to_string()));
}
#[test]
fn parse_secret_material_env_pairs_defaults_env_name_to_key() {
let _guard = EnvVarGuard::set("NAV_PARSE_SME_KEY_ONLY", "key-only-material");
let parsed = parse_secret_material_env_pairs(&["NAV_PARSE_SME_KEY_ONLY".to_string()])
.expect("parse");
assert_eq!(
parsed.get("NAV_PARSE_SME_KEY_ONLY"),
Some(&"key-only-material".to_string())
);
}
#[test]
fn parse_secret_material_env_pairs_rejects_missing_environment() {
let _guard = EnvVarGuard::unset("NAV_PARSE_SME_MISSING");
let err =
parse_secret_material_env_pairs(&["private_key=NAV_PARSE_SME_MISSING".to_string()])
.expect_err("missing env should error");
assert!(err.to_string().contains(
"requires local env var 'NAV_PARSE_SME_MISSING' to be set to a non-empty value"
));
}
#[test]
fn parse_secret_material_env_pairs_rejects_empty_environment_value() {
let _guard = EnvVarGuard::set("NAV_PARSE_SME_EMPTY", " ");
let err = parse_secret_material_env_pairs(&["private_key=NAV_PARSE_SME_EMPTY".to_string()])
.expect_err("blank env should error");
assert!(err.to_string().contains(
"requires local env var 'NAV_PARSE_SME_EMPTY' to be set to a non-empty value"
));
}
#[test]
fn parse_secret_material_env_pairs_rejects_empty_key() {
let err = parse_secret_material_env_pairs(&["=NAV_PARSE_SME_NO_KEY".to_string()])
.expect_err("empty key should error");
assert!(err.to_string().contains("key cannot be empty"));
}
#[test]
fn parse_secret_material_env_pairs_rejects_duplicate_keys() {
let _guard = EnvVarGuard::set("NAV_PARSE_SME_DUP", "value");
let err = parse_secret_material_env_pairs(&[
"private_key=NAV_PARSE_SME_DUP".to_string(),
"private_key=NAV_PARSE_SME_DUP".to_string(),
])
.expect_err("duplicate key should error");
assert!(
err.to_string()
.contains("key 'private_key' supplied more than once")
);
}
#[test]
fn parse_credential_expiry_pairs_accepts_epoch_millis_and_rfc3339() {
let parsed = parse_credential_expiry_pairs(&[
"API_TOKEN=1767225600000".to_string(),
"MS_GRAPH_ACCESS_TOKEN=2026-01-01T00:00:00Z".to_string(),
])
.expect("parse");
assert_eq!(parsed.get("API_TOKEN"), Some(&1_767_225_600_000));
assert_eq!(
parsed.get("MS_GRAPH_ACCESS_TOKEN"),
Some(&1_767_225_600_000)
);
}
#[test]
fn parse_credential_expiry_pairs_accepts_zero_to_clear_expiry() {
let parsed =
parse_credential_expiry_pairs(&["API_TOKEN=0".to_string()]).expect("parse zero");
assert_eq!(parsed.get("API_TOKEN"), Some(&0));
}
#[test]
fn parse_credential_expiry_rejects_invalid_timestamp() {
let err = parse_credential_expiry_pairs(&["API_TOKEN=next-week".to_string()])
.expect_err("invalid timestamp should error");
assert!(
err.to_string()
.contains("must be a Unix epoch millisecond timestamp or RFC3339 timestamp")
);
}
#[test]
fn parse_credential_expiry_cli_value_accepts_rfc3339_offsets() {
let parsed = parse_credential_expiry_cli_value("2026-01-01T01:00:00+01:00")
.expect("parse RFC3339 with offset");
assert_eq!(parsed, 1_767_225_600_000);
}
#[test]
fn progress_step_metadata_values_map_to_cli_steps() {
assert_eq!(
progress_step_from_metadata(PROGRESS_STEP_REQUESTING_SANDBOX),
Some(ProvisioningStep::RequestingSandbox)
);
assert_eq!(
progress_step_from_metadata(PROGRESS_STEP_PULLING_IMAGE),
Some(ProvisioningStep::PullingSandboxImage)
);
assert_eq!(
progress_step_from_metadata(PROGRESS_STEP_STARTING_SANDBOX),
Some(ProvisioningStep::StartingSandbox)
);
assert_eq!(progress_step_from_metadata("driver-private-step"), None);
}
#[test]
fn parse_cli_setting_value_parses_bool_aliases() {
let yes_value = parse_cli_setting_value("ocsf_json_enabled", "yes").expect("parse yes");
assert_eq!(
yes_value.value,
Some(openshell_core::proto::setting_value::Value::BoolValue(true))
);
let zero_value = parse_cli_setting_value("ocsf_json_enabled", "0").expect("parse 0");
assert_eq!(
zero_value.value,
Some(openshell_core::proto::setting_value::Value::BoolValue(
false
))
);
}
#[test]
fn parse_cli_setting_value_rejects_invalid_bool() {
let err = parse_cli_setting_value("ocsf_json_enabled", "maybe")
.expect_err("invalid bool should fail");
assert!(err.to_string().contains("invalid bool value"));
}
#[test]
fn parse_cli_setting_value_rejects_unknown_key() {
let err =
parse_cli_setting_value("unknown_key", "value").expect_err("unknown key should fail");
assert!(err.to_string().contains("unknown setting key"));
}
#[test]
fn build_sandbox_resource_limits_sets_limits_only() {
let resources = build_sandbox_resource_limits(Some("500m"), Some("2Gi"))
.expect("resource limits should parse")
.expect("resource limits should be present");
let limits = resources
.fields
.get("limits")
.and_then(|value| value.kind.as_ref())
.and_then(|kind| match kind {
prost_types::value::Kind::StructValue(inner) => Some(inner),
_ => None,
})
.expect("limits should be a struct");
assert_eq!(
limits
.fields
.get("cpu")
.and_then(|value| value.kind.as_ref())
.and_then(|kind| match kind {
prost_types::value::Kind::StringValue(value) => Some(value.as_str()),
_ => None,
}),
Some("500m")
);
assert_eq!(
limits
.fields
.get("memory")
.and_then(|value| value.kind.as_ref())
.and_then(|kind| match kind {
prost_types::value::Kind::StringValue(value) => Some(value.as_str()),
_ => None,
}),
Some("2Gi")
);
assert!(!resources.fields.contains_key("requests"));
}
#[test]
fn build_sandbox_resource_limits_rejects_invalid_quantities() {
assert!(build_sandbox_resource_limits(Some("0"), None).is_err());
assert!(build_sandbox_resource_limits(Some("half"), None).is_err());
assert!(build_sandbox_resource_limits(None, Some("0Gi")).is_err());
assert!(build_sandbox_resource_limits(None, Some("1.5Gi")).is_err());
}
#[test]
fn parse_driver_config_json_accepts_driver_keyed_object() {
let config =
parse_driver_config_json(r#"{"kubernetes":{"pod":{"node_selector":{"pool":"gpu"}}}}"#)
.expect("driver config should parse");
let kubernetes = config
.fields
.get("kubernetes")
.and_then(|value| value.kind.as_ref())
.and_then(|kind| match kind {
prost_types::value::Kind::StructValue(inner) => Some(inner),
_ => None,
})
.expect("kubernetes block should be a struct");
let pod = kubernetes
.fields
.get("pod")
.and_then(|value| value.kind.as_ref())
.and_then(|kind| match kind {
prost_types::value::Kind::StructValue(inner) => Some(inner),
_ => None,
})
.expect("pod block should be a struct");
assert!(pod.fields.contains_key("node_selector"));
}
#[test]
fn parse_driver_config_json_rejects_non_object() {
let err = parse_driver_config_json(r#"["kubernetes"]"#)
.expect_err("top-level array should be rejected");
assert!(
err.to_string().contains("keyed by driver name"),
"unexpected error: {err}"
);
}
#[test]
fn parse_driver_config_json_rejects_invalid_json() {
let err = parse_driver_config_json(r#"{"kubernetes":"#)
.expect_err("invalid JSON should be rejected");
assert!(
err.to_string().contains("must be valid JSON"),
"unexpected error: {err}"
);
}
#[test]
fn sandbox_should_persist_defaults_to_persistent() {
assert!(sandbox_should_persist(true, None, None));
}
#[test]
fn sandbox_should_not_persist_when_no_keep_is_set() {
assert!(!sandbox_should_persist(false, None, None));
}
#[test]
fn sandbox_should_persist_when_forward_is_requested() {
let spec = openshell_core::forward::ForwardSpec::new(8080);
assert!(sandbox_should_persist(false, Some(&spec), None));
}
#[test]
fn sandbox_should_persist_when_service_exposure_is_requested() {
assert!(sandbox_should_persist(false, None, Some(8080)));
}
#[test]
fn infrastructure_error_with_observed_exit_is_not_a_main_process_result() {
let mut sandbox = Sandbox {
status: Some(SandboxStatus {
exit_code: Some(137),
conditions: vec![SandboxCondition {
r#type: "Ready".to_string(),
status: "False".to_string(),
reason: "ComputeResourceMissing".to_string(),
message: "sandbox runtime disappeared".to_string(),
..Default::default()
}],
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Error as i32);
assert!(!has_main_process_result(&sandbox));
}
#[test]
fn main_process_failed_condition_identifies_command_result() {
let mut sandbox = Sandbox {
status: Some(SandboxStatus {
exit_code: Some(7),
conditions: vec![SandboxCondition {
r#type: "Ready".to_string(),
status: "False".to_string(),
reason: "MainProcessFailed".to_string(),
message: "canonical main process exited with status 7".to_string(),
..Default::default()
}],
..Default::default()
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Error as i32);
assert!(has_main_process_result(&sandbox));
}
#[test]
fn resolve_from_rejects_existing_dockerfile_path() {
let temp = tempfile::tempdir().expect("failed to create tempdir");
let dockerfile = temp.path().join("Dockerfile");
fs::write(&dockerfile, "FROM scratch\n").expect("failed to write Dockerfile");
let err = resolve_from(dockerfile.to_str().expect("temp path is not UTF-8"))
.expect_err("expected local Dockerfile path to be rejected");
assert!(
err.to_string()
.contains("no longer builds local Dockerfiles or directories"),
"unexpected error: {err}"
);
assert!(
err.to_string().contains("docker build -t <image>"),
"expected actionable build guidance: {err}"
);
assert!(
err.to_string().contains("podman build -t <image>"),
"expected Podman build guidance: {err}"
);
}
#[test]
fn resolve_from_rejects_missing_explicit_local_path() {
let temp = tempfile::tempdir().expect("failed to create tempdir");
let missing = temp.path().join("Dockerfile");
let err = resolve_from(missing.to_str().expect("temp path is not UTF-8"))
.expect_err("expected missing explicit local path to be rejected");
assert!(
err.to_string()
.contains("no longer builds local Dockerfiles or directories"),
"unexpected error: {err}"
);
}
#[test]
fn resolve_from_rejects_bare_dockerfile_name() {
let err = resolve_from("Dockerfile").expect_err("expected bare Dockerfile to be rejected");
assert!(
err.to_string()
.contains("no longer builds local Dockerfiles or directories"),
"unexpected error: {err}"
);
}
#[test]
fn resolve_from_keeps_bare_image_reference_when_local_directory_matches() {
let _lock = TEST_ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let temp = tempfile::tempdir().expect("create tempdir");
fs::create_dir(temp.path().join("python")).expect("create matching directory");
let original_dir = std::env::current_dir().expect("read current directory");
std::env::set_current_dir(temp.path()).expect("enter tempdir");
let result = resolve_from("python");
std::env::set_current_dir(original_dir).expect("restore current directory");
match result.expect("bare image reference should not be a local path") {
super::ResolvedSource::Image(image) => assert_eq!(image, "python"),
other @ super::ResolvedSource::RootfsTar { .. } => {
panic!("expected image source, got {other:?}");
}
}
}
#[test]
fn resolve_from_keeps_dockerfile_named_image_refs_as_images() {
let image_ref = "ghcr.io/acme/dockerfile-runner:latest";
match resolve_from(image_ref).expect("expected image source") {
super::ResolvedSource::Image(image) => assert_eq!(image, image_ref),
other @ super::ResolvedSource::RootfsTar { .. } => {
panic!("expected image ref, got {other:?}");
}
}
}
#[test]
fn resolve_from_classifies_tar_archive() {
let temp = tempfile::tempdir().expect("failed to create tempdir");
let archive = temp.path().join("rootfs.tar");
fs::write(&archive, b"fake tar content").expect("failed to write archive");
match resolve_from(archive.to_str().expect("temp path is not UTF-8"))
.expect("expected RootfsTar source")
{
super::ResolvedSource::RootfsTar { path } => {
assert_eq!(
path,
archive
.canonicalize()
.expect("failed to canonicalize archive")
);
}
other @ super::ResolvedSource::Image(_) => {
panic!("expected RootfsTar source, got {other:?}");
}
}
}
#[test]
fn resolve_from_classifies_tar_gz_archive() {
let temp = tempfile::tempdir().expect("failed to create tempdir");
let archive = temp.path().join("rootfs.tar.gz");
fs::write(&archive, b"fake tar.gz content").expect("failed to write archive");
match resolve_from(archive.to_str().expect("temp path is not UTF-8"))
.expect("expected RootfsTar source")
{
super::ResolvedSource::RootfsTar { path } => {
assert_eq!(
path,
archive
.canonicalize()
.expect("failed to canonicalize archive")
);
}
other @ super::ResolvedSource::Image(_) => {
panic!("expected RootfsTar source, got {other:?}");
}
}
}
#[test]
fn resolve_from_classifies_tgz_archive() {
let temp = tempfile::tempdir().expect("failed to create tempdir");
let archive = temp.path().join("rootfs.tgz");
fs::write(&archive, b"fake tgz content").expect("failed to write archive");
match resolve_from(archive.to_str().expect("temp path is not UTF-8"))
.expect("expected RootfsTar source")
{
super::ResolvedSource::RootfsTar { path } => {
assert_eq!(
path,
archive
.canonicalize()
.expect("failed to canonicalize archive")
);
}
other @ super::ResolvedSource::Image(_) => {
panic!("expected RootfsTar source, got {other:?}");
}
}
}
#[test]
fn resolve_from_rejects_missing_tar_archive() {
let temp = tempfile::tempdir().expect("failed to create tempdir");
let missing = temp.path().join("missing.tar");
let err = resolve_from(missing.to_str().expect("temp path is not UTF-8"))
.expect_err("expected missing archive to be rejected");
assert!(
err.to_string().contains("local --from path does not exist"),
"unexpected error: {err}"
);
}
#[test]
fn filename_looks_like_rootfs_tar_detects_extensions() {
use super::filename_looks_like_rootfs_tar;
assert!(filename_looks_like_rootfs_tar(Path::new("rootfs.tar")));
assert!(filename_looks_like_rootfs_tar(Path::new("rootfs.tar.gz")));
assert!(filename_looks_like_rootfs_tar(Path::new("rootfs.tgz")));
assert!(filename_looks_like_rootfs_tar(Path::new("IMAGE.TAR")));
assert!(filename_looks_like_rootfs_tar(Path::new("my-image.TAR.GZ")));
assert!(!filename_looks_like_rootfs_tar(Path::new("Dockerfile")));
assert!(!filename_looks_like_rootfs_tar(Path::new("image.zip")));
}
/// The gateway forwards only `template.driver_config.<driver_name>` to the
/// selected driver, so a top-level key is silently dropped and the archive
/// never reaches the VM driver.
#[test]
fn rootfs_tar_driver_config_nests_under_vm_key() {
use prost_types::value::Kind;
let config =
super::merge_rootfs_tar_driver_config(None, "tok-abc").expect("merge should succeed");
assert_eq!(
config.fields.keys().collect::<Vec<_>>(),
vec!["vm"],
"rootfs tar config must live under the vm driver key"
);
let Some(Kind::StructValue(vm)) = config.fields["vm"].kind.as_ref() else {
panic!("vm entry must be an object");
};
let Some(Kind::StringValue(token)) = vm.fields["rootfs_tar_staging_token"].kind.as_ref()
else {
panic!("rootfs_tar_staging_token must be a string");
};
assert_eq!(token, "tok-abc");
assert!(
!vm.fields.contains_key("rootfs_tar_path"),
"the CLI never names a host path; the gateway resolves one"
);
}
#[test]
fn rootfs_tar_driver_config_preserves_existing_vm_settings() {
use prost_types::value::Kind;
let base = parse_driver_config_json(
r#"{"vm":{"gpu_device_ids":["0000:2d:00.0"]},"docker":{"userns":"host"}}"#,
)
.expect("valid driver config json");
let config = super::merge_rootfs_tar_driver_config(Some(base), "tok-abc")
.expect("merge should succeed");
// The sibling driver block survives untouched.
assert!(config.fields.contains_key("docker"));
let Some(Kind::StructValue(vm)) = config.fields["vm"].kind.as_ref() else {
panic!("vm entry must be an object");
};
assert!(
vm.fields.contains_key("gpu_device_ids"),
"pre-existing vm settings must not be clobbered"
);
let Some(Kind::StringValue(token)) = vm.fields["rootfs_tar_staging_token"].kind.as_ref()
else {
panic!("rootfs_tar_staging_token must be a string");
};
assert_eq!(token, "tok-abc");
}
#[test]
fn rootfs_tar_driver_config_rejects_caller_supplied_token() {
let base = parse_driver_config_json(r#"{"vm":{"rootfs_tar_staging_token":"stolen"}}"#)
.expect("valid driver config json");
let err = super::merge_rootfs_tar_driver_config(Some(base), "tok-abc")
.expect_err("a caller-supplied staging token must not be silently overwritten");
assert!(
err.to_string()
.contains("already sets vm.rootfs_tar_staging_token"),
"unexpected error: {err}"
);
}
#[test]
fn rootfs_tar_driver_config_rejects_non_object_vm_block() {
let base = parse_driver_config_json(r#"{"vm":"nonsense"}"#).expect("valid json object");
let err = super::merge_rootfs_tar_driver_config(Some(base), "tok-abc")
.expect_err("a non-object vm block must be rejected");
assert!(
err.to_string().contains("must be an object"),
"unexpected error: {err}"
);
}
#[test]
fn rootfs_tar_sources_are_rejected_for_remote_gateways() {
let metadata = GatewayMetadata {
name: "remote".to_string(),
gateway_endpoint: "https://gateway.example.com".to_string(),
is_remote: true,
gateway_port: 443,
remote_host: Some("user@gateway.example.com".to_string()),
resolved_host: Some("gateway.example.com".to_string()),
auth_mode: None,
edge_team_domain: None,
edge_auth_url: None,
vm_driver_state_dir: None,
..Default::default()
};
assert!(!rootfs_tar_sources_supported_for_gateway(Some(&metadata)));
}
#[test]
fn rootfs_tar_sources_are_allowed_for_local_gateways() {
let metadata = GatewayMetadata {
name: "local".to_string(),
gateway_endpoint: "http://127.0.0.1:8080".to_string(),
is_remote: false,
gateway_port: 8080,
remote_host: None,
resolved_host: None,
auth_mode: None,
edge_team_domain: None,
edge_auth_url: None,
vm_driver_state_dir: None,
..Default::default()
};
assert!(rootfs_tar_sources_supported_for_gateway(Some(&metadata)));
assert!(rootfs_tar_sources_supported_for_gateway(None));
}
#[test]
fn service_url_for_gateway_uses_external_gateway_port() {
assert_eq!(
service_url_for_gateway(
"https://quiet-flamingo--notebook.navigator.openshell.localhost:8080/",
"https://127.0.0.1:31886"
),
"https://quiet-flamingo--notebook.navigator.openshell.localhost:31886/"
);
}
#[test]
fn service_url_for_gateway_omits_default_external_port() {
assert_eq!(
service_url_for_gateway(
"https://quiet-flamingo--notebook.navigator.openshell.localhost:8080/",
"https://gateway.example.com"
),
"https://quiet-flamingo--notebook.navigator.openshell.localhost/"
);
}
#[test]
fn service_url_for_gateway_preserves_service_scheme() {
assert_eq!(
service_url_for_gateway(
"http://quiet-flamingo--notebook.navigator.openshell.localhost:8080/",
"https://127.0.0.1:31886"
),
"http://quiet-flamingo--notebook.navigator.openshell.localhost:31886/"
);
}
#[test]
fn service_url_for_gateway_uses_gateway_default_port() {
assert_eq!(
service_url_for_gateway(
"http://quiet-flamingo--notebook.navigator.openshell.localhost:8080/",
"https://gateway.example.com"
),
"http://quiet-flamingo--notebook.navigator.openshell.localhost:443/"
);
}
#[test]
fn service_expose_status_error_mentions_required_scope() {
let report = service_expose_status_error(Status::permission_denied(
"scope 'sandbox:write' required",
));
assert_eq!(
report.to_string(),
"expose service failed: permission denied (requires sandbox:write)"
);
}
#[test]
fn ready_false_condition_message_prefers_reason_and_message() {
let status = SandboxStatus {
agent_pod: "gpu-pod".to_string(),
conditions: vec![SandboxCondition {
r#type: "Ready".to_string(),
status: "False".to_string(),
reason: "Unschedulable".to_string(),
message: "Another GPU sandbox may already be using the available GPU.".to_string(),
transition_time: None,
}],
..Default::default()
};
assert_eq!(
ready_false_condition_message(Some(&status)).as_deref(),
Some("Unschedulable: Another GPU sandbox may already be using the available GPU.")
);
}
#[test]
fn ready_false_condition_message_ignores_non_ready_conditions() {
let status = SandboxStatus {
agent_pod: "gpu-pod".to_string(),
conditions: vec![SandboxCondition {
r#type: "Scheduled".to_string(),
status: "True".to_string(),
reason: "Scheduled".to_string(),
message: "Sandbox scheduled".to_string(),
transition_time: None,
}],
..Default::default()
};
assert!(ready_false_condition_message(Some(&status)).is_none());
}
#[test]
fn provisioning_timeout_message_includes_condition_and_gpu_hint() {
let resource_requirements = ResourceRequirements {
gpu: Some(GpuResourceRequirements { count: None }),
};
let message = provisioning_timeout_message(
120,
Some(&resource_requirements),
Some("DependenciesNotReady: Pod exists with phase: Pending; Service Exists"),
);
assert!(message.contains("sandbox provisioning timed out after 120s"));
assert!(message.contains("Last reported status: DependenciesNotReady: Pod exists with phase: Pending; Service Exists"));
assert!(message.contains("available GPU is already in use by another sandbox"));
}
#[test]
fn provisioning_timeout_message_omits_gpu_hint_for_non_gpu_requests() {
let message = provisioning_timeout_message(120, None, None);
assert_eq!(message, "sandbox provisioning timed out after 120s");
}
#[test]
fn provisioning_timeout_message_omits_gpu_hint_without_gpu_requirements() {
let resource_requirements = ResourceRequirements { gpu: None };
let message = provisioning_timeout_message(120, Some(&resource_requirements), None);
assert_eq!(message, "sandbox provisioning timed out after 120s");
}
fn init_git_repo(path: &Path) {
let mut command = Command::new("git");
super::scrub_git_env(&mut command);
let status = command
.args(["init"])
.current_dir(path)
.status()
.expect("git init");
assert!(status.success(), "git init should succeed");
}
#[test]
fn git_sync_files_scopes_single_file_to_requested_path() {
let tmpdir = tempfile::tempdir().expect("create tmpdir");
let repo = tmpdir.path().join("repo");
fs::create_dir_all(repo.join("nested")).expect("create repo");
init_git_repo(&repo);
fs::write(repo.join("tracked.txt"), "tracked").expect("write tracked.txt");
fs::write(repo.join("nested/other.txt"), "other").expect("write other.txt");
let result = git_sync_files(&repo.join("tracked.txt"));
let (base_dir, files) = result.expect("git_sync_files should succeed");
assert_eq!(
base_dir,
fs::canonicalize(&repo).expect("canonicalize repo path")
);
assert_eq!(files, vec!["tracked.txt"]);
}
#[test]
fn git_sync_files_scopes_directory_to_requested_subtree() {
let tmpdir = tempfile::tempdir().expect("create tmpdir");
let repo = tmpdir.path().join("repo");
fs::create_dir_all(repo.join("nested/inner")).expect("create repo");
init_git_repo(&repo);
fs::write(repo.join("nested/file.txt"), "file").expect("write file.txt");
fs::write(repo.join("nested/inner/child.txt"), "child").expect("write child.txt");
fs::write(repo.join("top.txt"), "top").expect("write top.txt");
let result = git_sync_files(&repo.join("nested"));
let (base_dir, mut files) = result.expect("git_sync_files should succeed");
files.sort();
assert_eq!(
base_dir,
fs::canonicalize(repo.join("nested")).expect("canonicalize nested path")
);
assert_eq!(files, vec!["file.txt", "inner/child.txt"]);
}
#[test]
fn sandbox_upload_plan_errors_for_missing_local_path() {
let tmpdir = tempfile::tempdir().expect("create tmpdir");
let missing = tmpdir.path().join("missing");
let err = sandbox_upload_plan(&missing, false).expect_err("missing path should error");
assert!(
err.to_string().contains("local path does not exist"),
"expected missing-path error, got: {err}"
);
}
#[test]
fn sandbox_upload_plan_errors_for_missing_local_path_with_git_ignore() {
let tmpdir = tempfile::tempdir().expect("create tmpdir");
let repo = tmpdir.path().join("repo");
fs::create_dir_all(&repo).expect("create repo");
init_git_repo(&repo);
let missing = repo.join("missing");
let err = sandbox_upload_plan(&missing, true).expect_err("missing path should error");
assert!(
err.to_string().contains("local path does not exist"),
"expected missing-path error, got: {err}"
);
}
#[cfg(unix)]
#[test]
fn sandbox_upload_plan_uses_regular_upload_for_symlinks() {
let tmpdir = tempfile::tempdir().expect("create tmpdir");
let repo = tmpdir.path().join("repo");
fs::create_dir_all(repo.join("real-dir")).expect("create repo");
init_git_repo(&repo);
fs::write(repo.join("real-dir/file.txt"), "file").expect("write file.txt");
std::os::unix::fs::symlink("real-dir", repo.join("link-dir")).expect("create symlink");
let plan = sandbox_upload_plan(&repo.join("link-dir"), true)
.expect("symlink upload should be planned");
assert_eq!(plan, super::SandboxUploadPlan::Regular);
}
#[cfg(unix)]
#[test]
fn sandbox_upload_plan_accepts_dangling_symlinks() {
let tmpdir = tempfile::tempdir().expect("create tmpdir");
let link = tmpdir.path().join("dangling-link");
std::os::unix::fs::symlink("missing-target", &link).expect("create symlink");
let plan =
sandbox_upload_plan(&link, true).expect("dangling symlink upload should be planned");
assert_eq!(plan, super::SandboxUploadPlan::Regular);
}
#[test]
fn sandbox_upload_plan_falls_back_when_all_files_gitignored() {
let tmpdir = tempfile::tempdir().expect("create tmpdir");
let repo = tmpdir.path().join("repo");
fs::create_dir_all(repo.join("runs")).expect("create repo");
init_git_repo(&repo);
fs::write(repo.join(".gitignore"), "runs/\n").expect("write .gitignore");
fs::write(repo.join("runs/test.json"), r#"{"key":"value"}"#).expect("write test.json");
let plan =
sandbox_upload_plan(&repo.join("runs"), true).expect("upload plan should succeed");
assert_eq!(
plan,
super::SandboxUploadPlan::GitFilteredEmpty,
"gitignored directory should fall back with GitFilteredEmpty"
);
}
#[test]
fn git_sync_files_ignores_inherited_git_env() {
let _lock = TEST_ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let tmpdir = tempfile::tempdir().expect("create tmpdir");
let repo = tmpdir.path().join("repo");
fs::create_dir_all(repo.join("nested")).expect("create repo");
init_git_repo(&repo);
fs::write(repo.join("nested/file.txt"), "file").expect("write file.txt");
fs::write(repo.join("top.txt"), "top").expect("write top.txt");
let _git_dir = EnvVarGuard::set("GIT_DIR", "/tmp/not-the-test-repo/.git");
let _git_work_tree = EnvVarGuard::set("GIT_WORK_TREE", "/tmp/not-the-test-repo");
let result = git_sync_files(&repo.join("nested"));
let (base_dir, files) = result.expect("git_sync_files should succeed");
assert_eq!(
base_dir,
fs::canonicalize(repo.join("nested")).expect("canonicalize nested path")
);
assert_eq!(files, vec!["file.txt"]);
}
#[test]
fn format_endpoint_distinguishes_l4_from_l7_rest() {
use openshell_core::proto::{L7Allow, L7DenyRule, L7Rule, NetworkEndpoint};
let l4 = NetworkEndpoint {
host: "host.example.test".to_string(),
port: 443,
..Default::default()
};
assert_eq!(format_endpoint(&l4), "host.example.test:443 [L4]");
let l7_readonly = NetworkEndpoint {
host: "host.example.test".to_string(),
port: 443,
protocol: "rest".to_string(),
access: openshell_core::proto::NetworkAccessPreset::ReadOnly as i32,
..Default::default()
};
assert_eq!(
format_endpoint(&l7_readonly),
"host.example.test:443 [L7 rest, access=read-only]"
);
let l7_scoped = NetworkEndpoint {
host: "host.example.test".to_string(),
port: 443,
protocol: "rest".to_string(),
rules: vec![L7Rule {
allow: Some(L7Allow {
method: "PUT".to_string(),
path: "/v1/example/resource".to_string(),
..Default::default()
}),
}],
deny_rules: vec![L7DenyRule {
method: "DELETE".to_string(),
path: "/v1/example/resource".to_string(),
..Default::default()
}],
..Default::default()
};
assert_eq!(
format_endpoint(&l7_scoped),
"host.example.test:443 [L7 rest, allow PUT /v1/example/resource, deny DELETE /v1/example/resource]"
);
}
#[test]
fn sandbox_template_to_json_includes_metadata_labels_and_annotations() {
let template = SandboxWorkloadTemplate {
metadata: Some(ObjectMeta {
id: "template-123".to_string(),
name: "gpu-kata".to_string(),
labels: std::collections::HashMap::from([(
"team".to_string(),
"runtime".to_string(),
)]),
annotations: std::collections::HashMap::from([(
"owner".to_string(),
"platform".to_string(),
)]),
workspace: "default".to_string(),
..Default::default()
}),
..Default::default()
};
let json = super::sandbox_template_to_json(&template);
assert_eq!(json["labels"]["team"], "runtime");
assert_eq!(json["annotations"]["owner"], "platform");
}
#[test]
fn sandbox_template_to_json_formats_default_gpu_like_display_output() {
let template = SandboxWorkloadTemplate {
spec: Some(SandboxWorkloadTemplateSpec {
workload: Some(SandboxWorkloadConfig {
resources: Some(SandboxResources {
gpu: Some(GpuResourceRequirements { count: None }),
..Default::default()
}),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let json = super::sandbox_template_to_json(&template);
assert_eq!(json["resources"]["gpu"], "default");
}
#[test]
fn sandbox_template_to_json_preserves_explicit_gpu_count_as_number() {
let template = SandboxWorkloadTemplate {
spec: Some(SandboxWorkloadTemplateSpec {
workload: Some(SandboxWorkloadConfig {
resources: Some(SandboxResources {
gpu: Some(GpuResourceRequirements { count: Some(2) }),
..Default::default()
}),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let json = super::sandbox_template_to_json(&template);
assert_eq!(json["resources"]["gpu"], 2);
}
#[test]
fn provisioning_json_exposes_deadline_and_cleanup_separately() {
let mut sandbox = Sandbox::default();
sandbox.set_phase(SandboxPhase::Error.into());
sandbox.status.as_mut().unwrap().provisioning =
Some(openshell_core::proto::SandboxProvisioning {
attempt_id: "attempt".into(),
timeout_time: openshell_core::time::timestamp_from_millis(300_000).ok(),
cleanup_error: "Compute reclamation is pending; the gateway will retry".into(),
..Default::default()
});
let json = super::sandbox_to_json(&sandbox);
assert_eq!(json["provisioning"]["attempt_id"], "attempt");
assert!(json["provisioning"]["deadline"].is_null());
assert!(json["provisioning"]["cleanup_completed_time"].is_null());
assert_eq!(json["provisioning"]["timeout_time"], "1970-01-01T00:05:00Z");
assert!(
json["provisioning"]["cleanup_error"]
.as_str()
.unwrap()
.contains("pending")
);
}
#[test]
fn sandbox_json_exposes_repair_diagnostic_and_accepted_generation() {
use openshell_core::proto::{ConfigurationAdmissionState, SandboxConfigurationAdmission};
let mut sandbox = Sandbox::default();
sandbox.set_phase(SandboxPhase::Provisioning as i32);
let status = sandbox.status.as_mut().unwrap();
status.configuration_admission = Some(SandboxConfigurationAdmission {
state: ConfigurationAdmissionState::Rejected as i32,
error: "rule image_api requires L7 inspection".to_string(),
policy_hash: "candidate-hash".to_string(),
..Default::default()
});
status.conditions.push(SandboxCondition {
r#type: "ConfigurationReady".to_string(),
status: "False".to_string(),
reason: "ConfigurationInvalid".to_string(),
message: "rule image_api requires L7 inspection".to_string(),
..Default::default()
});
let json = super::sandbox_to_json(&sandbox);
assert_eq!(json["configuration_admission"]["state"], "rejected");
assert_eq!(json["conditions"][0]["reason"], "ConfigurationInvalid");
assert_eq!(
super::configuration_failure_message(&sandbox),
Some("rule image_api requires L7 inspection")
);
sandbox
.status
.as_mut()
.unwrap()
.configuration_admission
.as_mut()
.unwrap()
.error
.clear();
assert_eq!(super::configuration_failure_message(&sandbox), None);
}
#[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_time: openshell_core::time::timestamp_from_millis(1_609_459_200_000).ok(),
..Default::default()
}),
created_from_workload_template: Some(SandboxWorkloadTemplateProvenance {
name: "gpu-kata".to_string(),
resource_version: "7".to_string(),
}),
..Default::default()
};
sandbox.set_phase(SandboxPhase::Ready as i32);
sandbox.set_current_policy_version(2);
sandbox.spec = Some(SandboxSpec {
restart_policy: SandboxRestartPolicy::OnFailure as i32,
..Default::default()
});
sandbox.status = Some(SandboxStatus {
phase: SandboxPhase::Starting as i32,
main_process_instance_id: "main-2".to_string(),
exit_code: Some(9),
restart_count: 2,
next_restart_time: openshell_core::time::timestamp_from_millis(1_700_000_000_000).ok(),
main_process_started_time: openshell_core::time::timestamp_from_millis(
1_699_999_000_000,
)
.ok(),
..Default::default()
});
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"], "Starting");
assert_eq!(json["restart_policy"], "on-failure");
assert_eq!(json["main_process_instance_id"], "main-2");
assert_eq!(json["exit_code"], 9);
assert_eq!(json["restart_count"], 2);
assert_eq!(json["next_restart_at_ms"], 1_700_000_000_000_i64);
assert_eq!(json["main_process_started_at_ms"], 1_699_999_000_000_i64);
assert_eq!(json["policy_source"], "global");
assert_eq!(json["revision"], 3);
assert!(json["policy"].is_null());
assert_eq!(json["created_from_workload_template"]["name"], "gpu-kata");
assert_eq!(
json["created_from_workload_template"]["resource_version"],
"7"
);
}
#[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 sandbox_detail_keeps_failed_endpoint_separate_from_ready_conditions_in_json_and_yaml() {
let sandbox = Sandbox {
status: Some(SandboxStatus {
phase: SandboxPhase::Ready as i32,
endpoint_statuses: vec![EndpointStatus {
endpoint_id: "endpoint:example".to_string(),
host: "tools.example.test".to_string(),
ports: vec![443, 8443],
path: "/mcp".to_string(),
last_result: EndpointResult::TransportFailed as i32,
last_reported_time: Some("2026-09-05T10:01:00Z".parse().unwrap()),
}],
conditions: vec![SandboxCondition {
r#type: "Ready".to_string(),
status: "True".to_string(),
reason: "DependenciesReady".to_string(),
message: "Supervisor session connected".to_string(),
transition_time: "2026-09-05T10:00:00Z".parse().ok(),
}],
..Default::default()
}),
..Default::default()
};
let json = super::sandbox_detail_to_json(
&sandbox,
&GetSandboxConfigResponse {
policy_source: PolicySource::Sandbox as i32,
..Default::default()
},
)
.unwrap();
// Both formats preserve the complete endpoint record and independent
// lifecycle condition without requiring a join.
let yaml = serde_yml::to_string(&json).unwrap();
let from_yaml: serde_json::Value = serde_yml::from_str(&yaml).unwrap();
for output in [json, from_yaml] {
assert_eq!(output["phase"], "Ready");
assert_eq!(
output["conditions"],
serde_json::json!([{
"type": "Ready",
"status": "True",
"reason": "DependenciesReady",
"message": "Supervisor session connected",
"last_transition_time": "2026-09-05T10:00:00Z",
}])
);
assert_eq!(
output["endpoint_statuses"],
serde_json::json!([{
"endpoint_id": "endpoint:example",
"host": "tools.example.test",
"ports": [443, 8443],
"path": "/mcp",
"last_result": "TransportFailed",
"last_reported_at": "2026-09-05T10:01:00Z",
}])
);
}
}
#[test]
fn endpoint_status_display_explains_failure_and_report_time() {
let endpoint = EndpointStatus {
endpoint_id: "endpoint:example".to_string(),
host: "tools.example.test".to_string(),
ports: vec![443, 8443],
path: "/mcp".to_string(),
last_result: EndpointResult::TransportFailed as i32,
last_reported_time: Some("2026-09-05T11:01:00Z".parse().unwrap()),
};
assert_eq!(
super::endpoint_status_display_lines(&endpoint),
vec![
"tools.example.test (ports: 443, 8443; path: /mcp)",
"Last result: Connection failed before an HTTP response arrived.",
"Reported at (gateway acceptance): 2026-09-05T11:01:00Z",
]
);
}
#[test]
fn endpoint_status_preserves_address_without_an_observation() {
let endpoint = EndpointStatus {
endpoint_id: "endpoint:reset".to_string(),
host: "tools.example.test".to_string(),
ports: vec![443],
path: "/**".to_string(),
last_result: EndpointResult::NoObservedExchange as i32,
last_reported_time: None,
};
assert_eq!(
super::endpoint_status_display_lines(&endpoint),
vec![
"tools.example.test (ports: 443; path: /**)",
"Last result: No exchange observed.",
"Reported at (gateway acceptance): no report yet",
]
);
let sandbox = Sandbox {
status: Some(SandboxStatus {
endpoint_statuses: vec![endpoint],
..Default::default()
}),
..Default::default()
};
let output = super::sandbox_to_json(&sandbox);
assert_eq!(
output["endpoint_statuses"],
serde_json::json!([{
"endpoint_id": "endpoint:reset",
"host": "tools.example.test",
"ports": [443],
"path": "/**",
"last_result": "NoObservedExchange",
"last_reported_at": "",
}])
);
assert_eq!(output["conditions"], serde_json::json!([]));
}
#[test]
fn endpoint_status_http_response_does_not_claim_a_successful_tool_call() {
let endpoint = EndpointStatus {
host: "tools.example.test".to_string(),
ports: vec![443],
path: "/mcp".to_string(),
last_result: EndpointResult::HttpResponseReceived as i32,
last_reported_time: Some("2026-09-05T11:01:00Z".parse().unwrap()),
..Default::default()
};
assert_eq!(
super::endpoint_status_display_lines(&endpoint)[1],
"Last result: Server returned an HTTP response below 400."
);
}
#[test]
fn endpoint_status_structured_output_uses_stable_result_names() {
for (result, name) in [
(EndpointResult::Unspecified, "Unspecified"),
(EndpointResult::NoObservedExchange, "NoObservedExchange"),
(EndpointResult::HttpResponseReceived, "HttpResponseReceived"),
(EndpointResult::PolicyDenied, "PolicyDenied"),
(
EndpointResult::CredentialUnavailable,
"CredentialUnavailable",
),
(EndpointResult::TlsFailed, "TlsFailed"),
(EndpointResult::TransportFailed, "TransportFailed"),
(EndpointResult::UpstreamRejected, "UpstreamRejected"),
] {
let sandbox = Sandbox {
status: Some(SandboxStatus {
endpoint_statuses: vec![EndpointStatus {
last_result: result as i32,
..Default::default()
}],
..Default::default()
}),
..Default::default()
};
assert_eq!(
super::sandbox_to_json(&sandbox)["endpoint_statuses"][0]["last_result"],
name
);
}
}
#[test]
fn sandbox_condition_display_leaves_lifecycle_conditions_unchanged() {
let ordinary = SandboxCondition {
r#type: "Ready".to_string(),
status: "True".to_string(),
reason: "DependenciesReady".to_string(),
message: "Supervisor session connected".to_string(),
transition_time: None,
};
assert_eq!(
super::sandbox_condition_display_lines(&ordinary),
vec!["Ready: True (DependenciesReady) - Supervisor session connected".to_string()]
);
}
fn log_line(
level: &str,
target: &str,
message: &str,
source: &str,
fields: &[(&str, &str)],
) -> openshell_core::proto::SandboxLogLine {
openshell_core::proto::SandboxLogLine {
sandbox_id: "sb-1".to_string(),
event_time: openshell_core::time::timestamp_from_millis(1_234_567).ok(),
level: level.to_string(),
target: target.to_string(),
message: message.to_string(),
source: source.to_string(),
fields: fields
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect(),
}
}
#[test]
fn format_log_line_without_fields() {
let log = log_line("INFO", "openshell_server", "hello world", "sandbox", &[]);
assert_eq!(
format_log_line(&log),
"[1234.567] [sandbox] [INFO ] [openshell_server] hello world"
);
}
#[test]
fn format_log_line_empty_source_defaults_to_gateway() {
let log = log_line("WARN", "t", "msg", "", &[]);
assert_eq!(
format_log_line(&log),
"[1234.567] [gateway] [WARN ] [t] msg"
);
}
#[test]
fn format_log_line_pads_source_and_level() {
let log = log_line("OCSF", "ocsf", "NET:OPEN", "gateway", &[]);
assert_eq!(
format_log_line(&log),
"[1234.567] [gateway] [OCSF ] [ocsf] NET:OPEN"
);
let short_source = log_line("ERROR", "t", "m", "vm", &[]);
assert_eq!(
format_log_line(&short_source),
"[1234.567] [vm ] [ERROR] [t] m"
);
}
#[test]
fn format_log_line_sorts_fields_alphabetically() {
let log = log_line(
"INFO",
"ocsf",
"CONNECT",
"sandbox",
&[("dst_port", "443"), ("action", "allow"), ("dst_host", "x")],
);
assert_eq!(
format_log_line(&log),
"[1234.567] [sandbox] [INFO ] [ocsf] CONNECT action=allow dst_host=x dst_port=443"
);
}
#[test]
fn format_log_line_keeps_empty_field_values() {
let log = log_line("INFO", "t", "m", "sandbox", &[("a", ""), ("b", "1")]);
assert_eq!(
format_log_line(&log),
"[1234.567] [sandbox] [INFO ] [t] m a= b=1"
);
}
#[test]
fn format_log_line_renders_empty_target_as_empty_brackets() {
let log = log_line("INFO", "", "m", "sandbox", &[]);
assert_eq!(format_log_line(&log), "[1234.567] [sandbox] [INFO ] [] m");
}
#[test]
fn format_log_line_zero_pads_millis() {
let mut log = log_line("INFO", "t", "m", "sandbox", &[]);
log.event_time = openshell_core::time::timestamp_from_millis(1_000_007).ok();
assert_eq!(format_log_line(&log), "[1000.007] [sandbox] [INFO ] [t] m");
log.event_time = None;
assert_eq!(format_log_line(&log), "[0.000] [sandbox] [INFO ] [t] m");
}
#[test]
fn format_log_line_preserves_message_verbatim() {
// OCSF shorthand must pass through unchanged.
let message = "NET:OPEN [MED] DENIED /usr/bin/curl(4711) -> api.example.com:443";
let log = log_line("OCSF", "ocsf", message, "sandbox", &[]);
assert!(format_log_line(&log).ends_with(message));
}
use std::io::Write as _;
use std::time::{Duration, Instant};
fn exec_stdin_runtime() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime")
}
#[test]
fn piped_stdin_that_closes_quickly_goes_in_one_request() {
let (reader, mut writer) = std::io::pipe().expect("pipe");
writer.write_all(b"hi\n").unwrap();
drop(writer);
let collected = exec_stdin_runtime().block_on(super::collect_piped_stdin(
super::spawn_piped_stdin_reader(reader),
Duration::from_secs(5),
super::MAX_EXEC_STDIN_BYTES,
));
match collected.expect("collect") {
super::PipedStdin::Complete(prefix) => assert_eq!(prefix, b"hi\n"),
super::PipedStdin::Open { .. } => panic!("closed pipe must complete"),
}
}
#[test]
fn piped_stdin_that_stays_open_does_not_block_the_command() {
let (reader, mut writer) = std::io::pipe().expect("pipe");
writer.write_all(b"early").unwrap();
let grace = Duration::from_millis(100);
let started = Instant::now();
let runtime = exec_stdin_runtime();
let collected = runtime.block_on(super::collect_piped_stdin(
super::spawn_piped_stdin_reader(reader),
grace,
super::MAX_EXEC_STDIN_BYTES,
));
let elapsed = started.elapsed();
assert!(elapsed >= grace, "must wait the grace period: {elapsed:?}");
assert!(
elapsed < Duration::from_secs(3),
"must not wait for EOF: {elapsed:?}"
);
let super::PipedStdin::Open { prefix, mut rest } = collected.expect("collect") else {
panic!("an open pipe must start the command before EOF");
};
assert_eq!(prefix, b"early");
// The remainder keeps flowing after the command has started.
writer.write_all(b"late").unwrap();
drop(writer);
let next = runtime
.block_on(rest.recv())
.expect("late chunk")
.expect("read");
assert_eq!(next, b"late");
assert!(
runtime.block_on(rest.recv()).is_none(),
"EOF closes the channel"
);
}
#[test]
fn piped_stdin_over_the_limit_is_rejected_with_the_upload_hint() {
let (reader, mut writer) = std::io::pipe().expect("pipe");
writer.write_all(&[0u8; 8]).unwrap();
drop(writer);
let error = exec_stdin_runtime()
.block_on(super::collect_piped_stdin(
super::spawn_piped_stdin_reader(reader),
Duration::from_secs(5),
4,
))
.err()
.expect("over the limit must fail");
assert!(error.to_string().contains("sandbox upload"), "{error}");
}
}