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]
This commit is contained in:
9 files changed
+439
-4
No files matched your search
Executable
+40
@@ -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
|
||||||
Executable
+42
@@ -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
|
||||||
@@ -609,6 +609,10 @@ mod tests {
|
|||||||
parse(&["--install-builtins", "mcp_config"]).install_builtins,
|
parse(&["--install-builtins", "mcp_config"]).install_builtins,
|
||||||
Some(AssetCategory::McpConfig)
|
Some(AssetCategory::McpConfig)
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse(&["--install-builtins", "hooks"]).install_builtins,
|
||||||
|
Some(AssetCategory::Hooks)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -620,12 +624,14 @@ mod tests {
|
|||||||
assert!(category.get_name_and_aliases().any(|n| n == "mcp_config"));
|
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(&"mcp_config"));
|
assert!(!AssetCategory::NAMES.contains(&"mcp_config"));
|
||||||
|
assert!(AssetCategory::NAMES.contains(&"hooks"));
|
||||||
|
|
||||||
let filter = InstallFilter::McpConfig.to_possible_value().unwrap();
|
let filter = InstallFilter::McpConfig.to_possible_value().unwrap();
|
||||||
assert_eq!(filter.get_name(), "mcp-config");
|
assert_eq!(filter.get_name(), "mcp-config");
|
||||||
assert!(filter.get_name_and_aliases().any(|n| n == "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(&"mcp_config"));
|
assert!(!InstallFilter::NAMES.contains(&"mcp_config"));
|
||||||
|
assert!(InstallFilter::NAMES.contains(&"hooks"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -685,6 +691,10 @@ mod tests {
|
|||||||
.filter,
|
.filter,
|
||||||
Some(InstallFilter::McpConfig)
|
Some(InstallFilter::McpConfig)
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse(&["--install", "https://github.com/x/y", "--filter", "hooks"]).filter,
|
||||||
|
Some(InstallFilter::Hooks)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1183,6 +1183,13 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(resolved.name, "y/mcp_config");
|
assert_eq!(resolved.name, "y/mcp_config");
|
||||||
assert_eq!(resolved.qualified_from.as_deref(), Some("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]
|
#[test]
|
||||||
|
|||||||
@@ -1540,6 +1540,10 @@ fn apply_filter(mut layout: RemoteLayout, filter: Option<InstallFilter>) -> Remo
|
|||||||
functions_tools: layout.functions_tools.take(),
|
functions_tools: layout.functions_tools.take(),
|
||||||
..base
|
..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 {
|
InstallFilter::McpConfig => RemoteLayout {
|
||||||
mcp_json: layout.mcp_json.take(),
|
mcp_json: layout.mcp_json.take(),
|
||||||
..base
|
..base
|
||||||
@@ -3662,6 +3666,44 @@ mod tests {
|
|||||||
assert!(!paths::installed_bundles_file().exists());
|
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]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn update_silently_refreshes_owned_unmodified_files() {
|
fn update_silently_refreshes_owned_unmodified_files() {
|
||||||
|
|||||||
+17
-2
@@ -419,6 +419,7 @@ pub fn install_builtins() -> Result<()> {
|
|||||||
Agent::install_builtin_agents(false)?;
|
Agent::install_builtin_agents(false)?;
|
||||||
Macro::install_macros(false)?;
|
Macro::install_macros(false)?;
|
||||||
Skill::install_builtin_skills(false)?;
|
Skill::install_builtin_skills(false)?;
|
||||||
|
crate::hooks::install_builtin_hooks(false)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,12 +429,20 @@ pub enum AssetCategory {
|
|||||||
Macros,
|
Macros,
|
||||||
Functions,
|
Functions,
|
||||||
Skills,
|
Skills,
|
||||||
|
Hooks,
|
||||||
#[value(name = "mcp-config", alias = "mcp_config")]
|
#[value(name = "mcp-config", alias = "mcp_config")]
|
||||||
McpConfig,
|
McpConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AssetCategory {
|
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<Self> {
|
pub fn parse(name: &str) -> Option<Self> {
|
||||||
match name {
|
match name {
|
||||||
@@ -441,6 +450,7 @@ impl AssetCategory {
|
|||||||
"macros" => Some(Self::Macros),
|
"macros" => Some(Self::Macros),
|
||||||
"functions" => Some(Self::Functions),
|
"functions" => Some(Self::Functions),
|
||||||
"skills" => Some(Self::Skills),
|
"skills" => Some(Self::Skills),
|
||||||
|
"hooks" => Some(Self::Hooks),
|
||||||
"mcp-config" | "mcp_config" => Some(Self::McpConfig),
|
"mcp-config" | "mcp_config" => Some(Self::McpConfig),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
@@ -460,17 +470,19 @@ pub enum InstallFilter {
|
|||||||
Skills,
|
Skills,
|
||||||
Macros,
|
Macros,
|
||||||
Functions,
|
Functions,
|
||||||
|
Hooks,
|
||||||
#[value(name = "mcp-config", alias = "mcp_config")]
|
#[value(name = "mcp-config", alias = "mcp_config")]
|
||||||
McpConfig,
|
McpConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InstallFilter {
|
impl InstallFilter {
|
||||||
pub const NAMES: [&'static str; 6] = [
|
pub const NAMES: [&'static str; 7] = [
|
||||||
"agents",
|
"agents",
|
||||||
"roles",
|
"roles",
|
||||||
"skills",
|
"skills",
|
||||||
"macros",
|
"macros",
|
||||||
"functions",
|
"functions",
|
||||||
|
"hooks",
|
||||||
"mcp-config",
|
"mcp-config",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -481,6 +493,7 @@ impl InstallFilter {
|
|||||||
"skills" => Some(Self::Skills),
|
"skills" => Some(Self::Skills),
|
||||||
"macros" => Some(Self::Macros),
|
"macros" => Some(Self::Macros),
|
||||||
"functions" => Some(Self::Functions),
|
"functions" => Some(Self::Functions),
|
||||||
|
"hooks" => Some(Self::Hooks),
|
||||||
"mcp-config" | "mcp_config" => Some(Self::McpConfig),
|
"mcp-config" | "mcp_config" => Some(Self::McpConfig),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
@@ -493,6 +506,7 @@ pub fn install_assets(category: AssetCategory) -> Result<()> {
|
|||||||
AssetCategory::Macros => ("macros", paths::macros_dir()),
|
AssetCategory::Macros => ("macros", paths::macros_dir()),
|
||||||
AssetCategory::Functions => ("functions", paths::functions_dir()),
|
AssetCategory::Functions => ("functions", paths::functions_dir()),
|
||||||
AssetCategory::Skills => ("skills", paths::skills_dir()),
|
AssetCategory::Skills => ("skills", paths::skills_dir()),
|
||||||
|
AssetCategory::Hooks => ("hooks", paths::hooks_dir()),
|
||||||
AssetCategory::McpConfig => ("MCP config", paths::mcp_config_file()),
|
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::Macros => Macro::install_macros(true)?,
|
||||||
AssetCategory::Functions => Functions::install_builtin_global_tools(true)?,
|
AssetCategory::Functions => Functions::install_builtin_global_tools(true)?,
|
||||||
AssetCategory::Skills => Skill::install_builtin_skills(true)?,
|
AssetCategory::Skills => Skill::install_builtin_skills(true)?,
|
||||||
|
AssetCategory::Hooks => crate::hooks::install_builtin_hooks(true)?,
|
||||||
AssetCategory::McpConfig => Functions::install_mcp_config()?,
|
AssetCategory::McpConfig => Functions::install_mcp_config()?,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -184,7 +184,6 @@ pub fn role_file(name: &str) -> PathBuf {
|
|||||||
roles_dir().join(format!("{name}.md"))
|
roles_dir().join(format!("{name}.md"))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn hooks_dir() -> PathBuf {
|
pub fn hooks_dir() -> PathBuf {
|
||||||
match env::var(get_env_name("hooks_dir")) {
|
match env::var(get_env_name("hooks_dir")) {
|
||||||
Ok(value) => PathBuf::from(value),
|
Ok(value) => PathBuf::from(value),
|
||||||
@@ -956,6 +955,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
fn hooks_dir_reflects_env_var_state() {
|
fn hooks_dir_reflects_env_var_state() {
|
||||||
let env_name = get_env_name("hooks_dir");
|
let env_name = get_env_name("hooks_dir");
|
||||||
let prev = env::var_os(&env_name);
|
let prev = env::var_os(&env_name);
|
||||||
|
|||||||
+218
-1
@@ -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 chrono::{SecondsFormat, Utc};
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
use rand::distr::{Alphanumeric, SampleString};
|
use rand::distr::{Alphanumeric, SampleString};
|
||||||
|
use rust_embed::Embed;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
@@ -12,6 +15,52 @@ use tokio::io::AsyncWriteExt;
|
|||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
use tokio::sync::Notify;
|
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.
|
/// A single named hook: an external command to run when its event fires.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||||
pub struct HookDef {
|
pub struct HookDef {
|
||||||
@@ -605,6 +654,174 @@ mod tests {
|
|||||||
RequestContext::new(Arc::new(app), WorkingMode::Cmd)
|
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<std::io::Result<u32>> = {
|
||||||
|
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<String> = 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]
|
#[test]
|
||||||
fn hook_event_names_are_dotted() {
|
fn hook_event_names_are_dotted() {
|
||||||
let cases = [
|
let cases = [
|
||||||
|
|||||||
@@ -200,6 +200,19 @@ pub fn discover() -> Result<Vec<DiscoveredMixin>> {
|
|||||||
for path in collect_mixins(&paths::rags_dir(), &[ScanMode::Flat]) {
|
for path in collect_mixins(&paths::rags_dir(), &[ScanMode::Flat]) {
|
||||||
out.push(read_mixin(path)?);
|
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
|
||||||
|
// (`<agents>/<agent>/hooks/` — agent hooks run with cwd = the agent's
|
||||||
|
// data dir) is deliberately not scanned here either; agent-dir scanning
|
||||||
|
// above covers `<agents>/<agent>/*.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()
|
if let Ok(cwd) = env::current_dir()
|
||||||
&& let Some(path) = paths::find_workspace_sbx_mixin(&cwd)
|
&& let Some(path) = paths::find_workspace_sbx_mixin(&cwd)
|
||||||
@@ -940,6 +953,55 @@ network:
|
|||||||
let _ = fs::remove_dir_all(&root);
|
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<PathBuf> = 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
|
/// `generate_rag_sbx_mixin` writes an agent-scoped RAG sidecar next to the
|
||||||
/// rag yaml, at `<agents>/<agent>/<rag>.sbx-mixin.yaml`. Before `SubdirFlat`
|
/// rag yaml, at `<agents>/<agent>/<rag>.sbx-mixin.yaml`. Before `SubdirFlat`
|
||||||
/// existed, nothing scanned that shape and attaching a Qdrant RAG from
|
/// existed, nothing scanned that shape and attaching a Qdrant RAG from
|
||||||
|
|||||||
Reference in new issue
Block a user