From 4be77d61158314881b7205360e95eada2fc87bdd Mon Sep 17 00:00:00 2001 From: Zhongxuan Wang Date: Wed, 3 Jun 2026 17:39:56 -0700 Subject: [PATCH 01/15] feat: add code-driven plugin config layer Augment initialize_plugins so a code-driven plugin config layer, set via the new set_code_driven_plugin_config runtime API, is merged on top of the file/CLI-resolved config before validation and activation. The code-driven layer is the highest-precedence source and overrides discovered plugins.toml / [plugins].config / --plugin-config per-field; with no layer set, behavior is unchanged so file-only configs stay compatible. nemo-relay doctor now surfaces the effective plugin config source (code-driven layer vs file/CLI) and validates the merged effective config. Docs document the layering model, precedence, and conflict behavior. Additive and Rust-only: two new public functions in nemo_relay::plugin (set_code_driven_plugin_config, apply_code_driven_plugin_config). No existing API, struct/datatype, or language-binding changes. Relates to relay-183. Signed-off-by: Zhongxuan Wang --- crates/cli/src/doctor.rs | 96 ++++++++++--- crates/cli/tests/coverage/doctor_tests.rs | 96 +++++++++++++ crates/core/Cargo.toml | 4 + crates/core/src/plugin.rs | 127 ++++++++++++++++++ .../tests/integration/plugin_layer_tests.rs | 105 +++++++++++++++ crates/core/tests/unit/plugin_tests.rs | 105 +++++++++++++++ .../plugin-configuration-files.mdx | 83 +++++++++++- 7 files changed, 595 insertions(+), 21 deletions(-) create mode 100644 crates/core/tests/integration/plugin_layer_tests.rs diff --git a/crates/cli/src/doctor.rs b/crates/cli/src/doctor.rs index 2d93cc94..b1626cc6 100644 --- a/crates/cli/src/doctor.rs +++ b/crates/cli/src/doctor.rs @@ -14,7 +14,9 @@ use std::process::Stdio; use std::time::Duration; use nemo_relay::observability::plugin_component::OBSERVABILITY_PLUGIN_KIND; -use nemo_relay::plugin::{DiagnosticLevel, PluginConfig, validate_plugin_config}; +use nemo_relay::plugin::{ + DiagnosticLevel, PluginConfig, apply_code_driven_plugin_config, validate_plugin_config, +}; use nemo_relay_adaptive::plugin_component::register_adaptive_component; use serde::Serialize; use serde_json::Value; @@ -571,28 +573,74 @@ async fn probe_version(binary: &Path) -> Option { } async fn collect_observability(gateway: &GatewayConfig) -> Vec { + // Resolve the code-driven layer (if any) merged over the file/CLI config, so doctor validates + // and reports exactly what the runtime would activate. If the file config fails to parse we + // layer over an empty base here, but the inner function re-parses the raw value and reports the + // parse failure before the overlay is ever used. + let base = gateway + .plugin_config + .as_ref() + .and_then(|value| serde_json::from_value::(value.clone()).ok()) + .unwrap_or_default(); + let effective_overlay = apply_code_driven_plugin_config(&base); + collect_observability_layered(gateway.plugin_config.as_ref(), effective_overlay.as_ref()).await +} + +// Reports plugin/observability checks for the effective plugin configuration. `effective_overlay` is +// `Some` only when a code-driven layer is active, and then carries the layer already merged over the +// file/CLI config — the highest-precedence source, so its values win per-field. When it is `None`, +// this validates the file config alone, producing output identical to file-only behavior. Sub-checks +// (exporter directories/endpoints) run against the effective config so doctor reports what the +// runtime would actually activate, not just the file layer. +async fn collect_observability_layered( + file_value: Option<&Value>, + effective_overlay: Option<&PluginConfig>, +) -> Vec { let mut checks = Vec::new(); - let Some(plugin_value) = &gateway.plugin_config else { - checks.push(Check { - name: "Plugins", - status: Status::Info, - details: "plugins.toml not configured".into(), - }); - return checks; + let file_config = match file_value { + Some(plugin_value) => match serde_json::from_value::(plugin_value.clone()) { + Ok(config) => Some(config), + Err(err) => { + checks.push(Check { + name: "Plugins", + status: Status::Fail, + details: format!("invalid plugin config: {err}"), + }); + return checks; + } + }, + None => None, }; - let plugin_config = match serde_json::from_value::(plugin_value.clone()) { - Ok(config) => config, - Err(err) => { + let layer_active = effective_overlay.is_some(); + let effective_config = match (file_config.as_ref(), effective_overlay) { + (None, None) => { checks.push(Check { name: "Plugins", - status: Status::Fail, - details: format!("invalid plugin config: {err}"), + status: Status::Info, + details: "plugins.toml not configured".into(), }); return checks; } + (Some(file), None) => file.clone(), + (_, Some(overlay)) => overlay.clone(), }; + + // Surface where the effective config came from so conflicts have a clear winner. Only emitted + // when a code-driven layer is active; otherwise the file-only output is left unchanged. + if layer_active { + checks.push(Check { + name: "Plugin config source", + status: Status::Info, + details: if file_config.is_some() { + "code-driven layer active; overrides file/CLI plugin config".into() + } else { + "code-driven layer active; no file/CLI plugin config".into() + }, + }); + } + if let Err(error) = register_adaptive_component() { checks.push(Check { name: "Adaptive plugin", @@ -601,12 +649,19 @@ async fn collect_observability(gateway: &GatewayConfig) -> Vec { }); return checks; } - let report = validate_plugin_config(&plugin_config); + // Prefix diagnostics when a layer is active so the reported failure is attributed to the merged + // effective config rather than any single file/CLI source. + let detail_prefix = if layer_active { + "effective plugin config: " + } else { + "" + }; + let report = validate_plugin_config(&effective_config); if report.diagnostics.is_empty() { checks.push(Check { name: "Plugins", status: Status::Pass, - details: "validation passed".into(), + details: format!("{detail_prefix}validation passed"), }); } else { for diagnostic in report.diagnostics { @@ -617,12 +672,19 @@ async fn collect_observability(gateway: &GatewayConfig) -> Vec { } else { Status::Warn }, - details: format!("{}: {}", diagnostic.code, diagnostic.message), + details: format!("{detail_prefix}{}: {}", diagnostic.code, diagnostic.message), }); } } - if let Some(config) = observability_component_config(plugin_value) { + // Run exporter sub-checks against the effective config. When no layer is active this is the + // original file value (byte-identical output); otherwise it is the merged document. + let effective_value = if layer_active { + serde_json::to_value(&effective_config).unwrap_or(Value::Null) + } else { + file_value.cloned().unwrap_or(Value::Null) + }; + if let Some(config) = observability_component_config(&effective_value) { collect_observability_component_checks(&mut checks, config).await; } else { checks.push(Check { diff --git a/crates/cli/tests/coverage/doctor_tests.rs b/crates/cli/tests/coverage/doctor_tests.rs index 6cfcabdd..32406202 100644 --- a/crates/cli/tests/coverage/doctor_tests.rs +++ b/crates/cli/tests/coverage/doctor_tests.rs @@ -657,6 +657,102 @@ async fn collect_observability_registers_adaptive_before_validation() { ); } +#[tokio::test] +async fn collect_observability_layered_reports_code_driven_source_and_effective_config() { + let temp = tempfile::tempdir().unwrap(); + let missing = temp.path().join("missing-atif"); + + // File/CLI-resolved config: observability present, ATIF disabled. + let file_value = serde_json::json!({ + "version": 1, + "components": [{ + "kind": "observability", + "enabled": true, + "config": { "version": 1, "atif": { "enabled": false } } + }] + }); + // Effective config the code-driven layer produces over the file config: the layer is the higher + // precedence source and enables ATIF (with a missing output directory), so the effective config + // has ATIF enabled. `apply_code_driven_plugin_config` returns this merged result; doctor's inner + // function consumes it directly. + let effective: PluginConfig = serde_json::from_value(serde_json::json!({ + "version": 1, + "components": [{ + "kind": "observability", + "enabled": true, + "config": { "version": 1, "atif": { "enabled": true, "output_directory": missing } } + }] + })) + .unwrap(); + + let checks = collect_observability_layered(Some(&file_value), Some(&effective)).await; + + // The effective source is surfaced as the winning layer. + let source = checks + .iter() + .find(|check| check.name == "Plugin config source") + .expect("a plugin config source check"); + assert_eq!(source.status, Status::Info); + assert!( + source.details.contains("code-driven layer"), + "{:?}", + source.details + ); + + // Sub-checks run against the effective (merged) config: ATIF, disabled in the file, is enabled + // by the layer, so the ATIF directory check appears and warns about the missing directory. + let atif_check = checks + .iter() + .find(|check| check.name == "ATIF dir") + .expect("ATIF directory check from the effective config"); + assert_eq!(atif_check.status, Status::Warn); + assert!(!missing.exists()); + + // Diagnostics/validation are attributed to the effective config when a layer is active. + assert!( + checks + .iter() + .any(|check| check.details.contains("effective plugin config")), + "expected effective-config attribution: {checks:?}" + ); +} + +#[tokio::test] +async fn collect_observability_layered_without_file_config_uses_the_layer() { + // No file/CLI plugin config, only a code-driven layer: the layer alone is the effective config, + // so doctor reports it as the source and validates it instead of "plugins.toml not configured". + let effective: PluginConfig = serde_json::from_value(serde_json::json!({ + "version": 1, + "components": [{ "kind": "observability", "enabled": true, "config": { "version": 1 } }] + })) + .unwrap(); + + let checks = collect_observability_layered(None, Some(&effective)).await; + + assert!( + !checks + .iter() + .any(|check| check.details == "plugins.toml not configured"), + "layer-only config must not report 'not configured': {checks:?}" + ); + let source = checks + .iter() + .find(|check| check.name == "Plugin config source") + .expect("a plugin config source check"); + assert!( + source.details.contains("no file/CLI plugin config"), + "{:?}", + source.details + ); + assert!( + checks + .iter() + .any(|check| check.name == "Plugins" + && check.details.contains("effective plugin config")), + "expected effective-config validation: {checks:?}" + ); +} + #[test] fn format_agents_human_lists_supported_and_separates_detected() { let agents = vec![ diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index e205b83c..4d8154ff 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -118,6 +118,10 @@ path = "tests/integration/subscriber_dispatcher_tests.rs" name = "api_surface_integration" path = "tests/integration/api_surface_tests.rs" +[[test]] +name = "plugin_layer_integration" +path = "tests/integration/plugin_layer_tests.rs" + [[test]] name = "atif_storage_integration" path = "tests/integration/atif_storage_tests.rs" diff --git a/crates/core/src/plugin.rs b/crates/core/src/plugin.rs index f9731a3e..7b3bbcb9 100644 --- a/crates/core/src/plugin.rs +++ b/crates/core/src/plugin.rs @@ -44,6 +44,8 @@ type PluginMap = HashMap>; static PLUGIN_HANDLERS: LazyLock> = LazyLock::new(|| RwLock::new(HashMap::new())); static ACTIVE_PLUGIN_CONFIGURATION: LazyLock>> = LazyLock::new(|| Mutex::new(None)); +static PLUGIN_CONFIG_CODE_DRIVEN_LAYER: LazyLock>> = + LazyLock::new(|| Mutex::new(None)); static BUILTIN_PLUGIN_REGISTRATION: OnceLock> = OnceLock::new(); /// Error type for generic plugin operations. @@ -899,6 +901,124 @@ pub fn validate_plugin_config(config: &PluginConfig) -> ConfigReport { report } +/// Sets or clears the code-driven plugin configuration layer. +/// +/// The code-driven layer is the highest-precedence plugin configuration source. +/// When set, [`initialize_plugins`] merges this layer *on top of* the +/// configuration passed by the caller, so code-driven values win over file- or +/// CLI-resolved configuration. Passing [`None`] clears the layer and restores +/// file-only behavior. +/// +/// This is an additive, opt-in runtime API: when no layer is set, +/// [`initialize_plugins`] behaves exactly as before. Language bindings do not +/// expose this function; it is intended for Rust hosts (such as the +/// `nemo-relay` gateway and embedding applications) that overlay code-driven +/// configuration on top of discovered file/CLI configuration. +/// +/// # Parameters +/// - `config`: The code-driven layer to apply, or [`None`] to clear it. +/// +/// # Notes +/// The layer is process-global and is only consulted by [`initialize_plugins`] +/// calls in the same process. +pub fn set_code_driven_plugin_config(config: Option) { + let mut guard = PLUGIN_CONFIG_CODE_DRIVEN_LAYER + .lock() + .unwrap_or_else(|err| err.into_inner()); + *guard = config; +} + +/// Applies the code-driven plugin configuration layer on top of `config`. +/// +/// When a code-driven layer is set via [`set_code_driven_plugin_config`], this +/// returns [`Some`] containing the effective configuration — the layer merged +/// over `config`, with code-driven values winning. When no layer is set, it +/// returns [`None`], meaning `config` is already the effective configuration. +/// +/// This is the single read entry point for the code-driven layer: callers such +/// as [`initialize_plugins`] and `nemo-relay doctor` use it both to obtain the +/// effective configuration and to detect whether a layer contributed. +/// +/// # Parameters +/// - `config`: The file/CLI-resolved configuration to layer over. +/// +/// # Returns +/// [`Some`] effective configuration when a layer is active, otherwise [`None`]. +pub fn apply_code_driven_plugin_config(config: &PluginConfig) -> Option { + let guard = PLUGIN_CONFIG_CODE_DRIVEN_LAYER + .lock() + .unwrap_or_else(|err| err.into_inner()); + guard + .as_ref() + .map(|layer| merge_plugin_config_layers(config, layer)) +} + +/// Merges a higher-precedence plugin configuration layer over a lower one. +/// +/// `overlay` is the higher-precedence layer; its values win. Component matching +/// and component-`config` merging mirror the gateway's file merge semantics so +/// the composed configuration is deterministic; top-level `version` and `policy` +/// are instead replaced wholesale by `overlay` (unlike the field-by-field table +/// merge used for file configuration): +/// - `components` are matched by `kind`. A component present in both layers is +/// merged in place (preserving `base` ordering); a component only in +/// `overlay` is appended; a component only in `base` is preserved. +/// - A merged component takes `enabled` from `overlay` and deep-merges its +/// `config` object: nested objects merge recursively, while arrays and scalar +/// values are replaced by the `overlay` value. +/// - Top-level `version` and `policy` are taken from `overlay`. Both fields are +/// always present once a [`PluginConfig`] is constructed, so the overlay +/// fully specifies them; set them on the overlay to match the intended +/// effective values. +/// +/// # Parameters +/// - `base`: The lower-precedence configuration. +/// - `overlay`: The higher-precedence configuration whose values win. +/// +/// # Returns +/// The composed effective [`PluginConfig`]. +fn merge_plugin_config_layers(base: &PluginConfig, overlay: &PluginConfig) -> PluginConfig { + let mut components = base.components.clone(); + for overlay_component in &overlay.components { + match components + .iter_mut() + .find(|candidate| candidate.kind == overlay_component.kind) + { + Some(existing) => { + existing.enabled = overlay_component.enabled; + merge_json_object(&mut existing.config, overlay_component.config.clone()); + } + None => components.push(overlay_component.clone()), + } + } + PluginConfig { + version: overlay.version, + components, + policy: overlay.policy, + } +} + +/// Recursively merges `overlay` entries into a `base` JSON object. +/// +/// Nested objects merge key-by-key; arrays and scalar values are replaced by +/// the `overlay` value. This mirrors the gateway's recursive TOML table merge. +fn merge_json_object(base: &mut Map, overlay: Map) { + for (key, overlay_value) in overlay { + match overlay_value { + Json::Object(overlay_object) => { + if let Some(Json::Object(base_object)) = base.get_mut(&key) { + merge_json_object(base_object, overlay_object); + } else { + base.insert(key, Json::Object(overlay_object)); + } + } + other => { + base.insert(key, other); + } + } + } +} + /// Returns the JSON Schema for the canonical plugin configuration document. #[cfg(feature = "schema")] pub fn plugin_config_schema() -> Json { @@ -926,7 +1046,14 @@ pub fn plugin_config_schema() -> Json { /// # Notes /// Initialization is replace-with-rollback: the previous active configuration /// is removed before the new configuration is activated. +/// +/// When a code-driven layer is set via [`set_code_driven_plugin_config`], it is +/// merged on top of `config` first (code-driven values win), and the resulting +/// effective configuration is what gets validated, activated, stored, and +/// reported by [`active_plugin_report`]. When no layer is set, `config` is used +/// unchanged. pub async fn initialize_plugins(config: PluginConfig) -> Result { + let config = apply_code_driven_plugin_config(&config).unwrap_or(config); let report = validate_plugin_config(&config); if report.has_errors() { return Err(PluginError::InvalidConfig(join_error_messages(&report))); diff --git a/crates/core/tests/integration/plugin_layer_tests.rs b/crates/core/tests/integration/plugin_layer_tests.rs new file mode 100644 index 00000000..01751d7d --- /dev/null +++ b/crates/core/tests/integration/plugin_layer_tests.rs @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Integration tests for the code-driven plugin configuration layer. +//! +//! These run in a dedicated test binary so mutating the process-global +//! code-driven layer cannot contaminate the shared lib-test binary, which runs +//! other `initialize_plugins` tests concurrently. Within this binary the few +//! global-mutating tests are serialized with a local lock. + +use std::sync::Mutex; + +use nemo_relay::plugin::{ + ConfigPolicy, DiagnosticLevel, PluginComponentSpec, PluginConfig, PluginError, + UnsupportedBehavior, apply_code_driven_plugin_config, clear_plugin_configuration, + initialize_plugins, set_code_driven_plugin_config, +}; + +static LAYER_LOCK: Mutex<()> = Mutex::new(()); + +// A disabled, unknown-kind component. It is validated (so configuration policy applies to it) but +// skipped during activation (so initialization does not fail on a missing plugin registration). +fn disabled_unknown_component_config() -> PluginConfig { + PluginConfig { + components: vec![PluginComponentSpec { + kind: "relay183.ghost.kind".into(), + enabled: false, + config: serde_json::Map::new(), + }], + ..PluginConfig::default() + } +} + +#[test] +fn set_clears_and_applies_code_driven_layer() { + let _guard = LAYER_LOCK.lock().unwrap_or_else(|err| err.into_inner()); + set_code_driven_plugin_config(None); + // No layer: apply returns None, so the caller's own config is already effective. + assert!(apply_code_driven_plugin_config(&PluginConfig::default()).is_none()); + + let layer = PluginConfig { + components: vec![PluginComponentSpec::new("relay183.example")], + ..PluginConfig::default() + }; + set_code_driven_plugin_config(Some(layer)); + // Layer set: apply returns Some(effective) with the layer's component merged over the base. + let effective = + apply_code_driven_plugin_config(&PluginConfig::default()).expect("a layer is active"); + assert!( + effective + .components + .iter() + .any(|component| component.kind == "relay183.example"), + "effective config should include the code-driven component: {effective:?}" + ); + + set_code_driven_plugin_config(None); + assert!(apply_code_driven_plugin_config(&PluginConfig::default()).is_none()); +} + +#[test] +fn initialize_plugins_is_unchanged_without_a_layer() { + let _guard = LAYER_LOCK.lock().unwrap_or_else(|err| err.into_inner()); + set_code_driven_plugin_config(None); + + // Default policy treats an unknown component as a warning, so a disabled unknown component + // validates and initializes without error. This is the file-only baseline. + let report = + futures::executor::block_on(initialize_plugins(disabled_unknown_component_config())) + .expect("file-only initialization succeeds"); + assert!(!report.has_errors()); + assert!( + report.diagnostics.iter().any(|diagnostic| { + diagnostic.code == "plugin.unknown_component" + && diagnostic.level == DiagnosticLevel::Warning + }), + "expected an unknown-component warning: {report:?}" + ); + clear_plugin_configuration().unwrap(); +} + +#[test] +fn code_driven_layer_policy_overrides_file_config_before_validation() { + let _guard = LAYER_LOCK.lock().unwrap_or_else(|err| err.into_inner()); + + // The overlay tightens policy so the same disabled unknown component now fails validation. + // Because the failure occurs at all, the layer must have been merged on top of the caller's + // config *before* validation, and the overlay's policy won over the file default policy. + set_code_driven_plugin_config(Some(PluginConfig { + policy: ConfigPolicy { + unknown_component: UnsupportedBehavior::Error, + ..ConfigPolicy::default() + }, + ..PluginConfig::default() + })); + + let error = + futures::executor::block_on(initialize_plugins(disabled_unknown_component_config())) + .expect_err("overlay policy turns the unknown component into an error"); + assert!(matches!(error, PluginError::InvalidConfig(_)), "{error:?}"); + + // The failing activation must not leave a configuration active. + set_code_driven_plugin_config(None); + let _ = clear_plugin_configuration(); +} diff --git a/crates/core/tests/unit/plugin_tests.rs b/crates/core/tests/unit/plugin_tests.rs index e5eb7255..c61c5fa3 100644 --- a/crates/core/tests/unit/plugin_tests.rs +++ b/crates/core/tests/unit/plugin_tests.rs @@ -299,6 +299,7 @@ fn reset_global() { let mut state = ctx.write().unwrap(); *state = NemoRelayContextState::new(); clear_plugin_configuration().unwrap(); + set_code_driven_plugin_config(None); recorded_names().lock().unwrap().clear(); PARTIAL_FAIL_ROLLBACKS.store(0, Ordering::SeqCst); RESTORE_FAIL_REGISTRATIONS.store(0, Ordering::SeqCst); @@ -314,6 +315,110 @@ fn reset_global() { let _ = deregister_plugin("vanishing.plugin"); } +#[test] +fn test_merge_plugin_config_layers_overlay_wins() { + // The overlay is the highest-precedence layer: it overrides shared component fields, + // deep-merges nested config objects, replaces arrays, appends overlay-only kinds, preserves + // base-only kinds, and supplies the effective top-level version/policy. + let base = PluginConfig { + version: 1, + components: vec![ + PluginComponentSpec { + kind: "alpha".into(), + enabled: true, + config: serde_json::from_value(json!({ + "keep": "base", + "override": "base", + "nested": {"a": 1, "b": 2}, + "list": [1, 2, 3] + })) + .unwrap(), + }, + PluginComponentSpec { + kind: "base_only".into(), + enabled: true, + config: Map::new(), + }, + ], + policy: ConfigPolicy::default(), + }; + let overlay = PluginConfig { + version: 1, + components: vec![ + PluginComponentSpec { + kind: "alpha".into(), + enabled: false, + config: serde_json::from_value(json!({ + "override": "overlay", + "added": true, + "nested": {"b": 20, "c": 30}, + "list": [9] + })) + .unwrap(), + }, + PluginComponentSpec { + kind: "overlay_only".into(), + enabled: true, + config: Map::new(), + }, + ], + policy: ConfigPolicy { + unknown_component: UnsupportedBehavior::Error, + unknown_field: UnsupportedBehavior::Error, + unsupported_value: UnsupportedBehavior::Error, + }, + }; + + let merged = merge_plugin_config_layers(&base, &overlay); + + // Ordering: base components first (in base order), then overlay-only components appended. + let kinds: Vec<&str> = merged + .components + .iter() + .map(|component| component.kind.as_str()) + .collect(); + assert_eq!(kinds, vec!["alpha", "base_only", "overlay_only"]); + + let alpha = &merged.components[0]; + assert!(!alpha.enabled, "overlay enabled wins on a shared component"); + assert_eq!( + alpha.config.get("keep"), + Some(&json!("base")), + "base-only key is preserved" + ); + assert_eq!( + alpha.config.get("override"), + Some(&json!("overlay")), + "overlay scalar wins" + ); + assert_eq!( + alpha.config.get("added"), + Some(&json!(true)), + "overlay key is added" + ); + assert_eq!( + alpha.config.get("nested"), + Some(&json!({"a": 1, "b": 20, "c": 30})), + "nested objects merge recursively" + ); + assert_eq!( + alpha.config.get("list"), + Some(&json!([9])), + "arrays are replaced, not merged" + ); + + // Base-only component is preserved unchanged. + assert_eq!(merged.components[1].kind, "base_only"); + assert!(merged.components[1].enabled); + + // Top-level version and policy come from the overlay. + assert_eq!(merged.version, 1); + assert!(matches!( + merged.policy.unknown_component, + UnsupportedBehavior::Error + )); +} + #[test] fn test_config_report_has_errors() { let report = ConfigReport { diff --git a/docs/build-plugins/plugin-configuration-files.mdx b/docs/build-plugins/plugin-configuration-files.mdx index 866d80d9..1a753064 100644 --- a/docs/build-plugins/plugin-configuration-files.mdx +++ b/docs/build-plugins/plugin-configuration-files.mdx @@ -70,7 +70,10 @@ The gateway reads only files named `plugins.toml`. ## Discovery -The gateway can receive plugin configuration from three source classes: +The gateway resolves plugin configuration from two kinds of input: file/CLI +sources and an optional code-driven layer. + +File/CLI configuration comes from one of three mutually exclusive source classes: | Source | Use case | |---|---| @@ -78,10 +81,16 @@ The gateway can receive plugin configuration from three source classes: | `[plugins].config` in `config.toml` | Inline gateway config for small or generated setups. | | `--plugin-config ''` | CI, tests, wrappers, or one-off automation. | -Use only one source class for a given gateway run. The gateway fails clearly if -file-based plugin config and `--plugin-config` are both present, or if +Use only one file/CLI source class for a given gateway run. The gateway fails +clearly if file-based plugin config and `--plugin-config` are both present, or if `plugins.toml` and `[plugins].config` are both present. +A code-driven layer is a separate, programmatic source set through the runtime +API. It is not a file and is not mutually exclusive with the file/CLI sources: +when set, it is merged on top of the resolved file/CLI configuration as the +highest-precedence layer. See +[Code-Driven Configuration Layer](#code-driven-configuration-layer). + When `--config path/to/config.toml` is supplied, plugin file discovery is scoped to `path/to/plugins.toml`. Implicit system, project, and user plugin files are not loaded for that run. @@ -155,7 +164,9 @@ menu to reset, clear, preview, or save. When more than one `plugins.toml` file is discovered, later files have higher precedence. User config overrides project config, and project config overrides -system config. +system config. A code-driven layer, when set, sits above all of these as the +highest-precedence source (see +[Code-Driven Configuration Layer](#code-driven-configuration-layer)). TOML tables merge recursively: @@ -195,6 +206,70 @@ components that reach plugin validation also fail validation. Arrays inside component config are replaced by the higher-precedence value. Tables inside component config merge recursively. +## Code-Driven Configuration Layer + +A host application can supply a *code-driven* plugin configuration layer +programmatically through the runtime API instead of through files. In Rust: + +```rust +use nemo_relay::plugin::{set_code_driven_plugin_config, PluginConfig}; + +set_code_driven_plugin_config(Some(my_plugin_config)); +// Pass `None` to clear the layer and restore file-only behavior. +``` + +The code-driven layer is the **highest-precedence** plugin configuration source. +When set, `initialize_plugins` merges it on top of the configuration it is given +(the resolved file/CLI configuration), so code-driven values win field-by-field. +This lets an application apply programmatic overrides in code while still +honoring operator files for everything the code does not set. + +The merge uses the same rules as the file precedence above: + +- Components are matched by `kind`. A component present in both layers is merged + in place; a component only in the code-driven layer is added; a component only + in the file configuration is preserved. +- A merged component takes `enabled` from the code-driven layer and deep-merges + its `config` object: nested tables merge recursively, while arrays and scalar + values are replaced by the code-driven value. +- Top-level `version` and `policy` are taken from the code-driven layer. A typed + `PluginConfig` always carries these fields, so set them on the layer to match + the values you want in effect. + +### Precedence And Conflicts + +The effective configuration is composed from lowest to highest precedence: + +1. Discovered `plugins.toml` files, merged in order: system + (`/etc/nemo-relay/plugins.toml`) is lowest, then project (nearest + `.nemo-relay/plugins.toml`), then user + (`$XDG_CONFIG_HOME/nemo-relay/plugins.toml`) is highest +2. **or** a single inline `[plugins].config` block in `config.toml` +3. **or** `--plugin-config ''` +4. Code-driven configuration layer (highest) + +Items 1–3 are the **file/CLI source classes** and are mutually exclusive: exactly +one of them supplies the file/CLI configuration for a run, and the `plugins.toml` +sub-layers (system, project, user) only merge with each other. The code-driven +layer (item 4) is not a file/CLI source; when set it is always merged on top of +whichever file/CLI source is active, so its values win. + +The gateway fails fast, naming the sources, in these conflict cases: + +| Situation | Result | +|---|---| +| `--plugin-config` and any file config (`plugins.toml` or `[plugins].config`) | Error: choose one source | +| `plugins.toml` and `[plugins].config` both present | Error: choose one source | +| `[plugins].config` defined in more than one `config.toml` file | Error: consolidate into one block | +| Code-driven layer set together with any file/CLI source | No conflict — the layer overrides the file/CLI config field-by-field | + +The code-driven layer is process-local: it only affects `initialize_plugins` +calls in the same process. It is a Rust runtime API and is not exposed to the +Python, Node.js, or other language bindings, and it is not a file source, so the +file discovery and conflict rules above are unchanged. `nemo-relay doctor` +reports the effective configuration source and validates the merged result when a +code-driven layer is active in the doctor process. + ## Explicit Defaults And Overrides The editor writes explicit defaults for edited Observability and Adaptive From ceac635ad45df96f9dfb831bc61d9a08f074eedf Mon Sep 17 00:00:00 2001 From: Zhongxuan Wang Date: Wed, 3 Jun 2026 22:00:48 -0700 Subject: [PATCH 02/15] fix: preserve multi-instance components in code-driven layer merge merge_plugin_config_layers now pairs components by kind in order of appearance instead of always merging into the first match, so repeated kinds (multi-instance plugins) are no longer collapsed or dropped. Also slim the layer integration test to the apply path (merge correctness is covered by the unit tests) and tighten the new doc comments. Signed-off-by: Zhongxuan Wang --- crates/core/src/plugin.rs | 109 +++++++----------- .../tests/integration/plugin_layer_tests.rs | 87 ++------------ crates/core/tests/unit/plugin_tests.rs | 44 +++++++ .../plugin-configuration-files.mdx | 6 +- 4 files changed, 94 insertions(+), 152 deletions(-) diff --git a/crates/core/src/plugin.rs b/crates/core/src/plugin.rs index 7b3bbcb9..b44610a3 100644 --- a/crates/core/src/plugin.rs +++ b/crates/core/src/plugin.rs @@ -901,26 +901,11 @@ pub fn validate_plugin_config(config: &PluginConfig) -> ConfigReport { report } -/// Sets or clears the code-driven plugin configuration layer. +/// Sets (or clears with `None`) the process-global code-driven plugin layer. /// -/// The code-driven layer is the highest-precedence plugin configuration source. -/// When set, [`initialize_plugins`] merges this layer *on top of* the -/// configuration passed by the caller, so code-driven values win over file- or -/// CLI-resolved configuration. Passing [`None`] clears the layer and restores -/// file-only behavior. -/// -/// This is an additive, opt-in runtime API: when no layer is set, -/// [`initialize_plugins`] behaves exactly as before. Language bindings do not -/// expose this function; it is intended for Rust hosts (such as the -/// `nemo-relay` gateway and embedding applications) that overlay code-driven -/// configuration on top of discovered file/CLI configuration. -/// -/// # Parameters -/// - `config`: The code-driven layer to apply, or [`None`] to clear it. -/// -/// # Notes -/// The layer is process-global and is only consulted by [`initialize_plugins`] -/// calls in the same process. +/// The layer is the highest-precedence source: [`initialize_plugins`] merges it on +/// top of the config it is given. This Rust-only API is for embedding hosts and is +/// not exposed to the language bindings. pub fn set_code_driven_plugin_config(config: Option) { let mut guard = PLUGIN_CONFIG_CODE_DRIVEN_LAYER .lock() @@ -928,22 +913,9 @@ pub fn set_code_driven_plugin_config(config: Option) { *guard = config; } -/// Applies the code-driven plugin configuration layer on top of `config`. -/// -/// When a code-driven layer is set via [`set_code_driven_plugin_config`], this -/// returns [`Some`] containing the effective configuration — the layer merged -/// over `config`, with code-driven values winning. When no layer is set, it -/// returns [`None`], meaning `config` is already the effective configuration. -/// -/// This is the single read entry point for the code-driven layer: callers such -/// as [`initialize_plugins`] and `nemo-relay doctor` use it both to obtain the -/// effective configuration and to detect whether a layer contributed. -/// -/// # Parameters -/// - `config`: The file/CLI-resolved configuration to layer over. -/// -/// # Returns -/// [`Some`] effective configuration when a layer is active, otherwise [`None`]. +/// Returns the code-driven layer merged over `config`, or [`None`] when no layer +/// is set (so `config` is already effective). `Some`/`None` also tells callers +/// whether a layer contributed. pub fn apply_code_driven_plugin_config(config: &PluginConfig) -> Option { let guard = PLUGIN_CONFIG_CODE_DRIVEN_LAYER .lock() @@ -953,40 +925,40 @@ pub fn apply_code_driven_plugin_config(config: &PluginConfig) -> Option PluginConfig { let mut components = base.components.clone(); + // Base component positions grouped by kind, so the nth overlay component of a + // kind pairs with the nth base component of that kind rather than the first. + let mut base_slots: HashMap> = HashMap::new(); + for (index, component) in components.iter().enumerate() { + base_slots + .entry(component.kind.clone()) + .or_default() + .push(index); + } + let mut consumed: HashMap = HashMap::new(); for overlay_component in &overlay.components { - match components - .iter_mut() - .find(|candidate| candidate.kind == overlay_component.kind) - { - Some(existing) => { - existing.enabled = overlay_component.enabled; - merge_json_object(&mut existing.config, overlay_component.config.clone()); + let nth = consumed.entry(overlay_component.kind.clone()).or_insert(0); + let slot = base_slots + .get(&overlay_component.kind) + .and_then(|slots| slots.get(*nth)) + .copied(); + *nth += 1; + match slot { + Some(index) => { + components[index].enabled = overlay_component.enabled; + merge_json_object( + &mut components[index].config, + overlay_component.config.clone(), + ); } None => components.push(overlay_component.clone()), } @@ -1047,11 +1019,8 @@ pub fn plugin_config_schema() -> Json { /// Initialization is replace-with-rollback: the previous active configuration /// is removed before the new configuration is activated. /// -/// When a code-driven layer is set via [`set_code_driven_plugin_config`], it is -/// merged on top of `config` first (code-driven values win), and the resulting -/// effective configuration is what gets validated, activated, stored, and -/// reported by [`active_plugin_report`]. When no layer is set, `config` is used -/// unchanged. +/// When a code-driven layer is set, it is merged on top of `config` first and the +/// effective result is what gets validated, activated, and reported. pub async fn initialize_plugins(config: PluginConfig) -> Result { let config = apply_code_driven_plugin_config(&config).unwrap_or(config); let report = validate_plugin_config(&config); diff --git a/crates/core/tests/integration/plugin_layer_tests.rs b/crates/core/tests/integration/plugin_layer_tests.rs index 01751d7d..f7fe01ab 100644 --- a/crates/core/tests/integration/plugin_layer_tests.rs +++ b/crates/core/tests/integration/plugin_layer_tests.rs @@ -1,49 +1,24 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Integration tests for the code-driven plugin configuration layer. -//! -//! These run in a dedicated test binary so mutating the process-global -//! code-driven layer cannot contaminate the shared lib-test binary, which runs -//! other `initialize_plugins` tests concurrently. Within this binary the few -//! global-mutating tests are serialized with a local lock. - -use std::sync::Mutex; +//! Integration test for the code-driven plugin layer's runtime API. Runs in a dedicated test +//! binary so mutating the process-global layer cannot race other tests. Merge correctness is +//! covered by the pure `merge_plugin_config_layers` unit tests. use nemo_relay::plugin::{ - ConfigPolicy, DiagnosticLevel, PluginComponentSpec, PluginConfig, PluginError, - UnsupportedBehavior, apply_code_driven_plugin_config, clear_plugin_configuration, - initialize_plugins, set_code_driven_plugin_config, + PluginComponentSpec, PluginConfig, apply_code_driven_plugin_config, + set_code_driven_plugin_config, }; -static LAYER_LOCK: Mutex<()> = Mutex::new(()); - -// A disabled, unknown-kind component. It is validated (so configuration policy applies to it) but -// skipped during activation (so initialization does not fail on a missing plugin registration). -fn disabled_unknown_component_config() -> PluginConfig { - PluginConfig { - components: vec![PluginComponentSpec { - kind: "relay183.ghost.kind".into(), - enabled: false, - config: serde_json::Map::new(), - }], - ..PluginConfig::default() - } -} - #[test] -fn set_clears_and_applies_code_driven_layer() { - let _guard = LAYER_LOCK.lock().unwrap_or_else(|err| err.into_inner()); +fn apply_reflects_the_code_driven_layer() { set_code_driven_plugin_config(None); - // No layer: apply returns None, so the caller's own config is already effective. assert!(apply_code_driven_plugin_config(&PluginConfig::default()).is_none()); - let layer = PluginConfig { + set_code_driven_plugin_config(Some(PluginConfig { components: vec![PluginComponentSpec::new("relay183.example")], ..PluginConfig::default() - }; - set_code_driven_plugin_config(Some(layer)); - // Layer set: apply returns Some(effective) with the layer's component merged over the base. + })); let effective = apply_code_driven_plugin_config(&PluginConfig::default()).expect("a layer is active"); assert!( @@ -57,49 +32,3 @@ fn set_clears_and_applies_code_driven_layer() { set_code_driven_plugin_config(None); assert!(apply_code_driven_plugin_config(&PluginConfig::default()).is_none()); } - -#[test] -fn initialize_plugins_is_unchanged_without_a_layer() { - let _guard = LAYER_LOCK.lock().unwrap_or_else(|err| err.into_inner()); - set_code_driven_plugin_config(None); - - // Default policy treats an unknown component as a warning, so a disabled unknown component - // validates and initializes without error. This is the file-only baseline. - let report = - futures::executor::block_on(initialize_plugins(disabled_unknown_component_config())) - .expect("file-only initialization succeeds"); - assert!(!report.has_errors()); - assert!( - report.diagnostics.iter().any(|diagnostic| { - diagnostic.code == "plugin.unknown_component" - && diagnostic.level == DiagnosticLevel::Warning - }), - "expected an unknown-component warning: {report:?}" - ); - clear_plugin_configuration().unwrap(); -} - -#[test] -fn code_driven_layer_policy_overrides_file_config_before_validation() { - let _guard = LAYER_LOCK.lock().unwrap_or_else(|err| err.into_inner()); - - // The overlay tightens policy so the same disabled unknown component now fails validation. - // Because the failure occurs at all, the layer must have been merged on top of the caller's - // config *before* validation, and the overlay's policy won over the file default policy. - set_code_driven_plugin_config(Some(PluginConfig { - policy: ConfigPolicy { - unknown_component: UnsupportedBehavior::Error, - ..ConfigPolicy::default() - }, - ..PluginConfig::default() - })); - - let error = - futures::executor::block_on(initialize_plugins(disabled_unknown_component_config())) - .expect_err("overlay policy turns the unknown component into an error"); - assert!(matches!(error, PluginError::InvalidConfig(_)), "{error:?}"); - - // The failing activation must not leave a configuration active. - set_code_driven_plugin_config(None); - let _ = clear_plugin_configuration(); -} diff --git a/crates/core/tests/unit/plugin_tests.rs b/crates/core/tests/unit/plugin_tests.rs index c61c5fa3..e64cfa32 100644 --- a/crates/core/tests/unit/plugin_tests.rs +++ b/crates/core/tests/unit/plugin_tests.rs @@ -419,6 +419,50 @@ fn test_merge_plugin_config_layers_overlay_wins() { )); } +#[test] +fn test_merge_plugin_config_layers_preserves_multi_instance_kinds() { + // A kind used more than once (multi-instance plugins) must not collapse into the first slot. + let base = PluginConfig { + components: vec![PluginComponentSpec { + kind: "multi".into(), + enabled: true, + config: serde_json::from_value(json!({ "n": 0 })).unwrap(), + }], + ..PluginConfig::default() + }; + let overlay = PluginConfig { + components: vec![ + PluginComponentSpec { + kind: "multi".into(), + enabled: true, + config: serde_json::from_value(json!({ "n": 1 })).unwrap(), + }, + PluginComponentSpec { + kind: "multi".into(), + enabled: true, + config: serde_json::from_value(json!({ "tag": "second" })).unwrap(), + }, + ], + ..PluginConfig::default() + }; + + let merged = merge_plugin_config_layers(&base, &overlay); + + // First overlay instance pairs with the base instance; the second is appended, not dropped. + assert_eq!(merged.components.len(), 2); + assert!( + merged + .components + .iter() + .all(|component| component.kind == "multi") + ); + assert_eq!(merged.components[0].config.get("n"), Some(&json!(1))); + assert_eq!( + merged.components[1].config.get("tag"), + Some(&json!("second")) + ); +} + #[test] fn test_config_report_has_errors() { let report = ConfigReport { diff --git a/docs/build-plugins/plugin-configuration-files.mdx b/docs/build-plugins/plugin-configuration-files.mdx index 1a753064..83f69403 100644 --- a/docs/build-plugins/plugin-configuration-files.mdx +++ b/docs/build-plugins/plugin-configuration-files.mdx @@ -226,9 +226,9 @@ honoring operator files for everything the code does not set. The merge uses the same rules as the file precedence above: -- Components are matched by `kind`. A component present in both layers is merged - in place; a component only in the code-driven layer is added; a component only - in the file configuration is preserved. +- Components pair by `kind` in order of appearance, so a kind used more than once + (multi-instance plugins) keeps each instance; a component only in the code-driven + layer is added; a component only in the file configuration is preserved. - A merged component takes `enabled` from the code-driven layer and deep-merges its `config` object: nested tables merge recursively, while arrays and scalar values are replaced by the code-driven value. From c7c6fc298825bf46d5309fdece5c998b955a03b0 Mon Sep 17 00:00:00 2001 From: Zhongxuan Wang Date: Thu, 4 Jun 2026 09:44:30 -0700 Subject: [PATCH 03/15] refactor: drop doctor changes for the code-driven layer nemo-relay doctor runs in its own process and never sets a code-driven layer, so it can never observe one; the layer-aware reporting was unreachable in the shipped CLI (only the unit tests, which set the layer in-process, exercised it). Restore the original file-only collect_observability, remove the two layered doctor tests, and drop the doctor sentence from the docs. Signed-off-by: Zhongxuan Wang --- crates/cli/src/doctor.rs | 96 ++++--------------- crates/cli/tests/coverage/doctor_tests.rs | 96 ------------------- .../plugin-configuration-files.mdx | 4 +- 3 files changed, 18 insertions(+), 178 deletions(-) diff --git a/crates/cli/src/doctor.rs b/crates/cli/src/doctor.rs index b1626cc6..2d93cc94 100644 --- a/crates/cli/src/doctor.rs +++ b/crates/cli/src/doctor.rs @@ -14,9 +14,7 @@ use std::process::Stdio; use std::time::Duration; use nemo_relay::observability::plugin_component::OBSERVABILITY_PLUGIN_KIND; -use nemo_relay::plugin::{ - DiagnosticLevel, PluginConfig, apply_code_driven_plugin_config, validate_plugin_config, -}; +use nemo_relay::plugin::{DiagnosticLevel, PluginConfig, validate_plugin_config}; use nemo_relay_adaptive::plugin_component::register_adaptive_component; use serde::Serialize; use serde_json::Value; @@ -573,74 +571,28 @@ async fn probe_version(binary: &Path) -> Option { } async fn collect_observability(gateway: &GatewayConfig) -> Vec { - // Resolve the code-driven layer (if any) merged over the file/CLI config, so doctor validates - // and reports exactly what the runtime would activate. If the file config fails to parse we - // layer over an empty base here, but the inner function re-parses the raw value and reports the - // parse failure before the overlay is ever used. - let base = gateway - .plugin_config - .as_ref() - .and_then(|value| serde_json::from_value::(value.clone()).ok()) - .unwrap_or_default(); - let effective_overlay = apply_code_driven_plugin_config(&base); - collect_observability_layered(gateway.plugin_config.as_ref(), effective_overlay.as_ref()).await -} - -// Reports plugin/observability checks for the effective plugin configuration. `effective_overlay` is -// `Some` only when a code-driven layer is active, and then carries the layer already merged over the -// file/CLI config — the highest-precedence source, so its values win per-field. When it is `None`, -// this validates the file config alone, producing output identical to file-only behavior. Sub-checks -// (exporter directories/endpoints) run against the effective config so doctor reports what the -// runtime would actually activate, not just the file layer. -async fn collect_observability_layered( - file_value: Option<&Value>, - effective_overlay: Option<&PluginConfig>, -) -> Vec { let mut checks = Vec::new(); - let file_config = match file_value { - Some(plugin_value) => match serde_json::from_value::(plugin_value.clone()) { - Ok(config) => Some(config), - Err(err) => { - checks.push(Check { - name: "Plugins", - status: Status::Fail, - details: format!("invalid plugin config: {err}"), - }); - return checks; - } - }, - None => None, + let Some(plugin_value) = &gateway.plugin_config else { + checks.push(Check { + name: "Plugins", + status: Status::Info, + details: "plugins.toml not configured".into(), + }); + return checks; }; - let layer_active = effective_overlay.is_some(); - let effective_config = match (file_config.as_ref(), effective_overlay) { - (None, None) => { + let plugin_config = match serde_json::from_value::(plugin_value.clone()) { + Ok(config) => config, + Err(err) => { checks.push(Check { name: "Plugins", - status: Status::Info, - details: "plugins.toml not configured".into(), + status: Status::Fail, + details: format!("invalid plugin config: {err}"), }); return checks; } - (Some(file), None) => file.clone(), - (_, Some(overlay)) => overlay.clone(), }; - - // Surface where the effective config came from so conflicts have a clear winner. Only emitted - // when a code-driven layer is active; otherwise the file-only output is left unchanged. - if layer_active { - checks.push(Check { - name: "Plugin config source", - status: Status::Info, - details: if file_config.is_some() { - "code-driven layer active; overrides file/CLI plugin config".into() - } else { - "code-driven layer active; no file/CLI plugin config".into() - }, - }); - } - if let Err(error) = register_adaptive_component() { checks.push(Check { name: "Adaptive plugin", @@ -649,19 +601,12 @@ async fn collect_observability_layered( }); return checks; } - // Prefix diagnostics when a layer is active so the reported failure is attributed to the merged - // effective config rather than any single file/CLI source. - let detail_prefix = if layer_active { - "effective plugin config: " - } else { - "" - }; - let report = validate_plugin_config(&effective_config); + let report = validate_plugin_config(&plugin_config); if report.diagnostics.is_empty() { checks.push(Check { name: "Plugins", status: Status::Pass, - details: format!("{detail_prefix}validation passed"), + details: "validation passed".into(), }); } else { for diagnostic in report.diagnostics { @@ -672,19 +617,12 @@ async fn collect_observability_layered( } else { Status::Warn }, - details: format!("{detail_prefix}{}: {}", diagnostic.code, diagnostic.message), + details: format!("{}: {}", diagnostic.code, diagnostic.message), }); } } - // Run exporter sub-checks against the effective config. When no layer is active this is the - // original file value (byte-identical output); otherwise it is the merged document. - let effective_value = if layer_active { - serde_json::to_value(&effective_config).unwrap_or(Value::Null) - } else { - file_value.cloned().unwrap_or(Value::Null) - }; - if let Some(config) = observability_component_config(&effective_value) { + if let Some(config) = observability_component_config(plugin_value) { collect_observability_component_checks(&mut checks, config).await; } else { checks.push(Check { diff --git a/crates/cli/tests/coverage/doctor_tests.rs b/crates/cli/tests/coverage/doctor_tests.rs index 32406202..6cfcabdd 100644 --- a/crates/cli/tests/coverage/doctor_tests.rs +++ b/crates/cli/tests/coverage/doctor_tests.rs @@ -657,102 +657,6 @@ async fn collect_observability_registers_adaptive_before_validation() { ); } -#[tokio::test] -async fn collect_observability_layered_reports_code_driven_source_and_effective_config() { - let temp = tempfile::tempdir().unwrap(); - let missing = temp.path().join("missing-atif"); - - // File/CLI-resolved config: observability present, ATIF disabled. - let file_value = serde_json::json!({ - "version": 1, - "components": [{ - "kind": "observability", - "enabled": true, - "config": { "version": 1, "atif": { "enabled": false } } - }] - }); - // Effective config the code-driven layer produces over the file config: the layer is the higher - // precedence source and enables ATIF (with a missing output directory), so the effective config - // has ATIF enabled. `apply_code_driven_plugin_config` returns this merged result; doctor's inner - // function consumes it directly. - let effective: PluginConfig = serde_json::from_value(serde_json::json!({ - "version": 1, - "components": [{ - "kind": "observability", - "enabled": true, - "config": { "version": 1, "atif": { "enabled": true, "output_directory": missing } } - }] - })) - .unwrap(); - - let checks = collect_observability_layered(Some(&file_value), Some(&effective)).await; - - // The effective source is surfaced as the winning layer. - let source = checks - .iter() - .find(|check| check.name == "Plugin config source") - .expect("a plugin config source check"); - assert_eq!(source.status, Status::Info); - assert!( - source.details.contains("code-driven layer"), - "{:?}", - source.details - ); - - // Sub-checks run against the effective (merged) config: ATIF, disabled in the file, is enabled - // by the layer, so the ATIF directory check appears and warns about the missing directory. - let atif_check = checks - .iter() - .find(|check| check.name == "ATIF dir") - .expect("ATIF directory check from the effective config"); - assert_eq!(atif_check.status, Status::Warn); - assert!(!missing.exists()); - - // Diagnostics/validation are attributed to the effective config when a layer is active. - assert!( - checks - .iter() - .any(|check| check.details.contains("effective plugin config")), - "expected effective-config attribution: {checks:?}" - ); -} - -#[tokio::test] -async fn collect_observability_layered_without_file_config_uses_the_layer() { - // No file/CLI plugin config, only a code-driven layer: the layer alone is the effective config, - // so doctor reports it as the source and validates it instead of "plugins.toml not configured". - let effective: PluginConfig = serde_json::from_value(serde_json::json!({ - "version": 1, - "components": [{ "kind": "observability", "enabled": true, "config": { "version": 1 } }] - })) - .unwrap(); - - let checks = collect_observability_layered(None, Some(&effective)).await; - - assert!( - !checks - .iter() - .any(|check| check.details == "plugins.toml not configured"), - "layer-only config must not report 'not configured': {checks:?}" - ); - let source = checks - .iter() - .find(|check| check.name == "Plugin config source") - .expect("a plugin config source check"); - assert!( - source.details.contains("no file/CLI plugin config"), - "{:?}", - source.details - ); - assert!( - checks - .iter() - .any(|check| check.name == "Plugins" - && check.details.contains("effective plugin config")), - "expected effective-config validation: {checks:?}" - ); -} - #[test] fn format_agents_human_lists_supported_and_separates_detected() { let agents = vec![ diff --git a/docs/build-plugins/plugin-configuration-files.mdx b/docs/build-plugins/plugin-configuration-files.mdx index 83f69403..313e3819 100644 --- a/docs/build-plugins/plugin-configuration-files.mdx +++ b/docs/build-plugins/plugin-configuration-files.mdx @@ -266,9 +266,7 @@ The gateway fails fast, naming the sources, in these conflict cases: The code-driven layer is process-local: it only affects `initialize_plugins` calls in the same process. It is a Rust runtime API and is not exposed to the Python, Node.js, or other language bindings, and it is not a file source, so the -file discovery and conflict rules above are unchanged. `nemo-relay doctor` -reports the effective configuration source and validates the merged result when a -code-driven layer is active in the doctor process. +file discovery and conflict rules above are unchanged. ## Explicit Defaults And Overrides From 0d500ae11464598526e50bf51cfba411bf5c829d Mon Sep 17 00:00:00 2001 From: Zhongxuan Wang Date: Thu, 4 Jun 2026 10:06:52 -0700 Subject: [PATCH 04/15] refactor: share plugin config merge via public merge_plugin_config Add a single public core primitive, merge_plugin_config(&Value, &Value) -> Value: objects merge recursively, arrays and scalars are replaced by the overlay, and the top-level components array pairs entries by kind in order of appearance (so multi-instance kinds are not collapsed). The CLI's plugins.toml file-layer merging now calls it instead of the duplicate merge_plugin_toml/merge_plugin_components (merge_toml is kept for config.toml). This replaces the process-global code-driven layer (set/apply plus the auto-apply in initialize_plugins) with a caller-driven primitive; initialize_plugins is unchanged and the merge holds no global state. Tests and docs updated; the layer integration test is removed. Signed-off-by: Zhongxuan Wang --- crates/cli/src/config.rs | 66 ++----- crates/core/Cargo.toml | 4 - crates/core/src/plugin.rs | 137 +++++++------- .../tests/integration/plugin_layer_tests.rs | 34 ---- crates/core/tests/unit/plugin_tests.rs | 179 +++++++----------- .../plugin-configuration-files.mdx | 89 ++++----- 6 files changed, 181 insertions(+), 328 deletions(-) delete mode 100644 crates/core/tests/integration/plugin_layer_tests.rs diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index 2307065f..6c5c5fac 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -7,6 +7,7 @@ use std::path::{Path, PathBuf}; use axum::http::HeaderMap; use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum}; +use nemo_relay::plugin::merge_plugin_config; use serde::Deserialize; use serde_json::Value; @@ -744,7 +745,7 @@ fn load_plugin_toml_config_from_paths(paths: I) -> Result, { - let mut merged = toml::Value::Table(toml::map::Map::new()); + let mut merged = Value::Object(serde_json::Map::new()); let mut sources = Vec::new(); for path in paths { if path.exists() { @@ -759,16 +760,25 @@ where )) })?; validate_plugin_toml_component_kinds(&path, &parsed)?; - merge_plugin_toml(&mut merged, parsed); + // Merge file layers with the shared core primitive so file and code-driven layering + // stay in sync. Each file is converted to the canonical JSON document first. + let document = serde_json::to_value(parsed).map_err(|error| { + CliError::Config(format!( + "invalid plugin TOML shape in {}: {error}", + path.display() + )) + })?; + merged = merge_plugin_config(&merged, &document); sources.push(path); } } if sources.is_empty() { return Ok(None); } - let value = serde_json::to_value(merged) - .map_err(|error| CliError::Config(format!("invalid plugin TOML shape: {error}")))?; - Ok(Some(PluginTomlConfig { value, sources })) + Ok(Some(PluginTomlConfig { + value: merged, + sources, + })) } fn apply_plugin_toml_config( @@ -859,52 +869,6 @@ fn merge_toml(left: &mut toml::Value, right: toml::Value) { } } -// Plugin TOML uses normal recursive TOML merging except for the top-level components array. Each -// component is keyed by `kind`, so project/user plugins.toml files can add distinct plugin kinds or -// override one plugin kind without restating every other component. -fn merge_plugin_toml(left: &mut toml::Value, right: toml::Value) { - match (left, right) { - (toml::Value::Table(left), toml::Value::Table(right)) => { - for (key, value) in right { - match (key.as_str(), left.get_mut(&key)) { - ("components", Some(existing)) => merge_plugin_components(existing, value), - (_, Some(existing)) => merge_toml(existing, value), - _ => { - left.insert(key, value); - } - } - } - } - (left, right) => *left = right, - } -} - -fn merge_plugin_components(left: &mut toml::Value, right: toml::Value) { - let toml::Value::Array(left_components) = left else { - *left = right; - return; - }; - let toml::Value::Array(right_components) = right else { - *left = right; - return; - }; - - for component in right_components { - let Some(kind) = component_kind(&component).map(str::to_owned) else { - left_components.push(component); - continue; - }; - if let Some(existing) = left_components - .iter_mut() - .find(|candidate| component_kind(candidate) == Some(kind.as_str())) - { - merge_toml(existing, component); - } else { - left_components.push(component); - } - } -} - fn component_kind(component: &toml::Value) -> Option<&str> { component .as_table() diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 4d8154ff..e205b83c 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -118,10 +118,6 @@ path = "tests/integration/subscriber_dispatcher_tests.rs" name = "api_surface_integration" path = "tests/integration/api_surface_tests.rs" -[[test]] -name = "plugin_layer_integration" -path = "tests/integration/plugin_layer_tests.rs" - [[test]] name = "atif_storage_integration" path = "tests/integration/atif_storage_tests.rs" diff --git a/crates/core/src/plugin.rs b/crates/core/src/plugin.rs index b44610a3..a5f1ab10 100644 --- a/crates/core/src/plugin.rs +++ b/crates/core/src/plugin.rs @@ -44,8 +44,6 @@ type PluginMap = HashMap>; static PLUGIN_HANDLERS: LazyLock> = LazyLock::new(|| RwLock::new(HashMap::new())); static ACTIVE_PLUGIN_CONFIGURATION: LazyLock>> = LazyLock::new(|| Mutex::new(None)); -static PLUGIN_CONFIG_CODE_DRIVEN_LAYER: LazyLock>> = - LazyLock::new(|| Mutex::new(None)); static BUILTIN_PLUGIN_REGISTRATION: OnceLock> = OnceLock::new(); /// Error type for generic plugin operations. @@ -901,94 +899,91 @@ pub fn validate_plugin_config(config: &PluginConfig) -> ConfigReport { report } -/// Sets (or clears with `None`) the process-global code-driven plugin layer. +/// Merges `overlay` (higher precedence) over `base`, returning the effective plugin +/// configuration document. /// -/// The layer is the highest-precedence source: [`initialize_plugins`] merges it on -/// top of the config it is given. This Rust-only API is for embedding hosts and is -/// not exposed to the language bindings. -pub fn set_code_driven_plugin_config(config: Option) { - let mut guard = PLUGIN_CONFIG_CODE_DRIVEN_LAYER - .lock() - .unwrap_or_else(|err| err.into_inner()); - *guard = config; -} - -/// Returns the code-driven layer merged over `config`, or [`None`] when no layer -/// is set (so `config` is already effective). `Some`/`None` also tells callers -/// whether a layer contributed. -pub fn apply_code_driven_plugin_config(config: &PluginConfig) -> Option { - let guard = PLUGIN_CONFIG_CODE_DRIVEN_LAYER - .lock() - .unwrap_or_else(|err| err.into_inner()); - guard - .as_ref() - .map(|layer| merge_plugin_config_layers(config, layer)) +/// Both arguments are canonical plugin config documents (the JSON shape of +/// [`PluginConfig`]). Objects merge recursively and arrays/scalars are replaced by +/// `overlay`, except the top-level `components` array, whose entries pair by `kind` +/// in order of appearance — so a kind used by a multi-instance plugin keeps each +/// instance instead of collapsing into the first. This is the shared layering +/// primitive used by the gateway's file-layer discovery and by hosts composing a +/// configuration in code before calling [`initialize_plugins`]. +pub fn merge_plugin_config(base: &Json, overlay: &Json) -> Json { + let (Json::Object(base_object), Json::Object(overlay_object)) = (base, overlay) else { + return overlay.clone(); + }; + let mut merged = base_object.clone(); + for (key, overlay_value) in overlay_object { + let merged_value = match (key.as_str(), merged.get(key)) { + ("components", Some(base_components)) => { + merge_plugin_components(base_components, overlay_value) + } + (_, Some(base_value)) => merge_json_value(base_value, overlay_value), + (_, None) => overlay_value.clone(), + }; + merged.insert(key.clone(), merged_value); + } + Json::Object(merged) } -/// Merges `overlay` (higher precedence) over `base`. +/// Pairs `overlay` components with `base` components by `kind` in order of appearance. /// -/// Components pair by `kind` in order of appearance, so a kind used by a -/// multi-instance plugin keeps each instance instead of collapsing into the -/// first. A paired component takes `overlay`'s `enabled` and deep-merges its -/// `config` (nested objects merge; arrays and scalars are replaced); extra -/// `overlay` components are appended. Top-level `version`/`policy` come from -/// `overlay`. -fn merge_plugin_config_layers(base: &PluginConfig, overlay: &PluginConfig) -> PluginConfig { - let mut components = base.components.clone(); - // Base component positions grouped by kind, so the nth overlay component of a - // kind pairs with the nth base component of that kind rather than the first. +/// The nth `overlay` component of a kind merges into the nth `base` component of that +/// kind, so multi-instance kinds are not collapsed; unpaired `overlay` components are +/// appended and `base`-only components are preserved. Non-array values are replaced. +fn merge_plugin_components(base: &Json, overlay: &Json) -> Json { + let (Json::Array(base_components), Json::Array(overlay_components)) = (base, overlay) else { + return overlay.clone(); + }; + let mut components = base_components.clone(); let mut base_slots: HashMap> = HashMap::new(); for (index, component) in components.iter().enumerate() { - base_slots - .entry(component.kind.clone()) - .or_default() - .push(index); + if let Some(kind) = component_kind(component) { + base_slots.entry(kind.to_string()).or_default().push(index); + } } let mut consumed: HashMap = HashMap::new(); - for overlay_component in &overlay.components { - let nth = consumed.entry(overlay_component.kind.clone()).or_insert(0); + for overlay_component in overlay_components { + let Some(kind) = component_kind(overlay_component) else { + components.push(overlay_component.clone()); + continue; + }; + let nth = consumed.entry(kind.to_string()).or_insert(0); let slot = base_slots - .get(&overlay_component.kind) + .get(kind) .and_then(|slots| slots.get(*nth)) .copied(); *nth += 1; match slot { Some(index) => { - components[index].enabled = overlay_component.enabled; - merge_json_object( - &mut components[index].config, - overlay_component.config.clone(), - ); + components[index] = merge_json_value(&components[index], overlay_component); } None => components.push(overlay_component.clone()), } } - PluginConfig { - version: overlay.version, - components, - policy: overlay.policy, - } + Json::Array(components) } -/// Recursively merges `overlay` entries into a `base` JSON object. -/// -/// Nested objects merge key-by-key; arrays and scalar values are replaced by -/// the `overlay` value. This mirrors the gateway's recursive TOML table merge. -fn merge_json_object(base: &mut Map, overlay: Map) { - for (key, overlay_value) in overlay { - match overlay_value { - Json::Object(overlay_object) => { - if let Some(Json::Object(base_object)) = base.get_mut(&key) { - merge_json_object(base_object, overlay_object); - } else { - base.insert(key, Json::Object(overlay_object)); - } - } - other => { - base.insert(key, other); - } - } +/// Recursively merges two JSON values: objects merge key-by-key, while arrays and +/// scalars are replaced by `overlay`. Mirrors the gateway's recursive TOML table merge. +fn merge_json_value(base: &Json, overlay: &Json) -> Json { + let (Json::Object(base_object), Json::Object(overlay_object)) = (base, overlay) else { + return overlay.clone(); + }; + let mut merged = base_object.clone(); + for (key, overlay_value) in overlay_object { + let merged_value = match merged.get(key) { + Some(base_value) => merge_json_value(base_value, overlay_value), + None => overlay_value.clone(), + }; + merged.insert(key.clone(), merged_value); } + Json::Object(merged) +} + +fn component_kind(component: &Json) -> Option<&str> { + component.get("kind").and_then(Json::as_str) } /// Returns the JSON Schema for the canonical plugin configuration document. @@ -1018,11 +1013,7 @@ pub fn plugin_config_schema() -> Json { /// # Notes /// Initialization is replace-with-rollback: the previous active configuration /// is removed before the new configuration is activated. -/// -/// When a code-driven layer is set, it is merged on top of `config` first and the -/// effective result is what gets validated, activated, and reported. pub async fn initialize_plugins(config: PluginConfig) -> Result { - let config = apply_code_driven_plugin_config(&config).unwrap_or(config); let report = validate_plugin_config(&config); if report.has_errors() { return Err(PluginError::InvalidConfig(join_error_messages(&report))); diff --git a/crates/core/tests/integration/plugin_layer_tests.rs b/crates/core/tests/integration/plugin_layer_tests.rs deleted file mode 100644 index f7fe01ab..00000000 --- a/crates/core/tests/integration/plugin_layer_tests.rs +++ /dev/null @@ -1,34 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Integration test for the code-driven plugin layer's runtime API. Runs in a dedicated test -//! binary so mutating the process-global layer cannot race other tests. Merge correctness is -//! covered by the pure `merge_plugin_config_layers` unit tests. - -use nemo_relay::plugin::{ - PluginComponentSpec, PluginConfig, apply_code_driven_plugin_config, - set_code_driven_plugin_config, -}; - -#[test] -fn apply_reflects_the_code_driven_layer() { - set_code_driven_plugin_config(None); - assert!(apply_code_driven_plugin_config(&PluginConfig::default()).is_none()); - - set_code_driven_plugin_config(Some(PluginConfig { - components: vec![PluginComponentSpec::new("relay183.example")], - ..PluginConfig::default() - })); - let effective = - apply_code_driven_plugin_config(&PluginConfig::default()).expect("a layer is active"); - assert!( - effective - .components - .iter() - .any(|component| component.kind == "relay183.example"), - "effective config should include the code-driven component: {effective:?}" - ); - - set_code_driven_plugin_config(None); - assert!(apply_code_driven_plugin_config(&PluginConfig::default()).is_none()); -} diff --git a/crates/core/tests/unit/plugin_tests.rs b/crates/core/tests/unit/plugin_tests.rs index e64cfa32..50767671 100644 --- a/crates/core/tests/unit/plugin_tests.rs +++ b/crates/core/tests/unit/plugin_tests.rs @@ -299,7 +299,6 @@ fn reset_global() { let mut state = ctx.write().unwrap(); *state = NemoRelayContextState::new(); clear_plugin_configuration().unwrap(); - set_code_driven_plugin_config(None); recorded_names().lock().unwrap().clear(); PARTIAL_FAIL_ROLLBACKS.store(0, Ordering::SeqCst); RESTORE_FAIL_REGISTRATIONS.store(0, Ordering::SeqCst); @@ -316,151 +315,105 @@ fn reset_global() { } #[test] -fn test_merge_plugin_config_layers_overlay_wins() { - // The overlay is the highest-precedence layer: it overrides shared component fields, - // deep-merges nested config objects, replaces arrays, appends overlay-only kinds, preserves - // base-only kinds, and supplies the effective top-level version/policy. - let base = PluginConfig { - version: 1, - components: vec![ - PluginComponentSpec { - kind: "alpha".into(), - enabled: true, - config: serde_json::from_value(json!({ - "keep": "base", - "override": "base", - "nested": {"a": 1, "b": 2}, - "list": [1, 2, 3] - })) - .unwrap(), - }, - PluginComponentSpec { - kind: "base_only".into(), - enabled: true, - config: Map::new(), +fn test_merge_plugin_config_overlay_wins() { + // The overlay is the higher-precedence layer: it overrides shared component fields, deep-merges + // nested config objects, replaces arrays, appends overlay-only kinds, preserves base-only kinds, + // replaces top-level scalars, and recursively merges top-level objects (policy). + let base = json!({ + "version": 1, + "components": [ + { + "kind": "alpha", + "enabled": true, + "config": { "keep": "base", "override": "base", "nested": {"a": 1, "b": 2}, "list": [1, 2, 3] } }, + { "kind": "base_only", "enabled": true, "config": {} } ], - policy: ConfigPolicy::default(), - }; - let overlay = PluginConfig { - version: 1, - components: vec![ - PluginComponentSpec { - kind: "alpha".into(), - enabled: false, - config: serde_json::from_value(json!({ - "override": "overlay", - "added": true, - "nested": {"b": 20, "c": 30}, - "list": [9] - })) - .unwrap(), - }, - PluginComponentSpec { - kind: "overlay_only".into(), - enabled: true, - config: Map::new(), + "policy": { "unknown_component": "warn", "unknown_field": "warn" } + }); + let overlay = json!({ + "version": 2, + "components": [ + { + "kind": "alpha", + "enabled": false, + "config": { "override": "overlay", "added": true, "nested": {"b": 20, "c": 30}, "list": [9] } }, + { "kind": "overlay_only", "enabled": true, "config": {} } ], - policy: ConfigPolicy { - unknown_component: UnsupportedBehavior::Error, - unknown_field: UnsupportedBehavior::Error, - unsupported_value: UnsupportedBehavior::Error, - }, - }; + "policy": { "unknown_component": "error" } + }); - let merged = merge_plugin_config_layers(&base, &overlay); + let merged = merge_plugin_config(&base, &overlay); + let components = merged["components"].as_array().unwrap(); // Ordering: base components first (in base order), then overlay-only components appended. - let kinds: Vec<&str> = merged - .components + let kinds: Vec<&str> = components .iter() - .map(|component| component.kind.as_str()) + .map(|component| component["kind"].as_str().unwrap()) .collect(); - assert_eq!(kinds, vec!["alpha", "base_only", "overlay_only"]); + assert_eq!(kinds, ["alpha", "base_only", "overlay_only"]); - let alpha = &merged.components[0]; - assert!(!alpha.enabled, "overlay enabled wins on a shared component"); + let alpha = &components[0]; + assert_eq!(alpha["enabled"], json!(false), "overlay enabled wins"); assert_eq!( - alpha.config.get("keep"), - Some(&json!("base")), - "base-only key is preserved" + alpha["config"]["keep"], + json!("base"), + "base-only key preserved" ); assert_eq!( - alpha.config.get("override"), - Some(&json!("overlay")), + alpha["config"]["override"], + json!("overlay"), "overlay scalar wins" ); + assert_eq!(alpha["config"]["added"], json!(true), "overlay key added"); assert_eq!( - alpha.config.get("added"), - Some(&json!(true)), - "overlay key is added" - ); - assert_eq!( - alpha.config.get("nested"), - Some(&json!({"a": 1, "b": 20, "c": 30})), + alpha["config"]["nested"], + json!({"a": 1, "b": 20, "c": 30}), "nested objects merge recursively" ); assert_eq!( - alpha.config.get("list"), - Some(&json!([9])), + alpha["config"]["list"], + json!([9]), "arrays are replaced, not merged" ); - // Base-only component is preserved unchanged. - assert_eq!(merged.components[1].kind, "base_only"); - assert!(merged.components[1].enabled); + // Base-only component is preserved. + assert_eq!(components[1]["kind"], json!("base_only")); - // Top-level version and policy come from the overlay. - assert_eq!(merged.version, 1); - assert!(matches!( - merged.policy.unknown_component, - UnsupportedBehavior::Error - )); + // Top-level scalars are replaced by the overlay; objects (policy) merge recursively. + assert_eq!(merged["version"], json!(2)); + assert_eq!(merged["policy"]["unknown_component"], json!("error")); + assert_eq!( + merged["policy"]["unknown_field"], + json!("warn"), + "base-only policy field preserved" + ); } #[test] -fn test_merge_plugin_config_layers_preserves_multi_instance_kinds() { +fn test_merge_plugin_config_preserves_multi_instance_kinds() { // A kind used more than once (multi-instance plugins) must not collapse into the first slot. - let base = PluginConfig { - components: vec![PluginComponentSpec { - kind: "multi".into(), - enabled: true, - config: serde_json::from_value(json!({ "n": 0 })).unwrap(), - }], - ..PluginConfig::default() - }; - let overlay = PluginConfig { - components: vec![ - PluginComponentSpec { - kind: "multi".into(), - enabled: true, - config: serde_json::from_value(json!({ "n": 1 })).unwrap(), - }, - PluginComponentSpec { - kind: "multi".into(), - enabled: true, - config: serde_json::from_value(json!({ "tag": "second" })).unwrap(), - }, - ], - ..PluginConfig::default() - }; + let base = json!({ "components": [ { "kind": "multi", "config": { "n": 0 } } ] }); + let overlay = json!({ + "components": [ + { "kind": "multi", "config": { "n": 1 } }, + { "kind": "multi", "config": { "tag": "second" } } + ] + }); - let merged = merge_plugin_config_layers(&base, &overlay); + let merged = merge_plugin_config(&base, &overlay); + let components = merged["components"].as_array().unwrap(); // First overlay instance pairs with the base instance; the second is appended, not dropped. - assert_eq!(merged.components.len(), 2); + assert_eq!(components.len(), 2); assert!( - merged - .components + components .iter() - .all(|component| component.kind == "multi") - ); - assert_eq!(merged.components[0].config.get("n"), Some(&json!(1))); - assert_eq!( - merged.components[1].config.get("tag"), - Some(&json!("second")) + .all(|component| component["kind"] == json!("multi")) ); + assert_eq!(components[0]["config"]["n"], json!(1)); + assert_eq!(components[1]["config"]["tag"], json!("second")); } #[test] diff --git a/docs/build-plugins/plugin-configuration-files.mdx b/docs/build-plugins/plugin-configuration-files.mdx index 313e3819..99366636 100644 --- a/docs/build-plugins/plugin-configuration-files.mdx +++ b/docs/build-plugins/plugin-configuration-files.mdx @@ -85,11 +85,10 @@ Use only one file/CLI source class for a given gateway run. The gateway fails clearly if file-based plugin config and `--plugin-config` are both present, or if `plugins.toml` and `[plugins].config` are both present. -A code-driven layer is a separate, programmatic source set through the runtime -API. It is not a file and is not mutually exclusive with the file/CLI sources: -when set, it is merged on top of the resolved file/CLI configuration as the -highest-precedence layer. See -[Code-Driven Configuration Layer](#code-driven-configuration-layer). +Configuration can also be composed in code: the same merge the gateway uses for +file layers is exposed as the `merge_plugin_config` runtime API, so a Rust host +can layer a programmatic document over a file-resolved one before activating it. +See [Composing Configuration In Code](#composing-configuration-in-code). When `--config path/to/config.toml` is supplied, plugin file discovery is scoped to `path/to/plugins.toml`. Implicit system, project, and user plugin files are @@ -164,9 +163,7 @@ menu to reset, clear, preview, or save. When more than one `plugins.toml` file is discovered, later files have higher precedence. User config overrides project config, and project config overrides -system config. A code-driven layer, when set, sits above all of these as the -highest-precedence source (see -[Code-Driven Configuration Layer](#code-driven-configuration-layer)). +system config. TOML tables merge recursively: @@ -206,39 +203,10 @@ components that reach plugin validation also fail validation. Arrays inside component config are replaced by the higher-precedence value. Tables inside component config merge recursively. -## Code-Driven Configuration Layer +## Source Precedence And Conflicts -A host application can supply a *code-driven* plugin configuration layer -programmatically through the runtime API instead of through files. In Rust: - -```rust -use nemo_relay::plugin::{set_code_driven_plugin_config, PluginConfig}; - -set_code_driven_plugin_config(Some(my_plugin_config)); -// Pass `None` to clear the layer and restore file-only behavior. -``` - -The code-driven layer is the **highest-precedence** plugin configuration source. -When set, `initialize_plugins` merges it on top of the configuration it is given -(the resolved file/CLI configuration), so code-driven values win field-by-field. -This lets an application apply programmatic overrides in code while still -honoring operator files for everything the code does not set. - -The merge uses the same rules as the file precedence above: - -- Components pair by `kind` in order of appearance, so a kind used more than once - (multi-instance plugins) keeps each instance; a component only in the code-driven - layer is added; a component only in the file configuration is preserved. -- A merged component takes `enabled` from the code-driven layer and deep-merges - its `config` object: nested tables merge recursively, while arrays and scalar - values are replaced by the code-driven value. -- Top-level `version` and `policy` are taken from the code-driven layer. A typed - `PluginConfig` always carries these fields, so set them on the layer to match - the values you want in effect. - -### Precedence And Conflicts - -The effective configuration is composed from lowest to highest precedence: +A gateway run uses exactly one file/CLI source class. The effective configuration +is composed from lowest to highest precedence: 1. Discovered `plugins.toml` files, merged in order: system (`/etc/nemo-relay/plugins.toml`) is lowest, then project (nearest @@ -246,27 +214,42 @@ The effective configuration is composed from lowest to highest precedence: (`$XDG_CONFIG_HOME/nemo-relay/plugins.toml`) is highest 2. **or** a single inline `[plugins].config` block in `config.toml` 3. **or** `--plugin-config ''` -4. Code-driven configuration layer (highest) -Items 1–3 are the **file/CLI source classes** and are mutually exclusive: exactly -one of them supplies the file/CLI configuration for a run, and the `plugins.toml` -sub-layers (system, project, user) only merge with each other. The code-driven -layer (item 4) is not a file/CLI source; when set it is always merged on top of -whichever file/CLI source is active, so its values win. - -The gateway fails fast, naming the sources, in these conflict cases: +These source classes are mutually exclusive, and the `plugins.toml` sub-layers +(system, project, user) only merge with each other. The gateway fails fast, +naming the sources, in these conflict cases: | Situation | Result | |---|---| | `--plugin-config` and any file config (`plugins.toml` or `[plugins].config`) | Error: choose one source | | `plugins.toml` and `[plugins].config` both present | Error: choose one source | | `[plugins].config` defined in more than one `config.toml` file | Error: consolidate into one block | -| Code-driven layer set together with any file/CLI source | No conflict — the layer overrides the file/CLI config field-by-field | -The code-driven layer is process-local: it only affects `initialize_plugins` -calls in the same process. It is a Rust runtime API and is not exposed to the -Python, Node.js, or other language bindings, and it is not a file source, so the -file discovery and conflict rules above are unchanged. +## Composing Configuration In Code + +The merge the gateway uses to layer files is exposed as a Rust runtime API, +`merge_plugin_config`, so a host can compose a programmatic document over a +file-resolved one before activating it: + +```rust +use nemo_relay::plugin::{initialize_plugins, merge_plugin_config}; + +// `base` and `overlay` are plugin config documents (the JSON shape of `PluginConfig`); +// `overlay` is the higher-precedence layer. +let effective = merge_plugin_config(&base, &overlay); +initialize_plugins(serde_json::from_value(effective)?).await?; +``` + +The merge is the same one applied to file layers: + +- Objects merge recursively; arrays and scalar values are replaced by `overlay`. +- The top-level `components` array merges by `kind` in order of appearance, so a + kind used by a multi-instance plugin keeps each instance instead of collapsing; + components present in only one side are kept. + +`merge_plugin_config` is Rust-only (not exposed to the Python, Node.js, or other +bindings) and holds no global state — the caller controls precedence by argument +order, and `initialize_plugins` activates whatever document it is given. ## Explicit Defaults And Overrides From ddab3a24360af112660de490071901dc6ed09d5c Mon Sep 17 00:00:00 2001 From: Zhongxuan Wang Date: Thu, 4 Jun 2026 10:58:47 -0700 Subject: [PATCH 05/15] refactor: rename plugin merge to layer_config and trim doc comments Rename the public merge primitive merge_plugin_config -> layer_config (core, the CLI call site, tests, and docs), rename the docs section to "Configuration Layering", and trim the over-verbose doc comments on the merge helpers. Signed-off-by: Zhongxuan Wang --- crates/cli/src/config.rs | 7 +++--- crates/core/src/plugin.rs | 24 ++++++------------- crates/core/tests/unit/plugin_tests.rs | 8 +++---- .../plugin-configuration-files.mdx | 14 +++++------ 4 files changed, 21 insertions(+), 32 deletions(-) diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index 6c5c5fac..b3119d17 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -7,7 +7,7 @@ use std::path::{Path, PathBuf}; use axum::http::HeaderMap; use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum}; -use nemo_relay::plugin::merge_plugin_config; +use nemo_relay::plugin::layer_config; use serde::Deserialize; use serde_json::Value; @@ -760,15 +760,14 @@ where )) })?; validate_plugin_toml_component_kinds(&path, &parsed)?; - // Merge file layers with the shared core primitive so file and code-driven layering - // stay in sync. Each file is converted to the canonical JSON document first. + // Merge file layers with the shared core primitive (each file converted to JSON first). let document = serde_json::to_value(parsed).map_err(|error| { CliError::Config(format!( "invalid plugin TOML shape in {}: {error}", path.display() )) })?; - merged = merge_plugin_config(&merged, &document); + merged = layer_config(&merged, &document); sources.push(path); } } diff --git a/crates/core/src/plugin.rs b/crates/core/src/plugin.rs index a5f1ab10..faae5e53 100644 --- a/crates/core/src/plugin.rs +++ b/crates/core/src/plugin.rs @@ -899,17 +899,12 @@ pub fn validate_plugin_config(config: &PluginConfig) -> ConfigReport { report } -/// Merges `overlay` (higher precedence) over `base`, returning the effective plugin -/// configuration document. +/// Merges `overlay` (higher precedence) over `base` into the effective config document. /// -/// Both arguments are canonical plugin config documents (the JSON shape of -/// [`PluginConfig`]). Objects merge recursively and arrays/scalars are replaced by -/// `overlay`, except the top-level `components` array, whose entries pair by `kind` -/// in order of appearance — so a kind used by a multi-instance plugin keeps each -/// instance instead of collapsing into the first. This is the shared layering -/// primitive used by the gateway's file-layer discovery and by hosts composing a -/// configuration in code before calling [`initialize_plugins`]. -pub fn merge_plugin_config(base: &Json, overlay: &Json) -> Json { +/// Objects merge recursively and arrays/scalars are replaced by `overlay`, except the +/// top-level `components` array, whose entries pair by `kind` in order of appearance so +/// multi-instance kinds are not collapsed. Shared by the gateway's file-layer merge. +pub fn layer_config(base: &Json, overlay: &Json) -> Json { let (Json::Object(base_object), Json::Object(overlay_object)) = (base, overlay) else { return overlay.clone(); }; @@ -927,11 +922,7 @@ pub fn merge_plugin_config(base: &Json, overlay: &Json) -> Json { Json::Object(merged) } -/// Pairs `overlay` components with `base` components by `kind` in order of appearance. -/// -/// The nth `overlay` component of a kind merges into the nth `base` component of that -/// kind, so multi-instance kinds are not collapsed; unpaired `overlay` components are -/// appended and `base`-only components are preserved. Non-array values are replaced. +/// Merges `overlay` components into `base` by `kind`, pairing repeated kinds positionally. fn merge_plugin_components(base: &Json, overlay: &Json) -> Json { let (Json::Array(base_components), Json::Array(overlay_components)) = (base, overlay) else { return overlay.clone(); @@ -965,8 +956,7 @@ fn merge_plugin_components(base: &Json, overlay: &Json) -> Json { Json::Array(components) } -/// Recursively merges two JSON values: objects merge key-by-key, while arrays and -/// scalars are replaced by `overlay`. Mirrors the gateway's recursive TOML table merge. +/// Recursively merges JSON objects; arrays and scalars are replaced by `overlay`. fn merge_json_value(base: &Json, overlay: &Json) -> Json { let (Json::Object(base_object), Json::Object(overlay_object)) = (base, overlay) else { return overlay.clone(); diff --git a/crates/core/tests/unit/plugin_tests.rs b/crates/core/tests/unit/plugin_tests.rs index 50767671..36981a5e 100644 --- a/crates/core/tests/unit/plugin_tests.rs +++ b/crates/core/tests/unit/plugin_tests.rs @@ -315,7 +315,7 @@ fn reset_global() { } #[test] -fn test_merge_plugin_config_overlay_wins() { +fn test_layer_config_overlay_wins() { // The overlay is the higher-precedence layer: it overrides shared component fields, deep-merges // nested config objects, replaces arrays, appends overlay-only kinds, preserves base-only kinds, // replaces top-level scalars, and recursively merges top-level objects (policy). @@ -344,7 +344,7 @@ fn test_merge_plugin_config_overlay_wins() { "policy": { "unknown_component": "error" } }); - let merged = merge_plugin_config(&base, &overlay); + let merged = layer_config(&base, &overlay); let components = merged["components"].as_array().unwrap(); // Ordering: base components first (in base order), then overlay-only components appended. @@ -392,7 +392,7 @@ fn test_merge_plugin_config_overlay_wins() { } #[test] -fn test_merge_plugin_config_preserves_multi_instance_kinds() { +fn test_layer_config_preserves_multi_instance_kinds() { // A kind used more than once (multi-instance plugins) must not collapse into the first slot. let base = json!({ "components": [ { "kind": "multi", "config": { "n": 0 } } ] }); let overlay = json!({ @@ -402,7 +402,7 @@ fn test_merge_plugin_config_preserves_multi_instance_kinds() { ] }); - let merged = merge_plugin_config(&base, &overlay); + let merged = layer_config(&base, &overlay); let components = merged["components"].as_array().unwrap(); // First overlay instance pairs with the base instance; the second is appended, not dropped. diff --git a/docs/build-plugins/plugin-configuration-files.mdx b/docs/build-plugins/plugin-configuration-files.mdx index 99366636..c749e5a5 100644 --- a/docs/build-plugins/plugin-configuration-files.mdx +++ b/docs/build-plugins/plugin-configuration-files.mdx @@ -86,9 +86,9 @@ clearly if file-based plugin config and `--plugin-config` are both present, or i `plugins.toml` and `[plugins].config` are both present. Configuration can also be composed in code: the same merge the gateway uses for -file layers is exposed as the `merge_plugin_config` runtime API, so a Rust host +file layers is exposed as the `layer_config` runtime API, so a Rust host can layer a programmatic document over a file-resolved one before activating it. -See [Composing Configuration In Code](#composing-configuration-in-code). +See [Configuration Layering](#configuration-layering). When `--config path/to/config.toml` is supplied, plugin file discovery is scoped to `path/to/plugins.toml`. Implicit system, project, and user plugin files are @@ -225,18 +225,18 @@ naming the sources, in these conflict cases: | `plugins.toml` and `[plugins].config` both present | Error: choose one source | | `[plugins].config` defined in more than one `config.toml` file | Error: consolidate into one block | -## Composing Configuration In Code +## Configuration Layering The merge the gateway uses to layer files is exposed as a Rust runtime API, -`merge_plugin_config`, so a host can compose a programmatic document over a +`layer_config`, so a host can compose a programmatic document over a file-resolved one before activating it: ```rust -use nemo_relay::plugin::{initialize_plugins, merge_plugin_config}; +use nemo_relay::plugin::{initialize_plugins, layer_config}; // `base` and `overlay` are plugin config documents (the JSON shape of `PluginConfig`); // `overlay` is the higher-precedence layer. -let effective = merge_plugin_config(&base, &overlay); +let effective = layer_config(&base, &overlay); initialize_plugins(serde_json::from_value(effective)?).await?; ``` @@ -247,7 +247,7 @@ The merge is the same one applied to file layers: kind used by a multi-instance plugin keeps each instance instead of collapsing; components present in only one side are kept. -`merge_plugin_config` is Rust-only (not exposed to the Python, Node.js, or other +`layer_config` is Rust-only (not exposed to the Python, Node.js, or other bindings) and holds no global state — the caller controls precedence by argument order, and `initialize_plugins` activates whatever document it is given. From 9d15c15a8cad46a4ac3b18041005d199f7bdfc49 Mon Sep 17 00:00:00 2001 From: Zhongxuan Wang Date: Thu, 4 Jun 2026 11:03:11 -0700 Subject: [PATCH 06/15] refactor: use in-place layer_config(&mut, _) matching the original CLI merge layer_config / merge_plugin_components / merge_json_value now use the original in-place (&mut left, right) form, so they are faithful translations of the CLI's merge_plugin_toml / merge_plugin_components / merge_toml (on serde_json::Value, with the positional multi-instance pairing). Behavior is unchanged; the CLI call site, tests, and doc example are updated to match. Signed-off-by: Zhongxuan Wang --- crates/cli/src/config.rs | 2 +- crates/core/src/plugin.rs | 91 ++++++++++--------- crates/core/tests/unit/plugin_tests.rs | 6 +- .../plugin-configuration-files.mdx | 7 +- 4 files changed, 55 insertions(+), 51 deletions(-) diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index b3119d17..b622470d 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -767,7 +767,7 @@ where path.display() )) })?; - merged = layer_config(&merged, &document); + layer_config(&mut merged, document); sources.push(path); } } diff --git a/crates/core/src/plugin.rs b/crates/core/src/plugin.rs index faae5e53..61933249 100644 --- a/crates/core/src/plugin.rs +++ b/crates/core/src/plugin.rs @@ -899,77 +899,78 @@ pub fn validate_plugin_config(config: &PluginConfig) -> ConfigReport { report } -/// Merges `overlay` (higher precedence) over `base` into the effective config document. +/// Layers `right` (higher precedence) onto `left` in place. /// -/// Objects merge recursively and arrays/scalars are replaced by `overlay`, except the +/// Objects merge recursively and arrays/scalars are replaced by `right`, except the /// top-level `components` array, whose entries pair by `kind` in order of appearance so /// multi-instance kinds are not collapsed. Shared by the gateway's file-layer merge. -pub fn layer_config(base: &Json, overlay: &Json) -> Json { - let (Json::Object(base_object), Json::Object(overlay_object)) = (base, overlay) else { - return overlay.clone(); - }; - let mut merged = base_object.clone(); - for (key, overlay_value) in overlay_object { - let merged_value = match (key.as_str(), merged.get(key)) { - ("components", Some(base_components)) => { - merge_plugin_components(base_components, overlay_value) +pub fn layer_config(left: &mut Json, right: Json) { + match (left, right) { + (Json::Object(left), Json::Object(right)) => { + for (key, value) in right { + match (key.as_str(), left.get_mut(&key)) { + ("components", Some(existing)) => merge_plugin_components(existing, value), + (_, Some(existing)) => merge_json_value(existing, value), + (_, _) => { + left.insert(key, value); + } + } } - (_, Some(base_value)) => merge_json_value(base_value, overlay_value), - (_, None) => overlay_value.clone(), - }; - merged.insert(key.clone(), merged_value); + } + (left, right) => *left = right, } - Json::Object(merged) } -/// Merges `overlay` components into `base` by `kind`, pairing repeated kinds positionally. -fn merge_plugin_components(base: &Json, overlay: &Json) -> Json { - let (Json::Array(base_components), Json::Array(overlay_components)) = (base, overlay) else { - return overlay.clone(); +/// Merges `right` components into `left` by `kind`, pairing repeated kinds positionally. +fn merge_plugin_components(left: &mut Json, right: Json) { + let Json::Array(left_components) = left else { + *left = right; + return; + }; + let Json::Array(right_components) = right else { + *left = right; + return; }; - let mut components = base_components.clone(); let mut base_slots: HashMap> = HashMap::new(); - for (index, component) in components.iter().enumerate() { + for (index, component) in left_components.iter().enumerate() { if let Some(kind) = component_kind(component) { base_slots.entry(kind.to_string()).or_default().push(index); } } let mut consumed: HashMap = HashMap::new(); - for overlay_component in overlay_components { - let Some(kind) = component_kind(overlay_component) else { - components.push(overlay_component.clone()); + for component in right_components { + let Some(kind) = component_kind(&component).map(str::to_owned) else { + left_components.push(component); continue; }; - let nth = consumed.entry(kind.to_string()).or_insert(0); + let nth = consumed.entry(kind.clone()).or_insert(0); let slot = base_slots - .get(kind) + .get(&kind) .and_then(|slots| slots.get(*nth)) .copied(); *nth += 1; match slot { - Some(index) => { - components[index] = merge_json_value(&components[index], overlay_component); - } - None => components.push(overlay_component.clone()), + Some(index) => merge_json_value(&mut left_components[index], component), + None => left_components.push(component), } } - Json::Array(components) } -/// Recursively merges JSON objects; arrays and scalars are replaced by `overlay`. -fn merge_json_value(base: &Json, overlay: &Json) -> Json { - let (Json::Object(base_object), Json::Object(overlay_object)) = (base, overlay) else { - return overlay.clone(); - }; - let mut merged = base_object.clone(); - for (key, overlay_value) in overlay_object { - let merged_value = match merged.get(key) { - Some(base_value) => merge_json_value(base_value, overlay_value), - None => overlay_value.clone(), - }; - merged.insert(key.clone(), merged_value); +/// Recursively merges `right` into a `left` JSON object; arrays and scalars are replaced. +fn merge_json_value(left: &mut Json, right: Json) { + match (left, right) { + (Json::Object(left), Json::Object(right)) => { + for (key, value) in right { + match left.get_mut(&key) { + Some(existing) => merge_json_value(existing, value), + None => { + left.insert(key, value); + } + } + } + } + (left, right) => *left = right, } - Json::Object(merged) } fn component_kind(component: &Json) -> Option<&str> { diff --git a/crates/core/tests/unit/plugin_tests.rs b/crates/core/tests/unit/plugin_tests.rs index 36981a5e..ac9f419d 100644 --- a/crates/core/tests/unit/plugin_tests.rs +++ b/crates/core/tests/unit/plugin_tests.rs @@ -344,7 +344,8 @@ fn test_layer_config_overlay_wins() { "policy": { "unknown_component": "error" } }); - let merged = layer_config(&base, &overlay); + let mut merged = base; + layer_config(&mut merged, overlay); let components = merged["components"].as_array().unwrap(); // Ordering: base components first (in base order), then overlay-only components appended. @@ -402,7 +403,8 @@ fn test_layer_config_preserves_multi_instance_kinds() { ] }); - let merged = layer_config(&base, &overlay); + let mut merged = base; + layer_config(&mut merged, overlay); let components = merged["components"].as_array().unwrap(); // First overlay instance pairs with the base instance; the second is appended, not dropped. diff --git a/docs/build-plugins/plugin-configuration-files.mdx b/docs/build-plugins/plugin-configuration-files.mdx index c749e5a5..f453208a 100644 --- a/docs/build-plugins/plugin-configuration-files.mdx +++ b/docs/build-plugins/plugin-configuration-files.mdx @@ -234,9 +234,10 @@ file-resolved one before activating it: ```rust use nemo_relay::plugin::{initialize_plugins, layer_config}; -// `base` and `overlay` are plugin config documents (the JSON shape of `PluginConfig`); -// `overlay` is the higher-precedence layer. -let effective = layer_config(&base, &overlay); +// `effective` starts as the lower layer; `overlay` (higher precedence) is layered onto it. +// Both are plugin config documents (the JSON shape of `PluginConfig`). +let mut effective = base; +layer_config(&mut effective, overlay); initialize_plugins(serde_json::from_value(effective)?).await?; ``` From 2535602d373daef7d84610714f7b3a4d30c01a34 Mon Sep 17 00:00:00 2001 From: Zhongxuan Wang Date: Thu, 4 Jun 2026 12:13:57 -0700 Subject: [PATCH 07/15] docs: describe layer_config as an internal merge primitive Drop the host-facing compose-in-code example and the Discovery callout; describe layering as the gateway's internal file-layer merge implemented by layer_config. No code change. Signed-off-by: Zhongxuan Wang --- .../plugin-configuration-files.mdx | 36 ++++--------------- 1 file changed, 7 insertions(+), 29 deletions(-) diff --git a/docs/build-plugins/plugin-configuration-files.mdx b/docs/build-plugins/plugin-configuration-files.mdx index f453208a..cb92f133 100644 --- a/docs/build-plugins/plugin-configuration-files.mdx +++ b/docs/build-plugins/plugin-configuration-files.mdx @@ -85,11 +85,6 @@ Use only one file/CLI source class for a given gateway run. The gateway fails clearly if file-based plugin config and `--plugin-config` are both present, or if `plugins.toml` and `[plugins].config` are both present. -Configuration can also be composed in code: the same merge the gateway uses for -file layers is exposed as the `layer_config` runtime API, so a Rust host -can layer a programmatic document over a file-resolved one before activating it. -See [Configuration Layering](#configuration-layering). - When `--config path/to/config.toml` is supplied, plugin file discovery is scoped to `path/to/plugins.toml`. Implicit system, project, and user plugin files are not loaded for that run. @@ -227,30 +222,13 @@ naming the sources, in these conflict cases: ## Configuration Layering -The merge the gateway uses to layer files is exposed as a Rust runtime API, -`layer_config`, so a host can compose a programmatic document over a -file-resolved one before activating it: - -```rust -use nemo_relay::plugin::{initialize_plugins, layer_config}; - -// `effective` starts as the lower layer; `overlay` (higher precedence) is layered onto it. -// Both are plugin config documents (the JSON shape of `PluginConfig`). -let mut effective = base; -layer_config(&mut effective, overlay); -initialize_plugins(serde_json::from_value(effective)?).await?; -``` - -The merge is the same one applied to file layers: - -- Objects merge recursively; arrays and scalar values are replaced by `overlay`. -- The top-level `components` array merges by `kind` in order of appearance, so a - kind used by a multi-instance plugin keeps each instance instead of collapsing; - components present in only one side are kept. - -`layer_config` is Rust-only (not exposed to the Python, Node.js, or other -bindings) and holds no global state — the caller controls precedence by argument -order, and `initialize_plugins` activates whatever document it is given. +The file-layer merge described above is implemented by `layer_config` in +`nemo_relay::plugin` — a Rust-only primitive, shared with the core crate, that the +gateway uses internally to combine discovered layers. It is not a step operators +or language bindings invoke: configuration is written in files and applied through +plugin initialization, which performs the layering. (When the same component +`kind` appears more than once, entries pair by order of appearance, so +multi-instance plugins keep each instance rather than collapsing into the first.) ## Explicit Defaults And Overrides From 1adb2eb6e6404c8fd3a5c45d5d91728ed6f35378 Mon Sep 17 00:00:00 2001 From: Zhongxuan Wang Date: Thu, 4 Jun 2026 17:03:58 -0700 Subject: [PATCH 08/15] feat: layer code-driven plugin config over materialized file configs initialize_plugins now resolves the discovered plugins.toml layering as a base and layers the caller's config on top, so direct integrations (any binding) inherit the same file layering as the gateway instead of starting from an empty base. initialize_plugins_exact activates an already-materialized config without discovery; the CLI uses it so --config / --plugin-config source rules are unchanged. File discovery and TOML parsing sit behind an opt-in config-files core feature, keeping the default and wasm builds free of toml/filesystem; the CLI and native bindings enable it, and the CLI read/parse/merge is de-duplicated onto the shared core load_plugin_config_files primitive. The caller's config is layered as a typed document, so version/policy/enabled carry serde defaults while component config bodies merge field-by-field. Refs: RELAY-183 Signed-off-by: Zhongxuan Wang --- Cargo.lock | 1 + crates/cli/Cargo.toml | 2 +- crates/cli/src/config.rs | 79 +++---------------- crates/cli/src/server.rs | 6 +- crates/core/Cargo.toml | 4 + crates/core/src/plugin.rs | 160 +++++++++++++++++++++++++++++++++++--- crates/ffi/Cargo.toml | 2 +- crates/node/Cargo.toml | 2 +- crates/python/Cargo.toml | 2 +- 9 files changed, 173 insertions(+), 85 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0246c835..c37e0522 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1335,6 +1335,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-stream", + "toml", "tonic", "typed-builder", "uuid", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index ca1d49d2..5531544a 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -21,7 +21,7 @@ pkg-fmt = "bin" workspace = true [dependencies] -nemo-relay = { workspace = true, features = ["guardrails-remote", "object-store", "openinference"] } +nemo-relay = { workspace = true, features = ["config-files", "guardrails-remote", "object-store", "openinference"] } nemo-relay-adaptive = { workspace = true, features = ["redis-backend"] } async-stream = "0.3" axum = "0.8" diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index b622470d..1e76c422 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -1,13 +1,12 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use std::collections::HashSet; use std::net::SocketAddr; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use axum::http::HeaderMap; use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum}; -use nemo_relay::plugin::layer_config; +use nemo_relay::plugin::{PluginError, load_plugin_config_files}; use serde::Deserialize; use serde_json::Value; @@ -745,39 +744,13 @@ fn load_plugin_toml_config_from_paths(paths: I) -> Result, { - let mut merged = Value::Object(serde_json::Map::new()); - let mut sources = Vec::new(); - for path in paths { - if path.exists() { - let raw = std::fs::read_to_string(&path)?; - let parsed = raw - .parse::() - .map(toml::Value::Table) - .map_err(|error| { - CliError::Config(format!( - "invalid plugin TOML in {}: {error}", - path.display() - )) - })?; - validate_plugin_toml_component_kinds(&path, &parsed)?; - // Merge file layers with the shared core primitive (each file converted to JSON first). - let document = serde_json::to_value(parsed).map_err(|error| { - CliError::Config(format!( - "invalid plugin TOML shape in {}: {error}", - path.display() - )) - })?; - layer_config(&mut merged, document); - sources.push(path); - } - } - if sources.is_empty() { - return Ok(None); - } - Ok(Some(PluginTomlConfig { - value: merged, - sources, - })) + // Delegate read/parse/merge to the shared core primitive so file precedence and the + // per-file duplicate-kind check stay identical to the code-driven layering path. + let resolved = load_plugin_config_files(paths).map_err(|err| match err { + PluginError::InvalidConfig(message) => CliError::Config(message), + other => CliError::Config(other.to_string()), + })?; + Ok(resolved.map(|(value, sources)| PluginTomlConfig { value, sources })) } fn apply_plugin_toml_config( @@ -868,40 +841,6 @@ fn merge_toml(left: &mut toml::Value, right: toml::Value) { } } -fn component_kind(component: &toml::Value) -> Option<&str> { - component - .as_table() - .and_then(|table| table.get("kind")) - .and_then(toml::Value::as_str) -} - -fn validate_plugin_toml_component_kinds(path: &Path, value: &toml::Value) -> Result<(), CliError> { - let Some(components) = value.get("components").and_then(toml::Value::as_array) else { - return Ok(()); - }; - let mut seen = HashSet::new(); - let mut duplicates = Vec::new(); - for component in components { - let Some(kind) = component_kind(component) else { - continue; - }; - if !seen.insert(kind.to_string()) { - duplicates.push(kind.to_string()); - } - } - duplicates.sort(); - duplicates.dedup(); - if duplicates.is_empty() { - Ok(()) - } else { - Err(CliError::Config(format!( - "duplicate plugin component kind in {}: {}; declare each kind once per plugins.toml", - path.display(), - duplicates.join(", ") - ))) - } -} - fn has_config_toml_plugin_config(value: &toml::Value) -> bool { value .get("plugins") diff --git a/crates/cli/src/server.rs b/crates/cli/src/server.rs index fef92e1d..3639cc17 100644 --- a/crates/cli/src/server.rs +++ b/crates/cli/src/server.rs @@ -7,7 +7,7 @@ use axum::extract::State; use axum::http::HeaderMap; use axum::routing::{get, post}; use axum::{Json, Router}; -use nemo_relay::plugin::{PluginConfig, clear_plugin_configuration, initialize_plugins}; +use nemo_relay::plugin::{PluginConfig, clear_plugin_configuration, initialize_plugins_exact}; use nemo_relay_adaptive::plugin_component::register_adaptive_component; use reqwest::Client; use serde_json::Value; @@ -157,9 +157,11 @@ impl PluginActivation { register_adaptive_component().map_err(|error| { CliError::Config(format!("adaptive plugin registration failed: {error}")) })?; + // The gateway has already materialized its effective config via its own file/CLI + // source rules, so activate it exactly — core must not re-discover and re-layer. let plugin_config: PluginConfig = serde_json::from_value(config) .map_err(|error| CliError::Config(format!("invalid plugin config: {error}")))?; - initialize_plugins(plugin_config) + initialize_plugins_exact(plugin_config) .await .map_err(|error| CliError::Config(format!("plugin activation failed: {error}")))?; Ok(Self { active: true }) diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index e205b83c..d4f644c0 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -16,6 +16,9 @@ workspace = true [features] default = ["otel", "openinference", "guardrails-remote", "object-store"] schema = ["dep:schemars"] +# Opt-in plugins.toml discovery + TOML parsing for the code-driven layering path. +# Kept out of `default` so the lean and wasm builds never pull `toml`/filesystem. +config-files = ["dep:toml"] guardrails-remote = [ "dep:reqwest", "dep:rustls", @@ -61,6 +64,7 @@ openinference = [ uuid = { workspace = true, features = ["v7", "serde"] } serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" +toml = { version = "0.9", optional = true } schemars = { version = "0.8", optional = true } chrono = { version = "0.4", features = ["serde"] } bitflags = { version = "2", features = ["serde"] } diff --git a/crates/core/src/plugin.rs b/crates/core/src/plugin.rs index 61933249..388ed078 100644 --- a/crates/core/src/plugin.rs +++ b/crates/core/src/plugin.rs @@ -984,15 +984,18 @@ pub fn plugin_config_schema() -> Json { .expect("plugin config schema should serialize") } -/// Configures the active global plugin components. +/// Validates and activates `config` exactly, without resolving or layering any +/// file-based plugin configuration. /// -/// Initialization validates the supplied config, replaces the active -/// configuration, and rolls back partial registration on failure. If a -/// previous configuration was active, the host attempts to restore it when the -/// new activation fails. +/// Replaces the active configuration and rolls back partial registration on +/// failure, restoring the previous configuration when a replace fails. Callers +/// that have already materialized their effective configuration — such as the +/// `nemo-relay` gateway, which applies its own file/CLI source rules — use this +/// directly. Most embedders use [`initialize_plugins`], which layers the +/// discovered file configuration underneath first. /// /// # Parameters -/// - `config`: Plugin configuration to validate and activate. +/// - `config`: Plugin configuration to validate and activate as-is. /// /// # Returns /// A plugin [`Result`] containing the successful [`ConfigReport`]. @@ -1002,9 +1005,9 @@ pub fn plugin_config_schema() -> Json { /// when the previous configuration cannot be restored after a failed replace. /// /// # Notes -/// Initialization is replace-with-rollback: the previous active configuration -/// is removed before the new configuration is activated. -pub async fn initialize_plugins(config: PluginConfig) -> Result { +/// Activation is replace-with-rollback: the previous active configuration is +/// removed before the new configuration is activated. +pub async fn initialize_plugins_exact(config: PluginConfig) -> Result { let report = validate_plugin_config(&config); if report.has_errors() { return Err(PluginError::InvalidConfig(join_error_messages(&report))); @@ -1046,6 +1049,145 @@ pub async fn initialize_plugins(config: PluginConfig) -> Result { } } +/// Validates and activates `config`, layered on top of the materialized file +/// configuration. +/// +/// Resolves the discovered `plugins.toml` layering as the base, layers `config` +/// (higher precedence) on top, then validates and activates via +/// [`initialize_plugins_exact`]. Every language binding funnels through this, so a +/// direct integration sees the same file layering as the `nemo-relay` gateway +/// instead of starting from an empty base. +/// +/// `config` is layered as a typed document, so its `version`, `policy`, and each +/// component's `enabled` carry serde defaults and override the file base; a +/// component's free-form `config` body still merges field-by-field. When file +/// discovery is not compiled in (the `config-files` feature is disabled, as on +/// wasm targets), the base is empty and this matches [`initialize_plugins_exact`]. +pub async fn initialize_plugins(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 +} + +/// Resolves the default `plugins.toml` layering into one JSON document, or an +/// empty object when no file exists or file discovery is not compiled in. +fn resolve_default_file_plugin_config() -> Result { + #[cfg(feature = "config-files")] + { + Ok(load_plugin_config_files(default_plugin_config_paths())? + .map(|(value, _sources)| value) + .unwrap_or_else(|| Json::Object(Map::new()))) + } + #[cfg(not(feature = "config-files"))] + { + Ok(Json::Object(Map::new())) + } +} + +#[cfg(feature = "config-files")] +use std::path::{Path, PathBuf}; + +/// Reads and merges plugin config documents from `paths`, lowest precedence +/// first. +/// +/// Each existing file is parsed as TOML, converted to the canonical JSON +/// document shape, and layered with [`layer_config`]. Returns the merged +/// document and the contributing source paths, or `None` when no file existed. +/// +/// Internal shared primitive for the gateway's file discovery; `pub` only for +/// cross-crate reuse, not part of the stable API. +#[cfg(feature = "config-files")] +#[doc(hidden)] +pub fn load_plugin_config_files(paths: I) -> Result)>> +where + I: IntoIterator, +{ + let mut merged = Json::Object(Map::new()); + let mut sources = Vec::new(); + for path in paths { + if !path.exists() { + continue; + } + let raw = std::fs::read_to_string(&path).map_err(|err| { + PluginError::InvalidConfig(format!("failed to read {}: {err}", path.display())) + })?; + let parsed = raw.parse::().map_err(|err| { + PluginError::InvalidConfig(format!("invalid plugin TOML in {}: {err}", path.display())) + })?; + let document = serde_json::to_value(parsed)?; + validate_unique_component_kinds(&path, &document)?; + layer_config(&mut merged, document); + sources.push(path); + } + Ok((!sources.is_empty()).then_some((merged, sources))) +} + +/// Rejects a single file that declares the same component `kind` more than once. +#[cfg(feature = "config-files")] +fn validate_unique_component_kinds(path: &Path, document: &Json) -> Result<()> { + let Some(components) = document.get("components").and_then(Json::as_array) else { + return Ok(()); + }; + let mut seen = HashSet::new(); + let mut duplicates = Vec::new(); + for component in components { + if let Some(kind) = component_kind(component) + && !seen.insert(kind) + { + duplicates.push(kind.to_string()); + } + } + if duplicates.is_empty() { + return Ok(()); + } + duplicates.sort(); + duplicates.dedup(); + Err(PluginError::InvalidConfig(format!( + "duplicate plugin component kind in {}: {}; declare each kind once per plugins.toml", + path.display(), + duplicates.join(", ") + ))) +} + +/// Default `plugins.toml` search path, lowest precedence first: system, then the +/// nearest project file, then the user file. Mirrors the gateway's implicit +/// discovery so a direct integration sees the same layering as `nemo-relay`. +#[cfg(feature = "config-files")] +fn default_plugin_config_paths() -> Vec { + let mut paths = vec![PathBuf::from("/etc/nemo-relay/plugins.toml")]; + if let Ok(cwd) = std::env::current_dir() + && let Some(project) = nearest_project_plugin_config(&cwd) + { + paths.push(project); + } + if let Some(dir) = user_config_dir() { + paths.push(dir.join("plugins.toml")); + } + paths +} + +/// Walks upward from `start` for the nearest `.nemo-relay/plugins.toml`. +#[cfg(feature = "config-files")] +fn nearest_project_plugin_config(start: &Path) -> Option { + start + .ancestors() + .map(|ancestor| ancestor.join(".nemo-relay").join("plugins.toml")) + .find(|path| path.exists()) +} + +/// Resolves the nemo-relay user config directory from `XDG_CONFIG_HOME`, then +/// `HOME`/`USERPROFILE`. +#[cfg(feature = "config-files")] +fn user_config_dir() -> Option { + if let Some(base) = std::env::var_os("XDG_CONFIG_HOME") { + return Some(PathBuf::from(base).join("nemo-relay")); + } + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(|home| PathBuf::from(home).join(".config/nemo-relay")) +} + /// Deregisters and clears all configured plugin components. /// /// Registered plugin kinds remain available for future validation and diff --git a/crates/ffi/Cargo.toml b/crates/ffi/Cargo.toml index 7c5ba010..05c8fc5b 100644 --- a/crates/ffi/Cargo.toml +++ b/crates/ffi/Cargo.toml @@ -17,7 +17,7 @@ workspace = true crate-type = ["cdylib", "staticlib", "rlib"] [dependencies] -nemo-relay = { workspace = true, features = ["otel", "openinference"] } +nemo-relay = { workspace = true, features = ["config-files", "otel", "openinference"] } nemo-relay-adaptive = { workspace = true, features = ["redis-backend"] } chrono = "0.4" libc = "0.2" diff --git a/crates/node/Cargo.toml b/crates/node/Cargo.toml index 878a8513..b0460fd5 100644 --- a/crates/node/Cargo.toml +++ b/crates/node/Cargo.toml @@ -18,7 +18,7 @@ crate-type = ["cdylib"] test = false [dependencies] -nemo-relay = { workspace = true, features = ["otel", "openinference"] } +nemo-relay = { workspace = true, features = ["config-files", "otel", "openinference"] } nemo-relay-adaptive = { workspace = true, features = ["redis-backend"] } chrono = "0.4" napi = { version = "2", features = ["napi6", "async", "serde-json", "tokio_rt"] } diff --git a/crates/python/Cargo.toml b/crates/python/Cargo.toml index 6f20de35..2a1eaea1 100644 --- a/crates/python/Cargo.toml +++ b/crates/python/Cargo.toml @@ -18,7 +18,7 @@ name = "_native" crate-type = ["cdylib", "rlib"] [dependencies] -nemo-relay = { workspace = true, features = ["otel", "openinference"] } +nemo-relay = { workspace = true, features = ["config-files", "otel", "openinference"] } nemo-relay-adaptive = { workspace = true, features = ["redis-backend"] } pyo3 = { version = "0.28.2", features = ["abi3", "abi3-py311", "experimental-inspect", "macros"] } pyo3-async-runtimes = { version = "0.28.0", features = ["tokio-runtime"] } From dd49947dfbbe5abb62e74bc3e0c7b4d2b7d70a17 Mon Sep 17 00:00:00 2001 From: Zhongxuan Wang Date: Thu, 4 Jun 2026 18:40:12 -0700 Subject: [PATCH 09/15] refactor: drop the config-files feature; make toml a direct core dep The optional config-files feature gated plugins.toml discovery and the TOML parser out of core's default/wasm builds. Removing it makes toml a direct core dependency and discovery unconditional, dropping the feature flag, all #[cfg] gates, and the per-binding feature lines. Trade-off: toml and the filesystem discovery now compile into every core build, including wasm (where discovery finds no files, so the base is empty). Also trims the doc comments on the new plugin-init and discovery functions. Signed-off-by: Zhongxuan Wang --- crates/cli/Cargo.toml | 2 +- crates/cli/src/config.rs | 3 +- crates/cli/src/server.rs | 3 +- crates/core/Cargo.toml | 5 +-- crates/core/src/plugin.rs | 84 +++++++++------------------------------ crates/ffi/Cargo.toml | 2 +- crates/node/Cargo.toml | 2 +- crates/python/Cargo.toml | 2 +- 8 files changed, 26 insertions(+), 77 deletions(-) diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 5531544a..ca1d49d2 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -21,7 +21,7 @@ pkg-fmt = "bin" workspace = true [dependencies] -nemo-relay = { workspace = true, features = ["config-files", "guardrails-remote", "object-store", "openinference"] } +nemo-relay = { workspace = true, features = ["guardrails-remote", "object-store", "openinference"] } nemo-relay-adaptive = { workspace = true, features = ["redis-backend"] } async-stream = "0.3" axum = "0.8" diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index 1e76c422..5306c823 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -744,8 +744,7 @@ fn load_plugin_toml_config_from_paths(paths: I) -> Result, { - // Delegate read/parse/merge to the shared core primitive so file precedence and the - // per-file duplicate-kind check stay identical to the code-driven layering path. + // Delegate read/parse/merge to the shared core primitive (file precedence unchanged). let resolved = load_plugin_config_files(paths).map_err(|err| match err { PluginError::InvalidConfig(message) => CliError::Config(message), other => CliError::Config(other.to_string()), diff --git a/crates/cli/src/server.rs b/crates/cli/src/server.rs index 3639cc17..37c9c669 100644 --- a/crates/cli/src/server.rs +++ b/crates/cli/src/server.rs @@ -157,8 +157,7 @@ impl PluginActivation { register_adaptive_component().map_err(|error| { CliError::Config(format!("adaptive plugin registration failed: {error}")) })?; - // The gateway has already materialized its effective config via its own file/CLI - // source rules, so activate it exactly — core must not re-discover and re-layer. + // Gateway already resolved its config; activate exactly (no re-discovery). let plugin_config: PluginConfig = serde_json::from_value(config) .map_err(|error| CliError::Config(format!("invalid plugin config: {error}")))?; initialize_plugins_exact(plugin_config) diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index d4f644c0..0c8ebc88 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -16,9 +16,6 @@ workspace = true [features] default = ["otel", "openinference", "guardrails-remote", "object-store"] schema = ["dep:schemars"] -# Opt-in plugins.toml discovery + TOML parsing for the code-driven layering path. -# Kept out of `default` so the lean and wasm builds never pull `toml`/filesystem. -config-files = ["dep:toml"] guardrails-remote = [ "dep:reqwest", "dep:rustls", @@ -64,7 +61,7 @@ openinference = [ uuid = { workspace = true, features = ["v7", "serde"] } serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" -toml = { version = "0.9", optional = true } +toml = { version = "0.9" } schemars = { version = "0.8", optional = true } chrono = { version = "0.4", features = ["serde"] } bitflags = { version = "2", features = ["serde"] } diff --git a/crates/core/src/plugin.rs b/crates/core/src/plugin.rs index 388ed078..9ed1c3f2 100644 --- a/crates/core/src/plugin.rs +++ b/crates/core/src/plugin.rs @@ -984,29 +984,12 @@ pub fn plugin_config_schema() -> Json { .expect("plugin config schema should serialize") } -/// Validates and activates `config` exactly, without resolving or layering any -/// file-based plugin configuration. +/// Validates and activates `config` exactly, with no file-based layering. /// -/// Replaces the active configuration and rolls back partial registration on -/// failure, restoring the previous configuration when a replace fails. Callers -/// that have already materialized their effective configuration — such as the -/// `nemo-relay` gateway, which applies its own file/CLI source rules — use this -/// directly. Most embedders use [`initialize_plugins`], which layers the -/// discovered file configuration underneath first. -/// -/// # Parameters -/// - `config`: Plugin configuration to validate and activate as-is. -/// -/// # Returns -/// A plugin [`Result`] containing the successful [`ConfigReport`]. -/// -/// # Errors -/// Returns an error when validation fails, when plugin registration fails, or -/// when the previous configuration cannot be restored after a failed replace. -/// -/// # Notes -/// Activation is replace-with-rollback: the previous active configuration is -/// removed before the new configuration is activated. +/// Replace-with-rollback: the previous configuration is removed first and +/// restored if activation fails. The gateway uses this (it resolves its own +/// config); most callers use [`initialize_plugins`], which layers the discovered +/// file config underneath. pub async fn initialize_plugins_exact(config: PluginConfig) -> Result { let report = validate_plugin_config(&config); if report.has_errors() { @@ -1049,20 +1032,11 @@ pub async fn initialize_plugins_exact(config: PluginConfig) -> Result Result { let mut base = resolve_default_file_plugin_config()?; layer_config(&mut base, serde_json::to_value(config)?); @@ -1071,33 +1045,18 @@ pub async fn initialize_plugins(config: PluginConfig) -> Result { } /// Resolves the default `plugins.toml` layering into one JSON document, or an -/// empty object when no file exists or file discovery is not compiled in. +/// empty object when no plugin file exists. fn resolve_default_file_plugin_config() -> Result { - #[cfg(feature = "config-files")] - { - Ok(load_plugin_config_files(default_plugin_config_paths())? - .map(|(value, _sources)| value) - .unwrap_or_else(|| Json::Object(Map::new()))) - } - #[cfg(not(feature = "config-files"))] - { - Ok(Json::Object(Map::new())) - } + Ok(load_plugin_config_files(default_plugin_config_paths())? + .map(|(value, _sources)| value) + .unwrap_or_else(|| Json::Object(Map::new()))) } -#[cfg(feature = "config-files")] use std::path::{Path, PathBuf}; -/// Reads and merges plugin config documents from `paths`, lowest precedence -/// first. -/// -/// Each existing file is parsed as TOML, converted to the canonical JSON -/// document shape, and layered with [`layer_config`]. Returns the merged -/// document and the contributing source paths, or `None` when no file existed. -/// -/// Internal shared primitive for the gateway's file discovery; `pub` only for -/// cross-crate reuse, not part of the stable API. -#[cfg(feature = "config-files")] +/// Reads, parses, and merges the `plugins.toml` files at `paths` (lowest +/// precedence first) into one JSON document with its source paths, or `None` +/// when none exist. Internal: `pub` only for cross-crate reuse by the gateway. #[doc(hidden)] pub fn load_plugin_config_files(paths: I) -> Result)>> where @@ -1124,7 +1083,6 @@ where } /// Rejects a single file that declares the same component `kind` more than once. -#[cfg(feature = "config-files")] fn validate_unique_component_kinds(path: &Path, document: &Json) -> Result<()> { let Some(components) = document.get("components").and_then(Json::as_array) else { return Ok(()); @@ -1150,10 +1108,8 @@ fn validate_unique_component_kinds(path: &Path, document: &Json) -> Result<()> { ))) } -/// Default `plugins.toml` search path, lowest precedence first: system, then the -/// nearest project file, then the user file. Mirrors the gateway's implicit -/// discovery so a direct integration sees the same layering as `nemo-relay`. -#[cfg(feature = "config-files")] +/// Default `plugins.toml` search path (lowest precedence first): system, nearest +/// project file, then user file — mirroring the gateway's discovery. fn default_plugin_config_paths() -> Vec { let mut paths = vec![PathBuf::from("/etc/nemo-relay/plugins.toml")]; if let Ok(cwd) = std::env::current_dir() @@ -1168,7 +1124,6 @@ fn default_plugin_config_paths() -> Vec { } /// Walks upward from `start` for the nearest `.nemo-relay/plugins.toml`. -#[cfg(feature = "config-files")] fn nearest_project_plugin_config(start: &Path) -> Option { start .ancestors() @@ -1178,7 +1133,6 @@ fn nearest_project_plugin_config(start: &Path) -> Option { /// Resolves the nemo-relay user config directory from `XDG_CONFIG_HOME`, then /// `HOME`/`USERPROFILE`. -#[cfg(feature = "config-files")] fn user_config_dir() -> Option { if let Some(base) = std::env::var_os("XDG_CONFIG_HOME") { return Some(PathBuf::from(base).join("nemo-relay")); diff --git a/crates/ffi/Cargo.toml b/crates/ffi/Cargo.toml index 05c8fc5b..7c5ba010 100644 --- a/crates/ffi/Cargo.toml +++ b/crates/ffi/Cargo.toml @@ -17,7 +17,7 @@ workspace = true crate-type = ["cdylib", "staticlib", "rlib"] [dependencies] -nemo-relay = { workspace = true, features = ["config-files", "otel", "openinference"] } +nemo-relay = { workspace = true, features = ["otel", "openinference"] } nemo-relay-adaptive = { workspace = true, features = ["redis-backend"] } chrono = "0.4" libc = "0.2" diff --git a/crates/node/Cargo.toml b/crates/node/Cargo.toml index b0460fd5..878a8513 100644 --- a/crates/node/Cargo.toml +++ b/crates/node/Cargo.toml @@ -18,7 +18,7 @@ crate-type = ["cdylib"] test = false [dependencies] -nemo-relay = { workspace = true, features = ["config-files", "otel", "openinference"] } +nemo-relay = { workspace = true, features = ["otel", "openinference"] } nemo-relay-adaptive = { workspace = true, features = ["redis-backend"] } chrono = "0.4" napi = { version = "2", features = ["napi6", "async", "serde-json", "tokio_rt"] } diff --git a/crates/python/Cargo.toml b/crates/python/Cargo.toml index 2a1eaea1..6f20de35 100644 --- a/crates/python/Cargo.toml +++ b/crates/python/Cargo.toml @@ -18,7 +18,7 @@ name = "_native" crate-type = ["cdylib", "rlib"] [dependencies] -nemo-relay = { workspace = true, features = ["config-files", "otel", "openinference"] } +nemo-relay = { workspace = true, features = ["otel", "openinference"] } nemo-relay-adaptive = { workspace = true, features = ["redis-backend"] } pyo3 = { version = "0.28.2", features = ["abi3", "abi3-py311", "experimental-inspect", "macros"] } pyo3-async-runtimes = { version = "0.28.0", features = ["tokio-runtime"] } From 4dbcffb16db3dfbe2dab34d0c267d7a02635d07d Mon Sep 17 00:00:00 2001 From: Zhongxuan Wang Date: Thu, 4 Jun 2026 19:08:21 -0700 Subject: [PATCH 10/15] test: add core file-discovery tests and document code-driven layering Adds core unit tests for load_plugin_config_files (merge by precedence, per-file duplicate-kind rejection, absent-files case) and documents the code-driven layering model and its precedence in the plugin configuration docs. Restores the original initialize_plugins doc on initialize_plugins_exact. Signed-off-by: Zhongxuan Wang --- Cargo.lock | 1 + crates/core/Cargo.toml | 1 + crates/core/src/plugin.rs | 24 +++-- crates/core/tests/unit/plugin_tests.rs | 88 +++++++++++++++++++ .../plugin-configuration-files.mdx | 21 +++-- 5 files changed, 123 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c37e0522..f409bccc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1332,6 +1332,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "tempfile", "thiserror 2.0.18", "tokio", "tokio-stream", diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 0c8ebc88..07d59ba8 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -86,6 +86,7 @@ futures = "0.3" opentelemetry_sdk = { version = "0.31", default-features = false, features = ["trace", "testing"] } serde_json = "1" object_store = { version = "0.13", default-features = false, features = ["aws"] } +tempfile = "3" [[test]] name = "codec_integration" diff --git a/crates/core/src/plugin.rs b/crates/core/src/plugin.rs index 9ed1c3f2..942acaca 100644 --- a/crates/core/src/plugin.rs +++ b/crates/core/src/plugin.rs @@ -984,12 +984,26 @@ pub fn plugin_config_schema() -> Json { .expect("plugin config schema should serialize") } -/// Validates and activates `config` exactly, with no file-based layering. +/// Configures the active global plugin components. /// -/// Replace-with-rollback: the previous configuration is removed first and -/// restored if activation fails. The gateway uses this (it resolves its own -/// config); most callers use [`initialize_plugins`], which layers the discovered -/// file config underneath. +/// Initialization validates the supplied config, replaces the active +/// configuration, and rolls back partial registration on failure. If a +/// previous configuration was active, the host attempts to restore it when the +/// new activation fails. +/// +/// # Parameters +/// - `config`: Plugin configuration to validate and activate. +/// +/// # Returns +/// A plugin [`Result`] containing the successful [`ConfigReport`]. +/// +/// # Errors +/// Returns an error when validation fails, when plugin registration fails, or +/// when the previous configuration cannot be restored after a failed replace. +/// +/// # Notes +/// Initialization is replace-with-rollback: the previous active configuration +/// is removed before the new configuration is activated. pub async fn initialize_plugins_exact(config: PluginConfig) -> Result { let report = validate_plugin_config(&config); if report.has_errors() { diff --git a/crates/core/tests/unit/plugin_tests.rs b/crates/core/tests/unit/plugin_tests.rs index ac9f419d..9c0cbd80 100644 --- a/crates/core/tests/unit/plugin_tests.rs +++ b/crates/core/tests/unit/plugin_tests.rs @@ -1419,3 +1419,91 @@ fn test_initialize_plugins_reports_failed_restore_when_previous_configuration_ca assert!(active_plugin_report().is_none()); reset_global(); } + +#[test] +fn test_load_plugin_config_files_merges_files_by_precedence() { + let dir = tempfile::tempdir().unwrap(); + let lower = dir.path().join("lower.toml"); + let higher = dir.path().join("higher.toml"); + std::fs::write( + &lower, + "version = 1\n\ + [[components]]\n\ + kind = \"observability\"\n\ + enabled = false\n\ + [components.config]\n\ + output_directory = \"/var/log\"\n\ + mode = \"append\"\n", + ) + .unwrap(); + std::fs::write( + &higher, + "[[components]]\n\ + kind = \"observability\"\n\ + [components.config]\n\ + mode = \"overwrite\"\n\ + [[components]]\n\ + kind = \"adaptive\"\n", + ) + .unwrap(); + + let (merged, sources) = load_plugin_config_files([lower.clone(), higher.clone()]) + .unwrap() + .expect("a file exists"); + assert_eq!(sources, vec![lower, higher]); + + let components = merged["components"].as_array().unwrap(); + let observability = &components[0]; + assert_eq!(observability["kind"], json!("observability")); + assert_eq!( + observability["enabled"], + json!(false), + "lower-file enabled is inherited (higher omits it)" + ); + assert_eq!( + observability["config"]["output_directory"], + json!("/var/log"), + "lower-only config key is inherited" + ); + assert_eq!( + observability["config"]["mode"], + json!("overwrite"), + "higher file overrides the shared config key" + ); + assert_eq!( + components[1]["kind"], + json!("adaptive"), + "higher-only component kind is appended" + ); +} + +#[test] +fn test_load_plugin_config_files_rejects_duplicate_kinds_in_one_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("dup.toml"); + std::fs::write( + &path, + "[[components]]\n\ + kind = \"observability\"\n\ + [[components]]\n\ + kind = \"observability\"\n", + ) + .unwrap(); + + match load_plugin_config_files([path]).unwrap_err() { + PluginError::InvalidConfig(message) => { + assert!( + message.contains("duplicate plugin component kind"), + "{message}" + ); + } + other => panic!("unexpected error: {other}"), + } +} + +#[test] +fn test_load_plugin_config_files_returns_none_when_absent() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("missing.toml"); + assert!(load_plugin_config_files([missing]).unwrap().is_none()); +} diff --git a/docs/build-plugins/plugin-configuration-files.mdx b/docs/build-plugins/plugin-configuration-files.mdx index cb92f133..f1d59046 100644 --- a/docs/build-plugins/plugin-configuration-files.mdx +++ b/docs/build-plugins/plugin-configuration-files.mdx @@ -222,13 +222,20 @@ naming the sources, in these conflict cases: ## Configuration Layering -The file-layer merge described above is implemented by `layer_config` in -`nemo_relay::plugin` — a Rust-only primitive, shared with the core crate, that the -gateway uses internally to combine discovered layers. It is not a step operators -or language bindings invoke: configuration is written in files and applied through -plugin initialization, which performs the layering. (When the same component -`kind` appears more than once, entries pair by order of appearance, so -multi-instance plugins keep each instance rather than collapsing into the first.) +`layer_config` in `nemo_relay::plugin` is the document merge shared by the gateway +and the plugin runtime; it runs during initialization, not as an operator step. + +A direct integration that calls `plugin.initialize(...)` (any binding) resolves the +discovered `plugins.toml` layering as a base, then layers the code-driven config on +top (the code-driven config wins). Precedence differs slightly from the file-vs-file +rule above: a code-driven config is a typed document, so `version`, `policy`, and a +declared component's `enabled` take the code value (their defaults included), while +omitted component kinds and `config` keys are inherited from the files. On targets +without a filesystem (such as wasm), no files are found and the base is empty. + +(When the same component `kind` appears more than once, entries pair by order of +appearance, so multi-instance plugins keep each instance rather than collapsing into +the first.) ## Explicit Defaults And Overrides From 70e8737cdfba62406fa110e807a7f07aba740424 Mon Sep 17 00:00:00 2001 From: Zhongxuan Wang Date: Thu, 4 Jun 2026 20:53:56 -0700 Subject: [PATCH 11/15] refactor: hide initialize_plugins_exact from generated docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Internal gateway entry (no-discovery activation), not a documented public API — same treatment as load_plugin_config_files. Signed-off-by: Zhongxuan Wang --- crates/core/src/plugin.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/core/src/plugin.rs b/crates/core/src/plugin.rs index 942acaca..94b3ed4c 100644 --- a/crates/core/src/plugin.rs +++ b/crates/core/src/plugin.rs @@ -1004,6 +1004,7 @@ pub fn plugin_config_schema() -> Json { /// # Notes /// Initialization is replace-with-rollback: the previous active configuration /// is removed before the new configuration is activated. +#[doc(hidden)] pub async fn initialize_plugins_exact(config: PluginConfig) -> Result { let report = validate_plugin_config(&config); if report.has_errors() { From 44aca1fab0369520fb7f74a4dee9abf7dc0e9cca Mon Sep 17 00:00:00 2001 From: Zhongxuan Wang Date: Thu, 4 Jun 2026 21:08:15 -0700 Subject: [PATCH 12/15] docs: drop redundant source-precedence section and tighten layering Source Precedence And Conflicts duplicated the Discovery section (same source classes, mutual exclusivity, and precedence order); removed it. Tightened Configuration Layering to the code-driven model and precedence, dropping the internal layer_config note and the multi-instance aside. Signed-off-by: Zhongxuan Wang --- .../plugin-configuration-files.mdx | 29 ------------------- 1 file changed, 29 deletions(-) diff --git a/docs/build-plugins/plugin-configuration-files.mdx b/docs/build-plugins/plugin-configuration-files.mdx index f1d59046..aa602b4f 100644 --- a/docs/build-plugins/plugin-configuration-files.mdx +++ b/docs/build-plugins/plugin-configuration-files.mdx @@ -198,33 +198,8 @@ components that reach plugin validation also fail validation. Arrays inside component config are replaced by the higher-precedence value. Tables inside component config merge recursively. -## Source Precedence And Conflicts - -A gateway run uses exactly one file/CLI source class. The effective configuration -is composed from lowest to highest precedence: - -1. Discovered `plugins.toml` files, merged in order: system - (`/etc/nemo-relay/plugins.toml`) is lowest, then project (nearest - `.nemo-relay/plugins.toml`), then user - (`$XDG_CONFIG_HOME/nemo-relay/plugins.toml`) is highest -2. **or** a single inline `[plugins].config` block in `config.toml` -3. **or** `--plugin-config ''` - -These source classes are mutually exclusive, and the `plugins.toml` sub-layers -(system, project, user) only merge with each other. The gateway fails fast, -naming the sources, in these conflict cases: - -| Situation | Result | -|---|---| -| `--plugin-config` and any file config (`plugins.toml` or `[plugins].config`) | Error: choose one source | -| `plugins.toml` and `[plugins].config` both present | Error: choose one source | -| `[plugins].config` defined in more than one `config.toml` file | Error: consolidate into one block | - ## Configuration Layering -`layer_config` in `nemo_relay::plugin` is the document merge shared by the gateway -and the plugin runtime; it runs during initialization, not as an operator step. - A direct integration that calls `plugin.initialize(...)` (any binding) resolves the discovered `plugins.toml` layering as a base, then layers the code-driven config on top (the code-driven config wins). Precedence differs slightly from the file-vs-file @@ -233,10 +208,6 @@ declared component's `enabled` take the code value (their defaults included), wh omitted component kinds and `config` keys are inherited from the files. On targets without a filesystem (such as wasm), no files are found and the base is empty. -(When the same component `kind` appears more than once, entries pair by order of -appearance, so multi-instance plugins keep each instance rather than collapsing into -the first.) - ## Explicit Defaults And Overrides The editor writes explicit defaults for edited Observability and Adaptive From 4beb6c47703f7d551a932577e3e5f8f0b72c4063 Mon Sep 17 00:00:00 2001 From: Zhongxuan Wang Date: Thu, 4 Jun 2026 21:15:19 -0700 Subject: [PATCH 13/15] refactor: make layer_config a private helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit layer_config has no external callers — the CLI uses load_plugin_config_files and its tests are inline — so it drops pub and is fully internal to core's plugin module. Signed-off-by: Zhongxuan Wang --- crates/core/src/plugin.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/core/src/plugin.rs b/crates/core/src/plugin.rs index 94b3ed4c..3ba14dbd 100644 --- a/crates/core/src/plugin.rs +++ b/crates/core/src/plugin.rs @@ -903,8 +903,9 @@ pub fn validate_plugin_config(config: &PluginConfig) -> ConfigReport { /// /// Objects merge recursively and arrays/scalars are replaced by `right`, except the /// top-level `components` array, whose entries pair by `kind` in order of appearance so -/// multi-instance kinds are not collapsed. Shared by the gateway's file-layer merge. -pub fn layer_config(left: &mut Json, right: Json) { +/// multi-instance kinds are not collapsed. Internal helper shared by plugin +/// initialization and `plugins.toml` discovery. +fn layer_config(left: &mut Json, right: Json) { match (left, right) { (Json::Object(left), Json::Object(right)) => { for (key, value) in right { From 70b4894958e4eb43b27546ff9206787ed5347445 Mon Sep 17 00:00:00 2001 From: Zhongxuan Wang Date: Thu, 4 Jun 2026 21:32:59 -0700 Subject: [PATCH 14/15] test: swap marginal load-files tests for a typed-overlay precedence test Drops the duplicate-kind test (the CLI's plugins_toml_rejects_duplicate_component_kinds_per_file already covers that path through the shared loader) and the trivial absent-file test; adds a hermetic test pinning the code-vs-file typed-overlay precedence: a typed config's default version/policy/enabled override the file base, the config body merges, and an undeclared kind is inherited. Keeps the file-vs-file merge test. Signed-off-by: Zhongxuan Wang --- crates/core/tests/unit/plugin_tests.rs | 76 +++++++++++++++++--------- 1 file changed, 50 insertions(+), 26 deletions(-) diff --git a/crates/core/tests/unit/plugin_tests.rs b/crates/core/tests/unit/plugin_tests.rs index 9c0cbd80..b81df4d9 100644 --- a/crates/core/tests/unit/plugin_tests.rs +++ b/crates/core/tests/unit/plugin_tests.rs @@ -1478,32 +1478,56 @@ fn test_load_plugin_config_files_merges_files_by_precedence() { } #[test] -fn test_load_plugin_config_files_rejects_duplicate_kinds_in_one_file() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("dup.toml"); - std::fs::write( - &path, - "[[components]]\n\ - kind = \"observability\"\n\ - [[components]]\n\ - kind = \"observability\"\n", - ) - .unwrap(); - - match load_plugin_config_files([path]).unwrap_err() { - PluginError::InvalidConfig(message) => { - assert!( - message.contains("duplicate plugin component kind"), - "{message}" - ); +fn test_layer_config_applies_typed_overlay_defaults_over_file_base() { + // The code-vs-file path `initialize_plugins` takes: a typed `PluginConfig` is layered + // over the discovered file base. Its serde defaults (`version`/`policy`/`enabled`) + // override the file, the free-form `config` body merges, and an undeclared component + // kind is inherited from the file. + let file_base = json!({ + "version": 2, + "components": [ + { + "kind": "observability", + "enabled": false, + "config": { "output_directory": "/var/log", "mode": "append" } + }, + { "kind": "adaptive", "config": { "ttl": 60 } } + ], + "policy": { + "unknown_component": "error", + "unknown_field": "warn", + "unsupported_value": "error" } - other => panic!("unexpected error: {other}"), - } -} + }); + let code = PluginConfig { + components: vec![PluginComponentSpec { + config: Map::from_iter([(String::from("mode"), json!("overwrite"))]), + ..PluginComponentSpec::new("observability") + }], + ..PluginConfig::default() + }; -#[test] -fn test_load_plugin_config_files_returns_none_when_absent() { - let dir = tempfile::tempdir().unwrap(); - let missing = dir.path().join("missing.toml"); - assert!(load_plugin_config_files([missing]).unwrap().is_none()); + let mut merged = file_base; + layer_config(&mut merged, serde_json::to_value(code).unwrap()); + let typed: PluginConfig = serde_json::from_value(merged).unwrap(); + + // Typed defaults override the file base. + assert_eq!(typed.version, 1, "typed default version overrides the file"); + assert_eq!( + typed.policy.unknown_component, + UnsupportedBehavior::Warn, + "typed default policy overrides the file" + ); + let observability = &typed.components[0]; + assert_eq!(observability.kind, "observability"); + assert!( + observability.enabled, + "typed default enabled=true overrides the file's false" + ); + // The component config body merges: code's `mode` wins, the file's `output_directory` + // is inherited. + assert_eq!(observability.config["mode"], json!("overwrite")); + assert_eq!(observability.config["output_directory"], json!("/var/log")); + // A kind the code config does not declare is inherited from the file. + assert_eq!(typed.components[1].kind, "adaptive"); } From 5a62039f2fd5b9aa2c2388e54c44a1fc38766d92 Mon Sep 17 00:00:00 2001 From: Zhongxuan Wang Date: Fri, 5 Jun 2026 09:12:59 -0700 Subject: [PATCH 15/15] refactor: single-source plugins.toml location resolution in core Moves the plugins.toml location resolution (search-path list, project walk, user-config-dir) into core as the single source, exposed #[doc(hidden)] pub for cross-crate reuse, and rewires the gateway's config.rs helpers to forward to them. Removes the duplicated path/XDG logic (and the now-orphaned home_dir) so core and the gateway can't drift on which plugins.toml files exist. Behavior-preserving: --config scoping and the editor/doctor helpers are unchanged. Signed-off-by: Zhongxuan Wang --- crates/cli/src/config.rs | 43 +++++++-------------------------------- crates/core/src/plugin.rs | 27 +++++++++++++++--------- 2 files changed, 24 insertions(+), 46 deletions(-) diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index 5306c823..3fd2d5f1 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -619,18 +619,8 @@ fn implicit_plugin_config_paths( cwd: Option<&std::path::Path>, user_config_dir: Option, ) -> Vec { - // Ordered from lowest to highest precedence. User-level plugin config intentionally loads last - // so an operator can override project-local plugin defaults without editing the checkout. - let mut paths = vec![PathBuf::from("/etc/nemo-relay").join(PLUGINS_TOML)]; - if let Some(cwd) = cwd - && let Some(project) = find_project_plugin_config(cwd) - { - paths.push(project); - } - if let Some(user) = user_config_dir { - paths.push(user.join(PLUGINS_TOML)); - } - paths + // The search-path logic lives in core; the gateway shares it so discovery stays identical. + nemo_relay::plugin::default_plugin_config_paths(cwd, user_config_dir) } // Walks upward from the current directory and returns the nearest project-local gateway config. @@ -645,15 +635,9 @@ fn find_project_config(start: &std::path::Path) -> Option { None } -// Walks upward from the current directory and returns the nearest project-local plugin config. +// The project-walk lives in core; the gateway shares it so discovery stays identical. fn find_project_plugin_config(start: &std::path::Path) -> Option { - for ancestor in start.ancestors() { - let path = ancestor.join(".nemo-relay").join(PLUGINS_TOML); - if path.exists() { - return Some(path); - } - } - None + nemo_relay::plugin::nearest_project_plugin_config(start) } pub(crate) fn user_plugin_config_path() -> Option { @@ -679,15 +663,10 @@ fn user_config_path() -> Option { user_config_dir().map(|dir| dir.join("config.toml")) } -/// Resolves the nemo-relay user config DIRECTORY (without trailing filename) using the same XDG -/// rules as `user_config_path`. Exposed so wizard/doctor code paths that write to or display -/// the global location stay in sync with the loader — without this, hard-coded -/// `$HOME/.config/nemo-relay` references silently ignore `$XDG_CONFIG_HOME`. +/// Resolves the nemo-relay user config DIRECTORY (without trailing filename). Delegates to core's +/// resolver so the gateway, the editor, and the plugin runtime agree on the location. pub(crate) fn user_config_dir() -> Option { - if let Some(base) = std::env::var_os("XDG_CONFIG_HOME") { - return Some(PathBuf::from(base).join("nemo-relay")); - } - home_dir().map(|home| home.join(".config/nemo-relay")) + nemo_relay::plugin::user_config_dir() } // Applies the typed TOML config model to the resolved runtime config. Missing sections and fields @@ -880,14 +859,6 @@ fn parse_json_option(name: &str, value: &str) -> Result { .map_err(|error| CliError::Config(format!("invalid {name}: {error}"))) } -// Resolves a cross-platform home directory from environment only. The gateway avoids extra OS -// lookups here so tests can control install/config locations by setting env variables. -fn home_dir() -> Option { - std::env::var_os("HOME") - .or_else(|| std::env::var_os("USERPROFILE")) - .map(PathBuf::from) -} - /// Reads a non-empty UTF-8 header value as an owned string. /// /// Invalid header bytes and empty strings are treated as absent so callers can preserve their diff --git a/crates/core/src/plugin.rs b/crates/core/src/plugin.rs index 3ba14dbd..ac5c6ea8 100644 --- a/crates/core/src/plugin.rs +++ b/crates/core/src/plugin.rs @@ -1063,7 +1063,9 @@ pub async fn initialize_plugins(config: PluginConfig) -> Result { /// Resolves the default `plugins.toml` layering into one JSON document, or an /// empty object when no plugin file exists. fn resolve_default_file_plugin_config() -> Result { - Ok(load_plugin_config_files(default_plugin_config_paths())? + let paths = + default_plugin_config_paths(std::env::current_dir().ok().as_deref(), user_config_dir()); + Ok(load_plugin_config_files(paths)? .map(|(value, _sources)| value) .unwrap_or_else(|| Json::Object(Map::new()))) } @@ -1125,22 +1127,26 @@ fn validate_unique_component_kinds(path: &Path, document: &Json) -> Result<()> { } /// Default `plugins.toml` search path (lowest precedence first): system, nearest -/// project file, then user file — mirroring the gateway's discovery. -fn default_plugin_config_paths() -> Vec { +/// project file, then user file — mirroring the gateway's discovery. `pub` only +/// for cross-crate reuse by the gateway. +#[doc(hidden)] +pub fn default_plugin_config_paths(cwd: Option<&Path>, user_dir: Option) -> Vec { let mut paths = vec![PathBuf::from("/etc/nemo-relay/plugins.toml")]; - if let Ok(cwd) = std::env::current_dir() - && let Some(project) = nearest_project_plugin_config(&cwd) + if let Some(cwd) = cwd + && let Some(project) = nearest_project_plugin_config(cwd) { paths.push(project); } - if let Some(dir) = user_config_dir() { + if let Some(dir) = user_dir { paths.push(dir.join("plugins.toml")); } paths } -/// Walks upward from `start` for the nearest `.nemo-relay/plugins.toml`. -fn nearest_project_plugin_config(start: &Path) -> Option { +/// Walks upward from `start` for the nearest `.nemo-relay/plugins.toml`. `pub` +/// only for cross-crate reuse by the gateway. +#[doc(hidden)] +pub fn nearest_project_plugin_config(start: &Path) -> Option { start .ancestors() .map(|ancestor| ancestor.join(".nemo-relay").join("plugins.toml")) @@ -1148,8 +1154,9 @@ fn nearest_project_plugin_config(start: &Path) -> Option { } /// Resolves the nemo-relay user config directory from `XDG_CONFIG_HOME`, then -/// `HOME`/`USERPROFILE`. -fn user_config_dir() -> Option { +/// `HOME`/`USERPROFILE`. `pub` only for cross-crate reuse by the gateway. +#[doc(hidden)] +pub fn user_config_dir() -> Option { if let Some(base) = std::env::var_os("XDG_CONFIG_HOME") { return Some(PathBuf::from(base).join("nemo-relay")); }