feat: added support for loading sbx mixins that are dynamically discovered in the users workspace and config directory

This commit is contained in:
2026-06-17 14:39:32 -06:00
parent 8e0b07c9fb
commit 6ae474c79e
6 changed files with 399 additions and 24 deletions
+23 -1
View File
@@ -30,7 +30,7 @@ use std::io::{Read, stdin};
", ",
group( group(
ArgGroup::new("sbx-mode") ArgGroup::new("sbx-mode")
.args(["sandbox", "fresh"]) .args(["sandbox", "fresh", "no_mixins"])
.multiple(true) .multiple(true)
.conflicts_with_all([ .conflicts_with_all([
"model", "prompt", "role", "session", "agent", "rag", "rebuild_rag", "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 /// Create the sandbox without bootstrapping the host config or vault password file
#[arg(long, requires = "sandbox")] #[arg(long, requires = "sandbox")]
pub fresh: bool, 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 { impl Cli {
@@ -550,4 +553,23 @@ mod tests {
assert_eq!(cli.sandbox, Some(Some("foo".to_string()))); assert_eq!(cli.sandbox, Some(Some("foo".to_string())));
assert!(cli.fresh); 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);
}
} }
+1
View File
@@ -145,6 +145,7 @@ const WORKSPACE_MEMORY_FILE_NAME: &str = "COYOTE.md";
const WORKSPACE_MEMORY_DIR_NAME: &str = ".coyote"; const WORKSPACE_MEMORY_DIR_NAME: &str = ".coyote";
const SBX_KIT_DIR_NAME: &str = "sbx-kit"; const SBX_KIT_DIR_NAME: &str = "sbx-kit";
const SBX_KIT_HASH_FILE: &str = "kit.sha256"; const SBX_KIT_HASH_FILE: &str = "kit.sha256";
const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml";
const GIT_DIR_NAME: &str = ".git"; const GIT_DIR_NAME: &str = ".git";
const GITIGNORE_FILE_NAME: &str = ".gitignore"; const GITIGNORE_FILE_NAME: &str = ".gitignore";
const DEFAULT_VISIBLE_TOOLS: [&str; 18] = [ const DEFAULT_VISIBLE_TOOLS: [&str; 18] = [
+22 -1
View File
@@ -4,7 +4,7 @@ use super::{
ENV_FILE_NAME, FUNCTIONS_BIN_DIR_NAME, FUNCTIONS_DIR_NAME, GLOBAL_TOOLS_DIR_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, 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, 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::client::ProviderModels;
use crate::utils::{get_env_name, list_file_names, normalize_env_name}; use crate::utils::{get_env_name, list_file_names, normalize_env_name};
@@ -40,6 +40,27 @@ pub fn sandbox_kit_override() -> Option<PathBuf> {
env::var_os(get_env_name("sandbox_kit")).map(PathBuf::from) 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<PathBuf> {
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 { pub fn oauth_tokens_path() -> PathBuf {
cache_path().join("oauth") cache_path().join("oauth")
} }
+1 -1
View File
@@ -95,7 +95,7 @@ async fn main() -> Result<()> {
} }
if let Some(name) = &cli.sandbox { 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()?; install_builtins()?;
+237
View File
@@ -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<Vec<DiscoveredMixin>> {
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<DiscoveredMixin>, path: PathBuf) -> Result<()> {
if path.exists() {
out.push(read_mixin(path)?);
}
Ok(())
}
fn read_mixin(path: PathBuf) -> Result<DiscoveredMixin> {
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<PathBuf> {
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<String> = 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());
}
}
+115 -21
View File
@@ -7,7 +7,10 @@ use std::path::{Path, PathBuf};
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use which::which; use which::which;
mod mixins;
use crate::config::paths; use crate::config::paths;
use crate::sandbox::mixins::DiscoveredMixin;
use crate::utils::run_command_with_output; use crate::utils::run_command_with_output;
use crate::vault::Vault; use crate::vault::Vault;
@@ -19,26 +22,39 @@ const SANDBOX_AGENT: &str = "coyote";
#[folder = "assets/sbx-kit/"] #[folder = "assets/sbx-kit/"]
struct EmbeddedKit; struct EmbeddedKit;
pub fn launch(name: Option<String>, fresh: bool) -> Result<()> { pub fn launch(name: Option<String>, fresh: bool, no_mixins: bool) -> Result<()> {
ensure_sbx_installed()?; ensure_sbx_installed()?;
bail_if_nested()?; bail_if_nested()?;
let name = resolve_name(name)?; let name = resolve_name(name)?;
let kit_path = resolve_kit_path()?; let kit_path = resolve_kit_path()?;
let discovered = if no_mixins {
Vec::new()
} else {
mixins::discover()?
};
if sandbox_exists(&name)? { if sandbox_exists(&name)? {
info!("Re-attaching to existing sandbox '{name}'"); info!("Re-attaching to existing sandbox '{name}'");
if fresh { if fresh {
debug!("--fresh ignored: re-attaching to existing sandbox '{name}'"); debug!("--fresh ignored: re-attaching to existing sandbox '{name}'");
} }
} else if fresh { if no_mixins {
let msg = format!("Creating fresh sandbox '{name}' (no host config will be copied)"); debug!("--no-mixins ignored: re-attaching to existing sandbox '{name}'");
info!("{msg}"); }
println!("{msg}");
create_sandbox(&name, &kit_path)?;
} else { } else {
create_sandbox(&name, &kit_path)?; mixins::log_discovery(&discovered, no_mixins);
copy_host_files(&name)?;
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) exec_run(&name, &kit_path)
@@ -192,21 +208,11 @@ fn sandbox_exists(name: &str) -> Result<bool> {
.any(|line| line.split_whitespace().next() == Some(name))) .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}'"); info!("Creating sandbox '{name}'");
let kit_str = kit_path let args = build_create_args(name, kit_path, mixins)?;
.to_str()
.ok_or_else(|| anyhow!("Kit path is not valid UTF-8: {}", kit_path.display()))?;
let status = Command::new(SBX_BINARY) let status = Command::new(SBX_BINARY)
.args([ .args(&args)
"create",
"--kit",
kit_str,
SANDBOX_AGENT,
"--name",
name,
".",
])
.stdin(Stdio::inherit()) .stdin(Stdio::inherit())
.stdout(Stdio::inherit()) .stdout(Stdio::inherit())
.stderr(Stdio::inherit()) .stderr(Stdio::inherit())
@@ -220,6 +226,38 @@ fn create_sandbox(name: &str, kit_path: &Path) -> Result<()> {
Ok(()) 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(),
"--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<()> { fn copy_host_files(name: &str) -> Result<()> {
let config_dir = paths::config_dir(); let config_dir = paths::config_dir();
let home_dir = dirs::home_dir().context("Could not determine home directory")?; 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, h2);
assert_eq!(h1.len(), 64); 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(),
]
);
}
} }