From 979ce6a60bc9d2a7388e02843f2deed2228d9121 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 10 Oct 2026 22:49:34 +0800 Subject: [PATCH] fix(codex): find the CLI bundled with the Codex desktop app The Codex Stack page reports "official models unavailable" when both the online fetch and `codex debug models --bundled` fail. Users who only installed the desktop app have no `codex` on PATH, so the bundled source always failed for them. Add the desktop app's own CLI as the last fallback candidates: - macOS: ChatGPT.app and the pre-rename Codex.app under /Applications and ~/Applications, current `codex-cli/bin/codex` layout before the older `Resources/codex`; ChatGPT.app always wins so a leftover Codex.app cannot shadow the newer model list. - Windows: %LOCALAPPDATA%\OpenAI\Codex\bin\\codex.exe, newest modification time first (version names do not sort as strings). Standalone installs still take precedence. Refs #8087, #7993 --- src-tauri/src/codex_config.rs | 177 +++++++++++++++++++++++++++++++++- 1 file changed, 176 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index 97930f6ec..26a70876d 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -1562,6 +1562,18 @@ const CODEX_CLI_FIXED_CANDIDATES: &[&str] = &[ "/home/linuxbrew/.linuxbrew/bin/codex", // Linux Homebrew ]; +/// macOS 桌面版的 bundle 名,新的在前:改名前叫 Codex.app,改名迁移有时会两份都留着 +/// (openai/codex#48052),这时 ChatGPT.app 是新的那份。 +const CODEX_DESKTOP_APP_BUNDLES: &[&str] = &["ChatGPT.app", "Codex.app"]; + +/// bundle 里自带的 Codex CLI,新结构在前:`codex-cli/bin/codex` 是指向同目录 `CodexCLI.app` +/// 的垫片脚本,更早的版本直接放在 `Resources/codex`。只装了桌面版的人 PATH 上没有 `codex`, +/// 取 Codex 自带的模型列表只能靠它(#8087)。 +const CODEX_DESKTOP_CLI_IN_BUNDLE: &[&str] = &[ + "Contents/Resources/codex-cli/bin/codex", + "Contents/Resources/codex", +]; + fn push_codex_cli_candidate( candidates: &mut Vec, seen: &mut HashSet, @@ -1707,12 +1719,80 @@ fn codex_cli_candidates() -> Vec { push_codex_cli_candidate(&mut candidates, &mut seen, PathBuf::from(candidate)); } + let home = get_home_dir(); push_env_codex_cli_candidates(&mut candidates, &mut seen); - push_home_codex_cli_candidates(&mut candidates, &mut seen, &get_home_dir()); + push_home_codex_cli_candidates(&mut candidates, &mut seen, &home); + push_desktop_codex_cli_candidates(&mut candidates, &mut seen, &home); candidates } +/// 桌面版自带的 CLI。排在最后:装了独立 CLI 的人照旧先用独立的那个。 +/// +/// - macOS:`/Applications` 和 `~/Applications` 下的 ChatGPT.app、Codex.app,新 bundle +/// 排在前面,不管装在哪个目录(别的系统上不存在,自然跳过); +/// - Windows:`%LOCALAPPDATA%\OpenAI\Codex\bin\<版本>\codex.exe`,版本目录随桌面版更新而变。 +fn push_desktop_codex_cli_candidates( + candidates: &mut Vec, + seen: &mut HashSet, + home: &Path, +) { + for bundle in CODEX_DESKTOP_APP_BUNDLES { + for applications in [PathBuf::from("/Applications"), home.join("Applications")] { + for layout in CODEX_DESKTOP_CLI_IN_BUNDLE { + push_existing_codex_cli_candidate( + candidates, + seen, + applications.join(bundle).join(layout), + ); + } + } + } + + #[cfg(windows)] + { + if let Some(local_appdata) = std::env::var_os("LOCALAPPDATA") { + push_codex_cli_candidates_newest_first( + candidates, + seen, + &PathBuf::from(local_appdata) + .join("OpenAI") + .join("Codex") + .join("bin"), + "codex.exe", + ); + } + } +} + +/// `versions_dir` 下各个版本目录里的 `exe`,按它的修改时间从新到旧(相同时按目录名)。 +/// 不按目录名排:版本号按字符串比,`0.99.0` 会排在 `0.153.4` 前面。 +#[cfg(any(windows, test))] +fn push_codex_cli_candidates_newest_first( + candidates: &mut Vec, + seen: &mut HashSet, + versions_dir: &Path, + exe: &str, +) { + let Ok(entries) = fs::read_dir(versions_dir) else { + return; + }; + + let mut discovered = entries + .filter_map(Result::ok) + .filter_map(|entry| { + let candidate = entry.path().join(exe); + let modified = fs::metadata(&candidate).ok()?.modified().ok()?; + Some((modified, candidate)) + }) + .collect::>(); + + discovered.sort_by(|(a_time, a), (b_time, b)| b_time.cmp(a_time).then_with(|| a.cmp(b))); + for (_, candidate) in discovered { + push_codex_cli_candidate(candidates, seen, candidate); + } +} + fn codex_bundled_models_command(candidate: &Path) -> Command { let mut command = Command::new(candidate); command @@ -5499,6 +5579,101 @@ wire_api = "responses" ); } + fn create_cli_candidate(path: &Path) { + std::fs::create_dir_all(path.parent().expect("candidate parent")) + .expect("create candidate parent"); + std::fs::write(path, "").expect("create candidate"); + } + + fn desktop_cli(applications: &Path, bundle: &str, layout: &str) -> PathBuf { + applications.join(bundle).join(layout) + } + + #[test] + fn codex_cli_candidates_include_desktop_app_bundles_and_layouts() { + let temp_home = tempfile::tempdir().expect("create temp home"); + let applications = temp_home.path().join("Applications"); + let current_layout = CODEX_DESKTOP_CLI_IN_BUNDLE[0]; + let legacy_layout = CODEX_DESKTOP_CLI_IN_BUNDLE[1]; + let chatgpt_current = desktop_cli(&applications, "ChatGPT.app", current_layout); + let chatgpt_legacy = desktop_cli(&applications, "ChatGPT.app", legacy_layout); + // 改名前的 Codex.app 只有旧结构(openai/codex#48052)。 + let codex_legacy = desktop_cli(&applications, "Codex.app", legacy_layout); + for path in [&chatgpt_current, &chatgpt_legacy, &codex_legacy] { + create_cli_candidate(path); + } + + let mut candidates = Vec::new(); + let mut seen = HashSet::new(); + push_desktop_codex_cli_candidates(&mut candidates, &mut seen, temp_home.path()); + + let position = |path: &Path| { + candidates + .iter() + .position(|candidate| candidate == path) + .unwrap_or_else(|| panic!("missing desktop candidate {}", path.display())) + }; + assert!(position(&chatgpt_current) < position(&chatgpt_legacy)); + // 两份都在时 ChatGPT.app 是新的那份,先用它。 + assert!(position(&chatgpt_legacy) < position(&codex_legacy)); + } + + fn set_mtime(path: &Path, time: std::time::SystemTime) { + std::fs::File::options() + .write(true) + .open(path) + .expect("open") + .set_times(std::fs::FileTimes::new().set_modified(time)) + .expect("set mtime"); + } + + #[test] + fn codex_cli_candidates_try_the_newest_desktop_version_first() { + let temp = tempfile::tempdir().expect("create temp dir"); + let root = temp.path(); + let now = std::time::SystemTime::now(); + // 按目录名倒序排的话 0.99.0 会排在前面。 + let older = root.join("0.99.0").join("codex.exe"); + let newer = root.join("0.162.0").join("codex.exe"); + create_cli_candidate(&older); + create_cli_candidate(&newer); + set_mtime(&older, now - std::time::Duration::from_secs(3600)); + set_mtime(&newer, now); + std::fs::create_dir_all(root.join("logs")).expect("create dir without codex.exe"); + + let mut candidates = Vec::new(); + let mut seen = HashSet::new(); + push_codex_cli_candidates_newest_first(&mut candidates, &mut seen, root, "codex.exe"); + + assert_eq!(candidates, vec![newer, older]); + } + + #[test] + #[serial] + fn codex_cli_candidates_try_the_desktop_app_cli_after_standalone_installs() { + let test_home = CodexLiveTestHome::new(); + let home = test_home._dir.path(); + let standalone = home.join(".volta/bin/codex"); + let desktop = desktop_cli( + &home.join("Applications"), + "ChatGPT.app", + CODEX_DESKTOP_CLI_IN_BUNDLE[0], + ); + create_cli_candidate(&standalone); + create_cli_candidate(&desktop); + + let candidates = codex_cli_candidates(); + let position = |path: &Path| { + candidates + .iter() + .position(|candidate| candidate == path) + .unwrap_or_else(|| panic!("missing candidate {}", path.display())) + }; + + // 装了独立 CLI 的人照旧先用它,桌面版的只做兜底。 + assert!(position(&standalone) < position(&desktop)); + } + #[test] fn classic_tool_template_keeps_the_classic_tool_contract() { let template = load_codex_classic_tool_template();