From fc3bb259cbec2ff8fa11b4d7ae146ea84d355482 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 09:41:12 -0600 Subject: [PATCH] feat(TASK-038): add hooks builtin-asset category with example scripts and roles mixin scanning - AssetCategory::Hooks + InstallFilter::Hooks (NAMES/parse/clap), reserving the 'hooks' bundle name via the existing AssetCategory::parse check - HookAssets embed + install_builtin_hooks(force): installs the bundled example scripts 0755 into paths::hooks_dir() via write_file_atomic, wired into install_builtins() and install_assets(); warns once when COYOTE_HOOKS_DIR diverges from the config-dir default - assets/hooks/notify.sh: notify-send -> osascript -> terminal fallback, with AppleScript-literal sanitization of model-influenced values - assets/hooks/log-events.sh: appends a COYOTE_* env snapshot (secrets excluded, 0600 log, symlink-refusing) to ${COYOTE_HOOK_LOG:-/tmp/coyote-hooks.log} - sbx-mixin discovery now scans roles/*.sbx-mixin.yaml and roles/hooks/*.sbx-mixin.yaml (mirroring the role-hook cwd) - tests: install+0755+force, install_assets(Hooks) end-to-end, discover() roles scanning, reserved bundle name, no-ARGC-BUILD embed content, script content order + real execution (secret exclusion, tty-less fallback), CLI parser round-trips; hooks_dir env test made #[serial] --- assets/hooks/log-events.sh | 40 +++++++ assets/hooks/notify.sh | 42 +++++++ src/cli/mod.rs | 10 ++ src/config/bundles.rs | 7 ++ src/config/install_remote.rs | 42 +++++++ src/config/mod.rs | 19 ++- src/config/paths.rs | 2 +- src/hooks.rs | 219 ++++++++++++++++++++++++++++++++++- src/sandbox/mixins.rs | 62 ++++++++++ 9 files changed, 439 insertions(+), 4 deletions(-) create mode 100755 assets/hooks/log-events.sh create mode 100755 assets/hooks/notify.sh diff --git a/assets/hooks/log-events.sh b/assets/hooks/log-events.sh new file mode 100755 index 0000000..a5563d6 --- /dev/null +++ b/assets/hooks/log-events.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Event-logging hook: appends a timestamped snapshot of every COYOTE_* env var +# to a log file, so you can see exactly which events fire and what context +# each one carries. +# +# Env vars read: +# COYOTE_HOOK_LOG log file to append to (default: /tmp/coyote-hooks.log) +# COYOTE_* everything coyote sets for the event is dumped verbatim +# +# Secrets (COYOTE_SECRET_*) are excluded from the snapshot; widening the grep +# filter below opts into logging them. +# +# Wire it up under `hooks:` in your config.yaml (any event works): +# hooks: +# tool.completed: +# - name: log-events +# command: ./hooks/log-events.sh +# +# A relative command path like ./hooks/log-events.sh resolves against the +# coyote config directory for global hooks, so this works with the default +# hooks dir. +# +# See https://github.com/Dark-Alex-17/coyote/wiki/Hooks for the full list of +# events and env vars. Hooks are fire-and-forget: this script never fails +# loudly, and coyote ignores its exit code either way. + +set -u + +log_file="${COYOTE_HOOK_LOG:-/tmp/coyote-hooks.log}" + +# The default lives in shared /tmp: refuse to append through a symlink someone +# else planted there. Point COYOTE_HOOK_LOG at a private path to avoid the +# shared directory entirely. +[ -L "$log_file" ] && exit 0 +umask 077 +{ + echo "=== $(date '+%Y-%m-%dT%H:%M:%S%z') ${COYOTE_EVENT:-unknown}" + env | grep '^COYOTE_' | grep -v '^COYOTE_SECRET_' | sort + echo +} >> "$log_file" 2> /dev/null || true diff --git a/assets/hooks/notify.sh b/assets/hooks/notify.sh new file mode 100755 index 0000000..1c010a3 --- /dev/null +++ b/assets/hooks/notify.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Desktop-notification hook: posts a notification describing the event that +# fired, falling back to a message on the controlling terminal when no +# notifier is present. +# +# Env vars read (set by coyote when the hook fires): +# COYOTE_EVENT dotted event name, e.g. turn.completed +# COYOTE_AGENT_NAME agent name, when the event carries one +# COYOTE_TOOL_NAME tool name, when the event carries one +# +# Wire it up under `hooks:` in your config.yaml: +# hooks: +# turn.completed: +# - name: notify +# command: ./hooks/notify.sh +# +# A relative command path like ./hooks/notify.sh resolves against the coyote +# config directory for global hooks, so this works with the default hooks dir. +# +# See https://github.com/Dark-Alex-17/coyote/wiki/Hooks for the full list of +# events and env vars. Hooks are fire-and-forget: this script never fails +# loudly, and coyote ignores its exit code either way. + +set -u + +title="coyote" +body="${COYOTE_EVENT:-event}" +[ -n "${COYOTE_AGENT_NAME:-}" ] && body="$body agent=$COYOTE_AGENT_NAME" +[ -n "${COYOTE_TOOL_NAME:-}" ] && body="$body tool=$COYOTE_TOOL_NAME" + +if command -v notify-send > /dev/null 2>&1; then + notify-send "$title" "$body" 2> /dev/null || true +elif command -v osascript > /dev/null 2>&1; then + # $body carries model-influenced values (e.g. tool names); strip quotes and + # backslashes so it cannot escape the AppleScript string literal. + safe_body=${body//[\"\\]/} + osascript -e "display notification \"$safe_body\" with title \"$title\"" 2> /dev/null || true +else + # coyote discards hook stdout, so aim the fallback at the controlling + # terminal when one exists; plain stdout keeps direct/manual runs working. + { echo "[$title] $body" > /dev/tty; } 2> /dev/null || echo "[$title] $body" +fi diff --git a/src/cli/mod.rs b/src/cli/mod.rs index f53658b..4a262e5 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -609,6 +609,10 @@ mod tests { parse(&["--install-builtins", "mcp_config"]).install_builtins, Some(AssetCategory::McpConfig) ); + assert_eq!( + parse(&["--install-builtins", "hooks"]).install_builtins, + Some(AssetCategory::Hooks) + ); } #[test] @@ -620,12 +624,14 @@ mod tests { assert!(category.get_name_and_aliases().any(|n| n == "mcp_config")); assert!(AssetCategory::NAMES.contains(&"mcp-config")); assert!(!AssetCategory::NAMES.contains(&"mcp_config")); + assert!(AssetCategory::NAMES.contains(&"hooks")); let filter = InstallFilter::McpConfig.to_possible_value().unwrap(); assert_eq!(filter.get_name(), "mcp-config"); assert!(filter.get_name_and_aliases().any(|n| n == "mcp_config")); assert!(InstallFilter::NAMES.contains(&"mcp-config")); assert!(!InstallFilter::NAMES.contains(&"mcp_config")); + assert!(InstallFilter::NAMES.contains(&"hooks")); } #[test] @@ -685,6 +691,10 @@ mod tests { .filter, Some(InstallFilter::McpConfig) ); + assert_eq!( + parse(&["--install", "https://github.com/x/y", "--filter", "hooks"]).filter, + Some(InstallFilter::Hooks) + ); } #[test] diff --git a/src/config/bundles.rs b/src/config/bundles.rs index c48c560..caa3ce2 100644 --- a/src/config/bundles.rs +++ b/src/config/bundles.rs @@ -1183,6 +1183,13 @@ mod tests { assert_eq!(resolved.name, "y/mcp_config"); assert_eq!(resolved.qualified_from.as_deref(), Some("mcp_config")); + + let resolved = store + .resolve_bundle_name("https://github.com/z/hooks", None) + .unwrap(); + + assert_eq!(resolved.name, "z/hooks"); + assert_eq!(resolved.qualified_from.as_deref(), Some("hooks")); } #[test] diff --git a/src/config/install_remote.rs b/src/config/install_remote.rs index 18fdcea..02a5b99 100644 --- a/src/config/install_remote.rs +++ b/src/config/install_remote.rs @@ -1540,6 +1540,10 @@ fn apply_filter(mut layout: RemoteLayout, filter: Option) -> Remo functions_tools: layout.functions_tools.take(), ..base }, + // Bundles cannot ship hooks yet: RemoteLayout has no hooks field, so + // this filter always yields an empty layout. Bundle-side hooks support + // (layout field, scan, is_empty) lands together with TopCategory::Hooks. + InstallFilter::Hooks => base, InstallFilter::McpConfig => RemoteLayout { mcp_json: layout.mcp_json.take(), ..base @@ -3662,6 +3666,44 @@ mod tests { assert!(!paths::installed_bundles_file().exists()); } + #[test] + #[serial] + fn builtin_hooks_category_installs_example_scripts() { + if *IS_STDOUT_TERMINAL { + eprintln!( + "Skipping builtin_hooks_category_installs_example_scripts: requires non-TTY stdout" + ); + return; + } + let _guard = TestVaultConfigGuard::new("prov-hooks"); + let hooks_key = get_env_name("hooks_dir"); + let previous_hooks = env::var_os(&hooks_key); + let hooks_dir = fresh_temp_dir("prov-hooks-dir-"); + unsafe { + env::set_var(&hooks_key, &hooks_dir); + } + + // Capture outcomes before restoring the env var so a failed assertion + // cannot leave the hooks-dir override pointing at a deleted temp dir. + let result = crate::config::install_assets(crate::config::AssetCategory::Hooks); + let installed = ( + hooks_dir.join("notify.sh").is_file(), + hooks_dir.join("log-events.sh").is_file(), + ); + + unsafe { + match previous_hooks { + Some(v) => env::set_var(&hooks_key, v), + None => env::remove_var(&hooks_key), + } + } + let _ = fs::remove_dir_all(&hooks_dir); + + result.unwrap(); + assert!(installed.0, "notify.sh must be installed"); + assert!(installed.1, "log-events.sh must be installed"); + } + #[test] #[serial] fn update_silently_refreshes_owned_unmodified_files() { diff --git a/src/config/mod.rs b/src/config/mod.rs index b608ae2..73ecdc2 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -419,6 +419,7 @@ pub fn install_builtins() -> Result<()> { Agent::install_builtin_agents(false)?; Macro::install_macros(false)?; Skill::install_builtin_skills(false)?; + crate::hooks::install_builtin_hooks(false)?; Ok(()) } @@ -428,12 +429,20 @@ pub enum AssetCategory { Macros, Functions, Skills, + Hooks, #[value(name = "mcp-config", alias = "mcp_config")] McpConfig, } impl AssetCategory { - pub const NAMES: [&'static str; 5] = ["agents", "macros", "functions", "skills", "mcp-config"]; + pub const NAMES: [&'static str; 6] = [ + "agents", + "macros", + "functions", + "skills", + "hooks", + "mcp-config", + ]; pub fn parse(name: &str) -> Option { match name { @@ -441,6 +450,7 @@ impl AssetCategory { "macros" => Some(Self::Macros), "functions" => Some(Self::Functions), "skills" => Some(Self::Skills), + "hooks" => Some(Self::Hooks), "mcp-config" | "mcp_config" => Some(Self::McpConfig), _ => None, } @@ -460,17 +470,19 @@ pub enum InstallFilter { Skills, Macros, Functions, + Hooks, #[value(name = "mcp-config", alias = "mcp_config")] McpConfig, } impl InstallFilter { - pub const NAMES: [&'static str; 6] = [ + pub const NAMES: [&'static str; 7] = [ "agents", "roles", "skills", "macros", "functions", + "hooks", "mcp-config", ]; @@ -481,6 +493,7 @@ impl InstallFilter { "skills" => Some(Self::Skills), "macros" => Some(Self::Macros), "functions" => Some(Self::Functions), + "hooks" => Some(Self::Hooks), "mcp-config" | "mcp_config" => Some(Self::McpConfig), _ => None, } @@ -493,6 +506,7 @@ pub fn install_assets(category: AssetCategory) -> Result<()> { AssetCategory::Macros => ("macros", paths::macros_dir()), AssetCategory::Functions => ("functions", paths::functions_dir()), AssetCategory::Skills => ("skills", paths::skills_dir()), + AssetCategory::Hooks => ("hooks", paths::hooks_dir()), AssetCategory::McpConfig => ("MCP config", paths::mcp_config_file()), }; @@ -506,6 +520,7 @@ pub fn install_assets(category: AssetCategory) -> Result<()> { AssetCategory::Macros => Macro::install_macros(true)?, AssetCategory::Functions => Functions::install_builtin_global_tools(true)?, AssetCategory::Skills => Skill::install_builtin_skills(true)?, + AssetCategory::Hooks => crate::hooks::install_builtin_hooks(true)?, AssetCategory::McpConfig => Functions::install_mcp_config()?, } diff --git a/src/config/paths.rs b/src/config/paths.rs index 5955007..8b9e125 100644 --- a/src/config/paths.rs +++ b/src/config/paths.rs @@ -184,7 +184,6 @@ pub fn role_file(name: &str) -> PathBuf { roles_dir().join(format!("{name}.md")) } -#[allow(dead_code)] pub fn hooks_dir() -> PathBuf { match env::var(get_env_name("hooks_dir")) { Ok(value) => PathBuf::from(value), @@ -956,6 +955,7 @@ mod tests { } #[test] + #[serial_test::serial] fn hooks_dir_reflects_env_var_state() { let env_name = get_env_name("hooks_dir"); let prev = env::var_os(&env_name); diff --git a/src/hooks.rs b/src/hooks.rs index f7cfee3..590c5c8 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -1,8 +1,11 @@ -use crate::config::{RequestContext, paths}; +use crate::config::{RequestContext, ensure_parent_exists, paths}; +use crate::function::write_file_atomic; +use anyhow::{Result, anyhow}; use chrono::{SecondsFormat, Utc}; use indexmap::IndexMap; use rand::distr::{Alphanumeric, SampleString}; +use rust_embed::Embed; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use std::process::Stdio; @@ -12,6 +15,52 @@ use tokio::io::AsyncWriteExt; use tokio::process::Command; use tokio::sync::Notify; +#[derive(Embed)] +#[folder = "assets/hooks/"] +struct HookAssets; + +pub fn install_builtin_hooks(force: bool) -> Result<()> { + info!( + "Installing built-in example hooks in {}", + paths::hooks_dir().display() + ); + + let mut wrote_any = false; + for file in HookAssets::iter() { + debug!("Processing hook file: {}", file.as_ref()); + + let embedded_file = HookAssets::get(&file) + .ok_or_else(|| anyhow!("Failed to load embedded hook file: {}", file.as_ref()))?; + let content = unsafe { std::str::from_utf8_unchecked(&embedded_file.data) }; + let file_path = paths::hooks_dir().join(file.as_ref()); + + if file_path.exists() && !force { + debug!( + "Hook file already exists, skipping: {}", + file_path.display() + ); + continue; + } + + ensure_parent_exists(&file_path)?; + info!("Creating hook file: {}", file_path.display()); + write_file_atomic(&file_path, content, Some(0o755))?; + wrote_any = true; + } + + if wrote_any && paths::hooks_dir() != paths::config_dir().join("hooks") { + warn!( + "{} overrides the hooks dir: example scripts install to {}, but relative \ + global-hook commands resolve against {}", + crate::utils::get_env_name("hooks_dir"), + paths::hooks_dir().display(), + paths::config_dir().display() + ); + } + + Ok(()) +} + /// A single named hook: an external command to run when its event fires. #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] pub struct HookDef { @@ -605,6 +654,174 @@ mod tests { RequestContext::new(Arc::new(app), WorkingMode::Cmd) } + #[test] + #[serial] + fn install_builtin_hooks_installs_executable_scripts_and_honors_force() { + let env_name = crate::utils::get_env_name("hooks_dir"); + let prev = env::var_os(&env_name); + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = env::temp_dir().join(format!("coyote-hooks-install-{unique}")); + unsafe { + env::set_var(&env_name, &root); + } + + // Capture every outcome first and assert only after the env var is + // restored, so a failed assertion cannot leave the hooks-dir override + // pointing at a deleted temp dir for subsequent tests. + let notify = root.join("notify.sh"); + let log_events = root.join("log-events.sh"); + let fresh = install_builtin_hooks(false); + let installed = (notify.is_file(), log_events.is_file()); + #[cfg(unix)] + let modes: Vec> = { + use std::os::unix::fs::PermissionsExt; + [¬ify, &log_events] + .iter() + .map(|path| std::fs::metadata(path).map(|meta| meta.permissions().mode() & 0o777)) + .collect() + }; + let modified = std::fs::write(¬ify, "modified"); + let no_force = install_builtin_hooks(false); + let after_no_force = std::fs::read_to_string(¬ify); + let force = install_builtin_hooks(true); + let after_force = std::fs::read_to_string(¬ify); + + unsafe { + match prev { + Some(v) => env::set_var(&env_name, v), + None => env::remove_var(&env_name), + } + } + let _ = std::fs::remove_dir_all(&root); + + fresh.unwrap(); + assert!(installed.0, "notify.sh must be installed"); + assert!(installed.1, "log-events.sh must be installed"); + #[cfg(unix)] + for (path, mode) in [¬ify, &log_events].into_iter().zip(modes) { + assert_eq!( + mode.unwrap(), + 0o755, + "{} must be executable", + path.display() + ); + } + modified.unwrap(); + no_force.unwrap(); + assert_eq!(after_no_force.unwrap(), "modified"); + force.unwrap(); + assert!(after_force.unwrap().starts_with("#!/usr/bin/env bash")); + } + + /// The example hooks are plain scripts, not argc tools: they live outside + /// `assets/functions/tools/`, so the argc regeneration that runs during + /// tests must never have stamped them with an ARGC-BUILD block. + #[test] + fn builtin_hook_assets_are_not_argc_tools() { + let files: Vec = HookAssets::iter() + .map(|file| file.as_ref().to_string()) + .collect(); + assert!(files.contains(&"notify.sh".to_string()), "{files:?}"); + assert!(files.contains(&"log-events.sh".to_string()), "{files:?}"); + + for file in HookAssets::iter() { + let embedded = HookAssets::get(&file).unwrap(); + let content = std::str::from_utf8(&embedded.data).unwrap(); + assert!( + !content.contains("ARGC-BUILD"), + "{} must not contain an ARGC-BUILD block", + file.as_ref() + ); + assert!( + !content.contains("# @cmd"), + "{} must not use argc annotations", + file.as_ref() + ); + } + } + + #[test] + fn notify_script_prefers_notify_send_then_osascript_then_echo() { + let embedded = HookAssets::get("notify.sh").unwrap(); + let content = std::str::from_utf8(&embedded.data).unwrap(); + let notify_send = content.find("notify-send").expect("notify-send branch"); + let osascript = content.find("osascript").expect("osascript branch"); + let echo = content.find("echo \"[$title]").expect("echo fallback"); + assert!(notify_send < osascript); + assert!(osascript < echo); + } + + #[test] + fn log_events_script_defaults_to_tmp_log_behind_env_override() { + let embedded = HookAssets::get("log-events.sh").unwrap(); + let content = std::str::from_utf8(&embedded.data).unwrap(); + assert!(content.contains("COYOTE_HOOK_LOG")); + assert!(content.contains("/tmp/coyote-hooks.log")); + } + + #[cfg(unix)] + #[test] + fn log_events_script_appends_event_and_env_snapshot() { + let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/hooks/log-events.sh"); + let log = + env::temp_dir().join(format!("coyote-log-events-test-{}.log", std::process::id())); + let _ = std::fs::remove_file(&log); + + let status = std::process::Command::new("bash") + .arg(&script) + .env("COYOTE_HOOK_LOG", &log) + .env("COYOTE_EVENT", "turn.completed") + .env("COYOTE_AGENT_NAME", "demo-agent") + .env("COYOTE_SECRET_DEMO", "hunter2") + .status() + .unwrap(); + + assert!(status.success()); + let logged = std::fs::read_to_string(&log).unwrap(); + let mode = { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(&log).unwrap().permissions().mode() + }; + let _ = std::fs::remove_file(&log); + assert!(logged.contains("turn.completed"), "{logged}"); + assert!(logged.contains("COYOTE_EVENT=turn.completed"), "{logged}"); + assert!(logged.contains("COYOTE_AGENT_NAME=demo-agent"), "{logged}"); + assert!(!logged.contains("COYOTE_SECRET_"), "{logged}"); + assert!(!logged.contains("hunter2"), "{logged}"); + assert_eq!(mode & 0o777, 0o600, "log file should be created 0600"); + } + + #[cfg(unix)] + #[test] + fn notify_script_falls_back_to_echo_without_notifiers() { + let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/hooks/notify.sh"); + // An empty PATH hides notify-send and osascript; echo is a bash + // builtin, so only the fallback branch can produce output. + let mut cmd = std::process::Command::new("/bin/bash"); + cmd.arg(&script) + .env("PATH", "") + .env("COYOTE_EVENT", "turn.completed") + .env("COYOTE_TOOL_NAME", "demo"); + // Detach from any controlling terminal so the script cannot open + // /dev/tty and must fall back to captured stdout. + unsafe { + use std::os::unix::process::CommandExt; + cmd.pre_exec(|| { + libc::setsid(); + Ok(()) + }); + } + let output = cmd.output().unwrap(); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("turn.completed"), "{stdout}"); + assert!(stdout.contains("tool=demo"), "{stdout}"); + } + #[test] fn hook_event_names_are_dotted() { let cases = [ diff --git a/src/sandbox/mixins.rs b/src/sandbox/mixins.rs index 9d75e6f..100b06b 100644 --- a/src/sandbox/mixins.rs +++ b/src/sandbox/mixins.rs @@ -200,6 +200,19 @@ pub fn discover() -> Result> { for path in collect_mixins(&paths::rags_dir(), &[ScanMode::Flat]) { out.push(read_mixin(path)?); } + for path in collect_mixins(&paths::roles_dir(), &[ScanMode::Flat]) { + out.push(read_mixin(path)?); + } + // Mirrors the role-hook working directory (role hook commands run with + // cwd = roles_dir), so `./hooks/*.sbx-mixin.yaml` beside a role resolves + // the same way the role's hook commands do — intentionally NOT + // paths::hooks_dir(). The agent-hook working directory + // (`//hooks/` — agent hooks run with cwd = the agent's + // data dir) is deliberately not scanned here either; agent-dir scanning + // above covers `//*.sbx-mixin.yaml` only. + for path in collect_mixins(&paths::roles_dir().join("hooks"), &[ScanMode::Flat]) { + out.push(read_mixin(path)?); + } if let Ok(cwd) = env::current_dir() && let Some(path) = paths::find_workspace_sbx_mixin(&cwd) @@ -940,6 +953,55 @@ network: let _ = fs::remove_dir_all(&root); } + #[test] + #[serial_test::serial] + fn discover_scans_roles_and_role_hooks_mixins() { + use crate::utils::get_env_name; + + let root = unique_root("discover-roles"); + let roles = root.join("roles"); + fs::create_dir_all(roles.join("hooks")).unwrap(); + let role_mixin = roles.join("review.sbx-mixin.yaml"); + let hook_mixin = roles.join("hooks").join("notify.sbx-mixin.yaml"); + fs::write(&role_mixin, "kind: mixin\n").unwrap(); + fs::write(&hook_mixin, "kind: mixin\n").unwrap(); + + let config_env = get_env_name("config_dir"); + let roles_env = get_env_name("roles_dir"); + let prev_config = env::var_os(&config_env); + let prev_roles = env::var_os(&roles_env); + unsafe { + env::set_var(&config_env, &root); + env::set_var(&roles_env, &roles); + } + + let discovered = discover(); + + unsafe { + match prev_config { + Some(v) => env::set_var(&config_env, v), + None => env::remove_var(&config_env), + } + match prev_roles { + Some(v) => env::set_var(&roles_env, v), + None => env::remove_var(&roles_env), + } + } + + let found: Vec = discovered + .unwrap() + .into_iter() + .map(|mixin| mixin.path) + .collect(); + assert!(found.contains(&role_mixin), "missing role mixin: {found:?}"); + assert!( + found.contains(&hook_mixin), + "missing role hook mixin: {found:?}" + ); + + let _ = fs::remove_dir_all(&root); + } + /// `generate_rag_sbx_mixin` writes an agent-scoped RAG sidecar next to the /// rag yaml, at `//.sbx-mixin.yaml`. Before `SubdirFlat` /// existed, nothing scanned that shape and attaching a Qdrant RAG from