bamboo_server/handlers/settings/
lifecycle_hooks.rs1use actix_web::{web, HttpResponse};
2use bamboo_config::{
3 lifecycle_script_extension, LifecycleHookHandler, LifecycleScriptRunner,
4 DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS, LIFECYCLE_HOOK_EVENT_NAMES, MAX_LIFECYCLE_HOOK_TIMEOUT_MS,
5 MIN_LIFECYCLE_HOOK_TIMEOUT_MS,
6};
7use regex::Regex;
8use serde::Deserialize;
9
10use crate::{app_state::AppState, error::AppError};
11
12#[derive(Debug, Clone, Copy, Default, Deserialize)]
13#[serde(rename_all = "lowercase")]
14enum LifecycleHookTestType {
15 #[default]
16 Command,
17 Script,
18}
19
20#[derive(Debug, Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct LifecycleHookTestRequest {
24 event: String,
25 #[serde(default)]
26 matcher: Option<String>,
27 #[serde(rename = "type", default)]
28 hook_type: LifecycleHookTestType,
29 #[serde(default)]
30 command: Option<String>,
31 #[serde(default)]
32 path: Option<String>,
33 #[serde(default)]
34 timeout_ms: Option<u64>,
35 #[serde(default)]
36 runner: Option<LifecycleScriptRunner>,
37}
38
39pub async fn test_lifecycle_hook(
44 app_state: web::Data<AppState>,
45 payload: web::Json<LifecycleHookTestRequest>,
46) -> Result<HttpResponse, AppError> {
47 let payload = payload.into_inner();
48 if !LIFECYCLE_HOOK_EVENT_NAMES.contains(&payload.event.as_str()) {
49 return Err(AppError::BadRequest(format!(
50 "unknown lifecycle hook event '{}'",
51 payload.event
52 )));
53 }
54 let timeout_ms = payload
55 .timeout_ms
56 .unwrap_or(DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS);
57 if !(MIN_LIFECYCLE_HOOK_TIMEOUT_MS..=MAX_LIFECYCLE_HOOK_TIMEOUT_MS).contains(&timeout_ms) {
58 return Err(AppError::BadRequest(format!(
59 "timeout_ms must be between {MIN_LIFECYCLE_HOOK_TIMEOUT_MS} and {MAX_LIFECYCLE_HOOK_TIMEOUT_MS}"
60 )));
61 }
62 if let Some(matcher) = payload.matcher.as_deref() {
63 Regex::new(matcher).map_err(|error| {
64 AppError::BadRequest(format!("invalid lifecycle hook matcher regex: {error}"))
65 })?;
66 }
67
68 let fallback_cwd = app_state
69 .config
70 .read()
71 .await
72 .get_default_work_area_path()
73 .or_else(|| Some(app_state.app_data_dir.clone()));
74 let handler = match payload.hook_type {
75 LifecycleHookTestType::Command => {
76 let command = payload.command.unwrap_or_default();
77 if command.trim().is_empty() {
78 return Err(AppError::BadRequest(
79 "lifecycle hook command must not be empty".to_string(),
80 ));
81 }
82 LifecycleHookHandler::command(command, timeout_ms)
83 }
84 LifecycleHookTestType::Script => {
85 let path = payload.path.unwrap_or_default();
86 let path = path.trim();
87 if path.is_empty() {
88 return Err(AppError::BadRequest(
89 "lifecycle script path must not be empty".to_string(),
90 ));
91 }
92 if lifecycle_script_extension(path).is_none() {
93 return Err(AppError::BadRequest(
94 "lifecycle script path must end in .js, .mjs, .cjs, .py, .sh, .ps1, .bat, or .cmd"
95 .to_string(),
96 ));
97 }
98 let runner = payload.runner.unwrap_or_default();
99 if !runner.supports_path(path) {
100 return Err(AppError::BadRequest(format!(
101 "lifecycle script runner '{}' is incompatible with path '{path}'",
102 runner.as_str()
103 )));
104 }
105 LifecycleHookHandler::script(path, runner, timeout_ms)
106 }
107 };
108 let output = bamboo_engine::test_lifecycle_handler(&payload.event, &handler, fallback_cwd)
109 .await
110 .map_err(|error| {
111 AppError::InternalError(anyhow::anyhow!("lifecycle hook dry run failed: {error}"))
112 })?;
113
114 Ok(HttpResponse::Ok().json(output))
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120 use actix_web::{http::StatusCode, test, App};
121
122 #[actix_web::test]
123 async fn dry_run_returns_raw_exit_and_captured_streams() {
124 let dir = tempfile::tempdir().unwrap();
125 let state = web::Data::new(AppState::new(dir.path().to_path_buf()).await.unwrap());
126 let app = test::init_service(
127 App::new()
128 .app_data(state)
129 .route("/hooks/test", web::post().to(test_lifecycle_hook)),
130 )
131 .await;
132
133 let response = test::call_service(
134 &app,
135 test::TestRequest::post()
136 .uri("/hooks/test")
137 .set_json(serde_json::json!({
138 "event": "PreToolUse",
139 "matcher": "^Bash$",
140 "command": "printf '%s' \"$BAMBOO_HOOK_EVENT\"; printf 'diagnostic' >&2; exit 7",
141 "timeout_ms": 2_000
142 }))
143 .to_request(),
144 )
145 .await;
146
147 assert_eq!(response.status(), StatusCode::OK);
148 let body: serde_json::Value = test::read_body_json(response).await;
149 assert_eq!(body["exit_code"], 7);
150 assert_eq!(body["stdout"], "PreToolUse");
151 assert_eq!(body["stderr"], "diagnostic");
152 assert_eq!(body["timed_out"], false);
153 }
154
155 #[actix_web::test]
156 async fn dry_run_rejects_unknown_event_before_execution() {
157 let dir = tempfile::tempdir().unwrap();
158 let state = web::Data::new(AppState::new(dir.path().to_path_buf()).await.unwrap());
159 let app = test::init_service(
160 App::new()
161 .app_data(state)
162 .route("/hooks/test", web::post().to(test_lifecycle_hook)),
163 )
164 .await;
165
166 let response = test::call_service(
167 &app,
168 test::TestRequest::post()
169 .uri("/hooks/test")
170 .set_json(serde_json::json!({
171 "event": "Unknown",
172 "command": "exit 99"
173 }))
174 .to_request(),
175 )
176 .await;
177
178 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
179 }
180
181 #[actix_web::test]
182 async fn dry_run_executes_external_script_handler() {
183 let dir = tempfile::tempdir().unwrap();
184 std::fs::write(
185 dir.path().join("hook.js"),
186 r#"
187let raw = "";
188process.stdin.setEncoding("utf8");
189process.stdin.on("data", chunk => raw += chunk);
190process.stdin.on("end", () => {
191 const input = JSON.parse(raw);
192 process.stdout.write(JSON.stringify({additional_context: input.tool_name}));
193});
194"#,
195 )
196 .unwrap();
197 let state = web::Data::new(AppState::new(dir.path().to_path_buf()).await.unwrap());
198 let app = test::init_service(
199 App::new()
200 .app_data(state)
201 .route("/hooks/test", web::post().to(test_lifecycle_hook)),
202 )
203 .await;
204
205 let response = test::call_service(
206 &app,
207 test::TestRequest::post()
208 .uri("/hooks/test")
209 .set_json(serde_json::json!({
210 "event": "PreToolUse",
211 "type": "script",
212 "path": "hook.js",
213 "runner": "node"
214 }))
215 .to_request(),
216 )
217 .await;
218
219 assert_eq!(response.status(), StatusCode::OK);
220 let body: serde_json::Value = test::read_body_json(response).await;
221 assert_eq!(body["exit_code"], 0);
222 assert_eq!(body["timed_out"], false);
223 assert_eq!(
224 serde_json::from_str::<serde_json::Value>(body["stdout"].as_str().unwrap()).unwrap()
225 ["additional_context"],
226 "Bash"
227 );
228 }
229}