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