🛰️ export from upstream (31619fc)

This commit is contained in:
Gabor Koerber
2026-05-25 01:22:04 +02:00
parent 4b56c25709
commit 9f2e1d7266
12 changed files with 2641 additions and 68 deletions
Generated
+1243 -1
View File
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -66,6 +66,10 @@ sandcage claude --shell # shell for debugging
sandcage build # build/rebuild container image sandcage build # build/rebuild container image
sandcage init # generate .sandcage.yml for your project sandcage init # generate .sandcage.yml for your project
sandcage setup ssh # configure SSH key access for containers sandcage setup ssh # configure SSH key access for containers
sandcage acp run claude # ACP relay mode (bidirectional stdio)
sandcage acp list # list agents in the ACP registry
sandcage acp install <agent-id> # install an agent from the registry
sandcage acp installed # list locally installed agents
``` ```
## How It Works ## How It Works
@@ -80,6 +84,10 @@ sandcage setup ssh # configure SSH key access for containers
4. The agent runs as the container entrypoint, working in the mounted workspace 4. The agent runs as the container entrypoint, working in the mounted workspace
5. All file changes are immediately visible on your host 5. All file changes are immediately visible on your host
## ACP Support
Sandcage includes built-in support for the Agent Control Protocol (ACP). Running `sandcage acp run <service>` starts the named agent container and establishes a bidirectional stdio relay, letting any ACP-compatible client communicate with the agent over standard I/O. Agents can be installed from the ACP registry with `sandcage acp install <agent-id>` and browsed with `sandcage acp list`.
## Configuration ## Configuration
<p align="center"> <p align="center">
@@ -122,7 +130,8 @@ See **[Configuration Reference](docs/configuration.md)** for all available optio
- **Support for custom harnesses** — bring your own agent runtime beyond the built-in Claude Code, Codex, and Gemini CLI - **Support for custom harnesses** — bring your own agent runtime beyond the built-in Claude Code, Codex, and Gemini CLI
- **Full encapsulation hardening** — for worker and CI environments, ensuring complete sandboxing of file system, network, and credentials - **Full encapsulation hardening** — for worker and CI environments, ensuring complete sandboxing of file system, network, and credentials
- **ACP integration** via [`dirigate`](https://github.com/dirigence/dirigate) — Agent Communication Protocol support for structured agent orchestration - **Full bollard integration** — replace docker compose with direct Docker API calls for faster startup and richer container control
- **Interactive TTY mode** — full terminal emulation for agents that need a PTY
## Cross-Platform ## Cross-Platform
+23
View File
@@ -6,9 +6,20 @@ description = "Sandboxed containers for AI coding agents"
license = "MIT" license = "MIT"
keywords = ["docker", "sandbox", "ai", "agent"] keywords = ["docker", "sandbox", "ai", "agent"]
[features]
default = ["bollard"]
bollard = [
"dep:bollard",
"dep:futures-util",
"dep:reqwest",
"dep:flate2",
"dep:tar",
]
[dependencies] [dependencies]
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9" serde_yaml = "0.9"
figment = { version = "0.10", features = ["toml", "env"] } figment = { version = "0.10", features = ["toml", "env"] }
miette = { version = "7", features = ["fancy"] } miette = { version = "7", features = ["fancy"] }
@@ -21,3 +32,15 @@ indexmap = { version = "2", features = ["serde"] }
tempfile = "3" tempfile = "3"
dialoguer = "0.11" dialoguer = "0.11"
toml_edit = "0.22" 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 }
+229
View File
@@ -0,0 +1,229 @@
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 project_config = workspace.join(".sandcage.yml");
let local_config = workspace.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).await?;
let svc = registry.get(&service).ok_or_else(|| DockerError::UnknownService {
service: service.clone(),
available: registry.names().collect::<Vec<_>>().join(", "),
})?;
if !svc.enabled() {
return Err(DockerError::ServiceDisabled {
service: service.clone(),
});
}
eprintln!("sandcage: workspace \u{2192} {}", workspace.display());
eprintln!("sandcage: acp relay \u{2192} {service}");
let exit_code = backend.run_acp_relay(svc, &ctx, &cfg, &agent_args).await?;
if exit_code != 0 {
return Err(DockerError::ServiceFailed {
service,
code: exit_code,
});
}
Ok(())
}
AcpCommands::Install { agent_id, force } => {
handle_install(&agent_id, force).await.map_err(|e| {
DockerError::SpawnFailed(std::io::Error::new(std::io::ErrorKind::Other, e))
})
}
AcpCommands::List { refresh } => {
handle_list(refresh).await.map_err(|e| {
DockerError::SpawnFailed(std::io::Error::new(std::io::ErrorKind::Other, e))
})
}
AcpCommands::Installed => {
handle_installed().map_err(|e| {
DockerError::SpawnFailed(std::io::Error::new(std::io::ErrorKind::Other, e))
})
}
}
}
async fn handle_install(agent_id: &str, force: bool) -> std::result::Result<(), String> {
let index = registry::fetch_registry(false).await?;
let entry = registry::find_agent(&index, agent_id)
.ok_or_else(|| format!("Agent '{}' not found in registry", agent_id))?;
let platform = registry::platform_key();
let target = entry
.distribution
.binary
.as_ref()
.and_then(|b| b.get(platform))
.ok_or_else(|| {
format!(
"Agent '{}' has no binary for platform '{}'",
agent_id, platform
)
})?;
if !force && registry::is_agent_installed(agent_id, &entry.version) {
eprintln!("sandcage: {} v{} already installed", entry.name, entry.version);
return Ok(());
}
if force {
if let Some(dir) = registry::agent_install_dir(agent_id, &entry.version) {
std::fs::remove_dir_all(&dir).ok();
}
}
registry::install_agent(entry, target).await?;
Ok(())
}
async fn handle_list(refresh: bool) -> std::result::Result<(), String> {
let index = registry::fetch_registry(refresh).await?;
let platform = registry::platform_key();
println!("{:<25} {:<20} {:<10} {}", "ID", "NAME", "VERSION", "PLATFORM");
println!("{}", "-".repeat(70));
for agent in &index.agents {
let has_binary = agent
.distribution
.binary
.as_ref()
.map(|b| b.contains_key(platform))
.unwrap_or(false);
let platform_status = if has_binary {
"binary"
} else if agent.distribution.npx.is_some() {
"npx"
} else {
"none"
};
println!(
"{:<25} {:<20} {:<10} {}",
agent.id, agent.name, agent.version, platform_status
);
}
Ok(())
}
fn handle_installed() -> std::result::Result<(), String> {
let base = dirs::home_dir()
.map(|h| h.join(".sandcage").join("agents"))
.ok_or("cannot determine home directory")?;
if !base.exists() {
println!("No agents installed.");
return Ok(());
}
println!("{:<25} {:<15} {}", "AGENT", "VERSION", "PATH");
println!("{}", "-".repeat(60));
let entries = std::fs::read_dir(&base).map_err(|e| e.to_string())?;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let agent_id = entry.file_name().to_string_lossy().to_string();
if agent_id == "registry.json" || agent_id.ends_with(".timestamp") {
continue;
}
let versions = std::fs::read_dir(&path).map_err(|e| e.to_string())?;
for ver_entry in versions.flatten() {
let ver_path = ver_entry.path();
if !ver_path.is_dir() {
continue;
}
let version = ver_entry.file_name().to_string_lossy().to_string();
println!("{:<25} {:<15} {}", agent_id, version, ver_path.display());
}
}
Ok(())
}
+456
View File
@@ -0,0 +1,456 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
const REGISTRY_URL: &str =
"https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
const REFRESH_INTERVAL_SECS: u64 = 3600;
const FETCH_TIMEOUT_SECS: u64 = 30;
#[derive(Debug, Deserialize)]
pub struct RegistryIndex {
#[serde(rename = "version")]
pub _version: String,
pub agents: Vec<RegistryEntry>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RegistryEntry {
pub id: String,
pub name: String,
pub version: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub repository: Option<String>,
#[serde(default)]
pub website: Option<String>,
#[serde(default)]
pub icon: Option<String>,
pub distribution: RegistryDistribution,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RegistryDistribution {
#[serde(default)]
pub binary: Option<HashMap<String, RegistryBinaryTarget>>,
#[serde(default)]
pub npx: Option<RegistryNpxDistribution>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RegistryBinaryTarget {
pub archive: String,
pub cmd: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: HashMap<String, String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RegistryNpxDistribution {
pub package: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: HashMap<String, String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AgentMeta {
pub cmd: String,
pub args: Vec<String>,
pub env: HashMap<String, String>,
pub version: String,
pub installed_at: String,
}
fn agents_dir() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".sandcage").join("agents"))
}
fn cache_path() -> Option<PathBuf> {
agents_dir().map(|d| d.join("registry.json"))
}
fn timestamp_path() -> Option<PathBuf> {
agents_dir().map(|d| d.join("registry.json.timestamp"))
}
fn is_cache_fresh() -> bool {
let Some(ts_path) = timestamp_path() else {
return false;
};
let Ok(content) = std::fs::read_to_string(&ts_path) else {
return false;
};
let Ok(ts) = content.trim().parse::<u64>() else {
return false;
};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
now.saturating_sub(ts) < REFRESH_INTERVAL_SECS
}
fn write_timestamp() {
if let Some(ts_path) = timestamp_path() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
std::fs::write(&ts_path, now.to_string()).ok();
}
}
pub async fn fetch_registry(force_refresh: bool) -> std::result::Result<RegistryIndex, String> {
let cache = cache_path().ok_or("cannot determine home directory")?;
if !force_refresh && is_cache_fresh() {
if let Ok(data) = std::fs::read_to_string(&cache) {
if let Ok(index) = serde_json::from_str::<RegistryIndex>(&data) {
return Ok(index);
}
}
}
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(FETCH_TIMEOUT_SECS))
.build()
.map_err(|e| format!("HTTP client error: {e}"))?;
let resp = client
.get(REGISTRY_URL)
.send()
.await
.map_err(|e| format!("Failed to fetch registry: {e}"))?;
let body = resp
.text()
.await
.map_err(|e| format!("Failed to read registry response: {e}"))?;
let index: RegistryIndex =
serde_json::from_str(&body).map_err(|e| format!("Failed to parse registry JSON: {e}"))?;
if let Some(dir) = agents_dir() {
std::fs::create_dir_all(&dir).ok();
}
if let Some(ref path) = cache_path() {
std::fs::write(path, &body).ok();
}
write_timestamp();
Ok(index)
}
pub fn find_agent<'a>(index: &'a RegistryIndex, agent_id: &str) -> Option<&'a RegistryEntry> {
index.agents.iter().find(|a| a.id == agent_id)
}
pub fn platform_key() -> &'static str {
if cfg!(target_arch = "x86_64") {
"linux-x86_64"
} else if cfg!(target_arch = "aarch64") {
"linux-aarch64"
} else {
"linux-x86_64"
}
}
pub fn agent_install_dir(agent_id: &str, version: &str) -> Option<PathBuf> {
let sanitized_id = agent_id.replace(|c: char| !c.is_alphanumeric() && c != '-' && c != '_', "-");
agents_dir().map(|d| d.join(&sanitized_id).join(version))
}
pub fn is_agent_installed(agent_id: &str, version: &str) -> bool {
agent_install_dir(agent_id, version)
.map(|d| d.exists())
.unwrap_or(false)
}
pub async fn install_agent(
entry: &RegistryEntry,
target: &RegistryBinaryTarget,
) -> std::result::Result<PathBuf, String> {
let install_dir = agent_install_dir(&entry.id, &entry.version)
.ok_or("cannot determine install directory")?;
if install_dir.exists() {
return Ok(install_dir);
}
eprintln!("sandcage: downloading {} v{}...", entry.name, entry.version);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(300))
.build()
.map_err(|e| format!("HTTP client error: {e}"))?;
let resp = client
.get(&target.archive)
.send()
.await
.map_err(|e| format!("Failed to download agent: {e}"))?;
let bytes = resp
.bytes()
.await
.map_err(|e| format!("Failed to read agent archive: {e}"))?;
let parent = install_dir.parent().ok_or("invalid install path")?;
std::fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {e}"))?;
let url_lower = target.archive.to_lowercase();
if url_lower.ends_with(".tar.gz") || url_lower.ends_with(".tgz") {
let decoder = flate2::read::GzDecoder::new(&bytes[..]);
let mut archive = tar::Archive::new(decoder);
archive
.unpack(&install_dir)
.map_err(|e| format!("Failed to extract tar.gz: {e}"))?;
} else if url_lower.ends_with(".tar.bz2") || url_lower.ends_with(".tbz2") {
return Err("tar.bz2 extraction not yet supported".to_string());
} else if url_lower.ends_with(".zip") {
return Err("zip extraction not yet supported — use tar.gz agents".to_string());
} else {
return Err(format!("Unknown archive format: {}", target.archive));
}
let meta = AgentMeta {
cmd: target.cmd.clone(),
args: target.args.clone(),
env: target.env.clone(),
version: entry.version.clone(),
installed_at: chrono_free_now(),
};
let meta_path = install_dir.join(".sandcage-meta.json");
let meta_json = serde_json::to_string_pretty(&meta)
.map_err(|e| format!("Failed to serialize meta: {e}"))?;
std::fs::write(&meta_path, meta_json)
.map_err(|e| format!("Failed to write meta: {e}"))?;
cleanup_old_versions(&entry.id, &entry.version);
eprintln!("sandcage: installed {} v{}", entry.name, entry.version);
Ok(install_dir)
}
fn chrono_free_now() -> String {
let dur = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
format!("{}", dur.as_secs())
}
fn cleanup_old_versions(agent_id: &str, current_version: &str) {
let sanitized_id = agent_id.replace(|c: char| !c.is_alphanumeric() && c != '-' && c != '_', "-");
let Some(base) = agents_dir().map(|d| d.join(&sanitized_id)) else {
return;
};
let Ok(entries) = std::fs::read_dir(&base) else {
return;
};
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str != current_version && entry.path().is_dir() && name_str != ".sandcage-meta.json" {
std::fs::remove_dir_all(entry.path()).ok();
}
}
}
pub fn resolve_agent_command(
install_dir: &Path,
target: &RegistryBinaryTarget,
) -> std::result::Result<(PathBuf, Vec<String>, HashMap<String, String>), String> {
let cmd = target.cmd.strip_prefix("./").unwrap_or(&target.cmd);
if cmd.contains("..") {
return Err("path traversal in agent cmd is not allowed".to_string());
}
let bin_path = install_dir.join(cmd);
if !bin_path.exists() {
return Err(format!(
"agent binary not found at {}",
bin_path.display()
));
}
Ok((bin_path, target.args.clone(), target.env.clone()))
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_registry_json() -> &'static str {
r#"{
"version": "1",
"agents": [
{
"id": "test-agent",
"name": "Test Agent",
"version": "1.0.0",
"description": "A test agent",
"distribution": {
"binary": {
"linux-x86_64": {
"archive": "https://example.com/test-agent-linux-x86_64.tar.gz",
"cmd": "./test-agent",
"args": ["--acp"],
"env": {"FOO": "bar"}
}
}
}
},
{
"id": "npx-only-agent",
"name": "NPX Agent",
"version": "2.0.0",
"description": "NPX only",
"distribution": {
"npx": {
"package": "@test/agent@2.0.0",
"args": ["--stdio"],
"env": {}
}
}
}
]
}"#
}
#[test]
fn parse_registry_index() {
let index: RegistryIndex =
serde_json::from_str(sample_registry_json()).expect("parse registry");
assert_eq!(index.agents.len(), 2);
assert_eq!(index.agents[0].id, "test-agent");
assert_eq!(index.agents[1].id, "npx-only-agent");
}
#[test]
fn find_agent_by_id() {
let index: RegistryIndex =
serde_json::from_str(sample_registry_json()).expect("parse");
assert!(find_agent(&index, "test-agent").is_some());
assert!(find_agent(&index, "nonexistent").is_none());
}
#[test]
fn binary_target_has_correct_fields() {
let index: RegistryIndex =
serde_json::from_str(sample_registry_json()).expect("parse");
let agent = find_agent(&index, "test-agent").unwrap();
let target = agent
.distribution
.binary
.as_ref()
.unwrap()
.get("linux-x86_64")
.unwrap();
assert_eq!(target.cmd, "./test-agent");
assert_eq!(target.args, vec!["--acp"]);
assert_eq!(target.env.get("FOO"), Some(&"bar".to_string()));
}
#[test]
fn npx_agent_has_package() {
let index: RegistryIndex =
serde_json::from_str(sample_registry_json()).expect("parse");
let agent = find_agent(&index, "npx-only-agent").unwrap();
let npx = agent.distribution.npx.as_ref().unwrap();
assert_eq!(npx.package, "@test/agent@2.0.0");
}
#[test]
fn resolve_command_strips_dot_slash() {
let tmp = tempfile::tempdir().unwrap();
let bin = tmp.path().join("test-agent");
std::fs::write(&bin, "#!/bin/sh\necho ok").unwrap();
let target = RegistryBinaryTarget {
archive: String::new(),
cmd: "./test-agent".to_string(),
args: vec!["--acp".to_string()],
env: HashMap::new(),
};
let (path, args, _env) = resolve_agent_command(tmp.path(), &target).unwrap();
assert_eq!(path, bin);
assert_eq!(args, vec!["--acp"]);
}
#[test]
fn resolve_command_rejects_path_traversal() {
let tmp = tempfile::tempdir().unwrap();
let target = RegistryBinaryTarget {
archive: String::new(),
cmd: "../../../etc/passwd".to_string(),
args: vec![],
env: HashMap::new(),
};
let result = resolve_agent_command(tmp.path(), &target);
assert!(result.is_err());
assert!(result.unwrap_err().contains("path traversal"));
}
#[test]
fn resolve_command_fails_for_missing_binary() {
let tmp = tempfile::tempdir().unwrap();
let target = RegistryBinaryTarget {
archive: String::new(),
cmd: "./nonexistent".to_string(),
args: vec![],
env: HashMap::new(),
};
let result = resolve_agent_command(tmp.path(), &target);
assert!(result.is_err());
assert!(result.unwrap_err().contains("not found"));
}
#[test]
fn agent_install_dir_sanitizes_id() {
let dir = agent_install_dir("my@agent/test", "1.0.0").unwrap();
let dir_name = dir.parent().unwrap().file_name().unwrap().to_string_lossy();
assert!(!dir_name.contains('@'));
assert!(!dir_name.contains('/'));
}
#[test]
fn parse_registry_with_missing_optional_fields() {
let json = r#"{
"version": "1",
"agents": [{
"id": "minimal",
"name": "Minimal",
"version": "0.1.0",
"description": "",
"distribution": {
"binary": {
"linux-x86_64": {
"archive": "https://example.com/a.tar.gz",
"cmd": "./agent"
}
}
}
}]
}"#;
let index: RegistryIndex = serde_json::from_str(json).expect("parse");
let agent = &index.agents[0];
assert!(agent.repository.is_none());
assert!(agent.website.is_none());
assert!(agent.icon.is_none());
let target = agent.distribution.binary.as_ref().unwrap().get("linux-x86_64").unwrap();
assert!(target.args.is_empty());
assert!(target.env.is_empty());
}
}
+315
View File
@@ -0,0 +1,315 @@
use std::collections::HashMap;
use async_trait::async_trait;
use bollard::container::LogOutput;
use bollard::models::{ContainerCreateBody, HostConfig, Mount, MountType};
use bollard::Docker;
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;
pub struct BollardBackend {
docker: Docker,
}
impl BollardBackend {
pub fn new() -> Self {
let docker = Docker::connect_with_local_defaults()
.expect("failed to connect to Docker daemon");
Self { docker }
}
}
#[async_trait]
impl super::ContainerBackend for BollardBackend {
async fn run_interactive(
&self,
service: &dyn Service,
ctx: &ComposeContext,
config: &SandcageConfig,
registry: &ServiceRegistry,
shell_override: bool,
extra_args: &[String],
) -> Result<i32> {
let compose = super::compose::ComposeBackend;
compose
.run_interactive(service, ctx, config, registry, shell_override, extra_args)
.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, 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(())
}
}
impl BollardBackend {
fn build_container_config(
&self,
compose_def: &ComposeServiceDef,
config: &SandcageConfig,
extra_args: &[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);
}
_ => {}
}
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(), "acp".to_string());
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(false),
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) },
auto_remove: Some(true),
..Default::default()
}),
..Default::default()
}
}
async fn acp_relay(
&self,
compose_def: &ComposeServiceDef,
config: &SandcageConfig,
extra_args: &[String],
) -> 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, extra_args);
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();
}
}
}
}
}
+103
View File
@@ -0,0 +1,103 @@
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 run_args = docker::build_run_args(
service.name(),
&compose_path,
config,
shell_override,
extra_args,
);
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(())
}
}
+50
View File
@@ -0,0 +1,50 @@
pub mod compose;
#[cfg(feature = "bollard")]
pub mod bollard;
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<()>;
}
pub fn default_backend() -> Box<dyn ContainerBackend> {
#[cfg(feature = "bollard")]
{
Box::new(bollard::BollardBackend::new())
}
#[cfg(not(feature = "bollard"))]
{
Box::new(compose::ComposeBackend)
}
}
+69 -58
View File
@@ -2,7 +2,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::io::Write; use std::io::Write;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use tokio::process::Command;
use miette::Diagnostic; use miette::Diagnostic;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
@@ -92,18 +92,26 @@ pub enum DockerError {
help("Enable it with: services.{service}.enabled: true") help("Enable it with: services.{service}.enabled: true")
)] )]
ServiceDisabled { service: String }, ServiceDisabled { service: String },
#[error("ACP relay requires the 'bollard' feature")]
#[diagnostic(
code(sandcage::docker::acp_requires_bollard),
help("Rebuild sandcage with: cargo build --features bollard")
)]
AcpRequiresBollard,
} }
pub type Result<T> = std::result::Result<T, DockerError>; pub type Result<T> = std::result::Result<T, DockerError>;
fn require_docker() -> Result<PathBuf> { pub fn require_docker() -> Result<PathBuf> {
which::which("docker").map_err(|_| DockerError::DockerNotFound) which::which("docker").map_err(|_| DockerError::DockerNotFound)
} }
fn require_compose(docker: &Path) -> Result<()> { pub async fn require_compose(docker: &Path) -> Result<()> {
let output = Command::new(docker) let output = Command::new(docker)
.args(["compose", "version"]) .args(["compose", "version"])
.output() .output()
.await
.map_err(|_| DockerError::ComposeNotFound)?; .map_err(|_| DockerError::ComposeNotFound)?;
if !output.status.success() { if !output.status.success() {
@@ -112,10 +120,11 @@ fn require_compose(docker: &Path) -> Result<()> {
Ok(()) Ok(())
} }
fn id_flag(flag: &str) -> Result<String> { async fn id_flag(flag: &str) -> Result<String> {
let output = Command::new("id") let output = Command::new("id")
.arg(flag) .arg(flag)
.output() .output()
.await
.map_err(|e| DockerError::IdFailed(e.to_string()))?; .map_err(|e| DockerError::IdFailed(e.to_string()))?;
if !output.status.success() { if !output.status.success() {
@@ -128,7 +137,7 @@ fn id_flag(flag: &str) -> Result<String> {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
} }
pub fn build_compose_env(workspace: &Path, config: &SandcageConfig) -> Result<HashMap<String, String>> { pub async fn build_compose_env(workspace: &Path, config: &SandcageConfig) -> Result<HashMap<String, String>> {
let home = dirs::home_dir().ok_or(DockerError::NoHomeDir)?; let home = dirs::home_dir().ok_or(DockerError::NoHomeDir)?;
let sandcage_home = home.join(".sandcage"); let sandcage_home = home.join(".sandcage");
@@ -137,7 +146,7 @@ pub fn build_compose_env(workspace: &Path, config: &SandcageConfig) -> Result<Ha
let (uid, gid) = if cfg!(windows) { let (uid, gid) = if cfg!(windows) {
("1000".to_string(), "1000".to_string()) ("1000".to_string(), "1000".to_string())
} else { } else {
(id_flag("-u")?, id_flag("-g")?) (id_flag("-u").await?, id_flag("-g").await?)
}; };
let container_dir = match &config.container_workspace { let container_dir = match &config.container_workspace {
@@ -168,14 +177,14 @@ pub fn build_compose_env(workspace: &Path, config: &SandcageConfig) -> Result<Ha
Ok(env) Ok(env)
} }
pub fn build_compose_context(workspace: &Path, config: &SandcageConfig) -> Result<crate::service::ComposeContext> { pub async fn build_compose_context(workspace: &Path, config: &SandcageConfig) -> Result<crate::service::ComposeContext> {
let home = dirs::home_dir().ok_or(DockerError::NoHomeDir)?; let home = dirs::home_dir().ok_or(DockerError::NoHomeDir)?;
let sandcage_home = home.join(".sandcage"); let sandcage_home = home.join(".sandcage");
let (uid, gid) = if cfg!(windows) { let (uid, gid) = if cfg!(windows) {
("1000".to_string(), "1000".to_string()) ("1000".to_string(), "1000".to_string())
} else { } else {
(id_flag("-u")?, id_flag("-g")?) (id_flag("-u").await?, id_flag("-g").await?)
}; };
let container_dir = match &config.container_workspace { let container_dir = match &config.container_workspace {
@@ -209,7 +218,7 @@ fn default_container_dir(workspace: &Path) -> String {
} }
} }
fn write_compose_tempfile(content: &str) -> Result<tempfile::NamedTempFile> { pub fn write_compose_tempfile(content: &str) -> Result<tempfile::NamedTempFile> {
let mut tmp = tempfile::Builder::new() let mut tmp = tempfile::Builder::new()
.prefix("sandcage-compose-") .prefix("sandcage-compose-")
.suffix(".yml") .suffix(".yml")
@@ -223,22 +232,23 @@ fn write_compose_tempfile(content: &str) -> Result<tempfile::NamedTempFile> {
Ok(tmp) Ok(tmp)
} }
fn require_image(docker: &Path, image: &str) -> Result<()> { pub async fn require_image(docker: &Path, image: &str) -> Result<()> {
let output = Command::new(docker) let status = Command::new(docker)
.args(["image", "inspect", image]) .args(["image", "inspect", image])
.stdout(std::process::Stdio::null()) .stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null()) .stderr(std::process::Stdio::null())
.status(); .status()
.await;
match output { match status {
Ok(status) if status.success() => Ok(()), Ok(s) if s.success() => Ok(()),
_ => Err(DockerError::ImageNotFound { _ => Err(DockerError::ImageNotFound {
image: image.to_string(), image: image.to_string(),
}), }),
} }
} }
fn expand_mount_path(mount: &str) -> String { pub fn expand_mount_path(mount: &str) -> String {
let Some(colon_pos) = mount.find(':') else { let Some(colon_pos) = mount.find(':') else {
return mount.to_string(); return mount.to_string();
}; };
@@ -332,7 +342,7 @@ pub fn build_run_args(
args args
} }
pub fn run_service( pub async fn run_service(
service: &str, service: &str,
workspace: &Path, workspace: &Path,
config: &SandcageConfig, config: &SandcageConfig,
@@ -341,7 +351,7 @@ pub fn run_service(
extra_args: &[String], extra_args: &[String],
) -> Result<()> { ) -> Result<()> {
let docker = require_docker()?; let docker = require_docker()?;
require_compose(&docker)?; require_compose(&docker).await?;
let svc = registry.get(service).ok_or_else(|| DockerError::UnknownService { let svc = registry.get(service).ok_or_else(|| DockerError::UnknownService {
service: service.to_string(), service: service.to_string(),
@@ -354,14 +364,15 @@ pub fn run_service(
}); });
} }
let ctx = build_compose_context(workspace, config)?; let ctx = build_compose_context(workspace, config).await?;
let image_tag = format!("{}:latest", ctx.image); let image_tag = format!("{}:latest", ctx.image);
require_image(&docker, &image_tag)?; require_image(&docker, &image_tag).await?;
if config.ssh_mode.as_deref() == Some("volume") { if config.ssh_mode.as_deref() == Some("volume") {
let vol_check = Command::new(&docker) let vol_check = Command::new(&docker)
.args(["volume", "inspect", "sandcage-ssh"]) .args(["volume", "inspect", "sandcage-ssh"])
.output(); .output()
.await;
if !vol_check.map(|o| o.status.success()).unwrap_or(false) { if !vol_check.map(|o| o.status.success()).unwrap_or(false) {
eprintln!( eprintln!(
"sandcage: SSH volume not found — run 'sandcage setup ssh --refresh' to populate it" "sandcage: SSH volume not found — run 'sandcage setup ssh --refresh' to populate it"
@@ -382,7 +393,7 @@ pub fn run_service(
.stdout(std::process::Stdio::inherit()) .stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit()); .stderr(std::process::Stdio::inherit());
let status = cmd.status().map_err(DockerError::SpawnFailed)?; let status = cmd.status().await.map_err(DockerError::SpawnFailed)?;
if !status.success() { if !status.success() {
return Err(DockerError::ServiceFailed { return Err(DockerError::ServiceFailed {
@@ -452,7 +463,7 @@ fn write_stored_hashes(path: &Path, hashes: &HashMap<String, String>) -> Result<
} }
/// Build a single image from inline Dockerfile content (temp build context). /// Build a single image from inline Dockerfile content (temp build context).
fn build_one_image_from_content(docker: &Path, image: &str, dockerfile_content: &str, no_cache: bool) -> Result<()> { async fn build_one_image_from_content(docker: &Path, image: &str, dockerfile_content: &str, no_cache: bool) -> Result<()> {
let tmp_dir = tempfile::Builder::new() let tmp_dir = tempfile::Builder::new()
.prefix("sandcage-build-") .prefix("sandcage-build-")
.tempdir() .tempdir()
@@ -473,26 +484,26 @@ fn build_one_image_from_content(docker: &Path, image: &str, dockerfile_content:
} }
} }
run_docker_build(docker, image, tmp_dir.path(), None, no_cache) run_docker_build(docker, image, tmp_dir.path(), None, no_cache).await
} }
/// Build a single image from a user-provided path. /// Build a single image from a user-provided path.
/// If the path is a directory, it's used as the build context. /// If the path is a directory, it's used as the build context.
/// If the path is a file, it's used as the Dockerfile with a temp context. /// If the path is a file, it's used as the Dockerfile with a temp context.
fn build_one_image_from_path(docker: &Path, image: &str, override_path: &Path, no_cache: bool) -> Result<()> { async fn build_one_image_from_path(docker: &Path, image: &str, override_path: &Path, no_cache: bool) -> Result<()> {
if override_path.is_dir() { if override_path.is_dir() {
run_docker_build(docker, image, override_path, None, no_cache) run_docker_build(docker, image, override_path, None, no_cache).await
} else { } else {
let tmp_dir = tempfile::Builder::new() let tmp_dir = tempfile::Builder::new()
.prefix("sandcage-build-") .prefix("sandcage-build-")
.tempdir() .tempdir()
.map_err(DockerError::TempDirFailed)?; .map_err(DockerError::TempDirFailed)?;
run_docker_build(docker, image, tmp_dir.path(), Some(override_path), no_cache) run_docker_build(docker, image, tmp_dir.path(), Some(override_path), no_cache).await
} }
} }
fn run_docker_build( async fn run_docker_build(
docker: &Path, docker: &Path,
image: &str, image: &str,
context: &Path, context: &Path,
@@ -515,7 +526,7 @@ fn run_docker_build(
.stdout(std::process::Stdio::inherit()) .stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit()); .stderr(std::process::Stdio::inherit());
let status = cmd.status().map_err(DockerError::SpawnFailed)?; let status = cmd.status().await.map_err(DockerError::SpawnFailed)?;
if !status.success() { if !status.success() {
return Err(DockerError::BuildFailed { return Err(DockerError::BuildFailed {
@@ -544,7 +555,7 @@ fn read_dockerfile_at(path: &Path) -> Result<String> {
/// * When `force` is `true`, every image is rebuilt regardless of the stored hash. /// * When `force` is `true`, every image is rebuilt regardless of the stored hash.
/// * Otherwise, an image is skipped when its Dockerfile hash matches the stored value. /// * Otherwise, an image is skipped when its Dockerfile hash matches the stored value.
/// * After a successful build the hash is updated in `~/.sandcage/.build-hashes`. /// * After a successful build the hash is updated in `~/.sandcage/.build-hashes`.
pub fn build_images(force: bool, service_filter: &[String]) -> Result<()> { pub async fn build_images(force: bool, service_filter: &[String]) -> Result<()> {
let docker = require_docker()?; let docker = require_docker()?;
let home = dirs::home_dir().ok_or(DockerError::NoHomeDir)?; let home = dirs::home_dir().ok_or(DockerError::NoHomeDir)?;
@@ -634,9 +645,9 @@ pub fn build_images(force: bool, service_filter: &[String]) -> Result<()> {
} }
if let Some(path) = override_path { if let Some(path) = override_path {
build_one_image_from_path(&docker, image, path, force)?; build_one_image_from_path(&docker, image, path, force).await?;
} else { } else {
build_one_image_from_content(&docker, image, bundled_dockerfile, force)?; build_one_image_from_content(&docker, image, bundled_dockerfile, force).await?;
} }
stored.insert(image.to_string(), current_hash); stored.insert(image.to_string(), current_hash);
@@ -682,10 +693,10 @@ mod tests {
assert!(yaml.contains("shell:"), "should contain shell service"); assert!(yaml.contains("shell:"), "should contain shell service");
} }
#[test] #[tokio::test]
fn build_compose_env_contains_required_keys() { async fn build_compose_env_contains_required_keys() {
let workspace = PathBuf::from("/tmp/test-workspace"); let workspace = PathBuf::from("/tmp/test-workspace");
let env = build_compose_env(&workspace, &SandcageConfig::default()).expect("build_compose_env"); let env = build_compose_env(&workspace, &SandcageConfig::default()).await.expect("build_compose_env");
assert!(env.contains_key("SANDCAGE_UID"), "missing SANDCAGE_UID"); assert!(env.contains_key("SANDCAGE_UID"), "missing SANDCAGE_UID");
assert!(env.contains_key("SANDCAGE_GID"), "missing SANDCAGE_GID"); assert!(env.contains_key("SANDCAGE_GID"), "missing SANDCAGE_GID");
@@ -694,17 +705,17 @@ mod tests {
assert!(env.contains_key("SANDCAGE_CONTAINER_DIR"), "missing SANDCAGE_CONTAINER_DIR"); assert!(env.contains_key("SANDCAGE_CONTAINER_DIR"), "missing SANDCAGE_CONTAINER_DIR");
} }
#[test] #[tokio::test]
fn build_compose_env_workspace_matches() { async fn build_compose_env_workspace_matches() {
let workspace = PathBuf::from("/my/project"); let workspace = PathBuf::from("/my/project");
let env = build_compose_env(&workspace, &SandcageConfig::default()).expect("build_compose_env"); let env = build_compose_env(&workspace, &SandcageConfig::default()).await.expect("build_compose_env");
assert_eq!(env["SANDCAGE_WORKSPACE"], "/my/project"); assert_eq!(env["SANDCAGE_WORKSPACE"], "/my/project");
} }
#[test] #[tokio::test]
fn build_compose_env_home_ends_with_sandcage() { async fn build_compose_env_home_ends_with_sandcage() {
let workspace = PathBuf::from("/tmp"); let workspace = PathBuf::from("/tmp");
let env = build_compose_env(&workspace, &SandcageConfig::default()).expect("build_compose_env"); let env = build_compose_env(&workspace, &SandcageConfig::default()).await.expect("build_compose_env");
assert!( assert!(
env["SANDCAGE_HOME"].ends_with(".sandcage"), env["SANDCAGE_HOME"].ends_with(".sandcage"),
"SANDCAGE_HOME should end with .sandcage, got: {}", "SANDCAGE_HOME should end with .sandcage, got: {}",
@@ -712,10 +723,10 @@ mod tests {
); );
} }
#[test] #[tokio::test]
fn uid_gid_are_numeric() { async fn uid_gid_are_numeric() {
let workspace = PathBuf::from("/tmp"); let workspace = PathBuf::from("/tmp");
let env = build_compose_env(&workspace, &SandcageConfig::default()).expect("build_compose_env"); let env = build_compose_env(&workspace, &SandcageConfig::default()).await.expect("build_compose_env");
let uid: u32 = env["SANDCAGE_UID"] let uid: u32 = env["SANDCAGE_UID"]
.parse() .parse()
@@ -861,13 +872,13 @@ mod tests {
assert!(path.exists(), "hash file should have been created"); assert!(path.exists(), "hash file should have been created");
} }
#[test] #[tokio::test]
fn require_image_fails_for_nonexistent_image() { async fn require_image_fails_for_nonexistent_image() {
let docker = match require_docker() { let docker = match require_docker() {
Ok(path) => path, Ok(path) => path,
Err(_) => return, // skip if docker not installed Err(_) => return, // skip if docker not installed
}; };
let result = require_image(&docker, "sandcage-nonexistent-image-abc123"); let result = require_image(&docker, "sandcage-nonexistent-image-abc123").await;
assert!( assert!(
matches!(result, Err(DockerError::ImageNotFound { .. })), matches!(result, Err(DockerError::ImageNotFound { .. })),
"should fail for nonexistent image: {result:?}" "should fail for nonexistent image: {result:?}"
@@ -944,33 +955,33 @@ mod tests {
assert!(args.contains(&"--entrypoint".to_string())); assert!(args.contains(&"--entrypoint".to_string()));
} }
#[test] #[tokio::test]
fn build_compose_env_container_dir_auto_derived() { async fn build_compose_env_container_dir_auto_derived() {
let workspace = PathBuf::from("/home/user/projects/my-app"); let workspace = PathBuf::from("/home/user/projects/my-app");
let config = SandcageConfig::default(); let config = SandcageConfig::default();
let env = build_compose_env(&workspace, &config).expect("build_compose_env"); let env = build_compose_env(&workspace, &config).await.expect("build_compose_env");
assert_eq!(env["SANDCAGE_CONTAINER_DIR"], "/workspace/my-app"); assert_eq!(env["SANDCAGE_CONTAINER_DIR"], "/workspace/my-app");
} }
#[test] #[tokio::test]
fn build_compose_env_container_dir_from_config() { async fn build_compose_env_container_dir_from_config() {
let workspace = PathBuf::from("/home/user/projects/my-app"); let workspace = PathBuf::from("/home/user/projects/my-app");
let config = SandcageConfig { let config = SandcageConfig {
container_workspace: Some("/workspace/custom".to_string()), container_workspace: Some("/workspace/custom".to_string()),
..Default::default() ..Default::default()
}; };
let env = build_compose_env(&workspace, &config).expect("build_compose_env"); let env = build_compose_env(&workspace, &config).await.expect("build_compose_env");
assert_eq!(env["SANDCAGE_CONTAINER_DIR"], "/workspace/custom"); assert_eq!(env["SANDCAGE_CONTAINER_DIR"], "/workspace/custom");
} }
#[test] #[tokio::test]
fn build_compose_env_container_dir_ignores_relative_override() { async fn build_compose_env_container_dir_ignores_relative_override() {
let workspace = PathBuf::from("/home/user/projects/my-app"); let workspace = PathBuf::from("/home/user/projects/my-app");
let config = SandcageConfig { let config = SandcageConfig {
container_workspace: Some("relative/path".to_string()), container_workspace: Some("relative/path".to_string()),
..Default::default() ..Default::default()
}; };
let env = build_compose_env(&workspace, &config).expect("build_compose_env"); let env = build_compose_env(&workspace, &config).await.expect("build_compose_env");
assert_eq!( assert_eq!(
env["SANDCAGE_CONTAINER_DIR"], "/workspace/my-app", env["SANDCAGE_CONTAINER_DIR"], "/workspace/my-app",
"relative paths should be ignored, falling back to auto-derive" "relative paths should be ignored, falling back to auto-derive"
@@ -1142,11 +1153,11 @@ mod tests {
// May have .ssh in other mounts, but no SSH-specific mount // May have .ssh in other mounts, but no SSH-specific mount
} }
#[test] #[tokio::test]
fn build_compose_env_container_dir_root_fallback() { async fn build_compose_env_container_dir_root_fallback() {
let workspace = PathBuf::from("/"); let workspace = PathBuf::from("/");
let config = SandcageConfig::default(); let config = SandcageConfig::default();
let env = build_compose_env(&workspace, &config).expect("build_compose_env"); let env = build_compose_env(&workspace, &config).await.expect("build_compose_env");
assert_eq!(env["SANDCAGE_CONTAINER_DIR"], "/workspace"); assert_eq!(env["SANDCAGE_CONTAINER_DIR"], "/workspace");
} }
+4
View File
@@ -1,3 +1,4 @@
pub mod backend;
pub mod config; pub mod config;
pub mod docker; pub mod docker;
pub mod init; pub mod init;
@@ -5,3 +6,6 @@ pub mod service;
pub mod setup; pub mod setup;
pub mod ssh_config; pub mod ssh_config;
pub mod workspace; pub mod workspace;
#[cfg(feature = "bollard")]
pub mod acp;
+23 -8
View File
@@ -84,6 +84,12 @@ enum Commands {
#[command(subcommand)] #[command(subcommand)]
action: SetupAction, action: SetupAction,
}, },
/// ACP (Agent Control Protocol) commands
#[cfg(feature = "bollard")]
Acp {
#[command(subcommand)]
action: sandcage::acp::AcpCommands,
},
} }
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
@@ -131,7 +137,7 @@ enum AppError {
Setup(#[from] setup::SetupError), Setup(#[from] setup::SetupError),
} }
fn run_service(service: &str, path: Option<PathBuf>, shell_override: bool, agent_args: Vec<String>) -> std::result::Result<(), AppError> { async fn run_service(service: &str, path: Option<PathBuf>, shell_override: bool, agent_args: Vec<String>) -> std::result::Result<(), AppError> {
let workspace = workspace::resolve_workspace(path.as_deref())?; let workspace = workspace::resolve_workspace(path.as_deref())?;
eprintln!("sandcage: workspace \u{2192} {}", workspace.display()); eprintln!("sandcage: workspace \u{2192} {}", workspace.display());
@@ -150,27 +156,28 @@ fn run_service(service: &str, path: Option<PathBuf>, shell_override: bool, agent
let registry = service::registry::build_default_registry(&cfg); let registry = service::registry::build_default_registry(&cfg);
eprintln!("sandcage: service \u{2192} {service}"); eprintln!("sandcage: service \u{2192} {service}");
docker::run_service(service, &workspace, &cfg, &registry, shell_override, &agent_args)?; docker::run_service(service, &workspace, &cfg, &registry, shell_override, &agent_args).await?;
Ok(()) Ok(())
} }
fn main() -> miette::Result<()> { #[tokio::main]
async fn main() -> miette::Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
match cli.command { match cli.command {
Commands::Claude { path, shell, agent_args } => { Commands::Claude { path, shell, agent_args } => {
run_service("claude", path, shell, agent_args)? run_service("claude", path, shell, agent_args).await?
} }
Commands::Codex { path, shell, agent_args } => { Commands::Codex { path, shell, agent_args } => {
run_service("codex", path, shell, agent_args)? run_service("codex", path, shell, agent_args).await?
} }
Commands::Gemini { path, shell, agent_args } => { Commands::Gemini { path, shell, agent_args } => {
run_service("gemini", path, shell, agent_args)? run_service("gemini", path, shell, agent_args).await?
} }
Commands::Shell { path } => run_service("shell", path, false, vec![])?, Commands::Shell { path } => run_service("shell", path, false, vec![]).await?,
Commands::Build { force, services } => { Commands::Build { force, services } => {
docker::build_images(force, &services)?; docker::build_images(force, &services).await?;
} }
Commands::Init => { Commands::Init => {
let workspace = workspace::resolve_workspace(None)?; let workspace = workspace::resolve_workspace(None)?;
@@ -181,6 +188,14 @@ fn main() -> miette::Result<()> {
setup::run_ssh_setup(global, yes, refresh, bind)?; setup::run_ssh_setup(global, yes, refresh, bind)?;
} }
}, },
#[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(()) Ok(())
+116
View File
@@ -212,6 +212,122 @@ sandcage setup ssh --refresh
--- ---
## acp
Manage and run ACP (Agent Control Protocol) agents. This command group requires the `bollard` feature, which is enabled by default.
```
sandcage acp <SUBCOMMAND>
```
---
### acp run
Run a service in ACP relay mode, attaching stdin/stdout/stderr for bidirectional communication with the container. Uses the Bollard Docker API directly instead of docker compose. Designed for structured agent orchestration.
```
sandcage acp run <SERVICE> [OPTIONS] [-- AGENT_ARGS...]
```
**Options:**
| Flag | Description |
|------|-------------|
| `-p, --path <PATH>` | Path to the project directory (defaults to current directory) |
**Positional arguments:** `<SERVICE>` — the service name to run (e.g. `claude`, `codex`).
**Trailing arguments:** Any arguments after `--` are forwarded directly to the agent binary inside the container.
**Examples:**
```sh
# Run the claude service in ACP relay mode
sandcage acp run claude
# Run against a specific project directory
sandcage acp run claude --path /home/user/myproject
# Forward arguments to the agent
sandcage acp run claude -- --resume
```
---
### acp install
Install an agent from the ACP registry. Downloads the agent binary for the current platform and caches it at `~/.sandcage/agents/<id>/<version>/`.
```
sandcage acp install <AGENT_ID> [OPTIONS]
```
**Options:**
| Flag | Description |
|------|-------------|
| `--force` | Reinstall even if the agent is already installed |
**Positional arguments:** `<AGENT_ID>` — the identifier of the agent to install from the registry.
**Examples:**
```sh
# Install an agent from the registry
sandcage acp install my-agent
# Force reinstall an already-installed agent
sandcage acp install my-agent --force
```
---
### acp list
List available agents in the ACP registry. Fetches the agent list from the CDN with a 1-hour local cache.
```
sandcage acp list [OPTIONS]
```
**Options:**
| Flag | Description |
|------|-------------|
| `--refresh` | Bypass the local cache and fetch a fresh list from the registry |
**Examples:**
```sh
# List available agents (uses cache if fresh)
sandcage acp list
# Force a fresh fetch from the registry
sandcage acp list --refresh
```
---
### acp installed
List locally installed ACP agents.
```
sandcage acp installed
```
No options.
**Examples:**
```sh
# Show all locally installed ACP agents
sandcage acp installed
```
---
## Common Patterns ## Common Patterns
**Project path resolution:** All agent commands and `shell` accept `--path` (`-p`) to specify the project directory. When omitted, sandcage resolves the workspace from the current directory. **Project path resolution:** All agent commands and `shell` accept `--path` (`-p`) to specify the project directory. When omitted, sandcage resolves the workspace from the current directory.