diff --git a/crates/cli/README.md b/crates/cli/README.md index 2178713a4..b2d39ea10 100644 --- a/crates/cli/README.md +++ b/crates/cli/README.md @@ -152,8 +152,9 @@ nemo-relay run --agent codex --dry-run Project config lives at `./.nemo-relay/config.toml`; user config lives at `~/.config/nemo-relay/config.toml` or `$XDG_CONFIG_HOME/nemo-relay/config.toml`. -The project layer overrides system config, and the user layer overrides the -project layer. +Runtime files layer from lowest to highest precedence as explicit-or-user, +nearest project, then system. An explicit `--config` replaces the ambient user +file without suppressing project or system configuration. Set up agent entries in the top-level config with: @@ -174,8 +175,9 @@ Unix) and reject authorization headers; use the corresponding environment variables or a user config for credentials. When the top-level CLI receives `--config path/to/config.toml`, the config -editor uses that exact file. An explicit `config edit --user`, `--project`, or -`--global` flag overrides the inherited target. +editor uses that exact file as its user target, so the default editor and +`config edit --user` both open it. Use `--project` or `--global` to edit the +other active layers. Observability exporters are configured through the plugin config. Edit the user plugin config with: @@ -186,8 +188,9 @@ nemo-relay plugins edit When the top-level CLI receives `--plugin-config-path`, the editor uses that exact file. Otherwise, `--config path/to/config.toml` makes the editor use the -sibling `path/to/plugins.toml`, matching runtime selection. An explicit -`--user`, `--project`, or `--global` editor flag overrides the inherited target. +sibling `path/to/plugins.toml`, matching runtime selection. The explicit file +replaces the user layer, so `--user` keeps that inherited target. +`--project` and `--global` edit the other active layers. The top-level editor menu contains one entry per supported built-in, followed by the dynamic plugin references in the selected physical `plugins.toml`. Dynamic @@ -202,6 +205,12 @@ The canonical plugin file is `plugins.toml`; user config lives at not store credentials there. The editor rejects schema-declared secret values in global plugin configuration. +Runtime plugin files layer from lowest to highest precedence as +explicit-or-user, nearest project, then system. An explicit +`--plugin-config-path`, or a `plugins.toml` beside `--config`, replaces the +ambient XDG user file without suppressing project or system policy. Missing +files are skipped, and symlink aliases to one physical file are loaded once. + Minimal ATIF example: ```toml diff --git a/crates/cli/src/commands/configure/editor.rs b/crates/cli/src/commands/configure/editor.rs index 30c9fc37b..5be154746 100644 --- a/crates/cli/src/commands/configure/editor.rs +++ b/crates/cli/src/commands/configure/editor.rs @@ -81,7 +81,7 @@ fn resolve_edit_target( explicit_path: Option, ) -> Result<(TargetScope, PathBuf), CliError> { let scope = TargetScope::from(command); - let path = if command.user || command.project || command.global { + let path = if command.project || command.global { target_path(scope)? } else { match explicit_path { diff --git a/crates/cli/src/commands/configure/mod.rs b/crates/cli/src/commands/configure/mod.rs index b181d4927..a353b9f52 100644 --- a/crates/cli/src/commands/configure/mod.rs +++ b/crates/cli/src/commands/configure/mod.rs @@ -44,7 +44,7 @@ pub(crate) enum ConfigSubcommand { .multiple(false) ))] pub(crate) struct ConfigEditCommand { - /// Edit the user config at `$XDG_CONFIG_HOME/nemo-relay/config.toml`. + /// Edit explicit `--config`, otherwise `$XDG_CONFIG_HOME/nemo-relay/config.toml`. #[arg(long)] pub(crate) user: bool, /// Edit the nearest project config at `.nemo-relay/config.toml`. diff --git a/crates/cli/src/commands/configure/model.rs b/crates/cli/src/commands/configure/model.rs index e69bdb34b..273be01fc 100644 --- a/crates/cli/src/commands/configure/model.rs +++ b/crates/cli/src/commands/configure/model.rs @@ -42,9 +42,10 @@ pub(crate) fn plugins_edit_command_for_scope( scope: ConfigScope, explicit_path: Option, ) -> PluginsEditRequest { - let scope = match scope { - ConfigScope::Project | ConfigScope::Both => ConfigurationScope::Project, - ConfigScope::Global => ConfigurationScope::User, + let scope = match (&explicit_path, scope) { + (Some(_), _) => ConfigurationScope::User, + (None, ConfigScope::Project | ConfigScope::Both) => ConfigurationScope::Project, + (None, ConfigScope::Global) => ConfigurationScope::User, }; PluginsEditRequest { scope, diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 635d5bdd7..1a5e158f8 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -192,8 +192,12 @@ async fn run_default( // `nemo-relay config` remains the reconfiguration path. if runtime_args.requested_daemon_mode() { let resolved = runtime_configuration::resolve_server_config(&runtime_args)?; - let dynamic_plugins = crate::plugins::lifecycle::active_dynamic_plugin_components( + let explicit_plugin_config = crate::configuration::explicit_plugin_config_path( runtime_args.config.as_ref(), + runtime_args.plugin_config_path.as_ref(), + ); + let dynamic_plugins = crate::plugins::lifecycle::active_dynamic_plugin_components( + explicit_plugin_config.as_ref(), &resolved, )?; let managed_bootstrap = runtime_configuration::managed_bootstrap_identity( diff --git a/crates/cli/src/commands/plugins/subcommands.rs b/crates/cli/src/commands/plugins/subcommands.rs index 77d9ec291..47f9c4de5 100644 --- a/crates/cli/src/commands/plugins/subcommands.rs +++ b/crates/cli/src/commands/plugins/subcommands.rs @@ -72,7 +72,7 @@ impl PluginsSubcommand { .multiple(false) ))] pub(crate) struct PluginsScopeArgs { - /// Edit the user config at `$XDG_CONFIG_HOME/nemo-relay/plugins.toml`. + /// Edit the selected low layer: an explicit plugin target, or the XDG user config. #[arg(long)] pub(crate) user: bool, /// Edit the nearest project config at `.nemo-relay/plugins.toml`. @@ -169,11 +169,12 @@ impl PluginsEditCommand { explicit_path: Option, ) -> crate::plugins::PluginsEditRequest { let scope = self.scope.into(); - let explicit_path = if matches!(scope, crate::plugins::ConfigurationScope::Default) { - explicit_path - } else { - None - }; + let explicit_path = matches!( + scope, + crate::plugins::ConfigurationScope::Default | crate::plugins::ConfigurationScope::User + ) + .then_some(explicit_path) + .flatten(); crate::plugins::PluginsEditRequest { explicit_path, scope, diff --git a/crates/cli/src/commands/serve.rs b/crates/cli/src/commands/serve.rs index 3b83fff9e..c611281d3 100644 --- a/crates/cli/src/commands/serve.rs +++ b/crates/cli/src/commands/serve.rs @@ -8,7 +8,7 @@ use clap::Args; #[derive(Debug, Clone, Default, Args)] pub(crate) struct ServerArgs { - /// Path to an explicit config file (disables auto-discovery of workspace/global/system) + /// Path replacing the user config layer; project and system config still apply #[arg(long)] pub(super) config: Option, /// Address for the gateway to listen on in daemon mode (default 127.0.0.1:4040) diff --git a/crates/cli/src/configuration/mod.rs b/crates/cli/src/configuration/mod.rs index 2a6be133d..af6e5d850 100644 --- a/crates/cli/src/configuration/mod.rs +++ b/crates/cli/src/configuration/mod.rs @@ -10,7 +10,7 @@ use std::collections::HashSet; use std::env; use std::fs::{self, OpenOptions}; use std::io::{Read, Seek, SeekFrom, Write}; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use std::thread; use std::time::{Duration, Instant}; @@ -19,7 +19,9 @@ use nemo_relay::logging::LoggingConfig; use nemo_relay::plugin::dynamic::{ DYNAMIC_PLUGIN_MANIFEST_FILENAME, DynamicPluginManifest, DynamicPluginManifestLoad, }; -use nemo_relay::plugin::{PluginError, merge_plugin_config_documents}; +use nemo_relay::plugin::{ + PluginError, deduplicate_plugin_config_paths, merge_plugin_config_documents, +}; use ring::rand::{SecureRandom, SystemRandom}; use ring::{digest, hmac}; use serde::Deserialize; @@ -93,7 +95,9 @@ struct FileAgentCommandConfig { pub(crate) fn resolve_server_config(args: &GatewayOverrides) -> Result { 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)?; + let explicit_plugin_config = + explicit_plugin_config_path(args.config.as_ref(), args.plugin_config_path.as_ref()); + enforce_required_dynamic_plugin_startup(explicit_plugin_config.as_ref(), &resolved)?; log::info!( target: "nemo_relay.configuration", event = "configuration_resolved", @@ -116,7 +120,8 @@ pub(crate) fn resolve_logging_config( let user_only = user_only || user_config_scope(); let mut merged = toml::Value::Table(toml::map::Map::new()); for path in config_paths_scoped(explicit.as_ref(), user_only) { - let Some(raw) = read_config_file(&path, explicit.is_some(), "configuration")? else { + let required = explicit.as_ref() == Some(&path); + let Some(raw) = read_config_file(&path, required, "configuration")? else { continue; }; let parsed = raw @@ -125,7 +130,7 @@ pub(crate) fn resolve_logging_config( .map_err(|error| { CliError::Config(format!("invalid TOML in {}: {error}", path.display())) })?; - merge_toml(&mut merged, parsed); + merge_gateway_config_toml(&mut merged, parsed); } if merged.get("logging").is_none() { @@ -856,10 +861,18 @@ fn load_or_create_bootstrap_hmac_key_at_with_timeout( } /// Resolves shared config for plugin-facing CLI commands without mutating gateway runtime fields. +#[cfg(test)] pub(crate) fn resolve_plugins_config( explicit: Option<&PathBuf>, ) -> Result { - let resolved = load_shared_config(explicit, None)?; + resolve_plugins_config_with_path(explicit, None) +} + +pub(crate) fn resolve_plugins_config_with_path( + explicit: Option<&PathBuf>, + plugin_config_path: Option<&PathBuf>, +) -> Result { + let resolved = load_shared_config(explicit, plugin_config_path)?; log::info!( target: "nemo_relay.configuration", event = "plugin_configuration_resolved", @@ -895,7 +908,8 @@ pub(crate) fn resolve_run_config( .parse() .expect("valid transparent bind address"); if !command.dry_run { - enforce_required_dynamic_plugin_startup(config, &resolved)?; + let explicit_plugin_config = explicit_plugin_config_path(config, plugin_config_path); + enforce_required_dynamic_plugin_startup(explicit_plugin_config.as_ref(), &resolved)?; } log::info!( target: "nemo_relay.configuration", @@ -919,10 +933,18 @@ fn apply_run_overrides(config: &mut GatewayConfig, command: &RunOverrides) -> Re // from JSON options whose errors should include field context. fn apply_run_url_overrides(config: &mut GatewayConfig, command: &RunOverrides) { if let Some(value) = &command.openai_base_url { - config.openai_base_url = value.clone(); + replace_upstream_base_url( + &mut config.openai_base_url, + &mut config.openai_auth_header, + value.clone(), + ); } if let Some(value) = &command.anthropic_base_url { - config.anthropic_base_url = value.clone(); + replace_upstream_base_url( + &mut config.anthropic_base_url, + &mut config.anthropic_auth_header, + value.clone(), + ); } } @@ -948,10 +970,18 @@ fn apply_server_overrides( config.bind = value; } if let Some(value) = &args.openai_base_url { - config.openai_base_url = value.clone(); + replace_upstream_base_url( + &mut config.openai_base_url, + &mut config.openai_auth_header, + value.clone(), + ); } if let Some(value) = &args.anthropic_base_url { - config.anthropic_base_url = value.clone(); + replace_upstream_base_url( + &mut config.anthropic_base_url, + &mut config.anthropic_auth_header, + value.clone(), + ); } if let Some(value) = args.max_hook_payload_bytes { config.max_hook_payload_bytes = validate_body_limit("max hook payload bytes", value)?; @@ -983,7 +1013,8 @@ fn load_shared_config_scoped( ) -> Result { let mut merged = toml::Value::Table(toml::map::Map::new()); for path in config_paths_scoped(explicit, user_only) { - let Some(raw) = read_config_file(&path, explicit.is_some(), "configuration")? else { + let required = explicit == Some(&path); + let Some(raw) = read_config_file(&path, required, "configuration")? else { continue; }; let parsed = raw @@ -1051,32 +1082,31 @@ pub(crate) fn any_config_file_exists() -> bool { config_paths(None).iter().any(|path| path.exists()) } -// Returns the config search path. An explicit path disables implicit discovery; otherwise system -// config is lowest priority, the nearest project config is next, and user config is merged last. +// Returns the config search path from lowest to highest precedence. An explicit path replaces the +// ambient user file; project discovery and the system layer still apply. fn config_paths(explicit: Option<&PathBuf>) -> Vec { config_paths_scoped(explicit, user_config_scope()) } fn config_paths_scoped(explicit: Option<&PathBuf>, user_only: bool) -> Vec { + let mut paths = Vec::new(); if let Some(path) = explicit { - return vec![path.clone()]; + paths.push(path.clone()); + } else if let Some(user) = user_config_path() { + paths.push(user); } - let mut paths = vec![PathBuf::from("/etc/nemo-relay/config.toml")]; if !user_only && let Ok(cwd) = std::env::current_dir() && let Some(project) = find_project_config(&cwd) { paths.push(project); } - if let Some(user) = user_config_path() { - paths.push(user); - } + paths.push(PathBuf::from("/etc/nemo-relay/config.toml")); paths } -// 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. +// Returns the plugin config search path from lowest to highest precedence. An explicit plugin +// target replaces the ambient user file; project discovery and the system layer still apply. fn plugin_config_paths( explicit: Option<&PathBuf>, plugin_config_path: Option<&PathBuf>, @@ -1089,21 +1119,24 @@ fn plugin_config_paths_scoped( plugin_config_path: Option<&PathBuf>, user_only: bool, ) -> Vec { - if plugin_config_path.is_some() || explicit.is_some() { - return explicit_plugin_config_path(explicit, plugin_config_path) - .into_iter() - .collect(); - } - if user_only { - return implicit_plugin_config_paths(None, user_config_dir()); + let cwd = if user_only { + None + } else { + std::env::current_dir().ok() + }; + if let Some(path) = explicit_plugin_config_path(explicit, plugin_config_path) { + let mut paths = vec![path]; + paths.extend(implicit_plugin_config_paths(cwd.as_deref(), None)); + return paths; } - implicit_plugin_config_paths(std::env::current_dir().ok().as_deref(), user_config_dir()) + implicit_plugin_config_paths(cwd.as_deref(), user_config_dir()) } -/// Resolves the single plugin document selected by explicit gateway configuration. +/// Resolves the low-precedence plugin document selected by explicit gateway configuration. /// -/// An explicit plugin path wins. Otherwise an explicit `config.toml` selects its sibling -/// `plugins.toml`, matching runtime loading. `None` means normal layered discovery applies. +/// An explicit plugin path wins over the ambient user file. Otherwise an explicit `config.toml` +/// selects its sibling `plugins.toml`, matching runtime loading. `None` means normal user-layer +/// discovery applies. pub(crate) fn explicit_plugin_config_path( config_path: Option<&PathBuf>, plugin_config_path: Option<&PathBuf>, @@ -1127,7 +1160,7 @@ fn implicit_plugin_config_paths( // Walks upward from the current directory and returns the nearest project-local gateway config. // The first hit wins so nested projects can override parent workspace defaults. -fn find_project_config(start: &std::path::Path) -> Option { +pub(crate) fn find_project_config(start: &std::path::Path) -> Option { for ancestor in start.ancestors() { let path = ancestor.join(".nemo-relay/config.toml"); if path.exists() { @@ -1273,13 +1306,6 @@ struct FileDynamicPluginConfig { config: Option>, } -fn load_plugin_toml_config( - explicit: Option<&PathBuf>, - plugin_config_path: Option<&PathBuf>, -) -> Result, CliError> { - load_plugin_toml_config_scoped(explicit, plugin_config_path, user_config_scope()) -} - fn load_plugin_toml_config_scoped( explicit: Option<&PathBuf>, plugin_config_path: Option<&PathBuf>, @@ -1294,8 +1320,8 @@ fn load_plugin_toml_config_scoped( /// Returns the plugin configuration paths selected by the same rules as runtime resolution. /// -/// Diagnostics use this so an explicit gateway configuration reports only its sibling -/// `plugins.toml`, rather than unrelated discovered plugin configuration. +/// Diagnostics use this so they report the same explicit-or-user, project, and system layers as +/// runtime resolution. pub(crate) fn diagnostic_plugin_config_paths( explicit: Option<&PathBuf>, plugin_config_path: Option<&PathBuf>, @@ -1309,7 +1335,14 @@ pub(crate) fn effective_plugin_toml_sources( explicit: Option<&PathBuf>, plugin_config_path: Option<&PathBuf>, ) -> Result, CliError> { - let Some(config) = load_plugin_toml_config(explicit, plugin_config_path)? else { + effective_plugin_toml_sources_from_paths(plugin_config_paths(explicit, plugin_config_path)) +} + +fn effective_plugin_toml_sources_from_paths(paths: I) -> Result, CliError> +where + I: IntoIterator, +{ + let Some(config) = load_plugin_toml_config_from_paths(paths)? else { return Ok(Vec::new()); }; let mut sources = config.contributing_sources; @@ -1322,7 +1355,7 @@ fn load_plugin_toml_config_from_paths(paths: I) -> Result, { - let paths = paths.into_iter().collect::>(); + let paths = deduplicate_plugin_config_paths(paths); let mut dynamic_plugins = Vec::new(); let mut dynamic_plugin_policy = DynamicPluginHostPolicy::default(); let mut seen_plugin_ids = HashSet::new(); @@ -1359,7 +1392,7 @@ where } // Delegate merged runtime plugin config to the shared core primitive after dynamic refs have - // been validated independently. File precedence stays unchanged for the generic runtime path. + // been validated independently. Documents remain ordered from lowest to highest precedence. let resolved = merge_plugin_config_documents(runtime_documents).map_err(|err| match err { PluginError::InvalidConfig(message) => CliError::Config(message), other => CliError::Config(other.to_string()), @@ -1529,10 +1562,11 @@ fn apply_env_config(config: &mut GatewayConfig) -> Result<(), CliError> { } let openai_auth_header = std::env::var("NEMO_RELAY_OPENAI_AUTH_HEADER").ok(); if let Ok(value) = std::env::var("NEMO_RELAY_OPENAI_BASE_URL") { - config.openai_base_url = value; - if openai_auth_header.is_none() { - config.openai_auth_header = None; - } + replace_upstream_base_url( + &mut config.openai_base_url, + &mut config.openai_auth_header, + value, + ); } if let Some(value) = openai_auth_header { config.openai_auth_header = Some(validate_auth_header( @@ -1542,10 +1576,11 @@ fn apply_env_config(config: &mut GatewayConfig) -> Result<(), CliError> { } let anthropic_auth_header = std::env::var("NEMO_RELAY_ANTHROPIC_AUTH_HEADER").ok(); if let Ok(value) = std::env::var("NEMO_RELAY_ANTHROPIC_BASE_URL") { - config.anthropic_base_url = value; - if anthropic_auth_header.is_none() { - config.anthropic_auth_header = None; - } + replace_upstream_base_url( + &mut config.anthropic_base_url, + &mut config.anthropic_auth_header, + value, + ); } if let Some(value) = anthropic_auth_header { config.anthropic_auth_header = Some(validate_auth_header( @@ -1564,6 +1599,17 @@ fn apply_env_config(config: &mut GatewayConfig) -> Result<(), CliError> { Ok(()) } +fn replace_upstream_base_url( + base_url: &mut String, + auth_header: &mut Option, + replacement: String, +) { + if *base_url != replacement { + *auth_header = None; + } + *base_url = replacement; +} + fn validate_auth_header(name: &str, value: String) -> Result { let value = value.trim().to_string(); if value.is_empty() { @@ -1606,10 +1652,16 @@ fn merge_toml(left: &mut toml::Value, right: toml::Value) { } } -// Upstream credentials are bound to their configured endpoint. A higher-priority layer that -// changes an endpoint without supplying a replacement credential must not inherit the credential -// for the old endpoint. -fn merge_gateway_config_toml(left: &mut toml::Value, right: toml::Value) { +// Upstream credentials are bound to the exact configured base URL, which is the identity of the +// provider's singleton upstream. A higher-priority layer that changes that identity without +// supplying a replacement credential must not inherit the credential for the old endpoint. +fn merge_gateway_config_toml(left: &mut toml::Value, mut right: toml::Value) { + clear_credentials_for_replaced_upstreams(left, &right); + merge_logging_sinks_by_path(left, &mut right); + merge_toml(left, right); +} + +fn clear_credentials_for_replaced_upstreams(left: &mut toml::Value, right: &toml::Value) { if let (Some(existing), Some(override_upstream)) = ( left.get_mut("upstream").and_then(toml::Value::as_table_mut), right.get("upstream").and_then(toml::Value::as_table), @@ -1626,7 +1678,123 @@ fn merge_gateway_config_toml(left: &mut toml::Value, right: toml::Value) { } } } - merge_toml(left, right); +} + +fn merge_logging_sinks_by_path(left: &toml::Value, right: &mut toml::Value) { + let lower = left + .get("logging") + .and_then(|logging| logging.get("sinks")) + .and_then(toml::Value::as_array) + .cloned() + .unwrap_or_default(); + let Some(higher_value) = right + .get_mut("logging") + .and_then(toml::Value::as_table_mut) + .and_then(|logging| logging.get_mut("sinks")) + else { + return; + }; + let Some(higher) = higher_value.as_array().cloned() else { + return; + }; + *higher_value = toml::Value::Array(merge_logging_sink_lists(lower, higher)); +} + +fn merge_logging_sink_lists(lower: Vec, higher: Vec) -> Vec { + let lower = coalesce_logging_sinks(lower); + let higher = coalesce_logging_sinks(higher); + let mut lower_used = vec![false; lower.len()]; + let mut merged = Vec::with_capacity(lower.len() + higher.len()); + + for higher_sink in higher { + let identity = logging_sink_identity(&higher_sink); + let lower_match = identity.as_ref().and_then(|path| { + lower + .iter() + .position(|sink| logging_sink_identity(sink).as_ref() == Some(path)) + }); + if let Some(index) = lower_match { + let mut sink = lower[index].clone(); + merge_toml(&mut sink, higher_sink); + lower_used[index] = true; + merged.push(sink); + } else { + merged.push(higher_sink); + } + } + + merged.extend( + lower + .into_iter() + .enumerate() + .filter_map(|(index, sink)| (!lower_used[index]).then_some(sink)), + ); + merged +} + +fn coalesce_logging_sinks(sinks: Vec) -> Vec { + let mut coalesced: Vec = Vec::with_capacity(sinks.len()); + for sink in sinks { + let identity = logging_sink_identity(&sink); + let existing = identity.as_ref().and_then(|path| { + coalesced + .iter() + .position(|candidate| logging_sink_identity(candidate).as_ref() == Some(path)) + }); + if let Some(index) = existing { + merge_toml(&mut coalesced[index], sink); + } else { + coalesced.push(sink); + } + } + coalesced +} + +fn logging_sink_path(sink: &toml::Value) -> Option<&str> { + sink.as_table()?.get("path")?.as_str() +} + +fn logging_sink_identity(sink: &toml::Value) -> Option { + let path = Path::new(logging_sink_path(sink)?); + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir().ok()?.join(path) + }; + Some(logging_path_identity(&absolute)) +} + +// Keep merge-time identity aligned with core logging's runtime duplicate-path detection: prefer +// canonical paths, canonicalize an existing parent for not-yet-created sinks, then fall back to +// lexical component normalization. +fn logging_path_identity(path: &Path) -> PathBuf { + if let Ok(canonical) = std::fs::canonicalize(path) { + return canonical; + } + match path.parent() { + Some(parent) if !parent.as_os_str().is_empty() => { + let file_name = path.file_name().unwrap_or_default(); + if let Ok(canonical_parent) = std::fs::canonicalize(parent) { + return canonical_parent.join(file_name); + } + normalize_path_components(parent).join(file_name) + } + _ => normalize_path_components(path), + } +} + +fn normalize_path_components(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + other => normalized.push(other.as_os_str()), + } + } + normalized } fn legacy_observability_sections(value: &toml::Value) -> Vec<&'static str> { diff --git a/crates/cli/src/diagnostics/mod.rs b/crates/cli/src/diagnostics/mod.rs index 3d3714d6f..fe4d1be68 100644 --- a/crates/cli/src/diagnostics/mod.rs +++ b/crates/cli/src/diagnostics/mod.rs @@ -144,7 +144,8 @@ fn collect_configuration( ) -> ConfigurationInfo { let explicit_config = gateway_overrides.config.is_some(); let workspace_path = cwd - .map(|p| p.join(".nemo-relay").join("config.toml")) + .and_then(crate::configuration::find_project_config) + .or_else(|| cwd.map(|p| p.join(".nemo-relay").join("config.toml"))) .unwrap_or_else(|| PathBuf::from(".nemo-relay/config.toml")); // Use the same XDG-aware resolver the config loader uses, so doctor reports the path the // runtime would actually read instead of a hard-coded `$HOME/.config/nemo-relay`. @@ -153,23 +154,17 @@ fn collect_configuration( .or_else(|| home.map(|h| h.join(".config").join("nemo-relay").join("config.toml"))) .unwrap_or_else(|| PathBuf::from("~/.config/nemo-relay/config.toml")); let system_path = PathBuf::from("/etc/nemo-relay/config.toml"); - let workspace = gateway_overrides - .config - .as_deref() - .map_or_else(|| layer_status(&workspace_path), layer_status); + let explicit = gateway_overrides.config.as_deref().map(layer_status); + let workspace = layer_status(&workspace_path); let global = if explicit_config { - ignored_layer_status(&global_path) + replaced_user_layer_status(&global_path) } else { layer_status(&global_path) }; - let system = if explicit_config { - ignored_layer_status(&system_path) - } else { - layer_status(&system_path) - }; + let system = layer_status(&system_path); ConfigurationInfo { - explicit_config, + explicit, workspace, global, system, @@ -312,12 +307,12 @@ fn layer_status(path: &Path) -> ConfigLayer { } } -fn ignored_layer_status(path: &Path) -> ConfigLayer { +fn replaced_user_layer_status(path: &Path) -> ConfigLayer { ConfigLayer { path: path.to_path_buf(), status: Status::Info, active: false, - details: "not selected because --config scopes configuration".into(), + details: "replaced by explicit --config".into(), } } diff --git a/crates/cli/src/diagnostics/model.rs b/crates/cli/src/diagnostics/model.rs index 4d27e14f1..a034a02e9 100644 --- a/crates/cli/src/diagnostics/model.rs +++ b/crates/cli/src/diagnostics/model.rs @@ -50,8 +50,8 @@ pub(crate) struct EnvironmentInfo { #[derive(Debug, Clone, Serialize)] pub(crate) struct ConfigurationInfo { - #[serde(skip)] - pub explicit_config: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub explicit: Option, pub workspace: ConfigLayer, pub global: ConfigLayer, pub system: ConfigLayer, diff --git a/crates/cli/src/diagnostics/render.rs b/crates/cli/src/diagnostics/render.rs index 25c3c5366..6b58141b4 100644 --- a/crates/cli/src/diagnostics/render.rs +++ b/crates/cli/src/diagnostics/render.rs @@ -16,6 +16,11 @@ pub(crate) fn exit_code(report: &DoctorReport) -> u8 { .iter() .any(|agent| matches!(agent.status, Status::Fail)) || report.host_plugins.iter().any(|plugin| !plugin.ok()) + || report + .configuration + .explicit + .as_ref() + .is_some_and(|layer| matches!(layer.status, Status::Fail)) || matches!(report.configuration.workspace.status, Status::Fail) || matches!(report.configuration.global.status, Status::Fail) || matches!(report.configuration.system.status, Status::Fail) @@ -38,6 +43,11 @@ pub(super) fn report_has_warn(report: &DoctorReport) -> bool { .iter() .any(|agent| matches!(agent.status, Status::Warn)) || report.host_plugins.iter().any(|plugin| !plugin.ok()) + || report + .configuration + .explicit + .as_ref() + .is_some_and(|layer| matches!(layer.status, Status::Warn)) || matches!(report.configuration.workspace.status, Status::Warn) || matches!(report.configuration.global.status, Status::Warn) || matches!(report.configuration.system.status, Status::Warn) @@ -86,13 +96,11 @@ pub(super) fn format_human_environment(out: &mut String, report: &DoctorReport) pub(super) fn format_human_configuration(out: &mut String, report: &DoctorReport) { out.push_str(" Configuration\n"); - let workspace_label = if report.configuration.explicit_config { - "Explicit" - } else { - "Workspace" - }; + if let Some(explicit) = &report.configuration.explicit { + out.push_str(&format!(" Explicit {}\n", format_layer(explicit))); + } out.push_str(&format!( - " {workspace_label:<11}{}\n", + " Workspace {}\n", format_layer(&report.configuration.workspace) )); out.push_str(&format!( diff --git a/crates/cli/src/plugins/lifecycle/mod.rs b/crates/cli/src/plugins/lifecycle/mod.rs index 103d046d9..734754c74 100644 --- a/crates/cli/src/plugins/lifecycle/mod.rs +++ b/crates/cli/src/plugins/lifecycle/mod.rs @@ -17,9 +17,11 @@ use nemo_relay::plugin::dynamic::{ use serde_json::{Map, Value}; use sha2::{Digest, Sha256}; +#[cfg(test)] +use crate::configuration::resolve_plugins_config; use crate::configuration::{ - ResolvedConfig, ResolvedDynamicPluginConfig, load_bounded_dynamic_plugin_manifest_bytes, - resolve_plugins_config, + ResolvedConfig, ResolvedDynamicPluginConfig, explicit_plugin_config_path, + load_bounded_dynamic_plugin_manifest_bytes, resolve_plugins_config_with_path, }; use crate::error::{CliError, PluginLifecycleFailureKind}; use crate::filesystem::bounded::{ @@ -85,6 +87,10 @@ pub(crate) fn test_python_environment_digest_calls() -> usize { self::environment::environment_tree_digest_calls() } +fn lifecycle_plugin_config_path(server: &GatewayOverrides) -> Option { + explicit_plugin_config_path(server.config.as_ref(), server.plugin_config_path.as_ref()) +} + pub(crate) fn add(command: PluginsAddRequest, server: &GatewayOverrides) -> Result<(), CliError> { add_with_environment_runner(command, server, &ProcessPythonEnvironmentCommandRunner) } @@ -96,8 +102,12 @@ fn add_with_environment_runner( ) -> Result<(), CliError> { const COMMAND: &str = "plugins add"; - let resolved = resolve_plugins_config(server.config.as_ref())?; - let mut scopes = load_and_hydrate_scopes(server.config.as_ref(), &resolved)?; + let explicit_plugin_config = lifecycle_plugin_config_path(server); + let resolved = resolve_plugins_config_with_path( + server.config.as_ref(), + server.plugin_config_path.as_ref(), + )?; + let mut scopes = load_and_hydrate_scopes(explicit_plugin_config.as_ref(), &resolved)?; let (manifest, manifest_ref) = load_manifest_for_action("add", &command.path)?; let plugin_id = manifest.plugin.id.trim().to_owned(); load_config_schema_for_manifest(&manifest, &manifest_ref)?; @@ -112,15 +122,17 @@ fn add_with_environment_runner( None => false, }; - if server.config.is_some() && scope_flags_selected(&command.scope) { + if explicit_plugin_config.is_some() && scope_flags_selected(&command.scope) { return Err(CliError::Config( - "--config cannot be combined with --user, --project, or --global for `plugins add`" + "--config cannot be combined with --user, --project, or --global for `plugins add`; the same applies to --plugin-config-path" .into(), )); } - let (plugins_toml_path, state_path, scope) = - scoped_paths_for_add(target_scope(&command.scope)?, server.config.as_ref())?; + let (plugins_toml_path, state_path, scope) = scoped_paths_for_add( + target_scope(&command.scope)?, + explicit_plugin_config.as_ref(), + )?; let scope_index = ensure_scope(&mut scopes, scope, plugins_toml_path.clone(), state_path); let policy = evaluate_dynamic_plugin_host_policy(&resolved.dynamic_plugin_policy, &manifest); let trust = evaluate_dynamic_plugin_trust(&manifest, &manifest_ref, &policy); @@ -231,10 +243,11 @@ fn cleanup_provisioned_environment(state_path: &Path, plugin_id: &str, environme } pub(crate) fn enforce_required_dynamic_plugin_startup( - explicit: Option<&PathBuf>, + explicit_plugin_config: Option<&PathBuf>, resolved: &ResolvedConfig, ) -> Result<(), CliError> { - let (scopes, touched_scope_indices) = load_and_hydrate_scopes_with_updates(explicit, resolved)?; + let (scopes, touched_scope_indices) = + load_and_hydrate_scopes_with_updates(explicit_plugin_config, resolved)?; for scope_index in touched_scope_indices { scopes[scope_index].save()?; } @@ -267,7 +280,10 @@ pub(crate) fn validate( format!("dynamic plugin target '{}' does not exist", command.target), )); } - let resolved = resolve_plugins_config(server.config.as_ref())?; + let resolved = resolve_plugins_config_with_path( + server.config.as_ref(), + server.plugin_config_path.as_ref(), + )?; let (manifest, manifest_ref) = load_manifest_for_action("validate", &path)?; validate_python_entrypoint_artifact(&manifest, &manifest_ref) .map_err(CliError::Config)?; @@ -304,9 +320,13 @@ pub(crate) fn validate( Ok(()) } PluginTarget::Id(plugin_id) => { - let resolved = resolve_plugins_config(server.config.as_ref())?; + let explicit_plugin_config = lifecycle_plugin_config_path(server); + let resolved = resolve_plugins_config_with_path( + server.config.as_ref(), + server.plugin_config_path.as_ref(), + )?; let host_config_by_id = host_config_by_id(&resolved); - let mut scopes = load_and_hydrate_scopes(server.config.as_ref(), &resolved)?; + let mut scopes = load_and_hydrate_scopes(explicit_plugin_config.as_ref(), &resolved)?; let entry = find_registered_entry(&scopes, "plugins validate", &plugin_id)?; let manifest_ref = manifest_ref_from_record(&entry.record)?; let (manifest, manifest_ref) = load_manifest_for_action("validate", &manifest_ref)?; @@ -364,9 +384,13 @@ pub(crate) fn validate( } pub(crate) fn list(command: PluginsListRequest, server: &GatewayOverrides) -> Result<(), CliError> { - let resolved = resolve_plugins_config(server.config.as_ref())?; + let explicit_plugin_config = lifecycle_plugin_config_path(server); + let resolved = resolve_plugins_config_with_path( + server.config.as_ref(), + server.plugin_config_path.as_ref(), + )?; let host_config_by_id = host_config_by_id(&resolved); - let scopes = load_and_hydrate_scopes(server.config.as_ref(), &resolved)?; + let scopes = load_and_hydrate_scopes(explicit_plugin_config.as_ref(), &resolved)?; let records = collect_records(&scopes, command.all); if records.is_empty() { if command.json { @@ -404,9 +428,13 @@ pub(crate) fn inspect( command: PluginsInspectRequest, server: &GatewayOverrides, ) -> Result<(), CliError> { - let resolved = resolve_plugins_config(server.config.as_ref())?; + let explicit_plugin_config = lifecycle_plugin_config_path(server); + let resolved = resolve_plugins_config_with_path( + server.config.as_ref(), + server.plugin_config_path.as_ref(), + )?; let host_config_by_id = host_config_by_id(&resolved); - let scopes = load_and_hydrate_scopes(server.config.as_ref(), &resolved)?; + let scopes = load_and_hydrate_scopes(explicit_plugin_config.as_ref(), &resolved)?; let entry = find_registered_entry(&scopes, "plugins inspect", &command.id)?; let manifest_ref = manifest_ref_from_record(&entry.record)?; let (manifest, manifest_ref) = load_manifest_for_action("inspect", &manifest_ref)?; @@ -451,10 +479,14 @@ pub(crate) fn remove( command: PluginsRemoveRequest, server: &GatewayOverrides, ) -> Result<(), CliError> { - let mut scopes = load_scoped_registries(server.config.as_ref())?; + let explicit_plugin_config = lifecycle_plugin_config_path(server); + let mut scopes = load_scoped_registries(explicit_plugin_config.as_ref())?; if find_record_by_id(&scopes, &command.id)?.is_none() { - let resolved = resolve_plugins_config(server.config.as_ref())?; - scopes = load_and_hydrate_scopes(server.config.as_ref(), &resolved)?; + let resolved = resolve_plugins_config_with_path( + server.config.as_ref(), + server.plugin_config_path.as_ref(), + )?; + scopes = load_and_hydrate_scopes(explicit_plugin_config.as_ref(), &resolved)?; } let entry = find_registered_entry(&scopes, "plugins remove", &command.id)?; let original_plugins_toml = std::fs::read(&entry.plugins_toml_path).ok(); @@ -1743,26 +1775,26 @@ fn make_snapshot_removable(root: &Path) { } pub(crate) fn active_dynamic_plugin_components( - explicit: Option<&PathBuf>, + explicit_plugin_config: Option<&PathBuf>, resolved: &ResolvedConfig, ) -> Result, CliError> { - active_dynamic_plugin_components_inner(explicit, resolved, true) + active_dynamic_plugin_components_inner(explicit_plugin_config, resolved, true) } pub(crate) fn active_dynamic_plugin_components_for_identity( - explicit: Option<&PathBuf>, + explicit_plugin_config: Option<&PathBuf>, resolved: &ResolvedConfig, ) -> Result, CliError> { - let scopes = load_scoped_registries(explicit)?; + let scopes = load_scoped_registries(explicit_plugin_config)?; active_dynamic_plugin_components_from_scopes(&scopes, resolved, false) } fn active_dynamic_plugin_components_inner( - explicit: Option<&PathBuf>, + explicit_plugin_config: Option<&PathBuf>, resolved: &ResolvedConfig, create_activation_snapshots: bool, ) -> Result, CliError> { - let scopes = load_and_hydrate_scopes(explicit, resolved)?; + let scopes = load_and_hydrate_scopes(explicit_plugin_config, resolved)?; active_dynamic_plugin_components_from_scopes(&scopes, resolved, create_activation_snapshots) } @@ -1838,9 +1870,13 @@ fn mutate_enabled_state( } else { "plugins disable" }; + let explicit_plugin_config = lifecycle_plugin_config_path(server); let mut scopes = if enabled { - let resolved = resolve_plugins_config(server.config.as_ref())?; - let mut scopes = load_and_hydrate_scopes(server.config.as_ref(), &resolved)?; + let resolved = resolve_plugins_config_with_path( + server.config.as_ref(), + server.plugin_config_path.as_ref(), + )?; + let mut scopes = load_and_hydrate_scopes(explicit_plugin_config.as_ref(), &resolved)?; let entry = find_registered_entry(&scopes, command, &plugin_id)?; if entry.record.is_tombstoned() { return Err(plugin_refused( @@ -1905,7 +1941,7 @@ fn mutate_enabled_state( } scopes } else { - load_scoped_registries(server.config.as_ref())? + load_scoped_registries(explicit_plugin_config.as_ref())? }; let entry = find_registered_entry(&scopes, command, &plugin_id)?; if entry.record.is_tombstoned() { @@ -1941,10 +1977,11 @@ fn mutate_enabled_state( } fn load_and_hydrate_scopes( - explicit: Option<&PathBuf>, + explicit_plugin_config: Option<&PathBuf>, resolved: &ResolvedConfig, ) -> Result, CliError> { - let (scopes, touched_scope_indices) = load_and_hydrate_scopes_with_updates(explicit, resolved)?; + let (scopes, touched_scope_indices) = + load_and_hydrate_scopes_with_updates(explicit_plugin_config, resolved)?; for scope_index in touched_scope_indices { scopes[scope_index].save()?; } @@ -1952,10 +1989,10 @@ fn load_and_hydrate_scopes( } fn load_and_hydrate_scopes_with_updates( - explicit: Option<&PathBuf>, + explicit_plugin_config: Option<&PathBuf>, resolved: &ResolvedConfig, ) -> Result<(Vec, Vec), CliError> { - let mut scopes = load_scoped_registries(explicit)?; + let mut scopes = load_scoped_registries(explicit_plugin_config)?; let mut touched_scope_indices = BTreeSet::new(); for plugin in &resolved.dynamic_plugins { let scope_index = scopes diff --git a/crates/cli/src/plugins/lifecycle/state.rs b/crates/cli/src/plugins/lifecycle/state.rs index 5d01aab22..bc7b7b912 100644 --- a/crates/cli/src/plugins/lifecycle/state.rs +++ b/crates/cli/src/plugins/lifecycle/state.rs @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +use std::collections::HashSet; use std::io::Write; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -10,8 +11,7 @@ use serde::{Deserialize, Serialize}; use strum::{Display, IntoStaticStr}; use crate::configuration::{ - PLUGINS_TOML, global_plugin_config_path, project_plugin_config_path, user_config_dir, - user_plugin_config_path, + global_plugin_config_path, project_plugin_config_path, user_plugin_config_path, }; use crate::error::CliError; @@ -109,9 +109,9 @@ impl ScopedRegistry { } pub(super) fn load_scoped_registries( - explicit: Option<&PathBuf>, + explicit_plugin_config: Option<&PathBuf>, ) -> Result, CliError> { - scoped_registry_layouts(explicit)? + scoped_registry_layouts(explicit_plugin_config) .into_iter() .map(|(scope, plugins_toml_path, state_path)| { Ok(ScopedRegistry { @@ -126,18 +126,12 @@ pub(super) fn load_scoped_registries( pub(super) fn scoped_paths_for_add( scope: TargetScope, - explicit: Option<&PathBuf>, + explicit_plugin_config: Option<&PathBuf>, ) -> Result<(PathBuf, PathBuf, RegistryScope), CliError> { - if let Some(explicit) = explicit { - let parent = explicit.parent().ok_or_else(|| { - CliError::Config(format!( - "explicit config path {} has no parent directory", - explicit.display() - )) - })?; + if let Some(explicit_plugin_config) = explicit_plugin_config { return Ok(( - parent.join(PLUGINS_TOML), - parent.join(DYNAMIC_PLUGIN_STATE_FILENAME), + explicit_plugin_config.clone(), + sibling_state_path(explicit_plugin_config), RegistryScope::Explicit, )); } @@ -228,29 +222,25 @@ pub(super) fn find_record_by_id( } fn scoped_registry_layouts( - explicit: Option<&PathBuf>, -) -> Result, CliError> { - if let Some(explicit) = explicit { - let parent = explicit.parent().ok_or_else(|| { - CliError::Config(format!( - "explicit config path {} has no parent directory", - explicit.display() - )) - })?; - let plugins_toml_path = parent.join(PLUGINS_TOML); - return Ok(vec![( + explicit_plugin_config: Option<&PathBuf>, +) -> Vec<(RegistryScope, PathBuf, PathBuf)> { + let mut layouts = Vec::new(); + if let Some(explicit_plugin_config) = explicit_plugin_config { + layouts.push(( RegistryScope::Explicit, + explicit_plugin_config.clone(), + sibling_state_path(explicit_plugin_config), + )); + } else if let Some(plugins_toml_path) = user_plugin_config_path() { + layouts.push(( + RegistryScope::User, plugins_toml_path.clone(), sibling_state_path(&plugins_toml_path), - )]); + )); } - let mut layouts = vec![( - RegistryScope::Global, - global_plugin_config_path(), - sibling_state_path(&global_plugin_config_path()), - )]; - if let Ok(cwd) = std::env::current_dir() { + let user_only = std::env::var("NEMO_RELAY_CONFIG_SCOPE").ok().as_deref() == Some("user"); + if !user_only && let Ok(cwd) = std::env::current_dir() { let plugins_toml_path = project_plugin_config_path(&cwd); layouts.push(( RegistryScope::Project, @@ -258,15 +248,23 @@ fn scoped_registry_layouts( sibling_state_path(&plugins_toml_path), )); } - if let Some(user_dir) = user_config_dir() { - let plugins_toml_path = user_dir.join(PLUGINS_TOML); - layouts.push(( - RegistryScope::User, - plugins_toml_path.clone(), - sibling_state_path(&plugins_toml_path), - )); + let plugins_toml_path = global_plugin_config_path(); + layouts.push(( + RegistryScope::Global, + plugins_toml_path.clone(), + sibling_state_path(&plugins_toml_path), + )); + + let mut seen = HashSet::new(); + let mut unique = Vec::with_capacity(layouts.len()); + for layout in layouts.into_iter().rev() { + let identity = layout.1.canonicalize().unwrap_or_else(|_| layout.1.clone()); + if seen.insert(identity) { + unique.push(layout); + } } - Ok(layouts) + unique.reverse(); + unique } fn read_registry(path: &Path) -> Result { diff --git a/crates/cli/src/plugins/types.rs b/crates/cli/src/plugins/types.rs index 950ead3af..84fd091d1 100644 --- a/crates/cli/src/plugins/types.rs +++ b/crates/cli/src/plugins/types.rs @@ -18,7 +18,7 @@ pub(crate) enum ConfigurationScope { #[derive(Debug, Clone, Default)] pub(crate) struct PluginsEditRequest { pub(crate) scope: ConfigurationScope, - /// Physical file inherited from top-level runtime configuration. + /// Low-layer physical file inherited from top-level runtime configuration. pub(crate) explicit_path: Option, } #[derive(Debug, Clone, Default)] diff --git a/crates/cli/src/process/launcher.rs b/crates/cli/src/process/launcher.rs index c62764d93..81ed72569 100644 --- a/crates/cli/src/process/launcher.rs +++ b/crates/cli/src/process/launcher.rs @@ -67,11 +67,20 @@ impl TransparentRun { .config .as_ref() .or_else(|| inherited.and_then(|args| args.config.as_ref())); + let plugin_config_path = command + .plugin_config_path + .as_ref() + .or_else(|| inherited.and_then(|args| args.plugin_config_path.as_ref())); + let explicit_plugin_config = + crate::configuration::explicit_plugin_config_path(explicit_config, plugin_config_path); let mut resolved = resolve_run_config(&command, inherited)?; let dynamic_plugins = if dry_run { Vec::new() } else { - crate::plugins::lifecycle::active_dynamic_plugin_components(explicit_config, &resolved)? + crate::plugins::lifecycle::active_dynamic_plugin_components( + explicit_plugin_config.as_ref(), + &resolved, + )? }; let invocation = resolve_agent_invocation(&command, &resolved.agents)?; let agent = invocation.agent; diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index 3ef47abdb..b74e2e8dc 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -5,7 +5,7 @@ use std::io::{BufRead, BufReader, Read, Write}; use std::net::{SocketAddr, TcpListener, TcpStream}; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::{Child, ChildStdin, Command, ExitStatus, Output, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc; @@ -311,6 +311,57 @@ fn cli_jsonl_logging_records_successful_command_lifecycle_without_leaking_secret ); } +#[test] +fn cli_layered_logging_path_aliases_initialize_one_sink() { + let temp = tempfile::tempdir().unwrap(); + let cwd = temp.path().join("workspace"); + let project_config = cwd.join(".nemo-relay/config.toml"); + let explicit_config = temp.path().join("explicit/config.toml"); + std::fs::create_dir_all(project_config.parent().unwrap()).unwrap(); + std::fs::create_dir_all(explicit_config.parent().unwrap()).unwrap(); + std::fs::write( + &explicit_config, + r#" +[[logging.sinks]] +path = "relay.log" +level = "debug" +queue_capacity = 64 +"#, + ) + .unwrap(); + std::fs::write( + &project_config, + r#" +[[logging.sinks]] +path = "./relay.log" +level = "info" +format = "jsonl" +"#, + ) + .unwrap(); + + let output = Command::new(gateway_bin()) + .current_dir(&cwd) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("HOME", temp.path()) + .args([ + "--config", + explicit_config.to_str().unwrap(), + "agents", + "--json", + ]) + .output() + .unwrap(); + + assert!( + output.status.success(), + "layered aliases should initialize one logging sink: {}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice::(&output.stdout).unwrap(); + assert!(!String::from_utf8_lossy(&output.stderr).contains("duplicate logging sink path")); +} + #[test] fn cli_claude_startup_probe_bypass_is_debug_only() { let default_stderr = run_claude_startup_probe(None); @@ -3204,15 +3255,54 @@ fn cli_doctor_json_reports_a_missing_explicit_config() { } #[test] -fn cli_doctor_explicit_config_ignores_invalid_workspace_runtime_config() { +fn cli_doctor_reports_the_nearest_ancestor_workspace_config() { + let temp = tempfile::tempdir().unwrap(); + let project = temp.path().join("workspace"); + let nested = project.join("services/relay"); + let project_config = project.join(".nemo-relay/config.toml"); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::create_dir_all(project_config.parent().unwrap()).unwrap(); + std::fs::write( + &project_config, + "[gateway]\nmax_hook_payload_bytes = 1048576\n", + ) + .unwrap(); + + let output = Command::new(gateway_bin()) + .current_dir(&nested) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("HOME", temp.path()) + .args(["doctor", "--json"]) + .output() + .unwrap(); + + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let workspace = &report["configuration"]["workspace"]; + let reported_path = PathBuf::from(workspace["path"].as_str().unwrap()); + assert!( + reported_path.exists(), + "doctor reported undiscovered workspace path {}", + reported_path.display() + ); + assert_eq!( + reported_path.canonicalize().unwrap(), + project_config.canonicalize().unwrap() + ); + assert_eq!(workspace["status"], "pass"); + assert_eq!(workspace["active"], true); +} + +#[test] +fn cli_doctor_explicit_config_reports_invalid_layered_workspace_config() { let temp = tempfile::tempdir().unwrap(); let xdg = temp.path().join("xdg"); let cwd = temp.path().join("workdir"); - let config = temp.path().join("explicit/config.toml"); + let config = temp.path().join("explicit").join("config.toml"); + let workspace_config = cwd.join(".nemo-relay").join("config.toml"); std::fs::create_dir_all(&xdg).unwrap(); std::fs::create_dir_all(cwd.join(".nemo-relay")).unwrap(); std::fs::create_dir_all(config.parent().unwrap()).unwrap(); - std::fs::write(cwd.join(".nemo-relay/config.toml"), "[upstream\n").unwrap(); + std::fs::write(&workspace_config, "[upstream\n").unwrap(); std::fs::write(&config, "[upstream]\n").unwrap(); let output = Command::new(gateway_bin()) @@ -3223,25 +3313,33 @@ fn cli_doctor_explicit_config_ignores_invalid_workspace_runtime_config() { .output() .unwrap(); - assert!( - output.status.success(), - "explicit config should ignore invalid workspace config: stderr={}", - String::from_utf8_lossy(&output.stderr) - ); + assert!(!output.status.success()); let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); assert_eq!( - report["configuration"]["workspace"]["path"], + report["configuration"]["explicit"]["path"], config.display().to_string() ); - assert_eq!(report["configuration"]["workspace"]["status"], "pass"); - assert_eq!(report["configuration"]["workspace"]["active"], true); + assert_eq!(report["configuration"]["explicit"]["status"], "pass"); + assert_eq!(report["configuration"]["explicit"]["active"], true); + assert_eq!( + PathBuf::from( + report["configuration"]["workspace"]["path"] + .as_str() + .unwrap() + ) + .canonicalize() + .unwrap(), + workspace_config.canonicalize().unwrap() + ); + assert_eq!(report["configuration"]["workspace"]["status"], "fail"); + assert_eq!(report["configuration"]["workspace"]["active"], false); assert_eq!(report["configuration"]["global"]["status"], "info"); assert_eq!(report["configuration"]["global"]["active"], false); assert!( report["configuration"]["global"]["details"] .as_str() .unwrap() - .contains("--config scopes configuration") + .contains("replaced by explicit --config") ); let human_output = Command::new(gateway_bin()) @@ -3251,25 +3349,28 @@ fn cli_doctor_explicit_config_ignores_invalid_workspace_runtime_config() { .args(["--config", config.to_str().unwrap(), "doctor"]) .output() .unwrap(); - assert!(human_output.status.success()); + assert!(!human_output.status.success()); let stdout = String::from_utf8_lossy(&human_output.stdout); assert!(stdout.contains("Explicit")); assert!(stdout.contains(config.to_str().unwrap())); - assert!(stdout.contains("not selected because --config scopes configuration")); + assert!(stdout.contains("Workspace")); + assert!(stdout.contains("invalid TOML")); + assert!(stdout.contains("replaced by explicit --config")); } #[test] -fn cli_doctor_reports_invalid_explicit_config_and_sibling_plugins() { +fn cli_doctor_reports_invalid_explicit_config_and_layered_plugins() { let temp = tempfile::tempdir().unwrap(); let xdg = temp.path().join("xdg"); let cwd = temp.path().join("workdir"); let config_dir = temp.path().join("explicit"); let config = config_dir.join("config.toml"); + let project_plugins = cwd.join(".nemo-relay").join("plugins.toml"); std::fs::create_dir_all(&xdg).unwrap(); std::fs::create_dir_all(&cwd).unwrap(); std::fs::create_dir_all(&config_dir).unwrap(); std::fs::create_dir_all(cwd.join(".nemo-relay")).unwrap(); - std::fs::write(cwd.join(".nemo-relay/plugins.toml"), "components = [\n").unwrap(); + std::fs::write(&project_plugins, "components = [\n").unwrap(); std::fs::write(&config, "[upstream\n").unwrap(); let invalid_config = Command::new(gateway_bin()) @@ -3301,6 +3402,30 @@ fn cli_doctor_reports_invalid_explicit_config_and_sibling_plugins() { "version = 1\ncomponents = []\n", ) .unwrap(); + let invalid_project_plugins = Command::new(gateway_bin()) + .current_dir(&cwd) + .env("XDG_CONFIG_HOME", &xdg) + .env("HOME", temp.path()) + .args(["--config", config.to_str().unwrap(), "doctor"]) + .output() + .unwrap(); + assert!(!invalid_project_plugins.status.success()); + let stdout = String::from_utf8_lossy(&invalid_project_plugins.stdout); + assert!(stdout.contains("invalid plugin TOML")); + assert!( + [ + project_plugins.display().to_string(), + project_plugins + .canonicalize() + .unwrap() + .display() + .to_string(), + ] + .iter() + .any(|path| stdout.contains(path)) + ); + + std::fs::write(&project_plugins, "version = 1\ncomponents = []\n").unwrap(); let valid_config = Command::new(gateway_bin()) .current_dir(&cwd) .env("XDG_CONFIG_HOME", &xdg) @@ -3310,10 +3435,33 @@ fn cli_doctor_reports_invalid_explicit_config_and_sibling_plugins() { .unwrap(); let report: serde_json::Value = serde_json::from_slice(&valid_config.stdout).unwrap(); assert_eq!(report["configuration"]["resolution"]["status"], "pass"); - assert_eq!( - report["configuration"]["plugin_configs"][0]["path"], - config_dir.join("plugins.toml").display().to_string() - ); + let plugin_configs = report["configuration"]["plugin_configs"] + .as_array() + .unwrap(); + for path in [config_dir.join("plugins.toml"), project_plugins] { + let expected = path.canonicalize().unwrap(); + let layer = plugin_configs + .iter() + .find(|config| { + config["path"] + .as_str() + .map(PathBuf::from) + .and_then(|reported| reported.canonicalize().ok()) + .is_some_and(|reported| reported == expected) + }) + .unwrap_or_else(|| { + panic!( + "doctor should report layered plugin source {}", + path.display() + ) + }); + assert_ne!( + layer["status"], + "fail", + "doctor should clear invalid diagnostics for {}", + path.display() + ); + } } #[test] diff --git a/crates/cli/tests/coverage/agents/launcher_tests.rs b/crates/cli/tests/coverage/agents/launcher_tests.rs index 47f64586d..5dabbceac 100644 --- a/crates/cli/tests/coverage/agents/launcher_tests.rs +++ b/crates/cli/tests/coverage/agents/launcher_tests.rs @@ -1874,6 +1874,7 @@ async fn transparent_launcher_does_not_initialize_logging_sinks_directly() { #[tokio::test] async fn dry_run_does_not_hydrate_dynamic_plugin_lifecycle_state() { + let _cwd = crate::test_support::CwdTestScope::locked(); let temp = tempfile::tempdir().unwrap(); let plugin_dir = temp.path().join("plugins/acme"); std::fs::create_dir_all(&plugin_dir).unwrap(); diff --git a/crates/cli/tests/coverage/commands/configure_editor_tests.rs b/crates/cli/tests/coverage/commands/configure_editor_tests.rs index e66c5122e..fa0648ce6 100644 --- a/crates/cli/tests/coverage/commands/configure_editor_tests.rs +++ b/crates/cli/tests/coverage/commands/configure_editor_tests.rs @@ -181,7 +181,7 @@ fn target_selection_and_file_loading_behave_as_expected() { } #[test] -fn config_editor_inherits_explicit_target_unless_scope_is_selected() { +fn config_editor_treats_explicit_config_as_the_user_target() { let inherited = PathBuf::from("/managed/config.toml"); let (scope, path) = resolve_edit_target(&ConfigEditCommand::default(), Some(inherited.clone())).unwrap(); @@ -192,8 +192,11 @@ fn config_editor_inherits_explicit_target_unless_scope_is_selected() { user: true, ..ConfigEditCommand::default() }; - let (scope, path) = - resolve_edit_target(&user, Some(PathBuf::from("/ignored/config.toml"))).unwrap(); + let (scope, path) = resolve_edit_target(&user, Some(inherited.clone())).unwrap(); + assert_eq!(scope, TargetScope::User); + assert_eq!(path, inherited); + + let (scope, path) = resolve_edit_target(&user, None).unwrap(); assert_eq!(scope, TargetScope::User); assert_eq!( path, diff --git a/crates/cli/tests/coverage/commands/main_tests.rs b/crates/cli/tests/coverage/commands/main_tests.rs index 66ee3d558..b9f88c27e 100644 --- a/crates/cli/tests/coverage/commands/main_tests.rs +++ b/crates/cli/tests/coverage/commands/main_tests.rs @@ -17,7 +17,7 @@ use crate::commands::plugins::{ use crate::commands::root::AgentArg; #[test] -fn plugins_edit_inherits_explicit_plugin_target_unless_scope_is_selected() { +fn plugins_edit_treats_explicit_target_as_the_user_layer() { let config = PathBuf::from("/managed/config.toml"); let server = crate::server::GatewayOverrides { config: Some(config), @@ -42,6 +42,18 @@ fn plugins_edit_inherits_explicit_plugin_target_unless_scope_is_selected() { Some(PathBuf::from("/override/plugins.toml")) ); + let user = PluginsEditCommand { + scope: PluginsScopeArgs { + user: true, + ..PluginsScopeArgs::default() + }, + }; + let request = plugins::edit_request(user, &server); + assert_eq!( + request.explicit_path, + Some(PathBuf::from("/override/plugins.toml")) + ); + let project = PluginsEditCommand { scope: PluginsScopeArgs { project: true, @@ -50,6 +62,15 @@ fn plugins_edit_inherits_explicit_plugin_target_unless_scope_is_selected() { }; let request = plugins::edit_request(project, &server); assert_eq!(request.explicit_path, None); + + let global = PluginsEditCommand { + scope: PluginsScopeArgs { + global: true, + ..PluginsScopeArgs::default() + }, + }; + let request = plugins::edit_request(global, &server); + assert_eq!(request.explicit_path, None); } #[test] @@ -205,6 +226,7 @@ fn cli_logging_options_override_environment_source() { #[test] fn cli_logging_resolves_explicit_relay_config() { + let _cwd = crate::test_support::CwdTestScope::locked(); let _environment = crate::test_support::EnvScope::set(&[ ("NEMO_RELAY_LOG", None), ("NEMO_RELAY_LOG_STDERR_FORMAT", None), diff --git a/crates/cli/tests/coverage/shared/config_tests.rs b/crates/cli/tests/coverage/shared/config_tests.rs index c9d748fd6..da812129a 100644 --- a/crates/cli/tests/coverage/shared/config_tests.rs +++ b/crates/cli/tests/coverage/shared/config_tests.rs @@ -47,6 +47,107 @@ fn explicit_plugin_config_path_resolves_runtime_target() { assert_eq!(explicit_plugin_config_path(None, None), None); } +#[test] +fn config_paths_layer_explicit_or_user_then_project_then_system() { + let temp = tempfile::tempdir().unwrap(); + let project = temp.path().join("project"); + let child = project.join("nested"); + let xdg = temp.path().join("xdg"); + let project_config = project.join(".nemo-relay").join("config.toml"); + std::fs::create_dir_all(&child).unwrap(); + std::fs::create_dir_all(&xdg).unwrap(); + std::fs::create_dir_all(project_config.parent().unwrap()).unwrap(); + std::fs::write(&project_config, "").unwrap(); + let _scope = PluginConfigDiscoveryScope::enter(&child, &xdg); + let discovered_project_config = std::env::current_dir() + .unwrap() + .parent() + .unwrap() + .join(".nemo-relay") + .join("config.toml"); + let system_config = PathBuf::from("/etc/nemo-relay/config.toml"); + + assert_eq!( + config_paths_scoped(None, false), + vec![ + xdg.join("nemo-relay").join("config.toml"), + discovered_project_config.clone(), + system_config.clone(), + ] + ); + + let explicit_config = temp.path().join("managed").join("config.toml"); + assert_eq!( + config_paths_scoped(Some(&explicit_config), false), + vec![ + explicit_config.clone(), + discovered_project_config, + system_config.clone(), + ] + ); + assert_eq!( + config_paths_scoped(Some(&explicit_config), true), + vec![explicit_config, system_config], + "user-only mode suppresses project discovery but retains the system layer" + ); +} + +#[test] +fn plugin_config_paths_layer_explicit_or_user_then_project_then_system() { + let temp = tempfile::tempdir().unwrap(); + let project = temp.path().join("project"); + let child = project.join("nested"); + let xdg = temp.path().join("xdg"); + let project_plugins = project.join(".nemo-relay").join("plugins.toml"); + std::fs::create_dir_all(&child).unwrap(); + std::fs::create_dir_all(&xdg).unwrap(); + std::fs::create_dir_all(project_plugins.parent().unwrap()).unwrap(); + std::fs::write(&project_plugins, "version = 1\n").unwrap(); + let _scope = PluginConfigDiscoveryScope::enter(&child, &xdg); + let discovered_project_plugins = std::env::current_dir() + .unwrap() + .parent() + .unwrap() + .join(".nemo-relay") + .join("plugins.toml"); + let system_plugins = PathBuf::from("/etc/nemo-relay/plugins.toml"); + + assert_eq!( + plugin_config_paths_scoped(None, None, false), + vec![ + xdg.join("nemo-relay").join("plugins.toml"), + discovered_project_plugins.clone(), + system_plugins.clone(), + ] + ); + + let explicit_config = temp.path().join("managed").join("config.toml"); + let explicit_plugins = explicit_config.parent().unwrap().join("plugins.toml"); + assert_eq!( + plugin_config_paths_scoped(Some(&explicit_config), None, false), + vec![ + explicit_plugins.clone(), + discovered_project_plugins.clone(), + system_plugins.clone(), + ] + ); + + let override_plugins = temp.path().join("override").join("plugins.toml"); + assert_eq!( + plugin_config_paths_scoped(Some(&explicit_config), Some(&override_plugins), false), + vec![ + override_plugins.clone(), + discovered_project_plugins, + system_plugins.clone(), + ] + ); + assert_eq!( + plugin_config_paths_scoped(Some(&explicit_config), None, true), + vec![explicit_plugins, system_plugins], + "user-only mode suppresses project discovery but retains the system layer" + ); +} + struct PluginConfigDiscoveryScope { _cwd_guard: crate::test_support::CwdTestScope, _guard: MutexGuard<'static, ()>, @@ -203,6 +304,19 @@ fn provider_auth_headers_default_to_unset() { assert!(config.anthropic_auth_header.is_none()); } +fn effective_plugin_toml_sources_without_system( + explicit: Option<&PathBuf>, + plugin_config_path: Option<&PathBuf>, +) -> Result, CliError> { + // System-path discovery has dedicated coverage; source-list tests must not read host policy. + let system = global_plugin_config_path(); + effective_plugin_toml_sources_from_paths( + plugin_config_paths(explicit, plugin_config_path) + .into_iter() + .filter(|path| path != &system), + ) +} + #[test] fn effective_plugin_toml_sources_reports_empty_and_sorted_contributors() { let temp = tempfile::tempdir().unwrap(); @@ -213,18 +327,18 @@ fn effective_plugin_toml_sources_reports_empty_and_sorted_contributors() { let _scope = PluginConfigDiscoveryScope::enter(&project, &xdg); assert_eq!( - effective_plugin_toml_sources(None, None).unwrap(), + effective_plugin_toml_sources_without_system(None, None).unwrap(), Vec::::new() ); let project_plugins = project.join(".nemo-relay/plugins.toml"); - let user_plugins = xdg.join("nemo-relay/plugins.toml"); + let user_plugins = xdg.join("nemo-relay").join("plugins.toml"); std::fs::create_dir_all(project_plugins.parent().unwrap()).unwrap(); std::fs::create_dir_all(user_plugins.parent().unwrap()).unwrap(); std::fs::write(&project_plugins, "version = 1\ncomponents = []\n").unwrap(); std::fs::write(&user_plugins, "version = 1\ncomponents = []\n").unwrap(); - let sources = effective_plugin_toml_sources(None, None).unwrap(); + let sources = effective_plugin_toml_sources_without_system(None, None).unwrap(); assert!(sources.is_sorted()); assert!(sources.windows(2).all(|paths| paths[0] != paths[1])); @@ -242,7 +356,7 @@ fn effective_plugin_toml_sources_reports_empty_and_sorted_contributors() { } #[test] -fn effective_plugin_toml_sources_scope_to_an_explicit_config_sibling() { +fn effective_plugin_toml_sources_replace_user_with_explicit_and_include_project() { let temp = tempfile::tempdir().unwrap(); let project = temp.path().join("project"); let xdg = temp.path().join("xdg"); @@ -251,17 +365,27 @@ fn effective_plugin_toml_sources_scope_to_an_explicit_config_sibling() { std::fs::create_dir_all(&xdg).unwrap(); std::fs::create_dir_all(&explicit_dir).unwrap(); let _scope = PluginConfigDiscoveryScope::enter(&project, &xdg); + let discovered_project_plugins = std::env::current_dir() + .unwrap() + .join(".nemo-relay") + .join("plugins.toml"); let explicit_config = explicit_dir.join("config.toml"); let explicit_plugins = explicit_dir.join("plugins.toml"); std::fs::write(&explicit_config, "").unwrap(); std::fs::write(&explicit_plugins, "version = 1\ncomponents = []\n").unwrap(); std::fs::create_dir_all(project.join(".nemo-relay")).unwrap(); - std::fs::write(project.join(".nemo-relay/plugins.toml"), "components = [\n").unwrap(); + let project_plugins = project.join(".nemo-relay").join("plugins.toml"); + std::fs::write(&project_plugins, "version = 1\ncomponents = []\n").unwrap(); + let user_plugins = xdg.join("nemo-relay/plugins.toml"); + std::fs::create_dir_all(user_plugins.parent().unwrap()).unwrap(); + std::fs::write(&user_plugins, "components = [\n").unwrap(); + let mut expected = vec![explicit_plugins, discovered_project_plugins]; + expected.sort(); assert_eq!( - effective_plugin_toml_sources(Some(&explicit_config), None).unwrap(), - vec![explicit_plugins] + effective_plugin_toml_sources_without_system(Some(&explicit_config), None).unwrap(), + expected ); } @@ -499,6 +623,8 @@ fn agent_inference_uses_executable_basename() { #[test] fn explicit_toml_config_maps_supported_sections() { let temp = tempfile::tempdir().unwrap(); + let xdg = temp.path().join("xdg"); + let _scope = PluginConfigDiscoveryScope::enter(temp.path(), &xdg); let path = temp.path().join("config.toml"); std::fs::write( &path, @@ -611,9 +737,7 @@ fn endpoint_overrides_clear_inherited_provider_auth_headers() { r#" [upstream] openai_base_url = "http://project-openai" -openai_auth_header = "Bearer project-openai" anthropic_base_url = "http://project-anthropic" -anthropic_auth_header = "Basic project-anthropic" "#, ) .unwrap(); @@ -622,7 +746,9 @@ anthropic_auth_header = "Basic project-anthropic" r#" [upstream] openai_base_url = "http://user-openai" +openai_auth_header = "Bearer user-openai" anthropic_base_url = "http://user-anthropic" +anthropic_auth_header = "Basic user-anthropic" "#, ) .unwrap(); @@ -630,9 +756,12 @@ anthropic_base_url = "http://user-anthropic" let resolved = resolve_server_config(&GatewayOverrides::default()).unwrap(); - assert_eq!(resolved.gateway.openai_base_url, "http://user-openai"); + assert_eq!(resolved.gateway.openai_base_url, "http://project-openai"); assert!(resolved.gateway.openai_auth_header.is_none()); - assert_eq!(resolved.gateway.anthropic_base_url, "http://user-anthropic"); + assert_eq!( + resolved.gateway.anthropic_base_url, + "http://project-anthropic" + ); assert!(resolved.gateway.anthropic_auth_header.is_none()); } @@ -672,8 +801,45 @@ anthropic_auth_header = "Basic file-anthropic" assert!(resolved.gateway.anthropic_auth_header.is_none()); } +#[test] +fn matching_endpoint_environment_overrides_preserve_file_provider_auth_headers() { + let temp = tempfile::tempdir().unwrap(); + let xdg = temp.path().join("xdg"); + std::fs::create_dir_all(&xdg).unwrap(); + let scope = PluginConfigDiscoveryScope::enter(temp.path(), &xdg); + let path = temp.path().join("config.toml"); + std::fs::write( + &path, + r#" +[upstream] +openai_base_url = "http://same-openai" +openai_auth_header = "Bearer file-openai" +anthropic_base_url = "http://same-anthropic" +anthropic_auth_header = "Basic file-anthropic" +"#, + ) + .unwrap(); + scope.set_base_urls("http://same-openai", "http://same-anthropic"); + + let resolved = resolve_server_config(&GatewayOverrides { + config: Some(path), + ..GatewayOverrides::default() + }) + .unwrap(); + + assert_eq!( + resolved.gateway.openai_auth_header.as_deref(), + Some("Bearer file-openai") + ); + assert_eq!( + resolved.gateway.anthropic_auth_header.as_deref(), + Some("Basic file-anthropic") + ); +} + #[test] fn invalid_provider_auth_header_errors_do_not_expose_secret_values() { + let _cwd = crate::test_support::CwdTestScope::locked(); let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("config.toml"); std::fs::write( @@ -697,6 +863,7 @@ fn invalid_provider_auth_header_errors_do_not_expose_secret_values() { #[test] fn invalid_anthropic_provider_auth_header_errors_do_not_expose_secret_values() { + let _cwd = crate::test_support::CwdTestScope::locked(); let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("config.toml"); std::fs::write( @@ -742,6 +909,7 @@ fn invalid_provider_auth_environment_errors_do_not_expose_secret_values() { #[test] fn explicit_config_must_exist() { + let _cwd = crate::test_support::CwdTestScope::locked(); let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("missing-config.toml"); let command = RunOverrides { @@ -772,9 +940,50 @@ fn absent_optional_plugin_config_is_ignored() { assert!(loaded.is_none()); } +#[cfg(unix)] +#[test] +fn plugin_config_loader_deduplicates_aliases_at_highest_precedence() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let physical = temp.path().join("system-plugins.toml"); + let alias = temp.path().join("explicit-plugins.toml"); + std::fs::write( + &physical, + r#" +version = 1 + +[[components]] +kind = "pricing" +enabled = true + +[[components.config.sources]] +type = "file" +path = "/etc/nemo-relay/pricing.json" +"#, + ) + .unwrap(); + symlink(&physical, &alias).unwrap(); + + let resolved = load_plugin_toml_config_from_paths(vec![alias, physical.clone()]) + .unwrap() + .expect("the physical file exists"); + + assert_eq!(resolved.contributing_sources, vec![physical]); + assert_eq!( + resolved.value.unwrap()["components"][0]["config"]["sources"] + .as_array() + .unwrap() + .len(), + 1, + "the aliased file must not duplicate list entries" + ); +} + #[cfg(unix)] #[test] fn unreadable_config_errors_include_the_source_path() { + let _cwd = crate::test_support::CwdTestScope::locked(); use std::os::unix::fs::PermissionsExt; if unsafe { libc::geteuid() } == 0 { @@ -822,6 +1031,7 @@ fn unreadable_config_errors_include_the_source_path() { #[test] fn legacy_observability_config_sections_fail_clearly() { + let _cwd = crate::test_support::CwdTestScope::locked(); let temp = tempfile::tempdir().unwrap(); for (name, contents, expected) in [ ( @@ -865,6 +1075,7 @@ fn legacy_observability_config_sections_fail_clearly() { #[test] fn explicit_plugins_toml_maps_root_plugin_config() { + let _cwd = crate::test_support::CwdTestScope::locked(); let temp = tempfile::tempdir().unwrap(); let config_path = temp.path().join("config.toml"); std::fs::write( @@ -935,10 +1146,15 @@ mode = "overwrite" #[test] fn plugins_toml_path_resolution_tracks_config_scope() { let temp = tempfile::tempdir().unwrap(); + let xdg = temp.path().join("xdg"); + let _scope = PluginConfigDiscoveryScope::enter(temp.path(), &xdg); let explicit = temp.path().join("custom-config.toml"); assert_eq!( plugin_config_paths(Some(&explicit), None), - vec![temp.path().join("plugins.toml")] + vec![ + temp.path().join("plugins.toml"), + PathBuf::from("/etc/nemo-relay/plugins.toml"), + ] ); let project = temp.path().join("workspace"); @@ -957,9 +1173,9 @@ fn plugins_toml_path_resolution_tracks_config_scope() { assert_eq!( implicit_plugin_config_paths(Some(&nested), Some(user_config.clone())), vec![ - PathBuf::from("/etc/nemo-relay/plugins.toml"), - project.join(".nemo-relay/plugins.toml"), user_config.join("plugins.toml"), + project.join(".nemo-relay/plugins.toml"), + PathBuf::from("/etc/nemo-relay/plugins.toml"), ] ); @@ -988,15 +1204,15 @@ fn persistent_user_scope_excludes_project_gateway_and_plugin_layers() { assert_eq!( config_paths(None), vec![ - PathBuf::from("/etc/nemo-relay/config.toml"), xdg.join("nemo-relay/config.toml"), + PathBuf::from("/etc/nemo-relay/config.toml"), ] ); assert_eq!( plugin_config_paths(None, None), vec![ - PathBuf::from("/etc/nemo-relay/plugins.toml"), xdg.join("nemo-relay/plugins.toml"), + PathBuf::from("/etc/nemo-relay/plugins.toml"), ] ); } @@ -1047,6 +1263,50 @@ level = "warn" assert!(!has_project_sink(&user_only)); } +#[test] +fn operational_logging_aggregates_sinks_from_all_config_layers() { + let temp = tempfile::tempdir().unwrap(); + let project = temp.path().join("workspace"); + let nested = project.join("nested"); + let xdg = temp.path().join("xdg"); + let project_config_dir = project.join(".nemo-relay"); + let explicit_config_dir = temp.path().join("explicit"); + let project_sink = temp.path().join("project.log.jsonl"); + let explicit_sink = temp.path().join("explicit.log.jsonl"); + std::fs::create_dir_all(&project_config_dir).unwrap(); + std::fs::create_dir_all(&explicit_config_dir).unwrap(); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::write( + project_config_dir.join("config.toml"), + format!( + "[[logging.sinks]]\npath = {}\n", + toml_basic_string(project_sink.to_string_lossy().as_ref()) + ), + ) + .unwrap(); + let explicit_config = explicit_config_dir.join("config.toml"); + std::fs::write( + &explicit_config, + format!( + "[[logging.sinks]]\npath = {}\n", + toml_basic_string(explicit_sink.to_string_lossy().as_ref()) + ), + ) + .unwrap(); + let _scope = PluginConfigDiscoveryScope::enter(&nested, &xdg); + + let logging = resolve_logging_config(Some(&explicit_config), false).unwrap(); + let paths = logging + .sinks + .iter() + .map(|sink| match sink { + LogSinkConfig::File(file) => file.path.as_path(), + }) + .collect::>(); + + assert_eq!(paths, vec![project_sink.as_path(), explicit_sink.as_path()]); +} + #[test] fn discovered_plugins_toml_upserts_components_by_kind() { let temp = tempfile::tempdir().unwrap(); @@ -1757,6 +2017,7 @@ kind = "observability" #[test] fn config_toml_plugin_configuration_is_rejected() { + let _cwd = crate::test_support::CwdTestScope::locked(); let temp = tempfile::tempdir().unwrap(); let config_path = temp.path().join("config.toml"); std::fs::write( @@ -1781,6 +2042,7 @@ config = { version = 1, components = [] } #[test] fn plugin_config_path_overrides_sibling_plugin_file() { + let _cwd = crate::test_support::CwdTestScope::locked(); let temp = tempfile::tempdir().unwrap(); let config_path = temp.path().join("config.toml"); let sibling_path = temp.path().join("plugins.toml"); @@ -1810,6 +2072,7 @@ fn plugin_config_path_overrides_sibling_plugin_file() { #[test] fn cli_run_overrides_config_values() { + let _cwd = crate::test_support::CwdTestScope::locked(); let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("config.toml"); std::fs::write( @@ -1817,6 +2080,7 @@ fn cli_run_overrides_config_values() { r#" [upstream] openai_base_url = "http://file-openai" +openai_auth_header = "Bearer file-openai" "#, ) .unwrap(); @@ -1835,11 +2099,13 @@ openai_base_url = "http://file-openai" let resolved = resolve_run_config(&command, None).unwrap(); assert_eq!(resolved.gateway.openai_base_url, "http://cli-openai"); + assert!(resolved.gateway.openai_auth_header.is_none()); assert_eq!(resolved.gateway.metadata, Some(json!({ "team": "cli" }))); } #[test] fn run_inherits_top_level_server_flags_when_subcommand_flags_are_absent() { + let _cwd = crate::test_support::CwdTestScope::locked(); let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("config.toml"); std::fs::write( @@ -1847,6 +2113,7 @@ fn run_inherits_top_level_server_flags_when_subcommand_flags_are_absent() { r#" [upstream] openai_base_url = "http://file-openai" +openai_auth_header = "Bearer file-openai" "#, ) .unwrap(); @@ -1870,6 +2137,7 @@ openai_base_url = "http://file-openai" let resolved = resolve_run_config(&command, Some(&server)).unwrap(); assert_eq!(resolved.gateway.openai_base_url, "http://top-level-openai"); + assert!(resolved.gateway.openai_auth_header.is_none()); } #[test] @@ -1879,7 +2147,17 @@ fn server_resolution_applies_all_server_overrides() { std::fs::create_dir_all(&xdg).unwrap(); let _scope = PluginConfigDiscoveryScope::enter(temp.path(), &xdg); let config_path = isolated_config_path(&temp); - std::fs::write(&config_path, "").unwrap(); + std::fs::write( + &config_path, + r#" +[upstream] +openai_base_url = "http://file-openai" +openai_auth_header = "Bearer file-openai" +anthropic_base_url = "http://file-anthropic" +anthropic_auth_header = "Basic file-anthropic" +"#, + ) + .unwrap(); let args = GatewayOverrides { config: Some(config_path), bind: Some("127.0.0.1:0".parse().unwrap()), @@ -1895,7 +2173,9 @@ 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!(resolved.gateway.openai_auth_header.is_none()); assert_eq!(resolved.gateway.anthropic_base_url, "http://cli-anthropic"); + assert!(resolved.gateway.anthropic_auth_header.is_none()); assert_eq!(resolved.gateway.max_hook_payload_bytes, 222); assert_eq!(resolved.gateway.max_passthrough_body_bytes, 333); assert_eq!(resolved.gateway.plugin_config, None); @@ -2675,6 +2955,7 @@ fn bootstrap_hmac_key_rejects_corrupt_persistent_state() { #[test] fn persistent_server_resolution_rejects_project_specific_flags() { + let _cwd = crate::test_support::CwdTestScope::locked(); let args = GatewayOverrides { config: Some(PathBuf::from("project-config.toml")), ..GatewayOverrides::default() @@ -2690,6 +2971,7 @@ fn persistent_server_resolution_rejects_project_specific_flags() { #[test] fn server_resolution_fails_when_required_enabled_dynamic_plugin_is_blocked_by_policy() { + let _cwd = crate::test_support::CwdTestScope::locked(); let temp = tempfile::tempdir().unwrap(); let plugin_dir = temp.path().join("plugins/acme"); std::fs::create_dir_all(&plugin_dir).unwrap(); @@ -2724,6 +3006,7 @@ allowed = false #[test] fn server_resolution_fails_when_required_enabled_dynamic_plugin_fails_integrity() { + let _cwd = crate::test_support::CwdTestScope::locked(); let temp = tempfile::tempdir().unwrap(); let plugin_dir = temp.path().join("plugins/acme"); std::fs::create_dir_all(&plugin_dir).unwrap(); @@ -2790,6 +3073,7 @@ startup = "required" #[test] fn server_resolution_fails_when_required_enabled_dynamic_plugin_lacks_trusted_keys() { + let _cwd = crate::test_support::CwdTestScope::locked(); let temp = tempfile::tempdir().unwrap(); let plugin_dir = temp.path().join("plugins/acme"); std::fs::create_dir_all(&plugin_dir).unwrap(); @@ -2858,6 +3142,7 @@ attestation = "signature_required" #[test] fn server_resolution_fails_when_required_enabled_dynamic_plugin_has_wrong_trusted_key() { + let _cwd = crate::test_support::CwdTestScope::locked(); let temp = tempfile::tempdir().unwrap(); let plugin_dir = temp.path().join("plugins/acme"); std::fs::create_dir_all(&plugin_dir).unwrap(); @@ -2930,6 +3215,7 @@ fn server_resolution_fails_when_required_enabled_dynamic_plugin_has_wrong_truste #[test] fn server_resolution_fails_when_required_enabled_dynamic_plugin_has_malformed_signature() { + let _cwd = crate::test_support::CwdTestScope::locked(); let temp = tempfile::tempdir().unwrap(); let plugin_dir = temp.path().join("plugins/acme"); std::fs::create_dir_all(&plugin_dir).unwrap(); @@ -3016,6 +3302,7 @@ fn gateway_body_limit_defaults_are_stable() { #[test] fn gateway_body_limit_file_values_must_be_nonzero() { + let _cwd = crate::test_support::CwdTestScope::locked(); let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("config.toml"); for (field, expected) in [ @@ -3040,6 +3327,7 @@ fn gateway_body_limit_file_values_must_be_nonzero() { #[test] fn run_resolution_applies_all_run_overrides() { + let _cwd = crate::test_support::CwdTestScope::locked(); let temp = tempfile::tempdir().unwrap(); let config_path = isolated_config_path(&temp); std::fs::write(&config_path, "").unwrap(); @@ -3064,6 +3352,7 @@ fn run_resolution_applies_all_run_overrides() { #[test] fn run_resolution_fails_when_required_enabled_dynamic_plugin_is_blocked_by_policy() { + let _cwd = crate::test_support::CwdTestScope::locked(); let temp = tempfile::tempdir().unwrap(); let plugin_dir = temp.path().join("plugins/acme"); std::fs::create_dir_all(&plugin_dir).unwrap(); @@ -3105,6 +3394,7 @@ allowed = false #[test] fn malformed_shared_config_reports_context() { + let _cwd = crate::test_support::CwdTestScope::locked(); let temp = tempfile::tempdir().unwrap(); let invalid_toml = temp.path().join("invalid.toml"); std::fs::write(&invalid_toml, "server = [").unwrap(); @@ -3186,6 +3476,250 @@ unknown_component = "error" ); } +#[test] +fn uphill_config_layers_replace_scalars_and_aggregate_distinct_agent_tables() { + let layers = [ + r#" +[gateway] +max_hook_payload_bytes = 100 + +[agents.codex] +command = "user-codex" +"#, + r#" +[gateway] +max_passthrough_body_bytes = 200 + +[agents.claude] +command = "project-claude" +"#, + r#" +[gateway] +max_hook_payload_bytes = 300 + +[agents.codex] +command = "system-codex" +"#, + ]; + let mut merged = toml::Value::Table(toml::map::Map::new()); + for layer in layers { + let layer = layer + .parse::() + .map(toml::Value::Table) + .unwrap(); + merge_gateway_config_toml(&mut merged, layer); + } + + assert_eq!( + merged["gateway"]["max_hook_payload_bytes"].as_integer(), + Some(300) + ); + assert_eq!( + merged["gateway"]["max_passthrough_body_bytes"].as_integer(), + Some(200) + ); + assert_eq!( + merged["agents"]["codex"]["command"].as_str(), + Some("system-codex") + ); + assert_eq!( + merged["agents"]["claude"]["command"].as_str(), + Some("project-claude") + ); +} + +#[test] +fn upstream_base_url_identity_controls_credential_inheritance() { + fn merge_upstream(lower: &str, higher: &str) -> toml::Value { + let mut merged = lower + .parse::() + .map(toml::Value::Table) + .unwrap(); + let higher = higher + .parse::() + .map(toml::Value::Table) + .unwrap(); + merge_gateway_config_toml(&mut merged, higher); + merged + } + + let lower = r#" +[upstream] +openai_base_url = "https://gateway.example/v1" +openai_auth_header = "Bearer lower" +"#; + let same_identity = merge_upstream( + lower, + r#" +[upstream] +openai_base_url = "https://gateway.example/v1" +"#, + ); + assert_eq!( + same_identity["upstream"]["openai_auth_header"].as_str(), + Some("Bearer lower") + ); + + let replaced_credential = merge_upstream( + lower, + r#" +[upstream] +openai_base_url = "https://gateway.example/v1" +openai_auth_header = "Bearer higher" +"#, + ); + assert_eq!( + replaced_credential["upstream"]["openai_auth_header"].as_str(), + Some("Bearer higher") + ); + + let changed_identity = merge_upstream( + lower, + r#" +[upstream] +openai_base_url = "https://other.example/v1" +"#, + ); + assert!( + changed_identity["upstream"] + .get("openai_auth_header") + .is_none() + ); + + let replaced_identity_and_credential = merge_upstream( + lower, + r#" +[upstream] +openai_base_url = "https://other.example/v1" +openai_auth_header = "Bearer replacement" +"#, + ); + assert_eq!( + replaced_identity_and_credential["upstream"]["openai_auth_header"].as_str(), + Some("Bearer replacement") + ); +} + +#[test] +fn logging_sinks_aggregate_by_path_with_higher_layers_first() { + let layers = [ + r#" +[[logging.sinks]] +path = "shared.log" +level = "debug" +queue_capacity = 128 + +[[logging.sinks]] +path = "user.log" +level = "debug" +"#, + r#" +[[logging.sinks]] +path = "shared.log" +level = "info" +format = "jsonl" + +[[logging.sinks]] +path = "project.log" +level = "info" +"#, + r#" +[[logging.sinks]] +path = "system.log" +level = "warn" + +[[logging.sinks]] +path = "shared.log" +format = "human" +"#, + ]; + let mut merged = toml::Value::Table(toml::map::Map::new()); + for layer in layers { + let layer = layer + .parse::() + .map(toml::Value::Table) + .unwrap(); + merge_gateway_config_toml(&mut merged, layer); + } + + let sinks = merged["logging"]["sinks"].as_array().unwrap(); + let paths = sinks + .iter() + .map(|sink| logging_sink_path(sink).unwrap()) + .collect::>(); + assert_eq!( + paths, + vec!["system.log", "shared.log", "project.log", "user.log"] + ); + + let shared = sinks + .iter() + .find(|sink| logging_sink_path(sink) == Some("shared.log")) + .unwrap(); + assert_eq!(shared["level"].as_str(), Some("info")); + assert_eq!(shared["format"].as_str(), Some("human")); + assert_eq!(shared["queue_capacity"].as_integer(), Some(128)); +} + +#[test] +fn logging_sink_path_aliases_coalesce_by_runtime_destination() { + let temp = tempfile::tempdir().unwrap(); + let xdg = temp.path().join("xdg"); + let _scope = PluginConfigDiscoveryScope::enter(temp.path(), &xdg); + let mut merged = r#" +[[logging.sinks]] +path = "relay.log" +level = "debug" +queue_capacity = 128 +"# + .parse::() + .map(toml::Value::Table) + .unwrap(); + let higher = r#" +[[logging.sinks]] +path = "./relay.log" +level = "warn" +format = "human" +"# + .parse::() + .map(toml::Value::Table) + .unwrap(); + + merge_gateway_config_toml(&mut merged, higher); + + let sinks = merged["logging"]["sinks"].as_array().unwrap(); + assert_eq!(sinks.len(), 1); + assert_eq!(logging_sink_path(&sinks[0]), Some("./relay.log")); + assert_eq!(sinks[0]["level"].as_str(), Some("warn")); + assert_eq!(sinks[0]["format"].as_str(), Some("human")); + assert_eq!(sinks[0]["queue_capacity"].as_integer(), Some(128)); +} + +#[test] +fn empty_higher_logging_sink_list_preserves_lower_sinks() { + let mut merged = r#" +[[logging.sinks]] +path = "lower.log" +level = "info" +"# + .parse::() + .map(toml::Value::Table) + .unwrap(); + let higher = r#" +[logging] +sinks = [] +"# + .parse::() + .map(toml::Value::Table) + .unwrap(); + + merge_gateway_config_toml(&mut merged, higher); + + let sinks = merged["logging"]["sinks"].as_array().unwrap(); + assert_eq!(sinks.len(), 1); + assert_eq!(logging_sink_path(&sinks[0]), Some("lower.log")); +} + #[cfg(windows)] fn set_test_windows_dacl(path: &std::path::Path, sddl: &str) { use std::os::windows::ffi::OsStrExt; @@ -3237,6 +3771,7 @@ fn set_test_windows_dacl(path: &std::path::Path, sddl: &str) { #[test] fn logging_defaults_when_section_absent() { + let _cwd = crate::test_support::CwdTestScope::locked(); let temp = tempfile::tempdir().unwrap(); let config_path = isolated_config_path(&temp); std::fs::write(&config_path, "").unwrap(); @@ -3255,6 +3790,7 @@ fn logging_defaults_when_section_absent() { #[test] fn logging_parses_global_settings_and_file_sinks() { + let _cwd = crate::test_support::CwdTestScope::locked(); let temp = tempfile::tempdir().unwrap(); let config_path = isolated_config_path(&temp); let log_a = temp.path().join("a.log.jsonl"); @@ -3314,6 +3850,7 @@ format = "human" #[test] fn logging_rotation_cli_config_preserves_pair_and_rejects_incomplete_pair() { + let _cwd = crate::test_support::CwdTestScope::locked(); let temp = tempfile::tempdir().unwrap(); let config_path = isolated_config_path(&temp); let log_path = temp.path().join("relay.log.jsonl"); @@ -3364,6 +3901,7 @@ max_file_size_bytes = 1024 #[test] fn logging_rejects_invalid_level_format_missing_path_and_zero_queue() { + let _cwd = crate::test_support::CwdTestScope::locked(); let temp = tempfile::tempdir().unwrap(); let bad_level = temp.path().join("bad-level.toml"); @@ -3459,6 +3997,7 @@ queue_capacity = {} #[test] fn logging_rejects_invalid_sink_level_and_format() { + let _cwd = crate::test_support::CwdTestScope::locked(); let temp = tempfile::tempdir().unwrap(); let bad_sink_level = temp.path().join("bad-sink-level.toml"); @@ -3500,6 +4039,7 @@ format = "yaml" #[test] fn logging_rejects_unknown_section_and_sink_fields() { + let _cwd = crate::test_support::CwdTestScope::locked(); let temp = tempfile::tempdir().unwrap(); let unknown_logging_field = temp.path().join("unknown-logging-field.toml"); @@ -3540,6 +4080,7 @@ queue_capcity = 32 #[test] fn logging_rejects_empty_sink_path() { + let _cwd = crate::test_support::CwdTestScope::locked(); let temp = tempfile::tempdir().unwrap(); let config_path = isolated_config_path(&temp); std::fs::write( @@ -3561,6 +4102,7 @@ path = "" #[test] fn logging_config_does_not_read_rust_log() { + let _cwd = crate::test_support::CwdTestScope::locked(); let _env = crate::test_support::ENV_TEST_LOCK .lock() .unwrap_or_else(|error| error.into_inner()); diff --git a/crates/cli/tests/coverage/shared/doctor_tests.rs b/crates/cli/tests/coverage/shared/doctor_tests.rs index 599467d4d..4031b24cd 100644 --- a/crates/cli/tests/coverage/shared/doctor_tests.rs +++ b/crates/cli/tests/coverage/shared/doctor_tests.rs @@ -56,7 +56,7 @@ fn empty_report() -> DoctorReport { shell: Some("zsh".into()), }, configuration: ConfigurationInfo { - explicit_config: false, + explicit: None, workspace: ConfigLayer { path: PathBuf::from("/x/.nemo-relay/config.toml"), status: Status::Info, diff --git a/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs b/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs index d0ec546cb..4770cedc4 100644 --- a/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs +++ b/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs @@ -2991,7 +2991,9 @@ fn add_with_explicit_config_uses_sibling_plugins_and_state_files() { assert_eq!(resolved.dynamic_plugins.len(), 1); assert_eq!(resolved.dynamic_plugins[0].plugin_id, "acme.explicit"); - let scopes = load_and_hydrate_scopes(server.config.as_ref(), &resolved).unwrap(); + let explicit_plugin_config = + explicit_plugin_config_path(server.config.as_ref(), server.plugin_config_path.as_ref()); + let scopes = load_and_hydrate_scopes(explicit_plugin_config.as_ref(), &resolved).unwrap(); let entry = find_record_by_id(&scopes, "acme.explicit") .unwrap() .expect("explicit-scope record"); @@ -3000,6 +3002,84 @@ fn add_with_explicit_config_uses_sibling_plugins_and_state_files() { assert_eq!(entry.state_path, state_path); } +#[test] +fn explicit_config_keeps_project_dynamic_plugin_lifecycle_scope() { + let temp = tempfile::tempdir().unwrap(); + let _env = EnvScope::hermetic(&temp); + let project = temp.path().join("project"); + let nested = project.join("nested"); + let plugin_dir = temp.path().join("plugins").join("acme"); + let project_config_dir = project.join(".nemo-relay"); + let explicit_config_dir = temp.path().join("explicit"); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::create_dir_all(&plugin_dir).unwrap(); + std::fs::create_dir_all(&project_config_dir).unwrap(); + std::fs::create_dir_all(&explicit_config_dir).unwrap(); + let _cwd = CurrentDirGuard::enter(&nested); + let manifest_path = write_dynamic_manifest(&plugin_dir, "acme.project-layer"); + std::fs::write( + project_config_dir.join("plugins.toml"), + format!( + "[[plugins.dynamic]]\nmanifest = {:?}\n", + manifest_path.to_string_lossy() + ), + ) + .unwrap(); + let explicit_config = explicit_config_dir.join("config.toml"); + std::fs::write(&explicit_config, "").unwrap(); + + let resolved = resolve_plugins_config(Some(&explicit_config)).unwrap(); + let explicit_plugin_config = explicit_plugin_config_path(Some(&explicit_config), None); + let scopes = load_and_hydrate_scopes(explicit_plugin_config.as_ref(), &resolved).unwrap(); + let entry = find_record_by_id(&scopes, "acme.project-layer") + .unwrap() + .expect("project-layer record"); + + assert_eq!(entry.scope, RegistryScope::Project); + assert_eq!( + entry.plugins_toml_path.canonicalize().unwrap(), + project_config_dir + .join("plugins.toml") + .canonicalize() + .unwrap() + ); +} + +#[test] +fn explicit_plugin_path_drives_plugin_command_lifecycle_scope() { + let temp = tempfile::tempdir().unwrap(); + let _env = EnvScope::hermetic(&temp); + let _cwd = CurrentDirGuard::enter(temp.path()); + let plugin_dir = temp.path().join("plugins").join("acme"); + let config_dir = temp.path().join("custom"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + std::fs::create_dir_all(&config_dir).unwrap(); + let manifest_path = write_dynamic_manifest(&plugin_dir, "acme.explicit-plugin-path"); + let plugin_config_path = config_dir.join("custom-plugins.toml"); + std::fs::write( + &plugin_config_path, + format!( + "[[plugins.dynamic]]\nmanifest = {:?}\n", + manifest_path.to_string_lossy() + ), + ) + .unwrap(); + let server = GatewayOverrides { + plugin_config_path: Some(plugin_config_path.clone()), + ..GatewayOverrides::default() + }; + + list(PluginsListRequest::default(), &server).unwrap(); + + let scopes = load_scoped_registries(Some(&plugin_config_path)).unwrap(); + let entry = find_record_by_id(&scopes, "acme.explicit-plugin-path") + .unwrap() + .expect("explicit plugin-path record"); + assert_eq!(entry.scope, RegistryScope::Explicit); + assert_eq!(entry.plugins_toml_path, plugin_config_path); + assert_eq!(entry.state_path, config_dir.join(".dynamic-plugins.json")); +} + #[test] fn hydrate_bootstraps_registry_records_from_existing_dynamic_plugin_refs() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/cli/tests/coverage/shared/setup_tests.rs b/crates/cli/tests/coverage/shared/setup_tests.rs index ee825081f..a08ee621c 100644 --- a/crates/cli/tests/coverage/shared/setup_tests.rs +++ b/crates/cli/tests/coverage/shared/setup_tests.rs @@ -559,9 +559,15 @@ fn plugins_edit_command_for_scope_targets_expected_plugin_scope() { fn plugins_edit_command_for_scope_preserves_explicit_plugin_path() { let path = PathBuf::from("/managed/plugins.toml"); - let command = plugins_edit_command_for_scope(ConfigScope::Global, Some(path.clone())); - - assert_eq!(command.explicit_path, Some(path)); + for scope in [ConfigScope::Project, ConfigScope::Global, ConfigScope::Both] { + let command = plugins_edit_command_for_scope(scope, Some(path.clone())); + assert_eq!(command.explicit_path, Some(path.clone())); + assert_eq!( + command.scope, + crate::plugins::ConfigurationScope::User, + "the inherited explicit file is the selected low/user plugin layer" + ); + } } #[test] diff --git a/crates/core/src/plugin.rs b/crates/core/src/plugin.rs index 6d60e8864..8d298e396 100644 --- a/crates/core/src/plugin.rs +++ b/crates/core/src/plugin.rs @@ -1636,7 +1636,7 @@ where I: IntoIterator, { let mut documents = Vec::new(); - for path in paths { + for path in deduplicate_plugin_config_paths(paths) { if !path.exists() { continue; } @@ -1651,6 +1651,26 @@ where merge_plugin_config_documents(documents) } +/// Removes physical duplicates while preserving the highest-precedence path. +/// Internal: `pub` only for cross-crate reuse by the gateway. +#[doc(hidden)] +pub fn deduplicate_plugin_config_paths(paths: I) -> Vec +where + I: IntoIterator, +{ + let paths = paths.into_iter().collect::>(); + let mut seen = HashSet::new(); + let mut unique = Vec::with_capacity(paths.len()); + for path in paths.into_iter().rev() { + let identity = path.canonicalize().unwrap_or_else(|_| path.clone()); + if seen.insert(identity) { + unique.push(path); + } + } + unique.reverse(); + unique +} + /// Merges pre-parsed `plugins.toml` JSON documents (lowest precedence first) using the canonical /// plugin-config layering rules. Internal: `pub` only so the CLI can preprocess dynamic-plugin /// refs while still sharing one merge semantics implementation with core. @@ -1695,20 +1715,21 @@ fn validate_unique_component_kinds(path: &Path, document: &Json) -> Result<()> { ))) } -/// Default `plugins.toml` search path (lowest precedence first): system, nearest -/// project file, then user file — mirroring the gateway's discovery. `pub` only +/// Default `plugins.toml` search path (lowest precedence first): user, nearest +/// project file, then system file — mirroring the gateway's discovery. `pub` only /// for cross-crate reuse by the gateway. #[doc(hidden)] pub fn default_plugin_config_paths(cwd: Option<&Path>, user_dir: Option) -> Vec { - let mut paths = vec![PathBuf::from("/etc/nemo-relay/plugins.toml")]; + let mut paths = Vec::new(); + if let Some(dir) = user_dir { + paths.push(dir.join("plugins.toml")); + } if let Some(cwd) = cwd && let Some(project) = nearest_project_plugin_config(cwd) { paths.push(project); } - if let Some(dir) = user_dir { - paths.push(dir.join("plugins.toml")); - } + paths.push(PathBuf::from("/etc/nemo-relay/plugins.toml")); paths } diff --git a/crates/core/tests/unit/plugin_tests.rs b/crates/core/tests/unit/plugin_tests.rs index a0c7503cf..59e57d7ec 100644 --- a/crates/core/tests/unit/plugin_tests.rs +++ b/crates/core/tests/unit/plugin_tests.rs @@ -2077,33 +2077,49 @@ fn test_initialize_plugins_reports_failed_restore_when_previous_configuration_ca fn test_load_plugin_config_files_merges_files_by_precedence() { let dir = tempfile::tempdir().unwrap(); let lower = dir.path().join("lower.toml"); - let higher = dir.path().join("higher.toml"); + let project = dir.path().join("project.toml"); + let system = dir.path().join("system.toml"); std::fs::write( &lower, "version = 1\n\ [[components]]\n\ kind = \"observability\"\n\ - enabled = false\n\ + enabled = true\n\ [components.config]\n\ output_directory = \"/var/log\"\n\ - mode = \"append\"\n", + mode = \"append\"\n\ + values = [\"lower\"]\n", ) .unwrap(); std::fs::write( - &higher, + &project, "[[components]]\n\ kind = \"observability\"\n\ [components.config]\n\ - mode = \"overwrite\"\n\ + mode = \"project\"\n\ + values = [\"project\"]\n\ [[components]]\n\ kind = \"adaptive\"\n", ) .unwrap(); + std::fs::write( + &system, + "[[components]]\n\ + kind = \"observability\"\n\ + enabled = false\n\ + [components.config]\n\ + mode = \"system\"\n\ + values = [\"system\"]\n\ + [[components]]\n\ + kind = \"system_only\"\n", + ) + .unwrap(); - let (merged, sources) = load_plugin_config_files([lower.clone(), higher.clone()]) - .unwrap() - .expect("a file exists"); - assert_eq!(sources, vec![lower, higher]); + let (merged, sources) = + load_plugin_config_files([lower.clone(), project.clone(), system.clone()]) + .unwrap() + .expect("a file exists"); + assert_eq!(sources, vec![lower, project, system]); let components = merged["components"].as_array().unwrap(); let observability = &components[0]; @@ -2111,7 +2127,7 @@ fn test_load_plugin_config_files_merges_files_by_precedence() { assert_eq!( observability["enabled"], json!(false), - "lower-file enabled is inherited (higher omits it)" + "the system layer wins shared scalar fields" ); assert_eq!( observability["config"]["output_directory"], @@ -2120,13 +2136,79 @@ fn test_load_plugin_config_files_merges_files_by_precedence() { ); assert_eq!( observability["config"]["mode"], - json!("overwrite"), - "higher file overrides the shared config key" + json!("system"), + "the system layer wins recursively merged config fields" + ); + assert_eq!( + observability["config"]["values"], + json!(["system", "project", "lower"]), + "list entries aggregate from highest to lowest precedence" ); assert_eq!( components[1]["kind"], json!("adaptive"), - "higher-only component kind is appended" + "a project-only component kind is preserved" + ); + assert_eq!( + components[2]["kind"], + json!("system_only"), + "a system-only component kind is appended" + ); +} + +#[test] +fn test_default_plugin_config_paths_order_user_project_system() { + let dir = tempfile::tempdir().unwrap(); + let project = dir.path().join("project"); + let child = project.join("nested"); + let user = dir.path().join("user"); + let project_plugins = project.join(".nemo-relay/plugins.toml"); + std::fs::create_dir_all(&child).unwrap(); + std::fs::create_dir_all(project_plugins.parent().unwrap()).unwrap(); + std::fs::write(&project_plugins, "version = 1\n").unwrap(); + + assert_eq!( + default_plugin_config_paths(Some(&child), Some(user.clone())), + vec![ + user.join("plugins.toml"), + project_plugins, + PathBuf::from("/etc/nemo-relay/plugins.toml"), + ] + ); +} + +#[cfg(unix)] +#[test] +fn test_load_plugin_config_files_deduplicates_aliases_at_highest_precedence() { + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().unwrap(); + let physical = dir.path().join("system.toml"); + let alias = dir.path().join("explicit.toml"); + std::fs::write( + &physical, + "version = 1\n\ + [[components]]\n\ + kind = \"pricing\"\n\ + [[components.config.sources]]\n\ + type = \"file\"\n\ + path = \"/etc/nemo-relay/pricing.json\"\n", + ) + .unwrap(); + symlink(&physical, &alias).unwrap(); + + let (merged, sources) = load_plugin_config_files([alias, physical.clone()]) + .unwrap() + .expect("the physical file exists"); + + assert_eq!(sources, vec![physical]); + assert_eq!( + merged["components"][0]["config"]["sources"] + .as_array() + .unwrap() + .len(), + 1, + "the aliased file must not duplicate list entries" ); } diff --git a/docs/configure-plugins/model-pricing.mdx b/docs/configure-plugins/model-pricing.mdx index f4a27d866..09244009d 100644 --- a/docs/configure-plugins/model-pricing.mdx +++ b/docs/configure-plugins/model-pricing.mdx @@ -56,11 +56,11 @@ Use `type = "file"` with a JSON catalog path or `type = "inline"` with the catalog in `plugins.toml`. Relay checks sources and catalog entries in listed order, and it uses the first entry that matches the provider and model. -When Relay merges system, project, and user configuration files, it prepends -higher-priority `sources` instead of replacing lower-priority sources. The -effective order is user, project, then system. This lets a narrower user catalog -override a project or enterprise catalog while preserving those catalogs as -fallbacks. +When Relay merges explicit-or-user, project, and system configuration files, it +prepends higher-priority `sources` instead of replacing lower-priority sources. +The effective order is system, project, then explicit-or-user. This lets an +enterprise catalog override narrower catalogs while preserving those catalogs +as fallbacks. ## Manage Catalog Sources with the CLI diff --git a/docs/configure-plugins/observability/configuration.mdx b/docs/configure-plugins/observability/configuration.mdx index 299e7a73a..156669be1 100644 --- a/docs/configure-plugins/observability/configuration.mdx +++ b/docs/configure-plugins/observability/configuration.mdx @@ -86,9 +86,9 @@ exporter receives a teardown attempt. Top-level component `config` lists concatenate across configuration layers, with higher-precedence entries first. The observability destination lists `atof.sinks`, `opentelemetry.endpoints`, and `atif.storage` follow the same -rule, so system, project, user, and programmatic layers can contribute -destinations. Arbitrary lists nested inside structured values retain -replacement semantics. List entries are not merged item by item. +rule, so explicit-or-user, project, system, and programmatic layers can +contribute destinations. Arbitrary lists nested inside structured values +retain replacement semantics. List entries are not merged item by item. To change or remove an inherited endpoint, edit the layer that declares it. For complete layering rules, refer to [Plugin Configuration Files](/configure-plugins/plugin-configuration-files#precedence-and-merge-behavior). diff --git a/docs/configure-plugins/plugin-configuration-files.mdx b/docs/configure-plugins/plugin-configuration-files.mdx index aa3216614..80b459e32 100644 --- a/docs/configure-plugins/plugin-configuration-files.mdx +++ b/docs/configure-plugins/plugin-configuration-files.mdx @@ -67,14 +67,14 @@ Run the gateway with the following command: nemo-relay --config path/to/config.toml run -- codex ``` -This keeps plugin discovery scoped to the colocated `plugins.toml` instead of -relying on implicit project, user, or system plugin files. The plugin file is -the configuration being demonstrated here; `--config` only tells the gateway -which config root to use for this run. If you prefer implicit discovery, place -the file at `./.nemo-relay/plugins.toml` or another discovered location and -ensure no higher-precedence plugin file overrides the exporter you want to -verify. Refer to [CLI Basic Usage](/nemo-relay-cli/basic-usage) for the wrapper -command shapes. +This uses the colocated `plugins.toml` instead of the ambient user plugin file. +The nearest project file and the system file still layer on top, so run from a +directory without a project plugin file and ensure the system layer does not +override the exporter you want to verify. The plugin file is the configuration +being demonstrated here; `--config` only tells the gateway which low plugin +layer to use for this run. If you prefer implicit discovery, place the file at +`./.nemo-relay/plugins.toml` or another discovered location. Refer to +[CLI Basic Usage](/nemo-relay-cli/basic-usage) for the wrapper command shapes. ## What Success Looks Like @@ -182,22 +182,30 @@ The runtime does not read plugin configuration from `config.toml`. ### Gateway Explicit Config When the CLI gateway receives `--config path/to/config.toml`, it scopes plugin -file discovery to `path/to/plugins.toml`. It does not load implicit system, -project, or user plugin files for that run. +file discovery's low layer to `path/to/plugins.toml`. An explicit +`--plugin-config-path` selects that low layer directly. Either explicit form +replaces the ambient user plugin file; the nearest project file and the system +file still apply. ### Default Discovery Locations -When no gateway `--config` path overrides discovery, the runtime checks these -`plugins.toml` locations from lowest to highest precedence: +The runtime checks these `plugins.toml` locations from lowest to highest +precedence: -1. System: `/etc/nemo-relay/plugins.toml` +1. Explicit or user: + - `--plugin-config-path`, or the `plugins.toml` beside `--config`, when + supplied + - otherwise `$XDG_CONFIG_HOME/nemo-relay/plugins.toml`, or + `~/.config/nemo-relay/plugins.toml` when `XDG_CONFIG_HOME` is not set 2. Project: the nearest `.nemo-relay/plugins.toml` found by walking upward from the current directory -3. User: `$XDG_CONFIG_HOME/nemo-relay/plugins.toml`, or - `~/.config/nemo-relay/plugins.toml` when `XDG_CONFIG_HOME` is not set +3. System: `/etc/nemo-relay/plugins.toml` -The runtime skips missing files. If no plugin config source exists, -initialization continues without process-level plugin activation. +The runtime skips missing files and loads a physical file only once when +multiple paths or symlinks select it. If no plugin config source exists, +initialization continues without process-level plugin activation. The +user-only bootstrap scope suppresses project discovery but still applies the +system layer. ## Gateway Editing Files @@ -224,9 +232,11 @@ or: When the top-level CLI receives `--plugin-config-path`, the editor uses that exact file. Otherwise, `--config path/to/config.toml` makes the editor use the sibling `path/to/plugins.toml`, matching the runtime selection for that -configuration. An explicit editor scope flag overrides this inherited target. -These rules only select the file opened by the editor; they do not change -runtime discovery, layering, or merge precedence. +configuration. This explicit file replaces the user editor target, so +`plugins edit --user` keeps the inherited explicit target. Use `--project` or +`--global` to edit the other active layers. These rules only select the file +opened by the editor; they do not change runtime discovery, layering, or merge +precedence. Use a scope flag to edit another location: @@ -264,17 +274,17 @@ menu to reset, clear, preview, or save. ## Precedence and Merge Behavior 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. +precedence. System config overrides project config, and project config +overrides the selected explicit-or-user config. TOML tables merge recursively. Top-level lists inside a component's `config` concatenate, as do the declared observability destination lists. Entries from the higher-precedence layer are placed before entries from the lower-precedence -layer. Other nested lists replace. For example, the following system +layer. Other nested lists replace. For example, the following user configuration enables one ATOF file sink: ```toml -# system plugins.toml +# user plugins.toml [[components]] kind = "observability" @@ -286,14 +296,14 @@ enabled = true [[components.config.atof.sinks]] type = "file" -output_directory = "/var/log/nemo-relay" +output_directory = "~/.local/state/nemo-relay" mode = "append" ``` -The user scope can add another sink without repeating the system sink: +The system scope can add a fleet sink without repeating the user sink: ```toml -# user plugins.toml +# system plugins.toml [[components]] kind = "observability" @@ -301,13 +311,13 @@ kind = "observability" [[components.config.atof.sinks]] type = "file" -output_directory = "~/.local/state/nemo-relay" +output_directory = "/var/log/nemo-relay" mode = "overwrite" ``` The effective Agent Trajectory Observability Format (ATOF) configuration keeps -`version` and `enabled` from the system file. Its `sinks` list contains the user -sink first, followed by the system sink. +`version` and `enabled` from the user file. Its `sinks` list contains the system +sink first, followed by the user sink. The top-level `components` array is special. Relay matches components by `kind` across files. A higher-precedence component with the same `kind` merges into the @@ -320,8 +330,7 @@ This behavior applies to list fields declared at the top level of a component's `pricing.sources` and PII redaction `profiles` are top-level component config lists and concatenate across layers. Higher-precedence pricing sources can therefore override one model while still -falling back to fleet-managed model pricing from -`/etc/nemo-relay/plugins.toml`. +retaining lower-precedence project or user pricing sources. Lists nested inside arbitrary structured values are not treated as top-level plugin lists; a higher-precedence value replaces those lists. @@ -340,7 +349,8 @@ sits on top. When the two conflict, code takes precedence. Layering works as follows: 1. Discover and merge the `plugins.toml` files from lowest to highest precedence - (system → project → user), using the [Precedence And Merge Behavior](#precedence-and-merge-behavior) rules above. + (explicit-or-user → project → system), using the + [Precedence And Merge Behavior](#precedence-and-merge-behavior) rules above. 2. Layer the config object you pass to `initialize` over that merged base. Any setting it specifies overrides the file value, and the result is the effective config that Relay validates and activates. @@ -368,7 +378,8 @@ means "inherit a lower precedence value"; it does not mean "delete that value." Use the dedicated `nemo-relay model-pricing` commands to manage model-pricing catalog sources. -For example, this user file disables ATOF even if a project file enables it: +For example, this system file disables ATOF even if a project or user file +enables it: ```toml [[components]] diff --git a/docs/nemo-relay-cli/basic-usage.mdx b/docs/nemo-relay-cli/basic-usage.mdx index 597a185e2..13417c964 100644 --- a/docs/nemo-relay-cli/basic-usage.mdx +++ b/docs/nemo-relay-cli/basic-usage.mdx @@ -143,9 +143,10 @@ install directories, host-specific behavior, and the shared-sidecar lifecycle. ## Shared Configuration -Shared TOML config is optional. The gateway loads defaults, then system config, -then project config, then user config. User config takes priority over system -and project config. CLI flags and environment variables override file config. +Shared TOML config is optional. The gateway loads defaults, then the explicit +file when supplied or the XDG user file otherwise, then the nearest project +file, and finally the system file. System config has the highest file-level +priority. CLI flags and environment variables override file config. ### Interactive Setup @@ -165,6 +166,13 @@ plugin configuration. When the top-level command receives `--config path/to/config.toml`, the plugin editor instead uses the sibling `path/to/plugins.toml`, matching runtime selection. +Plugin files use the same file-level precedence order: +explicit-or-user, then the nearest project file, then the system file. An +explicit `--plugin-config-path`, or the sibling selected by `--config`, +replaces the ambient XDG user plugin file. Likewise, an explicit `--config` +replaces the ambient XDG user base file. Neither explicit file suppresses +project or system configuration. + Select **No** to finish after saving `config.toml`. Canceling the prompt or leaving the plugin editor does not remove the saved base configuration. You can open the plugin editor again later with the resume command printed by Relay. @@ -190,13 +198,18 @@ saves are system-readable (`0644` on Unix), so they reject authorization headers; store credentials in a user config or environment variables instead. When the top-level CLI receives `--config path/to/config.toml`, the editor uses -that exact file. An explicit `config edit --user`, `--project`, or `--global` -flag overrides the inherited target. This selects only the file opened by the -editor; it does not change runtime discovery, layering, or merge precedence. +that exact file as its user target, so the default editor and +`config edit --user` both open it. Use `--project` or `--global` to edit the +other active layers. This selects only the file opened by the editor; it does +not change runtime discovery, layering, or merge precedence. Agent command setup remains under `nemo-relay config`; plugin components remain under `nemo-relay plugins edit`. +When an explicit plugin file is selected, it becomes the editor's user target: +the default editor and `--user` open that file. Use `--project` to edit the +nearest project `plugins.toml`, or `--global` to edit the system file. + Use `nemo-relay plugins edit --global` for `/etc/nemo-relay/plugins.toml`. Global plugin configuration is system-readable (`0644` on Unix), so do not store credentials there. The editor rejects schema-declared secret values in @@ -250,11 +263,21 @@ export NEMO_RELAY_OPENAI_AUTH_HEADER="Bearer " export NEMO_RELAY_ANTHROPIC_AUTH_HEADER="Basic " ``` -If you override a base URL with `NEMO_RELAY_OPENAI_BASE_URL` or -`NEMO_RELAY_ANTHROPIC_BASE_URL`, set the matching auth-header environment -variable when the new endpoint needs custom authentication. Relay doesn't carry -a custom header from `config.toml` across an environment-level endpoint -override. +Configure a custom base URL and its authorization header in the same layer. For +environment configuration, set `NEMO_RELAY_OPENAI_BASE_URL` with +`NEMO_RELAY_OPENAI_AUTH_HEADER`, or set the corresponding Anthropic variables +together. A CLI `--openai-base-url` or `--anthropic-base-url` override can +inherit an environment header only when the environment selected the same URL. +If the CLI flag changes the URL, Relay clears the environment header; use the +paired environment variables instead when the new endpoint requires custom +authentication. + +The exact configured base URL is the upstream identity during file, +environment, and command-line layering. A higher layer that keeps the same URL +can inherit or replace the lower authorization header. A higher layer that +changes the URL clears the lower header unless that same layer supplies a +replacement, preventing credentials for one endpoint from being sent to +another. An MCP-managed gateway uses custom or environment credentials only after the client proves that it belongs to that managed gateway. @@ -274,6 +297,12 @@ The CLI initializes operational logging before operational commands run. `nemo-relay config` and `nemo-relay plugins edit` skip initialization so invalid logging settings do not prevent configuration repair. +File sinks from layered `config.toml` files aggregate by their resolved +destination `path`, with system entries first. A higher layer recursively +overlays a sink with the same path; path aliases such as `relay.log` and +`./relay.log` resolve to one sink, while distinct destinations remain active +together. + Configure temporary CLI settings with `--log-level` and `--log-stderr-format`, or select an absolute TOML file with `--log-config-path`. diff --git a/docs/nemo-relay-cli/claude-code.mdx b/docs/nemo-relay-cli/claude-code.mdx index f3f5f1a7a..b65cfefda 100644 --- a/docs/nemo-relay-cli/claude-code.mdx +++ b/docs/nemo-relay-cli/claude-code.mdx @@ -137,7 +137,8 @@ endpoint = "http://127.0.0.1:4318/v1/traces" ``` Run `nemo-relay run --agent claude` to use the configured command and plugin -config. User config takes priority over project and system config. +config. Files layer from explicit-or-user to project to system, so system +configuration has the highest file-level priority. ## Standalone Gateway diff --git a/docs/reference/operational-logging.mdx b/docs/reference/operational-logging.mdx index 29f2d092e..4604a1e22 100644 --- a/docs/reference/operational-logging.mdx +++ b/docs/reference/operational-logging.mdx @@ -113,6 +113,13 @@ exceed 9. A record larger than `max_file_size_bytes` remains intact rather than being split. File sinks remain append-only when rotation settings are omitted. +When Relay layers `config.toml` files, the resolved destination `path` is the +file sink identity. Distinct paths are emitted in highest-to-lowest precedence +order: system, project, then explicit-or-user. For matching paths, higher-layer +fields recursively overlay lower-layer fields, producing one effective sink. +Path aliases such as `relay.log` and `./relay.log` resolve to one sink, using +the higher-layer spelling and settings. + ## Rust Library API Initialize operational logging once during application startup by choosing one