Skip to main content

roboticus_plugin_sdk/
script.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::time::Duration;
4
5use async_trait::async_trait;
6use serde_json::{Value, json};
7use tokio::io::AsyncReadExt;
8use tracing::{debug, warn};
9
10use roboticus_core::{Result, RoboticusError, input_capability_scan};
11
12use crate::manifest::PluginManifest;
13use crate::{Plugin, ToolDef, ToolResult};
14
15const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
16/// Maximum bytes to read from script stdout/stderr (10 MB).
17const MAX_SCRIPT_OUTPUT: u64 = 10 * 1024 * 1024;
18const SCRIPT_EXTENSIONS: &[&str] = &[
19    "gosh", "go", "sh", "py", "rb", "js",
20    // Empty string matches extensionless files (e.g., `tool_name` without `.sh`).
21    // This is checked last so that recognized extensions take priority. Extensionless
22    // files are only accepted if they begin with a recognized shebang line; see
23    // `validate_shebang()`. Without the shebang check an attacker could place an
24    // arbitrary binary in the plugin directory and have it executed.
25    "",
26];
27
28/// A concrete `Plugin` implementation that executes external scripts.
29///
30/// Each tool declared in the plugin's `plugin.toml` maps to a script file
31/// in the plugin directory. The script receives input as the `ROBOTICUS_INPUT`
32/// environment variable (JSON) and should write its output to stdout.
33pub struct ScriptPlugin {
34    manifest: PluginManifest,
35    dir: PathBuf,
36    scripts: HashMap<String, PathBuf>,
37    timeout: Duration,
38    /// Extra environment variables injected into every script invocation.
39    /// Used to propagate workspace context, delegation depth, etc.
40    env_extra: HashMap<String, String>,
41}
42
43/// Default JSON Schema for script-backed plugin tools.
44///
45/// Includes a required `prompt` so shortcuts and structured callers always send a non-empty
46/// [`ROBOTICUS_INPUT`](ScriptPlugin) payload; scripts that ignore `prompt` are unaffected.
47fn default_script_tool_parameters_schema() -> serde_json::Value {
48    json!({
49        "type": "object",
50        "properties": {
51            "prompt": {
52                "type": "string",
53                "description": "Primary task or instruction (required for most script plugins; passed as ROBOTICUS_INPUT)."
54            },
55            "working_dir": {
56                "type": "string",
57                "description": "Working directory for the tool (optional)."
58            },
59            "task": { "type": "string", "description": "Alternate instruction field; some scripts read `task` instead of `prompt`." },
60            "max_turns": { "type": "integer", "description": "Max agentic turns (e.g. Claude Code headless)." },
61            "max_budget_usd": { "type": "number", "description": "Cost cap in USD." },
62            "session_id": { "type": "string", "description": "Resume a prior session ID." },
63            "continue_last": { "type": "boolean", "description": "Continue the most recent session." },
64            "allowed_tools": { "type": "string", "description": "Override allowed-tools allowlist for Claude Code." }
65        },
66        "required": ["prompt"]
67    })
68}
69
70fn resolve_manifest_tool_parameters(tool: &crate::manifest::ManifestToolDef) -> serde_json::Value {
71    if let Some(raw) = tool.parameters_schema.as_deref() {
72        match serde_json::from_str::<serde_json::Value>(raw.trim()) {
73            Ok(v) if v.is_object() => v,
74            Ok(_) => {
75                warn!(
76                    tool = %tool.name,
77                    "parameters_schema must be a JSON object; using default script tool schema"
78                );
79                default_script_tool_parameters_schema()
80            }
81            Err(e) => {
82                warn!(
83                    tool = %tool.name,
84                    error = %e,
85                    "invalid parameters_schema JSON; using default script tool schema"
86                );
87                default_script_tool_parameters_schema()
88            }
89        }
90    } else {
91        default_script_tool_parameters_schema()
92    }
93}
94
95impl ScriptPlugin {
96    pub fn new(manifest: PluginManifest, dir: PathBuf) -> Self {
97        let scripts = Self::discover_scripts(&manifest, &dir);
98        Self {
99            manifest,
100            dir,
101            scripts,
102            timeout: DEFAULT_TIMEOUT,
103            env_extra: HashMap::new(),
104        }
105    }
106
107    pub fn with_timeout(mut self, timeout: Duration) -> Self {
108        self.timeout = timeout;
109        self
110    }
111
112    /// Inject additional environment variables into every script invocation.
113    pub fn with_env(mut self, env: HashMap<String, String>) -> Self {
114        self.env_extra = env;
115        self
116    }
117
118    fn discover_scripts(manifest: &PluginManifest, dir: &Path) -> HashMap<String, PathBuf> {
119        let mut scripts = HashMap::new();
120        for tool in &manifest.tools {
121            if let Some(path) = Self::find_script(dir, &tool.name) {
122                debug!(tool = %tool.name, script = %path.display(), "mapped tool to script");
123                scripts.insert(tool.name.clone(), path);
124            } else {
125                warn!(tool = %tool.name, dir = %dir.display(), "no script found for tool");
126            }
127        }
128        scripts
129    }
130
131    fn find_script(dir: &Path, tool_name: &str) -> Option<PathBuf> {
132        for ext in SCRIPT_EXTENSIONS {
133            let filename = if ext.is_empty() {
134                tool_name.to_string()
135            } else {
136                format!("{tool_name}.{ext}")
137            };
138            let path = dir.join(&filename);
139            if path.exists() && path.is_file() {
140                if let Err(e) = Self::validate_script_path(&path, dir) {
141                    warn!(tool = %tool_name, error = %e, "script path rejected");
142                    return None;
143                }
144                // Extensionless files must have a recognized shebang line so we
145                // don't accidentally execute an arbitrary binary.
146                if ext.is_empty() && !Self::has_recognized_shebang(&path) {
147                    warn!(
148                        tool = %tool_name,
149                        path = %path.display(),
150                        "extensionless script rejected: missing recognized shebang"
151                    );
152                    continue;
153                }
154                return Some(path);
155            }
156        }
157        None
158    }
159
160    /// Returns `true` if the file starts with a shebang (`#!`) whose interpreter
161    /// is one we recognize. This prevents extensionless arbitrary binaries from
162    /// being executed as plugin scripts.
163    fn has_recognized_shebang(path: &Path) -> bool {
164        const RECOGNIZED_INTERPRETERS: &[&str] = &[
165            "sh", "bash", "zsh", "python", "python3", "ruby", "node", "gosh", "go",
166        ];
167
168        let Ok(content) = std::fs::read_to_string(path) else {
169            return false;
170        };
171        let Some(first_line) = content.lines().next() else {
172            return false;
173        };
174        if !first_line.starts_with("#!") {
175            return false;
176        }
177        // Extract the interpreter name from e.g. "#!/usr/bin/env python3" or "#!/bin/sh"
178        let shebang = first_line.trim_start_matches("#!");
179        let last_token = shebang.split_whitespace().last().unwrap_or("");
180        let interpreter = last_token.rsplit('/').next().unwrap_or(last_token);
181        RECOGNIZED_INTERPRETERS.contains(&interpreter)
182    }
183
184    /// Ensures a resolved script path is contained within the plugin directory.
185    /// Prevents path traversal attacks via symlinks or `..` components.
186    fn validate_script_path(script: &Path, plugin_dir: &Path) -> Result<()> {
187        let canonical_script = script.canonicalize().map_err(|e| RoboticusError::Tool {
188            tool: script.display().to_string(),
189            message: format!("cannot resolve script path: {e}"),
190        })?;
191        let canonical_dir = plugin_dir
192            .canonicalize()
193            .map_err(|e| RoboticusError::Tool {
194                tool: plugin_dir.display().to_string(),
195                message: format!("cannot resolve plugin directory: {e}"),
196            })?;
197        if !canonical_script.starts_with(&canonical_dir) {
198            return Err(RoboticusError::Tool {
199                tool: script.display().to_string(),
200                message: "script path escapes plugin directory".into(),
201            });
202        }
203        Ok(())
204    }
205
206    fn interpreter_for(path: &Path) -> Option<(&'static str, &'static [&'static str])> {
207        #[cfg(windows)]
208        const PYTHON_BIN: &str = "python";
209        #[cfg(not(windows))]
210        const PYTHON_BIN: &str = "python3";
211
212        match path.extension().and_then(|e| e.to_str()) {
213            Some("gosh") => Some(("gosh", &[])),
214            Some("go") => Some(("go", &["run"])),
215            Some("py") => Some((PYTHON_BIN, &[])),
216            Some("rb") => Some(("ruby", &[])),
217            Some("js") => Some(("node", &[])),
218            Some("sh") => Some(("sh", &[])),
219            _ => None,
220        }
221    }
222
223    pub fn has_script(&self, tool_name: &str) -> bool {
224        self.scripts.contains_key(tool_name)
225    }
226
227    pub fn script_path(&self, tool_name: &str) -> Option<&Path> {
228        self.scripts.get(tool_name).map(|p| p.as_path())
229    }
230
231    pub fn script_count(&self) -> usize {
232        self.scripts.len()
233    }
234
235    pub fn is_tool_dangerous(&self, tool_name: &str) -> bool {
236        self.manifest.is_tool_dangerous(tool_name)
237    }
238
239    pub fn manifest(&self) -> &PluginManifest {
240        &self.manifest
241    }
242
243    fn permissions_for_tool(&self, tool_name: &str) -> Vec<String> {
244        self.manifest
245            .tools
246            .iter()
247            .find(|t| t.name == tool_name)
248            .map(|t| {
249                if t.permissions.is_empty() {
250                    self.manifest.permissions.clone()
251                } else {
252                    t.permissions.clone()
253                }
254            })
255            .unwrap_or_default()
256    }
257
258    fn enforce_runtime_permissions(&self, tool_name: &str, input: &Value) -> Result<()> {
259        let declared: Vec<String> = self
260            .permissions_for_tool(tool_name)
261            .into_iter()
262            .map(|p| p.to_ascii_lowercase())
263            .collect();
264        let scan = input_capability_scan::scan_input_capabilities(input);
265        if scan.requires_filesystem && !declared.iter().any(|p| p == "filesystem") {
266            return Err(RoboticusError::Tool {
267                tool: tool_name.into(),
268                message: "tool input requires filesystem capability but plugin/tool did not declare 'filesystem' permission".into(),
269            });
270        }
271        if scan.requires_network && !declared.iter().any(|p| p == "network") {
272            return Err(RoboticusError::Tool {
273                tool: tool_name.into(),
274                message: "tool input requires network capability but plugin/tool did not declare 'network' permission".into(),
275            });
276        }
277        Ok(())
278    }
279}
280
281#[async_trait]
282impl Plugin for ScriptPlugin {
283    fn name(&self) -> &str {
284        &self.manifest.name
285    }
286
287    fn version(&self) -> &str {
288        &self.manifest.version
289    }
290
291    fn tools(&self) -> Vec<ToolDef> {
292        self.manifest
293            .tools
294            .iter()
295            .map(|t| ToolDef {
296                name: t.name.clone(),
297                description: t.description.clone(),
298                parameters: resolve_manifest_tool_parameters(t),
299                risk_level: if t.dangerous {
300                    roboticus_core::RiskLevel::Dangerous
301                } else {
302                    roboticus_core::RiskLevel::Caution
303                },
304                permissions: if t.permissions.is_empty() {
305                    self.manifest.permissions.clone()
306                } else {
307                    t.permissions.clone()
308                },
309                paired_skill: t.paired_skill.clone(),
310            })
311            .collect()
312    }
313
314    async fn init(&mut self) -> Result<()> {
315        self.scripts = Self::discover_scripts(&self.manifest, &self.dir);
316        debug!(
317            plugin = self.manifest.name,
318            scripts = self.scripts.len(),
319            "ScriptPlugin initialized"
320        );
321        Ok(())
322    }
323
324    async fn execute_tool(&self, tool_name: &str, input: &Value) -> Result<ToolResult> {
325        self.enforce_runtime_permissions(tool_name, input)?;
326        let script_path = self
327            .scripts
328            .get(tool_name)
329            .ok_or_else(|| RoboticusError::Tool {
330                tool: tool_name.into(),
331                message: format!(
332                    "no script found for tool '{}' in {}",
333                    tool_name,
334                    self.dir.display()
335                ),
336            })?;
337
338        let input_str = serde_json::to_string(input).unwrap_or_else(|_| "{}".to_string());
339
340        let mut cmd = if let Some((program, extra_args)) = Self::interpreter_for(script_path) {
341            let mut c = tokio::process::Command::new(program);
342            c.args(extra_args);
343            c.arg(script_path);
344            c
345        } else {
346            tokio::process::Command::new(script_path)
347        };
348
349        cmd.env_clear()
350            .env("ROBOTICUS_INPUT", &input_str)
351            .env("ROBOTICUS_TOOL", tool_name)
352            .env("ROBOTICUS_PLUGIN", &self.manifest.name);
353
354        for key in &["PATH", "HOME", "USER", "LANG", "TERM", "TMPDIR"] {
355            if let Ok(val) = std::env::var(key) {
356                cmd.env(key, val);
357            }
358        }
359
360        for (k, v) in &self.env_extra {
361            cmd.env(k, v);
362        }
363
364        cmd.current_dir(&self.dir)
365            .stdin(std::process::Stdio::null())
366            .stdout(std::process::Stdio::piped())
367            .stderr(std::process::Stdio::piped());
368
369        let mut child = cmd.spawn().map_err(|e| RoboticusError::Tool {
370            tool: tool_name.into(),
371            message: format!("failed to spawn script: {e}"),
372        })?;
373
374        // Take stdout/stderr pipes for bounded reading.
375        let mut child_stdout = child.stdout.take();
376        let mut child_stderr = child.stderr.take();
377
378        let timeout = self.timeout;
379        let tool = tool_name.to_string();
380
381        let result = tokio::time::timeout(timeout, async {
382            // Read stdout and stderr concurrently, bounded to MAX_SCRIPT_OUTPUT.
383            let stdout_fut = async {
384                let mut buf = Vec::new();
385                if let Some(out) = child_stdout.take() {
386                    out.take(MAX_SCRIPT_OUTPUT)
387                        .read_to_end(&mut buf)
388                        .await
389                        .inspect_err(
390                            |e| tracing::debug!(error = %e, "failed to read script stdout"),
391                        )
392                        .ok();
393                }
394                // `out` dropped here — closes the pipe, which sends SIGPIPE
395                // to the child if it tries to write more.
396                buf
397            };
398            let stderr_fut = async {
399                let mut buf = Vec::new();
400                if let Some(err) = child_stderr.take() {
401                    err.take(MAX_SCRIPT_OUTPUT)
402                        .read_to_end(&mut buf)
403                        .await
404                        .inspect_err(
405                            |e| tracing::debug!(error = %e, "failed to read script stderr"),
406                        )
407                        .ok();
408                }
409                buf
410            };
411
412            let (stdout_bytes, stderr_bytes) = tokio::join!(stdout_fut, stderr_fut);
413
414            // If the child is still running (e.g. output exceeded the cap
415            // and the process hasn't received/handled SIGPIPE yet), kill it.
416            let _ = child.kill().await;
417            let status = child.wait().await;
418            (stdout_bytes, stderr_bytes, status)
419        })
420        .await;
421
422        match result {
423            Ok((stdout_bytes, stderr_bytes, status)) => {
424                let stdout = String::from_utf8_lossy(&stdout_bytes).to_string();
425                let stderr = String::from_utf8_lossy(&stderr_bytes).to_string();
426                let status = status.map_err(|e| RoboticusError::Tool {
427                    tool: tool.clone(),
428                    message: format!("script execution failed: {e}"),
429                })?;
430
431                if status.success() {
432                    Ok(ToolResult {
433                        success: true,
434                        output: stdout,
435                        metadata: if stderr.is_empty() {
436                            None
437                        } else {
438                            Some(json!({ "stderr": stderr }))
439                        },
440                    })
441                } else {
442                    let code = status.code().unwrap_or(-1);
443                    Ok(ToolResult {
444                        success: false,
445                        output: if stderr.is_empty() {
446                            format!("script exited with code {code}")
447                        } else {
448                            stderr
449                        },
450                        metadata: Some(json!({
451                            "exit_code": code,
452                            "stdout": stdout,
453                        })),
454                    })
455                }
456            }
457            Err(_) => {
458                // Timeout: kill the child process and reap it to prevent zombies.
459                let _ = child.kill().await;
460                let _ = child.wait().await;
461                Err(RoboticusError::Tool {
462                    tool,
463                    message: format!("script timed out after {timeout:?}"),
464                })
465            }
466        }
467    }
468
469    async fn shutdown(&mut self) -> Result<()> {
470        debug!(plugin = self.manifest.name, "ScriptPlugin shutdown");
471        Ok(())
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use crate::manifest::ManifestToolDef;
479    use std::fs;
480
481    fn test_manifest(name: &str, tools: Vec<(&str, &str)>) -> PluginManifest {
482        PluginManifest {
483            name: name.into(),
484            version: "1.0.0".into(),
485            description: "test plugin".into(),
486            author: "test".into(),
487            permissions: vec![],
488            timeout_seconds: None,
489            requirements: vec![],
490            companion_skills: vec![],
491            tools: tools
492                .into_iter()
493                .map(|(n, d)| ManifestToolDef {
494                    name: n.into(),
495                    description: d.into(),
496                    dangerous: false,
497                    permissions: vec![],
498                    parameters_schema: None,
499                    paired_skill: None,
500                })
501                .collect(),
502        }
503    }
504
505    #[test]
506    fn discover_scripts_finds_gosh() {
507        let dir = tempfile::tempdir().unwrap();
508        fs::write(dir.path().join("greet.gosh"), "echo hello").unwrap();
509
510        let manifest = test_manifest("test", vec![("greet", "says hello")]);
511        let plugin = ScriptPlugin::new(manifest, dir.path().to_path_buf());
512        assert!(plugin.has_script("greet"));
513        assert_eq!(plugin.script_count(), 1);
514    }
515
516    #[test]
517    fn discover_scripts_finds_py() {
518        let dir = tempfile::tempdir().unwrap();
519        fs::write(dir.path().join("analyze.py"), "print('done')").unwrap();
520
521        let manifest = test_manifest("test", vec![("analyze", "analyzes stuff")]);
522        let plugin = ScriptPlugin::new(manifest, dir.path().to_path_buf());
523        assert!(plugin.has_script("analyze"));
524    }
525
526    #[test]
527    fn gosh_preferred_over_all_others() {
528        let dir = tempfile::tempdir().unwrap();
529        fs::write(dir.path().join("tool.gosh"), "echo gosh wins").unwrap();
530        fs::write(dir.path().join("tool.go"), "package main\nfunc main() {}\n").unwrap();
531        fs::write(dir.path().join("tool.sh"), "#!/bin/sh\necho hi").unwrap();
532        fs::write(dir.path().join("tool.py"), "print('hi')").unwrap();
533
534        let manifest = test_manifest("test", vec![("tool", "prefers gosh")]);
535        let plugin = ScriptPlugin::new(manifest, dir.path().to_path_buf());
536        assert!(plugin.has_script("tool"));
537        let path = plugin.script_path("tool").unwrap();
538        assert!(
539            path.to_string_lossy().ends_with(".gosh"),
540            "expected .gosh but got: {}",
541            path.display()
542        );
543    }
544
545    #[test]
546    fn discover_scripts_missing_tool() {
547        let dir = tempfile::tempdir().unwrap();
548        let manifest = test_manifest("test", vec![("missing_tool", "not here")]);
549        let plugin = ScriptPlugin::new(manifest, dir.path().to_path_buf());
550        assert!(!plugin.has_script("missing_tool"));
551        assert_eq!(plugin.script_count(), 0);
552    }
553
554    #[test]
555    fn interpreter_selection() {
556        assert_eq!(
557            ScriptPlugin::interpreter_for(Path::new("x.gosh")),
558            Some(("gosh", [].as_slice()))
559        );
560        assert_eq!(
561            ScriptPlugin::interpreter_for(Path::new("x.go")),
562            Some(("go", ["run"].as_slice()))
563        );
564        #[cfg(windows)]
565        let expected_python = Some(("python", [].as_slice()));
566        #[cfg(not(windows))]
567        let expected_python = Some(("python3", [].as_slice()));
568        assert_eq!(
569            ScriptPlugin::interpreter_for(Path::new("x.py")),
570            expected_python
571        );
572        assert_eq!(
573            ScriptPlugin::interpreter_for(Path::new("x.sh")),
574            Some(("sh", [].as_slice()))
575        );
576        assert_eq!(
577            ScriptPlugin::interpreter_for(Path::new("x.rb")),
578            Some(("ruby", [].as_slice()))
579        );
580        assert_eq!(
581            ScriptPlugin::interpreter_for(Path::new("x.js")),
582            Some(("node", [].as_slice()))
583        );
584        assert_eq!(ScriptPlugin::interpreter_for(Path::new("x")), None);
585    }
586
587    #[test]
588    fn plugin_name_and_version() {
589        let dir = tempfile::tempdir().unwrap();
590        let manifest = test_manifest("my-plugin", vec![]);
591        let plugin = ScriptPlugin::new(manifest, dir.path().to_path_buf());
592        assert_eq!(plugin.name(), "my-plugin");
593        assert_eq!(plugin.version(), "1.0.0");
594    }
595
596    #[test]
597    fn tools_from_manifest() {
598        let dir = tempfile::tempdir().unwrap();
599        let manifest = test_manifest("p", vec![("a", "tool a"), ("b", "tool b")]);
600        let plugin = ScriptPlugin::new(manifest, dir.path().to_path_buf());
601        let tools = plugin.tools();
602        assert_eq!(tools.len(), 2);
603        assert_eq!(tools[0].name, "a");
604        assert_eq!(tools[1].name, "b");
605    }
606
607    #[test]
608    fn script_tool_parameters_default_includes_required_prompt() {
609        let dir = tempfile::tempdir().unwrap();
610        let manifest = test_manifest("p", vec![("t", "one tool")]);
611        let plugin = ScriptPlugin::new(manifest, dir.path().to_path_buf());
612        let tools = plugin.tools();
613        let req = tools[0]
614            .parameters
615            .get("required")
616            .and_then(|v| v.as_array())
617            .expect("required array");
618        assert!(
619            req.iter().any(|v| v.as_str() == Some("prompt")),
620            "default schema must require prompt for ROBOTICUS_INPUT shortcuts"
621        );
622        assert!(tools[0].parameters["properties"].get("prompt").is_some());
623    }
624
625    #[tokio::test]
626    async fn execute_script_success() {
627        let dir = tempfile::tempdir().unwrap();
628        fs::write(
629            dir.path().join("greet.sh"),
630            "#!/bin/sh\necho \"hello from $ROBOTICUS_TOOL\"",
631        )
632        .unwrap();
633
634        let manifest = test_manifest("test", vec![("greet", "greets")]);
635        let plugin = ScriptPlugin::new(manifest, dir.path().to_path_buf());
636        let result = plugin
637            .execute_tool("greet", &json!({"name": "world"}))
638            .await
639            .unwrap();
640        assert!(result.success);
641        assert!(result.output.contains("hello from greet"));
642    }
643
644    #[tokio::test]
645    async fn execute_missing_tool_fails() {
646        let dir = tempfile::tempdir().unwrap();
647        let manifest = test_manifest("test", vec![("missing", "not here")]);
648        let plugin = ScriptPlugin::new(manifest, dir.path().to_path_buf());
649        let result = plugin.execute_tool("missing", &json!({})).await;
650        assert!(result.is_err());
651    }
652
653    #[tokio::test]
654    async fn execute_failing_script() {
655        let dir = tempfile::tempdir().unwrap();
656        fs::write(dir.path().join("fail.sh"), "#!/bin/sh\nexit 1").unwrap();
657
658        let manifest = test_manifest("test", vec![("fail", "always fails")]);
659        let plugin = ScriptPlugin::new(manifest, dir.path().to_path_buf());
660        let result = plugin.execute_tool("fail", &json!({})).await.unwrap();
661        assert!(!result.success);
662    }
663
664    #[tokio::test]
665    async fn execute_script_with_stderr() {
666        let dir = tempfile::tempdir().unwrap();
667        fs::write(
668            dir.path().join("warn.sh"),
669            "#!/bin/sh\necho 'result' && echo 'warning' >&2",
670        )
671        .unwrap();
672
673        let manifest = test_manifest("test", vec![("warn", "has stderr")]);
674        let plugin = ScriptPlugin::new(manifest, dir.path().to_path_buf());
675        let result = plugin.execute_tool("warn", &json!({})).await.unwrap();
676        assert!(result.success);
677        assert!(result.output.contains("result"));
678        assert!(result.metadata.is_some());
679        let meta = result.metadata.unwrap();
680        assert!(meta["stderr"].as_str().unwrap().contains("warning"));
681    }
682
683    #[tokio::test]
684    async fn init_rediscovers_scripts() {
685        let dir = tempfile::tempdir().unwrap();
686        let manifest = test_manifest("test", vec![("late", "added later")]);
687        let mut plugin = ScriptPlugin::new(manifest, dir.path().to_path_buf());
688        assert_eq!(plugin.script_count(), 0);
689
690        fs::write(dir.path().join("late.gosh"), "echo ok").unwrap();
691        plugin.init().await.unwrap();
692        assert_eq!(plugin.script_count(), 1);
693        let path = plugin.script_path("late").unwrap();
694        assert!(path.to_string_lossy().ends_with(".gosh"));
695    }
696
697    #[tokio::test]
698    async fn execute_receives_roboticus_input_env() {
699        let dir = tempfile::tempdir().unwrap();
700        fs::write(
701            dir.path().join("echo_input.sh"),
702            "#!/bin/sh\necho $ROBOTICUS_INPUT",
703        )
704        .unwrap();
705
706        let manifest = test_manifest("test", vec![("echo_input", "echoes input")]);
707        let plugin = ScriptPlugin::new(manifest, dir.path().to_path_buf());
708        let input = json!({"key": "value"});
709        let result = plugin.execute_tool("echo_input", &input).await.unwrap();
710        assert!(result.success);
711        assert!(result.output.contains("key"));
712        assert!(result.output.contains("value"));
713    }
714
715    #[test]
716    fn with_timeout_sets_timeout() {
717        let dir = tempfile::tempdir().unwrap();
718        let manifest = test_manifest("test", vec![]);
719        let plugin = ScriptPlugin::new(manifest, dir.path().to_path_buf())
720            .with_timeout(Duration::from_secs(5));
721        assert_eq!(plugin.timeout, Duration::from_secs(5));
722    }
723
724    fn test_manifest_with_dangerous(name: &str, tools: Vec<(&str, &str, bool)>) -> PluginManifest {
725        PluginManifest {
726            name: name.into(),
727            version: "1.0.0".into(),
728            description: "test plugin".into(),
729            author: "test".into(),
730            permissions: vec![],
731            timeout_seconds: None,
732            requirements: vec![],
733            companion_skills: vec![],
734            tools: tools
735                .into_iter()
736                .map(|(n, d, dangerous)| ManifestToolDef {
737                    name: n.into(),
738                    description: d.into(),
739                    dangerous,
740                    permissions: vec![],
741                    parameters_schema: None,
742                    paired_skill: None,
743                })
744                .collect(),
745        }
746    }
747
748    // ── has_recognized_shebang ──────────────────────────────────────
749
750    #[test]
751    fn shebang_recognized_env_python3() {
752        let dir = tempfile::tempdir().unwrap();
753        let path = dir.path().join("tool");
754        fs::write(&path, "#!/usr/bin/env python3\nprint('hi')").unwrap();
755        assert!(ScriptPlugin::has_recognized_shebang(&path));
756    }
757
758    #[test]
759    fn shebang_recognized_direct_sh() {
760        let dir = tempfile::tempdir().unwrap();
761        let path = dir.path().join("tool");
762        fs::write(&path, "#!/bin/sh\necho hi").unwrap();
763        assert!(ScriptPlugin::has_recognized_shebang(&path));
764    }
765
766    #[test]
767    fn shebang_recognized_bash() {
768        let dir = tempfile::tempdir().unwrap();
769        let path = dir.path().join("tool");
770        fs::write(&path, "#!/usr/bin/bash\necho hi").unwrap();
771        assert!(ScriptPlugin::has_recognized_shebang(&path));
772    }
773
774    #[test]
775    fn shebang_unrecognized_interpreter() {
776        let dir = tempfile::tempdir().unwrap();
777        let path = dir.path().join("tool");
778        fs::write(&path, "#!/usr/bin/perl\nprint 'hi'").unwrap();
779        assert!(!ScriptPlugin::has_recognized_shebang(&path));
780    }
781
782    #[test]
783    fn shebang_missing_no_shebang_line() {
784        let dir = tempfile::tempdir().unwrap();
785        let path = dir.path().join("tool");
786        fs::write(&path, "just some text\nno shebang").unwrap();
787        assert!(!ScriptPlugin::has_recognized_shebang(&path));
788    }
789
790    #[test]
791    fn shebang_empty_file() {
792        let dir = tempfile::tempdir().unwrap();
793        let path = dir.path().join("tool");
794        fs::write(&path, "").unwrap();
795        assert!(!ScriptPlugin::has_recognized_shebang(&path));
796    }
797
798    #[test]
799    fn shebang_nonexistent_file() {
800        let dir = tempfile::tempdir().unwrap();
801        let path = dir.path().join("nonexistent");
802        assert!(!ScriptPlugin::has_recognized_shebang(&path));
803    }
804
805    // ── validate_script_path ────────────────────────────────────────
806
807    #[test]
808    fn validate_script_path_inside_dir_ok() {
809        let dir = tempfile::tempdir().unwrap();
810        let script = dir.path().join("tool.sh");
811        fs::write(&script, "#!/bin/sh").unwrap();
812        assert!(ScriptPlugin::validate_script_path(&script, dir.path()).is_ok());
813    }
814
815    #[test]
816    fn validate_script_path_outside_dir_rejected() {
817        let dir = tempfile::tempdir().unwrap();
818        let other = tempfile::tempdir().unwrap();
819        let script = other.path().join("evil.sh");
820        fs::write(&script, "#!/bin/sh").unwrap();
821        let result = ScriptPlugin::validate_script_path(&script, dir.path());
822        assert!(result.is_err());
823        let msg = format!("{}", result.unwrap_err());
824        assert!(msg.contains("escapes plugin directory"));
825    }
826
827    #[test]
828    fn validate_script_path_nonexistent_script() {
829        let dir = tempfile::tempdir().unwrap();
830        let script = dir.path().join("nonexistent.sh");
831        let result = ScriptPlugin::validate_script_path(&script, dir.path());
832        assert!(result.is_err());
833        let msg = format!("{}", result.unwrap_err());
834        assert!(msg.contains("cannot resolve script path"));
835    }
836
837    #[cfg(unix)]
838    #[test]
839    fn validate_script_path_symlink_escape_rejected() {
840        let dir = tempfile::tempdir().unwrap();
841        let outside = tempfile::tempdir().unwrap();
842        let target = outside.path().join("payload.sh");
843        fs::write(&target, "#!/bin/sh\necho pwned").unwrap();
844        let link = dir.path().join("sneaky.sh");
845        std::os::unix::fs::symlink(&target, &link).unwrap();
846        let result = ScriptPlugin::validate_script_path(&link, dir.path());
847        assert!(result.is_err());
848    }
849
850    // ── find_script rejection paths ─────────────────────────────────
851
852    #[test]
853    fn extensionless_file_without_shebang_rejected() {
854        let dir = tempfile::tempdir().unwrap();
855        // Create extensionless file with no shebang
856        fs::write(dir.path().join("tool"), "just text, no shebang").unwrap();
857        let manifest = test_manifest("test", vec![("tool", "extensionless")]);
858        let plugin = ScriptPlugin::new(manifest, dir.path().to_path_buf());
859        assert!(!plugin.has_script("tool"));
860    }
861
862    #[test]
863    fn extensionless_file_with_recognized_shebang_accepted() {
864        let dir = tempfile::tempdir().unwrap();
865        fs::write(dir.path().join("tool"), "#!/bin/sh\necho hi").unwrap();
866        let manifest = test_manifest("test", vec![("tool", "extensionless with shebang")]);
867        let plugin = ScriptPlugin::new(manifest, dir.path().to_path_buf());
868        assert!(plugin.has_script("tool"));
869    }
870
871    #[cfg(unix)]
872    #[test]
873    fn find_script_rejects_symlink_escape() {
874        let dir = tempfile::tempdir().unwrap();
875        let outside = tempfile::tempdir().unwrap();
876        let target = outside.path().join("evil.sh");
877        fs::write(&target, "#!/bin/sh\necho pwned").unwrap();
878        let link = dir.path().join("tool.sh");
879        std::os::unix::fs::symlink(&target, &link).unwrap();
880
881        let manifest = test_manifest("test", vec![("tool", "symlinked")]);
882        let plugin = ScriptPlugin::new(manifest, dir.path().to_path_buf());
883        // Symlink escaping the plugin directory should be rejected
884        assert!(!plugin.has_script("tool"));
885    }
886
887    // ── is_tool_dangerous / manifest getters ────────────────────────
888
889    #[test]
890    fn is_tool_dangerous_returns_true() {
891        let dir = tempfile::tempdir().unwrap();
892        let manifest = test_manifest_with_dangerous("p", vec![("rm_all", "dangerous op", true)]);
893        let plugin = ScriptPlugin::new(manifest, dir.path().to_path_buf());
894        assert!(plugin.is_tool_dangerous("rm_all"));
895    }
896
897    #[test]
898    fn is_tool_dangerous_returns_false_for_safe() {
899        let dir = tempfile::tempdir().unwrap();
900        let manifest = test_manifest_with_dangerous("p", vec![("list", "safe op", false)]);
901        let plugin = ScriptPlugin::new(manifest, dir.path().to_path_buf());
902        assert!(!plugin.is_tool_dangerous("list"));
903    }
904
905    #[test]
906    fn manifest_getter() {
907        let dir = tempfile::tempdir().unwrap();
908        let manifest = test_manifest("my-plugin", vec![("t", "test")]);
909        let plugin = ScriptPlugin::new(manifest, dir.path().to_path_buf());
910        assert_eq!(plugin.manifest().name, "my-plugin");
911        assert_eq!(plugin.manifest().tools.len(), 1);
912    }
913
914    // ── tools() with dangerous flag ─────────────────────────────────
915
916    #[test]
917    fn tools_includes_dangerous_risk_level() {
918        let dir = tempfile::tempdir().unwrap();
919        let manifest = test_manifest_with_dangerous(
920            "p",
921            vec![("safe", "safe tool", false), ("danger", "risky tool", true)],
922        );
923        let plugin = ScriptPlugin::new(manifest, dir.path().to_path_buf());
924        let tools = plugin.tools();
925        assert_eq!(tools.len(), 2);
926        assert_eq!(tools[0].risk_level, roboticus_core::RiskLevel::Caution);
927        assert_eq!(tools[1].risk_level, roboticus_core::RiskLevel::Dangerous);
928    }
929
930    // ── shutdown ────────────────────────────────────────────────────
931
932    #[tokio::test]
933    async fn shutdown_succeeds() {
934        let dir = tempfile::tempdir().unwrap();
935        let manifest = test_manifest("test", vec![("t", "tool")]);
936        let mut plugin = ScriptPlugin::new(manifest, dir.path().to_path_buf());
937        assert!(plugin.shutdown().await.is_ok());
938    }
939
940    // ── execute_tool timeout ────────────────────────────────────────
941
942    #[tokio::test]
943    async fn execute_tool_timeout() {
944        let dir = tempfile::tempdir().unwrap();
945        fs::write(dir.path().join("slow.sh"), "#!/bin/sh\nsleep 60").unwrap();
946
947        let manifest = test_manifest("test", vec![("slow", "sleeps forever")]);
948        let plugin = ScriptPlugin::new(manifest, dir.path().to_path_buf())
949            .with_timeout(Duration::from_millis(100));
950        let result = plugin.execute_tool("slow", &json!({})).await;
951        assert!(result.is_err());
952        let msg = format!("{}", result.unwrap_err());
953        assert!(msg.contains("timed out"));
954    }
955
956    #[tokio::test]
957    async fn execute_tool_output_bounded() {
958        let dir = tempfile::tempdir().unwrap();
959        // Script that writes ~12 MB of output (exceeds 10 MB limit)
960        fs::write(
961            dir.path().join("big.sh"),
962            "#!/bin/sh\nhead -c 12582912 /dev/zero | tr '\\0' 'A'",
963        )
964        .unwrap();
965
966        let manifest = test_manifest("test", vec![("big", "big output")]);
967        let plugin = ScriptPlugin::new(manifest, dir.path().to_path_buf())
968            .with_timeout(Duration::from_secs(30));
969        let result = plugin.execute_tool("big", &json!({})).await.unwrap();
970        // The process is killed after exceeding the output cap, so success
971        // may be false and stdout lands in metadata.stdout instead of output.
972        let captured = if result.success {
973            result.output.clone()
974        } else {
975            result
976                .metadata
977                .as_ref()
978                .and_then(|m| m.get("stdout"))
979                .and_then(|v| v.as_str())
980                .unwrap_or("")
981                .to_string()
982        };
983        assert!(
984            captured.len() <= MAX_SCRIPT_OUTPUT as usize,
985            "output should be bounded to MAX_SCRIPT_OUTPUT, got {} bytes",
986            captured.len()
987        );
988        // Verify we actually read a non-trivial amount.
989        assert!(
990            captured.len() > 1_000_000,
991            "expected at least 1MB of output, got {} bytes",
992            captured.len()
993        );
994    }
995
996    // ── execute_tool spawn failure ──────────────────────────────────
997
998    #[tokio::test]
999    async fn execute_tool_spawn_failure_nonexecutable() {
1000        let dir = tempfile::tempdir().unwrap();
1001        // Create a script file but point to a nonexistent interpreter
1002        let script = dir.path().join("bad.sh");
1003        fs::write(&script, "#!/nonexistent/interpreter\necho hi").unwrap();
1004
1005        let manifest = test_manifest("test", vec![("bad", "bad interpreter")]);
1006        let mut plugin = ScriptPlugin::new(manifest, dir.path().to_path_buf());
1007        // The script won't be found by discover_scripts because .sh extension
1008        // will use our built-in interpreter mapping. Instead, directly insert
1009        // a script pointing to a nonexistent binary for the extensionless case.
1010        let fake_path = dir.path().join("nonexistent_binary");
1011        fs::write(&fake_path, "").unwrap();
1012        plugin.scripts.insert("bad".into(), fake_path);
1013        let result = plugin.execute_tool("bad", &json!({})).await;
1014        assert!(result.is_err());
1015        let msg = format!("{}", result.unwrap_err());
1016        assert!(
1017            msg.contains("spawn")
1018                || msg.contains("permission")
1019                || msg.contains("denied")
1020                || msg.contains("failed"),
1021            "unexpected error: {msg}"
1022        );
1023    }
1024}