use std::path::PathBuf;
use std::process::Stdio;
use std::time::Duration;
use async_trait::async_trait;
use tokio::io::AsyncWriteExt;
use crate::plugin_hosts::{HostPluginEntry, HostPluginTool};
use crate::tools::{Tool, ToolResult, ToolSpec};
const EXEC_TIMEOUT: Duration = Duration::from_secs(60);
const MAX_OUTPUT: usize = 64 * 1024;
pub struct HostPluginToolAdapter {
name: String,
description: String,
command: String,
args: Vec<String>,
cwd: PathBuf,
}
impl HostPluginToolAdapter {
pub fn build(entry: &HostPluginEntry, trusted: &[String]) -> Vec<Self> {
if entry.tools.is_empty() {
return Vec::new();
}
if !trusted.iter().any(|t| t == &entry.id) {
tracing::info!(
"[plugin-host] {} declares {} tool(s) but is not in \
plugin_layer.trusted_host_plugins — not loading them",
entry.id,
entry.tools.len()
);
return Vec::new();
}
let cwd = entry
.path
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."));
entry
.tools
.iter()
.map(|t| Self::from_declaration(t, &cwd))
.collect()
}
fn from_declaration(tool: &HostPluginTool, cwd: &std::path::Path) -> Self {
Self {
name: tool.name.clone(),
description: tool.description.clone(),
command: tool.command.clone(),
args: tool.args.clone(),
cwd: cwd.to_path_buf(),
}
}
}
#[async_trait]
impl Tool for HostPluginToolAdapter {
fn name(&self) -> &str {
&self.name
}
fn spec(&self) -> ToolSpec {
ToolSpec {
name: self.name.clone(),
description: self.description.clone(),
parameters: serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": true
}),
}
}
async fn execute(&self, arguments: &str) -> anyhow::Result<ToolResult> {
let mut child = tokio::process::Command::new(&self.command)
.args(&self.args)
.current_dir(&self.cwd)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| {
anyhow::anyhow!("host plugin tool '{}' failed to start: {e}", self.name)
})?;
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(arguments.as_bytes()).await;
let _ = stdin.shutdown().await;
}
let output = match tokio::time::timeout(EXEC_TIMEOUT, child.wait_with_output()).await {
Ok(out) => out?,
Err(_) => {
return Ok(ToolResult::error(format!(
"host plugin tool '{}' exceeded {}s and was abandoned",
self.name,
EXEC_TIMEOUT.as_secs()
)))
}
};
let mut text = String::from_utf8_lossy(&output.stdout).to_string();
if text.is_empty() {
text = String::from_utf8_lossy(&output.stderr).to_string();
}
if text.chars().count() > MAX_OUTPUT {
text = text.chars().take(MAX_OUTPUT).collect::<String>() + "\n… output truncated";
}
if output.status.success() {
Ok(ToolResult::success(text))
} else {
Ok(ToolResult::error(format!(
"host plugin tool '{}' exited with {}: {text}",
self.name, output.status
)))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plugin_hosts::{HostPluginKind, HostPluginTool};
fn entry_with_tool(id: &str, command: &str, args: &[&str]) -> HostPluginEntry {
HostPluginEntry {
id: id.to_string(),
kind: HostPluginKind::Hermes,
path: std::env::temp_dir().join("plugin.json"),
name: Some("t".into()),
description: None,
tools: vec![HostPluginTool {
name: "echoer".into(),
description: "echoes".into(),
command: command.into(),
args: args.iter().map(|s| s.to_string()).collect(),
}],
}
}
#[test]
fn discovery_alone_grants_nothing() {
let entry = entry_with_tool("hermes:evil", "/bin/sh", &["-c", "echo pwned"]);
assert!(HostPluginToolAdapter::build(&entry, &[]).is_empty());
assert!(HostPluginToolAdapter::build(&entry, &["hermes:other".into()]).is_empty());
}
#[test]
fn a_named_plugin_is_built() {
let entry = entry_with_tool("hermes:good", "/bin/echo", &[]);
let tools = HostPluginToolAdapter::build(&entry, &["hermes:good".into()]);
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].name(), "echoer");
}
#[tokio::test]
async fn arguments_go_to_stdin_not_argv() {
let entry = entry_with_tool("hermes:cat", "cat", &[]);
let tools = HostPluginToolAdapter::build(&entry, &["hermes:cat".into()]);
let result = tools[0].execute(r#"{"k":"v"}"#).await.unwrap();
assert!(!result.is_error, "{}", result.output);
assert!(result.output.contains("\"k\":\"v\""), "{}", result.output);
}
#[tokio::test]
async fn a_hostile_argument_is_not_interpreted() {
let entry = entry_with_tool("hermes:cat", "cat", &[]);
let tools = HostPluginToolAdapter::build(&entry, &["hermes:cat".into()]);
let result = tools[0]
.execute("; touch /tmp/apollo-should-not-exist; echo")
.await
.unwrap();
assert!(!result.is_error);
assert!(
!std::path::Path::new("/tmp/apollo-should-not-exist").exists(),
"argument was interpreted by a shell"
);
}
#[tokio::test]
async fn a_failing_command_reports_rather_than_panics() {
let entry = entry_with_tool("hermes:missing", "/nonexistent/apollo-binary", &[]);
let tools = HostPluginToolAdapter::build(&entry, &["hermes:missing".into()]);
let err = tools[0].execute("{}").await.unwrap_err().to_string();
assert!(err.contains("failed to start"), "{err}");
}
}