From ad376c40f1bae976789c55189b79d9bde962c403 Mon Sep 17 00:00:00 2001 From: Andrew Phillips Date: Wed, 10 Sep 2025 10:13:17 -0300 Subject: [PATCH] feat: add exec filter plugin for external command execution Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) --- src/filter_plugin/exec.rs | 192 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) diff --git a/src/filter_plugin/exec.rs b/src/filter_plugin/exec.rs index e69de29..b1f5549 100644 --- a/src/filter_plugin/exec.rs +++ b/src/filter_plugin/exec.rs @@ -0,0 +1,192 @@ +use super::{FilterPlugin, FilterOption}; +use std::io::{Result, Read, Write}; +use std::process::{Command, Stdio, Child}; +use which::which; +use log::*; + +#[derive(Debug, Clone)] +pub struct ExecFilter { + program: String, + args: Vec, + supported: bool, + split_whitespace: bool, + child_process: Option, + stdin_writer: Option, + stdout_reader: Option, +} + +impl ExecFilter { + pub fn new( + program: &str, + args: Vec<&str>, + split_whitespace: bool, + ) -> ExecFilter { + let program_path = which(program); + let supported = program_path.is_ok(); + + ExecFilter { + program: program_path.map_or_else(|| program.to_string(), |p| p.to_string_lossy().to_string()), + args: args.iter().map(|s| s.to_string()).collect(), + supported, + split_whitespace, + child_process: None, + stdin_writer: None, + stdout_reader: None, + } + } +} + +impl FilterPlugin for ExecFilter { + fn filter(&mut self, mut reader: Box<&mut dyn Read>, mut writer: Box<&mut dyn Write>) -> Result<()> { + if !self.supported { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("Program '{}' not found", self.program), + )); + } + + debug!("FILTER_EXEC: Executing command: {} {:?}", self.program, self.args); + + let mut child = Command::new(&self.program) + .args(&self.args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("Failed to spawn process '{}': {}", self.program, e), + ) + })?; + + let stdin = child.stdin.take().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::Other, + "Failed to capture stdin from child process", + ) + })?; + let stdout = child.stdout.take().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::Other, + "Failed to capture stdout from child process", + ) + })?; + + self.stdin_writer = Some(stdin); + self.stdout_reader = Some(stdout); + self.child_process = Some(child); + + let mut stdin_writer = self.stdin_writer.as_mut().unwrap(); + let mut stdout_reader = self.stdout_reader.as_mut().unwrap(); + + // Thread to copy from input reader to child stdin + let input_thread = std::thread::spawn(move || { + std::io::copy(&mut *reader, &mut *stdin_writer) + .map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("Failed to write to process stdin: {}", e), + ) + })?; + // Close stdin to signal EOF to the child process + drop(stdin_writer); + Ok(()) + }); + + // Thread to copy from child stdout to output writer + let output_thread = std::thread::spawn(move || { + std::io::copy(&mut *stdout_reader, &mut *writer) + .map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("Failed to read from process stdout: {}", e), + ) + })?; + Ok(()) + }); + + // Wait for both threads to complete + input_thread.join().map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("Input thread panicked: {:?}", e), + ) + })?; + output_thread.join().map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("Output thread panicked: {:?}", e), + ) + })?; + + // Wait for the child process to finish + if let Some(mut child) = self.child_process.take() { + let output = child.wait_with_output() + .map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("Failed to wait on child process: {}", e), + ) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if !stderr.is_empty() { + warn!("FILTER_EXEC: Process stderr: {}", stderr); + } + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + format!("Process exited with error: {:?}", output.status), + )); + } + } + + debug!("FILTER_EXEC: Process completed successfully"); + Ok(()) + } + + fn clone_box(&self) -> Box { + Box::new(ExecFilter { + program: self.program.clone(), + args: self.args.clone(), + supported: self.supported, + split_whitespace: self.split_whitespace, + child_process: None, + stdin_writer: None, + stdout_reader: None, + }) + } + + fn options(&self) -> Vec { + vec![ + FilterOption { + name: "command".to_string(), + default: None, + required: true, + }, + FilterOption { + name: "split_whitespace".to_string(), + default: Some(serde_json::Value::Bool(true)), + required: false, + }, + ] + } +} + +// Register the plugin at module initialization time +#[ctor::ctor] +fn register_exec_filter() { + crate::services::filter_service::register_filter_plugin("exec", || { + // Create a dummy instance - actual creation happens in create method + Box::new(ExecFilter { + program: String::new(), + args: Vec::new(), + supported: false, + split_whitespace: true, + child_process: None, + stdin_writer: None, + stdout_reader: None, + }) + }); +}