diff --git a/crates/core/src/plugin.rs b/crates/core/src/plugin.rs index 010cbcf5e..e1f46ca0a 100644 --- a/crates/core/src/plugin.rs +++ b/crates/core/src/plugin.rs @@ -1451,10 +1451,18 @@ async fn initialize_plugin_components_catching_panics( /// default `version`/`policy`/`enabled` override the file, while `config` bodies /// merge field-by-field. Delegates to [`initialize_plugins_exact`]. pub async fn initialize_plugins(config: PluginConfig) -> Result { + let config = resolve_plugin_config(config)?; + initialize_plugins_exact(config).await +} + +/// Layers `config` over the default discovered `plugins.toml` files. +/// +/// This is crate-visible so owned dynamic-plugin activation can use the same +/// one-time configuration resolution as regular harness-native initialization. +pub(crate) fn resolve_plugin_config(config: PluginConfig) -> Result { let mut base = resolve_default_file_plugin_config()?; layer_config(&mut base, serde_json::to_value(config)?); - let config: PluginConfig = serde_json::from_value(base)?; - initialize_plugins_exact(config).await + Ok(serde_json::from_value(base)?) } /// Resolves the default `plugins.toml` layering into one JSON document, or an diff --git a/crates/core/src/plugin/dynamic/host.rs b/crates/core/src/plugin/dynamic/host.rs index d30d2abdc..dce481855 100644 --- a/crates/core/src/plugin/dynamic/host.rs +++ b/crates/core/src/plugin/dynamic/host.rs @@ -18,7 +18,7 @@ use serde_json::{Map, Value as Json}; use crate::plugin::{ ConfigReport, PluginComponentSpec, PluginConfig, PluginHostLease, Result, acquire_plugin_host_lease, clear_plugin_configuration_for_host, - ensure_builtin_plugins_registered, initialize_plugins_exact_for_host, + ensure_builtin_plugins_registered, initialize_plugins_exact_for_host, resolve_plugin_config, run_owned_plugin_mutation, }; @@ -79,6 +79,32 @@ impl PluginHostActivation { { let dynamic_plugins = dynamic_plugins.into_iter().collect::>(); validate_dynamic_plugin_specs(&dynamic_plugins)?; + Self::activate_validated(config, dynamic_plugins).await + } + + /// Load dynamic plugins after layering `config` over discovered `plugins.toml` files. + /// + /// This is the harness-native entrypoint for language and FFI bindings. File + /// discovery and merging happen once before activation, and the explicit + /// `config` has higher precedence. Hosts such as the Relay CLI that already + /// resolved plugin configuration should call [`Self::activate`] instead. + pub async fn activate_with_discovered_config( + config: PluginConfig, + dynamic_plugins: I, + ) -> Result<(Self, ConfigReport)> + where + I: IntoIterator, + { + let dynamic_plugins = dynamic_plugins.into_iter().collect::>(); + validate_dynamic_plugin_specs(&dynamic_plugins)?; + let config = resolve_plugin_config(config)?; + Self::activate_validated(config, dynamic_plugins).await + } + + async fn activate_validated( + config: PluginConfig, + dynamic_plugins: Vec, + ) -> Result<(Self, ConfigReport)> { run_owned_plugin_mutation("dynamic plugin activation", move || async move { Self::activate_inner(config, dynamic_plugins).await }) diff --git a/crates/core/tests/integration/native_plugin_tests.rs b/crates/core/tests/integration/native_plugin_tests.rs index d4415791d..5d98c99b4 100644 --- a/crates/core/tests/integration/native_plugin_tests.rs +++ b/crates/core/tests/integration/native_plugin_tests.rs @@ -41,6 +41,7 @@ use tokio_stream::StreamExt; use uuid::Uuid; static NATIVE_PLUGIN_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); +const PLUGIN_DISCOVERY_TEST_CHILD: &str = "NEMO_RELAY_PLUGIN_DISCOVERY_TEST_CHILD"; struct ReplacementRegistryPlugin; @@ -1184,6 +1185,70 @@ async fn plugin_host_activation_combines_static_base_and_dynamic_components() { assert!(deregister_plugin(STATIC_BASE_PLUGIN_KIND)); } +#[tokio::test] +async fn plugin_host_activation_layers_discovered_static_base_with_dynamic_components() { + if std::env::var_os(PLUGIN_DISCOVERY_TEST_CHILD).is_none() { + let environment = TempDir::new().expect("plugin discovery environment should be created"); + let project_config_dir = environment.path().join(".nemo-relay"); + std::fs::create_dir_all(&project_config_dir) + .expect("project plugin config directory should be created"); + std::fs::write( + project_config_dir.join("plugins.toml"), + format!( + "version = 1\n\n[[components]]\nkind = {STATIC_BASE_PLUGIN_KIND:?}\nenabled = true\n" + ), + ) + .expect("project plugin config should be written"); + let xdg_config_home = environment.path().join("xdg"); + std::fs::create_dir_all(&xdg_config_home) + .expect("isolated user config directory should be created"); + + let output = Command::new(std::env::current_exe().expect("test executable should resolve")) + .args([ + "--exact", + "plugin_host_activation_layers_discovered_static_base_with_dynamic_components", + "--nocapture", + ]) + .current_dir(environment.path()) + .env("XDG_CONFIG_HOME", &xdg_config_home) + .env(PLUGIN_DISCOVERY_TEST_CHILD, "1") + .output() + .expect("plugin discovery child process should run"); + assert!( + output.status.success(), + "plugin discovery child process failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return; + } + + let _guard = NATIVE_PLUGIN_TEST_LOCK.lock().await; + let _ = deregister_plugin(STATIC_BASE_PLUGIN_KIND); + STATIC_BASE_REGISTRATIONS.store(0, Ordering::SeqCst); + STATIC_BASE_DEREGISTRATIONS.store(0, Ordering::SeqCst); + register_plugin(Arc::new(StaticBasePlugin)).expect("static base plugin should register"); + + let fixture = build_fixture_plugin(); + let manifest_ref = write_manifest(&fixture); + let (activation, report) = PluginHostActivation::activate_with_discovered_config( + PluginConfig::default(), + [host_spec("fixture_native", &manifest_ref)], + ) + .await + .expect("discovered static and dynamic components should activate together"); + + assert!(!report.has_errors()); + assert_eq!(STATIC_BASE_REGISTRATIONS.load(Ordering::SeqCst), 1); + assert!(lookup_plugin(STATIC_BASE_PLUGIN_KIND).is_some()); + assert!(lookup_plugin("fixture_native").is_some()); + + activation.clear().expect("discovered host should clear"); + assert_eq!(STATIC_BASE_DEREGISTRATIONS.load(Ordering::SeqCst), 1); + assert!(lookup_plugin("fixture_native").is_none()); + assert!(deregister_plugin(STATIC_BASE_PLUGIN_KIND)); +} + #[tokio::test] async fn plugin_host_clear_allows_an_in_flight_native_callback_to_finish() { let _guard = NATIVE_PLUGIN_TEST_LOCK.lock().await; diff --git a/go/nemo_relay/scope/error_coverage_test.go b/go/nemo_relay/scope/error_coverage_test.go index 811093efe..04ff8b7f8 100644 --- a/go/nemo_relay/scope/error_coverage_test.go +++ b/go/nemo_relay/scope/error_coverage_test.go @@ -12,6 +12,10 @@ import ( ) func TestWithScopeCleanupNoopsWhenPushFails(t *testing.T) { + runWithTestScopeStack(t, testWithScopeCleanupNoopsWhenPushFails) +} + +func testWithScopeCleanupNoopsWhenPushFails(t *testing.T) { for _, tc := range []struct { name string opt nemo_relay.ScopeOption