返回 CodeWhale
tests.rs
根目录 / crates / tui / src / plugins / tests.rs
1 use std::fs;
2 use std::path::{Path, PathBuf};
3
4 use super::discovery::{DiscoveryConfig, discover_with_config};
5 use super::types::PluginTrustStatus;
6
7 fn config(root: &Path) -> DiscoveryConfig {
8 DiscoveryConfig {
9 workspace: root.join("project"),
10 user_plugins_dir: root.join("user"),
11 workspace_plugins_dir: root.join("workspace"),
12 builtin_plugin_dirs: Vec::new(),
13 state_path: root.join("state/plugin-state.json"),
14 }
15 }
16
17 fn write_plugin(config: &DiscoveryConfig, extra: &str) -> PathBuf {
18 write_named_plugin(config, "demo", extra)
19 }
20
21 fn write_named_plugin(config: &DiscoveryConfig, name: &str, extra: &str) -> PathBuf {
22 let plugin = config.user_plugins_dir.join(name);
23 fs::create_dir_all(&plugin).unwrap();
24 fs::write(
25 plugin.join("plugin.toml"),
26 format!("schema_version = 1\n[plugin]\nname = {name:?}\nversion = \"1.0.0\"\n{extra}"),
27 )
28 .unwrap();
29 plugin
30 }
31
32 #[test]
33 fn trust_and_enablement_are_separate_atomic_state_transitions() {
34 let tmp = tempfile::tempdir().unwrap();
35 let config = config(tmp.path());
36 write_plugin(&config, "");
37
38 let mut registry = discover_with_config(&config);
39 assert!(registry.enable("demo").is_err());
40 assert!(!config.state_path.exists());
41
42 registry.trust("demo").unwrap();
43 assert!(registry.get("demo").unwrap().trusted());
44 assert!(!registry.get("demo").unwrap().enabled);
45 registry.enable("demo").unwrap();
46 assert!(registry.is_active("demo"));
47 registry.revoke_trust("demo").unwrap();
48 assert!(registry.get("demo").unwrap().enabled);
49 registry.trust("demo").unwrap();
50 assert!(registry.get("demo").unwrap().trusted());
51 assert!(
52 !registry.get("demo").unwrap().enabled,
53 "trust must never reuse an old enablement bit"
54 );
55 assert!(!registry.is_active("demo"));
56 registry.enable("demo").unwrap();
57 assert!(registry.is_active("demo"));
58
59 let raw = fs::read_to_string(&config.state_path).unwrap();
60 let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
61 assert_eq!(parsed["schema_version"], 1);
62 let receipt = parsed["plugins"]
63 .as_object()
64 .and_then(|plugins| plugins.values().next())
65 .and_then(|plugin| plugin.get("trust"))
66 .expect("trust receipt");
67 assert!(receipt["content_hash"].as_str().is_some());
68 assert!(receipt["capability_hash"].as_str().is_some());
69 assert_eq!(receipt["reviewed_capabilities"]["skills"], 0);
70 assert!(receipt["reviewed_at"].as_str().is_some());
71 let history = parsed["plugins"]
72 .as_object()
73 .and_then(|plugins| plugins.values().next())
74 .and_then(|plugin| plugin["review_history"].as_array())
75 .expect("review history");
76 assert_eq!(history.len(), 2);
77 assert_eq!(history[1]["content_hash"], receipt["content_hash"]);
78 #[cfg(unix)]
79 {
80 use std::os::unix::fs::PermissionsExt;
81 assert_eq!(
82 fs::metadata(config.state_path.parent().unwrap())
83 .unwrap()
84 .permissions()
85 .mode()
86 & 0o777,
87 0o700
88 );
89 assert_eq!(
90 fs::metadata(&config.state_path)
91 .unwrap()
92 .permissions()
93 .mode()
94 & 0o777,
95 0o600
96 );
97 }
98 let entries = fs::read_dir(config.state_path.parent().unwrap())
99 .unwrap()
100 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
101 .collect::<Vec<_>>();
102 assert!(entries.iter().any(|name| name == "plugin-state.json"));
103 assert!(entries.iter().any(|name| name == "plugin-state.json.lock"));
104 assert!(entries.iter().any(|name| name == ".runtime"));
105 assert!(
106 entries.iter().all(|name| !name.contains(".tmp")),
107 "atomic persistence must not strand temp files: {entries:?}"
108 );
109 }
110
111 #[test]
112 fn content_change_invalidates_trust_without_changing_capabilities() {
113 let tmp = tempfile::tempdir().unwrap();
114 let config = config(tmp.path());
115 let plugin = write_plugin(&config, "\n[skills]\npath = \"skills\"\n");
116 fs::create_dir_all(plugin.join("skills/demo")).unwrap();
117 fs::write(
118 plugin.join("skills/demo/SKILL.md"),
119 "---\nname: demo\ndescription: first\n---\nbody\n",
120 )
121 .unwrap();
122
123 let mut first = discover_with_config(&config);
124 first.trust("demo").unwrap();
125 first.enable("demo").unwrap();
126 assert!(first.is_active("demo"));
127
128 fs::write(
129 plugin.join("skills/demo/SKILL.md"),
130 "---\nname: demo\ndescription: changed\n---\nbody\n",
131 )
132 .unwrap();
133 let second = discover_with_config(&config);
134 let plugin = second.get("demo").unwrap();
135 assert!(plugin.enabled, "enablement is independent from trust");
136 assert_eq!(plugin.trust_status, PluginTrustStatus::ContentChanged);
137 assert!(!plugin.active());
138 }
139
140 #[test]
141 fn aba_source_skill_body_is_replaced_by_the_staged_snapshot_before_activation() {
142 let tmp = tempfile::tempdir().unwrap();
143 let config = config(tmp.path());
144 let plugin = write_plugin(&config, "\n[skills]\npath = \"skills\"\n");
145 let skill_path = plugin.join("skills/demo/SKILL.md");
146 fs::create_dir_all(skill_path.parent().unwrap()).unwrap();
147 fs::write(
148 &skill_path,
149 "---\nname: demo\ndescription: stable\n---\nbody A\n",
150 )
151 .unwrap();
152
153 // Capture authority A, parse a transient B body, then restore source A.
154 // This deterministically models the old discovery A -> B -> A race.
155 let mut authority_a = discover_with_config(&config);
156 fs::write(
157 &skill_path,
158 "---\nname: demo\ndescription: transient\n---\nbody B\n",
159 )
160 .unwrap();
161 let transient_b = discover_with_config(&config)
162 .get("demo")
163 .unwrap()
164 .skill_snapshots
165 .clone();
166 fs::write(
167 &skill_path,
168 "---\nname: demo\ndescription: stable\n---\nbody A\n",
169 )
170 .unwrap();
171 authority_a.replace_skill_snapshots_for_test("demo", transient_b);
172 assert!(
173 authority_a.get("demo").unwrap().skill_snapshots[0]
174 .body
175 .contains("body B")
176 );
177
178 authority_a.trust("demo").unwrap();
179 authority_a.enable("demo").unwrap();
180 let active = authority_a.get("demo").unwrap();
181 assert!(active.active());
182 assert!(active.skill_snapshots[0].body.contains("body A"));
183 assert!(!active.skill_snapshots[0].body.contains("body B"));
184 let staged_bytes = fs::read(&active.skill_snapshots[0].path).unwrap();
185 let mut digest = sha2::Sha256::new();
186 use sha2::Digest as _;
187 digest.update(b"codewhale-plugin-file-bytes-v1\0");
188 digest.update(staged_bytes);
189 let staged_hash = digest
190 .finalize()
191 .iter()
192 .map(|byte| format!("{byte:02x}"))
193 .collect::<String>();
194 assert_eq!(active.skill_snapshots[0].source_hash, staged_hash);
195 assert!(
196 active.skill_snapshots[0]
197 .path
198 .starts_with(active.staged_root.as_ref().unwrap()),
199 "active Skill paths must point into the Codewhale-owned staged tree"
200 );
201 }
202
203 #[test]
204 fn capability_escalation_invalidates_trust_and_stays_inactive() {
205 let tmp = tempfile::tempdir().unwrap();
206 let config = config(tmp.path());
207 let plugin = write_plugin(&config, "");
208
209 let mut first = discover_with_config(&config);
210 first.trust("demo").unwrap();
211 first.enable("demo").unwrap();
212
213 fs::create_dir_all(plugin.join("hooks")).unwrap();
214 fs::write(
215 plugin.join("plugin.toml"),
216 "schema_version = 1\n[plugin]\nname = \"demo\"\nversion = \"1.0.0\"\n[hooks]\npath = \"hooks\"\n",
217 )
218 .unwrap();
219 let second = discover_with_config(&config);
220 let plugin = second.get("demo").unwrap();
221 assert_eq!(plugin.trust_status, PluginTrustStatus::CapabilitiesChanged);
222 assert!(plugin.enabled);
223 assert!(!plugin.active());
224 }
225
226 #[test]
227 fn malformed_state_is_fail_closed_and_never_overwritten() {
228 let tmp = tempfile::tempdir().unwrap();
229 let config = config(tmp.path());
230 write_plugin(&config, "");
231 fs::create_dir_all(config.state_path.parent().unwrap()).unwrap();
232 #[cfg(unix)]
233 {
234 use std::os::unix::fs::PermissionsExt as _;
235 fs::set_permissions(
236 config.state_path.parent().unwrap(),
237 fs::Permissions::from_mode(0o700),
238 )
239 .unwrap();
240 }
241 fs::write(&config.state_path, "{ malformed").unwrap();
242
243 let mut registry = discover_with_config(&config);
244 assert!(registry.state_error().is_some());
245 assert!(!registry.get("demo").unwrap().enabled);
246 assert!(!registry.get("demo").unwrap().trusted());
247 assert!(registry.trust("demo").is_err());
248 assert_eq!(
249 fs::read_to_string(&config.state_path).unwrap(),
250 "{ malformed"
251 );
252 }
253
254 #[test]
255 fn atomic_write_failure_does_not_mutate_live_enablement() {
256 let tmp = tempfile::tempdir().unwrap();
257 let config = config(tmp.path());
258 write_plugin(&config, "");
259
260 let mut registry = discover_with_config(&config);
261 registry.trust("demo").unwrap();
262 fs::remove_file(&config.state_path).unwrap();
263 fs::create_dir(&config.state_path).unwrap();
264
265 assert!(registry.enable("demo").is_err());
266 let plugin = registry.get("demo").unwrap();
267 assert!(plugin.trusted());
268 assert!(!plugin.enabled);
269 assert!(!plugin.active());
270 }
271
272 #[test]
273 fn revoking_trust_does_not_rewrite_enablement() {
274 let tmp = tempfile::tempdir().unwrap();
275 let config = config(tmp.path());
276 write_plugin(&config, "");
277
278 let mut registry = discover_with_config(&config);
279 registry.trust("demo").unwrap();
280 registry.enable("demo").unwrap();
281 registry.revoke_trust("demo").unwrap();
282
283 let plugin = registry.get("demo").unwrap();
284 assert!(plugin.enabled);
285 assert!(!plugin.trusted());
286 assert!(!plugin.active());
287 }
288
289 #[test]
290 fn unsupported_components_can_be_reviewed_but_not_enabled() {
291 let tmp = tempfile::tempdir().unwrap();
292 let config = config(tmp.path());
293 let plugin = write_plugin(&config, "\n[commands]\npath = \"commands\"\n");
294 fs::create_dir_all(plugin.join("commands")).unwrap();
295
296 let mut registry = discover_with_config(&config);
297 registry.trust("demo").unwrap();
298 let error = registry.enable("demo").unwrap_err();
299 assert!(error.contains("inactive capabilities"));
300 assert!(!registry.is_active("demo"));
301 }
302
303 #[test]
304 fn stale_concurrent_registries_do_not_lose_updates() {
305 let tmp = tempfile::tempdir().unwrap();
306 let config = config(tmp.path());
307 write_named_plugin(&config, "alpha", "");
308 write_named_plugin(&config, "beta", "");
309
310 let left = discover_with_config(&config);
311 let right = discover_with_config(&config);
312 let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
313 let left_barrier = std::sync::Arc::clone(&barrier);
314 let left = std::thread::spawn(move || {
315 let mut registry = left;
316 left_barrier.wait();
317 registry.trust("alpha").unwrap();
318 registry.enable("alpha").unwrap();
319 });
320 let right = std::thread::spawn(move || {
321 let mut registry = right;
322 barrier.wait();
323 registry.trust("beta").unwrap();
324 registry.enable("beta").unwrap();
325 });
326 left.join().unwrap();
327 right.join().unwrap();
328
329 let fresh = discover_with_config(&config);
330 assert!(fresh.is_active("alpha"));
331 assert!(fresh.is_active("beta"));
332 }
333
334 #[test]
335 fn stale_enable_cannot_resurrect_revoked_trust() {
336 let tmp = tempfile::tempdir().unwrap();
337 let config = config(tmp.path());
338 write_plugin(&config, "");
339 let mut initial = discover_with_config(&config);
340 initial.trust("demo").unwrap();
341 initial.enable("demo").unwrap();
342
343 let mut stale = discover_with_config(&config);
344 let authority = stale.authority_for("demo").unwrap();
345 let mut revoker = discover_with_config(&config);
346 revoker.revoke_trust("demo").unwrap();
347 assert!(super::registry::verify_plugin_state_authority(&authority).is_err());
348
349 stale.enable("demo").unwrap();
350 let fresh = discover_with_config(&config);
351 assert!(fresh.get("demo").unwrap().enabled);
352 assert!(!fresh.get("demo").unwrap().trusted());
353 assert!(!fresh.is_active("demo"));
354 }
355
356 #[cfg(unix)]
357 #[test]
358 fn staging_is_owner_only_and_uses_the_reviewed_executable_shape() {
359 use std::os::unix::fs::PermissionsExt;
360
361 let tmp = tempfile::tempdir().unwrap();
362 let config = config(tmp.path());
363 let plugin = write_plugin(&config, "");
364 let executable = plugin.join("server.sh");
365 fs::write(&executable, "#!/bin/sh\nexit 0\n").unwrap();
366 fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap();
367
368 let mut registry = discover_with_config(&config);
369 registry.trust("demo").unwrap();
370 registry.enable("demo").unwrap();
371 let staged = registry.get("demo").unwrap().staged_root.as_ref().unwrap();
372 assert_ne!(staged, &plugin);
373 let state_parent = config.state_path.parent().unwrap().canonicalize().unwrap();
374 let relative_stage = staged.strip_prefix(state_parent).unwrap();
375 assert!(
376 relative_stage.starts_with(Path::new(".runtime/v2")),
377 "runtime authority must use the v2 staging domain: {}",
378 staged.display()
379 );
380 assert_eq!(
381 fs::metadata(staged).unwrap().permissions().mode() & 0o777,
382 0o500
383 );
384 assert_eq!(
385 fs::metadata(staged.join("plugin.toml"))
386 .unwrap()
387 .permissions()
388 .mode()
389 & 0o777,
390 0o400
391 );
392 assert_eq!(
393 fs::metadata(staged.join("server.sh"))
394 .unwrap()
395 .permissions()
396 .mode()
397 & 0o777,
398 0o500
399 );
400 }
401
402 #[cfg(unix)]
403 #[test]
404 fn discovery_does_not_rewrite_existing_state_or_lock_permissions() {
405 use std::os::unix::fs::PermissionsExt as _;
406
407 let tmp = tempfile::tempdir().unwrap();
408 let config = config(tmp.path());
409 write_plugin(&config, "");
410 fs::create_dir_all(config.state_path.parent().unwrap()).unwrap();
411 fs::set_permissions(
412 config.state_path.parent().unwrap(),
413 fs::Permissions::from_mode(0o700),
414 )
415 .unwrap();
416 fs::write(
417 &config.state_path,
418 "{\"schema_version\":1,\"plugins\":{}}\n",
419 )
420 .unwrap();
421 let lock = config.state_path.with_file_name("plugin-state.json.lock");
422 fs::write(&lock, b"sentinel").unwrap();
423 fs::set_permissions(&config.state_path, fs::Permissions::from_mode(0o644)).unwrap();
424 fs::set_permissions(&lock, fs::Permissions::from_mode(0o666)).unwrap();
425
426 let state_before = fs::metadata(&config.state_path).unwrap();
427 let lock_before = fs::metadata(&lock).unwrap();
428 let state_body = fs::read(&config.state_path).unwrap();
429 let lock_body = fs::read(&lock).unwrap();
430 let registry = discover_with_config(&config);
431
432 assert!(registry.state_error().is_none());
433 assert_eq!(fs::read(&config.state_path).unwrap(), state_body);
434 assert_eq!(fs::read(&lock).unwrap(), lock_body);
435 assert_eq!(
436 fs::metadata(&config.state_path)
437 .unwrap()
438 .permissions()
439 .mode(),
440 state_before.permissions().mode()
441 );
442 assert_eq!(
443 fs::metadata(&lock).unwrap().permissions().mode(),
444 lock_before.permissions().mode()
445 );
446 }
447
448 #[cfg(unix)]
449 #[test]
450 fn discovery_rejects_an_insecure_state_parent_without_mutating_it() {
451 use std::os::unix::fs::PermissionsExt as _;
452
453 let tmp = tempfile::tempdir().unwrap();
454 let config = config(tmp.path());
455 write_plugin(&config, "");
456 let state_parent = config.state_path.parent().unwrap();
457 fs::create_dir_all(state_parent).unwrap();
458 let state_body = b"{\"schema_version\":1,\"plugins\":{}}\n";
459 fs::write(&config.state_path, state_body).unwrap();
460 fs::set_permissions(state_parent, fs::Permissions::from_mode(0o777)).unwrap();
461
462 let registry = discover_with_config(&config);
463
464 assert!(registry.state_error().is_some());
465 assert_eq!(fs::read(&config.state_path).unwrap(), state_body);
466 assert_eq!(
467 fs::metadata(state_parent).unwrap().permissions().mode() & 0o777,
468 0o777,
469 "read-only discovery must not repair directory permissions"
470 );
471 }
472
473 #[cfg(unix)]
474 #[test]
475 fn trust_rejects_an_existing_group_accessible_state_parent_without_repairing_it() {
476 use std::os::unix::fs::PermissionsExt as _;
477
478 let tmp = tempfile::tempdir().unwrap();
479 let config = config(tmp.path());
480 write_plugin(&config, "");
481 let state_parent = config.state_path.parent().unwrap();
482 fs::create_dir_all(state_parent).unwrap();
483 fs::set_permissions(state_parent, fs::Permissions::from_mode(0o777)).unwrap();
484 let mut registry = discover_with_config(&config);
485 assert!(registry.state_error().is_some());
486
487 assert!(registry.trust("demo").is_err());
488
489 assert_eq!(
490 fs::metadata(state_parent).unwrap().permissions().mode() & 0o777,
491 0o777,
492 "trust must not silently repair a pre-existing unsafe authority directory"
493 );
494 assert!(!config.state_path.exists());
495 }
496
497 #[cfg(unix)]
498 #[test]
499 fn discovery_rejects_a_symlinked_state_parent_without_touching_its_target() {
500 use std::os::unix::fs::{PermissionsExt as _, symlink};
501
502 let tmp = tempfile::tempdir().unwrap();
503 let config = config(tmp.path());
504 write_plugin(&config, "");
505 let target = tmp.path().join("state-target");
506 fs::create_dir(&target).unwrap();
507 fs::set_permissions(&target, fs::Permissions::from_mode(0o700)).unwrap();
508 let state_body = b"{\"schema_version\":1,\"plugins\":{}}\n";
509 fs::write(target.join("plugin-state.json"), state_body).unwrap();
510 symlink(&target, config.state_path.parent().unwrap()).unwrap();
511
512 let mut registry = discover_with_config(&config);
513
514 assert!(registry.state_error().is_some());
515 assert!(registry.trust("demo").is_err());
516 assert_eq!(
517 fs::read(target.join("plugin-state.json")).unwrap(),
518 state_body
519 );
520 assert!(
521 fs::symlink_metadata(config.state_path.parent().unwrap())
522 .unwrap()
523 .file_type()
524 .is_symlink(),
525 "discovery and trust must leave the state-parent link in place"
526 );
527 }
528
529 #[cfg(unix)]
530 #[test]
531 fn discovery_rejects_linked_state_and_lock_without_touching_targets() {
532 use std::os::unix::fs::{PermissionsExt as _, symlink};
533
534 for linked_entry in ["state", "lock"] {
535 let tmp = tempfile::tempdir().unwrap();
536 let config = config(tmp.path());
537 write_plugin(&config, "");
538 fs::create_dir_all(config.state_path.parent().unwrap()).unwrap();
539 fs::set_permissions(
540 config.state_path.parent().unwrap(),
541 fs::Permissions::from_mode(0o700),
542 )
543 .unwrap();
544 let target = tmp.path().join(format!("{linked_entry}-target"));
545 fs::write(&target, "{\"schema_version\":1,\"plugins\":{}}\n").unwrap();
546 fs::set_permissions(&target, fs::Permissions::from_mode(0o644)).unwrap();
547 let target_before = fs::read(&target).unwrap();
548 let target_mode = fs::metadata(&target).unwrap().permissions().mode();
549 if linked_entry == "state" {
550 symlink(&target, &config.state_path).unwrap();
551 } else {
552 fs::write(
553 &config.state_path,
554 "{\"schema_version\":1,\"plugins\":{}}\n",
555 )
556 .unwrap();
557 symlink(
558 &target,
559 config.state_path.with_file_name("plugin-state.json.lock"),
560 )
561 .unwrap();
562 }
563
564 let registry = discover_with_config(&config);
565 assert!(
566 registry.state_error().is_some(),
567 "linked {linked_entry} must fail closed"
568 );
569 assert_eq!(fs::read(&target).unwrap(), target_before);
570 assert_eq!(
571 fs::metadata(&target).unwrap().permissions().mode(),
572 target_mode
573 );
574 }
575 }
576
577 #[cfg(unix)]
578 #[test]
579 fn staging_rejects_root_swaps_symlinked_runtime_parents_and_hardlinks() {
580 use std::os::unix::fs::{PermissionsExt as _, symlink};
581
582 let tmp = tempfile::tempdir().unwrap();
583 let config = config(tmp.path());
584 let plugin = write_plugin(&config, "");
585 let mut swapped = discover_with_config(&config);
586 let original = plugin.with_file_name("demo-original");
587 fs::rename(&plugin, &original).unwrap();
588 let outside = tmp.path().join("outside");
589 fs::create_dir(&outside).unwrap();
590 fs::write(
591 outside.join("plugin.toml"),
592 "schema_version = 1\n[plugin]\nname = \"demo\"\nversion = \"1.0.0\"\n",
593 )
594 .unwrap();
595 symlink(&outside, &plugin).unwrap();
596 assert!(swapped.trust("demo").is_err());
597
598 fs::remove_file(&plugin).unwrap();
599 fs::rename(&original, &plugin).unwrap();
600 let mut parent_swap = discover_with_config(&config);
601 fs::create_dir_all(config.state_path.parent().unwrap()).unwrap();
602 fs::set_permissions(
603 config.state_path.parent().unwrap(),
604 fs::Permissions::from_mode(0o700),
605 )
606 .unwrap();
607 let runtime_root = config.state_path.parent().unwrap().join(".runtime");
608 if runtime_root.exists() {
609 fs::remove_dir_all(&runtime_root).unwrap();
610 }
611 let runtime_outside = tmp.path().join("runtime-outside");
612 fs::create_dir(&runtime_outside).unwrap();
613 symlink(&runtime_outside, &runtime_root).unwrap();
614 assert!(parent_swap.trust("demo").is_err());
615
616 fs::remove_file(&runtime_root).unwrap();
617 let external_file = tmp.path().join("external.txt");
618 fs::write(&external_file, "reviewed-looking content").unwrap();
619 fs::hard_link(&external_file, plugin.join("hardlinked.txt")).unwrap();
620 let mut hardlinked = discover_with_config(&config);
621 assert!(hardlinked.trust("demo").is_err());
622 }
623
624 #[test]
625 fn workspace_scoped_registries_do_not_cross_load_skills() {
626 let tmp = tempfile::tempdir().unwrap();
627 let left_config = config(&tmp.path().join("left"));
628 let right_config = config(&tmp.path().join("right"));
629 for (config, body) in [(&left_config, "left body"), (&right_config, "right body")] {
630 let plugin = write_plugin(config, "\n[skills]\npath = \"skills\"\n");
631 fs::create_dir_all(plugin.join("skills/only")).unwrap();
632 fs::write(
633 plugin.join("skills/only/SKILL.md"),
634 format!("---\nname: only\ndescription: scoped\n---\n{body}\n"),
635 )
636 .unwrap();
637 }
638 let mut left = discover_with_config(&left_config);
639 left.trust("demo").unwrap();
640 left.enable("demo").unwrap();
641 let mut right = discover_with_config(&right_config);
642 right.trust("demo").unwrap();
643 right.enable("demo").unwrap();
644
645 let left_skills =
646 crate::skills::discover_from_directories_with_plugins(Vec::<PathBuf>::new(), Some(&left));
647 let right_skills =
648 crate::skills::discover_from_directories_with_plugins(Vec::<PathBuf>::new(), Some(&right));
649 assert_eq!(left_skills.get("demo:only").unwrap().body, "left body");
650 assert_eq!(right_skills.get("demo:only").unwrap().body, "right body");
651 assert_ne!(left.workspace(), right.workspace());
652 }
653
654 // ─────────────────────────────────────────────────────────────────────────────
655 // Install on-ramp integration (#5182)
656 // ─────────────────────────────────────────────────────────────────────────────
657
658 fn block_on<F: std::future::Future>(future: F) -> F::Output {
659 tokio::runtime::Builder::new_multi_thread()
660 .worker_threads(2)
661 .enable_all()
662 .build()
663 .unwrap()
664 .block_on(future)
665 }
666
667 fn allow_all_network() -> crate::network_policy::NetworkPolicy {
668 crate::network_policy::NetworkPolicy {
669 default: crate::network_policy::DecisionToml::Allow,
670 ..Default::default()
671 }
672 }
673
674 fn write_install_source(root: &Path, name: &str) -> PathBuf {
675 let source = root.join(format!("source/{name}"));
676 fs::create_dir_all(source.join("skills/hello")).unwrap();
677 fs::write(
678 source.join("plugin.toml"),
679 format!(
680 "schema_version = 1\n[plugin]\nname = {name:?}\nversion = \"1.0.0\"\n[skills]\npath = \"skills\"\n"
681 ),
682 )
683 .unwrap();
684 fs::write(
685 source.join("skills/hello/SKILL.md"),
686 "---\nname: hello\ndescription: hi\n---\nbody\n",
687 )
688 .unwrap();
689 source
690 }
691
692 #[test]
693 fn installed_bundles_land_disabled_and_untrusted_then_follow_the_trust_flow() {
694 let tmp = tempfile::tempdir().unwrap();
695 let config = config(tmp.path());
696 let source = write_install_source(tmp.path(), "demo");
697 let network = allow_all_network();
698
699 let outcome = block_on(super::install::install(
700 super::install::PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
701 &config.user_plugins_dir,
702 super::install::DEFAULT_MAX_SIZE_BYTES,
703 &network,
704 false,
705 &|_| None,
706 ))
707 .unwrap();
708 assert!(
709 matches!(outcome, super::install::PluginInstallOutcome::Installed(_)),
710 "local install must succeed"
711 );
712 assert!(
713 config
714 .user_plugins_dir
715 .join("demo")
716 .join(super::install::INSTALLED_FROM_MARKER)
717 .exists()
718 );
719
720 // The discovery invariant: freshly installed bits are disabled + untrusted.
721 let mut registry = discover_with_config(&config);
722 let plugin = registry.get("demo").unwrap();
723 assert!(!plugin.enabled);
724 assert!(!plugin.trusted());
725 assert!(registry.enable("demo").is_err());
726
727 registry.trust("demo").unwrap();
728 registry.enable("demo").unwrap();
729 assert!(registry.is_active("demo"));
730 assert_eq!(
731 registry
732 .get("demo")
733 .unwrap()
734 .skill_snapshots
735 .first()
736 .map(|snapshot| snapshot.name.as_str()),
737 Some("hello")
738 );
739 }
740
741 #[test]
742 fn mutation_uninstall_requires_disabled_then_deletes_bits_and_prunes_state() {
743 let tmp = tempfile::tempdir().unwrap();
744 let config = config(tmp.path());
745 let source = write_install_source(tmp.path(), "demo");
746 let network = allow_all_network();
747
748 let mut registry = discover_with_config(&config);
749 let ctx = super::mutation::PluginMutationContext {
750 network: &network,
751 max_size: super::install::DEFAULT_MAX_SIZE_BYTES,
752 };
753 let receipt = block_on(super::mutation::execute(
754 super::mutation::PluginMutationRequest::Install {
755 source: super::install::PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
756 },
757 &ctx,
758 &mut registry,
759 ))
760 .unwrap();
761 assert_eq!(
762 receipt.outcome,
763 super::mutation::PluginMutationOutcome::Installed
764 );
765
766 let mut registry = discover_with_config(&config);
767 registry.trust("demo").unwrap();
768 registry.enable("demo").unwrap();
769
770 // Enabled bundles are refused before anything is deleted.
771 let refused = block_on(super::mutation::execute(
772 super::mutation::PluginMutationRequest::Uninstall {
773 selector: "demo".to_string(),
774 },
775 &ctx,
776 &mut registry,
777 ));
778 assert!(refused.is_err(), "uninstall must require disabled");
779 assert!(config.user_plugins_dir.join("demo").exists());
780
781 registry.disable("demo").unwrap();
782 let receipt = block_on(super::mutation::execute(
783 super::mutation::PluginMutationRequest::Uninstall {
784 selector: "demo".to_string(),
785 },
786 &ctx,
787 &mut registry,
788 ))
789 .unwrap();
790 assert_eq!(
791 receipt.outcome,
792 super::mutation::PluginMutationOutcome::Uninstalled
793 );
794 assert!(!config.user_plugins_dir.join("demo").exists());
795
796 let rediscovered = discover_with_config(&config);
797 assert!(rediscovered.is_empty());
798 let raw = fs::read_to_string(&config.state_path).unwrap();
799 let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
800 assert!(
801 parsed["plugins"].as_object().unwrap().is_empty(),
802 "state entry must be pruned: {raw}"
803 );
804 }
805
806 #[test]
807 fn mutation_install_rejects_names_claimed_by_other_scopes() {
808 let tmp = tempfile::tempdir().unwrap();
809 let config = config(tmp.path());
810 // A hand-placed workspace bundle already owns the name `demo`.
811 let workspace_bundle = config.workspace_plugins_dir.join("demo");
812 fs::create_dir_all(&workspace_bundle).unwrap();
813 fs::write(
814 workspace_bundle.join("plugin.toml"),
815 "schema_version = 1\n[plugin]\nname = \"demo\"\nversion = \"1.0.0\"\n",
816 )
817 .unwrap();
818 let source = write_install_source(tmp.path(), "demo");
819 let network = allow_all_network();
820
821 let mut registry = discover_with_config(&config);
822 let ctx = super::mutation::PluginMutationContext {
823 network: &network,
824 max_size: super::install::DEFAULT_MAX_SIZE_BYTES,
825 };
826 let err = block_on(super::mutation::execute(
827 super::mutation::PluginMutationRequest::Install {
828 source: super::install::PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
829 },
830 &ctx,
831 &mut registry,
832 ))
833 .unwrap_err();
834 assert!(
835 format!("{err:#}").contains("already used by the workspace bundle"),
836 "got: {err:#}"
837 );
838 assert!(!config.user_plugins_dir.join("demo").exists());
839 }
840
841 #[test]
842 fn mutation_update_refuses_local_installs_and_foreign_scopes() {
843 let tmp = tempfile::tempdir().unwrap();
844 let config = config(tmp.path());
845 let source = write_install_source(tmp.path(), "demo");
846 let network = allow_all_network();
847
848 let mut registry = discover_with_config(&config);
849 let ctx = super::mutation::PluginMutationContext {
850 network: &network,
851 max_size: super::install::DEFAULT_MAX_SIZE_BYTES,
852 };
853 block_on(super::mutation::execute(
854 super::mutation::PluginMutationRequest::Install {
855 source: super::install::PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
856 },
857 &ctx,
858 &mut registry,
859 ))
860 .unwrap();
861
862 let mut registry = discover_with_config(&config);
863 let err = block_on(super::mutation::execute(
864 super::mutation::PluginMutationRequest::Update {
865 selector: "demo".to_string(),
866 },
867 &ctx,
868 &mut registry,
869 ))
870 .unwrap_err();
871 assert!(format!("{err:#}").contains("local path"), "got: {err:#}");
872
873 let err = block_on(super::mutation::execute(
874 super::mutation::PluginMutationRequest::Update {
875 selector: "missing".to_string(),
876 },
877 &ctx,
878 &mut registry,
879 ))
880 .unwrap_err();
881 assert!(format!("{err:#}").contains("was not found"), "got: {err:#}");
882 }
883
883 lines RUST