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}