use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use tracing::{debug, info, warn};
use roboticus_agent::tools::{
Tool, ToolContext, ToolError as AgentToolError, ToolRegistry, ToolResult as AgentToolResult,
};
use roboticus_core::config::PluginsConfig;
use roboticus_plugin_sdk::loader::discover_plugins;
use roboticus_plugin_sdk::registry::{PermissionPolicy, PluginRegistry};
use roboticus_plugin_sdk::script::ScriptPlugin;
pub async fn init_plugin_registry(
config: &PluginsConfig,
plugin_env: HashMap<String, String>,
) -> Arc<PluginRegistry> {
let registry = Arc::new(PluginRegistry::new(
config.allow.clone(),
config.deny.clone(),
PermissionPolicy {
strict: config.strict_permissions,
allowed: config.allowed_permissions.clone(),
},
));
let plugins_dir = &config.dir;
if !plugins_dir.exists() {
debug!(dir = %plugins_dir.display(), "plugins directory does not exist, skipping discovery");
return registry;
}
let discovered = match discover_plugins(plugins_dir) {
Ok(d) => d,
Err(e) => {
warn!(error = %e, "failed to discover plugins");
return registry;
}
};
info!(count = discovered.len(), "discovered plugins");
for dp in discovered {
let name = dp.manifest.name.clone();
let version = dp.manifest.version.clone();
let tool_count = dp.manifest.tools.len();
let report = dp.manifest.vet(&dp.dir);
for w in &report.warnings {
warn!(name = %name, warning = %w, "plugin vet warning");
}
if !report.is_ok() {
for e in &report.errors {
warn!(name = %name, error = %e, "plugin vet error");
}
warn!(
name = %name,
errors = report.errors.len(),
"skipping plugin due to vet errors"
);
continue;
}
let timeout_secs = dp.manifest.timeout_seconds;
let mut plugin = ScriptPlugin::new(dp.manifest, dp.dir).with_env(plugin_env.clone());
if let Some(secs) = timeout_secs {
plugin = plugin.with_timeout(std::time::Duration::from_secs(secs));
}
match registry.register(Box::new(plugin)).await {
Ok(()) => {
info!(
name = %name,
version = %version,
tools = tool_count,
"registered script plugin"
);
}
Err(e) => {
warn!(name = %name, error = %e, "failed to register plugin");
}
}
}
let init_errors = registry.init_all().await;
if !init_errors.is_empty() {
for err in &init_errors {
warn!(error = %err, "plugin init error");
}
}
let count = registry.plugin_count().await;
info!(active = count, "plugin registry ready");
registry
}
struct PluginTool {
plugin_name: String,
definition: roboticus_plugin_sdk::ToolDef,
registry: Arc<PluginRegistry>,
}
impl PluginTool {
fn new(
plugin_name: String,
definition: roboticus_plugin_sdk::ToolDef,
registry: Arc<PluginRegistry>,
) -> Self {
Self {
plugin_name,
definition,
registry,
}
}
}
#[async_trait]
impl Tool for PluginTool {
fn name(&self) -> &str {
&self.definition.name
}
fn description(&self) -> &str {
&self.definition.description
}
fn risk_level(&self) -> roboticus_core::RiskLevel {
self.definition.risk_level
}
fn parameters_schema(&self) -> serde_json::Value {
self.definition.parameters.clone()
}
fn paired_skill(&self) -> Option<&str> {
self.definition.paired_skill.as_deref()
}
fn plugin_owner(&self) -> Option<&str> {
Some(&self.plugin_name)
}
async fn execute(
&self,
params: serde_json::Value,
_ctx: &ToolContext,
) -> std::result::Result<AgentToolResult, AgentToolError> {
match self
.registry
.execute_plugin_tool(&self.plugin_name, &self.definition.name, ¶ms)
.await
{
Ok(result) if result.success => Ok(AgentToolResult {
output: result.output,
metadata: result.metadata,
}),
Ok(result) => Err(AgentToolError {
message: if result.output.trim().is_empty() {
format!("plugin tool '{}' failed", self.definition.name)
} else {
result.output
},
}),
Err(e) => Err(AgentToolError {
message: format!("plugin tool '{}' failed: {e}", self.definition.name),
}),
}
}
}
pub async fn register_plugin_tools(
tool_registry: &mut ToolRegistry,
plugin_registry: Arc<PluginRegistry>,
) {
let plugin_tools = plugin_registry.list_all_tools().await;
let discovered = plugin_tools.len();
let mut registered = 0usize;
let mut skipped = 0usize;
for (plugin_name, tool_def) in plugin_tools {
let tool_name = tool_def.name.clone();
if tool_registry.get(&tool_name).is_some() {
skipped += 1;
warn!(
plugin = %plugin_name,
tool = %tool_name,
"skipping plugin tool because tool name already exists"
);
continue;
}
tool_registry.register(Box::new(PluginTool::new(
plugin_name,
tool_def,
Arc::clone(&plugin_registry),
)));
registered += 1;
}
info!(
discovered,
registered, skipped, "plugin tool bridge registration complete"
);
}
#[cfg(test)]
mod tests {
use super::*;
use roboticus_agent::tools::ToolContext;
use roboticus_core::InputAuthority;
use std::fs;
use std::path::PathBuf;
#[tokio::test]
async fn init_with_nonexistent_dir() {
let config = PluginsConfig {
dir: PathBuf::from("/nonexistent/plugins"),
allow: vec![],
deny: vec![],
strict_permissions: false,
allowed_permissions: vec![],
};
let registry = init_plugin_registry(&config, HashMap::new()).await;
assert_eq!(registry.plugin_count().await, 0);
}
#[tokio::test]
async fn init_with_empty_dir() {
let dir = tempfile::tempdir().unwrap();
let config = PluginsConfig {
dir: dir.path().to_path_buf(),
allow: vec![],
deny: vec![],
strict_permissions: false,
allowed_permissions: vec![],
};
let registry = init_plugin_registry(&config, HashMap::new()).await;
assert_eq!(registry.plugin_count().await, 0);
}
#[tokio::test]
async fn init_discovers_and_registers_plugin() {
let dir = tempfile::tempdir().unwrap();
let plugin_dir = dir.path().join("hello-plugin");
fs::create_dir(&plugin_dir).unwrap();
fs::write(
plugin_dir.join("plugin.toml"),
r#"
name = "hello-plugin"
version = "0.1.0"
description = "A test plugin"
[[tools]]
name = "say_hello"
description = "Says hello"
"#,
)
.unwrap();
fs::write(plugin_dir.join("say_hello.gosh"), "echo hello").unwrap();
let config = PluginsConfig {
dir: dir.path().to_path_buf(),
allow: vec![],
deny: vec![],
strict_permissions: false,
allowed_permissions: vec![],
};
let registry = init_plugin_registry(&config, HashMap::new()).await;
assert_eq!(registry.plugin_count().await, 1);
let plugins = registry.list_plugins().await;
assert_eq!(plugins[0].name, "hello-plugin");
assert_eq!(plugins[0].tools.len(), 1);
}
#[tokio::test]
async fn deny_list_blocks_plugin() {
let dir = tempfile::tempdir().unwrap();
let plugin_dir = dir.path().join("blocked");
fs::create_dir(&plugin_dir).unwrap();
fs::write(
plugin_dir.join("plugin.toml"),
"name = \"blocked\"\nversion = \"1.0.0\"\n",
)
.unwrap();
let config = PluginsConfig {
dir: dir.path().to_path_buf(),
allow: vec![],
deny: vec!["blocked".into()],
strict_permissions: false,
allowed_permissions: vec![],
};
let registry = init_plugin_registry(&config, HashMap::new()).await;
assert_eq!(registry.plugin_count().await, 0);
}
#[tokio::test]
async fn init_respects_timeout_seconds() {
let dir = tempfile::tempdir().unwrap();
let plugin_dir = dir.path().join("slow-plugin");
fs::create_dir(&plugin_dir).unwrap();
fs::write(
plugin_dir.join("plugin.toml"),
r#"
name = "slow-plugin"
version = "0.1.0"
timeout_seconds = 300
[[tools]]
name = "slow_task"
description = "A long-running task"
"#,
)
.unwrap();
fs::write(plugin_dir.join("slow_task.sh"), "#!/bin/sh\necho done").unwrap();
let config = PluginsConfig {
dir: dir.path().to_path_buf(),
allow: vec![],
deny: vec![],
strict_permissions: false,
allowed_permissions: vec![],
};
let registry = init_plugin_registry(&config, HashMap::new()).await;
assert_eq!(registry.plugin_count().await, 1);
let result = registry
.execute_tool("slow_task", &serde_json::json!({}))
.await
.unwrap();
assert!(result.success);
}
#[tokio::test]
async fn plugin_tool_execution() {
let dir = tempfile::tempdir().unwrap();
let plugin_dir = dir.path().join("echo-plugin");
fs::create_dir(&plugin_dir).unwrap();
fs::write(
plugin_dir.join("plugin.toml"),
r#"
name = "echo-plugin"
version = "0.1.0"
[[tools]]
name = "echo"
description = "Echoes input"
"#,
)
.unwrap();
fs::write(
plugin_dir.join("echo.sh"),
"#!/bin/sh\necho $ROBOTICUS_INPUT",
)
.unwrap();
let config = PluginsConfig {
dir: dir.path().to_path_buf(),
allow: vec![],
deny: vec![],
strict_permissions: false,
allowed_permissions: vec![],
};
let registry = init_plugin_registry(&config, HashMap::new()).await;
let result = registry
.execute_tool("echo", &serde_json::json!({"msg": "hi"}))
.await
.unwrap();
assert!(result.success);
assert!(result.output.contains("msg"));
}
#[tokio::test]
async fn register_plugin_tools_bridges_active_plugin_tool_into_runtime_registry() {
let dir = tempfile::tempdir().unwrap();
let plugin_dir = dir.path().join("bridge-plugin");
fs::create_dir(&plugin_dir).unwrap();
fs::write(
plugin_dir.join("plugin.toml"),
r#"
name = "bridge-plugin"
version = "0.1.0"
[[tools]]
name = "bridge_echo"
description = "Echoes ROBOTICUS_INPUT from plugin bridge"
"#,
)
.unwrap();
fs::write(
plugin_dir.join("bridge_echo.sh"),
"#!/bin/sh\necho $ROBOTICUS_INPUT",
)
.unwrap();
let config = PluginsConfig {
dir: dir.path().to_path_buf(),
allow: vec![],
deny: vec![],
strict_permissions: false,
allowed_permissions: vec![],
};
let plugin_registry = init_plugin_registry(&config, HashMap::new()).await;
let mut runtime_tools = ToolRegistry::new();
register_plugin_tools(&mut runtime_tools, Arc::clone(&plugin_registry)).await;
let bridged = runtime_tools.get("bridge_echo");
assert!(
bridged.is_some(),
"plugin tool should be bridged into registry"
);
let ctx = ToolContext {
session_id: "test-session".to_string(),
agent_id: "test-agent".to_string(),
agent_name: "Test Agent".to_string(),
authority: InputAuthority::Creator,
workspace_root: dir.path().to_path_buf(),
tool_allowed_paths: vec![],
channel: None,
db: None,
sandbox: roboticus_agent::tools::ToolSandboxSnapshot::default(),
};
let result = bridged
.unwrap()
.execute(serde_json::json!({"hello":"world"}), &ctx)
.await
.expect("bridged plugin tool should execute");
assert!(result.output.contains("hello"));
assert!(result.output.contains("world"));
}
}