Files
coyote/src/config/paths.rs
T
Dark-Alex-17 98d3ba4a83 feat(rag): add DuckDB provider behind the RAG driver abstraction
Phase 3 of the RAG driver abstraction. Adds a `DuckDbProvider` that keeps
vectors and document content in a `.duckdb` sidecar next to the existing
YAML metadata, selected by the `driver: duckdb` field.

- `src/rag/providers/duckdb.rs` (new): vector search via the vss extension
  and keyword search via fts, an all-or-nothing hydration path (a partial
  read is an error, never a shorter map), and an anti-wipe guard that
  refuses the destructive `CREATE OR REPLACE TABLE` when `data.vectors` is
  empty while `data.files` is not and the store still holds rows.
- `src/rag/mod.rs`: `sync_documents` now refreshes `bm25`/`node_to_docs`
  BEFORE the fallible `provider.rebuild_indexes`. `self.data` is already
  mutated by that point, so propagating a provider error afterwards would
  leave the derived in-memory state describing the previous corpus while
  `data` describes the new one. Both rebuilds are pure functions of
  `self.data` and cannot fail, so running them first is always safe.
- `src/config/paths.rs`: sidecar path helpers.
- `src/rag/providers/mod.rs`, `src/config/agent.rs`: driver dispatch and
  RAG cache keying.

Also keeps `RequestContext::rag_key` in lockstep with `rag` at the two
sites that were still missing it, so that a cache insert and its matching
invalidate are structurally incapable of disagreeing:

- `use_agent` assigned `self.rag` from the agent but never set `rag_key`.
  This one was live. Agent RAGs are inserted under `RagKey::Agent(<name>)`,
  so with `rag_key == None` the invalidation guards in `rebuild_rag` and
  `edit_rag_docs` matched nothing and `.rebuild rag` left the stale cache
  entry in place. Worse, a preceding `.rag <name>` left a stale
  `Named(<name>)` key attached to the agent's RAG, pointing the
  invalidation at an unrelated RAG's cache entry. Now mirrors the insert
  key exactly, yielding `None` when the agent has no RAG.
- `exit_agent` cleared `self.rag` but left `rag_key` behind. Latent rather
  than live, since `rebuild_rag`/`edit_rag_docs` both bail on
  `rag.is_none()` before reaching the invalidate guards, but the guards
  that make it unobservable are not the kind of thing to depend on.

Covered by `use_agent_does_not_carry_stale_rag_key`, and by a new
assertion in `exit_agent_clears_all_agent_state`.
2026-08-10 12:51:56 -06:00

964 lines
29 KiB
Rust

