返回 CodeWhale
discovery.rs
根目录 / crates / tui / src / plugins / discovery.rs
1 use std::collections::{BTreeSet, HashSet};
2 use std::fs;
3 use std::path::{Path, PathBuf};
4
5 use sha2::{Digest, Sha256};
6
7 use super::manifest::{PluginManifest, ValidatedManifest};
8 use super::path_identity::metadata_is_link_or_reparse;
9 use super::registry::PluginRegistry;
10 use super::types::{
11 LoadedPlugin, PluginDiagnostic, PluginId, PluginOrigin, PluginScope, PluginSkillSnapshot,
12 PluginTrustStatus,
13 };
14
15 const PLUGIN_MANIFEST: &str = "plugin.toml";
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 #[must_use]
27 pub fn default_user_plugins_dir() -> PathBuf {
28 codewhale_config::codewhale_home()
29 .map(|path| path.join("plugins"))
30 .unwrap_or_else(|error| {
31 // Never fall back to a shared, predictable temporary directory:
32 // that would turn a home-resolution failure into ambient plugin
33 // discovery. A fresh nonexistent sentinel keeps startup read-only
34 // and fail-closed on every supported platform.
35 tracing::warn!(
36 target: "plugins",
37 %error,
38 "Codewhale home could not be resolved; user plugin discovery is disabled"
39 );
40 std::env::temp_dir()
41 .join(format!(
42 ".codewhale-home-unavailable-{}",
43 uuid::Uuid::new_v4().simple()
44 ))
45 .join("plugins")
46 })
47 }
48
49 #[must_use]
50 pub fn default_workspace_plugins_dir(workspace: &Path) -> PathBuf {
51 workspace.join(".codewhale").join("plugins")
52 }
53
54 #[cfg(test)]
55 #[must_use]
56 pub fn discover_with_config(config: &DiscoveryConfig) -> PluginRegistry {
57 let context = super::context::PluginDiscoveryContext::from_config_and_environment(
58 config,
59 super::context::HostEnvironment::capture(),
60 );
61 discover_with_context(config, context)
62 }
63
64 #[must_use]
65 pub(crate) fn discover_with_context(
66 config: &DiscoveryConfig,
67 context: std::sync::Arc<super::context::PluginDiscoveryContext>,
68 ) -> PluginRegistry {
69 let mut diagnostics = Vec::new();
70 let mut candidates = Vec::new();
71
72 for root in &config.builtin_plugin_dirs {
73 scan_root(
74 root,
75 PluginScope::Builtin,
76 PluginOrigin::Builtin,
77 &mut candidates,
78 &mut diagnostics,
79 );
80 }
81 scan_root(
82 &config.user_plugins_dir,
83 PluginScope::User,
84 PluginOrigin::CodeWhaleHome,
85 &mut candidates,
86 &mut diagnostics,
87 );
88 scan_root(
89 &config.workspace_plugins_dir,
90 PluginScope::Workspace,
91 PluginOrigin::Workspace,
92 &mut candidates,
93 &mut diagnostics,
94 );
95
96 candidates.sort_by(|left, right| {
97 left.scope
98 .cmp(&right.scope)
99 .then_with(|| left.name().cmp(right.name()))
100 .then_with(|| left.canonical_root.cmp(&right.canonical_root))
101 });
102
103 let mut seen_roots = HashSet::new();
104 let mut seen_names = BTreeSet::new();
105 let mut plugins = Vec::new();
106 for plugin in candidates {
107 if !seen_roots.insert(plugin.canonical_root.clone()) {
108 diagnostics.push(PluginDiagnostic::warning(
109 "duplicate-root",
110 format!(
111 "Ignoring duplicate plugin discovery at {}",
112 plugin.canonical_root.display()
113 ),
114 Some(plugin.canonical_root.clone()),
115 ));
116 continue;
117 }
118 if !seen_names.insert(plugin.name().to_string()) {
119 diagnostics.push(PluginDiagnostic::warning(
120 "name-conflict",
121 format!(
122 "Plugin `{}` at {} is shadowed by the higher-precedence bundle with the same name",
123 plugin.name(),
124 plugin.canonical_root.display()
125 ),
126 Some(plugin.canonical_root.clone()),
127 ));
128 continue;
129 }
130 plugins.push(plugin);
131 }
132
133 PluginRegistry::from_discovery(
134 plugins,
135 diagnostics,
136 config.state_path.clone(),
137 config.workspace.clone(),
138 Some(context),
139 )
140 }
141
142 fn scan_root(
143 root: &Path,
144 scope: PluginScope,
145 origin: PluginOrigin,
146 plugins: &mut Vec<LoadedPlugin>,
147 diagnostics: &mut Vec<PluginDiagnostic>,
148 ) {
149 let Ok(metadata) = fs::symlink_metadata(root) else {
150 return;
151 };
152 if metadata_is_link_or_reparse(&metadata) {
153 diagnostics.push(PluginDiagnostic::error(
154 "root-symlink",
155 format!(
156 "Plugin discovery root may not be a symbolic link or reparse point: {}",
157 root.display()
158 ),
159 Some(root.to_path_buf()),
160 ));
161 return;
162 }
163 if !metadata.is_dir() {
164 diagnostics.push(PluginDiagnostic::error(
165 "root-not-directory",
166 format!(
167 "Plugin discovery root is not a directory: {}",
168 root.display()
169 ),
170 Some(root.to_path_buf()),
171 ));
172 return;
173 }
174 let canonical_discovery_root = match root.canonicalize() {
175 Ok(root) => root,
176 Err(error) => {
177 diagnostics.push(PluginDiagnostic::error(
178 "root-canonicalize-failed",
179 format!(
180 "Failed to canonicalize plugin root {}: {error}",
181 root.display()
182 ),
183 Some(root.to_path_buf()),
184 ));
185 return;
186 }
187 };
188
189 let mut entries = match fs::read_dir(root) {
190 Ok(entries) => match entries.collect::<Result<Vec<_>, _>>() {
191 Ok(entries) => entries,
192 Err(error) => {
193 diagnostics.push(PluginDiagnostic::error(
194 "root-read-failed",
195 format!("Failed to read plugin root {}: {error}", root.display()),
196 Some(root.to_path_buf()),
197 ));
198 return;
199 }
200 },
201 Err(error) => {
202 diagnostics.push(PluginDiagnostic::error(
203 "root-read-failed",
204 format!("Failed to read plugin root {}: {error}", root.display()),
205 Some(root.to_path_buf()),
206 ));
207 return;
208 }
209 };
210 entries.sort_by_key(fs::DirEntry::file_name);
211
212 for entry in entries {
213 let plugin_root = entry.path();
214 let Ok(metadata) = fs::symlink_metadata(&plugin_root) else {
215 continue;
216 };
217 if metadata_is_link_or_reparse(&metadata) {
218 diagnostics.push(PluginDiagnostic::error(
219 "bundle-symlink",
220 format!(
221 "Plugin bundle directory may not be a symbolic link or reparse point: {}",
222 plugin_root.display()
223 ),
224 Some(plugin_root),
225 ));
226 continue;
227 }
228 if !metadata.is_dir() {
229 continue;
230 }
231 let manifest_path = plugin_root.join(PLUGIN_MANIFEST);
232 if !manifest_path.exists() {
233 continue;
234 }
235 match load_plugin(&manifest_path, &canonical_discovery_root, scope, origin) {
236 Ok(plugin) => plugins.push(plugin),
237 Err(error) => diagnostics.push(PluginDiagnostic::error(
238 "manifest-invalid",
239 error,
240 Some(manifest_path),
241 )),
242 }
243 }
244 }
245
246 fn load_plugin(
247 manifest_path: &Path,
248 canonical_discovery_root: &Path,
249 scope: PluginScope,
250 origin: PluginOrigin,
251 ) -> Result<LoadedPlugin, String> {
252 let validated = PluginManifest::validate_from_path(manifest_path)?;
253 if validated.canonical_root.parent() != Some(canonical_discovery_root) {
254 return Err(format!(
255 "plugin bundle resolved outside its Codewhale-owned discovery root: {}",
256 validated.canonical_root.display()
257 ));
258 }
259 let id = plugin_id(
260 scope,
261 &validated.manifest.plugin.name,
262 &validated.canonical_root,
263 );
264 let mut diagnostics = validated
265 .warnings
266 .iter()
267 .map(|warning| {
268 PluginDiagnostic::warning(
269 "manifest-legacy",
270 warning.clone(),
271 Some(manifest_path.to_path_buf()),
272 )
273 })
274 .collect::<Vec<_>>();
275
276 let (skill_snapshots, skill_diagnostics) = parse_skill_snapshots(&validated)?;
277 diagnostics.extend(skill_diagnostics);
278
279 // Skill parsing happens after hashing. Revalidate once so a concurrent
280 // bundle edit cannot pair a reviewed hash with different in-memory Skill
281 // instructions or MCP configuration. Active Skill bodies are replaced by
282 // snapshots parsed from the Codewhale-owned staged tree in `apply_state`.
283 let refreshed = PluginManifest::validate_from_path(manifest_path)?;
284 if refreshed.content_hash != validated.content_hash
285 || refreshed.capability_hash != validated.capability_hash
286 {
287 return Err(format!(
288 "plugin `{}` changed during discovery; reload and review the stable bundle",
289 validated.manifest.plugin.name
290 ));
291 }
292 let validated = refreshed;
293
294 Ok(LoadedPlugin {
295 id,
296 manifest: validated.manifest,
297 base_path: validated.canonical_root.clone(),
298 canonical_root: validated.canonical_root,
299 staged_root: None,
300 scope,
301 origin,
302 enabled: false,
303 trust_status: PluginTrustStatus::NeverReviewed,
304 applicable: validated.applicable,
305 inventory: validated.inventory,
306 components: validated.components,
307 content_hash: validated.content_hash,
308 capability_hash: validated.capability_hash,
309 state_generation: 0,
310 skill_snapshots,
311 diagnostics,
312 })
313 }
314
315 fn parse_skill_snapshots(
316 validated: &ValidatedManifest,
317 ) -> Result<(Vec<PluginSkillSnapshot>, Vec<PluginDiagnostic>), String> {
318 let mut diagnostics = Vec::new();
319 let mut skill_snapshots = Vec::new();
320 for skills_dir in &validated.components.skills {
321 let registry = crate::skills::SkillRegistry::discover(skills_dir);
322 for warning in registry.warnings() {
323 diagnostics.push(PluginDiagnostic::warning(
324 "skill-invalid",
325 warning.clone(),
326 Some(skills_dir.clone()),
327 ));
328 }
329 for skill in registry.list() {
330 let relative = skill
331 .path
332 .strip_prefix(&validated.canonical_root)
333 .map_err(|_| {
334 format!(
335 "plugin `{}` Skill path escaped the reviewed bundle",
336 validated.manifest.plugin.name
337 )
338 })?;
339 let expected_hash = validated.file_hashes.get(relative).ok_or_else(|| {
340 format!(
341 "plugin `{}` Skill was not present in the reviewed byte inventory",
342 validated.manifest.plugin.name
343 )
344 })?;
345 let bytes = read_skill_bytes(&skill.path)?;
346 let actual_hash = hash_skill_bytes(&bytes);
347 if &actual_hash != expected_hash {
348 return Err(format!(
349 "plugin `{}` Skill changed between review hashing and parsing",
350 validated.manifest.plugin.name
351 ));
352 }
353 let content = std::str::from_utf8(&bytes)
354 .map_err(|_| "plugin Skill must be valid UTF-8".to_string())?;
355 let (skill, parse_warnings) =
356 crate::skills::SkillRegistry::parse_verified_content(&skill.path, content)?;
357 for warning in parse_warnings {
358 diagnostics.push(PluginDiagnostic::warning(
359 "skill-invalid",
360 warning,
361 Some(skill.path.clone()),
362 ));
363 }
364 skill_snapshots.push(PluginSkillSnapshot {
365 name: skill.name.clone(),
366 description: skill.description.clone(),
367 localized_descriptions: skill.localized_descriptions.clone(),
368 invocation: skill.invocation,
369 aliases: skill.aliases.clone(),
370 body: skill.body.clone(),
371 path: skill.path.clone(),
372 source_hash: actual_hash,
373 });
374 }
375 }
376 skill_snapshots.sort_by(|left, right| {
377 left.name
378 .cmp(&right.name)
379 .then_with(|| left.path.cmp(&right.path))
380 });
381 let mut seen_skills = BTreeSet::new();
382 for skill in &skill_snapshots {
383 if !seen_skills.insert(skill.name.clone()) {
384 return Err(format!(
385 "plugin `{}` declares duplicate skill name `{}`",
386 validated.manifest.plugin.name, skill.name
387 ));
388 }
389 }
390
391 Ok((skill_snapshots, diagnostics))
392 }
393
394 fn read_skill_bytes(path: &Path) -> Result<Vec<u8>, String> {
395 use std::io::Read as _;
396
397 let file = super::manifest::open_bundle_file(path)
398 .map_err(|e| format!("failed to open plugin Skill without following links: {e}"))?;
399 let mut bytes = Vec::new();
400 file.take(1024 * 1024 + 1)
401 .read_to_end(&mut bytes)
402 .map_err(|e| format!("failed to read plugin Skill: {e}"))?;
403 if bytes.len() > 1024 * 1024 {
404 return Err("plugin Skill exceeds the one-megabyte parse limit".to_string());
405 }
406 Ok(bytes)
407 }
408
409 fn hash_skill_bytes(bytes: &[u8]) -> String {
410 let mut hasher = Sha256::new();
411 hasher.update(b"codewhale-plugin-file-bytes-v1\0");
412 hasher.update(bytes);
413 hasher
414 .finalize()
415 .iter()
416 .map(|byte| format!("{byte:02x}"))
417 .collect()
418 }
419
420 pub(crate) fn load_staged_skill_snapshots(
421 staged_root: &Path,
422 expected_content_hash: &str,
423 expected_capability_hash: &str,
424 ) -> Result<Vec<PluginSkillSnapshot>, String> {
425 let validated = PluginManifest::validate_from_path(&staged_root.join(PLUGIN_MANIFEST))?;
426 if validated.canonical_root != staged_root
427 || validated.content_hash != expected_content_hash
428 || validated.capability_hash != expected_capability_hash
429 {
430 return Err("staged plugin Skill snapshot no longer matches reviewed content".to_string());
431 }
432 let (snapshots, diagnostics) = parse_skill_snapshots(&validated)?;
433 if let Some(diagnostic) = diagnostics.first() {
434 return Err(format!(
435 "staged plugin Skill snapshot is invalid: {}",
436 diagnostic.message
437 ));
438 }
439 // Parsing is explicitly bound to the first validation's file inventory;
440 // a final whole-bundle pass also rejects additions/removals/config drift
441 // that occurred during directory traversal.
442 let refreshed = PluginManifest::validate_from_path(&staged_root.join(PLUGIN_MANIFEST))?;
443 if refreshed.content_hash != validated.content_hash
444 || refreshed.capability_hash != validated.capability_hash
445 || refreshed.file_hashes != validated.file_hashes
446 {
447 return Err("staged plugin changed while its Skill snapshots were parsed".to_string());
448 }
449 Ok(snapshots)
450 }
451
452 #[cfg(test)]
453 pub(crate) fn load_plugin_for_test(manifest_path: &Path) -> Result<LoadedPlugin, String> {
454 let discovery_root = manifest_path
455 .parent()
456 .and_then(Path::parent)
457 .ok_or_else(|| "test plugin manifest needs a bundle and discovery root".to_string())?
458 .canonicalize()
459 .map_err(|error| format!("failed to canonicalize test discovery root: {error}"))?;
460 load_plugin(
461 manifest_path,
462 &discovery_root,
463 PluginScope::User,
464 PluginOrigin::CodeWhaleHome,
465 )
466 }
467
468 fn plugin_id(scope: PluginScope, name: &str, canonical_root: &Path) -> PluginId {
469 let mut hasher = Sha256::new();
470 // v2 intentionally invalidates receipts produced by the former lossy
471 // Unicode path identity.
472 hasher.update(b"codewhale-plugin-id-v2\0");
473 hasher.update(scope.as_str().as_bytes());
474 hasher.update(b"\0");
475 super::path_identity::hash_os_path(&mut hasher, b"canonical-plugin-root", canonical_root);
476 let digest = hasher.finalize();
477 let suffix = digest[..6]
478 .iter()
479 .map(|byte| format!("{byte:02x}"))
480 .collect::<String>();
481 PluginId(format!("{}/{suffix}/{name}", scope.as_str()))
482 }
483
484 #[cfg(test)]
485 mod tests {
486 use super::*;
487
488 fn write_plugin(root: &Path, dir: &str, name: &str) -> PathBuf {
489 let plugin = root.join(dir);
490 fs::create_dir_all(&plugin).unwrap();
491 fs::write(
492 plugin.join("plugin.toml"),
493 format!("schema_version = 1\n[plugin]\nname = {name:?}\nversion = \"1.0.0\"\n"),
494 )
495 .unwrap();
496 plugin
497 }
498
499 fn config(tmp: &Path) -> DiscoveryConfig {
500 DiscoveryConfig {
501 workspace: tmp.join("project"),
502 user_plugins_dir: tmp.join("user"),
503 workspace_plugins_dir: tmp.join("workspace"),
504 builtin_plugin_dirs: vec![tmp.join("builtin")],
505 state_path: tmp.join("state.json"),
506 }
507 }
508
509 #[test]
510 fn user_and_workspace_bundles_are_disabled_and_untrusted_by_default() {
511 let tmp = tempfile::tempdir().unwrap();
512 let cfg = config(tmp.path());
513 write_plugin(&cfg.user_plugins_dir, "a", "user-plugin");
514 write_plugin(&cfg.workspace_plugins_dir, "b", "workspace-plugin");
515
516 let registry = discover_with_config(&cfg);
517 assert_eq!(registry.len(), 2);
518 assert!(registry.list().iter().all(|plugin| !plugin.enabled));
519 assert!(registry.list().iter().all(|plugin| !plugin.trusted()));
520 assert!(!cfg.state_path.exists(), "discovery must be read-only");
521 }
522
523 #[test]
524 fn precedence_is_builtin_then_user_then_workspace() {
525 let tmp = tempfile::tempdir().unwrap();
526 let cfg = config(tmp.path());
527 write_plugin(&cfg.builtin_plugin_dirs[0], "z", "same");
528 write_plugin(&cfg.user_plugins_dir, "a", "same");
529 write_plugin(&cfg.workspace_plugins_dir, "b", "same");
530
531 let registry = discover_with_config(&cfg);
532 assert_eq!(registry.len(), 1);
533 assert_eq!(registry.get("same").unwrap().scope, PluginScope::Builtin);
534 assert_eq!(
535 registry
536 .diagnostics()
537 .iter()
538 .filter(|diagnostic| diagnostic.code == "name-conflict")
539 .count(),
540 2
541 );
542 }
543
544 #[test]
545 fn discovery_is_sorted_and_plugin_ids_are_deterministic() {
546 let tmp = tempfile::tempdir().unwrap();
547 let cfg = config(tmp.path());
548 write_plugin(&cfg.user_plugins_dir, "z", "zulu");
549 write_plugin(&cfg.user_plugins_dir, "a", "alpha");
550
551 let first = discover_with_config(&cfg);
552 let second = discover_with_config(&cfg);
553 let names = first
554 .list()
555 .iter()
556 .map(|plugin| plugin.name())
557 .collect::<Vec<_>>();
558 assert_eq!(names, vec!["alpha", "zulu"]);
559 assert_eq!(
560 first.get("alpha").unwrap().id,
561 second.get("alpha").unwrap().id
562 );
563 }
564
565 #[cfg(unix)]
566 #[test]
567 fn plugin_ids_distinguish_lossy_colliding_native_roots() {
568 use std::ffi::OsString;
569 use std::os::unix::ffi::OsStringExt as _;
570
571 let tmp = tempfile::tempdir().unwrap();
572 let left_name = OsString::from_vec(vec![b'p', 0xff]);
573 let right_name = OsString::from_vec(vec![b'p', 0xfe]);
574 assert_eq!(left_name.to_string_lossy(), right_name.to_string_lossy());
575 let left = tmp.path().join(left_name);
576 let right = tmp.path().join(right_name);
577 assert_ne!(
578 plugin_id(PluginScope::User, "same", &left),
579 plugin_id(PluginScope::User, "same", &right)
580 );
581 }
582
583 #[cfg(unix)]
584 #[test]
585 fn symlinked_discovery_root_fails_closed() {
586 use std::os::unix::fs::symlink;
587
588 let tmp = tempfile::tempdir().unwrap();
589 let outside = tempfile::tempdir().unwrap();
590 write_plugin(outside.path(), "a", "outside");
591 let cfg = config(tmp.path());
592 symlink(outside.path(), &cfg.workspace_plugins_dir).unwrap();
593
594 let registry = discover_with_config(&cfg);
595 assert!(registry.is_empty());
596 assert!(
597 registry
598 .diagnostics()
599 .iter()
600 .any(|diagnostic| diagnostic.code == "root-symlink")
601 );
602 }
603
604 #[cfg(windows)]
605 fn create_junction(link: &Path, target: &Path) {
606 let output = std::process::Command::new("cmd")
607 .args(["/C", "mklink", "/J"])
608 .arg(link)
609 .arg(target)
610 .output()
611 .expect("invoke Windows junction creation");
612 assert!(
613 output.status.success(),
614 "failed to create junction: stdout={} stderr={}",
615 String::from_utf8_lossy(&output.stdout),
616 String::from_utf8_lossy(&output.stderr)
617 );
618 }
619
620 #[cfg(windows)]
621 #[test]
622 fn junction_discovery_root_fails_closed() {
623 let tmp = tempfile::tempdir().unwrap();
624 let outside = tempfile::tempdir().unwrap();
625 write_plugin(outside.path(), "a", "outside");
626 let cfg = config(tmp.path());
627 create_junction(&cfg.workspace_plugins_dir, outside.path());
628
629 let registry = discover_with_config(&cfg);
630 assert!(registry.is_empty());
631 assert!(
632 registry
633 .diagnostics()
634 .iter()
635 .any(|diagnostic| diagnostic.code == "root-symlink")
636 );
637 }
638
639 #[cfg(windows)]
640 #[test]
641 fn junction_bundle_entry_fails_closed() {
642 let tmp = tempfile::tempdir().unwrap();
643 let outside = tempfile::tempdir().unwrap();
644 let target = write_plugin(outside.path(), "actual", "outside");
645 let cfg = config(tmp.path());
646 fs::create_dir_all(&cfg.workspace_plugins_dir).unwrap();
647 create_junction(&cfg.workspace_plugins_dir.join("linked"), &target);
648
649 let registry = discover_with_config(&cfg);
650 assert!(registry.is_empty());
651 assert!(
652 registry
653 .diagnostics()
654 .iter()
655 .any(|diagnostic| diagnostic.code == "bundle-symlink")
656 );
657 }
658
659 #[test]
660 fn discovery_ignores_ambient_compatibility_roots() {
661 let _lock = crate::test_support::lock_test_env();
662 let tmp = tempfile::tempdir().unwrap();
663 let home = tmp.path().join("home");
664 let workspace = tmp.path().join("workspace");
665 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
666 write_plugin(
667 &workspace.join(".claude/plugins"),
668 "ambient",
669 "ambient-plugin",
670 );
671 write_plugin(
672 &workspace.join(".cursor/plugins"),
673 "ambient",
674 "cursor-plugin",
675 );
676
677 let discovery = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv();
678 let registry = discovery.registry_for_workspace(&workspace);
679 assert!(registry.is_empty());
680 assert!(!home.join("plugins/state.json").exists());
681 }
682 }
683
683 lines RUST