🥇 export from upstream (2df0c14)

This commit is contained in:
sandcage-export
2026-05-22 20:06:01 +02:00
parent ea7bb89295
commit 37a231beb4
4 changed files with 156 additions and 36 deletions
+7 -4
View File
@@ -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
+114 -19
View File
@@ -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);
}
}
+28 -9
View File
@@ -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)?;
}
+7 -4
View File
@@ -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: