🥇 export from upstream (2df0c14)
This commit is contained in:
@@ -38,12 +38,15 @@ This builds three images: `sandcage-base`, `sandcage-claude`, and `sandcage-code
|
|||||||
### Run an agent
|
### Run an agent
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sandcage claude # Claude Code in current directory
|
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 shell # interactive shell, same environment
|
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
|
### 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()?;
|
let docker = require_docker()?;
|
||||||
require_compose(&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)?;
|
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);
|
let mut cmd = Command::new(&docker);
|
||||||
cmd.args(["compose", "-f", &compose_path, "run", "--rm"]);
|
cmd.args(&run_args);
|
||||||
|
|
||||||
// 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);
|
|
||||||
|
|
||||||
// Inject compose environment variables
|
// Inject compose environment variables
|
||||||
cmd.envs(&compose_env);
|
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).
|
/// 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()
|
let tmp_dir = tempfile::Builder::new()
|
||||||
.prefix("sandcage-build-")
|
.prefix("sandcage-build-")
|
||||||
.tempdir()
|
.tempdir()
|
||||||
@@ -302,22 +336,22 @@ fn build_one_image_from_content(docker: &Path, image: &str, dockerfile_content:
|
|||||||
std::fs::write(&dockerfile_path, dockerfile_content)
|
std::fs::write(&dockerfile_path, dockerfile_content)
|
||||||
.map_err(DockerError::DockerfileWriteFailed)?;
|
.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.
|
/// 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 directory, it's used as the build context.
|
||||||
/// If the path is a file, it's used as the Dockerfile with a temp 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() {
|
if override_path.is_dir() {
|
||||||
run_docker_build(docker, image, override_path, None)
|
run_docker_build(docker, image, override_path, None, no_cache)
|
||||||
} else {
|
} else {
|
||||||
let tmp_dir = tempfile::Builder::new()
|
let tmp_dir = tempfile::Builder::new()
|
||||||
.prefix("sandcage-build-")
|
.prefix("sandcage-build-")
|
||||||
.tempdir()
|
.tempdir()
|
||||||
.map_err(DockerError::TempDirFailed)?;
|
.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,
|
image: &str,
|
||||||
context: &Path,
|
context: &Path,
|
||||||
dockerfile: Option<&Path>,
|
dockerfile: Option<&Path>,
|
||||||
|
no_cache: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut cmd = Command::new(docker);
|
let mut cmd = Command::new(docker);
|
||||||
cmd.args(["build", "-t", &format!("{image}:latest")]);
|
cmd.args(["build", "-t", &format!("{image}:latest")]);
|
||||||
|
|
||||||
|
if no_cache {
|
||||||
|
cmd.arg("--no-cache");
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(df) = dockerfile {
|
if let Some(df) = dockerfile {
|
||||||
cmd.args(["-f", &df.to_string_lossy()]);
|
cmd.args(["-f", &df.to_string_lossy()]);
|
||||||
}
|
}
|
||||||
@@ -443,9 +482,9 @@ pub fn build_images(force: bool) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(path) = override_path {
|
if let Some(path) = override_path {
|
||||||
build_one_image_from_path(&docker, image, path)?;
|
build_one_image_from_path(&docker, image, path, force)?;
|
||||||
} else {
|
} 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);
|
stored.insert(image.to_string(), current_hash);
|
||||||
@@ -704,4 +743,60 @@ mod tests {
|
|||||||
"should fail for nonexistent image: {result:?}"
|
"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
|
/// Run Claude Code agent in a sandboxed container
|
||||||
Claude {
|
Claude {
|
||||||
/// Path to the project directory (defaults to current directory)
|
/// Path to the project directory (defaults to current directory)
|
||||||
|
#[arg(long, short)]
|
||||||
path: Option<PathBuf>,
|
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
|
/// Run Codex agent in a sandboxed container
|
||||||
Codex {
|
Codex {
|
||||||
/// Path to the project directory (defaults to current directory)
|
/// Path to the project directory (defaults to current directory)
|
||||||
|
#[arg(long, short)]
|
||||||
path: Option<PathBuf>,
|
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
|
/// Interactive shell with the same environment
|
||||||
Shell {
|
Shell {
|
||||||
/// Path to the project directory (defaults to current directory)
|
/// Path to the project directory (defaults to current directory)
|
||||||
|
#[arg(long, short)]
|
||||||
path: Option<PathBuf>,
|
path: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
/// Build all container images
|
/// Build all container images
|
||||||
@@ -62,15 +81,12 @@ enum AppError {
|
|||||||
Docker(#[from] docker::DockerError),
|
Docker(#[from] docker::DockerError),
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_agent(service: &str, path: Option<PathBuf>) -> std::result::Result<(), AppError> {
|
fn run_agent(service: &str, path: Option<PathBuf>, shell_override: bool, agent_args: Vec<String>) -> std::result::Result<(), AppError> {
|
||||||
// 1. Resolve workspace
|
|
||||||
let workspace = workspace::resolve_workspace(path.as_deref())?;
|
let workspace = workspace::resolve_workspace(path.as_deref())?;
|
||||||
eprintln!("sandcage: workspace \u{2192} {}", workspace.display());
|
eprintln!("sandcage: workspace \u{2192} {}", workspace.display());
|
||||||
|
|
||||||
// 2. Preseed ~/.sandcage directories
|
|
||||||
init::preseed()?;
|
init::preseed()?;
|
||||||
|
|
||||||
// 3. Resolve layered config
|
|
||||||
let home = dirs::home_dir().ok_or(init::InitError::NoHomeDir)?;
|
let home = dirs::home_dir().ok_or(init::InitError::NoHomeDir)?;
|
||||||
let global_config = home.join(".sandcage").join("config.toml");
|
let global_config = home.join(".sandcage").join("config.toml");
|
||||||
let project_config = workspace.join(".sandcage.yml");
|
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),
|
Some(&project_config),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// 4. Run the service
|
|
||||||
eprintln!("sandcage: service \u{2192} {service}");
|
eprintln!("sandcage: service \u{2192} {service}");
|
||||||
docker::run_service(service, &workspace, &cfg)?;
|
docker::run_service(service, &workspace, &cfg, shell_override, &agent_args)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -90,9 +105,13 @@ fn main() -> miette::Result<()> {
|
|||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Commands::Claude { path } => run_agent("claude", path)?,
|
Commands::Claude { path, shell, agent_args } => {
|
||||||
Commands::Codex { path } => run_agent("codex", path)?,
|
run_agent("claude", path, shell, agent_args)?
|
||||||
Commands::Shell { path } => run_agent("shell", path)?,
|
}
|
||||||
|
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 } => {
|
Commands::Build { force } => {
|
||||||
docker::build_images(force)?;
|
docker::build_images(force)?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,13 +23,16 @@ build:
|
|||||||
test:
|
test:
|
||||||
cargo test --workspace --exclude sandcage-test
|
cargo test --workspace --exclude sandcage-test
|
||||||
|
|
||||||
# Run functional tests (requires Docker).
|
# Run drift-guard and CLI smoke tests (fast, no Docker).
|
||||||
test-functional:
|
test-drift:
|
||||||
cargo test -p sandcage-test -- --test-threads=1
|
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.
|
# Run all tests.
|
||||||
test-all:
|
test-all: test test-drift test-functional
|
||||||
cargo test --workspace
|
|
||||||
|
|
||||||
# Run Claude Code.
|
# Run Claude Code.
|
||||||
claude:
|
claude:
|
||||||
|
|||||||
Reference in New Issue
Block a user