返回 CodeWhale
tests.rs
根目录 / crates / tui / src / plugins / tests.rs
1 use std::fs;
2 use std::path::{Path, PathBuf};
3
4 use super::activation::PluginActivationCapability;
5 use super::discovery::{DiscoveryConfig, discover_with_config};
6 use super::managed_policy::{
7 MANAGED_POLICY_FILE_NAME, MANAGED_POLICY_PATH_ENV, MANAGED_POLICY_SCHEMA_VERSION,
8 ManagedPluginPolicy,
9 };
10 use super::manifest::{PluginCompatibility, capability_hash_v1, capability_hash_v2};
11 use super::types::{PluginDiagnosticLevel, PluginTrustStatus};
12
13 fn config(root: &Path) -> DiscoveryConfig {
14 DiscoveryConfig {
15 workspace: root.join("project"),
16 user_plugins_dir: root.join("user"),
17 workspace_plugins_dir: root.join("workspace"),
18 builtin_plugin_dirs: Vec::new(),
19 state_path: root.join("state/plugin-state.json"),
20 }
21 }
22
23 fn write_plugin(config: &DiscoveryConfig, extra: &str) -> PathBuf {
24 write_named_plugin(config, "demo", extra)
25 }
26
27 fn write_named_plugin(config: &DiscoveryConfig, name: &str, extra: &str) -> PathBuf {
28 let plugin = config.user_plugins_dir.join(name);
29 fs::create_dir_all(&plugin).unwrap();
30 fs::write(
31 plugin.join("plugin.toml"),
32 format!("schema_version = 1\n[plugin]\nname = {name:?}\nversion = \"1.0.0\"\n{extra}"),
33 )
34 .unwrap();
35 plugin
36 }
37
38 #[test]
39 fn on_disk_catalog_change_nudges_reload_once_until_rediscover() {
40 let tmp = tempfile::tempdir().unwrap();
41 let config = config(tmp.path());
42 write_plugin(&config, "");
43
44 let registry = discover_with_config(&config);
45 assert!(
46 !registry.on_disk_catalog_changed(),
47 "fresh discovery must match the on-disk stamp"
48 );
49
50 let mut last_nudged = None;
51 assert!(
52 crate::plugins::plugin_reload_nudge(&registry, &mut last_nudged).is_none(),
53 "unchanged catalog must not nudge"
54 );
55
56 write_named_plugin(&config, "extra", "");
57 assert!(
58 registry.on_disk_catalog_changed(),
59 "a new bundle directory is a catalog change"
60 );
61 assert_eq!(
62 crate::plugins::plugin_reload_nudge(&registry, &mut last_nudged),
63 Some(crate::plugins::PLUGIN_RELOAD_NUDGE)
64 );
65 assert!(
66 crate::plugins::plugin_reload_nudge(&registry, &mut last_nudged).is_none(),
67 "the same unseen catalog must nudge only once"
68 );
69
70 let reloaded = registry.rediscover_for_workspace(&config.workspace);
71 assert!(!reloaded.on_disk_catalog_changed());
72 assert!(crate::plugins::plugin_reload_nudge(&reloaded, &mut last_nudged).is_none());
73 }
74
75 #[test]
76 fn trust_and_enablement_are_separate_atomic_state_transitions() {
77 let tmp = tempfile::tempdir().unwrap();
78 let config = config(tmp.path());
79 write_plugin(&config, "");
80
81 let mut registry = discover_with_config(&config);
82 assert!(registry.enable("demo").is_err());
83 assert!(!config.state_path.exists());
84
85 registry.trust("demo").unwrap();
86 assert!(registry.get("demo").unwrap().trusted());
87 assert!(!registry.get("demo").unwrap().enabled);
88 registry.enable("demo").unwrap();
89 assert!(registry.is_active("demo"));
90 registry.revoke_trust("demo").unwrap();
91 assert!(registry.get("demo").unwrap().enabled);
92 registry.trust("demo").unwrap();
93 assert!(registry.get("demo").unwrap().trusted());
94 assert!(
95 !registry.get("demo").unwrap().enabled,
96 "trust must never reuse an old enablement bit"
97 );
98 assert!(!registry.is_active("demo"));
99 registry.enable("demo").unwrap();
100 assert!(registry.is_active("demo"));
101
102 let raw = fs::read_to_string(&config.state_path).unwrap();
103 let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
104 assert_eq!(parsed["schema_version"], 1);
105 let receipt = parsed["plugins"]
106 .as_object()
107 .and_then(|plugins| plugins.values().next())
108 .and_then(|plugin| plugin.get("trust"))
109 .expect("trust receipt");
110 assert!(receipt["content_hash"].as_str().is_some());
111 assert!(receipt["capability_hash"].as_str().is_some());
112 assert_eq!(receipt["reviewed_capabilities"]["skills"], 0);
113 assert!(receipt["reviewed_at"].as_str().is_some());
114 let history = parsed["plugins"]
115 .as_object()
116 .and_then(|plugins| plugins.values().next())
117 .and_then(|plugin| plugin["review_history"].as_array())
118 .expect("review history");
119 assert_eq!(history.len(), 2);
120 assert_eq!(history[1]["content_hash"], receipt["content_hash"]);
121 #[cfg(unix)]
122 {
123 use std::os::unix::fs::PermissionsExt;
124 assert_eq!(
125 fs::metadata(config.state_path.parent().unwrap())
126 .unwrap()
127 .permissions()
128 .mode()
129 & 0o777,
130 0o700
131 );
132 assert_eq!(
133 fs::metadata(&config.state_path)
134 .unwrap()
135 .permissions()
136 .mode()
137 & 0o777,
138 0o600
139 );
140 }
141 let entries = fs::read_dir(config.state_path.parent().unwrap())
142 .unwrap()
143 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
144 .collect::<Vec<_>>();
145 assert!(entries.iter().any(|name| name == "plugin-state.json"));
146 assert!(entries.iter().any(|name| name == "plugin-state.json.lock"));
147 assert!(entries.iter().any(|name| name == ".runtime"));
148 assert!(
149 entries.iter().all(|name| !name.contains(".tmp")),
150 "atomic persistence must not strand temp files: {entries:?}"
151 );
152 }
153
154 #[test]
155 fn native_review_mutation_finishes_then_rechecks_current_default_receipt() {
156 let _policy = super::activation::TestPolicyGuard::extension_host(true);
157 let tmp = tempfile::tempdir().unwrap();
158 let config = config(tmp.path());
159 let plugin = config.user_plugins_dir.join("native-preset");
160 fs::create_dir_all(&plugin).unwrap();
161 fs::write(
162 plugin.join("plugin.json"),
163 r#"{"$schema":"https://agent-plugins.org/schemas/plugin.json","name":"native-preset","version":"1.0.0","extensions":{"net.codewhale":{"native":{"paths":["index.mjs"]}}}}"#,
164 )
165 .unwrap();
166 fs::write(
167 plugin.join("index.mjs"),
168 "// codewhale-native-preset-v1 {\"id\":\"reviewer\",\"trust\":\"user\",\"is_default\":true}\nexport function apply() {}\n",
169 )
170 .unwrap();
171 let mut registry = discover_with_config(&config);
172 registry.trust("native-preset").unwrap();
173 registry.enable("native-preset").unwrap();
174 let selected = registry.selected_native_entries().to_vec();
175 assert_eq!(selected.len(), 1);
176 let fresh = discover_with_config(&config);
177 assert_eq!(fresh.selected_native_entries(), selected);
178 assert!(fresh.with_native_preset(selected[0].clone()).is_ok());
179
180 registry.disable("native-preset").unwrap();
181 assert!(registry.with_native_preset(selected[0].clone()).is_err());
182 assert!(!discover_with_config(&config).is_active("native-preset"));
183 registry.enable("native-preset").unwrap();
184 assert!(registry.with_native_preset(selected[0].clone()).is_ok());
185 registry.revoke_trust("native-preset").unwrap();
186 assert!(registry.with_native_preset(selected[0].clone()).is_err());
187 assert!(!discover_with_config(&config).is_active("native-preset"));
188 }
189
190 #[test]
191 fn declarative_runtime_sources_survive_restart_only_from_the_staged_snapshot() {
192 let fixture = super::test_fixture::DeclarativePluginFixture::new();
193 let plugin = fixture.registry.get("runtime-demo").expect("plugin");
194 let staged_root = plugin.staged_root.as_deref().expect("staged root");
195 for capability in [
196 PluginActivationCapability::Commands,
197 PluginActivationCapability::Agents,
198 PluginActivationCapability::Hooks,
199 ] {
200 let (sources, errors) =
201 super::runtime::active_component_sources(&fixture.registry, capability);
202 assert!(errors.is_empty(), "{capability:?}: {errors:?}");
203 assert_eq!(sources.len(), 1, "{capability:?}");
204 assert!(sources[0].path.starts_with(staged_root), "{capability:?}");
205 assert!(
206 !sources[0].path.starts_with(&plugin.canonical_root),
207 "{capability:?} must never execute from mutable source"
208 );
209 }
210 }
211
212 #[test]
213 fn content_change_invalidates_trust_without_changing_capabilities() {
214 let tmp = tempfile::tempdir().unwrap();
215 let config = config(tmp.path());
216 let plugin = write_plugin(&config, "\n[skills]\npath = \"skills\"\n");
217 fs::create_dir_all(plugin.join("skills/demo")).unwrap();
218 fs::write(
219 plugin.join("skills/demo/SKILL.md"),
220 "---\nname: demo\ndescription: first\n---\nbody\n",
221 )
222 .unwrap();
223
224 let mut first = discover_with_config(&config);
225 first.trust("demo").unwrap();
226 first.enable("demo").unwrap();
227 assert!(first.is_active("demo"));
228
229 fs::write(
230 plugin.join("skills/demo/SKILL.md"),
231 "---\nname: demo\ndescription: changed\n---\nbody\n",
232 )
233 .unwrap();
234 let second = discover_with_config(&config);
235 let plugin = second.get("demo").unwrap();
236 assert!(plugin.enabled, "enablement is independent from trust");
237 assert_eq!(plugin.trust_status, PluginTrustStatus::ContentChanged);
238 assert!(!plugin.active());
239 }
240
241 #[test]
242 fn aba_source_skill_body_is_replaced_by_the_staged_snapshot_before_activation() {
243 let tmp = tempfile::tempdir().unwrap();
244 let config = config(tmp.path());
245 let plugin = write_plugin(&config, "\n[skills]\npath = \"skills\"\n");
246 let skill_path = plugin.join("skills/demo/SKILL.md");
247 fs::create_dir_all(skill_path.parent().unwrap()).unwrap();
248 fs::write(
249 &skill_path,
250 "---\nname: demo\ndescription: stable\n---\nbody A\n",
251 )
252 .unwrap();
253
254 // Capture authority A, parse a transient B body, then restore source A.
255 // This deterministically models the old discovery A -> B -> A race.
256 let mut authority_a = discover_with_config(&config);
257 fs::write(
258 &skill_path,
259 "---\nname: demo\ndescription: transient\n---\nbody B\n",
260 )
261 .unwrap();
262 let transient_b = discover_with_config(&config)
263 .get("demo")
264 .unwrap()
265 .skill_snapshots
266 .clone();
267 fs::write(
268 &skill_path,
269 "---\nname: demo\ndescription: stable\n---\nbody A\n",
270 )
271 .unwrap();
272 authority_a.replace_skill_snapshots_for_test("demo", transient_b);
273 assert!(
274 authority_a.get("demo").unwrap().skill_snapshots[0]
275 .body
276 .contains("body B")
277 );
278
279 authority_a.trust("demo").unwrap();
280 authority_a.enable("demo").unwrap();
281 let active = authority_a.get("demo").unwrap();
282 assert!(active.active());
283 assert!(active.skill_snapshots[0].body.contains("body A"));
284 assert!(!active.skill_snapshots[0].body.contains("body B"));
285 let staged_bytes = fs::read(&active.skill_snapshots[0].path).unwrap();
286 let mut digest = sha2::Sha256::new();
287 use sha2::Digest as _;
288 digest.update(b"codewhale-plugin-file-bytes-v1\0");
289 digest.update(staged_bytes);
290 let staged_hash = digest
291 .finalize()
292 .iter()
293 .map(|byte| format!("{byte:02x}"))
294 .collect::<String>();
295 assert_eq!(active.skill_snapshots[0].source_hash, staged_hash);
296 assert!(
297 active.skill_snapshots[0]
298 .path
299 .starts_with(active.staged_root.as_ref().unwrap()),
300 "active Skill paths must point into the Codewhale-owned staged tree"
301 );
302 }
303
304 #[test]
305 fn capability_escalation_invalidates_trust_and_stays_inactive() {
306 let tmp = tempfile::tempdir().unwrap();
307 let config = config(tmp.path());
308 let plugin = write_plugin(&config, "");
309
310 let mut first = discover_with_config(&config);
311 first.trust("demo").unwrap();
312 first.enable("demo").unwrap();
313
314 fs::create_dir_all(plugin.join("hooks")).unwrap();
315 fs::write(
316 plugin.join("plugin.toml"),
317 "schema_version = 1\n[plugin]\nname = \"demo\"\nversion = \"1.0.0\"\n[hooks]\npath = \"hooks\"\n",
318 )
319 .unwrap();
320 let second = discover_with_config(&config);
321 let plugin = second.get("demo").unwrap();
322 assert_eq!(plugin.trust_status, PluginTrustStatus::CapabilitiesChanged);
323 assert!(plugin.enabled);
324 assert!(!plugin.active());
325 }
326
327 #[test]
328 fn malformed_state_is_fail_closed_and_never_overwritten() {
329 let tmp = tempfile::tempdir().unwrap();
330 let config = config(tmp.path());
331 write_plugin(&config, "");
332 fs::create_dir_all(config.state_path.parent().unwrap()).unwrap();
333 #[cfg(unix)]
334 {
335 use std::os::unix::fs::PermissionsExt as _;
336 fs::set_permissions(
337 config.state_path.parent().unwrap(),
338 fs::Permissions::from_mode(0o700),
339 )
340 .unwrap();
341 }
342 fs::write(&config.state_path, "{ malformed").unwrap();
343
344 let mut registry = discover_with_config(&config);
345 assert!(registry.state_error().is_some());
346 assert!(!registry.get("demo").unwrap().enabled);
347 assert!(!registry.get("demo").unwrap().trusted());
348 assert!(registry.trust("demo").is_err());
349 assert_eq!(
350 fs::read_to_string(&config.state_path).unwrap(),
351 "{ malformed"
352 );
353 }
354
355 #[test]
356 fn atomic_write_failure_does_not_mutate_live_enablement() {
357 let tmp = tempfile::tempdir().unwrap();
358 let config = config(tmp.path());
359 write_plugin(&config, "");
360
361 let mut registry = discover_with_config(&config);
362 registry.trust("demo").unwrap();
363 fs::remove_file(&config.state_path).unwrap();
364 fs::create_dir(&config.state_path).unwrap();
365
366 assert!(registry.enable("demo").is_err());
367 let plugin = registry.get("demo").unwrap();
368 assert!(plugin.trusted());
369 assert!(!plugin.enabled);
370 assert!(!plugin.active());
371 }
372
373 #[test]
374 fn revoking_trust_does_not_rewrite_enablement() {
375 let tmp = tempfile::tempdir().unwrap();
376 let config = config(tmp.path());
377 write_plugin(&config, "");
378
379 let mut registry = discover_with_config(&config);
380 registry.trust("demo").unwrap();
381 registry.enable("demo").unwrap();
382 registry.revoke_trust("demo").unwrap();
383
384 let plugin = registry.get("demo").unwrap();
385 assert!(plugin.enabled);
386 assert!(!plugin.trusted());
387 assert!(!plugin.active());
388 }
389
390 fn write_mixed_bundle(config: &DiscoveryConfig) -> PathBuf {
391 let plugin = write_plugin(
392 config,
393 "\n[skills]\npath = \"skills\"\n[commands]\npath = \"commands\"\n[hooks]\npath = \"hooks\"\n[lsp]\npath = \"lsp\"\n",
394 );
395 fs::create_dir_all(plugin.join("skills/demo")).unwrap();
396 fs::write(
397 plugin.join("skills/demo/SKILL.md"),
398 "---\nname: demo\ndescription: first\n---\nbody\n",
399 )
400 .unwrap();
401 fs::create_dir_all(plugin.join("commands")).unwrap();
402 fs::create_dir_all(plugin.join("hooks")).unwrap();
403 fs::create_dir_all(plugin.join("lsp")).unwrap();
404 plugin
405 }
406
407 #[test]
408 fn mixed_supported_and_unsupported_components_activate_only_supported_surfaces() {
409 let tmp = tempfile::tempdir().unwrap();
410 let config = config(tmp.path());
411 write_mixed_bundle(&config);
412
413 let mut registry = discover_with_config(&config);
414 let plugin = registry.get("demo").unwrap();
415 assert_eq!(plugin.compatibility(), PluginCompatibility::Partial);
416 assert_eq!(
417 plugin.inventory.supported_labels(),
418 vec!["skills", "commands", "hooks"]
419 );
420 assert_eq!(plugin.inventory.unsupported_labels(), vec!["lsp"]);
421 assert!(
422 plugin.diagnostics.iter().any(|diagnostic| {
423 diagnostic.level == PluginDiagnosticLevel::Warning
424 && diagnostic.code == "component-inactive"
425 && diagnostic.message.contains("lsp")
426 }),
427 "inactive components must stay visible in diagnostics: {:?}",
428 plugin.diagnostics
429 );
430
431 registry.trust("demo").unwrap();
432 registry.enable("demo").unwrap();
433 let plugin = registry.get("demo").unwrap();
434 assert!(plugin.active());
435 assert_eq!(plugin.state_label(), "active");
436 assert_eq!(plugin.compatibility(), PluginCompatibility::Partial);
437 assert_eq!(plugin.inventory.commands, 1);
438 assert_eq!(plugin.inventory.hooks, 1);
439 assert_eq!(plugin.skill_snapshots.len(), 1);
440 assert_eq!(plugin.skill_snapshots[0].name, "demo");
441 assert!(plugin.component_active(PluginActivationCapability::Skills));
442 assert!(plugin.component_active(PluginActivationCapability::Commands));
443 assert!(plugin.component_active(PluginActivationCapability::Hooks));
444 assert!(!plugin.component_active(PluginActivationCapability::Lsp));
445
446 let skills = crate::skills::discover_from_directories_with_plugins(
447 Vec::<PathBuf>::new(),
448 Some(&registry),
449 );
450 assert_eq!(skills.get("demo:demo").unwrap().body.trim(), "body");
451 }
452
453 #[test]
454 fn all_unsupported_bundles_can_be_reviewed_but_not_enabled() {
455 let tmp = tempfile::tempdir().unwrap();
456 let config = config(tmp.path());
457 let plugin = write_plugin(&config, "\n[lsp]\npath = \"lsp\"\n");
458 fs::create_dir_all(plugin.join("lsp")).unwrap();
459
460 let mut registry = discover_with_config(&config);
461 let plugin = registry.get("demo").unwrap();
462 assert_eq!(plugin.compatibility(), PluginCompatibility::Unsupported);
463 assert!(!plugin.inventory.has_supported_components());
464 registry.trust("demo").unwrap();
465 let error = registry.enable("demo").unwrap_err();
466 assert!(
467 error.contains("no supported declarative components"),
468 "all-unsupported enable must name the missing supported surfaces: {error}"
469 );
470 assert!(error.contains("lsp"), "{error}");
471 assert!(!registry.is_active("demo"));
472 let plugin = registry.get("demo").unwrap();
473 assert!(plugin.trusted());
474 assert!(!plugin.enabled);
475 assert_eq!(plugin.state_label(), "disabled");
476 assert_eq!(plugin.compatibility(), PluginCompatibility::Unsupported);
477 }
478
479 #[test]
480 fn fatal_manifest_errors_fail_closed_and_do_not_activate_mixed_looking_bundles() {
481 let tmp = tempfile::tempdir().unwrap();
482 let config = config(tmp.path());
483 let plugin = write_plugin(
484 &config,
485 "\n[skills]\npath = \"skills\"\n[commands]\npath = \"commands\"\nunknown_field = true\n",
486 );
487 fs::create_dir_all(plugin.join("skills/demo")).unwrap();
488 fs::write(
489 plugin.join("skills/demo/SKILL.md"),
490 "---\nname: demo\ndescription: first\n---\nbody\n",
491 )
492 .unwrap();
493 fs::create_dir_all(plugin.join("commands")).unwrap();
494
495 let mut registry = discover_with_config(&config);
496 assert!(registry.get("demo").is_none());
497 assert!(
498 registry.diagnostics().iter().any(|diagnostic| {
499 diagnostic.level == PluginDiagnosticLevel::Error
500 && diagnostic.code == "manifest-invalid"
501 }),
502 "fatal parse errors must remain registry-level errors: {:?}",
503 registry.diagnostics()
504 );
505 assert!(registry.trust("demo").is_err());
506 assert!(registry.enable("demo").is_err());
507 assert!(!registry.is_active("demo"));
508 }
509
510 #[test]
511 fn mixed_bundle_revocation_and_trust_changes_deactivate_supported_surfaces() {
512 let tmp = tempfile::tempdir().unwrap();
513 let config = config(tmp.path());
514 let plugin = write_mixed_bundle(&config);
515
516 let mut registry = discover_with_config(&config);
517 registry.trust("demo").unwrap();
518 registry.enable("demo").unwrap();
519 assert!(registry.is_active("demo"));
520
521 registry.revoke_trust("demo").unwrap();
522 let revoked = registry.get("demo").unwrap();
523 assert!(revoked.enabled);
524 assert!(!revoked.trusted());
525 assert!(!revoked.active());
526 assert_eq!(revoked.compatibility(), PluginCompatibility::Partial);
527 let skills = crate::skills::discover_from_directories_with_plugins(
528 Vec::<PathBuf>::new(),
529 Some(&registry),
530 );
531 assert!(
532 skills.get("demo:demo").is_none(),
533 "revoking trust must drop the supported Skill adapter"
534 );
535
536 registry.trust("demo").unwrap();
537 assert!(
538 !registry.is_active("demo"),
539 "re-trust must not reuse the previous enablement bit"
540 );
541 registry.enable("demo").unwrap();
542 assert!(registry.is_active("demo"));
543
544 fs::write(
545 plugin.join("skills/demo/SKILL.md"),
546 "---\nname: demo\ndescription: changed\n---\nbody two\n",
547 )
548 .unwrap();
549 let changed = discover_with_config(&config);
550 let plugin = changed.get("demo").unwrap();
551 assert_eq!(plugin.trust_status, PluginTrustStatus::ContentChanged);
552 assert!(plugin.enabled);
553 assert!(!plugin.active());
554 let skills = crate::skills::discover_from_directories_with_plugins(
555 Vec::<PathBuf>::new(),
556 Some(&changed),
557 );
558 assert!(skills.get("demo:demo").is_none());
559 }
560
561 #[test]
562 fn legacy_trust_receipts_fail_closed_as_needs_review_under_v3() {
563 let tmp = tempfile::tempdir().unwrap();
564 let config = config(tmp.path());
565 let plugin = write_plugin(&config, "\n[skills]\npath = \"skills\"\n");
566 fs::create_dir_all(plugin.join("skills/demo")).unwrap();
567 fs::write(
568 plugin.join("skills/demo/SKILL.md"),
569 "---\nname: demo\ndescription: first\n---\nbody\n",
570 )
571 .unwrap();
572
573 let mut first = discover_with_config(&config);
574 first.trust("demo").unwrap();
575 first.enable("demo").unwrap();
576 assert!(first.is_active("demo"));
577 let plugin = first.get("demo").unwrap();
578 let legacy_hashes = [
579 ("v1", capability_hash_v1(&plugin.inventory)),
580 ("v2", capability_hash_v2(&plugin.inventory)),
581 ];
582 let v3_hash = plugin.capability_hash.clone();
583 assert!(
584 legacy_hashes.iter().all(|(_, hash)| hash != &v3_hash),
585 "v3 receipts must not collide with either historical domain"
586 );
587 let content_hash = plugin.content_hash.clone();
588
589 let raw = fs::read_to_string(&config.state_path).unwrap();
590 for (version, legacy_hash) in legacy_hashes {
591 let mut parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
592 replace_capability_hashes(&mut parsed, &v3_hash, &legacy_hash);
593 fs::write(
594 &config.state_path,
595 serde_json::to_string_pretty(&parsed).unwrap(),
596 )
597 .unwrap();
598
599 let restarted = discover_with_config(&config);
600 let plugin = restarted.get("demo").unwrap();
601 assert_eq!(plugin.content_hash, content_hash);
602 assert_eq!(plugin.capability_hash, v3_hash);
603 assert_eq!(plugin.trust_status, PluginTrustStatus::CapabilitiesChanged);
604 assert!(plugin.enabled);
605 assert!(!plugin.trusted());
606 assert!(!plugin.active());
607 assert!(restarted.authority_for("demo").is_some());
608 let skills = crate::skills::discover_from_directories_with_plugins(
609 Vec::<PathBuf>::new(),
610 Some(&restarted),
611 );
612 assert!(
613 skills.get("demo:demo").is_none(),
614 "a {version} receipt must not activate Skills after the v3 policy binding"
615 );
616 }
617 }
618
619 fn replace_capability_hashes(value: &mut serde_json::Value, from: &str, to: &str) {
620 match value {
621 serde_json::Value::Object(map) => {
622 for (key, child) in map.iter_mut() {
623 if key == "capability_hash" && child.as_str() == Some(from) {
624 *child = serde_json::Value::String(to.to_string());
625 } else {
626 replace_capability_hashes(child, from, to);
627 }
628 }
629 }
630 serde_json::Value::Array(items) => {
631 for item in items {
632 replace_capability_hashes(item, from, to);
633 }
634 }
635 _ => {}
636 }
637 }
638
639 #[test]
640 fn stale_concurrent_registries_do_not_lose_updates() {
641 let tmp = tempfile::tempdir().unwrap();
642 let config = config(tmp.path());
643 write_named_plugin(&config, "alpha", "");
644 write_named_plugin(&config, "beta", "");
645
646 let left = discover_with_config(&config);
647 let right = discover_with_config(&config);
648 let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
649 let left_barrier = std::sync::Arc::clone(&barrier);
650 let left = std::thread::spawn(move || {
651 let mut registry = left;
652 left_barrier.wait();
653 registry.trust("alpha").unwrap();
654 registry.enable("alpha").unwrap();
655 });
656 let right = std::thread::spawn(move || {
657 let mut registry = right;
658 barrier.wait();
659 registry.trust("beta").unwrap();
660 registry.enable("beta").unwrap();
661 });
662 left.join().unwrap();
663 right.join().unwrap();
664
665 let fresh = discover_with_config(&config);
666 assert!(fresh.is_active("alpha"));
667 assert!(fresh.is_active("beta"));
668 }
669
670 #[test]
671 fn stale_enable_cannot_resurrect_revoked_trust() {
672 let tmp = tempfile::tempdir().unwrap();
673 let config = config(tmp.path());
674 write_plugin(&config, "");
675 let mut initial = discover_with_config(&config);
676 initial.trust("demo").unwrap();
677 initial.enable("demo").unwrap();
678
679 let mut stale = discover_with_config(&config);
680 let authority = stale.authority_for("demo").unwrap();
681 let mut revoker = discover_with_config(&config);
682 revoker.revoke_trust("demo").unwrap();
683 assert!(super::registry::verify_plugin_state_authority(&authority).is_err());
684
685 stale.enable("demo").unwrap();
686 let fresh = discover_with_config(&config);
687 assert!(fresh.get("demo").unwrap().enabled);
688 assert!(!fresh.get("demo").unwrap().trusted());
689 assert!(!fresh.is_active("demo"));
690 }
691
692 #[cfg(unix)]
693 #[test]
694 fn staging_is_owner_only_and_uses_the_reviewed_executable_shape() {
695 use std::os::unix::fs::PermissionsExt;
696
697 let tmp = tempfile::tempdir().unwrap();
698 let config = config(tmp.path());
699 let plugin = write_plugin(&config, "");
700 let executable = plugin.join("server.sh");
701 fs::write(&executable, "#!/bin/sh\nexit 0\n").unwrap();
702 fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap();
703
704 let mut registry = discover_with_config(&config);
705 registry.trust("demo").unwrap();
706 registry.enable("demo").unwrap();
707 let staged = registry.get("demo").unwrap().staged_root.as_ref().unwrap();
708 assert_ne!(staged, &plugin);
709 let state_parent = config.state_path.parent().unwrap().canonicalize().unwrap();
710 let relative_stage = staged.strip_prefix(state_parent).unwrap();
711 assert!(
712 relative_stage.starts_with(Path::new(".runtime/v2")),
713 "runtime authority must use the v2 staging domain: {}",
714 staged.display()
715 );
716 assert_eq!(
717 fs::metadata(staged).unwrap().permissions().mode() & 0o777,
718 0o500
719 );
720 assert_eq!(
721 fs::metadata(staged.join("plugin.toml"))
722 .unwrap()
723 .permissions()
724 .mode()
725 & 0o777,
726 0o400
727 );
728 assert_eq!(
729 fs::metadata(staged.join("server.sh"))
730 .unwrap()
731 .permissions()
732 .mode()
733 & 0o777,
734 0o500
735 );
736 }
737
738 #[cfg(unix)]
739 #[test]
740 fn discovery_does_not_rewrite_existing_state_or_lock_permissions() {
741 use std::os::unix::fs::PermissionsExt as _;
742
743 let tmp = tempfile::tempdir().unwrap();
744 let config = config(tmp.path());
745 write_plugin(&config, "");
746 fs::create_dir_all(config.state_path.parent().unwrap()).unwrap();
747 fs::set_permissions(
748 config.state_path.parent().unwrap(),
749 fs::Permissions::from_mode(0o700),
750 )
751 .unwrap();
752 fs::write(
753 &config.state_path,
754 "{\"schema_version\":1,\"plugins\":{}}\n",
755 )
756 .unwrap();
757 let lock = config.state_path.with_file_name("plugin-state.json.lock");
758 fs::write(&lock, b"sentinel").unwrap();
759 fs::set_permissions(&config.state_path, fs::Permissions::from_mode(0o644)).unwrap();
760 fs::set_permissions(&lock, fs::Permissions::from_mode(0o666)).unwrap();
761
762 let state_before = fs::metadata(&config.state_path).unwrap();
763 let lock_before = fs::metadata(&lock).unwrap();
764 let state_body = fs::read(&config.state_path).unwrap();
765 let lock_body = fs::read(&lock).unwrap();
766 let registry = discover_with_config(&config);
767
768 assert!(registry.state_error().is_none());
769 assert_eq!(fs::read(&config.state_path).unwrap(), state_body);
770 assert_eq!(fs::read(&lock).unwrap(), lock_body);
771 assert_eq!(
772 fs::metadata(&config.state_path)
773 .unwrap()
774 .permissions()
775 .mode(),
776 state_before.permissions().mode()
777 );
778 assert_eq!(
779 fs::metadata(&lock).unwrap().permissions().mode(),
780 lock_before.permissions().mode()
781 );
782 }
783
784 #[cfg(unix)]
785 #[test]
786 fn discovery_rejects_an_insecure_state_parent_without_mutating_it() {
787 use std::os::unix::fs::PermissionsExt as _;
788
789 let tmp = tempfile::tempdir().unwrap();
790 let config = config(tmp.path());
791 write_plugin(&config, "");
792 let state_parent = config.state_path.parent().unwrap();
793 fs::create_dir_all(state_parent).unwrap();
794 let state_body = b"{\"schema_version\":1,\"plugins\":{}}\n";
795 fs::write(&config.state_path, state_body).unwrap();
796 fs::set_permissions(state_parent, fs::Permissions::from_mode(0o777)).unwrap();
797
798 let registry = discover_with_config(&config);
799
800 assert!(registry.state_error().is_some());
801 assert_eq!(fs::read(&config.state_path).unwrap(), state_body);
802 assert_eq!(
803 fs::metadata(state_parent).unwrap().permissions().mode() & 0o777,
804 0o777,
805 "read-only discovery must not repair directory permissions"
806 );
807 }
808
809 #[cfg(unix)]
810 #[test]
811 fn trust_rejects_an_existing_group_accessible_state_parent_without_repairing_it() {
812 use std::os::unix::fs::PermissionsExt as _;
813
814 let tmp = tempfile::tempdir().unwrap();
815 let config = config(tmp.path());
816 write_plugin(&config, "");
817 let state_parent = config.state_path.parent().unwrap();
818 fs::create_dir_all(state_parent).unwrap();
819 fs::set_permissions(state_parent, fs::Permissions::from_mode(0o777)).unwrap();
820 let mut registry = discover_with_config(&config);
821 assert!(registry.state_error().is_some());
822
823 assert!(registry.trust("demo").is_err());
824
825 assert_eq!(
826 fs::metadata(state_parent).unwrap().permissions().mode() & 0o777,
827 0o777,
828 "trust must not silently repair a pre-existing unsafe authority directory"
829 );
830 assert!(!config.state_path.exists());
831 }
832
833 #[cfg(unix)]
834 #[test]
835 fn discovery_rejects_a_symlinked_state_parent_without_touching_its_target() {
836 use std::os::unix::fs::{PermissionsExt as _, symlink};
837
838 let tmp = tempfile::tempdir().unwrap();
839 let config = config(tmp.path());
840 write_plugin(&config, "");
841 let target = tmp.path().join("state-target");
842 fs::create_dir(&target).unwrap();
843 fs::set_permissions(&target, fs::Permissions::from_mode(0o700)).unwrap();
844 let state_body = b"{\"schema_version\":1,\"plugins\":{}}\n";
845 fs::write(target.join("plugin-state.json"), state_body).unwrap();
846 symlink(&target, config.state_path.parent().unwrap()).unwrap();
847
848 let mut registry = discover_with_config(&config);
849
850 assert!(registry.state_error().is_some());
851 assert!(registry.trust("demo").is_err());
852 assert_eq!(
853 fs::read(target.join("plugin-state.json")).unwrap(),
854 state_body
855 );
856 assert!(
857 fs::symlink_metadata(config.state_path.parent().unwrap())
858 .unwrap()
859 .file_type()
860 .is_symlink(),
861 "discovery and trust must leave the state-parent link in place"
862 );
863 }
864
865 #[cfg(unix)]
866 #[test]
867 fn discovery_rejects_linked_state_and_lock_without_touching_targets() {
868 use std::os::unix::fs::{PermissionsExt as _, symlink};
869
870 for linked_entry in ["state", "lock"] {
871 let tmp = tempfile::tempdir().unwrap();
872 let config = config(tmp.path());
873 write_plugin(&config, "");
874 fs::create_dir_all(config.state_path.parent().unwrap()).unwrap();
875 fs::set_permissions(
876 config.state_path.parent().unwrap(),
877 fs::Permissions::from_mode(0o700),
878 )
879 .unwrap();
880 let target = tmp.path().join(format!("{linked_entry}-target"));
881 fs::write(&target, "{\"schema_version\":1,\"plugins\":{}}\n").unwrap();
882 fs::set_permissions(&target, fs::Permissions::from_mode(0o644)).unwrap();
883 let target_before = fs::read(&target).unwrap();
884 let target_mode = fs::metadata(&target).unwrap().permissions().mode();
885 if linked_entry == "state" {
886 symlink(&target, &config.state_path).unwrap();
887 } else {
888 fs::write(
889 &config.state_path,
890 "{\"schema_version\":1,\"plugins\":{}}\n",
891 )
892 .unwrap();
893 symlink(
894 &target,
895 config.state_path.with_file_name("plugin-state.json.lock"),
896 )
897 .unwrap();
898 }
899
900 let registry = discover_with_config(&config);
901 assert!(
902 registry.state_error().is_some(),
903 "linked {linked_entry} must fail closed"
904 );
905 assert_eq!(fs::read(&target).unwrap(), target_before);
906 assert_eq!(
907 fs::metadata(&target).unwrap().permissions().mode(),
908 target_mode
909 );
910 }
911 }
912
913 #[cfg(unix)]
914 #[test]
915 fn staging_rejects_root_swaps_symlinked_runtime_parents_and_hardlinks() {
916 use std::os::unix::fs::{PermissionsExt as _, symlink};
917
918 let tmp = tempfile::tempdir().unwrap();
919 let config = config(tmp.path());
920 let plugin = write_plugin(&config, "");
921 let mut swapped = discover_with_config(&config);
922 let original = plugin.with_file_name("demo-original");
923 fs::rename(&plugin, &original).unwrap();
924 let outside = tmp.path().join("outside");
925 fs::create_dir(&outside).unwrap();
926 fs::write(
927 outside.join("plugin.toml"),
928 "schema_version = 1\n[plugin]\nname = \"demo\"\nversion = \"1.0.0\"\n",
929 )
930 .unwrap();
931 symlink(&outside, &plugin).unwrap();
932 assert!(swapped.trust("demo").is_err());
933
934 fs::remove_file(&plugin).unwrap();
935 fs::rename(&original, &plugin).unwrap();
936 let mut parent_swap = discover_with_config(&config);
937 fs::create_dir_all(config.state_path.parent().unwrap()).unwrap();
938 fs::set_permissions(
939 config.state_path.parent().unwrap(),
940 fs::Permissions::from_mode(0o700),
941 )
942 .unwrap();
943 let runtime_root = config.state_path.parent().unwrap().join(".runtime");
944 if runtime_root.exists() {
945 fs::remove_dir_all(&runtime_root).unwrap();
946 }
947 let runtime_outside = tmp.path().join("runtime-outside");
948 fs::create_dir(&runtime_outside).unwrap();
949 symlink(&runtime_outside, &runtime_root).unwrap();
950 assert!(parent_swap.trust("demo").is_err());
951
952 fs::remove_file(&runtime_root).unwrap();
953 let external_file = tmp.path().join("external.txt");
954 fs::write(&external_file, "reviewed-looking content").unwrap();
955 fs::hard_link(&external_file, plugin.join("hardlinked.txt")).unwrap();
956 let mut hardlinked = discover_with_config(&config);
957 assert!(hardlinked.trust("demo").is_err());
958 }
959
960 #[test]
961 fn workspace_scoped_registries_do_not_cross_load_skills() {
962 let tmp = tempfile::tempdir().unwrap();
963 let left_config = config(&tmp.path().join("left"));
964 let right_config = config(&tmp.path().join("right"));
965 for (config, body) in [(&left_config, "left body"), (&right_config, "right body")] {
966 let plugin = write_plugin(config, "\n[skills]\npath = \"skills\"\n");
967 fs::create_dir_all(plugin.join("skills/only")).unwrap();
968 fs::write(
969 plugin.join("skills/only/SKILL.md"),
970 format!("---\nname: only\ndescription: scoped\n---\n{body}\n"),
971 )
972 .unwrap();
973 }
974 let mut left = discover_with_config(&left_config);
975 left.trust("demo").unwrap();
976 left.enable("demo").unwrap();
977 let mut right = discover_with_config(&right_config);
978 right.trust("demo").unwrap();
979 right.enable("demo").unwrap();
980
981 let left_skills =
982 crate::skills::discover_from_directories_with_plugins(Vec::<PathBuf>::new(), Some(&left));
983 let right_skills =
984 crate::skills::discover_from_directories_with_plugins(Vec::<PathBuf>::new(), Some(&right));
985 assert_eq!(left_skills.get("demo:only").unwrap().body, "left body");
986 assert_eq!(right_skills.get("demo:only").unwrap().body, "right body");
987 assert_ne!(left.workspace(), right.workspace());
988 }
989
990 // ─────────────────────────────────────────────────────────────────────────────
991 // Install on-ramp integration (#5182)
992 // ─────────────────────────────────────────────────────────────────────────────
993
994 fn block_on<F: std::future::Future>(future: F) -> F::Output {
995 tokio::runtime::Builder::new_multi_thread()
996 .worker_threads(2)
997 .enable_all()
998 .build()
999 .unwrap()
1000 .block_on(future)
1001 }
1002
1003 fn allow_all_network() -> crate::network_policy::NetworkPolicy {
1004 crate::network_policy::NetworkPolicy {
1005 default: crate::network_policy::DecisionToml::Allow,
1006 ..Default::default()
1007 }
1008 }
1009
1010 fn write_install_source(root: &Path, name: &str) -> PathBuf {
1011 let source = root.join(format!("source/{name}"));
1012 fs::create_dir_all(source.join("skills/hello")).unwrap();
1013 fs::write(
1014 source.join("plugin.toml"),
1015 format!(
1016 "schema_version = 1\n[plugin]\nname = {name:?}\nversion = \"1.0.0\"\n[skills]\npath = \"skills\"\n"
1017 ),
1018 )
1019 .unwrap();
1020 fs::write(
1021 source.join("skills/hello/SKILL.md"),
1022 "---\nname: hello\ndescription: hi\n---\nbody\n",
1023 )
1024 .unwrap();
1025 source
1026 }
1027
1028 /// A DSH bundle package: one remote MCP row and one skill directory.
1029 fn write_dsh_package(root: &Path, url: &str) -> PathBuf {
1030 let package = root.join("dsh-source");
1031 fs::create_dir_all(package.join("pack-skills/guide")).unwrap();
1032 fs::write(
1033 package.join("package.json"),
1034 serde_json::json!({"name": "@demo/docs-dsh", "version": "1.0.0",
1035 "dsh": {"bundle": {"patch": "./cordis.patch.yml"}}})
1036 .to_string(),
1037 )
1038 .unwrap();
1039 fs::write(
1040 package.join("cordis.patch.yml"),
1041 format!(
1042 "- insert:\n - id: docs\n name: '@deepseek-ai/dsh-mcp-client'\n config: {{serverName: docs, transport: streamable-http, url: '{url}'}}\n - id: skills\n name: '@deepseek-ai/dsh-skill-filesystem'\n config: {{customSkillDirs: [pack-skills]}}\n - id: theme\n name: '@deepseek-ai/dsh-client-ui-theme'\n"
1043 ),
1044 )
1045 .unwrap();
1046 fs::write(
1047 package.join("pack-skills/guide/SKILL.md"),
1048 "---\nname: guide\ndescription: Bundled guide\n---\nBody.\n",
1049 )
1050 .unwrap();
1051 package
1052 }
1053
1054 /// DSH import is the ordinary reviewed install: the preview hash is what an
1055 /// exact install stages, the bundle lands disabled and untrusted, update
1056 /// re-converts the recorded package, and a changed package cannot be
1057 /// installed against a stale review or keep its trust.
1058 #[test]
1059 fn dsh_packages_import_through_the_reviewed_install_and_update_flow() {
1060 let tmp = tempfile::tempdir().unwrap();
1061 let config = config(tmp.path());
1062 let network = allow_all_network();
1063 let package = write_dsh_package(tmp.path(), "https://docs.example.invalid/mcp");
1064 let (conversion, reviewed) = super::install::preview_dsh(&package).unwrap();
1065 assert_eq!(conversion.plugin_name, "docs-dsh");
1066 assert_eq!(conversion.remote_servers, ["docs"]);
1067 assert_eq!(conversion.skills, ["guide"]);
1068 assert!(
1069 conversion
1070 .outcomes
1071 .iter()
1072 .any(|o| o.needs_manual_port() && o.row.as_deref() == Some("theme"))
1073 );
1074
1075 let source = super::install::PluginInstallSource::parse(package.to_str().unwrap()).unwrap();
1076 assert!(
1077 matches!(source, super::install::PluginInstallSource::Dsh(_)),
1078 "{source:?}"
1079 );
1080 let outcome = block_on(super::install::install_with_expected_content_hash(
1081 source,
1082 &config.user_plugins_dir,
1083 super::install::DEFAULT_MAX_SIZE_BYTES,
1084 &network,
1085 &|_| None,
1086 &reviewed,
1087 ))
1088 .unwrap();
1089 let super::install::PluginInstallOutcome::Installed(installed) = outcome else {
1090 panic!("DSH import must install");
1091 };
1092 assert_eq!(installed.content_hash, reviewed);
1093 let marker = fs::read_to_string(
1094 config
1095 .user_plugins_dir
1096 .join("docs-dsh")
1097 .join(super::install::INSTALLED_FROM_MARKER),
1098 )
1099 .unwrap();
1100 assert!(marker.contains("dsh:"), "{marker}");
1101
1102 let mut registry = discover_with_config(&config);
1103 let plugin = registry.get("docs-dsh").unwrap();
1104 assert!(!plugin.enabled && !plugin.trusted());
1105 registry.trust("docs-dsh").unwrap();
1106 registry.enable("docs-dsh").unwrap();
1107 assert!(registry.is_active("docs-dsh"));
1108
1109 let unchanged = block_on(super::install::update(
1110 "docs-dsh",
1111 &config.user_plugins_dir,
1112 super::install::DEFAULT_MAX_SIZE_BYTES,
1113 &network,
1114 ))
1115 .unwrap();
1116 assert!(matches!(
1117 unchanged,
1118 super::install::PluginUpdateResult::NoChange
1119 ));
1120
1121 fs::remove_dir_all(&package).unwrap();
1122 write_dsh_package(tmp.path(), "https://docs-v2.example.invalid/mcp");
1123 let stale = block_on(super::install::install_with_expected_content_hash(
1124 super::install::PluginInstallSource::Dsh(package.clone()),
1125 &tmp.path().join("other-plugins"),
1126 super::install::DEFAULT_MAX_SIZE_BYTES,
1127 &network,
1128 &|_| None,
1129 &reviewed,
1130 ))
1131 .unwrap_err();
1132 assert!(
1133 stale.to_string().contains("changed after review"),
1134 "{stale:#}"
1135 );
1136
1137 let updated = block_on(super::install::update(
1138 "docs-dsh",
1139 &config.user_plugins_dir,
1140 super::install::DEFAULT_MAX_SIZE_BYTES,
1141 &network,
1142 ))
1143 .unwrap();
1144 assert!(matches!(
1145 updated,
1146 super::install::PluginUpdateResult::Updated(_)
1147 ));
1148 let registry = discover_with_config(&config);
1149 assert!(
1150 !registry.get("docs-dsh").unwrap().trusted(),
1151 "changed converted bytes invalidate the trust receipt"
1152 );
1153 }
1154
1155 #[test]
1156 fn installed_bundles_land_disabled_and_untrusted_then_follow_the_trust_flow() {
1157 let tmp = tempfile::tempdir().unwrap();
1158 let config = config(tmp.path());
1159 let source = write_install_source(tmp.path(), "demo");
1160 let network = allow_all_network();
1161
1162 let outcome = block_on(super::install::install(
1163 super::install::PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
1164 &config.user_plugins_dir,
1165 super::install::DEFAULT_MAX_SIZE_BYTES,
1166 &network,
1167 false,
1168 &|_| None,
1169 ))
1170 .unwrap();
1171 assert!(
1172 matches!(outcome, super::install::PluginInstallOutcome::Installed(_)),
1173 "local install must succeed"
1174 );
1175 assert!(
1176 config
1177 .user_plugins_dir
1178 .join("demo")
1179 .join(super::install::INSTALLED_FROM_MARKER)
1180 .exists()
1181 );
1182
1183 // The discovery invariant: freshly installed bits are disabled + untrusted.
1184 let mut registry = discover_with_config(&config);
1185 let plugin = registry.get("demo").unwrap();
1186 assert!(!plugin.enabled);
1187 assert!(!plugin.trusted());
1188 assert!(registry.enable("demo").is_err());
1189
1190 registry.trust("demo").unwrap();
1191 registry.enable("demo").unwrap();
1192 assert!(registry.is_active("demo"));
1193 assert_eq!(
1194 registry
1195 .get("demo")
1196 .unwrap()
1197 .skill_snapshots
1198 .first()
1199 .map(|snapshot| snapshot.name.as_str()),
1200 Some("hello")
1201 );
1202 }
1203
1204 #[test]
1205 fn mutation_uninstall_requires_disabled_then_deletes_bits_and_prunes_state() {
1206 let tmp = tempfile::tempdir().unwrap();
1207 let config = config(tmp.path());
1208 let source = write_install_source(tmp.path(), "demo");
1209 let network = allow_all_network();
1210
1211 let mut registry = discover_with_config(&config);
1212 let ctx = super::mutation::PluginMutationContext {
1213 network: &network,
1214 max_size: super::install::DEFAULT_MAX_SIZE_BYTES,
1215 };
1216 let receipt = block_on(super::mutation::execute(
1217 super::mutation::PluginMutationRequest::Install {
1218 source: super::install::PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
1219 },
1220 &ctx,
1221 &mut registry,
1222 ))
1223 .unwrap();
1224 assert_eq!(
1225 receipt.outcome,
1226 super::mutation::PluginMutationOutcome::Installed
1227 );
1228
1229 let mut registry = discover_with_config(&config);
1230 registry.trust("demo").unwrap();
1231 registry.enable("demo").unwrap();
1232
1233 // Enabled bundles are refused before anything is deleted.
1234 let refused = block_on(super::mutation::execute(
1235 super::mutation::PluginMutationRequest::Uninstall {
1236 selector: "demo".to_string(),
1237 },
1238 &ctx,
1239 &mut registry,
1240 ));
1241 assert!(refused.is_err(), "uninstall must require disabled");
1242 assert!(config.user_plugins_dir.join("demo").exists());
1243
1244 registry.disable("demo").unwrap();
1245 let receipt = block_on(super::mutation::execute(
1246 super::mutation::PluginMutationRequest::Uninstall {
1247 selector: "demo".to_string(),
1248 },
1249 &ctx,
1250 &mut registry,
1251 ))
1252 .unwrap();
1253 assert_eq!(
1254 receipt.outcome,
1255 super::mutation::PluginMutationOutcome::Uninstalled
1256 );
1257 assert!(!config.user_plugins_dir.join("demo").exists());
1258
1259 let rediscovered = discover_with_config(&config);
1260 assert!(rediscovered.is_empty());
1261 let raw = fs::read_to_string(&config.state_path).unwrap();
1262 let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
1263 assert!(
1264 parsed["plugins"].as_object().unwrap().is_empty(),
1265 "state entry must be pruned: {raw}"
1266 );
1267 }
1268
1269 #[test]
1270 fn mutation_install_rejects_names_claimed_by_other_scopes() {
1271 let tmp = tempfile::tempdir().unwrap();
1272 let config = config(tmp.path());
1273 // A hand-placed workspace bundle already owns the name `demo`.
1274 let workspace_bundle = config.workspace_plugins_dir.join("demo");
1275 fs::create_dir_all(&workspace_bundle).unwrap();
1276 fs::write(
1277 workspace_bundle.join("plugin.toml"),
1278 "schema_version = 1\n[plugin]\nname = \"demo\"\nversion = \"1.0.0\"\n",
1279 )
1280 .unwrap();
1281 let source = write_install_source(tmp.path(), "demo");
1282 let network = allow_all_network();
1283
1284 let mut registry = discover_with_config(&config);
1285 let ctx = super::mutation::PluginMutationContext {
1286 network: &network,
1287 max_size: super::install::DEFAULT_MAX_SIZE_BYTES,
1288 };
1289 let err = block_on(super::mutation::execute(
1290 super::mutation::PluginMutationRequest::Install {
1291 source: super::install::PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
1292 },
1293 &ctx,
1294 &mut registry,
1295 ))
1296 .unwrap_err();
1297 assert!(
1298 format!("{err:#}").contains("already used by the workspace bundle"),
1299 "got: {err:#}"
1300 );
1301 assert!(!config.user_plugins_dir.join("demo").exists());
1302 }
1303
1304 #[test]
1305 fn mutation_update_refuses_local_installs_and_foreign_scopes() {
1306 let tmp = tempfile::tempdir().unwrap();
1307 let config = config(tmp.path());
1308 let source = write_install_source(tmp.path(), "demo");
1309 let network = allow_all_network();
1310
1311 let mut registry = discover_with_config(&config);
1312 let ctx = super::mutation::PluginMutationContext {
1313 network: &network,
1314 max_size: super::install::DEFAULT_MAX_SIZE_BYTES,
1315 };
1316 block_on(super::mutation::execute(
1317 super::mutation::PluginMutationRequest::Install {
1318 source: super::install::PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
1319 },
1320 &ctx,
1321 &mut registry,
1322 ))
1323 .unwrap();
1324
1325 let mut registry = discover_with_config(&config);
1326 let err = block_on(super::mutation::execute(
1327 super::mutation::PluginMutationRequest::Update {
1328 selector: "demo".to_string(),
1329 },
1330 &ctx,
1331 &mut registry,
1332 ))
1333 .unwrap_err();
1334 assert!(format!("{err:#}").contains("local path"), "got: {err:#}");
1335
1336 let err = block_on(super::mutation::execute(
1337 super::mutation::PluginMutationRequest::Update {
1338 selector: "missing".to_string(),
1339 },
1340 &ctx,
1341 &mut registry,
1342 ))
1343 .unwrap_err();
1344 assert!(format!("{err:#}").contains("was not found"), "got: {err:#}");
1345 }
1346
1347 // ─────────────────────────────────────────────────────────────────────────────
1348 // Agent Plugins v1.0.0 (plugin.json / mcp.json) interop
1349 // ─────────────────────────────────────────────────────────────────────────────
1350
1351 fn write_json_bundle(config: &DiscoveryConfig, dir: &str, plugin_json: &str) -> PathBuf {
1352 let plugin = config.user_plugins_dir.join(dir);
1353 fs::create_dir_all(&plugin).unwrap();
1354 fs::write(plugin.join("plugin.json"), plugin_json).unwrap();
1355 plugin
1356 }
1357
1358 #[test]
1359 fn third_party_agent_plugin_with_unknown_extension_namespace_loads_cleanly() {
1360 let tmp = tempfile::tempdir().unwrap();
1361 let config = config(tmp.path());
1362 let plugin = write_json_bundle(
1363 &config,
1364 "third-party",
1365 r#"{
1366 "$schema": "https://agent-plugins.org/schemas/plugin.json",
1367 "name": "third-party",
1368 "version": "1.4.2",
1369 "description": "A plugin authored for another client",
1370 "author": {"name": "Other Client", "email": "plugins@other.example"},
1371 "keywords": ["browser", "remote"],
1372 "extensions": {
1373 "com.example.client": {"anything": [1, 2, 3], "nested": {"x": true}},
1374 "net.codewhale": {"capabilities": {"network_hosts": ["example.com"]}}
1375 }
1376 }"#,
1377 );
1378 fs::create_dir_all(plugin.join("skills/demo")).unwrap();
1379 fs::write(
1380 plugin.join("skills/demo/SKILL.md"),
1381 "---\nname: demo\ndescription: demo skill\n---\nbody\n",
1382 )
1383 .unwrap();
1384 fs::create_dir_all(plugin.join("bin")).unwrap();
1385 fs::write(
1386 plugin.join("mcp.json"),
1387 r#"{
1388 "$schema": "https://agent-plugins.org/schemas/mcp.json",
1389 "mcpServers": {
1390 "local": {
1391 "type": "stdio",
1392 "command": "run.sh",
1393 "args": ["--port", "8080"],
1394 "env": {"API_KEY": "${THIRD_PARTY_API_KEY}"},
1395 "cwd": "bin"
1396 },
1397 "remote": {
1398 "type": "sse",
1399 "url": "https://example.com/mcp",
1400 "extensions": {
1401 "net.codewhale": {"env_headers": {"Authorization": "THIRD_PARTY_REMOTE_TOKEN"}},
1402 "com.example.client": {"polling": true}
1403 }
1404 }
1405 }
1406 }"#,
1407 )
1408 .unwrap();
1409
1410 let registry = discover_with_config(&config);
1411 let errors: Vec<_> = registry
1412 .diagnostics()
1413 .iter()
1414 .filter(|diagnostic| diagnostic.level == super::types::PluginDiagnosticLevel::Error)
1415 .collect();
1416 assert!(
1417 errors.is_empty(),
1418 "unexpected error diagnostics: {errors:?}"
1419 );
1420
1421 let plugin = registry
1422 .get("third-party")
1423 .expect("third-party plugin loads");
1424 assert_eq!(
1425 plugin.manifest.plugin.author.as_deref(),
1426 Some("Other Client <plugins@other.example>")
1427 );
1428 assert_eq!(plugin.manifest.plugin.keywords, vec!["browser", "remote"]);
1429 assert_eq!(plugin.inventory.skills, 1);
1430 assert_eq!(plugin.inventory.mcp_servers, 2);
1431 assert_eq!(plugin.inventory.stdio_mcp_servers, 1);
1432 assert_eq!(plugin.inventory.remote_mcp_servers, 1);
1433 assert_eq!(plugin.skill_snapshots.len(), 1);
1434 assert_eq!(plugin.skill_snapshots[0].name, "demo");
1435
1436 let servers = plugin.manifest.mcp_servers.as_ref().unwrap();
1437 assert_eq!(servers["local"].command.as_deref(), Some("run.sh"));
1438 assert_eq!(servers["local"].env["API_KEY"], "${THIRD_PARTY_API_KEY}");
1439 assert_eq!(servers["remote"].transport.as_deref(), Some("sse"));
1440 assert_eq!(
1441 servers["remote"].env_headers["Authorization"],
1442 "THIRD_PARTY_REMOTE_TOKEN"
1443 );
1444 }
1445
1446 #[test]
1447 fn discovery_prefers_plugin_json_over_legacy_toml() {
1448 let tmp = tempfile::tempdir().unwrap();
1449 let config = config(tmp.path());
1450 let plugin = config.user_plugins_dir.join("dual");
1451 fs::create_dir_all(&plugin).unwrap();
1452 fs::write(
1453 plugin.join("plugin.toml"),
1454 "schema_version = 1\n[plugin]\nname = \"toml-legacy\"\nversion = \"1.0.0\"\n",
1455 )
1456 .unwrap();
1457 fs::write(
1458 plugin.join("plugin.json"),
1459 r#"{"$schema": "https://agent-plugins.org/schemas/plugin.json", "name": "json-native", "version": "1.0.0"}"#,
1460 )
1461 .unwrap();
1462
1463 let registry = discover_with_config(&config);
1464 assert!(registry.get("json-native").is_some());
1465 assert!(registry.get("toml-legacy").is_none());
1466 }
1467
1468 #[test]
1469 fn legacy_toml_names_with_double_hyphens_still_load() {
1470 let tmp = tempfile::tempdir().unwrap();
1471 let config = config(tmp.path());
1472 write_named_plugin(&config, "a--b", "");
1473 let registry = discover_with_config(&config);
1474 assert!(
1475 registry.get("a--b").is_some(),
1476 "the legacy plugin.toml name rule keeps `--` runs readable"
1477 );
1478 }
1479
1480 #[test]
1481 fn plugin_json_with_reserved_mcp_env_name_is_rejected() {
1482 let tmp = tempfile::tempdir().unwrap();
1483 let config = config(tmp.path());
1484 let plugin = write_json_bundle(
1485 &config,
1486 "env-bad",
1487 r#"{"$schema": "https://agent-plugins.org/schemas/plugin.json", "name": "env-bad", "version": "1.0.0"}"#,
1488 );
1489 fs::write(
1490 plugin.join("mcp.json"),
1491 r#"{"mcpServers": {"x": {"command": "run", "env": {"PLUGIN_ROOT": "/tmp"}}}}"#,
1492 )
1493 .unwrap();
1494
1495 let registry = discover_with_config(&config);
1496 assert!(registry.get("env-bad").is_none());
1497 assert!(
1498 registry.diagnostics().iter().any(|diagnostic| {
1499 diagnostic.level == super::types::PluginDiagnosticLevel::Error
1500 && diagnostic.message.contains("PLUGIN_ROOT")
1501 }),
1502 "expected a PLUGIN_ROOT diagnostic, got {:?}",
1503 registry.diagnostics()
1504 );
1505 }
1506
1507 #[test]
1508 fn export_writes_spec_valid_bundle_that_rediscovers() {
1509 let tmp = tempfile::tempdir().unwrap();
1510 let config = config(tmp.path());
1511 let plugin = write_named_plugin(
1512 &config,
1513 "demo",
1514 "[skills]\npath = \"skills\"\n\n[mcp_servers.local]\ncommand = \"run.sh\"\n",
1515 );
1516 fs::create_dir_all(plugin.join("skills/hello")).unwrap();
1517 fs::write(
1518 plugin.join("skills/hello/SKILL.md"),
1519 "---\nname: hello\ndescription: hello skill\n---\nbody\n",
1520 )
1521 .unwrap();
1522
1523 let registry = discover_with_config(&config);
1524 let loaded = registry.get("demo").cloned().unwrap();
1525 let target = tmp.path().join("exported/demo-export");
1526 let receipt =
1527 super::export::export_plugin_bundle(&loaded, &target, &Default::default()).unwrap();
1528 assert_eq!(receipt.exported_name, "demo");
1529 assert_eq!(receipt.display_name, None);
1530 assert!(receipt.wrote_mcp_json);
1531 assert!(!receipt.skills_normalized);
1532
1533 // The emitted documents exist, conform to the standard's shape, and the
1534 // legacy manifest is not carried over.
1535 let plugin_json: serde_json::Value =
1536 serde_json::from_str(&fs::read_to_string(target.join("plugin.json")).unwrap()).unwrap();
1537 super::agent_plugin::validate_plugin_json(&plugin_json).unwrap();
1538 assert_eq!(plugin_json["name"], "demo");
1539 let mcp_json: serde_json::Value =
1540 serde_json::from_str(&fs::read_to_string(target.join("mcp.json")).unwrap()).unwrap();
1541 super::agent_plugin::validate_mcp_json(&mcp_json).unwrap();
1542 assert_eq!(mcp_json["mcpServers"]["local"]["type"], "stdio");
1543 assert_eq!(mcp_json["mcpServers"]["local"]["command"], "run.sh");
1544 assert!(target.join("skills/hello/SKILL.md").is_file());
1545 assert!(!target.join("plugin.toml").exists());
1546
1547 // The exported bundle is itself discoverable as an Agent Plugins bundle.
1548 let rediscovery = DiscoveryConfig {
1549 workspace: tmp.path().join("project2"),
1550 user_plugins_dir: tmp.path().join("exported"),
1551 workspace_plugins_dir: tmp.path().join("workspace2"),
1552 builtin_plugin_dirs: Vec::new(),
1553 state_path: tmp.path().join("state2/plugin-state.json"),
1554 };
1555 let registry = discover_with_config(&rediscovery);
1556 let exported = registry.get("demo").expect("exported bundle rediscovers");
1557 assert_eq!(exported.inventory.skills, 1);
1558 assert_eq!(exported.inventory.mcp_servers, 1);
1559 assert_eq!(exported.inventory.stdio_mcp_servers, 1);
1560 assert_eq!(exported.skill_snapshots[0].name, "hello");
1561 }
1562
1563 #[test]
1564 fn export_slugifies_legacy_names_and_collision_is_an_error() {
1565 // Two legacy plugins whose names collide once slugified: exporting the
1566 // `a--b` bundle must fail, not silently rename.
1567 let tmp = tempfile::tempdir().unwrap();
1568 let pair_config = config(tmp.path());
1569 write_named_plugin(&pair_config, "a--b", "");
1570 write_named_plugin(&pair_config, "a-b", "");
1571 let registry = discover_with_config(&pair_config);
1572 let loaded = registry.get("a--b").cloned().unwrap();
1573 let existing: std::collections::BTreeSet<String> = registry
1574 .list()
1575 .iter()
1576 .map(|plugin| plugin.name().to_string())
1577 .filter(|name| name != "a--b")
1578 .collect();
1579 let target = tmp.path().join("out");
1580 let error = super::export::export_plugin_bundle(&loaded, &target, &existing).unwrap_err();
1581 assert!(error.contains("collides"), "{error}");
1582 assert!(
1583 !target.exists(),
1584 "a failed export leaves no directory behind"
1585 );
1586
1587 // Without the collision, the export slugifies and preserves the original
1588 // name as the display name.
1589 let tmp = tempfile::tempdir().unwrap();
1590 let solo_config = config(tmp.path());
1591 write_named_plugin(&solo_config, "a--b", "");
1592 let registry = discover_with_config(&solo_config);
1593 let loaded = registry.get("a--b").cloned().unwrap();
1594 let target = tmp.path().join("out");
1595 let receipt =
1596 super::export::export_plugin_bundle(&loaded, &target, &Default::default()).unwrap();
1597 assert_eq!(receipt.exported_name, "a-b");
1598 assert_eq!(receipt.display_name.as_deref(), Some("a--b"));
1599 let plugin_json: serde_json::Value =
1600 serde_json::from_str(&fs::read_to_string(target.join("plugin.json")).unwrap()).unwrap();
1601 super::agent_plugin::validate_plugin_json(&plugin_json).unwrap();
1602 assert_eq!(plugin_json["name"], "a-b");
1603 assert_eq!(
1604 plugin_json["extensions"]["net.codewhale"]["display_name"],
1605 "a--b"
1606 );
1607 }
1608
1609 #[test]
1610 fn export_moves_custom_skills_layout_to_the_standard_tree() {
1611 let tmp = tempfile::tempdir().unwrap();
1612 let config = config(tmp.path());
1613 let plugin = config.user_plugins_dir.join("custom-skills");
1614 fs::create_dir_all(plugin.join("prompts/my-skill")).unwrap();
1615 fs::write(
1616 plugin.join("plugin.toml"),
1617 "schema_version = 1\n[plugin]\nname = \"custom-skills\"\nversion = \"1.0.0\"\n[skills]\npath = \"prompts\"\n",
1618 )
1619 .unwrap();
1620 fs::write(
1621 plugin.join("prompts/my-skill/SKILL.md"),
1622 "---\nname: my-skill\ndescription: custom layout\n---\nbody\n",
1623 )
1624 .unwrap();
1625
1626 let registry = discover_with_config(&config);
1627 let loaded = registry.get("custom-skills").cloned().unwrap();
1628 assert_eq!(loaded.skill_snapshots.len(), 1);
1629
1630 let target = tmp.path().join("exported/custom-skills");
1631 let receipt =
1632 super::export::export_plugin_bundle(&loaded, &target, &Default::default()).unwrap();
1633 assert!(receipt.skills_normalized);
1634 assert!(target.join("skills/my-skill/SKILL.md").is_file());
1635 assert!(!target.join("prompts").exists());
1636 let plugin_json: serde_json::Value =
1637 serde_json::from_str(&fs::read_to_string(target.join("plugin.json")).unwrap()).unwrap();
1638 super::agent_plugin::validate_plugin_json(&plugin_json).unwrap();
1639 assert!(
1640 plugin_json.get("extensions").is_none(),
1641 "a standard-layout bundle emits no Codewhale extension at all: {plugin_json}"
1642 );
1643
1644 let rediscovery = DiscoveryConfig {
1645 workspace: tmp.path().join("project2"),
1646 user_plugins_dir: tmp.path().join("exported"),
1647 workspace_plugins_dir: tmp.path().join("workspace2"),
1648 builtin_plugin_dirs: Vec::new(),
1649 state_path: tmp.path().join("state2/plugin-state.json"),
1650 };
1651 let registry = discover_with_config(&rediscovery);
1652 let exported = registry
1653 .get("custom-skills")
1654 .expect("normalized bundle rediscovers");
1655 assert_eq!(exported.inventory.skills, 1);
1656 assert_eq!(exported.skill_snapshots[0].name, "my-skill");
1657 }
1658
1659 #[test]
1660 fn export_skills_normalization_collision_is_an_error() {
1661 let tmp = tempfile::tempdir().unwrap();
1662 let config = config(tmp.path());
1663 let plugin = config.user_plugins_dir.join("colliding-skills");
1664 fs::create_dir_all(plugin.join("skills/dup")).unwrap();
1665 fs::create_dir_all(plugin.join("prompts/dup")).unwrap();
1666 fs::write(
1667 plugin.join("plugin.toml"),
1668 "schema_version = 1\n[plugin]\nname = \"colliding-skills\"\nversion = \"1.0.0\"\n[skills]\npath = \"skills\"\npaths = [\"prompts\"]\n",
1669 )
1670 .unwrap();
1671 fs::write(
1672 plugin.join("skills/dup/SKILL.md"),
1673 "---\nname: first\ndescription: one\n---\nbody\n",
1674 )
1675 .unwrap();
1676 fs::write(
1677 plugin.join("prompts/dup/SKILL.md"),
1678 "---\nname: second\ndescription: two\n---\nbody\n",
1679 )
1680 .unwrap();
1681
1682 let registry = discover_with_config(&config);
1683 let loaded = registry.get("colliding-skills").cloned().unwrap();
1684 assert_eq!(loaded.skill_snapshots.len(), 2);
1685 let target = tmp.path().join("out");
1686 let error =
1687 super::export::export_plugin_bundle(&loaded, &target, &Default::default()).unwrap_err();
1688 assert!(error.contains("collision"), "{error}");
1689 assert!(
1690 !target.exists(),
1691 "a failed export removes the directory it created"
1692 );
1693 }
1694
1695 fn policy_path(config: &DiscoveryConfig) -> PathBuf {
1696 config
1697 .state_path
1698 .parent()
1699 .expect("state path has a parent")
1700 .join(MANAGED_POLICY_FILE_NAME)
1701 }
1702
1703 fn write_policy(path: &Path, policy: &ManagedPluginPolicy) {
1704 fs::create_dir_all(path.parent().expect("policy path has a parent")).unwrap();
1705 fs::write(path, serde_json::to_string_pretty(policy).unwrap()).unwrap();
1706 }
1707
1708 fn restrictive_policy() -> ManagedPluginPolicy {
1709 ManagedPluginPolicy {
1710 schema_version: MANAGED_POLICY_SCHEMA_VERSION,
1711 allowed_plugins: Default::default(),
1712 allow_unlisted: false,
1713 }
1714 }
1715
1716 #[test]
1717 fn absent_managed_policy_preserves_today_enablement() {
1718 let tmp = tempfile::tempdir().unwrap();
1719 let config = config(tmp.path());
1720 write_plugin(&config, "");
1721
1722 let mut registry = discover_with_config(&config);
1723 assert!(registry.managed_policy_error().is_none());
1724 assert_eq!(
1725 registry.managed_policy_path(),
1726 Some(policy_path(&config).as_path())
1727 );
1728 assert!(
1729 !registry
1730 .diagnostics()
1731 .iter()
1732 .any(|diagnostic| diagnostic.code == "policy-invalid"),
1733 "no policy file means no policy diagnostic"
1734 );
1735
1736 registry.trust("demo").unwrap();
1737 registry.enable("demo").unwrap();
1738 assert!(registry.is_active("demo"));
1739
1740 let reloaded = discover_with_config(&config);
1741 assert!(
1742 reloaded.is_active("demo"),
1743 "absent policy must keep today's restart behaviour"
1744 );
1745 }
1746
1747 #[test]
1748 fn managed_policy_refuses_unlisted_enable_with_named_reason() {
1749 let tmp = tempfile::tempdir().unwrap();
1750 let config = config(tmp.path());
1751 write_plugin(&config, "");
1752 write_named_plugin(&config, "extra", "");
1753
1754 // Review first: trust hardens the state directory, and the policy file
1755 // lands next to it afterwards, the way an organization delivery would.
1756 let mut registry = discover_with_config(&config);
1757 registry.trust("demo").unwrap();
1758 registry.trust("extra").unwrap();
1759 let extra_id = registry.get("extra").expect("extra plugin").id.clone();
1760
1761 write_policy(
1762 &policy_path(&config),
1763 &ManagedPluginPolicy {
1764 schema_version: MANAGED_POLICY_SCHEMA_VERSION,
1765 allowed_plugins: [extra_id].into_iter().collect(),
1766 allow_unlisted: false,
1767 },
1768 );
1769
1770 let mut registry = discover_with_config(&config);
1771
1772 let error = registry.enable("demo").unwrap_err();
1773 assert!(
1774 error.contains("managed plugin policy"),
1775 "refusal must name the policy: {error}"
1776 );
1777 assert!(
1778 error.contains("not on the plugin allowlist"),
1779 "refusal must say why: {error}"
1780 );
1781 assert!(!registry.is_enabled("demo"));
1782 assert!(!registry.is_active("demo"));
1783
1784 registry.enable("extra").unwrap();
1785 assert!(registry.is_active("extra"));
1786
1787 // Flipping the document to allow unlisted plugins applies to the live
1788 // registry: `enable` re-reads the policy instead of trusting its snapshot.
1789 write_policy(
1790 &policy_path(&config),
1791 &ManagedPluginPolicy {
1792 schema_version: MANAGED_POLICY_SCHEMA_VERSION,
1793 allowed_plugins: Default::default(),
1794 allow_unlisted: true,
1795 },
1796 );
1797 registry.enable("demo").unwrap();
1798 assert!(registry.is_active("demo"));
1799 }
1800
1801 #[test]
1802 fn plugin_enabled_before_policy_arrived_does_not_survive_rediscovery() {
1803 let tmp = tempfile::tempdir().unwrap();
1804 let config = config(tmp.path());
1805 write_plugin(&config, "");
1806
1807 let mut registry = discover_with_config(&config);
1808 registry.trust("demo").unwrap();
1809 registry.enable("demo").unwrap();
1810 assert!(registry.is_active("demo"));
1811
1812 write_policy(&policy_path(&config), &restrictive_policy());
1813
1814 let reloaded = discover_with_config(&config);
1815 assert!(
1816 !reloaded.is_enabled("demo"),
1817 "a forbidden plugin must not come back enabled"
1818 );
1819 assert!(!reloaded.is_active("demo"));
1820 }
1821
1822 #[test]
1823 fn hand_edited_enabled_state_does_not_defeat_policy() {
1824 let tmp = tempfile::tempdir().unwrap();
1825 let config = config(tmp.path());
1826 write_plugin(&config, "");
1827
1828 let mut registry = discover_with_config(&config);
1829 registry.trust("demo").unwrap();
1830 let id = registry
1831 .get("demo")
1832 .expect("demo plugin")
1833 .id
1834 .as_str()
1835 .to_string();
1836
1837 // Forge the exact attack: flip the persisted bit by hand.
1838 let raw = fs::read_to_string(&config.state_path).unwrap();
1839 let mut state: serde_json::Value = serde_json::from_str(&raw).unwrap();
1840 state["plugins"][id.as_str()]["enabled"] = serde_json::Value::Bool(true);
1841 fs::write(
1842 &config.state_path,
1843 serde_json::to_string_pretty(&state).unwrap(),
1844 )
1845 .unwrap();
1846
1847 // Control: without a policy the forged bit comes back enabled and active.
1848 let unpoliced = discover_with_config(&config);
1849 assert!(unpoliced.is_enabled("demo"));
1850 assert!(unpoliced.is_active("demo"));
1851
1852 write_policy(&policy_path(&config), &restrictive_policy());
1853
1854 let policed = discover_with_config(&config);
1855 assert!(
1856 !policed.is_enabled("demo"),
1857 "hand-edited enabled:true must not survive a forbidding policy"
1858 );
1859 assert!(!policed.is_active("demo"));
1860 }
1861
1862 #[test]
1863 fn malformed_managed_policy_fails_closed() {
1864 let tmp = tempfile::tempdir().unwrap();
1865 let config = config(tmp.path());
1866 write_plugin(&config, "");
1867
1868 let mut registry = discover_with_config(&config);
1869 registry.trust("demo").unwrap();
1870 registry.enable("demo").unwrap();
1871 assert!(registry.is_active("demo"));
1872
1873 let path = policy_path(&config);
1874 fs::create_dir_all(path.parent().unwrap()).unwrap();
1875
1876 fs::write(&path, "{ malformed").unwrap();
1877 let mut registry = discover_with_config(&config);
1878 assert!(
1879 registry
1880 .managed_policy_error()
1881 .is_some_and(|error| error.contains("failed to parse")),
1882 "garbage policy must report a clear load error"
1883 );
1884 assert!(
1885 registry
1886 .diagnostics()
1887 .iter()
1888 .any(|diagnostic| diagnostic.code == "policy-invalid"),
1889 "garbage policy must leave a registry diagnostic"
1890 );
1891 assert!(
1892 !registry.is_enabled("demo"),
1893 "a malformed policy must not leave the plugin enabled"
1894 );
1895 assert!(!registry.is_active("demo"));
1896 let error = registry.enable("demo").unwrap_err();
1897 assert!(
1898 error.contains("Managed plugin policy"),
1899 "fail-closed refusal must name the policy: {error}"
1900 );
1901
1902 fs::write(
1903 &path,
1904 r#"{"schema_version":999,"allowed_plugins":[],"allow_unlisted":false}"#,
1905 )
1906 .unwrap();
1907 let registry = discover_with_config(&config);
1908 assert!(
1909 registry
1910 .managed_policy_error()
1911 .is_some_and(|error| error.contains("unsupported managed plugin policy schema")),
1912 "wrong-schema policy must fail closed with a clear error"
1913 );
1914 assert!(!registry.is_enabled("demo"));
1915
1916 fs::write(
1917 &path,
1918 r#"{"schema_version":1,"allowed_plugins":[],"allow_unlisted":false,"surprise":true}"#,
1919 )
1920 .unwrap();
1921 let registry = discover_with_config(&config);
1922 assert!(
1923 registry.managed_policy_error().is_some(),
1924 "unknown policy fields must fail closed like unknown state fields"
1925 );
1926 assert!(!registry.is_enabled("demo"));
1927 }
1928
1929 #[test]
1930 fn managed_policy_path_env_override_is_honored() {
1931 let tmp = tempfile::tempdir().unwrap();
1932 let config = config(tmp.path());
1933 write_plugin(&config, "");
1934
1935 let custom = tmp.path().join("custom-policy.json");
1936 write_policy(&custom, &restrictive_policy());
1937 assert!(
1938 !policy_path(&config).exists(),
1939 "the default sibling must stay absent so the override is proven"
1940 );
1941
1942 let environment = super::context::HostEnvironment::from_entries([(
1943 std::ffi::OsString::from(MANAGED_POLICY_PATH_ENV),
1944 custom.as_os_str().to_os_string(),
1945 )]);
1946 let context =
1947 super::context::PluginDiscoveryContext::from_config_and_environment(&config, environment);
1948 let mut registry = super::discovery::discover_with_context(&config, context);
1949 assert_eq!(
1950 registry.managed_policy_path(),
1951 Some(custom.as_path()),
1952 "the env override must win over the default sibling path"
1953 );
1954
1955 registry.trust("demo").unwrap();
1956 let error = registry.enable("demo").unwrap_err();
1957 assert!(
1958 error.contains("managed plugin policy"),
1959 "override policy must refuse with the named reason: {error}"
1960 );
1961 assert!(!registry.is_enabled("demo"));
1962 }
1963
1963 lines RUST