diff --git a/Cargo.lock b/Cargo.lock index 68b15ebc0bb..5ca9d7c0f0e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4060,6 +4060,21 @@ dependencies = [ "uuid", ] +[[package]] +name = "ironclaw_extensions" +version = "0.1.0" +dependencies = [ + "ironclaw_filesystem", + "ironclaw_host_api", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", + "toml 0.8.23", + "url", +] + [[package]] name = "ironclaw_filesystem" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 838102ac212..be3fa89d8a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = [".", "crates/ironclaw_common", "crates/ironclaw_host_api", "crates/ironclaw_filesystem", "crates/ironclaw_events", "crates/ironclaw_authorization", "crates/ironclaw_run_state", "crates/ironclaw_approvals", "crates/ironclaw_resources", "crates/ironclaw_architecture", "crates/ironclaw_safety", "crates/ironclaw_skills", "crates/ironclaw_engine", "crates/ironclaw_gateway", "crates/ironclaw_tui"] +members = [".", "crates/ironclaw_common", "crates/ironclaw_host_api", "crates/ironclaw_filesystem", "crates/ironclaw_events", "crates/ironclaw_extensions", "crates/ironclaw_authorization", "crates/ironclaw_run_state", "crates/ironclaw_approvals", "crates/ironclaw_resources", "crates/ironclaw_architecture", "crates/ironclaw_safety", "crates/ironclaw_skills", "crates/ironclaw_engine", "crates/ironclaw_gateway", "crates/ironclaw_tui"] exclude = [ "channels-src/discord", "channels-src/telegram", diff --git a/crates/ironclaw_extensions/Cargo.toml b/crates/ironclaw_extensions/Cargo.toml new file mode 100644 index 00000000000..7c64eb3aaa3 --- /dev/null +++ b/crates/ironclaw_extensions/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "ironclaw_extensions" +version = "0.1.0" +edition = "2024" +rust-version = "1.92" +description = "Extension manifest and registry contracts for IronClaw Reborn" +authors = ["NEAR AI "] +license = "MIT OR Apache-2.0" +homepage = "https://github.com/nearai/ironclaw" +repository = "https://github.com/nearai/ironclaw" +publish = false + +[dependencies] +ironclaw_filesystem = { path = "../ironclaw_filesystem", version = "0.1.0" } +ironclaw_host_api = { path = "../ironclaw_host_api", version = "0.1.0" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +toml = "0.8" +url = "2" + +[dev-dependencies] +tempfile = "3" +tokio = { version = "1", features = ["macros", "rt"] } diff --git a/crates/ironclaw_extensions/src/lib.rs b/crates/ironclaw_extensions/src/lib.rs new file mode 100644 index 00000000000..68ecdf6b6e1 --- /dev/null +++ b/crates/ironclaw_extensions/src/lib.rs @@ -0,0 +1,666 @@ +//! Extension manifest and registry contracts for IronClaw Reborn. +//! +//! `ironclaw_extensions` discovers and validates extension packages, extracts +//! capability descriptors, and records declarative runtime metadata. It does not +//! execute WASM modules, start Docker containers, connect to MCP servers, resolve +//! secrets, or reserve resources. + +use std::collections::{HashMap, HashSet}; + +use ironclaw_filesystem::{FileType, FilesystemError, RootFilesystem}; +use ironclaw_host_api::{ + CapabilityDescriptor, CapabilityId, EffectKind, ExtensionId, HostApiError, PermissionMode, + ResourceProfile, RuntimeKind, TrustClass, VirtualPath, +}; +use serde::Deserialize; +use thiserror::Error; + +/// Extension manifest and registry failures. +#[derive(Debug, Error)] +pub enum ExtensionError { + #[error(transparent)] + Contract(#[from] HostApiError), + #[error("failed to parse extension manifest: {reason}")] + ManifestParse { reason: String }, + #[error("invalid extension manifest: {reason}")] + InvalidManifest { reason: String }, + #[error("invalid extension asset path '{path}': {reason}")] + InvalidAssetPath { path: String, reason: String }, + #[error("extension manifest id mismatch at {root:?}: expected {expected}, actual {actual}")] + ManifestIdMismatch { + root: VirtualPath, + expected: ExtensionId, + actual: ExtensionId, + }, + #[error("duplicate extension id {id}")] + DuplicateExtension { id: ExtensionId }, + #[error("duplicate capability id {id}")] + DuplicateCapability { id: CapabilityId }, + #[error(transparent)] + Filesystem(#[from] FilesystemError), +} + +/// Manifest-local path for assets such as WASM modules. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ExtensionAssetPath(String); + +impl ExtensionAssetPath { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + validate_asset_path(&value)?; + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn resolve_under(&self, root: &VirtualPath) -> Result { + VirtualPath::new(format!( + "{}/{}", + root.as_str().trim_end_matches('/'), + self.0 + )) + .map_err(ExtensionError::from) + } +} + +/// Declarative runtime metadata for an extension package. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ExtensionRuntime { + Wasm { + module: ExtensionAssetPath, + }, + Script { + runner: String, + image: Option, + command: String, + args: Vec, + }, + Mcp { + transport: String, + command: Option, + args: Vec, + url: Option, + }, + FirstParty { + service: String, + }, + System { + service: String, + }, +} + +impl ExtensionRuntime { + pub fn kind(&self) -> RuntimeKind { + match self { + Self::Wasm { .. } => RuntimeKind::Wasm, + Self::Script { .. } => RuntimeKind::Script, + Self::Mcp { .. } => RuntimeKind::Mcp, + Self::FirstParty { .. } => RuntimeKind::FirstParty, + Self::System { .. } => RuntimeKind::System, + } + } +} + +/// Validated extension manifest. +#[derive(Debug, Clone, PartialEq)] +pub struct ExtensionManifest { + pub id: ExtensionId, + pub name: String, + pub version: String, + pub description: String, + pub trust: TrustClass, + pub runtime: ExtensionRuntime, + pub capabilities: Vec, +} + +impl ExtensionManifest { + pub fn parse(input: &str) -> Result { + let raw: RawManifest = + toml::from_str(input).map_err(|error| ExtensionError::ManifestParse { + reason: error.to_string(), + })?; + Self::from_raw(raw) + } + + pub fn runtime_kind(&self) -> RuntimeKind { + self.runtime.kind() + } + + fn from_raw(raw: RawManifest) -> Result { + if raw.name.trim().is_empty() { + return Err(ExtensionError::InvalidManifest { + reason: "name must not be empty".to_string(), + }); + } + if raw.version.trim().is_empty() { + return Err(ExtensionError::InvalidManifest { + reason: "version must not be empty".to_string(), + }); + } + if raw.capabilities.is_empty() { + return Err(ExtensionError::InvalidManifest { + reason: "at least one capability is required".to_string(), + }); + } + + let id = ExtensionId::new(raw.id)?; + let runtime = raw.runtime.into_runtime()?; + let capabilities = raw + .capabilities + .into_iter() + .map(CapabilityManifest::from_raw) + .collect::, _>>()?; + + Ok(Self { + id, + name: raw.name, + version: raw.version, + description: raw.description, + trust: raw.trust, + runtime, + capabilities, + }) + } +} + +/// Manifest capability declaration before registry/package context is applied. +#[derive(Debug, Clone, PartialEq)] +pub struct CapabilityManifest { + pub id: CapabilityId, + pub description: String, + pub effects: Vec, + pub default_permission: PermissionMode, + pub parameters_schema: serde_json::Value, + pub resource_profile: Option, +} + +impl CapabilityManifest { + fn from_raw(raw: RawCapability) -> Result { + if raw.description.trim().is_empty() { + return Err(ExtensionError::InvalidManifest { + reason: format!("capability {} description must not be empty", raw.id), + }); + } + Ok(Self { + id: CapabilityId::new(raw.id)?, + description: raw.description, + effects: raw.effects, + default_permission: raw.default_permission, + parameters_schema: raw.parameters_schema, + resource_profile: raw.resource_profile, + }) + } +} + +/// Validated package rooted under `/system/extensions/`. +#[derive(Debug, Clone, PartialEq)] +pub struct ExtensionPackage { + pub id: ExtensionId, + pub root: VirtualPath, + pub manifest: ExtensionManifest, + pub capabilities: Vec, +} + +impl ExtensionPackage { + pub fn from_manifest( + manifest: ExtensionManifest, + root: VirtualPath, + ) -> Result { + ensure_extension_root_matches(&manifest.id, &root)?; + let expected_prefix = format!("{}.", manifest.id.as_str()); + let mut seen_capabilities = HashSet::new(); + let capabilities = manifest + .capabilities + .iter() + .map(|capability| { + if !capability.id.as_str().starts_with(&expected_prefix) { + return Err(ExtensionError::InvalidManifest { + reason: format!( + "capability id {} must be provider-prefixed with {}", + capability.id.as_str(), + expected_prefix + ), + }); + } + if !seen_capabilities.insert(capability.id.clone()) { + return Err(ExtensionError::DuplicateCapability { + id: capability.id.clone(), + }); + } + Ok(CapabilityDescriptor { + id: capability.id.clone(), + provider: manifest.id.clone(), + runtime: manifest.runtime_kind(), + trust_ceiling: manifest.trust, + description: capability.description.clone(), + parameters_schema: capability.parameters_schema.clone(), + effects: capability.effects.clone(), + default_permission: capability.default_permission, + resource_profile: capability.resource_profile.clone(), + }) + }) + .collect::, ExtensionError>>()?; + + Ok(Self { + id: manifest.id.clone(), + root, + manifest, + capabilities, + }) + } +} + +/// Registry of validated extension packages and declared capabilities. +#[derive(Debug, Default)] +pub struct ExtensionRegistry { + packages: HashMap, + capabilities: HashMap, + extension_order: Vec, + capability_order: Vec, +} + +impl ExtensionRegistry { + pub fn new() -> Self { + Self::default() + } + + pub fn insert(&mut self, package: ExtensionPackage) -> Result<(), ExtensionError> { + validate_package_consistency(&package)?; + + if self.packages.contains_key(&package.id) { + return Err(ExtensionError::DuplicateExtension { id: package.id }); + } + + let mut seen_capabilities = HashSet::new(); + for descriptor in &package.capabilities { + if !seen_capabilities.insert(descriptor.id.clone()) + || self.capabilities.contains_key(&descriptor.id) + { + return Err(ExtensionError::DuplicateCapability { + id: descriptor.id.clone(), + }); + } + if descriptor.provider != package.id { + return Err(ExtensionError::InvalidManifest { + reason: format!( + "descriptor {} provider {} does not match package {}", + descriptor.id, descriptor.provider, package.id + ), + }); + } + } + + for descriptor in &package.capabilities { + self.capability_order.push(descriptor.id.clone()); + self.capabilities + .insert(descriptor.id.clone(), descriptor.clone()); + } + self.extension_order.push(package.id.clone()); + self.packages.insert(package.id.clone(), package); + Ok(()) + } + + pub fn get_extension(&self, id: &ExtensionId) -> Option<&ExtensionPackage> { + self.packages.get(id) + } + + pub fn get_capability(&self, id: &CapabilityId) -> Option<&CapabilityDescriptor> { + self.capabilities.get(id) + } + + pub fn extensions(&self) -> impl Iterator { + self.extension_order + .iter() + .filter_map(|id| self.packages.get(id)) + } + + pub fn capabilities(&self) -> impl Iterator { + self.capability_order + .iter() + .filter_map(|id| self.capabilities.get(id)) + } +} + +fn validate_package_consistency(package: &ExtensionPackage) -> Result<(), ExtensionError> { + let expected = ExtensionPackage::from_manifest(package.manifest.clone(), package.root.clone())?; + if package.id != expected.id { + return Err(ExtensionError::InvalidManifest { + reason: format!( + "package id {} does not match manifest/root id {}", + package.id, expected.id + ), + }); + } + if package.capabilities != expected.capabilities { + return Err(ExtensionError::InvalidManifest { + reason: "package capability descriptors do not match manifest declarations".to_string(), + }); + } + Ok(()) +} + +/// Filesystem-backed extension discovery. +pub struct ExtensionDiscovery; + +impl ExtensionDiscovery { + pub async fn discover( + fs: &F, + root: &VirtualPath, + ) -> Result + where + F: RootFilesystem, + { + let mut entries = fs.list_dir(root).await?; + entries.sort_by(|left, right| left.name.cmp(&right.name)); + + let mut registry = ExtensionRegistry::new(); + for entry in entries { + if entry.file_type != FileType::Directory { + continue; + } + let Ok(expected) = ExtensionId::new(entry.name.clone()) else { + continue; + }; + let manifest_path = VirtualPath::new(format!( + "{}/{}/manifest.toml", + root.as_str().trim_end_matches('/'), + entry.name + ))?; + let bytes = fs.read_file(&manifest_path).await?; + let text = String::from_utf8(bytes).map_err(|error| ExtensionError::ManifestParse { + reason: error.to_string(), + })?; + let manifest = ExtensionManifest::parse(&text)?; + if manifest.id != expected { + return Err(ExtensionError::ManifestIdMismatch { + root: entry.path, + expected, + actual: manifest.id, + }); + } + let package = ExtensionPackage::from_manifest(manifest, entry.path)?; + registry.insert(package)?; + } + + Ok(registry) + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawManifest { + id: String, + name: String, + version: String, + description: String, + trust: TrustClass, + runtime: RawRuntime, + #[serde(default)] + capabilities: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +enum RawRuntime { + Wasm { + module: String, + }, + Script { + runner: Option, + backend: Option, + image: Option, + command: String, + #[serde(default)] + args: Vec, + }, + Mcp { + transport: String, + command: Option, + #[serde(default)] + args: Vec, + url: Option, + }, + FirstParty { + service: String, + }, + System { + service: String, + }, +} + +impl RawRuntime { + fn into_runtime(self) -> Result { + match self { + Self::Wasm { module } => Ok(ExtensionRuntime::Wasm { + module: ExtensionAssetPath::new(module)?, + }), + Self::Script { + runner, + backend, + image, + command, + args, + } => { + let runner = match (runner, backend) { + (Some(runner), None) => runner, + (None, Some(backend)) => backend, + (Some(_), Some(_)) => { + return Err(ExtensionError::InvalidManifest { + reason: "script runtime must specify either runner or legacy backend, not both".to_string(), + }); + } + (None, None) => { + return Err(ExtensionError::InvalidManifest { + reason: "script runtime runner is required".to_string(), + }); + } + }; + validate_non_empty("script runner", &runner)?; + if runner == "docker" { + let image = image.as_deref().unwrap_or_default(); + validate_non_empty("script image", image)?; + } + validate_non_empty("script command", &command)?; + Ok(ExtensionRuntime::Script { + runner, + image, + command, + args, + }) + } + Self::Mcp { + transport, + command, + args, + url, + } => { + validate_mcp_runtime_shape(&transport, command.as_deref(), url.as_deref())?; + Ok(ExtensionRuntime::Mcp { + transport, + command, + args, + url, + }) + } + Self::FirstParty { service } => { + validate_non_empty("first-party service", &service)?; + Err(ExtensionError::InvalidManifest { + reason: "first-party and system runtimes are host-assigned and cannot be self-asserted by manifests".to_string(), + }) + } + Self::System { service } => { + validate_non_empty("system service", &service)?; + Err(ExtensionError::InvalidManifest { + reason: "first-party and system runtimes are host-assigned and cannot be self-asserted by manifests".to_string(), + }) + } + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawCapability { + id: String, + description: String, + effects: Vec, + default_permission: PermissionMode, + parameters_schema: serde_json::Value, + #[serde(default)] + resource_profile: Option, +} + +fn ensure_extension_root_matches( + id: &ExtensionId, + root: &VirtualPath, +) -> Result<(), ExtensionError> { + let expected = extension_id_from_package_root(root)?; + if &expected != id { + return Err(ExtensionError::ManifestIdMismatch { + root: root.clone(), + expected, + actual: id.clone(), + }); + } + Ok(()) +} + +fn extension_id_from_package_root(root: &VirtualPath) -> Result { + let Some(extension_id) = root.as_str().strip_prefix("/system/extensions/") else { + return Err(invalid_package_root(root)); + }; + if extension_id.is_empty() || extension_id.contains('/') { + return Err(invalid_package_root(root)); + } + Ok(ExtensionId::new(extension_id.to_string())?) +} + +fn invalid_package_root(root: &VirtualPath) -> ExtensionError { + ExtensionError::InvalidManifest { + reason: format!( + "extension package root {} must be /system/extensions/", + root.as_str() + ), + } +} + +fn validate_asset_path(value: &str) -> Result<(), ExtensionError> { + if value.is_empty() { + return Err(ExtensionError::InvalidAssetPath { + path: value.to_string(), + reason: "asset path must not be empty".to_string(), + }); + } + if value.contains('') || value.chars().any(char::is_control) { + return Err(ExtensionError::InvalidAssetPath { + path: value.to_string(), + reason: "NUL/control characters are not allowed".to_string(), + }); + } + if value.contains("://") { + return Err(ExtensionError::InvalidAssetPath { + path: value.to_string(), + reason: "URLs are not extension asset paths".to_string(), + }); + } + if value.starts_with('/') { + return Err(ExtensionError::InvalidAssetPath { + path: value.to_string(), + reason: "asset path must be relative".to_string(), + }); + } + if looks_like_windows_path(value) || value.contains('\\') { + return Err(ExtensionError::InvalidAssetPath { + path: value.to_string(), + reason: "host path separators are not allowed".to_string(), + }); + } + for segment in value.split('/') { + if segment.is_empty() || segment == "." || segment == ".." { + return Err(ExtensionError::InvalidAssetPath { + path: value.to_string(), + reason: "empty or dot path segments are not allowed".to_string(), + }); + } + } + Ok(()) +} + +fn validate_mcp_runtime_shape( + transport: &str, + command: Option<&str>, + url: Option<&str>, +) -> Result<(), ExtensionError> { + validate_non_empty("mcp transport", transport)?; + if let Some(command) = command { + validate_non_empty("mcp command", command)?; + } + if let Some(url) = url { + validate_non_empty("mcp url", url)?; + } + + match transport { + "stdio" => { + if url.is_some() { + return Err(ExtensionError::InvalidManifest { + reason: "mcp stdio transport must not specify url".to_string(), + }); + } + if command.is_none() { + return Err(ExtensionError::InvalidManifest { + reason: "mcp stdio transport requires command".to_string(), + }); + } + } + "http" | "sse" => { + if command.is_some() { + return Err(ExtensionError::InvalidManifest { + reason: format!("mcp {transport} transport must not specify command"), + }); + } + let Some(url) = url else { + return Err(ExtensionError::InvalidManifest { + reason: format!("mcp {transport} transport requires url"), + }); + }; + validate_mcp_http_url(transport, url)?; + } + _ => { + return Err(ExtensionError::InvalidManifest { + reason: "mcp transport must be one of stdio, http, or sse".to_string(), + }); + } + } + + Ok(()) +} + +fn validate_mcp_http_url(transport: &str, value: &str) -> Result<(), ExtensionError> { + let parsed = url::Url::parse(value).map_err(|_| ExtensionError::InvalidManifest { + reason: format!("mcp {transport} transport URL must be absolute http(s) URL"), + })?; + if !matches!(parsed.scheme(), "http" | "https") { + return Err(ExtensionError::InvalidManifest { + reason: format!("mcp {transport} transport URL must use http or https"), + }); + } + Ok(()) +} + +fn validate_non_empty(kind: &str, value: &str) -> Result<(), ExtensionError> { + if value.trim().is_empty() { + Err(ExtensionError::InvalidManifest { + reason: format!("{kind} must not be empty"), + }) + } else { + Ok(()) + } +} + +fn looks_like_windows_path(value: &str) -> bool { + let bytes = value.as_bytes(); + (bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':') + || (bytes.len() >= 3 && bytes[1] == b':' && (bytes[2] == b'\\' || bytes[2] == b'/')) +} diff --git a/crates/ironclaw_extensions/tests/extension_contract.rs b/crates/ironclaw_extensions/tests/extension_contract.rs new file mode 100644 index 00000000000..6ef967ce977 --- /dev/null +++ b/crates/ironclaw_extensions/tests/extension_contract.rs @@ -0,0 +1,920 @@ +use ironclaw_extensions::*; +use ironclaw_filesystem::*; +use ironclaw_host_api::*; +use tempfile::tempdir; + +#[test] +fn valid_wasm_manifest_parses_and_extracts_capability_descriptor() { + let manifest = ExtensionManifest::parse(WASM_MANIFEST).unwrap(); + assert_eq!(manifest.id.as_str(), "echo"); + assert_eq!(manifest.trust, TrustClass::Sandbox); + assert!(matches!( + manifest.runtime, + ExtensionRuntime::Wasm { ref module } if module.as_str() == "wasm/echo.wasm" + )); + + let package = ExtensionPackage::from_manifest( + manifest, + VirtualPath::new("/system/extensions/echo").unwrap(), + ) + .unwrap(); + assert_eq!(package.capabilities.len(), 1); + + let descriptor = &package.capabilities[0]; + assert_eq!(descriptor.id.as_str(), "echo.say"); + assert_eq!(descriptor.provider.as_str(), "echo"); + assert_eq!(descriptor.runtime, RuntimeKind::Wasm); + assert_eq!(descriptor.trust_ceiling, TrustClass::Sandbox); + assert_eq!(descriptor.default_permission, PermissionMode::Allow); + assert_eq!(descriptor.effects, vec![EffectKind::DispatchCapability]); + assert_eq!(descriptor.parameters_schema["type"], "object"); +} + +#[test] +fn invalid_extension_id_is_rejected() { + let err = + ExtensionManifest::parse(&WASM_MANIFEST.replace("id = \"echo\"", "id = \"Echo/Bad\"")) + .unwrap_err(); + assert!(matches!(err, ExtensionError::Contract(_))); +} + +#[test] +fn capability_id_must_be_prefixed_by_provider_extension() { + let manifest = + ExtensionManifest::parse(&WASM_MANIFEST.replace("echo.say", "other.say")).unwrap(); + let err = ExtensionPackage::from_manifest( + manifest, + VirtualPath::new("/system/extensions/echo").unwrap(), + ) + .unwrap_err(); + + assert!(matches!( + err, + ExtensionError::InvalidManifest { reason } if reason.contains("provider-prefixed") + )); +} + +#[test] +fn script_runtime_keeps_runner_metadata_without_execution() { + let manifest = ExtensionManifest::parse(SCRIPT_MANIFEST).unwrap(); + assert_eq!(manifest.runtime_kind(), RuntimeKind::Script); + assert!(matches!( + manifest.runtime, + ExtensionRuntime::Script { + ref runner, + image: Some(ref image), + ref command, + ref args, + } if runner == "docker" && image == "python:3.12-slim" && command == "pytest" && args == &["tests/".to_string()] + )); + + let descriptor = ExtensionPackage::from_manifest( + manifest, + VirtualPath::new("/system/extensions/project-tools").unwrap(), + ) + .unwrap() + .capabilities + .remove(0); + assert_eq!(descriptor.runtime, RuntimeKind::Script); + assert_eq!(descriptor.effects, vec![EffectKind::ExecuteCode]); +} + +#[test] +fn mcp_runtime_keeps_transport_metadata_without_connecting() { + let manifest = ExtensionManifest::parse(MCP_MANIFEST).unwrap(); + assert_eq!(manifest.runtime_kind(), RuntimeKind::Mcp); + assert_eq!(manifest.trust, TrustClass::UserTrusted); + assert!(matches!( + manifest.runtime, + ExtensionRuntime::Mcp { + ref transport, + ref command, + ref args, + url: None, + } if transport == "stdio" && command.as_deref() == Some("github-mcp-server") && args == &["--stdio".to_string()] + )); +} + +#[test] +fn invalid_manifest_asset_paths_are_rejected() { + for invalid in [ + "/Users/alice/echo.wasm", + "/workspace/echo.wasm", + "../echo.wasm", + "wasm\\\\echo.wasm", + "https://example.com/echo.wasm", + "wasm/has\\u0000nul.wasm", + "C:evil.wasm", + ] { + let manifest = WASM_MANIFEST.replace("wasm/echo.wasm", invalid); + assert!( + matches!( + ExtensionManifest::parse(&manifest), + Err(ExtensionError::InvalidAssetPath { .. }) + ), + "{invalid:?} should be rejected" + ); + } +} + +#[test] +fn registry_rejects_duplicate_extension_ids_and_mutated_packages() { + let package = ExtensionPackage::from_manifest( + ExtensionManifest::parse(WASM_MANIFEST).unwrap(), + VirtualPath::new("/system/extensions/echo").unwrap(), + ) + .unwrap(); + let duplicate_extension = package.clone(); + let mut mutated_package = package.clone(); + mutated_package.id = ExtensionId::new("echo2").unwrap(); + mutated_package.capabilities[0].provider = ExtensionId::new("echo2").unwrap(); + + let mut registry = ExtensionRegistry::new(); + registry.insert(package).unwrap(); + + assert!(matches!( + registry.insert(duplicate_extension), + Err(ExtensionError::DuplicateExtension { .. }) + )); + assert!(matches!( + registry.insert(mutated_package), + Err(ExtensionError::InvalidManifest { reason }) if reason.contains("does not match") + )); +} + +#[test] +fn registry_revalidates_public_package_descriptors_against_manifest() { + let mut package = ExtensionPackage::from_manifest( + ExtensionManifest::parse(WASM_MANIFEST).unwrap(), + VirtualPath::new("/system/extensions/echo").unwrap(), + ) + .unwrap(); + package.capabilities[0].runtime = RuntimeKind::System; + + let mut registry = ExtensionRegistry::new(); + assert!(matches!( + registry.insert(package), + Err(ExtensionError::InvalidManifest { reason }) if reason.contains("manifest") + )); +} + +#[test] +fn registry_rejects_duplicate_capability_ids_within_inserted_package() { + let mut package = ExtensionPackage::from_manifest( + ExtensionManifest::parse(WASM_MANIFEST).unwrap(), + VirtualPath::new("/system/extensions/echo").unwrap(), + ) + .unwrap(); + package.capabilities.push(package.capabilities[0].clone()); + + let mut registry = ExtensionRegistry::new(); + assert!(matches!( + registry.insert(package), + Err(ExtensionError::InvalidManifest { reason }) if reason.contains("manifest") + )); +} + +#[test] +fn registry_capabilities_iterate_in_extension_and_manifest_order() { + let alpha = ExtensionPackage::from_manifest( + ExtensionManifest::parse(ORDERED_CAPABILITIES_MANIFEST).unwrap(), + VirtualPath::new("/system/extensions/ordered").unwrap(), + ) + .unwrap(); + let beta_manifest = ORDERED_CAPABILITIES_MANIFEST + .replace("id = \"ordered\"", "id = \"beta\"") + .replace("ordered.", "beta."); + let beta = ExtensionPackage::from_manifest( + ExtensionManifest::parse(&beta_manifest).unwrap(), + VirtualPath::new("/system/extensions/beta").unwrap(), + ) + .unwrap(); + + let mut registry = ExtensionRegistry::new(); + registry.insert(alpha).unwrap(); + registry.insert(beta).unwrap(); + + let ids: Vec<_> = registry + .capabilities() + .map(|descriptor| descriptor.id.as_str().to_string()) + .collect(); + + assert_eq!( + ids, + vec![ + "ordered.alpha", + "ordered.bravo", + "ordered.charlie", + "ordered.delta", + "ordered.echo", + "beta.alpha", + "beta.bravo", + "beta.charlie", + "beta.delta", + "beta.echo", + ] + ); +} + +#[tokio::test] +async fn discovery_reads_manifests_from_filesystem_virtual_root() { + let storage = tempdir().unwrap(); + std::fs::create_dir_all(storage.path().join("echo")).unwrap(); + std::fs::write(storage.path().join("echo/manifest.toml"), WASM_MANIFEST).unwrap(); + + let mut fs = LocalFilesystem::new(); + fs.mount_local( + VirtualPath::new("/system/extensions").unwrap(), + HostPath::from_path_buf(storage.path().to_path_buf()), + ) + .unwrap(); + + let registry = + ExtensionDiscovery::discover(&fs, &VirtualPath::new("/system/extensions").unwrap()) + .await + .unwrap(); + + assert!( + registry + .get_extension(&ExtensionId::new("echo").unwrap()) + .is_some() + ); + assert!( + registry + .get_capability(&CapabilityId::new("echo.say").unwrap()) + .is_some() + ); +} + +#[tokio::test] +async fn discovery_rejects_missing_manifest() { + let storage = tempdir().unwrap(); + std::fs::create_dir_all(storage.path().join("echo")).unwrap(); + + let mut fs = LocalFilesystem::new(); + fs.mount_local( + VirtualPath::new("/system/extensions").unwrap(), + HostPath::from_path_buf(storage.path().to_path_buf()), + ) + .unwrap(); + + let err = ExtensionDiscovery::discover(&fs, &VirtualPath::new("/system/extensions").unwrap()) + .await + .unwrap_err(); + + assert!(matches!(err, ExtensionError::Filesystem(_))); +} + +#[tokio::test] +async fn discovery_rejects_manifest_id_mismatch_with_directory() { + let storage = tempdir().unwrap(); + std::fs::create_dir_all(storage.path().join("wrong-dir")).unwrap(); + std::fs::write( + storage.path().join("wrong-dir/manifest.toml"), + WASM_MANIFEST, + ) + .unwrap(); + + let mut fs = LocalFilesystem::new(); + fs.mount_local( + VirtualPath::new("/system/extensions").unwrap(), + HostPath::from_path_buf(storage.path().to_path_buf()), + ) + .unwrap(); + + let err = ExtensionDiscovery::discover(&fs, &VirtualPath::new("/system/extensions").unwrap()) + .await + .unwrap_err(); + + assert!(matches!( + err, + ExtensionError::ManifestIdMismatch { + expected, + actual, + .. + } if expected.as_str() == "wrong-dir" && actual.as_str() == "echo" + )); +} + +const WASM_MANIFEST: &str = r#" +id = "echo" +name = "Echo" +version = "0.1.0" +description = "Echo demo extension" +trust = "sandbox" + +[runtime] +kind = "wasm" +module = "wasm/echo.wasm" + +[[capabilities]] +id = "echo.say" +description = "Echo text" +effects = ["dispatch_capability"] +default_permission = "allow" +parameters_schema = { type = "object" } +"#; + +const ORDERED_CAPABILITIES_MANIFEST: &str = r#" +id = "ordered" +name = "Ordered" +version = "0.1.0" +description = "Ordered capability demo extension" +trust = "sandbox" + +[runtime] +kind = "wasm" +module = "wasm/ordered.wasm" + +[[capabilities]] +id = "ordered.alpha" +description = "First capability" +effects = ["dispatch_capability"] +default_permission = "allow" +parameters_schema = { type = "object" } + +[[capabilities]] +id = "ordered.bravo" +description = "Second capability" +effects = ["dispatch_capability"] +default_permission = "ask" +parameters_schema = { type = "object" } + +[[capabilities]] +id = "ordered.charlie" +description = "Third capability" +effects = ["dispatch_capability"] +default_permission = "deny" +parameters_schema = { type = "object" } + +[[capabilities]] +id = "ordered.delta" +description = "Fourth capability" +effects = ["dispatch_capability"] +default_permission = "allow" +parameters_schema = { type = "object" } + +[[capabilities]] +id = "ordered.echo" +description = "Fifth capability" +effects = ["dispatch_capability"] +default_permission = "ask" +parameters_schema = { type = "object" } +"#; + +const SCRIPT_MANIFEST: &str = r#" +id = "project-tools" +name = "Project Tools" +version = "0.1.0" +description = "Project-local CLI helpers" +trust = "sandbox" + +[runtime] +kind = "script" +backend = "docker" +image = "python:3.12-slim" +command = "pytest" +args = ["tests/"] + +[[capabilities]] +id = "project-tools.pytest" +description = "Run pytest" +effects = ["execute_code"] +default_permission = "ask" +parameters_schema = { type = "object" } +"#; + +const SCRIPT_RUNNER_MANIFEST: &str = r#" +id = "project-tools" +name = "Project Tools" +version = "0.1.0" +description = "Project-local CLI helpers" +trust = "sandbox" + +[runtime] +kind = "script" +runner = "sandboxed_process" +command = "pytest" +args = ["tests/"] + +[[capabilities]] +id = "project-tools.pytest" +description = "Run pytest" +effects = ["execute_code"] +default_permission = "ask" +parameters_schema = { type = "object" } +"#; + +const MCP_MANIFEST: &str = r#" +id = "github-mcp" +name = "GitHub MCP" +version = "0.1.0" +description = "GitHub MCP adapter" +trust = "user_trusted" + +[runtime] +kind = "mcp" +transport = "stdio" +command = "github-mcp-server" +args = ["--stdio"] + +[[capabilities]] +id = "github-mcp.search_issues" +description = "Search GitHub issues" +effects = ["network", "dispatch_capability"] +default_permission = "ask" +parameters_schema = { type = "object" } +"#; + +#[test] +fn malformed_or_incomplete_manifest_fails() { + assert!(matches!( + ExtensionManifest::parse("not = [valid"), + Err(ExtensionError::ManifestParse { .. }) + )); + + let missing_name = WASM_MANIFEST.replace("name = \"Echo\"\n", ""); + assert!(matches!( + ExtensionManifest::parse(&missing_name), + Err(ExtensionError::ManifestParse { .. }) + )); + + let unknown_field = + WASM_MANIFEST.replace("version = \"0.1.0\"", "version = \"0.1.0\"\nunknown = true"); + assert!(matches!( + ExtensionManifest::parse(&unknown_field), + Err(ExtensionError::ManifestParse { .. }) + )); + + let no_capabilities = r#" +id = "empty" +name = "Empty" +version = "0.1.0" +description = "No capabilities" +trust = "sandbox" + +[runtime] +kind = "wasm" +module = "wasm/empty.wasm" +"#; + assert!(matches!( + ExtensionManifest::parse(no_capabilities), + Err(ExtensionError::InvalidManifest { reason }) if reason.contains("capability") + )); +} + +#[test] +fn package_root_must_match_manifest_id() { + let manifest = ExtensionManifest::parse(WASM_MANIFEST).unwrap(); + let err = ExtensionPackage::from_manifest( + manifest, + VirtualPath::new("/system/extensions/not-echo").unwrap(), + ) + .unwrap_err(); + + assert!(matches!( + err, + ExtensionError::ManifestIdMismatch { expected, actual, .. } + if expected.as_str() == "not-echo" && actual.as_str() == "echo" + )); +} + +#[test] +fn package_root_must_be_direct_extension_directory() { + for root in [ + "/system/extensions", + "/system/extensions/echo/nested", + "/projects/echo", + ] { + let manifest = ExtensionManifest::parse(WASM_MANIFEST).unwrap(); + let err = + ExtensionPackage::from_manifest(manifest, VirtualPath::new(root).unwrap()).unwrap_err(); + + assert!( + matches!( + err, + ExtensionError::InvalidManifest { reason } + if reason.contains("/system/extensions/") + ), + "{root:?} should be rejected as an invalid package root" + ); + } +} + +#[test] +fn package_rejects_duplicate_capabilities_within_manifest() { + let duplicate_manifest = WASM_MANIFEST.to_string() + + r#" +[[capabilities]] +id = "echo.say" +description = "Duplicate Echo" +effects = ["dispatch_capability"] +default_permission = "allow" +parameters_schema = { type = "object" } +"#; + let manifest = ExtensionManifest::parse(&duplicate_manifest).unwrap(); + let err = ExtensionPackage::from_manifest( + manifest, + VirtualPath::new("/system/extensions/echo").unwrap(), + ) + .unwrap_err(); + + assert!(matches!( + err, + ExtensionError::DuplicateCapability { id } if id.as_str() == "echo.say" + )); +} + +#[test] +fn script_runtime_accepts_semantic_runner_without_docker_backend() { + let manifest = ExtensionManifest::parse(SCRIPT_RUNNER_MANIFEST).unwrap(); + + assert!(matches!( + manifest.runtime, + ExtensionRuntime::Script { + ref runner, + image: None, + ref command, + ref args, + } if runner == "sandboxed_process" && command == "pytest" && args == &["tests/".to_string()] + )); +} + +#[test] +fn script_runtime_rejects_runner_and_legacy_backend_together() { + let manifest = SCRIPT_MANIFEST.replace( + "backend = \"docker\"", + "backend = \"docker\"\nrunner = \"sandboxed_process\"", + ); + let err = ExtensionManifest::parse(&manifest).unwrap_err(); + + assert!(matches!( + err, + ExtensionError::InvalidManifest { reason } if reason.contains("either runner or legacy backend") + )); +} + +#[test] +fn first_party_and_system_runtimes_cannot_be_self_asserted_by_manifests() { + assert!(matches!( + ExtensionManifest::parse(FIRST_PARTY_MANIFEST), + Err(ExtensionError::ManifestParse { .. }) + )); + assert!(matches!( + ExtensionManifest::parse(SYSTEM_MANIFEST), + Err(ExtensionError::ManifestParse { .. }) + )); + assert!(matches!( + ExtensionManifest::parse(SANDBOX_FIRST_PARTY_RUNTIME_MANIFEST), + Err(ExtensionError::InvalidManifest { reason }) if reason.contains("host-assigned") + )); + assert!(matches!( + ExtensionManifest::parse(SANDBOX_SYSTEM_RUNTIME_MANIFEST), + Err(ExtensionError::InvalidManifest { reason }) if reason.contains("host-assigned") + )); +} + +#[test] +fn mcp_runtime_requires_endpoint_shape_for_transport() { + assert!(matches!( + ExtensionManifest::parse(MCP_STDIO_WITHOUT_COMMAND_MANIFEST), + Err(ExtensionError::InvalidManifest { reason }) if reason.contains("stdio") && reason.contains("command") + )); + assert!(matches!( + ExtensionManifest::parse(MCP_STDIO_WITH_URL_MANIFEST), + Err(ExtensionError::InvalidManifest { reason }) if reason.contains("stdio") && reason.contains("url") + )); + assert!(matches!( + ExtensionManifest::parse(MCP_HTTP_WITHOUT_URL_MANIFEST), + Err(ExtensionError::InvalidManifest { reason }) if reason.contains("http") && reason.contains("url") + )); + assert!(matches!( + ExtensionManifest::parse(MCP_HTTP_WITH_COMMAND_MANIFEST), + Err(ExtensionError::InvalidManifest { reason }) if reason.contains("http") && reason.contains("command") + )); + assert!(matches!( + ExtensionManifest::parse(MCP_HTTP_INVALID_URL_MANIFEST), + Err(ExtensionError::InvalidManifest { reason }) if reason.contains("http") && reason.contains("URL") + )); + assert!(matches!( + ExtensionManifest::parse(MCP_SSE_UNSUPPORTED_URL_SCHEME_MANIFEST), + Err(ExtensionError::InvalidManifest { reason }) if reason.contains("sse") && reason.contains("http") + )); +} + +#[test] +fn asset_path_resolves_under_extension_root_only() { + let asset = ExtensionAssetPath::new("wasm/echo.wasm").unwrap(); + let resolved = asset + .resolve_under(&VirtualPath::new("/system/extensions/echo").unwrap()) + .unwrap(); + + assert_eq!(resolved.as_str(), "/system/extensions/echo/wasm/echo.wasm"); +} + +#[test] +fn registry_lookup_returns_declared_package_and_descriptor() { + let package = ExtensionPackage::from_manifest( + ExtensionManifest::parse(WASM_MANIFEST).unwrap(), + VirtualPath::new("/system/extensions/echo").unwrap(), + ) + .unwrap(); + let mut registry = ExtensionRegistry::new(); + registry.insert(package).unwrap(); + + let extension = registry + .get_extension(&ExtensionId::new("echo").unwrap()) + .unwrap(); + assert_eq!(extension.root.as_str(), "/system/extensions/echo"); + + let capability = registry + .get_capability(&CapabilityId::new("echo.say").unwrap()) + .unwrap(); + assert_eq!(capability.description, "Echo text"); + assert_eq!(capability.provider.as_str(), "echo"); +} + +#[tokio::test] +async fn discovery_ignores_non_directory_entries_in_extension_root() { + let storage = tempdir().unwrap(); + std::fs::create_dir_all(storage.path().join("echo")).unwrap(); + std::fs::write(storage.path().join("echo/manifest.toml"), WASM_MANIFEST).unwrap(); + std::fs::write(storage.path().join(".DS_Store"), b"not an extension").unwrap(); + + let mut fs = LocalFilesystem::new(); + fs.mount_local( + VirtualPath::new("/system/extensions").unwrap(), + HostPath::from_path_buf(storage.path().to_path_buf()), + ) + .unwrap(); + + let registry = + ExtensionDiscovery::discover(&fs, &VirtualPath::new("/system/extensions").unwrap()) + .await + .unwrap(); + + assert!( + registry + .get_extension(&ExtensionId::new("echo").unwrap()) + .is_some() + ); +} + +#[tokio::test] +async fn discovery_ignores_non_extension_directories_with_invalid_ids() { + let storage = tempdir().unwrap(); + std::fs::create_dir_all(storage.path().join("echo")).unwrap(); + std::fs::write(storage.path().join("echo/manifest.toml"), WASM_MANIFEST).unwrap(); + std::fs::create_dir_all(storage.path().join(".cache")).unwrap(); + + let mut fs = LocalFilesystem::new(); + fs.mount_local( + VirtualPath::new("/system/extensions").unwrap(), + HostPath::from_path_buf(storage.path().to_path_buf()), + ) + .unwrap(); + + let registry = + ExtensionDiscovery::discover(&fs, &VirtualPath::new("/system/extensions").unwrap()) + .await + .unwrap(); + + assert!( + registry + .get_extension(&ExtensionId::new("echo").unwrap()) + .is_some() + ); +} + +#[tokio::test] +async fn discovery_returns_extensions_in_deterministic_name_order() { + let storage = tempdir().unwrap(); + std::fs::create_dir_all(storage.path().join("zeta")).unwrap(); + std::fs::create_dir_all(storage.path().join("alpha")).unwrap(); + std::fs::write( + storage.path().join("zeta/manifest.toml"), + WASM_MANIFEST + .replace("id = \"echo\"", "id = \"zeta\"") + .replace("echo.say", "zeta.say"), + ) + .unwrap(); + std::fs::write( + storage.path().join("alpha/manifest.toml"), + WASM_MANIFEST + .replace("id = \"echo\"", "id = \"alpha\"") + .replace("echo.say", "alpha.say"), + ) + .unwrap(); + + let mut fs = LocalFilesystem::new(); + fs.mount_local( + VirtualPath::new("/system/extensions").unwrap(), + HostPath::from_path_buf(storage.path().to_path_buf()), + ) + .unwrap(); + + let registry = + ExtensionDiscovery::discover(&fs, &VirtualPath::new("/system/extensions").unwrap()) + .await + .unwrap(); + let ids: Vec<_> = registry + .extensions() + .map(|package| package.id.as_str().to_string()) + .collect(); + + assert_eq!(ids, vec!["alpha", "zeta"]); +} + +const FIRST_PARTY_MANIFEST: &str = r#" +id = "conversation" +name = "Conversation" +version = "0.1.0" +description = "Conversation service" +trust = "first_party" + +[runtime] +kind = "first_party" +service = "conversation" + +[[capabilities]] +id = "conversation.ingest" +description = "Ingest normalized messages" +effects = ["dispatch_capability"] +default_permission = "allow" +parameters_schema = { type = "object" } +"#; + +const SYSTEM_MANIFEST: &str = r#" +id = "audit" +name = "Audit" +version = "0.1.0" +description = "Audit service" +trust = "system" + +[runtime] +kind = "system" +service = "audit" + +[[capabilities]] +id = "audit.write" +description = "Write audit event" +effects = ["dispatch_capability"] +default_permission = "allow" +parameters_schema = { type = "object" } +"#; + +const SANDBOX_FIRST_PARTY_RUNTIME_MANIFEST: &str = r#" +id = "conversation" +name = "Conversation" +version = "0.1.0" +description = "Conversation service" +trust = "sandbox" + +[runtime] +kind = "first_party" +service = "conversation" + +[[capabilities]] +id = "conversation.ingest" +description = "Ingest normalized messages" +effects = ["dispatch_capability"] +default_permission = "allow" +parameters_schema = { type = "object" } +"#; + +const SANDBOX_SYSTEM_RUNTIME_MANIFEST: &str = r#" +id = "audit" +name = "Audit" +version = "0.1.0" +description = "Audit service" +trust = "sandbox" + +[runtime] +kind = "system" +service = "audit" + +[[capabilities]] +id = "audit.write" +description = "Write audit event" +effects = ["dispatch_capability"] +default_permission = "allow" +parameters_schema = { type = "object" } +"#; + +const MCP_STDIO_WITHOUT_COMMAND_MANIFEST: &str = r#" +id = "github-mcp" +name = "GitHub MCP" +version = "0.1.0" +description = "GitHub MCP adapter" +trust = "user_trusted" + +[runtime] +kind = "mcp" +transport = "stdio" + +[[capabilities]] +id = "github-mcp.search_issues" +description = "Search GitHub issues" +effects = ["network", "dispatch_capability"] +default_permission = "ask" +parameters_schema = { type = "object" } +"#; + +const MCP_STDIO_WITH_URL_MANIFEST: &str = r#" +id = "github-mcp" +name = "GitHub MCP" +version = "0.1.0" +description = "GitHub MCP adapter" +trust = "user_trusted" + +[runtime] +kind = "mcp" +transport = "stdio" +url = "http://localhost:3000" + +[[capabilities]] +id = "github-mcp.search_issues" +description = "Search GitHub issues" +effects = ["network", "dispatch_capability"] +default_permission = "ask" +parameters_schema = { type = "object" } +"#; + +const MCP_HTTP_WITHOUT_URL_MANIFEST: &str = r#" +id = "github-mcp" +name = "GitHub MCP" +version = "0.1.0" +description = "GitHub MCP adapter" +trust = "user_trusted" + +[runtime] +kind = "mcp" +transport = "http" + +[[capabilities]] +id = "github-mcp.search_issues" +description = "Search GitHub issues" +effects = ["network", "dispatch_capability"] +default_permission = "ask" +parameters_schema = { type = "object" } +"#; + +const MCP_HTTP_WITH_COMMAND_MANIFEST: &str = r#" +id = "github-mcp" +name = "GitHub MCP" +version = "0.1.0" +description = "GitHub MCP adapter" +trust = "user_trusted" + +[runtime] +kind = "mcp" +transport = "http" +command = "github-mcp-server" + +[[capabilities]] +id = "github-mcp.search_issues" +description = "Search GitHub issues" +effects = ["network", "dispatch_capability"] +default_permission = "ask" +parameters_schema = { type = "object" } +"#; + +const MCP_HTTP_INVALID_URL_MANIFEST: &str = r#" +id = "github-mcp" +name = "GitHub MCP" +version = "0.1.0" +description = "GitHub MCP adapter" +trust = "user_trusted" + +[runtime] +kind = "mcp" +transport = "http" +url = "not a url" + +[[capabilities]] +id = "github-mcp.search_issues" +description = "Search GitHub issues" +effects = ["network", "dispatch_capability"] +default_permission = "ask" +parameters_schema = { type = "object" } +"#; + +const MCP_SSE_UNSUPPORTED_URL_SCHEME_MANIFEST: &str = r#" +id = "github-mcp" +name = "GitHub MCP" +version = "0.1.0" +description = "GitHub MCP adapter" +trust = "user_trusted" + +[runtime] +kind = "mcp" +transport = "sse" +url = "file:///tmp/mcp.sock" + +[[capabilities]] +id = "github-mcp.search_issues" +description = "Search GitHub issues" +effects = ["network", "dispatch_capability"] +default_permission = "ask" +parameters_schema = { type = "object" } +"#;