diff --git a/.gitattributes b/.gitattributes index e05c06b..a5280f8 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,7 @@ assets/config-template.yaml text eol=lf +assets/hooks/*.sh text eol=lf +assets/agents/** text eol=lf +assets/functions/tools/* text eol=lf +assets/functions/scripts/* text eol=lf +assets/functions/utils/*.sh text eol=lf +scripts/*.sh text eol=lf diff --git a/README.md b/README.md index f3a39e8..33d366d 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ Coming from [AIChat](https://github.com/sigoden/aichat)? Follow the [migration g * [Agents](https://github.com/Dark-Alex-17/coyote/wiki/Agents): Leverage AI agents to perform complex tasks and workflows, including sub-agent spawning, teammate messaging, and user interaction tools. * [Graph Agents](https://github.com/Dark-Alex-17/coyote/wiki/Graph-Agents): Define an agent as a declarative, YAML-driven workflow. A directed graph of typed nodes (LLM calls, scripts, approvals, user input, RAG retrieval, sub-agent spawns). * [Background Jobs](https://github.com/Dark-Alex-17/coyote/wiki/Background-Jobs): Run long tool calls (builds, test suites, slow MCP calls) in the background with the `job__*` tools while the model keeps working, and completion arrives as a push notification. +* [Hooks](https://github.com/Dark-Alex-17/coyote/wiki/Hooks): Run your own fire-and-forget commands when lifecycle events fire. Turns, tools, LLM requests, agents, sub-agent escalations, and more (26 events). Perfect for desktop notifications, activity logging, and downstream automation; hooks never block Coyote or change its behavior. * [Todo System](https://github.com/Dark-Alex-17/coyote/wiki/TODO-System): Built-in task tracking for improved LLM reliability with smaller models. * [Environment Variables](https://github.com/Dark-Alex-17/coyote/wiki/Environment-Variables): Override and customize your Coyote configuration at runtime with environment variables. * [Client Configurations](https://github.com/Dark-Alex-17/coyote/wiki/Clients): Configuration instructions for various LLM providers. diff --git a/assets/agents/adversary/scripts/run_checks.py b/assets/agents/adversary/scripts/run_checks.py index c90d605..cbb9a48 100644 --- a/assets/agents/adversary/scripts/run_checks.py +++ b/assets/agents/adversary/scripts/run_checks.py @@ -31,7 +31,9 @@ in-script degradation would never fire. Each command gets min(PER_COMMAND_TIMEOUT_SECS, remaining budget); commands the deadline leaves no budget for are recorded as {"cmd", "skipped": "deadline"} entries, so the in-script handling stays authoritative. The env var -ADVERSARY_RUN_CHECKS_DEADLINE_SECS overrides the deadline (test seam). +ADVERSARY_RUN_CHECKS_DEADLINE_SECS overrides the deadline (test seam), and +ADVERSARY_STACK_LOCK_PATH overrides the review-stack lock file location +(isolation seam — see acquire_stack_lock). Process hygiene: commands run through the shell. On POSIX each command gets its own session (start_new_session) and a timeout SIGKILLs the whole process @@ -51,14 +53,20 @@ GRAPH_STATE_FILE, so an inherited live state file would silently win. import json import os import signal -import fcntl import subprocess import sys import time +try: + import fcntl +except ImportError: # non-POSIX (Windows): advisory flock unavailable + fcntl = None + PER_COMMAND_TIMEOUT_SECS = 900 TOTAL_DEADLINE_SECS = float(os.environ.get("ADVERSARY_RUN_CHECKS_DEADLINE_SECS") or 3300) -STACK_LOCK_PATH = os.path.expanduser("~/.cache/coyote/review-stack.lock") +STACK_LOCK_PATH = os.environ.get("ADVERSARY_STACK_LOCK_PATH") or os.path.expanduser( + "~/.cache/coyote/review-stack.lock" +) STACK_LOCK_WAIT_SECS = float(os.environ.get("ADVERSARY_STACK_LOCK_WAIT_SECS") or 1200) @@ -67,7 +75,11 @@ def acquire_stack_lock(): well-known path; the probe is instructed to flock the same file around stack boot/teardown). Bounded wait so a hogged lock can never turn into a false red verification: on timeout we proceed WITHOUT the lock and note it - on stderr. The fd is held for the process lifetime (released on exit).""" + on stderr. The fd is held for the process lifetime (released on exit). + On non-POSIX platforms (no fcntl) the lock is skipped entirely.""" + if fcntl is None: + sys.stderr.write("WARN: review-stack lock unsupported on this platform; running unlocked\n") + return None try: os.makedirs(os.path.dirname(STACK_LOCK_PATH), exist_ok=True) fd = os.open(STACK_LOCK_PATH, os.O_CREAT | os.O_RDWR) diff --git a/assets/hooks/log-events.sh b/assets/hooks/log-events.sh index a5563d6..0b402b6 100755 --- a/assets/hooks/log-events.sh +++ b/assets/hooks/log-events.sh @@ -4,7 +4,8 @@ # each one carries. # # Env vars read: -# COYOTE_HOOK_LOG log file to append to (default: /tmp/coyote-hooks.log) +# COYOTE_HOOK_LOG log file to append to +# (default: ${XDG_STATE_HOME:-$HOME/.local/state}/coyote/hooks.log) # COYOTE_* everything coyote sets for the event is dumped verbatim # # Secrets (COYOTE_SECRET_*) are excluded from the snapshot; widening the grep @@ -26,15 +27,15 @@ set -u -log_file="${COYOTE_HOOK_LOG:-/tmp/coyote-hooks.log}" +log_file="${COYOTE_HOOK_LOG:-${XDG_STATE_HOME:-$HOME/.local/state}/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. +# COYOTE_HOOK_LOG may point into a shared directory: refuse to append through +# a symlink someone else planted there. [ -L "$log_file" ] && exit 0 umask 077 +mkdir -p "$(dirname "$log_file")" 2> /dev/null || true { - echo "=== $(date '+%Y-%m-%dT%H:%M:%S%z') ${COYOTE_EVENT:-unknown}" + echo "=== $(date -u '+%Y-%m-%dT%H:%M:%SZ') ${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 index 1c010a3..1a7e5ef 100755 --- a/assets/hooks/notify.sh +++ b/assets/hooks/notify.sh @@ -38,5 +38,8 @@ elif command -v osascript > /dev/null 2>&1; then 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" + # $body carries model-influenced values: strip control bytes (including + # ESC, so ANSI/OSC sequences cannot drive the terminal) before echoing. + safe_line="$(printf '%s' "[$title] $body" | tr -d '\000-\010\013\014\016-\037\177')" + { echo "$safe_line" > /dev/tty; } 2> /dev/null || echo "$safe_line" fi diff --git a/scripts/usage-probe-hooks.sh b/scripts/usage-probe-hooks.sh deleted file mode 100755 index 7096535..0000000 --- a/scripts/usage-probe-hooks.sh +++ /dev/null @@ -1,232 +0,0 @@ -#!/usr/bin/env bash -# Black-box usage-pattern probe for the coyote hook system. -# -# Verifies, against a REAL built `coyote` binary in a clean/isolated -# COYOTE_CONFIG_DIR (dry_run:true, no network/LLM credentials required): -# A) exactly one turn.started + one turn.completed + one llm.request.completed -# fire per headless run, with correctly scoped env vars (no cross-event -# extras leakage). -# B) a failing/missing-script hook never changes coyote's stdout, stderr, or -# exit code. -# C) `coyote --install-builtins hooks` installs both bundled example scripts -# 0755 into /hooks via the real CLI, and a second invocation -# (force reinstall) overwrites local edits back to the packaged content. -# D) `coyote --agent --info` (an inspection flag) fires ZERO -# agent.* hook events. -# -# Not covered here (see src/function/mod.rs tests for tool.* + payload-file -# coverage instead): a live tool.started firing requires a real LLM round -# trip that actually decides to call a tool; dry_run's echo_messages() path -# never invokes tools, so it cannot be exercised via this recipe. -# -# Usage: scripts/usage-probe-hooks.sh -# Requires: a `coyote` binary on PATH or at ./target/debug/coyote or -# ./target/release/coyote (build with `cargo build --bin coyote` first). - -set -euo pipefail - -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -BIN="${COYOTE_BIN:-}" -if [ -z "$BIN" ]; then - if [ -x "$REPO_ROOT/target/debug/coyote" ]; then - BIN="$REPO_ROOT/target/debug/coyote" - elif [ -x "$REPO_ROOT/target/release/coyote" ]; then - BIN="$REPO_ROOT/target/release/coyote" - else - echo "No coyote binary found; run 'cargo build --bin coyote' first." >&2 - exit 2 - fi -fi - -WORKDIR="$(mktemp -d)" -trap 'rm -rf "$WORKDIR"' EXIT - -fail() { - echo "FAIL: $1" >&2 - exit 1 -} - -write_dryrun_config() { - # $1 = config dir, $2..$n = extra YAML fragment lines appended verbatim - local dir="$1" - shift - cat > "$dir/config.yaml" <<'BASE' -model: dryrun:dry-model -dry_run: true -clients: - - type: openai - name: dryrun - auth: none - api_key: 'unused' - models: - - name: dry-model - max_input_tokens: 100000 - supports_function_calling: true -save: false -memory: false -stream: false -BASE - if [ "$#" -gt 0 ]; then - printf '%s\n' "$@" >> "$dir/config.yaml" - fi -} - -echo "== Scenario A: turn/llm hook cadence and env scoping ==" -CFG_A="$WORKDIR/a" -mkdir -p "$CFG_A" -TS_LOG="$WORKDIR/turn-started.log" -TC_LOG="$WORKDIR/turn-completed.log" -LC_LOG="$WORKDIR/llm-completed.log" -write_dryrun_config "$CFG_A" "hooks:" \ - " turn.started:" \ - " - name: probe" \ - " command: \"{ echo \\\"EVENT=\$COYOTE_EVENT NAME=\$COYOTE_HOOK_NAME\\\"; } >> $TS_LOG\"" \ - " turn.completed:" \ - " - name: probe" \ - " command: \"{ echo \\\"EVENT=\$COYOTE_EVENT NAME=\$COYOTE_HOOK_NAME\\\"; } >> $TC_LOG\"" \ - " llm.request.completed:" \ - " - name: probe-llm" \ - " command: \"{ echo \\\"EVENT=\$COYOTE_EVENT PROVIDER=\$COYOTE_LLM_PROVIDER MODEL=\$COYOTE_LLM_MODEL\\\"; } >> $LC_LOG\"" - -COYOTE_CONFIG_DIR="$CFG_A" "$BIN" --no-stream "say exactly: hi" > /dev/null -sleep 1 - -[ "$(grep -c '^EVENT=turn.started' "$TS_LOG" 2>/dev/null || echo 0)" = "1" ] \ - || fail "expected exactly one turn.started, got: $(cat "$TS_LOG" 2>/dev/null)" -[ "$(grep -c '^EVENT=turn.completed' "$TC_LOG" 2>/dev/null || echo 0)" = "1" ] \ - || fail "expected exactly one turn.completed, got: $(cat "$TC_LOG" 2>/dev/null)" -[ "$(grep -c '^EVENT=llm.request.completed' "$LC_LOG" 2>/dev/null || echo 0)" = "1" ] \ - || fail "expected exactly one llm.request.completed, got: $(cat "$LC_LOG" 2>/dev/null)" -grep -q 'PROVIDER=dryrun MODEL=dryrun:dry-model' "$LC_LOG" \ - || fail "llm.request.completed missing expected COYOTE_LLM_PROVIDER/MODEL: $(cat "$LC_LOG")" -echo "PASS: exactly 1 turn.started + 1 turn.completed + 1 llm.request.completed, correctly scoped" - -echo "== Scenario B: failing/missing-script hooks never affect output or exit code ==" -CFG_B="$WORKDIR/b" -mkdir -p "$CFG_B" -write_dryrun_config "$CFG_B" "hooks:" \ - " turn.completed:" \ - " - name: failing" \ - " command: /nonexistent/script/does/not/exist" \ - " - name: exit1" \ - " command: \"exit 1\"" -CFG_BASE="$WORKDIR/base" -mkdir -p "$CFG_BASE" -write_dryrun_config "$CFG_BASE" - -exit_b=0 -COYOTE_CONFIG_DIR="$CFG_B" "$BIN" --no-stream "say exactly: hi" > "$WORKDIR/out_b.txt" 2> "$WORKDIR/err_b.txt" || exit_b=$? -exit_base=0 -COYOTE_CONFIG_DIR="$CFG_BASE" "$BIN" --no-stream "say exactly: hi" > "$WORKDIR/out_base.txt" 2> "$WORKDIR/err_base.txt" || exit_base=$? - -[ "$exit_b" = "$exit_base" ] || fail "exit code differs: with-hooks=$exit_b baseline=$exit_base" -diff -q "$WORKDIR/out_b.txt" "$WORKDIR/out_base.txt" > /dev/null || fail "stdout differs when a hook fails/is missing" -diff -q "$WORKDIR/err_b.txt" "$WORKDIR/err_base.txt" > /dev/null || fail "stderr differs when a hook fails/is missing" -echo "PASS: failing/missing-script hooks are invisible to stdout/stderr/exit code" - -echo "== Scenario C: coyote --install-builtins hooks (real CLI, clean config dir) ==" -CFG_C="$WORKDIR/c" -mkdir -p "$CFG_C" - -out_install1="$WORKDIR/install1.out" -COYOTE_CONFIG_DIR="$CFG_C" "$BIN" --install-builtins hooks > "$out_install1" 2>&1 - -[ -f "$CFG_C/hooks/notify.sh" ] || fail "notify.sh was not installed: $(cat "$out_install1")" -[ -f "$CFG_C/hooks/log-events.sh" ] || fail "log-events.sh was not installed: $(cat "$out_install1")" -grep -q "Reinstalled bundled hooks" "$out_install1" \ - || fail "expected confirmation message, got: $(cat "$out_install1")" - -if command -v stat > /dev/null 2>&1; then - mode_notify="$(stat -c '%a' "$CFG_C/hooks/notify.sh" 2>/dev/null || stat -f '%Lp' "$CFG_C/hooks/notify.sh")" - mode_log="$(stat -c '%a' "$CFG_C/hooks/log-events.sh" 2>/dev/null || stat -f '%Lp' "$CFG_C/hooks/log-events.sh")" - [ "$mode_notify" = "755" ] || fail "notify.sh mode is $mode_notify, expected 755" - [ "$mode_log" = "755" ] || fail "log-events.sh mode is $mode_log, expected 755" -fi - -# Modify a script locally, then re-run --install-builtins hooks: it always -# forces (no confirmation prompt on non-tty stdout), so the local edit must -# be overwritten back to the packaged content. -echo "# LOCALLY MODIFIED" > "$CFG_C/hooks/notify.sh" -COYOTE_CONFIG_DIR="$CFG_C" "$BIN" --install-builtins hooks > "$WORKDIR/install2.out" 2>&1 -grep -q "LOCALLY MODIFIED" "$CFG_C/hooks/notify.sh" \ - && fail "force reinstall did not overwrite the locally modified notify.sh" -grep -q '^#!/usr/bin/env bash' "$CFG_C/hooks/notify.sh" \ - || fail "notify.sh was not restored to the packaged script" -echo "PASS: --install-builtins hooks installs both scripts 0755 and force-overwrites local edits" - -echo "== Scenario C2: cold-start auto-bootstrap fills the hooks dir but never forces ==" -CFG_C2="$WORKDIR/c2" -mkdir -p "$CFG_C2/hooks" -write_dryrun_config "$CFG_C2" - -# Every coyote invocation calls install_builtins() (force=false) before -# dispatching any flag, so a plain run on a config dir with no hooks/ yet -# must auto-populate it... -rmdir "$CFG_C2/hooks" 2>/dev/null || true -COYOTE_CONFIG_DIR="$CFG_C2" "$BIN" --no-stream "say exactly: hi" > /dev/null -[ -f "$CFG_C2/hooks/notify.sh" ] || fail "cold-start run did not auto-install notify.sh" - -# ...but once a file exists, the non-force path must NEVER touch it again. -echo "# LOCALLY MODIFIED" > "$CFG_C2/hooks/notify.sh" -COYOTE_CONFIG_DIR="$CFG_C2" "$BIN" --no-stream "say exactly: hi" > /dev/null -grep -q "LOCALLY MODIFIED" "$CFG_C2/hooks/notify.sh" \ - || fail "a plain (non --install-builtins) invocation overwrote a locally modified hook script" -echo "PASS: plain invocations auto-bootstrap missing hooks but never force-overwrite existing ones" - -echo "== Scenario C3: installed log-events.sh fires end-to-end via the documented relative path ==" -# Exercises the exact wiring promised in the script headers: -# command: ./hooks/log-events.sh (resolves against the config dir) -# and the script's own contract: COYOTE_HOOK_LOG override honored, log file -# created 0600, COYOTE_SECRET_* values excluded from the snapshot. -CFG_C3="$WORKDIR/c3" -mkdir -p "$CFG_C3" -HOOK_LOG="$WORKDIR/c3-hook.log" -write_dryrun_config "$CFG_C3" "hooks:" \ - " turn.completed:" \ - " - name: log-events" \ - " command: ./hooks/log-events.sh" -COYOTE_CONFIG_DIR="$CFG_C3" "$BIN" --install-builtins hooks > /dev/null 2>&1 -COYOTE_CONFIG_DIR="$CFG_C3" COYOTE_HOOK_LOG="$HOOK_LOG" COYOTE_SECRET_PROBE=hunter2 \ - "$BIN" --no-stream "say exactly: hi" > /dev/null -sleep 1 - -[ -f "$HOOK_LOG" ] || fail "installed log-events.sh did not write to COYOTE_HOOK_LOG via relative-path wiring" -[ "$(grep -c '^=== ' "$HOOK_LOG")" = "1" ] \ - || fail "expected exactly one event block in the hook log, got: $(cat "$HOOK_LOG")" -grep -q 'COYOTE_EVENT=turn.completed' "$HOOK_LOG" \ - || fail "hook log missing COYOTE_EVENT=turn.completed: $(cat "$HOOK_LOG")" -grep -q 'hunter2' "$HOOK_LOG" \ - && fail "COYOTE_SECRET_* value leaked into the hook log" -grep -q 'COYOTE_SECRET_' "$HOOK_LOG" \ - && fail "COYOTE_SECRET_ variable name leaked into the hook log" -if command -v stat > /dev/null 2>&1; then - mode_hook_log="$(stat -c '%a' "$HOOK_LOG" 2>/dev/null || stat -f '%Lp' "$HOOK_LOG")" - [ "$mode_hook_log" = "600" ] || fail "hook log mode is $mode_hook_log, expected 600" -fi -echo "PASS: relative-path wiring fires the installed log-events.sh; log is 0600 with secrets excluded" - -echo "== Scenario D: --agent --info fires zero agent.* events ==" -CFG_D="$WORKDIR/d" -mkdir -p "$CFG_D/agents" -AGENT_LOG="$WORKDIR/agent-events.log" -write_dryrun_config "$CFG_D" "hooks:" \ - " agent.started:" \ - " - name: probe-agent" \ - " command: \"echo AGENT_STARTED >> $AGENT_LOG\"" \ - " agent.completed:" \ - " - name: probe-agent" \ - " command: \"echo AGENT_DONE >> $AGENT_LOG\"" \ - " agent.failed:" \ - " - name: probe-agent" \ - " command: \"echo AGENT_FAILED >> $AGENT_LOG\"" - -AGENT_NAME="$(ls "$REPO_ROOT/assets/agents" | head -1)" -cp -r "$REPO_ROOT/assets/agents/$AGENT_NAME" "$CFG_D/agents/$AGENT_NAME" - -COYOTE_CONFIG_DIR="$CFG_D" "$BIN" --agent "$AGENT_NAME" --info > /dev/null 2>&1 -sleep 1 -[ -s "$AGENT_LOG" ] && fail "expected zero agent.* events from --agent --info, got: $(cat "$AGENT_LOG")" -echo "PASS: --agent $AGENT_NAME --info fired zero agent.* hook events" - -echo -echo "ALL SCENARIOS PASSED" diff --git a/src/cli/completer.rs b/src/cli/completer.rs index b64667c..68b4735 100644 --- a/src/cli/completer.rs +++ b/src/cli/completer.rs @@ -51,12 +51,12 @@ pub(super) fn model_completer(current: &OsStr) -> Vec { fn load_app_config_for_completion() -> anyhow::Result { let h = tokio::runtime::Handle::try_current().ok(); let cfg = match h { - Some(handle) => { - tokio::task::block_in_place(|| handle.block_on(Config::load_with_interpolation(true)))? - } + Some(handle) => tokio::task::block_in_place(|| { + handle.block_on(Config::load_with_interpolation(true, true)) + })?, None => { let rt = tokio::runtime::Runtime::new()?; - rt.block_on(Config::load_with_interpolation(true))? + rt.block_on(Config::load_with_interpolation(true, true))? } }; AppConfig::from_config(cfg) diff --git a/src/client/common.rs b/src/client/common.rs index 9c1146e..3ab7585 100644 --- a/src/client/common.rs +++ b/src/client/common.rs @@ -613,9 +613,9 @@ fn llm_failed_extras( } extras.push(( "COYOTE_LLM_ERROR", - // `{:#}` renders the whole context chain, so the root cause survives - // the `context(...)` wrapping the client trait defaults apply. - err.map(|err| format!("{err:#}")) + // The immediate error only, matching COYOTE_TOOL_ERROR; the context + // chain below it stays out of the env. + err.map(|err| err.to_string()) .unwrap_or_else(|| "Aborted.".to_string()), )); @@ -1896,12 +1896,10 @@ mod tests { failed.envs.get("COYOTE_LLM_STATUS").map(String::as_str), Some("429") ); - assert!( - failed - .envs - .get("COYOTE_LLM_ERROR") - .unwrap() - .contains("error (status: 429)") + assert_eq!( + failed.envs.get("COYOTE_LLM_ERROR").map(String::as_str), + Some("Failed to call chat-completions api"), + "the env must carry the immediate error only, not the context chain" ); assert!(!failed.envs.contains_key("COYOTE_LLM_DURATION_MS")); } diff --git a/src/config/agent.rs b/src/config/agent.rs index c7ba01c..24b7417 100644 --- a/src/config/agent.rs +++ b/src/config/agent.rs @@ -1,6 +1,6 @@ use super::*; -use crate::hooks::HooksMap; +use crate::hooks::{HooksMap, RagSyncHooks}; use crate::{ client::Model, config::memory, @@ -14,11 +14,13 @@ use crate::{ use super::rag_cache::RagKey; use crate::config::builtin_manifest; +use crate::config::conflict::{self, InstallMode, StickyMode}; use crate::config::paths; use crate::config::prompts::{ DEFAULT_JOB_INSTRUCTIONS, DEFAULT_SPAWN_INSTRUCTIONS, DEFAULT_TEAMMATE_INSTRUCTIONS, DEFAULT_TODO_INSTRUCTIONS, DEFAULT_USER_INTERACTION_INSTRUCTIONS, }; +use crate::function::write_file_atomic; use crate::graph::types::RagNode; use crate::graph::{Graph, GraphParser, NodeType}; use crate::mcp::McpServerFeatures; @@ -29,8 +31,8 @@ use fancy_regex::Captures; use inquire::{Text, validator::Validation}; use rust_embed::Embed; use serde::{Deserialize, Serialize}; -use std::collections::BTreeSet; -use std::{env, ffi::OsStr, path::Path}; +use std::collections::{BTreeMap, BTreeSet}; +use std::{env, path::Path}; const DEFAULT_AGENT_NAME: &str = "rag"; @@ -63,6 +65,101 @@ fn direct_hook_name(rest: &str) -> Option<&str> { (!name.is_empty() && !name.contains('/')).then_some(name) } +/// Installs one bundled agent's hook scripts under `/hooks/` and +/// reconciles that directory through its builtin manifest, mirroring the +/// role-side hook installer. +pub(crate) fn install_and_reconcile_agent_hooks( + agent: &str, + shipped: &[(String, String)], + mode: InstallMode, + sticky: &mut StickyMode, +) -> Result<()> { + let dir = paths::agents_data_dir().join(agent).join("hooks"); + let mut written = BTreeSet::new(); + for (name, content) in shipped { + let path = dir.join(name); + if path.exists() + && !conflict::should_replace_existing(&path, content, "agents", mode, sticky)? + { + debug!( + "Agent hook file already exists, skipping: {}", + path.display() + ); + continue; + } + ensure_parent_exists(&path)?; + info!("Creating agent hook file: {}", path.display()); + write_file_atomic(&path, content, None)?; + set_executable_bit_if_script(&path)?; + written.insert(name.clone()); + } + + let names: BTreeSet = shipped.iter().map(|(name, _)| name.clone()).collect(); + // Reconciliation is best-effort housekeeping: a failure here must not + // abort the install, matching the role-side policy. + if let Err(err) = builtin_manifest::reconcile_builtin_dir(&dir, &names, &written) { + warn!("Failed to reconcile builtin hooks for agent '{agent}': {err}"); + } + Ok(()) +} + +/// An agent the embed stopped bundling is never visited by the per-agent +/// reconcile loop, so its installed hooks and manifest would orphan forever. +/// Any agent directory that carries a builtin hooks manifest but is absent +/// from the current embed gets its manifest-owned hooks removed, each +/// deletion logged individually by the reconcile. Only the installer writes +/// manifests, but a manual copy of a builtin agent directory carries the +/// hidden manifest along: files it lists are treated as installer-owned and +/// swept, while files absent from the manifest always survive. +/// Best-effort: a failure here must not abort the install. +fn sweep_removed_agent_hooks(bundled_files: &HashMap>) { + let entries = match read_dir(paths::agents_data_dir()) { + Ok(entries) => entries, + Err(err) => { + warn!("Failed to scan agents dir for removed-agent hooks: {err}"); + return; + } + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + if bundled_files.contains_key(name) { + continue; + } + // A symlinked agent dir is user-arranged: never follow it into a + // hooks tree that lives somewhere else. + if !entry + .path() + .symlink_metadata() + .is_ok_and(|metadata| metadata.is_dir()) + { + continue; + } + let hooks_dir = entry.path().join("hooks"); + if !hooks_dir + .join(builtin_manifest::BUILTIN_MANIFEST_FILE) + .is_file() + { + continue; + } + info!( + "Removing hooks shipped by removed built-in agent '{name}' in {}", + hooks_dir.display() + ); + if let Err(err) = + builtin_manifest::reconcile_builtin_dir(&hooks_dir, &BTreeSet::new(), &BTreeSet::new()) + { + warn!("Failed to reconcile builtin hooks for removed agent '{name}': {err}"); + continue; + } + // remove_dir only succeeds on an empty directory, so a user file + // left behind keeps the directory in place. + let _ = fs::remove_dir(&hooks_dir); + } +} + #[derive(Debug, Clone)] pub struct Agent { name: String, @@ -81,28 +178,32 @@ pub struct Agent { } impl Agent { - pub fn install_builtin_agents(force: bool) -> Result<()> { + pub fn install_builtin_agents(mode: InstallMode) -> Result<()> { info!( "Installing built-in agents in {}", paths::agents_data_dir().display() ); + let mut sticky = StickyMode::None; let mut written_hooks: HashMap> = HashMap::new(); for file in AgentAssets::iter() { debug!("Processing agent file: {}", file.as_ref()); let embedded_file = AgentAssets::get(&file) .ok_or_else(|| anyhow!("Failed to load embedded agent file: {}", file.as_ref()))?; - let content = unsafe { std::str::from_utf8_unchecked(&embedded_file.data) }; + let content = std::str::from_utf8(&embedded_file.data) + .expect("bundled agent asset is not valid UTF-8"); let file_path = paths::agents_data_dir().join(file.as_ref()); - let file_extension = file_path - .extension() - .and_then(OsStr::to_str) - .map(|s| s.to_lowercase()); - #[cfg_attr(not(unix), expect(unused))] - let is_script = matches!(file_extension.as_deref(), Some("sh") | Some("py")); - if file_path.exists() && !force { + if file_path.exists() + && !conflict::should_replace_existing( + &file_path, + content, + "agents", + mode, + &mut sticky, + )? + { debug!( "Agent file already exists, skipping: {}", file_path.display() @@ -114,18 +215,13 @@ impl Agent { info!("Creating agent file: {}", file_path.display()); let mut agent_file = File::create(&file_path)?; agent_file.write_all(content.as_bytes())?; + set_executable_bit_if_script(&file_path)?; if let Some((agent, hook)) = parse_direct_hook_path(file.as_ref()) { written_hooks .entry(agent.to_string()) .or_default() .insert(hook.to_string()); } - - #[cfg(unix)] - if is_script { - use std::{fs, os::unix::fs::PermissionsExt}; - fs::set_permissions(&file_path, fs::Permissions::from_mode(0o755))?; - } } let mut bundled_files: HashMap> = HashMap::new(); @@ -179,6 +275,32 @@ impl Agent { } } + sweep_removed_agent_hooks(&bundled_files); + + Ok(()) + } + + pub fn install_builtin_agent_hooks(mode: InstallMode, sticky: &mut StickyMode) -> Result<()> { + let mut shipped: BTreeMap> = BTreeMap::new(); + for file in AgentAssets::iter() { + let Some((agent, rest)) = file.as_ref().split_once('/') else { + continue; + }; + let hooks = shipped.entry(agent.to_string()).or_default(); + let Some(hook) = direct_hook_name(rest) else { + continue; + }; + let embedded = AgentAssets::get(&file) + .ok_or_else(|| anyhow!("Failed to load embedded agent file: {}", file.as_ref()))?; + let content = std::str::from_utf8(&embedded.data) + .expect("bundled agent hook asset is not valid UTF-8"); + hooks.push((hook.to_string(), content.to_string())); + } + + for (agent, files) in &shipped { + install_and_reconcile_agent_hooks(agent, files, mode, sticky)?; + } + Ok(()) } @@ -234,6 +356,13 @@ impl Agent { } }; + let rag_sync_hooks = RagSyncHooks::resolve_for_agent( + &app.hooks, + &agent_config.global_hooks, + &agent_config.hooks, + name, + ); + let rag = if rag_path.exists() { let key = RagKey::Agent(name.to_string()); let app_clone = app.clone(); @@ -266,6 +395,7 @@ impl Agent { let app_clone = app.clone(); let rag_path_clone = rag_path.clone(); let abort = abort_signal.clone(); + let sync_hooks = rag_sync_hooks.clone(); let rag = app_state .rag_cache .load_with(key, || async move { @@ -276,6 +406,7 @@ impl Agent { &document_paths, abort, true, + sync_hooks, ) .await }) @@ -299,6 +430,7 @@ impl Agent { &loaders, info_flag, abort_signal.clone(), + &rag_sync_hooks, ) .await? } @@ -1019,10 +1151,13 @@ impl AgentConfig { if let Some(v) = read_env_value::(&with_prefix("reasoning_effort")) { self.reasoning_effort = v; } - if let Ok(v) = env::var(with_prefix("global_tools")) - && let Ok(v) = serde_json::from_str(&v) - { - self.global_tools = v; + if let Ok(v) = env::var(with_prefix("global_tools")) { + match serde_json::from_str(&v) { + Ok(v) => self.global_tools = v, + Err(err) => { + debug!("Ignoring malformed global_tools env override for agent '{name}': {err}") + } + } } if let Ok(v) = env::var(with_prefix("global_hooks")) { match serde_json::from_str(&v) { @@ -1178,6 +1313,7 @@ async fn init_graph_rags( loaders: &HashMap, info_flag: bool, abort_signal: AbortSignal, + sync_hooks: &RagSyncHooks, ) -> Result>> { let mut rags = HashMap::new(); if info_flag { @@ -1254,6 +1390,7 @@ async fn init_graph_rags( let path_clone = rag_path.clone(); let name_clone = node_id.clone(); let abort = abort_signal.clone(); + let sync_hooks = sync_hooks.clone(); app_state .rag_cache .load_with(key, || async move { @@ -1264,6 +1401,7 @@ async fn init_graph_rags( &document_paths, &config, abort, + sync_hooks, ) .await }) @@ -1361,6 +1499,7 @@ pub fn complete_agent_variables(agent_name: &str) -> Vec<(String, Option #[cfg(test)] mod tests { use super::*; + use crate::testing; #[test] fn parse_direct_hook_path_accepts_only_direct_hook_children() { @@ -1472,6 +1611,33 @@ global_hooks: } } + #[test] + #[serial_test::serial] + fn load_envs_ignores_malformed_global_tools_and_logs_debug() { + testing::install_log_collector(); + let name = "gt-malformed-envtest"; + let yaml = format!("name: {name}\ninstructions: hi\nglobal_tools:\n - keep_tool.sh\n"); + let mut config: AgentConfig = serde_yaml::from_str(&yaml).unwrap(); + let env_name = normalize_env_name(&format!("{name}_global_tools")); + let _guard = testing::EnvVarGuard::set(&env_name, "{not-json"); + + config.load_envs(&AppConfig::default()); + + assert_eq!( + config.global_tools, + vec!["keep_tool.sh"], + "a malformed override must leave the existing value untouched" + ); + let debugs = testing::debug_snapshot(); + assert!( + debugs.iter().any(|message| { + message.contains("Ignoring malformed global_tools env override") + && message.contains(name) + }), + "expected a debug log for the malformed override: {debugs:?}" + ); + } + #[test] fn from_graph_carries_hooks_and_global_hooks() { let yaml = "\ @@ -1896,4 +2062,164 @@ nodes: {} ); assert_eq!(output, expected); } + + use crate::testing::TestConfigDirGuard; + + fn fixture(entries: &[(&str, &str)]) -> Vec<(String, String)> { + entries + .iter() + .map(|(name, content)| (name.to_string(), content.to_string())) + .collect() + } + + #[test] + #[serial_test::serial] + fn agent_hooks_prompt_mode_asks_per_conflict_and_honors_sticky() { + use crate::config::conflict::prompt_script; + + let _guard = TestConfigDirGuard::new("agent-hooks-prompt"); + let shipped = fixture(&[("a.sh", "new content\n"), ("b.sh", "new content\n")]); + let dir = paths::agents_data_dir().join("probe").join("hooks"); + create_dir_all(&dir).unwrap(); + fs::write(dir.join("a.sh"), "local a").unwrap(); + fs::write(dir.join("b.sh"), "local b").unwrap(); + + // First conflict answered per-file, second by the sticky replace-all. + let script = prompt_script::install(&["replace-all"]); + let mut sticky = StickyMode::None; + install_and_reconcile_agent_hooks("probe", &shipped, InstallMode::Prompt, &mut sticky) + .unwrap(); + assert_eq!(prompt_script::prompts_asked(), 1); + assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "new content\n"); + assert_eq!(read_to_string(dir.join("b.sh")).unwrap(), "new content\n"); + assert_eq!(sticky, StickyMode::ReplaceAll); + assert_eq!( + read_to_string(dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE)).unwrap(), + "a.sh\nb.sh\n" + ); + drop(script); + + // A sticky mode carried in from an earlier location suppresses all + // prompting: `--install-builtins hooks` shares one sticky scope + // across the global, role, and agent hook locations. + fs::write(dir.join("a.sh"), "local again").unwrap(); + let script = prompt_script::install(&[]); + let mut sticky = StickyMode::KeepAll; + install_and_reconcile_agent_hooks("probe", &shipped, InstallMode::Prompt, &mut sticky) + .unwrap(); + assert_eq!(prompt_script::prompts_asked(), 0); + assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "local again"); + drop(script); + } + + #[test] + #[serial_test::serial] + fn agent_hooks_reconcile_adds_updates_and_drops_within_a_shipped_agent() { + let _guard = TestConfigDirGuard::new("agent-hooks-reconcile"); + let dir = paths::agents_data_dir().join("probe").join("hooks"); + + install_and_reconcile_agent_hooks( + "probe", + &fixture(&[("keep.sh", "v1\n"), ("drop.sh", "#!/bin/sh\n")]), + InstallMode::Skip, + &mut StickyMode::None, + ) + .unwrap(); + fs::write(dir.join("user.sh"), "user-owned").unwrap(); + + install_and_reconcile_agent_hooks( + "probe", + &fixture(&[("keep.sh", "v2\n"), ("added.sh", "#!/bin/sh\n")]), + InstallMode::Force, + &mut StickyMode::None, + ) + .unwrap(); + + assert_eq!( + read_to_string(dir.join("keep.sh")).unwrap(), + "v2\n", + "a still-shipped hook updates under Force" + ); + assert_eq!( + read_to_string(dir.join("added.sh")).unwrap(), + "#!/bin/sh\n", + "a newly shipped hook is added" + ); + assert!( + !dir.join("drop.sh").exists(), + "a dropped hook reconciles away" + ); + assert_eq!( + read_to_string(dir.join("user.sh")).unwrap(), + "user-owned", + "user files in the same dir survive" + ); + assert_eq!( + read_to_string(dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE)).unwrap(), + "added.sh\nkeep.sh\n" + ); + } + + #[test] + #[serial_test::serial] + fn install_builtin_agent_hooks_reconciles_hookless_bundled_agents() { + let _guard = TestConfigDirGuard::new("agent-hooks-hookless"); + let agent = AgentAssets::iter() + .filter_map(|file| { + file.as_ref() + .split_once('/') + .map(|(agent, _)| agent.to_string()) + }) + .find(|agent| { + !AgentAssets::iter().any(|file| { + parse_direct_hook_path(file.as_ref()) + .is_some_and(|(hook_agent, _)| hook_agent == agent) + }) + }) + .expect("at least one bundled agent ships no hooks"); + let dir = paths::agents_data_dir().join(&agent).join("hooks"); + // A manifest-owned hook left over from a release that shipped it. + install_and_reconcile_agent_hooks( + &agent, + &fixture(&[("stale.sh", "#!/bin/sh\n")]), + InstallMode::Skip, + &mut StickyMode::None, + ) + .unwrap(); + fs::write(dir.join("user.sh"), "user-owned").unwrap(); + + Agent::install_builtin_agent_hooks(InstallMode::Skip, &mut StickyMode::None).unwrap(); + + assert!( + !dir.join("stale.sh").exists(), + "a still-bundled agent with no hook assets must reconcile its hooks dir" + ); + assert_eq!(read_to_string(dir.join("user.sh")).unwrap(), "user-owned"); + } + + #[cfg(unix)] + #[test] + #[serial_test::serial] + fn sweep_removed_agent_hooks_never_follows_symlinked_agent_dirs() { + let guard = TestConfigDirGuard::new("agent-sweep-symlink"); + let real = guard.path.join("agent-elsewhere"); + let hooks = real.join("hooks"); + create_dir_all(&hooks).unwrap(); + fs::write( + hooks.join(builtin_manifest::BUILTIN_MANIFEST_FILE), + "shipped.sh\n", + ) + .unwrap(); + fs::write(hooks.join("shipped.sh"), "stale").unwrap(); + create_dir_all(paths::agents_data_dir()).unwrap(); + std::os::unix::fs::symlink(&real, paths::agents_data_dir().join("linked")).unwrap(); + + sweep_removed_agent_hooks(&HashMap::new()); + + assert!( + hooks.join("shipped.sh").exists(), + "the sweep must never follow a symlinked agent dir" + ); + assert!(hooks.join(builtin_manifest::BUILTIN_MANIFEST_FILE).exists()); + } } diff --git a/src/config/app_config.rs b/src/config/app_config.rs index bfa2fb9..6fe6ddd 100644 --- a/src/config/app_config.rs +++ b/src/config/app_config.rs @@ -210,6 +210,18 @@ impl Default for AppConfig { impl AppConfig { pub fn from_config(config: super::Config) -> Result { + Self::from_config_impl(config, true) + } + + /// Like [`Self::from_config`], but tolerates a config with no resolvable + /// model: `model_id` stays empty instead of failing with "No available + /// model". For inspection readouts (--info, --list-*, --mcp-list, + /// --list-secrets), which render model fields as absent/default. + pub fn from_config_lenient(config: super::Config) -> Result { + Self::from_config_impl(config, false) + } + + fn from_config_impl(config: super::Config, require_model: bool) -> Result { let mut app_config = Self { model_id: config.model_id, temperature: config.temperature, @@ -306,7 +318,13 @@ impl AppConfig { } app_config.setup_document_loaders(); app_config.setup_user_agent(); - app_config.resolve_model()?; + if require_model { + app_config.resolve_model()?; + } else { + // Model-less is fine here: model_id stays empty and inspection + // callers substitute Model::default(). + let _ = app_config.resolve_model(); + } app_config.validate_reasoning_effort()?; Ok(app_config) } @@ -1269,6 +1287,18 @@ mod tests { assert!(result.is_err()); } + #[test] + fn from_config_errors_when_no_model_available() { + let result = AppConfig::from_config(Config::default()); + assert!(result.is_err()); + } + + #[test] + fn from_config_lenient_tolerates_missing_model() { + let app = AppConfig::from_config_lenient(Config::default()).unwrap(); + assert!(app.model_id.is_empty()); + } + #[test] fn resolve_model_keeps_explicit_model_id() { let mut app = AppConfig { diff --git a/src/config/app_state.rs b/src/config/app_state.rs index 4190b49..fb882bf 100644 --- a/src/config/app_state.rs +++ b/src/config/app_state.rs @@ -43,6 +43,7 @@ impl AppState { log_path: Option, start_mcp_servers: bool, lenient_vault: bool, + inspection: bool, abort_signal: AbortSignal, ) -> Result { let vault = if lenient_vault { @@ -74,7 +75,11 @@ impl AppState { } } - let mut functions = Functions::init(config.visible_tools.as_deref())?; + let mut functions = if inspection { + Functions::default() + } else { + Functions::init(config.visible_tools.as_deref())? + }; if !mcp_registry.is_empty() && config.mcp_server_support { functions.append_mcp_meta_functions(mcp_registry.server_features()); } diff --git a/src/config/builtin_manifest.rs b/src/config/builtin_manifest.rs index 7e3bc31..1b51523 100644 --- a/src/config/builtin_manifest.rs +++ b/src/config/builtin_manifest.rs @@ -23,6 +23,22 @@ pub(crate) fn is_builtin_manifest_name(name: &str) -> bool { .eq_ignore_ascii_case(BUILTIN_MANIFEST_FILE) } +/// NTFS 8.3 short names (`BUILTI~1`, `builti~1.sh`) can alias any long +/// filename on the same volume, including the manifest itself, so an entry +/// shaped like one is never accepted: it could direct a deletion at a file +/// the manifest never named. +fn is_ntfs_short_name_alias(name: &str) -> bool { + let stem = name.split('.').next().unwrap_or(name); + let Some((base, digits)) = stem.rsplit_once('~') else { + return false; + }; + !base.is_empty() + && base.len() <= 8 + && base.chars().all(|c| c.is_ascii_alphanumeric() || c == '~') + && !digits.is_empty() + && digits.chars().all(|c| c.is_ascii_digit()) +} + /// Reconciles `dir` against the currently shipped set of builtin filenames: /// a file is removed ONLY if it appears in the previous manifest AND is /// absent from `shipped`. The manifest is then rewritten to the names this @@ -83,7 +99,7 @@ fn read_manifest(dir: &Path) -> BTreeSet { if line.is_empty() || is_builtin_manifest_name(line) { return false; } - if !is_plain_file_name(line) { + if !is_plain_file_name(line) || is_ntfs_short_name_alias(line) { debug!( "Ignoring suspicious builtin manifest entry in {}: {line:?}", path.display() @@ -290,4 +306,93 @@ mod tests { assert!(!is_builtin_manifest_name(".builtin-manifesto")); assert!(!is_builtin_manifest_name("notify.sh")); } + + #[test] + fn ntfs_short_name_aliases_are_detected() { + assert!(is_ntfs_short_name_alias("BUILTI~1")); + assert!(is_ntfs_short_name_alias("builti~1.sh")); + assert!(is_ntfs_short_name_alias("NOTIFY~12")); + assert!(is_ntfs_short_name_alias("A~1")); + assert!(!is_ntfs_short_name_alias("notify.sh")); + assert!(!is_ntfs_short_name_alias("my~hook.sh")); + assert!(!is_ntfs_short_name_alias("~1")); + assert!(!is_ntfs_short_name_alias("way-too-long~1")); + assert!(!is_ntfs_short_name_alias(".builtin-manifest")); + } + + /// This fixture is unrealizable on NTFS: `.builtin-manifest` gets an + /// auto-generated 8.3 short name of exactly this shape (`BUILTI~1`), so + /// writing `dir/BUILTI~1` opens the manifest through its alias and + /// clobbers it instead of creating a distinct file — the precise + /// collision [`is_ntfs_short_name_alias`] defends against. Only + /// filesystems without 8.3 aliasing can host alias-shaped names as real, + /// distinct files; Windows coverage lives in + /// `ntfs_short_name_entries_never_accepted_on_windows` below. + #[cfg(unix)] + #[test] + fn ntfs_short_name_entries_never_direct_deletions() { + let dir = fresh_dir("builtin-manifest-shortname-"); + fs::write(dir.join(BUILTIN_MANIFEST_FILE), "BUILTI~1\nbuilti~1.sh\n").unwrap(); + fs::write(dir.join("BUILTI~1"), "keep").unwrap(); + fs::write(dir.join("builti~1.sh"), "keep").unwrap(); + + reconcile_builtin_dir(&dir, &BTreeSet::new(), &BTreeSet::new()).unwrap(); + + assert!( + dir.join("BUILTI~1").exists() && dir.join("builti~1.sh").exists(), + "8.3-alias-shaped manifest entries must never be removal candidates" + ); + let _ = fs::remove_dir_all(&dir); + } + + /// Windows counterpart of `ntfs_short_name_entries_never_direct_deletions`: + /// alias-shaped files cannot exist as distinct fixtures here, so the guard + /// invariant is proven through the manifest filter instead. Only entries + /// accepted into the previous-manifest set can become removal candidates + /// (`previous.difference(shipped)`) or be claimed as owned + /// (`shipped ∩ previous`); listing the alias names in `shipped` makes the + /// second path observable in the rewritten manifest — a broken guard + /// would claim them. Where the volume generates 8.3 short names, the + /// self-collision that makes the unix fixture unrealizable is also + /// asserted directly; where generation is disabled, the alias simply + /// never materializes and the invariant assertions stand on their own. + #[cfg(windows)] + #[test] + fn ntfs_short_name_entries_never_accepted_on_windows() { + let dir = fresh_dir("builtin-manifest-shortname-win-"); + fs::write( + dir.join(BUILTIN_MANIFEST_FILE), + "BUILTI~1\nbuilti~1.sh\nkept.sh\n", + ) + .unwrap(); + fs::write(dir.join("kept.sh"), "current").unwrap(); + + let alias = dir.join("BUILTI~1"); + if alias.exists() { + assert_eq!( + fs::read_to_string(&alias).unwrap(), + fs::read_to_string(dir.join(BUILTIN_MANIFEST_FILE)).unwrap(), + "the 8.3 alias opens the manifest itself, not a distinct file" + ); + } + + reconcile_builtin_dir( + &dir, + &shipped(&["BUILTI~1", "builti~1.sh", "kept.sh"]), + &shipped(&[]), + ) + .unwrap(); + + assert!(dir.join("kept.sh").exists(), "real shipped file untouched"); + assert!( + dir.join(BUILTIN_MANIFEST_FILE).exists(), + "manifest survives reconciliation" + ); + assert_eq!( + fs::read_to_string(dir.join(BUILTIN_MANIFEST_FILE)).unwrap(), + "kept.sh\n", + "alias-shaped entries are never accepted: neither removal candidates nor claimed" + ); + let _ = fs::remove_dir_all(&dir); + } } diff --git a/src/config/conflict.rs b/src/config/conflict.rs new file mode 100644 index 0000000..7b3c57d --- /dev/null +++ b/src/config/conflict.rs @@ -0,0 +1,364 @@ +//! Shared keep/replace conflict resolution for installers that must not +//! silently clobber locally modified files. The bundle installer and the +//! builtin hook installers all funnel per-file conflicts through +//! [`resolve_conflict`], so the prompt wording and the sticky +//! keep-all/replace-all semantics are identical everywhere, including when +//! one sticky scope spans several install locations in a single run. + +use crate::utils::IS_STDOUT_TERMINAL; + +use anyhow::{Context, Result, bail}; +use inquire::Select; +use std::fs; +use std::path::Path; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum InstallMode { + Skip, + Force, + Prompt, +} + +/// A `keep-all`/`replace-all` answer outlives the file it was given for: +/// the caller threads one value through every subsequent conflict in the +/// same run. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) enum StickyMode { + None, + KeepAll, + ReplaceAll, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ConflictAction { + Keep, + Replace, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum NonInteractive { + Bail, + Keep, +} + +pub(crate) fn should_replace_existing( + dst: &Path, + content: &str, + category: &str, + mode: InstallMode, + sticky: &mut StickyMode, +) -> Result { + match mode { + InstallMode::Skip => Ok(false), + InstallMode::Force => Ok(true), + InstallMode::Prompt => { + if fs::read(dst).is_ok_and(|local| local == content.as_bytes()) { + return Ok(false); + } + match resolve_conflict(dst, category, sticky, NonInteractive::Keep)? { + ConflictAction::Keep => Ok(false), + ConflictAction::Replace => Ok(true), + } + } + } +} + +pub(crate) fn resolve_conflict( + dst: &Path, + category: &str, + sticky: &mut StickyMode, + non_interactive: NonInteractive, +) -> Result { + match *sticky { + StickyMode::KeepAll => return Ok(ConflictAction::Keep), + StickyMode::ReplaceAll => return Ok(ConflictAction::Replace), + StickyMode::None => {} + } + + if !is_interactive() { + match non_interactive { + NonInteractive::Keep => return Ok(ConflictAction::Keep), + NonInteractive::Bail => bail!( + "Refusing to overwrite local file {} non-interactively. \ + Re-run in a terminal, with --install-force (installs), \ + or with --yes (updates).", + dst.display() + ), + } + } + + let prompt = format!("Conflict at {} (category: {category})", dst.display()); + let choice = select_choice(&prompt)?; + + match choice.as_str() { + "keep" => Ok(ConflictAction::Keep), + "replace" => Ok(ConflictAction::Replace), + "keep-all" => { + *sticky = StickyMode::KeepAll; + Ok(ConflictAction::Keep) + } + "replace-all" => { + *sticky = StickyMode::ReplaceAll; + Ok(ConflictAction::Replace) + } + "abort" => bail!("Install aborted by user at conflict resolution."), + _ => unreachable!("inquire::Select returned an unexpected option"), + } +} + +fn is_interactive() -> bool { + #[cfg(test)] + if let Some(forced) = prompt_script::forced_tty() { + return forced; + } + *IS_STDOUT_TERMINAL +} + +fn select_choice(prompt: &str) -> Result { + #[cfg(test)] + if prompt_script::active() { + return Ok(prompt_script::next_answer(prompt)); + } + Select::new( + prompt, + vec!["keep", "replace", "keep-all", "replace-all", "abort"], + ) + .prompt() + .map(str::to_string) + .with_context(|| "failed to read conflict choice") +} + +#[cfg(test)] +pub(crate) mod prompt_script { + //! Scripted stand-in for the interactive conflict prompt. + //! `inquire::Select` cannot run under the test harness, so while a guard + //! is installed the resolver takes its terminal answer from the guard and + //! serves each prompt from a queue of scripted answers, counting every + //! prompt asked. A forced-terminal guard with an empty queue pins the + //! "never prompts" invariant: any prompt panics and the counter stays 0. + + use std::sync::Mutex; + use std::sync::atomic::{AtomicUsize, Ordering}; + + const INACTIVE: usize = 0; + const TTY: usize = 1; + const NON_TTY: usize = 2; + + static STATE: AtomicUsize = AtomicUsize::new(INACTIVE); + static ASKED: AtomicUsize = AtomicUsize::new(0); + // A test that panics while holding the queue poisons its mutex; every + // lock site recovers via `into_inner` so later tests keep working. + static ANSWERS: Mutex> = Mutex::new(Vec::new()); + + /// Installs the script with a forced terminal until the guard drops. + /// The script is process-global, so tests using it must serialize + /// (`#[serial]`). Answers are consumed front to back; a prompt beyond + /// the scripted answers panics. + #[must_use] + pub fn install(answers: &[&str]) -> ScriptGuard { + install_with_state(TTY, answers) + } + + /// Installs the script with the terminal forced absent, for pinning the + /// non-interactive behavior regardless of where the tests actually run. + #[must_use] + pub fn install_non_interactive() -> ScriptGuard { + install_with_state(NON_TTY, &[]) + } + + fn install_with_state(state: usize, answers: &[&str]) -> ScriptGuard { + *ANSWERS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = + answers.iter().map(|answer| answer.to_string()).collect(); + ASKED.store(0, Ordering::SeqCst); + STATE.store(state, Ordering::SeqCst); + ScriptGuard + } + + pub struct ScriptGuard; + + impl Drop for ScriptGuard { + fn drop(&mut self) { + STATE.store(INACTIVE, Ordering::SeqCst); + ANSWERS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clear(); + } + } + + /// Number of prompts asked since the script was installed. + pub fn prompts_asked() -> usize { + ASKED.load(Ordering::SeqCst) + } + + pub(super) fn active() -> bool { + STATE.load(Ordering::SeqCst) != INACTIVE + } + + pub(super) fn forced_tty() -> Option { + match STATE.load(Ordering::SeqCst) { + TTY => Some(true), + NON_TTY => Some(false), + _ => None, + } + } + + pub(super) fn next_answer(prompt: &str) -> String { + ASKED.fetch_add(1, Ordering::SeqCst); + let mut answers = ANSWERS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + assert!( + !answers.is_empty(), + "conflict prompt asked with no scripted answer left: {prompt}" + ); + answers.remove(0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + use std::path::PathBuf; + + fn dst() -> PathBuf { + PathBuf::from("/tmp/coyote-conflict-tests/example.yaml") + } + + #[test] + fn sticky_modes_short_circuit_without_prompting() { + let mut sticky = StickyMode::KeepAll; + assert!(matches!( + resolve_conflict(&dst(), "macros", &mut sticky, NonInteractive::Bail).unwrap(), + ConflictAction::Keep + )); + + let mut sticky = StickyMode::ReplaceAll; + assert!(matches!( + resolve_conflict(&dst(), "macros", &mut sticky, NonInteractive::Bail).unwrap(), + ConflictAction::Replace + )); + } + + #[test] + #[serial] + fn non_interactive_bail_keeps_the_bundle_error_shape() { + let _script = prompt_script::install_non_interactive(); + let mut sticky = StickyMode::None; + + let err = + resolve_conflict(&dst(), "macros", &mut sticky, NonInteractive::Bail).unwrap_err(); + + assert!( + err.to_string().contains("Refusing to overwrite local file"), + "got: {err}" + ); + assert!(err.to_string().contains("example.yaml"), "got: {err}"); + assert_eq!(prompt_script::prompts_asked(), 0); + } + + #[test] + #[serial] + fn non_interactive_keep_resolves_without_error_or_prompt() { + let _script = prompt_script::install_non_interactive(); + let mut sticky = StickyMode::None; + + let action = resolve_conflict(&dst(), "hooks", &mut sticky, NonInteractive::Keep).unwrap(); + + assert!(matches!(action, ConflictAction::Keep)); + assert_eq!(sticky, StickyMode::None); + assert_eq!(prompt_script::prompts_asked(), 0); + } + + #[test] + #[serial] + fn scripted_answers_drive_actions_and_sticky_transitions() { + let _script = prompt_script::install(&["keep", "replace", "replace-all"]); + let mut sticky = StickyMode::None; + + assert!(matches!( + resolve_conflict(&dst(), "hooks", &mut sticky, NonInteractive::Keep).unwrap(), + ConflictAction::Keep + )); + assert_eq!(sticky, StickyMode::None); + assert!(matches!( + resolve_conflict(&dst(), "hooks", &mut sticky, NonInteractive::Keep).unwrap(), + ConflictAction::Replace + )); + assert_eq!(sticky, StickyMode::None); + assert!(matches!( + resolve_conflict(&dst(), "hooks", &mut sticky, NonInteractive::Keep).unwrap(), + ConflictAction::Replace + )); + assert_eq!(sticky, StickyMode::ReplaceAll); + // The sticky answer now short-circuits: no further prompt is asked. + assert!(matches!( + resolve_conflict(&dst(), "hooks", &mut sticky, NonInteractive::Keep).unwrap(), + ConflictAction::Replace + )); + assert_eq!(prompt_script::prompts_asked(), 3); + } + + #[test] + #[serial] + fn abort_answer_bails_with_the_bundle_abort_message() { + let _script = prompt_script::install(&["abort"]); + let mut sticky = StickyMode::None; + + let err = resolve_conflict(&dst(), "hooks", &mut sticky, NonInteractive::Keep).unwrap_err(); + + assert!( + err.to_string() + .contains("Install aborted by user at conflict resolution."), + "got: {err}" + ); + } + + #[test] + #[serial] + fn should_replace_existing_skips_identical_content_without_prompting() { + let _script = prompt_script::install(&[]); + let dir = crate::utils::temp_file("conflict-identical-", ""); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("hook.sh"); + fs::write(&path, "same content").unwrap(); + + let mut sticky = StickyMode::None; + let replace = should_replace_existing( + &path, + "same content", + "hooks", + InstallMode::Prompt, + &mut sticky, + ) + .unwrap(); + + let _ = fs::remove_dir_all(&dir); + assert!(!replace); + assert_eq!(prompt_script::prompts_asked(), 0); + } + + #[test] + #[serial] + fn should_replace_existing_never_prompts_in_skip_or_force_mode() { + let _script = prompt_script::install(&[]); + let dir = crate::utils::temp_file("conflict-modes-", ""); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("hook.sh"); + fs::write(&path, "local edit").unwrap(); + + let mut sticky = StickyMode::None; + let skip = + should_replace_existing(&path, "shipped", "hooks", InstallMode::Skip, &mut sticky); + let force = + should_replace_existing(&path, "shipped", "hooks", InstallMode::Force, &mut sticky); + + let _ = fs::remove_dir_all(&dir); + assert!(!skip.unwrap()); + assert!(force.unwrap()); + assert_eq!(prompt_script::prompts_asked(), 0); + } +} diff --git a/src/config/install_remote.rs b/src/config/install_remote.rs index 01d9424..98a547c 100644 --- a/src/config/install_remote.rs +++ b/src/config/install_remote.rs @@ -3,9 +3,10 @@ use super::bundles::{ hash_file, }; use crate::config::builtin_manifest::is_builtin_manifest_name; -use crate::config::{AssetCategory, BUNDLE_MANIFEST_FILE, InstallFilter, paths}; -#[cfg(not(windows))] -use crate::function::Language; +use crate::config::conflict::{ConflictAction, NonInteractive, StickyMode, resolve_conflict}; +use crate::config::{ + AssetCategory, BUNDLE_MANIFEST_FILE, InstallFilter, paths, set_executable_bit_if_script, +}; use crate::mcp::{McpServer, McpServersConfig}; use crate::utils; use crate::utils::IS_STDOUT_TERMINAL; @@ -1857,18 +1858,12 @@ fn print_plan_summary(plan: &InstallPlan) { } /// Bundle-shipped hook scripts execute on the user's machine once wired into -/// config, so every planned file landing under a hooks/ directory is called -/// out individually. Visibility only: installing them needs no extra approval. +/// config, so every planned file landing in a hook location is called out +/// individually. Visibility only: installing them needs no extra approval. fn hook_script_lines(plan: &InstallPlan) -> Vec { plan.files .iter() - .filter(|planned| { - planned.top_category == TopCategory::Hooks - || planned - .rel - .parent() - .is_some_and(|parent| parent.components().any(|c| c.as_os_str() == "hooks")) - }) + .filter(|planned| is_hook_location(planned)) .map(|planned| { let rel = planned.rel.to_string_lossy().replace('\\', "/"); format!( @@ -1879,6 +1874,16 @@ fn hook_script_lines(plan: &InstallPlan) -> Vec { .collect() } +fn is_hook_location(planned: &PlannedFile) -> bool { + let components: Vec<_> = planned.rel.components().collect(); + match planned.top_category { + TopCategory::Hooks => true, + TopCategory::Agents => components.len() == 3 && components[1].as_os_str() == "hooks", + TopCategory::Roles => components.len() == 2 && components[0].as_os_str() == "hooks", + _ => false, + } +} + fn count_kind(plan: &InstallPlan, cat: TopCategory, kind: PlannedKind) -> usize { plan.files .iter() @@ -1886,18 +1891,6 @@ fn count_kind(plan: &InstallPlan, cat: TopCategory, kind: PlannedKind) -> usize .count() } -#[derive(Debug, Clone, Copy, PartialEq)] -enum StickyMode { - None, - KeepAll, - ReplaceAll, -} - -enum ConflictAction { - Keep, - Replace, -} - #[derive(Debug)] struct ApplyReport { new_count: usize, @@ -1937,7 +1930,12 @@ fn apply_plan( record_written_file(store, bundle, planned, FileAction::Replaced)?; report.refreshed_count += 1; } - PlannedKind::Conflict => match resolve_conflict(planned, &mut sticky)? { + PlannedKind::Conflict => match resolve_conflict( + &planned.dst, + planned.top_category.label(), + &mut sticky, + NonInteractive::Bail, + )? { ConflictAction::Keep => report.kept_count += 1, ConflictAction::Replace => { write_file(&planned.src, &planned.dst)?; @@ -2037,50 +2035,6 @@ fn record_mcp_merge(store: &mut BundleStore, bundle: &str, report: &McpMergeRepo store.record_mcp_servers(bundle, entries) } -fn resolve_conflict(planned: &PlannedFile, sticky: &mut StickyMode) -> Result { - match *sticky { - StickyMode::KeepAll => return Ok(ConflictAction::Keep), - StickyMode::ReplaceAll => return Ok(ConflictAction::Replace), - StickyMode::None => {} - } - - if !*IS_STDOUT_TERMINAL { - bail!( - "Refusing to overwrite local file {} non-interactively. \ - Re-run in a terminal, with --install-force (installs), \ - or with --yes (updates).", - planned.dst.display() - ); - } - - let prompt = format!( - "Conflict at {} (category: {})", - planned.dst.display(), - planned.top_category.label() - ); - let choice = Select::new( - &prompt, - vec!["keep", "replace", "keep-all", "replace-all", "abort"], - ) - .prompt() - .with_context(|| "failed to read conflict choice")?; - - match choice { - "keep" => Ok(ConflictAction::Keep), - "replace" => Ok(ConflictAction::Replace), - "keep-all" => { - *sticky = StickyMode::KeepAll; - Ok(ConflictAction::Keep) - } - "replace-all" => { - *sticky = StickyMode::ReplaceAll; - Ok(ConflictAction::Replace) - } - "abort" => bail!("Install aborted by user at conflict resolution."), - _ => unreachable!("inquire::Select returned an unexpected option"), - } -} - fn write_file(src: &Path, dst: &Path) -> Result<()> { if let Some(parent) = dst.parent() { fs::create_dir_all(parent) @@ -2092,26 +2046,6 @@ fn write_file(src: &Path, dst: &Path) -> Result<()> { Ok(()) } -#[cfg(unix)] -fn set_executable_bit_if_script(path: &Path) -> Result<()> { - use std::os::unix::fs::PermissionsExt; - - let Some(ext) = path.extension().and_then(OsStr::to_str) else { - return Ok(()); - }; - if Language::from_extension(ext) == Language::Unsupported { - return Ok(()); - } - fs::set_permissions(path, fs::Permissions::from_mode(0o755)) - .with_context(|| format!("chmod {}", path.display()))?; - Ok(()) -} - -#[cfg(not(unix))] -fn set_executable_bit_if_script(_path: &Path) -> Result<()> { - Ok(()) -} - #[derive(Debug)] struct McpMergeReport { added: Vec, @@ -2924,6 +2858,11 @@ mod tests { file("x/config.yaml", TopCategory::Agents), file("reviewer.md", TopCategory::Roles), file("hello.yaml", TopCategory::Macros), + file("x/hooks/nested/deep.sh", TopCategory::Agents), + file("x/sub/hooks/pre.sh", TopCategory::Agents), + file("sub/hooks/extra.sh", TopCategory::Roles), + file("my-skill/hooks/tool.sh", TopCategory::Skills), + file("my-macro/hooks/tool.sh", TopCategory::Macros), ], mcp_json: None, }; diff --git a/src/config/macros.rs b/src/config/macros.rs index 2193afe..5fc7dfd 100644 --- a/src/config/macros.rs +++ b/src/config/macros.rs @@ -83,12 +83,17 @@ pub async fn macro_execute( .bootstrap_tools(app.as_ref(), true, abort_signal.clone()) .await?; - for step in ¯o_value.steps { - let command = Macro::interpolate_command(step, &variables); - println!(">> {}", multiline_text(&command)); - run_repl_command(&mut macro_ctx, abort_signal.clone(), &command).await?; + let result = async { + for step in ¯o_value.steps { + let command = Macro::interpolate_command(step, &variables); + println!(">> {}", multiline_text(&command)); + run_repl_command(&mut macro_ctx, abort_signal.clone(), &command).await?; + } + Ok(()) } - Ok(()) + .await; + macro_ctx.top_level_agent_finished(result.as_ref().err(), Some(&abort_signal)); + result } struct MacroModeGuard<'a> { @@ -167,7 +172,8 @@ impl Macro { debug!("Processing macro file: {}", file.as_ref()); let embedded_file = MacroAssets::get(&file) .ok_or_else(|| anyhow!("Failed to load embedded macro file: {}", file.as_ref()))?; - let content = unsafe { std::str::from_utf8_unchecked(&embedded_file.data) }; + let content = std::str::from_utf8(&embedded_file.data) + .expect("bundled macro asset is not valid UTF-8"); let file_path = paths::macros_dir().join(file.as_ref()); if file_path.exists() && !force { @@ -350,56 +356,14 @@ fn default_true() -> bool { mod tests { use super::*; use crate::config::{AppState, Session, WorkingMode}; + use crate::testing::TestConfigDirGuard; use crate::utils::{create_abort_signal, get_env_name}; use serial_test::serial; use std::fs::{create_dir_all, remove_dir_all, write}; use std::future::Future; - use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; use std::{env, str}; - struct TestConfigDirGuard { - key: String, - previous: Option, - path: PathBuf, - } - - impl TestConfigDirGuard { - fn new() -> Self { - let key = get_env_name("config_dir"); - let previous = env::var_os(&key); - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let path = env::temp_dir().join(format!("coyote-macros-tests-{unique}")); - create_dir_all(&path).unwrap(); - unsafe { - env::set_var(&key, &path); - } - Self { - key, - previous, - path, - } - } - } - - impl Drop for TestConfigDirGuard { - fn drop(&mut self) { - if let Some(previous) = &self.previous { - unsafe { - env::set_var(&self.key, previous); - } - } else { - unsafe { - env::remove_var(&self.key); - } - } - let _ = remove_dir_all(&self.path); - } - } - fn test_ctx() -> RequestContext { RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd) } @@ -974,7 +938,7 @@ variables: #[test] #[serial] fn non_isolated_steps_run_on_live_ctx_and_mutations_persist() { - let _guard = TestConfigDirGuard::new(); + let _guard = TestConfigDirGuard::new("macros-tests"); write_macro_file( "live-macro", "isolated: false\nsteps:\n - \".set temperature 0.42\"\n", @@ -1006,7 +970,7 @@ variables: #[test] #[serial] fn non_isolated_step_failure_aborts_and_restores_flag_and_mode() { - let _guard = TestConfigDirGuard::new(); + let _guard = TestConfigDirGuard::new("macros-tests"); write_macro_file( "fail-macro", "isolated: false\nsteps:\n - \".set temperature 0.9\"\n - \".update\"\n - \".set temperature 0.1\"\n", @@ -1057,7 +1021,7 @@ variables: #[test] #[serial] fn non_isolated_macro_step_invoking_macro_is_rejected() { - let _guard = TestConfigDirGuard::new(); + let _guard = TestConfigDirGuard::new("macros-tests"); write_macro_file( "outer-macro", "isolated: false\nsteps:\n - \".inner-macro\"\n", @@ -1093,7 +1057,7 @@ variables: #[test] #[serial] fn isolated_macro_step_runs_non_isolated_macro_inline_on_fork() { - let _guard = TestConfigDirGuard::new(); + let _guard = TestConfigDirGuard::new("macros-tests"); write_macro_file("iso-outer-macro", "steps:\n - \".inner-macro\"\n"); write_macro_file( "inner-macro", @@ -1122,7 +1086,7 @@ variables: #[test] #[serial] fn isolated_macro_still_forks_and_leaves_live_ctx_untouched() { - let _guard = TestConfigDirGuard::new(); + let _guard = TestConfigDirGuard::new("macros-tests"); write_macro_file("iso-macro", "steps:\n - \".set temperature 0.77\"\n"); let mut ctx = test_ctx(); ctx.session = Some(Session::default()); @@ -1152,7 +1116,7 @@ variables: #[test] #[serial] fn guard_restores_prior_flag_values_after_inline_run() { - let _guard = TestConfigDirGuard::new(); + let _guard = TestConfigDirGuard::new("macros-tests"); write_macro_file( "inner-macro", "isolated: false\nsteps:\n - \".set temperature 0.11\"\n", diff --git a/src/config/mcp_factory.rs b/src/config/mcp_factory.rs index 23dba66..c09f5f4 100644 --- a/src/config/mcp_factory.rs +++ b/src/config/mcp_factory.rs @@ -1,5 +1,6 @@ +use crate::hooks::McpServerHooks; use crate::mcp::{ - ConnectedServer, JsonField, McpAuthRequired, McpServer, McpTransportType, + ConnectedServer, HttpAuth, JsonField, McpAuthRequired, McpServer, McpTransportType, is_auth_required_error, resolve_http_auth, spawn_mcp_server, }; @@ -80,11 +81,6 @@ pub struct McpFactory { } impl McpFactory { - pub fn try_get_active(&self, key: &McpServerKey) -> Option> { - let map = self.active.lock(); - map.get(key).and_then(|weak| weak.upgrade()) - } - pub fn insert_active(&self, key: McpServerKey, handle: &Arc) { let mut map = self.active.lock(); map.insert(key, Arc::downgrade(handle)); @@ -95,34 +91,88 @@ impl McpFactory { name: &str, spec: &McpServer, log_path: Option<&Path>, + hooks: &McpServerHooks, ) -> Result> { let key = McpServerKey::from_spec(name, spec); - if let Some(existing) = self.try_get_active(&key) { - return Ok(existing); - } - - let (auth, auth_reason) = resolve_http_auth(name, spec).await; - let handle = spawn_mcp_server(spec, log_path, auth).await.map_err(|e| { - if is_auth_required_error(&e) { - e.context(McpAuthRequired { - server: name.to_string(), - reason: auth_reason, - }) - } else { - e + let reconnect = { + let map = self.active.lock(); + if let Some(existing) = map.get(&key).and_then(|weak| weak.upgrade()) { + return Ok(existing); } - })?; + map.contains_key(&key) + }; + + let transport = transport_label(&spec.transport_type); + let handle = match spawn_server(name, spec, log_path).await { + Ok(handle) => handle, + Err(e) => { + hooks.fire_failed(name, transport, &e, is_auth_required_error(&e)); + return Err(e); + } + }; self.insert_active(key, &handle); + hooks.fire_connected(name, transport, reconnect); Ok(handle) } } +fn transport_label(transport: &McpTransportType) -> &'static str { + match transport { + McpTransportType::Stdio => "stdio", + McpTransportType::Http => "http", + McpTransportType::Sse => "sse", + } +} + +async fn spawn_server( + name: &str, + spec: &McpServer, + log_path: Option<&Path>, +) -> Result> { + let (auth, auth_reason) = resolve_http_auth(name, spec).await; + spawn_transport(spec, log_path, auth).await.map_err(|e| { + if is_auth_required_error(&e) { + e.context(McpAuthRequired { + server: name.to_string(), + reason: auth_reason, + }) + } else { + e + } + }) +} + +async fn spawn_transport( + spec: &McpServer, + log_path: Option<&Path>, + auth: HttpAuth, +) -> Result> { + #[cfg(test)] + if let Some(result) = STUB_SPAWNS.lock().pop() { + return result; + } + spawn_mcp_server(spec, log_path, auth).await +} + +/// Injected spawn outcomes, popped instead of spawning a real transport: +/// nothing in a test environment speaks MCP over stdio or HTTP, so the +/// connect path is exercised with fixture handles. Empty means spawn for +/// real. +#[cfg(test)] +static STUB_SPAWNS: Mutex>>> = Mutex::new(Vec::new()); + #[cfg(test)] mod tests { use super::*; + use crate::config::test_fixtures::FixtureServer; + use crate::config::{AppConfig, AppState, RequestContext, WorkingMode}; + use crate::hooks::{HookDef, HooksMap, test_sink}; use crate::mcp::{JsonField, McpServer, McpTransportType}; + use anyhow::anyhow; use indexmap::IndexMap; + use rmcp::ServiceExt; + use serial_test::serial; use std::collections::HashMap; fn stdio_spec( @@ -299,26 +349,195 @@ mod tests { } } - #[test] - fn factory_try_get_active_returns_none_when_empty() { - let factory = McpFactory::default(); - let spec = stdio_spec("cmd", None, None); - let key = McpServerKey::from_spec("s", &spec); - assert!(factory.try_get_active(&key).is_none()); - } - - #[test] - fn factory_try_get_active_returns_none_for_unknown_key() { - let factory = McpFactory::default(); - let spec = stdio_spec("cmd", None, None); - let key = McpServerKey::from_spec("s", &spec); - assert!(factory.try_get_active(&key).is_none()); - } - #[test] fn factory_default_has_empty_active_map() { let factory = McpFactory::default(); let map = factory.active.lock(); assert!(map.is_empty()); } + + /// Clears injected spawn outcomes on drop so a panicking test cannot + /// leak stubs into later tests. + struct StubSpawnsGuard; + + impl StubSpawnsGuard { + fn push(result: Result>) -> Self { + STUB_SPAWNS.lock().push(result); + StubSpawnsGuard + } + } + + impl Drop for StubSpawnsGuard { + fn drop(&mut self) { + STUB_SPAWNS.lock().clear(); + } + } + + /// An in-process client/server pair; the returned server half must stay + /// alive for the client handle to keep working. + async fn fixture_handle() -> (Arc, impl Sized) { + let (client_io, server_io) = tokio::io::duplex(4096); + let (server, client) = tokio::join!( + FixtureServer::default().serve(server_io), + ().serve(client_io) + ); + (Arc::new(client.unwrap()), server.unwrap()) + } + + fn ctx_with_mcp_hooks(marker: &str) -> RequestContext { + let mut hooks_map = HooksMap::default(); + for event in ["mcp.server.connected", "mcp.server.failed"] { + hooks_map.insert( + event.to_string(), + vec![HookDef { + name: format!("{marker}-{event}"), + command: "true".to_string(), + }], + ); + } + let mut app = AppState::test_default(); + app.config = Arc::new(AppConfig { + hooks: hooks_map, + ..Default::default() + }); + RequestContext::new(Arc::new(app), WorkingMode::Cmd) + } + + fn mcp_captures(marker: &str) -> Vec { + test_sink::snapshot() + .into_iter() + .filter(|capture| capture.hook_name.starts_with(marker)) + .collect() + } + + #[tokio::test] + #[serial] + async fn acquire_fires_connected_only_on_real_spawns_and_marks_reconnects() { + let _sink = test_sink::install(); + let marker = "mcp-spawn-k7d"; + let ctx = ctx_with_mcp_hooks(marker); + let hooks = McpServerHooks::resolve(&ctx); + let factory = McpFactory::default(); + let spec = stdio_spec("fixture-server-cmd", None, None); + + let (first, _first_server) = fixture_handle().await; + let stub = StubSpawnsGuard::push(Ok(first.clone())); + let connected = factory.acquire("srv", &spec, None, &hooks).await.unwrap(); + drop(stub); + assert!(Arc::ptr_eq(&connected, &first)); + let captures = mcp_captures(marker); + assert_eq!(captures.len(), 1, "{captures:?}"); + let capture = &captures[0]; + assert_eq!(capture.hook_name, format!("{marker}-mcp.server.connected")); + assert_eq!( + capture.envs.get("COYOTE_MCP_SERVER").map(String::as_str), + Some("srv") + ); + assert_eq!( + capture.envs.get("COYOTE_MCP_TRANSPORT").map(String::as_str), + Some("stdio") + ); + assert!( + !capture.envs.contains_key("COYOTE_MCP_RECONNECT"), + "a first connect must not be marked as a reconnect" + ); + + let reused = factory.acquire("srv", &spec, None, &hooks).await.unwrap(); + assert!(Arc::ptr_eq(&reused, &first)); + assert_eq!( + mcp_captures(marker).len(), + 1, + "reusing a live server must fire nothing" + ); + + // Every holder dropped: the weak in the factory dies, and the next + // acquire is a real spawn again — now marked as a reconnect. + drop(connected); + drop(reused); + drop(first); + let (second, _second_server) = fixture_handle().await; + let _stub = StubSpawnsGuard::push(Ok(second.clone())); + let respawned = factory.acquire("srv", &spec, None, &hooks).await.unwrap(); + assert!(Arc::ptr_eq(&respawned, &second)); + let captures = mcp_captures(marker); + assert_eq!(captures.len(), 2, "{captures:?}"); + assert_eq!( + captures[1] + .envs + .get("COYOTE_MCP_RECONNECT") + .map(String::as_str), + Some("true") + ); + } + + #[tokio::test] + #[serial] + async fn acquire_spawn_failure_fires_failed_with_the_error() { + let _sink = test_sink::install(); + let marker = "mcp-fail-v2q"; + let ctx = ctx_with_mcp_hooks(marker); + let hooks = McpServerHooks::resolve(&ctx); + let factory = McpFactory::default(); + // No stub: the nonexistent binary drives the real spawn-error path. + let spec = stdio_spec("coyote-nonexistent-mcp-binary-v2q", None, None); + + let result = factory.acquire("bad-srv", &spec, None, &hooks).await; + + assert!(result.is_err()); + let captures = mcp_captures(marker); + assert_eq!(captures.len(), 1, "{captures:?}"); + let capture = &captures[0]; + assert_eq!(capture.hook_name, format!("{marker}-mcp.server.failed")); + assert_eq!( + capture.envs.get("COYOTE_MCP_SERVER").map(String::as_str), + Some("bad-srv") + ); + assert_eq!( + capture.envs.get("COYOTE_MCP_TRANSPORT").map(String::as_str), + Some("stdio") + ); + assert!( + capture + .envs + .get("COYOTE_ERROR") + .is_some_and(|error| !error.is_empty()) + ); + assert!( + !capture.envs.contains_key("COYOTE_MCP_AUTH_REQUIRED"), + "a plain spawn failure must not be flagged as auth-required" + ); + } + + #[tokio::test] + #[serial] + async fn acquire_auth_failure_sets_the_auth_required_flag() { + let _sink = test_sink::install(); + let marker = "mcp-auth-h4n"; + let ctx = ctx_with_mcp_hooks(marker); + let hooks = McpServerHooks::resolve(&ctx); + let factory = McpFactory::default(); + let spec = stdio_spec("oauth-cmd", None, None); + let _stub = StubSpawnsGuard::push(Err(anyhow!("Auth required: no stored token"))); + + let err = factory + .acquire("oauth-srv", &spec, None, &hooks) + .await + .unwrap_err(); + + assert!( + err.downcast_ref::().is_some(), + "the auth error must be wrapped in McpAuthRequired context" + ); + let captures = mcp_captures(marker); + assert_eq!(captures.len(), 1, "{captures:?}"); + let capture = &captures[0]; + assert_eq!(capture.hook_name, format!("{marker}-mcp.server.failed")); + assert_eq!( + capture + .envs + .get("COYOTE_MCP_AUTH_REQUIRED") + .map(String::as_str), + Some("true") + ); + } } diff --git a/src/config/mod.rs b/src/config/mod.rs index 7cfed24..0491e4e 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -3,6 +3,7 @@ mod app_config; mod app_state; pub(crate) mod builtin_manifest; mod bundles; +pub(crate) mod conflict; mod input; mod install_remote; pub(crate) mod instructions; @@ -37,6 +38,7 @@ pub use self::app_config::AppConfig; pub use self::app_state::AppState; pub(crate) use self::bundles::installed_bundle_names; pub use self::bundles::list_installed_bundles; +use self::conflict::{InstallMode, StickyMode}; pub use self::input::Input; pub use self::install_remote::{ DEFAULT_GIT_HOST, install_or_update, install_or_update_from_repl_args, uninstall_bundle, @@ -417,12 +419,13 @@ impl Default for Config { } pub fn install_builtins() -> Result<()> { + hooks::sweep_stale_payload_files(); Functions::install_builtin_global_tools(false)?; - Agent::install_builtin_agents(false)?; + Agent::install_builtin_agents(InstallMode::Skip)?; Macro::install_macros(false)?; Skill::install_builtin_skills(false)?; - hooks::install_builtin_hooks(false)?; - Role::install_builtin_role_hooks(false)?; + hooks::install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None)?; + Role::install_builtin_role_hooks(InstallMode::Skip, &mut StickyMode::None)?; Ok(()) } @@ -519,11 +522,16 @@ pub fn install_assets(category: AssetCategory) -> Result<()> { } match category { - AssetCategory::Agents => Agent::install_builtin_agents(true)?, + AssetCategory::Agents => Agent::install_builtin_agents(InstallMode::Force)?, AssetCategory::Macros => Macro::install_macros(true)?, AssetCategory::Functions => Functions::install_builtin_global_tools(true)?, AssetCategory::Skills => Skill::install_builtin_skills(true)?, - AssetCategory::Hooks => hooks::install_builtin_hooks(true)?, + AssetCategory::Hooks => { + let mut sticky = StickyMode::None; + hooks::install_builtin_hooks(InstallMode::Prompt, &mut sticky)?; + Role::install_builtin_role_hooks(InstallMode::Prompt, &mut sticky)?; + Agent::install_builtin_agent_hooks(InstallMode::Prompt, &mut sticky)?; + } AssetCategory::McpConfig => Functions::install_mcp_config()?, } @@ -544,6 +552,14 @@ fn confirm_asset_overwrite(category: AssetCategory, label: &str, target: &Path) references) are left untouched.", target.display() ), + AssetCategory::Hooks => format!( + "Refreshing bundled hooks installs any missing bundled hook \ + scripts in {}, the roles hooks directory, and each bundled \ + agent's hooks directory. Where a local copy differs from the \ + bundled version you are asked per file whether to keep or \ + replace it; non-interactive runs keep local files.", + target.display() + ), _ => format!( "Reinstalling bundled {label} overwrites every bundled {label} in \ {} with this build's packaged versions. Local changes to bundled \ @@ -729,11 +745,17 @@ pub fn maybe_spawn_models_refresh(app: &AppConfig) { } impl Config { - pub async fn load_with_interpolation(info_flag: bool) -> Result { + pub async fn load_with_interpolation( + skip_interpolation: bool, + inspection: bool, + ) -> Result { let config_path = paths::config_file(); if env::var_os(SANDBOX_ENV_FLAG).is_some() { if !config_path.exists() { + if inspection { + return Ok(Self::default()); + } create_config_file(&config_path).await?; } @@ -748,6 +770,9 @@ impl Config { { Some(v) => (Self::load_dynamic(&v)?, String::new()), None => { + if inspection { + return Ok(Self::default()); + } if *IS_STDOUT_TERMINAL { create_config_file(&config_path).await?; } @@ -758,7 +783,7 @@ impl Config { Self::load_from_file(&config_path)? }; - if info_flag { + if skip_interpolation { return Ok(config); } @@ -1120,6 +1145,32 @@ pub(crate) fn ensure_parent_exists(path: &Path) -> Result<()> { Ok(()) } +/// The one exec-bit policy for every hook/agent installer: a file is +/// executable iff `Language::from_extension` recognizes its extension. +/// Users may keep non-executable support files in hooks directories, so +/// installers must write plain and then apply this predicate — never +/// blanket-0755. +#[cfg(unix)] +pub(crate) fn set_executable_bit_if_script(path: &Path) -> Result<()> { + use crate::function::Language; + use std::os::unix::fs::PermissionsExt; + + let Some(ext) = path.extension().and_then(std::ffi::OsStr::to_str) else { + return Ok(()); + }; + if Language::from_extension(ext) == Language::Unsupported { + return Ok(()); + } + fs::set_permissions(path, fs::Permissions::from_mode(0o755)) + .with_context(|| format!("chmod {}", path.display()))?; + Ok(()) +} + +#[cfg(not(unix))] +pub(crate) fn set_executable_bit_if_script(_path: &Path) -> Result<()> { + Ok(()) +} + fn read_env_value(key: &str) -> Option> where T: std::str::FromStr, @@ -1593,12 +1644,12 @@ hooks: use std::fs; use std::time; - let unique = time::SystemTime::now() + let unique = SystemTime::now() .duration_since(time::UNIX_EPOCH) .unwrap() .as_nanos(); - let tmp_dir = std::env::temp_dir().join(format!("coyote-sandbox-cfg-{unique}")); - fs::create_dir_all(&tmp_dir).unwrap(); + let tmp_dir = env::temp_dir().join(format!("coyote-sandbox-cfg-{unique}")); + create_dir_all(&tmp_dir).unwrap(); let config_path = tmp_dir.join("config.yaml"); fs::write( @@ -1608,25 +1659,25 @@ hooks: .unwrap(); let config_env = get_env_name("config_file"); - let prev_config = std::env::var_os(&config_env); - let prev_sandbox = std::env::var_os(crate::sandbox::SANDBOX_ENV_FLAG); + let prev_config = env::var_os(&config_env); + let prev_sandbox = env::var_os(SANDBOX_ENV_FLAG); unsafe { - std::env::set_var(&config_env, &config_path); - std::env::set_var(crate::sandbox::SANDBOX_ENV_FLAG, "1"); + env::set_var(&config_env, &config_path); + env::set_var(SANDBOX_ENV_FLAG, "1"); } - let result = Config::load_with_interpolation(false).await; + let result = Config::load_with_interpolation(false, false).await; let (_, raw) = Config::load_from_file(&config_path).unwrap(); unsafe { match prev_config { - Some(v) => std::env::set_var(&config_env, v), - None => std::env::remove_var(&config_env), + Some(v) => env::set_var(&config_env, v), + None => env::remove_var(&config_env), } match prev_sandbox { - Some(v) => std::env::set_var(crate::sandbox::SANDBOX_ENV_FLAG, v), - None => std::env::remove_var(crate::sandbox::SANDBOX_ENV_FLAG), + Some(v) => env::set_var(SANDBOX_ENV_FLAG, v), + None => env::remove_var(SANDBOX_ENV_FLAG), } } let _ = fs::remove_dir_all(&tmp_dir); @@ -1640,6 +1691,60 @@ hooks: ); } + #[tokio::test] + #[serial_test::serial] + async fn info_flag_with_missing_config_skips_wizard_and_writes_nothing() { + let unique = SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let tmp_dir = env::temp_dir().join(format!("coyote-info-bypass-{unique}")); + create_dir_all(&tmp_dir).unwrap(); + let _config_dir = crate::testing::EnvVarGuard::set(get_env_name("config_dir"), &tmp_dir); + let _config_file = crate::testing::EnvVarGuard::unset(get_env_name("config_file")); + let _sandbox = crate::testing::EnvVarGuard::unset(SANDBOX_ENV_FLAG); + let _provider = crate::testing::EnvVarGuard::unset(get_env_name("provider")); + let _platform = crate::testing::EnvVarGuard::unset(get_env_name("platform")); + + let result = Config::load_with_interpolation(true, true).await; + + let leftover: Vec<_> = read_dir(&tmp_dir) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + let _ = fs::remove_dir_all(&tmp_dir); + result.expect("inspection flags must load a default config without a config file"); + assert!( + leftover.is_empty(), + "inspection flags must not write anything: {leftover:?}" + ); + } + + #[tokio::test] + #[serial_test::serial] + async fn sandbox_info_flag_with_missing_config_writes_nothing() { + let unique = SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let tmp_dir = env::temp_dir().join(format!("coyote-sandbox-info-bypass-{unique}")); + create_dir_all(&tmp_dir).unwrap(); + let config_path = tmp_dir.join("config.yaml"); + let _config_file = + crate::testing::EnvVarGuard::set(get_env_name("config_file"), &config_path); + let _sandbox = crate::testing::EnvVarGuard::set(SANDBOX_ENV_FLAG, "1"); + + let result = Config::load_with_interpolation(true, true).await; + + let created = config_path.exists(); + let _ = fs::remove_dir_all(&tmp_dir); + result.expect("inspection flags must load a default config in sandbox mode too"); + assert!( + !created, + "inspection flags must not create the sandbox config file" + ); + } + #[test] fn unloadable_override_is_stale() { assert!(models_override_is_stale(false, Some(SystemTime::now()), 24)); @@ -1719,7 +1824,7 @@ hooks: let written = write_models_override(vec![entry()]).unwrap(); assert_eq!(written, path); let mut tmp_name = path.as_os_str().to_os_string(); - tmp_name.push(format!(".{}.tmp", std::process::id())); + tmp_name.push(format!(".{}.tmp", process::id())); assert!(!PathBuf::from(tmp_name).exists()); let loaded = paths::local_models_override().unwrap(); assert_eq!(loaded.len(), 1); @@ -1737,4 +1842,117 @@ hooks: std::panic::resume_unwind(panic); } } + + use crate::testing::TestConfigDirGuard; + + #[test] + #[serial_test::serial] + fn install_assets_hooks_shares_one_sticky_scope_across_all_three_locations() { + use crate::config::conflict::prompt_script; + + let _guard = TestConfigDirGuard::new("hooks-three-locations"); + let notify = paths::hooks_dir().join("notify.sh"); + let role_dir = paths::roles_dir().join("hooks"); + let agent_dir = paths::agents_data_dir().join("probe").join("hooks"); + let shipped = vec![("hook.sh".to_string(), "shipped\n".to_string())]; + + // Drives the exact three installers `install_assets(Hooks)` runs, + // threading one sticky scope through all of them. + let refresh = |sticky: &mut StickyMode| { + hooks::install_builtin_hooks(InstallMode::Prompt, sticky).unwrap(); + role::install_and_reconcile_role_hooks( + &role_dir, + &shipped, + InstallMode::Prompt, + sticky, + ) + .unwrap(); + agent::install_and_reconcile_agent_hooks( + "probe", + &shipped, + InstallMode::Prompt, + sticky, + ) + .unwrap(); + }; + let modify_all = || { + fs::write(¬ify, "local global").unwrap(); + fs::write(role_dir.join("hook.sh"), "local role").unwrap(); + fs::write(agent_dir.join("hook.sh"), "local agent").unwrap(); + }; + + let script = prompt_script::install(&[]); + refresh(&mut StickyMode::None); + assert_eq!( + prompt_script::prompts_asked(), + 0, + "a clean install asks nothing" + ); + drop(script); + + // One replace-all at the first (global) conflict must silently + // replace the role and agent conflicts too. + modify_all(); + let script = prompt_script::install(&["replace-all"]); + let mut sticky = StickyMode::None; + refresh(&mut sticky); + assert_eq!(prompt_script::prompts_asked(), 1); + assert_eq!(sticky, StickyMode::ReplaceAll); + assert!( + read_to_string(¬ify) + .unwrap() + .starts_with("#!/usr/bin/env bash") + ); + assert_eq!( + read_to_string(role_dir.join("hook.sh")).unwrap(), + "shipped\n" + ); + assert_eq!( + read_to_string(agent_dir.join("hook.sh")).unwrap(), + "shipped\n" + ); + drop(script); + + // And one keep-all must keep every location's local edit. + modify_all(); + let script = prompt_script::install(&["keep-all"]); + let mut sticky = StickyMode::None; + refresh(&mut sticky); + assert_eq!(prompt_script::prompts_asked(), 1); + assert_eq!(sticky, StickyMode::KeepAll); + assert_eq!(read_to_string(¬ify).unwrap(), "local global"); + assert_eq!( + read_to_string(role_dir.join("hook.sh")).unwrap(), + "local role" + ); + assert_eq!( + read_to_string(agent_dir.join("hook.sh")).unwrap(), + "local agent" + ); + drop(script); + } + + #[test] + #[serial_test::serial] + fn install_builtins_never_prompts_and_keeps_local_hook_edits() { + use crate::config::conflict::prompt_script; + + let _guard = TestConfigDirGuard::new("install-builtins-startup"); + install_builtins().unwrap(); + let notify = paths::hooks_dir().join("notify.sh"); + fs::write(¬ify, "# local edit").unwrap(); + + // A forced terminal with no scripted answers: any prompt would panic + // and the counter would move. + let script = prompt_script::install(&[]); + install_builtins().unwrap(); + assert_eq!(prompt_script::prompts_asked(), 0); + assert_eq!(read_to_string(¬ify).unwrap(), "# local edit"); + drop(script); + + let _script = prompt_script::install_non_interactive(); + install_builtins().unwrap(); + assert_eq!(prompt_script::prompts_asked(), 0); + assert_eq!(read_to_string(¬ify).unwrap(), "# local edit"); + } } diff --git a/src/config/rag_cache.rs b/src/config/rag_cache.rs index 5a43eb6..73c1d3d 100644 --- a/src/config/rag_cache.rs +++ b/src/config/rag_cache.rs @@ -47,3 +47,61 @@ impl RagCache { Ok(arc) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::AppConfig; + use crate::hooks::test_sink; + use crate::rag::RagData; + use anyhow::anyhow; + use serial_test::serial; + use std::time::{SystemTime, UNIX_EPOCH}; + + /// A cache hit must never reach the loader: the loader closure is where + /// a real RAG build (and its `rag.sync.*` hook dispatch) happens, so + /// returning the cached entry is what keeps warm-cache lookups silent. + #[tokio::test] + #[serial] + async fn cache_hit_returns_the_cached_rag_without_running_the_loader() { + let _sink = test_sink::install(); + test_sink::drain(); + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("coyote-rag-cache-hit-{unique}")); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("kb.yaml"); + let data = RagData { + driver: "yaml".to_string(), + // The only embedding model resolvable in tests; see + // `seed_model_registries` in the request_context tests. + embedding_model: "test-seeded:test-embedder".to_string(), + chunk_size: 1000, + chunk_overlap: 100, + top_k: 5, + ..Default::default() + }; + std::fs::write(&path, serde_yaml::to_string(&data).unwrap()).unwrap(); + let rag = Arc::new(Rag::load(&AppConfig::default(), "kb", &path).await.unwrap()); + let _ = std::fs::remove_dir_all(&dir); + + let cache = RagCache::default(); + let key = RagKey::Named("kb".to_string()); + cache.insert(key.clone(), &rag); + + let loaded = cache + .load_with(key, || async { + Err(anyhow!("a cache hit must not rebuild")) + }) + .await + .unwrap(); + + assert!(Arc::ptr_eq(&loaded, &rag)); + assert!( + test_sink::drain().is_empty(), + "a cache hit must dispatch no hooks" + ); + } +} diff --git a/src/config/request_context.rs b/src/config/request_context.rs index 8874ed8..d974b16 100644 --- a/src/config/request_context.rs +++ b/src/config/request_context.rs @@ -54,6 +54,7 @@ use crate::hooks::{self, HookEvent}; use anyhow::{Context, Error, Result, bail}; use colored::Colorize; use gman::providers::SupportedProvider; +use hooks::{McpServerHooks, RagSyncHooks}; use indexmap::IndexMap; use indoc::formatdoc; use inquire::{Confirm, MultiSelect, Text, list_option::ListOption, validator::Validation}; @@ -417,7 +418,11 @@ impl RequestContext { working_mode: WorkingMode, info_flag: bool, ) -> Result { - let model = Model::retrieve_model(&app.config, &app.config.model_id, ModelType::Chat)?; + let model = if info_flag && app.config.model_id.is_empty() { + Model::default() + } else { + Model::retrieve_model(&app.config, &app.config.model_id, ModelType::Chat)? + }; let mut functions = app.functions.clone(); if working_mode.is_repl() { @@ -1073,7 +1078,12 @@ impl RequestContext { // COYOTE_SESSION_ID and session-held role hooks still resolve; no // session means no event. if self.session.is_some() { - hooks::fire(HookEvent::SessionEnded, self, &[], None); + hooks::fire( + HookEvent::SessionEnded, + self, + &hooks::role_extras(self), + None, + ); } if let Some(mut session) = self.session.take() { let sessions_dir = self.sessions_dir(); @@ -4402,6 +4412,7 @@ impl RequestContext { if !server_ids.is_empty() { let app_ref = &self.app; + let mcp_hooks = McpServerHooks::resolve(self); let acquire_all = async { let mut handles = Vec::new(); let mut auth_required = Vec::new(); @@ -4409,7 +4420,7 @@ impl RequestContext { if let Some(spec) = mcp_config.mcp_servers.get(id) { match app_ref .mcp_factory - .acquire(id, spec, app_ref.mcp_log_path.as_deref()) + .acquire(id, spec, app_ref.mcp_log_path.as_deref(), &mcp_hooks) .await { Ok(handle) => handles.push((id.clone(), handle)), @@ -4734,7 +4745,19 @@ impl RequestContext { self.refresh_mcp_tool_filters(); self.init_agent_session_variables(new_session)?; if created_new_session { - hooks::fire(HookEvent::SessionStarted, self, &[], None); + hooks::fire( + HookEvent::SessionStarted, + self, + &hooks::role_extras(self), + None, + ); + } else { + hooks::fire( + HookEvent::SessionResumed, + self, + &hooks::role_extras(self), + None, + ); } Ok(()) } @@ -4903,7 +4926,10 @@ impl RequestContext { // Interactive switch-away closes the top-level agent bracket while // the agent is still attached, so agent-scoped hooks resolve. - self.top_level_agent_finished(None); + // No abort signal on purpose: switching away is a deliberate exit, + // never an interruption, even though cancel_recursive tears the + // agent's children down just below. + self.top_level_agent_finished(None, None); if self.agent.take().is_some() { if let Some(supervisor) = self.supervisor.clone() { supervisor.read().cancel_recursive(); @@ -4951,11 +4977,25 @@ impl RequestContext { ); } - pub fn top_level_agent_finished(&mut self, error: Option<&Error>) { + pub fn top_level_agent_finished( + &mut self, + error: Option<&Error>, + abort_signal: Option<&AbortSignal>, + ) { let Some(id) = self.top_level_agent_id.take() else { return; }; + if abort_signal.is_some_and(|signal| signal.aborted_ctrlc()) { + hooks::fire( + HookEvent::AgentInterrupted, + self, + &[("COYOTE_AGENT_ID", id)], + None, + ); + return; + } + match error { None => hooks::fire( HookEvent::AgentCompleted, @@ -5311,6 +5351,7 @@ impl RequestContext { let vault = self.app.vault.clone(); let rag_cache = self.rag_cache(); let working_mode = self.working_mode; + let sync_hooks = RagSyncHooks::resolve(self); let (rag, rag_key): (Arc, Option) = match rag { None => { @@ -5329,6 +5370,7 @@ impl RequestContext { &[], abort_signal.clone(), false, + sync_hooks, ) .await?, ), @@ -5350,8 +5392,16 @@ impl RequestContext { if working_mode.is_cmd() { bail!("Unknown RAG '{name}'"); } - Rag::init(&app, name, &rag_path, &[], abort_signal.clone(), true) - .await + Rag::init( + &app, + name, + &rag_path, + &[], + abort_signal.clone(), + true, + sync_hooks, + ) + .await } else { Rag::load_async(&app, &vault, name, &rag_path).await } @@ -5435,6 +5485,7 @@ impl RequestContext { false, &self.app.config, abort_signal, + RagSyncHooks::resolve(self), ) .await?; self.rag = Some(Arc::new(rag)); @@ -5465,8 +5516,15 @@ impl RequestContext { This will call the embedding API and may take a while.", rag.file_count() ); - rag.refresh_document_paths(&document_paths, true, true, &self.app.config, abort_signal) - .await?; + rag.refresh_document_paths( + &document_paths, + true, + true, + &self.app.config, + abort_signal, + RagSyncHooks::resolve(self), + ) + .await?; self.rag = Some(Arc::new(rag)); Ok(()) } @@ -5491,6 +5549,7 @@ mod tests { use crate::config::AppState; use crate::config::agent::AgentConfig; use crate::config::bundles::BundleStore; + use crate::config::conflict::InstallMode; use crate::config::mcp_tool_policy::LayerSource; use crate::config::tool_scope::test_fixtures::{FixtureServer, fixture_runtime}; use crate::function::jobs::RingBuf; @@ -8333,7 +8392,12 @@ mod tests { let agent_dir = paths::agent_data_dir(agent_name); create_dir_all(&agent_dir).unwrap(); let mut config = format!("name: {agent_name}\ninstructions: hi\nhooks:\n"); - for event in ["agent.started", "agent.completed", "agent.failed"] { + for event in [ + "agent.started", + "agent.completed", + "agent.interrupted", + "agent.failed", + ] { config.push_str(&format!( " {event}:\n - name: {marker}-{event}\n command: 'true'\n" )); @@ -8353,7 +8417,7 @@ mod tests { #[test] #[serial] - fn use_session_fires_session_started_for_new_sessions_only() { + fn use_session_discriminates_session_started_from_session_resumed() { let _guard = TestConfigDirGuard::new(); let sessions_dir = paths::local_dir("sessions"); create_dir_all(&sessions_dir).unwrap(); @@ -8361,13 +8425,17 @@ mod tests { let marker = "sess-started-q8n"; let mut ctx = create_test_ctx(); - ctx.update_app_config(|app| app.hooks = hooks_map_of(&["session.started"], marker)); + ctx.update_app_config(|app| { + app.hooks = hooks_map_of(&["session.started", "session.resumed"], marker) + }); + ctx.role = Some(Role::new("started-role", "Prompt")); let app = ctx.app.config.clone(); let abort = utils::create_abort_signal(); run_async(ctx.use_session(&app, Some("hook-fresh"), abort.clone())).unwrap(); let captures = marker_captures(marker); assert_eq!(captures.len(), 1, "a new session fires exactly one event"); + assert_eq!(captures[0].hook_name, format!("{marker}-session.started")); assert_eq!( captures[0] .envs @@ -8375,17 +8443,46 @@ mod tests { .map(String::as_str), Some("hook-fresh") ); + assert_eq!( + captures[0].envs.get("COYOTE_ROLE").map(String::as_str), + Some("started-role"), + "session.started must carry the active role" + ); - // Resuming a persisted session fires nothing. - write_paused_todo_session(&ctx, "hook-resume"); + // Resuming a persisted session fires session.resumed, never + // session.started, and carries the role the session holds. + let session_path = ctx.session_file("hook-resume"); + ensure_parent_exists(&session_path).unwrap(); + write( + &session_path, + "model: test-seeded:test-chat\nrole_name: resumed-role\nmessages: []\n", + ) + .unwrap(); let mut resumed_ctx = create_test_ctx(); - resumed_ctx.update_app_config(|app| app.hooks = hooks_map_of(&["session.started"], marker)); + resumed_ctx.update_app_config(|app| { + app.hooks = hooks_map_of(&["session.started", "session.resumed"], marker) + }); let resumed_app = resumed_ctx.app.config.clone(); run_async(resumed_ctx.use_session(&resumed_app, Some("hook-resume"), abort)).unwrap(); + let captures = marker_captures(marker); + let started: Vec<_> = captures + .iter() + .filter(|capture| capture.hook_name == format!("{marker}-session.started")) + .collect(); + let resumed: Vec<_> = captures + .iter() + .filter(|capture| capture.hook_name == format!("{marker}-session.resumed")) + .collect(); + assert_eq!(started.len(), 1, "resume must not fire session.started"); + assert_eq!(resumed.len(), 1, "resume fires exactly one session.resumed"); assert_eq!( - marker_captures(marker).len(), - 1, - "resume must not fire session.started" + resumed[0].envs.get("COYOTE_SESSION_ID").map(String::as_str), + Some("hook-resume") + ); + assert_eq!( + resumed[0].envs.get("COYOTE_ROLE").map(String::as_str), + Some("resumed-role"), + "session.resumed must carry the session-held role" ); } @@ -8403,14 +8500,22 @@ mod tests { ctx.exit_session().unwrap(); assert!(marker_captures(marker).is_empty()); - ctx.session = Some(Session::default()); + let mut session = Session::default(); + session.set_role(Role::new("ended-role", "Prompt")); + ctx.session = Some(session); ctx.exit_session().unwrap(); ctx.exit_session().unwrap(); + let captures = marker_captures(marker); assert_eq!( - marker_captures(marker).len(), + captures.len(), 1, "session.ended fires exactly once, only while a session is attached" ); + assert_eq!( + captures[0].envs.get("COYOTE_ROLE").map(String::as_str), + Some("ended-role"), + "session.ended must fire while the session-held role is still attached" + ); } #[test] @@ -8462,8 +8567,8 @@ mod tests { ctx.top_level_agent_started(); // Per agent RUN, never per message: re-entering while open is a no-op. ctx.top_level_agent_started(); - ctx.top_level_agent_finished(None); - ctx.top_level_agent_finished(None); + ctx.top_level_agent_finished(None, None); + ctx.top_level_agent_finished(None, None); let captures = marker_captures(marker); let started: Vec<_> = captures @@ -8512,7 +8617,7 @@ mod tests { ctx.top_level_agent_started(); let err = anyhow!("dispatch exploded"); - ctx.top_level_agent_finished(Some(&err)); + ctx.top_level_agent_finished(Some(&err), None); let captures = marker_captures(marker); let failed: Vec<_> = captures @@ -8532,6 +8637,124 @@ mod tests { assert!(failed[0].envs.contains_key("COYOTE_AGENT_ID")); } + #[test] + #[serial] + fn top_level_agent_ctrlc_fires_agent_interrupted_not_failed() { + let _guard = TestConfigDirGuard::new(); + let _sink = test_sink::install(); + let marker = "agent-top-int-r6t"; + let agent_name = unique_name("hook_agent"); + seed_agent_with_hooks(&agent_name, marker); + + let mut ctx = create_test_ctx(); + let app = ctx.app.config.clone(); + let abort = utils::create_abort_signal(); + run_async(ctx.use_agent(&app, &agent_name, None, abort.clone())).unwrap(); + + ctx.top_level_agent_started(); + abort.set_ctrlc(); + // The fired signal, not the error text, decides: even with an error + // in hand the bracket reports an interruption. + let err = anyhow!("Aborted."); + ctx.top_level_agent_finished(Some(&err), Some(&abort)); + + let captures = marker_captures(marker); + let interrupted: Vec<_> = captures + .iter() + .filter(|capture| capture.hook_name == format!("{marker}-agent.interrupted")) + .collect(); + assert_eq!(interrupted.len(), 1); + assert!(interrupted[0].envs.contains_key("COYOTE_AGENT_ID")); + assert!( + !interrupted[0].envs.contains_key("COYOTE_AGENT_ERROR"), + "an interruption is not a failure and carries no error env" + ); + assert!(captures.iter().all(|capture| { + capture.hook_name != format!("{marker}-agent.failed") + && capture.hook_name != format!("{marker}-agent.completed") + })); + } + + #[test] + #[serial] + fn top_level_agent_ctrlc_without_error_fires_agent_interrupted() { + let _guard = TestConfigDirGuard::new(); + let _sink = test_sink::install(); + let marker = "agent-top-int-noerr-v4d"; + let agent_name = unique_name("hook_agent"); + seed_agent_with_hooks(&agent_name, marker); + + let mut ctx = create_test_ctx(); + let app = ctx.app.config.clone(); + let abort = utils::create_abort_signal(); + run_async(ctx.use_agent(&app, &agent_name, None, abort.clone())).unwrap(); + + ctx.top_level_agent_started(); + abort.set_ctrlc(); + // Signal-first: a latched ctrl-c means interrupted even when the run + // surfaced no error at all. + ctx.top_level_agent_finished(None, Some(&abort)); + + let captures = marker_captures(marker); + assert_eq!( + captures + .iter() + .filter(|capture| capture.hook_name == format!("{marker}-agent.interrupted")) + .count(), + 1 + ); + assert!( + captures + .iter() + .all(|capture| capture.hook_name != format!("{marker}-agent.completed")), + "a ctrl-c'd run must not report completion" + ); + } + + /// The REPL's advertised exit path -- ctrl-c at an idle prompt (which + /// prints "To exit, press Ctrl+D"), then ctrl-d -- is a deliberate exit: + /// with no active tasks to cancel the prompt latch stays clear, so + /// teardown fires agent.completed, never agent.interrupted. + #[test] + #[serial] + fn idle_prompt_ctrlc_then_ctrld_exit_fires_agent_completed() { + let _guard = TestConfigDirGuard::new(); + let _sink = test_sink::install(); + let marker = "agent-top-idle-cc-w2b"; + let agent_name = unique_name("hook_agent"); + seed_agent_with_hooks(&agent_name, marker); + + let mut ctx = create_test_ctx(); + let app = ctx.app.config.clone(); + let abort = utils::create_abort_signal(); + run_async(ctx.use_agent(&app, &agent_name, None, abort.clone())).unwrap(); + ctx.top_level_agent_started(); + + // Ctrl-c at the idle prompt: nothing to cancel, so no latch. + let supervisor = Arc::new(RwLock::new(Supervisor::new(4, 3))); + crate::repl::latch_prompt_interrupt(&abort, &supervisor); + assert!(!abort.aborted_ctrlc()); + + // Ctrl-d exits; teardown sees only the ctrl-d flag. + abort.set_ctrld(); + ctx.top_level_agent_finished(None, Some(&abort)); + + let captures = marker_captures(marker); + assert_eq!( + captures + .iter() + .filter(|capture| capture.hook_name == format!("{marker}-agent.completed")) + .count(), + 1 + ); + assert!( + captures + .iter() + .all(|capture| capture.hook_name != format!("{marker}-agent.interrupted")), + "a deliberate exit is never an interruption" + ); + } + #[test] #[serial] fn top_level_agent_helpers_noop_without_agent() { @@ -8549,7 +8772,7 @@ mod tests { ctx.role = Some(Role::new("plain", "prompt")); ctx.top_level_agent_started(); - ctx.top_level_agent_finished(None); + ctx.top_level_agent_finished(None, None); assert!( marker_captures(marker).is_empty(), @@ -8575,7 +8798,7 @@ mod tests { ctx.exit_agent(&app).unwrap(); // A later exit seam (REPL teardown) must not double-fire. - ctx.top_level_agent_finished(None); + ctx.top_level_agent_finished(None, None); let captures = marker_captures(marker); assert_eq!( @@ -9178,19 +9401,19 @@ mod tests { fn install_builtin_agents_force_overwrites_only_with_force() { let _guard = TestConfigDirGuard::new(); - Agent::install_builtin_agents(false).unwrap(); + Agent::install_builtin_agents(InstallMode::Skip).unwrap(); let file = first_file(&paths::agents_data_dir()).expect("bundled agents should be installed"); write(&file, "SENTINEL").unwrap(); - Agent::install_builtin_agents(false).unwrap(); + Agent::install_builtin_agents(InstallMode::Skip).unwrap(); assert_eq!( read_to_string(&file).unwrap(), "SENTINEL", "non-force install must not overwrite an existing file" ); - Agent::install_builtin_agents(true).unwrap(); + Agent::install_builtin_agents(InstallMode::Force).unwrap(); assert_ne!( read_to_string(&file).unwrap(), "SENTINEL", @@ -9203,7 +9426,7 @@ mod tests { fn install_builtin_agents_removes_stale_definition_files() { let _guard = TestConfigDirGuard::new(); - Agent::install_builtin_agents(false).unwrap(); + Agent::install_builtin_agents(InstallMode::Skip).unwrap(); let agents_dir = paths::agents_data_dir(); // Simulate an upgrade from an older install: coder used to ship @@ -9224,7 +9447,7 @@ mod tests { create_dir_all(agents_dir.join("myagent")).unwrap(); write(agents_dir.join("myagent").join("config.yaml"), "keep").unwrap(); - Agent::install_builtin_agents(false).unwrap(); + Agent::install_builtin_agents(InstallMode::Skip).unwrap(); assert!( !agents_dir.join("coder").join("config.yaml").exists(), @@ -9264,12 +9487,12 @@ mod tests { fn install_builtin_agents_removes_stale_graph_for_config_agent() { let _guard = TestConfigDirGuard::new(); - Agent::install_builtin_agents(false).unwrap(); + Agent::install_builtin_agents(InstallMode::Skip).unwrap(); let agents_dir = paths::agents_data_dir(); write(agents_dir.join("architect").join("graph.yaml"), "stale").unwrap(); - Agent::install_builtin_agents(false).unwrap(); + Agent::install_builtin_agents(InstallMode::Skip).unwrap(); assert!( !agents_dir.join("architect").join("graph.yaml").exists(), @@ -9285,7 +9508,7 @@ mod tests { let _guard = TestConfigDirGuard::new(); - Agent::install_builtin_agents(false).unwrap(); + Agent::install_builtin_agents(InstallMode::Skip).unwrap(); let agents_dir = paths::agents_data_dir(); // Simulate an upgrade from an older install whose coder shipped a @@ -9297,14 +9520,20 @@ mod tests { write(hooks_dir.join(BUILTIN_MANIFEST_FILE), "old-hook.sh\n").unwrap(); write(hooks_dir.join("old-hook.sh"), "stale").unwrap(); write(hooks_dir.join("user-hook.sh"), "user-owned").unwrap(); - // A custom agent outside the bundle is never reconciled, even with a - // manifest present. - let custom_hooks = agents_dir.join("myagent").join("hooks"); - create_dir_all(&custom_hooks).unwrap(); - write(custom_hooks.join(BUILTIN_MANIFEST_FILE), "custom.sh\n").unwrap(); - write(custom_hooks.join("custom.sh"), "keep").unwrap(); + // An agent directory carrying a builtin manifest but absent from the + // embed is a removed builtin agent: the sweep deletes exactly the + // manifest-owned files, sparing anything user-created alongside. + let removed_hooks = agents_dir.join("myagent").join("hooks"); + create_dir_all(&removed_hooks).unwrap(); + write(removed_hooks.join(BUILTIN_MANIFEST_FILE), "shipped.sh\n").unwrap(); + write(removed_hooks.join("shipped.sh"), "stale").unwrap(); + write(removed_hooks.join("user-note.sh"), "keep").unwrap(); + // A user agent never has a manifest, so the sweep must not touch it. + let user_hooks = agents_dir.join("useragent").join("hooks"); + create_dir_all(&user_hooks).unwrap(); + write(user_hooks.join("mine.sh"), "keep").unwrap(); - Agent::install_builtin_agents(false).unwrap(); + Agent::install_builtin_agents(InstallMode::Skip).unwrap(); assert!( !hooks_dir.join("old-hook.sh").exists(), @@ -9319,10 +9548,18 @@ mod tests { "an empty shipped set removes the manifest itself" ); assert!( - custom_hooks.join("custom.sh").exists(), - "custom agents outside the bundle must survive reconciliation" + !removed_hooks.join("shipped.sh").exists(), + "manifest-owned hooks of a removed builtin agent must be swept" + ); + assert!(!removed_hooks.join(BUILTIN_MANIFEST_FILE).exists()); + assert!( + removed_hooks.join("user-note.sh").exists(), + "user files beside a removed agent's manifest must survive the sweep" + ); + assert!( + user_hooks.join("mine.sh").exists(), + "agent dirs without a manifest must never be touched" ); - assert!(custom_hooks.join(BUILTIN_MANIFEST_FILE).exists()); } #[test] @@ -9366,7 +9603,7 @@ mod tests { #[serial] fn bundled_assets_pin_task_queue_guidance() { let _guard = TestConfigDirGuard::new(); - Agent::install_builtin_agents(false).unwrap(); + Agent::install_builtin_agents(InstallMode::Skip).unwrap(); // 1. Architect config: the parallel-mode "Task-queue mirroring" // section with all five HARD RULES. @@ -9435,7 +9672,7 @@ mod tests { #[serial] fn bundled_assets_pin_review_incomplete_escalation() { let _guard = TestConfigDirGuard::new(); - Agent::install_builtin_agents(false).unwrap(); + Agent::install_builtin_agents(InstallMode::Skip).unwrap(); // Every anchor is a single-line prefix so YAML re-wrapping cannot // break the pin; clauses that wrap differently per config are split @@ -9599,7 +9836,7 @@ mod tests { #[serial] fn bundled_assets_pin_verification_commands_as_declared_variable() { let _guard = TestConfigDirGuard::new(); - Agent::install_builtin_agents(false).unwrap(); + Agent::install_builtin_agents(InstallMode::Skip).unwrap(); const VARIABLES_FORM: &str = "--variables {\"verification_commands\": \"[\\\"cargo test --all\\\""; @@ -9790,7 +10027,7 @@ mod tests { } let _guard = TestConfigDirGuard::new(); - Agent::install_builtin_agents(false).unwrap(); + Agent::install_builtin_agents(InstallMode::Skip).unwrap(); for (name, expected) in [ ("architect", 10), @@ -9909,7 +10146,7 @@ mod tests { let _guard = TestConfigDirGuard::new(); - Agent::install_builtin_agents(false).unwrap(); + Agent::install_builtin_agents(InstallMode::Skip).unwrap(); Skill::install_builtin_skills(false).unwrap(); let mut checked = Vec::new(); @@ -10136,7 +10373,7 @@ mod tests { let engine_seeded = ["initial_prompt", "output", "choice", "input"]; let _guard = TestConfigDirGuard::new(); - Agent::install_builtin_agents(false).unwrap(); + Agent::install_builtin_agents(InstallMode::Skip).unwrap(); let mut checked = Vec::new(); for entry in std::fs::read_dir(paths::agents_data_dir()).unwrap() { @@ -10255,13 +10492,24 @@ mod tests { // Scripts' load_state() prefers GRAPH_STATE_FILE over GRAPH_STATE, so an // inherited live state file (adversary verifying this repo) must not win. + // run_checks.py's review-stack flock defaults to ONE system-wide path; + // concurrent tests (or a real review running on this machine) would + // contend on it, and the losers' 5s-sleep retry loop burns the tight + // test deadlines. Every spawned script gets its own throwaway lock path. fn adversary_script_command(script: &str, state: &serde_json::Value) -> Command { - let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + static STACK_LOCK_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let path = Path::new(env!("CARGO_MANIFEST_DIR")) .join("assets/agents/adversary/scripts") .join(script); + let lock_path = env::temp_dir().join(format!( + "coyote-adversary-test-stack-{}-{}.lock", + std::process::id(), + STACK_LOCK_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); let mut cmd = Command::new("python3"); cmd.arg(&path) .env("GRAPH_STATE", state.to_string()) + .env("ADVERSARY_STACK_LOCK_PATH", &lock_path) .env_remove("GRAPH_STATE_FILE"); cmd } @@ -10853,6 +11101,67 @@ mod tests { ); } + /// ADVERSARY_STACK_LOCK_PATH must relocate the review-stack flock: + /// without it, every concurrent run_checks.py (parallel tests, or a real + /// review on the same machine) contends on the one system-wide lock path + /// and the losers' retry loop burns the total deadline budget. + #[test] + #[cfg(unix)] + fn adversary_run_checks_stack_lock_path_is_env_overridable() { + if !cmd_available("python3") || !cmd_available("sh") { + eprintln!("skipping: python3 or sh not available"); + return; + } + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let tmp = env::temp_dir().join(format!( + "coyote-adversary-run-checks-lockpath-{}-{unique}", + std::process::id() + )); + create_dir_all(&tmp).unwrap(); + // A not-yet-existing subdir pins the makedirs-on-override behavior. + let lock_path = tmp.join("override").join("stack.lock"); + let lock_env = lock_path.to_str().unwrap(); + let state = json!({"verification_commands": ["true"]}); + + // Two sequential runs with the same override: the lock fd is held + // only for the process lifetime, so the second run must acquire + // immediately — and both use the OVERRIDDEN location. + for run in 0..2 { + let out = run_adversary_script_env( + "run_checks.py", + &state, + &[("ADVERSARY_STACK_LOCK_PATH", lock_env)], + ); + assert_eq!( + out["exec_results"][0]["exit"], 0, + "run {run}: the declared command must run to completion: {out}" + ); + assert!( + lock_path.exists(), + "run {run}: the stack lock must be taken at the overridden path {}", + lock_path.display() + ); + } + + // No declared commands → the lock is skipped entirely: a fresh + // override path must never be created. + let skipped = tmp.join("skipped.lock"); + let _ = run_adversary_script_env( + "run_checks.py", + &json!({}), + &[("ADVERSARY_STACK_LOCK_PATH", skipped.to_str().unwrap())], + ); + assert!( + !skipped.exists(), + "with no declared commands the lock must be skipped, but {} was created", + skipped.display() + ); + let _ = remove_dir_all(&tmp); + } + /// TOTAL_DEADLINE_SECS in run_checks.py and the run_checks node's /// `timeout:` in graph.yaml live in different files; if the script's /// deadline ever creeps past the node timeout, the executor kills the diff --git a/src/config/role.rs b/src/config/role.rs index eaa8123..a2fa11c 100644 --- a/src/config/role.rs +++ b/src/config/role.rs @@ -2,6 +2,7 @@ use super::*; use crate::client::{Message, MessageContent, MessageRole, Model}; use crate::config::builtin_manifest; +use crate::config::conflict::{self, InstallMode, StickyMode}; use crate::function::write_file_atomic; use crate::hooks::HooksMap; @@ -179,7 +180,8 @@ impl Role { pub fn builtin(name: &str) -> Result { let content = RolesAsset::get(&format!("{name}.md")) .ok_or_else(|| anyhow!("Unknown role `{name}`"))?; - let content = unsafe { std::str::from_utf8_unchecked(&content.data) }; + let content = + std::str::from_utf8(&content.data).expect("bundled role asset is not valid UTF-8"); Ok(Role::new(name, content)) } @@ -193,7 +195,7 @@ impl Role { /// reconciles ones a release stopped shipping. This is the only builtin /// install machinery roles have: general role installation deliberately /// does not exist (builtin roles load straight from the embed). - pub fn install_builtin_role_hooks(force: bool) -> Result<()> { + pub fn install_builtin_role_hooks(mode: InstallMode, sticky: &mut StickyMode) -> Result<()> { let shipped: Vec<(String, String)> = RolesAsset::iter() .filter_map(|file| { let name = file.as_ref().strip_prefix("hooks/")?; @@ -201,12 +203,13 @@ impl Role { return None; } let embedded = RolesAsset::get(file.as_ref())?; - let content = unsafe { std::str::from_utf8_unchecked(&embedded.data) }; + let content = std::str::from_utf8(&embedded.data) + .expect("bundled role hook asset is not valid UTF-8"); Some((name.to_string(), content.to_string())) }) .collect(); - install_and_reconcile_role_hooks(&paths::roles_dir().join("hooks"), &shipped, force) + install_and_reconcile_role_hooks(&paths::roles_dir().join("hooks"), &shipped, mode, sticky) } pub fn has_args(&self) -> bool { @@ -641,10 +644,11 @@ fn parse_structure_prompt(prompt: &str) -> (&str, Vec<(&str, &str)>) { /// Only direct children of the hooks/ directory are supported: they are the /// only shape the builtin manifest tracks, so nested asset paths are skipped /// by the caller rather than installed unreconciled. -fn install_and_reconcile_role_hooks( +pub(crate) fn install_and_reconcile_role_hooks( dir: &Path, shipped: &[(String, String)], - force: bool, + mode: InstallMode, + sticky: &mut StickyMode, ) -> Result<()> { if !shipped.is_empty() { info!("Installing built-in role hooks in {}", dir.display()); @@ -652,7 +656,9 @@ fn install_and_reconcile_role_hooks( let mut written = BTreeSet::new(); for (name, content) in shipped { let path = dir.join(name); - if path.exists() && !force { + if path.exists() + && !conflict::should_replace_existing(&path, content, "roles", mode, sticky)? + { debug!( "Role hook file already exists, skipping: {}", path.display() @@ -661,7 +667,8 @@ fn install_and_reconcile_role_hooks( } ensure_parent_exists(&path)?; info!("Creating role hook file: {}", path.display()); - write_file_atomic(&path, content, Some(0o755))?; + write_file_atomic(&path, content, None)?; + set_executable_bit_if_script(&path)?; written.insert(name.clone()); } @@ -1049,8 +1056,13 @@ Input 1 fn role_hooks_install_writes_scripts_and_manifest() { let dir = hooks_fixture_dir("role-hooks-install-"); - install_and_reconcile_role_hooks(&dir, &fixture(&[("a.sh", "#!/bin/sh\n")]), false) - .unwrap(); + install_and_reconcile_role_hooks( + &dir, + &fixture(&[("a.sh", "#!/bin/sh\n")]), + InstallMode::Skip, + &mut StickyMode::None, + ) + .unwrap(); assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "#!/bin/sh\n"); assert_eq!( @@ -1075,6 +1087,41 @@ Input 1 let _ = fs::remove_dir_all(dir.parent().unwrap()); } + /// The installer writes plain and then applies the shared exec-bit + /// predicate: users may keep non-executable support files in hooks + /// directories, so only recognized script extensions get 0755. + #[cfg(unix)] + #[test] + fn role_hooks_install_leaves_non_script_files_non_executable() { + use std::os::unix::fs::PermissionsExt; + + let dir = hooks_fixture_dir("role-hooks-nonscript-"); + + install_and_reconcile_role_hooks( + &dir, + &fixture(&[("README.md", "docs\n"), ("run.sh", "#!/bin/sh\n")]), + InstallMode::Skip, + &mut StickyMode::None, + ) + .unwrap(); + + let readme_mode = fs::metadata(dir.join("README.md")) + .unwrap() + .permissions() + .mode(); + assert_eq!( + readme_mode & 0o111, + 0, + "a non-script file in a hooks dir must stay non-executable" + ); + let script_mode = fs::metadata(dir.join("run.sh")) + .unwrap() + .permissions() + .mode(); + assert_eq!(script_mode & 0o777, 0o755); + let _ = fs::remove_dir_all(dir.parent().unwrap()); + } + #[test] fn role_hooks_install_overwrites_only_with_force() { let dir = hooks_fixture_dir("role-hooks-force-"); @@ -1082,14 +1129,50 @@ Input 1 create_dir_all(&dir).unwrap(); fs::write(dir.join("a.sh"), "SENTINEL").unwrap(); - install_and_reconcile_role_hooks(&dir, &shipped, false).unwrap(); + install_and_reconcile_role_hooks(&dir, &shipped, InstallMode::Skip, &mut StickyMode::None) + .unwrap(); assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "SENTINEL"); - install_and_reconcile_role_hooks(&dir, &shipped, true).unwrap(); + install_and_reconcile_role_hooks(&dir, &shipped, InstallMode::Force, &mut StickyMode::None) + .unwrap(); assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "new content\n"); let _ = fs::remove_dir_all(dir.parent().unwrap()); } + #[test] + #[serial_test::serial] + fn role_hooks_prompt_mode_asks_per_conflict_and_honors_sticky() { + use crate::config::conflict::prompt_script; + + let dir = hooks_fixture_dir("role-hooks-prompt-"); + let shipped = fixture(&[("a.sh", "new content\n"), ("b.sh", "new content\n")]); + create_dir_all(&dir).unwrap(); + fs::write(dir.join("a.sh"), "local a").unwrap(); + fs::write(dir.join("b.sh"), "local b").unwrap(); + + // First conflict answered per-file, second by the sticky replace-all. + let script = prompt_script::install(&["replace-all"]); + let mut sticky = StickyMode::None; + install_and_reconcile_role_hooks(&dir, &shipped, InstallMode::Prompt, &mut sticky).unwrap(); + assert_eq!(prompt_script::prompts_asked(), 1); + assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "new content\n"); + assert_eq!(read_to_string(dir.join("b.sh")).unwrap(), "new content\n"); + assert_eq!(sticky, StickyMode::ReplaceAll); + drop(script); + + // A sticky mode carried in from an earlier location suppresses all + // prompting: `--install-builtins hooks` shares one sticky scope + // across the global, role, and agent hook locations. + fs::write(dir.join("a.sh"), "local again").unwrap(); + let script = prompt_script::install(&[]); + let mut sticky = StickyMode::KeepAll; + install_and_reconcile_role_hooks(&dir, &shipped, InstallMode::Prompt, &mut sticky).unwrap(); + assert_eq!(prompt_script::prompts_asked(), 0); + assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "local again"); + drop(script); + let _ = fs::remove_dir_all(dir.parent().unwrap()); + } + #[test] fn role_hooks_reconcile_removes_dropped_hook_and_keeps_user_files() { let dir = hooks_fixture_dir("role-hooks-reconcile-"); @@ -1097,13 +1180,19 @@ Input 1 install_and_reconcile_role_hooks( &dir, &fixture(&[("keep.sh", "#!/bin/sh\n"), ("drop.sh", "#!/bin/sh\n")]), - false, + InstallMode::Skip, + &mut StickyMode::None, ) .unwrap(); fs::write(dir.join("user.sh"), "user-owned").unwrap(); - install_and_reconcile_role_hooks(&dir, &fixture(&[("keep.sh", "#!/bin/sh\n")]), false) - .unwrap(); + install_and_reconcile_role_hooks( + &dir, + &fixture(&[("keep.sh", "#!/bin/sh\n")]), + InstallMode::Skip, + &mut StickyMode::None, + ) + .unwrap(); assert!( !dir.join("drop.sh").exists(), @@ -1124,14 +1213,20 @@ Input 1 create_dir_all(&dir).unwrap(); fs::write(dir.join("a.sh"), "user-owned").unwrap(); - install_and_reconcile_role_hooks(&dir, &fixture(&[("a.sh", "#!/bin/sh\n")]), false) - .unwrap(); + install_and_reconcile_role_hooks( + &dir, + &fixture(&[("a.sh", "#!/bin/sh\n")]), + InstallMode::Skip, + &mut StickyMode::None, + ) + .unwrap(); assert!( !dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE).exists(), "a skipped pre-existing file must not be claimed by the manifest" ); - install_and_reconcile_role_hooks(&dir, &[], false).unwrap(); + install_and_reconcile_role_hooks(&dir, &[], InstallMode::Skip, &mut StickyMode::None) + .unwrap(); assert_eq!( read_to_string(dir.join("a.sh")).unwrap(), "user-owned", @@ -1144,7 +1239,8 @@ Input 1 fn role_hooks_empty_shipped_set_creates_nothing() { let dir = hooks_fixture_dir("role-hooks-empty-"); - install_and_reconcile_role_hooks(&dir, &[], false).unwrap(); + install_and_reconcile_role_hooks(&dir, &[], InstallMode::Skip, &mut StickyMode::None) + .unwrap(); assert!(!dir.exists(), "an empty shipped set must not create hooks/"); let _ = fs::remove_dir_all(dir.parent().unwrap()); diff --git a/src/config/skill.rs b/src/config/skill.rs index c705a11..b4b7a7d 100644 --- a/src/config/skill.rs +++ b/src/config/skill.rs @@ -100,7 +100,8 @@ impl Skill { let embedded_file = SkillsAsset::get(&file) .ok_or_else(|| anyhow!("Failed to load embedded skill file: {}", file.as_ref()))?; - let content = unsafe { std::str::from_utf8_unchecked(&embedded_file.data) }; + let content = std::str::from_utf8(&embedded_file.data) + .expect("bundled skill asset is not valid UTF-8"); let file_path = paths::skills_dir().join(file.as_ref()); if file_path.exists() && !force { diff --git a/src/config/tool_scope.rs b/src/config/tool_scope.rs index d106ae3..eee141f 100644 --- a/src/config/tool_scope.rs +++ b/src/config/tool_scope.rs @@ -918,7 +918,7 @@ mod tests { use super::*; use crate::config::mcp_tool_policy::LayerSource; use crate::function::ToolCall; - use crate::testing::{install_warn_collector, warn_messages}; + use crate::testing::{install_log_collector, warn_snapshot}; use std::sync::atomic::Ordering; #[test] @@ -1083,7 +1083,7 @@ mod tests { #[tokio::test] async fn catalog_items_warns_when_resource_listing_fails() { - install_warn_collector(); + install_log_collector(); let fixture = FixtureServer { resources_capability: true, fail_resource_listings: true, @@ -1093,7 +1093,7 @@ mod tests { runtime.catalog_items("fixture").await.unwrap(); - let messages = warn_messages().lock().unwrap(); + let messages = warn_snapshot(); assert!( messages .iter() @@ -1547,7 +1547,7 @@ mod tests { #[tokio::test] async fn prompt_catalog_degrades_when_one_server_fails() { - install_warn_collector(); + install_log_collector(); let healthy = FixtureServer { prompts_capability: true, ..Default::default() @@ -1564,7 +1564,7 @@ mod tests { assert_eq!(items.len(), 1); assert_eq!(items[0].server, "fixture"); - let messages = warn_messages().lock().unwrap(); + let messages = warn_snapshot(); assert!( messages .iter() @@ -1794,7 +1794,7 @@ mod tests { #[tokio::test] async fn catalog_items_warns_on_dead_context_patterns() { - install_warn_collector(); + install_log_collector(); let (mut runtime, _server) = fixture_runtime(FixtureServer::default()).await; let mut filter = single_layer_filter(LayerSource::Global, &["*"]); filter.push_layer(LayerSource::Session, &["zzz_*".to_string()]); @@ -1802,7 +1802,7 @@ mod tests { runtime.catalog_items("fixture").await.unwrap(); - let messages = warn_messages().lock().unwrap(); + let messages = warn_snapshot(); assert!( messages.iter().any(|msg| msg.contains("'zzz_*'") && msg.contains("session (.set)") diff --git a/src/function/agents.rs b/src/function/agents.rs index 4e05b38..3083d24 100644 --- a/src/function/agents.rs +++ b/src/function/agents.rs @@ -4,7 +4,7 @@ use crate::client::{Model, ModelType, call_chat_completions}; use crate::config::{ Agent, AgentVariable, AgentVariables, AppState, Input, RequestContext, Role, RoleLike, default_max_agent_depth, effective_max_concurrent_jobs, jobs_enabled, - list_agents_with_descriptions, load_agent_variables, paths, + list_agents_with_descriptions, load_agent_variables, }; use crate::hooks::{self, HookEvent, ResolvedHook}; use crate::supervisor::mailbox::{Envelope, EnvelopePayload, Inbox, PeerRegistry, graph_agent_id}; @@ -15,7 +15,8 @@ use crate::utils::{AbortSignal, create_abort_signal, wait_abort_signal, wait_use use crate::graph; use crate::repl::DEFAULT_CONTINUATION_PROMPT; use anyhow::{Context, Result, anyhow, bail}; -use chrono::{SecondsFormat, Utc}; +use chrono::Utc; +use hooks::McpServerHooks; use indexmap::IndexMap; use log::{debug, warn}; use parking_lot::RwLock; @@ -961,10 +962,11 @@ async fn populate_agent_mcp_runtime(ctx: &mut RequestContext, server_ids: &[Stri }) .unwrap_or_default(); + let mcp_hooks = McpServerHooks::resolve(ctx); for (id, spec) in server_specs { let handle = app .mcp_factory - .acquire(&id, &spec, app.mcp_log_path.as_deref()) + .acquire(&id, &spec, app.mcp_log_path.as_deref(), &mcp_hooks) .await?; ctx.tool_scope.mcp_runtime.insert(id, handle); } @@ -997,44 +999,15 @@ fn sync_agent_functions_to_ctx(ctx: &mut RequestContext) -> Result<()> { Ok(()) } -/// Mirrors the base-env contract `hooks::fire` assembles from a live context, -/// for detached call sites that dispatch pre-resolved hook snapshots after -/// the context they were resolved from is gone. The names are captured by -/// the caller when it takes the snapshot; the timestamp is taken here, at -/// fire time, so it reflects when the event actually happened. -pub(crate) fn hook_base_envs( - event: HookEvent, - session_name: Option<&str>, - agent_name: Option<&str>, -) -> Vec<(String, String)> { - let mut envs = vec![ - ("COYOTE_EVENT".to_string(), event.as_str().to_string()), - ( - "COYOTE_CONFIG_DIR".to_string(), - paths::config_dir().display().to_string(), - ), - ( - "COYOTE_EVENT_TIMESTAMP".to_string(), - Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), - ), - ]; - if let Some(name) = session_name { - envs.push(("COYOTE_SESSION_ID".to_string(), name.to_string())); - } - if let Some(name) = agent_name { - envs.push(("COYOTE_AGENT_NAME".to_string(), name.to_string())); - } - envs -} - -/// Pre-resolved `agent.completed`/`agent.failed` snapshots for a spawned -/// agent's result arms, captured from the child context before the spawn -/// task takes ownership of it. Resolving against the child keeps global -/// hooks gated by the child's own whitelist, the same gate every other -/// event fired from the child's context goes through. +/// Pre-resolved `agent.completed`/`agent.interrupted`/`agent.failed` +/// snapshots for a spawned agent's result arms, captured from the child +/// context before the spawn task takes ownership of it. Resolving against +/// the child keeps global hooks gated by the child's own whitelist, the +/// same gate every other event fired from the child's context goes through. struct SpawnResultHooks { completed: Vec, failed: Vec, + interrupted: Vec, session_name: Option, agent_id: String, agent_name: String, @@ -1045,6 +1018,7 @@ impl SpawnResultHooks { Self { completed: child_ctx.resolved_hooks(HookEvent::AgentCompleted), failed: child_ctx.resolved_hooks(HookEvent::AgentFailed), + interrupted: child_ctx.resolved_hooks(HookEvent::AgentInterrupted), session_name: child_ctx .session .as_ref() @@ -1054,14 +1028,16 @@ impl SpawnResultHooks { } } - /// Fires `agent.completed`, or `agent.failed` when `error` is given. - fn fire(self, error: Option<&str>) { + fn fire(self, error: Option<&str>, child_abort: &AbortSignal) { let mut extras = vec![ ("COYOTE_AGENT_ID", self.agent_id), ("COYOTE_AGENT_NAME", self.agent_name.clone()), ]; let (event, resolved) = match error { None => (HookEvent::AgentCompleted, self.completed), + Some(_) if child_abort.aborted_ctrlc() => { + (HookEvent::AgentInterrupted, self.interrupted) + } Some(error) => { extras.push(("COYOTE_AGENT_ERROR", error.to_string())); (HookEvent::AgentFailed, self.failed) @@ -1070,7 +1046,7 @@ impl SpawnResultHooks { hooks::fire_resolved( event, resolved, - hook_base_envs(event, self.session_name.as_deref(), Some(&self.agent_name)), + hooks::base_envs_parts(event, self.session_name.as_deref(), Some(&self.agent_name)), &extras, None, ); @@ -1246,6 +1222,7 @@ async fn handle_spawn(ctx: &mut RequestContext, args: &Value) -> Result { let spawn_agent_id = agent_id.clone(); let spawn_agent_name = agent_name.clone(); let spawn_abort = child_abort.clone(); + let hook_abort = child_abort.clone(); let spawn_notifications = Arc::clone(&ctx.notification_queue); let child_supervisor = child_ctx.supervisor.clone(); @@ -1268,8 +1245,8 @@ async fn handle_spawn(ctx: &mut RequestContext, args: &Value) -> Result { }; let success = agent_result.exit_status == AgentExitStatus::Completed; match &agent_result.exit_status { - AgentExitStatus::Completed => result_hooks.fire(None), - AgentExitStatus::Failed(e) => result_hooks.fire(Some(e)), + AgentExitStatus::Completed => result_hooks.fire(None, &hook_abort), + AgentExitStatus::Failed(e) => result_hooks.fire(Some(e), &hook_abort), } spawn_notifications.push(agent_notification( &agent_result.id, @@ -1801,6 +1778,18 @@ fn handle_reply_escalation(ctx: &mut RequestContext, args: &Value) -> Result = envs.iter().map(|(key, _)| key.as_str()).collect(); assert!(keys.contains(&"COYOTE_EVENT")); assert!(keys.contains(&"COYOTE_CONFIG_DIR")); @@ -2390,7 +2430,7 @@ mod tests { .any(|(key, value)| key == "COYOTE_EVENT" && value == "agent.completed") ); - let envs = hook_base_envs(HookEvent::AgentFailed, Some("sess"), Some("bot")); + let envs = hooks::base_envs_parts(HookEvent::AgentFailed, Some("sess"), Some("bot")); assert!( envs.iter() .any(|(key, value)| key == "COYOTE_SESSION_ID" && value == "sess") @@ -3354,6 +3394,79 @@ mod tests { assert!(result.is_err()); } + /// `escalation.answered` belongs to the successful take -> reply arm + /// alone: exactly once per escalation, and never from the failure arms + /// (unknown id, already-taken id). + #[test] + #[serial] + fn reply_escalation_fires_answered_once_and_never_on_failure_arms() { + let _sink = hooks::test_sink::install(); + let marker = "t049_answered_once"; + let mut hooks_map = hooks::HooksMap::default(); + hooks_map.insert( + "escalation.answered".to_string(), + vec![hooks::HookDef { + name: marker.to_string(), + command: "true".to_string(), + }], + ); + let mut app = AppState::test_default(); + app.config = Arc::new(AppConfig { + hooks: hooks_map, + ..Default::default() + }); + let mut ctx = RequestContext::new(Arc::new(app), WorkingMode::Cmd); + let queue = Arc::new(EscalationQueue::new()); + let (tx, rx) = tokio::sync::oneshot::channel(); + queue.submit(EscalationRequest { + id: "esc_once".into(), + from_agent_id: "a1".into(), + from_agent_name: "explore".into(), + question: "[input] What do?".into(), + options: None, + reply_tx: tx, + }); + ctx.escalation_queue = Some(queue); + + // Unknown id: an error result, and no event. + let miss = handle_reply_escalation( + &mut ctx, + &json!({"escalation_id": "missing", "reply": "nope"}), + ) + .unwrap(); + assert_eq!(miss["status"], "error"); + assert!( + hook_captures_named(marker).is_empty(), + "a failed reply must not fire escalation.answered" + ); + + // The successful take -> reply arm fires it exactly once. + let ok = handle_reply_escalation( + &mut ctx, + &json!({"escalation_id": "esc_once", "reply": "do X"}), + ) + .unwrap(); + assert_eq!(ok["status"], "ok"); + assert_eq!(rx.blocking_recv().unwrap(), "do X"); + let answered = hook_captures_named(marker); + assert_eq!(answered.len(), 1, "{answered:?}"); + assert_eq!(answered[0].envs["COYOTE_ESCALATION_ID"], "esc_once"); + assert_eq!(answered[0].envs["COYOTE_ESCALATION_REPLY"], "do X"); + + // Replying again to the same id hits the already-taken arm: still one. + let again = handle_reply_escalation( + &mut ctx, + &json!({"escalation_id": "esc_once", "reply": "again"}), + ) + .unwrap(); + assert_eq!(again["status"], "error"); + assert_eq!( + hook_captures_named(marker).len(), + 1, + "a second reply must not re-fire escalation.answered" + ); + } + #[test] fn handle_task_create_simple() { let mut ctx = ctx_with_supervisor(4, 3); diff --git a/src/function/jobs.rs b/src/function/jobs.rs index c9e9a3b..dfae383 100644 --- a/src/function/jobs.rs +++ b/src/function/jobs.rs @@ -111,6 +111,18 @@ impl JobHookSnapshot { } } +fn fire_job_started(ctx: &RequestContext, job_id: &str, tool: &str) { + hooks::fire( + HookEvent::JobStarted, + ctx, + &[ + ("COYOTE_JOB_ID", job_id.to_string()), + ("COYOTE_TOOL_NAME", tool.to_string()), + ], + None, + ); +} + pub struct RingBuf { buf: Vec, capacity: usize, @@ -529,15 +541,7 @@ async fn handle_start(ctx: &mut RequestContext, args: &Value) -> Result { let notify_id = job_id.clone(); let notify_tool = tool.clone(); let hook_snapshot = JobHookSnapshot::resolve(ctx, &job_id, &tool); - hooks::fire( - HookEvent::JobStarted, - ctx, - &[ - ("COYOTE_JOB_ID", job_id.clone()), - ("COYOTE_TOOL_NAME", tool.clone()), - ], - None, - ); + fire_job_started(ctx, &job_id, &tool); tokio::spawn(async move { let result = run_mcp_job(job_ctx, server, inner_tool, inner_args).await; let success = result.is_ok(); @@ -559,15 +563,7 @@ async fn handle_start(ctx: &mut RequestContext, args: &Value) -> Result { let notify_id = job_id.clone(); let notify_tool = tool.clone(); let hook_snapshot = JobHookSnapshot::resolve(ctx, &job_id, &tool); - hooks::fire( - HookEvent::JobStarted, - ctx, - &[ - ("COYOTE_JOB_ID", job_id.clone()), - ("COYOTE_TOOL_NAME", tool.clone()), - ], - None, - ); + fire_job_started(ctx, &job_id, &tool); tokio::spawn(async move { let result = run_process_job(snapshot, Arc::clone(&task_state), task_buf).await; let success = matches!(&result, Ok(job_result) if job_result.exit_code == Some(0)); diff --git a/src/function/mod.rs b/src/function/mod.rs index d64b025..5c877ed 100644 --- a/src/function/mod.rs +++ b/src/function/mod.rs @@ -2694,7 +2694,7 @@ impl ToolCallTracker { } } -fn format_call_log(cmd_name: &str, cmd_args: &[String], json_data: &serde_json::Value) -> String { +fn format_call_log(cmd_name: &str, cmd_args: &[String], json_data: &Value) -> String { if *NO_COLOR { return format!("Call {cmd_name} {}", cmd_args.join(" ")); } @@ -2713,8 +2713,8 @@ fn format_call_log(cmd_name: &str, cmd_args: &[String], json_data: &serde_json:: ) } -fn format_json_colored_keys(value: &serde_json::Value) -> String { - let serde_json::Value::Object(map) = value else { +fn format_json_colored_keys(value: &Value) -> String { + let Value::Object(map) = value else { return dimmed_text(&value.to_string()); }; if map.is_empty() { @@ -3486,16 +3486,15 @@ mod tests { #[test] fn bundled_bash_tools_generate_declarations() { - let tools_dir = - std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/functions/tools"); + let tools_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/functions/tools"); let mut checked = Vec::new(); - for entry in std::fs::read_dir(&tools_dir).unwrap() { + for entry in fs::read_dir(&tools_dir).unwrap() { let path = entry.unwrap().path(); if path.extension().and_then(OsStr::to_str) != Some("sh") { continue; } let name = path.file_stem().unwrap().to_string_lossy().to_string(); - let src = std::fs::read_to_string(&path).unwrap(); + let src = fs::read_to_string(&path).unwrap(); let (_, declarations) = bash::build_bash_tool(&src, &name) .unwrap_or_else(|e| panic!("bundled tool '{name}' failed to parse: {e}")); assert!( @@ -4673,7 +4672,7 @@ mod tests { .unwrap() .expect("nonzero exit must return an error payload"); - let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + let json: Value = serde_json::from_str(&result).unwrap(); assert!( json["tool_call_error"] .as_str() @@ -4702,7 +4701,7 @@ mod tests { .unwrap() .expect("nonzero exit must return an error payload"); - let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + let json: Value = serde_json::from_str(&result).unwrap(); assert!( json["tool_call_error"] .as_str() @@ -4741,7 +4740,7 @@ mod tests { let elapsed = started.elapsed(); assert!(elapsed < Duration::from_secs(1), "took {elapsed:?}"); - let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + let json: Value = serde_json::from_str(&result).unwrap(); assert_eq!(json["tool_call_error"], "Tool call 'bash' aborted"); } @@ -4823,6 +4822,24 @@ mod tests { fn built_shims_resolve_paths_at_runtime_after_relocation() { use std::os::unix::fs::PermissionsExt; + // A shim written moments ago can still be open for writing somewhere + // in the process (schedule-dependent), making exec fail with + // ETXTBSY; retry briefly instead of flaking. + fn output_with_etxtbsy_retry(command: &mut Command) -> io::Result { + let mut attempts = 0; + loop { + match command.output() { + Err(err) + if err.kind() == io::ErrorKind::ExecutableFileBusy && attempts < 10 => + { + attempts += 1; + thread::sleep(Duration::from_millis(50)); + } + ret => return ret, + } + } + } + let root = temp_file("-shim-relocate-", ""); let home_a = root.join("home-a"); let config_a = home_a.join("coyote"); @@ -4886,14 +4903,14 @@ mod tests { return; } - let output = process::Command::new(&moved_shim) + let mut command = Command::new(&moved_shim); + command .arg("{}") .env_remove(&config_env) .env_remove(&functions_env) .env_remove("LLM_OUTPUT") - .env_remove("LLM_TOOL_DATA_FILE") - .output() - .unwrap(); + .env_remove("LLM_TOOL_DATA_FILE"); + let output = output_with_etxtbsy_retry(&mut command).unwrap(); let stdout = String::from_utf8_lossy(&output.stdout); assert!( output.status.success(), diff --git a/src/function/user_interaction.rs b/src/function/user_interaction.rs index 1e62d19..11f9fd7 100644 --- a/src/function/user_interaction.rs +++ b/src/function/user_interaction.rs @@ -1,5 +1,6 @@ use super::{FunctionDeclaration, JsonSchema}; use crate::config::RequestContext; +use crate::hooks::{self, HookEvent}; use crate::supervisor::escalation::{EscalationRequest, new_escalation_id}; use crate::utils::{ACP_SERVER, HEADLESS, queue_acp_permission}; @@ -278,22 +279,37 @@ async fn handle_escalated(ctx: &RequestContext, action: &str, args: &Value) -> R let escalation_id = new_escalation_id(); let (tx, rx) = oneshot::channel(); + let question = format!("[{action}] {question}"); let request = EscalationRequest { id: escalation_id.clone(), - from_agent_id, + from_agent_id: from_agent_id.clone(), from_agent_name: from_agent_name.clone(), - question: format!("[{action}] {question}"), + question: question.clone(), options, reply_tx: tx, }; root_queue.submit(request); + hooks::fire( + HookEvent::EscalationRaised, + ctx, + &[ + ("COYOTE_ESCALATION_ID", escalation_id), + ("COYOTE_ESCALATION_FROM_AGENT_ID", from_agent_id), + ("COYOTE_ESCALATION_FROM_AGENT_NAME", from_agent_name), + ("COYOTE_ESCALATION_QUESTION", question), + ], + None, + ); + await_escalation_reply(rx, timeout_secs).await } /// Waits for the parent's reply. `timeout_secs == 0` waits indefinitely; a /// dropped sender (the parent cancelled the request) resolves either way. +/// Both fallback arms resolve without touching the escalation queue, so +/// neither fires an escalation event: the request was never answered. async fn await_escalation_reply(rx: oneshot::Receiver, timeout_secs: u64) -> Result { let reply = if timeout_secs == 0 { rx.await @@ -346,8 +362,14 @@ fn parse_options(args: &Value) -> Result> { #[cfg(test)] mod tests { use super::*; + use crate::config::{AppConfig, AppState, WorkingMode}; + use crate::function::agents::handle_agent_tool; + use crate::hooks::{HookDef, HooksMap, test_sink}; + use crate::supervisor::escalation::EscalationQueue; + use serial_test::serial; use std::fs; use std::path::{Path, PathBuf}; + use std::sync::Arc; #[test] fn headless_select_returns_structured_json() { @@ -374,6 +396,160 @@ mod tests { assert_eq!(DEFAULT_ESCALATION_TIMEOUT_SECS, 0); } + fn ctx_with_escalation_hooks(marker: &str) -> RequestContext { + let mut hooks = HooksMap::default(); + for event in ["escalation.raised", "escalation.answered"] { + hooks.insert( + event.to_string(), + vec![HookDef { + name: format!("{marker}-{event}"), + command: "true".to_string(), + }], + ); + } + let mut app = AppState::test_default(); + app.config = Arc::new(AppConfig { + hooks, + ..Default::default() + }); + RequestContext::new(Arc::new(app), WorkingMode::Cmd) + } + + fn marker_captures(marker: &str, event: &str) -> Vec { + test_sink::snapshot() + .into_iter() + .filter(|capture| capture.hook_name == format!("{marker}-{event}")) + .collect() + } + + /// `escalation.raised` (child seam) and `escalation.answered` (parent + /// seam) must describe the same origin agent for one escalation id: the + /// answered envs come from the taken queue request, never from the + /// replying context. + #[tokio::test] + #[serial] + async fn escalation_raised_and_answered_agree_on_origin_envs() { + let _sink = test_sink::install(); + let marker = "esc-parity-k4w"; + + let queue = Arc::new(EscalationQueue::new()); + let mut child_ctx = ctx_with_escalation_hooks(marker); + child_ctx.escalation_queue = Some(Arc::clone(&queue)); + child_ctx.self_agent_id = Some("esc-child-1".to_string()); + let mut parent_ctx = ctx_with_escalation_hooks(marker); + parent_ctx.escalation_queue = Some(Arc::clone(&queue)); + + let raise_args = json!({ "question": "which db?" }); + let raise = handle_escalated(&child_ctx, "input", &raise_args); + let reply = async { + // Bounded poll: on the current-thread runtime the raise future + // submits before its first await, so this resolves immediately. + let mut escalation_id = None; + for _ in 0..1000 { + if let Some(entry) = queue.pending_summary().first() { + escalation_id = entry["escalation_id"].as_str().map(str::to_string); + break; + } + tokio::task::yield_now().await; + } + let escalation_id = escalation_id.expect("escalation must reach the queue"); + handle_agent_tool( + &mut parent_ctx, + "agent__reply_escalation", + &json!({ "escalation_id": escalation_id, "reply": "postgres" }), + ) + .await + .unwrap() + }; + let (raised_result, replied) = tokio::join!(raise, reply); + assert_eq!(raised_result.unwrap()["answer"], "postgres"); + assert_eq!(replied["status"], "ok"); + + let raised = marker_captures(marker, "escalation.raised"); + let answered = marker_captures(marker, "escalation.answered"); + assert_eq!(raised.len(), 1, "{raised:?}"); + assert_eq!(answered.len(), 1, "{answered:?}"); + for key in [ + "COYOTE_ESCALATION_ID", + "COYOTE_ESCALATION_FROM_AGENT_ID", + "COYOTE_ESCALATION_FROM_AGENT_NAME", + "COYOTE_ESCALATION_QUESTION", + ] { + assert!(raised[0].envs.contains_key(key), "{key} missing on raised"); + assert_eq!(raised[0].envs.get(key), answered[0].envs.get(key), "{key}"); + } + assert_eq!( + raised[0] + .envs + .get("COYOTE_ESCALATION_FROM_AGENT_ID") + .map(String::as_str), + Some("esc-child-1") + ); + assert_eq!( + raised[0] + .envs + .get("COYOTE_ESCALATION_QUESTION") + .map(String::as_str), + Some("[input] which db?") + ); + assert_eq!( + answered[0] + .envs + .get("COYOTE_ESCALATION_REPLY") + .map(String::as_str), + Some("postgres") + ); + assert!(!raised[0].envs.contains_key("COYOTE_ESCALATION_REPLY")); + } + + /// `COYOTE_ESCALATION_QUESTION` rides through the standard env + /// truncation: a huge question is trimmed to 2048 bytes on the raised + /// seam, never passed through whole. + #[tokio::test] + #[serial] + async fn escalation_question_env_is_truncated_to_2048_bytes() { + let _sink = test_sink::install(); + let marker = "esc-trunc-p9j"; + + let queue = Arc::new(EscalationQueue::new()); + let mut child_ctx = ctx_with_escalation_hooks(marker); + child_ctx.escalation_queue = Some(Arc::clone(&queue)); + child_ctx.self_agent_id = Some("esc-child-trunc".to_string()); + + let raise_args = json!({ "question": "q".repeat(3000) }); + let raise = handle_escalated(&child_ctx, "input", &raise_args); + let reply = async { + // Bounded poll: on the current-thread runtime the raise future + // submits before its first await, so this resolves immediately. + let mut request = None; + for _ in 0..1000 { + if let Some(entry) = queue.pending_summary().first() { + let id = entry["escalation_id"].as_str().unwrap().to_string(); + request = queue.take(&id); + break; + } + tokio::task::yield_now().await; + } + let request = request.expect("escalation must reach the queue"); + let _ = request.reply_tx.send("ok".to_string()); + }; + let (raised_result, ()) = tokio::join!(raise, reply); + assert_eq!(raised_result.unwrap()["answer"], "ok"); + + let raised = marker_captures(marker, "escalation.raised"); + assert_eq!(raised.len(), 1, "{raised:?}"); + let question = raised[0] + .envs + .get("COYOTE_ESCALATION_QUESTION") + .expect("QUESTION env must be present on escalation.raised"); + assert_eq!( + question.len(), + 2048, + "QUESTION env must be 2048-byte truncated" + ); + assert!(question.starts_with("[input] qqq")); + } + #[tokio::test(start_paused = true)] async fn zero_timeout_waits_indefinitely_for_the_reply() { let (tx, rx) = oneshot::channel(); diff --git a/src/graph/agent.rs b/src/graph/agent.rs index edce152..029198c 100644 --- a/src/graph/agent.rs +++ b/src/graph/agent.rs @@ -50,16 +50,32 @@ impl AgentNodeExecutor { ) .await; if let Err(e) = &result { - hooks::fire( - HookEvent::AgentFailed, - parent_ctx, - &[ - ("COYOTE_AGENT_ID", agent_hook_id), - ("COYOTE_AGENT_NAME", node.agent.clone()), - ("COYOTE_AGENT_ERROR", format!("{e:#}")), - ], - None, - ); + if parent_ctx + .session_abort + .as_ref() + .is_some_and(|signal| signal.aborted_ctrlc()) + { + hooks::fire( + HookEvent::AgentInterrupted, + parent_ctx, + &[ + ("COYOTE_AGENT_ID", agent_hook_id), + ("COYOTE_AGENT_NAME", node.agent.clone()), + ], + None, + ); + } else { + hooks::fire( + HookEvent::AgentFailed, + parent_ctx, + &[ + ("COYOTE_AGENT_ID", agent_hook_id), + ("COYOTE_AGENT_NAME", node.agent.clone()), + ("COYOTE_AGENT_ERROR", format!("{e:#}")), + ], + None, + ); + } } outcome_from(node_id, node, state_manager, result) } @@ -338,10 +354,12 @@ fn apply_state_updates(node: &AgentNode, state_manager: &mut StateManager, outpu mod tests { use super::super::types::AgentNode; use super::*; - use crate::config::{AppState, WorkingMode, default_max_agent_depth}; + use crate::config::{AppConfig, AppState, WorkingMode, default_max_agent_depth}; + use crate::hooks::{HookDef, HooksMap, test_sink}; use crate::supervisor::mailbox::{Inbox, PeerRegistry, graph_agent_id}; - use crate::testing::{install_warn_collector, warn_messages}; + use crate::testing::{install_log_collector, warn_snapshot}; use serde_json::json; + use serial_test::serial; use std::collections::HashMap; use std::sync::Arc; @@ -416,6 +434,95 @@ mod tests { RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd) } + fn ctx_with_agent_hooks(marker: &str) -> RequestContext { + let mut hooks = HooksMap::default(); + for event in ["agent.interrupted", "agent.failed"] { + hooks.insert( + event.to_string(), + vec![HookDef { + name: format!("{marker}-{event}"), + command: "true".to_string(), + }], + ); + } + let mut app = AppState::test_default(); + app.config = Arc::new(AppConfig { + hooks, + ..Default::default() + }); + RequestContext::new(Arc::new(app), WorkingMode::Cmd) + } + + /// A fired session signal (the user's interrupt) reclassifies a failed + /// agent node: `agent.interrupted` fires instead of `agent.failed`. + #[tokio::test] + #[serial] + async fn execute_failure_with_fired_session_abort_fires_agent_interrupted() { + let _sink = test_sink::install(); + let marker = "graph-agent-int-z2f"; + let mut ctx = ctx_with_agent_hooks(marker); + // Past max depth the node fails before touching agent config on disk. + ctx.current_depth = default_max_agent_depth(); + let session = create_abort_signal(); + session.set_ctrlc(); + ctx.session_abort = Some(session); + let node = node_with("hi", None); + let mut state = manager_with(&[]); + + AgentNodeExecutor::execute("test_node", &node, &mut state, &mut ctx, false) + .await + .expect_err("agent past max depth should fail"); + + let captures: Vec<_> = test_sink::snapshot() + .into_iter() + .filter(|capture| capture.hook_name.starts_with(marker)) + .collect(); + assert_eq!(captures.len(), 1, "{captures:?}"); + assert_eq!(captures[0].hook_name, format!("{marker}-agent.interrupted")); + assert_eq!( + captures[0] + .envs + .get("COYOTE_AGENT_NAME") + .map(String::as_str), + Some("test_agent") + ); + assert!( + !captures[0].envs.contains_key("COYOTE_AGENT_ERROR"), + "an interruption is not a failure and carries no error env" + ); + } + + /// The inverse gate: a failure whose session signal never fired stays + /// `agent.failed` with its error env — the presence of a signal alone + /// must not reclassify a real failure as an interruption. + #[tokio::test] + #[serial] + async fn execute_failure_without_fired_session_abort_fires_agent_failed() { + let _sink = test_sink::install(); + let marker = "graph-agent-fail-inv-x7q"; + let mut ctx = ctx_with_agent_hooks(marker); + // Past max depth the node fails before touching agent config on disk. + ctx.current_depth = default_max_agent_depth(); + ctx.session_abort = Some(create_abort_signal()); + let node = node_with("hi", None); + let mut state = manager_with(&[]); + + AgentNodeExecutor::execute("test_node", &node, &mut state, &mut ctx, false) + .await + .expect_err("agent past max depth should fail"); + + let captures: Vec<_> = test_sink::snapshot() + .into_iter() + .filter(|capture| capture.hook_name.starts_with(marker)) + .collect(); + assert_eq!(captures.len(), 1, "{captures:?}"); + assert_eq!(captures[0].hook_name, format!("{marker}-agent.failed")); + assert!( + captures[0].envs.contains_key("COYOTE_AGENT_ERROR"), + "a real failure keeps its error env" + ); + } + fn retryable_node(max_attempts: u32) -> AgentNode { let mut node = node_with("hi", None); node.max_attempts = max_attempts; @@ -676,7 +783,7 @@ mod tests { #[tokio::test] async fn retry_transient_warns_once_per_retried_attempt_with_the_context_chain() { - install_warn_collector(); + install_log_collector(); let mut ctx = plain_ctx(); let node = retryable_node(3); let mut attempts = 0u32; @@ -700,12 +807,9 @@ mod tests { .expect_err("all attempts fail"); assert_eq!(attempts, 3); - let warns: Vec = warn_messages() - .lock() - .unwrap() - .iter() + let warns: Vec = warn_snapshot() + .into_iter() .filter(|m| m.contains("'warn_capture'")) - .cloned() .collect(); assert_eq!( warns.len(), @@ -861,7 +965,7 @@ mod tests { #[test] fn outcome_from_fallback_warn_names_the_node_and_the_fallback_target() { - install_warn_collector(); + install_log_collector(); let mut node = node_with("hi", None); node.fallback = Some("recover".into()); let mut state = manager_with(&[]); @@ -875,12 +979,9 @@ mod tests { .unwrap(); assert_eq!(outcome, AgentExecutionOutcome::FellBack("recover".into())); - let warns: Vec = warn_messages() - .lock() - .unwrap() - .iter() + let warns: Vec = warn_snapshot() + .into_iter() .filter(|m| m.contains("'fallback_warn_capture'")) - .cloned() .collect(); assert_eq!(warns.len(), 1, "exactly one fallback warn: {warns:?}"); assert!( diff --git a/src/graph/executor.rs b/src/graph/executor.rs index 686b74b..a840280 100644 --- a/src/graph/executor.rs +++ b/src/graph/executor.rs @@ -11,7 +11,6 @@ use super::user_interaction::{ApprovalNodeExecutor, InputNodeExecutor}; use super::validator::{AgentValidationContext, GraphValidator}; use super::wall_clock; use crate::config::{AgentVariable, AgentVariables, RenderMode, RequestContext}; -use crate::function::agents::hook_base_envs; use crate::hooks::{self, HookEvent, ResolvedHook}; use crate::supervisor::mailbox::{Inbox, PeerAssignment, PeerRegistry, graph_agent_id}; use crate::utils::{AbortSignal, wait_abort_signal, wait_user_interrupt}; @@ -550,7 +549,7 @@ impl NodeHookEmitter { hooks::fire_resolved( event, resolved.to_vec(), - hook_base_envs( + hooks::base_envs_parts( event, self.session_name.as_deref(), self.agent_name.as_deref(), @@ -1133,14 +1132,12 @@ mod integration_tests { use crate::hooks::HookDef; #[cfg(unix)] use crate::supervisor::{JobHandle, JobResult, JobState, JobStatus, Supervisor, notification}; - use crate::utils::{create_abort_signal, get_env_name, temp_file}; + use crate::utils::{create_abort_signal, temp_file}; use parking_lot::Mutex; use serial_test::serial; - use std::env; use std::fs; #[cfg(unix)] use std::mem; - use std::time::{SystemTime, UNIX_EPOCH}; fn cmd_available(name: &str) -> bool { which::which(name).is_ok() @@ -1268,7 +1265,7 @@ nodes: eprintln!("skipping: bash or python3 not available"); return; } - let _guard = TestConfigDirGuard::new(); + let _guard = TestConfigDirGuard::new("graph-executor-tests"); materialize_probe_agent(0.0); let ws = TestWorkspace::new(); ws.write_script("dispatcher.sh", "#!/bin/bash\necho '{}'\n"); @@ -1619,7 +1616,7 @@ nodes: /// debug log, and a well-formed sibling on the same event still fires. #[tokio::test] async fn malformed_graph_yaml_hook_is_skipped_without_breaking_the_node() { - crate::testing::install_warn_collector(); + crate::testing::install_log_collector(); let _sink = hooks::test_sink::install(); let mut ctx = make_ctx(); @@ -1660,10 +1657,7 @@ nodes: assert_eq!(valid.len(), 1, "{valid:?}"); assert_eq!(valid[0].envs["COYOTE_NODE_ID"], "done"); - let debugs: Vec = crate::testing::debug_messages() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .clone(); + let debugs = crate::testing::debug_snapshot(); assert!( debugs .iter() @@ -1685,7 +1679,7 @@ nodes: eprintln!("skipping: python3 not available"); return; } - let _guard = TestConfigDirGuard::new(); + let _guard = TestConfigDirGuard::new("graph-executor-tests"); materialize_probe_agent(0.0); let ws = TestWorkspace::new(); @@ -2368,47 +2362,7 @@ nodes: assert_eq!(events[0].event, "job_completed"); } - struct TestConfigDirGuard { - key: String, - previous: Option, - path: PathBuf, - } - - impl TestConfigDirGuard { - fn new() -> Self { - let key = get_env_name("config_dir"); - let previous = env::var_os(&key); - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let path = env::temp_dir().join(format!("coyote-graph-executor-tests-{unique}")); - fs::create_dir_all(&path).unwrap(); - unsafe { - env::set_var(&key, &path); - } - Self { - key, - previous, - path, - } - } - } - - impl Drop for TestConfigDirGuard { - fn drop(&mut self) { - if let Some(previous) = &self.previous { - unsafe { - env::set_var(&self.key, previous); - } - } else { - unsafe { - env::remove_var(&self.key); - } - } - let _ = fs::remove_dir_all(&self.path); - } - } + use crate::testing::TestConfigDirGuard; const PROBE_AGENT: &str = "timeout-probe"; @@ -2658,7 +2612,7 @@ nodes: eprintln!("skipping: bash or python3 not available"); return; } - let _guard = TestConfigDirGuard::new(); + let _guard = TestConfigDirGuard::new("graph-executor-tests"); materialize_probe_agent(5.0); let ws = TestWorkspace::new(); // The abort must land after the worker tasks exist (their retirement diff --git a/src/graph/map.rs b/src/graph/map.rs index 514eea5..8f2b821 100644 --- a/src/graph/map.rs +++ b/src/graph/map.rs @@ -746,14 +746,12 @@ mod chain_tests { use super::*; use crate::config::paths; use crate::config::{AppState, Role, WorkingMode}; - use crate::utils::{AbortSignal, create_abort_signal, get_env_name, temp_file}; + use crate::utils::{AbortSignal, create_abort_signal, temp_file}; use indexmap::IndexMap; use serde_json::json; use serial_test::serial; - use std::env; use std::fs; use std::path::PathBuf; - use std::time::{SystemTime, UNIX_EPOCH}; fn cmd_available(name: &str) -> bool { which::which(name).is_ok() @@ -1739,47 +1737,7 @@ nodes: } } - struct TestConfigDirGuard { - key: String, - previous: Option, - path: PathBuf, - } - - impl TestConfigDirGuard { - fn new() -> Self { - let key = get_env_name("config_dir"); - let previous = env::var_os(&key); - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let path = env::temp_dir().join(format!("coyote-graph-map-tests-{unique}")); - fs::create_dir_all(&path).unwrap(); - unsafe { - env::set_var(&key, &path); - } - Self { - key, - previous, - path, - } - } - } - - impl Drop for TestConfigDirGuard { - fn drop(&mut self) { - if let Some(previous) = &self.previous { - unsafe { - env::set_var(&self.key, previous); - } - } else { - unsafe { - env::remove_var(&self.key); - } - } - let _ = fs::remove_dir_all(&self.path); - } - } + use crate::testing::TestConfigDirGuard; const PROBE_AGENT: &str = "timeout-probe"; @@ -2031,7 +1989,7 @@ nodes: eprintln!("skipping: python3 not available"); return; } - let _guard = TestConfigDirGuard::new(); + let _guard = TestConfigDirGuard::new("graph-map-tests"); materialize_probe_agent(0.0); let h = Harness::new(&flagged_probe_graph(None)); let peers = manual_peer("worker"); @@ -2057,7 +2015,7 @@ nodes: eprintln!("skipping: python3 not available"); return; } - let _guard = TestConfigDirGuard::new(); + let _guard = TestConfigDirGuard::new("graph-map-tests"); materialize_probe_agent(0.0); let ws = TestWorkspace::new(); let yaml = format!( @@ -2107,7 +2065,7 @@ nodes: eprintln!("skipping: python3 not available"); return; } - let _guard = TestConfigDirGuard::new(); + let _guard = TestConfigDirGuard::new("graph-map-tests"); materialize_probe_agent(5.0); let h = Harness::new(&flagged_probe_graph(Some(1))); let (registry, assignments) = h.provision("worker", 2); @@ -2144,7 +2102,7 @@ nodes: eprintln!("skipping: python3 not available"); return; } - let _guard = TestConfigDirGuard::new(); + let _guard = TestConfigDirGuard::new("graph-map-tests"); materialize_probe_agent(1.5); let h = Harness::new(&flagged_probe_graph(Some(0))); let peers = manual_peer("worker"); @@ -2167,7 +2125,7 @@ nodes: eprintln!("skipping: python3 not available"); return; } - let _guard = TestConfigDirGuard::new(); + let _guard = TestConfigDirGuard::new("graph-map-tests"); materialize_probe_agent(0.0); let h = Harness::new(&format!( r#" @@ -2272,7 +2230,7 @@ nodes: eprintln!("skipping: python3 not available"); return; } - let _guard = TestConfigDirGuard::new(); + let _guard = TestConfigDirGuard::new("graph-map-tests"); materialize_echo_inputs_agent(); let parent = echo_inputs_parent_graph(" inputs:\n width: \"{{n}}\"\n"); @@ -2291,7 +2249,7 @@ nodes: eprintln!("skipping: python3 not available"); return; } - let _guard = TestConfigDirGuard::new(); + let _guard = TestConfigDirGuard::new("graph-map-tests"); materialize_echo_inputs_agent(); let parent = echo_inputs_parent_graph(""); diff --git a/src/hooks.rs b/src/hooks.rs index 4c97dee..f0cf11a 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -1,16 +1,20 @@ -use crate::config::{RequestContext, ensure_parent_exists, paths}; +use crate::config::conflict::{self, InstallMode, StickyMode}; +use crate::config::{ + RequestContext, builtin_manifest, ensure_parent_exists, paths, set_executable_bit_if_script, +}; use crate::function::write_file_atomic; -use anyhow::{Result, anyhow}; +use anyhow::{Error, Result, anyhow}; use chrono::{SecondsFormat, Utc}; use indexmap::IndexMap; use rand::distr::{Alphanumeric, SampleString}; use rust_embed::Embed; use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::atomic::{AtomicUsize, Ordering}; -use std::time::Duration; +use std::time::{Duration, SystemTime}; use tokio::io::AsyncWriteExt; use tokio::process::Command; use tokio::sync::Notify; @@ -19,22 +23,26 @@ use tokio::sync::Notify; #[folder = "assets/hooks/"] struct HookAssets; -pub fn install_builtin_hooks(force: bool) -> Result<()> { +pub fn install_builtin_hooks(mode: InstallMode, sticky: &mut StickyMode) -> Result<()> { info!( "Installing built-in example hooks in {}", paths::hooks_dir().display() ); let mut wrote_any = false; + let mut written = BTreeSet::new(); 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 content = std::str::from_utf8(&embedded_file.data) + .expect("bundled hook asset is not valid UTF-8"); let file_path = paths::hooks_dir().join(file.as_ref()); - if file_path.exists() && !force { + if file_path.exists() + && !conflict::should_replace_existing(&file_path, content, "hooks", mode, sticky)? + { debug!( "Hook file already exists, skipping: {}", file_path.display() @@ -44,11 +52,30 @@ pub fn install_builtin_hooks(force: bool) -> Result<()> { ensure_parent_exists(&file_path)?; info!("Creating hook file: {}", file_path.display()); - write_file_atomic(&file_path, content, Some(0o755))?; + write_file_atomic(&file_path, content, None)?; + set_executable_bit_if_script(&file_path)?; wrote_any = true; + if !file.as_ref().contains('/') { + written.insert(file.as_ref().to_string()); + } } - if wrote_any && paths::hooks_dir() != paths::config_dir().join("hooks") { + let shipped: BTreeSet = HookAssets::iter() + .map(|file| file.as_ref().to_string()) + .filter(|name| !name.contains('/')) + .collect(); + if let Err(err) = + builtin_manifest::reconcile_builtin_dir(&paths::hooks_dir(), &shipped, &written) + { + warn!( + "Failed to reconcile builtin hooks in {}: {err}", + paths::hooks_dir().display() + ); + } + + let hooks_dir = canonical_or_original(&paths::hooks_dir()); + let default_hooks_dir = canonical_or_original(&paths::config_dir().join("hooks")); + if wrote_any && hooks_dir != default_hooks_dir { warn!( "{} overrides the hooks dir: example scripts install to {}, but relative \ global-hook commands resolve against {}", @@ -61,6 +88,10 @@ pub fn install_builtin_hooks(force: bool) -> Result<()> { Ok(()) } +fn canonical_or_original(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) +} + /// A single named hook: an external command to run when its event fires. #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] pub struct HookDef { @@ -73,7 +104,6 @@ pub type HooksMap = IndexMap>; /// Every event hooks can be attached to. `as_str` yields the dotted name used /// as the key in `hooks:` maps and in `global_hooks` whitelist entries. -#[allow(dead_code)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HookEvent { TurnStarted, @@ -81,6 +111,7 @@ pub enum HookEvent { TurnInterrupted, TurnFailed, SessionStarted, + SessionResumed, SessionEnded, SessionCompressed, ToolStarted, @@ -91,13 +122,21 @@ pub enum HookEvent { LlmRequestFailed, AgentStarted, AgentCompleted, + AgentInterrupted, AgentFailed, + EscalationRaised, + EscalationAnswered, GraphNodeStarted, GraphNodeCompleted, GraphNodeFailed, JobStarted, JobCompleted, JobFailed, + RagSyncStarted, + RagSyncCompleted, + RagSyncFailed, + McpServerConnected, + McpServerFailed, } impl HookEvent { @@ -108,6 +147,7 @@ impl HookEvent { HookEvent::TurnInterrupted => "turn.interrupted", HookEvent::TurnFailed => "turn.failed", HookEvent::SessionStarted => "session.started", + HookEvent::SessionResumed => "session.resumed", HookEvent::SessionEnded => "session.ended", HookEvent::SessionCompressed => "session.compressed", HookEvent::ToolStarted => "tool.started", @@ -118,15 +158,57 @@ impl HookEvent { HookEvent::LlmRequestFailed => "llm.request.failed", HookEvent::AgentStarted => "agent.started", HookEvent::AgentCompleted => "agent.completed", + HookEvent::AgentInterrupted => "agent.interrupted", HookEvent::AgentFailed => "agent.failed", + HookEvent::EscalationRaised => "escalation.raised", + HookEvent::EscalationAnswered => "escalation.answered", HookEvent::GraphNodeStarted => "graph.node.started", HookEvent::GraphNodeCompleted => "graph.node.completed", HookEvent::GraphNodeFailed => "graph.node.failed", HookEvent::JobStarted => "job.started", HookEvent::JobCompleted => "job.completed", HookEvent::JobFailed => "job.failed", + HookEvent::RagSyncStarted => "rag.sync.started", + HookEvent::RagSyncCompleted => "rag.sync.completed", + HookEvent::RagSyncFailed => "rag.sync.failed", + HookEvent::McpServerConnected => "mcp.server.connected", + HookEvent::McpServerFailed => "mcp.server.failed", } } + + const ALL: [HookEvent; 31] = [ + HookEvent::TurnStarted, + HookEvent::TurnCompleted, + HookEvent::TurnInterrupted, + HookEvent::TurnFailed, + HookEvent::SessionStarted, + HookEvent::SessionResumed, + HookEvent::SessionEnded, + HookEvent::SessionCompressed, + HookEvent::ToolStarted, + HookEvent::ToolCompleted, + HookEvent::ToolFailed, + HookEvent::LlmRequestStarted, + HookEvent::LlmRequestCompleted, + HookEvent::LlmRequestFailed, + HookEvent::AgentStarted, + HookEvent::AgentCompleted, + HookEvent::AgentInterrupted, + HookEvent::AgentFailed, + HookEvent::EscalationRaised, + HookEvent::EscalationAnswered, + HookEvent::GraphNodeStarted, + HookEvent::GraphNodeCompleted, + HookEvent::GraphNodeFailed, + HookEvent::JobStarted, + HookEvent::JobCompleted, + HookEvent::JobFailed, + HookEvent::RagSyncStarted, + HookEvent::RagSyncCompleted, + HookEvent::RagSyncFailed, + HookEvent::McpServerConnected, + HookEvent::McpServerFailed, + ]; } /// An owned snapshot of one hook to run. Detached dispatch tasks, job @@ -141,6 +223,165 @@ pub struct ResolvedHook { pub cwd: PathBuf, } +#[derive(Debug, Clone, Default)] +pub struct RagSyncHooks { + started: Vec, + completed: Vec, + failed: Vec, + session_name: Option, + agent_name: Option, +} + +impl RagSyncHooks { + pub fn resolve(ctx: &RequestContext) -> Self { + Self { + started: ctx.resolved_hooks(HookEvent::RagSyncStarted), + completed: ctx.resolved_hooks(HookEvent::RagSyncCompleted), + failed: ctx.resolved_hooks(HookEvent::RagSyncFailed), + session_name: ctx + .session + .as_ref() + .map(|session| session.name().to_string()), + agent_name: ctx.agent.as_ref().map(|agent| agent.name().to_string()), + } + } + + pub fn resolve_for_agent( + global_hooks: &HooksMap, + gate: &[String], + agent_hooks: &HooksMap, + agent_name: &str, + ) -> Self { + let resolve = |event| { + resolve_hooks( + event, + global_hooks, + Some((gate, agent_name)), + None, + Some((agent_hooks, agent_name)), + ) + }; + Self { + started: resolve(HookEvent::RagSyncStarted), + completed: resolve(HookEvent::RagSyncCompleted), + failed: resolve(HookEvent::RagSyncFailed), + session_name: None, + agent_name: Some(agent_name.to_string()), + } + } + + pub fn fire_started(&self, rag_name: &str, rag_path: &str) { + self.fire( + HookEvent::RagSyncStarted, + &self.started, + rag_name, + rag_path, + &[], + ); + } + + pub fn fire_completed(&self, rag_name: &str, rag_path: &str, doc_count: usize) { + self.fire( + HookEvent::RagSyncCompleted, + &self.completed, + rag_name, + rag_path, + &[("COYOTE_RAG_DOC_COUNT", doc_count.to_string())], + ); + } + + pub fn fire_failed(&self, rag_name: &str, rag_path: &str, error: &Error) { + self.fire( + HookEvent::RagSyncFailed, + &self.failed, + rag_name, + rag_path, + &[("COYOTE_ERROR", format!("{error:#}"))], + ); + } + + fn fire( + &self, + event: HookEvent, + resolved: &[ResolvedHook], + rag_name: &str, + rag_path: &str, + extras: &[(&str, String)], + ) { + if resolved.is_empty() { + return; + } + let base_envs = base_envs_parts( + event, + self.session_name.as_deref(), + self.agent_name.as_deref(), + ); + let mut envs = vec![ + ("COYOTE_RAG_NAME", rag_name.to_string()), + ("COYOTE_RAG_PATH", rag_path.to_string()), + ]; + envs.extend(extras.iter().map(|(key, value)| (*key, value.clone()))); + fire_resolved(event, resolved.to_vec(), base_envs, &envs, None); + } +} + +#[derive(Debug, Clone, Default)] +pub struct McpServerHooks { + connected: Vec, + failed: Vec, + session_name: Option, + agent_name: Option, +} + +impl McpServerHooks { + pub fn resolve(ctx: &RequestContext) -> Self { + Self { + connected: ctx.resolved_hooks(HookEvent::McpServerConnected), + failed: ctx.resolved_hooks(HookEvent::McpServerFailed), + session_name: ctx + .session + .as_ref() + .map(|session| session.name().to_string()), + agent_name: ctx.agent.as_ref().map(|agent| agent.name().to_string()), + } + } + + pub fn fire_connected(&self, server: &str, transport: &str, reconnect: bool) { + let mut extras = vec![ + ("COYOTE_MCP_SERVER", server.to_string()), + ("COYOTE_MCP_TRANSPORT", transport.to_string()), + ]; + if reconnect { + extras.push(("COYOTE_MCP_RECONNECT", "true".to_string())); + } + self.fire(HookEvent::McpServerConnected, &self.connected, &extras); + } + + pub fn fire_failed(&self, server: &str, transport: &str, error: &Error, auth_required: bool) { + let mut extras = vec![ + ("COYOTE_MCP_SERVER", server.to_string()), + ("COYOTE_MCP_TRANSPORT", transport.to_string()), + ("COYOTE_ERROR", format!("{error:#}")), + ]; + if auth_required { + extras.push(("COYOTE_MCP_AUTH_REQUIRED", "true".to_string())); + } + self.fire(HookEvent::McpServerFailed, &self.failed, &extras); + } + + fn fire(&self, event: HookEvent, resolved: &[ResolvedHook], extras: &[(&str, String)]) { + if resolved.is_empty() { + return; + } + let base_envs = base_envs_parts( + event, + self.session_name.as_deref(), + self.agent_name.as_deref(), + ); + fire_resolved(event, resolved.to_vec(), base_envs, extras, None); + } +} + impl RequestContext { /// Hooks that apply to this context for `event`, in firing order: global /// (whitelisted through `global_hooks` when an agent is active), then @@ -150,7 +391,6 @@ impl RequestContext { /// when the active role was moved into (or restored by) a session, from /// the hooks snapshot the session captured via `Session::set_role` / /// `Session::load_from_ctx`. - #[allow(dead_code)] pub fn resolved_hooks(&self, event: HookEvent) -> Vec { resolve_hooks( event, @@ -183,13 +423,20 @@ fn resolve_hooks( let event_name = event.as_str(); let mut resolved = Vec::new(); + let agent_gate = match event { + HookEvent::EscalationRaised | HookEvent::EscalationAnswered => None, + _ => agent_gate, + }; + if let Some(defs) = global_hooks.get(event_name) { let cwd = paths::config_dir(); for def in defs { let full_name = format!("{event_name}.{}", def.name); let admitted = match agent_gate { None => true, - Some((gate, _)) => gate.contains(&full_name), + Some((gate, _)) => gate + .iter() + .any(|entry| gate_entry_admits(entry, event_name, &def.name, &full_name)), }; if admitted { resolved.push(ResolvedHook { @@ -206,6 +453,14 @@ fn resolve_hooks( { let prefix = format!("{event_name}."); for entry in gate { + if entry.contains('*') { + if !entry_is_valid_wildcard(entry) { + debug!( + "Ignoring invalid glob global hook whitelist entry '{entry}' for agent '{agent_name}'" + ); + } + continue; + } if let Some(name) = entry.strip_prefix(&prefix) { let known = global_hooks .get(event_name) @@ -237,6 +492,28 @@ fn resolve_hooks( resolved } +fn gate_entry_admits(entry: &str, event_name: &str, hook_name: &str, full_name: &str) -> bool { + if entry == full_name || entry == "*" || entry == "*.*" { + return true; + } + if let Some(name) = entry.strip_prefix("*.") { + return name == hook_name; + } + entry.strip_suffix(".*") == Some(event_name) +} + +fn entry_is_valid_wildcard(entry: &str) -> bool { + if entry == "*" || entry == "*.*" { + return true; + } + if let Some(name) = entry.strip_prefix("*.") { + return !name.contains('*'); + } + entry + .strip_suffix(".*") + .is_some_and(|event| HookEvent::ALL.iter().any(|known| known.as_str() == event)) +} + fn push_defs(event_name: &str, defs: &[HookDef], cwd: &Path, out: &mut Vec) { for def in defs { out.push(ResolvedHook { @@ -248,7 +525,6 @@ fn push_defs(event_name: &str, defs: &[HookDef], cwd: &Path, out: &mut Vec, @@ -294,6 +569,12 @@ pub fn fire_resolved( let envs: Vec<(String, String)> = envs .into_iter() .map(|(key, mut value)| { + // Values like escalation questions carry agent-authored text; + // an interior NUL makes `Command::spawn` fail on Unix, which + // would let a child suppress its own hooks. Strip them. + if value.contains('\0') { + value.retain(|c| c != '\0'); + } // `end` is a char boundary by construction, so `truncate` cannot panic. let end = truncate_env_value(&value).len(); value.truncate(end); @@ -352,7 +633,6 @@ impl Drop for SpawnAckGuard { /// or until `timeout` elapses. For exit-path call sites only (session end / /// final turn): this waits for child spawn, never completion. Hook children /// deliberately outlive coyote as orphans. -#[allow(dead_code)] pub async fn drain_pending(timeout: Duration) { let deadline = tokio::time::Instant::now() + timeout; while PENDING_SPAWNS.load(Ordering::SeqCst) > 0 { @@ -404,12 +684,37 @@ pub(crate) fn base_envs_parts( envs } +pub fn role_extras(ctx: &RequestContext) -> Vec<(&'static str, String)> { + ctx.role + .as_ref() + .map(|role| role.name()) + .filter(|name| !name.is_empty()) + .or_else(|| ctx.session.as_ref().and_then(|session| session.role_name())) + .map(|name| vec![("COYOTE_ROLE", name.to_string())]) + .unwrap_or_default() +} + async fn write_payload_file(path: &Path, json: &str) -> std::io::Result<()> { let mut options = tokio::fs::OpenOptions::new(); options.write(true).create_new(true); #[cfg(unix)] options.mode(0o600); let mut file = options.open(path).await?; + let written = write_payload_contents(&mut file, json).await; + if written.is_err() { + // `create_new` succeeded, so the file is ours: never leave a partial + // payload orphaned in the shared directory. + drop(file); + let _ = tokio::fs::remove_file(path).await; + } + written +} + +async fn write_payload_contents(file: &mut tokio::fs::File, json: &str) -> std::io::Result<()> { + #[cfg(test)] + if PAYLOAD_WRITE_FAILURE.load(Ordering::SeqCst) { + return Err(std::io::Error::other("injected payload write failure")); + } file.write_all(json.as_bytes()).await?; file.flush().await } @@ -417,6 +722,10 @@ async fn write_payload_file(path: &Path, json: &str) -> std::io::Result<()> { #[cfg(test)] static PAYLOAD_DIR_OVERRIDE: std::sync::Mutex> = std::sync::Mutex::new(None); +#[cfg(test)] +static PAYLOAD_WRITE_FAILURE: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + fn payload_dir() -> PathBuf { #[cfg(test)] if let Some(dir) = PAYLOAD_DIR_OVERRIDE @@ -429,6 +738,60 @@ fn payload_dir() -> PathBuf { std::env::temp_dir() } +const STALE_PAYLOAD_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60); + +pub fn sweep_stale_payload_files() { + sweep_payload_files_older_than(STALE_PAYLOAD_MAX_AGE); +} + +fn sweep_payload_files_older_than(max_age: Duration) { + let dir = payload_dir(); + let entries = match std::fs::read_dir(&dir) { + Ok(entries) => entries, + Err(err) => { + debug!( + "Skipping stale hook-payload sweep in {}: {err}", + dir.display() + ); + return; + } + }; + let now = SystemTime::now(); + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + if !name.starts_with("coyote-hook-") || !name.ends_with(".json") { + continue; + } + // DirEntry::metadata does not traverse symlinks, so a planted link + // is skipped rather than followed. + let Ok(metadata) = entry.metadata() else { + continue; + }; + if !metadata.is_file() { + continue; + } + let stale = metadata + .modified() + .ok() + .and_then(|mtime| now.duration_since(mtime).ok()) + .is_some_and(|age| age >= max_age); + if !stale { + continue; + } + + match std::fs::remove_file(entry.path()) { + Ok(()) => debug!("Removed stale hook payload file {}", entry.path().display()), + Err(err) => debug!( + "Failed to remove stale hook payload file {}: {err}", + entry.path().display() + ), + } + } +} + async fn run_hook( event: HookEvent, hook: ResolvedHook, @@ -513,7 +876,6 @@ async fn run_hook( /// truncation pass, because that value is engine-generated, inherently short, /// and a truncated path would be corrupt rather than merely trimmed. Exported /// for call sites that need the bound before dispatch. -#[allow(dead_code)] pub fn truncate_env_value(value: &str) -> &str { const MAX_ENV_VALUE_BYTES: usize = 2048; if value.len() <= MAX_ENV_VALUE_BYTES { @@ -604,7 +966,6 @@ pub(crate) mod test_sink { /// A copy of everything recorded so far, for tests that filter by a hook /// name unique to their fixture. - #[allow(dead_code)] pub fn snapshot() -> Vec { CAPTURES .lock() @@ -616,10 +977,13 @@ pub(crate) mod test_sink { #[cfg(test)] mod tests { use super::*; + use crate::config::conflict::prompt_script; use crate::config::{AppConfig, AppState, Role, WorkingMode}; + use crate::{testing, utils}; use serial_test::serial; use std::env; use std::sync::Arc; + use std::time::UNIX_EPOCH; fn hooks_map(event: &str, defs: &[(&str, &str)]) -> HooksMap { let defs = defs @@ -645,26 +1009,175 @@ mod tests { RequestContext::new(Arc::new(app), WorkingMode::Cmd) } + /// Points the hooks dir at a fresh temp directory for the guard's + /// lifetime and removes it on drop. Tests using it must serialize. + struct HooksDirGuard { + _env: testing::EnvVarGuard, + root: PathBuf, + } + + impl HooksDirGuard { + fn new(label: &str) -> Self { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = env::temp_dir().join(format!("coyote-{label}-{unique}")); + Self { + _env: testing::EnvVarGuard::set(utils::get_env_name("hooks_dir"), &root), + root, + } + } + } + + impl Drop for HooksDirGuard { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } + } + + /// Points `run_hook`'s payload directory at `path` for the guard's + /// lifetime, so payload writes can be aimed at a controlled directory + /// without touching the process-global `TMPDIR`. + struct PayloadDirOverrideGuard; + + impl PayloadDirOverrideGuard { + fn new(path: PathBuf) -> Self { + *PAYLOAD_DIR_OVERRIDE + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(path); + Self + } + } + + impl Drop for PayloadDirOverrideGuard { + fn drop(&mut self) { + *PAYLOAD_DIR_OVERRIDE + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; + } + } + + /// Makes `write_payload_file` fail after the `create_new` open succeeds, + /// simulating a write error on the already-created file. + struct PayloadWriteFailureGuard; + + impl PayloadWriteFailureGuard { + fn install() -> Self { + PAYLOAD_WRITE_FAILURE.store(true, Ordering::SeqCst); + Self + } + } + + impl Drop for PayloadWriteFailureGuard { + fn drop(&mut self) { + PAYLOAD_WRITE_FAILURE.store(false, Ordering::SeqCst); + } + } + + fn fresh_payload_dir(label: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = env::temp_dir().join(format!("coyote-{label}-{unique}")); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[tokio::test] + #[serial] + async fn payload_write_failure_after_creation_unlinks_the_orphan() { + let dir = fresh_payload_dir("payload-orphan"); + let path = dir.join("coyote-hook-tool.started-orphan.json"); + let _fail = PayloadWriteFailureGuard::install(); + + let result = write_payload_file(&path, r#"{"probe":true}"#).await; + + assert!(result.is_err()); + assert!( + !path.exists(), + "a payload file created before the write failed must be unlinked" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + #[serial] + fn stale_payload_sweep_removes_only_matching_stale_files() { + let dir = fresh_payload_dir("payload-sweep"); + let _payload_dir = PayloadDirOverrideGuard::new(dir.clone()); + let stale = dir.join("coyote-hook-tool.started-abc123.json"); + std::fs::write(&stale, "{}").unwrap(); + // Everything outside the exact `coyote-hook-*.json` pattern survives, + // whatever its age: the payload dir is shared. + let bystanders = [ + dir.join("coyote-hook-tool.started-abc123.json.bak"), + dir.join("coyote-hookless.json"), + dir.join("other.json"), + dir.join("coyote-hook-note.txt"), + ]; + for path in &bystanders { + std::fs::write(path, "keep").unwrap(); + } + std::fs::create_dir_all(dir.join("coyote-hook-decoy-dir.json")).unwrap(); + + // Zero max age marks every matching file stale without depending on + // the wall clock. + sweep_payload_files_older_than(Duration::ZERO); + + assert!(!stale.exists(), "a stale payload file must be removed"); + for path in &bystanders { + assert!(path.exists(), "{} must survive the sweep", path.display()); + } + assert!(dir.join("coyote-hook-decoy-dir.json").is_dir()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + #[serial] + fn stale_payload_sweep_keeps_files_younger_than_the_threshold() { + let dir = fresh_payload_dir("payload-sweep-young"); + let _payload_dir = PayloadDirOverrideGuard::new(dir.clone()); + let young = dir.join("coyote-hook-tool.started-young.json"); + std::fs::write(&young, "{}").unwrap(); + + sweep_payload_files_older_than(STALE_PAYLOAD_MAX_AGE); + + assert!( + young.exists(), + "a freshly written payload file must never be swept" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + #[serial] + fn stale_payload_sweep_tolerates_a_missing_directory() { + let dir = env::temp_dir().join("coyote-payload-sweep-missing-dir"); + let _ = std::fs::remove_dir_all(&dir); + let _payload_dir = PayloadDirOverrideGuard::new(dir); + + sweep_stale_payload_files(); + } + #[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) + let env_name = utils::get_env_name("hooks_dir"); + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); let root = env::temp_dir().join(format!("coyote-hooks-install-{unique}")); - unsafe { - env::set_var(&env_name, &root); - } + let env_guard = testing::EnvVarGuard::set(&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. + // Capture every outcome first and assert only after cleanup: the + // guard restores the env var even on panic, but the temp dir removal + // below still has to run before any assertion can bail out. let notify = root.join("notify.sh"); let log_events = root.join("log-events.sh"); - let fresh = install_builtin_hooks(false); + let fresh = install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None); let installed = (notify.is_file(), log_events.is_file()); #[cfg(unix)] let modes: Vec> = { @@ -675,17 +1188,12 @@ mod tests { .collect() }; let modified = std::fs::write(¬ify, "modified"); - let no_force = install_builtin_hooks(false); + let no_force = install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None); let after_no_force = std::fs::read_to_string(¬ify); - let force = install_builtin_hooks(true); + let force = install_builtin_hooks(InstallMode::Force, &mut StickyMode::None); 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), - } - } + drop(env_guard); let _ = std::fs::remove_dir_all(&root); fresh.unwrap(); @@ -707,6 +1215,229 @@ mod tests { assert!(after_force.unwrap().starts_with("#!/usr/bin/env bash")); } + #[test] + #[serial] + fn install_builtin_hooks_skip_mode_never_prompts_even_on_a_terminal() { + let guard = HooksDirGuard::new("hooks-skip-no-prompt"); + install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None).unwrap(); + let notify = guard.root.join("notify.sh"); + std::fs::write(¬ify, "modified").unwrap(); + + // A forced terminal with no scripted answers: any prompt would panic + // and the counter would move. + let _script = prompt_script::install(&[]); + install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None).unwrap(); + + assert_eq!(prompt_script::prompts_asked(), 0); + assert_eq!(std::fs::read_to_string(¬ify).unwrap(), "modified"); + } + + #[test] + #[serial] + fn install_builtin_hooks_prompt_mode_asks_only_for_differing_files() { + let guard = HooksDirGuard::new("hooks-prompt-conflict"); + install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None).unwrap(); + let notify = guard.root.join("notify.sh"); + std::fs::write(¬ify, "modified").unwrap(); + + // log-events.sh is identical to the embed, so only notify.sh asks. + let script = prompt_script::install(&["keep"]); + install_builtin_hooks(InstallMode::Prompt, &mut StickyMode::None).unwrap(); + assert_eq!(prompt_script::prompts_asked(), 1); + assert_eq!(std::fs::read_to_string(¬ify).unwrap(), "modified"); + drop(script); + + let script = prompt_script::install(&["replace"]); + install_builtin_hooks(InstallMode::Prompt, &mut StickyMode::None).unwrap(); + assert_eq!(prompt_script::prompts_asked(), 1); + assert!( + std::fs::read_to_string(¬ify) + .unwrap() + .starts_with("#!/usr/bin/env bash") + ); + drop(script); + } + + #[test] + #[serial] + fn install_builtin_hooks_prompt_mode_writes_missing_files_without_asking() { + let guard = HooksDirGuard::new("hooks-prompt-missing"); + let _script = prompt_script::install(&[]); + + install_builtin_hooks(InstallMode::Prompt, &mut StickyMode::None).unwrap(); + + assert_eq!(prompt_script::prompts_asked(), 0); + assert!(guard.root.join("notify.sh").is_file()); + assert!(guard.root.join("log-events.sh").is_file()); + } + + #[test] + #[serial] + fn install_builtin_hooks_prompt_mode_keeps_local_files_without_a_terminal() { + let guard = HooksDirGuard::new("hooks-prompt-non-tty"); + install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None).unwrap(); + let notify = guard.root.join("notify.sh"); + std::fs::write(¬ify, "modified").unwrap(); + + let _script = prompt_script::install_non_interactive(); + install_builtin_hooks(InstallMode::Prompt, &mut StickyMode::None).unwrap(); + + assert_eq!(prompt_script::prompts_asked(), 0); + assert_eq!(std::fs::read_to_string(¬ify).unwrap(), "modified"); + } + + #[test] + #[serial] + fn install_builtin_hooks_keep_all_covers_every_remaining_conflict() { + let guard = HooksDirGuard::new("hooks-prompt-keep-all"); + install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None).unwrap(); + let notify = guard.root.join("notify.sh"); + let log_events = guard.root.join("log-events.sh"); + std::fs::write(¬ify, "modified notify").unwrap(); + std::fs::write(&log_events, "modified log-events").unwrap(); + + let _script = prompt_script::install(&["keep-all"]); + install_builtin_hooks(InstallMode::Prompt, &mut StickyMode::None).unwrap(); + + assert_eq!(prompt_script::prompts_asked(), 1); + assert_eq!(std::fs::read_to_string(¬ify).unwrap(), "modified notify"); + assert_eq!( + std::fs::read_to_string(&log_events).unwrap(), + "modified log-events" + ); + } + + #[test] + #[serial] + fn install_builtin_hooks_removes_stale_shipped_hooks_via_manifest() { + use crate::config::builtin_manifest::BUILTIN_MANIFEST_FILE; + + let guard = HooksDirGuard::new("hooks-manifest-stale"); + install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None).unwrap(); + + // Simulate an upgrade from a release that also shipped old-hook.sh: + // the manifest records it, so reinstall removes it — while a user + // script absent from the manifest survives. + std::fs::write( + guard.root.join(BUILTIN_MANIFEST_FILE), + "log-events.sh\nnotify.sh\nold-hook.sh\n", + ) + .unwrap(); + std::fs::write(guard.root.join("old-hook.sh"), "stale").unwrap(); + std::fs::write(guard.root.join("user-hook.sh"), "user-owned").unwrap(); + + install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None).unwrap(); + + assert!(!guard.root.join("old-hook.sh").exists()); + assert_eq!( + std::fs::read_to_string(guard.root.join("user-hook.sh")).unwrap(), + "user-owned" + ); + assert_eq!( + std::fs::read_to_string(guard.root.join(BUILTIN_MANIFEST_FILE)).unwrap(), + "log-events.sh\nnotify.sh\n" + ); + } + + #[test] + #[serial] + fn install_builtin_hooks_malformed_manifest_deletes_nothing() { + use crate::config::builtin_manifest::BUILTIN_MANIFEST_FILE; + + let guard = HooksDirGuard::new("hooks-manifest-malformed"); + install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None).unwrap(); + std::fs::write(guard.root.join(BUILTIN_MANIFEST_FILE), [0xff, 0xfe, 0x00]).unwrap(); + std::fs::write(guard.root.join("old-hook.sh"), "keep").unwrap(); + + install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None).unwrap(); + + assert!( + guard.root.join("old-hook.sh").exists(), + "an unreadable manifest must fail safe toward keeping files" + ); + } + + #[test] + #[serial] + fn install_builtin_hooks_respelled_default_hooks_dir_does_not_warn() { + testing::install_log_collector(); + let guard = testing::TestConfigDirGuard::new("hooks-warn-respelled"); + let guard_path_display = guard.path.display().to_string(); + let env_name = utils::get_env_name("hooks_dir"); + + // An override that reaches the default `config_dir()/hooks` through a + // `..` hop is the default path in disguise: the canonicalized compare + // must stay quiet even though the strings differ. + let alias = guard.path.join("hooks").join("..").join("hooks"); + let env_guard = testing::EnvVarGuard::set(&env_name, &alias); + install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None).unwrap(); + drop(env_guard); + + let default_notify = guard.path.join("hooks").join("notify.sh"); + assert!( + default_notify.is_file(), + "scripts must land in the default dir, so wrote_any was true" + ); + + // Same disguise via a symlink. One shipped script is removed first so + // the run rewrites it: a no-op install would skip the warning check + // entirely and prove nothing about the compare. + #[cfg(unix)] + { + let link = guard.path.join("hooks-link"); + std::os::unix::fs::symlink(guard.path.join("hooks"), &link).unwrap(); + std::fs::remove_file(&default_notify).unwrap(); + let _env_guard = testing::EnvVarGuard::set(&env_name, &link); + install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None).unwrap(); + assert!( + default_notify.is_file(), + "the removed script must be reinstalled, so wrote_any was true" + ); + } + + // The warn buffer is process-global, so scope the check to messages + // naming this test's unique directory. + let warns = testing::warn_snapshot(); + assert!( + warns.iter().all(|message| { + !(message.contains("overrides the hooks dir") + && message.contains(&guard_path_display)) + }), + "a respelled default hooks dir must not warn: {warns:?}" + ); + } + + #[test] + #[serial] + fn install_builtin_hooks_genuine_override_warns_when_default_dir_is_missing() { + testing::install_log_collector(); + let guard = testing::TestConfigDirGuard::new("hooks-warn-genuine"); + let override_dir = guard.path.join("elsewhere-hooks"); + let _env_guard = testing::EnvVarGuard::set(utils::get_env_name("hooks_dir"), &override_dir); + + install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None).unwrap(); + + // Fresh install: nothing ever created the default dir, so its + // canonicalization fails and the compare falls back to the original + // path. A genuine override must still be detected. + assert!( + !guard.path.join("hooks").exists(), + "the default hooks dir must not exist for this scenario" + ); + assert!( + override_dir.join("notify.sh").is_file(), + "scripts must land in the override dir, so wrote_any was true" + ); + let override_display = override_dir.display().to_string(); + let warns = testing::warn_snapshot(); + assert!( + warns.iter().any(|message| { + message.contains("overrides the hooks dir") && message.contains(&override_display) + }), + "a genuine override must warn even without a default dir: {warns:?}" + ); + } + /// 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. @@ -740,17 +1471,17 @@ mod tests { 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"); + let echo = content.find("echo \"$safe_line\"").expect("echo fallback"); assert!(notify_send < osascript); assert!(osascript < echo); } #[test] - fn log_events_script_defaults_to_tmp_log_behind_env_override() { + fn log_events_script_defaults_to_xdg_state_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")); + assert!(content.contains("${XDG_STATE_HOME:-$HOME/.local/state}/coyote/hooks.log")); } #[cfg(unix)] @@ -789,13 +1520,24 @@ mod tests { #[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. + // A PATH exposing only tr (needed by the fallback's sanitizer) hides + // notify-send and osascript; echo is a bash builtin, so only the + // fallback branch can produce output. + let tr = ["/usr/bin/tr", "/bin/tr"] + .into_iter() + .map(Path::new) + .find(|path| path.exists()) + .expect("tr binary"); + let bin = + env::temp_dir().join(format!("coyote-notify-fallback-bin-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&bin); + std::fs::create_dir_all(&bin).unwrap(); + std::os::unix::fs::symlink(tr, bin.join("tr")).unwrap(); let mut cmd = std::process::Command::new("/bin/bash"); cmd.arg(&script) - .env("PATH", "") + .env("PATH", &bin) .env("COYOTE_EVENT", "turn.completed") - .env("COYOTE_TOOL_NAME", "demo"); + .env("COYOTE_TOOL_NAME", "demo\u{1b}]0;evil\u{7}"); // Detach from any controlling terminal so the script cannot open // /dev/tty and must fall back to captured stdout. unsafe { @@ -806,11 +1548,16 @@ mod tests { }); } let output = cmd.output().unwrap(); + let _ = std::fs::remove_dir_all(&bin); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); assert!(stdout.contains("turn.completed"), "{stdout}"); assert!(stdout.contains("tool=demo"), "{stdout}"); + assert!( + !stdout.contains('\u{1b}') && !stdout.contains('\u{7}'), + "the fallback must strip control bytes before echoing: {stdout:?}" + ); } #[test] @@ -821,6 +1568,7 @@ mod tests { (HookEvent::TurnInterrupted, "turn.interrupted"), (HookEvent::TurnFailed, "turn.failed"), (HookEvent::SessionStarted, "session.started"), + (HookEvent::SessionResumed, "session.resumed"), (HookEvent::SessionEnded, "session.ended"), (HookEvent::SessionCompressed, "session.compressed"), (HookEvent::ToolStarted, "tool.started"), @@ -831,16 +1579,78 @@ mod tests { (HookEvent::LlmRequestFailed, "llm.request.failed"), (HookEvent::AgentStarted, "agent.started"), (HookEvent::AgentCompleted, "agent.completed"), + (HookEvent::AgentInterrupted, "agent.interrupted"), (HookEvent::AgentFailed, "agent.failed"), + (HookEvent::EscalationRaised, "escalation.raised"), + (HookEvent::EscalationAnswered, "escalation.answered"), (HookEvent::GraphNodeStarted, "graph.node.started"), (HookEvent::GraphNodeCompleted, "graph.node.completed"), (HookEvent::GraphNodeFailed, "graph.node.failed"), (HookEvent::JobStarted, "job.started"), (HookEvent::JobCompleted, "job.completed"), (HookEvent::JobFailed, "job.failed"), + (HookEvent::RagSyncStarted, "rag.sync.started"), + (HookEvent::RagSyncCompleted, "rag.sync.completed"), + (HookEvent::RagSyncFailed, "rag.sync.failed"), + (HookEvent::McpServerConnected, "mcp.server.connected"), + (HookEvent::McpServerFailed, "mcp.server.failed"), ]; for (event, name) in cases { assert_eq!(event.as_str(), name); + assert!( + HookEvent::ALL.contains(&event), + "HookEvent::ALL is missing {name}" + ); + } + assert_eq!(HookEvent::ALL.len(), cases.len()); + } + + #[test] + fn rag_and_mcp_wildcard_whitelist_entries_are_valid() { + for event in [ + "rag.sync.started", + "rag.sync.completed", + "rag.sync.failed", + "mcp.server.connected", + "mcp.server.failed", + ] { + assert!( + entry_is_valid_wildcard(&format!("{event}.*")), + "'{event}.*' must validate as an .* wildcard" + ); + } + // Family prefixes are not events: like `tool.*`, these are partial + // globs that admit nothing. + assert!(!entry_is_valid_wildcard("rag.sync.*")); + assert!(!entry_is_valid_wildcard("mcp.server.*")); + } + + #[test] + fn rag_and_mcp_events_stay_behind_the_agent_whitelist_gate() { + let mut global = hooks_map("rag.sync.started", &[("watch", "cmd-rag")]); + global.extend(hooks_map("mcp.server.connected", &[("watch", "cmd-mcp")])); + + for event in [HookEvent::RagSyncStarted, HookEvent::McpServerConnected] { + let resolved = resolve_hooks(event, &global, Some((&[], "gated-agent")), None, None); + assert!( + resolved.is_empty(), + "{} must stay whitelist-gated for agents", + event.as_str() + ); + } + + let gate = vec![ + "rag.sync.started.*".to_string(), + "mcp.server.connected.*".to_string(), + ]; + for event in [HookEvent::RagSyncStarted, HookEvent::McpServerConnected] { + let resolved = resolve_hooks(event, &global, Some((&gate, "gated-agent")), None, None); + assert_eq!( + names(&resolved), + ["watch"], + "an `.*` whitelist entry must admit {}", + event.as_str() + ); } } @@ -871,6 +1681,54 @@ mod tests { assert_eq!(names(&resolved), ["own"]); } + #[test] + fn escalation_events_bypass_the_agent_whitelist_gate() { + let mut global = hooks_map("escalation.raised", &[("watch", "cmd-raised")]); + global.extend(hooks_map( + "escalation.answered", + &[("watch", "cmd-answered")], + )); + global.extend(hooks_map("tool.started", &[("watch", "cmd-tool")])); + let mut role = hooks_map("escalation.raised", &[("role-watch", "role-raised")]); + role.extend(hooks_map( + "escalation.answered", + &[("role-watch", "role-answered")], + )); + let mut agent = hooks_map("escalation.raised", &[("own-watch", "agent-raised")]); + agent.extend(hooks_map( + "escalation.answered", + &[("own-watch", "agent-answered")], + )); + + for event in [HookEvent::EscalationRaised, HookEvent::EscalationAnswered] { + let resolved = resolve_hooks( + event, + &global, + Some((&[], "gated-agent")), + Some(&role), + Some((&agent, "gated-agent")), + ); + assert_eq!( + names(&resolved), + ["watch", "role-watch", "own-watch"], + "{} must resolve global, role, and agent hooks without a whitelist entry", + event.as_str() + ); + } + + let resolved = resolve_hooks( + HookEvent::ToolStarted, + &global, + Some((&[], "gated-agent")), + None, + None, + ); + assert!( + resolved.is_empty(), + "non-escalation events stay behind the whitelist gate" + ); + } + #[test] fn whitelist_admits_only_exact_global_entries() { let global = hooks_map( @@ -895,9 +1753,223 @@ mod tests { assert_eq!(names(&resolved), ["b"]); } + #[test] + fn event_star_wildcard_admits_every_hook_of_that_event() { + let mut global = hooks_map("turn.started", &[("a", "cmd-a"), ("b", "cmd-b")]); + global.extend(hooks_map("turn.completed", &[("other", "cmd-other")])); + let gate = vec!["turn.started.*".to_string()]; + + let started = resolve_hooks( + HookEvent::TurnStarted, + &global, + Some((&gate, "gated-agent")), + None, + None, + ); + assert_eq!(names(&started), ["a", "b"]); + + let completed = resolve_hooks( + HookEvent::TurnCompleted, + &global, + Some((&gate, "gated-agent")), + None, + None, + ); + assert!( + completed.is_empty(), + "'turn.started.*' must not admit hooks of other events" + ); + } + + #[test] + fn star_name_wildcard_admits_that_hook_on_every_event() { + let mut global = hooks_map("turn.started", &[("notify", "cmd-1"), ("other", "cmd-2")]); + global.extend(hooks_map("tool.completed", &[("notify", "cmd-3")])); + let gate = vec!["*.notify".to_string()]; + + for event in [HookEvent::TurnStarted, HookEvent::ToolCompleted] { + let resolved = resolve_hooks(event, &global, Some((&gate, "gated-agent")), None, None); + assert_eq!( + names(&resolved), + ["notify"], + "'*.notify' must admit exactly that hook name on {}", + event.as_str() + ); + } + } + + #[test] + fn star_and_star_dot_star_admit_everything() { + let mut global = hooks_map("turn.started", &[("a", "cmd-a"), ("b", "cmd-b")]); + global.extend(hooks_map("llm.request.failed", &[("c", "cmd-c")])); + + for entry in ["*", "*.*"] { + let gate = vec![entry.to_string()]; + let started = resolve_hooks( + HookEvent::TurnStarted, + &global, + Some((&gate, "gated-agent")), + None, + None, + ); + assert_eq!(names(&started), ["a", "b"], "gate entry '{entry}'"); + + let failed = resolve_hooks( + HookEvent::LlmRequestFailed, + &global, + Some((&gate, "gated-agent")), + None, + None, + ); + assert_eq!(names(&failed), ["c"], "gate entry '{entry}'"); + } + } + + // Event names contain dots, so `tool.*` names a nonexistent event + // `tool`, not a prefix over `tool.started`/`tool.completed` — like any + // other partial glob it admits nothing. + #[test] + fn partial_globs_admit_nothing() { + let global = hooks_map("tool.started", &[("st", "cmd-st"), ("stat", "cmd-stat")]); + let gate = vec![ + "tool.st*".to_string(), + "tool.started.st*".to_string(), + "tool.*".to_string(), + ]; + + let resolved = resolve_hooks( + HookEvent::ToolStarted, + &global, + Some((&gate, "gated-agent")), + None, + None, + ); + + assert!(resolved.is_empty(), "partial globs are not wildcards"); + } + + #[test] + fn escalation_events_stay_ungated_under_wildcard_whitelists() { + let mut global = hooks_map("escalation.raised", &[("watch", "cmd-raised")]); + global.extend(hooks_map( + "escalation.answered", + &[("watch", "cmd-answered")], + )); + // A whitelist naming only unrelated events must not narrow the + // escalation resolution: the gate is nulled before any matching. + let gate = vec!["turn.started.*".to_string()]; + + for event in [HookEvent::EscalationRaised, HookEvent::EscalationAnswered] { + let resolved = resolve_hooks(event, &global, Some((&gate, "gated-agent")), None, None); + assert_eq!( + names(&resolved), + ["watch"], + "{} must resolve ungated regardless of whitelist content", + event.as_str() + ); + } + } + + #[test] + fn wildcard_whitelist_entries_skip_unknown_entry_diagnostics() { + testing::install_log_collector(); + let global = hooks_map("turn.started", &[("a", "cmd-a")]); + let gate = vec![ + "turn.started.*".to_string(), + "*".to_string(), + "*.*".to_string(), + "*.some-name".to_string(), + ]; + + let resolved = resolve_hooks( + HookEvent::TurnStarted, + &global, + Some((&gate, "wildcard-exempt-agent-q9z")), + None, + None, + ); + + assert_eq!(names(&resolved), ["a"]); + let debugs = testing::debug_snapshot(); + assert!( + debugs + .iter() + .all(|message| !message.contains("wildcard-exempt-agent-q9z")), + "wildcard entries must not be flagged as unknown" + ); + } + + #[test] + fn partial_glob_whitelist_entries_stay_flagged_as_unknown() { + testing::install_log_collector(); + let global = hooks_map("turn.started", &[("a", "cmd-a")]); + let gate = vec!["turn.started.st*-marker-k4w".to_string()]; + + let resolved = resolve_hooks( + HookEvent::TurnStarted, + &global, + Some((&gate, "glob-flag-agent-k4w")), + None, + None, + ); + + assert!(resolved.is_empty(), "an invalid glob must admit nothing"); + let debugs = testing::debug_snapshot(); + assert!(debugs.iter().any(|message| { + message.contains("turn.started.st*-marker-k4w") + && message.contains("for agent 'glob-flag-agent-k4w'") + })); + } + + #[test] + fn partial_glob_over_event_prefix_logs_invalid_glob_diagnostic() { + testing::install_log_collector(); + let global = hooks_map("tool.started", &[("st", "cmd-st")]); + // `tool.st*` extends no full event name (`tool` is not an event), so + // the event-prefix diagnostic can never see it; the glob check must + // flag it instead. + let gate = vec!["tool.st*".to_string()]; + + let resolved = resolve_hooks( + HookEvent::ToolStarted, + &global, + Some((&gate, "prefix-glob-agent-t7c")), + None, + None, + ); + + assert!(resolved.is_empty(), "a partial glob admits nothing"); + let debugs = testing::debug_snapshot(); + assert!(debugs.iter().any(|message| { + message.contains("tool.st*") && message.contains("for agent 'prefix-glob-agent-t7c'") + })); + } + + #[test] + fn inner_glob_entries_log_invalid_glob_diagnostic() { + testing::install_log_collector(); + let global = hooks_map("agent.completed", &[("mark", "cmd-mark")]); + let gate = vec!["ag*.completed.mark".to_string()]; + + let resolved = resolve_hooks( + HookEvent::AgentCompleted, + &global, + Some((&gate, "inner-glob-agent-m2r")), + None, + None, + ); + + assert!(resolved.is_empty(), "an inner glob admits nothing"); + let debugs = testing::debug_snapshot(); + assert!(debugs.iter().any(|message| { + message.contains("ag*.completed.mark") + && message.contains("for agent 'inner-glob-agent-m2r'") + })); + } + #[test] fn unknown_whitelist_entries_log_debug_and_stay_inert() { - crate::testing::install_warn_collector(); + testing::install_log_collector(); let global = hooks_map("turn.started", &[("a", "cmd-a")]); let gate = vec![ "turn.started.a".to_string(), @@ -913,10 +1985,7 @@ mod tests { ); assert_eq!(names(&resolved), ["a"]); - let debugs: Vec = crate::testing::debug_messages() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .clone(); + let debugs = testing::debug_snapshot(); assert!(debugs.iter().any(|message| { message.contains("turn.started.nonexistent-marker-xyz") && message.contains("for agent 'gate-agent-xyz'") @@ -925,7 +1994,7 @@ mod tests { #[test] fn empty_global_map_short_circuits_whitelist_diagnostics() { - crate::testing::install_warn_collector(); + testing::install_log_collector(); let global = HooksMap::default(); let role = hooks_map("turn.started", &[("role-only", "role-cmd")]); let gate = vec!["turn.started.x-shortcircuit-marker-p3q".to_string()]; @@ -939,10 +2008,7 @@ mod tests { ); assert_eq!(names(&resolved), ["role-only"]); - let debugs: Vec = crate::testing::debug_messages() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .clone(); + let debugs = testing::debug_snapshot(); assert!( debugs .iter() @@ -1111,6 +2177,42 @@ mod tests { assert_eq!(capture.envs.get("COYOTE_BIG_EXTRA").unwrap().len(), 2048); } + #[test] + #[serial] + fn fire_resolved_strips_nul_bytes_from_env_values() { + let _guard = test_sink::install(); + let hook = ResolvedHook { + name: "nul-strip-probe-k4v".to_string(), + full_name: "escalation.raised.nul-strip-probe-k4v".to_string(), + command: "true".to_string(), + cwd: env::temp_dir(), + }; + + fire_resolved( + HookEvent::EscalationRaised, + vec![hook], + Vec::new(), + &[( + "COYOTE_ESCALATION_QUESTION", + "before\u{0000}after".to_string(), + )], + None, + ); + + let captures = test_sink::drain(); + let capture = captures + .iter() + .find(|capture| capture.hook_name == "nul-strip-probe-k4v") + .expect("a NUL in an env value must not suppress the hook"); + assert_eq!( + capture + .envs + .get("COYOTE_ESCALATION_QUESTION") + .map(String::as_str), + Some("beforeafter") + ); + } + #[test] #[serial] fn sink_recovers_after_capture_buffer_poisoning() { @@ -1171,15 +2273,12 @@ mod tests { #[tokio::test(start_paused = true)] #[serial] async fn drain_pending_logs_when_the_timeout_expires() { - crate::testing::install_warn_collector(); + testing::install_log_collector(); let pending = SpawnAckGuard::register(); drain_pending(Duration::from_millis(50)).await; - let debugs: Vec = crate::testing::debug_messages() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .clone(); + let debugs = testing::debug_snapshot(); assert!( debugs .iter() @@ -1206,7 +2305,7 @@ mod tests { #[test] #[serial] fn fire_resolved_without_runtime_skips_instead_of_panicking() { - crate::testing::install_warn_collector(); + testing::install_log_collector(); let hook = ResolvedHook { name: "no-runtime-marker-a7c".to_string(), full_name: "turn.failed.no-runtime-marker-a7c".to_string(), @@ -1216,10 +2315,7 @@ mod tests { fire_resolved(HookEvent::TurnFailed, vec![hook], Vec::new(), &[], None); - let debugs: Vec = crate::testing::debug_messages() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .clone(); + let debugs = testing::debug_snapshot(); assert!(debugs.iter().any(|message| { message.contains("no-runtime-marker-a7c") && message.contains("no Tokio runtime on this thread") @@ -1229,7 +2325,7 @@ mod tests { #[test] #[serial] fn fire_without_runtime_skips_instead_of_panicking() { - crate::testing::install_warn_collector(); + testing::install_log_collector(); let ctx = ctx_with_global_hooks(hooks_map( "turn.failed", &[("fire-no-runtime-marker-b8d", "true")], @@ -1237,10 +2333,7 @@ mod tests { fire(HookEvent::TurnFailed, &ctx, &[], None); - let debugs: Vec = crate::testing::debug_messages() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .clone(); + let debugs = testing::debug_snapshot(); assert!(debugs.iter().any(|message| { message.contains("fire-no-runtime-marker-b8d") && message.contains("no Tokio runtime on this thread") @@ -1249,7 +2342,7 @@ mod tests { #[test] fn debug_capture_excludes_non_hooks_targets() { - crate::testing::install_warn_collector(); + testing::install_log_collector(); let hook = ResolvedHook { name: "included-marker-k2v".to_string(), full_name: "turn.failed.included-marker-k2v".to_string(), @@ -1267,10 +2360,7 @@ mod tests { ); fire_resolved(HookEvent::TurnFailed, vec![hook], Vec::new(), &[], None); - let debugs: Vec = crate::testing::debug_messages() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .clone(); + let debugs = testing::debug_snapshot(); assert!( debugs .iter() @@ -1291,9 +2381,9 @@ mod tests { #[test] #[serial] fn debug_capture_recovers_after_buffer_poisoning() { - crate::testing::install_warn_collector(); + testing::install_log_collector(); std::thread::spawn(|| { - let _held = crate::testing::debug_messages().lock().unwrap(); + let _held = testing::debug_messages().lock().unwrap(); panic!("poison the debug buffer"); }) .join() @@ -1307,7 +2397,7 @@ mod tests { }; fire_resolved(HookEvent::TurnFailed, vec![hook], Vec::new(), &[], None); - let mut debugs = crate::testing::debug_messages() + let mut debugs = testing::debug_messages() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); assert!(debugs.iter().any(|message| { @@ -1333,92 +2423,131 @@ mod tests { assert_eq!(resolved[1].cwd, paths::roles_dir()); } - #[cfg(unix)] - mod dispatch { + async fn wait_for(what: &str, cond: impl Fn() -> bool) { + for _ in 0..400 { + if cond() { + return; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + panic!("timed out waiting for {what}"); + } + + /// Dispatch tests whose hooks fail before any shell is involved (bad + /// cwd, empty command), so they run identically on every OS. + mod dispatch_portable { use super::*; - use crate::utils::get_env_name; - use std::fs::{create_dir_all, remove_dir_all}; - use std::path::PathBuf; - use std::time::{Duration, SystemTime, UNIX_EPOCH}; + use crate::testing::TestConfigDirGuard; + use std::fs::create_dir_all; + use std::time::Duration; - struct TestConfigDirGuard { - key: String, - previous: Option, - path: PathBuf, - } + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn spawn_failure_removes_the_payload_file() { + testing::install_log_collector(); + let guard = TestConfigDirGuard::new("hooks-dispatch"); + let payload_dir = guard.path.join("payloads"); + create_dir_all(&payload_dir).unwrap(); + let _payload_dir = PayloadDirOverrideGuard::new(payload_dir.clone()); + let hooks = vec![ResolvedHook { + name: "spawnfail-marker-c9d".to_string(), + full_name: "tool.started.spawnfail-marker-c9d".to_string(), + command: "true".to_string(), + cwd: guard.path.join("nonexistent-spawnfail-marker-c9d"), + }]; - impl TestConfigDirGuard { - fn new() -> Self { - let key = get_env_name("config_dir"); - let previous = env::var_os(&key); - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) + fire_resolved( + HookEvent::ToolStarted, + hooks, + Vec::new(), + &[], + Some(r#"{"probe":true}"#.to_string()), + ); + drain_pending(Duration::from_secs(10)).await; + + // The unspawnable cwd must have driven the spawn-error branch, + // and the payload write before it must have succeeded: only then + // does the empty directory below demonstrate the orphan unlink + // rather than a payload file that never existed. + wait_for("spawn failure log", || { + testing::debug_snapshot().iter().any(|message| { + message.contains("Failed to spawn hook 'tool.started.spawnfail-marker-c9d'") + }) + }) + .await; + let debugs = testing::debug_snapshot(); + assert!(debugs.iter().all(|message| { + !(message.contains("spawnfail-marker-c9d") + && message.contains("Failed to write payload file")) + })); + + wait_for("payload file removal", || { + std::fs::read_dir(&payload_dir) .unwrap() - .as_nanos(); - let path = env::temp_dir().join(format!("coyote-hooks-tests-{unique}")); - create_dir_all(&path).unwrap(); - unsafe { - env::set_var(&key, &path); - } - Self { - key, - previous, - path, - } - } - } - - impl Drop for TestConfigDirGuard { - fn drop(&mut self) { - if let Some(previous) = &self.previous { - unsafe { - env::set_var(&self.key, previous); - } - } else { - unsafe { - env::remove_var(&self.key); - } - } - let _ = remove_dir_all(&self.path); - } - } - - /// Points `run_hook`'s payload directory at `path` for the guard's - /// lifetime, so payload writes can be aimed at a nonexistent - /// directory without touching the process-global `TMPDIR`. - struct PayloadDirOverrideGuard; - - impl PayloadDirOverrideGuard { - fn new(path: PathBuf) -> Self { - *PAYLOAD_DIR_OVERRIDE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(path); - Self - } - } - - impl Drop for PayloadDirOverrideGuard { - fn drop(&mut self) { - *PAYLOAD_DIR_OVERRIDE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; - } - } - - async fn wait_for(what: &str, cond: impl Fn() -> bool) { - for _ in 0..400 { - if cond() { - return; - } - tokio::time::sleep(Duration::from_millis(25)).await; - } - panic!("timed out waiting for {what}"); + .flatten() + .next() + .is_none() + }) + .await; } + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn error_paths_log_debug_only() { + testing::install_log_collector(); + let empty_cwd = env::temp_dir(); + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let bad_cwd = env::temp_dir().join(format!("coyote-badcwd-marker-f5b-{unique}")); + let hooks = vec![ + ResolvedHook { + name: "empty-marker-f5b".to_string(), + full_name: "turn.failed.empty-marker-f5b".to_string(), + command: " ".to_string(), + cwd: empty_cwd.clone(), + }, + ResolvedHook { + name: "badcwd-marker-f5b".to_string(), + full_name: "turn.failed.badcwd-marker-f5b".to_string(), + command: "true".to_string(), + cwd: bad_cwd.clone(), + }, + ]; + + fire_resolved(HookEvent::TurnFailed, hooks, Vec::new(), &[], None); + drain_pending(Duration::from_secs(10)).await; + + let debugs = testing::debug_snapshot(); + let empty_cwd_display = empty_cwd.display().to_string(); + assert!(debugs.iter().any(|message| { + message.contains("empty-marker-f5b") + && message.contains("empty command") + && message.contains(empty_cwd_display.as_str()) + })); + let bad_cwd_display = bad_cwd.display().to_string(); + assert!(debugs.iter().any(|message| { + message.contains("Failed to spawn hook 'turn.failed.badcwd-marker-f5b'") + && message.contains(bad_cwd_display.as_str()) + })); + let warns = testing::warn_snapshot(); + assert!(warns.iter().all(|message| !message.contains("marker-f5b"))); + } + } + + #[cfg(unix)] + mod dispatch { + use super::*; + use crate::testing::TestConfigDirGuard; + use std::fs::create_dir_all; + use std::path::PathBuf; + use std::time::Duration; + #[tokio::test(flavor = "multi_thread")] #[serial] async fn fire_spawns_detached_with_envs() { - let guard = TestConfigDirGuard::new(); + let guard = TestConfigDirGuard::new("hooks-dispatch"); let out = guard.path.join("env-out"); let gate = guard.path.join("gate"); let done = guard.path.join("done"); @@ -1463,7 +2592,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] #[serial] async fn hook_stdin_is_null_not_inherited() { - let guard = TestConfigDirGuard::new(); + let guard = TestConfigDirGuard::new("hooks-dispatch"); let out = guard.path.join("stdin-out"); let command = r#"if read -t 5 line; then echo open > "$HOOK_OUT.tmp"; else echo eof > "$HOOK_OUT.tmp"; fi; mv "$HOOK_OUT.tmp" "$HOOK_OUT""#; let ctx = ctx_with_global_hooks(hooks_map("turn.started", &[("stdin-probe", command)])); @@ -1487,7 +2616,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] #[serial] async fn payload_file_is_written_and_removed() { - let guard = TestConfigDirGuard::new(); + let guard = TestConfigDirGuard::new("hooks-dispatch"); let out = guard.path.join("payload-out"); let path_out = guard.path.join("payload-path"); let command = r#"cp "$COYOTE_HOOK_PAYLOAD_FILE" "$HOOK_OUT.tmp" && mv "$HOOK_OUT.tmp" "$HOOK_OUT"; printf '%s' "$COYOTE_HOOK_PAYLOAD_FILE" > "$HOOK_PATH.tmp" && mv "$HOOK_PATH.tmp" "$HOOK_PATH""#; @@ -1516,7 +2645,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] #[serial] async fn drain_pending_waits_for_spawn_not_completion() { - let guard = TestConfigDirGuard::new(); + let guard = TestConfigDirGuard::new("hooks-dispatch"); let out = guard.path.join("spawned"); let gate = guard.path.join("gate"); let done = guard.path.join("done"); @@ -1545,55 +2674,11 @@ mod tests { wait_for("hook completion", || done.exists()).await; } - #[tokio::test(flavor = "multi_thread")] - #[serial] - async fn error_paths_log_debug_only() { - crate::testing::install_warn_collector(); - let empty_cwd = env::temp_dir(); - let hooks = vec![ - ResolvedHook { - name: "empty-marker-f5b".to_string(), - full_name: "turn.failed.empty-marker-f5b".to_string(), - command: " ".to_string(), - cwd: empty_cwd.clone(), - }, - ResolvedHook { - name: "badcwd-marker-f5b".to_string(), - full_name: "turn.failed.badcwd-marker-f5b".to_string(), - command: "true".to_string(), - cwd: PathBuf::from("/nonexistent/coyote-badcwd-marker-f5b"), - }, - ]; - - fire_resolved(HookEvent::TurnFailed, hooks, Vec::new(), &[], None); - drain_pending(Duration::from_secs(10)).await; - - let debugs: Vec = crate::testing::debug_messages() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .clone(); - let empty_cwd_display = empty_cwd.display().to_string(); - assert!(debugs.iter().any(|message| { - message.contains("empty-marker-f5b") - && message.contains("empty command") - && message.contains(empty_cwd_display.as_str()) - })); - assert!(debugs.iter().any(|message| { - message.contains("Failed to spawn hook 'turn.failed.badcwd-marker-f5b'") - && message.contains("/nonexistent/coyote-badcwd-marker-f5b") - })); - let warns: Vec = crate::testing::warn_messages() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .clone(); - assert!(warns.iter().all(|message| !message.contains("marker-f5b"))); - } - #[tokio::test(flavor = "multi_thread")] #[serial] async fn failing_hook_never_disturbs_the_engine() { - crate::testing::install_warn_collector(); - let guard = TestConfigDirGuard::new(); + testing::install_log_collector(); + let guard = TestConfigDirGuard::new("hooks-dispatch"); let first = guard.path.join("first-out"); let second = guard.path.join("second-out"); let command = r#"echo err >&2; : > "$HOOK_OUT"; exit 1"#; @@ -1618,10 +2703,7 @@ mod tests { ); wait_for("second hook output", || second.exists()).await; - let warns: Vec = crate::testing::warn_messages() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .clone(); + let warns = testing::warn_snapshot(); assert!( warns .iter() @@ -1633,7 +2715,7 @@ mod tests { #[serial] async fn payload_file_is_created_with_owner_only_mode() { use std::os::unix::fs::PermissionsExt; - let guard = TestConfigDirGuard::new(); + let guard = TestConfigDirGuard::new("hooks-dispatch"); let path = guard.path.join("payload.json"); write_payload_file(&path, r#"{"probe":true}"#) @@ -1647,7 +2729,7 @@ mod tests { #[tokio::test] #[serial] async fn payload_file_write_refuses_existing_paths() { - let guard = TestConfigDirGuard::new(); + let guard = TestConfigDirGuard::new("hooks-dispatch"); let plain = guard.path.join("existing.json"); std::fs::write(&plain, "original").unwrap(); @@ -1665,8 +2747,8 @@ mod tests { #[tokio::test(flavor = "multi_thread")] #[serial] async fn payload_write_failure_still_runs_hook_without_payload_env() { - crate::testing::install_warn_collector(); - let guard = TestConfigDirGuard::new(); + testing::install_log_collector(); + let guard = TestConfigDirGuard::new("hooks-dispatch"); let out = guard.path.join("env-out"); let _payload_dir = PayloadDirOverrideGuard::new(guard.path.join("missing-payload-dir")); let command = r#"env > "$HOOK_OUT.tmp" && mv "$HOOK_OUT.tmp" "$HOOK_OUT""#; @@ -1689,10 +2771,7 @@ mod tests { .lines() .all(|line| !line.starts_with("COYOTE_HOOK_PAYLOAD_FILE=")) ); - let debugs: Vec = crate::testing::debug_messages() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .clone(); + let debugs = testing::debug_snapshot(); assert!(debugs.iter().any(|message| message.contains( "Failed to write payload file for hook 'tool.started.payload-fallback-marker'" ))); @@ -1701,7 +2780,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] #[serial] async fn payload_paths_differ_across_dispatches() { - let guard = TestConfigDirGuard::new(); + let guard = TestConfigDirGuard::new("hooks-dispatch"); let first = guard.path.join("first-path"); let second = guard.path.join("second-path"); let command = r#"printf '%s' "$COYOTE_HOOK_PAYLOAD_FILE" > "$HOOK_PATH.tmp" && mv "$HOOK_PATH.tmp" "$HOOK_PATH""#; @@ -1729,10 +2808,47 @@ mod tests { assert_ne!(first_path, second_path); } + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn payload_paths_differ_within_one_fire() { + let guard = TestConfigDirGuard::new("hooks-dispatch"); + let first = guard.path.join("first-path"); + let second = guard.path.join("second-path"); + let command_first = r#"printf '%s' "$COYOTE_HOOK_PAYLOAD_FILE" > "$HOOK_PATH_FIRST.tmp" && mv "$HOOK_PATH_FIRST.tmp" "$HOOK_PATH_FIRST""#; + let command_second = r#"printf '%s' "$COYOTE_HOOK_PAYLOAD_FILE" > "$HOOK_PATH_SECOND.tmp" && mv "$HOOK_PATH_SECOND.tmp" "$HOOK_PATH_SECOND""#; + let ctx = ctx_with_global_hooks(hooks_map( + "tool.started", + &[ + ("same-fire-a", command_first), + ("same-fire-b", command_second), + ], + )); + + fire( + HookEvent::ToolStarted, + &ctx, + &[ + ("HOOK_PATH_FIRST", first.display().to_string()), + ("HOOK_PATH_SECOND", second.display().to_string()), + ], + Some("{}".to_string()), + ); + wait_for("both payload paths", || first.exists() && second.exists()).await; + + let first_path = std::fs::read_to_string(&first).unwrap(); + let second_path = std::fs::read_to_string(&second).unwrap(); + assert!(!first_path.is_empty()); + assert!(!second_path.is_empty()); + assert_ne!( + first_path, second_path, + "each hook of one fire must get its own payload file" + ); + } + #[tokio::test] #[serial] async fn resolved_hooks_gates_globals_and_adds_agent_hooks_through_context() { - let _guard = TestConfigDirGuard::new(); + let _guard = TestConfigDirGuard::new("hooks-dispatch"); let mut ctx = ctx_with_global_hooks(hooks_map("tool.started", &[("notify", "global-cmd")])); let app = ctx.app.config.clone(); @@ -1747,7 +2863,7 @@ mod tests { ) .unwrap(); - ctx.use_agent(&app, agent_name, None, crate::utils::create_abort_signal()) + ctx.use_agent(&app, agent_name, None, utils::create_abort_signal()) .await .unwrap(); @@ -1757,4 +2873,231 @@ mod tests { assert_eq!(resolved[0].cwd, paths::agent_data_dir(agent_name)); } } + + /// Dispatch through the real `cmd /C` engine branch, plus the Windows + /// posture of install and payload placement. + #[cfg(windows)] + mod windows_dispatch { + use super::*; + use crate::testing::TestConfigDirGuard; + use std::path::PathBuf; + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn fire_spawns_via_cmd_with_envs() { + let guard = TestConfigDirGuard::new("hooks-win-env"); + let out = guard.path.join("env-out"); + let command = r#"set > "%HOOK_OUT%.tmp" && move /Y "%HOOK_OUT%.tmp" "%HOOK_OUT%""#; + let ctx = + ctx_with_global_hooks(hooks_map("turn.completed", &[("win-envdump", command)])); + + fire( + HookEvent::TurnCompleted, + &ctx, + &[("HOOK_OUT", out.display().to_string())], + None, + ); + + wait_for("hook env dump", || out.exists()).await; + let env_dump = std::fs::read_to_string(&out).unwrap(); + let lines: Vec<&str> = env_dump.lines().map(str::trim_end).collect(); + assert!(lines.contains(&"COYOTE_EVENT=turn.completed")); + assert!(lines.contains(&"COYOTE_HOOK_NAME=win-envdump")); + let config_dir_line = format!("COYOTE_CONFIG_DIR={}", guard.path.display()); + assert!(lines.contains(&config_dir_line.as_str())); + assert!( + lines + .iter() + .any(|line| line.starts_with("COYOTE_EVENT_TIMESTAMP=")) + ); + let out_line = format!("HOOK_OUT={}", out.display()); + assert!(lines.contains(&out_line.as_str())); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn fire_returns_while_the_hook_still_runs() { + let guard = TestConfigDirGuard::new("hooks-win-detach"); + let bat = guard.path.join("gate-wait.bat"); + let out = guard.path.join("started"); + let gate = guard.path.join("gate"); + let done = guard.path.join("done"); + // cmd one-liners cannot loop, so the gate wait lives in a batch + // file; `ping -n 2` is the batch idiom for a short sleep. + std::fs::write( + &bat, + concat!( + "@echo off\r\n", + "type nul > \"%HOOK_OUT%\"\r\n", + ":wait\r\n", + "if exist \"%HOOK_GATE%\" goto done\r\n", + "ping -n 2 127.0.0.1 >nul\r\n", + "goto wait\r\n", + ":done\r\n", + "type nul > \"%HOOK_DONE%\"\r\n", + ), + ) + .unwrap(); + let ctx = ctx_with_global_hooks(hooks_map( + "turn.completed", + &[("win-gate-probe", r#"call "%HOOK_BAT%""#)], + )); + + fire( + HookEvent::TurnCompleted, + &ctx, + &[ + ("HOOK_BAT", bat.display().to_string()), + ("HOOK_OUT", out.display().to_string()), + ("HOOK_GATE", gate.display().to_string()), + ("HOOK_DONE", done.display().to_string()), + ], + None, + ); + + wait_for("hook start marker", || out.exists()).await; + assert!( + !done.exists(), + "dispatch must return before the hook completes" + ); + + std::fs::write(&gate, "").unwrap(); + wait_for("hook completion", || done.exists()).await; + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn payload_file_is_written_and_removed() { + let guard = TestConfigDirGuard::new("hooks-win-payload"); + let out = guard.path.join("payload-out"); + let path_out = guard.path.join("payload-path"); + let command = r#"copy /Y "%COYOTE_HOOK_PAYLOAD_FILE%" "%HOOK_OUT%" >nul && (echo %COYOTE_HOOK_PAYLOAD_FILE%)> "%HOOK_PATH%""#; + let ctx = ctx_with_global_hooks(hooks_map("tool.started", &[("win-payload", command)])); + let payload = r#"{"path":"C:/target","recursive":true}"#; + + fire( + HookEvent::ToolStarted, + &ctx, + &[ + ("HOOK_OUT", out.display().to_string()), + ("HOOK_PATH", path_out.display().to_string()), + ], + Some(payload.to_string()), + ); + + // `echo` terminates the path with CRLF, so a complete read of the + // path file also proves the earlier `copy` finished. + wait_for("payload path echo", || { + std::fs::read_to_string(&path_out).is_ok_and(|path| path.ends_with("\r\n")) + }) + .await; + assert_eq!(std::fs::read_to_string(&out).unwrap(), payload); + + let payload_file = + PathBuf::from(std::fs::read_to_string(&path_out).unwrap().trim_end()); + let file_name = payload_file.file_name().unwrap().to_string_lossy(); + assert!(file_name.starts_with("coyote-hook-tool.started-")); + // No PayloadDirOverrideGuard here: the engine must fall through + // to the per-user temp dir, which is what scopes payload files to + // the owning user on Windows. + assert!(payload_file.starts_with(env::temp_dir())); + wait_for("payload file removal", || !payload_file.exists()).await; + } + + #[test] + #[serial] + fn install_builtin_hooks_lands_under_a_backslashed_override() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let hooks_dir = PathBuf::from(format!( + "{}\\coyote-win-bs-{unique}\\hooks", + env::temp_dir().display() + )); + let _env = testing::EnvVarGuard::set(utils::get_env_name("hooks_dir"), &hooks_dir); + + let result = install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None); + + // Capture outcomes first and assert only after the temp dir is + // removed, so a failed assertion cannot leak it. + let installed = ( + hooks_dir.join("notify.sh").is_file(), + hooks_dir.join("log-events.sh").is_file(), + ); + let _ = std::fs::remove_dir_all(hooks_dir.parent().unwrap()); + + result.unwrap(); + assert!(installed.0, "notify.sh must land under the backslashed dir"); + assert!( + installed.1, + "log-events.sh must land under the backslashed dir" + ); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn hook_cwds_resolve_per_scope_on_windows_paths() { + let guard = TestConfigDirGuard::new("hooks-win-cwd"); + let global = hooks_map("turn.completed", &[("win-cwd-global", "global-cmd")]); + let role = hooks_map("turn.completed", &[("win-cwd-role", "role-cmd")]); + let agent = hooks_map("turn.completed", &[("win-cwd-agent", "agent-cmd")]); + + let resolved = resolve_hooks( + HookEvent::TurnCompleted, + &global, + None, + Some(&role), + Some((&agent, "win-cwd-probe")), + ); + + assert_eq!( + names(&resolved), + ["win-cwd-global", "win-cwd-role", "win-cwd-agent"] + ); + assert_eq!(resolved[0].cwd, paths::config_dir()); + assert_eq!(resolved[1].cwd, paths::roles_dir()); + assert_eq!(resolved[2].cwd, paths::agent_data_dir("win-cwd-probe")); + + let out = guard.path.join("cwd-out"); + let ctx = ctx_with_global_hooks(hooks_map( + "turn.completed", + &[("win-cwd-echo", r#"(echo %CD%)> "%HOOK_OUT%""#)], + )); + fire( + HookEvent::TurnCompleted, + &ctx, + &[("HOOK_OUT", out.display().to_string())], + None, + ); + + wait_for("hook cwd echo", || { + std::fs::read_to_string(&out).is_ok_and(|cwd| cwd.ends_with("\r\n")) + }) + .await; + // %CD% can surface 8.3 short names or different casing; + // canonicalize both sides before comparing. + let echoed = std::fs::read_to_string(&out).unwrap(); + assert_eq!( + std::fs::canonicalize(echoed.trim_end()).unwrap(), + std::fs::canonicalize(paths::config_dir()).unwrap() + ); + } + + #[test] + #[serial] + fn install_completes_without_the_unix_exec_bit() { + let guard = HooksDirGuard::new("win-hooks-exec-noop"); + + install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None).unwrap(); + + let notify = guard.root.join("notify.sh"); + assert!(notify.is_file()); + let embedded = HookAssets::get("notify.sh").unwrap(); + assert_eq!( + std::fs::read_to_string(¬ify).unwrap(), + std::str::from_utf8(&embedded.data).unwrap() + ); + } + } } diff --git a/src/main.rs b/src/main.rs index 52ff77a..77b2d0a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -58,8 +58,37 @@ use std::path::PathBuf; use std::sync::atomic::Ordering; use std::{env, fs, process, sync::Arc}; -#[tokio::main] -async fn main() -> Result<()> { +const MAIN_STACK_SIZE: usize = 16 * 1024 * 1024; + +/// Bootstraps the async entry point on a dedicated thread with an explicit +/// large stack instead of `#[tokio::main]`, which would poll the root +/// future on the (small, non-configurable) OS main thread. Everything else +/// matches the macro expansion: same multi-thread runtime flavor, same +/// `enable_all()` driver set (so `tokio::signal::ctrl_c()` keeps working), +/// same `block_on` of the async body, and the `Result` propagates +/// unchanged. Worker threads get the same headroom because spawned tasks +/// (graph nodes, subagents) embed the same class of giant state machine. +/// The thread is named "main" and panics are re-raised on the real main +/// thread without re-invoking the panic hook, keeping panic output and the +/// exit code identical to the direct arrangement. +fn main() -> Result<()> { + std::thread::Builder::new() + .name("main".into()) + .stack_size(MAIN_STACK_SIZE) + .spawn(|| { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_stack_size(MAIN_STACK_SIZE) + .build() + .expect("failed to build the tokio runtime") + .block_on(async_main()) + }) + .expect("failed to spawn the main bootstrap thread") + .join() + .unwrap_or_else(|panic| std::panic::resume_unwind(panic)) +} + +async fn async_main() -> Result<()> { load_env_file()?; CompleteEnv::with_factory(Cli::command).complete(); let cli = Cli::parse(); @@ -113,6 +142,15 @@ async fn main() -> Result<()> { || cli.list_macros || cli.list_skills || cli.list_sessions; + let readout_flag = (cli.info + || cli.list_models + || cli.list_roles + || cli.list_agents + || cli.list_rags + || cli.list_macros + || cli.list_skills + || cli.list_sessions) + && !cli.sync_models; let vault_flags = cli.add_secret.is_some() || cli.get_secret.is_some() || cli.update_secret.is_some() @@ -140,7 +178,18 @@ async fn main() -> Result<()> { return config::list_installed_bundles(); } - install_builtins()?; + let mcp_inspect = + cli.mcp_list && cli.mcp_get.is_none() && cli.mcp_remove.is_none() && cli.mcp_add.is_none(); + let vault_inspect = cli.list_secrets + && cli.add_secret.is_none() + && cli.get_secret.is_none() + && cli.update_secret.is_none() + && cli.delete_secret.is_none(); + let inspection_only = + (readout_flag || vault_inspect || mcp_inspect) && cli.agent.is_none() && cli.role.is_none(); + if !inspection_only { + install_builtins()?; + } if let Some(category) = cli.install_builtins { return config::install_assets(category); @@ -164,7 +213,7 @@ async fn main() -> Result<()> { } if let Some(client_arg) = &cli.authenticate { - let cfg = Config::load_with_interpolation(true).await?; + let cfg = Config::load_with_interpolation(true, false).await?; let app_config = AppConfig::from_config(cfg)?; let (client_name, provider) = resolve_oauth_client(client_arg.as_deref(), &app_config.clients)?; @@ -173,7 +222,7 @@ async fn main() -> Result<()> { } if let Some(server_name) = &cli.auth_mcp { - let cfg = Config::load_with_interpolation(true).await?; + let cfg = Config::load_with_interpolation(true, false).await?; let app_config = AppConfig::from_config(cfg)?; let vault = Vault::init(&app_config)?; let mcp_path = paths::mcp_config_file(); @@ -225,9 +274,17 @@ async fn main() -> Result<()> { let mcp_action = cli.mcp_list || cli.mcp_get.is_some() || cli.mcp_remove.is_some() || cli.mcp_add.is_some(); if mcp_action { - let cfg = Config::load_with_interpolation(true).await?; - let app_config = AppConfig::from_config(cfg)?; - let vault = Vault::init(&app_config)?; + let cfg = Config::load_with_interpolation(true, mcp_inspect).await?; + let app_config = if mcp_inspect { + AppConfig::from_config_lenient(cfg)? + } else { + AppConfig::from_config(cfg)? + }; + let vault = if mcp_inspect { + Vault::init_lenient(&app_config) + } else { + Vault::init(&app_config)? + }; mcp::manage::handle(&cli, &vault)?; @@ -235,16 +292,24 @@ async fn main() -> Result<()> { } if vault_flags { - let cfg = Config::load_with_interpolation(true).await?; - let app_config = AppConfig::from_config(cfg)?; + let cfg = Config::load_with_interpolation(true, vault_inspect).await?; + let app_config = if vault_inspect { + AppConfig::from_config_lenient(cfg)? + } else { + AppConfig::from_config(cfg)? + }; let vault = Vault::init(&app_config)?; return Vault::handle_vault_flags(cli, &vault); } let abort_signal = create_abort_signal(); let start_mcp_servers = cli.agent.is_none() && cli.role.is_none(); - let cfg = Config::load_with_interpolation(info_flag).await?; - let mut app_config = AppConfig::from_config(cfg)?; + let cfg = Config::load_with_interpolation(info_flag, readout_flag).await?; + let mut app_config = if readout_flag { + AppConfig::from_config_lenient(cfg)? + } else { + AppConfig::from_config(cfg)? + }; if cli.no_workspace_mcp { app_config.no_workspace_mcp = true; } @@ -258,14 +323,17 @@ async fn main() -> Result<()> { log_path, start_mcp_servers, info_flag, + inspection_only, abort_signal.clone(), ) .await?, ); let mut ctx = RequestContext::bootstrap(app_state, working_mode, info_flag)?; let app_config = Arc::clone(&ctx.app.config); - ctx.bootstrap_tools(&app_config, start_mcp_servers, abort_signal.clone()) - .await?; + if !inspection_only { + ctx.bootstrap_tools(&app_config, start_mcp_servers, abort_signal.clone()) + .await?; + } { let app = &*ctx.app.config; @@ -306,9 +374,12 @@ async fn run( } if cli.list_models { - for model in list_models(ctx.app.config.as_ref(), ModelType::Chat) { - println!("{}", model.id()); - } + let models = list_models(ctx.app.config.as_ref(), ModelType::Chat) + .iter() + .map(|model| model.id()) + .collect::>() + .join("\n"); + println!("{models}"); return Ok(()); } if cli.list_roles { @@ -389,11 +460,12 @@ async fn run( ctx.use_agent(app.as_ref(), agent, session, abort_signal.clone()) .await?; // The top-level agent bracket does NOT open here: the inspection - // flags below (--list-sessions, --info, --macro, --rebuild-rag, - // --init-*) return early without running the agent, and an open - // bracket on those paths would leak `agent.started` with no - // terminal event. It opens at the dispatch sites further down, - // where an agent run is actually committed. + // flags below (--list-sessions, --info, --rebuild-rag, --init-*) + // return early without running the agent, and an open bracket on + // those paths would leak `agent.started` with no terminal event. + // It opens at the dispatch sites further down, where a run is + // actually committed, including the --macro arm, which executes + // rather than inspects. } else { let app: Arc = Arc::clone(&ctx.app.config); if let Some(prompt) = &cli.temp_role { @@ -526,8 +598,11 @@ async fn run( } } if let Some(name) = &cli.macro_name { - macro_execute(&mut ctx, name, text.as_deref(), abort_signal.clone()).await?; - return Ok(()); + ctx.top_level_agent_started(); + let result = macro_execute(&mut ctx, name, text.as_deref(), abort_signal.clone()).await; + ctx.top_level_agent_finished(result.as_ref().err(), Some(&abort_signal)); + hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await; + return result; } if cli.execute && !is_repl { // Dispatch committed: open the top-level agent bracket only now, @@ -538,7 +613,7 @@ async fn run( shell_execute(&mut ctx, &SHELL, input, abort_signal.clone()).await } .await; - ctx.top_level_agent_finished(result.as_ref().err()); + ctx.top_level_agent_finished(result.as_ref().err(), Some(&abort_signal)); hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await; return result; } @@ -572,7 +647,7 @@ async fn run( start_directive(&mut ctx, input, cli.code, abort_signal.clone()).await } .await; - ctx.top_level_agent_finished(result.as_ref().err()); + ctx.top_level_agent_finished(result.as_ref().err(), Some(&abort_signal)); hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await; result } @@ -706,7 +781,7 @@ async fn shell_execute( if code == 0 && app.save_shell_history { let _ = append_to_shell_history(&shell.name, &eval_str, code); } - ctx.top_level_agent_finished(None); + ctx.top_level_agent_finished(None, None); hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await; process::exit(code); } diff --git a/src/rag/mod.rs b/src/rag/mod.rs index 6f5c627..f88cca8 100644 --- a/src/rag/mod.rs +++ b/src/rag/mod.rs @@ -13,6 +13,7 @@ mod splitter; use self::graph::{KnowledgeGraph, extract_entities}; use self::provider::RagProvider; use self::providers::{DuckDbProvider, QdrantProvider, YamlProvider}; +use crate::hooks::RagSyncHooks; use crate::sandbox::mcp_credentials; use crate::vault::{Vault, interpolate_secrets}; @@ -144,6 +145,7 @@ impl Rag { doc_paths: &[String], config: &RagInitConfig, abort_signal: AbortSignal, + sync_hooks: RagSyncHooks, ) -> Result { if doc_paths.is_empty() { bail!("Cannot build RAG knowledge base '{name}' with no documents"); @@ -154,15 +156,27 @@ impl Rag { let mut rag = Self::create(app, name, save_path, data).await?; let loaders = app.document_loaders.clone(); let (spinner, spinner_rx) = Spinner::create(""); - abortable_run_with_spinner_rx( + sync_hooks.fire_started(&rag.name, &rag.path); + let synced = abortable_run_with_spinner_rx( rag.sync_documents(doc_paths, true, false, loaders, Some(spinner)), spinner_rx, abort_signal, ) - .await?; - if rag.save()? { - println!("✓ Saved RAG to '{}'.", save_path.display()); + .await; + if let Err(err) = synced { + sync_hooks.fire_failed(&rag.name, &rag.path, &err); + return Err(err); } + + match rag.save() { + Ok(true) => println!("✓ Saved RAG to '{}'.", save_path.display()), + Ok(false) => {} + Err(err) => { + sync_hooks.fire_failed(&rag.name, &rag.path, &err); + return Err(err); + } + } + sync_hooks.fire_completed(&rag.name, &rag.path, rag.file_count()); Ok(rag) } @@ -261,6 +275,7 @@ impl Rag { doc_paths: &[String], abort_signal: AbortSignal, prompt_for_driver: bool, + sync_hooks: RagSyncHooks, ) -> Result { if !*IS_STDOUT_TERMINAL { bail!("Failed to init rag in non-interactive mode"); @@ -305,15 +320,27 @@ impl Rag { }; let loaders = app.document_loaders.clone(); let (spinner, spinner_rx) = Spinner::create(""); - abortable_run_with_spinner_rx( + sync_hooks.fire_started(&rag.name, &rag.path); + let synced = abortable_run_with_spinner_rx( rag.sync_documents(&paths, true, false, loaders, Some(spinner)), spinner_rx, abort_signal, ) - .await?; - if rag.save()? { - println!("✓ Saved RAG to '{}'.", save_path.display()); + .await; + if let Err(err) = synced { + sync_hooks.fire_failed(&rag.name, &rag.path, &err); + return Err(err); } + + match rag.save() { + Ok(true) => println!("✓ Saved RAG to '{}'.", save_path.display()), + Ok(false) => {} + Err(err) => { + sync_hooks.fire_failed(&rag.name, &rag.path, &err); + return Err(err); + } + } + sync_hooks.fire_completed(&rag.name, &rag.path, rag.file_count()); Ok(rag) } @@ -636,10 +663,12 @@ impl Rag { force_reingest: bool, app: &AppConfig, abort_signal: AbortSignal, + sync_hooks: RagSyncHooks, ) -> Result<()> { let loaders = app.document_loaders.clone(); let (spinner, spinner_rx) = Spinner::create(""); - abortable_run_with_spinner_rx( + sync_hooks.fire_started(&self.name, &self.path); + let synced = abortable_run_with_spinner_rx( self.sync_documents( document_paths, refresh, @@ -650,10 +679,21 @@ impl Rag { spinner_rx, abort_signal, ) - .await?; - if self.save()? { - println!("✓ Saved rag to '{}'.", self.path); + .await; + if let Err(err) = synced { + sync_hooks.fire_failed(&self.name, &self.path, &err); + return Err(err); } + + match self.save() { + Ok(true) => println!("✓ Saved rag to '{}'.", self.path), + Ok(false) => {} + Err(err) => { + sync_hooks.fire_failed(&self.name, &self.path, &err); + return Err(err); + } + } + sync_hooks.fire_completed(&self.name, &self.path, self.file_count()); Ok(()) } @@ -2280,6 +2320,8 @@ where #[cfg(test)] mod tests { use super::*; + use crate::hooks::{HookDef, HooksMap, test_sink}; + use serial_test::serial; use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -2313,6 +2355,340 @@ mod tests { } } + fn rag_hooks_ctx(marker: &str) -> RequestContext { + let mut hooks_map = HooksMap::default(); + for event in ["rag.sync.started", "rag.sync.completed", "rag.sync.failed"] { + hooks_map.insert( + event.to_string(), + vec![HookDef { + name: format!("{marker}-{event}"), + command: "true".to_string(), + }], + ); + } + let mut app = AppState::test_default(); + app.config = Arc::new(AppConfig { + hooks: hooks_map, + ..Default::default() + }); + RequestContext::new(Arc::new(app), WorkingMode::Cmd) + } + + fn rag_captures(marker: &str) -> Vec { + test_sink::snapshot() + .into_iter() + .filter(|capture| capture.hook_name.starts_with(marker)) + .collect() + } + + fn buildable_data() -> RagData { + RagData { + driver: "yaml".to_string(), + // The only embedding model resolvable in tests: the model + // registry is a process-global OnceLock pre-seeded by + // `seed_model_registries` in the request_context tests. + embedding_model: "test-seeded:test-embedder".to_string(), + chunk_size: 1000, + chunk_overlap: 100, + top_k: 5, + ..Default::default() + } + } + + /// An AppConfig whose client list can construct the "test-seeded" + /// embeddings client: init_with_config instantiates the client even + /// when a sync has nothing to embed. + fn seeded_client_app() -> AppConfig { + let mut client = ClientConfig::default(); + if let ClientConfig::OpenAIConfig(config) = &mut client { + config.name = Some("test-seeded".to_string()); + let mut embedder = ModelData::new("test-embedder"); + embedder.model_type = "embedding".to_string(); + config.models = vec![embedder]; + } + AppConfig { + clients: vec![client], + ..Default::default() + } + } + + fn init_config() -> RagInitConfig { + RagInitConfig { + embedding_model: Some("test-seeded:test-embedder".to_string()), + chunk_size: Some(1000), + chunk_overlap: Some(100), + ..Default::default() + } + } + + fn yaml_rag(name: &str, path: &Path, data: RagData) -> Rag { + Rag { + app_config: Arc::new(AppConfig::default()), + name: name.to_string(), + path: path.display().to_string(), + embedding_model: Model::new("openai", "text-embedding-3-small"), + bm25: data.build_bm25(), + provider: Box::new(YamlProvider::from_data(&data)), + node_to_docs: IndexMap::new(), + data, + last_sources: RwLock::new(None), + } + } + + #[tokio::test] + #[serial] + async fn refresh_document_paths_fires_started_then_completed_with_doc_count() { + let _sink = test_sink::install(); + let marker = "rag-sync-ok-v5k"; + let dir = TempDir::new("sync-hooks-ok"); + let doc = dir.path.join("doc.txt"); + fs::write(&doc, "hello rag hooks").unwrap(); + let doc_path = doc.display().to_string(); + + // The prepopulated file entry must use the exact string + // sync_documents resolves, so the sync sees an unchanged corpus and + // never needs an embeddings client. + let loaders = HashMap::new(); + let (_, _, _, _, local_paths) = resolve_paths(&loaders, std::slice::from_ref(&doc_path)) + .await + .unwrap(); + let resolved_path = local_paths.iter().next().unwrap().clone(); + + let mut data = buildable_data(); + data.next_file_id = 1; + data.files.insert( + 0, + RagFile { + hash: sha256("hello rag hooks"), + path: resolved_path, + documents: vec![], + }, + ); + let rag_path = dir.path.join("kb.yaml"); + let rag_path_display = rag_path.display().to_string(); + let mut rag = yaml_rag("kb", &rag_path, data); + let sync_hooks = RagSyncHooks::resolve(&rag_hooks_ctx(marker)); + + rag.refresh_document_paths( + &[doc_path], + false, + false, + &AppConfig::default(), + create_abort_signal(), + sync_hooks, + ) + .await + .unwrap(); + + let captures = rag_captures(marker); + assert_eq!(captures.len(), 2, "{captures:?}"); + assert_eq!(captures[0].hook_name, format!("{marker}-rag.sync.started")); + assert_eq!( + captures[0].envs.get("COYOTE_RAG_NAME").map(String::as_str), + Some("kb") + ); + assert_eq!( + captures[0].envs.get("COYOTE_RAG_PATH").map(String::as_str), + Some(rag_path_display.as_str()) + ); + assert_eq!( + captures[1].hook_name, + format!("{marker}-rag.sync.completed") + ); + assert_eq!( + captures[1] + .envs + .get("COYOTE_RAG_DOC_COUNT") + .map(String::as_str), + Some("1") + ); + } + + #[tokio::test] + #[serial] + async fn refresh_document_paths_fires_started_then_failed_on_sync_error() { + let _sink = test_sink::install(); + let marker = "rag-sync-fail-b8m"; + let dir = TempDir::new("sync-hooks-fail"); + let missing = dir.path.join("missing.txt").display().to_string(); + let rag_path = dir.path.join("kb.yaml"); + let mut rag = yaml_rag("kb", &rag_path, buildable_data()); + let sync_hooks = RagSyncHooks::resolve(&rag_hooks_ctx(marker)); + + let result = rag + .refresh_document_paths( + &[missing], + false, + false, + &AppConfig::default(), + create_abort_signal(), + sync_hooks, + ) + .await; + + assert!(result.is_err(), "syncing a nonexistent document must fail"); + let captures = rag_captures(marker); + assert_eq!(captures.len(), 2, "{captures:?}"); + assert_eq!(captures[0].hook_name, format!("{marker}-rag.sync.started")); + assert_eq!(captures[1].hook_name, format!("{marker}-rag.sync.failed")); + assert_eq!( + captures[1].envs.get("COYOTE_RAG_NAME").map(String::as_str), + Some("kb") + ); + assert!( + captures[1] + .envs + .get("COYOTE_ERROR") + .is_some_and(|error| !error.is_empty()) + ); + } + + #[tokio::test] + #[serial] + async fn init_with_config_fires_started_then_completed_after_save() { + let _sink = test_sink::install(); + let marker = "rag-init-ok-q7t"; + let dir = TempDir::new("init-hooks-ok"); + // An empty document syncs without embedding anything, so the build + // needs no live embeddings endpoint. + let doc = dir.path.join("doc.txt"); + fs::write(&doc, "").unwrap(); + let save_path = dir.path.join("kb.yaml"); + let sync_hooks = RagSyncHooks::resolve(&rag_hooks_ctx(marker)); + + let rag = Rag::init_with_config( + &seeded_client_app(), + "kb-init", + &save_path, + &[doc.display().to_string()], + &init_config(), + create_abort_signal(), + sync_hooks, + ) + .await + .unwrap(); + + assert_eq!(rag.file_count(), 1); + assert!( + save_path.exists(), + "completed must only fire after the knowledge base was saved" + ); + let captures = rag_captures(marker); + assert_eq!(captures.len(), 2, "{captures:?}"); + assert_eq!(captures[0].hook_name, format!("{marker}-rag.sync.started")); + assert_eq!( + captures[1].hook_name, + format!("{marker}-rag.sync.completed") + ); + assert_eq!( + captures[1] + .envs + .get("COYOTE_RAG_DOC_COUNT") + .map(String::as_str), + Some("1") + ); + } + + #[tokio::test] + #[serial] + async fn init_with_config_fires_failed_when_save_fails() { + let _sink = test_sink::install(); + let marker = "rag-init-save-fail-w2j"; + let dir = TempDir::new("init-hooks-save-fail"); + let doc = dir.path.join("doc.txt"); + fs::write(&doc, "").unwrap(); + // A regular file where save() expects a parent directory makes the + // save fail after a successful sync. + let blocker = dir.path.join("blocker"); + fs::write(&blocker, "not a directory").unwrap(); + let save_path = blocker.join("kb.yaml"); + let sync_hooks = RagSyncHooks::resolve(&rag_hooks_ctx(marker)); + + let result = Rag::init_with_config( + &seeded_client_app(), + "kb-init", + &save_path, + &[doc.display().to_string()], + &init_config(), + create_abort_signal(), + sync_hooks, + ) + .await; + + assert!( + result.is_err(), + "an unwritable save path must fail the init" + ); + let captures = rag_captures(marker); + assert_eq!(captures.len(), 2, "{captures:?}"); + assert_eq!(captures[0].hook_name, format!("{marker}-rag.sync.started")); + assert_eq!(captures[1].hook_name, format!("{marker}-rag.sync.failed")); + assert!( + captures[1] + .envs + .get("COYOTE_ERROR") + .is_some_and(|error| !error.is_empty()) + ); + } + + // The user-abort arm in abortable_run_with_spinner_rx only exists when + // stdout is a terminal, so a unit test cannot reach it; its + // bail!("Aborted.") surfaces through the same `synced` Err arm this + // sync error drives. + #[tokio::test] + #[serial] + async fn init_with_config_fires_failed_on_sync_error() { + let _sink = test_sink::install(); + let marker = "rag-init-fail-d4n"; + let dir = TempDir::new("init-hooks-fail"); + let missing = dir.path.join("missing.txt").display().to_string(); + let save_path = dir.path.join("kb.yaml"); + let sync_hooks = RagSyncHooks::resolve(&rag_hooks_ctx(marker)); + + let result = Rag::init_with_config( + &seeded_client_app(), + "kb-init", + &save_path, + &[missing], + &init_config(), + create_abort_signal(), + sync_hooks, + ) + .await; + + assert!(result.is_err(), "syncing a nonexistent document must fail"); + assert!(!save_path.exists()); + let captures = rag_captures(marker); + assert_eq!(captures.len(), 2, "{captures:?}"); + assert_eq!(captures[0].hook_name, format!("{marker}-rag.sync.started")); + assert_eq!(captures[1].hook_name, format!("{marker}-rag.sync.failed")); + assert!( + captures[1] + .envs + .get("COYOTE_ERROR") + .is_some_and(|error| !error.is_empty()) + ); + } + + #[tokio::test] + #[serial] + async fn pure_load_fires_no_rag_sync_events() { + let _sink = test_sink::install(); + test_sink::drain(); + let dir = TempDir::new("sync-hooks-load"); + let path = dir.path.join("kb.yaml"); + fs::write(&path, serde_yaml::to_string(&buildable_data()).unwrap()).unwrap(); + + let rag = Rag::load(&AppConfig::default(), "kb", &path).await.unwrap(); + + assert_eq!(rag.file_count(), 0); + let captures = test_sink::drain(); + assert!( + captures.is_empty(), + "a pure load must dispatch no hooks: {captures:?}" + ); + } + #[test] fn attached_ragdata_serializes_driver_and_attached() { let mut data = RagData { diff --git a/src/repl/mod.rs b/src/repl/mod.rs index 00e8edc..9751a5a 100644 --- a/src/repl/mod.rs +++ b/src/repl/mod.rs @@ -19,6 +19,7 @@ use crate::config::{AssetCategory, paths}; use crate::function::agents::{GuardrailAction, check_pending_tasks_guardrail}; use crate::hooks::{self, HookEvent}; use crate::render::render_error; +use crate::supervisor::Supervisor; use crate::utils::{ AbortSignal, SHELL, abortable_run_with_spinner, create_abort_signal, dimmed_text, drain_stale_tty_input, run_command, set_text, temp_file, @@ -67,7 +68,7 @@ pub(crate) struct TurnBracket(()); impl TurnBracket { #[must_use] pub(crate) fn enter(ctx: &RequestContext) -> Self { - hooks::fire(HookEvent::TurnStarted, ctx, &[], None); + hooks::fire(HookEvent::TurnStarted, ctx, &hooks::role_extras(ctx), None); Self(()) } @@ -78,16 +79,25 @@ impl TurnBracket { result: Result, ) -> Result { if abort_signal.aborted_ctrlc() { - hooks::fire(HookEvent::TurnInterrupted, ctx, &[], None); + hooks::fire( + HookEvent::TurnInterrupted, + ctx, + &hooks::role_extras(ctx), + None, + ); } else { match &result { - Ok(_) => hooks::fire(HookEvent::TurnCompleted, ctx, &[], None), - Err(err) => hooks::fire( - HookEvent::TurnFailed, + Ok(_) => hooks::fire( + HookEvent::TurnCompleted, ctx, - &[("COYOTE_ERROR", format!("{err:#}"))], + &hooks::role_extras(ctx), None, ), + Err(err) => { + let mut extras = hooks::role_extras(ctx); + extras.push(("COYOTE_ERROR", format!("{err:#}"))); + hooks::fire(HookEvent::TurnFailed, ctx, &extras, None); + } } } result @@ -512,9 +522,8 @@ Type ".help" for additional help. } } Ok(Signal::CtrlC) => { - self.abort_signal.set_ctrlc(); if let Some(supervisor) = self.ctx.read().supervisor.clone() { - supervisor.read().cancel_recursive(); + latch_prompt_interrupt(&self.abort_signal, &supervisor); } println!("(To exit, press Ctrl+D or enter \".exit\")\n"); } @@ -533,7 +542,7 @@ Type ".help" for additional help. let exit_result = self.ctx.write().exit_session(); self.ctx .write() - .top_level_agent_finished(exit_result.as_ref().err()); + .top_level_agent_finished(exit_result.as_ref().err(), Some(&self.abort_signal)); hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await; exit_result } @@ -1143,7 +1152,12 @@ pub async fn run_repl_command( abort_signal.clone(), ) .await?; - hooks::fire(HookEvent::SessionCompressed, ctx, &[], None); + hooks::fire( + HookEvent::SessionCompressed, + ctx, + &hooks::role_extras(ctx), + None, + ); println!("✓ Successfully compressed the session."); } _ => { @@ -1697,7 +1711,12 @@ fn print_turn_divider() { async fn auto_compress_session(ctx: &mut RequestContext) { match ctx.compress_session().await { - Ok(()) => hooks::fire(HookEvent::SessionCompressed, ctx, &[], None), + Ok(()) => hooks::fire( + HookEvent::SessionCompressed, + ctx, + &hooks::role_extras(ctx), + None, + ), Err(err) => warn!("Failed to compress the session: {err}"), } } @@ -1715,6 +1734,14 @@ fn reset_continuation(ctx: &mut RequestContext) { ctx.reset_continuation_count(); } +pub(crate) fn latch_prompt_interrupt(abort_signal: &AbortSignal, supervisor: &RwLock) { + let supervisor = supervisor.read(); + if supervisor.has_active_tasks() { + abort_signal.set_ctrlc(); + } + supervisor.cancel_recursive(); +} + fn pause_banner_text(ctx: &RequestContext) -> Option { let reason = ctx.auto_continue_paused.as_deref()?; if !ctx.todo_list.has_incomplete() { @@ -2010,7 +2037,7 @@ pub fn split_args_text(line: &str, is_win: bool) -> (Vec, &str) { mod tests { use super::*; use crate::client::{ClientConfig, Model}; - use crate::config::{AppState, RoleLike, Session, WorkingMode}; + use crate::config::{AppState, Role, RoleLike, Session, TEMP_ROLE_NAME, WorkingMode}; use crate::hooks::{HookDef, HooksMap, test_sink}; use anyhow::anyhow; use serial_test::serial; @@ -2157,6 +2184,117 @@ mod tests { assert_eq!(turn_counts(marker), (1, 0, 1, 0)); } + #[tokio::test] + #[serial] + async fn turn_events_carry_the_role_a_session_holds() { + let _sink = test_sink::install(); + let marker = "tb-role-m3x"; + let mut ctx = ctx_with_hooks(&TURN_EVENTS, marker); + // The role lives on the session (as after `.role` inside a session); + // fire-time resolution must find it there. + let mut session = Session::default(); + session.set_role(Role::new("dev-role", "Prompt")); + ctx.session = Some(session); + let abort_signal = create_abort_signal(); + + let bracket = TurnBracket::enter(&ctx); + let result: Result<()> = Ok(()); + bracket.finish(&ctx, &abort_signal, result).unwrap(); + + let captures: Vec<_> = test_sink::snapshot() + .into_iter() + .filter(|capture| capture.hook_name.starts_with(marker)) + .collect(); + assert_eq!(captures.len(), 2, "{captures:?}"); + for capture in &captures { + assert_eq!( + capture.envs.get("COYOTE_ROLE").map(String::as_str), + Some("dev-role"), + "{} must carry the session-held role", + capture.hook_name + ); + } + } + + #[tokio::test] + #[serial] + async fn turn_interrupted_carries_the_role_a_session_holds() { + let _sink = test_sink::install(); + let marker = "tb-role-int-f7c"; + let mut ctx = ctx_with_hooks(&TURN_EVENTS, marker); + let mut session = Session::default(); + session.set_role(Role::new("dev-role", "Prompt")); + ctx.session = Some(session); + let abort_signal = create_abort_signal(); + abort_signal.set_ctrlc(); + + let bracket = TurnBracket::enter(&ctx); + let result: Result<()> = Ok(()); + bracket.finish(&ctx, &abort_signal, result).unwrap(); + + let interrupted = test_sink::snapshot() + .into_iter() + .find(|capture| capture.hook_name == format!("{marker}-turn.interrupted")) + .expect("turn.interrupted capture"); + assert_eq!( + interrupted.envs.get("COYOTE_ROLE").map(String::as_str), + Some("dev-role"), + "turn.interrupted must carry the session-held role" + ); + } + + #[tokio::test] + #[serial] + async fn turn_failed_carries_the_role_a_session_holds() { + let _sink = test_sink::install(); + let marker = "tb-role-fail-z2n"; + let mut ctx = ctx_with_hooks(&TURN_EVENTS, marker); + let mut session = Session::default(); + session.set_role(Role::new("dev-role", "Prompt")); + ctx.session = Some(session); + let abort_signal = create_abort_signal(); + + let bracket = TurnBracket::enter(&ctx); + let result: Result<()> = Err(anyhow!("turn exploded")); + bracket.finish(&ctx, &abort_signal, result).unwrap_err(); + + let failed = test_sink::snapshot() + .into_iter() + .find(|capture| capture.hook_name == format!("{marker}-turn.failed")) + .expect("turn.failed capture"); + assert_eq!( + failed.envs.get("COYOTE_ROLE").map(String::as_str), + Some("dev-role"), + "turn.failed must carry the session-held role" + ); + } + + #[tokio::test] + #[serial] + async fn turn_events_omit_the_role_var_when_no_role_is_active() { + let _sink = test_sink::install(); + let marker = "tb-norole-q8j"; + let ctx = ctx_with_hooks(&TURN_EVENTS, marker); + let abort_signal = create_abort_signal(); + + let bracket = TurnBracket::enter(&ctx); + let result: Result<()> = Ok(()); + bracket.finish(&ctx, &abort_signal, result).unwrap(); + + let captures: Vec<_> = test_sink::snapshot() + .into_iter() + .filter(|capture| capture.hook_name.starts_with(marker)) + .collect(); + assert_eq!(captures.len(), 2, "{captures:?}"); + for capture in &captures { + assert!( + !capture.envs.contains_key("COYOTE_ROLE"), + "{} must omit COYOTE_ROLE without an active role", + capture.hook_name + ); + } + } + /// Drives a deep REPL future to completion on a thread with extra stack /// headroom: nested `run_repl_command`/`compress_session` poll frames /// are deep in debug builds and overflow the default test-thread stack @@ -2304,6 +2442,48 @@ mod tests { ); } + #[test] + #[serial] + fn session_compressed_carries_the_active_role_name() { + let _sink = test_sink::install(); + let marker = "compress-role-t6b"; + run_async(async { + let mut ctx = compressible_ctx(marker); + // A temp role (`--prompt`) is a real role for COYOTE_ROLE. + ctx.role = Some(Role::new(TEMP_ROLE_NAME, "Session role prompt")); + auto_compress_session(&mut ctx).await; + }); + + let capture = test_sink::snapshot() + .into_iter() + .find(|capture| capture.hook_name == format!("{marker}-session.compressed")) + .expect("session.compressed capture"); + assert_eq!( + capture.envs.get("COYOTE_ROLE").map(String::as_str), + Some(TEMP_ROLE_NAME) + ); + } + + #[test] + #[serial] + fn session_compressed_omits_the_role_var_when_no_role_is_active() { + let _sink = test_sink::install(); + let marker = "compress-norole-p9c"; + run_async(async { + let mut ctx = compressible_ctx(marker); + auto_compress_session(&mut ctx).await; + }); + + let capture = test_sink::snapshot() + .into_iter() + .find(|capture| capture.hook_name == format!("{marker}-session.compressed")) + .expect("session.compressed capture"); + assert!( + !capture.envs.contains_key("COYOTE_ROLE"), + "session.compressed must omit COYOTE_ROLE without an active role" + ); + } + #[test] #[serial] fn auto_compress_failure_fires_no_session_compressed_and_swallows_error() { @@ -2898,4 +3078,63 @@ mod tests { assert_eq!(pause_banner_text(&ctx), None); } + + #[test] + fn latch_prompt_interrupt_stays_clear_at_idle_prompt() { + let abort = create_abort_signal(); + let supervisor = RwLock::new(Supervisor::new(4, 3)); + + latch_prompt_interrupt(&abort, &supervisor); + + assert!( + !abort.aborted_ctrlc(), + "ctrl-c with nothing to cancel must not latch as an interruption" + ); + } + + #[test] + fn latch_prompt_interrupt_latches_when_cancelling_active_children() { + use crate::supervisor::mailbox::Inbox; + use crate::supervisor::{AgentExitStatus, AgentHandle, AgentResult}; + + // Keep the runtime alive so the spawned task is never polled and the + // child counts as running. + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let join_handle = rt.spawn(async { + Ok(AgentResult { + id: "done".into(), + agent_name: "test".into(), + output: "result".into(), + exit_status: AgentExitStatus::Completed, + }) + }); + let child_signal = create_abort_signal(); + let mut sup = Supervisor::new(4, 3); + sup.register(AgentHandle { + id: "a1".to_string(), + agent_name: "explore".to_string(), + depth: 1, + inbox: Arc::new(Inbox::new()), + abort_signal: child_signal.clone(), + join_handle, + child_supervisor: None, + }) + .unwrap(); + let supervisor = RwLock::new(sup); + let abort = create_abort_signal(); + + latch_prompt_interrupt(&abort, &supervisor); + + assert!( + abort.aborted_ctrlc(), + "cancelling live work is an interruption" + ); + assert!( + child_signal.aborted_ctrlc(), + "the child must still be cancelled" + ); + } } diff --git a/src/sandbox/mixins.rs b/src/sandbox/mixins.rs index 7021d41..2c0fe64 100644 --- a/src/sandbox/mixins.rs +++ b/src/sandbox/mixins.rs @@ -197,6 +197,11 @@ pub fn discover() -> Result> { ) { out.push(read_mixin(path)?); } + for agent_dir in subdirs_of(&paths::agents_data_dir()) { + for path in collect_mixins(&agent_dir.join("hooks"), &[ScanMode::Flat]) { + out.push(read_mixin(path)?); + } + } for path in collect_mixins(&paths::rags_dir(), &[ScanMode::Flat]) { out.push(read_mixin(path)?); } @@ -206,6 +211,9 @@ pub fn discover() -> Result> { for path in collect_mixins(&paths::roles_dir().join("hooks"), &[ScanMode::Flat]) { out.push(read_mixin(path)?); } + for path in collect_mixins(&paths::hooks_dir(), &[ScanMode::Flat]) { + out.push(read_mixin(path)?); + } if let Ok(cwd) = env::current_dir() && let Some(path) = paths::find_workspace_sbx_mixin(&cwd) @@ -1005,6 +1013,51 @@ network: let _ = fs::remove_dir_all(&root); } + #[test] + #[serial_test::serial] + fn discover_scans_global_and_agent_hooks_mixins_but_never_the_manifest() { + use crate::config::builtin_manifest::BUILTIN_MANIFEST_FILE; + use crate::utils::get_env_name; + + let root = unique_root("discover-hooks-dirs"); + let hooks = root.join("hooks"); + let agent_hooks = root.join("agents").join("researcher").join("hooks"); + fs::create_dir_all(&hooks).unwrap(); + fs::create_dir_all(&agent_hooks).unwrap(); + let global_mixin = hooks.join("notify.sbx-mixin.yaml"); + let agent_mixin = agent_hooks.join("guard.sbx-mixin.yaml"); + fs::write(&global_mixin, "kind: mixin\n").unwrap(); + fs::write(&agent_mixin, "kind: mixin\n").unwrap(); + // The builtin installer's hidden manifest lives in both hooks dirs + // but is not a mixin and must never be picked up. + fs::write(hooks.join(BUILTIN_MANIFEST_FILE), "notify.sh\n").unwrap(); + fs::write(agent_hooks.join(BUILTIN_MANIFEST_FILE), "guard.sh\n").unwrap(); + + let _config = crate::testing::EnvVarGuard::set(get_env_name("config_dir"), &root); + let found: Vec = discover() + .unwrap() + .into_iter() + .map(|mixin| mixin.path) + .collect(); + + assert!( + found.contains(&global_mixin), + "missing global hooks mixin: {found:?}" + ); + assert!( + found.contains(&agent_mixin), + "missing agent hooks mixin: {found:?}" + ); + assert!( + found.iter().all(|path| path + .file_name() + .is_none_or(|name| name != BUILTIN_MANIFEST_FILE)), + "builtin manifest must not be scanned as a 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 diff --git a/src/supervisor/mod.rs b/src/supervisor/mod.rs index 044635c..0a6be9f 100644 --- a/src/supervisor/mod.rs +++ b/src/supervisor/mod.rs @@ -190,6 +190,17 @@ impl Supervisor { .count() } + pub fn has_active_tasks(&self) -> bool { + self.effective_active_count() > 0 + || self.active_job_count() > 0 + || self.agents().any(|agent| { + agent + .child_supervisor + .as_ref() + .is_some_and(|child_sup| child_sup.read().has_active_tasks()) + }) + } + pub fn max_concurrent(&self) -> usize { self.max_concurrent } @@ -449,6 +460,85 @@ mod tests { assert!(result.unwrap_err().to_string().contains("at capacity")); } + #[test] + fn has_active_tasks_false_when_empty_or_all_finished() { + let mut sup = Supervisor::new(4, 3); + assert!(!sup.has_active_tasks()); + // make_handle drops its runtime, aborting the task, so the + // registered agent is already finished. + sup.register(make_handle("a1", "explore", 1)).unwrap(); + assert!(!sup.has_active_tasks()); + } + + #[test] + fn has_active_tasks_true_with_running_agent() { + // Keep the runtime alive so the task is never polled and the agent + // counts as running. + let rt = Builder::new_current_thread().enable_all().build().unwrap(); + let join_handle = rt.spawn(async { + Ok::(AgentResult { + id: "done".into(), + agent_name: "test".into(), + output: "result".into(), + exit_status: AgentExitStatus::Completed, + }) + }); + let running_handle = AgentHandle { + id: "a1".to_string(), + agent_name: "explore".to_string(), + depth: 1, + inbox: Arc::new(Inbox::new()), + abort_signal: create_abort_signal(), + join_handle, + child_supervisor: None, + }; + let mut sup = Supervisor::new(4, 3); + sup.register(running_handle).unwrap(); + assert!(sup.has_active_tasks()); + } + + #[test] + fn has_active_tasks_true_with_running_job() { + let mut sup = Supervisor::new(4, 3).with_max_concurrent_jobs(1); + sup.register(make_job("j1", create_abort_signal())).unwrap(); + assert!(sup.has_active_tasks()); + } + + #[test] + fn has_active_tasks_true_with_only_grandchild_activity() { + // Keep the runtime alive so the grandchild task is never polled and + // counts as running. + let rt = Builder::new_current_thread().enable_all().build().unwrap(); + let join_handle = rt.spawn(async { + Ok::(AgentResult { + id: "done".into(), + agent_name: "test".into(), + output: "result".into(), + exit_status: AgentExitStatus::Completed, + }) + }); + let grandchild = AgentHandle { + id: "g1".to_string(), + agent_name: "worker".to_string(), + depth: 2, + inbox: Arc::new(Inbox::new()), + abort_signal: create_abort_signal(), + join_handle, + child_supervisor: None, + }; + let mut child_sup = Supervisor::new(4, 3); + child_sup.register(grandchild).unwrap(); + + // make_handle's direct task is already finished, so the only live + // work sits one supervisor level down. + let mut parent_handle = make_handle("a1", "explore", 1); + parent_handle.child_supervisor = Some(Arc::new(RwLock::new(child_sup))); + let mut sup = Supervisor::new(4, 3); + sup.register(parent_handle).unwrap(); + + assert!(sup.has_active_tasks()); + } + #[test] fn supervisor_register_rejects_exceeding_depth() { let mut sup = Supervisor::new(4, 2); diff --git a/src/testing.rs b/src/testing.rs index a41d69c..00957d6 100644 --- a/src/testing.rs +++ b/src/testing.rs @@ -1,6 +1,7 @@ //! Test-only helpers shared across modules' unit tests. use log::{Level, LevelFilter, Log, Metadata, Record}; +use std::ffi::{OsStr, OsString}; use std::sync::{Mutex, Once, OnceLock}; struct TestLogCollector; @@ -8,25 +9,30 @@ struct TestLogCollector; static WARN_MESSAGES: OnceLock>> = OnceLock::new(); static DEBUG_MESSAGES: OnceLock>> = OnceLock::new(); -/// Debug capture is restricted to the hooks module so the buffer is not -/// flooded by debug output from the rest of the crate. Matching is -/// module-boundary-aware: only this exact module or its `::` submodules +/// Debug capture is restricted to the modules whose tests assert on debug +/// output so the buffer is not flooded by the rest of the crate. Matching is +/// module-boundary-aware: only these exact modules or their `::` submodules /// qualify, so a sibling like `hooks_registry` would not. -const DEBUG_TARGET_PREFIX: &str = concat!(env!("CARGO_CRATE_NAME"), "::hooks"); +const DEBUG_TARGET_PREFIXES: [&str; 2] = [ + concat!(env!("CARGO_CRATE_NAME"), "::hooks"), + concat!(env!("CARGO_CRATE_NAME"), "::config::agent"), +]; fn captures_warn(metadata: &Metadata) -> bool { metadata.level() <= Level::Warn } -fn captures_hooks_debug(metadata: &Metadata) -> bool { +fn captures_module_debug(metadata: &Metadata) -> bool { if metadata.level() != Level::Debug { return false; } let target = metadata.target(); - target == DEBUG_TARGET_PREFIX - || target - .strip_prefix(DEBUG_TARGET_PREFIX) - .is_some_and(|rest| rest.starts_with("::")) + DEBUG_TARGET_PREFIXES.iter().any(|prefix| { + target == *prefix + || target + .strip_prefix(prefix) + .is_some_and(|rest| rest.starts_with("::")) + }) } /// Every warn- or error-level message captured since the collector was @@ -37,16 +43,35 @@ pub(crate) fn warn_messages() -> &'static Mutex> { WARN_MESSAGES.get_or_init(Mutex::default) } -/// Every debug-level message from the hooks module captured since the -/// collector was installed. Same marker-filtering discipline as -/// [`warn_messages`]. +/// Every debug-level message from the [`DEBUG_TARGET_PREFIXES`] modules +/// captured since the collector was installed. Same marker-filtering +/// discipline as [`warn_messages`]. pub(crate) fn debug_messages() -> &'static Mutex> { DEBUG_MESSAGES.get_or_init(Mutex::default) } +/// A copy of everything in [`warn_messages`], recovering from poisoning the +/// same way the collector itself does. +pub(crate) fn warn_snapshot() -> Vec { + snapshot_of(warn_messages()) +} + +/// A copy of everything in [`debug_messages`], recovering from poisoning the +/// same way the collector itself does. +pub(crate) fn debug_snapshot() -> Vec { + snapshot_of(debug_messages()) +} + +fn snapshot_of(buffer: &Mutex>) -> Vec { + buffer + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() +} + impl Log for TestLogCollector { fn enabled(&self, metadata: &Metadata) -> bool { - captures_warn(metadata) || captures_hooks_debug(metadata) + captures_warn(metadata) || captures_module_debug(metadata) } // A test that panics while holding a buffer poisons its mutex; the logger @@ -57,7 +82,7 @@ impl Log for TestLogCollector { .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) .push(record.args().to_string()); - } else if captures_hooks_debug(record.metadata()) { + } else if captures_module_debug(record.metadata()) { debug_messages() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) @@ -68,15 +93,85 @@ impl Log for TestLogCollector { fn flush(&self) {} } -/// Installs the process-wide log collector. `log::set_logger` accepts one -/// logger per process, so every test that captures warns or hooks debug -/// output must install through this shared entry point. The max level is -/// Debug so the hooks debug capture sees its records; warn capture is +/// Installs the process-wide log collector capturing warn-level messages and +/// debug messages from the [`DEBUG_TARGET_PREFIXES`] modules. +/// `log::set_logger` accepts one logger per process, so every test that +/// captures either stream must install through this shared entry point. The +/// max level is Debug so the debug capture sees its records; warn capture is /// unaffected. -pub(crate) fn install_warn_collector() { +pub(crate) fn install_log_collector() { static INSTALL: Once = Once::new(); INSTALL.call_once(|| { log::set_logger(&TestLogCollector).expect("no other logger should be installed"); log::set_max_level(LevelFilter::Debug); }); } + +/// Sets (or removes) an environment variable for the guard's lifetime and +/// restores the previous value on drop, including on panic, so a failing +/// assertion cannot leak the override into subsequent tests. Tests using it +/// must serialize (`#[serial]`) — the process environment is global. +pub(crate) struct EnvVarGuard { + key: String, + previous: Option, +} + +impl EnvVarGuard { + pub(crate) fn set(key: impl Into, value: impl AsRef) -> Self { + let key = key.into(); + let previous = std::env::var_os(&key); + unsafe { + std::env::set_var(&key, value); + } + Self { key, previous } + } + + pub(crate) fn unset(key: impl Into) -> Self { + let key = key.into(); + let previous = std::env::var_os(&key); + unsafe { + std::env::remove_var(&key); + } + Self { key, previous } + } +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + unsafe { + match &self.previous { + Some(previous) => std::env::set_var(&self.key, previous), + None => std::env::remove_var(&self.key), + } + } + } +} + +/// Points the config dir at a fresh temp directory for the guard's lifetime +/// and removes it on drop, including on panic. Tests using it must serialize +/// (`#[serial]`) — the config-dir env var is process-global. +pub(crate) struct TestConfigDirGuard { + _env: EnvVarGuard, + pub(crate) path: std::path::PathBuf, +} + +impl TestConfigDirGuard { + pub(crate) fn new(label: &str) -> Self { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!("coyote-{label}-{unique}")); + std::fs::create_dir_all(&path).unwrap(); + Self { + _env: EnvVarGuard::set(crate::utils::get_env_name("config_dir"), &path), + path, + } + } +} + +impl Drop for TestConfigDirGuard { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} diff --git a/src/vault/mod.rs b/src/vault/mod.rs index 43dad8a..82a9c78 100644 --- a/src/vault/mod.rs +++ b/src/vault/mod.rs @@ -332,7 +332,13 @@ impl Vault { } if cli.list_secrets { - vault.list_secrets(true)?; + if vault.sandbox_mode { + println!( + "The vault is unavailable in sandbox mode. Use `coyote --list-secrets` on your host." + ); + } else { + vault.list_secrets(true)?; + } } Ok(()) @@ -410,4 +416,23 @@ mod tests { } } } + + #[test] + fn list_secrets_flag_ok_in_sandbox() { + use clap::Parser; + + // Built directly rather than via IS_SANDBOX so this test cannot race + // the env juggling in vault_disabled_in_sandbox. + let vault = Vault { + sandbox_mode: true, + ..Vault::default() + }; + let cli = Cli::try_parse_from(["coyote", "--list-secrets"]).unwrap(); + + // Pins the --list-secrets sandbox contract: rc=0 with an informational + // readout instead of the hard failure the mutating flags keep. The + // message goes straight to stdout, so only Ok is asserted here. + Vault::handle_vault_flags(cli, &vault) + .expect("--list-secrets must succeed in sandbox mode"); + } } diff --git a/tests/inspection_flags.rs b/tests/inspection_flags.rs new file mode 100644 index 0000000..bdfb24e --- /dev/null +++ b/tests/inspection_flags.rs @@ -0,0 +1,440 @@ +//! Binary-level contract for inspection flags on a pristine config dir: +//! exit 0 and NOTHING written into the config dir (no first-run wizard, +//! no builtins bootstrap). Only --info guarantees output on stdout; the +//! list flags legitimately emit an empty listing on a pristine dir, so +//! their contract is exit 0 + zero writes. Vault-backed flags like +//! --list-secrets depend on host state and are covered by +//! scripts/usage-probe-inspection.sh instead. Agent runs, by contrast, +//! must still bootstrap builtins even when combined with --info — see +//! agent_info_on_empty_config_dir_bootstraps_builtins. Flags that write, +//! like --sync-models, are NOT inspection flags and must stay on the +//! bootstrap path — see sync_models_stays_on_bootstrap_path. +//! +//! Every test runs against a fresh fake HOME and (unless sandbox mode is the +//! subject under test) a scrubbed IS_SANDBOX/provider env, so host state — +//! a real ~/.coyote_password or a sandbox shell — cannot mask +//! virgin-environment regressions. + +use std::env; +use std::fs; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn fresh_config_dir(label: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let tmp_dir = env::temp_dir().join(format!("coyote-inspection-{label}-{unique}")); + fs::create_dir_all(&tmp_dir).unwrap(); + tmp_dir +} + +fn probe_inspection_flag(flag: &str, require_stdout: bool) { + let label = flag.trim_start_matches("--"); + let tmp_dir = fresh_config_dir(label); + let home_dir = fresh_config_dir(&format!("{label}-home")); + + let output = Command::new(env!("CARGO_BIN_EXE_coyote")) + .arg(flag) + .env("COYOTE_CONFIG_DIR", &tmp_dir) + // A fresh fake HOME hides any real ~/.coyote_password on the host; + // USERPROFILE is the best-effort Windows equivalent (dirs resolves + // home via the Known Folder API there). HOME is set, never removed: + // dirs::home_dir() panics inside gman when home can't be determined. + .env("HOME", &home_dir) + .env("USERPROFILE", &home_dir) + // A leaked sandbox/provider env would reroute the vault and config + // paths under test. + .env_remove("IS_SANDBOX") + .env_remove("COYOTE_PROVIDER") + .env_remove("COYOTE_PLATFORM") + // Workspace mcp.json discovery resolves from the CWD, so pin it to + // the isolated fake HOME to keep host state out of the probe. + .current_dir(&home_dir) + .stdin(Stdio::null()) + .output() + .unwrap(); + + let leftover: Vec<_> = fs::read_dir(&tmp_dir) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + assert!( + !home_dir.join(".coyote_password").exists(), + "{flag}: the lenient inspection path must never bootstrap a password file" + ); + let _ = fs::remove_dir_all(&tmp_dir); + let _ = fs::remove_dir_all(&home_dir); + + assert!( + output.status.success(), + "{flag}: expected exit 0 on an empty config dir, got {:?}\nstdout: {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + if require_stdout { + assert!( + !output.stdout.is_empty(), + "{flag}: expected some output on stdout" + ); + } + assert!( + leftover.is_empty(), + "{flag}: inspection flags must write nothing into the config dir: {leftover:?}" + ); +} + +#[test] +fn info_on_empty_config_dir_writes_nothing() { + probe_inspection_flag("--info", true); +} + +// List flags on a pristine dir correctly print an empty listing, so the +// contract is exit 0 + zero writes only (no stdout assertion). + +#[test] +fn list_models_on_empty_config_dir_writes_nothing() { + probe_inspection_flag("--list-models", false); +} + +#[test] +fn list_agents_on_empty_config_dir_writes_nothing() { + probe_inspection_flag("--list-agents", false); +} + +#[test] +fn mcp_list_on_empty_config_dir_writes_nothing() { + probe_inspection_flag("--mcp-list", false); +} + +// The wizard bypass must not swallow the builtins bootstrap for agent runs: +// `--agent --info` on a totally fresh config dir has to install +// the bundled definitions before it can render the agent readout. +#[test] +fn agent_info_on_empty_config_dir_bootstraps_builtins() { + let tmp_dir = fresh_config_dir("agent-info"); + let home_dir = fresh_config_dir("agent-info-home"); + + let output = Command::new(env!("CARGO_BIN_EXE_coyote")) + .args(["--agent", "adversary", "--info"]) + .env("COYOTE_CONFIG_DIR", &tmp_dir) + .env("HOME", &home_dir) + .env("USERPROFILE", &home_dir) + .env_remove("IS_SANDBOX") + .env_remove("COYOTE_PROVIDER") + .env_remove("COYOTE_PLATFORM") + .current_dir(&home_dir) + .stdin(Stdio::null()) + .output() + .unwrap(); + + let agents_dir = tmp_dir.join("agents"); + let builtins_installed = + agents_dir.is_dir() && fs::read_dir(&agents_dir).unwrap().next().is_some(); + let _ = fs::remove_dir_all(&tmp_dir); + let _ = fs::remove_dir_all(&home_dir); + + assert!( + output.status.success(), + "--agent adversary --info: expected exit 0 on an empty config dir, got {:?}\nstdout: {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + builtins_installed, + "--agent adversary --info: expected builtin agents installed into {}", + agents_dir.display() + ); + // The --info readout echoes the loaded agent definition including its + // global_tools, so a builtin tool name there proves the definitions were + // loaded into the agent's tool scope, not just written to disk. + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("fs_cat.sh"), + "--agent adversary --info: expected builtin tool fs_cat.sh in the readout\nstdout: {stdout}" + ); +} + +// Sandbox-mode vault contract: `--list-secrets` is an inspection readout and +// must exit 0 with an informational message (and zero writes) when the vault +// is unreachable inside a sandbox, while mutating vault flags stay strict. +#[test] +fn sandbox_list_secrets_prints_informational_message_and_writes_nothing() { + let tmp_dir = fresh_config_dir("sandbox-list-secrets"); + let home_dir = fresh_config_dir("sandbox-list-secrets-home"); + + let output = Command::new(env!("CARGO_BIN_EXE_coyote")) + .arg("--list-secrets") + .env("COYOTE_CONFIG_DIR", &tmp_dir) + .env("IS_SANDBOX", "1") + .env("HOME", &home_dir) + .env("USERPROFILE", &home_dir) + .current_dir(&home_dir) + .stdin(Stdio::null()) + .output() + .unwrap(); + + let leftover: Vec<_> = fs::read_dir(&tmp_dir) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + let _ = fs::remove_dir_all(&tmp_dir); + let _ = fs::remove_dir_all(&home_dir); + + assert!( + output.status.success(), + "--list-secrets in sandbox mode: expected exit 0, got {:?}\nstdout: {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + combined.contains("The vault is unavailable in sandbox mode"), + "--list-secrets in sandbox mode: expected the informational readout, got: {combined}" + ); + assert!( + leftover.is_empty(), + "--list-secrets in sandbox mode: expected zero writes into the config dir: {leftover:?}" + ); +} + +// Mutating vault flags are NOT inspection flags: in sandbox mode they must +// fail hard with the vault-management error, never the soft informational +// message. A minimal valid config is provided so the run gets past model +// resolution and reaches the vault gate. +#[test] +fn sandbox_mutating_vault_flag_stays_strict() { + let tmp_dir = fresh_config_dir("sandbox-delete-secret"); + let home_dir = fresh_config_dir("sandbox-delete-secret-home"); + fs::write( + tmp_dir.join("config.yaml"), + "model: dryrun:dry-model\n\ + dry_run: true\n\ + clients:\n\ + \x20 - type: openai\n\ + \x20 name: dryrun\n\ + \x20 auth: none\n\ + \x20 api_key: unused\n\ + \x20 models:\n\ + \x20 - name: dry-model\n\ + \x20 max_input_tokens: 100000\n\ + \x20 supports_function_calling: true\n\ + save: false\n", + ) + .unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_coyote")) + .args(["--delete-secret", "probe"]) + .env("COYOTE_CONFIG_DIR", &tmp_dir) + .env("IS_SANDBOX", "1") + .env("HOME", &home_dir) + .env("USERPROFILE", &home_dir) + .current_dir(&home_dir) + .stdin(Stdio::null()) + .output() + .unwrap(); + let _ = fs::remove_dir_all(&tmp_dir); + let _ = fs::remove_dir_all(&home_dir); + + assert!( + !output.status.success(), + "--delete-secret in sandbox mode: expected a non-zero exit, got {:?}\nstdout: {}", + output.status, + String::from_utf8_lossy(&output.stdout) + ); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + combined.contains("Vault management is disabled in sandbox mode"), + "--delete-secret in sandbox mode: expected the strict vault error, got: {combined}" + ); +} + +// The --mcp-list leniency must not leak into the mutating MCP flags on the +// host path: with no password file (fresh fake HOME) and no sandbox env, +// --mcp-get/--mcp-remove/--mcp-add have to fail on the strict vault +// requirement rather than operate with an unconfigured vault. +fn probe_strict_mcp_flag(label: &str, args: &[&str]) { + let tmp_dir = fresh_config_dir(&format!("{label}-no-vault")); + let home_dir = fresh_config_dir(&format!("{label}-no-vault-home")); + fs::write( + tmp_dir.join("config.yaml"), + "model: dryrun:dry-model\n\ + dry_run: true\n\ + clients:\n\ + \x20 - type: openai\n\ + \x20 name: dryrun\n\ + \x20 auth: none\n\ + \x20 api_key: unused\n\ + \x20 models:\n\ + \x20 - name: dry-model\n\ + \x20 max_input_tokens: 100000\n\ + \x20 supports_function_calling: true\n\ + save: false\n", + ) + .unwrap(); + fs::write( + tmp_dir.join("mcp.json"), + r#"{"mcpServers":{"probe-server":{"type":"stdio","command":"echo","args":["hi"]}}}"#, + ) + .unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_coyote")) + .args(args) + .env("COYOTE_CONFIG_DIR", &tmp_dir) + .env("HOME", &home_dir) + .env("USERPROFILE", &home_dir) + .env_remove("IS_SANDBOX") + .env_remove("COYOTE_PROVIDER") + .env_remove("COYOTE_PLATFORM") + .current_dir(&home_dir) + .stdin(Stdio::null()) + .output() + .unwrap(); + // The strict path must not touch the server registry either. + let mcp_json_after = fs::read_to_string(tmp_dir.join("mcp.json")).unwrap(); + let _ = fs::remove_dir_all(&tmp_dir); + let _ = fs::remove_dir_all(&home_dir); + + assert!( + !output.status.success(), + "{label} without a vault: expected a non-zero exit, got {:?}\nstdout: {}", + output.status, + String::from_utf8_lossy(&output.stdout) + ); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + combined.contains("A password file is required"), + "{label} without a vault: expected the strict vault error, got: {combined}" + ); + assert!( + mcp_json_after.contains("probe-server"), + "{label} without a vault: mcp.json must be left untouched, got: {mcp_json_after}" + ); +} + +#[test] +fn mcp_mutating_flag_stays_strict_without_vault() { + probe_strict_mcp_flag("mcp-remove", &["--mcp-remove", "probe-server"]); +} + +// --mcp-get is read-only but consumes resolved server config (which may +// embed secrets), so it stays on the strict vault path by design. +#[test] +fn mcp_get_stays_strict_without_vault() { + probe_strict_mcp_flag("mcp-get", &["--mcp-get", "probe-server"]); +} + +#[test] +fn mcp_add_stays_strict_without_vault() { + probe_strict_mcp_flag("mcp-add", &["--mcp-add", "newsrv", "--", "echo", "hi"]); +} + +// The listing leniency exercised with content: a configured, secret-free +// server must be listed even when no vault exists (no password file, no +// sandbox env), and the readout must not write anything new into the +// config dir. +#[test] +fn mcp_list_lists_configured_server_without_vault() { + let tmp_dir = fresh_config_dir("mcp-list-configured"); + let home_dir = fresh_config_dir("mcp-list-configured-home"); + fs::write( + tmp_dir.join("mcp.json"), + r#"{"mcpServers":{"probe-server":{"type":"stdio","command":"echo","args":["hi"]}}}"#, + ) + .unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_coyote")) + .arg("--mcp-list") + .env("COYOTE_CONFIG_DIR", &tmp_dir) + .env("HOME", &home_dir) + .env("USERPROFILE", &home_dir) + .env_remove("IS_SANDBOX") + .env_remove("COYOTE_PROVIDER") + .env_remove("COYOTE_PLATFORM") + .current_dir(&home_dir) + .stdin(Stdio::null()) + .output() + .unwrap(); + + let leftover: Vec<_> = fs::read_dir(&tmp_dir) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + let _ = fs::remove_dir_all(&tmp_dir); + let _ = fs::remove_dir_all(&home_dir); + + assert!( + output.status.success(), + "--mcp-list with a configured server and no vault: expected exit 0, got {:?}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("probe-server"), + "--mcp-list with a configured server: expected 'probe-server' in stdout, got: {stdout}" + ); + assert_eq!( + leftover, + vec![std::ffi::OsString::from("mcp.json")], + "--mcp-list must not write into the config dir beyond the pre-seeded mcp.json" + ); +} + +// --sync-models downloads and writes models-override.yaml, so it is NOT an +// inspection flag: on a pristine config dir it must take the bootstrap path +// (builtins installed) and, off a terminal, fail on the missing config +// instead of silently operating on defaults. The host path is the one under +// test: a leaked IS_SANDBOX (or provider env) would reroute the missing +// config to the sandbox wizard prompt instead of the fast failure. +#[test] +fn sync_models_stays_on_bootstrap_path() { + let tmp_dir = fresh_config_dir("sync-models"); + let home_dir = fresh_config_dir("sync-models-home"); + + let output = Command::new(env!("CARGO_BIN_EXE_coyote")) + .arg("--sync-models") + .env("COYOTE_CONFIG_DIR", &tmp_dir) + .env("HOME", &home_dir) + .env("USERPROFILE", &home_dir) + .env_remove("IS_SANDBOX") + .env_remove("COYOTE_PROVIDER") + .env_remove("COYOTE_PLATFORM") + .current_dir(&home_dir) + .stdin(Stdio::null()) + .output() + .unwrap(); + + let bootstrapped = fs::read_dir(&tmp_dir).unwrap().next().is_some(); + let _ = fs::remove_dir_all(&tmp_dir); + let _ = fs::remove_dir_all(&home_dir); + + assert!( + !output.status.success(), + "--sync-models: expected the strict missing-config failure on an empty config dir, got {:?}\nstdout: {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + bootstrapped, + "--sync-models: expected the builtins bootstrap to populate the config dir" + ); +} diff --git a/tests/macro_bracketing.rs b/tests/macro_bracketing.rs new file mode 100644 index 0000000..b079930 --- /dev/null +++ b/tests/macro_bracketing.rs @@ -0,0 +1,293 @@ +//! Binary-level contract for --macro bracket symmetry: a committed macro +//! run fires agent.started plus EXACTLY ONE terminal agent event +//! (agent.completed / agent.failed / agent.interrupted), whether the agent +//! arrives via the --agent flag or an in-macro `.agent` step, in both +//! isolation modes, on success and failure alike. The markers ride on +//! global agent.* hooks admitted through the agent's `global_hooks: ["*"]` +//! wildcard, so the fixture also exercises the whitelist grammar +//! end-to-end — and stays observable even if a failing step leaves the +//! context without a loaded agent. + +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +const MARKERS: [&str; 4] = ["STARTED", "COMPLETED", "FAILED", "INTERRUPTED"]; + +fn fresh_config_dir(label: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let tmp_dir = env::temp_dir().join(format!("coyote-macro-bracketing-{label}-{unique}")); + fs::create_dir_all(&tmp_dir).unwrap(); + tmp_dir +} + +/// Removes the fixture dir even when an assertion panics mid-run, so failed +/// runs do not accumulate temp dirs. +struct TempDirGuard(PathBuf); + +impl Drop for TempDirGuard { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +/// Path of the file `marker`'s hook appends to. Every event gets its OWN +/// file because cmd opens `>>` targets write-exclusive (no shared write +/// access): the STARTED and terminal hook children run concurrently, and +/// when both appended to one shared log, overlapping opens made the loser +/// fail with a sharing violation and silently drop its line — a permanent +/// marker loss that surfaced as a Windows-CI-only flake. Separate files +/// make writer overlap impossible: each event fires at most once per +/// bracket, and re-opened brackets are step-separated, so every file has +/// one writer at a time. +fn marker_log(dir: &Path, marker: &str) -> PathBuf { + dir.join(format!("agent-events-{marker}.log")) +} + +/// Builds the hook command that appends `marker` to its own marker file, in +/// the dialect of the shell the hook engine dispatches through: `sh -c` +/// elsewhere, `cmd /C` on Windows. The cmd form parenthesizes the echo +/// because `echo X >> f` under cmd writes "X " with a trailing space, which +/// would break exact marker matching. Both forms double-quote the path, so +/// the command also survives YAML single-quoting and spaces in temp paths. +fn marker_command(marker: &str, dir: &Path) -> String { + let log = marker_log(dir, marker); + let log = log.display(); + if cfg!(windows) { + format!(r#"(echo {marker})>> "{log}""#) + } else { + format!(r#"echo {marker} >> "{log}""#) + } +} + +/// Lays out a config dir with a dry_run model, global agent.* hooks that +/// append one marker line per event to that event's marker file, a +/// `probe-macro` agent that whitelists everything, and a `probe` macro with +/// the given YAML body. +fn write_fixture(dir: &Path, macro_yaml: &str) { + // Without IS_SANDBOX (scrubbed below) a run insists on a vault password + // file, so the fixture provides one. + let vault_pass = dir.join("vault-pass"); + fs::write(&vault_pass, "test-password\n").unwrap(); + // Paths land in single-quoted YAML scalars: double-quoted ones would read + // the backslashes in a Windows temp path (`C:\Users\...`) as escapes. + fs::write( + dir.join("config.yaml"), + format!( + "model: dryrun:dry-model\n\ + dry_run: true\n\ + vault_password_file: '{vault_pass}'\n\ + clients:\n\ + \x20 - type: openai\n\ + \x20 name: dryrun\n\ + \x20 auth: none\n\ + \x20 api_key: 'unused'\n\ + \x20 models:\n\ + \x20 - name: dry-model\n\ + \x20 max_input_tokens: 100000\n\ + \x20 supports_function_calling: true\n\ + save: false\n\ + memory: false\n\ + stream: false\n\ + hooks:\n\ + \x20 agent.started:\n\ + \x20 - name: mark\n\ + \x20 command: '{started}'\n\ + \x20 agent.completed:\n\ + \x20 - name: mark\n\ + \x20 command: '{completed}'\n\ + \x20 agent.failed:\n\ + \x20 - name: mark\n\ + \x20 command: '{failed}'\n\ + \x20 agent.interrupted:\n\ + \x20 - name: mark\n\ + \x20 command: '{interrupted}'\n", + vault_pass = vault_pass.display(), + started = marker_command("STARTED", dir), + completed = marker_command("COMPLETED", dir), + failed = marker_command("FAILED", dir), + interrupted = marker_command("INTERRUPTED", dir), + ), + ) + .unwrap(); + + let agent_dir = dir.join("agents/probe-macro"); + fs::create_dir_all(&agent_dir).unwrap(); + fs::write( + agent_dir.join("config.yaml"), + "name: probe-macro\n\ + description: macro bracketing probe agent\n\ + version: 0.1.0\n\ + instructions: |\n\ + \x20 You are a probe agent. Reply briefly.\n\ + global_hooks:\n\ + \x20 - \"*\"\n", + ) + .unwrap(); + + let macros_dir = dir.join("macros"); + fs::create_dir_all(¯os_dir).unwrap(); + fs::write(macros_dir.join("probe.yaml"), macro_yaml).unwrap(); +} + +fn run_coyote(dir: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_coyote")) + .args(args) + .current_dir(dir) + .env("COYOTE_CONFIG_DIR", dir) + .env_remove("IS_SANDBOX") + .stdin(Stdio::null()) + .output() + .unwrap() +} + +/// Counts `marker`'s exact-match lines in its own marker file; a missing +/// file is zero fires. Matching stays byte-exact on both platforms because +/// `lines()` strips the CRLF that cmd's echo emits on Windows. +fn marker_count(dir: &Path, marker: &str) -> usize { + fs::read_to_string(marker_log(dir, marker)) + .unwrap_or_default() + .lines() + .filter(|line| *line == marker) + .count() +} + +fn dump_markers(dir: &Path) -> String { + MARKERS + .iter() + .map(|marker| { + let contents = fs::read_to_string(marker_log(dir, marker)).unwrap_or_default(); + format!("{marker}: {contents:?}") + }) + .collect::>() + .join(", ") +} + +/// Bounded poll until every expected marker is present. The binary's exit +/// drain only acknowledges that each hook process SPAWNED — the hooks +/// themselves keep running concurrently after `output()` returns, and +/// nothing orders their writes (the STARTED writer can land after the +/// terminal one). Waiting on all expected markers, not just the terminal +/// one, keeps the exact-count assertions below race-free. +fn wait_for_markers(dir: &Path, needles: &[&str]) { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if needles.iter().all(|needle| marker_count(dir, needle) > 0) { + return; + } + assert!( + Instant::now() < deadline, + "timed out waiting for {needles:?}; markers so far: {}", + dump_markers(dir) + ); + thread::sleep(Duration::from_millis(50)); + } +} + +fn assert_marker_count(dir: &Path, marker: &str, expected: usize) { + let count = marker_count(dir, marker); + assert_eq!( + count, + expected, + "expected {expected}x {marker}; markers so far: {}", + dump_markers(dir) + ); +} + +fn assert_started_and_single_terminal(dir: &Path, terminal: &str) { + assert_marker_count(dir, "STARTED", 1); + for marker in ["COMPLETED", "FAILED", "INTERRUPTED"] { + let expected = usize::from(marker == terminal); + assert_marker_count(dir, marker, expected); + } +} + +fn probe_macro_run(label: &str, macro_yaml: &str, args: &[&str], expect_success: bool) { + let dir = fresh_config_dir(label); + let _cleanup = TempDirGuard(dir.clone()); + write_fixture(&dir, macro_yaml); + + let output = run_coyote(&dir, args); + assert_eq!( + output.status.success(), + expect_success, + "{label}: unexpected exit {:?}\nstdout: {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let terminal = if expect_success { + "COMPLETED" + } else { + "FAILED" + }; + wait_for_markers(&dir, &["STARTED", terminal]); + assert_started_and_single_terminal(&dir, terminal); +} + +#[test] +fn agent_macro_run_fires_started_and_exactly_one_completed() { + probe_macro_run( + "agent-flag-success", + "steps:\n - \".set temperature 0.5\"\n", + &["--agent", "probe-macro", "--macro", "probe"], + true, + ); +} + +#[test] +fn agent_macro_failure_closes_the_bracket_with_exactly_one_failed() { + probe_macro_run( + "agent-flag-failure", + "steps:\n - \".agent no-such-agent-zz\"\n", + &["--agent", "probe-macro", "--macro", "probe"], + false, + ); +} + +#[test] +fn isolated_in_macro_agent_step_fires_started_and_exactly_one_completed() { + probe_macro_run( + "isolated-step-success", + "steps:\n - \".agent probe-macro\"\n - \".set temperature 0.5\"\n", + &["--macro", "probe"], + true, + ); +} + +#[test] +fn isolated_in_macro_agent_step_failure_closes_the_bracket() { + probe_macro_run( + "isolated-step-failure", + "steps:\n - \".agent probe-macro\"\n - \".agent no-such-agent-zz\"\n", + &["--macro", "probe"], + false, + ); +} + +#[test] +fn non_isolated_in_macro_agent_step_fires_started_and_exactly_one_completed() { + probe_macro_run( + "non-isolated-step-success", + "isolated: false\nsteps:\n - \".agent probe-macro\"\n - \".set temperature 0.5\"\n", + &["--macro", "probe"], + true, + ); +} + +#[test] +fn non_isolated_in_macro_agent_step_failure_closes_the_bracket() { + probe_macro_run( + "non-isolated-step-failure", + "isolated: false\nsteps:\n - \".agent probe-macro\"\n - \".agent no-such-agent-zz\"\n", + &["--macro", "probe"], + false, + ); +}