Skip to main content

xz_agent_hooks/
runner.rs

1//! External handler abstraction (command / HTTP / in-process).
2
3use crate::contract::{HookEvent, HookEventKind, HookOutcome};
4use crate::registry::HookHandler;
5use crate::wire::parse_handler_output;
6use async_trait::async_trait;
7use std::sync::Arc;
8use std::time::Duration;
9use thiserror::Error;
10
11/// Error from an external hook runner.
12#[derive(Debug, Error)]
13pub enum ExternalHookRunnerError {
14    /// Timed out waiting for the handler.
15    #[error("hook timed out after {0:?}")]
16    Timeout(Duration),
17    /// Spawn / I/O / process failure (fail-open at registry layer).
18    #[error("hook I/O error: {0}")]
19    Io(String),
20}
21
22/// Request passed to an external runner.
23#[derive(Debug, Clone)]
24pub struct ExternalHookRequest {
25    /// Event payload (also JSON-serializable for stdin).
26    pub event: HookEvent,
27    /// Optional handler name for logging.
28    pub name: String,
29    /// Timeout for this invocation.
30    pub timeout: Duration,
31}
32
33/// Runs one external hook implementation.
34///
35/// Products implement this for `sh -c`, HTTP POST, etc. The crate does **not**
36/// hard-code shell paths or project directories.
37#[async_trait]
38pub trait ExternalHookRunner: Send + Sync {
39    /// Execute the handler and return raw process-like results `(exit_code, stdout, stderr)`.
40    async fn run_raw(
41        &self,
42        request: &ExternalHookRequest,
43    ) -> Result<(i32, String, String), ExternalHookRunnerError>;
44
45    /// Execute and parse into outcomes (default: JSON/exit protocol).
46    async fn run(
47        &self,
48        request: &ExternalHookRequest,
49    ) -> Result<Vec<HookOutcome>, ExternalHookRunnerError> {
50        let (code, stdout, stderr) = self.run_raw(request).await?;
51        Ok(parse_handler_output(code, &stdout, &stderr))
52    }
53}
54
55/// Helper: serialize event JSON for stdin / env (products choose transport).
56pub fn event_json(event: &HookEvent) -> Result<String, serde_json::Error> {
57    serde_json::to_string(event)
58}
59
60/// Adapts an [`ExternalHookRunner`] into a [`HookHandler`] for the registry.
61pub struct ExternalHookHandler {
62    name: String,
63    kinds: Vec<HookEventKind>,
64    matcher: Option<String>,
65    timeout: Duration,
66    runner: Arc<dyn ExternalHookRunner>,
67}
68
69impl ExternalHookHandler {
70    /// Create a handler that delegates to `runner`.
71    pub fn new(
72        name: impl Into<String>,
73        kinds: Vec<HookEventKind>,
74        runner: Arc<dyn ExternalHookRunner>,
75    ) -> Self {
76        Self {
77            name: name.into(),
78            kinds,
79            matcher: None,
80            timeout: Duration::from_secs(30),
81            runner,
82        }
83    }
84
85    /// Restrict to tools matching `pattern`.
86    pub fn with_tool_matcher(mut self, pattern: impl Into<String>) -> Self {
87        self.matcher = Some(pattern.into());
88        self
89    }
90
91    /// Override timeout.
92    pub fn with_timeout(mut self, timeout: Duration) -> Self {
93        self.timeout = timeout;
94        self
95    }
96}
97
98#[async_trait]
99impl HookHandler for ExternalHookHandler {
100    fn name(&self) -> &str {
101        &self.name
102    }
103
104    fn event_kinds(&self) -> &[HookEventKind] {
105        &self.kinds
106    }
107
108    fn tool_matcher(&self) -> Option<&str> {
109        self.matcher.as_deref()
110    }
111
112    async fn on_event(&self, event: &HookEvent) -> Result<Vec<HookOutcome>, String> {
113        let req = ExternalHookRequest {
114            event: event.clone(),
115            name: self.name.clone(),
116            timeout: self.timeout,
117        };
118        match self.runner.run(&req).await {
119            Ok(outcomes) => Ok(outcomes),
120            // Fail-open: surface as empty outcomes with error string for registry.errors
121            // Actually registry expects Err for fail-open logging — return Err
122            Err(e) => Err(e.to_string()),
123        }
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::contract::{HookEvent, MergeMode};
131    use crate::registry::HookRegistry;
132    use serde_json::json;
133    use std::sync::Mutex;
134
135    struct ScriptedRunner {
136        code: i32,
137        stdout: String,
138        stderr: String,
139        calls: Mutex<u32>,
140        fail_io: bool,
141    }
142
143    #[async_trait]
144    impl ExternalHookRunner for ScriptedRunner {
145        async fn run_raw(
146            &self,
147            _request: &ExternalHookRequest,
148        ) -> Result<(i32, String, String), ExternalHookRunnerError> {
149            if let Ok(mut c) = self.calls.lock() {
150                *c += 1;
151            }
152            if self.fail_io {
153                return Err(ExternalHookRunnerError::Io("spawn failed".into()));
154            }
155            Ok((self.code, self.stdout.clone(), self.stderr.clone()))
156        }
157    }
158
159    #[tokio::test]
160    async fn external_handler_mutate_via_wire() {
161        let body = json!({
162            "hookSpecificOutput": {
163                "permissionDecision": "allow",
164                "updatedInput": { "command": "rtk ls" }
165            }
166        })
167        .to_string();
168        let runner = Arc::new(ScriptedRunner {
169            code: 0,
170            stdout: body,
171            stderr: String::new(),
172            calls: Mutex::new(0),
173            fail_io: false,
174        });
175        let handler = ExternalHookHandler::new(
176            "rtk-like",
177            vec![HookEventKind::PreTool],
178            runner.clone(),
179        )
180        .with_tool_matcher("shell|*");
181
182        let mut reg = HookRegistry::new();
183        reg.register(Box::new(handler));
184        let r = reg
185            .fire_pre_tool(&HookEvent::pre_tool("shell", json!({"command": "ls"})))
186            .await;
187        assert!(r.errors.is_empty());
188        let args = r
189            .effect
190            .as_pre_tool()
191            .and_then(|e| e.args.clone());
192        assert_eq!(args, Some(json!({"command": "rtk ls"})));
193        assert_eq!(*runner.calls.lock().unwrap_or_else(|e| e.into_inner()), 1);
194    }
195
196    #[tokio::test]
197    async fn external_handler_io_error_fail_open() {
198        let runner = Arc::new(ScriptedRunner {
199            code: 0,
200            stdout: String::new(),
201            stderr: String::new(),
202            calls: Mutex::new(0),
203            fail_io: true,
204        });
205        let mut reg = HookRegistry::new();
206        reg.register(Box::new(ExternalHookHandler::new(
207            "bad",
208            vec![],
209            runner,
210        )));
211        let r = reg
212            .fire(
213                &HookEvent::unit(HookEventKind::SessionStart),
214                MergeMode::InjectOnly,
215            )
216            .await;
217        assert_eq!(r.errors.len(), 1);
218        assert!(r.outcomes.is_empty());
219    }
220
221    #[test]
222    fn event_json_ok() {
223        let ev = HookEvent::pre_tool("t", json!({}));
224        assert!(event_json(&ev).is_ok());
225    }
226}