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);
16const MAX_SCRIPT_OUTPUT: u64 = 10 * 1024 * 1024;
18const SCRIPT_EXTENSIONS: &[&str] = &[
19 "gosh", "go", "sh", "py", "rb", "js",
20 "",
26];
27
28pub 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 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 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 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 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 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 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 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 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 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 #[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 #[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 #[test]
762 fn extensionless_file_without_shebang_rejected() {
763 let dir = tempfile::tempdir().unwrap();
764 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 assert!(!plugin.has_script("tool"));
794 }
795
796 #[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 #[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 #[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 #[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 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 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 assert!(
899 captured.len() > 1_000_000,
900 "expected at least 1MB of output, got {} bytes",
901 captured.len()
902 );
903 }
904
905 #[tokio::test]
908 async fn execute_tool_spawn_failure_nonexecutable() {
909 let dir = tempfile::tempdir().unwrap();
910 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 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}