From ebe682141ee7b8f829e32da1e5a372e12ad70f7d Mon Sep 17 00:00:00 2001 From: Zhongxuan Wang Date: Mon, 1 Jun 2026 16:00:43 -0700 Subject: [PATCH 1/6] feat: layer code-driven plugin config Signed-off-by: Zhongxuan Wang --- crates/cli/src/config.rs | 58 +++++-- crates/cli/src/doctor.rs | 12 +- crates/cli/src/launcher.rs | 6 + crates/cli/src/server.rs | 20 ++- crates/cli/tests/coverage/config_tests.rs | 152 +++++++++++++++++- crates/cli/tests/coverage/doctor_tests.rs | 24 +++ crates/cli/tests/coverage/gateway_tests.rs | 4 + crates/cli/tests/coverage/server_tests.rs | 5 + crates/cli/tests/coverage/session_tests.rs | 15 ++ crates/core/src/plugin.rs | 81 ++++++++++ crates/core/tests/unit/plugin_tests.rs | 118 ++++++++++++++ crates/ffi/nemo_relay.h | 11 ++ crates/ffi/src/api/mod.rs | 3 +- crates/ffi/src/api/plugin.rs | 42 ++++- crates/ffi/tests/unit/api/plugin_tests.rs | 17 ++ crates/node/plugin.d.ts | 13 ++ crates/node/plugin.js | 17 ++ crates/node/src/api/mod.rs | 6 + crates/node/tests/plugin_tests.mjs | 13 ++ crates/python/src/py_plugin.rs | 16 +- crates/wasm/src/api/mod.rs | 13 ++ crates/wasm/tests-js/plugin_tests.mjs | 6 + crates/wasm/wrappers/esm/index.js | 1 + crates/wasm/wrappers/esm/plugin.d.ts | 13 ++ crates/wasm/wrappers/esm/plugin.js | 17 ++ crates/wasm/wrappers/nodejs/plugin.js | 18 +++ .../plugin-configuration-files.mdx | 68 ++++++-- docs/build-plugins/register-behavior.mdx | 58 +++++-- go/nemo_relay/plugin.go | 43 ++++- go/nemo_relay/plugin_gap_test.go | 22 ++- python/nemo_relay/_native.pyi | 12 ++ python/nemo_relay/plugin.py | 27 +++- python/nemo_relay/plugin.pyi | 1 + python/tests/test_plugin_config.py | 10 ++ skills/nemo-relay-build-plugin/SKILL.md | 7 + .../nemo-relay-tune-adaptive-config/SKILL.md | 3 + 36 files changed, 892 insertions(+), 60 deletions(-) create mode 100644 crates/node/tests/plugin_tests.mjs create mode 100644 python/tests/test_plugin_config.py diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index 2307065f..e4dfca71 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::layer_plugin_config; use serde::Deserialize; use serde_json::Value; @@ -222,6 +223,7 @@ pub(crate) struct GatewayConfig { pub(crate) anthropic_base_url: String, pub(crate) metadata: Option, pub(crate) plugin_config: Option, + pub(crate) plugin_config_source: Option, } #[derive(Debug, Clone, Args)] @@ -312,8 +314,14 @@ impl GatewayConfig { pub(crate) fn session_config_from_headers(&self, headers: &HeaderMap) -> SessionConfig { let metadata = header_json(headers, "x-nemo-relay-session-metadata").or_else(|| self.metadata.clone()); - let plugin_config = header_json(headers, "x-nemo-relay-plugin-config") - .or_else(|| self.plugin_config.clone()); + let plugin_config = match ( + self.plugin_config.clone(), + header_json(headers, "x-nemo-relay-plugin-config"), + ) { + (Some(base), Some(overlay)) => Some(layer_plugin_config(base, overlay)), + (None, Some(overlay)) => Some(overlay), + (base, None) => base, + }; let profile = header_string(headers, "x-nemo-relay-config-profile"); let gateway_mode = header_string(headers, "x-nemo-relay-gateway-mode"); SessionConfig { @@ -423,6 +431,7 @@ impl Default for GatewayConfig { anthropic_base_url: "https://api.anthropic.com".into(), metadata: None, plugin_config: None, + plugin_config_source: None, } } } @@ -568,6 +577,9 @@ fn load_shared_config(explicit: Option<&PathBuf>) -> Result Result<(), CliError> { - if config.plugin_config.is_some() { - return Err(CliError::Config( - "plugin config is defined by both --plugin-config and file configuration; choose one source".into(), - )); - } - config.plugin_config = Some(parse_json_option("plugin config", value)?); + apply_code_plugin_config_layer( + config, + parse_json_option("plugin config", value)?, + "--plugin-config", + ); Ok(()) } +fn apply_code_plugin_config_layer(config: &mut GatewayConfig, value: Value, source: &str) { + match config.plugin_config.take() { + Some(base) => { + config.plugin_config = Some(layer_plugin_config(base, value)); + let base_source = config + .plugin_config_source + .take() + .unwrap_or_else(|| "existing plugin config".into()); + config.plugin_config_source = Some(format!("{base_source} overlaid by {source}")); + } + None => { + config.plugin_config = Some(value); + config.plugin_config_source = Some(source.into()); + } + } +} + // Applies configured agent commands and Cursor's temporary-hook behavior. Cursor's // `patch_restore_hooks` flag is intentionally tri-state in file config so omitted values preserve // the safe default while explicit `false` disables temporary hook mutation. @@ -879,6 +908,9 @@ fn merge_plugin_toml(left: &mut toml::Value, right: toml::Value) { } } +// Mirrors the runtime layering merge in `nemo_relay::plugin` +// (`merge_plugin_components` over `serde_json::Value`). Keep the two in sync if +// the by-`kind` component merge rule changes. fn merge_plugin_components(left: &mut toml::Value, right: toml::Value) { let toml::Value::Array(left_components) = left else { *left = right; @@ -964,6 +996,14 @@ fn legacy_observability_sections(value: &toml::Value) -> Vec<&'static str> { sections } +fn config_toml_plugin_source(path: &Path) -> String { + format!("[plugins].config in {}", path.display()) +} + +fn plugin_toml_source(paths: &[PathBuf]) -> String { + format!("plugins.toml {}", format_paths(paths)) +} + fn format_paths(paths: &[PathBuf]) -> String { paths .iter() diff --git a/crates/cli/src/doctor.rs b/crates/cli/src/doctor.rs index 2d93cc94..1243d14f 100644 --- a/crates/cli/src/doctor.rs +++ b/crates/cli/src/doctor.rs @@ -577,10 +577,14 @@ async fn collect_observability(gateway: &GatewayConfig) -> Vec { checks.push(Check { name: "Plugins", status: Status::Info, - details: "plugins.toml not configured".into(), + details: "plugin config not configured".into(), }); return checks; }; + let source = gateway + .plugin_config_source + .as_deref() + .unwrap_or("plugin config"); let plugin_config = match serde_json::from_value::(plugin_value.clone()) { Ok(config) => config, @@ -588,7 +592,7 @@ async fn collect_observability(gateway: &GatewayConfig) -> Vec { checks.push(Check { name: "Plugins", status: Status::Fail, - details: format!("invalid plugin config: {err}"), + details: format!("invalid plugin config from {source}: {err}"), }); return checks; } @@ -606,7 +610,7 @@ async fn collect_observability(gateway: &GatewayConfig) -> Vec { checks.push(Check { name: "Plugins", status: Status::Pass, - details: "validation passed".into(), + details: format!("validation passed from {source}"), }); } else { for diagnostic in report.diagnostics { @@ -617,7 +621,7 @@ async fn collect_observability(gateway: &GatewayConfig) -> Vec { } else { Status::Warn }, - details: format!("{}: {}", diagnostic.code, diagnostic.message), + details: format!("{source}: {}: {}", diagnostic.code, diagnostic.message), }); } } diff --git a/crates/cli/src/launcher.rs b/crates/cli/src/launcher.rs index b9b3048c..2c22f0a4 100644 --- a/crates/cli/src/launcher.rs +++ b/crates/cli/src/launcher.rs @@ -538,6 +538,9 @@ impl PreparedRun { )); } } + if let Some(source) = &resolved.gateway.plugin_config_source { + lines.push(format!(" Plugins {source}")); + } if !self.notes.is_empty() { lines.push(String::new()); for note in &self.notes { @@ -592,6 +595,9 @@ impl PreparedRun { if let Some(cursor) = &self.cursor_restore { println!("cursor_hooks = {}", cursor.path.display()); } + if let Some(source) = &resolved.gateway.plugin_config_source { + println!("plugin_config_source = {source}"); + } for note in &self.notes { println!("note = {note}"); } diff --git a/crates/cli/src/server.rs b/crates/cli/src/server.rs index fef92e1d..4ddf1af2 100644 --- a/crates/cli/src/server.rs +++ b/crates/cli/src/server.rs @@ -66,7 +66,11 @@ pub(crate) async fn serve_listener( config: GatewayConfig, shutdown: Option>, ) -> Result<(), CliError> { - let plugin_activation = PluginActivation::initialize(config.plugin_config.clone()).await?; + let plugin_activation = PluginActivation::initialize( + config.plugin_config.clone(), + config.plugin_config_source.as_deref(), + ) + .await?; let state = AppState::new(config); let sessions = state.sessions.clone(); let app = router_with_state(state); @@ -150,18 +154,20 @@ struct PluginActivation { } impl PluginActivation { - async fn initialize(config: Option) -> Result { + async fn initialize(config: Option, source: Option<&str>) -> Result { let Some(config) = config else { return Ok(Self { active: false }); }; + let source = source.unwrap_or("plugin config"); register_adaptive_component().map_err(|error| { CliError::Config(format!("adaptive plugin registration failed: {error}")) })?; - let plugin_config: PluginConfig = serde_json::from_value(config) - .map_err(|error| CliError::Config(format!("invalid plugin config: {error}")))?; - initialize_plugins(plugin_config) - .await - .map_err(|error| CliError::Config(format!("plugin activation failed: {error}")))?; + let plugin_config: PluginConfig = serde_json::from_value(config).map_err(|error| { + CliError::Config(format!("invalid plugin config from {source}: {error}")) + })?; + initialize_plugins(plugin_config).await.map_err(|error| { + CliError::Config(format!("plugin activation failed for {source}: {error}")) + })?; Ok(Self { active: true }) } diff --git a/crates/cli/tests/coverage/config_tests.rs b/crates/cli/tests/coverage/config_tests.rs index d7426da4..7c44a36e 100644 --- a/crates/cli/tests/coverage/config_tests.rs +++ b/crates/cli/tests/coverage/config_tests.rs @@ -13,6 +13,7 @@ fn config() -> GatewayConfig { anthropic_base_url: "http://anthropic".into(), metadata: None, plugin_config: None, + plugin_config_source: None, } } @@ -63,6 +64,51 @@ fn session_config_uses_defaults_and_ignores_bad_json() { assert_eq!(header_string(&headers, "x-empty"), None); } +#[test] +fn session_config_layers_header_plugin_config_over_gateway_config() { + let mut gateway = config(); + gateway.plugin_config = Some(json!({ + "version": 1, + "components": [{ + "kind": "observability", + "enabled": false, + "config": { + "atof": { + "enabled": true, + "filename": "gateway.jsonl" + } + } + }] + })); + let mut headers = HeaderMap::new(); + headers.insert( + "x-nemo-relay-plugin-config", + HeaderValue::from_static( + r#"{"components":[{"kind":"observability","config":{"atof":{"mode":"overwrite"}}}]}"#, + ), + ); + + let session = gateway.session_config_from_headers(&headers); + + assert_eq!( + session.plugin_config, + Some(json!({ + "version": 1, + "components": [{ + "kind": "observability", + "enabled": false, + "config": { + "atof": { + "enabled": true, + "filename": "gateway.jsonl", + "mode": "overwrite" + } + } + }] + })) + ); +} + #[test] fn agent_and_gateway_mode_arguments_are_stable() { assert_eq!(CodingAgent::ClaudeCode.hook_path(), "/hooks/claude-code"); @@ -261,6 +307,9 @@ mode = "overwrite" ] })) ); + let source = resolved.gateway.plugin_config_source.as_deref().unwrap(); + assert!(source.contains("plugins.toml")); + assert!(source.contains(&temp.path().join("plugins.toml").display().to_string())); } #[test] @@ -522,27 +571,118 @@ config = { version = 1, components = [] } } #[test] -fn cli_plugin_config_conflicts_with_file_plugin_config() { +fn cli_plugin_config_layers_over_plugins_toml() { let temp = tempfile::tempdir().unwrap(); let config_path = temp.path().join("config.toml"); std::fs::write(&config_path, "").unwrap(); - std::fs::write(temp.path().join("plugins.toml"), "version = 1\n").unwrap(); + std::fs::write( + temp.path().join("plugins.toml"), + r#" +version = 1 + +[[components]] +kind = "observability" +enabled = false + +[components.config] +version = 1 + +[components.config.atof] +enabled = true +filename = "file.jsonl" +"#, + ) + .unwrap(); let command = RunCommand { agent: Some(CodingAgent::Codex), config: Some(config_path), openai_base_url: None, anthropic_base_url: None, session_metadata: None, - plugin_config: Some(r#"{"version":1,"components":[]}"#.into()), + plugin_config: Some( + r#"{"components":[{"kind":"observability","config":{"atof":{"mode":"overwrite"}}}]}"# + .into(), + ), dry_run: false, print: false, command: vec!["codex".into()], }; - let error = resolve_run_config(&command, None).unwrap_err().to_string(); + let resolved = resolve_run_config(&command, None).unwrap(); - assert!(error.contains("--plugin-config")); - assert!(error.contains("file configuration")); + assert_eq!( + resolved.gateway.plugin_config, + Some(json!({ + "version": 1, + "components": [{ + "kind": "observability", + "enabled": false, + "config": { + "version": 1, + "atof": { + "enabled": true, + "filename": "file.jsonl", + "mode": "overwrite" + } + } + }] + })) + ); + let source = resolved.gateway.plugin_config_source.as_deref().unwrap(); + assert!(source.contains("plugins.toml")); + assert!(source.contains("overlaid by --plugin-config")); +} + +#[test] +fn cli_plugin_config_layers_over_inline_config_toml_plugin_config() { + let temp = tempfile::tempdir().unwrap(); + let config_path = temp.path().join("config.toml"); + std::fs::write( + &config_path, + r#" +[plugins] +config = { version = 1, components = [{ kind = "observability", enabled = false, config = { atof = { enabled = true, filename = "inline.jsonl" } } }] } +"#, + ) + .unwrap(); + let command = RunCommand { + agent: Some(CodingAgent::Codex), + config: Some(config_path.clone()), + openai_base_url: None, + anthropic_base_url: None, + session_metadata: None, + plugin_config: Some( + r#"{"components":[{"kind":"observability","config":{"atof":{"mode":"append"}}}]}"# + .into(), + ), + dry_run: false, + print: false, + command: vec!["codex".into()], + }; + + let resolved = resolve_run_config(&command, None).unwrap(); + + assert_eq!( + resolved.gateway.plugin_config, + Some(json!({ + "version": 1, + "components": [{ + "kind": "observability", + "enabled": false, + "config": { + "atof": { + "enabled": true, + "filename": "inline.jsonl", + "mode": "append" + } + } + }] + })) + ); + let source = resolved.gateway.plugin_config_source.as_deref().unwrap(); + assert!(source.contains("[plugins].config")); + assert!(source.contains(&config_path.display().to_string())); + assert!(source.contains("overlaid by --plugin-config")); } #[test] diff --git a/crates/cli/tests/coverage/doctor_tests.rs b/crates/cli/tests/coverage/doctor_tests.rs index 6cfcabdd..7a74b365 100644 --- a/crates/cli/tests/coverage/doctor_tests.rs +++ b/crates/cli/tests/coverage/doctor_tests.rs @@ -657,6 +657,30 @@ async fn collect_observability_registers_adaptive_before_validation() { ); } +#[tokio::test] +async fn collect_observability_reports_plugin_config_source() { + let gateway = GatewayConfig { + plugin_config: Some(serde_json::json!({ + "version": 1, + "components": [] + })), + plugin_config_source: Some( + "plugins.toml /tmp/plugins.toml overlaid by --plugin-config".into(), + ), + ..GatewayConfig::default() + }; + + let checks = collect_observability(&gateway).await; + + let plugins = checks + .iter() + .find(|check| check.name == "Plugins") + .expect("plugin validation check"); + assert_eq!(plugins.status, Status::Pass); + assert!(plugins.details.contains("plugins.toml /tmp/plugins.toml")); + assert!(plugins.details.contains("--plugin-config")); +} + #[test] fn format_agents_human_lists_supported_and_separates_detected() { let agents = vec![ diff --git a/crates/cli/tests/coverage/gateway_tests.rs b/crates/cli/tests/coverage/gateway_tests.rs index 748b389d..7471acbc 100644 --- a/crates/cli/tests/coverage/gateway_tests.rs +++ b/crates/cli/tests/coverage/gateway_tests.rs @@ -111,6 +111,7 @@ fn provider_routes_preserve_path_query_and_choose_upstream() { anthropic_base_url: "http://anthropic/".into(), metadata: None, plugin_config: None, + plugin_config_source: None, }; assert_eq!( @@ -139,6 +140,7 @@ fn openai_upstream_url_accepts_origin_or_v1_base() { anthropic_base_url: "http://anthropic".into(), metadata: None, plugin_config: None, + plugin_config_source: None, }; assert_eq!( @@ -721,6 +723,7 @@ async fn passthrough_rejects_unsupported_provider_path_directly() { anthropic_base_url: "http://anthropic".into(), metadata: None, plugin_config: None, + plugin_config_source: None, }; let state = AppState { config: config.clone(), @@ -747,6 +750,7 @@ async fn models_rejects_non_get_requests_directly() { anthropic_base_url: "http://anthropic".into(), metadata: None, plugin_config: None, + plugin_config_source: None, }; let state = AppState { config: config.clone(), diff --git a/crates/cli/tests/coverage/server_tests.rs b/crates/cli/tests/coverage/server_tests.rs index 12caff1f..4f25bff0 100644 --- a/crates/cli/tests/coverage/server_tests.rs +++ b/crates/cli/tests/coverage/server_tests.rs @@ -102,6 +102,7 @@ fn test_config() -> GatewayConfig { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, + plugin_config_source: None, } } @@ -435,6 +436,8 @@ async fn serve_listener_rejects_invalid_plugin_config() { } ] })); + config.plugin_config_source = + Some("plugins.toml /tmp/plugins.toml overlaid by --plugin-config".into()); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let (_shutdown_tx, shutdown_rx) = oneshot::channel(); let error = serve_listener(listener, config, Some(shutdown_rx)) @@ -442,6 +445,8 @@ async fn serve_listener_rejects_invalid_plugin_config() { .unwrap_err(); assert!(error.to_string().contains("ATOF mode")); + assert!(error.to_string().contains("plugins.toml /tmp/plugins.toml")); + assert!(error.to_string().contains("--plugin-config")); assert!(nemo_relay::plugin::active_plugin_report().is_none()); } diff --git a/crates/cli/tests/coverage/session_tests.rs b/crates/cli/tests/coverage/session_tests.rs index 6a1f9be7..ad8910ad 100644 --- a/crates/cli/tests/coverage/session_tests.rs +++ b/crates/cli/tests/coverage/session_tests.rs @@ -103,6 +103,7 @@ async fn nests_agent_subagent_and_tool_lifecycle() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, + plugin_config_source: None, }; let manager = SessionManager::new(config); let headers = HeaderMap::new(); @@ -1238,6 +1239,7 @@ async fn writes_atif_on_session_end_from_plugin_config() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, + plugin_config_source: None, }; let manager = SessionManager::new(config); let mut headers = HeaderMap::new(); @@ -1307,6 +1309,7 @@ async fn duplicate_agent_end_does_not_overwrite_atif_with_empty_session() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, + plugin_config_source: None, }; let manager = SessionManager::new(config); let headers = HeaderMap::new(); @@ -1385,6 +1388,7 @@ async fn writes_hermes_api_hook_usage_to_atif_metrics() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, + plugin_config_source: None, }; let manager = SessionManager::new(config); let headers = HeaderMap::new(); @@ -1815,6 +1819,7 @@ async fn handles_out_of_order_subagent_and_tool_end_events() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, + plugin_config_source: None, }; let manager = SessionManager::new(config); let headers = HeaderMap::new(); @@ -1890,6 +1895,7 @@ async fn out_of_order_started_subagent_end_does_not_leak_scope() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, + plugin_config_source: None, }; let manager = SessionManager::new(config); let headers = HeaderMap::new(); @@ -1961,6 +1967,7 @@ async fn agent_end_closes_nested_active_subagents_lifo() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, + plugin_config_source: None, }; let manager = SessionManager::new(config); let headers = HeaderMap::new(); @@ -2016,6 +2023,7 @@ async fn llm_lifecycle_starts_implicit_gateway_session() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, + plugin_config_source: None, }; let manager = SessionManager::new(config); let active = manager @@ -2061,6 +2069,7 @@ async fn llm_lifecycle_uses_single_active_hook_session_when_header_is_missing() anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, + plugin_config_source: None, }; let manager = SessionManager::new(config); manager @@ -2117,6 +2126,7 @@ async fn single_pending_llm_hint_claims_next_gateway_llm() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, + plugin_config_source: None, }; let manager = SessionManager::new(config); manager @@ -2213,6 +2223,7 @@ async fn multiple_llm_hints_resolve_by_generation_id() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, + plugin_config_source: None, }; let manager = SessionManager::new(config); manager @@ -2327,6 +2338,7 @@ async fn ambiguous_llm_hints_fall_back_to_agent_scope() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, + plugin_config_source: None, }; let manager = SessionManager::new(config); manager @@ -2419,6 +2431,7 @@ async fn no_active_hint_reuses_last_llm_owner() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, + plugin_config_source: None, }; let manager = SessionManager::new(config); manager @@ -4077,6 +4090,7 @@ fn session_test_config() -> GatewayConfig { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, + plugin_config_source: None, } } @@ -4090,6 +4104,7 @@ async fn turn_ended_is_noop_without_active_turn_scope() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, + plugin_config_source: None, }; let manager = SessionManager::new(config); manager diff --git a/crates/core/src/plugin.rs b/crates/core/src/plugin.rs index f9731a3e..1a0596e1 100644 --- a/crates/core/src/plugin.rs +++ b/crates/core/src/plugin.rs @@ -126,6 +126,87 @@ impl PluginComponentSpec { } } +/// Layers one raw plugin configuration document over another. +/// +/// The plugin document is merged as JSON so callers can preserve omitted fields +/// before deserializing into [`PluginConfig`]. Objects merge recursively, arrays +/// and scalar values are replaced by the higher-precedence layer, and the +/// top-level `components` array is matched by component `kind`. +pub fn layer_plugin_config(base: Json, overlay: Json) -> Json { + let mut merged = base; + merge_plugin_config_layer(&mut merged, overlay); + merged +} + +fn merge_plugin_config_layer(base: &mut Json, overlay: Json) { + match (base, overlay) { + (Json::Object(base), Json::Object(overlay)) => { + for (key, value) in overlay { + match (key.as_str(), base.get_mut(&key)) { + ("components", Some(existing)) => merge_plugin_components(existing, value), + (_, Some(existing)) => merge_json_value(existing, value), + _ => { + base.insert(key, value); + } + } + } + } + (base, overlay) => *base = overlay, + } +} + +fn merge_json_value(base: &mut Json, overlay: Json) { + match (base, overlay) { + (Json::Object(base), Json::Object(overlay)) => { + for (key, value) in overlay { + match base.get_mut(&key) { + Some(existing) => merge_json_value(existing, value), + None => { + base.insert(key, value); + } + } + } + } + (base, overlay) => *base = overlay, + } +} + +// Mirrors the file-time `plugins.toml` merge in +// `crates/cli/src/config.rs::merge_plugin_components` (over `toml::Value`). Keep +// the two in sync if the by-`kind` component merge rule changes. +fn merge_plugin_components(base: &mut Json, overlay: Json) { + let Json::Array(base_components) = base else { + *base = overlay; + return; + }; + let Json::Array(overlay_components) = overlay else { + *base = overlay; + return; + }; + + for component in overlay_components { + let Some(kind) = json_component_kind(&component).map(str::to_owned) else { + base_components.push(component); + continue; + }; + if let Some(existing) = base_components + .iter_mut() + .find(|candidate| json_component_kind(candidate) == Some(kind.as_str())) + { + merge_json_value(existing, component); + } else { + base_components.push(component); + } + } +} + +fn json_component_kind(component: &Json) -> Option<&str> { + component + .as_object() + .and_then(|object| object.get("kind")) + .and_then(Json::as_str) +} + /// Structured validation report. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] diff --git a/crates/core/tests/unit/plugin_tests.rs b/crates/core/tests/unit/plugin_tests.rs index e5eb7255..def2b135 100644 --- a/crates/core/tests/unit/plugin_tests.rs +++ b/crates/core/tests/unit/plugin_tests.rs @@ -528,6 +528,124 @@ fn test_plugin_config_defaults_debug_and_invalid_config_messages() { reset_global(); } +#[test] +fn test_layer_plugin_config_merges_by_kind_and_preserves_omissions() { + let base = json!({ + "version": 1, + "components": [ + { + "kind": "observability", + "enabled": false, + "config": { + "atof": { + "enabled": true, + "headers": ["base"], + "nested": { + "base": true + } + } + } + }, + { + "kind": "adaptive", + "enabled": true, + "config": { + "source": "file" + } + } + ], + "policy": { + "unknown_field": "warn" + } + }); + let overlay = json!({ + "components": [ + { + "kind": "observability", + "config": { + "atof": { + "headers": ["code"], + "nested": { + "code": true + } + }, + "atif": { + "enabled": true + } + } + }, + { + "kind": "custom", + "config": { + "source": "code" + } + } + ], + "policy": { + "unknown_field": "error" + } + }); + + let merged = layer_plugin_config(base, overlay); + + assert_eq!( + merged, + json!({ + "version": 1, + "components": [ + { + "kind": "observability", + "enabled": false, + "config": { + "atof": { + "enabled": true, + "headers": ["code"], + "nested": { + "base": true, + "code": true + } + }, + "atif": { + "enabled": true + } + } + }, + { + "kind": "adaptive", + "enabled": true, + "config": { + "source": "file" + } + }, + { + "kind": "custom", + "config": { + "source": "code" + } + } + ], + "policy": { + "unknown_field": "error" + } + }) + ); +} + +#[test] +fn test_layer_plugin_config_replaces_non_object_shapes() { + assert_eq!( + layer_plugin_config(json!({"components": []}), json!([])), + json!([]) + ); + assert_eq!( + layer_plugin_config( + json!({"components": [{"kind": "base"}]}), + json!({"components": "not-an-array"}) + ), + json!({"components": "not-an-array"}) + ); +} + #[test] fn test_plugin_helper_defaults_and_policy_diagnostics() { let _guard = lock_runtime_owner(); diff --git a/crates/ffi/nemo_relay.h b/crates/ffi/nemo_relay.h index a5d5fa3d..4ec6f67b 100644 --- a/crates/ffi/nemo_relay.h +++ b/crates/ffi/nemo_relay.h @@ -1113,6 +1113,17 @@ NemoRelayStatus nemo_relay_openinference_subscriber_force_flush(const struct Ffi */ NemoRelayStatus nemo_relay_openinference_subscriber_shutdown(const struct FfiOpenInferenceSubscriber *subscriber); +/** + * Layer one raw plugin config document over another and return the effective JSON document. + * + * # Safety + * `base_json` and `overlay_json` must be valid C strings and `out_json` must be a valid, + * non-null pointer. + */ +NemoRelayStatus nemo_relay_layer_plugin_config(const char *base_json, + const char *overlay_json, + char **out_json); + /** * Validate a generic plugin config document and return the diagnostics report as JSON. * diff --git a/crates/ffi/src/api/mod.rs b/crates/ffi/src/api/mod.rs index a1d6fffd..48d76ba4 100644 --- a/crates/ffi/src/api/mod.rs +++ b/crates/ffi/src/api/mod.rs @@ -58,7 +58,8 @@ use nemo_relay::error::Result as FlowResult; use nemo_relay::plugin::{ ConfigDiagnostic, DiagnosticLevel, Plugin, PluginConfig, PluginError, PluginRegistrationContext, active_plugin_report, clear_plugin_configuration, deregister_plugin, - initialize_plugins, list_plugin_kinds, register_plugin, validate_plugin_config, + initialize_plugins, layer_plugin_config, list_plugin_kinds, register_plugin, + validate_plugin_config, }; use nemo_relay_adaptive::plugin_component::register_adaptive_component; use tokio::runtime::Runtime; diff --git a/crates/ffi/src/api/plugin.rs b/crates/ffi/src/api/plugin.rs index ad795e49..c5d48d2c 100644 --- a/crates/ffi/src/api/plugin.rs +++ b/crates/ffi/src/api/plugin.rs @@ -9,13 +9,13 @@ use super::{ NemoRelayToolConditionalCb, NemoRelayToolExecInterceptCb, NemoRelayToolSanitizeCb, Pin, Plugin, PluginConfig, PluginError, PluginRegistrationContext, active_plugin_report, c_char, c_str_to_json, c_str_to_string, clear_last_error, clear_plugin_configuration, - deregister_plugin, initialize_plugins, json_to_c_string, last_error_message, list_plugin_kinds, - nemo_relay_string_free, register_adaptive_component, register_plugin, set_last_error, - status_from_plugin_error, tokio_runtime, validate_plugin_config, wrap_event_subscriber, - wrap_llm_conditional_fn, wrap_llm_exec_intercept_fn, wrap_llm_request_intercept_fn, - wrap_llm_response_fn, wrap_llm_sanitize_request_fn, wrap_llm_stream_exec_intercept_fn, - wrap_tool_conditional_fn, wrap_tool_exec_intercept_fn, wrap_tool_request_intercept_fn, - wrap_tool_sanitize_fn, + deregister_plugin, initialize_plugins, json_to_c_string, last_error_message, + layer_plugin_config, list_plugin_kinds, nemo_relay_string_free, register_adaptive_component, + register_plugin, set_last_error, status_from_plugin_error, tokio_runtime, + validate_plugin_config, wrap_event_subscriber, wrap_llm_conditional_fn, + wrap_llm_exec_intercept_fn, wrap_llm_request_intercept_fn, wrap_llm_response_fn, + wrap_llm_sanitize_request_fn, wrap_llm_stream_exec_intercept_fn, wrap_tool_conditional_fn, + wrap_tool_exec_intercept_fn, wrap_tool_request_intercept_fn, wrap_tool_sanitize_fn, }; struct FfiHostedPluginUserData { @@ -126,6 +126,34 @@ fn ensure_adaptive_component_registered() -> std::result::Result<(), NemoRelaySt register_adaptive_component().map_err(|err| status_from_plugin_error(&err)) } +/// Layer one raw plugin config document over another and return the effective JSON document. +/// +/// # Safety +/// `base_json` and `overlay_json` must be valid C strings and `out_json` must be a valid, +/// non-null pointer. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn nemo_relay_layer_plugin_config( + base_json: *const c_char, + overlay_json: *const c_char, + out_json: *mut *mut c_char, +) -> NemoRelayStatus { + clear_last_error(); + if out_json.is_null() { + set_last_error("out_json pointer is null"); + return NemoRelayStatus::NullPointer; + } + let base = match c_str_to_json(base_json) { + Some(value) => value, + None => return NemoRelayStatus::InvalidJson, + }; + let overlay = match c_str_to_json(overlay_json) { + Some(value) => value, + None => return NemoRelayStatus::InvalidJson, + }; + unsafe { *out_json = json_to_c_string(&layer_plugin_config(base, overlay)) }; + NemoRelayStatus::Ok +} + /// Validate a generic plugin config document and return the diagnostics report as JSON. /// /// # Safety diff --git a/crates/ffi/tests/unit/api/plugin_tests.rs b/crates/ffi/tests/unit/api/plugin_tests.rs index 2d37db84..dd591350 100644 --- a/crates/ffi/tests/unit/api/plugin_tests.rs +++ b/crates/ffi/tests/unit/api/plugin_tests.rs @@ -5,6 +5,23 @@ use super::*; +#[test] +fn test_ffi_layer_plugin_config_round_trips_merge() { + // Smoke test only: merge semantics are covered by the core crate. This + // verifies the FFI boundary forwards both documents and returns merged JSON. + let base = cstring(&json!({ "a": 1 }).to_string()); + let overlay = cstring(&json!({ "b": 2 }).to_string()); + + unsafe { + let mut out_json = ptr::null_mut(); + assert_eq!( + nemo_relay_layer_plugin_config(base.as_ptr(), overlay.as_ptr(), &mut out_json), + NemoRelayStatus::Ok + ); + assert_eq!(returned_json(out_json), json!({ "a": 1, "b": 2 })); + } +} + #[test] fn test_ffi_plugin_registration_validation_and_cleanup() { let _guard = TEST_MUTEX.lock().unwrap(); diff --git a/crates/node/plugin.d.ts b/crates/node/plugin.d.ts index 4fbb6be8..f45ccc4b 100644 --- a/crates/node/plugin.d.ts +++ b/crates/node/plugin.d.ts @@ -161,6 +161,19 @@ export declare function ComponentSpec( enabled?: boolean; }, ): ComponentSpec; +/** + * Layer one plugin configuration over another. + * + * Objects merge recursively, arrays and scalar values are replaced by the + * overlay, and top-level components merge by `kind`. + * + * @param base - Lower-precedence plugin config, usually loaded from files. + * @param overlay - Higher-precedence plugin config, usually built in code. + * @returns The effective raw plugin config document. + * @remarks Passing raw objects preserves omitted fields so they can inherit + * from the base config. + */ +export declare function layer(base: PluginConfig, overlay: PluginConfig): PluginConfig; /** * Validate a plugin configuration without activating it. * diff --git a/crates/node/plugin.js b/crates/node/plugin.js index a84c3212..3dd4d78a 100644 --- a/crates/node/plugin.js +++ b/crates/node/plugin.js @@ -48,6 +48,22 @@ function ComponentSpec(kind, config = {}, { enabled = true } = {}) { }; } +/** + * Layer one plugin configuration over another. + * + * Objects merge recursively, arrays and scalar values are replaced by the + * overlay, and top-level components merge by `kind`. + * + * @param {object} base - Lower-precedence plugin config, usually loaded from files. + * @param {object} overlay - Higher-precedence plugin config, usually built in code. + * @returns {object} The effective raw plugin config document. + * @remarks Passing raw objects preserves omitted fields so they can inherit + * from the base config. + */ +function layer(base, overlay) { + return lib.layerPluginConfig(base, overlay); +} + /** * Validate a plugin configuration without activating it. * @@ -159,6 +175,7 @@ function deregister(pluginKind) { module.exports = { defaultConfig, ComponentSpec, + layer, validate, initialize, clear, diff --git a/crates/node/src/api/mod.rs b/crates/node/src/api/mod.rs index ca1602ad..2e400a1a 100644 --- a/crates/node/src/api/mod.rs +++ b/crates/node/src/api/mod.rs @@ -3202,6 +3202,12 @@ pub fn validate_plugin_config(config: Json) -> napi::Result { .map_err(|e| napi::Error::from_reason(e.to_string())) } +/// Layer one raw plugin config document over another. +#[napi] +pub fn layer_plugin_config(base: Json, overlay: Json) -> Json { + nemo_relay::plugin::layer_plugin_config(base, overlay) +} + /// Register a plugin backed by JavaScript callbacks. /// /// `validate` receives `(pluginConfig)` and should return a diagnostics array. diff --git a/crates/node/tests/plugin_tests.mjs b/crates/node/tests/plugin_tests.mjs new file mode 100644 index 00000000..779887e2 --- /dev/null +++ b/crates/node/tests/plugin_tests.mjs @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import * as plugin from '../plugin.js'; + +test('layer forwards documents to core and returns merged JSON', () => { + // Smoke test only: merge semantics are covered by the core crate. This + // verifies the wrapper forwards both documents and returns merged JSON. + assert.deepEqual(plugin.layer({ a: 1 }, { b: 2 }), { a: 1, b: 2 }); +}); diff --git a/crates/python/src/py_plugin.rs b/crates/python/src/py_plugin.rs index d483375b..1bc89c7b 100644 --- a/crates/python/src/py_plugin.rs +++ b/crates/python/src/py_plugin.rs @@ -29,7 +29,8 @@ use nemo_relay::api::subscriber::{deregister_subscriber, register_subscriber}; use nemo_relay::plugin::{ ConfigDiagnostic, DiagnosticLevel, Plugin, PluginConfig, PluginError, PluginRegistration, PluginRegistrationContext, active_plugin_report, clear_plugin_configuration, deregister_plugin, - initialize_plugins, list_plugin_kinds, register_plugin, validate_plugin_config, + initialize_plugins, layer_plugin_config, list_plugin_kinds, register_plugin, + validate_plugin_config, }; use crate::convert::{json_to_py, py_to_json}; @@ -720,6 +721,18 @@ impl Plugin for PyPlugin { } } +#[pyfunction(name = "layer_plugin_config")] +#[pyo3(signature = (base: "object", overlay: "object") -> "object", text_signature = "(base: object, overlay: object) -> object")] +fn layer_plugin_config_py( + py: Python<'_>, + base: &Bound<'_, PyAny>, + overlay: &Bound<'_, PyAny>, +) -> PyResult> { + let base = py_to_json(base)?; + let overlay = py_to_json(overlay)?; + json_to_py(py, &layer_plugin_config(base, overlay)) +} + #[pyfunction(name = "validate_plugin_config")] #[pyo3(signature = (config: "object") -> "object", text_signature = "(config: object) -> object")] fn validate_plugin_config_py(py: Python<'_>, config: &Bound<'_, PyAny>) -> PyResult> { @@ -796,6 +809,7 @@ fn deregister_plugin_py(plugin_kind: &str) -> bool { pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; + m.add_function(wrap_pyfunction!(layer_plugin_config_py, m)?)?; m.add_function(wrap_pyfunction!(validate_plugin_config_py, m)?)?; m.add_function(wrap_pyfunction!(initialize_plugins_py, m)?)?; m.add_function(wrap_pyfunction!(clear_plugin_configuration_py, m)?)?; diff --git a/crates/wasm/src/api/mod.rs b/crates/wasm/src/api/mod.rs index 7b2e04ac..3ab33781 100644 --- a/crates/wasm/src/api/mod.rs +++ b/crates/wasm/src/api/mod.rs @@ -2164,6 +2164,19 @@ pub fn validate_plugin_config( .map_err(|e| JsValue::from_str(&e.to_string())) } +/// Layer one raw plugin config document over another. +#[wasm_bindgen(js_name = "layerPluginConfig", unchecked_return_type = "Json")] +pub fn layer_plugin_config( + #[wasm_bindgen(unchecked_param_type = "Json")] base: JsValue, + #[wasm_bindgen(unchecked_param_type = "Json")] overlay: JsValue, +) -> Result { + let base = serde_wasm_bindgen::from_value(base)?; + let overlay = serde_wasm_bindgen::from_value(overlay)?; + Ok(json_to_js(&nemo_relay::plugin::layer_plugin_config( + base, overlay, + ))) +} + #[derive(Clone)] #[wasm_bindgen(js_name = "PluginContext", skip_typescript)] /// Plugin registration context exposed to JavaScript plugins. diff --git a/crates/wasm/tests-js/plugin_tests.mjs b/crates/wasm/tests-js/plugin_tests.mjs index 1fcd33e6..52aacb6b 100644 --- a/crates/wasm/tests-js/plugin_tests.mjs +++ b/crates/wasm/tests-js/plugin_tests.mjs @@ -15,6 +15,12 @@ test('WebAssembly plugin wrappers expose default config', () => { }); }); +test('WebAssembly plugin wrappers layer config documents', () => { + // Smoke test only: merge semantics are covered by the core crate. This + // verifies the wrapper forwards both documents and returns merged JSON. + assert.deepEqual(plugin.layer({ a: 1 }, { b: 2 }), { a: 1, b: 2 }); +}); + test('WebAssembly plugin wrappers register and validate components', () => { const pluginKind = unique('wasm.wrapper.plugin'); const validatedConfigs = []; diff --git a/crates/wasm/wrappers/esm/index.js b/crates/wasm/wrappers/esm/index.js index cc982c96..c7a3f7e6 100644 --- a/crates/wasm/wrappers/esm/index.js +++ b/crates/wasm/wrappers/esm/index.js @@ -40,6 +40,7 @@ export { getHandle, getLastCallbackError, initializePlugins, + layerPluginConfig, listPluginKinds, llmCall, llmCallEnd, diff --git a/crates/wasm/wrappers/esm/plugin.d.ts b/crates/wasm/wrappers/esm/plugin.d.ts index b664e8d5..996740fa 100644 --- a/crates/wasm/wrappers/esm/plugin.d.ts +++ b/crates/wasm/wrappers/esm/plugin.d.ts @@ -159,6 +159,19 @@ export declare function ComponentSpec( enabled?: boolean; }, ): ComponentSpec; +/** + * Layer one plugin configuration over another. + * + * Objects merge recursively, arrays and scalar values are replaced by the + * overlay, and top-level components merge by `kind`. + * + * @param base - Lower-precedence plugin config, usually loaded from files. + * @param overlay - Higher-precedence plugin config, usually built in code. + * @returns The effective raw plugin config document. + * @remarks Passing raw objects preserves omitted fields so they can inherit + * from the base config. + */ +export declare function layer(base: PluginConfig, overlay: PluginConfig): PluginConfig; /** * Validate a plugin configuration without activating it. * diff --git a/crates/wasm/wrappers/esm/plugin.js b/crates/wasm/wrappers/esm/plugin.js index 6a248e97..37b4ad3a 100644 --- a/crates/wasm/wrappers/esm/plugin.js +++ b/crates/wasm/wrappers/esm/plugin.js @@ -3,6 +3,7 @@ import { validatePluginConfig, + layerPluginConfig, registerPlugin, deregisterPlugin, initializePlugins, @@ -50,6 +51,22 @@ export function ComponentSpec(kind, config = {}, { enabled = true } = {}) { }; } +/** + * Layer one plugin configuration over another. + * + * Objects merge recursively, arrays and scalar values are replaced by the + * overlay, and top-level components merge by `kind`. + * + * @param {object} base - Lower-precedence plugin config, usually loaded from files. + * @param {object} overlay - Higher-precedence plugin config, usually built in code. + * @returns {object} The effective raw plugin config document. + * @remarks Passing raw objects preserves omitted fields so they can inherit + * from the base config. + */ +export function layer(base, overlay) { + return layerPluginConfig(base, overlay); +} + /** * Validate a plugin configuration without activating it. * diff --git a/crates/wasm/wrappers/nodejs/plugin.js b/crates/wasm/wrappers/nodejs/plugin.js index 0f5e54f0..f3dcafd2 100644 --- a/crates/wasm/wrappers/nodejs/plugin.js +++ b/crates/wasm/wrappers/nodejs/plugin.js @@ -5,6 +5,7 @@ const { validatePluginConfig, + layerPluginConfig, registerPlugin, deregisterPlugin, initializePlugins, @@ -52,6 +53,22 @@ function ComponentSpec(kind, config = {}, { enabled = true } = {}) { }; } +/** + * Layer one plugin configuration over another. + * + * Objects merge recursively, arrays and scalar values are replaced by the + * overlay, and top-level components merge by `kind`. + * + * @param {object} base - Lower-precedence plugin config, usually loaded from files. + * @param {object} overlay - Higher-precedence plugin config, usually built in code. + * @returns {object} The effective raw plugin config document. + * @remarks Passing raw objects preserves omitted fields so they can inherit + * from the base config. + */ +function layer(base, overlay) { + return layerPluginConfig(base, overlay); +} + /** * Validate a plugin configuration without activating it. * @@ -161,6 +178,7 @@ function deregister(pluginKind) { exports.defaultConfig = defaultConfig; exports.ComponentSpec = ComponentSpec; +exports.layer = layer; exports.validate = validate; exports.initialize = initialize; exports.clear = clear; diff --git a/docs/build-plugins/plugin-configuration-files.mdx b/docs/build-plugins/plugin-configuration-files.mdx index 866d80d9..b6643ea7 100644 --- a/docs/build-plugins/plugin-configuration-files.mdx +++ b/docs/build-plugins/plugin-configuration-files.mdx @@ -12,9 +12,10 @@ startup. The file contains the same generic plugin configuration document used by the Rust, Python, and Node.js plugin APIs, but encoded as TOML at the file root. -This page documents file discovery, precedence, merge behavior, editor behavior, -and conflict rules for the CLI gateway. Component-specific fields are documented -in the guide for each plugin component. +This page documents file discovery, precedence, code-driven overlays, merge +behavior, editor behavior, and conflict rules for the CLI gateway. +Component-specific fields are documented in the guide for each plugin +component. NeMo Relay plugin configuration keys use `snake_case` regardless of language or @@ -70,17 +71,32 @@ The gateway reads only files named `plugins.toml`. ## Discovery -The gateway can receive plugin configuration from three source classes: +The gateway can receive plugin configuration from file-backed sources and +code-driven overlay sources: | Source | Use case | |---|---| | `plugins.toml` | Normal operator- and project-managed gateway plugin configuration. | | `[plugins].config` in `config.toml` | Inline gateway config for small or generated setups. | -| `--plugin-config ''` | CI, tests, wrappers, or one-off automation. | +| `--plugin-config ''` | CI, tests, wrappers, or one-off automation that should layer over file config. | +| Runtime or hook-provided plugin config | Code-driven host configuration that should layer over the process config. | -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 -`plugins.toml` and `[plugins].config` are both present. +Use only one file-backed source class for a given gateway run. The gateway +still fails clearly if `plugins.toml` and `[plugins].config` are both present. +Code-driven sources such as `--plugin-config` are overlays and can be combined +with either file-backed source. + +The effective source order is: + +1. The selected file-backed source: + - discovered `plugins.toml` files, merged from system to project to user; or + - one inline `[plugins].config` block from `config.toml`. +2. Code-driven overlays, such as `--plugin-config`. + +For transparent `nemo-relay run`, a run-subcommand `--plugin-config` replaces an +inherited top-level `--plugin-config` before layering over the file-backed +config. This preserves the existing "run flag wins over top-level flag" +behavior while still allowing either flag to overlay `plugins.toml`. 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 @@ -157,6 +173,11 @@ 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. +After the file-backed config is resolved, code-driven config uses the same merge +rules at higher precedence. A code-driven component with the same `kind` layers +over the file-backed component; a code-driven component with a new `kind` is +appended to the effective component list. + TOML tables merge recursively: ```toml @@ -183,6 +204,29 @@ The effective Agent Trajectory Observability Format (ATOF) config keeps `enabled` and `output_directory` from the system file and uses `mode = "overwrite"` from the user file. +The same rule applies when the higher-precedence layer comes from code: + +```toml +# plugins.toml +version = 1 + +[[components]] +kind = "observability" +enabled = false + +[components.config.atof] +enabled = true +filename = "events.jsonl" +``` + +```bash +nemo-relay run --plugin-config '{"components":[{"kind":"observability","config":{"atof":{"mode":"overwrite"}}}]}' -- codex +``` + +The effective component keeps `enabled = false`, `atof.enabled = true`, and +`filename = "events.jsonl"` from the file, and uses `mode = "overwrite"` from +the code-driven overlay. + The top-level `components` array is special. Components are matched by `kind` across files. A higher-precedence component with the same `kind` merges into the lower-precedence component. A component with a different `kind` is added to the @@ -241,9 +285,11 @@ Common validation failures include: Format (ATIF) filename template that does not contain `{session_id}`. Use `nemo-relay doctor` to inspect the resolved gateway configuration and plugin -diagnostics. For Observability, doctor also reports enabled exporter sections and -checks writable file exporter directories or reachable OTLP endpoints when those -settings are present. +diagnostics. Doctor reports the effective plugin config source, including +code-driven overlays, so validation failures identify the winning layer. For +Observability, doctor also reports enabled exporter sections and checks writable +file exporter directories or reachable OTLP endpoints when those settings are +present. ## Relationship To `config.toml` diff --git a/docs/build-plugins/register-behavior.mdx b/docs/build-plugins/register-behavior.mdx index 1e37467a..d09eea7d 100644 --- a/docs/build-plugins/register-behavior.mdx +++ b/docs/build-plugins/register-behavior.mdx @@ -36,18 +36,34 @@ Use the plugin APIs in this order: 5. Inspect the activation report. 6. Clear active config during teardown when needed. +When a host has file-backed plugin config and wants to add code-driven defaults +or per-run overrides, layer the code-driven config over the file config before +validation. Layering preserves omitted fields, merges component objects by +`kind`, and replaces arrays and scalar values from the higher-precedence layer. + ```python import nemo_relay -config = nemo_relay.plugin.PluginConfig() -config.components = [ +file_config = { + "version": 1, + "components": [ + { + "kind": "header-plugin", + "enabled": True, + "config": {"header_name": "x-tenant"}, + } + ], +} +code_config = nemo_relay.plugin.PluginConfig() +code_config.components = [ nemo_relay.plugin.ComponentSpec( kind="header-plugin", - config={"header_name": "x-tenant", "value": "tenant-a"}, + config={"value": "tenant-a"}, ) ] +config = nemo_relay.plugin.layer(file_config, code_config) report = nemo_relay.plugin.validate(config) active_report = await nemo_relay.plugin.initialize(config) @@ -61,14 +77,21 @@ nemo_relay.plugin.clear() ```ts import * as plugin from 'nemo-relay-node/plugin'; -const config = plugin.defaultConfig(); -config.components = [ +const fileConfig = { + version: 1, + components: [ + plugin.ComponentSpec('header-plugin', { header_name: 'x-tenant' }), + ], +}; +const codeConfig = plugin.defaultConfig(); +codeConfig.components = [ plugin.ComponentSpec( 'header-plugin', - { header_name: 'x-tenant', value: 'tenant-a' }, + { value: 'tenant-a' }, { enabled: true }, ), ]; +const config = plugin.layer(fileConfig, codeConfig); const report = plugin.validate(config); const activeReport = await plugin.initialize(config); @@ -81,15 +104,28 @@ plugin.clear(); ```rust use nemo_relay::plugin::{ - clear_plugin_configuration, initialize_plugins, list_plugin_kinds, validate_plugin_config, + clear_plugin_configuration, initialize_plugins, layer_plugin_config, list_plugin_kinds, + validate_plugin_config, PluginComponentSpec, PluginConfig, }; - -let mut config = PluginConfig::default(); +use serde_json::json; + +let file_config = json!({ + "version": 1, + "components": [{ + "kind": "header-plugin", + "enabled": true, + "config": { + "header_name": "x-tenant" + } + }] +}); +let mut code_config = PluginConfig::default(); let mut component = PluginComponentSpec::new("header-plugin"); -component.config.insert("header_name".into(), "x-tenant".into()); component.config.insert("value".into(), "tenant-a".into()); -config.components.push(component); +code_config.components.push(component); +let config_json = layer_plugin_config(file_config, serde_json::to_value(code_config)?); +let config: PluginConfig = serde_json::from_value(config_json)?; let report = validate_plugin_config(&config); let active_report = initialize_plugins(config).await?; diff --git a/go/nemo_relay/plugin.go b/go/nemo_relay/plugin.go index c4a8affc..38ea13f1 100644 --- a/go/nemo_relay/plugin.go +++ b/go/nemo_relay/plugin.go @@ -25,6 +25,7 @@ typedef char* (*NemoRelayToolExecNextFn)(const char* args_json, void* next_ctx); typedef char* (*NemoRelayToolExecInterceptCb)(void* user_data, const char* args_json, NemoRelayToolExecNextFn next_fn, void* next_ctx); extern int32_t nemo_relay_validate_plugin_config(const char* config_json, char** out_json); +extern int32_t nemo_relay_layer_plugin_config(const char* base_json, const char* overlay_json, char** out_json); extern int32_t nemo_relay_initialize_plugins(const char* config_json, char** out_json); extern int32_t nemo_relay_clear_plugin_configuration(void); extern int32_t nemo_relay_active_plugin_report_json(char** out_json); @@ -90,6 +91,25 @@ var ( C.nemo_relay_string_free(out) }) } + layerPluginConfigJSON = func(base map[string]any, overlay map[string]any) (string, error) { + cBase, err := jsonCString(base) + if err != nil { + return "", err + } + defer C.free(unsafe.Pointer(cBase)) + + cOverlay, err := jsonCString(overlay) + if err != nil { + return "", err + } + defer C.free(unsafe.Pointer(cOverlay)) + + var out *C.char + status := C.nemo_relay_layer_plugin_config(cBase, cOverlay, &out) + return checkedJSONString(int32(status), func() string { return C.GoString(out) }, func() { + C.nemo_relay_string_free(out) + }) + } initializePluginsJSON = func(config PluginConfig) (string, error) { cConfig, err := pluginConfigCString(config) if err != nil { @@ -240,6 +260,23 @@ func ValidatePluginConfig(config PluginConfig) (ConfigReport, error) { return report, nil } +// LayerPluginConfig layers one raw plugin config document over another. +// +// Objects merge recursively, arrays and scalar values are replaced by overlay, +// and top-level components merge by kind. Passing raw maps preserves omitted +// fields so they can inherit from base. +func LayerPluginConfig(base map[string]any, overlay map[string]any) (map[string]any, error) { + raw, err := layerPluginConfigJSON(base, overlay) + if err != nil { + return nil, err + } + var merged map[string]any + if err := jsonUnmarshal([]byte(raw), &merged); err != nil { + return nil, err + } + return merged, nil +} + // InitializePlugins validates and activates a plugin config. // // The returned report describes the successfully activated configuration. @@ -549,7 +586,11 @@ func (ctx *PluginContext) RegisterToolExecutionIntercept(name string, priority i } func pluginConfigCString(config PluginConfig) (*C.char, error) { - payload, err := jsonMarshal(config) + return jsonCString(config) +} + +func jsonCString(value any) (*C.char, error) { + payload, err := jsonMarshal(value) if err != nil { return nil, err } diff --git a/go/nemo_relay/plugin_gap_test.go b/go/nemo_relay/plugin_gap_test.go index cfdf32f3..7515103e 100644 --- a/go/nemo_relay/plugin_gap_test.go +++ b/go/nemo_relay/plugin_gap_test.go @@ -3,7 +3,10 @@ package nemo_relay -import "testing" +import ( + "reflect" + "testing" +) func TestPluginConfigSerializationErrorsSurfaceBeforeFFI(t *testing.T) { config := PluginConfig{ @@ -31,3 +34,20 @@ func TestPluginConfigSerializationErrorsSurfaceBeforeFFI(t *testing.T) { t.Fatal("expected InitializePlugins serialization error") } } + +func TestLayerPluginConfigRoundTripsMerge(t *testing.T) { + // Smoke test only: merge semantics are covered by the core crate. This + // verifies the cgo boundary forwards both documents and returns merged JSON. + merged, err := LayerPluginConfig( + map[string]any{"a": float64(1)}, + map[string]any{"b": float64(2)}, + ) + if err != nil { + t.Fatalf("LayerPluginConfig failed: %v", err) + } + + expected := map[string]any{"a": float64(1), "b": float64(2)} + if !reflect.DeepEqual(merged, expected) { + t.Fatalf("merged config mismatch:\n got: %#v\nwant: %#v", merged, expected) + } +} diff --git a/python/nemo_relay/_native.pyi b/python/nemo_relay/_native.pyi index 642ca3db..b637cb1d 100644 --- a/python/nemo_relay/_native.pyi +++ b/python/nemo_relay/_native.pyi @@ -2077,6 +2077,18 @@ def scope_deregister_subscriber(scope_uuid: str, name: str) -> bool: """ ... +def layer_plugin_config(base: object, overlay: object) -> _JsonObject: + """Layer one raw plugin configuration over another. + + Args: + base: Lower-precedence plugin config object or equivalent mapping. + overlay: Higher-precedence plugin config object or equivalent mapping. + + Returns: + Effective plugin config as a JSON object. + """ + ... + def validate_plugin_config(config: object) -> _JsonObject: """Validate a plugin configuration without changing active runtime state. diff --git a/python/nemo_relay/plugin.py b/python/nemo_relay/plugin.py index 8568addd..ddbe4125 100644 --- a/python/nemo_relay/plugin.py +++ b/python/nemo_relay/plugin.py @@ -41,6 +41,9 @@ from nemo_relay._native import ( initialize_plugins as _initialize_plugins, ) +from nemo_relay._native import ( + layer_plugin_config as _layer_plugin_config, +) from nemo_relay._native import ( list_plugin_kinds as _list_plugin_kinds, ) @@ -285,6 +288,27 @@ def to_dict(self) -> JsonObject: } +def layer(base: PluginConfig | JsonObject, overlay: PluginConfig | JsonObject) -> JsonObject: + """Layer one plugin configuration over another. + + Args: + base: Lower-precedence plugin config, usually loaded from files. + overlay: Higher-precedence plugin config, usually built in code. + + Returns: + The effective raw JSON plugin config. + + Behavior: + Objects merge recursively, arrays and scalar values are replaced by the + overlay, and top-level components merge by `kind`. Passing raw mappings + preserves omitted fields so they can inherit from the base config. + """ + return cast( + JsonObject, + _layer_plugin_config(_normalize_object(base), _normalize_object(overlay)), + ) + + def validate(config: PluginConfig | JsonObject) -> ConfigReport: """Validate a plugin configuration without changing runtime state. @@ -420,8 +444,9 @@ def deregister(plugin_kind: str) -> bool: "PluginContext", "Plugin", "clear", - "initialize", "deregister", + "initialize", + "layer", "list_kinds", "register", "report", diff --git a/python/nemo_relay/plugin.pyi b/python/nemo_relay/plugin.pyi index 9e286830..ffe8cd7a 100644 --- a/python/nemo_relay/plugin.pyi +++ b/python/nemo_relay/plugin.pyi @@ -108,6 +108,7 @@ class PluginConfig: ) -> None: ... def to_dict(self) -> JsonObject: ... +def layer(base: PluginConfig | JsonObject, overlay: PluginConfig | JsonObject) -> JsonObject: ... def validate(config: PluginConfig | JsonObject) -> ConfigReport: ... async def initialize(config: PluginConfig | JsonObject) -> ConfigReport: ... def clear() -> None: ... diff --git a/python/tests/test_plugin_config.py b/python/tests/test_plugin_config.py new file mode 100644 index 00000000..ca0d53ba --- /dev/null +++ b/python/tests/test_plugin_config.py @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from nemo_relay import plugin + + +def test_layer_plugin_config_round_trips_merge(): + # Smoke test only: merge semantics are covered by the core crate. This + # verifies the binding forwards both documents and returns merged JSON. + assert plugin.layer({"a": 1}, {"b": 2}) == {"a": 1, "b": 2} diff --git a/skills/nemo-relay-build-plugin/SKILL.md b/skills/nemo-relay-build-plugin/SKILL.md index 53628922..cbd5a6e4 100644 --- a/skills/nemo-relay-build-plugin/SKILL.md +++ b/skills/nemo-relay-build-plugin/SKILL.md @@ -43,6 +43,8 @@ Do not build a plugin when a narrower NeMo Relay surface is enough: from a shared plugin document. - Plugin config must be JSON-compatible across Rust, Python, Node.js, files, tests, and deployment systems. +- Code-driven plugin config can layer over file-backed config. Use the + binding's plugin config layering helper instead of hand-merging nested JSON. - Validation is deterministic and side-effect free. It inspects config and returns structured diagnostics before runtime behavior changes. - Registration runs after validation and installs real behavior through @@ -109,6 +111,11 @@ endpoints rather than embedding sensitive values. - Rust: `nemo_relay::plugin` - Go, WebAssembly, and raw FFI are source-first or advanced surfaces. +When composing file-backed config with code-driven overrides, use +`nemo_relay.plugin.layer(...)`, `plugin.layer(...)`, or +`nemo_relay::plugin::layer_plugin_config(...)` so omitted fields inherit from +the lower-precedence layer and top-level components merge by `kind`. + Use the same canonical `snake_case` config keys across bindings and files. Node helper functions can be `camelCase`, but plugin config objects remain `snake_case`. diff --git a/skills/nemo-relay-tune-adaptive-config/SKILL.md b/skills/nemo-relay-tune-adaptive-config/SKILL.md index 2dc189c4..f0c5592b 100644 --- a/skills/nemo-relay-tune-adaptive-config/SKILL.md +++ b/skills/nemo-relay-tune-adaptive-config/SKILL.md @@ -26,6 +26,9 @@ request-specific middleware, or production trace debugging. - Wrap the adaptive object in an adaptive `ComponentSpec`, insert it into the shared plugin config `components` list, validate the plugin config, then initialize the plugin system. +- If adaptive settings are code-driven overlays on top of `plugins.toml` or + inline `[plugins].config`, use the plugin config layering helper before + validation so omitted fields inherit correctly. - Python uses `nemo_relay.adaptive.AdaptiveConfig(...)`, `nemo_relay.adaptive.ComponentSpec(...)`, and `nemo_relay.plugin.PluginConfig(...)`. From c196b7819b33f661070071d9f43cdfc870815557 Mon Sep 17 00:00:00 2001 From: Zhongxuan Wang Date: Mon, 1 Jun 2026 17:31:31 -0700 Subject: [PATCH 2/6] test: cover cli plugin config layering Signed-off-by: Zhongxuan Wang --- crates/cli/tests/cli_tests.rs | 57 +++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index a3533022..89a0dadc 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -305,6 +305,63 @@ command = "codex --full-auto" assert!(stdout.contains("argv = codex")); } +#[test] +fn cli_run_dry_run_layers_plugin_config_over_plugins_toml() { + let temp = tempfile::tempdir().unwrap(); + let config = temp.path().join("config.toml"); + std::fs::write( + &config, + r#" +[agents.codex] +command = "codex" +"#, + ) + .unwrap(); + std::fs::write( + temp.path().join("plugins.toml"), + r#" +version = 1 + +[[components]] +kind = "observability" +enabled = true + +[components.config] +version = 1 + +[components.config.atof] +enabled = false +output_directory = "logs" +filename = "events.jsonl" +"#, + ) + .unwrap(); + + let output = Command::new(gateway_bin()) + .args([ + "--config", + config.to_str().unwrap(), + "run", + "--agent", + "codex", + "--plugin-config", + r#"{"components":[{"kind":"observability","config":{"atof":{"enabled":true}}}]}"#, + "--dry-run", + ]) + .output() + .unwrap(); + + assert!( + output.status.success(), + "dry run should resolve layered plugin config: stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("exporter = ATOF logs/events.jsonl")); + assert!(stdout.contains("plugin_config_source = plugins.toml")); + assert!(stdout.contains("overlaid by --plugin-config")); +} + #[test] fn cli_hook_forward_fails_open_without_gateway_url() { let mut child = Command::new(gateway_bin()) From 16439e64e0effa793658cc96a96e3e2c6a3db60a Mon Sep 17 00:00:00 2001 From: Zhongxuan Wang Date: Mon, 1 Jun 2026 17:56:19 -0700 Subject: [PATCH 3/6] docs: clarify plugin config layering Signed-off-by: Zhongxuan Wang --- .../plugin-configuration-files.mdx | 48 ++++++++++++++++++- docs/build-plugins/register-behavior.mdx | 2 + 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/docs/build-plugins/plugin-configuration-files.mdx b/docs/build-plugins/plugin-configuration-files.mdx index b6643ea7..f38b195e 100644 --- a/docs/build-plugins/plugin-configuration-files.mdx +++ b/docs/build-plugins/plugin-configuration-files.mdx @@ -178,6 +178,10 @@ rules at higher precedence. A code-driven component with the same `kind` layers over the file-backed component; a code-driven component with a new `kind` is appended to the effective component list. +Omitted fields inherit from the lower-precedence layer. Explicit values in the +higher-precedence layer, including `false` and `null`, replace lower-precedence +values. + TOML tables merge recursively: ```toml @@ -220,7 +224,9 @@ filename = "events.jsonl" ``` ```bash -nemo-relay run --plugin-config '{"components":[{"kind":"observability","config":{"atof":{"mode":"overwrite"}}}]}' -- codex +nemo-relay run --agent codex \ + --plugin-config '{"components":[{"kind":"observability","config":{"atof":{"mode":"overwrite"}}}]}' \ + --dry-run ``` The effective component keeps `enabled = false`, `atof.enabled = true`, and @@ -239,6 +245,46 @@ 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. +## Verify Layering Quickly + +Use `--dry-run` to inspect the effective transparent-run configuration without +starting a gateway or agent process. For example, with this `plugins.toml` next +to the selected `config.toml`: + +```toml +version = 1 + +[[components]] +kind = "observability" +enabled = true + +[components.config] +version = 1 + +[components.config.atof] +enabled = false +output_directory = "logs" +filename = "events.jsonl" +``` + +Run this command: + +```bash +nemo-relay --config config.toml run --agent codex \ + --plugin-config '{"components":[{"kind":"observability","config":{"atof":{"enabled":true}}}]}' \ + --dry-run +``` + +The output includes these lines: + +```text +exporter = ATOF logs/events.jsonl +plugin_config_source = plugins.toml overlaid by --plugin-config +``` + +Those lines show that the overlay changed only `atof.enabled`, while the file +still supplies `output_directory` and `filename`. + ## Explicit Defaults And Overrides The editor writes explicit defaults for edited Observability and Adaptive diff --git a/docs/build-plugins/register-behavior.mdx b/docs/build-plugins/register-behavior.mdx index d09eea7d..2e66082b 100644 --- a/docs/build-plugins/register-behavior.mdx +++ b/docs/build-plugins/register-behavior.mdx @@ -40,6 +40,8 @@ When a host has file-backed plugin config and wants to add code-driven defaults or per-run overrides, layer the code-driven config over the file config before validation. Layering preserves omitted fields, merges component objects by `kind`, and replaces arrays and scalar values from the higher-precedence layer. +Omit fields to inherit them from the file-backed config. Set fields explicitly, +including `false` or `null`, to override the lower-precedence value. From 6fca8dc07690b18a328aeb21fe78cf416dcd0335 Mon Sep 17 00:00:00 2001 From: Zhongxuan Wang Date: Mon, 1 Jun 2026 18:01:08 -0700 Subject: [PATCH 4/6] docs: show plugin filename overlays Signed-off-by: Zhongxuan Wang --- docs/build-plugins/plugin-configuration-files.mdx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/build-plugins/plugin-configuration-files.mdx b/docs/build-plugins/plugin-configuration-files.mdx index f38b195e..03f8bfa7 100644 --- a/docs/build-plugins/plugin-configuration-files.mdx +++ b/docs/build-plugins/plugin-configuration-files.mdx @@ -285,6 +285,19 @@ plugin_config_source = plugins.toml overlaid by --plugin-config Those lines show that the overlay changed only `atof.enabled`, while the file still supplies `output_directory` and `filename`. +To supply or replace the output filename from the code-driven layer, include +`filename` in the overlay: + +```bash +nemo-relay --config config.toml run --agent codex \ + --plugin-config '{"components":[{"kind":"observability","config":{"atof":{"filename":"run-events.jsonl"}}}]}' \ + --dry-run +``` + +The effective ATOF config keeps any omitted file-backed fields, such as +`enabled`, `output_directory`, and `mode`, and uses +`filename = "run-events.jsonl"` from the overlay. + ## Explicit Defaults And Overrides The editor writes explicit defaults for edited Observability and Adaptive From bbff29239bc74965936183940ca32d9319be0ec3 Mon Sep 17 00:00:00 2001 From: Zhongxuan Wang Date: Mon, 1 Jun 2026 18:06:43 -0700 Subject: [PATCH 5/6] docs: clarify plugin config base discovery Signed-off-by: Zhongxuan Wang --- .../plugin-configuration-files.mdx | 21 +++++++++++++++++++ docs/observability-plugin/atof.mdx | 4 ++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/docs/build-plugins/plugin-configuration-files.mdx b/docs/build-plugins/plugin-configuration-files.mdx index 03f8bfa7..6c62fc15 100644 --- a/docs/build-plugins/plugin-configuration-files.mdx +++ b/docs/build-plugins/plugin-configuration-files.mdx @@ -98,10 +98,31 @@ inherited top-level `--plugin-config` before layering over the file-backed config. This preserves the existing "run flag wins over top-level flag" behavior while still allowing either flag to overlay `plugins.toml`. +For hook-forwarded or gateway sessions, the `x-nemo-relay-plugin-config` header +is a per-session overlay on top of the process-level plugin config. The +`nemo-relay hook-forward --plugin-config ''` flag sets that header for +automation and installed hooks. + 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. +For example, use this layout when you want an explicit base plugin file: + +```text +my-run/ + config.toml + plugins.toml +``` + +Run this command: + +```bash +nemo-relay --config my-run/config.toml run --agent codex --dry-run +``` + +The selected file-backed plugin config is `my-run/plugins.toml`. + When no explicit `--config` path is supplied, the gateway checks these `plugins.toml` locations from lowest to highest precedence: diff --git a/docs/observability-plugin/atof.mdx b/docs/observability-plugin/atof.mdx index df705473..bbd151fa 100644 --- a/docs/observability-plugin/atof.mdx +++ b/docs/observability-plugin/atof.mdx @@ -41,8 +41,8 @@ JSON object per lifecycle event to `logs/events.jsonl`. | Field | Default | Notes | |---|---|---| | `enabled` | `false` | Must be `true` to write events. | -| `output_directory` | Current working directory | Directory containing the JSONL file. | -| `filename` | Timestamped `nemo-relay-events-*.jsonl` | Explicit output filename. | +| `output_directory` | Current working directory | Directory containing the JSONL file. The directory must exist before initialization. | +| `filename` | Timestamped `nemo-relay-events-*.jsonl` | Explicit output filename inside `output_directory`. The exporter creates the file but not parent directories. | | `mode` | `append` | `append` or `overwrite`. | ## Expected Output From 64508db914c462d665f4a9719fd3036bc347efbd Mon Sep 17 00:00:00 2001 From: Zhongxuan Wang Date: Mon, 1 Jun 2026 20:52:14 -0700 Subject: [PATCH 6/6] test: make plugin config dry-run path portable Signed-off-by: Zhongxuan Wang --- crates/cli/tests/cli_tests.rs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index 89a0dadc..675dd39f 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -357,9 +357,22 @@ filename = "events.jsonl" String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("exporter = ATOF logs/events.jsonl")); - assert!(stdout.contains("plugin_config_source = plugins.toml")); - assert!(stdout.contains("overlaid by --plugin-config")); + let expected_exporter = format!( + "exporter = ATOF {}", + std::path::Path::new("logs").join("events.jsonl").display() + ); + assert!( + stdout.contains(&expected_exporter), + "expected dry-run output to contain `{expected_exporter}`, got:\n{stdout}" + ); + assert!( + stdout.contains("plugin_config_source = plugins.toml"), + "expected dry-run output to include plugin config source, got:\n{stdout}" + ); + assert!( + stdout.contains("overlaid by --plugin-config"), + "expected dry-run output to include overlay source, got:\n{stdout}" + ); } #[test]