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 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(¤t_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 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(), 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 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}