🥇 export from upstream (2df0c14)
This commit is contained in:
@@ -39,11 +39,14 @@ This builds three images: `sandcage-base`, `sandcage-claude`, and `sandcage-code
|
||||
|
||||
```bash
|
||||
sandcage claude # Claude Code in current directory
|
||||
sandcage codex ~/project # Codex in a specific project
|
||||
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
|
||||
|
||||
|
||||
+114
-19
@@ -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<String> {
|
||||
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<String, String>) -> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PathBuf>,
|
||||
|
||||
/// 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<String>,
|
||||
},
|
||||
/// Run Codex agent in a sandboxed container
|
||||
Codex {
|
||||
/// Path to the project directory (defaults to current directory)
|
||||
#[arg(long, short)]
|
||||
path: Option<PathBuf>,
|
||||
|
||||
/// 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<String>,
|
||||
},
|
||||
/// Interactive shell with the same environment
|
||||
Shell {
|
||||
/// Path to the project directory (defaults to current directory)
|
||||
#[arg(long, short)]
|
||||
path: Option<PathBuf>,
|
||||
},
|
||||
/// Build all container images
|
||||
@@ -62,15 +81,12 @@ enum AppError {
|
||||
Docker(#[from] docker::DockerError),
|
||||
}
|
||||
|
||||
fn run_agent(service: &str, path: Option<PathBuf>) -> std::result::Result<(), AppError> {
|
||||
// 1. Resolve workspace
|
||||
fn run_agent(service: &str, path: Option<PathBuf>, shell_override: bool, agent_args: Vec<String>) -> 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<PathBuf>) -> 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)?;
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user