1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use crate::internal::tool_runtime::sandbox::{
6 SandboxExecOptions, SandboxExecResult, SandboxMode, SandboxRouter,
7};
8use async_trait::async_trait;
9use microclaw_core::llm_types::ToolDefinition;
10use serde::{Deserialize, Serialize};
11use serde_json::json;
12use tracing::warn;
13
14use crate::config::{Config, WorkingDirIsolation};
15use crate::tools::{auth_context_from_input, schema_object, Tool, ToolResult};
16
17fn default_plugin_enabled() -> bool {
18 true
19}
20
21fn default_plugin_tool_schema() -> serde_json::Value {
22 schema_object(json!({}), &[])
23}
24
25fn default_plugin_timeout_secs() -> u64 {
26 30
27}
28
29fn default_plugin_context_kind() -> PluginContextKind {
30 PluginContextKind::Prompt
31}
32
33fn default_plugin_context_max_chars() -> usize {
34 6000
35}
36
37#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
38#[serde(rename_all = "snake_case")]
39pub enum PluginExecutionPolicy {
40 HostOnly,
41 SandboxOnly,
42 Dual,
43}
44
45impl PluginExecutionPolicy {
46 fn is_allowed(self, sandbox_mode: SandboxMode, sandbox_runtime_available: bool) -> bool {
47 match self {
48 PluginExecutionPolicy::HostOnly => true,
49 PluginExecutionPolicy::Dual => true,
50 PluginExecutionPolicy::SandboxOnly => {
51 sandbox_mode == SandboxMode::All && sandbox_runtime_available
52 }
53 }
54 }
55}
56
57#[derive(Clone, Debug, Deserialize, Serialize)]
58pub struct PluginsConfig {
59 #[serde(default = "default_plugin_enabled")]
60 pub enabled: bool,
61 #[serde(default)]
62 pub dir: Option<String>,
63}
64
65impl Default for PluginsConfig {
66 fn default() -> Self {
67 Self {
68 enabled: true,
69 dir: None,
70 }
71 }
72}
73
74#[derive(Clone, Debug, Deserialize)]
75pub struct PluginManifest {
76 pub name: String,
77 #[serde(default = "default_plugin_enabled")]
78 pub enabled: bool,
79 #[serde(default)]
80 pub commands: Vec<PluginCommandSpec>,
81 #[serde(default)]
82 pub tools: Vec<PluginToolSpec>,
83 #[serde(default)]
84 pub context_providers: Vec<PluginContextProviderSpec>,
85}
86
87#[derive(Clone, Debug, Default, Deserialize)]
88pub struct PluginCommandPermissions {
89 #[serde(default)]
90 pub allowed_channels: Vec<String>,
91 #[serde(default)]
92 pub require_control_chat: bool,
93}
94
95#[derive(Clone, Debug, Deserialize)]
96pub struct PluginCommandSpec {
97 pub command: String,
98 #[serde(default)]
99 pub description: String,
100 #[serde(default)]
101 pub response: Option<String>,
102 #[serde(default)]
103 pub run: Option<PluginExecSpec>,
104 #[serde(default)]
105 pub permissions: PluginCommandPermissions,
106}
107
108#[derive(Clone, Debug, Deserialize)]
109pub struct PluginExecSpec {
110 pub command: String,
111 #[serde(default = "default_plugin_timeout_secs")]
112 pub timeout_secs: u64,
113 #[serde(default)]
114 pub execution_policy: Option<PluginExecutionPolicy>,
115}
116
117#[derive(Clone, Debug, Default, Deserialize)]
118pub struct PluginToolPermissions {
119 #[serde(default)]
120 pub allowed_channels: Vec<String>,
121 #[serde(default)]
122 pub require_control_chat: bool,
123 #[serde(default)]
124 pub execution_policy: Option<PluginExecutionPolicy>,
125}
126
127#[derive(Clone, Debug, Deserialize)]
128pub struct PluginToolSpec {
129 pub name: String,
130 pub description: String,
131 #[serde(default = "default_plugin_tool_schema")]
132 pub input_schema: serde_json::Value,
133 pub run: PluginExecSpec,
134 #[serde(default)]
135 pub permissions: PluginToolPermissions,
136}
137
138#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
139#[serde(rename_all = "snake_case")]
140pub enum PluginContextKind {
141 Prompt,
142 Document,
143}
144
145#[derive(Clone, Debug, Default, Deserialize)]
146pub struct PluginContextPermissions {
147 #[serde(default)]
148 pub allowed_channels: Vec<String>,
149 #[serde(default)]
150 pub require_control_chat: bool,
151 #[serde(default)]
152 pub execution_policy: Option<PluginExecutionPolicy>,
153}
154
155#[derive(Clone, Debug, Deserialize)]
156pub struct PluginContextProviderSpec {
157 pub name: String,
158 #[serde(default = "default_plugin_context_kind")]
159 pub kind: PluginContextKind,
160 #[serde(default)]
161 pub description: String,
162 #[serde(default)]
163 pub content: Option<String>,
164 #[serde(default)]
165 pub run: Option<PluginExecSpec>,
166 #[serde(default = "default_plugin_context_max_chars")]
167 pub max_chars: usize,
168 #[serde(default)]
169 pub permissions: PluginContextPermissions,
170}
171
172#[derive(Clone, Debug)]
173pub struct PluginContextInjection {
174 pub plugin_name: String,
175 pub provider_name: String,
176 pub kind: PluginContextKind,
177 pub content: String,
178}
179
180#[derive(Clone)]
181pub struct LoadedPluginTool {
182 pub plugin_name: String,
183 pub spec: PluginToolSpec,
184}
185
186#[derive(Clone, Debug, Default)]
187pub struct PluginLoadReport {
188 pub manifests: Vec<PluginManifest>,
189 pub errors: Vec<String>,
190}
191
192pub fn plugins_dir(config: &Config) -> PathBuf {
193 if let Some(dir) = &config.plugins.dir {
194 let trimmed = dir.trim();
195 if !trimmed.is_empty() {
196 return PathBuf::from(trimmed);
197 }
198 }
199 config.data_root_dir().join("plugins")
200}
201
202pub fn load_plugin_report(config: &Config) -> PluginLoadReport {
203 if !config.plugins.enabled {
204 return PluginLoadReport::default();
205 }
206 let dir = plugins_dir(config);
207 let entries = match std::fs::read_dir(&dir) {
208 Ok(entries) => entries,
209 Err(_) => return PluginLoadReport::default(),
210 };
211
212 let mut report = PluginLoadReport::default();
213 let mut plugin_names = HashSet::new();
214 let mut manifests = Vec::new();
215 for entry in entries.flatten() {
216 let path = entry.path();
217 if !path.is_file() {
218 continue;
219 }
220 let ext = path
221 .extension()
222 .and_then(|v| v.to_str())
223 .map(|v| v.to_ascii_lowercase())
224 .unwrap_or_default();
225 if ext != "yaml" && ext != "yml" && ext != "json" {
226 continue;
227 }
228 match load_manifest_file(&path) {
229 Ok(mut manifest) => {
230 normalize_manifest(&mut manifest);
231 if manifest.name.is_empty() {
232 report
233 .errors
234 .push(format!("{}: plugin name is empty", path.display()));
235 continue;
236 }
237 let normalized_name = manifest.name.to_ascii_lowercase();
238 if !plugin_names.insert(normalized_name) {
239 report.errors.push(format!(
240 "{}: duplicate plugin name '{}'",
241 path.display(),
242 manifest.name
243 ));
244 continue;
245 }
246 validate_manifest(&manifest, &path, &mut report.errors);
247 if manifest.enabled {
248 manifests.push(manifest);
249 }
250 }
251 Err(e) => {
252 report.errors.push(format!(
253 "{}: failed to parse plugin manifest: {}",
254 path.display(),
255 e
256 ));
257 warn!(path = %path.display(), error = %e, "failed to load plugin manifest");
258 }
259 }
260 }
261 manifests.sort_by(|a, b| {
265 a.name
266 .to_ascii_lowercase()
267 .cmp(&b.name.to_ascii_lowercase())
268 .then_with(|| a.name.cmp(&b.name))
269 });
270 report.manifests = manifests;
271 report
272}
273
274pub fn load_plugin_manifests(config: &Config) -> Vec<PluginManifest> {
275 let report = load_plugin_report(config);
276 for err in &report.errors {
277 warn!(error = err.as_str(), "plugin manifest validation issue");
278 }
279 report.manifests
280}
281
282fn load_manifest_file(path: &Path) -> anyhow::Result<PluginManifest> {
283 let content = std::fs::read_to_string(path)?;
284 let ext = path
285 .extension()
286 .and_then(|v| v.to_str())
287 .map(|v| v.to_ascii_lowercase())
288 .unwrap_or_default();
289 if ext == "json" {
290 Ok(serde_json::from_str(&content)?)
291 } else {
292 Ok(serde_yaml::from_str(&content)?)
293 }
294}
295
296fn normalize_manifest(manifest: &mut PluginManifest) {
297 manifest.name = manifest.name.trim().to_string();
298 for command in &mut manifest.commands {
299 let trimmed = command.command.trim();
300 command.command = if trimmed.starts_with('/') {
301 trimmed.to_string()
302 } else {
303 format!("/{trimmed}")
304 };
305 command.description = command.description.trim().to_string();
306 normalize_channels(&mut command.permissions.allowed_channels);
307 }
308 for tool in &mut manifest.tools {
309 tool.name = tool.name.trim().to_string();
310 tool.description = tool.description.trim().to_string();
311 normalize_channels(&mut tool.permissions.allowed_channels);
312 }
313 for provider in &mut manifest.context_providers {
314 provider.name = provider.name.trim().to_string();
315 provider.description = provider.description.trim().to_string();
316 provider.max_chars = provider.max_chars.max(1);
317 if let Some(content) = &mut provider.content {
318 *content = content.trim().to_string();
319 }
320 normalize_channels(&mut provider.permissions.allowed_channels);
321 }
322}
323
324fn normalize_channels(channels: &mut Vec<String>) {
325 let mut deduped = Vec::new();
326 let mut seen = HashSet::new();
327 for channel in channels.drain(..) {
328 let normalized = channel.trim().to_ascii_lowercase();
329 if normalized.is_empty() || !seen.insert(normalized.clone()) {
330 continue;
331 }
332 deduped.push(normalized);
333 }
334 *channels = deduped;
335}
336
337fn validate_manifest(manifest: &PluginManifest, path: &Path, errors: &mut Vec<String>) {
338 let mut command_names = HashSet::new();
339 for command in &manifest.commands {
340 if command.command.trim() == "/" {
341 errors.push(format!(
342 "{}: plugin '{}' has invalid command '/'",
343 path.display(),
344 manifest.name
345 ));
346 }
347 let normalized = command.command.to_ascii_lowercase();
348 if !command_names.insert(normalized) {
349 errors.push(format!(
350 "{}: plugin '{}' has duplicate command '{}'",
351 path.display(),
352 manifest.name,
353 command.command
354 ));
355 }
356 if let Some(run) = &command.run {
357 if run.command.trim().is_empty() {
358 errors.push(format!(
359 "{}: plugin '{}' command '{}' has empty run.command",
360 path.display(),
361 manifest.name,
362 command.command
363 ));
364 }
365 }
366 }
367
368 let mut tool_names = HashSet::new();
369 for tool in &manifest.tools {
370 let normalized = tool.name.to_ascii_lowercase();
371 if normalized.is_empty() {
372 errors.push(format!(
373 "{}: plugin '{}' contains tool with empty name",
374 path.display(),
375 manifest.name
376 ));
377 continue;
378 }
379 if !tool_names.insert(normalized) {
380 errors.push(format!(
381 "{}: plugin '{}' has duplicate tool '{}'",
382 path.display(),
383 manifest.name,
384 tool.name
385 ));
386 }
387 if tool.run.command.trim().is_empty() {
388 errors.push(format!(
389 "{}: plugin '{}' tool '{}' has empty run.command",
390 path.display(),
391 manifest.name,
392 tool.name
393 ));
394 }
395 if !tool.input_schema.is_object() {
396 errors.push(format!(
397 "{}: plugin '{}' tool '{}' input_schema must be an object",
398 path.display(),
399 manifest.name,
400 tool.name
401 ));
402 }
403 }
404
405 let mut provider_names = HashSet::new();
406 for provider in &manifest.context_providers {
407 let normalized = provider.name.to_ascii_lowercase();
408 if normalized.is_empty() {
409 errors.push(format!(
410 "{}: plugin '{}' contains context provider with empty name",
411 path.display(),
412 manifest.name
413 ));
414 continue;
415 }
416 if !provider_names.insert(normalized) {
417 errors.push(format!(
418 "{}: plugin '{}' has duplicate context provider '{}'",
419 path.display(),
420 manifest.name,
421 provider.name
422 ));
423 }
424 let has_content = provider
425 .content
426 .as_ref()
427 .map(|v| !v.trim().is_empty())
428 .unwrap_or(false);
429 let has_run = provider.run.is_some();
430 if has_content == has_run {
431 errors.push(format!(
432 "{}: plugin '{}' context provider '{}' must set exactly one of content or run",
433 path.display(),
434 manifest.name,
435 provider.name
436 ));
437 }
438 if let Some(run) = &provider.run {
439 if run.command.trim().is_empty() {
440 errors.push(format!(
441 "{}: plugin '{}' context provider '{}' has empty run.command",
442 path.display(),
443 manifest.name,
444 provider.name
445 ));
446 }
447 }
448 }
449}
450
451pub fn load_plugin_tools(config: &Config) -> Vec<LoadedPluginTool> {
452 let mut out = Vec::new();
453 let report = load_plugin_report(config);
454 for err in &report.errors {
455 warn!(error = err.as_str(), "plugin manifest validation issue");
456 }
457 for manifest in report.manifests {
458 let plugin_name = manifest.name;
459 for spec in manifest.tools {
460 if spec.name.is_empty() || spec.run.command.trim().is_empty() {
461 continue;
462 }
463 out.push(LoadedPluginTool {
464 plugin_name: plugin_name.clone(),
465 spec,
466 });
467 }
468 }
469 out
470}
471
472pub fn dynamic_plugin_tool_definitions(config: &Config) -> Vec<ToolDefinition> {
473 let mut out = Vec::new();
474 let mut seen = HashSet::new();
475 for manifest in load_plugin_manifests(config) {
476 for spec in manifest.tools {
477 let normalized = spec.name.to_ascii_lowercase();
478 if normalized.is_empty() || !seen.insert(normalized) {
479 continue;
480 }
481 out.push(ToolDefinition {
482 name: spec.name,
483 description: format!("[plugin:{}] {}", manifest.name, spec.description),
484 input_schema: if spec.input_schema.is_object() {
485 spec.input_schema
486 } else {
487 default_plugin_tool_schema()
488 },
489 });
490 }
491 }
492 out
493}
494
495pub async fn execute_dynamic_plugin_tool(
496 config: &Config,
497 tool_name: &str,
498 input: serde_json::Value,
499) -> Option<ToolResult> {
500 let mut matched: Option<(String, PluginToolSpec)> = None;
501 for manifest in load_plugin_manifests(config) {
502 if let Some(spec) = manifest
503 .tools
504 .into_iter()
505 .find(|t| t.name.eq_ignore_ascii_case(tool_name))
506 {
507 matched = Some((manifest.name, spec));
508 break;
509 }
510 }
511 let (plugin_name, spec) = matched?;
512
513 Some(execute_plugin_tool_spec(config, &plugin_name, &spec, input).await)
514}
515
516pub fn handle_plugins_admin_command(
517 config: &Config,
518 caller_chat_id: i64,
519 command_text: &str,
520) -> Option<String> {
521 let trimmed = command_text.trim();
522 let (first_token, rest) = first_token_and_rest(trimmed);
523 if !command_token_base(first_token).eq_ignore_ascii_case("/plugins") {
524 return None;
525 }
526 if !config.control_chat_ids.contains(&caller_chat_id) {
527 return Some("Plugin admin commands require control chat permission.".to_string());
528 }
529
530 let arg = rest
531 .split_whitespace()
532 .next()
533 .unwrap_or("list")
534 .to_ascii_lowercase();
535 let report = load_plugin_report(config);
536 match arg.as_str() {
537 "list" => {
538 if report.manifests.is_empty() {
539 return Some("No enabled plugins found.".to_string());
540 }
541 let mut lines = Vec::new();
542 lines.push(format!("Plugins ({}):", report.manifests.len()));
543 for manifest in &report.manifests {
544 lines.push(format!(
545 "- {} (commands: {}, tools: {})",
546 manifest.name,
547 manifest.commands.len(),
548 manifest.tools.len()
549 ));
550 }
551 if !report.errors.is_empty() {
552 lines.push(format!("Validation issues: {}", report.errors.len()));
553 }
554 Some(lines.join("\n"))
555 }
556 "validate" => {
557 if report.errors.is_empty() {
558 Some("Plugin validation OK.".to_string())
559 } else {
560 let mut lines = Vec::new();
561 lines.push(format!(
562 "Plugin validation found {} issue(s):",
563 report.errors.len()
564 ));
565 for err in report.errors.iter().take(20) {
566 lines.push(format!("- {err}"));
567 }
568 if report.errors.len() > 20 {
569 lines.push(format!(
570 "... and {} more issue(s)",
571 report.errors.len() - 20
572 ));
573 }
574 Some(lines.join("\n"))
575 }
576 }
577 "reload" => Some(
578 "Plugin manifests are loaded dynamically; command and tool changes apply immediately."
579 .to_string(),
580 ),
581 _ => Some("Usage: /plugins [list|validate|reload]".to_string()),
582 }
583}
584
585pub fn command_matches(input: &str, configured: &str) -> bool {
586 let (first_token, _) = first_token_and_rest(input);
587 command_token_base(first_token).eq_ignore_ascii_case(configured)
588}
589
590pub async fn execute_plugin_slash_command(
591 config: &Config,
592 caller_channel: &str,
593 caller_chat_id: i64,
594 command_text: &str,
595) -> Option<String> {
596 let trimmed = command_text.trim();
597 if !trimmed.starts_with('/') {
598 return None;
599 }
600
601 let manifests = load_plugin_manifests(config);
602 for manifest in manifests {
603 for command in manifest.commands {
604 if !command_matches(trimmed, &command.command) {
605 continue;
606 }
607 if !is_channel_allowed(caller_channel, &command.permissions.allowed_channels) {
608 return Some(format!(
609 "Plugin command '{}' is not allowed in channel '{}'.",
610 command.command, caller_channel
611 ));
612 }
613 if command.permissions.require_control_chat
614 && !config.control_chat_ids.contains(&caller_chat_id)
615 {
616 return Some(format!(
617 "Plugin command '{}' requires control chat permission.",
618 command.command
619 ));
620 }
621
622 let (_, args) = first_token_and_rest(trimmed);
623 let args = args.to_string();
624
625 let mut vars = HashMap::new();
626 vars.insert("channel".to_string(), caller_channel.to_string());
627 vars.insert("chat_id".to_string(), caller_chat_id.to_string());
628 vars.insert("command".to_string(), command.command.clone());
629 vars.insert("args".to_string(), args);
630
631 let mut response_chunks = Vec::new();
632 if let Some(response) = &command.response {
633 match render_template_checked(response, &vars, false) {
634 Ok(text) => response_chunks.push(text),
635 Err(e) => {
636 return Some(format!("Plugin command response template error: {e}"));
637 }
638 }
639 }
640
641 if let Some(run) = &command.run {
642 match execute_with_template(
643 config,
644 caller_channel,
645 caller_chat_id,
646 &run.command,
647 run.timeout_secs,
648 run.execution_policy
649 .unwrap_or(PluginExecutionPolicy::HostOnly),
650 &vars,
651 )
652 .await
653 {
654 Ok(result) => {
655 vars.insert("stdout".into(), result.stdout.clone());
656 vars.insert("stderr".into(), result.stderr.clone());
657 vars.insert("exit_code".into(), result.exit_code.to_string());
658 vars.insert(
659 "success".into(),
660 if result.exit_code == 0 {
661 "true".into()
662 } else {
663 "false".into()
664 },
665 );
666 if command.response.is_none() {
667 response_chunks.push(format_exec_result(&result));
668 }
669 }
670 Err(e) => {
671 response_chunks.push(format!("Plugin command execution failed: {e}"));
672 }
673 }
674 }
675
676 if response_chunks.is_empty() {
677 response_chunks.push(format!("Executed plugin command '{}'.", command.command));
678 }
679 return Some(response_chunks.join("\n\n"));
680 }
681 }
682
683 None
684}
685
686pub async fn collect_plugin_context_injections(
687 config: &Config,
688 caller_channel: &str,
689 caller_chat_id: i64,
690 query: &str,
691) -> Vec<PluginContextInjection> {
692 let mut out = Vec::new();
693 let manifests = load_plugin_manifests(config);
694 for manifest in manifests {
695 for provider in manifest.context_providers {
696 if !is_channel_allowed(caller_channel, &provider.permissions.allowed_channels) {
697 continue;
698 }
699 if provider.permissions.require_control_chat
700 && !config.control_chat_ids.contains(&caller_chat_id)
701 {
702 continue;
703 }
704
705 let mut vars = HashMap::new();
706 vars.insert("channel".to_string(), caller_channel.to_string());
707 vars.insert("chat_id".to_string(), caller_chat_id.to_string());
708 vars.insert("query".to_string(), query.to_string());
709 vars.insert("provider".to_string(), provider.name.clone());
710 vars.insert("plugin".to_string(), manifest.name.clone());
711
712 let result = if let Some(content) = &provider.content {
713 render_template_checked(content, &vars, false).map(|text| SandboxExecResult {
714 stdout: text,
715 stderr: String::new(),
716 exit_code: 0,
717 })
718 } else if let Some(run) = &provider.run {
719 execute_with_template(
720 config,
721 caller_channel,
722 caller_chat_id,
723 &run.command,
724 run.timeout_secs,
725 provider
726 .permissions
727 .execution_policy
728 .or(run.execution_policy)
729 .unwrap_or(PluginExecutionPolicy::HostOnly),
730 &vars,
731 )
732 .await
733 } else {
734 continue;
735 };
736
737 match result {
738 Ok(exec_result) => {
739 if exec_result.exit_code != 0 {
740 warn!(
741 plugin = manifest.name.as_str(),
742 provider = provider.name.as_str(),
743 exit_code = exec_result.exit_code,
744 "plugin context provider returned non-zero exit code"
745 );
746 continue;
747 }
748 let mut content = exec_result.stdout.trim().to_string();
749 if content.is_empty() {
750 continue;
751 }
752 if content.len() > provider.max_chars {
753 content.truncate(provider.max_chars);
754 }
755 out.push(PluginContextInjection {
756 plugin_name: manifest.name.clone(),
757 provider_name: provider.name,
758 kind: provider.kind,
759 content,
760 });
761 }
762 Err(e) => {
763 warn!(
764 plugin = manifest.name.as_str(),
765 provider = provider.name.as_str(),
766 error = %e,
767 "plugin context provider execution failed"
768 );
769 }
770 }
771 }
772 }
773 out
774}
775
776fn is_channel_allowed(channel: &str, allowed: &[String]) -> bool {
777 if allowed.is_empty() {
778 return true;
779 }
780 let normalized = channel.trim().to_ascii_lowercase();
781 allowed.iter().any(|v| v == &normalized)
782}
783
784fn first_token_and_rest(input: &str) -> (&str, &str) {
785 let trimmed = input.trim();
786 let mut parts = trimmed.splitn(2, char::is_whitespace);
787 let first = parts.next().unwrap_or("");
788 let rest = parts.next().unwrap_or("").trim();
789 (first, rest)
790}
791
792fn command_token_base(token: &str) -> &str {
793 if token.starts_with('/') {
794 token.split_once('@').map(|(base, _)| base).unwrap_or(token)
795 } else {
796 token
797 }
798}
799
800fn render_template(template: &str, vars: &HashMap<String, String>, shell_escape: bool) -> String {
801 let mut out = String::with_capacity(template.len());
802 let mut i = 0usize;
803 while let Some(start_rel) = template[i..].find("{{") {
804 let start = i + start_rel;
805 out.push_str(&template[i..start]);
806 let Some(end_rel) = template[start + 2..].find("}}") else {
807 out.push_str(&template[start..]);
808 return out;
809 };
810 let end = start + 2 + end_rel;
811 let key = template[start + 2..end].trim();
812 if let Some(value) = vars.get(key) {
813 if shell_escape {
814 out.push_str(&shell_escape_single(value));
815 } else {
816 out.push_str(value);
817 }
818 }
819 i = end + 2;
820 }
821 out.push_str(&template[i..]);
822 out
823}
824
825fn template_keys(template: &str) -> Vec<String> {
826 let mut out = Vec::new();
827 let mut i = 0usize;
828 while let Some(start_rel) = template[i..].find("{{") {
829 let start = i + start_rel;
830 let Some(end_rel) = template[start + 2..].find("}}") else {
831 break;
832 };
833 let end = start + 2 + end_rel;
834 let key = template[start + 2..end].trim();
835 if !key.is_empty() {
836 out.push(key.to_string());
837 }
838 i = end + 2;
839 }
840 out
841}
842
843fn render_template_checked(
844 template: &str,
845 vars: &HashMap<String, String>,
846 shell_escape: bool,
847) -> anyhow::Result<String> {
848 let missing: Vec<String> = template_keys(template)
849 .into_iter()
850 .filter(|k| !vars.contains_key(k))
851 .collect();
852 if !missing.is_empty() {
853 anyhow::bail!("missing template variable(s): {}", missing.join(", "));
854 }
855 Ok(render_template(template, vars, shell_escape))
856}
857
858fn shell_escape_single(value: &str) -> String {
859 if value.is_empty() {
860 return "''".to_string();
861 }
862 let replaced = value.replace('\'', "'\\''");
863 format!("'{replaced}'")
864}
865
866fn make_tool_working_dir(
867 base_working_dir: &Path,
868 isolation: WorkingDirIsolation,
869 caller_channel: &str,
870 caller_chat_id: i64,
871) -> PathBuf {
872 let mut auth = serde_json::Map::new();
873 auth.insert("caller_channel".to_string(), json!(caller_channel));
874 auth.insert("caller_chat_id".to_string(), json!(caller_chat_id));
875 auth.insert("control_chat_ids".to_string(), json!([]));
876
877 let mut input = serde_json::Map::new();
878 input.insert(
879 "__microclaw_auth".to_string(),
880 serde_json::Value::Object(auth),
881 );
882
883 crate::tools::resolve_tool_working_dir(
884 base_working_dir,
885 isolation,
886 &serde_json::Value::Object(input),
887 )
888}
889
890async fn execute_with_template(
891 config: &Config,
892 caller_channel: &str,
893 caller_chat_id: i64,
894 command_template: &str,
895 timeout_secs: u64,
896 execution_policy: PluginExecutionPolicy,
897 vars: &HashMap<String, String>,
898) -> anyhow::Result<SandboxExecResult> {
899 let command = render_template_checked(command_template, vars, true)?;
900
901 let base_working_dir = PathBuf::from(&config.working_dir);
902 let working_dir = make_tool_working_dir(
903 &base_working_dir,
904 config.working_dir_isolation,
905 caller_channel,
906 caller_chat_id,
907 );
908 tokio::fs::create_dir_all(&working_dir).await?;
909
910 let router = Arc::new(SandboxRouter::new(
911 config.sandbox.clone(),
912 &base_working_dir,
913 vec![],
914 ));
915 execute_command_with_policy(
916 router,
917 caller_channel,
918 caller_chat_id,
919 &command,
920 timeout_secs,
921 working_dir,
922 execution_policy,
923 )
924 .await
925}
926
927async fn execute_command_with_policy(
928 router: Arc<SandboxRouter>,
929 caller_channel: &str,
930 caller_chat_id: i64,
931 command: &str,
932 timeout_secs: u64,
933 working_dir: PathBuf,
934 execution_policy: PluginExecutionPolicy,
935) -> anyhow::Result<SandboxExecResult> {
936 let opts = SandboxExecOptions {
937 timeout: std::time::Duration::from_secs(timeout_secs.max(1)),
938 working_dir: Some(working_dir),
939 envs: std::collections::HashMap::new(),
940 env_files: Vec::new(),
941 };
942
943 if !execution_policy.is_allowed(router.mode(), router.runtime_available()) {
944 anyhow::bail!(
945 "execution policy '{:?}' denied: sandbox runtime unavailable or disabled",
946 execution_policy
947 );
948 }
949
950 let session_key = format!("{}-{}", caller_channel, caller_chat_id);
951 match execution_policy {
952 PluginExecutionPolicy::HostOnly => {
953 crate::internal::tool_runtime::sandbox::exec_host_command(command, &opts).await
954 }
955 PluginExecutionPolicy::SandboxOnly => router.exec(&session_key, command, &opts).await,
956 PluginExecutionPolicy::Dual => {
957 if router.mode() == SandboxMode::All {
958 router.exec(&session_key, command, &opts).await
959 } else {
960 crate::internal::tool_runtime::sandbox::exec_host_command(command, &opts).await
961 }
962 }
963 }
964}
965
966fn format_exec_result(result: &SandboxExecResult) -> String {
967 let mut out = String::new();
968 if !result.stdout.trim().is_empty() {
969 out.push_str(result.stdout.trim_end());
970 }
971 if !result.stderr.trim().is_empty() {
972 if !out.is_empty() {
973 out.push_str("\n\n");
974 }
975 out.push_str("STDERR:\n");
976 out.push_str(result.stderr.trim_end());
977 }
978 if out.is_empty() {
979 out = format!("Command completed with exit code {}", result.exit_code);
980 }
981 out
982}
983
984async fn execute_plugin_tool_spec(
985 config: &Config,
986 _plugin_name: &str,
987 spec: &PluginToolSpec,
988 input: serde_json::Value,
989) -> ToolResult {
990 let auth = auth_context_from_input(&input);
991 let caller_channel = auth
992 .as_ref()
993 .map(|a| a.caller_channel.as_str())
994 .unwrap_or("unknown");
995 let caller_chat_id = auth.as_ref().map(|a| a.caller_chat_id).unwrap_or(0);
996
997 if !is_channel_allowed(caller_channel, &spec.permissions.allowed_channels) {
998 return ToolResult::error(format!(
999 "Plugin tool '{}' is not allowed in channel '{}'.",
1000 spec.name, caller_channel
1001 ))
1002 .with_error_type("plugin_permission_denied");
1003 }
1004 if spec.permissions.require_control_chat && !config.control_chat_ids.contains(&caller_chat_id) {
1005 return ToolResult::error(format!(
1006 "Plugin tool '{}' requires control chat permission.",
1007 spec.name
1008 ))
1009 .with_error_type("plugin_permission_denied");
1010 }
1011
1012 let mut vars = HashMap::new();
1013 vars.insert("channel".to_string(), caller_channel.to_string());
1014 vars.insert("chat_id".to_string(), caller_chat_id.to_string());
1015 if let Some(map) = input.as_object() {
1016 for (k, v) in map {
1017 if k == "__microclaw_auth" {
1018 continue;
1019 }
1020 let value = if let Some(s) = v.as_str() {
1021 s.to_string()
1022 } else if v.is_number() || v.is_boolean() {
1023 v.to_string()
1024 } else {
1025 serde_json::to_string(v).unwrap_or_default()
1026 };
1027 vars.insert(k.clone(), value);
1028 }
1029 }
1030
1031 let rendered = match render_template_checked(&spec.run.command, &vars, true) {
1032 Ok(v) => v,
1033 Err(e) => {
1034 return ToolResult::error(format!("Plugin tool template error: {e}"))
1035 .with_error_type("plugin_template_error");
1036 }
1037 };
1038
1039 let base_working_dir = PathBuf::from(&config.working_dir);
1040 let working_dir = make_tool_working_dir(
1041 &base_working_dir,
1042 config.working_dir_isolation,
1043 caller_channel,
1044 caller_chat_id,
1045 );
1046 if let Err(e) = tokio::fs::create_dir_all(&working_dir).await {
1047 return ToolResult::error(format!(
1048 "Failed to create plugin working directory {}: {e}",
1049 working_dir.display()
1050 ))
1051 .with_error_type("plugin_spawn_error");
1052 }
1053
1054 let router = Arc::new(SandboxRouter::new(
1055 config.sandbox.clone(),
1056 &base_working_dir,
1057 vec![],
1058 ));
1059 let result = execute_command_with_policy(
1060 router,
1061 caller_channel,
1062 caller_chat_id,
1063 &rendered,
1064 spec.run.timeout_secs,
1065 working_dir,
1066 PluginTool::resolve_policy(spec),
1067 )
1068 .await;
1069
1070 match result {
1071 Ok(exec_result) => {
1072 let text = format_exec_result(&exec_result);
1073 if exec_result.exit_code == 0 {
1074 ToolResult::success(text).with_status_code(exec_result.exit_code)
1075 } else {
1076 ToolResult::error(format!("Exit code {}\n{}", exec_result.exit_code, text))
1077 .with_status_code(exec_result.exit_code)
1078 .with_error_type("plugin_process_exit")
1079 }
1080 }
1081 Err(e) => ToolResult::error(format!("Plugin tool execution failed: {e}"))
1082 .with_error_type("plugin_spawn_error"),
1083 }
1084}
1085
1086pub struct PluginTool {
1087 plugin_name: String,
1088 config: Config,
1089 spec: PluginToolSpec,
1090}
1091
1092impl PluginTool {
1093 pub fn new(config: &Config, plugin_name: String, spec: PluginToolSpec) -> Self {
1094 Self {
1095 plugin_name,
1096 config: config.clone(),
1097 spec,
1098 }
1099 }
1100
1101 fn resolve_runtime_spec(&self) -> PluginToolSpec {
1102 let manifests = load_plugin_manifests(&self.config);
1103 manifests
1104 .into_iter()
1105 .find(|m| m.name == self.plugin_name)
1106 .and_then(|m| {
1107 m.tools
1108 .into_iter()
1109 .find(|t| t.name.eq_ignore_ascii_case(&self.spec.name))
1110 })
1111 .unwrap_or_else(|| self.spec.clone())
1112 }
1113
1114 fn resolve_policy(spec: &PluginToolSpec) -> PluginExecutionPolicy {
1115 spec.permissions
1116 .execution_policy
1117 .or(spec.run.execution_policy)
1118 .unwrap_or(PluginExecutionPolicy::HostOnly)
1119 }
1120}
1121
1122#[async_trait]
1123impl Tool for PluginTool {
1124 fn name(&self) -> &str {
1125 &self.spec.name
1126 }
1127
1128 fn definition(&self) -> ToolDefinition {
1129 ToolDefinition {
1130 name: self.spec.name.clone(),
1131 description: format!("[plugin:{}] {}", self.plugin_name, self.spec.description),
1132 input_schema: if self.spec.input_schema.is_object() {
1133 self.spec.input_schema.clone()
1134 } else {
1135 default_plugin_tool_schema()
1136 },
1137 }
1138 }
1139
1140 async fn execute(&self, input: serde_json::Value) -> ToolResult {
1141 let runtime_spec = self.resolve_runtime_spec();
1142 execute_plugin_tool_spec(&self.config, &self.plugin_name, &runtime_spec, input).await
1143 }
1144}
1145
1146#[cfg(test)]
1147mod tests {
1148 use super::*;
1149 use serde_json::json;
1150 use std::path::PathBuf;
1151
1152 fn make_temp_plugins_dir(name: &str) -> PathBuf {
1153 let root = std::env::temp_dir().join(format!(
1154 "microclaw_plugin_tests_{}_{}",
1155 name,
1156 uuid::Uuid::new_v4()
1157 ));
1158 std::fs::create_dir_all(&root).unwrap();
1159 root
1160 }
1161
1162 fn config_with_plugins_dir(dir: &Path) -> Config {
1163 let mut cfg = crate::config::Config::test_defaults();
1164 cfg.plugins.enabled = true;
1165 cfg.plugins.dir = Some(dir.to_string_lossy().to_string());
1166 cfg.working_dir = dir.join("work").to_string_lossy().to_string();
1167 cfg
1168 }
1169
1170 #[test]
1171 fn test_plugin_report_orders_manifests_by_name() {
1172 let dir = make_temp_plugins_dir("order");
1176 for name in ["zebra", "mango", "alpha"] {
1177 std::fs::write(
1178 dir.join(format!("{name}.json")),
1179 serde_json::to_string(&json!({
1180 "name": name,
1181 "description": format!("{name} plugin"),
1182 "enabled": true,
1183 "commands": [],
1184 "tools": [],
1185 }))
1186 .unwrap(),
1187 )
1188 .unwrap();
1189 }
1190 let cfg = config_with_plugins_dir(&dir);
1191 let report = load_plugin_report(&cfg);
1192 let names: Vec<&str> = report.manifests.iter().map(|m| m.name.as_str()).collect();
1193 assert_eq!(names, vec!["alpha", "mango", "zebra"]);
1194
1195 let report_again = load_plugin_report(&cfg);
1197 let again: Vec<&str> = report_again
1198 .manifests
1199 .iter()
1200 .map(|m| m.name.as_str())
1201 .collect();
1202 assert_eq!(again, names);
1203 let _ = std::fs::remove_dir_all(&dir);
1204 }
1205
1206 #[test]
1207 fn test_command_matches_first_token() {
1208 assert!(command_matches("/hello world", "/hello"));
1209 assert!(command_matches(" /HELLO world", "/hello"));
1210 assert!(command_matches("/hello@MicroClawBot world", "/hello"));
1211 assert!(!command_matches("/hello-world", "/hello"));
1212 assert!(!command_matches("hello", "/hello"));
1213 }
1214
1215 #[test]
1216 fn test_plugin_report_surfaces_manifest_parse_errors() {
1217 let root = make_temp_plugins_dir("parse_error");
1218 std::fs::write(root.join("bad.yaml"), "name: [invalid").unwrap();
1219 let cfg = config_with_plugins_dir(&root);
1220 let report = load_plugin_report(&cfg);
1221 assert!(report
1222 .errors
1223 .iter()
1224 .any(|e| e.contains("failed to parse plugin manifest")));
1225 let _ = std::fs::remove_dir_all(root);
1226 }
1227
1228 #[test]
1229 fn test_normalize_manifest_adds_slash_and_channels() {
1230 let mut manifest = PluginManifest {
1231 name: " demo ".to_string(),
1232 enabled: true,
1233 commands: vec![PluginCommandSpec {
1234 command: "ping".to_string(),
1235 description: " test ".to_string(),
1236 response: None,
1237 run: None,
1238 permissions: PluginCommandPermissions {
1239 allowed_channels: vec![" Telegram ".to_string(), "".to_string()],
1240 require_control_chat: false,
1241 },
1242 }],
1243 tools: vec![],
1244 context_providers: vec![],
1245 };
1246 normalize_manifest(&mut manifest);
1247 assert_eq!(manifest.name, "demo");
1248 assert_eq!(manifest.commands[0].command, "/ping");
1249 assert_eq!(
1250 manifest.commands[0].permissions.allowed_channels,
1251 vec!["telegram".to_string()]
1252 );
1253 }
1254
1255 #[test]
1256 fn test_render_template_checked_missing_var() {
1257 let vars = HashMap::from([(String::from("a"), String::from("1"))]);
1258 let err = render_template_checked("x {{a}} {{b}}", &vars, false).unwrap_err();
1259 assert!(err.to_string().contains("missing template variable"));
1260 assert!(err.to_string().contains("b"));
1261 }
1262
1263 #[test]
1264 fn test_validate_manifest_reports_duplicates() {
1265 let manifest = PluginManifest {
1266 name: "dup".to_string(),
1267 enabled: true,
1268 commands: vec![
1269 PluginCommandSpec {
1270 command: "/a".to_string(),
1271 description: String::new(),
1272 response: None,
1273 run: Some(PluginExecSpec {
1274 command: "echo 1".to_string(),
1275 timeout_secs: 3,
1276 execution_policy: None,
1277 }),
1278 permissions: PluginCommandPermissions::default(),
1279 },
1280 PluginCommandSpec {
1281 command: "/a".to_string(),
1282 description: String::new(),
1283 response: None,
1284 run: None,
1285 permissions: PluginCommandPermissions::default(),
1286 },
1287 ],
1288 tools: vec![
1289 PluginToolSpec {
1290 name: "t".to_string(),
1291 description: "d".to_string(),
1292 input_schema: schema_object(json!({}), &[]),
1293 run: PluginExecSpec {
1294 command: "echo 1".to_string(),
1295 timeout_secs: 3,
1296 execution_policy: None,
1297 },
1298 permissions: PluginToolPermissions::default(),
1299 },
1300 PluginToolSpec {
1301 name: "t".to_string(),
1302 description: "d".to_string(),
1303 input_schema: schema_object(json!({}), &[]),
1304 run: PluginExecSpec {
1305 command: "echo 1".to_string(),
1306 timeout_secs: 3,
1307 execution_policy: None,
1308 },
1309 permissions: PluginToolPermissions::default(),
1310 },
1311 ],
1312 context_providers: vec![],
1313 };
1314
1315 let mut errors = Vec::new();
1316 validate_manifest(&manifest, Path::new("/tmp/dup.yaml"), &mut errors);
1317 assert!(!errors.is_empty());
1318 assert!(errors.iter().any(|e| e.contains("duplicate command")));
1319 assert!(errors.iter().any(|e| e.contains("duplicate tool")));
1320 }
1321
1322 #[test]
1323 fn test_validate_manifest_reports_context_provider_errors() {
1324 let manifest = PluginManifest {
1325 name: "ctxdup".to_string(),
1326 enabled: true,
1327 commands: vec![],
1328 tools: vec![],
1329 context_providers: vec![
1330 PluginContextProviderSpec {
1331 name: "dup".to_string(),
1332 kind: PluginContextKind::Prompt,
1333 description: String::new(),
1334 content: Some("a".to_string()),
1335 run: None,
1336 max_chars: 100,
1337 permissions: PluginContextPermissions::default(),
1338 },
1339 PluginContextProviderSpec {
1340 name: "dup".to_string(),
1341 kind: PluginContextKind::Document,
1342 description: String::new(),
1343 content: Some("b".to_string()),
1344 run: None,
1345 max_chars: 100,
1346 permissions: PluginContextPermissions::default(),
1347 },
1348 PluginContextProviderSpec {
1349 name: "invalid-both".to_string(),
1350 kind: PluginContextKind::Prompt,
1351 description: String::new(),
1352 content: Some("x".to_string()),
1353 run: Some(PluginExecSpec {
1354 command: "echo x".to_string(),
1355 timeout_secs: 3,
1356 execution_policy: None,
1357 }),
1358 max_chars: 100,
1359 permissions: PluginContextPermissions::default(),
1360 },
1361 PluginContextProviderSpec {
1362 name: "invalid-none".to_string(),
1363 kind: PluginContextKind::Prompt,
1364 description: String::new(),
1365 content: None,
1366 run: None,
1367 max_chars: 100,
1368 permissions: PluginContextPermissions::default(),
1369 },
1370 PluginContextProviderSpec {
1371 name: "invalid-run-empty".to_string(),
1372 kind: PluginContextKind::Prompt,
1373 description: String::new(),
1374 content: None,
1375 run: Some(PluginExecSpec {
1376 command: " ".to_string(),
1377 timeout_secs: 3,
1378 execution_policy: None,
1379 }),
1380 max_chars: 100,
1381 permissions: PluginContextPermissions::default(),
1382 },
1383 ],
1384 };
1385
1386 let mut errors = Vec::new();
1387 validate_manifest(&manifest, Path::new("/tmp/ctxdup.yaml"), &mut errors);
1388 assert!(errors
1389 .iter()
1390 .any(|e| e.contains("duplicate context provider")));
1391 assert!(errors
1392 .iter()
1393 .any(|e| e.contains("must set exactly one of content or run")));
1394 assert!(errors.iter().any(|e| e.contains("has empty run.command")));
1395 }
1396
1397 #[test]
1398 fn test_plugin_admin_requires_control_chat() {
1399 let root = make_temp_plugins_dir("admin_perm");
1400 let cfg = config_with_plugins_dir(&root);
1401 let out = handle_plugins_admin_command(&cfg, 123, "/plugins list").unwrap();
1402 assert!(out.contains("require control chat"));
1403 let _ = std::fs::remove_dir_all(root);
1404 }
1405
1406 #[test]
1407 fn test_plugin_admin_list_and_validate() {
1408 let root = make_temp_plugins_dir("admin_list_validate");
1409 std::fs::write(
1410 root.join("good.yaml"),
1411 r#"
1412name: good
1413enabled: true
1414commands:
1415 - command: /ok
1416 response: "ok"
1417"#,
1418 )
1419 .unwrap();
1420 std::fs::write(
1421 root.join("bad.yaml"),
1422 r#"
1423name: bad
1424enabled: true
1425commands:
1426 - command: /
1427 run:
1428 command: ""
1429"#,
1430 )
1431 .unwrap();
1432
1433 let mut cfg = config_with_plugins_dir(&root);
1434 cfg.control_chat_ids = vec![42];
1435 let list = handle_plugins_admin_command(&cfg, 42, "/plugins list").unwrap();
1436 assert!(list.contains("Plugins"));
1437 assert!(list.contains("good"));
1438 let validate = handle_plugins_admin_command(&cfg, 42, "/plugins validate").unwrap();
1439 assert!(validate.contains("issue"));
1440 assert!(validate.contains("invalid command"));
1441 let _ = std::fs::remove_dir_all(root);
1442 }
1443
1444 #[test]
1445 fn test_plugin_admin_supports_mention_suffix() {
1446 let root = make_temp_plugins_dir("admin_mention");
1447 std::fs::write(
1448 root.join("good.yaml"),
1449 r#"
1450name: good
1451enabled: true
1452commands:
1453 - command: /ok
1454 response: "ok"
1455"#,
1456 )
1457 .unwrap();
1458
1459 let mut cfg = config_with_plugins_dir(&root);
1460 cfg.control_chat_ids = vec![42];
1461 let out = handle_plugins_admin_command(&cfg, 42, "/plugins@AnyBotName list").unwrap();
1462 assert!(out.contains("Plugins"));
1463 let _ = std::fs::remove_dir_all(root);
1464 }
1465
1466 #[tokio::test]
1467 async fn test_plugin_slash_command_permissions_matrix() {
1468 let root = make_temp_plugins_dir("slash_perm");
1469 std::fs::write(
1470 root.join("cmd.yaml"),
1471 r#"
1472name: cmds
1473enabled: true
1474commands:
1475 - command: /hello
1476 response: "hello {{channel}}"
1477 permissions:
1478 allowed_channels: ["telegram"]
1479 - command: /secure
1480 response: "secure-ok"
1481 permissions:
1482 require_control_chat: true
1483"#,
1484 )
1485 .unwrap();
1486
1487 let mut cfg = config_with_plugins_dir(&root);
1488 cfg.control_chat_ids = vec![100];
1489
1490 let denied_channel = execute_plugin_slash_command(&cfg, "discord", 100, "/hello")
1491 .await
1492 .unwrap();
1493 assert!(denied_channel.contains("not allowed"));
1494
1495 let allowed_channel = execute_plugin_slash_command(&cfg, "telegram", 100, "/hello")
1496 .await
1497 .unwrap();
1498 assert!(allowed_channel.contains("hello telegram"));
1499
1500 let denied_control = execute_plugin_slash_command(&cfg, "telegram", 101, "/secure")
1501 .await
1502 .unwrap();
1503 assert!(denied_control.contains("requires control chat"));
1504
1505 let allowed_control = execute_plugin_slash_command(&cfg, "telegram", 100, "/secure")
1506 .await
1507 .unwrap();
1508 assert_eq!(allowed_control, "secure-ok");
1509 let _ = std::fs::remove_dir_all(root);
1510 }
1511
1512 #[tokio::test]
1513 async fn test_plugin_slash_command_run_command_can_use_args_variable() {
1514 let root = make_temp_plugins_dir("slash_args");
1515 std::fs::write(
1516 root.join("cmd.yaml"),
1517 r#"
1518name: argsplug
1519enabled: true
1520commands:
1521 - command: /echo
1522 run:
1523 command: "echo echo={{args}}"
1524 timeout_secs: 5
1525 execution_policy: host_only
1526"#,
1527 )
1528 .unwrap();
1529 let cfg = config_with_plugins_dir(&root);
1530 let out = execute_plugin_slash_command(&cfg, "web", 7, "/echo hello world")
1531 .await
1532 .unwrap();
1533 assert!(out.contains("echo=hello world"), "got: {out}");
1534 let _ = std::fs::remove_dir_all(root);
1535 }
1536
1537 #[tokio::test]
1538 async fn test_plugin_slash_command_mention_keeps_args() {
1539 let root = make_temp_plugins_dir("slash_mention_args");
1540 std::fs::write(
1541 root.join("cmd.yaml"),
1542 r#"
1543name: mentionargs
1544enabled: true
1545commands:
1546 - command: /echo
1547 run:
1548 command: "echo echo={{args}}"
1549 timeout_secs: 5
1550 execution_policy: host_only
1551"#,
1552 )
1553 .unwrap();
1554 let cfg = config_with_plugins_dir(&root);
1555 let out = execute_plugin_slash_command(&cfg, "telegram", 7, "/echo@SomeBot hello world")
1556 .await
1557 .unwrap();
1558 assert!(out.contains("echo=hello world"), "got: {out}");
1559 let _ = std::fs::remove_dir_all(root);
1560 }
1561
1562 #[tokio::test]
1563 async fn test_plugin_tool_execution_policy_matrix() {
1564 let root = make_temp_plugins_dir("tool_policy");
1565 std::fs::write(
1566 root.join("tool.yaml"),
1567 r#"
1568name: tools
1569enabled: true
1570tools:
1571 - name: sandbox_tool
1572 description: sandbox only
1573 input_schema:
1574 type: object
1575 properties: {}
1576 required: []
1577 run:
1578 command: "echo sandbox"
1579 timeout_secs: 5
1580 execution_policy: sandbox_only
1581 - name: dual_tool
1582 description: dual mode
1583 input_schema:
1584 type: object
1585 properties: {}
1586 required: []
1587 run:
1588 command: "echo dual-ok"
1589 timeout_secs: 5
1590 execution_policy: dual
1591"#,
1592 )
1593 .unwrap();
1594
1595 let cfg = config_with_plugins_dir(&root);
1596 let auth_input = json!({
1597 "__microclaw_auth": {
1598 "caller_channel": "web",
1599 "caller_chat_id": 9,
1600 "control_chat_ids": []
1601 }
1602 });
1603
1604 let blocked = execute_dynamic_plugin_tool(&cfg, "sandbox_tool", auth_input.clone())
1605 .await
1606 .unwrap();
1607 assert!(blocked.is_error);
1608 assert_eq!(blocked.error_type.as_deref(), Some("plugin_spawn_error"));
1609 assert!(blocked.content.contains("execution policy"));
1610
1611 let dual = execute_dynamic_plugin_tool(&cfg, "dual_tool", auth_input)
1612 .await
1613 .unwrap();
1614 assert!(!dual.is_error, "{}", dual.content);
1615 assert!(dual.content.contains("dual-ok"));
1616 let _ = std::fs::remove_dir_all(root);
1617 }
1618
1619 #[test]
1620 fn test_plugin_report_detects_duplicate_plugin_names_across_files() {
1621 let root = make_temp_plugins_dir("dupe_plugin_names");
1622 std::fs::write(
1623 root.join("a.yaml"),
1624 r#"
1625name: dup
1626enabled: true
1627commands:
1628 - command: /a
1629 response: "a"
1630"#,
1631 )
1632 .unwrap();
1633 std::fs::write(
1634 root.join("b.yaml"),
1635 r#"
1636name: dup
1637enabled: true
1638commands:
1639 - command: /b
1640 response: "b"
1641"#,
1642 )
1643 .unwrap();
1644
1645 let cfg = config_with_plugins_dir(&root);
1646 let report = load_plugin_report(&cfg);
1647 assert!(report
1648 .errors
1649 .iter()
1650 .any(|e| e.contains("duplicate plugin name")));
1651 let _ = std::fs::remove_dir_all(root);
1652 }
1653
1654 #[tokio::test]
1655 async fn test_collect_plugin_context_injections_static_and_run() {
1656 let root = make_temp_plugins_dir("ctx_static_run");
1657 std::fs::write(
1658 root.join("ctx.yaml"),
1659 r#"
1660name: ctxplug
1661enabled: true
1662context_providers:
1663 - name: prompt-static
1664 kind: prompt
1665 content: "Prompt for {{channel}} q={{query}}"
1666 - name: doc-run
1667 kind: document
1668 run:
1669 command: "echo Doc for chat={{chat_id}}"
1670 timeout_secs: 5
1671 execution_policy: host_only
1672"#,
1673 )
1674 .unwrap();
1675
1676 let cfg = config_with_plugins_dir(&root);
1677 let injections = collect_plugin_context_injections(&cfg, "web", 88, "status now").await;
1678 assert_eq!(injections.len(), 2);
1679 assert!(injections
1680 .iter()
1681 .any(|i| i.kind == PluginContextKind::Prompt && i.content.contains("q=status now")));
1682 assert!(injections
1683 .iter()
1684 .any(|i| i.kind == PluginContextKind::Document && i.content.contains("chat=88")));
1685 let _ = std::fs::remove_dir_all(root);
1686 }
1687
1688 #[tokio::test]
1689 async fn test_collect_plugin_context_injections_permissions() {
1690 let root = make_temp_plugins_dir("ctx_perms");
1691 std::fs::write(
1692 root.join("ctx.yaml"),
1693 r#"
1694name: ctxsecure
1695enabled: true
1696context_providers:
1697 - name: only-telegram
1698 kind: prompt
1699 content: "telegram-only"
1700 permissions:
1701 allowed_channels: ["telegram"]
1702 - name: control-only
1703 kind: prompt
1704 content: "control-only"
1705 permissions:
1706 require_control_chat: true
1707"#,
1708 )
1709 .unwrap();
1710
1711 let mut cfg = config_with_plugins_dir(&root);
1712 cfg.control_chat_ids = vec![999];
1713
1714 let denied = collect_plugin_context_injections(&cfg, "web", 1, "").await;
1715 assert!(denied.is_empty());
1716
1717 let allowed = collect_plugin_context_injections(&cfg, "telegram", 999, "").await;
1718 assert_eq!(allowed.len(), 2);
1719 let _ = std::fs::remove_dir_all(root);
1720 }
1721
1722 #[tokio::test]
1723 async fn test_collect_plugin_context_injections_truncates_content() {
1724 let root = make_temp_plugins_dir("ctx_truncate");
1725 std::fs::write(
1726 root.join("ctx.yaml"),
1727 r#"
1728name: ctxtrim
1729enabled: true
1730context_providers:
1731 - name: trunc
1732 kind: prompt
1733 max_chars: 10
1734 content: "12345678901234567890"
1735"#,
1736 )
1737 .unwrap();
1738
1739 let cfg = config_with_plugins_dir(&root);
1740 let injections = collect_plugin_context_injections(&cfg, "web", 1, "").await;
1741 assert_eq!(injections.len(), 1);
1742 assert_eq!(injections[0].content, "1234567890");
1743 let _ = std::fs::remove_dir_all(root);
1744 }
1745
1746 #[tokio::test]
1747 async fn test_collect_plugin_context_injections_skips_failed_run_provider() {
1748 let root = make_temp_plugins_dir("ctx_failed_run");
1749 std::fs::write(
1750 root.join("ctx.yaml"),
1751 r#"
1752name: ctxfail
1753enabled: true
1754context_providers:
1755 - name: good
1756 kind: prompt
1757 content: "ok"
1758 - name: bad-run
1759 kind: document
1760 run:
1761 command: "false"
1762 timeout_secs: 3
1763 execution_policy: host_only
1764"#,
1765 )
1766 .unwrap();
1767
1768 let cfg = config_with_plugins_dir(&root);
1769 let injections = collect_plugin_context_injections(&cfg, "web", 1, "").await;
1770 assert_eq!(injections.len(), 1);
1771 assert_eq!(injections[0].provider_name, "good");
1772 assert_eq!(injections[0].content, "ok");
1773 let _ = std::fs::remove_dir_all(root);
1774 }
1775}