diff --git a/Cargo.lock b/Cargo.lock index fd9ef5fb..2cf0210a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4915,6 +4915,7 @@ name = "wassette" version = "0.1.0" dependencies = [ "anyhow", + "clap", "component2json", "etcetera", "futures", @@ -4934,6 +4935,7 @@ dependencies = [ "temp-env", "tempfile", "test-log", + "thiserror 2.0.17", "tokio", "tokio-test", "tokio-util", diff --git a/Cargo.toml b/Cargo.toml index 1ff5f045..76425e71 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,6 +64,7 @@ rmcp = { workspace = true, features = [ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } serde_yaml = { workspace = true } +sha2 = "0.10" tokio = { workspace = true, features = ["full"] } tracing = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter"] } diff --git a/changelog.d/567.feature.md b/changelog.d/567.feature.md new file mode 100644 index 00000000..137ad7e4 --- /dev/null +++ b/changelog.d/567.feature.md @@ -0,0 +1 @@ +Implemented headless mode policy enforcement and runtime permission handling. \ No newline at end of file diff --git a/crates/component2json/src/lib.rs b/crates/component2json/src/lib.rs index a5663c89..42f396a7 100644 --- a/crates/component2json/src/lib.rs +++ b/crates/component2json/src/lib.rs @@ -1018,7 +1018,7 @@ mod tests { use super::*; - fn result_schema<'a>(schema: &'a Value) -> &'a Value { + fn result_schema(schema: &Value) -> &Value { schema .get("properties") .and_then(|props| props.get("result")) diff --git a/crates/wassette/Cargo.toml b/crates/wassette/Cargo.toml index 5b506b41..e0b8bbbd 100644 --- a/crates/wassette/Cargo.toml +++ b/crates/wassette/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true [dependencies] anyhow = { workspace = true } +clap = { version = "4.5", features = ["derive"] } component2json = { path = "../component2json" } etcetera = { workspace = true } futures = { workspace = true } @@ -22,6 +23,7 @@ serde_json = { workspace = true } sha2 = "0.10" serde_yaml = { workspace = true } tempfile = { workspace = true } +thiserror = { workspace = true } tokio = { workspace = true, features = ["full", "test-util"] } tokio-util = { workspace = true, features = ["io"] } tracing = { workspace = true, features = ["attributes"] } diff --git a/crates/wassette/src/config.rs b/crates/wassette/src/config.rs index 705a8f58..6db8399e 100644 --- a/crates/wassette/src/config.rs +++ b/crates/wassette/src/config.rs @@ -9,11 +9,24 @@ use std::path::{Path, PathBuf}; use std::time::Duration; use anyhow::{Context, Result}; +use clap::ValueEnum; +use serde::{Deserialize, Serialize}; use crate::{ get_default_secrets_dir, LifecycleManager, DEFAULT_HTTP_TIMEOUT_SECS, DEFAULT_OCI_TIMEOUT_SECS, }; +/// Deployment mode for the MCP server +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ValueEnum)] +#[serde(rename_all = "lowercase")] +pub enum DeploymentProfile { + /// Interactive mode: permits runtime grants, interactive component loading + #[default] + Interactive, + /// Headless mode: declarative-only, fails fast on missing configuration + Headless, +} + /// Fully-specified configuration for constructing a [`LifecycleManager`]. #[derive(Clone)] pub struct LifecycleConfig { @@ -23,6 +36,7 @@ pub struct LifecycleConfig { http_client: reqwest::Client, oci_client: oci_client::Client, eager_load: bool, + profile: DeploymentProfile, } impl LifecycleConfig { @@ -56,6 +70,11 @@ impl LifecycleConfig { self.eager_load } + /// Get the deployment profile (interactive or headless). + pub fn profile(&self) -> &DeploymentProfile { + &self.profile + } + pub(crate) fn into_parts( self, ) -> ( @@ -65,6 +84,7 @@ impl LifecycleConfig { reqwest::Client, oci_client::Client, bool, + DeploymentProfile, ) { ( self.component_dir, @@ -73,6 +93,7 @@ impl LifecycleConfig { self.http_client, self.oci_client, self.eager_load, + self.profile, ) } } @@ -86,6 +107,7 @@ pub struct LifecycleBuilder { http_client: Option, oci_client: Option, eager_load: bool, + profile: DeploymentProfile, } impl LifecycleBuilder { @@ -99,6 +121,7 @@ impl LifecycleBuilder { http_client: None, oci_client: None, eager_load: true, + profile: DeploymentProfile::default(), } } @@ -142,6 +165,12 @@ impl LifecycleBuilder { self } + /// Set the deployment profile (interactive or headless). + pub fn with_profile(mut self, profile: DeploymentProfile) -> Self { + self.profile = profile; + self + } + /// Produce a validated [`LifecycleConfig`] without constructing a manager. pub fn build_config(self) -> Result { let component_dir = match self.component_dir.canonicalize() { @@ -168,6 +197,7 @@ impl LifecycleBuilder { http_client, oci_client, eager_load: self.eager_load, + profile: self.profile, }) } diff --git a/crates/wassette/src/lib.rs b/crates/wassette/src/lib.rs index 8a9b24e3..dcc72f95 100644 --- a/crates/wassette/src/lib.rs +++ b/crates/wassette/src/lib.rs @@ -38,11 +38,14 @@ mod secrets; mod wasistate; use component_storage::ComponentStorage; -pub use config::{LifecycleBuilder, LifecycleConfig}; +pub use config::{DeploymentProfile, LifecycleBuilder, LifecycleConfig}; pub use http::WassetteWasiState; use loader::{ComponentResource, DownloadedResource}; use policy_internal::PolicyManager; -pub use policy_internal::{PermissionGrantRequest, PermissionRule, PolicyInfo}; +pub use policy_internal::{ + HeadlessPolicyBackend, InteractivePolicyBackend, PermissionGrantRequest, PermissionRule, + PolicyBackend, PolicyInfo, +}; use runtime_context::RuntimeContext; pub use secrets::SecretsManager; use wasistate::WasiState; @@ -304,6 +307,7 @@ pub struct LifecycleManager { oci_client: Arc, http_client: reqwest::Client, secrets_manager: Arc, + profile: DeploymentProfile, } /// A representation of a loaded component instance. It contains both the base component info and a @@ -340,7 +344,7 @@ impl LifecycleManager { /// Construct a lifecycle manager from an explicit configuration without loading components. #[instrument(skip_all, fields(component_dir = %config.component_dir().display()))] pub async fn from_config(config: LifecycleConfig) -> Result { - let (component_dir, secrets_dir, environment_vars, http_client, oci_client, _) = + let (component_dir, secrets_dir, environment_vars, http_client, oci_client, _, profile) = config.into_parts(); let storage = @@ -354,12 +358,19 @@ impl LifecycleManager { let environment_vars = Arc::new(environment_vars); let oci_client = Arc::new(oci_wasm::WasmClient::new(oci_client)); + // Create the appropriate policy backend based on profile + let policy_backend: Arc = match profile { + DeploymentProfile::Interactive => Arc::new(policy_internal::InteractivePolicyBackend), + DeploymentProfile::Headless => Arc::new(policy_internal::HeadlessPolicyBackend), + }; + let policy_manager = PolicyManager::new( storage.clone(), Arc::clone(&secrets_manager), Arc::clone(&environment_vars), Arc::clone(&oci_client), http_client.clone(), + policy_backend, ); Ok(Self { @@ -370,6 +381,7 @@ impl LifecycleManager { oci_client, http_client, secrets_manager, + profile, }) } @@ -423,6 +435,12 @@ impl LifecycleManager { self.policy_manager.restore_from_disk(component_id).await } + /// Apply policy to a component by loading it from the co-located policy file + /// This is useful after provisioning when the policy file is created after component loading + pub async fn apply_policy_to_component(&self, component_id: &str) -> Result<()> { + self.restore_policy_attachment(component_id).await + } + async fn resolve_component_resource(&self, uri: &str) -> Result<(String, DownloadedResource)> { // Show progress when running in CLI mode (stderr is a TTY) let show_progress = std::io::stderr().is_terminal(); @@ -1300,6 +1318,11 @@ async fn load_components_parallel( } impl LifecycleManager { + /// Get the deployment profile + pub fn profile(&self) -> &DeploymentProfile { + &self.profile + } + /// Get the secrets manager pub fn secrets_manager(&self) -> &SecretsManager { &self.secrets_manager diff --git a/crates/wassette/src/policy_internal.rs b/crates/wassette/src/policy_internal.rs index e22eec3e..124f98fa 100644 --- a/crates/wassette/src/policy_internal.rs +++ b/crates/wassette/src/policy_internal.rs @@ -22,6 +22,66 @@ use crate::component_storage::ComponentStorage; use crate::loader::{self, PolicyResource}; use crate::{SecretsManager, WasiStateTemplate}; +/// Policy backend error types +#[derive(Debug, thiserror::Error)] +pub enum PolicyError { + /// Runtime permission grants are not allowed in headless mode + #[error("Runtime permission grants are disabled in headless mode. To grant {permission_type} permission to component '{component_id}', update the provisioning manifest.")] + HeadlessGrantDenied { + component_id: String, + permission_type: String, + }, + + /// Component not found + #[error("Component '{0}' not found")] + ComponentNotFound(String), + + /// Other policy errors + #[error("Policy error: {0}")] + Other(#[from] anyhow::Error), +} + +/// Policy backend determines whether runtime grants are permitted +pub trait PolicyBackend: Send + Sync { + /// Check if a runtime permission grant is allowed + fn can_grant_runtime( + &self, + component_id: &str, + permission_type: &str, + ) -> Result<(), PolicyError>; +} + +/// Interactive mode backend: permits all runtime grants +pub struct InteractivePolicyBackend; + +impl PolicyBackend for InteractivePolicyBackend { + fn can_grant_runtime( + &self, + _component_id: &str, + _permission_type: &str, + ) -> Result<(), PolicyError> { + // Interactive mode allows all runtime grants + Ok(()) + } +} + +/// Headless mode backend: denies all runtime grants +pub struct HeadlessPolicyBackend; + +impl PolicyBackend for HeadlessPolicyBackend { + fn can_grant_runtime( + &self, + component_id: &str, + permission_type: &str, + ) -> Result<(), PolicyError> { + // Headless mode rejects runtime grants with helpful error + Err(PolicyError::HeadlessGrantDenied { + component_id: component_id.to_string(), + permission_type: permission_type.to_string(), + }) + } +} + /// Granular permission rule types #[derive(Debug, Clone, Serialize, Deserialize)] pub enum PermissionRule { @@ -65,6 +125,7 @@ pub(crate) struct PolicyManager { environment_vars: Arc>, oci_client: Arc, http_client: Client, + backend: Arc, } /// Information about a policy attached to a component @@ -89,6 +150,7 @@ impl PolicyManager { environment_vars: Arc>, oci_client: Arc, http_client: Client, + backend: Arc, ) -> Self { Self { registry: Arc::new(RwLock::new(PolicyRegistry::default())), @@ -97,6 +159,7 @@ impl PolicyManager { environment_vars, oci_client, http_client, + backend, } } @@ -341,6 +404,11 @@ impl PolicyManager { permission_type: &str, details: &serde_json::Value, ) -> Result<()> { + // Check with policy backend before proceeding + self.backend + .can_grant_runtime(component_id, permission_type) + .map_err(|e| anyhow!("{}", e))?; + info!( component_id, permission_type, "Granting permission to component" diff --git a/src/cli_handlers.rs b/src/cli_handlers.rs index 8956ae27..b1a92f98 100644 --- a/src/cli_handlers.rs +++ b/src/cli_handlers.rs @@ -103,6 +103,7 @@ pub async fn create_lifecycle_manager(component_dir: Option) -> Result< disable_builtin_tools: false, bind_address: None, manifest: None, + profile: None, }) .context("Failed to load configuration")? }; diff --git a/src/commands.rs b/src/commands.rs index 65240889..8a17a626 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -129,6 +129,11 @@ pub struct Serve { #[arg(long)] #[serde(skip_serializing_if = "Option::is_none")] pub manifest: Option, + + /// Deployment profile: interactive (default) or headless + #[arg(long, value_enum)] + #[serde(skip_serializing_if = "Option::is_none")] + pub profile: Option, } #[derive(Args, Debug, Clone, Serialize, Deserialize, Default)] diff --git a/src/config.rs b/src/config.rs index 2915c33e..4a9f7245 100644 --- a/src/config.rs +++ b/src/config.rs @@ -147,6 +147,7 @@ mod tests { disable_builtin_tools: false, bind_address: None, manifest: None, + profile: None, } } @@ -159,6 +160,7 @@ mod tests { disable_builtin_tools: false, bind_address: None, manifest: None, + profile: None, } } @@ -371,6 +373,7 @@ bind_address = "0.0.0.0:8080" disable_builtin_tools: false, bind_address: Some("192.168.1.100:9090".to_string()), manifest: None, + profile: None, }; let config = @@ -415,6 +418,7 @@ bind_address = "0.0.0.0:8080" disable_builtin_tools: false, bind_address: Some("192.168.1.100:9090".to_string()), manifest: None, + profile: None, }; let config = Config::new_from_path(&serve_config, &config_file) diff --git a/src/lib.rs b/src/lib.rs index 4d7cccfe..2ced8927 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,3 +2,8 @@ // Licensed under the MIT license. pub use {mcp_server, wassette}; + +// Export modules needed for integration tests +pub mod manifest; +pub mod permission_synthesis; +pub mod provisioning_controller; diff --git a/src/main.rs b/src/main.rs index 5e4c144d..e0c74c11 100644 --- a/src/main.rs +++ b/src/main.rs @@ -80,6 +80,22 @@ async fn main() -> Result<()> { let config = config::Config::from_serve(cfg).context("Failed to load configuration")?; + // Validate headless mode requirements + if let Some(wassette::DeploymentProfile::Headless) = cfg.profile { + if cfg.manifest.is_none() { + anyhow::bail!( + "Headless deployment profile requires --manifest flag with path to provisioning manifest" + ); + } + } + + // Validate manifest exists if provided + if let Some(ref manifest_path) = cfg.manifest { + if !manifest_path.exists() { + anyhow::bail!("Manifest file not found: {}", manifest_path.display()); + } + } + // Parse and validate manifest if provided let manifest = if let Some(manifest_path) = &cfg.manifest { let m = manifest::ProvisioningManifest::from_file(manifest_path) @@ -112,14 +128,18 @@ async fn main() -> Result<()> { // Keep a clone of component_dir for provisioning let component_dir_path = component_dir.clone(); - let lifecycle_manager = LifecycleManager::builder(component_dir) + // Determine the profile to use + let profile = cfg.profile.clone().unwrap_or_default(); + + let builder = LifecycleManager::builder(component_dir) .with_environment_vars(environment_vars) .with_secrets_dir(secrets_dir) .with_oci_client(oci_client::Client::default()) .with_http_client(reqwest::Client::default()) .with_eager_loading(false) - .build() - .await?; + .with_profile(profile); + + let lifecycle_manager = builder.build().await?; // Provision components from manifest if provided if let Some(manifest) = &manifest { diff --git a/src/provisioning_controller.rs b/src/provisioning_controller.rs index 80b8565f..82b644d3 100644 --- a/src/provisioning_controller.rs +++ b/src/provisioning_controller.rs @@ -96,18 +96,52 @@ impl<'a> ProvisioningController<'a> { // Step 3: Load component using existing lifecycle manager // Note: The lifecycle manager will automatically: - // - Download the component from the URI + // - Download the component from the URI (and cache the bytes) // - Compile and cache it - // - Load the co-located policy file we just created // - Register the component and its tools - self.lifecycle_manager + let load_outcome = self + .lifecycle_manager .load_component(&component.uri) .await .with_context(|| format!("Failed to load component from URI: {}", component.uri))?; - // Step 4: Verify digest if specified + // Step 3.5: Rename temp policy file to proper name now that we have component_id + let final_policy_path = self + .plugin_dir + .join(format!("{}.policy.yaml", load_outcome.component_id)); + tokio::fs::rename(&policy_path, &final_policy_path) + .await + .with_context(|| { + format!( + "Failed to rename policy file from {} to {}", + policy_path.display(), + final_policy_path.display() + ) + })?; + + tracing::debug!("Renamed policy file to: {}", final_policy_path.display()); + + // Step 4: Apply the policy to the component (force reload) + // This ensures the policy is loaded even though the component was just loaded + self.lifecycle_manager + .apply_policy_to_component(&load_outcome.component_id) + .await + .with_context(|| { + format!( + "Failed to apply policy to component {}", + load_outcome.component_id + ) + })?; + + tracing::info!( + "Applied policy for component: {}", + load_outcome.component_id + ); + + // Step 5: Verify digest if specified (after loading so we have the cached file) if let Some(digest) = &component.digest { - self.verify_digest(component, digest) + self.verify_digest(&load_outcome.component_id, digest) + .await .context("Digest verification failed")?; } @@ -185,21 +219,57 @@ impl<'a> ProvisioningController<'a> { } /// Verify component digest (SHA-256) - fn verify_digest(&self, component: &ComponentDeclaration, expected_digest: &str) -> Result<()> { - // Digest verification is deferred to post-MVP for simplicity - // The digest format was validated during manifest validation, - // but actual verification requires reading the downloaded component bytes - - tracing::warn!( - "Digest verification is not yet implemented for component: {}. Expected: {}", - component.name.as_deref().unwrap_or(&component.uri), - expected_digest + async fn verify_digest(&self, component_id: &str, expected_digest: &str) -> Result<()> { + use sha2::{Digest, Sha256}; + + // Parse expected format: "sha256:hexstring" + let expected = expected_digest + .strip_prefix("sha256:") + .ok_or_else(|| anyhow::anyhow!("Digest must start with 'sha256:'"))?; + + // Read the component file from the plugin directory + let component_path = self.plugin_dir.join(format!("{}.wasm", component_id)); + + if !component_path.exists() { + bail!( + "Component file not found for digest verification: {}", + component_path.display() + ); + } + + tracing::debug!( + "Verifying digest for component at: {}", + component_path.display() ); - // TODO: Implement digest verification - // 1. Get the component bytes from the downloaded artifact - // 2. Compute SHA-256 hash - // 3. Compare with expected_digest (strip "sha256:" prefix) + // Read the component bytes + let component_bytes = tokio::fs::read(&component_path).await.with_context(|| { + format!( + "Failed to read component file for digest verification: {}", + component_path.display() + ) + })?; + + // Compute SHA-256 hash + let mut hasher = Sha256::new(); + hasher.update(&component_bytes); + let actual = format!("{:x}", hasher.finalize()); + + // Compare digests + if actual != expected { + bail!( + "Digest mismatch for component {}: expected sha256:{}, got sha256:{}", + component_id, + expected, + actual + ); + } + + tracing::info!( + "Digest verification passed for component: {} (sha256:{})", + component_id, + expected + ); Ok(()) } diff --git a/tests/headless_mode_integration_test.rs b/tests/headless_mode_integration_test.rs new file mode 100644 index 00000000..d35486cf --- /dev/null +++ b/tests/headless_mode_integration_test.rs @@ -0,0 +1,1056 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +//! Comprehensive integration tests for headless deployment mode +//! +//! These tests verify: +//! - Provisioning from manifests +//! - Policy enforcement in headless mode +//! - Runtime grant blocking +//! - Manifest validation +//! - Multi-component provisioning +//! - Digest verification +//! - Environment variable seeding + +use std::sync::Arc; + +use anyhow::{Context, Result}; +use tempfile::TempDir; +use test_log::test; +use wassette::{DeploymentProfile, LifecycleManager}; + +mod common; +use common::build_fetch_component; + +/// Helper to create a lifecycle manager in interactive mode (default) +async fn setup_interactive_manager() -> Result<(Arc, TempDir)> { + let tempdir = tempfile::tempdir()?; + let manager = Arc::new( + LifecycleManager::builder(&tempdir) + .with_profile(DeploymentProfile::Interactive) + .build() + .await + .context("Failed to create LifecycleManager")?, + ); + Ok((manager, tempdir)) +} + +/// Helper to create a lifecycle manager in headless mode +async fn setup_headless_manager() -> Result<(Arc, TempDir)> { + let tempdir = tempfile::tempdir()?; + let manager = Arc::new( + LifecycleManager::builder(&tempdir) + .with_profile(DeploymentProfile::Headless) + .build() + .await + .context("Failed to create LifecycleManager")?, + ); + Ok((manager, tempdir)) +} + +/// Helper to create a simple manifest YAML content +fn create_simple_manifest(component_path: &str, allowed_host: &str) -> String { + format!( + r#"version: 1 +components: + - uri: file://{} + name: test-component + permissions: + network: + allow: + - host: "{}" +"#, + component_path, allowed_host + ) +} + +/// Helper to create a multi-component manifest +fn create_multi_component_manifest(component_path: &str) -> String { + format!( + r#"version: 1 +components: + - uri: file://{} + name: component1 + permissions: + network: + allow: + - host: "api.example.com" + - uri: file://{} + name: component2 + permissions: + network: + allow: + - host: "cdn.example.com" + storage: + allow: + - uri: "fs:///tmp/data" + access: + - read +"#, + component_path, component_path + ) +} + +/// Helper to create a manifest with environment variables +fn create_manifest_with_env(component_path: &str) -> String { + format!( + r#"version: 1 +components: + - uri: file://{} + name: env-component + permissions: + environment: + allow: + - key: "API_KEY" + value_from: "TEST_API_KEY" + - key: "CONFIG_URL" + value_from: "TEST_CONFIG_URL" + network: + allow: + - host: "api.example.com" +"#, + component_path + ) +} + +/// Helper to create a manifest with digest verification +fn create_manifest_with_digest(component_path: &str, digest: &str) -> String { + format!( + r#"version: 1 +components: + - uri: file://{} + name: verified-component + digest: "sha256:{}" + permissions: + network: + allow: + - host: "api.example.com" +"#, + component_path, digest + ) +} + +// ============================================================================ +// BASIC HEADLESS MODE TESTS +// ============================================================================ + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[test(tokio::test)] +async fn test_interactive_mode_allows_runtime_grants() -> Result<()> { + let (manager, _tempdir) = setup_interactive_manager().await?; + let component_path = build_fetch_component().await?; + + let component_id = manager + .load_component(&format!("file://{}", component_path.display())) + .await? + .component_id; + + // In interactive mode, runtime grants should succeed + let result = manager + .grant_permission( + &component_id, + "network", + &serde_json::json!({"host": "example.com"}), + ) + .await; + + assert!( + result.is_ok(), + "Interactive mode should allow runtime grants" + ); + + Ok(()) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[test(tokio::test)] +async fn test_headless_mode_blocks_runtime_grants() -> Result<()> { + let (manager, _tempdir) = setup_headless_manager().await?; + let component_path = build_fetch_component().await?; + + let component_id = manager + .load_component(&format!("file://{}", component_path.display())) + .await? + .component_id; + + // In headless mode, runtime grants should fail + let result = manager + .grant_permission( + &component_id, + "network", + &serde_json::json!({"host": "example.com"}), + ) + .await; + + assert!(result.is_err(), "Headless mode should block runtime grants"); + let error_msg = result.unwrap_err().to_string(); + assert!( + error_msg.contains("headless mode"), + "Error should mention headless mode" + ); + assert!( + error_msg.contains("manifest"), + "Error should mention updating manifest" + ); + + Ok(()) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[test(tokio::test)] +async fn test_headless_mode_blocks_all_permission_types() -> Result<()> { + let (manager, _tempdir) = setup_headless_manager().await?; + let component_path = build_fetch_component().await?; + + let component_id = manager + .load_component(&format!("file://{}", component_path.display())) + .await? + .component_id; + + // Test network permission + let network_result = manager + .grant_permission( + &component_id, + "network", + &serde_json::json!({"host": "example.com"}), + ) + .await; + assert!( + network_result.is_err(), + "Network grants should be blocked in headless mode" + ); + + // Test storage permission + let storage_result = manager + .grant_permission( + &component_id, + "storage", + &serde_json::json!({"uri": "fs:///tmp/test", "access": ["read"]}), + ) + .await; + assert!( + storage_result.is_err(), + "Storage grants should be blocked in headless mode" + ); + + // Test environment permission + let env_result = manager + .grant_permission( + &component_id, + "environment", + &serde_json::json!({"key": "API_KEY"}), + ) + .await; + assert!( + env_result.is_err(), + "Environment grants should be blocked in headless mode" + ); + + Ok(()) +} + +// ============================================================================ +// MANIFEST PROVISIONING TESTS +// ============================================================================ + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[test(tokio::test)] +async fn test_provision_from_simple_manifest() -> Result<()> { + let component_path = build_fetch_component().await?; + let tempdir = tempfile::tempdir()?; + + // Create manifest file + let manifest_content = + create_simple_manifest(component_path.to_str().unwrap(), "api.github.com"); + let manifest_path = tempdir.path().join("manifest.yaml"); + tokio::fs::write(&manifest_path, manifest_content).await?; + + // Parse and validate manifest + let manifest_content = tokio::fs::read_to_string(&manifest_path).await?; + let manifest: wassette_mcp_server::manifest::ProvisioningManifest = + serde_yaml::from_str(&manifest_content)?; + + assert_eq!(manifest.components.len(), 1); + assert_eq!( + manifest.components[0].name, + Some("test-component".to_string()) + ); + + Ok(()) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[test(tokio::test)] +async fn test_provision_multi_component_manifest() -> Result<()> { + let component_path = build_fetch_component().await?; + let tempdir = tempfile::tempdir()?; + + // Create multi-component manifest + let manifest_content = create_multi_component_manifest(component_path.to_str().unwrap()); + let manifest_path = tempdir.path().join("manifest.yaml"); + tokio::fs::write(&manifest_path, manifest_content).await?; + + // Parse and validate manifest + let manifest_content = tokio::fs::read_to_string(&manifest_path).await?; + let manifest: wassette_mcp_server::manifest::ProvisioningManifest = + serde_yaml::from_str(&manifest_content)?; + + assert_eq!(manifest.components.len(), 2); + assert_eq!(manifest.components[0].name, Some("component1".to_string())); + assert_eq!(manifest.components[1].name, Some("component2".to_string())); + + // Verify permissions are present + assert!(manifest.components[0].permissions.network.is_some()); + assert!(manifest.components[1].permissions.network.is_some()); + assert!(manifest.components[1].permissions.storage.is_some()); + + Ok(()) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[test(tokio::test)] +async fn test_provision_with_environment_variables() -> Result<()> { + let component_path = build_fetch_component().await?; + let tempdir = tempfile::tempdir()?; + + // Set environment variables for the test + std::env::set_var("TEST_API_KEY", "secret123"); + std::env::set_var("TEST_CONFIG_URL", "https://config.example.com"); + + // Create manifest with environment variables + let manifest_content = create_manifest_with_env(component_path.to_str().unwrap()); + let manifest_path = tempdir.path().join("manifest.yaml"); + tokio::fs::write(&manifest_path, manifest_content).await?; + + // Parse manifest + let manifest_content = tokio::fs::read_to_string(&manifest_path).await?; + let manifest: wassette_mcp_server::manifest::ProvisioningManifest = + serde_yaml::from_str(&manifest_content)?; + + assert_eq!(manifest.components.len(), 1); + assert!(manifest.components[0].permissions.environment.is_some()); + + let env_perms = manifest.components[0] + .permissions + .environment + .as_ref() + .unwrap(); + assert_eq!(env_perms.allow.len(), 2); + assert_eq!(env_perms.allow[0].key, "API_KEY"); + assert_eq!( + env_perms.allow[0].value_from, + Some("TEST_API_KEY".to_string()) + ); + + // Cleanup + std::env::remove_var("TEST_API_KEY"); + std::env::remove_var("TEST_CONFIG_URL"); + + Ok(()) +} + +// ============================================================================ +// POLICY ENFORCEMENT TESTS +// ============================================================================ + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[test(tokio::test)] +async fn test_headless_enforces_manifest_network_policy() -> Result<()> { + let component_path = build_fetch_component().await?; + let tempdir = tempfile::tempdir()?; + + // Create manifest with specific allowed host + let manifest_content = + create_simple_manifest(component_path.to_str().unwrap(), "api.allowed.com"); + let manifest_path = tempdir.path().join("manifest.yaml"); + tokio::fs::write(&manifest_path, manifest_content).await?; + + // Parse manifest + let manifest_content = tokio::fs::read_to_string(&manifest_path).await?; + let manifest: wassette_mcp_server::manifest::ProvisioningManifest = + serde_yaml::from_str(&manifest_content)?; + + // Create headless lifecycle manager + let manager = Arc::new( + LifecycleManager::builder(&tempdir) + .with_profile(DeploymentProfile::Headless) + .build() + .await?, + ); + + // Provision the component using provisioning controller + let secrets_manager = wassette::SecretsManager::new(tempdir.path().join("secrets")); + let controller = wassette_mcp_server::provisioning_controller::ProvisioningController::new( + &manifest, + &manager, + &secrets_manager, + tempdir.path(), + ); + + controller.provision().await?; + + // Verify the component was loaded + let components = manager.list_components().await; + assert_eq!(components.len(), 1); + let component_id = &components[0]; + + // Verify policy was applied + let policy_info = manager.get_policy_info(component_id).await; + assert!(policy_info.is_some(), "Policy should be attached"); + + // Read and verify policy content + let policy_info = policy_info.unwrap(); + let policy_content = tokio::fs::read_to_string(&policy_info.local_path).await?; + assert!( + policy_content.contains("api.allowed.com"), + "Policy should contain allowed host" + ); + + Ok(()) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[test(tokio::test)] +async fn test_headless_multiple_network_permissions() -> Result<()> { + let component_path = build_fetch_component().await?; + let tempdir = tempfile::tempdir()?; + + // Create manifest with multiple hosts + let manifest_content = format!( + r#"version: 1 +components: + - uri: file://{} + name: multi-host-component + permissions: + network: + allow: + - host: "api.example.com" + - host: "cdn.example.com" + - host: "backup.example.com" +"#, + component_path.display() + ); + let manifest_path = tempdir.path().join("manifest.yaml"); + tokio::fs::write(&manifest_path, manifest_content).await?; + + let manifest_content = tokio::fs::read_to_string(&manifest_path).await?; + let manifest: wassette_mcp_server::manifest::ProvisioningManifest = + serde_yaml::from_str(&manifest_content)?; + + let manager = Arc::new( + LifecycleManager::builder(&tempdir) + .with_profile(DeploymentProfile::Headless) + .build() + .await?, + ); + + let secrets_manager = wassette::SecretsManager::new(tempdir.path().join("secrets")); + let controller = wassette_mcp_server::provisioning_controller::ProvisioningController::new( + &manifest, + &manager, + &secrets_manager, + tempdir.path(), + ); + + controller.provision().await?; + + let components = manager.list_components().await; + let component_id = &components[0]; + + // Verify all hosts are in the policy + let policy_info = manager.get_policy_info(component_id).await.unwrap(); + let policy_content = tokio::fs::read_to_string(&policy_info.local_path).await?; + + assert!(policy_content.contains("api.example.com")); + assert!(policy_content.contains("cdn.example.com")); + assert!(policy_content.contains("backup.example.com")); + + Ok(()) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[test(tokio::test)] +async fn test_headless_storage_permissions() -> Result<()> { + let component_path = build_fetch_component().await?; + let tempdir = tempfile::tempdir()?; + + let manifest_content = format!( + r#"version: 1 +components: + - uri: file://{} + name: storage-component + permissions: + storage: + allow: + - uri: "fs:///tmp/data" + access: + - read + - write + - uri: "fs:///tmp/cache" + access: + - read +"#, + component_path.display() + ); + let manifest_path = tempdir.path().join("manifest.yaml"); + tokio::fs::write(&manifest_path, manifest_content).await?; + + let manifest_content = tokio::fs::read_to_string(&manifest_path).await?; + let manifest: wassette_mcp_server::manifest::ProvisioningManifest = + serde_yaml::from_str(&manifest_content)?; + + let manager = Arc::new( + LifecycleManager::builder(&tempdir) + .with_profile(DeploymentProfile::Headless) + .build() + .await?, + ); + + let secrets_manager = wassette::SecretsManager::new(tempdir.path().join("secrets")); + let controller = wassette_mcp_server::provisioning_controller::ProvisioningController::new( + &manifest, + &manager, + &secrets_manager, + tempdir.path(), + ); + + controller.provision().await?; + + let components = manager.list_components().await; + let component_id = &components[0]; + + let policy_info = manager.get_policy_info(component_id).await.unwrap(); + let policy_content = tokio::fs::read_to_string(&policy_info.local_path).await?; + + assert!(policy_content.contains("fs:///tmp/data")); + assert!(policy_content.contains("fs:///tmp/cache")); + assert!(policy_content.contains("read")); + assert!(policy_content.contains("write")); + + Ok(()) +} + +// ============================================================================ +// DIGEST VERIFICATION TESTS +// ============================================================================ + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[test(tokio::test)] +async fn test_digest_verification_success() -> Result<()> { + use sha2::{Digest, Sha256}; + + let component_path = build_fetch_component().await?; + let tempdir = tempfile::tempdir()?; + + // Calculate actual digest + let component_bytes = tokio::fs::read(&component_path).await?; + let mut hasher = Sha256::new(); + hasher.update(&component_bytes); + let digest = format!("{:x}", hasher.finalize()); + + // Create manifest with correct digest + let manifest_content = create_manifest_with_digest(component_path.to_str().unwrap(), &digest); + let manifest_path = tempdir.path().join("manifest.yaml"); + tokio::fs::write(&manifest_path, manifest_content).await?; + + let manifest_content = tokio::fs::read_to_string(&manifest_path).await?; + let manifest: wassette_mcp_server::manifest::ProvisioningManifest = + serde_yaml::from_str(&manifest_content)?; + + let manager = Arc::new( + LifecycleManager::builder(&tempdir) + .with_profile(DeploymentProfile::Headless) + .build() + .await?, + ); + + let secrets_manager = wassette::SecretsManager::new(tempdir.path().join("secrets")); + let controller = wassette_mcp_server::provisioning_controller::ProvisioningController::new( + &manifest, + &manager, + &secrets_manager, + tempdir.path(), + ); + + // Should succeed with correct digest + let result = controller.provision().await; + assert!( + result.is_ok(), + "Provisioning should succeed with correct digest" + ); + + Ok(()) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[test(tokio::test)] +async fn test_digest_verification_failure() -> Result<()> { + let component_path = build_fetch_component().await?; + let tempdir = tempfile::tempdir()?; + + // Create manifest with incorrect digest + let wrong_digest = "0000000000000000000000000000000000000000000000000000000000000000"; + let manifest_content = + create_manifest_with_digest(component_path.to_str().unwrap(), wrong_digest); + let manifest_path = tempdir.path().join("manifest.yaml"); + tokio::fs::write(&manifest_path, manifest_content).await?; + + let manifest_content = tokio::fs::read_to_string(&manifest_path).await?; + let manifest: wassette_mcp_server::manifest::ProvisioningManifest = + serde_yaml::from_str(&manifest_content)?; + + let manager = Arc::new( + LifecycleManager::builder(&tempdir) + .with_profile(DeploymentProfile::Headless) + .build() + .await?, + ); + + let secrets_manager = wassette::SecretsManager::new(tempdir.path().join("secrets")); + let controller = wassette_mcp_server::provisioning_controller::ProvisioningController::new( + &manifest, + &manager, + &secrets_manager, + tempdir.path(), + ); + + // Should fail with incorrect digest + let result = controller.provision().await; + assert!( + result.is_err(), + "Provisioning should fail with incorrect digest" + ); + let error_msg = result.unwrap_err().to_string(); + assert!( + error_msg.contains("Digest") || error_msg.contains("digest"), + "Error should mention digest: {}", + error_msg + ); + + Ok(()) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[test(tokio::test)] +async fn test_digest_verification_malformed() -> Result<()> { + let component_path = build_fetch_component().await?; + let tempdir = tempfile::tempdir()?; + + // Create manifest with malformed digest (missing sha256: prefix) + let manifest_content = format!( + r#"version: 1 +components: + - uri: file://{} + name: malformed-digest + digest: "abcd1234" + permissions: + network: + allow: + - host: "api.example.com" +"#, + component_path.display() + ); + let manifest_path = tempdir.path().join("manifest.yaml"); + tokio::fs::write(&manifest_path, manifest_content).await?; + + let manifest_content = tokio::fs::read_to_string(&manifest_path).await?; + let manifest: wassette_mcp_server::manifest::ProvisioningManifest = + serde_yaml::from_str(&manifest_content)?; + + let manager = Arc::new( + LifecycleManager::builder(&tempdir) + .with_profile(DeploymentProfile::Headless) + .build() + .await?, + ); + + let secrets_manager = wassette::SecretsManager::new(tempdir.path().join("secrets")); + let controller = wassette_mcp_server::provisioning_controller::ProvisioningController::new( + &manifest, + &manager, + &secrets_manager, + tempdir.path(), + ); + + let result = controller.provision().await; + assert!(result.is_err(), "Should fail with malformed digest"); + let error_msg = result.unwrap_err().to_string(); + // Just verify it failed - the specific error message is implementation-dependent + assert!( + error_msg.contains("Digest") + || error_msg.contains("digest") + || error_msg.contains("sha256"), + "Error should relate to digest: {}", + error_msg + ); + + Ok(()) +} + +// ============================================================================ +// MANIFEST VALIDATION TESTS +// ============================================================================ + +#[test(tokio::test)] +async fn test_manifest_validation_empty_components() -> Result<()> { + let tempdir = tempfile::tempdir()?; + + let manifest_content = r#"version: 1 +components: [] +"#; + let manifest_path = tempdir.path().join("manifest.yaml"); + tokio::fs::write(&manifest_path, manifest_content).await?; + + let manifest_content = tokio::fs::read_to_string(&manifest_path).await?; + let manifest: wassette_mcp_server::manifest::ProvisioningManifest = + serde_yaml::from_str(&manifest_content)?; + + assert_eq!(manifest.components.len(), 0); + + Ok(()) +} + +#[test(tokio::test)] +async fn test_manifest_validation_missing_version() -> Result<()> { + let tempdir = tempfile::tempdir()?; + + let manifest_content = r#" +components: + - uri: "file:///tmp/test.wasm" + name: "test" +"#; + let manifest_path = tempdir.path().join("manifest.yaml"); + tokio::fs::write(&manifest_path, manifest_content).await?; + + let manifest_content = tokio::fs::read_to_string(&manifest_path).await?; + let result: Result = + serde_yaml::from_str(&manifest_content); + + assert!(result.is_err(), "Should fail without version field"); + + Ok(()) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[test(tokio::test)] +async fn test_manifest_validation_missing_uri() -> Result<()> { + let tempdir = tempfile::tempdir()?; + + let manifest_content = r#"version: 1 +components: + - name: "test-component" + permissions: + network: + allow: + - host: "api.example.com" +"#; + let manifest_path = tempdir.path().join("manifest.yaml"); + tokio::fs::write(&manifest_path, manifest_content).await?; + + let manifest_content = tokio::fs::read_to_string(&manifest_path).await?; + let result: Result = + serde_yaml::from_str(&manifest_content); + + assert!(result.is_err(), "Should fail without uri field"); + + Ok(()) +} + +// ============================================================================ +// ERROR HANDLING TESTS +// ============================================================================ + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[test(tokio::test)] +async fn test_headless_error_message_quality() -> Result<()> { + let (manager, _tempdir) = setup_headless_manager().await?; + let component_path = build_fetch_component().await?; + + let component_id = manager + .load_component(&format!("file://{}", component_path.display())) + .await? + .component_id; + + // Test network permission error + let network_result = manager + .grant_permission( + &component_id, + "network", + &serde_json::json!({"host": "example.com"}), + ) + .await; + + assert!(network_result.is_err()); + let error_msg = network_result.unwrap_err().to_string(); + + // Verify error message is helpful + assert!(error_msg.contains("Runtime permission grants are disabled")); + assert!(error_msg.contains("headless mode")); + assert!(error_msg.contains("network permission")); + assert!(error_msg.contains(&component_id)); + assert!(error_msg.contains("provisioning manifest")); + + Ok(()) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[test(tokio::test)] +async fn test_provision_with_nonexistent_component() -> Result<()> { + let tempdir = tempfile::tempdir()?; + + let manifest_content = r#"version: 1 +components: + - uri: file:///nonexistent/component.wasm + name: missing-component + permissions: + network: + allow: + - host: "api.example.com" +"#; + let manifest_path = tempdir.path().join("manifest.yaml"); + tokio::fs::write(&manifest_path, manifest_content).await?; + + let manifest_content = tokio::fs::read_to_string(&manifest_path).await?; + let manifest: wassette_mcp_server::manifest::ProvisioningManifest = + serde_yaml::from_str(&manifest_content)?; + + let manager = Arc::new( + LifecycleManager::builder(&tempdir) + .with_profile(DeploymentProfile::Headless) + .build() + .await?, + ); + + let secrets_manager = wassette::SecretsManager::new(tempdir.path().join("secrets")); + let controller = wassette_mcp_server::provisioning_controller::ProvisioningController::new( + &manifest, + &manager, + &secrets_manager, + tempdir.path(), + ); + + let result = controller.provision().await; + assert!(result.is_err(), "Should fail with nonexistent component"); + + Ok(()) +} + +// ============================================================================ +// PROFILE CONFIGURATION TESTS +// ============================================================================ + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[test(tokio::test)] +async fn test_profile_default_is_interactive() -> Result<()> { + let tempdir = tempfile::tempdir()?; + + // Create manager without specifying profile + let manager = LifecycleManager::new(&tempdir).await?; + let component_path = build_fetch_component().await?; + + let component_id = manager + .load_component(&format!("file://{}", component_path.display())) + .await? + .component_id; + + // Default profile should allow runtime grants + let result = manager + .grant_permission( + &component_id, + "network", + &serde_json::json!({"host": "example.com"}), + ) + .await; + + assert!(result.is_ok(), "Default profile should be interactive"); + + Ok(()) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[test(tokio::test)] +async fn test_profile_can_switch_between_modes() -> Result<()> { + let tempdir = tempfile::tempdir()?; + let component_path = build_fetch_component().await?; + + // Create interactive manager + let interactive_manager = Arc::new( + LifecycleManager::builder(&tempdir) + .with_profile(DeploymentProfile::Interactive) + .build() + .await?, + ); + + let component_id = interactive_manager + .load_component(&format!("file://{}", component_path.display())) + .await? + .component_id; + + // Should allow grants in interactive mode + let interactive_result = interactive_manager + .grant_permission( + &component_id, + "network", + &serde_json::json!({"host": "interactive.com"}), + ) + .await; + assert!(interactive_result.is_ok()); + + // Create new headless manager with same directory + let headless_manager = Arc::new( + LifecycleManager::builder(&tempdir) + .with_profile(DeploymentProfile::Headless) + .build() + .await?, + ); + + // Reload the same component + let component_id2 = headless_manager + .load_component(&format!("file://{}", component_path.display())) + .await? + .component_id; + + // Should block grants in headless mode + let headless_result = headless_manager + .grant_permission( + &component_id2, + "network", + &serde_json::json!({"host": "headless.com"}), + ) + .await; + assert!(headless_result.is_err()); + + Ok(()) +} + +// ============================================================================ +// INTEGRATION WITH EXISTING FEATURES +// ============================================================================ + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[test(tokio::test)] +async fn test_headless_with_component_unload() -> Result<()> { + let (manager, _tempdir) = setup_headless_manager().await?; + let component_path = build_fetch_component().await?; + + let component_id = manager + .load_component(&format!("file://{}", component_path.display())) + .await? + .component_id; + + // Verify component is loaded + let components = manager.list_components().await; + assert_eq!(components.len(), 1); + + // Unload should work in headless mode + let unload_result = manager.unload_component(&component_id).await; + assert!(unload_result.is_ok()); + + // Verify component is unloaded + let components = manager.list_components().await; + assert_eq!(components.len(), 0); + + Ok(()) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[test(tokio::test)] +async fn test_headless_with_component_reload() -> Result<()> { + let component_path = build_fetch_component().await?; + let tempdir = tempfile::tempdir()?; + + // Create manifest + let manifest_content = + create_simple_manifest(component_path.to_str().unwrap(), "api.example.com"); + let manifest_path = tempdir.path().join("manifest.yaml"); + tokio::fs::write(&manifest_path, manifest_content).await?; + + let manifest_content = tokio::fs::read_to_string(&manifest_path).await?; + let manifest: wassette_mcp_server::manifest::ProvisioningManifest = + serde_yaml::from_str(&manifest_content)?; + + let manager = Arc::new( + LifecycleManager::builder(&tempdir) + .with_profile(DeploymentProfile::Headless) + .build() + .await?, + ); + + let secrets_manager = wassette::SecretsManager::new(tempdir.path().join("secrets")); + let controller = wassette_mcp_server::provisioning_controller::ProvisioningController::new( + &manifest, + &manager, + &secrets_manager, + tempdir.path(), + ); + + controller.provision().await?; + + let components = manager.list_components().await; + let component_id = components[0].clone(); + + // Unload component + manager.unload_component(&component_id).await?; + + // Reload component - policy should still be enforced + let new_component_id = manager + .load_component(&format!("file://{}", component_path.display())) + .await? + .component_id; + + // Runtime grants should still be blocked + let grant_result = manager + .grant_permission( + &new_component_id, + "network", + &serde_json::json!({"host": "new.com"}), + ) + .await; + + assert!(grant_result.is_err()); + + Ok(()) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[test(tokio::test)] +async fn test_headless_list_components() -> Result<()> { + let component_path = build_fetch_component().await?; + let tempdir = tempfile::tempdir()?; + + let manifest_content = create_multi_component_manifest(component_path.to_str().unwrap()); + let manifest_path = tempdir.path().join("manifest.yaml"); + tokio::fs::write(&manifest_path, manifest_content).await?; + + let manifest_content = tokio::fs::read_to_string(&manifest_path).await?; + let manifest: wassette_mcp_server::manifest::ProvisioningManifest = + serde_yaml::from_str(&manifest_content)?; + + let manager = Arc::new( + LifecycleManager::builder(&tempdir) + .with_profile(DeploymentProfile::Headless) + .build() + .await?, + ); + + let secrets_manager = wassette::SecretsManager::new(tempdir.path().join("secrets")); + let controller = wassette_mcp_server::provisioning_controller::ProvisioningController::new( + &manifest, + &manager, + &secrets_manager, + tempdir.path(), + ); + + controller.provision().await?; + + // List components should work + // Note: Since both components have the same URI, the second one replaces the first, + // so we only have 1 component registered + let components = manager.list_components().await; + assert!( + !components.is_empty(), + "Should have at least 1 component loaded" + ); + + Ok(()) +}