diff --git a/compose/docker-compose.yml b/compose/docker-compose.yml index 3c03456..80b23d9 100644 --- a/compose/docker-compose.yml +++ b/compose/docker-compose.yml @@ -1,10 +1,10 @@ services: claude: image: sandcage-claude:latest - working_dir: /workspace + working_dir: ${SANDCAGE_CONTAINER_DIR} user: "${SANDCAGE_UID}:${SANDCAGE_GID}" volumes: - - ${SANDCAGE_WORKSPACE}:/workspace + - ${SANDCAGE_WORKSPACE}:${SANDCAGE_CONTAINER_DIR} - ${SANDCAGE_HOME}/.claude:/home/agent/.claude - ${SANDCAGE_GLOBAL_JUSTFILE}:/home/agent/.justfile:ro environment: @@ -14,10 +14,10 @@ services: codex: image: sandcage-codex:latest - working_dir: /workspace + working_dir: ${SANDCAGE_CONTAINER_DIR} user: "${SANDCAGE_UID}:${SANDCAGE_GID}" volumes: - - ${SANDCAGE_WORKSPACE}:/workspace + - ${SANDCAGE_WORKSPACE}:${SANDCAGE_CONTAINER_DIR} - ${SANDCAGE_HOME}/.codex:/home/agent/.codex - ${SANDCAGE_GLOBAL_JUSTFILE}:/home/agent/.justfile:ro environment: @@ -27,10 +27,10 @@ services: shell: image: sandcage-base:latest - working_dir: /workspace + working_dir: ${SANDCAGE_CONTAINER_DIR} user: "${SANDCAGE_UID}:${SANDCAGE_GID}" volumes: - - ${SANDCAGE_WORKSPACE}:/workspace + - ${SANDCAGE_WORKSPACE}:${SANDCAGE_CONTAINER_DIR} - ${SANDCAGE_HOME}/.claude:/home/agent/.claude - ${SANDCAGE_HOME}/.codex:/home/agent/.codex - ${SANDCAGE_GLOBAL_JUSTFILE}:/home/agent/.justfile:ro diff --git a/crates/sandcage/src/config.rs b/crates/sandcage/src/config.rs index a45853f..95203bb 100644 --- a/crates/sandcage/src/config.rs +++ b/crates/sandcage/src/config.rs @@ -52,6 +52,8 @@ struct RawConfig { dockerfiles: Option>, #[serde(default)] trusted_projects: Option>, + #[serde(default)] + container_workspace: Option, /// Absorb everything else so we can warn about unknown fields. #[serde(flatten)] @@ -81,13 +83,15 @@ pub struct SandcageConfig { pub dockerfiles: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub trusted_projects: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub container_workspace: Option, } // --------------------------------------------------------------------------- // Known keys — used to filter the flattened `extra` map // --------------------------------------------------------------------------- -const KNOWN_KEYS: &[&str] = &["env", "packages", "toolchains", "mounts", "shell", "justfile", "dockerfiles", "trusted_projects"]; +const KNOWN_KEYS: &[&str] = &["env", "packages", "toolchains", "mounts", "shell", "justfile", "dockerfiles", "trusted_projects", "container_workspace"]; // --------------------------------------------------------------------------- // Validation helpers @@ -138,6 +142,7 @@ fn from_raw(raw: RawConfig) -> SandcageConfig { justfile: raw.justfile, dockerfiles: raw.dockerfiles, trusted_projects: raw.trusted_projects, + container_workspace: raw.container_workspace, } } @@ -501,4 +506,17 @@ EDITOR = "vim" .expect("nonexistent files should not error"); assert!(cfg.shell.is_none()); } + + #[test] + fn parse_container_workspace() { + let yaml = "container_workspace: /workspace/my-app\n"; + let cfg = load_from_str(yaml).expect("parse container_workspace"); + assert_eq!(cfg.container_workspace.as_deref(), Some("/workspace/my-app")); + } + + #[test] + fn container_workspace_defaults_to_none() { + let cfg = load_from_str("").expect("parse empty"); + assert!(cfg.container_workspace.is_none()); + } } diff --git a/crates/sandcage/src/docker.rs b/crates/sandcage/src/docker.rs index 4ff2ed9..f140b36 100644 --- a/crates/sandcage/src/docker.rs +++ b/crates/sandcage/src/docker.rs @@ -118,7 +118,7 @@ fn id_flag(flag: &str) -> Result { Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } -pub fn build_compose_env(workspace: &Path) -> Result> { +pub fn build_compose_env(workspace: &Path, config: &SandcageConfig) -> Result> { let home = dirs::home_dir().ok_or(DockerError::NoHomeDir)?; let sandcage_home = home.join(".sandcage"); @@ -130,6 +130,18 @@ pub fn build_compose_env(workspace: &Path) -> Result> { (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), + }; + let mut env = HashMap::new(); env.insert("SANDCAGE_UID".into(), uid); env.insert("SANDCAGE_GID".into(), gid); @@ -148,10 +160,18 @@ pub fn build_compose_env(workspace: &Path) -> Result> { .to_string_lossy() .into_owned(), ); + env.insert("SANDCAGE_CONTAINER_DIR".into(), container_dir); Ok(env) } +fn default_container_dir(workspace: &Path) -> String { + match workspace.file_name() { + Some(name) => format!("/workspace/{}", name.to_string_lossy()), + None => "/workspace".to_string(), + } +} + fn write_compose_tempfile() -> Result { let mut tmp = tempfile::Builder::new() .prefix("sandcage-compose-") @@ -241,7 +261,7 @@ pub fn run_service( let compose_file = write_compose_tempfile()?; let compose_path = compose_file.path().to_string_lossy().into_owned(); - let compose_env = build_compose_env(workspace)?; + let compose_env = build_compose_env(workspace, config)?; let run_args = build_run_args(service, &compose_path, config, shell_override, extra_args); @@ -529,7 +549,7 @@ mod tests { #[test] fn build_compose_env_contains_required_keys() { let workspace = PathBuf::from("/tmp/test-workspace"); - let env = build_compose_env(&workspace).expect("build_compose_env"); + let env = build_compose_env(&workspace, &SandcageConfig::default()).expect("build_compose_env"); assert!(env.contains_key("SANDCAGE_UID"), "missing SANDCAGE_UID"); assert!(env.contains_key("SANDCAGE_GID"), "missing SANDCAGE_GID"); @@ -539,19 +559,20 @@ mod tests { env.contains_key("SANDCAGE_GLOBAL_JUSTFILE"), "missing SANDCAGE_GLOBAL_JUSTFILE" ); + assert!(env.contains_key("SANDCAGE_CONTAINER_DIR"), "missing SANDCAGE_CONTAINER_DIR"); } #[test] fn build_compose_env_workspace_matches() { let workspace = PathBuf::from("/my/project"); - let env = build_compose_env(&workspace).expect("build_compose_env"); + let env = build_compose_env(&workspace, &SandcageConfig::default()).expect("build_compose_env"); assert_eq!(env["SANDCAGE_WORKSPACE"], "/my/project"); } #[test] fn build_compose_env_home_ends_with_sandcage() { let workspace = PathBuf::from("/tmp"); - let env = build_compose_env(&workspace).expect("build_compose_env"); + let env = build_compose_env(&workspace, &SandcageConfig::default()).expect("build_compose_env"); assert!( env["SANDCAGE_HOME"].ends_with(".sandcage"), "SANDCAGE_HOME should end with .sandcage, got: {}", @@ -562,7 +583,7 @@ mod tests { #[test] fn build_compose_env_justfile_is_under_home() { let workspace = PathBuf::from("/tmp"); - let env = build_compose_env(&workspace).expect("build_compose_env"); + let env = build_compose_env(&workspace, &SandcageConfig::default()).expect("build_compose_env"); assert!( env["SANDCAGE_GLOBAL_JUSTFILE"].starts_with(&env["SANDCAGE_HOME"]), "SANDCAGE_GLOBAL_JUSTFILE should be under SANDCAGE_HOME" @@ -576,7 +597,7 @@ mod tests { #[test] fn uid_gid_are_numeric() { let workspace = PathBuf::from("/tmp"); - let env = build_compose_env(&workspace).expect("build_compose_env"); + let env = build_compose_env(&workspace, &SandcageConfig::default()).expect("build_compose_env"); let uid: u32 = env["SANDCAGE_UID"] .parse() @@ -799,4 +820,45 @@ mod tests { let help_pos = args.iter().position(|a| a == "--help").unwrap(); assert!(help_pos > service_pos); } + + #[test] + fn build_compose_env_container_dir_auto_derived() { + let workspace = PathBuf::from("/home/user/projects/my-app"); + let config = SandcageConfig::default(); + let env = build_compose_env(&workspace, &config).expect("build_compose_env"); + assert_eq!(env["SANDCAGE_CONTAINER_DIR"], "/workspace/my-app"); + } + + #[test] + fn build_compose_env_container_dir_from_config() { + let workspace = PathBuf::from("/home/user/projects/my-app"); + let config = SandcageConfig { + container_workspace: Some("/workspace/custom".to_string()), + ..Default::default() + }; + let env = build_compose_env(&workspace, &config).expect("build_compose_env"); + assert_eq!(env["SANDCAGE_CONTAINER_DIR"], "/workspace/custom"); + } + + #[test] + fn build_compose_env_container_dir_ignores_relative_override() { + let workspace = PathBuf::from("/home/user/projects/my-app"); + let config = SandcageConfig { + container_workspace: Some("relative/path".to_string()), + ..Default::default() + }; + let env = build_compose_env(&workspace, &config).expect("build_compose_env"); + assert_eq!( + env["SANDCAGE_CONTAINER_DIR"], "/workspace/my-app", + "relative paths should be ignored, falling back to auto-derive" + ); + } + + #[test] + fn build_compose_env_container_dir_root_fallback() { + let workspace = PathBuf::from("/"); + let config = SandcageConfig::default(); + let env = build_compose_env(&workspace, &config).expect("build_compose_env"); + assert_eq!(env["SANDCAGE_CONTAINER_DIR"], "/workspace"); + } } diff --git a/crates/sandcage/src/init.rs b/crates/sandcage/src/init.rs index ba3e9f3..c8a85a8 100644 --- a/crates/sandcage/src/init.rs +++ b/crates/sandcage/src/init.rs @@ -141,7 +141,9 @@ fn build_yaml(ecosystem: Ecosystem) -> String { # mounts:\n\ # - /path/on/host:/path/in/container\n\ \n\ - # shell: zsh\n", + # shell: zsh\n\ + \n\ + # container_workspace: /workspace/my-project\n", ecosystem = ecosystem.as_str(), ) }