use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookContext {
pub event: String,
pub session_id: Option<String>,
pub user_id: Option<String>,
pub platform: Option<String>,
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
impl HookContext {
pub fn new(event: impl Into<String>) -> Self {
Self {
event: event.into(),
session_id: None,
user_id: None,
platform: None,
extra: HashMap::new(),
}
}
pub fn with_session(mut self, session_id: impl Into<String>) -> Self {
self.session_id = Some(session_id.into());
self
}
pub fn with_user(mut self, user_id: impl Into<String>) -> Self {
self.user_id = Some(user_id.into());
self
}
pub fn with_platform(mut self, platform: impl Into<String>) -> Self {
self.platform = Some(platform.into());
self
}
pub fn with_value(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
self.extra.insert(key.into(), value.into());
self
}
pub fn with_str(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.extra.insert(key.into(), Value::String(value.into()));
self
}
pub fn to_json(&self) -> serde_json::Result<String> {
serde_json::to_string(self)
}
}
impl Default for HookContext {
fn default() -> Self {
Self::new("unknown")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookResult {
Continue,
Cancel { reason: String },
}
impl HookResult {
pub fn is_cancel(&self) -> bool {
matches!(self, Self::Cancel { .. })
}
}
#[derive(Debug, Deserialize, Default)]
struct ScriptResponse {
#[serde(default)]
cancel: bool,
#[serde(default)]
reason: Option<String>,
}
#[async_trait]
pub trait GatewayHook: Send + Sync {
fn name(&self) -> &str;
fn events(&self) -> &[&str];
async fn handle(&self, event: &str, context: &HookContext) -> anyhow::Result<HookResult>;
}
#[derive(Debug, Clone, Deserialize)]
pub struct HookManifest {
pub name: String,
#[serde(default)]
pub description: String,
pub events: Vec<String>,
#[serde(default = "default_timeout")]
pub timeout_secs: u64,
#[serde(default = "default_priority")]
pub priority: i32,
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default)]
pub env: HashMap<String, String>,
}
fn default_timeout() -> u64 {
10
}
fn default_priority() -> i32 {
50
}
fn default_enabled() -> bool {
true
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScriptLanguage {
Python,
JavaScript,
TypeScript,
}
impl ScriptLanguage {
fn from_path(p: &Path) -> Option<Self> {
match p.extension().and_then(|s| s.to_str()) {
Some("py") => Some(Self::Python),
Some("js") => Some(Self::JavaScript),
Some("ts") => Some(Self::TypeScript),
_ => None,
}
}
fn runtime(&self) -> &'static str {
match self {
Self::Python => "python3",
Self::JavaScript | Self::TypeScript => "bun",
}
}
}
pub struct ScriptHook {
manifest: HookManifest,
event_refs: Vec<String>,
script_path: PathBuf,
language: ScriptLanguage,
}
impl ScriptHook {
pub fn new(manifest: HookManifest, script_path: PathBuf, language: ScriptLanguage) -> Self {
let event_refs = manifest.events.clone();
Self {
manifest,
event_refs,
script_path,
language,
}
}
async fn run_script(&self, context: &HookContext) -> anyhow::Result<ScriptResponse> {
let json_input = context.to_json()?;
let timeout = Duration::from_secs(self.manifest.timeout_secs);
let mut cmd = tokio::process::Command::new(self.language.runtime());
cmd.arg(&self.script_path)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
for (k, v) in &self.manifest.env {
cmd.env(k, v);
}
let mut child = cmd.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
use tokio::io::AsyncWriteExt;
stdin.write_all(json_input.as_bytes()).await?;
}
let output = tokio::time::timeout(timeout, child.wait_with_output()).await;
match output {
Err(_) => {
anyhow::bail!(
"hook '{}' timed out after {}s",
self.manifest.name,
self.manifest.timeout_secs
)
}
Ok(Err(e)) => anyhow::bail!("hook '{}' process error: {e}", self.manifest.name),
Ok(Ok(out)) => {
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
tracing::warn!(
hook = %self.manifest.name,
exit_code = ?out.status.code(),
stderr = %stderr,
"hook script exited with non-zero status"
);
}
let stdout = std::str::from_utf8(&out.stdout)
.unwrap_or("")
.trim()
.to_string();
if stdout.is_empty() {
return Ok(ScriptResponse::default());
}
let resp: ScriptResponse =
serde_json::from_str(&stdout).unwrap_or_else(|_| ScriptResponse::default());
Ok(resp)
}
}
}
}
#[async_trait]
impl GatewayHook for ScriptHook {
fn name(&self) -> &str {
&self.manifest.name
}
fn events(&self) -> &[&str] {
let r: &[String] = &self.event_refs;
let _ = r; &[]
}
async fn handle(&self, event: &str, context: &HookContext) -> anyhow::Result<HookResult> {
match self.run_script(context).await {
Ok(resp) => {
if resp.cancel {
let reason = resp.reason.unwrap_or_else(|| {
format!("hook '{}' requested cancellation", self.manifest.name)
});
Ok(HookResult::Cancel { reason })
} else {
Ok(HookResult::Continue)
}
}
Err(e) => {
tracing::warn!(
hook = %self.manifest.name,
event,
error = %e,
"script hook error (non-fatal)"
);
Ok(HookResult::Continue)
}
}
}
}
struct HookEntry {
hook: Box<dyn GatewayHook>,
events_owned: Vec<String>,
priority: i32,
}
impl HookEntry {
fn from_native(hook: Box<dyn GatewayHook>) -> Self {
let events_owned: Vec<String> = hook.events().iter().map(|s| s.to_string()).collect();
Self {
hook,
events_owned,
priority: 50,
}
}
fn from_script(hook: ScriptHook, priority: i32) -> Self {
let events_owned = hook.event_refs.clone();
Self {
hook: Box::new(hook),
events_owned,
priority,
}
}
fn matches(&self, event: &str) -> bool {
self.events_owned.iter().any(|p| event_matches(p, event))
}
}
#[derive(Debug, Clone)]
pub struct LoadedHookInfo {
pub name: String,
pub description: String,
pub events: Vec<String>,
pub path: PathBuf,
pub language: String,
pub timeout_secs: u64,
pub priority: i32,
}
pub struct HookRegistry {
entries: Vec<HookEntry>,
loaded_files: Vec<LoadedHookInfo>,
}
impl HookRegistry {
pub fn new() -> Self {
Self {
entries: Vec::new(),
loaded_files: Vec::new(),
}
}
pub fn register(&mut self, hook: Box<dyn GatewayHook>) {
tracing::debug!(hook = hook.name(), "registered native gateway hook");
self.entries.push(HookEntry::from_native(hook));
self.sort_entries();
}
pub fn discover_and_load(&mut self) {
match lingshu_hooks_dir() {
Some(d) => self.discover_and_load_from(&d),
None => {
tracing::warn!("could not resolve ~/.lingshu/hooks — file-based hooks disabled");
}
}
}
pub fn discover_and_load_from(&mut self, hooks_dir: &std::path::Path) {
if !hooks_dir.exists() {
tracing::debug!(path = %hooks_dir.display(), "hooks directory does not exist — skipping");
return;
}
let entries = match std::fs::read_dir(hooks_dir) {
Ok(e) => e,
Err(err) => {
tracing::warn!(error = %err, "could not read hooks directory");
return;
}
};
let mut dirs: Vec<PathBuf> = entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect();
dirs.sort();
for hook_dir in dirs {
self.load_hook_dir(&hook_dir);
}
self.sort_entries();
tracing::info!(count = self.loaded_files.len(), "file-based hooks loaded");
}
fn load_hook_dir(&mut self, dir: &Path) {
let manifest_path = dir.join("HOOK.yaml");
if !manifest_path.exists() {
return;
}
let (script_path, language) = match find_handler(dir) {
Some(pair) => pair,
None => {
tracing::warn!(
dir = %dir.display(),
"HOOK.yaml found but no handler.py / handler.ts / handler.js — skipping"
);
return;
}
};
let manifest_text = match std::fs::read_to_string(&manifest_path) {
Ok(s) => s,
Err(e) => {
tracing::warn!(path = %manifest_path.display(), error = %e, "cannot read HOOK.yaml");
return;
}
};
let manifest: HookManifest = match serde_yml::from_str(&manifest_text) {
Ok(m) => m,
Err(e) => {
tracing::warn!(
path = %manifest_path.display(),
error = %e,
"invalid HOOK.yaml — skipping"
);
return;
}
};
if !manifest.enabled {
tracing::debug!(hook = %manifest.name, "hook is disabled — skipping");
return;
}
if manifest.events.is_empty() {
tracing::warn!(hook = %manifest.name, "no events declared — skipping");
return;
}
let runtime = language.runtime();
if which::which(runtime).is_err() {
tracing::warn!(
hook = %manifest.name,
runtime,
"runtime not found on PATH — hook disabled"
);
return;
}
let lang_label = match &language {
ScriptLanguage::Python => "python",
ScriptLanguage::JavaScript => "javascript",
ScriptLanguage::TypeScript => "typescript",
};
let info = LoadedHookInfo {
name: manifest.name.clone(),
description: manifest.description.clone(),
events: manifest.events.clone(),
path: dir.to_path_buf(),
language: lang_label.to_string(),
timeout_secs: manifest.timeout_secs,
priority: manifest.priority,
};
let priority = manifest.priority;
let hook = ScriptHook::new(manifest, script_path, language);
tracing::info!(
hook = %info.name,
events = ?info.events,
language = %info.language,
"loaded file-based hook"
);
self.loaded_files.push(info);
self.entries.push(HookEntry::from_script(hook, priority));
}
fn sort_entries(&mut self) {
self.entries.sort_by_key(|e| e.priority);
}
pub async fn emit(&self, event: &str, context: &HookContext) {
for entry in &self.entries {
if entry.matches(event) {
match entry.hook.handle(event, context).await {
Ok(HookResult::Cancel { reason }) => {
tracing::debug!(
hook = entry.hook.name(),
event,
reason,
"hook requested cancel on non-cancellable event"
);
}
Ok(HookResult::Continue) => {}
Err(e) => {
tracing::warn!(
hook = entry.hook.name(),
event,
error = %e,
"hook handler error (non-fatal)"
);
}
}
}
}
}
pub async fn emit_cancellable(&self, event: &str, context: &HookContext) -> HookResult {
for entry in &self.entries {
if entry.matches(event) {
match entry.hook.handle(event, context).await {
Ok(result @ HookResult::Cancel { .. }) => {
tracing::info!(hook = entry.hook.name(), event, "hook cancelled operation");
return result;
}
Ok(HookResult::Continue) => {}
Err(e) => {
tracing::warn!(
hook = entry.hook.name(),
event,
error = %e,
"hook handler error (non-fatal)"
);
}
}
}
}
HookResult::Continue
}
pub fn hook_count(&self) -> usize {
self.entries.len()
}
pub fn loaded_hooks(&self) -> &[LoadedHookInfo] {
&self.loaded_files
}
}
impl Default for HookRegistry {
fn default() -> Self {
Self::new()
}
}
fn lingshu_hooks_dir() -> Option<PathBuf> {
std::env::var("EDGECRAB_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| dirs::home_dir().map(|h| h.join(".lingshu")))
.map(|home| home.join("hooks"))
}
fn find_handler(dir: &Path) -> Option<(PathBuf, ScriptLanguage)> {
for filename in ["handler.py", "handler.ts", "handler.js"] {
let p = dir.join(filename);
if p.exists() {
if let Some(lang) = ScriptLanguage::from_path(&p) {
return Some((p, lang));
}
}
}
None
}
pub fn event_matches(pattern: &str, event: &str) -> bool {
if pattern == "*" {
return true;
}
if let Some(prefix) = pattern.strip_suffix('*') {
event.starts_with(prefix)
} else {
pattern == event
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exact_match() {
assert!(event_matches("session:start", "session:start"));
assert!(!event_matches("session:start", "session:end"));
}
#[test]
fn wildcard_suffix() {
assert!(event_matches("command:*", "command:new"));
assert!(event_matches("command:*", "command:model"));
assert!(!event_matches("command:*", "session:start"));
}
#[test]
fn global_wildcard() {
assert!(event_matches("*", "anything:at:all"));
assert!(event_matches("*", "session:start"));
}
#[test]
fn no_match() {
assert!(!event_matches("session:start", "command:new"));
}
#[test]
fn hook_context_builder() {
let ctx = HookContext::new("agent:start")
.with_session("s-123")
.with_user("u-456")
.with_platform("telegram")
.with_str("model", "claude-opus");
assert_eq!(ctx.session_id.as_deref(), Some("s-123"));
assert_eq!(ctx.user_id.as_deref(), Some("u-456"));
assert_eq!(ctx.platform.as_deref(), Some("telegram"));
assert_eq!(
ctx.extra.get("model"),
Some(&Value::String("claude-opus".into()))
);
}
#[test]
fn hook_context_json_serialise() {
let ctx = HookContext::new("session:start")
.with_session("s-1")
.with_str("platform", "cli");
let json = ctx.to_json().expect("serialise");
let parsed: serde_json::Value = serde_json::from_str(&json).expect("parse");
assert_eq!(parsed["event"], "session:start");
assert_eq!(parsed["session_id"], "s-1");
assert_eq!(parsed["platform"], "cli");
}
#[test]
fn hook_registry_empty() {
let reg = HookRegistry::new();
assert_eq!(reg.hook_count(), 0);
}
struct NoopHook {
name: &'static str,
evs: Vec<&'static str>,
}
#[async_trait]
impl GatewayHook for NoopHook {
fn name(&self) -> &str {
self.name
}
fn events(&self) -> &[&str] {
&self.evs
}
async fn handle(&self, _event: &str, _ctx: &HookContext) -> anyhow::Result<HookResult> {
Ok(HookResult::Continue)
}
}
struct CancelHook {
name: &'static str,
evs: Vec<&'static str>,
}
#[async_trait]
impl GatewayHook for CancelHook {
fn name(&self) -> &str {
self.name
}
fn events(&self) -> &[&str] {
&self.evs
}
async fn handle(&self, _event: &str, _ctx: &HookContext) -> anyhow::Result<HookResult> {
Ok(HookResult::Cancel {
reason: "test cancel".into(),
})
}
}
#[test]
fn register_native_hook() {
let mut reg = HookRegistry::new();
reg.register(Box::new(NoopHook {
name: "test",
evs: vec!["session:*"],
}));
assert_eq!(reg.hook_count(), 1);
}
#[tokio::test]
async fn emit_fires_matching_hooks() {
let mut reg = HookRegistry::new();
reg.register(Box::new(NoopHook {
name: "test",
evs: vec!["session:*"],
}));
reg.emit("session:start", &HookContext::new("session:start"))
.await;
}
#[tokio::test]
async fn emit_skips_non_matching_hooks() {
let mut reg = HookRegistry::new();
reg.register(Box::new(NoopHook {
name: "test",
evs: vec!["command:*"],
}));
reg.emit("session:start", &HookContext::new("session:start"))
.await;
}
#[tokio::test]
async fn emit_cancellable_returns_cancel() {
let mut reg = HookRegistry::new();
reg.register(Box::new(CancelHook {
name: "cancel-hook",
evs: vec!["tool:pre"],
}));
let result = reg
.emit_cancellable("tool:pre", &HookContext::new("tool:pre"))
.await;
assert!(result.is_cancel());
}
#[tokio::test]
async fn emit_cancellable_continues_when_no_cancel() {
let mut reg = HookRegistry::new();
reg.register(Box::new(NoopHook {
name: "noop",
evs: vec!["tool:pre"],
}));
let result = reg
.emit_cancellable("tool:pre", &HookContext::new("tool:pre"))
.await;
assert_eq!(result, HookResult::Continue);
}
#[tokio::test]
async fn emit_non_cancellable_ignores_cancel_result() {
let mut reg = HookRegistry::new();
reg.register(Box::new(CancelHook {
name: "cancel-hook",
evs: vec!["agent:end"],
}));
reg.emit("agent:end", &HookContext::new("agent:end")).await;
}
#[test]
fn priority_ordering() {
let mut reg = HookRegistry::new();
reg.register(Box::new(NoopHook {
name: "b",
evs: vec!["*"],
}));
reg.register(Box::new(NoopHook {
name: "a",
evs: vec!["*"],
}));
assert_eq!(reg.hook_count(), 2);
}
#[test]
fn wildcard_star_only_matches_everything() {
assert!(event_matches("*", ""));
assert!(event_matches("*", "tool:pre"));
assert!(event_matches("*", "session:start"));
assert!(event_matches("*", "any:deeply:nested:event"));
}
#[test]
fn wildcard_prefix_does_not_match_shorter_strings() {
assert!(!event_matches("command:*", "command"));
assert!(!event_matches("session:*", "session"));
}
#[test]
fn wildcard_colon_star_matches_all_subevents() {
assert!(event_matches("tool:*", "tool:pre"));
assert!(event_matches("tool:*", "tool:post"));
assert!(event_matches("llm:*", "llm:pre"));
assert!(event_matches("llm:*", "llm:post"));
}
#[test]
fn exact_match_is_case_sensitive() {
assert!(!event_matches("Session:Start", "session:start"));
}
#[test]
fn empty_pattern_matches_nothing_except_empty_event() {
assert!(event_matches("", ""));
assert!(!event_matches("", "session:start"));
}
#[test]
fn hook_context_with_value_stores_arbitrary_json() {
let ctx = HookContext::new("llm:post").with_value(
"tokens",
serde_json::json!({"prompt": 100, "completion": 42}),
);
let tokens = ctx.extra.get("tokens").expect("tokens present");
assert_eq!(tokens["prompt"], 100);
assert_eq!(tokens["completion"], 42);
}
#[test]
fn hook_context_json_round_trip_preserves_extra_fields() {
let ctx = HookContext::new("tool:post")
.with_session("s-abc")
.with_str("tool_name", "bash")
.with_str("result", "ok");
let json = ctx.to_json().expect("serialize");
let back: HookContext = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.event, "tool:post");
assert_eq!(back.session_id.as_deref(), Some("s-abc"));
assert_eq!(
back.extra.get("tool_name"),
Some(&Value::String("bash".into()))
);
}
#[test]
fn hook_context_default_event_is_unknown() {
let ctx = HookContext::default();
assert_eq!(ctx.event, "unknown");
assert!(ctx.session_id.is_none());
assert!(ctx.user_id.is_none());
assert!(ctx.platform.is_none());
}
#[test]
fn hook_result_cancel_is_cancel() {
let r = HookResult::Cancel {
reason: "blocked".into(),
};
assert!(r.is_cancel());
}
#[test]
fn hook_result_continue_is_not_cancel() {
assert!(!HookResult::Continue.is_cancel());
}
struct ErrorHook;
#[async_trait]
impl GatewayHook for ErrorHook {
fn name(&self) -> &str {
"error-hook"
}
fn events(&self) -> &[&str] {
&["*"]
}
async fn handle(&self, _event: &str, _ctx: &HookContext) -> anyhow::Result<HookResult> {
anyhow::bail!("simulated hook failure")
}
}
#[tokio::test]
async fn error_hook_does_not_crash_emit() {
let mut reg = HookRegistry::new();
reg.register(Box::new(ErrorHook));
reg.emit("session:start", &HookContext::new("session:start"))
.await;
}
#[tokio::test]
async fn error_hook_does_not_crash_emit_cancellable() {
let mut reg = HookRegistry::new();
reg.register(Box::new(ErrorHook));
let result = reg
.emit_cancellable("tool:pre", &HookContext::new("tool:pre"))
.await;
assert_eq!(result, HookResult::Continue);
}
struct CountingHook {
name: &'static str,
counter: std::sync::Arc<std::sync::atomic::AtomicUsize>,
}
#[async_trait]
impl GatewayHook for CountingHook {
fn name(&self) -> &str {
self.name
}
fn events(&self) -> &[&str] {
&["*"]
}
async fn handle(&self, _event: &str, _ctx: &HookContext) -> anyhow::Result<HookResult> {
self.counter
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Ok(HookResult::Continue)
}
}
#[tokio::test]
async fn cancellable_stops_after_first_cancel() {
let counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let counter_clone = counter.clone();
let mut reg = HookRegistry::new();
reg.register(Box::new(CountingHook { name: "a", counter }));
reg.register(Box::new(CancelHook {
name: "cancel",
evs: vec!["*"],
}));
reg.register(Box::new(CountingHook {
name: "b",
counter: counter_clone,
}));
let result = reg
.emit_cancellable("tool:pre", &HookContext::new("tool:pre"))
.await;
assert!(result.is_cancel(), "expected Cancel");
}
#[tokio::test]
async fn hook_subscribed_to_multiple_patterns_fires_once_per_event() {
let counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let counter2 = counter.clone();
struct MultiEventHook {
counter: std::sync::Arc<std::sync::atomic::AtomicUsize>,
}
#[async_trait]
impl GatewayHook for MultiEventHook {
fn name(&self) -> &str {
"multi"
}
fn events(&self) -> &[&str] {
&["session:start", "session:end"]
}
async fn handle(&self, _event: &str, _ctx: &HookContext) -> anyhow::Result<HookResult> {
self.counter
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Ok(HookResult::Continue)
}
}
let mut reg = HookRegistry::new();
reg.register(Box::new(MultiEventHook { counter }));
reg.emit("session:start", &HookContext::new("session:start"))
.await;
reg.emit("session:end", &HookContext::new("session:end"))
.await;
reg.emit("session:reset", &HookContext::new("session:reset"))
.await;
assert_eq!(counter2.load(std::sync::atomic::Ordering::Relaxed), 2);
}
#[test]
fn loaded_hooks_empty_on_new_registry() {
let reg = HookRegistry::new();
assert!(reg.loaded_hooks().is_empty());
}
#[test]
fn manifest_defaults_applied_when_fields_absent() {
let yaml = r#"
name: minimal
events:
- "session:start"
"#;
let m: HookManifest = serde_yml::from_str(yaml).expect("parse");
assert_eq!(m.name, "minimal");
assert_eq!(m.timeout_secs, 10);
assert_eq!(m.priority, 50);
assert!(m.enabled);
assert!(m.env.is_empty());
assert!(m.description.is_empty());
}
#[test]
fn manifest_full_fields_parse_correctly() {
let yaml = r#"
name: full-hook
description: A detailed hook
events:
- "tool:*"
- "llm:pre"
timeout_secs: 30
priority: 10
enabled: false
env:
FOO: bar
BAZ: "42"
"#;
let m: HookManifest = serde_yml::from_str(yaml).expect("parse");
assert_eq!(m.name, "full-hook");
assert_eq!(m.description, "A detailed hook");
assert_eq!(m.timeout_secs, 30);
assert_eq!(m.priority, 10);
assert!(!m.enabled);
assert_eq!(m.env.get("FOO").map(|s| s.as_str()), Some("bar"));
assert_eq!(m.env.get("BAZ").map(|s| s.as_str()), Some("42"));
assert_eq!(m.events, vec!["tool:*", "llm:pre"]);
}
#[test]
fn script_language_from_path_py() {
let lang = ScriptLanguage::from_path(Path::new("handler.py"));
assert_eq!(lang, Some(ScriptLanguage::Python));
}
#[test]
fn script_language_from_path_ts() {
let lang = ScriptLanguage::from_path(Path::new("handler.ts"));
assert_eq!(lang, Some(ScriptLanguage::TypeScript));
}
#[test]
fn script_language_from_path_js() {
let lang = ScriptLanguage::from_path(Path::new("handler.js"));
assert_eq!(lang, Some(ScriptLanguage::JavaScript));
}
#[test]
fn script_language_from_path_unknown() {
assert!(ScriptLanguage::from_path(Path::new("handler.rb")).is_none());
assert!(ScriptLanguage::from_path(Path::new("handler")).is_none());
}
#[test]
fn script_language_runtime_python() {
assert_eq!(ScriptLanguage::Python.runtime(), "python3");
}
#[test]
fn script_language_runtime_bun() {
assert_eq!(ScriptLanguage::JavaScript.runtime(), "bun");
assert_eq!(ScriptLanguage::TypeScript.runtime(), "bun");
}
#[tokio::test]
async fn script_hook_continue_on_empty_json_output() {
let dir = tempfile::tempdir().expect("tempdir");
let script = dir.path().join("handler.py");
std::fs::write(&script, b"import sys; sys.stdout.write('{}'); sys.exit(0)")
.expect("write script");
let manifest = HookManifest {
name: "test-noop".into(),
description: String::new(),
events: vec!["*".into()],
timeout_secs: 5,
priority: 50,
enabled: true,
env: HashMap::new(),
};
let hook = ScriptHook::new(manifest, script.clone(), ScriptLanguage::Python);
let ctx = HookContext::new("session:start");
let result = hook.handle("session:start", &ctx).await.expect("handle");
assert_eq!(result, HookResult::Continue);
}
#[tokio::test]
async fn script_hook_cancel_on_cancel_json() {
let dir = tempfile::tempdir().expect("tempdir");
let script = dir.path().join("handler.py");
std::fs::write(
&script,
br#"import sys; sys.stdout.write('{"cancel": true, "reason": "test"}'); sys.exit(0)"#,
)
.expect("write script");
let manifest = HookManifest {
name: "test-cancel".into(),
description: String::new(),
events: vec!["tool:pre".into()],
timeout_secs: 5,
priority: 50,
enabled: true,
env: HashMap::new(),
};
let hook = ScriptHook::new(manifest, script.clone(), ScriptLanguage::Python);
let ctx = HookContext::new("tool:pre");
let result = hook.handle("tool:pre", &ctx).await.expect("handle");
assert!(result.is_cancel(), "expected Cancel from script");
if let HookResult::Cancel { reason } = result {
assert_eq!(reason, "test");
}
}
#[tokio::test]
async fn script_hook_non_zero_exit_returns_continue() {
let dir = tempfile::tempdir().expect("tempdir");
let script = dir.path().join("handler.py");
std::fs::write(&script, b"import sys; sys.exit(1)").expect("write script");
let manifest = HookManifest {
name: "test-exit1".into(),
description: String::new(),
events: vec!["*".into()],
timeout_secs: 5,
priority: 50,
enabled: true,
env: HashMap::new(),
};
let hook = ScriptHook::new(manifest, script.clone(), ScriptLanguage::Python);
let ctx = HookContext::new("agent:end");
let result = hook.handle("agent:end", &ctx).await.expect("handle");
assert_eq!(result, HookResult::Continue);
}
#[tokio::test]
async fn script_hook_invalid_json_stdout_returns_continue() {
let dir = tempfile::tempdir().expect("tempdir");
let script = dir.path().join("handler.py");
std::fs::write(&script, b"print('not valid json')").expect("write script");
let manifest = HookManifest {
name: "test-bad-json".into(),
description: String::new(),
events: vec!["*".into()],
timeout_secs: 5,
priority: 50,
enabled: true,
env: HashMap::new(),
};
let hook = ScriptHook::new(manifest, script.clone(), ScriptLanguage::Python);
let ctx = HookContext::new("llm:post");
let result = hook.handle("llm:post", &ctx).await.expect("handle");
assert_eq!(result, HookResult::Continue);
}
#[tokio::test]
async fn script_hook_env_vars_are_injected() {
let dir = tempfile::tempdir().expect("tempdir");
let script = dir.path().join("handler.py");
std::fs::write(
&script,
b"import os, json, sys\nval = os.environ.get('TEST_SECRET', '')\n\
sys.stdout.write(json.dumps({'cancel': False, 'reason': val}))",
)
.expect("write script");
let mut env = HashMap::new();
env.insert("TEST_SECRET".into(), "injected-value".into());
let manifest = HookManifest {
name: "test-env".into(),
description: String::new(),
events: vec!["*".into()],
timeout_secs: 5,
priority: 50,
enabled: true,
env,
};
let hook = ScriptHook::new(manifest, script, ScriptLanguage::Python);
let ctx = HookContext::new("gateway:startup");
let result = hook.handle("gateway:startup", &ctx).await.expect("handle");
assert_eq!(result, HookResult::Continue);
}
#[tokio::test]
async fn script_hook_stdin_context_is_valid_json() {
let dir = tempfile::tempdir().expect("tempdir");
let script = dir.path().join("handler.py");
std::fs::write(
&script,
b"import json, sys\n\
data = json.load(sys.stdin)\n\
assert data['event'] == 'session:start', f\"bad event: {data}\"\n\
sys.stdout.write('{}')",
)
.expect("write script");
let manifest = HookManifest {
name: "test-stdin".into(),
description: String::new(),
events: vec!["session:start".into()],
timeout_secs: 5,
priority: 50,
enabled: true,
env: HashMap::new(),
};
let hook = ScriptHook::new(manifest, script, ScriptLanguage::Python);
let ctx = HookContext::new("session:start").with_session("s-1");
let result = hook.handle("session:start", &ctx).await.expect("handle");
assert_eq!(result, HookResult::Continue);
}
}