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 ApprovalDecisionCasResult, ApprovalRegistry, ApprovalState, DurableApproval,
21 SharedApprovalRegistry,
22};
23
24const BROWSER_APPROVAL_PERMISSION: &str = "Browser interaction approval";
25const PRIVATE_APPROVAL_FIELD: &str = "[redacted]";
26
27fn is_browser_approval(tool_name: &str, resource: &str) -> bool {
28 tool_name
29 .trim()
30 .rsplit("::")
31 .next()
32 .is_some_and(|name| name.trim().eq_ignore_ascii_case("browser"))
33 || resource
34 .trim_start()
35 .get(..8)
36 .is_some_and(|prefix| prefix.eq_ignore_ascii_case("browser:"))
37}
38
39pub fn approval_record_for_display(mut record: DurableApproval) -> DurableApproval {
42 if is_browser_approval(&record.tool_name, &record.resource) {
43 record.tool_name = "browser".into();
44 record.permission = BROWSER_APPROVAL_PERMISSION.into();
45 record.resource = PRIVATE_APPROVAL_FIELD.into();
46 record.reason = record.reason.map(|_| PRIVATE_APPROVAL_FIELD.into());
47 }
48 record
49}
50
51pub fn approval_event_for_display(mut event: AgentEvent) -> AgentEvent {
54 match &mut event {
55 AgentEvent::ChildApprovalRequested {
56 tool_name,
57 permission,
58 resource,
59 ..
60 } if is_browser_approval(tool_name, resource) => {
61 *tool_name = "browser".into();
62 *permission = BROWSER_APPROVAL_PERMISSION.into();
63 *resource = PRIVATE_APPROVAL_FIELD.into();
64 }
65 AgentEvent::ChildApprovalChanged {
66 tool_name,
67 permission,
68 resource,
69 reason,
70 ..
71 } if is_browser_approval(tool_name, resource) => {
72 *tool_name = "browser".into();
73 *permission = BROWSER_APPROVAL_PERMISSION.into();
74 *resource = PRIVATE_APPROVAL_FIELD.into();
75 *reason = reason.take().map(|_| PRIVATE_APPROVAL_FIELD.into());
76 }
77 _ => {}
78 }
79 event
80}
81
82type ScopeId = usize;
83type LiveKey = (ScopeId, String, u32);
84type PendingKey = (ScopeId, String, String, u32, String);
85
86fn scope_id(registry: Option<&SharedApprovalRegistry>) -> ScopeId {
87 registry.map_or(0, |registry| registry.lock().recover_poison().scope_id())
88}
89
90fn map() -> &'static Mutex<HashMap<LiveKey, mpsc::UnboundedSender<ParentFrame>>> {
91 static MAP: OnceLock<Mutex<HashMap<LiveKey, mpsc::UnboundedSender<ParentFrame>>>> =
92 OnceLock::new();
93 MAP.get_or_init(|| Mutex::new(HashMap::new()))
94}
95
96#[derive(Clone)]
103struct PendingApproval {
104 parent_session_id: String,
105 tool_name: String,
106 permission: String,
107 resource: String,
108 created_at: String,
109 version: u64,
110 child_attempt: u32,
111 event_tx: mpsc::Sender<AgentEvent>,
112}
113
114fn pending() -> &'static Mutex<HashMap<PendingKey, PendingApproval>> {
115 static PENDING: OnceLock<Mutex<HashMap<PendingKey, PendingApproval>>> = OnceLock::new();
116 PENDING.get_or_init(|| Mutex::new(HashMap::new()))
117}
118
119pub fn initialize_durable_approvals(
122 path: std::path::PathBuf,
123) -> std::io::Result<(SharedApprovalRegistry, Vec<AgentEvent>)> {
124 let mut registry = ApprovalRegistry::open(path)?;
125 let reconciled = registry.reconcile_restart()?;
126 let events = reconciled.into_iter().map(record_event).collect();
127 Ok((std::sync::Arc::new(Mutex::new(registry)), events))
128}
129
130pub struct PendingApprovalObservation<'a> {
132 pub registry: Option<&'a SharedApprovalRegistry>,
133 pub parent_session_id: &'a str,
134 pub child_id: &'a str,
135 pub child_attempt: u32,
136 pub request_id: &'a str,
137 pub tool_name: &'a str,
138 pub permission: &'a str,
139 pub resource: &'a str,
140 pub event_tx: mpsc::Sender<AgentEvent>,
141}
142
143pub fn observe_pending_approval(observation: PendingApprovalObservation<'_>) -> (u64, String) {
147 let PendingApprovalObservation {
148 registry,
149 parent_session_id,
150 child_id,
151 child_attempt,
152 request_id,
153 tool_name,
154 permission,
155 resource,
156 event_tx,
157 } = observation;
158 let now = chrono::Utc::now();
159 let version = now.timestamp_micros().max(0) as u64;
160 let created_at = now.to_rfc3339();
161 let durable_record = DurableApproval {
162 parent_session_id: parent_session_id.to_string(),
163 child_session_id: child_id.to_string(),
164 child_attempt,
165 request_id: request_id.to_string(),
166 tool_name: tool_name.to_string(),
167 permission: permission.to_string(),
168 resource: resource.to_string(),
169 created_at: created_at.clone(),
170 updated_at: created_at.clone(),
171 version,
172 state: ApprovalState::Pending,
173 approved: None,
174 reason: None,
175 };
176 if let Some(registry) = registry {
177 if let Err(error) = registry.lock().recover_poison().register(durable_record) {
178 tracing::error!("failed to persist pending child approval: {error}");
179 return (0, created_at);
180 }
181 }
182 pending().lock().recover_poison().insert(
183 (
184 scope_id(registry),
185 parent_session_id.to_string(),
186 child_id.to_string(),
187 child_attempt,
188 request_id.to_string(),
189 ),
190 PendingApproval {
191 parent_session_id: parent_session_id.to_string(),
192 tool_name: tool_name.to_string(),
193 permission: permission.to_string(),
194 resource: resource.to_string(),
195 created_at: created_at.clone(),
196 version,
197 child_attempt,
198 event_tx,
199 },
200 );
201 (version, created_at)
202}
203
204#[allow(
209 clippy::too_many_arguments,
210 reason = "public compatibility wrapper; the typed observation is the canonical API"
211)]
212pub fn register_pending_approval_observed(
213 registry: Option<&SharedApprovalRegistry>,
214 parent_session_id: &str,
215 child_id: &str,
216 child_attempt: u32,
217 request_id: &str,
218 tool_name: &str,
219 permission: &str,
220 resource: &str,
221 event_tx: mpsc::Sender<AgentEvent>,
222) -> (u64, String) {
223 observe_pending_approval(PendingApprovalObservation {
224 registry,
225 parent_session_id,
226 child_id,
227 child_attempt,
228 request_id,
229 tool_name,
230 permission,
231 resource,
232 event_tx,
233 })
234}
235
236#[cfg(test)]
237fn register_pending_approval(child_id: &str, request_id: &str) {
238 let (event_tx, _rx) = mpsc::channel(1);
239 let _ = observe_pending_approval(PendingApprovalObservation {
240 registry: None,
241 parent_session_id: "test-parent",
242 child_id,
243 child_attempt: 0,
244 request_id,
245 tool_name: "test-tool",
246 permission: "test-permission",
247 resource: "test-resource",
248 event_tx,
249 });
250}
251
252pub fn take_pending_approval(child_id: &str, request_id: &str) -> bool {
256 remove_unique_pending(None, child_id, request_id).is_some()
257}
258
259pub fn clear_pending_approvals_for(
261 registry: Option<&SharedApprovalRegistry>,
262 child_id: &str,
263 child_attempt: u32,
264) {
265 let records: Vec<_> = {
266 let mut guard = pending().lock().recover_poison();
267 let keys: Vec<_> = guard
268 .keys()
269 .filter(|(scope, _, child, attempt, _)| {
270 *scope == scope_id(registry) && child == child_id && *attempt == child_attempt
271 })
272 .cloned()
273 .collect();
274 keys.into_iter()
275 .filter_map(|key| guard.remove(&key).map(|record| (key.4, record)))
276 .collect()
277 };
278 for (request_id, record) in records {
279 let durable = finish_durable(
280 registry,
281 &record.parent_session_id,
282 child_id,
283 record.child_attempt,
284 &request_id,
285 false,
286 Some("child_disconnected"),
287 );
288 if registry.is_none() || durable.is_some() {
289 emit_resolution(
290 child_id,
291 &request_id,
292 record,
293 "delivery_failed",
294 Some("child_disconnected"),
295 durable.as_ref(),
296 );
297 }
298 }
299}
300
301pub fn expire_pending_approval(
302 registry: Option<&SharedApprovalRegistry>,
303 child_id: &str,
304 request_id: &str,
305) -> bool {
306 let record = remove_unique_pending(registry, child_id, request_id);
307 let Some(record) = record else {
308 return false;
309 };
310 let durable = finish_durable(
311 registry,
312 &record.parent_session_id,
313 child_id,
314 record.child_attempt,
315 request_id,
316 false,
317 Some("approval_timeout"),
318 );
319 if registry.is_none() || durable.is_some() {
320 emit_resolution(
321 child_id,
322 request_id,
323 record,
324 "expired",
325 Some("approval_timeout"),
326 durable.as_ref(),
327 );
328 }
329 true
330}
331
332fn emit_resolution(
333 child_id: &str,
334 request_id: &str,
335 record: PendingApproval,
336 status: &str,
337 reason: Option<&str>,
338 durable: Option<&DurableApproval>,
339) {
340 let now = chrono::Utc::now();
341 let event = approval_event_for_display(AgentEvent::ChildApprovalChanged {
342 parent_session_id: record.parent_session_id,
343 child_session_id: child_id.to_string(),
344 child_attempt: record.child_attempt,
345 request_id: request_id.to_string(),
346 version: durable.map_or_else(
347 || (now.timestamp_micros().max(0) as u64).max(record.version.saturating_add(1)),
348 |record| record.version,
349 ),
350 status: status.to_string(),
351 reason: reason.map(str::to_string),
352 tool_name: record.tool_name,
353 permission: record.permission,
354 resource: record.resource,
355 created_at: record.created_at,
356 resolved_at: Some(
357 durable
358 .map(|record| record.updated_at.clone())
359 .unwrap_or_else(|| now.to_rfc3339()),
360 ),
361 });
362 match record.event_tx.try_send(event) {
363 Ok(()) | Err(mpsc::error::TrySendError::Closed(_)) => {}
364 Err(mpsc::error::TrySendError::Full(event)) => {
365 let tx = record.event_tx;
366 tokio::spawn(async move {
367 let _ = tx.send(event).await;
368 });
369 }
370 }
371}
372
373pub fn deliver_approval_checked(
381 registry: Option<&SharedApprovalRegistry>,
382 child_id: &str,
383 request_id: &str,
384 approved: bool,
385) -> bool {
386 let Some((_, record)) = find_unique_pending(registry, child_id, request_id) else {
387 return false;
388 };
389 deliver_approval_checked_cas(
390 registry,
391 &record.parent_session_id,
392 child_id,
393 record.child_attempt,
394 request_id,
395 record.version,
396 approved,
397 ) == ApprovalDeliveryResult::Delivered
398}
399
400#[derive(Debug, Clone, Copy, PartialEq, Eq)]
401pub enum ApprovalDeliveryResult {
402 Delivered,
403 NotFound,
404 Conflict,
405 DeliveryFailed,
406}
407
408#[allow(
413 clippy::too_many_arguments,
414 reason = "the complete durable approval CAS identity must remain explicit"
415)]
416pub fn deliver_approval_checked_cas(
417 registry: Option<&SharedApprovalRegistry>,
418 parent_session_id: &str,
419 child_id: &str,
420 child_attempt: u32,
421 request_id: &str,
422 expected_version: u64,
423 approved: bool,
424) -> ApprovalDeliveryResult {
425 let (key, record) = match find_pending_cas(
426 registry,
427 parent_session_id,
428 child_id,
429 child_attempt,
430 request_id,
431 expected_version,
432 ) {
433 Ok(found) => found,
434 Err(result) => return result,
435 };
436 if let Some(registry) = registry {
439 match registry.lock().recover_poison().record_decision_cas(
440 parent_session_id,
441 child_id,
442 child_attempt,
443 request_id,
444 expected_version,
445 approved,
446 ) {
447 Ok(ApprovalDecisionCasResult::Recorded(_)) => {}
448 Ok(ApprovalDecisionCasResult::Conflict) => return ApprovalDeliveryResult::Conflict,
449 Ok(ApprovalDecisionCasResult::NotFound) => return ApprovalDeliveryResult::NotFound,
450 Err(error) => {
451 tracing::error!("failed to persist child approval decision: {error}");
452 return ApprovalDeliveryResult::DeliveryFailed;
453 }
454 }
455 }
456 let Some(record) = pending().lock().recover_poison().remove(&key) else {
457 let _ = finish_durable(
458 registry,
459 &record.parent_session_id,
460 child_id,
461 record.child_attempt,
462 request_id,
463 false,
464 Some("pending_state_lost"),
465 );
466 return ApprovalDeliveryResult::DeliveryFailed;
467 };
468 let delivered = deliver_approval_scoped(
469 registry,
470 child_id,
471 record.child_attempt,
472 request_id,
473 approved,
474 );
475 let status = if delivered {
476 if approved {
477 "approved"
478 } else {
479 "denied"
480 }
481 } else {
482 "delivery_failed"
483 };
484 let durable = finish_durable(
485 registry,
486 &record.parent_session_id,
487 child_id,
488 record.child_attempt,
489 request_id,
490 delivered,
491 (!delivered).then_some("child_not_live"),
492 );
493 if registry.is_none() || durable.is_some() {
494 emit_resolution(
495 child_id,
496 request_id,
497 record,
498 status,
499 (!delivered).then_some("child_not_live"),
500 durable.as_ref(),
501 );
502 }
503 if delivered {
504 ApprovalDeliveryResult::Delivered
505 } else {
506 ApprovalDeliveryResult::DeliveryFailed
507 }
508}
509
510fn finish_durable(
511 registry: Option<&SharedApprovalRegistry>,
512 parent_id: &str,
513 child_id: &str,
514 child_attempt: u32,
515 request_id: &str,
516 delivered: bool,
517 reason: Option<&str>,
518) -> Option<DurableApproval> {
519 if let Some(registry) = registry {
520 match registry.lock().recover_poison().finish(
521 parent_id,
522 child_id,
523 child_attempt,
524 request_id,
525 delivered,
526 reason,
527 ) {
528 Ok(record) => return record,
529 Err(error) => {
530 tracing::error!("failed to persist child approval resolution: {error}");
531 }
532 }
533 }
534 None
535}
536
537fn record_event(record: DurableApproval) -> AgentEvent {
538 approval_event_for_display(AgentEvent::ChildApprovalChanged {
539 parent_session_id: record.parent_session_id,
540 child_session_id: record.child_session_id,
541 child_attempt: record.child_attempt,
542 request_id: record.request_id,
543 version: record.version,
544 status: match record.state {
545 ApprovalState::Pending => "pending",
546 ApprovalState::DecisionRecorded => "decision_recorded",
547 ApprovalState::Delivered if record.approved == Some(true) => "approved",
548 ApprovalState::Delivered => "denied",
549 ApprovalState::DeliveryFailed => "delivery_failed",
550 ApprovalState::Expired => "expired",
551 }
552 .to_string(),
553 reason: record.reason,
554 tool_name: record.tool_name,
555 permission: record.permission,
556 resource: record.resource,
557 created_at: record.created_at,
558 resolved_at: Some(record.updated_at),
559 })
560}
561
562pub struct LiveActorGuard {
565 scope_id: ScopeId,
566 child_id: String,
567 child_attempt: u32,
568 approval_registry: Option<SharedApprovalRegistry>,
569}
570
571impl Drop for LiveActorGuard {
572 fn drop(&mut self) {
573 map().lock().recover_poison().remove(&(
574 self.scope_id,
575 self.child_id.clone(),
576 self.child_attempt,
577 ));
578 clear_pending_approvals_for(
581 self.approval_registry.as_ref(),
582 &self.child_id,
583 self.child_attempt,
584 );
585 }
586}
587
588pub fn register(
590 child_id: &str,
591 tx: mpsc::UnboundedSender<ParentFrame>,
592 child_attempt: u32,
593 approval_registry: Option<SharedApprovalRegistry>,
594) -> LiveActorGuard {
595 let scope_id = scope_id(approval_registry.as_ref());
596 map()
597 .lock()
598 .recover_poison()
599 .insert((scope_id, child_id.to_string(), child_attempt), tx);
600 LiveActorGuard {
601 scope_id,
602 child_id: child_id.to_string(),
603 child_attempt,
604 approval_registry,
605 }
606}
607
608fn find_unique_pending(
609 registry: Option<&SharedApprovalRegistry>,
610 child_id: &str,
611 request_id: &str,
612) -> Option<(PendingKey, PendingApproval)> {
613 let guard = pending().lock().recover_poison();
614 let scope = scope_id(registry);
615 let mut matches = guard
616 .iter()
617 .filter(|(key, _)| key.0 == scope && key.2 == child_id && key.4 == request_id);
618 let (key, record) = matches.next()?;
619 if matches.next().is_some() {
620 return None;
621 }
622 Some((key.clone(), record.clone()))
623}
624
625fn find_pending_cas(
626 registry: Option<&SharedApprovalRegistry>,
627 parent_session_id: &str,
628 child_id: &str,
629 child_attempt: u32,
630 request_id: &str,
631 expected_version: u64,
632) -> Result<(PendingKey, PendingApproval), ApprovalDeliveryResult> {
633 let scope = scope_id(registry);
634 let guard = pending().lock().recover_poison();
635 let key = (
636 scope,
637 parent_session_id.to_string(),
638 child_id.to_string(),
639 child_attempt,
640 request_id.to_string(),
641 );
642 if let Some(record) = guard.get(&key) {
643 if record.parent_session_id != parent_session_id
644 || record.child_attempt != child_attempt
645 || record.version != expected_version
646 {
647 return Err(ApprovalDeliveryResult::Conflict);
648 }
649 return Ok((key, record.clone()));
650 }
651 if guard.keys().any(|candidate| {
652 candidate.0 == scope && candidate.2 == child_id && candidate.4 == request_id
653 }) {
654 Err(ApprovalDeliveryResult::Conflict)
655 } else {
656 Err(ApprovalDeliveryResult::NotFound)
657 }
658}
659
660fn remove_unique_pending(
661 registry: Option<&SharedApprovalRegistry>,
662 child_id: &str,
663 request_id: &str,
664) -> Option<PendingApproval> {
665 let mut guard = pending().lock().recover_poison();
666 let scope = scope_id(registry);
667 let mut keys = guard
668 .keys()
669 .filter(|(key_scope, _, child, _, request)| {
670 *key_scope == scope && child == child_id && request == request_id
671 })
672 .cloned();
673 let key = keys.next()?;
674 if keys.next().is_some() {
675 return None;
676 }
677 guard.remove(&key)
678}
679
680pub fn deliver_message(child_id: &str, text: &str) -> bool {
683 let guard = map().lock().recover_poison();
684 let mut senders = guard
685 .iter()
686 .filter(|((_, child, _), _)| child == child_id)
687 .map(|(_, sender)| sender);
688 let Some(tx) = senders.next() else {
689 return false;
690 };
691 if senders.next().is_some() {
692 return false;
693 }
694 tx.send(ParentFrame::Message {
695 text: text.to_string(),
696 })
697 .is_ok()
698}
699
700pub fn deliver_approval(child_id: &str, request_id: &str, approved: bool) -> bool {
711 deliver_approval_scoped(None, child_id, 0, request_id, approved)
712}
713
714pub fn deliver_approval_scoped(
715 registry: Option<&SharedApprovalRegistry>,
716 child_id: &str,
717 child_attempt: u32,
718 request_id: &str,
719 approved: bool,
720) -> bool {
721 let guard = map().lock().recover_poison();
722 match guard.get(&(scope_id(registry), child_id.to_string(), child_attempt)) {
723 Some(tx) => tx
724 .send(ParentFrame::ApprovalReply {
725 id: request_id.to_string(),
726 approved,
727 })
728 .is_ok(),
729 None => false,
730 }
731}
732
733pub fn is_live(child_id: &str) -> bool {
735 map()
736 .lock()
737 .recover_poison()
738 .keys()
739 .any(|(_, child, _)| child == child_id)
740}
741
742#[cfg(test)]
743mod tests {
744 use super::*;
745
746 const PRIVATE_MARKER: &str = "private-download-selector-and-url";
747
748 fn browser_approval(state: ApprovalState) -> DurableApproval {
749 DurableApproval {
750 parent_session_id: "parent-private".into(),
751 child_session_id: "child-private".into(),
752 child_attempt: 3,
753 request_id: "request-private".into(),
754 tool_name: "default::browser".into(),
755 permission: PRIVATE_MARKER.into(),
756 resource: format!("browser:17:download:css:{PRIVATE_MARKER}"),
757 created_at: "2026-09-24T00:00:00Z".into(),
758 updated_at: "2026-09-24T00:00:01Z".into(),
759 version: 9,
760 state,
761 approved: None,
762 reason: Some(PRIVATE_MARKER.into()),
763 }
764 }
765
766 #[test]
767 fn browser_approval_display_copy_preserves_identity_but_hides_private_fields() {
768 let original = browser_approval(ApprovalState::Pending);
769 let displayed = approval_record_for_display(original.clone());
770 let wire = serde_json::to_string(&displayed).unwrap();
771 assert!(!wire.contains(PRIVATE_MARKER));
772 assert_eq!(displayed.parent_session_id, original.parent_session_id);
773 assert_eq!(displayed.child_session_id, original.child_session_id);
774 assert_eq!(displayed.child_attempt, original.child_attempt);
775 assert_eq!(displayed.request_id, original.request_id);
776 assert_eq!(displayed.version, original.version);
777 assert_eq!(displayed.state, original.state);
778 assert_eq!(displayed.created_at, original.created_at);
779 assert_eq!(displayed.updated_at, original.updated_at);
780 assert!(original.resource.contains(PRIVATE_MARKER));
781
782 let requested = approval_event_for_display(AgentEvent::ChildApprovalRequested {
783 child_session_id: original.child_session_id.clone(),
784 request_id: original.request_id.clone(),
785 tool_name: original.tool_name.clone(),
786 permission: original.permission.clone(),
787 resource: original.resource.clone(),
788 });
789 assert!(!serde_json::to_string(&requested)
790 .unwrap()
791 .contains(PRIVATE_MARKER));
792 assert!(
793 matches!(requested, AgentEvent::ChildApprovalRequested { request_id, .. }
794 if request_id == original.request_id)
795 );
796
797 let changed = record_event(original.clone());
798 let changed_wire = serde_json::to_string(&changed).unwrap();
799 assert!(!changed_wire.contains(PRIVATE_MARKER));
800 assert!(matches!(changed, AgentEvent::ChildApprovalChanged {
801 request_id, child_attempt: 3, version: 9, status, ..
802 } if request_id == original.request_id && status == "pending"));
803
804 let ordinary = DurableApproval {
805 tool_name: "Read".into(),
806 resource: PRIVATE_MARKER.into(),
807 ..original
808 };
809 assert_eq!(
810 approval_record_for_display(ordinary.clone()).resource,
811 ordinary.resource
812 );
813 let malformed = DurableApproval {
814 tool_name: "Read".into(),
815 resource: format!(" BROWSER:17:download:css:{PRIVATE_MARKER}"),
816 ..ordinary
817 };
818 assert!(
819 !serde_json::to_string(&approval_record_for_display(malformed))
820 .unwrap()
821 .contains(PRIVATE_MARKER)
822 );
823 }
824
825 #[tokio::test]
826 async fn browser_approval_resolution_and_restart_events_hide_private_fields() {
827 let temp = tempfile::tempdir().unwrap();
828 let path = temp.path().join("approvals.json");
829 let registry =
830 std::sync::Arc::new(Mutex::new(ApprovalRegistry::open(path.clone()).unwrap()));
831 let (wire_tx, mut wire_rx) = mpsc::unbounded_channel();
832 let _guard = register("child-private", wire_tx, 3, Some(registry.clone()));
833 let (event_tx, mut event_rx) = mpsc::channel(8);
834 let (version, _) = observe_pending_approval(PendingApprovalObservation {
835 registry: Some(®istry),
836 parent_session_id: "parent-private",
837 child_id: "child-private",
838 child_attempt: 3,
839 request_id: "request-private",
840 tool_name: "default::browser",
841 permission: PRIVATE_MARKER,
842 resource: &format!("browser:17:download:css:{PRIVATE_MARKER}"),
843 event_tx,
844 });
845 let authority = registry.lock().unwrap().unresolved_snapshot();
846 assert!(authority.approvals[0].resource.contains(PRIVATE_MARKER));
847 assert_eq!(
848 deliver_approval_checked_cas(
849 Some(®istry),
850 "parent-private",
851 "child-private",
852 3,
853 "request-private",
854 version,
855 true,
856 ),
857 ApprovalDeliveryResult::Delivered
858 );
859 assert!(matches!(wire_rx.try_recv(), Ok(ParentFrame::ApprovalReply {
860 id, approved: true
861 }) if id == "request-private"));
862 let changed = event_rx.recv().await.unwrap();
863 assert!(!serde_json::to_string(&changed)
864 .unwrap()
865 .contains(PRIVATE_MARKER));
866 assert!(matches!(changed, AgentEvent::ChildApprovalChanged {
867 request_id, version: changed_version, status, ..
868 } if request_id == "request-private" && changed_version > version && status == "approved"));
869 assert!(std::fs::read_to_string(&path)
870 .unwrap()
871 .contains(PRIVATE_MARKER));
872
873 let mut restart = browser_approval(ApprovalState::Pending);
876 restart.request_id = "restart-private".into();
877 registry.lock().unwrap().register(restart).unwrap();
878 drop(_guard);
879 drop(registry);
880 let (_, restarted_events) = initialize_durable_approvals(path.clone()).unwrap();
881 assert_eq!(restarted_events.len(), 1);
882 let restart_wire = serde_json::to_string(&restarted_events).unwrap();
883 assert!(!restart_wire.contains(PRIVATE_MARKER));
884 assert!(restart_wire.contains("restart-private"));
885 assert!(std::fs::read_to_string(path)
886 .unwrap()
887 .contains(PRIVATE_MARKER));
888 }
889
890 #[tokio::test]
891 async fn browser_denied_and_expired_events_hide_private_fields() {
892 let (wire_tx, _wire_rx) = mpsc::unbounded_channel();
893 let _guard = register("child-denied-private", wire_tx, 0, None);
894 let (event_tx, mut event_rx) = mpsc::channel(4);
895 for (child_id, request_id) in [
896 ("child-denied-private", "request-denied-private"),
897 ("child-expired-private", "request-expired-private"),
898 ] {
899 observe_pending_approval(PendingApprovalObservation {
900 registry: None,
901 parent_session_id: "parent-private",
902 child_id,
903 child_attempt: 0,
904 request_id,
905 tool_name: "browser",
906 permission: PRIVATE_MARKER,
907 resource: PRIVATE_MARKER,
908 event_tx: event_tx.clone(),
909 });
910 }
911 assert!(deliver_approval_checked(
912 None,
913 "child-denied-private",
914 "request-denied-private",
915 false,
916 ));
917 assert!(expire_pending_approval(
918 None,
919 "child-expired-private",
920 "request-expired-private",
921 ));
922 let denied = event_rx.recv().await.unwrap();
923 let expired = event_rx.recv().await.unwrap();
924 for event in [denied, expired] {
925 assert!(!serde_json::to_string(&event)
926 .unwrap()
927 .contains(PRIVATE_MARKER));
928 assert!(matches!(event, AgentEvent::ChildApprovalChanged { .. }));
929 }
930 }
931
932 fn registry() -> SharedApprovalRegistry {
933 std::sync::Arc::new(Mutex::new(
934 ApprovalRegistry::open(tempfile::tempdir().unwrap().keep().join("registry.json"))
935 .unwrap(),
936 ))
937 }
938
939 #[test]
940 fn register_deliver_unregister() {
941 let (tx, mut rx) = mpsc::unbounded_channel();
942 let guard = register("c-live", tx, 0, None);
943 assert!(is_live("c-live"));
944 assert!(deliver_message("c-live", "hi"));
945 match rx.try_recv() {
946 Ok(ParentFrame::Message { text }) => assert_eq!(text, "hi"),
947 other => panic!("expected message frame, got {other:?}"),
948 }
949
950 drop(guard);
951 assert!(!is_live("c-live"));
952 assert!(!deliver_message("c-live", "gone"));
953 }
954
955 #[test]
956 fn deliver_fails_when_receiver_dropped() {
957 let (tx, rx) = mpsc::unbounded_channel();
958 let _guard = register("c-dead", tx, 0, None);
959 drop(rx);
960 assert!(!deliver_message("c-dead", "hi"));
961 }
962
963 #[test]
964 fn deliver_approval_routes_reply_frame() {
965 let (tx, mut rx) = mpsc::unbounded_channel();
966 let guard = register("c-appr", tx, 0, None);
967 assert!(deliver_approval("c-appr", "req-7", true));
968 match rx.try_recv() {
969 Ok(ParentFrame::ApprovalReply { id, approved }) => {
970 assert_eq!(id, "req-7");
971 assert!(approved);
972 }
973 other => panic!("expected approval reply, got {other:?}"),
974 }
975 drop(guard);
976 assert!(!deliver_approval("c-appr", "req-8", false));
978 }
979
980 #[test]
981 fn app_scopes_and_attempt_guards_do_not_cross_talk() {
982 let first_registry = registry();
983 let second_registry = registry();
984 let (first_tx, mut first_rx) = mpsc::unbounded_channel();
985 let (retry_tx, mut retry_rx) = mpsc::unbounded_channel();
986 let (second_tx, mut second_rx) = mpsc::unbounded_channel();
987 let old_guard = register("shared-child", first_tx, 1, Some(first_registry.clone()));
988 let retry_guard = register("shared-child", retry_tx, 2, Some(first_registry.clone()));
989 let second_guard = register("shared-child", second_tx, 1, Some(second_registry.clone()));
990
991 drop(old_guard);
992 assert!(deliver_approval_scoped(
993 Some(&first_registry),
994 "shared-child",
995 2,
996 "retry-request",
997 true,
998 ));
999 assert!(matches!(
1000 retry_rx.try_recv(),
1001 Ok(ParentFrame::ApprovalReply { id, .. }) if id == "retry-request"
1002 ));
1003 assert!(deliver_approval_scoped(
1004 Some(&second_registry),
1005 "shared-child",
1006 1,
1007 "other-app-request",
1008 false,
1009 ));
1010 assert!(matches!(
1011 second_rx.try_recv(),
1012 Ok(ParentFrame::ApprovalReply { id, .. }) if id == "other-app-request"
1013 ));
1014 assert!(first_rx.try_recv().is_err());
1015 drop(retry_guard);
1016 drop(second_guard);
1017 }
1018
1019 #[test]
1020 fn pending_approval_is_one_shot() {
1021 register_pending_approval("c-pend", "req-1");
1022 assert!(take_pending_approval("c-pend", "req-1"));
1024 assert!(!take_pending_approval("c-pend", "req-1"));
1025 }
1026
1027 #[test]
1028 fn take_of_unregistered_pair_is_false() {
1029 assert!(!take_pending_approval("c-unknown", "req-x"));
1031 register_pending_approval("c-known", "req-real");
1033 assert!(!take_pending_approval("c-known", "req-bogus"));
1034 assert!(take_pending_approval("c-known", "req-real"));
1036 }
1037
1038 #[test]
1039 fn deliver_approval_checked_only_delivers_for_registered_pair() {
1040 let (tx, mut rx) = mpsc::unbounded_channel();
1041 let _guard = register("c-checked", tx, 0, None);
1042
1043 assert!(!deliver_approval_checked(
1045 None,
1046 "c-checked",
1047 "req-stray",
1048 true
1049 ));
1050 assert!(rx.try_recv().is_err());
1051
1052 register_pending_approval("c-checked", "req-ok");
1054 assert!(deliver_approval_checked(None, "c-checked", "req-ok", true));
1055 match rx.try_recv() {
1056 Ok(ParentFrame::ApprovalReply { id, approved }) => {
1057 assert_eq!(id, "req-ok");
1058 assert!(approved);
1059 }
1060 other => panic!("expected approval reply, got {other:?}"),
1061 }
1062 assert!(!deliver_approval_checked(None, "c-checked", "req-ok", true));
1064 assert!(rx.try_recv().is_err());
1065 }
1066
1067 #[test]
1068 fn clear_pending_approvals_for_drops_them() {
1069 register_pending_approval("c-clear", "req-a");
1070 register_pending_approval("c-clear", "req-b");
1071 clear_pending_approvals_for(None, "c-clear", 0);
1072 assert!(!take_pending_approval("c-clear", "req-a"));
1073 assert!(!take_pending_approval("c-clear", "req-b"));
1074 }
1075
1076 #[tokio::test]
1077 async fn observed_approval_emits_exactly_one_terminal_outcome() {
1078 let (wire_tx, _wire_rx) = mpsc::unbounded_channel();
1079 let _guard = register("c-audit", wire_tx, 0, None);
1080 let (event_tx, mut event_rx) = mpsc::channel(8);
1081 observe_pending_approval(PendingApprovalObservation {
1082 registry: None,
1083 parent_session_id: "parent-audit",
1084 child_id: "c-audit",
1085 child_attempt: 0,
1086 request_id: "req-audit",
1087 tool_name: "Bash",
1088 permission: "execute",
1089 resource: "/tmp/x",
1090 event_tx,
1091 });
1092
1093 assert!(deliver_approval_checked(
1094 None,
1095 "c-audit",
1096 "req-audit",
1097 false
1098 ));
1099 assert!(!deliver_approval_checked(
1100 None,
1101 "c-audit",
1102 "req-audit",
1103 true
1104 ));
1105 assert!(matches!(
1106 event_rx.recv().await,
1107 Some(AgentEvent::ChildApprovalChanged { status, .. }) if status == "denied"
1108 ));
1109 assert!(event_rx.try_recv().is_err());
1110 }
1111
1112 #[tokio::test]
1113 async fn durable_resolution_uses_registry_version_and_attempt() {
1114 let registry = registry();
1115 let (wire_tx, _wire_rx) = mpsc::unbounded_channel();
1116 let _guard = register("c-versioned", wire_tx, 7, Some(registry.clone()));
1117 let (event_tx, mut event_rx) = mpsc::channel(4);
1118 let (pending_version, _) = observe_pending_approval(PendingApprovalObservation {
1119 registry: Some(®istry),
1120 parent_session_id: "parent-versioned",
1121 child_id: "c-versioned",
1122 child_attempt: 7,
1123 request_id: "req-versioned",
1124 tool_name: "Bash",
1125 permission: "execute",
1126 resource: "/tmp/versioned",
1127 event_tx,
1128 });
1129
1130 assert!(deliver_approval_checked(
1131 Some(®istry),
1132 "c-versioned",
1133 "req-versioned",
1134 true,
1135 ));
1136 assert!(matches!(
1137 event_rx.recv().await,
1138 Some(AgentEvent::ChildApprovalChanged {
1139 child_attempt: 7,
1140 version,
1141 status,
1142 ..
1143 }) if version == pending_version + 2 && status == "approved"
1144 ));
1145 }
1146
1147 #[tokio::test]
1148 async fn delayed_attempt_one_decision_cannot_approve_attempt_two() {
1149 let registry = registry();
1150 let child_id = "c-delayed-attempt";
1151 let request_id = "req-reused";
1152 let (attempt_one_tx, mut attempt_one_rx) = mpsc::unbounded_channel();
1153 let attempt_one_guard = register(child_id, attempt_one_tx, 1, Some(registry.clone()));
1154 let (event_tx, _event_rx) = mpsc::channel(8);
1155 let (attempt_one_version, _) = observe_pending_approval(PendingApprovalObservation {
1156 registry: Some(®istry),
1157 parent_session_id: "parent-delayed",
1158 child_id,
1159 child_attempt: 1,
1160 request_id,
1161 tool_name: "Bash",
1162 permission: "execute",
1163 resource: "cargo test",
1164 event_tx: event_tx.clone(),
1165 });
1166 clear_pending_approvals_for(Some(®istry), child_id, 1);
1167
1168 let (attempt_two_tx, mut attempt_two_rx) = mpsc::unbounded_channel();
1169 let _attempt_two_guard = register(child_id, attempt_two_tx, 2, Some(registry.clone()));
1170 let (attempt_two_version, _) = observe_pending_approval(PendingApprovalObservation {
1171 registry: Some(®istry),
1172 parent_session_id: "parent-delayed",
1173 child_id,
1174 child_attempt: 2,
1175 request_id,
1176 tool_name: "Bash",
1177 permission: "execute",
1178 resource: "cargo test",
1179 event_tx,
1180 });
1181
1182 assert_eq!(
1183 deliver_approval_checked_cas(
1184 Some(®istry),
1185 "parent-delayed",
1186 child_id,
1187 1,
1188 request_id,
1189 attempt_one_version,
1190 true,
1191 ),
1192 ApprovalDeliveryResult::Conflict
1193 );
1194 assert!(attempt_one_rx.try_recv().is_err());
1195 assert!(attempt_two_rx.try_recv().is_err());
1196
1197 assert_eq!(
1198 deliver_approval_checked_cas(
1199 Some(®istry),
1200 "parent-delayed",
1201 child_id,
1202 2,
1203 request_id,
1204 attempt_two_version,
1205 true,
1206 ),
1207 ApprovalDeliveryResult::Delivered
1208 );
1209 assert!(matches!(
1210 attempt_two_rx.try_recv(),
1211 Ok(ParentFrame::ApprovalReply { id, approved }) if id == request_id && approved
1212 ));
1213 drop(attempt_one_guard);
1214 }
1215
1216 #[tokio::test]
1217 async fn parent_and_version_mismatches_do_not_consume_current_approval() {
1218 let registry = registry();
1219 let child_id = "c-identity-cas";
1220 let request_id = "req-identity-cas";
1221 let (wire_tx, mut wire_rx) = mpsc::unbounded_channel();
1222 let _guard = register(child_id, wire_tx, 3, Some(registry.clone()));
1223 let (event_tx, _event_rx) = mpsc::channel(4);
1224 let (version, _) = observe_pending_approval(PendingApprovalObservation {
1225 registry: Some(®istry),
1226 parent_session_id: "parent-current",
1227 child_id,
1228 child_attempt: 3,
1229 request_id,
1230 tool_name: "Write",
1231 permission: "write",
1232 resource: "/tmp/current",
1233 event_tx,
1234 });
1235
1236 assert_eq!(
1237 deliver_approval_checked_cas(
1238 Some(®istry),
1239 "parent-stale",
1240 child_id,
1241 3,
1242 request_id,
1243 version,
1244 true,
1245 ),
1246 ApprovalDeliveryResult::Conflict
1247 );
1248 assert_eq!(
1249 deliver_approval_checked_cas(
1250 Some(®istry),
1251 "parent-current",
1252 child_id,
1253 3,
1254 request_id,
1255 version.saturating_add(1),
1256 true,
1257 ),
1258 ApprovalDeliveryResult::Conflict
1259 );
1260 assert!(wire_rx.try_recv().is_err());
1261
1262 assert_eq!(
1263 deliver_approval_checked_cas(
1264 Some(®istry),
1265 "parent-current",
1266 child_id,
1267 3,
1268 request_id,
1269 version,
1270 false,
1271 ),
1272 ApprovalDeliveryResult::Delivered
1273 );
1274 assert!(matches!(
1275 wire_rx.try_recv(),
1276 Ok(ParentFrame::ApprovalReply { id, approved }) if id == request_id && !approved
1277 ));
1278 }
1279
1280 #[tokio::test]
1281 async fn timeout_and_disconnect_emit_terminal_outcomes() {
1282 let (event_tx, mut event_rx) = mpsc::channel(8);
1283 observe_pending_approval(PendingApprovalObservation {
1284 registry: None,
1285 parent_session_id: "parent-audit",
1286 child_id: "c-expire",
1287 child_attempt: 0,
1288 request_id: "req-expire",
1289 tool_name: "Bash",
1290 permission: "execute",
1291 resource: "/tmp/x",
1292 event_tx: event_tx.clone(),
1293 });
1294 assert!(expire_pending_approval(None, "c-expire", "req-expire"));
1295 assert!(!expire_pending_approval(None, "c-expire", "req-expire"));
1296 assert!(matches!(
1297 event_rx.recv().await,
1298 Some(AgentEvent::ChildApprovalChanged { status, .. }) if status == "expired"
1299 ));
1300
1301 observe_pending_approval(PendingApprovalObservation {
1302 registry: None,
1303 parent_session_id: "parent-audit",
1304 child_id: "c-disconnect",
1305 child_attempt: 0,
1306 request_id: "req-disconnect",
1307 tool_name: "Write",
1308 permission: "write",
1309 resource: "/tmp/y",
1310 event_tx,
1311 });
1312 clear_pending_approvals_for(None, "c-disconnect", 0);
1313 assert!(matches!(
1314 event_rx.recv().await,
1315 Some(AgentEvent::ChildApprovalChanged { status, .. }) if status == "delivery_failed"
1316 ));
1317 }
1318}