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