style: applied improved styling to the whole of the hooks integration
This commit is contained in:
17 files changed
+91
-175
No files matched your search
+16
-22
@@ -547,10 +547,6 @@ pub async fn create_openai_compatible_client_config(
|
||||
Ok(Some((model, clients)))
|
||||
}
|
||||
|
||||
/// Base extras every `llm.request.*` event carries. The three transport
|
||||
/// functions below are the single seam family for these events: they cover
|
||||
/// every provider and every caller, so the `Client` trait and per-provider
|
||||
/// code stay uninstrumented.
|
||||
fn llm_request_extras(client: &dyn Client) -> Vec<(&'static str, String)> {
|
||||
vec![
|
||||
("COYOTE_LLM_PROVIDER", client.name().to_string()),
|
||||
@@ -567,10 +563,6 @@ fn llm_completed_extras(client: &dyn Client, started_at: Instant) -> Vec<(&'stat
|
||||
extras
|
||||
}
|
||||
|
||||
/// `aborted` marks a user interrupt; otherwise the error is `api_error` when
|
||||
/// it bottoms out in an [`ApiStatusError`] (which also yields
|
||||
/// `COYOTE_LLM_STATUS`), else `other`. Abort paths that produce no error
|
||||
/// object report `Aborted.`.
|
||||
fn llm_failed_extras(
|
||||
client: &dyn Client,
|
||||
err: Option<&anyhow::Error>,
|
||||
@@ -592,6 +584,7 @@ fn llm_failed_extras(
|
||||
err.map(|err| format!("{err:#}"))
|
||||
.unwrap_or_else(|| "Aborted.".to_string()),
|
||||
));
|
||||
|
||||
extras
|
||||
}
|
||||
|
||||
@@ -1136,6 +1129,7 @@ mod tests {
|
||||
|
||||
use super::super::access_token::{is_rejected, set_access_token};
|
||||
use crate::config::{AppState, WorkingMode};
|
||||
use anyhow::Error;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use tokio::time::Instant;
|
||||
@@ -1352,8 +1346,8 @@ mod tests {
|
||||
|
||||
/// Wrapped in `.context(...)` so every test below proves the downcast
|
||||
/// works through an anyhow context chain, as in the trait methods.
|
||||
fn api_status_error(status: u16) -> anyhow::Error {
|
||||
anyhow::Error::new(ApiStatusError {
|
||||
fn api_status_error(status: u16) -> Error {
|
||||
Error::new(ApiStatusError {
|
||||
status,
|
||||
message: format!("error (status: {status})"),
|
||||
})
|
||||
@@ -1709,8 +1703,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn error(&self) -> anyhow::Error {
|
||||
anyhow::Error::new(ApiStatusError {
|
||||
fn error(&self) -> Error {
|
||||
Error::new(ApiStatusError {
|
||||
status: self.status,
|
||||
message: format!("error (status: {})", self.status),
|
||||
})
|
||||
@@ -1757,7 +1751,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn llm_hooks_map(marker: &str) -> crate::hooks::HooksMap {
|
||||
fn llm_hooks_map(marker: &str) -> hooks::HooksMap {
|
||||
[
|
||||
"llm.request.started",
|
||||
"llm.request.completed",
|
||||
@@ -1787,8 +1781,8 @@ mod tests {
|
||||
ctx
|
||||
}
|
||||
|
||||
fn llm_captures(marker: &str) -> Vec<crate::hooks::test_sink::Capture> {
|
||||
crate::hooks::test_sink::snapshot()
|
||||
fn llm_captures(marker: &str) -> Vec<hooks::test_sink::Capture> {
|
||||
hooks::test_sink::snapshot()
|
||||
.into_iter()
|
||||
.filter(|capture| capture.hook_name == marker)
|
||||
.collect()
|
||||
@@ -1802,7 +1796,7 @@ mod tests {
|
||||
let input = Input::from_str(&ctx, "hi", None).unwrap();
|
||||
let mut client = paced_client(false);
|
||||
client.nonstream_replies = true;
|
||||
let _guard = crate::hooks::test_sink::install();
|
||||
let _guard = hooks::test_sink::install();
|
||||
|
||||
call_chat_completions(
|
||||
&input,
|
||||
@@ -1838,7 +1832,7 @@ mod tests {
|
||||
let mut ctx = llm_hooked_ctx(marker);
|
||||
let input = Input::from_str(&ctx, "hi", None).unwrap();
|
||||
let client = FailingStatusClient::new(429);
|
||||
let _guard = crate::hooks::test_sink::install();
|
||||
let _guard = hooks::test_sink::install();
|
||||
|
||||
let err = call_chat_completions(
|
||||
&input,
|
||||
@@ -1885,7 +1879,7 @@ mod tests {
|
||||
let mut ctx = llm_hooked_ctx(marker);
|
||||
let input = Input::from_str(&ctx, "hi", None).unwrap();
|
||||
let client = paced_client(false);
|
||||
let _guard = crate::hooks::test_sink::install();
|
||||
let _guard = hooks::test_sink::install();
|
||||
|
||||
call_chat_completions_streaming(&input, &client, &mut ctx, create_abort_signal())
|
||||
.await
|
||||
@@ -1911,7 +1905,7 @@ mod tests {
|
||||
tokio::time::sleep(PACED_GAP * PACED_DELTAS as u32 + Duration::from_secs(30)).await;
|
||||
trigger.set_ctrlc();
|
||||
});
|
||||
let _guard = crate::hooks::test_sink::install();
|
||||
let _guard = hooks::test_sink::install();
|
||||
|
||||
let err = call_chat_completions_streaming(&input, &client, &mut ctx, abort)
|
||||
.await
|
||||
@@ -1936,7 +1930,7 @@ mod tests {
|
||||
let mut ctx = llm_hooked_ctx(marker);
|
||||
let input = Input::from_str(&ctx, "hi", None).unwrap();
|
||||
let client = paced_client(false);
|
||||
let _guard = crate::hooks::test_sink::install();
|
||||
let _guard = hooks::test_sink::install();
|
||||
|
||||
call_chat_completions_streaming_quiet(&input, &client, &mut ctx, create_abort_signal())
|
||||
.await
|
||||
@@ -1969,7 +1963,7 @@ mod tests {
|
||||
tokio::time::sleep(PACED_GAP * PACED_DELTAS as u32 + Duration::from_secs(30)).await;
|
||||
trigger.set_ctrlc();
|
||||
});
|
||||
let _guard = crate::hooks::test_sink::install();
|
||||
let _guard = hooks::test_sink::install();
|
||||
|
||||
let err = call_chat_completions_streaming_quiet(&input, &client, &mut ctx, abort)
|
||||
.await
|
||||
@@ -1997,7 +1991,7 @@ mod tests {
|
||||
let mut ctx = llm_hooked_ctx(marker);
|
||||
let input = Input::from_str(&ctx, "hi", None).unwrap();
|
||||
let client = FailingStatusClient::new(500);
|
||||
let _guard = crate::hooks::test_sink::install();
|
||||
let _guard = hooks::test_sink::install();
|
||||
|
||||
let err =
|
||||
call_chat_completions_streaming_quiet(&input, &client, &mut ctx, create_abort_signal())
|
||||
|
||||
+1
-1
@@ -1489,7 +1489,7 @@ nodes:
|
||||
type: end
|
||||
output: done
|
||||
";
|
||||
let graph: graph::Graph = serde_yaml::from_str(yaml).unwrap();
|
||||
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
|
||||
|
||||
let config = AgentConfig::from_graph("g", &graph);
|
||||
|
||||
|
||||
@@ -807,7 +807,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn from_config_copies_hooks() {
|
||||
let mut hooks = crate::hooks::HooksMap::new();
|
||||
let mut hooks = HooksMap::new();
|
||||
hooks.insert(
|
||||
"turn.completed".to_string(),
|
||||
vec![crate::hooks::HookDef {
|
||||
@@ -845,7 +845,7 @@ mod tests {
|
||||
let env_name = get_env_name("hooks");
|
||||
let prev = env::var_os(&env_name);
|
||||
|
||||
let mut hooks = crate::hooks::HooksMap::new();
|
||||
let mut hooks = HooksMap::new();
|
||||
hooks.insert(
|
||||
"turn.completed".to_string(),
|
||||
vec![crate::hooks::HookDef {
|
||||
|
||||
@@ -15,16 +15,8 @@ use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// Hidden per-directory record of the files the builtin installer shipped
|
||||
/// there. Format: one plain filename per line (LF-separated, sorted); no
|
||||
/// path separators, and never the manifest itself. Written non-executable
|
||||
/// through the same atomic-write path as other installed files.
|
||||
pub(crate) const BUILTIN_MANIFEST_FILE: &str = ".builtin-manifest";
|
||||
|
||||
/// True when `name` aliases the manifest file on some real-world filesystem:
|
||||
/// the comparison ignores trailing dots/spaces (Windows strips them at
|
||||
/// creation) and folds Unicode case (case-insensitive APFS/NTFS volumes fold
|
||||
/// beyond ASCII, e.g. U+017F ſ → s).
|
||||
pub(crate) fn is_builtin_manifest_name(name: &str) -> bool {
|
||||
name.trim_end_matches(['.', ' '])
|
||||
.to_uppercase()
|
||||
@@ -36,8 +28,8 @@ pub(crate) fn is_builtin_manifest_name(name: &str) -> bool {
|
||||
/// absent from `shipped`. The manifest is then rewritten to the names this
|
||||
/// installer actually owns: files written this run (`written`), plus shipped
|
||||
/// names it already owned, plus stale files whose removal failed (kept so a
|
||||
/// later run retries). A shipped name the installer declined to write — a
|
||||
/// pre-existing user file with a colliding name — is never claimed.
|
||||
/// later run retries). A shipped name the installer declined to write (e.g., a
|
||||
/// pre-existing user file with a colliding name) is never claimed.
|
||||
///
|
||||
/// Failure modes degrade toward keeping files: a missing or unreadable
|
||||
/// manifest yields no deletions, and entries that are not plain filenames
|
||||
@@ -137,8 +129,9 @@ fn write_manifest(dir: &Path, names: &BTreeSet<String>) -> Result<()> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::utils;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn fresh_dir(label: &str) -> std::path::PathBuf {
|
||||
fn fresh_dir(label: &str) -> PathBuf {
|
||||
let dir = utils::temp_file(label, "");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
|
||||
@@ -2418,6 +2418,7 @@ fn print_secret_summary(added: &[String], deferred: &[String]) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config;
|
||||
use crate::config::builtin_manifest::BUILTIN_MANIFEST_FILE;
|
||||
use crate::sandbox::SANDBOX_ENV_FLAG;
|
||||
use crate::utils::get_env_name;
|
||||
@@ -2710,8 +2711,6 @@ mod tests {
|
||||
assert!(out.functions_tools.is_none() && out.mcp_json.is_none());
|
||||
}
|
||||
|
||||
/// `--filter hooks` used to be an inert arm that always produced an empty
|
||||
/// layout; it now selects the bundle's top-level hooks directory.
|
||||
#[test]
|
||||
fn apply_filter_hooks_keeps_only_hooks() {
|
||||
let l = RemoteLayout {
|
||||
@@ -3624,8 +3623,9 @@ mod tests {
|
||||
fn assert_executable(path: &Path) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = fs::metadata(path).unwrap().permissions().mode();
|
||||
assert!(
|
||||
mode & 0o111 != 0,
|
||||
assert_ne!(
|
||||
mode & 0o111,
|
||||
0,
|
||||
"{} must carry the executable bit",
|
||||
path.display()
|
||||
);
|
||||
@@ -3932,7 +3932,7 @@ mod tests {
|
||||
}
|
||||
let _guard = TestVaultConfigGuard::new("prov-builtin");
|
||||
|
||||
crate::config::install_assets(crate::config::AssetCategory::Macros).unwrap();
|
||||
config::install_assets(config::AssetCategory::Macros).unwrap();
|
||||
|
||||
assert!(fs::read_dir(paths::macros_dir()).unwrap().next().is_some());
|
||||
assert!(!paths::installed_bundles_file().exists());
|
||||
@@ -3957,7 +3957,7 @@ mod tests {
|
||||
|
||||
// Capture outcomes before restoring the env var so a failed assertion
|
||||
// cannot leave the hooks-dir override pointing at a deleted temp dir.
|
||||
let result = crate::config::install_assets(crate::config::AssetCategory::Hooks);
|
||||
let result = config::install_assets(AssetCategory::Hooks);
|
||||
let installed = (
|
||||
hooks_dir.join("notify.sh").is_file(),
|
||||
hooks_dir.join("log-events.sh").is_file(),
|
||||
|
||||
+3
-2
@@ -83,6 +83,7 @@ use crate::utils::*;
|
||||
pub use macros::macro_execute;
|
||||
|
||||
use crate::config::macros::Macro;
|
||||
use crate::hooks;
|
||||
use crate::vault::{
|
||||
GlobalVault, Vault, create_vault_password_file, interpolate_secrets, prompt_provider_choice,
|
||||
};
|
||||
@@ -420,7 +421,7 @@ pub fn install_builtins() -> Result<()> {
|
||||
Agent::install_builtin_agents(false)?;
|
||||
Macro::install_macros(false)?;
|
||||
Skill::install_builtin_skills(false)?;
|
||||
crate::hooks::install_builtin_hooks(false)?;
|
||||
hooks::install_builtin_hooks(false)?;
|
||||
Role::install_builtin_role_hooks(false)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -522,7 +523,7 @@ pub fn install_assets(category: AssetCategory) -> Result<()> {
|
||||
AssetCategory::Macros => Macro::install_macros(true)?,
|
||||
AssetCategory::Functions => Functions::install_builtin_global_tools(true)?,
|
||||
AssetCategory::Skills => Skill::install_builtin_skills(true)?,
|
||||
AssetCategory::Hooks => crate::hooks::install_builtin_hooks(true)?,
|
||||
AssetCategory::Hooks => hooks::install_builtin_hooks(true)?,
|
||||
AssetCategory::McpConfig => Functions::install_mcp_config()?,
|
||||
}
|
||||
|
||||
|
||||
@@ -4733,8 +4733,6 @@ impl RequestContext {
|
||||
}
|
||||
self.refresh_mcp_tool_filters();
|
||||
self.init_agent_session_variables(new_session)?;
|
||||
// NEW sessions only, and only once the session is fully engaged on
|
||||
// the context; resuming an existing session fires nothing.
|
||||
if created_new_session {
|
||||
hooks::fire(HookEvent::SessionStarted, self, &[], None);
|
||||
}
|
||||
@@ -4933,24 +4931,18 @@ impl RequestContext {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Opens the top-level `agent.*` bracket: fires `agent.started` once per
|
||||
/// agent-context lifetime when the ROOT context runs with a loaded agent
|
||||
/// (headless `--agent` dispatch, or entering an agent interactively).
|
||||
/// Spawned sub-agents are bracketed at their spawn seam instead, never
|
||||
/// here. Synthesizes and stores `COYOTE_AGENT_ID` under the
|
||||
/// `top-<agent-name>-<8-char alphanumeric nonce>` scheme; calling this
|
||||
/// again while the bracket is open, or without an agent, is a no-op —
|
||||
/// per-message granularity belongs to `turn.*`, not `agent.*`.
|
||||
pub fn top_level_agent_started(&mut self) {
|
||||
if self.top_level_agent_id.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(agent) = self.agent.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let nonce = Alphanumeric.sample_string(&mut rand::rng(), 8);
|
||||
let id = format!("top-{}-{nonce}", agent.name());
|
||||
self.top_level_agent_id = Some(id.clone());
|
||||
|
||||
hooks::fire(
|
||||
HookEvent::AgentStarted,
|
||||
self,
|
||||
@@ -4959,15 +4951,11 @@ impl RequestContext {
|
||||
);
|
||||
}
|
||||
|
||||
/// Closes the top-level `agent.*` bracket: fires `agent.completed`, or
|
||||
/// `agent.failed` carrying `COYOTE_AGENT_ERROR` when the run ended in an
|
||||
/// error. No-op unless [`Self::top_level_agent_started`] opened the
|
||||
/// bracket; the stored id is consumed so exactly one terminal event
|
||||
/// fires per lifetime.
|
||||
pub fn top_level_agent_finished(&mut self, error: Option<&Error>) {
|
||||
let Some(id) = self.top_level_agent_id.take() else {
|
||||
return;
|
||||
};
|
||||
|
||||
match error {
|
||||
None => hooks::fire(
|
||||
HookEvent::AgentCompleted,
|
||||
|
||||
+21
-34
@@ -1039,9 +1039,9 @@ Input 1
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn hooks_fixture_dir(label: &str) -> std::path::PathBuf {
|
||||
let dir = crate::utils::temp_file(label, "");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
fn hooks_fixture_dir(label: &str) -> PathBuf {
|
||||
let dir = temp_file(label, "");
|
||||
create_dir_all(&dir).unwrap();
|
||||
dir.join("hooks")
|
||||
}
|
||||
|
||||
@@ -1052,24 +1052,17 @@ Input 1
|
||||
install_and_reconcile_role_hooks(&dir, &fixture(&[("a.sh", "#!/bin/sh\n")]), false)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "#!/bin/sh\n");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.join("a.sh")).unwrap(),
|
||||
"#!/bin/sh\n"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE)).unwrap(),
|
||||
read_to_string(dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE)).unwrap(),
|
||||
"a.sh\n"
|
||||
);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = std::fs::metadata(dir.join("a.sh"))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode();
|
||||
let mode = fs::metadata(dir.join("a.sh")).unwrap().permissions().mode();
|
||||
assert_eq!(mode & 0o777, 0o755, "role hook scripts install executable");
|
||||
let manifest_mode =
|
||||
std::fs::metadata(dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE))
|
||||
let manifest_mode = fs::metadata(dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode();
|
||||
@@ -1079,28 +1072,22 @@ Input 1
|
||||
"the manifest must never be executable"
|
||||
);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(dir.parent().unwrap());
|
||||
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-");
|
||||
let shipped = fixture(&[("a.sh", "new content\n")]);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("a.sh"), "SENTINEL").unwrap();
|
||||
create_dir_all(&dir).unwrap();
|
||||
fs::write(dir.join("a.sh"), "SENTINEL").unwrap();
|
||||
|
||||
install_and_reconcile_role_hooks(&dir, &shipped, false).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.join("a.sh")).unwrap(),
|
||||
"SENTINEL"
|
||||
);
|
||||
assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "SENTINEL");
|
||||
|
||||
install_and_reconcile_role_hooks(&dir, &shipped, true).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.join("a.sh")).unwrap(),
|
||||
"new content\n"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(dir.parent().unwrap());
|
||||
assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "new content\n");
|
||||
let _ = fs::remove_dir_all(dir.parent().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1113,7 +1100,7 @@ Input 1
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(dir.join("user.sh"), "user-owned").unwrap();
|
||||
fs::write(dir.join("user.sh"), "user-owned").unwrap();
|
||||
|
||||
install_and_reconcile_role_hooks(&dir, &fixture(&[("keep.sh", "#!/bin/sh\n")]), false)
|
||||
.unwrap();
|
||||
@@ -1125,17 +1112,17 @@ Input 1
|
||||
assert!(dir.join("keep.sh").exists());
|
||||
assert!(dir.join("user.sh").exists(), "user file survives reconcile");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE)).unwrap(),
|
||||
read_to_string(dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE)).unwrap(),
|
||||
"keep.sh\n"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(dir.parent().unwrap());
|
||||
let _ = fs::remove_dir_all(dir.parent().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_hooks_never_claim_a_preexisting_user_file() {
|
||||
let dir = hooks_fixture_dir("role-hooks-unclaimed-");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("a.sh"), "user-owned").unwrap();
|
||||
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();
|
||||
@@ -1146,11 +1133,11 @@ Input 1
|
||||
|
||||
install_and_reconcile_role_hooks(&dir, &[], false).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.join("a.sh")).unwrap(),
|
||||
read_to_string(dir.join("a.sh")).unwrap(),
|
||||
"user-owned",
|
||||
"the user file survives the hook being dropped from the embed"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(dir.parent().unwrap());
|
||||
let _ = fs::remove_dir_all(dir.parent().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1160,6 +1147,6 @@ Input 1
|
||||
install_and_reconcile_role_hooks(&dir, &[], false).unwrap();
|
||||
|
||||
assert!(!dir.exists(), "an empty shipped set must not create hooks/");
|
||||
let _ = std::fs::remove_dir_all(dir.parent().unwrap());
|
||||
let _ = fs::remove_dir_all(dir.parent().unwrap());
|
||||
}
|
||||
}
|
||||
@@ -1288,7 +1288,7 @@ mod tests {
|
||||
fn session_deserialization_ignores_injected_role_hooks() {
|
||||
// A session file is attacker-writable data: a `role_hooks:` key
|
||||
// crafted into it must never deserialize into executable hook
|
||||
// config — hooks are only ever re-read from the role file itself.
|
||||
// config. Hooks are only ever re-read from the role file itself.
|
||||
let yaml = "model: ''\nmessages: []\nrole_hooks:\n session.started:\n - name: injected\n command: touch /tmp/pwned\n";
|
||||
let session: Session = serde_yaml::from_str(yaml).unwrap();
|
||||
assert!(
|
||||
|
||||
+12
-12
@@ -54,10 +54,6 @@ pub fn is_agent_task(supervisor: Option<&Arc<RwLock<Supervisor>>>, id: &str) ->
|
||||
|| supervisor.is_some_and(|sup| sup.read().has_agent(id))
|
||||
}
|
||||
|
||||
/// Terminal-event hooks for one job, resolved on the caller's context before
|
||||
/// the task is spawned (`build_env_snapshot` precedent) so the detached
|
||||
/// closure owns everything it fires with. Session and agent names are
|
||||
/// snapshotted because the context is gone by fire time.
|
||||
struct JobHookSnapshot {
|
||||
completed: Vec<ResolvedHook>,
|
||||
failed: Vec<ResolvedHook>,
|
||||
@@ -92,9 +88,11 @@ impl JobHookSnapshot {
|
||||
} else {
|
||||
(HookEvent::JobFailed, self.failed)
|
||||
};
|
||||
|
||||
if resolved.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let base_envs = hooks::base_envs_parts(
|
||||
event,
|
||||
self.session_name.as_deref(),
|
||||
@@ -104,9 +102,11 @@ impl JobHookSnapshot {
|
||||
("COYOTE_JOB_ID", self.job_id),
|
||||
("COYOTE_TOOL_NAME", self.tool),
|
||||
];
|
||||
|
||||
if let Some(error) = error {
|
||||
extras.push(("COYOTE_JOB_ERROR", error));
|
||||
}
|
||||
|
||||
hooks::fire_resolved(event, resolved, base_envs, &extras, None);
|
||||
}
|
||||
}
|
||||
@@ -3208,13 +3208,13 @@ mod tests {
|
||||
assert!(ctx.supervisor.is_none(), "no job may be spawned");
|
||||
}
|
||||
|
||||
fn job_hooks_map(marker: &str) -> crate::hooks::HooksMap {
|
||||
fn job_hooks_map(marker: &str) -> hooks::HooksMap {
|
||||
["job.started", "job.completed", "job.failed"]
|
||||
.into_iter()
|
||||
.map(|event| {
|
||||
(
|
||||
event.to_string(),
|
||||
vec![crate::hooks::HookDef {
|
||||
vec![hooks::HookDef {
|
||||
name: marker.to_string(),
|
||||
command: "true".to_string(),
|
||||
}],
|
||||
@@ -3230,8 +3230,8 @@ mod tests {
|
||||
RequestContext::new(app, WorkingMode::Cmd)
|
||||
}
|
||||
|
||||
fn job_captures(marker: &str) -> Vec<crate::hooks::test_sink::Capture> {
|
||||
crate::hooks::test_sink::snapshot()
|
||||
fn job_captures(marker: &str) -> Vec<hooks::test_sink::Capture> {
|
||||
hooks::test_sink::snapshot()
|
||||
.into_iter()
|
||||
.filter(|capture| capture.hook_name == marker)
|
||||
.collect()
|
||||
@@ -3245,7 +3245,7 @@ mod tests {
|
||||
let marker = "job-hooks-ok-x7x";
|
||||
let mut ctx = job_hooked_ctx(marker);
|
||||
ctx.declared_function_names.insert("echo".into());
|
||||
let _guard = crate::hooks::test_sink::install();
|
||||
let _guard = hooks::test_sink::install();
|
||||
|
||||
let started = handle_start(&mut ctx, &json!({"tool": "echo", "arguments": {}}))
|
||||
.await
|
||||
@@ -3300,7 +3300,7 @@ mod tests {
|
||||
let marker = "job-hooks-fail-x7x";
|
||||
let mut ctx = job_hooked_ctx(marker);
|
||||
ctx.declared_function_names.insert("false".into());
|
||||
let _guard = crate::hooks::test_sink::install();
|
||||
let _guard = hooks::test_sink::install();
|
||||
|
||||
let started = handle_start(&mut ctx, &json!({"tool": "false", "arguments": {}}))
|
||||
.await
|
||||
@@ -3344,7 +3344,7 @@ mod tests {
|
||||
filter.push_layer(LayerSource::Global, &[]);
|
||||
runtime.tool_filters.insert("fixture".to_string(), filter);
|
||||
ctx.tool_scope.mcp_runtime = runtime;
|
||||
let _guard = crate::hooks::test_sink::install();
|
||||
let _guard = hooks::test_sink::install();
|
||||
|
||||
let started = handle_start(
|
||||
&mut ctx,
|
||||
@@ -3382,7 +3382,7 @@ mod tests {
|
||||
fn rejected_job_start_fires_no_hooks() {
|
||||
let marker = "job-hooks-rejected-x7x";
|
||||
let mut ctx = job_hooked_ctx(marker);
|
||||
let _guard = crate::hooks::test_sink::install();
|
||||
let _guard = hooks::test_sink::install();
|
||||
|
||||
let result = run_async(handle_start(
|
||||
&mut ctx,
|
||||
|
||||
+11
-17
@@ -1539,12 +1539,6 @@ impl ToolCall {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Evaluates the call, firing `tool.*` hooks around it: `tool.started`
|
||||
/// once the name and arguments are resolved, then exactly one of
|
||||
/// `tool.completed` / `tool.failed`. A failure before resolution
|
||||
/// (unknown tool, malformed arguments) fires `tool.failed` alone. Hooks
|
||||
/// are pure observers — the returned value is exactly what the tool
|
||||
/// produced.
|
||||
pub async fn eval(&self, ctx: &mut RequestContext) -> Result<Value> {
|
||||
let result = self.eval_inner(ctx).await;
|
||||
if let Err(err) = &result {
|
||||
@@ -5308,13 +5302,13 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_hooks_map(marker: &str) -> crate::hooks::HooksMap {
|
||||
fn tool_hooks_map(marker: &str) -> hooks::HooksMap {
|
||||
["tool.started", "tool.completed", "tool.failed"]
|
||||
.into_iter()
|
||||
.map(|event| {
|
||||
(
|
||||
event.to_string(),
|
||||
vec![crate::hooks::HookDef {
|
||||
vec![hooks::HookDef {
|
||||
name: marker.to_string(),
|
||||
command: "true".to_string(),
|
||||
}],
|
||||
@@ -5334,8 +5328,8 @@ mod tests {
|
||||
RequestContext::new(Arc::new(app), WorkingMode::Cmd)
|
||||
}
|
||||
|
||||
fn tool_captures(marker: &str) -> Vec<crate::hooks::test_sink::Capture> {
|
||||
crate::hooks::test_sink::snapshot()
|
||||
fn tool_captures(marker: &str) -> Vec<hooks::test_sink::Capture> {
|
||||
hooks::test_sink::snapshot()
|
||||
.into_iter()
|
||||
.filter(|capture| capture.hook_name == marker)
|
||||
.collect()
|
||||
@@ -5351,7 +5345,7 @@ mod tests {
|
||||
config.auto_continue = true;
|
||||
});
|
||||
ctx.tool_scope.functions.append_todo_functions();
|
||||
let _guard = crate::hooks::test_sink::install();
|
||||
let _guard = hooks::test_sink::install();
|
||||
|
||||
let output =
|
||||
run_async(call_with_args("todo__init", json!({"goal": "g"})).eval(&mut ctx)).unwrap();
|
||||
@@ -5389,7 +5383,7 @@ mod tests {
|
||||
ctx.tool_scope
|
||||
.functions
|
||||
.append_mcp_meta_functions(vec![tools_only("fixture")]);
|
||||
let _guard = crate::hooks::test_sink::install();
|
||||
let _guard = hooks::test_sink::install();
|
||||
|
||||
let output = call_with_args("mcp_invoke_fixture", json!({"tool": "dup"}))
|
||||
.eval(&mut ctx)
|
||||
@@ -5424,7 +5418,7 @@ mod tests {
|
||||
parameters: JsonSchema::default(),
|
||||
agent: false,
|
||||
});
|
||||
let _guard = crate::hooks::test_sink::install();
|
||||
let _guard = hooks::test_sink::install();
|
||||
|
||||
let output = run_async(call_with_args("echo", json!({"x": 1})).eval(&mut ctx)).unwrap();
|
||||
|
||||
@@ -5454,7 +5448,7 @@ mod tests {
|
||||
parameters: JsonSchema::default(),
|
||||
agent: false,
|
||||
});
|
||||
let _guard = crate::hooks::test_sink::install();
|
||||
let _guard = hooks::test_sink::install();
|
||||
|
||||
let output = run_async(call_with_args("false", json!({})).eval(&mut ctx)).unwrap();
|
||||
|
||||
@@ -5485,7 +5479,7 @@ mod tests {
|
||||
let mut plain = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd);
|
||||
plain.tool_scope.functions.append_todo_functions();
|
||||
let call = call_with_args("todo__done", json!({}));
|
||||
let _guard = crate::hooks::test_sink::install();
|
||||
let _guard = hooks::test_sink::install();
|
||||
|
||||
let hooked_output = run_async(call.eval(&mut hooked)).unwrap();
|
||||
let plain_output = run_async(call.eval(&mut plain)).unwrap();
|
||||
@@ -5517,7 +5511,7 @@ mod tests {
|
||||
let marker = "tool-hooks-user-x7x";
|
||||
let mut ctx = ctx_with_tool_hooks(marker, |_| {});
|
||||
ctx.tool_scope.functions.append_user_interaction_functions();
|
||||
let _guard = crate::hooks::test_sink::install();
|
||||
let _guard = hooks::test_sink::install();
|
||||
|
||||
let output = run_async(call_with_args("user__confirm", json!({})).eval(&mut ctx)).unwrap();
|
||||
|
||||
@@ -5554,7 +5548,7 @@ mod tests {
|
||||
let mut hooked = ctx_with_tool_hooks(marker, |_| {});
|
||||
let mut plain = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd);
|
||||
let call = call_with_args("nonexistent_tool", json!({}));
|
||||
let _guard = crate::hooks::test_sink::install();
|
||||
let _guard = hooks::test_sink::install();
|
||||
|
||||
let hooked_err = run_async(call.eval(&mut hooked)).unwrap_err();
|
||||
let plain_err = run_async(call.eval(&mut plain)).unwrap_err();
|
||||
|
||||
@@ -36,9 +36,6 @@ impl AgentNodeExecutor {
|
||||
parent_ctx: &mut RequestContext,
|
||||
retire_peer_on_return: bool,
|
||||
) -> Result<AgentExecutionOutcome> {
|
||||
// The teammate identity doubles as the agent's id for observers; the
|
||||
// run consumes it off the ctx, so it is captured up front. Nodes
|
||||
// without one are identified by their node id.
|
||||
let agent_hook_id = parent_ctx
|
||||
.peer_assignment
|
||||
.as_ref()
|
||||
@@ -52,9 +49,6 @@ impl AgentNodeExecutor {
|
||||
retire_peer_on_return,
|
||||
)
|
||||
.await;
|
||||
// Fired at the point of failure, before a declared fallback converts
|
||||
// it into a successful route: observers must see the agent's failure
|
||||
// even when the node itself continues.
|
||||
if let Err(e) = &result {
|
||||
hooks::fire(
|
||||
HookEvent::AgentFailed,
|
||||
|
||||
+10
-16
@@ -514,12 +514,6 @@ fn provision_frontier_peers(
|
||||
(Some(registry), assignments)
|
||||
}
|
||||
|
||||
/// Pre-resolved `graph.node.completed`/`graph.node.failed` snapshots plus
|
||||
/// the metadata one branch task needs to fire them. Captured per branch
|
||||
/// before the branch is spawned: every parallel super-step branch owns its
|
||||
/// emitter, so node metadata never crosses between concurrent tasks and no
|
||||
/// task borrows the context it was forked from. Base envs are rebuilt at
|
||||
/// fire time so the timestamp reflects when the node actually finished.
|
||||
struct NodeHookEmitter {
|
||||
completed: Vec<ResolvedHook>,
|
||||
failed: Vec<ResolvedHook>,
|
||||
@@ -1152,8 +1146,8 @@ mod integration_tests {
|
||||
which::which(name).is_ok()
|
||||
}
|
||||
|
||||
fn hooks_config(entries: &[(&str, &str)]) -> crate::hooks::HooksMap {
|
||||
let mut map = crate::hooks::HooksMap::new();
|
||||
fn hooks_config(entries: &[(&str, &str)]) -> hooks::HooksMap {
|
||||
let mut map = hooks::HooksMap::new();
|
||||
for (event, name) in entries {
|
||||
map.entry((*event).to_string()).or_default().push(HookDef {
|
||||
name: (*name).to_string(),
|
||||
@@ -1163,7 +1157,7 @@ mod integration_tests {
|
||||
map
|
||||
}
|
||||
|
||||
fn ctx_with_global_hooks(hooks: crate::hooks::HooksMap) -> RequestContext {
|
||||
fn ctx_with_global_hooks(hooks: hooks::HooksMap) -> RequestContext {
|
||||
let mut app = AppState::test_default();
|
||||
app.config = Arc::new(AppConfig {
|
||||
hooks,
|
||||
@@ -1172,8 +1166,8 @@ mod integration_tests {
|
||||
RequestContext::new(Arc::new(app), WorkingMode::Cmd)
|
||||
}
|
||||
|
||||
fn hook_captures_named(name: &str) -> Vec<crate::hooks::test_sink::Capture> {
|
||||
crate::hooks::test_sink::snapshot()
|
||||
fn hook_captures_named(name: &str) -> Vec<hooks::test_sink::Capture> {
|
||||
hooks::test_sink::snapshot()
|
||||
.into_iter()
|
||||
.filter(|capture| capture.hook_name == name)
|
||||
.collect()
|
||||
@@ -1328,7 +1322,7 @@ nodes:
|
||||
/// node also yields `graph.node.completed`, never `graph.node.failed`.
|
||||
#[tokio::test]
|
||||
async fn agent_node_failure_with_fallback_fires_agent_failed_and_node_completed() {
|
||||
let _sink = crate::hooks::test_sink::install();
|
||||
let _sink = hooks::test_sink::install();
|
||||
let mut ctx = ctx_with_global_hooks(hooks_config(&[
|
||||
("agent.failed", "t037_fb_agent_failed"),
|
||||
("graph.node.completed", "t037_fb_node_completed"),
|
||||
@@ -1404,7 +1398,7 @@ nodes:
|
||||
eprintln!("skipping: bash not available");
|
||||
return;
|
||||
}
|
||||
let _sink = crate::hooks::test_sink::install();
|
||||
let _sink = hooks::test_sink::install();
|
||||
let ws = TestWorkspace::new();
|
||||
for script in [
|
||||
"dispatcher.sh",
|
||||
@@ -1512,7 +1506,7 @@ nodes:
|
||||
/// event stays gated off because the agent whitelisted nothing.
|
||||
#[tokio::test]
|
||||
async fn graph_agent_hooks_resolve_from_graph_yaml_top_level() {
|
||||
let _sink = crate::hooks::test_sink::install();
|
||||
let _sink = hooks::test_sink::install();
|
||||
let mut ctx =
|
||||
ctx_with_global_hooks(hooks_config(&[("graph.node.completed", "t037_gy_global")]));
|
||||
|
||||
@@ -1571,7 +1565,7 @@ nodes:
|
||||
/// failure then propagates out of the executor.
|
||||
#[tokio::test]
|
||||
async fn genuine_node_failure_fires_graph_node_failed_with_payload() {
|
||||
let _sink = crate::hooks::test_sink::install();
|
||||
let _sink = hooks::test_sink::install();
|
||||
let mut ctx = ctx_with_global_hooks(hooks_config(&[(
|
||||
"graph.node.failed",
|
||||
"t037_gf_node_failed",
|
||||
@@ -1626,7 +1620,7 @@ nodes:
|
||||
#[tokio::test]
|
||||
async fn malformed_graph_yaml_hook_is_skipped_without_breaking_the_node() {
|
||||
crate::testing::install_warn_collector();
|
||||
let _sink = crate::hooks::test_sink::install();
|
||||
let _sink = hooks::test_sink::install();
|
||||
let mut ctx = make_ctx();
|
||||
|
||||
let yaml = r#"
|
||||
|
||||
+1
-10
@@ -248,10 +248,6 @@ fn push_defs(event_name: &str, defs: &[HookDef], cwd: &Path, out: &mut Vec<Resol
|
||||
}
|
||||
}
|
||||
|
||||
/// Fires every hook configured for `event` in `ctx`. Fire-and-forget: each
|
||||
/// hook runs in its own detached task and nothing about its outcome — exit
|
||||
/// status, stdout, stderr — is awaited or read. `payload` carries the tool
|
||||
/// arguments JSON for `tool.*` events; pass `None` otherwise.
|
||||
#[allow(dead_code)]
|
||||
pub fn fire(
|
||||
event: HookEvent,
|
||||
@@ -263,6 +259,7 @@ pub fn fire(
|
||||
if resolved.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
fire_resolved(event, resolved, base_envs(event, ctx), extras, payload);
|
||||
}
|
||||
|
||||
@@ -407,9 +404,6 @@ pub(crate) fn base_envs_parts(
|
||||
envs
|
||||
}
|
||||
|
||||
/// Creates the payload file fresh (`create_new`) with owner-only permissions,
|
||||
/// so a pre-existing path — symlink or otherwise — is an error. On failure
|
||||
/// the hook still runs, just without `COYOTE_HOOK_PAYLOAD_FILE`.
|
||||
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);
|
||||
@@ -423,9 +417,6 @@ async fn write_payload_file(path: &Path, json: &str) -> std::io::Result<()> {
|
||||
#[cfg(test)]
|
||||
static PAYLOAD_DIR_OVERRIDE: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
|
||||
|
||||
/// Directory payload files are written to: `std::env::temp_dir()`, except in
|
||||
/// tests, where `PAYLOAD_DIR_OVERRIDE` redirects it without touching
|
||||
/// process-global state like `TMPDIR`.
|
||||
fn payload_dir() -> PathBuf {
|
||||
#[cfg(test)]
|
||||
if let Some(dir) = PAYLOAD_DIR_OVERRIDE
|
||||
|
||||
@@ -706,11 +706,6 @@ async fn shell_execute(
|
||||
if code == 0 && app.save_shell_history {
|
||||
let _ = append_to_shell_history(&shell.name, &eval_str, code);
|
||||
}
|
||||
// `process::exit` never returns, so the dispatch
|
||||
// wrapper's bracket close in `run()` is unreachable
|
||||
// from here: close the top-level agent bracket now.
|
||||
// The agent run itself succeeded — the exit code
|
||||
// belongs to the user's shell command, not to us.
|
||||
ctx.top_level_agent_finished(None);
|
||||
hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await;
|
||||
process::exit(code);
|
||||
@@ -760,9 +755,6 @@ async fn shell_execute(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The LLM portion of one shell-execute turn — everything the turn bracket
|
||||
/// measures. The interactive follow-up menu (execute/revise/describe/copy)
|
||||
/// runs after the turn has already completed.
|
||||
async fn shell_execute_turn(
|
||||
ctx: &mut RequestContext,
|
||||
input: &Input,
|
||||
|
||||
@@ -1695,11 +1695,6 @@ fn print_turn_divider() {
|
||||
println!();
|
||||
}
|
||||
|
||||
/// Auto-compression seam for the ask loop: `session.compressed` fires only
|
||||
/// when compression actually succeeded. A failed auto-compression is
|
||||
/// survivable mid-session, so the error is logged and swallowed rather
|
||||
/// than aborting the turn — unlike `.compress session`, which surfaces
|
||||
/// the failure to the user.
|
||||
async fn auto_compress_session(ctx: &mut RequestContext) {
|
||||
match ctx.compress_session().await {
|
||||
Ok(()) => hooks::fire(HookEvent::SessionCompressed, ctx, &[], None),
|
||||
|
||||
@@ -203,13 +203,6 @@ pub fn discover() -> Result<Vec<DiscoveredMixin>> {
|
||||
for path in collect_mixins(&paths::roles_dir(), &[ScanMode::Flat]) {
|
||||
out.push(read_mixin(path)?);
|
||||
}
|
||||
// Mirrors the role-hook working directory (role hook commands run with
|
||||
// cwd = roles_dir), so `./hooks/*.sbx-mixin.yaml` beside a role resolves
|
||||
// the same way the role's hook commands do — intentionally NOT
|
||||
// paths::hooks_dir(). The agent-hook working directory
|
||||
// (`<agents>/<agent>/hooks/` — agent hooks run with cwd = the agent's
|
||||
// data dir) is deliberately not scanned here either; agent-dir scanning
|
||||
// above covers `<agents>/<agent>/*.sbx-mixin.yaml` only.
|
||||
for path in collect_mixins(&paths::roles_dir().join("hooks"), &[ScanMode::Flat]) {
|
||||
out.push(read_mixin(path)?);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user