Skip to main content

roboticus_server/
plugins.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use async_trait::async_trait;
5use tracing::{debug, info, warn};
6
7use roboticus_agent::tools::{
8    Tool, ToolContext, ToolError as AgentToolError, ToolRegistry, ToolResult as AgentToolResult,
9};
10use roboticus_core::config::PluginsConfig;
11use roboticus_plugin_sdk::loader::discover_plugins;
12use roboticus_plugin_sdk::registry::{PermissionPolicy, PluginRegistry};
13use roboticus_plugin_sdk::script::ScriptPlugin;
14
15/// Discover plugin manifests, instantiate `ScriptPlugin`s, and register them.
16///
17/// `plugin_env` is injected into every plugin script invocation. Callers should
18/// populate it with workspace context (`ROBOTICUS_WORKSPACE`,
19/// `ROBOTICUS_DELEGATION_DEPTH`, etc.) so plugins can discover allowed paths.
20pub async fn init_plugin_registry(
21    config: &PluginsConfig,
22    plugin_env: HashMap<String, String>,
23) -> Arc<PluginRegistry> {
24    let registry = Arc::new(PluginRegistry::new(
25        config.allow.clone(),
26        config.deny.clone(),
27        PermissionPolicy {
28            strict: config.strict_permissions,
29            allowed: config.allowed_permissions.clone(),
30        },
31    ));
32
33    let plugins_dir = &config.dir;
34    if !plugins_dir.exists() {
35        debug!(dir = %plugins_dir.display(), "plugins directory does not exist, skipping discovery");
36        return registry;
37    }
38
39    let discovered = match discover_plugins(plugins_dir) {
40        Ok(d) => d,
41        Err(e) => {
42            warn!(error = %e, "failed to discover plugins");
43            return registry;
44        }
45    };
46
47    info!(count = discovered.len(), "discovered plugins");
48
49    for dp in discovered {
50        let name = dp.manifest.name.clone();
51        let version = dp.manifest.version.clone();
52        let tool_count = dp.manifest.tools.len();
53
54        // ── Vet plugin integrity before registration ─────────────
55        let report = dp.manifest.vet(&dp.dir);
56        for w in &report.warnings {
57            warn!(name = %name, warning = %w, "plugin vet warning");
58        }
59        if !report.is_ok() {
60            for e in &report.errors {
61                warn!(name = %name, error = %e, "plugin vet error");
62            }
63            warn!(
64                name = %name,
65                errors = report.errors.len(),
66                "skipping plugin due to vet errors"
67            );
68            continue;
69        }
70
71        let timeout_secs = dp.manifest.timeout_seconds;
72        let mut plugin = ScriptPlugin::new(dp.manifest, dp.dir).with_env(plugin_env.clone());
73        if let Some(secs) = timeout_secs {
74            plugin = plugin.with_timeout(std::time::Duration::from_secs(secs));
75        }
76        match registry.register(Box::new(plugin)).await {
77            Ok(()) => {
78                info!(
79                    name = %name,
80                    version = %version,
81                    tools = tool_count,
82                    "registered script plugin"
83                );
84            }
85            Err(e) => {
86                warn!(name = %name, error = %e, "failed to register plugin");
87            }
88        }
89    }
90
91    let init_errors = registry.init_all().await;
92    if !init_errors.is_empty() {
93        for err in &init_errors {
94            warn!(error = %err, "plugin init error");
95        }
96    }
97
98    let count = registry.plugin_count().await;
99    info!(active = count, "plugin registry ready");
100
101    registry
102}
103
104struct PluginTool {
105    plugin_name: String,
106    definition: roboticus_plugin_sdk::ToolDef,
107    registry: Arc<PluginRegistry>,
108}
109
110impl PluginTool {
111    fn new(
112        plugin_name: String,
113        definition: roboticus_plugin_sdk::ToolDef,
114        registry: Arc<PluginRegistry>,
115    ) -> Self {
116        Self {
117            plugin_name,
118            definition,
119            registry,
120        }
121    }
122}
123
124#[async_trait]
125impl Tool for PluginTool {
126    fn name(&self) -> &str {
127        &self.definition.name
128    }
129
130    fn description(&self) -> &str {
131        &self.definition.description
132    }
133
134    fn risk_level(&self) -> roboticus_core::RiskLevel {
135        self.definition.risk_level
136    }
137
138    fn parameters_schema(&self) -> serde_json::Value {
139        self.definition.parameters.clone()
140    }
141
142    fn paired_skill(&self) -> Option<&str> {
143        self.definition.paired_skill.as_deref()
144    }
145
146    fn plugin_owner(&self) -> Option<&str> {
147        Some(&self.plugin_name)
148    }
149
150    async fn execute(
151        &self,
152        params: serde_json::Value,
153        _ctx: &ToolContext,
154    ) -> std::result::Result<AgentToolResult, AgentToolError> {
155        match self
156            .registry
157            .execute_plugin_tool(&self.plugin_name, &self.definition.name, &params)
158            .await
159        {
160            Ok(result) if result.success => Ok(AgentToolResult {
161                output: result.output,
162                metadata: result.metadata,
163            }),
164            Ok(result) => Err(AgentToolError {
165                message: if result.output.trim().is_empty() {
166                    format!("plugin tool '{}' failed", self.definition.name)
167                } else {
168                    result.output
169                },
170            }),
171            Err(e) => Err(AgentToolError {
172                message: format!("plugin tool '{}' failed: {e}", self.definition.name),
173            }),
174        }
175    }
176}
177
178/// Register all active plugin tools into the runtime ToolRegistry.
179///
180/// This bridges plugin-discovered tools into the same callable registry used by
181/// the ReAct loop and tool schema export path. Duplicate tool names are skipped
182/// so built-in tools retain precedence.
183pub async fn register_plugin_tools(
184    tool_registry: &mut ToolRegistry,
185    plugin_registry: Arc<PluginRegistry>,
186) {
187    let plugin_tools = plugin_registry.list_all_tools().await;
188    let discovered = plugin_tools.len();
189    let mut registered = 0usize;
190    let mut skipped = 0usize;
191
192    for (plugin_name, tool_def) in plugin_tools {
193        let tool_name = tool_def.name.clone();
194        if tool_registry.get(&tool_name).is_some() {
195            skipped += 1;
196            warn!(
197                plugin = %plugin_name,
198                tool = %tool_name,
199                "skipping plugin tool because tool name already exists"
200            );
201            continue;
202        }
203        tool_registry.register(Box::new(PluginTool::new(
204            plugin_name,
205            tool_def,
206            Arc::clone(&plugin_registry),
207        )));
208        registered += 1;
209    }
210
211    info!(
212        discovered,
213        registered, skipped, "plugin tool bridge registration complete"
214    );
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use roboticus_agent::tools::ToolContext;
221    use roboticus_core::InputAuthority;
222    use std::fs;
223    use std::path::PathBuf;
224
225    #[tokio::test]
226    async fn init_with_nonexistent_dir() {
227        let config = PluginsConfig {
228            dir: PathBuf::from("/nonexistent/plugins"),
229            allow: vec![],
230            deny: vec![],
231            strict_permissions: false,
232            allowed_permissions: vec![],
233        };
234        let registry = init_plugin_registry(&config, HashMap::new()).await;
235        assert_eq!(registry.plugin_count().await, 0);
236    }
237
238    #[tokio::test]
239    async fn init_with_empty_dir() {
240        let dir = tempfile::tempdir().unwrap();
241        let config = PluginsConfig {
242            dir: dir.path().to_path_buf(),
243            allow: vec![],
244            deny: vec![],
245            strict_permissions: false,
246            allowed_permissions: vec![],
247        };
248        let registry = init_plugin_registry(&config, HashMap::new()).await;
249        assert_eq!(registry.plugin_count().await, 0);
250    }
251
252    #[tokio::test]
253    async fn init_discovers_and_registers_plugin() {
254        let dir = tempfile::tempdir().unwrap();
255        let plugin_dir = dir.path().join("hello-plugin");
256        fs::create_dir(&plugin_dir).unwrap();
257        fs::write(
258            plugin_dir.join("plugin.toml"),
259            r#"
260name = "hello-plugin"
261version = "0.1.0"
262description = "A test plugin"
263
264[[tools]]
265name = "say_hello"
266description = "Says hello"
267"#,
268        )
269        .unwrap();
270        fs::write(plugin_dir.join("say_hello.gosh"), "echo hello").unwrap();
271
272        let config = PluginsConfig {
273            dir: dir.path().to_path_buf(),
274            allow: vec![],
275            deny: vec![],
276            strict_permissions: false,
277            allowed_permissions: vec![],
278        };
279        let registry = init_plugin_registry(&config, HashMap::new()).await;
280        assert_eq!(registry.plugin_count().await, 1);
281
282        let plugins = registry.list_plugins().await;
283        assert_eq!(plugins[0].name, "hello-plugin");
284        assert_eq!(plugins[0].tools.len(), 1);
285    }
286
287    #[tokio::test]
288    async fn deny_list_blocks_plugin() {
289        let dir = tempfile::tempdir().unwrap();
290        let plugin_dir = dir.path().join("blocked");
291        fs::create_dir(&plugin_dir).unwrap();
292        fs::write(
293            plugin_dir.join("plugin.toml"),
294            "name = \"blocked\"\nversion = \"1.0.0\"\n",
295        )
296        .unwrap();
297
298        let config = PluginsConfig {
299            dir: dir.path().to_path_buf(),
300            allow: vec![],
301            deny: vec!["blocked".into()],
302            strict_permissions: false,
303            allowed_permissions: vec![],
304        };
305        let registry = init_plugin_registry(&config, HashMap::new()).await;
306        assert_eq!(registry.plugin_count().await, 0);
307    }
308
309    #[tokio::test]
310    async fn init_respects_timeout_seconds() {
311        let dir = tempfile::tempdir().unwrap();
312        let plugin_dir = dir.path().join("slow-plugin");
313        fs::create_dir(&plugin_dir).unwrap();
314        fs::write(
315            plugin_dir.join("plugin.toml"),
316            r#"
317name = "slow-plugin"
318version = "0.1.0"
319timeout_seconds = 300
320
321[[tools]]
322name = "slow_task"
323description = "A long-running task"
324"#,
325        )
326        .unwrap();
327        fs::write(plugin_dir.join("slow_task.sh"), "#!/bin/sh\necho done").unwrap();
328
329        let config = PluginsConfig {
330            dir: dir.path().to_path_buf(),
331            allow: vec![],
332            deny: vec![],
333            strict_permissions: false,
334            allowed_permissions: vec![],
335        };
336        let registry = init_plugin_registry(&config, HashMap::new()).await;
337        assert_eq!(registry.plugin_count().await, 1);
338        // Verify the plugin registered and its tool works
339        let result = registry
340            .execute_tool("slow_task", &serde_json::json!({}))
341            .await
342            .unwrap();
343        assert!(result.success);
344    }
345
346    #[tokio::test]
347    async fn plugin_tool_execution() {
348        let dir = tempfile::tempdir().unwrap();
349        let plugin_dir = dir.path().join("echo-plugin");
350        fs::create_dir(&plugin_dir).unwrap();
351        fs::write(
352            plugin_dir.join("plugin.toml"),
353            r#"
354name = "echo-plugin"
355version = "0.1.0"
356[[tools]]
357name = "echo"
358description = "Echoes input"
359"#,
360        )
361        .unwrap();
362        fs::write(
363            plugin_dir.join("echo.sh"),
364            "#!/bin/sh\necho $ROBOTICUS_INPUT",
365        )
366        .unwrap();
367
368        let config = PluginsConfig {
369            dir: dir.path().to_path_buf(),
370            allow: vec![],
371            deny: vec![],
372            strict_permissions: false,
373            allowed_permissions: vec![],
374        };
375        let registry = init_plugin_registry(&config, HashMap::new()).await;
376
377        let result = registry
378            .execute_tool("echo", &serde_json::json!({"msg": "hi"}))
379            .await
380            .unwrap();
381        assert!(result.success);
382        assert!(result.output.contains("msg"));
383    }
384
385    #[tokio::test]
386    async fn register_plugin_tools_bridges_active_plugin_tool_into_runtime_registry() {
387        let dir = tempfile::tempdir().unwrap();
388        let plugin_dir = dir.path().join("bridge-plugin");
389        fs::create_dir(&plugin_dir).unwrap();
390        fs::write(
391            plugin_dir.join("plugin.toml"),
392            r#"
393name = "bridge-plugin"
394version = "0.1.0"
395[[tools]]
396name = "bridge_echo"
397description = "Echoes ROBOTICUS_INPUT from plugin bridge"
398"#,
399        )
400        .unwrap();
401        fs::write(
402            plugin_dir.join("bridge_echo.sh"),
403            "#!/bin/sh\necho $ROBOTICUS_INPUT",
404        )
405        .unwrap();
406
407        let config = PluginsConfig {
408            dir: dir.path().to_path_buf(),
409            allow: vec![],
410            deny: vec![],
411            strict_permissions: false,
412            allowed_permissions: vec![],
413        };
414        let plugin_registry = init_plugin_registry(&config, HashMap::new()).await;
415        let mut runtime_tools = ToolRegistry::new();
416
417        register_plugin_tools(&mut runtime_tools, Arc::clone(&plugin_registry)).await;
418
419        let bridged = runtime_tools.get("bridge_echo");
420        assert!(
421            bridged.is_some(),
422            "plugin tool should be bridged into registry"
423        );
424        let ctx = ToolContext {
425            session_id: "test-session".to_string(),
426            agent_id: "test-agent".to_string(),
427            agent_name: "Test Agent".to_string(),
428            authority: InputAuthority::Creator,
429            workspace_root: dir.path().to_path_buf(),
430            tool_allowed_paths: vec![],
431            channel: None,
432            db: None,
433            sandbox: roboticus_agent::tools::ToolSandboxSnapshot::default(),
434        };
435        let result = bridged
436            .unwrap()
437            .execute(serde_json::json!({"hello":"world"}), &ctx)
438            .await
439            .expect("bridged plugin tool should execute");
440        assert!(result.output.contains("hello"));
441        assert!(result.output.contains("world"));
442    }
443}