use tokio::sync::mpsc;
use tokio::sync::oneshot;
pub type AskSender = mpsc::Sender<AskRequest>;
pub type AskReceiver = mpsc::Receiver<AskRequest>;
#[derive(Debug)]
pub struct AskRequest {
pub tool: String,
pub input: String,
pub reason: Option<String>,
pub reply: oneshot::Sender<UserDecision>,
}
#[derive(Debug, Clone)]
pub enum UserDecision {
AllowOnce,
AllowAlways(String),
Deny,
}
pub fn spawn_headless_ask_responder(mut ask_rx: AskReceiver) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
while let Some(req) = ask_rx.recv().await {
eprintln!(
"[headless] tool '{}' requires confirmation but no interactive \
prompt is available; denying. Use --yolo or add an allow rule \
to permit it.",
req.tool,
);
let _ = req.reply.send(UserDecision::Deny);
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn headless_responder_denies_pending_ask() {
let (tx, rx) = mpsc::channel(4);
let _handle = spawn_headless_ask_responder(rx);
let (reply_tx, reply_rx) = oneshot::channel();
tx.send(AskRequest {
tool: "bash".to_string(),
input: "which allium".to_string(),
reason: None,
reply: reply_tx,
})
.await
.unwrap();
let decision = reply_rx.await.expect("responder should answer the ask");
assert!(matches!(decision, UserDecision::Deny));
}
#[tokio::test]
async fn headless_responder_exits_when_channel_closes() {
let (tx, rx) = mpsc::channel::<AskRequest>(1);
let handle = spawn_headless_ask_responder(rx);
drop(tx);
handle.await.expect("drain task should finish");
}
}