diff --git a/README.md b/README.md index 777b80e..f49d73b 100644 --- a/README.md +++ b/README.md @@ -38,12 +38,15 @@ This builds three images: `sandcage-base`, `sandcage-claude`, and `sandcage-code ### Run an agent ```bash -sandcage claude # Claude Code in current directory -sandcage codex ~/project # Codex in a specific project -sandcage shell # interactive shell, same environment +sandcage claude # Claude Code in current directory +sandcage claude -p ~/project # Claude Code in a specific project +sandcage claude -- --resume # forward --resume to Claude Code +sandcage codex -p ~/project # Codex in a specific project +sandcage shell # interactive shell, same environment +sandcage claude --shell # shell in the Claude image (for debugging) ``` -The workspace is resolved to the git repo root automatically. Inside a git worktree, the worktree root is used instead. +The workspace is resolved to the git repo root automatically. Inside a git worktree, the worktree root is used instead. Arguments after `--` are forwarded to the agent inside the container. ### Initialize a project diff --git a/crates/sandcage/src/docker.rs b/crates/sandcage/src/docker.rs index 9ca6e5e..4ff2ed9 100644 --- a/crates/sandcage/src/docker.rs +++ b/crates/sandcage/src/docker.rs @@ -189,7 +189,49 @@ fn require_image(docker: &Path, image: &str) -> Result<()> { } } -pub fn run_service(service: &str, workspace: &Path, config: &SandcageConfig) -> Result<()> { +pub fn build_run_args( + service: &str, + compose_path: &str, + config: &SandcageConfig, + shell_override: bool, + extra_args: &[String], +) -> Vec { + let mut args = vec![ + "compose".to_string(), + "-f".to_string(), + compose_path.to_string(), + "run".to_string(), + "--rm".to_string(), + ]; + + if let Some(ref env_map) = config.env { + for (key, value) in env_map { + args.push("-e".to_string()); + args.push(format!("{key}={value}")); + } + } + + if shell_override { + args.push("--entrypoint".to_string()); + args.push("/bin/zsh".to_string()); + } + + args.push(service.to_string()); + + for arg in extra_args { + args.push(arg.clone()); + } + + args +} + +pub fn run_service( + service: &str, + workspace: &Path, + config: &SandcageConfig, + shell_override: bool, + extra_args: &[String], +) -> Result<()> { let docker = require_docker()?; require_compose(&docker)?; @@ -201,18 +243,10 @@ pub fn run_service(service: &str, workspace: &Path, config: &SandcageConfig) -> let compose_env = build_compose_env(workspace)?; - // Build the command + let run_args = build_run_args(service, &compose_path, config, shell_override, extra_args); + let mut cmd = Command::new(&docker); - cmd.args(["compose", "-f", &compose_path, "run", "--rm"]); - - // Add extra -e flags from the merged config env map - if let Some(ref env_map) = config.env { - for (key, value) in env_map { - cmd.args(["-e", &format!("{key}={value}")]); - } - } - - cmd.arg(service); + cmd.args(&run_args); // Inject compose environment variables cmd.envs(&compose_env); @@ -292,7 +326,7 @@ fn write_stored_hashes(path: &Path, hashes: &HashMap) -> Result< } /// Build a single image from inline Dockerfile content (temp build context). -fn build_one_image_from_content(docker: &Path, image: &str, dockerfile_content: &str) -> Result<()> { +fn build_one_image_from_content(docker: &Path, image: &str, dockerfile_content: &str, no_cache: bool) -> Result<()> { let tmp_dir = tempfile::Builder::new() .prefix("sandcage-build-") .tempdir() @@ -302,22 +336,22 @@ fn build_one_image_from_content(docker: &Path, image: &str, dockerfile_content: std::fs::write(&dockerfile_path, dockerfile_content) .map_err(DockerError::DockerfileWriteFailed)?; - run_docker_build(docker, image, tmp_dir.path(), None) + run_docker_build(docker, image, tmp_dir.path(), None, no_cache) } /// Build a single image from a user-provided path. /// If the path is a directory, it's used as the build context. /// If the path is a file, it's used as the Dockerfile with a temp context. -fn build_one_image_from_path(docker: &Path, image: &str, override_path: &Path) -> Result<()> { +fn build_one_image_from_path(docker: &Path, image: &str, override_path: &Path, no_cache: bool) -> Result<()> { if override_path.is_dir() { - run_docker_build(docker, image, override_path, None) + run_docker_build(docker, image, override_path, None, no_cache) } else { let tmp_dir = tempfile::Builder::new() .prefix("sandcage-build-") .tempdir() .map_err(DockerError::TempDirFailed)?; - run_docker_build(docker, image, tmp_dir.path(), Some(override_path)) + run_docker_build(docker, image, tmp_dir.path(), Some(override_path), no_cache) } } @@ -326,10 +360,15 @@ fn run_docker_build( image: &str, context: &Path, dockerfile: Option<&Path>, + no_cache: bool, ) -> Result<()> { let mut cmd = Command::new(docker); cmd.args(["build", "-t", &format!("{image}:latest")]); + if no_cache { + cmd.arg("--no-cache"); + } + if let Some(df) = dockerfile { cmd.args(["-f", &df.to_string_lossy()]); } @@ -443,9 +482,9 @@ pub fn build_images(force: bool) -> Result<()> { } if let Some(path) = override_path { - build_one_image_from_path(&docker, image, path)?; + build_one_image_from_path(&docker, image, path, force)?; } else { - build_one_image_from_content(&docker, image, bundled_dockerfile)?; + build_one_image_from_content(&docker, image, bundled_dockerfile, force)?; } stored.insert(image.to_string(), current_hash); @@ -704,4 +743,60 @@ mod tests { "should fail for nonexistent image: {result:?}" ); } + + #[test] + fn build_run_args_basic() { + let config = SandcageConfig::default(); + let args = build_run_args("claude", "/tmp/compose.yml", &config, false, &[]); + assert_eq!(args, vec!["compose", "-f", "/tmp/compose.yml", "run", "--rm", "claude"]); + } + + #[test] + fn build_run_args_with_env() { + let mut env = std::collections::HashMap::new(); + env.insert("FOO".to_string(), "bar".to_string()); + let config = SandcageConfig { + env: Some(env), + ..Default::default() + }; + let args = build_run_args("claude", "/tmp/compose.yml", &config, false, &[]); + assert!(args.contains(&"-e".to_string())); + assert!(args.contains(&"FOO=bar".to_string())); + let service_pos = args.iter().position(|a| a == "claude").unwrap(); + let env_pos = args.iter().position(|a| a == "FOO=bar").unwrap(); + assert!(env_pos < service_pos, "-e flags must come before the service name"); + } + + #[test] + fn build_run_args_shell_override() { + let config = SandcageConfig::default(); + let args = build_run_args("claude", "/tmp/compose.yml", &config, true, &[]); + assert!(args.contains(&"--entrypoint".to_string())); + assert!(args.contains(&"/bin/zsh".to_string())); + let ep_pos = args.iter().position(|a| a == "--entrypoint").unwrap(); + let service_pos = args.iter().position(|a| a == "claude").unwrap(); + assert!(ep_pos < service_pos, "--entrypoint must come before the service name"); + } + + #[test] + fn build_run_args_extra_args_after_service() { + let config = SandcageConfig::default(); + let args = build_run_args("claude", "/tmp/compose.yml", &config, false, &["--resume".to_string(), "--verbose".to_string()]); + let service_pos = args.iter().position(|a| a == "claude").unwrap(); + let resume_pos = args.iter().position(|a| a == "--resume").unwrap(); + let verbose_pos = args.iter().position(|a| a == "--verbose").unwrap(); + assert!(resume_pos > service_pos, "extra args must come after the service name"); + assert!(verbose_pos > resume_pos, "extra args must preserve order"); + } + + #[test] + fn build_run_args_shell_override_with_extra_args() { + let config = SandcageConfig::default(); + let args = build_run_args("codex", "/tmp/compose.yml", &config, true, &["--help".to_string()]); + assert!(args.contains(&"--entrypoint".to_string())); + assert!(args.contains(&"/bin/zsh".to_string())); + let service_pos = args.iter().position(|a| a == "codex").unwrap(); + let help_pos = args.iter().position(|a| a == "--help").unwrap(); + assert!(help_pos > service_pos); + } } diff --git a/crates/sandcage/src/main.rs b/crates/sandcage/src/main.rs index b3cc728..9047cd1 100644 --- a/crates/sandcage/src/main.rs +++ b/crates/sandcage/src/main.rs @@ -21,16 +21,35 @@ enum Commands { /// Run Claude Code agent in a sandboxed container Claude { /// 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, }, /// Run Codex agent in a sandboxed container Codex { /// 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 @@ -62,15 +81,12 @@ enum AppError { Docker(#[from] docker::DockerError), } -fn run_agent(service: &str, path: Option) -> std::result::Result<(), AppError> { - // 1. Resolve workspace +fn run_agent(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()); - // 2. Preseed ~/.sandcage directories init::preseed()?; - // 3. Resolve layered config let home = dirs::home_dir().ok_or(init::InitError::NoHomeDir)?; let global_config = home.join(".sandcage").join("config.toml"); let project_config = workspace.join(".sandcage.yml"); @@ -79,9 +95,8 @@ fn run_agent(service: &str, path: Option) -> std::result::Result<(), Ap Some(&project_config), )?; - // 4. Run the service eprintln!("sandcage: service \u{2192} {service}"); - docker::run_service(service, &workspace, &cfg)?; + docker::run_service(service, &workspace, &cfg, shell_override, &agent_args)?; Ok(()) } @@ -90,9 +105,13 @@ fn main() -> miette::Result<()> { let cli = Cli::parse(); match cli.command { - Commands::Claude { path } => run_agent("claude", path)?, - Commands::Codex { path } => run_agent("codex", path)?, - Commands::Shell { path } => run_agent("shell", path)?, + Commands::Claude { path, shell, agent_args } => { + run_agent("claude", path, shell, agent_args)? + } + Commands::Codex { path, shell, agent_args } => { + run_agent("codex", path, shell, agent_args)? + } + Commands::Shell { path } => run_agent("shell", path, false, vec![])?, Commands::Build { force } => { docker::build_images(force)?; } diff --git a/justfile b/justfile index 69cc2f0..4a217b8 100644 --- a/justfile +++ b/justfile @@ -23,13 +23,16 @@ build: test: cargo test --workspace --exclude sandcage-test -# Run functional tests (requires Docker). -test-functional: +# Run drift-guard and CLI smoke tests (fast, no Docker). +test-drift: cargo test -p sandcage-test -- --test-threads=1 +# Run functional tests (requires Docker + pre-built images). +test-functional: + cargo test -p sandcage-test -- --ignored --test-threads=1 + # Run all tests. -test-all: - cargo test --workspace +test-all: test test-drift test-functional # Run Claude Code. claude: