Skip to content

Commit a126bd4

Browse files
committed
feat(cli): warn about duplicate agent executables
1 parent d27a20e commit a126bd4

9 files changed

Lines changed: 588 additions & 1 deletion

File tree

crates/cli/src/commands/diagnostics.rs

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,18 @@
44
use std::path::PathBuf;
55
use std::process::ExitCode;
66

7-
use clap::Args;
7+
use clap::{Args, Subcommand};
88
use serde_json::{Value, json};
99

1010
use super::install::InstallTarget;
1111
use super::root::AgentArg;
1212
use crate::error::CliError;
1313

1414
#[derive(Debug, Clone, Args)]
15+
#[command(args_conflicts_with_subcommands = true)]
1516
pub(crate) struct DoctorCommand {
17+
#[command(subcommand)]
18+
pub(crate) command: Option<DoctorSubcommand>,
1619
#[arg(value_enum, conflicts_with = "plugin")]
1720
pub(crate) agent: Option<AgentArg>,
1821
#[arg(long, value_enum)]
@@ -28,6 +31,27 @@ pub(crate) struct DoctorCommand {
2831
pub(crate) offline: bool,
2932
}
3033

34+
#[derive(Debug, Clone, Subcommand)]
35+
pub(crate) enum DoctorSubcommand {
36+
/// Inspect an agent invocation without launching it.
37+
Invocation(InvocationDoctorCommand),
38+
}
39+
40+
#[derive(Debug, Clone, Args)]
41+
pub(crate) struct InvocationDoctorCommand {
42+
#[arg(long, value_enum)]
43+
agent: AgentArg,
44+
#[arg(long)]
45+
shortcut: bool,
46+
#[arg(
47+
long,
48+
help = "Display the complete invocation; arguments may contain sensitive data"
49+
)]
50+
show_full_command: bool,
51+
#[arg(last = true, required = true)]
52+
command: Vec<String>,
53+
}
54+
3155
#[derive(Debug, Clone, Args)]
3256
pub(crate) struct AgentsCommand {
3357
#[arg(long)]
@@ -39,6 +63,9 @@ pub(super) async fn execute(
3963
server: &super::serve::ServerArgs,
4064
logging_fallback_error: Option<&CliError>,
4165
) -> Result<ExitCode, CliError> {
66+
if let Some(DoctorSubcommand::Invocation(invocation)) = command.command {
67+
return execute_invocation_doctor(invocation);
68+
}
4269
if let Some(plugin) = command.plugin {
4370
return execute_plugin_doctor(plugin, command.install_dir, command.json);
4471
}
@@ -53,6 +80,31 @@ pub(super) async fn execute(
5380
.await
5481
}
5582

83+
fn execute_invocation_doctor(command: InvocationDoctorCommand) -> Result<ExitCode, CliError> {
84+
let agent = command.agent.into();
85+
let form = if command.shortcut {
86+
crate::diagnostics::invocation::InvocationForm::Shortcut
87+
} else {
88+
crate::diagnostics::invocation::InvocationForm::Run
89+
};
90+
match crate::diagnostics::invocation::DuplicateAgentExecutable::detect(
91+
agent,
92+
&command.command,
93+
form,
94+
) {
95+
Some(diagnostic) => {
96+
println!("{}", diagnostic.format_doctor(command.show_full_command));
97+
}
98+
None => {
99+
println!(
100+
"INVOCATION DIAGNOSTIC\ncode = none\nselected_agent = {}\nresult = no duplicate agent executable detected",
101+
agent.as_arg()
102+
);
103+
}
104+
}
105+
Ok(ExitCode::SUCCESS)
106+
}
107+
56108
fn execute_plugin_doctor(
57109
plugin: InstallTarget,
58110
install_dir: Option<PathBuf>,

crates/cli/src/commands/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,10 @@ fn configure_logging(cli: &Cli) -> Result<LoggingSetup, error::CliError> {
102102
})
103103
}
104104

105+
fn print_invocation_warning(warning: &str) {
106+
eprintln!("{warning}");
107+
}
108+
105109
async fn dispatch(bootstrap_shutdown_token: Option<String>) -> Result<ExitCode, error::CliError> {
106110
let cli = Cli::parse();
107111
let command_name = cli

crates/cli/src/commands/run.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,13 @@ pub(super) async fn execute(
6060
command: RunCommand,
6161
server: &ServerArgs,
6262
) -> Result<ExitCode, CliError> {
63+
if let Some(agent) = command.agent.map(Into::into) {
64+
warn_for_possible_duplicate(
65+
agent,
66+
&command.command,
67+
crate::diagnostics::invocation::InvocationForm::Run,
68+
);
69+
}
6370
let inherited = server.to_runtime();
6471
crate::process::launcher::run(command.into_runtime(), Some(&inherited)).await
6572
}
@@ -79,6 +86,11 @@ pub(super) async fn easy_path(
7986
command: EasyPathCommand,
8087
server: &ServerArgs,
8188
) -> Result<ExitCode, CliError> {
89+
warn_for_possible_duplicate(
90+
agent,
91+
&command.command,
92+
crate::diagnostics::invocation::InvocationForm::Shortcut,
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
@@ -102,3 +114,16 @@ pub(super) async fn easy_path(
102114
};
103115
crate::process::launcher::run(runtime, Some(&inherited)).await
104116
}
117+
118+
fn warn_for_possible_duplicate(
119+
agent: CodingAgent,
120+
command: &[String],
121+
form: crate::diagnostics::invocation::InvocationForm,
122+
) {
123+
if let Some(diagnostic) =
124+
crate::diagnostics::invocation::DuplicateAgentExecutable::detect(agent, command, form)
125+
{
126+
diagnostic.log();
127+
super::print_invocation_warning(&diagnostic.format_warning());
128+
}
129+
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! Advisory diagnostics for structurally suspicious agent invocations.
5+
6+
use crate::agents::CodingAgent;
7+
8+
pub(crate) const POSSIBLE_DUPLICATE_AGENT_EXECUTABLE: &str = "possible_duplicate_agent_executable";
9+
10+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11+
pub(crate) enum InvocationForm {
12+
Run,
13+
Shortcut,
14+
}
15+
16+
#[derive(Debug, Clone, PartialEq, Eq)]
17+
pub(crate) struct DuplicateAgentExecutable {
18+
agent: CodingAgent,
19+
form: InvocationForm,
20+
command: Vec<String>,
21+
}
22+
23+
impl DuplicateAgentExecutable {
24+
pub(crate) fn detect(
25+
agent: CodingAgent,
26+
command: &[String],
27+
form: InvocationForm,
28+
) -> Option<Self> {
29+
let executable = command.first()?;
30+
(CodingAgent::infer(executable) == Some(agent)).then(|| Self {
31+
agent,
32+
form,
33+
command: command.to_vec(),
34+
})
35+
}
36+
37+
pub(crate) fn log(&self) {
38+
let agent = self.agent.as_arg();
39+
log::warn!(
40+
target: "nemo_relay.cli",
41+
event = "agent_invocation_warning",
42+
diagnostic_code = POSSIBLE_DUPLICATE_AGENT_EXECUTABLE,
43+
agent = agent,
44+
duplicate_executable = agent,
45+
confidence = "high",
46+
action = "continued",
47+
command_modified = false,
48+
arguments_redacted = true;
49+
"Possible duplicate agent executable after `--`"
50+
);
51+
}
52+
53+
pub(crate) fn format_doctor(&self, show_full_command: bool) -> String {
54+
let visibility = if show_full_command {
55+
"full command; may contain sensitive data"
56+
} else {
57+
"arguments redacted"
58+
};
59+
format!(
60+
"INVOCATION DIAGNOSTIC\n\
61+
code = {POSSIBLE_DUPLICATE_AGENT_EXECUTABLE}\n\
62+
confidence = high\n\
63+
selected_agent = {}\n\
64+
duplicate_executable = {}\n\
65+
visibility = {visibility}\n\
66+
observed = {}\n\
67+
recommended = {}\n\
68+
action = continue unchanged",
69+
self.agent.as_arg(),
70+
self.agent.as_arg(),
71+
self.observed_command(show_full_command),
72+
self.recommended_command(show_full_command),
73+
)
74+
}
75+
76+
pub(crate) fn format_warning(&self) -> String {
77+
format!(
78+
"WARNING: Possible duplicate agent executable after `--`.\n\
79+
Diagnostic: {POSSIBLE_DUPLICATE_AGENT_EXECUTABLE}\n\
80+
Duplicate executable: {}\n\
81+
Observed: {}\n\
82+
Recommended: {}\n\
83+
Doctor (safe): {}\n\
84+
Doctor (full): {}\n\
85+
Relay will continue without modifying the command.",
86+
self.agent.as_arg(),
87+
self.observed_command(false),
88+
self.recommended_command(false),
89+
self.doctor_command(false),
90+
self.doctor_command(true),
91+
)
92+
}
93+
94+
fn observed_command(&self, show_full_command: bool) -> String {
95+
let mut command = self.relay_prefix();
96+
command.push("--".into());
97+
if show_full_command {
98+
command.extend(self.command.iter().cloned());
99+
} else {
100+
command.push(self.agent.as_arg().into());
101+
if self.command.len() > 1 {
102+
command.push("<arguments redacted>".into());
103+
}
104+
}
105+
render_command(&command)
106+
}
107+
108+
fn recommended_command(&self, show_full_command: bool) -> String {
109+
let mut command = self.relay_prefix();
110+
command.push("--".into());
111+
if show_full_command {
112+
command.extend(self.command.iter().skip(1).cloned());
113+
} else if self.command.len() > 1 {
114+
command.push("<arguments redacted>".into());
115+
}
116+
render_command(&command)
117+
}
118+
119+
fn doctor_command(&self, show_full_command: bool) -> String {
120+
let mut command = vec![
121+
"nemo-relay".into(),
122+
"doctor".into(),
123+
"invocation".into(),
124+
"--agent".into(),
125+
self.agent.as_arg().into(),
126+
];
127+
if self.form == InvocationForm::Shortcut {
128+
command.push("--shortcut".into());
129+
}
130+
if show_full_command {
131+
command.push("--show-full-command".into());
132+
}
133+
command.push("--".into());
134+
command.push(self.agent.as_arg().into());
135+
if show_full_command && self.command.len() > 1 {
136+
command.push("<original arguments>".into());
137+
}
138+
render_command(&command)
139+
}
140+
141+
fn relay_prefix(&self) -> Vec<String> {
142+
match self.form {
143+
InvocationForm::Run => vec![
144+
"nemo-relay".into(),
145+
"run".into(),
146+
"--agent".into(),
147+
self.agent.as_arg().into(),
148+
],
149+
InvocationForm::Shortcut => {
150+
vec!["nemo-relay".into(), self.agent.as_arg().into()]
151+
}
152+
}
153+
}
154+
}
155+
156+
fn render_command(command: &[String]) -> String {
157+
command
158+
.iter()
159+
.map(|argument| crate::process::shell_quote_arg_for_platform(argument, cfg!(windows)))
160+
.collect::<Vec<_>>()
161+
.join(" ")
162+
}
163+
164+
#[cfg(test)]
165+
#[path = "../../tests/coverage/shared/invocation_diagnostic_tests.rs"]
166+
mod tests;

crates/cli/src/diagnostics/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
//! - `format_human(&report)` / `format_json(&report)` render the report.
1111
1212
mod environment;
13+
pub(crate) mod invocation;
1314
mod model;
1415
mod probes;
1516
mod render;

0 commit comments

Comments
 (0)