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