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);
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 env_extra: HashMap<String, String>,
41}
42
43fn 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 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 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 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 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 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 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 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 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 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 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 #[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 #[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 #[test]
853 fn extensionless_file_without_shebang_rejected() {
854 let dir = tempfile::tempdir().unwrap();
855 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 assert!(!plugin.has_script("tool"));
885 }
886
887 #[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 #[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 #[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 #[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 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 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 assert!(
990 captured.len() > 1_000_000,
991 "expected at least 1MB of output, got {} bytes",
992 captured.len()
993 );
994 }
995
996 #[tokio::test]
999 async fn execute_tool_spawn_failure_nonexecutable() {
1000 let dir = tempfile::tempdir().unwrap();
1001 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 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}