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 4d5cf53f..955704aa 100644 --- a/src/handlers/run/entry.rs +++ b/src/handlers/run/entry.rs @@ -51,21 +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 { - 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", - ); + 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!( @@ -79,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) @@ -88,6 +93,76 @@ 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 { "" }, + ) +} + +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 +/// 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} - < {}", + 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. /// @@ -1905,9 +1980,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_secret_labels, needs_remote_fetch, - prepare_local_run_secrets, + 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::{ @@ -1917,6 +1993,92 @@ 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" + ); + } + + #[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}" + ); + } + + #[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.