diff --git a/crates/openshell-bootstrap/src/lib.rs b/crates/openshell-bootstrap/src/lib.rs index b6423aae..192b6c3f 100644 --- a/crates/openshell-bootstrap/src/lib.rs +++ b/crates/openshell-bootstrap/src/lib.rs @@ -48,10 +48,10 @@ pub use crate::docker::{ DockerPreflight, ExistingGatewayInfo, check_docker_available, create_ssh_docker_client, }; pub use crate::metadata::{ - GatewayMetadata, clear_active_gateway, extract_host_from_ssh_destination, get_gateway_metadata, - list_gateways, load_active_gateway, load_gateway_metadata, load_last_sandbox, - remove_gateway_metadata, resolve_ssh_hostname, save_active_gateway, save_last_sandbox, - store_gateway_metadata, + GatewayMetadata, clear_active_gateway, clear_last_sandbox, extract_host_from_ssh_destination, + get_gateway_metadata, list_gateways, load_active_gateway, load_gateway_metadata, + load_last_sandbox, remove_gateway_metadata, resolve_ssh_hostname, save_active_gateway, + save_last_sandbox, store_gateway_metadata, }; /// Options for remote SSH deployment. diff --git a/crates/openshell-bootstrap/src/metadata.rs b/crates/openshell-bootstrap/src/metadata.rs index bd49ba8c..5faf3386 100644 --- a/crates/openshell-bootstrap/src/metadata.rs +++ b/crates/openshell-bootstrap/src/metadata.rs @@ -271,6 +271,17 @@ pub fn load_last_sandbox(gateway: &str) -> Option { if name.is_empty() { None } else { Some(name) } } +/// Remove the persisted last-used sandbox for a gateway. +pub fn clear_last_sandbox(gateway: &str) -> Result<()> { + let path = last_sandbox_path(gateway)?; + if path.exists() { + std::fs::remove_file(&path) + .into_diagnostic() + .wrap_err_with(|| format!("failed to remove {}", path.display()))?; + } + Ok(()) +} + /// List all gateways that have stored metadata. /// /// Scans `$XDG_CONFIG_HOME/openshell/gateways/` for subdirectories containing @@ -596,4 +607,19 @@ mod tests { ); }); } + + #[test] + fn clear_last_sandbox_removes_saved_value() { + let tmp = tempfile::tempdir().unwrap(); + with_tmp_xdg(tmp.path(), || { + save_last_sandbox("gateway-a", "sandbox-a").unwrap(); + assert_eq!( + load_last_sandbox("gateway-a"), + Some("sandbox-a".to_string()) + ); + + clear_last_sandbox("gateway-a").unwrap(); + assert_eq!(load_last_sandbox("gateway-a"), None); + }); + } } diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 84a323b5..197a5730 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -2058,7 +2058,7 @@ async fn main() -> Result<()> { run::sandbox_list(endpoint, limit, offset, ids, names, &tls).await?; } SandboxCommands::Delete { names, all } => { - run::sandbox_delete(endpoint, &names, all, &tls).await?; + run::sandbox_delete(endpoint, &ctx.name, &names, all, &tls).await?; } SandboxCommands::Connect { name, editor } => { let name = resolve_sandbox_name(name, &ctx.name)?; diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 37f11fcb..184d141b 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -16,10 +16,10 @@ use hyper_util::{client::legacy::Client, rt::TokioExecutor}; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use miette::{IntoDiagnostic, Result, WrapErr}; use openshell_bootstrap::{ - DeployOptions, GatewayMetadata, RemoteOptions, clear_active_gateway, container_name, - extract_host_from_ssh_destination, get_gateway_metadata, list_gateways, load_active_gateway, - remove_gateway_metadata, resolve_ssh_hostname, save_active_gateway, save_last_sandbox, - store_gateway_metadata, + DeployOptions, GatewayMetadata, RemoteOptions, clear_active_gateway, clear_last_sandbox, + container_name, extract_host_from_ssh_destination, get_gateway_metadata, list_gateways, + load_active_gateway, load_last_sandbox, remove_gateway_metadata, resolve_ssh_hostname, + save_active_gateway, save_last_sandbox, store_gateway_metadata, }; use openshell_core::proto::{ ApproveAllDraftChunksRequest, ApproveDraftChunkRequest, ClearDraftChunksRequest, @@ -1819,6 +1819,7 @@ fn sandbox_should_persist( async fn finalize_sandbox_create_session( server: &str, + gateway_name: &str, sandbox_name: &str, persist: bool, session_result: Result<()>, @@ -1829,7 +1830,7 @@ async fn finalize_sandbox_create_session( } let names = [sandbox_name.to_string()]; - if let Err(err) = sandbox_delete(server, &names, false, tls).await { + if let Err(err) = sandbox_delete(server, gateway_name, &names, false, tls).await { if session_result.is_ok() { return Err(err); } @@ -2295,6 +2296,7 @@ pub async fn sandbox_create( return finalize_sandbox_create_session( &effective_server, + effective_tls.gateway_name().unwrap_or(gateway_name), &sandbox_name, persist, connect_result, @@ -2330,6 +2332,7 @@ pub async fn sandbox_create( finalize_sandbox_create_session( &effective_server, + effective_tls.gateway_name().unwrap_or(gateway_name), &sandbox_name, persist, exec_result, @@ -2740,6 +2743,7 @@ pub async fn sandbox_list( /// Delete a sandbox by name, or all sandboxes when `all` is true. pub async fn sandbox_delete( server: &str, + gateway_name: &str, names: &[String], all: bool, tls: &TlsOptions, @@ -2784,8 +2788,14 @@ pub async fn sandbox_delete( let deleted = response.into_inner().deleted; if deleted { println!("{} Deleted sandbox {name}", "✓".green().bold()); + if load_last_sandbox(gateway_name).as_deref() == Some(name.as_str()) { + clear_last_sandbox(gateway_name)?; + } } else { println!("{} Sandbox {name} not found", "!".yellow()); + if load_last_sandbox(gateway_name).as_deref() == Some(name.as_str()) { + clear_last_sandbox(gateway_name)?; + } } } diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index 9fcfeced..90dce185 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use openshell_bootstrap::load_last_sandbox; +use openshell_bootstrap::{load_last_sandbox, save_last_sandbox}; use openshell_cli::run; use openshell_cli::tls::TlsOptions; use openshell_core::proto::open_shell_server::{OpenShell, OpenShellServer}; @@ -716,3 +716,34 @@ async fn sandbox_create_keeps_sandbox_with_forwarding() { assert!(deleted_names(&server).await.is_empty()); } + +#[tokio::test] +async fn sandbox_delete_clears_matching_last_used_sandbox() { + let server = run_server().await; + let fake_ssh_dir = tempfile::tempdir().unwrap(); + let xdg_dir = tempfile::tempdir().unwrap(); + let _env = test_env(&fake_ssh_dir, &xdg_dir); + let tls = test_tls(&server); + + save_last_sandbox("openshell", "stale-sandbox").expect("save should succeed"); + assert_eq!( + load_last_sandbox("openshell").as_deref(), + Some("stale-sandbox") + ); + + run::sandbox_delete( + &server.endpoint, + "openshell", + &["stale-sandbox".to_string()], + false, + &tls, + ) + .await + .expect("sandbox delete should succeed"); + + assert_eq!(load_last_sandbox("openshell"), None); + assert_eq!( + deleted_names(&server).await, + vec![vec!["stale-sandbox".to_string()]] + ); +}