1use 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#[derive(Debug, Error)]
13pub enum ExternalHookRunnerError {
14 #[error("hook timed out after {0:?}")]
16 Timeout(Duration),
17 #[error("hook I/O error: {0}")]
19 Io(String),
20}
21
22#[derive(Debug, Clone)]
24pub struct ExternalHookRequest {
25 pub event: HookEvent,
27 pub name: String,
29 pub timeout: Duration,
31}
32
33#[async_trait]
38pub trait ExternalHookRunner: Send + Sync {
39 async fn run_raw(
41 &self,
42 request: &ExternalHookRequest,
43 ) -> Result<(i32, String, String), ExternalHookRunnerError>;
44
45 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
55pub fn event_json(event: &HookEvent) -> Result<String, serde_json::Error> {
57 serde_json::to_string(event)
58}
59
60pub 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 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 pub fn with_tool_matcher(mut self, pattern: impl Into<String>) -> Self {
87 self.matcher = Some(pattern.into());
88 self
89 }
90
91 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 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}