From b0196faf58cde29648be4ae86c08d982c07c909d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabor=20K=C3=B6rber?= Date: Sun, 24 May 2026 18:40:35 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=A5=87=20export=20from=20upstream=20(0aed?= =?UTF-8?q?74f)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 1 + compose/docker-compose.yml | 39 ---- crates/sandcage/Cargo.toml | 1 + crates/sandcage/src/config.rs | 78 +++++++- crates/sandcage/src/docker.rs | 146 +++++++++++---- crates/sandcage/src/init.rs | 11 +- crates/sandcage/src/lib.rs | 1 + crates/sandcage/src/main.rs | 39 +++- crates/sandcage/src/service/builtin.rs | 157 ++++++++++++++++ crates/sandcage/src/service/compose.rs | 150 +++++++++++++++ crates/sandcage/src/service/mod.rs | 234 +++++++++++++++++++++++ crates/sandcage/src/service/registry.rs | 237 ++++++++++++++++++++++++ 12 files changed, 1013 insertions(+), 81 deletions(-) delete mode 100644 compose/docker-compose.yml create mode 100644 crates/sandcage/src/service/builtin.rs create mode 100644 crates/sandcage/src/service/compose.rs create mode 100644 crates/sandcage/src/service/mod.rs create mode 100644 crates/sandcage/src/service/registry.rs diff --git a/Cargo.lock b/Cargo.lock index f01db83..2a5c277 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -780,6 +780,7 @@ dependencies = [ "dirs", "figment", "hex", + "indexmap", "miette", "serde", "serde_yaml", diff --git a/compose/docker-compose.yml b/compose/docker-compose.yml deleted file mode 100644 index 9cc6a6f..0000000 --- a/compose/docker-compose.yml +++ /dev/null @@ -1,39 +0,0 @@ -services: - claude: - image: sandcage:latest - entrypoint: ["sandcage-claude-entrypoint"] - working_dir: ${SANDCAGE_CONTAINER_DIR} - user: "${SANDCAGE_UID}:${SANDCAGE_GID}" - volumes: - - ${SANDCAGE_HOME}/home:/home/agent - - ${SANDCAGE_WORKSPACE}:${SANDCAGE_CONTAINER_DIR} - environment: - - HOME=/home/agent - tty: true - stdin_open: true - - codex: - image: sandcage:latest - entrypoint: ["sandcage-codex-entrypoint"] - working_dir: ${SANDCAGE_CONTAINER_DIR} - user: "${SANDCAGE_UID}:${SANDCAGE_GID}" - volumes: - - ${SANDCAGE_HOME}/home:/home/agent - - ${SANDCAGE_WORKSPACE}:${SANDCAGE_CONTAINER_DIR} - environment: - - HOME=/home/agent - tty: true - stdin_open: true - - shell: - image: sandcage:latest - working_dir: ${SANDCAGE_CONTAINER_DIR} - user: "${SANDCAGE_UID}:${SANDCAGE_GID}" - volumes: - - ${SANDCAGE_HOME}/home:/home/agent - - ${SANDCAGE_WORKSPACE}:${SANDCAGE_CONTAINER_DIR} - environment: - - HOME=/home/agent - tty: true - stdin_open: true - entrypoint: ["/bin/zsh"] diff --git a/crates/sandcage/Cargo.toml b/crates/sandcage/Cargo.toml index fa17f27..1ec9e3b 100644 --- a/crates/sandcage/Cargo.toml +++ b/crates/sandcage/Cargo.toml @@ -17,6 +17,7 @@ which = "7" dirs = "6" sha2 = "0.10" hex = "0.4" +indexmap = { version = "2", features = ["serde"] } tempfile = "3" dialoguer = "0.11" toml_edit = "0.22" diff --git a/crates/sandcage/src/config.rs b/crates/sandcage/src/config.rs index 6468ecf..5e142a2 100644 --- a/crates/sandcage/src/config.rs +++ b/crates/sandcage/src/config.rs @@ -39,6 +39,12 @@ pub struct SshKeyEntry { pub identity_file: String, } +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct ServiceOverride { + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, +} + // --------------------------------------------------------------------------- // Raw deserialization type — captures unknown keys // --------------------------------------------------------------------------- @@ -70,6 +76,10 @@ struct RawConfig { ssh_mode: Option, #[serde(default)] ssh_keys: Option>, + #[serde(default)] + services: Option>, + #[serde(default)] + default_services: Option>, /// Absorb everything else so we can warn about unknown fields. #[serde(flatten)] @@ -107,13 +117,17 @@ pub struct SandcageConfig { pub ssh_mode: Option, #[serde(skip_serializing_if = "Option::is_none")] pub ssh_keys: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub services: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_services: 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"]; +const KNOWN_KEYS: &[&str] = &["env", "packages", "toolchains", "mounts", "shell", "justfile", "dockerfiles", "trusted_projects", "container_workspace", "agent_args", "ssh_mode", "ssh_keys", "services", "default_services"]; // --------------------------------------------------------------------------- // Validation helpers @@ -168,6 +182,8 @@ fn from_raw(raw: RawConfig) -> SandcageConfig { agent_args: raw.agent_args, ssh_mode: raw.ssh_mode, ssh_keys: raw.ssh_keys, + services: raw.services, + default_services: raw.default_services, } } @@ -642,4 +658,64 @@ ssh_keys: let cfg = resolve_config(Some(global_tmp.path()), Some(project_tmp.path()), None).expect("resolve"); assert_eq!(cfg.ssh_mode.as_deref(), Some("volume")); } + + #[test] + fn parse_services_override() { + let yaml = r#" +services: + codex: + enabled: false + claude: + enabled: true +"#; + let cfg = load_from_str(yaml).expect("parse services"); + let services = cfg.services.expect("services should be Some"); + assert_eq!(services.get("codex").unwrap().enabled, Some(false)); + assert_eq!(services.get("claude").unwrap().enabled, Some(true)); + } + + #[test] + fn parse_default_services() { + let yaml = r#" +default_services: + - claude + - shell +"#; + let cfg = load_from_str(yaml).expect("parse default_services"); + let defaults = cfg.default_services.expect("default_services should be Some"); + assert_eq!(defaults, vec!["claude", "shell"]); + } + + #[test] + fn services_defaults_to_none() { + let cfg = load_from_str("").expect("parse empty"); + assert!(cfg.services.is_none()); + assert!(cfg.default_services.is_none()); + } + + #[test] + fn resolve_services_project_overrides_global() { + let global_toml = r#" +[services.codex] +enabled = true +"#; + let project_yaml = r#" +services: + codex: + enabled: false +"#; + let mut global_tmp = NamedTempFile::new().expect("create global tmpfile"); + write!(global_tmp, "{global_toml}").expect("write global tmpfile"); + let mut project_tmp = NamedTempFile::new().expect("create project tmpfile"); + write!(project_tmp, "{project_yaml}").expect("write project tmpfile"); + + let cfg = resolve_config(Some(global_tmp.path()), Some(project_tmp.path()), None) + .expect("resolve"); + let services = cfg.services.expect("services should be Some"); + assert_eq!( + services.get("codex").unwrap().enabled, + Some(false), + "project should override global" + ); + } } diff --git a/crates/sandcage/src/docker.rs b/crates/sandcage/src/docker.rs index 7468fa2..fdff9aa 100644 --- a/crates/sandcage/src/docker.rs +++ b/crates/sandcage/src/docker.rs @@ -16,8 +16,6 @@ use crate::config::SandcageConfig; pub const DOCKERFILE: &str = include_str!("../../../images/base/Dockerfile"); -pub const COMPOSE_YAML: &str = include_str!("../../../compose/docker-compose.yml"); - #[derive(Debug, Error, Diagnostic)] pub enum DockerError { #[error("docker executable not found in PATH")] @@ -80,6 +78,20 @@ pub enum DockerError { help("Run `sandcage build` to build the required images.") )] ImageNotFound { image: String }, + + #[error("Service '{service}' is not registered")] + #[diagnostic( + code(sandcage::docker::unknown_service), + help("Available services: {available}") + )] + UnknownService { service: String, available: String }, + + #[error("Service '{service}' is disabled in config")] + #[diagnostic( + code(sandcage::docker::service_disabled), + help("Enable it with: services.{service}.enabled: true") + )] + ServiceDisabled { service: String }, } pub type Result = std::result::Result; @@ -156,6 +168,37 @@ pub fn build_compose_env(workspace: &Path, config: &SandcageConfig) -> Result Result { + let home = dirs::home_dir().ok_or(DockerError::NoHomeDir)?; + let sandcage_home = home.join(".sandcage"); + + let (uid, gid) = if cfg!(windows) { + ("1000".to_string(), "1000".to_string()) + } else { + (id_flag("-u")?, id_flag("-g")?) + }; + + let container_dir = match &config.container_workspace { + Some(path) if path.starts_with('/') => path.clone(), + Some(path) => { + eprintln!( + "sandcage: warning: container_workspace '{}' is not absolute, ignoring", + path + ); + default_container_dir(workspace) + } + None => default_container_dir(workspace), + }; + + Ok(crate::service::ComposeContext { + uid, + gid, + workspace: workspace.to_path_buf(), + container_dir, + sandcage_home, + }) +} + fn default_container_dir(workspace: &Path) -> String { match workspace.file_name() { Some(name) => format!("/workspace/{}", name.to_string_lossy()), @@ -163,14 +206,14 @@ fn default_container_dir(workspace: &Path) -> String { } } -fn write_compose_tempfile() -> Result { +fn write_compose_tempfile(content: &str) -> Result { let mut tmp = tempfile::Builder::new() .prefix("sandcage-compose-") .suffix(".yml") .tempfile() .map_err(DockerError::TempfileFailed)?; - tmp.write_all(COMPOSE_YAML.as_bytes()) + tmp.write_all(content.as_bytes()) .map_err(DockerError::TempfileFailed)?; tmp.flush().map_err(DockerError::TempfileFailed)?; @@ -294,12 +337,24 @@ pub fn run_service( service: &str, workspace: &Path, config: &SandcageConfig, + registry: &crate::service::registry::ServiceRegistry, shell_override: bool, extra_args: &[String], ) -> Result<()> { let docker = require_docker()?; require_compose(&docker)?; + let svc = registry.get(service).ok_or_else(|| DockerError::UnknownService { + service: service.to_string(), + available: registry.names().collect::>().join(", "), + })?; + + if !svc.enabled() { + return Err(DockerError::ServiceDisabled { + service: service.to_string(), + }); + } + let image = image_for_service(service); require_image(&docker, image)?; @@ -314,20 +369,16 @@ pub fn run_service( } } - let compose_file = write_compose_tempfile()?; + 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(); - let compose_env = build_compose_env(workspace, config)?; - let run_args = build_run_args(service, &compose_path, config, shell_override, extra_args); let mut cmd = Command::new(&docker); cmd.args(&run_args); - // Inject compose environment variables - cmd.envs(&compose_env); - - // Inherit stdio for interactive use cmd.stdin(std::process::Stdio::inherit()) .stdout(std::process::Stdio::inherit()) .stderr(std::process::Stdio::inherit()); @@ -412,6 +463,17 @@ fn build_one_image_from_content(docker: &Path, image: &str, dockerfile_content: std::fs::write(&dockerfile_path, dockerfile_content) .map_err(DockerError::DockerfileWriteFailed)?; + for svc in crate::service::builtin::all() { + if let (Some(name), Some(script)) = (svc.entrypoint_name.as_ref(), svc.entrypoint_script.as_ref()) + && name.starts_with("sandcage-") + { + let dir = tmp_dir.path().join("images").join(&svc.name); + std::fs::create_dir_all(&dir).map_err(DockerError::TempDirFailed)?; + std::fs::write(dir.join("entrypoint.sh"), script) + .map_err(DockerError::DockerfileWriteFailed)?; + } + } + run_docker_build(docker, image, tmp_dir.path(), None, no_cache) } @@ -483,7 +545,7 @@ fn read_dockerfile_at(path: &Path) -> Result { /// * When `force` is `true`, every image is rebuilt regardless of the stored hash. /// * Otherwise, an image is skipped when its Dockerfile hash matches the stored value. /// * After a successful build the hash is updated in `~/.sandcage/.build-hashes`. -pub fn build_images(force: bool) -> Result<()> { +pub fn build_images(force: bool, service_filter: &[String]) -> Result<()> { let docker = require_docker()?; let home = dirs::home_dir().ok_or(DockerError::NoHomeDir)?; @@ -494,6 +556,22 @@ pub fn build_images(force: bool) -> Result<()> { // Global overrides are always trusted. let global_cfg = crate::config::resolve_config(Some(&global_config), None, None) .unwrap_or_default(); + + // Validate service names if provided + if !service_filter.is_empty() { + let registry = crate::service::registry::build_default_registry(&global_cfg); + let known: Vec<&str> = registry.names().collect(); + for name in service_filter { + if !known.contains(&name.as_str()) { + return Err(DockerError::UnknownService { + service: name.clone(), + available: known.join(", "), + }); + } + } + eprintln!("sandcage: building for services: {}", service_filter.join(", ")); + } + let mut overrides = global_cfg.dockerfiles.unwrap_or_default(); // Project overrides require the project to be in trusted_projects. @@ -581,23 +659,26 @@ mod tests { use std::path::PathBuf; #[test] - fn compose_yaml_is_bundled() { - assert!( - COMPOSE_YAML.contains("services:"), - "bundled compose YAML should contain 'services:'" - ); - assert!( - COMPOSE_YAML.contains("claude:"), - "bundled compose YAML should define 'claude' service" - ); - assert!( - COMPOSE_YAML.contains("codex:"), - "bundled compose YAML should define 'codex' service" - ); - assert!( - COMPOSE_YAML.contains("shell:"), - "bundled compose YAML should define 'shell' service" - ); + fn generated_compose_contains_all_services() { + use crate::service::ComposeContext; + use crate::service::registry::build_default_registry; + use crate::service::compose::generate_compose; + + let config = SandcageConfig::default(); + let registry = build_default_registry(&config); + let ctx = ComposeContext { + uid: "1000".to_string(), + gid: "1000".to_string(), + workspace: PathBuf::from("/tmp/test"), + container_dir: "/workspace/test".to_string(), + sandcage_home: PathBuf::from("/home/user/.sandcage"), + }; + let yaml = generate_compose(®istry, &ctx); + + assert!(yaml.contains("claude:"), "should contain claude service"); + assert!(yaml.contains("codex:"), "should contain codex service"); + assert!(yaml.contains("gemini:"), "should contain gemini service"); + assert!(yaml.contains("shell:"), "should contain shell service"); } #[test] @@ -649,9 +730,10 @@ mod tests { #[test] fn write_compose_tempfile_creates_valid_yaml() { - let tmp = write_compose_tempfile().expect("write_compose_tempfile"); - let content = std::fs::read_to_string(tmp.path()).expect("read tempfile"); - assert_eq!(content, COMPOSE_YAML, "tempfile content should match bundled YAML"); + let content = "services:\n test:\n image: test:latest\n"; + let tmp = write_compose_tempfile(content).expect("write_compose_tempfile"); + let read_back = std::fs::read_to_string(tmp.path()).expect("read tempfile"); + assert_eq!(read_back, content, "tempfile content should match input"); } #[test] diff --git a/crates/sandcage/src/init.rs b/crates/sandcage/src/init.rs index 0e815a5..5f70c70 100644 --- a/crates/sandcage/src/init.rs +++ b/crates/sandcage/src/init.rs @@ -47,6 +47,10 @@ pub fn preseed_with_home(home: &Path) -> Result<()> { let codex_dir = agent_home.join(".codex"); create_dir_all(&codex_dir)?; + // ~/.sandcage/home/.gemini/ + let gemini_dir = agent_home.join(".gemini"); + create_dir_all(&gemini_dir)?; + // 4. Seed Claude settings.json from the bundled template let settings_dest = claude_dir.join("settings.json"); if !settings_dest.exists() { @@ -231,7 +235,7 @@ mod tests { } #[test] - fn creates_claude_and_codex_dirs() { + fn creates_agent_config_dirs() { let home = TempDir::new().expect("create tempdir"); run(home.path()).expect("preseed"); @@ -243,6 +247,10 @@ mod tests { home.path().join(".sandcage/home/.codex").is_dir(), ".codex dir should exist" ); + assert!( + home.path().join(".sandcage/home/.gemini").is_dir(), + ".gemini dir should exist" + ); } #[test] @@ -320,6 +328,7 @@ mod tests { // All artefacts should still exist after two runs assert!(home.path().join(".sandcage/home/.claude").is_dir()); assert!(home.path().join(".sandcage/home/.codex").is_dir()); + assert!(home.path().join(".sandcage/home/.gemini").is_dir()); assert!(home.path().join(".sandcage/home/.claude/settings.json").exists()); assert!(home.path().join(".sandcage/home/.justfile").exists()); } diff --git a/crates/sandcage/src/lib.rs b/crates/sandcage/src/lib.rs index d778be2..ab38bba 100644 --- a/crates/sandcage/src/lib.rs +++ b/crates/sandcage/src/lib.rs @@ -1,6 +1,7 @@ pub mod config; pub mod docker; pub mod init; +pub mod service; pub mod setup; pub mod ssh_config; pub mod workspace; diff --git a/crates/sandcage/src/main.rs b/crates/sandcage/src/main.rs index b548490..63958ab 100644 --- a/crates/sandcage/src/main.rs +++ b/crates/sandcage/src/main.rs @@ -6,6 +6,7 @@ use thiserror::Error; use sandcage::config; use sandcage::docker; use sandcage::init; +use sandcage::service; use sandcage::setup; use sandcage::workspace; @@ -47,17 +48,34 @@ enum Commands { #[arg(trailing_var_arg = true, allow_hyphen_values = true)] agent_args: Vec, }, + /// Run Gemini CLI agent in a sandboxed container + Gemini { + /// Path to the project directory (defaults to current directory) + #[arg(long, short)] + path: Option, + + /// Drop into a shell instead of launching the agent + #[arg(long)] + shell: bool, + + /// Arguments forwarded to the agent inside the container + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + agent_args: Vec, + }, /// Interactive shell with the same environment Shell { /// Path to the project directory (defaults to current directory) #[arg(long, short)] path: Option, }, - /// Build all container images + /// Build container images Build { /// Force rebuild even if images are up to date #[arg(long, short)] force: bool, + + /// Services to build (default: all enabled services) + services: Vec, }, /// Initialize a .sandcage.yml for a project Init, @@ -113,7 +131,7 @@ enum AppError { Setup(#[from] setup::SetupError), } -fn run_agent(service: &str, path: Option, shell_override: bool, agent_args: Vec) -> std::result::Result<(), AppError> { +fn run_service(service: &str, path: Option, shell_override: bool, agent_args: Vec) -> std::result::Result<(), AppError> { let workspace = workspace::resolve_workspace(path.as_deref())?; eprintln!("sandcage: workspace \u{2192} {}", workspace.display()); @@ -129,8 +147,10 @@ fn run_agent(service: &str, path: Option, shell_override: bool, agent_a Some(&local_config), )?; + let registry = service::registry::build_default_registry(&cfg); + eprintln!("sandcage: service \u{2192} {service}"); - docker::run_service(service, &workspace, &cfg, shell_override, &agent_args)?; + docker::run_service(service, &workspace, &cfg, ®istry, shell_override, &agent_args)?; Ok(()) } @@ -140,14 +160,17 @@ fn main() -> miette::Result<()> { match cli.command { Commands::Claude { path, shell, agent_args } => { - run_agent("claude", path, shell, agent_args)? + run_service("claude", path, shell, agent_args)? } Commands::Codex { path, shell, agent_args } => { - run_agent("codex", path, shell, agent_args)? + run_service("codex", path, shell, agent_args)? } - Commands::Shell { path } => run_agent("shell", path, false, vec![])?, - Commands::Build { force } => { - docker::build_images(force)?; + Commands::Gemini { path, shell, agent_args } => { + run_service("gemini", path, shell, agent_args)? + } + Commands::Shell { path } => run_service("shell", path, false, vec![])?, + Commands::Build { force, services } => { + docker::build_images(force, &services)?; } Commands::Init => { let workspace = workspace::resolve_workspace(None)?; diff --git a/crates/sandcage/src/service/builtin.rs b/crates/sandcage/src/service/builtin.rs new file mode 100644 index 0000000..1736dc0 --- /dev/null +++ b/crates/sandcage/src/service/builtin.rs @@ -0,0 +1,157 @@ +use super::ServiceDef; + +pub const CLAUDE_ENTRYPOINT: &str = r#"#!/bin/sh +set -e +CLAUDE_BIN="$HOME/.local/bin/claude" +if [ ! -x "$CLAUDE_BIN" ]; then + echo "sandcage: installing Claude Code..." >&2 + curl -fsSL https://claude.ai/install.sh | bash >&2 +fi +exec "$CLAUDE_BIN" "$@" +"#; + +pub const CODEX_ENTRYPOINT: &str = r#"#!/bin/sh +set -e +CODEX_BIN="$HOME/.local/bin/codex" +if [ ! -x "$CODEX_BIN" ]; then + echo "sandcage: installing Codex..." >&2 + arch=$(uname -m) + case "$arch" in + x86_64) target="x86_64-unknown-linux-musl" ;; + aarch64) target="aarch64-unknown-linux-musl" ;; + *) echo "sandcage: unsupported arch: $arch" >&2; exit 1 ;; + esac + mkdir -p "$HOME/.local/bin" + curl -fsSL "https://github.com/openai/codex/releases/latest/download/codex-${target}.tar.gz" \ + | tar xz -C "$HOME/.local/bin" + mv "$HOME/.local/bin/codex-${target}" "$CODEX_BIN" +fi +exec "$CODEX_BIN" "$@" +"#; + +pub const GEMINI_ENTRYPOINT: &str = r#"#!/bin/sh +set -e +GEMINI_BIN="$HOME/.local/bin/gemini" +if [ ! -x "$GEMINI_BIN" ]; then + echo "sandcage: installing Gemini CLI..." >&2 + npm install -g @anthropic-ai/gemini-cli >&2 +fi +exec "$GEMINI_BIN" "$@" +"#; + +pub fn claude() -> ServiceDef { + ServiceDef { + name: "claude".to_string(), + description: "Claude Code agent".to_string(), + enabled: true, + entrypoint_name: Some("sandcage-claude-entrypoint".to_string()), + entrypoint_script: Some(CLAUDE_ENTRYPOINT.to_string()), + config_dir: Some(".claude".to_string()), + } +} + +pub fn codex() -> ServiceDef { + ServiceDef { + name: "codex".to_string(), + description: "Codex agent".to_string(), + enabled: true, + entrypoint_name: Some("sandcage-codex-entrypoint".to_string()), + entrypoint_script: Some(CODEX_ENTRYPOINT.to_string()), + config_dir: Some(".codex".to_string()), + } +} + +pub fn gemini() -> ServiceDef { + ServiceDef { + name: "gemini".to_string(), + description: "Gemini CLI agent".to_string(), + enabled: true, + entrypoint_name: Some("sandcage-gemini-entrypoint".to_string()), + entrypoint_script: Some(GEMINI_ENTRYPOINT.to_string()), + config_dir: Some(".gemini".to_string()), + } +} + +pub fn shell() -> ServiceDef { + ServiceDef { + name: "shell".to_string(), + description: "Interactive shell".to_string(), + enabled: true, + entrypoint_name: Some("/bin/zsh".to_string()), + entrypoint_script: None, + config_dir: None, + } +} + +pub fn all() -> Vec { + vec![claude(), codex(), gemini(), shell()] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::service::Service; + + #[test] + fn all_builtins_are_enabled_by_default() { + for svc in all() { + assert!(svc.enabled(), "{} should be enabled by default", svc.name()); + } + } + + #[test] + fn all_builtins_have_unique_names() { + let services = all(); + let mut names: Vec<&str> = services.iter().map(|s| s.name()).collect(); + let count = names.len(); + names.dedup(); + assert_eq!(names.len(), count, "service names must be unique"); + } + + #[test] + fn agent_services_have_entrypoint_scripts() { + for svc in [claude(), codex(), gemini()] { + assert!( + svc.entrypoint_script().is_some(), + "{} should have an entrypoint script", + svc.name() + ); + assert!( + svc.entrypoint_script().unwrap().starts_with("#!/bin/sh"), + "{} entrypoint should start with shebang", + svc.name() + ); + } + } + + #[test] + fn shell_has_no_entrypoint_script() { + let svc = shell(); + assert!(svc.entrypoint_script().is_none()); + assert_eq!(svc.entrypoint_name(), Some("/bin/zsh")); + } + + #[test] + fn agent_services_have_config_dirs() { + assert_eq!(claude().config_dir(), Some(".claude")); + assert_eq!(codex().config_dir(), Some(".codex")); + assert_eq!(gemini().config_dir(), Some(".gemini")); + } + + #[test] + fn shell_has_no_config_dir() { + assert_eq!(shell().config_dir(), None); + } + + #[test] + fn claude_entrypoint_installs_from_claude_ai() { + assert!(CLAUDE_ENTRYPOINT.contains("claude.ai/install.sh")); + } + + #[test] + fn codex_entrypoint_handles_arch_detection() { + assert!(CODEX_ENTRYPOINT.contains("uname -m")); + assert!(CODEX_ENTRYPOINT.contains("x86_64")); + assert!(CODEX_ENTRYPOINT.contains("aarch64")); + } +} diff --git a/crates/sandcage/src/service/compose.rs b/crates/sandcage/src/service/compose.rs new file mode 100644 index 0000000..24bf5a4 --- /dev/null +++ b/crates/sandcage/src/service/compose.rs @@ -0,0 +1,150 @@ +use super::registry::ServiceRegistry; +use super::ComposeContext; + +pub fn generate_compose(registry: &ServiceRegistry, ctx: &ComposeContext) -> String { + let mut services = serde_yaml::Mapping::new(); + + for svc in registry.enabled() { + let def = svc.compose_service(ctx); + let value = serde_yaml::to_value(&def).expect("ComposeServiceDef must serialize"); + services.insert( + serde_yaml::Value::String(svc.name().to_string()), + value, + ); + } + + let mut doc = serde_yaml::Mapping::new(); + doc.insert( + serde_yaml::Value::String("services".to_string()), + serde_yaml::Value::Mapping(services), + ); + + serde_yaml::to_string(&serde_yaml::Value::Mapping(doc)) + .expect("compose doc must serialize") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::service::builtin; + use crate::service::ServiceDef; + use std::path::PathBuf; + + fn test_context() -> ComposeContext { + ComposeContext { + uid: "1000".to_string(), + gid: "1000".to_string(), + workspace: PathBuf::from("/home/user/projects/my-app"), + container_dir: "/workspace/my-app".to_string(), + sandcage_home: PathBuf::from("/home/user/.sandcage"), + } + } + + fn registry_with(services: Vec) -> ServiceRegistry { + let mut reg = ServiceRegistry::new(); + for svc in services { + reg.register(Box::new(svc)); + } + reg + } + + #[test] + fn generates_valid_yaml() { + let reg = registry_with(vec![builtin::claude(), builtin::shell()]); + let yaml = generate_compose(®, &test_context()); + + let parsed: serde_yaml::Value = + serde_yaml::from_str(&yaml).expect("output must be valid YAML"); + assert!(parsed.as_mapping().unwrap().contains_key("services")); + } + + #[test] + fn contains_only_enabled_services() { + let mut codex = builtin::codex(); + codex.enabled = false; + + let reg = registry_with(vec![builtin::claude(), codex, builtin::shell()]); + let yaml = generate_compose(®, &test_context()); + + assert!(yaml.contains("claude:"), "claude should be present"); + assert!(yaml.contains("shell:"), "shell should be present"); + assert!(!yaml.contains("codex:"), "disabled codex should be absent"); + } + + #[test] + fn empty_registry_produces_empty_services() { + let reg = ServiceRegistry::new(); + let yaml = generate_compose(®, &test_context()); + + let parsed: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap(); + let services = parsed.as_mapping().unwrap().get("services").unwrap(); + assert!( + services.as_mapping().unwrap().is_empty(), + "services should be empty mapping" + ); + } + + #[test] + fn service_has_correct_image() { + let reg = registry_with(vec![builtin::claude()]); + let yaml = generate_compose(®, &test_context()); + assert!(yaml.contains("sandcage:latest")); + } + + #[test] + fn service_has_correct_entrypoint() { + let reg = registry_with(vec![builtin::claude()]); + let yaml = generate_compose(®, &test_context()); + assert!(yaml.contains("sandcage-claude-entrypoint")); + } + + #[test] + fn service_has_correct_working_dir() { + let reg = registry_with(vec![builtin::claude()]); + let yaml = generate_compose(®, &test_context()); + assert!(yaml.contains("/workspace/my-app")); + } + + #[test] + fn service_has_home_volume() { + let reg = registry_with(vec![builtin::claude()]); + let yaml = generate_compose(®, &test_context()); + assert!(yaml.contains("/home/user/.sandcage/home:/home/agent")); + } + + #[test] + fn all_four_builtins_generate_successfully() { + let reg = registry_with(builtin::all()); + let yaml = generate_compose(®, &test_context()); + + for name in ["claude", "codex", "gemini", "shell"] { + assert!( + yaml.contains(&format!("{name}:")), + "compose should contain {name} service" + ); + } + } + + #[test] + fn backward_compat_claude_codex_shell_match_old_structure() { + let reg = registry_with(vec![ + builtin::claude(), + builtin::codex(), + builtin::shell(), + ]); + let yaml = generate_compose(®, &test_context()); + let parsed: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap(); + let services = parsed["services"].as_mapping().unwrap(); + + for name in ["claude", "codex", "shell"] { + let svc = &services[&serde_yaml::Value::String(name.to_string())]; + assert_eq!( + svc["image"].as_str().unwrap(), + "sandcage:latest", + "{name}: image" + ); + assert!(svc["tty"].as_bool().unwrap(), "{name}: tty"); + assert!(svc["stdin_open"].as_bool().unwrap(), "{name}: stdin_open"); + } + } +} diff --git a/crates/sandcage/src/service/mod.rs b/crates/sandcage/src/service/mod.rs new file mode 100644 index 0000000..660cd93 --- /dev/null +++ b/crates/sandcage/src/service/mod.rs @@ -0,0 +1,234 @@ +pub mod builtin; +pub mod compose; +pub mod registry; + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Compose output types +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +pub struct ComposeContext { + pub uid: String, + pub gid: String, + pub workspace: PathBuf, + pub container_dir: String, + pub sandcage_home: PathBuf, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ComposeServiceDef { + pub image: String, + pub entrypoint: Vec, + pub working_dir: String, + pub user: String, + pub volumes: Vec, + pub environment: Vec, + pub tty: bool, + pub stdin_open: bool, +} + +// --------------------------------------------------------------------------- +// Service trait +// --------------------------------------------------------------------------- + +pub trait Service: std::fmt::Debug + Send + Sync { + fn name(&self) -> &str; + fn description(&self) -> &str; + fn enabled(&self) -> bool; + fn set_enabled(&mut self, enabled: bool); + fn entrypoint_name(&self) -> Option<&str>; + fn entrypoint_script(&self) -> Option<&str>; + fn config_dir(&self) -> Option<&str>; + fn compose_service(&self, ctx: &ComposeContext) -> ComposeServiceDef; +} + +// --------------------------------------------------------------------------- +// ServiceDef — standard implementation +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServiceDef { + pub name: String, + pub description: String, + pub enabled: bool, + pub entrypoint_name: Option, + pub entrypoint_script: Option, + pub config_dir: Option, +} + +impl Service for ServiceDef { + fn name(&self) -> &str { + &self.name + } + + fn description(&self) -> &str { + &self.description + } + + fn enabled(&self) -> bool { + self.enabled + } + + fn set_enabled(&mut self, enabled: bool) { + self.enabled = enabled; + } + + fn entrypoint_name(&self) -> Option<&str> { + self.entrypoint_name.as_deref() + } + + fn entrypoint_script(&self) -> Option<&str> { + self.entrypoint_script.as_deref() + } + + fn config_dir(&self) -> Option<&str> { + self.config_dir.as_deref() + } + + fn compose_service(&self, ctx: &ComposeContext) -> ComposeServiceDef { + let entrypoint = match self.entrypoint_name.as_deref() { + Some(name) => vec![name.to_string()], + None => vec!["/bin/zsh".to_string()], + }; + + ComposeServiceDef { + image: "sandcage:latest".to_string(), + entrypoint, + working_dir: ctx.container_dir.clone(), + user: format!("{}:{}", ctx.uid, ctx.gid), + volumes: vec![ + format!("{}/home:/home/agent", ctx.sandcage_home.display()), + format!( + "{}:{}", + ctx.workspace.display(), + ctx.container_dir + ), + ], + environment: vec!["HOME=/home/agent".to_string()], + tty: true, + stdin_open: true, + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn test_context() -> ComposeContext { + ComposeContext { + uid: "1000".to_string(), + gid: "1000".to_string(), + workspace: PathBuf::from("/home/user/projects/my-app"), + container_dir: "/workspace/my-app".to_string(), + sandcage_home: PathBuf::from("/home/user/.sandcage"), + } + } + + fn agent_service() -> ServiceDef { + ServiceDef { + name: "test-agent".to_string(), + description: "A test agent".to_string(), + enabled: true, + entrypoint_name: Some("sandcage-test-entrypoint".to_string()), + entrypoint_script: Some("#!/bin/sh\nexec echo hello".to_string()), + config_dir: Some(".test-agent".to_string()), + } + } + + #[test] + fn service_def_trait_accessors() { + let svc = agent_service(); + assert_eq!(svc.name(), "test-agent"); + assert_eq!(svc.description(), "A test agent"); + assert!(svc.enabled()); + assert_eq!(svc.entrypoint_name(), Some("sandcage-test-entrypoint")); + assert!(svc.entrypoint_script().is_some()); + assert_eq!(svc.config_dir(), Some(".test-agent")); + } + + #[test] + fn service_def_set_enabled() { + let mut svc = agent_service(); + assert!(svc.enabled()); + svc.set_enabled(false); + assert!(!svc.enabled()); + } + + #[test] + fn compose_service_has_correct_image() { + let svc = agent_service(); + let ctx = test_context(); + let compose = svc.compose_service(&ctx); + assert_eq!(compose.image, "sandcage:latest"); + } + + #[test] + fn compose_service_uses_entrypoint_name() { + let svc = agent_service(); + let ctx = test_context(); + let compose = svc.compose_service(&ctx); + assert_eq!(compose.entrypoint, vec!["sandcage-test-entrypoint"]); + } + + #[test] + fn compose_service_shell_uses_bin_zsh_when_no_entrypoint() { + let svc = ServiceDef { + entrypoint_name: None, + ..agent_service() + }; + let ctx = test_context(); + let compose = svc.compose_service(&ctx); + assert_eq!(compose.entrypoint, vec!["/bin/zsh"]); + } + + #[test] + fn compose_service_has_correct_user() { + let svc = agent_service(); + let ctx = test_context(); + let compose = svc.compose_service(&ctx); + assert_eq!(compose.user, "1000:1000"); + } + + #[test] + fn compose_service_has_home_and_workspace_volumes() { + let svc = agent_service(); + let ctx = test_context(); + let compose = svc.compose_service(&ctx); + assert_eq!(compose.volumes.len(), 2); + assert!(compose.volumes[0].contains("/home:/home/agent")); + assert!(compose.volumes[1].contains("/workspace/my-app")); + } + + #[test] + fn compose_service_has_tty_and_stdin() { + let svc = agent_service(); + let ctx = test_context(); + let compose = svc.compose_service(&ctx); + assert!(compose.tty); + assert!(compose.stdin_open); + } + + #[test] + fn shell_service_uses_explicit_entrypoint() { + let svc = ServiceDef { + name: "shell".to_string(), + description: "Interactive shell".to_string(), + enabled: true, + entrypoint_name: Some("/bin/zsh".to_string()), + entrypoint_script: None, + config_dir: None, + }; + let ctx = test_context(); + let compose = svc.compose_service(&ctx); + assert_eq!(compose.entrypoint, vec!["/bin/zsh"]); + } +} diff --git a/crates/sandcage/src/service/registry.rs b/crates/sandcage/src/service/registry.rs new file mode 100644 index 0000000..fcd211a --- /dev/null +++ b/crates/sandcage/src/service/registry.rs @@ -0,0 +1,237 @@ +use indexmap::IndexMap; + +use super::Service; + +pub struct ServiceRegistry { + services: IndexMap>, +} + +impl Default for ServiceRegistry { + fn default() -> Self { + Self::new() + } +} + +impl ServiceRegistry { + pub fn new() -> Self { + Self { + services: IndexMap::new(), + } + } + + pub fn register(&mut self, service: Box) { + self.services.insert(service.name().to_string(), service); + } + + pub fn get(&self, name: &str) -> Option<&dyn Service> { + self.services.get(name).map(|s| s.as_ref()) + } + + pub fn get_mut(&mut self, name: &str) -> Option<&mut Box> { + self.services.get_mut(name) + } + + pub fn enabled(&self) -> impl Iterator { + self.services.values().filter(|s| s.enabled()).map(|s| s.as_ref()) + } + + pub fn all(&self) -> impl Iterator { + self.services.values().map(|s| s.as_ref()) + } + + pub fn names(&self) -> impl Iterator { + self.services.keys().map(|k| k.as_str()) + } + + pub fn enabled_names(&self) -> Vec<&str> { + self.enabled().map(|s| s.name()).collect() + } + + pub fn apply_overrides(&mut self, config: &crate::config::SandcageConfig) { + if let Some(ref overrides) = config.services { + for (name, ovr) in overrides { + if let Some(svc) = self.get_mut(name) + && let Some(enabled) = ovr.enabled + { + svc.set_enabled(enabled); + } + } + } + } +} + +impl std::fmt::Debug for ServiceRegistry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ServiceRegistry") + .field("services", &self.services.keys().collect::>()) + .finish() + } +} + +pub fn build_default_registry(config: &crate::config::SandcageConfig) -> ServiceRegistry { + let mut reg = ServiceRegistry::new(); + for svc in super::builtin::all() { + reg.register(Box::new(svc)); + } + reg.apply_overrides(config); + reg +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::service::ServiceDef; + + fn make_service(name: &str, enabled: bool) -> Box { + Box::new(ServiceDef { + name: name.to_string(), + description: format!("{name} service"), + enabled, + entrypoint_name: Some(format!("sandcage-{name}-entrypoint")), + entrypoint_script: Some("#!/bin/sh\nexec echo test".to_string()), + config_dir: Some(format!(".{name}")), + }) + } + + #[test] + fn register_and_get() { + let mut reg = ServiceRegistry::new(); + reg.register(make_service("claude", true)); + assert!(reg.get("claude").is_some()); + assert!(reg.get("nonexistent").is_none()); + } + + #[test] + fn enabled_filters_disabled_services() { + let mut reg = ServiceRegistry::new(); + reg.register(make_service("claude", true)); + reg.register(make_service("codex", false)); + reg.register(make_service("shell", true)); + + let enabled: Vec<&str> = reg.enabled().map(|s| s.name()).collect(); + assert_eq!(enabled, vec!["claude", "shell"]); + } + + #[test] + fn all_returns_every_service() { + let mut reg = ServiceRegistry::new(); + reg.register(make_service("claude", true)); + reg.register(make_service("codex", false)); + + let all: Vec<&str> = reg.all().map(|s| s.name()).collect(); + assert_eq!(all, vec!["claude", "codex"]); + } + + #[test] + fn names_returns_all_keys() { + let mut reg = ServiceRegistry::new(); + reg.register(make_service("claude", true)); + reg.register(make_service("codex", true)); + + let names: Vec<&str> = reg.names().collect(); + assert_eq!(names, vec!["claude", "codex"]); + } + + #[test] + fn preserves_insertion_order() { + let mut reg = ServiceRegistry::new(); + reg.register(make_service("shell", true)); + reg.register(make_service("claude", true)); + reg.register(make_service("codex", true)); + + let names: Vec<&str> = reg.names().collect(); + assert_eq!(names, vec!["shell", "claude", "codex"]); + } + + #[test] + fn get_mut_can_disable_service() { + let mut reg = ServiceRegistry::new(); + reg.register(make_service("claude", true)); + + assert!(reg.get("claude").unwrap().enabled()); + reg.get_mut("claude").unwrap().set_enabled(false); + assert!(!reg.get("claude").unwrap().enabled()); + } + + #[test] + fn enabled_names_convenience() { + let mut reg = ServiceRegistry::new(); + reg.register(make_service("claude", true)); + reg.register(make_service("codex", false)); + reg.register(make_service("shell", true)); + + assert_eq!(reg.enabled_names(), vec!["claude", "shell"]); + } + + use crate::config::{SandcageConfig, ServiceOverride}; + use std::collections::HashMap; + + #[test] + fn apply_overrides_disables_service() { + let mut reg = ServiceRegistry::new(); + reg.register(make_service("claude", true)); + reg.register(make_service("codex", true)); + + let mut overrides = HashMap::new(); + overrides.insert("codex".to_string(), ServiceOverride { enabled: Some(false) }); + let config = SandcageConfig { + services: Some(overrides), + ..Default::default() + }; + + reg.apply_overrides(&config); + assert!(reg.get("claude").unwrap().enabled()); + assert!(!reg.get("codex").unwrap().enabled()); + } + + #[test] + fn apply_overrides_ignores_unknown_services() { + let mut reg = ServiceRegistry::new(); + reg.register(make_service("claude", true)); + + let mut overrides = HashMap::new(); + overrides.insert("nonexistent".to_string(), ServiceOverride { enabled: Some(false) }); + let config = SandcageConfig { + services: Some(overrides), + ..Default::default() + }; + + reg.apply_overrides(&config); + assert!(reg.get("claude").unwrap().enabled()); + } + + #[test] + fn build_default_registry_has_all_builtins() { + let config = SandcageConfig::default(); + let reg = build_default_registry(&config); + + for name in ["claude", "codex", "gemini", "shell"] { + assert!( + reg.get(name).is_some(), + "{name} should be in default registry" + ); + assert!( + reg.get(name).unwrap().enabled(), + "{name} should be enabled by default" + ); + } + } + + #[test] + fn build_default_registry_applies_overrides() { + let mut overrides = HashMap::new(); + overrides.insert("codex".to_string(), ServiceOverride { enabled: Some(false) }); + overrides.insert("gemini".to_string(), ServiceOverride { enabled: Some(false) }); + + let config = SandcageConfig { + services: Some(overrides), + ..Default::default() + }; + + let reg = build_default_registry(&config); + assert!(reg.get("claude").unwrap().enabled()); + assert!(!reg.get("codex").unwrap().enabled()); + assert!(!reg.get("gemini").unwrap().enabled()); + assert!(reg.get("shell").unwrap().enabled()); + } +}