🥇 export from upstream (0aed74f)

This commit is contained in:
2026-05-24 18:40:35 +02:00
parent a300f514ef
commit b0196faf58
12 changed files with 1013 additions and 81 deletions
Generated
+1
View File
@@ -780,6 +780,7 @@ dependencies = [
"dirs",
"figment",
"hex",
"indexmap",
"miette",
"serde",
"serde_yaml",
-39
View File
@@ -1,39 +0,0 @@
services:
claude:
image: sandcage:latest
entrypoint: ["sandcage-claude-entrypoint"]
working_dir: ${SANDCAGE_CONTAINER_DIR}
user: "${SANDCAGE_UID}:${SANDCAGE_GID}"
volumes:
- ${SANDCAGE_HOME}/home:/home/agent
- ${SANDCAGE_WORKSPACE}:${SANDCAGE_CONTAINER_DIR}
environment:
- HOME=/home/agent
tty: true
stdin_open: true
codex:
image: sandcage:latest
entrypoint: ["sandcage-codex-entrypoint"]
working_dir: ${SANDCAGE_CONTAINER_DIR}
user: "${SANDCAGE_UID}:${SANDCAGE_GID}"
volumes:
- ${SANDCAGE_HOME}/home:/home/agent
- ${SANDCAGE_WORKSPACE}:${SANDCAGE_CONTAINER_DIR}
environment:
- HOME=/home/agent
tty: true
stdin_open: true
shell:
image: sandcage:latest
working_dir: ${SANDCAGE_CONTAINER_DIR}
user: "${SANDCAGE_UID}:${SANDCAGE_GID}"
volumes:
- ${SANDCAGE_HOME}/home:/home/agent
- ${SANDCAGE_WORKSPACE}:${SANDCAGE_CONTAINER_DIR}
environment:
- HOME=/home/agent
tty: true
stdin_open: true
entrypoint: ["/bin/zsh"]
+1
View File
@@ -17,6 +17,7 @@ which = "7"
dirs = "6"
sha2 = "0.10"
hex = "0.4"
indexmap = { version = "2", features = ["serde"] }
tempfile = "3"
dialoguer = "0.11"
toml_edit = "0.22"
+77 -1
View File
@@ -39,6 +39,12 @@ pub struct SshKeyEntry {
pub identity_file: String,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct ServiceOverride {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
}
// ---------------------------------------------------------------------------
// Raw deserialization type — captures unknown keys
// ---------------------------------------------------------------------------
@@ -70,6 +76,10 @@ struct RawConfig {
ssh_mode: Option<String>,
#[serde(default)]
ssh_keys: Option<Vec<SshKeyEntry>>,
#[serde(default)]
services: Option<HashMap<String, ServiceOverride>>,
#[serde(default)]
default_services: Option<Vec<String>>,
/// Absorb everything else so we can warn about unknown fields.
#[serde(flatten)]
@@ -107,13 +117,17 @@ pub struct SandcageConfig {
pub ssh_mode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ssh_keys: Option<Vec<SshKeyEntry>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub services: Option<HashMap<String, ServiceOverride>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_services: Option<Vec<String>>,
}
// ---------------------------------------------------------------------------
// Known keys — used to filter the flattened `extra` map
// ---------------------------------------------------------------------------
const KNOWN_KEYS: &[&str] = &["env", "packages", "toolchains", "mounts", "shell", "justfile", "dockerfiles", "trusted_projects", "container_workspace", "agent_args", "ssh_mode", "ssh_keys"];
const KNOWN_KEYS: &[&str] = &["env", "packages", "toolchains", "mounts", "shell", "justfile", "dockerfiles", "trusted_projects", "container_workspace", "agent_args", "ssh_mode", "ssh_keys", "services", "default_services"];
// ---------------------------------------------------------------------------
// Validation helpers
@@ -168,6 +182,8 @@ fn from_raw(raw: RawConfig) -> SandcageConfig {
agent_args: raw.agent_args,
ssh_mode: raw.ssh_mode,
ssh_keys: raw.ssh_keys,
services: raw.services,
default_services: raw.default_services,
}
}
@@ -642,4 +658,64 @@ ssh_keys:
let cfg = resolve_config(Some(global_tmp.path()), Some(project_tmp.path()), None).expect("resolve");
assert_eq!(cfg.ssh_mode.as_deref(), Some("volume"));
}
#[test]
fn parse_services_override() {
let yaml = r#"
services:
codex:
enabled: false
claude:
enabled: true
"#;
let cfg = load_from_str(yaml).expect("parse services");
let services = cfg.services.expect("services should be Some");
assert_eq!(services.get("codex").unwrap().enabled, Some(false));
assert_eq!(services.get("claude").unwrap().enabled, Some(true));
}
#[test]
fn parse_default_services() {
let yaml = r#"
default_services:
- claude
- shell
"#;
let cfg = load_from_str(yaml).expect("parse default_services");
let defaults = cfg.default_services.expect("default_services should be Some");
assert_eq!(defaults, vec!["claude", "shell"]);
}
#[test]
fn services_defaults_to_none() {
let cfg = load_from_str("").expect("parse empty");
assert!(cfg.services.is_none());
assert!(cfg.default_services.is_none());
}
#[test]
fn resolve_services_project_overrides_global() {
let global_toml = r#"
[services.codex]
enabled = true
"#;
let project_yaml = r#"
services:
codex:
enabled: false
"#;
let mut global_tmp = NamedTempFile::new().expect("create global tmpfile");
write!(global_tmp, "{global_toml}").expect("write global tmpfile");
let mut project_tmp = NamedTempFile::new().expect("create project tmpfile");
write!(project_tmp, "{project_yaml}").expect("write project tmpfile");
let cfg = resolve_config(Some(global_tmp.path()), Some(project_tmp.path()), None)
.expect("resolve");
let services = cfg.services.expect("services should be Some");
assert_eq!(
services.get("codex").unwrap().enabled,
Some(false),
"project should override global"
);
}
}
+114 -32
View File
@@ -16,8 +16,6 @@ use crate::config::SandcageConfig;
pub const DOCKERFILE: &str = include_str!("../../../images/base/Dockerfile");
pub const COMPOSE_YAML: &str = include_str!("../../../compose/docker-compose.yml");
#[derive(Debug, Error, Diagnostic)]
pub enum DockerError {
#[error("docker executable not found in PATH")]
@@ -80,6 +78,20 @@ pub enum DockerError {
help("Run `sandcage build` to build the required images.")
)]
ImageNotFound { image: String },
#[error("Service '{service}' is not registered")]
#[diagnostic(
code(sandcage::docker::unknown_service),
help("Available services: {available}")
)]
UnknownService { service: String, available: String },
#[error("Service '{service}' is disabled in config")]
#[diagnostic(
code(sandcage::docker::service_disabled),
help("Enable it with: services.{service}.enabled: true")
)]
ServiceDisabled { service: String },
}
pub type Result<T> = std::result::Result<T, DockerError>;
@@ -156,6 +168,37 @@ pub fn build_compose_env(workspace: &Path, config: &SandcageConfig) -> Result<Ha
Ok(env)
}
pub fn build_compose_context(workspace: &Path, config: &SandcageConfig) -> Result<crate::service::ComposeContext> {
let home = dirs::home_dir().ok_or(DockerError::NoHomeDir)?;
let sandcage_home = home.join(".sandcage");
let (uid, gid) = if cfg!(windows) {
("1000".to_string(), "1000".to_string())
} else {
(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),
};
Ok(crate::service::ComposeContext {
uid,
gid,
workspace: workspace.to_path_buf(),
container_dir,
sandcage_home,
})
}
fn default_container_dir(workspace: &Path) -> String {
match workspace.file_name() {
Some(name) => format!("/workspace/{}", name.to_string_lossy()),
@@ -163,14 +206,14 @@ fn default_container_dir(workspace: &Path) -> String {
}
}
fn write_compose_tempfile() -> Result<tempfile::NamedTempFile> {
fn write_compose_tempfile(content: &str) -> Result<tempfile::NamedTempFile> {
let mut tmp = tempfile::Builder::new()
.prefix("sandcage-compose-")
.suffix(".yml")
.tempfile()
.map_err(DockerError::TempfileFailed)?;
tmp.write_all(COMPOSE_YAML.as_bytes())
tmp.write_all(content.as_bytes())
.map_err(DockerError::TempfileFailed)?;
tmp.flush().map_err(DockerError::TempfileFailed)?;
@@ -294,12 +337,24 @@ pub fn run_service(
service: &str,
workspace: &Path,
config: &SandcageConfig,
registry: &crate::service::registry::ServiceRegistry,
shell_override: bool,
extra_args: &[String],
) -> Result<()> {
let docker = require_docker()?;
require_compose(&docker)?;
let svc = registry.get(service).ok_or_else(|| DockerError::UnknownService {
service: service.to_string(),
available: registry.names().collect::<Vec<_>>().join(", "),
})?;
if !svc.enabled() {
return Err(DockerError::ServiceDisabled {
service: service.to_string(),
});
}
let image = image_for_service(service);
require_image(&docker, image)?;
@@ -314,20 +369,16 @@ pub fn run_service(
}
}
let compose_file = write_compose_tempfile()?;
let ctx = build_compose_context(workspace, config)?;
let compose_content = crate::service::compose::generate_compose(registry, &ctx);
let compose_file = write_compose_tempfile(&compose_content)?;
let compose_path = compose_file.path().to_string_lossy().into_owned();
let compose_env = build_compose_env(workspace, config)?;
let run_args = build_run_args(service, &compose_path, config, shell_override, extra_args);
let mut cmd = Command::new(&docker);
cmd.args(&run_args);
// Inject compose environment variables
cmd.envs(&compose_env);
// Inherit stdio for interactive use
cmd.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit());
@@ -412,6 +463,17 @@ fn build_one_image_from_content(docker: &Path, image: &str, dockerfile_content:
std::fs::write(&dockerfile_path, dockerfile_content)
.map_err(DockerError::DockerfileWriteFailed)?;
for svc in crate::service::builtin::all() {
if let (Some(name), Some(script)) = (svc.entrypoint_name.as_ref(), svc.entrypoint_script.as_ref())
&& name.starts_with("sandcage-")
{
let dir = tmp_dir.path().join("images").join(&svc.name);
std::fs::create_dir_all(&dir).map_err(DockerError::TempDirFailed)?;
std::fs::write(dir.join("entrypoint.sh"), script)
.map_err(DockerError::DockerfileWriteFailed)?;
}
}
run_docker_build(docker, image, tmp_dir.path(), None, no_cache)
}
@@ -483,7 +545,7 @@ fn read_dockerfile_at(path: &Path) -> Result<String> {
/// * When `force` is `true`, every image is rebuilt regardless of the stored hash.
/// * Otherwise, an image is skipped when its Dockerfile hash matches the stored value.
/// * After a successful build the hash is updated in `~/.sandcage/.build-hashes`.
pub fn build_images(force: bool) -> Result<()> {
pub fn build_images(force: bool, service_filter: &[String]) -> Result<()> {
let docker = require_docker()?;
let home = dirs::home_dir().ok_or(DockerError::NoHomeDir)?;
@@ -494,6 +556,22 @@ pub fn build_images(force: bool) -> Result<()> {
// Global overrides are always trusted.
let global_cfg = crate::config::resolve_config(Some(&global_config), None, None)
.unwrap_or_default();
// Validate service names if provided
if !service_filter.is_empty() {
let registry = crate::service::registry::build_default_registry(&global_cfg);
let known: Vec<&str> = registry.names().collect();
for name in service_filter {
if !known.contains(&name.as_str()) {
return Err(DockerError::UnknownService {
service: name.clone(),
available: known.join(", "),
});
}
}
eprintln!("sandcage: building for services: {}", service_filter.join(", "));
}
let mut overrides = global_cfg.dockerfiles.unwrap_or_default();
// Project overrides require the project to be in trusted_projects.
@@ -581,23 +659,26 @@ mod tests {
use std::path::PathBuf;
#[test]
fn compose_yaml_is_bundled() {
assert!(
COMPOSE_YAML.contains("services:"),
"bundled compose YAML should contain 'services:'"
);
assert!(
COMPOSE_YAML.contains("claude:"),
"bundled compose YAML should define 'claude' service"
);
assert!(
COMPOSE_YAML.contains("codex:"),
"bundled compose YAML should define 'codex' service"
);
assert!(
COMPOSE_YAML.contains("shell:"),
"bundled compose YAML should define 'shell' service"
);
fn generated_compose_contains_all_services() {
use crate::service::ComposeContext;
use crate::service::registry::build_default_registry;
use crate::service::compose::generate_compose;
let config = SandcageConfig::default();
let registry = build_default_registry(&config);
let ctx = ComposeContext {
uid: "1000".to_string(),
gid: "1000".to_string(),
workspace: PathBuf::from("/tmp/test"),
container_dir: "/workspace/test".to_string(),
sandcage_home: PathBuf::from("/home/user/.sandcage"),
};
let yaml = generate_compose(&registry, &ctx);
assert!(yaml.contains("claude:"), "should contain claude service");
assert!(yaml.contains("codex:"), "should contain codex service");
assert!(yaml.contains("gemini:"), "should contain gemini service");
assert!(yaml.contains("shell:"), "should contain shell service");
}
#[test]
@@ -649,9 +730,10 @@ mod tests {
#[test]
fn write_compose_tempfile_creates_valid_yaml() {
let tmp = write_compose_tempfile().expect("write_compose_tempfile");
let content = std::fs::read_to_string(tmp.path()).expect("read tempfile");
assert_eq!(content, COMPOSE_YAML, "tempfile content should match bundled YAML");
let content = "services:\n test:\n image: test:latest\n";
let tmp = write_compose_tempfile(content).expect("write_compose_tempfile");
let read_back = std::fs::read_to_string(tmp.path()).expect("read tempfile");
assert_eq!(read_back, content, "tempfile content should match input");
}
#[test]
+10 -1
View File
@@ -47,6 +47,10 @@ pub fn preseed_with_home(home: &Path) -> Result<()> {
let codex_dir = agent_home.join(".codex");
create_dir_all(&codex_dir)?;
// ~/.sandcage/home/.gemini/
let gemini_dir = agent_home.join(".gemini");
create_dir_all(&gemini_dir)?;
// 4. Seed Claude settings.json from the bundled template
let settings_dest = claude_dir.join("settings.json");
if !settings_dest.exists() {
@@ -231,7 +235,7 @@ mod tests {
}
#[test]
fn creates_claude_and_codex_dirs() {
fn creates_agent_config_dirs() {
let home = TempDir::new().expect("create tempdir");
run(home.path()).expect("preseed");
@@ -243,6 +247,10 @@ mod tests {
home.path().join(".sandcage/home/.codex").is_dir(),
".codex dir should exist"
);
assert!(
home.path().join(".sandcage/home/.gemini").is_dir(),
".gemini dir should exist"
);
}
#[test]
@@ -320,6 +328,7 @@ mod tests {
// All artefacts should still exist after two runs
assert!(home.path().join(".sandcage/home/.claude").is_dir());
assert!(home.path().join(".sandcage/home/.codex").is_dir());
assert!(home.path().join(".sandcage/home/.gemini").is_dir());
assert!(home.path().join(".sandcage/home/.claude/settings.json").exists());
assert!(home.path().join(".sandcage/home/.justfile").exists());
}
+1
View File
@@ -1,6 +1,7 @@
pub mod config;
pub mod docker;
pub mod init;
pub mod service;
pub mod setup;
pub mod ssh_config;
pub mod workspace;
+31 -8
View File
@@ -6,6 +6,7 @@ use thiserror::Error;
use sandcage::config;
use sandcage::docker;
use sandcage::init;
use sandcage::service;
use sandcage::setup;
use sandcage::workspace;
@@ -47,17 +48,34 @@ enum Commands {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
agent_args: Vec<String>,
},
/// Run Gemini CLI agent in a sandboxed container
Gemini {
/// 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
/// Build container images
Build {
/// Force rebuild even if images are up to date
#[arg(long, short)]
force: bool,
/// Services to build (default: all enabled services)
services: Vec<String>,
},
/// Initialize a .sandcage.yml for a project
Init,
@@ -113,7 +131,7 @@ enum AppError {
Setup(#[from] setup::SetupError),
}
fn run_agent(service: &str, path: Option<PathBuf>, shell_override: bool, agent_args: Vec<String>) -> std::result::Result<(), AppError> {
fn run_service(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());
@@ -129,8 +147,10 @@ fn run_agent(service: &str, path: Option<PathBuf>, shell_override: bool, agent_a
Some(&local_config),
)?;
let registry = service::registry::build_default_registry(&cfg);
eprintln!("sandcage: service \u{2192} {service}");
docker::run_service(service, &workspace, &cfg, shell_override, &agent_args)?;
docker::run_service(service, &workspace, &cfg, &registry, shell_override, &agent_args)?;
Ok(())
}
@@ -140,14 +160,17 @@ fn main() -> miette::Result<()> {
match cli.command {
Commands::Claude { path, shell, agent_args } => {
run_agent("claude", path, shell, agent_args)?
run_service("claude", path, shell, agent_args)?
}
Commands::Codex { path, shell, agent_args } => {
run_agent("codex", path, shell, agent_args)?
run_service("codex", path, shell, agent_args)?
}
Commands::Shell { path } => run_agent("shell", path, false, vec![])?,
Commands::Build { force } => {
docker::build_images(force)?;
Commands::Gemini { path, shell, agent_args } => {
run_service("gemini", path, shell, agent_args)?
}
Commands::Shell { path } => run_service("shell", path, false, vec![])?,
Commands::Build { force, services } => {
docker::build_images(force, &services)?;
}
Commands::Init => {
let workspace = workspace::resolve_workspace(None)?;
+157
View File
@@ -0,0 +1,157 @@
use super::ServiceDef;
pub const CLAUDE_ENTRYPOINT: &str = r#"#!/bin/sh
set -e
CLAUDE_BIN="$HOME/.local/bin/claude"
if [ ! -x "$CLAUDE_BIN" ]; then
echo "sandcage: installing Claude Code..." >&2
curl -fsSL https://claude.ai/install.sh | bash >&2
fi
exec "$CLAUDE_BIN" "$@"
"#;
pub const CODEX_ENTRYPOINT: &str = r#"#!/bin/sh
set -e
CODEX_BIN="$HOME/.local/bin/codex"
if [ ! -x "$CODEX_BIN" ]; then
echo "sandcage: installing Codex..." >&2
arch=$(uname -m)
case "$arch" in
x86_64) target="x86_64-unknown-linux-musl" ;;
aarch64) target="aarch64-unknown-linux-musl" ;;
*) echo "sandcage: unsupported arch: $arch" >&2; exit 1 ;;
esac
mkdir -p "$HOME/.local/bin"
curl -fsSL "https://github.com/openai/codex/releases/latest/download/codex-${target}.tar.gz" \
| tar xz -C "$HOME/.local/bin"
mv "$HOME/.local/bin/codex-${target}" "$CODEX_BIN"
fi
exec "$CODEX_BIN" "$@"
"#;
pub const GEMINI_ENTRYPOINT: &str = r#"#!/bin/sh
set -e
GEMINI_BIN="$HOME/.local/bin/gemini"
if [ ! -x "$GEMINI_BIN" ]; then
echo "sandcage: installing Gemini CLI..." >&2
npm install -g @anthropic-ai/gemini-cli >&2
fi
exec "$GEMINI_BIN" "$@"
"#;
pub fn claude() -> ServiceDef {
ServiceDef {
name: "claude".to_string(),
description: "Claude Code agent".to_string(),
enabled: true,
entrypoint_name: Some("sandcage-claude-entrypoint".to_string()),
entrypoint_script: Some(CLAUDE_ENTRYPOINT.to_string()),
config_dir: Some(".claude".to_string()),
}
}
pub fn codex() -> ServiceDef {
ServiceDef {
name: "codex".to_string(),
description: "Codex agent".to_string(),
enabled: true,
entrypoint_name: Some("sandcage-codex-entrypoint".to_string()),
entrypoint_script: Some(CODEX_ENTRYPOINT.to_string()),
config_dir: Some(".codex".to_string()),
}
}
pub fn gemini() -> ServiceDef {
ServiceDef {
name: "gemini".to_string(),
description: "Gemini CLI agent".to_string(),
enabled: true,
entrypoint_name: Some("sandcage-gemini-entrypoint".to_string()),
entrypoint_script: Some(GEMINI_ENTRYPOINT.to_string()),
config_dir: Some(".gemini".to_string()),
}
}
pub fn shell() -> ServiceDef {
ServiceDef {
name: "shell".to_string(),
description: "Interactive shell".to_string(),
enabled: true,
entrypoint_name: Some("/bin/zsh".to_string()),
entrypoint_script: None,
config_dir: None,
}
}
pub fn all() -> Vec<ServiceDef> {
vec![claude(), codex(), gemini(), shell()]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::service::Service;
#[test]
fn all_builtins_are_enabled_by_default() {
for svc in all() {
assert!(svc.enabled(), "{} should be enabled by default", svc.name());
}
}
#[test]
fn all_builtins_have_unique_names() {
let services = all();
let mut names: Vec<&str> = services.iter().map(|s| s.name()).collect();
let count = names.len();
names.dedup();
assert_eq!(names.len(), count, "service names must be unique");
}
#[test]
fn agent_services_have_entrypoint_scripts() {
for svc in [claude(), codex(), gemini()] {
assert!(
svc.entrypoint_script().is_some(),
"{} should have an entrypoint script",
svc.name()
);
assert!(
svc.entrypoint_script().unwrap().starts_with("#!/bin/sh"),
"{} entrypoint should start with shebang",
svc.name()
);
}
}
#[test]
fn shell_has_no_entrypoint_script() {
let svc = shell();
assert!(svc.entrypoint_script().is_none());
assert_eq!(svc.entrypoint_name(), Some("/bin/zsh"));
}
#[test]
fn agent_services_have_config_dirs() {
assert_eq!(claude().config_dir(), Some(".claude"));
assert_eq!(codex().config_dir(), Some(".codex"));
assert_eq!(gemini().config_dir(), Some(".gemini"));
}
#[test]
fn shell_has_no_config_dir() {
assert_eq!(shell().config_dir(), None);
}
#[test]
fn claude_entrypoint_installs_from_claude_ai() {
assert!(CLAUDE_ENTRYPOINT.contains("claude.ai/install.sh"));
}
#[test]
fn codex_entrypoint_handles_arch_detection() {
assert!(CODEX_ENTRYPOINT.contains("uname -m"));
assert!(CODEX_ENTRYPOINT.contains("x86_64"));
assert!(CODEX_ENTRYPOINT.contains("aarch64"));
}
}
+150
View File
@@ -0,0 +1,150 @@
use super::registry::ServiceRegistry;
use super::ComposeContext;
pub fn generate_compose(registry: &ServiceRegistry, ctx: &ComposeContext) -> String {
let mut services = serde_yaml::Mapping::new();
for svc in registry.enabled() {
let def = svc.compose_service(ctx);
let value = serde_yaml::to_value(&def).expect("ComposeServiceDef must serialize");
services.insert(
serde_yaml::Value::String(svc.name().to_string()),
value,
);
}
let mut doc = serde_yaml::Mapping::new();
doc.insert(
serde_yaml::Value::String("services".to_string()),
serde_yaml::Value::Mapping(services),
);
serde_yaml::to_string(&serde_yaml::Value::Mapping(doc))
.expect("compose doc must serialize")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::service::builtin;
use crate::service::ServiceDef;
use std::path::PathBuf;
fn test_context() -> ComposeContext {
ComposeContext {
uid: "1000".to_string(),
gid: "1000".to_string(),
workspace: PathBuf::from("/home/user/projects/my-app"),
container_dir: "/workspace/my-app".to_string(),
sandcage_home: PathBuf::from("/home/user/.sandcage"),
}
}
fn registry_with(services: Vec<ServiceDef>) -> ServiceRegistry {
let mut reg = ServiceRegistry::new();
for svc in services {
reg.register(Box::new(svc));
}
reg
}
#[test]
fn generates_valid_yaml() {
let reg = registry_with(vec![builtin::claude(), builtin::shell()]);
let yaml = generate_compose(&reg, &test_context());
let parsed: serde_yaml::Value =
serde_yaml::from_str(&yaml).expect("output must be valid YAML");
assert!(parsed.as_mapping().unwrap().contains_key("services"));
}
#[test]
fn contains_only_enabled_services() {
let mut codex = builtin::codex();
codex.enabled = false;
let reg = registry_with(vec![builtin::claude(), codex, builtin::shell()]);
let yaml = generate_compose(&reg, &test_context());
assert!(yaml.contains("claude:"), "claude should be present");
assert!(yaml.contains("shell:"), "shell should be present");
assert!(!yaml.contains("codex:"), "disabled codex should be absent");
}
#[test]
fn empty_registry_produces_empty_services() {
let reg = ServiceRegistry::new();
let yaml = generate_compose(&reg, &test_context());
let parsed: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
let services = parsed.as_mapping().unwrap().get("services").unwrap();
assert!(
services.as_mapping().unwrap().is_empty(),
"services should be empty mapping"
);
}
#[test]
fn service_has_correct_image() {
let reg = registry_with(vec![builtin::claude()]);
let yaml = generate_compose(&reg, &test_context());
assert!(yaml.contains("sandcage:latest"));
}
#[test]
fn service_has_correct_entrypoint() {
let reg = registry_with(vec![builtin::claude()]);
let yaml = generate_compose(&reg, &test_context());
assert!(yaml.contains("sandcage-claude-entrypoint"));
}
#[test]
fn service_has_correct_working_dir() {
let reg = registry_with(vec![builtin::claude()]);
let yaml = generate_compose(&reg, &test_context());
assert!(yaml.contains("/workspace/my-app"));
}
#[test]
fn service_has_home_volume() {
let reg = registry_with(vec![builtin::claude()]);
let yaml = generate_compose(&reg, &test_context());
assert!(yaml.contains("/home/user/.sandcage/home:/home/agent"));
}
#[test]
fn all_four_builtins_generate_successfully() {
let reg = registry_with(builtin::all());
let yaml = generate_compose(&reg, &test_context());
for name in ["claude", "codex", "gemini", "shell"] {
assert!(
yaml.contains(&format!("{name}:")),
"compose should contain {name} service"
);
}
}
#[test]
fn backward_compat_claude_codex_shell_match_old_structure() {
let reg = registry_with(vec![
builtin::claude(),
builtin::codex(),
builtin::shell(),
]);
let yaml = generate_compose(&reg, &test_context());
let parsed: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
let services = parsed["services"].as_mapping().unwrap();
for name in ["claude", "codex", "shell"] {
let svc = &services[&serde_yaml::Value::String(name.to_string())];
assert_eq!(
svc["image"].as_str().unwrap(),
"sandcage:latest",
"{name}: image"
);
assert!(svc["tty"].as_bool().unwrap(), "{name}: tty");
assert!(svc["stdin_open"].as_bool().unwrap(), "{name}: stdin_open");
}
}
}
+234
View File
@@ -0,0 +1,234 @@
pub mod builtin;
pub mod compose;
pub mod registry;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
// Compose output types
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
pub struct ComposeContext {
pub uid: String,
pub gid: String,
pub workspace: PathBuf,
pub container_dir: String,
pub sandcage_home: PathBuf,
}
#[derive(Debug, Clone, Serialize)]
pub struct ComposeServiceDef {
pub image: String,
pub entrypoint: Vec<String>,
pub working_dir: String,
pub user: String,
pub volumes: Vec<String>,
pub environment: Vec<String>,
pub tty: bool,
pub stdin_open: bool,
}
// ---------------------------------------------------------------------------
// Service trait
// ---------------------------------------------------------------------------
pub trait Service: std::fmt::Debug + Send + Sync {
fn name(&self) -> &str;
fn description(&self) -> &str;
fn enabled(&self) -> bool;
fn set_enabled(&mut self, enabled: bool);
fn entrypoint_name(&self) -> Option<&str>;
fn entrypoint_script(&self) -> Option<&str>;
fn config_dir(&self) -> Option<&str>;
fn compose_service(&self, ctx: &ComposeContext) -> ComposeServiceDef;
}
// ---------------------------------------------------------------------------
// ServiceDef — standard implementation
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceDef {
pub name: String,
pub description: String,
pub enabled: bool,
pub entrypoint_name: Option<String>,
pub entrypoint_script: Option<String>,
pub config_dir: Option<String>,
}
impl Service for ServiceDef {
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &str {
&self.description
}
fn enabled(&self) -> bool {
self.enabled
}
fn set_enabled(&mut self, enabled: bool) {
self.enabled = enabled;
}
fn entrypoint_name(&self) -> Option<&str> {
self.entrypoint_name.as_deref()
}
fn entrypoint_script(&self) -> Option<&str> {
self.entrypoint_script.as_deref()
}
fn config_dir(&self) -> Option<&str> {
self.config_dir.as_deref()
}
fn compose_service(&self, ctx: &ComposeContext) -> ComposeServiceDef {
let entrypoint = match self.entrypoint_name.as_deref() {
Some(name) => vec![name.to_string()],
None => vec!["/bin/zsh".to_string()],
};
ComposeServiceDef {
image: "sandcage:latest".to_string(),
entrypoint,
working_dir: ctx.container_dir.clone(),
user: format!("{}:{}", ctx.uid, ctx.gid),
volumes: vec![
format!("{}/home:/home/agent", ctx.sandcage_home.display()),
format!(
"{}:{}",
ctx.workspace.display(),
ctx.container_dir
),
],
environment: vec!["HOME=/home/agent".to_string()],
tty: true,
stdin_open: true,
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn test_context() -> ComposeContext {
ComposeContext {
uid: "1000".to_string(),
gid: "1000".to_string(),
workspace: PathBuf::from("/home/user/projects/my-app"),
container_dir: "/workspace/my-app".to_string(),
sandcage_home: PathBuf::from("/home/user/.sandcage"),
}
}
fn agent_service() -> ServiceDef {
ServiceDef {
name: "test-agent".to_string(),
description: "A test agent".to_string(),
enabled: true,
entrypoint_name: Some("sandcage-test-entrypoint".to_string()),
entrypoint_script: Some("#!/bin/sh\nexec echo hello".to_string()),
config_dir: Some(".test-agent".to_string()),
}
}
#[test]
fn service_def_trait_accessors() {
let svc = agent_service();
assert_eq!(svc.name(), "test-agent");
assert_eq!(svc.description(), "A test agent");
assert!(svc.enabled());
assert_eq!(svc.entrypoint_name(), Some("sandcage-test-entrypoint"));
assert!(svc.entrypoint_script().is_some());
assert_eq!(svc.config_dir(), Some(".test-agent"));
}
#[test]
fn service_def_set_enabled() {
let mut svc = agent_service();
assert!(svc.enabled());
svc.set_enabled(false);
assert!(!svc.enabled());
}
#[test]
fn compose_service_has_correct_image() {
let svc = agent_service();
let ctx = test_context();
let compose = svc.compose_service(&ctx);
assert_eq!(compose.image, "sandcage:latest");
}
#[test]
fn compose_service_uses_entrypoint_name() {
let svc = agent_service();
let ctx = test_context();
let compose = svc.compose_service(&ctx);
assert_eq!(compose.entrypoint, vec!["sandcage-test-entrypoint"]);
}
#[test]
fn compose_service_shell_uses_bin_zsh_when_no_entrypoint() {
let svc = ServiceDef {
entrypoint_name: None,
..agent_service()
};
let ctx = test_context();
let compose = svc.compose_service(&ctx);
assert_eq!(compose.entrypoint, vec!["/bin/zsh"]);
}
#[test]
fn compose_service_has_correct_user() {
let svc = agent_service();
let ctx = test_context();
let compose = svc.compose_service(&ctx);
assert_eq!(compose.user, "1000:1000");
}
#[test]
fn compose_service_has_home_and_workspace_volumes() {
let svc = agent_service();
let ctx = test_context();
let compose = svc.compose_service(&ctx);
assert_eq!(compose.volumes.len(), 2);
assert!(compose.volumes[0].contains("/home:/home/agent"));
assert!(compose.volumes[1].contains("/workspace/my-app"));
}
#[test]
fn compose_service_has_tty_and_stdin() {
let svc = agent_service();
let ctx = test_context();
let compose = svc.compose_service(&ctx);
assert!(compose.tty);
assert!(compose.stdin_open);
}
#[test]
fn shell_service_uses_explicit_entrypoint() {
let svc = ServiceDef {
name: "shell".to_string(),
description: "Interactive shell".to_string(),
enabled: true,
entrypoint_name: Some("/bin/zsh".to_string()),
entrypoint_script: None,
config_dir: None,
};
let ctx = test_context();
let compose = svc.compose_service(&ctx);
assert_eq!(compose.entrypoint, vec!["/bin/zsh"]);
}
}
+237
View File
@@ -0,0 +1,237 @@
use indexmap::IndexMap;
use super::Service;
pub struct ServiceRegistry {
services: IndexMap<String, Box<dyn Service>>,
}
impl Default for ServiceRegistry {
fn default() -> Self {
Self::new()
}
}
impl ServiceRegistry {
pub fn new() -> Self {
Self {
services: IndexMap::new(),
}
}
pub fn register(&mut self, service: Box<dyn Service>) {
self.services.insert(service.name().to_string(), service);
}
pub fn get(&self, name: &str) -> Option<&dyn Service> {
self.services.get(name).map(|s| s.as_ref())
}
pub fn get_mut(&mut self, name: &str) -> Option<&mut Box<dyn Service>> {
self.services.get_mut(name)
}
pub fn enabled(&self) -> impl Iterator<Item = &dyn Service> {
self.services.values().filter(|s| s.enabled()).map(|s| s.as_ref())
}
pub fn all(&self) -> impl Iterator<Item = &dyn Service> {
self.services.values().map(|s| s.as_ref())
}
pub fn names(&self) -> impl Iterator<Item = &str> {
self.services.keys().map(|k| k.as_str())
}
pub fn enabled_names(&self) -> Vec<&str> {
self.enabled().map(|s| s.name()).collect()
}
pub fn apply_overrides(&mut self, config: &crate::config::SandcageConfig) {
if let Some(ref overrides) = config.services {
for (name, ovr) in overrides {
if let Some(svc) = self.get_mut(name)
&& let Some(enabled) = ovr.enabled
{
svc.set_enabled(enabled);
}
}
}
}
}
impl std::fmt::Debug for ServiceRegistry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ServiceRegistry")
.field("services", &self.services.keys().collect::<Vec<_>>())
.finish()
}
}
pub fn build_default_registry(config: &crate::config::SandcageConfig) -> ServiceRegistry {
let mut reg = ServiceRegistry::new();
for svc in super::builtin::all() {
reg.register(Box::new(svc));
}
reg.apply_overrides(config);
reg
}
#[cfg(test)]
mod tests {
use super::*;
use crate::service::ServiceDef;
fn make_service(name: &str, enabled: bool) -> Box<dyn Service> {
Box::new(ServiceDef {
name: name.to_string(),
description: format!("{name} service"),
enabled,
entrypoint_name: Some(format!("sandcage-{name}-entrypoint")),
entrypoint_script: Some("#!/bin/sh\nexec echo test".to_string()),
config_dir: Some(format!(".{name}")),
})
}
#[test]
fn register_and_get() {
let mut reg = ServiceRegistry::new();
reg.register(make_service("claude", true));
assert!(reg.get("claude").is_some());
assert!(reg.get("nonexistent").is_none());
}
#[test]
fn enabled_filters_disabled_services() {
let mut reg = ServiceRegistry::new();
reg.register(make_service("claude", true));
reg.register(make_service("codex", false));
reg.register(make_service("shell", true));
let enabled: Vec<&str> = reg.enabled().map(|s| s.name()).collect();
assert_eq!(enabled, vec!["claude", "shell"]);
}
#[test]
fn all_returns_every_service() {
let mut reg = ServiceRegistry::new();
reg.register(make_service("claude", true));
reg.register(make_service("codex", false));
let all: Vec<&str> = reg.all().map(|s| s.name()).collect();
assert_eq!(all, vec!["claude", "codex"]);
}
#[test]
fn names_returns_all_keys() {
let mut reg = ServiceRegistry::new();
reg.register(make_service("claude", true));
reg.register(make_service("codex", true));
let names: Vec<&str> = reg.names().collect();
assert_eq!(names, vec!["claude", "codex"]);
}
#[test]
fn preserves_insertion_order() {
let mut reg = ServiceRegistry::new();
reg.register(make_service("shell", true));
reg.register(make_service("claude", true));
reg.register(make_service("codex", true));
let names: Vec<&str> = reg.names().collect();
assert_eq!(names, vec!["shell", "claude", "codex"]);
}
#[test]
fn get_mut_can_disable_service() {
let mut reg = ServiceRegistry::new();
reg.register(make_service("claude", true));
assert!(reg.get("claude").unwrap().enabled());
reg.get_mut("claude").unwrap().set_enabled(false);
assert!(!reg.get("claude").unwrap().enabled());
}
#[test]
fn enabled_names_convenience() {
let mut reg = ServiceRegistry::new();
reg.register(make_service("claude", true));
reg.register(make_service("codex", false));
reg.register(make_service("shell", true));
assert_eq!(reg.enabled_names(), vec!["claude", "shell"]);
}
use crate::config::{SandcageConfig, ServiceOverride};
use std::collections::HashMap;
#[test]
fn apply_overrides_disables_service() {
let mut reg = ServiceRegistry::new();
reg.register(make_service("claude", true));
reg.register(make_service("codex", true));
let mut overrides = HashMap::new();
overrides.insert("codex".to_string(), ServiceOverride { enabled: Some(false) });
let config = SandcageConfig {
services: Some(overrides),
..Default::default()
};
reg.apply_overrides(&config);
assert!(reg.get("claude").unwrap().enabled());
assert!(!reg.get("codex").unwrap().enabled());
}
#[test]
fn apply_overrides_ignores_unknown_services() {
let mut reg = ServiceRegistry::new();
reg.register(make_service("claude", true));
let mut overrides = HashMap::new();
overrides.insert("nonexistent".to_string(), ServiceOverride { enabled: Some(false) });
let config = SandcageConfig {
services: Some(overrides),
..Default::default()
};
reg.apply_overrides(&config);
assert!(reg.get("claude").unwrap().enabled());
}
#[test]
fn build_default_registry_has_all_builtins() {
let config = SandcageConfig::default();
let reg = build_default_registry(&config);
for name in ["claude", "codex", "gemini", "shell"] {
assert!(
reg.get(name).is_some(),
"{name} should be in default registry"
);
assert!(
reg.get(name).unwrap().enabled(),
"{name} should be enabled by default"
);
}
}
#[test]
fn build_default_registry_applies_overrides() {
let mut overrides = HashMap::new();
overrides.insert("codex".to_string(), ServiceOverride { enabled: Some(false) });
overrides.insert("gemini".to_string(), ServiceOverride { enabled: Some(false) });
let config = SandcageConfig {
services: Some(overrides),
..Default::default()
};
let reg = build_default_registry(&config);
assert!(reg.get("claude").unwrap().enabled());
assert!(!reg.get("codex").unwrap().enabled());
assert!(!reg.get("gemini").unwrap().enabled());
assert!(reg.get("shell").unwrap().enabled());
}
}