xz-agent-hooks 0.1.0

Lifecycle hook contract, ordered registry, and wire parsers for agent extension hosts
Documentation
//! External handler abstraction (command / HTTP / in-process).

use crate::contract::{HookEvent, HookEventKind, HookOutcome};
use crate::registry::HookHandler;
use crate::wire::parse_handler_output;
use async_trait::async_trait;
use std::sync::Arc;
use std::time::Duration;
use thiserror::Error;

/// Error from an external hook runner.
#[derive(Debug, Error)]
pub enum ExternalHookRunnerError {
    /// Timed out waiting for the handler.
    #[error("hook timed out after {0:?}")]
    Timeout(Duration),
    /// Spawn / I/O / process failure (fail-open at registry layer).
    #[error("hook I/O error: {0}")]
    Io(String),
}

/// Request passed to an external runner.
#[derive(Debug, Clone)]
pub struct ExternalHookRequest {
    /// Event payload (also JSON-serializable for stdin).
    pub event: HookEvent,
    /// Optional handler name for logging.
    pub name: String,
    /// Timeout for this invocation.
    pub timeout: Duration,
}

/// Runs one external hook implementation.
///
/// Products implement this for `sh -c`, HTTP POST, etc. The crate does **not**
/// hard-code shell paths or project directories.
#[async_trait]
pub trait ExternalHookRunner: Send + Sync {
    /// Execute the handler and return raw process-like results `(exit_code, stdout, stderr)`.
    async fn run_raw(
        &self,
        request: &ExternalHookRequest,
    ) -> Result<(i32, String, String), ExternalHookRunnerError>;

    /// Execute and parse into outcomes (default: JSON/exit protocol).
    async fn run(
        &self,
        request: &ExternalHookRequest,
    ) -> Result<Vec<HookOutcome>, ExternalHookRunnerError> {
        let (code, stdout, stderr) = self.run_raw(request).await?;
        Ok(parse_handler_output(code, &stdout, &stderr))
    }
}

/// Helper: serialize event JSON for stdin / env (products choose transport).
pub fn event_json(event: &HookEvent) -> Result<String, serde_json::Error> {
    serde_json::to_string(event)
}

/// Adapts an [`ExternalHookRunner`] into a [`HookHandler`] for the registry.
pub struct ExternalHookHandler {
    name: String,
    kinds: Vec<HookEventKind>,
    matcher: Option<String>,
    timeout: Duration,
    runner: Arc<dyn ExternalHookRunner>,
}

impl ExternalHookHandler {
    /// Create a handler that delegates to `runner`.
    pub fn new(
        name: impl Into<String>,
        kinds: Vec<HookEventKind>,
        runner: Arc<dyn ExternalHookRunner>,
    ) -> Self {
        Self {
            name: name.into(),
            kinds,
            matcher: None,
            timeout: Duration::from_secs(30),
            runner,
        }
    }

    /// Restrict to tools matching `pattern`.
    pub fn with_tool_matcher(mut self, pattern: impl Into<String>) -> Self {
        self.matcher = Some(pattern.into());
        self
    }

    /// Override timeout.
    pub fn with_timeout(mut self, timeout: Duration) -> Self {
        self.timeout = timeout;
        self
    }
}

#[async_trait]
impl HookHandler for ExternalHookHandler {
    fn name(&self) -> &str {
        &self.name
    }

    fn event_kinds(&self) -> &[HookEventKind] {
        &self.kinds
    }

    fn tool_matcher(&self) -> Option<&str> {
        self.matcher.as_deref()
    }

    async fn on_event(&self, event: &HookEvent) -> Result<Vec<HookOutcome>, String> {
        let req = ExternalHookRequest {
            event: event.clone(),
            name: self.name.clone(),
            timeout: self.timeout,
        };
        match self.runner.run(&req).await {
            Ok(outcomes) => Ok(outcomes),
            // Fail-open: surface as empty outcomes with error string for registry.errors
            // Actually registry expects Err for fail-open logging — return Err
            Err(e) => Err(e.to_string()),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::contract::{HookEvent, MergeMode};
    use crate::registry::HookRegistry;
    use serde_json::json;
    use std::sync::Mutex;

    struct ScriptedRunner {
        code: i32,
        stdout: String,
        stderr: String,
        calls: Mutex<u32>,
        fail_io: bool,
    }

    #[async_trait]
    impl ExternalHookRunner for ScriptedRunner {
        async fn run_raw(
            &self,
            _request: &ExternalHookRequest,
        ) -> Result<(i32, String, String), ExternalHookRunnerError> {
            if let Ok(mut c) = self.calls.lock() {
                *c += 1;
            }
            if self.fail_io {
                return Err(ExternalHookRunnerError::Io("spawn failed".into()));
            }
            Ok((self.code, self.stdout.clone(), self.stderr.clone()))
        }
    }

    #[tokio::test]
    async fn external_handler_mutate_via_wire() {
        let body = json!({
            "hookSpecificOutput": {
                "permissionDecision": "allow",
                "updatedInput": { "command": "rtk ls" }
            }
        })
        .to_string();
        let runner = Arc::new(ScriptedRunner {
            code: 0,
            stdout: body,
            stderr: String::new(),
            calls: Mutex::new(0),
            fail_io: false,
        });
        let handler = ExternalHookHandler::new(
            "rtk-like",
            vec![HookEventKind::PreTool],
            runner.clone(),
        )
        .with_tool_matcher("shell|*");

        let mut reg = HookRegistry::new();
        reg.register(Box::new(handler));
        let r = reg
            .fire_pre_tool(&HookEvent::pre_tool("shell", json!({"command": "ls"})))
            .await;
        assert!(r.errors.is_empty());
        let args = r
            .effect
            .as_pre_tool()
            .and_then(|e| e.args.clone());
        assert_eq!(args, Some(json!({"command": "rtk ls"})));
        assert_eq!(*runner.calls.lock().unwrap_or_else(|e| e.into_inner()), 1);
    }

    #[tokio::test]
    async fn external_handler_io_error_fail_open() {
        let runner = Arc::new(ScriptedRunner {
            code: 0,
            stdout: String::new(),
            stderr: String::new(),
            calls: Mutex::new(0),
            fail_io: true,
        });
        let mut reg = HookRegistry::new();
        reg.register(Box::new(ExternalHookHandler::new(
            "bad",
            vec![],
            runner,
        )));
        let r = reg
            .fire(
                &HookEvent::unit(HookEventKind::SessionStart),
                MergeMode::InjectOnly,
            )
            .await;
        assert_eq!(r.errors.len(), 1);
        assert!(r.outcomes.is_empty());
    }

    #[test]
    fn event_json_ok() {
        let ev = HookEvent::pre_tool("t", json!({}));
        assert!(event_json(&ev).is_ok());
    }
}