use std::process::Command;
use crate::broker::publish::{build_status_message, publish_to_broker_http};
use crate::error::PawError;
use crate::supervisor::permission_prompt::PermissionType;
pub const APPROVAL_KEYS: &[&str] = &["BTab", "Down", "Enter"];
pub trait KeyDispatcher {
fn send_key(&mut self, session: &str, pane_index: usize, key: &str) -> std::io::Result<()>;
}
pub struct TmuxKeyDispatcher;
impl KeyDispatcher for TmuxKeyDispatcher {
fn send_key(&mut self, session: &str, pane_index: usize, key: &str) -> std::io::Result<()> {
let target = format!("{session}:0.{pane_index}");
let status = Command::new("tmux")
.args(["send-keys", "-t", &target, key])
.status()?;
if !status.success() {
return Err(std::io::Error::other(format!(
"tmux send-keys exited with {status}"
)));
}
Ok(())
}
}
#[derive(Debug, Clone, Copy)]
pub struct ApprovalRequest<'a> {
pub enabled: bool,
pub session: &'a str,
pub pane_index: usize,
pub agent_id: &'a str,
pub kind: PermissionType,
pub matched_entry: Option<&'a str>,
pub broker_url: Option<&'a str>,
}
pub fn auto_approve_pane<D: KeyDispatcher>(
dispatcher: &mut D,
req: ApprovalRequest<'_>,
) -> Result<bool, PawError> {
if !req.enabled || req.kind == PermissionType::Unknown {
return Ok(false);
}
if let Some(url) = req.broker_url {
let summary = req.matched_entry.map_or_else(
|| "auto_approved".to_string(),
|e| format!("auto_approved: matched {e}"),
);
let msg = build_status_message(req.agent_id, "auto_approved", Some(summary), None);
if let Err(e) = publish_to_broker_http(url, &msg) {
eprintln!(
"warning: failed to publish auto-approve status for {}: {e}",
req.agent_id
);
}
}
for key in APPROVAL_KEYS {
dispatcher
.send_key(req.session, req.pane_index, key)
.map_err(|e| PawError::TmuxError(format!("send-keys {key} failed: {e}")))?;
}
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
struct Recorder {
events: Vec<(String, usize, String)>,
}
impl Recorder {
fn new() -> Self {
Self { events: Vec::new() }
}
}
impl KeyDispatcher for Recorder {
fn send_key(&mut self, session: &str, pane_index: usize, key: &str) -> std::io::Result<()> {
self.events
.push((session.to_string(), pane_index, key.to_string()));
Ok(())
}
}
fn req(
enabled: bool,
kind: PermissionType,
matched_entry: Option<&str>,
) -> ApprovalRequest<'_> {
ApprovalRequest {
enabled,
session: "paw-test",
pane_index: 2,
agent_id: "feat-foo",
kind,
matched_entry,
broker_url: None,
}
}
#[test]
fn safe_prompt_dispatches_btab_down_enter_in_order() {
let mut rec = Recorder::new();
let fired = auto_approve_pane(
&mut rec,
req(true, PermissionType::Cargo, Some("cargo test")),
)
.unwrap();
assert!(fired, "should fire when enabled and class is safe");
let keys: Vec<&str> = rec.events.iter().map(|(_, _, k)| k.as_str()).collect();
assert_eq!(keys, vec!["BTab", "Down", "Enter"]);
for (s, p, _) in &rec.events {
assert_eq!(s, "paw-test");
assert_eq!(*p, 2);
}
}
#[test]
fn each_key_dispatched_separately() {
let mut rec = Recorder::new();
auto_approve_pane(&mut rec, req(true, PermissionType::Curl, None)).unwrap();
assert_eq!(rec.events.len(), 3);
}
#[test]
fn disabled_config_is_noop() {
let mut rec = Recorder::new();
let fired = auto_approve_pane(
&mut rec,
req(false, PermissionType::Cargo, Some("cargo test")),
)
.unwrap();
assert!(!fired);
assert!(rec.events.is_empty(), "disabled => no keystrokes");
}
#[test]
fn unknown_class_is_noop() {
let mut rec = Recorder::new();
let fired = auto_approve_pane(&mut rec, req(true, PermissionType::Unknown, None)).unwrap();
assert!(!fired);
assert!(rec.events.is_empty(), "Unknown => no keystrokes");
}
#[test]
fn approval_keys_constant_matches_spec() {
assert_eq!(APPROVAL_KEYS, &["BTab", "Down", "Enter"]);
}
#[test]
#[allow(clippy::items_after_statements)]
fn broker_audit_message_published_before_keystrokes() {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::{Arc, Mutex};
use std::time::Instant;
#[derive(Debug)]
#[allow(dead_code)] enum Event {
Published(String),
Key(String),
}
struct TimedRecorder {
timeline: Arc<Mutex<Vec<(Instant, Event)>>>,
}
impl KeyDispatcher for TimedRecorder {
fn send_key(
&mut self,
_session: &str,
_pane_index: usize,
key: &str,
) -> std::io::Result<()> {
self.timeline
.lock()
.unwrap()
.push((Instant::now(), Event::Key(key.to_string())));
Ok(())
}
}
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
let addr = listener.local_addr().expect("local_addr");
let timeline: Arc<Mutex<Vec<(Instant, Event)>>> = Arc::new(Mutex::new(Vec::new()));
let server_timeline = Arc::clone(&timeline);
let server = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept");
let mut buf = [0u8; 4096];
let n = stream.read(&mut buf).unwrap_or(0);
let request = String::from_utf8_lossy(&buf[..n]).to_string();
server_timeline
.lock()
.unwrap()
.push((Instant::now(), Event::Published(request.clone())));
let _ = stream.write_all(b"HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\n\r\n");
let _ = stream.flush();
request
});
let mut dispatcher = TimedRecorder {
timeline: Arc::clone(&timeline),
};
let broker_url = format!("http://{addr}");
let req = ApprovalRequest {
enabled: true,
session: "paw-test",
pane_index: 0,
agent_id: "feat-foo",
kind: PermissionType::Cargo,
matched_entry: Some("cargo test"),
broker_url: Some(&broker_url),
};
let fired = auto_approve_pane(&mut dispatcher, req).expect("auto_approve_pane");
assert!(fired, "safe class with enabled=true must fire");
let request = server.join().expect("server thread");
assert!(
request.contains("POST /publish"),
"expected a /publish request, got: {request}"
);
assert!(
request.contains("auto_approved"),
"expected auto_approved tag in body, got: {request}"
);
assert!(
request.contains("feat-foo"),
"expected agent_id in body, got: {request}"
);
let events = timeline.lock().unwrap();
let publish_idx = events
.iter()
.position(|(_, e)| matches!(e, Event::Published(_)))
.expect("publish event recorded");
let first_key_idx = events
.iter()
.position(|(_, e)| matches!(e, Event::Key(_)))
.expect("key event recorded");
assert!(
publish_idx < first_key_idx,
"audit message must be published BEFORE keystrokes; timeline: {events:?}"
);
let keys: Vec<String> = events
.iter()
.filter_map(|(_, e)| match e {
Event::Key(k) => Some(k.clone()),
Event::Published(_) => None,
})
.collect();
assert_eq!(keys, vec!["BTab", "Down", "Enter"]);
}
}