diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index 1121a1f56..5e48eb55f 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -423,9 +423,9 @@ 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, + /// Internal override for the plugin configuration file. + #[arg(long, env = "NEMO_RELAY_PLUGIN_CONFIG_PATH", hide = true)] + pub(crate) plugin_config_path: Option, /// Maximum accepted coding-agent hook payload size, in bytes. #[arg(long, env = "NEMO_RELAY_MAX_HOOK_PAYLOAD_BYTES")] pub(crate) max_hook_payload_bytes: Option, @@ -444,7 +444,7 @@ impl ServerArgs { self.bind.is_some() || self.openai_base_url.is_some() || self.anthropic_base_url.is_some() - || self.plugin_config.is_some() + || self.plugin_config_path.is_some() || self.max_hook_payload_bytes.is_some() || self.max_passthrough_body_bytes.is_some() || self.config.is_some() @@ -475,8 +475,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)] @@ -507,8 +505,9 @@ pub(crate) struct RunCommand { pub(crate) anthropic_base_url: Option, #[arg(long)] pub(crate) session_metadata: Option, - #[arg(long)] - pub(crate) plugin_config: Option, + /// Internal override for the plugin configuration file. + #[arg(long, env = "NEMO_RELAY_PLUGIN_CONFIG_PATH", hide = true)] + pub(crate) plugin_config_path: Option, #[arg(long)] pub(crate) dry_run: bool, #[arg(long)] @@ -625,13 +624,11 @@ pub(crate) struct AgentCommandConfig { } // TOML file shape grouped by user intent. Sections map 1:1 onto fields already present on -// `GatewayConfig` / `AgentConfigs`; plugin config is passed through to the runtime's generic -// `PluginConfig` activation path. +// `GatewayConfig` / `AgentConfigs`; plugin configuration lives in `plugins.toml`. #[derive(Debug, Clone, Default, Deserialize)] struct FileConfig { gateway: Option, upstream: Option, - plugins: Option, agents: Option, } @@ -647,12 +644,6 @@ struct FileUpstreamConfig { anthropic_base_url: Option, } -#[derive(Debug, Clone, Default, Deserialize)] -struct FilePluginsConfig { - // Generic plugin initialization shape. The gateway activates this process-wide at startup. - config: Option, -} - #[derive(Debug, Clone, Default, Deserialize)] struct FileAgentsConfig { // Keys match the agent's CLI invocation name (`claude`, `codex`, `hermes`) — the @@ -693,7 +684,7 @@ 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())?; + let mut resolved = load_shared_config(args.config.as_ref(), args.plugin_config_path.as_ref())?; apply_server_overrides(&mut resolved.gateway, args)?; enforce_required_dynamic_plugin_startup(args.config.as_ref(), &resolved)?; Ok(resolved) @@ -703,7 +694,7 @@ pub(crate) fn resolve_server_config(args: &ServerArgs) -> Result, ) -> Result { - load_shared_config(explicit) + load_shared_config(explicit, None) } /// Resolves transparent `run` configuration and switches the gateway to an ephemeral bind address. @@ -719,18 +710,13 @@ 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)?; + let plugin_config_path = command + .plugin_config_path + .as_ref() + .or_else(|| inherited.and_then(|args| args.plugin_config_path.as_ref())); + let mut resolved = load_shared_config(config, plugin_config_path)?; 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" @@ -770,9 +756,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(()) } @@ -788,9 +771,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)?; - } if let Some(value) = args.max_hook_payload_bytes { config.max_hook_payload_bytes = validate_body_limit("max hook payload bytes", value)?; } @@ -807,9 +787,11 @@ pub(crate) 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_config_path: Option<&PathBuf>, +) -> 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) { let Some(raw) = read_config_file(&path, explicit.is_some(), "configuration")? else { continue; @@ -829,29 +811,21 @@ fn load_shared_config(explicit: Option<&PathBuf>) -> Result 1 { - return Err(CliError::Config(format!( - "plugin config is defined in multiple config.toml files: {}; move it to one \ - [plugins].config block or use plugins.toml", - format_paths(&config_toml_plugin_sources) - ))); - } - let plugin_toml = load_plugin_toml_config(explicit)?; + let plugin_toml = load_plugin_toml_config(explicit, plugin_config_path)?; let mut resolved = ResolvedConfig { gateway: GatewayConfig::default(), ..ResolvedConfig::default() }; apply_file_config(&mut resolved, merged)?; - apply_plugin_toml_config( - &mut resolved, - config_toml_plugin_sources.first(), - plugin_toml, - )?; + apply_plugin_toml_config(&mut resolved, plugin_toml); apply_env_config(&mut resolved.gateway)?; Ok(resolved) } @@ -908,7 +882,13 @@ fn config_paths(explicit: Option<&PathBuf>) -> Vec { // Returns the plugin config search path. An explicit gateway config path scopes plugins.toml to the // same directory so `--config path/to/config.toml` can be extended by `path/to/plugins.toml` without // reading unrelated implicit project/user/global plugin files. -fn plugin_config_paths(explicit: Option<&PathBuf>) -> Vec { +fn plugin_config_paths( + explicit: Option<&PathBuf>, + plugin_config_path: Option<&PathBuf>, +) -> Vec { + if let Some(path) = plugin_config_path { + return vec![path.clone()]; + } if let Some(path) = explicit { return path .parent() @@ -980,7 +960,6 @@ fn apply_file_config(resolved: &mut ResolvedConfig, value: toml::Value) -> Resul })?; apply_file_gateway_config(&mut resolved.gateway, config.gateway)?; apply_file_upstream_config(&mut resolved.gateway, config.upstream); - apply_file_plugins_config(&mut resolved.gateway, config.plugins); apply_file_agents_config(&mut resolved.agents, config.agents); Ok(()) } @@ -1017,23 +996,11 @@ fn apply_file_upstream_config(gateway: &mut GatewayConfig, upstream: Option) { - let Some(plugins) = plugins else { - return; - }; - if let Some(value) = plugins.config { - gateway.plugin_config = Some(value); - } -} - #[derive(Debug, Clone)] struct PluginTomlConfig { value: Option, dynamic_plugins: Vec, dynamic_plugin_policy: DynamicPluginHostPolicy, - sources: Vec, } #[derive(Debug, Clone, Default, Deserialize)] @@ -1054,8 +1021,9 @@ struct FileDynamicPluginConfig { fn load_plugin_toml_config( explicit: Option<&PathBuf>, + plugin_config_path: Option<&PathBuf>, ) -> Result, CliError> { - load_plugin_toml_config_from_paths(plugin_config_paths(explicit)) + load_plugin_toml_config_from_paths(plugin_config_paths(explicit, plugin_config_path)) } fn load_plugin_toml_config_from_paths(paths: I) -> Result, CliError> @@ -1099,11 +1067,10 @@ where other => CliError::Config(other.to_string()), })?; match resolved { - Some((value, sources)) => Ok(Some(PluginTomlConfig { + Some((value, _sources)) => Ok(Some(PluginTomlConfig { value: plugin_toml_runtime_value(value), dynamic_plugins, dynamic_plugin_policy, - sources, })), None => Ok((!dynamic_plugins.is_empty() || dynamic_plugin_policy != DynamicPluginHostPolicy::default()) @@ -1111,34 +1078,19 @@ where value: None, dynamic_plugins, dynamic_plugin_policy, - sources: Vec::new(), })), } } -fn apply_plugin_toml_config( - resolved: &mut ResolvedConfig, - config_toml_plugin_source: Option<&PathBuf>, - plugin_toml: Option, -) -> Result<(), CliError> { +fn apply_plugin_toml_config(resolved: &mut ResolvedConfig, plugin_toml: Option) { let Some(plugin_toml) = plugin_toml else { - return Ok(()); + return; }; - if let Some(config_source) = config_toml_plugin_source - && plugin_toml.value.is_some() - { - return Err(CliError::Config(format!( - "plugin config is defined in both {} and {}; choose one source", - config_source.display(), - format_paths(&plugin_toml.sources) - ))); - } if let Some(value) = plugin_toml.value { resolved.gateway.plugin_config = Some(value); } resolved.dynamic_plugins = plugin_toml.dynamic_plugins; resolved.dynamic_plugin_policy = plugin_toml.dynamic_plugin_policy; - Ok(()) } struct ResolvedDynamicPluginRefs { @@ -1239,16 +1191,6 @@ fn remove_dynamic_plugin_sections(mut value: toml::Value) -> toml::Value { value } -fn apply_cli_plugin_config(config: &mut GatewayConfig, value: &str) -> 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 from the merged file configuration. fn apply_file_agents_config(agents: &mut AgentConfigs, file_agents: Option) { let Some(file_agents) = file_agents else { @@ -1323,13 +1265,6 @@ fn merge_toml(left: &mut toml::Value, right: toml::Value) { } } -fn has_config_toml_plugin_config(value: &toml::Value) -> bool { - value - .get("plugins") - .and_then(|plugins| plugins.get("config")) - .is_some() -} - fn legacy_observability_sections(value: &toml::Value) -> Vec<&'static str> { let mut sections = Vec::new(); if value.get("exporters").is_some() { @@ -1348,14 +1283,6 @@ fn legacy_observability_sections(value: &toml::Value) -> Vec<&'static str> { sections } -fn format_paths(paths: &[PathBuf]) -> String { - paths - .iter() - .map(|path| path.display().to_string()) - .collect::>() - .join(", ") -} - // Parses JSON-valued CLI options into runtime metadata/config values and labels errors with the // user-facing option name so callers can report which structured argument was malformed. fn parse_json_option(name: &str, value: &str) -> Result { diff --git a/crates/cli/src/installer.rs b/crates/cli/src/installer.rs index 69017888f..364f229e9 100644 --- a/crates/cli/src/installer.rs +++ b/crates/cli/src/installer.rs @@ -58,7 +58,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 { @@ -119,7 +118,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") @@ -376,7 +374,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(); @@ -386,7 +383,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 6e684be4b..0147c83a0 100644 --- a/crates/cli/src/launcher.rs +++ b/crates/cli/src/launcher.rs @@ -76,7 +76,7 @@ pub(crate) async fn easy_path( openai_base_url: None, anthropic_base_url: None, session_metadata: None, - plugin_config: None, + plugin_config_path: None, dry_run: false, print: false, command: command.command, diff --git a/crates/cli/src/setup/model.rs b/crates/cli/src/setup/model.rs index 45b137518..cd20afba0 100644 --- a/crates/cli/src/setup/model.rs +++ b/crates/cli/src/setup/model.rs @@ -113,9 +113,9 @@ pub(super) fn build_agents_table(answers: &SetupAnswers) -> Option { /// /// When `merge_scope` is `Some(agent)`, an existing `config.toml` at the target path is parsed /// and only the single `[agents.]` block owned by THIS wizard run is replaced. Other -/// `[agents.*]` blocks and hand-edited shared sections such as `[plugins]` are preserved when -/// omitted from the wizard output. When `merge_scope` is `None`, the file is overwritten outright -/// with the wizard's full output (the user explicitly chose which agents to include). +/// `[agents.*]` blocks are preserved when omitted from the wizard output. When `merge_scope` is +/// `None`, the file is overwritten outright with the wizard's full output (the user explicitly +/// chose which agents to include). /// /// Returns the list of paths written. `home` and `cwd` are explicit so tests can drive this with /// tempdirs. @@ -175,22 +175,14 @@ pub(super) fn write_or_merge( .parse() .map_err(|err| CliError::Config(format!("could not parse existing config: {err}")))?; let agent_key = agent_key_and_command(agent).0; - // `plugins` is not wizard-owned (users may hand-edit it). Preserve on omission. - merge_section(&mut existing, doc, "plugins"); + // Remove the legacy plugin configuration block so the merged config remains loadable after + // plugin configuration moved to plugins.toml. + existing.remove("plugins"); merge_agents_entry(&mut existing, doc, agent_key); std::fs::write(path, existing.to_string())?; Ok(()) } -// Copies a top-level section from `src` into `dst`, replacing any existing entry under the same -// key. If `src` does not contain the section, the existing entry in `dst` is left as-is. -// Use for shared/hand-edited sections the wizard does not own. -pub(super) fn merge_section(dst: &mut DocumentMut, src: &DocumentMut, key: &str) { - if let Some(item) = src.get(key) { - dst[key] = item.clone(); - } -} - // Replaces the single `[agents.]` block in `dst` with the one from `src`. If `src` does // not contain that block, the existing entry in `dst` is left as-is. pub(super) fn merge_agents_entry(dst: &mut DocumentMut, src: &DocumentMut, agent_key: &str) { diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index 8f2aa6952..91dfa0b71 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -147,6 +147,7 @@ fn cli_help_exits_successfully() { assert!(output.status.success()); assert!(String::from_utf8_lossy(&output.stdout).contains("Coding-agent gateway")); + assert!(!String::from_utf8_lossy(&output.stdout).contains("plugin-config")); } #[test] @@ -1534,6 +1535,27 @@ anthropic_base_url = "http://user-anthropic" [agents.codex] command = "codex --full-auto" +"#, + ) + .unwrap(); + let plugin_config = temp.path().join("override-plugins.toml"); + std::fs::write( + &plugin_config, + r#" +version = 1 + +[[components]] +kind = "observability" +enabled = true + +[components.config] +version = 1 + +[components.config.atof] +enabled = true +output_directory = "logs" +filename = "events.jsonl" +mode = "append" "#, ) .unwrap(); @@ -1547,6 +1569,7 @@ command = "codex --full-auto" .env("NEMO_RELAY_ANTHROPIC_BASE_URL", "http://env-anthropic") .env("NEMO_RELAY_MAX_HOOK_PAYLOAD_BYTES", "444") .env("NEMO_RELAY_MAX_PASSTHROUGH_BODY_BYTES", "555") + .env("NEMO_RELAY_PLUGIN_CONFIG_PATH", &plugin_config) .args(["run", "--agent", "codex", "--dry-run"]) .output() .unwrap(); @@ -1560,6 +1583,8 @@ command = "codex --full-auto" assert!(!stdout.contains("atif_dir")); assert!(!stdout.contains("openinference_endpoint")); assert!(stdout.contains("argv = codex")); + let expected_atof_path = std::path::Path::new("logs").join("events.jsonl"); + assert!(stdout.contains(&format!("ATOF {}", expected_atof_path.display()))); } #[test] @@ -1634,8 +1659,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 20b50e0f6..d4ee2602d 100644 --- a/crates/cli/tests/coverage/config_tests.rs +++ b/crates/cli/tests/coverage/config_tests.rs @@ -249,9 +249,6 @@ anthropic_base_url = "http://anthropic" max_hook_payload_bytes = 12345 max_passthrough_body_bytes = 67890 -[plugins] -config = { components = [] } - [agents.claude] command = "claude" @@ -269,7 +266,7 @@ command = "hermes --yolo chat" openai_base_url: None, anthropic_base_url: None, session_metadata: None, - plugin_config: None, + plugin_config_path: None, dry_run: false, print: false, command: vec![], @@ -283,10 +280,7 @@ command = "hermes --yolo chat" assert_eq!(resolved.gateway.max_hook_payload_bytes, 12345); assert_eq!(resolved.gateway.max_passthrough_body_bytes, 67890); assert_eq!(resolved.gateway.metadata, None); - assert_eq!( - resolved.gateway.plugin_config, - Some(json!({ "components": [] })) - ); + assert_eq!(resolved.gateway.plugin_config, None); assert_eq!( resolved.agents.codex.command.as_deref(), Some("codex --approval-mode never") @@ -307,7 +301,7 @@ fn explicit_config_must_exist() { openai_base_url: None, anthropic_base_url: None, session_metadata: None, - plugin_config: None, + plugin_config_path: None, dry_run: true, print: false, command: vec![], @@ -344,7 +338,7 @@ fn unreadable_config_errors_include_the_source_path() { openai_base_url: None, anthropic_base_url: None, session_metadata: None, - plugin_config: None, + plugin_config_path: None, dry_run: true, print: false, command: vec![], @@ -401,7 +395,7 @@ fn legacy_observability_config_sections_fail_clearly() { openai_base_url: None, anthropic_base_url: None, session_metadata: None, - plugin_config: None, + plugin_config_path: None, dry_run: false, print: false, command: vec![], @@ -454,7 +448,7 @@ mode = "overwrite" openai_base_url: None, anthropic_base_url: None, session_metadata: None, - plugin_config: None, + plugin_config_path: None, dry_run: false, print: false, command: vec!["codex".into()], @@ -490,7 +484,7 @@ fn plugins_toml_path_resolution_tracks_config_scope() { let temp = tempfile::tempdir().unwrap(); let explicit = temp.path().join("custom-config.toml"); assert_eq!( - plugin_config_paths(Some(&explicit)), + plugin_config_paths(Some(&explicit), None), vec![temp.path().join("plugins.toml")] ); @@ -1224,7 +1218,7 @@ kind = "observability" } #[test] -fn plugins_toml_conflicts_with_config_toml_plugins_config() { +fn config_toml_plugin_configuration_is_rejected() { let temp = tempfile::tempdir().unwrap(); let config_path = temp.path().join("config.toml"); std::fs::write( @@ -1235,7 +1229,6 @@ config = { version = 1, components = [] } "#, ) .unwrap(); - std::fs::write(temp.path().join("plugins.toml"), "version = 1\n").unwrap(); let args = ServerArgs { config: Some(config_path), ..ServerArgs::default() @@ -1243,71 +1236,38 @@ config = { version = 1, components = [] } 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("plugin configuration")); + assert!(error.contains("no longer supported")); assert!(error.contains("plugins.toml")); } #[test] -fn plugins_toml_with_only_dynamic_plugins_preserves_config_toml_plugin_config() { - let temp = tempfile::tempdir().unwrap(); - let plugin_dir = temp.path().join("plugins/acme"); - std::fs::create_dir_all(&plugin_dir).unwrap(); - write_dynamic_manifest(&plugin_dir, "acme.worker"); - let config_path = temp.path().join("config.toml"); - std::fs::write( - &config_path, - r#" -[plugins] -config = { version = 1, components = [] } -"#, - ) - .unwrap(); - std::fs::write( - temp.path().join("plugins.toml"), - r#" -[[plugins.dynamic]] -manifest = "plugins/acme/relay-plugin.toml" -"#, - ) - .unwrap(); - let args = ServerArgs { - config: Some(config_path), - ..ServerArgs::default() - }; - - let resolved = resolve_server_config(&args).unwrap(); - - assert_eq!( - resolved.gateway.plugin_config, - Some(json!({ "version": 1, "components": [] })) - ); - assert_eq!(resolved.dynamic_plugins.len(), 1); - assert_eq!(resolved.dynamic_plugins[0].plugin_id, "acme.worker"); -} - -#[test] -fn cli_plugin_config_conflicts_with_file_plugin_config() { +fn plugin_config_path_overrides_sibling_plugin_file() { let temp = tempfile::tempdir().unwrap(); let config_path = temp.path().join("config.toml"); + let sibling_path = temp.path().join("plugins.toml"); + let override_path = temp.path().join("override.toml"); std::fs::write(&config_path, "").unwrap(); - std::fs::write(temp.path().join("plugins.toml"), "version = 1\n").unwrap(); + std::fs::write(&sibling_path, "version = 1\n").unwrap(); + std::fs::write(&override_path, "version = 2\n").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, + plugin_config_path: Some(override_path), + dry_run: true, 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": 2 })) + ); } #[test] @@ -1328,7 +1288,7 @@ 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, + plugin_config_path: None, dry_run: false, print: false, command: vec!["codex".into()], @@ -1363,7 +1323,7 @@ openai_base_url = "http://file-openai" openai_base_url: None, anthropic_base_url: None, session_metadata: None, - plugin_config: None, + plugin_config_path: None, dry_run: false, print: false, command: vec!["codex".into()], @@ -1374,36 +1334,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 config_path = isolated_config_path(&temp); - std::fs::write(&config_path, "").unwrap(); - let server = ServerArgs { - config: Some(config_path), - 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(); @@ -1414,7 +1344,7 @@ 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()), + plugin_config_path: None, max_hook_payload_bytes: Some(222), max_passthrough_body_bytes: Some(333), }; @@ -1426,10 +1356,7 @@ fn server_resolution_applies_all_server_overrides() { assert_eq!(resolved.gateway.anthropic_base_url, "http://cli-anthropic"); assert_eq!(resolved.gateway.max_hook_payload_bytes, 222); assert_eq!(resolved.gateway.max_passthrough_body_bytes, 333); - assert_eq!( - resolved.gateway.plugin_config, - Some(json!({ "version": 1, "components": [] })) - ); + assert_eq!(resolved.gateway.plugin_config, None); assert!(args.requested_daemon_mode()); } @@ -1794,7 +1721,7 @@ 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()), + plugin_config_path: None, dry_run: false, print: false, command: vec!["codex".into()], @@ -1805,10 +1732,6 @@ 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"] })) - ); } #[test] @@ -1839,7 +1762,7 @@ allowed = false openai_base_url: None, anthropic_base_url: None, session_metadata: None, - plugin_config: None, + plugin_config_path: None, dry_run: false, print: false, command: vec!["codex".into()], @@ -1897,7 +1820,7 @@ fn recursive_toml_merge_replaces_scalars_and_preserves_tables() { openai_base_url = "http://old" anthropic_base_url = "http://anthropic" -[plugins.config] +[runtime.policy] version = 1 policy = { unknown_component = "warn", unknown_field = "warn" } "# @@ -1908,7 +1831,7 @@ policy = { unknown_component = "warn", unknown_field = "warn" } [upstream] openai_base_url = "http://new" -[plugins.config.policy] +[runtime.policy.policy] unknown_component = "error" "# .parse::() @@ -1926,11 +1849,11 @@ unknown_component = "error" Some("http://anthropic") ); assert_eq!( - left["plugins"]["config"]["policy"]["unknown_component"].as_str(), + left["runtime"]["policy"]["policy"]["unknown_component"].as_str(), Some("error") ); assert_eq!( - left["plugins"]["config"]["policy"]["unknown_field"].as_str(), + left["runtime"]["policy"]["policy"]["unknown_field"].as_str(), Some("warn") ); } diff --git a/crates/cli/tests/coverage/installer_tests.rs b/crates/cli/tests/coverage/installer_tests.rs index 03b7515b6..9a5b96073 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 273ac307b..1615642cd 100644 --- a/crates/cli/tests/coverage/launcher_tests.rs +++ b/crates/cli/tests/coverage/launcher_tests.rs @@ -60,7 +60,7 @@ fn infers_agent_from_command_or_uses_override() { openai_base_url: None, anthropic_base_url: None, session_metadata: None, - plugin_config: None, + plugin_config_path: None, dry_run: false, print: false, command: vec!["/usr/bin/codex".into()], @@ -93,7 +93,7 @@ fn uses_configured_command_when_no_argv_is_supplied() { openai_base_url: None, anthropic_base_url: None, session_metadata: None, - plugin_config: None, + plugin_config_path: None, dry_run: false, print: false, command: vec![], @@ -120,7 +120,7 @@ fn uses_configured_hermes_command_when_no_argv_is_supplied() { openai_base_url: None, anthropic_base_url: None, session_metadata: None, - plugin_config: None, + plugin_config_path: None, dry_run: false, print: false, command: vec![], @@ -140,7 +140,7 @@ fn inference_failure_has_actionable_message() { openai_base_url: None, anthropic_base_url: None, session_metadata: None, - plugin_config: None, + plugin_config_path: None, dry_run: false, print: false, command: vec!["my-agent".into()], @@ -165,7 +165,7 @@ fn missing_command_without_agent_errors() { openai_base_url: None, anthropic_base_url: None, session_metadata: None, - plugin_config: None, + plugin_config_path: None, dry_run: false, print: false, command: vec![], @@ -188,7 +188,7 @@ fn agent_without_configured_command_falls_back_to_default_binary() { openai_base_url: None, anthropic_base_url: None, session_metadata: None, - plugin_config: None, + plugin_config_path: None, dry_run: false, print: false, command: vec![], @@ -209,7 +209,7 @@ fn agent_with_passthrough_args_appends_to_configured_command() { openai_base_url: None, anthropic_base_url: None, session_metadata: None, - plugin_config: None, + plugin_config_path: None, dry_run: false, print: false, command: vec!["--model".into(), "openai/openai/gpt-5.1-codex".into()], @@ -878,7 +878,7 @@ 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, + plugin_config_path: None, dry_run: false, print: false, command: command_argv, @@ -919,7 +919,7 @@ async fn dry_run_does_not_spawn_agent() { openai_base_url: None, anthropic_base_url: None, session_metadata: None, - plugin_config: None, + plugin_config_path: None, dry_run: true, print: false, command: vec!["/path/that/does/not/exist".into()], @@ -980,7 +980,7 @@ entrypoint = "acme.worker:create_plugin" openai_base_url: None, anthropic_base_url: None, session_metadata: None, - plugin_config: None, + plugin_config_path: None, dry_run: true, print: false, command: vec!["codex".into()], diff --git a/crates/cli/tests/coverage/setup_tests.rs b/crates/cli/tests/coverage/setup_tests.rs index f1242104f..52deb42de 100644 --- a/crates/cli/tests/coverage/setup_tests.rs +++ b/crates/cli/tests/coverage/setup_tests.rs @@ -455,7 +455,8 @@ fn write_or_merge_recovers_from_non_table_agents_value() { agents = "not-a-table" [plugins] -enabled = true +config = { version = 1, components = [] } + "#, ) .unwrap(); @@ -470,7 +471,7 @@ enabled = true let merged = std::fs::read_to_string(path).unwrap(); assert!(merged.contains("[agents.codex]")); assert!(merged.contains(r#"command = "codex""#)); - assert!(merged.contains("[plugins]")); + assert!(!merged.contains("[plugins]")); } #[test] diff --git a/docs/build-plugins/plugin-configuration-files.mdx b/docs/build-plugins/plugin-configuration-files.mdx index 72a219c66..c1a206932 100644 --- a/docs/build-plugins/plugin-configuration-files.mdx +++ b/docs/build-plugins/plugin-configuration-files.mdx @@ -127,20 +127,16 @@ The gateway reads only files named `plugins.toml`. ## Discovery -The gateway resolves plugin configuration using two kinds of input: file and CLI -sources and an optional code-driven layer. +The gateway resolves plugin configuration from `plugins.toml` files and an +optional code-driven layer. -File and CLI configuration comes from one of three mutually exclusive source classes: +File configuration comes from `plugins.toml`: | 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 file and CLI source class for a given gateway run. The gateway fails -clearly if file-based plugin config and `--plugin-config` are both present, or if -`plugins.toml` and `[plugins].config` are both present. +Plugin configuration is not read 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 @@ -347,10 +343,8 @@ when a source is unreadable or the catalog schema is invalid. and agent command configuration. `plugins.toml` owns reusable runtime behavior 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. +Keep all long-lived plugin setup in `plugins.toml`. `config.toml` owns gateway +and agent setup only. Legacy observability config sections in `config.toml`, such as `[exporters]`, `[observability]`, and `[export.openinference]`, are not supported. Configure diff --git a/docs/nemo-relay-cli/basic-usage.mdx b/docs/nemo-relay-cli/basic-usage.mdx index 94979e592..171bf3cd2 100644 --- a/docs/nemo-relay-cli/basic-usage.mdx +++ b/docs/nemo-relay-cli/basic-usage.mdx @@ -361,7 +361,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/integrations/coding-agents/README.md b/integrations/coding-agents/README.md index 2487ca66f..9a9c09a0c 100644 --- a/integrations/coding-agents/README.md +++ b/integrations/coding-agents/README.md @@ -193,7 +193,6 @@ Useful wrapper options: - `--session-metadata ''` adds structured metadata to the agent begin event. -- `--plugin-config ''` records scope-local plugin configuration metadata. - `--profile ` records a configuration profile in session metadata. - `--gateway-mode hook-only|passthrough|required` records the expected gateway behavior in session metadata.