diff --git a/README.md b/README.md index 7c79fcd3f..db0977d96 100644 --- a/README.md +++ b/README.md @@ -86,15 +86,14 @@ for version pinning, custom directories, and source-based installation. #### 2. Enable Local Observability Output -From the project directory ready to be observed, open the project-scoped plugin -editor: +Open the user-scoped plugin editor: ```bash -nemo-relay plugins edit --project +nemo-relay plugins edit ``` -The editor creates or updates the nearest project plugin file at -`.nemo-relay/plugins.toml`. In the top-level menu, select **Observability**, +The editor creates or updates `$XDG_CONFIG_HOME/nemo-relay/plugins.toml` (or +`~/.config/nemo-relay/plugins.toml`). In the top-level menu, select **Observability**, then configure these sections: 1. Toggle the Observability component on. @@ -112,8 +111,9 @@ then configure these sections: 5. Press `s` to save. > [!NOTE] -> Run `nemo-relay plugins edit` without `--project` only when you want -> user-level exporter settings that apply across projects. +> Repository-local `.nemo-relay/plugins.toml` files are ignored. To use a +> configuration stored elsewhere, pass `--config path/to/config.toml`; Relay +> also selects the sibling `path/to/plugins.toml`. #### 3. Run a Coding Agent Through Relay diff --git a/crates/cli/README.md b/crates/cli/README.md index 19badc512..088998622 100644 --- a/crates/cli/README.md +++ b/crates/cli/README.md @@ -144,11 +144,11 @@ nemo-relay run --agent codex --dry-run ## Configuration -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`. -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. +User config lives at `~/.config/nemo-relay/config.toml` or +`$XDG_CONFIG_HOME/nemo-relay/config.toml`. Runtime files layer from lowest to +highest precedence as explicit-or-user, then system. An explicit `--config` +replaces the ambient user file without suppressing system configuration. +Repository-local `.nemo-relay/config.toml` files are ignored. Set up agent entries in the top-level config with: @@ -163,15 +163,14 @@ structured user-config editor: nemo-relay config edit ``` -Use `--project` for the nearest project `config.toml`, or `--global` for -`/etc/nemo-relay/config.toml`. Global saves are system-readable (`0644` on -Unix) and reject authorization headers; use the corresponding environment -variables or a user config for credentials. +Use `--global` for system configuration: `/etc/nemo-relay/config.toml` on Unix +or `%ProgramData%\nemo-relay\config.toml` on Windows. Global saves are +system-readable (`0644` on 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 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. +`config edit --user` both open it. Use `--global` to edit the system layer. Observability exporters are configured through the plugin config. Edit the user plugin config with: @@ -183,8 +182,8 @@ 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. The explicit file -replaces the user layer, so `--user` keeps that inherited target. -`--project` and `--global` edit the other active layers. +replaces the user layer, so `--user` keeps that inherited target. `--global` +edits the system layer. 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 @@ -193,16 +192,17 @@ Other dynamic plugins use a raw JSON object editor. The canonical plugin file is `plugins.toml`; user config lives at `~/.config/nemo-relay/plugins.toml` or -`$XDG_CONFIG_HOME/nemo-relay/plugins.toml`. Project config lives at -`.nemo-relay/plugins.toml`. Use `nemo-relay plugins edit --global` to edit -`/etc/nemo-relay/plugins.toml`; it is system-readable (`0644` on Unix), so do -not store credentials there. The editor rejects schema-declared secret values -in global plugin configuration. +`$XDG_CONFIG_HOME/nemo-relay/plugins.toml`. Use +`nemo-relay plugins edit --global` to edit `/etc/nemo-relay/plugins.toml` on +Unix or `%ProgramData%\nemo-relay\plugins.toml` on Windows. It is +system-readable (`0644` on Unix), so do 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 +explicit-or-user, 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 +ambient XDG user file without suppressing system policy. Repository-local +`.nemo-relay/plugins.toml` files are ignored. Missing files are skipped, and symlink aliases to one physical file are loaded once. Minimal ATIF example: diff --git a/crates/cli/src/agents/hermes/integration.rs b/crates/cli/src/agents/hermes/integration.rs index e1ea8c2b7..fbba5a6b3 100644 --- a/crates/cli/src/agents/hermes/integration.rs +++ b/crates/cli/src/agents/hermes/integration.rs @@ -34,8 +34,7 @@ use crate::installation::generation::{ GENERATION_FILE_ENV, GENERATION_TOKEN_ENV, GenerationRetirement, InstallGeneration, }; -/// Hermes host configuration is user-owned even when Relay itself uses project configuration. -/// Project-specific Relay behavior remains available through transparent `nemo-relay run`. +/// Hermes host configuration is user-owned. pub(crate) fn user_config_path(default_home: &Path) -> PathBuf { user_config_path_with_override(default_home, env::var_os("HERMES_HOME")) } diff --git a/crates/cli/src/bootstrap/mod.rs b/crates/cli/src/bootstrap/mod.rs index 79e8aad02..a7cd3d61e 100644 --- a/crates/cli/src/bootstrap/mod.rs +++ b/crates/cli/src/bootstrap/mod.rs @@ -56,7 +56,6 @@ pub(crate) struct GatewaySpec { bind: SocketAddr, launch_args: Vec, bootstrap_fingerprint: Option, - user_config_scope: bool, } impl GatewaySpec { @@ -65,7 +64,6 @@ impl GatewaySpec { bind, launch_args: Vec::new(), bootstrap_fingerprint: None, - user_config_scope: false, } } @@ -79,11 +77,6 @@ impl GatewaySpec { self } - pub(crate) fn with_user_config_scope(mut self) -> Self { - self.user_config_scope = true; - self - } - pub(crate) fn bind(&self) -> SocketAddr { self.bind } @@ -335,18 +328,6 @@ fn start_gateway(spec: &GatewaySpec, state: &Path) -> Result for TargetScope { fn from(command: &ConfigEditCommand) -> Self { - if command.project { - Self::Project - } else if command.global { + if command.global { Self::Global } else { Self::User @@ -47,7 +44,7 @@ fn resolve_edit_target( explicit_path: Option, ) -> Result<(TargetScope, PathBuf), CliError> { let scope = TargetScope::from(command); - let path = if command.project || command.global { + let path = if command.global { target_path(scope)? } else { match explicit_path { @@ -100,7 +97,7 @@ impl ConfigDocument { } crate::filesystem::atomic_write_system_readable(&self.path, contents.as_bytes()) } - TargetScope::User | TargetScope::Project => { + TargetScope::User => { crate::filesystem::atomic_write_private(&self.path, contents.as_bytes()) } } @@ -550,28 +547,10 @@ fn target_path(scope: TargetScope) -> Result { "cannot determine user config directory; set HOME or XDG_CONFIG_HOME".into(), ) }), - TargetScope::Project => Ok(project_config_path(&std::env::current_dir()?)), - TargetScope::Global => Ok(PathBuf::from("/etc/nemo-relay/config.toml")), + TargetScope::Global => Ok(crate::configuration::system_config_dir().join("config.toml")), } } -fn project_config_path(start: &Path) -> PathBuf { - project_config_path_with_boundary(start, None) -} - -fn project_config_path_with_boundary(start: &Path, boundary: Option<&Path>) -> PathBuf { - for ancestor in start.ancestors() { - let candidate = ancestor.join(".nemo-relay/config.toml"); - if candidate.exists() { - return candidate; - } - if boundary == Some(ancestor) { - break; - } - } - start.join(".nemo-relay/config.toml") -} - #[cfg(test)] #[path = "../../../tests/coverage/commands/configure_editor_tests.rs"] mod tests; diff --git a/crates/cli/src/commands/configure/mod.rs b/crates/cli/src/commands/configure/mod.rs index a353b9f52..01ca40b90 100644 --- a/crates/cli/src/commands/configure/mod.rs +++ b/crates/cli/src/commands/configure/mod.rs @@ -22,13 +22,10 @@ pub(crate) struct ConfigCommand { pub(crate) command: Option, #[arg(value_enum)] pub(crate) agent: Option, - /// Reset Relay configuration for the selected scope. Persistent Hermes integration state is + /// Reset user Relay configuration. Persistent Hermes integration state is /// managed separately with `nemo-relay uninstall hermes`. #[arg(long)] pub(crate) reset: bool, - /// Configuration scope to reset. Defaults to the project configuration. - #[arg(long, value_enum, requires = "reset")] - pub(crate) scope: Option, } #[derive(Debug, Clone, Subcommand)] @@ -40,17 +37,14 @@ pub(crate) enum ConfigSubcommand { #[derive(Debug, Clone, Default, Args)] #[command(group( ArgGroup::new("scope") - .args(["user", "project", "global"]) + .args(["user", "global"]) .multiple(false) ))] pub(crate) struct ConfigEditCommand { /// 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`. - #[arg(long)] - pub(crate) project: bool, - /// Edit the system config at `/etc/nemo-relay/config.toml`. + /// Edit system config (`/etc/nemo-relay` on Unix; `%ProgramData%\nemo-relay` on Windows). #[arg(long)] pub(crate) global: bool, } @@ -65,7 +59,7 @@ pub(super) async fn execute( } let agent = command.agent.map(Into::into); if command.reset { - model::reset(command.scope.unwrap_or(model::ConfigScope::Project), agent)?; + model::reset(agent)?; } else { let overrides = server.to_runtime(); let explicit_plugin_path = crate::configuration::explicit_plugin_config_path( diff --git a/crates/cli/src/commands/configure/model.rs b/crates/cli/src/commands/configure/model.rs index 3062f093d..2c1bb514c 100644 --- a/crates/cli/src/commands/configure/model.rs +++ b/crates/cli/src/commands/configure/model.rs @@ -5,56 +5,23 @@ use std::path::{Path, PathBuf}; -use clap::ValueEnum; use toml_edit::{DocumentMut, Item, Table, value}; use crate::agents::CodingAgent; use crate::error::CliError; use crate::plugins::{ConfigurationScope, PluginsEditRequest}; -/// Where the setup saves its output. -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -pub(crate) enum ConfigScope { - /// `./.nemo-relay/config.toml` (walked-up workspace dir). - Project, - /// `~/.config/nemo-relay/config.toml` (or `$XDG_CONFIG_HOME/nemo-relay/config.toml`). - Global, - /// Both project and global; project takes precedence per merge order. - Both, -} - -impl ConfigScope { - pub(crate) fn label(self) -> &'static str { - match self { - Self::Project => "project ./.nemo-relay/config.toml (recommended)", - Self::Global => "global ~/.config/nemo-relay/config.toml", - Self::Both => "both project overrides global", - } - } -} - -/// Maps the base setup scope to the plugin editor target for the guided continuation. -/// -/// An explicit plugin path follows the runtime contract and wins over the wizard scope. Without -/// one, `Project` and `Both` configure the project `plugins.toml`, while `Global` configures the -/// user `plugins.toml`. -pub(crate) fn plugins_edit_command_for_scope( - scope: ConfigScope, - explicit_path: Option, -) -> PluginsEditRequest { - let scope = match (&explicit_path, scope) { - (Some(_), _) => ConfigurationScope::User, - (None, ConfigScope::Project | ConfigScope::Both) => ConfigurationScope::Project, - (None, ConfigScope::Global) => ConfigurationScope::User, - }; +/// Maps setup to the plugin editor target for the guided continuation. An explicit plugin path +/// follows the runtime contract and wins over the user file. +pub(crate) fn plugins_edit_command(explicit_path: Option) -> PluginsEditRequest { PluginsEditRequest { - scope, + scope: ConfigurationScope::User, explicit_path, } } /// Returns the exact command a user runs to resume plugin setup after skipping the continuation. -pub(crate) fn plugins_resume_command(scope: ConfigScope, explicit_path: Option<&Path>) -> String { +pub(crate) fn plugins_resume_command(explicit_path: Option<&Path>) -> String { if let Some(path) = explicit_path { let path = crate::process::shell_quote_arg_for_platform( &path.display().to_string(), @@ -62,16 +29,12 @@ pub(crate) fn plugins_resume_command(scope: ConfigScope, explicit_path: Option<& ); return format!("nemo-relay --plugin-config-path {path} plugins edit"); } - match scope { - ConfigScope::Project | ConfigScope::Both => "nemo-relay plugins edit --project".into(), - ConfigScope::Global => "nemo-relay plugins edit".into(), - } + "nemo-relay plugins edit".into() } /// Resolved answers from setup. Built either by `prompt_user` (interactive) or by tests. #[derive(Debug, Clone)] pub(crate) struct SetupAnswers { - pub scope: ConfigScope, pub agents: Vec, } @@ -127,7 +90,7 @@ pub(crate) fn build_agents_table(answers: &SetupAnswers) -> Option { Some(agents_table) } -/// Writes the setup's TOML document to the scope-appropriate path(s). +/// Writes the setup's TOML document to the user configuration path. /// /// When `merge_scope` is `Some(agent)`, an existing `config.toml` at the target path is parsed /// and only the single `[agents.]` block owned by THIS wizard run is replaced. Other @@ -139,33 +102,20 @@ pub(crate) fn build_agents_table(answers: &SetupAnswers) -> Option
{ /// tempdirs. pub(crate) fn save_config( doc: &DocumentMut, - scope: ConfigScope, - cwd: &Path, home: &Path, merge_scope: Option, ) -> Result, CliError> { - let mut written = Vec::new(); - if matches!(scope, ConfigScope::Project | ConfigScope::Both) { - let project_dir = cwd.join(".nemo-relay"); - std::fs::create_dir_all(&project_dir)?; - let path = project_dir.join("config.toml"); - write_or_merge(&path, doc, merge_scope)?; - written.push(path); - } - if matches!(scope, ConfigScope::Global | ConfigScope::Both) { - let global_dir = global_config_dir(home); - std::fs::create_dir_all(&global_dir)?; - let path = global_dir.join("config.toml"); - write_or_merge(&path, doc, merge_scope)?; - written.push(path); - } - Ok(written) + let user_dir = user_config_dir(home); + std::fs::create_dir_all(&user_dir)?; + let path = user_dir.join("config.toml"); + write_or_merge(&path, doc, merge_scope)?; + Ok(vec![path]) } -// Resolves the global nemo-relay config directory. Prefers `$XDG_CONFIG_HOME/nemo-relay` (matches +// Resolves the user nemo-relay config directory. Prefers `$XDG_CONFIG_HOME/nemo-relay` (matches // `config::user_config_dir`), falling back to `/.config/nemo-relay`. Tests that pass a // tempdir for `home` get hermetic paths unless they set XDG_CONFIG_HOME explicitly. -pub(crate) fn global_config_dir(home: &Path) -> PathBuf { +pub(crate) fn user_config_dir(home: &Path) -> PathBuf { if let Some(base) = std::env::var_os("XDG_CONFIG_HOME") { return PathBuf::from(base).join("nemo-relay"); } @@ -235,32 +185,20 @@ pub(crate) fn merge_agents_entry(dst: &mut DocumentMut, src: &DocumentMut, agent agents_table.insert(agent_key, src_agent.clone()); } -/// Removes the project `config.toml` (or just one agent's block within it). +/// Removes the user `config.toml` (or just one agent's block within it). /// -/// `agent_hint = None` deletes the whole project config file. `agent_hint = Some(agent)` parses +/// `agent_hint = None` deletes the whole user config file. `agent_hint = Some(agent)` parses /// the existing file and removes only `[agents.]`, leaving every other section intact. -/// In both cases this targets the *project* layer; global and system layers are left to direct -/// editing because they typically aren't owned by the wizard. -pub(crate) fn reset(scope: ConfigScope, agent_hint: Option) -> Result<(), CliError> { - if matches!(scope, ConfigScope::Project | ConfigScope::Both) { - let cwd = std::env::current_dir()?; - reset_config_path( - &cwd.join(".nemo-relay").join("config.toml"), - "project", - agent_hint, - )?; - } - if matches!(scope, ConfigScope::Global | ConfigScope::Both) { - let home = home_dir().ok_or_else(|| { - CliError::Config("cannot resolve the home directory for global reset".into()) - })?; - reset_config_path( - &global_config_dir(&home).join("config.toml"), - "global", - agent_hint, - )?; - } - Ok(()) +/// System configuration is left to direct editing because it is not owned by the wizard. +pub(crate) fn reset(agent_hint: Option) -> Result<(), CliError> { + let home = home_dir().ok_or_else(|| { + CliError::Config("cannot resolve the home directory for user reset".into()) + })?; + reset_config_path( + &user_config_dir(&home).join("config.toml"), + "user", + agent_hint, + ) } fn reset_config_path( @@ -318,49 +256,22 @@ fn reset_config_path( /// unparseable the defaults are all-empty and the wizard behaves like a first-run setup. #[derive(Debug, Clone, Default)] pub(crate) struct Defaults { - pub(crate) scope: Option, pub(crate) agents: Vec, } impl Defaults { pub(crate) fn has_any(&self) -> bool { - self.scope.is_some() || !self.agents.is_empty() + !self.agents.is_empty() } } -/// Reads the highest-precedence existing config file and derives wizard defaults from it. -/// Workspace config wins over global; if both exist, scope defaults to `Both`. Missing or -/// malformed files yield `None` (the wizard then behaves as if no config existed). +/// Reads the user config file and derives wizard defaults from it. Missing or malformed files +/// yield `None` (the wizard then behaves as if no config existed). pub(crate) fn read_existing_defaults() -> Option { - let cwd = std::env::current_dir().ok()?; - let home = home_dir(); - - let workspace_path = cwd.join(".nemo-relay").join("config.toml"); - let global_path = home - .as_ref() - .map(|h| global_config_dir(h).join("config.toml")); - - let workspace_exists = workspace_path.exists(); - let global_exists = global_path.as_ref().is_some_and(|p| p.exists()); - - let read_doc = - |path: &Path| -> Option { std::fs::read_to_string(path).ok()?.parse().ok() }; - - let doc = match (workspace_exists, global_exists) { - (true, _) => read_doc(&workspace_path)?, - (false, true) => read_doc(global_path.as_ref()?)?, - (false, false) => return None, - }; - - let scope = match (workspace_exists, global_exists) { - (true, true) => Some(ConfigScope::Both), - (true, false) => Some(ConfigScope::Project), - (false, true) => Some(ConfigScope::Global), - (false, false) => None, - }; + let path = user_config_dir(&home_dir()?).join("config.toml"); + let doc: DocumentMut = std::fs::read_to_string(path).ok()?.parse().ok()?; Some(Defaults { - scope, agents: read_agents_from_doc(&doc), }) } @@ -388,15 +299,8 @@ pub(crate) fn agent_key_and_command(agent: CodingAgent) -> (&'static str, &'stat (agent.as_arg(), agent.executable()) } -pub(crate) fn preview_paths(scope: ConfigScope, cwd: &Path, home: &Path) -> Vec { - let mut paths = Vec::new(); - if matches!(scope, ConfigScope::Project | ConfigScope::Both) { - paths.push(cwd.join(".nemo-relay").join("config.toml")); - } - if matches!(scope, ConfigScope::Global | ConfigScope::Both) { - paths.push(global_config_dir(home).join("config.toml")); - } - paths +pub(crate) fn preview_paths(home: &Path) -> Vec { + vec![user_config_dir(home).join("config.toml")] } pub(crate) fn home_dir() -> Option { diff --git a/crates/cli/src/commands/configure/wizard.rs b/crates/cli/src/commands/configure/wizard.rs index 3b3a418d3..1bdbbe323 100644 --- a/crates/cli/src/commands/configure/wizard.rs +++ b/crates/cli/src/commands/configure/wizard.rs @@ -12,8 +12,7 @@ use toml_edit::DocumentMut; #[cfg(test)] use self::model::{ - ConfigScope, build_config, plugins_edit_command_for_scope, plugins_resume_command, - preview_paths, save_config, + build_config, plugins_edit_command, plugins_resume_command, preview_paths, save_config, }; use super::model; use crate::agents::CodingAgent; @@ -21,7 +20,7 @@ use crate::error::CliError; #[cfg(test)] use self::model::{ - Defaults, SetupAnswers, global_config_dir, read_agents_from_doc, read_existing_defaults, reset, + Defaults, SetupAnswers, read_agents_from_doc, read_existing_defaults, reset, user_config_dir, write_or_merge, }; diff --git a/crates/cli/src/commands/configure/wizard/prompt.rs b/crates/cli/src/commands/configure/wizard/prompt.rs index 4c9d01192..544fe46f7 100644 --- a/crates/cli/src/commands/configure/wizard/prompt.rs +++ b/crates/cli/src/commands/configure/wizard/prompt.rs @@ -7,18 +7,18 @@ use std::io::IsTerminal; use std::path::PathBuf; use dialoguer::theme::ColorfulTheme; -use dialoguer::{Confirm, MultiSelect, Select}; +use dialoguer::{Confirm, MultiSelect}; use toml_edit::DocumentMut; use super::model::{ - ConfigScope, SetupAnswers, agent_key_and_command, build_config, detect_installed_agents, - home_dir, plugins_edit_command_for_scope, plugins_resume_command, preview_paths, - read_existing_defaults, save_config, + SetupAnswers, agent_key_and_command, build_config, detect_installed_agents, home_dir, + plugins_edit_command, plugins_resume_command, preview_paths, read_existing_defaults, + save_config, }; use crate::agents::CodingAgent; use crate::error::CliError; -/// Prompts for the configuration scope and agents selected by the user. +/// Prompts for the agents selected by the user. /// /// When `agent_hint` is present, the agent picker is skipped because the command already /// identified the requested agent. @@ -61,7 +61,6 @@ pub(crate) fn prompt_user( println!(); let theme = ColorfulTheme::default(); - let scope = ask_scope(&theme, defaults.scope)?; let agents = match agent_hint { Some(agent) => vec![agent], None => ask_agents(&theme, detected_agents, &defaults.agents)?, @@ -70,7 +69,7 @@ pub(crate) fn prompt_user( print_codex_api_key_guide(); } - Ok(SetupAnswers { scope, agents }) + Ok(SetupAnswers { agents }) } pub(super) async fn run( @@ -80,40 +79,35 @@ pub(super) async fn run( let detected = detect_installed_agents(); let answers = prompt_user(&detected, agent_hint)?; - let cwd = std::env::current_dir()?; let home = home_dir().ok_or_else(|| { CliError::Config("cannot determine home directory (set $HOME or $USERPROFILE)".into()) })?; let doc = build_config(&answers); - let preview_paths = preview_paths(answers.scope, &cwd, &home); + let preview_paths = preview_paths(&home); if !confirm_summary(&preview_paths, &doc)? { return Err(CliError::Config("setup cancelled — no config saved".into())); } - let written = save_config(&doc, answers.scope, &cwd, &home, agent_hint)?; + let written = save_config(&doc, &home, agent_hint)?; println!(); println!(" ✓ Saved:"); for path in &written { println!(" {}", path.display()); } println!(); - continue_to_plugins(answers.scope, explicit_plugin_path) + continue_to_plugins(explicit_plugin_path) } /// After the base config is saved, offers to continue into plugin configuration in-process. /// /// Prompts once. On acceptance it runs the existing plugin editor targeting an explicit runtime -/// plugin path when present, otherwise the scope derived from base setup (project for -/// `Project`/`Both`, user for `Global`). On decline it reports that the base config was saved, +/// plugin path when present, otherwise the user plugin file. On decline it reports that the base config was saved, /// that plugin setup was skipped, and prints the command to resume later. Prompt interruption is /// treated as a skip; other prompt or editor failures surface an error that makes clear the base /// config remains saved. The saved `config.toml` is never rolled back here. -fn continue_to_plugins( - scope: ConfigScope, - explicit_plugin_path: Option, -) -> Result<(), CliError> { - let resume_command = plugins_resume_command(scope, explicit_plugin_path.as_deref()); +fn continue_to_plugins(explicit_plugin_path: Option) -> Result<(), CliError> { + let resume_command = plugins_resume_command(explicit_plugin_path.as_deref()); let proceed = match confirm_plugin_setup() { Ok(proceed) => proceed, Err(error) if super::plugin_prompt_was_interrupted(&error) => { @@ -132,7 +126,7 @@ fn continue_to_plugins( print_plugins_skipped(&resume_command); return Ok(()); } - let result = crate::plugins::edit(plugins_edit_command_for_scope(scope, explicit_plugin_path)); + let result = crate::plugins::edit(plugins_edit_command(explicit_plugin_path)); result.map_err(|error| { let cause = match error { CliError::Config(message) => message, @@ -180,7 +174,7 @@ fn ensure_tty() -> Result<(), CliError> { if !std::io::stdin().is_terminal() { return Err(CliError::Config( "interactive setup requires a TTY; pass `--config ` or set up \ - `.nemo-relay/config.toml` manually" + `$XDG_CONFIG_HOME/nemo-relay/config.toml` manually" .into(), )); } @@ -198,26 +192,6 @@ fn print_detected_agents(detected: &[CodingAgent]) { } } -fn ask_scope( - theme: &ColorfulTheme, - existing: Option, -) -> Result { - let options = [ConfigScope::Project, ConfigScope::Global, ConfigScope::Both]; - let labels: Vec<&str> = options.iter().map(|s| s.label()).collect(); - // Start on the user's existing scope if there is one (so re-running the wizard doesn't - // accidentally relocate their config), else `Project` per the design default. - let default_idx = existing - .and_then(|s| options.iter().position(|opt| *opt == s)) - .unwrap_or(0); - let idx = Select::with_theme(theme) - .with_prompt("Save config where?") - .items(&labels) - .default(default_idx) - .interact() - .map_err(setup_error)?; - Ok(options[idx]) -} - fn ask_agents( theme: &ColorfulTheme, detected: &[CodingAgent], diff --git a/crates/cli/src/commands/logging.rs b/crates/cli/src/commands/logging.rs index 96d76b228..cc53aad52 100644 --- a/crates/cli/src/commands/logging.rs +++ b/crates/cli/src/commands/logging.rs @@ -41,7 +41,6 @@ impl LoggingArgs { pub(super) fn resolve( &self, explicit_config: Option<&Path>, - user_only: bool, ) -> Result { if let Some(path) = &self.config_path { return LoggingConfig::from_file_path(path).map_err(logging_config_error); @@ -63,7 +62,7 @@ impl LoggingArgs { return Ok(config); } - crate::configuration::resolve_logging_config(explicit_config, user_only) + crate::configuration::resolve_logging_config(explicit_config) } } diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 9771ba937..3b1d3bcf8 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -73,16 +73,13 @@ fn configure_logging(cli: &Cli) -> Result { }); } - let user_only = matches!(cli.command.as_ref(), Some(Command::Mcp)); - let explicit_config = match (user_only, cli.command.as_ref()) { - (true, _) => None, - (false, Some(Command::Run(command))) => { - command.config.as_deref().or(cli.server.config.as_deref()) - } - (false, _) => cli.server.config.as_deref(), + let explicit_config = match cli.command.as_ref() { + Some(Command::Mcp) => None, + Some(Command::Run(command)) => command.config.as_deref().or(cli.server.config.as_deref()), + _ => cli.server.config.as_deref(), }; let mut fallback_error = None; - let config = match cli.logging.resolve(explicit_config, user_only) { + let config = match cli.logging.resolve(explicit_config) { Ok(config) => config, Err(error) if matches!(cli.command.as_ref(), Some(Command::Doctor(_))) => { fallback_error = Some(error); diff --git a/crates/cli/src/commands/model_pricing/mod.rs b/crates/cli/src/commands/model_pricing/mod.rs index 6ee8b5af0..94f33aa01 100644 --- a/crates/cli/src/commands/model_pricing/mod.rs +++ b/crates/cli/src/commands/model_pricing/mod.rs @@ -32,17 +32,14 @@ pub(crate) enum PricingSubcommand { #[derive(Debug, Clone, Default, Args)] #[command(group( ArgGroup::new("pricing_scope") - .args(["user", "project", "global"]) + .args(["user", "global"]) .multiple(false) ))] pub(crate) struct PricingScopeArgs { /// Edit the user config at `$XDG_CONFIG_HOME/nemo-relay/plugins.toml`. #[arg(long)] pub(crate) user: bool, - /// Edit the nearest project config at `.nemo-relay/plugins.toml`. - #[arg(long)] - pub(crate) project: bool, - /// Edit the system config at `/etc/nemo-relay/plugins.toml`. + /// Edit system config (`/etc/nemo-relay` on Unix; `%ProgramData%\nemo-relay` on Windows). #[arg(long)] pub(crate) global: bool, } @@ -96,11 +93,10 @@ pub(crate) struct PricingResolveCommand { } impl From for crate::plugins::ConfigurationScope { fn from(value: PricingScopeArgs) -> Self { - match (value.user, value.project, value.global) { - (false, false, false) => Self::Default, - (true, false, false) => Self::User, - (false, true, false) => Self::Project, - (false, false, true) => Self::Global, + match (value.user, value.global) { + (false, false) => Self::Default, + (true, false) => Self::User, + (false, true) => Self::Global, _ => Self::Invalid, } } diff --git a/crates/cli/src/commands/plugins/subcommands.rs b/crates/cli/src/commands/plugins/subcommands.rs index 47f9c4de5..42deac674 100644 --- a/crates/cli/src/commands/plugins/subcommands.rs +++ b/crates/cli/src/commands/plugins/subcommands.rs @@ -68,17 +68,14 @@ impl PluginsSubcommand { #[derive(Debug, Clone, Default, Args)] #[command(group( ArgGroup::new("scope") - .args(["user", "project", "global"]) + .args(["user", "global"]) .multiple(false) ))] pub(crate) struct PluginsScopeArgs { /// 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`. - #[arg(long)] - pub(crate) project: bool, - /// Edit the system config at `/etc/nemo-relay/plugins.toml`. + /// Edit system config (`/etc/nemo-relay` on Unix; `%ProgramData%\nemo-relay` on Windows). #[arg(long)] pub(crate) global: bool, } @@ -153,11 +150,10 @@ pub(crate) struct PluginsRemoveCommand { impl From for crate::plugins::ConfigurationScope { fn from(value: PluginsScopeArgs) -> Self { - match (value.user, value.project, value.global) { - (false, false, false) => Self::Default, - (true, false, false) => Self::User, - (false, true, false) => Self::Project, - (false, false, true) => Self::Global, + match (value.user, value.global) { + (false, false) => Self::Default, + (true, false) => Self::User, + (false, true) => Self::Global, _ => Self::Invalid, } } diff --git a/crates/cli/src/commands/root.rs b/crates/cli/src/commands/root.rs index 6b8c4f48f..9a64bc01f 100644 --- a/crates/cli/src/commands/root.rs +++ b/crates/cli/src/commands/root.rs @@ -54,7 +54,7 @@ pub(crate) enum Command { long_about = "Run Anthropic's `claude` CLI under an ephemeral NeMo Relay gateway. \ Observability (ATIF + OpenInference) is wired in transparently via \ ANTHROPIC_BASE_URL. First-time use launches the setup wizard so the \ - `[agents.claude]` block lands in `.nemo-relay/config.toml` and observation \ + `[agents.claude]` block lands in the XDG user `config.toml` and observation \ starts on the next invocation without prompts.", after_help = "Examples:\n \ nemo-relay claude\n \ @@ -102,7 +102,7 @@ pub(crate) enum Command { nemo-relay --bind 127.0.0.1:4041 mcp # explicit standalone/test bind" )] Mcp, - /// Run the interactive setup (writes `.nemo-relay/config.toml`) + /// Run the interactive setup (writes the XDG user `config.toml`) Config(ConfigCommand), /// Create or edit plugin configuration (writes `plugins.toml`) Plugins(PluginsCommand), diff --git a/crates/cli/src/commands/serve.rs b/crates/cli/src/commands/serve.rs index c611281d3..e0d99e255 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 replacing the user config layer; project and system config still apply + /// Path replacing the user config layer; system config still applies #[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 2200b9c48..6c3a1ff09 100644 --- a/crates/cli/src/configuration/mod.rs +++ b/crates/cli/src/configuration/mod.rs @@ -113,14 +113,10 @@ pub(crate) fn resolve_server_config(args: &GatewayOverrides) -> Result, - user_only: bool, -) -> Result { +pub(crate) fn resolve_logging_config(explicit: Option<&Path>) -> Result { let explicit = explicit.map(Path::to_path_buf); - 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) { + for path in config_paths(explicit.as_ref()) { let required = explicit.as_ref() == Some(&path); let Some(raw) = read_config_file(&path, required, "configuration")? else { continue; @@ -152,11 +148,11 @@ pub(crate) fn resolve_persistent_server_config( ) -> Result { if args.config.is_some() || args.plugin_config_path.is_some() || args.ready_file.is_some() { return Err(CliError::Config( - "nemo-relay mcp uses system and user configuration only; use `nemo-relay run` for explicit or project configuration" + "nemo-relay mcp uses system and user configuration only; use `nemo-relay run` for explicit configuration" .into(), )); } - let mut resolved = load_shared_config_scoped(None, None, true)?; + let mut resolved = load_shared_config(None, None)?; apply_server_overrides(&mut resolved.gateway, args)?; let active_dynamic_plugins = active_dynamic_plugin_components_for_identity(None, &resolved)?; resolved.bootstrap_fingerprint = Some(persistent_bootstrap_fingerprint( @@ -1003,17 +999,9 @@ pub(crate) const PLUGINS_TOML: &str = "plugins.toml"; fn load_shared_config( explicit: Option<&PathBuf>, plugin_config_path: Option<&PathBuf>, -) -> Result { - load_shared_config_scoped(explicit, plugin_config_path, user_config_scope()) -} - -fn load_shared_config_scoped( - explicit: Option<&PathBuf>, - plugin_config_path: Option<&PathBuf>, - user_only: bool, ) -> Result { let mut merged = toml::Value::Table(toml::map::Map::new()); - for path in config_paths_scoped(explicit, user_only) { + for path in config_paths(explicit) { let required = explicit == Some(&path); let Some(raw) = read_config_file(&path, required, "configuration")? else { continue; @@ -1041,7 +1029,7 @@ fn load_shared_config_scoped( } merge_gateway_config_toml(&mut merged, parsed); } - let plugin_toml = load_plugin_toml_config_scoped(explicit, plugin_config_path, user_only)?; + let plugin_toml = load_plugin_toml_config(explicit, plugin_config_path)?; let mut resolved = ResolvedConfig { gateway: GatewayConfig::default(), ..ResolvedConfig::default() @@ -1084,53 +1072,30 @@ pub(crate) fn any_config_file_exists() -> bool { } // 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. +// ambient user file; the system layer still applies. 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 { paths.push(path.clone()); } else if let Some(user) = user_config_path() { paths.push(user); } - if !user_only - && let Ok(cwd) = std::env::current_dir() - && let Some(project) = find_project_config(&cwd) - { - paths.push(project); - } - paths.push(PathBuf::from("/etc/nemo-relay/config.toml")); + paths.push(system_config_dir().join("config.toml")); paths } // 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. +// target replaces the ambient user file; the system layer still applies. fn plugin_config_paths( explicit: Option<&PathBuf>, plugin_config_path: Option<&PathBuf>, ) -> Vec { - plugin_config_paths_scoped(explicit, plugin_config_path, user_config_scope()) -} - -fn plugin_config_paths_scoped( - explicit: Option<&PathBuf>, - plugin_config_path: Option<&PathBuf>, - user_only: bool, -) -> Vec { - 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)); + paths.extend(implicit_plugin_config_paths(None)); return paths; } - implicit_plugin_config_paths(cwd.as_deref(), user_config_dir()) + implicit_plugin_config_paths(user_config_dir()) } /// Resolves the low-precedence plugin document selected by explicit gateway configuration. @@ -1147,33 +1112,9 @@ pub(crate) fn explicit_plugin_config_path( }) } -fn user_config_scope() -> bool { - std::env::var("NEMO_RELAY_CONFIG_SCOPE").ok().as_deref() == Some("user") -} - -fn implicit_plugin_config_paths( - cwd: Option<&std::path::Path>, - user_config_dir: Option, -) -> Vec { +fn implicit_plugin_config_paths(user_config_dir: Option) -> Vec { // The search-path logic lives in core; the gateway shares it so discovery stays identical. - nemo_relay::plugin::default_plugin_config_paths(cwd, user_config_dir) -} - -// 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. -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() { - return Some(path); - } - } - None -} - -// The project-walk lives in core; the gateway shares it so discovery stays identical. -fn find_project_plugin_config(start: &std::path::Path) -> Option { - nemo_relay::plugin::nearest_project_plugin_config(start) + nemo_relay::plugin::default_plugin_config_paths(user_config_dir) } pub(crate) fn user_plugin_config_path() -> Option { @@ -1182,22 +1123,13 @@ pub(crate) fn user_plugin_config_path() -> Option { pub(crate) fn user_plugin_runtime_config() -> Result, CliError> { Ok( - load_plugin_toml_config_from_paths(implicit_plugin_config_paths(None, user_config_dir()))? + load_plugin_toml_config_from_paths(implicit_plugin_config_paths(user_config_dir()))? .and_then(|config| config.value), ) } -pub(crate) fn project_plugin_config_path(start: &std::path::Path) -> PathBuf { - find_project_plugin_config(start) - .or_else(|| { - find_project_config(start) - .and_then(|path| path.parent().map(|parent| parent.join(PLUGINS_TOML))) - }) - .unwrap_or_else(|| start.join(".nemo-relay").join(PLUGINS_TOML)) -} - pub(crate) fn global_plugin_config_path() -> PathBuf { - PathBuf::from("/etc/nemo-relay").join(PLUGINS_TOML) + system_config_dir().join(PLUGINS_TOML) } // Resolves the user config using XDG first and HOME/USERPROFILE second. Returning `None` keeps @@ -1212,6 +1144,11 @@ pub(crate) fn user_config_dir() -> Option { nemo_relay::plugin::user_config_dir() } +/// Resolves the platform system config directory shared with the core plugin runtime. +pub(crate) fn system_config_dir() -> PathBuf { + nemo_relay::plugin::system_config_dir() +} + // Applies the typed TOML config model to the resolved runtime config. Missing sections and fields // are ignored, preserving defaults and prior merge layers. fn apply_file_config(resolved: &mut ResolvedConfig, value: toml::Value) -> Result<(), CliError> { @@ -1316,21 +1253,16 @@ struct FileDynamicPluginConfig { config: Option>, } -fn load_plugin_toml_config_scoped( +fn load_plugin_toml_config( explicit: Option<&PathBuf>, plugin_config_path: Option<&PathBuf>, - user_only: bool, ) -> Result, CliError> { - load_plugin_toml_config_from_paths(plugin_config_paths_scoped( - explicit, - plugin_config_path, - user_only, - )) + load_plugin_toml_config_from_paths(plugin_config_paths(explicit, plugin_config_path)) } /// Returns the plugin configuration paths selected by the same rules as runtime resolution. /// -/// Diagnostics use this so they report the same explicit-or-user, project, and system layers as +/// Diagnostics use this so they report the same explicit-or-user and system layers as /// runtime resolution. pub(crate) fn diagnostic_plugin_config_paths( explicit: Option<&PathBuf>, diff --git a/crates/cli/src/diagnostics/mod.rs b/crates/cli/src/diagnostics/mod.rs index 6a325ca0d..c033d7d54 100644 --- a/crates/cli/src/diagnostics/mod.rs +++ b/crates/cli/src/diagnostics/mod.rs @@ -133,7 +133,7 @@ pub(crate) async fn collect_report( }; Ok(DoctorReport { - schema_version: 1, + schema_version: 2, binary_version: env!("CARGO_PKG_VERSION"), target_agent: target_agent.map(|agent| agent.as_arg().to_string()), environment: collect_environment(), @@ -163,19 +163,14 @@ fn collect_configuration( plugin_diagnostics: &PluginConfigurationDiagnostics, ) -> ConfigurationInfo { let explicit_config = gateway_overrides.config.is_some(); - let workspace_path = cwd - .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`. let global_path = crate::configuration::user_config_dir() .map(|dir| dir.join("config.toml")) .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 system_path = crate::configuration::system_config_dir().join("config.toml"); let explicit = gateway_overrides.config.as_deref().map(layer_status); - let workspace = layer_status(&workspace_path); let global = if explicit_config { replaced_user_layer_status(&global_path) } else { @@ -190,9 +185,9 @@ fn collect_configuration( ConfigurationInfo { explicit, - workspace, global, system, + unsupported_project_files: unsupported_project_files(cwd), upstream_auth, plugin_configs: diagnostic_plugin_config_paths( gateway_overrides.config.as_ref(), @@ -226,6 +221,26 @@ fn collect_configuration( } } +fn unsupported_project_files(cwd: Option<&Path>) -> Vec { + let Some(cwd) = cwd else { + return Vec::new(); + }; + cwd.ancestors() + .flat_map(|ancestor| { + let directory = ancestor.join(".nemo-relay"); + ["config.toml", "plugins.toml", ".dynamic-plugins.json"] + .map(move |filename| directory.join(filename)) + }) + .filter(|path| path.is_file()) + .map(|path| ConfigLayer { + path, + status: Status::Warn, + active: false, + details: "unsupported project file; ignored by Relay; move configuration to a user or system location, or select a config file explicitly".into(), + }) + .collect() +} + fn plugin_resolution_check( resolved: &ResolvedConfig, resolution: &Check, diff --git a/crates/cli/src/diagnostics/model.rs b/crates/cli/src/diagnostics/model.rs index 792643244..6ca5ca3b2 100644 --- a/crates/cli/src/diagnostics/model.rs +++ b/crates/cli/src/diagnostics/model.rs @@ -52,9 +52,10 @@ pub(crate) struct EnvironmentInfo { pub(crate) struct ConfigurationInfo { #[serde(skip_serializing_if = "Option::is_none")] pub explicit: Option, - pub workspace: ConfigLayer, pub global: ConfigLayer, pub system: ConfigLayer, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub unsupported_project_files: Vec, pub upstream_auth: UpstreamAuthInfo, pub plugin_configs: Vec, pub plugin_resolution: Check, diff --git a/crates/cli/src/diagnostics/render.rs b/crates/cli/src/diagnostics/render.rs index 5e4361530..3844d7360 100644 --- a/crates/cli/src/diagnostics/render.rs +++ b/crates/cli/src/diagnostics/render.rs @@ -21,7 +21,6 @@ pub(crate) fn exit_code(report: &DoctorReport) -> u8 { .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) || matches!(report.configuration.plugin_resolution.status, Status::Fail) @@ -48,7 +47,11 @@ pub(super) fn report_has_warn(report: &DoctorReport) -> bool { .explicit .as_ref() .is_some_and(|layer| matches!(layer.status, Status::Warn)) - || matches!(report.configuration.workspace.status, Status::Warn) + || report + .configuration + .unsupported_project_files + .iter() + .any(|layer| matches!(layer.status, Status::Warn)) || matches!(report.configuration.global.status, Status::Warn) || matches!(report.configuration.system.status, Status::Warn) || matches!(report.configuration.plugin_resolution.status, Status::Warn) @@ -99,10 +102,6 @@ pub(super) fn format_human_configuration(out: &mut String, report: &DoctorReport if let Some(explicit) = &report.configuration.explicit { out.push_str(&format!(" Explicit {}\n", format_layer(explicit))); } - out.push_str(&format!( - " Workspace {}\n", - format_layer(&report.configuration.workspace) - )); out.push_str(&format!( " Global {}\n", format_layer(&report.configuration.global) @@ -111,6 +110,15 @@ pub(super) fn format_human_configuration(out: &mut String, report: &DoctorReport " System {}\n", format_layer(&report.configuration.system) )); + for (index, layer) in report + .configuration + .unsupported_project_files + .iter() + .enumerate() + { + let label = if index == 0 { "Unsupported" } else { "" }; + out.push_str(&format!(" {label:<11}{}\n", format_layer(layer))); + } out.push_str(&format!( " Upstream openai={} anthropic={}\n", report.configuration.upstream_auth.openai.as_str(), diff --git a/crates/cli/src/mcp_environment.rs b/crates/cli/src/mcp_environment.rs index 1625aa5ba..de200be95 100644 --- a/crates/cli/src/mcp_environment.rs +++ b/crates/cli/src/mcp_environment.rs @@ -82,7 +82,6 @@ const BLOCKED_MCP_ENV_VARS: &[&str] = &[ "NEMO_RELAY_BOOTSTRAP_FINGERPRINT", "NEMO_RELAY_BOOTSTRAP_STATE_DIR", "NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN", - "NEMO_RELAY_CONFIG_SCOPE", "NEMO_RELAY_FAIL_CLOSED", "NEMO_RELAY_GATEWAY_BIND", "NEMO_RELAY_HOST_SOCKET", diff --git a/crates/cli/src/plugins/config_io.rs b/crates/cli/src/plugins/config_io.rs index 67ec843ec..8c320349d 100644 --- a/crates/cli/src/plugins/config_io.rs +++ b/crates/cli/src/plugins/config_io.rs @@ -10,9 +10,7 @@ use nemo_relay::plugin::{ConfigPolicy, PluginConfig, validate_plugin_config}; use serde::Serialize; use serde_json::{Map, Value}; -use crate::configuration::{ - global_plugin_config_path, project_plugin_config_path, user_plugin_config_path, -}; +use crate::configuration::{global_plugin_config_path, user_plugin_config_path}; use crate::error::CliError; use crate::plugins::ConfigurationScope; use crate::server::register_and_validate_plugin_components; @@ -20,7 +18,6 @@ use crate::server::register_and_validate_plugin_components; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum TargetScope { User, - Project, Global, } @@ -179,9 +176,7 @@ impl PluginConfigDocument { TargetScope::Global => { crate::filesystem::atomic_write_system_readable(&self.path, rendered.as_bytes()) } - TargetScope::User | TargetScope::Project => { - crate::filesystem::atomic_write(&self.path, rendered.as_bytes()) - } + TargetScope::User => crate::filesystem::atomic_write(&self.path, rendered.as_bytes()), } .map_err(CliError::Config) } @@ -280,10 +275,9 @@ fn json_to_toml(value: Value) -> Result { pub(crate) fn target_scope(command: &ConfigurationScope) -> Result { match command { ConfigurationScope::Default | ConfigurationScope::User => Ok(TargetScope::User), - ConfigurationScope::Project => Ok(TargetScope::Project), ConfigurationScope::Global => Ok(TargetScope::Global), ConfigurationScope::Invalid => Err(CliError::Config( - "choose only one of --user, --project, or --global".into(), + "choose only one of --user or --global".into(), )), } } @@ -302,10 +296,6 @@ pub(crate) fn target_path(scope: TargetScope) -> Result { "cannot determine user config directory; set HOME or XDG_CONFIG_HOME".into(), ) }), - TargetScope::Project => { - let cwd = std::env::current_dir()?; - Ok(project_plugin_config_path(&cwd)) - } TargetScope::Global => Ok(global_plugin_config_path()), } } diff --git a/crates/cli/src/plugins/lifecycle/mod.rs b/crates/cli/src/plugins/lifecycle/mod.rs index 734754c74..56d3d2096 100644 --- a/crates/cli/src/plugins/lifecycle/mod.rs +++ b/crates/cli/src/plugins/lifecycle/mod.rs @@ -124,7 +124,7 @@ fn add_with_environment_runner( 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`; the same applies to --plugin-config-path" + "--config cannot be combined with --user or --global for `plugins add`; the same applies to --plugin-config-path" .into(), )); } diff --git a/crates/cli/src/plugins/lifecycle/state.rs b/crates/cli/src/plugins/lifecycle/state.rs index bc7b7b912..f927f65b3 100644 --- a/crates/cli/src/plugins/lifecycle/state.rs +++ b/crates/cli/src/plugins/lifecycle/state.rs @@ -10,9 +10,7 @@ use nemo_relay::plugin::dynamic::{DynamicPluginRecord, DynamicPluginRegistry}; use serde::{Deserialize, Serialize}; use strum::{Display, IntoStaticStr}; -use crate::configuration::{ - global_plugin_config_path, project_plugin_config_path, user_plugin_config_path, -}; +use crate::configuration::{global_plugin_config_path, user_plugin_config_path}; use crate::error::CliError; use super::super::config_io::TargetScope; @@ -26,7 +24,6 @@ const DYNAMIC_PLUGIN_STATE_SCHEMA_VERSION: u32 = 1; #[strum(serialize_all = "snake_case")] pub(super) enum RegistryScope { User, - Project, Global, Explicit, } @@ -142,16 +139,11 @@ pub(super) fn scoped_paths_for_add( "cannot determine user config directory; set HOME or XDG_CONFIG_HOME".into(), ) })?, - TargetScope::Project => { - let cwd = std::env::current_dir()?; - project_plugin_config_path(&cwd) - } TargetScope::Global => global_plugin_config_path(), }; let state_path = sibling_state_path(&plugins_toml_path); let scope = match scope { TargetScope::User => RegistryScope::User, - TargetScope::Project => RegistryScope::Project, TargetScope::Global => RegistryScope::Global, }; Ok((plugins_toml_path, state_path, scope)) @@ -239,15 +231,6 @@ fn scoped_registry_layouts( )); } - 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, - plugins_toml_path.clone(), - sibling_state_path(&plugins_toml_path), - )); - } let plugins_toml_path = global_plugin_config_path(); layouts.push(( RegistryScope::Global, diff --git a/crates/cli/src/plugins/mod.rs b/crates/cli/src/plugins/mod.rs index ae856c28b..9853b5187 100644 --- a/crates/cli/src/plugins/mod.rs +++ b/crates/cli/src/plugins/mod.rs @@ -151,7 +151,7 @@ fn save_document( .any(DynamicPluginEditorState::has_persisted_secrets) { return Err(CliError::Config( - "global plugin configuration cannot contain schema-declared secret values; use a user or project plugin config".into(), + "global plugin configuration cannot contain schema-declared secret values; use a user plugin config".into(), )); } for plugin in dynamic_plugins { diff --git a/crates/cli/src/plugins/pricing.rs b/crates/cli/src/plugins/pricing.rs index 5112ee00d..45e2651a1 100644 --- a/crates/cli/src/plugins/pricing.rs +++ b/crates/cli/src/plugins/pricing.rs @@ -231,10 +231,9 @@ fn resolve_pricing( fn target_pricing_scope(scope: &ConfigurationScope) -> Result { match scope { ConfigurationScope::Default | ConfigurationScope::User => Ok(TargetScope::User), - ConfigurationScope::Project => Ok(TargetScope::Project), ConfigurationScope::Global => Ok(TargetScope::Global), ConfigurationScope::Invalid => Err(CliError::Config( - "choose only one of --user, --project, or --global".into(), + "choose only one of --user or --global".into(), )), } } diff --git a/crates/cli/src/plugins/types.rs b/crates/cli/src/plugins/types.rs index 84fd091d1..a774ff40f 100644 --- a/crates/cli/src/plugins/types.rs +++ b/crates/cli/src/plugins/types.rs @@ -9,7 +9,6 @@ pub(crate) enum ConfigurationScope { #[default] Default, User, - Project, Global, /// More than one mutually exclusive command scope was supplied. Invalid, diff --git a/crates/cli/src/process/launcher.rs b/crates/cli/src/process/launcher.rs index 9d58441b8..587ef412a 100644 --- a/crates/cli/src/process/launcher.rs +++ b/crates/cli/src/process/launcher.rs @@ -484,7 +484,7 @@ impl PreparedAgentLaunch { // Builds the launch plan and applies only the preparation needed by the selected agent. // Dry-run preparation records equivalent notes and argv/env changes without writing temporary - // hook files or patching user/project configuration. + // hook files or patching user configuration. fn build( agent: CodingAgent, argv: Vec, diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index 7a59b9ced..356d85586 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -313,7 +313,7 @@ fn cli_jsonl_logging_records_successful_command_lifecycle_without_leaking_secret } #[test] -fn cli_layered_logging_path_aliases_initialize_one_sink() { +fn cli_explicit_logging_ignores_project_path_alias() { let temp = tempfile::tempdir().unwrap(); let cwd = temp.path().join("workspace"); let project_config = cwd.join(".nemo-relay/config.toml"); @@ -1946,7 +1946,7 @@ fn cli_doctor_json_emits_versioned_report() { assert!(output.status.success()); let parsed: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); - assert_eq!(parsed["schema_version"], 1); + assert_eq!(parsed["schema_version"], 2); assert!(parsed["environment"].is_object()); assert!(parsed["configuration"].is_object()); assert!(parsed["agents"].is_array()); @@ -1989,7 +1989,7 @@ fn cli_plugins_validate_rejects_malformed_python_entrypoints_by_path_and_id() { let temp = tempfile::tempdir().unwrap(); let cwd = temp.path().join("workdir"); let plugin_dir = cwd.join("plugins").join("acme"); - let config_dir = cwd.join(".nemo-relay"); + let config_dir = temp.path().join("xdg/nemo-relay"); let plugin_id = "acme.invalid-python-entrypoint"; std::fs::create_dir_all(&config_dir).unwrap(); write_python_dynamic_plugin_manifest(&plugin_dir, plugin_id); @@ -2097,7 +2097,7 @@ fn cli_plugins_list_all_json_includes_tombstoned_records() { .current_dir(&cwd) .env("XDG_CONFIG_HOME", temp.path().join("xdg")) .env("HOME", temp.path()) - .args(["plugins", "add", "--project"]) + .args(["plugins", "add", "--user"]) .arg(&plugin_dir) .output() .unwrap(); @@ -2304,7 +2304,7 @@ fn cli_plugins_list_json_reports_blocked_policy_for_installed_plugin() { let temp = tempfile::tempdir().unwrap(); let cwd = temp.path().join("workdir"); let plugin_dir = cwd.join("plugins").join("acme"); - let config_dir = cwd.join(".nemo-relay"); + let config_dir = temp.path().join("xdg/nemo-relay"); std::fs::create_dir_all(&cwd).unwrap(); std::fs::create_dir_all(&config_dir).unwrap(); write_dynamic_plugin_manifest(&plugin_dir, "acme.cli-blocked-list"); @@ -2313,7 +2313,7 @@ fn cli_plugins_list_json_reports_blocked_policy_for_installed_plugin() { .current_dir(&cwd) .env("XDG_CONFIG_HOME", temp.path().join("xdg")) .env("HOME", temp.path()) - .args(["plugins", "add", "--project"]) + .args(["plugins", "add", "--user"]) .arg(&plugin_dir) .output() .unwrap(); @@ -2379,7 +2379,7 @@ fn cli_plugins_list_json_reports_invalid_trust_in_validation_state() { let temp = tempfile::tempdir().unwrap(); let cwd = temp.path().join("workdir"); let plugin_dir = cwd.join("plugins").join("acme"); - let config_dir = cwd.join(".nemo-relay"); + let config_dir = temp.path().join("xdg/nemo-relay"); std::fs::create_dir_all(&cwd).unwrap(); std::fs::create_dir_all(&config_dir).unwrap(); write_dynamic_plugin_manifest(&plugin_dir, "acme.cli-trust-list"); @@ -2388,7 +2388,7 @@ fn cli_plugins_list_json_reports_invalid_trust_in_validation_state() { .current_dir(&cwd) .env("XDG_CONFIG_HOME", temp.path().join("xdg")) .env("HOME", temp.path()) - .args(["plugins", "add", "--project"]) + .args(["plugins", "add", "--user"]) .arg(&plugin_dir) .output() .unwrap(); @@ -2440,7 +2440,7 @@ fn cli_plugins_validate_json_reports_blocked_policy_for_installed_id_target() { let temp = tempfile::tempdir().unwrap(); let cwd = temp.path().join("workdir"); let plugin_dir = cwd.join("plugins").join("acme"); - let config_dir = cwd.join(".nemo-relay"); + let config_dir = temp.path().join("xdg/nemo-relay"); std::fs::create_dir_all(&cwd).unwrap(); std::fs::create_dir_all(&config_dir).unwrap(); write_dynamic_plugin_manifest(&plugin_dir, "acme.cli-blocked-id"); @@ -2449,7 +2449,7 @@ fn cli_plugins_validate_json_reports_blocked_policy_for_installed_id_target() { .current_dir(&cwd) .env("XDG_CONFIG_HOME", temp.path().join("xdg")) .env("HOME", temp.path()) - .args(["plugins", "add", "--project"]) + .args(["plugins", "add", "--user"]) .arg(&plugin_dir) .output() .unwrap(); @@ -2516,7 +2516,7 @@ fn cli_plugins_inspect_json_emits_installed_plugin_details() { .current_dir(&cwd) .env("XDG_CONFIG_HOME", temp.path().join("xdg")) .env("HOME", temp.path()) - .args(["plugins", "add", "--project"]) + .args(["plugins", "add", "--user"]) .arg(&plugin_dir) .output() .unwrap(); @@ -2546,7 +2546,7 @@ fn cli_plugins_inspect_json_emits_installed_plugin_details() { assert_eq!(parsed["target"], "acme.inspect-json"); assert_eq!(parsed["data"]["id"], "acme.inspect-json"); assert_eq!(parsed["data"]["kind"], "worker"); - assert_eq!(parsed["data"]["scope"], "project"); + assert_eq!(parsed["data"]["scope"], "user"); assert_eq!(parsed["data"]["policy_state"], "valid"); assert_eq!(parsed["data"]["startup_class"], "optional"); assert_eq!(parsed["data"]["attestation_mode"], "integrity_only"); @@ -2559,7 +2559,7 @@ fn cli_plugins_inspect_json_reports_blocked_policy_for_installed_plugin() { let temp = tempfile::tempdir().unwrap(); let cwd = temp.path().join("workdir"); let plugin_dir = cwd.join("plugins").join("acme"); - let config_dir = cwd.join(".nemo-relay"); + let config_dir = temp.path().join("xdg/nemo-relay"); std::fs::create_dir_all(&cwd).unwrap(); std::fs::create_dir_all(&config_dir).unwrap(); write_dynamic_plugin_manifest(&plugin_dir, "acme.inspect-blocked"); @@ -2568,7 +2568,7 @@ fn cli_plugins_inspect_json_reports_blocked_policy_for_installed_plugin() { .current_dir(&cwd) .env("XDG_CONFIG_HOME", temp.path().join("xdg")) .env("HOME", temp.path()) - .args(["plugins", "add", "--project"]) + .args(["plugins", "add", "--user"]) .arg(&plugin_dir) .output() .unwrap(); @@ -2640,7 +2640,7 @@ fn cli_plugins_mutation_commands_emit_terse_confirmation_output() { .current_dir(&cwd) .env("XDG_CONFIG_HOME", temp.path().join("xdg")) .env("HOME", temp.path()) - .args(["plugins", "add", "--project"]) + .args(["plugins", "add", "--user"]) .arg(&plugin_dir) .output() .unwrap(); @@ -2709,7 +2709,7 @@ fn cli_plugins_mutation_commands_emit_terse_confirmation_output() { .current_dir(&cwd) .env("XDG_CONFIG_HOME", temp.path().join("xdg")) .env("HOME", temp.path()) - .args(["plugins", "add", "--project"]) + .args(["plugins", "add", "--user"]) .arg(&plugin_dir) .output() .unwrap(); @@ -2736,7 +2736,7 @@ fn cli_plugins_enable_tombstoned_plugin_returns_refused_exit_code() { .current_dir(&cwd) .env("XDG_CONFIG_HOME", temp.path().join("xdg")) .env("HOME", temp.path()) - .args(["plugins", "add", "--project"]) + .args(["plugins", "add", "--user"]) .arg(&plugin_dir) .output() .unwrap(); @@ -2854,19 +2854,19 @@ fn cli_model_pricing_validate_rejects_invalid_catalog() { } #[test] -fn cli_model_pricing_init_creates_project_pricing_component() { +fn cli_model_pricing_init_creates_user_pricing_component() { let temp = tempfile::tempdir().unwrap(); - let project = temp.path().join("project"); - std::fs::create_dir_all(&project).unwrap(); + let xdg = temp.path().join("xdg"); let output = Command::new(gateway_bin()) - .current_dir(&project) - .args(["model-pricing", "init", "--project"]) + .env("XDG_CONFIG_HOME", &xdg) + .env("HOME", temp.path()) + .args(["model-pricing", "init", "--user"]) .output() .unwrap(); assert!(output.status.success()); - let path = project.join(".nemo-relay/plugins.toml"); + let path = xdg.join("nemo-relay/plugins.toml"); let rendered = std::fs::read_to_string(path).unwrap(); assert!(rendered.contains("kind = \"pricing\"")); assert!(!rendered.contains("include_bundled")); @@ -3225,8 +3225,9 @@ fn cli_bare_invocation_runs_doctor_when_config_exists() { let xdg = temp.path().join("xdg"); std::fs::create_dir_all(&xdg).unwrap(); let cwd = temp.path().join("workdir"); - std::fs::create_dir_all(cwd.join(".nemo-relay")).unwrap(); - std::fs::write(cwd.join(".nemo-relay/config.toml"), "[upstream]\n").unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + std::fs::create_dir_all(xdg.join("nemo-relay")).unwrap(); + std::fs::write(xdg.join("nemo-relay/config.toml"), "[upstream]\n").unwrap(); let output = Command::new(gateway_bin()) .current_dir(&cwd) @@ -3252,9 +3253,10 @@ fn cli_bare_invocation_reports_invalid_config_resolution() { let xdg = temp.path().join("xdg"); std::fs::create_dir_all(&xdg).unwrap(); let cwd = temp.path().join("workdir"); - std::fs::create_dir_all(cwd.join(".nemo-relay")).unwrap(); - std::fs::write(cwd.join(".nemo-relay/config.toml"), "[upstream]\n").unwrap(); - std::fs::write(cwd.join(".nemo-relay/plugins.toml"), "components = [\n").unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + std::fs::create_dir_all(xdg.join("nemo-relay")).unwrap(); + std::fs::write(xdg.join("nemo-relay/config.toml"), "[upstream]\n").unwrap(); + std::fs::write(xdg.join("nemo-relay/plugins.toml"), "components = [\n").unwrap(); let output = Command::new(gateway_bin()) .current_dir(&cwd) @@ -3453,7 +3455,7 @@ fn cli_doctor_accepts_a_valid_explicit_logging_config() { } #[test] -fn cli_doctor_reports_the_nearest_ancestor_workspace_config() { +fn cli_doctor_reports_unsupported_ancestor_project_config() { let temp = tempfile::tempdir().unwrap(); let project = temp.path().join("workspace"); let nested = project.join("services/relay"); @@ -3475,8 +3477,19 @@ fn cli_doctor_reports_the_nearest_ancestor_workspace_config() { .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!(report["configuration"].get("workspace").is_none()); + let unsupported = report["configuration"]["unsupported_project_files"] + .as_array() + .unwrap(); + let warning = unsupported + .iter() + .find(|entry| { + entry["path"] + .as_str() + .is_some_and(|path| path.ends_with("config.toml")) + }) + .unwrap(); + let reported_path = PathBuf::from(warning["path"].as_str().unwrap()); assert!( reported_path.exists(), "doctor reported undiscovered workspace path {}", @@ -3486,8 +3499,8 @@ fn cli_doctor_reports_the_nearest_ancestor_workspace_config() { reported_path.canonicalize().unwrap(), project_config.canonicalize().unwrap() ); - assert_eq!(workspace["status"], "pass"); - assert_eq!(workspace["active"], true); + assert_eq!(warning["status"], "warn"); + assert_eq!(warning["active"], false); } #[test] @@ -3495,11 +3508,11 @@ fn cli_doctor_json_reports_effective_upstream_auth_presence() { let temp = tempfile::tempdir().unwrap(); let project = temp.path().join("workspace"); let nested = project.join("nested"); - let project_config = project.join(".nemo-relay/config.toml"); - std::fs::create_dir_all(project_config.parent().unwrap()).unwrap(); + let user_config = temp.path().join("xdg/nemo-relay/config.toml"); + std::fs::create_dir_all(user_config.parent().unwrap()).unwrap(); std::fs::create_dir_all(&nested).unwrap(); std::fs::write( - &project_config, + &user_config, r#" [upstream] openai_base_url = "http://project-openai" @@ -3565,7 +3578,7 @@ fn cli_doctor_json_reports_unknown_upstream_auth_when_resolution_fails() { } #[test] -fn cli_doctor_explicit_config_reports_invalid_layered_workspace_config() { +fn cli_doctor_explicit_config_warns_about_ignored_malformed_project_config() { let temp = tempfile::tempdir().unwrap(); let xdg = temp.path().join("xdg"); let cwd = temp.path().join("workdir"); @@ -3585,7 +3598,7 @@ fn cli_doctor_explicit_config_reports_invalid_layered_workspace_config() { .output() .unwrap(); - assert!(!output.status.success()); + assert!(output.status.success()); let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); assert_eq!( report["configuration"]["explicit"]["path"], @@ -3593,18 +3606,24 @@ fn cli_doctor_explicit_config_reports_invalid_layered_workspace_config() { ); assert_eq!(report["configuration"]["explicit"]["status"], "pass"); assert_eq!(report["configuration"]["explicit"]["active"], true); - assert_eq!( - PathBuf::from( - report["configuration"]["workspace"]["path"] + let warning = report["configuration"]["unsupported_project_files"] + .as_array() + .unwrap() + .iter() + .find(|entry| { + entry["path"] .as_str() - .unwrap() - ) - .canonicalize() - .unwrap(), + .is_some_and(|path| path.ends_with("config.toml")) + }) + .unwrap(); + assert_eq!( + PathBuf::from(warning["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!(warning["status"], "warn"); + assert_eq!(warning["active"], false); assert_eq!(report["configuration"]["global"]["status"], "info"); assert_eq!(report["configuration"]["global"]["active"], false); assert!( @@ -3621,12 +3640,12 @@ fn cli_doctor_explicit_config_reports_invalid_layered_workspace_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("Workspace")); - assert!(stdout.contains("invalid TOML")); + assert!(stdout.contains("Unsupported")); + assert!(stdout.contains("ignored")); assert!(stdout.contains("replaced by explicit --config")); } @@ -3674,28 +3693,17 @@ fn cli_doctor_reports_invalid_explicit_config_and_layered_plugins() { "version = 1\ncomponents = []\n", ) .unwrap(); - let invalid_project_plugins = Command::new(gateway_bin()) + let ignored_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)) - ); + assert!(ignored_project_plugins.status.success()); + let stdout = String::from_utf8_lossy(&ignored_project_plugins.stdout); + assert!(stdout.contains("Unsupported")); + assert!(stdout.contains("ignored")); std::fs::write(&project_plugins, "version = 1\ncomponents = []\n").unwrap(); let valid_config = Command::new(gateway_bin()) @@ -3710,30 +3718,29 @@ fn cli_doctor_reports_invalid_explicit_config_and_layered_plugins() { 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() - ); - } + let path = config_dir.join("plugins.toml"); + 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] @@ -3833,7 +3840,7 @@ fn cli_run_dry_run_rejects_missing_explicit_config() { } #[test] -fn cli_run_dry_run_uses_project_user_and_env_config_layers() { +fn cli_run_dry_run_ignores_project_and_uses_user_and_env_config() { let temp = tempfile::tempdir().unwrap(); let project = temp.path().join("project"); let nested = project.join("nested"); @@ -3917,10 +3924,11 @@ fn cli_run_dry_run_reports_effective_upstream_auth_presence() { let temp = tempfile::tempdir().unwrap(); let project = temp.path().join("project"); let nested = project.join("nested"); - std::fs::create_dir_all(project.join(".nemo-relay")).unwrap(); + let user_config = temp.path().join("xdg/nemo-relay/config.toml"); + std::fs::create_dir_all(user_config.parent().unwrap()).unwrap(); std::fs::create_dir_all(&nested).unwrap(); std::fs::write( - project.join(".nemo-relay/config.toml"), + user_config, r#" [upstream] openai_base_url = "http://project-openai" diff --git a/crates/cli/tests/coverage/commands/configure_editor_tests.rs b/crates/cli/tests/coverage/commands/configure_editor_tests.rs index 7feeeadbb..5b5dfd30f 100644 --- a/crates/cli/tests/coverage/commands/configure_editor_tests.rs +++ b/crates/cli/tests/coverage/commands/configure_editor_tests.rs @@ -155,21 +155,8 @@ fn malformed_sections_and_missing_sinks_report_errors() { fn target_selection_and_file_loading_behave_as_expected() { let user = ConfigEditCommand::default(); assert_eq!(TargetScope::from(&user), TargetScope::User); - let project = ConfigEditCommand { - project: true, - ..ConfigEditCommand::default() - }; - assert_eq!(TargetScope::from(&project), TargetScope::Project); let root = tempfile::tempdir().unwrap(); - let project = root.path().join("project"); - let nested = project.join("nested"); - std::fs::create_dir_all(&nested).unwrap(); - let config = project.join(".nemo-relay/config.toml"); - std::fs::create_dir_all(config.parent().unwrap()).unwrap(); - std::fs::write(&config, "").unwrap(); - assert_eq!(project_config_path(&nested), config); - let invalid = root.path().join("invalid.toml"); std::fs::write(&invalid, "[gateway\n").unwrap(); let error = match ConfigDocument::read(invalid.clone()) { @@ -205,22 +192,6 @@ fn config_editor_treats_explicit_config_as_the_user_target() { .join("config.toml") ); - let project_root = tempfile::tempdir().unwrap(); - let _cwd = crate::test_support::CwdTestScope::enter(project_root.path()); - let project = ConfigEditCommand { - project: true, - ..ConfigEditCommand::default() - }; - let (scope, path) = - resolve_edit_target(&project, Some(PathBuf::from("/ignored/config.toml"))).unwrap(); - assert_eq!(scope, TargetScope::Project); - assert_eq!( - path, - std::env::current_dir() - .unwrap() - .join(".nemo-relay/config.toml") - ); - let global = ConfigEditCommand { global: true, ..ConfigEditCommand::default() @@ -228,17 +199,22 @@ fn config_editor_treats_explicit_config_as_the_user_target() { let (scope, path) = resolve_edit_target(&global, Some(PathBuf::from("/ignored/config.toml"))).unwrap(); assert_eq!(scope, TargetScope::Global); - assert_eq!(path, PathBuf::from("/etc/nemo-relay/config.toml")); + assert_eq!( + path, + crate::configuration::system_config_dir().join("config.toml") + ); } #[test] fn documents_are_written_atomically_with_scope_appropriate_permissions() { let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("nested/config.toml"); + let path = directory.path().join("user/nested/config.toml"); + assert!(!path.parent().unwrap().exists()); let document = ConfigDocument::read(path.clone()).unwrap(); assert!(!path.exists()); document.write(TargetScope::User).unwrap(); assert!(path.exists()); + assert!(path.parent().unwrap().is_dir()); let original = std::fs::read_to_string(&path).unwrap(); crate::filesystem::fail_next_atomic_write(&path); @@ -255,11 +231,14 @@ fn documents_are_written_atomically_with_scope_appropriate_permissions() { ); } - let global_path = directory.path().join("global/config.toml"); + let global_path = directory.path().join("system/nested/config.toml"); + assert!(!global_path.parent().unwrap().exists()); ConfigDocument::read(global_path.clone()) .unwrap() .write(TargetScope::Global) .unwrap(); + assert!(global_path.is_file()); + assert!(global_path.parent().unwrap().is_dir()); #[cfg(unix)] { @@ -367,15 +346,3 @@ fn sink_accessors_report_invalid_and_incomplete_entries() { Some("relay.log") ); } - -#[test] -fn project_path_defaults_to_start_when_no_ancestor_config_exists() { - let root = tempfile::tempdir().unwrap(); - let nested = root.path().join("a/b"); - std::fs::create_dir_all(&nested).unwrap(); - - assert_eq!( - project_config_path_with_boundary(&nested, Some(root.path())), - nested.join(".nemo-relay/config.toml") - ); -} diff --git a/crates/cli/tests/coverage/commands/main_tests.rs b/crates/cli/tests/coverage/commands/main_tests.rs index 2967a50ad..9aa1492a3 100644 --- a/crates/cli/tests/coverage/commands/main_tests.rs +++ b/crates/cli/tests/coverage/commands/main_tests.rs @@ -54,15 +54,6 @@ fn plugins_edit_treats_explicit_target_as_the_user_layer() { Some(PathBuf::from("/override/plugins.toml")) ); - let project = PluginsEditCommand { - scope: PluginsScopeArgs { - project: true, - ..PluginsScopeArgs::default() - }, - }; - let request = plugins::edit_request(project, &server); - assert_eq!(request.explicit_path, None); - let global = PluginsEditCommand { scope: PluginsScopeArgs { global: true, @@ -217,7 +208,7 @@ fn cli_logging_options_override_environment_source() { ]) .unwrap(); - let config = cli.logging.resolve(None, false).unwrap(); + let config = cli.logging.resolve(None).unwrap(); assert_eq!(config.level, nemo_relay::logging::LogLevel::Trace); assert_eq!(config.stderr_format, nemo_relay::logging::LogFormat::Jsonl); @@ -251,10 +242,7 @@ stderr_format = "jsonl" ]) .unwrap(); - let config = cli - .logging - .resolve(cli.server.config.as_deref(), false) - .unwrap(); + let config = cli.logging.resolve(cli.server.config.as_deref()).unwrap(); assert_eq!(config.level, nemo_relay::logging::LogLevel::Warn); assert_eq!(config.stderr_format, nemo_relay::logging::LogFormat::Jsonl); @@ -283,7 +271,7 @@ fn command_logging_policy_excludes_only_configuration_editors() { let config = Cli::try_parse_from(["nemo-relay", "config"]).unwrap(); assert!(config.command.as_ref().unwrap().skips_logging()); - let plugins_edit = Cli::try_parse_from(["nemo-relay", "plugins", "edit", "--project"]).unwrap(); + let plugins_edit = Cli::try_parse_from(["nemo-relay", "plugins", "edit", "--user"]).unwrap(); assert!(plugins_edit.command.as_ref().unwrap().skips_logging()); let plugins_list = Cli::try_parse_from(["nemo-relay", "plugins", "list"]).unwrap(); @@ -309,7 +297,6 @@ fn cli_parses_config_edit_scopes_and_rejects_conflicts() { panic!("expected config edit command"); }; assert!(!command.user); - assert!(!command.project); assert!(!command.global); let explicit = Cli::try_parse_from([ @@ -325,14 +312,10 @@ fn cli_parses_config_edit_scopes_and_rejects_conflicts() { Some(PathBuf::from("/managed/config.toml")) ); - let project = Cli::try_parse_from(["nemo-relay", "config", "edit", "--project"]).unwrap(); - let Command::Config(command) = project.command.unwrap() else { - panic!("expected config command"); - }; - let Some(ConfigSubcommand::Edit(command)) = command.command else { - panic!("expected config edit command"); - }; - assert!(command.project); + assert!(Cli::try_parse_from(["nemo-relay", "config", "edit", "--project"]).is_err()); + assert!(Cli::try_parse_from(["nemo-relay", "plugins", "edit", "--project"]).is_err()); + assert!(Cli::try_parse_from(["nemo-relay", "model-pricing", "init", "--project"]).is_err()); + assert!(Cli::try_parse_from(["nemo-relay", "config", "--reset", "--scope", "user"]).is_err()); let error = Cli::try_parse_from(["nemo-relay", "config", "edit", "--user", "--global"]).unwrap_err(); diff --git a/crates/cli/tests/coverage/commands/model_pricing_tests.rs b/crates/cli/tests/coverage/commands/model_pricing_tests.rs index 70174d5e0..d07568566 100644 --- a/crates/cli/tests/coverage/commands/model_pricing_tests.rs +++ b/crates/cli/tests/coverage/commands/model_pricing_tests.rs @@ -38,8 +38,8 @@ fn pricing_helpers_cover_scopes_components_sources_and_usage() { TargetScope::User ); assert_eq!( - target_pricing_scope(&ConfigurationScope::Project).unwrap(), - TargetScope::Project + target_pricing_scope(&ConfigurationScope::User).unwrap(), + TargetScope::User ); assert_eq!( target_pricing_scope(&ConfigurationScope::Global).unwrap(), diff --git a/crates/cli/tests/coverage/shared/bootstrap_tests.rs b/crates/cli/tests/coverage/shared/bootstrap_tests.rs index 15ab05263..c5fed8d32 100644 --- a/crates/cli/tests/coverage/shared/bootstrap_tests.rs +++ b/crates/cli/tests/coverage/shared/bootstrap_tests.rs @@ -183,7 +183,6 @@ fn persistent_gateway_resolution_keeps_server_configuration_in_one_spec() { crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES ); assert!(resolved.gateway.bootstrap_fingerprint.is_some()); - assert!(resolved.gateway.user_config_scope); assert!( resolved .gateway diff --git a/crates/cli/tests/coverage/shared/config_tests.rs b/crates/cli/tests/coverage/shared/config_tests.rs index 333650646..d6b0e70d4 100644 --- a/crates/cli/tests/coverage/shared/config_tests.rs +++ b/crates/cli/tests/coverage/shared/config_tests.rs @@ -48,7 +48,7 @@ fn explicit_plugin_config_path_resolves_runtime_target() { } #[test] -fn config_paths_layer_explicit_or_user_then_project_then_system() { +fn config_paths_layer_explicit_or_user_then_system_and_ignore_project() { let temp = tempfile::tempdir().unwrap(); let project = temp.path().join("project"); let child = project.join("nested"); @@ -59,41 +59,25 @@ fn config_paths_layer_explicit_or_user_then_project_then_system() { 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"); + let system_config = system_config_dir().join("config.toml"); assert_eq!( - config_paths_scoped(None, false), + config_paths(None), 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" + config_paths(Some(&explicit_config)), + vec![explicit_config, system_config] ); } #[test] -fn plugin_config_paths_layer_explicit_or_user_then_project_then_system() { +fn plugin_config_paths_layer_explicit_or_user_then_system_and_ignore_project() { let temp = tempfile::tempdir().unwrap(); let project = temp.path().join("project"); let child = project.join("nested"); @@ -104,19 +88,12 @@ fn plugin_config_paths_layer_explicit_or_user_then_project_then_system() { 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"); + let system_plugins = system_config_dir().join("plugins.toml"); assert_eq!( - plugin_config_paths_scoped(None, None, false), + plugin_config_paths(None, None), vec![ xdg.join("nemo-relay").join("plugins.toml"), - discovered_project_plugins.clone(), system_plugins.clone(), ] ); @@ -124,27 +101,14 @@ fn plugin_config_paths_layer_explicit_or_user_then_project_then_system() { 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(), - ] + plugin_config_paths(Some(&explicit_config), None), + vec![explicit_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" + plugin_config_paths(Some(&explicit_config), Some(&override_plugins)), + vec![override_plugins, system_plugins] ); } @@ -153,7 +117,6 @@ struct PluginConfigDiscoveryScope { _guard: MutexGuard<'static, ()>, previous_cwd: PathBuf, previous_xdg_config_home: Option, - previous_config_scope: Option, previous_openai_api_key: Option, previous_openai_base_url: Option, previous_openai_auth_header: Option, @@ -171,7 +134,6 @@ impl PluginConfigDiscoveryScope { .unwrap_or_else(|error| error.into_inner()); let previous_cwd = std::env::current_dir().unwrap(); let previous_xdg_config_home = std::env::var_os("XDG_CONFIG_HOME"); - let previous_config_scope = std::env::var_os("NEMO_RELAY_CONFIG_SCOPE"); let previous_openai_api_key = std::env::var_os("OPENAI_API_KEY"); let previous_openai_base_url = std::env::var_os("NEMO_RELAY_OPENAI_BASE_URL"); let previous_openai_auth_header = std::env::var_os("NEMO_RELAY_OPENAI_AUTH_HEADER"); @@ -181,7 +143,6 @@ impl PluginConfigDiscoveryScope { let previous_plugin_idle_timeout = std::env::var_os(PLUGIN_IDLE_TIMEOUT_ENV); unsafe { std::env::set_var("XDG_CONFIG_HOME", xdg_config_home); - std::env::remove_var("NEMO_RELAY_CONFIG_SCOPE"); std::env::remove_var("OPENAI_API_KEY"); std::env::remove_var("NEMO_RELAY_OPENAI_BASE_URL"); std::env::remove_var("NEMO_RELAY_OPENAI_AUTH_HEADER"); @@ -196,7 +157,6 @@ impl PluginConfigDiscoveryScope { _guard: guard, previous_cwd, previous_xdg_config_home, - previous_config_scope, previous_openai_api_key, previous_openai_base_url, previous_openai_auth_header, @@ -207,13 +167,6 @@ impl PluginConfigDiscoveryScope { } } - fn enable_user_scope(&self) { - // SAFETY: This scope holds the process-wide environment mutex. - unsafe { - std::env::set_var("NEMO_RELAY_CONFIG_SCOPE", "user"); - } - } - fn set_bootstrap_fingerprint(&self, fingerprint: &str) { // SAFETY: This scope holds the process-wide environment mutex. unsafe { @@ -246,10 +199,6 @@ impl Drop for PluginConfigDiscoveryScope { Some(value) => std::env::set_var("XDG_CONFIG_HOME", value), None => std::env::remove_var("XDG_CONFIG_HOME"), } - match self.previous_config_scope.take() { - Some(value) => std::env::set_var("NEMO_RELAY_CONFIG_SCOPE", value), - None => std::env::remove_var("NEMO_RELAY_CONFIG_SCOPE"), - } match self.previous_openai_api_key.take() { Some(value) => std::env::set_var("OPENAI_API_KEY", value), None => std::env::remove_var("OPENAI_API_KEY"), @@ -347,7 +296,7 @@ fn effective_plugin_toml_sources_reports_empty_and_sorted_contributors() { .map(|path| path.canonicalize().unwrap()) .collect::>(); actual.sort(); - let mut expected = [project_plugins, user_plugins] + let mut expected = [user_plugins] .iter() .map(|path| path.canonicalize().unwrap()) .collect::>(); @@ -356,7 +305,7 @@ fn effective_plugin_toml_sources_reports_empty_and_sorted_contributors() { } #[test] -fn effective_plugin_toml_sources_replace_user_with_explicit_and_include_project() { +fn effective_plugin_toml_sources_replace_user_with_explicit_and_ignore_project() { let temp = tempfile::tempdir().unwrap(); let project = temp.path().join("project"); let xdg = temp.path().join("xdg"); @@ -365,11 +314,6 @@ fn effective_plugin_toml_sources_replace_user_with_explicit_and_include_project( 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(); @@ -381,11 +325,9 @@ fn effective_plugin_toml_sources_replace_user_with_explicit_and_include_project( 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_without_system(Some(&explicit_config), None).unwrap(), - expected + vec![explicit_plugins] ); } @@ -724,7 +666,7 @@ anthropic_auth_header = "Basic anthropic-file" } #[test] -fn endpoint_overrides_clear_inherited_provider_auth_headers() { +fn ignored_project_endpoints_do_not_clear_user_provider_auth_headers() { let temp = tempfile::tempdir().unwrap(); let project = temp.path().join("project"); let nested = project.join("nested"); @@ -756,13 +698,16 @@ anthropic_auth_header = "Basic user-anthropic" let resolved = resolve_server_config(&GatewayOverrides::default()).unwrap(); - assert_eq!(resolved.gateway.openai_base_url, "http://project-openai"); - assert!(resolved.gateway.openai_auth_header.is_none()); + assert_eq!(resolved.gateway.openai_base_url, "http://user-openai"); assert_eq!( - resolved.gateway.anthropic_base_url, - "http://project-anthropic" + resolved.gateway.openai_auth_header.as_deref(), + Some("Bearer user-openai") + ); + assert_eq!(resolved.gateway.anthropic_base_url, "http://user-anthropic"); + assert_eq!( + resolved.gateway.anthropic_auth_header.as_deref(), + Some("Basic user-anthropic") ); - assert!(resolved.gateway.anthropic_auth_header.is_none()); } #[test] @@ -1153,7 +1098,7 @@ fn plugins_toml_path_resolution_tracks_config_scope() { plugin_config_paths(Some(&explicit), None), vec![ temp.path().join("plugins.toml"), - PathBuf::from("/etc/nemo-relay/plugins.toml"), + system_config_dir().join("plugins.toml"), ] ); @@ -1165,31 +1110,21 @@ fn plugins_toml_path_resolution_tracks_config_scope() { std::fs::write(&plugin_path, "version = 1").unwrap(); let user_config = temp.path().join("xdg/nemo-relay"); - assert_eq!(find_project_plugin_config(&nested), Some(plugin_path)); - assert_eq!( - project_plugin_config_path(&nested), - project.join(".nemo-relay/plugins.toml") - ); assert_eq!( - implicit_plugin_config_paths(Some(&nested), Some(user_config.clone())), + implicit_plugin_config_paths(Some(user_config.clone())), vec![ user_config.join("plugins.toml"), - project.join(".nemo-relay/plugins.toml"), - PathBuf::from("/etc/nemo-relay/plugins.toml"), + system_config_dir().join("plugins.toml"), ] ); - - std::fs::remove_file(project.join(".nemo-relay/plugins.toml")).unwrap(); - std::fs::write(project.join(".nemo-relay/config.toml"), "").unwrap(); - assert_eq!(find_project_plugin_config(&nested), None); - assert_eq!( - project_plugin_config_path(&nested), - project.join(".nemo-relay/plugins.toml") + assert!( + plugin_path.exists(), + "project file remains present but ignored" ); } #[test] -fn persistent_user_scope_excludes_project_gateway_and_plugin_layers() { +fn all_runtime_scopes_exclude_project_gateway_and_plugin_layers() { let temp = tempfile::tempdir().unwrap(); let project = temp.path().join("workspace"); let nested = project.join("nested"); @@ -1198,27 +1133,26 @@ fn persistent_user_scope_excludes_project_gateway_and_plugin_layers() { std::fs::create_dir_all(&nested).unwrap(); std::fs::write(project.join(".nemo-relay/config.toml"), "").unwrap(); std::fs::write(project.join(".nemo-relay/plugins.toml"), "version = 1\n").unwrap(); - let scope = PluginConfigDiscoveryScope::enter(&nested, &xdg); - scope.enable_user_scope(); + let _scope = PluginConfigDiscoveryScope::enter(&nested, &xdg); assert_eq!( config_paths(None), vec![ xdg.join("nemo-relay/config.toml"), - PathBuf::from("/etc/nemo-relay/config.toml"), + system_config_dir().join("config.toml"), ] ); assert_eq!( plugin_config_paths(None, None), vec![ xdg.join("nemo-relay/plugins.toml"), - PathBuf::from("/etc/nemo-relay/plugins.toml"), + system_config_dir().join("plugins.toml"), ] ); } #[test] -fn logging_resolution_respects_environment_user_scope() { +fn logging_resolution_ignores_project_config() { let temp = tempfile::tempdir().unwrap(); let project = temp.path().join("workspace"); let nested = project.join("nested"); @@ -1248,23 +1182,19 @@ level = "warn" "#, ) .unwrap(); - let scope = PluginConfigDiscoveryScope::enter(&nested, &xdg); + let _scope = PluginConfigDiscoveryScope::enter(&nested, &xdg); let has_project_sink = |config: &LoggingConfig| { config.sinks.iter().any( |sink| matches!(sink, LogSinkConfig::File(file) if file.path == project_sink.as_path()), ) }; - let normal = resolve_logging_config(None, false).unwrap(); - assert!(has_project_sink(&normal)); - - scope.enable_user_scope(); - let user_only = resolve_logging_config(None, false).unwrap(); - assert!(!has_project_sink(&user_only)); + let resolved = resolve_logging_config(None).unwrap(); + assert!(!has_project_sink(&resolved)); } #[test] -fn operational_logging_aggregates_sinks_from_all_config_layers() { +fn operational_logging_uses_explicit_config_and_ignores_project_layer() { let temp = tempfile::tempdir().unwrap(); let project = temp.path().join("workspace"); let nested = project.join("nested"); @@ -1295,7 +1225,7 @@ fn operational_logging_aggregates_sinks_from_all_config_layers() { .unwrap(); let _scope = PluginConfigDiscoveryScope::enter(&nested, &xdg); - let logging = resolve_logging_config(Some(&explicit_config), false).unwrap(); + let logging = resolve_logging_config(Some(&explicit_config)).unwrap(); let paths = logging .sinks .iter() @@ -1304,7 +1234,7 @@ fn operational_logging_aggregates_sinks_from_all_config_layers() { }) .collect::>(); - assert_eq!(paths, vec![project_sink.as_path(), explicit_sink.as_path()]); + assert_eq!(paths, vec![explicit_sink.as_path()]); } #[test] @@ -2767,7 +2697,7 @@ fn persistent_hook_identity_authenticates_python_marker_without_rehashing_enviro "persistent hook identity must trust only the authenticated environment marker" ); - let resolved = load_shared_config_scoped(None, None, true).unwrap(); + let resolved = load_shared_config(None, None).unwrap(); let active = active_dynamic_plugin_components(None, &resolved).unwrap(); assert_eq!(active.len(), 1); assert!(active[0].activation_snapshot.is_some()); @@ -2965,10 +2895,10 @@ fn bootstrap_hmac_key_rejects_corrupt_persistent_state() { } #[test] -fn persistent_server_resolution_rejects_project_specific_flags() { +fn persistent_server_resolution_rejects_explicit_config_flags() { let _cwd = crate::test_support::CwdTestScope::locked(); let args = GatewayOverrides { - config: Some(PathBuf::from("project-config.toml")), + config: Some(PathBuf::from("explicit-config.toml")), ..GatewayOverrides::default() }; diff --git a/crates/cli/tests/coverage/shared/doctor_tests.rs b/crates/cli/tests/coverage/shared/doctor_tests.rs index 3c3da1391..9c47eeea1 100644 --- a/crates/cli/tests/coverage/shared/doctor_tests.rs +++ b/crates/cli/tests/coverage/shared/doctor_tests.rs @@ -47,7 +47,7 @@ fn start_doctor_http_capture_server() -> (String, Arc>, std::threa fn empty_report() -> DoctorReport { DoctorReport { - schema_version: 1, + schema_version: 2, binary_version: "0.0.0-test", target_agent: None, environment: EnvironmentInfo { @@ -57,20 +57,15 @@ fn empty_report() -> DoctorReport { }, configuration: ConfigurationInfo { explicit: None, - workspace: ConfigLayer { - path: PathBuf::from("/x/.nemo-relay/config.toml"), - status: Status::Info, - active: false, - details: "not present".into(), - }, global: ConfigLayer { path: PathBuf::from("/x/.config/nemo-relay/config.toml"), status: Status::Info, active: false, details: "not present".into(), }, + unsupported_project_files: vec![], system: ConfigLayer { - path: PathBuf::from("/etc/nemo-relay/config.toml"), + path: crate::configuration::system_config_dir().join("config.toml"), status: Status::Info, active: false, details: "not present".into(), @@ -151,11 +146,19 @@ fn exit_code_passes_with_warn_only() { } #[test] -fn exit_code_fails_when_workspace_config_is_invalid() { +fn unsupported_project_config_warns_without_failing() { let mut report = empty_report(); - report.configuration.workspace.status = Status::Fail; - report.configuration.workspace.details = "invalid TOML".into(); - assert_eq!(exit_code(&report), 1); + report + .configuration + .unsupported_project_files + .push(ConfigLayer { + path: PathBuf::from("/x/.nemo-relay/config.toml"), + status: Status::Warn, + active: false, + details: "unsupported project configuration; ignored by Relay".into(), + }); + assert_eq!(exit_code(&report), 0); + assert!(report_has_warn(&report)); } #[test] @@ -210,7 +213,7 @@ fn exit_code_fails_when_an_installed_host_plugin_is_unready() { assert!(rendered.contains("Persistent integrations")); assert!(rendered.contains("repair: nemo-relay install codex --force")); let json: serde_json::Value = serde_json::from_str(&format_json(&report).unwrap()).unwrap(); - assert_eq!(json["schema_version"], 1); + assert_eq!(json["schema_version"], 2); assert_eq!(json["host_plugins"][0]["checks"][0]["ok"], false); assert_eq!( json["host_plugins"][0]["remediation"], @@ -370,13 +373,7 @@ async fn agents_report_surfaces_merged_config_resolution_errors() { let config = config_home.join("nemo-relay").join("config.toml"); std::fs::create_dir_all(config.parent().unwrap()).unwrap(); std::fs::write(&config, "[upstream\n").unwrap(); - let _env = EnvScope::set(&[ - ("XDG_CONFIG_HOME", Some(config_home.as_os_str())), - ( - "NEMO_RELAY_CONFIG_SCOPE", - Some(std::ffi::OsStr::new("user")), - ), - ]); + let _env = EnvScope::set(&[("XDG_CONFIG_HOME", Some(config_home.as_os_str()))]); let error = agents_report().await.unwrap_err().to_string(); @@ -408,7 +405,7 @@ fn format_json_is_stable_and_versioned() { let json = format_json(&report).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); // schema_version pins the wire format. Bump only on breaking renames/removals. - assert_eq!(parsed["schema_version"], 1); + assert_eq!(parsed["schema_version"], 2); assert!(parsed["target_agent"].is_null()); assert!(parsed["environment"]["os"].is_string()); assert!(parsed["agents"].is_array()); @@ -650,8 +647,16 @@ fn collect_configuration_uses_xdg_global_path_and_renders_resolution_branches() }, ); - assert_eq!(configuration.workspace.status, Status::Pass); - assert!(configuration.workspace.active); + assert_eq!(configuration.unsupported_project_files.len(), 1); + assert_eq!( + configuration.unsupported_project_files[0].path, + workspace_config + ); + assert_eq!( + configuration.unsupported_project_files[0].status, + Status::Warn + ); + assert!(!configuration.unsupported_project_files[0].active); assert_eq!(configuration.global.path, global_config); assert_eq!(configuration.global.status, Status::Fail); assert_eq!(configuration.upstream_auth.openai, SecretPresence::Unset); @@ -933,7 +938,7 @@ fn configuration_and_path_helpers_cover_direct_paths_and_fallbacks() { }, }, ); - assert_eq!(info.workspace.status, Status::Pass); + assert_eq!(info.unsupported_project_files.len(), 1); assert!(info.global.path.starts_with(&home)); assert_eq!(info.configured_agents, vec!["codex".to_string()]); assert_eq!(info.upstream_auth.openai, SecretPresence::Configured); diff --git a/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs b/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs index efcef6488..b9688f7d7 100644 --- a/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs +++ b/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs @@ -617,7 +617,7 @@ fn tracked_native_plugin_example_satisfies_default_trust_policy() { add( PluginsAddRequest { - scope: ConfigurationScope::Project, + scope: ConfigurationScope::User, path: plugin_dir, }, &GatewayOverrides::default(), @@ -644,7 +644,7 @@ fn tracked_native_plugin_example_rejects_tampered_artifact() { let error = add( PluginsAddRequest { - scope: ConfigurationScope::Project, + scope: ConfigurationScope::User, path: plugin_dir, }, &GatewayOverrides::default(), @@ -1747,7 +1747,7 @@ fn python_environment_attestation_rejects_invalid_json_and_source_identity_drift } #[test] -fn add_registers_dynamic_plugin_in_project_plugins_toml() { +fn add_registers_dynamic_plugin_in_user_plugins_toml() { let temp = tempfile::tempdir().unwrap(); let _env = EnvScope::hermetic(&temp); let _cwd = CurrentDirGuard::enter(temp.path()); @@ -1757,14 +1757,18 @@ fn add_registers_dynamic_plugin_in_project_plugins_toml() { add( PluginsAddRequest { - scope: ConfigurationScope::Project, + scope: ConfigurationScope::User, path: plugin_dir.clone(), }, &crate::server::GatewayOverrides::default(), ) .unwrap(); - let plugins_toml = temp.path().join(".nemo-relay").join("plugins.toml"); + let plugins_toml = temp + .path() + .join("xdg") + .join("nemo-relay") + .join("plugins.toml"); let rendered = std::fs::read_to_string(&plugins_toml).unwrap(); assert!(rendered.contains("[[plugins.dynamic]]")); assert!(rendered.contains("relay-plugin.toml")); @@ -1793,7 +1797,7 @@ fn add_rejects_unreadable_declared_config_schema() { let error = add( PluginsAddRequest { - scope: ConfigurationScope::Project, + scope: ConfigurationScope::User, path: plugin_dir, }, &GatewayOverrides::default(), @@ -1855,14 +1859,18 @@ fn validate_id_checks_resolved_host_config_against_declared_schema() { let server = GatewayOverrides::default(); add( PluginsAddRequest { - scope: ConfigurationScope::Project, + scope: ConfigurationScope::User, path: plugin_dir, }, &server, ) .unwrap(); - let plugins_toml = temp.path().join(".nemo-relay").join("plugins.toml"); + let plugins_toml = temp + .path() + .join("xdg") + .join("nemo-relay") + .join("plugins.toml"); let mut rendered = std::fs::read_to_string(&plugins_toml).unwrap(); rendered.push_str( r#" @@ -1899,7 +1907,7 @@ fn add_provisions_persists_and_removes_managed_python_environment() { add_with_environment_runner( PluginsAddRequest { - scope: ConfigurationScope::Project, + scope: ConfigurationScope::User, path: plugin_dir.clone(), }, &server, @@ -1975,7 +1983,7 @@ fn add_provisions_persists_and_removes_managed_python_environment() { add_with_environment_runner( PluginsAddRequest { - scope: ConfigurationScope::Project, + scope: ConfigurationScope::User, path: plugin_dir, }, &server, @@ -2055,7 +2063,7 @@ fn add_rolls_back_python_environment_when_installation_fails() { let error = add_with_environment_runner( PluginsAddRequest { - scope: ConfigurationScope::Project, + scope: ConfigurationScope::User, path: plugin_dir, }, &GatewayOverrides::default(), @@ -2072,7 +2080,8 @@ fn add_rolls_back_python_environment_when_installation_fails() { assert_eq!(runner.calls().len(), 2); let managed_root = temp .path() - .join(".nemo-relay") + .join("xdg") + .join("nemo-relay") .join(".dynamic-plugin-environments"); assert!(!managed_root.exists() || std::fs::read_dir(managed_root).unwrap().next().is_none()); assert!( @@ -2095,7 +2104,7 @@ fn enable_rejects_missing_managed_python_environment() { let server = GatewayOverrides::default(); add_with_environment_runner( PluginsAddRequest { - scope: ConfigurationScope::Project, + scope: ConfigurationScope::User, path: plugin_dir, }, &server, @@ -2151,7 +2160,7 @@ fn enable_rejects_python_environment_outside_managed_location() { let server = GatewayOverrides::default(); add_with_environment_runner( PluginsAddRequest { - scope: ConfigurationScope::Project, + scope: ConfigurationScope::User, path: plugin_dir, }, &server, @@ -2221,7 +2230,7 @@ fn add_requires_manifest_root_for_python_workers() { let error = add_with_environment_runner( PluginsAddRequest { - scope: ConfigurationScope::Project, + scope: ConfigurationScope::User, path: plugin_dir, }, &GatewayOverrides::default(), @@ -2260,7 +2269,7 @@ fn add_rejects_python_entrypoint_module_that_is_not_integrity_checked_artifact() let error = add_with_environment_runner( PluginsAddRequest { - scope: ConfigurationScope::Project, + scope: ConfigurationScope::User, path: plugin_dir, }, &GatewayOverrides::default(), @@ -2363,7 +2372,7 @@ fn remove_can_retry_after_guarded_environment_cleanup_failure() { let server = GatewayOverrides::default(); add_with_environment_runner( PluginsAddRequest { - scope: ConfigurationScope::Project, + scope: ConfigurationScope::User, path: plugin_dir, }, &server, @@ -2446,7 +2455,7 @@ fn remove_can_retry_after_guarded_environment_cleanup_failure() { } #[test] -fn active_dynamic_plugin_components_project_enabled_native_records_only() { +fn active_dynamic_plugin_components_user_enabled_native_records_only() { let temp = tempfile::tempdir().unwrap(); let _env = EnvScope::hermetic(&temp); let _cwd = CurrentDirGuard::enter(temp.path()); @@ -2457,7 +2466,7 @@ fn active_dynamic_plugin_components_project_enabled_native_records_only() { add( PluginsAddRequest { - scope: ConfigurationScope::Project, + scope: ConfigurationScope::User, path: plugin_dir, }, &server, @@ -2501,7 +2510,7 @@ fn active_dynamic_plugin_components_accept_enabled_worker_records() { add( PluginsAddRequest { - scope: ConfigurationScope::Project, + scope: ConfigurationScope::User, path: plugin_dir, }, &server, @@ -2541,7 +2550,7 @@ fn active_dynamic_plugin_components_accept_worker_records_without_manifest_ref() add( PluginsAddRequest { - scope: ConfigurationScope::Project, + scope: ConfigurationScope::User, path: plugin_dir, }, &server, @@ -2590,7 +2599,7 @@ fn add_rejects_duplicate_dynamic_plugin_ids() { add( PluginsAddRequest { - scope: ConfigurationScope::Project, + scope: ConfigurationScope::User, path: plugin_dir.clone(), }, &crate::server::GatewayOverrides::default(), @@ -2599,7 +2608,7 @@ fn add_rejects_duplicate_dynamic_plugin_ids() { let error = add( PluginsAddRequest { - scope: ConfigurationScope::Project, + scope: ConfigurationScope::User, path: plugin_dir, }, &crate::server::GatewayOverrides::default(), @@ -2628,7 +2637,7 @@ fn add_rejects_scope_flags_when_explicit_config_is_set() { let error = add( PluginsAddRequest { - scope: ConfigurationScope::Project, + scope: ConfigurationScope::User, path: plugin_dir, }, &server, @@ -2644,7 +2653,7 @@ fn add_refuses_dynamic_plugins_blocked_by_host_policy() { 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(".nemo-relay"); + let config_dir = temp.path().join("xdg").join("nemo-relay"); std::fs::create_dir_all(&plugin_dir).unwrap(); std::fs::create_dir_all(&config_dir).unwrap(); write_dynamic_manifest(&plugin_dir, "acme.blocked"); @@ -2659,7 +2668,7 @@ allowed = false let error = add( PluginsAddRequest { - scope: ConfigurationScope::Project, + scope: ConfigurationScope::User, path: plugin_dir, }, &crate::server::GatewayOverrides::default(), @@ -2730,7 +2739,7 @@ fn list_and_inspect_render_discovered_dynamic_plugins() { add( PluginsAddRequest { - scope: ConfigurationScope::Project, + scope: ConfigurationScope::User, path: plugin_dir, }, &crate::server::GatewayOverrides::default(), @@ -2799,7 +2808,7 @@ fn validate_renders_summary_for_path_and_id_targets() { add( PluginsAddRequest { - scope: ConfigurationScope::Project, + scope: ConfigurationScope::User, path: plugin_dir, }, &crate::server::GatewayOverrides::default(), @@ -2890,7 +2899,7 @@ fn enable_disable_and_remove_persist_lifecycle_state() { add( PluginsAddRequest { - scope: ConfigurationScope::Project, + scope: ConfigurationScope::User, path: plugin_dir, }, &server, @@ -3015,7 +3024,7 @@ fn add_with_explicit_config_uses_sibling_plugins_and_state_files() { } #[test] -fn explicit_config_keeps_project_dynamic_plugin_lifecycle_scope() { +fn explicit_config_ignores_project_dynamic_plugin_lifecycle_scope() { let temp = tempfile::tempdir().unwrap(); let _env = EnvScope::hermetic(&temp); let project = temp.path().join("project"); @@ -3037,23 +3046,18 @@ fn explicit_config_keeps_project_dynamic_plugin_lifecycle_scope() { ), ) .unwrap(); + std::fs::write(project_config_dir.join(".dynamic-plugins.json"), "{").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() + assert!(resolved.dynamic_plugins.is_empty()); + assert!( + find_record_by_id(&scopes, "acme.project-layer") .unwrap() + .is_none() ); } @@ -3098,7 +3102,7 @@ fn hydrate_bootstraps_registry_records_from_existing_dynamic_plugin_refs() { 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(".nemo-relay"); + let config_dir = temp.path().join("xdg").join("nemo-relay"); 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.bootstrap"); @@ -3119,7 +3123,7 @@ fn hydrate_bootstraps_registry_records_from_existing_dynamic_plugin_refs() { let entry = find_record_by_id(&scopes, "acme.bootstrap") .unwrap() .expect("hydrated record"); - assert_eq!(entry.scope.to_string(), "project"); + assert_eq!(entry.scope.to_string(), "user"); assert_eq!(entry.record.metadata.id, "acme.bootstrap"); assert!(entry.record.spec.present); assert!(!entry.record.spec.enabled); @@ -3136,7 +3140,7 @@ fn manually_configured_python_worker_cannot_enable_without_lifecycle_add() { let _env = EnvScope::hermetic(&temp); let _cwd = CurrentDirGuard::enter(temp.path()); let plugin_dir = temp.path().join("plugins").join("python"); - let config_dir = temp.path().join(".nemo-relay"); + let config_dir = temp.path().join("xdg").join("nemo-relay"); std::fs::create_dir_all(&plugin_dir).unwrap(); std::fs::create_dir_all(&config_dir).unwrap(); let manifest_path = write_python_dynamic_manifest(&plugin_dir, "acme.python-direct"); @@ -3195,7 +3199,7 @@ fn hydrate_applies_host_policy_status_to_discovered_dynamic_plugins() { 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(".nemo-relay"); + let config_dir = temp.path().join("xdg").join("nemo-relay"); 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.policy"); @@ -3271,14 +3275,14 @@ fn hydrate_persists_updated_policy_and_error_state() { 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(".nemo-relay"); + let config_dir = temp.path().join("xdg").join("nemo-relay"); std::fs::create_dir_all(&plugin_dir).unwrap(); std::fs::create_dir_all(&config_dir).unwrap(); write_dynamic_manifest(&plugin_dir, "acme.persist-blocked"); add( PluginsAddRequest { - scope: ConfigurationScope::Project, + scope: ConfigurationScope::User, path: plugin_dir.clone(), }, &GatewayOverrides::default(), @@ -3326,7 +3330,7 @@ fn hydrate_verifies_signatures_when_host_policy_provides_trusted_keys() { 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(".nemo-relay"); + let config_dir = temp.path().join("xdg").join("nemo-relay"); std::fs::create_dir_all(&plugin_dir).unwrap(); std::fs::create_dir_all(&config_dir).unwrap(); let manifest_path = write_dynamic_manifest_with_options( @@ -3377,7 +3381,7 @@ fn hydrate_marks_signature_required_plugins_invalid_without_trusted_keys() { 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(".nemo-relay"); + let config_dir = temp.path().join("xdg").join("nemo-relay"); std::fs::create_dir_all(&plugin_dir).unwrap(); std::fs::create_dir_all(&config_dir).unwrap(); let manifest_path = write_dynamic_manifest_with_options( @@ -3430,7 +3434,7 @@ fn hydrate_marks_signature_required_plugins_invalid_with_wrong_trusted_key() { 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(".nemo-relay"); + let config_dir = temp.path().join("xdg").join("nemo-relay"); std::fs::create_dir_all(&plugin_dir).unwrap(); std::fs::create_dir_all(&config_dir).unwrap(); let manifest_path = write_dynamic_manifest_with_options( @@ -3486,7 +3490,7 @@ fn hydrate_marks_malformed_signature_files_invalid_when_signature_is_present() { 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(".nemo-relay"); + let config_dir = temp.path().join("xdg").join("nemo-relay"); std::fs::create_dir_all(&plugin_dir).unwrap(); std::fs::create_dir_all(&config_dir).unwrap(); let manifest_path = write_dynamic_manifest_with_options( @@ -3542,7 +3546,7 @@ fn enable_refuses_dynamic_plugins_blocked_by_host_policy_and_persists_status() { 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(".nemo-relay"); + let config_dir = temp.path().join("xdg").join("nemo-relay"); 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.enable-blocked"); @@ -3550,7 +3554,7 @@ fn enable_refuses_dynamic_plugins_blocked_by_host_policy_and_persists_status() { add( PluginsAddRequest { - scope: ConfigurationScope::Project, + scope: ConfigurationScope::User, path: plugin_dir, }, &server, @@ -3628,7 +3632,7 @@ fn disable_succeeds_when_registered_plugin_manifest_is_unreadable() { add( PluginsAddRequest { - scope: ConfigurationScope::Project, + scope: ConfigurationScope::User, path: plugin_dir.clone(), }, &server, @@ -3666,7 +3670,7 @@ fn validate_marks_registered_plugins_invalid_when_host_policy_blocks_them() { 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(".nemo-relay"); + let config_dir = temp.path().join("xdg").join("nemo-relay"); 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.validate-blocked"); @@ -3674,7 +3678,7 @@ fn validate_marks_registered_plugins_invalid_when_host_policy_blocks_them() { add( PluginsAddRequest { - scope: ConfigurationScope::Project, + scope: ConfigurationScope::User, path: plugin_dir, }, &server, @@ -3814,7 +3818,7 @@ fn add_can_revive_tombstoned_records() { add( PluginsAddRequest { - scope: ConfigurationScope::Project, + scope: ConfigurationScope::User, path: plugin_dir.clone(), }, &server, @@ -3831,7 +3835,7 @@ fn add_can_revive_tombstoned_records() { add( PluginsAddRequest { - scope: ConfigurationScope::Project, + scope: ConfigurationScope::User, path: plugin_dir, }, &server, @@ -3859,7 +3863,7 @@ fn json_helpers_emit_stable_success_and_failure_shapes() { add( PluginsAddRequest { - scope: ConfigurationScope::Project, + scope: ConfigurationScope::User, path: plugin_dir, }, &server, @@ -3990,14 +3994,18 @@ fn remove_tolerates_unreadable_non_target_manifest_entries() { add( PluginsAddRequest { - scope: ConfigurationScope::Project, + scope: ConfigurationScope::User, path: plugin_dir, }, &server, ) .unwrap(); - let plugins_toml = temp.path().join(".nemo-relay").join("plugins.toml"); + let plugins_toml = temp + .path() + .join("xdg") + .join("nemo-relay") + .join("plugins.toml"); std::fs::write( &plugins_toml, format!( @@ -4064,7 +4072,7 @@ fn remove_matches_relative_target_manifest_refs_without_loading_manifest() { let temp = tempfile::tempdir().unwrap(); let _env = EnvScope::hermetic(&temp); let _cwd = CurrentDirGuard::enter(temp.path()); - let config_dir = temp.path().join(".nemo-relay"); + let config_dir = temp.path().join("xdg").join("nemo-relay"); let plugin_dir = temp.path().join("plugins").join("acme"); std::fs::create_dir_all(&config_dir).unwrap(); std::fs::create_dir_all(&plugin_dir).unwrap(); @@ -4114,14 +4122,18 @@ fn inspect_redacts_host_config_values() { add( PluginsAddRequest { - scope: ConfigurationScope::Project, + scope: ConfigurationScope::User, path: plugin_dir, }, &server, ) .unwrap(); - let plugins_toml = temp.path().join(".nemo-relay").join("plugins.toml"); + let plugins_toml = temp + .path() + .join("xdg") + .join("nemo-relay") + .join("plugins.toml"); std::fs::write( &plugins_toml, format!( @@ -4191,14 +4203,18 @@ fn inspect_distinguishes_empty_host_config_from_missing_host_config() { add( PluginsAddRequest { - scope: ConfigurationScope::Project, + scope: ConfigurationScope::User, path: plugin_dir, }, &server, ) .unwrap(); - let plugins_toml = temp.path().join(".nemo-relay").join("plugins.toml"); + let plugins_toml = temp + .path() + .join("xdg") + .join("nemo-relay") + .join("plugins.toml"); std::fs::write( &plugins_toml, format!( @@ -4267,7 +4283,7 @@ fn required_lifecycle_record( let server = GatewayOverrides::default(); add( PluginsAddRequest { - scope: ConfigurationScope::Project, + scope: ConfigurationScope::User, path: plugin_dir, }, &server, @@ -4309,19 +4325,19 @@ fn lifecycle_helpers_cover_environment_manifest_scope_and_restore_paths() { let state_path = temp.path().join("state.json"); let first = ensure_scope( &mut scopes, - RegistryScope::Project, + RegistryScope::User, plugins_path.clone(), state_path.clone(), ); let existing = ensure_scope( &mut scopes, - RegistryScope::Project, + RegistryScope::User, plugins_path.clone(), state_path, ); assert_eq!((first, existing, scopes.len()), (0, 0, 1)); assert!(!scope_flags_selected(&ConfigurationScope::Default)); - assert!(scope_flags_selected(&ConfigurationScope::Project)); + assert!(scope_flags_selected(&ConfigurationScope::User)); restore_plugins_toml(&plugins_path, Some(b"[plugins]\n")).unwrap(); assert_eq!(std::fs::read(&plugins_path).unwrap(), b"[plugins]\n"); @@ -4443,7 +4459,7 @@ fn lifecycle_commands_cover_json_and_human_output_paths() { let manifest_path = write_dynamic_manifest(&plugin_dir, "acme.output"); add( PluginsAddRequest { - scope: ConfigurationScope::Project, + scope: ConfigurationScope::User, path: plugin_dir, }, &server, diff --git a/crates/cli/tests/coverage/shared/plugins_tests.rs b/crates/cli/tests/coverage/shared/plugins_tests.rs index 7f87c91e0..e4c4423c9 100644 --- a/crates/cli/tests/coverage/shared/plugins_tests.rs +++ b/crates/cli/tests/coverage/shared/plugins_tests.rs @@ -2,9 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use super::*; -use crate::configuration::{ - global_plugin_config_path, project_plugin_config_path, user_plugin_config_path, -}; +use crate::configuration::{global_plugin_config_path, user_plugin_config_path}; use crate::plugins::ConfigurationScope; use nemo_relay::config_editor::{ EditorConfig, EditorListItemSpec, EditorSchema, EditorTaggedUnionSpec, EditorVariantSpec, @@ -158,8 +156,8 @@ fn target_scope_defaults_to_user_and_rejects_conflicts() { TargetScope::User ); assert_eq!( - target_scope(&ConfigurationScope::Project).unwrap(), - TargetScope::Project + target_scope(&ConfigurationScope::User).unwrap(), + TargetScope::User ); assert_eq!( target_scope(&ConfigurationScope::Global).unwrap(), @@ -1920,20 +1918,34 @@ fn assert_preserved_plugin_document(root: &toml::Table) { assert!(dynamic[1].get("config").is_none()); } -#[cfg(unix)] #[test] -fn global_plugin_document_is_system_readable() { - use std::os::unix::fs::PermissionsExt; - +fn plugin_documents_create_missing_user_and_system_directories() { let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("plugins.toml"); - let document = PluginConfigDocument::read(&path).unwrap(); - document.write_for_scope(TargetScope::Global).unwrap(); + let user_path = temp.path().join("user/nested/plugins.toml"); + let system_path = temp.path().join("system/nested/plugins.toml"); + assert!(!user_path.parent().unwrap().exists()); + assert!(!system_path.parent().unwrap().exists()); - assert_eq!( - std::fs::metadata(path).unwrap().permissions().mode() & 0o777, - 0o644 - ); + PluginConfigDocument::read(&user_path) + .unwrap() + .write_for_scope(TargetScope::User) + .unwrap(); + PluginConfigDocument::read(&system_path) + .unwrap() + .write_for_scope(TargetScope::Global) + .unwrap(); + + assert!(user_path.is_file()); + assert!(system_path.is_file()); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(system_path).unwrap().permissions().mode() & 0o777, + 0o644 + ); + } } #[test] @@ -3076,14 +3088,7 @@ fn string_map_add_rejects_an_existing_trimmed_key() { } #[test] -fn target_path_resolves_project_and_global_without_user_env() { - let _cwd = crate::test_support::CwdTestScope::locked(); - let cwd = std::env::current_dir().unwrap(); - - assert_eq!( - target_path(TargetScope::Project).unwrap(), - project_plugin_config_path(&cwd) - ); +fn target_path_resolves_global_scope() { assert_eq!( target_path(TargetScope::Global).unwrap(), global_plugin_config_path() diff --git a/crates/cli/tests/coverage/shared/setup_tests.rs b/crates/cli/tests/coverage/shared/setup_tests.rs index b16c318b6..7f72142b6 100644 --- a/crates/cli/tests/coverage/shared/setup_tests.rs +++ b/crates/cli/tests/coverage/shared/setup_tests.rs @@ -112,10 +112,7 @@ fn detect_installed_agents_handles_missing_path() { #[test] fn build_config_does_not_emit_observability_exporters() { - let answers = SetupAnswers { - scope: ConfigScope::Project, - agents: vec![], - }; + let answers = SetupAnswers { agents: vec![] }; let rendered = build_config(&answers).to_string(); @@ -128,10 +125,7 @@ fn build_config_does_not_emit_observability_exporters() { #[test] fn build_config_skips_empty_sections_when_no_backends_selected() { - let answers = SetupAnswers { - scope: ConfigScope::Project, - agents: vec![], - }; + let answers = SetupAnswers { agents: vec![] }; let doc = build_config(&answers); let rendered = doc.to_string(); @@ -145,7 +139,6 @@ fn build_config_skips_empty_sections_when_no_backends_selected() { #[test] fn build_config_emits_agents_block_with_user_facing_keys() { let answers = SetupAnswers { - scope: ConfigScope::Project, agents: vec![CodingAgent::ClaudeCode, CodingAgent::Codex], }; @@ -160,20 +153,25 @@ fn build_config_emits_agents_block_with_user_facing_keys() { } #[test] -fn save_config_writes_project_scope_to_workspace_dir() { +fn save_config_writes_user_scope_to_user_config_dir() { + let _xdg = XdgScope::cleared(); let answers = SetupAnswers { - scope: ConfigScope::Project, agents: vec![CodingAgent::ClaudeCode], }; let doc = build_config(&answers); - let temp = tempfile::tempdir().unwrap(); let home = tempfile::tempdir().unwrap(); + let user_config_dir = home.path().join(".config/nemo-relay"); + assert!(!user_config_dir.exists()); - let written = save_config(&doc, ConfigScope::Project, temp.path(), home.path(), None).unwrap(); + let written = save_config(&doc, home.path(), None).unwrap(); assert_eq!(written.len(), 1); - assert_eq!(written[0], temp.path().join(".nemo-relay/config.toml")); + assert_eq!( + written[0], + home.path().join(".config/nemo-relay/config.toml") + ); let contents = std::fs::read_to_string(&written[0]).unwrap(); + assert!(user_config_dir.is_dir()); assert!(!contents.contains("[exporters]")); assert!(contents.contains("[agents.claude]")); } @@ -183,11 +181,11 @@ fn save_config_scoped_merge_preserves_other_agents() { // Seed an existing config with claude AND codex blocks, plus a custom [upstream] that the // wizard does not touch. Then "re-run" the wizard scoped to claude and assert codex + // upstream survive while claude is updated and observability is written fresh. - let temp = tempfile::tempdir().unwrap(); let home = tempfile::tempdir().unwrap(); - let project_dir = temp.path().join(".nemo-relay"); - std::fs::create_dir_all(&project_dir).unwrap(); - let existing_path = project_dir.join("config.toml"); + let _xdg = XdgScope::cleared(); + let user_dir = home.path().join(".config/nemo-relay"); + std::fs::create_dir_all(&user_dir).unwrap(); + let existing_path = user_dir.join("config.toml"); std::fs::write( &existing_path, r#"[upstream] @@ -203,18 +201,10 @@ command = "codex --full-auto" .unwrap(); let answers = SetupAnswers { - scope: ConfigScope::Project, agents: vec![CodingAgent::ClaudeCode], }; let doc = build_config(&answers); - save_config( - &doc, - ConfigScope::Project, - temp.path(), - home.path(), - Some(CodingAgent::ClaudeCode), - ) - .unwrap(); + save_config(&doc, home.path(), Some(CodingAgent::ClaudeCode)).unwrap(); let merged = std::fs::read_to_string(&existing_path).unwrap(); assert!(!merged.contains("[exporters]")); @@ -242,59 +232,30 @@ command = "codex --full-auto" } #[test] -fn save_config_writes_both_scopes_when_both_selected() { +fn save_config_writes_only_user_scope() { let _xdg = XdgScope::cleared(); - let answers = SetupAnswers { - scope: ConfigScope::Both, - agents: vec![], - }; + let answers = SetupAnswers { agents: vec![] }; let doc = build_config(&answers); - let cwd = tempfile::tempdir().unwrap(); let home = tempfile::tempdir().unwrap(); - let written = save_config(&doc, ConfigScope::Both, cwd.path(), home.path(), None).unwrap(); + let written = save_config(&doc, home.path(), None).unwrap(); - assert_eq!(written.len(), 2); - assert!(written.iter().any(|p| p.starts_with(cwd.path()))); - assert!(written.iter().any(|p| p.starts_with(home.path()))); + assert_eq!( + written, + vec![home.path().join(".config/nemo-relay/config.toml")] + ); } #[test] -fn global_config_dir_and_preview_paths_prefer_xdg_when_set() { +fn user_config_dir_and_preview_paths_prefer_xdg_when_set() { let xdg = tempfile::tempdir().unwrap(); - let cwd = tempfile::tempdir().unwrap(); let home = tempfile::tempdir().unwrap(); let _env = EnvScope::set(&[("XDG_CONFIG_HOME", Some(xdg.path().as_os_str()))]); + assert_eq!(user_config_dir(home.path()), xdg.path().join("nemo-relay")); assert_eq!( - global_config_dir(home.path()), - xdg.path().join("nemo-relay") - ); - assert_eq!( - preview_paths(ConfigScope::Both, cwd.path(), home.path()), - vec![ - cwd.path().join(".nemo-relay/config.toml"), - xdg.path().join("nemo-relay/config.toml"), - ] - ); -} - -#[test] -fn config_scope_labels_are_user_facing_and_stable() { - assert!( - ConfigScope::Project - .label() - .contains(".nemo-relay/config.toml") - ); - assert!( - ConfigScope::Global - .label() - .contains(".config/nemo-relay/config.toml") - ); - assert!( - ConfigScope::Both - .label() - .contains("project overrides global") + preview_paths(home.path()), + vec![xdg.path().join("nemo-relay/config.toml")] ); } @@ -304,14 +265,6 @@ fn existing_defaults_detects_scope_and_agents_from_docs() { assert!(!empty.has_any()); assert!( Defaults { - scope: Some(ConfigScope::Project), - agents: vec![] - } - .has_any() - ); - assert!( - Defaults { - scope: None, agents: vec![CodingAgent::Codex] } .has_any() @@ -334,7 +287,7 @@ command = "custom" } #[test] -fn read_existing_defaults_prefers_workspace_and_reports_scope_variants() { +fn read_existing_defaults_reads_user_config_and_ignores_project_config() { let cwd = tempfile::tempdir().unwrap(); let home = tempfile::tempdir().unwrap(); let _cwd = CwdScope::enter(cwd.path()); @@ -350,20 +303,16 @@ fn read_existing_defaults_prefers_workspace_and_reports_scope_variants() { std::fs::create_dir_all(global_path.parent().unwrap()).unwrap(); std::fs::write(&global_path, "[agents.codex]\ncommand = \"codex\"\n").unwrap(); let defaults = read_existing_defaults().unwrap(); - assert_eq!(defaults.scope, Some(ConfigScope::Global)); assert_eq!(defaults.agents, vec![CodingAgent::Codex]); let workspace_path = cwd.path().join(".nemo-relay/config.toml"); std::fs::create_dir_all(workspace_path.parent().unwrap()).unwrap(); std::fs::write(&workspace_path, "[agents.claude]\ncommand = \"claude\"\n").unwrap(); let defaults = read_existing_defaults().unwrap(); - assert_eq!(defaults.scope, Some(ConfigScope::Both)); - assert_eq!(defaults.agents, vec![CodingAgent::ClaudeCode]); + assert_eq!(defaults.agents, vec![CodingAgent::Codex]); std::fs::remove_file(&global_path).unwrap(); - let defaults = read_existing_defaults().unwrap(); - assert_eq!(defaults.scope, Some(ConfigScope::Project)); - assert_eq!(defaults.agents, vec![CodingAgent::ClaudeCode]); + assert!(read_existing_defaults().is_none()); } #[test] @@ -382,7 +331,6 @@ config = { version = 1, components = [] } ) .unwrap(); let doc = build_config(&SetupAnswers { - scope: ConfigScope::Project, agents: vec![CodingAgent::Codex], }); @@ -404,7 +352,6 @@ fn write_or_merge_replaces_agents_without_merge_scope_and_preserves_other_sectio ) .unwrap(); let doc = build_config(&SetupAnswers { - scope: ConfigScope::Project, agents: vec![CodingAgent::Hermes], }); @@ -421,10 +368,14 @@ fn write_or_merge_replaces_agents_without_merge_scope_and_preserves_other_sectio } #[test] -fn reset_removes_whole_project_config_or_one_agent() { +fn reset_removes_whole_user_config_or_one_agent() { let temp = tempfile::tempdir().unwrap(); - let _cwd = CwdScope::enter(temp.path()); - let config_dir = temp.path().join(".nemo-relay"); + let _env = EnvScope::set(&[ + ("HOME", Some(temp.path().as_os_str())), + ("USERPROFILE", None), + ("XDG_CONFIG_HOME", None), + ]); + let config_dir = temp.path().join(".config/nemo-relay"); std::fs::create_dir_all(&config_dir).unwrap(); let path = config_dir.join("config.toml"); std::fs::write( @@ -439,13 +390,13 @@ command = "codex" ) .unwrap(); - reset(ConfigScope::Project, Some(CodingAgent::ClaudeCode)).unwrap(); + reset(Some(CodingAgent::ClaudeCode)).unwrap(); let scoped = std::fs::read_to_string(&path).unwrap(); assert!(!scoped.contains("[agents.claude]")); assert!(scoped.contains("[agents.codex]")); - reset(ConfigScope::Project, None).unwrap(); + reset(None).unwrap(); assert!(!path.exists()); } @@ -453,13 +404,17 @@ command = "codex" #[test] fn reset_removes_empty_agents_table_when_last_agent_is_removed() { let temp = tempfile::tempdir().unwrap(); - let _cwd = CwdScope::enter(temp.path()); - let config_dir = temp.path().join(".nemo-relay"); + let _env = EnvScope::set(&[ + ("HOME", Some(temp.path().as_os_str())), + ("USERPROFILE", None), + ("XDG_CONFIG_HOME", None), + ]); + let config_dir = temp.path().join(".config/nemo-relay"); std::fs::create_dir_all(&config_dir).unwrap(); let path = config_dir.join("config.toml"); std::fs::write(&path, "[agents.codex]\ncommand = \"codex\"\n").unwrap(); - reset(ConfigScope::Project, Some(CodingAgent::Codex)).unwrap(); + reset(Some(CodingAgent::Codex)).unwrap(); let contents = std::fs::read_to_string(&path).unwrap(); assert!(!contents.contains("[agents]")); @@ -467,30 +422,34 @@ fn reset_removes_empty_agents_table_when_last_agent_is_removed() { } #[test] -fn reset_noops_when_project_config_is_missing() { +fn reset_noops_when_user_config_is_missing() { let temp = tempfile::tempdir().unwrap(); - let _cwd = CwdScope::enter(temp.path()); + let _env = EnvScope::set(&[ + ("HOME", Some(temp.path().as_os_str())), + ("USERPROFILE", None), + ("XDG_CONFIG_HOME", None), + ]); - reset(ConfigScope::Project, None).unwrap(); - reset(ConfigScope::Project, Some(CodingAgent::Codex)).unwrap(); + reset(None).unwrap(); + reset(Some(CodingAgent::Codex)).unwrap(); } #[test] fn reset_reports_missing_or_malformed_agent_blocks_without_rewriting() { let temp = tempfile::tempdir().unwrap(); - let _cwd = CwdScope::enter(temp.path()); let hermes_home = temp.path().join("hermes-home"); let _env = EnvScope::set(&[ ("HOME", Some(temp.path().as_os_str())), ("USERPROFILE", None), + ("XDG_CONFIG_HOME", None), ("HERMES_HOME", Some(hermes_home.as_os_str())), ]); - let config_dir = temp.path().join(".nemo-relay"); + let config_dir = temp.path().join(".config/nemo-relay"); std::fs::create_dir_all(&config_dir).unwrap(); let path = config_dir.join("config.toml"); std::fs::write(&path, "agents = \"not-a-table\"\n").unwrap(); - reset(ConfigScope::Project, Some(CodingAgent::Hermes)).unwrap(); + reset(Some(CodingAgent::Hermes)).unwrap(); assert_eq!( std::fs::read_to_string(&path).unwrap(), @@ -498,9 +457,7 @@ fn reset_reports_missing_or_malformed_agent_blocks_without_rewriting() { ); std::fs::write(&path, "not valid toml = [\n").unwrap(); - let error = reset(ConfigScope::Project, Some(CodingAgent::Hermes)) - .unwrap_err() - .to_string(); + let error = reset(Some(CodingAgent::Hermes)).unwrap_err().to_string(); assert!( error.contains("could not parse existing config"), "error was: {error}" @@ -508,7 +465,7 @@ fn reset_reports_missing_or_malformed_agent_blocks_without_rewriting() { } #[test] -fn reset_honors_global_and_both_scopes() { +fn reset_removes_user_config_and_leaves_project_file_untouched() { let temp = tempfile::tempdir().unwrap(); let project = temp.path().join("project"); let home = temp.path().join("home"); @@ -523,72 +480,37 @@ fn reset_honors_global_and_both_scopes() { ]); let project_path = project.join(".nemo-relay/config.toml"); - let global_path = global_config_dir(&home).join("config.toml"); + let user_path = user_config_dir(&home).join("config.toml"); std::fs::create_dir_all(project_path.parent().unwrap()).unwrap(); - std::fs::create_dir_all(global_path.parent().unwrap()).unwrap(); + std::fs::create_dir_all(user_path.parent().unwrap()).unwrap(); std::fs::write(&project_path, "[agents.codex]\ncommand = \"codex\"\n").unwrap(); - std::fs::write(&global_path, "[agents.codex]\ncommand = \"codex\"\n").unwrap(); + std::fs::write(&user_path, "[agents.codex]\ncommand = \"codex\"\n").unwrap(); - reset(ConfigScope::Global, None).unwrap(); + reset(None).unwrap(); assert!(project_path.exists()); - assert!(!global_path.exists()); - - std::fs::write(&global_path, "[agents.codex]\ncommand = \"codex\"\n").unwrap(); - reset(ConfigScope::Both, None).unwrap(); - assert!(!project_path.exists()); - assert!(!global_path.exists()); + assert!(!user_path.exists()); } #[test] -fn plugins_edit_command_for_scope_targets_expected_plugin_scope() { +fn plugins_edit_command_targets_user_scope() { use crate::plugins::config_io::{TargetScope, target_scope}; - let cases = [ - (ConfigScope::Project, TargetScope::Project), - (ConfigScope::Global, TargetScope::User), - (ConfigScope::Both, TargetScope::Project), - ]; - - for (scope, expected) in cases { - let command = plugins_edit_command_for_scope(scope, None); - assert_eq!( - target_scope(&command.scope).unwrap(), - expected, - "unexpected plugin target scope for {scope:?}" - ); - } + let command = plugins_edit_command(None); + assert_eq!(target_scope(&command.scope).unwrap(), TargetScope::User); } #[test] -fn plugins_edit_command_for_scope_preserves_explicit_plugin_path() { +fn plugins_edit_command_preserves_explicit_plugin_path() { let path = PathBuf::from("/managed/plugins.toml"); - 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" - ); - } + let command = plugins_edit_command(Some(path.clone())); + assert_eq!(command.explicit_path, Some(path)); + assert_eq!(command.scope, crate::plugins::ConfigurationScope::User); } #[test] -fn plugins_resume_command_matches_scope() { - let cases = [ - (ConfigScope::Project, "nemo-relay plugins edit --project"), - (ConfigScope::Both, "nemo-relay plugins edit --project"), - (ConfigScope::Global, "nemo-relay plugins edit"), - ]; - - for (scope, expected) in cases { - assert_eq!( - plugins_resume_command(scope, None), - expected, - "unexpected resume command for {scope:?}" - ); - } +fn plugins_resume_command_targets_user_config() { + assert_eq!(plugins_resume_command(None), "nemo-relay plugins edit"); } #[test] @@ -605,10 +527,7 @@ fn plugins_resume_command_preserves_explicit_plugin_path() { "'/managed/plugin configs/plugins.toml' plugins edit" ); - assert_eq!( - plugins_resume_command(ConfigScope::Global, Some(&path)), - expected - ); + assert_eq!(plugins_resume_command(Some(&path)), expected); } #[test] diff --git a/crates/core/src/plugin.rs b/crates/core/src/plugin.rs index 335ec7afd..2d6078a3a 100644 --- a/crates/core/src/plugin.rs +++ b/crates/core/src/plugin.rs @@ -1790,8 +1790,7 @@ fn remove_default_policy_overlay(root: &mut Map, config: &ConfigPo /// Resolves the default `plugins.toml` layering into one JSON document, or an /// empty object when no plugin file exists. fn resolve_default_file_plugin_config() -> Result { - let paths = - default_plugin_config_paths(std::env::current_dir().ok().as_deref(), user_config_dir()); + let paths = default_plugin_config_paths(user_config_dir()); let documents = read_plugin_config_files(paths)?; resolve_discovered_plugin_config(documents) } @@ -2044,32 +2043,33 @@ fn validate_unique_component_kinds(path: &Path, document: &Json) -> Result<()> { ))) } -/// 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. +/// Default `plugins.toml` search path (lowest precedence first): user, then +/// system — 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 { +pub fn default_plugin_config_paths(user_dir: Option) -> Vec { 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); - } - paths.push(PathBuf::from("/etc/nemo-relay/plugins.toml")); + paths.push(system_config_dir().join("plugins.toml")); paths } -/// Walks upward from `start` for the nearest `.nemo-relay/plugins.toml`. `pub` -/// only for cross-crate reuse by the gateway. +/// Resolves the platform system configuration directory. #[doc(hidden)] -pub fn nearest_project_plugin_config(start: &Path) -> Option { - start - .ancestors() - .map(|ancestor| ancestor.join(".nemo-relay").join("plugins.toml")) - .find(|path| path.exists()) +pub fn system_config_dir() -> PathBuf { + #[cfg(windows)] + { + std::env::var_os("ProgramData") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\ProgramData")) + .join("nemo-relay") + } + #[cfg(not(windows))] + { + PathBuf::from("/etc/nemo-relay") + } } /// Resolves the nemo-relay user config directory from `XDG_CONFIG_HOME`, then diff --git a/crates/core/tests/integration/native_plugin_tests.rs b/crates/core/tests/integration/native_plugin_tests.rs index 8d572f247..da6a4fa18 100644 --- a/crates/core/tests/integration/native_plugin_tests.rs +++ b/crates/core/tests/integration/native_plugin_tests.rs @@ -1461,19 +1461,22 @@ async fn plugin_host_activation_combines_static_base_and_dynamic_components() { async fn plugin_host_activation_layers_discovered_static_base_with_dynamic_components() { if std::env::var_os(PLUGIN_DISCOVERY_TEST_CHILD).is_none() { let environment = TempDir::new().expect("plugin discovery environment should be created"); - let project_config_dir = environment.path().join(".nemo-relay"); - std::fs::create_dir_all(&project_config_dir) - .expect("project plugin config directory should be created"); + let xdg_config_home = environment.path().join("xdg"); + let user_config_dir = xdg_config_home.join("nemo-relay"); + std::fs::create_dir_all(&user_config_dir) + .expect("user plugin config directory should be created"); std::fs::write( - project_config_dir.join("plugins.toml"), + user_config_dir.join("plugins.toml"), format!( "version = 1\n\n[[components]]\nkind = {STATIC_BASE_PLUGIN_KIND:?}\nenabled = true\n" ), ) - .expect("project plugin config should be written"); - let xdg_config_home = environment.path().join("xdg"); - std::fs::create_dir_all(&xdg_config_home) - .expect("isolated user config directory should be created"); + .expect("user plugin config should be written"); + let legacy_project_dir = environment.path().join(".nemo-relay"); + std::fs::create_dir_all(&legacy_project_dir) + .expect("legacy project config directory should be created"); + std::fs::write(legacy_project_dir.join("plugins.toml"), "components = [\n") + .expect("malformed legacy project config should be written"); let output = Command::new(std::env::current_exe().expect("test executable should resolve")) .args([ diff --git a/crates/core/tests/unit/plugin_tests.rs b/crates/core/tests/unit/plugin_tests.rs index 815239734..3eea91301 100644 --- a/crates/core/tests/unit/plugin_tests.rs +++ b/crates/core/tests/unit/plugin_tests.rs @@ -2642,26 +2642,32 @@ fn test_plugin_config_loading_reports_read_parse_and_version_type_errors() { } #[test] -fn test_default_plugin_config_paths_order_user_project_system() { +fn test_default_plugin_config_paths_order_user_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())), + default_plugin_config_paths(Some(user.clone())), vec![ user.join("plugins.toml"), - project_plugins, - PathBuf::from("/etc/nemo-relay/plugins.toml"), + system_config_dir().join("plugins.toml"), ] ); } +#[test] +fn test_system_config_dir_matches_platform_convention() { + #[cfg(windows)] + { + let expected_base = std::env::var_os("ProgramData") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\ProgramData")); + assert_eq!(system_config_dir(), expected_base.join("nemo-relay")); + } + #[cfg(not(windows))] + assert_eq!(system_config_dir(), PathBuf::from("/etc/nemo-relay")); +} + #[cfg(unix)] #[test] fn test_load_plugin_config_files_deduplicates_aliases_at_highest_precedence() { diff --git a/crates/ffi/tests/integration/plugin_activation_tests.rs b/crates/ffi/tests/integration/plugin_activation_tests.rs index d4f9b8adf..c00b13736 100644 --- a/crates/ffi/tests/integration/plugin_activation_tests.rs +++ b/crates/ffi/tests/integration/plugin_activation_tests.rs @@ -85,12 +85,15 @@ fn run_discovered_config_activation_test() { *DISCOVERED_STATIC_CONFIG.lock().unwrap() = None; let environment = TempDir::new().expect("plugin discovery environment"); - let project_config_dir = environment.path().join(".nemo-relay"); let xdg_config_home = environment.path().join("xdg"); - std::fs::create_dir_all(&project_config_dir).expect("project config directory"); - std::fs::create_dir_all(&xdg_config_home).expect("isolated user config directory"); - let plugins_toml = project_config_dir.join("plugins.toml"); - std::fs::write(&plugins_toml, "invalid = [").expect("write invalid plugin config"); + let user_config_dir = xdg_config_home.join("nemo-relay"); + let project_config_dir = environment.path().join(".nemo-relay"); + std::fs::create_dir_all(&user_config_dir).expect("isolated user config directory"); + std::fs::create_dir_all(&project_config_dir).expect("legacy project config directory"); + let plugins_toml = user_config_dir.join("plugins.toml"); + std::fs::write(project_config_dir.join("plugins.toml"), "invalid = [") + .expect("write ignored project plugin config"); + std::fs::write(&plugins_toml, "invalid = [").expect("write invalid user plugin config"); let _environment = PluginDiscoveryTestEnv::enter(environment.path(), &xdg_config_home); // Empty specifications fail before discovery or ownership. The malformed @@ -110,7 +113,7 @@ kind = {DISCOVERED_STATIC_PLUGIN_KIND:?} enabled = true [components.config] -source = "project-file" +source = "user-file" "# ), ) @@ -202,7 +205,7 @@ fn write_and_assert_discovered_activation(report: &Json, plugins_toml: &Path) { assert_eq!(DISCOVERED_STATIC_REGISTRATIONS.load(Ordering::SeqCst), 1); assert_eq!( DISCOVERED_STATIC_CONFIG.lock().unwrap().as_ref(), - Some(&json!({"source": "project-file"})) + Some(&json!({"source": "user-file"})) ); assert!(plugin_kinds().iter().any(|kind| kind == "fixture_native")); diff --git a/crates/node/tests/dynamic_plugin_tests.mjs b/crates/node/tests/dynamic_plugin_tests.mjs index a321020df..01ceee0f6 100644 --- a/crates/node/tests/dynamic_plugin_tests.mjs +++ b/crates/node/tests/dynamic_plugin_tests.mjs @@ -207,11 +207,10 @@ describe('dynamic plugin host', () => { it('layers plugins.toml static base components with dynamic plugins', async () => { const staticKind = 'node.fixture.static-base'; const projectRoot = path.join(tempRoot, 'file-static-base-project'); - const projectConfigDirectory = path.join(projectRoot, '.nemo-relay'); const isolatedUserConfig = path.join(projectRoot, 'xdg'); - const pluginsToml = path.join(projectConfigDirectory, 'plugins.toml'); - mkdirSync(projectConfigDirectory, { recursive: true }); - mkdirSync(isolatedUserConfig, { recursive: true }); + const userConfigDirectory = path.join(isolatedUserConfig, 'nemo-relay'); + const pluginsToml = path.join(userConfigDirectory, 'plugins.toml'); + mkdirSync(userConfigDirectory, { recursive: true }); writeFileSync( pluginsToml, `version = 1 diff --git a/docs/about-nemo-relay/release-notes/index.mdx b/docs/about-nemo-relay/release-notes/index.mdx index 3f1b3137c..5b7bceb66 100644 --- a/docs/about-nemo-relay/release-notes/index.mdx +++ b/docs/about-nemo-relay/release-notes/index.mdx @@ -40,8 +40,24 @@ supported platforms and architectures, worker runtimes, coding agents, and integrations. It also records current limitations, including platform-specific worker requirements. -Migration guidance for upgrading from 0.7 to 0.8 will be added to the -[Migration Guides](/reference/migration-guides) before the release. +Migration guidance for upgrading from 0.7 to 0.8 is available in the +[Migration Guides](/reference/migration-guides). + +### Breaking Changes + +- Repository-local `.nemo-relay/config.toml`, `plugins.toml`, and + `.dynamic-plugins.json` files are no longer discovered or activated. Runtime + resolution is explicit-or-user followed by higher-precedence system policy. +- Project setup scopes and the `--project` and `config --reset --scope` + interfaces have been removed. Setup and reset now target XDG user + configuration; `--user`, `--global`, `--config`, and + `--plugin-config-path` remain supported. +- `nemo-relay doctor` reports ignored ancestor project configuration as + warning-only migration diagnostics. Relay does not automatically move, + rewrite, or delete those legacy files. + +Refer to [Migration Guides](/reference/migration-guides#move-project-configuration-to-a-supported-location) +for destination paths and explicit-file alternatives. ### Fixed Known Issues in 0.8 diff --git a/docs/configure-plugins/discoverable-plugins.mdx b/docs/configure-plugins/discoverable-plugins.mdx index 3f73c4b28..8860f9d2b 100644 --- a/docs/configure-plugins/discoverable-plugins.mdx +++ b/docs/configure-plugins/discoverable-plugins.mdx @@ -22,11 +22,11 @@ trust. ## Add and Enable a Plugin -Validate the manifest before registering it in project configuration: +Validate the manifest before registering it in user configuration: ```bash nemo-relay plugins validate ./acme-plugin/relay-plugin.toml -nemo-relay plugins add --project ./acme-plugin/relay-plugin.toml +nemo-relay plugins add --user ./acme-plugin/relay-plugin.toml nemo-relay plugins inspect acme.plugin nemo-relay plugins enable acme.plugin nemo-relay plugins validate acme.plugin diff --git a/docs/configure-plugins/model-pricing.mdx b/docs/configure-plugins/model-pricing.mdx index 09244009d..101e96588 100644 --- a/docs/configure-plugins/model-pricing.mdx +++ b/docs/configure-plugins/model-pricing.mdx @@ -56,26 +56,29 @@ 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 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 +When Relay merges explicit-or-user and system configuration files, it prepends +higher-priority `sources` instead of replacing lower-priority sources. The +effective order is system, then explicit-or-user. This lets an enterprise catalog override narrower catalogs while preserving those catalogs as fallbacks. ## Manage Catalog Sources with the CLI -Run the following commands to validate a catalog and add it to project +Run the following commands to validate a catalog and add it to user configuration: ```bash -nemo-relay model-pricing validate /path/to/pricing.json -nemo-relay model-pricing init --project -nemo-relay model-pricing add-source /path/to/pricing.json --project +nemo-relay model-pricing validate +nemo-relay model-pricing init --user +nemo-relay model-pricing add-source --user ``` -Use `--user` for the user configuration file or `--global` for -`/etc/nemo-relay/plugins.toml`. `add-source` places a new source ahead of -existing sources by default. Use `--append` to keep it as a fallback. +Replace `` with the path to your pricing catalog before +running either command. Use `--user` for the user configuration file or +`--global` for system configuration at `/etc/nemo-relay/plugins.toml` on Unix or +`%ProgramData%\nemo-relay\plugins.toml` on Windows. `add-source` places a new +source ahead of existing sources by default. Use `--append` to keep it as a +fallback. ## Verify the Active Configuration diff --git a/docs/configure-plugins/observability/configuration.mdx b/docs/configure-plugins/observability/configuration.mdx index 88b8b61ca..010f91ef9 100644 --- a/docs/configure-plugins/observability/configuration.mdx +++ b/docs/configure-plugins/observability/configuration.mdx @@ -104,7 +104,7 @@ for queue sizing and drop-warning behavior. 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 explicit-or-user, project, system, and programmatic layers can +rule, so explicit-or-user, system, and programmatic layers can contribute destinations. Arbitrary lists nested inside structured values retain replacement semantics. List entries are not merged item by item. diff --git a/docs/configure-plugins/plugin-configuration-files.mdx b/docs/configure-plugins/plugin-configuration-files.mdx index 203360961..930d0b7b7 100644 --- a/docs/configure-plugins/plugin-configuration-files.mdx +++ b/docs/configure-plugins/plugin-configuration-files.mdx @@ -68,12 +68,11 @@ nemo-relay --config path/to/config.toml run -- codex ``` 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 +The system file still layers on top, so ensure system policy 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. For implicit discovery, place the file in the XDG user +configuration directory. Refer to [CLI Basic Usage](/nemo-relay-cli/basic-usage) for the wrapper command shapes. ## What Success Looks Like @@ -219,7 +218,7 @@ File configuration comes from `plugins.toml`: | Source | Use case | |---|---| -| `plugins.toml` | Normal operator- and project-managed runtime plugin configuration. | +| `plugins.toml` | Normal user- and system-managed runtime plugin configuration. | The runtime does not read plugin configuration from `config.toml`. @@ -228,8 +227,7 @@ The runtime does not read plugin configuration from `config.toml`. When the CLI gateway receives `--config path/to/config.toml`, it scopes plugin 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. +replaces the ambient user plugin file; the system file still applies. ### Default Discovery Locations @@ -241,15 +239,14 @@ precedence: 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. System: `/etc/nemo-relay/plugins.toml` +2. System: + - Unix: `/etc/nemo-relay/plugins.toml` + - Windows: `%ProgramData%\nemo-relay\plugins.toml` 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. +initialization continues without process-level plugin activation. +Repository-local `.nemo-relay/plugins.toml` files are not discovered. ## Gateway Editing Files @@ -277,27 +274,22 @@ 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. 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 +`plugins edit --user` keeps the inherited explicit target. Use `--global` to +edit the system layer. 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: ```bash -nemo-relay plugins edit --project nemo-relay plugins edit --global ``` Scope flags are mutually exclusive. -`--project` writes the nearest existing `.nemo-relay/plugins.toml`. If none -exists, it writes next to the nearest `.nemo-relay/config.toml`. If neither file -exists in the parent directories, it writes `./.nemo-relay/plugins.toml` from the -current directory. - -`--global` writes `/etc/nemo-relay/plugins.toml` and usually requires elevated -filesystem permissions. +`--global` writes `/etc/nemo-relay/plugins.toml` on Unix or +`%ProgramData%\nemo-relay\plugins.toml` on Windows and usually requires +elevated filesystem permissions. The editor menus support these controls: @@ -318,8 +310,7 @@ 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. System config overrides project config, and project config -overrides the selected explicit-or-user config. +precedence. System 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 @@ -380,7 +371,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 -retaining lower-precedence project or user pricing sources. +retaining lower-precedence user pricing sources. Lists nested inside arbitrary structured values are not treated as top-level plugin lists; a higher-precedence value replaces those lists. @@ -399,7 +390,7 @@ 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 - (explicit-or-user → project → system), using the + (explicit-or-user → 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 @@ -407,7 +398,7 @@ follows: Programmatic lists participate in the same concatenation rules. For example, a programmatic `config.opentelemetry.endpoints` list appears before endpoints -inherited from system, project, and explicit-or-user files; it does not remove +inherited from system and explicit-or-user files; it does not remove those file entries. When library initialization discovers `plugins.toml` files, Relay emits one @@ -445,7 +436,7 @@ 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 system file disables ATOF even if a project or user file +For example, this system file disables ATOF even if an explicit or user file enables it: ```toml diff --git a/docs/integrate-into-frameworks/provider-response-codecs.mdx b/docs/integrate-into-frameworks/provider-response-codecs.mdx index a2974d6e6..205fc9533 100644 --- a/docs/integrate-into-frameworks/provider-response-codecs.mdx +++ b/docs/integrate-into-frameworks/provider-response-codecs.mdx @@ -84,17 +84,17 @@ the same component config directly through the plugin APIs. Source precedence is deployment controlled: -1. Project or application overrides. -2. User/global device model pricing. -3. Enterprise-managed sources, such as a remotely synced file or a service +1. Explicit application model pricing supplied programmatically. +2. Enterprise-managed system sources, such as a remotely synced file or a service backed by a database. The built-in `pricing` plugin component accepts inline catalogs or JSON catalog files in precedence order. In discovered `plugins.toml` config, system config -loads first, project config loads next, and user config loads last. For the +has higher precedence than the selected explicit-or-user config. For the `pricing` component, higher-priority `sources` are prepended instead of -replacing lower-priority sources, so a user override can win for one model while -enterprise or fleet model pricing remains available for everything else: +replacing lower-priority sources, so a system source wins for a matching model +while explicit-or-user sources remain available as fallbacks. Programmatic +application sources can precede both file layers: ```toml [[components]] @@ -112,6 +112,9 @@ version = 1 entries = [] ``` +On Windows, use the expanded ProgramData path in TOML, for example +`C:\\ProgramData\\nemo-relay\\pricing.json`. + Each catalog entry declares: - `provider` and canonical `model_id`. @@ -132,12 +135,15 @@ plus tests. It should not require adding another Rust `match` arm. Use the CLI to validate catalog files and manage file-backed model pricing sources: ```bash -nemo-relay model-pricing validate /path/to/pricing.json -nemo-relay model-pricing init --project -nemo-relay model-pricing add-source /path/to/pricing.json --project +nemo-relay model-pricing validate +nemo-relay model-pricing init --user +nemo-relay model-pricing add-source --user nemo-relay model-pricing resolve gpt-4o-mini --provider openai --prompt-tokens 1000 --completion-tokens 500 ``` +Replace `` with the path to your pricing catalog before +running the validation or source-management commands. + `model-pricing init` creates or enables the `pricing` plugin component in the selected `plugins.toml`. The initialized component has an empty `sources` list. Use `model-pricing add-source` or an inline config edit to provide model pricing data. @@ -147,13 +153,13 @@ Use `model-pricing add-source` or an inline config edit to provide model pricing file source by default, making it the highest-priority source in that scope. Use `--append` when the file should be a lower-priority fallback. Both commands default to user config at `$XDG_CONFIG_HOME/nemo-relay/plugins.toml`. Pass -`--project` for `.nemo-relay/plugins.toml` or `--global` for -`/etc/nemo-relay/plugins.toml`. +`--global` for `/etc/nemo-relay/plugins.toml` on Unix or +`%ProgramData%\nemo-relay\plugins.toml` on Windows. `model-pricing resolve` uses the same discovered config path as the gateway. It reports the winning catalog source, matched provider/model, and, when token counts are supplied, the estimated total cost. The source line is one of -`file:` or `inline:`, which makes overlapping project/user/fleet +`file:` or `inline:`, which makes overlapping user/fleet entries debuggable. This is a dry diagnostic command. It does not mutate configuration. @@ -215,9 +221,10 @@ entry, Relay omits the estimate instead of guessing. Database-backed or remote model pricing should be implemented as a source that returns a validated `PricingCatalog` snapshot to Relay. Keep database queries, service auth, refresh cadence, and caching outside the LLM response hot path. A -fleet deployment can refresh `/etc/nemo-relay/pricing.json` from an IT-managed -service, or embed a custom Rust `PricingSource` that reads from a database and -installs a `PricingResolver` snapshot during process startup. +fleet deployment can refresh `/etc/nemo-relay/pricing.json` on Unix or the +corresponding `%ProgramData%\nemo-relay\pricing.json` file on Windows from an +IT-managed service, or embed a custom Rust `PricingSource` that reads from a +database and installs a `PricingResolver` snapshot during process startup. External model pricing catalogs should be converted into Relay catalog JSON out-of-band and then loaded through a `file` source, unless the embedding diff --git a/docs/nemo-relay-cli/basic-usage.mdx b/docs/nemo-relay-cli/basic-usage.mdx index 55d9b4e8a..ff5fe5176 100644 --- a/docs/nemo-relay-cli/basic-usage.mdx +++ b/docs/nemo-relay-cli/basic-usage.mdx @@ -144,42 +144,38 @@ install directories, host-specific behavior, and the shared-sidecar lifecycle. ## Shared Configuration 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. +file when supplied or the XDG user file otherwise, and finally the system file. +System config has the highest file-level priority. CLI flags and environment +variables override file config. Repository-local `.nemo-relay/config.toml` +files are ignored unless selected explicitly. ### Interactive Setup Run `nemo-relay config` to set up Relay interactively: -1. Choose whether the base configuration should apply to the current project, - your user account, or both. -2. Select the coding agents that Relay should observe. -3. Review and save the base `config.toml`. -4. Choose whether to continue to the plugin editor. +1. Select the coding agents that Relay should observe. +2. Review and save the user `config.toml`. +3. Choose whether to continue to the plugin editor. The base `config.toml` stores the agent settings. Select **Yes** at the plugin prompt to configure optional Relay components and save them separately in -`plugins.toml`. Project setup uses the project plugin configuration, global -setup uses the user plugin configuration, and `both` continues with the project -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. +`plugins.toml`. Setup writes the XDG user 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-or-user, 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. +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. For an explicit `--config path/to/config.toml` flow, the equivalent command is -`nemo-relay --plugin-config-path path/to/plugins.toml plugins edit`. Otherwise, -use `nemo-relay plugins edit` for user configuration or -`nemo-relay plugins edit --project` for project configuration. +`nemo-relay --config path/to/config.toml plugins edit`. Otherwise, +use `nemo-relay plugins edit` for user configuration. ### Edit Gateway Configuration @@ -190,30 +186,33 @@ controls for gateway limits, provider upstreams, and operational logging: nemo-relay config edit ``` -Use `--project` to edit the nearest `.nemo-relay/config.toml`, or `--global` -to edit `/etc/nemo-relay/config.toml`. The editor creates a missing target only -after you select **Save**, preserves unrelated TOML sections, and lets you -clear a setting to restore normal configuration precedence and defaults. Global -saves are system-readable (`0644` on Unix), so they reject authorization -headers; store credentials in a user config or environment variables instead. +Use `--global` to edit system configuration at +`/etc/nemo-relay/config.toml` on Unix or +`%ProgramData%\nemo-relay\config.toml` on Windows. The editor creates a +missing target only after you select **Save**, preserves unrelated TOML +sections, and lets you clear a setting to restore normal configuration +precedence and defaults. Global 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 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 +`config edit --user` both open it. Use `--global` to edit the system layer. +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. +the default editor and `--user` open that file. Use `--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 -global plugin configuration. +Use `nemo-relay plugins edit --global` for `/etc/nemo-relay/plugins.toml` on +Unix or `%ProgramData%\nemo-relay\plugins.toml` on Windows. Global plugin +configuration is system-readable (`0644` on Unix), so do not store credentials +there. The editor rejects schema-declared secret values in global plugin +configuration. The upstream authorization-header controls show only whether a value is configured and never print it in menus or previews. Prefer @@ -352,13 +351,15 @@ Create a Relay model pricing catalog JSON file: Validate and add the file-backed source: ```bash -nemo-relay model-pricing validate /path/to/pricing.json -nemo-relay model-pricing init --project -nemo-relay model-pricing add-source /path/to/pricing.json --project +nemo-relay model-pricing validate +nemo-relay model-pricing init --user +nemo-relay model-pricing add-source --user ``` -Use `--user` instead of `--project` for a device-wide user config, or -`--global` for `/etc/nemo-relay/plugins.toml`. `model-pricing add-source` +Replace `` with the path to your pricing catalog before +running either command. Use `--global` for `/etc/nemo-relay/plugins.toml` on +Unix or +`%ProgramData%\nemo-relay\plugins.toml` on Windows. `model-pricing add-source` prepends the source by default, so the new file becomes the highest-priority source for that scope. Use `--append` to add it as a lower-priority fallback. @@ -373,7 +374,7 @@ nemo-relay model-pricing resolve gpt-4o-mini \ `model-pricing resolve` prints the source that won, the matched provider/model, and an estimated total when token counts are supplied. Use it to debug -overlapping fleet, project, and user model pricing files. +overlapping system, explicit, and user model pricing sources. Run doctor to validate the active model pricing sources alongside exporter checks: diff --git a/docs/nemo-relay-cli/claude-code.mdx b/docs/nemo-relay-cli/claude-code.mdx index b65cfefda..de90b43a2 100644 --- a/docs/nemo-relay-cli/claude-code.mdx +++ b/docs/nemo-relay-cli/claude-code.mdx @@ -96,7 +96,7 @@ install directories, rollback behavior, and source marketplace notes. ## Shared Config -Create `.nemo-relay/config.toml` for project defaults or +Create `$XDG_CONFIG_HOME/nemo-relay/config.toml` or `~/.config/nemo-relay/config.toml` for user defaults: ```toml @@ -111,8 +111,8 @@ To forward Claude Code requests to a custom provider host, configure Relay's endpoint and authentication as described in [Provider Upstreams](/nemo-relay-cli/basic-usage#provider-upstreams). -Then configure observability with `nemo-relay plugins edit --project` or -`.nemo-relay/plugins.toml`: +Then configure observability with `nemo-relay plugins edit` or the XDG user +`plugins.toml`: ```toml version = 1 @@ -137,7 +137,7 @@ endpoint = "http://127.0.0.1:4318/v1/traces" ``` Run `nemo-relay run --agent claude` to use the configured command and plugin -config. Files layer from explicit-or-user to project to system, so system +config. Files layer from explicit-or-user to system, so system configuration has the highest file-level priority. ## Standalone Gateway diff --git a/docs/nemo-relay-cli/codex.mdx b/docs/nemo-relay-cli/codex.mdx index f0725dec3..2b774761c 100644 --- a/docs/nemo-relay-cli/codex.mdx +++ b/docs/nemo-relay-cli/codex.mdx @@ -171,8 +171,10 @@ process-tree cleanup guarantees. Persistent plugin mode loads system and user Relay configuration only. It does not load a project's `.nemo-relay` files. The sidecar starts in the user Relay -configuration directory, so relative exporter paths are deterministic. Use a -transparent `nemo-relay run` when project-specific configuration is required. +configuration directory, so relative exporter paths are deterministic. When +configuration belongs with a repository, use `nemo-relay --config +path/to/config.toml run`; Relay selects its sibling `plugins.toml` +automatically. The generated MCP entry forwards variable names, never values, for provider credentials, Relay runtime settings, common OpenTelemetry, AWS, proxy, and @@ -236,7 +238,8 @@ marketplace notes. ## Configure Transparent Runs -Create `.nemo-relay/config.toml` for project defaults: +Create `$XDG_CONFIG_HOME/nemo-relay/config.toml` (or +`~/.config/nemo-relay/config.toml`) for user defaults: ```toml [upstream] @@ -246,8 +249,8 @@ openai_base_url = "https://api.openai.com/v1" command = "codex" ``` -Then configure observability with `nemo-relay plugins edit --project` or -`.nemo-relay/plugins.toml`: +Then configure observability with `nemo-relay plugins edit` or the XDG user +`plugins.toml`: ```toml version = 1 @@ -261,7 +264,7 @@ enabled = true output_directory = ".nemo-relay/atif" ``` -Run `nemo-relay run --agent codex` to use project-specific configuration. The +Run `nemo-relay run --agent codex` to use the configuration. The ATIF files from this example are written under the project at `.nemo-relay/atif`. @@ -269,8 +272,8 @@ ATIF files from this example are written under the project at Create `~/.config/nemo-relay/config.toml`, or `$XDG_CONFIG_HOME/nemo-relay/config.toml` when `XDG_CONFIG_HOME` is set, for -persistent provider defaults. Use `nemo-relay plugins edit` without -`--project` to write user-scoped observability configuration. For example, set +persistent provider defaults. Use `nemo-relay plugins edit` to write +user-scoped observability configuration. For example, set the ATIF output directory to `atif`: ```toml @@ -285,8 +288,8 @@ enabled = true output_directory = "atif" ``` -The persistent sidecar deliberately ignores project layers, merges only system -and user configuration, and starts in the user Relay configuration directory. +The persistent sidecar uses the same system-over-user configuration model and +starts in the user Relay configuration directory. The relative output directory in this example resolves to `$XDG_CONFIG_HOME/nemo-relay/atif`, or `~/.config/nemo-relay/atif` when `XDG_CONFIG_HOME` is not set. diff --git a/docs/nemo-relay-cli/hermes.mdx b/docs/nemo-relay-cli/hermes.mdx index f600afa78..61ba2d1da 100644 --- a/docs/nemo-relay-cli/hermes.mdx +++ b/docs/nemo-relay-cli/hermes.mdx @@ -43,8 +43,7 @@ NeMo Relay checks the installed Hermes CLI before it changes any files. Relay preserves unrelated Hermes settings and updates the Relay-owned portions of the user configuration. Hermes reads this configuration from `$HERMES_HOME/config.yaml`, or `~/.hermes/config.yaml` when `HERMES_HOME` is -unset. This location is user-owned even when you choose project-scoped Relay -configuration. +unset. This location is user-owned, like Relay's default configuration. The MCP server name `nemo-relay` is reserved for the Relay-managed entry. If that name already belongs to another command, installation stops without diff --git a/docs/reference/migration-guides.mdx b/docs/reference/migration-guides.mdx index 52207b34f..faeadf832 100644 --- a/docs/reference/migration-guides.mdx +++ b/docs/reference/migration-guides.mdx @@ -13,7 +13,40 @@ intervening release in sequence. ## Upgrade to NeMo Relay 0.8 -Migration guidance will be added before the 0.8 release. +### Move Project Configuration to a Supported Location + +NeMo Relay 0.8 no longer discovers repository-local configuration. Files named +`.nemo-relay/config.toml`, `.nemo-relay/plugins.toml`, and +`.nemo-relay/.dynamic-plugins.json` do not affect normal commands, plugin +activation, or dynamic-plugin lifecycle state. + +Move settings that should apply to your account into these files: + +- `$XDG_CONFIG_HOME/nemo-relay/config.toml` and `plugins.toml` +- `~/.config/nemo-relay/config.toml` and `plugins.toml` when + `XDG_CONFIG_HOME` is not set + +Use `/etc/nemo-relay/config.toml` and `/etc/nemo-relay/plugins.toml` on Unix, +or `%ProgramData%\nemo-relay\config.toml` and +`%ProgramData%\nemo-relay\plugins.toml` on Windows, for system policy. System +configuration has higher precedence than the selected user or explicit +configuration. + +For a deliberately selected file in any location, pass `--config` or +`--plugin-config-path`. A `plugins.toml` beside an explicit `config.toml` is +still selected automatically. Relay does not move, rewrite, or delete legacy +project files. Run `nemo-relay doctor` to find ignored files in the current +directory's ancestors. + +Remove `--project` from config, plugin, and model-pricing commands. The +interactive `nemo-relay config` workflow now writes user configuration only, +and `config --reset` resets that user file. The former setup project/both +choices and `config --reset --scope` option have been removed. `--user` and +`--global` remain available. + +Local output directories such as `.nemo-relay/atof`, `.nemo-relay/atif`, and +logs remain supported. Only configuration discovery and dynamic-plugin +lifecycle state lose project semantics. ## Related Release Information diff --git a/docs/reference/operational-logging.mdx b/docs/reference/operational-logging.mdx index c3912f29f..bc3755a7b 100644 --- a/docs/reference/operational-logging.mdx +++ b/docs/reference/operational-logging.mdx @@ -134,7 +134,7 @@ 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 +order: system, 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. diff --git a/go/nemo_relay/plugin_activation_test.go b/go/nemo_relay/plugin_activation_test.go index 67ce0089f..7387cc13e 100644 --- a/go/nemo_relay/plugin_activation_test.go +++ b/go/nemo_relay/plugin_activation_test.go @@ -586,7 +586,7 @@ func TestInitializeWithDynamicPluginsLoadsNativePluginThroughCgo(t *testing.T) { } library := goNativePluginFixture(t) manifest := writeGoNativePluginManifest(t, library) - pluginsTOML := configureNativePluginProject(t) + pluginsTOML := configureNativePluginUserConfig(t) staticRegistrations, staticCallbacks := registerStaticFixturePlugin(t) activation, report, err := InitializeWithDynamicPlugins(NewPluginConfig(), []DynamicPluginActivationSpec{{ @@ -621,14 +621,68 @@ func TestInitializeWithDynamicPluginsLoadsNativePluginThroughCgo(t *testing.T) { assertMissingNativePluginFails(t) } -func configureNativePluginProject(t *testing.T) string { +func TestInitializeWithDynamicPluginsIgnoresProjectPluginConfig(t *testing.T) { + if err := ClearPluginConfiguration(); err != nil { + t.Fatalf(clearConfigurationErrorFmt, err) + } + library := goNativePluginFixture(t) + manifest := writeGoNativePluginManifest(t, library) + configureNativePluginProjectConfig(t) + staticRegistrations, staticCallbacks := registerStaticFixturePlugin(t) + + activation, report, err := InitializeWithDynamicPlugins(NewPluginConfig(), []DynamicPluginActivationSpec{{ + PluginID: "fixture_native", + Kind: DynamicPluginKindRustDynamic, + ManifestRef: manifest, + Config: map[string]any{}, + }}) + if err != nil { + t.Fatalf(initializePluginsErrorFmt, err) + } + defer func() { + if err := activation.Close(); err != nil { + t.Errorf("deferred Close() error = %v", err) + } + }() + if len(report.Diagnostics) != 0 { + t.Fatalf("activation diagnostics = %#v, want none", report.Diagnostics) + } + if staticRegistrations.Load() != 0 { + t.Fatalf("static registrations = %d, want 0", staticRegistrations.Load()) + } + + transformed, err := ToolRequestIntercepts("go-native-tool", json.RawMessage(`{"input":true}`)) + if err != nil { + t.Fatalf("ToolRequestIntercepts() error = %v", err) + } + var transformedObject map[string]any + if err := json.Unmarshal(transformed, &transformedObject); err != nil { + t.Fatalf("transformed tool args are invalid JSON: %v", err) + } + if transformedObject["native_plugin"] != true || transformedObject["go_static_base"] != nil { + t.Fatalf("transformed tool args = %s, want only native-plugin marker", transformed) + } + if staticCallbacks.Load() != 0 { + t.Fatalf("static callbacks = %d, want 0", staticCallbacks.Load()) + } +} + +func configureNativePluginUserConfig(t *testing.T) string { t.Helper() projectDir := t.TempDir() - projectConfigDir := filepath.Join(projectDir, ".nemo-relay") - if err := os.MkdirAll(projectConfigDir, 0o700); err != nil { - t.Fatalf("MkdirAll(project config) error = %v", err) + legacyPluginsTOML := filepath.Join(projectDir, ".nemo-relay", "plugins.toml") + if err := os.MkdirAll(filepath.Dir(legacyPluginsTOML), 0o700); err != nil { + t.Fatalf("MkdirAll(legacy project config) error = %v", err) + } + if err := os.WriteFile(legacyPluginsTOML, []byte("components = ["), 0o600); err != nil { + t.Fatalf("WriteFile(legacy project plugins.toml) error = %v", err) + } + xdgConfigHome := filepath.Join(projectDir, "xdg") + userConfigDir := filepath.Join(xdgConfigHome, "nemo-relay") + if err := os.MkdirAll(userConfigDir, 0o700); err != nil { + t.Fatalf("MkdirAll(user config) error = %v", err) } - pluginsTOML := filepath.Join(projectConfigDir, "plugins.toml") + pluginsTOML := filepath.Join(userConfigDir, "plugins.toml") const staticKind = "go.fixture.static_base" fileConfig := fmt.Sprintf(`version = 1 @@ -637,7 +691,7 @@ kind = %q enabled = true [components.config] -source = "project-file" +source = "user-file" `, staticKind) if err := os.WriteFile(pluginsTOML, []byte(fileConfig), 0o600); err != nil { t.Fatalf("WriteFile(plugins.toml) error = %v", err) @@ -654,10 +708,49 @@ source = "project-file" t.Errorf("restore working directory error = %v", err) } }) - t.Setenv("XDG_CONFIG_HOME", filepath.Join(projectDir, "xdg")) + t.Setenv("XDG_CONFIG_HOME", xdgConfigHome) return pluginsTOML } +func configureNativePluginProjectConfig(t *testing.T) { + t.Helper() + projectDir := t.TempDir() + projectPluginsTOML := filepath.Join(projectDir, ".nemo-relay", "plugins.toml") + if err := os.MkdirAll(filepath.Dir(projectPluginsTOML), 0o700); err != nil { + t.Fatalf("MkdirAll(project config) error = %v", err) + } + const staticKind = "go.fixture.static_base" + projectConfig := fmt.Sprintf(`version = 1 + +[[components]] +kind = %q +enabled = true + +[components.config] +source = "project-file" +`, staticKind) + if err := os.WriteFile(projectPluginsTOML, []byte(projectConfig), 0o600); err != nil { + t.Fatalf("WriteFile(project plugins.toml) error = %v", err) + } + xdgConfigHome := filepath.Join(projectDir, "xdg") + if err := os.MkdirAll(xdgConfigHome, 0o700); err != nil { + t.Fatalf("MkdirAll(XDG config home) error = %v", err) + } + previousCWD, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd() error = %v", err) + } + if err := os.Chdir(projectDir); err != nil { + t.Fatalf("Chdir(project) error = %v", err) + } + t.Cleanup(func() { + if err := os.Chdir(previousCWD); err != nil { + t.Errorf("restore working directory error = %v", err) + } + }) + t.Setenv("XDG_CONFIG_HOME", xdgConfigHome) +} + func registerStaticFixturePlugin(t *testing.T) (*atomic.Int32, *atomic.Int32) { t.Helper() const staticKind = "go.fixture.static_base" @@ -665,8 +758,8 @@ func registerStaticFixturePlugin(t *testing.T) (*atomic.Int32, *atomic.Int32) { staticCallbacks := &atomic.Int32{} if err := RegisterPlugin(staticKind, PluginFuncs{ RegisterFunc: func(config map[string]any, ctx *PluginContext) error { - if config["source"] != "project-file" { - return fmt.Errorf("static plugin config = %#v, want project-file source", config) + if config["source"] != "user-file" { + return fmt.Errorf("static plugin config = %#v, want user-file source", config) } staticRegistrations.Add(1) return ctx.RegisterToolRequestIntercept( diff --git a/integrations/coding-agents/README.md b/integrations/coding-agents/README.md index 0ef4d79e9..cab978b4c 100644 --- a/integrations/coding-agents/README.md +++ b/integrations/coding-agents/README.md @@ -195,13 +195,10 @@ It writes the MCP server and trusted hooks to `$HERMES_HOME/config.yaml` or export the dynamic `NEMO_RELAY_GATEWAY_URL` through a process-private `HERMES_HOME` overlay with no fixed MCP entry. -Shared TOML config is loaded from `/etc/nemo-relay/config.toml`, then nearest -project `.nemo-relay/config.toml`, then -`$XDG_CONFIG_HOME/nemo-relay/config.toml` or -`~/.config/nemo-relay/config.toml`. - -That layering applies to transparent runs. Persistent mode skips the -project layer and merges only system and user configuration. +Shared TOML config uses the XDG user file (or an explicit file) and then the +system file, with the system file at higher precedence. The system path is +`/etc/nemo-relay/config.toml` on Unix or +`%ProgramData%\nemo-relay\config.toml` on Windows. ```toml [agents.codex] @@ -212,8 +209,7 @@ command = "hermes" ``` Observability exporters are configured in `plugins.toml`. Run -`nemo-relay plugins edit --project` to create `.nemo-relay/plugins.toml`, or -write the plugin config directly: +`nemo-relay plugins edit` to create the XDG user file, or write it directly: ```toml version = 1 diff --git a/integrations/coding-agents/claude-code/README.md b/integrations/coding-agents/claude-code/README.md index 0ae25300e..516e32b10 100644 --- a/integrations/coding-agents/claude-code/README.md +++ b/integrations/coding-agents/claude-code/README.md @@ -77,7 +77,7 @@ nemo-relay run \ ## Shared Config -Use `.nemo-relay/config.toml` for project defaults or +Use `$XDG_CONFIG_HOME/nemo-relay/config.toml` or `~/.config/nemo-relay/config.toml` for user defaults: ```toml @@ -85,8 +85,8 @@ Use `.nemo-relay/config.toml` for project defaults or command = "claude" ``` -Configure observability with `nemo-relay plugins edit --project` or -`.nemo-relay/plugins.toml`: +Configure observability with `nemo-relay plugins edit` or the XDG user +`plugins.toml`: ```toml version = 1 diff --git a/integrations/coding-agents/codex/README.md b/integrations/coding-agents/codex/README.md index ce88420fc..cfedb0006 100644 --- a/integrations/coding-agents/codex/README.md +++ b/integrations/coding-agents/codex/README.md @@ -163,15 +163,16 @@ nemo-relay run \ ## Configure Transparent Runs -Use `.nemo-relay/config.toml` for project defaults: +Use `$XDG_CONFIG_HOME/nemo-relay/config.toml` or +`~/.config/nemo-relay/config.toml` for user defaults: ```toml [agents.codex] command = "codex" ``` -Configure observability with `nemo-relay plugins edit --project` or -`.nemo-relay/plugins.toml`: +Configure observability with `nemo-relay plugins edit` or the XDG user +`plugins.toml`: ```toml version = 1 @@ -197,8 +198,8 @@ This example writes ATIF files under the project at `.nemo-relay/atif`. Use `~/.config/nemo-relay/config.toml`, or `$XDG_CONFIG_HOME/nemo-relay/config.toml` when `XDG_CONFIG_HOME` is set, for -persistent provider defaults. Run `nemo-relay plugins edit` without -`--project` to write user-scoped observability configuration. For example: +persistent provider defaults. Run `nemo-relay plugins edit` to write +user-scoped observability configuration. For example: ```toml version = 1 @@ -212,8 +213,8 @@ enabled = true output_directory = "atif" ``` -Persistent mode ignores project layers and starts the sidecar in the user Relay -configuration directory. The relative path above resolves to +Persistent mode starts the sidecar in the user Relay configuration directory. +The relative path above resolves to `$XDG_CONFIG_HOME/nemo-relay/atif`, or `~/.config/nemo-relay/atif` when `XDG_CONFIG_HOME` is not set. diff --git a/python/tests/test_dynamic_plugin_host.py b/python/tests/test_dynamic_plugin_host.py index f06673032..92ab7806a 100644 --- a/python/tests/test_dynamic_plugin_host.py +++ b/python/tests/test_dynamic_plugin_host.py @@ -490,9 +490,10 @@ def register(self, _plugin_config, context): lambda _name, args: {**args, "file_static_base": True}, ) - project_config = tmp_path / ".nemo-relay" - project_config.mkdir() - plugins_toml = project_config / "plugins.toml" + isolated_user_config = tmp_path / "xdg" + user_config = isolated_user_config / "nemo-relay" + user_config.mkdir(parents=True) + plugins_toml = user_config / "plugins.toml" plugins_toml.write_text( textwrap.dedent( f""" @@ -507,8 +508,6 @@ def register(self, _plugin_config, context): """ ) ) - isolated_user_config = tmp_path / "xdg" - isolated_user_config.mkdir() monkeypatch.chdir(tmp_path) monkeypatch.setenv("XDG_CONFIG_HOME", str(isolated_user_config))