From 6ae474c79ee093a8fda1c09b5dfda88d022f4ebe Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 17 Jun 2026 14:39:32 -0600 Subject: [PATCH] feat: added support for loading sbx mixins that are dynamically discovered in the users workspace and config directory --- src/cli/mod.rs | 24 ++++- src/config/mod.rs | 1 + src/config/paths.rs | 23 +++- src/main.rs | 2 +- src/sandbox/mixins.rs | 237 ++++++++++++++++++++++++++++++++++++++++++ src/sandbox/mod.rs | 136 ++++++++++++++++++++---- 6 files changed, 399 insertions(+), 24 deletions(-) create mode 100644 src/sandbox/mixins.rs diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 45d1144..3744fb7 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -30,7 +30,7 @@ use std::io::{Read, stdin}; ", group( ArgGroup::new("sbx-mode") - .args(["sandbox", "fresh"]) + .args(["sandbox", "fresh", "no_mixins"]) .multiple(true) .conflicts_with_all([ "model", "prompt", "role", "session", "agent", "rag", "rebuild_rag", @@ -186,6 +186,9 @@ pub struct Cli { /// Create the sandbox without bootstrapping the host config or vault password file #[arg(long, requires = "sandbox")] pub fresh: bool, + /// Skip discovery and application of all sbx mixins (user and built-in) + #[arg(long, requires = "sandbox")] + pub no_mixins: bool, } impl Cli { @@ -550,4 +553,23 @@ mod tests { assert_eq!(cli.sandbox, Some(Some("foo".to_string()))); assert!(cli.fresh); } + + #[test] + fn parse_no_mixins_requires_sandbox() { + assert!(Cli::try_parse_from(["coyote", "--no-mixins"]).is_err()); + } + + #[test] + fn parse_no_mixins_with_sandbox() { + let cli = parse(&["--sandbox", "--no-mixins"]); + assert!(cli.no_mixins); + } + + #[test] + fn parse_sandbox_with_fresh_and_no_mixins() { + let cli = parse(&["--sandbox", "foo", "--fresh", "--no-mixins"]); + assert_eq!(cli.sandbox, Some(Some("foo".to_string()))); + assert!(cli.fresh); + assert!(cli.no_mixins); + } } diff --git a/src/config/mod.rs b/src/config/mod.rs index 9bef039..1999fad 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -145,6 +145,7 @@ 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 SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml"; 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 193604c..df49cf2 100644 --- a/src/config/paths.rs +++ b/src/config/paths.rs @@ -4,7 +4,7 @@ use super::{ 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, SBX_KIT_DIR_NAME, - SBX_KIT_HASH_FILE, SKILLS_DIR_NAME, WORKSPACE_MEMORY_DIR_NAME, + SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME, SKILLS_DIR_NAME, WORKSPACE_MEMORY_DIR_NAME, }; use crate::client::ProviderModels; use crate::utils::{get_env_name, list_file_names, normalize_env_name}; @@ -40,6 +40,27 @@ pub fn sandbox_kit_override() -> Option { env::var_os(get_env_name("sandbox_kit")).map(PathBuf::from) } +pub fn sbx_mixin_file() -> PathBuf { + config_dir().join(SBX_MIXIN_FILE_NAME) +} + +pub fn global_tools_sbx_mixin_file() -> PathBuf { + functions_dir().join(SBX_MIXIN_FILE_NAME) +} + +pub fn find_workspace_sbx_mixin(start: &Path) -> Option { + for dir in start.ancestors() { + let candidate = dir + .join(WORKSPACE_MEMORY_DIR_NAME) + .join(SBX_MIXIN_FILE_NAME); + if candidate.exists() { + return Some(candidate); + } + } + + None +} + pub fn oauth_tokens_path() -> PathBuf { cache_path().join("oauth") } diff --git a/src/main.rs b/src/main.rs index f31d934..4453763 100644 --- a/src/main.rs +++ b/src/main.rs @@ -95,7 +95,7 @@ async fn main() -> Result<()> { } if let Some(name) = &cli.sandbox { - return sandbox::launch(name.clone(), cli.fresh); + return sandbox::launch(name.clone(), cli.fresh, cli.no_mixins); } install_builtins()?; diff --git a/src/sandbox/mixins.rs b/src/sandbox/mixins.rs new file mode 100644 index 0000000..0fe9e0d --- /dev/null +++ b/src/sandbox/mixins.rs @@ -0,0 +1,237 @@ +use std::env; +use std::fs::{read_dir, read_to_string}; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde_yaml::Value; + +use crate::config::paths; + +const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml"; + +#[derive(Debug, Clone)] +pub struct DiscoveredMixin { + pub path: PathBuf, + pub label: String, + pub install_count: usize, + pub domain_count: usize, +} + +pub fn discover() -> Result> { + let mut out = Vec::new(); + + push_if_exists(&mut out, paths::sbx_mixin_file())?; + push_if_exists(&mut out, paths::global_tools_sbx_mixin_file())?; + + for path in collect_subdir_mixins(&paths::functions_dir()) { + out.push(read_mixin(path)?); + } + for path in collect_subdir_mixins(&paths::agents_data_dir()) { + out.push(read_mixin(path)?); + } + + if let Ok(cwd) = env::current_dir() + && let Some(path) = paths::find_workspace_sbx_mixin(&cwd) + { + out.push(read_mixin(path)?); + } + + Ok(out) +} + +pub fn summarize(path: &Path) -> Result<(usize, usize)> { + let content = read_to_string(path) + .with_context(|| format!("Failed to read sbx mixin {}", path.display()))?; + let value: Value = serde_yaml::from_str(&content) + .with_context(|| format!("Failed to parse sbx mixin {}", path.display()))?; + + let installs = value + .get("commands") + .and_then(|c| c.get("install")) + .and_then(|i| i.as_sequence()) + .map(|s| s.len()) + .unwrap_or(0); + + let domains = value + .get("network") + .and_then(|n| n.get("allowedDomains")) + .and_then(|d| d.as_sequence()) + .map(|s| s.len()) + .unwrap_or(0); + + Ok((installs, domains)) +} + +pub fn log_discovery(mixins: &[DiscoveredMixin], disabled: bool) { + if disabled { + info!("Mixin discovery disabled via --no-mixins."); + return; + } + + if mixins.is_empty() { + info!("No sbx mixins discovered."); + return; + } + + let header = format!("Applying {} sbx mixin(s):", mixins.len()); + info!("{header}"); + println!("{header}"); + + for m in mixins { + let line = format!( + " {} (adds: {} install{}, {} domain{})", + m.label, + m.install_count, + if m.install_count == 1 { "" } else { "s" }, + m.domain_count, + if m.domain_count == 1 { "" } else { "s" }, + ); + info!("{line}"); + println!("{line}"); + } +} + +fn push_if_exists(out: &mut Vec, path: PathBuf) -> Result<()> { + if path.exists() { + out.push(read_mixin(path)?); + } + Ok(()) +} + +fn read_mixin(path: PathBuf) -> Result { + let label = path.display().to_string(); + let (install_count, domain_count) = summarize(&path)?; + + Ok(DiscoveredMixin { + path, + label, + install_count, + domain_count, + }) +} + +fn collect_subdir_mixins(dir: &Path) -> Vec { + let mut result = Vec::new(); + let Ok(rd) = read_dir(dir) else { return result }; + + let mut entries: Vec<_> = rd + .flatten() + .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false)) + .collect(); + entries.sort_by_key(|e| e.file_name()); + + for entry in entries { + let candidate = entry.path().join(SBX_MIXIN_FILE_NAME); + if candidate.exists() { + result.push(candidate); + } + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::time; + + fn unique_root(prefix: &str) -> PathBuf { + let nanos = time::SystemTime::now() + .duration_since(time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = env::temp_dir().join(format!("coyote-{prefix}-{nanos}")); + fs::create_dir_all(&root).unwrap(); + root + } + + #[test] + fn summarize_counts_installs_and_domains() { + let root = unique_root("sbx-mixin-counts"); + let path = root.join("sbx-mixin.yaml"); + fs::write( + &path, + r#" +schemaVersion: "1" +kind: mixin +commands: + install: + - command: "echo hi" + - command: "echo bye" +network: + allowedDomains: + - "a.example.com:443" + - "b.example.com:443" + - "c.example.com:443" +"#, + ) + .unwrap(); + + assert_eq!(summarize(&path).unwrap(), (2, 3)); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn summarize_treats_missing_blocks_as_zero() { + let root = unique_root("sbx-mixin-empty"); + let path = root.join("sbx-mixin.yaml"); + fs::write(&path, "schemaVersion: \"1\"\nkind: mixin\n").unwrap(); + + assert_eq!(summarize(&path).unwrap(), (0, 0)); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn summarize_returns_err_on_malformed_yaml() { + let root = unique_root("sbx-mixin-bad"); + let path = root.join("sbx-mixin.yaml"); + fs::write(&path, "this: is: not: yaml: ::").unwrap(); + + let err = summarize(&path).unwrap_err(); + let msg = format!("{err:#}"); + assert!( + msg.contains(&path.display().to_string()), + "expected error to mention path; got: {msg}" + ); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn collect_subdir_mixins_sorts_and_skips_missing() { + let root = unique_root("sbx-mixin-subdirs"); + for name in ["zebra", "apple", "no-mixin", "mango"] { + let dir = root.join(name); + fs::create_dir_all(&dir).unwrap(); + if name != "no-mixin" { + fs::write(dir.join("sbx-mixin.yaml"), "kind: mixin\n").unwrap(); + } + } + + let found = collect_subdir_mixins(&root); + let names: Vec = found + .iter() + .map(|p| { + p.parent() + .unwrap() + .file_name() + .unwrap() + .to_string_lossy() + .to_string() + }) + .collect(); + assert_eq!(names, vec!["apple", "mango", "zebra"]); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn collect_subdir_mixins_returns_empty_for_missing_dir() { + let absent = env::temp_dir().join("coyote-definitely-not-here-xyz"); + let found = collect_subdir_mixins(&absent); + assert!(found.is_empty()); + } +} diff --git a/src/sandbox/mod.rs b/src/sandbox/mod.rs index 2379deb..fc4a95e 100644 --- a/src/sandbox/mod.rs +++ b/src/sandbox/mod.rs @@ -7,7 +7,10 @@ use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use which::which; +mod mixins; + use crate::config::paths; +use crate::sandbox::mixins::DiscoveredMixin; use crate::utils::run_command_with_output; use crate::vault::Vault; @@ -19,26 +22,39 @@ const SANDBOX_AGENT: &str = "coyote"; #[folder = "assets/sbx-kit/"] struct EmbeddedKit; -pub fn launch(name: Option, fresh: bool) -> Result<()> { +pub fn launch(name: Option, fresh: bool, no_mixins: bool) -> Result<()> { ensure_sbx_installed()?; bail_if_nested()?; let name = resolve_name(name)?; let kit_path = resolve_kit_path()?; + let discovered = if no_mixins { + Vec::new() + } else { + mixins::discover()? + }; + if sandbox_exists(&name)? { info!("Re-attaching to existing sandbox '{name}'"); if fresh { debug!("--fresh ignored: re-attaching to existing sandbox '{name}'"); } - } else if fresh { - let msg = format!("Creating fresh sandbox '{name}' (no host config will be copied)"); - info!("{msg}"); - println!("{msg}"); - create_sandbox(&name, &kit_path)?; + if no_mixins { + debug!("--no-mixins ignored: re-attaching to existing sandbox '{name}'"); + } } else { - create_sandbox(&name, &kit_path)?; - copy_host_files(&name)?; + mixins::log_discovery(&discovered, no_mixins); + + if fresh { + let msg = format!("Creating fresh sandbox '{name}' (no host config will be copied)"); + info!("{msg}"); + println!("{msg}"); + create_sandbox(&name, &kit_path, &discovered)?; + } else { + create_sandbox(&name, &kit_path, &discovered)?; + copy_host_files(&name)?; + } } exec_run(&name, &kit_path) @@ -192,21 +208,11 @@ fn sandbox_exists(name: &str) -> Result { .any(|line| line.split_whitespace().next() == Some(name))) } -fn create_sandbox(name: &str, kit_path: &Path) -> Result<()> { +fn create_sandbox(name: &str, kit_path: &Path, mixins: &[DiscoveredMixin]) -> 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 args = build_create_args(name, kit_path, mixins)?; let status = Command::new(SBX_BINARY) - .args([ - "create", - "--kit", - kit_str, - SANDBOX_AGENT, - "--name", - name, - ".", - ]) + .args(&args) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) @@ -220,6 +226,38 @@ fn create_sandbox(name: &str, kit_path: &Path) -> Result<()> { Ok(()) } +fn build_create_args( + name: &str, + kit_path: &Path, + mixins: &[DiscoveredMixin], +) -> Result> { + 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(), + "--kit".to_string(), + kit_str.to_string(), + ]; + + for mixin in mixins { + let mixin_str = mixin + .path + .to_str() + .ok_or_else(|| anyhow!("Mixin path is not valid UTF-8: {}", mixin.path.display()))?; + args.push("--kit".to_string()); + args.push(mixin_str.to_string()); + } + + args.push(SANDBOX_AGENT.to_string()); + args.push("--name".to_string()); + args.push(name.to_string()); + args.push(".".to_string()); + + Ok(args) +} + 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")?; @@ -393,4 +431,60 @@ mod tests { 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 mixins = vec![ + DiscoveredMixin { + path: PathBuf::from("/cfg/sbx-mixin.yaml"), + label: "user".into(), + install_count: 0, + domain_count: 0, + }, + DiscoveredMixin { + path: PathBuf::from("/cfg/agents/sql/sbx-mixin.yaml"), + 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(), + "--kit".to_string(), + "/cache/sbx-kit".to_string(), + "--kit".to_string(), + "/cfg/sbx-mixin.yaml".to_string(), + "--kit".to_string(), + "/cfg/agents/sql/sbx-mixin.yaml".to_string(), + "coyote".to_string(), + "--name".to_string(), + "my-box".to_string(), + ".".to_string(), + ] + ); + } + + #[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(), + "--kit".to_string(), + "/cache/sbx-kit".to_string(), + "coyote".to_string(), + "--name".to_string(), + "box".to_string(), + ".".to_string(), + ] + ); + } }