🛰️ export from upstream (31619fc)
This commit is contained in:
Generated
+1243
-1
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const REGISTRY_URL: &str =
|
||||
"https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
|
||||
const REFRESH_INTERVAL_SECS: u64 = 3600;
|
||||
const FETCH_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RegistryIndex {
|
||||
#[serde(rename = "version")]
|
||||
pub _version: String,
|
||||
pub agents: Vec<RegistryEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct RegistryEntry {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub repository: Option<String>,
|
||||
#[serde(default)]
|
||||
pub website: Option<String>,
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
pub distribution: RegistryDistribution,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct RegistryDistribution {
|
||||
#[serde(default)]
|
||||
pub binary: Option<HashMap<String, RegistryBinaryTarget>>,
|
||||
#[serde(default)]
|
||||
pub npx: Option<RegistryNpxDistribution>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct RegistryBinaryTarget {
|
||||
pub archive: String,
|
||||
pub cmd: String,
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct RegistryNpxDistribution {
|
||||
pub package: String,
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AgentMeta {
|
||||
pub cmd: String,
|
||||
pub args: Vec<String>,
|
||||
pub env: HashMap<String, String>,
|
||||
pub version: String,
|
||||
pub installed_at: String,
|
||||
}
|
||||
|
||||
fn agents_dir() -> Option<PathBuf> {
|
||||
dirs::home_dir().map(|h| h.join(".sandcage").join("agents"))
|
||||
}
|
||||
|
||||
fn cache_path() -> Option<PathBuf> {
|
||||
agents_dir().map(|d| d.join("registry.json"))
|
||||
}
|
||||
|
||||
fn timestamp_path() -> Option<PathBuf> {
|
||||
agents_dir().map(|d| d.join("registry.json.timestamp"))
|
||||
}
|
||||
|
||||
fn is_cache_fresh() -> bool {
|
||||
let Some(ts_path) = timestamp_path() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(content) = std::fs::read_to_string(&ts_path) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(ts) = content.trim().parse::<u64>() else {
|
||||
return false;
|
||||
};
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
now.saturating_sub(ts) < REFRESH_INTERVAL_SECS
|
||||
}
|
||||
|
||||
fn write_timestamp() {
|
||||
if let Some(ts_path) = timestamp_path() {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
std::fs::write(&ts_path, now.to_string()).ok();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn fetch_registry(force_refresh: bool) -> std::result::Result<RegistryIndex, String> {
|
||||
let cache = cache_path().ok_or("cannot determine home directory")?;
|
||||
|
||||
if !force_refresh && is_cache_fresh() {
|
||||
if let Ok(data) = std::fs::read_to_string(&cache) {
|
||||
if let Ok(index) = serde_json::from_str::<RegistryIndex>(&data) {
|
||||
return Ok(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(FETCH_TIMEOUT_SECS))
|
||||
.build()
|
||||
.map_err(|e| format!("HTTP client error: {e}"))?;
|
||||
|
||||
let resp = client
|
||||
.get(REGISTRY_URL)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch registry: {e}"))?;
|
||||
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read registry response: {e}"))?;
|
||||
|
||||
let index: RegistryIndex =
|
||||
serde_json::from_str(&body).map_err(|e| format!("Failed to parse registry JSON: {e}"))?;
|
||||
|
||||
if let Some(dir) = agents_dir() {
|
||||
std::fs::create_dir_all(&dir).ok();
|
||||
}
|
||||
if let Some(ref path) = cache_path() {
|
||||
std::fs::write(path, &body).ok();
|
||||
}
|
||||
write_timestamp();
|
||||
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
pub fn find_agent<'a>(index: &'a RegistryIndex, agent_id: &str) -> Option<&'a RegistryEntry> {
|
||||
index.agents.iter().find(|a| a.id == agent_id)
|
||||
}
|
||||
|
||||
pub fn platform_key() -> &'static str {
|
||||
if cfg!(target_arch = "x86_64") {
|
||||
"linux-x86_64"
|
||||
} else if cfg!(target_arch = "aarch64") {
|
||||
"linux-aarch64"
|
||||
} else {
|
||||
"linux-x86_64"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn agent_install_dir(agent_id: &str, version: &str) -> Option<PathBuf> {
|
||||
let sanitized_id = agent_id.replace(|c: char| !c.is_alphanumeric() && c != '-' && c != '_', "-");
|
||||
agents_dir().map(|d| d.join(&sanitized_id).join(version))
|
||||
}
|
||||
|
||||
pub fn is_agent_installed(agent_id: &str, version: &str) -> bool {
|
||||
agent_install_dir(agent_id, version)
|
||||
.map(|d| d.exists())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub async fn install_agent(
|
||||
entry: &RegistryEntry,
|
||||
target: &RegistryBinaryTarget,
|
||||
) -> std::result::Result<PathBuf, String> {
|
||||
let install_dir = agent_install_dir(&entry.id, &entry.version)
|
||||
.ok_or("cannot determine install directory")?;
|
||||
|
||||
if install_dir.exists() {
|
||||
return Ok(install_dir);
|
||||
}
|
||||
|
||||
eprintln!("sandcage: downloading {} v{}...", entry.name, entry.version);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(300))
|
||||
.build()
|
||||
.map_err(|e| format!("HTTP client error: {e}"))?;
|
||||
|
||||
let resp = client
|
||||
.get(&target.archive)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to download agent: {e}"))?;
|
||||
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read agent archive: {e}"))?;
|
||||
|
||||
let parent = install_dir.parent().ok_or("invalid install path")?;
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {e}"))?;
|
||||
|
||||
let url_lower = target.archive.to_lowercase();
|
||||
if url_lower.ends_with(".tar.gz") || url_lower.ends_with(".tgz") {
|
||||
let decoder = flate2::read::GzDecoder::new(&bytes[..]);
|
||||
let mut archive = tar::Archive::new(decoder);
|
||||
archive
|
||||
.unpack(&install_dir)
|
||||
.map_err(|e| format!("Failed to extract tar.gz: {e}"))?;
|
||||
} else if url_lower.ends_with(".tar.bz2") || url_lower.ends_with(".tbz2") {
|
||||
return Err("tar.bz2 extraction not yet supported".to_string());
|
||||
} else if url_lower.ends_with(".zip") {
|
||||
return Err("zip extraction not yet supported — use tar.gz agents".to_string());
|
||||
} else {
|
||||
return Err(format!("Unknown archive format: {}", target.archive));
|
||||
}
|
||||
|
||||
let meta = AgentMeta {
|
||||
cmd: target.cmd.clone(),
|
||||
args: target.args.clone(),
|
||||
env: target.env.clone(),
|
||||
version: entry.version.clone(),
|
||||
installed_at: chrono_free_now(),
|
||||
};
|
||||
let meta_path = install_dir.join(".sandcage-meta.json");
|
||||
let meta_json = serde_json::to_string_pretty(&meta)
|
||||
.map_err(|e| format!("Failed to serialize meta: {e}"))?;
|
||||
std::fs::write(&meta_path, meta_json)
|
||||
.map_err(|e| format!("Failed to write meta: {e}"))?;
|
||||
|
||||
cleanup_old_versions(&entry.id, &entry.version);
|
||||
|
||||
eprintln!("sandcage: installed {} v{}", entry.name, entry.version);
|
||||
Ok(install_dir)
|
||||
}
|
||||
|
||||
fn chrono_free_now() -> String {
|
||||
let dur = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
format!("{}", dur.as_secs())
|
||||
}
|
||||
|
||||
fn cleanup_old_versions(agent_id: &str, current_version: &str) {
|
||||
let sanitized_id = agent_id.replace(|c: char| !c.is_alphanumeric() && c != '-' && c != '_', "-");
|
||||
let Some(base) = agents_dir().map(|d| d.join(&sanitized_id)) else {
|
||||
return;
|
||||
};
|
||||
let Ok(entries) = std::fs::read_dir(&base) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
if name_str != current_version && entry.path().is_dir() && name_str != ".sandcage-meta.json" {
|
||||
std::fs::remove_dir_all(entry.path()).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_agent_command(
|
||||
install_dir: &Path,
|
||||
target: &RegistryBinaryTarget,
|
||||
) -> std::result::Result<(PathBuf, Vec<String>, HashMap<String, String>), String> {
|
||||
let cmd = target.cmd.strip_prefix("./").unwrap_or(&target.cmd);
|
||||
|
||||
if cmd.contains("..") {
|
||||
return Err("path traversal in agent cmd is not allowed".to_string());
|
||||
}
|
||||
|
||||
let bin_path = install_dir.join(cmd);
|
||||
if !bin_path.exists() {
|
||||
return Err(format!(
|
||||
"agent binary not found at {}",
|
||||
bin_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
Ok((bin_path, target.args.clone(), target.env.clone()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_registry_json() -> &'static str {
|
||||
r#"{
|
||||
"version": "1",
|
||||
"agents": [
|
||||
{
|
||||
"id": "test-agent",
|
||||
"name": "Test Agent",
|
||||
"version": "1.0.0",
|
||||
"description": "A test agent",
|
||||
"distribution": {
|
||||
"binary": {
|
||||
"linux-x86_64": {
|
||||
"archive": "https://example.com/test-agent-linux-x86_64.tar.gz",
|
||||
"cmd": "./test-agent",
|
||||
"args": ["--acp"],
|
||||
"env": {"FOO": "bar"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "npx-only-agent",
|
||||
"name": "NPX Agent",
|
||||
"version": "2.0.0",
|
||||
"description": "NPX only",
|
||||
"distribution": {
|
||||
"npx": {
|
||||
"package": "@test/agent@2.0.0",
|
||||
"args": ["--stdio"],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_registry_index() {
|
||||
let index: RegistryIndex =
|
||||
serde_json::from_str(sample_registry_json()).expect("parse registry");
|
||||
assert_eq!(index.agents.len(), 2);
|
||||
assert_eq!(index.agents[0].id, "test-agent");
|
||||
assert_eq!(index.agents[1].id, "npx-only-agent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_agent_by_id() {
|
||||
let index: RegistryIndex =
|
||||
serde_json::from_str(sample_registry_json()).expect("parse");
|
||||
assert!(find_agent(&index, "test-agent").is_some());
|
||||
assert!(find_agent(&index, "nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_target_has_correct_fields() {
|
||||
let index: RegistryIndex =
|
||||
serde_json::from_str(sample_registry_json()).expect("parse");
|
||||
let agent = find_agent(&index, "test-agent").unwrap();
|
||||
let target = agent
|
||||
.distribution
|
||||
.binary
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.get("linux-x86_64")
|
||||
.unwrap();
|
||||
assert_eq!(target.cmd, "./test-agent");
|
||||
assert_eq!(target.args, vec!["--acp"]);
|
||||
assert_eq!(target.env.get("FOO"), Some(&"bar".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npx_agent_has_package() {
|
||||
let index: RegistryIndex =
|
||||
serde_json::from_str(sample_registry_json()).expect("parse");
|
||||
let agent = find_agent(&index, "npx-only-agent").unwrap();
|
||||
let npx = agent.distribution.npx.as_ref().unwrap();
|
||||
assert_eq!(npx.package, "@test/agent@2.0.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_command_strips_dot_slash() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let bin = tmp.path().join("test-agent");
|
||||
std::fs::write(&bin, "#!/bin/sh\necho ok").unwrap();
|
||||
|
||||
let target = RegistryBinaryTarget {
|
||||
archive: String::new(),
|
||||
cmd: "./test-agent".to_string(),
|
||||
args: vec!["--acp".to_string()],
|
||||
env: HashMap::new(),
|
||||
};
|
||||
|
||||
let (path, args, _env) = resolve_agent_command(tmp.path(), &target).unwrap();
|
||||
assert_eq!(path, bin);
|
||||
assert_eq!(args, vec!["--acp"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_command_rejects_path_traversal() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let target = RegistryBinaryTarget {
|
||||
archive: String::new(),
|
||||
cmd: "../../../etc/passwd".to_string(),
|
||||
args: vec![],
|
||||
env: HashMap::new(),
|
||||
};
|
||||
|
||||
let result = resolve_agent_command(tmp.path(), &target);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("path traversal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_command_fails_for_missing_binary() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let target = RegistryBinaryTarget {
|
||||
archive: String::new(),
|
||||
cmd: "./nonexistent".to_string(),
|
||||
args: vec![],
|
||||
env: HashMap::new(),
|
||||
};
|
||||
|
||||
let result = resolve_agent_command(tmp.path(), &target);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_install_dir_sanitizes_id() {
|
||||
let dir = agent_install_dir("my@agent/test", "1.0.0").unwrap();
|
||||
let dir_name = dir.parent().unwrap().file_name().unwrap().to_string_lossy();
|
||||
assert!(!dir_name.contains('@'));
|
||||
assert!(!dir_name.contains('/'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_registry_with_missing_optional_fields() {
|
||||
let json = r#"{
|
||||
"version": "1",
|
||||
"agents": [{
|
||||
"id": "minimal",
|
||||
"name": "Minimal",
|
||||
"version": "0.1.0",
|
||||
"description": "",
|
||||
"distribution": {
|
||||
"binary": {
|
||||
"linux-x86_64": {
|
||||
"archive": "https://example.com/a.tar.gz",
|
||||
"cmd": "./agent"
|
||||
}
|
||||
}
|
||||
}
|
||||
}]
|
||||
}"#;
|
||||
|
||||
let index: RegistryIndex = serde_json::from_str(json).expect("parse");
|
||||
let agent = &index.agents[0];
|
||||
assert!(agent.repository.is_none());
|
||||
assert!(agent.website.is_none());
|
||||
assert!(agent.icon.is_none());
|
||||
|
||||
let target = agent.distribution.binary.as_ref().unwrap().get("linux-x86_64").unwrap();
|
||||
assert!(target.args.is_empty());
|
||||
assert!(target.env.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, ®istry, shell_override, &agent_args)?;
|
||||
docker::run_service(service, &workspace, &cfg, ®istry, 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(())
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user