From c284414b775d98d5474d6fc14f1ac6c21f17c189 Mon Sep 17 00:00:00 2001 From: Eric Evans <194135482+ericevans-nv@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:32:46 -0500 Subject: [PATCH 1/9] feat(cli): warn about duplicate agent executables Signed-off-by: Eric Evans <194135482+ericevans-nv@users.noreply.github.com> --- crates/cli/src/commands/diagnostics.rs | 54 ++++- crates/cli/src/commands/mod.rs | 4 + crates/cli/src/commands/run.rs | 25 +++ crates/cli/src/diagnostics/invocation.rs | 166 +++++++++++++++ crates/cli/src/diagnostics/mod.rs | 1 + crates/cli/tests/cli_tests.rs | 199 ++++++++++++++++++ .../cli/tests/coverage/commands/main_tests.rs | 27 +++ .../shared/invocation_diagnostic_tests.rs | 86 ++++++++ docs/nemo-relay-cli/basic-usage.mdx | 27 +++ 9 files changed, 588 insertions(+), 1 deletion(-) create mode 100644 crates/cli/src/diagnostics/invocation.rs create mode 100644 crates/cli/tests/coverage/shared/invocation_diagnostic_tests.rs diff --git a/crates/cli/src/commands/diagnostics.rs b/crates/cli/src/commands/diagnostics.rs index ccf6cbe08..e60daefe7 100644 --- a/crates/cli/src/commands/diagnostics.rs +++ b/crates/cli/src/commands/diagnostics.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use std::process::ExitCode; -use clap::Args; +use clap::{Args, Subcommand}; use serde_json::{Value, json}; use super::install::InstallTarget; @@ -12,7 +12,10 @@ use super::root::AgentArg; use crate::error::CliError; #[derive(Debug, Clone, Args)] +#[command(args_conflicts_with_subcommands = true)] pub(crate) struct DoctorCommand { + #[command(subcommand)] + pub(crate) command: Option, #[arg(value_enum, conflicts_with = "plugin")] pub(crate) agent: Option, #[arg(long, value_enum)] @@ -28,6 +31,27 @@ pub(crate) struct DoctorCommand { pub(crate) offline: bool, } +#[derive(Debug, Clone, Subcommand)] +pub(crate) enum DoctorSubcommand { + /// Inspect an agent invocation without launching it. + Invocation(InvocationDoctorCommand), +} + +#[derive(Debug, Clone, Args)] +pub(crate) struct InvocationDoctorCommand { + #[arg(long, value_enum)] + agent: AgentArg, + #[arg(long)] + shortcut: bool, + #[arg( + long, + help = "Display the complete invocation; arguments may contain sensitive data" + )] + show_full_command: bool, + #[arg(last = true, required = true)] + command: Vec, +} + #[derive(Debug, Clone, Args)] pub(crate) struct AgentsCommand { #[arg(long)] @@ -39,6 +63,9 @@ pub(super) async fn execute( server: &super::serve::ServerArgs, logging_fallback_error: Option<&CliError>, ) -> Result { + if let Some(DoctorSubcommand::Invocation(invocation)) = command.command { + return execute_invocation_doctor(invocation); + } if let Some(plugin) = command.plugin { return execute_plugin_doctor(plugin, command.install_dir, command.json); } @@ -53,6 +80,31 @@ pub(super) async fn execute( .await } +fn execute_invocation_doctor(command: InvocationDoctorCommand) -> Result { + let agent = command.agent.into(); + let form = if command.shortcut { + crate::diagnostics::invocation::InvocationForm::Shortcut + } else { + crate::diagnostics::invocation::InvocationForm::Run + }; + match crate::diagnostics::invocation::DuplicateAgentExecutable::detect( + agent, + &command.command, + form, + ) { + Some(diagnostic) => { + println!("{}", diagnostic.format_doctor(command.show_full_command)); + } + None => { + println!( + "INVOCATION DIAGNOSTIC\ncode = none\nselected_agent = {}\nresult = no duplicate agent executable detected", + agent.as_arg() + ); + } + } + Ok(ExitCode::SUCCESS) +} + fn execute_plugin_doctor( plugin: InstallTarget, install_dir: Option, diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 3b1d3bcf8..03411920c 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -102,6 +102,10 @@ fn configure_logging(cli: &Cli) -> Result { }) } +fn print_invocation_warning(warning: &str) { + eprintln!("{warning}"); +} + async fn dispatch(bootstrap_shutdown_token: Option) -> Result { let cli = Cli::parse(); let command_name = cli diff --git a/crates/cli/src/commands/run.rs b/crates/cli/src/commands/run.rs index 3c176c147..d40e4baa3 100644 --- a/crates/cli/src/commands/run.rs +++ b/crates/cli/src/commands/run.rs @@ -60,6 +60,13 @@ pub(super) async fn execute( command: RunCommand, server: &ServerArgs, ) -> Result { + if let Some(agent) = command.agent.map(Into::into) { + warn_for_possible_duplicate( + agent, + &command.command, + crate::diagnostics::invocation::InvocationForm::Run, + ); + } let inherited = server.to_runtime(); crate::process::launcher::run(command.into_runtime(), Some(&inherited)).await } @@ -79,6 +86,11 @@ pub(super) async fn easy_path( command: EasyPathCommand, server: &ServerArgs, ) -> Result { + warn_for_possible_duplicate( + agent, + &command.command, + crate::diagnostics::invocation::InvocationForm::Shortcut, + ); let inherited = server.to_runtime(); // An explicit config path is the user's contract. Without one, setup is required only when // none of the normal discovery layers exists. Keep this interactive decision in the command @@ -102,3 +114,16 @@ pub(super) async fn easy_path( }; crate::process::launcher::run(runtime, Some(&inherited)).await } + +fn warn_for_possible_duplicate( + agent: CodingAgent, + command: &[String], + form: crate::diagnostics::invocation::InvocationForm, +) { + if let Some(diagnostic) = + crate::diagnostics::invocation::DuplicateAgentExecutable::detect(agent, command, form) + { + diagnostic.log(); + super::print_invocation_warning(&diagnostic.format_warning()); + } +} diff --git a/crates/cli/src/diagnostics/invocation.rs b/crates/cli/src/diagnostics/invocation.rs new file mode 100644 index 000000000..21f9b05c6 --- /dev/null +++ b/crates/cli/src/diagnostics/invocation.rs @@ -0,0 +1,166 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Advisory diagnostics for structurally suspicious agent invocations. + +use crate::agents::CodingAgent; + +pub(crate) const POSSIBLE_DUPLICATE_AGENT_EXECUTABLE: &str = "possible_duplicate_agent_executable"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum InvocationForm { + Run, + Shortcut, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DuplicateAgentExecutable { + agent: CodingAgent, + form: InvocationForm, + command: Vec, +} + +impl DuplicateAgentExecutable { + pub(crate) fn detect( + agent: CodingAgent, + command: &[String], + form: InvocationForm, + ) -> Option { + let executable = command.first()?; + (CodingAgent::infer(executable) == Some(agent)).then(|| Self { + agent, + form, + command: command.to_vec(), + }) + } + + pub(crate) fn log(&self) { + let agent = self.agent.as_arg(); + log::warn!( + target: "nemo_relay.cli", + event = "agent_invocation_warning", + diagnostic_code = POSSIBLE_DUPLICATE_AGENT_EXECUTABLE, + agent = agent, + duplicate_executable = agent, + confidence = "high", + action = "continued", + command_modified = false, + arguments_redacted = true; + "Possible duplicate agent executable after `--`" + ); + } + + pub(crate) fn format_doctor(&self, show_full_command: bool) -> String { + let visibility = if show_full_command { + "full command; may contain sensitive data" + } else { + "arguments redacted" + }; + format!( + "INVOCATION DIAGNOSTIC\n\ + code = {POSSIBLE_DUPLICATE_AGENT_EXECUTABLE}\n\ + confidence = high\n\ + selected_agent = {}\n\ + duplicate_executable = {}\n\ + visibility = {visibility}\n\ + observed = {}\n\ + recommended = {}\n\ + action = continue unchanged", + self.agent.as_arg(), + self.agent.as_arg(), + self.observed_command(show_full_command), + self.recommended_command(show_full_command), + ) + } + + pub(crate) fn format_warning(&self) -> String { + format!( + "WARNING: Possible duplicate agent executable after `--`.\n\ + Diagnostic: {POSSIBLE_DUPLICATE_AGENT_EXECUTABLE}\n\ + Duplicate executable: {}\n\ + Observed: {}\n\ + Recommended: {}\n\ + Doctor (safe): {}\n\ + Doctor (full): {}\n\ + Relay will continue without modifying the command.", + self.agent.as_arg(), + self.observed_command(false), + self.recommended_command(false), + self.doctor_command(false), + self.doctor_command(true), + ) + } + + fn observed_command(&self, show_full_command: bool) -> String { + let mut command = self.relay_prefix(); + command.push("--".into()); + if show_full_command { + command.extend(self.command.iter().cloned()); + } else { + command.push(self.agent.as_arg().into()); + if self.command.len() > 1 { + command.push("".into()); + } + } + render_command(&command) + } + + fn recommended_command(&self, show_full_command: bool) -> String { + let mut command = self.relay_prefix(); + command.push("--".into()); + if show_full_command { + command.extend(self.command.iter().skip(1).cloned()); + } else if self.command.len() > 1 { + command.push("".into()); + } + render_command(&command) + } + + fn doctor_command(&self, show_full_command: bool) -> String { + let mut command = vec![ + "nemo-relay".into(), + "doctor".into(), + "invocation".into(), + "--agent".into(), + self.agent.as_arg().into(), + ]; + if self.form == InvocationForm::Shortcut { + command.push("--shortcut".into()); + } + if show_full_command { + command.push("--show-full-command".into()); + } + command.push("--".into()); + command.push(self.agent.as_arg().into()); + if show_full_command && self.command.len() > 1 { + command.push("".into()); + } + render_command(&command) + } + + fn relay_prefix(&self) -> Vec { + match self.form { + InvocationForm::Run => vec![ + "nemo-relay".into(), + "run".into(), + "--agent".into(), + self.agent.as_arg().into(), + ], + InvocationForm::Shortcut => { + vec!["nemo-relay".into(), self.agent.as_arg().into()] + } + } + } +} + +fn render_command(command: &[String]) -> String { + command + .iter() + .map(|argument| crate::process::shell_quote_arg_for_platform(argument, cfg!(windows))) + .collect::>() + .join(" ") +} + +#[cfg(test)] +#[path = "../../tests/coverage/shared/invocation_diagnostic_tests.rs"] +mod tests; diff --git a/crates/cli/src/diagnostics/mod.rs b/crates/cli/src/diagnostics/mod.rs index c033d7d54..2e0ee8cd5 100644 --- a/crates/cli/src/diagnostics/mod.rs +++ b/crates/cli/src/diagnostics/mod.rs @@ -10,6 +10,7 @@ //! - `format_human(&report)` / `format_json(&report)` render the report. mod environment; +pub(crate) mod invocation; mod model; mod probes; mod render; diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index 5ed7d6790..56544cf18 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -3814,6 +3814,205 @@ command = "hermes --yolo chat" assert!(stdout.contains("argv = hermes --yolo chat")); } +#[test] +fn invocation_diagnostic_cli_warns_without_rewriting_the_command() { + let temp = tempfile::tempdir().unwrap(); + let config = temp.path().join("config.toml"); + std::fs::write( + &config, + r#" +[upstream] +openai_base_url = "http://127.0.0.1:1" +anthropic_base_url = "http://127.0.0.1:1" +"#, + ) + .unwrap(); + + let output = Command::new(gateway_bin()) + .current_dir(temp.path()) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("HOME", temp.path()) + .args([ + "--config", + config.to_str().unwrap(), + "run", + "--agent", + "claude", + "--dry-run", + "--", + "/opt/bin/claude-code.exe", + "-p", + "synthetic prompt", + ]) + .output() + .unwrap(); + + assert!(output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("possible_duplicate_agent_executable"), + "{stderr}" + ); + assert!(stderr.contains("Relay will continue without modifying the command")); + assert!(!stderr.contains("/opt/bin/claude-code.exe")); + assert!(!stderr.contains("synthetic prompt")); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("/opt/bin/claude-code.exe -p synthetic prompt"), + "{stdout}" + ); +} + +#[test] +fn invocation_diagnostic_cli_ignores_agent_names_after_a_different_first_token() { + let temp = tempfile::tempdir().unwrap(); + let config = temp.path().join("config.toml"); + std::fs::write( + &config, + r#" +[upstream] +openai_base_url = "http://127.0.0.1:1" +anthropic_base_url = "http://127.0.0.1:1" +"#, + ) + .unwrap(); + + let output = Command::new(gateway_bin()) + .current_dir(temp.path()) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("HOME", temp.path()) + .args([ + "--config", + config.to_str().unwrap(), + "run", + "--agent", + "claude", + "--dry-run", + "--", + "-p", + "compare claude with codex", + ]) + .output() + .unwrap(); + + assert!(output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stderr.contains("possible_duplicate_agent_executable"), + "{stderr}" + ); +} + +#[test] +fn invocation_diagnostic_cli_doctor_requires_opt_in_for_full_output() { + let safe = Command::new(gateway_bin()) + .args([ + "doctor", + "invocation", + "--agent", + "claude", + "--", + "claude", + "-p", + "private synthetic value", + ]) + .output() + .unwrap(); + assert!(safe.status.success()); + let safe_stdout = String::from_utf8_lossy(&safe.stdout); + assert!(safe_stdout.contains("")); + assert!(!safe_stdout.contains("private synthetic value")); + + let full = Command::new(gateway_bin()) + .args([ + "doctor", + "invocation", + "--agent", + "claude", + "--show-full-command", + "--", + "claude", + "-p", + "private synthetic value", + ]) + .output() + .unwrap(); + assert!(full.status.success()); + let full_stdout = String::from_utf8_lossy(&full.stdout); + assert!(full_stdout.contains("private synthetic value")); + assert!(full_stdout.contains("recommended = nemo-relay run --agent claude -- -p")); +} + +#[test] +fn invocation_diagnostic_cli_warns_for_agent_shortcut() { + let temp = tempfile::tempdir().unwrap(); + 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).unwrap(); + + let output = Command::new(gateway_bin()) + .current_dir(&cwd) + .env("XDG_CONFIG_HOME", &xdg) + .env("HOME", temp.path()) + .args(["claude", "--", "claude", "-p", "private synthetic value"]) + .output() + .unwrap(); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("possible_duplicate_agent_executable"), + "{stderr}" + ); + assert!( + stderr.contains("Observed: nemo-relay claude -- claude ''"), + "{stderr}" + ); + assert!( + stderr.contains("Recommended: nemo-relay claude -- ''"), + "{stderr}" + ); + assert!( + stderr.contains( + "Doctor (safe): nemo-relay doctor invocation --agent claude --shortcut -- claude" + ), + "{stderr}" + ); + assert!(stderr.contains("setup requires a TTY"), "{stderr}"); + assert!(!stderr.contains("private synthetic value"), "{stderr}"); +} + +#[test] +fn invocation_diagnostic_cli_doctor_reports_no_duplicate() { + let output = Command::new(gateway_bin()) + .args([ + "doctor", + "invocation", + "--agent", + "claude", + "--", + "-p", + "synthetic prompt", + ]) + .output() + .unwrap(); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("code = none"), "{stdout}"); + assert!(stdout.contains("selected_agent = claude"), "{stdout}"); + assert!( + stdout.contains("result = no duplicate agent executable detected"), + "{stdout}" + ); + assert!( + !stdout.contains("possible_duplicate_agent_executable"), + "{stdout}" + ); +} + #[test] fn cli_run_dry_run_rejects_missing_explicit_config() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/cli/tests/coverage/commands/main_tests.rs b/crates/cli/tests/coverage/commands/main_tests.rs index 9aa1492a3..27012ae4a 100644 --- a/crates/cli/tests/coverage/commands/main_tests.rs +++ b/crates/cli/tests/coverage/commands/main_tests.rs @@ -345,6 +345,33 @@ fn doctor_accepts_offline_flag() { } } +#[test] +fn invocation_diagnostic_parser_accepts_doctor_without_runtime_probe_flags() { + let cli = Cli::try_parse_from([ + "nemo-relay", + "doctor", + "invocation", + "--agent", + "claude", + "--", + "claude", + "-p", + "synthetic prompt", + ]) + .unwrap(); + match cli.command { + Some(Command::Doctor(command)) => assert!(matches!( + command.command, + Some(diagnostics::DoctorSubcommand::Invocation(_)) + )), + other => panic!("expected doctor invocation command, got {other:?}"), + } + + assert!( + Cli::try_parse_from(["nemo-relay", "doctor", "invocation", "--agent", "claude",]).is_err() + ); +} + #[test] fn multi_agent_operations_attempt_every_target_before_reporting_errors() { let visited = std::cell::RefCell::new(Vec::new()); diff --git a/crates/cli/tests/coverage/shared/invocation_diagnostic_tests.rs b/crates/cli/tests/coverage/shared/invocation_diagnostic_tests.rs new file mode 100644 index 000000000..c97f82289 --- /dev/null +++ b/crates/cli/tests/coverage/shared/invocation_diagnostic_tests.rs @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::*; + +fn argv(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() +} + +#[test] +fn invocation_diagnostic_detects_supported_agent_names_aliases_and_paths() { + let cases = [ + (CodingAgent::ClaudeCode, "claude"), + (CodingAgent::ClaudeCode, "/opt/bin/claude-code.exe"), + (CodingAgent::Codex, r"C:\\tools\\CODEX.CMD"), + (CodingAgent::Hermes, "hermes-agent"), + ]; + + for (agent, executable) in cases { + assert!( + DuplicateAgentExecutable::detect( + agent, + &argv(&[executable, "synthetic argument"]), + InvocationForm::Run, + ) + .is_some(), + "expected {executable:?} to duplicate {agent:?}" + ); + } +} + +#[test] +fn invocation_diagnostic_only_inspects_the_first_post_boundary_token() { + for command in [ + argv(&[]), + argv(&["-p", "claude appears later"]), + argv(&["my-wrapper", "claude"]), + argv(&["codex", "claude"]), + ] { + assert!( + DuplicateAgentExecutable::detect( + CodingAgent::ClaudeCode, + &command, + InvocationForm::Run, + ) + .is_none(), + "unexpected duplicate for {command:?}" + ); + } +} + +#[test] +fn invocation_diagnostic_doctor_redacts_arguments_unless_explicitly_requested() { + let diagnostic = DuplicateAgentExecutable::detect( + CodingAgent::ClaudeCode, + &argv(&["/opt/bin/claude-code", "-p", "private synthetic value"]), + InvocationForm::Run, + ) + .unwrap(); + + let safe = diagnostic.format_doctor(false); + assert!(safe.contains("code = possible_duplicate_agent_executable")); + assert!(safe.contains("observed = nemo-relay run --agent claude -- claude")); + assert!(safe.contains("")); + assert!(!safe.contains("/opt/bin/claude-code")); + assert!(!safe.contains("private synthetic value")); + + let full = diagnostic.format_doctor(true); + assert!(full.contains("/opt/bin/claude-code")); + assert!(full.contains("private synthetic value")); + assert!(full.contains("recommended = nemo-relay run --agent claude -- -p")); +} + +#[test] +fn invocation_diagnostic_uses_the_shortcut_command_shape() { + let diagnostic = DuplicateAgentExecutable::detect( + CodingAgent::Hermes, + &argv(&["hermes-agent", "chat"]), + InvocationForm::Shortcut, + ) + .unwrap(); + + let output = diagnostic.format_doctor(false); + assert!(output.contains("observed = nemo-relay hermes -- hermes")); + assert!(output.contains("recommended = nemo-relay hermes -- ''")); +} diff --git a/docs/nemo-relay-cli/basic-usage.mdx b/docs/nemo-relay-cli/basic-usage.mdx index ff5fe5176..bc727687a 100644 --- a/docs/nemo-relay-cli/basic-usage.mdx +++ b/docs/nemo-relay-cli/basic-usage.mdx @@ -74,6 +74,33 @@ instead of the built-in shortcut: nemo-relay run -- codex ``` +When `--agent` or an agent shortcut already selects the host, pass only that +host's arguments after `--`. For example, use: + +```bash +nemo-relay run --agent claude -- -p "Review this change" +``` + +Do not repeat the selected executable as the first argument: + +```bash +nemo-relay run --agent claude -- claude -p "Review this change" +``` + +Relay reports `possible_duplicate_agent_executable` when the first argument +after `--` resolves to the agent that Relay already selected. The warning is +advisory: Relay does not reject or rewrite the command. It does not inspect +later arguments or prompt content. + +Use the invocation doctor to compare the observed and recommended command +without launching an agent. Arguments are redacted by default; request the full +command only when it is safe to display potentially sensitive values: + +```bash +nemo-relay doctor invocation --agent claude -- claude -p "Review this change" +nemo-relay doctor invocation --agent claude --show-full-command -- claude -p "Review this change" +``` + For Claude Code and Codex, transparent mode leaves the caller's source settings, selected profile, and installed plugin state unchanged. A process marker makes any installed Relay MCP borrow the wrapper-owned dynamic gateway. Claude's From 79760d09344804ffefc2384c5c01432a82cf5968 Mon Sep 17 00:00:00 2001 From: Eric Evans <194135482+ericevans-nv@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:38:57 -0500 Subject: [PATCH 2/9] docs(cli): condense invocation diagnostic guidance Signed-off-by: Eric Evans <194135482+ericevans-nv@users.noreply.github.com> --- docs/nemo-relay-cli/basic-usage.mdx | 24 +++++++----------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/docs/nemo-relay-cli/basic-usage.mdx b/docs/nemo-relay-cli/basic-usage.mdx index bc727687a..5c85f3ae0 100644 --- a/docs/nemo-relay-cli/basic-usage.mdx +++ b/docs/nemo-relay-cli/basic-usage.mdx @@ -74,32 +74,22 @@ instead of the built-in shortcut: nemo-relay run -- codex ``` -When `--agent` or an agent shortcut already selects the host, pass only that -host's arguments after `--`. For example, use: +When `--agent` or an agent shortcut selects the host, pass only the host's +arguments after `--`: ```bash nemo-relay run --agent claude -- -p "Review this change" ``` -Do not repeat the selected executable as the first argument: +If the selected executable is repeated after `--`, Relay warns and continues +without modifying the command. Inspect the correction without launching the +agent: ```bash -nemo-relay run --agent claude -- claude -p "Review this change" +nemo-relay doctor invocation --agent claude -- claude ``` -Relay reports `possible_duplicate_agent_executable` when the first argument -after `--` resolves to the agent that Relay already selected. The warning is -advisory: Relay does not reject or rewrite the command. It does not inspect -later arguments or prompt content. - -Use the invocation doctor to compare the observed and recommended command -without launching an agent. Arguments are redacted by default; request the full -command only when it is safe to display potentially sensitive values: - -```bash -nemo-relay doctor invocation --agent claude -- claude -p "Review this change" -nemo-relay doctor invocation --agent claude --show-full-command -- claude -p "Review this change" -``` +Arguments are redacted unless `--show-full-command` is explicitly used. For Claude Code and Codex, transparent mode leaves the caller's source settings, selected profile, and installed plugin state unchanged. A process marker makes From ba3bb8a21dfd4293bf12617329b8073089816005 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Fri, 7 Aug 2026 08:16:08 -0700 Subject: [PATCH 3/9] fix(cli): make invocation diagnostics portable Signed-off-by: Alex Fournier --- crates/cli/src/diagnostics/invocation.rs | 12 +++--- crates/cli/tests/cli_tests.rs | 41 ++++++++++++++++++- .../shared/invocation_diagnostic_tests.rs | 29 +++++++------ 3 files changed, 61 insertions(+), 21 deletions(-) diff --git a/crates/cli/src/diagnostics/invocation.rs b/crates/cli/src/diagnostics/invocation.rs index 21f9b05c6..ba2f63b1b 100644 --- a/crates/cli/src/diagnostics/invocation.rs +++ b/crates/cli/src/diagnostics/invocation.rs @@ -14,23 +14,23 @@ pub(crate) enum InvocationForm { } #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct DuplicateAgentExecutable { +pub(crate) struct DuplicateAgentExecutable<'a> { agent: CodingAgent, form: InvocationForm, - command: Vec, + command: &'a [String], } -impl DuplicateAgentExecutable { +impl<'a> DuplicateAgentExecutable<'a> { pub(crate) fn detect( agent: CodingAgent, - command: &[String], + command: &'a [String], form: InvocationForm, ) -> Option { let executable = command.first()?; - (CodingAgent::infer(executable) == Some(agent)).then(|| Self { + (CodingAgent::infer(executable) == Some(agent)).then_some(Self { agent, form, - command: command.to_vec(), + command, }) } diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index 56544cf18..ceda229c6 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -86,6 +86,21 @@ fn read_jsonl_records(path: &Path) -> Vec { .collect() } +fn read_jsonl_event(path: &Path, event: &str) -> serde_json::Value { + read_jsonl_records(path) + .into_iter() + .find(|record| record["event"] == event) + .unwrap_or_else(|| panic!("missing {event} record in {}", path.display())) +} + +fn quoted_redacted_arguments() -> &'static str { + if cfg!(windows) { + "\"\"" + } else { + "''" + } +} + fn write_dynamic_plugin_manifest(dir: &std::path::Path, plugin_id: &str) { write_dynamic_plugin_manifest_with_options(dir, plugin_id, &["plugin_worker"], None); } @@ -3817,6 +3832,7 @@ command = "hermes --yolo chat" #[test] fn invocation_diagnostic_cli_warns_without_rewriting_the_command() { let temp = tempfile::tempdir().unwrap(); + let (logging_config, log_path) = write_jsonl_logging_config(temp.path()); let config = temp.path().join("config.toml"); std::fs::write( &config, @@ -3832,6 +3848,8 @@ anthropic_base_url = "http://127.0.0.1:1" .current_dir(temp.path()) .env("XDG_CONFIG_HOME", temp.path().join("xdg")) .env("HOME", temp.path()) + .args(["--log-config-path"]) + .arg(&logging_config) .args([ "--config", config.to_str().unwrap(), @@ -3862,6 +3880,13 @@ anthropic_base_url = "http://127.0.0.1:1" stdout.contains("/opt/bin/claude-code.exe -p synthetic prompt"), "{stdout}" ); + + let diagnostic = read_jsonl_event(&log_path, "agent_invocation_warning"); + assert_eq!(diagnostic["fields"]["agent"], "claude"); + assert_eq!(diagnostic["fields"]["duplicate_executable"], "claude"); + let diagnostic = diagnostic.to_string(); + assert!(!diagnostic.contains("/opt/bin/claude-code.exe")); + assert!(!diagnostic.contains("synthetic prompt")); } #[test] @@ -3947,6 +3972,7 @@ fn invocation_diagnostic_cli_doctor_requires_opt_in_for_full_output() { #[test] fn invocation_diagnostic_cli_warns_for_agent_shortcut() { let temp = tempfile::tempdir().unwrap(); + let (logging_config, log_path) = write_jsonl_logging_config(temp.path()); let xdg = temp.path().join("xdg"); std::fs::create_dir_all(&xdg).unwrap(); let cwd = temp.path().join("workdir"); @@ -3956,6 +3982,8 @@ fn invocation_diagnostic_cli_warns_for_agent_shortcut() { .current_dir(&cwd) .env("XDG_CONFIG_HOME", &xdg) .env("HOME", temp.path()) + .args(["--log-config-path"]) + .arg(&logging_config) .args(["claude", "--", "claude", "-p", "private synthetic value"]) .output() .unwrap(); @@ -3967,11 +3995,17 @@ fn invocation_diagnostic_cli_warns_for_agent_shortcut() { "{stderr}" ); assert!( - stderr.contains("Observed: nemo-relay claude -- claude ''"), + stderr.contains(&format!( + "Observed: nemo-relay claude -- claude {}", + quoted_redacted_arguments() + )), "{stderr}" ); assert!( - stderr.contains("Recommended: nemo-relay claude -- ''"), + stderr.contains(&format!( + "Recommended: nemo-relay claude -- {}", + quoted_redacted_arguments() + )), "{stderr}" ); assert!( @@ -3982,6 +4016,9 @@ fn invocation_diagnostic_cli_warns_for_agent_shortcut() { ); assert!(stderr.contains("setup requires a TTY"), "{stderr}"); assert!(!stderr.contains("private synthetic value"), "{stderr}"); + + let diagnostic = read_jsonl_event(&log_path, "agent_invocation_warning").to_string(); + assert!(!diagnostic.contains("private synthetic value")); } #[test] diff --git a/crates/cli/tests/coverage/shared/invocation_diagnostic_tests.rs b/crates/cli/tests/coverage/shared/invocation_diagnostic_tests.rs index c97f82289..81b7aa7ee 100644 --- a/crates/cli/tests/coverage/shared/invocation_diagnostic_tests.rs +++ b/crates/cli/tests/coverage/shared/invocation_diagnostic_tests.rs @@ -11,9 +11,14 @@ fn argv(values: &[&str]) -> Vec { fn invocation_diagnostic_detects_supported_agent_names_aliases_and_paths() { let cases = [ (CodingAgent::ClaudeCode, "claude"), + (CodingAgent::ClaudeCode, "claude-code"), + (CodingAgent::ClaudeCode, "/opt/bin/claude"), (CodingAgent::ClaudeCode, "/opt/bin/claude-code.exe"), + (CodingAgent::Codex, "codex"), (CodingAgent::Codex, r"C:\\tools\\CODEX.CMD"), + (CodingAgent::Hermes, "hermes"), (CodingAgent::Hermes, "hermes-agent"), + (CodingAgent::Hermes, "/opt/bin/hermes-agent.bat"), ]; for (agent, executable) in cases { @@ -51,12 +56,10 @@ fn invocation_diagnostic_only_inspects_the_first_post_boundary_token() { #[test] fn invocation_diagnostic_doctor_redacts_arguments_unless_explicitly_requested() { - let diagnostic = DuplicateAgentExecutable::detect( - CodingAgent::ClaudeCode, - &argv(&["/opt/bin/claude-code", "-p", "private synthetic value"]), - InvocationForm::Run, - ) - .unwrap(); + let command = argv(&["/opt/bin/claude-code", "-p", "private synthetic value"]); + let diagnostic = + DuplicateAgentExecutable::detect(CodingAgent::ClaudeCode, &command, InvocationForm::Run) + .unwrap(); let safe = diagnostic.format_doctor(false); assert!(safe.contains("code = possible_duplicate_agent_executable")); @@ -73,14 +76,14 @@ fn invocation_diagnostic_doctor_redacts_arguments_unless_explicitly_requested() #[test] fn invocation_diagnostic_uses_the_shortcut_command_shape() { - let diagnostic = DuplicateAgentExecutable::detect( - CodingAgent::Hermes, - &argv(&["hermes-agent", "chat"]), - InvocationForm::Shortcut, - ) - .unwrap(); + let command = argv(&["hermes-agent", "chat"]); + let diagnostic = + DuplicateAgentExecutable::detect(CodingAgent::Hermes, &command, InvocationForm::Shortcut) + .unwrap(); let output = diagnostic.format_doctor(false); + let redacted = + crate::process::shell_quote_arg_for_platform("", cfg!(windows)); assert!(output.contains("observed = nemo-relay hermes -- hermes")); - assert!(output.contains("recommended = nemo-relay hermes -- ''")); + assert!(output.contains(&format!("recommended = nemo-relay hermes -- {redacted}"))); } From 7818ea531006e0e1a029a538d77eed53380c5f75 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Fri, 7 Aug 2026 08:16:18 -0700 Subject: [PATCH 4/9] test: reduce observability test complexity Signed-off-by: Alex Fournier --- .../tests/unit/observability/plugin_component_tests.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/core/tests/unit/observability/plugin_component_tests.rs b/crates/core/tests/unit/observability/plugin_component_tests.rs index 5ee3cd9e1..4a0e9d9b6 100644 --- a/crates/core/tests/unit/observability/plugin_component_tests.rs +++ b/crates/core/tests/unit/observability/plugin_component_tests.rs @@ -494,6 +494,12 @@ fn default_config_and_component_conversion_cover_public_shape() { assert_eq!(generic.config["version"], json!(3)); assert_eq!(generic.config["atif"]["agent_name"], json!("NeMo Relay")); let serialized_endpoint = &generic.config["opentelemetry"]["endpoints"][0]; + assert_endpoint_batch_fields_omitted(serialized_endpoint); + + assert_endpoint_batch_fields_deserialize(); +} + +fn assert_endpoint_batch_fields_omitted(serialized_endpoint: &Json) { for field in [ "max_queue_size", "max_export_batch_size", @@ -501,8 +507,6 @@ fn default_config_and_component_conversion_cover_public_shape() { ] { assert!(serialized_endpoint.get(field).is_none()); } - - assert_endpoint_batch_fields_deserialize(); } fn assert_endpoint_batch_fields_deserialize() { From 6c7387e284fa5d607f34a604130496f9a4d454ff Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Fri, 7 Aug 2026 08:33:59 -0700 Subject: [PATCH 5/9] refactor(cli): use dry-run for invocation diagnostics Signed-off-by: Alex Fournier --- crates/cli/src/commands/diagnostics.rs | 54 +---------- crates/cli/src/commands/run.rs | 23 +++-- .../run}/invocation.rs | 74 ++++----------- crates/cli/src/diagnostics/mod.rs | 1 - crates/cli/tests/cli_tests.rs | 93 +++++-------------- .../cli/tests/coverage/commands/main_tests.rs | 42 ++++----- .../shared/invocation_diagnostic_tests.rs | 33 ++++--- docs/nemo-relay-cli/basic-usage.mdx | 10 +- 8 files changed, 91 insertions(+), 239 deletions(-) rename crates/cli/src/{diagnostics => commands/run}/invocation.rs (57%) diff --git a/crates/cli/src/commands/diagnostics.rs b/crates/cli/src/commands/diagnostics.rs index e60daefe7..ccf6cbe08 100644 --- a/crates/cli/src/commands/diagnostics.rs +++ b/crates/cli/src/commands/diagnostics.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use std::process::ExitCode; -use clap::{Args, Subcommand}; +use clap::Args; use serde_json::{Value, json}; use super::install::InstallTarget; @@ -12,10 +12,7 @@ use super::root::AgentArg; use crate::error::CliError; #[derive(Debug, Clone, Args)] -#[command(args_conflicts_with_subcommands = true)] pub(crate) struct DoctorCommand { - #[command(subcommand)] - pub(crate) command: Option, #[arg(value_enum, conflicts_with = "plugin")] pub(crate) agent: Option, #[arg(long, value_enum)] @@ -31,27 +28,6 @@ pub(crate) struct DoctorCommand { pub(crate) offline: bool, } -#[derive(Debug, Clone, Subcommand)] -pub(crate) enum DoctorSubcommand { - /// Inspect an agent invocation without launching it. - Invocation(InvocationDoctorCommand), -} - -#[derive(Debug, Clone, Args)] -pub(crate) struct InvocationDoctorCommand { - #[arg(long, value_enum)] - agent: AgentArg, - #[arg(long)] - shortcut: bool, - #[arg( - long, - help = "Display the complete invocation; arguments may contain sensitive data" - )] - show_full_command: bool, - #[arg(last = true, required = true)] - command: Vec, -} - #[derive(Debug, Clone, Args)] pub(crate) struct AgentsCommand { #[arg(long)] @@ -63,9 +39,6 @@ pub(super) async fn execute( server: &super::serve::ServerArgs, logging_fallback_error: Option<&CliError>, ) -> Result { - if let Some(DoctorSubcommand::Invocation(invocation)) = command.command { - return execute_invocation_doctor(invocation); - } if let Some(plugin) = command.plugin { return execute_plugin_doctor(plugin, command.install_dir, command.json); } @@ -80,31 +53,6 @@ pub(super) async fn execute( .await } -fn execute_invocation_doctor(command: InvocationDoctorCommand) -> Result { - let agent = command.agent.into(); - let form = if command.shortcut { - crate::diagnostics::invocation::InvocationForm::Shortcut - } else { - crate::diagnostics::invocation::InvocationForm::Run - }; - match crate::diagnostics::invocation::DuplicateAgentExecutable::detect( - agent, - &command.command, - form, - ) { - Some(diagnostic) => { - println!("{}", diagnostic.format_doctor(command.show_full_command)); - } - None => { - println!( - "INVOCATION DIAGNOSTIC\ncode = none\nselected_agent = {}\nresult = no duplicate agent executable detected", - agent.as_arg() - ); - } - } - Ok(ExitCode::SUCCESS) -} - fn execute_plugin_doctor( plugin: InstallTarget, install_dir: Option, diff --git a/crates/cli/src/commands/run.rs b/crates/cli/src/commands/run.rs index d40e4baa3..b28ccf3c0 100644 --- a/crates/cli/src/commands/run.rs +++ b/crates/cli/src/commands/run.rs @@ -11,9 +11,14 @@ use super::serve::ServerArgs; use crate::agents::CodingAgent; use crate::error::CliError; +mod invocation; + /// Args for an easy-path agent shortcut. #[derive(Debug, Clone, Args)] pub(crate) struct EasyPathCommand { + /// Print the resolved launch plan without setup, gateway startup, or agent execution. + #[arg(long)] + pub(super) dry_run: bool, #[arg(last = true)] pub(super) command: Vec, } @@ -61,11 +66,7 @@ pub(super) async fn execute( server: &ServerArgs, ) -> Result { if let Some(agent) = command.agent.map(Into::into) { - warn_for_possible_duplicate( - agent, - &command.command, - crate::diagnostics::invocation::InvocationForm::Run, - ); + warn_for_possible_duplicate(agent, &command.command, invocation::InvocationForm::Run); } let inherited = server.to_runtime(); crate::process::launcher::run(command.into_runtime(), Some(&inherited)).await @@ -89,7 +90,7 @@ pub(super) async fn easy_path( warn_for_possible_duplicate( agent, &command.command, - crate::diagnostics::invocation::InvocationForm::Shortcut, + invocation::InvocationForm::Shortcut, ); let inherited = server.to_runtime(); // An explicit config path is the user's contract. Without one, setup is required only when @@ -97,7 +98,7 @@ pub(super) async fn easy_path( // layer so process supervision receives a complete, agent-neutral run request. let explicit_config = inherited.config.as_deref(); let needs_setup = explicit_config.is_none() && !crate::configuration::any_config_file_exists(); - if needs_setup { + if needs_setup && !command.dry_run { let explicit_plugin_path = easy_path_plugin_config_path(&inherited); super::configure::run(Some(agent), explicit_plugin_path).await?; } @@ -108,7 +109,7 @@ pub(super) async fn easy_path( anthropic_base_url: None, session_metadata: None, plugin_config_path: None, - dry_run: false, + dry_run: command.dry_run, print: false, command: command.command, }; @@ -118,11 +119,9 @@ pub(super) async fn easy_path( fn warn_for_possible_duplicate( agent: CodingAgent, command: &[String], - form: crate::diagnostics::invocation::InvocationForm, + form: invocation::InvocationForm, ) { - if let Some(diagnostic) = - crate::diagnostics::invocation::DuplicateAgentExecutable::detect(agent, command, form) - { + if let Some(diagnostic) = invocation::DuplicateAgentExecutable::detect(agent, command, form) { diagnostic.log(); super::print_invocation_warning(&diagnostic.format_warning()); } diff --git a/crates/cli/src/diagnostics/invocation.rs b/crates/cli/src/commands/run/invocation.rs similarity index 57% rename from crates/cli/src/diagnostics/invocation.rs rename to crates/cli/src/commands/run/invocation.rs index ba2f63b1b..9fbdd8e31 100644 --- a/crates/cli/src/diagnostics/invocation.rs +++ b/crates/cli/src/commands/run/invocation.rs @@ -50,29 +50,6 @@ impl<'a> DuplicateAgentExecutable<'a> { ); } - pub(crate) fn format_doctor(&self, show_full_command: bool) -> String { - let visibility = if show_full_command { - "full command; may contain sensitive data" - } else { - "arguments redacted" - }; - format!( - "INVOCATION DIAGNOSTIC\n\ - code = {POSSIBLE_DUPLICATE_AGENT_EXECUTABLE}\n\ - confidence = high\n\ - selected_agent = {}\n\ - duplicate_executable = {}\n\ - visibility = {visibility}\n\ - observed = {}\n\ - recommended = {}\n\ - action = continue unchanged", - self.agent.as_arg(), - self.agent.as_arg(), - self.observed_command(show_full_command), - self.recommended_command(show_full_command), - ) - } - pub(crate) fn format_warning(&self) -> String { format!( "WARNING: Possible duplicate agent executable after `--`.\n\ @@ -80,60 +57,41 @@ impl<'a> DuplicateAgentExecutable<'a> { Duplicate executable: {}\n\ Observed: {}\n\ Recommended: {}\n\ - Doctor (safe): {}\n\ - Doctor (full): {}\n\ + Inspect without launching: {}\n\ Relay will continue without modifying the command.", self.agent.as_arg(), - self.observed_command(false), - self.recommended_command(false), - self.doctor_command(false), - self.doctor_command(true), + self.observed_command(), + self.recommended_command(), + self.dry_run_command(), ) } - fn observed_command(&self, show_full_command: bool) -> String { + fn observed_command(&self) -> String { let mut command = self.relay_prefix(); command.push("--".into()); - if show_full_command { - command.extend(self.command.iter().cloned()); - } else { - command.push(self.agent.as_arg().into()); - if self.command.len() > 1 { - command.push("".into()); - } + command.push(self.agent.as_arg().into()); + if self.command.len() > 1 { + command.push("".into()); } render_command(&command) } - fn recommended_command(&self, show_full_command: bool) -> String { + fn recommended_command(&self) -> String { let mut command = self.relay_prefix(); command.push("--".into()); - if show_full_command { - command.extend(self.command.iter().skip(1).cloned()); - } else if self.command.len() > 1 { + if self.command.len() > 1 { command.push("".into()); } render_command(&command) } - fn doctor_command(&self, show_full_command: bool) -> String { - let mut command = vec![ - "nemo-relay".into(), - "doctor".into(), - "invocation".into(), - "--agent".into(), - self.agent.as_arg().into(), - ]; - if self.form == InvocationForm::Shortcut { - command.push("--shortcut".into()); - } - if show_full_command { - command.push("--show-full-command".into()); - } + fn dry_run_command(&self) -> String { + let mut command = self.relay_prefix(); + command.push("--dry-run".into()); command.push("--".into()); command.push(self.agent.as_arg().into()); - if show_full_command && self.command.len() > 1 { - command.push("".into()); + if self.command.len() > 1 { + command.push("".into()); } render_command(&command) } @@ -162,5 +120,5 @@ fn render_command(command: &[String]) -> String { } #[cfg(test)] -#[path = "../../tests/coverage/shared/invocation_diagnostic_tests.rs"] +#[path = "../../../tests/coverage/shared/invocation_diagnostic_tests.rs"] mod tests; diff --git a/crates/cli/src/diagnostics/mod.rs b/crates/cli/src/diagnostics/mod.rs index 2e0ee8cd5..c033d7d54 100644 --- a/crates/cli/src/diagnostics/mod.rs +++ b/crates/cli/src/diagnostics/mod.rs @@ -10,7 +10,6 @@ //! - `format_human(&report)` / `format_json(&report)` render the report. mod environment; -pub(crate) mod invocation; mod model; mod probes; mod render; diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index ceda229c6..f4f2b615f 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -3929,46 +3929,6 @@ anthropic_base_url = "http://127.0.0.1:1" ); } -#[test] -fn invocation_diagnostic_cli_doctor_requires_opt_in_for_full_output() { - let safe = Command::new(gateway_bin()) - .args([ - "doctor", - "invocation", - "--agent", - "claude", - "--", - "claude", - "-p", - "private synthetic value", - ]) - .output() - .unwrap(); - assert!(safe.status.success()); - let safe_stdout = String::from_utf8_lossy(&safe.stdout); - assert!(safe_stdout.contains("")); - assert!(!safe_stdout.contains("private synthetic value")); - - let full = Command::new(gateway_bin()) - .args([ - "doctor", - "invocation", - "--agent", - "claude", - "--show-full-command", - "--", - "claude", - "-p", - "private synthetic value", - ]) - .output() - .unwrap(); - assert!(full.status.success()); - let full_stdout = String::from_utf8_lossy(&full.stdout); - assert!(full_stdout.contains("private synthetic value")); - assert!(full_stdout.contains("recommended = nemo-relay run --agent claude -- -p")); -} - #[test] fn invocation_diagnostic_cli_warns_for_agent_shortcut() { let temp = tempfile::tempdir().unwrap(); @@ -3984,11 +3944,18 @@ fn invocation_diagnostic_cli_warns_for_agent_shortcut() { .env("HOME", temp.path()) .args(["--log-config-path"]) .arg(&logging_config) - .args(["claude", "--", "claude", "-p", "private synthetic value"]) + .args([ + "claude", + "--dry-run", + "--", + "claude", + "-p", + "private synthetic value", + ]) .output() .unwrap(); - assert!(!output.status.success()); + assert!(output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); assert!( stderr.contains("possible_duplicate_agent_executable"), @@ -4009,44 +3976,26 @@ fn invocation_diagnostic_cli_warns_for_agent_shortcut() { "{stderr}" ); assert!( - stderr.contains( - "Doctor (safe): nemo-relay doctor invocation --agent claude --shortcut -- claude" - ), + stderr.contains("Inspect without launching: nemo-relay claude --dry-run -- claude"), "{stderr}" ); - assert!(stderr.contains("setup requires a TTY"), "{stderr}"); assert!(!stderr.contains("private synthetic value"), "{stderr}"); - let diagnostic = read_jsonl_event(&log_path, "agent_invocation_warning").to_string(); - assert!(!diagnostic.contains("private synthetic value")); -} - -#[test] -fn invocation_diagnostic_cli_doctor_reports_no_duplicate() { - let output = Command::new(gateway_bin()) - .args([ - "doctor", - "invocation", - "--agent", - "claude", - "--", - "-p", - "synthetic prompt", - ]) - .output() - .unwrap(); - - assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("code = none"), "{stdout}"); - assert!(stdout.contains("selected_agent = claude"), "{stdout}"); + let argv = stdout + .lines() + .find(|line| line.starts_with("argv = ")) + .expect("dry run should print the resolved argv"); assert!( - stdout.contains("result = no duplicate agent executable detected"), - "{stdout}" + argv.ends_with(" claude -p private synthetic value"), + "{argv}" ); + + let diagnostic = read_jsonl_event(&log_path, "agent_invocation_warning").to_string(); + assert!(!diagnostic.contains("private synthetic value")); assert!( - !stdout.contains("possible_duplicate_agent_executable"), - "{stdout}" + !xdg.join("nemo-relay/config.toml").exists(), + "shortcut dry run must not invoke first-use setup" ); } diff --git a/crates/cli/tests/coverage/commands/main_tests.rs b/crates/cli/tests/coverage/commands/main_tests.rs index 27012ae4a..51242647b 100644 --- a/crates/cli/tests/coverage/commands/main_tests.rs +++ b/crates/cli/tests/coverage/commands/main_tests.rs @@ -346,30 +346,26 @@ fn doctor_accepts_offline_flag() { } #[test] -fn invocation_diagnostic_parser_accepts_doctor_without_runtime_probe_flags() { - let cli = Cli::try_parse_from([ - "nemo-relay", - "doctor", - "invocation", - "--agent", - "claude", - "--", - "claude", - "-p", - "synthetic prompt", - ]) - .unwrap(); - match cli.command { - Some(Command::Doctor(command)) => assert!(matches!( - command.command, - Some(diagnostics::DoctorSubcommand::Invocation(_)) - )), - other => panic!("expected doctor invocation command, got {other:?}"), +fn agent_shortcut_parser_accepts_dry_run_before_forwarded_arguments() { + for shortcut in ["claude", "codex", "hermes"] { + let cli = Cli::try_parse_from([ + "nemo-relay", + shortcut, + "--dry-run", + "--", + shortcut, + "synthetic argument", + ]) + .unwrap(); + let command = match cli.command { + Some(Command::Claude(command)) + | Some(Command::Codex(command)) + | Some(Command::Hermes(command)) => command, + other => panic!("expected agent shortcut command, got {other:?}"), + }; + assert!(command.dry_run); + assert_eq!(command.command, [shortcut, "synthetic argument"]); } - - assert!( - Cli::try_parse_from(["nemo-relay", "doctor", "invocation", "--agent", "claude",]).is_err() - ); } #[test] diff --git a/crates/cli/tests/coverage/shared/invocation_diagnostic_tests.rs b/crates/cli/tests/coverage/shared/invocation_diagnostic_tests.rs index 81b7aa7ee..989a0891b 100644 --- a/crates/cli/tests/coverage/shared/invocation_diagnostic_tests.rs +++ b/crates/cli/tests/coverage/shared/invocation_diagnostic_tests.rs @@ -16,6 +16,7 @@ fn invocation_diagnostic_detects_supported_agent_names_aliases_and_paths() { (CodingAgent::ClaudeCode, "/opt/bin/claude-code.exe"), (CodingAgent::Codex, "codex"), (CodingAgent::Codex, r"C:\\tools\\CODEX.CMD"), + (CodingAgent::Codex, r"C:\\tools\\codex.com"), (CodingAgent::Hermes, "hermes"), (CodingAgent::Hermes, "hermes-agent"), (CodingAgent::Hermes, "/opt/bin/hermes-agent.bat"), @@ -55,23 +56,24 @@ fn invocation_diagnostic_only_inspects_the_first_post_boundary_token() { } #[test] -fn invocation_diagnostic_doctor_redacts_arguments_unless_explicitly_requested() { +fn invocation_diagnostic_warning_redacts_arguments_and_points_to_dry_run() { let command = argv(&["/opt/bin/claude-code", "-p", "private synthetic value"]); let diagnostic = DuplicateAgentExecutable::detect(CodingAgent::ClaudeCode, &command, InvocationForm::Run) .unwrap(); - let safe = diagnostic.format_doctor(false); - assert!(safe.contains("code = possible_duplicate_agent_executable")); - assert!(safe.contains("observed = nemo-relay run --agent claude -- claude")); - assert!(safe.contains("")); - assert!(!safe.contains("/opt/bin/claude-code")); - assert!(!safe.contains("private synthetic value")); - - let full = diagnostic.format_doctor(true); - assert!(full.contains("/opt/bin/claude-code")); - assert!(full.contains("private synthetic value")); - assert!(full.contains("recommended = nemo-relay run --agent claude -- -p")); + let output = diagnostic.format_warning(); + assert!(output.contains("Diagnostic: possible_duplicate_agent_executable")); + assert!(output.contains("Observed: nemo-relay run --agent claude -- claude")); + assert!(output.contains("Recommended: nemo-relay run --agent claude --")); + assert!( + output.contains( + "Inspect without launching: nemo-relay run --agent claude --dry-run -- claude" + ) + ); + assert!(output.contains("")); + assert!(!output.contains("/opt/bin/claude-code")); + assert!(!output.contains("private synthetic value")); } #[test] @@ -81,9 +83,10 @@ fn invocation_diagnostic_uses_the_shortcut_command_shape() { DuplicateAgentExecutable::detect(CodingAgent::Hermes, &command, InvocationForm::Shortcut) .unwrap(); - let output = diagnostic.format_doctor(false); + let output = diagnostic.format_warning(); let redacted = crate::process::shell_quote_arg_for_platform("", cfg!(windows)); - assert!(output.contains("observed = nemo-relay hermes -- hermes")); - assert!(output.contains(&format!("recommended = nemo-relay hermes -- {redacted}"))); + assert!(output.contains("Observed: nemo-relay hermes -- hermes")); + assert!(output.contains(&format!("Recommended: nemo-relay hermes -- {redacted}"))); + assert!(output.contains("Inspect without launching: nemo-relay hermes --dry-run -- hermes")); } diff --git a/docs/nemo-relay-cli/basic-usage.mdx b/docs/nemo-relay-cli/basic-usage.mdx index 5c85f3ae0..b58c92b95 100644 --- a/docs/nemo-relay-cli/basic-usage.mdx +++ b/docs/nemo-relay-cli/basic-usage.mdx @@ -82,15 +82,15 @@ nemo-relay run --agent claude -- -p "Review this change" ``` If the selected executable is repeated after `--`, Relay warns and continues -without modifying the command. Inspect the correction without launching the -agent: +without modifying the command. Add `--dry-run` before `--` to inspect the +resolved launch plan without running setup, starting the gateway, or launching +the agent: ```bash -nemo-relay doctor invocation --agent claude -- claude +nemo-relay run --agent claude --dry-run -- claude +nemo-relay claude --dry-run -- claude ``` -Arguments are redacted unless `--show-full-command` is explicitly used. - For Claude Code and Codex, transparent mode leaves the caller's source settings, selected profile, and installed plugin state unchanged. A process marker makes any installed Relay MCP borrow the wrapper-owned dynamic gateway. Claude's From 5a921e09d89376a9bacbeb5de7d6ab9e1f8df3e4 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Fri, 7 Aug 2026 08:36:46 -0700 Subject: [PATCH 6/9] docs(cli): clarify dry-run argument visibility Signed-off-by: Alex Fournier --- crates/cli/src/commands/run.rs | 2 +- docs/nemo-relay-cli/basic-usage.mdx | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/cli/src/commands/run.rs b/crates/cli/src/commands/run.rs index b28ccf3c0..3c91cc614 100644 --- a/crates/cli/src/commands/run.rs +++ b/crates/cli/src/commands/run.rs @@ -16,7 +16,7 @@ mod invocation; /// Args for an easy-path agent shortcut. #[derive(Debug, Clone, Args)] pub(crate) struct EasyPathCommand { - /// Print the resolved launch plan without setup, gateway startup, or agent execution. + /// Print the resolved launch plan, including forwarded arguments, without executing it. #[arg(long)] pub(super) dry_run: bool, #[arg(last = true)] diff --git a/docs/nemo-relay-cli/basic-usage.mdx b/docs/nemo-relay-cli/basic-usage.mdx index b58c92b95..c26a7adf3 100644 --- a/docs/nemo-relay-cli/basic-usage.mdx +++ b/docs/nemo-relay-cli/basic-usage.mdx @@ -91,6 +91,9 @@ nemo-relay run --agent claude --dry-run -- claude nemo-relay claude --dry-run -- claude ``` +Dry-run output includes forwarded arguments. Do not share it when those +arguments contain prompts, credentials, or other sensitive values. + For Claude Code and Codex, transparent mode leaves the caller's source settings, selected profile, and installed plugin state unchanged. A process marker makes any installed Relay MCP borrow the wrapper-owned dynamic gateway. Claude's From 01e538a0fd1c1e38d8043c16851faaf5b353aa70 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Fri, 7 Aug 2026 08:51:45 -0700 Subject: [PATCH 7/9] refactor(cli): scope invocation checks to dry runs Signed-off-by: Alex Fournier --- crates/cli/src/commands/run.rs | 98 ++++++++++++-- crates/cli/src/commands/run/invocation.rs | 124 ------------------ crates/cli/tests/cli_tests.rs | 51 +++++-- .../cli/tests/coverage/commands/main_tests.rs | 63 +++++++++ .../shared/invocation_diagnostic_tests.rs | 92 ------------- docs/nemo-relay-cli/basic-usage.mdx | 11 +- 6 files changed, 197 insertions(+), 242 deletions(-) delete mode 100644 crates/cli/src/commands/run/invocation.rs delete mode 100644 crates/cli/tests/coverage/shared/invocation_diagnostic_tests.rs diff --git a/crates/cli/src/commands/run.rs b/crates/cli/src/commands/run.rs index 3c91cc614..6485de2d6 100644 --- a/crates/cli/src/commands/run.rs +++ b/crates/cli/src/commands/run.rs @@ -11,7 +11,13 @@ use super::serve::ServerArgs; use crate::agents::CodingAgent; use crate::error::CliError; -mod invocation; +const POSSIBLE_DUPLICATE_AGENT_EXECUTABLE: &str = "possible_duplicate_agent_executable"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum InvocationForm { + Run, + Shortcut, +} /// Args for an easy-path agent shortcut. #[derive(Debug, Clone, Args)] @@ -65,8 +71,10 @@ pub(super) async fn execute( command: RunCommand, server: &ServerArgs, ) -> Result { - if let Some(agent) = command.agent.map(Into::into) { - warn_for_possible_duplicate(agent, &command.command, invocation::InvocationForm::Run); + if command.dry_run + && let Some(agent) = command.agent.map(Into::into) + { + warn_for_possible_duplicate(agent, &command.command, InvocationForm::Run); } let inherited = server.to_runtime(); crate::process::launcher::run(command.into_runtime(), Some(&inherited)).await @@ -87,11 +95,9 @@ pub(super) async fn easy_path( command: EasyPathCommand, server: &ServerArgs, ) -> Result { - warn_for_possible_duplicate( - agent, - &command.command, - invocation::InvocationForm::Shortcut, - ); + if command.dry_run { + warn_for_possible_duplicate(agent, &command.command, InvocationForm::Shortcut); + } let inherited = server.to_runtime(); // An explicit config path is the user's contract. Without one, setup is required only when // none of the normal discovery layers exists. Keep this interactive decision in the command @@ -116,13 +122,77 @@ pub(super) async fn easy_path( crate::process::launcher::run(runtime, Some(&inherited)).await } -fn warn_for_possible_duplicate( +fn warn_for_possible_duplicate(agent: CodingAgent, command: &[String], form: InvocationForm) { + let Some(warning) = possible_duplicate_agent_warning(agent, command, form) else { + return; + }; + let agent = agent.as_arg(); + log::warn!( + target: "nemo_relay.cli", + event = "agent_invocation_warning", + diagnostic_code = POSSIBLE_DUPLICATE_AGENT_EXECUTABLE, + agent = agent, + duplicate_executable = agent, + confidence = "high", + action = "dry_run_warning", + command_modified = false, + arguments_redacted = true; + "Possible duplicate agent executable during dry-run validation" + ); + super::print_invocation_warning(&warning); +} + +pub(super) fn possible_duplicate_agent_warning( agent: CodingAgent, command: &[String], - form: invocation::InvocationForm, -) { - if let Some(diagnostic) = invocation::DuplicateAgentExecutable::detect(agent, command, form) { - diagnostic.log(); - super::print_invocation_warning(&diagnostic.format_warning()); + form: InvocationForm, +) -> Option { + let executable = command.first()?; + if CodingAgent::infer(executable) != Some(agent) { + return None; + } + + let mut observed = relay_prefix(agent, form); + observed.extend(["--dry-run".into(), "--".into(), agent.as_arg().into()]); + if command.len() > 1 { + observed.push("".into()); + } + + let mut recommended = relay_prefix(agent, form); + recommended.extend(["--dry-run".into(), "--".into()]); + if command.len() > 1 { + recommended.push("".into()); } + + Some(format!( + "WARNING: Possible duplicate agent executable after `--`.\n\ + Diagnostic: {POSSIBLE_DUPLICATE_AGENT_EXECUTABLE}\n\ + Duplicate executable: {}\n\ + Observed: {}\n\ + Recommended: {}\n\ + Dry-run validation will continue without launching the agent.", + agent.as_arg(), + render_command(&observed), + render_command(&recommended), + )) +} + +fn relay_prefix(agent: CodingAgent, form: InvocationForm) -> Vec { + match form { + InvocationForm::Run => vec![ + "nemo-relay".into(), + "run".into(), + "--agent".into(), + agent.as_arg().into(), + ], + InvocationForm::Shortcut => vec!["nemo-relay".into(), agent.as_arg().into()], + } +} + +fn render_command(command: &[String]) -> String { + command + .iter() + .map(|argument| crate::process::shell_quote_arg_for_platform(argument, cfg!(windows))) + .collect::>() + .join(" ") } diff --git a/crates/cli/src/commands/run/invocation.rs b/crates/cli/src/commands/run/invocation.rs deleted file mode 100644 index 9fbdd8e31..000000000 --- a/crates/cli/src/commands/run/invocation.rs +++ /dev/null @@ -1,124 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Advisory diagnostics for structurally suspicious agent invocations. - -use crate::agents::CodingAgent; - -pub(crate) const POSSIBLE_DUPLICATE_AGENT_EXECUTABLE: &str = "possible_duplicate_agent_executable"; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum InvocationForm { - Run, - Shortcut, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct DuplicateAgentExecutable<'a> { - agent: CodingAgent, - form: InvocationForm, - command: &'a [String], -} - -impl<'a> DuplicateAgentExecutable<'a> { - pub(crate) fn detect( - agent: CodingAgent, - command: &'a [String], - form: InvocationForm, - ) -> Option { - let executable = command.first()?; - (CodingAgent::infer(executable) == Some(agent)).then_some(Self { - agent, - form, - command, - }) - } - - pub(crate) fn log(&self) { - let agent = self.agent.as_arg(); - log::warn!( - target: "nemo_relay.cli", - event = "agent_invocation_warning", - diagnostic_code = POSSIBLE_DUPLICATE_AGENT_EXECUTABLE, - agent = agent, - duplicate_executable = agent, - confidence = "high", - action = "continued", - command_modified = false, - arguments_redacted = true; - "Possible duplicate agent executable after `--`" - ); - } - - pub(crate) fn format_warning(&self) -> String { - format!( - "WARNING: Possible duplicate agent executable after `--`.\n\ - Diagnostic: {POSSIBLE_DUPLICATE_AGENT_EXECUTABLE}\n\ - Duplicate executable: {}\n\ - Observed: {}\n\ - Recommended: {}\n\ - Inspect without launching: {}\n\ - Relay will continue without modifying the command.", - self.agent.as_arg(), - self.observed_command(), - self.recommended_command(), - self.dry_run_command(), - ) - } - - fn observed_command(&self) -> String { - let mut command = self.relay_prefix(); - command.push("--".into()); - command.push(self.agent.as_arg().into()); - if self.command.len() > 1 { - command.push("".into()); - } - render_command(&command) - } - - fn recommended_command(&self) -> String { - let mut command = self.relay_prefix(); - command.push("--".into()); - if self.command.len() > 1 { - command.push("".into()); - } - render_command(&command) - } - - fn dry_run_command(&self) -> String { - let mut command = self.relay_prefix(); - command.push("--dry-run".into()); - command.push("--".into()); - command.push(self.agent.as_arg().into()); - if self.command.len() > 1 { - command.push("".into()); - } - render_command(&command) - } - - fn relay_prefix(&self) -> Vec { - match self.form { - InvocationForm::Run => vec![ - "nemo-relay".into(), - "run".into(), - "--agent".into(), - self.agent.as_arg().into(), - ], - InvocationForm::Shortcut => { - vec!["nemo-relay".into(), self.agent.as_arg().into()] - } - } - } -} - -fn render_command(command: &[String]) -> String { - command - .iter() - .map(|argument| crate::process::shell_quote_arg_for_platform(argument, cfg!(windows))) - .collect::>() - .join(" ") -} - -#[cfg(test)] -#[path = "../../../tests/coverage/shared/invocation_diagnostic_tests.rs"] -mod tests; diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index f4f2b615f..774afa3b6 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -3830,7 +3830,7 @@ command = "hermes --yolo chat" } #[test] -fn invocation_diagnostic_cli_warns_without_rewriting_the_command() { +fn invocation_diagnostic_cli_warns_during_dry_run_without_rewriting_the_plan() { let temp = tempfile::tempdir().unwrap(); let (logging_config, log_path) = write_jsonl_logging_config(temp.path()); let config = temp.path().join("config.toml"); @@ -3871,7 +3871,7 @@ anthropic_base_url = "http://127.0.0.1:1" stderr.contains("possible_duplicate_agent_executable"), "{stderr}" ); - assert!(stderr.contains("Relay will continue without modifying the command")); + assert!(stderr.contains("Dry-run validation will continue without launching the agent")); assert!(!stderr.contains("/opt/bin/claude-code.exe")); assert!(!stderr.contains("synthetic prompt")); @@ -3889,6 +3889,45 @@ anthropic_base_url = "http://127.0.0.1:1" assert!(!diagnostic.contains("synthetic prompt")); } +#[test] +fn invocation_diagnostic_does_not_preflight_live_launches() { + let temp = tempfile::tempdir().unwrap(); + let config = temp.path().join("config.toml"); + std::fs::write( + &config, + r#" +[agents.claude] +command = "nemo-relay-test-agent-that-does-not-exist" +"#, + ) + .unwrap(); + + let output = Command::new(gateway_bin()) + .current_dir(temp.path()) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("HOME", temp.path()) + .args([ + "--config", + config.to_str().unwrap(), + "run", + "--agent", + "claude", + "--", + "claude", + "private synthetic value", + ]) + .output() + .unwrap(); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stderr.contains("possible_duplicate_agent_executable"), + "{stderr}" + ); + assert!(stderr.contains("error_kind=io"), "{stderr}"); +} + #[test] fn invocation_diagnostic_cli_ignores_agent_names_after_a_different_first_token() { let temp = tempfile::tempdir().unwrap(); @@ -3963,22 +4002,18 @@ fn invocation_diagnostic_cli_warns_for_agent_shortcut() { ); assert!( stderr.contains(&format!( - "Observed: nemo-relay claude -- claude {}", + "Observed: nemo-relay claude --dry-run -- claude {}", quoted_redacted_arguments() )), "{stderr}" ); assert!( stderr.contains(&format!( - "Recommended: nemo-relay claude -- {}", + "Recommended: nemo-relay claude --dry-run -- {}", quoted_redacted_arguments() )), "{stderr}" ); - assert!( - stderr.contains("Inspect without launching: nemo-relay claude --dry-run -- claude"), - "{stderr}" - ); assert!(!stderr.contains("private synthetic value"), "{stderr}"); let stdout = String::from_utf8_lossy(&output.stdout); diff --git a/crates/cli/tests/coverage/commands/main_tests.rs b/crates/cli/tests/coverage/commands/main_tests.rs index 51242647b..1a3286634 100644 --- a/crates/cli/tests/coverage/commands/main_tests.rs +++ b/crates/cli/tests/coverage/commands/main_tests.rs @@ -368,6 +368,69 @@ fn agent_shortcut_parser_accepts_dry_run_before_forwarded_arguments() { } } +#[test] +fn dry_run_diagnostic_recognizes_supported_agent_executable_forms() { + let cases = [ + (CodingAgent::ClaudeCode, "claude"), + (CodingAgent::ClaudeCode, "claude-code"), + (CodingAgent::ClaudeCode, "/opt/bin/claude"), + (CodingAgent::ClaudeCode, "/opt/bin/claude-code.exe"), + (CodingAgent::Codex, "codex"), + (CodingAgent::Codex, r"C:\tools\CODEX.CMD"), + (CodingAgent::Codex, r"C:\tools\codex.com"), + (CodingAgent::Hermes, "hermes"), + (CodingAgent::Hermes, "hermes-agent"), + (CodingAgent::Hermes, "/opt/bin/hermes-agent.bat"), + ]; + + for (agent, executable) in cases { + let command = vec![executable.to_string(), "synthetic argument".to_string()]; + assert!( + run::possible_duplicate_agent_warning(agent, &command, run::InvocationForm::Run) + .is_some(), + "expected {executable:?} to duplicate {agent:?}" + ); + } +} + +#[test] +fn dry_run_diagnostic_checks_only_the_first_forwarded_token_and_redacts_the_rest() { + for command in [ + vec![], + vec!["-p".to_string(), "claude appears later".to_string()], + vec!["my-wrapper".to_string(), "claude".to_string()], + vec!["codex".to_string(), "claude".to_string()], + ] { + assert!( + run::possible_duplicate_agent_warning( + CodingAgent::ClaudeCode, + &command, + run::InvocationForm::Run, + ) + .is_none(), + "unexpected duplicate for {command:?}" + ); + } + + let command = vec![ + "/opt/bin/claude-code".to_string(), + "-p".to_string(), + "private synthetic value".to_string(), + ]; + let warning = run::possible_duplicate_agent_warning( + CodingAgent::ClaudeCode, + &command, + run::InvocationForm::Shortcut, + ) + .unwrap(); + assert!(warning.contains("Diagnostic: possible_duplicate_agent_executable")); + assert!(warning.contains("Observed: nemo-relay claude --dry-run -- claude")); + assert!(warning.contains("Recommended: nemo-relay claude --dry-run --")); + assert!(warning.contains("")); + assert!(!warning.contains("/opt/bin/claude-code")); + assert!(!warning.contains("private synthetic value")); +} + #[test] fn multi_agent_operations_attempt_every_target_before_reporting_errors() { let visited = std::cell::RefCell::new(Vec::new()); diff --git a/crates/cli/tests/coverage/shared/invocation_diagnostic_tests.rs b/crates/cli/tests/coverage/shared/invocation_diagnostic_tests.rs deleted file mode 100644 index 989a0891b..000000000 --- a/crates/cli/tests/coverage/shared/invocation_diagnostic_tests.rs +++ /dev/null @@ -1,92 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -use super::*; - -fn argv(values: &[&str]) -> Vec { - values.iter().map(|value| (*value).to_string()).collect() -} - -#[test] -fn invocation_diagnostic_detects_supported_agent_names_aliases_and_paths() { - let cases = [ - (CodingAgent::ClaudeCode, "claude"), - (CodingAgent::ClaudeCode, "claude-code"), - (CodingAgent::ClaudeCode, "/opt/bin/claude"), - (CodingAgent::ClaudeCode, "/opt/bin/claude-code.exe"), - (CodingAgent::Codex, "codex"), - (CodingAgent::Codex, r"C:\\tools\\CODEX.CMD"), - (CodingAgent::Codex, r"C:\\tools\\codex.com"), - (CodingAgent::Hermes, "hermes"), - (CodingAgent::Hermes, "hermes-agent"), - (CodingAgent::Hermes, "/opt/bin/hermes-agent.bat"), - ]; - - for (agent, executable) in cases { - assert!( - DuplicateAgentExecutable::detect( - agent, - &argv(&[executable, "synthetic argument"]), - InvocationForm::Run, - ) - .is_some(), - "expected {executable:?} to duplicate {agent:?}" - ); - } -} - -#[test] -fn invocation_diagnostic_only_inspects_the_first_post_boundary_token() { - for command in [ - argv(&[]), - argv(&["-p", "claude appears later"]), - argv(&["my-wrapper", "claude"]), - argv(&["codex", "claude"]), - ] { - assert!( - DuplicateAgentExecutable::detect( - CodingAgent::ClaudeCode, - &command, - InvocationForm::Run, - ) - .is_none(), - "unexpected duplicate for {command:?}" - ); - } -} - -#[test] -fn invocation_diagnostic_warning_redacts_arguments_and_points_to_dry_run() { - let command = argv(&["/opt/bin/claude-code", "-p", "private synthetic value"]); - let diagnostic = - DuplicateAgentExecutable::detect(CodingAgent::ClaudeCode, &command, InvocationForm::Run) - .unwrap(); - - let output = diagnostic.format_warning(); - assert!(output.contains("Diagnostic: possible_duplicate_agent_executable")); - assert!(output.contains("Observed: nemo-relay run --agent claude -- claude")); - assert!(output.contains("Recommended: nemo-relay run --agent claude --")); - assert!( - output.contains( - "Inspect without launching: nemo-relay run --agent claude --dry-run -- claude" - ) - ); - assert!(output.contains("")); - assert!(!output.contains("/opt/bin/claude-code")); - assert!(!output.contains("private synthetic value")); -} - -#[test] -fn invocation_diagnostic_uses_the_shortcut_command_shape() { - let command = argv(&["hermes-agent", "chat"]); - let diagnostic = - DuplicateAgentExecutable::detect(CodingAgent::Hermes, &command, InvocationForm::Shortcut) - .unwrap(); - - let output = diagnostic.format_warning(); - let redacted = - crate::process::shell_quote_arg_for_platform("", cfg!(windows)); - assert!(output.contains("Observed: nemo-relay hermes -- hermes")); - assert!(output.contains(&format!("Recommended: nemo-relay hermes -- {redacted}"))); - assert!(output.contains("Inspect without launching: nemo-relay hermes --dry-run -- hermes")); -} diff --git a/docs/nemo-relay-cli/basic-usage.mdx b/docs/nemo-relay-cli/basic-usage.mdx index c26a7adf3..16acbbb74 100644 --- a/docs/nemo-relay-cli/basic-usage.mdx +++ b/docs/nemo-relay-cli/basic-usage.mdx @@ -81,16 +81,19 @@ arguments after `--`: nemo-relay run --agent claude -- -p "Review this change" ``` -If the selected executable is repeated after `--`, Relay warns and continues -without modifying the command. Add `--dry-run` before `--` to inspect the -resolved launch plan without running setup, starting the gateway, or launching -the agent: +Add `--dry-run` before `--` to validate and inspect the resolved launch plan +without running setup, starting the gateway, or launching the agent. During +dry-run validation, Relay warns when the selected executable is repeated after +`--`: ```bash nemo-relay run --agent claude --dry-run -- claude nemo-relay claude --dry-run -- claude ``` +Live launches do not perform this diagnostic or rewrite forwarded arguments. +The selected agent receives the arguments exactly as provided. + Dry-run output includes forwarded arguments. Do not share it when those arguments contain prompts, credentials, or other sensitive values. From 36ea9013eeb81c51dcd8f2f47b8a7c0e2c9df215 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Fri, 7 Aug 2026 09:09:28 -0700 Subject: [PATCH 8/9] refactor(cli): log dry-run invocation diagnostics Signed-off-by: Alex Fournier --- crates/cli/src/commands/mod.rs | 4 - crates/cli/src/commands/run.rs | 78 +++---------------- crates/cli/tests/cli_tests.rs | 23 ------ .../cli/tests/coverage/commands/main_tests.rs | 30 +------ docs/nemo-relay-cli/basic-usage.mdx | 4 +- 5 files changed, 17 insertions(+), 122 deletions(-) diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 03411920c..3b1d3bcf8 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -102,10 +102,6 @@ fn configure_logging(cli: &Cli) -> Result { }) } -fn print_invocation_warning(warning: &str) { - eprintln!("{warning}"); -} - async fn dispatch(bootstrap_shutdown_token: Option) -> Result { let cli = Cli::parse(); let command_name = cli diff --git a/crates/cli/src/commands/run.rs b/crates/cli/src/commands/run.rs index 6485de2d6..c1f636b95 100644 --- a/crates/cli/src/commands/run.rs +++ b/crates/cli/src/commands/run.rs @@ -13,12 +13,6 @@ use crate::error::CliError; const POSSIBLE_DUPLICATE_AGENT_EXECUTABLE: &str = "possible_duplicate_agent_executable"; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum InvocationForm { - Run, - Shortcut, -} - /// Args for an easy-path agent shortcut. #[derive(Debug, Clone, Args)] pub(crate) struct EasyPathCommand { @@ -74,9 +68,10 @@ pub(super) async fn execute( if command.dry_run && let Some(agent) = command.agent.map(Into::into) { - warn_for_possible_duplicate(agent, &command.command, InvocationForm::Run); + warn_for_possible_duplicate(agent, &command.command); } let inherited = server.to_runtime(); + // The launcher prints the plan and returns before gateway or child execution for dry runs. crate::process::launcher::run(command.into_runtime(), Some(&inherited)).await } @@ -96,7 +91,7 @@ pub(super) async fn easy_path( server: &ServerArgs, ) -> Result { if command.dry_run { - warn_for_possible_duplicate(agent, &command.command, InvocationForm::Shortcut); + warn_for_possible_duplicate(agent, &command.command); } let inherited = server.to_runtime(); // An explicit config path is the user's contract. Without one, setup is required only when @@ -119,13 +114,14 @@ pub(super) async fn easy_path( print: false, command: command.command, }; + // The launcher prints the plan and returns before gateway or child execution for dry runs. crate::process::launcher::run(runtime, Some(&inherited)).await } -fn warn_for_possible_duplicate(agent: CodingAgent, command: &[String], form: InvocationForm) { - let Some(warning) = possible_duplicate_agent_warning(agent, command, form) else { +fn warn_for_possible_duplicate(agent: CodingAgent, command: &[String]) { + if !has_duplicate_agent_executable(agent, command) { return; - }; + } let agent = agent.as_arg(); log::warn!( target: "nemo_relay.cli", @@ -134,65 +130,15 @@ fn warn_for_possible_duplicate(agent: CodingAgent, command: &[String], form: Inv agent = agent, duplicate_executable = agent, confidence = "high", - action = "dry_run_warning", + action = "remove_duplicate_executable", command_modified = false, arguments_redacted = true; - "Possible duplicate agent executable during dry-run validation" + "Possible duplicate agent executable after `--`; remove the repeated executable" ); - super::print_invocation_warning(&warning); -} - -pub(super) fn possible_duplicate_agent_warning( - agent: CodingAgent, - command: &[String], - form: InvocationForm, -) -> Option { - let executable = command.first()?; - if CodingAgent::infer(executable) != Some(agent) { - return None; - } - - let mut observed = relay_prefix(agent, form); - observed.extend(["--dry-run".into(), "--".into(), agent.as_arg().into()]); - if command.len() > 1 { - observed.push("".into()); - } - - let mut recommended = relay_prefix(agent, form); - recommended.extend(["--dry-run".into(), "--".into()]); - if command.len() > 1 { - recommended.push("".into()); - } - - Some(format!( - "WARNING: Possible duplicate agent executable after `--`.\n\ - Diagnostic: {POSSIBLE_DUPLICATE_AGENT_EXECUTABLE}\n\ - Duplicate executable: {}\n\ - Observed: {}\n\ - Recommended: {}\n\ - Dry-run validation will continue without launching the agent.", - agent.as_arg(), - render_command(&observed), - render_command(&recommended), - )) -} - -fn relay_prefix(agent: CodingAgent, form: InvocationForm) -> Vec { - match form { - InvocationForm::Run => vec![ - "nemo-relay".into(), - "run".into(), - "--agent".into(), - agent.as_arg().into(), - ], - InvocationForm::Shortcut => vec!["nemo-relay".into(), agent.as_arg().into()], - } } -fn render_command(command: &[String]) -> String { +pub(super) fn has_duplicate_agent_executable(agent: CodingAgent, command: &[String]) -> bool { command - .iter() - .map(|argument| crate::process::shell_quote_arg_for_platform(argument, cfg!(windows))) - .collect::>() - .join(" ") + .first() + .is_some_and(|executable| CodingAgent::infer(executable) == Some(agent)) } diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index 774afa3b6..60279bdeb 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -93,14 +93,6 @@ fn read_jsonl_event(path: &Path, event: &str) -> serde_json::Value { .unwrap_or_else(|| panic!("missing {event} record in {}", path.display())) } -fn quoted_redacted_arguments() -> &'static str { - if cfg!(windows) { - "\"\"" - } else { - "''" - } -} - fn write_dynamic_plugin_manifest(dir: &std::path::Path, plugin_id: &str) { write_dynamic_plugin_manifest_with_options(dir, plugin_id, &["plugin_worker"], None); } @@ -3871,7 +3863,6 @@ anthropic_base_url = "http://127.0.0.1:1" stderr.contains("possible_duplicate_agent_executable"), "{stderr}" ); - assert!(stderr.contains("Dry-run validation will continue without launching the agent")); assert!(!stderr.contains("/opt/bin/claude-code.exe")); assert!(!stderr.contains("synthetic prompt")); @@ -4000,20 +3991,6 @@ fn invocation_diagnostic_cli_warns_for_agent_shortcut() { stderr.contains("possible_duplicate_agent_executable"), "{stderr}" ); - assert!( - stderr.contains(&format!( - "Observed: nemo-relay claude --dry-run -- claude {}", - quoted_redacted_arguments() - )), - "{stderr}" - ); - assert!( - stderr.contains(&format!( - "Recommended: nemo-relay claude --dry-run -- {}", - quoted_redacted_arguments() - )), - "{stderr}" - ); assert!(!stderr.contains("private synthetic value"), "{stderr}"); let stdout = String::from_utf8_lossy(&output.stdout); diff --git a/crates/cli/tests/coverage/commands/main_tests.rs b/crates/cli/tests/coverage/commands/main_tests.rs index 1a3286634..399958631 100644 --- a/crates/cli/tests/coverage/commands/main_tests.rs +++ b/crates/cli/tests/coverage/commands/main_tests.rs @@ -386,15 +386,14 @@ fn dry_run_diagnostic_recognizes_supported_agent_executable_forms() { for (agent, executable) in cases { let command = vec![executable.to_string(), "synthetic argument".to_string()]; assert!( - run::possible_duplicate_agent_warning(agent, &command, run::InvocationForm::Run) - .is_some(), + run::has_duplicate_agent_executable(agent, &command), "expected {executable:?} to duplicate {agent:?}" ); } } #[test] -fn dry_run_diagnostic_checks_only_the_first_forwarded_token_and_redacts_the_rest() { +fn dry_run_diagnostic_checks_only_the_first_forwarded_token() { for command in [ vec![], vec!["-p".to_string(), "claude appears later".to_string()], @@ -402,33 +401,10 @@ fn dry_run_diagnostic_checks_only_the_first_forwarded_token_and_redacts_the_rest vec!["codex".to_string(), "claude".to_string()], ] { assert!( - run::possible_duplicate_agent_warning( - CodingAgent::ClaudeCode, - &command, - run::InvocationForm::Run, - ) - .is_none(), + !run::has_duplicate_agent_executable(CodingAgent::ClaudeCode, &command), "unexpected duplicate for {command:?}" ); } - - let command = vec![ - "/opt/bin/claude-code".to_string(), - "-p".to_string(), - "private synthetic value".to_string(), - ]; - let warning = run::possible_duplicate_agent_warning( - CodingAgent::ClaudeCode, - &command, - run::InvocationForm::Shortcut, - ) - .unwrap(); - assert!(warning.contains("Diagnostic: possible_duplicate_agent_executable")); - assert!(warning.contains("Observed: nemo-relay claude --dry-run -- claude")); - assert!(warning.contains("Recommended: nemo-relay claude --dry-run --")); - assert!(warning.contains("")); - assert!(!warning.contains("/opt/bin/claude-code")); - assert!(!warning.contains("private synthetic value")); } #[test] diff --git a/docs/nemo-relay-cli/basic-usage.mdx b/docs/nemo-relay-cli/basic-usage.mdx index 16acbbb74..d332b0c64 100644 --- a/docs/nemo-relay-cli/basic-usage.mdx +++ b/docs/nemo-relay-cli/basic-usage.mdx @@ -83,8 +83,8 @@ nemo-relay run --agent claude -- -p "Review this change" Add `--dry-run` before `--` to validate and inspect the resolved launch plan without running setup, starting the gateway, or launching the agent. During -dry-run validation, Relay warns when the selected executable is repeated after -`--`: +dry-run validation, Relay logs a warning when the selected executable is +repeated after `--`: ```bash nemo-relay run --agent claude --dry-run -- claude From 0b90ddb1f69dca63184a42e32d3e72c37f1a0ae1 Mon Sep 17 00:00:00 2001 From: Eric Evans <194135482+ericevans-nv@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:02:37 -0500 Subject: [PATCH 9/9] refactor(cli): inline duplicate invocation check Signed-off-by: Eric Evans <194135482+ericevans-nv@users.noreply.github.com> --- crates/cli/src/commands/run.rs | 15 +++----- .../coverage/agents/coding_agent_tests.rs | 8 +++++ .../cli/tests/coverage/commands/main_tests.rs | 36 ------------------- 3 files changed, 13 insertions(+), 46 deletions(-) diff --git a/crates/cli/src/commands/run.rs b/crates/cli/src/commands/run.rs index c1f636b95..d1dcc2ba6 100644 --- a/crates/cli/src/commands/run.rs +++ b/crates/cli/src/commands/run.rs @@ -11,8 +11,6 @@ use super::serve::ServerArgs; use crate::agents::CodingAgent; use crate::error::CliError; -const POSSIBLE_DUPLICATE_AGENT_EXECUTABLE: &str = "possible_duplicate_agent_executable"; - /// Args for an easy-path agent shortcut. #[derive(Debug, Clone, Args)] pub(crate) struct EasyPathCommand { @@ -119,14 +117,17 @@ pub(super) async fn easy_path( } fn warn_for_possible_duplicate(agent: CodingAgent, command: &[String]) { - if !has_duplicate_agent_executable(agent, command) { + if !command + .first() + .is_some_and(|executable| CodingAgent::infer(executable) == Some(agent)) + { return; } let agent = agent.as_arg(); log::warn!( target: "nemo_relay.cli", event = "agent_invocation_warning", - diagnostic_code = POSSIBLE_DUPLICATE_AGENT_EXECUTABLE, + diagnostic_code = "possible_duplicate_agent_executable", agent = agent, duplicate_executable = agent, confidence = "high", @@ -136,9 +137,3 @@ fn warn_for_possible_duplicate(agent: CodingAgent, command: &[String]) { "Possible duplicate agent executable after `--`; remove the repeated executable" ); } - -pub(super) fn has_duplicate_agent_executable(agent: CodingAgent, command: &[String]) -> bool { - command - .first() - .is_some_and(|executable| CodingAgent::infer(executable) == Some(agent)) -} diff --git a/crates/cli/tests/coverage/agents/coding_agent_tests.rs b/crates/cli/tests/coverage/agents/coding_agent_tests.rs index deb81656b..63538f2ba 100644 --- a/crates/cli/tests/coverage/agents/coding_agent_tests.rs +++ b/crates/cli/tests/coverage/agents/coding_agent_tests.rs @@ -85,6 +85,14 @@ fn agent_inference_accepts_supported_binary_aliases() { CodingAgent::infer(r"C:\\tools\\codex.cmd"), Some(CodingAgent::Codex) ); + assert_eq!( + CodingAgent::infer(r"C:\\tools\\codex.bat"), + Some(CodingAgent::Codex) + ); + assert_eq!( + CodingAgent::infer(r"C:\\tools\\codex.com"), + Some(CodingAgent::Codex) + ); assert_eq!(CodingAgent::infer("@openai/codex"), None); assert_eq!(CodingAgent::infer("hermes"), None); assert_eq!(CodingAgent::infer("hermes-agent"), None); diff --git a/crates/cli/tests/coverage/commands/main_tests.rs b/crates/cli/tests/coverage/commands/main_tests.rs index e4f5f32b6..16acc1e3f 100644 --- a/crates/cli/tests/coverage/commands/main_tests.rs +++ b/crates/cli/tests/coverage/commands/main_tests.rs @@ -366,42 +366,6 @@ fn agent_shortcut_parser_accepts_dry_run_before_forwarded_arguments() { } } -#[test] -fn dry_run_diagnostic_recognizes_supported_agent_executable_forms() { - let cases = [ - (CodingAgent::ClaudeCode, "claude"), - (CodingAgent::ClaudeCode, "claude-code"), - (CodingAgent::ClaudeCode, "/opt/bin/claude"), - (CodingAgent::ClaudeCode, "/opt/bin/claude-code.exe"), - (CodingAgent::Codex, "codex"), - (CodingAgent::Codex, r"C:\tools\CODEX.CMD"), - (CodingAgent::Codex, r"C:\tools\codex.com"), - ]; - - for (agent, executable) in cases { - let command = vec![executable.to_string(), "synthetic argument".to_string()]; - assert!( - run::has_duplicate_agent_executable(agent, &command), - "expected {executable:?} to duplicate {agent:?}" - ); - } -} - -#[test] -fn dry_run_diagnostic_checks_only_the_first_forwarded_token() { - for command in [ - vec![], - vec!["-p".to_string(), "claude appears later".to_string()], - vec!["my-wrapper".to_string(), "claude".to_string()], - vec!["codex".to_string(), "claude".to_string()], - ] { - assert!( - !run::has_duplicate_agent_executable(CodingAgent::ClaudeCode, &command), - "unexpected duplicate for {command:?}" - ); - } -} - #[test] fn multi_agent_operations_attempt_every_target_before_reporting_errors() { let visited = std::cell::RefCell::new(Vec::new());