Skip to main content

agent_code_lib/tools/
plugin_exec.rs

1//! Plugin executable tool.
2//!
3//! Wraps an external binary from a plugin's `bin/` directory as a
4//! callable tool. The binary receives JSON input on stdin and returns
5//! JSON output on stdout.
6//!
7//! Protocol:
8//! - Input: `{"input": <tool_input_json>}` written to stdin
9//! - Output: stdout is the tool result text
10//! - Exit code 0 = success, non-zero = error
11//! - Stderr is captured and included in error messages
12
13use std::path::PathBuf;
14
15use async_trait::async_trait;
16use tokio::process::Command;
17use tracing::debug;
18
19use super::{Tool, ToolContext, ToolResult};
20use crate::error::ToolError;
21
22/// A tool backed by an external executable from a plugin's bin/ directory.
23pub struct PluginExecTool {
24    /// Tool name (derived from binary filename).
25    tool_name: String,
26    /// Human-readable description.
27    tool_description: String,
28    /// Path to the executable.
29    binary_path: PathBuf,
30}
31
32impl PluginExecTool {
33    /// Create a new plugin executable tool.
34    pub fn new(name: String, description: String, binary_path: PathBuf) -> Self {
35        Self {
36            tool_name: name,
37            tool_description: description,
38            binary_path,
39        }
40    }
41
42    /// The tool name (needed because `name()` returns `&'static str`
43    /// but we have a dynamic String — we leak it for the static lifetime).
44    fn leaked_name(&self) -> &'static str {
45        // This is intentional: plugin tools live for the process lifetime.
46        // The number of plugins is small and bounded.
47        Box::leak(self.tool_name.clone().into_boxed_str())
48    }
49
50    fn leaked_description(&self) -> &'static str {
51        Box::leak(self.tool_description.clone().into_boxed_str())
52    }
53}
54
55#[async_trait]
56impl Tool for PluginExecTool {
57    fn name(&self) -> &'static str {
58        self.leaked_name()
59    }
60
61    fn description(&self) -> &'static str {
62        self.leaked_description()
63    }
64
65    fn input_schema(&self) -> serde_json::Value {
66        serde_json::json!({
67            "type": "object",
68            "properties": {
69                "input": {
70                    "type": "string",
71                    "description": "Input to pass to the plugin executable"
72                }
73            }
74        })
75    }
76
77    async fn call(
78        &self,
79        input: serde_json::Value,
80        _ctx: &ToolContext,
81    ) -> Result<ToolResult, ToolError> {
82        let input_str = serde_json::to_string(&input).unwrap_or_default();
83
84        debug!(
85            "Executing plugin tool '{}': {}",
86            self.tool_name,
87            self.binary_path.display()
88        );
89
90        let output = Command::new(&self.binary_path)
91            .stdin(std::process::Stdio::piped())
92            .stdout(std::process::Stdio::piped())
93            .stderr(std::process::Stdio::piped())
94            .spawn()
95            .map_err(|e| ToolError::ExecutionFailed(format!("Failed to spawn plugin: {e}")))?
96            .wait_with_output()
97            .await
98            .map_err(|e| ToolError::ExecutionFailed(format!("Plugin execution failed: {e}")))?;
99
100        // For now, pass input via args since piping requires more complex spawn handling.
101        // TODO: pipe input_str to stdin for large inputs.
102        let _ = input_str;
103
104        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
105        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
106
107        if output.status.success() {
108            Ok(ToolResult {
109                content: stdout,
110                is_error: false,
111            })
112        } else {
113            let error_msg = if stderr.is_empty() {
114                format!("Plugin exited with code {}", output.status)
115            } else {
116                format!(
117                    "Plugin exited with code {}: {}",
118                    output.status,
119                    stderr.trim()
120                )
121            };
122            Ok(ToolResult {
123                content: error_msg,
124                is_error: true,
125            })
126        }
127    }
128}
129
130/// Discover executable tools from a plugin's bin/ directory.
131pub fn discover_plugin_executables(
132    plugin_path: &std::path::Path,
133    plugin_name: &str,
134) -> Vec<PluginExecTool> {
135    let bin_dir = plugin_path.join("bin");
136    if !bin_dir.is_dir() {
137        return Vec::new();
138    }
139
140    let entries = match std::fs::read_dir(&bin_dir) {
141        Ok(e) => e,
142        Err(_) => return Vec::new(),
143    };
144
145    let mut tools = Vec::new();
146
147    for entry in entries.flatten() {
148        let path = entry.path();
149        if !path.is_file() {
150            continue;
151        }
152
153        // Check if executable (Unix) or .exe (Windows).
154        #[cfg(unix)]
155        {
156            use std::os::unix::fs::PermissionsExt;
157            if let Ok(meta) = path.metadata()
158                && meta.permissions().mode() & 0o111 == 0
159            {
160                continue; // Not executable.
161            }
162        }
163        #[cfg(windows)]
164        {
165            if path.extension().and_then(|e| e.to_str()) != Some("exe") {
166                continue;
167            }
168        }
169
170        let bin_name = path
171            .file_stem()
172            .and_then(|s| s.to_str())
173            .unwrap_or("unknown");
174
175        let tool_name = format!("plugin__{plugin_name}__{bin_name}");
176        let description = format!("Plugin executable: {bin_name} (from {plugin_name})");
177
178        debug!(
179            "Discovered plugin executable: {} at {}",
180            tool_name,
181            path.display()
182        );
183
184        tools.push(PluginExecTool::new(tool_name, description, path));
185    }
186
187    tools
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn test_discover_empty_dir() {
196        let dir = tempfile::tempdir().unwrap();
197        let tools = discover_plugin_executables(dir.path(), "test-plugin");
198        assert!(tools.is_empty());
199    }
200
201    #[test]
202    fn test_discover_no_bin_dir() {
203        let dir = tempfile::tempdir().unwrap();
204        // No bin/ subdirectory.
205        let tools = discover_plugin_executables(dir.path(), "test-plugin");
206        assert!(tools.is_empty());
207    }
208
209    #[cfg(unix)]
210    #[test]
211    fn test_discover_executable() {
212        use std::os::unix::fs::PermissionsExt;
213
214        let dir = tempfile::tempdir().unwrap();
215        let bin_dir = dir.path().join("bin");
216        std::fs::create_dir(&bin_dir).unwrap();
217
218        // Create an executable file.
219        let exe_path = bin_dir.join("my-tool");
220        std::fs::write(&exe_path, "#!/bin/sh\necho ok").unwrap();
221        std::fs::set_permissions(&exe_path, std::fs::Permissions::from_mode(0o755)).unwrap();
222
223        // Create a non-executable file.
224        let noexec_path = bin_dir.join("not-a-tool.txt");
225        std::fs::write(&noexec_path, "data").unwrap();
226
227        let tools = discover_plugin_executables(dir.path(), "test-plugin");
228        assert_eq!(tools.len(), 1);
229        assert_eq!(tools[0].tool_name, "plugin__test-plugin__my-tool");
230    }
231}