Skip to main content

roboticus_server/
plugins.rs

1use std::sync::Arc;
2
3use tracing::{debug, info, warn};
4
5use roboticus_core::config::PluginsConfig;
6use roboticus_plugin_sdk::loader::discover_plugins;
7use roboticus_plugin_sdk::registry::{PermissionPolicy, PluginRegistry};
8use roboticus_plugin_sdk::script::ScriptPlugin;
9
10/// Discover plugin manifests, instantiate `ScriptPlugin`s, and register them.
11pub async fn init_plugin_registry(config: &PluginsConfig) -> Arc<PluginRegistry> {
12    let registry = Arc::new(PluginRegistry::new(
13        config.allow.clone(),
14        config.deny.clone(),
15        PermissionPolicy {
16            strict: config.strict_permissions,
17            allowed: config.allowed_permissions.clone(),
18        },
19    ));
20
21    let plugins_dir = &config.dir;
22    if !plugins_dir.exists() {
23        debug!(dir = %plugins_dir.display(), "plugins directory does not exist, skipping discovery");
24        return registry;
25    }
26
27    let discovered = match discover_plugins(plugins_dir) {
28        Ok(d) => d,
29        Err(e) => {
30            warn!(error = %e, "failed to discover plugins");
31            return registry;
32        }
33    };
34
35    info!(count = discovered.len(), "discovered plugins");
36
37    for dp in discovered {
38        let name = dp.manifest.name.clone();
39        let version = dp.manifest.version.clone();
40        let tool_count = dp.manifest.tools.len();
41
42        // ── Vet plugin integrity before registration ─────────────
43        let report = dp.manifest.vet(&dp.dir);
44        for w in &report.warnings {
45            warn!(name = %name, warning = %w, "plugin vet warning");
46        }
47        if !report.is_ok() {
48            for e in &report.errors {
49                warn!(name = %name, error = %e, "plugin vet error");
50            }
51            warn!(
52                name = %name,
53                errors = report.errors.len(),
54                "skipping plugin due to vet errors"
55            );
56            continue;
57        }
58
59        let timeout_secs = dp.manifest.timeout_seconds;
60        let mut plugin = ScriptPlugin::new(dp.manifest, dp.dir);
61        if let Some(secs) = timeout_secs {
62            plugin = plugin.with_timeout(std::time::Duration::from_secs(secs));
63        }
64        match registry.register(Box::new(plugin)).await {
65            Ok(()) => {
66                info!(
67                    name = %name,
68                    version = %version,
69                    tools = tool_count,
70                    "registered script plugin"
71                );
72            }
73            Err(e) => {
74                warn!(name = %name, error = %e, "failed to register plugin");
75            }
76        }
77    }
78
79    let init_errors = registry.init_all().await;
80    if !init_errors.is_empty() {
81        for err in &init_errors {
82            warn!(error = %err, "plugin init error");
83        }
84    }
85
86    let count = registry.plugin_count().await;
87    info!(active = count, "plugin registry ready");
88
89    registry
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use std::fs;
96    use std::path::PathBuf;
97
98    #[tokio::test]
99    async fn init_with_nonexistent_dir() {
100        let config = PluginsConfig {
101            dir: PathBuf::from("/nonexistent/plugins"),
102            allow: vec![],
103            deny: vec![],
104            strict_permissions: false,
105            allowed_permissions: vec![],
106        };
107        let registry = init_plugin_registry(&config).await;
108        assert_eq!(registry.plugin_count().await, 0);
109    }
110
111    #[tokio::test]
112    async fn init_with_empty_dir() {
113        let dir = tempfile::tempdir().unwrap();
114        let config = PluginsConfig {
115            dir: dir.path().to_path_buf(),
116            allow: vec![],
117            deny: vec![],
118            strict_permissions: false,
119            allowed_permissions: vec![],
120        };
121        let registry = init_plugin_registry(&config).await;
122        assert_eq!(registry.plugin_count().await, 0);
123    }
124
125    #[tokio::test]
126    async fn init_discovers_and_registers_plugin() {
127        let dir = tempfile::tempdir().unwrap();
128        let plugin_dir = dir.path().join("hello-plugin");
129        fs::create_dir(&plugin_dir).unwrap();
130        fs::write(
131            plugin_dir.join("plugin.toml"),
132            r#"
133name = "hello-plugin"
134version = "0.1.0"
135description = "A test plugin"
136
137[[tools]]
138name = "say_hello"
139description = "Says hello"
140"#,
141        )
142        .unwrap();
143        fs::write(plugin_dir.join("say_hello.gosh"), "echo hello").unwrap();
144
145        let config = PluginsConfig {
146            dir: dir.path().to_path_buf(),
147            allow: vec![],
148            deny: vec![],
149            strict_permissions: false,
150            allowed_permissions: vec![],
151        };
152        let registry = init_plugin_registry(&config).await;
153        assert_eq!(registry.plugin_count().await, 1);
154
155        let plugins = registry.list_plugins().await;
156        assert_eq!(plugins[0].name, "hello-plugin");
157        assert_eq!(plugins[0].tools.len(), 1);
158    }
159
160    #[tokio::test]
161    async fn deny_list_blocks_plugin() {
162        let dir = tempfile::tempdir().unwrap();
163        let plugin_dir = dir.path().join("blocked");
164        fs::create_dir(&plugin_dir).unwrap();
165        fs::write(
166            plugin_dir.join("plugin.toml"),
167            "name = \"blocked\"\nversion = \"1.0.0\"\n",
168        )
169        .unwrap();
170
171        let config = PluginsConfig {
172            dir: dir.path().to_path_buf(),
173            allow: vec![],
174            deny: vec!["blocked".into()],
175            strict_permissions: false,
176            allowed_permissions: vec![],
177        };
178        let registry = init_plugin_registry(&config).await;
179        assert_eq!(registry.plugin_count().await, 0);
180    }
181
182    #[tokio::test]
183    async fn init_respects_timeout_seconds() {
184        let dir = tempfile::tempdir().unwrap();
185        let plugin_dir = dir.path().join("slow-plugin");
186        fs::create_dir(&plugin_dir).unwrap();
187        fs::write(
188            plugin_dir.join("plugin.toml"),
189            r#"
190name = "slow-plugin"
191version = "0.1.0"
192timeout_seconds = 300
193
194[[tools]]
195name = "slow_task"
196description = "A long-running task"
197"#,
198        )
199        .unwrap();
200        fs::write(plugin_dir.join("slow_task.sh"), "#!/bin/sh\necho done").unwrap();
201
202        let config = PluginsConfig {
203            dir: dir.path().to_path_buf(),
204            allow: vec![],
205            deny: vec![],
206            strict_permissions: false,
207            allowed_permissions: vec![],
208        };
209        let registry = init_plugin_registry(&config).await;
210        assert_eq!(registry.plugin_count().await, 1);
211        // Verify the plugin registered and its tool works
212        let result = registry
213            .execute_tool("slow_task", &serde_json::json!({}))
214            .await
215            .unwrap();
216        assert!(result.success);
217    }
218
219    #[tokio::test]
220    async fn plugin_tool_execution() {
221        let dir = tempfile::tempdir().unwrap();
222        let plugin_dir = dir.path().join("echo-plugin");
223        fs::create_dir(&plugin_dir).unwrap();
224        fs::write(
225            plugin_dir.join("plugin.toml"),
226            r#"
227name = "echo-plugin"
228version = "0.1.0"
229[[tools]]
230name = "echo"
231description = "Echoes input"
232"#,
233        )
234        .unwrap();
235        fs::write(
236            plugin_dir.join("echo.sh"),
237            "#!/bin/sh\necho $ROBOTICUS_INPUT",
238        )
239        .unwrap();
240
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).await;
249
250        let result = registry
251            .execute_tool("echo", &serde_json::json!({"msg": "hi"}))
252            .await
253            .unwrap();
254        assert!(result.success);
255        assert!(result.output.contains("msg"));
256    }
257}