From 7ae85a39786218e66a0fd581e512ea748b869808 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20H=C3=B6fer?= Date: Thu, 8 Oct 2026 14:23:27 +0200 Subject: [PATCH 1/3] fix(run): say how to build a missing sandbox image when nobody can be asked Without a terminal (stdin piped, CI, a script over SSH) the "build the image now?" prompt can't be answered, so a Docker-backend agent run failed with only "its image was not built". It now fails straight away with the command that builds it, as --silent already did. The hint itself was wrong for the default image: it said `docker build -t `, but that Dockerfile is embedded in the binary, so it now names `stashbase agent docker build`. A custom Dockerfile gets `docker build -t - < `, which matches how the CLI builds it and also covers a per-run --docker-dockerfile. --- src/handlers/run/entry.rs | 58 ++++++++++++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/src/handlers/run/entry.rs b/src/handlers/run/entry.rs index 4d5cf53f..c2e13cf8 100644 --- a/src/handlers/run/entry.rs +++ b/src/handlers/run/entry.rs @@ -51,20 +51,26 @@ use super::format::format_env_variable_value; /// /// In an interactive session, asks before building (an implicit multi- /// minute `docker build` on first use would otherwise be a surprising side -/// effect of `agent run`). In `--silent` mode there is no one to ask, so -/// this fails closed with instructions rather than silently building or -/// silently running unsandboxed. +/// effect of `agent run`). In `--silent` mode, or without a terminal to +/// prompt on (stdin piped, CI, a script over SSH), there is no one to ask, +/// so this fails closed with the command that builds it rather than +/// silently building or silently running unsandboxed. fn ensure_docker_sandbox_image_available( source: &super::docker_sandbox::AgentImageSource, silent: bool, ) -> anyhow::Result { + use std::io::IsTerminal; + let tag = source.image_tag(); if super::docker_sandbox::sandbox_image_exists(source) { return Ok(tag); } - if silent { + let can_prompt = std::io::stdin().is_terminal() && std::io::stderr().is_terminal(); + if silent || !can_prompt { anyhow::bail!( - "the Docker sandbox image ({tag}) is not built yet; build it once with `docker build -t {tag} ` or re-run without --silent to be prompted", + "the Docker sandbox image ({tag}) is not built yet; build it once with `{}`, or re-run in an interactive terminal{} to be asked", + missing_image_build_command(source, &tag), + if silent { " without --silent" } else { "" }, ); } eprintln!(); @@ -88,6 +94,24 @@ fn ensure_docker_sandbox_image_available( Ok(tag) } +/// The command that builds a missing sandbox image. The default image's +/// Dockerfile is embedded in the binary, so only `agent docker build` can +/// build it. A custom Dockerfile is built from stdin with no context, the +/// same way `build_sandbox_image` builds it (a context holding only the +/// Dockerfile), which also covers a per-run `--docker-dockerfile` that no +/// profile names. +fn missing_image_build_command( + source: &super::docker_sandbox::AgentImageSource, + tag: &str, +) -> String { + match source { + super::docker_sandbox::AgentImageSource::Dockerfile(path) => { + format!("docker build -t {tag} - < {}", path.display()) + } + _ => "stashbase agent docker build".to_owned(), + } +} + /// Ensures every image a Docker-backend run will actually need is /// available, and returns the tag the agent container should use. /// @@ -1906,8 +1930,8 @@ fn missing_secret_labels( mod tests { use super::{ apply_secret_bindings, load_run_secrets_from_file, loaded_message, loading_message, - merge_remote_and_local_secrets, missing_secret_labels, needs_remote_fetch, - prepare_local_run_secrets, + merge_remote_and_local_secrets, missing_image_build_command, missing_secret_labels, + needs_remote_fetch, prepare_local_run_secrets, }; use crate::models::secrets::SecretWithoutComment; use std::{ @@ -1917,6 +1941,26 @@ mod tests { time::{SystemTime, UNIX_EPOCH}, }; + #[test] + fn missing_default_image_points_to_agent_docker_build() { + let source = crate::handlers::run::docker_sandbox::AgentImageSource::Default; + assert_eq!( + missing_image_build_command(&source, "stashbase/agent-sandbox:latest"), + "stashbase agent docker build" + ); + } + + #[test] + fn missing_custom_image_builds_the_dockerfile_from_stdin() { + let source = crate::handlers::run::docker_sandbox::AgentImageSource::Dockerfile( + PathBuf::from("/repo/sandbox.Dockerfile"), + ); + assert_eq!( + missing_image_build_command(&source, "stashbase/agent-sandbox-custom:abc"), + "docker build -t stashbase/agent-sandbox-custom:abc - < /repo/sandbox.Dockerfile" + ); + } + /// `finish_run_worktree` must repair the pointer file before any host /// git runs in the worktree, then reset foreign refs, then remove the /// (clean) worktree — with the agent's own branch kept. From 7d2010958f2a91e5080e6a5c6e13bdf72367291e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20H=C3=B6fer?= Date: Thu, 8 Oct 2026 14:37:20 +0200 Subject: [PATCH 2/3] fix(run): quote the Dockerfile path in the missing-image hint A custom Dockerfile path with a space or other shell-special character (`/repo/my image/Dockerfile`) was printed bare, so the suggested `docker build -t - < ` broke when pasted. It is now single-quoted when needed, with embedded quotes escaped. The decision whether to prompt (not --silent, stdin and stderr are terminals) and the error text are now small functions with tests, so a change that brings back a blocking prompt in CI fails a test. --- src/handlers/run/entry.rs | 119 ++++++++++++++++++++++++++++++++++---- 1 file changed, 108 insertions(+), 11 deletions(-) diff --git a/src/handlers/run/entry.rs b/src/handlers/run/entry.rs index c2e13cf8..95261a56 100644 --- a/src/handlers/run/entry.rs +++ b/src/handlers/run/entry.rs @@ -65,13 +65,12 @@ fn ensure_docker_sandbox_image_available( if super::docker_sandbox::sandbox_image_exists(source) { return Ok(tag); } - let can_prompt = std::io::stdin().is_terminal() && std::io::stderr().is_terminal(); - if silent || !can_prompt { - anyhow::bail!( - "the Docker sandbox image ({tag}) is not built yet; build it once with `{}`, or re-run in an interactive terminal{} to be asked", - missing_image_build_command(source, &tag), - if silent { " without --silent" } else { "" }, - ); + if !can_prompt_for_image_build( + silent, + std::io::stdin().is_terminal(), + std::io::stderr().is_terminal(), + ) { + anyhow::bail!(missing_image_error(source, &tag, silent)); } eprintln!(); let should_build = crate::utils::interaction::confirm_opt(&format!( @@ -94,6 +93,30 @@ fn ensure_docker_sandbox_image_available( Ok(tag) } +/// Whether a missing sandbox image may be offered for building with a +/// prompt. The prompt reads stdin and draws on stderr, so both must be a +/// terminal; otherwise (CI, piped stdin, a script over SSH) it would block +/// or fail, so the run fails with `missing_image_error` instead. +fn can_prompt_for_image_build( + silent: bool, + stdin_is_terminal: bool, + stderr_is_terminal: bool, +) -> bool { + !silent && stdin_is_terminal && stderr_is_terminal +} + +fn missing_image_error( + source: &super::docker_sandbox::AgentImageSource, + tag: &str, + silent: bool, +) -> String { + format!( + "the Docker sandbox image ({tag}) is not built yet; build it once with `{}`, or re-run in an interactive terminal{} to be asked", + missing_image_build_command(source, tag), + if silent { " without --silent" } else { "" }, + ) +} + /// The command that builds a missing sandbox image. The default image's /// Dockerfile is embedded in the binary, so only `agent docker build` can /// build it. A custom Dockerfile is built from stdin with no context, the @@ -106,12 +129,30 @@ fn missing_image_build_command( ) -> String { match source { super::docker_sandbox::AgentImageSource::Dockerfile(path) => { - format!("docker build -t {tag} - < {}", path.display()) + format!( + "docker build -t {tag} - < {}", + shell_quote(&path.to_string_lossy()) + ) } _ => "stashbase agent docker build".to_owned(), } } +/// Quotes `value` for a POSIX shell so a copied command keeps it as one +/// word: left bare when it only has characters no shell treats specially, +/// otherwise single-quoted with each embedded `'` written as `'\''`. +fn shell_quote(value: &str) -> String { + let is_plain = !value.is_empty() + && value + .chars() + .all(|c| c.is_ascii_alphanumeric() || "/._-+,:=@%".contains(c)); + if is_plain { + value.to_owned() + } else { + format!("'{}'", value.replace('\'', r"'\''")) + } +} + /// Ensures every image a Docker-backend run will actually need is /// available, and returns the tag the agent container should use. /// @@ -1929,9 +1970,10 @@ fn missing_secret_labels( #[cfg(test)] mod tests { use super::{ - apply_secret_bindings, load_run_secrets_from_file, loaded_message, loading_message, - merge_remote_and_local_secrets, missing_image_build_command, missing_secret_labels, - needs_remote_fetch, prepare_local_run_secrets, + apply_secret_bindings, can_prompt_for_image_build, load_run_secrets_from_file, + loaded_message, loading_message, merge_remote_and_local_secrets, + missing_image_build_command, missing_image_error, missing_secret_labels, + needs_remote_fetch, prepare_local_run_secrets, shell_quote, }; use crate::models::secrets::SecretWithoutComment; use std::{ @@ -1961,6 +2003,61 @@ mod tests { ); } + #[test] + fn missing_custom_image_quotes_a_dockerfile_path_with_spaces() { + let source = crate::handlers::run::docker_sandbox::AgentImageSource::Dockerfile( + PathBuf::from("/repo/my image/Dockerfile"), + ); + assert_eq!( + missing_image_build_command(&source, "t:abc"), + "docker build -t t:abc - < '/repo/my image/Dockerfile'" + ); + } + + #[test] + fn shell_quote_leaves_plain_paths_bare_and_escapes_single_quotes() { + assert_eq!( + shell_quote("/repo/sandbox.Dockerfile"), + "/repo/sandbox.Dockerfile" + ); + assert_eq!( + shell_quote("/repo/it's/Dockerfile"), + r"'/repo/it'\''s/Dockerfile'" + ); + assert_eq!(shell_quote("/repo/$HOME;rm"), "'/repo/$HOME;rm'"); + assert_eq!(shell_quote(""), "''"); + } + + #[test] + fn image_build_prompt_needs_a_terminal_and_no_silent_flag() { + assert!(can_prompt_for_image_build(false, true, true)); + assert!(!can_prompt_for_image_build(true, true, true), "--silent"); + assert!( + !can_prompt_for_image_build(false, false, true), + "piped stdin / CI" + ); + assert!( + !can_prompt_for_image_build(false, true, false), + "stderr redirected" + ); + } + + #[test] + fn missing_image_error_names_the_build_command_and_how_to_be_asked() { + let source = crate::handlers::run::docker_sandbox::AgentImageSource::Default; + let piped = missing_image_error(&source, "img:latest", false); + assert!(piped.contains("`stashbase agent docker build`"), "{piped}"); + assert!( + piped.ends_with("re-run in an interactive terminal to be asked"), + "{piped}" + ); + let silent = missing_image_error(&source, "img:latest", true); + assert!( + silent.ends_with("interactive terminal without --silent to be asked"), + "{silent}" + ); + } + /// `finish_run_worktree` must repair the pointer file before any host /// git runs in the worktree, then reset foreign refs, then remove the /// (clean) worktree — with the agent's own branch kept. From 7095de1f8fc2e98e36a7125d5e9fdd647b1dde43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20H=C3=B6fer?= Date: Fri, 9 Oct 2026 08:34:40 +0200 Subject: [PATCH 3/3] fix(run): name the build command when the image prompt is declined Answering "no" to "build the image now?" still ended with the bare "its image was not built". It now names the same build command as the non-interactive path. docs/sandboxing.md says the prompt only appears in an interactive terminal and what a run without one prints instead. --- docs/sandboxing.md | 2 +- src/handlers/run/entry.rs | 31 ++++++++++++++++++++++++++----- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/docs/sandboxing.md b/docs/sandboxing.md index 5a594bdd..fc399807 100644 --- a/docs/sandboxing.md +++ b/docs/sandboxing.md @@ -77,7 +77,7 @@ This was verified directly, including trying to defeat it from inside a real san Claude Code and Codex are pre-installed in the default sandbox image and are the only agents validated against this backend so far. Other tools that don't need anything beyond what the image provides should also run. -The default image is built from `node:22-bookworm-slim` (Debian underneath) with `git`, `curl`, `ca-certificates`, `bubblewrap`, `iptables`, `jq`, `gh`, `dnsutils`, `unzip`, `less`, `procps`, and Python 3 (`python3`, `python3-pip`, `python3-venv`) installed via `apt`, plus Claude Code and Codex installed via their own official native installer scripts (not `npm install -g` — see the Dockerfile for why). `pip install` works out of the box without needing a virtualenv first — Debian's system pip normally refuses this (PEP 668), but that protection matters less for an ephemeral sandbox container than a real host, so it's relaxed here. It is not published to a registry — the Dockerfile is embedded in the `stashbase` binary itself, so a plain installed copy of the CLI can build it locally without needing this source repository. The first `agent run` that selects the Docker backend detects the image is missing and offers to build it (interactively; `--silent` runs fail closed instead of prompting). The build streams Docker's own progress live rather than running silently. +The default image is built from `node:22-bookworm-slim` (Debian underneath) with `git`, `curl`, `ca-certificates`, `bubblewrap`, `iptables`, `jq`, `gh`, `dnsutils`, `unzip`, `less`, `procps`, and Python 3 (`python3`, `python3-pip`, `python3-venv`) installed via `apt`, plus Claude Code and Codex installed via their own official native installer scripts (not `npm install -g` — see the Dockerfile for why). `pip install` works out of the box without needing a virtualenv first — Debian's system pip normally refuses this (PEP 668), but that protection matters less for an ephemeral sandbox container than a real host, so it's relaxed here. It is not published to a registry — the Dockerfile is embedded in the `stashbase` binary itself, so a plain installed copy of the CLI can build it locally without needing this source repository. The first `agent run` that selects the Docker backend detects the image is missing and offers to build it. It only asks in an interactive terminal: with `--silent`, or without a terminal (CI, piped input, a script over SSH), the run fails closed with the command that builds it — `stashbase agent docker build` for the default image. The build streams Docker's own progress live rather than running silently. ### Custom images diff --git a/src/handlers/run/entry.rs b/src/handlers/run/entry.rs index 95261a56..955704aa 100644 --- a/src/handlers/run/entry.rs +++ b/src/handlers/run/entry.rs @@ -84,7 +84,7 @@ fn ensure_docker_sandbox_image_available( // unconditionally before deciding what the prompt's outcome was. let _ = dialoguer::console::Term::stdout().show_cursor(); if !should_build { - anyhow::bail!("Docker sandbox backend selected, but its image was not built"); + anyhow::bail!(declined_image_build_error(source, &tag)); } eprintln!("Building Docker sandbox image ({tag})..."); super::docker_sandbox::build_sandbox_image(source) @@ -117,6 +117,16 @@ fn missing_image_error( ) } +fn declined_image_build_error( + source: &super::docker_sandbox::AgentImageSource, + tag: &str, +) -> String { + format!( + "Docker sandbox backend selected, but its image ({tag}) was not built; build it later with `{}`", + missing_image_build_command(source, tag), + ) +} + /// The command that builds a missing sandbox image. The default image's /// Dockerfile is embedded in the binary, so only `agent docker build` can /// build it. A custom Dockerfile is built from stdin with no context, the @@ -1970,10 +1980,10 @@ fn missing_secret_labels( #[cfg(test)] mod tests { use super::{ - apply_secret_bindings, can_prompt_for_image_build, load_run_secrets_from_file, - loaded_message, loading_message, merge_remote_and_local_secrets, - missing_image_build_command, missing_image_error, missing_secret_labels, - needs_remote_fetch, prepare_local_run_secrets, shell_quote, + apply_secret_bindings, can_prompt_for_image_build, declined_image_build_error, + load_run_secrets_from_file, loaded_message, loading_message, + merge_remote_and_local_secrets, missing_image_build_command, missing_image_error, + missing_secret_labels, needs_remote_fetch, prepare_local_run_secrets, shell_quote, }; use crate::models::secrets::SecretWithoutComment; use std::{ @@ -2058,6 +2068,17 @@ mod tests { ); } + #[test] + fn declined_image_build_still_names_the_build_command() { + let source = crate::handlers::run::docker_sandbox::AgentImageSource::Default; + let message = declined_image_build_error(&source, "img:latest"); + assert!(message.contains("(img:latest) was not built"), "{message}"); + assert!( + message.ends_with("build it later with `stashbase agent docker build`"), + "{message}" + ); + } + /// `finish_run_worktree` must repair the pointer file before any host /// git runs in the worktree, then reset foreign refs, then remove the /// (clean) worktree — with the agent's own branch kept.