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 if let Ok(mut q) = self.queue.lock() {
120 q.push(QueuedApproval {
121 child_agent_id: self.child_agent_id.clone(),
122 tool: req.tool.to_string(),
123 subject: req.subject.map(String::from),
124 queued_at_ms: now_ms(),
125 });
126 }
127 let (reply_tx, reply_rx) = mpsc::channel();
128 let pending = PendingChildApproval {
129 child_agent_id: self.child_agent_id.clone(),
130 tool: req.tool.to_string(),
131 subject: req.subject.map(String::from),
132 raw_args: req.raw_args.clone(),
133 reply_tx,
134 };
135 if self.tx.send(pending).is_err() {
136 return ApprovalOutcome::Deny;
137 }
138 reply_rx.recv().unwrap_or(ApprovalOutcome::Deny)
139 }
140}
141
142fn now_ms() -> i64 {
143 std::time::SystemTime::now()
144 .duration_since(std::time::UNIX_EPOCH)
145 .map(|d| d.as_millis() as i64)
146 .unwrap_or(0)
147}
148
149pub struct TuiElicitationHandler {
159 tx: mpsc::Sender<PendingElicitation>,
160}
161
162impl TuiElicitationHandler {
163 pub fn new(tx: mpsc::Sender<PendingElicitation>) -> Self {
166 TuiElicitationHandler { tx }
167 }
168}
169
170#[async_trait]
171impl McpElicitationHandler for TuiElicitationHandler {
172 async fn handle(&self, request: &ElicitationRequest) -> ElicitationResponse {
173 let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
174 let pending = PendingElicitation {
175 message: request.message.clone(),
176 requested_schema: request.requested_schema.clone(),
177 reply_tx,
178 };
179 if self.tx.send(pending).is_err() {
180 return ElicitationResponse {
181 action: crate::mcp::ElicitationAction::Cancel,
182 content: None,
183 };
184 }
185 reply_rx.await.unwrap_or(ElicitationResponse {
186 action: crate::mcp::ElicitationAction::Cancel,
187 content: None,
188 })
189 }
190}
191
192pub struct TuiBridge {
200 approval_tx: mpsc::Sender<PendingApprovalRequest>,
201 pub approval_rx: mpsc::Receiver<PendingApprovalRequest>,
204 child_approval_tx: mpsc::Sender<PendingChildApproval>,
205 pub child_approval_rx: mpsc::Receiver<PendingChildApproval>,
207 elicitation_tx: mpsc::Sender<PendingElicitation>,
208 pub elicitation_rx: mpsc::Receiver<PendingElicitation>,
210 oauth_tx: mpsc::Sender<PendingOAuthDisplay>,
211 pub oauth_rx: mpsc::Receiver<PendingOAuthDisplay>,
213}
214
215impl Default for TuiBridge {
216 fn default() -> Self {
217 Self::new()
218 }
219}
220
221impl TuiBridge {
222 pub fn new() -> Self {
225 let (approval_tx, approval_rx) = mpsc::channel();
226 let (child_approval_tx, child_approval_rx) = mpsc::channel();
227 let (elicitation_tx, elicitation_rx) = mpsc::channel();
228 let (oauth_tx, oauth_rx) = mpsc::channel();
229 TuiBridge {
230 approval_tx,
231 approval_rx,
232 child_approval_tx,
233 child_approval_rx,
234 elicitation_tx,
235 elicitation_rx,
236 oauth_tx,
237 oauth_rx,
238 }
239 }
240
241 pub fn install_on(&self, agent: &mut crate::agent::Agent) {
248 agent.set_permissions_approval_handler(TuiApprovalHandler::new(self.approval_tx.clone()));
249 agent.set_child_approval_handler_factory({
250 let tx = self.child_approval_tx.clone();
251 move |child_id, queue| {
252 Arc::new(TuiChildApprovalHandler::new(child_id, queue, tx.clone()))
253 as Arc<dyn PermissionsApprovalHandler>
254 }
255 });
256 }
257
258 pub fn elicitation_handler(&self) -> Arc<dyn McpElicitationHandler> {
263 Arc::new(TuiElicitationHandler::new(self.elicitation_tx.clone()))
264 }
265
266 pub fn oauth_sender(&self) -> mpsc::Sender<PendingOAuthDisplay> {
270 self.oauth_tx.clone()
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use crate::permissions::approval::resolve_ask;
278 use crate::permissions::ApprovalCache;
279
280 #[test]
283 fn approval_ask_blocks_until_render_loop_replies_allow_for_session_and_cache_then_skips_handler(
284 ) {
285 let (tx, rx) = mpsc::channel::<PendingApprovalRequest>();
286 let handler = Arc::new(TuiApprovalHandler::new(tx));
287 let cache = Arc::new(ApprovalCache::new());
288
289 let h = handler.clone();
292 let c = cache.clone();
293 let worker = std::thread::spawn(move || {
294 let args = serde_json::json!({});
295 let req = ApprovalRequest {
296 tool: "bash",
297 subject: Some("ls -la"),
298 raw_args: &args,
299 };
300 resolve_ask(&c, Some(h.as_ref()), &req)
301 });
302
303 let pending = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
306 assert_eq!(pending.tool, "bash");
307 assert_eq!(pending.subject.as_deref(), Some("ls -la"));
308 pending
309 .reply_tx
310 .send(ApprovalOutcome::AllowForSession)
311 .unwrap();
312
313 let approved = worker.join().unwrap();
314 assert!(approved, "first Ask must be approved via the modal");
315
316 let args = serde_json::json!({});
320 let req2 = ApprovalRequest {
321 tool: "bash",
322 subject: Some("ls -la"),
323 raw_args: &args,
324 };
325 let approved2 = resolve_ask(&cache, Some(handler.as_ref()), &req2);
326 assert!(approved2);
327 assert!(
328 rx.try_recv().is_err(),
329 "a cached AllowForSession must skip the handler entirely"
330 );
331 }
332
333 #[test]
334 fn approval_ask_deny_is_not_cached() {
335 let (tx, rx) = mpsc::channel::<PendingApprovalRequest>();
336 let handler = Arc::new(TuiApprovalHandler::new(tx));
337 let cache = Arc::new(ApprovalCache::new());
338 let h = handler.clone();
339 let c = cache.clone();
340 let worker = std::thread::spawn(move || {
341 let args = serde_json::json!({});
342 let req = ApprovalRequest {
343 tool: "bash",
344 subject: Some("curl evil.example"),
345 raw_args: &args,
346 };
347 resolve_ask(&c, Some(h.as_ref()), &req)
348 });
349 let pending = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
350 pending.reply_tx.send(ApprovalOutcome::Deny).unwrap();
351 let approved = worker.join().unwrap();
352 assert!(!approved);
353 assert!(!cache.is_approved(&ApprovalCache::key("bash", Some("curl evil.example"))));
354 }
355
356 #[test]
357 fn approval_ask_fails_closed_when_render_loop_is_gone() {
358 let (tx, rx) = mpsc::channel::<PendingApprovalRequest>();
359 let handler = TuiApprovalHandler::new(tx);
360 drop(rx); let args = serde_json::json!({});
362 let req = ApprovalRequest {
363 tool: "bash",
364 subject: None,
365 raw_args: &args,
366 };
367 assert_eq!(handler.ask(&req), ApprovalOutcome::Deny);
368 }
369
370 #[test]
371 fn approval_ask_fails_closed_when_reply_sender_is_dropped_without_replying() {
372 let (tx, rx) = mpsc::channel::<PendingApprovalRequest>();
373 let handler = Arc::new(TuiApprovalHandler::new(tx));
374 let h = handler.clone();
375 let worker = std::thread::spawn(move || {
376 let args = serde_json::json!({});
377 let req = ApprovalRequest {
378 tool: "bash",
379 subject: None,
380 raw_args: &args,
381 };
382 h.ask(&req)
383 });
384 let pending = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
385 drop(pending.reply_tx); assert_eq!(worker.join().unwrap(), ApprovalOutcome::Deny);
387 }
388
389 #[test]
395 fn approval_handler_never_consulted_when_rule_engine_already_denies() {
396 use crate::permissions::approval::decision_to_approved;
397 use crate::permissions::rules::Decision;
398 let (tx, rx) = mpsc::channel::<PendingApprovalRequest>();
399 let handler = TuiApprovalHandler::new(tx);
400 let approved = decision_to_approved(Decision::Deny, || {
405 handler.ask(&ApprovalRequest {
406 tool: "bash",
407 subject: None,
408 raw_args: &serde_json::json!({}),
409 }) == ApprovalOutcome::Allow
410 });
411 assert!(!approved);
412 assert!(rx.try_recv().is_err(), "handler must never have been asked");
413 }
414
415 #[test]
418 fn child_approval_handler_blocks_for_an_answer_instead_of_immediate_deny() {
419 let (tx, rx) = mpsc::channel::<PendingChildApproval>();
420 let queue = Arc::new(Mutex::new(Vec::new()));
421 let handler = Arc::new(TuiChildApprovalHandler::new(
422 "agent-bg-7".to_string(),
423 queue.clone(),
424 tx,
425 ));
426 let h = handler.clone();
427 let worker = std::thread::spawn(move || {
428 let args = serde_json::json!({});
429 let subject = "/workspace/out.txt".to_string();
430 let req = ApprovalRequest {
431 tool: "write_file",
432 subject: Some(subject.as_str()),
433 raw_args: &args,
434 };
435 h.ask(&req)
436 });
437 let pending = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
438 assert_eq!(pending.child_agent_id, "agent-bg-7");
439 assert_eq!(pending.tool, "write_file");
440 pending.reply_tx.send(ApprovalOutcome::Allow).unwrap();
441 assert_eq!(worker.join().unwrap(), ApprovalOutcome::Allow);
442
443 let recorded = queue.lock().unwrap();
446 assert_eq!(recorded.len(), 1);
447 assert_eq!(recorded[0].child_agent_id, "agent-bg-7");
448 }
449
450 #[test]
451 fn child_approval_handler_fails_closed_when_nobody_answers() {
452 let (tx, rx) = mpsc::channel::<PendingChildApproval>();
453 let queue = Arc::new(Mutex::new(Vec::new()));
454 let handler = Arc::new(TuiChildApprovalHandler::new(
455 "agent-bg-8".to_string(),
456 queue,
457 tx,
458 ));
459 let h = handler.clone();
460 let worker = std::thread::spawn(move || {
461 let args = serde_json::json!({});
462 let req = ApprovalRequest {
463 tool: "bash",
464 subject: None,
465 raw_args: &args,
466 };
467 h.ask(&req)
468 });
469 let pending = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
470 drop(pending); assert_eq!(worker.join().unwrap(), ApprovalOutcome::Deny);
472 }
473
474 #[tokio::test]
477 async fn elicitation_handler_returns_the_modals_accept_answer() {
478 let (tx, rx) = mpsc::channel::<PendingElicitation>();
479 let handler = TuiElicitationHandler::new(tx);
480 let request = ElicitationRequest {
481 message: "What's the deploy tag?".to_string(),
482 requested_schema: serde_json::json!({"properties": {"tag": {"type": "string"}}}),
483 };
484
485 let handle_fut = handler.handle(&request);
486 let reply_task = tokio::task::spawn_blocking(move || {
489 let pending = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
490 assert_eq!(pending.message, "What's the deploy tag?");
491 pending
492 .reply_tx
493 .send(ElicitationResponse {
494 action: crate::mcp::ElicitationAction::Accept,
495 content: Some(serde_json::json!({"tag": "v1.2.3"})),
496 })
497 .unwrap();
498 });
499
500 let (resp, _) = tokio::join!(handle_fut, reply_task);
501 assert_eq!(resp.action, crate::mcp::ElicitationAction::Accept);
502 assert_eq!(resp.content, Some(serde_json::json!({"tag": "v1.2.3"})));
503 }
504
505 #[tokio::test]
506 async fn elicitation_handler_cancels_when_render_loop_is_gone() {
507 let (tx, rx) = mpsc::channel::<PendingElicitation>();
508 let handler = TuiElicitationHandler::new(tx);
509 drop(rx);
510 let request = ElicitationRequest {
511 message: "…".to_string(),
512 requested_schema: serde_json::json!({}),
513 };
514 let resp = handler.handle(&request).await;
515 assert_eq!(resp.action, crate::mcp::ElicitationAction::Cancel);
516 assert_eq!(resp.content, None);
517 }
518
519 #[tokio::test]
520 async fn elicitation_handler_cancels_when_reply_sender_dropped_without_replying() {
521 let (tx, rx) = mpsc::channel::<PendingElicitation>();
522 let handler = TuiElicitationHandler::new(tx);
523 let request = ElicitationRequest {
524 message: "…".to_string(),
525 requested_schema: serde_json::json!({}),
526 };
527 let handle_fut = handler.handle(&request);
528 let drop_task = tokio::task::spawn_blocking(move || {
529 let pending = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
530 drop(pending); });
532 let (resp, _) = tokio::join!(handle_fut, drop_task);
533 assert_eq!(resp.action, crate::mcp::ElicitationAction::Cancel);
534 }
535
536 #[test]
539 fn bridge_install_on_wires_a_working_approval_handler() {
540 let bridge = TuiBridge::new();
541 let mut agent = crate::agent::Agent::new(
542 crate::Config::builder()
543 .model("test/model")
544 .api_key("test-key")
545 .build(),
546 )
547 .expect("agent construction");
548 bridge.install_on(&mut agent);
549 assert!(bridge.approval_rx.try_recv().is_err());
553 assert!(bridge.child_approval_rx.try_recv().is_err());
554 }
555
556 #[test]
557 fn bridge_elicitation_handler_feeds_the_bridges_receiver() {
558 let bridge = TuiBridge::new();
559 let handler = bridge.elicitation_handler();
560 let h = handler.clone();
561 let worker = std::thread::spawn(move || {
562 let rt = tokio::runtime::Builder::new_current_thread()
563 .enable_all()
564 .build()
565 .unwrap();
566 rt.block_on(async {
567 let req = ElicitationRequest {
568 message: "hi".to_string(),
569 requested_schema: serde_json::json!({}),
570 };
571 h.handle(&req).await
572 })
573 });
574 let pending = bridge
575 .elicitation_rx
576 .recv_timeout(std::time::Duration::from_secs(5))
577 .unwrap();
578 pending
579 .reply_tx
580 .send(ElicitationResponse {
581 action: crate::mcp::ElicitationAction::Decline,
582 content: None,
583 })
584 .unwrap();
585 let resp = worker.join().unwrap();
586 assert_eq!(resp.action, crate::mcp::ElicitationAction::Decline);
587 }
588}