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    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}