Skip to main content

microclaw_engine/
plugins.rs

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    // `read_dir` yields entries in arbitrary filesystem order, which would make
262    // the plugin tool list (part of the cached prompt prefix) reshuffle between
263    // restarts. Sort by manifest name so the prefix stays byte-stable.
264    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        // `read_dir` order is arbitrary, so the report must impose its own
1173        // ordering. Plugin tool definitions come from this list and sit in the
1174        // provider's cached prefix, so an unstable order silently breaks caching.
1175        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        // Repeat: the order must not depend on prior enumeration state.
1196        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}