diff --git a/crates/cli/src/commands/run.rs b/crates/cli/src/commands/run.rs index 3c176c147..d1dcc2ba6 100644 --- a/crates/cli/src/commands/run.rs +++ b/crates/cli/src/commands/run.rs @@ -14,6 +14,9 @@ use crate::error::CliError; /// Args for an easy-path agent shortcut. #[derive(Debug, Clone, Args)] pub(crate) struct EasyPathCommand { + /// Print the resolved launch plan, including forwarded arguments, without executing it. + #[arg(long)] + pub(super) dry_run: bool, #[arg(last = true)] pub(super) command: Vec, } @@ -60,7 +63,13 @@ pub(super) async fn execute( command: RunCommand, server: &ServerArgs, ) -> Result { + if command.dry_run + && let Some(agent) = command.agent.map(Into::into) + { + 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 } @@ -79,13 +88,16 @@ pub(super) async fn easy_path( command: EasyPathCommand, server: &ServerArgs, ) -> Result { + if command.dry_run { + 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 // none of the normal discovery layers exists. Keep this interactive decision in the command // 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?; } @@ -96,9 +108,32 @@ 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, }; + // 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]) { + 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", + agent = agent, + duplicate_executable = agent, + confidence = "high", + action = "remove_duplicate_executable", + command_modified = false, + arguments_redacted = true; + "Possible duplicate agent executable after `--`; remove the repeated executable" + ); +} diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index 5dbc8aabb..4fe159eb3 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -86,6 +86,13 @@ 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 write_dynamic_plugin_manifest(dir: &std::path::Path, plugin_id: &str) { write_dynamic_plugin_manifest_with_options(dir, plugin_id, &["plugin_worker"], None); } @@ -3676,6 +3683,196 @@ command = "codex exec" assert!(argv.ends_with(" exec"), "{stdout}"); } +#[test] +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"); + 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(["--log-config-path"]) + .arg(&logging_config) + .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("/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}" + ); + + 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] +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(); + 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_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"); + 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(["--log-config-path"]) + .arg(&logging_config) + .args([ + "claude", + "--dry-run", + "--", + "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("private synthetic value"), "{stderr}"); + + let stdout = String::from_utf8_lossy(&output.stdout); + let argv = stdout + .lines() + .find(|line| line.starts_with("argv = ")) + .expect("dry run should print the resolved argv"); + assert!( + 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!( + !xdg.join("nemo-relay/config.toml").exists(), + "shortcut dry run must not invoke first-use setup" + ); +} + #[test] fn cli_run_dry_run_rejects_missing_explicit_config() { let temp = tempfile::tempdir().unwrap(); 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 5f9399a6f..16acc1e3f 100644 --- a/crates/cli/tests/coverage/commands/main_tests.rs +++ b/crates/cli/tests/coverage/commands/main_tests.rs @@ -345,6 +345,27 @@ fn doctor_accepts_offline_flag() { } } +#[test] +fn agent_shortcut_parser_accepts_dry_run_before_forwarded_arguments() { + for shortcut in ["claude", "codex"] { + 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)) => command, + other => panic!("expected agent shortcut command, got {other:?}"), + }; + assert!(command.dry_run); + assert_eq!(command.command, [shortcut, "synthetic argument"]); + } +} + #[test] fn multi_agent_operations_attempt_every_target_before_reporting_errors() { let visited = std::cell::RefCell::new(Vec::new()); diff --git a/docs/nemo-relay-cli/basic-usage.mdx b/docs/nemo-relay-cli/basic-usage.mdx index 4ac1e69f9..810f60fbe 100644 --- a/docs/nemo-relay-cli/basic-usage.mdx +++ b/docs/nemo-relay-cli/basic-usage.mdx @@ -70,6 +70,29 @@ instead of the built-in shortcut: nemo-relay run -- codex ``` +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" +``` + +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 logs a warning 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. + 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