1use super::error::PluginError;
4use super::signature::{self, SignatureMode, VerificationResult};
5use super::{PluginCapability, PluginInfo, PluginManifest};
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9const SKILLS_SUBDIR: &str = "skills";
11
12pub struct PluginHost {
14 plugins_dir: PathBuf,
15 loaded: HashMap<String, LoadedPlugin>,
16 signature_mode: SignatureMode,
17 trusted_publisher_keys: Vec<String>,
18}
19
20struct LoadedPlugin {
21 manifest: PluginManifest,
22 plugin_dir: PathBuf,
23 wasm_path: Option<PathBuf>,
25 #[allow(dead_code)]
26 verification: VerificationResult,
27}
28
29impl PluginHost {
30 pub fn new(workspace_dir: &Path) -> Result<Self, PluginError> {
32 Self::with_security(workspace_dir, SignatureMode::Disabled, Vec::new())
33 }
34
35 pub fn with_security(
37 workspace_dir: &Path,
38 signature_mode: SignatureMode,
39 trusted_publisher_keys: Vec<String>,
40 ) -> Result<Self, PluginError> {
41 let plugins_dir = workspace_dir.join("plugins");
42 if !plugins_dir.exists() {
43 std::fs::create_dir_all(&plugins_dir)?;
44 }
45
46 let mut host = Self {
47 plugins_dir,
48 loaded: HashMap::new(),
49 signature_mode,
50 trusted_publisher_keys,
51 };
52
53 host.discover()?;
54 Ok(host)
55 }
56
57 pub fn parse_signature_mode(mode: &str) -> SignatureMode {
59 match mode.to_lowercase().as_str() {
60 "strict" => SignatureMode::Strict,
61 "permissive" => SignatureMode::Permissive,
62 _ => SignatureMode::Disabled,
63 }
64 }
65
66 fn discover(&mut self) -> Result<(), PluginError> {
68 if !self.plugins_dir.exists() {
69 return Ok(());
70 }
71
72 let entries = std::fs::read_dir(&self.plugins_dir)?;
73 for entry in entries.flatten() {
74 let path = entry.path();
75 if path.is_dir() {
76 let manifest_path = path.join("manifest.toml");
77 if manifest_path.exists()
78 && let Ok(manifest) = self.load_manifest(&manifest_path)
79 {
80 if let Err(e) = validate_manifest_shape(&manifest, &path) {
81 ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"plugin": path.display().to_string(), "error": format!("{}", e)})), "skipping plugin due to invalid manifest shape");
82 continue;
83 }
84
85 let manifest_toml = std::fs::read_to_string(&manifest_path).unwrap_or_default();
87 match self.verify_plugin_signature(&manifest.name, &manifest_toml, &manifest) {
88 Ok(verification) => {
89 let wasm_path = manifest.wasm_path.as_deref().map(|p| path.join(p));
90 self.loaded.insert(
91 manifest.name.clone(),
92 LoadedPlugin {
93 manifest,
94 plugin_dir: path.clone(),
95 wasm_path,
96 verification,
97 },
98 );
99 }
100 Err(e) => {
101 ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"plugin": path.display().to_string(), "error": format!("{}", e)})), "skipping plugin due to signature verification failure");
102 }
103 }
104 }
105 }
106 }
107
108 Ok(())
109 }
110
111 fn load_manifest(&self, path: &Path) -> Result<PluginManifest, PluginError> {
112 let content = std::fs::read_to_string(path)?;
113 let manifest: PluginManifest = toml::from_str(&content)?;
114 Ok(manifest)
115 }
116
117 fn verify_plugin_signature(
119 &self,
120 name: &str,
121 manifest_toml: &str,
122 manifest: &PluginManifest,
123 ) -> Result<VerificationResult, PluginError> {
124 signature::enforce_signature_policy(
125 name,
126 manifest_toml,
127 manifest.signature.as_deref(),
128 manifest.publisher_key.as_deref(),
129 &self.trusted_publisher_keys,
130 self.signature_mode,
131 )
132 }
133
134 pub fn list_plugins(&self) -> Vec<PluginInfo> {
136 self.loaded.values().map(plugin_info_from_loaded).collect()
137 }
138
139 pub fn get_plugin(&self, name: &str) -> Option<PluginInfo> {
141 self.loaded.get(name).map(plugin_info_from_loaded)
142 }
143
144 pub fn install(&mut self, source: &str) -> Result<(), PluginError> {
146 let source_path = PathBuf::from(source);
147 let manifest_path = if source_path.is_dir() {
148 source_path.join("manifest.toml")
149 } else {
150 source_path.clone()
151 };
152
153 if !manifest_path.exists() {
154 return Err(PluginError::NotFound(format!(
155 "manifest.toml not found at {}",
156 manifest_path.display()
157 )));
158 }
159
160 let manifest = self.load_manifest(&manifest_path)?;
161 let source_dir = manifest_path
162 .parent()
163 .ok_or_else(|| PluginError::InvalidManifest("no parent directory".into()))?;
164
165 validate_manifest_shape(&manifest, source_dir)?;
166
167 let wasm_source = manifest.wasm_path.as_deref().map(|p| source_dir.join(p));
168 if let Some(ref wasm_source) = wasm_source
169 && !wasm_source.exists()
170 {
171 return Err(PluginError::NotFound(format!(
172 "WASM file not found: {}",
173 wasm_source.display()
174 )));
175 }
176
177 if self.loaded.contains_key(&manifest.name) {
178 return Err(PluginError::AlreadyLoaded(manifest.name));
179 }
180
181 let manifest_toml = std::fs::read_to_string(&manifest_path)?;
183 let verification =
184 self.verify_plugin_signature(&manifest.name, &manifest_toml, &manifest)?;
185
186 let dest_dir = self.plugins_dir.join(&manifest.name);
188 std::fs::create_dir_all(&dest_dir)?;
189
190 std::fs::copy(&manifest_path, dest_dir.join("manifest.toml"))?;
192
193 let wasm_dest = if let (Some(rel), Some(src)) = (manifest.wasm_path.as_deref(), wasm_source)
195 {
196 let dest = dest_dir.join(rel);
197 if let Some(parent) = dest.parent() {
198 std::fs::create_dir_all(parent)?;
199 }
200 std::fs::copy(&src, &dest)?;
201 Some(dest)
202 } else {
203 None
204 };
205
206 if manifest.capabilities.contains(&PluginCapability::Skill) {
208 let src_skills = source_dir.join(SKILLS_SUBDIR);
209 let dest_skills = dest_dir.join(SKILLS_SUBDIR);
210 if src_skills.is_dir() {
211 copy_dir_recursive(&src_skills, &dest_skills)?;
212 }
213 }
214
215 self.loaded.insert(
216 manifest.name.clone(),
217 LoadedPlugin {
218 manifest,
219 plugin_dir: dest_dir,
220 wasm_path: wasm_dest,
221 verification,
222 },
223 );
224
225 Ok(())
226 }
227
228 pub fn remove(&mut self, name: &str) -> Result<(), PluginError> {
230 if self.loaded.remove(name).is_none() {
231 return Err(PluginError::NotFound(name.to_string()));
232 }
233
234 let plugin_dir = self.plugins_dir.join(name);
235 if plugin_dir.exists() {
236 std::fs::remove_dir_all(plugin_dir)?;
237 }
238
239 Ok(())
240 }
241
242 pub fn tool_plugins(&self) -> Vec<&PluginManifest> {
244 self.loaded
245 .values()
246 .filter(|p| p.manifest.capabilities.contains(&PluginCapability::Tool))
247 .map(|p| &p.manifest)
248 .collect()
249 }
250
251 pub fn tool_plugin_details(&self) -> Vec<(&PluginManifest, &Path)> {
255 self.loaded
256 .values()
257 .filter(|p| p.manifest.capabilities.contains(&PluginCapability::Tool))
258 .filter_map(|p| p.wasm_path.as_deref().map(|wp| (&p.manifest, wp)))
259 .collect()
260 }
261
262 pub fn channel_plugins(&self) -> Vec<&PluginManifest> {
264 self.loaded
265 .values()
266 .filter(|p| p.manifest.capabilities.contains(&PluginCapability::Channel))
267 .map(|p| &p.manifest)
268 .collect()
269 }
270
271 pub fn skill_plugins(&self) -> Vec<&PluginManifest> {
273 self.loaded
274 .values()
275 .filter(|p| p.manifest.capabilities.contains(&PluginCapability::Skill))
276 .map(|p| &p.manifest)
277 .collect()
278 }
279
280 pub fn skill_plugin_details(&self) -> Vec<(&PluginManifest, PathBuf)> {
287 self.loaded
288 .values()
289 .filter(|p| p.manifest.capabilities.contains(&PluginCapability::Skill))
290 .filter_map(|p| {
291 let skills_dir = p.plugin_dir.join(SKILLS_SUBDIR);
292 if skills_dir.is_dir() {
293 Some((&p.manifest, skills_dir))
294 } else {
295 None
296 }
297 })
298 .collect()
299 }
300
301 pub fn plugins_dir(&self) -> &Path {
303 &self.plugins_dir
304 }
305}
306
307fn plugin_info_from_loaded(p: &LoadedPlugin) -> PluginInfo {
308 let loaded = match &p.wasm_path {
309 Some(path) => path.exists(),
310 None => p.plugin_dir.join(SKILLS_SUBDIR).is_dir(),
312 };
313 PluginInfo {
314 name: p.manifest.name.clone(),
315 version: p.manifest.version.clone(),
316 description: p.manifest.description.clone(),
317 capabilities: p.manifest.capabilities.clone(),
318 permissions: p.manifest.permissions.clone(),
319 wasm_path: p.wasm_path.clone(),
320 loaded,
321 }
322}
323
324fn validate_manifest_shape(
329 manifest: &PluginManifest,
330 plugin_dir: &Path,
331) -> Result<(), PluginError> {
332 if manifest.capabilities.is_empty() {
333 return Err(PluginError::InvalidManifest(format!(
334 "plugin '{}' declares no capabilities",
335 manifest.name
336 )));
337 }
338
339 let is_skill_only =
340 manifest.capabilities.len() == 1 && manifest.capabilities[0] == PluginCapability::Skill;
341
342 if !is_skill_only && manifest.wasm_path.is_none() {
343 return Err(PluginError::InvalidManifest(format!(
344 "plugin '{}' is missing required `wasm_path` for non-skill capabilities",
345 manifest.name
346 )));
347 }
348
349 if manifest.capabilities.contains(&PluginCapability::Skill) {
350 validate_skill_bundle(&manifest.name, plugin_dir)?;
351 }
352
353 Ok(())
354}
355
356fn validate_skill_bundle(plugin_name: &str, plugin_dir: &Path) -> Result<(), PluginError> {
360 let skills_dir = plugin_dir.join(SKILLS_SUBDIR);
361 if !skills_dir.is_dir() {
362 return Err(PluginError::InvalidManifest(format!(
363 "skill plugin '{}' is missing `skills/` directory at {}",
364 plugin_name,
365 skills_dir.display()
366 )));
367 }
368
369 let mut found_any = false;
370 for entry in std::fs::read_dir(&skills_dir)? {
371 let entry = entry?;
372 let path = entry.path();
373 if !path.is_dir() {
374 continue;
375 }
376 found_any = true;
377 let skill_md = path.join("SKILL.md");
378 if !skill_md.is_file() {
379 return Err(PluginError::InvalidManifest(format!(
380 "skill plugin '{}' subdirectory '{}' is missing SKILL.md",
381 plugin_name,
382 path.file_name().and_then(|n| n.to_str()).unwrap_or("?")
383 )));
384 }
385 validate_skill_md_frontmatter(plugin_name, &skill_md)?;
386 }
387
388 if !found_any {
389 return Err(PluginError::InvalidManifest(format!(
390 "skill plugin '{}' has empty `skills/` directory",
391 plugin_name
392 )));
393 }
394
395 Ok(())
396}
397
398fn validate_skill_md_frontmatter(plugin_name: &str, skill_md: &Path) -> Result<(), PluginError> {
399 let content = std::fs::read_to_string(skill_md)?;
400 let normalized = content.replace("\r\n", "\n");
401 let rest = normalized.strip_prefix("---\n").ok_or_else(|| {
402 PluginError::InvalidManifest(format!(
403 "skill plugin '{}': {} is missing YAML frontmatter",
404 plugin_name,
405 skill_md.display()
406 ))
407 })?;
408 let frontmatter = if let Some(idx) = rest.find("\n---\n") {
409 &rest[..idx]
410 } else if let Some(stripped) = rest.strip_suffix("\n---") {
411 stripped
412 } else {
413 return Err(PluginError::InvalidManifest(format!(
414 "skill plugin '{}': {} has unterminated frontmatter",
415 plugin_name,
416 skill_md.display()
417 )));
418 };
419
420 let mut has_name = false;
421 let mut has_description = false;
422 for line in frontmatter.lines() {
423 let trimmed = line.trim_start();
424 if let Some((key, value)) = trimmed.split_once(':') {
425 let key = key.trim();
426 let value = value.trim();
427 let has_value = !value.is_empty();
431 match key {
432 "name" if has_value => has_name = true,
433 "description" if has_value => has_description = true,
434 _ => {}
435 }
436 }
437 }
438
439 if !has_name || !has_description {
440 return Err(PluginError::InvalidManifest(format!(
441 "skill plugin '{}': {} frontmatter must declare `name` and `description`",
442 plugin_name,
443 skill_md.display()
444 )));
445 }
446
447 Ok(())
448}
449
450fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), PluginError> {
451 std::fs::create_dir_all(dst)?;
452 for entry in std::fs::read_dir(src)? {
453 let entry = entry?;
454 let from = entry.path();
455 let to = dst.join(entry.file_name());
456 let ft = entry.file_type()?;
457 if ft.is_dir() {
458 copy_dir_recursive(&from, &to)?;
459 } else if ft.is_file() {
460 std::fs::copy(&from, &to)?;
461 }
462 }
464 Ok(())
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470 use tempfile::tempdir;
471
472 #[test]
473 fn test_empty_plugin_dir() {
474 let dir = tempdir().unwrap();
475 let host = PluginHost::new(dir.path()).unwrap();
476 assert!(host.list_plugins().is_empty());
477 }
478
479 #[test]
480 fn test_discover_with_manifest() {
481 let dir = tempdir().unwrap();
482 let plugin_dir = dir.path().join("plugins").join("test-plugin");
483 std::fs::create_dir_all(&plugin_dir).unwrap();
484
485 std::fs::write(
486 plugin_dir.join("manifest.toml"),
487 r#"
488name = "test-plugin"
489version = "0.1.0"
490description = "A test plugin"
491wasm_path = "plugin.wasm"
492capabilities = ["tool"]
493permissions = []
494"#,
495 )
496 .unwrap();
497
498 let host = PluginHost::new(dir.path()).unwrap();
499 let plugins = host.list_plugins();
500 assert_eq!(plugins.len(), 1);
501 assert_eq!(plugins[0].name, "test-plugin");
502 }
503
504 #[test]
505 fn test_tool_plugins_filter() {
506 let dir = tempdir().unwrap();
507 let plugins_base = dir.path().join("plugins");
508
509 let tool_dir = plugins_base.join("my-tool");
511 std::fs::create_dir_all(&tool_dir).unwrap();
512 std::fs::write(
513 tool_dir.join("manifest.toml"),
514 r#"
515name = "my-tool"
516version = "0.1.0"
517wasm_path = "tool.wasm"
518capabilities = ["tool"]
519"#,
520 )
521 .unwrap();
522
523 let chan_dir = plugins_base.join("my-channel");
525 std::fs::create_dir_all(&chan_dir).unwrap();
526 std::fs::write(
527 chan_dir.join("manifest.toml"),
528 r#"
529name = "my-channel"
530version = "0.1.0"
531wasm_path = "channel.wasm"
532capabilities = ["channel"]
533"#,
534 )
535 .unwrap();
536
537 let host = PluginHost::new(dir.path()).unwrap();
538 assert_eq!(host.list_plugins().len(), 2);
539 assert_eq!(host.tool_plugins().len(), 1);
540 assert_eq!(host.channel_plugins().len(), 1);
541 assert_eq!(host.tool_plugins()[0].name, "my-tool");
542 }
543
544 #[test]
545 fn test_get_plugin() {
546 let dir = tempdir().unwrap();
547 let plugin_dir = dir.path().join("plugins").join("lookup-test");
548 std::fs::create_dir_all(&plugin_dir).unwrap();
549 std::fs::write(
550 plugin_dir.join("manifest.toml"),
551 r#"
552name = "lookup-test"
553version = "1.0.0"
554description = "Lookup test"
555wasm_path = "plugin.wasm"
556capabilities = ["tool"]
557"#,
558 )
559 .unwrap();
560
561 let host = PluginHost::new(dir.path()).unwrap();
562 assert!(host.get_plugin("lookup-test").is_some());
563 assert!(host.get_plugin("nonexistent").is_none());
564 }
565
566 #[test]
567 fn test_remove_plugin() {
568 let dir = tempdir().unwrap();
569 let plugin_dir = dir.path().join("plugins").join("removable");
570 std::fs::create_dir_all(&plugin_dir).unwrap();
571 std::fs::write(
572 plugin_dir.join("manifest.toml"),
573 r#"
574name = "removable"
575version = "0.1.0"
576wasm_path = "plugin.wasm"
577capabilities = ["tool"]
578"#,
579 )
580 .unwrap();
581
582 let mut host = PluginHost::new(dir.path()).unwrap();
583 assert_eq!(host.list_plugins().len(), 1);
584
585 host.remove("removable").unwrap();
586 assert!(host.list_plugins().is_empty());
587 assert!(!plugin_dir.exists());
588 }
589
590 #[test]
591 fn test_remove_nonexistent_returns_error() {
592 let dir = tempdir().unwrap();
593 let mut host = PluginHost::new(dir.path()).unwrap();
594 assert!(host.remove("ghost").is_err());
595 }
596
597 fn write_skill_md(path: &Path, name: &str, description: &str) {
598 std::fs::write(
599 path,
600 format!(
601 "---\nname: {name}\ndescription: {description}\n---\n\nBody content for {name}.\n"
602 ),
603 )
604 .unwrap();
605 }
606
607 fn write_skill_bundle_plugin(plugins_base: &Path, plugin_name: &str, skill_names: &[&str]) {
608 let plugin_dir = plugins_base.join(plugin_name);
609 std::fs::create_dir_all(&plugin_dir).unwrap();
610 std::fs::write(
611 plugin_dir.join("manifest.toml"),
612 format!("name = \"{plugin_name}\"\nversion = \"0.1.0\"\ncapabilities = [\"skill\"]\n"),
613 )
614 .unwrap();
615 let skills_dir = plugin_dir.join("skills");
616 std::fs::create_dir_all(&skills_dir).unwrap();
617 for skill in skill_names {
618 let sd = skills_dir.join(skill);
619 std::fs::create_dir_all(&sd).unwrap();
620 write_skill_md(
621 &sd.join("SKILL.md"),
622 skill,
623 &format!("Description for {skill}"),
624 );
625 }
626 }
627
628 #[test]
629 fn test_skill_only_plugin_discovers_without_wasm_path() {
630 let dir = tempdir().unwrap();
631 let plugins_base = dir.path().join("plugins");
632 write_skill_bundle_plugin(
633 &plugins_base,
634 "my-toolkit",
635 &["design-review", "code-review"],
636 );
637
638 let host = PluginHost::new(dir.path()).unwrap();
639 let plugins = host.list_plugins();
640 assert_eq!(plugins.len(), 1);
641 assert_eq!(plugins[0].name, "my-toolkit");
642 assert!(plugins[0].wasm_path.is_none());
643 assert!(plugins[0].loaded);
644
645 let skill_plugins = host.skill_plugins();
646 assert_eq!(skill_plugins.len(), 1);
647
648 let details = host.skill_plugin_details();
649 assert_eq!(details.len(), 1);
650 assert_eq!(details[0].0.name, "my-toolkit");
651 assert!(details[0].1.ends_with("skills"));
652 }
653
654 #[test]
655 fn test_non_skill_plugin_without_wasm_path_is_rejected() {
656 let dir = tempdir().unwrap();
657 let plugin_dir = dir.path().join("plugins").join("broken");
658 std::fs::create_dir_all(&plugin_dir).unwrap();
659 std::fs::write(
660 plugin_dir.join("manifest.toml"),
661 "name = \"broken\"\nversion = \"0.1.0\"\ncapabilities = [\"tool\"]\n",
662 )
663 .unwrap();
664
665 let host = PluginHost::new(dir.path()).unwrap();
666 assert!(host.list_plugins().is_empty());
668 }
669
670 #[test]
671 fn test_skill_plugin_missing_skills_dir_is_rejected() {
672 let dir = tempdir().unwrap();
673 let plugin_dir = dir.path().join("plugins").join("empty-skills");
674 std::fs::create_dir_all(&plugin_dir).unwrap();
675 std::fs::write(
676 plugin_dir.join("manifest.toml"),
677 "name = \"empty-skills\"\nversion = \"0.1.0\"\ncapabilities = [\"skill\"]\n",
678 )
679 .unwrap();
680
681 let host = PluginHost::new(dir.path()).unwrap();
682 assert!(host.list_plugins().is_empty());
683 }
684
685 #[test]
686 fn test_skill_plugin_rejects_skill_without_required_frontmatter() {
687 let dir = tempdir().unwrap();
688 let plugin_dir = dir.path().join("plugins").join("bad-frontmatter");
689 std::fs::create_dir_all(&plugin_dir).unwrap();
690 std::fs::write(
691 plugin_dir.join("manifest.toml"),
692 "name = \"bad-frontmatter\"\nversion = \"0.1.0\"\ncapabilities = [\"skill\"]\n",
693 )
694 .unwrap();
695 let skill_dir = plugin_dir.join("skills").join("oops");
696 std::fs::create_dir_all(&skill_dir).unwrap();
697 std::fs::write(skill_dir.join("SKILL.md"), "---\nname: oops\n---\n\nbody\n").unwrap();
699
700 let host = PluginHost::new(dir.path()).unwrap();
701 assert!(host.list_plugins().is_empty());
702 }
703
704 #[test]
705 fn test_skill_plugin_rejects_skill_without_skill_md() {
706 let dir = tempdir().unwrap();
707 let plugin_dir = dir.path().join("plugins").join("missing-md");
708 std::fs::create_dir_all(&plugin_dir).unwrap();
709 std::fs::write(
710 plugin_dir.join("manifest.toml"),
711 "name = \"missing-md\"\nversion = \"0.1.0\"\ncapabilities = [\"skill\"]\n",
712 )
713 .unwrap();
714 let skill_dir = plugin_dir.join("skills").join("orphan");
715 std::fs::create_dir_all(&skill_dir).unwrap();
716 std::fs::write(skill_dir.join("notes.md"), "no SKILL.md here").unwrap();
717
718 let host = PluginHost::new(dir.path()).unwrap();
719 assert!(host.list_plugins().is_empty());
720 }
721
722 #[test]
723 fn test_skill_plugin_does_not_appear_in_tool_or_channel_lists() {
724 let dir = tempdir().unwrap();
725 let plugins_base = dir.path().join("plugins");
726 write_skill_bundle_plugin(&plugins_base, "skill-bundle", &["one"]);
727
728 let host = PluginHost::new(dir.path()).unwrap();
729 assert!(host.tool_plugins().is_empty());
730 assert!(host.tool_plugin_details().is_empty());
731 assert!(host.channel_plugins().is_empty());
732 assert_eq!(host.skill_plugins().len(), 1);
733 }
734}