From 231b157086a717d92aa53a17d35d6fda38f5fb7b Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Fri, 5 Jun 2026 09:07:35 -0700 Subject: [PATCH] (fix): - Added --plugin-config-file and NEMO_RELAY_PLUGIN_CONFIG_FILE. - Added it to both daemon startup args and nemo-relay run. - It loads TOML from the exact file path. - It skips normal discovered plugins.toml loading when present. - It conflicts with --plugin-config. - [plugins].config in config.toml still conflicts with plugin TOML sources, including the explicit file. - Missing or invalid plugin TOML produces a startup config error. Signed-off-by: Bryan Bednarski --- crates/cli/src/config.rs | 106 ++++++++++- crates/cli/src/launcher.rs | 1 + crates/cli/tests/coverage/config_tests.rs | 176 ++++++++++++++++++ crates/cli/tests/coverage/launcher_tests.rs | 9 + .../plugin-configuration-files.mdx | 16 +- 5 files changed, 299 insertions(+), 9 deletions(-) diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index 2307065f..28d2ab53 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -198,6 +198,9 @@ pub(crate) struct ServerArgs { /// Generic plugin configuration JSON for process-level gateway plugin activation. #[arg(long, env = "NEMO_RELAY_PLUGIN_CONFIG")] pub(crate) plugin_config: Option, + /// Path to plugin configuration TOML for process-level gateway plugin activation. + #[arg(long, env = "NEMO_RELAY_PLUGIN_CONFIG_FILE")] + pub(crate) plugin_config_file: Option, } impl ServerArgs { @@ -211,6 +214,7 @@ impl ServerArgs { || self.openai_base_url.is_some() || self.anthropic_base_url.is_some() || self.plugin_config.is_some() + || self.plugin_config_file.is_some() || self.config.is_some() } } @@ -269,6 +273,8 @@ pub(crate) struct RunCommand { #[arg(long)] pub(crate) plugin_config: Option, #[arg(long)] + pub(crate) plugin_config_file: Option, + #[arg(long)] pub(crate) dry_run: bool, #[arg(long)] pub(crate) print: bool, @@ -432,7 +438,12 @@ impl Default for GatewayConfig { /// File discovery and merge behavior live in `load_shared_config`; this function only applies the /// server-facing command-line layer so launcher-only settings cannot leak into daemon mode. pub(crate) fn resolve_server_config(args: &ServerArgs) -> Result { - let mut resolved = load_shared_config(args.config.as_ref())?; + validate_cli_plugin_config_sources( + args.plugin_config.as_ref(), + args.plugin_config_file.as_ref(), + )?; + let plugin_toml_override = load_plugin_config_file_override(args.plugin_config_file.as_ref())?; + let mut resolved = load_shared_config(args.config.as_ref(), plugin_toml_override)?; apply_server_overrides(&mut resolved.gateway, args)?; Ok(resolved) } @@ -450,14 +461,23 @@ pub(crate) fn resolve_run_config( .config .as_ref() .or_else(|| inherited.and_then(|args| args.config.as_ref())); - let mut resolved = load_shared_config(config)?; + validate_run_plugin_config_sources(command, inherited)?; + let plugin_config_file = command + .plugin_config_file + .as_ref() + .or_else(|| inherited.and_then(|args| args.plugin_config_file.as_ref())); + let plugin_toml_override = load_plugin_config_file_override(plugin_config_file)?; + let mut resolved = load_shared_config(config, plugin_toml_override)?; 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() { + if command.plugin_config.is_some() && args.plugin_config.is_some() + || command.plugin_config_file.is_some() && args.plugin_config_file.is_some() + { let mut inherited = args.clone(); inherited.plugin_config = None; + inherited.plugin_config_file = None; apply_server_overrides(&mut resolved.gateway, &inherited)?; } else { apply_server_overrides(&mut resolved.gateway, args)?; @@ -528,7 +548,10 @@ const PLUGINS_TOML: &str = "plugins.toml"; // shape onto runtime structs, applies a sibling/discovered plugins.toml when present, then lets // environment variables override file values. Invalid TOML or typed shapes fail closed because // they indicate an operator configuration error. -fn load_shared_config(explicit: Option<&PathBuf>) -> Result { +fn load_shared_config( + explicit: Option<&PathBuf>, + plugin_toml_override: Option, +) -> Result { let mut merged = toml::Value::Table(toml::map::Map::new()); let mut config_toml_plugin_sources = Vec::new(); for path in config_paths(explicit) { @@ -562,7 +585,10 @@ fn load_shared_config(explicit: Option<&PathBuf>) -> Result Some(plugin_toml), + None => load_plugin_toml_config(explicit)?, + }; let mut resolved = ResolvedConfig { gateway: GatewayConfig::default(), ..ResolvedConfig::default() @@ -740,6 +766,38 @@ fn load_plugin_toml_config( load_plugin_toml_config_from_paths(plugin_config_paths(explicit)) } +fn load_plugin_config_file_override( + path: Option<&PathBuf>, +) -> Result, CliError> { + path.map(|path| load_plugin_toml_config_from_path(path)) + .transpose() +} + +fn load_plugin_toml_config_from_path(path: &Path) -> Result { + let raw = std::fs::read_to_string(path).map_err(|error| { + CliError::Config(format!( + "could not read plugin config file {}: {error}", + path.display() + )) + })?; + let parsed = raw + .parse::() + .map(toml::Value::Table) + .map_err(|error| { + CliError::Config(format!( + "invalid plugin TOML in {}: {error}", + path.display() + )) + })?; + validate_plugin_toml_component_kinds(path, &parsed)?; + let value = serde_json::to_value(parsed) + .map_err(|error| CliError::Config(format!("invalid plugin TOML shape: {error}")))?; + Ok(PluginTomlConfig { + value, + sources: vec![path.to_path_buf()], + }) +} + fn load_plugin_toml_config_from_paths(paths: I) -> Result, CliError> where I: IntoIterator, @@ -800,6 +858,44 @@ fn apply_cli_plugin_config(config: &mut GatewayConfig, value: &str) -> Result<() Ok(()) } +fn validate_cli_plugin_config_sources( + plugin_config: Option<&String>, + plugin_config_file: Option<&PathBuf>, +) -> Result<(), CliError> { + if plugin_config.is_some() && plugin_config_file.is_some() { + return Err(CliError::Config( + "choose only one of --plugin-config or --plugin-config-file".into(), + )); + } + Ok(()) +} + +fn validate_run_plugin_config_sources( + command: &RunCommand, + inherited: Option<&ServerArgs>, +) -> Result<(), CliError> { + validate_cli_plugin_config_sources( + command.plugin_config.as_ref(), + command.plugin_config_file.as_ref(), + )?; + if let Some(inherited) = inherited { + validate_cli_plugin_config_sources( + inherited.plugin_config.as_ref(), + inherited.plugin_config_file.as_ref(), + )?; + } + let inline_present = command.plugin_config.is_some() + || inherited.is_some_and(|args| args.plugin_config.is_some()); + let file_present = command.plugin_config_file.is_some() + || inherited.is_some_and(|args| args.plugin_config_file.is_some()); + if inline_present && file_present { + return Err(CliError::Config( + "choose only one of --plugin-config or --plugin-config-file".into(), + )); + } + 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. diff --git a/crates/cli/src/launcher.rs b/crates/cli/src/launcher.rs index acf5d268..5fcc5b99 100644 --- a/crates/cli/src/launcher.rs +++ b/crates/cli/src/launcher.rs @@ -76,6 +76,7 @@ pub(crate) async fn easy_path( anthropic_base_url: None, session_metadata: None, plugin_config: None, + plugin_config_file: None, dry_run: false, print: false, command: command.command, diff --git a/crates/cli/tests/coverage/config_tests.rs b/crates/cli/tests/coverage/config_tests.rs index d7426da4..399735d0 100644 --- a/crates/cli/tests/coverage/config_tests.rs +++ b/crates/cli/tests/coverage/config_tests.rs @@ -125,6 +125,7 @@ command = "hermes --yolo chat" anthropic_base_url: None, session_metadata: None, plugin_config: None, + plugin_config_file: None, dry_run: false, print: false, command: vec![], @@ -180,6 +181,7 @@ fn legacy_observability_config_sections_fail_clearly() { anthropic_base_url: None, session_metadata: None, plugin_config: None, + plugin_config_file: None, dry_run: false, print: false, command: vec![], @@ -233,6 +235,7 @@ mode = "overwrite" anthropic_base_url: None, session_metadata: None, plugin_config: None, + plugin_config_file: None, dry_run: false, print: false, command: vec!["codex".into()], @@ -534,6 +537,7 @@ fn cli_plugin_config_conflicts_with_file_plugin_config() { anthropic_base_url: None, session_metadata: None, plugin_config: Some(r#"{"version":1,"components":[]}"#.into()), + plugin_config_file: None, dry_run: false, print: false, command: vec!["codex".into()], @@ -564,6 +568,7 @@ openai_base_url = "http://file-openai" anthropic_base_url: None, session_metadata: Some(r#"{"team":"cli"}"#.into()), plugin_config: None, + plugin_config_file: None, dry_run: false, print: false, command: vec!["codex".into()], @@ -599,6 +604,7 @@ openai_base_url = "http://file-openai" anthropic_base_url: None, session_metadata: None, plugin_config: None, + plugin_config_file: None, dry_run: false, print: false, command: vec!["codex".into()], @@ -624,6 +630,7 @@ fn run_plugin_config_overrides_inherited_top_level_plugin_config() { anthropic_base_url: None, session_metadata: None, plugin_config: Some(r#"{"components":["run"]}"#.into()), + plugin_config_file: None, dry_run: false, print: false, command: vec!["codex".into()], @@ -646,6 +653,7 @@ fn server_resolution_applies_all_server_overrides() { openai_base_url: Some("http://cli-openai".into()), anthropic_base_url: Some("http://cli-anthropic".into()), plugin_config: Some(r#"{"version":1,"components":[]}"#.into()), + plugin_config_file: None, }; let resolved = resolve_server_config(&args).unwrap(); @@ -660,6 +668,173 @@ fn server_resolution_applies_all_server_overrides() { assert!(args.requested_daemon_mode()); } +#[test] +fn plugin_config_file_overrides_discovered_plugins_toml() { + let temp = tempfile::tempdir().unwrap(); + let config_path = temp.path().join("config.toml"); + let explicit_plugin_path = temp.path().join("explicit-plugins.toml"); + std::fs::write(&config_path, "").unwrap(); + std::fs::write( + temp.path().join("plugins.toml"), + r#"version = 1 +components = ["discovered"] +"#, + ) + .unwrap(); + std::fs::write( + &explicit_plugin_path, + r#"version = 1 +components = ["explicit"] +"#, + ) + .unwrap(); + let args = ServerArgs { + config: Some(config_path), + plugin_config_file: Some(explicit_plugin_path), + ..ServerArgs::default() + }; + + let resolved = resolve_server_config(&args).unwrap(); + + assert_eq!( + resolved.gateway.plugin_config, + Some(json!({ "version": 1, "components": ["explicit"] })) + ); + assert!(args.requested_daemon_mode()); +} + +#[test] +fn plugin_config_file_conflicts_with_inline_plugin_config() { + let temp = tempfile::tempdir().unwrap(); + let plugin_path = temp.path().join("plugins.toml"); + std::fs::write(&plugin_path, "version = 1\n").unwrap(); + let args = ServerArgs { + plugin_config: Some(r#"{"version":1,"components":[]}"#.into()), + plugin_config_file: Some(plugin_path), + ..ServerArgs::default() + }; + + let error = resolve_server_config(&args).unwrap_err().to_string(); + + assert!(error.contains("choose only one of --plugin-config or --plugin-config-file")); +} + +#[test] +fn plugin_config_file_conflicts_with_config_toml_plugins_config() { + let temp = tempfile::tempdir().unwrap(); + let config_path = temp.path().join("config.toml"); + let plugin_path = temp.path().join("override-plugins.toml"); + std::fs::write( + &config_path, + r#" +[plugins] +config = { version = 1, components = [] } +"#, + ) + .unwrap(); + std::fs::write(&plugin_path, "version = 1\n").unwrap(); + let args = ServerArgs { + config: Some(config_path), + plugin_config_file: Some(plugin_path), + ..ServerArgs::default() + }; + + let error = resolve_server_config(&args).unwrap_err().to_string(); + + assert!(error.contains("plugin config is defined in both")); + assert!(error.contains("config.toml")); + assert!(error.contains("override-plugins.toml")); +} + +#[test] +fn plugin_config_file_reports_missing_and_invalid_files() { + let temp = tempfile::tempdir().unwrap(); + let missing = temp.path().join("missing-plugins.toml"); + let args = ServerArgs { + plugin_config_file: Some(missing.clone()), + ..ServerArgs::default() + }; + + let error = resolve_server_config(&args).unwrap_err().to_string(); + + assert!(error.contains("could not read plugin config file")); + assert!(error.contains("missing-plugins.toml")); + + let invalid = temp.path().join("invalid-plugins.toml"); + std::fs::write(&invalid, "version = [").unwrap(); + let args = ServerArgs { + plugin_config_file: Some(invalid), + ..ServerArgs::default() + }; + + let error = resolve_server_config(&args).unwrap_err().to_string(); + + assert!(error.contains("invalid plugin TOML")); +} + +#[test] +fn run_plugin_config_file_overrides_inherited_top_level_plugin_config_file() { + let temp = tempfile::tempdir().unwrap(); + let top_level_plugin_path = temp.path().join("top-level-plugins.toml"); + let run_plugin_path = temp.path().join("run-plugins.toml"); + std::fs::write(&top_level_plugin_path, "components = [\"top-level\"]\n").unwrap(); + std::fs::write(&run_plugin_path, "components = [\"run\"]\n").unwrap(); + let server = ServerArgs { + config: Some(isolated_config_path(&temp)), + plugin_config_file: Some(top_level_plugin_path), + ..ServerArgs::default() + }; + let command = RunCommand { + agent: Some(CodingAgent::Codex), + config: None, + openai_base_url: None, + anthropic_base_url: None, + session_metadata: None, + plugin_config: None, + plugin_config_file: Some(run_plugin_path), + 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 run_plugin_config_file_conflicts_with_inherited_inline_plugin_config() { + let temp = tempfile::tempdir().unwrap(); + let run_plugin_path = temp.path().join("run-plugins.toml"); + std::fs::write(&run_plugin_path, "components = [\"run\"]\n").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: None, + plugin_config_file: Some(run_plugin_path), + dry_run: false, + print: false, + command: vec!["codex".into()], + }; + + let error = resolve_run_config(&command, Some(&server)) + .unwrap_err() + .to_string(); + + assert!(error.contains("choose only one of --plugin-config or --plugin-config-file")); +} + #[test] fn run_resolution_applies_all_run_overrides() { let temp = tempfile::tempdir().unwrap(); @@ -670,6 +845,7 @@ fn run_resolution_applies_all_run_overrides() { anthropic_base_url: Some("http://run-anthropic".into()), session_metadata: Some(r#"{"team":"run"}"#.into()), plugin_config: Some(r#"{"components":["x"]}"#.into()), + plugin_config_file: None, dry_run: false, print: false, command: vec!["codex".into()], diff --git a/crates/cli/tests/coverage/launcher_tests.rs b/crates/cli/tests/coverage/launcher_tests.rs index c7fc25f9..4c4d0c54 100644 --- a/crates/cli/tests/coverage/launcher_tests.rs +++ b/crates/cli/tests/coverage/launcher_tests.rs @@ -19,6 +19,7 @@ fn infers_agent_from_command_or_uses_override() { anthropic_base_url: None, session_metadata: None, plugin_config: None, + plugin_config_file: None, dry_run: false, print: false, command: vec!["/usr/bin/codex".into()], @@ -52,6 +53,7 @@ fn uses_configured_command_when_no_argv_is_supplied() { anthropic_base_url: None, session_metadata: None, plugin_config: None, + plugin_config_file: None, dry_run: false, print: false, command: vec![], @@ -79,6 +81,7 @@ fn uses_configured_hermes_command_when_no_argv_is_supplied() { anthropic_base_url: None, session_metadata: None, plugin_config: None, + plugin_config_file: None, dry_run: false, print: false, command: vec![], @@ -99,6 +102,7 @@ fn inference_failure_has_actionable_message() { anthropic_base_url: None, session_metadata: None, plugin_config: None, + plugin_config_file: None, dry_run: false, print: false, command: vec!["my-agent".into()], @@ -124,6 +128,7 @@ fn missing_command_without_agent_errors() { anthropic_base_url: None, session_metadata: None, plugin_config: None, + plugin_config_file: None, dry_run: false, print: false, command: vec![], @@ -147,6 +152,7 @@ fn agent_without_configured_command_falls_back_to_default_binary() { anthropic_base_url: None, session_metadata: None, plugin_config: None, + plugin_config_file: None, dry_run: false, print: false, command: vec![], @@ -168,6 +174,7 @@ fn agent_with_passthrough_args_appends_to_configured_command() { anthropic_base_url: None, session_metadata: None, plugin_config: None, + plugin_config_file: None, dry_run: false, print: false, command: vec!["--model".into(), "openai/openai/gpt-5.1-codex".into()], @@ -711,6 +718,7 @@ async fn run_starts_gateway_injects_env_and_returns_agent_exit_code() { anthropic_base_url: None, session_metadata: None, plugin_config: None, + plugin_config_file: None, dry_run: false, print: false, command: command_argv, @@ -752,6 +760,7 @@ async fn dry_run_does_not_spawn_agent() { anthropic_base_url: None, session_metadata: None, plugin_config: None, + plugin_config_file: None, dry_run: true, print: false, command: vec!["/path/that/does/not/exist".into()], diff --git a/docs/build-plugins/plugin-configuration-files.mdx b/docs/build-plugins/plugin-configuration-files.mdx index 866d80d9..4b250fba 100644 --- a/docs/build-plugins/plugin-configuration-files.mdx +++ b/docs/build-plugins/plugin-configuration-files.mdx @@ -76,11 +76,18 @@ The gateway can receive plugin configuration from three source classes: |---|---| | `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-file path/to/plugins.toml` | Explicit file override for tests, demos, or wrappers that must bypass discovered plugin files. | | `--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. +`--plugin-config` and `--plugin-config-file` are both present, if file-based +plugin config and `--plugin-config` are both present, or if `plugins.toml` and +`[plugins].config` are both present. + +`--plugin-config-file` and `NEMO_RELAY_PLUGIN_CONFIG_FILE` load exactly the +specified TOML file and skip normal `plugins.toml` discovery. This lets an +operator override a sibling or project plugin file for one run without moving +files around. 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 @@ -253,8 +260,9 @@ 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 `--plugin-config-file` for automation that should use +a specific TOML file, and `--plugin-config` for automation that should pass +inline JSON without writing files. Legacy observability config sections in `config.toml`, such as `[exporters]`, `[observability]`, and `[export.openinference]`, are not supported. Configure