From ca76d6a9f783b8b95eba9e3713c4459f5a90372e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20H=C3=B6fer?= Date: Fri, 9 Oct 2026 13:10:09 +0200 Subject: [PATCH 1/2] feat(agent): refresh agent CLIs on docker build --force and flag stale images `agent docker build --force` replayed Docker's layer cache, which keys a RUN step on its command text, so the rebuilt image kept the old Claude Code and Codex binaries. A forced build now passes --pull and a fresh AGENT_CLI_CACHEBUST build arg (declared just above the install step, so the apt layer stays cached). Since the image pins both CLIs, also surface when it goes stale: `agent docker doctor` reports the image's age and the Claude Code/Codex versions inside it, and `agent run` prints a rebuild hint once the default image is older than 14 days. --- docker/agent-sandbox/Dockerfile | 7 + docs/sandboxing.md | 6 +- src/cmd/agent.rs | 2 +- src/handlers/agent/docker.rs | 59 +++++-- src/handlers/run/docker_sandbox.rs | 237 ++++++++++++++++++++++++++++- src/handlers/run/entry.rs | 17 ++- 6 files changed, 302 insertions(+), 26 deletions(-) diff --git a/docker/agent-sandbox/Dockerfile b/docker/agent-sandbox/Dockerfile index 565bb08a..eedc86bb 100644 --- a/docker/agent-sandbox/Dockerfile +++ b/docker/agent-sandbox/Dockerfile @@ -56,6 +56,13 @@ ENV PIP_BREAK_SYSTEM_PACKAGES=1 # its main executable, so its install tree is kept intact and symlinked # rather than copying a single binary out the way Claude Code's simpler, # single-file layout would allow. +# +# `agent docker build --force` passes a fresh AGENT_CLI_CACHEBUST value: +# Docker caches a RUN step by its command text, not by what the installers +# would fetch today, so without this a rebuild would replay the cached +# layer and keep the old Claude Code/Codex versions. Declared here, not at +# the top, so the apt layer above stays cached. +ARG AGENT_CLI_CACHEBUST RUN curl -fsSL https://claude.ai/install.sh | bash \ && curl -fsSL https://chatgpt.com/codex/install.sh | sh \ && chmod 755 /root \ diff --git a/docs/sandboxing.md b/docs/sandboxing.md index ae513075..a4867db9 100644 --- a/docs/sandboxing.md +++ b/docs/sandboxing.md @@ -215,9 +215,9 @@ Lists the same networks (name, session id, creation time, and whether it's tied stashbase agent docker build [--force] ``` -Builds the default sandbox image ahead of time instead of waiting to be prompted on first `agent run`, or rebuilds it with `--force` (e.g. after the embedded Dockerfile picks up new apt packages or a security patch) without needing to `docker rmi` it by hand first. +Builds the default sandbox image ahead of time instead of waiting to be prompted on first `agent run`, or rebuilds it with `--force` without needing to `docker rmi` it by hand first. `--force` also refreshes what's inside: it pulls the latest base image (`docker build --pull`) and reinstalls Claude Code and Codex at their current versions, since the image pins them and disables their in-app auto-updaters. Re-run it whenever you want newer agent CLIs; `agent run` also reminds you once the default image is more than 14 days old. -Add `--profile ` to target that profile's own `sandbox.image`/`sandbox.dockerfile` instead of the default — useful for pre-building or force-refreshing a custom image the same way, without needing to trigger a real `agent run` first. `--profile-source auto|global|directory` controls where `--profile` is loaded from, same as `agent run`/`agent validate`. A profile using a plain `image` reference has nothing to build (`docker run` pulls it automatically), so this reports that and does nothing rather than erroring. +Add `--profile ` to target that profile's own `sandbox.image`/`sandbox.dockerfile` instead of the default — useful for pre-building or force-refreshing a custom image the same way, without needing to trigger a real `agent run` first. For a custom Dockerfile, `--force` pulls the base image but otherwise follows Docker's normal layer cache. `--profile-source auto|global|directory` controls where `--profile` is loaded from, same as `agent run`/`agent validate`. A profile using a plain `image` reference has nothing to build (`docker run` pulls it automatically), so this reports that and does nothing rather than erroring. ### Checking readiness @@ -225,7 +225,7 @@ Add `--profile ` to target that profile's own `sandbox.image`/`sandbox.doc stashbase agent docker doctor ``` -Checks whether the Docker sandbox backend can actually run here — the `docker` CLI on PATH, the daemon reachable (and its version), and whether the default image is already built — without starting a real sandboxed run to find out. Exits non-zero if anything's not ready; pass `--json` for machine-readable output. Useful for onboarding or CI setup scripts that want to fail fast with a clear reason, rather than discovering a missing Docker install only when a real `agent run` fails. +Checks whether the Docker sandbox backend can actually run here — the `docker` CLI on PATH, the daemon reachable (and its version), and whether the default image is already built (with how old it is and the Claude Code/Codex versions inside it) — without starting a real sandboxed run to find out. Exits non-zero if anything's not ready; pass `--json` for machine-readable output. Useful for onboarding or CI setup scripts that want to fail fast with a clear reason, rather than discovering a missing Docker install only when a real `agent run` fails. ### Docker backend limitations diff --git a/src/cmd/agent.rs b/src/cmd/agent.rs index 2245ecb9..aac9938d 100644 --- a/src/cmd/agent.rs +++ b/src/cmd/agent.rs @@ -174,7 +174,7 @@ pub struct AgentDockerStatusCommand {} #[derive(Debug, Args)] pub struct AgentDockerBuildCommand { - /// Rebuild even if the image already exists locally + /// Rebuild even if the image already exists, pulling the latest base image (and, for the default image, the latest Claude Code and Codex) #[arg(long)] pub force: bool, diff --git a/src/handlers/agent/docker.rs b/src/handlers/agent/docker.rs index 03d5cf70..b33650b8 100644 --- a/src/handlers/agent/docker.rs +++ b/src/handlers/agent/docker.rs @@ -195,7 +195,7 @@ pub async fn handle_docker_build_command( // suppress it while still showing build progress, the same tradeoff // `docker build` itself has. The JSON result below is still the last // thing printed, so it remains the thing to parse. - crate::handlers::run::docker_sandbox::build_sandbox_image(&source) + crate::handlers::run::docker_sandbox::build_sandbox_image(&source, command.force) .map_err(|error| anyhow::anyhow!("failed to build the Docker sandbox image: {error}"))?; if raw_output { println!( @@ -479,9 +479,22 @@ pub async fn handle_docker_doctor_command( } else { Err("skipped: `docker` CLI not found".to_owned()) }; - let image_built = crate::handlers::run::docker_sandbox::sandbox_image_exists( - &crate::handlers::run::docker_sandbox::AgentImageSource::Default, - ); + let default_source = crate::handlers::run::docker_sandbox::AgentImageSource::Default; + let image_created_at = if daemon_version.is_ok() { + crate::handlers::run::docker_sandbox::sandbox_image_created_at(&default_source) + } else { + None + }; + let image_built = image_created_at.is_some(); + let agent_versions = if image_built { + crate::handlers::run::docker_sandbox::sandbox_image_agent_versions(&default_source) + } else { + Default::default() + }; + let now = chrono::Utc::now(); + let stale_hint = image_created_at.and_then(|created_at| { + crate::handlers::run::docker_sandbox::stale_default_image_hint(created_at, now) + }); let all_ok = binary_available && daemon_version.is_ok(); if raw_output { @@ -491,6 +504,10 @@ pub async fn handle_docker_doctor_command( "daemon_version": daemon_version.as_ref().ok(), "daemon_error": daemon_version.as_ref().err(), "default_image_built": image_built, + "default_image_created_at": image_created_at.map(|created_at| created_at.to_rfc3339()), + "default_image_stale": stale_hint.is_some(), + "claude_code_version": agent_versions.claude, + "codex_version": agent_versions.codex, "ready": all_ok, }); println!("{}", get_formatted_json_string(&json, true)?); @@ -512,18 +529,30 @@ pub async fn handle_docker_doctor_command( ), Err(error) => println!("{} Docker daemon reachable: {error}", "✗".red_if_tty()), } - println!( - "{} Default sandbox image built", - if image_built { - "✓".green_if_tty() - } else { - "○".yellow_if_tty() + match image_created_at { + Some(created_at) => { + let unknown = || "unknown".to_owned(); + println!( + "{} Default sandbox image built {} (Claude Code {}, Codex {})", + if stale_hint.is_some() { + "○".yellow_if_tty() + } else { + "✓".green_if_tty() + }, + crate::handlers::run::docker_sandbox::format_image_age(created_at, now), + agent_versions.claude.clone().unwrap_or_else(unknown), + agent_versions.codex.clone().unwrap_or_else(unknown), + ); + if let Some(hint) = &stale_hint { + println!(" ({hint})"); + } + } + None => { + println!("{} Default sandbox image built", "○".yellow_if_tty()); + println!( + " (not required — `agent run` builds it on first use, or run `agent docker build`)" + ); } - ); - if !image_built { - println!( - " (not required — `agent run` builds it on first use, or run `agent docker build`)" - ); } println!(); if all_ok { diff --git a/src/handlers/run/docker_sandbox.rs b/src/handlers/run/docker_sandbox.rs index 84f70596..8c5be5f7 100644 --- a/src/handlers/run/docker_sandbox.rs +++ b/src/handlers/run/docker_sandbox.rs @@ -638,6 +638,97 @@ pub(crate) fn sandbox_image_exists(source: &AgentImageSource) -> bool { .unwrap_or(false) } +/// How old the default image may get before `agent run` suggests +/// rebuilding it. The image pins Claude Code and Codex (their in-app +/// updaters are disabled), and both ship new versions every few days, so +/// without a nudge an image silently falls weeks behind. +pub(crate) const STALE_IMAGE_AFTER_DAYS: i64 = 14; + +/// When the local image for `source` was built, or `None` if it isn't +/// built (or `docker` can't say). Not meaningful for a plain image +/// reference, which `docker run` pulls on demand. +pub(crate) fn sandbox_image_created_at( + source: &AgentImageSource, +) -> Option> { + let output = std::process::Command::new("docker") + .args([ + "image", + "inspect", + "--format", + "{{.Created}}", + &source.image_tag(), + ]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + chrono::DateTime::parse_from_rfc3339(String::from_utf8_lossy(&output.stdout).trim()) + .ok() + .map(|created_at| created_at.with_timezone(&chrono::Utc)) +} + +/// Whole days between `created_at` and `now` as a short phrase. +pub(crate) fn format_image_age( + created_at: chrono::DateTime, + now: chrono::DateTime, +) -> String { + match (now - created_at).num_days() { + days if days <= 0 => "today".to_owned(), + 1 => "1 day ago".to_owned(), + days => format!("{days} days ago"), + } +} + +/// The nudge `agent run` prints when the default image is older than +/// `STALE_IMAGE_AFTER_DAYS`, or `None` while it's still fresh. +pub(crate) fn stale_default_image_hint( + created_at: chrono::DateTime, + now: chrono::DateTime, +) -> Option { + ((now - created_at).num_days() > STALE_IMAGE_AFTER_DAYS).then(|| { + format!( + "The Docker sandbox image was built {}; run `stashbase agent docker build --force` to update Claude Code and Codex.", + format_image_age(created_at, now) + ) + }) +} + +/// Claude Code and Codex versions baked into an image; a field is `None` +/// when that CLI is missing or its `--version` output wasn't recognized. +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct AgentCliVersions { + pub claude: Option, + pub codex: Option, +} + +/// Reads the agent CLI versions installed in `source`'s image by running +/// their `--version` in a throwaway, network-less container. Costs a +/// container start, so it's for diagnostics (`agent docker doctor`), not +/// the `agent run` hot path. +pub(crate) fn sandbox_image_agent_versions(source: &AgentImageSource) -> AgentCliVersions { + std::process::Command::new("docker") + .args(["run", "--rm", "--network", "none", &source.image_tag()]) + .args(["sh", "-c", "claude --version; codex --version"]) + .output() + .map(|output| parse_agent_cli_versions(&String::from_utf8_lossy(&output.stdout))) + .unwrap_or_default() +} + +/// Parses `claude --version` (`2.1.295 (Claude Code)`) and +/// `codex --version` (`codex-cli 0.162.0`) output. +fn parse_agent_cli_versions(output: &str) -> AgentCliVersions { + let mut versions = AgentCliVersions::default(); + for line in output.lines().map(str::trim) { + if line.ends_with("(Claude Code)") { + versions.claude = line.split_whitespace().next().map(str::to_owned); + } else if let Some(version) = line.strip_prefix("codex-cli ") { + versions.codex = Some(version.trim().to_owned()); + } + } + versions +} + /// Reads a file at `relative_path` (relative to `CONTAINER_HOME`) out of the /// persistent home volume — the same volume `docker_run_command` mounts as /// the sandboxed agent's `HOME` — without starting a full agent run. Used to @@ -717,7 +808,13 @@ pub(crate) fn docker_shell_command_args( /// packages), and a silent hang would look broken. This does mean a /// failure's error message comes from the already-visible build output, /// not a captured string. -pub(crate) fn build_sandbox_image(source: &AgentImageSource) -> Result<(), String> { +/// +/// `refresh` (`agent docker build --force`) makes the rebuild pick up new +/// upstream content rather than replaying Docker's layer cache, which keys +/// a `RUN` step on its command text — not on what that command would fetch +/// today — so a plain rebuild would reuse the old Claude Code/Codex +/// binaries forever. See `docker_build_args`. +pub(crate) fn build_sandbox_image(source: &AgentImageSource, refresh: bool) -> Result<(), String> { let tag = source .build_tag() .ok_or_else(|| "a custom image reference has nothing to build".to_owned())?; @@ -746,7 +843,7 @@ pub(crate) fn build_sandbox_image(source: &AgentImageSource) -> Result<(), Strin } .and_then(|build_dir| { std::process::Command::new("docker") - .args(["build", "-t", &tag]) + .args(docker_build_args(source, &tag, refresh)) .arg(&build_dir) .status() .map_err(|error| format!("failed to run `docker build`: {error}")) @@ -762,6 +859,33 @@ pub(crate) fn build_sandbox_image(source: &AgentImageSource) -> Result<(), Strin build_result } +/// Name of the build arg declared just above the embedded Dockerfile's +/// Claude Code/Codex install step. +const AGENT_CLI_CACHEBUST_ARG: &str = "AGENT_CLI_CACHEBUST"; + +/// `docker build` args (minus the context directory). With `refresh`: +/// `--pull` re-resolves the base image, so OS security patches land; and, +/// for the default image only, a fresh `AGENT_CLI_CACHEBUST` value +/// invalidates the cache from the agent-CLI install step onward, so +/// Claude Code and Codex are reinstalled at their latest versions while +/// the slow apt layer above it stays cached (unless `--pull` brought a new +/// base image, which rebuilds everything anyway). A custom Dockerfile +/// doesn't declare that arg, so it isn't passed there. +fn docker_build_args(source: &AgentImageSource, tag: &str, refresh: bool) -> Vec { + let mut args = vec!["build".to_owned(), "-t".to_owned(), tag.to_owned()]; + if refresh { + args.push("--pull".to_owned()); + if matches!(source, AgentImageSource::Default) { + args.push("--build-arg".to_owned()); + args.push(format!( + "{AGENT_CLI_CACHEBUST_ARG}={}", + uuid::Uuid::new_v4() + )); + } + } + args +} + /// `--user uid:gid` args for the agent container, Linux only: Docker /// Desktop already maps container-root writes on a bind mount back to the /// host user transparently; native Linux does not, so without this every @@ -1683,7 +1807,7 @@ mod tests { static BUILD_ONCE: OnceLock<()> = OnceLock::new(); BUILD_ONCE.get_or_init(|| { if !sandbox_image_exists(&AgentImageSource::Default) { - build_sandbox_image(&AgentImageSource::Default) + build_sandbox_image(&AgentImageSource::Default, false) .expect("building the embedded Dockerfile should succeed for tests"); } }); @@ -1694,6 +1818,108 @@ mod tests { assert!(SANDBOX_DOCKERFILE.contains("FROM")); } + #[test] + fn sandbox_dockerfile_declares_the_agent_cli_cachebust_arg_before_installing() { + let arg_line = SANDBOX_DOCKERFILE + .lines() + .position(|line| line.trim() == format!("ARG {AGENT_CLI_CACHEBUST_ARG}")) + .expect("the cache-bust ARG must be declared"); + let install_line = SANDBOX_DOCKERFILE + .lines() + .position(|line| line.contains("claude.ai/install.sh")) + .expect("the Claude Code install step must exist"); + assert!(arg_line < install_line); + } + + #[test] + fn format_image_age_reads_naturally() { + let now = chrono::Utc::now(); + assert_eq!(format_image_age(now, now), "today"); + assert_eq!( + format_image_age(now - chrono::Duration::days(1), now), + "1 day ago" + ); + assert_eq!( + format_image_age(now - chrono::Duration::days(23), now), + "23 days ago" + ); + } + + #[test] + fn stale_default_image_hint_only_past_the_threshold() { + let now = chrono::Utc::now(); + assert_eq!( + stale_default_image_hint(now - chrono::Duration::days(STALE_IMAGE_AFTER_DAYS), now), + None + ); + let hint = stale_default_image_hint( + now - chrono::Duration::days(STALE_IMAGE_AFTER_DAYS + 1), + now, + ) + .expect("stale image should produce a hint"); + assert!(hint.contains("15 days ago"), "{hint}"); + assert!( + hint.contains("`stashbase agent docker build --force`"), + "{hint}" + ); + } + + #[test] + fn parse_agent_cli_versions_reads_both_tools() { + assert_eq!( + parse_agent_cli_versions("2.1.295 (Claude Code)\ncodex-cli 0.162.0\n"), + AgentCliVersions { + claude: Some("2.1.295".to_owned()), + codex: Some("0.162.0".to_owned()), + } + ); + } + + #[test] + fn parse_agent_cli_versions_tolerates_a_missing_tool() { + assert_eq!( + parse_agent_cli_versions("codex-cli 0.162.0\n"), + AgentCliVersions { + claude: None, + codex: Some("0.162.0".to_owned()), + } + ); + } + + #[test] + fn docker_build_args_reuse_the_cache_without_refresh() { + assert_eq!( + docker_build_args(&AgentImageSource::Default, "t", false), + ["build", "-t", "t"] + ); + } + + #[test] + fn docker_build_args_refresh_pulls_and_busts_the_agent_cli_layer() { + let args = docker_build_args(&AgentImageSource::Default, "t", true); + assert!(args.contains(&"--pull".to_owned())); + let bust = args + .iter() + .position(|arg| arg == "--build-arg") + .map(|index| &args[index + 1]) + .expect("a cache-bust build arg"); + assert!(bust.starts_with(&format!("{AGENT_CLI_CACHEBUST_ARG}="))); + // A fresh value every time, or the cache would still hit. + assert_ne!( + args, + docker_build_args(&AgentImageSource::Default, "t", true) + ); + } + + #[test] + fn docker_build_args_refresh_only_pulls_for_a_custom_dockerfile() { + let source = AgentImageSource::Dockerfile(std::path::PathBuf::from("Dockerfile")); + assert_eq!( + docker_build_args(&source, "t", true), + ["build", "-t", "t", "--pull"] + ); + } + #[test] fn sandbox_dockerfile_clears_the_node_base_entrypoint() { assert!(SANDBOX_DOCKERFILE @@ -1756,7 +1982,8 @@ mod tests { #[test] fn building_a_plain_image_reference_is_rejected() { let source = AgentImageSource::Image("myorg/img:tag".to_owned()); - let error = build_sandbox_image(&source).expect_err("nothing to build for an image ref"); + let error = + build_sandbox_image(&source, false).expect_err("nothing to build for an image ref"); assert!(error.contains("nothing to build")); } @@ -1772,7 +1999,7 @@ mod tests { // Don't assert on the starting state — a prior test run or the // developer's own machine may already have the image built. // Just prove building it results in it existing. - build_sandbox_image(&AgentImageSource::Default) + build_sandbox_image(&AgentImageSource::Default, false) .expect("building the embedded Dockerfile should succeed"); assert!(sandbox_image_exists(&AgentImageSource::Default)); } diff --git a/src/handlers/run/entry.rs b/src/handlers/run/entry.rs index 2b359cab..897bd124 100644 --- a/src/handlers/run/entry.rs +++ b/src/handlers/run/entry.rs @@ -87,7 +87,7 @@ fn ensure_docker_sandbox_image_available( anyhow::bail!(declined_image_build_error(source, &tag)); } eprintln!("Building Docker sandbox image ({tag})..."); - super::docker_sandbox::build_sandbox_image(source) + super::docker_sandbox::build_sandbox_image(source, false) .map_err(|error| anyhow::anyhow!("failed to build the Docker sandbox image: {error}"))?; eprintln!("Docker sandbox image built."); Ok(tag) @@ -186,7 +186,20 @@ fn ensure_docker_images_available( silent, )?; } - ensure_docker_sandbox_image_available(agent_image_source, silent) + let tag = ensure_docker_sandbox_image_available(agent_image_source, silent)?; + // Only when the agent itself runs from the default image: a profile with + // a custom image still uses the default one for the netns holder, but + // its Claude Code/Codex versions are irrelevant there. + if !silent && *agent_image_source == super::docker_sandbox::AgentImageSource::Default { + if let Some(hint) = super::docker_sandbox::sandbox_image_created_at(agent_image_source) + .and_then(|created_at| { + super::docker_sandbox::stale_default_image_hint(created_at, chrono::Utc::now()) + }) + { + eprintln!("{hint}"); + } + } + Ok(tag) } fn prepare_run_worktree_in( From ee494dc0eddd92b0faa15041df7537e063f53c54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20H=C3=B6fer?= Date: Fri, 9 Oct 2026 15:55:42 +0200 Subject: [PATCH 2/2] test: only build the default sandbox image when it's missing A cached rebuild over an existing image re-tagged :latest to the first cached install layer, undoing a `agent docker build --force` on every test run with Docker available. Fresh CI runners still build it. --- src/handlers/run/docker_sandbox.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/handlers/run/docker_sandbox.rs b/src/handlers/run/docker_sandbox.rs index 8c5be5f7..3b2440d7 100644 --- a/src/handlers/run/docker_sandbox.rs +++ b/src/handlers/run/docker_sandbox.rs @@ -1996,11 +1996,14 @@ mod tests { eprintln!("skipping: Docker not available in this environment"); return; } - // Don't assert on the starting state — a prior test run or the - // developer's own machine may already have the image built. - // Just prove building it results in it existing. - build_sandbox_image(&AgentImageSource::Default, false) - .expect("building the embedded Dockerfile should succeed"); + // Only build when missing (always the case on a fresh CI runner): a + // cached rebuild over a developer's existing image re-tags `:latest` + // to whatever install layer Docker cached first, silently undoing a + // `agent docker build --force` that pulled newer agent CLIs. + if !sandbox_image_exists(&AgentImageSource::Default) { + build_sandbox_image(&AgentImageSource::Default, false) + .expect("building the embedded Dockerfile should succeed"); + } assert!(sandbox_image_exists(&AgentImageSource::Default)); }