diff --git a/crates/sandcage/src/config.rs b/crates/sandcage/src/config.rs index 5e142a2..d36ff17 100644 --- a/crates/sandcage/src/config.rs +++ b/crates/sandcage/src/config.rs @@ -80,6 +80,8 @@ struct RawConfig { services: Option>, #[serde(default)] default_services: Option>, + #[serde(default)] + image: Option, /// Absorb everything else so we can warn about unknown fields. #[serde(flatten)] @@ -121,13 +123,15 @@ pub struct SandcageConfig { pub services: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub default_services: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub image: Option, } // --------------------------------------------------------------------------- // Known keys — used to filter the flattened `extra` map // --------------------------------------------------------------------------- -const KNOWN_KEYS: &[&str] = &["env", "packages", "toolchains", "mounts", "shell", "justfile", "dockerfiles", "trusted_projects", "container_workspace", "agent_args", "ssh_mode", "ssh_keys", "services", "default_services"]; +const KNOWN_KEYS: &[&str] = &["env", "packages", "toolchains", "mounts", "shell", "justfile", "dockerfiles", "trusted_projects", "container_workspace", "agent_args", "ssh_mode", "ssh_keys", "services", "default_services", "image"]; // --------------------------------------------------------------------------- // Validation helpers @@ -184,6 +188,7 @@ fn from_raw(raw: RawConfig) -> SandcageConfig { ssh_keys: raw.ssh_keys, services: raw.services, default_services: raw.default_services, + image: raw.image, } } @@ -718,4 +723,17 @@ services: "project should override global" ); } + + #[test] + fn parse_image_field() { + let yaml = "image: my-custom\n"; + let cfg = load_from_str(yaml).expect("parse image field"); + assert_eq!(cfg.image.as_deref(), Some("my-custom")); + } + + #[test] + fn image_defaults_to_none() { + let cfg = load_from_str("").expect("parse empty"); + assert!(cfg.image.is_none()); + } } diff --git a/crates/sandcage/src/docker.rs b/crates/sandcage/src/docker.rs index fdff9aa..a16bbfa 100644 --- a/crates/sandcage/src/docker.rs +++ b/crates/sandcage/src/docker.rs @@ -190,12 +190,15 @@ pub fn build_compose_context(workspace: &Path, config: &SandcageConfig) -> Resul None => default_container_dir(workspace), }; + let image = config.image.as_deref().unwrap_or("sandcage").to_string(); + Ok(crate::service::ComposeContext { uid, gid, workspace: workspace.to_path_buf(), container_dir, sandcage_home, + image, }) } @@ -220,10 +223,6 @@ fn write_compose_tempfile(content: &str) -> Result { Ok(tmp) } -fn image_for_service(_service: &str) -> &'static str { - "sandcage" -} - fn require_image(docker: &Path, image: &str) -> Result<()> { let output = Command::new(docker) .args(["image", "inspect", image]) @@ -355,8 +354,9 @@ pub fn run_service( }); } - let image = image_for_service(service); - require_image(&docker, image)?; + let ctx = build_compose_context(workspace, config)?; + let image_tag = format!("{}:latest", ctx.image); + require_image(&docker, &image_tag)?; if config.ssh_mode.as_deref() == Some("volume") { let vol_check = Command::new(&docker) @@ -369,7 +369,6 @@ pub fn run_service( } } - let ctx = build_compose_context(workspace, config)?; let compose_content = crate::service::compose::generate_compose(registry, &ctx); let compose_file = write_compose_tempfile(&compose_content)?; let compose_path = compose_file.path().to_string_lossy().into_owned(); @@ -605,8 +604,9 @@ pub fn build_images(force: bool, service_filter: &[String]) -> Result<()> { } - let images: &[(&str, &str)] = &[ - ("sandcage", DOCKERFILE), + let image_name = global_cfg.image.as_deref().unwrap_or("sandcage"); + let images: Vec<(&str, &str)> = vec![ + (image_name, DOCKERFILE), ]; let hashes_path = build_hashes_path()?; @@ -622,7 +622,7 @@ pub fn build_images(force: bool, service_filter: &[String]) -> Result<()> { sha256_hex(bundled_dockerfile) }; - let needs_build = force || stored.get(*image) != Some(¤t_hash); + let needs_build = force || stored.get(image) != Some(¤t_hash); if needs_build { if force { @@ -672,6 +672,7 @@ mod tests { workspace: PathBuf::from("/tmp/test"), container_dir: "/workspace/test".to_string(), sandcage_home: PathBuf::from("/home/user/.sandcage"), + image: "sandcage".to_string(), }; let yaml = generate_compose(®istry, &ctx); @@ -860,14 +861,6 @@ mod tests { assert!(path.exists(), "hash file should have been created"); } - #[test] - fn image_for_service_maps_correctly() { - assert_eq!(image_for_service("claude"), "sandcage"); - assert_eq!(image_for_service("codex"), "sandcage"); - assert_eq!(image_for_service("shell"), "sandcage"); - assert_eq!(image_for_service("anything-else"), "sandcage"); - } - #[test] fn require_image_fails_for_nonexistent_image() { let docker = match require_docker() { diff --git a/crates/sandcage/src/service/compose.rs b/crates/sandcage/src/service/compose.rs index 24bf5a4..a460d43 100644 --- a/crates/sandcage/src/service/compose.rs +++ b/crates/sandcage/src/service/compose.rs @@ -37,6 +37,7 @@ mod tests { workspace: PathBuf::from("/home/user/projects/my-app"), container_dir: "/workspace/my-app".to_string(), sandcage_home: PathBuf::from("/home/user/.sandcage"), + image: "sandcage".to_string(), } } diff --git a/crates/sandcage/src/service/mod.rs b/crates/sandcage/src/service/mod.rs index 660cd93..c9ab430 100644 --- a/crates/sandcage/src/service/mod.rs +++ b/crates/sandcage/src/service/mod.rs @@ -17,6 +17,7 @@ pub struct ComposeContext { pub workspace: PathBuf, pub container_dir: String, pub sandcage_home: PathBuf, + pub image: String, } #[derive(Debug, Clone, Serialize)] @@ -96,7 +97,7 @@ impl Service for ServiceDef { }; ComposeServiceDef { - image: "sandcage:latest".to_string(), + image: format!("{}:latest", ctx.image), entrypoint, working_dir: ctx.container_dir.clone(), user: format!("{}:{}", ctx.uid, ctx.gid), @@ -130,6 +131,7 @@ mod tests { workspace: PathBuf::from("/home/user/projects/my-app"), container_dir: "/workspace/my-app".to_string(), sandcage_home: PathBuf::from("/home/user/.sandcage"), + image: "sandcage".to_string(), } } @@ -231,4 +233,13 @@ mod tests { let compose = svc.compose_service(&ctx); assert_eq!(compose.entrypoint, vec!["/bin/zsh"]); } + + #[test] + fn compose_service_uses_configured_image() { + let svc = agent_service(); + let mut ctx = test_context(); + ctx.image = "custom-img".to_string(); + let compose = svc.compose_service(&ctx); + assert_eq!(compose.image, "custom-img:latest"); + } } diff --git a/docs/configuration.md b/docs/configuration.md index 841ddef..59ca47c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -267,6 +267,26 @@ default_services: --- +### image + +The Docker image name used for building and running containers. + +- **Type:** String +- **Valid in:** Global, Project, Local +- **Default:** `"sandcage"` + +```yaml +# .sandcage.yml +image: my-custom-sandcage +``` + +```toml +# ~/.sandcage/config.toml +image = "my-custom-sandcage" +``` + +--- + ## Services Sandcage ships with four built-in services, all enabled by default: