🥇 export from upstream (0aed74f)
This commit is contained in:
Generated
+1
@@ -780,6 +780,7 @@ dependencies = [
|
|||||||
"dirs",
|
"dirs",
|
||||||
"figment",
|
"figment",
|
||||||
"hex",
|
"hex",
|
||||||
|
"indexmap",
|
||||||
"miette",
|
"miette",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
|
|||||||
@@ -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"]
|
|
||||||
@@ -17,6 +17,7 @@ which = "7"
|
|||||||
dirs = "6"
|
dirs = "6"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
|
indexmap = { version = "2", features = ["serde"] }
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
dialoguer = "0.11"
|
dialoguer = "0.11"
|
||||||
toml_edit = "0.22"
|
toml_edit = "0.22"
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ pub struct SshKeyEntry {
|
|||||||
pub identity_file: String,
|
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
|
// Raw deserialization type — captures unknown keys
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -70,6 +76,10 @@ struct RawConfig {
|
|||||||
ssh_mode: Option<String>,
|
ssh_mode: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
ssh_keys: Option<Vec<SshKeyEntry>>,
|
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.
|
/// Absorb everything else so we can warn about unknown fields.
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
@@ -107,13 +117,17 @@ pub struct SandcageConfig {
|
|||||||
pub ssh_mode: Option<String>,
|
pub ssh_mode: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub ssh_keys: Option<Vec<SshKeyEntry>>,
|
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
|
// 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
|
// Validation helpers
|
||||||
@@ -168,6 +182,8 @@ fn from_raw(raw: RawConfig) -> SandcageConfig {
|
|||||||
agent_args: raw.agent_args,
|
agent_args: raw.agent_args,
|
||||||
ssh_mode: raw.ssh_mode,
|
ssh_mode: raw.ssh_mode,
|
||||||
ssh_keys: raw.ssh_keys,
|
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");
|
let cfg = resolve_config(Some(global_tmp.path()), Some(project_tmp.path()), None).expect("resolve");
|
||||||
assert_eq!(cfg.ssh_mode.as_deref(), Some("volume"));
|
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
@@ -16,8 +16,6 @@ use crate::config::SandcageConfig;
|
|||||||
|
|
||||||
pub const DOCKERFILE: &str = include_str!("../../../images/base/Dockerfile");
|
pub const DOCKERFILE: &str = include_str!("../../../images/base/Dockerfile");
|
||||||
|
|
||||||
pub const COMPOSE_YAML: &str = include_str!("../../../compose/docker-compose.yml");
|
|
||||||
|
|
||||||
#[derive(Debug, Error, Diagnostic)]
|
#[derive(Debug, Error, Diagnostic)]
|
||||||
pub enum DockerError {
|
pub enum DockerError {
|
||||||
#[error("docker executable not found in PATH")]
|
#[error("docker executable not found in PATH")]
|
||||||
@@ -80,6 +78,20 @@ pub enum DockerError {
|
|||||||
help("Run `sandcage build` to build the required images.")
|
help("Run `sandcage build` to build the required images.")
|
||||||
)]
|
)]
|
||||||
ImageNotFound { image: String },
|
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>;
|
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)
|
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 {
|
fn default_container_dir(workspace: &Path) -> String {
|
||||||
match workspace.file_name() {
|
match workspace.file_name() {
|
||||||
Some(name) => format!("/workspace/{}", name.to_string_lossy()),
|
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()
|
let mut tmp = tempfile::Builder::new()
|
||||||
.prefix("sandcage-compose-")
|
.prefix("sandcage-compose-")
|
||||||
.suffix(".yml")
|
.suffix(".yml")
|
||||||
.tempfile()
|
.tempfile()
|
||||||
.map_err(DockerError::TempfileFailed)?;
|
.map_err(DockerError::TempfileFailed)?;
|
||||||
|
|
||||||
tmp.write_all(COMPOSE_YAML.as_bytes())
|
tmp.write_all(content.as_bytes())
|
||||||
.map_err(DockerError::TempfileFailed)?;
|
.map_err(DockerError::TempfileFailed)?;
|
||||||
tmp.flush().map_err(DockerError::TempfileFailed)?;
|
tmp.flush().map_err(DockerError::TempfileFailed)?;
|
||||||
|
|
||||||
@@ -294,12 +337,24 @@ pub fn run_service(
|
|||||||
service: &str,
|
service: &str,
|
||||||
workspace: &Path,
|
workspace: &Path,
|
||||||
config: &SandcageConfig,
|
config: &SandcageConfig,
|
||||||
|
registry: &crate::service::registry::ServiceRegistry,
|
||||||
shell_override: bool,
|
shell_override: bool,
|
||||||
extra_args: &[String],
|
extra_args: &[String],
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let docker = require_docker()?;
|
let docker = require_docker()?;
|
||||||
require_compose(&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);
|
let image = image_for_service(service);
|
||||||
require_image(&docker, image)?;
|
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_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 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(&run_args);
|
cmd.args(&run_args);
|
||||||
|
|
||||||
// Inject compose environment variables
|
|
||||||
cmd.envs(&compose_env);
|
|
||||||
|
|
||||||
// Inherit stdio for interactive use
|
|
||||||
cmd.stdin(std::process::Stdio::inherit())
|
cmd.stdin(std::process::Stdio::inherit())
|
||||||
.stdout(std::process::Stdio::inherit())
|
.stdout(std::process::Stdio::inherit())
|
||||||
.stderr(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)
|
std::fs::write(&dockerfile_path, dockerfile_content)
|
||||||
.map_err(DockerError::DockerfileWriteFailed)?;
|
.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)
|
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.
|
/// * 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.
|
/// * 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`.
|
/// * 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 docker = require_docker()?;
|
||||||
|
|
||||||
let home = dirs::home_dir().ok_or(DockerError::NoHomeDir)?;
|
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.
|
// Global overrides are always trusted.
|
||||||
let global_cfg = crate::config::resolve_config(Some(&global_config), None, None)
|
let global_cfg = crate::config::resolve_config(Some(&global_config), None, None)
|
||||||
.unwrap_or_default();
|
.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();
|
let mut overrides = global_cfg.dockerfiles.unwrap_or_default();
|
||||||
|
|
||||||
// Project overrides require the project to be in trusted_projects.
|
// Project overrides require the project to be in trusted_projects.
|
||||||
@@ -581,23 +659,26 @@ mod tests {
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn compose_yaml_is_bundled() {
|
fn generated_compose_contains_all_services() {
|
||||||
assert!(
|
use crate::service::ComposeContext;
|
||||||
COMPOSE_YAML.contains("services:"),
|
use crate::service::registry::build_default_registry;
|
||||||
"bundled compose YAML should contain 'services:'"
|
use crate::service::compose::generate_compose;
|
||||||
);
|
|
||||||
assert!(
|
let config = SandcageConfig::default();
|
||||||
COMPOSE_YAML.contains("claude:"),
|
let registry = build_default_registry(&config);
|
||||||
"bundled compose YAML should define 'claude' service"
|
let ctx = ComposeContext {
|
||||||
);
|
uid: "1000".to_string(),
|
||||||
assert!(
|
gid: "1000".to_string(),
|
||||||
COMPOSE_YAML.contains("codex:"),
|
workspace: PathBuf::from("/tmp/test"),
|
||||||
"bundled compose YAML should define 'codex' service"
|
container_dir: "/workspace/test".to_string(),
|
||||||
);
|
sandcage_home: PathBuf::from("/home/user/.sandcage"),
|
||||||
assert!(
|
};
|
||||||
COMPOSE_YAML.contains("shell:"),
|
let yaml = generate_compose(®istry, &ctx);
|
||||||
"bundled compose YAML should define 'shell' service"
|
|
||||||
);
|
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]
|
#[test]
|
||||||
@@ -649,9 +730,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn write_compose_tempfile_creates_valid_yaml() {
|
fn write_compose_tempfile_creates_valid_yaml() {
|
||||||
let tmp = write_compose_tempfile().expect("write_compose_tempfile");
|
let content = "services:\n test:\n image: test:latest\n";
|
||||||
let content = std::fs::read_to_string(tmp.path()).expect("read tempfile");
|
let tmp = write_compose_tempfile(content).expect("write_compose_tempfile");
|
||||||
assert_eq!(content, COMPOSE_YAML, "tempfile content should match bundled YAML");
|
let read_back = std::fs::read_to_string(tmp.path()).expect("read tempfile");
|
||||||
|
assert_eq!(read_back, content, "tempfile content should match input");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ pub fn preseed_with_home(home: &Path) -> Result<()> {
|
|||||||
let codex_dir = agent_home.join(".codex");
|
let codex_dir = agent_home.join(".codex");
|
||||||
create_dir_all(&codex_dir)?;
|
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
|
// 4. Seed Claude settings.json from the bundled template
|
||||||
let settings_dest = claude_dir.join("settings.json");
|
let settings_dest = claude_dir.join("settings.json");
|
||||||
if !settings_dest.exists() {
|
if !settings_dest.exists() {
|
||||||
@@ -231,7 +235,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn creates_claude_and_codex_dirs() {
|
fn creates_agent_config_dirs() {
|
||||||
let home = TempDir::new().expect("create tempdir");
|
let home = TempDir::new().expect("create tempdir");
|
||||||
run(home.path()).expect("preseed");
|
run(home.path()).expect("preseed");
|
||||||
|
|
||||||
@@ -243,6 +247,10 @@ mod tests {
|
|||||||
home.path().join(".sandcage/home/.codex").is_dir(),
|
home.path().join(".sandcage/home/.codex").is_dir(),
|
||||||
".codex dir should exist"
|
".codex dir should exist"
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
home.path().join(".sandcage/home/.gemini").is_dir(),
|
||||||
|
".gemini dir should exist"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -320,6 +328,7 @@ mod tests {
|
|||||||
// All artefacts should still exist after two runs
|
// All artefacts should still exist after two runs
|
||||||
assert!(home.path().join(".sandcage/home/.claude").is_dir());
|
assert!(home.path().join(".sandcage/home/.claude").is_dir());
|
||||||
assert!(home.path().join(".sandcage/home/.codex").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/.claude/settings.json").exists());
|
||||||
assert!(home.path().join(".sandcage/home/.justfile").exists());
|
assert!(home.path().join(".sandcage/home/.justfile").exists());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod docker;
|
pub mod docker;
|
||||||
pub mod init;
|
pub mod init;
|
||||||
|
pub mod service;
|
||||||
pub mod setup;
|
pub mod setup;
|
||||||
pub mod ssh_config;
|
pub mod ssh_config;
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use thiserror::Error;
|
|||||||
use sandcage::config;
|
use sandcage::config;
|
||||||
use sandcage::docker;
|
use sandcage::docker;
|
||||||
use sandcage::init;
|
use sandcage::init;
|
||||||
|
use sandcage::service;
|
||||||
use sandcage::setup;
|
use sandcage::setup;
|
||||||
use sandcage::workspace;
|
use sandcage::workspace;
|
||||||
|
|
||||||
@@ -47,17 +48,34 @@ enum Commands {
|
|||||||
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
||||||
agent_args: Vec<String>,
|
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
|
/// 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)]
|
#[arg(long, short)]
|
||||||
path: Option<PathBuf>,
|
path: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
/// Build all container images
|
/// Build container images
|
||||||
Build {
|
Build {
|
||||||
/// Force rebuild even if images are up to date
|
/// Force rebuild even if images are up to date
|
||||||
#[arg(long, short)]
|
#[arg(long, short)]
|
||||||
force: bool,
|
force: bool,
|
||||||
|
|
||||||
|
/// Services to build (default: all enabled services)
|
||||||
|
services: Vec<String>,
|
||||||
},
|
},
|
||||||
/// Initialize a .sandcage.yml for a project
|
/// Initialize a .sandcage.yml for a project
|
||||||
Init,
|
Init,
|
||||||
@@ -113,7 +131,7 @@ enum AppError {
|
|||||||
Setup(#[from] setup::SetupError),
|
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())?;
|
let workspace = workspace::resolve_workspace(path.as_deref())?;
|
||||||
eprintln!("sandcage: workspace \u{2192} {}", workspace.display());
|
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),
|
Some(&local_config),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
let registry = service::registry::build_default_registry(&cfg);
|
||||||
|
|
||||||
eprintln!("sandcage: service \u{2192} {service}");
|
eprintln!("sandcage: service \u{2192} {service}");
|
||||||
docker::run_service(service, &workspace, &cfg, shell_override, &agent_args)?;
|
docker::run_service(service, &workspace, &cfg, ®istry, shell_override, &agent_args)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -140,14 +160,17 @@ fn main() -> miette::Result<()> {
|
|||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Commands::Claude { path, shell, agent_args } => {
|
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 } => {
|
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::Gemini { path, shell, agent_args } => {
|
||||||
Commands::Build { force } => {
|
run_service("gemini", path, shell, agent_args)?
|
||||||
docker::build_images(force)?;
|
}
|
||||||
|
Commands::Shell { path } => run_service("shell", path, false, vec![])?,
|
||||||
|
Commands::Build { force, services } => {
|
||||||
|
docker::build_images(force, &services)?;
|
||||||
}
|
}
|
||||||
Commands::Init => {
|
Commands::Init => {
|
||||||
let workspace = workspace::resolve_workspace(None)?;
|
let workspace = workspace::resolve_workspace(None)?;
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(®, &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(®, &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(®, &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(®, &test_context());
|
||||||
|
assert!(yaml.contains("sandcage:latest"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn service_has_correct_entrypoint() {
|
||||||
|
let reg = registry_with(vec![builtin::claude()]);
|
||||||
|
let yaml = generate_compose(®, &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(®, &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(®, &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(®, &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(®, &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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user