1use std::path::PathBuf;
24use std::process::Stdio;
25use std::time::Duration;
26
27use async_trait::async_trait;
28use tokio::io::AsyncWriteExt;
29
30use crate::plugin_hosts::{HostPluginEntry, HostPluginTool};
31use crate::tools::{Tool, ToolResult, ToolSpec};
32
33const EXEC_TIMEOUT: Duration = Duration::from_secs(60);
35const MAX_OUTPUT: usize = 64 * 1024;
37
38pub struct HostPluginToolAdapter {
40 name: String,
41 description: String,
42 command: String,
43 args: Vec<String>,
44 cwd: PathBuf,
45}
46
47impl HostPluginToolAdapter {
48 pub fn build(entry: &HostPluginEntry, trusted: &[String]) -> Vec<Self> {
55 if entry.tools.is_empty() {
56 return Vec::new();
57 }
58 if !trusted.iter().any(|t| t == &entry.id) {
59 tracing::info!(
60 "[plugin-host] {} declares {} tool(s) but is not in \
61 plugin_layer.trusted_host_plugins — not loading them",
62 entry.id,
63 entry.tools.len()
64 );
65 return Vec::new();
66 }
67 let cwd = entry
68 .path
69 .parent()
70 .map(|p| p.to_path_buf())
71 .unwrap_or_else(|| PathBuf::from("."));
72
73 entry
74 .tools
75 .iter()
76 .map(|t| Self::from_declaration(t, &cwd))
77 .collect()
78 }
79
80 fn from_declaration(tool: &HostPluginTool, cwd: &std::path::Path) -> Self {
81 Self {
82 name: tool.name.clone(),
83 description: tool.description.clone(),
84 command: tool.command.clone(),
85 args: tool.args.clone(),
86 cwd: cwd.to_path_buf(),
87 }
88 }
89}
90
91#[async_trait]
92impl Tool for HostPluginToolAdapter {
93 fn name(&self) -> &str {
94 &self.name
95 }
96
97 fn spec(&self) -> ToolSpec {
98 ToolSpec {
99 name: self.name.clone(),
100 description: self.description.clone(),
101 parameters: serde_json::json!({
104 "type": "object",
105 "properties": {},
106 "additionalProperties": true
107 }),
108 }
109 }
110
111 async fn execute(&self, arguments: &str) -> anyhow::Result<ToolResult> {
112 let mut child = tokio::process::Command::new(&self.command)
113 .args(&self.args)
114 .current_dir(&self.cwd)
115 .stdin(Stdio::piped())
116 .stdout(Stdio::piped())
117 .stderr(Stdio::piped())
118 .spawn()
119 .map_err(|e| {
120 anyhow::anyhow!("host plugin tool '{}' failed to start: {e}", self.name)
121 })?;
122
123 if let Some(mut stdin) = child.stdin.take() {
124 let _ = stdin.write_all(arguments.as_bytes()).await;
125 let _ = stdin.shutdown().await;
126 }
127
128 let output = match tokio::time::timeout(EXEC_TIMEOUT, child.wait_with_output()).await {
129 Ok(out) => out?,
130 Err(_) => {
131 return Ok(ToolResult::error(format!(
132 "host plugin tool '{}' exceeded {}s and was abandoned",
133 self.name,
134 EXEC_TIMEOUT.as_secs()
135 )))
136 }
137 };
138
139 let mut text = String::from_utf8_lossy(&output.stdout).to_string();
140 if text.is_empty() {
141 text = String::from_utf8_lossy(&output.stderr).to_string();
142 }
143 if text.chars().count() > MAX_OUTPUT {
145 text = text.chars().take(MAX_OUTPUT).collect::<String>() + "\n… output truncated";
146 }
147
148 if output.status.success() {
149 Ok(ToolResult::success(text))
150 } else {
151 Ok(ToolResult::error(format!(
152 "host plugin tool '{}' exited with {}: {text}",
153 self.name, output.status
154 )))
155 }
156 }
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162 use crate::plugin_hosts::{HostPluginKind, HostPluginTool};
163
164 fn entry_with_tool(id: &str, command: &str, args: &[&str]) -> HostPluginEntry {
165 HostPluginEntry {
166 id: id.to_string(),
167 kind: HostPluginKind::Hermes,
168 path: std::env::temp_dir().join("plugin.json"),
169 name: Some("t".into()),
170 description: None,
171 tools: vec![HostPluginTool {
172 name: "echoer".into(),
173 description: "echoes".into(),
174 command: command.into(),
175 args: args.iter().map(|s| s.to_string()).collect(),
176 }],
177 }
178 }
179
180 #[test]
181 fn discovery_alone_grants_nothing() {
182 let entry = entry_with_tool("hermes:evil", "/bin/sh", &["-c", "echo pwned"]);
183 assert!(HostPluginToolAdapter::build(&entry, &[]).is_empty());
185 assert!(HostPluginToolAdapter::build(&entry, &["hermes:other".into()]).is_empty());
187 }
188
189 #[test]
190 fn a_named_plugin_is_built() {
191 let entry = entry_with_tool("hermes:good", "/bin/echo", &[]);
192 let tools = HostPluginToolAdapter::build(&entry, &["hermes:good".into()]);
193 assert_eq!(tools.len(), 1);
194 assert_eq!(tools[0].name(), "echoer");
195 }
196
197 #[tokio::test]
198 async fn arguments_go_to_stdin_not_argv() {
199 let entry = entry_with_tool("hermes:cat", "cat", &[]);
202 let tools = HostPluginToolAdapter::build(&entry, &["hermes:cat".into()]);
203 let result = tools[0].execute(r#"{"k":"v"}"#).await.unwrap();
204 assert!(!result.is_error, "{}", result.output);
205 assert!(result.output.contains("\"k\":\"v\""), "{}", result.output);
206 }
207
208 #[tokio::test]
209 async fn a_hostile_argument_is_not_interpreted() {
210 let entry = entry_with_tool("hermes:cat", "cat", &[]);
213 let tools = HostPluginToolAdapter::build(&entry, &["hermes:cat".into()]);
214 let result = tools[0]
215 .execute("; touch /tmp/apollo-should-not-exist; echo")
216 .await
217 .unwrap();
218 assert!(!result.is_error);
219 assert!(
220 !std::path::Path::new("/tmp/apollo-should-not-exist").exists(),
221 "argument was interpreted by a shell"
222 );
223 }
224
225 #[tokio::test]
226 async fn a_failing_command_reports_rather_than_panics() {
227 let entry = entry_with_tool("hermes:missing", "/nonexistent/apollo-binary", &[]);
228 let tools = HostPluginToolAdapter::build(&entry, &["hermes:missing".into()]);
229 let err = tools[0].execute("{}").await.unwrap_err().to_string();
230 assert!(err.contains("failed to start"), "{err}");
231 }
232}