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;
#[derive(Debug, Error)]
pub enum ExternalHookRunnerError {
#[error("hook timed out after {0:?}")]
Timeout(Duration),
#[error("hook I/O error: {0}")]
Io(String),
}
#[derive(Debug, Clone)]
pub struct ExternalHookRequest {
pub event: HookEvent,
pub name: String,
pub timeout: Duration,
}
#[async_trait]
pub trait ExternalHookRunner: Send + Sync {
async fn run_raw(
&self,
request: &ExternalHookRequest,
) -> Result<(i32, String, String), ExternalHookRunnerError>;
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))
}
}
pub fn event_json(event: &HookEvent) -> Result<String, serde_json::Error> {
serde_json::to_string(event)
}
pub struct ExternalHookHandler {
name: String,
kinds: Vec<HookEventKind>,
matcher: Option<String>,
timeout: Duration,
runner: Arc<dyn ExternalHookRunner>,
}
impl ExternalHookHandler {
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,
}
}
pub fn with_tool_matcher(mut self, pattern: impl Into<String>) -> Self {
self.matcher = Some(pattern.into());
self
}
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),
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());
}
}