diff --git a/crates/dbx-core/src/ai_codex_cli.rs b/crates/dbx-core/src/ai_codex_cli.rs index 98aafc023..dfe08e492 100644 --- a/crates/dbx-core/src/ai_codex_cli.rs +++ b/crates/dbx-core/src/ai_codex_cli.rs @@ -101,14 +101,29 @@ async fn resolve_program_path(program: &str) -> Option { fn direct_program_path(program: &str) -> Option { let path = Path::new(program); - if path.is_absolute() && path.is_file() { + if !path.is_absolute() { + return None; + } + + if path.is_dir() { + return launchable_program_in_dir(path, "codex"); + } + + if path.is_file() { #[cfg(windows)] return windows_launchable_program_path(path); #[cfg(not(windows))] return Some(path.to_string_lossy().to_string()); - } else { - None } + + None +} + +fn launchable_program_in_dir(dir: &Path, program: &str) -> Option { + program_path_candidates(dir, program) + .into_iter() + .find(|candidate| is_launchable_program_path(candidate) && candidate.is_file()) + .map(|path| path.to_string_lossy().to_string()) } #[cfg(windows)] @@ -313,6 +328,13 @@ fn validate_codex_program(config: &AiConfig) -> Result { if starts_with_env_assignment(&program) { return Err("[codexCliPathInvalid] Codex CLI path should contain only the executable path. Add environment variables in the Codex CLI environment variables section.".to_string()); } + if is_path_like_program(&program) { + let expanded = expand_tilde(&program); + let path = Path::new(&expanded); + if path.is_dir() && direct_program_path(&expanded).is_none() { + return Err("[codexCliPathInvalid] Codex CLI path should point to the Codex executable or a directory containing codex.".to_string()); + } + } Ok(program) } @@ -558,7 +580,8 @@ mod tests { use super::{codex_process_env, common_executable_dirs, merged_path_with_dir}; #[cfg(windows)] use super::{ - direct_program_path, first_windows_program_path, program_path_candidates, windows_npm_codex_shim_command, + direct_program_path, first_windows_program_path, program_path_candidates, resolve_codex_command, + windows_npm_codex_shim_command, }; use crate::agent_events::AgentEvent; use crate::ai::{AiApiStyle, AiAuthMethod, AiConfig, AiProvider, AiReasoningLevel}; @@ -712,6 +735,50 @@ mod tests { let _ = std::fs::remove_dir(dir); } + #[test] + #[cfg(windows)] + fn windows_direct_directory_path_uses_codex_cmd_inside() { + let dir = std::env::temp_dir().join(format!("dbx-codex-direct-dir-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let extensionless = dir.join("codex"); + let cmd = dir.join("codex.cmd"); + std::fs::write(&extensionless, "#!/bin/sh\n").unwrap(); + std::fs::write(&cmd, "@echo off\n").unwrap(); + + let resolved = direct_program_path(dir.to_string_lossy().as_ref()).unwrap(); + + assert_eq!(resolved, cmd.to_string_lossy().as_ref()); + let _ = std::fs::remove_file(extensionless); + let _ = std::fs::remove_file(cmd); + let _ = std::fs::remove_dir(dir); + } + + #[tokio::test] + #[cfg(windows)] + async fn windows_configured_nvm_directory_resolves_codex_cmd_shim() { + let dir = std::env::temp_dir().join(format!("dbx-codex-nvm-dir-test-{}", std::process::id())); + let bin_dir = dir.join("node_modules").join("@openai").join("codex").join("bin"); + std::fs::create_dir_all(&bin_dir).unwrap(); + let cmd = dir.join("codex.cmd"); + let node = dir.join("node.exe"); + let codex_js = bin_dir.join("codex.js"); + std::fs::write(&cmd, "@echo off\nnode codex.js %*\n").unwrap(); + std::fs::write(&node, "").unwrap(); + std::fs::write(&codex_js, "#!/usr/bin/env node\n").unwrap(); + + let mut config = codex_config("default"); + config.codex_cli_path = Some(dir.to_string_lossy().to_string()); + + let command = resolve_codex_command(&config).await; + + assert_eq!(command.program, node.to_string_lossy().as_ref()); + assert_eq!(command.args, vec![codex_js.to_string_lossy().to_string()]); + let _ = std::fs::remove_file(cmd); + let _ = std::fs::remove_file(node); + let _ = std::fs::remove_file(codex_js); + let _ = std::fs::remove_dir_all(dir); + } + #[test] #[cfg(windows)] fn windows_npm_codex_cmd_shim_uses_node_directly() { @@ -844,6 +911,19 @@ mod tests { assert!(err.contains("environment variables section")); } + #[test] + fn rejects_directory_without_codex_in_codex_cli_path() { + let dir = std::env::temp_dir().join(format!("dbx-codex-empty-dir-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut config = codex_config("default"); + config.codex_cli_path = Some(dir.to_string_lossy().to_string()); + + let err = validate_codex_program(&config).unwrap_err(); + + assert!(err.contains("[codexCliPathInvalid]")); + let _ = std::fs::remove_dir(dir); + } + #[test] fn parses_codex_model_catalog_without_service_tiers() { let models = parse_codex_models(