🛰️ export from upstream (18069bd)

This commit is contained in:
2026-09-21 14:31:12 +02:00
commit 8262b5f39d
52 changed files with 16288 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
[package]
name = "sandcage"
version = "0.1.0"
edition = "2024"
description = "Sandboxed containers for AI coding agents"
license = "MIT"
keywords = ["docker", "sandbox", "ai", "agent"]
[features]
default = ["bollard"]
bollard = [
"dep:bollard",
"dep:futures-util",
"dep:reqwest",
"dep:flate2",
"dep:tar",
"dep:crossterm",
]
[dependencies]
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
figment = { version = "0.10", features = ["toml", "env"] }
miette = { version = "7", features = ["fancy"] }
thiserror = "2"
which = "7"
dirs = "6"
sha2 = "0.10"
hex = "0.4"
indexmap = { version = "2", features = ["serde"] }
tempfile = "3"
walkdir = "2"
dialoguer = "0.11"
toml = "0.8"
toml_edit = "0.22"
async-trait = "0.1"
tokio = { version = "1", features = [
"rt-multi-thread", "io-std", "io-util",
"signal", "time", "macros", "process",
] }
# Feature-gated
bollard = { version = "0.21", optional = true }
futures-util = { version = "0.3", optional = true }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true }
flate2 = { version = "1", optional = true }
tar = { version = "0.4", optional = true }
crossterm = { version = "0.28", optional = true }
+231
View File
@@ -0,0 +1,231 @@
pub mod registry;
use clap::Subcommand;
use std::path::PathBuf;
use crate::backend::ContainerBackend;
use crate::docker::DockerError;
use crate::service;
use crate::workspace;
#[derive(Subcommand, Debug)]
pub enum AcpCommands {
/// Run a service in ACP relay mode (bidirectional stdio)
Run {
/// Service name (e.g., claude, codex, gemini)
service: String,
/// Path to the project directory (defaults to current directory)
#[arg(long, short)]
path: Option<PathBuf>,
/// Arguments forwarded to the agent inside the container
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
agent_args: Vec<String>,
},
/// Install an agent from the ACP registry
Install {
/// Agent ID from the registry (e.g., claude-code)
agent_id: String,
/// Force re-download even if already installed
#[arg(long)]
force: bool,
},
/// List available agents in the ACP registry
List {
/// Force refresh of the registry cache
#[arg(long)]
refresh: bool,
},
/// List locally installed ACP agents
Installed,
}
pub async fn handle(
cmd: AcpCommands,
backend: &dyn ContainerBackend,
workspace_path: Option<PathBuf>,
) -> std::result::Result<(), DockerError> {
match cmd {
AcpCommands::Run {
service,
path,
agent_args,
} => {
let effective_path = path.or(workspace_path);
let workspace = workspace::resolve_workspace(effective_path.as_deref())
.map_err(|e| DockerError::SpawnFailed(std::io::Error::new(
std::io::ErrorKind::Other,
e.to_string(),
)))?;
crate::init::preseed()
.map_err(|e| DockerError::SpawnFailed(std::io::Error::new(
std::io::ErrorKind::Other,
e.to_string(),
)))?;
let home = dirs::home_dir().ok_or(DockerError::NoHomeDir)?;
let global_config = home.join(".sandcage").join("config.toml");
let config_src = crate::config_source::resolve_config_source(&workspace);
let config_root = config_src.as_ref().map(|s| s.root.clone()).unwrap_or_else(|| workspace.clone());
let project_config = config_root.join(".sandcage").join("sandcage.yml");
let local_config = config_root.join(".sandcage").join("local").join("sandcage.local.yml");
let cfg = crate::config::resolve_config(
Some(&global_config),
Some(&project_config),
Some(&local_config),
)
.map_err(|e| DockerError::SpawnFailed(std::io::Error::new(
std::io::ErrorKind::Other,
e.to_string(),
)))?;
let registry = service::registry::build_default_registry(&cfg);
let ctx = crate::docker::build_compose_context(&workspace, &cfg, &config_root).await?;
let svc = registry.get(&service).ok_or_else(|| DockerError::UnknownService {
service: service.clone(),
available: registry.names().collect::<Vec<_>>().join(", "),
})?;
if !svc.enabled() {
return Err(DockerError::ServiceDisabled {
service: service.clone(),
});
}
eprintln!("sandcage: workspace \u{2192} {}", workspace.display());
eprintln!("sandcage: acp relay \u{2192} {service}");
let exit_code = backend.run_acp_relay(svc, &ctx, &cfg, &agent_args).await?;
if exit_code != 0 {
return Err(DockerError::ServiceFailed {
service,
code: exit_code,
});
}
Ok(())
}
AcpCommands::Install { agent_id, force } => {
handle_install(&agent_id, force).await.map_err(|e| {
DockerError::SpawnFailed(std::io::Error::new(std::io::ErrorKind::Other, e))
})
}
AcpCommands::List { refresh } => {
handle_list(refresh).await.map_err(|e| {
DockerError::SpawnFailed(std::io::Error::new(std::io::ErrorKind::Other, e))
})
}
AcpCommands::Installed => {
handle_installed().map_err(|e| {
DockerError::SpawnFailed(std::io::Error::new(std::io::ErrorKind::Other, e))
})
}
}
}
async fn handle_install(agent_id: &str, force: bool) -> std::result::Result<(), String> {
let index = registry::fetch_registry(false).await?;
let entry = registry::find_agent(&index, agent_id)
.ok_or_else(|| format!("Agent '{}' not found in registry", agent_id))?;
let platform = registry::platform_key();
let target = entry
.distribution
.binary
.as_ref()
.and_then(|b| b.get(platform))
.ok_or_else(|| {
format!(
"Agent '{}' has no binary for platform '{}'",
agent_id, platform
)
})?;
if !force && registry::is_agent_installed(agent_id, &entry.version) {
eprintln!("sandcage: {} v{} already installed", entry.name, entry.version);
return Ok(());
}
if force {
if let Some(dir) = registry::agent_install_dir(agent_id, &entry.version) {
std::fs::remove_dir_all(&dir).ok();
}
}
registry::install_agent(entry, target).await?;
Ok(())
}
async fn handle_list(refresh: bool) -> std::result::Result<(), String> {
let index = registry::fetch_registry(refresh).await?;
let platform = registry::platform_key();
println!("{:<25} {:<20} {:<10} {}", "ID", "NAME", "VERSION", "PLATFORM");
println!("{}", "-".repeat(70));
for agent in &index.agents {
let has_binary = agent
.distribution
.binary
.as_ref()
.map(|b| b.contains_key(platform))
.unwrap_or(false);
let platform_status = if has_binary {
"binary"
} else if agent.distribution.npx.is_some() {
"npx"
} else {
"none"
};
println!(
"{:<25} {:<20} {:<10} {}",
agent.id, agent.name, agent.version, platform_status
);
}
Ok(())
}
fn handle_installed() -> std::result::Result<(), String> {
let base = dirs::home_dir()
.map(|h| h.join(".sandcage").join("agents"))
.ok_or("cannot determine home directory")?;
if !base.exists() {
println!("No agents installed.");
return Ok(());
}
println!("{:<25} {:<15} {}", "AGENT", "VERSION", "PATH");
println!("{}", "-".repeat(60));
let entries = std::fs::read_dir(&base).map_err(|e| e.to_string())?;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let agent_id = entry.file_name().to_string_lossy().to_string();
if agent_id == "registry.json" || agent_id.ends_with(".timestamp") {
continue;
}
let versions = std::fs::read_dir(&path).map_err(|e| e.to_string())?;
for ver_entry in versions.flatten() {
let ver_path = ver_entry.path();
if !ver_path.is_dir() {
continue;
}
let version = ver_entry.file_name().to_string_lossy().to_string();
println!("{:<25} {:<15} {}", agent_id, version, ver_path.display());
}
}
Ok(())
}
+456
View File
@@ -0,0 +1,456 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
const REGISTRY_URL: &str =
"https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
const REFRESH_INTERVAL_SECS: u64 = 3600;
const FETCH_TIMEOUT_SECS: u64 = 30;
#[derive(Debug, Deserialize)]
pub struct RegistryIndex {
#[serde(rename = "version")]
pub _version: String,
pub agents: Vec<RegistryEntry>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RegistryEntry {
pub id: String,
pub name: String,
pub version: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub repository: Option<String>,
#[serde(default)]
pub website: Option<String>,
#[serde(default)]
pub icon: Option<String>,
pub distribution: RegistryDistribution,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RegistryDistribution {
#[serde(default)]
pub binary: Option<HashMap<String, RegistryBinaryTarget>>,
#[serde(default)]
pub npx: Option<RegistryNpxDistribution>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RegistryBinaryTarget {
pub archive: String,
pub cmd: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: HashMap<String, String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RegistryNpxDistribution {
pub package: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: HashMap<String, String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AgentMeta {
pub cmd: String,
pub args: Vec<String>,
pub env: HashMap<String, String>,
pub version: String,
pub installed_at: String,
}
fn agents_dir() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".sandcage").join("agents"))
}
fn cache_path() -> Option<PathBuf> {
agents_dir().map(|d| d.join("registry.json"))
}
fn timestamp_path() -> Option<PathBuf> {
agents_dir().map(|d| d.join("registry.json.timestamp"))
}
fn is_cache_fresh() -> bool {
let Some(ts_path) = timestamp_path() else {
return false;
};
let Ok(content) = std::fs::read_to_string(&ts_path) else {
return false;
};
let Ok(ts) = content.trim().parse::<u64>() else {
return false;
};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
now.saturating_sub(ts) < REFRESH_INTERVAL_SECS
}
fn write_timestamp() {
if let Some(ts_path) = timestamp_path() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
std::fs::write(&ts_path, now.to_string()).ok();
}
}
pub async fn fetch_registry(force_refresh: bool) -> std::result::Result<RegistryIndex, String> {
let cache = cache_path().ok_or("cannot determine home directory")?;
if !force_refresh && is_cache_fresh() {
if let Ok(data) = std::fs::read_to_string(&cache) {
if let Ok(index) = serde_json::from_str::<RegistryIndex>(&data) {
return Ok(index);
}
}
}
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(FETCH_TIMEOUT_SECS))
.build()
.map_err(|e| format!("HTTP client error: {e}"))?;
let resp = client
.get(REGISTRY_URL)
.send()
.await
.map_err(|e| format!("Failed to fetch registry: {e}"))?;
let body = resp
.text()
.await
.map_err(|e| format!("Failed to read registry response: {e}"))?;
let index: RegistryIndex =
serde_json::from_str(&body).map_err(|e| format!("Failed to parse registry JSON: {e}"))?;
if let Some(dir) = agents_dir() {
std::fs::create_dir_all(&dir).ok();
}
if let Some(ref path) = cache_path() {
std::fs::write(path, &body).ok();
}
write_timestamp();
Ok(index)
}
pub fn find_agent<'a>(index: &'a RegistryIndex, agent_id: &str) -> Option<&'a RegistryEntry> {
index.agents.iter().find(|a| a.id == agent_id)
}
pub fn platform_key() -> &'static str {
if cfg!(target_arch = "x86_64") {
"linux-x86_64"
} else if cfg!(target_arch = "aarch64") {
"linux-aarch64"
} else {
"linux-x86_64"
}
}
pub fn agent_install_dir(agent_id: &str, version: &str) -> Option<PathBuf> {
let sanitized_id = agent_id.replace(|c: char| !c.is_alphanumeric() && c != '-' && c != '_', "-");
agents_dir().map(|d| d.join(&sanitized_id).join(version))
}
pub fn is_agent_installed(agent_id: &str, version: &str) -> bool {
agent_install_dir(agent_id, version)
.map(|d| d.exists())
.unwrap_or(false)
}
pub async fn install_agent(
entry: &RegistryEntry,
target: &RegistryBinaryTarget,
) -> std::result::Result<PathBuf, String> {
let install_dir = agent_install_dir(&entry.id, &entry.version)
.ok_or("cannot determine install directory")?;
if install_dir.exists() {
return Ok(install_dir);
}
eprintln!("sandcage: downloading {} v{}...", entry.name, entry.version);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(300))
.build()
.map_err(|e| format!("HTTP client error: {e}"))?;
let resp = client
.get(&target.archive)
.send()
.await
.map_err(|e| format!("Failed to download agent: {e}"))?;
let bytes = resp
.bytes()
.await
.map_err(|e| format!("Failed to read agent archive: {e}"))?;
let parent = install_dir.parent().ok_or("invalid install path")?;
std::fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {e}"))?;
let url_lower = target.archive.to_lowercase();
if url_lower.ends_with(".tar.gz") || url_lower.ends_with(".tgz") {
let decoder = flate2::read::GzDecoder::new(&bytes[..]);
let mut archive = tar::Archive::new(decoder);
archive
.unpack(&install_dir)
.map_err(|e| format!("Failed to extract tar.gz: {e}"))?;
} else if url_lower.ends_with(".tar.bz2") || url_lower.ends_with(".tbz2") {
return Err("tar.bz2 extraction not yet supported".to_string());
} else if url_lower.ends_with(".zip") {
return Err("zip extraction not yet supported — use tar.gz agents".to_string());
} else {
return Err(format!("Unknown archive format: {}", target.archive));
}
let meta = AgentMeta {
cmd: target.cmd.clone(),
args: target.args.clone(),
env: target.env.clone(),
version: entry.version.clone(),
installed_at: chrono_free_now(),
};
let meta_path = install_dir.join(".sandcage-meta.json");
let meta_json = serde_json::to_string_pretty(&meta)
.map_err(|e| format!("Failed to serialize meta: {e}"))?;
std::fs::write(&meta_path, meta_json)
.map_err(|e| format!("Failed to write meta: {e}"))?;
cleanup_old_versions(&entry.id, &entry.version);
eprintln!("sandcage: installed {} v{}", entry.name, entry.version);
Ok(install_dir)
}
fn chrono_free_now() -> String {
let dur = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
format!("{}", dur.as_secs())
}
fn cleanup_old_versions(agent_id: &str, current_version: &str) {
let sanitized_id = agent_id.replace(|c: char| !c.is_alphanumeric() && c != '-' && c != '_', "-");
let Some(base) = agents_dir().map(|d| d.join(&sanitized_id)) else {
return;
};
let Ok(entries) = std::fs::read_dir(&base) else {
return;
};
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str != current_version && entry.path().is_dir() && name_str != ".sandcage-meta.json" {
std::fs::remove_dir_all(entry.path()).ok();
}
}
}
pub fn resolve_agent_command(
install_dir: &Path,
target: &RegistryBinaryTarget,
) -> std::result::Result<(PathBuf, Vec<String>, HashMap<String, String>), String> {
let cmd = target.cmd.strip_prefix("./").unwrap_or(&target.cmd);
if cmd.contains("..") {
return Err("path traversal in agent cmd is not allowed".to_string());
}
let bin_path = install_dir.join(cmd);
if !bin_path.exists() {
return Err(format!(
"agent binary not found at {}",
bin_path.display()
));
}
Ok((bin_path, target.args.clone(), target.env.clone()))
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_registry_json() -> &'static str {
r#"{
"version": "1",
"agents": [
{
"id": "test-agent",
"name": "Test Agent",
"version": "1.0.0",
"description": "A test agent",
"distribution": {
"binary": {
"linux-x86_64": {
"archive": "https://example.com/test-agent-linux-x86_64.tar.gz",
"cmd": "./test-agent",
"args": ["--acp"],
"env": {"FOO": "bar"}
}
}
}
},
{
"id": "npx-only-agent",
"name": "NPX Agent",
"version": "2.0.0",
"description": "NPX only",
"distribution": {
"npx": {
"package": "@test/agent@2.0.0",
"args": ["--stdio"],
"env": {}
}
}
}
]
}"#
}
#[test]
fn parse_registry_index() {
let index: RegistryIndex =
serde_json::from_str(sample_registry_json()).expect("parse registry");
assert_eq!(index.agents.len(), 2);
assert_eq!(index.agents[0].id, "test-agent");
assert_eq!(index.agents[1].id, "npx-only-agent");
}
#[test]
fn find_agent_by_id() {
let index: RegistryIndex =
serde_json::from_str(sample_registry_json()).expect("parse");
assert!(find_agent(&index, "test-agent").is_some());
assert!(find_agent(&index, "nonexistent").is_none());
}
#[test]
fn binary_target_has_correct_fields() {
let index: RegistryIndex =
serde_json::from_str(sample_registry_json()).expect("parse");
let agent = find_agent(&index, "test-agent").unwrap();
let target = agent
.distribution
.binary
.as_ref()
.unwrap()
.get("linux-x86_64")
.unwrap();
assert_eq!(target.cmd, "./test-agent");
assert_eq!(target.args, vec!["--acp"]);
assert_eq!(target.env.get("FOO"), Some(&"bar".to_string()));
}
#[test]
fn npx_agent_has_package() {
let index: RegistryIndex =
serde_json::from_str(sample_registry_json()).expect("parse");
let agent = find_agent(&index, "npx-only-agent").unwrap();
let npx = agent.distribution.npx.as_ref().unwrap();
assert_eq!(npx.package, "@test/agent@2.0.0");
}
#[test]
fn resolve_command_strips_dot_slash() {
let tmp = tempfile::tempdir().unwrap();
let bin = tmp.path().join("test-agent");
std::fs::write(&bin, "#!/bin/sh\necho ok").unwrap();
let target = RegistryBinaryTarget {
archive: String::new(),
cmd: "./test-agent".to_string(),
args: vec!["--acp".to_string()],
env: HashMap::new(),
};
let (path, args, _env) = resolve_agent_command(tmp.path(), &target).unwrap();
assert_eq!(path, bin);
assert_eq!(args, vec!["--acp"]);
}
#[test]
fn resolve_command_rejects_path_traversal() {
let tmp = tempfile::tempdir().unwrap();
let target = RegistryBinaryTarget {
archive: String::new(),
cmd: "../../../etc/passwd".to_string(),
args: vec![],
env: HashMap::new(),
};
let result = resolve_agent_command(tmp.path(), &target);
assert!(result.is_err());
assert!(result.unwrap_err().contains("path traversal"));
}
#[test]
fn resolve_command_fails_for_missing_binary() {
let tmp = tempfile::tempdir().unwrap();
let target = RegistryBinaryTarget {
archive: String::new(),
cmd: "./nonexistent".to_string(),
args: vec![],
env: HashMap::new(),
};
let result = resolve_agent_command(tmp.path(), &target);
assert!(result.is_err());
assert!(result.unwrap_err().contains("not found"));
}
#[test]
fn agent_install_dir_sanitizes_id() {
let dir = agent_install_dir("my@agent/test", "1.0.0").unwrap();
let dir_name = dir.parent().unwrap().file_name().unwrap().to_string_lossy();
assert!(!dir_name.contains('@'));
assert!(!dir_name.contains('/'));
}
#[test]
fn parse_registry_with_missing_optional_fields() {
let json = r#"{
"version": "1",
"agents": [{
"id": "minimal",
"name": "Minimal",
"version": "0.1.0",
"description": "",
"distribution": {
"binary": {
"linux-x86_64": {
"archive": "https://example.com/a.tar.gz",
"cmd": "./agent"
}
}
}
}]
}"#;
let index: RegistryIndex = serde_json::from_str(json).expect("parse");
let agent = &index.agents[0];
assert!(agent.repository.is_none());
assert!(agent.website.is_none());
assert!(agent.icon.is_none());
let target = agent.distribution.binary.as_ref().unwrap().get("linux-x86_64").unwrap();
assert!(target.args.is_empty());
assert!(target.env.is_empty());
}
}
+728
View File
@@ -0,0 +1,728 @@
use std::collections::HashMap;
use async_trait::async_trait;
use bollard::container::LogOutput;
use bollard::models::{ContainerCreateBody, HostConfig, Mount, MountType};
use bollard::Docker;
use crossterm::terminal;
use futures_util::StreamExt;
use tokio::io::{self, AsyncReadExt, AsyncWriteExt};
use crate::config::SandcageConfig;
use crate::docker::{self, DockerError};
use crate::service::registry::ServiceRegistry;
use crate::service::{ComposeContext, ComposeServiceDef, Service};
use super::Result;
/// RAII guard that restores terminal from raw mode on drop.
struct RawModeGuard;
impl RawModeGuard {
fn enter() -> std::result::Result<Self, std::io::Error> {
terminal::enable_raw_mode()?;
Ok(Self)
}
}
impl Drop for RawModeGuard {
fn drop(&mut self) {
terminal::disable_raw_mode().ok();
}
}
pub struct BollardBackend {
docker: Docker,
runtime_info: tokio::sync::OnceCell<super::runtime::RuntimeInfo>,
}
impl BollardBackend {
pub fn try_new() -> super::Result<Self> {
let docker = Self::connect_docker().map_err(|e| {
DockerError::SpawnFailed(std::io::Error::new(
std::io::ErrorKind::ConnectionRefused,
format!(
"Cannot connect to Docker daemon: {e}. \
Check that Docker is running and $DOCKER_HOST is set correctly."
),
))
})?;
Ok(Self {
docker,
runtime_info: tokio::sync::OnceCell::new(),
})
}
fn connect_docker() -> std::result::Result<Docker, bollard::errors::Error> {
if std::env::var("DOCKER_HOST").is_ok() {
return Docker::connect_with_local_defaults();
}
if let Some(socket) = Self::socket_from_docker_context() {
eprintln!("sandcage: using docker socket from context → {socket}");
return Docker::connect_with_socket(&socket, 120, bollard::API_DEFAULT_VERSION);
}
Docker::connect_with_local_defaults()
}
fn socket_from_docker_context() -> Option<String> {
let output = std::process::Command::new("docker")
.args(["context", "inspect", "--format", "{{.Endpoints.docker.Host}}"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let host = String::from_utf8(output.stdout).ok()?.trim().to_string();
let path = host.strip_prefix("unix://")?;
if std::path::Path::new(path).exists() {
Some(path.to_string())
} else {
None
}
}
}
#[async_trait]
impl super::ContainerBackend for BollardBackend {
// Bollard-native interactive TTY with crossterm raw mode.
// Falls back to compose if raw mode is unavailable (e.g. MinTTY/Git Bash).
async fn run_interactive(
&self,
service: &dyn Service,
ctx: &ComposeContext,
config: &SandcageConfig,
registry: &ServiceRegistry,
shell_override: bool,
extra_args: &[String],
) -> Result<i32> {
if !self.can_use_bollard_tty() {
let compose = super::compose::ComposeBackend;
return compose
.run_interactive(service, ctx, config, registry, shell_override, extra_args)
.await;
}
let mut compose_def = service.compose_service(ctx);
if shell_override {
compose_def.entrypoint = vec!["/bin/zsh".to_string()];
}
self.run_interactive_tty(&compose_def, config, &ctx.config_root, extra_args, ctx.forwarding_socket.as_deref())
.await
}
async fn run_acp_relay(
&self,
service: &dyn Service,
ctx: &ComposeContext,
config: &SandcageConfig,
extra_args: &[String],
) -> Result<i32> {
let compose_def = service.compose_service(ctx);
self.acp_relay(&compose_def, config, &ctx.config_root, extra_args).await
}
async fn image_exists(&self, image: &str) -> Result<bool> {
Ok(self.docker.inspect_image(image).await.is_ok())
}
async fn volume_exists(&self, name: &str) -> Result<bool> {
Ok(self.docker.inspect_volume(name).await.is_ok())
}
async fn check_availability(&self) -> Result<()> {
self.docker.ping().await.map_err(|e| {
DockerError::SpawnFailed(std::io::Error::new(
std::io::ErrorKind::ConnectionRefused,
format!("Cannot connect to Docker: {e}"),
))
})?;
Ok(())
}
async fn runtime_info(&self) -> Result<super::runtime::RuntimeInfo> {
let info = self
.runtime_info
.get_or_init(|| super::runtime::detect_runtime(&self.docker))
.await;
Ok(info.clone())
}
}
impl BollardBackend {
fn build_container_config(
&self,
compose_def: &ComposeServiceDef,
config: &SandcageConfig,
config_root: &std::path::Path,
extra_args: &[String],
interactive: bool,
forwarding_socket: Option<&std::path::Path>,
security_opts: &[String],
) -> ContainerCreateBody {
let mut env: Vec<String> = compose_def.environment.clone();
if let Some(ref env_map) = config.env {
for (key, value) in env_map {
env.push(format!("{key}={value}"));
}
}
let mut binds: Vec<String> = compose_def.volumes.clone();
if let Some(ref mount_list) = config.mounts {
for mount in mount_list {
if mount.contains(':') {
binds.push(docker::expand_mount_path(mount));
}
}
}
let mut mounts: Vec<Mount> = Vec::new();
match config.ssh_mode.as_deref() {
Some("volume") => {
mounts.push(Mount {
target: Some("/home/agent/.ssh".to_string()),
source: Some("sandcage-ssh".to_string()),
typ: Some(MountType::VOLUME),
read_only: Some(true),
..Default::default()
});
}
Some("bind") => {
let mount = docker::expand_mount_path("~/.ssh:/home/agent/.ssh:ro");
binds.push(mount);
}
_ => {}
}
// Overlay mounts from .sandcage/overlay/ and .sandcage/local/overlay/
let overlay_enabled = config.overlay.as_ref()
.and_then(|o| o.enabled)
.unwrap_or(true);
if overlay_enabled {
for mount in docker::collect_overlay_mounts(config_root, &compose_def.working_dir) {
binds.push(mount);
}
}
// Shadow mounts (volume + directory as -v binds; tmpfs folded into tmpfs_map below).
let shadow_mounts =
docker::collect_shadow_mounts(config, config_root, &compose_def.working_dir);
binds.extend(crate::shadow::shadow_bind_strings(&shadow_mounts));
if let Some(spoof) = docker::collect_spoof_mount(config_root) {
binds.push(spoof);
}
// Forwarding socket mount
if let Some(ref sock) = forwarding_socket {
binds.push(format!(
"{}:{}",
sock.display(),
crate::forwarding::CONTAINER_SOCKET_PATH
));
}
let cmd: Option<Vec<String>> = if extra_args.is_empty() {
None
} else {
Some(extra_args.to_vec())
};
let mut labels = HashMap::new();
labels.insert("sandcage.managed".to_string(), "true".to_string());
labels.insert(
"sandcage.mode".to_string(),
if interactive { "interactive" } else { "acp" }.to_string(),
);
// Build tmpfs map for .sandcage/ exclusion plus any tmpfs-backed shadow dirs.
let mut tmpfs_paths: Vec<String> = compose_def.tmpfs.clone();
tmpfs_paths.extend(crate::shadow::shadow_tmpfs_paths(&shadow_mounts));
let tmpfs_map: Option<HashMap<String, String>> = if tmpfs_paths.is_empty() {
None
} else {
Some(
tmpfs_paths
.into_iter()
.map(|p| (p, String::new()))
.collect(),
)
};
ContainerCreateBody {
image: Some(compose_def.image.clone()),
entrypoint: Some(compose_def.entrypoint.clone()),
cmd,
working_dir: Some(compose_def.working_dir.clone()),
user: Some(compose_def.user.clone()),
env: Some(env),
labels: Some(labels),
tty: Some(interactive),
open_stdin: Some(true),
attach_stdin: Some(true),
attach_stdout: Some(true),
attach_stderr: Some(true),
stdin_once: Some(false),
host_config: Some(HostConfig {
binds: Some(binds),
mounts: if mounts.is_empty() { None } else { Some(mounts) },
tmpfs: tmpfs_map,
auto_remove: Some(true),
security_opt: if security_opts.is_empty() {
None
} else {
Some(security_opts.to_vec())
},
..Default::default()
}),
..Default::default()
}
}
/// Check whether bollard-native TTY is usable in the current terminal.
/// On Windows, MinTTY (Git Bash) uses MSYS PTY pipes instead of a real
/// Windows console handle, so `GetConsoleMode` on stdin fails.
fn can_use_bollard_tty(&self) -> bool {
#[cfg(windows)]
{
// MinTTY (Git Bash / MSYS2) provides a Windows console handle, so
// GetConsoleMode and crossterm raw mode both succeed — but MinTTY's
// terminal emulator can't render ANSI output in raw console mode,
// producing garbled escape sequences. Detect MSYS2/MinGW by MSYSTEM
// env var and fall back to compose, which lets MinTTY handle the PTY.
if std::env::var_os("MSYSTEM").is_some() {
eprintln!("sandcage: tty probe → MSYS2/MinGW detected (MSYSTEM set) — using compose");
return false;
}
use std::os::windows::io::AsRawHandle;
unsafe extern "system" {
fn GetConsoleMode(handle: *mut std::ffi::c_void, mode: *mut u32) -> i32;
}
let handle = std::io::stdin().as_raw_handle();
let mut mode: u32 = 0;
if unsafe { GetConsoleMode(handle as *mut _, &mut mode) } == 0 {
eprintln!("sandcage: tty probe → stdin is not a Windows console handle — using compose");
return false;
}
eprintln!("sandcage: tty probe → Windows console handle OK (mode=0x{mode:x})");
}
match terminal::enable_raw_mode() {
Ok(()) => {
terminal::disable_raw_mode().ok();
eprintln!("sandcage: tty probe → crossterm raw mode OK — using bollard TTY");
true
}
Err(e) => {
eprintln!("sandcage: tty probe → crossterm raw mode failed ({e}) — using compose");
false
}
}
}
/// Bollard-native interactive session with crossterm raw mode and resize handling.
async fn run_interactive_tty(
&self,
compose_def: &ComposeServiceDef,
config: &SandcageConfig,
config_root: &std::path::Path,
extra_args: &[String],
forwarding_socket: Option<&std::path::Path>,
) -> Result<i32> {
self.cleanup_orphans().await;
// Verify image exists
let image = &compose_def.image;
self.docker.inspect_image(image).await.map_err(|_| {
DockerError::ImageNotFound {
image: image.clone(),
}
})?;
// Create and start container with TTY
let security_opts = docker::resolve_security_opts(config, config_root);
let container_config =
self.build_container_config(compose_def, config, config_root, extra_args, true, forwarding_socket, &security_opts);
let container = self
.docker
.create_container(
None::<bollard::query_parameters::CreateContainerOptions>,
container_config,
)
.await
.map_err(|e| {
DockerError::SpawnFailed(std::io::Error::new(
std::io::ErrorKind::Other,
format!("Failed to create container: {e}"),
))
})?;
let id = container.id.clone();
eprintln!("sandcage: container created → {}", &id[..12.min(id.len())]);
self.docker
.start_container(
&id,
None::<bollard::query_parameters::StartContainerOptions>,
)
.await
.map_err(|e| {
DockerError::SpawnFailed(std::io::Error::new(
std::io::ErrorKind::Other,
format!("Failed to start container: {e}"),
))
})?;
eprintln!("sandcage: container started");
// Initial terminal resize
if let Ok((w, h)) = terminal::size() {
eprintln!("sandcage: terminal size {w}x{h} — resizing container TTY");
self.docker
.resize_container_tty(
&id,
bollard::query_parameters::ResizeContainerTTYOptionsBuilder::default()
.w(w as i32)
.h(h as i32)
.build(),
)
.await
.ok();
}
// Enter raw mode (guard restores on drop, including panics)
let _guard = RawModeGuard::enter().map_err(|e| {
DockerError::SpawnFailed(std::io::Error::new(
std::io::ErrorKind::Other,
format!("Failed to enter raw mode: {e}"),
))
})?;
eprintln!("sandcage: raw mode entered");
// Attach to container
let attach_options = bollard::query_parameters::AttachContainerOptionsBuilder::default()
.stdin(true)
.stdout(true)
.stderr(true)
.stream(true)
.build();
let bollard::container::AttachContainerResults { mut output, mut input } = self
.docker
.attach_container(&id, Some(attach_options))
.await
.map_err(|e| {
DockerError::SpawnFailed(std::io::Error::new(
std::io::ErrorKind::Other,
format!("Failed to attach to container: {e}"),
))
})?;
eprintln!("sandcage: attached — spawning relay tasks");
// Container wait task
let docker_wait = self.docker.clone();
let id_wait = id.clone();
let wait_handle = tokio::spawn(async move {
let opts = bollard::query_parameters::WaitContainerOptionsBuilder::default()
.condition("not-running")
.build();
let mut stream = docker_wait.wait_container(&id_wait, Some(opts));
if let Some(Ok(resp)) = stream.next().await {
resp.status_code
} else {
-1
}
});
// Stdin relay task
let stdin_task = tokio::spawn(async move {
let mut host_stdin = io::stdin();
let mut buf = vec![0u8; 8192];
let mut total_bytes: usize = 0;
loop {
match host_stdin.read(&mut buf).await {
Ok(0) => {
eprintln!("sandcage: stdin → EOF after {total_bytes} bytes");
input.shutdown().await.ok();
break;
}
Ok(n) => {
total_bytes += n;
if input.write_all(&buf[..n]).await.is_err() {
eprintln!("sandcage: stdin → write to container failed after {total_bytes} bytes");
break;
}
if input.flush().await.is_err() {
eprintln!("sandcage: stdin → flush to container failed after {total_bytes} bytes");
break;
}
}
Err(e) => {
eprintln!("sandcage: stdin → read error: {e}");
break;
}
}
}
});
// Output relay — TTY mode sends Console variant, non-TTY sends StdOut/StdErr
let output_task = tokio::spawn(async move {
let mut host_stdout = io::stdout();
let mut host_stderr = io::stderr();
let mut chunk_count: usize = 0;
let mut total_bytes: usize = 0;
while let Some(result) = output.next().await {
match result {
Ok(LogOutput::Console { message })
| Ok(LogOutput::StdOut { message }) => {
chunk_count += 1;
total_bytes += message.len();
host_stdout.write_all(&message).await.ok();
host_stdout.flush().await.ok();
}
Ok(LogOutput::StdErr { message }) => {
chunk_count += 1;
total_bytes += message.len();
host_stderr.write_all(&message).await.ok();
host_stderr.flush().await.ok();
}
Ok(other) => {
eprintln!("sandcage: output → unexpected variant: {other:?}");
}
Err(e) => {
eprintln!("sandcage: output → stream error: {e}");
break;
}
}
}
eprintln!("sandcage: output → stream ended ({chunk_count} chunks, {total_bytes} bytes)");
});
// Resize poller — checks terminal size every 250ms
let docker_resize = self.docker.clone();
let id_resize = id.clone();
let resize_task = tokio::spawn(async move {
let mut last_size = terminal::size().unwrap_or((80, 24));
loop {
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
if let Ok(current) = terminal::size() {
if current != last_size {
last_size = current;
docker_resize
.resize_container_tty(
&id_resize,
bollard::query_parameters::ResizeContainerTTYOptionsBuilder::default()
.w(current.0 as i32)
.h(current.1 as i32)
.build(),
)
.await
.ok();
}
}
}
});
// Signal handler — Ctrl+C stops the container, lets wait_handle return naturally
let docker_signal = self.docker.clone();
let id_signal = id.clone();
let signal_task = tokio::spawn(async move {
tokio::signal::ctrl_c().await.ok();
eprintln!("sandcage: received interrupt, stopping container...");
let opts = bollard::query_parameters::StopContainerOptionsBuilder::default()
.t(2)
.build();
docker_signal
.stop_container(&id_signal, Some(opts))
.await
.ok();
});
// Wait for output to finish (container exited), then clean up tasks
let _ = output_task.await;
stdin_task.abort();
signal_task.abort();
resize_task.abort();
let exit_code = wait_handle.await.unwrap_or(-1);
// _guard drops here, restoring terminal from raw mode
Ok(exit_code as i32)
}
async fn acp_relay(
&self,
compose_def: &ComposeServiceDef,
config: &SandcageConfig,
config_root: &std::path::Path,
extra_args: &[String],
) -> Result<i32> {
self.run_container(compose_def, config, config_root, extra_args, false).await
}
async fn run_container(
&self,
compose_def: &ComposeServiceDef,
config: &SandcageConfig,
config_root: &std::path::Path,
extra_args: &[String],
interactive: bool,
) -> Result<i32> {
self.cleanup_orphans().await;
let image = &compose_def.image;
self.docker.inspect_image(image).await.map_err(|_| {
DockerError::ImageNotFound {
image: image.clone(),
}
})?;
let container_config =
self.build_container_config(compose_def, config, config_root, extra_args, interactive, None, &[]);
let container = self
.docker
.create_container(
None::<bollard::query_parameters::CreateContainerOptions>,
container_config,
)
.await
.map_err(|e| {
DockerError::SpawnFailed(std::io::Error::new(
std::io::ErrorKind::Other,
format!("Failed to create container: {e}"),
))
})?;
let id = container.id.clone();
let docker_signal = self.docker.clone();
let id_signal = id.clone();
let signal_task = tokio::spawn(async move {
tokio::signal::ctrl_c().await.ok();
eprintln!("sandcage: received interrupt, stopping container...");
let opts = bollard::query_parameters::RemoveContainerOptionsBuilder::default()
.force(true)
.build();
docker_signal
.remove_container(&id_signal, Some(opts))
.await
.ok();
std::process::exit(130);
});
self.docker
.start_container(
&id,
None::<bollard::query_parameters::StartContainerOptions>,
)
.await
.map_err(|e| {
DockerError::SpawnFailed(std::io::Error::new(
std::io::ErrorKind::Other,
format!("Failed to start container: {e}"),
))
})?;
let attach_options = bollard::query_parameters::AttachContainerOptionsBuilder::default()
.stdin(true)
.stdout(true)
.stderr(true)
.stream(true)
.build();
let bollard::container::AttachContainerResults { mut output, mut input } = self
.docker
.attach_container(&id, Some(attach_options))
.await
.map_err(|e| {
DockerError::SpawnFailed(std::io::Error::new(
std::io::ErrorKind::Other,
format!("Failed to attach to container: {e}"),
))
})?;
let docker_wait = self.docker.clone();
let id_wait = id.clone();
let wait_handle = tokio::spawn(async move {
let opts = bollard::query_parameters::WaitContainerOptionsBuilder::default()
.condition("not-running")
.build();
let mut stream = docker_wait.wait_container(&id_wait, Some(opts));
if let Some(Ok(resp)) = stream.next().await {
resp.status_code
} else {
-1
}
});
let stdin_task = tokio::spawn(async move {
let mut host_stdin = io::stdin();
let mut buf = vec![0u8; 8192];
loop {
match host_stdin.read(&mut buf).await {
Ok(0) => {
input.shutdown().await.ok();
break;
}
Ok(n) => {
if input.write_all(&buf[..n]).await.is_err() {
break;
}
if input.flush().await.is_err() {
break;
}
}
Err(_) => break,
}
}
});
let output_task = tokio::spawn(async move {
let mut host_stdout = io::stdout();
let mut host_stderr = io::stderr();
while let Some(result) = output.next().await {
match result {
Ok(LogOutput::StdOut { message }) => {
host_stdout.write_all(&message).await.ok();
host_stdout.flush().await.ok();
}
Ok(LogOutput::StdErr { message }) => {
host_stderr.write_all(&message).await.ok();
host_stderr.flush().await.ok();
}
Ok(_) => {}
Err(e) => {
eprintln!("sandcage: container stream error: {e}");
break;
}
}
}
});
let _ = output_task.await;
stdin_task.abort();
signal_task.abort();
let exit_code = wait_handle.await.unwrap_or(-1);
Ok(exit_code as i32)
}
async fn cleanup_orphans(&self) {
let mut filters = HashMap::new();
filters.insert("label", vec!["sandcage.managed=true"]);
let opts = bollard::query_parameters::ListContainersOptionsBuilder::default()
.all(true)
.filters(&filters)
.build();
if let Ok(containers) = self.docker.list_containers(Some(opts)).await {
for container in containers {
if let Some(id) = container.id {
eprintln!("sandcage: cleaning up orphaned container {}", &id[..12.min(id.len())]);
let rm_opts = bollard::query_parameters::RemoveContainerOptionsBuilder::default()
.force(true)
.build();
self.docker.remove_container(&id, Some(rm_opts)).await.ok();
}
}
}
}
}
+112
View File
@@ -0,0 +1,112 @@
use async_trait::async_trait;
use tokio::process::Command;
use crate::config::SandcageConfig;
use crate::docker::{self, DockerError};
use crate::service::registry::ServiceRegistry;
use crate::service::{ComposeContext, Service};
use super::Result;
pub struct ComposeBackend;
#[async_trait]
impl super::ContainerBackend for ComposeBackend {
async fn run_interactive(
&self,
service: &dyn Service,
ctx: &ComposeContext,
config: &SandcageConfig,
registry: &ServiceRegistry,
shell_override: bool,
extra_args: &[String],
) -> Result<i32> {
let docker_path = docker::require_docker()?;
docker::require_compose(&docker_path).await?;
let image_tag = format!("{}:latest", ctx.image);
docker::require_image(&docker_path, &image_tag).await?;
if config.ssh_mode.as_deref() == Some("volume") {
let vol_check = Command::new(&docker_path)
.args(["volume", "inspect", "sandcage-ssh"])
.output()
.await;
if !vol_check.map(|o| o.status.success()).unwrap_or(false) {
eprintln!(
"sandcage: SSH volume not found — run 'sandcage setup ssh --refresh' to populate it"
);
}
}
let compose_content = crate::service::compose::generate_compose(registry, ctx);
let compose_file = docker::write_compose_tempfile(&compose_content)?;
let compose_path = compose_file.path().to_string_lossy().into_owned();
let security_opts = docker::resolve_security_opts(config, &ctx.config_root);
let run_args = docker::build_run_args(
service.name(),
&compose_path,
config,
&ctx.config_root,
&ctx.container_dir,
shell_override,
extra_args,
ctx.forwarding_socket.as_deref(),
&security_opts,
);
let mut cmd = Command::new(&docker_path);
cmd.args(&run_args);
cmd.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit());
let status = cmd.status().await.map_err(DockerError::SpawnFailed)?;
Ok(status.code().unwrap_or(-1))
}
async fn run_acp_relay(
&self,
_service: &dyn Service,
_ctx: &ComposeContext,
_config: &SandcageConfig,
_extra_args: &[String],
) -> Result<i32> {
Err(DockerError::AcpRequiresBollard)
}
async fn image_exists(&self, image: &str) -> Result<bool> {
let docker_path = docker::require_docker()?;
let status = Command::new(&docker_path)
.args(["image", "inspect", image])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.await
.map_err(DockerError::SpawnFailed)?;
Ok(status.success())
}
async fn volume_exists(&self, name: &str) -> Result<bool> {
let docker_path = docker::require_docker()?;
let status = Command::new(&docker_path)
.args(["volume", "inspect", name])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.await
.map_err(DockerError::SpawnFailed)?;
Ok(status.success())
}
async fn check_availability(&self) -> Result<()> {
let docker_path = docker::require_docker()?;
docker::require_compose(&docker_path).await?;
Ok(())
}
async fn runtime_info(&self) -> Result<super::runtime::RuntimeInfo> {
Ok(super::runtime::RuntimeInfo::default())
}
}
+57
View File
@@ -0,0 +1,57 @@
pub mod compose;
pub mod runtime;
#[cfg(feature = "bollard")]
pub mod bollard;
#[cfg(feature = "bollard")]
pub mod network;
#[cfg(feature = "bollard")]
pub mod orchestrator;
use async_trait::async_trait;
use crate::config::SandcageConfig;
use crate::docker::DockerError;
use crate::service::{ComposeContext, Service};
pub type Result<T> = std::result::Result<T, DockerError>;
#[async_trait]
pub trait ContainerBackend: Send + Sync {
async fn run_interactive(
&self,
service: &dyn Service,
ctx: &ComposeContext,
config: &SandcageConfig,
registry: &crate::service::registry::ServiceRegistry,
shell_override: bool,
extra_args: &[String],
) -> Result<i32>;
async fn run_acp_relay(
&self,
service: &dyn Service,
ctx: &ComposeContext,
config: &SandcageConfig,
extra_args: &[String],
) -> Result<i32>;
async fn image_exists(&self, image: &str) -> Result<bool>;
async fn volume_exists(&self, name: &str) -> Result<bool>;
async fn check_availability(&self) -> Result<()>;
async fn runtime_info(&self) -> Result<runtime::RuntimeInfo>;
}
pub fn default_backend() -> Result<Box<dyn ContainerBackend>> {
#[cfg(feature = "bollard")]
{
Ok(Box::new(bollard::BollardBackend::try_new()?))
}
#[cfg(not(feature = "bollard"))]
{
Ok(Box::new(compose::ComposeBackend))
}
}
+103
View File
@@ -0,0 +1,103 @@
use std::collections::HashMap;
use std::path::Path;
use bollard::Docker;
use sha2::{Digest, Sha256};
use crate::docker::DockerError;
/// Generate a deterministic network name from the workspace path.
pub fn network_name(workspace: &Path) -> String {
let mut hasher = Sha256::new();
hasher.update(workspace.to_string_lossy().as_bytes());
let hash = hex::encode(hasher.finalize());
format!("sandcage-{}", &hash[..12])
}
/// Create or reuse a project-scoped Docker network. Returns the network name.
pub async fn ensure_network(
docker: &Docker,
workspace: &Path,
) -> Result<String, DockerError> {
let name = network_name(workspace);
// Check if it already exists
if docker.inspect_network(&name, None::<bollard::query_parameters::InspectNetworkOptions>).await.is_ok() {
return Ok(name);
}
// Create it
let mut labels = HashMap::new();
labels.insert("sandcage.managed".to_string(), "true".to_string());
let config = bollard::models::NetworkCreateRequest {
name: name.clone(),
driver: Some("bridge".to_string()),
labels: Some(labels),
..Default::default()
};
docker.create_network(config).await.map_err(|e| {
DockerError::SpawnFailed(std::io::Error::new(
std::io::ErrorKind::Other,
format!("Failed to create network {name}: {e}"),
))
})?;
Ok(name)
}
/// Remove a project network. Only removes if it has the sandcage.managed label.
pub async fn cleanup_network(
docker: &Docker,
network_name: &str,
) -> Result<(), DockerError> {
// Verify it's ours before removing
match docker
.inspect_network(network_name, None::<bollard::query_parameters::InspectNetworkOptions>)
.await
{
Ok(info) => {
let is_ours = info
.labels
.as_ref()
.map(|l| l.get("sandcage.managed").map(|v| v == "true").unwrap_or(false))
.unwrap_or(false);
if !is_ours {
return Ok(()); // Not our network, don't touch it
}
}
Err(_) => return Ok(()), // Already gone
}
docker.remove_network(network_name).await.map_err(|e| {
DockerError::SpawnFailed(std::io::Error::new(
std::io::ErrorKind::Other,
format!("Failed to remove network {network_name}: {e}"),
))
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn network_name_is_deterministic() {
let p = Path::new("/home/user/projects/my-app");
let name1 = network_name(p);
let name2 = network_name(p);
assert_eq!(name1, name2);
assert!(name1.starts_with("sandcage-"));
assert_eq!(name1.len(), "sandcage-".len() + 12);
}
#[test]
fn different_paths_produce_different_names() {
let a = network_name(Path::new("/a"));
let b = network_name(Path::new("/b"));
assert_ne!(a, b);
}
}
+148
View File
@@ -0,0 +1,148 @@
use std::path::Path;
use crate::service::ComposeServiceDef;
use super::network;
/// A plan for running one or more containers on a shared network.
#[derive(Debug)]
pub struct OrchestrationPlan {
pub network_name: String,
pub services: Vec<ServiceInstance>,
/// Which service to attach stdin/stdout to.
pub primary: String,
}
/// A single container to be started as part of an orchestration.
#[derive(Debug)]
pub struct ServiceInstance {
pub name: String,
pub compose_def: ComposeServiceDef,
pub depends_on: Vec<String>,
}
impl OrchestrationPlan {
/// Create a simple plan for a single service (no network needed).
pub fn single(name: &str, compose_def: ComposeServiceDef) -> Self {
Self {
network_name: String::new(),
services: vec![ServiceInstance {
name: name.to_string(),
compose_def,
depends_on: vec![],
}],
primary: name.to_string(),
}
}
/// Create a plan with multiple services on a shared network.
pub fn multi(
workspace: &Path,
services: Vec<ServiceInstance>,
primary: &str,
) -> Self {
Self {
network_name: network::network_name(workspace),
services,
primary: primary.to_string(),
}
}
/// Whether this plan requires a Docker network.
pub fn needs_network(&self) -> bool {
self.services.len() > 1
}
/// Return services in dependency order (simple topological sort).
/// Services with no dependencies come first.
pub fn startup_order(&self) -> Vec<&ServiceInstance> {
let mut result: Vec<&ServiceInstance> = Vec::new();
let mut placed: Vec<&str> = Vec::new();
// Simple iterative approach — works for small service counts
let mut remaining: Vec<&ServiceInstance> = self.services.iter().collect();
while !remaining.is_empty() {
let before = remaining.len();
remaining.retain(|s| {
if s.depends_on.iter().all(|dep| placed.contains(&dep.as_str())) {
result.push(s);
placed.push(&s.name);
false // remove from remaining
} else {
true // keep in remaining
}
});
if remaining.len() == before {
// Circular dependency — just append the rest
result.extend(remaining.drain(..));
break;
}
}
result
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::service::ComposeServiceDef;
fn dummy_def() -> ComposeServiceDef {
ComposeServiceDef {
image: "test:latest".to_string(),
entrypoint: vec!["/bin/sh".to_string()],
working_dir: "/workspace".to_string(),
user: "1000:1000".to_string(),
volumes: vec![],
environment: vec![],
tmpfs: vec![],
tty: true,
stdin_open: true,
}
}
#[test]
fn single_plan_has_one_service() {
let plan = OrchestrationPlan::single("claude", dummy_def());
assert_eq!(plan.services.len(), 1);
assert_eq!(plan.primary, "claude");
assert!(!plan.needs_network());
}
#[test]
fn multi_plan_needs_network() {
let plan = OrchestrationPlan::multi(
Path::new("/workspace"),
vec![
ServiceInstance { name: "db".into(), compose_def: dummy_def(), depends_on: vec![] },
ServiceInstance { name: "app".into(), compose_def: dummy_def(), depends_on: vec!["db".into()] },
],
"app",
);
assert!(plan.needs_network());
assert_eq!(plan.services.len(), 2);
}
#[test]
fn startup_order_respects_dependencies() {
let plan = OrchestrationPlan::multi(
Path::new("/workspace"),
vec![
ServiceInstance { name: "app".into(), compose_def: dummy_def(), depends_on: vec!["db".into()] },
ServiceInstance { name: "db".into(), compose_def: dummy_def(), depends_on: vec![] },
],
"app",
);
let order = plan.startup_order();
assert_eq!(order[0].name, "db");
assert_eq!(order[1].name, "app");
}
#[test]
fn startup_order_handles_no_deps() {
let plan = OrchestrationPlan::single("solo", dummy_def());
let order = plan.startup_order();
assert_eq!(order.len(), 1);
assert_eq!(order[0].name, "solo");
}
}
+187
View File
@@ -0,0 +1,187 @@
/// Runtime detection for rootless/rootful Docker and Podman.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RuntimeMode {
RootlessDocker,
RootfulDocker,
Podman,
Unknown,
}
impl std::fmt::Display for RuntimeMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::RootlessDocker => write!(f, "docker (rootless)"),
Self::RootfulDocker => write!(f, "docker (rootful)"),
Self::Podman => write!(f, "podman (rootless)"),
Self::Unknown => write!(f, "unknown"),
}
}
}
#[derive(Debug, Clone)]
pub struct RuntimeInfo {
pub mode: RuntimeMode,
pub cgroup_v2: bool,
pub api_version: Option<String>,
pub server_version: Option<String>,
}
impl Default for RuntimeInfo {
fn default() -> Self {
Self {
mode: RuntimeMode::Unknown,
cgroup_v2: false,
api_version: None,
server_version: None,
}
}
}
impl RuntimeInfo {
pub fn supports_resource_limits(&self) -> bool {
self.cgroup_v2
}
pub fn supports_privileged(&self) -> bool {
self.mode == RuntimeMode::RootfulDocker
}
pub fn supports_low_ports(&self) -> bool {
self.mode == RuntimeMode::RootfulDocker
}
}
#[cfg(feature = "bollard")]
fn detect_cgroup_v2() -> bool {
#[cfg(target_os = "linux")]
{
std::path::Path::new("/sys/fs/cgroup/cgroup.controllers").exists()
}
#[cfg(not(target_os = "linux"))]
{
false
}
}
#[cfg(feature = "bollard")]
pub async fn detect_runtime(docker: &bollard::Docker) -> RuntimeInfo {
let info = match docker.info().await {
Ok(info) => info,
Err(_) => return RuntimeInfo::default(),
};
// Determine mode
let mode = if let Some(ref sec_opts) = info.security_options {
if sec_opts.iter().any(|s| s.contains("rootless")) {
RuntimeMode::RootlessDocker
} else {
detect_mode_from_root_dir(&info)
}
} else {
detect_mode_from_root_dir(&info)
};
let server_version = info.server_version.clone();
// Get API version from docker.version()
let api_version = match docker.version().await {
Ok(ver) => ver.api_version,
Err(_) => None,
};
RuntimeInfo {
mode,
cgroup_v2: detect_cgroup_v2(),
api_version,
server_version,
}
}
#[cfg(feature = "bollard")]
fn detect_mode_from_root_dir(info: &bollard::models::SystemInfo) -> RuntimeMode {
if let Some(ref root_dir) = info.docker_root_dir {
if root_dir.contains(".local/share/docker") {
return RuntimeMode::RootlessDocker;
}
}
RuntimeMode::RootfulDocker
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_runtime_mode() {
assert_eq!(RuntimeMode::RootlessDocker.to_string(), "docker (rootless)");
assert_eq!(RuntimeMode::RootfulDocker.to_string(), "docker (rootful)");
assert_eq!(RuntimeMode::Podman.to_string(), "podman (rootless)");
assert_eq!(RuntimeMode::Unknown.to_string(), "unknown");
}
#[test]
fn supports_resource_limits_requires_cgroup_v2() {
let info = RuntimeInfo {
mode: RuntimeMode::RootfulDocker,
cgroup_v2: false,
api_version: None,
server_version: None,
};
assert!(!info.supports_resource_limits());
let info = RuntimeInfo {
cgroup_v2: true,
..info
};
assert!(info.supports_resource_limits());
}
#[test]
fn supports_privileged_only_rootful() {
let rootful = RuntimeInfo {
mode: RuntimeMode::RootfulDocker,
..Default::default()
};
assert!(rootful.supports_privileged());
let rootless = RuntimeInfo {
mode: RuntimeMode::RootlessDocker,
..Default::default()
};
assert!(!rootless.supports_privileged());
let podman = RuntimeInfo {
mode: RuntimeMode::Podman,
..Default::default()
};
assert!(!podman.supports_privileged());
let unknown = RuntimeInfo::default();
assert!(!unknown.supports_privileged());
}
#[test]
fn supports_low_ports_only_rootful() {
let rootful = RuntimeInfo {
mode: RuntimeMode::RootfulDocker,
..Default::default()
};
assert!(rootful.supports_low_ports());
let rootless = RuntimeInfo {
mode: RuntimeMode::RootlessDocker,
..Default::default()
};
assert!(!rootless.supports_low_ports());
}
#[test]
fn default_runtime_info() {
let info = RuntimeInfo::default();
assert_eq!(info.mode, RuntimeMode::Unknown);
assert!(!info.cgroup_v2);
assert!(info.api_version.is_none());
assert!(info.server_version.is_none());
}
}
File diff suppressed because it is too large Load Diff
+304
View File
@@ -0,0 +1,304 @@
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug)]
pub struct ConfigSource {
pub root: PathBuf,
pub origin: ConfigOrigin,
}
#[derive(Debug)]
pub enum ConfigOrigin {
EnvVar,
Workspace,
Redirect { from: PathBuf },
MainWorktree,
}
impl std::fmt::Display for ConfigOrigin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConfigOrigin::EnvVar => write!(f, "SANDCAGE_CONFIG_DIR"),
ConfigOrigin::Workspace => write!(f, "workspace"),
ConfigOrigin::Redirect { from } => write!(f, "redirect from {}", from.display()),
ConfigOrigin::MainWorktree => write!(f, "main worktree"),
}
}
}
/// Resolve the directory from which `.sandcage/` config is loaded.
///
/// Resolution order:
/// 1. `SANDCAGE_CONFIG_DIR` env var
/// 2. `.sandcage/config.toml` in workspace
/// 3. Redirect file (`sandcage.toml` at workspace root with `redirect =` key)
/// 4. Main worktree root (if workspace is a git worktree)
/// 5. None — no project config found
pub fn resolve_config_source(workspace: &Path) -> Option<ConfigSource> {
if let Ok(dir) = std::env::var("SANDCAGE_CONFIG_DIR") {
let expanded = expand_tilde(&dir);
if expanded.join(".sandcage").join("config.toml").exists() {
return Some(ConfigSource { root: expanded, origin: ConfigOrigin::EnvVar });
}
eprintln!(
"sandcage: warning: SANDCAGE_CONFIG_DIR={dir} does not contain \
.sandcage/config.toml, ignoring"
);
}
if workspace.join(".sandcage").join("config.toml").exists() {
return Some(ConfigSource {
root: workspace.to_path_buf(),
origin: ConfigOrigin::Workspace,
});
}
if let Some(target) = check_redirect(workspace) {
return Some(ConfigSource {
root: target,
origin: ConfigOrigin::Redirect { from: workspace.to_path_buf() },
});
}
if let Some(main_root) = find_main_worktree_root(workspace) {
if main_root != workspace {
if main_root.join(".sandcage").join("config.toml").exists() {
return Some(ConfigSource {
root: main_root,
origin: ConfigOrigin::MainWorktree,
});
}
if let Some(target) = check_redirect(&main_root) {
return Some(ConfigSource {
root: target,
origin: ConfigOrigin::Redirect { from: main_root },
});
}
}
}
None
}
fn expand_tilde(path: &str) -> PathBuf {
if path == "~" {
dirs::home_dir().unwrap_or_else(|| PathBuf::from(path))
} else if let Some(suffix) = path.strip_prefix("~/") {
match dirs::home_dir() {
Some(home) => home.join(suffix),
None => PathBuf::from(path),
}
} else {
PathBuf::from(path)
}
}
fn check_redirect(dir: &Path) -> Option<PathBuf> {
let redirect_file = dir.join("sandcage.toml");
if !redirect_file.exists() {
return None;
}
let content = std::fs::read_to_string(&redirect_file).ok()?;
let parsed: toml::Value = toml::from_str(&content).ok()?;
let redirect = parsed.get("redirect")?.as_str()?;
let target = expand_tilde(redirect);
let resolved = if target.is_relative() {
dir.join(&target)
} else {
target
};
if resolved.join(".sandcage").join("config.toml").exists() {
Some(resolved)
} else {
eprintln!(
"sandcage: warning: redirect in {} points to {}, which does not contain \
.sandcage/config.toml",
redirect_file.display(),
resolved.display()
);
None
}
}
fn find_main_worktree_root(from: &Path) -> Option<PathBuf> {
let common = Command::new("git")
.args(["rev-parse", "--git-common-dir"])
.current_dir(from)
.output()
.ok()?;
if !common.status.success() {
return None;
}
let git_dir = Command::new("git")
.args(["rev-parse", "--git-dir"])
.current_dir(from)
.output()
.ok()?;
if !git_dir.status.success() {
return None;
}
let common_path = PathBuf::from(String::from_utf8_lossy(&common.stdout).trim());
let git_dir_path = PathBuf::from(String::from_utf8_lossy(&git_dir.stdout).trim());
let common_abs = from.join(&common_path).canonicalize().ok()?;
let git_dir_abs = from.join(&git_dir_path).canonicalize().ok()?;
if common_abs == git_dir_abs {
return None;
}
common_abs.parent().map(|p| p.to_path_buf())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn make_git_repo() -> (TempDir, PathBuf) {
let dir = TempDir::new().expect("create tempdir");
let path = dir.path().to_path_buf();
Command::new("git")
.args(["init"])
.current_dir(&path)
.status()
.expect("git init");
// Need at least one commit for worktrees to work
Command::new("git")
.args(["commit", "--allow-empty", "-m", "init"])
.current_dir(&path)
.status()
.expect("git commit");
(dir, path)
}
fn scaffold_sandcage(root: &Path) {
let dir = root.join(".sandcage");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("config.toml"), "# test config\n").unwrap();
}
#[test]
fn resolves_from_workspace() {
let dir = TempDir::new().unwrap();
scaffold_sandcage(dir.path());
let result = resolve_config_source(dir.path());
assert!(result.is_some());
let src = result.unwrap();
assert!(matches!(src.origin, ConfigOrigin::Workspace));
assert_eq!(src.root, dir.path().to_path_buf());
}
#[test]
fn returns_none_when_no_config() {
let dir = TempDir::new().unwrap();
let result = resolve_config_source(dir.path());
assert!(result.is_none());
}
#[test]
fn resolves_redirect() {
let dir = TempDir::new().unwrap();
let target = TempDir::new().unwrap();
scaffold_sandcage(target.path());
let redirect_content = format!(
"redirect = \"{}\"",
target.path().to_string_lossy().replace('\\', "/")
);
std::fs::write(dir.path().join("sandcage.toml"), redirect_content).unwrap();
let result = resolve_config_source(dir.path());
assert!(result.is_some());
let src = result.unwrap();
assert!(matches!(src.origin, ConfigOrigin::Redirect { .. }));
}
#[test]
fn invalid_redirect_returns_none() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join("sandcage.toml"),
"redirect = \"/nonexistent/path\"\n",
)
.unwrap();
let result = resolve_config_source(dir.path());
assert!(result.is_none());
}
#[test]
fn direct_config_wins_over_redirect() {
let dir = TempDir::new().unwrap();
scaffold_sandcage(dir.path());
// Also place a redirect — should be ignored since .sandcage/ exists
std::fs::write(dir.path().join("sandcage.toml"), "redirect = \"/somewhere\"\n").unwrap();
let result = resolve_config_source(dir.path());
assert!(result.is_some());
assert!(matches!(result.unwrap().origin, ConfigOrigin::Workspace));
}
#[test]
fn resolves_from_main_worktree() {
let (_guard, repo_root) = make_git_repo();
scaffold_sandcage(&repo_root);
let wt_path = repo_root.parent().unwrap().join("test-worktree");
let status = Command::new("git")
.args([
"worktree",
"add",
&wt_path.to_string_lossy(),
"-b",
"test-wt",
])
.current_dir(&repo_root)
.status()
.expect("git worktree add");
assert!(status.success(), "git worktree add failed");
let result = resolve_config_source(&wt_path);
assert!(result.is_some(), "should find config from main worktree");
let src = result.unwrap();
assert!(
matches!(src.origin, ConfigOrigin::MainWorktree),
"origin should be MainWorktree, got {:?}",
src.origin
);
// Cleanup worktree
Command::new("git")
.args(["worktree", "remove", &wt_path.to_string_lossy()])
.current_dir(&repo_root)
.status()
.ok();
}
#[test]
fn main_worktree_not_triggered_when_config_in_workspace() {
let (_guard, repo_root) = make_git_repo();
scaffold_sandcage(&repo_root);
// In normal (non-worktree) case, should resolve as Workspace
let result = resolve_config_source(&repo_root);
assert!(result.is_some());
assert!(matches!(result.unwrap().origin, ConfigOrigin::Workspace));
}
#[test]
fn expand_tilde_expands_home() {
let home = dirs::home_dir().unwrap();
assert_eq!(expand_tilde("~/foo"), home.join("foo"));
assert_eq!(expand_tilde("~"), home);
}
#[test]
fn expand_tilde_leaves_absolute_unchanged() {
assert_eq!(expand_tilde("/absolute/path"), PathBuf::from("/absolute/path"));
}
}
File diff suppressed because it is too large Load Diff
+831
View File
@@ -0,0 +1,831 @@
use std::path::{Path, PathBuf};
use crate::config::SandcageConfig;
use crate::spoof::ToolMode;
pub const CONTAINER_SOCKET_PATH: &str = "/run/sandcage/cmd.sock";
pub fn session_dir(sandcage_home: &Path, session_id: &str) -> PathBuf {
sandcage_home.join("run").join(session_id)
}
pub fn socket_path(sandcage_home: &Path, session_id: &str) -> PathBuf {
session_dir(sandcage_home, session_id).join("cmd.sock")
}
pub fn audit_log_path(sandcage_home: &Path, session_id: &str) -> PathBuf {
session_dir(sandcage_home, session_id).join("audit.jsonl")
}
pub fn generate_session_id() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let ts = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
let secs = ts.as_secs();
let nanos = ts.subsec_nanos();
format!("{secs}-{nanos:09}")
}
pub fn create_session_dir(sandcage_home: &Path, session_id: &str) -> std::io::Result<PathBuf> {
let dir = session_dir(sandcage_home, session_id);
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
pub fn cleanup_session_dir(sandcage_home: &Path, session_id: &str) {
let dir = session_dir(sandcage_home, session_id);
if dir.exists() {
let _ = std::fs::remove_dir_all(&dir);
}
}
#[derive(Debug, Clone)]
pub struct PathMapping {
pub container_prefix: String,
pub host_prefix: String,
}
impl PathMapping {
pub fn to_host(&self, container_path: &str) -> String {
if let Some(rest) = container_path.strip_prefix(&self.container_prefix) {
format!("{}{rest}", self.host_prefix)
} else {
container_path.to_string()
}
}
pub fn to_container(&self, host_path: &str) -> String {
if let Some(rest) = host_path.strip_prefix(&self.host_prefix) {
format!("{}{rest}", self.container_prefix)
} else {
host_path.to_string()
}
}
pub fn translate_args_to_host(&self, args: &[String]) -> Vec<String> {
args.iter()
.map(|arg| arg.replace(&self.container_prefix, &self.host_prefix))
.collect()
}
pub fn translate_output_to_container(&self, text: &str) -> String {
text.replace(&self.host_prefix, &self.container_prefix)
}
}
use serde::Serialize;
#[cfg(unix)]
use serde::Deserialize;
#[derive(Debug, Serialize)]
pub struct AuditEntry {
pub timestamp: String,
pub kind: String,
pub name: String,
pub args: Vec<String>,
pub cwd: String,
pub exit_code: i32,
}
pub fn append_audit(log_path: &Path, entry: &AuditEntry) -> std::io::Result<()> {
use std::io::Write;
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(log_path)?;
let json = serde_json::to_string(entry)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
writeln!(file, "{json}")
}
pub fn now_iso8601() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let d = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
let secs = d.as_secs();
let time_secs = secs % 86400;
let hours = time_secs / 3600;
let minutes = (time_secs % 3600) / 60;
let seconds = time_secs % 60;
let (year, month, day) = epoch_days_to_ymd(secs / 86400);
format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
}
fn epoch_days_to_ymd(mut days: u64) -> (u64, u64, u64) {
days += 719468;
let era = days / 146097;
let doe = days - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
pub fn seccomp_uses_notify(profile_path: &Path) -> bool {
std::fs::read_to_string(profile_path)
.map(|content| content.contains("SCMP_ACT_NOTIFY"))
.unwrap_or(false)
}
pub fn validate_request(
kind: &str,
name: &str,
config: &SandcageConfig,
is_trusted: bool,
) -> Result<(), String> {
if !is_trusted {
return Err("rejected: project is not in trusted_projects — host forwarding disabled".to_string());
}
match kind {
"tool" => {
let tools = config.tools.as_ref().ok_or_else(|| {
format!("rejected: tool '{name}' not configured (no [tools] section)")
})?;
let tool_cfg = tools.get(name).ok_or_else(|| {
format!("rejected: tool '{name}' not configured in [tools]")
})?;
let mode = tool_cfg
.mode
.as_deref()
.map(ToolMode::parse)
.unwrap_or(ToolMode::InCage);
if mode != ToolMode::Host {
return Err(format!(
"rejected: tool '{name}' is mode={}, not host",
tool_cfg.mode.as_deref().unwrap_or("in-cage")
));
}
Ok(())
}
"task" => {
let tasks = config.tasks.as_ref().ok_or_else(|| {
format!("rejected: task '{name}' not configured (no [tasks] section)")
})?;
let task_cfg = tasks.get(name).ok_or_else(|| {
format!("rejected: task '{name}' not configured in [tasks]")
})?;
let location = task_cfg.location.as_deref().unwrap_or("in-cage");
if location != "host" {
return Err(format!(
"rejected: task '{name}' is where={location}, not host — \
it runs in-cage and should not be forwarded"
));
}
Ok(())
}
other => Err(format!("rejected: unknown request kind '{other}'")),
}
}
// ---------------------------------------------------------------------------
// Daemon loop (Unix only)
// ---------------------------------------------------------------------------
#[cfg(unix)]
#[derive(Debug, Deserialize)]
struct IncomingRequest {
kind: String,
name: String,
#[serde(default)]
args: Vec<String>,
cwd: String,
}
#[cfg(unix)]
pub async fn serve_one(
socket_path: &Path,
audit_log: &Path,
mapping: &PathMapping,
config: &SandcageConfig,
is_trusted: bool,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use tokio::net::UnixListener;
if socket_path.exists() {
std::fs::remove_file(socket_path)?;
}
let listener = UnixListener::bind(socket_path)?;
// Set socket permissions to 0o600
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
std::fs::set_permissions(socket_path, perms)?;
}
let (stream, _addr) = listener.accept().await?;
handle_connection(stream, audit_log, mapping, config, is_trusted).await
}
#[cfg(unix)]
pub async fn serve_loop(
socket_path: &Path,
audit_log: &Path,
mapping: &PathMapping,
config: &SandcageConfig,
is_trusted: bool,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use tokio::net::UnixListener;
if socket_path.exists() {
std::fs::remove_file(socket_path)?;
}
let listener = UnixListener::bind(socket_path)?;
// Set socket permissions to 0o600
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
std::fs::set_permissions(socket_path, perms)?;
}
loop {
let (stream, _addr) = listener.accept().await?;
let audit_log = audit_log.to_path_buf();
let mapping = mapping.clone();
let config = config.clone();
tokio::spawn(async move {
if let Err(e) = handle_connection(stream, &audit_log, &mapping, &config, is_trusted).await {
eprintln!("sandcage forwarding: connection error: {e}");
}
});
}
}
#[cfg(unix)]
async fn handle_connection(
stream: tokio::net::UnixStream,
audit_log: &Path,
mapping: &PathMapping,
config: &SandcageConfig,
is_trusted: bool,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
let (reader, mut writer) = tokio::io::split(stream);
let mut buf_reader = BufReader::new(reader);
let mut line = String::new();
buf_reader.read_line(&mut line).await?;
let line = line.trim();
let req: IncomingRequest = serde_json::from_str(line)?;
// Validate
if let Err(msg) = validate_request(&req.kind, &req.name, config, is_trusted) {
let err_frame = serde_json::to_string(&serde_json::json!({"stream": "stderr", "data": format!("{msg}\n")}))?;
writer.write_all(err_frame.as_bytes()).await?;
writer.write_all(b"\n").await?;
let exit_frame = serde_json::to_string(&serde_json::json!({"exit": 1}))?;
writer.write_all(exit_frame.as_bytes()).await?;
writer.write_all(b"\n").await?;
writer.flush().await?;
let entry = AuditEntry {
timestamp: now_iso8601(),
kind: req.kind,
name: req.name,
args: req.args,
cwd: req.cwd,
exit_code: 1,
};
let _ = append_audit(audit_log, &entry);
return Ok(());
}
// Translate paths
let host_args = mapping.translate_args_to_host(&req.args);
let host_cwd = mapping.to_host(&req.cwd);
// Execute
let exit_code = execute_forwarded(&req.name, &host_args, &host_cwd, mapping, &mut writer).await?;
// Exit frame
let exit_frame = serde_json::to_string(&serde_json::json!({"exit": exit_code}))?;
writer.write_all(exit_frame.as_bytes()).await?;
writer.write_all(b"\n").await?;
writer.flush().await?;
// Audit
let entry = AuditEntry {
timestamp: now_iso8601(),
kind: req.kind,
name: req.name,
args: req.args,
cwd: req.cwd,
exit_code,
};
let _ = append_audit(audit_log, &entry);
Ok(())
}
#[cfg(unix)]
async fn execute_forwarded<W: tokio::io::AsyncWrite + Unpin>(
name: &str,
args: &[String],
cwd: &str,
mapping: &PathMapping,
writer: &mut W,
) -> Result<i32, Box<dyn std::error::Error + Send + Sync>> {
use tokio::io::AsyncWriteExt;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use tokio::sync::mpsc;
let mut child = Command::new(name)
.args(args)
.current_dir(cwd)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()?;
let stdout = child.stdout.take().unwrap();
let stderr = child.stderr.take().unwrap();
let (tx, mut rx) = mpsc::channel::<(String, String)>(64);
let tx_out = tx.clone();
let stdout_task = tokio::spawn(async move {
let mut reader = BufReader::new(stdout);
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line).await {
Ok(0) => break,
Ok(_) => {
let _ = tx_out.send(("stdout".to_string(), line.clone())).await;
}
Err(_) => break,
}
}
});
let tx_err = tx;
let stderr_task = tokio::spawn(async move {
let mut reader = BufReader::new(stderr);
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line).await {
Ok(0) => break,
Ok(_) => {
let _ = tx_err.send(("stderr".to_string(), line.clone())).await;
}
Err(_) => break,
}
}
});
// Stream frames as they arrive
// We need to drop the tasks' senders first by awaiting them, but we also
// need to read from rx concurrently. Use a select-style approach.
let mapping_clone = mapping.clone();
let stream_task = tokio::spawn(async move {
// Collect frames — we cannot write to `writer` from a spawned task
// because it borrows the caller's writer. Instead, collect and return.
let mut frames = Vec::new();
while let Some((stream_name, data)) = rx.recv().await {
let translated = mapping_clone.translate_output_to_container(&data);
frames.push((stream_name, translated));
}
frames
});
stdout_task.await?;
stderr_task.await?;
let frames = stream_task.await?;
for (stream_name, data) in frames {
let frame = serde_json::to_string(&serde_json::json!({"stream": stream_name, "data": data}))?;
writer.write_all(frame.as_bytes()).await?;
writer.write_all(b"\n").await?;
}
let status = child.wait().await?;
Ok(status.code().unwrap_or(1))
}
pub fn resolve_task_command(
task_name: &str,
config: &SandcageConfig,
) -> Result<(String, Vec<String>), String> {
resolve_task_command_with_args(task_name, &[] as &[String], config)
}
pub fn resolve_task_command_with_args<S: AsRef<str>>(
task_name: &str,
extra_args: &[S],
config: &SandcageConfig,
) -> Result<(String, Vec<String>), String> {
let tasks = config.tasks.as_ref().ok_or("no [tasks] section")?;
let task_cfg = tasks
.get(task_name)
.ok_or_else(|| format!("task '{task_name}' not found"))?;
let run_cmd = task_cfg
.run
.as_deref()
.ok_or_else(|| format!("task '{task_name}' has no `run` command"))?;
let mut parts: Vec<String> = run_cmd.split_whitespace().map(String::from).collect();
if parts.is_empty() {
return Err(format!("task '{task_name}' has an empty `run` command"));
}
let cmd = parts.remove(0);
parts.extend(extra_args.iter().map(|a| a.as_ref().to_string()));
Ok((cmd, parts))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{SandcageConfig, TaskConfig, ToolConfig};
use std::collections::HashMap;
fn config_with_host_tool(name: &str) -> SandcageConfig {
let mut tools = HashMap::new();
tools.insert(
name.to_string(),
ToolConfig {
mode: Some("host".to_string()),
message: None,
allow: vec![],
deny: vec![],
},
);
SandcageConfig {
tools: Some(tools),
..Default::default()
}
}
#[test]
fn validate_allows_configured_host_tool() {
let config = config_with_host_tool("docker");
let result = validate_request("tool", "docker", &config, true);
assert!(result.is_ok());
}
#[test]
fn validate_rejects_unconfigured_tool() {
let config = config_with_host_tool("docker");
let result = validate_request("tool", "git", &config, true);
assert!(result.is_err());
assert!(result.unwrap_err().contains("not configured"));
}
#[test]
fn validate_rejects_when_untrusted() {
let config = config_with_host_tool("docker");
let result = validate_request("tool", "docker", &config, false);
assert!(result.is_err());
assert!(result.unwrap_err().contains("trusted"));
}
#[test]
fn validate_rejects_non_host_mode_tool() {
let mut tools = HashMap::new();
tools.insert(
"cargo".to_string(),
ToolConfig {
mode: Some("spoof".to_string()),
message: Some("use just build".to_string()),
allow: vec![],
deny: vec![],
},
);
let config = SandcageConfig {
tools: Some(tools),
..Default::default()
};
let result = validate_request("tool", "cargo", &config, true);
assert!(result.is_err());
}
// Session directory tests
#[test]
fn session_dir_is_under_sandcage_run() {
let home = PathBuf::from("/home/user/.sandcage");
let dir = session_dir(&home, "abc123");
assert_eq!(dir, PathBuf::from("/home/user/.sandcage/run/abc123"));
}
#[test]
fn session_id_is_unique() {
let a = generate_session_id();
let b = generate_session_id();
assert_ne!(a, b);
assert!(a.len() > 10);
}
#[test]
fn socket_path_is_cmd_sock() {
let home = PathBuf::from("/home/user/.sandcage");
assert_eq!(
socket_path(&home, "abc123"),
PathBuf::from("/home/user/.sandcage/run/abc123/cmd.sock")
);
}
#[test]
fn audit_log_path_is_in_session_dir() {
let home = PathBuf::from("/home/user/.sandcage");
assert_eq!(
audit_log_path(&home, "abc123"),
PathBuf::from("/home/user/.sandcage/run/abc123/audit.jsonl")
);
}
// Path translation tests
#[test]
fn translate_container_to_host() {
let m = PathMapping {
container_prefix: "/workspace/app".to_string(),
host_prefix: "/home/user/projects/app".to_string(),
};
assert_eq!(m.to_host("/workspace/app/src/main.rs"), "/home/user/projects/app/src/main.rs");
}
#[test]
fn translate_host_to_container() {
let m = PathMapping {
container_prefix: "/workspace/app".to_string(),
host_prefix: "/home/user/projects/app".to_string(),
};
assert_eq!(m.to_container("/home/user/projects/app/Cargo.toml"), "/workspace/app/Cargo.toml");
}
#[test]
fn no_translation_when_prefix_absent() {
let m = PathMapping {
container_prefix: "/workspace/app".to_string(),
host_prefix: "/home/user/projects/app".to_string(),
};
assert_eq!(m.to_host("/usr/local/bin/cargo"), "/usr/local/bin/cargo");
assert_eq!(m.to_container("/etc/passwd"), "/etc/passwd");
}
#[test]
fn translate_args_replaces_all_occurrences() {
let m = PathMapping {
container_prefix: "/workspace/app".to_string(),
host_prefix: "/home/user/projects/app".to_string(),
};
let args = vec![
"test".to_string(),
"--manifest-path=/workspace/app/Cargo.toml".to_string(),
"/workspace/app/src/lib.rs".to_string(),
];
let translated = m.translate_args_to_host(&args);
assert_eq!(translated[0], "test");
assert_eq!(translated[1], "--manifest-path=/home/user/projects/app/Cargo.toml");
assert_eq!(translated[2], "/home/user/projects/app/src/lib.rs");
}
#[test]
fn translate_output_text_replaces_host_paths() {
let m = PathMapping {
container_prefix: "/workspace/app".to_string(),
host_prefix: "/home/user/projects/app".to_string(),
};
let output = "error[E0308]: /home/user/projects/app/src/main.rs:42:5\n";
assert_eq!(
m.translate_output_to_container(output),
"error[E0308]: /workspace/app/src/main.rs:42:5\n"
);
}
// Audit logging tests
#[test]
fn audit_entry_serializes() {
let entry = AuditEntry {
timestamp: "2026-06-02T14:00:00Z".to_string(),
kind: "task".to_string(),
name: "build".to_string(),
args: vec!["--release".to_string()],
cwd: "/home/user/projects/app".to_string(),
exit_code: 0,
};
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains("\"name\":\"build\""));
assert!(json.contains("\"exit_code\":0"));
}
#[test]
fn append_audit_creates_file_and_appends() {
let dir = tempfile::tempdir().unwrap();
let log_path = dir.path().join("audit.jsonl");
let entry1 = AuditEntry {
timestamp: "2026-06-02T14:00:00Z".to_string(),
kind: "task".to_string(),
name: "build".to_string(),
args: vec![],
cwd: "/workspace".to_string(),
exit_code: 0,
};
append_audit(&log_path, &entry1).unwrap();
let entry2 = AuditEntry {
timestamp: "2026-06-02T14:00:01Z".to_string(),
kind: "tool".to_string(),
name: "docker".to_string(),
args: vec!["ps".to_string()],
cwd: "/workspace".to_string(),
exit_code: 0,
};
append_audit(&log_path, &entry2).unwrap();
let content = std::fs::read_to_string(&log_path).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 2);
assert!(lines[0].contains("build"));
assert!(lines[1].contains("docker"));
}
#[cfg(unix)]
#[tokio::test]
async fn daemon_executes_echo_and_streams_output() {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader as TokioBufReader};
use tokio::net::UnixStream as TokioUnixStream;
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("cmd.sock");
let audit = dir.path().join("audit.jsonl");
let mapping = PathMapping {
container_prefix: "/workspace".to_string(),
host_prefix: dir.path().to_string_lossy().to_string(),
};
let config = SandcageConfig::default();
let daemon_sock = sock.clone();
let daemon_audit = audit.clone();
let daemon_mapping = mapping.clone();
let handle = tokio::spawn(async move {
serve_one(&daemon_sock, &daemon_audit, &daemon_mapping, &config, true).await
});
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let mut stream = TokioUnixStream::connect(&sock).await.unwrap();
let request = r#"{"kind":"task","name":"echo","args":["hello-from-daemon"],"cwd":"/workspace"}"#;
stream.write_all(request.as_bytes()).await.unwrap();
stream.write_all(b"\n").await.unwrap();
stream.shutdown().await.unwrap();
let reader = TokioBufReader::new(&mut stream);
let mut lines = reader.lines();
let mut got_exit = false;
let mut output_data = String::new();
while let Some(line) = lines.next_line().await.unwrap() {
if line.contains("\"exit\"") {
let v: serde_json::Value = serde_json::from_str(&line).unwrap();
assert_eq!(v["exit"].as_i64().unwrap(), 0);
got_exit = true;
break;
}
if line.contains("\"data\"") {
let v: serde_json::Value = serde_json::from_str(&line).unwrap();
output_data.push_str(v["data"].as_str().unwrap_or(""));
}
}
assert!(got_exit, "should receive exit frame");
assert!(output_data.contains("hello-from-daemon"), "output should contain 'hello-from-daemon', got: '{output_data}'");
handle.await.unwrap().unwrap();
let audit_content = std::fs::read_to_string(&audit).unwrap();
assert!(audit_content.contains("echo"));
}
#[cfg(unix)]
#[tokio::test]
async fn daemon_rejects_untrusted_request() {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader as TokioBufReader};
use tokio::net::UnixStream as TokioUnixStream;
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("cmd.sock");
let audit = dir.path().join("audit.jsonl");
let mapping = PathMapping {
container_prefix: "/workspace".to_string(),
host_prefix: dir.path().to_string_lossy().to_string(),
};
let config = SandcageConfig::default();
let daemon_sock = sock.clone();
let daemon_audit = audit.clone();
let daemon_mapping = mapping.clone();
let handle = tokio::spawn(async move {
serve_one(&daemon_sock, &daemon_audit, &daemon_mapping, &config, false).await
});
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let mut stream = TokioUnixStream::connect(&sock).await.unwrap();
let request = r#"{"kind":"task","name":"echo","args":["hello"],"cwd":"/workspace"}"#;
stream.write_all(request.as_bytes()).await.unwrap();
stream.write_all(b"\n").await.unwrap();
stream.shutdown().await.unwrap();
let reader = TokioBufReader::new(&mut stream);
let mut lines = reader.lines();
let mut got_rejection = false;
let mut got_exit = false;
while let Some(line) = lines.next_line().await.unwrap() {
if line.contains("trusted") {
got_rejection = true;
}
if line.contains("\"exit\"") {
let v: serde_json::Value = serde_json::from_str(&line).unwrap();
assert_eq!(v["exit"].as_i64().unwrap(), 1, "rejected requests should exit 1");
got_exit = true;
break;
}
}
assert!(got_rejection, "should mention trust rejection");
assert!(got_exit, "should send exit frame");
handle.await.unwrap().unwrap();
}
fn config_with_task(name: &str, location: &str, run_cmd: &str) -> SandcageConfig {
let mut tasks = HashMap::new();
tasks.insert(
name.to_string(),
TaskConfig {
location: Some(location.to_string()),
run: Some(run_cmd.to_string()),
},
);
SandcageConfig {
tasks: Some(tasks),
..Default::default()
}
}
#[test]
fn validate_allows_configured_host_task() {
let config = config_with_task("build", "host", "cargo build --release");
let result = validate_request("task", "build", &config, true);
assert!(result.is_ok());
}
#[test]
fn validate_rejects_unconfigured_task() {
let config = config_with_task("build", "host", "cargo build");
let result = validate_request("task", "deploy", &config, true);
assert!(result.is_err());
}
#[test]
fn validate_rejects_in_cage_task_via_forwarding() {
let config = config_with_task("lint", "in-cage", "clippy");
let result = validate_request("task", "lint", &config, true);
assert!(result.is_err());
}
#[test]
fn resolve_task_command_returns_run_field() {
let config = config_with_task("build", "host", "cargo build --release");
let (cmd, args) = resolve_task_command("build", &config).unwrap();
assert_eq!(cmd, "cargo");
assert_eq!(args, vec!["build", "--release"]);
}
#[test]
fn resolve_task_command_appends_extra_args() {
let config = config_with_task("test", "host", "cargo test");
let (cmd, args) = resolve_task_command_with_args("test", &["--lib"], &config).unwrap();
assert_eq!(cmd, "cargo");
assert_eq!(args, vec!["test", "--lib"]);
}
#[test]
fn detects_scmp_act_notify_in_seccomp_profile() {
let dir = tempfile::tempdir().unwrap();
let with_notify = dir.path().join("notify.json");
std::fs::write(
&with_notify,
r#"{"defaultAction":"SCMP_ACT_ERRNO","syscalls":[{"names":["execve"],"action":"SCMP_ACT_NOTIFY"}]}"#,
).unwrap();
assert!(seccomp_uses_notify(&with_notify));
let without = dir.path().join("basic.json");
std::fs::write(
&without,
r#"{"defaultAction":"SCMP_ACT_ERRNO","syscalls":[]}"#,
).unwrap();
assert!(!seccomp_uses_notify(&without));
}
}
+493
View File
@@ -0,0 +1,493 @@
use std::path::{Path, PathBuf};
use miette::Diagnostic;
use thiserror::Error;
const CLAUDE_SETTINGS_TEMPLATE: &str =
include_str!("../../../templates/claude/settings.json");
#[derive(Debug, Error, Diagnostic)]
pub enum InitError {
#[error("Failed to create directory {0}: {1}")]
#[diagnostic(code(sandcage::init::create_dir_failed))]
CreateDirFailed(PathBuf, #[source] std::io::Error),
#[error("Failed to write file {0}: {1}")]
#[diagnostic(code(sandcage::init::write_file_failed))]
WriteFileFailed(PathBuf, #[source] std::io::Error),
#[error("Cannot determine home directory")]
#[diagnostic(code(sandcage::init::no_home_dir))]
NoHomeDir,
#[error(".sandcage/config.toml already exists at {0}; remove it manually to re-initialise")]
#[diagnostic(code(sandcage::init::config_already_exists))]
ConfigAlreadyExists(PathBuf),
#[error("You're in a git worktree. Run `sandcage init` from the main worktree at {0} instead.")]
#[diagnostic(code(sandcage::init::in_worktree))]
InWorktree(PathBuf),
}
pub type Result<T> = std::result::Result<T, InitError>;
pub fn preseed() -> Result<()> {
let home = dirs::home_dir().ok_or(InitError::NoHomeDir)?;
preseed_with_home(&home)
}
pub fn preseed_with_home(home: &Path) -> Result<()> {
let sandcage_home = home.join(".sandcage");
let agent_home = sandcage_home.join("homes").join("default");
// 1. ~/.sandcage/homes/default/ — persistent agent home directory
create_dir_all(&agent_home)?;
// 2. ~/.sandcage/home/.claude/
let claude_dir = agent_home.join(".claude");
create_dir_all(&claude_dir)?;
// 3. ~/.sandcage/home/.codex/
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() {
std::fs::write(&settings_dest, CLAUDE_SETTINGS_TEMPLATE)
.map_err(|e| InitError::WriteFileFailed(settings_dest.clone(), e))?;
println!("sandcage: seeded {} from template", settings_dest.display());
}
// 5. Seed .claude.json so the bind mount targets a file, not a directory
let claude_json = agent_home.join(".claude.json");
if !claude_json.exists() {
std::fs::write(&claude_json, "{}\n")
.map_err(|e| InitError::WriteFileFailed(claude_json.clone(), e))?;
}
// 6. Create an empty .justfile if absent
let justfile = agent_home.join(".justfile");
if !justfile.exists() {
std::fs::write(&justfile, "")
.map_err(|e| InitError::WriteFileFailed(justfile.clone(), e))?;
}
Ok(())
}
fn create_dir_all(path: &Path) -> Result<()> {
std::fs::create_dir_all(path)
.map_err(|e| InitError::CreateDirFailed(path.to_path_buf(), e))
}
// ---------------------------------------------------------------------------
// Project scaffolding — `sandcage init`
// ---------------------------------------------------------------------------
/// Language ecosystems we can detect from marker files.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Ecosystem {
Rust,
Node,
Python,
Go,
Generic,
}
impl Ecosystem {
fn as_str(self) -> &'static str {
match self {
Ecosystem::Rust => "rust",
Ecosystem::Node => "node",
Ecosystem::Python => "python",
Ecosystem::Go => "go",
Ecosystem::Generic => "generic",
}
}
}
/// Detect the primary language ecosystem from files present in `dir`.
pub fn detect_ecosystem(dir: &Path) -> Ecosystem {
if dir.join("Cargo.toml").exists() {
Ecosystem::Rust
} else if dir.join("package.json").exists() {
Ecosystem::Node
} else if dir.join("pyproject.toml").exists() || dir.join("requirements.txt").exists() {
Ecosystem::Python
} else if dir.join("go.mod").exists() {
Ecosystem::Go
} else {
Ecosystem::Generic
}
}
/// Build the TOML content for a `config.toml` config file.
fn build_toml(ecosystem: Ecosystem) -> String {
let toolchain_and_packages = match ecosystem {
Ecosystem::Rust => {
"[toolchains]\nrust = true\n\npackages = [\"ripgrep\", \"fd-find\"]\n"
}
Ecosystem::Node => {
"[toolchains]\nnvm = true\n\npackages = [\"git\", \"curl\"]\n"
}
Ecosystem::Python => {
"[toolchains]\nuv = true\n\npackages = [\"git\", \"curl\"]\n"
}
Ecosystem::Go => {
"# [toolchains]\n# go = true # TBD\n\npackages = [\"git\", \"curl\"]\n"
}
Ecosystem::Generic => {
"packages = [\"git\", \"curl\"]\n"
}
};
format!(
"# Sandcage project configuration\n\
# Docs: https://github.com/user/sandcage\n\
\n\
# Detected ecosystem: {ecosystem}\n\
\n\
# [env]\n\
# EXAMPLE_VAR = \"value\"\n\
\n\
{toolchain_and_packages}\
\n\
# mounts = [\n\
# \"~/.ssh:/home/agent/.ssh:ro\",\n\
# \"~/.gitconfig:/home/agent/.gitconfig:ro\",\n\
# ]\n\
\n\
# [agent_args]\n\
# claude = [\"--dangerously-skip-permissions\"]\n\
\n\
# shell = \"zsh\"\n\
\n\
# container_workspace = \"/workspace/my-project\"\n",
ecosystem = ecosystem.as_str(),
)
}
/// Check if `path` is a linked git worktree (not the main worktree).
/// Returns the main worktree root if so.
fn detect_linked_worktree(path: &Path) -> Option<PathBuf> {
let common = std::process::Command::new("git")
.args(["rev-parse", "--git-common-dir"])
.current_dir(path)
.output()
.ok()?;
if !common.status.success() {
return None;
}
let git_dir = std::process::Command::new("git")
.args(["rev-parse", "--git-dir"])
.current_dir(path)
.output()
.ok()?;
if !git_dir.status.success() {
return None;
}
let common_path = PathBuf::from(String::from_utf8_lossy(&common.stdout).trim());
let git_dir_path = PathBuf::from(String::from_utf8_lossy(&git_dir.stdout).trim());
let common_abs = path.join(&common_path).canonicalize().ok()?;
let git_dir_abs = path.join(&git_dir_path).canonicalize().ok()?;
if common_abs == git_dir_abs {
return None;
}
common_abs.parent().map(|p| p.to_path_buf())
}
/// Scaffold a `.sandcage/` directory in `path` with starter configuration
/// based on the detected language ecosystem. Returns an error if the
/// config file already exists.
pub fn scaffold_project(path: &Path) -> Result<()> {
if let Some(main_root) = detect_linked_worktree(path) {
return Err(InitError::InWorktree(main_root));
}
let sandcage_dir = path.join(".sandcage");
let config_path = sandcage_dir.join("config.toml");
if config_path.exists() {
return Err(InitError::ConfigAlreadyExists(config_path));
}
let ecosystem = detect_ecosystem(path);
let content = build_toml(ecosystem);
// Create directory structure
create_dir_all(&sandcage_dir)?;
create_dir_all(&sandcage_dir.join("overlay"))?;
let local_dir = sandcage_dir.join("local");
create_dir_all(&local_dir)?;
create_dir_all(&local_dir.join("overlay"))?;
// Write config file
std::fs::write(&config_path, &content)
.map_err(|e| InitError::WriteFileFailed(config_path.clone(), e))?;
// Write .sandcage/.gitignore to exclude local/
let inner_gitignore = sandcage_dir.join(".gitignore");
std::fs::write(&inner_gitignore, "local/\nshadow/\n")
.map_err(|e| InitError::WriteFileFailed(inner_gitignore, e))?;
println!(
"sandcage: initialised {} (detected ecosystem: {})",
config_path.display(),
ecosystem.as_str(),
);
println!();
print!("{content}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn run(home: &Path) -> Result<()> {
preseed_with_home(home)
}
#[test]
fn creates_home_dir() {
let home = TempDir::new().expect("create tempdir");
run(home.path()).expect("preseed");
assert!(
home.path().join(".sandcage/homes/default").is_dir(),
"home dir should exist"
);
}
#[test]
fn creates_agent_config_dirs() {
let home = TempDir::new().expect("create tempdir");
run(home.path()).expect("preseed");
assert!(
home.path().join(".sandcage/homes/default/.claude").is_dir(),
".claude dir should exist"
);
assert!(
home.path().join(".sandcage/homes/default/.codex").is_dir(),
".codex dir should exist"
);
assert!(
home.path().join(".sandcage/homes/default/.gemini").is_dir(),
".gemini dir should exist"
);
}
#[test]
fn seeds_claude_settings_when_absent() {
let home = TempDir::new().expect("create tempdir");
run(home.path()).expect("preseed");
let settings = home.path().join(".sandcage/homes/default/.claude/settings.json");
assert!(settings.exists(), "settings.json should be created");
let content = std::fs::read_to_string(&settings).expect("read settings");
assert_eq!(
content.trim(),
CLAUDE_SETTINGS_TEMPLATE.trim(),
"settings.json content should match the template"
);
}
#[test]
fn does_not_overwrite_existing_claude_settings() {
let home = TempDir::new().expect("create tempdir");
// Create the directory and put custom content in settings.json first
let claude_dir = home.path().join(".sandcage/homes/default/.claude");
std::fs::create_dir_all(&claude_dir).expect("mkdir");
let settings = claude_dir.join("settings.json");
std::fs::write(&settings, r#"{"existing": true}"#).expect("write");
run(home.path()).expect("preseed");
let content = std::fs::read_to_string(&settings).expect("read");
assert_eq!(
content, r#"{"existing": true}"#,
"existing settings.json must not be overwritten"
);
}
#[test]
fn creates_empty_justfile_when_absent() {
let home = TempDir::new().expect("create tempdir");
run(home.path()).expect("preseed");
let justfile = home.path().join(".sandcage/homes/default/.justfile");
assert!(justfile.exists(), "Justfile should be created");
assert_eq!(
std::fs::read_to_string(&justfile).expect("read"),
"",
"Justfile should be empty"
);
}
#[test]
fn does_not_overwrite_existing_justfile() {
let home = TempDir::new().expect("create tempdir");
// Seed a Justfile with content first
let sandcage_dir = home.path().join(".sandcage/homes/default");
std::fs::create_dir_all(&sandcage_dir).expect("mkdir");
let justfile = sandcage_dir.join(".justfile");
std::fs::write(&justfile, "default:\n\t@echo hello").expect("write");
run(home.path()).expect("preseed");
let content = std::fs::read_to_string(&justfile).expect("read");
assert_eq!(
content, "default:\n\t@echo hello",
"existing Justfile must not be overwritten"
);
}
#[test]
fn idempotent_second_run() {
let home = TempDir::new().expect("create tempdir");
run(home.path()).expect("first preseed");
run(home.path()).expect("second preseed should also succeed");
// All artefacts should still exist after two runs
assert!(home.path().join(".sandcage/homes/default/.claude").is_dir());
assert!(home.path().join(".sandcage/homes/default/.codex").is_dir());
assert!(home.path().join(".sandcage/homes/default/.gemini").is_dir());
assert!(home.path().join(".sandcage/homes/default/.claude/settings.json").exists());
assert!(home.path().join(".sandcage/homes/default/.justfile").exists());
}
// -----------------------------------------------------------------------
// scaffold_project / ecosystem detection tests
// -----------------------------------------------------------------------
fn touch(dir: &Path, name: &str) {
std::fs::write(dir.join(name), "").expect("touch file");
}
#[test]
fn detects_rust_from_cargo_toml() {
let dir = TempDir::new().expect("tempdir");
touch(dir.path(), "Cargo.toml");
assert_eq!(detect_ecosystem(dir.path()), Ecosystem::Rust);
}
#[test]
fn detects_node_from_package_json() {
let dir = TempDir::new().expect("tempdir");
touch(dir.path(), "package.json");
assert_eq!(detect_ecosystem(dir.path()), Ecosystem::Node);
}
#[test]
fn detects_python_from_pyproject_toml() {
let dir = TempDir::new().expect("tempdir");
touch(dir.path(), "pyproject.toml");
assert_eq!(detect_ecosystem(dir.path()), Ecosystem::Python);
}
#[test]
fn detects_python_from_requirements_txt() {
let dir = TempDir::new().expect("tempdir");
touch(dir.path(), "requirements.txt");
assert_eq!(detect_ecosystem(dir.path()), Ecosystem::Python);
}
#[test]
fn detects_go_from_go_mod() {
let dir = TempDir::new().expect("tempdir");
touch(dir.path(), "go.mod");
assert_eq!(detect_ecosystem(dir.path()), Ecosystem::Go);
}
#[test]
fn falls_back_to_generic_when_no_markers() {
let dir = TempDir::new().expect("tempdir");
assert_eq!(detect_ecosystem(dir.path()), Ecosystem::Generic);
}
#[test]
fn scaffold_creates_config_toml() {
let dir = TempDir::new().expect("tempdir");
touch(dir.path(), "Cargo.toml");
scaffold_project(dir.path()).expect("scaffold");
assert!(dir.path().join(".sandcage").join("config.toml").exists());
}
#[test]
fn scaffold_refuses_to_overwrite_existing_config() {
let dir = TempDir::new().expect("tempdir");
let sandcage_dir = dir.path().join(".sandcage");
std::fs::create_dir_all(&sandcage_dir).expect("mkdir");
std::fs::write(sandcage_dir.join("config.toml"), "# existing config\n").expect("write");
let result = scaffold_project(dir.path());
assert!(
matches!(result, Err(InitError::ConfigAlreadyExists(_))),
"expected ConfigAlreadyExists, got {result:?}"
);
}
#[test]
fn scaffold_generated_toml_is_valid() {
let dir = TempDir::new().expect("tempdir");
touch(dir.path(), "Cargo.toml");
scaffold_project(dir.path()).expect("scaffold");
let content = std::fs::read_to_string(dir.path().join(".sandcage").join("config.toml"))
.expect("read config.toml");
let parsed: toml::Value =
toml::from_str(&content).expect("TOML must be valid");
let table = parsed.as_table().expect("top-level TOML must be a table");
assert!(
table.contains_key("toolchains"),
"expected 'toolchains' key in generated TOML"
);
}
#[test]
fn scaffold_creates_directory_structure() {
let dir = TempDir::new().expect("tempdir");
touch(dir.path(), "Cargo.toml");
scaffold_project(dir.path()).expect("scaffold");
let sandcage = dir.path().join(".sandcage");
assert!(sandcage.join("overlay").is_dir(), "overlay/ should exist");
assert!(sandcage.join("local").is_dir(), "local/ should exist");
assert!(sandcage.join("local").join("overlay").is_dir(), "local/overlay/ should exist");
}
#[test]
fn scaffold_creates_inner_gitignore() {
let dir = TempDir::new().expect("tempdir");
touch(dir.path(), "Cargo.toml");
scaffold_project(dir.path()).expect("scaffold");
let gitignore = dir.path().join(".sandcage").join(".gitignore");
assert!(gitignore.exists(), ".sandcage/.gitignore should exist");
let content = std::fs::read_to_string(&gitignore).expect("read .gitignore");
assert!(content.contains("local/"), ".gitignore should contain 'local/'");
}
#[test]
fn scaffold_gitignore_excludes_shadow() {
let dir = TempDir::new().expect("tempdir");
touch(dir.path(), "Cargo.toml");
scaffold_project(dir.path()).expect("scaffold");
let content = std::fs::read_to_string(dir.path().join(".sandcage").join(".gitignore")).unwrap();
assert!(content.contains("local/"));
assert!(content.contains("shadow/"));
}
}
+18
View File
@@ -0,0 +1,18 @@
pub mod backend;
pub mod config;
pub mod config_source;
pub mod docker;
pub mod forwarding;
pub mod init;
pub mod recipe;
pub mod service;
pub mod setup;
pub mod shadow;
pub mod spoof;
pub mod ssh_config;
pub mod tasks;
pub mod workspace;
pub mod worktree;
#[cfg(feature = "bollard")]
pub mod acp;
+412
View File
@@ -0,0 +1,412 @@
use clap::{Parser, Subcommand};
use miette::Diagnostic;
use std::path::PathBuf;
use thiserror::Error;
use sandcage::config;
use sandcage::config_source;
use sandcage::docker;
use sandcage::init;
use sandcage::service;
use sandcage::setup;
use sandcage::workspace;
/// Sandboxed containers for AI coding agents
#[derive(Parser, Debug)]
#[command(name = "sandcage", version, about, long_about = None)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// Run Claude Code agent in a sandboxed container
Claude {
/// Path to the project directory (defaults to current directory)
#[arg(long, short)]
path: Option<PathBuf>,
/// Drop into a shell instead of launching the agent
#[arg(long)]
shell: bool,
/// Create a git worktree and mount it instead of the project directory
#[arg(long)]
worktree: bool,
/// Named home profile for agent auth/config (default: "default")
#[arg(long)]
home: Option<String>,
/// Arguments forwarded to the agent inside the container
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
agent_args: Vec<String>,
},
/// Run Codex agent in a sandboxed container
Codex {
/// Path to the project directory (defaults to current directory)
#[arg(long, short)]
path: Option<PathBuf>,
/// Drop into a shell instead of launching the agent
#[arg(long)]
shell: bool,
/// Create a git worktree and mount it instead of the project directory
#[arg(long)]
worktree: bool,
/// Named home profile for agent auth/config (default: "default")
#[arg(long)]
home: Option<String>,
/// Arguments forwarded to the agent inside the container
#[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,
/// Create a git worktree and mount it instead of the project directory
#[arg(long)]
worktree: bool,
/// Named home profile for agent auth/config (default: "default")
#[arg(long)]
home: Option<String>,
/// 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>,
/// Named home profile for agent auth/config (default: "default")
#[arg(long)]
home: Option<String>,
},
/// 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/ directory for a project
Init,
/// Remove this project's shadow volumes (named `sandcage-<project>-…`).
Clean {
/// Actually delete (default is a dry run).
#[arg(long)]
yes: bool,
},
/// Guided configuration helpers
Setup {
#[command(subcommand)]
action: SetupAction,
},
/// Manage sandcage-created worktrees
Worktree {
#[command(subcommand)]
action: WorktreeAction,
},
/// ACP (Agent Control Protocol) commands
#[cfg(feature = "bollard")]
Acp {
#[command(subcommand)]
action: sandcage::acp::AcpCommands,
},
}
#[derive(Subcommand, Debug)]
enum SetupAction {
/// Configure SSH key access for containers
Ssh {
/// Write to global config instead of project config
#[arg(long)]
global: bool,
/// Skip confirmation prompt
#[arg(long)]
yes: bool,
/// Re-populate the SSH volume using saved selection
#[arg(long)]
refresh: bool,
/// Use legacy full bind mount instead of volume copy
#[arg(long)]
bind: bool,
},
}
#[derive(Subcommand, Debug)]
enum WorktreeAction {
/// List sandcage-managed worktrees
List,
/// Remove all sandcage-managed worktrees
Clean {
/// Skip confirmation prompt
#[arg(long)]
yes: bool,
},
}
#[derive(Debug, Error, Diagnostic)]
enum AppError {
#[error(transparent)]
#[diagnostic(transparent)]
Workspace(#[from] workspace::WorkspaceError),
#[error(transparent)]
#[diagnostic(transparent)]
Init(#[from] init::InitError),
#[error(transparent)]
#[diagnostic(transparent)]
Config(#[from] config::ConfigError),
#[error(transparent)]
#[diagnostic(transparent)]
Docker(#[from] docker::DockerError),
#[error(transparent)]
#[diagnostic(transparent)]
Setup(#[from] setup::SetupError),
#[error(transparent)]
#[diagnostic(transparent)]
Worktree(#[from] sandcage::worktree::WorktreeError),
}
async fn print_runtime_info(config: &sandcage::config::SandcageConfig) {
let backend = match sandcage::backend::default_backend() {
Ok(b) => b,
Err(_) => return,
};
if backend.check_availability().await.is_err() {
return; // Docker not available — will fail later with a proper error
}
match backend.runtime_info().await {
Ok(info) => {
use sandcage::backend::runtime::RuntimeMode;
match info.mode {
RuntimeMode::RootlessDocker => {
eprintln!("sandcage: runtime \u{2192} {} (recommended)", info.mode);
}
RuntimeMode::RootfulDocker => {
if config.runtime.as_deref() == Some("rootful") {
eprintln!("sandcage: runtime \u{2192} {}", info.mode);
} else {
eprintln!("sandcage: runtime \u{2192} {} \u{2014} consider rootless for better isolation", info.mode);
}
}
RuntimeMode::Podman => {
eprintln!("sandcage: runtime \u{2192} {}", info.mode);
}
RuntimeMode::Unknown => {
eprintln!("sandcage: runtime \u{2192} {}", info.mode);
}
}
if !info.cgroup_v2 && info.mode != RuntimeMode::Unknown {
eprintln!("sandcage: note: cgroup v2 not detected — resource limits will not be enforced");
}
if let Some(ref runtime_pref) = config.runtime {
match runtime_pref.as_str() {
"rootless" if info.mode != RuntimeMode::RootlessDocker && info.mode != RuntimeMode::Podman => {
eprintln!("sandcage: error: config requires rootless runtime, but connected daemon is {}", info.mode);
std::process::exit(1);
}
_ => {}
}
}
}
Err(_) => {} // Silent — will fail later
}
}
async fn run_service(service: &str, path: Option<PathBuf>, shell_override: bool, worktree: bool, home_override: Option<String>, agent_args: Vec<String>) -> std::result::Result<(), AppError> {
let workspace = workspace::resolve_workspace(path.as_deref())?;
eprintln!("sandcage: workspace \u{2192} {}", workspace.display());
let workspace = if worktree {
let sandcage_home = dirs::home_dir()
.map(|h| h.join(".sandcage"))
.unwrap_or_else(|| PathBuf::from(".sandcage"));
let slug = sandcage::worktree::project_slug(&workspace);
let ts = sandcage::worktree::generate_timestamp();
let wt_path = sandcage::worktree::worktree_dir(&sandcage_home, &slug, &ts);
eprintln!("sandcage: creating worktree at {}", wt_path.display());
sandcage::worktree::create_worktree(&workspace, &wt_path, None)?;
eprintln!("sandcage: launching in worktree");
wt_path
} else {
workspace
};
init::preseed()?;
let home = dirs::home_dir().ok_or(init::InitError::NoHomeDir)?;
let global_config = home.join(".sandcage").join("config.toml");
let config_src = config_source::resolve_config_source(&workspace);
let (project_config, local_config) = match &config_src {
Some(src) => {
if !matches!(src.origin, config_source::ConfigOrigin::Workspace) {
eprintln!("sandcage: config → {} ({})", src.root.display(), src.origin);
}
(
src.root.join(".sandcage").join("config.toml"),
src.root.join(".sandcage").join("local").join("config.local.toml"),
)
}
None => (
workspace.join(".sandcage").join("config.toml"),
workspace.join(".sandcage").join("local").join("config.local.toml"),
),
};
// Warn about old YAML config files that have not yet been migrated.
for (old_name, new_name) in [
("sandcage.yml", "config.toml"),
("sandcage.local.yml", "config.local.toml"),
] {
let dir = config_src.as_ref().map(|s| s.root.as_path()).unwrap_or(&workspace);
let old_path = dir.join(".sandcage").join(old_name);
if old_path.exists() {
eprintln!("sandcage: warning: found old {old_name} — please migrate to {new_name}");
}
}
let mut cfg = config::resolve_config(
Some(&global_config),
Some(&project_config),
Some(&local_config),
)?;
if let Some(home_name) = home_override {
cfg.home = Some(home_name);
}
print_runtime_info(&cfg).await;
let registry = service::registry::build_default_registry(&cfg);
eprintln!("sandcage: service \u{2192} {service}");
let config_root = config_src.as_ref().map(|s| s.root.as_path()).unwrap_or(&workspace);
docker::run_service(service, &workspace, &cfg, config_root, &registry, shell_override, &agent_args).await?;
Ok(())
}
#[tokio::main]
async fn main() -> miette::Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Claude { path, shell, worktree, home, agent_args } => {
run_service("claude", path, shell, worktree, home, agent_args).await?
}
Commands::Codex { path, shell, worktree, home, agent_args } => {
run_service("codex", path, shell, worktree, home, agent_args).await?
}
Commands::Gemini { path, shell, worktree, home, agent_args } => {
run_service("gemini", path, shell, worktree, home, agent_args).await?
}
Commands::Shell { path, home } => run_service("shell", path, false, false, home, vec![]).await?,
Commands::Build { force, services } => {
docker::build_images(force, &services).await?;
}
Commands::Init => {
let workspace = workspace::resolve_workspace(None)?;
init::scaffold_project(&workspace)?;
}
Commands::Clean { yes } => {
use miette::IntoDiagnostic;
let workspace = std::env::current_dir().into_diagnostic()?;
docker::clean(&workspace, yes).await.map_err(AppError::Docker)?;
}
Commands::Setup { action } => match action {
SetupAction::Ssh { global, yes, refresh, bind } => {
setup::run_ssh_setup(global, yes, refresh, bind)?;
}
},
Commands::Worktree { action } => {
let sandcage_home = dirs::home_dir()
.map(|h| h.join(".sandcage"))
.unwrap_or_else(|| PathBuf::from(".sandcage"));
match action {
WorktreeAction::List => {
let entries = sandcage::worktree::list_worktrees(&sandcage_home);
if entries.is_empty() {
eprintln!("No sandcage worktrees found.");
} else {
for entry in &entries {
println!("{}", entry.path.display());
}
eprintln!("{} worktree(s)", entries.len());
}
}
WorktreeAction::Clean { yes } => {
let entries = sandcage::worktree::list_worktrees(&sandcage_home);
if entries.is_empty() {
eprintln!("No worktrees to clean.");
return Ok(());
}
eprintln!("Worktrees to remove:");
for entry in &entries {
eprintln!(" {}", entry.path.display());
}
if !yes {
eprint!("Remove {} worktree(s)? [y/N] ", entries.len());
let mut input = String::new();
std::io::stdin().read_line(&mut input).ok();
if !input.trim().eq_ignore_ascii_case("y") {
eprintln!("Aborted.");
return Ok(());
}
}
let results = sandcage::worktree::clean_all(&sandcage_home, false);
for (name, result) in &results {
match result {
Ok(()) => eprintln!(" removed: {name}"),
Err(e) => eprintln!(" failed: {name}: {e}"),
}
}
}
}
}
#[cfg(feature = "bollard")]
Commands::Acp { action } => {
let backend = sandcage::backend::default_backend()?;
backend.check_availability().await
.map_err(|e| AppError::Docker(e))?;
sandcage::acp::handle(action, backend.as_ref(), None).await
.map_err(|e| AppError::Docker(e))?;
}
}
Ok(())
}
+298
View File
@@ -0,0 +1,298 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use miette::Diagnostic;
use serde::Deserialize;
use thiserror::Error;
const RECIPES_TOML: &str = include_str!("../../../images/fragments/recipes.toml");
const FRAGMENT_UV: &str = include_str!("../../../images/fragments/uv.Dockerfile");
const FRAGMENT_NVM: &str = include_str!("../../../images/fragments/nvm.Dockerfile");
const FRAGMENT_RUST: &str = include_str!("../../../images/fragments/rust.Dockerfile");
/// Where a toolchain's Dockerfile fragment comes from.
#[derive(Debug, Clone, PartialEq)]
pub enum FragmentSource {
/// Compiled into the binary — built-in recipes (`include_str!`).
Embedded(&'static str),
/// A path on disk, relative to the project — custom recipes.
Path(PathBuf),
}
/// A fully-resolved toolchain recipe.
#[derive(Debug, Clone, PartialEq)]
pub struct Recipe {
pub name: String,
pub fragment: FragmentSource,
pub shadow: Vec<String>,
pub shared_caches: Vec<String>,
}
/// The deserializable shape of a recipe in TOML (built-in bundle or custom config).
#[derive(Debug, Clone, Deserialize)]
pub struct RecipeSpec {
pub fragment: String,
#[serde(default)]
pub shadow: Vec<String>,
#[serde(default)]
pub shared_caches: Vec<String>,
}
/// Map a built-in fragment filename to its embedded content.
fn embedded_fragment(name: &str) -> Option<&'static str> {
match name {
"uv.Dockerfile" => Some(FRAGMENT_UV),
"nvm.Dockerfile" => Some(FRAGMENT_NVM),
"rust.Dockerfile" => Some(FRAGMENT_RUST),
_ => None,
}
}
/// Parse the bundled recipe TOML into resolved built-in recipes.
/// Panics on a malformed bundle — this is compiled-in data, so a failure is a build bug.
pub fn builtin_recipes() -> HashMap<String, Recipe> {
let specs: HashMap<String, RecipeSpec> =
toml::from_str(RECIPES_TOML).expect("bundled recipes.toml must parse");
specs
.into_iter()
.map(|(name, spec)| {
let fragment = embedded_fragment(&spec.fragment).unwrap_or_else(|| {
panic!("built-in recipe '{name}' references unknown fragment '{}'", spec.fragment)
});
let recipe = Recipe {
name: name.clone(),
fragment: FragmentSource::Embedded(fragment),
shadow: spec.shadow,
shared_caches: spec.shared_caches,
};
(name, recipe)
})
.collect()
}
#[derive(Debug, Error, Diagnostic)]
pub enum RecipeError {
#[error("toolchain '{name}' has no built-in recipe")]
#[diagnostic(
code(sandcage::recipe::no_builtin),
help("Provide a [toolchains.{name}] table with a `fragment` path, or remove it.")
)]
NoBuiltin { name: String },
#[error("custom toolchain '{name}' has an invalid table: {reason}")]
#[diagnostic(code(sandcage::recipe::invalid_spec))]
InvalidSpec { name: String, reason: String },
#[error("fragment for toolchain '{name}' not found at {path}")]
#[diagnostic(
code(sandcage::recipe::fragment_not_found),
help("Check the `fragment` path in [toolchains.{name}]; it is resolved relative to the project root.")
)]
FragmentNotFound { name: String, path: PathBuf },
}
/// Resolve `[toolchains]` config into a list of recipes.
///
/// - `name = true` → built-in recipe (error if none exists).
/// - `name = false` → disabled, skipped.
/// - `[toolchains.name]` table → custom recipe, fully overriding any built-in of the same name.
/// The `fragment` path is resolved relative to `project_root` and must exist.
///
/// Iteration order is sorted by name so output is deterministic (the input is a HashMap).
pub fn resolve_recipes(
toolchains: &HashMap<String, toml::Value>,
project_root: &Path,
) -> Result<Vec<Recipe>, RecipeError> {
let builtins = builtin_recipes();
let mut names: Vec<&String> = toolchains.keys().collect();
names.sort();
let mut out = Vec::new();
for name in names {
let value = &toolchains[name];
match value {
toml::Value::Boolean(false) => continue,
toml::Value::Boolean(true) => {
let recipe = builtins
.get(name)
.cloned()
.ok_or_else(|| RecipeError::NoBuiltin { name: name.clone() })?;
out.push(recipe);
}
toml::Value::Table(_) => {
let spec: RecipeSpec = value.clone().try_into().map_err(|e| {
RecipeError::InvalidSpec { name: name.clone(), reason: e.to_string() }
})?;
let frag_path = project_root.join(&spec.fragment);
if !frag_path.exists() {
return Err(RecipeError::FragmentNotFound {
name: name.clone(),
path: frag_path,
});
}
out.push(Recipe {
name: name.clone(),
fragment: FragmentSource::Path(frag_path),
shadow: spec.shadow,
shared_caches: spec.shared_caches,
});
}
// Strings and other scalar truthy forms: treat like `true` for back-compat
// with the previous `match value` logic (String meant "enabled").
toml::Value::String(_) => {
let recipe = builtins
.get(name)
.cloned()
.ok_or_else(|| RecipeError::NoBuiltin { name: name.clone() })?;
out.push(recipe);
}
_ => continue,
}
}
Ok(out)
}
impl Recipe {
/// The Dockerfile fragment text for this recipe.
pub fn fragment_text(&self) -> Result<String, RecipeError> {
match &self.fragment {
FragmentSource::Embedded(s) => Ok((*s).to_string()),
FragmentSource::Path(p) => std::fs::read_to_string(p).map_err(|_| {
RecipeError::FragmentNotFound { name: self.name.clone(), path: p.clone() }
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn fragment_source_variants_construct() {
let embedded = FragmentSource::Embedded("FROM scratch");
let path = FragmentSource::Path(PathBuf::from(".sandcage/fragments/zig.Dockerfile"));
assert_ne!(embedded, path);
}
#[test]
fn recipe_holds_fields() {
let r = Recipe {
name: "rust".to_string(),
fragment: FragmentSource::Embedded("FROM rust"),
shadow: vec!["target".to_string()],
shared_caches: vec![".cargo/registry".to_string()],
};
assert_eq!(r.name, "rust");
assert_eq!(r.shadow, vec!["target".to_string()]);
assert_eq!(r.shared_caches, vec![".cargo/registry".to_string()]);
}
#[test]
fn builtin_recipes_load_uv_nvm_rust() {
let builtins = builtin_recipes();
assert!(builtins.contains_key("uv"), "uv built-in must exist");
assert!(builtins.contains_key("nvm"), "nvm built-in must exist");
assert!(builtins.contains_key("rust"), "rust built-in must exist");
let rust = &builtins["rust"];
assert_eq!(rust.shadow, vec!["target".to_string()]);
assert_eq!(rust.shared_caches, vec![".cargo/registry".to_string()]);
// Built-ins resolve to embedded fragment content.
match &rust.fragment {
FragmentSource::Embedded(s) => assert!(s.contains("rustup"), "rust fragment should mention rustup"),
other => panic!("built-in fragment must be Embedded, got {other:?}"),
}
}
use std::collections::HashMap as Map;
fn toolchains(pairs: &[(&str, toml::Value)]) -> Map<String, toml::Value> {
pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
}
#[test]
fn resolve_enables_builtin_on_true() {
let tc = toolchains(&[("rust", toml::Value::Boolean(true))]);
let recipes = resolve_recipes(&tc, std::path::Path::new("/proj")).expect("resolves");
assert_eq!(recipes.len(), 1);
assert_eq!(recipes[0].name, "rust");
assert!(matches!(recipes[0].fragment, FragmentSource::Embedded(_)));
}
#[test]
fn resolve_skips_disabled_builtin() {
let tc = toolchains(&[("rust", toml::Value::Boolean(false))]);
let recipes = resolve_recipes(&tc, std::path::Path::new("/proj")).expect("resolves");
assert!(recipes.is_empty(), "rust = false must not produce a recipe");
}
#[test]
fn resolve_true_for_unknown_name_errors() {
let tc = toolchains(&[("zig", toml::Value::Boolean(true))]);
let err = resolve_recipes(&tc, std::path::Path::new("/proj")).unwrap_err();
assert!(
matches!(err, RecipeError::NoBuiltin { ref name } if name == "zig"),
"expected NoBuiltin for zig, got {err:?}"
);
}
#[test]
fn resolve_custom_table_overrides_with_existing_fragment() {
let dir = tempfile::tempdir().unwrap();
let frag = dir.path().join("zig.Dockerfile");
std::fs::write(&frag, "RUN install zig\n").unwrap();
let mut table = toml::value::Table::new();
table.insert("fragment".into(), toml::Value::String("zig.Dockerfile".into()));
table.insert(
"shadow".into(),
toml::Value::Array(vec![toml::Value::String("zig-cache".into())]),
);
let tc = toolchains(&[("zig", toml::Value::Table(table))]);
let recipes = resolve_recipes(&tc, dir.path()).expect("resolves custom");
assert_eq!(recipes.len(), 1);
assert_eq!(recipes[0].name, "zig");
assert_eq!(recipes[0].shadow, vec!["zig-cache".to_string()]);
assert_eq!(recipes[0].fragment, FragmentSource::Path(frag));
}
#[test]
fn resolve_custom_table_missing_fragment_errors() {
let dir = tempfile::tempdir().unwrap();
let mut table = toml::value::Table::new();
table.insert("fragment".into(), toml::Value::String("nope.Dockerfile".into()));
let tc = toolchains(&[("zig", toml::Value::Table(table))]);
let err = resolve_recipes(&tc, dir.path()).unwrap_err();
assert!(
matches!(err, RecipeError::FragmentNotFound { .. }),
"expected FragmentNotFound, got {err:?}"
);
}
#[test]
fn fragment_text_reads_embedded_and_path() {
let embedded = Recipe {
name: "uv".into(),
fragment: FragmentSource::Embedded("FROM uv-base"),
shadow: vec![],
shared_caches: vec![],
};
assert_eq!(embedded.fragment_text().unwrap(), "FROM uv-base");
let dir = tempfile::tempdir().unwrap();
let frag = dir.path().join("z.Dockerfile");
std::fs::write(&frag, "RUN custom\n").unwrap();
let custom = Recipe {
name: "zig".into(),
fragment: FragmentSource::Path(frag),
shadow: vec![],
shared_caches: vec![],
};
assert_eq!(custom.fragment_text().unwrap(), "RUN custom\n");
}
}
+166
View File
@@ -0,0 +1,166 @@
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
WORKDIR="$(pwd)"
cd "$HOME"
curl -fsSL https://claude.ai/install.sh | bash >&2
cd "$WORKDIR"
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
WORKDIR="$(pwd)"
cd "$HOME"
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"
cd "$WORKDIR"
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
WORKDIR="$(pwd)"
cd "$HOME"
npm install -g @anthropic-ai/gemini-cli >&2
cd "$WORKDIR"
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"));
}
}
+154
View File
@@ -0,0 +1,154 @@
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"),
image: "sandcage".to_string(),
config_root: PathBuf::from("/home/user/projects/my-app"),
forwarding_socket: None,
agent_home: PathBuf::from("/home/user/.sandcage/homes/default"),
}
}
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/homes/default:/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");
}
}
}
+266
View File
@@ -0,0 +1,266 @@
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,
pub image: String,
/// Directory containing .sandcage/ (may differ from workspace in worktrees)
pub config_root: PathBuf,
/// Unix socket for the forwarding daemon (bind-mounted into container).
pub forwarding_socket: Option<PathBuf>,
/// Resolved path to the agent home directory on the host
/// (e.g. `~/.sandcage/homes/work/`).
pub agent_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>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tmpfs: 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: format!("{}:latest", ctx.image),
entrypoint,
working_dir: ctx.container_dir.clone(),
user: format!("{}:{}", ctx.uid, ctx.gid),
volumes: vec![
format!("{}:/home/agent", ctx.agent_home.display()),
format!(
"{}:{}",
ctx.workspace.display(),
ctx.container_dir
),
],
environment: vec!["HOME=/home/agent".to_string()],
tmpfs: vec![format!("{}/.sandcage", ctx.container_dir)],
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"),
agent_home: PathBuf::from("/home/user/.sandcage/homes/default"),
container_dir: "/workspace/my-app".to_string(),
sandcage_home: PathBuf::from("/home/user/.sandcage"),
image: "sandcage".to_string(),
config_root: PathBuf::from("/home/user/projects/my-app"),
forwarding_socket: None,
}
}
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("/homes/default:/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"]);
}
#[test]
fn compose_service_uses_configured_image() {
let svc = agent_service();
let mut ctx = test_context();
ctx.image = "custom-img".to_string();
let compose = svc.compose_service(&ctx);
assert_eq!(compose.image, "custom-img:latest");
}
#[test]
fn compose_service_excludes_sandcage_dir_via_tmpfs() {
let svc = agent_service();
let ctx = test_context();
let compose = svc.compose_service(&ctx);
assert_eq!(compose.tmpfs, vec!["/workspace/my-app/.sandcage"]);
}
}
+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());
}
}
File diff suppressed because it is too large Load Diff
+314
View File
@@ -0,0 +1,314 @@
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use crate::config::ShadowConfig;
use crate::recipe::Recipe;
#[derive(Debug, Clone, PartialEq)]
pub enum ShadowBackend {
Volume,
Directory,
Tmpfs,
}
impl ShadowBackend {
fn parse(s: &str) -> ShadowBackend {
match s {
"directory" => ShadowBackend::Directory,
"tmpfs" => ShadowBackend::Tmpfs,
_ => ShadowBackend::Volume,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ShadowMount {
pub dir: String,
pub container_path: String,
pub backend: ShadowBackend,
pub volume_name: Option<String>,
pub host_path: Option<PathBuf>,
}
/// Flatten enabled recipes into `(dir, shared)` shadow pairs.
pub fn recipe_shadow_dirs(recipes: &[Recipe]) -> Vec<(String, bool)> {
let mut out = Vec::new();
for r in recipes {
for d in &r.shadow {
out.push((d.clone(), false));
}
for d in &r.shared_caches {
out.push((d.clone(), true));
}
}
out
}
/// Resolve effective shadow mounts from explicit `[shadow]` config + recipe dirs.
/// Explicit `[shadow.dirs]` overrides a recipe dir of the same name. Default backend = volume.
/// `project_root` is the config root (where `.sandcage/` lives).
pub fn resolve_shadow_mounts(
shadow: Option<&ShadowConfig>,
recipe_dirs: &[(String, bool)],
project_root: &Path,
container_dir: &str,
) -> Vec<ShadowMount> {
let default_backend = shadow
.and_then(|s| s.backend.as_deref())
.map(ShadowBackend::parse)
.unwrap_or(ShadowBackend::Volume);
// dir → (explicit backend override, shared). BTreeMap = deterministic order.
let mut merged: std::collections::BTreeMap<String, (Option<ShadowBackend>, bool)> =
std::collections::BTreeMap::new();
for (dir, shared) in recipe_dirs {
merged.insert(dir.clone(), (None, *shared));
}
if let Some(cfg) = shadow {
if let Some(dirs) = &cfg.dirs {
for (dir, spec) in dirs {
merged.insert(dir.clone(), (spec.backend.as_deref().map(ShadowBackend::parse), spec.shared));
}
}
}
merged
.into_iter()
.map(|(dir, (backend_override, shared))| {
let backend = backend_override.unwrap_or_else(|| default_backend.clone());
let container_path = format!("{}/{}", container_dir.trim_end_matches('/'), dir);
let (vol_name, host_path) = match backend {
ShadowBackend::Volume => (Some(volume_name(project_root, &dir, shared)), None),
ShadowBackend::Directory => (
None,
Some(project_root.join(".sandcage").join("shadow").join(normalize_dir(&dir))),
),
ShadowBackend::Tmpfs => (None, None),
};
ShadowMount { dir, container_path, backend, volume_name: vol_name, host_path }
})
.collect()
}
/// Normalize a shadow dir name into Docker's allowed volume-name charset.
/// Leading dots stripped; non-`[a-zA-Z0-9_]` chars become `-`.
pub fn normalize_dir(dir: &str) -> String {
let trimmed = dir.trim_start_matches('.');
let mut out = String::with_capacity(trimmed.len());
let mut prev_dash = false;
for ch in trimmed.chars() {
if ch.is_ascii_alphanumeric() || ch == '_' {
out.push(ch);
prev_dash = false;
} else if !prev_dash {
out.push('-');
prev_dash = true;
}
}
out.trim_matches('-').to_string()
}
/// Human-legible project slug: basename, lowercased, sanitized.
pub fn project_slug(project_root: &Path) -> String {
let base = project_root
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "project".to_string());
normalize_dir(&base.to_lowercase())
}
/// Short (8 hex) hash of the absolute project path — uniqueness across same-named projects.
pub fn project_hash(project_root: &Path) -> String {
let abs = std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
let mut hasher = Sha256::new();
hasher.update(abs.to_string_lossy().as_bytes());
let digest = hasher.finalize();
hex::encode(&digest[..4])
}
/// Docker volume name for a shadow dir.
pub fn volume_name(project_root: &Path, dir: &str, shared: bool) -> String {
let d = normalize_dir(dir);
if shared {
format!("sandcage-shared-{d}")
} else {
format!("sandcage-{}-{}-{}", project_slug(project_root), project_hash(project_root), d)
}
}
/// Bind / `-v` strings for `volume` (named) and `directory` (host bind) mounts.
/// A `name:path` source with no leading `/` is treated by Docker as a named volume
/// (auto-created); a host path is a bind. Tmpfs mounts are excluded.
pub fn shadow_bind_strings(mounts: &[ShadowMount]) -> Vec<String> {
mounts
.iter()
.filter_map(|m| match m.backend {
ShadowBackend::Volume => {
Some(format!("{}:{}", m.volume_name.as_deref().unwrap(), m.container_path))
}
ShadowBackend::Directory => {
Some(format!("{}:{}:rw", m.host_path.as_ref().unwrap().display(), m.container_path))
}
ShadowBackend::Tmpfs => None,
})
.collect()
}
/// Container paths for `tmpfs`-backend mounts.
pub fn shadow_tmpfs_paths(mounts: &[ShadowMount]) -> Vec<String> {
mounts
.iter()
.filter(|m| matches!(m.backend, ShadowBackend::Tmpfs))
.map(|m| m.container_path.clone())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
use crate::config::{ShadowConfig, ShadowDir};
use crate::recipe::{Recipe, FragmentSource};
use std::collections::HashMap;
fn shadow_cfg(backend: Option<&str>, dirs: &[(&str, Option<&str>, bool)]) -> ShadowConfig {
let mut map = HashMap::new();
for (name, b, shared) in dirs {
map.insert(name.to_string(), ShadowDir { backend: b.map(|s| s.to_string()), shared: *shared });
}
ShadowConfig { backend: backend.map(|s| s.to_string()), dirs: if map.is_empty() { None } else { Some(map) } }
}
#[test]
fn resolves_default_backend_volume() {
let root = Path::new("/home/u/projects/app");
let cfg = shadow_cfg(None, &[("target", None, false)]);
let mounts = resolve_shadow_mounts(Some(&cfg), &[], root, "/workspace/app");
assert_eq!(mounts.len(), 1);
assert_eq!(mounts[0].container_path, "/workspace/app/target");
assert!(matches!(mounts[0].backend, ShadowBackend::Volume));
assert_eq!(mounts[0].volume_name.as_deref().unwrap(), volume_name(root, "target", false));
}
#[test]
fn resolves_per_dir_directory_backend() {
let root = Path::new("/home/u/projects/app");
let cfg = shadow_cfg(Some("volume"), &[(".venv", Some("directory"), false)]);
let mounts = resolve_shadow_mounts(Some(&cfg), &[], root, "/workspace/app");
assert!(matches!(mounts[0].backend, ShadowBackend::Directory));
assert_eq!(
mounts[0].host_path.as_deref().unwrap(),
Path::new("/home/u/projects/app/.sandcage/shadow/venv")
);
}
#[test]
fn resolves_tmpfs_backend() {
let root = Path::new("/home/u/projects/app");
let cfg = shadow_cfg(Some("tmpfs"), &[("target", None, false)]);
let mounts = resolve_shadow_mounts(Some(&cfg), &[], root, "/workspace/app");
assert!(matches!(mounts[0].backend, ShadowBackend::Tmpfs));
assert!(mounts[0].volume_name.is_none() && mounts[0].host_path.is_none());
}
#[test]
fn shared_dir_uses_shared_volume_name() {
let root = Path::new("/home/u/projects/app");
let cfg = shadow_cfg(None, &[(".cargo/registry", None, true)]);
let mounts = resolve_shadow_mounts(Some(&cfg), &[], root, "/workspace/app");
assert_eq!(mounts[0].volume_name.as_deref().unwrap(), "sandcage-shared-cargo-registry");
}
#[test]
fn recipe_dirs_flatten_and_resolve() {
let root = Path::new("/home/u/projects/app");
let recipes = vec![Recipe {
name: "rust".into(),
fragment: FragmentSource::Embedded("FROM rust"),
shadow: vec!["target".into()],
shared_caches: vec![".cargo/registry".into()],
}];
let pairs = recipe_shadow_dirs(&recipes);
assert!(pairs.contains(&("target".to_string(), false)));
assert!(pairs.contains(&(".cargo/registry".to_string(), true)));
let mounts = resolve_shadow_mounts(None, &pairs, root, "/workspace/app");
assert_eq!(mounts.len(), 2);
}
#[test]
fn explicit_overrides_recipe_dir_backend() {
let root = Path::new("/home/u/projects/app");
let recipe_dirs = vec![("target".to_string(), false)];
let cfg = shadow_cfg(None, &[("target", Some("directory"), false)]);
let mounts = resolve_shadow_mounts(Some(&cfg), &recipe_dirs, root, "/workspace/app");
assert_eq!(mounts.len(), 1, "same dir name de-duplicated");
assert!(matches!(mounts[0].backend, ShadowBackend::Directory));
}
#[test]
fn no_config_no_recipes_yields_empty() {
let mounts = resolve_shadow_mounts(None, &[], Path::new("/x"), "/workspace/x");
assert!(mounts.is_empty());
}
fn sample_mounts() -> Vec<ShadowMount> {
vec![
ShadowMount { dir: "target".into(), container_path: "/workspace/app/target".into(),
backend: ShadowBackend::Volume, volume_name: Some("sandcage-app-dead-target".into()), host_path: None },
ShadowMount { dir: ".venv".into(), container_path: "/workspace/app/.venv".into(),
backend: ShadowBackend::Directory, volume_name: None,
host_path: Some(Path::new("/home/u/projects/app/.sandcage/shadow/venv").to_path_buf()) },
ShadowMount { dir: "cache".into(), container_path: "/workspace/app/cache".into(),
backend: ShadowBackend::Tmpfs, volume_name: None, host_path: None },
]
}
#[test]
fn bind_strings_cover_volume_and_directory_only() {
let binds = shadow_bind_strings(&sample_mounts());
assert_eq!(binds.len(), 2);
assert!(binds.contains(&"sandcage-app-dead-target:/workspace/app/target".to_string()));
assert!(binds.contains(&"/home/u/projects/app/.sandcage/shadow/venv:/workspace/app/.venv:rw".to_string()));
}
#[test]
fn tmpfs_paths_cover_tmpfs_only() {
let paths = shadow_tmpfs_paths(&sample_mounts());
assert_eq!(paths, vec!["/workspace/app/cache".to_string()]);
}
#[test]
fn normalize_dir_sanitizes_path_separators_and_dots() {
assert_eq!(normalize_dir(".venv"), "venv");
assert_eq!(normalize_dir("target"), "target");
assert_eq!(normalize_dir(".cargo/registry"), "cargo-registry");
assert_eq!(normalize_dir("node_modules"), "node_modules");
}
#[test]
fn project_slug_is_basename_sanitized() {
assert_eq!(project_slug(Path::new("/home/u/projects/My-App")), "my-app");
assert_eq!(project_slug(Path::new("/home/u/projects/api.service")), "api-service");
}
#[test]
fn project_hash_is_stable_and_path_specific() {
let a = project_hash(Path::new("/home/u/projects/api"));
let b = project_hash(Path::new("/home/u/projects/api"));
let c = project_hash(Path::new("/home/u/other/api"));
assert_eq!(a, b);
assert_ne!(a, c);
assert_eq!(a.len(), 8);
}
#[test]
fn volume_name_per_project_and_shared() {
let root = Path::new("/home/u/projects/my-app");
let per = volume_name(root, "target", false);
assert!(per.starts_with("sandcage-my-app-"), "got {per}");
assert!(per.ends_with("-target"), "got {per}");
assert_eq!(volume_name(root, ".cargo/registry", true), "sandcage-shared-cargo-registry");
}
}
+170
View File
@@ -0,0 +1,170 @@
use std::collections::HashMap;
use std::path::Path;
use miette::Diagnostic;
use thiserror::Error;
use crate::config::ToolConfig;
/// Container directory baked onto the front of PATH in the base image.
pub const SPOOF_CONTAINER_DIR: &str = "/usr/local/sandcage/spoof";
#[derive(Debug, Error, Diagnostic)]
pub enum SpoofError {
#[error("Failed to write spoof script {0}: {1}")]
#[diagnostic(code(sandcage::spoof::write_failed))]
WriteFailed(std::path::PathBuf, #[source] std::io::Error),
}
/// Generate spoof / host-stub scripts for the given tools into `dir`.
/// Returns the number of scripts written. `in-cage` tools produce nothing.
pub fn generate_spoof_scripts(
tools: &HashMap<String, ToolConfig>,
dir: &Path,
) -> Result<usize, SpoofError> {
let mut written = 0;
for (name, cfg) in tools {
let mode = cfg.mode.as_deref().map(ToolMode::parse).unwrap_or(ToolMode::InCage);
let body = match mode {
ToolMode::InCage => continue,
ToolMode::Spoof => {
let message = cfg.message.clone().unwrap_or_else(|| {
format!("`{name}` is intercepted by sandcage for this project.")
});
spoof_script(&message)
}
ToolMode::Host => host_forwarding_script(name),
};
let path = dir.join(name);
std::fs::write(&path, body).map_err(|e| SpoofError::WriteFailed(path.clone(), e))?;
set_executable(&path).map_err(|e| SpoofError::WriteFailed(path.clone(), e))?;
written += 1;
}
Ok(written)
}
#[cfg(unix)]
fn set_executable(path: &Path) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(path, perms)
}
#[cfg(not(unix))]
fn set_executable(_path: &Path) -> std::io::Result<()> {
Ok(()) // execute bit is meaningless on non-unix hosts; the container is Linux
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolMode {
InCage,
Spoof,
Host,
}
impl ToolMode {
pub fn parse(s: &str) -> ToolMode {
match s {
"spoof" => ToolMode::Spoof,
"host" => ToolMode::Host,
_ => ToolMode::InCage,
}
}
}
/// A spoof script: prints `message` to stderr and exits non-zero (never 127).
/// The message is embedded in a single-quoted heredoc so backticks, `$`, and
/// quotes in it stay literal and are never executed by the shell.
pub fn spoof_script(message: &str) -> String {
format!(
"#!/usr/bin/env bash\ncat >&2 <<'SANDCAGE_SPOOF_EOF'\n{message}\nSANDCAGE_SPOOF_EOF\nexit 1\n"
)
}
/// A `host`-mode script that forwards execution to the thumper relay.
pub fn host_forwarding_script(tool: &str) -> String {
format!("#!/usr/bin/env bash\nexec thumper {tool} \"$@\"\n")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ToolConfig;
use std::collections::HashMap;
fn tool(mode: &str, message: Option<&str>) -> ToolConfig {
ToolConfig {
mode: Some(mode.to_string()),
message: message.map(|s| s.to_string()),
allow: vec![],
deny: vec![],
}
}
#[test]
fn generate_writes_only_spoof_and_host_scripts() {
let dir = tempfile::tempdir().unwrap();
let mut tools = HashMap::new();
tools.insert("cargo".to_string(), tool("spoof", Some("Use `just build`.")));
tools.insert("docker".to_string(), tool("host", None));
tools.insert("uv".to_string(), tool("in-cage", None));
let count = generate_spoof_scripts(&tools, dir.path()).expect("generate");
assert_eq!(count, 2, "spoof + host produce scripts; in-cage does not");
let cargo = dir.path().join("cargo");
assert!(cargo.exists(), "cargo spoof script written");
let body = std::fs::read_to_string(&cargo).unwrap();
assert!(body.contains("Use `just build`."));
assert!(!dir.path().join("uv").exists(), "in-cage tool has no script");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&cargo).unwrap().permissions().mode();
assert!(mode & 0o111 != 0, "script should be executable");
}
}
#[test]
fn generate_uses_default_message_when_absent() {
let dir = tempfile::tempdir().unwrap();
let mut tools = HashMap::new();
tools.insert("npm".to_string(), tool("spoof", None));
generate_spoof_scripts(&tools, dir.path()).expect("generate");
let body = std::fs::read_to_string(dir.path().join("npm")).unwrap();
assert!(body.contains("npm"), "default message mentions the tool name");
}
#[test]
fn parse_mode_maps_strings() {
assert_eq!(ToolMode::parse("in-cage"), ToolMode::InCage);
assert_eq!(ToolMode::parse("spoof"), ToolMode::Spoof);
assert_eq!(ToolMode::parse("host"), ToolMode::Host);
// unknown defaults to in-cage (least surprising: tool runs normally)
assert_eq!(ToolMode::parse("wat"), ToolMode::InCage);
}
#[test]
fn spoof_script_embeds_message_safely_and_is_not_127() {
let msg = "Use `just build` to compile this $PROJECT.";
let script = spoof_script(msg);
assert!(script.starts_with("#!/usr/bin/env bash\n"));
assert!(script.contains(msg), "message embedded verbatim");
// single-quoted heredoc → backticks and $ stay literal (not executed)
assert!(script.contains("<<'SANDCAGE_SPOOF_EOF'"));
assert!(script.contains("exit 1"));
assert!(!script.contains("exit 127"), "must never imply command-not-found");
}
#[test]
fn host_forwarding_script_invokes_thumper() {
let script = host_forwarding_script("docker");
assert!(script.starts_with("#!/usr/bin/env bash\n"));
assert!(script.contains("thumper"), "should invoke thumper");
assert!(script.contains("docker"), "should pass the tool name");
assert!(script.contains("\"$@\""), "should forward all args");
}
}
+449
View File
@@ -0,0 +1,449 @@
use std::path::{Path, PathBuf};
/// A parsed SSH config `Host` block.
#[derive(Debug, Clone, PartialEq)]
pub struct SshHostBlock {
/// The patterns listed on the `Host` line (e.g. `["github.com", "gh"]`).
pub aliases: Vec<String>,
/// Key-value directives in the order they appear in the config.
pub directives: Vec<(String, String)>,
}
impl SshHostBlock {
/// Return the value of the first directive matching `key` (case-insensitive).
pub fn get(&self, key: &str) -> Option<&str> {
let key_lower = key.to_lowercase();
self.directives
.iter()
.find(|(k, _)| k.to_lowercase() == key_lower)
.map(|(_, v)| v.as_str())
}
/// Return the `User` directive value, if present.
pub fn user(&self) -> Option<&str> {
self.get("User")
}
/// Return the `IdentityFile` directive value, if present.
pub fn identity_file(&self) -> Option<&str> {
self.get("IdentityFile")
}
/// Return the `HostName` directive value, if present.
pub fn hostname(&self) -> Option<&str> {
self.get("HostName")
}
/// Returns `true` if this block's `User` is `git` (common for GitHub/GitLab host entries).
pub fn is_git_user(&self) -> bool {
self.user().map(|u| u == "git").unwrap_or(false)
}
}
/// Resolve an SSH `IdentityFile` path to an absolute [`PathBuf`].
///
/// - `~/...` paths expand `~` via [`dirs::home_dir`], falling back to `ssh_dir.parent()`.
/// - Relative paths are joined onto `ssh_dir`.
/// - Absolute paths are returned as-is.
pub fn resolve_identity_file(identity_file: &str, ssh_dir: &Path) -> PathBuf {
if let Some(suffix) = identity_file.strip_prefix("~/") {
let home = dirs::home_dir()
.unwrap_or_else(|| ssh_dir.parent().unwrap_or(ssh_dir).to_path_buf());
home.join(suffix)
} else if Path::new(identity_file).is_absolute() {
PathBuf::from(identity_file)
} else {
ssh_dir.join(identity_file)
}
}
/// Produce a valid SSH config file string from a slice of [`SshHostBlock`]s.
///
/// Blocks are separated by blank lines; directives are indented with 4 spaces.
pub fn synthesize_ssh_config(blocks: &[SshHostBlock]) -> String {
let mut out = String::new();
for (i, block) in blocks.iter().enumerate() {
if i > 0 {
out.push('\n');
}
out.push_str("Host ");
out.push_str(&block.aliases.join(" "));
out.push('\n');
for (key, value) in &block.directives {
out.push_str(" ");
out.push_str(key);
out.push(' ');
out.push_str(value);
out.push('\n');
}
}
out
}
/// Parse SSH config text into a list of [`SshHostBlock`]s, supporting `Include` directives.
///
/// When an `Include <path>` line is encountered:
/// - The current block is flushed.
/// - `read_include(path)` is called; if it returns `Some(content)`, that content is parsed
/// recursively and its blocks are appended.
/// - Parsing of the main content then continues.
///
/// Pass `&|_| None` to disable Include handling (identical to [`parse_ssh_config`]).
pub fn parse_ssh_config_with_includes(
content: &str,
read_include: &dyn Fn(&str) -> Option<String>,
) -> Vec<SshHostBlock> {
let mut blocks: Vec<SshHostBlock> = Vec::new();
// None → no active block yet; Some(true) → Host block; Some(false) → Match block (skip)
let mut current_aliases: Option<Vec<String>> = None;
let mut current_directives: Vec<(String, String)> = Vec::new();
let mut in_host_block = false;
let flush = |blocks: &mut Vec<SshHostBlock>,
aliases: &mut Option<Vec<String>>,
directives: &mut Vec<(String, String)>,
in_host: &mut bool| {
if *in_host {
if let Some(a) = aliases.take() {
blocks.push(SshHostBlock {
aliases: a,
directives: std::mem::take(directives),
});
}
} else {
aliases.take();
directives.clear();
}
*in_host = false;
};
for line in content.lines() {
let trimmed = line.trim();
// Skip blank lines and comments
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
// Split keyword from the rest on first whitespace or '='
let (keyword, rest) = split_keyword(trimmed);
match keyword.to_lowercase().as_str() {
"host" => {
// Flush previous block
flush(
&mut blocks,
&mut current_aliases,
&mut current_directives,
&mut in_host_block,
);
let aliases: Vec<String> = rest
.split_whitespace()
.map(|s| s.to_string())
.collect();
current_aliases = Some(aliases);
in_host_block = true;
}
"match" => {
// Flush previous block, then enter skip mode
flush(
&mut blocks,
&mut current_aliases,
&mut current_directives,
&mut in_host_block,
);
current_aliases = Some(Vec::new()); // placeholder so flush knows there's a block
in_host_block = false;
}
"include" => {
// Flush the current block before processing the include
flush(
&mut blocks,
&mut current_aliases,
&mut current_directives,
&mut in_host_block,
);
if let Some(included_content) = read_include(rest.trim()) {
let included_blocks =
parse_ssh_config_with_includes(&included_content, read_include);
blocks.extend(included_blocks);
}
}
_ => {
if in_host_block && current_aliases.is_some() {
current_directives.push((keyword.to_string(), rest.to_string()));
}
// In Match block or before any block → ignore
}
}
}
// Flush last block
flush(
&mut blocks,
&mut current_aliases,
&mut current_directives,
&mut in_host_block,
);
blocks
}
/// Parse SSH config text into a list of [`SshHostBlock`]s.
///
/// - Only `Host` blocks are returned; `Match` blocks are skipped entirely.
/// - Comments (`#`) and blank lines are ignored.
/// - Directive lines are split on the first run of whitespace or `=`.
/// - Multiple aliases on a single `Host` line are all captured.
/// - `Include` directives are ignored (use [`parse_ssh_config_with_includes`] to handle them).
pub fn parse_ssh_config(content: &str) -> Vec<SshHostBlock> {
parse_ssh_config_with_includes(content, &|_| None)
}
/// Split a trimmed SSH config line into (keyword, value).
///
/// The separator is the first `=` (with optional surrounding whitespace) or the first
/// run of whitespace — whichever comes first.
fn split_keyword(line: &str) -> (String, String) {
// Find position of first '=' or first whitespace character
let eq_pos = line.find('=');
let ws_pos = line.find(|c: char| c.is_ascii_whitespace());
let sep_pos = match (eq_pos, ws_pos) {
(Some(e), Some(w)) => Some(e.min(w)),
(Some(e), None) => Some(e),
(None, Some(w)) => Some(w),
(None, None) => None,
};
match sep_pos {
None => (line.to_string(), String::new()),
Some(pos) => {
let keyword = line[..pos].to_string();
// Skip separator characters (whitespace and '=')
let rest = line[pos..].trim_start_matches(|c: char| c == '=' || c.is_ascii_whitespace());
(keyword, rest.to_string())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_single_host_block() {
let config = "\
Host github.com
User git
IdentityFile ~/.ssh/id_ed25519
HostName github.com
";
let blocks = parse_ssh_config(config);
assert_eq!(blocks.len(), 1);
let b = &blocks[0];
assert_eq!(b.aliases, vec!["github.com"]);
assert_eq!(b.user(), Some("git"));
assert_eq!(b.identity_file(), Some("~/.ssh/id_ed25519"));
assert_eq!(b.hostname(), Some("github.com"));
}
#[test]
fn parse_multiple_blocks_filter_git_user() {
let config = "\
Host work-gitlab
HostName gitlab.mycompany.com
User git
IdentityFile ~/.ssh/id_work
Host personal
HostName github.com
User git
IdentityFile ~/.ssh/id_personal
Host bastion
HostName bastion.mycompany.com
User admin
IdentityFile ~/.ssh/id_bastion
";
let blocks = parse_ssh_config(config);
assert_eq!(blocks.len(), 3);
let git_blocks: Vec<_> = blocks.iter().filter(|b| b.is_git_user()).collect();
assert_eq!(git_blocks.len(), 2);
let git_aliases: Vec<&str> = git_blocks.iter().map(|b| b.aliases[0].as_str()).collect();
assert!(git_aliases.contains(&"work-gitlab"));
assert!(git_aliases.contains(&"personal"));
let non_git: Vec<_> = blocks.iter().filter(|b| !b.is_git_user()).collect();
assert_eq!(non_git.len(), 1);
assert_eq!(non_git[0].aliases[0], "bastion");
}
#[test]
fn parse_host_with_multiple_aliases() {
let config = "\
Host github.com gh
User git
IdentityFile ~/.ssh/id_github
";
let blocks = parse_ssh_config(config);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].aliases, vec!["github.com", "gh"]);
}
#[test]
fn skip_match_blocks() {
let config = "\
Host first
User alice
Match host internal.example.com
ProxyJump bastion
Host second
User bob
";
let blocks = parse_ssh_config(config);
assert_eq!(blocks.len(), 2);
assert_eq!(blocks[0].aliases, vec!["first"]);
assert_eq!(blocks[0].user(), Some("alice"));
assert_eq!(blocks[1].aliases, vec!["second"]);
assert_eq!(blocks[1].user(), Some("bob"));
}
#[test]
fn handle_tabs_and_varied_indentation() {
let config = "Host tabbed\n\tUser git\n\tIdentityFile\t~/.ssh/id_tab\n";
let blocks = parse_ssh_config(config);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].user(), Some("git"));
assert_eq!(blocks[0].identity_file(), Some("~/.ssh/id_tab"));
}
#[test]
fn ignore_comments_and_blank_lines() {
let config = "\
# Global settings comment
# Host section
Host example.com
# inline comment
User git
IdentityFile ~/.ssh/id_example
";
let blocks = parse_ssh_config(config);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].aliases, vec!["example.com"]);
assert_eq!(blocks[0].user(), Some("git"));
assert_eq!(blocks[0].identity_file(), Some("~/.ssh/id_example"));
// The inline comment line should not appear as a directive
assert!(!blocks[0].directives.iter().any(|(k, _)| k.starts_with('#')));
}
#[test]
fn empty_config() {
let blocks = parse_ssh_config("");
assert!(blocks.is_empty());
}
#[test]
fn comments_only() {
let config = "\
# This is a comment
# Another comment
# Yet another
";
let blocks = parse_ssh_config(config);
assert!(blocks.is_empty());
}
// --- Task 2: identity file resolution ---
#[test]
fn resolve_identity_file_tilde() {
let ssh_dir = Path::new("/home/user/.ssh");
let path = resolve_identity_file("~/.ssh/id_ed25519", ssh_dir);
let home = dirs::home_dir().unwrap();
assert_eq!(path, home.join(".ssh/id_ed25519"));
}
#[test]
fn resolve_identity_file_relative() {
let path = resolve_identity_file("id_ed25519", Path::new("/home/user/.ssh"));
assert_eq!(path, PathBuf::from("/home/user/.ssh/id_ed25519"));
}
#[test]
fn resolve_identity_file_absolute() {
let path = resolve_identity_file("/etc/ssh/key", Path::new("/home/user/.ssh"));
assert_eq!(path, PathBuf::from("/etc/ssh/key"));
}
#[test]
fn synthesize_config_from_blocks() {
let blocks = vec![
SshHostBlock {
aliases: vec!["github.com".into()],
directives: vec![
("User".into(), "git".into()),
("IdentityFile".into(), "~/.ssh/id_ed25519".into()),
("HostName".into(), "github.com".into()),
],
},
SshHostBlock {
aliases: vec!["gitea".into()],
directives: vec![
("User".into(), "git".into()),
("HostName".into(), "gitea.example.com".into()),
("IdentityFile".into(), "~/.ssh/work_gitea".into()),
("Port".into(), "2222".into()),
],
},
];
let config = synthesize_ssh_config(&blocks);
let expected = "\
Host github.com
User git
IdentityFile ~/.ssh/id_ed25519
HostName github.com
Host gitea
User git
HostName gitea.example.com
IdentityFile ~/.ssh/work_gitea
Port 2222
";
assert_eq!(config, expected);
}
// --- Task 3: Include directive support ---
#[test]
fn parse_with_include_callback() {
let main_config = "\
Include extra_config
Host github.com
User git
IdentityFile ~/.ssh/id_ed25519
";
let extra_config = "\
Host gitea.local
User git
IdentityFile ~/.ssh/gitea_key
";
let blocks = parse_ssh_config_with_includes(main_config, &|path: &str| {
if path == "extra_config" {
Some(extra_config.to_string())
} else {
None
}
});
assert_eq!(blocks.len(), 2);
assert_eq!(blocks[0].aliases, vec!["gitea.local"]);
assert_eq!(blocks[1].aliases, vec!["github.com"]);
}
}
+97
View File
@@ -0,0 +1,97 @@
pub fn glob_match(pattern: &str, text: &str) -> bool {
let parts: Vec<&str> = pattern.split('*').collect();
let mut pos = 0;
for (i, part) in parts.iter().enumerate() {
if part.is_empty() {
continue;
}
match text[pos..].find(part) {
Some(found) => {
if i == 0 && found != 0 && !pattern.starts_with('*') {
return false;
}
pos += found + part.len();
}
None => return false,
}
}
if !pattern.ends_with('*') && pos != text.len() {
if let Some(last) = parts.last() {
if !last.is_empty() {
return text.ends_with(last);
}
}
}
true
}
pub fn check_patterns(allow: &[String], deny: &[String], args_joined: &str) -> Result<(), String> {
for pattern in deny {
if glob_match(pattern, args_joined) {
return Err(format!("denied by pattern: {pattern}"));
}
}
if !allow.is_empty() {
let allowed = allow.iter().any(|p| glob_match(p, args_joined));
if !allowed {
return Err("not matched by any allow pattern".to_string());
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn glob_matches_wildcard_prefix() {
assert!(glob_match("* prune *", "docker system prune -af"));
}
#[test]
fn glob_matches_wildcard_suffix() {
assert!(glob_match("compose up *", "compose up -d"));
assert!(!glob_match("compose up *", "compose down"));
}
#[test]
fn glob_matches_exact() {
assert!(glob_match("compose down", "compose down"));
assert!(!glob_match("compose down", "compose up"));
}
#[test]
fn check_allow_deny_with_empty_lists() {
assert!(check_patterns(&[], &[], "anything goes").is_ok());
}
#[test]
fn check_deny_blocks_matching() {
let deny = vec!["* prune *".to_string(), "* rm -f *".to_string()];
assert!(check_patterns(&[], &deny, "docker system prune -af").is_err());
assert!(check_patterns(&[], &deny, "docker rm -f abc").is_err());
assert!(check_patterns(&[], &deny, "docker ps").is_ok());
}
#[test]
fn check_allow_is_fail_closed() {
let allow = vec!["compose up *".to_string(), "compose down".to_string()];
assert!(check_patterns(&allow, &[], "compose up -d").is_ok());
assert!(check_patterns(&allow, &[], "compose down").is_ok());
assert!(check_patterns(&allow, &[], "compose rm").is_err());
}
#[test]
fn deny_overrides_allow() {
let allow = vec!["compose *".to_string()];
let deny = vec!["compose rm *".to_string()];
assert!(check_patterns(&allow, &deny, "compose up -d").is_ok());
assert!(check_patterns(&allow, &deny, "compose rm -f").is_err());
}
}
+114
View File
@@ -0,0 +1,114 @@
use std::path::{Path, PathBuf};
use std::process::Command;
use miette::Diagnostic;
use thiserror::Error;
#[derive(Debug, Error, Diagnostic)]
pub enum WorkspaceError {
#[error("Failed to resolve absolute path for {0}: {1}")]
#[diagnostic(code(sandcage::workspace::canonicalize_failed))]
CanonicalizeFailed(PathBuf, #[source] std::io::Error),
}
pub type Result<T> = std::result::Result<T, WorkspaceError>;
pub fn resolve_workspace(path: Option<&Path>) -> Result<PathBuf> {
// Determine starting path
let raw = match path {
Some(p) => p.to_path_buf(),
None => std::env::current_dir()
.map_err(|e| WorkspaceError::CanonicalizeFailed(PathBuf::from("."), e))?,
};
// Resolve to absolute, canonical path (follows symlinks, checks existence)
let absolute = raw
.canonicalize()
.map_err(|e| WorkspaceError::CanonicalizeFailed(raw.clone(), e))?;
// Ask git for the worktree root; silently fall back if not a git repo
let git_root = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(&absolute)
.output();
match git_root {
Ok(output) if output.status.success() => {
let root = String::from_utf8_lossy(&output.stdout)
.trim()
.to_string();
Ok(PathBuf::from(root))
}
// Not a git repo, or git not installed — return the absolute path as-is
_ => Ok(absolute),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
use tempfile::TempDir;
/// Create a temporary directory and initialise it as a git repository.
/// Returns the `TempDir` guard (keep it alive for the test) and the path.
fn make_git_repo() -> (TempDir, PathBuf) {
let dir = TempDir::new().expect("create tempdir");
let path = dir.path().to_path_buf();
// git init
let status = Command::new("git")
.args(["init"])
.current_dir(&path)
.status()
.expect("run git init");
assert!(status.success(), "git init failed");
(dir, path)
}
#[test]
fn returns_repo_root_when_inside_git_repo() {
let (_guard, repo_root) = make_git_repo();
// Create a nested directory inside the repo
let nested = repo_root.join("a").join("b");
std::fs::create_dir_all(&nested).expect("create nested dirs");
// resolve_workspace from the nested dir should return the repo root
let resolved = resolve_workspace(Some(&nested)).expect("resolve_workspace");
assert_eq!(
resolved.canonicalize().unwrap(),
repo_root.canonicalize().unwrap(),
"expected repo root, got {resolved:?}"
);
}
#[test]
fn returns_directory_itself_when_not_in_git_repo() {
// Create a temp dir that is NOT a git repo (no git init)
let dir = TempDir::new().expect("create tempdir");
// Make sure there is no ancestor git repo that would pollute the result.
// tempfile creates dirs under the system temp folder, which is typically
// outside any user repo, so this should be safe.
let path = dir.path().to_path_buf();
let resolved = resolve_workspace(Some(&path)).expect("resolve_workspace");
assert_eq!(
resolved.canonicalize().unwrap(),
path.canonicalize().unwrap(),
"expected the directory itself, got {resolved:?}"
);
}
#[test]
fn none_path_uses_current_directory() {
// When None is passed the function should not error; it should return
// something (either the cwd or its git root).
let result = resolve_workspace(None);
assert!(result.is_ok(), "resolve_workspace(None) returned error: {result:?}");
let resolved = result.unwrap();
assert!(resolved.is_absolute(), "result should be absolute: {resolved:?}");
}
}
+224
View File
@@ -0,0 +1,224 @@
use std::path::{Path, PathBuf};
use std::process::Command;
use miette::Diagnostic;
use thiserror::Error;
#[derive(Debug, Error, Diagnostic)]
pub enum WorktreeError {
#[error("not a git repository: {0}")]
#[diagnostic(code(sandcage::worktree::not_git))]
NotGitRepo(PathBuf),
#[error("git worktree add failed: {0}")]
#[diagnostic(code(sandcage::worktree::create_failed))]
CreateFailed(String),
#[error("git worktree remove failed: {0}")]
#[diagnostic(code(sandcage::worktree::remove_failed))]
RemoveFailed(String),
}
pub fn worktree_dir(sandcage_home: &Path, project_slug: &str, timestamp: &str) -> PathBuf {
sandcage_home
.join("worktrees")
.join(format!("{project_slug}-{timestamp}"))
}
pub fn project_slug(project_path: &Path) -> String {
project_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "project".to_string())
}
pub fn generate_timestamp() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let d = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
format!("{}", d.as_secs())
}
pub fn create_worktree(
repo_root: &Path,
target_path: &Path,
branch: Option<&str>,
) -> Result<(), WorktreeError> {
let check = Command::new("git")
.args(["rev-parse", "--git-dir"])
.current_dir(repo_root)
.output()
.map_err(|_| WorktreeError::NotGitRepo(repo_root.to_path_buf()))?;
if !check.status.success() {
return Err(WorktreeError::NotGitRepo(repo_root.to_path_buf()));
}
let mut cmd = Command::new("git");
cmd.args(["worktree", "add"]);
if let Some(b) = branch {
cmd.args([&target_path.to_string_lossy().to_string(), b]);
} else {
cmd.args(["--detach", &target_path.to_string_lossy().to_string()]);
}
cmd.current_dir(repo_root);
let output = cmd
.output()
.map_err(|e| WorktreeError::CreateFailed(format!("failed to run git: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(WorktreeError::CreateFailed(stderr.to_string()));
}
Ok(())
}
#[derive(Debug)]
pub struct WorktreeEntry {
pub name: String,
pub path: PathBuf,
}
pub fn list_worktrees(sandcage_home: &Path) -> Vec<WorktreeEntry> {
let wt_dir = sandcage_home.join("worktrees");
if !wt_dir.exists() {
return vec![];
}
let mut entries = Vec::new();
if let Ok(read_dir) = std::fs::read_dir(&wt_dir) {
for entry in read_dir.flatten() {
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
let name = entry.file_name().to_string_lossy().to_string();
entries.push(WorktreeEntry {
name,
path: entry.path(),
});
}
}
}
entries.sort_by(|a, b| a.name.cmp(&b.name));
entries
}
pub fn remove_worktree(path: &Path) -> Result<(), WorktreeError> {
let output = Command::new("git")
.args(["worktree", "remove", "--force", &path.to_string_lossy()])
.output();
match output {
Ok(o) if o.status.success() => return Ok(()),
_ => {}
}
std::fs::remove_dir_all(path).map_err(|e| {
WorktreeError::RemoveFailed(format!("failed to remove {}: {e}", path.display()))
})
}
pub fn clean_all(sandcage_home: &Path, dry_run: bool) -> Vec<(String, Result<(), WorktreeError>)> {
let entries = list_worktrees(sandcage_home);
let mut results = Vec::new();
for entry in entries {
if dry_run {
results.push((entry.name, Ok(())));
} else {
let result = remove_worktree(&entry.path);
results.push((entry.name, result));
}
}
results
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn worktree_dir_path_format() {
let home = PathBuf::from("/home/user/.sandcage");
let dir = worktree_dir(&home, "myapp", "20260602-140000");
assert_eq!(
dir,
PathBuf::from("/home/user/.sandcage/worktrees/myapp-20260602-140000")
);
}
#[test]
fn project_slug_extracts_basename() {
assert_eq!(
project_slug(Path::new("/home/user/projects/my-app")),
"my-app"
);
}
#[test]
fn list_worktrees_finds_managed() {
let home = tempfile::tempdir().unwrap();
let wt_dir = home.path().join("worktrees");
std::fs::create_dir_all(&wt_dir).unwrap();
std::fs::create_dir_all(wt_dir.join("myapp-1717300000")).unwrap();
std::fs::create_dir_all(wt_dir.join("myapp-1717300001")).unwrap();
let entries = list_worktrees(home.path());
assert_eq!(entries.len(), 2);
}
#[test]
fn list_worktrees_empty_when_no_dir() {
let home = tempfile::tempdir().unwrap();
let entries = list_worktrees(home.path());
assert!(entries.is_empty());
}
#[test]
fn create_worktree_produces_valid_checkout() {
let dir = tempfile::tempdir().unwrap();
let repo_root = dir.path().to_path_buf();
Command::new("git")
.args(["init"])
.current_dir(&repo_root)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("git init");
Command::new("git")
.args(["commit", "--allow-empty", "-m", "init"])
.current_dir(&repo_root)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("git commit");
let wt_dir = tempfile::tempdir().unwrap();
let wt_path = wt_dir.path().join("test-wt");
create_worktree(&repo_root, &wt_path, None).unwrap();
assert!(wt_path.exists(), "worktree dir should exist");
let status = Command::new("git")
.args(["status"])
.current_dir(&wt_path)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.unwrap();
assert!(status.success(), "git status should work in worktree");
// Cleanup
Command::new("git")
.args(["worktree", "remove", "--force", &wt_path.to_string_lossy()])
.current_dir(&repo_root)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.ok();
}
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "thumper"
version = "0.1.0"
edition = "2024"
description = "In-container forwarding client for sandcage"
license = "MIT"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[dev-dependencies]
tempfile = "3"
+311
View File
@@ -0,0 +1,311 @@
#[cfg(unix)]
mod imp {
use std::io::{self, BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::Path;
use crate::protocol::{ForwardRequest, ResponseFrame, StreamKind};
#[derive(Debug)]
pub enum ClientError {
Connect(io::Error),
Send(io::Error),
Receive(io::Error),
Parse(serde_json::Error),
UnexpectedEof,
}
impl std::fmt::Display for ClientError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ClientError::Connect(e) => write!(f, "failed to connect to forwarding socket: {e}"),
ClientError::Send(e) => write!(f, "failed to send request: {e}"),
ClientError::Receive(e) => write!(f, "failed to read response: {e}"),
ClientError::Parse(e) => write!(f, "malformed response frame: {e}"),
ClientError::UnexpectedEof => write!(f, "connection closed without exit frame"),
}
}
}
impl std::error::Error for ClientError {}
pub fn forward(socket_path: &Path, request: &ForwardRequest) -> Result<i32, ClientError> {
let stream = UnixStream::connect(socket_path).map_err(ClientError::Connect)?;
// Send request as a single JSON line.
let mut writer = io::BufWriter::new(&stream);
serde_json::to_writer(&mut writer, request).map_err(|e| ClientError::Send(e.into()))?;
writer.write_all(b"\n").map_err(ClientError::Send)?;
writer.flush().map_err(ClientError::Send)?;
// Read response frames line by line.
let reader = BufReader::new(&stream);
for line_result in reader.lines() {
let line = line_result.map_err(ClientError::Receive)?;
if line.is_empty() {
continue;
}
let frame: ResponseFrame = serde_json::from_str(&line).map_err(ClientError::Parse)?;
match frame {
ResponseFrame::Output { stream: StreamKind::Stdout, data } => {
print!("{data}");
}
ResponseFrame::Output { stream: StreamKind::Stderr, data } => {
eprint!("{data}");
}
ResponseFrame::Exit { exit } => {
return Ok(exit);
}
}
}
Err(ClientError::UnexpectedEof)
}
}
#[cfg(unix)]
pub use imp::*;
#[cfg(not(unix))]
mod imp {
use std::path::Path;
use crate::protocol::ForwardRequest;
#[derive(Debug)]
pub enum ClientError {
Unsupported,
}
impl std::fmt::Display for ClientError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "thumper is only supported on Unix platforms")
}
}
impl std::error::Error for ClientError {}
pub fn forward(_socket_path: &Path, _request: &ForwardRequest) -> Result<i32, ClientError> {
Err(ClientError::Unsupported)
}
}
#[cfg(not(unix))]
pub use imp::*;
#[cfg(test)]
#[cfg(unix)]
mod tests {
use super::*;
use crate::protocol::RequestKind;
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::Path;
use std::thread;
fn mock_server(
socket_path: &Path,
handler: impl FnOnce(UnixStream) + Send + 'static,
) -> thread::JoinHandle<()> {
let listener = UnixListener::bind(socket_path).unwrap();
thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
handler(stream);
})
}
#[test]
fn sends_request_as_single_json_line() {
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("cmd.sock");
let handle = mock_server(&sock, |stream| {
let mut reader = BufReader::new(&stream);
let mut line = String::new();
reader.read_line(&mut line).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&line).unwrap();
assert_eq!(parsed["kind"], "task");
assert_eq!(parsed["name"], "build");
assert_eq!(parsed["args"][0], "--release");
assert_eq!(parsed["cwd"], "/workspace/app");
// Send exit frame so forward() returns
let mut writer = reader.into_inner();
writeln!(writer, r#"{{"exit":0}}"#).unwrap();
});
let req = crate::protocol::ForwardRequest {
kind: RequestKind::Task,
name: "build".to_string(),
args: vec!["--release".to_string()],
cwd: "/workspace/app".to_string(),
};
let exit = forward(&sock, &req).unwrap();
assert_eq!(exit, 0);
handle.join().unwrap();
}
#[test]
fn routes_stdout_and_stderr_separately() {
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("cmd.sock");
let handle = mock_server(&sock, |stream| {
let mut reader = BufReader::new(&stream);
let mut line = String::new();
reader.read_line(&mut line).unwrap(); // consume request
let mut writer = reader.into_inner();
writeln!(writer, r#"{{"stream":"stdout","data":"out1\n"}}"#).unwrap();
writeln!(writer, r#"{{"stream":"stderr","data":"err1\n"}}"#).unwrap();
writeln!(writer, r#"{{"stream":"stdout","data":"out2\n"}}"#).unwrap();
writeln!(writer, r#"{{"exit":0}}"#).unwrap();
});
let req = crate::protocol::ForwardRequest {
kind: RequestKind::Task,
name: "test".to_string(),
args: vec![],
cwd: "/workspace".to_string(),
};
let exit = forward(&sock, &req).unwrap();
assert_eq!(exit, 0);
handle.join().unwrap();
}
#[test]
fn nonzero_exit_is_relayed() {
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("cmd.sock");
let handle = mock_server(&sock, |stream| {
let mut reader = BufReader::new(&stream);
let mut line = String::new();
reader.read_line(&mut line).unwrap();
let mut writer = reader.into_inner();
writeln!(writer, r#"{{"stream":"stderr","data":"error: build failed\n"}}"#).unwrap();
writeln!(writer, r#"{{"exit":1}}"#).unwrap();
});
let req = crate::protocol::ForwardRequest {
kind: RequestKind::Task,
name: "build".to_string(),
args: vec![],
cwd: "/workspace".to_string(),
};
let exit = forward(&sock, &req).unwrap();
assert_eq!(exit, 1);
handle.join().unwrap();
}
#[test]
fn unexpected_eof_when_no_exit_frame() {
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("cmd.sock");
let handle = mock_server(&sock, |stream| {
let mut reader = BufReader::new(&stream);
let mut line = String::new();
reader.read_line(&mut line).unwrap();
let mut writer = reader.into_inner();
writeln!(writer, r#"{{"stream":"stdout","data":"partial"}}"#).unwrap();
let _ = writer; // close without exit frame
});
let req = crate::protocol::ForwardRequest {
kind: RequestKind::Task,
name: "test".to_string(),
args: vec![],
cwd: "/workspace".to_string(),
};
let err = forward(&sock, &req).unwrap_err();
assert!(
matches!(err, ClientError::UnexpectedEof),
"expected UnexpectedEof, got {err}"
);
handle.join().unwrap();
}
#[test]
fn full_roundtrip_simulating_cargo_build() {
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("cmd.sock");
let handle = mock_server(&sock, |stream| {
let mut reader = BufReader::new(&stream);
let mut req_line = String::new();
reader.read_line(&mut req_line).unwrap();
// Validate the request
let req: serde_json::Value = serde_json::from_str(&req_line).unwrap();
assert_eq!(req["kind"], "task");
assert_eq!(req["name"], "build");
assert_eq!(req["args"], serde_json::json!(["--release"]));
// Simulate cargo build output
let writer = reader.into_inner();
writeln!(&*writer, r#"{{"stream":"stderr","data":" Compiling sandcage v0.1.0\n"}}"#).unwrap();
writeln!(&*writer, r#"{{"stream":"stderr","data":" Compiling thumper v0.1.0\n"}}"#).unwrap();
writeln!(&*writer, r#"{{"stream":"stderr","data":" Finished release [optimized]\n"}}"#).unwrap();
writeln!(&*writer, r#"{{"exit":0}}"#).unwrap();
});
let req = crate::protocol::ForwardRequest {
kind: RequestKind::Task,
name: "build".to_string(),
args: vec!["--release".to_string()],
cwd: "/workspace/app".to_string(),
};
let exit = forward(&sock, &req).unwrap();
assert_eq!(exit, 0);
handle.join().unwrap();
}
#[test]
fn tool_request_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("cmd.sock");
let handle = mock_server(&sock, |stream| {
let mut reader = BufReader::new(&stream);
let mut req_line = String::new();
reader.read_line(&mut req_line).unwrap();
let req: serde_json::Value = serde_json::from_str(&req_line).unwrap();
assert_eq!(req["kind"], "tool");
assert_eq!(req["name"], "docker");
let writer = reader.into_inner();
writeln!(&*writer, r#"{{"stream":"stdout","data":"container-id-abc123\n"}}"#).unwrap();
writeln!(&*writer, r#"{{"exit":0}}"#).unwrap();
});
let req = crate::protocol::ForwardRequest {
kind: RequestKind::Tool,
name: "docker".to_string(),
args: vec!["compose".to_string(), "up".to_string(), "-d".to_string()],
cwd: "/workspace/app".to_string(),
};
let exit = forward(&sock, &req).unwrap();
assert_eq!(exit, 0);
handle.join().unwrap();
}
#[test]
fn connect_error_on_missing_socket() {
let err = forward(
Path::new("/tmp/sandcage-nonexistent-test.sock"),
&crate::protocol::ForwardRequest {
kind: RequestKind::Task,
name: "x".to_string(),
args: vec![],
cwd: "/workspace".to_string(),
},
)
.unwrap_err();
assert!(matches!(err, ClientError::Connect(_)), "expected Connect, got {err}");
}
}
+44
View File
@@ -0,0 +1,44 @@
mod client;
mod protocol;
use std::path::Path;
use std::process::ExitCode;
use protocol::{ForwardRequest, RequestKind};
const DEFAULT_SOCKET: &str = "/run/sandcage/cmd.sock";
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().collect();
if args.len() < 2 || args[1] == "--help" || args[1] == "-h" {
eprintln!("usage: thumper <task-name> [args...]");
eprintln!();
eprintln!("Forwards a named task to the sandcage host daemon.");
eprintln!("The task is executed on the host and output is streamed back.");
return ExitCode::from(2);
}
let socket_path = std::env::var("SANDCAGE_SOCKET")
.map(|s| std::path::PathBuf::from(s))
.unwrap_or_else(|_| Path::new(DEFAULT_SOCKET).to_path_buf());
let cwd = std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| "/workspace".to_string());
let request = ForwardRequest {
kind: RequestKind::Task,
name: args[1].clone(),
args: args[2..].to_vec(),
cwd,
};
match client::forward(&socket_path, &request) {
Ok(code) => ExitCode::from(code as u8),
Err(e) => {
eprintln!("thumper: {e}");
ExitCode::from(1)
}
}
}
+113
View File
@@ -0,0 +1,113 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize)]
pub struct ForwardRequest {
pub kind: RequestKind,
pub name: String,
pub args: Vec<String>,
pub cwd: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "snake_case")]
#[allow(dead_code)]
pub enum RequestKind {
Task,
Tool,
}
/// Response frames from the host-side daemon.
///
/// IMPORTANT: `Exit` MUST come before `Output`. Serde's `untagged` tries variants in
/// declaration order — `{"exit":0}` would match `Output` if `Output` were first.
#[derive(Debug, Deserialize)]
#[serde(untagged)]
#[allow(dead_code)]
pub enum ResponseFrame {
Exit { exit: i32 },
Output { stream: StreamKind, data: String },
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
#[allow(dead_code)]
pub enum StreamKind {
Stdout,
Stderr,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_serializes_to_expected_json() {
let req = ForwardRequest {
kind: RequestKind::Task,
name: "build".to_string(),
args: vec!["--release".to_string()],
cwd: "/workspace".to_string(),
};
let json = serde_json::to_value(&req).unwrap();
assert_eq!(json["kind"], "task");
assert_eq!(json["name"], "build");
assert_eq!(json["args"][0], "--release");
assert_eq!(json["cwd"], "/workspace");
}
#[test]
fn tool_request_kind_serializes() {
let req = ForwardRequest {
kind: RequestKind::Tool,
name: "grep".to_string(),
args: vec![],
cwd: "/".to_string(),
};
let json = serde_json::to_value(&req).unwrap();
assert_eq!(json["kind"], "tool");
}
#[test]
fn stdout_frame_deserializes() {
let frame: ResponseFrame =
serde_json::from_str(r#"{"stream":"stdout","data":"hello\n"}"#).unwrap();
match frame {
ResponseFrame::Output { stream: StreamKind::Stdout, data } => {
assert_eq!(data, "hello\n");
}
other => panic!("unexpected variant: {:?}", other),
}
}
#[test]
fn stderr_frame_deserializes() {
let frame: ResponseFrame =
serde_json::from_str(r#"{"stream":"stderr","data":"warning: unused var"}"#).unwrap();
match frame {
ResponseFrame::Output { stream: StreamKind::Stderr, data } => {
assert_eq!(data, "warning: unused var");
}
other => panic!("unexpected variant: {:?}", other),
}
}
#[test]
fn exit_frame_deserializes() {
let frame: ResponseFrame = serde_json::from_str(r#"{"exit":42}"#).unwrap();
match frame {
ResponseFrame::Exit { exit } => assert_eq!(exit, 42),
other => panic!("unexpected variant: {:?}", other),
}
}
#[test]
fn exit_zero_frame() {
// Regression guard: serde untagged ordering — Exit must come before Output
// so {"exit":0} does not accidentally match the Output variant.
let frame: ResponseFrame = serde_json::from_str(r#"{"exit":0}"#).unwrap();
match frame {
ResponseFrame::Exit { exit } => assert_eq!(exit, 0),
other => panic!("unexpected variant: {:?}", other),
}
}
}