1use std::sync::{mpsc, Arc, Mutex};
37
38use async_trait::async_trait;
39
40use crate::mcp::{ElicitationRequest, ElicitationResponse, McpElicitationHandler};
41use crate::permissions::{ApprovalOutcome, ApprovalRequest, PermissionsApprovalHandler};
42use crate::subagents::QueuedApproval;
43
44use super::bridge::{
45 PendingApprovalRequest, PendingChildApproval, PendingElicitation, PendingOAuthDisplay,
46};
47
48pub struct TuiApprovalHandler {
57 tx: mpsc::Sender<PendingApprovalRequest>,
58}
59
60impl TuiApprovalHandler {
61 pub fn new(tx: mpsc::Sender<PendingApprovalRequest>) -> Self {
64 TuiApprovalHandler { tx }
65 }
66}
67
68impl PermissionsApprovalHandler for TuiApprovalHandler {
69 fn ask(&self, req: &ApprovalRequest) -> ApprovalOutcome {
70 let (reply_tx, reply_rx) = mpsc::channel();
71 let pending = PendingApprovalRequest {
72 tool: req.tool.to_string(),
73 subject: req.subject.map(String::from),
74 raw_args: req.raw_args.clone(),
75 reply_tx,
76 };
77 if self.tx.send(pending).is_err() {
78 return ApprovalOutcome::Deny;
79 }
80 reply_rx.recv().unwrap_or(ApprovalOutcome::Deny)
81 }
82}
83
84pub struct TuiChildApprovalHandler {
94 child_agent_id: String,
95 queue: Arc<Mutex<Vec<QueuedApproval>>>,
96 tx: mpsc::Sender<PendingChildApproval>,
97}
98
99impl TuiChildApprovalHandler {
100 pub fn new(
105 child_agent_id: String,
106 queue: Arc<Mutex<Vec<QueuedApproval>>>,
107 tx: mpsc::Sender<PendingChildApproval>,
108 ) -> Self {
109 TuiChildApprovalHandler {
110 child_agent_id,
111 queue,
112 tx,
113 }
114 }
115}
116
117impl PermissionsApprovalHandler for TuiChildApprovalHandler {
118 fn ask(&self, req: &ApprovalRequest) -> ApprovalOutcome {
119 let queued = crate::subagents::queue_approval(
122 &self.queue,
123 QueuedApproval {
124 child_agent_id: self.child_agent_id.clone(),
125 tool: req.tool.to_string(),
126 subject: req.subject.map(String::from),
127 queued_at_ms: now_ms(),
128 outcome: None,
129 },
130 );
131 let (reply_tx, reply_rx) = mpsc::channel();
132 let pending = PendingChildApproval {
133 child_agent_id: self.child_agent_id.clone(),
134 tool: req.tool.to_string(),
135 subject: req.subject.map(String::from),
136 raw_args: req.raw_args.clone(),
137 reply_tx,
138 };
139 let outcome = if self.tx.send(pending).is_err() {
140 ApprovalOutcome::Deny
141 } else {
142 reply_rx.recv().unwrap_or(ApprovalOutcome::Deny)
143 };
144 if let Some(index) = queued {
145 crate::subagents::record_queued_outcome(&self.queue, index, outcome.into());
146 }
147 outcome
148 }
149}
150
151fn now_ms() -> i64 {
152 std::time::SystemTime::now()
153 .duration_since(std::time::UNIX_EPOCH)
154 .map(|d| d.as_millis() as i64)
155 .unwrap_or(0)
156}
157
158pub struct TuiElicitationHandler {
168 tx: mpsc::Sender<PendingElicitation>,
169}
170
171impl TuiElicitationHandler {
172 pub fn new(tx: mpsc::Sender<PendingElicitation>) -> Self {
175 TuiElicitationHandler { tx }
176 }
177}
178
179#[async_trait]
180impl McpElicitationHandler for TuiElicitationHandler {
181 async fn handle(&self, request: &ElicitationRequest) -> ElicitationResponse {
182 let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
183 let pending = PendingElicitation {
184 message: request.message.clone(),
185 requested_schema: request.requested_schema.clone(),
186 reply_tx,
187 };
188 if self.tx.send(pending).is_err() {
189 return ElicitationResponse {
190 action: crate::mcp::ElicitationAction::Cancel,
191 content: None,
192 };
193 }
194 reply_rx.await.unwrap_or(ElicitationResponse {
195 action: crate::mcp::ElicitationAction::Cancel,
196 content: None,
197 })
198 }
199}
200
201pub struct TuiBridge {
209 approval_tx: mpsc::Sender<PendingApprovalRequest>,
210 pub approval_rx: mpsc::Receiver<PendingApprovalRequest>,
213 child_approval_tx: mpsc::Sender<PendingChildApproval>,
214 pub child_approval_rx: mpsc::Receiver<PendingChildApproval>,
216 elicitation_tx: mpsc::Sender<PendingElicitation>,
217 pub elicitation_rx: mpsc::Receiver<PendingElicitation>,
219 oauth_tx: mpsc::Sender<PendingOAuthDisplay>,
220 pub oauth_rx: mpsc::Receiver<PendingOAuthDisplay>,
222}
223
224impl Default for TuiBridge {
225 fn default() -> Self {
226 Self::new()
227 }
228}
229
230impl TuiBridge {
231 pub fn new() -> Self {
234 let (approval_tx, approval_rx) = mpsc::channel();
235 let (child_approval_tx, child_approval_rx) = mpsc::channel();
236 let (elicitation_tx, elicitation_rx) = mpsc::channel();
237 let (oauth_tx, oauth_rx) = mpsc::channel();
238 TuiBridge {
239 approval_tx,
240 approval_rx,
241 child_approval_tx,
242 child_approval_rx,
243 elicitation_tx,
244 elicitation_rx,
245 oauth_tx,
246 oauth_rx,
247 }
248 }
249
250 pub fn install_on(&self, agent: &mut crate::agent::Agent) {
257 agent.set_permissions_approval_handler(TuiApprovalHandler::new(self.approval_tx.clone()));
258 agent.set_user_question_handler(self.elicitation_handler());
263 agent.set_child_approval_handler_factory({
264 let tx = self.child_approval_tx.clone();
265 move |child_id, queue| {
266 Arc::new(TuiChildApprovalHandler::new(child_id, queue, tx.clone()))
267 as Arc<dyn PermissionsApprovalHandler>
268 }
269 });
270 }
271
272 pub fn elicitation_handler(&self) -> Arc<dyn McpElicitationHandler> {
277 Arc::new(TuiElicitationHandler::new(self.elicitation_tx.clone()))
278 }
279
280 pub fn oauth_sender(&self) -> mpsc::Sender<PendingOAuthDisplay> {
284 self.oauth_tx.clone()
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291 use crate::permissions::approval::resolve_ask;
292 use crate::permissions::ApprovalCache;
293
294 #[test]
297 fn approval_ask_blocks_until_render_loop_replies_allow_for_session_and_cache_then_skips_handler(
298 ) {
299 let (tx, rx) = mpsc::channel::<PendingApprovalRequest>();
300 let handler = Arc::new(TuiApprovalHandler::new(tx));
301 let cache = Arc::new(ApprovalCache::new());
302
303 let h = handler.clone();
306 let c = cache.clone();
307 let worker = std::thread::spawn(move || {
308 let args = serde_json::json!({});
309 let req = ApprovalRequest {
310 tool: "bash",
311 subject: Some("ls -la"),
312 raw_args: &args,
313 };
314 resolve_ask(&c, Some(h.as_ref()), &req)
315 });
316
317 let pending = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
320 assert_eq!(pending.tool, "bash");
321 assert_eq!(pending.subject.as_deref(), Some("ls -la"));
322 pending
323 .reply_tx
324 .send(ApprovalOutcome::AllowForSession)
325 .unwrap();
326
327 let approved = worker.join().unwrap();
328 assert!(approved, "first Ask must be approved via the modal");
329
330 let args = serde_json::json!({});
334 let req2 = ApprovalRequest {
335 tool: "bash",
336 subject: Some("ls -la"),
337 raw_args: &args,
338 };
339 let approved2 = resolve_ask(&cache, Some(handler.as_ref()), &req2);
340 assert!(approved2);
341 assert!(
342 rx.try_recv().is_err(),
343 "a cached AllowForSession must skip the handler entirely"
344 );
345 }
346
347 #[test]
348 fn approval_ask_deny_is_not_cached() {
349 let (tx, rx) = mpsc::channel::<PendingApprovalRequest>();
350 let handler = Arc::new(TuiApprovalHandler::new(tx));
351 let cache = Arc::new(ApprovalCache::new());
352 let h = handler.clone();
353 let c = cache.clone();
354 let worker = std::thread::spawn(move || {
355 let args = serde_json::json!({});
356 let req = ApprovalRequest {
357 tool: "bash",
358 subject: Some("curl evil.example"),
359 raw_args: &args,
360 };
361 resolve_ask(&c, Some(h.as_ref()), &req)
362 });
363 let pending = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
364 pending.reply_tx.send(ApprovalOutcome::Deny).unwrap();
365 let approved = worker.join().unwrap();
366 assert!(!approved);
367 assert!(!cache.is_approved(&ApprovalCache::key("bash", Some("curl evil.example"))));
368 }
369
370 #[test]
371 fn approval_ask_fails_closed_when_render_loop_is_gone() {
372 let (tx, rx) = mpsc::channel::<PendingApprovalRequest>();
373 let handler = TuiApprovalHandler::new(tx);
374 drop(rx); let args = serde_json::json!({});
376 let req = ApprovalRequest {
377 tool: "bash",
378 subject: None,
379 raw_args: &args,
380 };
381 assert_eq!(handler.ask(&req), ApprovalOutcome::Deny);
382 }
383
384 #[test]
385 fn approval_ask_fails_closed_when_reply_sender_is_dropped_without_replying() {
386 let (tx, rx) = mpsc::channel::<PendingApprovalRequest>();
387 let handler = Arc::new(TuiApprovalHandler::new(tx));
388 let h = handler.clone();
389 let worker = std::thread::spawn(move || {
390 let args = serde_json::json!({});
391 let req = ApprovalRequest {
392 tool: "bash",
393 subject: None,
394 raw_args: &args,
395 };
396 h.ask(&req)
397 });
398 let pending = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
399 drop(pending.reply_tx); assert_eq!(worker.join().unwrap(), ApprovalOutcome::Deny);
401 }
402
403 #[test]
409 fn approval_handler_never_consulted_when_rule_engine_already_denies() {
410 use crate::permissions::approval::decision_to_approved;
411 use crate::permissions::rules::Decision;
412 let (tx, rx) = mpsc::channel::<PendingApprovalRequest>();
413 let handler = TuiApprovalHandler::new(tx);
414 let approved = decision_to_approved(Decision::Deny, || {
419 handler.ask(&ApprovalRequest {
420 tool: "bash",
421 subject: None,
422 raw_args: &serde_json::json!({}),
423 }) == ApprovalOutcome::Allow
424 });
425 assert!(!approved);
426 assert!(rx.try_recv().is_err(), "handler must never have been asked");
427 }
428
429 #[test]
432 fn child_approval_handler_blocks_for_an_answer_instead_of_immediate_deny() {
433 let (tx, rx) = mpsc::channel::<PendingChildApproval>();
434 let queue = Arc::new(Mutex::new(Vec::new()));
435 let handler = Arc::new(TuiChildApprovalHandler::new(
436 "agent-bg-7".to_string(),
437 queue.clone(),
438 tx,
439 ));
440 let h = handler.clone();
441 let worker = std::thread::spawn(move || {
442 let args = serde_json::json!({});
443 let subject = "/workspace/out.txt".to_string();
444 let req = ApprovalRequest {
445 tool: "write_file",
446 subject: Some(subject.as_str()),
447 raw_args: &args,
448 };
449 h.ask(&req)
450 });
451 let pending = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
452 assert_eq!(pending.child_agent_id, "agent-bg-7");
453 assert_eq!(pending.tool, "write_file");
454 pending.reply_tx.send(ApprovalOutcome::Allow).unwrap();
455 assert_eq!(worker.join().unwrap(), ApprovalOutcome::Allow);
456
457 let recorded = queue.lock().unwrap();
460 assert_eq!(recorded.len(), 1);
461 assert_eq!(recorded[0].child_agent_id, "agent-bg-7");
462 }
463
464 #[test]
465 fn child_approval_handler_fails_closed_when_nobody_answers() {
466 let (tx, rx) = mpsc::channel::<PendingChildApproval>();
467 let queue = Arc::new(Mutex::new(Vec::new()));
468 let handler = Arc::new(TuiChildApprovalHandler::new(
469 "agent-bg-8".to_string(),
470 queue,
471 tx,
472 ));
473 let h = handler.clone();
474 let worker = std::thread::spawn(move || {
475 let args = serde_json::json!({});
476 let req = ApprovalRequest {
477 tool: "bash",
478 subject: None,
479 raw_args: &args,
480 };
481 h.ask(&req)
482 });
483 let pending = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
484 drop(pending); assert_eq!(worker.join().unwrap(), ApprovalOutcome::Deny);
486 }
487
488 #[tokio::test]
491 async fn elicitation_handler_returns_the_modals_accept_answer() {
492 let (tx, rx) = mpsc::channel::<PendingElicitation>();
493 let handler = TuiElicitationHandler::new(tx);
494 let request = ElicitationRequest {
495 message: "What's the deploy tag?".to_string(),
496 requested_schema: serde_json::json!({"properties": {"tag": {"type": "string"}}}),
497 };
498
499 let handle_fut = handler.handle(&request);
500 let reply_task = tokio::task::spawn_blocking(move || {
503 let pending = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
504 assert_eq!(pending.message, "What's the deploy tag?");
505 pending
506 .reply_tx
507 .send(ElicitationResponse {
508 action: crate::mcp::ElicitationAction::Accept,
509 content: Some(serde_json::json!({"tag": "v1.2.3"})),
510 })
511 .unwrap();
512 });
513
514 let (resp, _) = tokio::join!(handle_fut, reply_task);
515 assert_eq!(resp.action, crate::mcp::ElicitationAction::Accept);
516 assert_eq!(resp.content, Some(serde_json::json!({"tag": "v1.2.3"})));
517 }
518
519 #[tokio::test]
520 async fn elicitation_handler_cancels_when_render_loop_is_gone() {
521 let (tx, rx) = mpsc::channel::<PendingElicitation>();
522 let handler = TuiElicitationHandler::new(tx);
523 drop(rx);
524 let request = ElicitationRequest {
525 message: "…".to_string(),
526 requested_schema: serde_json::json!({}),
527 };
528 let resp = handler.handle(&request).await;
529 assert_eq!(resp.action, crate::mcp::ElicitationAction::Cancel);
530 assert_eq!(resp.content, None);
531 }
532
533 #[tokio::test]
534 async fn elicitation_handler_cancels_when_reply_sender_dropped_without_replying() {
535 let (tx, rx) = mpsc::channel::<PendingElicitation>();
536 let handler = TuiElicitationHandler::new(tx);
537 let request = ElicitationRequest {
538 message: "…".to_string(),
539 requested_schema: serde_json::json!({}),
540 };
541 let handle_fut = handler.handle(&request);
542 let drop_task = tokio::task::spawn_blocking(move || {
543 let pending = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
544 drop(pending); });
546 let (resp, _) = tokio::join!(handle_fut, drop_task);
547 assert_eq!(resp.action, crate::mcp::ElicitationAction::Cancel);
548 }
549
550 #[test]
553 fn bridge_install_on_wires_a_working_approval_handler() {
554 let bridge = TuiBridge::new();
555 let mut agent = crate::agent::Agent::new(
556 crate::Config::builder()
557 .model("test/model")
558 .api_key("test-key")
559 .build(),
560 )
561 .expect("agent construction");
562 bridge.install_on(&mut agent);
563 assert!(bridge.approval_rx.try_recv().is_err());
567 assert!(bridge.child_approval_rx.try_recv().is_err());
568 }
569
570 #[test]
571 fn bridge_elicitation_handler_feeds_the_bridges_receiver() {
572 let bridge = TuiBridge::new();
573 let handler = bridge.elicitation_handler();
574 let h = handler.clone();
575 let worker = std::thread::spawn(move || {
576 let rt = tokio::runtime::Builder::new_current_thread()
577 .enable_all()
578 .build()
579 .unwrap();
580 rt.block_on(async {
581 let req = ElicitationRequest {
582 message: "hi".to_string(),
583 requested_schema: serde_json::json!({}),
584 };
585 h.handle(&req).await
586 })
587 });
588 let pending = bridge
589 .elicitation_rx
590 .recv_timeout(std::time::Duration::from_secs(5))
591 .unwrap();
592 pending
593 .reply_tx
594 .send(ElicitationResponse {
595 action: crate::mcp::ElicitationAction::Decline,
596 content: None,
597 })
598 .unwrap();
599 let resp = worker.join().unwrap();
600 assert_eq!(resp.action, crate::mcp::ElicitationAction::Decline);
601 }
602}