Skip to main content

bamboo_server/handlers/settings/
lifecycle_hooks.rs

1use 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/// One lifecycle handler selected in the settings editor for a dry run.
21#[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
39/// Execute one command against Bamboo's deterministic synthetic lifecycle
40/// payload and return raw output. The route is mounted inside the same access-
41/// password middleware as config writes; it deliberately never persists the
42/// submitted command.
43pub 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}