Skip to main content

loong_kernel/
plugin.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    fs,
4    path::{Path, PathBuf},
5};
6
7use semver::{Version, VersionReq};
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11use crate::{
12    contracts::Capability,
13    errors::IntegrationError,
14    integration::{AutoProvisionRequest, ChannelConfig, IntegrationCatalog, ProviderConfig},
15    pack::VerticalPackManifest,
16};
17
18pub const PACKAGE_MANIFEST_FILE_NAME: &str = "loong.plugin.json";
19const OPENCLAW_PACKAGE_MANIFEST_FILE_NAME: &str = "openclaw.plugin.json";
20const PACKAGE_JSON_FILE_NAME: &str = "package.json";
21const OPENCLAW_MODERN_COMPATIBILITY_ADAPTER_FAMILY: &str = "openclaw-modern-compat";
22const OPENCLAW_LEGACY_COMPATIBILITY_ADAPTER_FAMILY: &str = "openclaw-legacy-compat";
23pub const CURRENT_PLUGIN_MANIFEST_API_VERSION: &str = "v1alpha1";
24pub const CURRENT_PLUGIN_HOST_API: &str = "loong-plugin/v1";
25const RESERVED_PACKAGE_METADATA_PREFIX: &str = "plugin_";
26pub(crate) const PLUGIN_MANIFEST_API_VERSION_METADATA_KEY: &str = "plugin_manifest_api_version";
27pub(crate) const PLUGIN_VERSION_METADATA_KEY: &str = "plugin_version";
28pub(crate) const PLUGIN_DIALECT_METADATA_KEY: &str = "plugin_dialect";
29pub(crate) const PLUGIN_DIALECT_VERSION_METADATA_KEY: &str = "plugin_dialect_version";
30pub(crate) const PLUGIN_COMPATIBILITY_MODE_METADATA_KEY: &str = "plugin_compatibility_mode";
31pub(crate) const PLUGIN_COMPATIBILITY_SHIM_ID_METADATA_KEY: &str = "plugin_compatibility_shim_id";
32pub(crate) const PLUGIN_COMPATIBILITY_SHIM_FAMILY_METADATA_KEY: &str =
33    "plugin_compatibility_shim_family";
34pub(crate) const PLUGIN_SLOT_CLAIMS_METADATA_KEY: &str = "plugin_slot_claims_json";
35pub(crate) const PLUGIN_COMPATIBILITY_HOST_API_METADATA_KEY: &str = "plugin_compatibility_host_api";
36pub(crate) const PLUGIN_COMPATIBILITY_HOST_VERSION_REQ_METADATA_KEY: &str =
37    "plugin_compatibility_host_version_req";
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
40#[serde(rename_all = "kebab-case")]
41pub enum PluginTrustTier {
42    Official,
43    #[serde(alias = "verified_community")]
44    VerifiedCommunity,
45    #[default]
46    Unverified,
47}
48
49impl PluginTrustTier {
50    #[must_use]
51    pub fn as_str(self) -> &'static str {
52        match self {
53            Self::Official => "official",
54            Self::VerifiedCommunity => "verified-community",
55            Self::Unverified => "unverified",
56        }
57    }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
61#[serde(rename_all = "snake_case")]
62pub enum PluginSetupMode {
63    #[default]
64    MetadataOnly,
65    GovernedEntry,
66}
67
68impl PluginSetupMode {
69    #[must_use]
70    pub fn as_str(self) -> &'static str {
71        match self {
72            Self::MetadataOnly => "metadata_only",
73            Self::GovernedEntry => "governed_entry",
74        }
75    }
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
79#[serde(deny_unknown_fields)]
80pub struct PluginSetup {
81    #[serde(default)]
82    pub mode: PluginSetupMode,
83    #[serde(default)]
84    pub surface: Option<String>,
85    #[serde(default)]
86    pub required_env_vars: Vec<String>,
87    #[serde(default)]
88    pub recommended_env_vars: Vec<String>,
89    #[serde(default)]
90    pub required_config_keys: Vec<String>,
91    #[serde(default)]
92    pub default_env_var: Option<String>,
93    #[serde(default)]
94    pub docs_urls: Vec<String>,
95    #[serde(default)]
96    pub remediation: Option<String>,
97}
98
99impl PluginSetup {
100    #[must_use]
101    pub fn normalized(self) -> Self {
102        let mode = self.mode;
103        let surface = normalize_optional_manifest_string(self.surface);
104        let required_env_vars = normalize_manifest_string_list(self.required_env_vars);
105        let recommended_env_vars = normalize_manifest_string_list(self.recommended_env_vars);
106        let required_config_keys = normalize_manifest_string_list(self.required_config_keys);
107        let default_env_var = normalize_optional_manifest_string(self.default_env_var);
108        let docs_urls = normalize_manifest_string_list(self.docs_urls);
109        let remediation = normalize_optional_manifest_string(self.remediation);
110
111        Self {
112            mode,
113            surface,
114            required_env_vars,
115            recommended_env_vars,
116            required_config_keys,
117            default_env_var,
118            docs_urls,
119            remediation,
120        }
121    }
122
123    #[must_use]
124    pub fn is_effectively_empty(&self) -> bool {
125        let has_surface = self.surface.is_some();
126        let has_required_env_vars = !self.required_env_vars.is_empty();
127        let has_recommended_env_vars = !self.recommended_env_vars.is_empty();
128        let has_required_config_keys = !self.required_config_keys.is_empty();
129        let has_default_env_var = self.default_env_var.is_some();
130        let has_docs_urls = !self.docs_urls.is_empty();
131        let has_remediation = self.remediation.is_some();
132        let has_non_default_payload = has_surface
133            || has_required_env_vars
134            || has_recommended_env_vars
135            || has_required_config_keys
136            || has_default_env_var
137            || has_docs_urls
138            || has_remediation;
139
140        if has_non_default_payload {
141            return false;
142        }
143
144        matches!(self.mode, PluginSetupMode::MetadataOnly)
145    }
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
149#[serde(rename_all = "snake_case")]
150pub enum PluginSlotMode {
151    #[default]
152    Exclusive,
153    Shared,
154    Advisory,
155}
156
157impl PluginSlotMode {
158    #[must_use]
159    pub fn as_str(self) -> &'static str {
160        match self {
161            Self::Exclusive => "exclusive",
162            Self::Shared => "shared",
163            Self::Advisory => "advisory",
164        }
165    }
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
169#[serde(deny_unknown_fields)]
170pub struct PluginSlotClaim {
171    pub slot: String,
172    pub key: String,
173    pub mode: PluginSlotMode,
174}
175
176impl PluginSlotClaim {
177    #[must_use]
178    pub fn normalized(self) -> Self {
179        Self {
180            slot: self.slot.trim().to_owned(),
181            key: self.key.trim().to_owned(),
182            mode: self.mode,
183        }
184    }
185
186    #[must_use]
187    pub fn canonical_label(&self) -> String {
188        format!("{}#{}@{}", self.slot, self.key, self.mode.as_str())
189    }
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
193#[serde(deny_unknown_fields)]
194pub struct PluginCompatibility {
195    #[serde(default)]
196    pub host_api: Option<String>,
197    #[serde(default)]
198    pub host_version_req: Option<String>,
199}
200
201impl PluginCompatibility {
202    #[must_use]
203    pub fn normalized(self) -> Self {
204        Self {
205            host_api: normalize_optional_manifest_string(self.host_api),
206            host_version_req: normalize_optional_manifest_string(self.host_version_req),
207        }
208    }
209
210    #[must_use]
211    pub fn is_effectively_empty(&self) -> bool {
212        self.host_api.is_none() && self.host_version_req.is_none()
213    }
214}
215
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217pub struct PluginManifest {
218    #[serde(default)]
219    pub api_version: Option<String>,
220    #[serde(default)]
221    pub version: Option<String>,
222    pub plugin_id: String,
223    pub provider_id: String,
224    pub connector_name: String,
225    pub channel_id: Option<String>,
226    pub endpoint: Option<String>,
227    pub capabilities: BTreeSet<Capability>,
228    #[serde(default)]
229    pub trust_tier: PluginTrustTier,
230    pub metadata: BTreeMap<String, String>,
231    #[serde(default)]
232    pub summary: Option<String>,
233    #[serde(default)]
234    pub tags: Vec<String>,
235    #[serde(default)]
236    pub input_examples: Vec<Value>,
237    #[serde(default)]
238    pub output_examples: Vec<Value>,
239    #[serde(default)]
240    pub defer_loading: bool,
241    #[serde(default)]
242    pub setup: Option<PluginSetup>,
243    #[serde(default)]
244    pub slot_claims: Vec<PluginSlotClaim>,
245    #[serde(default)]
246    pub compatibility: Option<PluginCompatibility>,
247}
248
249#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
250#[serde(rename_all = "snake_case")]
251pub enum PluginSourceKind {
252    PackageManifest,
253    EmbeddedSource,
254}
255
256impl PluginSourceKind {
257    #[must_use]
258    pub fn as_str(self) -> &'static str {
259        match self {
260            Self::PackageManifest => "package_manifest",
261            Self::EmbeddedSource => "embedded_source",
262        }
263    }
264}
265
266#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
267#[serde(rename_all = "snake_case")]
268pub enum PluginContractDialect {
269    #[default]
270    LoongPackageManifest,
271    LoongEmbeddedSource,
272    OpenClawModernManifest,
273    OpenClawLegacyPackage,
274}
275
276impl PluginContractDialect {
277    #[must_use]
278    pub fn as_str(self) -> &'static str {
279        match self {
280            Self::LoongPackageManifest => "loong_package_manifest",
281            Self::LoongEmbeddedSource => "loong_embedded_source",
282            Self::OpenClawModernManifest => "openclaw_modern_manifest",
283            Self::OpenClawLegacyPackage => "openclaw_legacy_package",
284        }
285    }
286}
287
288#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
289#[serde(rename_all = "snake_case")]
290pub enum PluginCompatibilityMode {
291    #[default]
292    Native,
293    OpenClawModern,
294    OpenClawLegacy,
295}
296
297impl PluginCompatibilityMode {
298    #[must_use]
299    pub fn as_str(self) -> &'static str {
300        match self {
301            Self::Native => "native",
302            Self::OpenClawModern => "openclaw_modern",
303            Self::OpenClawLegacy => "openclaw_legacy",
304        }
305    }
306
307    #[must_use]
308    pub const fn is_native(self) -> bool {
309        matches!(self, Self::Native)
310    }
311}
312
313#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
314pub struct PluginCompatibilityShim {
315    pub shim_id: String,
316    pub family: String,
317}
318
319impl PluginCompatibilityShim {
320    #[must_use]
321    pub fn for_mode(mode: PluginCompatibilityMode) -> Option<Self> {
322        match mode {
323            PluginCompatibilityMode::Native => None,
324            PluginCompatibilityMode::OpenClawModern => Some(Self {
325                shim_id: OPENCLAW_MODERN_COMPATIBILITY_ADAPTER_FAMILY.to_owned(),
326                family: OPENCLAW_MODERN_COMPATIBILITY_ADAPTER_FAMILY.to_owned(),
327            }),
328            PluginCompatibilityMode::OpenClawLegacy => Some(Self {
329                shim_id: OPENCLAW_LEGACY_COMPATIBILITY_ADAPTER_FAMILY.to_owned(),
330                family: OPENCLAW_LEGACY_COMPATIBILITY_ADAPTER_FAMILY.to_owned(),
331            }),
332        }
333    }
334}
335
336#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
337#[serde(rename_all = "snake_case")]
338pub enum PluginDiagnosticSeverity {
339    Info,
340    Warning,
341    Error,
342}
343
344impl PluginDiagnosticSeverity {
345    #[must_use]
346    pub fn as_str(self) -> &'static str {
347        match self {
348            Self::Info => "info",
349            Self::Warning => "warning",
350            Self::Error => "error",
351        }
352    }
353}
354
355#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
356#[serde(rename_all = "snake_case")]
357pub enum PluginDiagnosticPhase {
358    #[default]
359    Unknown,
360    Scan,
361    Translation,
362    Activation,
363}
364
365impl PluginDiagnosticPhase {
366    #[must_use]
367    pub fn as_str(self) -> &'static str {
368        match self {
369            Self::Unknown => "unknown",
370            Self::Scan => "scan",
371            Self::Translation => "translation",
372            Self::Activation => "activation",
373        }
374    }
375}
376
377#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
378#[serde(rename_all = "snake_case")]
379pub enum PluginDiagnosticCode {
380    EmbeddedSourceLegacyContract,
381    LegacyMetadataVersion,
382    ShadowedEmbeddedSource,
383    ForeignDialectContract,
384    LegacyOpenClawContract,
385    InvalidManifestContract,
386    CompatibilityShimRequired,
387    IncompatibleHost,
388    UnsupportedBridge,
389    UnsupportedAdapterFamily,
390    SlotClaimConflict,
391}
392
393impl PluginDiagnosticCode {
394    #[must_use]
395    pub fn as_str(self) -> &'static str {
396        match self {
397            Self::EmbeddedSourceLegacyContract => "embedded_source_legacy_contract",
398            Self::LegacyMetadataVersion => "legacy_metadata_version",
399            Self::ShadowedEmbeddedSource => "shadowed_embedded_source",
400            Self::ForeignDialectContract => "foreign_dialect_contract",
401            Self::LegacyOpenClawContract => "legacy_openclaw_contract",
402            Self::InvalidManifestContract => "invalid_manifest_contract",
403            Self::CompatibilityShimRequired => "compatibility_shim_required",
404            Self::IncompatibleHost => "incompatible_host",
405            Self::UnsupportedBridge => "unsupported_bridge",
406            Self::UnsupportedAdapterFamily => "unsupported_adapter_family",
407            Self::SlotClaimConflict => "slot_claim_conflict",
408        }
409    }
410}
411
412#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
413pub struct PluginDiagnosticFinding {
414    pub code: PluginDiagnosticCode,
415    pub severity: PluginDiagnosticSeverity,
416    #[serde(default)]
417    pub phase: PluginDiagnosticPhase,
418    #[serde(default)]
419    pub blocking: bool,
420    #[serde(default)]
421    pub plugin_id: Option<String>,
422    #[serde(default)]
423    pub source_path: Option<String>,
424    #[serde(default)]
425    pub source_kind: Option<PluginSourceKind>,
426    #[serde(default)]
427    pub field_path: Option<String>,
428    pub message: String,
429    #[serde(default)]
430    pub remediation: Option<String>,
431}
432
433impl PluginDiagnosticFinding {
434    #[must_use]
435    pub fn matches_plugin(&self, source_path: &str, plugin_id: &str) -> bool {
436        self.source_path.as_deref() == Some(source_path)
437            && self.plugin_id.as_deref() == Some(plugin_id)
438    }
439}
440
441#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
442pub struct PluginDescriptor {
443    pub path: String,
444    pub source_kind: PluginSourceKind,
445    pub dialect: PluginContractDialect,
446    pub dialect_version: Option<String>,
447    pub compatibility_mode: PluginCompatibilityMode,
448    pub package_root: String,
449    pub package_manifest_path: Option<String>,
450    pub language: String,
451    pub manifest: PluginManifest,
452}
453
454#[must_use]
455pub fn format_plugin_provenance_summary(
456    source_kind: PluginSourceKind,
457    source_path: &str,
458    package_manifest_path: Option<&str>,
459) -> String {
460    if let Some(package_manifest_path) = package_manifest_path
461        && !matches!(source_kind, PluginSourceKind::PackageManifest)
462    {
463        return format!(
464            "{}:{} (package_manifest:{package_manifest_path})",
465            source_kind.as_str(),
466            source_path
467        );
468    }
469
470    format!("{}:{source_path}", source_kind.as_str())
471}
472
473#[must_use]
474pub fn plugin_provenance_summary_for_descriptor(descriptor: &PluginDescriptor) -> String {
475    format_plugin_provenance_summary(
476        descriptor.source_kind,
477        &descriptor.path,
478        descriptor.package_manifest_path.as_deref(),
479    )
480}
481
482#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
483pub struct PluginScanReport {
484    pub scanned_files: usize,
485    pub matched_plugins: usize,
486    #[serde(default)]
487    pub diagnostic_findings: Vec<PluginDiagnosticFinding>,
488    pub descriptors: Vec<PluginDescriptor>,
489}
490
491#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
492pub struct PluginAbsorbReport {
493    pub absorbed_plugins: usize,
494    pub provider_upserts: usize,
495    pub channel_upserts: usize,
496    pub connectors_added_to_pack: BTreeSet<String>,
497    pub capabilities_added_to_pack: BTreeSet<Capability>,
498}
499
500#[derive(Debug, Default)]
501pub struct PluginScanner;
502
503impl PluginScanner {
504    #[must_use]
505    pub fn new() -> Self {
506        Self
507    }
508
509    pub fn scan_path<P: AsRef<Path>>(&self, root: P) -> Result<PluginScanReport, IntegrationError> {
510        let root = root.as_ref();
511        if !root.exists() {
512            return Err(IntegrationError::PluginScanRootNotFound(
513                root.display().to_string(),
514            ));
515        }
516
517        let mut report = PluginScanReport::default();
518        let mut files = Vec::new();
519        collect_files(root, &mut files)?;
520        files.sort();
521        report.scanned_files = files.len();
522
523        let package_manifest_descriptors = collect_package_manifest_descriptors(&files)?;
524        let source_manifest_collection = collect_source_manifest_descriptors(&files)?;
525        report
526            .diagnostic_findings
527            .extend(source_manifest_collection.diagnostic_findings.clone());
528        for descriptor in package_manifest_descriptors.values() {
529            report
530                .diagnostic_findings
531                .extend(descriptor_contract_diagnostic_findings(descriptor));
532        }
533        let source_manifest_descriptors = source_manifest_collection.descriptors;
534        let package_manifests_by_root =
535            collect_package_manifest_descriptors_by_root(&package_manifest_descriptors);
536
537        validate_package_manifest_conflicts(
538            &package_manifests_by_root,
539            &source_manifest_descriptors,
540        )?;
541
542        for (source_path, source_descriptor) in &source_manifest_descriptors {
543            let Some(package_descriptor) =
544                find_covering_package_manifest_descriptor(source_path, &package_manifests_by_root)
545            else {
546                continue;
547            };
548
549            report
550                .diagnostic_findings
551                .push(shadowed_embedded_source_finding(
552                    source_descriptor,
553                    package_descriptor,
554                ));
555        }
556
557        for path in &files {
558            if let Some(descriptor) = package_manifest_descriptors.get(path) {
559                push_descriptor(&mut report, descriptor.clone());
560                continue;
561            }
562
563            let covering_package_manifest =
564                find_covering_package_manifest_descriptor(path, &package_manifests_by_root);
565
566            if covering_package_manifest.is_some() {
567                continue;
568            }
569
570            if let Some(descriptor) = source_manifest_descriptors.get(path) {
571                push_descriptor(&mut report, descriptor.clone());
572            }
573        }
574
575        Ok(report)
576    }
577
578    /// Absorb plugin descriptors into the catalog and pack manifest.
579    ///
580    /// Uses clone-and-restore rollback: if any operation fails partway through,
581    /// both `catalog` and `pack` are restored to their pre-absorb state so
582    /// callers never observe a partially-mutated configuration.
583    pub fn absorb(
584        &self,
585        catalog: &mut IntegrationCatalog,
586        pack: &mut VerticalPackManifest,
587        report: &PluginScanReport,
588    ) -> Result<PluginAbsorbReport, IntegrationError> {
589        let catalog_snapshot = catalog.clone();
590        let pack_snapshot = pack.clone();
591
592        let result = self.absorb_inner(catalog, pack, report);
593
594        if result.is_err() {
595            *catalog = catalog_snapshot;
596            *pack = pack_snapshot;
597        }
598
599        result
600    }
601
602    fn absorb_inner(
603        &self,
604        catalog: &mut IntegrationCatalog,
605        pack: &mut VerticalPackManifest,
606        report: &PluginScanReport,
607    ) -> Result<PluginAbsorbReport, IntegrationError> {
608        let mut absorbed = PluginAbsorbReport::default();
609        let mut claimed_slots = collect_claimed_slots(catalog)?;
610
611        for descriptor in &report.descriptors {
612            let manifest = &descriptor.manifest;
613
614            if manifest.provider_id.is_empty() {
615                return Err(IntegrationError::PluginAbsorbFailed {
616                    plugin_id: manifest.plugin_id.clone(),
617                    reason: "provider_id must not be empty".to_owned(),
618                });
619            }
620
621            if manifest.connector_name.is_empty() {
622                return Err(IntegrationError::PluginAbsorbFailed {
623                    plugin_id: manifest.plugin_id.clone(),
624                    reason: "connector_name must not be empty".to_owned(),
625                });
626            }
627
628            validate_plugin_slot_claims(manifest)?;
629            validate_plugin_host_compatibility(manifest)?;
630            register_plugin_slot_claims(manifest, &mut claimed_slots)?;
631
632            let mut provider_metadata = manifest.metadata.clone();
633            stamp_plugin_manifest_contract_metadata(&mut provider_metadata, manifest);
634            stamp_plugin_descriptor_contract_metadata(&mut provider_metadata, descriptor);
635            stamp_plugin_slot_claims_metadata(&mut provider_metadata, &manifest.slot_claims)?;
636            stamp_plugin_compatibility_metadata(
637                &mut provider_metadata,
638                manifest.compatibility.as_ref(),
639            );
640            catalog.upsert_provider(ProviderConfig {
641                provider_id: manifest.provider_id.clone(),
642                connector_name: manifest.connector_name.clone(),
643                version: manifest
644                    .version
645                    .clone()
646                    .or_else(|| manifest.metadata.get("version").cloned())
647                    .unwrap_or_else(|| "0.1.0".to_owned()),
648                metadata: provider_metadata,
649            });
650            absorbed.provider_upserts = absorbed.provider_upserts.saturating_add(1);
651
652            if let Some(channel_id) = &manifest.channel_id {
653                catalog.upsert_channel(ChannelConfig {
654                    channel_id: channel_id.clone(),
655                    provider_id: manifest.provider_id.clone(),
656                    endpoint: manifest.endpoint.clone().unwrap_or_else(|| {
657                        format!("https://{}.local/{channel_id}/invoke", manifest.provider_id)
658                    }),
659                    enabled: true,
660                    metadata: BTreeMap::from([(
661                        "source_plugin".to_owned(),
662                        manifest.plugin_id.clone(),
663                    )]),
664                });
665                absorbed.channel_upserts = absorbed.channel_upserts.saturating_add(1);
666            }
667
668            if pack
669                .allowed_connectors
670                .insert(manifest.connector_name.clone())
671            {
672                absorbed
673                    .connectors_added_to_pack
674                    .insert(manifest.connector_name.clone());
675            }
676
677            if pack
678                .granted_capabilities
679                .insert(Capability::InvokeConnector)
680            {
681                absorbed
682                    .capabilities_added_to_pack
683                    .insert(Capability::InvokeConnector);
684            }
685
686            for capability in &manifest.capabilities {
687                if pack.granted_capabilities.insert(*capability) {
688                    absorbed.capabilities_added_to_pack.insert(*capability);
689                }
690            }
691
692            absorbed.absorbed_plugins = absorbed.absorbed_plugins.saturating_add(1);
693        }
694
695        Ok(absorbed)
696    }
697
698    #[must_use]
699    pub fn to_auto_provision_requests(
700        &self,
701        report: &PluginScanReport,
702    ) -> Vec<AutoProvisionRequest> {
703        report
704            .descriptors
705            .iter()
706            .map(|descriptor| AutoProvisionRequest {
707                provider_id: descriptor.manifest.provider_id.clone(),
708                channel_id: descriptor
709                    .manifest
710                    .channel_id
711                    .clone()
712                    .unwrap_or_else(|| format!("{}-default", descriptor.manifest.provider_id)),
713                connector_name: Some(descriptor.manifest.connector_name.clone()),
714                endpoint: descriptor.manifest.endpoint.clone(),
715                required_capabilities: descriptor.manifest.capabilities.clone(),
716            })
717            .collect()
718    }
719}
720
721#[derive(Debug, Default)]
722struct SourceManifestCollection {
723    descriptors: BTreeMap<PathBuf, PluginDescriptor>,
724    diagnostic_findings: Vec<PluginDiagnosticFinding>,
725}
726
727#[derive(Debug, Deserialize)]
728#[serde(deny_unknown_fields)]
729struct PackageManifestDocument {
730    #[serde(default)]
731    api_version: Option<String>,
732    #[serde(default)]
733    version: Option<String>,
734    plugin_id: String,
735    provider_id: String,
736    connector_name: String,
737    channel_id: Option<String>,
738    endpoint: Option<String>,
739    capabilities: BTreeSet<Capability>,
740    metadata: BTreeMap<String, String>,
741    #[serde(default)]
742    trust_tier: PluginTrustTier,
743    #[serde(default)]
744    summary: Option<String>,
745    #[serde(default)]
746    tags: Vec<String>,
747    #[serde(default)]
748    input_examples: Vec<Value>,
749    #[serde(default)]
750    output_examples: Vec<Value>,
751    #[serde(default)]
752    defer_loading: bool,
753    #[serde(default)]
754    setup: Option<PluginSetup>,
755    #[serde(default)]
756    slot_claims: Vec<PluginSlotClaim>,
757    #[serde(default)]
758    compatibility: Option<PluginCompatibility>,
759}
760
761impl PackageManifestDocument {
762    fn into_manifest(self) -> PluginManifest {
763        PluginManifest {
764            api_version: self.api_version,
765            version: self.version,
766            plugin_id: self.plugin_id,
767            provider_id: self.provider_id,
768            connector_name: self.connector_name,
769            channel_id: self.channel_id,
770            endpoint: self.endpoint,
771            capabilities: self.capabilities,
772            trust_tier: self.trust_tier,
773            metadata: self.metadata,
774            summary: self.summary,
775            tags: self.tags,
776            input_examples: self.input_examples,
777            output_examples: self.output_examples,
778            defer_loading: self.defer_loading,
779            setup: self.setup,
780            slot_claims: self.slot_claims,
781            compatibility: self.compatibility,
782        }
783    }
784}
785
786#[derive(Debug, Deserialize)]
787struct OpenClawManifestDocument {
788    id: String,
789    #[serde(default, rename = "configSchema")]
790    config_schema: Option<Value>,
791    #[serde(default, rename = "enabledByDefault")]
792    enabled_by_default: bool,
793    #[serde(default)]
794    kind: Option<String>,
795    #[serde(default)]
796    channels: Vec<String>,
797    #[serde(default)]
798    providers: Vec<String>,
799    #[serde(default, rename = "providerAuthEnvVars")]
800    provider_auth_env_vars: BTreeMap<String, Vec<String>>,
801    #[serde(default, rename = "providerAuthChoices")]
802    provider_auth_choices: Vec<Value>,
803    #[serde(default)]
804    skills: Vec<String>,
805    #[serde(default)]
806    name: Option<String>,
807    #[serde(default)]
808    description: Option<String>,
809    #[serde(default)]
810    version: Option<String>,
811    #[serde(default, rename = "uiHints")]
812    ui_hints: BTreeMap<String, Value>,
813}
814
815#[derive(Debug, Deserialize, Default)]
816struct OpenClawPackageJsonDocument {
817    #[serde(default)]
818    name: Option<String>,
819    #[serde(default)]
820    version: Option<String>,
821    #[serde(default)]
822    description: Option<String>,
823    #[serde(default)]
824    openclaw: Option<OpenClawPackageMetadataDocument>,
825}
826
827#[derive(Debug, Deserialize, Default)]
828struct OpenClawPackageMetadataDocument {
829    #[serde(default)]
830    extensions: Vec<String>,
831    #[serde(default, rename = "setupEntry")]
832    setup_entry: Option<String>,
833    #[serde(default)]
834    channel: Option<OpenClawPackageChannelDocument>,
835    #[serde(default)]
836    install: Option<OpenClawPackageInstallDocument>,
837}
838
839#[derive(Debug, Deserialize, Default)]
840struct OpenClawPackageChannelDocument {
841    #[serde(default)]
842    id: Option<String>,
843    #[serde(default)]
844    label: Option<String>,
845    #[serde(default, rename = "docsPath")]
846    docs_path: Option<String>,
847    #[serde(default)]
848    blurb: Option<String>,
849    #[serde(default)]
850    aliases: Vec<String>,
851}
852
853#[derive(Debug, Deserialize, Default)]
854struct OpenClawPackageInstallDocument {
855    #[serde(default, rename = "npmSpec")]
856    npm_spec: Option<String>,
857    #[serde(default, rename = "localPath")]
858    local_path: Option<String>,
859    #[serde(default, rename = "minHostVersion")]
860    min_host_version: Option<String>,
861}
862
863fn collect_files(path: &Path, acc: &mut Vec<PathBuf>) -> Result<(), IntegrationError> {
864    let metadata = fs::metadata(path).map_err(|error| IntegrationError::PluginFileRead {
865        path: path.display().to_string(),
866        reason: error.to_string(),
867    })?;
868
869    if metadata.is_file() {
870        acc.push(path.to_path_buf());
871        return Ok(());
872    }
873
874    for entry in fs::read_dir(path).map_err(|error| IntegrationError::PluginFileRead {
875        path: path.display().to_string(),
876        reason: error.to_string(),
877    })? {
878        let entry = entry.map_err(|error| IntegrationError::PluginFileRead {
879            path: path.display().to_string(),
880            reason: error.to_string(),
881        })?;
882        let child = entry.path();
883        if child.is_dir() {
884            if should_skip_dir(&child) {
885                continue;
886            }
887            collect_files(&child, acc)?;
888        } else if child.is_file() {
889            acc.push(child);
890        }
891    }
892    Ok(())
893}
894
895fn collect_package_manifest_descriptors(
896    files: &[PathBuf],
897) -> Result<BTreeMap<PathBuf, PluginDescriptor>, IntegrationError> {
898    let mut descriptors = BTreeMap::new();
899    let known_files = files.iter().cloned().collect::<BTreeSet<_>>();
900
901    for path in files {
902        if is_loong_package_manifest_file(path) {
903            let descriptor = parse_package_manifest_descriptor(path)?;
904            descriptors.insert(path.clone(), descriptor);
905            continue;
906        }
907
908        if is_openclaw_package_manifest_file(path) {
909            let descriptor = parse_openclaw_manifest_descriptor(path)?;
910            descriptors.insert(PathBuf::from(descriptor.path.clone()), descriptor);
911            continue;
912        }
913
914        if is_package_json_file(path) {
915            for descriptor in parse_openclaw_legacy_package_descriptors(path, &known_files)? {
916                descriptors.insert(PathBuf::from(descriptor.path.clone()), descriptor);
917            }
918        }
919    }
920
921    Ok(descriptors)
922}
923
924fn collect_source_manifest_descriptors(
925    files: &[PathBuf],
926) -> Result<SourceManifestCollection, IntegrationError> {
927    let mut collection = SourceManifestCollection::default();
928
929    for path in files {
930        let descriptor = parse_source_manifest_descriptor(path)?;
931        let Some(descriptor) = descriptor else {
932            continue;
933        };
934
935        collection
936            .diagnostic_findings
937            .extend(descriptor_contract_diagnostic_findings(&descriptor));
938        collection.descriptors.insert(path.clone(), descriptor);
939    }
940
941    Ok(collection)
942}
943
944fn collect_package_manifest_descriptors_by_root(
945    descriptors: &BTreeMap<PathBuf, PluginDescriptor>,
946) -> BTreeMap<PathBuf, PluginDescriptor> {
947    let mut manifests_by_root = BTreeMap::new();
948
949    for (path, descriptor) in descriptors {
950        let Some(parent) = path.parent() else {
951            continue;
952        };
953
954        let package_root = parent.to_path_buf();
955        let descriptor = descriptor.clone();
956
957        manifests_by_root.insert(package_root, descriptor);
958    }
959
960    manifests_by_root
961}
962
963fn push_descriptor(report: &mut PluginScanReport, descriptor: PluginDescriptor) {
964    report.matched_plugins = report.matched_plugins.saturating_add(1);
965    report.descriptors.push(descriptor);
966}
967
968fn descriptor_contract_diagnostic_findings(
969    descriptor: &PluginDescriptor,
970) -> Vec<PluginDiagnosticFinding> {
971    let mut findings = Vec::new();
972
973    if matches!(descriptor.source_kind, PluginSourceKind::EmbeddedSource) {
974        findings.push(PluginDiagnosticFinding {
975            code: PluginDiagnosticCode::EmbeddedSourceLegacyContract,
976            severity: PluginDiagnosticSeverity::Warning,
977            phase: PluginDiagnosticPhase::Scan,
978            blocking: false,
979            plugin_id: Some(descriptor.manifest.plugin_id.clone()),
980            source_path: Some(descriptor.path.clone()),
981            source_kind: Some(descriptor.source_kind),
982            field_path: None,
983            message:
984                "embedded source manifests remain a migration-only contract; package manifests are the preferred public SDK surface"
985                    .to_owned(),
986            remediation: Some(
987                "add a `loong.plugin.json` package manifest and keep source markers only as a temporary compatibility bridge"
988                    .to_owned(),
989            ),
990        });
991    }
992
993    if matches!(descriptor.source_kind, PluginSourceKind::EmbeddedSource)
994        && descriptor.manifest.metadata.contains_key("version")
995    {
996        findings.push(PluginDiagnosticFinding {
997            code: PluginDiagnosticCode::LegacyMetadataVersion,
998            severity: PluginDiagnosticSeverity::Warning,
999            phase: PluginDiagnosticPhase::Scan,
1000            blocking: false,
1001            plugin_id: Some(descriptor.manifest.plugin_id.clone()),
1002            source_path: Some(descriptor.path.clone()),
1003            source_kind: Some(descriptor.source_kind),
1004            field_path: Some("metadata.version".to_owned()),
1005            message:
1006                "embedded source manifest still carries legacy metadata.version; typed top-level version is the stable contract"
1007                    .to_owned(),
1008            remediation: Some(
1009                "move plugin version truth to top-level `version` and remove legacy metadata.version once package manifests are in place"
1010                    .to_owned(),
1011            ),
1012        });
1013    }
1014
1015    if !descriptor.compatibility_mode.is_native() {
1016        findings.push(PluginDiagnosticFinding {
1017            code: PluginDiagnosticCode::ForeignDialectContract,
1018            severity: PluginDiagnosticSeverity::Info,
1019            phase: PluginDiagnosticPhase::Scan,
1020            blocking: false,
1021            plugin_id: Some(descriptor.manifest.plugin_id.clone()),
1022            source_path: Some(descriptor.path.clone()),
1023            source_kind: Some(descriptor.source_kind),
1024            field_path: Some("dialect".to_owned()),
1025            message: format!(
1026                "plugin contract dialect `{}` is projected through compatibility mode `{}` before native activation",
1027                descriptor.dialect.as_str(),
1028                descriptor.compatibility_mode.as_str()
1029            ),
1030            remediation: Some(
1031                "keep compatibility intake on the adapter boundary, or migrate the plugin to a native `loong.plugin.json` contract for first-class SDK support"
1032                    .to_owned(),
1033            ),
1034        });
1035    }
1036
1037    if matches!(
1038        descriptor.compatibility_mode,
1039        PluginCompatibilityMode::OpenClawLegacy
1040    ) {
1041        findings.push(PluginDiagnosticFinding {
1042            code: PluginDiagnosticCode::LegacyOpenClawContract,
1043            severity: PluginDiagnosticSeverity::Warning,
1044            phase: PluginDiagnosticPhase::Scan,
1045            blocking: false,
1046            plugin_id: Some(descriptor.manifest.plugin_id.clone()),
1047            source_path: Some(descriptor.path.clone()),
1048            source_kind: Some(descriptor.source_kind),
1049            field_path: Some("package.json#openclaw.extensions".to_owned()),
1050            message:
1051                "legacy OpenClaw package metadata remains compatibility-only; modern openclaw.plugin.json manifests are the preferred foreign contract"
1052                    .to_owned(),
1053            remediation: Some(
1054                "add `openclaw.plugin.json` and keep package.json openclaw metadata only for entrypoint/setup declarations during migration"
1055                    .to_owned(),
1056            ),
1057        });
1058    }
1059
1060    findings
1061}
1062
1063fn shadowed_embedded_source_finding(
1064    source_descriptor: &PluginDescriptor,
1065    package_descriptor: &PluginDescriptor,
1066) -> PluginDiagnosticFinding {
1067    PluginDiagnosticFinding {
1068        code: PluginDiagnosticCode::ShadowedEmbeddedSource,
1069        severity: PluginDiagnosticSeverity::Warning,
1070        phase: PluginDiagnosticPhase::Scan,
1071        blocking: false,
1072        plugin_id: Some(source_descriptor.manifest.plugin_id.clone()),
1073        source_path: Some(source_descriptor.path.clone()),
1074        source_kind: Some(source_descriptor.source_kind),
1075        field_path: None,
1076        message: format!(
1077            "embedded source manifest is shadowed by package manifest `{}` and no longer acts as the authoritative contract",
1078            package_descriptor.path
1079        ),
1080        remediation: Some(
1081            "remove the shadowed marker block or keep it strictly migration-compatible until the package manifest is the sole source of truth"
1082                .to_owned(),
1083        ),
1084    }
1085}
1086
1087fn parse_package_manifest_descriptor(path: &Path) -> Result<PluginDescriptor, IntegrationError> {
1088    let manifest = parse_package_manifest_file(path)?;
1089    let descriptor = build_plugin_descriptor(
1090        path,
1091        PluginSourceKind::PackageManifest,
1092        PluginContractDialect::LoongPackageManifest,
1093        Some(CURRENT_PLUGIN_MANIFEST_API_VERSION.to_owned()),
1094        PluginCompatibilityMode::Native,
1095        Some(path),
1096        None,
1097        manifest,
1098    );
1099
1100    Ok(descriptor)
1101}
1102
1103fn parse_package_manifest_file(path: &Path) -> Result<PluginManifest, IntegrationError> {
1104    let bytes = fs::read(path).map_err(|error| IntegrationError::PluginFileRead {
1105        path: path.display().to_string(),
1106        reason: error.to_string(),
1107    })?;
1108
1109    let content =
1110        String::from_utf8(bytes).map_err(|error| IntegrationError::PluginManifestParse {
1111            path: path.display().to_string(),
1112            reason: error.to_string(),
1113        })?;
1114
1115    let document: PackageManifestDocument =
1116        serde_json::from_str(content.trim()).map_err(|error| {
1117            IntegrationError::PluginManifestParse {
1118                path: path.display().to_string(),
1119                reason: error.to_string(),
1120            }
1121        })?;
1122
1123    validate_package_manifest_document_contract(&document, path)?;
1124
1125    let normalized_manifest = normalize_plugin_manifest(document.into_manifest());
1126    validate_plugin_manifest_contract(
1127        &normalized_manifest,
1128        PluginSourceKind::PackageManifest,
1129        path,
1130    )?;
1131
1132    Ok(normalized_manifest)
1133}
1134
1135fn parse_openclaw_manifest_descriptor(path: &Path) -> Result<PluginDescriptor, IntegrationError> {
1136    let document = parse_json_document::<OpenClawManifestDocument>(path)?;
1137    validate_openclaw_manifest_document(&document, path)?;
1138
1139    let package_json_path = path
1140        .parent()
1141        .map(|parent| parent.join(PACKAGE_JSON_FILE_NAME))
1142        .filter(|candidate| candidate.is_file());
1143    let package_document = package_json_path
1144        .as_deref()
1145        .map(parse_json_document::<OpenClawPackageJsonDocument>)
1146        .transpose()?;
1147
1148    let package_root = path.parent().unwrap_or(path);
1149    let primary_entry_path =
1150        resolve_openclaw_primary_entry_path(package_root, package_document.as_ref(), true);
1151    let setup_entry_path = package_document
1152        .as_ref()
1153        .and_then(|package| package.openclaw.as_ref())
1154        .and_then(|metadata| metadata.setup_entry.as_deref())
1155        .and_then(|entry| resolve_openclaw_relative_path(package_root, entry));
1156    let manifest = build_openclaw_manifest(
1157        &document,
1158        package_document.as_ref(),
1159        primary_entry_path.as_deref(),
1160        setup_entry_path.as_deref(),
1161        PluginCompatibilityMode::OpenClawModern,
1162    );
1163    let descriptor_path = primary_entry_path.as_deref().unwrap_or(path);
1164    let descriptor = build_plugin_descriptor(
1165        descriptor_path,
1166        PluginSourceKind::PackageManifest,
1167        PluginContractDialect::OpenClawModernManifest,
1168        Some("openclaw.plugin.json".to_owned()),
1169        PluginCompatibilityMode::OpenClawModern,
1170        Some(path),
1171        primary_entry_path.as_deref(),
1172        manifest,
1173    );
1174
1175    Ok(descriptor)
1176}
1177
1178fn parse_openclaw_legacy_package_descriptors(
1179    path: &Path,
1180    known_files: &BTreeSet<PathBuf>,
1181) -> Result<Vec<PluginDescriptor>, IntegrationError> {
1182    let document = parse_json_document::<OpenClawPackageJsonDocument>(path)?;
1183    let Some(openclaw) = document.openclaw.as_ref() else {
1184        return Ok(Vec::new());
1185    };
1186
1187    let package_root = path.parent().unwrap_or(path);
1188    let sibling_openclaw_manifest = package_root.join(OPENCLAW_PACKAGE_MANIFEST_FILE_NAME);
1189    if known_files.contains(&sibling_openclaw_manifest) || sibling_openclaw_manifest.is_file() {
1190        return Ok(Vec::new());
1191    }
1192
1193    let extension_entries = resolve_openclaw_legacy_extension_entries(package_root, &document);
1194    if extension_entries.is_empty() {
1195        return Ok(Vec::new());
1196    }
1197
1198    let multiple_entries = extension_entries.len() > 1;
1199    let setup_entry_path = openclaw
1200        .setup_entry
1201        .as_deref()
1202        .and_then(|entry| resolve_openclaw_relative_path(package_root, entry));
1203    let mut descriptors = Vec::new();
1204
1205    for entry_path in extension_entries {
1206        let plugin_id = derive_openclaw_legacy_plugin_id(
1207            document.name.as_deref(),
1208            &entry_path,
1209            multiple_entries,
1210        );
1211        let manifest = build_openclaw_legacy_manifest(
1212            &document,
1213            plugin_id,
1214            &entry_path,
1215            setup_entry_path.as_deref(),
1216        );
1217        descriptors.push(build_plugin_descriptor(
1218            &entry_path,
1219            PluginSourceKind::PackageManifest,
1220            PluginContractDialect::OpenClawLegacyPackage,
1221            Some("package.json#openclaw".to_owned()),
1222            PluginCompatibilityMode::OpenClawLegacy,
1223            Some(path),
1224            Some(&entry_path),
1225            manifest,
1226        ));
1227    }
1228
1229    Ok(descriptors)
1230}
1231
1232fn parse_json_document<T>(path: &Path) -> Result<T, IntegrationError>
1233where
1234    T: for<'de> Deserialize<'de>,
1235{
1236    let content = read_utf8_file(path)?;
1237    serde_json::from_str(content.trim()).map_err(|error| IntegrationError::PluginManifestParse {
1238        path: path.display().to_string(),
1239        reason: error.to_string(),
1240    })
1241}
1242
1243fn read_utf8_file(path: &Path) -> Result<String, IntegrationError> {
1244    let bytes = fs::read(path).map_err(|error| IntegrationError::PluginFileRead {
1245        path: path.display().to_string(),
1246        reason: error.to_string(),
1247    })?;
1248
1249    String::from_utf8(bytes).map_err(|error| IntegrationError::PluginManifestParse {
1250        path: path.display().to_string(),
1251        reason: error.to_string(),
1252    })
1253}
1254
1255fn validate_openclaw_manifest_document(
1256    document: &OpenClawManifestDocument,
1257    path: &Path,
1258) -> Result<(), IntegrationError> {
1259    if document.id.trim().is_empty() {
1260        return Err(IntegrationError::PluginManifestParse {
1261            path: path.display().to_string(),
1262            reason: "openclaw.plugin.json must declare id".to_owned(),
1263        });
1264    }
1265
1266    if !matches!(document.config_schema.as_ref(), Some(Value::Object(_))) {
1267        return Err(IntegrationError::PluginManifestParse {
1268            path: path.display().to_string(),
1269            reason: "openclaw.plugin.json must declare configSchema object".to_owned(),
1270        });
1271    }
1272
1273    Ok(())
1274}
1275
1276fn build_openclaw_manifest(
1277    document: &OpenClawManifestDocument,
1278    package_document: Option<&OpenClawPackageJsonDocument>,
1279    primary_entry_path: Option<&Path>,
1280    setup_entry_path: Option<&Path>,
1281    compatibility_mode: PluginCompatibilityMode,
1282) -> PluginManifest {
1283    let mut metadata = BTreeMap::new();
1284
1285    metadata.insert("bridge_kind".to_owned(), "process_stdio".to_owned());
1286    metadata.insert(
1287        "adapter_family".to_owned(),
1288        match compatibility_mode {
1289            PluginCompatibilityMode::Native => "native".to_owned(),
1290            PluginCompatibilityMode::OpenClawModern => {
1291                OPENCLAW_MODERN_COMPATIBILITY_ADAPTER_FAMILY.to_owned()
1292            }
1293            PluginCompatibilityMode::OpenClawLegacy => {
1294                OPENCLAW_LEGACY_COMPATIBILITY_ADAPTER_FAMILY.to_owned()
1295            }
1296        },
1297    );
1298
1299    if let Some(entry) = primary_entry_path {
1300        metadata.insert("entrypoint".to_owned(), path_to_string(entry));
1301    }
1302    if let Some(setup_entry) = setup_entry_path {
1303        metadata.insert("setup_entrypoint".to_owned(), path_to_string(setup_entry));
1304    }
1305    if let Some(kind) = normalize_optional_manifest_string(document.kind.clone()) {
1306        metadata.insert("openclaw_kind".to_owned(), kind);
1307    }
1308    if let Some(package_document) = package_document {
1309        if let Some(name) = normalize_optional_manifest_string(package_document.name.clone()) {
1310            metadata.insert("openclaw_package_name".to_owned(), name);
1311        }
1312        if let Some(version) = normalize_optional_manifest_string(package_document.version.clone())
1313        {
1314            metadata.insert("openclaw_package_version".to_owned(), version);
1315        }
1316        if let Some(description) =
1317            normalize_optional_manifest_string(package_document.description.clone())
1318        {
1319            metadata.insert("openclaw_package_description".to_owned(), description);
1320        }
1321        if let Some(channel) = package_document
1322            .openclaw
1323            .as_ref()
1324            .and_then(|openclaw| openclaw.channel.as_ref())
1325        {
1326            if let Some(channel_id) = normalize_optional_manifest_string(channel.id.clone()) {
1327                metadata.insert("openclaw_channel_id".to_owned(), channel_id);
1328            }
1329            if let Some(label) = normalize_optional_manifest_string(channel.label.clone()) {
1330                metadata.insert("openclaw_channel_label".to_owned(), label);
1331            }
1332            if let Some(blurb) = normalize_optional_manifest_string(channel.blurb.clone()) {
1333                metadata.insert("openclaw_channel_blurb".to_owned(), blurb);
1334            }
1335            if let Some(docs_path) = normalize_optional_manifest_string(channel.docs_path.clone()) {
1336                metadata.insert("openclaw_channel_docs_path".to_owned(), docs_path);
1337            }
1338            let aliases = normalize_manifest_string_list(channel.aliases.clone());
1339            if !aliases.is_empty()
1340                && let Ok(encoded) = serde_json::to_string(&aliases)
1341            {
1342                metadata.insert("openclaw_channel_aliases_json".to_owned(), encoded);
1343            }
1344        }
1345        if let Some(install) = package_document
1346            .openclaw
1347            .as_ref()
1348            .and_then(|openclaw| openclaw.install.as_ref())
1349        {
1350            if let Some(npm_spec) = normalize_optional_manifest_string(install.npm_spec.clone()) {
1351                metadata.insert("openclaw_install_npm_spec".to_owned(), npm_spec);
1352            }
1353            if let Some(local_path) = normalize_optional_manifest_string(install.local_path.clone())
1354            {
1355                metadata.insert("openclaw_install_local_path".to_owned(), local_path);
1356            }
1357            if let Some(min_host_version) =
1358                normalize_optional_manifest_string(install.min_host_version.clone())
1359            {
1360                metadata.insert(
1361                    "openclaw_install_min_host_version".to_owned(),
1362                    min_host_version,
1363                );
1364            }
1365        }
1366    }
1367
1368    if !document.channels.is_empty()
1369        && let Ok(encoded) =
1370            serde_json::to_string(&normalize_manifest_string_list(document.channels.clone()))
1371    {
1372        metadata.insert("openclaw_channels_json".to_owned(), encoded);
1373    }
1374    if !document.providers.is_empty()
1375        && let Ok(encoded) =
1376            serde_json::to_string(&normalize_manifest_string_list(document.providers.clone()))
1377    {
1378        metadata.insert("openclaw_providers_json".to_owned(), encoded);
1379    }
1380    if !document.skills.is_empty()
1381        && let Ok(encoded) =
1382            serde_json::to_string(&normalize_manifest_string_list(document.skills.clone()))
1383    {
1384        metadata.insert("openclaw_skills_json".to_owned(), encoded);
1385    }
1386    if !document.provider_auth_env_vars.is_empty()
1387        && let Ok(encoded) = serde_json::to_string(&document.provider_auth_env_vars)
1388    {
1389        metadata.insert("openclaw_provider_auth_env_vars_json".to_owned(), encoded);
1390    }
1391    if !document.provider_auth_choices.is_empty()
1392        && let Ok(encoded) = serde_json::to_string(&document.provider_auth_choices)
1393    {
1394        metadata.insert("openclaw_provider_auth_choices_json".to_owned(), encoded);
1395    }
1396    if !document.ui_hints.is_empty()
1397        && let Ok(encoded) = serde_json::to_string(&document.ui_hints)
1398    {
1399        metadata.insert("openclaw_ui_hints_json".to_owned(), encoded);
1400    }
1401    if document.enabled_by_default {
1402        metadata.insert("openclaw_enabled_by_default".to_owned(), "true".to_owned());
1403    }
1404    if let Some(language) = primary_entry_path
1405        .map(detect_language)
1406        .filter(|language| language != "unknown")
1407    {
1408        metadata.insert(
1409            "source_language".to_owned(),
1410            normalize_language_name(&language),
1411        );
1412    }
1413
1414    normalize_plugin_manifest(PluginManifest {
1415        api_version: Some(CURRENT_PLUGIN_MANIFEST_API_VERSION.to_owned()),
1416        version: normalize_optional_manifest_string(document.version.clone()).or_else(|| {
1417            package_document
1418                .and_then(|package| normalize_optional_manifest_string(package.version.clone()))
1419        }),
1420        plugin_id: document.id.trim().to_owned(),
1421        provider_id: document.id.trim().to_owned(),
1422        connector_name: document.id.trim().to_owned(),
1423        channel_id: None,
1424        endpoint: None,
1425        capabilities: derive_openclaw_capabilities(
1426            document.providers.as_slice(),
1427            document.channels.as_slice(),
1428            document.skills.as_slice(),
1429            document.kind.as_deref(),
1430        ),
1431        trust_tier: PluginTrustTier::default(),
1432        metadata,
1433        summary: normalize_optional_manifest_string(
1434            document
1435                .description
1436                .clone()
1437                .or_else(|| document.name.clone()),
1438        ),
1439        tags: derive_openclaw_tags(
1440            compatibility_mode,
1441            document.providers.as_slice(),
1442            document.channels.as_slice(),
1443            document.skills.as_slice(),
1444            document.kind.as_deref(),
1445        ),
1446        input_examples: Vec::new(),
1447        output_examples: Vec::new(),
1448        defer_loading: setup_entry_path.is_some(),
1449        setup: derive_openclaw_setup(document, setup_entry_path),
1450        slot_claims: derive_openclaw_slot_claims(document.kind.as_deref()),
1451        compatibility: None,
1452    })
1453}
1454
1455fn build_openclaw_legacy_manifest(
1456    package_document: &OpenClawPackageJsonDocument,
1457    plugin_id: String,
1458    primary_entry_path: &Path,
1459    setup_entry_path: Option<&Path>,
1460) -> PluginManifest {
1461    let synthetic_document = OpenClawManifestDocument {
1462        id: plugin_id,
1463        config_schema: Some(Value::Object(Default::default())),
1464        enabled_by_default: false,
1465        kind: None,
1466        channels: Vec::new(),
1467        providers: Vec::new(),
1468        provider_auth_env_vars: BTreeMap::new(),
1469        provider_auth_choices: Vec::new(),
1470        skills: Vec::new(),
1471        name: package_document.name.clone(),
1472        description: package_document.description.clone(),
1473        version: package_document.version.clone(),
1474        ui_hints: BTreeMap::new(),
1475    };
1476
1477    let mut manifest = build_openclaw_manifest(
1478        &synthetic_document,
1479        Some(package_document),
1480        Some(primary_entry_path),
1481        setup_entry_path,
1482        PluginCompatibilityMode::OpenClawLegacy,
1483    );
1484    manifest
1485        .metadata
1486        .insert("openclaw_legacy_package".to_owned(), "true".to_owned());
1487    manifest.summary = normalize_optional_manifest_string(
1488        package_document
1489            .description
1490            .clone()
1491            .or_else(|| package_document.name.clone()),
1492    );
1493    manifest
1494}
1495
1496fn resolve_openclaw_primary_entry_path(
1497    package_root: &Path,
1498    package_document: Option<&OpenClawPackageJsonDocument>,
1499    prefer_declared_extension: bool,
1500) -> Option<PathBuf> {
1501    if prefer_declared_extension && let Some(package_document) = package_document {
1502        let entries = resolve_openclaw_extension_entries(package_root, package_document);
1503        if let Some(first) = entries.into_iter().next() {
1504            return Some(first);
1505        }
1506    }
1507
1508    resolve_openclaw_default_entry_path(package_root)
1509}
1510
1511fn resolve_openclaw_legacy_extension_entries(
1512    package_root: &Path,
1513    package_document: &OpenClawPackageJsonDocument,
1514) -> Vec<PathBuf> {
1515    let declared = resolve_openclaw_extension_entries(package_root, package_document);
1516    if !declared.is_empty() {
1517        return declared;
1518    }
1519
1520    resolve_openclaw_default_entry_path(package_root)
1521        .into_iter()
1522        .collect()
1523}
1524
1525fn resolve_openclaw_extension_entries(
1526    package_root: &Path,
1527    package_document: &OpenClawPackageJsonDocument,
1528) -> Vec<PathBuf> {
1529    package_document
1530        .openclaw
1531        .as_ref()
1532        .map(|metadata| metadata.extensions.as_slice())
1533        .unwrap_or_default()
1534        .iter()
1535        .filter_map(|entry| resolve_openclaw_relative_path(package_root, entry))
1536        .collect()
1537}
1538
1539fn resolve_openclaw_default_entry_path(package_root: &Path) -> Option<PathBuf> {
1540    for candidate in ["index.ts", "index.js", "index.mjs", "index.cjs"] {
1541        let entry = package_root.join(candidate);
1542        if entry.is_file() {
1543            return Some(entry);
1544        }
1545    }
1546
1547    None
1548}
1549
1550fn resolve_openclaw_relative_path(package_root: &Path, raw: &str) -> Option<PathBuf> {
1551    let trimmed = raw.trim();
1552    if trimmed.is_empty() {
1553        return None;
1554    }
1555
1556    let candidate = package_root.join(trimmed);
1557    Some(candidate)
1558}
1559
1560fn derive_openclaw_legacy_plugin_id(
1561    package_name: Option<&str>,
1562    entry_path: &Path,
1563    has_multiple_extensions: bool,
1564) -> String {
1565    let base = entry_path
1566        .file_stem()
1567        .and_then(|stem| stem.to_str())
1568        .map(str::trim)
1569        .filter(|stem| !stem.is_empty())
1570        .unwrap_or("plugin");
1571
1572    let Some(package_name) = package_name.map(str::trim).filter(|name| !name.is_empty()) else {
1573        return base.to_owned();
1574    };
1575
1576    let unscoped = package_name.rsplit('/').next().unwrap_or(package_name);
1577    let canonical = unscoped
1578        .strip_suffix("-provider")
1579        .unwrap_or(unscoped)
1580        .trim();
1581
1582    if !has_multiple_extensions {
1583        return canonical.to_owned();
1584    }
1585
1586    format!("{canonical}/{base}")
1587}
1588
1589fn derive_openclaw_capabilities(
1590    providers: &[String],
1591    channels: &[String],
1592    skills: &[String],
1593    kind: Option<&str>,
1594) -> BTreeSet<Capability> {
1595    let mut capabilities = BTreeSet::new();
1596    if !providers.is_empty() || !channels.is_empty() {
1597        capabilities.insert(Capability::InvokeConnector);
1598    }
1599    if !skills.is_empty() {
1600        capabilities.insert(Capability::InvokeTool);
1601    }
1602
1603    match kind.map(|value| value.trim().to_ascii_lowercase()) {
1604        Some(kind) if kind == "memory" => {
1605            capabilities.insert(Capability::MemoryRead);
1606            capabilities.insert(Capability::MemoryWrite);
1607        }
1608        Some(kind) if kind == "context-engine" => {
1609            capabilities.insert(Capability::ObserveTelemetry);
1610        }
1611        _ => {}
1612    }
1613
1614    capabilities
1615}
1616
1617fn derive_openclaw_tags(
1618    compatibility_mode: PluginCompatibilityMode,
1619    providers: &[String],
1620    channels: &[String],
1621    skills: &[String],
1622    kind: Option<&str>,
1623) -> Vec<String> {
1624    let mut tags = vec![
1625        "openclaw".to_owned(),
1626        compatibility_mode.as_str().to_owned(),
1627        "compat".to_owned(),
1628    ];
1629    if !providers.is_empty() {
1630        tags.push("provider".to_owned());
1631    }
1632    if !channels.is_empty() {
1633        tags.push("channel".to_owned());
1634    }
1635    if !skills.is_empty() {
1636        tags.push("skill".to_owned());
1637    }
1638    if let Some(kind) = kind.map(str::trim).filter(|kind| !kind.is_empty()) {
1639        tags.push(kind.to_ascii_lowercase());
1640    }
1641
1642    normalize_manifest_string_list(tags)
1643}
1644
1645fn derive_openclaw_setup(
1646    document: &OpenClawManifestDocument,
1647    setup_entry_path: Option<&Path>,
1648) -> Option<PluginSetup> {
1649    let required_env_vars = document
1650        .provider_auth_env_vars
1651        .values()
1652        .flat_map(|values| values.iter().cloned())
1653        .collect::<Vec<_>>();
1654    let docs_urls = document
1655        .provider_auth_choices
1656        .iter()
1657        .filter_map(|choice| choice.get("docsUrl"))
1658        .filter_map(Value::as_str)
1659        .map(str::to_owned)
1660        .collect::<Vec<_>>();
1661    let surface = if !document.channels.is_empty() {
1662        Some("channel".to_owned())
1663    } else if !document.providers.is_empty() {
1664        Some("provider".to_owned())
1665    } else if !document.skills.is_empty() {
1666        Some("skill".to_owned())
1667    } else {
1668        Some("plugin".to_owned())
1669    };
1670    let remediation = Some(
1671        "enable the required OpenClaw compatibility shim and configure plugin settings before activation"
1672            .to_owned(),
1673    );
1674    let setup = PluginSetup {
1675        mode: if setup_entry_path.is_some() {
1676            PluginSetupMode::GovernedEntry
1677        } else {
1678            PluginSetupMode::MetadataOnly
1679        },
1680        surface,
1681        required_env_vars: normalize_manifest_string_list(required_env_vars),
1682        recommended_env_vars: Vec::new(),
1683        required_config_keys: vec![format!("plugins.entries.{}", document.id.trim())],
1684        default_env_var: document
1685            .provider_auth_env_vars
1686            .values()
1687            .flat_map(|values| values.iter())
1688            .next()
1689            .cloned(),
1690        docs_urls: normalize_manifest_string_list(docs_urls),
1691        remediation,
1692    };
1693
1694    (!setup.is_effectively_empty()).then_some(setup.normalized())
1695}
1696
1697fn derive_openclaw_slot_claims(kind: Option<&str>) -> Vec<PluginSlotClaim> {
1698    match kind.map(|value| value.trim().to_ascii_lowercase()) {
1699        Some(kind) if kind == "memory" => vec![PluginSlotClaim {
1700            slot: "openclaw_kind".to_owned(),
1701            key: "memory".to_owned(),
1702            mode: PluginSlotMode::Exclusive,
1703        }],
1704        Some(kind) if kind == "context-engine" => vec![PluginSlotClaim {
1705            slot: "openclaw_kind".to_owned(),
1706            key: "context_engine".to_owned(),
1707            mode: PluginSlotMode::Exclusive,
1708        }],
1709        _ => Vec::new(),
1710    }
1711}
1712
1713fn validate_package_manifest_document_contract(
1714    document: &PackageManifestDocument,
1715    path: &Path,
1716) -> Result<(), IntegrationError> {
1717    if normalize_optional_manifest_string(document.version.clone()).is_none() {
1718        return Err(IntegrationError::PluginManifestParse {
1719            path: path.display().to_string(),
1720            reason: "package manifest must declare top-level version".to_owned(),
1721        });
1722    }
1723
1724    if let Some(version) = document
1725        .metadata
1726        .get("version")
1727        .cloned()
1728        .and_then(|value| normalize_optional_manifest_string(Some(value)))
1729    {
1730        return Err(IntegrationError::PluginManifestParse {
1731            path: path.display().to_string(),
1732            reason: format!(
1733                "package manifest must declare version via top-level `version`, not metadata.version (`{version}`)"
1734            ),
1735        });
1736    }
1737
1738    if let Some(reserved_key) = document
1739        .metadata
1740        .keys()
1741        .find(|key| key.starts_with(RESERVED_PACKAGE_METADATA_PREFIX))
1742    {
1743        return Err(IntegrationError::PluginManifestParse {
1744            path: path.display().to_string(),
1745            reason: format!(
1746                "package manifest metadata key `{reserved_key}` is reserved for host-managed projection"
1747            ),
1748        });
1749    }
1750
1751    Ok(())
1752}
1753
1754fn parse_source_manifest_descriptor(
1755    path: &Path,
1756) -> Result<Option<PluginDescriptor>, IntegrationError> {
1757    let bytes = fs::read(path).map_err(|error| IntegrationError::PluginFileRead {
1758        path: path.display().to_string(),
1759        reason: error.to_string(),
1760    })?;
1761
1762    let content = match String::from_utf8(bytes) {
1763        Ok(content) => content,
1764        Err(_) => return Ok(None),
1765    };
1766
1767    let Some(manifest) = parse_manifest_block(&content, path)? else {
1768        return Ok(None);
1769    };
1770
1771    let descriptor = build_plugin_descriptor(
1772        path,
1773        PluginSourceKind::EmbeddedSource,
1774        PluginContractDialect::LoongEmbeddedSource,
1775        None,
1776        PluginCompatibilityMode::Native,
1777        None,
1778        None,
1779        manifest,
1780    );
1781
1782    Ok(Some(descriptor))
1783}
1784
1785fn build_plugin_descriptor(
1786    path: &Path,
1787    source_kind: PluginSourceKind,
1788    dialect: PluginContractDialect,
1789    dialect_version: Option<String>,
1790    compatibility_mode: PluginCompatibilityMode,
1791    package_manifest_path: Option<&Path>,
1792    runtime_entry_path: Option<&Path>,
1793    manifest: PluginManifest,
1794) -> PluginDescriptor {
1795    let path_string = path_to_string(path);
1796    let package_root = package_manifest_path
1797        .and_then(Path::parent)
1798        .map(path_to_string)
1799        .unwrap_or_else(|| package_root_for_path(path));
1800    let package_manifest_path = package_manifest_path.map(path_to_string);
1801    let language = runtime_entry_path
1802        .map(detect_language)
1803        .unwrap_or_else(|| detect_language(path));
1804
1805    PluginDescriptor {
1806        path: path_string,
1807        source_kind,
1808        dialect,
1809        dialect_version,
1810        compatibility_mode,
1811        package_root,
1812        package_manifest_path,
1813        language,
1814        manifest,
1815    }
1816}
1817
1818fn package_root_for_path(path: &Path) -> String {
1819    let package_root = path.parent().unwrap_or(path);
1820
1821    path_to_string(package_root)
1822}
1823
1824fn path_to_string(path: &Path) -> String {
1825    path.display().to_string()
1826}
1827
1828fn is_package_manifest_file(path: &Path) -> bool {
1829    is_loong_package_manifest_file(path) || is_openclaw_package_manifest_file(path)
1830}
1831
1832fn is_loong_package_manifest_file(path: &Path) -> bool {
1833    let file_name = path.file_name();
1834    let file_name = file_name.and_then(|value| value.to_str());
1835
1836    matches!(file_name, Some(PACKAGE_MANIFEST_FILE_NAME))
1837}
1838
1839fn is_openclaw_package_manifest_file(path: &Path) -> bool {
1840    let file_name = path.file_name();
1841    let file_name = file_name.and_then(|value| value.to_str());
1842
1843    matches!(file_name, Some(OPENCLAW_PACKAGE_MANIFEST_FILE_NAME))
1844}
1845
1846fn is_package_json_file(path: &Path) -> bool {
1847    let file_name = path.file_name();
1848    let file_name = file_name.and_then(|value| value.to_str());
1849
1850    matches!(file_name, Some(PACKAGE_JSON_FILE_NAME))
1851}
1852
1853fn find_covering_package_manifest_descriptor<'a>(
1854    path: &Path,
1855    package_manifests_by_root: &'a BTreeMap<PathBuf, PluginDescriptor>,
1856) -> Option<&'a PluginDescriptor> {
1857    let mut best_match: Option<(&PathBuf, &PluginDescriptor)> = None;
1858
1859    for (package_root, descriptor) in package_manifests_by_root {
1860        if !path.starts_with(package_root) {
1861            continue;
1862        }
1863
1864        let candidate_depth = package_root.components().count();
1865        let Some((best_root, _)) = best_match else {
1866            best_match = Some((package_root, descriptor));
1867            continue;
1868        };
1869
1870        let best_depth = best_root.components().count();
1871
1872        if candidate_depth > best_depth {
1873            best_match = Some((package_root, descriptor));
1874        }
1875    }
1876
1877    best_match.map(|(_, descriptor)| descriptor)
1878}
1879
1880fn validate_package_manifest_conflicts(
1881    package_manifests_by_root: &BTreeMap<PathBuf, PluginDescriptor>,
1882    source_manifest_descriptors: &BTreeMap<PathBuf, PluginDescriptor>,
1883) -> Result<(), IntegrationError> {
1884    for (source_path, source_descriptor) in source_manifest_descriptors {
1885        let package_descriptor =
1886            find_covering_package_manifest_descriptor(source_path, package_manifests_by_root);
1887
1888        let Some(package_descriptor) = package_descriptor else {
1889            continue;
1890        };
1891
1892        validate_package_manifest_pair(package_descriptor, source_descriptor)?;
1893    }
1894
1895    Ok(())
1896}
1897
1898fn validate_package_manifest_pair(
1899    package_descriptor: &PluginDescriptor,
1900    source_descriptor: &PluginDescriptor,
1901) -> Result<(), IntegrationError> {
1902    let conflict =
1903        first_manifest_conflict(&package_descriptor.manifest, &source_descriptor.manifest);
1904
1905    let Some(conflict) = conflict else {
1906        return Ok(());
1907    };
1908
1909    Err(IntegrationError::PluginManifestConflict {
1910        package_manifest_path: package_descriptor.path.clone(),
1911        source_path: source_descriptor.path.clone(),
1912        field: conflict.field,
1913        package_value: conflict.package_value,
1914        source_value: conflict.source_value,
1915    })
1916}
1917
1918fn first_manifest_conflict(
1919    package_manifest: &PluginManifest,
1920    source_manifest: &PluginManifest,
1921) -> Option<ManifestFieldConflict> {
1922    let plugin_id_conflict = compare_manifest_value(
1923        "plugin_id",
1924        &package_manifest.plugin_id,
1925        &source_manifest.plugin_id,
1926    );
1927    if plugin_id_conflict.is_some() {
1928        return plugin_id_conflict;
1929    }
1930
1931    let provider_id_conflict = compare_manifest_value(
1932        "provider_id",
1933        &package_manifest.provider_id,
1934        &source_manifest.provider_id,
1935    );
1936    if provider_id_conflict.is_some() {
1937        return provider_id_conflict;
1938    }
1939
1940    let connector_name_conflict = compare_manifest_value(
1941        "connector_name",
1942        &package_manifest.connector_name,
1943        &source_manifest.connector_name,
1944    );
1945    if connector_name_conflict.is_some() {
1946        return connector_name_conflict;
1947    }
1948
1949    let channel_id_conflict = compare_manifest_value(
1950        "channel_id",
1951        &package_manifest.channel_id,
1952        &source_manifest.channel_id,
1953    );
1954    if channel_id_conflict.is_some() {
1955        return channel_id_conflict;
1956    }
1957
1958    let endpoint_conflict = compare_manifest_value(
1959        "endpoint",
1960        &package_manifest.endpoint,
1961        &source_manifest.endpoint,
1962    );
1963    if endpoint_conflict.is_some() {
1964        return endpoint_conflict;
1965    }
1966
1967    let capabilities_conflict = compare_manifest_value(
1968        "capabilities",
1969        &package_manifest.capabilities,
1970        &source_manifest.capabilities,
1971    );
1972    if capabilities_conflict.is_some() {
1973        return capabilities_conflict;
1974    }
1975
1976    let metadata_conflict =
1977        first_shared_metadata_conflict(&package_manifest.metadata, &source_manifest.metadata);
1978    if metadata_conflict.is_some() {
1979        return metadata_conflict;
1980    }
1981
1982    let summary_conflict = compare_optional_fill_value(
1983        "summary",
1984        &package_manifest.summary,
1985        &source_manifest.summary,
1986    );
1987    if summary_conflict.is_some() {
1988        return summary_conflict;
1989    }
1990
1991    let tags_conflict =
1992        compare_optional_fill_sequence("tags", &package_manifest.tags, &source_manifest.tags);
1993    if tags_conflict.is_some() {
1994        return tags_conflict;
1995    }
1996
1997    let input_examples_conflict = compare_optional_fill_sequence(
1998        "input_examples",
1999        &package_manifest.input_examples,
2000        &source_manifest.input_examples,
2001    );
2002    if input_examples_conflict.is_some() {
2003        return input_examples_conflict;
2004    }
2005
2006    let output_examples_conflict = compare_optional_fill_sequence(
2007        "output_examples",
2008        &package_manifest.output_examples,
2009        &source_manifest.output_examples,
2010    );
2011    if output_examples_conflict.is_some() {
2012        return output_examples_conflict;
2013    }
2014
2015    let api_version_conflict = compare_optional_fill_value(
2016        "api_version",
2017        &package_manifest.api_version,
2018        &source_manifest.api_version,
2019    );
2020    if api_version_conflict.is_some() {
2021        return api_version_conflict;
2022    }
2023
2024    let version_conflict = compare_optional_fill_value(
2025        "version",
2026        &package_manifest.version,
2027        &source_manifest.version,
2028    );
2029    if version_conflict.is_some() {
2030        return version_conflict;
2031    }
2032
2033    let setup_conflict =
2034        compare_manifest_value("setup", &package_manifest.setup, &source_manifest.setup);
2035    if setup_conflict.is_some() {
2036        return setup_conflict;
2037    }
2038
2039    let slot_claims_conflict = compare_manifest_value(
2040        "slot_claims",
2041        &package_manifest.slot_claims,
2042        &source_manifest.slot_claims,
2043    );
2044    if slot_claims_conflict.is_some() {
2045        return slot_claims_conflict;
2046    }
2047
2048    let compatibility_conflict = compare_optional_fill_value(
2049        "compatibility",
2050        &package_manifest.compatibility,
2051        &source_manifest.compatibility,
2052    );
2053    if compatibility_conflict.is_some() {
2054        return compatibility_conflict;
2055    }
2056
2057    compare_manifest_value(
2058        "defer_loading",
2059        &package_manifest.defer_loading,
2060        &source_manifest.defer_loading,
2061    )
2062}
2063
2064fn compare_manifest_value<T>(
2065    field: &str,
2066    package_value: &T,
2067    source_value: &T,
2068) -> Option<ManifestFieldConflict>
2069where
2070    T: ?Sized + PartialEq + Serialize,
2071{
2072    if package_value == source_value {
2073        return None;
2074    }
2075
2076    let package_value = serialize_manifest_value(package_value);
2077    let source_value = serialize_manifest_value(source_value);
2078
2079    Some(ManifestFieldConflict {
2080        field: field.to_owned(),
2081        package_value,
2082        source_value,
2083    })
2084}
2085
2086fn compare_optional_fill_value<T>(
2087    field: &str,
2088    package_value: &Option<T>,
2089    source_value: &Option<T>,
2090) -> Option<ManifestFieldConflict>
2091where
2092    T: PartialEq + Serialize,
2093{
2094    let package_value = package_value.as_ref()?;
2095    let source_value = source_value.as_ref()?;
2096
2097    compare_manifest_value(field, package_value, source_value)
2098}
2099
2100fn compare_optional_fill_sequence<T>(
2101    field: &str,
2102    package_value: &[T],
2103    source_value: &[T],
2104) -> Option<ManifestFieldConflict>
2105where
2106    T: PartialEq + Serialize,
2107{
2108    if package_value.is_empty() {
2109        return None;
2110    }
2111
2112    if source_value.is_empty() {
2113        return None;
2114    }
2115
2116    compare_manifest_value(field, package_value, source_value)
2117}
2118
2119fn first_shared_metadata_conflict(
2120    package_metadata: &BTreeMap<String, String>,
2121    source_metadata: &BTreeMap<String, String>,
2122) -> Option<ManifestFieldConflict> {
2123    for (key, package_value) in package_metadata {
2124        let Some(source_value) = source_metadata.get(key) else {
2125            continue;
2126        };
2127
2128        if package_value == source_value {
2129            continue;
2130        }
2131
2132        let field = format!("metadata.{key}");
2133        let package_value = serialize_manifest_value(package_value);
2134        let source_value = serialize_manifest_value(source_value);
2135
2136        return Some(ManifestFieldConflict {
2137            field,
2138            package_value,
2139            source_value,
2140        });
2141    }
2142
2143    None
2144}
2145
2146fn serialize_manifest_value<T>(value: &T) -> String
2147where
2148    T: ?Sized + Serialize,
2149{
2150    let serialized = serde_json::to_string(value);
2151
2152    match serialized {
2153        Ok(serialized) => serialized,
2154        Err(error) => format!("\"<serialization_error:{error}>\""),
2155    }
2156}
2157
2158fn should_skip_dir(path: &Path) -> bool {
2159    matches!(
2160        path.file_name().and_then(|name| name.to_str()),
2161        Some(".git" | "target" | "node_modules" | ".venv" | ".idea" | ".codex")
2162    )
2163}
2164
2165fn parse_manifest_block(
2166    content: &str,
2167    path: &Path,
2168) -> Result<Option<PluginManifest>, IntegrationError> {
2169    const START: &str = "LOONG_PLUGIN_START";
2170    const END: &str = "LOONG_PLUGIN_END";
2171
2172    let Some(start_idx) = content.find(START) else {
2173        return Ok(None);
2174    };
2175
2176    let Some(end_idx) = content[start_idx..].find(END).map(|idx| start_idx + idx) else {
2177        return Err(IntegrationError::PluginManifestParse {
2178            path: path.display().to_string(),
2179            reason: "missing LOONG_PLUGIN_END".to_owned(),
2180        });
2181    };
2182
2183    let block = &content[start_idx + START.len()..end_idx];
2184    let cleaned = block
2185        .lines()
2186        .map(clean_manifest_line)
2187        .collect::<Vec<_>>()
2188        .join("\n");
2189
2190    let manifest: PluginManifest = serde_json::from_str(cleaned.trim()).map_err(|error| {
2191        IntegrationError::PluginManifestParse {
2192            path: path.display().to_string(),
2193            reason: error.to_string(),
2194        }
2195    })?;
2196
2197    let normalized_manifest = normalize_plugin_manifest(manifest);
2198    validate_plugin_manifest_contract(
2199        &normalized_manifest,
2200        PluginSourceKind::EmbeddedSource,
2201        path,
2202    )?;
2203
2204    Ok(Some(normalized_manifest))
2205}
2206
2207fn clean_manifest_line(line: &str) -> String {
2208    let trimmed = line.trim_start();
2209    for prefix in ["//", "#", "--", ";", "/*", "*", "*/"] {
2210        if let Some(rest) = trimmed.strip_prefix(prefix) {
2211            return rest.trim_start().to_owned();
2212        }
2213    }
2214    trimmed.to_owned()
2215}
2216
2217fn normalize_plugin_manifest(mut manifest: PluginManifest) -> PluginManifest {
2218    let normalized_api_version = normalize_optional_manifest_string(manifest.api_version.take());
2219    let normalized_version =
2220        normalize_optional_manifest_string(manifest.version.take()).or_else(|| {
2221            manifest
2222                .metadata
2223                .get("version")
2224                .cloned()
2225                .and_then(|value| normalize_optional_manifest_string(Some(value)))
2226        });
2227    let normalized_setup = manifest.setup.take().map(PluginSetup::normalized);
2228    let canonical_setup = normalized_setup.filter(|setup| !setup.is_effectively_empty());
2229    let normalized_slot_claims = normalize_plugin_slot_claims(manifest.slot_claims);
2230    let normalized_compatibility = manifest
2231        .compatibility
2232        .take()
2233        .map(PluginCompatibility::normalized)
2234        .filter(|compatibility| !compatibility.is_effectively_empty());
2235    manifest.api_version = normalized_api_version;
2236    manifest.version = normalized_version.clone();
2237    manifest.setup = canonical_setup;
2238    manifest.slot_claims = normalized_slot_claims;
2239    manifest.compatibility = normalized_compatibility;
2240    if let Some(version) = normalized_version {
2241        manifest
2242            .metadata
2243            .entry("version".to_owned())
2244            .or_insert(version);
2245    }
2246    manifest
2247}
2248
2249fn validate_plugin_manifest_contract(
2250    manifest: &PluginManifest,
2251    source_kind: PluginSourceKind,
2252    path: &Path,
2253) -> Result<(), IntegrationError> {
2254    if matches!(source_kind, PluginSourceKind::PackageManifest) && manifest.api_version.is_none() {
2255        return Err(IntegrationError::PluginManifestParse {
2256            path: path.display().to_string(),
2257            reason: "package manifest must declare api_version".to_owned(),
2258        });
2259    }
2260
2261    if let Some(api_version) = manifest.api_version.as_deref()
2262        && api_version != CURRENT_PLUGIN_MANIFEST_API_VERSION
2263    {
2264        return Err(IntegrationError::PluginManifestParse {
2265            path: path.display().to_string(),
2266            reason: format!(
2267                "plugin api_version `{api_version}` is not supported by current manifest api `{CURRENT_PLUGIN_MANIFEST_API_VERSION}`"
2268            ),
2269        });
2270    }
2271
2272    if matches!(source_kind, PluginSourceKind::PackageManifest) && manifest.version.is_none() {
2273        return Err(IntegrationError::PluginManifestParse {
2274            path: path.display().to_string(),
2275            reason: "package manifest must declare top-level version".to_owned(),
2276        });
2277    }
2278
2279    if let Some(version) = manifest.version.as_deref()
2280        && let Err(error) = Version::parse(version)
2281    {
2282        return Err(IntegrationError::PluginManifestParse {
2283            path: path.display().to_string(),
2284            reason: format!("plugin version `{version}` is invalid semver: {error}"),
2285        });
2286    }
2287
2288    if let Some(version) = manifest.version.as_deref()
2289        && let Some(metadata_version) = manifest
2290            .metadata
2291            .get("version")
2292            .cloned()
2293            .and_then(|value| normalize_optional_manifest_string(Some(value)))
2294        && metadata_version != version
2295    {
2296        return Err(IntegrationError::PluginManifestParse {
2297            path: path.display().to_string(),
2298            reason: format!(
2299                "plugin version conflict: top-level version `{version}` does not match metadata.version `{metadata_version}`"
2300            ),
2301        });
2302    }
2303
2304    Ok(())
2305}
2306
2307fn normalize_plugin_slot_claims(mut claims: Vec<PluginSlotClaim>) -> Vec<PluginSlotClaim> {
2308    let mut normalized_claims = claims
2309        .drain(..)
2310        .map(PluginSlotClaim::normalized)
2311        .collect::<Vec<_>>();
2312    normalized_claims.sort();
2313    normalized_claims.dedup();
2314    normalized_claims
2315}
2316
2317#[derive(Debug, Clone)]
2318struct RegisteredSlotClaim {
2319    plugin_id: String,
2320    provider_id: String,
2321    mode: PluginSlotMode,
2322}
2323
2324type ClaimedSlotRegistry = BTreeMap<(String, String), Vec<RegisteredSlotClaim>>;
2325
2326fn collect_claimed_slots(
2327    catalog: &IntegrationCatalog,
2328) -> Result<ClaimedSlotRegistry, IntegrationError> {
2329    let mut registry = ClaimedSlotRegistry::new();
2330
2331    for provider in catalog.providers() {
2332        let Some(raw_json) = provider.metadata.get(PLUGIN_SLOT_CLAIMS_METADATA_KEY) else {
2333            continue;
2334        };
2335        let claims = serde_json::from_str::<Vec<PluginSlotClaim>>(raw_json).map_err(|error| {
2336            IntegrationError::PluginAbsorbFailed {
2337                plugin_id: provider
2338                    .metadata
2339                    .get("plugin_id")
2340                    .cloned()
2341                    .unwrap_or_else(|| format!("provider:{}", provider.provider_id)),
2342                reason: format!(
2343                    "existing provider `{}` has invalid {PLUGIN_SLOT_CLAIMS_METADATA_KEY}: {error}",
2344                    provider.provider_id
2345                ),
2346            }
2347        })?;
2348
2349        let plugin_id = provider
2350            .metadata
2351            .get("plugin_id")
2352            .cloned()
2353            .unwrap_or_else(|| format!("provider:{}", provider.provider_id));
2354
2355        for claim in claims {
2356            registry
2357                .entry((claim.slot, claim.key))
2358                .or_default()
2359                .push(RegisteredSlotClaim {
2360                    plugin_id: plugin_id.clone(),
2361                    provider_id: provider.provider_id.clone(),
2362                    mode: claim.mode,
2363                });
2364        }
2365    }
2366
2367    Ok(registry)
2368}
2369
2370fn validate_plugin_slot_claims(manifest: &PluginManifest) -> Result<(), IntegrationError> {
2371    let mut seen_modes = BTreeMap::<(String, String), PluginSlotMode>::new();
2372
2373    for claim in &manifest.slot_claims {
2374        if claim.slot.is_empty() {
2375            return Err(IntegrationError::PluginAbsorbFailed {
2376                plugin_id: manifest.plugin_id.clone(),
2377                reason: "slot claim slot must not be empty".to_owned(),
2378            });
2379        }
2380        if claim.key.is_empty() {
2381            return Err(IntegrationError::PluginAbsorbFailed {
2382                plugin_id: manifest.plugin_id.clone(),
2383                reason: "slot claim key must not be empty".to_owned(),
2384            });
2385        }
2386
2387        let slot_key = (claim.slot.clone(), claim.key.clone());
2388        if let Some(existing_mode) = seen_modes.insert(slot_key.clone(), claim.mode)
2389            && existing_mode != claim.mode
2390        {
2391            return Err(IntegrationError::PluginAbsorbFailed {
2392                plugin_id: manifest.plugin_id.clone(),
2393                reason: format!(
2394                    "slot claim `{}`:`{}` declares conflicting modes `{}` and `{}`",
2395                    slot_key.0,
2396                    slot_key.1,
2397                    existing_mode.as_str(),
2398                    claim.mode.as_str()
2399                ),
2400            });
2401        }
2402    }
2403
2404    Ok(())
2405}
2406
2407pub(crate) fn plugin_host_compatibility_issue(
2408    compatibility: Option<&PluginCompatibility>,
2409) -> Option<String> {
2410    let compatibility = compatibility?;
2411
2412    if let Some(host_api) = compatibility.host_api.as_deref()
2413        && host_api != CURRENT_PLUGIN_HOST_API
2414    {
2415        return Some(format!(
2416            "plugin compatibility.host_api `{host_api}` is not supported by current host api `{CURRENT_PLUGIN_HOST_API}`"
2417        ));
2418    }
2419
2420    if let Some(host_version_req) = compatibility.host_version_req.as_deref() {
2421        let parsed_req = match VersionReq::parse(host_version_req) {
2422            Ok(parsed_req) => parsed_req,
2423            Err(error) => {
2424                return Some(format!(
2425                    "plugin compatibility.host_version_req `{host_version_req}` is invalid: {error}"
2426                ));
2427            }
2428        };
2429        let current_version = match current_plugin_host_version() {
2430            Ok(current_version) => current_version,
2431            Err(error) => {
2432                return Some(error);
2433            }
2434        };
2435        if !parsed_req.matches(&current_version) {
2436            return Some(format!(
2437                "plugin compatibility.host_version_req `{host_version_req}` does not match current host version `{current_version}`"
2438            ));
2439        }
2440    }
2441
2442    None
2443}
2444
2445fn validate_plugin_host_compatibility(manifest: &PluginManifest) -> Result<(), IntegrationError> {
2446    let Some(issue) = plugin_host_compatibility_issue(manifest.compatibility.as_ref()) else {
2447        return Ok(());
2448    };
2449
2450    Err(IntegrationError::PluginAbsorbFailed {
2451        plugin_id: manifest.plugin_id.clone(),
2452        reason: issue,
2453    })
2454}
2455
2456fn register_plugin_slot_claims(
2457    manifest: &PluginManifest,
2458    registry: &mut ClaimedSlotRegistry,
2459) -> Result<(), IntegrationError> {
2460    for claim in &manifest.slot_claims {
2461        let slot_key = (claim.slot.clone(), claim.key.clone());
2462
2463        if let Some(existing_claims) = registry.get(&slot_key)
2464            && let Some(existing) = existing_claims.iter().find(|existing| {
2465                existing.plugin_id != manifest.plugin_id
2466                    && slot_modes_conflict(existing.mode, claim.mode)
2467            })
2468        {
2469            return Err(IntegrationError::PluginAbsorbFailed {
2470                plugin_id: manifest.plugin_id.clone(),
2471                reason: format!(
2472                    "slot claim conflict on `{}`:`{}` with plugin `{}` (provider `{}`): `{}` cannot coexist with `{}`",
2473                    claim.slot,
2474                    claim.key,
2475                    existing.plugin_id,
2476                    existing.provider_id,
2477                    claim.mode.as_str(),
2478                    existing.mode.as_str()
2479                ),
2480            });
2481        }
2482
2483        registry
2484            .entry(slot_key)
2485            .or_default()
2486            .push(RegisteredSlotClaim {
2487                plugin_id: manifest.plugin_id.clone(),
2488                provider_id: manifest.provider_id.clone(),
2489                mode: claim.mode,
2490            });
2491    }
2492
2493    Ok(())
2494}
2495
2496pub(crate) fn slot_modes_conflict(existing: PluginSlotMode, incoming: PluginSlotMode) -> bool {
2497    matches!(
2498        (existing, incoming),
2499        (PluginSlotMode::Exclusive, _) | (_, PluginSlotMode::Exclusive)
2500    )
2501}
2502
2503fn stamp_plugin_slot_claims_metadata(
2504    metadata: &mut BTreeMap<String, String>,
2505    slot_claims: &[PluginSlotClaim],
2506) -> Result<(), IntegrationError> {
2507    if slot_claims.is_empty() {
2508        metadata.remove(PLUGIN_SLOT_CLAIMS_METADATA_KEY);
2509        return Ok(());
2510    }
2511
2512    let encoded = serde_json::to_string(slot_claims).map_err(|error| {
2513        IntegrationError::PluginAbsorbFailed {
2514            plugin_id: metadata
2515                .get("plugin_id")
2516                .cloned()
2517                .unwrap_or_else(|| "unknown-plugin".to_owned()),
2518            reason: format!("serialize plugin slot claims metadata failed: {error}"),
2519        }
2520    })?;
2521    metadata.insert(PLUGIN_SLOT_CLAIMS_METADATA_KEY.to_owned(), encoded);
2522    Ok(())
2523}
2524
2525fn stamp_plugin_manifest_contract_metadata(
2526    metadata: &mut BTreeMap<String, String>,
2527    manifest: &PluginManifest,
2528) {
2529    if let Some(api_version) = manifest.api_version.clone() {
2530        metadata.insert(
2531            PLUGIN_MANIFEST_API_VERSION_METADATA_KEY.to_owned(),
2532            api_version,
2533        );
2534    } else {
2535        metadata.remove(PLUGIN_MANIFEST_API_VERSION_METADATA_KEY);
2536    }
2537
2538    if let Some(version) = manifest.version.clone() {
2539        metadata.insert(PLUGIN_VERSION_METADATA_KEY.to_owned(), version);
2540    } else {
2541        metadata.remove(PLUGIN_VERSION_METADATA_KEY);
2542    }
2543}
2544
2545fn stamp_plugin_descriptor_contract_metadata(
2546    metadata: &mut BTreeMap<String, String>,
2547    descriptor: &PluginDescriptor,
2548) {
2549    metadata.insert(
2550        PLUGIN_DIALECT_METADATA_KEY.to_owned(),
2551        descriptor.dialect.as_str().to_owned(),
2552    );
2553
2554    if let Some(dialect_version) = descriptor.dialect_version.clone() {
2555        metadata.insert(
2556            PLUGIN_DIALECT_VERSION_METADATA_KEY.to_owned(),
2557            dialect_version,
2558        );
2559    } else {
2560        metadata.remove(PLUGIN_DIALECT_VERSION_METADATA_KEY);
2561    }
2562
2563    metadata.insert(
2564        PLUGIN_COMPATIBILITY_MODE_METADATA_KEY.to_owned(),
2565        descriptor.compatibility_mode.as_str().to_owned(),
2566    );
2567
2568    if let Some(shim) = PluginCompatibilityShim::for_mode(descriptor.compatibility_mode) {
2569        metadata.insert(
2570            PLUGIN_COMPATIBILITY_SHIM_ID_METADATA_KEY.to_owned(),
2571            shim.shim_id,
2572        );
2573        metadata.insert(
2574            PLUGIN_COMPATIBILITY_SHIM_FAMILY_METADATA_KEY.to_owned(),
2575            shim.family,
2576        );
2577    } else {
2578        metadata.remove(PLUGIN_COMPATIBILITY_SHIM_ID_METADATA_KEY);
2579        metadata.remove(PLUGIN_COMPATIBILITY_SHIM_FAMILY_METADATA_KEY);
2580    }
2581}
2582
2583fn stamp_plugin_compatibility_metadata(
2584    metadata: &mut BTreeMap<String, String>,
2585    compatibility: Option<&PluginCompatibility>,
2586) {
2587    let Some(compatibility) = compatibility else {
2588        metadata.remove(PLUGIN_COMPATIBILITY_HOST_API_METADATA_KEY);
2589        metadata.remove(PLUGIN_COMPATIBILITY_HOST_VERSION_REQ_METADATA_KEY);
2590        return;
2591    };
2592
2593    if let Some(host_api) = compatibility.host_api.clone() {
2594        metadata.insert(
2595            PLUGIN_COMPATIBILITY_HOST_API_METADATA_KEY.to_owned(),
2596            host_api,
2597        );
2598    } else {
2599        metadata.remove(PLUGIN_COMPATIBILITY_HOST_API_METADATA_KEY);
2600    }
2601
2602    if let Some(host_version_req) = compatibility.host_version_req.clone() {
2603        metadata.insert(
2604            PLUGIN_COMPATIBILITY_HOST_VERSION_REQ_METADATA_KEY.to_owned(),
2605            host_version_req,
2606        );
2607    } else {
2608        metadata.remove(PLUGIN_COMPATIBILITY_HOST_VERSION_REQ_METADATA_KEY);
2609    }
2610}
2611
2612fn current_plugin_host_version() -> Result<Version, String> {
2613    let raw_version = env!("CARGO_PKG_VERSION");
2614    let parsed_version = Version::parse(raw_version);
2615
2616    parsed_version.map_err(|error| {
2617        format!("current host version `{raw_version}` is invalid and cannot satisfy plugin compatibility checks: {error}")
2618    })
2619}
2620
2621fn normalize_optional_manifest_string(raw: Option<String>) -> Option<String> {
2622    let value = raw?;
2623    let trimmed = value.trim();
2624
2625    if trimmed.is_empty() {
2626        return None;
2627    }
2628
2629    Some(trimmed.to_owned())
2630}
2631
2632fn normalize_manifest_string_list(values: Vec<String>) -> Vec<String> {
2633    let mut normalized_values = Vec::new();
2634
2635    for value in values {
2636        let trimmed = value.trim();
2637        let is_empty = trimmed.is_empty();
2638
2639        if is_empty {
2640            continue;
2641        }
2642
2643        let candidate = trimmed.to_owned();
2644        let is_duplicate = normalized_values
2645            .iter()
2646            .any(|existing| existing == &candidate);
2647
2648        if is_duplicate {
2649            continue;
2650        }
2651
2652        normalized_values.push(candidate);
2653    }
2654
2655    normalized_values
2656}
2657
2658fn detect_language(path: &Path) -> String {
2659    if is_package_manifest_file(path) {
2660        return "manifest".to_owned();
2661    }
2662
2663    path.extension()
2664        .and_then(|ext| ext.to_str())
2665        .map(|ext| ext.to_lowercase())
2666        .unwrap_or_else(|| "unknown".to_owned())
2667}
2668
2669fn normalize_language_name(language: &str) -> String {
2670    match language.trim().to_ascii_lowercase().as_str() {
2671        "rs" => "rust".to_owned(),
2672        "py" => "python".to_owned(),
2673        "js" => "javascript".to_owned(),
2674        "ts" => "typescript".to_owned(),
2675        "mjs" | "cjs" | "cts" | "mts" => "javascript".to_owned(),
2676        "unknown" | "" => "unknown".to_owned(),
2677        other => other.to_owned(),
2678    }
2679}
2680
2681#[derive(Debug, Clone, PartialEq, Eq)]
2682struct ManifestFieldConflict {
2683    field: String,
2684    package_value: String,
2685    source_value: String,
2686}
2687
2688#[cfg(test)]
2689mod tests {
2690    use super::*;
2691    use std::time::{SystemTime, UNIX_EPOCH};
2692
2693    fn unique_tmp_dir(prefix: &str) -> PathBuf {
2694        let nanos = SystemTime::now()
2695            .duration_since(UNIX_EPOCH)
2696            .expect("clock should be monotonic")
2697            .as_nanos();
2698        std::env::temp_dir().join(format!("{}-{}", prefix, nanos))
2699    }
2700
2701    fn sample_pack() -> VerticalPackManifest {
2702        VerticalPackManifest {
2703            pack_id: "sample-pack".to_owned(),
2704            domain: "engineering".to_owned(),
2705            version: "0.1.0".to_owned(),
2706            default_route: crate::contracts::ExecutionRoute {
2707                harness_kind: crate::contracts::HarnessKind::EmbeddedPi,
2708                adapter: Some("pi-local".to_owned()),
2709            },
2710            allowed_connectors: BTreeSet::new(),
2711            granted_capabilities: BTreeSet::new(),
2712            metadata: BTreeMap::new(),
2713        }
2714    }
2715
2716    fn scan_diagnostic<'a>(
2717        report: &'a PluginScanReport,
2718        code: PluginDiagnosticCode,
2719        plugin_id: &str,
2720    ) -> Option<&'a PluginDiagnosticFinding> {
2721        report
2722            .diagnostic_findings
2723            .iter()
2724            .find(|finding| finding.code == code && finding.plugin_id.as_deref() == Some(plugin_id))
2725    }
2726
2727    #[test]
2728    fn scanner_finds_manifest_in_rust_and_python_files() {
2729        let root = unique_tmp_dir("loong-plugin-scan");
2730        fs::create_dir_all(&root).expect("create temp root");
2731
2732        let rust_file = root.join("openrouter.rs");
2733        fs::write(
2734            &rust_file,
2735            r#"
2736// LOONG_PLUGIN_START
2737// {
2738//   "plugin_id": "openrouter-rs",
2739//   "provider_id": "openrouter",
2740//   "connector_name": "openrouter",
2741//   "channel_id": "primary",
2742//   "endpoint": "https://openrouter.ai/api/v1/chat/completions",
2743//   "capabilities": ["InvokeConnector", "ObserveTelemetry"],
2744//   "metadata": {"version":"0.2.0","lang":"rust"}
2745// }
2746// LOONG_PLUGIN_END
2747"#,
2748        )
2749        .expect("write rust plugin");
2750
2751        let py_file = root.join("slack_plugin.py");
2752        fs::write(
2753            &py_file,
2754            r#"
2755# LOONG_PLUGIN_START
2756# {
2757#   "plugin_id": "slack-py",
2758#   "provider_id": "slack",
2759#   "connector_name": "slack",
2760#   "channel_id": "alerts",
2761#   "endpoint": "https://hooks.slack.com/services/aaa/bbb/ccc",
2762#   "capabilities": ["InvokeConnector"],
2763#   "metadata": {"version":"1.1.0","lang":"python"}
2764# }
2765# LOONG_PLUGIN_END
2766"#,
2767        )
2768        .expect("write python plugin");
2769
2770        let scanner = PluginScanner::new();
2771        let report = scanner.scan_path(&root).expect("scan should succeed");
2772        assert_eq!(report.matched_plugins, 2);
2773        assert!(
2774            report
2775                .descriptors
2776                .iter()
2777                .any(|descriptor| descriptor.manifest.provider_id == "openrouter")
2778        );
2779        assert!(
2780            report
2781                .descriptors
2782                .iter()
2783                .all(|descriptor| descriptor.source_kind == PluginSourceKind::EmbeddedSource)
2784        );
2785        assert!(
2786            report
2787                .descriptors
2788                .iter()
2789                .all(|descriptor| descriptor.package_manifest_path.is_none())
2790        );
2791        assert!(report.descriptors.iter().all(|descriptor| matches!(
2792            descriptor.manifest.trust_tier,
2793            PluginTrustTier::Unverified
2794        )));
2795        assert!(
2796            report
2797                .descriptors
2798                .iter()
2799                .any(|descriptor| descriptor.manifest.provider_id == "slack")
2800        );
2801        assert_eq!(
2802            report
2803                .diagnostic_findings
2804                .iter()
2805                .filter(|finding| finding.code == PluginDiagnosticCode::EmbeddedSourceLegacyContract)
2806                .count(),
2807            2
2808        );
2809        assert_eq!(
2810            report
2811                .diagnostic_findings
2812                .iter()
2813                .filter(|finding| finding.code == PluginDiagnosticCode::LegacyMetadataVersion)
2814                .count(),
2815            2
2816        );
2817    }
2818
2819    #[test]
2820    fn scanner_finds_package_manifest_file() {
2821        let root = unique_tmp_dir("loong-plugin-package-manifest");
2822        fs::create_dir_all(&root).expect("create temp root");
2823
2824        let manifest_file = root.join(PACKAGE_MANIFEST_FILE_NAME);
2825        fs::write(
2826            &manifest_file,
2827            r#"
2828{
2829  "api_version": "v1alpha1",
2830  "plugin_id": "tavily-search",
2831  "version": "0.3.0",
2832  "provider_id": "tavily",
2833  "connector_name": "tavily-http",
2834  "endpoint": "https://api.tavily.com/search",
2835  "capabilities": ["InvokeConnector"],
2836  "trust_tier": "verified-community",
2837  "metadata": {
2838    "bridge_kind": "http_json",
2839    "adapter_family": "web-search"
2840  },
2841  "summary": "Manifest-discovered Tavily package",
2842  "tags": ["search", "provider"],
2843  "setup": {
2844    "mode": "metadata_only",
2845    "surface": " web_search ",
2846    "required_env_vars": ["TAVILY_API_KEY", " ", "TAVILY_API_KEY"],
2847    "recommended_env_vars": ["TEAM_TAVILY_KEY"],
2848    "required_config_keys": ["tools.web_search.default_provider"],
2849    "default_env_var": " TAVILY_API_KEY ",
2850    "docs_urls": ["https://docs.example.com/tavily", "https://docs.example.com/tavily"],
2851    "remediation": " set a Tavily credential before enabling search "
2852  }
2853}
2854"#,
2855        )
2856        .expect("write package manifest");
2857
2858        let scanner = PluginScanner::new();
2859        let report = scanner.scan_path(&root).expect("scan should succeed");
2860
2861        assert_eq!(report.scanned_files, 1);
2862        assert_eq!(report.matched_plugins, 1);
2863        assert_eq!(report.descriptors.len(), 1);
2864        assert_eq!(
2865            report.descriptors[0].path,
2866            manifest_file.display().to_string()
2867        );
2868        assert_eq!(report.descriptors[0].language, "manifest");
2869        assert_eq!(
2870            report.descriptors[0].manifest.api_version.as_deref(),
2871            Some(CURRENT_PLUGIN_MANIFEST_API_VERSION)
2872        );
2873        assert_eq!(
2874            report.descriptors[0].manifest.version.as_deref(),
2875            Some("0.3.0")
2876        );
2877        assert_eq!(report.descriptors[0].manifest.plugin_id, "tavily-search");
2878        assert_eq!(report.descriptors[0].manifest.provider_id, "tavily");
2879        assert_eq!(
2880            report.descriptors[0]
2881                .manifest
2882                .metadata
2883                .get("version")
2884                .map(String::as_str),
2885            Some("0.3.0")
2886        );
2887        assert_eq!(
2888            report.descriptors[0].source_kind,
2889            PluginSourceKind::PackageManifest
2890        );
2891        assert_eq!(
2892            report.descriptors[0].package_root,
2893            root.display().to_string()
2894        );
2895        assert_eq!(
2896            report.descriptors[0].package_manifest_path,
2897            Some(manifest_file.display().to_string())
2898        );
2899        assert_eq!(
2900            report.descriptors[0].manifest.trust_tier,
2901            PluginTrustTier::VerifiedCommunity
2902        );
2903        assert_eq!(
2904            report.descriptors[0].manifest.setup,
2905            Some(PluginSetup {
2906                mode: PluginSetupMode::MetadataOnly,
2907                surface: Some("web_search".to_owned()),
2908                required_env_vars: vec!["TAVILY_API_KEY".to_owned()],
2909                recommended_env_vars: vec!["TEAM_TAVILY_KEY".to_owned()],
2910                required_config_keys: vec!["tools.web_search.default_provider".to_owned()],
2911                default_env_var: Some("TAVILY_API_KEY".to_owned()),
2912                docs_urls: vec!["https://docs.example.com/tavily".to_owned()],
2913                remediation: Some("set a Tavily credential before enabling search".to_owned()),
2914            })
2915        );
2916    }
2917
2918    #[test]
2919    fn scanner_requires_api_version_for_package_manifest() {
2920        let root = unique_tmp_dir("loong-plugin-package-api-required");
2921        fs::create_dir_all(&root).expect("create temp root");
2922
2923        let manifest_file = root.join(PACKAGE_MANIFEST_FILE_NAME);
2924        fs::write(
2925            &manifest_file,
2926            r#"
2927{
2928  "version": "1.0.0",
2929  "plugin_id": "missing-api-version",
2930  "provider_id": "missing-api-version",
2931  "connector_name": "missing-api-version",
2932  "capabilities": ["InvokeConnector"],
2933  "metadata": {
2934    "bridge_kind": "http_json"
2935  }
2936}
2937"#,
2938        )
2939        .expect("write package manifest");
2940
2941        let error = PluginScanner::new()
2942            .scan_path(&root)
2943            .expect_err("package manifests must declare api_version");
2944
2945        let rendered = error.to_string();
2946        assert!(rendered.contains("api_version"));
2947        assert!(rendered.contains("package manifest"));
2948    }
2949
2950    #[test]
2951    fn scanner_requires_top_level_version_for_package_manifest() {
2952        let root = unique_tmp_dir("loong-plugin-package-version-required");
2953        fs::create_dir_all(&root).expect("create temp root");
2954
2955        let manifest_file = root.join(PACKAGE_MANIFEST_FILE_NAME);
2956        fs::write(
2957            &manifest_file,
2958            r#"
2959{
2960  "api_version": "v1alpha1",
2961  "plugin_id": "missing-version",
2962  "provider_id": "missing-version",
2963  "connector_name": "missing-version",
2964  "capabilities": ["InvokeConnector"],
2965  "metadata": {
2966    "bridge_kind": "http_json"
2967  }
2968}
2969"#,
2970        )
2971        .expect("write package manifest");
2972
2973        let error = PluginScanner::new()
2974            .scan_path(&root)
2975            .expect_err("package manifests must declare top-level version");
2976
2977        let rendered = error.to_string();
2978        assert!(rendered.contains("top-level version"));
2979        assert!(rendered.contains("package manifest"));
2980    }
2981
2982    #[test]
2983    fn scanner_rejects_legacy_version_metadata_in_package_manifest() {
2984        let root = unique_tmp_dir("loong-plugin-package-legacy-version");
2985        fs::create_dir_all(&root).expect("create temp root");
2986
2987        let manifest_file = root.join(PACKAGE_MANIFEST_FILE_NAME);
2988        fs::write(
2989            &manifest_file,
2990            r#"
2991{
2992  "api_version": "v1alpha1",
2993  "version": "1.2.3",
2994  "plugin_id": "legacy-version-metadata",
2995  "provider_id": "legacy-version-metadata",
2996  "connector_name": "legacy-version-metadata",
2997  "capabilities": ["InvokeConnector"],
2998  "metadata": {
2999    "bridge_kind": "http_json",
3000    "version": "1.2.3"
3001  }
3002}
3003"#,
3004        )
3005        .expect("write package manifest");
3006
3007        let error = PluginScanner::new()
3008            .scan_path(&root)
3009            .expect_err("package manifests should reject metadata.version");
3010
3011        let rendered = error.to_string();
3012        assert!(rendered.contains("metadata.version"));
3013        assert!(rendered.contains("top-level `version`"));
3014    }
3015
3016    #[test]
3017    fn scanner_rejects_reserved_metadata_namespace_in_package_manifest() {
3018        let root = unique_tmp_dir("loong-plugin-package-reserved-metadata");
3019        fs::create_dir_all(&root).expect("create temp root");
3020
3021        let manifest_file = root.join(PACKAGE_MANIFEST_FILE_NAME);
3022        fs::write(
3023            &manifest_file,
3024            r#"
3025{
3026  "api_version": "v1alpha1",
3027  "version": "1.2.3",
3028  "plugin_id": "reserved-metadata",
3029  "provider_id": "reserved-metadata",
3030  "connector_name": "reserved-metadata",
3031  "capabilities": ["InvokeConnector"],
3032  "metadata": {
3033    "bridge_kind": "http_json",
3034    "plugin_version": "1.2.3"
3035  }
3036}
3037"#,
3038        )
3039        .expect("write package manifest");
3040
3041        let error = PluginScanner::new()
3042            .scan_path(&root)
3043            .expect_err("package manifests should reject reserved metadata namespace");
3044
3045        let rendered = error.to_string();
3046        assert!(rendered.contains("plugin_version"));
3047        assert!(rendered.contains("reserved"));
3048    }
3049
3050    #[test]
3051    fn scanner_rejects_invalid_top_level_plugin_version() {
3052        let root = unique_tmp_dir("loong-plugin-invalid-version");
3053        fs::create_dir_all(&root).expect("create temp root");
3054
3055        let manifest_file = root.join(PACKAGE_MANIFEST_FILE_NAME);
3056        fs::write(
3057            &manifest_file,
3058            r#"
3059{
3060  "api_version": "v1alpha1",
3061  "version": "not-a-semver",
3062  "plugin_id": "bad-version",
3063  "provider_id": "bad-version",
3064  "connector_name": "bad-version",
3065  "capabilities": ["InvokeConnector"],
3066  "metadata": {
3067    "bridge_kind": "http_json"
3068  }
3069}
3070"#,
3071        )
3072        .expect("write package manifest");
3073
3074        let error = PluginScanner::new()
3075            .scan_path(&root)
3076            .expect_err("invalid plugin version should fail parse");
3077
3078        let rendered = error.to_string();
3079        assert!(rendered.contains("invalid semver"));
3080        assert!(rendered.contains("not-a-semver"));
3081    }
3082
3083    #[test]
3084    fn scanner_rejects_conflicting_top_level_and_metadata_version_in_source_manifest() {
3085        let root = unique_tmp_dir("loong-plugin-source-version-conflict");
3086        fs::create_dir_all(&root).expect("create temp root");
3087
3088        let source_file = root.join("plugin.py");
3089        fs::write(
3090            &source_file,
3091            r#"
3092# LOONG_PLUGIN_START
3093# {
3094#   "version": "1.2.3",
3095#   "plugin_id": "source-version-conflict",
3096#   "provider_id": "source-version-conflict",
3097#   "connector_name": "source-version-conflict",
3098#   "capabilities": ["InvokeConnector"],
3099#   "metadata": {
3100#     "bridge_kind": "http_json",
3101#     "version": "9.9.9"
3102#   }
3103# }
3104# LOONG_PLUGIN_END
3105"#,
3106        )
3107        .expect("write source manifest");
3108
3109        let error = PluginScanner::new()
3110            .scan_path(&root)
3111            .expect_err("source manifests should reject conflicting version truth");
3112
3113        let rendered = error.to_string();
3114        assert!(rendered.contains("plugin version conflict"));
3115        assert!(rendered.contains("1.2.3"));
3116        assert!(rendered.contains("9.9.9"));
3117    }
3118
3119    #[test]
3120    fn scanner_rejects_unknown_package_manifest_fields() {
3121        let root = unique_tmp_dir("loong-plugin-unknown-package-field");
3122        fs::create_dir_all(&root).expect("create temp root");
3123
3124        let manifest_file = root.join(PACKAGE_MANIFEST_FILE_NAME);
3125        fs::write(
3126            &manifest_file,
3127            r#"
3128{
3129  "api_version": "v1alpha1",
3130  "version": "1.0.0",
3131  "plugin_id": "unknown-field",
3132  "provider_id": "unknown-field",
3133  "connector_name": "unknown-field",
3134  "capabilities": ["InvokeConnector"],
3135  "metadata": {
3136    "bridge_kind": "http_json"
3137  },
3138  "slot_claim": []
3139}
3140"#,
3141        )
3142        .expect("write package manifest");
3143
3144        let error = PluginScanner::new()
3145            .scan_path(&root)
3146            .expect_err("unknown package manifest fields should fail parse");
3147
3148        let rendered = error.to_string();
3149        assert!(rendered.contains("unknown field"));
3150        assert!(rendered.contains("slot_claim"));
3151    }
3152
3153    #[test]
3154    fn scanner_prefers_package_manifest_over_embedded_source_manifest() {
3155        let root = unique_tmp_dir("loong-plugin-precedence");
3156        let package_root = root.join("pkg");
3157        fs::create_dir_all(&package_root).expect("create temp root");
3158
3159        let manifest_file = package_root.join(PACKAGE_MANIFEST_FILE_NAME);
3160        fs::write(
3161            &manifest_file,
3162            r#"
3163{
3164  "api_version": "v1alpha1",
3165  "version": "1.0.0",
3166  "plugin_id": "package-plugin",
3167  "provider_id": "package-provider",
3168  "connector_name": "package-connector",
3169  "channel_id": "package-channel",
3170  "endpoint": "https://package.example/invoke",
3171  "capabilities": ["InvokeConnector"],
3172  "metadata": {
3173    "bridge_kind": "http_json"
3174  }
3175}
3176"#,
3177        )
3178        .expect("write package manifest");
3179
3180        let source_file = package_root.join("plugin.py");
3181        fs::write(
3182            &source_file,
3183            r#"
3184# LOONG_PLUGIN_START
3185# {
3186#   "plugin_id": "package-plugin",
3187#   "provider_id": "package-provider",
3188#   "connector_name": "package-connector",
3189#   "channel_id": "package-channel",
3190#   "endpoint": "https://package.example/invoke",
3191#   "capabilities": ["InvokeConnector"],
3192#   "metadata": {"bridge_kind":"http_json"}
3193# }
3194# LOONG_PLUGIN_END
3195"#,
3196        )
3197        .expect("write source plugin");
3198
3199        let scanner = PluginScanner::new();
3200        let report = scanner.scan_path(&root).expect("scan should succeed");
3201
3202        assert_eq!(report.scanned_files, 2);
3203        assert_eq!(report.matched_plugins, 1);
3204        assert_eq!(report.descriptors.len(), 1);
3205        assert_eq!(
3206            report.descriptors[0].path,
3207            manifest_file.display().to_string()
3208        );
3209        assert_eq!(
3210            report.descriptors[0].source_kind,
3211            PluginSourceKind::PackageManifest
3212        );
3213        assert_eq!(
3214            report.descriptors[0].package_root,
3215            package_root.display().to_string()
3216        );
3217        assert_eq!(
3218            report.descriptors[0].package_manifest_path,
3219            Some(manifest_file.display().to_string())
3220        );
3221        assert_eq!(report.descriptors[0].manifest.plugin_id, "package-plugin");
3222        assert_eq!(
3223            report.descriptors[0].manifest.provider_id,
3224            "package-provider"
3225        );
3226        let finding = scan_diagnostic(
3227            &report,
3228            PluginDiagnosticCode::ShadowedEmbeddedSource,
3229            "package-plugin",
3230        )
3231        .expect("shadowed embedded source finding");
3232        assert_eq!(finding.phase, PluginDiagnosticPhase::Scan);
3233        assert!(!finding.blocking);
3234    }
3235
3236    #[test]
3237    fn scanner_fails_when_package_manifest_conflicts_with_source_manifest() {
3238        let root = unique_tmp_dir("loong-plugin-conflict");
3239        let package_root = root.join("pkg");
3240        fs::create_dir_all(&package_root).expect("create temp root");
3241
3242        let manifest_file = package_root.join(PACKAGE_MANIFEST_FILE_NAME);
3243        fs::write(
3244            &manifest_file,
3245            r#"
3246{
3247  "api_version": "v1alpha1",
3248  "version": "1.0.0",
3249  "plugin_id": "package-plugin",
3250  "provider_id": "package-provider",
3251  "connector_name": "package-connector",
3252  "channel_id": "package-channel",
3253  "endpoint": "https://package.example/invoke",
3254  "capabilities": ["InvokeConnector"],
3255  "metadata": {
3256    "bridge_kind": "http_json"
3257  }
3258}
3259"#,
3260        )
3261        .expect("write package manifest");
3262
3263        let source_file = package_root.join("plugin.py");
3264        fs::write(
3265            &source_file,
3266            r#"
3267# LOONG_PLUGIN_START
3268# {
3269#   "plugin_id": "package-plugin",
3270#   "provider_id": "source-provider",
3271#   "connector_name": "package-connector",
3272#   "channel_id": "package-channel",
3273#   "endpoint": "https://package.example/invoke",
3274#   "capabilities": ["InvokeConnector"],
3275#   "metadata": {"bridge_kind":"http_json"}
3276# }
3277# LOONG_PLUGIN_END
3278"#,
3279        )
3280        .expect("write source plugin");
3281
3282        let scanner = PluginScanner::new();
3283        let error = scanner
3284            .scan_path(&root)
3285            .expect_err("conflicting manifests should fail");
3286
3287        assert_eq!(
3288            error,
3289            IntegrationError::PluginManifestConflict {
3290                package_manifest_path: manifest_file.display().to_string(),
3291                source_path: source_file.display().to_string(),
3292                field: "provider_id".to_owned(),
3293                package_value: "\"package-provider\"".to_owned(),
3294                source_value: "\"source-provider\"".to_owned(),
3295            }
3296        );
3297    }
3298
3299    #[test]
3300    fn scanner_uses_nearest_package_manifest_for_nested_package_roots() {
3301        let root = unique_tmp_dir("loong-plugin-nested-package-root");
3302        let outer_root = root.join("outer");
3303        let inner_root = outer_root.join("inner");
3304        fs::create_dir_all(&inner_root).expect("create nested root");
3305
3306        let outer_manifest_file = outer_root.join(PACKAGE_MANIFEST_FILE_NAME);
3307        fs::write(
3308            &outer_manifest_file,
3309            r#"
3310{
3311  "api_version": "v1alpha1",
3312  "version": "1.0.0",
3313  "plugin_id": "outer-plugin",
3314  "provider_id": "outer-provider",
3315  "connector_name": "outer-connector",
3316  "channel_id": "outer-channel",
3317  "endpoint": "https://outer.example/invoke",
3318  "capabilities": ["InvokeConnector"],
3319  "metadata": {
3320    "bridge_kind": "http_json"
3321  }
3322}
3323"#,
3324        )
3325        .expect("write outer package manifest");
3326
3327        let inner_manifest_file = inner_root.join(PACKAGE_MANIFEST_FILE_NAME);
3328        fs::write(
3329            &inner_manifest_file,
3330            r#"
3331{
3332  "api_version": "v1alpha1",
3333  "version": "1.0.0",
3334  "plugin_id": "inner-plugin",
3335  "provider_id": "inner-provider",
3336  "connector_name": "inner-connector",
3337  "channel_id": "inner-channel",
3338  "endpoint": "https://inner.example/invoke",
3339  "capabilities": ["InvokeConnector"],
3340  "metadata": {
3341    "bridge_kind": "http_json"
3342  }
3343}
3344"#,
3345        )
3346        .expect("write inner package manifest");
3347
3348        let source_file = inner_root.join("plugin.py");
3349        fs::write(
3350            &source_file,
3351            r#"
3352# LOONG_PLUGIN_START
3353# {
3354#   "plugin_id": "inner-plugin",
3355#   "provider_id": "inner-provider",
3356#   "connector_name": "inner-connector",
3357#   "channel_id": "inner-channel",
3358#   "endpoint": "https://inner.example/invoke",
3359#   "capabilities": ["InvokeConnector"],
3360#   "metadata": {"bridge_kind":"http_json"}
3361# }
3362# LOONG_PLUGIN_END
3363"#,
3364        )
3365        .expect("write nested source plugin");
3366
3367        let scanner = PluginScanner::new();
3368        let report = scanner.scan_path(&root).expect("scan should succeed");
3369
3370        assert_eq!(report.matched_plugins, 2);
3371        assert_eq!(report.descriptors.len(), 2);
3372        assert!(
3373            report
3374                .descriptors
3375                .iter()
3376                .any(|descriptor| descriptor.path == outer_manifest_file.display().to_string())
3377        );
3378        assert!(
3379            report
3380                .descriptors
3381                .iter()
3382                .any(|descriptor| descriptor.path == inner_manifest_file.display().to_string())
3383        );
3384    }
3385
3386    #[test]
3387    fn scanner_allows_source_only_optional_fields_under_package_manifest() {
3388        let root = unique_tmp_dir("loong-plugin-optional-source-fields");
3389        let package_root = root.join("pkg");
3390        fs::create_dir_all(&package_root).expect("create temp root");
3391
3392        let manifest_file = package_root.join(PACKAGE_MANIFEST_FILE_NAME);
3393        fs::write(
3394            &manifest_file,
3395            r#"
3396{
3397  "api_version": "v1alpha1",
3398  "version": "1.0.0",
3399  "plugin_id": "package-plugin",
3400  "provider_id": "package-provider",
3401  "connector_name": "package-connector",
3402  "channel_id": "package-channel",
3403  "endpoint": "https://package.example/invoke",
3404  "capabilities": ["InvokeConnector"],
3405  "metadata": {
3406    "bridge_kind": "http_json"
3407  }
3408}
3409"#,
3410        )
3411        .expect("write package manifest");
3412
3413        let source_file = package_root.join("plugin.py");
3414        fs::write(
3415            &source_file,
3416            r#"
3417# LOONG_PLUGIN_START
3418# {
3419#   "plugin_id": "package-plugin",
3420#   "provider_id": "package-provider",
3421#   "connector_name": "package-connector",
3422#   "channel_id": "package-channel",
3423#   "endpoint": "https://package.example/invoke",
3424#   "capabilities": ["InvokeConnector"],
3425#   "metadata": {"bridge_kind":"http_json","legacy_source":"true"},
3426#   "summary": "legacy source summary",
3427#   "tags": ["legacy", "source"],
3428#   "input_examples": [{"query":"hello"}]
3429# }
3430# LOONG_PLUGIN_END
3431"#,
3432        )
3433        .expect("write source plugin");
3434
3435        let scanner = PluginScanner::new();
3436        let report = scanner.scan_path(&root).expect("scan should succeed");
3437
3438        assert_eq!(report.scanned_files, 2);
3439        assert_eq!(report.matched_plugins, 1);
3440        assert_eq!(report.descriptors.len(), 1);
3441        assert_eq!(
3442            report.descriptors[0].path,
3443            manifest_file.display().to_string()
3444        );
3445        assert_eq!(report.descriptors[0].manifest.summary, None);
3446        assert!(report.descriptors[0].manifest.tags.is_empty());
3447        assert!(report.descriptors[0].manifest.input_examples.is_empty());
3448        assert!(
3449            !report.descriptors[0]
3450                .manifest
3451                .metadata
3452                .contains_key("legacy_source")
3453        );
3454        assert_eq!(
3455            report.descriptors[0].manifest.provider_id,
3456            "package-provider"
3457        );
3458        assert_eq!(report.descriptors[0].language, "manifest");
3459        let finding = scan_diagnostic(
3460            &report,
3461            PluginDiagnosticCode::ShadowedEmbeddedSource,
3462            "package-plugin",
3463        )
3464        .expect("shadowed embedded source finding");
3465        assert_eq!(finding.phase, PluginDiagnosticPhase::Scan);
3466        assert!(!finding.blocking);
3467    }
3468
3469    #[test]
3470    fn scanner_falls_back_to_embedded_source_manifest_without_package_manifest() {
3471        let root = unique_tmp_dir("loong-plugin-source-fallback");
3472        let package_root = root.join("pkg");
3473        fs::create_dir_all(&package_root).expect("create temp root");
3474
3475        let source_file = package_root.join("plugin.py");
3476        fs::write(
3477            &source_file,
3478            r#"
3479# LOONG_PLUGIN_START
3480# {
3481#   "plugin_id": "source-plugin",
3482#   "provider_id": "source-provider",
3483#   "connector_name": "source-connector",
3484#   "channel_id": "source-channel",
3485#   "endpoint": "https://source.example/invoke",
3486#   "capabilities": ["InvokeConnector"],
3487#   "metadata": {"bridge_kind":"process_stdio"},
3488#   "setup": {
3489#     "surface": "channel",
3490#     "required_env_vars": ["SOURCE_TOKEN"],
3491#     "default_env_var": "SOURCE_TOKEN"
3492#   }
3493# }
3494# LOONG_PLUGIN_END
3495"#,
3496        )
3497        .expect("write source plugin");
3498
3499        let scanner = PluginScanner::new();
3500        let report = scanner.scan_path(&root).expect("scan should succeed");
3501
3502        assert_eq!(report.scanned_files, 1);
3503        assert_eq!(report.matched_plugins, 1);
3504        assert_eq!(report.descriptors.len(), 1);
3505        assert_eq!(
3506            report.descriptors[0].path,
3507            source_file.display().to_string()
3508        );
3509        assert_eq!(
3510            report.descriptors[0].source_kind,
3511            PluginSourceKind::EmbeddedSource
3512        );
3513        assert_eq!(
3514            report.descriptors[0].package_root,
3515            package_root.display().to_string()
3516        );
3517        assert_eq!(report.descriptors[0].package_manifest_path, None);
3518        assert_eq!(report.descriptors[0].language, "py");
3519        assert_eq!(report.descriptors[0].manifest.plugin_id, "source-plugin");
3520        assert_eq!(
3521            report.descriptors[0].manifest.provider_id,
3522            "source-provider"
3523        );
3524        let finding = scan_diagnostic(
3525            &report,
3526            PluginDiagnosticCode::EmbeddedSourceLegacyContract,
3527            "source-plugin",
3528        )
3529        .expect("embedded source legacy finding");
3530        assert_eq!(finding.phase, PluginDiagnosticPhase::Scan);
3531        assert!(!finding.blocking);
3532        assert_eq!(
3533            report.descriptors[0].manifest.setup,
3534            Some(PluginSetup {
3535                mode: PluginSetupMode::MetadataOnly,
3536                surface: Some("channel".to_owned()),
3537                required_env_vars: vec!["SOURCE_TOKEN".to_owned()],
3538                recommended_env_vars: Vec::new(),
3539                required_config_keys: Vec::new(),
3540                default_env_var: Some("SOURCE_TOKEN".to_owned()),
3541                docs_urls: Vec::new(),
3542                remediation: None,
3543            })
3544        );
3545    }
3546
3547    #[test]
3548    fn scanner_treats_empty_metadata_only_setup_as_absent() {
3549        let root = unique_tmp_dir("loong-plugin-empty-setup");
3550        let package_root = root.join("pkg");
3551        fs::create_dir_all(&package_root).expect("create temp root");
3552
3553        let manifest_file = package_root.join(PACKAGE_MANIFEST_FILE_NAME);
3554        fs::write(
3555            &manifest_file,
3556            r#"
3557{
3558  "api_version": "v1alpha1",
3559  "version": "1.0.0",
3560  "plugin_id": "package-plugin",
3561  "provider_id": "package-provider",
3562  "connector_name": "package-connector",
3563  "channel_id": "package-channel",
3564  "endpoint": "https://package.example/invoke",
3565  "capabilities": ["InvokeConnector"],
3566  "metadata": {
3567    "bridge_kind": "http_json"
3568  }
3569}
3570"#,
3571        )
3572        .expect("write package manifest");
3573
3574        let source_file = package_root.join("plugin.py");
3575        fs::write(
3576            &source_file,
3577            r#"
3578# LOONG_PLUGIN_START
3579# {
3580#   "plugin_id": "package-plugin",
3581#   "provider_id": "package-provider",
3582#   "connector_name": "package-connector",
3583#   "channel_id": "package-channel",
3584#   "endpoint": "https://package.example/invoke",
3585#   "capabilities": ["InvokeConnector"],
3586#   "metadata": {"bridge_kind":"http_json"},
3587#   "setup": {}
3588# }
3589# LOONG_PLUGIN_END
3590"#,
3591        )
3592        .expect("write source plugin");
3593
3594        let scanner = PluginScanner::new();
3595        let report = scanner.scan_path(&root).expect("scan should succeed");
3596
3597        assert_eq!(report.scanned_files, 2);
3598        assert_eq!(report.matched_plugins, 1);
3599        assert_eq!(report.descriptors.len(), 1);
3600        assert_eq!(report.descriptors[0].manifest.setup, None);
3601    }
3602
3603    #[test]
3604    fn scanner_recognizes_openclaw_modern_manifest_through_explicit_compatibility_boundary() {
3605        let root = unique_tmp_dir("loong-openclaw-modern");
3606        let package_root = root.join("pkg");
3607        fs::create_dir_all(package_root.join("dist")).expect("create temp root");
3608
3609        let package_manifest = package_root.join(OPENCLAW_PACKAGE_MANIFEST_FILE_NAME);
3610        fs::write(
3611            &package_manifest,
3612            r#"
3613{
3614  "id": "search-sdk",
3615  "name": "Search SDK",
3616  "description": "OpenClaw search integration",
3617  "version": "1.2.3",
3618  "kind": "provider",
3619  "providers": ["web_search"],
3620  "channels": ["search"],
3621  "skills": ["search"],
3622  "configSchema": {}
3623}
3624"#,
3625        )
3626        .expect("write openclaw manifest");
3627
3628        let package_json = package_root.join(PACKAGE_JSON_FILE_NAME);
3629        fs::write(
3630            &package_json,
3631            r#"
3632{
3633  "name": "@acme/search-provider",
3634  "version": "1.2.3",
3635  "description": "Search provider package",
3636  "openclaw": {
3637    "extensions": ["dist/index.js"],
3638    "setupEntry": "dist/setup.js",
3639    "channel": {
3640      "id": "search",
3641      "label": "Search",
3642      "aliases": ["web-search"]
3643    }
3644  }
3645}
3646"#,
3647        )
3648        .expect("write package.json");
3649        fs::write(package_root.join("dist/index.js"), "export {};\n").expect("write entry");
3650        fs::write(package_root.join("dist/setup.js"), "export {};\n").expect("write setup");
3651
3652        let report = PluginScanner::new()
3653            .scan_path(&root)
3654            .expect("scan should succeed");
3655
3656        assert_eq!(report.matched_plugins, 1);
3657        assert_eq!(report.descriptors.len(), 1);
3658        assert_eq!(
3659            report.descriptors[0].dialect,
3660            PluginContractDialect::OpenClawModernManifest
3661        );
3662        assert_eq!(
3663            report.descriptors[0].compatibility_mode,
3664            PluginCompatibilityMode::OpenClawModern
3665        );
3666        assert_eq!(report.descriptors[0].language, "js");
3667        assert_eq!(report.descriptors[0].manifest.plugin_id, "search-sdk");
3668        assert_eq!(
3669            report.descriptors[0].package_manifest_path,
3670            Some(package_manifest.display().to_string())
3671        );
3672        assert_eq!(
3673            report.descriptors[0].path,
3674            package_root.join("dist/index.js").display().to_string()
3675        );
3676        let foreign = scan_diagnostic(
3677            &report,
3678            PluginDiagnosticCode::ForeignDialectContract,
3679            "search-sdk",
3680        )
3681        .expect("foreign dialect diagnostic");
3682        assert_eq!(foreign.phase, PluginDiagnosticPhase::Scan);
3683        assert!(!foreign.blocking);
3684        assert_eq!(
3685            report.descriptors[0]
3686                .manifest
3687                .metadata
3688                .get("adapter_family")
3689                .map(String::as_str),
3690            Some(OPENCLAW_MODERN_COMPATIBILITY_ADAPTER_FAMILY)
3691        );
3692    }
3693
3694    #[test]
3695    fn scanner_recognizes_openclaw_legacy_package_metadata_without_promoting_it_to_native() {
3696        let root = unique_tmp_dir("loong-openclaw-legacy");
3697        let package_root = root.join("pkg");
3698        fs::create_dir_all(package_root.join("dist")).expect("create temp root");
3699
3700        let package_json = package_root.join(PACKAGE_JSON_FILE_NAME);
3701        fs::write(
3702            &package_json,
3703            r#"
3704{
3705  "name": "@acme/search-provider",
3706  "version": "0.9.0",
3707  "description": "Legacy OpenClaw package",
3708  "openclaw": {
3709    "extensions": ["dist/index.js"],
3710    "setupEntry": "dist/setup.js"
3711  }
3712}
3713"#,
3714        )
3715        .expect("write legacy package.json");
3716        fs::write(package_root.join("dist/index.js"), "export {};\n").expect("write entry");
3717        fs::write(package_root.join("dist/setup.js"), "export {};\n").expect("write setup");
3718
3719        let report = PluginScanner::new()
3720            .scan_path(&root)
3721            .expect("scan should succeed");
3722
3723        assert_eq!(report.matched_plugins, 1);
3724        assert_eq!(report.descriptors.len(), 1);
3725        assert_eq!(
3726            report.descriptors[0].dialect,
3727            PluginContractDialect::OpenClawLegacyPackage
3728        );
3729        assert_eq!(
3730            report.descriptors[0].compatibility_mode,
3731            PluginCompatibilityMode::OpenClawLegacy
3732        );
3733        assert_eq!(report.descriptors[0].manifest.plugin_id, "search");
3734        assert_eq!(
3735            report.descriptors[0].package_manifest_path,
3736            Some(package_json.display().to_string())
3737        );
3738        let foreign = scan_diagnostic(
3739            &report,
3740            PluginDiagnosticCode::ForeignDialectContract,
3741            "search",
3742        )
3743        .expect("foreign dialect diagnostic");
3744        assert_eq!(foreign.phase, PluginDiagnosticPhase::Scan);
3745        let legacy = scan_diagnostic(
3746            &report,
3747            PluginDiagnosticCode::LegacyOpenClawContract,
3748            "search",
3749        )
3750        .expect("legacy openclaw diagnostic");
3751        assert_eq!(legacy.phase, PluginDiagnosticPhase::Scan);
3752        assert_eq!(
3753            report.descriptors[0]
3754                .manifest
3755                .metadata
3756                .get("adapter_family")
3757                .map(String::as_str),
3758            Some(OPENCLAW_LEGACY_COMPATIBILITY_ADAPTER_FAMILY)
3759        );
3760    }
3761
3762    #[test]
3763    fn scanner_absorbs_plugins_into_catalog_and_pack() {
3764        let report = PluginScanReport {
3765            scanned_files: 1,
3766            matched_plugins: 1,
3767            diagnostic_findings: Vec::new(),
3768            descriptors: vec![PluginDescriptor {
3769                path: "/tmp/openai.rs".to_owned(),
3770                source_kind: PluginSourceKind::EmbeddedSource,
3771                dialect: PluginContractDialect::LoongEmbeddedSource,
3772                dialect_version: None,
3773                compatibility_mode: PluginCompatibilityMode::Native,
3774                package_root: "/tmp".to_owned(),
3775                package_manifest_path: None,
3776                language: "rs".to_owned(),
3777                manifest: PluginManifest {
3778                    api_version: None,
3779                    version: Some("1.3.0".to_owned()),
3780                    plugin_id: "openai-rs".to_owned(),
3781                    provider_id: "openai".to_owned(),
3782                    connector_name: "openai".to_owned(),
3783                    channel_id: Some("chat-main".to_owned()),
3784                    endpoint: Some("https://api.openai.com/v1/chat/completions".to_owned()),
3785                    capabilities: BTreeSet::from([
3786                        Capability::InvokeConnector,
3787                        Capability::ObserveTelemetry,
3788                    ]),
3789                    trust_tier: PluginTrustTier::Official,
3790                    metadata: BTreeMap::from([("version".to_owned(), "1.3.0".to_owned())]),
3791                    summary: None,
3792                    tags: Vec::new(),
3793                    input_examples: Vec::new(),
3794                    output_examples: Vec::new(),
3795                    defer_loading: false,
3796                    setup: None,
3797                    slot_claims: Vec::new(),
3798                    compatibility: None,
3799                },
3800            }],
3801        };
3802
3803        let mut catalog = IntegrationCatalog::new();
3804        let mut pack = sample_pack();
3805        let scanner = PluginScanner::new();
3806
3807        let absorb = scanner
3808            .absorb(&mut catalog, &mut pack, &report)
3809            .expect("absorb should succeed");
3810        assert_eq!(absorb.absorbed_plugins, 1);
3811        assert_eq!(absorb.provider_upserts, 1);
3812        assert_eq!(absorb.channel_upserts, 1);
3813        assert!(catalog.provider("openai").is_some());
3814        assert!(catalog.channel("chat-main").is_some());
3815        assert!(pack.allowed_connectors.contains("openai"));
3816        assert!(
3817            pack.granted_capabilities
3818                .contains(&Capability::InvokeConnector)
3819        );
3820    }
3821
3822    #[test]
3823    fn absorb_rejects_conflicting_exclusive_slot_claims() {
3824        let report = PluginScanReport {
3825            scanned_files: 2,
3826            matched_plugins: 2,
3827            diagnostic_findings: Vec::new(),
3828            descriptors: vec![
3829                PluginDescriptor {
3830                    path: "/tmp/search-a.py".to_owned(),
3831                    source_kind: PluginSourceKind::EmbeddedSource,
3832                    dialect: PluginContractDialect::LoongEmbeddedSource,
3833                    dialect_version: None,
3834                    compatibility_mode: PluginCompatibilityMode::Native,
3835                    package_root: "/tmp".to_owned(),
3836                    package_manifest_path: None,
3837                    language: "py".to_owned(),
3838                    manifest: PluginManifest {
3839                        api_version: None,
3840                        version: None,
3841                        plugin_id: "search-a".to_owned(),
3842                        provider_id: "search-a".to_owned(),
3843                        connector_name: "search-a".to_owned(),
3844                        channel_id: None,
3845                        endpoint: None,
3846                        capabilities: BTreeSet::from([Capability::InvokeConnector]),
3847                        trust_tier: PluginTrustTier::Unverified,
3848                        metadata: BTreeMap::new(),
3849                        summary: None,
3850                        tags: Vec::new(),
3851                        input_examples: Vec::new(),
3852                        output_examples: Vec::new(),
3853                        defer_loading: false,
3854                        setup: None,
3855                        slot_claims: vec![PluginSlotClaim {
3856                            slot: "provider:web_search".to_owned(),
3857                            key: "tavily".to_owned(),
3858                            mode: PluginSlotMode::Exclusive,
3859                        }],
3860                        compatibility: None,
3861                    },
3862                },
3863                PluginDescriptor {
3864                    path: "/tmp/search-b.py".to_owned(),
3865                    source_kind: PluginSourceKind::EmbeddedSource,
3866                    dialect: PluginContractDialect::LoongEmbeddedSource,
3867                    dialect_version: None,
3868                    compatibility_mode: PluginCompatibilityMode::Native,
3869                    package_root: "/tmp".to_owned(),
3870                    package_manifest_path: None,
3871                    language: "py".to_owned(),
3872                    manifest: PluginManifest {
3873                        api_version: None,
3874                        version: None,
3875                        plugin_id: "search-b".to_owned(),
3876                        provider_id: "search-b".to_owned(),
3877                        connector_name: "search-b".to_owned(),
3878                        channel_id: None,
3879                        endpoint: None,
3880                        capabilities: BTreeSet::from([Capability::InvokeConnector]),
3881                        trust_tier: PluginTrustTier::Unverified,
3882                        metadata: BTreeMap::new(),
3883                        summary: None,
3884                        tags: Vec::new(),
3885                        input_examples: Vec::new(),
3886                        output_examples: Vec::new(),
3887                        defer_loading: false,
3888                        setup: None,
3889                        slot_claims: vec![PluginSlotClaim {
3890                            slot: "provider:web_search".to_owned(),
3891                            key: "tavily".to_owned(),
3892                            mode: PluginSlotMode::Exclusive,
3893                        }],
3894                        compatibility: None,
3895                    },
3896                },
3897            ],
3898        };
3899
3900        let mut catalog = IntegrationCatalog::new();
3901        let mut pack = sample_pack();
3902
3903        let error = PluginScanner::new()
3904            .absorb(&mut catalog, &mut pack, &report)
3905            .expect_err("conflicting exclusive slot claims should fail");
3906
3907        let rendered = error.to_string();
3908        assert!(rendered.contains("slot claim conflict"));
3909        assert!(catalog.provider("search-a").is_none());
3910        assert!(catalog.provider("search-b").is_none());
3911    }
3912
3913    #[test]
3914    fn absorb_allows_shared_and_advisory_slot_claims_and_projects_metadata() {
3915        let current_host_version_req = format!(">={}", env!("CARGO_PKG_VERSION"));
3916        let report = PluginScanReport {
3917            scanned_files: 2,
3918            matched_plugins: 2,
3919            diagnostic_findings: Vec::new(),
3920            descriptors: vec![
3921                PluginDescriptor {
3922                    path: "/tmp/search-shared.py".to_owned(),
3923                    source_kind: PluginSourceKind::EmbeddedSource,
3924                    dialect: PluginContractDialect::LoongEmbeddedSource,
3925                    dialect_version: None,
3926                    compatibility_mode: PluginCompatibilityMode::Native,
3927                    package_root: "/tmp".to_owned(),
3928                    package_manifest_path: None,
3929                    language: "py".to_owned(),
3930                    manifest: PluginManifest {
3931                        api_version: None,
3932                        version: Some("1.0.0".to_owned()),
3933                        plugin_id: "search-shared".to_owned(),
3934                        provider_id: "search-shared".to_owned(),
3935                        connector_name: "search-shared".to_owned(),
3936                        channel_id: None,
3937                        endpoint: None,
3938                        capabilities: BTreeSet::from([Capability::InvokeConnector]),
3939                        trust_tier: PluginTrustTier::Unverified,
3940                        metadata: BTreeMap::new(),
3941                        summary: None,
3942                        tags: Vec::new(),
3943                        input_examples: Vec::new(),
3944                        output_examples: Vec::new(),
3945                        defer_loading: false,
3946                        setup: None,
3947                        slot_claims: vec![PluginSlotClaim {
3948                            slot: "tool:search".to_owned(),
3949                            key: "web".to_owned(),
3950                            mode: PluginSlotMode::Shared,
3951                        }],
3952                        compatibility: Some(PluginCompatibility {
3953                            host_api: Some(CURRENT_PLUGIN_HOST_API.to_owned()),
3954                            host_version_req: Some(current_host_version_req.clone()),
3955                        }),
3956                    },
3957                },
3958                PluginDescriptor {
3959                    path: "/tmp/search-advisory.py".to_owned(),
3960                    source_kind: PluginSourceKind::EmbeddedSource,
3961                    dialect: PluginContractDialect::LoongEmbeddedSource,
3962                    dialect_version: None,
3963                    compatibility_mode: PluginCompatibilityMode::Native,
3964                    package_root: "/tmp".to_owned(),
3965                    package_manifest_path: None,
3966                    language: "py".to_owned(),
3967                    manifest: PluginManifest {
3968                        api_version: None,
3969                        version: None,
3970                        plugin_id: "search-advisory".to_owned(),
3971                        provider_id: "search-advisory".to_owned(),
3972                        connector_name: "search-advisory".to_owned(),
3973                        channel_id: None,
3974                        endpoint: None,
3975                        capabilities: BTreeSet::from([Capability::InvokeConnector]),
3976                        trust_tier: PluginTrustTier::Unverified,
3977                        metadata: BTreeMap::new(),
3978                        summary: None,
3979                        tags: Vec::new(),
3980                        input_examples: Vec::new(),
3981                        output_examples: Vec::new(),
3982                        defer_loading: false,
3983                        setup: None,
3984                        slot_claims: vec![PluginSlotClaim {
3985                            slot: "tool:search".to_owned(),
3986                            key: "web".to_owned(),
3987                            mode: PluginSlotMode::Advisory,
3988                        }],
3989                        compatibility: None,
3990                    },
3991                },
3992            ],
3993        };
3994
3995        let mut catalog = IntegrationCatalog::new();
3996        let mut pack = sample_pack();
3997
3998        let absorb = PluginScanner::new()
3999            .absorb(&mut catalog, &mut pack, &report)
4000            .expect("shared and advisory slot claims should coexist");
4001
4002        assert_eq!(absorb.absorbed_plugins, 2);
4003        let shared_provider = catalog
4004            .provider("search-shared")
4005            .expect("shared provider should be registered");
4006        assert_eq!(
4007            shared_provider
4008                .metadata
4009                .get(PLUGIN_SLOT_CLAIMS_METADATA_KEY)
4010                .map(String::as_str),
4011            Some("[{\"slot\":\"tool:search\",\"key\":\"web\",\"mode\":\"shared\"}]")
4012        );
4013        assert_eq!(
4014            shared_provider
4015                .metadata
4016                .get(PLUGIN_COMPATIBILITY_HOST_API_METADATA_KEY)
4017                .map(String::as_str),
4018            Some(CURRENT_PLUGIN_HOST_API)
4019        );
4020        assert_eq!(
4021            shared_provider
4022                .metadata
4023                .get(PLUGIN_COMPATIBILITY_HOST_VERSION_REQ_METADATA_KEY)
4024                .map(String::as_str),
4025            Some(current_host_version_req.as_str())
4026        );
4027    }
4028
4029    #[test]
4030    fn absorb_rejects_incompatible_host_api() {
4031        let report = PluginScanReport {
4032            scanned_files: 1,
4033            matched_plugins: 1,
4034            diagnostic_findings: Vec::new(),
4035            descriptors: vec![PluginDescriptor {
4036                path: "/tmp/incompatible-host.py".to_owned(),
4037                source_kind: PluginSourceKind::EmbeddedSource,
4038                dialect: PluginContractDialect::LoongEmbeddedSource,
4039                dialect_version: None,
4040                compatibility_mode: PluginCompatibilityMode::Native,
4041                package_root: "/tmp".to_owned(),
4042                package_manifest_path: None,
4043                language: "py".to_owned(),
4044                manifest: PluginManifest {
4045                    api_version: None,
4046                    version: None,
4047                    plugin_id: "incompatible-host".to_owned(),
4048                    provider_id: "incompatible-host".to_owned(),
4049                    connector_name: "incompatible-host".to_owned(),
4050                    channel_id: None,
4051                    endpoint: None,
4052                    capabilities: BTreeSet::from([Capability::InvokeConnector]),
4053                    trust_tier: PluginTrustTier::Unverified,
4054                    metadata: BTreeMap::new(),
4055                    summary: None,
4056                    tags: Vec::new(),
4057                    input_examples: Vec::new(),
4058                    output_examples: Vec::new(),
4059                    defer_loading: false,
4060                    setup: None,
4061                    slot_claims: Vec::new(),
4062                    compatibility: Some(PluginCompatibility {
4063                        host_api: Some("loong-plugin/v999".to_owned()),
4064                        host_version_req: None,
4065                    }),
4066                },
4067            }],
4068        };
4069
4070        let mut catalog = IntegrationCatalog::new();
4071        let mut pack = sample_pack();
4072
4073        let error = PluginScanner::new()
4074            .absorb(&mut catalog, &mut pack, &report)
4075            .expect_err("incompatible host api should fail closed");
4076
4077        let rendered = error.to_string();
4078        assert!(rendered.contains("compatibility.host_api"));
4079        assert!(rendered.contains(CURRENT_PLUGIN_HOST_API));
4080        assert!(catalog.provider("incompatible-host").is_none());
4081    }
4082
4083    #[test]
4084    fn absorb_rejects_invalid_host_version_requirement() {
4085        let report = PluginScanReport {
4086            scanned_files: 1,
4087            matched_plugins: 1,
4088            diagnostic_findings: Vec::new(),
4089            descriptors: vec![PluginDescriptor {
4090                path: "/tmp/invalid-version.py".to_owned(),
4091                source_kind: PluginSourceKind::EmbeddedSource,
4092                dialect: PluginContractDialect::LoongEmbeddedSource,
4093                dialect_version: None,
4094                compatibility_mode: PluginCompatibilityMode::Native,
4095                package_root: "/tmp".to_owned(),
4096                package_manifest_path: None,
4097                language: "py".to_owned(),
4098                manifest: PluginManifest {
4099                    api_version: None,
4100                    version: None,
4101                    plugin_id: "invalid-version".to_owned(),
4102                    provider_id: "invalid-version".to_owned(),
4103                    connector_name: "invalid-version".to_owned(),
4104                    channel_id: None,
4105                    endpoint: None,
4106                    capabilities: BTreeSet::from([Capability::InvokeConnector]),
4107                    trust_tier: PluginTrustTier::Unverified,
4108                    metadata: BTreeMap::new(),
4109                    summary: None,
4110                    tags: Vec::new(),
4111                    input_examples: Vec::new(),
4112                    output_examples: Vec::new(),
4113                    defer_loading: false,
4114                    setup: None,
4115                    slot_claims: Vec::new(),
4116                    compatibility: Some(PluginCompatibility {
4117                        host_api: Some(CURRENT_PLUGIN_HOST_API.to_owned()),
4118                        host_version_req: Some("not-a-semver-req".to_owned()),
4119                    }),
4120                },
4121            }],
4122        };
4123
4124        let mut catalog = IntegrationCatalog::new();
4125        let mut pack = sample_pack();
4126
4127        let error = PluginScanner::new()
4128            .absorb(&mut catalog, &mut pack, &report)
4129            .expect_err("invalid host version requirement should fail closed");
4130
4131        let rendered = error.to_string();
4132        assert!(rendered.contains("compatibility.host_version_req"));
4133        assert!(rendered.contains("invalid"));
4134        assert!(catalog.provider("invalid-version").is_none());
4135    }
4136
4137    #[test]
4138    fn scanner_skips_non_utf8_files_instead_of_failing() {
4139        let root = unique_tmp_dir("loong-plugin-binary");
4140        fs::create_dir_all(&root).expect("create temp root");
4141        let binary = root.join("compiled.bin");
4142        fs::write(&binary, [0xff_u8, 0xfe, 0x00, 0x81]).expect("write binary file");
4143
4144        let scanner = PluginScanner::new();
4145        let report = scanner
4146            .scan_path(&root)
4147            .expect("binary files should be skipped, not fail");
4148        assert_eq!(report.scanned_files, 1);
4149        assert_eq!(report.matched_plugins, 0);
4150    }
4151
4152    #[test]
4153    fn absorb_rolls_back_catalog_and_pack_on_validation_failure() {
4154        // First descriptor is valid, second has an empty provider_id which
4155        // triggers validation failure. The rollback must undo the first
4156        // descriptor's mutations so catalog and pack remain unchanged.
4157        let report = PluginScanReport {
4158            scanned_files: 2,
4159            matched_plugins: 2,
4160            diagnostic_findings: Vec::new(),
4161            descriptors: vec![
4162                PluginDescriptor {
4163                    path: "/tmp/good.rs".to_owned(),
4164                    source_kind: PluginSourceKind::EmbeddedSource,
4165                    dialect: PluginContractDialect::LoongEmbeddedSource,
4166                    dialect_version: None,
4167                    compatibility_mode: PluginCompatibilityMode::Native,
4168                    package_root: "/tmp".to_owned(),
4169                    package_manifest_path: None,
4170                    language: "rs".to_owned(),
4171                    manifest: PluginManifest {
4172                        api_version: None,
4173                        version: Some("1.0.0".to_owned()),
4174                        plugin_id: "good-plugin".to_owned(),
4175                        provider_id: "good-provider".to_owned(),
4176                        connector_name: "good-connector".to_owned(),
4177                        channel_id: Some("good-channel".to_owned()),
4178                        endpoint: Some("https://good.local/invoke".to_owned()),
4179                        capabilities: BTreeSet::from([Capability::InvokeConnector]),
4180                        trust_tier: PluginTrustTier::VerifiedCommunity,
4181                        metadata: BTreeMap::from([("version".to_owned(), "1.0.0".to_owned())]),
4182                        summary: None,
4183                        tags: Vec::new(),
4184                        input_examples: Vec::new(),
4185                        output_examples: Vec::new(),
4186                        defer_loading: false,
4187                        setup: None,
4188                        slot_claims: Vec::new(),
4189                        compatibility: None,
4190                    },
4191                },
4192                PluginDescriptor {
4193                    path: "/tmp/bad.rs".to_owned(),
4194                    source_kind: PluginSourceKind::EmbeddedSource,
4195                    dialect: PluginContractDialect::LoongEmbeddedSource,
4196                    dialect_version: None,
4197                    compatibility_mode: PluginCompatibilityMode::Native,
4198                    package_root: "/tmp".to_owned(),
4199                    package_manifest_path: None,
4200                    language: "rs".to_owned(),
4201                    manifest: PluginManifest {
4202                        api_version: None,
4203                        version: None,
4204                        plugin_id: "bad-plugin".to_owned(),
4205                        provider_id: String::new(), // empty — triggers validation error
4206                        connector_name: "bad-connector".to_owned(),
4207                        channel_id: None,
4208                        endpoint: None,
4209                        capabilities: BTreeSet::new(),
4210                        trust_tier: PluginTrustTier::Unverified,
4211                        metadata: BTreeMap::new(),
4212                        summary: None,
4213                        tags: Vec::new(),
4214                        input_examples: Vec::new(),
4215                        output_examples: Vec::new(),
4216                        defer_loading: false,
4217                        setup: None,
4218                        slot_claims: Vec::new(),
4219                        compatibility: None,
4220                    },
4221                },
4222            ],
4223        };
4224
4225        let mut catalog = IntegrationCatalog::new();
4226        let mut pack = sample_pack();
4227        let scanner = PluginScanner::new();
4228
4229        let catalog_before = catalog.clone();
4230        let pack_before = pack.clone();
4231
4232        let result = scanner.absorb(&mut catalog, &mut pack, &report);
4233        assert!(result.is_err(), "absorb should fail on empty provider_id");
4234
4235        // Verify rollback: catalog and pack are identical to their pre-absorb state.
4236        assert_eq!(catalog, catalog_before, "catalog must be rolled back");
4237        assert_eq!(pack, pack_before, "pack must be rolled back");
4238    }
4239
4240    #[test]
4241    fn format_plugin_provenance_summary_prefers_package_manifest_context() {
4242        let summary = format_plugin_provenance_summary(
4243            PluginSourceKind::EmbeddedSource,
4244            "/tmp/pkg/plugin.py",
4245            Some("/tmp/pkg/loong.plugin.json"),
4246        );
4247
4248        assert_eq!(
4249            summary,
4250            "embedded_source:/tmp/pkg/plugin.py (package_manifest:/tmp/pkg/loong.plugin.json)"
4251        );
4252    }
4253}