1use std::collections::HashMap;
12use std::sync::{Mutex, OnceLock};
13
14use bamboo_agent_core::AgentEvent;
15use bamboo_domain::poison::PoisonRecover;
16use bamboo_subagent::proto::ParentFrame;
17use tokio::sync::mpsc;
18
19use super::approval_registry::{
20 ApprovalRegistry, ApprovalState, DurableApproval, SharedApprovalRegistry,
21};
22
23type ScopeId = usize;
24type LiveKey = (ScopeId, String, u32);
25type PendingKey = (ScopeId, String, String, u32, String);
26
27fn scope_id(registry: Option<&SharedApprovalRegistry>) -> ScopeId {
28 registry.map_or(0, |registry| registry.lock().recover_poison().scope_id())
29}
30
31fn map() -> &'static Mutex<HashMap<LiveKey, mpsc::UnboundedSender<ParentFrame>>> {
32 static MAP: OnceLock<Mutex<HashMap<LiveKey, mpsc::UnboundedSender<ParentFrame>>>> =
33 OnceLock::new();
34 MAP.get_or_init(|| Mutex::new(HashMap::new()))
35}
36
37#[derive(Clone)]
44struct PendingApproval {
45 parent_session_id: String,
46 tool_name: String,
47 permission: String,
48 resource: String,
49 created_at: String,
50 version: u64,
51 child_attempt: u32,
52 event_tx: mpsc::Sender<AgentEvent>,
53}
54
55fn pending() -> &'static Mutex<HashMap<PendingKey, PendingApproval>> {
56 static PENDING: OnceLock<Mutex<HashMap<PendingKey, PendingApproval>>> = OnceLock::new();
57 PENDING.get_or_init(|| Mutex::new(HashMap::new()))
58}
59
60pub fn initialize_durable_approvals(
63 path: std::path::PathBuf,
64) -> std::io::Result<(SharedApprovalRegistry, Vec<AgentEvent>)> {
65 let mut registry = ApprovalRegistry::open(path)?;
66 let reconciled = registry.reconcile_restart()?;
67 let events = reconciled.into_iter().map(record_event).collect();
68 Ok((std::sync::Arc::new(Mutex::new(registry)), events))
69}
70
71pub fn register_pending_approval_observed(
75 registry: Option<&SharedApprovalRegistry>,
76 parent_session_id: &str,
77 child_id: &str,
78 child_attempt: u32,
79 request_id: &str,
80 tool_name: &str,
81 permission: &str,
82 resource: &str,
83 event_tx: mpsc::Sender<AgentEvent>,
84) -> (u64, String) {
85 let now = chrono::Utc::now();
86 let version = now.timestamp_micros().max(0) as u64;
87 let created_at = now.to_rfc3339();
88 let durable_record = DurableApproval {
89 parent_session_id: parent_session_id.to_string(),
90 child_session_id: child_id.to_string(),
91 child_attempt,
92 request_id: request_id.to_string(),
93 tool_name: tool_name.to_string(),
94 permission: permission.to_string(),
95 resource: resource.to_string(),
96 created_at: created_at.clone(),
97 updated_at: created_at.clone(),
98 version,
99 state: ApprovalState::Pending,
100 approved: None,
101 reason: None,
102 };
103 if let Some(registry) = registry {
104 if let Err(error) = registry.lock().recover_poison().register(durable_record) {
105 tracing::error!("failed to persist pending child approval: {error}");
106 return (0, created_at);
107 }
108 }
109 pending().lock().recover_poison().insert(
110 (
111 scope_id(registry),
112 parent_session_id.to_string(),
113 child_id.to_string(),
114 child_attempt,
115 request_id.to_string(),
116 ),
117 PendingApproval {
118 parent_session_id: parent_session_id.to_string(),
119 tool_name: tool_name.to_string(),
120 permission: permission.to_string(),
121 resource: resource.to_string(),
122 created_at: created_at.clone(),
123 version,
124 child_attempt,
125 event_tx,
126 },
127 );
128 (version, created_at)
129}
130
131#[cfg(test)]
132fn register_pending_approval(child_id: &str, request_id: &str) {
133 let (event_tx, _rx) = mpsc::channel(1);
134 let _ = register_pending_approval_observed(
135 None,
136 "test-parent",
137 child_id,
138 0,
139 request_id,
140 "test-tool",
141 "test-permission",
142 "test-resource",
143 event_tx,
144 );
145}
146
147pub fn take_pending_approval(child_id: &str, request_id: &str) -> bool {
151 remove_unique_pending(None, child_id, request_id).is_some()
152}
153
154pub fn clear_pending_approvals_for(
156 registry: Option<&SharedApprovalRegistry>,
157 child_id: &str,
158 child_attempt: u32,
159) {
160 let records: Vec<_> = {
161 let mut guard = pending().lock().recover_poison();
162 let keys: Vec<_> = guard
163 .keys()
164 .filter(|(scope, _, child, attempt, _)| {
165 *scope == scope_id(registry) && child == child_id && *attempt == child_attempt
166 })
167 .cloned()
168 .collect();
169 keys.into_iter()
170 .filter_map(|key| guard.remove(&key).map(|record| (key.4, record)))
171 .collect()
172 };
173 for (request_id, record) in records {
174 let durable = finish_durable(
175 registry,
176 &record.parent_session_id,
177 child_id,
178 record.child_attempt,
179 &request_id,
180 false,
181 Some("child_disconnected"),
182 );
183 if registry.is_none() || durable.is_some() {
184 emit_resolution(
185 child_id,
186 &request_id,
187 record,
188 "delivery_failed",
189 Some("child_disconnected"),
190 durable.as_ref(),
191 );
192 }
193 }
194}
195
196pub fn expire_pending_approval(
197 registry: Option<&SharedApprovalRegistry>,
198 child_id: &str,
199 request_id: &str,
200) -> bool {
201 let record = remove_unique_pending(registry, child_id, request_id);
202 let Some(record) = record else {
203 return false;
204 };
205 let durable = finish_durable(
206 registry,
207 &record.parent_session_id,
208 child_id,
209 record.child_attempt,
210 request_id,
211 false,
212 Some("approval_timeout"),
213 );
214 if registry.is_none() || durable.is_some() {
215 emit_resolution(
216 child_id,
217 request_id,
218 record,
219 "expired",
220 Some("approval_timeout"),
221 durable.as_ref(),
222 );
223 }
224 true
225}
226
227fn emit_resolution(
228 child_id: &str,
229 request_id: &str,
230 record: PendingApproval,
231 status: &str,
232 reason: Option<&str>,
233 durable: Option<&DurableApproval>,
234) {
235 let now = chrono::Utc::now();
236 let event = AgentEvent::ChildApprovalChanged {
237 parent_session_id: record.parent_session_id,
238 child_session_id: child_id.to_string(),
239 child_attempt: record.child_attempt,
240 request_id: request_id.to_string(),
241 version: durable.map_or_else(
242 || (now.timestamp_micros().max(0) as u64).max(record.version.saturating_add(1)),
243 |record| record.version,
244 ),
245 status: status.to_string(),
246 reason: reason.map(str::to_string),
247 tool_name: record.tool_name,
248 permission: record.permission,
249 resource: record.resource,
250 created_at: record.created_at,
251 resolved_at: Some(
252 durable
253 .map(|record| record.updated_at.clone())
254 .unwrap_or_else(|| now.to_rfc3339()),
255 ),
256 };
257 match record.event_tx.try_send(event) {
258 Ok(()) | Err(mpsc::error::TrySendError::Closed(_)) => {}
259 Err(mpsc::error::TrySendError::Full(event)) => {
260 let tx = record.event_tx;
261 tokio::spawn(async move {
262 let _ = tx.send(event).await;
263 });
264 }
265 }
266}
267
268pub fn deliver_approval_checked(
276 registry: Option<&SharedApprovalRegistry>,
277 child_id: &str,
278 request_id: &str,
279 approved: bool,
280) -> bool {
281 let Some((key, record)) = find_unique_pending(registry, child_id, request_id) else {
282 return false;
283 };
284 if let Some(registry) = registry {
287 match registry.lock().recover_poison().record_decision(
288 &record.parent_session_id,
289 child_id,
290 record.child_attempt,
291 request_id,
292 approved,
293 ) {
294 Ok(Some(_)) => {}
295 Ok(None) => return false,
296 Err(error) => {
297 tracing::error!("failed to persist child approval decision: {error}");
298 return false;
299 }
300 }
301 }
302 let Some(record) = pending().lock().recover_poison().remove(&key) else {
303 let _ = finish_durable(
304 registry,
305 &record.parent_session_id,
306 child_id,
307 record.child_attempt,
308 request_id,
309 false,
310 Some("pending_state_lost"),
311 );
312 return false;
313 };
314 let delivered = deliver_approval_scoped(
315 registry,
316 child_id,
317 record.child_attempt,
318 request_id,
319 approved,
320 );
321 let status = if delivered {
322 if approved {
323 "approved"
324 } else {
325 "denied"
326 }
327 } else {
328 "delivery_failed"
329 };
330 let durable = finish_durable(
331 registry,
332 &record.parent_session_id,
333 child_id,
334 record.child_attempt,
335 request_id,
336 delivered,
337 (!delivered).then_some("child_not_live"),
338 );
339 if registry.is_none() || durable.is_some() {
340 emit_resolution(
341 child_id,
342 request_id,
343 record,
344 status,
345 (!delivered).then_some("child_not_live"),
346 durable.as_ref(),
347 );
348 }
349 delivered
350}
351
352fn finish_durable(
353 registry: Option<&SharedApprovalRegistry>,
354 parent_id: &str,
355 child_id: &str,
356 child_attempt: u32,
357 request_id: &str,
358 delivered: bool,
359 reason: Option<&str>,
360) -> Option<DurableApproval> {
361 if let Some(registry) = registry {
362 match registry.lock().recover_poison().finish(
363 parent_id,
364 child_id,
365 child_attempt,
366 request_id,
367 delivered,
368 reason,
369 ) {
370 Ok(record) => return record,
371 Err(error) => {
372 tracing::error!("failed to persist child approval resolution: {error}");
373 }
374 }
375 }
376 None
377}
378
379fn record_event(record: DurableApproval) -> AgentEvent {
380 AgentEvent::ChildApprovalChanged {
381 parent_session_id: record.parent_session_id,
382 child_session_id: record.child_session_id,
383 child_attempt: record.child_attempt,
384 request_id: record.request_id,
385 version: record.version,
386 status: match record.state {
387 ApprovalState::Pending => "pending",
388 ApprovalState::DecisionRecorded => "decision_recorded",
389 ApprovalState::Delivered if record.approved == Some(true) => "approved",
390 ApprovalState::Delivered => "denied",
391 ApprovalState::DeliveryFailed => "delivery_failed",
392 ApprovalState::Expired => "expired",
393 }
394 .to_string(),
395 reason: record.reason,
396 tool_name: record.tool_name,
397 permission: record.permission,
398 resource: record.resource,
399 created_at: record.created_at,
400 resolved_at: Some(record.updated_at),
401 }
402}
403
404pub struct LiveActorGuard {
407 scope_id: ScopeId,
408 child_id: String,
409 child_attempt: u32,
410 approval_registry: Option<SharedApprovalRegistry>,
411}
412
413impl Drop for LiveActorGuard {
414 fn drop(&mut self) {
415 map().lock().recover_poison().remove(&(
416 self.scope_id,
417 self.child_id.clone(),
418 self.child_attempt,
419 ));
420 clear_pending_approvals_for(
423 self.approval_registry.as_ref(),
424 &self.child_id,
425 self.child_attempt,
426 );
427 }
428}
429
430pub fn register(
432 child_id: &str,
433 tx: mpsc::UnboundedSender<ParentFrame>,
434 child_attempt: u32,
435 approval_registry: Option<SharedApprovalRegistry>,
436) -> LiveActorGuard {
437 let scope_id = scope_id(approval_registry.as_ref());
438 map()
439 .lock()
440 .recover_poison()
441 .insert((scope_id, child_id.to_string(), child_attempt), tx);
442 LiveActorGuard {
443 scope_id,
444 child_id: child_id.to_string(),
445 child_attempt,
446 approval_registry,
447 }
448}
449
450fn find_unique_pending(
451 registry: Option<&SharedApprovalRegistry>,
452 child_id: &str,
453 request_id: &str,
454) -> Option<(PendingKey, PendingApproval)> {
455 let guard = pending().lock().recover_poison();
456 let scope = scope_id(registry);
457 let mut matches = guard
458 .iter()
459 .filter(|(key, _)| key.0 == scope && key.2 == child_id && key.4 == request_id);
460 let (key, record) = matches.next()?;
461 if matches.next().is_some() {
462 return None;
463 }
464 Some((key.clone(), record.clone()))
465}
466
467fn remove_unique_pending(
468 registry: Option<&SharedApprovalRegistry>,
469 child_id: &str,
470 request_id: &str,
471) -> Option<PendingApproval> {
472 let mut guard = pending().lock().recover_poison();
473 let scope = scope_id(registry);
474 let mut keys = guard
475 .keys()
476 .filter(|(key_scope, _, child, _, request)| {
477 *key_scope == scope && child == child_id && request == request_id
478 })
479 .cloned();
480 let key = keys.next()?;
481 if keys.next().is_some() {
482 return None;
483 }
484 guard.remove(&key)
485}
486
487pub fn deliver_message(child_id: &str, text: &str) -> bool {
490 let guard = map().lock().recover_poison();
491 let mut senders = guard
492 .iter()
493 .filter(|((_, child, _), _)| child == child_id)
494 .map(|(_, sender)| sender);
495 let Some(tx) = senders.next() else {
496 return false;
497 };
498 if senders.next().is_some() {
499 return false;
500 }
501 tx.send(ParentFrame::Message {
502 text: text.to_string(),
503 })
504 .is_ok()
505}
506
507pub fn deliver_approval(child_id: &str, request_id: &str, approved: bool) -> bool {
518 deliver_approval_scoped(None, child_id, 0, request_id, approved)
519}
520
521pub fn deliver_approval_scoped(
522 registry: Option<&SharedApprovalRegistry>,
523 child_id: &str,
524 child_attempt: u32,
525 request_id: &str,
526 approved: bool,
527) -> bool {
528 let guard = map().lock().recover_poison();
529 match guard.get(&(scope_id(registry), child_id.to_string(), child_attempt)) {
530 Some(tx) => tx
531 .send(ParentFrame::ApprovalReply {
532 id: request_id.to_string(),
533 approved,
534 })
535 .is_ok(),
536 None => false,
537 }
538}
539
540pub fn is_live(child_id: &str) -> bool {
542 map()
543 .lock()
544 .recover_poison()
545 .keys()
546 .any(|(_, child, _)| child == child_id)
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552
553 fn registry() -> SharedApprovalRegistry {
554 std::sync::Arc::new(Mutex::new(
555 ApprovalRegistry::open(tempfile::tempdir().unwrap().keep().join("registry.json"))
556 .unwrap(),
557 ))
558 }
559
560 #[test]
561 fn register_deliver_unregister() {
562 let (tx, mut rx) = mpsc::unbounded_channel();
563 let guard = register("c-live", tx, 0, None);
564 assert!(is_live("c-live"));
565 assert!(deliver_message("c-live", "hi"));
566 match rx.try_recv() {
567 Ok(ParentFrame::Message { text }) => assert_eq!(text, "hi"),
568 other => panic!("expected message frame, got {other:?}"),
569 }
570
571 drop(guard);
572 assert!(!is_live("c-live"));
573 assert!(!deliver_message("c-live", "gone"));
574 }
575
576 #[test]
577 fn deliver_fails_when_receiver_dropped() {
578 let (tx, rx) = mpsc::unbounded_channel();
579 let _guard = register("c-dead", tx, 0, None);
580 drop(rx);
581 assert!(!deliver_message("c-dead", "hi"));
582 }
583
584 #[test]
585 fn deliver_approval_routes_reply_frame() {
586 let (tx, mut rx) = mpsc::unbounded_channel();
587 let guard = register("c-appr", tx, 0, None);
588 assert!(deliver_approval("c-appr", "req-7", true));
589 match rx.try_recv() {
590 Ok(ParentFrame::ApprovalReply { id, approved }) => {
591 assert_eq!(id, "req-7");
592 assert!(approved);
593 }
594 other => panic!("expected approval reply, got {other:?}"),
595 }
596 drop(guard);
597 assert!(!deliver_approval("c-appr", "req-8", false));
599 }
600
601 #[test]
602 fn app_scopes_and_attempt_guards_do_not_cross_talk() {
603 let first_registry = registry();
604 let second_registry = registry();
605 let (first_tx, mut first_rx) = mpsc::unbounded_channel();
606 let (retry_tx, mut retry_rx) = mpsc::unbounded_channel();
607 let (second_tx, mut second_rx) = mpsc::unbounded_channel();
608 let old_guard = register("shared-child", first_tx, 1, Some(first_registry.clone()));
609 let retry_guard = register("shared-child", retry_tx, 2, Some(first_registry.clone()));
610 let second_guard = register("shared-child", second_tx, 1, Some(second_registry.clone()));
611
612 drop(old_guard);
613 assert!(deliver_approval_scoped(
614 Some(&first_registry),
615 "shared-child",
616 2,
617 "retry-request",
618 true,
619 ));
620 assert!(matches!(
621 retry_rx.try_recv(),
622 Ok(ParentFrame::ApprovalReply { id, .. }) if id == "retry-request"
623 ));
624 assert!(deliver_approval_scoped(
625 Some(&second_registry),
626 "shared-child",
627 1,
628 "other-app-request",
629 false,
630 ));
631 assert!(matches!(
632 second_rx.try_recv(),
633 Ok(ParentFrame::ApprovalReply { id, .. }) if id == "other-app-request"
634 ));
635 assert!(first_rx.try_recv().is_err());
636 drop(retry_guard);
637 drop(second_guard);
638 }
639
640 #[test]
641 fn pending_approval_is_one_shot() {
642 register_pending_approval("c-pend", "req-1");
643 assert!(take_pending_approval("c-pend", "req-1"));
645 assert!(!take_pending_approval("c-pend", "req-1"));
646 }
647
648 #[test]
649 fn take_of_unregistered_pair_is_false() {
650 assert!(!take_pending_approval("c-unknown", "req-x"));
652 register_pending_approval("c-known", "req-real");
654 assert!(!take_pending_approval("c-known", "req-bogus"));
655 assert!(take_pending_approval("c-known", "req-real"));
657 }
658
659 #[test]
660 fn deliver_approval_checked_only_delivers_for_registered_pair() {
661 let (tx, mut rx) = mpsc::unbounded_channel();
662 let _guard = register("c-checked", tx, 0, None);
663
664 assert!(!deliver_approval_checked(
666 None,
667 "c-checked",
668 "req-stray",
669 true
670 ));
671 assert!(rx.try_recv().is_err());
672
673 register_pending_approval("c-checked", "req-ok");
675 assert!(deliver_approval_checked(None, "c-checked", "req-ok", true));
676 match rx.try_recv() {
677 Ok(ParentFrame::ApprovalReply { id, approved }) => {
678 assert_eq!(id, "req-ok");
679 assert!(approved);
680 }
681 other => panic!("expected approval reply, got {other:?}"),
682 }
683 assert!(!deliver_approval_checked(None, "c-checked", "req-ok", true));
685 assert!(rx.try_recv().is_err());
686 }
687
688 #[test]
689 fn clear_pending_approvals_for_drops_them() {
690 register_pending_approval("c-clear", "req-a");
691 register_pending_approval("c-clear", "req-b");
692 clear_pending_approvals_for(None, "c-clear", 0);
693 assert!(!take_pending_approval("c-clear", "req-a"));
694 assert!(!take_pending_approval("c-clear", "req-b"));
695 }
696
697 #[tokio::test]
698 async fn observed_approval_emits_exactly_one_terminal_outcome() {
699 let (wire_tx, _wire_rx) = mpsc::unbounded_channel();
700 let _guard = register("c-audit", wire_tx, 0, None);
701 let (event_tx, mut event_rx) = mpsc::channel(8);
702 register_pending_approval_observed(
703 None,
704 "parent-audit",
705 "c-audit",
706 0,
707 "req-audit",
708 "Bash",
709 "execute",
710 "/tmp/x",
711 event_tx,
712 );
713
714 assert!(deliver_approval_checked(
715 None,
716 "c-audit",
717 "req-audit",
718 false
719 ));
720 assert!(!deliver_approval_checked(
721 None,
722 "c-audit",
723 "req-audit",
724 true
725 ));
726 assert!(matches!(
727 event_rx.recv().await,
728 Some(AgentEvent::ChildApprovalChanged { status, .. }) if status == "denied"
729 ));
730 assert!(event_rx.try_recv().is_err());
731 }
732
733 #[tokio::test]
734 async fn durable_resolution_uses_registry_version_and_attempt() {
735 let registry = registry();
736 let (wire_tx, _wire_rx) = mpsc::unbounded_channel();
737 let _guard = register("c-versioned", wire_tx, 7, Some(registry.clone()));
738 let (event_tx, mut event_rx) = mpsc::channel(4);
739 let (pending_version, _) = register_pending_approval_observed(
740 Some(®istry),
741 "parent-versioned",
742 "c-versioned",
743 7,
744 "req-versioned",
745 "Bash",
746 "execute",
747 "/tmp/versioned",
748 event_tx,
749 );
750
751 assert!(deliver_approval_checked(
752 Some(®istry),
753 "c-versioned",
754 "req-versioned",
755 true,
756 ));
757 assert!(matches!(
758 event_rx.recv().await,
759 Some(AgentEvent::ChildApprovalChanged {
760 child_attempt: 7,
761 version,
762 status,
763 ..
764 }) if version == pending_version + 2 && status == "approved"
765 ));
766 }
767
768 #[tokio::test]
769 async fn timeout_and_disconnect_emit_terminal_outcomes() {
770 let (event_tx, mut event_rx) = mpsc::channel(8);
771 register_pending_approval_observed(
772 None,
773 "parent-audit",
774 "c-expire",
775 0,
776 "req-expire",
777 "Bash",
778 "execute",
779 "/tmp/x",
780 event_tx.clone(),
781 );
782 assert!(expire_pending_approval(None, "c-expire", "req-expire"));
783 assert!(!expire_pending_approval(None, "c-expire", "req-expire"));
784 assert!(matches!(
785 event_rx.recv().await,
786 Some(AgentEvent::ChildApprovalChanged { status, .. }) if status == "expired"
787 ));
788
789 register_pending_approval_observed(
790 None,
791 "parent-audit",
792 "c-disconnect",
793 0,
794 "req-disconnect",
795 "Write",
796 "write",
797 "/tmp/y",
798 event_tx,
799 );
800 clear_pending_approvals_for(None, "c-disconnect", 0);
801 assert!(matches!(
802 event_rx.recv().await,
803 Some(AgentEvent::ChildApprovalChanged { status, .. }) if status == "delivery_failed"
804 ));
805 }
806}