🛰️ 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 init # generate .sandcage.yml for your project
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
@@ -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
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
<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
- **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
+23
View File
@@ -6,9 +6,20 @@ 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",
]
[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"] }
@@ -21,3 +32,15 @@ indexmap = { version = "2", features = ["serde"] }
tempfile = "3"
dialoguer = "0.11"
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::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use tokio::process::Command;
use miette::Diagnostic;
use sha2::{Digest, Sha256};
@@ -92,18 +92,26 @@ pub enum DockerError {
help("Enable it with: services.{service}.enabled: true")
)]
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>;
fn require_docker() -> Result<PathBuf> {
pub fn require_docker() -> Result<PathBuf> {
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)
.args(["compose", "version"])
.output()
.await
.map_err(|_| DockerError::ComposeNotFound)?;
if !output.status.success() {
@@ -112,10 +120,11 @@ fn require_compose(docker: &Path) -> Result<()> {
Ok(())
}
fn id_flag(flag: &str) -> Result<String> {
async fn id_flag(flag: &str) -> Result<String> {
let output = Command::new("id")
.arg(flag)
.output()
.await
.map_err(|e| DockerError::IdFailed(e.to_string()))?;
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())
}
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 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) {
("1000".to_string(), "1000".to_string())
} else {
(id_flag("-u")?, id_flag("-g")?)
(id_flag("-u").await?, id_flag("-g").await?)
};
let container_dir = match &config.container_workspace {
@@ -168,14 +177,14 @@ pub fn build_compose_env(workspace: &Path, config: &SandcageConfig) -> Result<Ha
Ok(env)
}
pub fn build_compose_context(workspace: &Path, config: &SandcageConfig) -> Result<crate::service::ComposeContext> {
pub async fn build_compose_context(workspace: &Path, config: &SandcageConfig) -> Result<crate::service::ComposeContext> {
let home = dirs::home_dir().ok_or(DockerError::NoHomeDir)?;
let sandcage_home = home.join(".sandcage");
let (uid, gid) = if cfg!(windows) {
("1000".to_string(), "1000".to_string())
} else {
(id_flag("-u")?, id_flag("-g")?)
(id_flag("-u").await?, id_flag("-g").await?)
};
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()
.prefix("sandcage-compose-")
.suffix(".yml")
@@ -223,22 +232,23 @@ fn write_compose_tempfile(content: &str) -> Result<tempfile::NamedTempFile> {
Ok(tmp)
}
fn require_image(docker: &Path, image: &str) -> Result<()> {
let output = Command::new(docker)
pub async fn require_image(docker: &Path, image: &str) -> Result<()> {
let status = Command::new(docker)
.args(["image", "inspect", image])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
.status()
.await;
match output {
Ok(status) if status.success() => Ok(()),
match status {
Ok(s) if s.success() => Ok(()),
_ => Err(DockerError::ImageNotFound {
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 {
return mount.to_string();
};
@@ -332,7 +342,7 @@ pub fn build_run_args(
args
}
pub fn run_service(
pub async fn run_service(
service: &str,
workspace: &Path,
config: &SandcageConfig,
@@ -341,7 +351,7 @@ pub fn run_service(
extra_args: &[String],
) -> Result<()> {
let docker = require_docker()?;
require_compose(&docker)?;
require_compose(&docker).await?;
let svc = registry.get(service).ok_or_else(|| DockerError::UnknownService {
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);
require_image(&docker, &image_tag)?;
require_image(&docker, &image_tag).await?;
if config.ssh_mode.as_deref() == Some("volume") {
let vol_check = Command::new(&docker)
.args(["volume", "inspect", "sandcage-ssh"])
.output();
.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"
@@ -382,7 +393,7 @@ pub fn run_service(
.stdout(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() {
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).
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()
.prefix("sandcage-build-")
.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.
/// 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.
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() {
run_docker_build(docker, image, override_path, None, no_cache)
run_docker_build(docker, image, override_path, None, no_cache).await
} else {
let tmp_dir = tempfile::Builder::new()
.prefix("sandcage-build-")
.tempdir()
.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,
image: &str,
context: &Path,
@@ -515,7 +526,7 @@ fn run_docker_build(
.stdout(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() {
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.
/// * Otherwise, an image is skipped when its Dockerfile hash matches the stored value.
/// * After a successful build the hash is updated in `~/.sandcage/.build-hashes`.
pub fn build_images(force: bool, service_filter: &[String]) -> Result<()> {
pub async fn build_images(force: bool, service_filter: &[String]) -> Result<()> {
let docker = require_docker()?;
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 {
build_one_image_from_path(&docker, image, path, force)?;
build_one_image_from_path(&docker, image, path, force).await?;
} 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);
@@ -682,10 +693,10 @@ mod tests {
assert!(yaml.contains("shell:"), "should contain shell service");
}
#[test]
fn build_compose_env_contains_required_keys() {
#[tokio::test]
async fn build_compose_env_contains_required_keys() {
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_GID"), "missing SANDCAGE_GID");
@@ -694,17 +705,17 @@ mod tests {
assert!(env.contains_key("SANDCAGE_CONTAINER_DIR"), "missing SANDCAGE_CONTAINER_DIR");
}
#[test]
fn build_compose_env_workspace_matches() {
#[tokio::test]
async fn build_compose_env_workspace_matches() {
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");
}
#[test]
fn build_compose_env_home_ends_with_sandcage() {
#[tokio::test]
async fn build_compose_env_home_ends_with_sandcage() {
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!(
env["SANDCAGE_HOME"].ends_with(".sandcage"),
"SANDCAGE_HOME should end with .sandcage, got: {}",
@@ -712,10 +723,10 @@ mod tests {
);
}
#[test]
fn uid_gid_are_numeric() {
#[tokio::test]
async fn uid_gid_are_numeric() {
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"]
.parse()
@@ -861,13 +872,13 @@ mod tests {
assert!(path.exists(), "hash file should have been created");
}
#[test]
fn require_image_fails_for_nonexistent_image() {
#[tokio::test]
async fn require_image_fails_for_nonexistent_image() {
let docker = match require_docker() {
Ok(path) => path,
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!(
matches!(result, Err(DockerError::ImageNotFound { .. })),
"should fail for nonexistent image: {result:?}"
@@ -944,33 +955,33 @@ mod tests {
assert!(args.contains(&"--entrypoint".to_string()));
}
#[test]
fn build_compose_env_container_dir_auto_derived() {
#[tokio::test]
async fn build_compose_env_container_dir_auto_derived() {
let workspace = PathBuf::from("/home/user/projects/my-app");
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");
}
#[test]
fn build_compose_env_container_dir_from_config() {
#[tokio::test]
async fn build_compose_env_container_dir_from_config() {
let workspace = PathBuf::from("/home/user/projects/my-app");
let config = SandcageConfig {
container_workspace: Some("/workspace/custom".to_string()),
..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");
}
#[test]
fn build_compose_env_container_dir_ignores_relative_override() {
#[tokio::test]
async fn build_compose_env_container_dir_ignores_relative_override() {
let workspace = PathBuf::from("/home/user/projects/my-app");
let config = SandcageConfig {
container_workspace: Some("relative/path".to_string()),
..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/my-app",
"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
}
#[test]
fn build_compose_env_container_dir_root_fallback() {
#[tokio::test]
async fn build_compose_env_container_dir_root_fallback() {
let workspace = PathBuf::from("/");
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");
}
+4
View File
@@ -1,3 +1,4 @@
pub mod backend;
pub mod config;
pub mod docker;
pub mod init;
@@ -5,3 +6,6 @@ pub mod service;
pub mod setup;
pub mod ssh_config;
pub mod workspace;
#[cfg(feature = "bollard")]
pub mod acp;
+23 -8
View File
@@ -84,6 +84,12 @@ enum Commands {
#[command(subcommand)]
action: SetupAction,
},
/// ACP (Agent Control Protocol) commands
#[cfg(feature = "bollard")]
Acp {
#[command(subcommand)]
action: sandcage::acp::AcpCommands,
},
}
#[derive(Subcommand, Debug)]
@@ -131,7 +137,7 @@ enum AppError {
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())?;
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);
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(())
}
fn main() -> miette::Result<()> {
#[tokio::main]
async fn main() -> miette::Result<()> {
let cli = Cli::parse();
match cli.command {
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 } => {
run_service("codex", path, shell, agent_args)?
run_service("codex", path, shell, agent_args).await?
}
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 } => {
docker::build_images(force, &services)?;
docker::build_images(force, &services).await?;
}
Commands::Init => {
let workspace = workspace::resolve_workspace(None)?;
@@ -181,6 +188,14 @@ fn main() -> miette::Result<()> {
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(())
+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
**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.