From 6514588e0585fd561cd0a20953920cfb37e3a24f Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 14:02:42 -0600 Subject: [PATCH 01/29] fix(TASK-046): engine polish + wizard bypass + ETXTBSY retry - unify hook base-env assembly on hooks::base_envs_parts (drop the hand-copied hook_base_envs in function/agents.rs; graph executor and spawn-result hooks now share the single construction site, test-pinned) - factor the duplicated job.started dispatch in jobs.rs handle_start into one fire_job_started helper - COYOTE_LLM_ERROR now carries the immediate error (to_string()), matching COYOTE_TOOL_ERROR; test pins the new format - remove all stale #[allow(dead_code)] markers in src/hooks.rs - test infra: rename install_warn_collector -> install_log_collector, add poison-symmetric warn_snapshot/debug_snapshot helpers, hoist a shared EnvVarGuard (TestConfigDirGuard now builds on it), add a same-fire payload-uniqueness test, and give global_tools env overrides the same malformed-JSON debug diagnostic as global_hooks - inspection-only flags (--info et al) bypass the first-run wizard: missing config + info_flag returns Config::default() without writing anything, in both the sandbox and interactive arms (tests included) - bound the shim-relocation test's exec in an ETXTBSY retry (10x50ms) - document the intended 'roles' reserved-name asymmetry at resolve_bundle_name's gate --- src/client/common.rs | 16 ++-- src/config/agent.rs | 11 ++- src/config/bundles.rs | 4 + src/config/mod.rs | 65 ++++++++++++++++ src/config/tool_scope.rs | 14 ++-- src/function/agents.rs | 42 ++--------- src/function/jobs.rs | 34 ++++----- src/function/mod.rs | 29 +++++++- src/graph/agent.rs | 20 ++--- src/graph/executor.rs | 10 +-- src/hooks.rs | 156 +++++++++++++++++---------------------- src/testing.rs | 72 ++++++++++++++++-- 12 files changed, 282 insertions(+), 191 deletions(-) diff --git a/src/client/common.rs b/src/client/common.rs index c24eebb..8f9c63f 100644 --- a/src/client/common.rs +++ b/src/client/common.rs @@ -579,9 +579,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()), )); @@ -1862,12 +1862,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..a76b9c1 100644 --- a/src/config/agent.rs +++ b/src/config/agent.rs @@ -1019,10 +1019,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) { diff --git a/src/config/bundles.rs b/src/config/bundles.rs index caa3ce2..c5309c9 100644 --- a/src/config/bundles.rs +++ b/src/config/bundles.rs @@ -241,6 +241,10 @@ impl BundleStore { same_name_other_source: None, }; + // Reserved names are the `--install-builtins` categories. `roles` is + // deliberately NOT among them: builtin roles are not installable that + // way (some are coupled to flag-specific coyote behavior), so the + // asymmetry with InstallFilter is intended. let reserved = AssetCategory::parse(&base).is_some(); let collision = self.source_of_other_bundle(&base, &canonical); if reserved || collision.is_some() { diff --git a/src/config/mod.rs b/src/config/mod.rs index 7cfed24..3ba4323 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -734,6 +734,11 @@ impl Config { if env::var_os(SANDBOX_ENV_FLAG).is_some() { if !config_path.exists() { + // Inspection-only flags are look-don't-touch: no first-run + // config creation, nothing written. + if info_flag { + return Ok(Self::default()); + } create_config_file(&config_path).await?; } @@ -748,6 +753,12 @@ impl Config { { Some(v) => (Self::load_dynamic(&v)?, String::new()), None => { + // Same look-don't-touch rule: never run the interactive + // first-run wizard (or write a config) for a flag that + // only inspects state. + if info_flag { + return Ok(Self::default()); + } if *IS_STDOUT_TERMINAL { create_config_file(&config_path).await?; } @@ -1640,6 +1651,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(crate::sandbox::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).await; + + let leftover: Vec<_> = std::fs::read_dir(&tmp_dir) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + let _ = std::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(crate::sandbox::SANDBOX_ENV_FLAG, "1"); + + let result = Config::load_with_interpolation(true).await; + + let created = config_path.exists(); + let _ = std::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)); 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..c3a22b6 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,7 @@ 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 indexmap::IndexMap; use log::{debug, warn}; use parking_lot::RwLock; @@ -997,36 +997,6 @@ 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 @@ -1070,7 +1040,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, ); @@ -2377,8 +2347,8 @@ mod tests { } #[test] - fn hook_base_envs_carries_optional_names_only_when_present() { - let envs = hook_base_envs(HookEvent::AgentCompleted, None, None); + fn agent_event_base_envs_carry_optional_names_only_when_present() { + let envs = hooks::base_envs_parts(HookEvent::AgentCompleted, None, None); let keys: Vec<&str> = envs.iter().map(|(key, _)| key.as_str()).collect(); assert!(keys.contains(&"COYOTE_EVENT")); assert!(keys.contains(&"COYOTE_CONFIG_DIR")); @@ -2390,7 +2360,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") diff --git a/src/function/jobs.rs b/src/function/jobs.rs index c9e9a3b..e5e2ac8 100644 --- a/src/function/jobs.rs +++ b/src/function/jobs.rs @@ -111,6 +111,20 @@ impl JobHookSnapshot { } } +/// Fires `job.started` for a freshly accepted job; both `handle_start` +/// branches (MCP and process) dispatch through here. +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 +543,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 +565,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 38b6579..f8a1785 100644 --- a/src/function/mod.rs +++ b/src/function/mod.rs @@ -4795,6 +4795,27 @@ 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 process::Command, + ) -> std::io::Result { + let mut attempts = 0; + loop { + match command.output() { + Err(err) + if err.kind() == std::io::ErrorKind::ExecutableFileBusy + && attempts < 10 => + { + attempts += 1; + std::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"); @@ -4858,14 +4879,14 @@ mod tests { return; } - let output = process::Command::new(&moved_shim) + let mut command = process::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/graph/agent.rs b/src/graph/agent.rs index edce152..d77066c 100644 --- a/src/graph/agent.rs +++ b/src/graph/agent.rs @@ -340,7 +340,7 @@ mod tests { use super::*; use crate::config::{AppState, WorkingMode, default_max_agent_depth}; 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 std::collections::HashMap; use std::sync::Arc; @@ -676,7 +676,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 +700,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 +858,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 +872,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..9f6b6e2 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(), @@ -1619,7 +1618,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 +1659,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() diff --git a/src/hooks.rs b/src/hooks.rs index 4c97dee..78bf511 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -73,7 +73,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, @@ -150,7 +149,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, @@ -248,7 +246,6 @@ fn push_defs(event_name: &str, defs: &[HookDef], cwd: &Path, out: &mut Vec, @@ -352,7 +348,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 { @@ -379,6 +374,11 @@ fn base_envs(event: HookEvent, ctx: &RequestContext) -> Vec<(String, String)> { ) } +/// The single construction site for the base env set every hook receives. +/// [`base_envs`] feeds it from a live context; detached call sites that +/// dispatch pre-resolved snapshots after their context is gone pass the +/// names they captured with the snapshot. The timestamp is taken here, at +/// fire time, so it reflects when the event actually happened. pub(crate) fn base_envs_parts( event: HookEvent, session_name: Option<&str>, @@ -513,7 +513,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 +603,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() @@ -649,19 +647,16 @@ mod tests { #[serial] fn install_builtin_hooks_installs_executable_scripts_and_honors_force() { let env_name = crate::utils::get_env_name("hooks_dir"); - let prev = env::var_os(&env_name); let unique = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos(); let root = env::temp_dir().join(format!("coyote-hooks-install-{unique}")); - unsafe { - env::set_var(&env_name, &root); - } + let env_guard = crate::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); @@ -680,12 +675,7 @@ mod tests { let force = install_builtin_hooks(true); let after_force = std::fs::read_to_string(¬ify); - unsafe { - match prev { - Some(v) => env::set_var(&env_name, v), - None => env::remove_var(&env_name), - } - } + drop(env_guard); let _ = std::fs::remove_dir_all(&root); fresh.unwrap(); @@ -897,7 +887,7 @@ mod tests { #[test] fn unknown_whitelist_entries_log_debug_and_stay_inert() { - crate::testing::install_warn_collector(); + crate::testing::install_log_collector(); let global = hooks_map("turn.started", &[("a", "cmd-a")]); let gate = vec![ "turn.started.a".to_string(), @@ -913,10 +903,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 = crate::testing::debug_snapshot(); assert!(debugs.iter().any(|message| { message.contains("turn.started.nonexistent-marker-xyz") && message.contains("for agent 'gate-agent-xyz'") @@ -925,7 +912,7 @@ mod tests { #[test] fn empty_global_map_short_circuits_whitelist_diagnostics() { - crate::testing::install_warn_collector(); + crate::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 +926,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 = crate::testing::debug_snapshot(); assert!( debugs .iter() @@ -1171,15 +1155,12 @@ mod tests { #[tokio::test(start_paused = true)] #[serial] async fn drain_pending_logs_when_the_timeout_expires() { - crate::testing::install_warn_collector(); + crate::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 = crate::testing::debug_snapshot(); assert!( debugs .iter() @@ -1206,7 +1187,7 @@ mod tests { #[test] #[serial] fn fire_resolved_without_runtime_skips_instead_of_panicking() { - crate::testing::install_warn_collector(); + crate::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 +1197,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 = crate::testing::debug_snapshot(); assert!(debugs.iter().any(|message| { message.contains("no-runtime-marker-a7c") && message.contains("no Tokio runtime on this thread") @@ -1229,7 +1207,7 @@ mod tests { #[test] #[serial] fn fire_without_runtime_skips_instead_of_panicking() { - crate::testing::install_warn_collector(); + crate::testing::install_log_collector(); let ctx = ctx_with_global_hooks(hooks_map( "turn.failed", &[("fire-no-runtime-marker-b8d", "true")], @@ -1237,10 +1215,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 = crate::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 +1224,7 @@ mod tests { #[test] fn debug_capture_excludes_non_hooks_targets() { - crate::testing::install_warn_collector(); + crate::testing::install_log_collector(); let hook = ResolvedHook { name: "included-marker-k2v".to_string(), full_name: "turn.failed.included-marker-k2v".to_string(), @@ -1267,10 +1242,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 = crate::testing::debug_snapshot(); assert!( debugs .iter() @@ -1291,7 +1263,7 @@ mod tests { #[test] #[serial] fn debug_capture_recovers_after_buffer_poisoning() { - crate::testing::install_warn_collector(); + crate::testing::install_log_collector(); std::thread::spawn(|| { let _held = crate::testing::debug_messages().lock().unwrap(); panic!("poison the debug buffer"); @@ -1342,27 +1314,21 @@ mod tests { use std::time::{Duration, SystemTime, UNIX_EPOCH}; struct TestConfigDirGuard { - key: String, - previous: Option, + _env: crate::testing::EnvVarGuard, 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-hooks-tests-{unique}")); create_dir_all(&path).unwrap(); - unsafe { - env::set_var(&key, &path); - } Self { - key, - previous, + _env: crate::testing::EnvVarGuard::set(key, &path), path, } } @@ -1370,15 +1336,6 @@ mod tests { 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); } } @@ -1548,7 +1505,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] #[serial] async fn error_paths_log_debug_only() { - crate::testing::install_warn_collector(); + crate::testing::install_log_collector(); let empty_cwd = env::temp_dir(); let hooks = vec![ ResolvedHook { @@ -1568,10 +1525,7 @@ mod tests { 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 debugs = crate::testing::debug_snapshot(); let empty_cwd_display = empty_cwd.display().to_string(); assert!(debugs.iter().any(|message| { message.contains("empty-marker-f5b") @@ -1582,17 +1536,14 @@ mod tests { 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(); + let warns = crate::testing::warn_snapshot(); 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(); + crate::testing::install_log_collector(); let guard = TestConfigDirGuard::new(); let first = guard.path.join("first-out"); let second = guard.path.join("second-out"); @@ -1618,10 +1569,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 = crate::testing::warn_snapshot(); assert!( warns .iter() @@ -1665,7 +1613,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] #[serial] async fn payload_write_failure_still_runs_hook_without_payload_env() { - crate::testing::install_warn_collector(); + crate::testing::install_log_collector(); let guard = TestConfigDirGuard::new(); let out = guard.path.join("env-out"); let _payload_dir = PayloadDirOverrideGuard::new(guard.path.join("missing-payload-dir")); @@ -1689,10 +1637,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 = crate::testing::debug_snapshot(); assert!(debugs.iter().any(|message| message.contains( "Failed to write payload file for hook 'tool.started.payload-fallback-marker'" ))); @@ -1729,6 +1674,43 @@ 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(); + 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() { diff --git a/src/testing.rs b/src/testing.rs index a41d69c..4442565 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; @@ -44,6 +45,25 @@ 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) @@ -68,15 +88,55 @@ 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 -/// unaffected. -pub(crate) fn install_warn_collector() { +/// Installs the process-wide log collector capturing warn-level messages and +/// hooks-module debug messages. `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 hooks debug capture +/// sees its records; warn capture is unaffected. +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), + } + } + } +} From 9da0b916730126d90b89c089eaed4a72a4ce2c41 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 15:07:21 -0600 Subject: [PATCH 02/29] fix(TASK-046): make inspection flags look-don't-touch and pin the contracts - Skip the builtins bootstrap, function-binary build, and tool-scope rebuild for inspection-only runs (--info/--list-*/--mcp-list/ --list-secrets without --agent/--role): a pristine COYOTE_CONFIG_DIR now stays byte-empty (was 269 files written before dispatch) - Tolerate a model-less default config on the inspection surface via AppConfig::from_config_lenient + Model::default() in bootstrap; strict 'No available model' behavior unchanged for the REPL path and all mutating mcp/vault flags - --list-secrets degrades to an rc=0 informational message in sandbox mode (mutating vault flags still fail there), pinned by test - Add the 041.7-4 regression test: a malformed COYOTE__GLOBAL_TOOLS env override logs the debug message and leaves global_tools untouched - Adopt scripts/usage-probe-inspection.sh (10 flags: rc=0 + zero writes, output required only for --info) and add binary-level tests/inspection_flags.rs, incl. the agent+--info still-bootstraps pin --- scripts/usage-probe-inspection.sh | 92 ++++++++++++++++++++++ src/config/agent.rs | 27 +++++++ src/config/app_config.rs | 32 +++++++- src/config/app_state.rs | 9 ++- src/config/request_context.rs | 8 +- src/main.rs | 57 +++++++++++--- src/testing.rs | 42 +++++----- src/vault/mod.rs | 29 ++++++- tests/inspection_flags.rs | 124 ++++++++++++++++++++++++++++++ 9 files changed, 389 insertions(+), 31 deletions(-) create mode 100755 scripts/usage-probe-inspection.sh create mode 100644 tests/inspection_flags.rs diff --git a/scripts/usage-probe-inspection.sh b/scripts/usage-probe-inspection.sh new file mode 100755 index 0000000..5be12cb --- /dev/null +++ b/scripts/usage-probe-inspection.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Black-box usage-pattern probe for inspection flags on a pristine config dir. +# +# Contract under test (first-run wizard bypass for look-don't-touch flags): +# With COYOTE_CONFIG_DIR pointing at an EMPTY directory, every inspection +# flag (--info, --list-* , --mcp-list, vault list) must: +# 1) exit without prompting (no tty read, no first-run wizard), +# 2) exit 0 (Config::default() fallback); only --info guarantees output +# on stdout — list flags legitimately emit an empty listing on a +# pristine dir, +# 3) write NOTHING into the config dir (no config.yaml, no builtins). +# The plain interactive path (`coyote` on a tty, no flags) must still reach +# the first-run wizard prompt; that is covered manually / by the REPL recipe +# and is intentionally not asserted here (needs a pty). +# +# Usage: scripts/usage-probe-inspection.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 + +failures=0 +fail() { + echo "FAIL: $1" >&2 + failures=$((failures + 1)) +} + +# Each flag gets a fresh, empty config dir. stdin is closed so any attempt to +# prompt fails fast; timeout guards against a hang instead of a read error. +probe_flag() { + # $1 = "require-output" or "allow-empty" (stdout expectation), + # $2... = the flag (and any args) to pass + local output_mode="$1"; shift + local desc="$*" + local cfg out rc nwritten + cfg="$(mktemp -d "$WORKDIR/cfg.XXXXXX")" + out="$WORKDIR/out.txt" + rc=0 + COYOTE_CONFIG_DIR="$cfg" timeout 30 "$BIN" "$@" "$out" 2>&1 || rc=$? + + if [ "$rc" = "124" ]; then + fail "$desc: hung (likely waiting on a prompt) on an empty config dir" + return + fi + if [ "$rc" != "0" ]; then + fail "$desc: expected clean exit 0 on an empty config dir, got rc=$rc, output: $(head -c 200 "$out")" + fi + if [ "$output_mode" = "require-output" ] && [ ! -s "$out" ]; then + fail "$desc: expected some output, got none" + fi + nwritten="$(find "$cfg" -mindepth 1 | wc -l | tr -d ' ')" + if [ "$nwritten" != "0" ]; then + fail "$desc: expected NOTHING written into the empty config dir, found $nwritten entries: $(find "$cfg" -mindepth 1 -maxdepth 1 | tr '\n' ' ')" + fi +} + +echo "== Inspection flags on a pristine COYOTE_CONFIG_DIR: no wizard, clean exit, zero writes ==" +probe_flag require-output --info +probe_flag allow-empty --list-models +probe_flag allow-empty --list-roles +probe_flag allow-empty --list-sessions +probe_flag allow-empty --list-agents +probe_flag allow-empty --list-macros +probe_flag allow-empty --list-skills +probe_flag allow-empty --list-bundles +probe_flag allow-empty --list-secrets +probe_flag allow-empty --mcp-list + +if [ "$failures" != "0" ]; then + echo + echo "$failures inspection-flag scenario(s) FAILED" >&2 + exit 1 +fi + +echo +echo "ALL SCENARIOS PASSED" diff --git a/src/config/agent.rs b/src/config/agent.rs index a76b9c1..00ed166 100644 --- a/src/config/agent.rs +++ b/src/config/agent.rs @@ -1475,6 +1475,33 @@ global_hooks: } } + #[test] + #[serial_test::serial] + fn load_envs_ignores_malformed_global_tools_and_logs_debug() { + crate::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 = crate::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 = crate::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 = "\ 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..29d20e4 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,13 @@ impl AppState { } } - let mut functions = Functions::init(config.visible_tools.as_deref())?; + // Inspection-only runs are look-don't-touch readouts: skip the + // function-binary prune/build, which writes into the config dir. + 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/request_context.rs b/src/config/request_context.rs index 8874ed8..3e3e412 100644 --- a/src/config/request_context.rs +++ b/src/config/request_context.rs @@ -417,7 +417,13 @@ impl RequestContext { working_mode: WorkingMode, info_flag: bool, ) -> Result { - let model = Model::retrieve_model(&app.config, &app.config.model_id, ModelType::Chat)?; + // A lenient AppConfig (inspection-only run, no resolvable model) + // leaves model_id empty; render those readouts with a default model. + 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() { diff --git a/src/main.rs b/src/main.rs index 52ff77a..012c511 100644 --- a/src/main.rs +++ b/src/main.rs @@ -140,7 +140,16 @@ async fn main() -> Result<()> { return config::list_installed_bundles(); } - install_builtins()?; + // Inspection flags are look-don't-touch readouts: skip the builtins + // bootstrap so a pristine config dir stays empty. On such a dir the + // list flags list nothing because nothing is installed, by design. + // Agent/role runs still bootstrap: they load builtin tool definitions. + let inspection_only = (info_flag || cli.list_secrets || cli.mcp_list) + && 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); @@ -226,7 +235,15 @@ async fn main() -> Result<()> { 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 app_config = if cli.mcp_list + && cli.mcp_get.is_none() + && cli.mcp_remove.is_none() + && cli.mcp_add.is_none() + { + AppConfig::from_config_lenient(cfg)? + } else { + AppConfig::from_config(cfg)? + }; let vault = Vault::init(&app_config)?; mcp::manage::handle(&cli, &vault)?; @@ -236,7 +253,16 @@ async fn main() -> Result<()> { if vault_flags { let cfg = Config::load_with_interpolation(true).await?; - let app_config = AppConfig::from_config(cfg)?; + let app_config = if cli.list_secrets + && cli.add_secret.is_none() + && cli.get_secret.is_none() + && cli.update_secret.is_none() + && cli.delete_secret.is_none() + { + AppConfig::from_config_lenient(cfg)? + } else { + AppConfig::from_config(cfg)? + }; let vault = Vault::init(&app_config)?; return Vault::handle_vault_flags(cli, &vault); } @@ -244,7 +270,11 @@ async fn main() -> Result<()> { 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 mut app_config = if info_flag { + AppConfig::from_config_lenient(cfg)? + } else { + AppConfig::from_config(cfg)? + }; if cli.no_workspace_mcp { app_config.no_workspace_mcp = true; } @@ -258,14 +288,20 @@ 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?; + // Rebuilding the tool scope prunes and builds function binaries; + // inspection-only runs are readouts that never execute tools, so skip + // it there too. + if !inspection_only { + ctx.bootstrap_tools(&app_config, start_mcp_servers, abort_signal.clone()) + .await?; + } { let app = &*ctx.app.config; @@ -306,9 +342,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 { diff --git a/src/testing.rs b/src/testing.rs index 4442565..380e7cd 100644 --- a/src/testing.rs +++ b/src/testing.rs @@ -9,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 @@ -38,9 +43,9 @@ 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) } @@ -66,7 +71,7 @@ fn snapshot_of(buffer: &Mutex>) -> Vec { 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 @@ -77,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()) @@ -89,10 +94,11 @@ impl Log for TestLogCollector { } /// Installs the process-wide log collector capturing warn-level messages and -/// hooks-module debug messages. `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 hooks debug capture -/// sees its records; warn capture is unaffected. +/// 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_log_collector() { static INSTALL: Once = Once::new(); INSTALL.call_once(|| { diff --git a/src/vault/mod.rs b/src/vault/mod.rs index 43dad8a..4d187f2 100644 --- a/src/vault/mod.rs +++ b/src/vault/mod.rs @@ -332,7 +332,15 @@ impl Vault { } if cli.list_secrets { - vault.list_secrets(true)?; + // Listing is an inspection readout: report sandbox unavailability + // instead of failing like the mutating vault flags do. + 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 +418,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..1486c23 --- /dev/null +++ b/tests/inspection_flags.rs @@ -0,0 +1,124 @@ +//! 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. + +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 tmp_dir = fresh_config_dir(flag.trim_start_matches("--")); + + let output = Command::new(env!("CARGO_BIN_EXE_coyote")) + .arg(flag) + .env("COYOTE_CONFIG_DIR", &tmp_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); + + 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 output = Command::new(env!("CARGO_BIN_EXE_coyote")) + .args(["--agent", "adversary", "--info"]) + .env("COYOTE_CONFIG_DIR", &tmp_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); + + 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}" + ); +} From d89d24aff4783dcf6d123771e4d53a114e08bf8e Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 16:48:57 -0600 Subject: [PATCH 03/29] fix(TASK-046): keep inspection leniency off mutating and writing flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The look-don't-touch bypass was keyed on load_with_interpolation's pre-existing bool, which mutating early paths (--authenticate, --auth-mcp, mcp manage, vault flags) already passed as true meaning only 'skip vault interpolation' — so they silently lost their first-run config creation. Split the parameter: skip_interpolation keeps the historical meaning, a distinct inspection flag alone gates the missing-config default/wizard bypass. inspection_only is now computed from an explicit read-only whitelist: --sync-models is excluded (it downloads and writes models-override.yaml) and returns to the bootstrap/strict path, keeping only its historical interpolation-skip and vault leniency. mcp/vault inspection sub-conditions are hoisted and shared so mutating flag combos never inherit the bypass. Coverage: sandbox vault contract tests, a sync-models bootstrap-path pin, a --sync-models scenario in usage-probe-inspection.sh, and a PTY wizard regression probe (scripts/usage-probe-wizard.sh). --- scripts/usage-probe-inspection.sh | 23 ++++++ scripts/usage-probe-wizard.sh | 68 +++++++++++++++ src/cli/completer.rs | 10 ++- src/config/mod.rs | 17 ++-- src/main.rs | 50 ++++++----- tests/inspection_flags.rs | 133 +++++++++++++++++++++++++++++- 6 files changed, 269 insertions(+), 32 deletions(-) create mode 100755 scripts/usage-probe-wizard.sh diff --git a/scripts/usage-probe-inspection.sh b/scripts/usage-probe-inspection.sh index 5be12cb..b7e5543 100755 --- a/scripts/usage-probe-inspection.sh +++ b/scripts/usage-probe-inspection.sh @@ -82,6 +82,29 @@ probe_flag allow-empty --list-bundles probe_flag allow-empty --list-secrets probe_flag allow-empty --mcp-list +# --sync-models writes models-override.yaml, so it must NOT behave like an +# inspection flag: on a fresh empty config dir it takes the bootstrap path +# (builtins written) and, off a terminal, fails on the missing config. +# IS_SANDBOX is stripped: the host path is the one under test, and a leaked +# sandbox flag would reroute the missing config to the wizard prompt. +echo "== --sync-models on a pristine COYOTE_CONFIG_DIR: bootstrap path, strict failure ==" +sync_cfg="$(mktemp -d "$WORKDIR/cfg.XXXXXX")" +sync_out="$WORKDIR/sync-out.txt" +sync_rc=0 +env -u IS_SANDBOX -u COYOTE_PROVIDER -u COYOTE_PLATFORM COYOTE_CONFIG_DIR="$sync_cfg" \ + timeout 30 "$BIN" --sync-models "$sync_out" 2>&1 || sync_rc=$? +if [ "$sync_rc" = "124" ]; then + fail "--sync-models: hung (likely waiting on a prompt) on an empty config dir" +else + if [ "$sync_rc" = "0" ]; then + fail "--sync-models: expected the strict missing-config failure on an empty config dir, got rc=0" + fi + sync_nwritten="$(find "$sync_cfg" -mindepth 1 | wc -l | tr -d ' ')" + if [ "$sync_nwritten" = "0" ]; then + fail "--sync-models: expected the builtins bootstrap to populate the empty config dir, found nothing" + fi +fi + if [ "$failures" != "0" ]; then echo echo "$failures inspection-flag scenario(s) FAILED" >&2 diff --git a/scripts/usage-probe-wizard.sh b/scripts/usage-probe-wizard.sh new file mode 100755 index 0000000..947c5d5 --- /dev/null +++ b/scripts/usage-probe-wizard.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Black-box usage-pattern probe for the first-run wizard on a real PTY. +# +# Contract under test (companion to scripts/usage-probe-inspection.sh, which +# intentionally cannot assert this because it needs a pty): +# Plain `coyote` (no flags, no message) on a TTY with an EMPTY +# COYOTE_CONFIG_DIR must still reach the first-run wizard — the +# look-don't-touch bypass for inspection flags must NOT swallow the +# interactive setup path. We assert the wizard's first prompt +# ("No config file, create a new one?") appears within the timeout; the +# run is then killed, so the exit code is not asserted. +# +# Usage: scripts/usage-probe-wizard.sh +# Requires: a `coyote` binary on PATH or at ./target/debug/coyote or +# ./target/release/coyote, and the `script` utility (util-linux or BSD). + +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 + +if ! command -v script > /dev/null 2>&1; then + echo "SKIP: 'script' utility not available; cannot allocate a pty" >&2 + exit 0 +fi + +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT + +CFG="$WORKDIR/cfg" +mkdir -p "$CFG" +OUT="$WORKDIR/out.txt" + +echo "== Plain \`coyote\` on a pty with an empty COYOTE_CONFIG_DIR reaches the wizard ==" + +# `script` allocates a real pty for the child, so IS_STDOUT_TERMINAL is true +# inside even in headless CI. IS_SANDBOX is stripped to exercise the plain +# host path from the recipe. The wizard blocks on its prompt forever, so a +# timeout kill (rc 124) is the expected way out. +rc=0 +if script --version 2>/dev/null | grep -q util-linux; then + env -u IS_SANDBOX COYOTE_CONFIG_DIR="$CFG" \ + timeout 15 script -qec "$BIN" /dev/null > "$OUT" 2>&1 < /dev/null || rc=$? +else + # BSD/macOS script: command goes after the typescript file. + env -u IS_SANDBOX COYOTE_CONFIG_DIR="$CFG" \ + timeout 15 script -q "$OUT" "$BIN" < /dev/null > /dev/null 2>&1 || rc=$? +fi + +if ! grep -aq "No config file, create a new one?" "$OUT"; then + echo "FAIL: wizard prompt never appeared (rc=$rc); output was:" >&2 + head -c 500 "$OUT" >&2 + exit 1 +fi + +echo "PASS: first-run wizard prompt appeared on a pty with an empty config dir" +echo +echo "ALL SCENARIOS PASSED" diff --git a/src/cli/completer.rs b/src/cli/completer.rs index b64667c..6e356f5 100644 --- a/src/cli/completer.rs +++ b/src/cli/completer.rs @@ -50,13 +50,15 @@ pub(super) fn model_completer(current: &OsStr) -> Vec { fn load_app_config_for_completion() -> anyhow::Result { let h = tokio::runtime::Handle::try_current().ok(); + // Tab completion is a read-only inspection surface: never run the + // first-run wizard or write anything from a completer. 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/config/mod.rs b/src/config/mod.rs index 3ba4323..497c679 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -729,14 +729,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() { // Inspection-only flags are look-don't-touch: no first-run // config creation, nothing written. - if info_flag { + if inspection { return Ok(Self::default()); } create_config_file(&config_path).await?; @@ -756,7 +759,7 @@ impl Config { // Same look-don't-touch rule: never run the interactive // first-run wizard (or write a config) for a flag that // only inspects state. - if info_flag { + if inspection { return Ok(Self::default()); } if *IS_STDOUT_TERMINAL { @@ -769,7 +772,7 @@ impl Config { Self::load_from_file(&config_path)? }; - if info_flag { + if skip_interpolation { return Ok(config); } @@ -1627,7 +1630,7 @@ hooks: std::env::set_var(crate::sandbox::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 { @@ -1666,7 +1669,7 @@ hooks: 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).await; + let result = Config::load_with_interpolation(true, true).await; let leftover: Vec<_> = std::fs::read_dir(&tmp_dir) .unwrap() @@ -1694,7 +1697,7 @@ hooks: crate::testing::EnvVarGuard::set(get_env_name("config_file"), &config_path); let _sandbox = crate::testing::EnvVarGuard::set(crate::sandbox::SANDBOX_ENV_FLAG, "1"); - let result = Config::load_with_interpolation(true).await; + let result = Config::load_with_interpolation(true, true).await; let created = config_path.exists(); let _ = std::fs::remove_dir_all(&tmp_dir); diff --git a/src/main.rs b/src/main.rs index 012c511..39981e4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -113,6 +113,19 @@ async fn main() -> Result<()> { || cli.list_macros || cli.list_skills || cli.list_sessions; + // Read-only whitelist: --sync-models is excluded because it downloads + // and writes models-override.yaml; combined with a readout flag the run + // still writes (run() handles --sync-models first), so it disqualifies + // the whole invocation. + 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() @@ -144,9 +157,15 @@ async fn main() -> Result<()> { // bootstrap so a pristine config dir stays empty. On such a dir the // list flags list nothing because nothing is installed, by design. // Agent/role runs still bootstrap: they load builtin tool definitions. - let inspection_only = (info_flag || cli.list_secrets || cli.mcp_list) - && cli.agent.is_none() - && cli.role.is_none(); + 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()?; } @@ -173,7 +192,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)?; @@ -182,7 +201,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(); @@ -234,12 +253,8 @@ 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 = if cli.mcp_list - && cli.mcp_get.is_none() - && cli.mcp_remove.is_none() - && cli.mcp_add.is_none() - { + 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)? @@ -252,13 +267,8 @@ async fn main() -> Result<()> { } if vault_flags { - let cfg = Config::load_with_interpolation(true).await?; - let app_config = if cli.list_secrets - && cli.add_secret.is_none() - && cli.get_secret.is_none() - && cli.update_secret.is_none() - && cli.delete_secret.is_none() - { + 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)? @@ -269,8 +279,8 @@ async fn main() -> Result<()> { 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 = if info_flag { + 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)? diff --git a/tests/inspection_flags.rs b/tests/inspection_flags.rs index 1486c23..d96a293 100644 --- a/tests/inspection_flags.rs +++ b/tests/inspection_flags.rs @@ -6,7 +6,9 @@ //! --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. +//! 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. use std::env; use std::fs; @@ -122,3 +124,132 @@ fn agent_info_on_empty_config_dir_bootstraps_builtins() { "--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 output = Command::new(env!("CARGO_BIN_EXE_coyote")) + .arg("--list-secrets") + .env("COYOTE_CONFIG_DIR", &tmp_dir) + .env("IS_SANDBOX", "1") + .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); + + 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"); + 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") + .stdin(Stdio::null()) + .output() + .unwrap(); + let _ = fs::remove_dir_all(&tmp_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}" + ); +} + +// --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 output = Command::new(env!("CARGO_BIN_EXE_coyote")) + .arg("--sync-models") + .env("COYOTE_CONFIG_DIR", &tmp_dir) + .env_remove("IS_SANDBOX") + .env_remove("COYOTE_PROVIDER") + .env_remove("COYOTE_PLATFORM") + .stdin(Stdio::null()) + .output() + .unwrap(); + + let bootstrapped = fs::read_dir(&tmp_dir).unwrap().next().is_some(); + let _ = fs::remove_dir_all(&tmp_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" + ); +} From e7bd75947ccf2d102c82953436cfe93ef2f72986 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 17:59:09 -0600 Subject: [PATCH 04/29] feat(TASK-047): three-state hook install mode, top-level manifest reconcile, removed-agent sweep - Thread an explicit InstallMode (Skip/Prompt/Force) through the hook installers: startup install_builtins() is pinned to Skip and never prompts (test-pinned on both mocked-TTY and TTY-less paths); --install-builtins hooks runs Prompt mode across all three hook locations (global hooks dir, roles/hooks, agents//hooks) under one shared keep-all/replace-all sticky scope; force paths keep unconditional overwrite semantics. - Hoist the bundle resolve_conflict/StickyMode/ConflictAction prompt into shared config::conflict with a NonInteractive policy: bundle installs keep their non-TTY bail, builtin hook refreshes resolve non-TTY conflicts to keep. Bundle UX preserved byte-for-byte, pinned by tests. - Extend .builtin-manifest reconcile to the resolved top-level hooks dir (shipped set from HookAssets::iter()); malformed-manifest fail-safe (zero deletions) preserved and test-pinned. - Sweep removed builtin agents' hooks: agent dirs absent from the embed with a hooks/.builtin-manifest reconcile against an empty shipped set; empty hooks dirs are removed, user files and manifest-less agent dirs are never touched. - Update usage-probe-hooks.sh Scenario C to the new non-interactive keep contract and add black-box Scenarios E-G covering role/agent hook reconcile, top-level manifest fail-safety, and the removed-agent sweep. --- scripts/usage-probe-hooks.sh | 125 ++++++++++- src/config/agent.rs | 300 ++++++++++++++++++++++++++- src/config/conflict.rs | 379 ++++++++++++++++++++++++++++++++++ src/config/install_remote.rs | 64 +----- src/config/mod.rs | 167 ++++++++++++++- src/config/request_context.rs | 63 +++--- src/config/role.rs | 90 ++++++-- src/hooks.rs | 212 ++++++++++++++++++- 8 files changed, 1280 insertions(+), 120 deletions(-) create mode 100644 src/config/conflict.rs diff --git a/scripts/usage-probe-hooks.sh b/scripts/usage-probe-hooks.sh index 7096535..6246bb5 100755 --- a/scripts/usage-probe-hooks.sh +++ b/scripts/usage-probe-hooks.sh @@ -9,10 +9,16 @@ # 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. +# 0755 into /hooks via the real CLI; a non-interactive re-run +# keeps locally modified scripts while still installing missing ones. # D) `coyote --agent --info` (an inspection flag) fires ZERO # agent.* hook events. +# E) `coyote --install-builtins hooks` reconciles manifest-owned leftovers +# in roles/hooks and each bundled agent's hooks dir, sparing user files. +# F) top-level hooks-dir manifest reconcile: a stale shipped hook is removed +# on reinstall; a malformed manifest deletes NOTHING (fail-safe). +# G) a plain startup sweeps manifest-owned hooks of removed builtin agents +# while leaving user agent dirs and user files untouched. # # 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 @@ -143,16 +149,20 @@ if command -v stat > /dev/null 2>&1; then [ "$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. +# Modify one script locally and delete the other, then re-run +# --install-builtins hooks: without a terminal the conflict resolution keeps +# local files, so the edit must survive while the missing script is +# reinstalled from the packaged content. echo "# LOCALLY MODIFIED" > "$CFG_C/hooks/notify.sh" +rm "$CFG_C/hooks/log-events.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" + || fail "non-interactive reinstall overwrote the locally modified notify.sh" +[ -f "$CFG_C/hooks/log-events.sh" ] \ + || fail "reinstall did not restore the deleted log-events.sh" +grep -q '^#!/usr/bin/env bash' "$CFG_C/hooks/log-events.sh" \ + || fail "log-events.sh was not restored to the packaged script" +echo "PASS: --install-builtins hooks installs both scripts 0755; re-runs keep modified files and restore missing ones" echo "== Scenario C2: cold-start auto-bootstrap fills the hooks dir but never forces ==" CFG_C2="$WORKDIR/c2" @@ -228,5 +238,102 @@ 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 "== Scenario E: --install-builtins hooks reconciles roles/hooks and agent hooks dirs ==" +CFG_E="$WORKDIR/e" +AGENT_E="$(ls "$REPO_ROOT/assets/agents" | head -1)" +mkdir -p "$CFG_E/roles/hooks" "$CFG_E/agents/$AGENT_E/hooks" +# Manifest-owned leftovers a previous release shipped, plus user files that +# must survive the refresh in both locations. +printf 'stale-role.sh\n' > "$CFG_E/roles/hooks/.builtin-manifest" +echo "stale" > "$CFG_E/roles/hooks/stale-role.sh" +echo "mine" > "$CFG_E/roles/hooks/user-role.sh" +printf 'stale-agent.sh\n' > "$CFG_E/agents/$AGENT_E/hooks/.builtin-manifest" +echo "stale" > "$CFG_E/agents/$AGENT_E/hooks/stale-agent.sh" +echo "mine" > "$CFG_E/agents/$AGENT_E/hooks/user-agent.sh" + +COYOTE_CONFIG_DIR="$CFG_E" "$BIN" --install-builtins hooks > "$WORKDIR/install_e.out" 2>&1 \ + || fail "--install-builtins hooks failed: $(cat "$WORKDIR/install_e.out")" + +[ -f "$CFG_E/hooks/notify.sh" ] || fail "global hooks were not installed" +[ ! -f "$CFG_E/roles/hooks/stale-role.sh" ] \ + || fail "manifest-owned stale role hook survived the refresh" +[ "$(cat "$CFG_E/roles/hooks/user-role.sh")" = "mine" ] \ + || fail "user file in roles/hooks was touched by the refresh" +[ ! -f "$CFG_E/agents/$AGENT_E/hooks/stale-agent.sh" ] \ + || fail "manifest-owned stale agent hook survived the refresh" +[ "$(cat "$CFG_E/agents/$AGENT_E/hooks/user-agent.sh")" = "mine" ] \ + || fail "user file in the agent hooks dir was touched by the refresh" +echo "PASS: refresh reconciles roles/hooks and bundled-agent hooks, sparing user files" + +echo "== Scenario F: top-level manifest reconcile removes stale hooks; malformed manifest deletes nothing ==" +CFG_F="$WORKDIR/f" +mkdir -p "$CFG_F" +COYOTE_CONFIG_DIR="$CFG_F" "$BIN" --install-builtins hooks > /dev/null 2>&1 +[ -f "$CFG_F/hooks/.builtin-manifest" ] \ + || fail "install did not write a .builtin-manifest to the top-level hooks dir" + +# Simulate an upgrade from a release that also shipped old-hook.sh: the +# manifest records it, so reinstall must remove it — while a user script +# absent from the manifest survives. +printf 'log-events.sh\nnotify.sh\nold-hook.sh\n' > "$CFG_F/hooks/.builtin-manifest" +echo "stale" > "$CFG_F/hooks/old-hook.sh" +echo "mine" > "$CFG_F/hooks/user-hook.sh" +COYOTE_CONFIG_DIR="$CFG_F" "$BIN" --install-builtins hooks > /dev/null 2>&1 +[ ! -f "$CFG_F/hooks/old-hook.sh" ] \ + || fail "stale shipped hook recorded in the manifest was not removed on reinstall" +[ "$(cat "$CFG_F/hooks/user-hook.sh")" = "mine" ] \ + || fail "user hook script was deleted or modified by manifest reconcile" +grep -q 'old-hook.sh' "$CFG_F/hooks/.builtin-manifest" \ + && fail "manifest still lists the removed stale hook" + +# Fail-safe: a malformed (non-UTF8) manifest must delete NOTHING and must +# not fail the command. +printf '\xff\xfe\x00' > "$CFG_F/hooks/.builtin-manifest" +echo "keep" > "$CFG_F/hooks/orphan.sh" +exit_f=0 +COYOTE_CONFIG_DIR="$CFG_F" "$BIN" --install-builtins hooks > "$WORKDIR/install_f.out" 2>&1 || exit_f=$? +[ "$exit_f" = "0" ] \ + || fail "reinstall failed on a malformed manifest (exit $exit_f): $(cat "$WORKDIR/install_f.out")" +[ -f "$CFG_F/hooks/orphan.sh" ] \ + || fail "a malformed manifest caused a deletion (fail-safe broken)" +[ -f "$CFG_F/hooks/notify.sh" ] && [ -f "$CFG_F/hooks/log-events.sh" ] \ + || fail "shipped hooks missing after malformed-manifest reinstall" +echo "PASS: stale manifest-owned hook removed; user files survive; malformed manifest deletes nothing" + +echo "== Scenario G: plain startup sweeps removed-builtin-agent hooks, spares user agents ==" +CFG_G="$WORKDIR/g" +mkdir -p "$CFG_G" +write_dryrun_config "$CFG_G" +# ghost: a removed builtin agent (has a manifest, absent from the embed) +# with a user file beside the manifest-owned hook -> hooks dir survives +# with only the user file. +mkdir -p "$CFG_G/agents/ghost/hooks" +printf 'shipped.sh\n' > "$CFG_G/agents/ghost/hooks/.builtin-manifest" +echo "stale" > "$CFG_G/agents/ghost/hooks/shipped.sh" +echo "mine" > "$CFG_G/agents/ghost/hooks/user.sh" +# ghost2: same, but nothing user-owned -> the emptied hooks dir is removed. +mkdir -p "$CFG_G/agents/ghost2/hooks" +printf 'shipped.sh\n' > "$CFG_G/agents/ghost2/hooks/.builtin-manifest" +echo "stale" > "$CFG_G/agents/ghost2/hooks/shipped.sh" +# mine: a user agent with no manifest -> never touched. +mkdir -p "$CFG_G/agents/mine/hooks" +echo "mine" > "$CFG_G/agents/mine/hooks/mine.sh" + +COYOTE_CONFIG_DIR="$CFG_G" "$BIN" --no-stream "say exactly: hi" > /dev/null + +[ ! -f "$CFG_G/agents/ghost/hooks/shipped.sh" ] \ + || fail "removed-agent sweep left the manifest-owned hook behind" +[ ! -f "$CFG_G/agents/ghost/hooks/.builtin-manifest" ] \ + || fail "removed-agent sweep left the manifest behind" +[ "$(cat "$CFG_G/agents/ghost/hooks/user.sh")" = "mine" ] \ + || fail "removed-agent sweep deleted a user file" +[ ! -d "$CFG_G/agents/ghost2/hooks" ] \ + || fail "emptied hooks dir of a removed agent was not removed" +[ -d "$CFG_G/agents/ghost2" ] \ + || fail "sweep removed more than the hooks dir of a removed agent" +[ "$(cat "$CFG_G/agents/mine/hooks/mine.sh")" = "mine" ] \ + || fail "sweep touched a user agent dir without a manifest" +echo "PASS: startup sweep removes only manifest-owned hooks of removed agents" + echo echo "ALL SCENARIOS PASSED" diff --git a/src/config/agent.rs b/src/config/agent.rs index 00ed166..58e208c 100644 --- a/src/config/agent.rs +++ b/src/config/agent.rs @@ -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,7 +31,7 @@ use fancy_regex::Captures; use inquire::{Text, validator::Validation}; use rust_embed::Embed; use serde::{Deserialize, Serialize}; -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::{env, ffi::OsStr, path::Path}; const DEFAULT_AGENT_NAME: &str = "rag"; @@ -63,6 +65,91 @@ 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, Some(0o755))?; + 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 std::fs::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; + } + 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 _ = std::fs::remove_dir(&hooks_dir); + } +} + #[derive(Debug, Clone)] pub struct Agent { name: String, @@ -81,12 +168,16 @@ 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() ); + // Whole-agent installs run Skip (startup) or Force (reinstall); a + // Prompt caller would still get coherent per-file conflict handling, + // scoped to this run. + let mut sticky = StickyMode::None; let mut written_hooks: HashMap> = HashMap::new(); for file in AgentAssets::iter() { debug!("Processing agent file: {}", file.as_ref()); @@ -102,7 +193,15 @@ impl Agent { #[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() @@ -179,6 +278,38 @@ impl Agent { } } + sweep_removed_agent_hooks(&bundled_files); + + Ok(()) + } + + /// Refreshes only the bundled agents' hook scripts, leaving every other + /// agent file alone: the hooks asset category must be able to update all + /// hook locations without reinstalling whole agents. + /// Every bundled agent's hooks/ directory reconciles, even when the + /// current embed ships no hooks for it. + pub fn install_builtin_agent_hooks(mode: InstallMode, sticky: &mut StickyMode) -> Result<()> { + // Every bundled agent gets an entry, hooks or not: an agent whose + // hook assets were all dropped still needs its hooks/ directory + // reconciled so manifest-owned leftovers are removed. + 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 = unsafe { std::str::from_utf8_unchecked(&embedded.data) }; + hooks.push((hook.to_string(), content.to_string())); + } + + for (agent, files) in &shipped { + install_and_reconcile_agent_hooks(agent, files, mode, sticky)?; + } Ok(()) } @@ -1926,4 +2057,167 @@ nodes: {} ); assert_eq!(output, expected); } + + /// Points the config dir at a fresh temp directory for the guard's + /// lifetime and removes it on drop. Tests using it must serialize. + struct TestConfigDirGuard { + _env: crate::testing::EnvVarGuard, + root: PathBuf, + } + + impl TestConfigDirGuard { + fn new(label: &str) -> Self { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = env::temp_dir().join(format!("coyote-{label}-{unique}")); + std::fs::create_dir_all(&root).unwrap(); + Self { + _env: crate::testing::EnvVarGuard::set( + crate::utils::get_env_name("config_dir"), + &root, + ), + root, + } + } + } + + impl Drop for TestConfigDirGuard { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } + } + + 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"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("a.sh"), "local a").unwrap(); + std::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. + std::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(); + std::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(); + std::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"); + } } diff --git a/src/config/conflict.rs b/src/config/conflict.rs new file mode 100644 index 0000000..2ff7f1e --- /dev/null +++ b/src/config/conflict.rs @@ -0,0 +1,379 @@ +//! 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; + +/// How an installer treats a destination file that already exists. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum InstallMode { + /// Leave existing files untouched. The only mode startup ever uses: + /// startup runs on every launch — including the REPL, which is a + /// terminal — so it must never prompt. + Skip, + /// Overwrite existing files unconditionally. + Force, + /// Write missing files, silently skip identical ones, and ask per + /// differing file. + 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, +} + +/// What [`resolve_conflict`] does without a terminal: bundle installs bail +/// (an unattended install must not half-apply a plan silently), builtin hook +/// refreshes keep the local file (a scripted refresh must neither clobber +/// local changes nor fail). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum NonInteractive { + Bail, + Keep, +} + +/// Decides whether an installer should overwrite the existing file at `dst` +/// with `content` under `mode`. Only called for files that already exist; +/// missing files are always written without prompting. In `Prompt` mode an +/// unreadable local file counts as differing, so the user decides. +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-", ""); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("hook.sh"); + std::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 _ = std::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-", ""); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("hook.sh"); + std::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 _ = std::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..4ebbee1 100644 --- a/src/config/install_remote.rs +++ b/src/config/install_remote.rs @@ -3,6 +3,7 @@ use super::bundles::{ hash_file, }; use crate::config::builtin_manifest::is_builtin_manifest_name; +use crate::config::conflict::{ConflictAction, NonInteractive, StickyMode, resolve_conflict}; use crate::config::{AssetCategory, BUNDLE_MANIFEST_FILE, InstallFilter, paths}; #[cfg(not(windows))] use crate::function::Language; @@ -1886,18 +1887,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 +1926,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 +2031,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) diff --git a/src/config/mod.rs b/src/config/mod.rs index 497c679..e5d5885 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, @@ -418,11 +420,11 @@ impl Default for Config { pub fn install_builtins() -> Result<()> { 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 +521,19 @@ 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 => { + // One sticky scope spans all three hook locations, so a + // keep-all/replace-all answer carries through the whole refresh, + // matching bundle-install semantics. + 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 +554,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 \ @@ -1805,4 +1823,143 @@ hooks: std::panic::resume_unwind(panic); } } + + /// Points the config dir at a fresh temp directory for the guard's + /// lifetime and removes it on drop. Tests using it must serialize. + struct TestConfigDirGuard { + _env: crate::testing::EnvVarGuard, + root: PathBuf, + } + + impl TestConfigDirGuard { + fn new(label: &str) -> Self { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = env::temp_dir().join(format!("coyote-{label}-{unique}")); + fs::create_dir_all(&root).unwrap(); + Self { + _env: crate::testing::EnvVarGuard::set(get_env_name("config_dir"), &root), + root, + } + } + } + + impl Drop for TestConfigDirGuard { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } + } + + #[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!( + fs::read_to_string(¬ify) + .unwrap() + .starts_with("#!/usr/bin/env bash") + ); + assert_eq!( + fs::read_to_string(role_dir.join("hook.sh")).unwrap(), + "shipped\n" + ); + assert_eq!( + fs::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!(fs::read_to_string(¬ify).unwrap(), "local global"); + assert_eq!( + fs::read_to_string(role_dir.join("hook.sh")).unwrap(), + "local role" + ); + assert_eq!( + fs::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!(fs::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!(fs::read_to_string(¬ify).unwrap(), "# local edit"); + } } diff --git a/src/config/request_context.rs b/src/config/request_context.rs index 3e3e412..30e8486 100644 --- a/src/config/request_context.rs +++ b/src/config/request_context.rs @@ -5497,6 +5497,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; @@ -9184,19 +9185,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", @@ -9209,7 +9210,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 @@ -9230,7 +9231,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(), @@ -9270,12 +9271,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(), @@ -9291,7 +9292,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 @@ -9303,14 +9304,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(), @@ -9325,10 +9332,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] @@ -9372,7 +9387,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. @@ -9441,7 +9456,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 @@ -9605,7 +9620,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\\\""; @@ -9796,7 +9811,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), @@ -9915,7 +9930,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(); @@ -10142,7 +10157,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() { diff --git a/src/config/role.rs b/src/config/role.rs index eaa8123..55b2c9c 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; @@ -193,7 +194,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/")?; @@ -206,7 +207,7 @@ impl Role { }) .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 +642,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 +654,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() @@ -1049,8 +1053,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!( @@ -1082,14 +1091,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 +1142,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 +1175,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 +1201,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/hooks.rs b/src/hooks.rs index 78bf511..e3e1d4b 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -1,4 +1,5 @@ -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}; use crate::function::write_file_atomic; use anyhow::{Result, anyhow}; @@ -7,6 +8,7 @@ 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}; @@ -19,13 +21,14 @@ 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()); @@ -34,7 +37,9 @@ pub fn install_builtin_hooks(force: bool) -> Result<()> { let content = unsafe { std::str::from_utf8_unchecked(&embedded_file.data) }; let file_path = paths::hooks_dir().join(file.as_ref()); - if file_path.exists() && !force { + if file_path.exists() + && !conflict::should_replace_existing(&file_path, content, "hooks", mode, sticky)? + { debug!( "Hook file already exists, skipping: {}", file_path.display() @@ -46,6 +51,28 @@ pub fn install_builtin_hooks(force: bool) -> Result<()> { info!("Creating hook file: {}", file_path.display()); write_file_atomic(&file_path, content, Some(0o755))?; wrote_any = true; + if !file.as_ref().contains('/') { + written.insert(file.as_ref().to_string()); + } + } + + // Only direct children of the hooks dir reconcile through the builtin + // manifest — the only shape it tracks — so a hook a release stops + // shipping is removed while user scripts in the same directory are + // never touched. + let shipped: BTreeSet = HookAssets::iter() + .map(|file| file.as_ref().to_string()) + .filter(|name| !name.contains('/')) + .collect(); + // Reconciliation is best-effort housekeeping: a failure here must not + // abort startup (install_builtins), matching the role/agent-side policy. + 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() + ); } if wrote_any && paths::hooks_dir() != paths::config_dir().join("hooks") { @@ -614,6 +641,7 @@ 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 serial_test::serial; use std::env; @@ -643,6 +671,36 @@ 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: crate::testing::EnvVarGuard, + root: PathBuf, + } + + impl HooksDirGuard { + fn new(label: &str) -> Self { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = env::temp_dir().join(format!("coyote-{label}-{unique}")); + Self { + _env: crate::testing::EnvVarGuard::set( + crate::utils::get_env_name("hooks_dir"), + &root, + ), + root, + } + } + } + + impl Drop for HooksDirGuard { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } + } + #[test] #[serial] fn install_builtin_hooks_installs_executable_scripts_and_honors_force() { @@ -659,7 +717,7 @@ mod tests { // 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> = { @@ -670,9 +728,9 @@ 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); drop(env_guard); @@ -697,6 +755,148 @@ 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" + ); + } + /// 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. From 89a9398c2859058538f98ce95c3cdb3947bc97a0 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 18:30:02 -0600 Subject: [PATCH 05/29] feat(TASK-048): install polish, hooks-dir mixin scan, script and probe hardening - one shared exec-bit predicate (Language-recognized extensions => 0755) used by every hook/agent installer; non-script files in hooks dirs stay non-executable - mixin discovery scans the global hooks dir and each agent's hooks dir; .builtin-manifest is never scanned - NTFS 8.3 short-name aliases rejected as builtin-manifest entries - hook-script install announcements restricted to real hook locations - COYOTE_HOOKS_DIR override warning compares canonicalized paths - embedded assets decode via checked from_utf8 instead of from_utf8_unchecked - log-events.sh defaults under ${XDG_STATE_HOME:-$HOME/.local/state}/coyote/hooks.log keeping 0600/symlink-refusal/secret-filter hardening; notify.sh tty fallback strips control bytes; both use UTC ISO-8601 timestamps - payload files orphaned by a write failure are unlinked; stale coyote-hook-*.json payloads swept best-effort at startup - sweep_removed_agent_hooks never follows symlinked agent dirs - usage-probe-hooks.sh replaces bare sleeps with a bounded poll, re-verifies 0755 on restored scripts, and covers roles/agent hook locations - TestConfigDirGuard hoisted into crate::testing - .gitattributes pins LF line endings for bundled scripts hooks is now a reserved bundle name. --- .gitattributes | 4 + assets/hooks/log-events.sh | 13 +- assets/hooks/notify.sh | 5 +- scripts/usage-probe-hooks.sh | 66 +++++-- src/config/agent.rs | 90 +++++---- src/config/builtin_manifest.rs | 47 ++++- src/config/install_remote.rs | 62 +++---- src/config/macros.rs | 59 +----- src/config/mod.rs | 58 +++--- src/config/role.rs | 44 ++++- src/config/skill.rs | 3 +- src/graph/executor.rs | 52 +----- src/graph/map.rs | 60 +----- src/hooks.rs | 330 +++++++++++++++++++++++++-------- src/sandbox/mixins.rs | 58 ++++++ src/testing.rs | 29 +++ 16 files changed, 627 insertions(+), 353 deletions(-) diff --git a/.gitattributes b/.gitattributes index e05c06b..07bd69a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,5 @@ assets/config-template.yaml text eol=lf +assets/hooks/*.sh text eol=lf +assets/functions/tools/* text eol=lf +assets/functions/scripts/* text eol=lf +assets/functions/utils/*.sh text eol=lf 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 index 6246bb5..aab18ac 100755 --- a/scripts/usage-probe-hooks.sh +++ b/scripts/usage-probe-hooks.sh @@ -52,6 +52,21 @@ fail() { exit 1 } +# Bounded poll: retries the given command every 0.1s until it succeeds or the +# ~10s deadline expires, then fails loudly. Hooks are fire-and-forget child +# processes, so every wait on their side effects goes through this helper — +# never a bare sleep. +wait_for() { + local what="$1" + shift + local tries=0 + until "$@"; do + tries=$((tries + 1)) + [ "$tries" -ge 100 ] && fail "timed out waiting for $what" + sleep 0.1 + done +} + write_dryrun_config() { # $1 = config dir, $2..$n = extra YAML fragment lines appended verbatim local dir="$1" @@ -95,7 +110,8 @@ write_dryrun_config "$CFG_A" "hooks:" \ " 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 +scenario_a_logs_ready() { [ -s "$TS_LOG" ] && [ -s "$TC_LOG" ] && [ -s "$LC_LOG" ]; } +wait_for "turn/llm hook logs" scenario_a_logs_ready [ "$(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)" @@ -120,12 +136,12 @@ 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=$? +rc_b=0 +COYOTE_CONFIG_DIR="$CFG_B" "$BIN" --no-stream "say exactly: hi" > "$WORKDIR/out_b.txt" 2> "$WORKDIR/err_b.txt" || rc_b=$? +rc_base=0 +COYOTE_CONFIG_DIR="$CFG_BASE" "$BIN" --no-stream "say exactly: hi" > "$WORKDIR/out_base.txt" 2> "$WORKDIR/err_base.txt" || rc_base=$? -[ "$exit_b" = "$exit_base" ] || fail "exit code differs: with-hooks=$exit_b baseline=$exit_base" +[ "$rc_b" = "$rc_base" ] || fail "exit code differs: with-hooks=$rc_b baseline=$rc_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" @@ -155,6 +171,12 @@ fi # reinstalled from the packaged content. echo "# LOCALLY MODIFIED" > "$CFG_C/hooks/notify.sh" rm "$CFG_C/hooks/log-events.sh" +# The refresh also touches the roles and per-agent hook locations: user files +# living there must survive it untouched. +AGENT_C="$(ls "$CFG_C/agents" | head -1)" +mkdir -p "$CFG_C/roles/hooks" "$CFG_C/agents/$AGENT_C/hooks" +echo "mine-role" > "$CFG_C/roles/hooks/user-role.sh" +echo "mine-agent" > "$CFG_C/agents/$AGENT_C/hooks/user-agent.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 "non-interactive reinstall overwrote the locally modified notify.sh" @@ -162,7 +184,15 @@ grep -q "LOCALLY MODIFIED" "$CFG_C/hooks/notify.sh" \ || fail "reinstall did not restore the deleted log-events.sh" grep -q '^#!/usr/bin/env bash' "$CFG_C/hooks/log-events.sh" \ || fail "log-events.sh was not restored to the packaged script" -echo "PASS: --install-builtins hooks installs both scripts 0755; re-runs keep modified files and restore missing ones" +if command -v stat > /dev/null 2>&1; then + mode_restored="$(stat -c '%a' "$CFG_C/hooks/log-events.sh" 2>/dev/null || stat -f '%Lp' "$CFG_C/hooks/log-events.sh")" + [ "$mode_restored" = "755" ] || fail "restored log-events.sh mode is $mode_restored, expected 755" +fi +[ "$(cat "$CFG_C/roles/hooks/user-role.sh")" = "mine-role" ] \ + || fail "user file in roles/hooks was touched by the refresh" +[ "$(cat "$CFG_C/agents/$AGENT_C/hooks/user-agent.sh")" = "mine-agent" ] \ + || fail "user file in the agent hooks dir was touched by the refresh" +echo "PASS: --install-builtins hooks installs both scripts 0755; re-runs keep modified files (all hook locations) and restore missing ones 0755" echo "== Scenario C2: cold-start auto-bootstrap fills the hooks dir but never forces ==" CFG_C2="$WORKDIR/c2" @@ -198,7 +228,7 @@ write_dryrun_config "$CFG_C3" "hooks:" \ 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 +wait_for "installed log-events.sh output" test -s "$HOOK_LOG" [ -f "$HOOK_LOG" ] || fail "installed log-events.sh did not write to COYOTE_HOOK_LOG via relative-path wiring" [ "$(grep -c '^=== ' "$HOOK_LOG")" = "1" ] \ @@ -219,6 +249,7 @@ echo "== Scenario D: --agent --info fires zero agent.* events ==" CFG_D="$WORKDIR/d" mkdir -p "$CFG_D/agents" AGENT_LOG="$WORKDIR/agent-events.log" +SENTINEL_LOG="$WORKDIR/d-sentinel.log" write_dryrun_config "$CFG_D" "hooks:" \ " agent.started:" \ " - name: probe-agent" \ @@ -228,13 +259,20 @@ write_dryrun_config "$CFG_D" "hooks:" \ " command: \"echo AGENT_DONE >> $AGENT_LOG\"" \ " agent.failed:" \ " - name: probe-agent" \ - " command: \"echo AGENT_FAILED >> $AGENT_LOG\"" + " command: \"echo AGENT_FAILED >> $AGENT_LOG\"" \ + " turn.completed:" \ + " - name: sentinel" \ + " command: \"echo SENTINEL >> $SENTINEL_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 +# Proving absence needs a positive signal to wait on: a follow-up plain run +# fires turn.completed, and its sentinel landing bounds how long the --info +# run's (nonexistent) agent.* children could have taken to write. +COYOTE_CONFIG_DIR="$CFG_D" "$BIN" --no-stream "say exactly: hi" > /dev/null +wait_for "scenario D sentinel" test -s "$SENTINEL_LOG" [ -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" @@ -290,10 +328,10 @@ grep -q 'old-hook.sh' "$CFG_F/hooks/.builtin-manifest" \ # not fail the command. printf '\xff\xfe\x00' > "$CFG_F/hooks/.builtin-manifest" echo "keep" > "$CFG_F/hooks/orphan.sh" -exit_f=0 -COYOTE_CONFIG_DIR="$CFG_F" "$BIN" --install-builtins hooks > "$WORKDIR/install_f.out" 2>&1 || exit_f=$? -[ "$exit_f" = "0" ] \ - || fail "reinstall failed on a malformed manifest (exit $exit_f): $(cat "$WORKDIR/install_f.out")" +rc_f=0 +COYOTE_CONFIG_DIR="$CFG_F" "$BIN" --install-builtins hooks > "$WORKDIR/install_f.out" 2>&1 || rc_f=$? +[ "$rc_f" = "0" ] \ + || fail "reinstall failed on a malformed manifest (exit $rc_f): $(cat "$WORKDIR/install_f.out")" [ -f "$CFG_F/hooks/orphan.sh" ] \ || fail "a malformed manifest caused a deletion (fail-safe broken)" [ -f "$CFG_F/hooks/notify.sh" ] && [ -f "$CFG_F/hooks/log-events.sh" ] \ diff --git a/src/config/agent.rs b/src/config/agent.rs index 58e208c..156bd5e 100644 --- a/src/config/agent.rs +++ b/src/config/agent.rs @@ -32,7 +32,7 @@ use inquire::{Text, validator::Validation}; use rust_embed::Embed; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; -use std::{env, ffi::OsStr, path::Path}; +use std::{env, path::Path}; const DEFAULT_AGENT_NAME: &str = "rag"; @@ -89,7 +89,8 @@ pub(crate) fn install_and_reconcile_agent_hooks( } ensure_parent_exists(&path)?; info!("Creating agent 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()); } @@ -127,6 +128,15 @@ fn sweep_removed_agent_hooks(bundled_files: &HashMap>) { 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) @@ -184,14 +194,9 @@ impl Agent { 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() && !conflict::should_replace_existing( @@ -213,18 +218,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(); @@ -303,7 +303,8 @@ impl Agent { }; let embedded = 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.data) }; + 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())); } @@ -2058,36 +2059,7 @@ nodes: {} assert_eq!(output, expected); } - /// Points the config dir at a fresh temp directory for the guard's - /// lifetime and removes it on drop. Tests using it must serialize. - struct TestConfigDirGuard { - _env: crate::testing::EnvVarGuard, - root: PathBuf, - } - - impl TestConfigDirGuard { - fn new(label: &str) -> Self { - let unique = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(); - let root = env::temp_dir().join(format!("coyote-{label}-{unique}")); - std::fs::create_dir_all(&root).unwrap(); - Self { - _env: crate::testing::EnvVarGuard::set( - crate::utils::get_env_name("config_dir"), - &root, - ), - root, - } - } - } - - impl Drop for TestConfigDirGuard { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.root); - } - } + use crate::testing::TestConfigDirGuard; fn fixture(entries: &[(&str, &str)]) -> Vec<(String, String)> { entries @@ -2220,4 +2192,30 @@ nodes: {} ); 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"); + std::fs::create_dir_all(&hooks).unwrap(); + std::fs::write( + hooks.join(builtin_manifest::BUILTIN_MANIFEST_FILE), + "shipped.sh\n", + ) + .unwrap(); + std::fs::write(hooks.join("shipped.sh"), "stale").unwrap(); + std::fs::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/builtin_manifest.rs b/src/config/builtin_manifest.rs index 7e3bc31..ce0da9c 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,33 @@ 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")); + } + + #[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); + } } diff --git a/src/config/install_remote.rs b/src/config/install_remote.rs index 4ebbee1..d776fd0 100644 --- a/src/config/install_remote.rs +++ b/src/config/install_remote.rs @@ -4,9 +4,9 @@ use super::bundles::{ }; use crate::config::builtin_manifest::is_builtin_manifest_name; use crate::config::conflict::{ConflictAction, NonInteractive, StickyMode, resolve_conflict}; -use crate::config::{AssetCategory, BUNDLE_MANIFEST_FILE, InstallFilter, paths}; -#[cfg(not(windows))] -use crate::function::Language; +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; @@ -1858,18 +1858,18 @@ 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. +/// +/// Only the locations the engine actually resolves hooks from are announced: +/// the top-level hooks category, direct children of `agents//hooks/`, +/// and direct children of `roles/hooks/`. A `hooks/` directory anywhere else +/// (e.g. inside a skill or macro subtree) is inert data and announcing it +/// would be noise. 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!( @@ -1880,6 +1880,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() @@ -2042,26 +2052,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, @@ -2874,6 +2864,14 @@ mod tests { file("x/config.yaml", TopCategory::Agents), file("reviewer.md", TopCategory::Roles), file("hello.yaml", TopCategory::Macros), + // Only the locations the engine resolves hooks from announce: + // a hooks/ directory nested deeper or under another category + // is inert data. + 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..1da3b9d 100644 --- a/src/config/macros.rs +++ b/src/config/macros.rs @@ -167,7 +167,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 +351,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 +933,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 +965,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 +1016,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 +1052,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 +1081,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 +1111,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/mod.rs b/src/config/mod.rs index e5d5885..c87a6b0 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -419,6 +419,10 @@ impl Default for Config { } pub fn install_builtins() -> Result<()> { + // Best-effort housekeeping riding along with the startup installers: + // payload files orphaned by crashed processes accumulate in a shared + // temp dir and nothing else ever revisits them. + hooks::sweep_stale_payload_files(); Functions::install_builtin_global_tools(false)?; Agent::install_builtin_agents(InstallMode::Skip)?; Macro::install_macros(false)?; @@ -1152,6 +1156,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, @@ -1824,33 +1854,7 @@ hooks: } } - /// Points the config dir at a fresh temp directory for the guard's - /// lifetime and removes it on drop. Tests using it must serialize. - struct TestConfigDirGuard { - _env: crate::testing::EnvVarGuard, - root: PathBuf, - } - - impl TestConfigDirGuard { - fn new(label: &str) -> Self { - let unique = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(); - let root = env::temp_dir().join(format!("coyote-{label}-{unique}")); - fs::create_dir_all(&root).unwrap(); - Self { - _env: crate::testing::EnvVarGuard::set(get_env_name("config_dir"), &root), - root, - } - } - } - - impl Drop for TestConfigDirGuard { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.root); - } - } + use crate::testing::TestConfigDirGuard; #[test] #[serial_test::serial] diff --git a/src/config/role.rs b/src/config/role.rs index 55b2c9c..a2fa11c 100644 --- a/src/config/role.rs +++ b/src/config/role.rs @@ -180,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)) } @@ -202,7 +203,8 @@ 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(); @@ -665,7 +667,8 @@ pub(crate) 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()); } @@ -1084,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-"); 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/graph/executor.rs b/src/graph/executor.rs index 9f6b6e2..a840280 100644 --- a/src/graph/executor.rs +++ b/src/graph/executor.rs @@ -1132,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() @@ -1267,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"); @@ -1681,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(); @@ -2364,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"; @@ -2654,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 e3e1d4b..2ee36fa 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -1,5 +1,7 @@ use crate::config::conflict::{self, InstallMode, StickyMode}; -use crate::config::{RequestContext, builtin_manifest, ensure_parent_exists, paths}; +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}; @@ -34,7 +36,8 @@ pub fn install_builtin_hooks(mode: InstallMode, sticky: &mut StickyMode) -> Resu 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() @@ -49,7 +52,8 @@ pub fn install_builtin_hooks(mode: InstallMode, sticky: &mut StickyMode) -> Resu 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()); @@ -75,7 +79,11 @@ pub fn install_builtin_hooks(mode: InstallMode, sticky: &mut StickyMode) -> Resu ); } - if wrote_any && paths::hooks_dir() != paths::config_dir().join("hooks") { + // Both sides canonicalize so an override that merely spells the default + // path differently (symlinks, `..`, trailing components) does not warn. + 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 {}", @@ -88,6 +96,10 @@ pub fn install_builtin_hooks(mode: InstallMode, sticky: &mut StickyMode) -> Resu 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 { @@ -437,6 +449,21 @@ async fn write_payload_file(path: &Path, json: &str) -> std::io::Result<()> { #[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 } @@ -444,6 +471,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 @@ -456,6 +487,65 @@ fn payload_dir() -> PathBuf { std::env::temp_dir() } +const STALE_PAYLOAD_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60); + +/// Removes payload files left in the shared payload directory by crashed +/// processes — `run_hook` deletes its own file after the hook exits, so +/// anything older than [`STALE_PAYLOAD_MAX_AGE`] is an orphan. Runs once per +/// startup from `install_builtins`. Only exact `coyote-hook-*.json` names are +/// candidates; the directory is shared, so nothing else may ever be touched. +/// Best-effort: failures log at debug and never abort startup. +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 = std::time::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, @@ -701,6 +791,131 @@ mod tests { } } + /// 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 = std::time::SystemTime::now() + .duration_since(std::time::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() { @@ -930,17 +1145,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)] @@ -979,13 +1194,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 { @@ -996,11 +1222,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] @@ -1508,59 +1739,10 @@ mod tests { #[cfg(unix)] mod dispatch { use super::*; - use crate::utils::get_env_name; - use std::fs::{create_dir_all, remove_dir_all}; + use crate::testing::TestConfigDirGuard; + use std::fs::create_dir_all; use std::path::PathBuf; - use std::time::{Duration, SystemTime, UNIX_EPOCH}; - - struct TestConfigDirGuard { - _env: crate::testing::EnvVarGuard, - path: PathBuf, - } - - impl TestConfigDirGuard { - fn new() -> Self { - let key = get_env_name("config_dir"); - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let path = env::temp_dir().join(format!("coyote-hooks-tests-{unique}")); - create_dir_all(&path).unwrap(); - Self { - _env: crate::testing::EnvVarGuard::set(key, &path), - path, - } - } - } - - impl Drop for TestConfigDirGuard { - fn drop(&mut self) { - 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; - } - } + use std::time::Duration; async fn wait_for(what: &str, cond: impl Fn() -> bool) { for _ in 0..400 { @@ -1575,7 +1757,7 @@ mod tests { #[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"); @@ -1620,7 +1802,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)])); @@ -1644,7 +1826,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""#; @@ -1673,7 +1855,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"); @@ -1744,7 +1926,7 @@ mod tests { #[serial] async fn failing_hook_never_disturbs_the_engine() { crate::testing::install_log_collector(); - let guard = TestConfigDirGuard::new(); + 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"#; @@ -1781,7 +1963,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}"#) @@ -1795,7 +1977,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(); @@ -1814,7 +1996,7 @@ mod tests { #[serial] async fn payload_write_failure_still_runs_hook_without_payload_env() { crate::testing::install_log_collector(); - let guard = TestConfigDirGuard::new(); + 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""#; @@ -1846,7 +2028,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""#; @@ -1877,7 +2059,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] #[serial] async fn payload_paths_differ_within_one_fire() { - 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_first = r#"printf '%s' "$COYOTE_HOOK_PAYLOAD_FILE" > "$HOOK_PATH_FIRST.tmp" && mv "$HOOK_PATH_FIRST.tmp" "$HOOK_PATH_FIRST""#; @@ -1914,7 +2096,7 @@ mod tests { #[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(); diff --git a/src/sandbox/mixins.rs b/src/sandbox/mixins.rs index 7021d41..015b2e9 100644 --- a/src/sandbox/mixins.rs +++ b/src/sandbox/mixins.rs @@ -197,6 +197,16 @@ pub fn discover() -> Result> { ) { out.push(read_mixin(path)?); } + // Hooks directories are deliberately scanned: hook scripts run inside the + // sandbox too, so a sidecar mixin next to one can declare its needs. The + // agents scan above never descends into `agents//hooks/`, and the + // `.builtin-manifest` living in these dirs is not matched by the + // `*.sbx-mixin.yaml` suffix filter. + 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 +216,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 +1018,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/testing.rs b/src/testing.rs index 380e7cd..00957d6 100644 --- a/src/testing.rs +++ b/src/testing.rs @@ -146,3 +146,32 @@ impl Drop for EnvVarGuard { } } } + +/// 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); + } +} From 7ace1731c65426ab12e20b08aceaf98db799c7de Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 18:48:15 -0600 Subject: [PATCH 06/29] fix(TASK-048): cover agent and repo scripts in eol=lf rules; probe Scenario H for installed bundled scripts Extends the .gitattributes eol=lf guard to assets/agents/** (33 embedded shell scripts share the CRLF failure mode the hooks rules fix) and scripts/*.sh. Adopts the probe-authored Scenario H: black-box checks of the installed log-events.sh (XDG default, 0600, UTC header, secret filter, symlink refusal) and notify.sh (control-byte-stripped fallback), plus non-executable .builtin-manifest. --- .gitattributes | 2 + scripts/usage-probe-hooks.sh | 85 ++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/.gitattributes b/.gitattributes index 07bd69a..a5280f8 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +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/scripts/usage-probe-hooks.sh b/scripts/usage-probe-hooks.sh index aab18ac..2cd99be 100755 --- a/scripts/usage-probe-hooks.sh +++ b/scripts/usage-probe-hooks.sh @@ -19,6 +19,12 @@ # on reinstall; a malformed manifest deletes NOTHING (fail-safe). # G) a plain startup sweeps manifest-owned hooks of removed builtin agents # while leaving user agent dirs and user files untouched. +# H) the INSTALLED bundled scripts behave as documented when run directly: +# log-events.sh defaults under ${XDG_STATE_HOME:-$HOME/.local/state}/coyote/ +# (0600, UTC ISO-8601 header, secrets filtered, symlink at the default +# path refused) and notify.sh's no-notifier/no-tty fallback strips +# ANSI/OSC control bytes; the hooks-dir .builtin-manifest is never +# installed executable. # # 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 @@ -373,5 +379,84 @@ COYOTE_CONFIG_DIR="$CFG_G" "$BIN" --no-stream "say exactly: hi" > /dev/null || fail "sweep touched a user agent dir without a manifest" echo "PASS: startup sweep removes only manifest-owned hooks of removed agents" +echo "== Scenario H: bundled hook scripts behave as documented when run directly ==" +# The two shipped scripts are consumer-facing artifacts in their own right. +# Exercise the INSTALLED copies (real CLI install) the way a hook runner or a +# manual invocation would: clean env, no COYOTE_HOOK_LOG, no notifier +# binaries, no controlling terminal. +CFG_H="$WORKDIR/h" +mkdir -p "$CFG_H" +COYOTE_CONFIG_DIR="$CFG_H" "$BIN" --install-builtins hooks > /dev/null 2>&1 +LOG_SCRIPT="$CFG_H/hooks/log-events.sh" +NOTIFY_SCRIPT="$CFG_H/hooks/notify.sh" +[ -x "$LOG_SCRIPT" ] || fail "installed log-events.sh missing or non-executable" +[ -x "$NOTIFY_SCRIPT" ] || fail "installed notify.sh missing or non-executable" + +# The manifest the installer drops beside the scripts is NOT a script: the +# unified exec-bit predicate must leave it non-executable. +if [ -f "$CFG_H/hooks/.builtin-manifest" ]; then + [ ! -x "$CFG_H/hooks/.builtin-manifest" ] \ + || fail ".builtin-manifest in the hooks dir is executable; non-script files must stay non-executable" +fi + +# H1: with COYOTE_HOOK_LOG unset the log defaults under XDG_STATE_HOME, is +# created 0600, carries a UTC ISO-8601 header, and never logs COYOTE_SECRET_*. +XDG_H="$WORKDIR/h-xdg" +env -u COYOTE_HOOK_LOG XDG_STATE_HOME="$XDG_H" HOME="$WORKDIR/h-home-unused" \ + COYOTE_EVENT=probe.event COYOTE_HOOK_NAME=probe COYOTE_SECRET_TOKEN=hunter2 \ + bash "$LOG_SCRIPT" || fail "log-events.sh exited non-zero on the XDG default path" +HLOG="$XDG_H/coyote/hooks.log" +[ -f "$HLOG" ] || fail "log-events.sh did not default to \$XDG_STATE_HOME/coyote/hooks.log" +if command -v stat > /dev/null 2>&1; then + mode_hlog="$(stat -c '%a' "$HLOG" 2>/dev/null || stat -f '%Lp' "$HLOG")" + [ "$mode_hlog" = "600" ] || fail "default hook log mode is $mode_hlog, expected 600" +fi +grep -Eq '^=== [0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z probe\.event$' "$HLOG" \ + || fail "log header is not a UTC ISO-8601 timestamp: $(head -1 "$HLOG")" +grep -q '^COYOTE_EVENT=probe.event$' "$HLOG" \ + || fail "log snapshot is missing COYOTE_EVENT" +! grep -q 'hunter2' "$HLOG" \ + || fail "COYOTE_SECRET_* value leaked into the hook log" + +# H2: with XDG_STATE_HOME also unset, the log falls back to +# $HOME/.local/state/coyote/hooks.log. +HOME_H="$WORKDIR/h-home" +mkdir -p "$HOME_H" +env -u COYOTE_HOOK_LOG -u XDG_STATE_HOME HOME="$HOME_H" COYOTE_EVENT=probe.event \ + bash "$LOG_SCRIPT" || fail "log-events.sh exited non-zero on the HOME default path" +[ -f "$HOME_H/.local/state/coyote/hooks.log" ] \ + || fail "log-events.sh did not fall back to ~/.local/state/coyote/hooks.log" + +# H3: a symlink planted at the DEFAULT log path is refused — exit 0, target +# untouched (the hardening must hold on the new default location too). +HOME_S="$WORKDIR/h-sym" +mkdir -p "$HOME_S/.local/state/coyote" +VICTIM_H="$WORKDIR/h-victim.txt" +echo "untouched" > "$VICTIM_H" +ln -s "$VICTIM_H" "$HOME_S/.local/state/coyote/hooks.log" +env -u COYOTE_HOOK_LOG -u XDG_STATE_HOME HOME="$HOME_S" COYOTE_EVENT=probe.event \ + bash "$LOG_SCRIPT" || fail "log-events.sh exited non-zero when refusing a symlink" +[ "$(cat "$VICTIM_H")" = "untouched" ] \ + || fail "log-events.sh wrote through a symlink planted at the default log path" + +# H4: notify.sh with no notifier on PATH and no controlling terminal falls +# back to stdout with control bytes (ESC/BEL, i.e. ANSI + OSC) stripped from +# model-influenced values. +BIN_H="$WORKDIR/h-bin" +mkdir -p "$BIN_H" +ln -s "$(command -v tr)" "$BIN_H/tr" +NOTIFY_OUT="$WORKDIR/h-notify.out" +rc_h=0 +setsid -w env -i PATH="$BIN_H" \ + COYOTE_EVENT=turn.completed \ + COYOTE_TOOL_NAME="$(printf 'evil\033]0;own\007\033[31mred')" \ + "$(command -v bash)" "$NOTIFY_SCRIPT" > "$NOTIFY_OUT" 2> /dev/null || rc_h=$? +[ "$rc_h" = "0" ] || fail "notify.sh fallback exited $rc_h" +grep -Fq '[coyote] turn.completed tool=evil]0;own[31mred' "$NOTIFY_OUT" \ + || fail "notify.sh fallback output missing/unsanitized: $(cat "$NOTIFY_OUT")" +! LC_ALL=C grep -q "$(printf '\033')" "$NOTIFY_OUT" \ + || fail "ANSI/OSC escape bytes leaked through the notify.sh tty fallback" +echo "PASS: installed hook scripts honor XDG default, 0600, UTC timestamps, symlink refusal, secret filter, and ANSI-stripped notify fallback" + echo echo "ALL SCENARIOS PASSED" From 1744d2ba523864d5b37e6469b2de88aeeeec05f0 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 19:05:17 -0600 Subject: [PATCH 07/29] test(TASK-048): cover canonicalized hooks-dir override warning and payload orphan unlink on spawn failure Two conformance gaps were tests-only: - install_builtin_hooks warning: a #[serial] test proves the canonicalized compare stays quiet when COYOTE_HOOKS_DIR merely respells the default (via a '..' hop and, on unix, a symlink), and a second test proves a genuine override still warns on a fresh install where the default hooks dir does not exist (fallback compare). - run_hook payload cleanup: a dispatch test fires a hook with a payload and an unspawnable cwd, anchors on the spawn-failure debug log (and the absence of a payload-write failure) so the assertion cannot pass vacuously, then polls with a bounded deadline until the payload file is unlinked. Both tests were verified red-capable: reverting the canonicalization or skipping the unlink on the error branch makes them fail. --- src/hooks.rs | 134 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/src/hooks.rs b/src/hooks.rs index 2ee36fa..608a244 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -1112,6 +1112,90 @@ mod tests { ); } + #[test] + #[serial] + fn install_builtin_hooks_respelled_default_hooks_dir_does_not_warn() { + crate::testing::install_log_collector(); + let guard = crate::testing::TestConfigDirGuard::new("hooks-warn-respelled"); + let guard_path_display = guard.path.display().to_string(); + let env_name = crate::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 = crate::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 = crate::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 = crate::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() { + crate::testing::install_log_collector(); + let guard = crate::testing::TestConfigDirGuard::new("hooks-warn-genuine"); + let override_dir = guard.path.join("elsewhere-hooks"); + let _env_guard = crate::testing::EnvVarGuard::set( + crate::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 = crate::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. @@ -1852,6 +1936,56 @@ mod tests { wait_for("payload file removal", || !payload_file.exists()).await; } + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn spawn_failure_removes_the_payload_file() { + crate::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: PathBuf::from("/nonexistent/coyote-spawnfail-marker-c9d"), + }]; + + 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", || { + crate::testing::debug_snapshot().iter().any(|message| { + message.contains("Failed to spawn hook 'tool.started.spawnfail-marker-c9d'") + }) + }) + .await; + let debugs = crate::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() + .flatten() + .next() + .is_none() + }) + .await; + } + #[tokio::test(flavor = "multi_thread")] #[serial] async fn drain_pending_waits_for_spawn_not_completion() { From 739c1be8bb83cfc25879607f860319f0743eac01 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 19:30:59 -0600 Subject: [PATCH 08/29] feat(TASK-049): add escalation.raised/answered, session.resumed, and agent.interrupted hook events - escalation.raised fires in the child ctx after queue submit; escalation.answered fires in the parent ctx from the taken request, so both agree on origin envs - escalation.* resolves global hooks ungated: the per-agent global_hooks whitelist never applies to these root-level events - session.resumed fires on the use_session resume leg, inverse of session.started - agent.interrupted replaces agent.completed/failed when the abort signal was ctrl-c'd: top-level bracket (now abort-aware), spawned-subagent result arm (agent__cancel / REPL ctrl-c), and graph agent nodes via the session signal; exit_agent and the shell-exit path stay non-interrupted by passing no signal - document the deliberate non-fire paths (direct prompts, escalation timeout and parent-drop, graph approval nodes) --- src/config/request_context.rs | 110 +++++++++++++++++++++---- src/function/agents.rs | 93 ++++++++++++++++++--- src/function/user_interaction.rs | 134 ++++++++++++++++++++++++++++++- src/graph/agent.rs | 102 ++++++++++++++++++++--- src/graph/user_interaction.rs | 4 + src/hooks.rs | 55 +++++++++++++ src/main.rs | 9 ++- src/repl/mod.rs | 2 +- 8 files changed, 468 insertions(+), 41 deletions(-) diff --git a/src/config/request_context.rs b/src/config/request_context.rs index 30e8486..7756496 100644 --- a/src/config/request_context.rs +++ b/src/config/request_context.rs @@ -4741,6 +4741,8 @@ impl RequestContext { self.init_agent_session_variables(new_session)?; if created_new_session { hooks::fire(HookEvent::SessionStarted, self, &[], None); + } else { + hooks::fire(HookEvent::SessionResumed, self, &[], None); } Ok(()) } @@ -4909,7 +4911,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(); @@ -4957,11 +4962,27 @@ 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; }; + // The abort signal, never the error text, decides interrupted: a + // ctrl-c'd run is not a failure, so no error env rides along. + 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, @@ -8340,7 +8361,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" )); @@ -8360,7 +8386,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(); @@ -8368,13 +8394,16 @@ 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) + }); 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 @@ -8383,16 +8412,29 @@ mod tests { Some("hook-fresh") ); - // Resuming a persisted session fires nothing. + // Resuming a persisted session fires session.resumed, never + // session.started. write_paused_todo_session(&ctx, "hook-resume"); 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") ); } @@ -8469,8 +8511,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 @@ -8519,7 +8561,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 @@ -8539,6 +8581,44 @@ 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_helpers_noop_without_agent() { @@ -8556,7 +8636,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(), @@ -8582,7 +8662,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!( diff --git a/src/function/agents.rs b/src/function/agents.rs index c3a22b6..10847f4 100644 --- a/src/function/agents.rs +++ b/src/function/agents.rs @@ -997,14 +997,15 @@ fn sync_agent_functions_to_ctx(ctx: &mut RequestContext) -> Result<()> { Ok(()) } -/// 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, @@ -1015,6 +1016,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() @@ -1024,14 +1026,20 @@ impl SpawnResultHooks { } } - /// Fires `agent.completed`, or `agent.failed` when `error` is given. - fn fire(self, error: Option<&str>) { + /// Fires `agent.completed`, or — for a failed child — `agent.interrupted` + /// when its abort signal was ctrl-c'd (REPL ctrl-c or `agent__cancel`), + /// else `agent.failed`. The signal, never the error text, decides: + /// cancellation is not a failure, so no error env rides along. + 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) @@ -1216,6 +1224,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(); @@ -1238,8 +1247,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, @@ -1771,6 +1780,21 @@ fn handle_reply_escalation(ctx: &mut RequestContext, args: &Value) -> Result Value { }) } +// The root agent prompting its own user is a direct interaction, not an +// escalation: no escalation event fires on this path. fn handle_direct(action: &str, args: &Value) -> Result { match action { "select" => handle_direct_ask(args), @@ -278,22 +281,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 +364,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 +398,112 @@ 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")); + } + #[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 d77066c..b2f22b9 100644 --- a/src/graph/agent.rs +++ b/src/graph/agent.rs @@ -50,16 +50,36 @@ 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, - ); + // The child's own abort signal dies inside `run`; the session + // signal is what a user interrupt fires (the executor bridges it + // onto the graph abort), so it is the only honest + // interrupted-vs-failed discriminator at this seam. + 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 +358,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_log_collector, warn_snapshot}; use serde_json::json; + use serial_test::serial; use std::collections::HashMap; use std::sync::Arc; @@ -416,6 +438,64 @@ 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" + ); + } + fn retryable_node(max_attempts: u32) -> AgentNode { let mut node = node_with("hi", None); node.max_attempts = max_attempts; diff --git a/src/graph/user_interaction.rs b/src/graph/user_interaction.rs index 9349e0c..85d1d22 100644 --- a/src/graph/user_interaction.rs +++ b/src/graph/user_interaction.rs @@ -21,6 +21,10 @@ impl ApprovalNodeExecutor { .interpolate(&node.question) .context("Failed to interpolate approval question")?; + // No escalation event fires at this seam: a root graph prompts its + // user directly (outside the escalation queue), and a child graph's + // escalation fires inside `handle_escalated` like any other user__* + // call. let response = handle_user_tool( ctx, &format!("{USER_FUNCTION_PREFIX}select"), diff --git a/src/hooks.rs b/src/hooks.rs index 608a244..8e3fe4b 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -119,6 +119,7 @@ pub enum HookEvent { TurnInterrupted, TurnFailed, SessionStarted, + SessionResumed, SessionEnded, SessionCompressed, ToolStarted, @@ -129,7 +130,10 @@ pub enum HookEvent { LlmRequestFailed, AgentStarted, AgentCompleted, + AgentInterrupted, AgentFailed, + EscalationRaised, + EscalationAnswered, GraphNodeStarted, GraphNodeCompleted, GraphNodeFailed, @@ -146,6 +150,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", @@ -156,7 +161,10 @@ 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", @@ -220,6 +228,17 @@ fn resolve_hooks( let event_name = event.as_str(); let mut resolved = Vec::new(); + // Escalations are a root/user-level concern: the user wires these hooks + // to hear about children asking for help, so requiring every agent to + // whitelist them through `global_hooks` would silence exactly the events + // they were set up for. The per-agent gate never applies here (this also + // skips the unknown-entry diagnostics: whitelisting an escalation hook is + // harmless and needs no warning). + 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 { @@ -1326,6 +1345,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"), @@ -1336,7 +1356,10 @@ 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"), @@ -1376,6 +1399,38 @@ 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")])); + + for event in [HookEvent::EscalationRaised, HookEvent::EscalationAnswered] { + let resolved = resolve_hooks(event, &global, Some((&[], "gated-agent")), None, None); + assert_eq!( + names(&resolved), + ["watch"], + "{} must resolve 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( diff --git a/src/main.rs b/src/main.rs index 39981e4..2c995e2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -587,7 +587,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; } @@ -621,7 +621,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 } @@ -755,7 +755,10 @@ 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); + // Executing the generated command is a normal exit: no + // abort signal on purpose, this seam can never report an + // interruption. + ctx.top_level_agent_finished(None, None); hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await; process::exit(code); } diff --git a/src/repl/mod.rs b/src/repl/mod.rs index 00e8edc..b0e85b6 100644 --- a/src/repl/mod.rs +++ b/src/repl/mod.rs @@ -533,7 +533,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 } From a3b71a6889b9c994d527531ad2de6a810d7b2955 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 19:55:52 -0600 Subject: [PATCH 09/29] feat(TASK-049): classify idle-prompt ctrl-c + ctrl-d as a completed exit Ctrl-C at the reedline prompt no longer latches the abort signal unconditionally: latch_prompt_interrupt only marks an interruption when the supervisor had live agents/jobs to cancel (new Supervisor::has_active_tasks), so the advertised exit path (ctrl-c at an idle prompt, then ctrl-d) fires agent.completed at teardown instead of agent.interrupted. Mid-turn ctrl-c still latches directly via wait_user_interrupt/poll_abort_signal, preserving the interrupted-then-exit classification. Also: probe-lane tests for escalation.answered single-fire, escalation question env truncation, agent.failed inverse gate, and hooks probe Scenario I (session.started vs session.resumed); comment notes on escalation.answered semantics and the agent__cancel blind spot. --- scripts/usage-probe-hooks.sh | 37 +++++++++++++++ src/config/request_context.rs | 44 ++++++++++++++++++ src/function/agents.rs | 79 +++++++++++++++++++++++++++++++- src/function/user_interaction.rs | 48 +++++++++++++++++++ src/graph/agent.rs | 37 ++++++++++++++- src/repl/mod.rs | 78 ++++++++++++++++++++++++++++++- src/supervisor/mod.rs | 50 ++++++++++++++++++++ 7 files changed, 369 insertions(+), 4 deletions(-) diff --git a/scripts/usage-probe-hooks.sh b/scripts/usage-probe-hooks.sh index 2cd99be..90326e1 100755 --- a/scripts/usage-probe-hooks.sh +++ b/scripts/usage-probe-hooks.sh @@ -25,6 +25,10 @@ # path refused) and notify.sh's no-notifier/no-tty fallback strips # ANSI/OSC control bytes; the hooks-dir .builtin-manifest is never # installed executable. +# I) `--session ` discriminates the session events end-to-end: a +# fresh named session fires exactly one session.started (and no +# session.resumed); resuming a persisted session fires exactly one +# session.resumed (and no session.started). # # 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 @@ -458,5 +462,38 @@ grep -Fq '[coyote] turn.completed tool=evil]0;own[31mred' "$NOTIFY_OUT" \ || fail "ANSI/OSC escape bytes leaked through the notify.sh tty fallback" echo "PASS: installed hook scripts honor XDG default, 0600, UTC timestamps, symlink refusal, secret filter, and ANSI-stripped notify fallback" +echo "== Scenario I: session.started vs session.resumed via --session ==" +CFG_I="$WORKDIR/i" +mkdir -p "$CFG_I" +SESS_LOG="$WORKDIR/i-sess.log" +write_dryrun_config "$CFG_I" "hooks:" \ + " session.started:" \ + " - name: probe-sess" \ + " command: \"echo STARTED >> $SESS_LOG\"" \ + " session.resumed:" \ + " - name: probe-sess" \ + " command: \"echo RESUMED >> $SESS_LOG\"" + +# A fresh named session (no file on disk yet) fires session.started only. +COYOTE_CONFIG_DIR="$CFG_I" "$BIN" --session probe-fresh --no-stream "say exactly: hi" > /dev/null +wait_for "session.started log" test -s "$SESS_LOG" +[ "$(grep -c '^STARTED$' "$SESS_LOG" 2>/dev/null || echo 0)" = "1" ] \ + || fail "expected exactly one session.started for a fresh session, got: $(cat "$SESS_LOG")" +grep -q '^RESUMED$' "$SESS_LOG" \ + && fail "a fresh named session must not fire session.resumed: $(cat "$SESS_LOG")" + +# A persisted session on disk: resuming it fires session.resumed, and never +# session.started (the inverse gate). +mkdir -p "$CFG_I/sessions" +printf 'model: dryrun:dry-model\nmessages: []\n' > "$CFG_I/sessions/probe-resume.yaml" +COYOTE_CONFIG_DIR="$CFG_I" "$BIN" --session probe-resume --no-stream "say exactly: hi" > /dev/null +scenario_i_resumed() { grep -q '^RESUMED$' "$SESS_LOG"; } +wait_for "session.resumed log" scenario_i_resumed +[ "$(grep -c '^RESUMED$' "$SESS_LOG" 2>/dev/null || echo 0)" = "1" ] \ + || fail "expected exactly one session.resumed for a resumed session, got: $(cat "$SESS_LOG")" +[ "$(grep -c '^STARTED$' "$SESS_LOG" 2>/dev/null || echo 0)" = "1" ] \ + || fail "resuming a session must not fire session.started: $(cat "$SESS_LOG")" +echo "PASS: fresh session fires session.started only; resume fires session.resumed only" + echo echo "ALL SCENARIOS PASSED" diff --git a/src/config/request_context.rs b/src/config/request_context.rs index 7756496..b7566e2 100644 --- a/src/config/request_context.rs +++ b/src/config/request_context.rs @@ -8619,6 +8619,50 @@ mod tests { })); } + /// 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() { diff --git a/src/function/agents.rs b/src/function/agents.rs index 10847f4..4ff0bc6 100644 --- a/src/function/agents.rs +++ b/src/function/agents.rs @@ -1782,7 +1782,11 @@ fn handle_reply_escalation(ctx: &mut RequestContext, args: &Value) -> Result 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 = crate::hooks::HooksMap::default(); + hooks_map.insert( + "escalation.answered".to_string(), + vec![crate::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/user_interaction.rs b/src/function/user_interaction.rs index c22fc8c..6281fd1 100644 --- a/src/function/user_interaction.rs +++ b/src/function/user_interaction.rs @@ -504,6 +504,54 @@ mod tests { 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 b2f22b9..39cdb7e 100644 --- a/src/graph/agent.rs +++ b/src/graph/agent.rs @@ -53,7 +53,11 @@ impl AgentNodeExecutor { // The child's own abort signal dies inside `run`; the session // signal is what a user interrupt fires (the executor bridges it // onto the graph abort), so it is the only honest - // interrupted-vs-failed discriminator at this seam. + // interrupted-vs-failed discriminator at this seam. Blind spot: + // agent__cancel sets the child handle's signal, which never + // reaches parent_ctx.session_abort, so a node failing during + // such a cancellation stays agent.failed while the outer bracket + // reports agent.interrupted (narrow window, accepted). if parent_ctx .session_abort .as_ref() @@ -496,6 +500,37 @@ mod tests { ); } + /// 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; diff --git a/src/repl/mod.rs b/src/repl/mod.rs index b0e85b6..69a0ec3 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, @@ -512,9 +513,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"); } @@ -1715,6 +1715,21 @@ fn reset_continuation(ctx: &mut RequestContext) { ctx.reset_continuation_count(); } +/// Ctrl-C at the prompt: cancel background work, but only latch the abort +/// signal when there was live work to cancel. An idle-prompt ctrl-c is a +/// step on the advertised exit path ("To exit, press Ctrl+D"), not an +/// interruption, so teardown after the following ctrl-d classifies the run +/// as completed. Mid-turn ctrl-c never passes through here -- it latches +/// the signal directly (wait_user_interrupt / poll_abort_signal), so an +/// interrupted turn followed by ctrl-d still reports agent.interrupted. +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() { @@ -2898,4 +2913,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/supervisor/mod.rs b/src/supervisor/mod.rs index 044635c..f80eb4e 100644 --- a/src/supervisor/mod.rs +++ b/src/supervisor/mod.rs @@ -190,6 +190,12 @@ impl Supervisor { .count() } + /// True when any registered agent or job is still running -- i.e. a + /// cancellation right now would actually interrupt in-flight work. + pub fn has_active_tasks(&self) -> bool { + self.effective_active_count() > 0 || self.active_job_count() > 0 + } + pub fn max_concurrent(&self) -> usize { self.max_concurrent } @@ -449,6 +455,50 @@ 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 supervisor_register_rejects_exceeding_depth() { let mut sup = Supervisor::new(4, 2); From c73d5317a08021f539c59d898d597594b29e3f42 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 20:31:36 -0600 Subject: [PATCH 10/29] feat(TASK-049): harden hook dispatch against NUL env values and nested-supervisor latch misses - strip interior NUL bytes from hook env values in fire_resolved so agent-authored text (e.g. escalation questions) cannot suppress a hook via a failed spawn; raise the spawn-failure log to warn! - make Supervisor::has_active_tasks recurse into child supervisors, mirroring cancel_recursive, so a prompt ctrl-c that cancels only a grandchild still latches as an interruption - document the signal-first vs error-first classification asymmetry at top_level_agent_finished / SpawnResultHooks::fire and the deliberate REPL teardown cancel; pin ctrlc-with-no-error => agent.interrupted - probe Scenario J: pty-driven ctrl-c during a live headless --agent run fires exactly one agent.interrupted with no COYOTE_AGENT_ERROR --- scripts/usage-probe-hooks.sh | 154 ++++++++++++++++++++++++++++++++++ src/config/request_context.rs | 45 +++++++++- src/function/agents.rs | 5 +- src/hooks.rs | 60 +++++++++++-- src/repl/mod.rs | 3 + src/supervisor/mod.rs | 47 ++++++++++- 6 files changed, 305 insertions(+), 9 deletions(-) diff --git a/scripts/usage-probe-hooks.sh b/scripts/usage-probe-hooks.sh index 90326e1..2943153 100755 --- a/scripts/usage-probe-hooks.sh +++ b/scripts/usage-probe-hooks.sh @@ -29,6 +29,10 @@ # fresh named session fires exactly one session.started (and no # session.resumed); resuming a persisted session fires exactly one # session.resumed (and no session.started). +# J) pressing ctrl-c in a real terminal during a live headless --agent run +# fires exactly one agent.interrupted with NO COYOTE_AGENT_ERROR, and +# never agent.failed or agent.completed (needs python3 for the pty and +# the hanging LLM endpoint; skipped when python3 is unavailable). # # 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 @@ -495,5 +499,155 @@ wait_for "session.resumed log" scenario_i_resumed || fail "resuming a session must not fire session.started: $(cat "$SESS_LOG")" echo "PASS: fresh session fires session.started only; resume fires session.resumed only" +echo "== Scenario J: terminal ctrl-c during a live headless --agent run fires agent.interrupted ==" +if ! command -v python3 > /dev/null 2>&1; then + echo "SKIP: python3 not available; cannot allocate a pty or hanging LLM endpoint" >&2 +else + CFG_J="$WORKDIR/j" + mkdir -p "$CFG_J/agents/probe-int" + INT_LOG="$WORKDIR/j-int.log" + + # A local "LLM endpoint" that accepts connections and never answers keeps + # the agent run in flight (spinner active, its ctrl-c watcher polling) + # while the probe delivers ^C. It writes its port to $1 once listening and + # touches $2 on the first accepted connection, so the runner below can + # gate the ^C on the request being demonstrably in flight. + python3 - "$WORKDIR/j-port" "$WORKDIR/j-conn" <<'PY' & +import socket, sys +s = socket.socket() +s.bind(("127.0.0.1", 0)) +s.listen(8) +open(sys.argv[1], "w").write(str(s.getsockname()[1])) +conns = [] +while True: + c, _ = s.accept() + if not conns: + open(sys.argv[2], "w").write("1") + conns.append(c) +PY + SRV_J=$! + trap 'kill "$SRV_J" 2>/dev/null || true; rm -rf "$WORKDIR"' EXIT + wait_for "hang-server port" test -s "$WORKDIR/j-port" + PORT_J="$(cat "$WORKDIR/j-port")" + + cat > "$CFG_J/config.yaml" < "$CFG_J/agents/probe-int/config.yaml" <> $INT_LOG" + agent.completed: + - name: mark + command: "echo COMPLETED >> $INT_LOG" + agent.failed: + - name: mark + command: "echo \"FAILED err=\${COYOTE_AGENT_ERROR:-unset}\" >> $INT_LOG" + agent.interrupted: + - name: mark + command: "echo \"INTERRUPTED err=\${COYOTE_AGENT_ERROR:-unset}\" >> $INT_LOG" +EOF + + # Spawn coyote on a real pty (the spinner's ctrl-c watcher only runs on a + # terminal), wait until the agent.started marker AND the first accepted + # LLM connection prove the run is in flight, then send a literal ^C + # through the pty line discipline — exactly what a terminal user does. + cat > "$WORKDIR/j-pty.py" <<'PY' +import os, pty, sys, time + +int_log, conn_marker = sys.argv[1], sys.argv[2] +argv = sys.argv[3:] + +pid, master = pty.fork() +if pid == 0: + os.execvp(argv[0], argv) + +os.set_blocking(master, False) + +def drain(): + try: + while os.read(master, 4096): + pass + except OSError: + pass + +def marker_seen(): + try: + with open(int_log) as f: + return "STARTED" in f.read() + except FileNotFoundError: + return False + +deadline = time.time() + 15 +while time.time() < deadline: + drain() + if marker_seen() and os.path.exists(conn_marker): + break + time.sleep(0.1) +else: + print("TIMEOUT: agent.started marker / LLM connection never appeared", flush=True) + os.kill(pid, 9) + os.waitpid(pid, 0) + sys.exit(3) + +os.write(master, b"\x03") # ^C via the pty line discipline + +deadline = time.time() + 15 +while time.time() < deadline: + drain() + got, status = os.waitpid(pid, os.WNOHANG) + if got == pid: + if os.WIFSIGNALED(status): + print(f"FATAL: coyote died on signal {os.WTERMSIG(status)} — no ctrl-c handler was installed", flush=True) + sys.exit(4) + sys.exit(0) + time.sleep(0.1) + +print("TIMEOUT: coyote did not exit within 15s of ^C", flush=True) +os.kill(pid, 9) +os.waitpid(pid, 0) +sys.exit(3) +PY + + COYOTE_CONFIG_DIR="$CFG_J" python3 "$WORKDIR/j-pty.py" "$INT_LOG" "$WORKDIR/j-conn" \ + "$BIN" --agent probe-int --no-stream "say exactly: hi" \ + || fail "pty ctrl-c run did not exit cleanly (see message above)" + + scenario_j_interrupted() { grep -qs '^INTERRUPTED err=unset$' "$INT_LOG"; } + wait_for "agent.interrupted log" scenario_j_interrupted + [ "$(grep -c '^STARTED$' "$INT_LOG" 2>/dev/null || echo 0)" = "1" ] \ + || fail "expected exactly one agent.started, got: $(cat "$INT_LOG")" + [ "$(grep -c '^INTERRUPTED' "$INT_LOG" 2>/dev/null || echo 0)" = "1" ] \ + || fail "expected exactly one agent.interrupted, got: $(cat "$INT_LOG")" + grep -q '^FAILED' "$INT_LOG" \ + && fail "ctrl-c must fire agent.interrupted, never agent.failed: $(cat "$INT_LOG")" + grep -q '^COMPLETED' "$INT_LOG" \ + && fail "an interrupted run must not fire agent.completed: $(cat "$INT_LOG")" + kill "$SRV_J" 2>/dev/null || true + echo "PASS: terminal ctrl-c fires exactly one agent.interrupted with no COYOTE_AGENT_ERROR; never failed/completed" +fi + echo echo "ALL SCENARIOS PASSED" diff --git a/src/config/request_context.rs b/src/config/request_context.rs index b7566e2..7daf797 100644 --- a/src/config/request_context.rs +++ b/src/config/request_context.rs @@ -4972,7 +4972,14 @@ impl RequestContext { }; // The abort signal, never the error text, decides interrupted: a - // ctrl-c'd run is not a failure, so no error env rides along. + // ctrl-c'd run is not a failure, so no error env rides along. The + // gate is deliberately signal-first -- a latched ctrl-c means + // interrupted even with no error in hand -- unlike + // SpawnResultHooks::fire, which is error-first: a child that finished + // cleanly stays agent.completed under a latched signal because its + // output is collectible. Blind spot: a mid-stream ctrl-d aborts live + // work via poll_abort_signal yet still classifies as agent.completed, + // because ctrl-d always means a deliberate exit (accepted). if abort_signal.is_some_and(|signal| signal.aborted_ctrlc()) { hooks::fire( HookEvent::AgentInterrupted, @@ -8619,6 +8626,42 @@ mod tests { })); } + #[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 diff --git a/src/function/agents.rs b/src/function/agents.rs index 4ff0bc6..ac19b3a 100644 --- a/src/function/agents.rs +++ b/src/function/agents.rs @@ -1029,7 +1029,10 @@ impl SpawnResultHooks { /// Fires `agent.completed`, or — for a failed child — `agent.interrupted` /// when its abort signal was ctrl-c'd (REPL ctrl-c or `agent__cancel`), /// else `agent.failed`. The signal, never the error text, decides: - /// cancellation is not a failure, so no error env rides along. + /// cancellation is not a failure, so no error env rides along. Unlike the + /// signal-first `top_level_agent_finished` gate, completion wins over a + /// latched ctrl-c at this seam — a finished child's output is collectible, + /// so the asymmetry is intentional. fn fire(self, error: Option<&str>, child_abort: &AbortSignal) { let mut extras = vec![ ("COYOTE_AGENT_ID", self.agent_id), diff --git a/src/hooks.rs b/src/hooks.rs index 8e3fe4b..ea47f66 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -348,6 +348,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); @@ -629,7 +635,7 @@ async fn run_hook( let _ = child.wait().await; } Err(err) => { - debug!( + warn!( "Failed to spawn hook '{}' in '{}': {err}", hook.full_name, hook.cwd.display() @@ -1665,6 +1671,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() { @@ -2020,7 +2062,7 @@ mod tests { // does the empty directory below demonstrate the orphan unlink // rather than a payload file that never existed. wait_for("spawn failure log", || { - crate::testing::debug_snapshot().iter().any(|message| { + crate::testing::warn_snapshot().iter().any(|message| { message.contains("Failed to spawn hook 'tool.started.spawnfail-marker-c9d'") }) }) @@ -2075,7 +2117,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] #[serial] - async fn error_paths_log_debug_only() { + async fn empty_command_logs_debug_and_spawn_failure_logs_warn() { crate::testing::install_log_collector(); let empty_cwd = env::temp_dir(); let hooks = vec![ @@ -2103,12 +2145,18 @@ mod tests { && message.contains("empty command") && message.contains(empty_cwd_display.as_str()) })); - assert!(debugs.iter().any(|message| { + // A failed spawn silently suppresses the hook, so it must be + // loud enough to notice; a skipped empty command stays debug. + let warns = crate::testing::warn_snapshot(); + assert!(warns.iter().any(|message| { message.contains("Failed to spawn hook 'turn.failed.badcwd-marker-f5b'") && message.contains("/nonexistent/coyote-badcwd-marker-f5b") })); - let warns = crate::testing::warn_snapshot(); - assert!(warns.iter().all(|message| !message.contains("marker-f5b"))); + assert!( + warns + .iter() + .all(|message| !message.contains("empty-marker-f5b")) + ); } #[tokio::test(flavor = "multi_thread")] diff --git a/src/repl/mod.rs b/src/repl/mod.rs index 69a0ec3..218962d 100644 --- a/src/repl/mod.rs +++ b/src/repl/mod.rs @@ -527,6 +527,9 @@ Type ".help" for additional help. } if let Some(supervisor) = self.ctx.read().supervisor.clone() { + // Leaving the loop is a deliberate exit, never an interruption: + // cancelling straggler children here does not latch the abort + // signal, so teardown still classifies as agent.completed. supervisor.read().cancel_recursive(); } diff --git a/src/supervisor/mod.rs b/src/supervisor/mod.rs index f80eb4e..7028637 100644 --- a/src/supervisor/mod.rs +++ b/src/supervisor/mod.rs @@ -192,8 +192,18 @@ impl Supervisor { /// True when any registered agent or job is still running -- i.e. a /// cancellation right now would actually interrupt in-flight work. + /// Mirrors `cancel_recursive`'s traversal: work buried in a nested child + /// supervisor still counts, so a prompt ctrl-c that cancels only a + /// grandchild latches like any other interruption. pub fn has_active_tasks(&self) -> bool { - self.effective_active_count() > 0 || self.active_job_count() > 0 + 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 { @@ -499,6 +509,41 @@ mod tests { 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); From 6ae093a482a53acb6f6ec0d6d9a6dc33c258c1dc Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 20:58:24 -0600 Subject: [PATCH 11/29] fix(TASK-049): cover role and agent scopes in escalation bypass test; keep spawn-failure logging at debug --- src/hooks.rs | 40 +++++++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/src/hooks.rs b/src/hooks.rs index ea47f66..bd96b27 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -635,7 +635,7 @@ async fn run_hook( let _ = child.wait().await; } Err(err) => { - warn!( + debug!( "Failed to spawn hook '{}' in '{}': {err}", hook.full_name, hook.cwd.display() @@ -1413,13 +1413,29 @@ mod tests { &[("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")), None, None); + let resolved = resolve_hooks( + event, + &global, + Some((&[], "gated-agent")), + Some(&role), + Some((&agent, "gated-agent")), + ); assert_eq!( names(&resolved), - ["watch"], - "{} must resolve without a whitelist entry", + ["watch", "role-watch", "own-watch"], + "{} must resolve global, role, and agent hooks without a whitelist entry", event.as_str() ); } @@ -2062,7 +2078,7 @@ mod tests { // does the empty directory below demonstrate the orphan unlink // rather than a payload file that never existed. wait_for("spawn failure log", || { - crate::testing::warn_snapshot().iter().any(|message| { + crate::testing::debug_snapshot().iter().any(|message| { message.contains("Failed to spawn hook 'tool.started.spawnfail-marker-c9d'") }) }) @@ -2117,7 +2133,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] #[serial] - async fn empty_command_logs_debug_and_spawn_failure_logs_warn() { + async fn error_paths_log_debug_only() { crate::testing::install_log_collector(); let empty_cwd = env::temp_dir(); let hooks = vec![ @@ -2145,18 +2161,12 @@ mod tests { && message.contains("empty command") && message.contains(empty_cwd_display.as_str()) })); - // A failed spawn silently suppresses the hook, so it must be - // loud enough to notice; a skipped empty command stays debug. - let warns = crate::testing::warn_snapshot(); - assert!(warns.iter().any(|message| { + assert!(debugs.iter().any(|message| { message.contains("Failed to spawn hook 'turn.failed.badcwd-marker-f5b'") && message.contains("/nonexistent/coyote-badcwd-marker-f5b") })); - assert!( - warns - .iter() - .all(|message| !message.contains("empty-marker-f5b")) - ); + let warns = crate::testing::warn_snapshot(); + assert!(warns.iter().all(|message| !message.contains("marker-f5b"))); } #[tokio::test(flavor = "multi_thread")] From 90f7631b35314cea3db0aa113fa6ebcaaada6bb5 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 21:23:04 -0600 Subject: [PATCH 12/29] feat(TASK-050): add wildcard grammar to the global-hooks whitelist Whitelist entries now admit hooks via .* (every hook of that event), *. (that hook name on every event), and * / *.* (everything), alongside the unchanged exact . form. Partial globs like tool.st* are not wildcards: they admit nothing and still land in the unknown-entry debug diagnostics, while .* entries are exempt from them. Escalation events keep resolving ungated regardless of whitelist content. --- src/hooks.rs | 196 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 194 insertions(+), 2 deletions(-) diff --git a/src/hooks.rs b/src/hooks.rs index bd96b27..be82157 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -245,7 +245,9 @@ fn resolve_hooks( 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 { @@ -262,7 +264,13 @@ fn resolve_hooks( { let prefix = format!("{event_name}."); for entry in gate { - if let Some(name) = entry.strip_prefix(&prefix) { + // `.*` is a valid wildcard, not a typo; the other wildcard + // forms (`*`, `*.*`, `*.`) never match the event prefix and + // skip this check naturally. A partial glob like `tool.st*` is + // outside the grammar and stays flagged. + if let Some(name) = entry.strip_prefix(&prefix) + && name != "*" + { let known = global_hooks .get(event_name) .is_some_and(|defs| defs.iter().any(|def| def.name == name)); @@ -293,6 +301,22 @@ fn resolve_hooks( resolved } +/// Whitelist grammar for a `global_hooks` entry: `.` exact, +/// `.*` (every hook of that event), `*.` (that hook name on +/// every event), and `*` / `*.*` (everything). Event names contain dots +/// while hook names never do, so `*.` compares everything after the +/// leading `*.` against the hook name alone. Anything else containing `*` +/// (a partial glob like `tool.st*`) is not a wildcard and admits nothing. +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 push_defs(event_name: &str, defs: &[HookDef], cwd: &Path, out: &mut Vec) { for def in defs { out.push(ResolvedHook { @@ -1477,6 +1501,174 @@ 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() { + crate::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 = crate::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() { + crate::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 = crate::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 unknown_whitelist_entries_log_debug_and_stay_inert() { crate::testing::install_log_collector(); From dee89d4ca3c672b5f5c53e448e33af562225e111 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 21:23:04 -0600 Subject: [PATCH 13/29] feat(TASK-050): bracket --macro runs like --execute and close the isolated-macro leak The --macro dispatch arm now opens the top-level agent bracket before macro_execute and closes it signal-aware afterwards, draining pending hooks like the --execute arm; both bracket calls no-op without a loaded agent, so agent-less macro runs are unaffected. The isolated arm of macro_execute closes the bracket an in-macro .agent step opens on its fresh context, on the step-failure path too. Binary-level tests pin agent.started plus exactly one terminal event for the --agent flag and for in-macro .agent steps in both isolation modes, on success and failure. --- src/config/macros.rs | 20 +++- src/main.rs | 20 ++-- tests/macro_bracketing.rs | 221 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 249 insertions(+), 12 deletions(-) create mode 100644 tests/macro_bracketing.rs diff --git a/src/config/macros.rs b/src/config/macros.rs index 1da3b9d..ebee6a9 100644 --- a/src/config/macros.rs +++ b/src/config/macros.rs @@ -83,12 +83,22 @@ 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?; + // An in-macro `.agent` step opens the top-level agent bracket on this + // fresh context, so it must also close here — on the step-failure path + // too, or the run would leak `agent.started` with no terminal event. + // Signal-aware like the headless dispatch arms: a ctrl-c'd macro + // classifies as interrupted, not failed. + 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> { diff --git a/src/main.rs b/src/main.rs index 2c995e2..50d797c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -438,11 +438,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 { @@ -575,8 +576,13 @@ async fn run( } } if let Some(name) = &cli.macro_name { - macro_execute(&mut ctx, name, text.as_deref(), abort_signal.clone()).await?; - return Ok(()); + // Dispatch committed: open the top-level agent bracket only now, + // past every inspection-flag early return above. + 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, diff --git a/tests/macro_bracketing.rs b/tests/macro_bracketing.rs new file mode 100644 index 0000000..7c5594c --- /dev/null +++ b/tests/macro_bracketing.rs @@ -0,0 +1,221 @@ +//! 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}; + +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 +} + +/// Lays out a config dir with a dry_run model, global agent.* hooks that +/// append one marker line per event to `log`, a `probe-macro` agent that +/// whitelists everything, and a `probe` macro with the given YAML body. +fn write_fixture(dir: &Path, log: &Path, macro_yaml: &str) { + let log = log.display(); + // 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(); + 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: \"echo STARTED >> '{log}'\"\n\ + \x20 agent.completed:\n\ + \x20 - name: mark\n\ + \x20 command: \"echo COMPLETED >> '{log}'\"\n\ + \x20 agent.failed:\n\ + \x20 - name: mark\n\ + \x20 command: \"echo FAILED >> '{log}'\"\n\ + \x20 agent.interrupted:\n\ + \x20 - name: mark\n\ + \x20 command: \"echo INTERRUPTED >> '{log}'\"\n", + vault_pass = vault_pass.display() + ), + ) + .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() +} + +/// Bounded poll for the expected terminal marker. The binary drains pending +/// hooks before exiting, so the log is normally complete once `output()` +/// returns; the poll only absorbs filesystem latency, never paces the run. +fn wait_for_marker(log: &Path, needle: &str) -> String { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let contents = fs::read_to_string(log).unwrap_or_default(); + if contents.lines().any(|line| line == needle) { + return contents; + } + assert!( + Instant::now() < deadline, + "timed out waiting for '{needle}'; log so far: {contents:?}" + ); + thread::sleep(Duration::from_millis(50)); + } +} + +fn assert_marker_count(contents: &str, marker: &str, expected: usize) { + let count = contents.lines().filter(|line| *line == marker).count(); + assert_eq!( + count, expected, + "expected {expected}x {marker}: {contents:?}" + ); +} + +fn assert_started_and_single_terminal(contents: &str, terminal: &str) { + assert_marker_count(contents, "STARTED", 1); + for marker in ["COMPLETED", "FAILED", "INTERRUPTED"] { + let expected = usize::from(marker == terminal); + assert_marker_count(contents, marker, expected); + } +} + +fn probe_macro_run(label: &str, macro_yaml: &str, args: &[&str], expect_success: bool) -> String { + let dir = fresh_config_dir(label); + let log = dir.join("agent-events.log"); + write_fixture(&dir, &log, 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" + }; + let contents = wait_for_marker(&log, terminal); + let _ = fs::remove_dir_all(&dir); + assert_started_and_single_terminal(&contents, terminal); + contents +} + +#[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, + ); +} From 4b2428b33497197b658d46da4af5126cdfe9ec7e Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 21:42:15 -0600 Subject: [PATCH 14/29] test(TASK-050): probe wildcard whitelist grammar and macro-without-agent inertness Scenario K pins the global_hooks wildcard forms (exact, .*, *., *.*) and partial-glob inertness against a real binary; Scenario L proves --macro without --agent fires zero agent.* events. --- scripts/usage-probe-hooks.sh | 112 +++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/scripts/usage-probe-hooks.sh b/scripts/usage-probe-hooks.sh index 2943153..44916ad 100755 --- a/scripts/usage-probe-hooks.sh +++ b/scripts/usage-probe-hooks.sh @@ -649,5 +649,117 @@ PY echo "PASS: terminal ctrl-c fires exactly one agent.interrupted with no COYOTE_AGENT_ERROR; never failed/completed" fi +echo "== Scenario K: global_hooks wildcard grammar (exact, .*, *., *.*, invalid globs) ==" +# Global hooks: agent.started has one hook named 'mark'; agent.completed has +# two hooks named 'mark' and 'other'. Each sub-case runs the same +# `--agent wc-probe --macro probe` recipe in its own config dir with a +# different global_hooks whitelist, then asserts exactly which markers were +# admitted. The binary drains pending hooks before exiting, so post-exit +# waits only absorb filesystem latency. +k_run() { + # $1 = label, $2 = marker log, $3.. = global_hooks entries (verbatim) + local label="$1" klog="$2" + shift 2 + local kcfg="$WORKDIR/k-$label" + mkdir -p "$kcfg/agents/wc-probe" "$kcfg/macros" + write_dryrun_config "$kcfg" "hooks:" \ + " agent.started:" \ + " - name: mark" \ + " command: \"echo S-mark >> $klog\"" \ + " agent.completed:" \ + " - name: mark" \ + " command: \"echo C-mark >> $klog\"" \ + " - name: other" \ + " command: \"echo C-other >> $klog\"" + { + printf 'name: wc-probe\n' + printf 'description: wildcard whitelist probe agent\n' + printf 'version: 0.1.0\n' + printf 'instructions: |\n' + printf ' You are a probe agent. Reply briefly.\n' + printf 'global_hooks:\n' + local entry + for entry in "$@"; do + printf ' - "%s"\n' "$entry" + done + } > "$kcfg/agents/wc-probe/config.yaml" + printf 'steps:\n - ".set temperature 0.5"\n' > "$kcfg/macros/probe.yaml" + COYOTE_CONFIG_DIR="$kcfg" "$BIN" --agent wc-probe --macro probe > /dev/null 2>&1 \ + || fail "scenario K ($label): --agent wc-probe --macro probe exited nonzero" +} + +marker_count_is() { # $1 = log, $2 = marker, $3 = expected count + local n + n="$(grep -c -x -- "$2" "$1" 2>/dev/null)" || n=0 + [ "${n:-0}" = "$3" ] +} + +# K1: exact . admits only that hook on that event (unchanged). +KLOG1="$WORKDIR/k1.log" +k_run exact "$KLOG1" "agent.completed.mark" +wait_for "K1 C-mark" marker_count_is "$KLOG1" C-mark 1 +marker_count_is "$KLOG1" S-mark 0 || fail "K1: exact entry leaked agent.started: $(cat "$KLOG1")" +marker_count_is "$KLOG1" C-other 0 || fail "K1: exact entry leaked sibling hook 'other': $(cat "$KLOG1")" + +# K2: .* admits every hook of that event, nothing else. +KLOG2="$WORKDIR/k2.log" +k_run event-star "$KLOG2" "agent.completed.*" +wait_for "K2 C-mark" marker_count_is "$KLOG2" C-mark 1 +wait_for "K2 C-other" marker_count_is "$KLOG2" C-other 1 +marker_count_is "$KLOG2" S-mark 0 || fail "K2: agent.completed.* leaked agent.started: $(cat "$KLOG2")" + +# K3: *. admits that hook name on every event, nothing else. +KLOG3="$WORKDIR/k3.log" +k_run star-name "$KLOG3" "*.mark" +wait_for "K3 S-mark" marker_count_is "$KLOG3" S-mark 1 +wait_for "K3 C-mark" marker_count_is "$KLOG3" C-mark 1 +marker_count_is "$KLOG3" C-other 0 || fail "K3: *.mark leaked hook 'other': $(cat "$KLOG3")" + +# K4: *.* admits everything (the bare * form is pinned by tests/macro_bracketing.rs). +KLOG4="$WORKDIR/k4.log" +k_run star-star "$KLOG4" "*.*" +wait_for "K4 S-mark" marker_count_is "$KLOG4" S-mark 1 +wait_for "K4 C-mark" marker_count_is "$KLOG4" C-mark 1 +wait_for "K4 C-other" marker_count_is "$KLOG4" C-other 1 + +# K5: partial/inner globs are NOT wildcards — they admit nothing, and the run +# is otherwise unaffected (exit code checked inside k_run). The exact +# agent.started.mark entry is the positive signal bounding the absence checks. +KLOG5="$WORKDIR/k5.log" +k_run invalid-globs "$KLOG5" "agent.completed.ma*" "agent.*" "ag*.completed.mark" "agent.started.mark" +wait_for "K5 S-mark" marker_count_is "$KLOG5" S-mark 1 +marker_count_is "$KLOG5" C-mark 0 || fail "K5: a partial glob admitted agent.completed.mark: $(cat "$KLOG5")" +marker_count_is "$KLOG5" C-other 0 || fail "K5: a partial glob admitted agent.completed.other: $(cat "$KLOG5")" +echo "PASS: wildcard whitelist grammar admits exactly the spec'd sets; partial globs stay inert" + +echo "== Scenario L: --macro without --agent fires zero agent.* events ==" +CFG_L="$WORKDIR/l" +mkdir -p "$CFG_L/macros" +L_AGENT_LOG="$WORKDIR/l-agent-events.log" +L_SENTINEL_LOG="$WORKDIR/l-sentinel.log" +write_dryrun_config "$CFG_L" "hooks:" \ + " agent.started:" \ + " - name: mark" \ + " command: \"echo STARTED >> $L_AGENT_LOG\"" \ + " agent.completed:" \ + " - name: mark" \ + " command: \"echo COMPLETED >> $L_AGENT_LOG\"" \ + " agent.failed:" \ + " - name: mark" \ + " command: \"echo FAILED >> $L_AGENT_LOG\"" \ + " turn.completed:" \ + " - name: sentinel" \ + " command: \"echo SENTINEL >> $L_SENTINEL_LOG\"" +printf 'steps:\n - ".set temperature 0.5"\n' > "$CFG_L/macros/probe.yaml" +COYOTE_CONFIG_DIR="$CFG_L" "$BIN" --macro probe > /dev/null 2>&1 \ + || fail "scenario L: --macro without --agent exited nonzero" +# Proving absence needs a positive signal to wait on: a follow-up plain run +# fires turn.completed, and its sentinel landing bounds how long the macro +# run's (nonexistent) agent.* children could have taken to write. +COYOTE_CONFIG_DIR="$CFG_L" "$BIN" --no-stream "say exactly: hi" > /dev/null +wait_for "scenario L sentinel" test -s "$L_SENTINEL_LOG" +[ -s "$L_AGENT_LOG" ] && fail "expected zero agent.* events from --macro without --agent, got: $(cat "$L_AGENT_LOG")" +echo "PASS: --macro without --agent fired zero agent.* hook events and exited cleanly" + echo echo "ALL SCENARIOS PASSED" From f7d0f93fa14694994874ba9d2ccbd485780ee0ac Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 21:54:25 -0600 Subject: [PATCH 15/29] fix(TASK-050): flag invalid whitelist globs that extend no event name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unknown-entry diagnostic only inspected gate entries prefixed by the current event's full name, so a partial glob like 'tool.st*' — which extends no event name at all — was silently inert on every event. Entries containing '*' now validate against the wildcard grammar (*, *.*, *., .* over a real event) and anything outside it gets the same debug-level diagnostic; exact entries keep the existing event-prefix path unchanged. --- src/hooks.rs | 123 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 116 insertions(+), 7 deletions(-) diff --git a/src/hooks.rs b/src/hooks.rs index be82157..0af7501 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -173,6 +173,38 @@ impl HookEvent { HookEvent::JobFailed => "job.failed", } } + + /// Every event, in declaration order. Dotted names stay single-sourced + /// through `as_str`; this exists so whitelist-glob validation can tell a + /// real `.*` from a typo over a nonexistent event. + const ALL: [HookEvent; 26] = [ + 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, + ]; } /// An owned snapshot of one hook to run. Detached dispatch tasks, job @@ -264,13 +296,21 @@ fn resolve_hooks( { let prefix = format!("{event_name}."); for entry in gate { - // `.*` is a valid wildcard, not a typo; the other wildcard - // forms (`*`, `*.*`, `*.`) never match the event prefix and - // skip this check naturally. A partial glob like `tool.st*` is - // outside the grammar and stays flagged. - if let Some(name) = entry.strip_prefix(&prefix) - && name != "*" - { + // Entries containing `*` are grammatical only in the wildcard + // forms `*`, `*.*`, `*.`, and `.*` over a real + // event. Anything else (`tool.st*`, `ag*.completed.mark`) is a + // typo that admits nothing on any event and never extends a full + // event name, so the prefix check below would stay silent about + // it — flag it here instead. + 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) .is_some_and(|defs| defs.iter().any(|def| def.name == name)); @@ -317,6 +357,24 @@ fn gate_entry_admits(entry: &str, event_name: &str, hook_name: &str, full_name: entry.strip_suffix(".*") == Some(event_name) } +/// True when a `*`-containing gate entry is one of the wildcard forms +/// `gate_entry_admits` understands. `*.` takes a literal hook name +/// (globbing inside the name is not supported), and `.*` requires a +/// real event: event names contain dots, so e.g. `tool.*` names the +/// nonexistent event `tool` rather than a prefix over `tool.started` and +/// `tool.completed`. +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 { @@ -1399,7 +1457,12 @@ mod tests { ]; 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] @@ -1669,6 +1732,52 @@ mod tests { })); } + #[test] + fn partial_glob_over_event_prefix_logs_invalid_glob_diagnostic() { + crate::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 = crate::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() { + crate::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 = crate::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_log_collector(); From 22078b1ddcb4c2d9de7d064d36c2f09a737a64ee Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 22:32:42 -0600 Subject: [PATCH 16/29] test(TASK-051): cover windows hook dispatch, payload posture, and install semantics Add a #[cfg(windows)] dispatch test module exercising the real cmd /C raw_arg path: env delivery through a verbatim command line, fire() detachment via a gate-file batch loop, payload-file lifecycle with the per-user temp-dir posture assertion, backslashed COYOTE_HOOKS_DIR install override, per-scope cwd resolution with an end-to-end %CD% check, and exec-bit-free install. Hoist wait_for to shared test scope and move the two OS-agnostic dispatch tests (spawn-failure payload cleanup, debug-only error paths) into an ungated dispatch_portable module so both cfg lanes share them instead of duplicating. --- src/hooks.rs | 441 +++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 343 insertions(+), 98 deletions(-) diff --git a/src/hooks.rs b/src/hooks.rs index 0af7501..08bcad6 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -2234,6 +2234,119 @@ mod tests { assert_eq!(resolved[1].cwd, paths::roles_dir()); } + 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::testing::TestConfigDirGuard; + use std::fs::create_dir_all; + use std::time::Duration; + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn spawn_failure_removes_the_payload_file() { + crate::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"), + }]; + + 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", || { + crate::testing::debug_snapshot().iter().any(|message| { + message.contains("Failed to spawn hook 'tool.started.spawnfail-marker-c9d'") + }) + }) + .await; + let debugs = crate::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() + .flatten() + .next() + .is_none() + }) + .await; + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn error_paths_log_debug_only() { + crate::testing::install_log_collector(); + let empty_cwd = env::temp_dir(); + let unique = std::time::SystemTime::now() + .duration_since(std::time::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 = crate::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 = crate::testing::warn_snapshot(); + assert!(warns.iter().all(|message| !message.contains("marker-f5b"))); + } + } + #[cfg(unix)] mod dispatch { use super::*; @@ -2242,16 +2355,6 @@ mod tests { use std::path::PathBuf; use std::time::Duration; - 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}"); - } - #[tokio::test(flavor = "multi_thread")] #[serial] async fn fire_spawns_detached_with_envs() { @@ -2350,56 +2453,6 @@ mod tests { wait_for("payload file removal", || !payload_file.exists()).await; } - #[tokio::test(flavor = "multi_thread")] - #[serial] - async fn spawn_failure_removes_the_payload_file() { - crate::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: PathBuf::from("/nonexistent/coyote-spawnfail-marker-c9d"), - }]; - - 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", || { - crate::testing::debug_snapshot().iter().any(|message| { - message.contains("Failed to spawn hook 'tool.started.spawnfail-marker-c9d'") - }) - }) - .await; - let debugs = crate::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() - .flatten() - .next() - .is_none() - }) - .await; - } - #[tokio::test(flavor = "multi_thread")] #[serial] async fn drain_pending_waits_for_spawn_not_completion() { @@ -2432,44 +2485,6 @@ 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_log_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 = crate::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()) - })); - 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 = crate::testing::warn_snapshot(); - assert!(warns.iter().all(|message| !message.contains("marker-f5b"))); - } - #[tokio::test(flavor = "multi_thread")] #[serial] async fn failing_hook_never_disturbs_the_engine() { @@ -2669,4 +2684,234 @@ 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 = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let hooks_dir = PathBuf::from(format!( + "{}\\coyote-win-bs-{unique}\\hooks", + env::temp_dir().display() + )); + let _env = crate::testing::EnvVarGuard::set( + crate::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() + ); + } + } } From c2e30c12a653c0cbeeef4cdb0fab1a99cd485e65 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 23:09:59 -0600 Subject: [PATCH 17/29] fix(TASK-046): honor the --mcp-list inspection contract without a vault Pure --mcp-list never reads secrets (only --mcp-add does), yet the mcp action path always ran strict Vault::init, so a virgin environment with no password file exited 1 instead of printing the empty listing. Route the mcp_inspect path through the established Vault::init_lenient constructor; get/remove/add stay on the strict init. Make tests/inspection_flags.rs hermetic against host state: every spawned binary gets a fresh fake HOME (and USERPROFILE) so a real ~/.coyote_password can't mask virgin-environment regressions, scrubs IS_SANDBOX/COYOTE_PROVIDER/COYOTE_PLATFORM outside the sandbox-mode tests, and pins CWD so workspace mcp.json discovery can't leak in. New tests pin both sides of the boundary: the lenient list path (empty and configured, never bootstrapping a password file) and the strict vault gate for get/remove/add. --- src/main.rs | 10 +- tests/inspection_flags.rs | 187 +++++++++++++++++++++++++++++++++++++- 2 files changed, 195 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 50d797c..a7833c4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -259,7 +259,15 @@ async fn main() -> Result<()> { } else { AppConfig::from_config(cfg)? }; - let vault = Vault::init(&app_config)?; + // Listing servers reads no secrets, so an unconfigured vault (no + // Listing servers reads no secrets, so an unconfigured vault (no + // password file) must not block the readout; every other MCP flag + // (get/remove/add) stays on the strict init. + let vault = if mcp_inspect { + Vault::init_lenient(&app_config) + } else { + Vault::init(&app_config)? + }; mcp::manage::handle(&cli, &vault)?; diff --git a/tests/inspection_flags.rs b/tests/inspection_flags.rs index d96a293..bdfb24e 100644 --- a/tests/inspection_flags.rs +++ b/tests/inspection_flags.rs @@ -9,6 +9,11 @@ //! 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; @@ -27,11 +32,27 @@ fn fresh_config_dir(label: &str) -> PathBuf { } fn probe_inspection_flag(flag: &str, require_stdout: bool) { - let tmp_dir = fresh_config_dir(flag.trim_start_matches("--")); + 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(); @@ -40,7 +61,12 @@ fn probe_inspection_flag(flag: &str, require_stdout: bool) { .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(), @@ -90,10 +116,17 @@ fn mcp_list_on_empty_config_dir_writes_nothing() { #[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(); @@ -102,6 +135,7 @@ fn agent_info_on_empty_config_dir_bootstraps_builtins() { 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(), @@ -131,11 +165,15 @@ fn agent_info_on_empty_config_dir_bootstraps_builtins() { #[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(); @@ -145,6 +183,7 @@ fn sandbox_list_secrets_prints_informational_message_and_writes_nothing() { .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(), @@ -175,6 +214,7 @@ fn sandbox_list_secrets_prints_informational_message_and_writes_nothing() { #[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\ @@ -196,10 +236,14 @@ fn sandbox_mutating_vault_flag_stays_strict() { .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(), @@ -218,6 +262,142 @@ fn sandbox_mutating_vault_flag_stays_strict() { ); } +// 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 @@ -227,19 +407,24 @@ fn sandbox_mutating_vault_flag_stays_strict() { #[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(), From bf10aeb74d50170ee2ddaea26b4dfa1459bf1b33 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 23:24:18 -0600 Subject: [PATCH 18/29] test(TASK-047): split the ntfs short-name guard test by platform reality On NTFS the fixture self-collides: .builtin-manifest auto-generates the 8.3 short name BUILTI~1, so writing that fixture file opens the manifest through its alias and clobbers it -- the exact threat the guard models. Gate the fixture-based test to unix, and add a windows counterpart that proves the collision where 8.3 generation is enabled and asserts the filter invariant (alias-shaped entries are neither removal candidates nor claimed) either way. --- src/config/builtin_manifest.rs | 60 ++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/src/config/builtin_manifest.rs b/src/config/builtin_manifest.rs index ce0da9c..abd616e 100644 --- a/src/config/builtin_manifest.rs +++ b/src/config/builtin_manifest.rs @@ -320,6 +320,15 @@ mod tests { 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-"); @@ -335,4 +344,55 @@ mod tests { ); 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); + } } From 83978617bcea7bc1eb460af33753888786027914 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 23:38:28 -0600 Subject: [PATCH 19/29] fix: bootstrap the async entry point on a 16 MiB thread to survive Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows links the process main thread with a ~1 MiB stack (unix defaults to 8 MiB). Coyote's root async future is a compiler-generated state machine whose debug-build poll frames blow through 1 MiB the moment block_on polls it, so every invocation of the binary on Windows died at startup with STATUS_STACK_OVERFLOW (0xC00000FD) before doing any work. Replace #[tokio::main] with an explicit bootstrap: main() spawns a dedicated thread (named "main", 16 MiB stack) that builds the same multi-thread enable_all runtime and block_on's the async body, then joins and propagates the Result unchanged; panics are re-raised on the real main thread via resume_unwind without re-invoking the panic hook, so panic output and exit codes stay identical. Runtime worker threads get the same 16 MiB headroom since spawned tasks embed the same class of giant state machine. The bootstrap is unconditional rather than cfg(windows)-gated: the unix arrangement is behaviorally identical (same runtime flavor and driver set — tokio's ctrl_c signal driver is thread-agnostic), and one shared code path removes the entire small-main-stack class everywhere instead of special-casing one OS. Verified on Linux with the full test suite (inspection_flags 12/12) and the hooks usage probe end-to-end, including the terminal ctrl-c -> agent.interrupted scenario. --- src/main.rs | 42 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index a7833c4..fda9c6e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -58,8 +58,46 @@ use std::path::PathBuf; use std::sync::atomic::Ordering; use std::{env, fs, process, sync::Arc}; -#[tokio::main] -async fn main() -> Result<()> { +/// Stack headroom for the thread that polls the root async future, and for +/// the runtime's worker threads. +/// +/// Windows links the process main thread with a ~1 MiB stack (unix defaults +/// to 8 MiB), and the root future is a compiler-generated state machine +/// whose debug-build poll frames blow through 1 MiB the moment it is +/// polled — every binary invocation dies with STATUS_STACK_OVERFLOW +/// (0xC00000FD) before doing any work. 16 MiB gives comfortable headroom +/// over the deepest observed frames on every platform. +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(); From 3d37ed237a1949c35317f85f8bded1bc33f7f740 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 23:51:18 -0600 Subject: [PATCH 20/29] test(TASK-050): build the macro-bracketing hook commands in cmd's dialect on windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixture's hook commands were sh-only: single-quoted redirect targets are literal under cmd /C, so the marker log never received content and all six tests timed out on windows-latest. Build the marker command per-platform — parenthesized `(echo MARKER)>> "log"` on Windows to avoid cmd's trailing-space echo gotcha — and move the interpolated paths into single-quoted YAML scalars so Windows backslashes are not read as double-quoted-scalar escapes. Also replace the cleanup call that a wait timeout skipped with a drop guard, so failed runs no longer leak temp dirs. --- tests/macro_bracketing.rs | 48 ++++++++++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/tests/macro_bracketing.rs b/tests/macro_bracketing.rs index 7c5594c..e2b2f63 100644 --- a/tests/macro_bracketing.rs +++ b/tests/macro_bracketing.rs @@ -25,21 +25,47 @@ fn fresh_config_dir(label: &str) -> PathBuf { 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); + } +} + +/// Builds the hook command that appends `marker` to `log` 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, log: &Path) -> String { + 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 `log`, a `probe-macro` agent that /// whitelists everything, and a `probe` macro with the given YAML body. fn write_fixture(dir: &Path, log: &Path, macro_yaml: &str) { - let log = log.display(); // 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\ + vault_password_file: '{vault_pass}'\n\ clients:\n\ \x20 - type: openai\n\ \x20 name: dryrun\n\ @@ -55,17 +81,21 @@ fn write_fixture(dir: &Path, log: &Path, macro_yaml: &str) { hooks:\n\ \x20 agent.started:\n\ \x20 - name: mark\n\ - \x20 command: \"echo STARTED >> '{log}'\"\n\ + \x20 command: '{started}'\n\ \x20 agent.completed:\n\ \x20 - name: mark\n\ - \x20 command: \"echo COMPLETED >> '{log}'\"\n\ + \x20 command: '{completed}'\n\ \x20 agent.failed:\n\ \x20 - name: mark\n\ - \x20 command: \"echo FAILED >> '{log}'\"\n\ + \x20 command: '{failed}'\n\ \x20 agent.interrupted:\n\ \x20 - name: mark\n\ - \x20 command: \"echo INTERRUPTED >> '{log}'\"\n", - vault_pass = vault_pass.display() + \x20 command: '{interrupted}'\n", + vault_pass = vault_pass.display(), + started = marker_command("STARTED", log), + completed = marker_command("COMPLETED", log), + failed = marker_command("FAILED", log), + interrupted = marker_command("INTERRUPTED", log), ), ) .unwrap(); @@ -103,6 +133,8 @@ fn run_coyote(dir: &Path, args: &[&str]) -> Output { /// Bounded poll for the expected terminal marker. The binary drains pending /// hooks before exiting, so the log is normally complete once `output()` /// returns; the poll only absorbs filesystem latency, never paces the run. +/// Marker matching stays byte-exact on both platforms because `lines()` +/// strips the CRLF that cmd's echo emits on Windows. fn wait_for_marker(log: &Path, needle: &str) -> String { let deadline = Instant::now() + Duration::from_secs(10); loop { @@ -136,6 +168,7 @@ fn assert_started_and_single_terminal(contents: &str, terminal: &str) { fn probe_macro_run(label: &str, macro_yaml: &str, args: &[&str], expect_success: bool) -> String { let dir = fresh_config_dir(label); + let _cleanup = TempDirGuard(dir.clone()); let log = dir.join("agent-events.log"); write_fixture(&dir, &log, macro_yaml); @@ -155,7 +188,6 @@ fn probe_macro_run(label: &str, macro_yaml: &str, args: &[&str], expect_success: "FAILED" }; let contents = wait_for_marker(&log, terminal); - let _ = fs::remove_dir_all(&dir); assert_started_and_single_terminal(&contents, terminal); contents } From ec66494e8f0e77805a23e732fb7f9c1196a747e2 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 17 Sep 2026 00:16:08 -0600 Subject: [PATCH 21/29] docs(TASK-052): add hooks feature bullet to the README quick links --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index f3a39e8..55c2372 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. From 611900057c4d9eb39064619193ed245e03c20190 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 17 Sep 2026 00:33:37 -0600 Subject: [PATCH 22/29] test(TASK-050): wait on every expected marker before asserting hook counts Hook processes are fire-and-forget: the binary's exit drain acknowledges spawns only, so the STARTED writer can land after the terminal one. The macro-bracketing tests waited only for the terminal marker and then asserted STARTED's count immediately, flaking on Windows CI and loaded Linux runners. The bounded wait now covers every expected marker before the exact-count and absence assertions run; the probe script's scenario K comment is corrected to state the spawn-ack-only contract it already waits around. --- scripts/usage-probe-hooks.sh | 5 +++-- tests/macro_bracketing.rs | 24 +++++++++++++++--------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/scripts/usage-probe-hooks.sh b/scripts/usage-probe-hooks.sh index 44916ad..003a66a 100755 --- a/scripts/usage-probe-hooks.sh +++ b/scripts/usage-probe-hooks.sh @@ -654,8 +654,9 @@ echo "== Scenario K: global_hooks wildcard grammar (exact, .*, *., # two hooks named 'mark' and 'other'. Each sub-case runs the same # `--agent wc-probe --macro probe` recipe in its own config dir with a # different global_hooks whitelist, then asserts exactly which markers were -# admitted. The binary drains pending hooks before exiting, so post-exit -# waits only absorb filesystem latency. +# admitted. The binary's exit drain only acknowledges hook SPAWNS — the hook +# processes finish on their own after exit — so every expected marker gets +# its own wait_for before any absence check reads the log. k_run() { # $1 = label, $2 = marker log, $3.. = global_hooks entries (verbatim) local label="$1" klog="$2" diff --git a/tests/macro_bracketing.rs b/tests/macro_bracketing.rs index e2b2f63..5f65010 100644 --- a/tests/macro_bracketing.rs +++ b/tests/macro_bracketing.rs @@ -130,21 +130,27 @@ fn run_coyote(dir: &Path, args: &[&str]) -> Output { .unwrap() } -/// Bounded poll for the expected terminal marker. The binary drains pending -/// hooks before exiting, so the log is normally complete once `output()` -/// returns; the poll only absorbs filesystem latency, never paces the run. -/// Marker matching stays byte-exact on both platforms because `lines()` -/// strips the CRLF that cmd's echo emits on Windows. -fn wait_for_marker(log: &Path, needle: &str) -> String { +/// 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. Marker matching +/// stays byte-exact on both platforms because `lines()` strips the CRLF +/// that cmd's echo emits on Windows. +fn wait_for_markers(log: &Path, needles: &[&str]) -> String { let deadline = Instant::now() + Duration::from_secs(10); loop { let contents = fs::read_to_string(log).unwrap_or_default(); - if contents.lines().any(|line| line == needle) { + if needles + .iter() + .all(|needle| contents.lines().any(|line| line == *needle)) + { return contents; } assert!( Instant::now() < deadline, - "timed out waiting for '{needle}'; log so far: {contents:?}" + "timed out waiting for {needles:?}; log so far: {contents:?}" ); thread::sleep(Duration::from_millis(50)); } @@ -187,7 +193,7 @@ fn probe_macro_run(label: &str, macro_yaml: &str, args: &[&str], expect_success: } else { "FAILED" }; - let contents = wait_for_marker(&log, terminal); + let contents = wait_for_markers(&log, &["STARTED", terminal]); assert_started_and_single_terminal(&contents, terminal); contents } From ad9b319149e7ce0b36fbf582dae851d02bf74482 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 17 Sep 2026 01:00:48 -0600 Subject: [PATCH 23/29] test(TASK-050): give each macro-bracketing marker its own file cmd opens '>>' redirection targets write-exclusive, so when the STARTED and terminal hook children appended to one shared log concurrently, the loser of the open race hit a sharing violation and silently dropped its marker line -- a permanent loss that surfaced as an intermittent Windows-CI-only failure of the macro-bracketing suite (the engine's exit drain was verified sound: spawn intent is registered synchronously at fire time and covers macro-context fires). One file per event makes writer overlap impossible while keeping every exact-count assertion byte-identical. --- tests/macro_bracketing.rs | 110 +++++++++++++++++++++++++------------- 1 file changed, 72 insertions(+), 38 deletions(-) diff --git a/tests/macro_bracketing.rs b/tests/macro_bracketing.rs index 5f65010..b079930 100644 --- a/tests/macro_bracketing.rs +++ b/tests/macro_bracketing.rs @@ -15,6 +15,8 @@ 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) @@ -35,13 +37,27 @@ impl Drop for TempDirGuard { } } -/// Builds the hook command that appends `marker` to `log` 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, log: &Path) -> String { +/// 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}""#) @@ -51,9 +67,10 @@ fn marker_command(marker: &str, log: &Path) -> String { } /// Lays out a config dir with a dry_run model, global agent.* hooks that -/// append one marker line per event to `log`, a `probe-macro` agent that -/// whitelists everything, and a `probe` macro with the given YAML body. -fn write_fixture(dir: &Path, log: &Path, macro_yaml: &str) { +/// 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"); @@ -92,10 +109,10 @@ fn write_fixture(dir: &Path, log: &Path, macro_yaml: &str) { \x20 - name: mark\n\ \x20 command: '{interrupted}'\n", vault_pass = vault_pass.display(), - started = marker_command("STARTED", log), - completed = marker_command("COMPLETED", log), - failed = marker_command("FAILED", log), - interrupted = marker_command("INTERRUPTED", log), + started = marker_command("STARTED", dir), + completed = marker_command("COMPLETED", dir), + failed = marker_command("FAILED", dir), + interrupted = marker_command("INTERRUPTED", dir), ), ) .unwrap(); @@ -130,53 +147,71 @@ fn run_coyote(dir: &Path, args: &[&str]) -> 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. Marker matching -/// stays byte-exact on both platforms because `lines()` strips the CRLF -/// that cmd's echo emits on Windows. -fn wait_for_markers(log: &Path, needles: &[&str]) -> String { +/// 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 { - let contents = fs::read_to_string(log).unwrap_or_default(); - if needles - .iter() - .all(|needle| contents.lines().any(|line| line == *needle)) - { - return contents; + if needles.iter().all(|needle| marker_count(dir, needle) > 0) { + return; } assert!( Instant::now() < deadline, - "timed out waiting for {needles:?}; log so far: {contents:?}" + "timed out waiting for {needles:?}; markers so far: {}", + dump_markers(dir) ); thread::sleep(Duration::from_millis(50)); } } -fn assert_marker_count(contents: &str, marker: &str, expected: usize) { - let count = contents.lines().filter(|line| *line == marker).count(); +fn assert_marker_count(dir: &Path, marker: &str, expected: usize) { + let count = marker_count(dir, marker); assert_eq!( - count, expected, - "expected {expected}x {marker}: {contents:?}" + count, + expected, + "expected {expected}x {marker}; markers so far: {}", + dump_markers(dir) ); } -fn assert_started_and_single_terminal(contents: &str, terminal: &str) { - assert_marker_count(contents, "STARTED", 1); +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(contents, marker, expected); + assert_marker_count(dir, marker, expected); } } -fn probe_macro_run(label: &str, macro_yaml: &str, args: &[&str], expect_success: bool) -> String { +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()); - let log = dir.join("agent-events.log"); - write_fixture(&dir, &log, macro_yaml); + write_fixture(&dir, macro_yaml); let output = run_coyote(&dir, args); assert_eq!( @@ -193,9 +228,8 @@ fn probe_macro_run(label: &str, macro_yaml: &str, args: &[&str], expect_success: } else { "FAILED" }; - let contents = wait_for_markers(&log, &["STARTED", terminal]); - assert_started_and_single_terminal(&contents, terminal); - contents + wait_for_markers(&dir, &["STARTED", terminal]); + assert_started_and_single_terminal(&dir, terminal); } #[test] From 5bd1174dddb409d4ccf0d322652a22eab8310dd2 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 17 Sep 2026 10:49:35 -0600 Subject: [PATCH 24/29] fix: isolate the adversary review-stack lock for parallel tests and non-POSIX platforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The advisory flock in run_checks.py uses one system-wide lock path (~/.cache/coyote/review-stack.lock). Concurrent runs of the timeout/deadline tests contend on it: the losers sleep in the bounded retry loop, the total deadline (computed before lock acquisition) expires, and commands record 'skipped: deadline' instead of the expected timeout/kill behavior. - run_checks.py: ADVERSARY_STACK_LOCK_PATH env override relocates the lock file (isolation seam, documented alongside the other env seams); every other lock semantic (default path, bounded wait, proceed-unlocked warning, fd held for process lifetime, skip when no commands) is unchanged. - run_checks.py: guard the POSIX-only fcntl import — windows-latest CI has python3 and runs these tests, so the unconditional import crashed the script there; on non-POSIX the lock is now skipped with a stderr WARN, matching the script's documented non-POSIX limitations. - tests: the shared adversary_script_command helper points every spawned script at its own throwaway lock path, isolating the run_checks tests from each other and from any real review running on the same machine. - tests: adversary_run_checks_stack_lock_path_is_env_overridable pins the override (lock file appears at the overridden path across two sequential runs; no lock file is created when no commands are declared). --- assets/agents/adversary/scripts/run_checks.py | 20 ++++-- src/config/request_context.rs | 72 +++++++++++++++++++ 2 files changed, 88 insertions(+), 4 deletions(-) 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/src/config/request_context.rs b/src/config/request_context.rs index 7daf797..a495047 100644 --- a/src/config/request_context.rs +++ b/src/config/request_context.rs @@ -10443,13 +10443,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 { + static STACK_LOCK_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let path = std::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 } @@ -11041,6 +11052,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 From fcdcb303a3430f1251dc32eb2b110f527d911393 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 17 Sep 2026 12:00:16 -0600 Subject: [PATCH 25/29] style: Cleaned up unnecessary path prefixes, imports, stray scripts, and some documentation --- README.md | 2 +- scripts/usage-probe-hooks.sh | 766 ------------------------------ scripts/usage-probe-inspection.sh | 115 ----- scripts/usage-probe-wizard.sh | 68 --- src/cli/completer.rs | 2 - src/config/agent.rs | 43 +- src/config/app_state.rs | 2 - src/config/builtin_manifest.rs | 2 +- src/config/bundles.rs | 4 - src/config/conflict.rs | 29 +- src/config/install_remote.rs | 9 - src/config/macros.rs | 5 - src/config/mod.rs | 61 +-- src/config/request_context.rs | 13 +- src/function/agents.rs | 18 +- src/function/jobs.rs | 2 - src/function/mod.rs | 30 +- src/function/user_interaction.rs | 2 - src/graph/agent.rs | 8 - src/graph/user_interaction.rs | 4 - src/hooks.rs | 180 +++---- src/main.rs | 31 +- src/repl/mod.rs | 10 - src/sandbox/mixins.rs | 5 - src/supervisor/mod.rs | 5 - src/vault/mod.rs | 2 - 26 files changed, 132 insertions(+), 1286 deletions(-) delete mode 100755 scripts/usage-probe-hooks.sh delete mode 100755 scripts/usage-probe-inspection.sh delete mode 100755 scripts/usage-probe-wizard.sh diff --git a/README.md b/README.md index 55c2372..33d366d 100644 --- a/README.md +++ b/README.md @@ -56,7 +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. +* [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/scripts/usage-probe-hooks.sh b/scripts/usage-probe-hooks.sh deleted file mode 100755 index 003a66a..0000000 --- a/scripts/usage-probe-hooks.sh +++ /dev/null @@ -1,766 +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; a non-interactive re-run -# keeps locally modified scripts while still installing missing ones. -# D) `coyote --agent --info` (an inspection flag) fires ZERO -# agent.* hook events. -# E) `coyote --install-builtins hooks` reconciles manifest-owned leftovers -# in roles/hooks and each bundled agent's hooks dir, sparing user files. -# F) top-level hooks-dir manifest reconcile: a stale shipped hook is removed -# on reinstall; a malformed manifest deletes NOTHING (fail-safe). -# G) a plain startup sweeps manifest-owned hooks of removed builtin agents -# while leaving user agent dirs and user files untouched. -# H) the INSTALLED bundled scripts behave as documented when run directly: -# log-events.sh defaults under ${XDG_STATE_HOME:-$HOME/.local/state}/coyote/ -# (0600, UTC ISO-8601 header, secrets filtered, symlink at the default -# path refused) and notify.sh's no-notifier/no-tty fallback strips -# ANSI/OSC control bytes; the hooks-dir .builtin-manifest is never -# installed executable. -# I) `--session ` discriminates the session events end-to-end: a -# fresh named session fires exactly one session.started (and no -# session.resumed); resuming a persisted session fires exactly one -# session.resumed (and no session.started). -# J) pressing ctrl-c in a real terminal during a live headless --agent run -# fires exactly one agent.interrupted with NO COYOTE_AGENT_ERROR, and -# never agent.failed or agent.completed (needs python3 for the pty and -# the hanging LLM endpoint; skipped when python3 is unavailable). -# -# 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 -} - -# Bounded poll: retries the given command every 0.1s until it succeeds or the -# ~10s deadline expires, then fails loudly. Hooks are fire-and-forget child -# processes, so every wait on their side effects goes through this helper — -# never a bare sleep. -wait_for() { - local what="$1" - shift - local tries=0 - until "$@"; do - tries=$((tries + 1)) - [ "$tries" -ge 100 ] && fail "timed out waiting for $what" - sleep 0.1 - done -} - -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 -scenario_a_logs_ready() { [ -s "$TS_LOG" ] && [ -s "$TC_LOG" ] && [ -s "$LC_LOG" ]; } -wait_for "turn/llm hook logs" scenario_a_logs_ready - -[ "$(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" - -rc_b=0 -COYOTE_CONFIG_DIR="$CFG_B" "$BIN" --no-stream "say exactly: hi" > "$WORKDIR/out_b.txt" 2> "$WORKDIR/err_b.txt" || rc_b=$? -rc_base=0 -COYOTE_CONFIG_DIR="$CFG_BASE" "$BIN" --no-stream "say exactly: hi" > "$WORKDIR/out_base.txt" 2> "$WORKDIR/err_base.txt" || rc_base=$? - -[ "$rc_b" = "$rc_base" ] || fail "exit code differs: with-hooks=$rc_b baseline=$rc_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 one script locally and delete the other, then re-run -# --install-builtins hooks: without a terminal the conflict resolution keeps -# local files, so the edit must survive while the missing script is -# reinstalled from the packaged content. -echo "# LOCALLY MODIFIED" > "$CFG_C/hooks/notify.sh" -rm "$CFG_C/hooks/log-events.sh" -# The refresh also touches the roles and per-agent hook locations: user files -# living there must survive it untouched. -AGENT_C="$(ls "$CFG_C/agents" | head -1)" -mkdir -p "$CFG_C/roles/hooks" "$CFG_C/agents/$AGENT_C/hooks" -echo "mine-role" > "$CFG_C/roles/hooks/user-role.sh" -echo "mine-agent" > "$CFG_C/agents/$AGENT_C/hooks/user-agent.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 "non-interactive reinstall overwrote the locally modified notify.sh" -[ -f "$CFG_C/hooks/log-events.sh" ] \ - || fail "reinstall did not restore the deleted log-events.sh" -grep -q '^#!/usr/bin/env bash' "$CFG_C/hooks/log-events.sh" \ - || fail "log-events.sh was not restored to the packaged script" -if command -v stat > /dev/null 2>&1; then - mode_restored="$(stat -c '%a' "$CFG_C/hooks/log-events.sh" 2>/dev/null || stat -f '%Lp' "$CFG_C/hooks/log-events.sh")" - [ "$mode_restored" = "755" ] || fail "restored log-events.sh mode is $mode_restored, expected 755" -fi -[ "$(cat "$CFG_C/roles/hooks/user-role.sh")" = "mine-role" ] \ - || fail "user file in roles/hooks was touched by the refresh" -[ "$(cat "$CFG_C/agents/$AGENT_C/hooks/user-agent.sh")" = "mine-agent" ] \ - || fail "user file in the agent hooks dir was touched by the refresh" -echo "PASS: --install-builtins hooks installs both scripts 0755; re-runs keep modified files (all hook locations) and restore missing ones 0755" - -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 -wait_for "installed log-events.sh output" test -s "$HOOK_LOG" - -[ -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" -SENTINEL_LOG="$WORKDIR/d-sentinel.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\"" \ - " turn.completed:" \ - " - name: sentinel" \ - " command: \"echo SENTINEL >> $SENTINEL_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 -# Proving absence needs a positive signal to wait on: a follow-up plain run -# fires turn.completed, and its sentinel landing bounds how long the --info -# run's (nonexistent) agent.* children could have taken to write. -COYOTE_CONFIG_DIR="$CFG_D" "$BIN" --no-stream "say exactly: hi" > /dev/null -wait_for "scenario D sentinel" test -s "$SENTINEL_LOG" -[ -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 "== Scenario E: --install-builtins hooks reconciles roles/hooks and agent hooks dirs ==" -CFG_E="$WORKDIR/e" -AGENT_E="$(ls "$REPO_ROOT/assets/agents" | head -1)" -mkdir -p "$CFG_E/roles/hooks" "$CFG_E/agents/$AGENT_E/hooks" -# Manifest-owned leftovers a previous release shipped, plus user files that -# must survive the refresh in both locations. -printf 'stale-role.sh\n' > "$CFG_E/roles/hooks/.builtin-manifest" -echo "stale" > "$CFG_E/roles/hooks/stale-role.sh" -echo "mine" > "$CFG_E/roles/hooks/user-role.sh" -printf 'stale-agent.sh\n' > "$CFG_E/agents/$AGENT_E/hooks/.builtin-manifest" -echo "stale" > "$CFG_E/agents/$AGENT_E/hooks/stale-agent.sh" -echo "mine" > "$CFG_E/agents/$AGENT_E/hooks/user-agent.sh" - -COYOTE_CONFIG_DIR="$CFG_E" "$BIN" --install-builtins hooks > "$WORKDIR/install_e.out" 2>&1 \ - || fail "--install-builtins hooks failed: $(cat "$WORKDIR/install_e.out")" - -[ -f "$CFG_E/hooks/notify.sh" ] || fail "global hooks were not installed" -[ ! -f "$CFG_E/roles/hooks/stale-role.sh" ] \ - || fail "manifest-owned stale role hook survived the refresh" -[ "$(cat "$CFG_E/roles/hooks/user-role.sh")" = "mine" ] \ - || fail "user file in roles/hooks was touched by the refresh" -[ ! -f "$CFG_E/agents/$AGENT_E/hooks/stale-agent.sh" ] \ - || fail "manifest-owned stale agent hook survived the refresh" -[ "$(cat "$CFG_E/agents/$AGENT_E/hooks/user-agent.sh")" = "mine" ] \ - || fail "user file in the agent hooks dir was touched by the refresh" -echo "PASS: refresh reconciles roles/hooks and bundled-agent hooks, sparing user files" - -echo "== Scenario F: top-level manifest reconcile removes stale hooks; malformed manifest deletes nothing ==" -CFG_F="$WORKDIR/f" -mkdir -p "$CFG_F" -COYOTE_CONFIG_DIR="$CFG_F" "$BIN" --install-builtins hooks > /dev/null 2>&1 -[ -f "$CFG_F/hooks/.builtin-manifest" ] \ - || fail "install did not write a .builtin-manifest to the top-level hooks dir" - -# Simulate an upgrade from a release that also shipped old-hook.sh: the -# manifest records it, so reinstall must remove it — while a user script -# absent from the manifest survives. -printf 'log-events.sh\nnotify.sh\nold-hook.sh\n' > "$CFG_F/hooks/.builtin-manifest" -echo "stale" > "$CFG_F/hooks/old-hook.sh" -echo "mine" > "$CFG_F/hooks/user-hook.sh" -COYOTE_CONFIG_DIR="$CFG_F" "$BIN" --install-builtins hooks > /dev/null 2>&1 -[ ! -f "$CFG_F/hooks/old-hook.sh" ] \ - || fail "stale shipped hook recorded in the manifest was not removed on reinstall" -[ "$(cat "$CFG_F/hooks/user-hook.sh")" = "mine" ] \ - || fail "user hook script was deleted or modified by manifest reconcile" -grep -q 'old-hook.sh' "$CFG_F/hooks/.builtin-manifest" \ - && fail "manifest still lists the removed stale hook" - -# Fail-safe: a malformed (non-UTF8) manifest must delete NOTHING and must -# not fail the command. -printf '\xff\xfe\x00' > "$CFG_F/hooks/.builtin-manifest" -echo "keep" > "$CFG_F/hooks/orphan.sh" -rc_f=0 -COYOTE_CONFIG_DIR="$CFG_F" "$BIN" --install-builtins hooks > "$WORKDIR/install_f.out" 2>&1 || rc_f=$? -[ "$rc_f" = "0" ] \ - || fail "reinstall failed on a malformed manifest (exit $rc_f): $(cat "$WORKDIR/install_f.out")" -[ -f "$CFG_F/hooks/orphan.sh" ] \ - || fail "a malformed manifest caused a deletion (fail-safe broken)" -[ -f "$CFG_F/hooks/notify.sh" ] && [ -f "$CFG_F/hooks/log-events.sh" ] \ - || fail "shipped hooks missing after malformed-manifest reinstall" -echo "PASS: stale manifest-owned hook removed; user files survive; malformed manifest deletes nothing" - -echo "== Scenario G: plain startup sweeps removed-builtin-agent hooks, spares user agents ==" -CFG_G="$WORKDIR/g" -mkdir -p "$CFG_G" -write_dryrun_config "$CFG_G" -# ghost: a removed builtin agent (has a manifest, absent from the embed) -# with a user file beside the manifest-owned hook -> hooks dir survives -# with only the user file. -mkdir -p "$CFG_G/agents/ghost/hooks" -printf 'shipped.sh\n' > "$CFG_G/agents/ghost/hooks/.builtin-manifest" -echo "stale" > "$CFG_G/agents/ghost/hooks/shipped.sh" -echo "mine" > "$CFG_G/agents/ghost/hooks/user.sh" -# ghost2: same, but nothing user-owned -> the emptied hooks dir is removed. -mkdir -p "$CFG_G/agents/ghost2/hooks" -printf 'shipped.sh\n' > "$CFG_G/agents/ghost2/hooks/.builtin-manifest" -echo "stale" > "$CFG_G/agents/ghost2/hooks/shipped.sh" -# mine: a user agent with no manifest -> never touched. -mkdir -p "$CFG_G/agents/mine/hooks" -echo "mine" > "$CFG_G/agents/mine/hooks/mine.sh" - -COYOTE_CONFIG_DIR="$CFG_G" "$BIN" --no-stream "say exactly: hi" > /dev/null - -[ ! -f "$CFG_G/agents/ghost/hooks/shipped.sh" ] \ - || fail "removed-agent sweep left the manifest-owned hook behind" -[ ! -f "$CFG_G/agents/ghost/hooks/.builtin-manifest" ] \ - || fail "removed-agent sweep left the manifest behind" -[ "$(cat "$CFG_G/agents/ghost/hooks/user.sh")" = "mine" ] \ - || fail "removed-agent sweep deleted a user file" -[ ! -d "$CFG_G/agents/ghost2/hooks" ] \ - || fail "emptied hooks dir of a removed agent was not removed" -[ -d "$CFG_G/agents/ghost2" ] \ - || fail "sweep removed more than the hooks dir of a removed agent" -[ "$(cat "$CFG_G/agents/mine/hooks/mine.sh")" = "mine" ] \ - || fail "sweep touched a user agent dir without a manifest" -echo "PASS: startup sweep removes only manifest-owned hooks of removed agents" - -echo "== Scenario H: bundled hook scripts behave as documented when run directly ==" -# The two shipped scripts are consumer-facing artifacts in their own right. -# Exercise the INSTALLED copies (real CLI install) the way a hook runner or a -# manual invocation would: clean env, no COYOTE_HOOK_LOG, no notifier -# binaries, no controlling terminal. -CFG_H="$WORKDIR/h" -mkdir -p "$CFG_H" -COYOTE_CONFIG_DIR="$CFG_H" "$BIN" --install-builtins hooks > /dev/null 2>&1 -LOG_SCRIPT="$CFG_H/hooks/log-events.sh" -NOTIFY_SCRIPT="$CFG_H/hooks/notify.sh" -[ -x "$LOG_SCRIPT" ] || fail "installed log-events.sh missing or non-executable" -[ -x "$NOTIFY_SCRIPT" ] || fail "installed notify.sh missing or non-executable" - -# The manifest the installer drops beside the scripts is NOT a script: the -# unified exec-bit predicate must leave it non-executable. -if [ -f "$CFG_H/hooks/.builtin-manifest" ]; then - [ ! -x "$CFG_H/hooks/.builtin-manifest" ] \ - || fail ".builtin-manifest in the hooks dir is executable; non-script files must stay non-executable" -fi - -# H1: with COYOTE_HOOK_LOG unset the log defaults under XDG_STATE_HOME, is -# created 0600, carries a UTC ISO-8601 header, and never logs COYOTE_SECRET_*. -XDG_H="$WORKDIR/h-xdg" -env -u COYOTE_HOOK_LOG XDG_STATE_HOME="$XDG_H" HOME="$WORKDIR/h-home-unused" \ - COYOTE_EVENT=probe.event COYOTE_HOOK_NAME=probe COYOTE_SECRET_TOKEN=hunter2 \ - bash "$LOG_SCRIPT" || fail "log-events.sh exited non-zero on the XDG default path" -HLOG="$XDG_H/coyote/hooks.log" -[ -f "$HLOG" ] || fail "log-events.sh did not default to \$XDG_STATE_HOME/coyote/hooks.log" -if command -v stat > /dev/null 2>&1; then - mode_hlog="$(stat -c '%a' "$HLOG" 2>/dev/null || stat -f '%Lp' "$HLOG")" - [ "$mode_hlog" = "600" ] || fail "default hook log mode is $mode_hlog, expected 600" -fi -grep -Eq '^=== [0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z probe\.event$' "$HLOG" \ - || fail "log header is not a UTC ISO-8601 timestamp: $(head -1 "$HLOG")" -grep -q '^COYOTE_EVENT=probe.event$' "$HLOG" \ - || fail "log snapshot is missing COYOTE_EVENT" -! grep -q 'hunter2' "$HLOG" \ - || fail "COYOTE_SECRET_* value leaked into the hook log" - -# H2: with XDG_STATE_HOME also unset, the log falls back to -# $HOME/.local/state/coyote/hooks.log. -HOME_H="$WORKDIR/h-home" -mkdir -p "$HOME_H" -env -u COYOTE_HOOK_LOG -u XDG_STATE_HOME HOME="$HOME_H" COYOTE_EVENT=probe.event \ - bash "$LOG_SCRIPT" || fail "log-events.sh exited non-zero on the HOME default path" -[ -f "$HOME_H/.local/state/coyote/hooks.log" ] \ - || fail "log-events.sh did not fall back to ~/.local/state/coyote/hooks.log" - -# H3: a symlink planted at the DEFAULT log path is refused — exit 0, target -# untouched (the hardening must hold on the new default location too). -HOME_S="$WORKDIR/h-sym" -mkdir -p "$HOME_S/.local/state/coyote" -VICTIM_H="$WORKDIR/h-victim.txt" -echo "untouched" > "$VICTIM_H" -ln -s "$VICTIM_H" "$HOME_S/.local/state/coyote/hooks.log" -env -u COYOTE_HOOK_LOG -u XDG_STATE_HOME HOME="$HOME_S" COYOTE_EVENT=probe.event \ - bash "$LOG_SCRIPT" || fail "log-events.sh exited non-zero when refusing a symlink" -[ "$(cat "$VICTIM_H")" = "untouched" ] \ - || fail "log-events.sh wrote through a symlink planted at the default log path" - -# H4: notify.sh with no notifier on PATH and no controlling terminal falls -# back to stdout with control bytes (ESC/BEL, i.e. ANSI + OSC) stripped from -# model-influenced values. -BIN_H="$WORKDIR/h-bin" -mkdir -p "$BIN_H" -ln -s "$(command -v tr)" "$BIN_H/tr" -NOTIFY_OUT="$WORKDIR/h-notify.out" -rc_h=0 -setsid -w env -i PATH="$BIN_H" \ - COYOTE_EVENT=turn.completed \ - COYOTE_TOOL_NAME="$(printf 'evil\033]0;own\007\033[31mred')" \ - "$(command -v bash)" "$NOTIFY_SCRIPT" > "$NOTIFY_OUT" 2> /dev/null || rc_h=$? -[ "$rc_h" = "0" ] || fail "notify.sh fallback exited $rc_h" -grep -Fq '[coyote] turn.completed tool=evil]0;own[31mred' "$NOTIFY_OUT" \ - || fail "notify.sh fallback output missing/unsanitized: $(cat "$NOTIFY_OUT")" -! LC_ALL=C grep -q "$(printf '\033')" "$NOTIFY_OUT" \ - || fail "ANSI/OSC escape bytes leaked through the notify.sh tty fallback" -echo "PASS: installed hook scripts honor XDG default, 0600, UTC timestamps, symlink refusal, secret filter, and ANSI-stripped notify fallback" - -echo "== Scenario I: session.started vs session.resumed via --session ==" -CFG_I="$WORKDIR/i" -mkdir -p "$CFG_I" -SESS_LOG="$WORKDIR/i-sess.log" -write_dryrun_config "$CFG_I" "hooks:" \ - " session.started:" \ - " - name: probe-sess" \ - " command: \"echo STARTED >> $SESS_LOG\"" \ - " session.resumed:" \ - " - name: probe-sess" \ - " command: \"echo RESUMED >> $SESS_LOG\"" - -# A fresh named session (no file on disk yet) fires session.started only. -COYOTE_CONFIG_DIR="$CFG_I" "$BIN" --session probe-fresh --no-stream "say exactly: hi" > /dev/null -wait_for "session.started log" test -s "$SESS_LOG" -[ "$(grep -c '^STARTED$' "$SESS_LOG" 2>/dev/null || echo 0)" = "1" ] \ - || fail "expected exactly one session.started for a fresh session, got: $(cat "$SESS_LOG")" -grep -q '^RESUMED$' "$SESS_LOG" \ - && fail "a fresh named session must not fire session.resumed: $(cat "$SESS_LOG")" - -# A persisted session on disk: resuming it fires session.resumed, and never -# session.started (the inverse gate). -mkdir -p "$CFG_I/sessions" -printf 'model: dryrun:dry-model\nmessages: []\n' > "$CFG_I/sessions/probe-resume.yaml" -COYOTE_CONFIG_DIR="$CFG_I" "$BIN" --session probe-resume --no-stream "say exactly: hi" > /dev/null -scenario_i_resumed() { grep -q '^RESUMED$' "$SESS_LOG"; } -wait_for "session.resumed log" scenario_i_resumed -[ "$(grep -c '^RESUMED$' "$SESS_LOG" 2>/dev/null || echo 0)" = "1" ] \ - || fail "expected exactly one session.resumed for a resumed session, got: $(cat "$SESS_LOG")" -[ "$(grep -c '^STARTED$' "$SESS_LOG" 2>/dev/null || echo 0)" = "1" ] \ - || fail "resuming a session must not fire session.started: $(cat "$SESS_LOG")" -echo "PASS: fresh session fires session.started only; resume fires session.resumed only" - -echo "== Scenario J: terminal ctrl-c during a live headless --agent run fires agent.interrupted ==" -if ! command -v python3 > /dev/null 2>&1; then - echo "SKIP: python3 not available; cannot allocate a pty or hanging LLM endpoint" >&2 -else - CFG_J="$WORKDIR/j" - mkdir -p "$CFG_J/agents/probe-int" - INT_LOG="$WORKDIR/j-int.log" - - # A local "LLM endpoint" that accepts connections and never answers keeps - # the agent run in flight (spinner active, its ctrl-c watcher polling) - # while the probe delivers ^C. It writes its port to $1 once listening and - # touches $2 on the first accepted connection, so the runner below can - # gate the ^C on the request being demonstrably in flight. - python3 - "$WORKDIR/j-port" "$WORKDIR/j-conn" <<'PY' & -import socket, sys -s = socket.socket() -s.bind(("127.0.0.1", 0)) -s.listen(8) -open(sys.argv[1], "w").write(str(s.getsockname()[1])) -conns = [] -while True: - c, _ = s.accept() - if not conns: - open(sys.argv[2], "w").write("1") - conns.append(c) -PY - SRV_J=$! - trap 'kill "$SRV_J" 2>/dev/null || true; rm -rf "$WORKDIR"' EXIT - wait_for "hang-server port" test -s "$WORKDIR/j-port" - PORT_J="$(cat "$WORKDIR/j-port")" - - cat > "$CFG_J/config.yaml" < "$CFG_J/agents/probe-int/config.yaml" <> $INT_LOG" - agent.completed: - - name: mark - command: "echo COMPLETED >> $INT_LOG" - agent.failed: - - name: mark - command: "echo \"FAILED err=\${COYOTE_AGENT_ERROR:-unset}\" >> $INT_LOG" - agent.interrupted: - - name: mark - command: "echo \"INTERRUPTED err=\${COYOTE_AGENT_ERROR:-unset}\" >> $INT_LOG" -EOF - - # Spawn coyote on a real pty (the spinner's ctrl-c watcher only runs on a - # terminal), wait until the agent.started marker AND the first accepted - # LLM connection prove the run is in flight, then send a literal ^C - # through the pty line discipline — exactly what a terminal user does. - cat > "$WORKDIR/j-pty.py" <<'PY' -import os, pty, sys, time - -int_log, conn_marker = sys.argv[1], sys.argv[2] -argv = sys.argv[3:] - -pid, master = pty.fork() -if pid == 0: - os.execvp(argv[0], argv) - -os.set_blocking(master, False) - -def drain(): - try: - while os.read(master, 4096): - pass - except OSError: - pass - -def marker_seen(): - try: - with open(int_log) as f: - return "STARTED" in f.read() - except FileNotFoundError: - return False - -deadline = time.time() + 15 -while time.time() < deadline: - drain() - if marker_seen() and os.path.exists(conn_marker): - break - time.sleep(0.1) -else: - print("TIMEOUT: agent.started marker / LLM connection never appeared", flush=True) - os.kill(pid, 9) - os.waitpid(pid, 0) - sys.exit(3) - -os.write(master, b"\x03") # ^C via the pty line discipline - -deadline = time.time() + 15 -while time.time() < deadline: - drain() - got, status = os.waitpid(pid, os.WNOHANG) - if got == pid: - if os.WIFSIGNALED(status): - print(f"FATAL: coyote died on signal {os.WTERMSIG(status)} — no ctrl-c handler was installed", flush=True) - sys.exit(4) - sys.exit(0) - time.sleep(0.1) - -print("TIMEOUT: coyote did not exit within 15s of ^C", flush=True) -os.kill(pid, 9) -os.waitpid(pid, 0) -sys.exit(3) -PY - - COYOTE_CONFIG_DIR="$CFG_J" python3 "$WORKDIR/j-pty.py" "$INT_LOG" "$WORKDIR/j-conn" \ - "$BIN" --agent probe-int --no-stream "say exactly: hi" \ - || fail "pty ctrl-c run did not exit cleanly (see message above)" - - scenario_j_interrupted() { grep -qs '^INTERRUPTED err=unset$' "$INT_LOG"; } - wait_for "agent.interrupted log" scenario_j_interrupted - [ "$(grep -c '^STARTED$' "$INT_LOG" 2>/dev/null || echo 0)" = "1" ] \ - || fail "expected exactly one agent.started, got: $(cat "$INT_LOG")" - [ "$(grep -c '^INTERRUPTED' "$INT_LOG" 2>/dev/null || echo 0)" = "1" ] \ - || fail "expected exactly one agent.interrupted, got: $(cat "$INT_LOG")" - grep -q '^FAILED' "$INT_LOG" \ - && fail "ctrl-c must fire agent.interrupted, never agent.failed: $(cat "$INT_LOG")" - grep -q '^COMPLETED' "$INT_LOG" \ - && fail "an interrupted run must not fire agent.completed: $(cat "$INT_LOG")" - kill "$SRV_J" 2>/dev/null || true - echo "PASS: terminal ctrl-c fires exactly one agent.interrupted with no COYOTE_AGENT_ERROR; never failed/completed" -fi - -echo "== Scenario K: global_hooks wildcard grammar (exact, .*, *., *.*, invalid globs) ==" -# Global hooks: agent.started has one hook named 'mark'; agent.completed has -# two hooks named 'mark' and 'other'. Each sub-case runs the same -# `--agent wc-probe --macro probe` recipe in its own config dir with a -# different global_hooks whitelist, then asserts exactly which markers were -# admitted. The binary's exit drain only acknowledges hook SPAWNS — the hook -# processes finish on their own after exit — so every expected marker gets -# its own wait_for before any absence check reads the log. -k_run() { - # $1 = label, $2 = marker log, $3.. = global_hooks entries (verbatim) - local label="$1" klog="$2" - shift 2 - local kcfg="$WORKDIR/k-$label" - mkdir -p "$kcfg/agents/wc-probe" "$kcfg/macros" - write_dryrun_config "$kcfg" "hooks:" \ - " agent.started:" \ - " - name: mark" \ - " command: \"echo S-mark >> $klog\"" \ - " agent.completed:" \ - " - name: mark" \ - " command: \"echo C-mark >> $klog\"" \ - " - name: other" \ - " command: \"echo C-other >> $klog\"" - { - printf 'name: wc-probe\n' - printf 'description: wildcard whitelist probe agent\n' - printf 'version: 0.1.0\n' - printf 'instructions: |\n' - printf ' You are a probe agent. Reply briefly.\n' - printf 'global_hooks:\n' - local entry - for entry in "$@"; do - printf ' - "%s"\n' "$entry" - done - } > "$kcfg/agents/wc-probe/config.yaml" - printf 'steps:\n - ".set temperature 0.5"\n' > "$kcfg/macros/probe.yaml" - COYOTE_CONFIG_DIR="$kcfg" "$BIN" --agent wc-probe --macro probe > /dev/null 2>&1 \ - || fail "scenario K ($label): --agent wc-probe --macro probe exited nonzero" -} - -marker_count_is() { # $1 = log, $2 = marker, $3 = expected count - local n - n="$(grep -c -x -- "$2" "$1" 2>/dev/null)" || n=0 - [ "${n:-0}" = "$3" ] -} - -# K1: exact . admits only that hook on that event (unchanged). -KLOG1="$WORKDIR/k1.log" -k_run exact "$KLOG1" "agent.completed.mark" -wait_for "K1 C-mark" marker_count_is "$KLOG1" C-mark 1 -marker_count_is "$KLOG1" S-mark 0 || fail "K1: exact entry leaked agent.started: $(cat "$KLOG1")" -marker_count_is "$KLOG1" C-other 0 || fail "K1: exact entry leaked sibling hook 'other': $(cat "$KLOG1")" - -# K2: .* admits every hook of that event, nothing else. -KLOG2="$WORKDIR/k2.log" -k_run event-star "$KLOG2" "agent.completed.*" -wait_for "K2 C-mark" marker_count_is "$KLOG2" C-mark 1 -wait_for "K2 C-other" marker_count_is "$KLOG2" C-other 1 -marker_count_is "$KLOG2" S-mark 0 || fail "K2: agent.completed.* leaked agent.started: $(cat "$KLOG2")" - -# K3: *. admits that hook name on every event, nothing else. -KLOG3="$WORKDIR/k3.log" -k_run star-name "$KLOG3" "*.mark" -wait_for "K3 S-mark" marker_count_is "$KLOG3" S-mark 1 -wait_for "K3 C-mark" marker_count_is "$KLOG3" C-mark 1 -marker_count_is "$KLOG3" C-other 0 || fail "K3: *.mark leaked hook 'other': $(cat "$KLOG3")" - -# K4: *.* admits everything (the bare * form is pinned by tests/macro_bracketing.rs). -KLOG4="$WORKDIR/k4.log" -k_run star-star "$KLOG4" "*.*" -wait_for "K4 S-mark" marker_count_is "$KLOG4" S-mark 1 -wait_for "K4 C-mark" marker_count_is "$KLOG4" C-mark 1 -wait_for "K4 C-other" marker_count_is "$KLOG4" C-other 1 - -# K5: partial/inner globs are NOT wildcards — they admit nothing, and the run -# is otherwise unaffected (exit code checked inside k_run). The exact -# agent.started.mark entry is the positive signal bounding the absence checks. -KLOG5="$WORKDIR/k5.log" -k_run invalid-globs "$KLOG5" "agent.completed.ma*" "agent.*" "ag*.completed.mark" "agent.started.mark" -wait_for "K5 S-mark" marker_count_is "$KLOG5" S-mark 1 -marker_count_is "$KLOG5" C-mark 0 || fail "K5: a partial glob admitted agent.completed.mark: $(cat "$KLOG5")" -marker_count_is "$KLOG5" C-other 0 || fail "K5: a partial glob admitted agent.completed.other: $(cat "$KLOG5")" -echo "PASS: wildcard whitelist grammar admits exactly the spec'd sets; partial globs stay inert" - -echo "== Scenario L: --macro without --agent fires zero agent.* events ==" -CFG_L="$WORKDIR/l" -mkdir -p "$CFG_L/macros" -L_AGENT_LOG="$WORKDIR/l-agent-events.log" -L_SENTINEL_LOG="$WORKDIR/l-sentinel.log" -write_dryrun_config "$CFG_L" "hooks:" \ - " agent.started:" \ - " - name: mark" \ - " command: \"echo STARTED >> $L_AGENT_LOG\"" \ - " agent.completed:" \ - " - name: mark" \ - " command: \"echo COMPLETED >> $L_AGENT_LOG\"" \ - " agent.failed:" \ - " - name: mark" \ - " command: \"echo FAILED >> $L_AGENT_LOG\"" \ - " turn.completed:" \ - " - name: sentinel" \ - " command: \"echo SENTINEL >> $L_SENTINEL_LOG\"" -printf 'steps:\n - ".set temperature 0.5"\n' > "$CFG_L/macros/probe.yaml" -COYOTE_CONFIG_DIR="$CFG_L" "$BIN" --macro probe > /dev/null 2>&1 \ - || fail "scenario L: --macro without --agent exited nonzero" -# Proving absence needs a positive signal to wait on: a follow-up plain run -# fires turn.completed, and its sentinel landing bounds how long the macro -# run's (nonexistent) agent.* children could have taken to write. -COYOTE_CONFIG_DIR="$CFG_L" "$BIN" --no-stream "say exactly: hi" > /dev/null -wait_for "scenario L sentinel" test -s "$L_SENTINEL_LOG" -[ -s "$L_AGENT_LOG" ] && fail "expected zero agent.* events from --macro without --agent, got: $(cat "$L_AGENT_LOG")" -echo "PASS: --macro without --agent fired zero agent.* hook events and exited cleanly" - -echo -echo "ALL SCENARIOS PASSED" diff --git a/scripts/usage-probe-inspection.sh b/scripts/usage-probe-inspection.sh deleted file mode 100755 index b7e5543..0000000 --- a/scripts/usage-probe-inspection.sh +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env bash -# Black-box usage-pattern probe for inspection flags on a pristine config dir. -# -# Contract under test (first-run wizard bypass for look-don't-touch flags): -# With COYOTE_CONFIG_DIR pointing at an EMPTY directory, every inspection -# flag (--info, --list-* , --mcp-list, vault list) must: -# 1) exit without prompting (no tty read, no first-run wizard), -# 2) exit 0 (Config::default() fallback); only --info guarantees output -# on stdout — list flags legitimately emit an empty listing on a -# pristine dir, -# 3) write NOTHING into the config dir (no config.yaml, no builtins). -# The plain interactive path (`coyote` on a tty, no flags) must still reach -# the first-run wizard prompt; that is covered manually / by the REPL recipe -# and is intentionally not asserted here (needs a pty). -# -# Usage: scripts/usage-probe-inspection.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 - -failures=0 -fail() { - echo "FAIL: $1" >&2 - failures=$((failures + 1)) -} - -# Each flag gets a fresh, empty config dir. stdin is closed so any attempt to -# prompt fails fast; timeout guards against a hang instead of a read error. -probe_flag() { - # $1 = "require-output" or "allow-empty" (stdout expectation), - # $2... = the flag (and any args) to pass - local output_mode="$1"; shift - local desc="$*" - local cfg out rc nwritten - cfg="$(mktemp -d "$WORKDIR/cfg.XXXXXX")" - out="$WORKDIR/out.txt" - rc=0 - COYOTE_CONFIG_DIR="$cfg" timeout 30 "$BIN" "$@" "$out" 2>&1 || rc=$? - - if [ "$rc" = "124" ]; then - fail "$desc: hung (likely waiting on a prompt) on an empty config dir" - return - fi - if [ "$rc" != "0" ]; then - fail "$desc: expected clean exit 0 on an empty config dir, got rc=$rc, output: $(head -c 200 "$out")" - fi - if [ "$output_mode" = "require-output" ] && [ ! -s "$out" ]; then - fail "$desc: expected some output, got none" - fi - nwritten="$(find "$cfg" -mindepth 1 | wc -l | tr -d ' ')" - if [ "$nwritten" != "0" ]; then - fail "$desc: expected NOTHING written into the empty config dir, found $nwritten entries: $(find "$cfg" -mindepth 1 -maxdepth 1 | tr '\n' ' ')" - fi -} - -echo "== Inspection flags on a pristine COYOTE_CONFIG_DIR: no wizard, clean exit, zero writes ==" -probe_flag require-output --info -probe_flag allow-empty --list-models -probe_flag allow-empty --list-roles -probe_flag allow-empty --list-sessions -probe_flag allow-empty --list-agents -probe_flag allow-empty --list-macros -probe_flag allow-empty --list-skills -probe_flag allow-empty --list-bundles -probe_flag allow-empty --list-secrets -probe_flag allow-empty --mcp-list - -# --sync-models writes models-override.yaml, so it must NOT behave like an -# inspection flag: on a fresh empty config dir it takes the bootstrap path -# (builtins written) and, off a terminal, fails on the missing config. -# IS_SANDBOX is stripped: the host path is the one under test, and a leaked -# sandbox flag would reroute the missing config to the wizard prompt. -echo "== --sync-models on a pristine COYOTE_CONFIG_DIR: bootstrap path, strict failure ==" -sync_cfg="$(mktemp -d "$WORKDIR/cfg.XXXXXX")" -sync_out="$WORKDIR/sync-out.txt" -sync_rc=0 -env -u IS_SANDBOX -u COYOTE_PROVIDER -u COYOTE_PLATFORM COYOTE_CONFIG_DIR="$sync_cfg" \ - timeout 30 "$BIN" --sync-models "$sync_out" 2>&1 || sync_rc=$? -if [ "$sync_rc" = "124" ]; then - fail "--sync-models: hung (likely waiting on a prompt) on an empty config dir" -else - if [ "$sync_rc" = "0" ]; then - fail "--sync-models: expected the strict missing-config failure on an empty config dir, got rc=0" - fi - sync_nwritten="$(find "$sync_cfg" -mindepth 1 | wc -l | tr -d ' ')" - if [ "$sync_nwritten" = "0" ]; then - fail "--sync-models: expected the builtins bootstrap to populate the empty config dir, found nothing" - fi -fi - -if [ "$failures" != "0" ]; then - echo - echo "$failures inspection-flag scenario(s) FAILED" >&2 - exit 1 -fi - -echo -echo "ALL SCENARIOS PASSED" diff --git a/scripts/usage-probe-wizard.sh b/scripts/usage-probe-wizard.sh deleted file mode 100755 index 947c5d5..0000000 --- a/scripts/usage-probe-wizard.sh +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env bash -# Black-box usage-pattern probe for the first-run wizard on a real PTY. -# -# Contract under test (companion to scripts/usage-probe-inspection.sh, which -# intentionally cannot assert this because it needs a pty): -# Plain `coyote` (no flags, no message) on a TTY with an EMPTY -# COYOTE_CONFIG_DIR must still reach the first-run wizard — the -# look-don't-touch bypass for inspection flags must NOT swallow the -# interactive setup path. We assert the wizard's first prompt -# ("No config file, create a new one?") appears within the timeout; the -# run is then killed, so the exit code is not asserted. -# -# Usage: scripts/usage-probe-wizard.sh -# Requires: a `coyote` binary on PATH or at ./target/debug/coyote or -# ./target/release/coyote, and the `script` utility (util-linux or BSD). - -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 - -if ! command -v script > /dev/null 2>&1; then - echo "SKIP: 'script' utility not available; cannot allocate a pty" >&2 - exit 0 -fi - -WORKDIR="$(mktemp -d)" -trap 'rm -rf "$WORKDIR"' EXIT - -CFG="$WORKDIR/cfg" -mkdir -p "$CFG" -OUT="$WORKDIR/out.txt" - -echo "== Plain \`coyote\` on a pty with an empty COYOTE_CONFIG_DIR reaches the wizard ==" - -# `script` allocates a real pty for the child, so IS_STDOUT_TERMINAL is true -# inside even in headless CI. IS_SANDBOX is stripped to exercise the plain -# host path from the recipe. The wizard blocks on its prompt forever, so a -# timeout kill (rc 124) is the expected way out. -rc=0 -if script --version 2>/dev/null | grep -q util-linux; then - env -u IS_SANDBOX COYOTE_CONFIG_DIR="$CFG" \ - timeout 15 script -qec "$BIN" /dev/null > "$OUT" 2>&1 < /dev/null || rc=$? -else - # BSD/macOS script: command goes after the typescript file. - env -u IS_SANDBOX COYOTE_CONFIG_DIR="$CFG" \ - timeout 15 script -q "$OUT" "$BIN" < /dev/null > /dev/null 2>&1 || rc=$? -fi - -if ! grep -aq "No config file, create a new one?" "$OUT"; then - echo "FAIL: wizard prompt never appeared (rc=$rc); output was:" >&2 - head -c 500 "$OUT" >&2 - exit 1 -fi - -echo "PASS: first-run wizard prompt appeared on a pty with an empty config dir" -echo -echo "ALL SCENARIOS PASSED" diff --git a/src/cli/completer.rs b/src/cli/completer.rs index 6e356f5..68b4735 100644 --- a/src/cli/completer.rs +++ b/src/cli/completer.rs @@ -50,8 +50,6 @@ pub(super) fn model_completer(current: &OsStr) -> Vec { fn load_app_config_for_completion() -> anyhow::Result { let h = tokio::runtime::Handle::try_current().ok(); - // Tab completion is a read-only inspection surface: never run the - // first-run wizard or write anything from a completer. let cfg = match h { Some(handle) => tokio::task::block_in_place(|| { handle.block_on(Config::load_with_interpolation(true, true)) diff --git a/src/config/agent.rs b/src/config/agent.rs index 156bd5e..358d256 100644 --- a/src/config/agent.rs +++ b/src/config/agent.rs @@ -113,7 +113,7 @@ pub(crate) fn install_and_reconcile_agent_hooks( /// 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 std::fs::read_dir(paths::agents_data_dir()) { + 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}"); @@ -156,7 +156,7 @@ fn sweep_removed_agent_hooks(bundled_files: &HashMap>) { } // remove_dir only succeeds on an empty directory, so a user file // left behind keeps the directory in place. - let _ = std::fs::remove_dir(&hooks_dir); + let _ = fs::remove_dir(&hooks_dir); } } @@ -184,9 +184,6 @@ impl Agent { paths::agents_data_dir().display() ); - // Whole-agent installs run Skip (startup) or Force (reinstall); a - // Prompt caller would still get coherent per-file conflict handling, - // scoped to this run. let mut sticky = StickyMode::None; let mut written_hooks: HashMap> = HashMap::new(); for file in AgentAssets::iter() { @@ -283,15 +280,7 @@ impl Agent { Ok(()) } - /// Refreshes only the bundled agents' hook scripts, leaving every other - /// agent file alone: the hooks asset category must be able to update all - /// hook locations without reinstalling whole agents. - /// Every bundled agent's hooks/ directory reconciles, even when the - /// current embed ships no hooks for it. pub fn install_builtin_agent_hooks(mode: InstallMode, sticky: &mut StickyMode) -> Result<()> { - // Every bundled agent gets an entry, hooks or not: an agent whose - // hook assets were all dropped still needs its hooks/ directory - // reconciled so manifest-owned leftovers are removed. let mut shipped: BTreeMap> = BTreeMap::new(); for file in AgentAssets::iter() { let Some((agent, rest)) = file.as_ref().split_once('/') else { @@ -311,6 +300,7 @@ impl Agent { for (agent, files) in &shipped { install_and_reconcile_agent_hooks(agent, files, mode, sticky)?; } + Ok(()) } @@ -1496,6 +1486,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() { @@ -1610,12 +1601,12 @@ global_hooks: #[test] #[serial_test::serial] fn load_envs_ignores_malformed_global_tools_and_logs_debug() { - crate::testing::install_log_collector(); + 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 = crate::testing::EnvVarGuard::set(&env_name, "{not-json"); + let _guard = testing::EnvVarGuard::set(&env_name, "{not-json"); config.load_envs(&AppConfig::default()); @@ -1624,7 +1615,7 @@ global_hooks: vec!["keep_tool.sh"], "a malformed override must leave the existing value untouched" ); - let debugs = crate::testing::debug_snapshot(); + let debugs = testing::debug_snapshot(); assert!( debugs.iter().any(|message| { message.contains("Ignoring malformed global_tools env override") @@ -2076,9 +2067,9 @@ nodes: {} 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"); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("a.sh"), "local a").unwrap(); - std::fs::write(dir.join("b.sh"), "local b").unwrap(); + 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"]); @@ -2098,7 +2089,7 @@ nodes: {} // 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. - std::fs::write(dir.join("a.sh"), "local again").unwrap(); + 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) @@ -2121,7 +2112,7 @@ nodes: {} &mut StickyMode::None, ) .unwrap(); - std::fs::write(dir.join("user.sh"), "user-owned").unwrap(); + fs::write(dir.join("user.sh"), "user-owned").unwrap(); install_and_reconcile_agent_hooks( "probe", @@ -2182,7 +2173,7 @@ nodes: {} &mut StickyMode::None, ) .unwrap(); - std::fs::write(dir.join("user.sh"), "user-owned").unwrap(); + fs::write(dir.join("user.sh"), "user-owned").unwrap(); Agent::install_builtin_agent_hooks(InstallMode::Skip, &mut StickyMode::None).unwrap(); @@ -2200,14 +2191,14 @@ nodes: {} let guard = TestConfigDirGuard::new("agent-sweep-symlink"); let real = guard.path.join("agent-elsewhere"); let hooks = real.join("hooks"); - std::fs::create_dir_all(&hooks).unwrap(); - std::fs::write( + create_dir_all(&hooks).unwrap(); + fs::write( hooks.join(builtin_manifest::BUILTIN_MANIFEST_FILE), "shipped.sh\n", ) .unwrap(); - std::fs::write(hooks.join("shipped.sh"), "stale").unwrap(); - std::fs::create_dir_all(paths::agents_data_dir()).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()); diff --git a/src/config/app_state.rs b/src/config/app_state.rs index 29d20e4..fb882bf 100644 --- a/src/config/app_state.rs +++ b/src/config/app_state.rs @@ -75,8 +75,6 @@ impl AppState { } } - // Inspection-only runs are look-don't-touch readouts: skip the - // function-binary prune/build, which writes into the config dir. let mut functions = if inspection { Functions::default() } else { diff --git a/src/config/builtin_manifest.rs b/src/config/builtin_manifest.rs index abd616e..1b51523 100644 --- a/src/config/builtin_manifest.rs +++ b/src/config/builtin_manifest.rs @@ -24,7 +24,7 @@ pub(crate) fn is_builtin_manifest_name(name: &str) -> bool { } /// 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 +/// 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 { diff --git a/src/config/bundles.rs b/src/config/bundles.rs index c5309c9..caa3ce2 100644 --- a/src/config/bundles.rs +++ b/src/config/bundles.rs @@ -241,10 +241,6 @@ impl BundleStore { same_name_other_source: None, }; - // Reserved names are the `--install-builtins` categories. `roles` is - // deliberately NOT among them: builtin roles are not installable that - // way (some are coupled to flag-specific coyote behavior), so the - // asymmetry with InstallFilter is intended. let reserved = AssetCategory::parse(&base).is_some(); let collision = self.source_of_other_bundle(&base, &canonical); if reserved || collision.is_some() { diff --git a/src/config/conflict.rs b/src/config/conflict.rs index 2ff7f1e..7b3c57d 100644 --- a/src/config/conflict.rs +++ b/src/config/conflict.rs @@ -2,7 +2,7 @@ //! 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 +//! 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; @@ -12,17 +12,10 @@ use inquire::Select; use std::fs; use std::path::Path; -/// How an installer treats a destination file that already exists. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum InstallMode { - /// Leave existing files untouched. The only mode startup ever uses: - /// startup runs on every launch — including the REPL, which is a - /// terminal — so it must never prompt. Skip, - /// Overwrite existing files unconditionally. Force, - /// Write missing files, silently skip identical ones, and ask per - /// differing file. Prompt, } @@ -42,20 +35,12 @@ pub(crate) enum ConflictAction { Replace, } -/// What [`resolve_conflict`] does without a terminal: bundle installs bail -/// (an unattended install must not half-apply a plan silently), builtin hook -/// refreshes keep the local file (a scripted refresh must neither clobber -/// local changes nor fail). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum NonInteractive { Bail, Keep, } -/// Decides whether an installer should overwrite the existing file at `dst` -/// with `content` under `mode`. Only called for files that already exist; -/// missing files are always written without prompting. In `Prompt` mode an -/// unreadable local file counts as differing, so the user decides. pub(crate) fn should_replace_existing( dst: &Path, content: &str, @@ -337,9 +322,9 @@ mod tests { fn should_replace_existing_skips_identical_content_without_prompting() { let _script = prompt_script::install(&[]); let dir = crate::utils::temp_file("conflict-identical-", ""); - std::fs::create_dir_all(&dir).unwrap(); + fs::create_dir_all(&dir).unwrap(); let path = dir.join("hook.sh"); - std::fs::write(&path, "same content").unwrap(); + fs::write(&path, "same content").unwrap(); let mut sticky = StickyMode::None; let replace = should_replace_existing( @@ -351,7 +336,7 @@ mod tests { ) .unwrap(); - let _ = std::fs::remove_dir_all(&dir); + let _ = fs::remove_dir_all(&dir); assert!(!replace); assert_eq!(prompt_script::prompts_asked(), 0); } @@ -361,9 +346,9 @@ mod tests { fn should_replace_existing_never_prompts_in_skip_or_force_mode() { let _script = prompt_script::install(&[]); let dir = crate::utils::temp_file("conflict-modes-", ""); - std::fs::create_dir_all(&dir).unwrap(); + fs::create_dir_all(&dir).unwrap(); let path = dir.join("hook.sh"); - std::fs::write(&path, "local edit").unwrap(); + fs::write(&path, "local edit").unwrap(); let mut sticky = StickyMode::None; let skip = @@ -371,7 +356,7 @@ mod tests { let force = should_replace_existing(&path, "shipped", "hooks", InstallMode::Force, &mut sticky); - let _ = std::fs::remove_dir_all(&dir); + 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 d776fd0..98a547c 100644 --- a/src/config/install_remote.rs +++ b/src/config/install_remote.rs @@ -1860,12 +1860,6 @@ 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 in a hook location is called out /// individually. Visibility only: installing them needs no extra approval. -/// -/// Only the locations the engine actually resolves hooks from are announced: -/// the top-level hooks category, direct children of `agents//hooks/`, -/// and direct children of `roles/hooks/`. A `hooks/` directory anywhere else -/// (e.g. inside a skill or macro subtree) is inert data and announcing it -/// would be noise. fn hook_script_lines(plan: &InstallPlan) -> Vec { plan.files .iter() @@ -2864,9 +2858,6 @@ mod tests { file("x/config.yaml", TopCategory::Agents), file("reviewer.md", TopCategory::Roles), file("hello.yaml", TopCategory::Macros), - // Only the locations the engine resolves hooks from announce: - // a hooks/ directory nested deeper or under another category - // is inert data. file("x/hooks/nested/deep.sh", TopCategory::Agents), file("x/sub/hooks/pre.sh", TopCategory::Agents), file("sub/hooks/extra.sh", TopCategory::Roles), diff --git a/src/config/macros.rs b/src/config/macros.rs index ebee6a9..5fc7dfd 100644 --- a/src/config/macros.rs +++ b/src/config/macros.rs @@ -83,11 +83,6 @@ pub async fn macro_execute( .bootstrap_tools(app.as_ref(), true, abort_signal.clone()) .await?; - // An in-macro `.agent` step opens the top-level agent bracket on this - // fresh context, so it must also close here — on the step-failure path - // too, or the run would leak `agent.started` with no terminal event. - // Signal-aware like the headless dispatch arms: a ctrl-c'd macro - // classifies as interrupted, not failed. let result = async { for step in ¯o_value.steps { let command = Macro::interpolate_command(step, &variables); diff --git a/src/config/mod.rs b/src/config/mod.rs index c87a6b0..0491e4e 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -419,9 +419,6 @@ impl Default for Config { } pub fn install_builtins() -> Result<()> { - // Best-effort housekeeping riding along with the startup installers: - // payload files orphaned by crashed processes accumulate in a shared - // temp dir and nothing else ever revisits them. hooks::sweep_stale_payload_files(); Functions::install_builtin_global_tools(false)?; Agent::install_builtin_agents(InstallMode::Skip)?; @@ -530,9 +527,6 @@ pub fn install_assets(category: AssetCategory) -> Result<()> { AssetCategory::Functions => Functions::install_builtin_global_tools(true)?, AssetCategory::Skills => Skill::install_builtin_skills(true)?, AssetCategory::Hooks => { - // One sticky scope spans all three hook locations, so a - // keep-all/replace-all answer carries through the whole refresh, - // matching bundle-install semantics. let mut sticky = StickyMode::None; hooks::install_builtin_hooks(InstallMode::Prompt, &mut sticky)?; Role::install_builtin_role_hooks(InstallMode::Prompt, &mut sticky)?; @@ -759,8 +753,6 @@ impl Config { if env::var_os(SANDBOX_ENV_FLAG).is_some() { if !config_path.exists() { - // Inspection-only flags are look-don't-touch: no first-run - // config creation, nothing written. if inspection { return Ok(Self::default()); } @@ -778,9 +770,6 @@ impl Config { { Some(v) => (Self::load_dynamic(&v)?, String::new()), None => { - // Same look-don't-touch rule: never run the interactive - // first-run wizard (or write a config) for a flag that - // only inspects state. if inspection { return Ok(Self::default()); } @@ -1655,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( @@ -1670,12 +1659,12 @@ 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, false).await; @@ -1683,12 +1672,12 @@ hooks: 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); @@ -1713,17 +1702,17 @@ hooks: 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(crate::sandbox::SANDBOX_ENV_FLAG); + 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<_> = std::fs::read_dir(&tmp_dir) + let leftover: Vec<_> = read_dir(&tmp_dir) .unwrap() .map(|entry| entry.unwrap().file_name()) .collect(); - let _ = std::fs::remove_dir_all(&tmp_dir); + let _ = fs::remove_dir_all(&tmp_dir); result.expect("inspection flags must load a default config without a config file"); assert!( leftover.is_empty(), @@ -1743,12 +1732,12 @@ hooks: 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(crate::sandbox::SANDBOX_ENV_FLAG, "1"); + 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 _ = std::fs::remove_dir_all(&tmp_dir); + let _ = fs::remove_dir_all(&tmp_dir); result.expect("inspection flags must load a default config in sandbox mode too"); assert!( !created, @@ -1835,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); @@ -1910,16 +1899,16 @@ hooks: assert_eq!(prompt_script::prompts_asked(), 1); assert_eq!(sticky, StickyMode::ReplaceAll); assert!( - fs::read_to_string(¬ify) + read_to_string(¬ify) .unwrap() .starts_with("#!/usr/bin/env bash") ); assert_eq!( - fs::read_to_string(role_dir.join("hook.sh")).unwrap(), + read_to_string(role_dir.join("hook.sh")).unwrap(), "shipped\n" ); assert_eq!( - fs::read_to_string(agent_dir.join("hook.sh")).unwrap(), + read_to_string(agent_dir.join("hook.sh")).unwrap(), "shipped\n" ); drop(script); @@ -1931,13 +1920,13 @@ hooks: refresh(&mut sticky); assert_eq!(prompt_script::prompts_asked(), 1); assert_eq!(sticky, StickyMode::KeepAll); - assert_eq!(fs::read_to_string(¬ify).unwrap(), "local global"); + assert_eq!(read_to_string(¬ify).unwrap(), "local global"); assert_eq!( - fs::read_to_string(role_dir.join("hook.sh")).unwrap(), + read_to_string(role_dir.join("hook.sh")).unwrap(), "local role" ); assert_eq!( - fs::read_to_string(agent_dir.join("hook.sh")).unwrap(), + read_to_string(agent_dir.join("hook.sh")).unwrap(), "local agent" ); drop(script); @@ -1958,12 +1947,12 @@ hooks: let script = prompt_script::install(&[]); install_builtins().unwrap(); assert_eq!(prompt_script::prompts_asked(), 0); - assert_eq!(fs::read_to_string(¬ify).unwrap(), "# local edit"); + 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!(fs::read_to_string(¬ify).unwrap(), "# local edit"); + assert_eq!(read_to_string(¬ify).unwrap(), "# local edit"); } } diff --git a/src/config/request_context.rs b/src/config/request_context.rs index a495047..0f791a3 100644 --- a/src/config/request_context.rs +++ b/src/config/request_context.rs @@ -417,8 +417,6 @@ impl RequestContext { working_mode: WorkingMode, info_flag: bool, ) -> Result { - // A lenient AppConfig (inspection-only run, no resolvable model) - // leaves model_id empty; render those readouts with a default model. let model = if info_flag && app.config.model_id.is_empty() { Model::default() } else { @@ -4971,15 +4969,6 @@ impl RequestContext { return; }; - // The abort signal, never the error text, decides interrupted: a - // ctrl-c'd run is not a failure, so no error env rides along. The - // gate is deliberately signal-first -- a latched ctrl-c means - // interrupted even with no error in hand -- unlike - // SpawnResultHooks::fire, which is error-first: a child that finished - // cleanly stays agent.completed under a latched signal because its - // output is collectible. Blind spot: a mid-stream ctrl-d aborts live - // work via poll_abort_signal yet still classifies as agent.completed, - // because ctrl-d always means a deliberate exit (accepted). if abort_signal.is_some_and(|signal| signal.aborted_ctrlc()) { hooks::fire( HookEvent::AgentInterrupted, @@ -10449,7 +10438,7 @@ mod tests { // test deadlines. Every spawned script gets its own throwaway lock path. fn adversary_script_command(script: &str, state: &serde_json::Value) -> Command { static STACK_LOCK_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + let path = Path::new(env!("CARGO_MANIFEST_DIR")) .join("assets/agents/adversary/scripts") .join(script); let lock_path = env::temp_dir().join(format!( diff --git a/src/function/agents.rs b/src/function/agents.rs index ac19b3a..e1b92fe 100644 --- a/src/function/agents.rs +++ b/src/function/agents.rs @@ -1026,13 +1026,6 @@ impl SpawnResultHooks { } } - /// Fires `agent.completed`, or — for a failed child — `agent.interrupted` - /// when its abort signal was ctrl-c'd (REPL ctrl-c or `agent__cancel`), - /// else `agent.failed`. The signal, never the error text, decides: - /// cancellation is not a failure, so no error env rides along. Unlike the - /// signal-first `top_level_agent_finished` gate, completion wins over a - /// latched ctrl-c at this seam — a finished child's output is collectible, - /// so the asymmetry is intentional. fn fire(self, error: Option<&str>, child_abort: &AbortSignal) { let mut extras = vec![ ("COYOTE_AGENT_ID", self.agent_id), @@ -1783,13 +1776,6 @@ fn handle_reply_escalation(ctx: &mut RequestContext, args: &Value) -> Result 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"); } @@ -4826,18 +4825,15 @@ mod tests { // 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 process::Command, - ) -> std::io::Result { + fn output_with_etxtbsy_retry(command: &mut Command) -> io::Result { let mut attempts = 0; loop { match command.output() { Err(err) - if err.kind() == std::io::ErrorKind::ExecutableFileBusy - && attempts < 10 => + if err.kind() == io::ErrorKind::ExecutableFileBusy && attempts < 10 => { attempts += 1; - std::thread::sleep(Duration::from_millis(50)); + thread::sleep(Duration::from_millis(50)); } ret => return ret, } @@ -4907,7 +4903,7 @@ mod tests { return; } - let mut command = process::Command::new(&moved_shim); + let mut command = Command::new(&moved_shim); command .arg("{}") .env_remove(&config_env) diff --git a/src/function/user_interaction.rs b/src/function/user_interaction.rs index 6281fd1..11f9fd7 100644 --- a/src/function/user_interaction.rs +++ b/src/function/user_interaction.rs @@ -180,8 +180,6 @@ fn handle_headless(action: &str, args: &Value) -> Value { }) } -// The root agent prompting its own user is a direct interaction, not an -// escalation: no escalation event fires on this path. fn handle_direct(action: &str, args: &Value) -> Result { match action { "select" => handle_direct_ask(args), diff --git a/src/graph/agent.rs b/src/graph/agent.rs index 39cdb7e..029198c 100644 --- a/src/graph/agent.rs +++ b/src/graph/agent.rs @@ -50,14 +50,6 @@ impl AgentNodeExecutor { ) .await; if let Err(e) = &result { - // The child's own abort signal dies inside `run`; the session - // signal is what a user interrupt fires (the executor bridges it - // onto the graph abort), so it is the only honest - // interrupted-vs-failed discriminator at this seam. Blind spot: - // agent__cancel sets the child handle's signal, which never - // reaches parent_ctx.session_abort, so a node failing during - // such a cancellation stays agent.failed while the outer bracket - // reports agent.interrupted (narrow window, accepted). if parent_ctx .session_abort .as_ref() diff --git a/src/graph/user_interaction.rs b/src/graph/user_interaction.rs index 85d1d22..9349e0c 100644 --- a/src/graph/user_interaction.rs +++ b/src/graph/user_interaction.rs @@ -21,10 +21,6 @@ impl ApprovalNodeExecutor { .interpolate(&node.question) .context("Failed to interpolate approval question")?; - // No escalation event fires at this seam: a root graph prompts its - // user directly (outside the escalation queue), and a child graph's - // escalation fires inside `handle_escalated` like any other user__* - // call. let response = handle_user_tool( ctx, &format!("{USER_FUNCTION_PREFIX}select"), diff --git a/src/hooks.rs b/src/hooks.rs index 08bcad6..efb19c6 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -14,7 +14,7 @@ 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; @@ -60,16 +60,10 @@ pub fn install_builtin_hooks(mode: InstallMode, sticky: &mut StickyMode) -> Resu } } - // Only direct children of the hooks dir reconcile through the builtin - // manifest — the only shape it tracks — so a hook a release stops - // shipping is removed while user scripts in the same directory are - // never touched. let shipped: BTreeSet = HookAssets::iter() .map(|file| file.as_ref().to_string()) .filter(|name| !name.contains('/')) .collect(); - // Reconciliation is best-effort housekeeping: a failure here must not - // abort startup (install_builtins), matching the role/agent-side policy. if let Err(err) = builtin_manifest::reconcile_builtin_dir(&paths::hooks_dir(), &shipped, &written) { @@ -79,8 +73,6 @@ pub fn install_builtin_hooks(mode: InstallMode, sticky: &mut StickyMode) -> Resu ); } - // Both sides canonicalize so an override that merely spells the default - // path differently (symlinks, `..`, trailing components) does not warn. 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 { @@ -174,9 +166,6 @@ impl HookEvent { } } - /// Every event, in declaration order. Dotted names stay single-sourced - /// through `as_str`; this exists so whitelist-glob validation can tell a - /// real `.*` from a typo over a nonexistent event. const ALL: [HookEvent; 26] = [ HookEvent::TurnStarted, HookEvent::TurnCompleted, @@ -260,12 +249,6 @@ fn resolve_hooks( let event_name = event.as_str(); let mut resolved = Vec::new(); - // Escalations are a root/user-level concern: the user wires these hooks - // to hear about children asking for help, so requiring every agent to - // whitelist them through `global_hooks` would silence exactly the events - // they were set up for. The per-agent gate never applies here (this also - // skips the unknown-entry diagnostics: whitelisting an escalation hook is - // harmless and needs no warning). let agent_gate = match event { HookEvent::EscalationRaised | HookEvent::EscalationAnswered => None, _ => agent_gate, @@ -296,12 +279,6 @@ fn resolve_hooks( { let prefix = format!("{event_name}."); for entry in gate { - // Entries containing `*` are grammatical only in the wildcard - // forms `*`, `*.*`, `*.`, and `.*` over a real - // event. Anything else (`tool.st*`, `ag*.completed.mark`) is a - // typo that admits nothing on any event and never extends a full - // event name, so the prefix check below would stay silent about - // it — flag it here instead. if entry.contains('*') { if !entry_is_valid_wildcard(entry) { debug!( @@ -341,12 +318,6 @@ fn resolve_hooks( resolved } -/// Whitelist grammar for a `global_hooks` entry: `.` exact, -/// `.*` (every hook of that event), `*.` (that hook name on -/// every event), and `*` / `*.*` (everything). Event names contain dots -/// while hook names never do, so `*.` compares everything after the -/// leading `*.` against the hook name alone. Anything else containing `*` -/// (a partial glob like `tool.st*`) is not a wildcard and admits nothing. fn gate_entry_admits(entry: &str, event_name: &str, hook_name: &str, full_name: &str) -> bool { if entry == full_name || entry == "*" || entry == "*.*" { return true; @@ -357,12 +328,6 @@ fn gate_entry_admits(entry: &str, event_name: &str, hook_name: &str, full_name: entry.strip_suffix(".*") == Some(event_name) } -/// True when a `*`-containing gate entry is one of the wildcard forms -/// `gate_entry_admits` understands. `*.` takes a literal hook name -/// (globbing inside the name is not supported), and `.*` requires a -/// real event: event names contain dots, so e.g. `tool.*` names the -/// nonexistent event `tool` rather than a prefix over `tool.started` and -/// `tool.completed`. fn entry_is_valid_wildcard(entry: &str) -> bool { if entry == "*" || entry == "*.*" { return true; @@ -520,11 +485,6 @@ fn base_envs(event: HookEvent, ctx: &RequestContext) -> Vec<(String, String)> { ) } -/// The single construction site for the base env set every hook receives. -/// [`base_envs`] feeds it from a live context; detached call sites that -/// dispatch pre-resolved snapshots after their context is gone pass the -/// names they captured with the snapshot. The timestamp is taken here, at -/// fire time, so it reflects when the event actually happened. pub(crate) fn base_envs_parts( event: HookEvent, session_name: Option<&str>, @@ -596,12 +556,6 @@ fn payload_dir() -> PathBuf { const STALE_PAYLOAD_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60); -/// Removes payload files left in the shared payload directory by crashed -/// processes — `run_hook` deletes its own file after the hook exits, so -/// anything older than [`STALE_PAYLOAD_MAX_AGE`] is an orphan. Runs once per -/// startup from `install_builtins`. Only exact `coyote-hook-*.json` names are -/// candidates; the directory is shared, so nothing else may ever be touched. -/// Best-effort: failures log at debug and never abort startup. pub fn sweep_stale_payload_files() { sweep_payload_files_older_than(STALE_PAYLOAD_MAX_AGE); } @@ -618,7 +572,7 @@ fn sweep_payload_files_older_than(max_age: Duration) { return; } }; - let now = std::time::SystemTime::now(); + let now = SystemTime::now(); for entry in entries.flatten() { let name = entry.file_name(); let Some(name) = name.to_str() else { @@ -643,6 +597,7 @@ fn sweep_payload_files_older_than(max_age: Duration) { if !stale { continue; } + match std::fs::remove_file(entry.path()) { Ok(()) => debug!("Removed stale hook payload file {}", entry.path().display()), Err(err) => debug!( @@ -840,9 +795,11 @@ 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 @@ -871,22 +828,19 @@ mod tests { /// 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: crate::testing::EnvVarGuard, + _env: testing::EnvVarGuard, root: PathBuf, } impl HooksDirGuard { fn new(label: &str) -> Self { - let unique = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); let root = env::temp_dir().join(format!("coyote-{label}-{unique}")); Self { - _env: crate::testing::EnvVarGuard::set( - crate::utils::get_env_name("hooks_dir"), - &root, - ), + _env: testing::EnvVarGuard::set(utils::get_env_name("hooks_dir"), &root), root, } } @@ -938,8 +892,8 @@ mod tests { } fn fresh_payload_dir(label: &str) -> PathBuf { - let unique = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); let dir = env::temp_dir().join(format!("coyote-{label}-{unique}")); @@ -1026,13 +980,13 @@ mod tests { #[test] #[serial] fn install_builtin_hooks_installs_executable_scripts_and_honors_force() { - let env_name = crate::utils::get_env_name("hooks_dir"); - 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}")); - let env_guard = crate::testing::EnvVarGuard::set(&env_name, &root); + let env_guard = testing::EnvVarGuard::set(&env_name, &root); // Capture every outcome first and assert only after cleanup: the // guard restores the env var even on panic, but the temp dir removal @@ -1222,16 +1176,16 @@ mod tests { #[test] #[serial] fn install_builtin_hooks_respelled_default_hooks_dir_does_not_warn() { - crate::testing::install_log_collector(); - let guard = crate::testing::TestConfigDirGuard::new("hooks-warn-respelled"); + testing::install_log_collector(); + let guard = testing::TestConfigDirGuard::new("hooks-warn-respelled"); let guard_path_display = guard.path.display().to_string(); - let env_name = crate::utils::get_env_name("hooks_dir"); + 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 = crate::testing::EnvVarGuard::set(&env_name, &alias); + let env_guard = testing::EnvVarGuard::set(&env_name, &alias); install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None).unwrap(); drop(env_guard); @@ -1249,7 +1203,7 @@ mod tests { 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 = crate::testing::EnvVarGuard::set(&env_name, &link); + let _env_guard = testing::EnvVarGuard::set(&env_name, &link); install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None).unwrap(); assert!( default_notify.is_file(), @@ -1259,7 +1213,7 @@ mod tests { // The warn buffer is process-global, so scope the check to messages // naming this test's unique directory. - let warns = crate::testing::warn_snapshot(); + let warns = testing::warn_snapshot(); assert!( warns.iter().all(|message| { !(message.contains("overrides the hooks dir") @@ -1272,13 +1226,10 @@ mod tests { #[test] #[serial] fn install_builtin_hooks_genuine_override_warns_when_default_dir_is_missing() { - crate::testing::install_log_collector(); - let guard = crate::testing::TestConfigDirGuard::new("hooks-warn-genuine"); + testing::install_log_collector(); + let guard = testing::TestConfigDirGuard::new("hooks-warn-genuine"); let override_dir = guard.path.join("elsewhere-hooks"); - let _env_guard = crate::testing::EnvVarGuard::set( - crate::utils::get_env_name("hooks_dir"), - &override_dir, - ); + let _env_guard = testing::EnvVarGuard::set(utils::get_env_name("hooks_dir"), &override_dir); install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None).unwrap(); @@ -1294,7 +1245,7 @@ mod tests { "scripts must land in the override dir, so wrote_any was true" ); let override_display = override_dir.display().to_string(); - let warns = crate::testing::warn_snapshot(); + let warns = testing::warn_snapshot(); assert!( warns.iter().any(|message| { message.contains("overrides the hooks dir") && message.contains(&override_display) @@ -1683,7 +1634,7 @@ mod tests { #[test] fn wildcard_whitelist_entries_skip_unknown_entry_diagnostics() { - crate::testing::install_log_collector(); + testing::install_log_collector(); let global = hooks_map("turn.started", &[("a", "cmd-a")]); let gate = vec![ "turn.started.*".to_string(), @@ -1701,7 +1652,7 @@ mod tests { ); assert_eq!(names(&resolved), ["a"]); - let debugs = crate::testing::debug_snapshot(); + let debugs = testing::debug_snapshot(); assert!( debugs .iter() @@ -1712,7 +1663,7 @@ mod tests { #[test] fn partial_glob_whitelist_entries_stay_flagged_as_unknown() { - crate::testing::install_log_collector(); + testing::install_log_collector(); let global = hooks_map("turn.started", &[("a", "cmd-a")]); let gate = vec!["turn.started.st*-marker-k4w".to_string()]; @@ -1725,7 +1676,7 @@ mod tests { ); assert!(resolved.is_empty(), "an invalid glob must admit nothing"); - let debugs = crate::testing::debug_snapshot(); + 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'") @@ -1734,7 +1685,7 @@ mod tests { #[test] fn partial_glob_over_event_prefix_logs_invalid_glob_diagnostic() { - crate::testing::install_log_collector(); + 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 @@ -1750,7 +1701,7 @@ mod tests { ); assert!(resolved.is_empty(), "a partial glob admits nothing"); - let debugs = crate::testing::debug_snapshot(); + let debugs = testing::debug_snapshot(); assert!(debugs.iter().any(|message| { message.contains("tool.st*") && message.contains("for agent 'prefix-glob-agent-t7c'") })); @@ -1758,7 +1709,7 @@ mod tests { #[test] fn inner_glob_entries_log_invalid_glob_diagnostic() { - crate::testing::install_log_collector(); + testing::install_log_collector(); let global = hooks_map("agent.completed", &[("mark", "cmd-mark")]); let gate = vec!["ag*.completed.mark".to_string()]; @@ -1771,7 +1722,7 @@ mod tests { ); assert!(resolved.is_empty(), "an inner glob admits nothing"); - let debugs = crate::testing::debug_snapshot(); + let debugs = testing::debug_snapshot(); assert!(debugs.iter().any(|message| { message.contains("ag*.completed.mark") && message.contains("for agent 'inner-glob-agent-m2r'") @@ -1780,7 +1731,7 @@ mod tests { #[test] fn unknown_whitelist_entries_log_debug_and_stay_inert() { - crate::testing::install_log_collector(); + testing::install_log_collector(); let global = hooks_map("turn.started", &[("a", "cmd-a")]); let gate = vec![ "turn.started.a".to_string(), @@ -1796,7 +1747,7 @@ mod tests { ); assert_eq!(names(&resolved), ["a"]); - let debugs = crate::testing::debug_snapshot(); + let debugs = testing::debug_snapshot(); assert!(debugs.iter().any(|message| { message.contains("turn.started.nonexistent-marker-xyz") && message.contains("for agent 'gate-agent-xyz'") @@ -1805,7 +1756,7 @@ mod tests { #[test] fn empty_global_map_short_circuits_whitelist_diagnostics() { - crate::testing::install_log_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()]; @@ -1819,7 +1770,7 @@ mod tests { ); assert_eq!(names(&resolved), ["role-only"]); - let debugs = crate::testing::debug_snapshot(); + let debugs = testing::debug_snapshot(); assert!( debugs .iter() @@ -2084,12 +2035,12 @@ mod tests { #[tokio::test(start_paused = true)] #[serial] async fn drain_pending_logs_when_the_timeout_expires() { - crate::testing::install_log_collector(); + testing::install_log_collector(); let pending = SpawnAckGuard::register(); drain_pending(Duration::from_millis(50)).await; - let debugs = crate::testing::debug_snapshot(); + let debugs = testing::debug_snapshot(); assert!( debugs .iter() @@ -2116,7 +2067,7 @@ mod tests { #[test] #[serial] fn fire_resolved_without_runtime_skips_instead_of_panicking() { - crate::testing::install_log_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(), @@ -2126,7 +2077,7 @@ mod tests { fire_resolved(HookEvent::TurnFailed, vec![hook], Vec::new(), &[], None); - let debugs = crate::testing::debug_snapshot(); + let debugs = testing::debug_snapshot(); assert!(debugs.iter().any(|message| { message.contains("no-runtime-marker-a7c") && message.contains("no Tokio runtime on this thread") @@ -2136,7 +2087,7 @@ mod tests { #[test] #[serial] fn fire_without_runtime_skips_instead_of_panicking() { - crate::testing::install_log_collector(); + testing::install_log_collector(); let ctx = ctx_with_global_hooks(hooks_map( "turn.failed", &[("fire-no-runtime-marker-b8d", "true")], @@ -2144,7 +2095,7 @@ mod tests { fire(HookEvent::TurnFailed, &ctx, &[], None); - let debugs = crate::testing::debug_snapshot(); + 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") @@ -2153,7 +2104,7 @@ mod tests { #[test] fn debug_capture_excludes_non_hooks_targets() { - crate::testing::install_log_collector(); + testing::install_log_collector(); let hook = ResolvedHook { name: "included-marker-k2v".to_string(), full_name: "turn.failed.included-marker-k2v".to_string(), @@ -2171,7 +2122,7 @@ mod tests { ); fire_resolved(HookEvent::TurnFailed, vec![hook], Vec::new(), &[], None); - let debugs = crate::testing::debug_snapshot(); + let debugs = testing::debug_snapshot(); assert!( debugs .iter() @@ -2192,9 +2143,9 @@ mod tests { #[test] #[serial] fn debug_capture_recovers_after_buffer_poisoning() { - crate::testing::install_log_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() @@ -2208,7 +2159,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| { @@ -2255,7 +2206,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] #[serial] async fn spawn_failure_removes_the_payload_file() { - crate::testing::install_log_collector(); + testing::install_log_collector(); let guard = TestConfigDirGuard::new("hooks-dispatch"); let payload_dir = guard.path.join("payloads"); create_dir_all(&payload_dir).unwrap(); @@ -2281,12 +2232,12 @@ mod tests { // does the empty directory below demonstrate the orphan unlink // rather than a payload file that never existed. wait_for("spawn failure log", || { - crate::testing::debug_snapshot().iter().any(|message| { + testing::debug_snapshot().iter().any(|message| { message.contains("Failed to spawn hook 'tool.started.spawnfail-marker-c9d'") }) }) .await; - let debugs = crate::testing::debug_snapshot(); + let debugs = testing::debug_snapshot(); assert!(debugs.iter().all(|message| { !(message.contains("spawnfail-marker-c9d") && message.contains("Failed to write payload file")) @@ -2305,10 +2256,10 @@ mod tests { #[tokio::test(flavor = "multi_thread")] #[serial] async fn error_paths_log_debug_only() { - crate::testing::install_log_collector(); + testing::install_log_collector(); let empty_cwd = env::temp_dir(); - let unique = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); let bad_cwd = env::temp_dir().join(format!("coyote-badcwd-marker-f5b-{unique}")); @@ -2330,7 +2281,7 @@ mod tests { fire_resolved(HookEvent::TurnFailed, hooks, Vec::new(), &[], None); drain_pending(Duration::from_secs(10)).await; - let debugs = crate::testing::debug_snapshot(); + let debugs = testing::debug_snapshot(); let empty_cwd_display = empty_cwd.display().to_string(); assert!(debugs.iter().any(|message| { message.contains("empty-marker-f5b") @@ -2342,7 +2293,7 @@ mod tests { message.contains("Failed to spawn hook 'turn.failed.badcwd-marker-f5b'") && message.contains(bad_cwd_display.as_str()) })); - let warns = crate::testing::warn_snapshot(); + let warns = testing::warn_snapshot(); assert!(warns.iter().all(|message| !message.contains("marker-f5b"))); } } @@ -2488,7 +2439,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] #[serial] async fn failing_hook_never_disturbs_the_engine() { - crate::testing::install_log_collector(); + testing::install_log_collector(); let guard = TestConfigDirGuard::new("hooks-dispatch"); let first = guard.path.join("first-out"); let second = guard.path.join("second-out"); @@ -2514,7 +2465,7 @@ mod tests { ); wait_for("second hook output", || second.exists()).await; - let warns = crate::testing::warn_snapshot(); + let warns = testing::warn_snapshot(); assert!( warns .iter() @@ -2558,7 +2509,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] #[serial] async fn payload_write_failure_still_runs_hook_without_payload_env() { - crate::testing::install_log_collector(); + 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")); @@ -2582,7 +2533,7 @@ mod tests { .lines() .all(|line| !line.starts_with("COYOTE_HOOK_PAYLOAD_FILE=")) ); - let debugs = crate::testing::debug_snapshot(); + let debugs = testing::debug_snapshot(); assert!(debugs.iter().any(|message| message.contains( "Failed to write payload file for hook 'tool.started.payload-fallback-marker'" ))); @@ -2674,7 +2625,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(); @@ -2818,18 +2769,15 @@ mod tests { #[test] #[serial] fn install_builtin_hooks_lands_under_a_backslashed_override() { - let unique = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + 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 = crate::testing::EnvVarGuard::set( - crate::utils::get_env_name("hooks_dir"), - &hooks_dir, - ); + let _env = testing::EnvVarGuard::set(utils::get_env_name("hooks_dir"), &hooks_dir); let result = install_builtin_hooks(InstallMode::Skip, &mut StickyMode::None); diff --git a/src/main.rs b/src/main.rs index fda9c6e..77b2d0a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -58,15 +58,6 @@ use std::path::PathBuf; use std::sync::atomic::Ordering; use std::{env, fs, process, sync::Arc}; -/// Stack headroom for the thread that polls the root async future, and for -/// the runtime's worker threads. -/// -/// Windows links the process main thread with a ~1 MiB stack (unix defaults -/// to 8 MiB), and the root future is a compiler-generated state machine -/// whose debug-build poll frames blow through 1 MiB the moment it is -/// polled — every binary invocation dies with STATUS_STACK_OVERFLOW -/// (0xC00000FD) before doing any work. 16 MiB gives comfortable headroom -/// over the deepest observed frames on every platform. const MAIN_STACK_SIZE: usize = 16 * 1024 * 1024; /// Bootstraps the async entry point on a dedicated thread with an explicit @@ -151,10 +142,6 @@ async fn async_main() -> Result<()> { || cli.list_macros || cli.list_skills || cli.list_sessions; - // Read-only whitelist: --sync-models is excluded because it downloads - // and writes models-override.yaml; combined with a readout flag the run - // still writes (run() handles --sync-models first), so it disqualifies - // the whole invocation. let readout_flag = (cli.info || cli.list_models || cli.list_roles @@ -191,10 +178,6 @@ async fn async_main() -> Result<()> { return config::list_installed_bundles(); } - // Inspection flags are look-don't-touch readouts: skip the builtins - // bootstrap so a pristine config dir stays empty. On such a dir the - // list flags list nothing because nothing is installed, by design. - // Agent/role runs still bootstrap: they load builtin tool definitions. 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 @@ -297,10 +280,6 @@ async fn async_main() -> Result<()> { } else { AppConfig::from_config(cfg)? }; - // Listing servers reads no secrets, so an unconfigured vault (no - // Listing servers reads no secrets, so an unconfigured vault (no - // password file) must not block the readout; every other MCP flag - // (get/remove/add) stays on the strict init. let vault = if mcp_inspect { Vault::init_lenient(&app_config) } else { @@ -351,9 +330,6 @@ async fn async_main() -> Result<()> { ); let mut ctx = RequestContext::bootstrap(app_state, working_mode, info_flag)?; let app_config = Arc::clone(&ctx.app.config); - // Rebuilding the tool scope prunes and builds function binaries; - // inspection-only runs are readouts that never execute tools, so skip - // it there too. if !inspection_only { ctx.bootstrap_tools(&app_config, start_mcp_servers, abort_signal.clone()) .await?; @@ -488,7 +464,7 @@ async fn run( // 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 + // actually committed, including the --macro arm, which executes // rather than inspects. } else { let app: Arc = Arc::clone(&ctx.app.config); @@ -622,8 +598,6 @@ async fn run( } } if let Some(name) = &cli.macro_name { - // Dispatch committed: open the top-level agent bracket only now, - // past every inspection-flag early return above. 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)); @@ -807,9 +781,6 @@ async fn shell_execute( if code == 0 && app.save_shell_history { let _ = append_to_shell_history(&shell.name, &eval_str, code); } - // Executing the generated command is a normal exit: no - // abort signal on purpose, this seam can never report an - // interruption. ctx.top_level_agent_finished(None, None); hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await; process::exit(code); diff --git a/src/repl/mod.rs b/src/repl/mod.rs index 218962d..58ab8b5 100644 --- a/src/repl/mod.rs +++ b/src/repl/mod.rs @@ -527,9 +527,6 @@ Type ".help" for additional help. } if let Some(supervisor) = self.ctx.read().supervisor.clone() { - // Leaving the loop is a deliberate exit, never an interruption: - // cancelling straggler children here does not latch the abort - // signal, so teardown still classifies as agent.completed. supervisor.read().cancel_recursive(); } @@ -1718,13 +1715,6 @@ fn reset_continuation(ctx: &mut RequestContext) { ctx.reset_continuation_count(); } -/// Ctrl-C at the prompt: cancel background work, but only latch the abort -/// signal when there was live work to cancel. An idle-prompt ctrl-c is a -/// step on the advertised exit path ("To exit, press Ctrl+D"), not an -/// interruption, so teardown after the following ctrl-d classifies the run -/// as completed. Mid-turn ctrl-c never passes through here -- it latches -/// the signal directly (wait_user_interrupt / poll_abort_signal), so an -/// interrupted turn followed by ctrl-d still reports agent.interrupted. pub(crate) fn latch_prompt_interrupt(abort_signal: &AbortSignal, supervisor: &RwLock) { let supervisor = supervisor.read(); if supervisor.has_active_tasks() { diff --git a/src/sandbox/mixins.rs b/src/sandbox/mixins.rs index 015b2e9..2c0fe64 100644 --- a/src/sandbox/mixins.rs +++ b/src/sandbox/mixins.rs @@ -197,11 +197,6 @@ pub fn discover() -> Result> { ) { out.push(read_mixin(path)?); } - // Hooks directories are deliberately scanned: hook scripts run inside the - // sandbox too, so a sidecar mixin next to one can declare its needs. The - // agents scan above never descends into `agents//hooks/`, and the - // `.builtin-manifest` living in these dirs is not matched by the - // `*.sbx-mixin.yaml` suffix filter. 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)?); diff --git a/src/supervisor/mod.rs b/src/supervisor/mod.rs index 7028637..0a6be9f 100644 --- a/src/supervisor/mod.rs +++ b/src/supervisor/mod.rs @@ -190,11 +190,6 @@ impl Supervisor { .count() } - /// True when any registered agent or job is still running -- i.e. a - /// cancellation right now would actually interrupt in-flight work. - /// Mirrors `cancel_recursive`'s traversal: work buried in a nested child - /// supervisor still counts, so a prompt ctrl-c that cancels only a - /// grandchild latches like any other interruption. pub fn has_active_tasks(&self) -> bool { self.effective_active_count() > 0 || self.active_job_count() > 0 diff --git a/src/vault/mod.rs b/src/vault/mod.rs index 4d187f2..82a9c78 100644 --- a/src/vault/mod.rs +++ b/src/vault/mod.rs @@ -332,8 +332,6 @@ impl Vault { } if cli.list_secrets { - // Listing is an inspection readout: report sandbox unavailability - // instead of failing like the mutating vault flags do. if vault.sandbox_mode { println!( "The vault is unavailable in sandbox mode. Use `coyote --list-secrets` on your host." From 04b983459b7530c1536e9840ec3457530f0693fc Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 17 Sep 2026 13:16:05 -0600 Subject: [PATCH 26/29] feat(TASK-061): add rag.sync.* and mcp.server.* hook events plus COYOTE_ROLE - Register five new events (26 -> 31): rag.sync.started/completed/failed and mcp.server.connected/failed, whitelist-gated for agent global hooks like every non-escalation event - Bracket every real RAG document sync with rag.sync.* via pre-resolved RagSyncHooks snapshots carried into the build funnels; cache hits, pure loads, and attaches fire nothing, and a user abort mid-sync counts as a failure - Fire mcp.server.connected only on real spawns in McpFactory::acquire (reusing a live server is silent), marking respawns of a previously live key with COYOTE_MCP_RECONNECT; mcp.server.failed carries COYOTE_ERROR and COYOTE_MCP_AUTH_REQUIRED on auth failures. Boot-time registry connects deliberately fire nothing - Carry COYOTE_ROLE on session.* and turn.* events, resolved at fire time so mid-session role switches and temp roles are reflected --- src/config/agent.rs | 18 ++- src/config/mcp_factory.rs | 265 +++++++++++++++++++++++++++++++-- src/config/rag_cache.rs | 58 ++++++++ src/config/request_context.rs | 50 ++++++- src/function/agents.rs | 3 +- src/hooks.rs | 271 +++++++++++++++++++++++++++++++++- src/mcp/mod.rs | 4 + src/rag/mod.rs | 217 ++++++++++++++++++++++++++- src/repl/mod.rs | 137 +++++++++++++++-- 9 files changed, 986 insertions(+), 37 deletions(-) diff --git a/src/config/agent.rs b/src/config/agent.rs index 358d256..0451902 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, @@ -356,6 +356,16 @@ impl Agent { } }; + // Resolved from the config under construction: no context carries + // this agent yet, but its whitelist gate and hooks must still apply + // to the RAG builds below. + 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(); @@ -388,6 +398,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 { @@ -398,6 +409,7 @@ impl Agent { &document_paths, abort, true, + sync_hooks, ) .await }) @@ -421,6 +433,7 @@ impl Agent { &loaders, info_flag, abort_signal.clone(), + &rag_sync_hooks, ) .await? } @@ -1303,6 +1316,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 { @@ -1379,6 +1393,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 { @@ -1389,6 +1404,7 @@ async fn init_graph_rags( &document_paths, &config, abort, + sync_hooks, ) .await }) diff --git a/src/config/mcp_factory.rs b/src/config/mcp_factory.rs index 23dba66..3405e0e 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, }; @@ -95,34 +96,91 @@ impl McpFactory { name: &str, spec: &McpServer, log_path: Option<&Path>, + hooks: &McpServerHooks, ) -> Result> { let key = McpServerKey::from_spec(name, spec); + // Reuse of a live server fires no hooks; only a real spawn below + // reports anything. 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 + // The live probe failed, so an entry still present for this key is a + // dead weak: the key was connected earlier in this process and the + // spawn below is a reconnect. + let reconnect = self.active.lock().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( @@ -321,4 +379,189 @@ mod tests { 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/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 0f791a3..ae47738 100644 --- a/src/config/request_context.rs +++ b/src/config/request_context.rs @@ -1077,7 +1077,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(); @@ -4406,6 +4411,7 @@ impl RequestContext { if !server_ids.is_empty() { let app_ref = &self.app; + let mcp_hooks = hooks::McpServerHooks::resolve(self); let acquire_all = async { let mut handles = Vec::new(); let mut auth_required = Vec::new(); @@ -4413,7 +4419,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)), @@ -4738,9 +4744,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, &[], None); + hooks::fire( + HookEvent::SessionResumed, + self, + &hooks::role_extras(self), + None, + ); } Ok(()) } @@ -5334,6 +5350,7 @@ impl RequestContext { let vault = self.app.vault.clone(); let rag_cache = self.rag_cache(); let working_mode = self.working_mode; + let sync_hooks = hooks::RagSyncHooks::resolve(self); let (rag, rag_key): (Arc, Option) = match rag { None => { @@ -5352,6 +5369,7 @@ impl RequestContext { &[], abort_signal.clone(), false, + sync_hooks, ) .await?, ), @@ -5373,8 +5391,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 } @@ -5458,6 +5484,7 @@ impl RequestContext { false, &self.app.config, abort_signal, + hooks::RagSyncHooks::resolve(self), ) .await?; self.rag = Some(Arc::new(rag)); @@ -5488,8 +5515,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, + hooks::RagSyncHooks::resolve(self), + ) + .await?; self.rag = Some(Arc::new(rag)); Ok(()) } diff --git a/src/function/agents.rs b/src/function/agents.rs index e1b92fe..24b2063 100644 --- a/src/function/agents.rs +++ b/src/function/agents.rs @@ -961,10 +961,11 @@ async fn populate_agent_mcp_runtime(ctx: &mut RequestContext, server_ids: &[Stri }) .unwrap_or_default(); + let mcp_hooks = 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); } diff --git a/src/hooks.rs b/src/hooks.rs index efb19c6..7c76a0a 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -132,6 +132,11 @@ pub enum HookEvent { JobStarted, JobCompleted, JobFailed, + RagSyncStarted, + RagSyncCompleted, + RagSyncFailed, + McpServerConnected, + McpServerFailed, } impl HookEvent { @@ -163,10 +168,15 @@ impl HookEvent { 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; 26] = [ + const ALL: [HookEvent; 31] = [ HookEvent::TurnStarted, HookEvent::TurnCompleted, HookEvent::TurnInterrupted, @@ -193,6 +203,11 @@ impl HookEvent { HookEvent::JobStarted, HookEvent::JobCompleted, HookEvent::JobFailed, + HookEvent::RagSyncStarted, + HookEvent::RagSyncCompleted, + HookEvent::RagSyncFailed, + HookEvent::McpServerConnected, + HookEvent::McpServerFailed, ]; } @@ -208,6 +223,191 @@ pub struct ResolvedHook { pub cwd: PathBuf, } +/// Pre-resolved `rag.sync.*` hooks plus the names `base_envs_parts` needs, +/// carried by value into the context-free RAG build funnels (`Rag::init`, +/// `Rag::init_with_config`, `Rag::refresh_document_paths`). Resolution +/// happens where the caller's identity is known; dispatch happens inside the +/// funnel, bracketing the one real sync it performs — so cache hits, pure +/// loads, and attaches (which never enter a funnel) fire nothing. +#[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()), + } + } + + /// Resolution for `Agent::init`, which builds agent and graph-node RAGs + /// before any context carries the agent: the whitelist gate and the + /// agent's own hooks come straight from the config being constructed. + /// No session or role exists at that point. + 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())], + ); + } + + /// A user abort mid-sync counts as a failure: the funnel's sync came + /// back `Err` either way, and the knowledge base was not (re)built. + pub fn fire_failed(&self, rag_name: &str, rag_path: &str, error: &anyhow::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); + } +} + +/// Pre-resolved `mcp.server.*` hooks plus the names `base_envs_parts` needs, +/// carried into the context-free `McpFactory::acquire`. Dispatch happens at +/// the spawn itself — one event per real spawn — so handing back an +/// already-live server fires nothing. +#[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()), + } + } + + /// `reconnect` marks a spawn for a server key that was live earlier in + /// this process; the variable is omitted entirely on a first connect. + 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); + } + + /// `auth_required` marks an `McpAuthRequired` failure; the variable is + /// omitted on every other error. + pub fn fire_failed( + &self, + server: &str, + transport: &str, + error: &anyhow::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 @@ -510,6 +710,21 @@ pub(crate) fn base_envs_parts( envs } +/// `COYOTE_ROLE` extras for the `session.*` and `turn.*` fire sites — the +/// only two families that carry the variable. Resolved at fire time, because +/// roles change mid-session (`.role`, `.exit role`, temp roles): the role +/// held directly on the context wins, then the name of a role a session has +/// absorbed. Derived roles have no name and report nothing. +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); @@ -1405,6 +1620,11 @@ mod tests { (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); @@ -1416,6 +1636,55 @@ mod tests { 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() + ); + } + } + #[test] fn root_context_gets_all_global_hooks() { let mut global = hooks_map("turn.started", &[("a", "cmd-a"), ("b", "cmd-b")]); diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 72f3930..fe3059a 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -401,6 +401,10 @@ impl McpRegistry { Ok(()) } + /// Boot-time connects fire no `mcp.server.*` hooks: this runs from + /// `AppState::new`, before any `RequestContext` exists to resolve hook + /// scopes against. Hook-observed connects go through + /// `McpFactory::acquire`. async fn start_server(&self, id: String) -> Result)>> { let spec = self .config diff --git a/src/rag/mod.rs b/src/rag/mod.rs index 6f5c627..5c171eb 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,12 +156,18 @@ 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?; + .await; + if let Err(err) = synced { + sync_hooks.fire_failed(&rag.name, &rag.path, &err); + return Err(err); + } + sync_hooks.fire_completed(&rag.name, &rag.path, rag.file_count()); if rag.save()? { println!("✓ Saved RAG to '{}'.", save_path.display()); } @@ -261,6 +269,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,12 +314,18 @@ 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?; + .await; + if let Err(err) = synced { + sync_hooks.fire_failed(&rag.name, &rag.path, &err); + return Err(err); + } + sync_hooks.fire_completed(&rag.name, &rag.path, rag.file_count()); if rag.save()? { println!("✓ Saved RAG to '{}'.", save_path.display()); } @@ -636,10 +651,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,7 +667,12 @@ impl Rag { spinner_rx, abort_signal, ) - .await?; + .await; + if let Err(err) = synced { + sync_hooks.fire_failed(&self.name, &self.path, &err); + return Err(err); + } + sync_hooks.fire_completed(&self.name, &self.path, self.file_count()); if self.save()? { println!("✓ Saved rag to '{}'.", self.path); } @@ -2280,6 +2302,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 +2337,187 @@ 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() + } + } + + 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 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 58ab8b5..4e46b59 100644 --- a/src/repl/mod.rs +++ b/src/repl/mod.rs @@ -68,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(()) } @@ -79,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 @@ -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}"), } } @@ -2018,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; @@ -2165,6 +2184,64 @@ 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_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 @@ -2312,6 +2389,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() { From dc2f26d0e6e123ef3ba0658202307f66ae1a4b60 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 17 Sep 2026 13:43:16 -0600 Subject: [PATCH 27/29] feat(TASK-061): fix reconnect-flag race and completed-after-save hook ordering - McpFactory::acquire now reads the live probe and the reconnect flag under one lock section so a racing first connect cannot be misreported as COYOTE_MCP_RECONNECT=true; the residual window across the spawn await (no singleflight) is documented. try_get_active, now unused, is removed. - All three RAG sync funnels (init, init_with_config, refresh_document_paths) fire rag.sync.completed only after a successful save(), matching the documented contract; a save error routes through rag.sync.failed with COYOTE_ERROR before propagating. - New funnel-level tests pin init_with_config's started-then-completed ordering, the failed event on a save error, and the failed event on the sync Err arm that a user abort surfaces through. --- src/config/mcp_factory.rs | 45 +++------ src/rag/mod.rs | 192 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 198 insertions(+), 39 deletions(-) diff --git a/src/config/mcp_factory.rs b/src/config/mcp_factory.rs index 3405e0e..aaa4b14 100644 --- a/src/config/mcp_factory.rs +++ b/src/config/mcp_factory.rs @@ -81,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)); @@ -101,15 +96,21 @@ impl McpFactory { let key = McpServerKey::from_spec(name, spec); // Reuse of a live server fires no hooks; only a real spawn below - // reports anything. - if let Some(existing) = self.try_get_active(&key) { - return Ok(existing); - } - - // The live probe failed, so an entry still present for this key is a - // dead weak: the key was connected earlier in this process and the - // spawn below is a reconnect. - let reconnect = self.active.lock().contains_key(&key); + // reports anything. The live probe and the reconnect read share one + // lock section so a racing first connect that inserts between them + // cannot be misreported as a reconnect. The spawn await below stays + // outside the lock, so two racing first connects may still both + // spawn (no singleflight); the loser then reports a reconnect. + let reconnect = { + let map = self.active.lock(); + if let Some(existing) = map.get(&key).and_then(|weak| weak.upgrade()) { + return Ok(existing); + } + // The live probe failed, so an entry still present for this key + // is a dead weak: the key was connected earlier in this process + // and the spawn below is a reconnect. + map.contains_key(&key) + }; let transport = transport_label(&spec.transport_type); let handle = match spawn_server(name, spec, log_path).await { @@ -357,22 +358,6 @@ 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(); diff --git a/src/rag/mod.rs b/src/rag/mod.rs index 5c171eb..129962d 100644 --- a/src/rag/mod.rs +++ b/src/rag/mod.rs @@ -167,10 +167,17 @@ impl Rag { sync_hooks.fire_failed(&rag.name, &rag.path, &err); return Err(err); } - sync_hooks.fire_completed(&rag.name, &rag.path, rag.file_count()); - if rag.save()? { - println!("✓ Saved RAG to '{}'.", save_path.display()); + // rag.sync.completed promises a saved knowledge base, so it fires + // only after a successful save; a save error is a sync failure. + 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) } @@ -325,10 +332,17 @@ impl Rag { sync_hooks.fire_failed(&rag.name, &rag.path, &err); return Err(err); } - sync_hooks.fire_completed(&rag.name, &rag.path, rag.file_count()); - if rag.save()? { - println!("✓ Saved RAG to '{}'.", save_path.display()); + // rag.sync.completed promises a saved knowledge base, so it fires + // only after a successful save; a save error is a sync failure. + 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) } @@ -672,10 +686,17 @@ impl Rag { sync_hooks.fire_failed(&self.name, &self.path, &err); return Err(err); } - sync_hooks.fire_completed(&self.name, &self.path, self.file_count()); - if self.save()? { - println!("✓ Saved rag to '{}'.", self.path); + // rag.sync.completed promises a saved knowledge base, so it fires + // only after a successful save; a save error is a sync failure. + 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(()) } @@ -2377,6 +2398,32 @@ mod tests { } } + /// 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()), @@ -2499,6 +2546,133 @@ mod tests { ); } + #[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() { From 74e7bc54eb764a1d65d838bc931c9ab55a960d45 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 17 Sep 2026 14:00:07 -0600 Subject: [PATCH 28/29] feat(TASK-061): assert COYOTE_ROLE on all session/turn events; fix two stale comments Cover the remaining role-carrying events with test_sink assertions: session.started (ctx-held role), session.resumed and session.ended (session-held role), turn.interrupted and turn.failed. Correct the mcp_factory race comment (the loser fires a second plain connected and overwrites the winner's map entry, not a reconnect) and the role_extras doc (derived roles fall through to the session-held role). --- src/config/mcp_factory.rs | 4 ++- src/config/request_context.rs | 33 +++++++++++++++++++--- src/hooks.rs | 3 +- src/repl/mod.rs | 53 +++++++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 6 deletions(-) diff --git a/src/config/mcp_factory.rs b/src/config/mcp_factory.rs index aaa4b14..5153d68 100644 --- a/src/config/mcp_factory.rs +++ b/src/config/mcp_factory.rs @@ -100,7 +100,9 @@ impl McpFactory { // lock section so a racing first connect that inserts between them // cannot be misreported as a reconnect. The spawn await below stays // outside the lock, so two racing first connects may still both - // spawn (no singleflight); the loser then reports a reconnect. + // spawn (no singleflight); the loser then fires a second plain + // `connected` (no RECONNECT flag) and its insert overwrites the + // winner's map entry. let reconnect = { let map = self.active.lock(); if let Some(existing) = map.get(&key).and_then(|weak| weak.upgrade()) { diff --git a/src/config/request_context.rs b/src/config/request_context.rs index ae47738..271fd0d 100644 --- a/src/config/request_context.rs +++ b/src/config/request_context.rs @@ -8427,6 +8427,7 @@ mod tests { 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(); @@ -8441,10 +8442,21 @@ 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 session.resumed, never - // session.started. - write_paused_todo_session(&ctx, "hook-resume"); + // 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", "session.resumed"], marker) @@ -8466,6 +8478,11 @@ mod tests { 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" + ); } #[test] @@ -8482,14 +8499,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] diff --git a/src/hooks.rs b/src/hooks.rs index 7c76a0a..eaa5c65 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -714,7 +714,8 @@ pub(crate) fn base_envs_parts( /// only two families that carry the variable. Resolved at fire time, because /// roles change mid-session (`.role`, `.exit role`, temp roles): the role /// held directly on the context wins, then the name of a role a session has -/// absorbed. Derived roles have no name and report nothing. +/// absorbed. Derived roles have no name; resolution then falls through to +/// the session-held role, if any. pub fn role_extras(ctx: &RequestContext) -> Vec<(&'static str, String)> { ctx.role .as_ref() diff --git a/src/repl/mod.rs b/src/repl/mod.rs index 4e46b59..9751a5a 100644 --- a/src/repl/mod.rs +++ b/src/repl/mod.rs @@ -2216,6 +2216,59 @@ mod tests { } } + #[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() { From 08dfbac6f61fe231eb261c2836df4eeaac950af7 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 17 Sep 2026 14:39:33 -0600 Subject: [PATCH 29/29] style: applied uniform style across the new rag and mcp hook implementation --- src/config/agent.rs | 3 --- src/config/mcp_factory.rs | 11 ---------- src/config/request_context.rs | 9 +++++---- src/function/agents.rs | 3 ++- src/hooks.rs | 38 +++-------------------------------- src/mcp/mod.rs | 4 ---- src/rag/mod.rs | 9 +++------ 7 files changed, 13 insertions(+), 64 deletions(-) diff --git a/src/config/agent.rs b/src/config/agent.rs index 0451902..24b7417 100644 --- a/src/config/agent.rs +++ b/src/config/agent.rs @@ -356,9 +356,6 @@ impl Agent { } }; - // Resolved from the config under construction: no context carries - // this agent yet, but its whitelist gate and hooks must still apply - // to the RAG builds below. let rag_sync_hooks = RagSyncHooks::resolve_for_agent( &app.hooks, &agent_config.global_hooks, diff --git a/src/config/mcp_factory.rs b/src/config/mcp_factory.rs index 5153d68..c09f5f4 100644 --- a/src/config/mcp_factory.rs +++ b/src/config/mcp_factory.rs @@ -95,22 +95,11 @@ impl McpFactory { ) -> Result> { let key = McpServerKey::from_spec(name, spec); - // Reuse of a live server fires no hooks; only a real spawn below - // reports anything. The live probe and the reconnect read share one - // lock section so a racing first connect that inserts between them - // cannot be misreported as a reconnect. The spawn await below stays - // outside the lock, so two racing first connects may still both - // spawn (no singleflight); the loser then fires a second plain - // `connected` (no RECONNECT flag) and its insert overwrites the - // winner's map entry. let reconnect = { let map = self.active.lock(); if let Some(existing) = map.get(&key).and_then(|weak| weak.upgrade()) { return Ok(existing); } - // The live probe failed, so an entry still present for this key - // is a dead weak: the key was connected earlier in this process - // and the spawn below is a reconnect. map.contains_key(&key) }; diff --git a/src/config/request_context.rs b/src/config/request_context.rs index 271fd0d..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}; @@ -4411,7 +4412,7 @@ impl RequestContext { if !server_ids.is_empty() { let app_ref = &self.app; - let mcp_hooks = hooks::McpServerHooks::resolve(self); + let mcp_hooks = McpServerHooks::resolve(self); let acquire_all = async { let mut handles = Vec::new(); let mut auth_required = Vec::new(); @@ -5350,7 +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 = hooks::RagSyncHooks::resolve(self); + let sync_hooks = RagSyncHooks::resolve(self); let (rag, rag_key): (Arc, Option) = match rag { None => { @@ -5484,7 +5485,7 @@ impl RequestContext { false, &self.app.config, abort_signal, - hooks::RagSyncHooks::resolve(self), + RagSyncHooks::resolve(self), ) .await?; self.rag = Some(Arc::new(rag)); @@ -5521,7 +5522,7 @@ impl RequestContext { true, &self.app.config, abort_signal, - hooks::RagSyncHooks::resolve(self), + RagSyncHooks::resolve(self), ) .await?; self.rag = Some(Arc::new(rag)); diff --git a/src/function/agents.rs b/src/function/agents.rs index 24b2063..3083d24 100644 --- a/src/function/agents.rs +++ b/src/function/agents.rs @@ -16,6 +16,7 @@ use crate::graph; use crate::repl::DEFAULT_CONTINUATION_PROMPT; use anyhow::{Context, Result, anyhow, bail}; use chrono::Utc; +use hooks::McpServerHooks; use indexmap::IndexMap; use log::{debug, warn}; use parking_lot::RwLock; @@ -961,7 +962,7 @@ async fn populate_agent_mcp_runtime(ctx: &mut RequestContext, server_ids: &[Stri }) .unwrap_or_default(); - let mcp_hooks = hooks::McpServerHooks::resolve(ctx); + let mcp_hooks = McpServerHooks::resolve(ctx); for (id, spec) in server_specs { let handle = app .mcp_factory diff --git a/src/hooks.rs b/src/hooks.rs index eaa5c65..f0cf11a 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -4,7 +4,7 @@ use crate::config::{ }; 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}; @@ -223,12 +223,6 @@ pub struct ResolvedHook { pub cwd: PathBuf, } -/// Pre-resolved `rag.sync.*` hooks plus the names `base_envs_parts` needs, -/// carried by value into the context-free RAG build funnels (`Rag::init`, -/// `Rag::init_with_config`, `Rag::refresh_document_paths`). Resolution -/// happens where the caller's identity is known; dispatch happens inside the -/// funnel, bracketing the one real sync it performs — so cache hits, pure -/// loads, and attaches (which never enter a funnel) fire nothing. #[derive(Debug, Clone, Default)] pub struct RagSyncHooks { started: Vec, @@ -252,10 +246,6 @@ impl RagSyncHooks { } } - /// Resolution for `Agent::init`, which builds agent and graph-node RAGs - /// before any context carries the agent: the whitelist gate and the - /// agent's own hooks come straight from the config being constructed. - /// No session or role exists at that point. pub fn resolve_for_agent( global_hooks: &HooksMap, gate: &[String], @@ -300,9 +290,7 @@ impl RagSyncHooks { ); } - /// A user abort mid-sync counts as a failure: the funnel's sync came - /// back `Err` either way, and the knowledge base was not (re)built. - pub fn fire_failed(&self, rag_name: &str, rag_path: &str, error: &anyhow::Error) { + pub fn fire_failed(&self, rag_name: &str, rag_path: &str, error: &Error) { self.fire( HookEvent::RagSyncFailed, &self.failed, @@ -337,10 +325,6 @@ impl RagSyncHooks { } } -/// Pre-resolved `mcp.server.*` hooks plus the names `base_envs_parts` needs, -/// carried into the context-free `McpFactory::acquire`. Dispatch happens at -/// the spawn itself — one event per real spawn — so handing back an -/// already-live server fires nothing. #[derive(Debug, Clone, Default)] pub struct McpServerHooks { connected: Vec, @@ -362,8 +346,6 @@ impl McpServerHooks { } } - /// `reconnect` marks a spawn for a server key that was live earlier in - /// this process; the variable is omitted entirely on a first connect. pub fn fire_connected(&self, server: &str, transport: &str, reconnect: bool) { let mut extras = vec![ ("COYOTE_MCP_SERVER", server.to_string()), @@ -375,15 +357,7 @@ impl McpServerHooks { self.fire(HookEvent::McpServerConnected, &self.connected, &extras); } - /// `auth_required` marks an `McpAuthRequired` failure; the variable is - /// omitted on every other error. - pub fn fire_failed( - &self, - server: &str, - transport: &str, - error: &anyhow::Error, - auth_required: bool, - ) { + 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()), @@ -710,12 +684,6 @@ pub(crate) fn base_envs_parts( envs } -/// `COYOTE_ROLE` extras for the `session.*` and `turn.*` fire sites — the -/// only two families that carry the variable. Resolved at fire time, because -/// roles change mid-session (`.role`, `.exit role`, temp roles): the role -/// held directly on the context wins, then the name of a role a session has -/// absorbed. Derived roles have no name; resolution then falls through to -/// the session-held role, if any. pub fn role_extras(ctx: &RequestContext) -> Vec<(&'static str, String)> { ctx.role .as_ref() diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index fe3059a..72f3930 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -401,10 +401,6 @@ impl McpRegistry { Ok(()) } - /// Boot-time connects fire no `mcp.server.*` hooks: this runs from - /// `AppState::new`, before any `RequestContext` exists to resolve hook - /// scopes against. Hook-observed connects go through - /// `McpFactory::acquire`. async fn start_server(&self, id: String) -> Result)>> { let spec = self .config diff --git a/src/rag/mod.rs b/src/rag/mod.rs index 129962d..f88cca8 100644 --- a/src/rag/mod.rs +++ b/src/rag/mod.rs @@ -167,8 +167,7 @@ impl Rag { sync_hooks.fire_failed(&rag.name, &rag.path, &err); return Err(err); } - // rag.sync.completed promises a saved knowledge base, so it fires - // only after a successful save; a save error is a sync failure. + match rag.save() { Ok(true) => println!("✓ Saved RAG to '{}'.", save_path.display()), Ok(false) => {} @@ -332,8 +331,7 @@ impl Rag { sync_hooks.fire_failed(&rag.name, &rag.path, &err); return Err(err); } - // rag.sync.completed promises a saved knowledge base, so it fires - // only after a successful save; a save error is a sync failure. + match rag.save() { Ok(true) => println!("✓ Saved RAG to '{}'.", save_path.display()), Ok(false) => {} @@ -686,8 +684,7 @@ impl Rag { sync_hooks.fire_failed(&self.name, &self.path, &err); return Err(err); } - // rag.sync.completed promises a saved knowledge base, so it fires - // only after a successful save; a save error is a sync failure. + match self.save() { Ok(true) => println!("✓ Saved rag to '{}'.", self.path), Ok(false) => {}