use super::role::Role;
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, HIDDEN_MCP_FILE_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, SBX_MIXIN_FILE_NAME, SBX_MIXIN_KITS_DIR_NAME,
SKILLS_DIR_NAME, WORKSPACE_COYOTE_DIR_NAME,
};
use crate::client::ProviderModels;
use crate::config::REPL_HISTORY_DIR_NAME;
use crate::config::session::Session;
use crate::utils::{get_env_name, list_file_names, normalize_env_name};
use anyhow::{Context, Result, anyhow, bail};
use log::LevelFilter;
use std::collections::HashSet;
use std::env;
use std::fs::{read_dir, read_to_string, remove_file};
use std::path::{Path, PathBuf};
pub fn config_dir() -> PathBuf {
if let Ok(v) = env::var(get_env_name("config_dir")) {
PathBuf::from(v)
} else if let Ok(v) = env::var("XDG_CONFIG_HOME") {
PathBuf::from(v).join(env!("CARGO_CRATE_NAME"))
} else {
let dir = dirs::config_dir().expect("No user's config directory");
dir.join(env!("CARGO_CRATE_NAME"))
}
}
pub fn local_dir(name: &str) -> PathBuf {
config_dir().join(name)
}
pub fn cache_dir() -> PathBuf {
if let Ok(v) = env::var(get_env_name("cache_dir")) {
PathBuf::from(v)
} else if let Ok(v) = env::var("XDG_CACHE_HOME") {
PathBuf::from(v).join(env!("CARGO_CRATE_NAME"))
} else {
let base_dir = dirs::cache_dir().unwrap_or_else(env::temp_dir);
base_dir.join(env!("CARGO_CRATE_NAME"))
}
}
pub fn sandbox_kit_override() -> Option<PathBuf> {
env::var_os(get_env_name("sandbox_kit")).map(PathBuf::from)
}
pub fn translate_sandboxed_home_dir(path: &Path) -> Option<PathBuf> {
env::var_os("IS_SANDBOX")?;
let s = path.to_str()?;
if let Some(translated) = translate_unix_home_style(s, "/home/") {
return Some(translated);
}
if let Some(translated) = translate_unix_home_style(s, "/Users/") {
return Some(translated);
}
translate_windows_users_dir(s)
}
fn translate_unix_home_style(s: &str, prefix: &str) -> Option<PathBuf> {
let rest = s.strip_prefix(prefix)?;
let (user, tail) = match rest.split_once('/') {
Some((u, t)) => (u, t),
None => (rest, ""),
};
if user.is_empty() || user == "agent" {
return None;
}
Some(if tail.is_empty() {
PathBuf::from("/home/agent")
} else {
PathBuf::from(format!("/home/agent/{tail}"))
})
}
fn translate_windows_users_dir(s: &str) -> Option<PathBuf> {
let bytes = s.as_bytes();
if bytes.len() < 4 || !bytes[0].is_ascii_alphabetic() || bytes[1] != b':' || bytes[2] != b'\\' {
return None;
}
let after_drive = &s[3..];
let rest = after_drive.strip_prefix("Users\\")?;
let (user, tail) = match rest.split_once('\\') {
Some((u, t)) => (u, t.replace('\\', "/")),
None => (rest, String::new()),
};
if user.is_empty() || user == "agent" {
return None;
}
Some(if tail.is_empty() {
PathBuf::from("/home/agent")
} else {
PathBuf::from(format!("/home/agent/{tail}"))
})
}
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_COYOTE_DIR_NAME)
.join(SBX_MIXIN_FILE_NAME);
if candidate.exists() {
return Some(candidate);
}
}
None
}
pub fn oauth_tokens_dir() -> PathBuf {
cache_dir().join("oauth")
}
pub fn token_file(client_name: &str) -> PathBuf {
oauth_tokens_dir().join(format!("{client_name}_oauth_tokens.json"))
}
pub fn log_file() -> PathBuf {
cache_dir().join(format!("{}.log", env!("CARGO_CRATE_NAME")))
}
pub fn sbx_kit_dir() -> PathBuf {
cache_dir().join(SBX_KIT_DIR_NAME)
}
pub fn sbx_kit_hash_file() -> PathBuf {
sbx_kit_dir().join(SBX_KIT_HASH_FILE)
}
pub fn sbx_mixin_kits_dir() -> PathBuf {
cache_dir().join(SBX_MIXIN_KITS_DIR_NAME)
}
pub fn config_file() -> PathBuf {
match env::var(get_env_name("config_file")) {
Ok(value) => PathBuf::from(value),
Err(_) => local_dir(CONFIG_FILE_NAME),
}
}
pub fn roles_dir() -> PathBuf {
match env::var(get_env_name("roles_dir")) {
Ok(value) => PathBuf::from(value),
Err(_) => local_dir(ROLES_DIR_NAME),
}
}
pub fn role_file(name: &str) -> PathBuf {
roles_dir().join(format!("{name}.md"))
}
pub fn skills_dir() -> PathBuf {
match env::var(get_env_name("skills_dir")) {
Ok(value) => PathBuf::from(value),
Err(_) => local_dir(SKILLS_DIR_NAME),
}
}
pub fn skill_dir(name: &str) -> PathBuf {
skills_dir().join(name)
}
pub fn skill_file(name: &str) -> PathBuf {
skill_dir(name).join("SKILL.md")
}
pub fn workspace_config_dir() -> PathBuf {
let workspace_dir_name = match env::var(get_env_name("workspace_config_dir")) {
Ok(value) => value,
Err(_) => WORKSPACE_COYOTE_DIR_NAME.to_string(),
};
env::current_dir()
.unwrap_or_default()
.join(workspace_dir_name)
}
pub fn workspace_skills_dir() -> PathBuf {
workspace_config_dir().join(SKILLS_DIR_NAME)
}
pub fn workspace_skill_file(name: &str) -> PathBuf {
workspace_skills_dir().join(name).join("SKILL.md")
}
pub fn workspace_mcp_config_file() -> Option<PathBuf> {
workspace_mcp_config_file_in(&env::current_dir().unwrap_or_default())
}
fn workspace_mcp_config_file_in(workspace_root: &Path) -> Option<PathBuf> {
let dir = workspace_config_dir();
[
dir.join(MCP_FILE_NAME),
dir.join(HIDDEN_MCP_FILE_NAME),
workspace_root.join(HIDDEN_MCP_FILE_NAME),
]
.into_iter()
.find(|candidate| candidate.is_file())
}
pub fn validate_skill_name(name: &str) -> Result<()> {
if name.is_empty() {
bail!("Skill name cannot be empty");
}
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
bail!("Invalid skill name '{name}': only letters, digits, '-', and '_' are allowed");
}
Ok(())
}
pub fn macros_dir() -> PathBuf {
match env::var(get_env_name("macros_dir")) {
Ok(value) => PathBuf::from(value),
Err(_) => local_dir(MACROS_DIR_NAME),
}
}
pub fn macro_file(name: &str) -> PathBuf {
macros_dir().join(format!("{name}.yaml"))
}
pub fn env_file() -> PathBuf {
match env::var(get_env_name("env_file")) {
Ok(value) => PathBuf::from(value),
Err(_) => local_dir(ENV_FILE_NAME),
}
}
pub fn rags_dir() -> PathBuf {
match env::var(get_env_name("rags_dir")) {
Ok(value) => PathBuf::from(value),
Err(_) => local_dir(RAGS_DIR_NAME),
}
}
pub fn functions_dir() -> PathBuf {
match env::var(get_env_name("functions_dir")) {
Ok(value) => PathBuf::from(value),
Err(_) => local_dir(FUNCTIONS_DIR_NAME),
}
}
pub fn functions_bin_dir() -> PathBuf {
functions_dir().join(FUNCTIONS_BIN_DIR_NAME)
}
pub fn mcp_config_file() -> PathBuf {
functions_dir().join(MCP_FILE_NAME)
}
pub fn global_tools_dir() -> PathBuf {
functions_dir().join(GLOBAL_TOOLS_DIR_NAME)
}
pub fn global_utils_dir() -> PathBuf {
functions_dir().join(GLOBAL_TOOLS_UTILS_DIR_NAME)
}
pub fn bash_prompt_utils_file() -> PathBuf {
global_utils_dir().join(BASH_PROMPT_UTILS_FILE_NAME)
}
pub fn agents_data_dir() -> PathBuf {
local_dir(AGENTS_DIR_NAME)
}
pub fn agent_data_dir(name: &str) -> PathBuf {
match env::var(format!("{}_DATA_DIR", normalize_env_name(name))) {
Ok(value) => PathBuf::from(value),
Err(_) => agents_data_dir().join(name),
}
}
pub fn agent_graph_file(agent_name: &str) -> PathBuf {
agent_data_dir(agent_name).join(AGENT_GRAPH_FILE_NAME)
}
pub fn agent_config_file(name: &str) -> PathBuf {
match env::var(format!("{}_CONFIG_FILE", normalize_env_name(name))) {
Ok(value) => PathBuf::from(value),
Err(_) => agent_data_dir(name).join(CONFIG_FILE_NAME),
}
}
pub fn agent_bin_dir(name: &str) -> PathBuf {
agent_data_dir(name).join(FUNCTIONS_BIN_DIR_NAME)
}
pub fn agent_rag_file(agent_name: &str, rag_name: &str) -> PathBuf {
agent_data_dir(agent_name).join(format!("{rag_name}.yaml"))
}
pub fn agent_functions_file(name: &str) -> Result<PathBuf> {
let priority = ["tools.sh", "tools.py", "tools.ts", "tools.js"];
let dir = agent_data_dir(name);
for filename in priority {
let path = dir.join(filename);
if path.exists() {
return Ok(path);
}
}
Err(anyhow!(
"No tools script found in agent functions directory"
))
}
pub fn models_override_file() -> PathBuf {
local_dir("models-override.yaml")
}
pub fn global_memory_dir() -> PathBuf {
config_dir().join(MEMORY_DIR_NAME)
}
pub fn global_memory_index_file() -> PathBuf {
global_memory_dir().join(MEMORY_INDEX_FILE_NAME)
}
pub fn workspace_memory_dir_for(workspace_root: &Path) -> PathBuf {
workspace_root
.join(WORKSPACE_COYOTE_DIR_NAME)
.join(MEMORY_DIR_NAME)
}
pub fn workspace_memory_index_file_for(workspace_root: &Path) -> PathBuf {
workspace_memory_dir_for(workspace_root).join(MEMORY_INDEX_FILE_NAME)
}
pub fn repl_history_dir() -> PathBuf {
cache_dir().join(REPL_HISTORY_DIR_NAME)
}
pub fn repl_history_file(session: &Option<Session>) -> PathBuf {
let history_key = if let Some(session) = &session {
format!("session_{}", session.name().replace('/', "_"))
} else {
"default".to_string()
};
repl_history_dir().join(history_key)
}
pub fn log_config() -> Result<(LevelFilter, Option<PathBuf>)> {
let log_level = env::var(get_env_name("log_level"))
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(match cfg!(debug_assertions) {
true => LevelFilter::Debug,
false => LevelFilter::Info,
});
let resolved_log_path = match env::var(get_env_name("log_path")) {
Ok(v) => Some(PathBuf::from(v)),
Err(_) => Some(log_file()),
};
Ok((log_level, resolved_log_path))
}
pub fn list_roles(with_builtin: bool) -> Vec<String> {
let mut names = HashSet::new();
if let Ok(rd) = read_dir(roles_dir()) {
for entry in rd.flatten() {
if let Some(name) = entry
.file_name()
.to_str()
.and_then(|v| v.strip_suffix(".md"))
{
names.insert(name.to_string());
}
}
}
if with_builtin {
names.extend(Role::list_builtin_role_names());
}
let mut names: Vec<_> = names.into_iter().collect();
names.sort_unstable();
names
}
pub fn has_role(name: &str) -> bool {
let names = list_roles(true);
names.contains(&name.to_string())
}
pub fn list_rags() -> Vec<String> {
match read_dir(rags_dir()) {
Ok(rd) => {
let mut names = vec![];
for entry in rd.flatten() {
let name = entry.file_name();
if let Some(name) = name.to_string_lossy().strip_suffix(".yaml") {
// Sidecars are not RAGs. `.duckdb` files are already excluded by
// the `.yaml` suffix check above; this rejects `<name>.sbx-mixin`.
if is_rag_sidecar_name(name) {
continue;
}
names.push(name.to_string());
}
}
names.sort_unstable();
names
}
Err(_) => vec![],
}
}
/// True for the sidecar YAML files that must never be listed or deleted as RAGs.
/// `name` is the already-stripped stem (i.e. after `strip_suffix(".yaml")`).
/// Uses `ends_with`, not `contains('.')`, so a RAG legitimately named "v2.docs" is
/// not rejected.
pub(crate) fn is_rag_sidecar_name(name: &str) -> bool {
name.ends_with(".sbx-mixin")
}
/// Remove every sidecar belonging to RAG `name` in `dir`. Missing files are NOT an
/// error. A failure to remove an EXISTING mixin IS an error and must propagate — a
/// silently-orphaned mixin keeps a sandbox network permission alive after the user
/// believes it is gone. The `.duckdb` orphan is only wasted disk, so its removal
/// failure is ignorable; the asymmetry is deliberate.
///
/// Callers must run this BEFORE unlinking the primary `.yaml`. If the YAML goes first
/// and this then fails, the RAG disappears from `list_rags()` — so the user can no
/// longer select it to retry — while its `allowedDomains` entry keeps being injected
/// into every sandbox launch.
pub(crate) fn remove_rag_sidecars(dir: &Path, name: &str) -> Result<()> {
let duckdb_path = dir.join(format!("{name}.duckdb"));
if duckdb_path.exists() {
let _ = remove_file(&duckdb_path);
}
let mixin_path = dir.join(format!("{name}.sbx-mixin.yaml"));
if mixin_path.exists() {
remove_file(&mixin_path).with_context(|| {
format!(
"Failed to remove the sandbox mixin for RAG '{name}' at '{}'. \
The RAG was NOT deleted so you can retry; this host remains \
whitelisted in the sandbox until the file is removed.",
mixin_path.display()
)
})?;
}
Ok(())
}
pub fn list_macros() -> Vec<String> {
list_file_names(macros_dir(), ".yaml")
}
pub fn has_macro(name: &str) -> bool {
let names = list_macros();
names.contains(&name.to_string())
}
pub fn list_skills() -> Vec<String> {
let mut names = Vec::new();
let mut seen = HashSet::new();
for dir in [workspace_skills_dir(), skills_dir()] {
if let Ok(rd) = read_dir(dir) {
for entry in rd.flatten() {
if let Ok(file_type) = entry.file_type()
&& file_type.is_dir()
&& let Some(name) = entry.file_name().to_str()
&& !seen.contains(name)
&& entry.path().join("SKILL.md").is_file()
&& validate_skill_name(name).is_ok()
{
seen.insert(name.to_string());
names.push(name.to_string());
}
}
}
}
names.sort_unstable();
names
}
pub fn has_skill(name: &str) -> bool {
workspace_skill_file(name).is_file() || skill_file(name).is_file()
}
pub fn local_models_override() -> Result<Vec<ProviderModels>> {
let model_override_path = models_override_file();
let err = || {
format!(
"Failed to load models at '{}'",
model_override_path.display()
)
};
let content = read_to_string(&model_override_path).with_context(err)?;
let models_override: ModelsOverride = serde_yaml::from_str(&content).with_context(err)?;
if models_override.version != env!("CARGO_PKG_VERSION") {
bail!("Incompatible version")
}
Ok(models_override.list)
}
#[cfg(test)]
mod tests {
use super::*;
use std::{fs, time};
#[test]
fn validate_skill_name_accepts_alphanumerics_and_dashes() {
assert!(validate_skill_name("git-master").is_ok());
assert!(validate_skill_name("code_review").is_ok());
assert!(validate_skill_name("Skill1").is_ok());
}
#[test]
fn validate_skill_name_rejects_empty() {
let err = validate_skill_name("").unwrap_err();
assert!(err.to_string().contains("cannot be empty"));
}
#[test]
fn validate_skill_name_rejects_path_traversal() {
for bad in ["../escape", "..", "foo/bar", "foo\\bar", "./hidden"] {
let err = validate_skill_name(bad).unwrap_err();
assert!(
err.to_string().contains("Invalid skill name"),
"expected rejection for {bad:?}, got: {err}"
);
}
}
#[test]
fn validate_skill_name_rejects_other_special_chars() {
for bad in ["with space", "null\0byte", "weird?char", "dot.name"] {
assert!(
validate_skill_name(bad).is_err(),
"expected rejection for {bad:?}"
);
}
}
#[test]
fn has_skill_returns_false_for_missing_paths() {
for absent in ["definitely-not-installed-skill-xyz", "another-missing"] {
assert!(
!has_skill(absent),
"has_skill({absent:?}) should be false for a missing skill"
);
}
}
mod sandbox_home_translation {
use super::*;
use serial_test::serial;
fn with_sandbox<F: FnOnce()>(f: F) {
let prev = env::var_os("IS_SANDBOX");
unsafe {
env::set_var("IS_SANDBOX", "1");
}
f();
unsafe {
match prev {
Some(v) => env::set_var("IS_SANDBOX", v),
None => env::remove_var("IS_SANDBOX"),
}
}
}
fn without_sandbox<F: FnOnce()>(f: F) {
let prev = env::var_os("IS_SANDBOX");
unsafe {
env::remove_var("IS_SANDBOX");
}
f();
unsafe {
if let Some(v) = prev {
env::set_var("IS_SANDBOX", v);
}
}
}
#[test]
#[serial]
fn returns_none_when_not_in_sandbox() {
without_sandbox(|| {
let p = Path::new("/home/atusa/.coyote_password");
assert_eq!(translate_sandboxed_home_dir(p), None);
});
}
#[test]
#[serial]
fn translates_host_home_to_agent_home() {
with_sandbox(|| {
let p = Path::new("/home/atusa/.coyote_password");
assert_eq!(
translate_sandboxed_home_dir(p),
Some(PathBuf::from("/home/agent/.coyote_password"))
);
});
}
#[test]
#[serial]
fn translates_nested_host_home_dir() {
with_sandbox(|| {
let p = Path::new("/home/atusa/.config/coyote/.password");
assert_eq!(
translate_sandboxed_home_dir(p),
Some(PathBuf::from("/home/agent/.config/coyote/.password"))
);
});
}
#[test]
#[serial]
fn returns_none_when_path_already_targets_agent_home() {
with_sandbox(|| {
let p = Path::new("/home/agent/.coyote_password");
assert_eq!(translate_sandboxed_home_dir(p), None);
});
}
#[test]
#[serial]
fn returns_none_when_path_is_outside_home() {
with_sandbox(|| {
let p = Path::new("/etc/coyote/.coyote_password");
assert_eq!(translate_sandboxed_home_dir(p), None);
});
}
#[test]
#[serial]
fn returns_none_for_relative_path() {
with_sandbox(|| {
let p = Path::new(".coyote_password");
assert_eq!(translate_sandboxed_home_dir(p), None);
});
}
#[test]
#[serial]
fn returns_none_for_first_segment_not_home() {
with_sandbox(|| {
let p = Path::new("/opt/atusa/.coyote_password");
assert_eq!(translate_sandboxed_home_dir(p), None);
});
}
#[test]
#[serial]
fn translates_macos_users_dir() {
with_sandbox(|| {
let p = Path::new("/Users/atusa/.coyote_password");
assert_eq!(
translate_sandboxed_home_dir(p),
Some(PathBuf::from("/home/agent/.coyote_password"))
);
});
}
#[test]
#[serial]
fn translates_macos_nested_dir() {
with_sandbox(|| {
let p = Path::new("/Users/atusa/.config/coyote/.password");
assert_eq!(
translate_sandboxed_home_dir(p),
Some(PathBuf::from("/home/agent/.config/coyote/.password"))
);
});
}
#[test]
#[serial]
fn returns_none_when_macos_dir_already_targets_agent() {
with_sandbox(|| {
let p = Path::new("/Users/agent/.coyote_password");
assert_eq!(translate_sandboxed_home_dir(p), None);
});
}
#[test]
#[serial]
fn translates_windows_drive_letter_path() {
with_sandbox(|| {
let p = Path::new("C:\\Users\\atusa\\.coyote_password");
assert_eq!(
translate_sandboxed_home_dir(p),
Some(PathBuf::from("/home/agent/.coyote_password"))
);
});
}
#[test]
#[serial]
fn translates_windows_nested_path() {
with_sandbox(|| {
let p = Path::new("D:\\Users\\atusa\\.config\\coyote\\.password");
assert_eq!(
translate_sandboxed_home_dir(p),
Some(PathBuf::from("/home/agent/.config/coyote/.password"))
);
});
}
#[test]
#[serial]
fn returns_none_when_windows_path_already_targets_agent() {
with_sandbox(|| {
let p = Path::new("C:\\Users\\agent\\.coyote_password");
assert_eq!(translate_sandboxed_home_dir(p), None);
});
}
}
mod workspace_mcp_resolution {
use super::*;
use serial_test::serial;
fn with_workspace_dir<F: FnOnce(&Path, &Path)>(f: F) {
let unique = time::SystemTime::now()
.duration_since(time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = env::temp_dir().join(format!("coyote-workspace-mcp-test-{unique}"));
let ws_dir = root.join(WORKSPACE_COYOTE_DIR_NAME);
fs::create_dir_all(&ws_dir).unwrap();
let env_name = get_env_name("workspace_config_dir");
let prev = env::var_os(&env_name);
unsafe {
env::set_var(&env_name, &ws_dir);
}
f(&root, &ws_dir);
unsafe {
match prev {
Some(v) => env::set_var(&env_name, v),
None => env::remove_var(&env_name),
}
}
let _ = fs::remove_dir_all(&root);
}
#[test]
#[serial]
fn returns_none_when_no_config_exists() {
with_workspace_dir(|root, _| {
assert_eq!(workspace_mcp_config_file_in(root), None);
});
}
#[test]
#[serial]
fn finds_mcp_json() {
with_workspace_dir(|root, ws_dir| {
fs::write(ws_dir.join("mcp.json"), "{}").unwrap();
assert_eq!(
workspace_mcp_config_file_in(root),
Some(ws_dir.join("mcp.json"))
);
});
}
#[test]
#[serial]
fn falls_back_to_claude_style_hidden_mcp_json() {
with_workspace_dir(|root, ws_dir| {
fs::write(ws_dir.join(".mcp.json"), "{}").unwrap();
assert_eq!(
workspace_mcp_config_file_in(root),
Some(ws_dir.join(".mcp.json"))
);
});
}
#[test]
#[serial]
fn prefers_mcp_json_when_both_exist() {
with_workspace_dir(|root, ws_dir| {
fs::write(ws_dir.join("mcp.json"), "{}").unwrap();
fs::write(ws_dir.join(".mcp.json"), "{}").unwrap();
assert_eq!(
workspace_mcp_config_file_in(root),
Some(ws_dir.join("mcp.json"))
);
});
}
#[test]
#[serial]
fn falls_back_to_project_root_hidden_mcp_json() {
with_workspace_dir(|root, _| {
fs::write(root.join(".mcp.json"), "{}").unwrap();
assert_eq!(
workspace_mcp_config_file_in(root),
Some(root.join(".mcp.json"))
);
});
}
#[test]
#[serial]
fn prefers_workspace_dir_config_over_project_root() {
with_workspace_dir(|root, ws_dir| {
fs::write(ws_dir.join(".mcp.json"), "{}").unwrap();
fs::write(root.join(".mcp.json"), "{}").unwrap();
assert_eq!(
workspace_mcp_config_file_in(root),
Some(ws_dir.join(".mcp.json"))
);
});
}
}
#[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()
.duration_since(time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = env::temp_dir().join(format!("coyote-list-skills-test-{unique}"));
fs::create_dir_all(&root).unwrap();
let prev = env::var_os(get_env_name("skills_dir"));
unsafe {
env::set_var(get_env_name("skills_dir"), &root);
}
for name in ["valid-skill", "with space", ".hidden", "dot.name"] {
let dir = root.join(name);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("SKILL.md"), "body").unwrap();
}
let listed = list_skills();
assert_eq!(listed, vec!["valid-skill".to_string()]);
unsafe {
match prev {
Some(v) => env::set_var(get_env_name("skills_dir"), v),
None => env::remove_var(get_env_name("skills_dir")),
}
}
let _ = fs::remove_dir_all(&root);
}
/// Unique temp dir for the sidecar helper tests. These take `dir: &Path` directly,
/// so no env-var mutation and therefore no `#[serial]` is needed.
fn sidecar_temp_dir(label: &str) -> PathBuf {
let unique = time::SystemTime::now()
.duration_since(time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = env::temp_dir().join(format!("coyote-{label}-test-{unique}"));
fs::create_dir_all(&root).unwrap();
root
}
#[test]
fn is_rag_sidecar_name_accepts_dotted_rag_names() {
// A RAG legitimately named "v2.docs" must not be mistaken for a sidecar.
assert!(!is_rag_sidecar_name("v2.docs"));
assert!(!is_rag_sidecar_name("myrag"));
assert!(is_rag_sidecar_name("myrag.sbx-mixin"));
assert!(is_rag_sidecar_name("v2.docs.sbx-mixin"));
}
#[test]
fn remove_rag_sidecars_removes_both() {
let root = sidecar_temp_dir("rag-sidecars-both");
let duckdb = root.join("docs.duckdb");
let mixin = root.join("docs.sbx-mixin.yaml");
fs::write(&duckdb, "db").unwrap();
fs::write(&mixin, "mixin").unwrap();
remove_rag_sidecars(&root, "docs").unwrap();
assert!(!duckdb.exists(), "the .duckdb sidecar must be removed");
assert!(
!mixin.exists(),
"the .sbx-mixin.yaml sidecar must be removed"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn remove_rag_sidecars_is_ok_when_absent() {
let root = sidecar_temp_dir("rag-sidecars-absent");
assert!(remove_rag_sidecars(&root, "docs").is_ok());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn remove_rag_sidecars_runs_before_yaml_unlink() {
let root = sidecar_temp_dir("rag-sidecars-order");
let yaml = root.join("docs.yaml");
fs::write(&yaml, "rag").unwrap();
// A non-empty DIRECTORY at the mixin path makes remove_file fail, standing in
// for any real removal failure (permissions, a busy mount).
let mixin = root.join("docs.sbx-mixin.yaml");
fs::create_dir_all(&mixin).unwrap();
fs::write(mixin.join("blocker"), "x").unwrap();
let err = remove_rag_sidecars(&root, "docs").unwrap_err();
assert!(
err.to_string()
.contains("Failed to remove the sandbox mixin"),
"got: {err}"
);
// The whole point of removing sidecars first: the RAG is still on disk, still
// listed, and the deletion is retryable.
assert!(
yaml.exists(),
"the .yaml must survive a sidecar-removal failure so the delete is retryable"
);
let _ = fs::remove_dir_all(&root);
}
}