Skip to main content

loong_kernel/
bootstrap.rs

1use std::collections::BTreeSet;
2
3use serde::{Deserialize, Serialize};
4
5use crate::{
6    PluginCompatibilityMode, PluginCompatibilityShim, PluginTrustTier,
7    plugin_ir::{PluginActivationPlan, PluginActivationStatus, PluginBridgeKind},
8};
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11pub struct BootstrapPolicy {
12    pub allow_http_json_auto_apply: bool,
13    pub allow_process_stdio_auto_apply: bool,
14    pub allow_native_ffi_auto_apply: bool,
15    pub allow_wasm_component_auto_apply: bool,
16    pub allow_mcp_server_auto_apply: bool,
17    pub allow_acp_bridge_auto_apply: bool,
18    pub allow_acp_runtime_auto_apply: bool,
19    #[serde(default)]
20    pub block_unverified_high_risk_auto_apply: bool,
21    pub enforce_ready_execution: bool,
22    pub max_tasks: usize,
23}
24
25impl Default for BootstrapPolicy {
26    fn default() -> Self {
27        Self {
28            allow_http_json_auto_apply: true,
29            allow_process_stdio_auto_apply: false,
30            allow_native_ffi_auto_apply: false,
31            allow_wasm_component_auto_apply: false,
32            allow_mcp_server_auto_apply: false,
33            allow_acp_bridge_auto_apply: false,
34            allow_acp_runtime_auto_apply: false,
35            block_unverified_high_risk_auto_apply: false,
36            enforce_ready_execution: false,
37            max_tasks: 256,
38        }
39    }
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum BootstrapTaskStatus {
45    Applied,
46    DeferredUnsupportedAutoApply,
47    SkippedNotReady,
48    SkippedByPolicyLimit,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct BootstrapTask {
53    pub plugin_id: String,
54    pub source_path: String,
55    #[serde(default)]
56    pub trust_tier: PluginTrustTier,
57    #[serde(default)]
58    pub compatibility_mode: PluginCompatibilityMode,
59    #[serde(default)]
60    pub compatibility_shim: Option<PluginCompatibilityShim>,
61    pub bridge_kind: PluginBridgeKind,
62    pub adapter_family: String,
63    pub bootstrap_hint: String,
64    pub status: BootstrapTaskStatus,
65    pub reason: String,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
69pub struct BootstrapReport {
70    pub total_tasks: usize,
71    pub applied_tasks: usize,
72    pub deferred_tasks: usize,
73    pub skipped_tasks: usize,
74    pub blocked: bool,
75    pub block_reason: Option<String>,
76    pub applied_plugin_keys: BTreeSet<(String, String)>,
77    pub tasks: Vec<BootstrapTask>,
78}
79
80#[derive(Debug, Default)]
81pub struct PluginBootstrapExecutor;
82
83impl PluginBootstrapExecutor {
84    #[must_use]
85    pub fn new() -> Self {
86        Self
87    }
88
89    #[must_use]
90    pub fn execute(
91        &self,
92        plan: &PluginActivationPlan,
93        policy: &BootstrapPolicy,
94    ) -> BootstrapReport {
95        let mut report = BootstrapReport::default();
96        let mut ready_handled = 0_usize;
97
98        for candidate in &plan.candidates {
99            report.total_tasks = report.total_tasks.saturating_add(1);
100
101            if !matches!(candidate.status, PluginActivationStatus::Ready) {
102                report.skipped_tasks = report.skipped_tasks.saturating_add(1);
103                report.tasks.push(BootstrapTask {
104                    plugin_id: candidate.plugin_id.clone(),
105                    source_path: candidate.source_path.clone(),
106                    trust_tier: candidate.trust_tier,
107                    compatibility_mode: candidate.compatibility_mode,
108                    compatibility_shim: candidate.compatibility_shim.clone(),
109                    bridge_kind: candidate.bridge_kind,
110                    adapter_family: candidate.adapter_family.clone(),
111                    bootstrap_hint: candidate.bootstrap_hint.clone(),
112                    status: BootstrapTaskStatus::SkippedNotReady,
113                    reason: "activation status is not ready".to_owned(),
114                });
115                continue;
116            }
117
118            if ready_handled >= policy.max_tasks {
119                report.skipped_tasks = report.skipped_tasks.saturating_add(1);
120                report.tasks.push(BootstrapTask {
121                    plugin_id: candidate.plugin_id.clone(),
122                    source_path: candidate.source_path.clone(),
123                    trust_tier: candidate.trust_tier,
124                    compatibility_mode: candidate.compatibility_mode,
125                    compatibility_shim: candidate.compatibility_shim.clone(),
126                    bridge_kind: candidate.bridge_kind,
127                    adapter_family: candidate.adapter_family.clone(),
128                    bootstrap_hint: candidate.bootstrap_hint.clone(),
129                    status: BootstrapTaskStatus::SkippedByPolicyLimit,
130                    reason: format!("max bootstrap task limit reached: {}", policy.max_tasks),
131                });
132                continue;
133            }
134            ready_handled = ready_handled.saturating_add(1);
135
136            if policy.block_unverified_high_risk_auto_apply
137                && matches!(candidate.trust_tier, PluginTrustTier::Unverified)
138                && plugin_bridge_is_high_risk_auto_apply(candidate.bridge_kind)
139            {
140                report.deferred_tasks = report.deferred_tasks.saturating_add(1);
141                report.tasks.push(BootstrapTask {
142                    plugin_id: candidate.plugin_id.clone(),
143                    source_path: candidate.source_path.clone(),
144                    trust_tier: candidate.trust_tier,
145                    compatibility_mode: candidate.compatibility_mode,
146                    compatibility_shim: candidate.compatibility_shim.clone(),
147                    bridge_kind: candidate.bridge_kind,
148                    adapter_family: candidate.adapter_family.clone(),
149                    bootstrap_hint: candidate.bootstrap_hint.clone(),
150                    status: BootstrapTaskStatus::DeferredUnsupportedAutoApply,
151                    reason:
152                        "bridge is ready but auto-apply is blocked by bootstrap trust policy for unverified high-risk plugins"
153                            .to_owned(),
154                });
155                continue;
156            }
157
158            if bridge_auto_apply_allowed(candidate.bridge_kind, policy) {
159                report.applied_tasks = report.applied_tasks.saturating_add(1);
160                report
161                    .applied_plugin_keys
162                    .insert((candidate.source_path.clone(), candidate.plugin_id.clone()));
163                report.tasks.push(BootstrapTask {
164                    plugin_id: candidate.plugin_id.clone(),
165                    source_path: candidate.source_path.clone(),
166                    trust_tier: candidate.trust_tier,
167                    compatibility_mode: candidate.compatibility_mode,
168                    compatibility_shim: candidate.compatibility_shim.clone(),
169                    bridge_kind: candidate.bridge_kind,
170                    adapter_family: candidate.adapter_family.clone(),
171                    bootstrap_hint: candidate.bootstrap_hint.clone(),
172                    status: BootstrapTaskStatus::Applied,
173                    reason: "bridge is allowed for automatic bootstrap apply".to_owned(),
174                });
175            } else {
176                report.deferred_tasks = report.deferred_tasks.saturating_add(1);
177                report.tasks.push(BootstrapTask {
178                    plugin_id: candidate.plugin_id.clone(),
179                    source_path: candidate.source_path.clone(),
180                    trust_tier: candidate.trust_tier,
181                    compatibility_mode: candidate.compatibility_mode,
182                    compatibility_shim: candidate.compatibility_shim.clone(),
183                    bridge_kind: candidate.bridge_kind,
184                    adapter_family: candidate.adapter_family.clone(),
185                    bootstrap_hint: candidate.bootstrap_hint.clone(),
186                    status: BootstrapTaskStatus::DeferredUnsupportedAutoApply,
187                    reason: "bridge is ready but auto-apply is disabled by bootstrap policy"
188                        .to_owned(),
189                });
190            }
191        }
192
193        if policy.enforce_ready_execution && report.deferred_tasks > 0 {
194            report.blocked = true;
195            report.block_reason = Some(format!(
196                "bootstrap policy blocked {} ready plugin(s) that were not auto-applied",
197                report.deferred_tasks
198            ));
199        }
200
201        report
202    }
203}
204
205fn bridge_auto_apply_allowed(bridge: PluginBridgeKind, policy: &BootstrapPolicy) -> bool {
206    match bridge {
207        PluginBridgeKind::HttpJson => policy.allow_http_json_auto_apply,
208        PluginBridgeKind::ProcessStdio => policy.allow_process_stdio_auto_apply,
209        PluginBridgeKind::NativeFfi => policy.allow_native_ffi_auto_apply,
210        PluginBridgeKind::WasmComponent => policy.allow_wasm_component_auto_apply,
211        PluginBridgeKind::McpServer => policy.allow_mcp_server_auto_apply,
212        PluginBridgeKind::AcpBridge => policy.allow_acp_bridge_auto_apply,
213        PluginBridgeKind::AcpRuntime => policy.allow_acp_runtime_auto_apply,
214        PluginBridgeKind::Unknown => false,
215    }
216}
217
218#[must_use]
219pub fn plugin_bridge_is_high_risk_auto_apply(bridge: PluginBridgeKind) -> bool {
220    matches!(
221        bridge,
222        PluginBridgeKind::ProcessStdio
223            | PluginBridgeKind::NativeFfi
224            | PluginBridgeKind::WasmComponent
225            | PluginBridgeKind::McpServer
226            | PluginBridgeKind::AcpBridge
227            | PluginBridgeKind::AcpRuntime
228    )
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use crate::PluginSourceKind;
235    use crate::plugin_ir::{
236        PluginActivationCandidate, PluginActivationPlan, PluginActivationStatus, PluginBridgeKind,
237    };
238
239    fn sample_plan() -> PluginActivationPlan {
240        PluginActivationPlan {
241            total_plugins: 2,
242            ready_plugins: 2,
243            setup_incomplete_plugins: 0,
244            blocked_plugins: 0,
245            candidates: vec![
246                PluginActivationCandidate {
247                    plugin_id: "http-plugin".to_owned(),
248                    source_path: "/tmp/http.rs".to_owned(),
249                    source_kind: PluginSourceKind::EmbeddedSource,
250                    package_root: "/tmp".to_owned(),
251                    package_manifest_path: None,
252                    trust_tier: PluginTrustTier::Official,
253                    compatibility_mode: PluginCompatibilityMode::Native,
254                    compatibility_shim: None,
255                    compatibility_shim_support: None,
256                    compatibility_shim_support_mismatch_reasons: Vec::new(),
257                    bridge_kind: PluginBridgeKind::HttpJson,
258                    adapter_family: "http-adapter".to_owned(),
259                    slot_claims: Vec::new(),
260                    diagnostic_findings: Vec::new(),
261                    status: PluginActivationStatus::Ready,
262                    reason: "ready".to_owned(),
263                    missing_required_env_vars: Vec::new(),
264                    missing_required_config_keys: Vec::new(),
265                    bootstrap_hint: "register http".to_owned(),
266                },
267                PluginActivationCandidate {
268                    plugin_id: "ffi-plugin".to_owned(),
269                    source_path: "/tmp/ffi.rs".to_owned(),
270                    source_kind: PluginSourceKind::EmbeddedSource,
271                    package_root: "/tmp".to_owned(),
272                    package_manifest_path: None,
273                    trust_tier: PluginTrustTier::VerifiedCommunity,
274                    compatibility_mode: PluginCompatibilityMode::Native,
275                    compatibility_shim: None,
276                    compatibility_shim_support: None,
277                    compatibility_shim_support_mismatch_reasons: Vec::new(),
278                    bridge_kind: PluginBridgeKind::NativeFfi,
279                    adapter_family: "rust-ffi-adapter".to_owned(),
280                    slot_claims: Vec::new(),
281                    diagnostic_findings: Vec::new(),
282                    status: PluginActivationStatus::Ready,
283                    reason: "ready".to_owned(),
284                    missing_required_env_vars: Vec::new(),
285                    missing_required_config_keys: Vec::new(),
286                    bootstrap_hint: "load ffi".to_owned(),
287                },
288            ],
289        }
290    }
291
292    #[test]
293    fn default_policy_applies_http_and_defers_ffi() {
294        let executor = PluginBootstrapExecutor::new();
295        let report = executor.execute(&sample_plan(), &BootstrapPolicy::default());
296
297        assert_eq!(report.applied_tasks, 1);
298        assert_eq!(report.deferred_tasks, 1);
299        assert!(!report.blocked);
300        assert!(
301            report
302                .applied_plugin_keys
303                .contains(&("/tmp/http.rs".to_owned(), "http-plugin".to_owned()))
304        );
305        assert!(
306            !report
307                .applied_plugin_keys
308                .contains(&("/tmp/ffi.rs".to_owned(), "ffi-plugin".to_owned()))
309        );
310    }
311
312    #[test]
313    fn enforce_ready_execution_blocks_when_ready_tasks_are_deferred() {
314        let executor = PluginBootstrapExecutor::new();
315        let policy = BootstrapPolicy {
316            enforce_ready_execution: true,
317            ..BootstrapPolicy::default()
318        };
319
320        let report = executor.execute(&sample_plan(), &policy);
321        assert!(report.blocked);
322        assert!(report.block_reason.is_some());
323    }
324
325    #[test]
326    fn allow_all_bridges_applies_all_ready_tasks() {
327        let executor = PluginBootstrapExecutor::new();
328        let policy = BootstrapPolicy {
329            allow_native_ffi_auto_apply: true,
330            ..BootstrapPolicy::default()
331        };
332
333        let report = executor.execute(&sample_plan(), &policy);
334        assert_eq!(report.applied_tasks, 2);
335        assert_eq!(report.deferred_tasks, 0);
336        assert!(!report.blocked);
337    }
338
339    #[test]
340    fn bootstrap_tasks_preserve_compatibility_shim_context() {
341        let executor = PluginBootstrapExecutor::new();
342        let plan = PluginActivationPlan {
343            total_plugins: 1,
344            ready_plugins: 1,
345            setup_incomplete_plugins: 0,
346            blocked_plugins: 0,
347            candidates: vec![PluginActivationCandidate {
348                plugin_id: "openclaw-weather".to_owned(),
349                source_path: "/tmp/openclaw-weather/index.js".to_owned(),
350                source_kind: PluginSourceKind::EmbeddedSource,
351                package_root: "/tmp/openclaw-weather".to_owned(),
352                package_manifest_path: None,
353                trust_tier: PluginTrustTier::Unverified,
354                compatibility_mode: PluginCompatibilityMode::OpenClawModern,
355                compatibility_shim: Some(PluginCompatibilityShim {
356                    shim_id: "openclaw-modern-compat".to_owned(),
357                    family: "openclaw-modern-compat".to_owned(),
358                }),
359                compatibility_shim_support: None,
360                compatibility_shim_support_mismatch_reasons: Vec::new(),
361                bridge_kind: PluginBridgeKind::ProcessStdio,
362                adapter_family: "javascript-stdio-adapter".to_owned(),
363                slot_claims: Vec::new(),
364                diagnostic_findings: Vec::new(),
365                status: PluginActivationStatus::Ready,
366                reason: "ready".to_owned(),
367                missing_required_env_vars: Vec::new(),
368                missing_required_config_keys: Vec::new(),
369                bootstrap_hint:
370                    "enable compatibility shim `openclaw-modern-compat` (openclaw-modern-compat) and then spawn javascript worker".to_owned(),
371            }],
372        };
373        let policy = BootstrapPolicy {
374            allow_process_stdio_auto_apply: true,
375            ..BootstrapPolicy::default()
376        };
377
378        let report = executor.execute(&plan, &policy);
379
380        assert_eq!(report.tasks.len(), 1);
381        assert_eq!(
382            report.tasks[0].compatibility_mode,
383            PluginCompatibilityMode::OpenClawModern
384        );
385        assert_eq!(
386            report.tasks[0]
387                .compatibility_shim
388                .as_ref()
389                .map(|shim| shim.shim_id.as_str()),
390            Some("openclaw-modern-compat")
391        );
392    }
393
394    #[test]
395    fn acp_bridge_and_runtime_auto_apply_are_gated_independently() {
396        let executor = PluginBootstrapExecutor::new();
397        let plan = PluginActivationPlan {
398            total_plugins: 2,
399            ready_plugins: 2,
400            setup_incomplete_plugins: 0,
401            blocked_plugins: 0,
402            candidates: vec![
403                PluginActivationCandidate {
404                    plugin_id: "acp-bridge-plugin".to_owned(),
405                    source_path: "/tmp/acp-bridge.rs".to_owned(),
406                    source_kind: PluginSourceKind::EmbeddedSource,
407                    package_root: "/tmp".to_owned(),
408                    package_manifest_path: None,
409                    trust_tier: PluginTrustTier::VerifiedCommunity,
410                    compatibility_mode: PluginCompatibilityMode::Native,
411                    compatibility_shim: None,
412                    compatibility_shim_support: None,
413                    compatibility_shim_support_mismatch_reasons: Vec::new(),
414                    bridge_kind: PluginBridgeKind::AcpBridge,
415                    adapter_family: "acp-bridge-adapter".to_owned(),
416                    slot_claims: Vec::new(),
417                    diagnostic_findings: Vec::new(),
418                    status: PluginActivationStatus::Ready,
419                    reason: "ready".to_owned(),
420                    missing_required_env_vars: Vec::new(),
421                    missing_required_config_keys: Vec::new(),
422                    bootstrap_hint: "register acp bridge".to_owned(),
423                },
424                PluginActivationCandidate {
425                    plugin_id: "acpx-runtime-plugin".to_owned(),
426                    source_path: "/tmp/acpx-runtime.rs".to_owned(),
427                    source_kind: PluginSourceKind::EmbeddedSource,
428                    package_root: "/tmp".to_owned(),
429                    package_manifest_path: None,
430                    trust_tier: PluginTrustTier::VerifiedCommunity,
431                    compatibility_mode: PluginCompatibilityMode::Native,
432                    compatibility_shim: None,
433                    compatibility_shim_support: None,
434                    compatibility_shim_support_mismatch_reasons: Vec::new(),
435                    bridge_kind: PluginBridgeKind::AcpRuntime,
436                    adapter_family: "acp-runtime-adapter".to_owned(),
437                    slot_claims: Vec::new(),
438                    diagnostic_findings: Vec::new(),
439                    status: PluginActivationStatus::Ready,
440                    reason: "ready".to_owned(),
441                    missing_required_env_vars: Vec::new(),
442                    missing_required_config_keys: Vec::new(),
443                    bootstrap_hint: "register acp runtime".to_owned(),
444                },
445            ],
446        };
447
448        let bridge_only = BootstrapPolicy {
449            allow_acp_bridge_auto_apply: true,
450            allow_acp_runtime_auto_apply: false,
451            ..BootstrapPolicy::default()
452        };
453        let bridge_report = executor.execute(&plan, &bridge_only);
454        assert!(bridge_report.applied_plugin_keys.contains(&(
455            "/tmp/acp-bridge.rs".to_owned(),
456            "acp-bridge-plugin".to_owned()
457        )));
458        assert!(!bridge_report.applied_plugin_keys.contains(&(
459            "/tmp/acpx-runtime.rs".to_owned(),
460            "acpx-runtime-plugin".to_owned()
461        )));
462
463        let runtime_only = BootstrapPolicy {
464            allow_acp_bridge_auto_apply: false,
465            allow_acp_runtime_auto_apply: true,
466            ..BootstrapPolicy::default()
467        };
468        let runtime_report = executor.execute(&plan, &runtime_only);
469        assert!(!runtime_report.applied_plugin_keys.contains(&(
470            "/tmp/acp-bridge.rs".to_owned(),
471            "acp-bridge-plugin".to_owned()
472        )));
473        assert!(runtime_report.applied_plugin_keys.contains(&(
474            "/tmp/acpx-runtime.rs".to_owned(),
475            "acpx-runtime-plugin".to_owned()
476        )));
477    }
478
479    #[test]
480    fn trust_policy_can_block_unverified_high_risk_auto_apply() {
481        let executor = PluginBootstrapExecutor::new();
482        let plan = PluginActivationPlan {
483            total_plugins: 1,
484            ready_plugins: 1,
485            setup_incomplete_plugins: 0,
486            blocked_plugins: 0,
487            candidates: vec![PluginActivationCandidate {
488                plugin_id: "ffi-plugin".to_owned(),
489                source_path: "/tmp/ffi.rs".to_owned(),
490                source_kind: PluginSourceKind::EmbeddedSource,
491                package_root: "/tmp".to_owned(),
492                package_manifest_path: None,
493                trust_tier: PluginTrustTier::Unverified,
494                compatibility_mode: PluginCompatibilityMode::Native,
495                compatibility_shim: None,
496                compatibility_shim_support: None,
497                compatibility_shim_support_mismatch_reasons: Vec::new(),
498                bridge_kind: PluginBridgeKind::NativeFfi,
499                adapter_family: "rust-ffi-adapter".to_owned(),
500                slot_claims: Vec::new(),
501                diagnostic_findings: Vec::new(),
502                status: PluginActivationStatus::Ready,
503                reason: "ready".to_owned(),
504                missing_required_env_vars: Vec::new(),
505                missing_required_config_keys: Vec::new(),
506                bootstrap_hint: "load ffi".to_owned(),
507            }],
508        };
509        let policy = BootstrapPolicy {
510            allow_native_ffi_auto_apply: true,
511            block_unverified_high_risk_auto_apply: true,
512            ..BootstrapPolicy::default()
513        };
514
515        let report = executor.execute(&plan, &policy);
516
517        assert_eq!(report.applied_tasks, 0);
518        assert_eq!(report.deferred_tasks, 1);
519        assert_eq!(report.tasks[0].trust_tier, PluginTrustTier::Unverified);
520        assert!(
521            report.tasks[0]
522                .reason
523                .contains("bootstrap trust policy for unverified high-risk plugins")
524        );
525    }
526
527    #[test]
528    fn bootstrap_task_deserializes_legacy_payload_without_compatibility_mode() {
529        let raw = r#"
530{
531  "plugin_id": "legacy-plugin",
532  "source_path": "/tmp/legacy-plugin.py",
533  "bridge_kind": "http_json",
534  "adapter_family": "http-adapter",
535  "bootstrap_hint": "register http adapter",
536  "status": "applied",
537  "reason": "legacy payload"
538}
539"#;
540
541        let task: BootstrapTask =
542            serde_json::from_str(raw).expect("legacy bootstrap task should deserialize");
543
544        assert_eq!(task.compatibility_mode, PluginCompatibilityMode::Native);
545    }
546}