🛰️ export from upstream (18069bd)
This commit is contained in:
@@ -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 }
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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/"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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, ®istry, 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(())
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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(®, &test_context());
|
||||
|
||||
let parsed: serde_yaml::Value =
|
||||
serde_yaml::from_str(&yaml).expect("output must be valid YAML");
|
||||
assert!(parsed.as_mapping().unwrap().contains_key("services"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contains_only_enabled_services() {
|
||||
let mut codex = builtin::codex();
|
||||
codex.enabled = false;
|
||||
|
||||
let reg = registry_with(vec![builtin::claude(), codex, builtin::shell()]);
|
||||
let yaml = generate_compose(®, &test_context());
|
||||
|
||||
assert!(yaml.contains("claude:"), "claude should be present");
|
||||
assert!(yaml.contains("shell:"), "shell should be present");
|
||||
assert!(!yaml.contains("codex:"), "disabled codex should be absent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_registry_produces_empty_services() {
|
||||
let reg = ServiceRegistry::new();
|
||||
let yaml = generate_compose(®, &test_context());
|
||||
|
||||
let parsed: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
|
||||
let services = parsed.as_mapping().unwrap().get("services").unwrap();
|
||||
assert!(
|
||||
services.as_mapping().unwrap().is_empty(),
|
||||
"services should be empty mapping"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_has_correct_image() {
|
||||
let reg = registry_with(vec![builtin::claude()]);
|
||||
let yaml = generate_compose(®, &test_context());
|
||||
assert!(yaml.contains("sandcage:latest"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_has_correct_entrypoint() {
|
||||
let reg = registry_with(vec![builtin::claude()]);
|
||||
let yaml = generate_compose(®, &test_context());
|
||||
assert!(yaml.contains("sandcage-claude-entrypoint"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_has_correct_working_dir() {
|
||||
let reg = registry_with(vec![builtin::claude()]);
|
||||
let yaml = generate_compose(®, &test_context());
|
||||
assert!(yaml.contains("/workspace/my-app"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_has_home_volume() {
|
||||
let reg = registry_with(vec![builtin::claude()]);
|
||||
let yaml = generate_compose(®, &test_context());
|
||||
assert!(yaml.contains("/home/user/.sandcage/homes/default:/home/agent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_four_builtins_generate_successfully() {
|
||||
let reg = registry_with(builtin::all());
|
||||
let yaml = generate_compose(®, &test_context());
|
||||
|
||||
for name in ["claude", "codex", "gemini", "shell"] {
|
||||
assert!(
|
||||
yaml.contains(&format!("{name}:")),
|
||||
"compose should contain {name} service"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backward_compat_claude_codex_shell_match_old_structure() {
|
||||
let reg = registry_with(vec![
|
||||
builtin::claude(),
|
||||
builtin::codex(),
|
||||
builtin::shell(),
|
||||
]);
|
||||
let yaml = generate_compose(®, &test_context());
|
||||
let parsed: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
|
||||
let services = parsed["services"].as_mapping().unwrap();
|
||||
|
||||
for name in ["claude", "codex", "shell"] {
|
||||
let svc = &services[&serde_yaml::Value::String(name.to_string())];
|
||||
assert_eq!(
|
||||
svc["image"].as_str().unwrap(),
|
||||
"sandcage:latest",
|
||||
"{name}: image"
|
||||
);
|
||||
assert!(svc["tty"].as_bool().unwrap(), "{name}: tty");
|
||||
assert!(svc["stdin_open"].as_bool().unwrap(), "{name}: stdin_open");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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"]);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user