Skip to content

Commit 2dba802

Browse files
authored
feat(cli): validate duplicate agent executables during dry run (#736)
#### Overview NeMo Relay's CLI can accept a command that is syntactically valid but likely duplicates an explicitly selected agent executable: ```bash nemo-relay run --agent <agent> --dry-run -- <agent> [arguments] ``` This PR makes `--dry-run` the validation and inspection surface for that high-confidence mistake. During a dry run, Relay logs a structured warning about the duplicate and prints the actual resolved launch plan without running setup, starting the gateway, activating dynamic plugins, or launching the agent. Live launches are unchanged. Relay does not preflight, reject, or rewrite their forwarded arguments; downstream command handling remains authoritative. - [x] I confirm this contribution is my own work, or I have the right to submit it under this project's license. - [x] I searched existing issues and open pull requests, and this does not duplicate existing work. #### Details - Add `--dry-run` to the Claude, Codex, and Hermes shortcuts. - Detect a repeated executable only when `--dry-run` is present and the first token after `--` resolves to the explicitly selected agent. - Reuse `CodingAgent::infer` for canonical names, aliases, paths, and supported executable suffixes. - Keep the diagnostic in `commands/run.rs` and reuse the existing launcher planning path rather than adding a second command renderer. - Borrow parsed forwarded arguments rather than cloning them. - Emit one structured warning containing only the selected agent and fixed diagnostic metadata; executable paths and forwarded arguments are not logged. - Preserve the complete resolved argv in explicit dry-run output and document that it can contain prompts or credentials. - Verify that live launches skip this diagnostic and continue through normal downstream handling. #### Where should the reviewer start? Start with `crates/cli/src/commands/run.rs`, which owns dry-run admission and the diagnostic. Then review the CLI behavior tests in `crates/cli/tests/cli_tests.rs` and the command-level coverage in `crates/cli/tests/coverage/commands/main_tests.rs`. #### Validation - `just test-rust` passed. - `cargo test -p nemo-relay-cli` passed: 1,202 unit tests, 12 architecture tests, and 104 CLI integration tests. - `cargo clippy --workspace --all-targets -- -D warnings` passed. - `cargo fmt --all` and `git diff --check` passed. - The complete pre-commit suite passed, including Cargo check, Clippy, formatting, audit, FFI header sync, Python typing, Go vet, and Node checks. - `just docs` passed with zero errors. Fern redirect validation was skipped because the local environment was not authenticated. - Full PR CI passed on Linux, macOS, Windows amd64, and Windows arm64, including package smoke tests and documentation preview. #### Related Issues: (use one of the action keywords Closes / Fixes / Resolves / Relates to) - Relates to: none. Authors: - Eric Evans II (https://github.com/ericevans-nv) - Alex Fournier (https://github.com/afourniernv) Approvers: - Maryam Najafian (https://github.com/mnajafian-nv) - Will Killian (https://github.com/willkill07) URL: #736
1 parent 588b75d commit 2dba802

5 files changed

Lines changed: 286 additions & 2 deletions

File tree

crates/cli/src/commands/run.rs

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ use crate::error::CliError;
1414
/// Args for an easy-path agent shortcut.
1515
#[derive(Debug, Clone, Args)]
1616
pub(crate) struct EasyPathCommand {
17+
/// Print the resolved launch plan, including forwarded arguments, without executing it.
18+
#[arg(long)]
19+
pub(super) dry_run: bool,
1720
#[arg(last = true)]
1821
pub(super) command: Vec<String>,
1922
}
@@ -60,7 +63,13 @@ pub(super) async fn execute(
6063
command: RunCommand,
6164
server: &ServerArgs,
6265
) -> Result<ExitCode, CliError> {
66+
if command.dry_run
67+
&& let Some(agent) = command.agent.map(Into::into)
68+
{
69+
warn_for_possible_duplicate(agent, &command.command);
70+
}
6371
let inherited = server.to_runtime();
72+
// The launcher prints the plan and returns before gateway or child execution for dry runs.
6473
crate::process::launcher::run(command.into_runtime(), Some(&inherited)).await
6574
}
6675

@@ -79,13 +88,16 @@ pub(super) async fn easy_path(
7988
command: EasyPathCommand,
8089
server: &ServerArgs,
8190
) -> Result<ExitCode, CliError> {
91+
if command.dry_run {
92+
warn_for_possible_duplicate(agent, &command.command);
93+
}
8294
let inherited = server.to_runtime();
8395
// An explicit config path is the user's contract. Without one, setup is required only when
8496
// none of the normal discovery layers exists. Keep this interactive decision in the command
8597
// layer so process supervision receives a complete, agent-neutral run request.
8698
let explicit_config = inherited.config.as_deref();
8799
let needs_setup = explicit_config.is_none() && !crate::configuration::any_config_file_exists();
88-
if needs_setup {
100+
if needs_setup && !command.dry_run {
89101
let explicit_plugin_path = easy_path_plugin_config_path(&inherited);
90102
super::configure::run(Some(agent), explicit_plugin_path).await?;
91103
}
@@ -96,9 +108,32 @@ pub(super) async fn easy_path(
96108
anthropic_base_url: None,
97109
session_metadata: None,
98110
plugin_config_path: None,
99-
dry_run: false,
111+
dry_run: command.dry_run,
100112
print: false,
101113
command: command.command,
102114
};
115+
// The launcher prints the plan and returns before gateway or child execution for dry runs.
103116
crate::process::launcher::run(runtime, Some(&inherited)).await
104117
}
118+
119+
fn warn_for_possible_duplicate(agent: CodingAgent, command: &[String]) {
120+
if !command
121+
.first()
122+
.is_some_and(|executable| CodingAgent::infer(executable) == Some(agent))
123+
{
124+
return;
125+
}
126+
let agent = agent.as_arg();
127+
log::warn!(
128+
target: "nemo_relay.cli",
129+
event = "agent_invocation_warning",
130+
diagnostic_code = "possible_duplicate_agent_executable",
131+
agent = agent,
132+
duplicate_executable = agent,
133+
confidence = "high",
134+
action = "remove_duplicate_executable",
135+
command_modified = false,
136+
arguments_redacted = true;
137+
"Possible duplicate agent executable after `--`; remove the repeated executable"
138+
);
139+
}

crates/cli/tests/cli_tests.rs

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,13 @@ fn read_jsonl_records(path: &Path) -> Vec<serde_json::Value> {
8686
.collect()
8787
}
8888

89+
fn read_jsonl_event(path: &Path, event: &str) -> serde_json::Value {
90+
read_jsonl_records(path)
91+
.into_iter()
92+
.find(|record| record["event"] == event)
93+
.unwrap_or_else(|| panic!("missing {event} record in {}", path.display()))
94+
}
95+
8996
fn write_dynamic_plugin_manifest(dir: &std::path::Path, plugin_id: &str) {
9097
write_dynamic_plugin_manifest_with_options(dir, plugin_id, &["plugin_worker"], None);
9198
}
@@ -3676,6 +3683,196 @@ command = "codex exec"
36763683
assert!(argv.ends_with(" exec"), "{stdout}");
36773684
}
36783685

3686+
#[test]
3687+
fn invocation_diagnostic_cli_warns_during_dry_run_without_rewriting_the_plan() {
3688+
let temp = tempfile::tempdir().unwrap();
3689+
let (logging_config, log_path) = write_jsonl_logging_config(temp.path());
3690+
let config = temp.path().join("config.toml");
3691+
std::fs::write(
3692+
&config,
3693+
r#"
3694+
[upstream]
3695+
openai_base_url = "http://127.0.0.1:1"
3696+
anthropic_base_url = "http://127.0.0.1:1"
3697+
"#,
3698+
)
3699+
.unwrap();
3700+
3701+
let output = Command::new(gateway_bin())
3702+
.current_dir(temp.path())
3703+
.env("XDG_CONFIG_HOME", temp.path().join("xdg"))
3704+
.env("HOME", temp.path())
3705+
.args(["--log-config-path"])
3706+
.arg(&logging_config)
3707+
.args([
3708+
"--config",
3709+
config.to_str().unwrap(),
3710+
"run",
3711+
"--agent",
3712+
"claude",
3713+
"--dry-run",
3714+
"--",
3715+
"/opt/bin/claude-code.exe",
3716+
"-p",
3717+
"synthetic prompt",
3718+
])
3719+
.output()
3720+
.unwrap();
3721+
3722+
assert!(output.status.success());
3723+
let stderr = String::from_utf8_lossy(&output.stderr);
3724+
assert!(
3725+
stderr.contains("possible_duplicate_agent_executable"),
3726+
"{stderr}"
3727+
);
3728+
assert!(!stderr.contains("/opt/bin/claude-code.exe"));
3729+
assert!(!stderr.contains("synthetic prompt"));
3730+
3731+
let stdout = String::from_utf8_lossy(&output.stdout);
3732+
assert!(
3733+
stdout.contains("/opt/bin/claude-code.exe -p synthetic prompt"),
3734+
"{stdout}"
3735+
);
3736+
3737+
let diagnostic = read_jsonl_event(&log_path, "agent_invocation_warning");
3738+
assert_eq!(diagnostic["fields"]["agent"], "claude");
3739+
assert_eq!(diagnostic["fields"]["duplicate_executable"], "claude");
3740+
let diagnostic = diagnostic.to_string();
3741+
assert!(!diagnostic.contains("/opt/bin/claude-code.exe"));
3742+
assert!(!diagnostic.contains("synthetic prompt"));
3743+
}
3744+
3745+
#[test]
3746+
fn invocation_diagnostic_does_not_preflight_live_launches() {
3747+
let temp = tempfile::tempdir().unwrap();
3748+
let config = temp.path().join("config.toml");
3749+
std::fs::write(
3750+
&config,
3751+
r#"
3752+
[agents.claude]
3753+
command = "nemo-relay-test-agent-that-does-not-exist"
3754+
"#,
3755+
)
3756+
.unwrap();
3757+
3758+
let output = Command::new(gateway_bin())
3759+
.current_dir(temp.path())
3760+
.env("XDG_CONFIG_HOME", temp.path().join("xdg"))
3761+
.env("HOME", temp.path())
3762+
.args([
3763+
"--config",
3764+
config.to_str().unwrap(),
3765+
"run",
3766+
"--agent",
3767+
"claude",
3768+
"--",
3769+
"claude",
3770+
"private synthetic value",
3771+
])
3772+
.output()
3773+
.unwrap();
3774+
3775+
assert!(!output.status.success());
3776+
let stderr = String::from_utf8_lossy(&output.stderr);
3777+
assert!(
3778+
!stderr.contains("possible_duplicate_agent_executable"),
3779+
"{stderr}"
3780+
);
3781+
assert!(stderr.contains("error_kind=io"), "{stderr}");
3782+
}
3783+
3784+
#[test]
3785+
fn invocation_diagnostic_cli_ignores_agent_names_after_a_different_first_token() {
3786+
let temp = tempfile::tempdir().unwrap();
3787+
let config = temp.path().join("config.toml");
3788+
std::fs::write(
3789+
&config,
3790+
r#"
3791+
[upstream]
3792+
openai_base_url = "http://127.0.0.1:1"
3793+
anthropic_base_url = "http://127.0.0.1:1"
3794+
"#,
3795+
)
3796+
.unwrap();
3797+
3798+
let output = Command::new(gateway_bin())
3799+
.current_dir(temp.path())
3800+
.env("XDG_CONFIG_HOME", temp.path().join("xdg"))
3801+
.env("HOME", temp.path())
3802+
.args([
3803+
"--config",
3804+
config.to_str().unwrap(),
3805+
"run",
3806+
"--agent",
3807+
"claude",
3808+
"--dry-run",
3809+
"--",
3810+
"-p",
3811+
"compare claude with codex",
3812+
])
3813+
.output()
3814+
.unwrap();
3815+
3816+
assert!(output.status.success());
3817+
let stderr = String::from_utf8_lossy(&output.stderr);
3818+
assert!(
3819+
!stderr.contains("possible_duplicate_agent_executable"),
3820+
"{stderr}"
3821+
);
3822+
}
3823+
3824+
#[test]
3825+
fn invocation_diagnostic_cli_warns_for_agent_shortcut() {
3826+
let temp = tempfile::tempdir().unwrap();
3827+
let (logging_config, log_path) = write_jsonl_logging_config(temp.path());
3828+
let xdg = temp.path().join("xdg");
3829+
std::fs::create_dir_all(&xdg).unwrap();
3830+
let cwd = temp.path().join("workdir");
3831+
std::fs::create_dir_all(&cwd).unwrap();
3832+
3833+
let output = Command::new(gateway_bin())
3834+
.current_dir(&cwd)
3835+
.env("XDG_CONFIG_HOME", &xdg)
3836+
.env("HOME", temp.path())
3837+
.args(["--log-config-path"])
3838+
.arg(&logging_config)
3839+
.args([
3840+
"claude",
3841+
"--dry-run",
3842+
"--",
3843+
"claude",
3844+
"-p",
3845+
"private synthetic value",
3846+
])
3847+
.output()
3848+
.unwrap();
3849+
3850+
assert!(output.status.success());
3851+
let stderr = String::from_utf8_lossy(&output.stderr);
3852+
assert!(
3853+
stderr.contains("possible_duplicate_agent_executable"),
3854+
"{stderr}"
3855+
);
3856+
assert!(!stderr.contains("private synthetic value"), "{stderr}");
3857+
3858+
let stdout = String::from_utf8_lossy(&output.stdout);
3859+
let argv = stdout
3860+
.lines()
3861+
.find(|line| line.starts_with("argv = "))
3862+
.expect("dry run should print the resolved argv");
3863+
assert!(
3864+
argv.ends_with(" claude -p private synthetic value"),
3865+
"{argv}"
3866+
);
3867+
3868+
let diagnostic = read_jsonl_event(&log_path, "agent_invocation_warning").to_string();
3869+
assert!(!diagnostic.contains("private synthetic value"));
3870+
assert!(
3871+
!xdg.join("nemo-relay/config.toml").exists(),
3872+
"shortcut dry run must not invoke first-use setup"
3873+
);
3874+
}
3875+
36793876
#[test]
36803877
fn cli_run_dry_run_rejects_missing_explicit_config() {
36813878
let temp = tempfile::tempdir().unwrap();

crates/cli/tests/coverage/agents/coding_agent_tests.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,14 @@ fn agent_inference_accepts_supported_binary_aliases() {
8585
CodingAgent::infer(r"C:\\tools\\codex.cmd"),
8686
Some(CodingAgent::Codex)
8787
);
88+
assert_eq!(
89+
CodingAgent::infer(r"C:\\tools\\codex.bat"),
90+
Some(CodingAgent::Codex)
91+
);
92+
assert_eq!(
93+
CodingAgent::infer(r"C:\\tools\\codex.com"),
94+
Some(CodingAgent::Codex)
95+
);
8896
assert_eq!(CodingAgent::infer("@openai/codex"), None);
8997
assert_eq!(CodingAgent::infer("hermes"), None);
9098
assert_eq!(CodingAgent::infer("hermes-agent"), None);

crates/cli/tests/coverage/commands/main_tests.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,27 @@ fn doctor_accepts_offline_flag() {
345345
}
346346
}
347347

348+
#[test]
349+
fn agent_shortcut_parser_accepts_dry_run_before_forwarded_arguments() {
350+
for shortcut in ["claude", "codex"] {
351+
let cli = Cli::try_parse_from([
352+
"nemo-relay",
353+
shortcut,
354+
"--dry-run",
355+
"--",
356+
shortcut,
357+
"synthetic argument",
358+
])
359+
.unwrap();
360+
let command = match cli.command {
361+
Some(Command::Claude(command)) | Some(Command::Codex(command)) => command,
362+
other => panic!("expected agent shortcut command, got {other:?}"),
363+
};
364+
assert!(command.dry_run);
365+
assert_eq!(command.command, [shortcut, "synthetic argument"]);
366+
}
367+
}
368+
348369
#[test]
349370
fn multi_agent_operations_attempt_every_target_before_reporting_errors() {
350371
let visited = std::cell::RefCell::new(Vec::new());

docs/nemo-relay-cli/basic-usage.mdx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,29 @@ instead of the built-in shortcut:
7070
nemo-relay run -- codex
7171
```
7272

73+
When `--agent` or an agent shortcut selects the host, pass only the host's
74+
arguments after `--`:
75+
76+
```bash
77+
nemo-relay run --agent claude -- -p "Review this change"
78+
```
79+
80+
Add `--dry-run` before `--` to validate and inspect the resolved launch plan
81+
without running setup, starting the gateway, or launching the agent. During
82+
dry-run validation, Relay logs a warning when the selected executable is
83+
repeated after `--`:
84+
85+
```bash
86+
nemo-relay run --agent claude --dry-run -- claude
87+
nemo-relay claude --dry-run -- claude
88+
```
89+
90+
Live launches do not perform this diagnostic or rewrite forwarded arguments.
91+
The selected agent receives the arguments exactly as provided.
92+
93+
Dry-run output includes forwarded arguments. Do not share it when those
94+
arguments contain prompts, credentials, or other sensitive values.
95+
7396
For Claude Code and Codex, transparent mode leaves the caller's source settings,
7497
selected profile, and installed plugin state unchanged. A process marker makes
7598
any installed Relay MCP borrow the wrapper-owned dynamic gateway. Claude's

0 commit comments

Comments
 (0)