diff --git a/crates/cli/README.md b/crates/cli/README.md index 443d3003d..e11a05ca6 100644 --- a/crates/cli/README.md +++ b/crates/cli/README.md @@ -130,12 +130,24 @@ Project config lives at `./.nemo-relay/config.toml`; user config lives at The project layer overrides system config, and the user layer overrides the project layer. -General options are configured through the top-level config. Edit the config with: +Set up agent entries in the top-level config with: ```bash nemo-relay config ``` +Edit gateway limits, provider upstreams, and operational logging with the +structured user-config editor: + +```bash +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. + Observability exporters are configured through the plugin config. Edit the user plugin config with: @@ -151,7 +163,10 @@ 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`. +`.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. Minimal ATIF example: diff --git a/crates/cli/src/commands/configure/editor.rs b/crates/cli/src/commands/configure/editor.rs new file mode 100644 index 000000000..410966df5 --- /dev/null +++ b/crates/cli/src/commands/configure/editor.rs @@ -0,0 +1,991 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Interactive editor for the non-agent sections of Relay's `config.toml`. + +use std::io::IsTerminal; +use std::path::{Path, PathBuf}; + +use dialoguer::theme::ColorfulTheme; +use dialoguer::{Input, Password, Select}; +use nemo_relay::logging::MAX_FILE_SINK_QUEUE_ENTRIES; +use toml_edit::{ArrayOfTables, DocumentMut, Item, Table, Value, value}; + +use super::ConfigEditCommand; +use crate::error::CliError; + +const EDIT_CANCELLED_MESSAGE: &str = "configuration edit cancelled — no config saved"; +const LOG_LEVELS: &[&str] = &["error", "warn", "info", "debug", "trace"]; +const LOG_FORMATS: &[&str] = &["human", "jsonl"]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TargetScope { + User, + Project, + Global, +} + +impl From<&ConfigEditCommand> for TargetScope { + fn from(command: &ConfigEditCommand) -> Self { + if command.project { + Self::Project + } else if command.global { + Self::Global + } else { + Self::User + } + } +} + +pub(super) fn edit(command: ConfigEditCommand) -> Result<(), CliError> { + ensure_tty()?; + let scope = TargetScope::from(&command); + let path = target_path(scope)?; + let mut document = ConfigDocument::read(path)?; + let theme = ColorfulTheme::default(); + + crate::banner::print_intro(); + println!(" Editing config at {}", document.path().display()); + println!(" Secrets are never displayed. Choose Save to write changes."); + println!(); + + loop { + let choices = [ + format!("Gateway limits ({})", document.gateway_summary()), + format!("Provider upstreams ({})", document.upstream_summary()), + format!("Operational logging ({})", document.logging_summary()), + "Preview".into(), + "Save".into(), + "Cancel".into(), + ]; + match select(&theme, "config.toml", &choices)? { + 0 => edit_gateway(&theme, &mut document)?, + 1 => edit_upstream(&theme, &mut document)?, + 2 => edit_logging(&theme, &mut document)?, + 3 => print_preview(&document), + 4 => { + document.write(scope)?; + println!(" ✓ Saved {}", document.path().display()); + return Ok(()); + } + 5 => return Err(CliError::Config(EDIT_CANCELLED_MESSAGE.into())), + _ => unreachable!("select returns an in-range index"), + } + } +} + +fn ensure_tty() -> Result<(), CliError> { + ensure_tty_with(std::io::stdin().is_terminal()) +} + +fn ensure_tty_with(stdin_is_terminal: bool) -> Result<(), CliError> { + if stdin_is_terminal { + Ok(()) + } else { + Err(CliError::Config( + "interactive configuration editing requires a TTY".into(), + )) + } +} + +fn select(theme: &ColorfulTheme, prompt: &str, choices: &[String]) -> Result { + Select::with_theme(theme) + .with_prompt(prompt) + .items(choices) + .default(0) + .interact() + .map_err(prompt_error) +} + +fn choose_action(theme: &ColorfulTheme, configured: bool) -> Result { + let choices = if configured { + vec!["Set or replace".into(), "Clear".into(), "Back".into()] + } else { + vec!["Set".into(), "Back".into()] + }; + select(theme, "Action", &choices) +} + +fn edit_gateway(theme: &ColorfulTheme, document: &mut ConfigDocument) -> Result<(), CliError> { + loop { + let choices = [ + format!( + "Maximum hook payload bytes: {}", + document.integer_summary("gateway", "max_hook_payload_bytes") + ), + format!( + "Maximum passthrough body bytes: {}", + document.integer_summary("gateway", "max_passthrough_body_bytes") + ), + "Back".into(), + ]; + match select(theme, "Gateway limits", &choices)? { + 0 => edit_positive_integer(theme, document, "gateway", "max_hook_payload_bytes")?, + 1 => edit_positive_integer(theme, document, "gateway", "max_passthrough_body_bytes")?, + 2 => return Ok(()), + _ => unreachable!(), + } + } +} + +fn edit_upstream(theme: &ColorfulTheme, document: &mut ConfigDocument) -> Result<(), CliError> { + loop { + let choices = [ + format!( + "OpenAI base URL: {}", + document.string_summary("upstream", "openai_base_url") + ), + format!( + "OpenAI authorization header: {}", + document.secret_summary("openai_auth_header") + ), + format!( + "Anthropic base URL: {}", + document.string_summary("upstream", "anthropic_base_url") + ), + format!( + "Anthropic authorization header: {}", + document.secret_summary("anthropic_auth_header") + ), + "Back".into(), + ]; + match select(theme, "Provider upstreams", &choices)? { + 0 => edit_string(theme, document, "upstream", "openai_base_url")?, + 1 => edit_secret(theme, document, "openai_auth_header")?, + 2 => edit_string(theme, document, "upstream", "anthropic_base_url")?, + 3 => edit_secret(theme, document, "anthropic_auth_header")?, + 4 => return Ok(()), + _ => unreachable!(), + } + } +} + +fn edit_logging(theme: &ColorfulTheme, document: &mut ConfigDocument) -> Result<(), CliError> { + loop { + let choices = [ + format!("Level: {}", document.string_summary("logging", "level")), + format!( + "Stderr format: {}", + document.string_summary("logging", "stderr_format") + ), + format!( + "Flush interval (ms): {}", + document.integer_summary("logging", "flush_interval_millis") + ), + format!("File sinks ({})", document.sink_count()), + "Back".into(), + ]; + match select(theme, "Operational logging", &choices)? { + 0 => edit_enum(theme, document, "logging", "level", LOG_LEVELS)?, + 1 => edit_enum(theme, document, "logging", "stderr_format", LOG_FORMATS)?, + 2 => edit_nonnegative_integer(theme, document, "logging", "flush_interval_millis")?, + 3 => edit_sinks(theme, document)?, + 4 => return Ok(()), + _ => unreachable!(), + } + } +} + +fn edit_positive_integer( + theme: &ColorfulTheme, + document: &mut ConfigDocument, + section: &str, + key: &str, +) -> Result<(), CliError> { + let configured = document.has_key(section, key); + match choose_action(theme, configured)? { + 0 => { + let value = prompt_u64(theme, "Value in bytes", document.integer(section, key))?; + document.set_positive_integer(section, key, value)?; + } + 1 if configured => document.clear_key(section, key)?, + _ => {} + } + Ok(()) +} + +fn edit_nonnegative_integer( + theme: &ColorfulTheme, + document: &mut ConfigDocument, + section: &str, + key: &str, +) -> Result<(), CliError> { + let configured = document.has_key(section, key); + match choose_action(theme, configured)? { + 0 => { + let value = prompt_u64( + theme, + "Milliseconds (0 flushes on shutdown)", + document.integer(section, key), + )?; + document.set_integer(section, key, value)?; + } + 1 if configured => document.clear_key(section, key)?, + _ => {} + } + Ok(()) +} + +fn edit_string( + theme: &ColorfulTheme, + document: &mut ConfigDocument, + section: &str, + key: &str, +) -> Result<(), CliError> { + let configured = document.has_key(section, key); + match choose_action(theme, configured)? { + 0 => { + let default = document.string(section, key).unwrap_or_default(); + let value = Input::::with_theme(theme) + .with_prompt("Value") + .with_initial_text(default) + .validate_with(|value: &String| { + if value.trim().is_empty() { + Err("value must not be empty; use Clear to remove it") + } else { + Ok(()) + } + }) + .interact_text() + .map_err(prompt_error)?; + document.set_string(section, key, value)?; + } + 1 if configured => document.clear_key(section, key)?, + _ => {} + } + Ok(()) +} + +fn edit_secret( + theme: &ColorfulTheme, + document: &mut ConfigDocument, + key: &str, +) -> Result<(), CliError> { + let configured = document.has_key("upstream", key); + match choose_action(theme, configured)? { + 0 => { + let value = Password::with_theme(theme) + .with_prompt("Authorization header value") + .allow_empty_password(false) + .interact() + .map_err(prompt_error)?; + document.set_auth_header(key, value)?; + } + 1 if configured => document.clear_key("upstream", key)?, + _ => {} + } + Ok(()) +} + +fn edit_enum( + theme: &ColorfulTheme, + document: &mut ConfigDocument, + section: &str, + key: &str, + values: &[&str], +) -> Result<(), CliError> { + let configured = document.has_key(section, key); + match choose_action(theme, configured)? { + 0 => { + let current = document.string(section, key); + let default = current + .as_deref() + .and_then(|current| values.iter().position(|value| *value == current)) + .unwrap_or(0); + let selected = Select::with_theme(theme) + .with_prompt("Value") + .items(values) + .default(default) + .interact() + .map_err(prompt_error)?; + document.set_enum(section, key, values[selected], values)?; + } + 1 if configured => document.clear_key(section, key)?, + _ => {} + } + Ok(()) +} + +fn edit_sinks(theme: &ColorfulTheme, document: &mut ConfigDocument) -> Result<(), CliError> { + loop { + let mut choices = document + .sink_labels() + .into_iter() + .map(|label| format!("Edit {label}")) + .collect::>(); + let sink_count = choices.len(); + choices.push("Add file sink".into()); + choices.push("Back".into()); + match select(theme, "File sinks", &choices)? { + index if index < sink_count => edit_sink(theme, document, index)?, + index if index == sink_count => { + let path = Input::::with_theme(theme) + .with_prompt("File path") + .validate_with(|value: &String| { + if value.trim().is_empty() { + Err("value must not be empty".to_owned()) + } else { + Ok(()) + } + }) + .interact_text() + .map_err(prompt_error)?; + document.add_sink(path)?; + } + _ => return Ok(()), + } + } +} + +fn edit_sink( + theme: &ColorfulTheme, + document: &mut ConfigDocument, + index: usize, +) -> Result<(), CliError> { + loop { + let choices = [ + format!("Path: {}", document.sink_string_summary(index, "path")), + format!("Level: {}", document.sink_string_summary(index, "level")), + format!("Format: {}", document.sink_string_summary(index, "format")), + format!( + "Queue capacity: {}", + document.sink_integer_summary(index, "queue_capacity") + ), + format!("Rotation: {}", document.sink_rotation_summary(index)), + "Remove sink".into(), + "Back".into(), + ]; + match select(theme, "File sink", &choices)? { + 0 => edit_sink_path(theme, document, index)?, + 1 => edit_sink_enum(theme, document, index, "level", LOG_LEVELS)?, + 2 => edit_sink_enum(theme, document, index, "format", LOG_FORMATS)?, + 3 => edit_sink_queue_capacity(theme, document, index)?, + 4 => edit_sink_rotation(theme, document, index)?, + 5 => { + document.remove_sink(index)?; + return Ok(()); + } + _ => return Ok(()), + } + } +} + +fn edit_sink_path( + theme: &ColorfulTheme, + document: &mut ConfigDocument, + index: usize, +) -> Result<(), CliError> { + let current = document.sink_string(index, "path").unwrap_or_default(); + let value = Input::::with_theme(theme) + .with_prompt("File path") + .with_initial_text(current) + .validate_with(|value: &String| { + if value.trim().is_empty() { + Err("value must not be empty".to_owned()) + } else { + Ok(()) + } + }) + .interact_text() + .map_err(prompt_error)?; + document.set_sink_string(index, "path", value) +} + +fn edit_sink_enum( + theme: &ColorfulTheme, + document: &mut ConfigDocument, + index: usize, + key: &str, + values: &[&str], +) -> Result<(), CliError> { + let configured = document.sink_has_key(index, key)?; + match choose_action(theme, configured)? { + 0 => { + let default = document + .sink_string(index, key) + .as_deref() + .and_then(|current| values.iter().position(|value| *value == current)) + .unwrap_or(0); + let selected = Select::with_theme(theme) + .with_prompt("Value") + .items(values) + .default(default) + .interact() + .map_err(prompt_error)?; + document.set_sink_enum(index, key, values[selected], values)?; + } + 1 if configured => document.clear_sink_key(index, key)?, + _ => {} + } + Ok(()) +} + +fn edit_sink_queue_capacity( + theme: &ColorfulTheme, + document: &mut ConfigDocument, + index: usize, +) -> Result<(), CliError> { + let configured = document.sink_has_key(index, "queue_capacity")?; + match choose_action(theme, configured)? { + 0 => { + let value = prompt_u64( + theme, + "Queue entries", + document.sink_integer(index, "queue_capacity"), + )?; + document.set_sink_queue_capacity(index, value)?; + } + 1 if configured => document.clear_sink_key(index, "queue_capacity")?, + _ => {} + } + Ok(()) +} + +fn edit_sink_rotation( + theme: &ColorfulTheme, + document: &mut ConfigDocument, + index: usize, +) -> Result<(), CliError> { + let configured = document.sink_has_key(index, "max_file_size_bytes")? + || document.sink_has_key(index, "retained_files")?; + match choose_action(theme, configured)? { + 0 => { + let size = prompt_u64( + theme, + "Maximum file size in bytes", + document.sink_integer(index, "max_file_size_bytes"), + )?; + let retained = prompt_u64( + theme, + "Retained backup files", + document.sink_integer(index, "retained_files"), + )?; + document.set_sink_rotation(index, size, retained)?; + } + 1 if configured => document.clear_sink_rotation(index)?, + _ => {} + } + Ok(()) +} + +fn prompt_u64(theme: &ColorfulTheme, prompt: &str, current: Option) -> Result { + let mut input = Input::::with_theme(theme).with_prompt(prompt); + if let Some(current) = current { + input = input.with_initial_text(current.to_string()); + } + input.interact_text().map_err(prompt_error) +} + +fn prompt_error(error: dialoguer::Error) -> CliError { + CliError::Config(format!("configuration edit error: {error}")) +} + +fn print_preview(document: &ConfigDocument) { + println!(); + println!(" ─── Preview ─────────────────────────────────────────────"); + for line in document.preview().lines() { + println!(" {line}"); + } + println!(); +} + +struct ConfigDocument { + path: PathBuf, + document: DocumentMut, +} + +impl ConfigDocument { + fn read(path: PathBuf) -> Result { + let document = if path.exists() { + std::fs::read_to_string(&path)?.parse().map_err(|error| { + CliError::Config(format!("invalid TOML in {}: {error}", path.display())) + })? + } else { + DocumentMut::new() + }; + Ok(Self { path, document }) + } + + fn path(&self) -> &Path { + &self.path + } + + fn write(&self, scope: TargetScope) -> Result<(), CliError> { + let contents = self.document.to_string(); + match scope { + TargetScope::Global => { + if self.has_auth_headers() { + return Err(CliError::Config( + "global config cannot include upstream authorization headers; configure credentials in a user config".into(), + )); + } + crate::filesystem::atomic_write_system_readable(&self.path, contents.as_bytes()) + } + TargetScope::User | TargetScope::Project => { + crate::filesystem::atomic_write_private(&self.path, contents.as_bytes()) + } + } + .map_err(CliError::Config) + } + + fn has_auth_headers(&self) -> bool { + ["openai_auth_header", "anthropic_auth_header"] + .into_iter() + .any(|key| self.has_key("upstream", key)) + } + + fn preview(&self) -> String { + let mut document = self.document.clone(); + if let Some(upstream) = document.get_mut("upstream") { + for key in ["openai_auth_header", "anthropic_auth_header"] { + if let Some(table) = upstream.as_table_mut() { + if table.contains_key(key) { + table[key] = value(""); + } + } else if let Some(inline) = + upstream.as_value_mut().and_then(Value::as_inline_table_mut) + && inline.contains_key(key) + { + inline.insert(key, Value::from("")); + } + } + } + document.to_string() + } + + fn item(&self, section: &str, key: &str) -> Option<&Item> { + self.document.get(section)?.as_table()?.get(key) + } + + fn has_key(&self, section: &str, key: &str) -> bool { + self.item(section, key).is_some() + || self + .document + .get(section) + .and_then(Item::as_value) + .and_then(Value::as_inline_table) + .is_some_and(|table| table.contains_key(key)) + } + + fn string(&self, section: &str, key: &str) -> Option { + self.item(section, key) + .and_then(Item::as_value) + .and_then(Value::as_str) + .or_else(|| { + self.document + .get(section) + .and_then(Item::as_value) + .and_then(Value::as_inline_table) + .and_then(|table| table.get(key)) + .and_then(Value::as_str) + }) + .map(str::to_owned) + } + + fn integer(&self, section: &str, key: &str) -> Option { + self.item(section, key) + .and_then(Item::as_value) + .and_then(Value::as_integer) + .or_else(|| { + self.document + .get(section) + .and_then(Item::as_value) + .and_then(Value::as_inline_table) + .and_then(|table| table.get(key)) + .and_then(Value::as_integer) + }) + .and_then(|value| u64::try_from(value).ok()) + } + + fn string_summary(&self, section: &str, key: &str) -> String { + match (self.has_key(section, key), self.string(section, key)) { + (false, _) => "unset".into(), + (true, Some(value)) => value, + (true, None) => "invalid".into(), + } + } + + fn integer_summary(&self, section: &str, key: &str) -> String { + match (self.has_key(section, key), self.integer(section, key)) { + (false, _) => "unset".into(), + (true, Some(value)) => value.to_string(), + (true, None) => "invalid".into(), + } + } + + fn secret_summary(&self, key: &str) -> &'static str { + if self.has_key("upstream", key) { + "configured" + } else { + "unset" + } + } + + fn gateway_summary(&self) -> &'static str { + if self.has_key("gateway", "max_hook_payload_bytes") + || self.has_key("gateway", "max_passthrough_body_bytes") + { + "configured" + } else { + "defaults" + } + } + + fn upstream_summary(&self) -> &'static str { + if self.document.get("upstream").is_some() { + "configured" + } else { + "defaults" + } + } + + fn logging_summary(&self) -> &'static str { + if self.document.get("logging").is_some() { + "configured" + } else { + "defaults" + } + } + + fn table_mut(&mut self, section: &str) -> Result<&mut Table, CliError> { + if self.document.get(section).is_none() { + self.document[section] = Item::Table(Table::new()); + } + self.document[section].as_table_mut().ok_or_else(|| { + CliError::Config(format!( + "[{section}] must be a TOML table before it can be edited" + )) + }) + } + + fn set_string(&mut self, section: &str, key: &str, new_value: String) -> Result<(), CliError> { + self.set_value(section, key, Value::from(new_value)) + } + + fn set_integer(&mut self, section: &str, key: &str, new_value: u64) -> Result<(), CliError> { + let numeric = i64::try_from(new_value) + .map_err(|_| CliError::Config(format!("{section}.{key} is too large")))?; + self.set_value(section, key, Value::from(numeric)) + } + + fn set_positive_integer( + &mut self, + section: &str, + key: &str, + new_value: u64, + ) -> Result<(), CliError> { + if new_value == 0 { + return Err(CliError::Config(format!( + "{section}.{key} must be greater than 0" + ))); + } + self.set_integer(section, key, new_value) + } + + fn set_enum( + &mut self, + section: &str, + key: &str, + new_value: &str, + allowed: &[&str], + ) -> Result<(), CliError> { + if !allowed.contains(&new_value) { + return Err(CliError::Config(format!( + "invalid {section}.{key}: {new_value}" + ))); + } + self.set_string(section, key, new_value.into()) + } + + fn set_auth_header(&mut self, key: &str, new_value: String) -> Result<(), CliError> { + let value = new_value.trim(); + if value.is_empty() { + return Err(CliError::Config(format!( + "upstream.{key} must not be empty" + ))); + } + axum::http::HeaderValue::from_str(value).map_err(|_| { + CliError::Config(format!("upstream.{key} must be a valid HTTP header value")) + })?; + self.set_string("upstream", key, value.into()) + } + + fn clear_key(&mut self, section: &str, key: &str) -> Result<(), CliError> { + let empty = match self.document.get_mut(section) { + Some(item) => { + if let Some(table) = item.as_table_mut() { + table.remove(key); + table.is_empty() + } else if let Some(table) = item.as_value_mut().and_then(Value::as_inline_table_mut) + { + table.remove(key); + table.is_empty() + } else { + return Err(CliError::Config(format!( + "[{section}] must be a TOML table before it can be edited" + ))); + } + } + None => false, + }; + if empty { + self.document.remove(section); + } + Ok(()) + } + + fn sinks(&self) -> Option<&ArrayOfTables> { + self.document + .get("logging")? + .as_table()? + .get("sinks")? + .as_array_of_tables() + } + + fn sinks_mut(&mut self) -> Result<&mut ArrayOfTables, CliError> { + let logging = self.table_mut("logging")?; + if logging.get("sinks").is_none() { + logging["sinks"] = Item::ArrayOfTables(ArrayOfTables::new()); + } + logging["sinks"].as_array_of_tables_mut().ok_or_else(|| { + CliError::Config( + "logging.sinks must be an array of tables before it can be edited".into(), + ) + }) + } + + fn sink_count(&self) -> usize { + self.sinks().map_or(0, ArrayOfTables::len) + } + + fn sink_labels(&self) -> Vec { + self.sinks() + .map(|sinks| { + sinks + .iter() + .enumerate() + .map(|(index, sink)| { + let path = sink + .get("path") + .and_then(Item::as_value) + .and_then(Value::as_str) + .unwrap_or("invalid path"); + format!("sink {} ({path})", index + 1) + }) + .collect() + }) + .unwrap_or_default() + } + + fn sink(&self, index: usize) -> Option<&Table> { + self.sinks()?.get(index) + } + + fn sink_mut(&mut self, index: usize) -> Result<&mut Table, CliError> { + self.sinks_mut()? + .get_mut(index) + .ok_or_else(|| CliError::Config(format!("logging sink {} does not exist", index + 1))) + } + + fn add_sink(&mut self, path: String) -> Result<(), CliError> { + let mut sink = Table::new(); + sink["path"] = value(path); + self.sinks_mut()?.push(sink); + Ok(()) + } + + fn remove_sink(&mut self, index: usize) -> Result<(), CliError> { + let empty = { + let sinks = self.sinks_mut()?; + if index >= sinks.len() { + return Err(CliError::Config(format!( + "logging sink {} does not exist", + index + 1 + ))); + } + sinks.remove(index); + sinks.is_empty() + }; + if empty { + self.clear_key("logging", "sinks")?; + } + Ok(()) + } + + fn sink_has_key(&self, index: usize, key: &str) -> Result { + Ok(self + .sink(index) + .ok_or_else(|| CliError::Config(format!("logging sink {} does not exist", index + 1)))? + .contains_key(key)) + } + + fn sink_string(&self, index: usize, key: &str) -> Option { + self.sink(index)? + .get(key)? + .as_value()? + .as_str() + .map(str::to_owned) + } + + fn sink_integer(&self, index: usize, key: &str) -> Option { + self.sink(index)? + .get(key)? + .as_value()? + .as_integer() + .and_then(|value| u64::try_from(value).ok()) + } + + fn sink_string_summary(&self, index: usize, key: &str) -> String { + match ( + self.sink(index).is_some_and(|sink| sink.contains_key(key)), + self.sink_string(index, key), + ) { + (false, _) => "unset".into(), + (true, Some(value)) => value, + (true, None) => "invalid".into(), + } + } + + fn sink_integer_summary(&self, index: usize, key: &str) -> String { + match ( + self.sink(index).is_some_and(|sink| sink.contains_key(key)), + self.sink_integer(index, key), + ) { + (false, _) => "unset".into(), + (true, Some(value)) => value.to_string(), + (true, None) => "invalid".into(), + } + } + + fn sink_rotation_summary(&self, index: usize) -> String { + match ( + self.sink_integer(index, "max_file_size_bytes"), + self.sink_integer(index, "retained_files"), + ) { + (None, None) => "unset".into(), + (Some(size), Some(retained)) => format!("{size} bytes, {retained} backups"), + _ => "incomplete".into(), + } + } + + fn set_sink_string( + &mut self, + index: usize, + key: &str, + new_value: String, + ) -> Result<(), CliError> { + self.sink_mut(index)?[key] = value(new_value); + Ok(()) + } + + fn set_sink_enum( + &mut self, + index: usize, + key: &str, + new_value: &str, + allowed: &[&str], + ) -> Result<(), CliError> { + if !allowed.contains(&new_value) { + return Err(CliError::Config(format!( + "invalid logging sink {key}: {new_value}" + ))); + } + self.set_sink_string(index, key, new_value.into()) + } + + fn set_sink_queue_capacity(&mut self, index: usize, capacity: u64) -> Result<(), CliError> { + if capacity == 0 { + return Err(CliError::Config( + "logging sink queue_capacity must be greater than 0".into(), + )); + } + if capacity > MAX_FILE_SINK_QUEUE_ENTRIES as u64 { + return Err(CliError::Config(format!( + "logging sink queue_capacity {capacity} exceeds maximum {MAX_FILE_SINK_QUEUE_ENTRIES} entries per file sink" + ))); + } + let capacity = i64::try_from(capacity) + .map_err(|_| CliError::Config("logging sink queue_capacity is too large".into()))?; + self.sink_mut(index)?["queue_capacity"] = value(capacity); + Ok(()) + } + + fn set_sink_rotation( + &mut self, + index: usize, + max_size: u64, + retained: u64, + ) -> Result<(), CliError> { + let max_size = i64::try_from(max_size).map_err(|_| { + CliError::Config("logging sink max_file_size_bytes is too large".into()) + })?; + let retained_i64 = i64::try_from(retained) + .map_err(|_| CliError::Config("logging sink retained_files is too large".into()))?; + let retained = usize::try_from(retained) + .map_err(|_| CliError::Config("logging sink retained_files is too large".into()))?; + nemo_relay::logging::FileLogRotationConfig::new(max_size as u64, retained) + .map_err(|error| CliError::Config(error.to_string()))?; + let sink = self.sink_mut(index)?; + sink["max_file_size_bytes"] = value(max_size); + sink["retained_files"] = value(retained_i64); + Ok(()) + } + + fn clear_sink_key(&mut self, index: usize, key: &str) -> Result<(), CliError> { + self.sink_mut(index)?.remove(key); + Ok(()) + } + + fn clear_sink_rotation(&mut self, index: usize) -> Result<(), CliError> { + let sink = self.sink_mut(index)?; + sink.remove("max_file_size_bytes"); + sink.remove("retained_files"); + Ok(()) + } + + fn set_value(&mut self, section: &str, key: &str, new_value: Value) -> Result<(), CliError> { + if self.document.get(section).is_none() { + self.document[section] = Item::Table(Table::new()); + } + let item = &mut self.document[section]; + if let Some(table) = item.as_table_mut() { + table[key] = Item::Value(new_value); + Ok(()) + } else if let Some(table) = item.as_value_mut().and_then(Value::as_inline_table_mut) { + table.insert(key, new_value); + Ok(()) + } else { + Err(CliError::Config(format!( + "[{section}] must be a TOML table before it can be edited" + ))) + } + } +} + +fn target_path(scope: TargetScope) -> Result { + match scope { + TargetScope::User => crate::configuration::user_config_dir() + .map(|directory| directory.join("config.toml")) + .ok_or_else(|| { + CliError::Config( + "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")), + } +} + +fn project_config_path(start: &Path) -> PathBuf { + for ancestor in start.ancestors() { + let candidate = ancestor.join(".nemo-relay/config.toml"); + if candidate.exists() { + return candidate; + } + } + 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 bd57c008d..dc915c618 100644 --- a/crates/cli/src/commands/configure/mod.rs +++ b/crates/cli/src/commands/configure/mod.rs @@ -3,18 +3,22 @@ use std::process::ExitCode; -use clap::Args; +use clap::{ArgGroup, Args, Subcommand}; use super::root::AgentArg; use crate::error::CliError; +mod editor; mod model; mod wizard; pub(super) use wizard::run; #[derive(Debug, Clone, Args)] +#[command(args_conflicts_with_subcommands = true)] pub(crate) struct ConfigCommand { + #[command(subcommand)] + pub(crate) command: Option, #[arg(value_enum)] pub(crate) agent: Option, /// Reset Relay configuration for the selected scope. Persistent Hermes integration state is @@ -26,7 +30,35 @@ pub(crate) struct ConfigCommand { pub(crate) scope: Option, } +#[derive(Debug, Clone, Subcommand)] +pub(crate) enum ConfigSubcommand { + /// Interactively edit gateway, upstream, and operational logging configuration. + Edit(ConfigEditCommand), +} + +#[derive(Debug, Clone, Default, Args)] +#[command(group( + ArgGroup::new("scope") + .args(["user", "project", "global"]) + .multiple(false) +))] +pub(crate) struct ConfigEditCommand { + /// Edit the user config at `$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`. + #[arg(long)] + pub(crate) global: bool, +} + pub(super) async fn execute(command: ConfigCommand) -> Result { + if let Some(ConfigSubcommand::Edit(edit)) = command.command.as_ref() { + editor::edit(edit.clone())?; + return Ok(ExitCode::SUCCESS); + } let agent = command.agent.map(Into::into); if command.reset { model::reset(command.scope.unwrap_or(model::ConfigScope::Project), agent)?; diff --git a/crates/cli/src/filesystem/atomic.rs b/crates/cli/src/filesystem/atomic.rs index 89c9abfca..b866e2e4c 100644 --- a/crates/cli/src/filesystem/atomic.rs +++ b/crates/cli/src/filesystem/atomic.rs @@ -46,6 +46,26 @@ pub(crate) fn atomic_write_private(path: &Path, bytes: &[u8]) -> Result<(), Stri } } +/// Atomically replace a system configuration file with owner-writable, world-readable access. +pub(crate) fn atomic_write_system_readable(path: &Path, bytes: &[u8]) -> Result<(), String> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + atomic_write_impl( + path, + bytes, + Some(&Permissions::from_mode(0o644)), + AtomicWritePrivacy::Standard, + None, + ) + } + #[cfg(not(unix))] + { + atomic_write(path, bytes) + } +} + /// Atomically replace `path` while applying `permissions` before the new bytes become visible. pub(crate) fn atomic_write_with_permissions( path: &Path, diff --git a/crates/cli/src/filesystem/mod.rs b/crates/cli/src/filesystem/mod.rs index 808c1796d..8088d63dd 100644 --- a/crates/cli/src/filesystem/mod.rs +++ b/crates/cli/src/filesystem/mod.rs @@ -11,7 +11,9 @@ pub(crate) mod temp; #[cfg(test)] pub(crate) use atomic::fail_next_atomic_write; -pub(crate) use atomic::{atomic_write, atomic_write_private, atomic_write_with_permissions}; +pub(crate) use atomic::{ + atomic_write, atomic_write_private, atomic_write_system_readable, atomic_write_with_permissions, +}; #[cfg(windows)] pub(crate) use atomic::{ atomic_write_with_windows_dacl, open_private_windows_file, protect_private_windows_path, diff --git a/crates/cli/src/plugins/config_io.rs b/crates/cli/src/plugins/config_io.rs index 226ba112d..67ec843ec 100644 --- a/crates/cli/src/plugins/config_io.rs +++ b/crates/cli/src/plugins/config_io.rs @@ -170,11 +170,20 @@ impl PluginConfigDocument { pub(crate) fn write(&self) -> Result<(), CliError> { let rendered = self.render()?; - if let Some(parent) = self.path.parent() { - std::fs::create_dir_all(parent)?; + crate::filesystem::atomic_write(&self.path, rendered.as_bytes()).map_err(CliError::Config) + } + + pub(crate) fn write_for_scope(&self, scope: TargetScope) -> Result<(), CliError> { + let rendered = self.render()?; + match scope { + 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()) + } } - std::fs::write(&self.path, rendered)?; - Ok(()) + .map_err(CliError::Config) } fn dynamic_entry_mut( diff --git a/crates/cli/src/plugins/dynamic_editor.rs b/crates/cli/src/plugins/dynamic_editor.rs index 2627c6e6c..e31df9673 100644 --- a/crates/cli/src/plugins/dynamic_editor.rs +++ b/crates/cli/src/plugins/dynamic_editor.rs @@ -62,6 +62,14 @@ impl DynamicPluginEditorState { Ok(()) } + pub(super) fn has_persisted_secrets(&self) -> bool { + self.config.as_ref().is_some_and(|config| { + self.schema + .as_ref() + .is_some_and(|schema| schema.has_persisted_secrets(&Value::Object(config.clone()))) + }) + } + pub(super) fn apply_to_document( &self, document: &mut PluginConfigDocument, diff --git a/crates/cli/src/plugins/mod.rs b/crates/cli/src/plugins/mod.rs index 74116e01e..052b46938 100644 --- a/crates/cli/src/plugins/mod.rs +++ b/crates/cli/src/plugins/mod.rs @@ -138,6 +138,7 @@ pub(crate) fn edit(command: PluginsEditRequest) -> Result<(), CliError> { &mut dynamic_plugins, &actions, selection, + scope, )? == EditLoopControl::Finish { return Ok(()); @@ -152,6 +153,7 @@ fn handle_menu_response( dynamic_plugins: &mut [DynamicPluginEditorState], actions: &[MenuAction], selection: MenuResponse, + scope: TargetScope, ) -> Result { match selection { MenuResponse::Selected(selection) => handle_menu_action( @@ -160,13 +162,14 @@ fn handle_menu_response( components, dynamic_plugins, actions.get(selection).copied(), + scope, ), MenuResponse::Shortcut(MenuShortcut::Preview, _) => { preview_document(document, components, dynamic_plugins)?; Ok(EditLoopControl::Continue) } MenuResponse::Shortcut(MenuShortcut::Save, _) => { - save_document(document, components, dynamic_plugins) + save_document(document, components, dynamic_plugins, scope) } MenuResponse::Shortcut(MenuShortcut::Help, _) => { print_editor_help(); @@ -186,6 +189,7 @@ fn handle_menu_action( components: &mut [EditableComponent], dynamic_plugins: &mut [DynamicPluginEditorState], action: Option, + scope: TargetScope, ) -> Result { match action { Some(MenuAction::EditComponent(component_index)) => { @@ -204,7 +208,7 @@ fn handle_menu_action( preview_document(document, components, dynamic_plugins)?; Ok(EditLoopControl::Continue) } - Some(MenuAction::Save) => save_document(document, components, dynamic_plugins), + Some(MenuAction::Save) => save_document(document, components, dynamic_plugins, scope), Some(MenuAction::Cancel) | None => Err(cancelled_error()), } } @@ -265,14 +269,26 @@ fn save_document( document: &mut PluginConfigDocument, components: &[EditableComponent], dynamic_plugins: &[DynamicPluginEditorState], + scope: TargetScope, ) -> Result { store_editable_components(document.config_mut(), components)?; validate_config(document.config())?; for plugin in dynamic_plugins { plugin.validate()?; + } + if scope == TargetScope::Global + && dynamic_plugins + .iter() + .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(), + )); + } + for plugin in dynamic_plugins { plugin.apply_to_document(document, false)?; } - document.write()?; + document.write_for_scope(scope)?; print_save_success(document.path()); Ok(EditLoopControl::Finish) } diff --git a/crates/cli/src/plugins/schema/mod.rs b/crates/cli/src/plugins/schema/mod.rs index 4d505fad9..94559a378 100644 --- a/crates/cli/src/plugins/schema/mod.rs +++ b/crates/cli/src/plugins/schema/mod.rs @@ -209,6 +209,18 @@ impl PluginConfigSchema { !self.secret_patterns.is_empty() } + /// Returns whether a configuration contains a non-null schema-declared secret value. + pub(super) fn has_persisted_secrets(&self, config: &Value) -> bool { + let mut config = config.clone(); + let mut has_persisted_secrets = false; + for pattern in &self.secret_patterns { + pattern.visit_matching_values(&mut config, 0, &mut |value| { + has_persisted_secrets |= !value.is_null(); + }); + } + has_persisted_secrets + } + pub(super) fn has_secrets_at(&self, path: &[String]) -> bool { self.secret_patterns .iter() diff --git a/crates/cli/tests/coverage/commands/configure_editor_tests.rs b/crates/cli/tests/coverage/commands/configure_editor_tests.rs new file mode 100644 index 000000000..69a463aeb --- /dev/null +++ b/crates/cli/tests/coverage/commands/configure_editor_tests.rs @@ -0,0 +1,252 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::*; + +fn document(contents: &str) -> ConfigDocument { + ConfigDocument { + path: PathBuf::from("config.toml"), + document: contents.parse().unwrap(), + } +} + +#[test] +fn document_preserves_toml_and_redacts_standard_inline_and_dotted_auth_headers() { + let mut standard = document( + "# keep this comment\n[agents.codex]\ncommand = \"codex\"\n\n[upstream]\nopenai_auth_header = \"Bearer secret\"\nanthropic_auth_header = \"Basic secret\"\n", + ); + standard + .set_positive_integer("gateway", "max_hook_payload_bytes", 42) + .unwrap(); + + let preview = standard.preview(); + assert!(preview.contains("# keep this comment")); + assert!(preview.contains("[agents.codex]")); + assert!(preview.contains("")); + assert!(!preview.contains("Bearer secret")); + assert!(!preview.contains("Basic secret")); + assert!(standard.document.to_string().contains("Bearer secret")); + + let mut inline = document( + "upstream = { openai_auth_header = \"Bearer inline\", anthropic_auth_header = \"Basic inline\" }\n", + ); + assert_eq!(inline.secret_summary("openai_auth_header"), "configured"); + inline + .set_auth_header("openai_auth_header", "Bearer replacement".into()) + .unwrap(); + inline + .clear_key("upstream", "anthropic_auth_header") + .unwrap(); + let preview = inline.preview(); + assert!(preview.contains("")); + assert!(!preview.contains("Bearer inline")); + assert!(!preview.contains("Bearer replacement")); + assert!(!preview.contains("Basic inline")); + + let dotted = document("upstream.openai_auth_header = \"Bearer dotted\"\n"); + assert_eq!(dotted.secret_summary("openai_auth_header"), "configured"); + assert!(!dotted.preview().contains("Bearer dotted")); +} + +#[test] +fn edits_and_clears_supported_scalars() { + let mut document = document( + "[gateway]\nmax_hook_payload_bytes = \"not-a-number\"\n\n[upstream]\nopenai_base_url = \"https://example.test/v1\"\n\n[logging]\nlevel = \"info\"\nstderr_format = \"human\"\n", + ); + assert_eq!( + document.integer_summary("gateway", "max_hook_payload_bytes"), + "invalid" + ); + document + .set_positive_integer("gateway", "max_hook_payload_bytes", 2048) + .unwrap(); + document + .set_enum("logging", "level", "debug", LOG_LEVELS) + .unwrap(); + document + .set_enum("logging", "stderr_format", "jsonl", LOG_FORMATS) + .unwrap(); + document + .set_integer("logging", "flush_interval_millis", 0) + .unwrap(); + document.clear_key("upstream", "openai_base_url").unwrap(); + + let rendered = document.document.to_string(); + assert!(rendered.contains("max_hook_payload_bytes = 2048")); + assert!(rendered.contains("level = \"debug\"")); + assert!(rendered.contains("stderr_format = \"jsonl\"")); + assert!(rendered.contains("flush_interval_millis = 0")); + assert!(!rendered.contains("example.test")); +} + +#[test] +fn validates_gateway_auth_and_sink_values() { + let mut document = document(""); + assert!( + document + .set_positive_integer("gateway", "max_hook_payload_bytes", 0) + .is_err() + ); + assert!( + document + .set_auth_header("openai_auth_header", "Bearer\nsecret".into()) + .is_err() + ); + assert!( + document + .set_integer("logging", "flush_interval_millis", u64::MAX) + .is_err() + ); + + document.add_sink("relay.log".into()).unwrap(); + assert!(document.set_sink_queue_capacity(0, 0).is_err()); + assert!( + document + .set_sink_queue_capacity(0, MAX_FILE_SINK_QUEUE_ENTRIES as u64 + 1) + .is_err() + ); + assert!(document.set_sink_rotation(0, 1024, 10).is_err()); + assert!(document.set_sink_rotation(0, u64::MAX, 1).is_err()); + assert!(document.set_sink_rotation(0, 1024, u64::MAX).is_err()); + assert!( + document + .set_sink_enum(0, "level", "invalid", LOG_LEVELS) + .is_err() + ); +} + +#[test] +fn manages_sink_lifecycle_and_summaries() { + let mut document = document(""); + document.add_sink("relay.log".into()).unwrap(); + document.set_sink_queue_capacity(0, 128).unwrap(); + document.set_sink_rotation(0, 1024 * 1024, 2).unwrap(); + assert_eq!(document.gateway_summary(), "defaults"); + assert_eq!(document.logging_summary(), "configured"); + assert_eq!(document.sink_labels(), ["sink 1 (relay.log)"]); + assert_eq!(document.sink_integer_summary(0, "queue_capacity"), "128"); + assert_eq!( + document.sink_rotation_summary(0), + "1048576 bytes, 2 backups" + ); + document.clear_sink_rotation(0).unwrap(); + document.remove_sink(0).unwrap(); + assert_eq!(document.sink_count(), 0); + assert!(!document.document.to_string().contains("[logging]")); +} + +#[test] +fn malformed_sections_and_missing_sinks_report_errors() { + let mut malformed = document("gateway = \"invalid\"\nlogging = \"invalid\"\n"); + assert!( + malformed + .set_positive_integer("gateway", "max_hook_payload_bytes", 1) + .is_err() + ); + assert!(malformed.add_sink("relay.log".into()).is_err()); + + let mut document = document(""); + assert!(document.remove_sink(0).is_err()); + assert!(document.sink_has_key(0, "path").is_err()); + assert!(document.clear_sink_key(0, "path").is_err()); +} + +#[test] +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()) { + Ok(_) => panic!("invalid TOML should be rejected"), + Err(error) => error.to_string(), + }; + assert!(error.contains("invalid TOML")); + assert!(error.contains(&invalid.display().to_string())); +} + +#[test] +fn documents_are_written_atomically_with_scope_appropriate_permissions() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("nested/config.toml"); + let document = ConfigDocument::read(path.clone()).unwrap(); + assert!(!path.exists()); + document.write(TargetScope::User).unwrap(); + assert!(path.exists()); + + let original = std::fs::read_to_string(&path).unwrap(); + crate::filesystem::fail_next_atomic_write(&path); + let error = document.write(TargetScope::User).unwrap_err().to_string(); + assert!(error.contains("injected test failure")); + assert_eq!(std::fs::read_to_string(&path).unwrap(), original); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + + let global_path = directory.path().join("global/config.toml"); + ConfigDocument::read(global_path.clone()) + .unwrap() + .write(TargetScope::Global) + .unwrap(); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(global_path).unwrap().permissions().mode() & 0o777, + 0o644 + ); + } +} + +#[test] +fn global_document_rejects_authorization_headers() { + let directory = tempfile::tempdir().unwrap(); + for (index, contents) in [ + "[upstream]\nopenai_auth_header = \"Bearer secret\"\n", + "upstream = { anthropic_auth_header = \"Bearer secret\" }\n", + "upstream.openai_auth_header = \"Bearer secret\"\n", + ] + .into_iter() + .enumerate() + { + let path = directory.path().join(format!("config-{index}.toml")); + std::fs::write(&path, "original").unwrap(); + let mut document = document(contents); + document.path = path.clone(); + + let error = document.write(TargetScope::Global).unwrap_err().to_string(); + assert!(error.contains("global config cannot include upstream authorization headers")); + assert_eq!(std::fs::read_to_string(path).unwrap(), "original"); + } +} + +#[test] +fn noninteractive_editor_guard_is_deterministic() { + let error = ensure_tty_with(false).unwrap_err().to_string(); + assert_eq!( + error, + "configuration error: interactive configuration editing requires a TTY" + ); +} diff --git a/crates/cli/tests/coverage/commands/main_tests.rs b/crates/cli/tests/coverage/commands/main_tests.rs index e3f7f232f..11396a745 100644 --- a/crates/cli/tests/coverage/commands/main_tests.rs +++ b/crates/cli/tests/coverage/commands/main_tests.rs @@ -7,11 +7,13 @@ use std::ffi::OsString; use super::completions::CompletionsCommand; use super::serve::ServerArgs; use super::*; +use crate::commands::configure::ConfigSubcommand; use crate::commands::model_pricing::{PricingSubcommand, PricingValidateCommand}; use crate::commands::plugins::{ PluginsCommand, PluginsInspectCommand, PluginsListCommand, PluginsSubcommand, PluginsValidateCommand, }; +use crate::commands::root::AgentArg; #[test] fn operational_command_names_cover_logging_exempt_commands() { @@ -218,6 +220,46 @@ fn command_logging_policy_excludes_only_configuration_editors() { assert!(!agents.command.as_ref().unwrap().skips_logging()); } +#[test] +fn cli_parses_config_edit_scopes_and_rejects_conflicts() { + let legacy = Cli::try_parse_from(["nemo-relay", "config", "codex"]).unwrap(); + let Command::Config(command) = legacy.command.unwrap() else { + panic!("expected config command"); + }; + assert!(matches!(command.agent, Some(AgentArg::Codex))); + + let user = Cli::try_parse_from(["nemo-relay", "config", "edit"]).unwrap(); + let Command::Config(command) = user.command.unwrap() else { + panic!("expected config command"); + }; + let Some(ConfigSubcommand::Edit(command)) = command.command else { + panic!("expected config edit command"); + }; + assert!(!command.user); + assert!(!command.project); + assert!(!command.global); + + 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); + + let error = + Cli::try_parse_from(["nemo-relay", "config", "edit", "--user", "--global"]).unwrap_err(); + assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict); + + for arguments in [ + ["nemo-relay", "config", "codex", "edit", "--reset"].as_slice(), + ["nemo-relay", "config", "--reset", "edit"].as_slice(), + ] { + assert!(Cli::try_parse_from(arguments).is_err()); + } +} + #[test] fn doctor_rejects_conflicting_agent_and_plugin_targets() { let error = diff --git a/crates/cli/tests/coverage/shared/plugins_tests.rs b/crates/cli/tests/coverage/shared/plugins_tests.rs index b40e38d05..57721e960 100644 --- a/crates/cli/tests/coverage/shared/plugins_tests.rs +++ b/crates/cli/tests/coverage/shared/plugins_tests.rs @@ -1661,6 +1661,51 @@ value = "preserve-host-section" assert!(dynamic[1].get("config").is_none()); } +#[cfg(unix)] +#[test] +fn global_plugin_document_is_system_readable() { + use std::os::unix::fs::PermissionsExt; + + 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(); + + assert_eq!( + std::fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o644 + ); +} + +#[test] +fn global_plugin_editor_rejects_persisted_schema_secrets() { + let temp = tempfile::tempdir().unwrap(); + write_editor_dynamic_manifest( + &temp.path().join("plugin"), + "acme.secret", + None, + Some(&json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {"password": {"type": "string", "writeOnly": true}} + })), + ); + let path = temp.path().join("plugins.toml"); + let original = "[[plugins.dynamic]]\nmanifest = \"./plugin/relay-plugin.toml\"\nconfig = { password = \"secret\" }\n"; + std::fs::write(&path, original).unwrap(); + + let mut document = PluginConfigDocument::read(&path).unwrap(); + let states = load_dynamic_plugin_states(&document).unwrap(); + let error = save_document(&mut document, &[], &states, TargetScope::Global) + .unwrap_err() + .to_string(); + + assert!( + error.contains("global plugin configuration cannot contain schema-declared secret values") + ); + assert_eq!(std::fs::read_to_string(path).unwrap(), original); +} + #[test] fn dynamic_config_array_resize_preserves_toml_native_values() { let temp = tempfile::tempdir().unwrap(); diff --git a/docs/nemo-relay-cli/basic-usage.mdx b/docs/nemo-relay-cli/basic-usage.mdx index 324b9b113..23e285862 100644 --- a/docs/nemo-relay-cli/basic-usage.mdx +++ b/docs/nemo-relay-cli/basic-usage.mdx @@ -168,6 +168,35 @@ leaving the plugin editor does not remove the saved base configuration. You can open the plugin editor again later with `nemo-relay plugins edit`, or use `nemo-relay plugins edit --project` for project configuration. +### Edit Gateway Configuration + +Use `nemo-relay config edit` to update the user `config.toml` with structured +controls for gateway limits, provider upstreams, and operational logging: + +```bash +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. + +Agent command setup remains under `nemo-relay config`; plugin components remain +under `nemo-relay plugins edit`. + +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. + +The upstream authorization-header controls show only whether a value is +configured and never print it in menus or previews. Prefer +`NEMO_RELAY_OPENAI_AUTH_HEADER` and `NEMO_RELAY_ANTHROPIC_AUTH_HEADER` instead +of storing credentials in `config.toml`. + ### Provider Upstreams Set provider base URLs under `[upstream]` when you want Relay to forward