diff --git a/src/plugins/manifest.rs b/src/plugins/manifest.rs index ea0f80c92..8525f8cc7 100644 --- a/src/plugins/manifest.rs +++ b/src/plugins/manifest.rs @@ -15,6 +15,17 @@ const SUPPORTED_WIT_MAJOR: u64 = 1; const SUPPORTED_WIT_PACKAGES: [&str; 3] = ["zeroclaw:hooks", "zeroclaw:tools", "zeroclaw:providers"]; +/// Validation profile for plugin manifests. +/// +/// Runtime uses `RuntimeWasm` today (strict; requires module path). +/// `SchemaOnly` exists so future non-WASM plugin forms can validate metadata +/// without forcing a fake module path. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ManifestValidationProfile { + RuntimeWasm, + SchemaOnly, +} + /// Filename plugins must use for their manifest. pub const PLUGIN_MANIFEST_FILENAME: &str = "zeroclaw.plugin.toml"; @@ -49,7 +60,8 @@ pub struct PluginManifest { /// Declared capability set for this plugin. #[serde(default)] pub capabilities: Vec, - /// Optional module path used by WASM-oriented plugin runtimes. + /// WASM module path used by runtime execution. + /// Required in runtime validation; optional in schema-only validation. #[serde(default)] pub module_path: String, /// Declared WIT package contracts the plugin expects. @@ -138,7 +150,10 @@ fn required_wit_package_for_capability(capability: &PluginCapability) -> &'stati } } -pub fn validate_manifest(manifest: &PluginManifest) -> anyhow::Result<()> { +pub fn validate_manifest_with_profile( + manifest: &PluginManifest, + profile: ManifestValidationProfile, +) -> anyhow::Result<()> { if manifest.id.trim().is_empty() { anyhow::bail!("plugin id cannot be empty"); } @@ -147,7 +162,9 @@ pub fn validate_manifest(manifest: &PluginManifest) -> anyhow::Result<()> { anyhow::bail!("plugin version cannot be empty"); } } - if manifest.module_path.trim().is_empty() { + if matches!(profile, ManifestValidationProfile::RuntimeWasm) + && manifest.module_path.trim().is_empty() + { anyhow::bail!("plugin module_path cannot be empty"); } let mut declared_wit_packages = HashSet::new(); @@ -204,6 +221,10 @@ pub fn validate_manifest(manifest: &PluginManifest) -> anyhow::Result<()> { Ok(()) } +pub fn validate_manifest(manifest: &PluginManifest) -> anyhow::Result<()> { + validate_manifest_with_profile(manifest, ManifestValidationProfile::RuntimeWasm) +} + impl PluginManifest { pub fn is_valid(&self) -> bool { validate_manifest(self).is_ok() @@ -343,6 +364,26 @@ id = " " assert!(validate_manifest(&manifest).is_err()); } + #[test] + fn schema_only_validation_allows_empty_module_path() { + let manifest = PluginManifest { + id: "demo".into(), + name: None, + description: None, + version: Some("1.0.0".into()), + config_schema: None, + capabilities: vec![], + module_path: " ".into(), + wit_packages: vec![], + tools: vec![], + providers: vec![], + }; + assert!( + validate_manifest_with_profile(&manifest, ManifestValidationProfile::SchemaOnly) + .is_ok() + ); + } + #[test] fn manifest_rejects_capability_without_matching_wit_package() { let manifest = PluginManifest { @@ -400,4 +441,21 @@ id = " " }; assert!(validate_manifest(&manifest).is_err()); } + + #[test] + fn manifest_rejects_providers_without_providers_wit_package() { + let manifest = PluginManifest { + id: "demo".into(), + name: None, + description: None, + version: Some("1.0.0".into()), + config_schema: None, + capabilities: vec![], + module_path: "plugins/demo.wasm".into(), + wit_packages: vec!["zeroclaw:hooks@1.0.0".into()], + tools: vec![], + providers: vec!["demo_provider".into()], + }; + assert!(validate_manifest(&manifest).is_err()); + } }