1use std::process::Command;
14
15use crate::broker::publish::{build_status_message, publish_to_broker_http};
16use crate::error::PawError;
17use crate::supervisor::permission_prompt::PermissionType;
18
19pub const APPROVAL_KEYS: &[&str] = &["BTab", "Down", "Enter"];
25
26pub trait KeyDispatcher {
29 fn send_key(&mut self, session: &str, pane_index: usize, key: &str) -> std::io::Result<()>;
34}
35
36pub struct TmuxKeyDispatcher;
38
39impl KeyDispatcher for TmuxKeyDispatcher {
40 fn send_key(&mut self, session: &str, pane_index: usize, key: &str) -> std::io::Result<()> {
41 let target = format!("{session}:0.{pane_index}");
42 let status = Command::new("tmux")
43 .args(["send-keys", "-t", &target, key])
44 .status()?;
45 if !status.success() {
46 return Err(std::io::Error::other(format!(
47 "tmux send-keys exited with {status}"
48 )));
49 }
50 Ok(())
51 }
52}
53
54#[derive(Debug, Clone, Copy)]
59pub struct ApprovalRequest<'a> {
60 pub enabled: bool,
62 pub session: &'a str,
64 pub pane_index: usize,
66 pub agent_id: &'a str,
68 pub kind: PermissionType,
70 pub matched_entry: Option<&'a str>,
73 pub broker_url: Option<&'a str>,
75}
76
77pub fn auto_approve_pane<D: KeyDispatcher>(
93 dispatcher: &mut D,
94 req: ApprovalRequest<'_>,
95) -> Result<bool, PawError> {
96 if !req.enabled || req.kind == PermissionType::Unknown {
97 return Ok(false);
98 }
99
100 if let Some(url) = req.broker_url {
103 let summary = req.matched_entry.map_or_else(
104 || "auto_approved".to_string(),
105 |e| format!("auto_approved: matched {e}"),
106 );
107 let msg = build_status_message(req.agent_id, "auto_approved", Some(summary));
108 if let Err(e) = publish_to_broker_http(url, &msg) {
109 eprintln!(
110 "warning: failed to publish auto-approve status for {}: {e}",
111 req.agent_id
112 );
113 }
114 }
115
116 for key in APPROVAL_KEYS {
117 dispatcher
118 .send_key(req.session, req.pane_index, key)
119 .map_err(|e| PawError::TmuxError(format!("send-keys {key} failed: {e}")))?;
120 }
121 Ok(true)
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127
128 struct Recorder {
131 events: Vec<(String, usize, String)>,
132 }
133
134 impl Recorder {
135 fn new() -> Self {
136 Self { events: Vec::new() }
137 }
138 }
139
140 impl KeyDispatcher for Recorder {
141 fn send_key(&mut self, session: &str, pane_index: usize, key: &str) -> std::io::Result<()> {
142 self.events
143 .push((session.to_string(), pane_index, key.to_string()));
144 Ok(())
145 }
146 }
147
148 fn req(
149 enabled: bool,
150 kind: PermissionType,
151 matched_entry: Option<&str>,
152 ) -> ApprovalRequest<'_> {
153 ApprovalRequest {
154 enabled,
155 session: "paw-test",
156 pane_index: 2,
157 agent_id: "feat-foo",
158 kind,
159 matched_entry,
160 broker_url: None,
161 }
162 }
163
164 #[test]
165 fn safe_prompt_dispatches_btab_down_enter_in_order() {
166 let mut rec = Recorder::new();
167 let fired = auto_approve_pane(
168 &mut rec,
169 req(true, PermissionType::Cargo, Some("cargo test")),
170 )
171 .unwrap();
172 assert!(fired, "should fire when enabled and class is safe");
173 let keys: Vec<&str> = rec.events.iter().map(|(_, _, k)| k.as_str()).collect();
174 assert_eq!(keys, vec!["BTab", "Down", "Enter"]);
175 for (s, p, _) in &rec.events {
176 assert_eq!(s, "paw-test");
177 assert_eq!(*p, 2);
178 }
179 }
180
181 #[test]
182 fn each_key_dispatched_separately() {
183 let mut rec = Recorder::new();
184 auto_approve_pane(&mut rec, req(true, PermissionType::Curl, None)).unwrap();
185 assert_eq!(rec.events.len(), 3);
187 }
188
189 #[test]
190 fn disabled_config_is_noop() {
191 let mut rec = Recorder::new();
192 let fired = auto_approve_pane(
193 &mut rec,
194 req(false, PermissionType::Cargo, Some("cargo test")),
195 )
196 .unwrap();
197 assert!(!fired);
198 assert!(rec.events.is_empty(), "disabled => no keystrokes");
199 }
200
201 #[test]
202 fn unknown_class_is_noop() {
203 let mut rec = Recorder::new();
204 let fired = auto_approve_pane(&mut rec, req(true, PermissionType::Unknown, None)).unwrap();
205 assert!(!fired);
206 assert!(rec.events.is_empty(), "Unknown => no keystrokes");
207 }
208
209 #[test]
210 fn approval_keys_constant_matches_spec() {
211 assert_eq!(APPROVAL_KEYS, &["BTab", "Down", "Enter"]);
214 }
215
216 #[test]
225 #[allow(clippy::items_after_statements)]
226 fn broker_audit_message_published_before_keystrokes() {
227 use std::io::{Read, Write};
228 use std::net::TcpListener;
229 use std::sync::{Arc, Mutex};
230 use std::time::Instant;
231
232 #[derive(Debug)]
235 #[allow(dead_code)] enum Event {
237 Published(String),
238 Key(String),
239 }
240
241 struct TimedRecorder {
243 timeline: Arc<Mutex<Vec<(Instant, Event)>>>,
244 }
245 impl KeyDispatcher for TimedRecorder {
246 fn send_key(
247 &mut self,
248 _session: &str,
249 _pane_index: usize,
250 key: &str,
251 ) -> std::io::Result<()> {
252 self.timeline
253 .lock()
254 .unwrap()
255 .push((Instant::now(), Event::Key(key.to_string())));
256 Ok(())
257 }
258 }
259
260 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
264 let addr = listener.local_addr().expect("local_addr");
265
266 let timeline: Arc<Mutex<Vec<(Instant, Event)>>> = Arc::new(Mutex::new(Vec::new()));
267
268 let server_timeline = Arc::clone(&timeline);
272 let server = std::thread::spawn(move || {
273 let (mut stream, _) = listener.accept().expect("accept");
274 let mut buf = [0u8; 4096];
275 let n = stream.read(&mut buf).unwrap_or(0);
277 let request = String::from_utf8_lossy(&buf[..n]).to_string();
278 server_timeline
279 .lock()
280 .unwrap()
281 .push((Instant::now(), Event::Published(request.clone())));
282 let _ = stream.write_all(b"HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\n\r\n");
283 let _ = stream.flush();
284 request
285 });
286
287 let mut dispatcher = TimedRecorder {
288 timeline: Arc::clone(&timeline),
289 };
290 let broker_url = format!("http://{addr}");
291 let req = ApprovalRequest {
292 enabled: true,
293 session: "paw-test",
294 pane_index: 0,
295 agent_id: "feat-foo",
296 kind: PermissionType::Cargo,
297 matched_entry: Some("cargo test"),
298 broker_url: Some(&broker_url),
299 };
300 let fired = auto_approve_pane(&mut dispatcher, req).expect("auto_approve_pane");
301 assert!(fired, "safe class with enabled=true must fire");
302
303 let request = server.join().expect("server thread");
305
306 assert!(
309 request.contains("POST /publish"),
310 "expected a /publish request, got: {request}"
311 );
312 assert!(
313 request.contains("auto_approved"),
314 "expected auto_approved tag in body, got: {request}"
315 );
316 assert!(
317 request.contains("feat-foo"),
318 "expected agent_id in body, got: {request}"
319 );
320
321 let events = timeline.lock().unwrap();
323 let publish_idx = events
324 .iter()
325 .position(|(_, e)| matches!(e, Event::Published(_)))
326 .expect("publish event recorded");
327 let first_key_idx = events
328 .iter()
329 .position(|(_, e)| matches!(e, Event::Key(_)))
330 .expect("key event recorded");
331 assert!(
332 publish_idx < first_key_idx,
333 "audit message must be published BEFORE keystrokes; timeline: {events:?}"
334 );
335
336 let keys: Vec<String> = events
338 .iter()
339 .filter_map(|(_, e)| match e {
340 Event::Key(k) => Some(k.clone()),
341 Event::Published(_) => None,
342 })
343 .collect();
344 assert_eq!(keys, vec!["BTab", "Down", "Enter"]);
345 }
346}