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 .kill_on_drop(true)
119 .spawn()
120 .map_err(|e| {
121 anyhow::anyhow!("host plugin tool '{}' failed to start: {e}", self.name)
122 })?;
123
124 if let Some(mut stdin) = child.stdin.take() {
125 let _ = stdin.write_all(arguments.as_bytes()).await;
126 let _ = stdin.shutdown().await;
127 }
128
129 let output =
130 match crate::tools::child_proc::wait_with_timeout(&mut child, EXEC_TIMEOUT).await? {
131 Some(out) => out,
132 None => {
133 return Ok(ToolResult::error(format!(
134 "host plugin tool '{}' exceeded {}s and was killed",
135 self.name,
136 EXEC_TIMEOUT.as_secs()
137 )))
138 }
139 };
140
141 let mut text = String::from_utf8_lossy(&output.stdout).to_string();
142 if text.is_empty() {
143 text = String::from_utf8_lossy(&output.stderr).to_string();
144 }
145 if text.chars().count() > MAX_OUTPUT {
147 text = text.chars().take(MAX_OUTPUT).collect::<String>() + "\n… output truncated";
148 }
149
150 if output.status.success() {
151 Ok(ToolResult::success(text))
152 } else {
153 Ok(ToolResult::error(format!(
154 "host plugin tool '{}' exited with {}: {text}",
155 self.name, output.status
156 )))
157 }
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164 use crate::plugin_hosts::{HostPluginKind, HostPluginTool};
165
166 fn entry_with_tool(id: &str, command: &str, args: &[&str]) -> HostPluginEntry {
167 HostPluginEntry {
168 id: id.to_string(),
169 kind: HostPluginKind::Hermes,
170 path: std::env::temp_dir().join("plugin.json"),
171 name: Some("t".into()),
172 description: None,
173 tools: vec![HostPluginTool {
174 name: "echoer".into(),
175 description: "echoes".into(),
176 command: command.into(),
177 args: args.iter().map(|s| s.to_string()).collect(),
178 }],
179 }
180 }
181
182 #[test]
183 fn discovery_alone_grants_nothing() {
184 let entry = entry_with_tool("hermes:evil", "/bin/sh", &["-c", "echo pwned"]);
185 assert!(HostPluginToolAdapter::build(&entry, &[]).is_empty());
187 assert!(HostPluginToolAdapter::build(&entry, &["hermes:other".into()]).is_empty());
189 }
190
191 #[test]
192 fn a_named_plugin_is_built() {
193 let entry = entry_with_tool("hermes:good", "/bin/echo", &[]);
194 let tools = HostPluginToolAdapter::build(&entry, &["hermes:good".into()]);
195 assert_eq!(tools.len(), 1);
196 assert_eq!(tools[0].name(), "echoer");
197 }
198
199 #[tokio::test]
200 async fn arguments_go_to_stdin_not_argv() {
201 let entry = entry_with_tool("hermes:cat", "cat", &[]);
204 let tools = HostPluginToolAdapter::build(&entry, &["hermes:cat".into()]);
205 let result = tools[0].execute(r#"{"k":"v"}"#).await.unwrap();
206 assert!(!result.is_error, "{}", result.output);
207 assert!(result.output.contains("\"k\":\"v\""), "{}", result.output);
208 }
209
210 #[tokio::test]
211 async fn a_hostile_argument_is_not_interpreted() {
212 let entry = entry_with_tool("hermes:cat", "cat", &[]);
215 let tools = HostPluginToolAdapter::build(&entry, &["hermes:cat".into()]);
216 let result = tools[0]
217 .execute("; touch /tmp/apollo-should-not-exist; echo")
218 .await
219 .unwrap();
220 assert!(!result.is_error);
221 assert!(
222 !std::path::Path::new("/tmp/apollo-should-not-exist").exists(),
223 "argument was interpreted by a shell"
224 );
225 }
226
227 #[tokio::test]
228 async fn a_failing_command_reports_rather_than_panics() {
229 let entry = entry_with_tool("hermes:missing", "/nonexistent/apollo-binary", &[]);
230 let tools = HostPluginToolAdapter::build(&entry, &["hermes:missing".into()]);
231 let err = tools[0].execute("{}").await.unwrap_err().to_string();
232 assert!(err.contains("failed to start"), "{err}");
233 }
234}