Skip to content
44 changes: 42 additions & 2 deletions crates/cli/src/commands/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,14 @@ use super::serve::ServerArgs;
use crate::agents::CodingAgent;
use crate::error::CliError;

const POSSIBLE_DUPLICATE_AGENT_EXECUTABLE: &str = "possible_duplicate_agent_executable";

Comment on lines +14 to +15

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

only used in one place. no need for constant

/// 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<String>,
}
Expand Down Expand Up @@ -60,7 +65,13 @@ pub(super) async fn execute(
command: RunCommand,
server: &ServerArgs,
) -> Result<ExitCode, CliError> {
if command.dry_run
&& let Some(agent) = command.agent.map(Into::into)
{
warn_for_possible_duplicate(agent, &command.command);
}
Comment on lines +68 to +72

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic doesn't make sense because it falls through.

If dry-run then we shouldn't execute? Why do we fall through to execute?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The call does not fall through into live execution. dry_run is carried in RunOverrides; launcher::run prints the prepared plan, and TransparentRun::execute returns ExitCode::SUCCESS before starting the gateway or child when that flag is set. I added comments at both call sites to make that ownership explicit. The existing launcher dry_run_does_not_spawn_agent test and the shortcut CLI test cover the no-execution/no-setup behavior.

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
}

Expand All @@ -79,13 +90,16 @@ pub(super) async fn easy_path(
command: EasyPathCommand,
server: &ServerArgs,
) -> Result<ExitCode, CliError> {
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?;
}
Expand All @@ -96,9 +110,35 @@ 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 !has_duplicate_agent_executable(agent, command) {
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"
);
}

pub(super) fn has_duplicate_agent_executable(agent: CodingAgent, command: &[String]) -> bool {
command
.first()
.is_some_and(|executable| CodingAgent::infer(executable) == Some(agent))
}
Comment on lines +140 to +144

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This internal detail should be inline to warn_for_possible_duplicate -- making pub(super) for tests is unnecessary

197 changes: 197 additions & 0 deletions crates/cli/tests/cli_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@ fn read_jsonl_records(path: &Path) -> Vec<serde_json::Value> {
.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);
}
Expand Down Expand Up @@ -3814,6 +3821,196 @@ command = "hermes --yolo chat"
assert!(stdout.contains("argv = hermes --yolo chat"));
}

#[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"));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

#[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();
Expand Down
62 changes: 62 additions & 0 deletions crates/cli/tests/coverage/commands/main_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,68 @@ fn doctor_accepts_offline_flag() {
}
}

#[test]
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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hermes agent has been removed

other => panic!("expected agent shortcut command, got {other:?}"),
};
assert!(command.dry_run);
assert_eq!(command.command, [shortcut, "synthetic argument"]);
}
}

#[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"),
Comment on lines +381 to +383

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No longer exists

];

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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -494,15 +494,19 @@ 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",
"scheduled_delay_millis",
] {
assert!(serialized_endpoint.get(field).is_none());
}

assert_endpoint_batch_fields_deserialize();
}

fn assert_endpoint_batch_fields_deserialize() {
Expand Down
Loading
Loading