use actix_web::{web, HttpResponse, Responder};
use serde::{Deserialize, Serialize};
use crate::app_state::AppState;
#[derive(Debug, Deserialize)]
pub struct ChildApprovalDecision {
pub parent_session_id: String,
pub child_attempt: u32,
pub request_id: String,
pub expected_version: u64,
pub approved: bool,
}
#[derive(Debug, Serialize)]
struct ChildApprovalResponse {
delivered: bool,
}
pub async fn handler(
state: web::Data<AppState>,
path: web::Path<String>,
body: web::Json<ChildApprovalDecision>,
) -> impl Responder {
let child_session_id = path.into_inner();
let ChildApprovalDecision {
parent_session_id,
child_attempt,
request_id,
expected_version,
approved,
} = body.into_inner();
if parent_session_id.trim().is_empty() || request_id.trim().is_empty() {
return HttpResponse::BadRequest().json(ChildApprovalResponse { delivered: false });
}
let outcome = bamboo_engine::external_agents::live::deliver_approval_checked_cas(
Some(&state.approval_registry),
&parent_session_id,
&child_session_id,
child_attempt,
&request_id,
expected_version,
approved,
);
match outcome {
bamboo_engine::external_agents::live::ApprovalDeliveryResult::Delivered => {
tracing::info!(
"[{}] child approval delivered (parent_session_id={}, child_attempt={}, request_id={}, expected_version={}, approved={})",
child_session_id,
parent_session_id,
child_attempt,
request_id,
expected_version,
approved
);
HttpResponse::Ok().json(ChildApprovalResponse { delivered: true })
}
bamboo_engine::external_agents::live::ApprovalDeliveryResult::Conflict => {
tracing::warn!(
"[{}] child approval rejected due to stale durable identity (parent_session_id={}, child_attempt={}, request_id={}, expected_version={})",
child_session_id,
parent_session_id,
child_attempt,
request_id,
expected_version
);
HttpResponse::Conflict().json(ChildApprovalResponse { delivered: false })
}
bamboo_engine::external_agents::live::ApprovalDeliveryResult::NotFound
| bamboo_engine::external_agents::live::ApprovalDeliveryResult::DeliveryFailed => {
tracing::warn!(
"[{}] child approval not delivered (parent_session_id={}, child_attempt={}, request_id={}, expected_version={})",
child_session_id,
parent_session_id,
child_attempt,
request_id,
expected_version
);
HttpResponse::NotFound().json(ChildApprovalResponse { delivered: false })
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use actix_web::{http::StatusCode, test, App};
use bamboo_subagent::proto::ParentFrame;
#[actix_web::test]
async fn decision_body_deserializes() {
let v: ChildApprovalDecision = serde_json::from_str(
r#"{"parent_session_id":"p1","child_attempt":2,"request_id":"r1","expected_version":7,"approved":true}"#,
)
.unwrap();
assert_eq!(v.parent_session_id, "p1");
assert_eq!(v.child_attempt, 2);
assert_eq!(v.request_id, "r1");
assert_eq!(v.expected_version, 7);
assert!(v.approved);
}
#[actix_web::test]
async fn legacy_decision_body_without_cas_identity_fails_closed() {
assert!(serde_json::from_str::<ChildApprovalDecision>(
r#"{"request_id":"r1","approved":true}"#
)
.is_err());
}
#[actix_web::test]
async fn http_handler_conflicts_on_parent_or_version_mismatch_without_consuming() {
let dir = tempfile::tempdir().expect("temp dir");
let state = web::Data::new(
AppState::new(dir.path().to_path_buf())
.await
.expect("app state"),
);
let child_id = "child-http-cas";
let request_id = "request-http-cas";
let (wire_tx, mut wire_rx) = tokio::sync::mpsc::unbounded_channel();
let _guard = bamboo_engine::external_agents::live::register(
child_id,
wire_tx,
4,
Some(state.approval_registry.clone()),
);
let (event_tx, _event_rx) = tokio::sync::mpsc::channel(4);
let (version, _) = bamboo_engine::external_agents::live::observe_pending_approval(
bamboo_engine::external_agents::live::PendingApprovalObservation {
registry: Some(&state.approval_registry),
parent_session_id: "parent-http-cas",
child_id,
child_attempt: 4,
request_id,
tool_name: "Bash",
permission: "execute",
resource: "cargo test",
event_tx,
},
);
let app = test::init_service(App::new().app_data(state).route(
"/api/v1/child-approval/{child_session_id}",
web::post().to(handler),
))
.await;
let missing_identity = test::call_service(
&app,
test::TestRequest::post()
.uri(&format!("/api/v1/child-approval/{child_id}"))
.set_json(serde_json::json!({
"request_id": request_id,
"approved": true
}))
.to_request(),
)
.await;
assert_eq!(missing_identity.status(), StatusCode::BAD_REQUEST);
let wrong_parent = test::call_service(
&app,
test::TestRequest::post()
.uri(&format!("/api/v1/child-approval/{child_id}"))
.set_json(serde_json::json!({
"parent_session_id": "parent-stale",
"child_attempt": 4,
"request_id": request_id,
"expected_version": version,
"approved": true
}))
.to_request(),
)
.await;
assert_eq!(wrong_parent.status(), StatusCode::CONFLICT);
let wrong_version = test::call_service(
&app,
test::TestRequest::post()
.uri(&format!("/api/v1/child-approval/{child_id}"))
.set_json(serde_json::json!({
"parent_session_id": "parent-http-cas",
"child_attempt": 4,
"request_id": request_id,
"expected_version": version.saturating_add(1),
"approved": true
}))
.to_request(),
)
.await;
assert_eq!(wrong_version.status(), StatusCode::CONFLICT);
assert!(wire_rx.try_recv().is_err());
let accepted = test::call_service(
&app,
test::TestRequest::post()
.uri(&format!("/api/v1/child-approval/{child_id}"))
.set_json(serde_json::json!({
"parent_session_id": "parent-http-cas",
"child_attempt": 4,
"request_id": request_id,
"expected_version": version,
"approved": false
}))
.to_request(),
)
.await;
assert_eq!(accepted.status(), StatusCode::OK);
assert!(matches!(
wire_rx.try_recv(),
Ok(ParentFrame::ApprovalReply { id, approved }) if id == request_id && !approved
));
}
}