1use kranz_engine::hook_status::{HookStatusSpec, SignalPost, STDIN_PAYLOAD_MAX_BYTES};
29use std::io::Read;
30use std::path::Path;
31use std::time::Duration;
32
33const POST_TIMEOUT: Duration = Duration::from_secs(5);
36
37pub async fn run_hook_status(
42 config: &Path,
43 stdin: &mut impl Read,
44 post_fn: &impl AsyncFn(&str, &SignalPost) -> Result<(), String>,
45) -> i32 {
46 let spec = match HookStatusSpec::load(config) {
47 Ok(spec) => spec,
48 Err(e) => {
49 eprintln!(
50 "kranz hook-status: failed to load the hook spec {}: {e} \
51 (ignoring; the lane is observational)",
52 config.display()
53 );
54 return 0;
55 }
56 };
57
58 let mut payload_bytes = Vec::new();
62 if let Err(e) = stdin
63 .take((STDIN_PAYLOAD_MAX_BYTES + 1) as u64)
64 .read_to_end(&mut payload_bytes)
65 {
66 eprintln!("kranz hook-status: failed to read the hook payload on stdin: {e} (ignoring)");
67 return 0;
68 }
69 if payload_bytes.len() > STDIN_PAYLOAD_MAX_BYTES {
70 eprintln!(
71 "kranz hook-status: hook payload exceeds {} bytes (ignoring)",
72 STDIN_PAYLOAD_MAX_BYTES
73 );
74 return 0;
75 }
76 let payload: serde_json::Value = match serde_json::from_slice(&payload_bytes) {
77 Ok(payload) => payload,
78 Err(e) => {
79 eprintln!("kranz hook-status: hook payload was not JSON: {e} (ignoring)");
80 return 0;
81 }
82 };
83
84 let Some(post) = kranz_engine::hook_status::signal_post_for(&spec, &payload) else {
88 return 0;
89 };
90
91 if let Err(e) = post_fn(&spec.endpoint, &post).await {
92 eprintln!("kranz hook-status: signal POST failed: {e} (ignoring)");
93 }
94 0
95}
96
97pub async fn post_signal(endpoint: &str, post: &SignalPost) -> Result<(), String> {
101 let client = reqwest::Client::builder()
102 .timeout(POST_TIMEOUT)
103 .build()
104 .map_err(|e| e.to_string())?;
105 let response = client
106 .post(endpoint)
107 .json(post)
108 .send()
109 .await
110 .map_err(|e| e.to_string())?;
111 if response.status().is_success() {
112 Ok(())
113 } else {
114 Err(format!("endpoint returned {}", response.status()))
115 }
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121 use std::sync::{Arc, Mutex};
122
123 fn write_spec(dir: &Path) -> std::path::PathBuf {
124 let spec = HookStatusSpec {
125 version: kranz_engine::hook_status::SPEC_VERSION,
126 endpoint: "http://127.0.0.1:9/api/hook-status".to_string(),
127 token: "tok-1".to_string(),
128 mission_id: "m-1".to_string(),
129 run_id: "r-1".to_string(),
130 };
131 let path = dir.join("spec.json");
132 std::fs::write(&path, serde_json::to_string_pretty(&spec).unwrap()).unwrap();
133 path
134 }
135
136 #[tokio::test]
139 async fn hook_status_signal_relay_posts_mapped_payloads_and_never_fails() {
140 let dir = tempfile::tempdir().unwrap();
141 let config = write_spec(dir.path());
142 let captured: Arc<Mutex<Vec<(String, serde_json::Value)>>> =
143 Arc::new(Mutex::new(Vec::new()));
144 let sink = {
145 let captured = Arc::clone(&captured);
146 move |endpoint: &str, post: &SignalPost| {
147 let captured = Arc::clone(&captured);
148 let endpoint = endpoint.to_string();
149 let post = post.clone();
150 async move {
151 captured
152 .lock()
153 .unwrap()
154 .push((endpoint, serde_json::to_value(post).unwrap()));
155 Ok(())
156 }
157 }
158 };
159
160 let payload = serde_json::json!({
161 "hook_event_name": "postToolUseFailure",
162 "tool_name": "Shell",
163 "failure_type": "permission_denied",
164 })
165 .to_string();
166 let code = run_hook_status(&config, &mut payload.as_bytes(), &sink).await;
167 assert_eq!(code, 0, "the relay always exits 0");
168
169 let captured = captured.lock().unwrap();
170 assert_eq!(captured.len(), 1);
171 assert_eq!(captured[0].0, "http://127.0.0.1:9/api/hook-status");
172 let body = &captured[0].1;
173 assert_eq!(body["token"], "tok-1");
174 assert_eq!(body["missionId"], "m-1");
175 assert_eq!(body["runId"], "r-1");
176 assert_eq!(body["signal"], "needs-input");
177 assert!(body["detail"].as_str().unwrap().contains("Shell"));
178 }
179
180 #[tokio::test]
183 async fn hook_status_signal_relay_ignores_malformed_unmapped_and_oversized() {
184 let dir = tempfile::tempdir().unwrap();
185 let config = write_spec(dir.path());
186 let calls = Arc::new(Mutex::new(0usize));
187 let counting = {
188 let calls = Arc::clone(&calls);
189 move |_: &str, _: &SignalPost| {
190 let calls = Arc::clone(&calls);
191 async move {
192 *calls.lock().unwrap() += 1;
193 Ok(())
194 }
195 }
196 };
197
198 let mut bad = b"{not json".as_slice();
200 assert_eq!(run_hook_status(&config, &mut bad, &counting).await, 0);
201 let payload = serde_json::json!({ "hook_event_name": "preCompact" }).to_string();
203 assert_eq!(
204 run_hook_status(&config, &mut payload.as_bytes(), &counting).await,
205 0
206 );
207 let oversized = vec![b'x'; STDIN_PAYLOAD_MAX_BYTES + 1];
209 assert_eq!(
210 run_hook_status(&config, &mut oversized.as_slice(), &counting).await,
211 0
212 );
213 let missing = dir.path().join("no-such-spec.json");
215 let payload = serde_json::json!({ "hook_event_name": "sessionStart" }).to_string();
216 assert_eq!(
217 run_hook_status(&missing, &mut payload.as_bytes(), &counting).await,
218 0
219 );
220
221 assert_eq!(*calls.lock().unwrap(), 0, "nothing was POSTed");
222 }
223
224 #[tokio::test]
227 async fn hook_status_signal_relay_swallows_endpoint_failures() {
228 let dir = tempfile::tempdir().unwrap();
229 let config = write_spec(dir.path());
230 let failing = |_: &str, _: &SignalPost| async move {
231 Err("endpoint returned 403".to_string()) as Result<(), String>
232 };
233 let payload = serde_json::json!({ "hook_event_name": "sessionStart" }).to_string();
234 assert_eq!(
235 run_hook_status(&config, &mut payload.as_bytes(), &failing).await,
236 0
237 );
238 }
239}