diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 9771ba937..5106fd5e2 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -225,6 +225,8 @@ async fn run_default( )?; server::serve_with_dynamic( resolved.gateway, + resolved.plugin_had_input, + resolved.plugin_diagnostics, dynamic_plugins, managed_bootstrap, runtime_args.ready_file.as_deref(), diff --git a/crates/cli/src/configuration/mod.rs b/crates/cli/src/configuration/mod.rs index 99ae0aee2..6ce21e991 100644 --- a/crates/cli/src/configuration/mod.rs +++ b/crates/cli/src/configuration/mod.rs @@ -259,6 +259,7 @@ fn persistent_bootstrap_fingerprint( "max_hook_payload_bytes": gateway.max_hook_payload_bytes, "max_passthrough_body_bytes": gateway.max_passthrough_body_bytes, "plugin_idle_timeout_secs": idle_timeout_secs, + "plugin_had_input": resolved.plugin_had_input, "dynamic_plugins": dynamic_plugins, "dynamic_plugin_policy": format!("{:?}", resolved.dynamic_plugin_policy), }); @@ -1300,6 +1301,8 @@ struct PluginTomlConfig { dynamic_plugin_policy: DynamicPluginHostPolicy, contributing_sources: Vec, selected_sources: Vec, + had_input: bool, + diagnostics: Vec, } #[cfg(test)] @@ -1396,6 +1399,8 @@ where dynamic_plugin_policy: resolved.dynamic_plugin_policy, contributing_sources, selected_sources: resolved.selected_sources, + had_input: resolved.had_input, + diagnostics: resolved.diagnostics, })) } @@ -1409,6 +1414,8 @@ fn apply_plugin_toml_config(resolved: &mut ResolvedConfig, plugin_toml: Option

