Add --preserve-sandbox flag to keep sandboxes alive for debugging

Adds a `--preserve-sandbox` CLI flag and `sandbox.preserve` TOML config
option that skips sandbox cleanup after a run. Cascade order:
CLI flag > run TOML > server.toml defaults > false.

When preserving, prints sandbox identity (container ID or Daytona
sandbox name) so users can reconnect for debugging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-04 09:32:17 -05:00
parent 49ac81624b
commit 13ad7d948a
6 changed files with 218 additions and 1 deletions

View file

@ -746,6 +746,13 @@ impl Sandbox for DockerSandbox {
.cloned()
.unwrap_or_else(|| "linux".to_string())
}
fn sandbox_info(&self) -> String {
self.container_id
.get()
.cloned()
.unwrap_or_default()
}
}
#[cfg(test)]

View file

@ -88,6 +88,10 @@ macro_rules! delegate_sandbox {
fn os_version(&self) -> String {
self.$field.os_version()
}
fn sandbox_info(&self) -> String {
self.$field.sandbox_info()
}
}
};
}
@ -320,6 +324,11 @@ pub trait Sandbox: Send + Sync {
fn working_directory(&self) -> &str;
fn platform(&self) -> &str;
fn os_version(&self) -> String;
/// Return a human-readable identifier for the sandbox (e.g. container ID, sandbox name).
/// Used when `--preserve-sandbox` is active to tell the user how to reconnect.
fn sandbox_info(&self) -> String {
String::new()
}
}
#[cfg(test)]

View file

@ -131,6 +131,10 @@ pub struct RunArgs {
/// Create SSH access to the Daytona sandbox and print the connection command
#[arg(long)]
pub ssh: bool,
/// Keep the sandbox alive after the run finishes (for debugging)
#[arg(long)]
pub preserve_sandbox: bool,
}
#[derive(Args)]

View file

