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
15pub 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 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, ¶ms)
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
178pub 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 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}