1use std::{
2 collections::{BTreeMap, BTreeSet},
3 path::Path,
4};
5
6use serde::{Deserialize, Deserializer, Serialize};
7
8use crate::{
9 contracts::Capability,
10 integration::IntegrationCatalog,
11 plugin::{
12 PLUGIN_SLOT_CLAIMS_METADATA_KEY, PluginCompatibility, PluginCompatibilityMode,
13 PluginCompatibilityShim, PluginContractDialect, PluginDescriptor, PluginDiagnosticCode,
14 PluginDiagnosticFinding, PluginDiagnosticPhase, PluginDiagnosticSeverity, PluginManifest,
15 PluginScanReport, PluginSetup, PluginSlotClaim, PluginSlotMode, PluginSourceKind,
16 PluginTrustTier, plugin_host_compatibility_issue, slot_modes_conflict,
17 },
18};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
21#[serde(rename_all = "snake_case")]
22pub enum PluginBridgeKind {
23 HttpJson,
24 ProcessStdio,
25 NativeFfi,
26 WasmComponent,
27 McpServer,
28 AcpBridge,
29 AcpRuntime,
30 #[default]
31 Unknown,
32}
33
34impl PluginBridgeKind {
35 #[must_use]
36 pub fn parse_label(raw: &str) -> Option<Self> {
37 parse_bridge_kind(raw)
38 }
39
40 #[must_use]
41 pub fn as_str(self) -> &'static str {
42 match self {
43 Self::HttpJson => "http_json",
44 Self::ProcessStdio => "process_stdio",
45 Self::NativeFfi => "native_ffi",
46 Self::WasmComponent => "wasm_component",
47 Self::McpServer => "mcp_server",
48 Self::AcpBridge => "acp_bridge",
49 Self::AcpRuntime => "acp_runtime",
50 Self::Unknown => "unknown",
51 }
52 }
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
56#[serde(default)]
57pub struct PluginRuntimeProfile {
58 pub source_language: String,
59 pub bridge_kind: PluginBridgeKind,
60 pub adapter_family: String,
61 pub entrypoint_hint: String,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct PluginRuntimeScaffoldDefaults {
66 pub source_language: Option<String>,
67 pub bridge_kind: PluginBridgeKind,
68 pub adapter_family: String,
69 pub entrypoint_hint: String,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct PluginChannelBridgeReadiness {
74 pub ready: bool,
75 #[serde(default)]
76 pub missing_fields: Vec<String>,
77}
78
79impl Default for PluginChannelBridgeReadiness {
80 fn default() -> Self {
81 Self {
82 ready: true,
83 missing_fields: Vec::new(),
84 }
85 }
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89pub struct PluginChannelBridgeContract {
90 #[serde(default)]
91 pub channel_id: Option<String>,
92 #[serde(default)]
93 pub setup_surface: Option<String>,
94 #[serde(default)]
95 pub transport_family: Option<String>,
96 #[serde(default)]
97 pub target_contract: Option<String>,
98 #[serde(default)]
99 pub account_scope: Option<String>,
100 #[serde(default)]
101 pub runtime_contract: Option<String>,
102 #[serde(default)]
103 pub runtime_operations: Vec<String>,
104 #[serde(default)]
105 pub runtime_metadata_issues: Vec<String>,
106 #[serde(default)]
107 pub readiness: PluginChannelBridgeReadiness,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
111pub struct PluginIR {
112 pub manifest_api_version: Option<String>,
113 pub plugin_version: Option<String>,
114 #[serde(default)]
115 pub dialect: PluginContractDialect,
116 pub dialect_version: Option<String>,
117 #[serde(default)]
118 pub compatibility_mode: PluginCompatibilityMode,
119 pub plugin_id: String,
120 pub provider_id: String,
121 pub connector_name: String,
122 pub channel_id: Option<String>,
123 pub endpoint: Option<String>,
124 pub capabilities: BTreeSet<Capability>,
125 #[serde(default)]
126 pub trust_tier: PluginTrustTier,
127 pub metadata: BTreeMap<String, String>,
128 pub source_path: String,
129 pub source_kind: PluginSourceKind,
130 pub package_root: String,
131 pub package_manifest_path: Option<String>,
132 #[serde(default)]
133 pub diagnostic_findings: Vec<PluginDiagnosticFinding>,
134 pub setup: Option<PluginSetup>,
135 #[serde(default)]
136 pub channel_bridge: Option<PluginChannelBridgeContract>,
137 #[serde(default)]
138 pub slot_claims: Vec<PluginSlotClaim>,
139 pub compatibility: Option<PluginCompatibility>,
140 #[serde(default)]
141 pub runtime: PluginRuntimeProfile,
142}
143
144#[derive(Debug, Deserialize)]
145struct PluginIRSerde {
146 manifest_api_version: Option<String>,
147 plugin_version: Option<String>,
148 dialect: Option<PluginContractDialect>,
149 dialect_version: Option<String>,
150 compatibility_mode: Option<PluginCompatibilityMode>,
151 plugin_id: String,
152 provider_id: String,
153 connector_name: String,
154 channel_id: Option<String>,
155 endpoint: Option<String>,
156 capabilities: BTreeSet<Capability>,
157 #[serde(default)]
158 trust_tier: PluginTrustTier,
159 metadata: BTreeMap<String, String>,
160 source_path: String,
161 source_kind: PluginSourceKind,
162 package_root: String,
163 package_manifest_path: Option<String>,
164 #[serde(default)]
165 diagnostic_findings: Vec<PluginDiagnosticFinding>,
166 setup: Option<PluginSetup>,
167 #[serde(default)]
168 channel_bridge: Option<PluginChannelBridgeContract>,
169 #[serde(default)]
170 slot_claims: Vec<PluginSlotClaim>,
171 compatibility: Option<PluginCompatibility>,
172 runtime: Option<PluginRuntimeProfile>,
173}
174
175impl<'de> Deserialize<'de> for PluginIR {
176 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
177 where
178 D: Deserializer<'de>,
179 {
180 let raw = PluginIRSerde::deserialize(deserializer)?;
181 let dialect = raw
182 .dialect
183 .unwrap_or_else(|| legacy_plugin_ir_dialect(raw.source_kind));
184 let compatibility_mode = raw.compatibility_mode.unwrap_or_default();
185 let runtime = raw.runtime.unwrap_or_else(|| {
186 legacy_plugin_ir_runtime_profile(
187 &raw.source_path,
188 raw.source_kind,
189 &raw.metadata,
190 raw.endpoint.as_deref(),
191 )
192 });
193
194 Ok(Self {
195 manifest_api_version: raw.manifest_api_version,
196 plugin_version: raw.plugin_version,
197 dialect,
198 dialect_version: raw.dialect_version,
199 compatibility_mode,
200 plugin_id: raw.plugin_id,
201 provider_id: raw.provider_id,
202 connector_name: raw.connector_name,
203 channel_id: raw.channel_id,
204 endpoint: raw.endpoint,
205 capabilities: raw.capabilities,
206 trust_tier: raw.trust_tier,
207 metadata: raw.metadata,
208 source_path: raw.source_path,
209 source_kind: raw.source_kind,
210 package_root: raw.package_root,
211 package_manifest_path: raw.package_manifest_path,
212 diagnostic_findings: raw.diagnostic_findings,
213 setup: raw.setup,
214 channel_bridge: raw.channel_bridge,
215 slot_claims: raw.slot_claims,
216 compatibility: raw.compatibility,
217 runtime,
218 })
219 }
220}
221
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
224pub struct PluginSetupReadinessContext {
225 pub verified_env_vars: BTreeSet<String>,
226 pub verified_config_keys: BTreeSet<String>,
227}
228
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231pub struct PluginSetupReadiness {
232 pub ready: bool,
233 pub missing_required_env_vars: Vec<String>,
234 pub missing_required_config_keys: Vec<String>,
235}
236
237impl Default for PluginSetupReadiness {
238 fn default() -> Self {
239 Self {
240 ready: true,
241 missing_required_env_vars: Vec::new(),
242 missing_required_config_keys: Vec::new(),
243 }
244 }
245}
246
247pub fn evaluate_plugin_setup_requirements(
249 required_env_vars: &[String],
250 required_config_keys: &[String],
251 context: &PluginSetupReadinessContext,
252) -> PluginSetupReadiness {
253 let mut missing_required_env_vars = Vec::new();
254 for required_env_var in required_env_vars {
255 let env_var_is_verified =
256 verified_env_var_names_contain(&context.verified_env_vars, required_env_var);
257 if !env_var_is_verified {
258 missing_required_env_vars.push(required_env_var.clone());
259 }
260 }
261
262 let mut missing_required_config_keys = Vec::new();
263 for required_config_key in required_config_keys {
264 let config_key_is_verified = context.verified_config_keys.contains(required_config_key);
265 if !config_key_is_verified {
266 missing_required_config_keys.push(required_config_key.clone());
267 }
268 }
269
270 let env_ready = missing_required_env_vars.is_empty();
271 let config_ready = missing_required_config_keys.is_empty();
272 let ready = env_ready && config_ready;
273
274 PluginSetupReadiness {
275 ready,
276 missing_required_env_vars,
277 missing_required_config_keys,
278 }
279}
280
281fn verified_env_var_names_contain(
282 verified_env_vars: &BTreeSet<String>,
283 required_env_var: &str,
284) -> bool {
285 #[cfg(windows)]
286 {
287 verified_env_vars
288 .iter()
289 .any(|verified_env_var| verified_env_var.eq_ignore_ascii_case(required_env_var))
290 }
291
292 #[cfg(not(windows))]
293 {
294 verified_env_vars.contains(required_env_var)
295 }
296}
297
298#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
299pub struct PluginTranslationReport {
300 pub translated_plugins: usize,
301 pub bridge_distribution: BTreeMap<String, usize>,
302 pub entries: Vec<PluginIR>,
303}
304
305#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
311#[serde(rename_all = "snake_case")]
312pub enum PluginActivationStatus {
313 Ready,
314 SetupIncomplete,
316 BlockedInvalidManifestContract,
317 BlockedCompatibilityMode,
318 BlockedIncompatibleHost,
319 BlockedUnsupportedBridge,
320 BlockedUnsupportedAdapterFamily,
321 BlockedSlotClaimConflict,
322 #[serde(other)]
323 Unknown,
324}
325
326impl PluginActivationStatus {
327 #[must_use]
328 pub fn as_str(self) -> &'static str {
329 match self {
330 Self::Ready => "ready",
331 Self::SetupIncomplete => "setup_incomplete",
332 Self::BlockedInvalidManifestContract => "blocked_invalid_manifest_contract",
333 Self::BlockedCompatibilityMode => "blocked_compatibility_mode",
334 Self::BlockedIncompatibleHost => "blocked_incompatible_host",
335 Self::BlockedUnsupportedBridge => "blocked_unsupported_bridge",
336 Self::BlockedUnsupportedAdapterFamily => "blocked_unsupported_adapter_family",
337 Self::BlockedSlotClaimConflict => "blocked_slot_claim_conflict",
338 Self::Unknown => "unknown",
339 }
340 }
341}
342
343#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
345pub struct PluginActivationCandidate {
346 pub plugin_id: String,
347 pub source_path: String,
348 pub source_kind: PluginSourceKind,
349 pub package_root: String,
350 pub package_manifest_path: Option<String>,
351 #[serde(default)]
352 pub trust_tier: PluginTrustTier,
353 #[serde(default)]
354 pub compatibility_mode: PluginCompatibilityMode,
355 #[serde(default)]
356 pub compatibility_shim: Option<PluginCompatibilityShim>,
357 #[serde(default)]
358 pub compatibility_shim_support: Option<PluginCompatibilityShimSupport>,
359 #[serde(default)]
360 pub compatibility_shim_support_mismatch_reasons: Vec<String>,
361 pub bridge_kind: PluginBridgeKind,
362 pub adapter_family: String,
363 #[serde(default)]
364 pub slot_claims: Vec<PluginSlotClaim>,
365 #[serde(default)]
366 pub diagnostic_findings: Vec<PluginDiagnosticFinding>,
367 pub status: PluginActivationStatus,
368 pub reason: String,
369 #[serde(default)]
370 pub missing_required_env_vars: Vec<String>,
371 #[serde(default)]
372 pub missing_required_config_keys: Vec<String>,
373 pub bootstrap_hint: String,
374}
375
376#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
378pub struct PluginActivationPlan {
379 pub total_plugins: usize,
380 pub ready_plugins: usize,
381 #[serde(default)]
382 pub setup_incomplete_plugins: usize,
383 pub blocked_plugins: usize,
384 pub candidates: Vec<PluginActivationCandidate>,
385}
386
387#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
388pub struct PluginActivationInventoryEntry {
389 pub manifest_api_version: Option<String>,
390 pub plugin_version: Option<String>,
391 pub dialect: PluginContractDialect,
392 pub dialect_version: Option<String>,
393 pub compatibility_mode: PluginCompatibilityMode,
394 pub compatibility_shim: Option<PluginCompatibilityShim>,
395 pub compatibility_shim_support: Option<PluginCompatibilityShimSupport>,
396 pub compatibility_shim_support_mismatch_reasons: Vec<String>,
397 pub plugin_id: String,
398 pub provider_id: String,
399 pub connector_name: String,
400 pub source_path: String,
401 pub source_kind: PluginSourceKind,
402 pub package_root: String,
403 pub package_manifest_path: Option<String>,
404 pub bridge_kind: PluginBridgeKind,
405 pub adapter_family: String,
406 pub entrypoint_hint: String,
407 pub source_language: String,
408 pub slot_claims: Vec<PluginSlotClaim>,
409 pub diagnostic_findings: Vec<PluginDiagnosticFinding>,
410 pub compatibility: Option<PluginCompatibility>,
411 pub activation_status: Option<PluginActivationStatus>,
412 pub activation_reason: Option<String>,
413 pub bootstrap_hint: Option<String>,
414}
415
416impl PluginActivationPlan {
417 #[must_use]
418 pub fn has_blockers(&self) -> bool {
419 self.blocked_plugins > 0
420 }
421
422 #[must_use]
423 pub fn candidate_for(
424 &self,
425 source_path: &str,
426 plugin_id: &str,
427 ) -> Option<&PluginActivationCandidate> {
428 self.candidates.iter().find(|candidate| {
429 candidate.source_path == source_path && candidate.plugin_id == plugin_id
430 })
431 }
432
433 #[must_use]
434 pub fn inventory_entries(
435 &self,
436 translation: &PluginTranslationReport,
437 ) -> Vec<PluginActivationInventoryEntry> {
438 translation
439 .entries
440 .iter()
441 .map(|entry| {
442 let candidate = self.candidate_for(&entry.source_path, &entry.plugin_id);
443 PluginActivationInventoryEntry {
444 manifest_api_version: entry.manifest_api_version.clone(),
445 plugin_version: entry.plugin_version.clone(),
446 dialect: entry.dialect,
447 dialect_version: entry.dialect_version.clone(),
448 compatibility_mode: entry.compatibility_mode,
449 compatibility_shim: candidate
450 .and_then(|candidate| candidate.compatibility_shim.clone())
451 .or_else(|| PluginCompatibilityShim::for_mode(entry.compatibility_mode)),
452 compatibility_shim_support: candidate
453 .and_then(|candidate| candidate.compatibility_shim_support.clone()),
454 compatibility_shim_support_mismatch_reasons: candidate
455 .map(|candidate| {
456 candidate
457 .compatibility_shim_support_mismatch_reasons
458 .clone()
459 })
460 .unwrap_or_default(),
461 plugin_id: entry.plugin_id.clone(),
462 provider_id: entry.provider_id.clone(),
463 connector_name: entry.connector_name.clone(),
464 source_path: entry.source_path.clone(),
465 source_kind: entry.source_kind,
466 package_root: entry.package_root.clone(),
467 package_manifest_path: entry.package_manifest_path.clone(),
468 bridge_kind: entry.runtime.bridge_kind,
469 adapter_family: entry.runtime.adapter_family.clone(),
470 entrypoint_hint: entry.runtime.entrypoint_hint.clone(),
471 source_language: entry.runtime.source_language.clone(),
472 slot_claims: entry.slot_claims.clone(),
473 diagnostic_findings: candidate
474 .map(|candidate| candidate.diagnostic_findings.clone())
475 .unwrap_or_else(|| entry.diagnostic_findings.clone()),
476 compatibility: entry.compatibility.clone(),
477 activation_status: candidate.map(|candidate| candidate.status),
478 activation_reason: candidate.map(|candidate| candidate.reason.clone()),
479 bootstrap_hint: candidate.map(|candidate| candidate.bootstrap_hint.clone()),
480 }
481 })
482 .collect()
483 }
484
485 #[must_use]
486 pub fn blocker_summary(&self, limit: usize) -> String {
487 if self.blocked_plugins == 0 {
488 return "no blocked plugins".to_owned();
489 }
490
491 let capped_limit = limit.clamp(1, 16);
492 let mut details = self
493 .candidates
494 .iter()
495 .filter(|candidate| {
496 matches!(
497 candidate.status,
498 PluginActivationStatus::BlockedInvalidManifestContract
499 | PluginActivationStatus::BlockedCompatibilityMode
500 | PluginActivationStatus::BlockedUnsupportedBridge
501 | PluginActivationStatus::BlockedIncompatibleHost
502 | PluginActivationStatus::BlockedUnsupportedAdapterFamily
503 | PluginActivationStatus::BlockedSlotClaimConflict
504 | PluginActivationStatus::Unknown
505 )
506 })
507 .take(capped_limit)
508 .map(|candidate| {
509 format!(
510 "{} [{}]: {}",
511 candidate.plugin_id,
512 candidate.status.as_str(),
513 candidate.reason
514 )
515 })
516 .collect::<Vec<_>>();
517
518 if self.blocked_plugins > capped_limit {
519 details.push(format!(
520 "+{} more blocked plugin(s)",
521 self.blocked_plugins - capped_limit
522 ));
523 }
524
525 details.join("; ")
526 }
527}
528
529#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
530pub struct BridgeSupportMatrix {
531 pub supported_bridges: BTreeSet<PluginBridgeKind>,
532 pub supported_adapter_families: BTreeSet<String>,
533 pub supported_compatibility_modes: BTreeSet<PluginCompatibilityMode>,
534 pub supported_compatibility_shims: BTreeSet<PluginCompatibilityShim>,
535 pub supported_compatibility_shim_profiles:
536 BTreeMap<PluginCompatibilityShim, PluginCompatibilityShimSupport>,
537}
538
539#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
540pub struct PluginCompatibilityShimSupport {
541 pub shim: PluginCompatibilityShim,
542 #[serde(default)]
543 pub version: Option<String>,
544 #[serde(default)]
545 pub supported_dialects: BTreeSet<PluginContractDialect>,
546 #[serde(default)]
547 pub supported_bridges: BTreeSet<PluginBridgeKind>,
548 #[serde(default)]
549 pub supported_adapter_families: BTreeSet<String>,
550 #[serde(default)]
551 pub supported_source_languages: BTreeSet<String>,
552}
553
554impl PluginCompatibilityShimSupport {
555 #[must_use]
556 pub fn normalized(self) -> Self {
557 Self {
558 shim: PluginCompatibilityShim {
559 shim_id: self.shim.shim_id.trim().to_owned(),
560 family: self.shim.family.trim().to_owned(),
561 },
562 version: self
563 .version
564 .map(|value| value.trim().to_owned())
565 .filter(|value| !value.is_empty()),
566 supported_dialects: self.supported_dialects,
567 supported_bridges: self.supported_bridges,
568 supported_adapter_families: self
569 .supported_adapter_families
570 .into_iter()
571 .map(|value| value.trim().to_ascii_lowercase())
572 .filter(|value| !value.is_empty())
573 .collect(),
574 supported_source_languages: self
575 .supported_source_languages
576 .into_iter()
577 .map(|value| normalize_language(&value))
578 .filter(|value| value != "unknown")
579 .collect(),
580 }
581 }
582
583 fn mismatch_reasons(&self, ir: &PluginIR) -> Vec<String> {
584 let mut reasons = Vec::new();
585
586 if !self.supported_dialects.is_empty() && !self.supported_dialects.contains(&ir.dialect) {
587 reasons.push(format!("dialect `{}`", ir.dialect.as_str()));
588 }
589
590 if !self.supported_bridges.is_empty()
591 && !self.supported_bridges.contains(&ir.runtime.bridge_kind)
592 {
593 reasons.push(format!("bridge kind `{}`", ir.runtime.bridge_kind.as_str()));
594 }
595
596 if !self.supported_adapter_families.is_empty()
597 && !self
598 .supported_adapter_families
599 .contains(&ir.runtime.adapter_family.trim().to_ascii_lowercase())
600 {
601 reasons.push(format!("adapter family `{}`", ir.runtime.adapter_family));
602 }
603
604 let normalized_source_language = normalize_language(&ir.runtime.source_language);
605 if !self.supported_source_languages.is_empty()
606 && !self
607 .supported_source_languages
608 .contains(&normalized_source_language)
609 {
610 reasons.push(format!("source language `{}`", ir.runtime.source_language));
611 }
612
613 reasons
614 }
615}
616
617impl Default for BridgeSupportMatrix {
618 fn default() -> Self {
619 Self {
620 supported_bridges: BTreeSet::from([
621 PluginBridgeKind::HttpJson,
622 PluginBridgeKind::ProcessStdio,
623 PluginBridgeKind::NativeFfi,
624 PluginBridgeKind::WasmComponent,
625 PluginBridgeKind::McpServer,
626 PluginBridgeKind::AcpBridge,
627 PluginBridgeKind::AcpRuntime,
628 ]),
629 supported_adapter_families: BTreeSet::new(),
630 supported_compatibility_modes: BTreeSet::from([PluginCompatibilityMode::Native]),
631 supported_compatibility_shims: BTreeSet::new(),
632 supported_compatibility_shim_profiles: BTreeMap::new(),
633 }
634 }
635}
636
637impl BridgeSupportMatrix {
638 #[must_use]
639 pub fn is_bridge_supported(&self, bridge_kind: PluginBridgeKind) -> bool {
640 self.supported_bridges.contains(&bridge_kind)
641 }
642
643 #[must_use]
644 pub fn is_adapter_family_supported(&self, adapter_family: &str) -> bool {
645 self.supported_adapter_families.is_empty()
646 || self.supported_adapter_families.contains(adapter_family)
647 }
648
649 #[must_use]
650 pub fn is_compatibility_mode_supported(
651 &self,
652 compatibility_mode: PluginCompatibilityMode,
653 ) -> bool {
654 self.supported_compatibility_modes
655 .contains(&compatibility_mode)
656 }
657
658 #[must_use]
659 pub fn is_compatibility_shim_supported(
660 &self,
661 compatibility_shim: Option<&PluginCompatibilityShim>,
662 ) -> bool {
663 compatibility_shim.is_none_or(|shim| {
664 self.supported_compatibility_shims.contains(shim)
665 || self
666 .supported_compatibility_shim_profiles
667 .contains_key(shim)
668 })
669 }
670
671 #[must_use]
672 pub fn compatibility_shim_support_issue(
673 &self,
674 ir: &PluginIR,
675 compatibility_shim: Option<&PluginCompatibilityShim>,
676 ) -> Option<String> {
677 let shim = compatibility_shim?;
678 let profile = self.supported_compatibility_shim_profiles.get(shim)?;
679 let mismatches = profile.mismatch_reasons(ir);
680 compatibility_shim_support_issue(shim, profile, &mismatches)
681 }
682
683 #[must_use]
684 pub fn compatibility_shim_support_profile(
685 &self,
686 compatibility_shim: Option<&PluginCompatibilityShim>,
687 ) -> Option<&PluginCompatibilityShimSupport> {
688 compatibility_shim.and_then(|shim| self.supported_compatibility_shim_profiles.get(shim))
689 }
690}
691
692fn compatibility_shim_support_issue(
693 shim: &PluginCompatibilityShim,
694 profile: &PluginCompatibilityShimSupport,
695 mismatches: &[String],
696) -> Option<String> {
697 if mismatches.is_empty() {
698 return None;
699 }
700
701 let version_clause = profile
702 .version
703 .as_deref()
704 .map(|version| format!(" version `{version}`"))
705 .unwrap_or_default();
706
707 Some(format!(
708 "compatibility shim `{}` ({}) is enabled but its support profile{} does not support {}",
709 shim.shim_id,
710 shim.family,
711 version_clause,
712 mismatches.join(", ")
713 ))
714}
715
716#[derive(Debug, Default)]
717pub struct PluginTranslator;
718
719impl PluginTranslator {
720 #[must_use]
721 pub fn new() -> Self {
722 Self
723 }
724
725 #[must_use]
726 pub fn translate_scan_report(&self, report: &PluginScanReport) -> PluginTranslationReport {
727 let mut translated = PluginTranslationReport::default();
728 let mut diagnostics_by_key: BTreeMap<(String, String), Vec<PluginDiagnosticFinding>> =
729 BTreeMap::new();
730
731 for finding in &report.diagnostic_findings {
732 let (Some(source_path), Some(plugin_id)) =
733 (finding.source_path.clone(), finding.plugin_id.clone())
734 else {
735 continue;
736 };
737
738 diagnostics_by_key
739 .entry((source_path, plugin_id))
740 .or_default()
741 .push(finding.clone());
742 }
743
744 for descriptor in &report.descriptors {
745 let mut ir = self.translate_descriptor(descriptor);
746 ir.diagnostic_findings = diagnostics_by_key
747 .remove(&(
748 descriptor.path.clone(),
749 descriptor.manifest.plugin_id.clone(),
750 ))
751 .unwrap_or_default();
752 let bridge = ir.runtime.bridge_kind.as_str().to_owned();
753 *translated.bridge_distribution.entry(bridge).or_insert(0) += 1;
754 translated.translated_plugins = translated.translated_plugins.saturating_add(1);
755 translated.entries.push(ir);
756 }
757
758 translated
759 }
760
761 #[must_use]
762 pub fn translate_descriptor(&self, descriptor: &PluginDescriptor) -> PluginIR {
763 let runtime = infer_runtime_profile(&descriptor.language, &descriptor.manifest);
764 let channel_bridge = derive_channel_bridge_contract(&descriptor.manifest);
765
766 PluginIR {
767 manifest_api_version: descriptor.manifest.api_version.clone(),
768 plugin_version: descriptor.manifest.version.clone(),
769 dialect: descriptor.dialect,
770 dialect_version: descriptor.dialect_version.clone(),
771 compatibility_mode: descriptor.compatibility_mode,
772 plugin_id: descriptor.manifest.plugin_id.clone(),
773 provider_id: descriptor.manifest.provider_id.clone(),
774 connector_name: descriptor.manifest.connector_name.clone(),
775 channel_id: descriptor.manifest.channel_id.clone(),
776 endpoint: descriptor.manifest.endpoint.clone(),
777 capabilities: descriptor.manifest.capabilities.clone(),
778 trust_tier: descriptor.manifest.trust_tier,
779 metadata: descriptor.manifest.metadata.clone(),
780 source_path: descriptor.path.clone(),
781 source_kind: descriptor.source_kind,
782 package_root: descriptor.package_root.clone(),
783 package_manifest_path: descriptor.package_manifest_path.clone(),
784 diagnostic_findings: Vec::new(),
785 setup: descriptor.manifest.setup.clone(),
786 channel_bridge,
787 slot_claims: descriptor.manifest.slot_claims.clone(),
788 compatibility: descriptor.manifest.compatibility.clone(),
789 runtime,
790 }
791 }
792
793 #[must_use]
794 pub fn plan_activation(
795 &self,
796 translation: &PluginTranslationReport,
797 matrix: &BridgeSupportMatrix,
798 setup_readiness_context: &PluginSetupReadinessContext,
799 ) -> PluginActivationPlan {
800 self.plan_activation_with_catalog(translation, matrix, setup_readiness_context, None)
801 }
802
803 #[must_use]
804 pub fn plan_activation_with_catalog(
805 &self,
806 translation: &PluginTranslationReport,
807 matrix: &BridgeSupportMatrix,
808 setup_readiness_context: &PluginSetupReadinessContext,
809 catalog: Option<&IntegrationCatalog>,
810 ) -> PluginActivationPlan {
811 let mut plan = PluginActivationPlan::default();
812 let slot_conflicts = collect_slot_claim_conflicts(&translation.entries, catalog);
813
814 for ir in &translation.entries {
815 plan.total_plugins = plan.total_plugins.saturating_add(1);
816 let compatibility_shim = PluginCompatibilityShim::for_mode(ir.compatibility_mode);
817 let compatibility_shim_support = matrix
818 .compatibility_shim_support_profile(compatibility_shim.as_ref())
819 .cloned();
820 let compatibility_shim_support_mismatch_reasons = compatibility_shim_support
821 .as_ref()
822 .map(|profile| profile.mismatch_reasons(ir))
823 .unwrap_or_default();
824
825 let setup_readiness =
826 evaluate_plugin_setup_readiness(ir.setup.as_ref(), setup_readiness_context);
827 let setup_is_incomplete = !setup_readiness.ready;
828 let slot_conflict_key = (ir.source_path.clone(), ir.plugin_id.clone());
829 let invalid_manifest_contract = plugin_manifest_contract_is_invalid(ir);
830 let (status, reason) = if !matrix.is_compatibility_mode_supported(ir.compatibility_mode)
831 {
832 let shim_clause = compatibility_shim
833 .as_ref()
834 .map(|shim| format!(" via shim `{}` ({})", shim.shim_id, shim.family))
835 .unwrap_or_default();
836 (
837 PluginActivationStatus::BlockedCompatibilityMode,
838 format!(
839 "compatibility mode {} requires a host shim that is not enabled in the current runtime matrix{}",
840 ir.compatibility_mode.as_str(),
841 shim_clause
842 ),
843 )
844 } else if !matrix.is_compatibility_shim_supported(compatibility_shim.as_ref()) {
845 let maybe_shim = compatibility_shim.as_ref();
846 let missing_shim_reason = format!(
847 "compatibility mode {} did not resolve a canonical shim before runtime-matrix evaluation",
848 ir.compatibility_mode.as_str()
849 );
850 let reason = match maybe_shim {
851 Some(shim) => {
852 let shim_id = shim.shim_id.as_str();
853 let shim_family = shim.family.as_str();
854
855 format!(
856 "compatibility mode {} requires compatibility shim `{}` ({}) that is not enabled in the current runtime matrix",
857 ir.compatibility_mode.as_str(),
858 shim_id,
859 shim_family
860 )
861 }
862 None => missing_shim_reason,
863 };
864
865 (PluginActivationStatus::BlockedCompatibilityMode, reason)
866 } else if let Some(reason) = plugin_host_compatibility_issue(ir.compatibility.as_ref())
867 {
868 (PluginActivationStatus::BlockedIncompatibleHost, reason)
869 } else if let Some(reason) = compatibility_shim
870 .as_ref()
871 .zip(compatibility_shim_support.as_ref())
872 .and_then(|(shim, profile)| {
873 compatibility_shim_support_issue(
874 shim,
875 profile,
876 &compatibility_shim_support_mismatch_reasons,
877 )
878 })
879 {
880 (PluginActivationStatus::BlockedCompatibilityMode, reason)
881 } else if let Some(reason) = slot_conflicts.get(&slot_conflict_key) {
882 (
883 PluginActivationStatus::BlockedSlotClaimConflict,
884 reason.clone(),
885 )
886 } else if invalid_manifest_contract {
887 (
888 PluginActivationStatus::BlockedInvalidManifestContract,
889 format_invalid_manifest_contract_reason(ir),
890 )
891 } else if !matrix.is_bridge_supported(ir.runtime.bridge_kind) {
892 (
893 PluginActivationStatus::BlockedUnsupportedBridge,
894 format!(
895 "bridge kind {} is not supported by current runtime matrix",
896 ir.runtime.bridge_kind.as_str()
897 ),
898 )
899 } else if !matrix.is_adapter_family_supported(&ir.runtime.adapter_family) {
900 (
901 PluginActivationStatus::BlockedUnsupportedAdapterFamily,
902 format!(
903 "adapter family {} is not supported by current runtime matrix",
904 ir.runtime.adapter_family
905 ),
906 )
907 } else if setup_is_incomplete {
908 (
909 PluginActivationStatus::SetupIncomplete,
910 format_plugin_setup_incomplete_reason(&setup_readiness),
911 )
912 } else {
913 (
914 PluginActivationStatus::Ready,
915 "plugin runtime profile is supported by current runtime matrix".to_owned(),
916 )
917 };
918
919 let mut diagnostic_findings = ir.diagnostic_findings.clone();
920 if let Some(finding) = activation_diagnostic_finding(ir, status, &reason) {
921 diagnostic_findings.push(finding);
922 }
923
924 match status {
925 PluginActivationStatus::Ready => {
926 plan.ready_plugins = plan.ready_plugins.saturating_add(1)
927 }
928 PluginActivationStatus::SetupIncomplete => {
929 plan.setup_incomplete_plugins = plan.setup_incomplete_plugins.saturating_add(1)
930 }
931 PluginActivationStatus::BlockedInvalidManifestContract
932 | PluginActivationStatus::BlockedCompatibilityMode
933 | PluginActivationStatus::BlockedUnsupportedBridge
934 | PluginActivationStatus::BlockedIncompatibleHost
935 | PluginActivationStatus::BlockedUnsupportedAdapterFamily
936 | PluginActivationStatus::BlockedSlotClaimConflict
937 | PluginActivationStatus::Unknown => {
938 plan.blocked_plugins = plan.blocked_plugins.saturating_add(1)
939 }
940 }
941
942 plan.candidates.push(PluginActivationCandidate {
943 plugin_id: ir.plugin_id.clone(),
944 source_path: ir.source_path.clone(),
945 source_kind: ir.source_kind,
946 package_root: ir.package_root.clone(),
947 package_manifest_path: ir.package_manifest_path.clone(),
948 trust_tier: ir.trust_tier,
949 compatibility_mode: ir.compatibility_mode,
950 compatibility_shim,
951 compatibility_shim_support,
952 compatibility_shim_support_mismatch_reasons,
953 bridge_kind: ir.runtime.bridge_kind,
954 adapter_family: ir.runtime.adapter_family.clone(),
955 slot_claims: ir.slot_claims.clone(),
956 diagnostic_findings,
957 status,
958 reason,
959 missing_required_env_vars: setup_readiness.missing_required_env_vars,
960 missing_required_config_keys: setup_readiness.missing_required_config_keys,
961 bootstrap_hint: bootstrap_hint(ir),
962 });
963 }
964
965 plan
966 }
967}
968
969fn evaluate_plugin_setup_readiness(
970 setup: Option<&PluginSetup>,
971 context: &PluginSetupReadinessContext,
972) -> PluginSetupReadiness {
973 let Some(setup) = setup else {
974 return PluginSetupReadiness::default();
975 };
976
977 evaluate_plugin_setup_requirements(
978 &setup.required_env_vars,
979 &setup.required_config_keys,
980 context,
981 )
982}
983
984fn format_plugin_setup_incomplete_reason(readiness: &PluginSetupReadiness) -> String {
985 let mut reasons = Vec::new();
986
987 if !readiness.missing_required_env_vars.is_empty() {
988 let missing_env_vars = readiness.missing_required_env_vars.join(", ");
989 let env_reason = format!("missing required env vars: {missing_env_vars}");
990 reasons.push(env_reason);
991 }
992
993 if !readiness.missing_required_config_keys.is_empty() {
994 let missing_config_keys = readiness.missing_required_config_keys.join(", ");
995 let config_reason = format!("missing required config keys: {missing_config_keys}");
996 reasons.push(config_reason);
997 }
998
999 let combined_reasons = reasons.join("; ");
1000 format!("plugin setup is incomplete: {combined_reasons}")
1001}
1002
1003fn plugin_manifest_contract_is_invalid(ir: &PluginIR) -> bool {
1004 let Some(channel_bridge) = ir.channel_bridge.as_ref() else {
1005 return false;
1006 };
1007
1008 !channel_bridge.readiness.ready
1009}
1010
1011fn format_invalid_manifest_contract_reason(ir: &PluginIR) -> String {
1012 let Some(channel_bridge) = ir.channel_bridge.as_ref() else {
1013 return "plugin manifest contract is invalid".to_owned();
1014 };
1015
1016 let missing_fields = channel_bridge.readiness.missing_fields.join(", ");
1017 format!("plugin channel bridge contract is incomplete: {missing_fields}")
1018}
1019
1020fn activation_diagnostic_finding(
1021 ir: &PluginIR,
1022 status: PluginActivationStatus,
1023 reason: &str,
1024) -> Option<PluginDiagnosticFinding> {
1025 let (code, field_path, remediation) = match status {
1026 PluginActivationStatus::Ready => return None,
1027 PluginActivationStatus::SetupIncomplete => return None,
1028 PluginActivationStatus::BlockedInvalidManifestContract => (
1029 PluginDiagnosticCode::InvalidManifestContract,
1030 Some("channel_bridge".to_owned()),
1031 Some(
1032 "declare the required channel bridge contract fields in the manifest before activation"
1033 .to_owned(),
1034 ),
1035 ),
1036 PluginActivationStatus::BlockedCompatibilityMode => (
1037 PluginDiagnosticCode::CompatibilityShimRequired,
1038 Some("compatibility_mode".to_owned()),
1039 Some(
1040 "enable or widen the required compatibility shim support policy in the runtime bridge matrix, or migrate the plugin to the native Loong contract before activation"
1041 .to_owned(),
1042 ),
1043 ),
1044 PluginActivationStatus::BlockedIncompatibleHost => (
1045 PluginDiagnosticCode::IncompatibleHost,
1046 Some("compatibility".to_owned()),
1047 Some(
1048 "align `compatibility.host_api` / `compatibility.host_version_req` with the current host, or upgrade Loong before activation"
1049 .to_owned(),
1050 ),
1051 ),
1052 PluginActivationStatus::BlockedUnsupportedBridge => (
1053 PluginDiagnosticCode::UnsupportedBridge,
1054 Some("metadata.bridge_kind".to_owned()),
1055 Some(
1056 "switch the plugin to a supported bridge kind or widen the runtime bridge support policy before activation"
1057 .to_owned(),
1058 ),
1059 ),
1060 PluginActivationStatus::BlockedUnsupportedAdapterFamily => (
1061 PluginDiagnosticCode::UnsupportedAdapterFamily,
1062 Some("metadata.adapter_family".to_owned()),
1063 Some(
1064 "switch the plugin adapter family to one supported by the current runtime matrix"
1065 .to_owned(),
1066 ),
1067 ),
1068 PluginActivationStatus::BlockedSlotClaimConflict => (
1069 PluginDiagnosticCode::SlotClaimConflict,
1070 Some("slot_claims".to_owned()),
1071 Some(
1072 "choose a different slot/key pair or relax ownership to shared/advisory only when the surface is intentionally multi-owner"
1073 .to_owned(),
1074 ),
1075 ),
1076 PluginActivationStatus::Unknown => return None,
1077 };
1078
1079 Some(PluginDiagnosticFinding {
1080 code,
1081 severity: PluginDiagnosticSeverity::Error,
1082 phase: PluginDiagnosticPhase::Activation,
1083 blocking: true,
1084 plugin_id: Some(ir.plugin_id.clone()),
1085 source_path: Some(ir.source_path.clone()),
1086 source_kind: Some(ir.source_kind),
1087 field_path,
1088 message: reason.to_owned(),
1089 remediation,
1090 })
1091}
1092
1093#[derive(Debug, Clone)]
1094struct SlotClaimOwner {
1095 plugin_id: String,
1096 provider_id: String,
1097 mode: PluginSlotMode,
1098 source_path: Option<String>,
1099}
1100
1101fn collect_slot_claim_conflicts(
1102 entries: &[PluginIR],
1103 catalog: Option<&IntegrationCatalog>,
1104) -> BTreeMap<(String, String), String> {
1105 let mut conflicts: BTreeMap<(String, String), BTreeSet<String>> = BTreeMap::new();
1106
1107 if let Some(catalog) = catalog {
1108 let existing_claims = existing_slot_claims_from_catalog(catalog);
1109
1110 for entry in entries {
1111 for claim in &entry.slot_claims {
1112 let key = (claim.slot.clone(), claim.key.clone());
1113 let Some(existing_owners) = existing_claims.get(&key) else {
1114 continue;
1115 };
1116
1117 for owner in existing_owners {
1118 if owner.plugin_id == entry.plugin_id
1119 || !slot_modes_conflict(owner.mode, claim.mode)
1120 {
1121 continue;
1122 }
1123
1124 conflicts
1125 .entry((entry.source_path.clone(), entry.plugin_id.clone()))
1126 .or_default()
1127 .insert(format!(
1128 "slot claim `{}`:`{}` ({}) conflicts with existing plugin `{}` (provider `{}`{})",
1129 claim.slot,
1130 claim.key,
1131 claim.mode.as_str(),
1132 owner.plugin_id,
1133 owner.provider_id,
1134 owner
1135 .source_path
1136 .as_deref()
1137 .map(|path| format!(", source `{path}`"))
1138 .unwrap_or_default()
1139 ));
1140 }
1141 }
1142 }
1143 }
1144
1145 for (index, entry) in entries.iter().enumerate() {
1146 for other in entries.iter().skip(index + 1) {
1147 for claim in &entry.slot_claims {
1148 let Some(other_claim) = other
1149 .slot_claims
1150 .iter()
1151 .find(|candidate| candidate.slot == claim.slot && candidate.key == claim.key)
1152 else {
1153 continue;
1154 };
1155
1156 if !slot_modes_conflict(claim.mode, other_claim.mode) {
1157 continue;
1158 }
1159
1160 conflicts
1161 .entry((entry.source_path.clone(), entry.plugin_id.clone()))
1162 .or_default()
1163 .insert(format!(
1164 "slot claim `{}`:`{}` ({}) conflicts with plugin `{}` (provider `{}`, source `{}`) as `{}`",
1165 claim.slot,
1166 claim.key,
1167 claim.mode.as_str(),
1168 other.plugin_id,
1169 other.provider_id,
1170 other.source_path,
1171 other_claim.mode.as_str()
1172 ));
1173 conflicts
1174 .entry((other.source_path.clone(), other.plugin_id.clone()))
1175 .or_default()
1176 .insert(format!(
1177 "slot claim `{}`:`{}` ({}) conflicts with plugin `{}` (provider `{}`, source `{}`) as `{}`",
1178 other_claim.slot,
1179 other_claim.key,
1180 other_claim.mode.as_str(),
1181 entry.plugin_id,
1182 entry.provider_id,
1183 entry.source_path,
1184 claim.mode.as_str()
1185 ));
1186 }
1187 }
1188 }
1189
1190 conflicts
1191 .into_iter()
1192 .map(|(key, reasons)| (key, reasons.into_iter().collect::<Vec<_>>().join("; ")))
1193 .collect()
1194}
1195
1196fn existing_slot_claims_from_catalog(
1197 catalog: &IntegrationCatalog,
1198) -> BTreeMap<(String, String), Vec<SlotClaimOwner>> {
1199 let mut registry: BTreeMap<(String, String), Vec<SlotClaimOwner>> = BTreeMap::new();
1200
1201 for provider in catalog.providers() {
1202 let Some(raw_json) = provider.metadata.get(PLUGIN_SLOT_CLAIMS_METADATA_KEY) else {
1203 continue;
1204 };
1205 let Ok(claims) = serde_json::from_str::<Vec<PluginSlotClaim>>(raw_json) else {
1206 continue;
1207 };
1208
1209 let plugin_id = provider
1210 .metadata
1211 .get("plugin_id")
1212 .cloned()
1213 .unwrap_or_else(|| format!("provider:{}", provider.provider_id));
1214 let source_path = provider.metadata.get("plugin_source_path").cloned();
1215
1216 for claim in claims {
1217 registry
1218 .entry((claim.slot, claim.key))
1219 .or_default()
1220 .push(SlotClaimOwner {
1221 plugin_id: plugin_id.clone(),
1222 provider_id: provider.provider_id.clone(),
1223 mode: claim.mode,
1224 source_path: source_path.clone(),
1225 });
1226 }
1227 }
1228
1229 registry
1230}
1231
1232fn infer_runtime_profile(language: &str, manifest: &PluginManifest) -> PluginRuntimeProfile {
1233 let endpoint = manifest.endpoint.as_deref();
1234 infer_runtime_profile_from_parts(language, &manifest.metadata, endpoint)
1235}
1236
1237fn infer_runtime_profile_from_parts(
1238 language: &str,
1239 metadata: &BTreeMap<String, String>,
1240 endpoint: Option<&str>,
1241) -> PluginRuntimeProfile {
1242 let source_language = metadata
1243 .get("source_language")
1244 .map(|value| normalize_language(value))
1245 .filter(|value| value != "unknown")
1246 .unwrap_or_else(|| normalize_language(language));
1247
1248 let bridge_kind = metadata
1249 .get("bridge_kind")
1250 .and_then(|value| parse_bridge_kind(value))
1251 .or_else(|| {
1252 metadata
1253 .get("protocol")
1254 .filter(|value| value.eq_ignore_ascii_case("mcp"))
1255 .map(|_| PluginBridgeKind::McpServer)
1256 })
1257 .unwrap_or_else(|| default_bridge_kind(&source_language, endpoint));
1258
1259 let adapter_family = metadata
1260 .get("adapter_family")
1261 .cloned()
1262 .unwrap_or_else(|| default_adapter_family(&source_language, bridge_kind));
1263
1264 let entrypoint_hint = metadata
1265 .get("entrypoint")
1266 .cloned()
1267 .or_else(|| default_entrypoint_hint(bridge_kind, endpoint))
1268 .unwrap_or_else(|| "invoke".to_owned());
1269
1270 PluginRuntimeProfile {
1271 source_language,
1272 bridge_kind,
1273 adapter_family,
1274 entrypoint_hint,
1275 }
1276}
1277
1278fn derive_channel_bridge_contract(
1279 manifest: &PluginManifest,
1280) -> Option<PluginChannelBridgeContract> {
1281 let channel_id = normalized_optional_value(manifest.channel_id.as_deref());
1282 let setup_surface = normalized_optional_value(
1283 manifest
1284 .setup
1285 .as_ref()
1286 .and_then(|setup| setup.surface.as_deref()),
1287 );
1288 let transport_family = normalized_manifest_metadata_value(manifest, "transport_family");
1289 let target_contract = normalized_manifest_metadata_value(manifest, "target_contract");
1290 let account_scope = normalized_manifest_metadata_value(manifest, "account_scope");
1291 let runtime_contract = normalized_manifest_metadata_value(manifest, "channel_runtime_contract");
1292 let runtime_operations =
1293 normalized_manifest_metadata_string_list(manifest, "channel_runtime_operations_json");
1294 let mut runtime_metadata_issues = Vec::new();
1295 let runtime_operations = match runtime_operations {
1296 Ok(runtime_operations) => runtime_operations,
1297 Err(issue) => {
1298 runtime_metadata_issues.push(issue);
1299 Vec::new()
1300 }
1301 };
1302 let adapter_family = normalized_manifest_metadata_value(manifest, "adapter_family");
1303
1304 let has_channel_bridge_metadata =
1305 transport_family.is_some() || target_contract.is_some() || account_scope.is_some();
1306 let adapter_declares_channel_bridge = adapter_family.as_deref() == Some("channel-bridge");
1307 let declares_channel_bridge = has_channel_bridge_metadata || adapter_declares_channel_bridge;
1311
1312 if !declares_channel_bridge {
1313 return None;
1314 }
1315
1316 let readiness = evaluate_channel_bridge_readiness(
1317 channel_id.as_deref(),
1318 setup_surface.as_deref(),
1319 transport_family.as_deref(),
1320 target_contract.as_deref(),
1321 );
1322
1323 Some(PluginChannelBridgeContract {
1324 channel_id,
1325 setup_surface,
1326 transport_family,
1327 target_contract,
1328 account_scope,
1329 runtime_contract,
1330 runtime_operations,
1331 runtime_metadata_issues,
1332 readiness,
1333 })
1334}
1335
1336fn evaluate_channel_bridge_readiness(
1337 channel_id: Option<&str>,
1338 setup_surface: Option<&str>,
1339 transport_family: Option<&str>,
1340 target_contract: Option<&str>,
1341) -> PluginChannelBridgeReadiness {
1342 let mut missing_fields = Vec::new();
1343
1344 if channel_id.is_none() {
1345 missing_fields.push("channel_id".to_owned());
1346 }
1347
1348 if setup_surface != Some("channel") {
1349 missing_fields.push("setup.surface".to_owned());
1350 }
1351
1352 if transport_family.is_none() {
1353 missing_fields.push("metadata.transport_family".to_owned());
1354 }
1355
1356 if target_contract.is_none() {
1357 missing_fields.push("metadata.target_contract".to_owned());
1358 }
1359
1360 let ready = missing_fields.is_empty();
1361
1362 PluginChannelBridgeReadiness {
1363 ready,
1364 missing_fields,
1365 }
1366}
1367
1368fn normalized_manifest_metadata_value(manifest: &PluginManifest, key: &str) -> Option<String> {
1369 let value = manifest.metadata.get(key);
1370 let value = value.map(String::as_str);
1371 normalized_optional_value(value)
1372}
1373
1374fn normalized_manifest_metadata_string_list(
1375 manifest: &PluginManifest,
1376 key: &str,
1377) -> Result<Vec<String>, String> {
1378 let Some(raw_value) = manifest.metadata.get(key) else {
1379 return Ok(Vec::new());
1380 };
1381
1382 let parsed_values = serde_json::from_str::<Vec<String>>(raw_value)
1383 .map_err(|error| format!("metadata.{key} must be valid json string array: {error}"))?;
1384
1385 let mut normalized_values = Vec::new();
1386 for parsed_value in parsed_values {
1387 let trimmed_value = parsed_value.trim();
1388 if trimmed_value.is_empty() {
1389 continue;
1390 }
1391
1392 let normalized_value = trimmed_value.to_owned();
1393 normalized_values.push(normalized_value);
1394 }
1395
1396 Ok(normalized_values)
1397}
1398
1399fn normalized_optional_value(raw: Option<&str>) -> Option<String> {
1400 let value = raw?;
1401 let trimmed = value.trim();
1402
1403 if trimmed.is_empty() {
1404 return None;
1405 }
1406
1407 Some(trimmed.to_owned())
1408}
1409
1410pub fn plugin_runtime_scaffold_defaults(
1411 bridge_kind: PluginBridgeKind,
1412 source_language: Option<&str>,
1413) -> Result<PluginRuntimeScaffoldDefaults, String> {
1414 if matches!(bridge_kind, PluginBridgeKind::Unknown) {
1415 return Err("plugin scaffold does not support bridge_kind `unknown`".to_owned());
1416 }
1417
1418 let normalized_source_language = source_language
1419 .map(normalize_language)
1420 .filter(|value| value != "unknown" && value != "manifest");
1421
1422 let source_language_is_required = matches!(
1423 bridge_kind,
1424 PluginBridgeKind::ProcessStdio | PluginBridgeKind::NativeFfi
1425 );
1426
1427 if source_language_is_required && normalized_source_language.is_none() {
1428 return Err(format!(
1429 "plugin scaffold requires an explicit source language for bridge_kind `{}`",
1430 bridge_kind.as_str()
1431 ));
1432 }
1433
1434 let adapter_language = normalized_source_language.as_deref().unwrap_or("unknown");
1435 let adapter_family = default_adapter_family(adapter_language, bridge_kind);
1436 let entrypoint_hint =
1437 default_entrypoint_hint(bridge_kind, None).unwrap_or_else(|| "invoke".to_owned());
1438
1439 Ok(PluginRuntimeScaffoldDefaults {
1440 source_language: normalized_source_language,
1441 bridge_kind,
1442 adapter_family,
1443 entrypoint_hint,
1444 })
1445}
1446
1447fn legacy_plugin_ir_dialect(source_kind: PluginSourceKind) -> PluginContractDialect {
1448 match source_kind {
1449 PluginSourceKind::PackageManifest => PluginContractDialect::LoongPackageManifest,
1450 PluginSourceKind::EmbeddedSource => PluginContractDialect::LoongEmbeddedSource,
1451 }
1452}
1453
1454fn legacy_plugin_ir_runtime_profile(
1455 source_path: &str,
1456 source_kind: PluginSourceKind,
1457 metadata: &BTreeMap<String, String>,
1458 endpoint: Option<&str>,
1459) -> PluginRuntimeProfile {
1460 let source_language = legacy_plugin_ir_source_language(source_path, source_kind);
1461 infer_runtime_profile_from_parts(&source_language, metadata, endpoint)
1462}
1463
1464fn legacy_plugin_ir_source_language(source_path: &str, source_kind: PluginSourceKind) -> String {
1465 if source_kind == PluginSourceKind::PackageManifest {
1466 return "unknown".to_owned();
1467 }
1468
1469 let extension = Path::new(source_path)
1470 .extension()
1471 .and_then(|value| value.to_str())
1472 .unwrap_or_default();
1473 normalize_language(extension)
1474}
1475
1476fn normalize_language(language: &str) -> String {
1477 match language.trim().to_ascii_lowercase().as_str() {
1478 "rs" => "rust".to_owned(),
1479 "py" => "python".to_owned(),
1480 "js" => "javascript".to_owned(),
1481 "ts" => "typescript".to_owned(),
1482 "go" => "go".to_owned(),
1483 "wasm" => "wasm".to_owned(),
1484 "" => "unknown".to_owned(),
1485 other => other.to_owned(),
1486 }
1487}
1488
1489fn parse_bridge_kind(raw: &str) -> Option<PluginBridgeKind> {
1490 match raw.trim().to_ascii_lowercase().as_str() {
1491 "http_json" | "http" => Some(PluginBridgeKind::HttpJson),
1492 "process_stdio" | "stdio" => Some(PluginBridgeKind::ProcessStdio),
1493 "native_ffi" | "ffi" => Some(PluginBridgeKind::NativeFfi),
1494 "wasm_component" | "wasm" => Some(PluginBridgeKind::WasmComponent),
1495 "mcp_server" | "mcp" => Some(PluginBridgeKind::McpServer),
1496 "acp_bridge" | "acp" => Some(PluginBridgeKind::AcpBridge),
1497 "acp_runtime" | "acpx" => Some(PluginBridgeKind::AcpRuntime),
1498 "unknown" => Some(PluginBridgeKind::Unknown),
1499 _ => None,
1500 }
1501}
1502
1503fn default_bridge_kind(language: &str, endpoint: Option<&str>) -> PluginBridgeKind {
1504 match language {
1505 "rust" | "go" | "c" | "cpp" | "cxx" => PluginBridgeKind::NativeFfi,
1506 "python" | "javascript" | "typescript" | "java" => PluginBridgeKind::ProcessStdio,
1507 "wasm" | "wat" => PluginBridgeKind::WasmComponent,
1508 _ => {
1509 if let Some(endpoint) = endpoint
1510 && (endpoint.starts_with("http://") || endpoint.starts_with("https://"))
1511 {
1512 return PluginBridgeKind::HttpJson;
1513 }
1514 PluginBridgeKind::Unknown
1515 }
1516 }
1517}
1518
1519fn default_adapter_family(language: &str, bridge_kind: PluginBridgeKind) -> String {
1520 match bridge_kind {
1521 PluginBridgeKind::HttpJson => "http-adapter".to_owned(),
1522 PluginBridgeKind::ProcessStdio => format!("{language}-stdio-adapter"),
1523 PluginBridgeKind::NativeFfi => format!("{language}-ffi-adapter"),
1524 PluginBridgeKind::WasmComponent => "wasm-component-adapter".to_owned(),
1525 PluginBridgeKind::McpServer => "mcp-adapter".to_owned(),
1526 PluginBridgeKind::AcpBridge => "acp-bridge-adapter".to_owned(),
1527 PluginBridgeKind::AcpRuntime => "acp-runtime-adapter".to_owned(),
1528 PluginBridgeKind::Unknown => format!("{language}-unknown-adapter"),
1529 }
1530}
1531
1532fn default_entrypoint_hint(
1533 bridge_kind: PluginBridgeKind,
1534 endpoint: Option<&str>,
1535) -> Option<String> {
1536 match bridge_kind {
1537 PluginBridgeKind::HttpJson => {
1538 Some(endpoint.unwrap_or("https://localhost/invoke").to_owned())
1539 }
1540 PluginBridgeKind::ProcessStdio => Some("stdin/stdout::invoke".to_owned()),
1541 PluginBridgeKind::NativeFfi => Some("lib::invoke".to_owned()),
1542 PluginBridgeKind::WasmComponent => Some("component::run".to_owned()),
1543 PluginBridgeKind::McpServer => Some("mcp::stdio".to_owned()),
1544 PluginBridgeKind::AcpBridge => Some("acp::bridge".to_owned()),
1545 PluginBridgeKind::AcpRuntime => Some("acp::turn".to_owned()),
1546 PluginBridgeKind::Unknown => None,
1547 }
1548}
1549
1550fn bootstrap_hint(ir: &PluginIR) -> String {
1551 let compatibility_prefix = PluginCompatibilityShim::for_mode(ir.compatibility_mode)
1552 .map(|shim| {
1553 format!(
1554 "enable compatibility shim `{}` ({}) and then ",
1555 shim.shim_id, shim.family
1556 )
1557 })
1558 .unwrap_or_default();
1559
1560 match ir.runtime.bridge_kind {
1561 PluginBridgeKind::HttpJson => format!(
1562 "{}register http connector adapter for {} at {}",
1563 compatibility_prefix,
1564 ir.connector_name,
1565 ir.endpoint.as_deref().unwrap_or("https://localhost/invoke")
1566 ),
1567 PluginBridgeKind::ProcessStdio => format!(
1568 "{}spawn {} worker and bind stdio bridge {}",
1569 compatibility_prefix, ir.runtime.source_language, ir.runtime.entrypoint_hint
1570 ),
1571 PluginBridgeKind::NativeFfi => format!(
1572 "{}load native library adapter {} with symbol {}",
1573 compatibility_prefix, ir.runtime.adapter_family, ir.runtime.entrypoint_hint
1574 ),
1575 PluginBridgeKind::WasmComponent => {
1576 format!(
1577 "{}load wasm component and invoke {}",
1578 compatibility_prefix, ir.runtime.entrypoint_hint
1579 )
1580 }
1581 PluginBridgeKind::McpServer => format!(
1582 "{}register MCP server bridge and handshake capability schema",
1583 compatibility_prefix
1584 ),
1585 PluginBridgeKind::AcpBridge => format!(
1586 "{}register ACP bridge surface and bind the external gateway/runtime contract",
1587 compatibility_prefix
1588 ),
1589 PluginBridgeKind::AcpRuntime => {
1590 format!(
1591 "{}register ACP runtime backend and bind a session-aware control plane",
1592 compatibility_prefix
1593 )
1594 }
1595 PluginBridgeKind::Unknown => format!(
1596 "{}inspect plugin metadata and define explicit bridge_kind override",
1597 compatibility_prefix
1598 ),
1599 }
1600}
1601
1602#[cfg(test)]
1603mod tests {
1604 use super::*;
1605 use crate::{
1606 integration::ProviderConfig,
1607 plugin::{
1608 CURRENT_PLUGIN_HOST_API, CURRENT_PLUGIN_MANIFEST_API_VERSION, PluginCompatibility,
1609 PluginCompatibilityMode, PluginContractDialect, PluginDiagnosticCode,
1610 PluginDiagnosticFinding, PluginDiagnosticPhase, PluginDiagnosticSeverity,
1611 PluginManifest, PluginSetup, PluginSetupMode, PluginSlotClaim, PluginSlotMode,
1612 PluginSourceKind, PluginTrustTier,
1613 },
1614 };
1615
1616 fn descriptor(language: &str, metadata: BTreeMap<String, String>) -> PluginDescriptor {
1617 let source_kind = if language == "manifest" {
1618 PluginSourceKind::PackageManifest
1619 } else {
1620 PluginSourceKind::EmbeddedSource
1621 };
1622 let path = if language == "manifest" {
1623 "/tmp/loong.plugin.json".to_owned()
1624 } else {
1625 format!("/tmp/plugin.{language}")
1626 };
1627 let package_manifest_path = if matches!(source_kind, PluginSourceKind::PackageManifest) {
1628 Some(path.clone())
1629 } else {
1630 None
1631 };
1632
1633 PluginDescriptor {
1634 path,
1635 source_kind,
1636 dialect: match source_kind {
1637 PluginSourceKind::PackageManifest => PluginContractDialect::LoongPackageManifest,
1638 PluginSourceKind::EmbeddedSource => PluginContractDialect::LoongEmbeddedSource,
1639 },
1640 dialect_version: matches!(source_kind, PluginSourceKind::PackageManifest)
1641 .then(|| CURRENT_PLUGIN_MANIFEST_API_VERSION.to_owned()),
1642 compatibility_mode: PluginCompatibilityMode::Native,
1643 package_root: "/tmp".to_owned(),
1644 package_manifest_path,
1645 language: language.to_owned(),
1646 manifest: PluginManifest {
1647 api_version: matches!(source_kind, PluginSourceKind::PackageManifest)
1648 .then(|| CURRENT_PLUGIN_MANIFEST_API_VERSION.to_owned()),
1649 version: Some("1.0.0".to_owned()),
1650 plugin_id: format!("sample-{language}"),
1651 provider_id: "sample-provider".to_owned(),
1652 connector_name: "sample-connector".to_owned(),
1653 channel_id: Some("primary".to_owned()),
1654 endpoint: Some("https://example.com/invoke".to_owned()),
1655 capabilities: BTreeSet::from([Capability::InvokeConnector]),
1656 trust_tier: PluginTrustTier::VerifiedCommunity,
1657 metadata,
1658 summary: None,
1659 tags: Vec::new(),
1660 input_examples: Vec::new(),
1661 output_examples: Vec::new(),
1662 defer_loading: false,
1663 setup: Some(PluginSetup {
1664 mode: PluginSetupMode::MetadataOnly,
1665 surface: Some("web_search".to_owned()),
1666 required_env_vars: vec!["TAVILY_API_KEY".to_owned()],
1667 recommended_env_vars: vec!["TEAM_TAVILY_KEY".to_owned()],
1668 required_config_keys: vec!["tools.web_search.default_provider".to_owned()],
1669 default_env_var: Some("TAVILY_API_KEY".to_owned()),
1670 docs_urls: vec!["https://docs.example.com/tavily".to_owned()],
1671 remediation: Some("set a Tavily credential before enabling search".to_owned()),
1672 }),
1673 slot_claims: Vec::new(),
1674 compatibility: None,
1675 },
1676 }
1677 }
1678
1679 fn verified_setup_readiness_context() -> PluginSetupReadinessContext {
1680 PluginSetupReadinessContext {
1681 verified_env_vars: BTreeSet::from(["TAVILY_API_KEY".to_owned()]),
1682 verified_config_keys: BTreeSet::from(["tools.web_search.default_provider".to_owned()]),
1683 }
1684 }
1685
1686 fn channel_bridge_descriptor(metadata: BTreeMap<String, String>) -> PluginDescriptor {
1687 let mut descriptor = descriptor("manifest", metadata);
1688
1689 descriptor.manifest.plugin_id = "weixin-clawbot-bridge".to_owned();
1690 descriptor.manifest.provider_id = "weixin-bridge".to_owned();
1691 descriptor.manifest.connector_name = "weixin-clawbot-http".to_owned();
1692 descriptor.manifest.channel_id = Some("weixin".to_owned());
1693 descriptor.manifest.endpoint = Some("http://127.0.0.1:8091/bridge".to_owned());
1694 descriptor
1695 .manifest
1696 .metadata
1697 .entry("adapter_family".to_owned())
1698 .or_insert_with(|| "channel-bridge".to_owned());
1699 descriptor.manifest.setup = Some(PluginSetup {
1700 mode: PluginSetupMode::MetadataOnly,
1701 surface: Some("channel".to_owned()),
1702 required_env_vars: vec!["WEIXIN_BRIDGE_URL".to_owned()],
1703 recommended_env_vars: vec!["WEIXIN_BRIDGE_ACCESS_TOKEN".to_owned()],
1704 required_config_keys: vec![
1705 "weixin.enabled".to_owned(),
1706 "weixin.bridge_url".to_owned(),
1707 "weixin.bridge_access_token".to_owned(),
1708 ],
1709 default_env_var: Some("WEIXIN_BRIDGE_URL".to_owned()),
1710 docs_urls: vec!["https://docs.example.com/weixin-bridge".to_owned()],
1711 remediation: Some("configure the sanctioned weixin bridge contract".to_owned()),
1712 });
1713
1714 descriptor
1715 }
1716
1717 #[test]
1718 fn translator_infers_bridge_from_source_language() {
1719 let scanner_report = PluginScanReport {
1720 scanned_files: 2,
1721 matched_plugins: 2,
1722 diagnostic_findings: Vec::new(),
1723 descriptors: vec![
1724 descriptor("rs", BTreeMap::new()),
1725 descriptor("py", BTreeMap::new()),
1726 ],
1727 };
1728
1729 let translator = PluginTranslator::new();
1730 let report = translator.translate_scan_report(&scanner_report);
1731
1732 assert_eq!(report.translated_plugins, 2);
1733 assert_eq!(
1734 report.bridge_distribution.get("native_ffi").copied(),
1735 Some(1)
1736 );
1737 assert_eq!(
1738 report.bridge_distribution.get("process_stdio").copied(),
1739 Some(1)
1740 );
1741 assert!(
1742 report
1743 .entries
1744 .iter()
1745 .all(|entry| entry.trust_tier == PluginTrustTier::VerifiedCommunity)
1746 );
1747 }
1748
1749 #[test]
1750 fn translator_honors_metadata_bridge_override() {
1751 let descriptor = descriptor(
1752 "js",
1753 BTreeMap::from([
1754 ("bridge_kind".to_owned(), "mcp_server".to_owned()),
1755 ("entrypoint".to_owned(), "custom::run".to_owned()),
1756 ]),
1757 );
1758
1759 let translator = PluginTranslator::new();
1760 let ir = translator.translate_descriptor(&descriptor);
1761
1762 assert_eq!(ir.runtime.bridge_kind, PluginBridgeKind::McpServer);
1763 assert_eq!(ir.runtime.entrypoint_hint, "custom::run");
1764 assert_eq!(ir.runtime.adapter_family, "mcp-adapter");
1765 }
1766
1767 #[test]
1768 fn translator_honors_metadata_source_language_for_package_manifests() {
1769 let descriptor = descriptor(
1770 "manifest",
1771 BTreeMap::from([
1772 ("bridge_kind".to_owned(), "process_stdio".to_owned()),
1773 ("source_language".to_owned(), "py".to_owned()),
1774 ]),
1775 );
1776
1777 let translator = PluginTranslator::new();
1778 let ir = translator.translate_descriptor(&descriptor);
1779
1780 assert_eq!(ir.runtime.source_language, "python");
1781 assert_eq!(ir.runtime.bridge_kind, PluginBridgeKind::ProcessStdio);
1782 assert_eq!(ir.runtime.adapter_family, "python-stdio-adapter");
1783 assert_eq!(ir.runtime.entrypoint_hint, "stdin/stdout::invoke");
1784 }
1785
1786 #[test]
1787 fn translator_defaults_manifest_descriptor_with_endpoint_to_http_json() {
1788 let descriptor = descriptor("manifest", BTreeMap::new());
1789
1790 let translator = PluginTranslator::new();
1791 let ir = translator.translate_descriptor(&descriptor);
1792
1793 assert_eq!(ir.runtime.source_language, "manifest");
1794 assert_eq!(ir.runtime.bridge_kind, PluginBridgeKind::HttpJson);
1795 assert_eq!(ir.runtime.adapter_family, "http-adapter");
1796 assert_eq!(ir.trust_tier, PluginTrustTier::VerifiedCommunity);
1797 assert_eq!(ir.source_kind, PluginSourceKind::PackageManifest);
1798 assert_eq!(ir.package_root, "/tmp");
1799 assert_eq!(
1800 ir.setup.as_ref().and_then(|setup| setup.surface.as_deref()),
1801 Some("web_search")
1802 );
1803 assert_eq!(
1804 ir.package_manifest_path,
1805 Some("/tmp/loong.plugin.json".to_owned())
1806 );
1807 }
1808
1809 #[test]
1810 fn translator_derives_channel_bridge_contract_from_manifest_conventions() {
1811 let descriptor = channel_bridge_descriptor(BTreeMap::from([
1812 (
1813 "transport_family".to_owned(),
1814 "wechat_clawbot_ilink_bridge".to_owned(),
1815 ),
1816 (
1817 "target_contract".to_owned(),
1818 "weixin:<account>:contact:<id> | weixin:<account>:room:<id>".to_owned(),
1819 ),
1820 ("account_scope".to_owned(), "multi_account".to_owned()),
1821 (
1822 "channel_runtime_contract".to_owned(),
1823 "loong_channel_bridge_v1".to_owned(),
1824 ),
1825 (
1826 "channel_runtime_operations_json".to_owned(),
1827 "[\"send\",\"receive_batch\",\"ack_inbound\",\"complete_batch\"]".to_owned(),
1828 ),
1829 ]));
1830
1831 let translator = PluginTranslator::new();
1832 let ir = translator.translate_descriptor(&descriptor);
1833
1834 let channel_bridge = ir
1835 .channel_bridge
1836 .as_ref()
1837 .expect("channel bridge contract should exist");
1838
1839 assert_eq!(channel_bridge.channel_id.as_deref(), Some("weixin"));
1840 assert_eq!(channel_bridge.setup_surface.as_deref(), Some("channel"));
1841 assert_eq!(
1842 channel_bridge.transport_family.as_deref(),
1843 Some("wechat_clawbot_ilink_bridge")
1844 );
1845 assert_eq!(
1846 channel_bridge.target_contract.as_deref(),
1847 Some("weixin:<account>:contact:<id> | weixin:<account>:room:<id>")
1848 );
1849 assert_eq!(
1850 channel_bridge.account_scope.as_deref(),
1851 Some("multi_account")
1852 );
1853 assert_eq!(
1854 channel_bridge.runtime_contract.as_deref(),
1855 Some("loong_channel_bridge_v1")
1856 );
1857 assert_eq!(
1858 channel_bridge.runtime_operations,
1859 vec![
1860 "send".to_owned(),
1861 "receive_batch".to_owned(),
1862 "ack_inbound".to_owned(),
1863 "complete_batch".to_owned(),
1864 ]
1865 );
1866 assert!(channel_bridge.readiness.ready);
1867 assert!(channel_bridge.readiness.missing_fields.is_empty());
1868 }
1869
1870 #[test]
1871 fn translator_marks_declared_channel_bridge_contract_incomplete_when_required_fields_are_missing()
1872 {
1873 let descriptor = channel_bridge_descriptor(BTreeMap::new());
1874
1875 let translator = PluginTranslator::new();
1876 let ir = translator.translate_descriptor(&descriptor);
1877
1878 let channel_bridge = ir
1879 .channel_bridge
1880 .as_ref()
1881 .expect("channel bridge contract should exist");
1882
1883 assert!(!channel_bridge.readiness.ready);
1884 assert_eq!(
1885 channel_bridge.readiness.missing_fields,
1886 vec![
1887 "metadata.transport_family".to_owned(),
1888 "metadata.target_contract".to_owned(),
1889 ]
1890 );
1891 }
1892
1893 #[test]
1894 fn translator_preserves_invalid_runtime_operations_metadata_json_as_issue() {
1895 let descriptor = channel_bridge_descriptor(BTreeMap::from([
1896 (
1897 "transport_family".to_owned(),
1898 "wechat_clawbot_ilink_bridge".to_owned(),
1899 ),
1900 (
1901 "target_contract".to_owned(),
1902 "weixin:<account>:contact:<id> | weixin:<account>:room:<id>".to_owned(),
1903 ),
1904 (
1905 "channel_runtime_operations_json".to_owned(),
1906 "{not-json}".to_owned(),
1907 ),
1908 ]));
1909
1910 let translator = PluginTranslator::new();
1911 let ir = translator.translate_descriptor(&descriptor);
1912 let channel_bridge = ir
1913 .channel_bridge
1914 .as_ref()
1915 .expect("channel bridge contract should exist");
1916
1917 assert!(channel_bridge.runtime_operations.is_empty());
1918 assert_eq!(channel_bridge.runtime_metadata_issues.len(), 1);
1919 let issue = channel_bridge
1920 .runtime_metadata_issues
1921 .first()
1922 .expect("runtime metadata issue should exist");
1923 assert!(issue.starts_with(
1924 "metadata.channel_runtime_operations_json must be valid json string array:"
1925 ));
1926 }
1927
1928 #[test]
1929 fn translator_does_not_treat_channel_surface_only_setup_as_channel_bridge_contract() {
1930 let mut descriptor = descriptor("manifest", BTreeMap::new());
1931
1932 descriptor.manifest.channel_id = Some("weather".to_owned());
1933 descriptor.manifest.setup = Some(PluginSetup {
1934 mode: PluginSetupMode::GovernedEntry,
1935 surface: Some("channel".to_owned()),
1936 required_env_vars: vec!["WEATHER_API_KEY".to_owned()],
1937 recommended_env_vars: Vec::new(),
1938 required_config_keys: vec!["plugins.entries.weather-sdk".to_owned()],
1939 default_env_var: Some("WEATHER_API_KEY".to_owned()),
1940 docs_urls: vec!["https://example.com/weather-sdk".to_owned()],
1941 remediation: Some("configure the weather sdk before activation".to_owned()),
1942 });
1943
1944 let translator = PluginTranslator::new();
1945 let ir = translator.translate_descriptor(&descriptor);
1946
1947 assert!(
1948 ir.channel_bridge.is_none(),
1949 "plain channel-surface setup should not be treated as a managed channel bridge"
1950 );
1951 }
1952
1953 #[test]
1954 fn plugin_runtime_scaffold_defaults_require_source_language_for_process_stdio() {
1955 let error = plugin_runtime_scaffold_defaults(PluginBridgeKind::ProcessStdio, None)
1956 .expect_err("process bridge scaffold should require source language");
1957
1958 assert!(error.contains("source language"));
1959 assert!(error.contains("process_stdio"));
1960 }
1961
1962 #[test]
1963 fn plugin_runtime_scaffold_defaults_require_source_language_for_native_ffi() {
1964 let error = plugin_runtime_scaffold_defaults(PluginBridgeKind::NativeFfi, None)
1965 .expect_err("native ffi scaffold should require source language");
1966
1967 assert!(error.contains("source language"));
1968 assert!(error.contains("native_ffi"));
1969 }
1970
1971 #[test]
1972 fn plugin_runtime_scaffold_defaults_normalize_python_process_bridge() {
1973 let defaults = plugin_runtime_scaffold_defaults(PluginBridgeKind::ProcessStdio, Some("py"))
1974 .expect("python process bridge scaffold defaults should resolve");
1975
1976 assert_eq!(defaults.source_language.as_deref(), Some("python"));
1977 assert_eq!(defaults.bridge_kind, PluginBridgeKind::ProcessStdio);
1978 assert_eq!(defaults.adapter_family, "python-stdio-adapter");
1979 assert_eq!(defaults.entrypoint_hint, "stdin/stdout::invoke");
1980 }
1981
1982 #[test]
1983 fn translator_accepts_acpx_runtime_alias() {
1984 let descriptor = descriptor(
1985 "js",
1986 BTreeMap::from([("bridge_kind".to_owned(), "acpx".to_owned())]),
1987 );
1988
1989 let translator = PluginTranslator::new();
1990 let ir = translator.translate_descriptor(&descriptor);
1991
1992 assert_eq!(ir.runtime.bridge_kind, PluginBridgeKind::AcpRuntime);
1993 assert_eq!(ir.runtime.adapter_family, "acp-runtime-adapter");
1994 assert_eq!(ir.runtime.entrypoint_hint, "acp::turn");
1995 }
1996
1997 #[test]
1998 fn translator_maps_acp_alias_to_bridge_surface() {
1999 let descriptor = descriptor(
2000 "js",
2001 BTreeMap::from([("bridge_kind".to_owned(), "acp".to_owned())]),
2002 );
2003
2004 let translator = PluginTranslator::new();
2005 let ir = translator.translate_descriptor(&descriptor);
2006
2007 assert_eq!(ir.runtime.bridge_kind, PluginBridgeKind::AcpBridge);
2008 assert_eq!(ir.runtime.adapter_family, "acp-bridge-adapter");
2009 assert_eq!(ir.runtime.entrypoint_hint, "acp::bridge");
2010 }
2011
2012 #[test]
2013 fn translator_projects_scan_diagnostics_into_ir() {
2014 let descriptor = descriptor("js", BTreeMap::new());
2015 let translation = PluginTranslator::new().translate_scan_report(&PluginScanReport {
2016 scanned_files: 1,
2017 matched_plugins: 1,
2018 diagnostic_findings: vec![PluginDiagnosticFinding {
2019 code: PluginDiagnosticCode::EmbeddedSourceLegacyContract,
2020 severity: PluginDiagnosticSeverity::Warning,
2021 phase: PluginDiagnosticPhase::Scan,
2022 blocking: false,
2023 plugin_id: Some("sample-js".to_owned()),
2024 source_path: Some("/tmp/plugin.js".to_owned()),
2025 source_kind: Some(PluginSourceKind::EmbeddedSource),
2026 field_path: None,
2027 message: "legacy source marker".to_owned(),
2028 remediation: Some("add loong.plugin.json".to_owned()),
2029 }],
2030 descriptors: vec![descriptor],
2031 });
2032
2033 assert_eq!(translation.entries.len(), 1);
2034 assert_eq!(translation.entries[0].diagnostic_findings.len(), 1);
2035 assert_eq!(
2036 translation.entries[0].diagnostic_findings[0].code,
2037 PluginDiagnosticCode::EmbeddedSourceLegacyContract
2038 );
2039 assert_eq!(
2040 translation.entries[0].diagnostic_findings[0].phase,
2041 PluginDiagnosticPhase::Scan
2042 );
2043 assert!(!translation.entries[0].diagnostic_findings[0].blocking);
2044 }
2045
2046 #[test]
2047 fn activation_plan_marks_setup_incomplete_when_required_setup_is_missing() {
2048 let descriptor = descriptor(
2049 "js",
2050 BTreeMap::from([("bridge_kind".to_owned(), "http_json".to_owned())]),
2051 );
2052 let translator = PluginTranslator::new();
2053 let translation = translator.translate_scan_report(&PluginScanReport {
2054 scanned_files: 1,
2055 matched_plugins: 1,
2056 diagnostic_findings: Vec::new(),
2057 descriptors: vec![descriptor],
2058 });
2059
2060 let matrix = BridgeSupportMatrix {
2061 supported_bridges: BTreeSet::from([PluginBridgeKind::HttpJson]),
2062 supported_adapter_families: BTreeSet::new(),
2063 supported_compatibility_modes: BTreeSet::from([PluginCompatibilityMode::Native]),
2064 supported_compatibility_shims: BTreeSet::new(),
2065 supported_compatibility_shim_profiles: BTreeMap::new(),
2066 };
2067 let setup_readiness_context = PluginSetupReadinessContext::default();
2068 let plan = translator.plan_activation(&translation, &matrix, &setup_readiness_context);
2069
2070 assert_eq!(plan.total_plugins, 1);
2071 assert_eq!(plan.ready_plugins, 0);
2072 assert_eq!(plan.setup_incomplete_plugins, 1);
2073 assert_eq!(plan.blocked_plugins, 0);
2074 assert_eq!(
2075 plan.candidates[0].source_kind,
2076 PluginSourceKind::EmbeddedSource
2077 );
2078 assert_eq!(plan.candidates[0].package_root, "/tmp");
2079 assert_eq!(plan.candidates[0].package_manifest_path, None);
2080 assert_eq!(
2081 plan.candidates[0].trust_tier,
2082 PluginTrustTier::VerifiedCommunity
2083 );
2084 assert!(matches!(
2085 plan.candidates[0].status,
2086 PluginActivationStatus::SetupIncomplete
2087 ));
2088 assert_eq!(
2089 plan.candidates[0].missing_required_env_vars,
2090 vec!["TAVILY_API_KEY".to_owned()]
2091 );
2092 assert_eq!(
2093 plan.candidates[0].missing_required_config_keys,
2094 vec!["tools.web_search.default_provider".to_owned()]
2095 );
2096 }
2097
2098 #[test]
2099 fn activation_plan_blocks_declared_channel_bridge_when_manifest_contract_fields_are_missing() {
2100 let descriptor = channel_bridge_descriptor(BTreeMap::new());
2101 let translator = PluginTranslator::new();
2102 let translation = translator.translate_scan_report(&PluginScanReport {
2103 scanned_files: 1,
2104 matched_plugins: 1,
2105 diagnostic_findings: Vec::new(),
2106 descriptors: vec![descriptor],
2107 });
2108
2109 let matrix = BridgeSupportMatrix {
2110 supported_bridges: BTreeSet::from([PluginBridgeKind::HttpJson]),
2111 supported_adapter_families: BTreeSet::new(),
2112 supported_compatibility_modes: BTreeSet::from([PluginCompatibilityMode::Native]),
2113 supported_compatibility_shims: BTreeSet::new(),
2114 supported_compatibility_shim_profiles: BTreeMap::new(),
2115 };
2116 let setup_readiness_context = PluginSetupReadinessContext {
2117 verified_env_vars: BTreeSet::from(["WEIXIN_BRIDGE_URL".to_owned()]),
2118 verified_config_keys: BTreeSet::from([
2119 "weixin.enabled".to_owned(),
2120 "weixin.bridge_url".to_owned(),
2121 "weixin.bridge_access_token".to_owned(),
2122 ]),
2123 };
2124 let plan = translator.plan_activation(&translation, &matrix, &setup_readiness_context);
2125
2126 assert_eq!(plan.ready_plugins, 0);
2127 assert_eq!(plan.setup_incomplete_plugins, 0);
2128 assert_eq!(plan.blocked_plugins, 1);
2129 assert!(matches!(
2130 plan.candidates[0].status,
2131 PluginActivationStatus::BlockedInvalidManifestContract
2132 ));
2133 assert!(
2134 plan.candidates[0]
2135 .reason
2136 .contains("metadata.transport_family"),
2137 "reason should surface missing transport family"
2138 );
2139 assert!(
2140 plan.candidates[0]
2141 .reason
2142 .contains("metadata.target_contract"),
2143 "reason should surface missing target contract"
2144 );
2145 assert!(
2146 plan.candidates[0]
2147 .diagnostic_findings
2148 .iter()
2149 .any(|finding| finding.code == PluginDiagnosticCode::InvalidManifestContract)
2150 );
2151 }
2152
2153 #[test]
2154 fn evaluate_plugin_setup_requirements_uses_platform_env_name_rules() {
2155 let required_env_vars = vec!["PATH".to_owned()];
2156 let required_config_keys = Vec::new();
2157 let context = PluginSetupReadinessContext {
2158 verified_env_vars: BTreeSet::from(["Path".to_owned()]),
2159 verified_config_keys: BTreeSet::new(),
2160 };
2161
2162 let readiness =
2163 evaluate_plugin_setup_requirements(&required_env_vars, &required_config_keys, &context);
2164
2165 #[cfg(windows)]
2166 {
2167 assert!(readiness.ready);
2168 assert!(readiness.missing_required_env_vars.is_empty());
2169 }
2170
2171 #[cfg(not(windows))]
2172 {
2173 assert!(!readiness.ready);
2174 assert_eq!(readiness.missing_required_env_vars, required_env_vars);
2175 }
2176 }
2177
2178 #[test]
2179 fn activation_plan_deserializes_legacy_payloads_without_setup_fields() {
2180 let legacy_payload = r#"{
2181 "total_plugins": 1,
2182 "ready_plugins": 1,
2183 "blocked_plugins": 0,
2184 "candidates": [
2185 {
2186 "plugin_id": "sample-plugin",
2187 "source_path": "/tmp/plugin.py",
2188 "source_kind": "embedded_source",
2189 "package_root": "/tmp",
2190 "package_manifest_path": null,
2191 "bridge_kind": "http_json",
2192 "adapter_family": "http-adapter",
2193 "status": "ready",
2194 "reason": "plugin runtime profile is supported by current runtime matrix",
2195 "bootstrap_hint": "register http connector adapter"
2196 }
2197 ]
2198 }"#;
2199
2200 let plan: PluginActivationPlan =
2201 serde_json::from_str(legacy_payload).expect("legacy payload should deserialize");
2202
2203 assert_eq!(plan.setup_incomplete_plugins, 0);
2204 assert_eq!(plan.candidates.len(), 1);
2205 assert!(plan.candidates[0].missing_required_env_vars.is_empty());
2206 assert!(plan.candidates[0].missing_required_config_keys.is_empty());
2207 }
2208
2209 #[test]
2210 fn plugin_ir_deserializes_legacy_embedded_source_payload_with_inferred_defaults() {
2211 let raw = r#"{
2212 "plugin_id": "legacy-plugin",
2213 "provider_id": "legacy-provider",
2214 "connector_name": "legacy-connector",
2215 "capabilities": [],
2216 "metadata": {},
2217 "source_path": "/tmp/legacy-plugin.py",
2218 "source_kind": "embedded_source",
2219 "package_root": "/tmp"
2220 }"#;
2221
2222 let ir: PluginIR =
2223 serde_json::from_str(raw).expect("legacy embedded-source payload should deserialize");
2224
2225 assert_eq!(ir.dialect, PluginContractDialect::LoongEmbeddedSource);
2226 assert_eq!(ir.compatibility_mode, PluginCompatibilityMode::Native);
2227 assert!(ir.diagnostic_findings.is_empty());
2228 assert!(ir.slot_claims.is_empty());
2229 assert_eq!(ir.runtime.source_language, "python");
2230 assert_eq!(ir.runtime.bridge_kind, PluginBridgeKind::ProcessStdio);
2231 assert_eq!(ir.runtime.adapter_family, "python-stdio-adapter");
2232 }
2233
2234 #[test]
2235 fn plugin_ir_deserializes_legacy_package_manifest_payload_with_inferred_defaults() {
2236 let raw = r#"{
2237 "plugin_id": "legacy-package",
2238 "provider_id": "legacy-provider",
2239 "connector_name": "legacy-connector",
2240 "endpoint": "https://plugins.example.test/invoke",
2241 "capabilities": [],
2242 "metadata": {},
2243 "source_path": "/tmp/loong.plugin.json",
2244 "source_kind": "package_manifest",
2245 "package_root": "/tmp"
2246 }"#;
2247
2248 let ir: PluginIR =
2249 serde_json::from_str(raw).expect("legacy package payload should deserialize");
2250
2251 assert_eq!(ir.dialect, PluginContractDialect::LoongPackageManifest);
2252 assert_eq!(ir.compatibility_mode, PluginCompatibilityMode::Native);
2253 assert_eq!(ir.runtime.source_language, "unknown");
2254 assert_eq!(ir.runtime.bridge_kind, PluginBridgeKind::HttpJson);
2255 assert_eq!(
2256 ir.runtime.entrypoint_hint,
2257 "https://plugins.example.test/invoke"
2258 );
2259 }
2260
2261 #[test]
2262 fn plugin_activation_status_deserializes_unknown_variants_as_unknown() {
2263 let raw = "\"blocked_future_contract_surface\"";
2264 let status: PluginActivationStatus =
2265 serde_json::from_str(raw).expect("unknown activation status should deserialize");
2266
2267 assert_eq!(status, PluginActivationStatus::Unknown);
2268 assert_eq!(status.as_str(), "unknown");
2269 }
2270
2271 #[test]
2272 fn activation_plan_blocks_unsupported_bridge() {
2273 let descriptor = descriptor(
2274 "js",
2275 BTreeMap::from([("bridge_kind".to_owned(), "mcp_server".to_owned())]),
2276 );
2277 let translator = PluginTranslator::new();
2278 let translation = translator.translate_scan_report(&PluginScanReport {
2279 scanned_files: 1,
2280 matched_plugins: 1,
2281 diagnostic_findings: Vec::new(),
2282 descriptors: vec![descriptor],
2283 });
2284
2285 let matrix = BridgeSupportMatrix {
2286 supported_bridges: BTreeSet::from([PluginBridgeKind::HttpJson]),
2287 supported_adapter_families: BTreeSet::new(),
2288 supported_compatibility_modes: BTreeSet::from([PluginCompatibilityMode::Native]),
2289 supported_compatibility_shims: BTreeSet::new(),
2290 supported_compatibility_shim_profiles: BTreeMap::new(),
2291 };
2292 let setup_readiness_context = PluginSetupReadinessContext::default();
2293 let plan = translator.plan_activation(&translation, &matrix, &setup_readiness_context);
2294
2295 assert_eq!(plan.total_plugins, 1);
2296 assert_eq!(plan.ready_plugins, 0);
2297 assert_eq!(plan.blocked_plugins, 1);
2298 assert_eq!(
2299 plan.candidates[0].source_kind,
2300 PluginSourceKind::EmbeddedSource
2301 );
2302 assert_eq!(plan.candidates[0].package_root, "/tmp");
2303 assert_eq!(plan.candidates[0].package_manifest_path, None);
2304 assert!(matches!(
2305 plan.candidates[0].status,
2306 PluginActivationStatus::BlockedUnsupportedBridge
2307 ));
2308 assert_eq!(
2309 plan.candidates[0].diagnostic_findings[0].code,
2310 PluginDiagnosticCode::UnsupportedBridge
2311 );
2312 assert_eq!(
2313 plan.candidates[0].diagnostic_findings[0].phase,
2314 PluginDiagnosticPhase::Activation
2315 );
2316 assert!(plan.candidates[0].diagnostic_findings[0].blocking);
2317 }
2318
2319 #[test]
2320 fn activation_plan_blocks_unsupported_adapter_family() {
2321 let descriptor = descriptor(
2322 "py",
2323 BTreeMap::from([(
2324 "adapter_family".to_owned(),
2325 "python-stdio-adapter".to_owned(),
2326 )]),
2327 );
2328 let translator = PluginTranslator::new();
2329 let translation = translator.translate_scan_report(&PluginScanReport {
2330 scanned_files: 1,
2331 matched_plugins: 1,
2332 diagnostic_findings: Vec::new(),
2333 descriptors: vec![descriptor],
2334 });
2335
2336 let matrix = BridgeSupportMatrix {
2337 supported_bridges: BTreeSet::from([PluginBridgeKind::ProcessStdio]),
2338 supported_adapter_families: BTreeSet::from(["rust-stdio-adapter".to_owned()]),
2339 supported_compatibility_modes: BTreeSet::from([PluginCompatibilityMode::Native]),
2340 supported_compatibility_shims: BTreeSet::new(),
2341 supported_compatibility_shim_profiles: BTreeMap::new(),
2342 };
2343 let setup_readiness_context = PluginSetupReadinessContext {
2344 verified_env_vars: BTreeSet::from(["TAVILY_API_KEY".to_owned()]),
2345 verified_config_keys: BTreeSet::from(["tools.web_search.default_provider".to_owned()]),
2346 };
2347 let plan = translator.plan_activation(&translation, &matrix, &setup_readiness_context);
2348
2349 assert_eq!(plan.total_plugins, 1);
2350 assert_eq!(plan.ready_plugins, 0);
2351 assert_eq!(plan.blocked_plugins, 1);
2352 assert!(matches!(
2353 plan.candidates[0].status,
2354 PluginActivationStatus::BlockedUnsupportedAdapterFamily
2355 ));
2356 assert_eq!(
2357 plan.candidates[0].diagnostic_findings[0].code,
2358 PluginDiagnosticCode::UnsupportedAdapterFamily
2359 );
2360 }
2361
2362 #[test]
2363 fn activation_plan_blocks_conflicting_slot_claims_within_translation() {
2364 let mut first = descriptor(
2365 "js",
2366 BTreeMap::from([("bridge_kind".to_owned(), "http_json".to_owned())]),
2367 );
2368 first.manifest.plugin_id = "search-a".to_owned();
2369 first.manifest.provider_id = "search-a".to_owned();
2370 first.manifest.connector_name = "search-a".to_owned();
2371 first.path = "/tmp/search-a.js".to_owned();
2372 first.manifest.slot_claims = vec![PluginSlotClaim {
2373 slot: "provider:web_search".to_owned(),
2374 key: "tavily".to_owned(),
2375 mode: PluginSlotMode::Exclusive,
2376 }];
2377
2378 let mut second = descriptor(
2379 "ts",
2380 BTreeMap::from([("bridge_kind".to_owned(), "http_json".to_owned())]),
2381 );
2382 second.manifest.plugin_id = "search-b".to_owned();
2383 second.manifest.provider_id = "search-b".to_owned();
2384 second.manifest.connector_name = "search-b".to_owned();
2385 second.path = "/tmp/search-b.ts".to_owned();
2386 second.manifest.slot_claims = vec![PluginSlotClaim {
2387 slot: "provider:web_search".to_owned(),
2388 key: "tavily".to_owned(),
2389 mode: PluginSlotMode::Exclusive,
2390 }];
2391
2392 let translation = PluginTranslator::new().translate_scan_report(&PluginScanReport {
2393 scanned_files: 2,
2394 matched_plugins: 2,
2395 diagnostic_findings: Vec::new(),
2396 descriptors: vec![first, second],
2397 });
2398 let matrix = BridgeSupportMatrix {
2399 supported_bridges: BTreeSet::from([PluginBridgeKind::HttpJson]),
2400 supported_adapter_families: BTreeSet::new(),
2401 supported_compatibility_modes: BTreeSet::from([PluginCompatibilityMode::Native]),
2402 supported_compatibility_shims: BTreeSet::new(),
2403 supported_compatibility_shim_profiles: BTreeMap::new(),
2404 };
2405
2406 let setup_readiness_context = verified_setup_readiness_context();
2407 let plan = PluginTranslator::new().plan_activation(
2408 &translation,
2409 &matrix,
2410 &setup_readiness_context,
2411 );
2412
2413 assert_eq!(plan.total_plugins, 2);
2414 assert_eq!(plan.ready_plugins, 0);
2415 assert_eq!(plan.blocked_plugins, 2);
2416 assert!(plan.candidates.iter().all(|candidate| matches!(
2417 candidate.status,
2418 PluginActivationStatus::BlockedSlotClaimConflict
2419 )));
2420 assert!(
2421 plan.candidates[0].reason.contains("provider:web_search"),
2422 "slot conflict reason should mention the claimed surface"
2423 );
2424 assert!(plan.candidates.iter().all(|candidate| {
2425 candidate
2426 .diagnostic_findings
2427 .iter()
2428 .any(|finding| finding.code == PluginDiagnosticCode::SlotClaimConflict)
2429 }));
2430 }
2431
2432 #[test]
2433 fn activation_plan_blocks_slot_claim_conflicts_against_existing_catalog() {
2434 let mut descriptor = descriptor(
2435 "js",
2436 BTreeMap::from([("bridge_kind".to_owned(), "http_json".to_owned())]),
2437 );
2438 descriptor.manifest.plugin_id = "incoming-search".to_owned();
2439 descriptor.manifest.provider_id = "incoming-search".to_owned();
2440 descriptor.manifest.connector_name = "incoming-search".to_owned();
2441 descriptor.path = "/tmp/incoming-search.js".to_owned();
2442 descriptor.manifest.slot_claims = vec![PluginSlotClaim {
2443 slot: "provider:web_search".to_owned(),
2444 key: "tavily".to_owned(),
2445 mode: PluginSlotMode::Exclusive,
2446 }];
2447
2448 let translation = PluginTranslator::new().translate_scan_report(&PluginScanReport {
2449 scanned_files: 1,
2450 matched_plugins: 1,
2451 diagnostic_findings: Vec::new(),
2452 descriptors: vec![descriptor],
2453 });
2454 let matrix = BridgeSupportMatrix {
2455 supported_bridges: BTreeSet::from([PluginBridgeKind::HttpJson]),
2456 supported_adapter_families: BTreeSet::new(),
2457 supported_compatibility_modes: BTreeSet::from([PluginCompatibilityMode::Native]),
2458 supported_compatibility_shims: BTreeSet::new(),
2459 supported_compatibility_shim_profiles: BTreeMap::new(),
2460 };
2461 let mut catalog = IntegrationCatalog::new();
2462 catalog.upsert_provider(ProviderConfig {
2463 provider_id: "existing-search".to_owned(),
2464 connector_name: "existing-search".to_owned(),
2465 version: "1.0.0".to_owned(),
2466 metadata: BTreeMap::from([
2467 ("plugin_id".to_owned(), "existing-search".to_owned()),
2468 (
2469 PLUGIN_SLOT_CLAIMS_METADATA_KEY.to_owned(),
2470 "[{\"slot\":\"provider:web_search\",\"key\":\"tavily\",\"mode\":\"exclusive\"}]"
2471 .to_owned(),
2472 ),
2473 (
2474 "plugin_source_path".to_owned(),
2475 "/tmp/existing-search.plugin.json".to_owned(),
2476 ),
2477 ]),
2478 });
2479
2480 let setup_readiness_context = PluginSetupReadinessContext::default();
2481 let plan = PluginTranslator::new().plan_activation_with_catalog(
2482 &translation,
2483 &matrix,
2484 &setup_readiness_context,
2485 Some(&catalog),
2486 );
2487
2488 assert_eq!(plan.total_plugins, 1);
2489 assert_eq!(plan.ready_plugins, 0);
2490 assert_eq!(plan.blocked_plugins, 1);
2491 assert!(matches!(
2492 plan.candidates[0].status,
2493 PluginActivationStatus::BlockedSlotClaimConflict
2494 ));
2495 assert!(
2496 plan.candidates[0]
2497 .reason
2498 .contains("existing plugin `existing-search`")
2499 );
2500 assert!(
2501 plan.candidates[0]
2502 .diagnostic_findings
2503 .iter()
2504 .any(|finding| { finding.code == PluginDiagnosticCode::SlotClaimConflict })
2505 );
2506 }
2507
2508 #[test]
2509 fn activation_plan_blocks_incompatible_host_before_bridge_checks() {
2510 let mut descriptor = descriptor(
2511 "js",
2512 BTreeMap::from([("bridge_kind".to_owned(), "mcp_server".to_owned())]),
2513 );
2514 descriptor.manifest.compatibility = Some(PluginCompatibility {
2515 host_api: Some("loong-plugin/v999".to_owned()),
2516 host_version_req: None,
2517 });
2518
2519 let translation = PluginTranslator::new().translate_scan_report(&PluginScanReport {
2520 scanned_files: 1,
2521 matched_plugins: 1,
2522 diagnostic_findings: Vec::new(),
2523 descriptors: vec![descriptor],
2524 });
2525 let matrix = BridgeSupportMatrix {
2526 supported_bridges: BTreeSet::from([PluginBridgeKind::HttpJson]),
2527 supported_adapter_families: BTreeSet::new(),
2528 supported_compatibility_modes: BTreeSet::from([PluginCompatibilityMode::Native]),
2529 supported_compatibility_shims: BTreeSet::new(),
2530 supported_compatibility_shim_profiles: BTreeMap::new(),
2531 };
2532
2533 let setup_readiness_context = verified_setup_readiness_context();
2534 let plan = PluginTranslator::new().plan_activation(
2535 &translation,
2536 &matrix,
2537 &setup_readiness_context,
2538 );
2539
2540 assert_eq!(plan.total_plugins, 1);
2541 assert_eq!(plan.ready_plugins, 0);
2542 assert_eq!(plan.blocked_plugins, 1);
2543 assert!(matches!(
2544 plan.candidates[0].status,
2545 PluginActivationStatus::BlockedIncompatibleHost
2546 ));
2547 assert!(
2548 plan.candidates[0].reason.contains(CURRENT_PLUGIN_HOST_API),
2549 "compatibility reason should mention the supported host api"
2550 );
2551 assert!(
2552 plan.candidates[0]
2553 .diagnostic_findings
2554 .iter()
2555 .any(|finding| { finding.code == PluginDiagnosticCode::IncompatibleHost })
2556 );
2557 }
2558
2559 #[test]
2560 fn activation_plan_projects_inventory_entries_with_activation_truth() {
2561 let descriptor = descriptor(
2562 "manifest",
2563 BTreeMap::from([("bridge_kind".to_owned(), "http_json".to_owned())]),
2564 );
2565 let translator = PluginTranslator::new();
2566 let translation = translator.translate_scan_report(&PluginScanReport {
2567 scanned_files: 1,
2568 matched_plugins: 1,
2569 diagnostic_findings: Vec::new(),
2570 descriptors: vec![descriptor],
2571 });
2572 let setup_readiness_context = verified_setup_readiness_context();
2573 let plan = translator.plan_activation(
2574 &translation,
2575 &BridgeSupportMatrix::default(),
2576 &setup_readiness_context,
2577 );
2578
2579 let inventory = plan.inventory_entries(&translation);
2580
2581 assert_eq!(inventory.len(), 1);
2582 assert_eq!(inventory[0].plugin_id, "sample-manifest");
2583 assert_eq!(
2584 inventory[0].manifest_api_version.as_deref(),
2585 Some("v1alpha1")
2586 );
2587 assert_eq!(inventory[0].plugin_version.as_deref(), Some("1.0.0"));
2588 assert_eq!(
2589 inventory[0].dialect,
2590 PluginContractDialect::LoongPackageManifest
2591 );
2592 assert_eq!(
2593 inventory[0].compatibility_mode,
2594 PluginCompatibilityMode::Native
2595 );
2596 assert_eq!(inventory[0].provider_id, "sample-provider");
2597 assert_eq!(inventory[0].connector_name, "sample-connector");
2598 assert_eq!(inventory[0].bridge_kind, PluginBridgeKind::HttpJson);
2599 assert_eq!(inventory[0].source_language, "manifest");
2600 assert_eq!(
2601 inventory[0]
2602 .activation_status
2603 .map(|status| status.as_str().to_owned()),
2604 Some("ready".to_owned())
2605 );
2606 assert!(
2607 inventory[0]
2608 .activation_reason
2609 .as_deref()
2610 .is_some_and(|reason| reason.contains("runtime profile"))
2611 );
2612 assert!(inventory[0].bootstrap_hint.is_some());
2613 assert!(inventory[0].diagnostic_findings.is_empty());
2614 }
2615
2616 #[test]
2617 fn activation_plan_blocker_summary_includes_specific_plugin_reasons() {
2618 let mut first = descriptor(
2619 "js",
2620 BTreeMap::from([("bridge_kind".to_owned(), "http_json".to_owned())]),
2621 );
2622 first.manifest.plugin_id = "search-a".to_owned();
2623 first.manifest.provider_id = "search-a".to_owned();
2624 first.manifest.connector_name = "search-a".to_owned();
2625 first.path = "/tmp/search-a.js".to_owned();
2626 first.manifest.slot_claims = vec![PluginSlotClaim {
2627 slot: "provider:web_search".to_owned(),
2628 key: "default".to_owned(),
2629 mode: PluginSlotMode::Exclusive,
2630 }];
2631
2632 let mut second = descriptor(
2633 "ts",
2634 BTreeMap::from([("bridge_kind".to_owned(), "http_json".to_owned())]),
2635 );
2636 second.manifest.plugin_id = "search-b".to_owned();
2637 second.manifest.provider_id = "search-b".to_owned();
2638 second.manifest.connector_name = "search-b".to_owned();
2639 second.path = "/tmp/search-b.ts".to_owned();
2640 second.manifest.slot_claims = vec![PluginSlotClaim {
2641 slot: "provider:web_search".to_owned(),
2642 key: "default".to_owned(),
2643 mode: PluginSlotMode::Exclusive,
2644 }];
2645
2646 let translation = PluginTranslator::new().translate_scan_report(&PluginScanReport {
2647 scanned_files: 2,
2648 matched_plugins: 2,
2649 diagnostic_findings: Vec::new(),
2650 descriptors: vec![first, second],
2651 });
2652 let setup_readiness_context = PluginSetupReadinessContext::default();
2653 let plan = PluginTranslator::new().plan_activation(
2654 &translation,
2655 &BridgeSupportMatrix::default(),
2656 &setup_readiness_context,
2657 );
2658
2659 let summary = plan.blocker_summary(1);
2660
2661 assert!(summary.contains("search-a") || summary.contains("search-b"));
2662 assert!(summary.contains("blocked_slot_claim_conflict"));
2663 assert!(summary.contains("provider:web_search"));
2664 assert!(summary.contains("+1 more blocked plugin(s)"));
2665 }
2666
2667 #[test]
2668 fn activation_plan_blocks_unsupported_compatibility_mode() {
2669 let mut descriptor = descriptor(
2670 "js",
2671 BTreeMap::from([("bridge_kind".to_owned(), "process_stdio".to_owned())]),
2672 );
2673 descriptor.dialect = PluginContractDialect::OpenClawModernManifest;
2674 descriptor.dialect_version = Some("openclaw.plugin.json".to_owned());
2675 descriptor.compatibility_mode = PluginCompatibilityMode::OpenClawModern;
2676
2677 let translation = PluginTranslator::new().translate_scan_report(&PluginScanReport {
2678 scanned_files: 1,
2679 matched_plugins: 1,
2680 diagnostic_findings: Vec::new(),
2681 descriptors: vec![descriptor],
2682 });
2683 let matrix = BridgeSupportMatrix {
2684 supported_bridges: BTreeSet::from([PluginBridgeKind::ProcessStdio]),
2685 supported_adapter_families: BTreeSet::new(),
2686 supported_compatibility_modes: BTreeSet::from([PluginCompatibilityMode::Native]),
2687 supported_compatibility_shims: BTreeSet::new(),
2688 supported_compatibility_shim_profiles: BTreeMap::new(),
2689 };
2690
2691 let setup_readiness_context = verified_setup_readiness_context();
2692 let plan = PluginTranslator::new().plan_activation(
2693 &translation,
2694 &matrix,
2695 &setup_readiness_context,
2696 );
2697
2698 assert_eq!(plan.blocked_plugins, 1);
2699 assert!(matches!(
2700 plan.candidates[0].status,
2701 PluginActivationStatus::BlockedCompatibilityMode
2702 ));
2703 assert_eq!(
2704 plan.candidates[0].compatibility_mode,
2705 PluginCompatibilityMode::OpenClawModern
2706 );
2707 assert_eq!(
2708 plan.candidates[0]
2709 .compatibility_shim
2710 .as_ref()
2711 .map(|shim| shim.shim_id.as_str()),
2712 Some("openclaw-modern-compat")
2713 );
2714 assert!(plan.candidates[0].reason.contains("openclaw-modern-compat"));
2715 assert!(
2716 plan.candidates[0]
2717 .bootstrap_hint
2718 .contains("enable compatibility shim `openclaw-modern-compat`")
2719 );
2720 assert!(
2721 plan.candidates[0]
2722 .diagnostic_findings
2723 .iter()
2724 .any(|finding| {
2725 finding.code == PluginDiagnosticCode::CompatibilityShimRequired
2726 && finding.blocking
2727 })
2728 );
2729 }
2730
2731 #[test]
2732 fn activation_plan_allows_supported_compatibility_mode() {
2733 let mut descriptor = descriptor(
2734 "js",
2735 BTreeMap::from([("bridge_kind".to_owned(), "process_stdio".to_owned())]),
2736 );
2737 descriptor.dialect = PluginContractDialect::OpenClawModernManifest;
2738 descriptor.dialect_version = Some("openclaw.plugin.json".to_owned());
2739 descriptor.compatibility_mode = PluginCompatibilityMode::OpenClawModern;
2740
2741 let translation = PluginTranslator::new().translate_scan_report(&PluginScanReport {
2742 scanned_files: 1,
2743 matched_plugins: 1,
2744 diagnostic_findings: Vec::new(),
2745 descriptors: vec![descriptor],
2746 });
2747 let matrix = BridgeSupportMatrix {
2748 supported_bridges: BTreeSet::from([PluginBridgeKind::ProcessStdio]),
2749 supported_adapter_families: BTreeSet::new(),
2750 supported_compatibility_modes: BTreeSet::from([
2751 PluginCompatibilityMode::Native,
2752 PluginCompatibilityMode::OpenClawModern,
2753 ]),
2754 supported_compatibility_shims: BTreeSet::new(),
2755 supported_compatibility_shim_profiles: BTreeMap::new(),
2756 };
2757
2758 let setup_readiness_context = verified_setup_readiness_context();
2759 let plan = PluginTranslator::new().plan_activation(
2760 &translation,
2761 &matrix,
2762 &setup_readiness_context,
2763 );
2764
2765 assert_eq!(plan.ready_plugins, 0);
2766 assert_eq!(plan.blocked_plugins, 1);
2767 assert!(matches!(
2768 plan.candidates[0].status,
2769 PluginActivationStatus::BlockedCompatibilityMode
2770 ));
2771 assert_eq!(
2772 plan.candidates[0]
2773 .compatibility_shim
2774 .as_ref()
2775 .map(|shim| shim.shim_id.as_str()),
2776 Some("openclaw-modern-compat")
2777 );
2778 assert!(
2779 plan.candidates[0]
2780 .reason
2781 .contains("requires compatibility shim `openclaw-modern-compat`")
2782 );
2783 assert!(
2784 plan.candidates[0]
2785 .bootstrap_hint
2786 .contains("enable compatibility shim `openclaw-modern-compat`")
2787 );
2788 }
2789
2790 #[test]
2791 fn activation_plan_allows_supported_compatibility_mode_when_shim_is_enabled() {
2792 let mut descriptor = descriptor(
2793 "js",
2794 BTreeMap::from([("bridge_kind".to_owned(), "process_stdio".to_owned())]),
2795 );
2796 descriptor.dialect = PluginContractDialect::OpenClawModernManifest;
2797 descriptor.dialect_version = Some("openclaw.plugin.json".to_owned());
2798 descriptor.compatibility_mode = PluginCompatibilityMode::OpenClawModern;
2799
2800 let translation = PluginTranslator::new().translate_scan_report(&PluginScanReport {
2801 scanned_files: 1,
2802 matched_plugins: 1,
2803 diagnostic_findings: Vec::new(),
2804 descriptors: vec![descriptor],
2805 });
2806 let matrix = BridgeSupportMatrix {
2807 supported_bridges: BTreeSet::from([PluginBridgeKind::ProcessStdio]),
2808 supported_adapter_families: BTreeSet::new(),
2809 supported_compatibility_modes: BTreeSet::from([
2810 PluginCompatibilityMode::Native,
2811 PluginCompatibilityMode::OpenClawModern,
2812 ]),
2813 supported_compatibility_shims: BTreeSet::from([PluginCompatibilityShim {
2814 shim_id: "openclaw-modern-compat".to_owned(),
2815 family: "openclaw-modern-compat".to_owned(),
2816 }]),
2817 supported_compatibility_shim_profiles: BTreeMap::new(),
2818 };
2819
2820 let setup_readiness_context = verified_setup_readiness_context();
2821 let plan = PluginTranslator::new().plan_activation(
2822 &translation,
2823 &matrix,
2824 &setup_readiness_context,
2825 );
2826
2827 assert_eq!(plan.ready_plugins, 1);
2828 assert_eq!(plan.blocked_plugins, 0);
2829 assert!(matches!(
2830 plan.candidates[0].status,
2831 PluginActivationStatus::Ready
2832 ));
2833 }
2834
2835 #[test]
2836 fn activation_plan_blocks_enabled_shim_profile_when_runtime_projection_mismatches() {
2837 let mut descriptor = descriptor(
2838 "js",
2839 BTreeMap::from([("bridge_kind".to_owned(), "process_stdio".to_owned())]),
2840 );
2841 descriptor.dialect = PluginContractDialect::OpenClawModernManifest;
2842 descriptor.dialect_version = Some("openclaw.plugin.json".to_owned());
2843 descriptor.compatibility_mode = PluginCompatibilityMode::OpenClawModern;
2844
2845 let translation = PluginTranslator::new().translate_scan_report(&PluginScanReport {
2846 scanned_files: 1,
2847 matched_plugins: 1,
2848 diagnostic_findings: Vec::new(),
2849 descriptors: vec![descriptor],
2850 });
2851 let shim = PluginCompatibilityShim {
2852 shim_id: "openclaw-modern-compat".to_owned(),
2853 family: "openclaw-modern-compat".to_owned(),
2854 };
2855 let matrix = BridgeSupportMatrix {
2856 supported_bridges: BTreeSet::from([PluginBridgeKind::ProcessStdio]),
2857 supported_adapter_families: BTreeSet::new(),
2858 supported_compatibility_modes: BTreeSet::from([
2859 PluginCompatibilityMode::Native,
2860 PluginCompatibilityMode::OpenClawModern,
2861 ]),
2862 supported_compatibility_shims: BTreeSet::new(),
2863 supported_compatibility_shim_profiles: BTreeMap::from([(
2864 shim.clone(),
2865 PluginCompatibilityShimSupport {
2866 shim,
2867 version: Some("openclaw-modern@1".to_owned()),
2868 supported_dialects: BTreeSet::from([
2869 PluginContractDialect::OpenClawModernManifest,
2870 ]),
2871 supported_bridges: BTreeSet::from([PluginBridgeKind::ProcessStdio]),
2872 supported_adapter_families: BTreeSet::new(),
2873 supported_source_languages: BTreeSet::from(["python".to_owned()]),
2874 },
2875 )]),
2876 };
2877
2878 let setup_readiness_context = verified_setup_readiness_context();
2879 let plan = PluginTranslator::new().plan_activation(
2880 &translation,
2881 &matrix,
2882 &setup_readiness_context,
2883 );
2884
2885 assert_eq!(plan.ready_plugins, 0);
2886 assert_eq!(plan.blocked_plugins, 1);
2887 assert!(matches!(
2888 plan.candidates[0].status,
2889 PluginActivationStatus::BlockedCompatibilityMode
2890 ));
2891 assert_eq!(
2892 plan.candidates[0]
2893 .compatibility_shim_support
2894 .as_ref()
2895 .and_then(|support| support.version.as_deref()),
2896 Some("openclaw-modern@1")
2897 );
2898 assert_eq!(
2899 plan.candidates[0].compatibility_shim_support_mismatch_reasons,
2900 vec!["source language `javascript`".to_owned()]
2901 );
2902 assert!(
2903 plan.candidates[0]
2904 .reason
2905 .contains("source language `javascript`")
2906 );
2907 assert!(plan.candidates[0].reason.contains("openclaw-modern@1"));
2908 }
2909
2910 #[test]
2911 fn activation_plan_allows_enabled_shim_profile_when_runtime_projection_matches() {
2912 let mut descriptor = descriptor(
2913 "js",
2914 BTreeMap::from([("bridge_kind".to_owned(), "process_stdio".to_owned())]),
2915 );
2916 descriptor.dialect = PluginContractDialect::OpenClawModernManifest;
2917 descriptor.dialect_version = Some("openclaw.plugin.json".to_owned());
2918 descriptor.compatibility_mode = PluginCompatibilityMode::OpenClawModern;
2919
2920 let translation = PluginTranslator::new().translate_scan_report(&PluginScanReport {
2921 scanned_files: 1,
2922 matched_plugins: 1,
2923 diagnostic_findings: Vec::new(),
2924 descriptors: vec![descriptor],
2925 });
2926 let shim = PluginCompatibilityShim {
2927 shim_id: "openclaw-modern-compat".to_owned(),
2928 family: "openclaw-modern-compat".to_owned(),
2929 };
2930 let matrix = BridgeSupportMatrix {
2931 supported_bridges: BTreeSet::from([PluginBridgeKind::ProcessStdio]),
2932 supported_adapter_families: BTreeSet::new(),
2933 supported_compatibility_modes: BTreeSet::from([
2934 PluginCompatibilityMode::Native,
2935 PluginCompatibilityMode::OpenClawModern,
2936 ]),
2937 supported_compatibility_shims: BTreeSet::new(),
2938 supported_compatibility_shim_profiles: BTreeMap::from([(
2939 shim.clone(),
2940 PluginCompatibilityShimSupport {
2941 shim,
2942 version: Some("openclaw-modern@1".to_owned()),
2943 supported_dialects: BTreeSet::from([
2944 PluginContractDialect::OpenClawModernManifest,
2945 ]),
2946 supported_bridges: BTreeSet::from([PluginBridgeKind::ProcessStdio]),
2947 supported_adapter_families: BTreeSet::new(),
2948 supported_source_languages: BTreeSet::from(["javascript".to_owned()]),
2949 },
2950 )]),
2951 };
2952
2953 let setup_readiness_context = verified_setup_readiness_context();
2954 let plan = PluginTranslator::new().plan_activation(
2955 &translation,
2956 &matrix,
2957 &setup_readiness_context,
2958 );
2959
2960 assert_eq!(plan.ready_plugins, 1);
2961 assert_eq!(plan.blocked_plugins, 0);
2962 assert!(matches!(
2963 plan.candidates[0].status,
2964 PluginActivationStatus::Ready
2965 ));
2966 assert_eq!(
2967 plan.candidates[0]
2968 .compatibility_shim_support
2969 .as_ref()
2970 .and_then(|support| support.version.as_deref()),
2971 Some("openclaw-modern@1")
2972 );
2973 assert!(
2974 plan.candidates[0]
2975 .compatibility_shim_support_mismatch_reasons
2976 .is_empty()
2977 );
2978 }
2979
2980 #[test]
2981 fn activation_plan_normalizes_source_language_before_shim_profile_match() {
2982 let mut descriptor = descriptor(
2983 "JavaScript",
2984 BTreeMap::from([("bridge_kind".to_owned(), "process_stdio".to_owned())]),
2985 );
2986 descriptor.dialect = PluginContractDialect::OpenClawModernManifest;
2987 descriptor.dialect_version = Some("openclaw.plugin.json".to_owned());
2988 descriptor.compatibility_mode = PluginCompatibilityMode::OpenClawModern;
2989
2990 let translation = PluginTranslator::new().translate_scan_report(&PluginScanReport {
2991 scanned_files: 1,
2992 matched_plugins: 1,
2993 diagnostic_findings: Vec::new(),
2994 descriptors: vec![descriptor],
2995 });
2996 let shim = PluginCompatibilityShim {
2997 shim_id: "openclaw-modern-compat".to_owned(),
2998 family: "openclaw-modern-compat".to_owned(),
2999 };
3000 let matrix = BridgeSupportMatrix {
3001 supported_bridges: BTreeSet::from([PluginBridgeKind::ProcessStdio]),
3002 supported_adapter_families: BTreeSet::new(),
3003 supported_compatibility_modes: BTreeSet::from([
3004 PluginCompatibilityMode::Native,
3005 PluginCompatibilityMode::OpenClawModern,
3006 ]),
3007 supported_compatibility_shims: BTreeSet::new(),
3008 supported_compatibility_shim_profiles: BTreeMap::from([(
3009 shim.clone(),
3010 PluginCompatibilityShimSupport {
3011 shim,
3012 version: Some("openclaw-modern@1".to_owned()),
3013 supported_dialects: BTreeSet::from([
3014 PluginContractDialect::OpenClawModernManifest,
3015 ]),
3016 supported_bridges: BTreeSet::from([PluginBridgeKind::ProcessStdio]),
3017 supported_adapter_families: BTreeSet::new(),
3018 supported_source_languages: BTreeSet::from(["javascript".to_owned()]),
3019 },
3020 )]),
3021 };
3022
3023 let setup_readiness_context = verified_setup_readiness_context();
3024 let plan = PluginTranslator::new().plan_activation(
3025 &translation,
3026 &matrix,
3027 &setup_readiness_context,
3028 );
3029
3030 assert_eq!(plan.ready_plugins, 1);
3031 assert_eq!(plan.blocked_plugins, 0);
3032 assert!(matches!(
3033 plan.candidates[0].status,
3034 PluginActivationStatus::Ready
3035 ));
3036 assert!(
3037 plan.candidates[0]
3038 .compatibility_shim_support_mismatch_reasons
3039 .is_empty()
3040 );
3041 }
3042
3043 #[test]
3044 fn activation_plan_marks_plugin_ready_when_setup_requirements_are_verified() {
3045 let descriptor = descriptor("manifest", BTreeMap::new());
3046 let translator = PluginTranslator::new();
3047 let translation = translator.translate_scan_report(&PluginScanReport {
3048 scanned_files: 1,
3049 matched_plugins: 1,
3050 diagnostic_findings: Vec::new(),
3051 descriptors: vec![descriptor],
3052 });
3053
3054 let matrix = BridgeSupportMatrix {
3055 supported_bridges: BTreeSet::from([PluginBridgeKind::HttpJson]),
3056 supported_adapter_families: BTreeSet::new(),
3057 ..BridgeSupportMatrix::default()
3058 };
3059 let setup_readiness_context = PluginSetupReadinessContext {
3060 verified_env_vars: BTreeSet::from(["TAVILY_API_KEY".to_owned()]),
3061 verified_config_keys: BTreeSet::from(["tools.web_search.default_provider".to_owned()]),
3062 };
3063 let plan = translator.plan_activation(&translation, &matrix, &setup_readiness_context);
3064
3065 assert_eq!(plan.total_plugins, 1);
3066 assert_eq!(plan.ready_plugins, 1);
3067 assert_eq!(plan.setup_incomplete_plugins, 0);
3068 assert_eq!(plan.blocked_plugins, 0);
3069 assert!(matches!(
3070 plan.candidates[0].status,
3071 PluginActivationStatus::Ready
3072 ));
3073 assert!(plan.candidates[0].missing_required_env_vars.is_empty());
3074 assert!(plan.candidates[0].missing_required_config_keys.is_empty());
3075 }
3076
3077 #[cfg(windows)]
3078 #[test]
3079 fn activation_plan_matches_verified_env_vars_case_insensitively_on_windows() {
3080 let descriptor = descriptor("manifest", BTreeMap::new());
3081 let translator = PluginTranslator::new();
3082 let translation = translator.translate_scan_report(&PluginScanReport {
3083 scanned_files: 1,
3084 matched_plugins: 1,
3085 diagnostic_findings: Vec::new(),
3086 descriptors: vec![descriptor],
3087 });
3088
3089 let matrix = BridgeSupportMatrix {
3090 supported_bridges: BTreeSet::from([PluginBridgeKind::HttpJson]),
3091 supported_adapter_families: BTreeSet::new(),
3092 ..BridgeSupportMatrix::default()
3093 };
3094 let setup_readiness_context = PluginSetupReadinessContext {
3095 verified_env_vars: BTreeSet::from(["tavily_api_key".to_owned()]),
3096 verified_config_keys: BTreeSet::from(["tools.web_search.default_provider".to_owned()]),
3097 };
3098 let plan = translator.plan_activation(&translation, &matrix, &setup_readiness_context);
3099
3100 assert!(matches!(
3101 plan.candidates[0].status,
3102 PluginActivationStatus::Ready
3103 ));
3104 }
3105
3106 #[cfg(not(windows))]
3107 #[test]
3108 fn activation_plan_keeps_verified_env_vars_case_sensitive_off_windows() {
3109 let descriptor = descriptor("manifest", BTreeMap::new());
3110 let translator = PluginTranslator::new();
3111 let translation = translator.translate_scan_report(&PluginScanReport {
3112 scanned_files: 1,
3113 matched_plugins: 1,
3114 diagnostic_findings: Vec::new(),
3115 descriptors: vec![descriptor],
3116 });
3117
3118 let matrix = BridgeSupportMatrix {
3119 supported_bridges: BTreeSet::from([PluginBridgeKind::HttpJson]),
3120 supported_adapter_families: BTreeSet::new(),
3121 ..BridgeSupportMatrix::default()
3122 };
3123 let setup_readiness_context = PluginSetupReadinessContext {
3124 verified_env_vars: BTreeSet::from(["tavily_api_key".to_owned()]),
3125 verified_config_keys: BTreeSet::from(["tools.web_search.default_provider".to_owned()]),
3126 };
3127 let plan = translator.plan_activation(&translation, &matrix, &setup_readiness_context);
3128
3129 assert!(matches!(
3130 plan.candidates[0].status,
3131 PluginActivationStatus::SetupIncomplete
3132 ));
3133 }
3134
3135 #[test]
3136 fn activation_plan_deserializes_old_payload_without_new_readiness_fields() {
3137 let raw = r#"
3138{
3139 "total_plugins": 1,
3140 "ready_plugins": 0,
3141 "blocked_plugins": 1,
3142 "candidates": [
3143 {
3144 "plugin_id": "legacy-plugin",
3145 "source_path": "/tmp/legacy-plugin.py",
3146 "source_kind": "embedded_source",
3147 "package_root": "/tmp",
3148 "package_manifest_path": null,
3149 "bridge_kind": "http_json",
3150 "adapter_family": "web-search",
3151 "status": "blocked_unsupported_bridge",
3152 "reason": "legacy payload",
3153 "bootstrap_hint": "skip"
3154 }
3155 ]
3156}
3157"#;
3158
3159 let plan: PluginActivationPlan =
3160 serde_json::from_str(raw).expect("legacy activation payload should deserialize");
3161
3162 assert_eq!(plan.setup_incomplete_plugins, 0);
3163 assert!(plan.candidates[0].missing_required_env_vars.is_empty());
3164 assert!(plan.candidates[0].missing_required_config_keys.is_empty());
3165 }
3166
3167 #[test]
3168 fn activation_plan_still_blocks_unsupported_bridge_before_setup_readiness() {
3169 let descriptor = descriptor(
3170 "js",
3171 BTreeMap::from([("bridge_kind".to_owned(), "mcp_server".to_owned())]),
3172 );
3173 let translator = PluginTranslator::new();
3174 let translation = translator.translate_scan_report(&PluginScanReport {
3175 scanned_files: 1,
3176 matched_plugins: 1,
3177 diagnostic_findings: Vec::new(),
3178 descriptors: vec![descriptor],
3179 });
3180
3181 let matrix = BridgeSupportMatrix {
3182 supported_bridges: BTreeSet::from([PluginBridgeKind::HttpJson]),
3183 supported_adapter_families: BTreeSet::new(),
3184 ..BridgeSupportMatrix::default()
3185 };
3186 let setup_readiness_context = PluginSetupReadinessContext {
3187 verified_env_vars: BTreeSet::from(["TAVILY_API_KEY".to_owned()]),
3188 verified_config_keys: BTreeSet::from(["tools.web_search.default_provider".to_owned()]),
3189 };
3190 let plan = translator.plan_activation(&translation, &matrix, &setup_readiness_context);
3191
3192 assert_eq!(plan.ready_plugins, 0);
3193 assert_eq!(plan.setup_incomplete_plugins, 0);
3194 assert_eq!(plan.blocked_plugins, 1);
3195 assert!(matches!(
3196 plan.candidates[0].status,
3197 PluginActivationStatus::BlockedUnsupportedBridge
3198 ));
3199 }
3200
3201 #[test]
3202 fn blocker_summary_excludes_setup_incomplete_candidates() {
3203 let plan = PluginActivationPlan {
3204 total_plugins: 2,
3205 ready_plugins: 0,
3206 setup_incomplete_plugins: 1,
3207 blocked_plugins: 1,
3208 candidates: vec![
3209 PluginActivationCandidate {
3210 plugin_id: "setup-plugin".to_owned(),
3211 source_path: "/tmp/setup-plugin.py".to_owned(),
3212 source_kind: PluginSourceKind::EmbeddedSource,
3213 package_root: "/tmp".to_owned(),
3214 package_manifest_path: None,
3215 trust_tier: PluginTrustTier::Unverified,
3216 compatibility_mode: PluginCompatibilityMode::Native,
3217 compatibility_shim: None,
3218 compatibility_shim_support: None,
3219 compatibility_shim_support_mismatch_reasons: Vec::new(),
3220 bridge_kind: PluginBridgeKind::HttpJson,
3221 adapter_family: "http-adapter".to_owned(),
3222 slot_claims: Vec::new(),
3223 diagnostic_findings: Vec::new(),
3224 status: PluginActivationStatus::SetupIncomplete,
3225 reason: "missing TAVILY_API_KEY".to_owned(),
3226 missing_required_env_vars: vec!["TAVILY_API_KEY".to_owned()],
3227 missing_required_config_keys: Vec::new(),
3228 bootstrap_hint: "export TAVILY_API_KEY".to_owned(),
3229 },
3230 PluginActivationCandidate {
3231 plugin_id: "blocked-plugin".to_owned(),
3232 source_path: "/tmp/blocked-plugin.py".to_owned(),
3233 source_kind: PluginSourceKind::EmbeddedSource,
3234 package_root: "/tmp".to_owned(),
3235 package_manifest_path: None,
3236 trust_tier: PluginTrustTier::Unverified,
3237 compatibility_mode: PluginCompatibilityMode::Native,
3238 compatibility_shim: None,
3239 compatibility_shim_support: None,
3240 compatibility_shim_support_mismatch_reasons: Vec::new(),
3241 bridge_kind: PluginBridgeKind::HttpJson,
3242 adapter_family: "http-adapter".to_owned(),
3243 slot_claims: Vec::new(),
3244 diagnostic_findings: Vec::new(),
3245 status: PluginActivationStatus::BlockedUnsupportedBridge,
3246 reason: "http_json bridge is disabled".to_owned(),
3247 missing_required_env_vars: Vec::new(),
3248 missing_required_config_keys: Vec::new(),
3249 bootstrap_hint: "enable http bridge".to_owned(),
3250 },
3251 ],
3252 };
3253
3254 let summary = plan.blocker_summary(4);
3255
3256 assert!(summary.contains("blocked-plugin"));
3257 assert!(!summary.contains("setup-plugin"));
3258 }
3259}