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 2307065f..ee2716e1 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -195,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 { @@ -210,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() } } @@ -222,6 +218,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)] @@ -234,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)] @@ -267,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, @@ -312,13 +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 = header_json(headers, "x-nemo-relay-plugin-config") - .or_else(|| self.plugin_config.clone()); 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, } @@ -423,6 +414,7 @@ impl Default for GatewayConfig { anthropic_base_url: "https://api.anthropic.com".into(), metadata: None, plugin_config: None, + plugin_config_source: None, } } } @@ -440,8 +432,8 @@ pub(crate) fn resolve_server_config(args: &ServerArgs) -> Result, @@ -452,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" @@ -471,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)?; @@ -489,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, @@ -498,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(()) } @@ -516,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(()) } @@ -568,6 +545,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)?); - Ok(()) -} - // 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 +850,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 +938,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/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 b9b3048c..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, @@ -538,6 +537,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 +594,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/cli_tests.rs b/crates/cli/tests/cli_tests.rs index a3533022..559701ee 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -305,6 +305,85 @@ command = "codex --full-auto" assert!(stdout.contains("argv = codex")); } +#[test] +fn cli_run_dry_run_reports_plugins_toml_config() { + 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 = true +output_directory = "logs" +filename = "events.jsonl" +"#, + ) + .unwrap(); + + let output = Command::new(gateway_bin()) + .args([ + "--config", + config.to_str().unwrap(), + "run", + "--agent", + "codex", + "--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); + 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}" + ); +} + +#[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!( + 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) + ); +} + #[test] fn cli_hook_forward_fails_open_without_gateway_url() { let mut child = Command::new(gateway_bin()) @@ -352,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 d7426da4..0e23d5c2 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, } } @@ -31,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"), @@ -44,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")); } @@ -63,6 +60,44 @@ fn session_config_uses_defaults_and_ignores_bad_json() { assert_eq!(header_string(&headers, "x-empty"), None); } +#[test] +fn session_config_uses_gateway_plugin_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 headers = HeaderMap::new(); + + 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" + } + } + }] + })) + ); +} + #[test] fn agent_and_gateway_mode_arguments_are_stable() { assert_eq!(CodingAgent::ClaudeCode.hook_path(), "/hooks/claude-code"); @@ -124,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![], @@ -179,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![], @@ -232,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()], @@ -261,6 +293,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 +557,106 @@ config = { version = 1, components = [] } } #[test] -fn cli_plugin_config_conflicts_with_file_plugin_config() { +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(); - 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()), 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" + } + } + }] + })) + ); + let source = resolved.gateway.plugin_config_source.as_deref().unwrap(); + assert!(source.contains("plugins.toml")); +} + +#[test] +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( + &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, + 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" + } + } + }] + })) + ); + let source = resolved.gateway.plugin_config_source.as_deref().unwrap(); + assert!(source.contains("[plugins].config")); + assert!(source.contains(&config_path.display().to_string())); } #[test] @@ -563,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()], @@ -598,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()], @@ -609,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(); @@ -645,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(); @@ -653,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()); } @@ -669,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()], @@ -680,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 6cfcabdd..37cbc392 100644 --- a/crates/cli/tests/coverage/doctor_tests.rs +++ b/crates/cli/tests/coverage/doctor_tests.rs @@ -657,6 +657,27 @@ 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".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")); +} + #[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/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 12caff1f..d7707cf0 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,7 @@ async fn serve_listener_rejects_invalid_plugin_config() { } ] })); + 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)) @@ -442,6 +444,7 @@ 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!(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/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 f9731a3e..70675bc7 100644 --- a/crates/core/src/plugin.rs +++ b/crates/core/src/plugin.rs @@ -12,6 +12,8 @@ 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}; @@ -126,6 +128,372 @@ impl PluginComponentSpec { } } +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); + Ok(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) +} + +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)] +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(), + )) + } +} + +#[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 + && 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 +} + +#[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); + if path.exists() { + return Some(path); + } + } + 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")); + } + 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, +{ + 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), + })) +} + +#[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)) => { + 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, + } +} + +#[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)) => { + 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, + } +} + +#[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; + 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); + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn toml_component_kind(component: &toml::Value) -> Option<&str> { + component + .as_table() + .and_then(|table| table.get("kind")) + .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(()); + }; + 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(", ") + ))) + } +} + +#[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() + .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 e5eb7255..46ff33b8 100644 --- a/crates/core/tests/unit/plugin_tests.rs +++ b/crates/core/tests/unit/plugin_tests.rs @@ -528,6 +528,254 @@ 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).unwrap(); + + 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!([])).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-{}-{}", + 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 a5d5fa3d..33ff06e2 100644 --- a/crates/ffi/nemo_relay.h +++ b/crates/ffi/nemo_relay.h @@ -1118,14 +1118,30 @@ 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); +/** + * Initialize plugins from discovered config files plus an optional code overlay. + * + * # 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); + /** * Initialize the active global plugin components and return the resulting diagnostics report. * * # 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); @@ -1139,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); @@ -1147,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/mod.rs b/crates/ffi/src/api/mod.rs index a1d6fffd..2643f6fa 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, 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 ad795e49..e5ded0a8 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, 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, }; struct FfiHostedPluginUserData { @@ -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, @@ -165,10 +167,56 @@ pub unsafe extern "C" fn nemo_relay_validate_plugin_config( NemoRelayStatus::Ok } +/// Initialize plugins from discovered config files plus an optional code overlay. +/// +/// # 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, + 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; + } + if let Err(status) = ensure_adaptive_component_registered() { + return status; + } + 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 = + 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()); + return NemoRelayStatus::Internal; + } + }; + unsafe { *out_json = json_to_c_string(&report_json) }; + NemoRelayStatus::Ok +} + /// Initialize the active global plugin components and return the resulting diagnostics report. /// /// # 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, @@ -222,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, @@ -246,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, diff --git a/crates/ffi/tests/unit/api/plugin_tests.rs b/crates/ffi/tests/unit/api/plugin_tests.rs index 2d37db84..72706225 100644 --- a/crates/ffi/tests/unit/api/plugin_tests.rs +++ b/crates/ffi/tests/unit/api/plugin_tests.rs @@ -54,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 4fbb6be8..05d936ad 100644 --- a/crates/node/plugin.d.ts +++ b/crates/node/plugin.d.ts @@ -179,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 a84c3212..c6376e46 100644 --- a/crates/node/plugin.js +++ b/crates/node/plugin.js @@ -69,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); } /** diff --git a/crates/node/src/api/mod.rs b/crates/node/src/api/mod.rs index b3930001..eae1b4b9 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, }; @@ -3271,6 +3272,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 new file mode 100644 index 00000000..1c5a66cd --- /dev/null +++ b/crates/node/tests/plugin_tests.mjs @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// 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'; + +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'); + 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 = ${atifEnabled} +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 callback({ root, project }); + } finally { + 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; + } 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({ + components: [ + { + kind: 'observability', + config: { + atif: { + enabled: true, + }, + }, + }, + ], + }), + /filename_template/, + ); + }); +}); + +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/crates/python/src/py_plugin.rs b/crates/python/src/py_plugin.rs index d483375b..b69cea11 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, initialize_plugins_from_discovered_config, list_plugin_kinds, + register_plugin, validate_plugin_config, }; use crate::convert::{json_to_py, py_to_json}; @@ -751,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<()> { @@ -798,6 +818,10 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; 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 7b2e04ac..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, }; @@ -2824,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/wrappers/esm/plugin.d.ts b/crates/wasm/wrappers/esm/plugin.d.ts index b664e8d5..13227eba 100644 --- a/crates/wasm/wrappers/esm/plugin.d.ts +++ b/crates/wasm/wrappers/esm/plugin.d.ts @@ -182,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 6a248e97..eba3642b 100644 --- a/crates/wasm/wrappers/esm/plugin.js +++ b/crates/wasm/wrappers/esm/plugin.js @@ -71,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 0f5e54f0..a7bf6593 100644 --- a/crates/wasm/wrappers/nodejs/plugin.js +++ b/crates/wasm/wrappers/nodejs/plugin.js @@ -73,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); } diff --git a/docs/build-plugins/plugin-configuration-files.mdx b/docs/build-plugins/plugin-configuration-files.mdx index 866d80d9..3e0d48e9 100644 --- a/docs/build-plugins/plugin-configuration-files.mdx +++ b/docs/build-plugins/plugin-configuration-files.mdx @@ -12,9 +12,11 @@ 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 `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. NeMo Relay plugin configuration keys use `snake_case` regardless of language or @@ -70,22 +72,47 @@ The gateway reads only files named `plugins.toml`. ## Discovery -The gateway can receive plugin configuration from three source classes: +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. | -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. + +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`. 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: @@ -157,6 +184,16 @@ 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, 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 +values. + TOML tables merge recursively: ```toml @@ -183,6 +220,41 @@ 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 passed to +`initialize(...)`: + +```toml +# plugins.toml +version = 1 + +[[components]] +kind = "observability" +enabled = false + +[components.config.atof] +enabled = true +filename = "events.jsonl" +``` + +```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 +`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 @@ -192,9 +264,94 @@ 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. +## 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 +to the selected `config.toml`: + +```toml +version = 1 + +[[components]] +kind = "observability" +enabled = true + +[components.config] +version = 1 + +[components.config.atof] +enabled = true +output_directory = "logs" +filename = "events.jsonl" +``` + +Run this command: + +```bash +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 +``` + +Those lines show that the gateway found the sibling `plugins.toml` and that the +file supplies the ATOF output directory and filename. + +## Verify Initialize Layering Quickly + +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 omitted `header_name` inherits from `plugins.toml`, while the explicit +`value` from code overwrites the file value. + ## Explicit Defaults And Overrides The editor writes explicit defaults for edited Observability and Adaptive @@ -241,9 +398,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. 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` @@ -253,8 +411,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 d995ca77..6e204bee 100644 --- a/docs/build-plugins/register-behavior.mdx +++ b/docs/build-plugins/register-behavior.mdx @@ -193,6 +193,13 @@ With the plugin kind registered (see the Header Plugin Example above), use the p Register the plugin kind before you initialize. An unregistered kind is reported by `validate()` only as a warning under the default `unknown_component="warn"` policy, so an error-only check still passes, but `initialize()` raises for a kind that was never registered. +When a host also has file-backed plugin config, Python and Node.js +`plugin.initialize(...)` discover the normal `plugins.toml` base config first, +then layer the supplied code-driven config over it. Rust hosts can use +`initialize_plugins_from_discovered_config(...)` for the same behavior. Omit +fields to inherit them from file-backed config, and set fields explicitly, +including `false` or `null`, to override lower-precedence values. + ```python 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/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 diff --git a/go/nemo_relay/plugin.go b/go/nemo_relay/plugin.go index c4a8affc..8f128695 100644 --- a/go/nemo_relay/plugin.go +++ b/go/nemo_relay/plugin.go @@ -26,6 +26,7 @@ typedef char* (*NemoRelayToolExecInterceptCb)(void* user_data, const char* args_ extern int32_t nemo_relay_validate_plugin_config(const char* config_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); @@ -98,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) }) @@ -243,8 +244,8 @@ func ValidatePluginConfig(config PluginConfig) (ConfigReport, error) { // 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 { @@ -549,7 +550,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..cddf216b 100644 --- a/go/nemo_relay/plugin_gap_test.go +++ b/go/nemo_relay/plugin_gap_test.go @@ -3,7 +3,11 @@ package nemo_relay -import "testing" +import ( + "os" + "path/filepath" + "testing" +) func TestPluginConfigSerializationErrorsSurfaceBeforeFFI(t *testing.T) { config := PluginConfig{ @@ -31,3 +35,87 @@ func TestPluginConfigSerializationErrorsSurfaceBeforeFFI(t *testing.T) { t.Fatal("expected InitializePlugins serialization error") } } + +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("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) + } + + 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 642ca3db..7f61b5da 100644 --- a/python/nemo_relay/_native.pyi +++ b/python/nemo_relay/_native.pyi @@ -2106,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 8568addd..4a7d26be 100644 --- a/python/nemo_relay/plugin.py +++ b/python/nemo_relay/plugin.py @@ -39,7 +39,7 @@ deregister_plugin as _deregister_plugin, ) from nemo_relay._native import ( - initialize_plugins as _initialize_plugins, + initialize_plugins_from_discovered_config as _initialize_plugins_from_discovered_config, ) from nemo_relay._native import ( list_plugin_kinds as _list_plugin_kinds, @@ -301,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: @@ -332,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. @@ -420,8 +423,8 @@ def deregister(plugin_kind: str) -> bool: "PluginContext", "Plugin", "clear", - "initialize", "deregister", + "initialize", "list_kinds", "register", "report", diff --git a/python/nemo_relay/plugin.pyi b/python/nemo_relay/plugin.pyi index 9e286830..782137c9 100644 --- a/python/nemo_relay/plugin.pyi +++ b/python/nemo_relay/plugin.pyi @@ -109,9 +109,9 @@ class PluginConfig: def to_dict(self) -> 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 new file mode 100644 index 00000000..01391702 --- /dev/null +++ b/python/tests/test_plugin_config.py @@ -0,0 +1,70 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from nemo_relay import JsonObject, plugin + + +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 53628922..55afc721 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,12 @@ 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, 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 `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(...)`.