diff --git a/assets/sbx-kit/spec.yaml b/assets/sbx-kit/spec.yaml new file mode 100644 index 0000000..3c0b4f8 --- /dev/null +++ b/assets/sbx-kit/spec.yaml @@ -0,0 +1,322 @@ +# Docker sbx agent kit for Coyote +# +# Setup (paths use $HOME so commands work in bash/zsh/PowerShell/Git Bash): +# sbx create --kit ./sbx-kit/ coyote --name testing . +# sbx cp $HOME/.config/coyote/ testing:/home/agent/.config/ +# sbx cp $HOME/.coyote_password testing:/home/agent/ +# sbx run testing --kit ./sbx-kit/ +schemaVersion: "1" +kind: agent +name: coyote +displayName: Coyote +description: > + An all-in-one, batteries-included LLM CLI tool featuring Shell Assistant, + CLI & REPL mode, RAG, AI tools & agents, MCP servers, skills, and macros. + +agent: + image: "docker/sandbox-templates:shell-docker" + aiFilename: COYOTE.md +# persistence: persistent + entrypoint: + run: ["bash", "-lc", "exec /home/agent/.cargo/bin/coyote"] + +network: + # Proxy-managed LLM providers: the proxy substitutes `proxy-managed` for + # the env var inside the sandbox and rewrites the auth header per + # serviceAuth at request time. Multiple domains may map to one service + # (e.g. jina) so they share a single credential. + serviceDomains: + api.openai.com: openai + api.anthropic.com: anthropic + generativelanguage.googleapis.com: gemini + api.cohere.ai: cohere + api.groq.com: groq + openrouter.ai: openrouter + api.ai21.com: ai21 + api.cloudflare.com: cloudflare + api.deepinfra.com: deepinfra + api.deepseek.com: deepseek + api.mistral.ai: mistral + api.perplexity.ai: perplexity + api.voyageai.com: voyageai + api.x.ai: xai + api.jina.ai: jina + r.jina.ai: jina + qianfan.baidubce.com: ernie + api.hunyuan.cloud.tencent.com: hunyuan + api.minimax.chat: minimax + api.moonshot.cn: moonshot + dashscope.aliyuncs.com: qianwen + open.bigmodel.cn: zhipuai + serviceAuth: + openai: + headerName: Authorization + valueFormat: "Bearer %s" + anthropic: + headerName: x-api-key + valueFormat: "%s" + gemini: + headerName: x-goog-api-key + valueFormat: "%s" + cohere: + headerName: Authorization + valueFormat: "Bearer %s" + groq: + headerName: Authorization + valueFormat: "Bearer %s" + openrouter: + headerName: Authorization + valueFormat: "Bearer %s" + ai21: + headerName: Authorization + valueFormat: "Bearer %s" + cloudflare: + headerName: Authorization + valueFormat: "Bearer %s" + deepinfra: + headerName: Authorization + valueFormat: "Bearer %s" + deepseek: + headerName: Authorization + valueFormat: "Bearer %s" + mistral: + headerName: Authorization + valueFormat: "Bearer %s" + perplexity: + headerName: Authorization + valueFormat: "Bearer %s" + voyageai: + headerName: Authorization + valueFormat: "Bearer %s" + xai: + headerName: Authorization + valueFormat: "Bearer %s" + jina: + headerName: Authorization + valueFormat: "Bearer %s" + ernie: + headerName: Authorization + valueFormat: "Bearer %s" + hunyuan: + headerName: Authorization + valueFormat: "Bearer %s" + minimax: + headerName: Authorization + valueFormat: "Bearer %s" + moonshot: + headerName: Authorization + valueFormat: "Bearer %s" + qianwen: + headerName: Authorization + valueFormat: "Bearer %s" + zhipuai: + headerName: Authorization + valueFormat: "Bearer %s" + allowedDomains: + - "github.com:443" + - "api.github.com:443" + - "raw.githubusercontent.com:443" + - "objects.githubusercontent.com:443" + - "*.githubusercontent.com:443" + - "crates.io:443" + - "static.crates.io:443" + - "pypi.org:443" + - "files.pythonhosted.org:443" + - "registry.npmjs.org:443" + - "astral.sh:443" + - "sh.rustup.rs:443" + - "static.rust-lang.org:443" + + - "claude.ai:443" + - "console.anthropic.com:443" + - "accounts.google.com:443" + # *.googleapis.com covers oauth2 + userinfo + VertexAI regional endpoints + # (*-aiplatform.googleapis.com). Do not narrow without re-checking VertexAI. + - "*.googleapis.com:443" + + # Bedrock and GitHub Models use signed / GitHub-PAT auth that the proxy + # cannot rewrite. Domains are allow-listed; credentials must be injected + # separately (see README "Extending"). + - "*.amazonaws.com:443" + - "models.inference.ai.azure.com:443" + + - "api.githubcopilot.com:443" + - "mcp.atlassian.com:443" + - "duckduckgo.com:443" + - "html.duckduckgo.com:443" + - "lite.duckduckgo.com:443" + + - "wttr.in:443" + # search_arxiv.sh uses http://, so :80 is required until the tool is fixed + - "export.arxiv.org:443" + - "export.arxiv.org:80" + - "en.wikipedia.org:443" + - "api.wolframalpha.com:443" + - "api.twilio.com:443" + - "api.tavily.com:443" + - "doi.org:443" + +credentials: + sources: + openai: + env: + - OPENAI_API_KEY + anthropic: + env: + - ANTHROPIC_API_KEY + gemini: + env: + - GEMINI_API_KEY + - GOOGLE_API_KEY + cohere: + env: + - COHERE_API_KEY + groq: + env: + - GROQ_API_KEY + openrouter: + env: + - OPENROUTER_API_KEY + ai21: + env: + - AI21_API_KEY + cloudflare: + env: + - CLOUDFLARE_API_KEY + deepinfra: + env: + - DEEPINFRA_API_KEY + deepseek: + env: + - DEEPSEEK_API_KEY + mistral: + env: + - MISTRAL_API_KEY + perplexity: + env: + - PERPLEXITY_API_KEY + voyageai: + env: + - VOYAGE_API_KEY + xai: + env: + - XAI_API_KEY + jina: + env: + - JINA_API_KEY + ernie: + env: + - ERNIE_API_KEY + hunyuan: + env: + - HUNYUAN_API_KEY + minimax: + env: + - MINIMAX_API_KEY + moonshot: + env: + - MOONSHOT_API_KEY + qianwen: + env: + - DASHSCOPE_API_KEY + zhipuai: + env: + - ZHIPUAI_API_KEY + +environment: + variables: + IS_SANDBOX: "1" + COYOTE_LOG_LEVEL: INFO + proxyManaged: + - OPENAI_API_KEY + - ANTHROPIC_API_KEY + - GEMINI_API_KEY + - GOOGLE_API_KEY + - COHERE_API_KEY + - GROQ_API_KEY + - OPENROUTER_API_KEY + - AI21_API_KEY + - CLOUDFLARE_API_KEY + - DEEPINFRA_API_KEY + - DEEPSEEK_API_KEY + - MISTRAL_API_KEY + - PERPLEXITY_API_KEY + - VOYAGE_API_KEY + - XAI_API_KEY + - JINA_API_KEY + - ERNIE_API_KEY + - HUNYUAN_API_KEY + - MINIMAX_API_KEY + - MOONSHOT_API_KEY + - DASHSCOPE_API_KEY + - ZHIPUAI_API_KEY + +commands: + install: + - command: | + sudo apt-get update && + sudo apt-get install -y \ + jq curl git \ + build-essential pkg-config \ + cmake \ + clang libclang-dev \ + musl-tools \ + libssl-dev + user: "1000" + description: Install system prerequisites + - command: "curl -LsSf https://astral.sh/uv/install.sh | sh" + user: "1000" + description: Install uv (required for Python-based custom tools) + - command: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ + sh -s -- -y \ + --default-toolchain stable \ + --profile minimal \ + --target x86_64-unknown-linux-musl + . "$HOME/.cargo/env" + cargo install --locked coyote-ai + user: "1000" + description: Install Coyote AI CLI via Rust's Cargo + + startup: + - command: ["sh", "-c", "test -f \"$HOME/.config/coyote/config.yaml\" || coyote --info >/dev/null 2>&1 || true"] + user: "1000" + background: false + description: Bootstrap Coyote config directory on first sandbox start + +memory: | + ## Sandbox environment + + You are running inside a Docker sandbox launched via `sbx run coyote`. The + user's project workspace is mounted at its absolute host path and is the + current working directory. `sudo` is passwordless; use it for system + package installs. + + Coyote's configuration lives at `~/.config/coyote/` and logs at + `~/.cache/coyote/coyote.log`. Persistence is enabled, so config, sessions, + vault state, OAuth tokens, and installed tools survive sandbox restarts. + + LLM provider credentials are forwarded by the sandbox HTTP proxy. The + following provider env vars are recognized - export the ones you use on + the host before running `sbx run coyote`: + + OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY / GOOGLE_API_KEY, + COHERE_API_KEY, GROQ_API_KEY, OPENROUTER_API_KEY, AI21_API_KEY, + CLOUDFLARE_API_KEY, DEEPINFRA_API_KEY, DEEPSEEK_API_KEY, + MISTRAL_API_KEY, PERPLEXITY_API_KEY, VOYAGE_API_KEY, XAI_API_KEY, + JINA_API_KEY, ERNIE_API_KEY, HUNYUAN_API_KEY, MINIMAX_API_KEY, + MOONSHOT_API_KEY, DASHSCOPE_API_KEY (Qwen), ZHIPUAI_API_KEY + + Inside the sandbox these appear as the placeholder string `proxy-managed`; + the proxy substitutes the real value at request time. OAuth flows for + Claude Pro/Max and Gemini are also allow-listed. + + Bedrock (AWS) and VertexAI (Google Cloud) use signed/OAuth-token requests + that the proxy cannot rewrite. Their domains are allow-listed but you must + inject credentials yourself via `sbx run --env AWS_ACCESS_KEY_ID=...` or + a mixin kit that mounts a service-account JSON. + + Useful first-run commands: + - `coyote --info` # show config paths and resolved settings + - `coyote --list-secrets` # initialise the local vault + - `coyote --authenticate ` # OAuth flow (Claude Pro/Max, Gemini) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 54dae2e..ce40693 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -167,6 +167,9 @@ pub struct Cli { /// With --update, update even if Coyote was installed via a package manager #[arg(long, requires = "update")] pub force: bool, + /// Launch Coyote inside a Docker sandbox (via `sbx`); name defaults to current directory basename + #[arg(long, exclusive = true, value_name = "NAME")] + pub sandbox: Option>, } impl Cli { @@ -495,4 +498,21 @@ mod tests { fn parse_force_without_update_fails() { assert!(Cli::try_parse_from(["coyote", "--force"]).is_err()); } + + #[test] + fn parse_sandbox_flag_no_value() { + let cli = parse(&["--sandbox"]); + assert_eq!(cli.sandbox, Some(None)); + } + + #[test] + fn parse_sandbox_flag_with_name() { + let cli = parse(&["--sandbox", "my-box"]); + assert_eq!(cli.sandbox, Some(Some("my-box".to_string()))); + } + + #[test] + fn parse_sandbox_is_exclusive() { + assert!(Cli::try_parse_from(["coyote", "--sandbox", "--agent", "foo"]).is_err()); + } } diff --git a/src/config/mod.rs b/src/config/mod.rs index 884327e..9bef039 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -143,6 +143,8 @@ const MEMORY_DIR_NAME: &str = "memory"; const MEMORY_INDEX_FILE_NAME: &str = "MEMORY.md"; const WORKSPACE_MEMORY_FILE_NAME: &str = "COYOTE.md"; const WORKSPACE_MEMORY_DIR_NAME: &str = ".coyote"; +const SBX_KIT_DIR_NAME: &str = "sbx-kit"; +const SBX_KIT_HASH_FILE: &str = "kit.sha256"; const GIT_DIR_NAME: &str = ".git"; const GITIGNORE_FILE_NAME: &str = ".gitignore"; const DEFAULT_VISIBLE_TOOLS: [&str; 18] = [ diff --git a/src/config/paths.rs b/src/config/paths.rs index a5d0c40..193604c 100644 --- a/src/config/paths.rs +++ b/src/config/paths.rs @@ -3,8 +3,8 @@ use super::{ AGENT_GRAPH_FILE_NAME, AGENTS_DIR_NAME, BASH_PROMPT_UTILS_FILE_NAME, CONFIG_FILE_NAME, ENV_FILE_NAME, FUNCTIONS_BIN_DIR_NAME, FUNCTIONS_DIR_NAME, GLOBAL_TOOLS_DIR_NAME, GLOBAL_TOOLS_UTILS_DIR_NAME, MACROS_DIR_NAME, MCP_FILE_NAME, MEMORY_DIR_NAME, - MEMORY_INDEX_FILE_NAME, ModelsOverride, RAGS_DIR_NAME, ROLES_DIR_NAME, SKILLS_DIR_NAME, - WORKSPACE_MEMORY_DIR_NAME, + MEMORY_INDEX_FILE_NAME, ModelsOverride, RAGS_DIR_NAME, ROLES_DIR_NAME, SBX_KIT_DIR_NAME, + SBX_KIT_HASH_FILE, SKILLS_DIR_NAME, WORKSPACE_MEMORY_DIR_NAME, }; use crate::client::ProviderModels; use crate::utils::{get_env_name, list_file_names, normalize_env_name}; @@ -36,6 +36,10 @@ pub fn cache_path() -> PathBuf { base_dir.join(env!("CARGO_CRATE_NAME")) } +pub fn sandbox_kit_override() -> Option { + env::var_os(get_env_name("sandbox_kit")).map(PathBuf::from) +} + pub fn oauth_tokens_path() -> PathBuf { cache_path().join("oauth") } @@ -48,6 +52,14 @@ pub fn log_path() -> PathBuf { cache_path().join(format!("{}.log", env!("CARGO_CRATE_NAME"))) } +pub fn sbx_kit_dir() -> PathBuf { + cache_path().join(SBX_KIT_DIR_NAME) +} + +pub fn sbx_kit_hash_file() -> PathBuf { + sbx_kit_dir().join(SBX_KIT_HASH_FILE) +} + pub fn config_file() -> PathBuf { match env::var(get_env_name("config_file")) { Ok(value) => PathBuf::from(value), @@ -365,6 +377,30 @@ mod tests { } } + #[test] + fn sandbox_kit_override_reflects_env_var_state() { + let env_name = get_env_name("sandbox_kit"); + let prev = env::var_os(&env_name); + + unsafe { + env::remove_var(&env_name); + } + assert_eq!(sandbox_kit_override(), None); + + let probe = PathBuf::from("/tmp/coyote-sandbox-kit-probe"); + unsafe { + env::set_var(&env_name, &probe); + } + assert_eq!(sandbox_kit_override(), Some(probe)); + + unsafe { + match prev { + Some(v) => env::set_var(&env_name, v), + None => env::remove_var(&env_name), + } + } + } + #[test] fn list_skills_skips_invalid_directory_names() { let unique = time::SystemTime::now() diff --git a/src/main.rs b/src/main.rs index 293a260..01f4d20 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ mod repl; mod utils; mod mcp; mod parsers; +mod sandbox; mod supervisor; mod vault; @@ -56,6 +57,7 @@ async fn main() -> Result<()> { shell.generate_completions(&mut cmd); return Ok(()); } + if cli.tail_logs { tail_logs(cli.disable_log_colors).await; return Ok(()); @@ -92,6 +94,10 @@ async fn main() -> Result<()> { .await?; } + if let Some(name) = &cli.sandbox { + return sandbox::launch(name.clone()); + } + install_builtins()?; if let Some(category) = cli.install { diff --git a/src/repl/mod.rs b/src/repl/mod.rs index f7758a1..57ccd4f 100644 --- a/src/repl/mod.rs +++ b/src/repl/mod.rs @@ -18,6 +18,7 @@ use crate::utils::{ AbortSignal, abortable_run_with_spinner, create_abort_signal, dimmed_text, set_text, temp_file, }; +use crate::sandbox::SANDBOX_ENV_FLAG; use crate::{config, graph, resolve_oauth_client}; use anyhow::{Context, Result, bail}; use crossterm::cursor::SetCursorStyle; @@ -278,7 +279,12 @@ Type ".help" for additional help. "#, env!("CARGO_CRATE_NAME"), env!("CARGO_PKG_VERSION"), - ) + ); + if env::var_os(SANDBOX_ENV_FLAG).is_some() { + eprintln!( + "Sandbox mode is enabled. All changes made to the Coyote config will not persist to the host machine." + ); + } } loop { diff --git a/src/sandbox/mod.rs b/src/sandbox/mod.rs new file mode 100644 index 0000000..3d31b04 --- /dev/null +++ b/src/sandbox/mod.rs @@ -0,0 +1,388 @@ +use anyhow::{Context, Result, anyhow, bail}; +use rust_embed::RustEmbed; +use sha2::{Digest, Sha256}; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use which::which; + +use crate::config::paths; +use crate::utils::run_command_with_output; +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) -> Result<()> { + ensure_sbx_installed()?; + bail_if_nested()?; + + let name = resolve_name(name)?; + let kit_path = resolve_kit_path()?; + + if sandbox_exists(&name)? { + info!("Re-attaching to existing sandbox '{name}'"); + } else { + create_sandbox(&name, &kit_path)?; + 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) -> Result { + 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 "); + } + + 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 { + 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 { + 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 { + 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 sandbox_exists(name: &str) -> Result { + 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) -> Result<()> { + info!("Creating sandbox '{name}'"); + let kit_str = kit_path + .to_str() + .ok_or_else(|| anyhow!("Kit path is not valid UTF-8: {}", kit_path.display()))?; + let status = Command::new(SBX_BINARY) + .args([ + "create", + "--kit", + kit_str, + SANDBOX_AGENT, + "--name", + name, + ".", + ]) + .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 copy_host_files(name: &str) -> Result<()> { + let config_dir = paths::config_dir(); + let home_dir = dirs::home_dir().context("Could not determine home directory")?; + + if config_dir.exists() { + ensure_sandbox_dir(name, "/home/agent/.config")?; + let src = format!("{}/", config_dir.display()); + let dest = format!("{name}:/home/agent/.config/"); + sbx_cp(&src, &dest)?; + } else { + debug!( + "Skipping config copy: {} does not exist", + config_dir.display() + ); + } + + match resolve_vault_password_file() { + Some(password_file) if password_file.exists() => { + let dest_path = match password_file.strip_prefix(&home_dir) { + Ok(rel) => format!("/home/agent/{}", rel.display()), + Err(_) => password_file.display().to_string(), + }; + if let Some(parent) = Path::new(&dest_path).parent() + && let Some(parent_str) = parent.to_str() + && !parent_str.is_empty() + { + ensure_sandbox_dir(name, parent_str)?; + } + let dest = format!("{name}:{dest_path}"); + sbx_cp(&password_file.display().to_string(), &dest)?; + } + Some(password_file) => { + debug!( + "Skipping vault password copy: {} does not exist", + password_file.display() + ); + } + None => { + debug!("Skipping vault password copy: no local vault provider configured"); + } + } + + 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 resolve_vault_password_file() -> Option { + Vault::init_bare().ok()?.local_password_file().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()))?; + let status = Command::new(SBX_BINARY) + .args(["run", 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(()) +} + +#[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); + } +}