diff --git a/src/sandbox/container.rs b/src/sandbox/container.rs index a5ef12ab5c6..b4444210021 100644 --- a/src/sandbox/container.rs +++ b/src/sandbox/container.rs @@ -140,6 +140,67 @@ impl ContainerRunner { Ok(()) } + /// Build the sandbox image from a Dockerfile. + /// + /// This is used when the image is not available from a registry and needs + /// to be built locally from source. + /// + /// # Security + /// + /// The `dockerfile_path` MUST point to a trusted Dockerfile. Docker builds + /// execute arbitrary `RUN` commands from the Dockerfile, which is a code + /// execution vector. Callers must ensure the path is not user-controlled + /// and points to a known, safe Dockerfile (e.g., bundled with the application). + pub async fn build_image(&self, dockerfile_path: &Path) -> Result<()> { + use tokio::process::Command; + + let dockerfile_str = dockerfile_path.to_string_lossy(); + + // Determine context directory: + // - If dockerfile has a parent dir, use it + // - Otherwise use current working directory + let context_dir = match dockerfile_path.parent() { + Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(), + _ => std::env::current_dir().map_err(|e| SandboxError::ContainerCreationFailed { + reason: format!("cannot determine current directory: {}", e), + })?, + }; + + tracing::info!( + "Building sandbox image from {}: {}", + dockerfile_str, + self.image + ); + + let output = Command::new("docker") + .arg("build") + .arg("-f") + .arg(dockerfile_path) + .arg("-t") + .arg(&self.image) + .arg(".") + .current_dir(context_dir) + .output() + .await + .map_err(|e| SandboxError::ContainerCreationFailed { + reason: format!("failed to run docker build: {}", e), + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(SandboxError::ContainerCreationFailed { + reason: format!( + "docker build failed (exit {}): {}", + output.status.code().unwrap_or(-1), + stderr + ), + }); + } + + tracing::info!("Successfully built image: {}", self.image); + Ok(()) + } + /// Execute a command in a new container. pub async fn execute( &self, diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index 67dec9dc884..19ed30651f9 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -59,6 +59,9 @@ pub enum SetupError { #[error("User cancelled")] Cancelled, + + #[error("Sandbox error: {0}")] + Sandbox(#[from] crate::sandbox::error::SandboxError), } impl From for SetupError { @@ -2074,6 +2077,9 @@ impl SetupWizard { crate::sandbox::detect::DockerStatus::Available => { self.settings.sandbox.enabled = true; print_success("Docker is installed and running. Sandbox enabled."); + + // Check if the worker image exists + self.ensure_worker_image().await?; } crate::sandbox::detect::DockerStatus::NotInstalled | crate::sandbox::detect::DockerStatus::NotRunning => { @@ -2103,6 +2109,8 @@ impl SetupWizard { } else { "Docker is now running. Sandbox enabled." }); + // Check if the worker image exists + self.ensure_worker_image().await?; } else { self.settings.sandbox.enabled = false; print_info(if not_installed { @@ -2189,6 +2197,81 @@ impl SetupWizard { Ok(()) } + /// Ensure the sandbox worker Docker image exists, building it if necessary. + async fn ensure_worker_image(&mut self) -> Result<(), SetupError> { + use crate::sandbox::container::{ContainerRunner, connect_docker}; + + let image_name = self.settings.sandbox.image.clone(); + let docker = connect_docker() + .await + .map_err(|e| SetupError::Auth(e.to_string()))?; + let runner = ContainerRunner::new(docker, image_name.clone(), 0); + + if runner.image_exists().await { + print_success(&format!("Worker image '{}' found.", image_name)); + return Ok(()); + } + + println!(); + print_info(&format!("Worker image '{}' not found.", image_name)); + print_info("This image is required for sandboxed job execution."); + println!(); + + // Look for Dockerfile.worker in common locations + let dockerfile_candidates = [ + std::path::PathBuf::from("Dockerfile.worker"), + std::path::PathBuf::from("docker/sandbox.Dockerfile"), + ]; + + let dockerfile_path = dockerfile_candidates.iter().find(|p| p.exists()).cloned(); + + match dockerfile_path { + Some(path) => { + print_info(&format!("Found Dockerfile at: {}", path.display())); + if confirm( + "Build the worker image now? (this may take a few minutes)", + true, + ) + .map_err(SetupError::Io)? + { + print_info("Building worker image... This may take a few minutes."); + match runner.build_image(&path).await { + Ok(()) => { + print_success(&format!("Successfully built image '{}'.", image_name)); + } + Err(e) => { + print_error(&format!("Failed to build image: {}", e)); + print_info("You can build it manually later with:"); + print_info(&format!( + " docker build -f {} -t {} .", + path.display(), + image_name + )); + } + } + } else { + print_info("Skipped image build. Build it manually with:"); + print_info(&format!( + " docker build -f {} -t {} .", + path.display(), + image_name + )); + } + } + None => { + print_info("No Dockerfile.worker found in current directory."); + print_info("To use Docker sandbox, build the worker image manually:"); + print_info(&format!( + " docker build -f Dockerfile.worker -t {} .", + image_name + )); + print_info("or clone the IronClaw repository and build from source."); + } + } + + Ok(()) + } + /// Step 9: Heartbeat configuration. fn step_heartbeat(&mut self) -> Result<(), SetupError> { print_info("Heartbeat runs periodic background tasks (e.g., checking your calendar,");