返回 CodeWhale
discovery.rs
根目录 / crates / tui / src / plugins / discovery.rs
1 use std::collections::{BTreeMap, BTreeSet, HashSet};
2 use std::fs;
3 use std::path::{Path, PathBuf};
4 use std::time::SystemTime;
5
6 use sha2::{Digest, Sha256};
7
8 use super::agent_plugin::resolve_manifest_path;
9 use super::manifest::{PluginManifest, ValidatedManifest};
10 use super::path_identity::metadata_is_link_or_reparse;
11 use super::registry::PluginRegistry;
12 use super::types::{
13 LoadedPlugin, PluginDiagnostic, PluginId, PluginOrigin, PluginScope, PluginSkillSnapshot,
14 PluginTrustStatus,
15 };
16
17 #[derive(Debug, Clone)]
18 pub struct DiscoveryConfig {
19 pub workspace: PathBuf,
20 pub user_plugins_dir: PathBuf,
21 pub workspace_plugins_dir: PathBuf,
22 pub builtin_plugin_dirs: Vec<PathBuf>,
23 pub state_path: PathBuf,
24 }
25
26 /// Cheap on-disk catalog fingerprint: plugin-bundle directories only.
27 ///
28 /// Used to nudge `/plugin reload` when a bundle appears, disappears, or is
29 /// rewritten without consulting process environment or auto-applying trust.
30 /// `state.json` writes are ignored so enable/trust does not look like a
31 /// catalog change.
32 #[derive(Debug, Clone, PartialEq, Eq, Default)]
33 pub struct PluginCatalogStamp {
34 entries: BTreeMap<PathBuf, Option<SystemTime>>,
35 }
36
37 impl PluginCatalogStamp {
38 #[must_use]
39 pub fn capture(roots: impl IntoIterator<Item = PathBuf>) -> Self {
40 let mut entries = BTreeMap::new();
41 for root in roots {
42 let Ok(read) = fs::read_dir(&root) else {
43 continue;
44 };
45 for entry in read.flatten() {
46 let path = entry.path();
47 let Ok(metadata) = fs::symlink_metadata(&path) else {
48 continue;
49 };
50 if metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() {
51 continue;
52 }
53 entries.insert(path, metadata.modified().ok());
54 }
55 }
56 Self { entries }
57 }
58 }
59
60 #[must_use]
61 pub fn default_user_plugins_dir() -> PathBuf {
62 codewhale_config::codewhale_home()
63 .map(|path| path.join("plugins"))
64 .unwrap_or_else(|error| {
65 // Never fall back to a shared, predictable temporary directory:
66 // that would turn a home-resolution failure into ambient plugin
67 // discovery. A fresh nonexistent sentinel keeps startup read-only
68 // and fail-closed on every supported platform.
69 tracing::warn!(
70 target: "plugins",
71 %error,
72 "Codewhale home could not be resolved; user plugin discovery is disabled"
73 );
74 std::env::temp_dir()
75 .join(format!(
76 ".codewhale-home-unavailable-{}",
77 uuid::Uuid::new_v4().simple()
78 ))
79 .join("plugins")
80 })
81 }
82
83 #[must_use]
84 pub fn default_workspace_plugins_dir(workspace: &Path) -> PathBuf {
85 workspace.join(".codewhale").join("plugins")
86 }
87
88 #[cfg(test)]
89 #[must_use]
90 pub fn discover_with_config(config: &DiscoveryConfig) -> PluginRegistry {
91 let context = super::context::PluginDiscoveryContext::from_config_and_environment(
92 config,
93 super::context::HostEnvironment::capture(),
94 );
95 discover_with_context(config, context)
96 }
97
98 #[must_use]
99 pub(crate) fn discover_with_context(
100 config: &DiscoveryConfig,
101 context: std::sync::Arc<super::context::PluginDiscoveryContext>,
102 ) -> PluginRegistry {
103 let mut diagnostics = Vec::new();
104 let mut candidates = Vec::new();
105
106 for root in &config.builtin_plugin_dirs {
107 scan_root(
108 root,
109 PluginScope::Builtin,
110 PluginOrigin::Builtin,
111 &mut candidates,
112 &mut diagnostics,
113 );
114 }
115 scan_root(
116 &config.user_plugins_dir,
117 PluginScope::User,
118 PluginOrigin::CodeWhaleHome,
119 &mut candidates,
120 &mut diagnostics,
121 );
122 scan_root(
123 &config.workspace_plugins_dir,
124 PluginScope::Workspace,
125 PluginOrigin::Workspace,
126 &mut candidates,
127 &mut diagnostics,
128 );
129
130 candidates.sort_by(|left, right| {
131 left.scope
132 .cmp(&right.scope)
133 .then_with(|| left.name().cmp(right.name()))
134 .then_with(|| left.canonical_root.cmp(&right.canonical_root))
135 });
136
137 let mut seen_roots = HashSet::new();
138 let mut seen_names = BTreeSet::new();
139 let mut plugins = Vec::new();
140 for plugin in candidates {
141 if !seen_roots.insert(plugin.canonical_root.clone()) {
142 diagnostics.push(PluginDiagnostic::warning(
143 "duplicate-root",
144 format!(
145 "Ignoring duplicate plugin discovery at {}",
146 plugin.canonical_root.display()
147 ),
148 Some(plugin.canonical_root.clone()),
149 ));
150 continue;
151 }
152 if !seen_names.insert(plugin.name().to_string()) {
153 diagnostics.push(PluginDiagnostic::warning(
154 "name-conflict",
155 format!(
156 "Plugin `{}` at {} is shadowed by the higher-precedence bundle with the same name",
157 plugin.name(),
158 plugin.canonical_root.display()
159 ),
160 Some(plugin.canonical_root.clone()),
161 ));
162 continue;
163 }
164 plugins.push(plugin);
165 }
166
167 PluginRegistry::from_discovery(
168 plugins,
169 diagnostics,
170 config.state_path.clone(),
171 config.workspace.clone(),
172 Some(context),
173 )
174 }
175
176 fn scan_root(
177 root: &Path,
178 scope: PluginScope,
179 origin: PluginOrigin,
180 plugins: &mut Vec<LoadedPlugin>,
181 diagnostics: &mut Vec<PluginDiagnostic>,
182 ) {
183 let Ok(metadata) = fs::symlink_metadata(root) else {
184 return;
185 };
186 if metadata_is_link_or_reparse(&metadata) {
187 diagnostics.push(PluginDiagnostic::error(
188 "root-symlink",
189 format!(
190 "Plugin discovery root may not be a symbolic link or reparse point: {}",
191 root.display()
192 ),
193 Some(root.to_path_buf()),
194 ));
195 return;
196 }
197 if !metadata.is_dir() {
198 diagnostics.push(PluginDiagnostic::error(
199 "root-not-directory",
200 format!(
201 "Plugin discovery root is not a directory: {}",
202 root.display()
203 ),
204 Some(root.to_path_buf()),
205 ));
206 return;
207 }
208 let canonical_discovery_root = match root.canonicalize() {
209 Ok(root) => root,
210 Err(error) => {
211 diagnostics.push(PluginDiagnostic::error(
212 "root-canonicalize-failed",
213 format!(
214 "Failed to canonicalize plugin root {}: {error}",
215 root.display()
216 ),
217 Some(root.to_path_buf()),
218 ));
219 return;
220 }
221 };
222
223 let mut entries = match fs::read_dir(root) {
224 Ok(entries) => match entries.collect::<Result<Vec<_>, _>>() {
225 Ok(entries) => entries,
226 Err(error) => {
227 diagnostics.push(PluginDiagnostic::error(
228 "root-read-failed",
229 format!("Failed to read plugin root {}: {error}", root.display()),
230 Some(root.to_path_buf()),
231 ));
232 return;
233 }
234 },
235 Err(error) => {
236 diagnostics.push(PluginDiagnostic::error(
237 "root-read-failed",
238 format!("Failed to read plugin root {}: {error}", root.display()),
239 Some(root.to_path_buf()),
240 ));
241 return;
242 }
243 };
244 entries.sort_by_key(fs::DirEntry::file_name);
245
246 for entry in entries {
247 let plugin_root = entry.path();
248 let Ok(metadata) = fs::symlink_metadata(&plugin_root) else {
249 continue;
250 };
251 if metadata_is_link_or_reparse(&metadata) {
252 diagnostics.push(PluginDiagnostic::error(
253 "bundle-symlink",
254 format!(
255 "Plugin bundle directory may not be a symbolic link or reparse point: {}",
256 plugin_root.display()
257 ),
258 Some(plugin_root),
259 ));
260 continue;
261 }
262 if !metadata.is_dir() {
263 continue;
264 }
265 // Agent Plugins v1.0.0 `plugin.json` is preferred; a legacy
266 // `plugin.toml` in the same bundle stays readable.
267 let Some(manifest_path) = resolve_manifest_path(&plugin_root) else {
268 continue;
269 };
270 match load_plugin(&manifest_path, &canonical_discovery_root, scope, origin) {
271 Ok(plugin) => plugins.push(plugin),
272 Err(error) => diagnostics.push(PluginDiagnostic::error(
273 "manifest-invalid",
274 error,
275 Some(manifest_path),
276 )),
277 }
278 }
279 }
280
281 fn load_plugin(
282 manifest_path: &Path,
283 canonical_discovery_root: &Path,
284 scope: PluginScope,
285 origin: PluginOrigin,
286 ) -> Result<LoadedPlugin, String> {
287 let validated = PluginManifest::validate_from_path(manifest_path)?;
288 if validated.canonical_root.parent() != Some(canonical_discovery_root) {
289 return Err(format!(
290 "plugin bundle resolved outside its Codewhale-owned discovery root: {}",
291 validated.canonical_root.display()
292 ));
293 }
294 let id = plugin_id(
295 scope,
296 &validated.manifest.plugin.name,
297 &validated.canonical_root,
298 );
299 let mut diagnostics = validated
300 .warnings
301 .iter()
302 .map(|warning| {
303 PluginDiagnostic::warning(
304 "manifest-legacy",
305 warning.clone(),
306 Some(manifest_path.to_path_buf()),
307 )
308 })
309 .collect::<Vec<_>>();
310
311 let (skill_snapshots, skill_diagnostics) = parse_skill_snapshots(&validated)?;
312 diagnostics.extend(skill_diagnostics);
313
314 let unsupported = validated.inventory.unsupported_labels();
315 if !unsupported.is_empty() {
316 diagnostics.push(PluginDiagnostic::warning(
317 "component-inactive",
318 format!(
319 "compatibility {}; inactive components remain inventoried but are not activated: {}",
320 validated.inventory.compatibility().as_str(),
321 unsupported.join(", ")
322 ),
323 Some(manifest_path.to_path_buf()),
324 ));
325 }
326 // Under the extension-host policy a `native` entry is executable host
327 // code. Report invalid entries during validation and review, before
328 // activation refuses the bundle because it has an error diagnostic.
329 if super::activation::PluginActivationPolicy::current()
330 .is_supported(super::activation::PluginActivationCapability::Native)
331 {
332 for entry in &validated.components.native {
333 // Every regular bundle file is hashed; a directory is not.
334 let is_regular_file = entry
335 .strip_prefix(&validated.canonical_root)
336 .is_ok_and(|relative| validated.file_hashes.contains_key(relative));
337 if let Some(problem) = super::runtime::native_entry_problem(entry, is_regular_file) {
338 diagnostics.push(PluginDiagnostic::error(
339 "native-entry-invalid",
340 format!("native entry {}: {problem}", entry.display()),
341 Some(entry.clone()),
342 ));
343 }
344 }
345 }
346
347 // Skill parsing happens after hashing. Revalidate once so a concurrent
348 // bundle edit cannot pair a reviewed hash with different in-memory Skill
349 // instructions or MCP configuration. Active Skill bodies are replaced by
350 // snapshots parsed from the Codewhale-owned staged tree in `apply_state`.
351 let refreshed = PluginManifest::validate_from_path(manifest_path)?;
352 if refreshed.content_hash != validated.content_hash
353 || refreshed.capability_hash != validated.capability_hash
354 {
355 return Err(format!(
356 "plugin `{}` changed during discovery; reload and review the stable bundle",
357 validated.manifest.plugin.name
358 ));
359 }
360 let validated = refreshed;
361
362 Ok(LoadedPlugin {
363 id,
364 manifest: validated.manifest,
365 base_path: validated.canonical_root.clone(),
366 canonical_root: validated.canonical_root,
367 staged_root: None,
368 scope,
369 origin,
370 enabled: false,
371 trust_status: PluginTrustStatus::NeverReviewed,
372 applicable: validated.applicable,
373 inventory: validated.inventory,
374 components: validated.components,
375 content_hash: validated.content_hash,
376 capability_hash: validated.capability_hash,
377 state_generation: 0,
378 skill_snapshots,
379 diagnostics,
380 })
381 }
382
383 fn parse_skill_snapshots(
384 validated: &ValidatedManifest,
385 ) -> Result<(Vec<PluginSkillSnapshot>, Vec<PluginDiagnostic>), String> {
386 parse_skill_snapshots_for_roots(validated, &validated.components.skills, false)
387 }
388
389 /// One parser for declared and programmable roots. Only the byte inventory
390 /// that produced the reviewed bundle hash chooses files; discovery never
391 /// parses a mutable/unreviewed file before its receipt has been checked.
392 fn parse_skill_snapshots_for_roots(
393 validated: &ValidatedManifest,
394 roots: &[PathBuf],
395 bounded_native: bool,
396 ) -> Result<(Vec<PluginSkillSnapshot>, Vec<PluginDiagnostic>), String> {
397 let mut diagnostics = Vec::new();
398 let mut skill_snapshots = Vec::new();
399 let mut parsed_files = 0usize;
400 let mut parsed_bytes = 0usize;
401 for root in roots {
402 let root_relative = root
403 .strip_prefix(&validated.canonical_root)
404 .map_err(|_| "Skill root escaped the reviewed bundle".to_string())?;
405 let mut candidates: Vec<_> = validated
406 .file_hashes
407 .iter()
408 .filter(|(path, _)| {
409 let Ok(relative) = path.strip_prefix(root_relative) else {
410 return false;
411 };
412 let components: Vec<_> = relative.components().collect();
413 path.file_name().is_some_and(|name| name == "SKILL.md")
414 && components.len() >= 2
415 && components.len() - 2 <= crate::skills::SkillRegistry::MAX_DISCOVERY_DEPTH
416 && components[..components.len() - 1].iter().all(|component| {
417 !component
418 .as_os_str()
419 .to_str()
420 .is_some_and(|name| name.starts_with('.'))
421 })
422 })
423 .collect();
424 candidates.sort_by(|(a, _), (b, _)| {
425 a.components()
426 .count()
427 .cmp(&b.components().count())
428 .then_with(|| a.cmp(b))
429 });
430 let mut claimed = BTreeSet::<PathBuf>::new();
431 for (relative, expected_hash) in candidates {
432 let directory = relative.parent().expect("SKILL.md has a parent");
433 if claimed.iter().any(|parent| directory.starts_with(parent)) {
434 continue;
435 }
436 // Even an invalid parent Skill claims its package, so nested
437 // examples cannot become independent instructions.
438 claimed.insert(directory.to_path_buf());
439 if bounded_native && parsed_files >= crate::extension_host::skills::MAX_SKILLS_PER_OWNER
440 {
441 return Err("skill root exceeds the bounded candidate count".to_string());
442 }
443 parsed_files += 1;
444 let path = validated.canonical_root.join(relative);
445 let bytes = read_skill_bytes(&validated.canonical_root, &path)?;
446 parsed_bytes = parsed_bytes.saturating_add(bytes.len());
447 if bounded_native && parsed_bytes > crate::extension_host::skills::MAX_BYTES_PER_OWNER {
448 return Err("skill root exceeds the bounded instruction byte limit".to_string());
449 }
450 let actual_hash = hash_skill_bytes(&bytes);
451 if &actual_hash != expected_hash {
452 return Err("plugin Skill changed between review hashing and parsing".to_string());
453 }
454 let content = std::str::from_utf8(&bytes)
455 .map_err(|_| "plugin Skill must be valid UTF-8".to_string())?;
456 let (skill, warnings) =
457 match crate::skills::SkillRegistry::parse_verified_content(&path, content) {
458 Ok(parsed) => parsed,
459 Err(reason) => {
460 diagnostics.push(PluginDiagnostic::warning(
461 "skill-invalid",
462 reason,
463 Some(path),
464 ));
465 continue;
466 }
467 };
468 for warning in warnings {
469 diagnostics.push(PluginDiagnostic::warning(
470 "skill-invalid",
471 warning,
472 Some(path.clone()),
473 ));
474 }
475 skill_snapshots.push(PluginSkillSnapshot {
476 name: skill.name,
477 legacy_activation_name: skill.legacy_activation_name,
478 description: skill.description,
479 localized_descriptions: skill.localized_descriptions,
480 invocation: skill.invocation,
481 aliases: skill.aliases,
482 argument_hint: skill.argument_hint,
483 body: skill.body,
484 path,
485 source_hash: actual_hash,
486 });
487 }
488 }
489 skill_snapshots.sort_by(|left, right| {
490 left.name
491 .cmp(&right.name)
492 .then_with(|| left.path.cmp(&right.path))
493 });
494 let mut seen_skills = BTreeSet::new();
495 for skill in &skill_snapshots {
496 if !seen_skills.insert(skill.name.clone()) {
497 return Err(format!(
498 "plugin `{}` declares duplicate skill name `{}`",
499 validated.manifest.plugin.name, skill.name
500 ));
501 }
502 }
503 Ok((skill_snapshots, diagnostics))
504 }
505
506 fn read_skill_bytes(root: &Path, path: &Path) -> Result<Vec<u8>, String> {
507 use std::io::Read as _;
508 let file = crate::fs_confined::open_read(root, path)
509 .map_err(|e| format!("failed to open plugin Skill without following links: {e}"))?;
510 let mut bytes = Vec::new();
511 file.take(1024 * 1024 + 1)
512 .read_to_end(&mut bytes)
513 .map_err(|e| format!("failed to read plugin Skill: {e}"))?;
514 if bytes.len() > 1024 * 1024 {
515 return Err("plugin Skill exceeds the one-megabyte parse limit".to_string());
516 }
517 Ok(bytes)
518 }
519
520 fn hash_skill_bytes(bytes: &[u8]) -> String {
521 let mut hasher = Sha256::new();
522 hasher.update(b"codewhale-plugin-file-bytes-v1\0");
523 hasher.update(bytes);
524 hasher
525 .finalize()
526 .iter()
527 .map(|byte| format!("{byte:02x}"))
528 .collect()
529 }
530
531 pub(crate) fn load_staged_skill_snapshots(
532 staged_root: &Path,
533 expected_content_hash: &str,
534 expected_capability_hash: &str,
535 ) -> Result<Vec<PluginSkillSnapshot>, String> {
536 load_staged_skill_snapshots_with_roots(
537 staged_root,
538 expected_content_hash,
539 expected_capability_hash,
540 None,
541 )
542 }
543
544 pub(crate) fn load_staged_skill_root_snapshots(
545 authority: &super::types::PluginAuthority,
546 relative: &Path,
547 ) -> Result<Vec<PluginSkillSnapshot>, String> {
548 let staged_root = super::agent_plugin::plugin_root_for_manifest(&authority.staged_manifest)
549 .ok_or_else(|| "reviewed plugin has no staged root".to_string())?;
550 let root = staged_root.join(relative);
551 crate::fleet::files::reject_linked_path(staged_root, &root).map_err(|e| e.to_string())?;
552 if !root.is_dir() {
553 return Err("skill root must be a directory inside the reviewed bundle".to_string());
554 }
555 let snapshots = load_staged_skill_snapshots_with_roots(
556 staged_root,
557 &authority.content_hash,
558 &authority.capability_hash,
559 Some(&[root]),
560 )?;
561 if snapshots.is_empty() {
562 return Err("skill root contains no valid reviewed Skills".to_string());
563 }
564 Ok(snapshots)
565 }
566
567 fn load_staged_skill_snapshots_with_roots(
568 staged_root: &Path,
569 expected_content_hash: &str,
570 expected_capability_hash: &str,
571 roots: Option<&[PathBuf]>,
572 ) -> Result<Vec<PluginSkillSnapshot>, String> {
573 let staged_manifest = resolve_manifest_path(staged_root)
574 .ok_or_else(|| "staged plugin has no plugin.json, .claude-plugin/plugin.json, kimi.plugin.json, or plugin.toml".to_string())?;
575 let validated = PluginManifest::validate_from_path(&staged_manifest)?;
576 if validated.canonical_root != staged_root
577 || validated.content_hash != expected_content_hash
578 || validated.capability_hash != expected_capability_hash
579 {
580 return Err("staged plugin Skill snapshot no longer matches reviewed content".to_string());
581 }
582 let (snapshots, diagnostics) = match roots {
583 Some(roots) => parse_skill_snapshots_for_roots(&validated, roots, true)?,
584 None => parse_skill_snapshots(&validated)?,
585 };
586 if let Some(diagnostic) = diagnostics.first() {
587 return Err(format!(
588 "staged plugin Skill snapshot is invalid: {}",
589 diagnostic.message
590 ));
591 }
592 // Parsing is explicitly bound to the first validation's file inventory;
593 // a final whole-bundle pass also rejects additions/removals/config drift
594 // that occurred during directory traversal.
595 let refreshed = PluginManifest::validate_from_path(&staged_manifest)?;
596 if refreshed.content_hash != validated.content_hash
597 || refreshed.capability_hash != validated.capability_hash
598 || refreshed.file_hashes != validated.file_hashes
599 {
600 return Err("staged plugin changed while its Skill snapshots were parsed".to_string());
601 }
602 Ok(snapshots)
603 }
604
605 fn plugin_id(scope: PluginScope, name: &str, canonical_root: &Path) -> PluginId {
606 let mut hasher = Sha256::new();
607 // v2 intentionally invalidates receipts produced by the former lossy
608 // Unicode path identity.
609 hasher.update(b"codewhale-plugin-id-v2\0");
610 hasher.update(scope.as_str().as_bytes());
611 hasher.update(b"\0");
612 super::path_identity::hash_os_path(&mut hasher, b"canonical-plugin-root", canonical_root);
613 let digest = hasher.finalize();
614 let suffix = digest[..6]
615 .iter()
616 .map(|byte| format!("{byte:02x}"))
617 .collect::<String>();
618 PluginId(format!("{}/{suffix}/{name}", scope.as_str()))
619 }
620
621 #[cfg(test)]
622 mod tests {
623 use super::*;
624
625 fn write_plugin(root: &Path, dir: &str, name: &str) -> PathBuf {
626 let plugin = root.join(dir);
627 fs::create_dir_all(&plugin).unwrap();
628 fs::write(
629 plugin.join("plugin.toml"),
630 format!("schema_version = 1\n[plugin]\nname = {name:?}\nversion = \"1.0.0\"\n"),
631 )
632 .unwrap();
633 plugin
634 }
635
636 fn config(tmp: &Path) -> DiscoveryConfig {
637 DiscoveryConfig {
638 workspace: tmp.join("project"),
639 user_plugins_dir: tmp.join("user"),
640 workspace_plugins_dir: tmp.join("workspace"),
641 builtin_plugin_dirs: vec![tmp.join("builtin")],
642 state_path: tmp.join("state.json"),
643 }
644 }
645
646 #[test]
647 fn user_and_workspace_bundles_are_disabled_and_untrusted_by_default() {
648 let tmp = tempfile::tempdir().unwrap();
649 let cfg = config(tmp.path());
650 write_plugin(&cfg.user_plugins_dir, "a", "user-plugin");
651 write_plugin(&cfg.workspace_plugins_dir, "b", "workspace-plugin");
652
653 let registry = discover_with_config(&cfg);
654 assert_eq!(registry.len(), 2);
655 assert!(registry.list().iter().all(|plugin| !plugin.enabled));
656 assert!(registry.list().iter().all(|plugin| !plugin.trusted()));
657 assert!(!cfg.state_path.exists(), "discovery must be read-only");
658 }
659
660 #[test]
661 fn precedence_is_builtin_then_user_then_workspace() {
662 let tmp = tempfile::tempdir().unwrap();
663 let cfg = config(tmp.path());
664 write_plugin(&cfg.builtin_plugin_dirs[0], "z", "same");
665 write_plugin(&cfg.user_plugins_dir, "a", "same");
666 write_plugin(&cfg.workspace_plugins_dir, "b", "same");
667
668 let registry = discover_with_config(&cfg);
669 assert_eq!(registry.len(), 1);
670 assert_eq!(registry.get("same").unwrap().scope, PluginScope::Builtin);
671 assert_eq!(
672 registry
673 .diagnostics()
674 .iter()
675 .filter(|diagnostic| diagnostic.code == "name-conflict")
676 .count(),
677 2
678 );
679 }
680
681 #[test]
682 fn discovery_is_sorted_and_plugin_ids_are_deterministic() {
683 let tmp = tempfile::tempdir().unwrap();
684 let cfg = config(tmp.path());
685 write_plugin(&cfg.user_plugins_dir, "z", "zulu");
686 write_plugin(&cfg.user_plugins_dir, "a", "alpha");
687
688 let first = discover_with_config(&cfg);
689 let second = discover_with_config(&cfg);
690 let names = first
691 .list()
692 .iter()
693 .map(|plugin| plugin.name())
694 .collect::<Vec<_>>();
695 assert_eq!(names, vec!["alpha", "zulu"]);
696 assert_eq!(
697 first.get("alpha").unwrap().id,
698 second.get("alpha").unwrap().id
699 );
700 }
701
702 #[cfg(unix)]
703 #[test]
704 fn plugin_ids_distinguish_lossy_colliding_native_roots() {
705 use std::ffi::OsString;
706 use std::os::unix::ffi::OsStringExt as _;
707
708 let tmp = tempfile::tempdir().unwrap();
709 let left_name = OsString::from_vec(vec![b'p', 0xff]);
710 let right_name = OsString::from_vec(vec![b'p', 0xfe]);
711 assert_eq!(left_name.to_string_lossy(), right_name.to_string_lossy());
712 let left = tmp.path().join(left_name);
713 let right = tmp.path().join(right_name);
714 assert_ne!(
715 plugin_id(PluginScope::User, "same", &left),
716 plugin_id(PluginScope::User, "same", &right)
717 );
718 }
719
720 #[cfg(unix)]
721 #[test]
722 fn symlinked_discovery_root_fails_closed() {
723 use std::os::unix::fs::symlink;
724
725 let tmp = tempfile::tempdir().unwrap();
726 let outside = tempfile::tempdir().unwrap();
727 write_plugin(outside.path(), "a", "outside");
728 let cfg = config(tmp.path());
729 symlink(outside.path(), &cfg.workspace_plugins_dir).unwrap();
730
731 let registry = discover_with_config(&cfg);
732 assert!(registry.is_empty());
733 assert!(
734 registry
735 .diagnostics()
736 .iter()
737 .any(|diagnostic| diagnostic.code == "root-symlink")
738 );
739 }
740
741 #[cfg(windows)]
742 fn create_junction(link: &Path, target: &Path) {
743 let output = std::process::Command::new("cmd")
744 .args(["/C", "mklink", "/J"])
745 .arg(link)
746 .arg(target)
747 .output()
748 .expect("invoke Windows junction creation");
749 assert!(
750 output.status.success(),
751 "failed to create junction: stdout={} stderr={}",
752 String::from_utf8_lossy(&output.stdout),
753 String::from_utf8_lossy(&output.stderr)
754 );
755 }
756
757 #[cfg(windows)]
758 #[test]
759 fn junction_discovery_root_fails_closed() {
760 let tmp = tempfile::tempdir().unwrap();
761 let outside = tempfile::tempdir().unwrap();
762 write_plugin(outside.path(), "a", "outside");
763 let cfg = config(tmp.path());
764 create_junction(&cfg.workspace_plugins_dir, outside.path());
765
766 let registry = discover_with_config(&cfg);
767 assert!(registry.is_empty());
768 assert!(
769 registry
770 .diagnostics()
771 .iter()
772 .any(|diagnostic| diagnostic.code == "root-symlink")
773 );
774 }
775
776 #[cfg(windows)]
777 #[test]
778 fn junction_bundle_entry_fails_closed() {
779 let tmp = tempfile::tempdir().unwrap();
780 let outside = tempfile::tempdir().unwrap();
781 let target = write_plugin(outside.path(), "actual", "outside");
782 let cfg = config(tmp.path());
783 fs::create_dir_all(&cfg.workspace_plugins_dir).unwrap();
784 create_junction(&cfg.workspace_plugins_dir.join("linked"), &target);
785
786 let registry = discover_with_config(&cfg);
787 assert!(registry.is_empty());
788 assert!(
789 registry
790 .diagnostics()
791 .iter()
792 .any(|diagnostic| diagnostic.code == "bundle-symlink")
793 );
794 }
795
796 #[test]
797 fn discovery_ignores_ambient_compatibility_roots() {
798 let _lock = crate::test_support::lock_test_env();
799 let tmp = tempfile::tempdir().unwrap();
800 let home = tmp.path().join("home");
801 let workspace = tmp.path().join("workspace");
802 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
803 write_plugin(
804 &workspace.join(".claude/plugins"),
805 "ambient",
806 "ambient-plugin",
807 );
808 write_plugin(
809 &workspace.join(".cursor/plugins"),
810 "ambient",
811 "cursor-plugin",
812 );
813
814 let discovery = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv();
815 let registry = discovery.registry_for_workspace(&workspace);
816 assert!(registry.get("ambient-plugin").is_none());
817 assert!(registry.get("cursor-plugin").is_none());
818 // Only what Codewhale itself ships is present: no ambient root loaded.
819 assert_eq!(
820 registry
821 .list()
822 .iter()
823 .filter(|plugin| plugin.scope != PluginScope::Builtin)
824 .count(),
825 0
826 );
827 assert!(!home.join("plugins/state.json").exists());
828 }
829 }
830
830 lines RUST