, pub(crate) dynamic_plugin_policy: DynamicPluginHostPolicy, pub(crate) plugin_selected_sources: Vec, + pub(crate) plugin_had_input: bool, + pub(crate) plugin_diagnostics: Vec, pub(crate) bootstrap_fingerprint: Option, } diff --git a/crates/cli/src/process/launcher.rs b/crates/cli/src/process/launcher.rs index 9d58441b8..56ded8c49 100644 --- a/crates/cli/src/process/launcher.rs +++ b/crates/cli/src/process/launcher.rs @@ -9,7 +9,7 @@ use nemo_relay::observability::OpenTelemetryType; use nemo_relay::observability::plugin_component::{ AtifStorageConfig, AtofSinkSectionConfig, OBSERVABILITY_PLUGIN_KIND, ObservabilityConfig, }; -use nemo_relay::plugin::PluginConfig; +use nemo_relay::plugin::{ConfigDiagnostic, PluginConfig}; use serde_json::Value; #[cfg(test)] use serde_json::json; @@ -137,6 +137,8 @@ impl TransparentRun { let result = execute_live_run_with_dynamic( self.listener, self.resolved.gateway, + self.resolved.plugin_had_input, + self.resolved.plugin_diagnostics, self.dynamic_plugins, &self.gateway_url, self.prepared, @@ -178,12 +180,23 @@ async fn execute_live_run( gateway_url: &str, prepared: PreparedAgentLaunch, ) -> Result { - execute_live_run_with_dynamic(listener, gateway_config, Vec::new(), gateway_url, prepared).await + execute_live_run_with_dynamic( + listener, + gateway_config, + false, + Vec::new(), + Vec::new(), + gateway_url, + prepared, + ) + .await } async fn execute_live_run_with_dynamic( listener: TcpListener, gateway_config: GatewayConfig, + plugin_had_input: bool, + plugin_diagnostics: Vec, dynamic_plugins: Vec, gateway_url: &str, prepared: PreparedAgentLaunch, @@ -193,6 +206,8 @@ async fn execute_live_run_with_dynamic( let running_server = RunningGateway::start( listener, gateway_config, + plugin_had_input, + plugin_diagnostics, dynamic_plugins, bootstrap_fingerprint.clone(), proxy_credential, @@ -365,6 +380,8 @@ impl RunningGateway { fn start( listener: TcpListener, config: crate::configuration::GatewayConfig, + plugin_had_input: bool, + plugin_diagnostics: Vec, dynamic_plugins: Vec, bootstrap_fingerprint: String, proxy_credential: crate::provider_auth::TransparentProxyCredential, @@ -374,6 +391,8 @@ impl RunningGateway { server::serve_transparent_listener_with_dynamic( listener, config, + plugin_had_input, + plugin_diagnostics, dynamic_plugins, bootstrap_fingerprint, proxy_credential, diff --git a/crates/cli/src/server/mod.rs b/crates/cli/src/server/mod.rs index 8e60bfabb..da59cd442 100644 --- a/crates/cli/src/server/mod.rs +++ b/crates/cli/src/server/mod.rs @@ -20,16 +20,15 @@ use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use axum::{Json, Router}; use nemo_relay::plugin::dynamic::{ - DynamicPluginKind, NativePluginActivation, NativePluginLoadSpec, WorkerPluginActivation, - WorkerPluginLoadSpec, load_native_plugins, load_worker_plugins, -}; -use nemo_relay::plugin::{ - PluginComponentSpec, PluginConfig, clear_plugin_configuration, initialize_plugins_exact, + DynamicPluginActivationResource, DynamicPluginActivationSpec, PlannedDynamicPluginActivation, + PluginHostActivationPlan, }; +use nemo_relay::plugin::{ConfigDiagnostic, PluginConfig}; use nemo_relay_adaptive::plugin_component::register_adaptive_component; #[cfg(feature = "switchyard")] use nemo_relay_adaptive::{AdaptiveConfig, plugin_component::ADAPTIVE_PLUGIN_KIND}; use nemo_relay_pii_redaction::component::register_pii_redaction_component; +use nemo_relay_plugin_host_config::PluginFileActivation; #[cfg(feature = "switchyard")] use nemo_relay_switchyard::{ SWITCHYARD_PLUGIN_KIND, SwitchyardConfig, register_switchyard_component, @@ -47,7 +46,7 @@ use crate::configuration::{ }; use crate::error::CliError; use crate::gateway; -use crate::plugins::lifecycle::{ActiveDynamicPluginComponent, DynamicPluginActivationSnapshot}; +use crate::plugins::lifecycle::ActiveDynamicPluginComponent; use crate::sessions::SessionManager; const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(30); @@ -89,6 +88,8 @@ struct BootstrapServeOptions<'a> { /// Binds the configured address and activates enabled dynamic plugins before serving. pub(crate) async fn serve_with_dynamic( config: GatewayConfig, + plugin_had_input: bool, + plugin_diagnostics: Vec, dynamic_plugins: Vec, managed_bootstrap: Option, ready_file: Option<&Path>, @@ -109,6 +110,8 @@ pub(crate) async fn serve_with_dynamic( serve_listener_with_dynamic_inner( listener, config, + plugin_had_input, + plugin_diagnostics, dynamic_plugins, Some(ShutdownMode::ProcessSignal), BootstrapServeOptions { @@ -185,7 +188,17 @@ pub(crate) async fn serve_listener( config: GatewayConfig, shutdown: Option>, ) -> Result<(), CliError> { - serve_listener_with_dynamic(listener, config, Vec::new(), shutdown).await + let plugin_had_input = config.plugin_config.is_some(); + serve_listener_with_dynamic_inner( + listener, + config, + plugin_had_input, + Vec::new(), + Vec::new(), + shutdown.map(ShutdownMode::Receiver), + BootstrapServeOptions::default(), + ) + .await } #[cfg(test)] @@ -198,6 +211,8 @@ pub(crate) async fn serve_listener_with_bootstrap( serve_listener_with_dynamic_inner( listener, config, + false, + Vec::new(), Vec::new(), shutdown.map(ShutdownMode::Receiver), BootstrapServeOptions { @@ -216,9 +231,12 @@ pub(crate) async fn serve_listener_with_dynamic( dynamic_plugins: Vec, shutdown: Option>, ) -> Result<(), CliError> { + let plugin_had_input = config.plugin_config.is_some() || !dynamic_plugins.is_empty(); serve_listener_with_dynamic_inner( listener, config, + plugin_had_input, + Vec::new(), dynamic_plugins, shutdown.map(ShutdownMode::Receiver), BootstrapServeOptions::default(), @@ -228,9 +246,12 @@ pub(crate) async fn serve_listener_with_dynamic( /// Serves a wrapper-owned dynamic gateway with authenticated health while keeping foreground /// provider-auth semantics. Plugin-owned MCP clients use the proof to borrow only this instance. +#[allow(clippy::too_many_arguments)] pub(crate) async fn serve_transparent_listener_with_dynamic( listener: TcpListener, config: GatewayConfig, + plugin_had_input: bool, + plugin_diagnostics: Vec, dynamic_plugins: Vec, bootstrap_fingerprint: String, transparent_proxy_credential: crate::provider_auth::TransparentProxyCredential, @@ -239,6 +260,8 @@ pub(crate) async fn serve_transparent_listener_with_dynamic( serve_listener_with_dynamic_inner( listener, config, + plugin_had_input, + plugin_diagnostics, dynamic_plugins, shutdown.map(ShutdownMode::Receiver), BootstrapServeOptions { @@ -260,6 +283,8 @@ enum ShutdownMode { async fn serve_listener_with_dynamic_inner( listener: TcpListener, config: GatewayConfig, + plugin_had_input: bool, + plugin_diagnostics: Vec, dynamic_plugins: Vec, shutdown_mode: Option, bootstrap: BootstrapServeOptions<'_>, @@ -284,8 +309,13 @@ async fn serve_listener_with_dynamic_inner( .transpose() .map_err(CliError::Launch)?; let require_provider_client_token = managed_bootstrap.is_some(); - let plugin_activation = - initialize_plugin_host(config.plugin_config.clone(), dynamic_plugins).await?; + let plugin_activation = initialize_plugin_host( + config.plugin_config.clone(), + plugin_had_input, + plugin_diagnostics, + dynamic_plugins, + ) + .await?; let (bootstrap_shutdown, bootstrap_shutdown_rx) = bootstrap_shutdown_channel(bootstrap_shutdown_token.clone()); let mut state = AppState::new_with_bootstrap( @@ -396,13 +426,17 @@ fn combine_shutdown_futures( async fn finish_server_shutdown( serve_result: std::io::Result<()>, sessions: &SessionManager, - plugin_activation: Option, + plugin_activation: Option, instance_id: &str, ) -> Result<(), CliError> { let close_result = sessions.close_all("gateway_shutdown").await; let flush_result = nemo_relay::api::runtime::flush_subscribers().map_err(CliError::from); let clear_result = plugin_activation - .map(ServerPluginActivation::clear) + .map(|activation| { + activation + .clear() + .map_err(|error| CliError::Config(format!("plugin teardown failed: {error}"))) + }) .unwrap_or(Ok(())); if let Err(serve_error) = serve_result { log::error!( @@ -843,21 +877,6 @@ where }) } -enum ServerPluginActivation { - Static, - Dynamic(PluginActivation), -} - -impl ServerPluginActivation { - fn clear(self) -> Result<(), CliError> { - match self { - Self::Static => clear_plugin_configuration() - .map_err(|error| CliError::Config(format!("plugin teardown failed: {error}"))), - Self::Dynamic(activation) => activation.clear(), - } - } -} - #[derive(Debug)] pub(crate) enum PluginComponentSetupError { Adaptive(String), @@ -993,189 +1012,61 @@ fn validate_switchyard_response_cache_order(config: &PluginConfig) -> Result<(), async fn initialize_plugin_host( config: Option, + plugin_had_input: bool, + diagnostics: Vec, dynamic_plugins: Vec, -) -> Result, CliError> { - if config.is_none() && dynamic_plugins.is_empty() { +) -> Result, CliError> { + if !plugin_had_input && config.is_none() && dynamic_plugins.is_empty() { return Ok(None); } - if dynamic_plugins.is_empty() { - let plugin_config: PluginConfig = config - .map(serde_json::from_value) - .transpose() - .map_err(|error| CliError::Config(format!("invalid plugin config: {error}")))? - .unwrap_or_default(); - if let Some(error) = register_and_validate_plugin_components(&plugin_config) - .into_iter() - .next() - { - return Err(CliError::Config(error.to_string())); - } - initialize_plugins_exact(plugin_config) - .await - .map_err(|error| CliError::Config(format!("plugin activation failed: {error}")))?; - return Ok(Some(ServerPluginActivation::Static)); + let plugin_config = config + .map(serde_json::from_value) + .transpose() + .map_err(|error| CliError::Config(format!("invalid plugin config: {error}")))? + .unwrap_or_default(); + if let Some(error) = register_and_validate_plugin_components(&plugin_config) + .into_iter() + .next() + { + return Err(CliError::Config(error.to_string())); } - PluginActivation::initialize(config, dynamic_plugins) + let plan = plugin_host_activation_plan(plugin_config, diagnostics, dynamic_plugins)?; + PluginFileActivation::activate_plan(plan) .await - .map(ServerPluginActivation::Dynamic) .map(Some) + .map_err(|error| CliError::Config(format!("plugin activation failed: {error}"))) } -struct PluginActivation { - active: bool, - native: Option, - worker: Option, - _snapshots: Vec>, -} - -impl PluginActivation { - async fn initialize( - config: Option, - dynamic_plugins: Vec, - ) -> Result { - if config.is_none() && dynamic_plugins.is_empty() { - return Ok(Self { - active: false, - native: None, - worker: None, - _snapshots: Vec::new(), - }); - }; - // Gateway already resolved its config; activate exactly (no re-discovery). - let mut plugin_config: PluginConfig = match config { - Some(config) => serde_json::from_value(config) - .map_err(|error| CliError::Config(format!("invalid plugin config: {error}")))?, - None => PluginConfig::default(), - }; - plugin_config - .components - .extend(dynamic_plugins.iter().map(|plugin| PluginComponentSpec { - kind: plugin.plugin_id.clone(), - enabled: true, - config: plugin.config.clone(), - })); - if let Some(error) = register_and_validate_plugin_components(&plugin_config) - .into_iter() - .next() - { - return Err(CliError::Config(error.to_string())); - } - for plugin in &dynamic_plugins { - if let Some(snapshot) = plugin.activation_snapshot.as_ref() { - snapshot - .verify_current() - .map_err(|error| CliError::Config(error.to_string()))?; - } - } - let native_specs = dynamic_plugins - .iter() - .filter(|plugin| plugin.kind == DynamicPluginKind::RustDynamic) - .map(|plugin| { - let manifest_ref = plugin - .activation_snapshot - .as_ref() - .map(|snapshot| snapshot.activation_manifest_ref()) - .or_else(|| plugin.manifest_ref.clone()) - .ok_or_else(|| { - CliError::Config(format!( - "native dynamic plugin '{}' has no manifest_ref in lifecycle state", - plugin.plugin_id - )) - })?; - Ok(NativePluginLoadSpec { - plugin_id: plugin.plugin_id.clone(), - manifest_ref, - }) - }) - .collect::, CliError>>()?; - let worker_specs = dynamic_plugins - .iter() - .filter(|plugin| plugin.kind == DynamicPluginKind::Worker) - .map(|plugin| { - let manifest_ref = plugin - .activation_snapshot - .as_ref() - .map(|snapshot| snapshot.activation_manifest_ref()) - .or_else(|| plugin.manifest_ref.clone()) - .ok_or_else(|| { - CliError::Config(format!( - "worker dynamic plugin '{}' has no manifest_ref in lifecycle state", - plugin.plugin_id - )) - })?; - Ok(WorkerPluginLoadSpec { - plugin_id: plugin.plugin_id.clone(), - manifest_ref, - environment_ref: plugin - .activation_snapshot - .as_ref() - .and_then(|snapshot| snapshot.activation_environment_ref()) - .map(ToOwned::to_owned) - .or_else(|| plugin.environment_ref.clone()), - config: plugin.config.clone(), - }) - }) - .collect::, CliError>>()?; - let snapshots = dynamic_plugins - .iter() - .filter_map(|plugin| plugin.activation_snapshot.clone()) - .collect(); - let native = - if native_specs.is_empty() { - None - } else { - Some(load_native_plugins(native_specs).map_err(|error| { - CliError::Config(format!("native plugin load failed: {error}")) - })?) - }; - for plugin in &dynamic_plugins { - if let Some(snapshot) = plugin.activation_snapshot.as_ref() { - snapshot - .verify_current() - .map_err(|error| CliError::Config(error.to_string()))?; - } - } - let worker = - if worker_specs.is_empty() { - None - } else { - Some(load_worker_plugins(worker_specs).map_err(|error| { - CliError::Config(format!("worker plugin load failed: {error}")) - })?) +fn plugin_host_activation_plan( + config: PluginConfig, + diagnostics: Vec, + dynamic_plugins: Vec, +) -> Result { + let dynamic_plugins = dynamic_plugins + .into_iter() + .map(|plugin| { + let snapshot = plugin.activation_snapshot.ok_or_else(|| { + CliError::Config(format!( + "dynamic plugin '{}' has no activation snapshot", + plugin.plugin_id + )) + })?; + let spec = DynamicPluginActivationSpec { + plugin_id: plugin.plugin_id, + kind: plugin.kind, + manifest_ref: snapshot.activation_manifest_ref(), + environment_ref: snapshot.activation_environment_ref().map(str::to_owned), + config: plugin.config, }; - initialize_plugins_exact(plugin_config) - .await - .map_err(|error| CliError::Config(format!("plugin activation failed: {error}")))?; - Ok(Self { - active: true, - native, - worker, - _snapshots: snapshots, + let resource: Arc = snapshot; + Ok(PlannedDynamicPluginActivation { spec, resource }) }) - } - - fn clear(mut self) -> Result<(), CliError> { - let result = if self.active { - self.active = false; - clear_plugin_configuration() - .map_err(|error| CliError::Config(format!("plugin teardown failed: {error}")))?; - Ok(()) - } else { - Ok(()) - }; - self.native.take(); - self.worker.take(); - result - } -} - -impl Drop for PluginActivation { - fn drop(&mut self) { - if self.active { - let _ = clear_plugin_configuration(); - self.active = false; - } - } + .collect::, CliError>>()?; + Ok(PluginHostActivationPlan { + config, + dynamic_plugins, + diagnostics, + }) } // Normalizes a Codex hook payload, applies all resulting events before responding, and returns the diff --git a/crates/cli/tests/coverage/shared/config_tests.rs b/crates/cli/tests/coverage/shared/config_tests.rs index fdb61de15..a1fa743d9 100644 --- a/crates/cli/tests/coverage/shared/config_tests.rs +++ b/crates/cli/tests/coverage/shared/config_tests.rs @@ -938,6 +938,37 @@ fn absent_optional_plugin_config_is_ignored() { let loaded = load_plugin_toml_config_from_paths(vec![missing]).unwrap(); assert!(loaded.is_none()); + assert!(!ResolvedConfig::default().plugin_had_input); + assert!(ResolvedConfig::default().plugin_diagnostics.is_empty()); +} + +#[test] +fn existing_plugin_config_preserves_activation_metadata() { + let temp = tempfile::tempdir().unwrap(); + let plugins_path = temp.path().join("plugins.toml"); + std::fs::write(&plugins_path, "").unwrap(); + + let loaded = load_plugin_toml_config_from_paths(vec![plugins_path.clone()]) + .unwrap() + .expect("the physical plugin file participates even when it has no components"); + assert!(loaded.had_input); + assert_eq!(loaded.diagnostics.len(), 1); + assert_eq!(loaded.diagnostics[0].code, "plugin.configuration_inherited"); + assert!( + loaded.diagnostics[0] + .message + .contains(plugins_path.to_string_lossy().as_ref()) + ); + + let mut resolved = ResolvedConfig::default(); + apply_plugin_toml_config(&mut resolved, Some(loaded)); + assert!(resolved.gateway.plugin_config.is_none()); + assert!(resolved.plugin_had_input); + assert_eq!(resolved.plugin_diagnostics.len(), 1); + assert_eq!( + resolved.plugin_diagnostics[0].code, + "plugin.configuration_inherited" + ); } #[cfg(unix)] @@ -2534,6 +2565,31 @@ fn persistent_fingerprint_tracks_provider_auth_headers() { assert_ne!(openai_changed, anthropic_changed); } +#[test] +fn persistent_fingerprint_distinguishes_absent_and_existing_empty_plugin_config() { + let temp = tempfile::tempdir().unwrap(); + let project = temp.path().join("project"); + let xdg = temp.path().join("xdg"); + let user_config_dir = xdg.join("nemo-relay"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::create_dir_all(&user_config_dir).unwrap(); + let _scope = PluginConfigDiscoveryScope::enter(&project, &xdg); + + let absent = resolve_persistent_server_config(&GatewayOverrides::default()).unwrap(); + assert!(!absent.plugin_had_input); + assert!(absent.gateway.plugin_config.is_none()); + + std::fs::write(user_config_dir.join("plugins.toml"), "").unwrap(); + let existing_empty = resolve_persistent_server_config(&GatewayOverrides::default()).unwrap(); + assert!(existing_empty.plugin_had_input); + assert!(existing_empty.gateway.plugin_config.is_none()); + assert_eq!(existing_empty.plugin_diagnostics.len(), 1); + assert_ne!( + absent.bootstrap_fingerprint, existing_empty.bootstrap_fingerprint, + "an existing empty plugins.toml owns an activation and must not reuse an absent-config daemon" + ); +} + #[test] fn managed_bootstrap_canonicalizes_unset_and_zero_padded_default_idle_timeout() { let temp = tempfile::tempdir().unwrap(); @@ -2734,7 +2790,7 @@ fn persistent_hook_identity_authenticates_python_marker_without_rehashing_enviro std::fs::create_dir_all(&project).unwrap(); std::fs::create_dir_all(&user_config).unwrap(); std::fs::create_dir_all(&plugin_dir).unwrap(); - let _scope = PluginConfigDiscoveryScope::enter(&project, &xdg); + let scope = PluginConfigDiscoveryScope::enter(&project, &xdg); let plugin_id = "acme.read-only-hook-identity"; let manifest_path = write_dynamic_manifest(&plugin_dir, plugin_id); let plugins_toml = user_config.join("plugins.toml"); @@ -2782,6 +2838,21 @@ fn persistent_hook_identity_authenticates_python_marker_without_rehashing_enviro assert!(active[0].activation_snapshot.is_some()); let snapshot_fingerprint = persistent_bootstrap_fingerprint(&resolved, &active).unwrap(); assert!(snapshot_fingerprint.starts_with("hmac-sha256:")); + scope.set_bootstrap_fingerprint(&snapshot_fingerprint); + let managed_args = GatewayOverrides { + ready_file: Some(temp.path().join("managed.ready.json")), + ..GatewayOverrides::default() + }; + let identity = managed_bootstrap_identity(&managed_args, &resolved, &active) + .unwrap() + .unwrap(); + assert!(std::sync::Arc::ptr_eq( + active[0].activation_snapshot.as_ref().unwrap(), + identity.active_dynamic_plugins[0] + .activation_snapshot + .as_ref() + .unwrap(), + )); crate::plugins::lifecycle::reset_test_python_environment_digest_calls(); std::fs::write( diff --git a/crates/cli/tests/coverage/shared/installer_tests.rs b/crates/cli/tests/coverage/shared/installer_tests.rs index f77f7d74d..54246ad88 100644 --- a/crates/cli/tests/coverage/shared/installer_tests.rs +++ b/crates/cli/tests/coverage/shared/installer_tests.rs @@ -60,6 +60,8 @@ async fn transparent_hook_delivery_authenticates_the_wrapper_gateway() { let server = tokio::spawn(crate::server::serve_transparent_listener_with_dynamic( listener, config, + false, + Vec::new(), Vec::new(), fingerprint.clone(), crate::provider_auth::TransparentProxyCredential::generate().unwrap(), diff --git a/crates/cli/tests/coverage/shared/mcp_tests.rs b/crates/cli/tests/coverage/shared/mcp_tests.rs index f506344f9..fbac6c4d3 100644 --- a/crates/cli/tests/coverage/shared/mcp_tests.rs +++ b/crates/cli/tests/coverage/shared/mcp_tests.rs @@ -421,6 +421,8 @@ async fn borrowed_transparent_gateway_is_authenticated_and_monitored() { let gateway = tokio::spawn(crate::server::serve_transparent_listener_with_dynamic( listener, config, + false, + Vec::new(), Vec::new(), fingerprint.clone(), crate::provider_auth::TransparentProxyCredential::generate().unwrap(), diff --git a/crates/cli/tests/coverage/shared/server_tests.rs b/crates/cli/tests/coverage/shared/server_tests.rs index e25c048c7..ab8a187fe 100644 --- a/crates/cli/tests/coverage/shared/server_tests.rs +++ b/crates/cli/tests/coverage/shared/server_tests.rs @@ -25,10 +25,11 @@ use nemo_relay::api::registry::{ use nemo_relay::api::subscriber::{deregister_subscriber, flush_subscribers, register_subscriber}; use nemo_relay::plugin::dynamic::DynamicPluginKind; use nemo_relay::plugin::{ - ConfigDiagnostic, Plugin, PluginRegistration, PluginRegistrationContext, deregister_plugin, - register_plugin, + ConfigDiagnostic, DiagnosticLevel, Plugin, PluginRegistration, PluginRegistrationContext, + deregister_plugin, register_plugin, }; use serde_json::{Map, Value, json}; +use sha2::{Digest, Sha256}; use tokio::net::TcpListener; use tokio::sync::{Semaphore, oneshot}; use tokio::task::JoinHandle; @@ -37,7 +38,7 @@ use tower::ServiceExt; use super::*; use crate::configuration::BootstrapChallengeKey; use crate::error::CliError; -use crate::plugins::lifecycle::ActiveDynamicPluginComponent; +use crate::plugins::lifecycle::{ActiveDynamicPluginComponent, DynamicPluginActivationSnapshot}; use crate::test_support::PLUGIN_CONFIG_TEST_LOCK; const GENERIC_TEST_PLUGIN_KIND: &str = "cli-test-generic-plugin"; @@ -253,14 +254,23 @@ fn startup_status_reports_not_configured_when_no_exporters() { assert!(output.contains("Exporters not configured")); } -fn write_missing_native_plugin_manifest( +fn write_invalid_native_plugin_manifest( dir: &std::path::Path, plugin_id: &str, ) -> std::path::PathBuf { - let missing_library = dir.join("missing-native-plugin"); + let invalid_library_bytes = b"not a dynamic library"; + let invalid_library = dir.join("invalid-native-plugin"); + std::fs::write(&invalid_library, invalid_library_bytes).unwrap(); let manifest_ref = dir.join("relay-plugin.toml"); let plugin_id = serde_json::to_string(plugin_id).unwrap(); - let library = serde_json::to_string(&missing_library.to_string_lossy()).unwrap(); + let library = serde_json::to_string(&invalid_library.to_string_lossy()).unwrap(); + let digest = format!( + "sha256:{}", + Sha256::digest(invalid_library_bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ); std::fs::write( &manifest_ref, format!( @@ -280,6 +290,12 @@ enabled = false [capabilities] items = ["plugin_native"] +[source] +artifact = {library} + +[integrity] +sha256 = "{digest}" + [load] library = {library} symbol = "nemo_relay_missing_native_plugin" @@ -2116,7 +2132,7 @@ async fn serve_listener_activates_any_registered_plugin_kind() { } #[tokio::test] -async fn static_only_cli_configuration_keeps_the_legacy_lifecycle() { +async fn static_only_cli_configuration_uses_owned_file_lifecycle() { let _guard = PLUGIN_CONFIG_TEST_LOCK.lock().await; let _ = nemo_relay::plugin::clear_plugin_configuration(); let _ = deregister_plugin(GENERIC_TEST_PLUGIN_KIND); @@ -2131,21 +2147,97 @@ async fn static_only_cli_configuration_keeps_the_legacy_lifecycle() { "config": {} }] })), + true, + Vec::new(), Vec::new(), ) .await .expect("static CLI config should initialize") .expect("static CLI config should return a teardown guard"); - assert!(matches!(&activation, ServerPluginActivation::Static)); + assert!(activation.is_active()); nemo_relay::plugin::clear_plugin_configuration() - .expect("legacy clear should remain available for a static-only CLI config"); + .expect_err("generic clear must not bypass the owned file activation"); + nemo_relay::plugin::initialize_plugins_exact(PluginConfig::default()) + .await + .expect_err("legacy initialization must not replace the owned file activation"); + let conflict = initialize_plugin_host(Some(json!({})), true, Vec::new(), Vec::new()) + .await + .err() + .expect("a second owned activation must conflict"); + assert!( + conflict.to_string().contains("owned by an active"), + "{conflict}" + ); activation .clear() - .expect("the static teardown guard should tolerate prior clear"); + .expect("the owned static activation should clear"); let _ = deregister_plugin(GENERIC_TEST_PLUGIN_KIND); } +#[tokio::test] +async fn plugin_host_distinguishes_no_input_from_explicit_empty_and_preserves_diagnostics() { + let _guard = PLUGIN_CONFIG_TEST_LOCK.lock().await; + let _ = nemo_relay::plugin::clear_plugin_configuration(); + + let inactive = initialize_plugin_host(None, false, Vec::new(), Vec::new()) + .await + .expect("no-input initialization should succeed"); + assert!(inactive.is_none()); + + let diagnostic = ConfigDiagnostic { + level: DiagnosticLevel::Warning, + code: "plugin.configuration_inherited".into(), + component: None, + field: None, + message: "Inherited plugin configuration from /redacted/plugins.toml".into(), + }; + let activation = + initialize_plugin_host(Some(json!({})), true, vec![diagnostic.clone()], Vec::new()) + .await + .expect("explicit empty configuration should activate") + .expect("explicit empty configuration should own a host"); + assert!(activation.is_active()); + assert_eq!(activation.report().diagnostics, vec![diagnostic]); + activation.clear().unwrap(); +} + +#[test] +fn plugin_host_plan_uses_the_exact_activation_snapshot_resource() { + let temp = tempfile::tempdir().unwrap(); + let manifest_ref = write_invalid_native_plugin_manifest(temp.path(), "cli.snapshot-plan"); + let snapshot = DynamicPluginActivationSnapshot::create( + manifest_ref.to_str().unwrap(), + "cli.snapshot-plan", + DynamicPluginKind::RustDynamic, + None, + &crate::plugins::policy::DynamicPluginHostPolicy::default(), + ) + .expect("the test plugin should produce an activation snapshot"); + let expected_manifest_ref = snapshot.activation_manifest_ref(); + let expected_resource: Arc = snapshot.clone(); + let plan = plugin_host_activation_plan( + PluginConfig::default(), + Vec::new(), + vec![ActiveDynamicPluginComponent { + plugin_id: "cli.snapshot-plan".into(), + kind: DynamicPluginKind::RustDynamic, + lifecycle_generation: 1, + manifest_ref: Some("/untrusted/original/relay-plugin.toml".into()), + environment_ref: Some("/untrusted/original/environment".into()), + config: Map::new(), + activation_snapshot: Some(snapshot), + }], + ) + .expect("the server should build a plan from the snapshot"); + + assert_eq!(plan.dynamic_plugins.len(), 1); + let planned = &plan.dynamic_plugins[0]; + assert_eq!(planned.spec.manifest_ref, expected_manifest_ref); + assert_eq!(planned.spec.environment_ref, None); + assert!(Arc::ptr_eq(&expected_resource, &planned.resource)); +} + #[test] fn plugin_component_setup_errors_render_every_diagnostic_variant() { let adaptive = PluginComponentSetupError::Adaptive("adaptive failure".into()); @@ -2207,14 +2299,10 @@ fn dynamic_component_without_manifest( #[tokio::test] async fn plugin_activation_covers_empty_invalid_and_missing_manifest_paths() { let _guard = PLUGIN_CONFIG_TEST_LOCK.lock().await; - let inactive = PluginActivation::initialize(None, Vec::new()) - .await - .unwrap(); - assert!(!inactive.active); - inactive.clear().unwrap(); - - let invalid = PluginActivation::initialize( + let invalid = initialize_plugin_host( Some(json!("not a plugin config")), + true, + Vec::new(), vec![dynamic_component_without_manifest( "acme.invalid-config", DynamicPluginKind::Worker, @@ -2225,8 +2313,10 @@ async fn plugin_activation_covers_empty_invalid_and_missing_manifest_paths() { .expect("invalid config should fail activation"); assert!(invalid.to_string().contains("invalid plugin config")); - let native = PluginActivation::initialize( + let native = initialize_plugin_host( None, + true, + Vec::new(), vec![dynamic_component_without_manifest( "acme.native-missing", DynamicPluginKind::RustDynamic, @@ -2235,10 +2325,12 @@ async fn plugin_activation_covers_empty_invalid_and_missing_manifest_paths() { .await .err() .expect("native plugin without a manifest should fail activation"); - assert!(native.to_string().contains("native dynamic plugin")); + assert!(native.to_string().contains("activation snapshot")); - let worker = PluginActivation::initialize( + let worker = initialize_plugin_host( None, + true, + Vec::new(), vec![dynamic_component_without_manifest( "acme.worker-missing", DynamicPluginKind::Worker, @@ -2247,7 +2339,7 @@ async fn plugin_activation_covers_empty_invalid_and_missing_manifest_paths() { .await .err() .expect("worker plugin without a manifest should fail activation"); - assert!(worker.to_string().contains("worker dynamic plugin")); + assert!(worker.to_string().contains("activation snapshot")); } #[tokio::test] @@ -2432,7 +2524,15 @@ async fn serve_listener_with_dynamic_reports_native_load_errors() { let _ = nemo_relay::plugin::clear_plugin_configuration(); let temp = tempfile::tempdir().unwrap(); - let manifest_ref = write_missing_native_plugin_manifest(temp.path(), "cli.missing-native"); + let manifest_ref = write_invalid_native_plugin_manifest(temp.path(), "cli.invalid-native"); + let snapshot = DynamicPluginActivationSnapshot::create( + manifest_ref.to_str().unwrap(), + "cli.invalid-native", + DynamicPluginKind::RustDynamic, + None, + &crate::plugins::policy::DynamicPluginHostPolicy::default(), + ) + .expect("the invalid library should still produce an immutable activation snapshot"); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let (shutdown_tx, shutdown_rx) = oneshot::channel(); drop(shutdown_tx); @@ -2440,13 +2540,13 @@ async fn serve_listener_with_dynamic_reports_native_load_errors() { listener, test_config(), vec![ActiveDynamicPluginComponent { - plugin_id: "cli.missing-native".into(), + plugin_id: "cli.invalid-native".into(), kind: DynamicPluginKind::RustDynamic, lifecycle_generation: 0, manifest_ref: Some(manifest_ref.to_string_lossy().into_owned()), environment_ref: None, config: Map::new(), - activation_snapshot: None, + activation_snapshot: Some(snapshot), }], Some(shutdown_rx), ) @@ -2455,8 +2555,14 @@ async fn serve_listener_with_dynamic_reports_native_load_errors() { let error = error.to_string(); assert!(error.contains("native plugin load failed"), "{error}"); - assert!(error.contains("does not exist"), "{error}"); + assert!(error.contains("invalid-native-plugin"), "{error}"); assert!(nemo_relay::plugin::active_plugin_report().is_none()); + + let recovery = initialize_plugin_host(Some(json!({})), true, Vec::new(), Vec::new()) + .await + .expect("failed native loading must release the host lease") + .expect("explicit empty recovery config should own the host"); + recovery.clear().unwrap(); } #[tokio::test]