use anyhow::{Context, Result, anyhow, bail};
use rust_embed::RustEmbed;
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use std::env;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use which::which;
mod mixins;
use crate::config::AppConfig;
use crate::config::Config;
use crate::config::VAULT_DATA_FILE_NAME;
use crate::config::paths;
use crate::sandbox::mixins::DiscoveredMixin;
use crate::utils::run_command_with_output;
use crate::vault::SECRET_RE;
use crate::vault::Vault;
const SBX_BINARY: &str = "sbx";
pub(crate) const SANDBOX_ENV_FLAG: &str = "IS_SANDBOX";
const SANDBOX_AGENT: &str = "coyote";
#[derive(RustEmbed)]
#[folder = "assets/sbx-kit/"]
struct EmbeddedKit;
pub fn launch(name: Option<String>, fresh: bool) -> Result<()> {
ensure_sbx_installed()?;
bail_if_nested()?;
let name = resolve_name(name)?;
let kit_path = resolve_kit_path()?;
let config_path = paths::config_file();
if !config_path.exists() {
bail!("No Coyote config found. Run `coyote` on your host to complete setup first.");
}
let (config, config_content) = Config::load_from_file(&config_path)?;
let bootstrap = AppConfig {
vault_password_file: config.vault_password_file.clone(),
secrets_provider: config.secrets_provider.clone(),
..AppConfig::default()
};
let vault = Vault::init(&bootstrap)?;
let registered = sbx_registered_services()?;
inject_llm_secret(&config_content, &vault, ®istered)?;
if !fresh {
inject_mcp_secrets(&vault, ®istered)?;
}
let discovered = mixins::discover()?;
if sandbox_exists(&name)? {
info!("Re-attaching to existing sandbox '{name}'");
} else {
mixins::log_discovery(&discovered, false);
create_sandbox(&name, &kit_path, &discovered)?;
if !fresh {
copy_host_files(&name)?;
}
}
exec_run(&name, &kit_path)
}
fn ensure_sbx_installed() -> Result<()> {
which(SBX_BINARY).map_err(|_| {
anyhow!(
"`sbx` binary not found in PATH.\n\n\
Install Docker Sandboxes:\n https://docs.docker.com/ai/sandboxes/get-started/"
)
})?;
Ok(())
}
fn bail_if_nested() -> Result<()> {
if env::var_os(SANDBOX_ENV_FLAG).is_some() {
bail!("Refusing to nest sandboxes: ${SANDBOX_ENV_FLAG} is set, already inside one");
}
Ok(())
}
fn resolve_name(name: Option<String>) -> Result<String> {
if let Some(n) = name {
let trimmed = n.trim();
if !trimmed.is_empty() {
let sanitized = sanitize_name(trimmed);
if sanitized.is_empty() {
bail!("Sandbox name '{trimmed}' sanitizes to an empty string");
}
return Ok(sanitized);
}
}
let cwd = env::current_dir().context("Failed to determine current directory")?;
let basename = cwd
.file_name()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow!("Could not derive sandbox name from current directory"))?;
let sanitized = sanitize_name(basename);
if sanitized.is_empty() {
bail!("Could not derive a valid sandbox name from '{basename}'; pass --sandbox <NAME>");
}
Ok(sanitized)
}
fn sanitize_name(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut last_was_dash = false;
for ch in input.chars() {
let lower = ch.to_ascii_lowercase();
if lower.is_ascii_alphanumeric() {
out.push(lower);
last_was_dash = false;
} else if !last_was_dash {
out.push('-');
last_was_dash = true;
}
}
out.trim_matches('-').to_string()
}
fn resolve_kit_path() -> Result<PathBuf> {
if let Some(path) = paths::sandbox_kit_override() {
if !path.exists() {
bail!(
"$COYOTE_SANDBOX_KIT is set but path does not exist: {}",
path.display()
);
}
debug!(
"Using kit override from $COYOTE_SANDBOX_KIT: {}",
path.display()
);
return Ok(path);
}
extract_embedded_kit()
}
fn extract_embedded_kit() -> Result<PathBuf> {
let cache_root = paths::sbx_kit_dir();
let new_hash = compute_kit_hash()?;
let hash_file = paths::sbx_kit_hash_file();
if let Ok(existing) = fs::read_to_string(&hash_file)
&& existing == new_hash
{
return Ok(cache_root);
}
if cache_root.exists() {
fs::remove_dir_all(&cache_root)
.with_context(|| format!("Failed to clear stale kit at {}", cache_root.display()))?;
}
fs::create_dir_all(&cache_root)
.with_context(|| format!("Failed to create {}", cache_root.display()))?;
for entry in EmbeddedKit::iter() {
let file = EmbeddedKit::get(&entry)
.ok_or_else(|| anyhow!("Embedded kit file missing during extraction: {entry}"))?;
let dest = cache_root.join(entry.as_ref());
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create {}", parent.display()))?;
}
fs::write(&dest, &file.data)
.with_context(|| format!("Failed to write {}", dest.display()))?;
}
fs::write(&hash_file, &new_hash)
.with_context(|| format!("Failed to write {}", hash_file.display()))?;
debug!("Extracted embedded sbx-kit to {}", cache_root.display());
Ok(cache_root)
}
fn compute_kit_hash() -> Result<String> {
let mut hasher = Sha256::new();
let mut entries: Vec<_> = EmbeddedKit::iter().collect();
entries.sort();
for entry in &entries {
let file = EmbeddedKit::get(entry)
.ok_or_else(|| anyhow!("Embedded kit file missing during hash: {entry}"))?;
hasher.update(entry.as_bytes());
hasher.update(b"\0");
hasher.update(&file.data);
}
Ok(format!("{:x}", hasher.finalize()))
}
fn inject_llm_secret(
config_content: &str,
vault: &Vault,
registered: &HashSet<String>,
) -> Result<()> {
let value: serde_yaml::Value = serde_yaml::from_str(config_content)
.context("Failed to parse config for LLM secret injection")?;
let Some(clients) = value.get("clients").and_then(|v| v.as_sequence()) else {
return Ok(());
};
for client in clients {
let Some(api_key) = client.get("api_key").and_then(|v| v.as_str()) else {
continue;
};
let Some(caps) = SECRET_RE.captures(api_key)? else {
continue;
};
let secret_name = caps[1].to_string();
let client_type = client.get("type").and_then(|v| v.as_str()).unwrap_or("");
let client_name = client.get("name").and_then(|v| v.as_str());
let service = provider_to_sbx_service(client_type, client_name);
if registered.contains(&service) {
eprintln!(
"Secret for '{service}' already registered with sbx. \
To update it, run: sbx secret set -g --force {service}"
);
continue;
}
let secret_value = vault
.get_secret(&secret_name, false)
.with_context(|| format!("Failed to decrypt LLM api_key secret '{secret_name}'"))?;
sbx_secret_set(&service, &secret_value)?;
}
Ok(())
}
fn find_secret_placeholder(value: &Value) -> Option<String> {
match value {
Value::String(s) => SECRET_RE
.captures(s)
.ok()
.flatten()
.map(|caps| caps[1].to_string()),
Value::Object(map) => map.values().find_map(find_secret_placeholder),
Value::Array(arr) => arr.iter().find_map(find_secret_placeholder),
_ => None,
}
}
fn inject_mcp_secrets(vault: &Vault, registered: &HashSet<String>) -> Result<()> {
let mcp_path = paths::mcp_config_file();
if !mcp_path.exists() {
return Ok(());
}
let content = fs::read_to_string(&mcp_path)
.with_context(|| format!("Failed to read {}", mcp_path.display()))?;
let mcp: Value = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse {}", mcp_path.display()))?;
let Some(servers) = mcp.get("mcpServers").and_then(|v| v.as_object()) else {
return Ok(());
};
for (server_name, server_config) in servers {
let Some(secret_name) = find_secret_placeholder(server_config) else {
continue;
};
if registered.contains(server_name.as_str()) {
eprintln!(
"Secret for '{server_name}' already registered with sbx. \
To update it, run: sbx secret set -g --force {server_name}"
);
continue;
}
let secret_value = vault.get_secret(&secret_name, false).with_context(|| {
format!(
"Secret '{secret_name}' referenced by MCP server '{server_name}' not found \
in vault. Add it with: coyote --add-secret {secret_name}"
)
})?;
sbx_secret_set(server_name, &secret_value)?;
}
Ok(())
}
fn provider_to_sbx_service(provider_type: &str, client_name: Option<&str>) -> String {
match provider_type {
"claude" => "anthropic".to_string(),
"openai" => "openai".to_string(),
"gemini" | "vertexai" => "google".to_string(),
"openai-compatible" => client_name.unwrap_or("openai-compatible").to_string(),
other => client_name.unwrap_or(other).to_string(),
}
}
fn sbx_registered_services() -> Result<HashSet<String>> {
let (success, stdout, _) = run_command_with_output(SBX_BINARY, &["secret", "ls"], None)
.context("Failed to run `sbx secret ls`")?;
if !success {
return Ok(HashSet::new());
}
Ok(stdout
.lines()
.skip(1)
.filter_map(|line| {
let mut parts = line.split_whitespace();
let scope = parts.next()?;
let _kind = parts.next()?;
let name = parts.next()?;
if scope == "(global)" {
Some(name.to_string())
} else {
None
}
})
.collect())
}
fn sbx_secret_set(service: &str, secret_value: &str) -> Result<()> {
let mut child = Command::new(SBX_BINARY)
.args(["secret", "set", "-g", service])
.stdin(Stdio::piped())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()
.context("Failed to spawn `sbx secret set -g`")?;
if let Some(mut stdin_handle) = child.stdin.take() {
stdin_handle
.write_all(secret_value.as_bytes())
.context("Failed to write secret to `sbx secret set -g` stdin")?;
}
let status = child
.wait()
.context("Failed to wait for `sbx secret set -g`")?;
if !status.success() {
bail!("`sbx secret set -g {service}` exited with {status}");
}
Ok(())
}
fn sandbox_exists(name: &str) -> Result<bool> {
let (success, stdout, stderr) =
run_command_with_output(SBX_BINARY, &["ls"], None).context("Failed to run `sbx ls`")?;
if !success {
bail!("`sbx ls` failed: {stderr}");
}
Ok(stdout
.lines()
.skip(1)
.any(|line| line.split_whitespace().next() == Some(name)))
}
fn create_sandbox(name: &str, kit_path: &Path, mixins: &[DiscoveredMixin]) -> Result<()> {
info!("Creating sandbox '{name}'");
let args = build_create_args(name, kit_path, mixins)?;
debug!("sbx {}", args.join(" "));
let status = Command::new(SBX_BINARY)
.args(&args)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.context("Failed to spawn `sbx create`")?;
if !status.success() {
bail!("`sbx create` exited with {status}");
}
Ok(())
}
fn build_create_args(
name: &str,
kit_path: &Path,
mixins: &[DiscoveredMixin],
) -> Result<Vec<String>> {
let kit_str = kit_path
.to_str()
.ok_or_else(|| anyhow!("Kit path is not valid UTF-8: {}", kit_path.display()))?;
let mut args = vec![
"create".to_string(),
"--name".to_string(),
name.to_string(),
"--kit".to_string(),
kit_str.to_string(),
];
for mixin in mixins {
let mixin_kit = mixin.kit_path()?;
let mixin_str = mixin_kit
.to_str()
.ok_or_else(|| anyhow!("Mixin kit path is not valid UTF-8: {}", mixin_kit.display()))?
.to_string();
args.push("--kit".to_string());
args.push(mixin_str);
}
args.push(SANDBOX_AGENT.to_string());
args.push(".".to_string());
Ok(args)
}
fn copy_host_files(name: &str) -> Result<()> {
let config_dir = paths::config_dir();
if config_dir.exists() {
let sandbox_config_dir = "/home/agent/.config/coyote";
ensure_sandbox_dir(name, sandbox_config_dir)?;
let dest = format!("{name}:{sandbox_config_dir}/");
for entry in fs::read_dir(&config_dir)
.with_context(|| format!("Failed to read {}", config_dir.display()))?
{
let entry = entry?;
let path = entry.path();
if path.file_name().is_some_and(|n| n == VAULT_DATA_FILE_NAME) {
continue;
}
sbx_cp(&path.display().to_string(), &dest)?;
}
chown_agent_recursive(name, sandbox_config_dir)?;
} else {
debug!(
"Skipping config copy: {} does not exist",
config_dir.display()
);
}
let oauth_tokens_dir = paths::oauth_tokens_dir();
if oauth_tokens_dir.exists() {
let sandbox_cache_dir = "/home/agent/.cache";
let sandbox_oauth_dir = "/home/agent/.cache/coyote/oauth";
ensure_sandbox_dir(name, sandbox_oauth_dir)?;
let dest = format!("{name}:{sandbox_oauth_dir}/");
for entry in fs::read_dir(&oauth_tokens_dir)
.with_context(|| format!("Failed to read {}", oauth_tokens_dir.display()))?
{
let entry = entry?;
let path = entry.path();
sbx_cp(&path.display().to_string(), &dest)?;
}
chown_agent_recursive(name, sandbox_cache_dir)?;
} else {
debug!(
"Skipping OAuth token copy: {} does not exist",
oauth_tokens_dir.display()
);
}
Ok(())
}
fn ensure_sandbox_dir(sandbox: &str, dir: &str) -> Result<()> {
let dir_q = shell_words::quote(dir);
let cmd = format!("sudo mkdir -p {dir_q} && sudo chown agent:agent {dir_q}");
debug!("sbx exec {sandbox}: {cmd}");
let status = Command::new(SBX_BINARY)
.args(["exec", sandbox, "sh", "-c", &cmd])
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.context("Failed to spawn `sbx exec` to prepare destination directory")?;
if !status.success() {
bail!("Preparing sandbox directory '{dir}' failed: sbx exec exited with {status}");
}
Ok(())
}
fn sbx_cp(src: &str, dest: &str) -> Result<()> {
debug!("sbx cp {src} {dest}");
let status = Command::new(SBX_BINARY)
.args(["cp", src, dest])
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.context("Failed to spawn `sbx cp`")?;
if !status.success() {
bail!("`sbx cp {src} {dest}` exited with {status}");
}
Ok(())
}
fn exec_run(name: &str, kit_path: &Path) -> Result<()> {
let kit_str = kit_path
.to_str()
.ok_or_else(|| anyhow!("Kit path is not valid UTF-8: {}", kit_path.display()))?;
debug!("sbx run --name {name} --kit {kit_str}");
let status = Command::new(SBX_BINARY)
.args(["run", "--name", name, "--kit", kit_str])
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.context("Failed to spawn `sbx run`")?;
if !status.success() {
bail!("`sbx run` exited with {status}");
}
Ok(())
}
fn chown_agent_recursive(sandbox: &str, path: &str) -> Result<()> {
let path_q = shell_words::quote(path);
let cmd = format!("sudo chown -R agent:agent {path_q}");
debug!("sbx exec {sandbox}: {cmd}");
let status = Command::new(SBX_BINARY)
.args(["exec", sandbox, "sh", "-c", &cmd])
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.context("Failed to spawn `sbx exec` to chown copied files")?;
if !status.success() {
bail!("Chowning '{path}' in sandbox failed: sbx exec exited with {status}");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_name_lowercases() {
assert_eq!(sanitize_name("Foo"), "foo");
}
#[test]
fn sanitize_name_replaces_non_alphanumeric() {
assert_eq!(sanitize_name("hello world!"), "hello-world");
}
#[test]
fn sanitize_name_collapses_dash_runs() {
assert_eq!(sanitize_name("a___b"), "a-b");
}
#[test]
fn sanitize_name_trims_dashes() {
assert_eq!(sanitize_name("---hi---"), "hi");
}
#[test]
fn sanitize_name_handles_mixed_input() {
assert_eq!(sanitize_name("My Project (v2)"), "my-project-v2");
}
#[test]
fn sanitize_name_all_invalid_yields_empty() {
assert_eq!(sanitize_name("///"), "");
}
#[test]
fn resolve_name_uses_explicit_arg() {
let n = resolve_name(Some("explicit-name".to_string())).unwrap();
assert_eq!(n, "explicit-name");
}
#[test]
fn resolve_name_sanitizes_explicit_arg() {
let n = resolve_name(Some("My Sandbox!".to_string())).unwrap();
assert_eq!(n, "my-sandbox");
}
#[test]
fn resolve_name_rejects_empty_after_sanitize() {
let err = resolve_name(Some("///".to_string()));
assert!(err.is_err());
}
#[test]
fn resolve_name_falls_back_to_cwd_when_none() {
let n = resolve_name(None).unwrap();
assert!(!n.is_empty());
assert!(n.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'));
}
#[test]
fn compute_kit_hash_is_deterministic() {
let h1 = compute_kit_hash().unwrap();
let h2 = compute_kit_hash().unwrap();
assert_eq!(h1, h2);
assert_eq!(h1.len(), 64);
}
#[test]
fn build_create_args_emits_base_kit_before_mixins() {
let kit = PathBuf::from("/cache/sbx-kit");
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir_a = env::temp_dir().join(format!("coyote-mixin-a-{unique}"));
let dir_b = env::temp_dir().join(format!("coyote-mixin-b-{unique}"));
fs::create_dir_all(&dir_a).unwrap();
fs::create_dir_all(&dir_b).unwrap();
let mixins = vec![
DiscoveredMixin {
path: dir_a.clone(),
label: "user".into(),
install_count: 0,
domain_count: 0,
},
DiscoveredMixin {
path: dir_b.clone(),
label: "sql".into(),
install_count: 0,
domain_count: 0,
},
];
let args = build_create_args("my-box", &kit, &mixins).unwrap();
assert_eq!(
args,
vec![
"create".to_string(),
"--name".to_string(),
"my-box".to_string(),
"--kit".to_string(),
"/cache/sbx-kit".to_string(),
"--kit".to_string(),
dir_a.display().to_string(),
"--kit".to_string(),
dir_b.display().to_string(),
"coyote".to_string(),
".".to_string(),
]
);
let _ = fs::remove_dir_all(&dir_a);
let _ = fs::remove_dir_all(&dir_b);
}
#[test]
fn build_create_args_with_no_mixins_omits_mixin_kits() {
let kit = PathBuf::from("/cache/sbx-kit");
let args = build_create_args("box", &kit, &[]).unwrap();
assert_eq!(
args,
vec![
"create".to_string(),
"--name".to_string(),
"box".to_string(),
"--kit".to_string(),
"/cache/sbx-kit".to_string(),
"coyote".to_string(),
".".to_string(),
]
);
}
}