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<libc::pid_t>,
83 needs_sudo: bool,
84}
85
86impl ProbeProcessGuard {
87 fn new(pid: Option<u32>, needs_sudo: bool) -> Self {
88 Self {
89 pgid: pid.map(|pid| pid as libc::pid_t),
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 unsafe {
108 libc::killpg(pgid, libc::SIGTERM);
109 }
110 }
111 }
112}
113
114impl Drop for ProbeProcessGuard {
115 fn drop(&mut self) {
116 self.terminate();
117 }
118}
119
120pub fn current_boot_time_ns() -> u64 {
121 std::fs::read_to_string("/proc/uptime")
122 .ok()
123 .and_then(|uptime| uptime.split_whitespace().next()?.parse::<f64>().ok())
124 .map(|secs| (secs * 1_000_000_000.0) as u64)
125 .unwrap_or(0)
126}
127
128pub fn parse_error_event(
129 runner: &'static str,
130 raw: serde_json::Value,
131 reason: impl Into<String>,
132 errors: &AtomicU64,
133) -> Event {
134 let timestamp = raw
135 .get("timestamp_ns")
136 .or_else(|| raw.get("timestamp"))
137 .and_then(|v| v.as_u64())
138 .unwrap_or_else(current_boot_time_ns);
139 let pid = raw
140 .get("pid")
141 .and_then(|v| v.as_u64())
142 .map(|v| v as u32)
143 .unwrap_or(0);
144 let comm = raw
145 .get("comm")
146 .and_then(|v| v.as_str())
147 .unwrap_or(runner)
148 .to_string();
149 let count = errors.fetch_add(1, Ordering::Relaxed) + 1;
150
151 Event::new_with_timestamp(
152 timestamp,
153 "diagnostic".to_string(),
154 pid,
155 comm,
156 serde_json::json!({
157 "type": "runner_parse_error",
158 "runner": runner,
159 "reason": reason.into(),
160 "parse_error_count": count,
161 "raw": raw,
162 }),
163 )
164}
165
166pub fn parse_json_event(
167 runner: &'static str,
168 timestamp_field: &'static str,
169 raw: serde_json::Value,
170 errors: &AtomicU64,
171) -> Event {
172 let Some(timestamp) = raw.get(timestamp_field).and_then(|v| v.as_u64()) else {
173 return parse_error_event(runner, raw, format!("missing {timestamp_field}"), errors);
174 };
175 let Some(pid) = raw.get("pid").and_then(|v| v.as_u64()).map(|v| v as u32) else {
176 return parse_error_event(runner, raw, "missing pid", errors);
177 };
178 let Some(comm) = raw.get("comm").and_then(|v| v.as_str()).map(str::to_string) else {
179 return parse_error_event(runner, raw, "missing comm", errors);
180 };
181
182 Event::new_with_timestamp(timestamp, runner.to_string(), pid, comm, raw)
183}
184
185pub struct BinaryExecutor {
187 binary_path: String,
188 additional_args: Vec<String>,
189 runner_name: Option<String>,
190}
191
192impl BinaryExecutor {
193 pub fn new(binary_path: String) -> Self {
194 Self {
195 binary_path,
196 additional_args: Vec::new(),
197 runner_name: None,
198 }
199 }
200
201 pub fn with_args(mut self, args: &[String]) -> Self {
202 self.additional_args = args.to_vec();
203 self
204 }
205
206 pub fn set_args(&mut self, args: &[String]) {
207 self.additional_args = args.to_vec();
208 }
209
210 pub fn with_runner_name(mut self, name: String) -> Self {
211 self.runner_name = Some(name);
212 self
213 }
214
215 pub async fn get_json_stream(&self) -> Result<JsonStream, RunnerError> {
220 let needs_sudo = unsafe { libc::geteuid() } != 0;
221
222 if needs_sudo {
223 log::info!(
224 "Executing binary (via sudo): {} {}",
225 self.binary_path,
226 self.additional_args.join(" ")
227 );
228 } else if self.additional_args.is_empty() {
229 log::info!("Executing binary: {}", self.binary_path);
230 } else {
231 log::info!(
232 "Executing binary: {} {}",
233 self.binary_path,
234 self.additional_args.join(" ")
235 );
236 }
237
238 let mut cmd = if needs_sudo {
239 let mut c = TokioCommand::new("sudo");
240 c.arg("-n").arg(&self.binary_path);
241 c
242 } else {
243 TokioCommand::new(&self.binary_path)
244 };
245 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
246 cmd.kill_on_drop(true);
247 cmd.process_group(0);
248
249 if !self.additional_args.is_empty() {
251 cmd.args(&self.additional_args);
252 debug!("Added arguments: {:?}", self.additional_args);
253 }
254
255 let mut child = cmd.spawn().map_err(|e| {
256 Box::new(std::io::Error::other(format!(
257 "Failed to start binary: {}",
258 e
259 ))) as RunnerError
260 })?;
261
262 let stdout = child.stdout.take().ok_or_else(|| {
263 Box::new(std::io::Error::other("Failed to get stdout")) as RunnerError
264 })?;
265
266 let stderr = child.stderr.take().ok_or_else(|| {
267 Box::new(std::io::Error::other("Failed to get stderr")) as RunnerError
268 })?;
269
270 let child_pid = child.id();
271 if let Some(pid) = child_pid {
272 debug!("Binary started with PID: Some({})", pid);
273 }
274
275 let runner_name = self.runner_name.clone();
277 let binary_path = self.binary_path.clone();
278 let label = runner_label(runner_name.as_deref(), &binary_path);
279
280 let stderr_label = label.clone();
282 tokio::spawn(async move {
283 let mut stderr_reader = BufReader::new(stderr);
284 let mut stderr_line = String::new();
285
286 loop {
287 stderr_line.clear();
288 match stderr_reader.read_line(&mut stderr_line).await {
289 Ok(0) => {
290 break;
292 }
293 Ok(_) => {
294 let trimmed = stderr_line.trim();
295 if !trimmed.is_empty() {
296 log::warn!("[{}] STDERR: {}", stderr_label, trimmed);
297 }
298 }
299 Err(e) => {
300 if e.kind() != std::io::ErrorKind::UnexpectedEof {
301 log::warn!("Error reading stderr: {}", e);
302 }
303 break;
304 }
305 }
306 }
307 });
308
309 let startup_delay_ms = if self
310 .additional_args
311 .iter()
312 .any(|arg| arg == "--binary-path")
313 {
314 Some(1500)
315 } else if needs_sudo {
316 Some(200)
317 } else {
318 None
319 };
320 if let Some(delay_ms) = startup_delay_ms {
321 tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
322 if let Some(status) = child.try_wait()? {
323 let label = runner_name.as_deref().unwrap_or("binary");
324 return Err(RunnerError::from(runner_startup_exit_message(
325 label, status, needs_sudo,
326 )));
327 }
328 }
329
330 let stream = async_stream::stream! {
331 let mut guard = ProbeProcessGuard::new(child_pid, needs_sudo);
332 let mut reader = BufReader::new(stdout);
333 let mut line = Vec::new();
334 let mut line_count = 0;
335
336 debug!("Reading from binary stdout");
337
338 loop {
339 line.clear();
340
341 match reader.read_until(b'\n', &mut line).await {
342 Ok(0) => {
343 debug!("Binary stdout closed (EOF)");
344 break;
345 }
346 Ok(_) => {
347 line_count += 1;
348 let decoded = String::from_utf8_lossy(&line);
349 let trimmed = decoded.trim();
350
351 if !trimmed.is_empty() {
352 debug!("Line {}: {}", line_count, preview_line(trimmed, 100));
353
354 if trimmed.starts_with('{') && trimmed.ends_with('}') {
356 match serde_json::from_str::<serde_json::Value>(trimmed) {
357 Ok(json_value) => {
358 debug!("Parsed JSON value");
359 yield json_value;
360 }
361 Err(e) => {
362 log::warn!("Failed to parse JSON from line {}: {} - Line: {}",
363 line_count, e,
364 preview_line(trimmed, 200)
365 );
366 }
367 }
368 } else {
369 if trimmed.contains("error") || trimmed.contains("warn") ||
371 trimmed.contains("failed") || trimmed.contains("Error:") {
372 log::warn!("Possible error message from binary at line {}: {}",
373 line_count, trimmed);
374 } else {
375 log::warn!("Skipping non-JSON line {} from binary: {}",
376 line_count,
377 preview_line(trimmed, 100)
378 );
379 }
380 }
381 }
382 }
383 Err(e) => {
384 if e.kind() == std::io::ErrorKind::Interrupted {
385 log::debug!("Read interrupted, retrying...");
387 continue;
388 } else {
389 log::warn!("Error reading from binary: {} (kind: {:?})", e, e.kind());
390 break;
391 }
392 }
393 }
394 }
395
396 log::info!("Terminating binary process");
397
398 guard.terminate();
400 if let Err(e) = child.kill().await {
401 log::warn!("Failed to kill binary process: {}", e);
402 }
403
404 match child.wait().await {
406 Ok(status) => {
407 debug!("Binary process terminated with status: {}", status);
408 guard.disarm();
409 if !status.success() {
410 yield runner_error_json(&label, format!("{label} exited with {status}"));
411 }
412 }
413 Err(e) => {
414 yield runner_error_json(&label, format!("failed to wait for {label}: {e}"));
415 }
416 }
417 };
418
419 Ok(Box::pin(stream))
420 }
421}
422
423pub struct AnalyzerProcessor;
425
426impl AnalyzerProcessor {
427 pub async fn process_through_analyzers(
429 mut stream: EventStream,
430 analyzers: &mut [Box<dyn Analyzer>],
431 ) -> Result<EventStream, RunnerError> {
432 for analyzer in analyzers.iter_mut() {
433 stream = analyzer.process(stream).await?;
434 }
435 Ok(stream)
436 }
437}
438
439pub struct BinaryRunner {
440 analyzers: Vec<Box<dyn Analyzer>>,
441 executor: BinaryExecutor,
442 source: &'static str,
443 timestamp_field: &'static str,
444}
445
446impl BinaryRunner {
447 pub fn new(
448 runner_name: &str,
449 source: &'static str,
450 timestamp_field: &'static str,
451 binary_path: impl AsRef<Path>,
452 ) -> Self {
453 Self {
454 analyzers: Vec::new(),
455 executor: BinaryExecutor::new(binary_path.as_ref().to_string_lossy().into_owned())
456 .with_runner_name(runner_name.to_string()),
457 source,
458 timestamp_field,
459 }
460 }
461
462 pub fn ssl(binary_path: impl AsRef<Path>) -> Self {
463 Self::new("SSL", "ssl", "timestamp_ns", binary_path)
464 }
465
466 pub fn stdio(binary_path: impl AsRef<Path>) -> Self {
467 Self::new("Stdio", "stdio", "timestamp_ns", binary_path)
468 }
469
470 pub fn with_args<I, S>(mut self, args: I) -> Self
471 where
472 I: IntoIterator<Item = S>,
473 S: AsRef<str>,
474 {
475 let args: Vec<_> = args.into_iter().map(|s| s.as_ref().to_string()).collect();
476 self.executor = self.executor.with_args(&args);
477 self
478 }
479}
480
481#[async_trait]
482impl Runner for BinaryRunner {
483 async fn run(&mut self) -> Result<EventStream, RunnerError> {
484 let json_stream = self.executor.get_json_stream().await?;
485 let errors = Arc::new(AtomicU64::new(0));
486 let source = self.source;
487 let ts_field = self.timestamp_field;
488 let stream = json_stream.map(move |v| parse_json_event(source, ts_field, v, &errors));
489 AnalyzerProcessor::process_through_analyzers(Box::pin(stream), &mut self.analyzers).await
490 }
491
492 fn add_analyzer(mut self, analyzer: Box<dyn Analyzer>) -> Self {
493 self.analyzers.push(analyzer);
494 self
495 }
496}
497
498#[cfg(test)]
499mod tests {
500 use super::*;
501
502 #[test]
503 fn sudo_startup_exit_message_names_noninteractive_sudo() {
504 let message = runner_startup_exit_message("Process", "exit status: 1", true);
505 assert!(message.contains("Process exited during startup with exit status: 1"));
506 assert!(message.contains("sudo -v"));
507 assert!(message.contains("non-interactive"));
508 }
509
510 #[test]
511 fn non_sudo_startup_exit_message_stays_short() {
512 let message = runner_startup_exit_message("Process", "exit status: 1", false);
513 assert_eq!(message, "Process exited during startup with exit status: 1");
514 }
515}