返回 CodeWhale
plugin.rs
根目录 / crates / tui / src / tools / plugin.rs
1 //! Plugin tool system — scripts and commands as first-class tools.
2 //!
3 //! Users can drop self-describing scripts in `~/.codewhale/tools/` and they
4 //! are auto-discovered, parsed for frontmatter, and registered as model-visible
5 //! tools alongside built-in implementations.
6 //!
7 //! # Script frontmatter format
8 //!
9 //! Every plugin script must have a frontmatter header in its first 20 lines:
10 //!
11 //! ```sh
12 //! # name: my-tool
13 //! # description: Does something useful
14 //! # schema: {"type":"object","properties":{"input":{"type":"string"}}}
15 //! # approval: auto
16 //! ```
17 //!
18 //! The script receives the tool's JSON input on **stdin** and must return
19 //! a JSON `ToolResult` (`{"content": "...", "success": true}`) on **stdout**.
20 //! Non-JSON output is wrapped in a `ToolResult` with `success: false`.
21
22 use std::path::{Path, PathBuf};
23 use std::sync::Arc;
24 use std::time::Duration;
25
26 use async_trait::async_trait;
27 use serde_json::Value;
28 use tokio::io::AsyncWriteExt;
29
30 use super::spec::{
31 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
32 };
33
34 use crate::config::ToolOverride;
35
36 /// Timeout for plugin script execution (120 seconds).
37 const PLUGIN_EXECUTION_TIMEOUT: Duration = Duration::from_secs(120);
38
39 /// Metadata extracted from a plugin script's frontmatter header.
40 #[derive(Debug, Clone)]
41 pub struct PluginMetadata {
42 /// Tool name (from `# name:`).
43 pub name: String,
44 /// Human-readable description (from `# description:`).
45 pub description: String,
46 /// JSON Schema for the tool's input (from `# schema:`).
47 /// Defaults to a permissive `{"type": "object"}` when absent.
48 pub input_schema: Value,
49 /// Approval requirement (from `# approval:`).
50 /// Defaults to `Suggest`.
51 pub approval: ApprovalRequirement,
52 }
53
54 /// A tool backed by an external script or executable dropped into the
55 /// plugins directory. The script receives JSON input on stdin and writes
56 /// a JSON `ToolResult` to stdout.
57 struct ScriptPluginTool {
58 metadata: PluginMetadata,
59 /// Absolute path to the script.
60 script_path: PathBuf,
61 /// Optional static arguments passed before the JSON input.
62 args: Vec<String>,
63 }
64
65 impl std::fmt::Debug for ScriptPluginTool {
66 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67 f.debug_struct("ScriptPluginTool")
68 .field("name", &self.metadata.name)
69 .field("script_path", &self.script_path)
70 .finish()
71 }
72 }
73
74 #[async_trait]
75 impl ToolSpec for ScriptPluginTool {
76 fn name(&self) -> &str {
77 &self.metadata.name
78 }
79
80 fn description(&self) -> &str {
81 &self.metadata.description
82 }
83
84 fn input_schema(&self) -> Value {
85 self.metadata.input_schema.clone()
86 }
87
88 fn capabilities(&self) -> Vec<ToolCapability> {
89 // Unknown plugin — conservative: mark as requiring execution + approval.
90 vec![
91 ToolCapability::ExecutesCode,
92 ToolCapability::RequiresApproval,
93 ]
94 }
95
96 fn approval_requirement(&self) -> ApprovalRequirement {
97 self.metadata.approval
98 }
99
100 async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
101 let (interpreter, script_args) = script_command_parts(&self.script_path, &self.args);
102 let label = self.script_path.display().to_string();
103 run_plugin_child(&interpreter, &script_args, &label, input).await
104 }
105 }
106
107 /// A tool backed by an arbitrary shell command from config.toml overrides.
108 /// Behaves like `ScriptPluginTool` but uses the user-specified command string.
109 struct CommandPluginTool {
110 name: String,
111 description: String,
112 input_schema: Value,
113 command: String,
114 args: Vec<String>,
115 approval: ApprovalRequirement,
116 }
117
118 impl std::fmt::Debug for CommandPluginTool {
119 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120 f.debug_struct("CommandPluginTool")
121 .field("name", &self.name)
122 .field("command", &self.command)
123 .finish()
124 }
125 }
126
127 #[async_trait]
128 impl ToolSpec for CommandPluginTool {
129 fn name(&self) -> &str {
130 &self.name
131 }
132
133 fn description(&self) -> &str {
134 &self.description
135 }
136
137 fn input_schema(&self) -> Value {
138 self.input_schema.clone()
139 }
140
141 fn capabilities(&self) -> Vec<ToolCapability> {
142 vec![
143 ToolCapability::ExecutesCode,
144 ToolCapability::RequiresApproval,
145 ]
146 }
147
148 fn approval_requirement(&self) -> ApprovalRequirement {
149 self.approval
150 }
151
152 async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
153 // On Windows, if the command doesn't have an extension, try wrapping
154 // in `cmd /c` or use `powershell` for `.ps1` files. For portability
155 // we let tokio::process::Command resolve via PATH.
156 let mut cmd = if cfg!(windows) && !self.command.contains('.') {
157 let mut c = tokio::process::Command::new("cmd");
158 crate::utils::suppress_tokio_console_window(&mut c);
159 c.arg("/c").arg(&self.command);
160 c
161 } else {
162 let mut c = tokio::process::Command::new(&self.command);
163 crate::utils::suppress_tokio_console_window(&mut c);
164 c
165 };
166 cmd.args(&self.args);
167 let label = format!("command '{}'", self.command);
168 run_plugin_child_raw(&mut cmd, &label, input).await
169 }
170 }
171
172 // ---------------------------------------------------------------------------
173 // Script interpreter resolution
174 // ---------------------------------------------------------------------------
175
176 /// Parse a shebang line (`#!/usr/bin/env node`) to extract the interpreter.
177 fn parse_shebang(path: &Path) -> Option<(String, Vec<String>)> {
178 let mut file = std::fs::File::open(path).ok()?;
179 let content = read_prefix_to_string(&mut file, 256)?;
180 let first_line = content.lines().next()?;
181 let rest = first_line.strip_prefix("#!")?;
182 let parts: Vec<&str> = rest.split_whitespace().collect();
183 if parts.is_empty() {
184 return None;
185 }
186 let interpreter = parts[0].to_string();
187 let args: Vec<String> = parts[1..].iter().map(|s| s.to_string()).collect();
188 Some((interpreter, args))
189 }
190
191 /// Resolve the interpreter binary and pre-args for a script file.
192 ///
193 /// Priority:
194 /// 1. Shebang line from the script itself (`#!/usr/bin/env node`)
195 /// 2. Extension-based fallback for known script types
196 /// 3. Direct execution (assumes the OS knows how to run it)
197 fn resolve_interpreter(path: &Path) -> (String, Vec<String>) {
198 // 1. Try shebang
199 if let Some((interp, shebang_args)) = parse_shebang(path) {
200 let bin_name = interp.rsplit('/').next().unwrap_or(&interp);
201 // `env` is a special case: `#!/usr/bin/env node` → `node`
202 // On Windows, `env` is not available, so extract the intended binary.
203 if bin_name == "env" && !shebang_args.is_empty() {
204 return (shebang_args[0].clone(), shebang_args[1..].to_vec());
205 }
206 if cfg!(windows) {
207 return (bin_name.to_string(), shebang_args);
208 }
209 return (interp, shebang_args);
210 }
211
212 // 2. Extension-based fallback for common script types
213 let ext = path
214 .extension()
215 .and_then(|e| e.to_str())
216 .unwrap_or("")
217 .to_lowercase();
218 match ext.as_str() {
219 "ps1" => ("powershell".into(), vec!["-File".into()]),
220 "py" => ("python".into(), vec![]),
221 "js" | "mjs" => ("node".into(), vec![]),
222 "ts" => ("npx".into(), vec!["tsx".into()]),
223 "rb" => ("ruby".into(), vec![]),
224 "sh" | "bash" | "zsh" => {
225 // On Windows, route shell scripts through sh if available
226 if cfg!(windows) {
227 ("sh".into(), vec![])
228 } else {
229 (path.to_string_lossy().into(), vec![])
230 }
231 }
232 _ => (path.to_string_lossy().into(), vec![]),
233 }
234 }
235
236 fn script_command_parts(script_path: &Path, args: &[String]) -> (String, Vec<String>) {
237 let (interpreter, mut script_args) = resolve_interpreter(script_path);
238 let script_path_arg = script_path.to_string_lossy().to_string();
239 if interpreter != script_path_arg {
240 script_args.push(script_path_arg);
241 }
242 script_args.extend(args.iter().cloned());
243 (interpreter, script_args)
244 }
245
246 fn read_prefix_to_string(reader: impl std::io::Read, max_bytes: u64) -> Option<String> {
247 use std::io::Read;
248
249 let mut buf = Vec::new();
250 reader.take(max_bytes).read_to_end(&mut buf).ok()?;
251 Some(String::from_utf8_lossy(&buf).into_owned())
252 }
253
254 // ---------------------------------------------------------------------------
255 // Shared child process helpers
256 // ---------------------------------------------------------------------------
257
258 /// Spawn a command, pipe JSON input to stdin, collect ToolResult from stdout.
259 async fn run_plugin_child(
260 command: &str,
261 args: &[String],
262 label: &str,
263 input: Value,
264 ) -> Result<ToolResult, ToolError> {
265 let mut cmd = tokio::process::Command::new(command);
266 crate::utils::suppress_tokio_console_window(&mut cmd);
267 cmd.args(args);
268 run_plugin_child_raw(&mut cmd, label, input).await
269 }
270
271 /// Run a pre-configured tokio Command, pipe JSON input, collect ToolResult.
272 async fn run_plugin_child_raw(
273 cmd: &mut tokio::process::Command,
274 label: &str,
275 input: Value,
276 ) -> Result<ToolResult, ToolError> {
277 let input_bytes = serde_json::to_vec(&input)
278 .map_err(|e| ToolError::invalid_input(format!("failed to serialize input: {e}")))?;
279
280 cmd.stdin(std::process::Stdio::piped());
281 cmd.stdout(std::process::Stdio::piped());
282 cmd.stderr(std::process::Stdio::piped());
283
284 let mut child = cmd
285 .spawn()
286 .map_err(|e| ToolError::execution_failed(format!("failed to spawn {label}: {e}")))?;
287
288 let stdin_writer = child.stdin.take().map(|mut stdin| {
289 tokio::spawn(async move {
290 if stdin.write_all(&input_bytes).await.is_ok() {
291 let _ = stdin.shutdown().await;
292 }
293 })
294 });
295
296 let output = tokio::time::timeout(PLUGIN_EXECUTION_TIMEOUT, child.wait_with_output())
297 .await
298 .map_err(|_| ToolError::Timeout {
299 seconds: PLUGIN_EXECUTION_TIMEOUT.as_secs(),
300 })?
301 .map_err(|e| ToolError::execution_failed(format!("process error: {e}")))?;
302
303 if let Some(stdin_writer) = stdin_writer {
304 let _ = stdin_writer.await;
305 }
306
307 if output.status.success() {
308 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
309 if let Ok(parsed) = serde_json::from_str::<ToolResult>(&stdout) {
310 Ok(parsed)
311 } else {
312 Ok(ToolResult::success(stdout))
313 }
314 } else {
315 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
316 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
317 let combined = if stderr.is_empty() {
318 stdout
319 } else if stdout.is_empty() {
320 stderr
321 } else {
322 format!("{stdout}\n{stderr}")
323 };
324 Err(ToolError::execution_failed(combined))
325 }
326 }
327
328 // ---------------------------------------------------------------------------
329 // Frontmatter parsing
330 // ---------------------------------------------------------------------------
331
332 /// Parse frontmatter header from the first `max_lines` lines of a text file.
333 ///
334 /// Expected format (one `# key: value` per line):
335 /// ```text
336 /// # name: my-tool
337 /// # description: Does something
338 /// # schema: {"type":"object"}
339 /// # approval: auto
340 /// ```
341 ///
342 /// Also supports `// ` prefix for JavaScript/TypeScript scripts and `-- ` for Lua.
343 pub fn parse_frontmatter(content: &str) -> PluginMetadata {
344 let mut name = String::new();
345 let mut description = String::new();
346 let mut schema_str = String::new();
347 let mut approval_str = String::new();
348
349 for line in content.lines().take(20) {
350 let line = line.trim();
351 // Strip leading comment markers: `#`, `//`, `--`.
352 let rest = line
353 .strip_prefix('#')
354 .or_else(|| line.strip_prefix("//"))
355 .or_else(|| line.strip_prefix("--"));
356 let Some(rest) = rest else { continue };
357 if let Some((key, value)) = rest.trim_start().split_once(':') {
358 let key = key.trim().to_lowercase();
359 let value = value.trim();
360 match key.as_str() {
361 "name" => name = value.to_string(),
362 "description" => description = value.to_string(),
363 "schema" => schema_str = value.to_string(),
364 "approval" => approval_str = value.to_string(),
365 _ => {}
366 }
367 }
368 }
369
370 let input_schema = if schema_str.is_empty() {
371 // Default: accept any object payload
372 serde_json::json!({"type": "object"})
373 } else {
374 serde_json::from_str(&schema_str).unwrap_or_else(|_| serde_json::json!({"type": "object"}))
375 };
376
377 let approval = match approval_str.to_lowercase().as_str() {
378 "auto" => ApprovalRequirement::Auto,
379 "required" => ApprovalRequirement::Required,
380 _ => ApprovalRequirement::Suggest,
381 };
382
383 PluginMetadata {
384 name: if name.is_empty() {
385 "unnamed-plugin".to_string()
386 } else {
387 name
388 },
389 description: if description.is_empty() {
390 "User-provided plugin tool".to_string()
391 } else {
392 description
393 },
394 input_schema,
395 approval,
396 }
397 }
398
399 /// Read the first 4 KB of a file and parse its frontmatter.
400 fn read_script_metadata(path: &Path) -> Option<PluginMetadata> {
401 let mut file = std::fs::File::open(path).ok()?;
402 let content = read_prefix_to_string(&mut file, 4096)?;
403 let meta = parse_frontmatter(&content);
404 // Require at least the `name` field to consider it a valid plugin.
405 if meta.name == "unnamed-plugin" {
406 return None;
407 }
408 Some(meta)
409 }
410
411 // ---------------------------------------------------------------------------
412 // Directory scanning
413 // ---------------------------------------------------------------------------
414
415 /// Scan a directory for plugin script files with frontmatter headers.
416 ///
417 /// Files are considered eligible when:
418 /// - They are regular files (not directories, not symlinks)
419 /// - They don't start with `.` (hidden files)
420 /// - They are not `README.md`
421 /// - Their first 20 lines contain `# name:` frontmatter
422 pub fn scan_plugin_dir(dir: &Path) -> Vec<(PathBuf, PluginMetadata)> {
423 let mut results = Vec::new();
424
425 let entries = match std::fs::read_dir(dir) {
426 Ok(entries) => entries,
427 Err(e) => {
428 tracing::warn!("Failed to read plugin directory {}: {e}", dir.display());
429 return results;
430 }
431 };
432
433 let mut entries: Vec<_> = entries.flatten().collect();
434 entries.sort_by_key(|entry| entry.file_name());
435
436 for entry in entries {
437 let path = entry.path();
438
439 // Skip directories and hidden files
440 if path.is_dir() {
441 continue;
442 }
443 if let Some(name) = path.file_name().and_then(|n| n.to_str())
444 && (name.starts_with('.') || name == "README.md")
445 {
446 continue;
447 }
448
449 // Try to parse frontmatter
450 if let Some(meta) = read_script_metadata(&path) {
451 results.push((path, meta));
452 }
453 }
454
455 results
456 }
457
458 /// Load all plugin tools from a directory. Each eligible script becomes
459 /// a registered `ScriptPluginTool`.
460 pub fn load_plugin_tools(plugin_dir: &Path) -> Vec<Arc<dyn ToolSpec>> {
461 let discovered = scan_plugin_dir(plugin_dir);
462 let mut tools: Vec<Arc<dyn ToolSpec>> = Vec::with_capacity(discovered.len());
463
464 for (path, meta) in discovered {
465 tracing::info!(
466 "Discovered plugin tool '{}' at {}",
467 meta.name,
468 path.display()
469 );
470 tools.push(Arc::new(ScriptPluginTool {
471 metadata: meta,
472 script_path: path,
473 args: Vec::new(),
474 }));
475 }
476
477 tools
478 }
479
480 /// Create a single tool from a `ToolOverride` config entry.
481 ///
482 /// Returns `None` for `Disabled` (the caller handles removal separately).
483 pub fn tool_from_override(
484 tool_name: &str,
485 override_cfg: &ToolOverride,
486 plugin_dir: &Path,
487 ) -> Option<Arc<dyn ToolSpec>> {
488 match override_cfg {
489 ToolOverride::Disabled => None,
490 ToolOverride::Script { path, args } => {
491 let script_path = if Path::new(path).is_absolute() {
492 PathBuf::from(path)
493 } else {
494 // Relative paths resolve relative to the plugin directory.
495 plugin_dir.join(path)
496 };
497
498 if !script_path.exists() {
499 tracing::warn!(
500 "Override script for '{}' not found at {}",
501 tool_name,
502 script_path.display()
503 );
504 return None;
505 }
506
507 // Read the script's own frontmatter for metadata, or provide
508 // defaults if it has none.
509 let meta = read_script_metadata(&script_path).unwrap_or_else(|| PluginMetadata {
510 name: tool_name.to_string(),
511 description: format!("Override for built-in tool '{tool_name}'"),
512 input_schema: serde_json::json!({"type": "object"}),
513 approval: ApprovalRequirement::Suggest,
514 });
515
516 Some(Arc::new(ScriptPluginTool {
517 metadata: meta,
518 script_path,
519 args: args.clone().unwrap_or_default(),
520 }) as Arc<dyn ToolSpec>)
521 }
522 ToolOverride::Command { command, args } => {
523 // Build a description that includes the command.
524 let description = format!("Override for '{tool_name}' — runs: {command}");
525 let cmd_args = args.clone().unwrap_or_default();
526
527 Some(Arc::new(CommandPluginTool {
528 name: tool_name.to_string(),
529 description,
530 input_schema: serde_json::json!({"type": "object"}),
531 command: command.clone(),
532 args: cmd_args,
533 approval: ApprovalRequirement::Suggest,
534 }) as Arc<dyn ToolSpec>)
535 }
536 }
537 }
538
539 // ---------------------------------------------------------------------------
540 // Tests
541 // ---------------------------------------------------------------------------
542
543 #[cfg(test)]
544 mod tests {
545 use super::*;
546 use tempfile::TempDir;
547
548 const DEADLOCK_CHILD_ENV: &str = "CODEWHALE_PLUGIN_DEADLOCK_CHILD";
549
550 #[test]
551 fn test_parse_frontmatter_full() {
552 let content = "\
553 #!/usr/bin/env sh
554 # name: my-tool
555 # description: A useful custom tool
556 # schema: {\"type\":\"object\",\"properties\":{\"input\":{\"type\":\"string\"}}}
557 # approval: required
558 echo hello
559 ";
560 let meta = parse_frontmatter(content);
561 assert_eq!(meta.name, "my-tool");
562 assert_eq!(meta.description, "A useful custom tool");
563 assert_eq!(meta.approval, ApprovalRequirement::Required);
564 assert_eq!(
565 meta.input_schema,
566 serde_json::json!({"type":"object","properties":{"input":{"type":"string"}}})
567 );
568 }
569
570 #[test]
571 fn test_parse_frontmatter_accepts_compact_and_spaced_markers() {
572 let content = "\
573 #!/usr/bin/env node
574 #name:compact-name
575 // description: spaced description
576 -- schema : {\"type\":\"object\",\"properties\":{\"ok\":{\"type\":\"boolean\"}}}
577 # approval: auto
578 ";
579
580 let meta = parse_frontmatter(content);
581
582 assert_eq!(meta.name, "compact-name");
583 assert_eq!(meta.description, "spaced description");
584 assert_eq!(meta.approval, ApprovalRequirement::Auto);
585 assert_eq!(
586 meta.input_schema,
587 serde_json::json!({"type":"object","properties":{"ok":{"type":"boolean"}}})
588 );
589 }
590
591 #[test]
592 fn test_parse_frontmatter_minimal() {
593 let content = "# name: mini";
594 let meta = parse_frontmatter(content);
595 assert_eq!(meta.name, "mini");
596 assert_eq!(meta.description, "User-provided plugin tool");
597 assert_eq!(meta.approval, ApprovalRequirement::Suggest);
598 }
599
600 #[test]
601 fn test_parse_frontmatter_missing_name() {
602 let content = "# description: no name here";
603 let meta = parse_frontmatter(content);
604 assert_eq!(meta.name, "unnamed-plugin");
605 // read_script_metadata would return None for this.
606 }
607
608 #[test]
609 fn test_read_prefix_collects_multiple_short_reads() {
610 struct OneByteReader {
611 bytes: Vec<u8>,
612 pos: usize,
613 }
614
615 impl std::io::Read for OneByteReader {
616 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
617 if self.pos >= self.bytes.len() {
618 return Ok(0);
619 }
620 buf[0] = self.bytes[self.pos];
621 self.pos += 1;
622 Ok(1)
623 }
624 }
625
626 let reader = OneByteReader {
627 bytes: b"# name: short-read\n# description: ok\n".to_vec(),
628 pos: 0,
629 };
630
631 assert_eq!(
632 read_prefix_to_string(reader, 4096).as_deref(),
633 Some("# name: short-read\n# description: ok\n")
634 );
635 }
636
637 #[test]
638 fn test_resolve_interpreter_handles_absolute_shebang_by_platform() {
639 let dir = TempDir::new().unwrap();
640 let script = dir.path().join("tool");
641 std::fs::write(
642 &script,
643 "#!/opt/custom/bin/tool-runner --safe\n# name: tool\n",
644 )
645 .unwrap();
646
647 let (interpreter, args) = resolve_interpreter(&script);
648
649 if cfg!(windows) {
650 assert_eq!(interpreter, "tool-runner");
651 } else {
652 assert_eq!(interpreter, "/opt/custom/bin/tool-runner");
653 }
654 assert_eq!(args, vec!["--safe"]);
655 }
656
657 #[test]
658 fn test_script_command_parts_does_not_pass_direct_script_as_own_arg() {
659 let dir = TempDir::new().unwrap();
660 let script = dir.path().join("direct-tool");
661 std::fs::write(&script, "# name: direct\n").unwrap();
662
663 let (interpreter, args) =
664 script_command_parts(&script, &["--flag".to_string(), "value".to_string()]);
665
666 assert_eq!(interpreter, script.to_string_lossy());
667 assert_eq!(args, vec!["--flag", "value"]);
668 }
669
670 #[test]
671 fn test_script_command_parts_passes_script_to_external_interpreter() {
672 let dir = TempDir::new().unwrap();
673 let script = dir.path().join("script.py");
674 std::fs::write(&script, "# name: py\n").unwrap();
675
676 let (interpreter, args) = script_command_parts(&script, &["--flag".to_string()]);
677
678 assert_eq!(interpreter, "python");
679 assert_eq!(
680 args,
681 vec![script.to_string_lossy().to_string(), "--flag".to_string()]
682 );
683 }
684
685 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
686 async fn test_run_plugin_child_drains_stdout_while_writing_large_stdin() {
687 let mut cmd = tokio::process::Command::new(std::env::current_exe().unwrap());
688 cmd.arg("plugin_deadlock_child_process")
689 .arg("--nocapture")
690 .env(DEADLOCK_CHILD_ENV, "1");
691
692 let input = serde_json::json!({ "payload": "y".repeat(1024 * 1024) });
693 let result = tokio::time::timeout(
694 Duration::from_secs(10),
695 run_plugin_child_raw(&mut cmd, "deadlock child", input),
696 )
697 .await
698 .expect("plugin execution should not deadlock")
699 .expect("plugin child should succeed");
700
701 assert!(result.success);
702 assert!(result.content.len() > 64 * 1024);
703 }
704
705 #[test]
706 fn plugin_deadlock_child_process() {
707 if std::env::var_os(DEADLOCK_CHILD_ENV).is_none() {
708 return;
709 }
710
711 use std::io::{Read, Write};
712
713 let mut stdout = std::io::stdout();
714 stdout.write_all(&vec![b'x'; 1024 * 1024]).unwrap();
715 stdout.flush().unwrap();
716
717 let mut stdin = Vec::new();
718 std::io::stdin().read_to_end(&mut stdin).unwrap();
719 writeln!(
720 stdout,
721 "{{\"content\":\"read {} bytes\",\"success\":true}}",
722 stdin.len()
723 )
724 .unwrap();
725 std::process::exit(0);
726 }
727
728 #[test]
729 fn test_scan_plugin_dir_finds_scripts() {
730 let dir = TempDir::new().unwrap();
731
732 // Valid plugin
733 std::fs::write(
734 dir.path().join("my-plugin.sh"),
735 "# name: my-plugin\n# description: test\n",
736 )
737 .unwrap();
738
739 // Hidden file — should be skipped
740 std::fs::write(
741 dir.path().join(".hidden.sh"),
742 "# name: hidden\n# description: should skip\n",
743 )
744 .unwrap();
745
746 // README — should be skipped
747 std::fs::write(dir.path().join("README.md"), "# Tools\n").unwrap();
748
749 // No frontmatter — should be skipped
750 std::fs::write(dir.path().join("random.sh"), "echo hi\n").unwrap();
751
752 let discovered = scan_plugin_dir(dir.path());
753 assert_eq!(discovered.len(), 1);
754 assert_eq!(discovered[0].1.name, "my-plugin");
755 }
756
757 #[test]
758 fn test_scan_plugin_dir_returns_files_sorted_by_name() {
759 let dir = TempDir::new().unwrap();
760 std::fs::write(
761 dir.path().join("z-plugin.sh"),
762 "# name: z-plugin\n# description: z\n",
763 )
764 .unwrap();
765 std::fs::write(
766 dir.path().join("a-plugin.sh"),
767 "# name: a-plugin\n# description: a\n",
768 )
769 .unwrap();
770
771 let discovered = scan_plugin_dir(dir.path());
772
773 let names: Vec<_> = discovered
774 .iter()
775 .map(|(_, meta)| meta.name.as_str())
776 .collect();
777 assert_eq!(names, vec!["a-plugin", "z-plugin"]);
778 }
779
780 #[test]
781 fn test_load_plugin_tools_creates_tools() {
782 let dir = TempDir::new().unwrap();
783 std::fs::write(
784 dir.path().join("greet.sh"),
785 "# name: greet\n# description: Say hello\n# schema: {\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"}},\"required\":[\"name\"]}\n",
786 )
787 .unwrap();
788
789 let tools = load_plugin_tools(dir.path());
790 assert_eq!(tools.len(), 1);
791 assert_eq!(tools[0].name(), "greet");
792 assert_eq!(tools[0].description(), "Say hello");
793 }
794
795 #[test]
796 fn test_tool_from_override_script() {
797 let dir = TempDir::new().unwrap();
798 std::fs::write(
799 dir.path().join("wrapper.sh"),
800 "# name: exec_shell\n# description: Audit wrapper for exec_shell\n",
801 )
802 .unwrap();
803
804 let override_cfg = ToolOverride::Script {
805 path: "wrapper.sh".to_string(),
806 args: None,
807 };
808
809 let tool = tool_from_override("exec_shell", &override_cfg, dir.path());
810 assert!(tool.is_some());
811 assert_eq!(tool.unwrap().name(), "exec_shell");
812 }
813
814 #[test]
815 fn test_tool_from_override_disabled() {
816 let dir = TempDir::new().unwrap();
817 let override_cfg = ToolOverride::Disabled;
818 let tool = tool_from_override("code_execution", &override_cfg, dir.path());
819 assert!(tool.is_none());
820 }
821
822 #[test]
823 fn test_tool_from_override_command() {
824 let dir = TempDir::new().unwrap();
825 let override_cfg = ToolOverride::Command {
826 command: "my-custom-reader".to_string(),
827 args: Some(vec!["--format".to_string(), "json".to_string()]),
828 };
829 let tool = tool_from_override("read_file", &override_cfg, dir.path());
830 assert!(tool.is_some());
831 assert_eq!(tool.unwrap().name(), "read_file");
832 }
833
834 #[test]
835 fn test_tool_from_override_script_absolute_path() {
836 let dir = TempDir::new().unwrap();
837 let script_path = dir.path().join("audit.sh");
838 std::fs::write(&script_path, "# name: exec_shell\n# description: Audit\n").unwrap();
839
840 let override_cfg = ToolOverride::Script {
841 path: script_path.to_str().unwrap().to_string(),
842 args: None,
843 };
844
845 let tool = tool_from_override("exec_shell", &override_cfg, dir.path());
846 assert!(tool.is_some());
847 }
848
849 #[test]
850 fn test_approval_variants() {
851 let check = |content: &str, expected: ApprovalRequirement| {
852 assert_eq!(parse_frontmatter(content).approval, expected);
853 };
854
855 check("# name: x\n# approval: auto", ApprovalRequirement::Auto);
856 check(
857 "# name: x\n# approval: required",
858 ApprovalRequirement::Required,
859 );
860 check(
861 "# name: x\n# approval: suggest",
862 ApprovalRequirement::Suggest,
863 );
864 check(
865 "# name: x\n# approval: unknown",
866 ApprovalRequirement::Suggest,
867 );
868 check("# name: x", ApprovalRequirement::Suggest);
869 }
870 }
871
871 lines RUST