Skip to content

Commit a31b9e1

Browse files
authored
fix: batch fix for cortex-cli bugs (exit codes, conflicting flags, JSON output, security) (#3)
* fix(cortex-cli): Apply batch of issue fixes and improvements - Add validation for mutually exclusive flags across commands: - agent list: --primary vs --subagents - login: --token, --sso, --device-auth, --with-api-key - exec: --enabled-tools vs --disabled-tools - plugin list: --enabled vs --disabled - mcp add: --url vs --sse - mcp logout: --name vs --all - Improve error handling with JSON support: - alias show: JSON error output - mcp get: JSON error output - plugin show: JSON error output - debug wait: JSON error output - run cmd: auth error JSON output - Fix bug in autonomy.rs: is_read_only_command now uses command string - Add session ID validation in lock_cmd - Support 'last' as SESSION_ID in resume command - Add duplicate task ID check in DAG helpers - Add --jobs validation in DAG execute - Add sort value validation in models list - Add empty plugin name validation - Update help text URLs to point to correct repo - Replace emojis with text in PR checkout - Move confirmation before backup in uninstall - Convert GitHub URLs to raw URLs in upgrade changelog - Support config.json in debug config command - Add dry_run JSON output in compact and DAG commands - Make run_whoami return Result<()> for proper error handling - Change config get/unset to bail! on errors Issues: #3646, #3651, #3682, #3696, #3700, #3716, #3722, #3815 * fix(cortex-cli): address validation feedback from PR review - Fix clippy::bind_instead_of_map in debug_cmd/handlers/config.rs by replacing and_then() with map() since closure always returns Some() - Fix duplicate error output in alias_cmd.rs and plugin_cmd.rs by using std::process::exit(1) after JSON error output instead of bail!() to avoid duplicating error message to stderr - Replace raw .unwrap() calls with safer patterns in dag_cmd/helpers.rs using if-let guards and Option::and_then() instead of panicking - Improve is_read_only_command() in exec_cmd/autonomy.rs to use exact word matching instead of prefix matching to prevent false positives (e.g., 'catfile' no longer matches 'cat') * fix(cortex-cli): integrate allows_risk() for proper autonomy level validation Address Greptile review feedback by actually calling the allows_risk() method in the exec command approval flow. Previously, the security fix only added the method but did not integrate it into the command execution path. Changes: - Replace simple AutonomyLevel::ReadOnly check with allows_risk(risk, command) - Extract risk_level from sandbox_assessment if available - Pass actual command string to validate read-only commands properly - Provide clearer error messages including risk level and autonomy mode
1 parent f74847f commit a31b9e1

21 files changed

Lines changed: 612 additions & 240 deletions

File tree

src/cortex-cli/src/agent_cmd/handlers/list.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
//! Handler for the `agent list` command.
22
3-
use anyhow::Result;
3+
use anyhow::{Result, bail};
44

55
use crate::agent_cmd::cli::ListArgs;
66
use crate::agent_cmd::loader::load_all_agents;
@@ -9,6 +9,13 @@ use crate::agent_cmd::utils::matches_pattern;
99

1010
/// List agents command.
1111
pub async fn run_list(args: ListArgs) -> Result<()> {
12+
// Validate mutually exclusive flags
13+
if args.primary && args.subagents {
14+
bail!(
15+
"Cannot specify both --primary and --subagents. Choose one filter or use neither for all agents."
16+
);
17+
}
18+
1219
// Handle --remote flag
1320
if args.remote {
1421
println!("Remote agent registry:");

src/cortex-cli/src/alias_cmd.rs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -241,10 +241,20 @@ async fn run_remove(args: AliasRemoveArgs) -> Result<()> {
241241
async fn run_show(args: AliasShowArgs) -> Result<()> {
242242
let config = load_aliases()?;
243243

244-
let alias = config
245-
.aliases
246-
.get(&args.name)
247-
.ok_or_else(|| anyhow::anyhow!("Alias '{}' does not exist.", args.name))?;
244+
let alias = match config.aliases.get(&args.name) {
245+
Some(a) => a,
246+
None => {
247+
if args.json {
248+
let error = serde_json::json!({
249+
"error": format!("Alias '{}' does not exist", args.name)
250+
});
251+
println!("{}", serde_json::to_string_pretty(&error)?);
252+
// Exit with error code but don't duplicate error message via bail!()
253+
std::process::exit(1);
254+
}
255+
bail!("Alias '{}' does not exist.", args.name);
256+
}
257+
};
248258

249259
if args.json {
250260
println!("{}", serde_json::to_string_pretty(alias)?);

src/cortex-cli/src/cli/handlers.rs

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ pub async fn dispatch_command(cli: Cli) -> Result<()> {
2727
Some(Commands::Login(login_cli)) => handle_login(login_cli).await,
2828
Some(Commands::Logout(logout_cli)) => handle_logout(logout_cli).await,
2929
Some(Commands::Whoami) => {
30-
run_whoami().await;
30+
run_whoami().await?;
3131
Ok(())
3232
}
3333
Some(Commands::Mcp(mcp_cli)) => mcp_cli.run().await,
@@ -182,6 +182,23 @@ async fn run_init(init_cli: InitCommand) -> Result<()> {
182182

183183
/// Handle login command.
184184
async fn handle_login(login_cli: LoginCommand) -> Result<()> {
185+
// Validate mutually exclusive authentication methods
186+
let auth_methods_count = [
187+
login_cli.token.is_some(),
188+
login_cli.use_sso,
189+
login_cli.use_device_code,
190+
login_cli.with_api_key,
191+
]
192+
.iter()
193+
.filter(|&&x| x)
194+
.count();
195+
196+
if auth_methods_count > 1 {
197+
bail!(
198+
"Cannot specify multiple authentication methods. Choose one: --token, --sso, --device-auth, or --with-api-key."
199+
);
200+
}
201+
185202
match login_cli.action {
186203
Some(LoginSubcommand::Status) => run_login_status(login_cli.config_overrides).await,
187204
None => {
@@ -512,7 +529,7 @@ fn install_completions(shell: Shell) -> Result<()> {
512529
// ============================================================================
513530

514531
/// Show current logged-in user.
515-
pub async fn run_whoami() {
532+
pub async fn run_whoami() -> Result<()> {
516533
use cortex_login::{AuthMode, load_auth_with_fallback, safe_format_key};
517534

518535
let cortex_home = dirs::home_dir()
@@ -527,7 +544,7 @@ pub async fn run_whoami() {
527544
"Authenticated via CORTEX_AUTH_TOKEN: {}",
528545
safe_format_key(&token)
529546
);
530-
return;
547+
return Ok(());
531548
}
532549

533550
if let Ok(token) = std::env::var("CORTEX_API_KEY")
@@ -537,7 +554,7 @@ pub async fn run_whoami() {
537554
"Authenticated via CORTEX_API_KEY: {}",
538555
safe_format_key(&token)
539556
);
540-
return;
557+
return Ok(());
541558
}
542559

543560
// Load stored credentials
@@ -565,9 +582,11 @@ pub async fn run_whoami() {
565582
println!("Not logged in. Run 'cortex login' to authenticate.");
566583
}
567584
Err(e) => {
568-
eprintln!("Error checking login status: {}", e);
585+
return Err(anyhow::anyhow!("Error checking login status: {}", e));
569586
}
570587
}
588+
589+
Ok(())
571590
}
572591

573592
/// Resume a previous session.
@@ -585,6 +604,16 @@ pub async fn run_resume(resume_cli: ResumeCommand) -> Result<()> {
585604
let config = cortex_engine::Config::default();
586605

587606
let id_str = match (resume_cli.session_id, resume_cli.last, resume_cli.pick) {
607+
// Support "last" as SESSION_ID as documented in help text (Issue #3646)
608+
(Some(id), _, _) if id.to_lowercase() == "last" => {
609+
let sessions = cortex_engine::list_sessions(&config.cortex_home)?;
610+
if sessions.is_empty() {
611+
print_info("No sessions found to resume.");
612+
return Ok(());
613+
}
614+
print_info("Resuming most recent session...");
615+
sessions[0].id.clone()
616+
}
588617
(Some(id), _, _) => id,
589618
(None, true, _) => {
590619
let sessions = cortex_engine::list_sessions(&config.cortex_home)?;
@@ -810,10 +839,10 @@ pub async fn show_config(config_cli: ConfigCommand) -> Result<()> {
810839
if let Some(value) = config.get(&args.key) {
811840
println!("{}", value);
812841
} else {
813-
println!("Key '{}' not found.", args.key);
842+
bail!("Key '{}' not found in configuration", args.key);
814843
}
815844
} else {
816-
println!("No configuration file found.");
845+
bail!("No configuration file found");
817846
}
818847
}
819848
Some(ConfigSubcommand::Set(args)) => {
@@ -841,10 +870,10 @@ pub async fn show_config(config_cli: ConfigCommand) -> Result<()> {
841870
std::fs::write(&config_path, content)?;
842871
print_success(&format!("Removed key: {}", args.key));
843872
} else {
844-
print_warning(&format!("Key '{}' not found.", args.key));
873+
bail!("Key '{}' not found in configuration", args.key);
845874
}
846875
} else {
847-
print_warning("No configuration file found.");
876+
bail!("No configuration file found");
848877
}
849878
}
850879
None => {

src/cortex-cli/src/cli/styles.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ pub const AFTER_HELP: &str = color_print::cstr!(
4949
<dim>Agents</> ~/.cortex/agents/ (personal), .cortex/agents/ (project)
5050
5151
<cyan,bold>🔗 LEARN MORE</>
52-
<blue,underline>https://docs.cortex.foundation</> Documentation
53-
<blue,underline>https://github.com/CortexLM/cortex-cli</> Source & Issues"#
52+
<blue,underline>https://docs.cortex.foundation</> Documentation
53+
<blue,underline>https://github.com/CortexLM/cortex</> Source & Issues"#
5454
);
5555

5656
/// Before-help section with ASCII art banner.

src/cortex-cli/src/compact_cmd.rs

Lines changed: 25 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -407,33 +407,32 @@ async fn run_compact(args: CompactRunArgs) -> Result<()> {
407407
}
408408

409409
if args.dry_run {
410-
println!("Dry run mode - no changes will be made.");
411-
println!();
410+
let log_files_count = dir_stats(&logs_dir).0;
411+
let session_files_count = dir_stats(&sessions_dir).0;
412+
let history_files_count = dir_stats(&history_dir).0;
413+
let orphaned_history_count = count_orphaned_history(&sessions_dir, &history_dir);
412414

413-
let status = CompactionStatus {
414-
data_dir: data_dir.clone(),
415-
logs_dir: logs_dir.clone(),
416-
sessions_dir: sessions_dir.clone(),
417-
history_dir: history_dir.clone(),
418-
log_files_count: dir_stats(&logs_dir).0,
419-
log_files_size: dir_stats(&logs_dir).1,
420-
log_files_size_human: format_size(dir_stats(&logs_dir).1),
421-
session_files_count: dir_stats(&sessions_dir).0,
422-
history_files_count: dir_stats(&history_dir).0,
423-
orphaned_history_count: count_orphaned_history(&sessions_dir, &history_dir),
424-
total_data_size: 0,
425-
total_data_size_human: String::new(),
426-
lock_held: false,
427-
};
428-
429-
println!("Would process:");
430-
println!(" Log files: {}", status.log_files_count);
431-
println!(" Session files: {}", status.session_files_count);
432-
println!(" History files: {}", status.history_files_count);
433-
println!(
434-
" Orphaned files to clean: {}",
435-
status.orphaned_history_count
436-
);
415+
if args.json {
416+
let output = serde_json::json!({
417+
"dry_run": true,
418+
"would_process": {
419+
"log_files": log_files_count,
420+
"session_files": session_files_count,
421+
"history_files": history_files_count,
422+
"orphaned_files_to_clean": orphaned_history_count
423+
},
424+
"message": "Dry run completed - no changes made"
425+
});
426+
println!("{}", serde_json::to_string_pretty(&output)?);
427+
} else {
428+
println!("Dry run mode - no changes will be made.");
429+
println!();
430+
println!("Would process:");
431+
println!(" Log files: {}", log_files_count);
432+
println!(" Session files: {}", session_files_count);
433+
println!(" History files: {}", history_files_count);
434+
println!(" Orphaned files to clean: {}", orphaned_history_count);
435+
}
437436
return Ok(());
438437
}
439438

src/cortex-cli/src/dag_cmd/commands.rs

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ pub async fn run_create(args: DagCreateArgs) -> Result<()> {
7171

7272
/// Execute a DAG.
7373
pub async fn run_execute(args: DagRunArgs) -> Result<()> {
74+
// Validate --jobs argument (Issue #3716)
75+
if args.max_concurrent == 0 {
76+
bail!("--jobs must be at least 1. Use --jobs 1 for sequential execution.");
77+
}
78+
7479
let spec = load_spec(&args.file)?;
7580
let specs = convert_specs(&spec);
7681

@@ -86,14 +91,33 @@ pub async fn run_execute(args: DagRunArgs) -> Result<()> {
8691

8792
if matches!(args.strategy, ExecutionStrategy::DryRun) {
8893
// Dry run - just validate
89-
dag.topological_sort()
94+
let order = dag
95+
.topological_sort()
9096
.map_err(|e| anyhow::anyhow!("DAG validation failed: {}", e))?;
91-
print_success(&format!(
92-
"✓ DAG is valid ({} tasks in execution order)",
93-
dag.len()
94-
));
95-
println!();
96-
print_execution_order(&dag)?;
97+
98+
match args.format {
99+
DagOutputFormat::Json => {
100+
let task_names: Vec<String> = order
101+
.iter()
102+
.filter_map(|id| dag.get_task(*id).map(|t| t.name.clone()))
103+
.collect();
104+
let output = serde_json::json!({
105+
"dry_run": true,
106+
"valid": true,
107+
"task_count": dag.len(),
108+
"execution_order": task_names
109+
});
110+
println!("{}", serde_json::to_string_pretty(&output)?);
111+
}
112+
_ => {
113+
print_success(&format!(
114+
"✓ DAG is valid ({} tasks in execution order)",
115+
dag.len()
116+
));
117+
println!();
118+
print_execution_order(&dag)?;
119+
}
120+
}
97121
return Ok(());
98122
}
99123

@@ -251,7 +275,14 @@ pub async fn run_list(args: DagListArgs) -> Result<()> {
251275
let ids = store.list().await?;
252276

253277
if ids.is_empty() {
254-
print_info("No DAGs found");
278+
match args.format {
279+
DagOutputFormat::Json => {
280+
println!("[]");
281+
}
282+
_ => {
283+
print_info("No DAGs found");
284+
}
285+
}
255286
return Ok(());
256287
}
257288

src/cortex-cli/src/dag_cmd/helpers.rs

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,17 @@ pub fn load_spec(path: &PathBuf) -> Result<DagSpecInput> {
2828
serde_yaml::from_str(&content).context("Failed to parse YAML")?
2929
};
3030

31+
// Check for duplicate task IDs (Issue #3815)
32+
let mut seen_names = std::collections::HashSet::new();
33+
for task in &spec.tasks {
34+
if !seen_names.insert(&task.name) {
35+
anyhow::bail!(
36+
"Duplicate task ID '{}' found in DAG specification. Task IDs must be unique.",
37+
task.name
38+
);
39+
}
40+
}
41+
3142
Ok(spec)
3243
}
3344

@@ -67,8 +78,9 @@ pub fn convert_specs(input: &DagSpecInput) -> Vec<TaskSpec> {
6778
pub fn print_dag_summary(dag: &TaskDag) {
6879
println!("Tasks:");
6980
for task in dag.all_tasks() {
70-
let deps = dag
71-
.get_dependencies(task.id.unwrap())
81+
let deps = task
82+
.id
83+
.and_then(|id| dag.get_dependencies(id))
7284
.map(|d| d.len())
7385
.unwrap_or(0);
7486
let dep_str = if deps > 0 {
@@ -119,7 +131,11 @@ pub fn print_execution_summary(stats: &DagExecutionStats, format: DagOutputForma
119131
"skipped": stats.skipped_tasks,
120132
"duration_secs": stats.total_duration.as_secs_f64()
121133
});
122-
println!("{}", serde_json::to_string_pretty(&output).unwrap());
134+
println!(
135+
"{}",
136+
serde_json::to_string_pretty(&output)
137+
.expect("JSON serialization should not fail for DagExecutionStats")
138+
);
123139
}
124140
DagOutputFormat::Compact => {
125141
println!(
@@ -170,7 +186,7 @@ pub fn print_ascii_graph(dag: &TaskDag, spec: &DagSpecInput) {
170186
for task_id in tasks_at_depth {
171187
if let Some(task) = dag.get_task(*task_id) {
172188
let deps = dag.get_dependencies(*task_id);
173-
let arrow = if deps.map(|d| !d.is_empty()).unwrap_or(false) {
189+
let arrow = if deps.is_some_and(|d| !d.is_empty()) {
174190
"└─► "
175191
} else {
176192
"● "
@@ -195,7 +211,7 @@ pub fn print_dot_graph(dag: &TaskDag, spec: &DagSpecInput) {
195211
println!();
196212

197213
for task in dag.all_tasks() {
198-
let task_id = task.id.unwrap();
214+
let Some(task_id) = task.id else { continue };
199215
let color = match task.status {
200216
TaskStatus::Completed => "green",
201217
TaskStatus::Failed => "red",
@@ -214,7 +230,7 @@ pub fn print_dot_graph(dag: &TaskDag, spec: &DagSpecInput) {
214230
println!();
215231

216232
for task in dag.all_tasks() {
217-
let task_id = task.id.unwrap();
233+
let Some(task_id) = task.id else { continue };
218234
if let Some(deps) = dag.get_dependencies(task_id) {
219235
for dep_id in deps {
220236
println!(" \"{}\" -> \"{}\";", dep_id.inner(), task_id.inner());
@@ -234,14 +250,14 @@ pub fn print_mermaid_graph(dag: &TaskDag, spec: &DagSpecInput) {
234250
// Create task ID to safe name mapping
235251
let mut id_to_name: HashMap<TaskId, String> = HashMap::new();
236252
for task in dag.all_tasks() {
237-
let task_id = task.id.unwrap();
253+
let Some(task_id) = task.id else { continue };
238254
let safe_name = task.name.replace([' ', '-'], "_");
239255
id_to_name.insert(task_id, safe_name.clone());
240256
println!(" {}[{}]", safe_name, task.name);
241257
}
242258

243259
for task in dag.all_tasks() {
244-
let task_id = task.id.unwrap();
260+
let Some(task_id) = task.id else { continue };
245261
if let Some(deps) = dag.get_dependencies(task_id) {
246262
for dep_id in deps {
247263
if let (Some(from), Some(to)) = (id_to_name.get(dep_id), id_to_name.get(&task_id)) {

0 commit comments

Comments
 (0)