From ebe682141ee7b8f829e32da1e5a372e12ad70f7d Mon Sep 17 00:00:00 2001 From: Zhongxuan Wang Date: Mon, 1 Jun 2026 16:00:43 -0700 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 06/10] 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] From 75bc17f5d114ece69ea9fe0135aa3713454e4d0e Mon Sep 17 00:00:00 2001 From: Zhongxuan Wang Date: Tue, 2 Jun 2026 11:57:42 -0700 Subject: [PATCH 07/10] feat: initialize plugins from discovered config Signed-off-by: Zhongxuan Wang --- Cargo.lock | 1 + crates/cli/src/config.rs | 74 +---- crates/cli/src/installer.rs | 4 - crates/cli/src/launcher.rs | 1 - crates/cli/tests/cli_tests.rs | 23 +- crates/cli/tests/coverage/config_tests.rs | 84 +----- crates/cli/tests/coverage/doctor_tests.rs | 5 +- crates/cli/tests/coverage/installer_tests.rs | 3 +- crates/cli/tests/coverage/launcher_tests.rs | 9 - crates/cli/tests/coverage/server_tests.rs | 5 +- crates/core/Cargo.toml | 1 + crates/core/src/plugin.rs | 259 +++++++++++++++++- crates/core/tests/unit/plugin_tests.rs | 83 ++++++ crates/ffi/nemo_relay.h | 17 +- crates/ffi/src/api/mod.rs | 4 +- crates/ffi/src/api/plugin.rs | 70 +++-- crates/ffi/tests/unit/api/plugin_tests.rs | 19 +- crates/node/plugin.d.ts | 23 +- crates/node/plugin.js | 29 +- crates/node/src/api/mod.rs | 16 +- crates/node/tests/plugin_tests.mjs | 67 ++++- crates/python/src/py_plugin.rs | 40 ++- crates/wasm/src/api/mod.rs | 31 +-- crates/wasm/tests-js/plugin_tests.mjs | 6 - crates/wasm/wrappers/esm/index.js | 1 - crates/wasm/wrappers/esm/plugin.d.ts | 15 +- crates/wasm/wrappers/esm/plugin.js | 27 +- crates/wasm/wrappers/nodejs/plugin.js | 28 +- .../plugin-configuration-files.mdx | 133 +++++---- docs/build-plugins/register-behavior.mdx | 81 ++---- docs/nemo-relay-cli/basic-usage.mdx | 5 +- go/nemo_relay/plugin.go | 44 +-- go/nemo_relay/plugin_gap_test.go | 92 ++++++- python/nemo_relay/_native.pyi | 27 +- python/nemo_relay/plugin.py | 46 +--- python/nemo_relay/plugin.pyi | 5 +- python/tests/test_plugin_config.py | 70 ++++- skills/nemo-relay-build-plugin/SKILL.md | 9 +- 38 files changed, 850 insertions(+), 607 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/src/config.rs b/crates/cli/src/config.rs index e4dfca71..ee2716e1 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -7,7 +7,6 @@ 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; @@ -196,9 +195,6 @@ pub(crate) struct ServerArgs { /// Upstream Anthropic base URL (e.g. https://api.anthropic.com) #[arg(long, env = "NEMO_RELAY_ANTHROPIC_BASE_URL")] pub(crate) anthropic_base_url: Option, - /// Generic plugin configuration JSON for process-level gateway plugin activation. - #[arg(long, env = "NEMO_RELAY_PLUGIN_CONFIG")] - pub(crate) plugin_config: Option, } impl ServerArgs { @@ -211,7 +207,6 @@ impl ServerArgs { self.bind.is_some() || self.openai_base_url.is_some() || self.anthropic_base_url.is_some() - || self.plugin_config.is_some() || self.config.is_some() } } @@ -236,8 +231,6 @@ pub(crate) struct HookForwardCommand { pub(crate) profile: Option, #[arg(long)] pub(crate) session_metadata: Option, - #[arg(long)] - pub(crate) plugin_config: Option, #[arg(long, value_enum)] pub(crate) gateway_mode: Option, #[arg(long)] @@ -269,8 +262,6 @@ pub(crate) struct RunCommand { #[arg(long)] pub(crate) session_metadata: Option, #[arg(long)] - pub(crate) plugin_config: Option, - #[arg(long)] pub(crate) dry_run: bool, #[arg(long)] pub(crate) print: bool, @@ -314,19 +305,11 @@ 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 = 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 { metadata, - plugin_config, + plugin_config: self.plugin_config.clone(), profile, gateway_mode, } @@ -449,8 +432,8 @@ pub(crate) fn resolve_server_config(args: &ServerArgs) -> Result, @@ -461,16 +444,7 @@ pub(crate) fn resolve_run_config( .or_else(|| inherited.and_then(|args| args.config.as_ref())); let mut resolved = load_shared_config(config)?; if let Some(args) = inherited { - // Run-subcommand plugin config has higher precedence than inherited top-level plugin - // config. Skip only that inherited field so file/plugins.toml conflicts are still caught - // when the run-level override is applied below. - if command.plugin_config.is_some() && args.plugin_config.is_some() { - let mut inherited = args.clone(); - inherited.plugin_config = None; - apply_server_overrides(&mut resolved.gateway, &inherited)?; - } else { - apply_server_overrides(&mut resolved.gateway, args)?; - } + apply_server_overrides(&mut resolved.gateway, args)?; } apply_run_overrides(&mut resolved.gateway, command)?; resolved.gateway.bind = "127.0.0.1:0" @@ -480,7 +454,7 @@ pub(crate) fn resolve_run_config( } // Applies subcommand-specific `run` overrides after inherited top-level flags. JSON-bearing fields -// are parsed here so invalid metadata or plugin config fails before the gateway binds a port. +// are parsed here so invalid metadata fails before the gateway binds a port. fn apply_run_overrides(config: &mut GatewayConfig, command: &RunCommand) -> Result<(), CliError> { apply_run_url_overrides(config, command); apply_run_json_overrides(config, command)?; @@ -498,8 +472,8 @@ fn apply_run_url_overrides(config: &mut GatewayConfig, command: &RunCommand) { } } -// Parses JSON-bearing run overrides after simple values. Invalid metadata or plugin config fails -// before transparent run mode binds its ephemeral gateway listener. +// Parses JSON-bearing run overrides after simple values. Invalid metadata fails before transparent +// run mode binds its ephemeral gateway listener. fn apply_run_json_overrides( config: &mut GatewayConfig, command: &RunCommand, @@ -507,9 +481,6 @@ fn apply_run_json_overrides( if let Some(value) = &command.session_metadata { config.metadata = Some(parse_json_option("session metadata", value)?); } - if let Some(value) = &command.plugin_config { - apply_cli_plugin_config(config, value)?; - } Ok(()) } @@ -525,9 +496,6 @@ fn apply_server_overrides(config: &mut GatewayConfig, args: &ServerArgs) -> Resu if let Some(value) = &args.anthropic_base_url { config.anthropic_base_url = value.clone(); } - if let Some(value) = &args.plugin_config { - apply_cli_plugin_config(config, value)?; - } Ok(()) } @@ -793,7 +761,7 @@ fn apply_plugin_toml_config( }; if let Some(config_source) = config_toml_plugin_source { return Err(CliError::Config(format!( - "plugin config is defined in both {} and {}; choose one file source before applying code-driven layers", + "plugin config is defined in both {} and {}; choose one file source", config_source.display(), format_paths(&plugin_toml.sources) ))); @@ -803,32 +771,6 @@ fn apply_plugin_toml_config( Ok(()) } -fn apply_cli_plugin_config(config: &mut GatewayConfig, value: &str) -> Result<(), CliError> { - 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. diff --git a/crates/cli/src/installer.rs b/crates/cli/src/installer.rs index 9cdf98b0..b94836e8 100644 --- a/crates/cli/src/installer.rs +++ b/crates/cli/src/installer.rs @@ -76,7 +76,6 @@ const HERMES_HOOK_EVENTS: &[&str] = &[ /// `--fail-closed` converts missing URLs, HTTP failures, and upstream errors into process errors. pub(crate) async fn hook_forward(command: HookForwardCommand) -> Result<(), CliError> { validate_optional_json("session metadata", command.session_metadata.as_deref())?; - validate_optional_json("plugin config", command.plugin_config.as_deref())?; let input = read_hook_payload()?; let Some(url) = hook_forward_url(&command)? else { @@ -138,7 +137,6 @@ async fn send_hook_forward_request( .headers(gateway_headers( command.profile.as_deref(), command.session_metadata.as_deref(), - command.plugin_config.as_deref(), command.gateway_mode, )?) .header(CONTENT_TYPE, "application/json") @@ -435,7 +433,6 @@ fn validate_optional_json(name: &str, value: Option<&str>) -> Result<(), CliErro fn gateway_headers( profile: Option<&str>, session_metadata: Option<&str>, - plugin_config: Option<&str>, gateway_mode: Option, ) -> Result { let mut headers = HeaderMap::new(); @@ -445,7 +442,6 @@ fn gateway_headers( "x-nemo-relay-session-metadata", session_metadata, )?; - insert_header(&mut headers, "x-nemo-relay-plugin-config", plugin_config)?; insert_header( &mut headers, "x-nemo-relay-gateway-mode", diff --git a/crates/cli/src/launcher.rs b/crates/cli/src/launcher.rs index 2c22f0a4..1b8162e4 100644 --- a/crates/cli/src/launcher.rs +++ b/crates/cli/src/launcher.rs @@ -73,7 +73,6 @@ pub(crate) async fn easy_path( openai_base_url: None, anthropic_base_url: None, session_metadata: None, - plugin_config: None, dry_run: false, print: false, command: command.command, diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index 675dd39f..559701ee 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -306,7 +306,7 @@ command = "codex --full-auto" } #[test] -fn cli_run_dry_run_layers_plugin_config_over_plugins_toml() { +fn cli_run_dry_run_reports_plugins_toml_config() { let temp = tempfile::tempdir().unwrap(); let config = temp.path().join("config.toml"); std::fs::write( @@ -330,7 +330,7 @@ enabled = true version = 1 [components.config.atof] -enabled = false +enabled = true output_directory = "logs" filename = "events.jsonl" "#, @@ -344,8 +344,6 @@ filename = "events.jsonl" "run", "--agent", "codex", - "--plugin-config", - r#"{"components":[{"kind":"observability","config":{"atof":{"enabled":true}}}]}"#, "--dry-run", ]) .output() @@ -369,9 +367,20 @@ filename = "events.jsonl" stdout.contains("plugin_config_source = plugins.toml"), "expected dry-run output to include plugin config source, got:\n{stdout}" ); +} + +#[test] +fn cli_run_rejects_plugin_config_flag() { + let output = Command::new(gateway_bin()) + .args(["run", "--plugin-config", "{}", "--dry-run"]) + .output() + .unwrap(); + + assert!(!output.status.success()); assert!( - stdout.contains("overlaid by --plugin-config"), - "expected dry-run output to include overlay source, got:\n{stdout}" + String::from_utf8_lossy(&output.stderr).contains("--plugin-config"), + "expected removed flag to be named in stderr, got:\n{}", + String::from_utf8_lossy(&output.stderr) ); } @@ -422,8 +431,6 @@ fn cli_hook_forward_posts_payload_headers_and_prints_response() { "coverage", "--session-metadata", r#"{"team":"cli"}"#, - "--plugin-config", - r#"{"components":[]}"#, "--gateway-mode", "passthrough", "--fail-closed", diff --git a/crates/cli/tests/coverage/config_tests.rs b/crates/cli/tests/coverage/config_tests.rs index 7c44a36e..0e23d5c2 100644 --- a/crates/cli/tests/coverage/config_tests.rs +++ b/crates/cli/tests/coverage/config_tests.rs @@ -32,10 +32,6 @@ fn session_config_prefers_headers_and_parses_json() { "x-nemo-relay-session-metadata", HeaderValue::from_static(r#"{"team":"obs"}"#), ); - headers.insert( - "x-nemo-relay-plugin-config", - HeaderValue::from_static(r#"{"components":[]}"#), - ); headers.insert( "x-nemo-relay-gateway-mode", HeaderValue::from_static("required"), @@ -45,7 +41,7 @@ fn session_config_prefers_headers_and_parses_json() { assert_eq!(session.profile.as_deref(), Some("profile-a")); assert_eq!(session.metadata, Some(json!({ "team": "obs" }))); - assert_eq!(session.plugin_config, Some(json!({ "components": [] }))); + assert_eq!(session.plugin_config, None); assert_eq!(session.gateway_mode.as_deref(), Some("required")); } @@ -65,7 +61,7 @@ fn session_config_uses_defaults_and_ignores_bad_json() { } #[test] -fn session_config_layers_header_plugin_config_over_gateway_config() { +fn session_config_uses_gateway_plugin_config() { let mut gateway = config(); gateway.plugin_config = Some(json!({ "version": 1, @@ -80,13 +76,7 @@ fn session_config_layers_header_plugin_config_over_gateway_config() { } }] })); - let mut headers = HeaderMap::new(); - headers.insert( - "x-nemo-relay-plugin-config", - HeaderValue::from_static( - r#"{"components":[{"kind":"observability","config":{"atof":{"mode":"overwrite"}}}]}"#, - ), - ); + let headers = HeaderMap::new(); let session = gateway.session_config_from_headers(&headers); @@ -100,8 +90,7 @@ fn session_config_layers_header_plugin_config_over_gateway_config() { "config": { "atof": { "enabled": true, - "filename": "gateway.jsonl", - "mode": "overwrite" + "filename": "gateway.jsonl" } } }] @@ -170,7 +159,6 @@ command = "hermes --yolo chat" openai_base_url: None, anthropic_base_url: None, session_metadata: None, - plugin_config: None, dry_run: false, print: false, command: vec![], @@ -225,7 +213,6 @@ fn legacy_observability_config_sections_fail_clearly() { openai_base_url: None, anthropic_base_url: None, session_metadata: None, - plugin_config: None, dry_run: false, print: false, command: vec![], @@ -278,7 +265,6 @@ mode = "overwrite" openai_base_url: None, anthropic_base_url: None, session_metadata: None, - plugin_config: None, dry_run: false, print: false, command: vec!["codex".into()], @@ -571,7 +557,7 @@ config = { version = 1, components = [] } } #[test] -fn cli_plugin_config_layers_over_plugins_toml() { +fn plugins_toml_maps_root_plugin_config_without_cli_overlay() { let temp = tempfile::tempdir().unwrap(); let config_path = temp.path().join("config.toml"); std::fs::write(&config_path, "").unwrap(); @@ -599,10 +585,6 @@ filename = "file.jsonl" openai_base_url: None, anthropic_base_url: None, session_metadata: None, - plugin_config: Some( - r#"{"components":[{"kind":"observability","config":{"atof":{"mode":"overwrite"}}}]}"# - .into(), - ), dry_run: false, print: false, command: vec!["codex".into()], @@ -621,8 +603,7 @@ filename = "file.jsonl" "version": 1, "atof": { "enabled": true, - "filename": "file.jsonl", - "mode": "overwrite" + "filename": "file.jsonl" } } }] @@ -630,11 +611,10 @@ filename = "file.jsonl" ); 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() { +fn inline_config_toml_plugin_config_remains_a_file_source() { let temp = tempfile::tempdir().unwrap(); let config_path = temp.path().join("config.toml"); std::fs::write( @@ -651,10 +631,6 @@ config = { version = 1, components = [{ kind = "observability", enabled = false, 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()], @@ -672,8 +648,7 @@ config = { version = 1, components = [{ kind = "observability", enabled = false, "config": { "atof": { "enabled": true, - "filename": "inline.jsonl", - "mode": "append" + "filename": "inline.jsonl" } } }] @@ -682,7 +657,6 @@ config = { version = 1, components = [{ kind = "observability", enabled = false, 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] @@ -703,7 +677,6 @@ openai_base_url = "http://file-openai" openai_base_url: Some("http://cli-openai".into()), anthropic_base_url: None, session_metadata: Some(r#"{"team":"cli"}"#.into()), - plugin_config: None, dry_run: false, print: false, command: vec!["codex".into()], @@ -738,7 +711,6 @@ openai_base_url = "http://file-openai" openai_base_url: None, anthropic_base_url: None, session_metadata: None, - plugin_config: None, dry_run: false, print: false, command: vec!["codex".into()], @@ -749,34 +721,6 @@ openai_base_url = "http://file-openai" assert_eq!(resolved.gateway.openai_base_url, "http://top-level-openai"); } -#[test] -fn run_plugin_config_overrides_inherited_top_level_plugin_config() { - let temp = tempfile::tempdir().unwrap(); - let server = ServerArgs { - config: Some(isolated_config_path(&temp)), - plugin_config: Some(r#"{"components":["top-level"]}"#.into()), - ..ServerArgs::default() - }; - let command = RunCommand { - agent: Some(CodingAgent::Codex), - config: None, - openai_base_url: None, - anthropic_base_url: None, - session_metadata: None, - plugin_config: Some(r#"{"components":["run"]}"#.into()), - dry_run: false, - print: false, - command: vec!["codex".into()], - }; - - let resolved = resolve_run_config(&command, Some(&server)).unwrap(); - - assert_eq!( - resolved.gateway.plugin_config, - Some(json!({ "components": ["run"] })) - ); -} - #[test] fn server_resolution_applies_all_server_overrides() { let temp = tempfile::tempdir().unwrap(); @@ -785,7 +729,6 @@ fn server_resolution_applies_all_server_overrides() { bind: Some("127.0.0.1:0".parse().unwrap()), openai_base_url: Some("http://cli-openai".into()), anthropic_base_url: Some("http://cli-anthropic".into()), - plugin_config: Some(r#"{"version":1,"components":[]}"#.into()), }; let resolved = resolve_server_config(&args).unwrap(); @@ -793,10 +736,7 @@ fn server_resolution_applies_all_server_overrides() { assert_eq!(resolved.gateway.bind.to_string(), "127.0.0.1:0"); assert_eq!(resolved.gateway.openai_base_url, "http://cli-openai"); assert_eq!(resolved.gateway.anthropic_base_url, "http://cli-anthropic"); - assert_eq!( - resolved.gateway.plugin_config, - Some(json!({ "version": 1, "components": [] })) - ); + assert_eq!(resolved.gateway.plugin_config, None); assert!(args.requested_daemon_mode()); } @@ -809,7 +749,6 @@ fn run_resolution_applies_all_run_overrides() { openai_base_url: Some("http://run-openai".into()), anthropic_base_url: Some("http://run-anthropic".into()), session_metadata: Some(r#"{"team":"run"}"#.into()), - plugin_config: Some(r#"{"components":["x"]}"#.into()), dry_run: false, print: false, command: vec!["codex".into()], @@ -820,10 +759,7 @@ fn run_resolution_applies_all_run_overrides() { assert_eq!(resolved.gateway.openai_base_url, "http://run-openai"); assert_eq!(resolved.gateway.anthropic_base_url, "http://run-anthropic"); assert_eq!(resolved.gateway.metadata, Some(json!({ "team": "run" }))); - assert_eq!( - resolved.gateway.plugin_config, - Some(json!({ "components": ["x"] })) - ); + assert_eq!(resolved.gateway.plugin_config, None); } #[test] diff --git a/crates/cli/tests/coverage/doctor_tests.rs b/crates/cli/tests/coverage/doctor_tests.rs index 7a74b365..37cbc392 100644 --- a/crates/cli/tests/coverage/doctor_tests.rs +++ b/crates/cli/tests/coverage/doctor_tests.rs @@ -664,9 +664,7 @@ async fn collect_observability_reports_plugin_config_source() { "version": 1, "components": [] })), - plugin_config_source: Some( - "plugins.toml /tmp/plugins.toml overlaid by --plugin-config".into(), - ), + plugin_config_source: Some("plugins.toml /tmp/plugins.toml".into()), ..GatewayConfig::default() }; @@ -678,7 +676,6 @@ async fn collect_observability_reports_plugin_config_source() { .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] diff --git a/crates/cli/tests/coverage/installer_tests.rs b/crates/cli/tests/coverage/installer_tests.rs index 8ca7fdae..b82f7fc3 100644 --- a/crates/cli/tests/coverage/installer_tests.rs +++ b/crates/cli/tests/coverage/installer_tests.rs @@ -96,7 +96,6 @@ fn helper_formatting_and_headers_cover_optional_paths() { let headers = gateway_headers( Some("profile"), Some(r#"{"team":"obs"}"#), - Some(r#"{"plugins":[]}"#), Some(GatewayMode::Passthrough), ) .unwrap(); @@ -115,7 +114,7 @@ fn helper_formatting_and_headers_cover_optional_paths() { .is_err() ); - let headers = gateway_headers(None, None, None, None).unwrap(); + let headers = gateway_headers(None, None, None).unwrap(); assert!(headers.is_empty()); } diff --git a/crates/cli/tests/coverage/launcher_tests.rs b/crates/cli/tests/coverage/launcher_tests.rs index c079e227..6011dbd5 100644 --- a/crates/cli/tests/coverage/launcher_tests.rs +++ b/crates/cli/tests/coverage/launcher_tests.rs @@ -18,7 +18,6 @@ fn infers_agent_from_command_or_uses_override() { openai_base_url: None, anthropic_base_url: None, session_metadata: None, - plugin_config: None, dry_run: false, print: false, command: vec!["/usr/bin/codex".into()], @@ -51,7 +50,6 @@ fn uses_configured_command_when_no_argv_is_supplied() { openai_base_url: None, anthropic_base_url: None, session_metadata: None, - plugin_config: None, dry_run: false, print: false, command: vec![], @@ -78,7 +76,6 @@ fn uses_configured_hermes_command_when_no_argv_is_supplied() { openai_base_url: None, anthropic_base_url: None, session_metadata: None, - plugin_config: None, dry_run: false, print: false, command: vec![], @@ -98,7 +95,6 @@ fn inference_failure_has_actionable_message() { openai_base_url: None, anthropic_base_url: None, session_metadata: None, - plugin_config: None, dry_run: false, print: false, command: vec!["my-agent".into()], @@ -123,7 +119,6 @@ fn missing_command_without_agent_errors() { openai_base_url: None, anthropic_base_url: None, session_metadata: None, - plugin_config: None, dry_run: false, print: false, command: vec![], @@ -146,7 +141,6 @@ fn agent_without_configured_command_falls_back_to_default_binary() { openai_base_url: None, anthropic_base_url: None, session_metadata: None, - plugin_config: None, dry_run: false, print: false, command: vec![], @@ -167,7 +161,6 @@ fn agent_with_passthrough_args_appends_to_configured_command() { openai_base_url: None, anthropic_base_url: None, session_metadata: None, - plugin_config: None, dry_run: false, print: false, command: vec!["--model".into(), "openai/openai/gpt-5.1-codex".into()], @@ -644,7 +637,6 @@ async fn run_starts_gateway_injects_env_and_returns_agent_exit_code() { openai_base_url: None, anthropic_base_url: None, session_metadata: None, - plugin_config: None, dry_run: false, print: false, command: command_argv, @@ -685,7 +677,6 @@ async fn dry_run_does_not_spawn_agent() { openai_base_url: None, anthropic_base_url: None, session_metadata: None, - plugin_config: None, dry_run: true, print: false, command: vec!["/path/that/does/not/exist".into()], diff --git a/crates/cli/tests/coverage/server_tests.rs b/crates/cli/tests/coverage/server_tests.rs index 4f25bff0..ebbf1cca 100644 --- a/crates/cli/tests/coverage/server_tests.rs +++ b/crates/cli/tests/coverage/server_tests.rs @@ -436,8 +436,7 @@ async fn serve_listener_rejects_invalid_plugin_config() { } ] })); - config.plugin_config_source = - Some("plugins.toml /tmp/plugins.toml overlaid by --plugin-config".into()); + config.plugin_config_source = Some("plugins.toml /tmp/plugins.toml".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)) @@ -446,7 +445,7 @@ async fn serve_listener_rejects_invalid_plugin_config() { 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!(error.to_string().contains("plugins.toml /tmp/plugins.toml")); assert!(nemo_relay::plugin::active_plugin_report().is_none()); } diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index e205b83c..30a6fe60 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -61,6 +61,7 @@ openinference = [ uuid = { workspace = true, features = ["v7", "serde"] } serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" +toml = "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 1a0596e1..3a46ce2f 100644 --- a/crates/core/src/plugin.rs +++ b/crates/core/src/plugin.rs @@ -12,6 +12,7 @@ use std::collections::{HashMap, HashSet}; use std::fmt; use std::future::Future; +use std::path::{Path, PathBuf}; use std::pin::Pin; use std::sync::{Arc, LazyLock, Mutex, OnceLock, RwLock}; @@ -126,13 +127,7 @@ 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 { +fn layer_plugin_config(base: Json, overlay: Json) -> Json { let mut merged = base; merge_plugin_config_layer(&mut merged, overlay); merged @@ -207,6 +202,256 @@ fn json_component_kind(component: &Json) -> Option<&str> { .and_then(Json::as_str) } +/// Effective plugin configuration resolved from discovered files plus an optional +/// code-driven layer. +#[derive(Debug, Clone)] +pub struct ResolvedPluginConfig { + /// Parsed plugin configuration ready for validation or activation. + pub config: PluginConfig, + /// Human-readable source description for diagnostics. + pub source: String, +} + +#[derive(Debug, Clone)] +struct RawPluginConfigLayer { + value: Json, + source: String, +} + +/// Resolve the plugin configuration that binding-level `initialize(...)` calls +/// should activate. +/// +/// Discovered `plugins.toml` files are the base layer. The optional +/// code-driven config is overlaid on top, preserving omitted fields until the +/// effective document is deserialized into [`PluginConfig`]. +pub fn resolve_plugin_config_layers(code_config: Option) -> Result { + let raw = match (discover_plugin_toml_config()?, code_config) { + (Some(base), Some(overlay)) => RawPluginConfigLayer { + value: layer_plugin_config(base.value, overlay), + source: format!("{} overlaid by plugin.initialize(...)", base.source), + }, + (Some(base), None) => base, + (None, Some(value)) => RawPluginConfigLayer { + value, + source: "plugin.initialize(...)".into(), + }, + (None, None) => RawPluginConfigLayer { + value: Json::Object(Map::new()), + source: "default plugin config".into(), + }, + }; + let config: PluginConfig = serde_json::from_value(raw.value).map_err(|error| { + PluginError::InvalidConfig(format!( + "invalid plugin config from {}: {error}", + raw.source + )) + })?; + Ok(ResolvedPluginConfig { + config, + source: raw.source, + }) +} + +/// Validate and activate plugin configuration from discovered files plus an +/// optional code-driven overlay. +pub async fn initialize_plugins_from_discovered_config( + code_config: Option, +) -> Result { + let resolved = resolve_plugin_config_layers(code_config)?; + initialize_plugins(resolved.config).await +} + +fn discover_plugin_toml_config() -> Result> { + #[cfg(target_arch = "wasm32")] + { + Ok(None) + } + #[cfg(not(target_arch = "wasm32"))] + { + load_plugin_toml_config_from_paths(plugin_config_paths( + std::env::current_dir().ok().as_deref(), + user_config_dir(), + )) + } +} + +const PLUGINS_TOML: &str = "plugins.toml"; + +fn plugin_config_paths(cwd: Option<&Path>, user_config_dir: Option) -> Vec { + 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 +} + +fn find_project_plugin_config(start: &Path) -> Option { + for ancestor in start.ancestors() { + let path = ancestor.join(".nemo-relay").join(PLUGINS_TOML); + if path.exists() { + return Some(path); + } + } + None +} + +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")) +} + +fn home_dir() -> Option { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) +} + +fn load_plugin_toml_config_from_paths(paths: I) -> Result> +where + I: IntoIterator, +{ + let mut merged = toml::Value::Table(toml::map::Map::new()); + let mut sources = Vec::new(); + for path in paths { + if path.exists() { + let raw = std::fs::read_to_string(&path) + .map_err(|error| PluginError::InvalidConfig(error.to_string()))?; + let parsed = raw + .parse::() + .map(toml::Value::Table) + .map_err(|error| { + PluginError::InvalidConfig(format!( + "invalid plugin TOML in {}: {error}", + path.display() + )) + })?; + validate_plugin_toml_component_kinds(&path, &parsed)?; + merge_plugin_toml(&mut merged, parsed); + sources.push(path); + } + } + if sources.is_empty() { + return Ok(None); + } + let value = serde_json::to_value(merged)?; + Ok(Some(RawPluginConfigLayer { + value, + source: plugin_toml_source(&sources), + })) +} + +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_toml_components(existing, value), + (_, Some(existing)) => merge_toml_value(existing, value), + _ => { + left.insert(key, value); + } + } + } + } + (left, right) => *left = right, + } +} + +fn merge_toml_value(left: &mut toml::Value, right: toml::Value) { + match (left, right) { + (toml::Value::Table(left), toml::Value::Table(right)) => { + for (key, value) in right { + match left.get_mut(&key) { + Some(existing) => merge_toml_value(existing, value), + None => { + left.insert(key, value); + } + } + } + } + (left, right) => *left = right, + } +} + +fn merge_toml_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) = toml_component_kind(&component).map(str::to_owned) else { + left_components.push(component); + continue; + }; + if let Some(existing) = left_components + .iter_mut() + .find(|candidate| toml_component_kind(candidate) == Some(kind.as_str())) + { + merge_toml_value(existing, component); + } else { + left_components.push(component); + } + } +} + +fn toml_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<()> { + 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) = toml_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(PluginError::InvalidConfig(format!( + "duplicate plugin component kind in {}: {}; declare each kind once per plugins.toml", + path.display(), + duplicates.join(", ") + ))) + } +} + +fn plugin_toml_source(paths: &[PathBuf]) -> String { + format!("plugins.toml {}", format_paths(paths)) +} + +fn format_paths(paths: &[PathBuf]) -> String { + paths + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(", ") +} + /// 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 def2b135..479ee87b 100644 --- a/crates/core/tests/unit/plugin_tests.rs +++ b/crates/core/tests/unit/plugin_tests.rs @@ -646,6 +646,89 @@ fn test_layer_plugin_config_replaces_non_object_shapes() { ); } +#[test] +fn test_load_plugin_toml_config_from_paths_merges_by_kind() { + let temp = std::env::temp_dir().join(format!( + "nemo-relay-plugin-config-{}-{}", + std::process::id(), + "merge" + )); + let _ = std::fs::remove_dir_all(&temp); + std::fs::create_dir_all(&temp).unwrap(); + let base = temp.join("base.toml"); + let overlay = temp.join("overlay.toml"); + std::fs::write( + &base, + r#" +version = 1 + +[[components]] +kind = "observability" +enabled = true + +[components.config] +source = "base" + +[components.config.nested] +base = true + +[[components]] +kind = "adaptive" + +[components.config] +source = "base" +"#, + ) + .unwrap(); + std::fs::write( + &overlay, + r#" +[[components]] +kind = "observability" + +[components.config] +source = "overlay" + +[components.config.nested] +overlay = true +"#, + ) + .unwrap(); + + let loaded = load_plugin_toml_config_from_paths([base.clone(), overlay.clone()]) + .unwrap() + .unwrap(); + + assert_eq!( + loaded.value, + json!({ + "version": 1, + "components": [ + { + "kind": "observability", + "enabled": true, + "config": { + "source": "overlay", + "nested": { + "base": true, + "overlay": true + } + } + }, + { + "kind": "adaptive", + "config": { + "source": "base" + } + } + ] + }) + ); + assert!(loaded.source.contains(&base.display().to_string())); + assert!(loaded.source.contains(&overlay.display().to_string())); + let _ = std::fs::remove_dir_all(&temp); +} + #[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 4ec6f67b..0d4039c3 100644 --- a/crates/ffi/nemo_relay.h +++ b/crates/ffi/nemo_relay.h @@ -1114,23 +1114,22 @@ 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. + * Validate a generic plugin config document and return the diagnostics report as JSON. * * # Safety - * `base_json` and `overlay_json` must be valid C strings and `out_json` must be a valid, - * non-null pointer. + * `config_json` must be a valid C string 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); +NemoRelayStatus nemo_relay_validate_plugin_config(const char *config_json, char **out_json); /** - * Validate a generic plugin config document and return the diagnostics report as JSON. + * Initialize plugins from discovered config files plus an optional code overlay. * * # Safety - * `config_json` must be a valid C string and `out_json` must be a valid, non-null pointer. + * `config_json` may be null to use only discovered file config. When non-null, it must be a valid + * C string. `out_json` must be a valid, non-null pointer. */ -NemoRelayStatus nemo_relay_validate_plugin_config(const char *config_json, char **out_json); +NemoRelayStatus nemo_relay_initialize_plugins_from_discovered_config(const char *config_json, + char **out_json); /** * Initialize the active global plugin components and return the resulting diagnostics report. diff --git a/crates/ffi/src/api/mod.rs b/crates/ffi/src/api/mod.rs index 48d76ba4..2643f6fa 100644 --- a/crates/ffi/src/api/mod.rs +++ b/crates/ffi/src/api/mod.rs @@ -58,8 +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, layer_plugin_config, list_plugin_kinds, register_plugin, - validate_plugin_config, + initialize_plugins, initialize_plugins_from_discovered_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 c5d48d2c..ba4f8ff0 100644 --- a/crates/ffi/src/api/plugin.rs +++ b/crates/ffi/src/api/plugin.rs @@ -9,10 +9,10 @@ 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, - 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, + deregister_plugin, initialize_plugins, initialize_plugins_from_discovered_config, + 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, @@ -126,15 +126,13 @@ 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. +/// Validate a generic plugin config document and return the diagnostics report as JSON. /// /// # Safety -/// `base_json` and `overlay_json` must be valid C strings and `out_json` must be a valid, -/// non-null pointer. +/// `config_json` must be a valid C string 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, +pub unsafe extern "C" fn nemo_relay_validate_plugin_config( + config_json: *const c_char, out_json: *mut *mut c_char, ) -> NemoRelayStatus { clear_last_error(); @@ -142,24 +140,38 @@ pub unsafe extern "C" fn nemo_relay_layer_plugin_config( set_last_error("out_json pointer is null"); return NemoRelayStatus::NullPointer; } - let base = match c_str_to_json(base_json) { + if let Err(status) = ensure_adaptive_component_registered() { + return status; + } + let config_value = match c_str_to_json(config_json) { Some(value) => value, None => return NemoRelayStatus::InvalidJson, }; - let overlay = match c_str_to_json(overlay_json) { - Some(value) => value, - None => return NemoRelayStatus::InvalidJson, + let config: PluginConfig = match serde_json::from_value(config_value) { + Ok(config) => config, + Err(err) => { + set_last_error(&err.to_string()); + return NemoRelayStatus::InvalidJson; + } + }; + let report_json = match serde_json::to_value(validate_plugin_config(&config)) { + Ok(value) => value, + Err(err) => { + set_last_error(&err.to_string()); + return NemoRelayStatus::Internal; + } }; - unsafe { *out_json = json_to_c_string(&layer_plugin_config(base, overlay)) }; + unsafe { *out_json = json_to_c_string(&report_json) }; NemoRelayStatus::Ok } -/// Validate a generic plugin config document and return the diagnostics report as JSON. +/// Initialize plugins from discovered config files plus an optional code overlay. /// /// # Safety -/// `config_json` must be a valid C string and `out_json` must be a valid, non-null pointer. +/// `config_json` may be null to use only discovered file config. When non-null, it must be a valid +/// C string. `out_json` must be a valid, non-null pointer. #[unsafe(no_mangle)] -pub unsafe extern "C" fn nemo_relay_validate_plugin_config( +pub unsafe extern "C" fn nemo_relay_initialize_plugins_from_discovered_config( config_json: *const c_char, out_json: *mut *mut c_char, ) -> NemoRelayStatus { @@ -171,18 +183,20 @@ pub unsafe extern "C" fn nemo_relay_validate_plugin_config( if let Err(status) = ensure_adaptive_component_registered() { return status; } - let config_value = match c_str_to_json(config_json) { - Some(value) => value, - None => return NemoRelayStatus::InvalidJson, - }; - let config: PluginConfig = match serde_json::from_value(config_value) { - Ok(config) => config, - Err(err) => { - set_last_error(&err.to_string()); - return NemoRelayStatus::InvalidJson; + let config_value = if config_json.is_null() { + None + } else { + match c_str_to_json(config_json) { + Some(value) => Some(value), + None => return NemoRelayStatus::InvalidJson, } }; - let report_json = match serde_json::to_value(validate_plugin_config(&config)) { + let report = + match tokio_runtime().block_on(initialize_plugins_from_discovered_config(config_value)) { + Ok(report) => report, + Err(err) => return status_from_plugin_error(&err), + }; + let report_json = match serde_json::to_value(report) { Ok(value) => value, Err(err) => { set_last_error(&err.to_string()); diff --git a/crates/ffi/tests/unit/api/plugin_tests.rs b/crates/ffi/tests/unit/api/plugin_tests.rs index dd591350..72706225 100644 --- a/crates/ffi/tests/unit/api/plugin_tests.rs +++ b/crates/ffi/tests/unit/api/plugin_tests.rs @@ -5,23 +5,6 @@ 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(); @@ -71,7 +54,7 @@ fn test_ffi_plugin_registration_validation_and_cleanup() { let mut init_json = ptr::null_mut(); assert_eq!( - nemo_relay_initialize_plugins(config.as_ptr(), &mut init_json), + nemo_relay_initialize_plugins_from_discovered_config(config.as_ptr(), &mut init_json), NemoRelayStatus::Ok ); let initialized = returned_json(init_json); diff --git a/crates/node/plugin.d.ts b/crates/node/plugin.d.ts index f45ccc4b..05d936ad 100644 --- a/crates/node/plugin.d.ts +++ b/crates/node/plugin.d.ts @@ -161,19 +161,6 @@ 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. * @@ -192,12 +179,14 @@ export declare function validate(config: PluginConfig): ConfigReport; * Replaces the current active config, invokes each enabled component's * registration hooks, and resolves with the final activation report. * - * @param config - Plugin configuration document to activate. + * @param config - Optional plugin configuration overlay to activate. * @returns A promise resolving to the activation report. - * @remarks Partial plugin registration is rolled back if activation fails, and - * the promise rejects with the underlying validation or setup error. + * @remarks Discovered `plugins.toml` files are used as the base config. The + * supplied object is layered on top, partial plugin registration is rolled back + * if activation fails, and the promise rejects with the underlying validation + * or setup error. */ -export declare function initialize(config: PluginConfig): Promise; +export declare function initialize(config?: PluginConfig | null): Promise; /** * Clear the active plugin configuration. * diff --git a/crates/node/plugin.js b/crates/node/plugin.js index 3dd4d78a..c6376e46 100644 --- a/crates/node/plugin.js +++ b/crates/node/plugin.js @@ -48,22 +48,6 @@ 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. * @@ -85,13 +69,15 @@ function validate(config) { * Replaces the current active config, invokes each enabled component's * registration hooks, and resolves with the final activation report. * - * @param {object} config - Plugin configuration document to activate. + * @param {object} [config] - Optional plugin configuration overlay to activate. * @returns {Promise} A promise resolving to the activation report. - * @remarks Partial plugin registration is rolled back if activation fails, and - * the returned promise rejects with the underlying validation or setup error. + * @remarks Discovered `plugins.toml` files are used as the base config. The + * supplied object is layered on top, partial plugin registration is rolled back + * if activation fails, and the returned promise rejects with the underlying + * validation or setup error. */ -function initialize(config) { - return lib.initializePlugins(config); +function initialize(config = undefined) { + return lib.initializePluginsFromDiscoveredConfig(config); } /** @@ -175,7 +161,6 @@ 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 2e400a1a..01acf414 100644 --- a/crates/node/src/api/mod.rs +++ b/crates/node/src/api/mod.rs @@ -45,6 +45,7 @@ use nemo_relay::plugin::{ PluginRegistrationContext, active_plugin_report as active_plugin_report_impl, clear_plugin_configuration as clear_plugin_configuration_impl, deregister_plugin as deregister_plugin_impl, initialize_plugins as initialize_plugins_impl, + initialize_plugins_from_discovered_config as initialize_plugins_from_discovered_config_impl, list_plugin_kinds as list_plugin_kinds_impl, register_plugin as register_plugin_impl, validate_plugin_config as validate_plugin_config_impl, }; @@ -3202,12 +3203,6 @@ 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. @@ -3272,6 +3267,15 @@ pub async fn initialize_plugins(config: Json) -> napi::Result { serde_json::to_value(&report).map_err(|e| napi::Error::from_reason(e.to_string())) } +/// Initialize plugin components from discovered config files plus an optional code overlay. +#[napi] +pub async fn initialize_plugins_from_discovered_config(config: Option) -> napi::Result { + let report = initialize_plugins_from_discovered_config_impl(config) + .await + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + serde_json::to_value(&report).map_err(|e| napi::Error::from_reason(e.to_string())) +} + /// Clear the active global plugin configuration. #[napi] pub fn clear_plugin_configuration() -> napi::Result<()> { diff --git a/crates/node/tests/plugin_tests.mjs b/crates/node/tests/plugin_tests.mjs index 779887e2..379b92ce 100644 --- a/crates/node/tests/plugin_tests.mjs +++ b/crates/node/tests/plugin_tests.mjs @@ -2,12 +2,71 @@ // SPDX-License-Identifier: Apache-2.0 import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; 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 }); +test('initialize layers code config over project plugins.toml', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'nemo-relay-node-plugin-')); + const project = path.join(root, 'project'); + const configDir = path.join(project, '.nemo-relay'); + const oldCwd = process.cwd(); + const oldXdg = process.env.XDG_CONFIG_HOME; + const oldHome = process.env.HOME; + + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'plugins.toml'), + ` +version = 1 + +[[components]] +kind = "observability" +enabled = true + +[components.config.atif] +enabled = false +output_directory = ${JSON.stringify(path.join(root, 'atif'))} +filename_template = "missing-session-id.json" +`, + ); + process.chdir(project); + process.env.XDG_CONFIG_HOME = path.join(root, 'xdg'); + process.env.HOME = path.join(root, 'home'); + + try { + await assert.rejects( + () => + plugin.initialize({ + components: [ + { + kind: 'observability', + config: { + atif: { + enabled: true, + }, + }, + }, + ], + }), + /filename_template/, + ); + } finally { + plugin.clear(); + process.chdir(oldCwd); + if (oldXdg === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = oldXdg; + } + if (oldHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = oldHome; + } + fs.rmSync(root, { recursive: true, force: true }); + } }); diff --git a/crates/python/src/py_plugin.rs b/crates/python/src/py_plugin.rs index 1bc89c7b..b69cea11 100644 --- a/crates/python/src/py_plugin.rs +++ b/crates/python/src/py_plugin.rs @@ -29,8 +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, layer_plugin_config, list_plugin_kinds, register_plugin, - validate_plugin_config, + initialize_plugins, initialize_plugins_from_discovered_config, list_plugin_kinds, + register_plugin, validate_plugin_config, }; use crate::convert::{json_to_py, py_to_json}; @@ -721,18 +721,6 @@ 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> { @@ -764,6 +752,25 @@ fn initialize_plugins_py<'py>( }) } +#[pyfunction(name = "initialize_plugins_from_discovered_config")] +#[pyo3(signature = (config=None), text_signature = "(config: object | None = None) -> object")] +fn initialize_plugins_from_discovered_config_py<'py>( + py: Python<'py>, + config: Option<&Bound<'_, PyAny>>, +) -> PyResult> { + let config_json = config.map(py_to_json).transpose()?; + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let report = initialize_plugins_from_discovered_config(config_json) + .await + .map_err(to_py_err)?; + Python::attach(|py| { + let report = serde_json::to_value(&report) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; + json_to_py(py, &report) + }) + }) +} + #[pyfunction(name = "clear_plugin_configuration")] #[pyo3(signature = () -> "None", text_signature = "() -> None")] fn clear_plugin_configuration_py() -> PyResult<()> { @@ -809,9 +816,12 @@ 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!( + initialize_plugins_from_discovered_config_py, + m + )?)?; m.add_function(wrap_pyfunction!(clear_plugin_configuration_py, m)?)?; m.add_function(wrap_pyfunction!(active_plugin_report_py, m)?)?; m.add_function(wrap_pyfunction!(list_plugin_kinds_py, m)?)?; diff --git a/crates/wasm/src/api/mod.rs b/crates/wasm/src/api/mod.rs index 3ab33781..88c73f6b 100644 --- a/crates/wasm/src/api/mod.rs +++ b/crates/wasm/src/api/mod.rs @@ -55,7 +55,8 @@ use nemo_relay::plugin::{ PluginRegistration as ComponentRegistration, PluginRegistrationContext, active_plugin_report as active_plugin_report_impl, clear_plugin_configuration as clear_plugin_configuration_impl, - deregister_plugin as deregister_plugin_impl, initialize_plugins as initialize_plugins_impl, + deregister_plugin as deregister_plugin_impl, + initialize_plugins_from_discovered_config as initialize_plugins_from_discovered_config_impl, list_plugin_kinds as list_plugin_kinds_impl, register_plugin as register_plugin_impl, validate_plugin_config as validate_plugin_config_impl, }; @@ -2164,19 +2165,6 @@ 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. @@ -2837,14 +2825,21 @@ pub fn deregister_plugin( #[wasm_bindgen(js_name = "initializePlugins", unchecked_return_type = "Json")] /// Validate and activate a plugin configuration. /// -/// Replaces the current active plugin configuration and rolls back partial -/// registration on failure. +/// Uses discovered file config as the base where the target supports +/// discovery, layers the supplied code config on top, replaces the current +/// active plugin configuration, and rolls back partial registration on failure. pub async fn initialize_plugins( #[wasm_bindgen(unchecked_param_type = "Json")] config: JsValue, ) -> Result { ensure_adaptive_component_registered()?; - let config: PluginConfig = serde_wasm_bindgen::from_value(config)?; - let report = initialize_plugins_impl(config).await.map_err(to_js_err)?; + let config = if config.is_null() || config.is_undefined() { + None + } else { + Some(js_to_json(&config)?) + }; + let report = initialize_plugins_from_discovered_config_impl(config) + .await + .map_err(to_js_err)?; serde_wasm_bindgen::to_value(&report).map_err(|e| JsValue::from_str(&e.to_string())) } diff --git a/crates/wasm/tests-js/plugin_tests.mjs b/crates/wasm/tests-js/plugin_tests.mjs index 52aacb6b..1fcd33e6 100644 --- a/crates/wasm/tests-js/plugin_tests.mjs +++ b/crates/wasm/tests-js/plugin_tests.mjs @@ -15,12 +15,6 @@ 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 c7a3f7e6..cc982c96 100644 --- a/crates/wasm/wrappers/esm/index.js +++ b/crates/wasm/wrappers/esm/index.js @@ -40,7 +40,6 @@ 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 996740fa..13227eba 100644 --- a/crates/wasm/wrappers/esm/plugin.d.ts +++ b/crates/wasm/wrappers/esm/plugin.d.ts @@ -159,19 +159,6 @@ 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. * @@ -195,7 +182,7 @@ export declare function validate(config: PluginConfig): ConfigReport; * @remarks Partial plugin registration is rolled back if activation fails, and * the promise rejects with the underlying validation or setup error. */ -export declare function initialize(config: PluginConfig): Promise; +export declare function initialize(config?: PluginConfig | null): Promise; /** * Clear the active plugin configuration. * diff --git a/crates/wasm/wrappers/esm/plugin.js b/crates/wasm/wrappers/esm/plugin.js index 37b4ad3a..eba3642b 100644 --- a/crates/wasm/wrappers/esm/plugin.js +++ b/crates/wasm/wrappers/esm/plugin.js @@ -3,7 +3,6 @@ import { validatePluginConfig, - layerPluginConfig, registerPlugin, deregisterPlugin, initializePlugins, @@ -51,22 +50,6 @@ 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. * @@ -88,12 +71,14 @@ export function validate(config) { * Replaces the current active config, invokes each enabled component's * registration hooks, and resolves with the final activation report. * - * @param {object} config - Plugin configuration document to activate. + * @param {object} [config] - Optional plugin configuration document to activate. * @returns {Promise} A promise resolving to the activation report. - * @remarks Partial plugin registration is rolled back if activation fails, and - * the returned promise rejects with the underlying validation or setup error. + * @remarks WebAssembly does not discover local `plugins.toml` files, so the + * supplied object is layered over an empty base config. Partial plugin + * registration is rolled back if activation fails, and the returned promise + * rejects with the underlying validation or setup error. */ -export function initialize(config) { +export function initialize(config = undefined) { return initializePlugins(config); } diff --git a/crates/wasm/wrappers/nodejs/plugin.js b/crates/wasm/wrappers/nodejs/plugin.js index f3dcafd2..a7bf6593 100644 --- a/crates/wasm/wrappers/nodejs/plugin.js +++ b/crates/wasm/wrappers/nodejs/plugin.js @@ -5,7 +5,6 @@ const { validatePluginConfig, - layerPluginConfig, registerPlugin, deregisterPlugin, initializePlugins, @@ -53,22 +52,6 @@ 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. * @@ -90,12 +73,14 @@ function validate(config) { * Replaces the current active config, invokes each enabled component's * registration hooks, and resolves with the final activation report. * - * @param {object} config - Plugin configuration document to activate. + * @param {object} [config] - Optional plugin configuration document to activate. * @returns {Promise} A promise resolving to the activation report. - * @remarks Partial plugin registration is rolled back if activation fails, and - * the returned promise rejects with the underlying validation or setup error. + * @remarks WebAssembly does not discover local `plugins.toml` files, so the + * supplied object is layered over an empty base config. Partial plugin + * registration is rolled back if activation fails, and the returned promise + * rejects with the underlying validation or setup error. */ -function initialize(config) { +function initialize(config = undefined) { return initializePlugins(config); } @@ -178,7 +163,6 @@ 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 6c62fc15..ba672ef2 100644 --- a/docs/build-plugins/plugin-configuration-files.mdx +++ b/docs/build-plugins/plugin-configuration-files.mdx @@ -12,8 +12,9 @@ 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, code-driven overlays, merge -behavior, editor behavior, and conflict rules for the CLI gateway. +This page documents file discovery, precedence, code-driven `initialize(...)` +overlays, merge behavior, editor behavior, and conflict rules for the CLI +gateway and language bindings. Component-specific fields are documented in the guide for each plugin component. @@ -71,37 +72,26 @@ The gateway reads only files named `plugins.toml`. ## Discovery -The gateway can receive plugin configuration from file-backed sources and -code-driven overlay sources: +The gateway receives plugin configuration from file-backed 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 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 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: +For binding-level plugin activation, code-driven configuration is supplied to +the binding's `plugin.initialize(...)` function. NeMo Relay discovers +`plugins.toml` from the normal system, project, and user locations, then layers +the `initialize(...)` argument on top. + +The effective file 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`. - -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 @@ -194,10 +184,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. +After the file-backed config is resolved, binding-level code config supplied to +`plugin.initialize(...)` 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. Omitted fields inherit from the lower-precedence layer. Explicit values in the higher-precedence layer, including `false` and `null`, replace lower-precedence @@ -229,7 +220,8 @@ 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: +The same rule applies when the higher-precedence layer comes from code passed to +`initialize(...)`: ```toml # plugins.toml @@ -244,10 +236,19 @@ enabled = true filename = "events.jsonl" ``` -```bash -nemo-relay run --agent codex \ - --plugin-config '{"components":[{"kind":"observability","config":{"atof":{"mode":"overwrite"}}}]}' \ - --dry-run +```python +import nemo_relay + +active_report = await nemo_relay.plugin.initialize({ + "components": [{ + "kind": "observability", + "config": { + "atof": { + "mode": "overwrite", + }, + }, + }], +}) ``` The effective component keeps `enabled = false`, `atof.enabled = true`, and @@ -266,7 +267,7 @@ 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 +## Verify File Config 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 @@ -283,7 +284,7 @@ enabled = true version = 1 [components.config.atof] -enabled = false +enabled = true output_directory = "logs" filename = "events.jsonl" ``` @@ -291,33 +292,60 @@ 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 +nemo-relay --config config.toml run --agent codex --dry-run ``` The output includes these lines: ```text exporter = ATOF logs/events.jsonl -plugin_config_source = plugins.toml overlaid by --plugin-config +plugin_config_source = plugins.toml ``` -Those lines show that the overlay changed only `atof.enabled`, while the file -still supplies `output_directory` and `filename`. +Those lines show that the gateway found the sibling `plugins.toml` and that the +file supplies the ATOF output directory and filename. -To supply or replace the output filename from the code-driven layer, include -`filename` in the overlay: +## Verify Initialize Layering Quickly -```bash -nemo-relay --config config.toml run --agent codex \ - --plugin-config '{"components":[{"kind":"observability","config":{"atof":{"filename":"run-events.jsonl"}}}]}' \ - --dry-run +Use a binding `initialize(...)` call to test code-driven overrides. With this +project file: + +```toml +version = 1 + +[[components]] +kind = "header-plugin" +enabled = true + +[components.config] +header_name = "x-tenant" +value = "from-file" +``` + +and this Python call: + +```python +active_report = await nemo_relay.plugin.initialize({ + "components": [{ + "kind": "header-plugin", + "config": { + "value": "from-code", + }, + }], +}) +``` + +the plugin receives this component-local config: + +```json +{ + "header_name": "x-tenant", + "value": "from-code" +} ``` -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. +The omitted `header_name` inherits from `plugins.toml`, while the explicit +`value` from code overwrites the file value. ## Explicit Defaults And Overrides @@ -365,11 +393,10 @@ 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. 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. +diagnostics. Doctor reports the effective file-backed plugin config source so +validation failures identify the winning file 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` @@ -379,8 +406,8 @@ installed by the plugin system. Keep long-lived plugin setup in `plugins.toml`. Use `[plugins].config` in `config.toml` only when a generated or embedded config must keep all gateway -settings in one file. Use `--plugin-config` for automation that should not write -files. +settings in one file. Use binding `plugin.initialize(...)` arguments for +code-driven overrides that should layer over discovered files. Legacy observability config sections in `config.toml`, such as `[exporters]`, `[observability]`, and `[export.openinference]`, are not supported. Configure diff --git a/docs/build-plugins/register-behavior.mdx b/docs/build-plugins/register-behavior.mdx index 2e66082b..8f104497 100644 --- a/docs/build-plugins/register-behavior.mdx +++ b/docs/build-plugins/register-behavior.mdx @@ -37,38 +37,27 @@ Use the plugin APIs in this order: 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. -Omit fields to inherit them from the file-backed config. Set fields explicitly, -including `false` or `null`, to override the lower-precedence value. +or per-run overrides, pass the code-driven config to the binding's +`plugin.initialize(...)` function. Initialization discovers the normal +`plugins.toml` base config first, then layers the supplied config over it. +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. ```python import nemo_relay -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={"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) +active_report = await nemo_relay.plugin.initialize({ + "components": [{ + "kind": "header-plugin", + "config": { + "value": "tenant-a", + }, + }], +}) kinds = nemo_relay.plugin.list_kinds() nemo_relay.plugin.clear() ``` @@ -79,24 +68,11 @@ nemo_relay.plugin.clear() ```ts import * as plugin from 'nemo-relay-node/plugin'; -const fileConfig = { - version: 1, +const activeReport = await plugin.initialize({ components: [ - plugin.ComponentSpec('header-plugin', { header_name: 'x-tenant' }), + plugin.ComponentSpec('header-plugin', { value: 'tenant-a' }), ], -}; -const codeConfig = plugin.defaultConfig(); -codeConfig.components = [ - plugin.ComponentSpec( - 'header-plugin', - { value: 'tenant-a' }, - { enabled: true }, - ), -]; -const config = plugin.layer(fileConfig, codeConfig); - -const report = plugin.validate(config); -const activeReport = await plugin.initialize(config); +}); const kinds = plugin.listKinds(); plugin.clear(); ``` @@ -106,31 +82,18 @@ plugin.clear(); ```rust use nemo_relay::plugin::{ - clear_plugin_configuration, initialize_plugins, layer_plugin_config, list_plugin_kinds, - validate_plugin_config, - PluginComponentSpec, PluginConfig, + clear_plugin_configuration, initialize_plugins_from_discovered_config, list_plugin_kinds, }; use serde_json::json; -let file_config = json!({ - "version": 1, +let active_report = initialize_plugins_from_discovered_config(Some(json!({ "components": [{ "kind": "header-plugin", - "enabled": true, "config": { - "header_name": "x-tenant" + "value": "tenant-a" } }] -}); -let mut code_config = PluginConfig::default(); -let mut component = PluginComponentSpec::new("header-plugin"); -component.config.insert("value".into(), "tenant-a".into()); -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?; +})).await?; let kinds = list_plugin_kinds(); clear_plugin_configuration()?; ``` diff --git a/docs/nemo-relay-cli/basic-usage.mdx b/docs/nemo-relay-cli/basic-usage.mdx index effc2c3b..55b7f11a 100644 --- a/docs/nemo-relay-cli/basic-usage.mdx +++ b/docs/nemo-relay-cli/basic-usage.mdx @@ -158,14 +158,12 @@ Common environment variables for direct gateway server use are: - `NEMO_RELAY_ANTHROPIC_BASE_URL` Plugin configuration controls process-level Observability exporters. Per-session -configuration controls structured metadata on the top-level agent begin event -and the plugin configuration metadata associated with the session. +configuration controls structured metadata on the top-level agent begin event. `hook-forward` can also pass per-session configuration through headers: - `x-nemo-relay-config-profile` - `x-nemo-relay-session-metadata` -- `x-nemo-relay-plugin-config` - `x-nemo-relay-gateway-mode` The accepted gateway mode values are `hook-only`, `passthrough`, and @@ -250,7 +248,6 @@ default so observability outages do not block the coding agent. Add Optional flags map to gateway headers: - `--session-metadata` sets `x-nemo-relay-session-metadata`. -- `--plugin-config` sets `x-nemo-relay-plugin-config`. - `--profile` sets `x-nemo-relay-config-profile`. - `--gateway-mode` sets `x-nemo-relay-gateway-mode`. diff --git a/go/nemo_relay/plugin.go b/go/nemo_relay/plugin.go index 38ea13f1..8f128695 100644 --- a/go/nemo_relay/plugin.go +++ b/go/nemo_relay/plugin.go @@ -25,8 +25,8 @@ 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_initialize_plugins_from_discovered_config(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); extern int32_t nemo_relay_list_plugin_kinds_json(char** out_json); @@ -91,25 +91,6 @@ 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 { @@ -118,7 +99,7 @@ var ( defer C.free(unsafe.Pointer(cConfig)) var out *C.char - status := C.nemo_relay_initialize_plugins(cConfig, &out) + status := C.nemo_relay_initialize_plugins_from_discovered_config(cConfig, &out) return checkedJSONString(int32(status), func() string { return C.GoString(out) }, func() { C.nemo_relay_string_free(out) }) @@ -260,28 +241,11 @@ 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. -// Initialization replaces the current active config and rolls back partial -// registration on failure. +// Discovered plugins.toml files are used as the base config, the supplied +// config is layered on top, and partial registration rolls back on failure. func InitializePlugins(config PluginConfig) (ConfigReport, error) { raw, err := initializePluginsJSON(config) if err != nil { diff --git a/go/nemo_relay/plugin_gap_test.go b/go/nemo_relay/plugin_gap_test.go index 7515103e..cddf216b 100644 --- a/go/nemo_relay/plugin_gap_test.go +++ b/go/nemo_relay/plugin_gap_test.go @@ -4,7 +4,8 @@ package nemo_relay import ( - "reflect" + "os" + "path/filepath" "testing" ) @@ -35,19 +36,86 @@ func TestPluginConfigSerializationErrorsSurfaceBeforeFFI(t *testing.T) { } } -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)}, - ) +func TestInitializePluginsLayersCodeConfigOverProjectPluginsToml(t *testing.T) { + root := t.TempDir() + project := filepath.Join(root, "project") + configDir := filepath.Join(project, ".nemo-relay") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatalf("failed to create config dir: %v", err) + } + pluginKind := "go.layered.plugin" + pluginsToml := ` +version = 1 + +[[components]] +kind = "go.layered.plugin" +enabled = true + +[components.config] +source = "file" + +[components.config.nested] +file = true +` + if err := os.WriteFile(filepath.Join(configDir, "plugins.toml"), []byte(pluginsToml), 0o644); err != nil { + t.Fatalf("failed to write plugins.toml: %v", err) + } + oldCwd, err := os.Getwd() if err != nil { - t.Fatalf("LayerPluginConfig failed: %v", err) + t.Fatalf("failed to read cwd: %v", err) + } + t.Cleanup(func() { + _ = os.Chdir(oldCwd) + _ = ClearPluginConfiguration() + _ = DeregisterPlugin(pluginKind) + }) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(root, "xdg")) + t.Setenv("HOME", filepath.Join(root, "home")) + if err := os.Chdir(project); err != nil { + t.Fatalf("failed to change cwd: %v", err) + } + + var configs []map[string]any + if err := RegisterPlugin(pluginKind, PluginFuncs{ + ValidateFunc: func(pluginConfig map[string]any) ([]ConfigDiagnostic, error) { + configs = append(configs, pluginConfig) + return nil, nil + }, + RegisterFunc: func(pluginConfig map[string]any, ctx *PluginContext) error { + configs = append(configs, pluginConfig) + return nil + }, + }); err != nil { + t.Fatalf("RegisterPlugin 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) + report, err := InitializePlugins(PluginConfig{ + Components: []PluginComponentSpec{{ + Kind: pluginKind, + Config: map[string]any{ + "source": "code", + "nested": map[string]any{ + "code": true, + }, + }, + }}, + }) + if err != nil { + t.Fatalf("InitializePlugins failed: %v", err) + } + if len(report.Diagnostics) != 0 { + t.Fatalf("unexpected diagnostics: %#v", report.Diagnostics) + } + if len(configs) != 2 { + t.Fatalf("expected validate and register configs, got %#v", configs) + } + for _, config := range configs { + if config["source"] != "code" { + t.Fatalf("source mismatch: %#v", config) + } + nested, ok := config["nested"].(map[string]any) + if !ok || nested["file"] != true || nested["code"] != true { + t.Fatalf("nested config mismatch: %#v", config) + } } } diff --git a/python/nemo_relay/_native.pyi b/python/nemo_relay/_native.pyi index b637cb1d..7f61b5da 100644 --- a/python/nemo_relay/_native.pyi +++ b/python/nemo_relay/_native.pyi @@ -2077,18 +2077,6 @@ 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. @@ -2118,6 +2106,21 @@ def initialize_plugins(config: object) -> Awaitable[_JsonObject]: """ ... +def initialize_plugins_from_discovered_config(config: object | None = None) -> Awaitable[_JsonObject]: + """Validate and activate discovered plugin configuration with an optional overlay. + + Args: + config: Optional code-driven plugin configuration overlay. + + Returns: + Awaitable resolving to the activation report. + + Exceptional flow: + Activation errors propagate through the awaitable. The native runtime + rolls back partial registration when possible. + """ + ... + def clear_plugin_configuration() -> None: """Clear active plugin configuration while preserving registered kinds. diff --git a/python/nemo_relay/plugin.py b/python/nemo_relay/plugin.py index ddbe4125..4a7d26be 100644 --- a/python/nemo_relay/plugin.py +++ b/python/nemo_relay/plugin.py @@ -39,10 +39,7 @@ deregister_plugin as _deregister_plugin, ) from nemo_relay._native import ( - initialize_plugins as _initialize_plugins, -) -from nemo_relay._native import ( - layer_plugin_config as _layer_plugin_config, + initialize_plugins_from_discovered_config as _initialize_plugins_from_discovered_config, ) from nemo_relay._native import ( list_plugin_kinds as _list_plugin_kinds, @@ -288,27 +285,6 @@ 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. @@ -325,21 +301,24 @@ def validate(config: PluginConfig | JsonObject) -> ConfigReport: return cast(ConfigReport, _validate_plugin_config(_normalize_object(config))) -async def initialize(config: PluginConfig | JsonObject) -> ConfigReport: +async def initialize(config: PluginConfig | JsonObject | None = None) -> ConfigReport: """Validate and activate a plugin configuration. Args: - config: `PluginConfig` or an equivalent JSON object. + config: Optional `PluginConfig` or equivalent JSON object. When omitted, + NeMo Relay initializes the discovered `plugins.toml` configuration. Returns: The report for the successfully activated configuration. Behavior: - Initialization replaces the current active plugin configuration. Partial - registration is rolled back on failure, and the previous configuration - is restored when possible. + Initialization layers the supplied code config over discovered + `plugins.toml` files, replaces the current active plugin configuration, + rolls back partial registration on failure, and restores the previous + configuration when possible. """ - return cast(ConfigReport, await _initialize_plugins(_normalize_object(config))) + overlay = None if config is None else _normalize_object(config) + return cast(ConfigReport, await _initialize_plugins_from_discovered_config(overlay)) def clear() -> None: @@ -356,11 +335,11 @@ def clear() -> None: @asynccontextmanager -async def plugin(config: PluginConfig | JsonObject) -> AsyncIterator[ConfigReport]: +async def plugin(config: PluginConfig | JsonObject | None = None) -> AsyncIterator[ConfigReport]: """Context manager for plugin initialization and cleanup. Args: - config: `PluginConfig` or an equivalent JSON object. + config: Optional `PluginConfig` or equivalent JSON object. Yields: The `ConfigReport` for the initialized configuration. @@ -446,7 +425,6 @@ def deregister(plugin_kind: str) -> bool: "clear", "deregister", "initialize", - "layer", "list_kinds", "register", "report", diff --git a/python/nemo_relay/plugin.pyi b/python/nemo_relay/plugin.pyi index ffe8cd7a..782137c9 100644 --- a/python/nemo_relay/plugin.pyi +++ b/python/nemo_relay/plugin.pyi @@ -108,11 +108,10 @@ 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: ... +async def initialize(config: PluginConfig | JsonObject | None = None) -> ConfigReport: ... def clear() -> None: ... -def plugin(config: PluginConfig | JsonObject) -> AsyncContextManager[ConfigReport]: ... +def plugin(config: PluginConfig | JsonObject | None = None) -> AsyncContextManager[ConfigReport]: ... def report() -> ConfigReport | None: ... def list_kinds() -> list[str]: ... def register(plugin_kind: str, plugin: Plugin) -> None: ... diff --git a/python/tests/test_plugin_config.py b/python/tests/test_plugin_config.py index ca0d53ba..01391702 100644 --- a/python/tests/test_plugin_config.py +++ b/python/tests/test_plugin_config.py @@ -1,10 +1,70 @@ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -from nemo_relay import plugin +from nemo_relay import JsonObject, 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} +class _RecordingPlugin: + def __init__(self) -> None: + self.configs: list[JsonObject] = [] + + def validate(self, plugin_config: JsonObject) -> list[plugin.ConfigDiagnostic]: + self.configs.append(plugin_config) + return [] + + def register(self, plugin_config: JsonObject, context: plugin.PluginContext) -> None: + self.configs.append(plugin_config) + + +async def test_initialize_layers_code_config_over_project_plugins_toml(tmp_path, monkeypatch): + plugin_kind = "python.layered.plugin" + project = tmp_path / "project" + project_config = project / ".nemo-relay" + project_config.mkdir(parents=True) + (project_config / "plugins.toml").write_text( + f""" +version = 1 + +[[components]] +kind = "{plugin_kind}" +enabled = true + +[components.config] +source = "file" + +[components.config.nested] +file = true +""", + encoding="utf-8", + ) + monkeypatch.chdir(project) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg")) + monkeypatch.setenv("HOME", str(tmp_path / "home")) + recorder = _RecordingPlugin() + plugin.register(plugin_kind, recorder) + + try: + report = await plugin.initialize( + { + "components": [ + { + "kind": plugin_kind, + "config": { + "source": "code", + "nested": { + "code": True, + }, + }, + } + ] + } + ) + finally: + plugin.clear() + plugin.deregister(plugin_kind) + + assert report["diagnostics"] == [] + assert recorder.configs == [ + {"source": "code", "nested": {"file": True, "code": True}}, + {"source": "code", "nested": {"file": True, "code": True}}, + ] diff --git a/skills/nemo-relay-build-plugin/SKILL.md b/skills/nemo-relay-build-plugin/SKILL.md index cbd5a6e4..55afc721 100644 --- a/skills/nemo-relay-build-plugin/SKILL.md +++ b/skills/nemo-relay-build-plugin/SKILL.md @@ -111,10 +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`. +When composing file-backed config with code-driven overrides, pass the +code-driven config to the binding's plugin `initialize(...)` function. The +initializer discovers `plugins.toml`, layers the supplied config on top, keeps +omitted fields inherited from the file layer, and merges top-level components 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 From 69d22fd5b04b9e3303e69367a9b473a81a872363 Mon Sep 17 00:00:00 2001 From: Zhongxuan Wang Date: Tue, 2 Jun 2026 12:53:36 -0700 Subject: [PATCH 08/10] fix: reject ambiguous plugin config layers Signed-off-by: Zhongxuan Wang --- crates/core/src/plugin.rs | 48 +++++++++++++++-- crates/core/tests/unit/plugin_tests.rs | 53 +++++++++++++++++-- crates/node/tests/plugin_tests.mjs | 46 ++++++++++------ .../plugin-configuration-files.mdx | 5 ++ 4 files changed, 129 insertions(+), 23 deletions(-) diff --git a/crates/core/src/plugin.rs b/crates/core/src/plugin.rs index 3a46ce2f..70675bc7 100644 --- a/crates/core/src/plugin.rs +++ b/crates/core/src/plugin.rs @@ -12,6 +12,7 @@ use std::collections::{HashMap, HashSet}; use std::fmt; use std::future::Future; +#[cfg(not(target_arch = "wasm32"))] use std::path::{Path, PathBuf}; use std::pin::Pin; use std::sync::{Arc, LazyLock, Mutex, OnceLock, RwLock}; @@ -127,10 +128,12 @@ impl PluginComponentSpec { } } -fn layer_plugin_config(base: Json, overlay: Json) -> Json { +fn layer_plugin_config(base: Json, overlay: Json) -> Result { + validate_json_layer_component_kinds("base layer", &base)?; + validate_json_layer_component_kinds("overlay layer", &overlay)?; let mut merged = base; merge_plugin_config_layer(&mut merged, overlay); - merged + Ok(merged) } fn merge_plugin_config_layer(base: &mut Json, overlay: Json) { @@ -202,6 +205,32 @@ fn json_component_kind(component: &Json) -> Option<&str> { .and_then(Json::as_str) } +fn validate_json_layer_component_kinds(layer_name: &str, value: &Json) -> Result<()> { + let Some(components) = value.get("components").and_then(Json::as_array) else { + return Ok(()); + }; + let mut seen = HashSet::new(); + let mut duplicates = Vec::new(); + for component in components { + let Some(kind) = json_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(PluginError::InvalidConfig(format!( + "plugin config layering cannot merge duplicate component kind values in {layer_name}: {}; use a fully materialized config or add a stable component instance key before layering", + duplicates.join(", ") + ))) + } +} + /// Effective plugin configuration resolved from discovered files plus an optional /// code-driven layer. #[derive(Debug, Clone)] @@ -227,7 +256,7 @@ struct RawPluginConfigLayer { pub fn resolve_plugin_config_layers(code_config: Option) -> Result { let raw = match (discover_plugin_toml_config()?, code_config) { (Some(base), Some(overlay)) => RawPluginConfigLayer { - value: layer_plugin_config(base.value, overlay), + value: layer_plugin_config(base.value, overlay)?, source: format!("{} overlaid by plugin.initialize(...)", base.source), }, (Some(base), None) => base, @@ -275,8 +304,10 @@ fn discover_plugin_toml_config() -> Result> { } } +#[cfg(not(target_arch = "wasm32"))] const PLUGINS_TOML: &str = "plugins.toml"; +#[cfg(not(target_arch = "wasm32"))] fn plugin_config_paths(cwd: Option<&Path>, user_config_dir: Option) -> Vec { let mut paths = vec![PathBuf::from("/etc/nemo-relay").join(PLUGINS_TOML)]; if let Some(cwd) = cwd @@ -290,6 +321,7 @@ fn plugin_config_paths(cwd: Option<&Path>, user_config_dir: Option) -> paths } +#[cfg(not(target_arch = "wasm32"))] fn find_project_plugin_config(start: &Path) -> Option { for ancestor in start.ancestors() { let path = ancestor.join(".nemo-relay").join(PLUGINS_TOML); @@ -300,6 +332,7 @@ fn find_project_plugin_config(start: &Path) -> Option { None } +#[cfg(not(target_arch = "wasm32"))] fn user_config_dir() -> Option { if let Some(base) = std::env::var_os("XDG_CONFIG_HOME") { return Some(PathBuf::from(base).join("nemo-relay")); @@ -307,12 +340,14 @@ fn user_config_dir() -> Option { home_dir().map(|home| home.join(".config/nemo-relay")) } +#[cfg(not(target_arch = "wasm32"))] fn home_dir() -> Option { std::env::var_os("HOME") .or_else(|| std::env::var_os("USERPROFILE")) .map(PathBuf::from) } +#[cfg(not(target_arch = "wasm32"))] fn load_plugin_toml_config_from_paths(paths: I) -> Result> where I: IntoIterator, @@ -347,6 +382,7 @@ where })) } +#[cfg(not(target_arch = "wasm32"))] fn merge_plugin_toml(left: &mut toml::Value, right: toml::Value) { match (left, right) { (toml::Value::Table(left), toml::Value::Table(right)) => { @@ -364,6 +400,7 @@ fn merge_plugin_toml(left: &mut toml::Value, right: toml::Value) { } } +#[cfg(not(target_arch = "wasm32"))] fn merge_toml_value(left: &mut toml::Value, right: toml::Value) { match (left, right) { (toml::Value::Table(left), toml::Value::Table(right)) => { @@ -380,6 +417,7 @@ fn merge_toml_value(left: &mut toml::Value, right: toml::Value) { } } +#[cfg(not(target_arch = "wasm32"))] fn merge_toml_components(left: &mut toml::Value, right: toml::Value) { let toml::Value::Array(left_components) = left else { *left = right; @@ -406,6 +444,7 @@ fn merge_toml_components(left: &mut toml::Value, right: toml::Value) { } } +#[cfg(not(target_arch = "wasm32"))] fn toml_component_kind(component: &toml::Value) -> Option<&str> { component .as_table() @@ -413,6 +452,7 @@ fn toml_component_kind(component: &toml::Value) -> Option<&str> { .and_then(toml::Value::as_str) } +#[cfg(not(target_arch = "wasm32"))] fn validate_plugin_toml_component_kinds(path: &Path, value: &toml::Value) -> Result<()> { let Some(components) = value.get("components").and_then(toml::Value::as_array) else { return Ok(()); @@ -440,10 +480,12 @@ fn validate_plugin_toml_component_kinds(path: &Path, value: &toml::Value) -> Res } } +#[cfg(not(target_arch = "wasm32"))] fn plugin_toml_source(paths: &[PathBuf]) -> String { format!("plugins.toml {}", format_paths(paths)) } +#[cfg(not(target_arch = "wasm32"))] fn format_paths(paths: &[PathBuf]) -> String { paths .iter() diff --git a/crates/core/tests/unit/plugin_tests.rs b/crates/core/tests/unit/plugin_tests.rs index 479ee87b..46ff33b8 100644 --- a/crates/core/tests/unit/plugin_tests.rs +++ b/crates/core/tests/unit/plugin_tests.rs @@ -586,7 +586,7 @@ fn test_layer_plugin_config_merges_by_kind_and_preserves_omissions() { } }); - let merged = layer_plugin_config(base, overlay); + let merged = layer_plugin_config(base, overlay).unwrap(); assert_eq!( merged, @@ -634,19 +634,66 @@ fn test_layer_plugin_config_merges_by_kind_and_preserves_omissions() { #[test] fn test_layer_plugin_config_replaces_non_object_shapes() { assert_eq!( - layer_plugin_config(json!({"components": []}), json!([])), + layer_plugin_config(json!({"components": []}), json!([])).unwrap(), json!([]) ); assert_eq!( layer_plugin_config( json!({"components": [{"kind": "base"}]}), json!({"components": "not-an-array"}) - ), + ) + .unwrap(), json!({"components": "not-an-array"}) ); } #[test] +fn test_layer_plugin_config_rejects_duplicate_component_kinds() { + let base_error = layer_plugin_config( + json!({ + "components": [ + {"kind": "duplicate.plugin", "config": {"name": "first"}}, + {"kind": "duplicate.plugin", "config": {"name": "second"}} + ] + }), + json!({}), + ) + .unwrap_err(); + match base_error { + PluginError::InvalidConfig(message) => { + assert!( + message.contains("duplicate component kind values in base layer"), + "{message}" + ); + assert!(message.contains("duplicate.plugin"), "{message}"); + } + other => panic!("unexpected duplicate-kind error: {other}"), + } + + let overlay_error = layer_plugin_config( + json!({"components": [{"kind": "duplicate.plugin"}]}), + json!({ + "components": [ + {"kind": "duplicate.plugin", "config": {"name": "first"}}, + {"kind": "duplicate.plugin", "config": {"name": "second"}} + ] + }), + ) + .unwrap_err(); + match overlay_error { + PluginError::InvalidConfig(message) => { + assert!( + message.contains("duplicate component kind values in overlay layer"), + "{message}" + ); + assert!(message.contains("duplicate.plugin"), "{message}"); + } + other => panic!("unexpected duplicate-kind error: {other}"), + } +} + +#[test] +#[cfg(not(target_arch = "wasm32"))] fn test_load_plugin_toml_config_from_paths_merges_by_kind() { let temp = std::env::temp_dir().join(format!( "nemo-relay-plugin-config-{}-{}", diff --git a/crates/node/tests/plugin_tests.mjs b/crates/node/tests/plugin_tests.mjs index 379b92ce..2f52bc70 100644 --- a/crates/node/tests/plugin_tests.mjs +++ b/crates/node/tests/plugin_tests.mjs @@ -9,7 +9,7 @@ import test from 'node:test'; import * as plugin from '../plugin.js'; -test('initialize layers code config over project plugins.toml', async () => { +async function withProjectPluginsToml({ atifEnabled }, callback) { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'nemo-relay-node-plugin-')); const project = path.join(root, 'project'); const configDir = path.join(project, '.nemo-relay'); @@ -28,7 +28,7 @@ kind = "observability" enabled = true [components.config.atif] -enabled = false +enabled = ${atifEnabled} output_directory = ${JSON.stringify(path.join(root, 'atif'))} filename_template = "missing-session-id.json" `, @@ -38,6 +38,26 @@ filename_template = "missing-session-id.json" process.env.HOME = path.join(root, 'home'); try { + await callback({ root, project }); + } finally { + plugin.clear(); + process.chdir(oldCwd); + if (oldXdg === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = oldXdg; + } + if (oldHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = oldHome; + } + fs.rmSync(root, { recursive: true, force: true }); + } +} + +test('initialize layers code config over project plugins.toml', async () => { + await withProjectPluginsToml({ atifEnabled: false }, async () => { await assert.rejects( () => plugin.initialize({ @@ -54,19 +74,11 @@ filename_template = "missing-session-id.json" }), /filename_template/, ); - } finally { - plugin.clear(); - process.chdir(oldCwd); - if (oldXdg === undefined) { - delete process.env.XDG_CONFIG_HOME; - } else { - process.env.XDG_CONFIG_HOME = oldXdg; - } - if (oldHome === undefined) { - delete process.env.HOME; - } else { - process.env.HOME = oldHome; - } - fs.rmSync(root, { recursive: true, force: true }); - } + }); +}); + +test('initialize with no arguments uses project plugins.toml', async () => { + await withProjectPluginsToml({ atifEnabled: true }, async () => { + await assert.rejects(() => plugin.initialize(), /filename_template/); + }); }); diff --git a/docs/build-plugins/plugin-configuration-files.mdx b/docs/build-plugins/plugin-configuration-files.mdx index ba672ef2..3e0d48e9 100644 --- a/docs/build-plugins/plugin-configuration-files.mdx +++ b/docs/build-plugins/plugin-configuration-files.mdx @@ -264,6 +264,11 @@ Declare each `kind` at most once inside one `plugins.toml` file. Duplicate component kinds in the same file fail before merge. Duplicate singleton components that reach plugin validation also fail validation. +Code-driven layering also rejects duplicate component `kind` values in either +layer. A fully materialized config can still contain repeated plugin kinds when +that plugin supports multiple components, but layered configs need a unique +`kind` match so NeMo Relay does not update the wrong instance. + Arrays inside component config are replaced by the higher-precedence value. Tables inside component config merge recursively. From cf19861aaed3f43b9e86efca32fd14aa1a63e6bc Mon Sep 17 00:00:00 2001 From: Zhongxuan Wang Date: Tue, 2 Jun 2026 13:03:28 -0700 Subject: [PATCH 09/10] fix: clarify ffi plugin json ownership Signed-off-by: Zhongxuan Wang --- crates/cli/tests/coverage/server_tests.rs | 1 - crates/ffi/nemo_relay.h | 10 ++++++++++ crates/ffi/src/api/plugin.rs | 10 ++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/cli/tests/coverage/server_tests.rs b/crates/cli/tests/coverage/server_tests.rs index ebbf1cca..d7707cf0 100644 --- a/crates/cli/tests/coverage/server_tests.rs +++ b/crates/cli/tests/coverage/server_tests.rs @@ -445,7 +445,6 @@ async fn serve_listener_rejects_invalid_plugin_config() { assert!(error.to_string().contains("ATOF mode")); assert!(error.to_string().contains("plugins.toml /tmp/plugins.toml")); - assert!(error.to_string().contains("plugins.toml /tmp/plugins.toml")); assert!(nemo_relay::plugin::active_plugin_report().is_none()); } diff --git a/crates/ffi/nemo_relay.h b/crates/ffi/nemo_relay.h index 0d4039c3..33ff06e2 100644 --- a/crates/ffi/nemo_relay.h +++ b/crates/ffi/nemo_relay.h @@ -1118,6 +1118,8 @@ NemoRelayStatus nemo_relay_openinference_subscriber_shutdown(const struct FfiOpe * * # Safety * `config_json` must be a valid C string and `out_json` must be a valid, non-null pointer. + * On success, `*out_json` is set to an allocated JSON string that the caller must free with + * `nemo_relay_string_free`. */ NemoRelayStatus nemo_relay_validate_plugin_config(const char *config_json, char **out_json); @@ -1127,6 +1129,8 @@ NemoRelayStatus nemo_relay_validate_plugin_config(const char *config_json, char * # Safety * `config_json` may be null to use only discovered file config. When non-null, it must be a valid * C string. `out_json` must be a valid, non-null pointer. + * On success, `*out_json` is set to an allocated JSON string that the caller must free with + * `nemo_relay_string_free`. */ NemoRelayStatus nemo_relay_initialize_plugins_from_discovered_config(const char *config_json, char **out_json); @@ -1136,6 +1140,8 @@ NemoRelayStatus nemo_relay_initialize_plugins_from_discovered_config(const char * * # Safety * `config_json` must be a valid C string and `out_json` must be a valid, non-null pointer. + * On success, `*out_json` is set to an allocated JSON string that the caller must free with + * `nemo_relay_string_free`. */ NemoRelayStatus nemo_relay_initialize_plugins(const char *config_json, char **out_json); @@ -1149,6 +1155,8 @@ NemoRelayStatus nemo_relay_clear_plugin_configuration(void); * * # Safety * `out_json` must be a valid, non-null pointer. + * On success, `*out_json` is set to an allocated JSON string that the caller must free with + * `nemo_relay_string_free`. */ NemoRelayStatus nemo_relay_active_plugin_report_json(char **out_json); @@ -1157,6 +1165,8 @@ NemoRelayStatus nemo_relay_active_plugin_report_json(char **out_json); * * # Safety * `out_json` must be a valid, non-null pointer. + * On success, `*out_json` is set to an allocated JSON string that the caller must free with + * `nemo_relay_string_free`. */ NemoRelayStatus nemo_relay_list_plugin_kinds_json(char **out_json); diff --git a/crates/ffi/src/api/plugin.rs b/crates/ffi/src/api/plugin.rs index ba4f8ff0..e5ded0a8 100644 --- a/crates/ffi/src/api/plugin.rs +++ b/crates/ffi/src/api/plugin.rs @@ -130,6 +130,8 @@ fn ensure_adaptive_component_registered() -> std::result::Result<(), NemoRelaySt /// /// # Safety /// `config_json` must be a valid C string and `out_json` must be a valid, non-null pointer. +/// On success, `*out_json` is set to an allocated JSON string that the caller must free with +/// `nemo_relay_string_free`. #[unsafe(no_mangle)] pub unsafe extern "C" fn nemo_relay_validate_plugin_config( config_json: *const c_char, @@ -170,6 +172,8 @@ pub unsafe extern "C" fn nemo_relay_validate_plugin_config( /// # Safety /// `config_json` may be null to use only discovered file config. When non-null, it must be a valid /// C string. `out_json` must be a valid, non-null pointer. +/// On success, `*out_json` is set to an allocated JSON string that the caller must free with +/// `nemo_relay_string_free`. #[unsafe(no_mangle)] pub unsafe extern "C" fn nemo_relay_initialize_plugins_from_discovered_config( config_json: *const c_char, @@ -211,6 +215,8 @@ pub unsafe extern "C" fn nemo_relay_initialize_plugins_from_discovered_config( /// /// # Safety /// `config_json` must be a valid C string and `out_json` must be a valid, non-null pointer. +/// On success, `*out_json` is set to an allocated JSON string that the caller must free with +/// `nemo_relay_string_free`. #[unsafe(no_mangle)] pub unsafe extern "C" fn nemo_relay_initialize_plugins( config_json: *const c_char, @@ -264,6 +270,8 @@ pub extern "C" fn nemo_relay_clear_plugin_configuration() -> NemoRelayStatus { /// /// # Safety /// `out_json` must be a valid, non-null pointer. +/// On success, `*out_json` is set to an allocated JSON string that the caller must free with +/// `nemo_relay_string_free`. #[unsafe(no_mangle)] pub unsafe extern "C" fn nemo_relay_active_plugin_report_json( out_json: *mut *mut c_char, @@ -288,6 +296,8 @@ pub unsafe extern "C" fn nemo_relay_active_plugin_report_json( /// /// # Safety /// `out_json` must be a valid, non-null pointer. +/// On success, `*out_json` is set to an allocated JSON string that the caller must free with +/// `nemo_relay_string_free`. #[unsafe(no_mangle)] pub unsafe extern "C" fn nemo_relay_list_plugin_kinds_json( out_json: *mut *mut c_char, From 7ded7349a6b7d81358d664d5e654d1f2e72b2fca Mon Sep 17 00:00:00 2001 From: Zhongxuan Wang Date: Tue, 2 Jun 2026 14:42:47 -0700 Subject: [PATCH 10/10] test: harden node plugin cleanup Signed-off-by: Zhongxuan Wang --- crates/node/tests/plugin_tests.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/node/tests/plugin_tests.mjs b/crates/node/tests/plugin_tests.mjs index 2f52bc70..1c5a66cd 100644 --- a/crates/node/tests/plugin_tests.mjs +++ b/crates/node/tests/plugin_tests.mjs @@ -40,7 +40,11 @@ filename_template = "missing-session-id.json" try { await callback({ root, project }); } finally { - plugin.clear(); + try { + plugin.clear(); + } catch (error) { + console.warn('plugin.clear() failed during cleanup:', error); + } process.chdir(oldCwd); if (oldXdg === undefined) { delete process.env.XDG_CONFIG_HOME;