agentsight_capture/runners/
common.rs1use super::{EventStream, Runner, RunnerError};
5use crate::analyzers::Analyzer;
6use crate::event::Event;
7use async_trait::async_trait;
8use futures::stream::{Stream, StreamExt};
9use log::debug;
10use std::path::Path;
11use std::pin::Pin;
12use std::process::Stdio;
13use std::sync::Arc;
14use std::sync::atomic::{AtomicU64, Ordering};
15use tokio::io::{AsyncBufReadExt, BufReader};
16use tokio::process::Command as TokioCommand;
17
18pub type JsonStream = Pin<Box<dyn Stream<Item = serde_json::Value> + Send>>;
20const RUNNER_ERROR_TYPE: &str = "runner_error";
21
22fn preview_line(line: &str, max_chars: usize) -> String {
23 let mut chars = line.chars();
24 let preview: String = chars.by_ref().take(max_chars).collect();
25 if chars.next().is_some() {
26 format!("{preview}...")
27 } else {
28 preview
29 }
30}
31
32fn runner_label(runner_name: Option<&str>, binary_path: &str) -> String {
33 runner_name.map(str::to_string).unwrap_or_else(|| {
34 Path::new(binary_path)
35 .file_name()
36 .and_then(|n| n.to_str())
37 .unwrap_or("binary")
38 .to_string()
39 })
40}
41
42fn runner_startup_exit_message(
43 label: &str,
44 status: impl std::fmt::Display,
45 needs_sudo: bool,
46) -> String {
47 let mut message = format!("{label} exited during startup with {status}");
48 if needs_sudo {
49 message.push_str(
50 "; probe sudo is non-interactive, so run AgentSight with sudo or authenticate first with `sudo -v`",
51 );
52 }
53 message
54}
55
56fn runner_error_json(runner: &str, message: String) -> serde_json::Value {
57 let timestamp = current_boot_time_ns();
58 serde_json::json!({
59 "timestamp": timestamp,
60 "timestamp_ns": timestamp,
61 "pid": 0,
62 "comm": runner,
63 "type": RUNNER_ERROR_TYPE,
64 "message": message,
65 })
66}
67
68pub fn runner_error_from_event(event: &Event) -> Option<RunnerError> {
69 (event.data.get("type").and_then(|v| v.as_str()) == Some(RUNNER_ERROR_TYPE)).then(|| {
70 RunnerError::from(
71 event
72 .data
73 .get("message")
74 .and_then(|v| v.as_str())
75 .unwrap_or("runner failed")
76 .to_string(),
77 )
78 })
79}
80
81struct ProbeProcessGuard {
82 pgid: Option<u32>,
83 needs_sudo: bool,
84}
85
86impl ProbeProcessGuard {
87 fn new(pid: Option<u32>, needs_sudo: bool) -> Self {
88 Self {
89 pgid: pid,
90 needs_sudo,
91 }
92 }
93
94 fn disarm(&mut self) {
95 self.pgid = None;
96 }
97
98 fn terminate(&mut self) {
99 let Some(pgid) = self.pgid.take() else {
100 return;
101 };
102 if self.needs_sudo {
103 let _ = std::process::Command::new("sudo")
104 .args(["-n", "kill", "-TERM", "--", &format!("-{pgid}")])
105 .status();
106 } else {
107 terminate_process_group(pgid);
108 }
109 }
110}
111
112#[cfg(unix)]
113fn terminate_process_group(pgid: u32) {
114 unsafe {
115 libc::killpg(pgid as libc::pid_t, libc::SIGTERM);
116 }
117}
118
119#[cfg(not(unix))]
120fn terminate_process_group(_pgid: u32) {}
121
122#[cfg(unix)]
123fn needs_sudo() -> bool {
124 unsafe { libc::geteuid() != 0 }
125}
126
127#[cfg(not(unix))]
128fn needs_sudo() -> bool {
129 false
130}
131
132impl Drop for ProbeProcessGuard {
133 fn drop(&mut self) {
134 self.terminate();
135 }
136}
137
138pub fn current_boot_time_ns() -> u64 {
139 std::fs::read_to_string("/proc/uptime")
140 .ok()
141 .and_then(|uptime| uptime.split_whitespace().next()?.parse::<f64>().ok())
142 .map(|secs| (secs * 1_000_000_000.0) as u64)
143 .unwrap_or(0)
144}
145
146pub fn parse_error_event(
147 runner: &'static str,
148 raw: serde_json::Value,
149 reason: impl Into<String>,
150 errors: &AtomicU64,
151) -> Event {
152 let timestamp = raw
153 .get("timestamp_ns")
154 .or_else(|| raw.get("timestamp"))
155 .and_then(|v| v.as_u64())
156 .unwrap_or_else(current_boot_time_ns);
157 let pid = raw
158 .get("pid")
159 .and_then(|v| v.as_u64())
160 .map(|v| v as u32)
161 .unwrap_or(0);
162 let comm = raw
163 .get("comm")
164 .and_then(|v| v.as_str())
165 .unwrap_or(runner)
166 .to_string();
167 let count = errors.fetch_add(1, Ordering::Relaxed) + 1;
168
169 Event::new_with_timestamp(
170 timestamp,
171 "diagnostic".to_string(),
172 pid,
173 comm,
174 serde_json::json!({
175 "type": "runner_parse_error",
176 "runner": runner,
177 "reason": reason.into(),
178 "parse_error_count": count,
179 "raw": raw,
180 }),
181 )
182}
183
184pub fn parse_json_event(
185 runner: &'static str,
186 timestamp_field: &'static str,
187 raw: serde_json::Value,
188 errors: &AtomicU64,
189) -> Event {
190 let Some(timestamp) = raw.get(timestamp_field).and_then(|v| v.as_u64()) else {
191 return parse_error_event(runner, raw, format!("missing {timestamp_field}"), errors);
192 };
193 let Some(pid) = raw.get("pid").and_then(|v| v.as_u64()).map(|v| v as u32) else {
194 return parse_error_event(runner, raw, "missing pid", errors);
195 };
196 let Some(comm) = raw.get("comm").and_then(|v| v.as_str()).map(str::to_string) else {
197 return parse_error_event(runner, raw, "missing comm", errors);
198 };
199
200 Event::new_with_timestamp(timestamp, runner.to_string(), pid, comm, raw)
201}
202
203pub struct BinaryExecutor {
205 binary_path: String,
206 additional_args: Vec<String>,
207 runner_name: Option<String>,
208}
209
210impl BinaryExecutor {
211 pub fn new(binary_path: String) -> Self {
212 Self {
213 binary_path,
214 additional_args: Vec::new(),
215 runner_name: None,
216 }
217 }
218
219 pub fn with_args(mut self, args: &[String]) -> Self {
220 self.additional_args = args.to_vec();
221 self
222 }
223
224 pub fn set_args(&mut self, args: &[String]) {
225 self.additional_args = args.to_vec();
226 }
227
228 pub fn with_runner_name(mut self, name: String) -> Self {
229 self.runner_name = Some(name);
230 self
231 }
232
233 pub async fn get_json_stream(&self) -> Result<JsonStream, RunnerError> {
238 let needs_sudo = needs_sudo();
239
240 if needs_sudo {
241 log::info!(
242 "Executing binary (via sudo): {} {}",
243 self.binary_path,
244 self.additional_args.join(" ")
245 );
246 } else if self.additional_args.is_empty() {
247 log::info!("Executing binary: {}", self.binary_path);
248 } else {
249 log::info!(
250 "Executing binary: {} {}",
251 self.binary_path,
252 self.additional_args.join(" ")
253 );
254 }
255
256 let mut cmd = if needs_sudo {
257 let mut c = TokioCommand::new("sudo");
258 c.arg("-n").arg(&self.binary_path);
259 c
260 } else {
261 TokioCommand::new(&self.binary_path)
262 };
263 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
264 cmd.kill_on_drop(true);
265 #[cfg(unix)]
266 cmd.process_group(0);
267
268 if !self.additional_args.is_empty() {
270 cmd.args(&self.additional_args);
271 debug!("Added arguments: {:?}", self.additional_args);
272 }
273
274 let mut child = cmd.spawn().map_err(|e| {
275 Box::new(std::io::Error::other(format!(
276 "Failed to start binary: {}",
277 e
278 ))) as RunnerError
279 })?;
280
281 let stdout = child.stdout.take().ok_or_else(|| {
282 Box::new(std::io::Error::other("Failed to get stdout")) as RunnerError
283 })?;
284
285 let stderr = child.stderr.take().ok_or_else(|| {
286 Box::new(std::io::Error::other("Failed to get stderr")) as RunnerError
287 })?;
288
289 let child_pid = child.id();
290 if let Some(pid) = child_pid {
291 debug!("Binary started with PID: Some({})", pid);
292 }
293
294 let runner_name = self.runner_name.clone();
296 let binary_path = self.binary_path.clone();
297 let label = runner_label(runner_name.as_deref(), &binary_path);
298
299 let stderr_label = label.clone();
301 tokio::spawn(async move {
302 let mut stderr_reader = BufReader::new(stderr);
303 let mut stderr_line = String::new();
304
305 loop {
306 stderr_line.clear();
307 match stderr_reader.read_line(&mut stderr_line).await {
308 Ok(0) => {
309 break;
311 }
312 Ok(_) => {
313 let trimmed = stderr_line.trim();
314 if !trimmed.is_empty() {
315 log::warn!("[{}] STDERR: {}", stderr_label, trimmed);
316 }
317 }
318 Err(e) => {
319 if e.kind() != std::io::ErrorKind::UnexpectedEof {
320 log::warn!("Error reading stderr: {}", e);
321 }
322 break;
323 }
324 }
325 }
326 });
327
328 let startup_delay_ms = if self
329 .additional_args
330 .iter()
331 .any(|arg| arg == "--binary-path")
332 {
333 Some(1500)
334 } else if needs_sudo {
335 Some(200)
336 } else {
337 None
338 };
339 if let Some(delay_ms) = startup_delay_ms {
340 tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
341 if let Some(status) = child.try_wait()? {
342 let label = runner_name.as_deref().unwrap_or("binary");
343 return Err(RunnerError::from(runner_startup_exit_message(
344 label, status, needs_sudo,
345 )));
346 }
347 }
348
349 let stream = async_stream::stream! {
350 let mut guard = ProbeProcessGuard::new(child_pid, needs_sudo);
351 let mut reader = BufReader::new(stdout);
352 let mut line = Vec::new();
353 let mut line_count = 0;
354
355 debug!("Reading from binary stdout");
356
357 loop {
358 line.clear();
359
360 match reader.read_until(b'\n', &mut line).await {
361 Ok(0) => {
362 debug!("Binary stdout closed (EOF)");
363 break;
364 }
365 Ok(_) => {
366 line_count += 1;
367 let decoded = String::from_utf8_lossy(&line);
368 let trimmed = decoded.trim();
369
370 if !trimmed.is_empty() {
371 debug!("Line {}: {}", line_count, preview_line(trimmed, 100));
372
373 if trimmed.starts_with('{') && trimmed.ends_with('}') {
375 match serde_json::from_str::<serde_json::Value>(trimmed) {
376 Ok(json_value) => {
377 debug!("Parsed JSON value");
378 yield json_value;
379 }
380 Err(e) => {
381 log::warn!("Failed to parse JSON from line {}: {} - Line: {}",
382 line_count, e,
383 preview_line(trimmed, 200)
384 );
385 }
386 }
387 } else {
388 if trimmed.contains("error") || trimmed.contains("warn") ||
390 trimmed.contains("failed") || trimmed.contains("Error:") {
391 log::warn!("Possible error message from binary at line {}: {}",
392 line_count, trimmed);
393 } else {
394 log::warn!("Skipping non-JSON line {} from binary: {}",
395 line_count,
396 preview_line(trimmed, 100)
397 );
398 }
399 }
400 }
401 }
402 Err(e) => {
403 if e.kind() == std::io::ErrorKind::Interrupted {
404 log::debug!("Read interrupted, retrying...");
406 continue;
407 } else {
408 log::warn!("Error reading from binary: {} (kind: {:?})", e, e.kind());
409 break;
410 }
411 }
412 }
413 }
414
415 log::info!("Terminating binary process");
416
417 guard.terminate();
419 if let Err(e) = child.kill().await {
420 log::warn!("Failed to kill binary process: {}", e);
421 }
422
423 match child.wait().await {
425 Ok(status) => {
426 debug!("Binary process terminated with status: {}", status);
427 guard.disarm();
428 if !status.success() {
429 yield runner_error_json(&label, format!("{label} exited with {status}"));
430 }
431 }
432 Err(e) => {
433 yield runner_error_json(&label, format!("failed to wait for {label}: {e}"));
434 }
435 }
436 };
437
438 Ok(Box::pin(stream))
439 }
440}
441
442pub struct AnalyzerProcessor;
444
445impl AnalyzerProcessor {
446 pub async fn process_through_analyzers(
448 mut stream: EventStream,
449 analyzers: &mut [Box<dyn Analyzer>],
450 ) -> Result<EventStream, RunnerError> {
451 for analyzer in analyzers.iter_mut() {
452 stream = analyzer.process(stream).await?;
453 }
454 Ok(stream)
455 }
456}
457
458pub struct BinaryRunner {
459 analyzers: Vec<Box<dyn Analyzer>>,
460 executor: BinaryExecutor,
461 source: &'static str,
462 timestamp_field: &'static str,
463}
464
465impl BinaryRunner {
466 pub fn new(
467 runner_name: &str,
468 source: &'static str,
469 timestamp_field: &'static str,
470 binary_path: impl AsRef<Path>,
471 ) -> Self {
472 Self {
473 analyzers: Vec::new(),
474 executor: BinaryExecutor::new(binary_path.as_ref().to_string_lossy().into_owned())
475 .with_runner_name(runner_name.to_string()),
476 source,
477 timestamp_field,
478 }
479 }
480
481 pub fn ssl(binary_path: impl AsRef<Path>) -> Self {
482 Self::new("SSL", "ssl", "timestamp_ns", binary_path)
483 }
484
485 pub fn stdio(binary_path: impl AsRef<Path>) -> Self {
486 Self::new("Stdio", "stdio", "timestamp_ns", binary_path)
487 }
488
489 pub fn with_args<I, S>(mut self, args: I) -> Self
490 where
491 I: IntoIterator<Item = S>,
492 S: AsRef<str>,
493 {
494 let args: Vec<_> = args.into_iter().map(|s| s.as_ref().to_string()).collect();
495 self.executor = self.executor.with_args(&args);
496 self
497 }
498}
499
500#[async_trait]
501impl Runner for BinaryRunner {
502 async fn run(&mut self) -> Result<EventStream, RunnerError> {
503 let json_stream = self.executor.get_json_stream().await?;
504 let errors = Arc::new(AtomicU64::new(0));
505 let source = self.source;
506 let ts_field = self.timestamp_field;
507 let stream = json_stream.map(move |v| parse_json_event(source, ts_field, v, &errors));
508 AnalyzerProcessor::process_through_analyzers(Box::pin(stream), &mut self.analyzers).await
509 }
510
511 fn add_analyzer(mut self, analyzer: Box<dyn Analyzer>) -> Self {
512 self.analyzers.push(analyzer);
513 self
514 }
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520
521 #[test]
522 fn sudo_startup_exit_message_names_noninteractive_sudo() {
523 let message = runner_startup_exit_message("Process", "exit status: 1", true);
524 assert!(message.contains("Process exited during startup with exit status: 1"));
525 assert!(message.contains("sudo -v"));
526 assert!(message.contains("non-interactive"));
527 }
528
529 #[test]
530 fn non_sudo_startup_exit_message_stays_short() {
531 let message = runner_startup_exit_message("Process", "exit status: 1", false);
532 assert_eq!(message, "Process exited during startup with exit status: 1");
533 }
534}