@ -119,6 +119,22 @@ fn resolve_sandbox_provider(
Ok(cli.or(toml).or(defaults).unwrap_or_default())
}
/// Resolve preserve-sandbox: CLI flag > TOML config > run defaults > false.
fn resolve_preserve_sandbox(
cli: bool,
run_cfg: Option<&WorkflowRunConfig>,
run_defaults: &RunDefaults,
) -> bool {
if cli {
return true;
}
run_cfg
.and_then(|c| c.sandbox.as_ref())
.and_then(|s| s.preserve)
.or_else(|| run_defaults.sandbox.as_ref().and_then(|s| s.preserve))
.unwrap_or(false)
}
/// Resolve daytona config: TOML config > run defaults.
fn resolve_daytona_config(
run_cfg: Option<&WorkflowRunConfig>,
@ -231,6 +247,8 @@ pub async fn run_command(
// 2. Pre-flight: check git cleanliness before creating any files
// (must happen before logs dir is created, which may be inside the repo)
let sandbox_provider = resolve_sandbox_provider(args.sandbox, run_cfg.as_ref(), &run_defaults)?;
let preserve_sandbox =
resolve_preserve_sandbox(args.preserve_sandbox, run_cfg.as_ref(), &run_defaults);
let original_cwd = std::env::current_dir()?;
let git_clean = match sandbox_provider {
SandboxProvider::Local | SandboxProvider::Docker => {
@ -460,6 +478,9 @@ pub async fn run_command(
// Safety net: if we panic or return early, best-effort cleanup via spawn.
let sandbox_for_cleanup = Arc::clone(&sandbox);
let cleanup_guard = scopeguard::guard((), move |()| {
if preserve_sandbox {
return;
}
let rt = tokio::runtime::Handle::try_current();
if let Ok(handle) = rt {
handle.spawn(async move {
@ -771,7 +792,20 @@ pub async fn run_command(
// 9. Cleanup sandbox (defuse the scopeguard so we await properly)
scopeguard::ScopeGuard::into_inner(cleanup_guard);
if let Err(e) = sandbox.cleanup().await {
if preserve_sandbox {
let info = sandbox.sandbox_info();
if !info.is_empty() {
eprintln!(
"\n{} sandbox preserved: {info}",
styles.bold.apply_to("Info:")
);
} else {
eprintln!(
"\n{} sandbox preserved",
styles.bold.apply_to("Info:")
);
}
} else if let Err(e) = sandbox.cleanup().await {
tracing::warn!(error = %e, "Sandbox cleanup failed");
eprintln!(
"\n{} sandbox cleanup failed: {e}",
@ -1563,6 +1597,72 @@ mod tests {
assert_eq!(provider, Some("openai".to_string()));
}
#[test]
fn resolve_preserve_sandbox_cli_wins() {
let cfg = run_config::WorkflowRunConfig {
version: 1,
goal: "test".into(),
graph: "w.dot".into(),
directory: None,
llm: None,
setup: None,
sandbox: Some(run_config::SandboxConfig {
provider: None,
preserve: Some(false),
daytona: None,
}),
vars: None,
};
let defaults = RunDefaults::default();
assert!(resolve_preserve_sandbox(true, Some(&cfg), &defaults));
}
#[test]
fn resolve_preserve_sandbox_toml_wins_over_defaults() {
let cfg = run_config::WorkflowRunConfig {
version: 1,
goal: "test".into(),
graph: "w.dot".into(),
directory: None,
llm: None,
setup: None,
sandbox: Some(run_config::SandboxConfig {
provider: None,
preserve: Some(true),
daytona: None,
}),
vars: None,
};
let defaults = RunDefaults {
sandbox: Some(run_config::SandboxConfig {
provider: None,
preserve: Some(false),
daytona: None,
}),
..RunDefaults::default()
};
assert!(resolve_preserve_sandbox(false, Some(&cfg), &defaults));
}
#[test]
fn resolve_preserve_sandbox_defaults_used() {
let defaults = RunDefaults {
sandbox: Some(run_config::SandboxConfig {
provider: None,
preserve: Some(true),
daytona: None,
}),
..RunDefaults::default()
};
assert!(resolve_preserve_sandbox(false, None, &defaults));
}
#[test]
fn resolve_preserve_sandbox_defaults_to_false() {
let defaults = RunDefaults::default();
assert!(!resolve_preserve_sandbox(false, None, &defaults));
}
#[test]
fn redact_removes_aws_key_from_compact_json() {
let envelope = serde_json::json!({

View file

@ -36,6 +36,7 @@ pub struct SetupConfig {
#[derive(Clone, Debug, Deserialize)]
pub struct SandboxConfig {
pub provider: Option<String>,
pub preserve: Option<bool>,
pub daytona: Option<DaytonaConfig>,
}
@ -89,6 +90,9 @@ impl WorkflowRunConfig {
if task.provider.is_none() {
task.provider = default.provider.clone();
}
if task.preserve.is_none() {
task.preserve = default.preserve;
}
match (&mut task.daytona, &default.daytona) {
(Some(task_d), Some(default_d)) => {
if task_d.auto_stop_interval.is_none() {
@ -664,6 +668,87 @@ commands = ["make test"]
assert_eq!(setup.timeout_ms, Some(60000));
}
#[test]
fn parse_toml_with_sandbox_preserve() {
let toml = r#"
version = 1
goal = "Run tests"
graph = "workflow.dot"
[sandbox]
provider = "docker"
preserve = true
"#;
let config = parse_run_config(toml).unwrap();
let sandbox = config.sandbox.unwrap();
assert_eq!(sandbox.preserve, Some(true));
}
#[test]
fn parse_toml_sandbox_preserve_defaults_to_none() {
let toml = r#"
version = 1
goal = "Run tests"
graph = "workflow.dot"
[sandbox]
provider = "docker"
"#;
let config = parse_run_config(toml).unwrap();
let sandbox = config.sandbox.unwrap();
assert_eq!(sandbox.preserve, None);
}
#[test]
fn apply_defaults_merges_sandbox_preserve_task_wins() {
let mut cfg = parse_run_config(
r#"
version = 1
goal = "test"
graph = "w.dot"
[sandbox]
preserve = true
"#,
)
.unwrap();
let defaults = RunDefaults {
sandbox: Some(SandboxConfig {
provider: None,
preserve: Some(false),
daytona: None,
}),
..RunDefaults::default()
};
cfg.apply_defaults(&defaults);
assert_eq!(cfg.sandbox.unwrap().preserve, Some(true));
}
#[test]
fn apply_defaults_merges_sandbox_preserve_from_default() {
let mut cfg = parse_run_config(
r#"
version = 1
goal = "test"
graph = "w.dot"
[sandbox]
provider = "docker"
"#,
)
.unwrap();
let defaults = RunDefaults {
sandbox: Some(SandboxConfig {
provider: None,
preserve: Some(true),
daytona: None,
}),
..RunDefaults::default()
};
cfg.apply_defaults(&defaults);
assert_eq!(cfg.sandbox.unwrap().preserve, Some(true));
}
#[test]
fn apply_defaults_merges_sandbox_fields() {
let mut cfg = parse_run_config(
@ -680,6 +765,7 @@ provider = "daytona"
let defaults = RunDefaults {
sandbox: Some(SandboxConfig {
provider: None,
preserve: None,
daytona: Some(DaytonaConfig {
auto_stop_interval: Some(30),
labels: None,
@ -714,6 +800,7 @@ auto_stop_interval = 60
let defaults = RunDefaults {
sandbox: Some(SandboxConfig {
provider: Some("daytona".into()),
preserve: None,
daytona: Some(DaytonaConfig {
auto_stop_interval: Some(30),
labels: Some(HashMap::from([("env".into(), "prod".into())])),
@ -745,6 +832,7 @@ env = "from_task"
let defaults = RunDefaults {
sandbox: Some(SandboxConfig {
provider: None,
preserve: None,
daytona: Some(DaytonaConfig {
auto_stop_interval: None,
labels: Some(HashMap::from([
@ -780,6 +868,7 @@ cpu = 2
let defaults = RunDefaults {
sandbox: Some(SandboxConfig {
provider: None,
preserve: None,
daytona: Some(DaytonaConfig {
auto_stop_interval: None,
labels: None,
@ -817,6 +906,7 @@ auto_stop_interval = 60
let defaults = RunDefaults {
sandbox: Some(SandboxConfig {
provider: None,
preserve: None,
daytona: Some(DaytonaConfig {
auto_stop_interval: None,
labels: None,

View file

@ -525,6 +525,13 @@ impl Sandbox for DaytonaSandbox {
"Linux (Daytona)".to_string()
}
fn sandbox_info(&self) -> String {
self.sandbox
.get()
.map(|s| s.name.clone())
.unwrap_or_default()
}
async fn read_file(
&self,
path: &str,