Skip to main content

bamboo_engine/external_agents/
live.rs

1//! Live actor registry: in-band delivery to currently-running actor children.
2//!
3//! While `ActorChildRunner` drives a child over WebSocket, it registers a frame
4//! sender here keyed by `child_session_id`. `send_message` (running, no
5//! interrupt) consults this map: when the child is live, the message rides the
6//! existing WS as a `ParentFrame::Message` and is admitted by the worker's
7//! agent loop at its next round boundary — the same mechanism in-process
8//! children use, extended across the process boundary. When the child is not
9//! live, callers fall back to the durable `pending_injected_messages` queue.
10
11use 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
39/// Return only a presentation copy. The durable record and its CAS identity
40/// remain untouched so an approval can still be delivered after reconnect.
41pub 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
51/// Project the actor or host approval event before it reaches a user-visible
52/// event stream. Request and version fields are left intact for reconciliation.
53pub 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/// Process-global registry of pending human-loop approval requests, keyed by
97/// `child_id` → set of `request_id`s currently awaiting a decision. Only the
98/// human-in-the-loop path (top orchestrator) registers here; trusted internal
99/// paths (model-review, escalation-bridge) do NOT — so the external handler's
100/// [`deliver_approval_checked`] correctly rejects any stray external POST aimed
101/// at a request that isn't a genuinely-pending human-loop one.
102#[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
119/// Configure durable approval storage and fail-close records whose live
120/// transport was lost across restart.
121pub 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
130/// Named identity, audit, and delivery inputs for one observed approval.
131pub 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
143/// Record a `(child_id, request_id)` as a pending human-loop approval. Called
144/// just before surfacing `ChildApprovalRequested` so an external POST can be
145/// correlated against a genuinely-pending request.
146pub 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/// Backward-compatible positional wrapper for existing engine consumers.
205///
206/// New call sites should use [`observe_pending_approval`] so each approval
207/// identity and audit field is named at the call site.
208#[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
252/// One-shot consume of a `(child_id, request_id)` pending pair: remove it and
253/// return whether it WAS present. A second call for the same pair returns
254/// `false`, so a request can't be answered (or replayed) twice.
255pub fn take_pending_approval(child_id: &str, request_id: &str) -> bool {
256    remove_unique_pending(None, child_id, request_id).is_some()
257}
258
259/// Drop all pending approvals for a child (e.g. when its live connection ends).
260pub 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
373/// Validated external entry point: deliver an approval decision ONLY if the
374/// `(child_id, request_id)` pair is currently pending. Consumes the pending
375/// entry (one-shot) before delivering, so the same request can't be replayed,
376/// and rejects (returns `false`) any `request_id` that isn't currently pending
377/// — unknown, already-answered/timed-out, or a non-human-loop path
378/// (model-review / escalation) that never registered. This is the entry the
379/// external HTTP handler must use.
380pub 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/// Versioned external entry point used by typed HTTP clients. The complete
409/// durable identity is checked against both live pending state and the durable
410/// registry before either is mutated. Identity/version mismatches are reported
411/// as conflicts and never fall back to another attempt with the same request id.
412#[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    // Persist DecisionRecorded before touching the live transport. Duplicate or
437    // concurrent decisions fail this transition and cannot deliver twice.
438    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
562/// Unregisters the child on drop, so a panicking/returning runner can't leak
563/// a stale sender.
564pub 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        // A disconnecting child can't answer any still-pending approval — drop
579        // them so a late external POST finds nothing pending and is rejected.
580        clear_pending_approvals_for(
581            self.approval_registry.as_ref(),
582            &self.child_id,
583            self.child_attempt,
584        );
585    }
586}
587
588/// Register a live child's frame sender for the duration of its run.
589pub 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
680/// Deliver an in-band steering message to a live child. Returns `false` when
681/// the child is not live (caller should use the durable queue instead).
682pub 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
700/// Deliver a host/human approval decision to a live child's pending gated-tool
701/// request (Phase 2: child → parent approval delegation). Sends
702/// `ParentFrame::ApprovalReply{id, approved}` over the child's live WS
703/// connection; `drive()` forwards it to the worker, whose pending map resolves
704/// the `host.approval_call` the child's gated tool is blocked on (approve ⇒ the
705/// tool proceeds, deny ⇒ it fails closed). This is the decision-DOWN half of the
706/// human-in-the-loop route: a parent-side responder (e.g. a `/respond`-style
707/// handler) calls this with the `request_id` it surfaced to the human. Returns
708/// `false` when the child is not live (no connection to answer on — the caller
709/// should treat that as a denied/expired request).
710pub 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
733/// Whether a child currently has a live actor connection.
734pub 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(&registry),
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(&registry),
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        // A different unresolved request is reconciled from the authoritative
874        // registry after restart, then published only as a display copy.
875        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        // Not-live child ⇒ false (no connection to answer on).
977        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        // First take consumes it; the second finds nothing.
1023        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        // Unknown child entirely.
1030        assert!(!take_pending_approval("c-unknown", "req-x"));
1031        // Known child, but an unregistered request_id.
1032        register_pending_approval("c-known", "req-real");
1033        assert!(!take_pending_approval("c-known", "req-bogus"));
1034        // The real one is still pending (a bogus take didn't disturb it).
1035        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        // Not registered ⇒ rejected, nothing on the wire.
1044        assert!(!deliver_approval_checked(
1045            None,
1046            "c-checked",
1047            "req-stray",
1048            true
1049        ));
1050        assert!(rx.try_recv().is_err());
1051
1052        // Registered ⇒ delivered, frame rides the wire, and consumed.
1053        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        // One-shot: a replay is rejected (and nothing further on the wire).
1063        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(&registry),
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(&registry),
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(&registry),
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(&registry), 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(&registry),
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(&registry),
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(&registry),
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(&registry),
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(&registry),
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(&registry),
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(&registry),
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}