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