diff --git a/CHANGELOG.md b/CHANGELOG.md index 902c0b4..2ae238c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 1.5.12 - 2026-05-29 + +- Settings → Codex CLI path gains an **Auto-detect** button next to "Change". Unlike the existing path self-check (which trusts the cached / override path), it force-rescans every common install location plus PATH and verifies each candidate is actually runnable via `codex --version`. A lone runnable hit is applied immediately; several open the dialog with the verified candidates to pick from; none falls back to the manual dialog. Targets the two cases the self-check can't: auto-detection landed on a wrong / stale path, or the user doesn't know where to point it. Backed by a new `redetect_codex_cli_path` command that runs on the blocking pool (each candidate probe spawns a child) with a per-candidate timeout so a hung binary can't wedge the scan. macOS + Windows symmetric. + ## 1.5.11 - 2026-05-16 - Added experimental Linux x86_64 build to the release pipeline. Tagged releases now publish `.deb` (Debian/Ubuntu) and `.AppImage` (generic portable) artifacts alongside the existing macOS / Windows ones. Built on `ubuntu-22.04` (glibc 2.35) so binaries run on Ubuntu 22.04+ / Debian 12+ and equivalent distros. UI, profile switching, and plan / quota readout work as on the other platforms; Linux-native paths for Codex CLI discovery and `codex login` spawning are not separately adapted yet (the non-macOS code branch is currently reused), so feedback issues are welcome. diff --git a/README.md b/README.md index c81808c..6f7b99f 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ - **登录可取消**:进行中的 `codex login` OAuth 流程支持点击同一按钮取消(向子进程 SIGTERM / taskkill),解决浏览器关闭后应用卡在等待回调的场景。 - **plan / quota 智能缓存**:bulk plan refresh 在 6 小时窗口内跳过已确认账号,per-card 刷新按钮也共享同一缓存;切换 / 登录 / 刷新后直接复用 backend 写回的 snapshot,不重复发 IPC。 - **Custom Base URL**:每个账号可独立配置 `OPENAI_BASE_URL`;配置后按钮变红警示(自定义 Base 与 ChatGPT OAuth 账号互斥)。 -- **Codex CLI 路径自检**:自动定位 `codex` 可执行(PATH / `~/.codex/bin` / Homebrew / nvm),找不到或路径错误时设置页可手动指定,结果写入 `install_state.json` 优先生效。 +- **Codex CLI 路径自检**:自动定位 `codex` 可执行(PATH / `~/.codex/bin` / Homebrew / nvm),找不到或路径错误时设置页可手动指定,结果写入 `install_state.json` 优先生效。设置页还提供「自动检测」按钮:忽略可能出错的缓存重新扫描所有常见位置,并用 `codex --version` 验证候选确实可运行——唯一命中直接应用,多个命中时让你选。 - **跨平台原生 Tauri**:macOS arm64 / x64 与 Windows x64 提供原生窗口、原生标题栏 / 关闭按钮,配套 5 套浅色 / 深色主题与中英文界面。 - **本地预览模式**:没有 Tauri 运行时(直接 `vite` 跑前端)时自动使用 mock snapshot,方便单纯调样式。 @@ -143,7 +143,7 @@ npm run version:check # CI 用:拒绝把 semver 字面量写回 *. ### 没有 Codex CLI 怎么办 -应用启动后会探测 `codex` 路径;探测失败时设置页会标红「Codex CLI 路径未找到」,可手动指定(写入 `install_state.json` 的 `user_codex_path` 优先级最高)。未装 Codex CLI 也能用 plan / quota 查看(走 ChatGPT OAuth token 直接拉 rate-limits),但「登录」按钮和切换后启动 Codex 等动作需要 CLI 存在。 +应用启动后会探测 `codex` 路径;探测失败时设置页会标红「Codex CLI 路径未找到」,可手动指定(写入 `install_state.json` 的 `user_codex_path` 优先级最高)。也可以点设置页「Codex CLI 路径」行的「自动检测」按钮强制重新扫描,并用 `codex --version` 验证候选——比启动时的自检更主动,适合自动定位出错或不清楚 `codex` 装在哪的情况。未装 Codex CLI 也能用 plan / quota 查看(走 ChatGPT OAuth token 直接拉 rate-limits),但「登录」按钮和切换后启动 Codex 等动作需要 CLI 存在。 ### 切换账号会丢失原账号的 sessions / 历史吗 diff --git a/README.zh-CN.md b/README.zh-CN.md index c81808c..6f7b99f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -28,7 +28,7 @@ - **登录可取消**:进行中的 `codex login` OAuth 流程支持点击同一按钮取消(向子进程 SIGTERM / taskkill),解决浏览器关闭后应用卡在等待回调的场景。 - **plan / quota 智能缓存**:bulk plan refresh 在 6 小时窗口内跳过已确认账号,per-card 刷新按钮也共享同一缓存;切换 / 登录 / 刷新后直接复用 backend 写回的 snapshot,不重复发 IPC。 - **Custom Base URL**:每个账号可独立配置 `OPENAI_BASE_URL`;配置后按钮变红警示(自定义 Base 与 ChatGPT OAuth 账号互斥)。 -- **Codex CLI 路径自检**:自动定位 `codex` 可执行(PATH / `~/.codex/bin` / Homebrew / nvm),找不到或路径错误时设置页可手动指定,结果写入 `install_state.json` 优先生效。 +- **Codex CLI 路径自检**:自动定位 `codex` 可执行(PATH / `~/.codex/bin` / Homebrew / nvm),找不到或路径错误时设置页可手动指定,结果写入 `install_state.json` 优先生效。设置页还提供「自动检测」按钮:忽略可能出错的缓存重新扫描所有常见位置,并用 `codex --version` 验证候选确实可运行——唯一命中直接应用,多个命中时让你选。 - **跨平台原生 Tauri**:macOS arm64 / x64 与 Windows x64 提供原生窗口、原生标题栏 / 关闭按钮,配套 5 套浅色 / 深色主题与中英文界面。 - **本地预览模式**:没有 Tauri 运行时(直接 `vite` 跑前端)时自动使用 mock snapshot,方便单纯调样式。 @@ -143,7 +143,7 @@ npm run version:check # CI 用:拒绝把 semver 字面量写回 *. ### 没有 Codex CLI 怎么办 -应用启动后会探测 `codex` 路径;探测失败时设置页会标红「Codex CLI 路径未找到」,可手动指定(写入 `install_state.json` 的 `user_codex_path` 优先级最高)。未装 Codex CLI 也能用 plan / quota 查看(走 ChatGPT OAuth token 直接拉 rate-limits),但「登录」按钮和切换后启动 Codex 等动作需要 CLI 存在。 +应用启动后会探测 `codex` 路径;探测失败时设置页会标红「Codex CLI 路径未找到」,可手动指定(写入 `install_state.json` 的 `user_codex_path` 优先级最高)。也可以点设置页「Codex CLI 路径」行的「自动检测」按钮强制重新扫描,并用 `codex --version` 验证候选——比启动时的自检更主动,适合自动定位出错或不清楚 `codex` 装在哪的情况。未装 Codex CLI 也能用 plan / quota 查看(走 ChatGPT OAuth token 直接拉 rate-limits),但「登录」按钮和切换后启动 Codex 等动作需要 CLI 存在。 ### 切换账号会丢失原账号的 sessions / 历史吗 diff --git a/src-tauri/mac/front/index.html b/src-tauri/mac/front/index.html index 371656b..4c8502f 100644 --- a/src-tauri/mac/front/index.html +++ b/src-tauri/mac/front/index.html @@ -151,6 +151,7 @@

Profiles

Codex CLI path

--

+
diff --git a/src-tauri/mac/runtime/process.rs b/src-tauri/mac/runtime/process.rs index 2d28aa5..ece196d 100644 --- a/src-tauri/mac/runtime/process.rs +++ b/src-tauri/mac/runtime/process.rs @@ -9,6 +9,7 @@ use std::time::Duration; use crate::errors::{AppError, AppResult}; use crate::platform::hooks::PlatformHooks; use crate::shared::codex_app_server::{fetch_account_snapshot, AppServerSnapshot}; +use crate::models::CodexCliCandidate; use crate::shared::codex_cli_path::CodexPathResolver; pub use crate::shared::codex_cli_path::{InstallState, RealCodexPathSource}; use crate::shared::login_cancel::wait_for_login_or_cancel; @@ -81,15 +82,14 @@ fn discover_real_codex_cli_from_shell(managed_shim_path: Option<&Path>) -> Optio let managed_shim_text = managed_shim_path .map(|path| path.to_string_lossy().into_owned()) .unwrap_or_default(); - let output = Command::new(&resolver_path) - .arg(managed_shim_text) - .output() - .ok()?; - if !output.status.success() { - return None; - } - - let stdout = String::from_utf8_lossy(&output.stdout); + let mut command = Command::new(&resolver_path); + command.arg(managed_shim_text); + // Bounded like the other shell spawns: even this first-party resolver + // script sources the login environment, which can stall on a hung rc. + let stdout = crate::shared::codex_cli_path::run_capturing_stdout_with_timeout( + command, + LOGIN_SHELL_PROBE_TIMEOUT, + )?; let resolved = stdout .lines() .map(str::trim) @@ -252,6 +252,10 @@ impl CodexPathResolver for MacosCodexPathResolver { fn suggested_paths(&self, codex_home: &Path) -> Vec { suggested_codex_cli_paths(Some(codex_home)) } + + fn redetect_runnable_paths(&self, codex_home: &Path) -> Vec { + redetect_runnable_codex_cli_paths(Some(codex_home)) + } } /// Soft cap on how long the PATH walk can spend stat'ing entries @@ -301,6 +305,119 @@ pub fn suggested_codex_cli_paths(codex_home: Option<&Path>) -> Vec { suggestions } +/// How long a single `codex --version` probe may run before we kill it +/// and treat the candidate as unusable. Keeps a hung or input-waiting +/// binary from wedging the auto-detect scan; a healthy codex answers +/// well under this. +const RUNNABLE_PROBE_TIMEOUT: Duration = Duration::from_secs(3); + +/// Upper bound on how many candidates the auto-detect scan will probe. +/// Each probe spawns a child (up to `RUNNABLE_PROBE_TIMEOUT`), so without +/// a cap a pathological PATH with many `codex` entries could stall the +/// scan. Realistic machines have 1-3 candidates. +const MAX_PROBE_CANDIDATES: usize = 12; + +/// Login shells source the user's full profile chain (nvm / asdf / brew +/// shellenv / network-y rc files), which can be slower than a bare +/// `--version`, so give the login-shell resolve a more generous budget — +/// but still hard-bounded so a hung profile can't wedge the whole scan. +const LOGIN_SHELL_PROBE_TIMEOUT: Duration = Duration::from_secs(8); + +/// Probe whether `path` is a runnable codex CLI and capture its version. +/// `Some(version)` (possibly empty) means it's a file that ran and exited +/// 0; `None` means not-a-file, couldn't spawn, exited non-zero, or timed +/// out. The failure is logged so a broken install leaves a diagnostic +/// trail instead of looking identical to "not found". +fn probe_codex_version(path: &Path) -> Option { + if !path.is_file() { + return None; + } + let mut command = Command::new(path); + command.arg("--version"); + let result = + crate::shared::codex_cli_path::probe_version_with_timeout(command, RUNNABLE_PROBE_TIMEOUT); + if result.is_none() { + eprintln!( + "codex probe: {} is not a runnable codex (spawn / non-zero exit / timeout)", + path.display() + ); + } + result +} + +/// Resolve codex through the user's login shell, so installs on the +/// shell's PATH (nvm / asdf / brew / fnm / any rc-managed location) are +/// found even when the app was launched from Finder with the narrow +/// launchd PATH. A non-interactive login shell + `command -v` avoids +/// loading the user's `codex` shell *function* (if any), so we resolve to +/// the real binary; the result is verified to be an absolute file. +fn discover_codex_via_login_shell(managed_shim_path: Option<&Path>) -> Option { + let shell = env::var_os("SHELL")?; + let mut command = Command::new(&shell); + command.args(["-lc", "command -v codex"]); + // Bounded via the shared helper: a slow / hung login profile (nvm, + // asdf, network-y rc files) must NOT wedge the scan the way an + // unbounded `.output()` would — this runs first and synchronously. + let stdout = crate::shared::codex_cli_path::run_capturing_stdout_with_timeout( + command, + LOGIN_SHELL_PROBE_TIMEOUT, + )?; + // `command -v` prints the resolved path last — after any banner a noisy + // profile may have echoed to stdout — so take the last non-empty line. + let resolved = stdout + .lines() + .map(str::trim) + .rev() + .find(|value| !value.is_empty())?; + let candidate = PathBuf::from(resolved); + // `command -v` of a function/alias returns a bare name, not a path — + // require an absolute path to a real file so we never feed that back. + if !candidate.is_absolute() || !candidate.is_file() { + return None; + } + if managed_shim_path.is_some_and(|managed| managed == candidate.as_path()) { + return None; + } + Some(candidate) +} + +/// Force a fresh scan for the Settings auto-detect button: gather every +/// candidate the discovery + suggestion paths know about (login-shell +/// resolution, managed-shim resolver, Codex.app bundle, fixed install +/// locations, PATH), then keep only those that pass the runnable probe, +/// capturing each one's version. Ignores the cached/override path so a +/// wrong saved path can be corrected. +pub fn redetect_runnable_codex_cli_paths(codex_home: Option<&Path>) -> Vec { + let managed_shim = codex_home.map(managed_shim_path); + let mut candidates: Vec = Vec::new(); + + // Login-shell resolution first — catches nvm / asdf / brew / fnm + // installs on the user's PATH even under Finder's narrow launchd PATH. + if let Some(path) = discover_codex_via_login_shell(managed_shim.as_deref()) { + push_candidate(&mut candidates, path); + } + // The managed-shim resolver (present when codex_switch installed its + // shim) and the suggestion list (Codex.app bundle, fixed locations, + // bounded PATH walk) fill in the rest. + if let Some(shell_path) = discover_real_codex_cli_from_shell(managed_shim.as_deref()) { + push_candidate(&mut candidates, shell_path); + } + for path in suggested_codex_cli_paths(codex_home) { + push_candidate(&mut candidates, path); + } + + candidates + .into_iter() + .take(MAX_PROBE_CANDIDATES) + .filter_map(|path| { + probe_codex_version(&path).map(|version| CodexCliCandidate { + path: path.to_string_lossy().into_owned(), + version: (!version.is_empty()).then_some(version), + }) + }) + .collect() +} + fn codex_app_candidates() -> Vec { let mut candidates = vec![PathBuf::from("/Applications/Codex.app")]; if let Some(home) = env::var_os("HOME") { diff --git a/src-tauri/shared/commands/actions.rs b/src-tauri/shared/commands/actions.rs index 581a023..1b86cd6 100644 --- a/src-tauri/shared/commands/actions.rs +++ b/src-tauri/shared/commands/actions.rs @@ -1,8 +1,8 @@ use crate::errors::CommandError; use crate::models::{ - ActionResponse, AddProfilePayload, CodexCliStatus, OpenUrlPayload, ProfilePayload, - RenameProfilePayload, SetCodexCliPathPayload, UpdateCheckPayload, UpdateCheckResponse, - UpdateProfileBaseUrlPayload, + ActionResponse, AddProfilePayload, CodexCliRedetectResult, CodexCliStatus, OpenUrlPayload, + ProfilePayload, RenameProfilePayload, SetCodexCliPathPayload, UpdateCheckPayload, + UpdateCheckResponse, UpdateProfileBaseUrlPayload, }; #[cfg(target_os = "macos")] @@ -222,6 +222,28 @@ pub fn clear_codex_cli_path() -> Result { )) } +/// Force a fresh codex CLI detection scan for the Settings auto-detect +/// button. Runs on the blocking pool because it probes each candidate +/// with `codex --version`, which can take a second or two per path and +/// would otherwise stall the UI thread. +#[tauri::command] +pub async fn redetect_codex_cli_path() -> Result { + tauri::async_runtime::spawn_blocking(|| { + let codex_home = platform_runtime::paths::get_codex_home(); + crate::shared::codex_cli_path::redetect_codex_cli_path( + platform_runtime::codex_cli_resolver(), + &codex_home, + ) + }) + .await + .map_err(|error| { + CommandError::new( + "CODEX_CLI_REDETECT_FAILED", + format!("Redetect task failed: {error}"), + ) + }) +} + #[tauri::command] pub fn cancel_codex_login() -> Result { Ok(crate::shared::login_cancel::cancel_login_in_progress()) diff --git a/src-tauri/shared/front/actions.ts b/src-tauri/shared/front/actions.ts index eebba20..873ccc7 100644 --- a/src-tauri/shared/front/actions.ts +++ b/src-tauri/shared/front/actions.ts @@ -34,12 +34,13 @@ import { refreshActiveProfileQuotaSilent, refreshAllOauthProfilePlansSilent, refreshProfile, + redetectCodexCliPath, renameProfile, setCodexCliPath, switchProfile, updateProfileBaseUrl, } from "@front-shared/tauri"; -import type { CodexCliStatus } from "@front-shared/types"; +import type { CodexCliCandidate, CodexCliRedetectResult, CodexCliStatus } from "@front-shared/types"; import { applyLocale, elements, @@ -678,7 +679,7 @@ function codexCliSourceLabel(source: CodexCliStatus["source"]): string { } } -function renderCodexCliStatus(status: CodexCliStatus): void { +function renderCodexCliStatus(status: CodexCliStatus, detected?: CodexCliCandidate[]): void { if (status.resolved_path) { elements.codexCliCurrentValue.textContent = status.resolved_path; elements.codexCliCurrentSource.textContent = ` (${codexCliSourceLabel(status.source)})`; @@ -691,8 +692,22 @@ function renderCodexCliStatus(status: CodexCliStatus): void { elements.clearCodexCliButton.hidden = true; } + // When auto-detect routes here with verified-runnable candidates, show + // those (with versions) instead of the raw common-location hints, so + // the user only picks from installs that actually ran. + const showingDetected = detected !== undefined && detected.length > 0; + elements.codexCliSuggestionsHeading.textContent = showingDetected + ? t(state.locale, "codexCliDetectedHeading") + : t(state.locale, "codexCliSuggestionsHeading"); + + // Normalise both sources to { path, version } so one render loop serves + // detected candidates (with versions) and plain suggestion hints. + const chips: CodexCliCandidate[] = showingDetected + ? detected + : status.suggested_paths.map((path) => ({ path, version: null })); + elements.codexCliSuggestions.replaceChildren(); - if (status.suggested_paths.length === 0) { + if (chips.length === 0) { const empty = document.createElement("p"); empty.className = "codex-cli-suggestions-empty"; empty.textContent = t(state.locale, "codexCliSuggestionsEmpty"); @@ -700,13 +715,15 @@ function renderCodexCliStatus(status: CodexCliStatus): void { return; } - for (const path of status.suggested_paths) { + for (const candidate of chips) { const button = document.createElement("button"); button.type = "button"; button.className = "codex-cli-suggestion"; - button.textContent = path; + button.textContent = candidate.version + ? `${candidate.path} · ${candidate.version}` + : candidate.path; button.addEventListener("click", () => { - elements.codexCliInput.value = path; + elements.codexCliInput.value = candidate.path; elements.codexCliInput.focus(); elements.codexCliInput.select(); clearDialogError(elements.codexCliDialogError); @@ -715,7 +732,10 @@ function renderCodexCliStatus(status: CodexCliStatus): void { } } -async function openCodexCliDialog(onSavedRetry?: () => Promise): Promise { +async function openCodexCliDialog( + onSavedRetry?: () => Promise, + detectedCandidates?: CodexCliCandidate[], +): Promise { pendingLoginRetry = onSavedRetry ?? null; clearDialogError(elements.codexCliDialogError); elements.codexCliInput.value = ""; @@ -734,19 +754,87 @@ async function openCodexCliDialog(onSavedRetry?: () => Promise): Promise 0; + elements.codexCliDialogCopy.textContent = hasDetected + ? t(state.locale, "codexCliDetectPickCopy") + : t(state.locale, "codexCliDialogCopy"); + + // Prefill the first detected candidate; otherwise the resolved path. + if (detectedCandidates !== undefined && detectedCandidates.length > 0) { + elements.codexCliInput.value = detectedCandidates[0].path; + } else if (status.resolved_path) { elements.codexCliInput.value = status.resolved_path; } elements.submitCodexCliButton.textContent = onSavedRetry ? t(state.locale, "codexCliRetryLogin") : t(state.locale, "save"); - renderCodexCliStatus(status); + renderCodexCliStatus(status, detectedCandidates); elements.codexCliDialog.showModal(); elements.codexCliInput.focus(); elements.codexCliInput.select(); } +/// Settings "auto-detect" button: force a fresh runnable scan and act on +/// the result — apply a lone hit, let the user pick when several survive, +/// or fall back to the manual dialog when none do. +async function handleDetectCodexCli(): Promise { + const button = elements.settingsCodexCliDetectButton; + if (button.disabled) { + return; + } + button.disabled = true; + button.textContent = t(state.locale, "settingsCodexCliDetecting"); + try { + const result: CodexCliRedetectResult = await redetectCodexCliPath(); + if (result.candidates.length === 1) { + // Lone runnable hit → apply it straight away (the small-user + // fallback: one click, done). If the backend's set/validate then + // rejects it (managed shim, or the file vanished between probe and + // set), don't dump the raw error — fall back to the dialog with the + // candidate so the user can adjust. + const only = result.candidates[0]; + try { + const status = await setCodexCliPath(only.path); + applyCodexCliSettingsDisplay(status); + showToast(t(state.locale, "codexCliDetectApplied", { path: only.path })); + } catch { + applyCodexCliSettingsDisplay(result.status); + void openCodexCliDialog(undefined, result.candidates); + } + } else if (result.candidates.length === 0) { + // Nothing runnable. Distinguish "no codex anywhere" from "codex + // exists on disk but none would run" (a broken install, not a + // missing one) via the on-disk suggestions in the refreshed status. + applyCodexCliSettingsDisplay(result.status); + const foundButBroken = result.status.suggested_paths.length > 0; + showToast( + t(state.locale, foundButBroken ? "codexCliDetectFoundButBroken" : "codexCliDetectNone"), + true, + ); + void openCodexCliDialog(); + } else { + // Several runnable hits → let the user choose in the dialog. + applyCodexCliSettingsDisplay(result.status); + showToast( + t(state.locale, "codexCliDetectMultiple", { count: String(result.candidates.length) }), + ); + void openCodexCliDialog(undefined, result.candidates); + } + } catch (error) { + showToast( + error instanceof Error ? error.message : t(state.locale, "codexCliDetectFailed"), + true, + ); + } finally { + button.disabled = false; + button.textContent = t(state.locale, "settingsCodexCliDetect"); + } +} + function closeCodexCliDialog(): void { pendingLoginRetry = null; elements.codexCliDialog.close(); @@ -933,6 +1021,9 @@ export function bootstrap(): void { elements.codexCliForm.addEventListener("submit", (event) => { void handleSubmitCodexCliPath(event as SubmitEvent); }); + elements.settingsCodexCliDetectButton.addEventListener("click", () => { + void handleDetectCodexCli(); + }); elements.settingsCodexCliButton.addEventListener("click", () => { void openCodexCliDialog(); }); diff --git a/src-tauri/shared/front/base.css b/src-tauri/shared/front/base.css index ad69c09..e734a19 100644 --- a/src-tauri/shared/front/base.css +++ b/src-tauri/shared/front/base.css @@ -988,6 +988,14 @@ p { min-width: 0; } +/* Two action buttons now share this row with the path value (auto-detect + + change). Drop the fixed min-width so they size to their label and + leave the flexible remainder to the (ellipsised) path value. */ +.settings-cli-inline .settings-action-button { + min-width: auto; + flex: none; +} + .settings-value--inline { flex: 1; min-width: 0; diff --git a/src-tauri/shared/front/i18n.ts b/src-tauri/shared/front/i18n.ts index 48e8f70..cf4a369 100644 --- a/src-tauri/shared/front/i18n.ts +++ b/src-tauri/shared/front/i18n.ts @@ -242,6 +242,17 @@ const enMessages = { settingsCodexCli: "Codex CLI path", settingsCodexCliChange: "Change", settingsCodexCliEmpty: "Not detected", + settingsCodexCliDetect: "Auto-detect", + settingsCodexCliDetecting: "Detecting…", + codexCliDetectedHeading: "Detected (verified runnable)", + codexCliDetectApplied: "Detected and set: {path}", + codexCliDetectNone: "Couldn't auto-detect codex. Set the path manually below.", + codexCliDetectFoundButBroken: + "Found codex on disk, but none of them would run. Reinstall codex or set a working path below.", + codexCliDetectPickCopy: + "Found multiple runnable codex installs — pick the one to use, or set a path manually below.", + codexCliDetectMultiple: "Found {count} runnable codex binaries — pick one.", + codexCliDetectFailed: "Auto-detect failed.", } as const; export type MessageKey = keyof typeof enMessages; @@ -488,6 +499,17 @@ const messages: Record = { settingsCodexCli: "Codex CLI 路径", settingsCodexCliChange: "更改", settingsCodexCliEmpty: "未检测到", + settingsCodexCliDetect: "自动检测", + settingsCodexCliDetecting: "检测中…", + codexCliDetectedHeading: "已检测到(已验证可运行)", + codexCliDetectApplied: "已检测并设置:{path}", + codexCliDetectNone: "未能自动检测到 codex,请在下方手动设置路径。", + codexCliDetectFoundButBroken: + "找到了 codex,但都无法运行。请重装 codex 或在下方指定一个可用路径。", + codexCliDetectPickCopy: + "检测到多个可运行的 codex,选择要使用的那个;也可以在下方手动指定。", + codexCliDetectMultiple: "检测到 {count} 个可运行的 codex,请选择一个。", + codexCliDetectFailed: "自动检测失败。", }, }; diff --git a/src-tauri/shared/front/render.ts b/src-tauri/shared/front/render.ts index 86dd902..6669e6e 100644 --- a/src-tauri/shared/front/render.ts +++ b/src-tauri/shared/front/render.ts @@ -116,6 +116,7 @@ export const elements = { settingsCodexCliLabel: requiredElement("settings-codex-cli-label"), settingsCodexCliValue: requiredElement("settings-codex-cli-value"), settingsCodexCliButton: requiredElement("settings-codex-cli-button"), + settingsCodexCliDetectButton: requiredElement("settings-codex-cli-detect-button"), codexCliDialog: requiredElement("codex-cli-dialog"), codexCliForm: requiredElement("codex-cli-form"), codexCliDialogTitle: requiredElement("codex-cli-dialog-title"), @@ -758,6 +759,7 @@ export function applyLocale(): void { elements.submitCodexCliButton.textContent = t(state.locale, "save"); elements.settingsCodexCliLabel.textContent = t(state.locale, "settingsCodexCli"); elements.settingsCodexCliButton.textContent = t(state.locale, "settingsCodexCliChange"); + elements.settingsCodexCliDetectButton.textContent = t(state.locale, "settingsCodexCliDetect"); // Version label is locale-independent but lives next to the i18n // settings rows; set it here so a single render pass paints both. // `__CODEX_APP_VERSION__` is injected by Vite from `package.json` so diff --git a/src-tauri/shared/front/tauri.ts b/src-tauri/shared/front/tauri.ts index d62675e..1ff0af9 100644 --- a/src-tauri/shared/front/tauri.ts +++ b/src-tauri/shared/front/tauri.ts @@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import type { ActionResponse, + CodexCliRedetectResult, CodexCliStatus, CommandError, CurrentCard, @@ -334,6 +335,15 @@ async function invokeCommand(command: string, args?: Record) source: command === "set_codex_cli_path" ? "user_override" : "discovery", suggested_paths: ["/preview/codex", "/preview/usr/local/bin/codex"], }) as Promise; + case "redetect_codex_cli_path": + return Promise.resolve({ + candidates: [{ path: "/preview/codex", version: "codex-cli 0.133.0" }], + status: { + resolved_path: "/preview/codex", + source: "user_override", + suggested_paths: ["/preview/codex", "/preview/usr/local/bin/codex"], + }, + }) as Promise; case "cancel_codex_login": return Promise.resolve(true) as Promise; case "open_profile_folder": @@ -464,6 +474,10 @@ export function clearCodexCliPath(): Promise { return invokeCommand("clear_codex_cli_path"); } +export function redetectCodexCliPath(): Promise { + return invokeCommand("redetect_codex_cli_path"); +} + export function cancelCodexLogin(): Promise { return invokeCommand("cancel_codex_login"); } diff --git a/src-tauri/shared/front/types.ts b/src-tauri/shared/front/types.ts index 17c52e0..5314c1f 100644 --- a/src-tauri/shared/front/types.ts +++ b/src-tauri/shared/front/types.ts @@ -98,4 +98,18 @@ export interface CodexCliStatus { suggested_paths: string[]; } +export interface CodexCliCandidate { + /** Absolute path to the verified-runnable codex binary. */ + path: string; + /** `codex --version` line (e.g. "codex-cli 0.133.0"), or null if it ran but printed nothing parseable. */ + version: string | null; +} + +export interface CodexCliRedetectResult { + /** Candidates verified runnable by the forced scan, deduped, best-first. */ + candidates: CodexCliCandidate[]; + /** Refreshed status snapshot so the Settings row can update in step. */ + status: CodexCliStatus; +} + export type ShellRoute = "dashboard" | "profiles" | "settings" | "guide"; diff --git a/src-tauri/shared/runtime/codex_cli_path.rs b/src-tauri/shared/runtime/codex_cli_path.rs index 91a697c..8986abd 100644 --- a/src-tauri/shared/runtime/codex_cli_path.rs +++ b/src-tauri/shared/runtime/codex_cli_path.rs @@ -1,6 +1,7 @@ -//! Shared `InstallState` schema + `CodexPathResolver` trait + the four +//! Shared `InstallState` schema + `CodexPathResolver` trait + the //! Tauri-command helpers (`get_codex_cli_status` / `set_codex_cli_path` -//! / `clear_codex_cli_path` / `build_codex_cli_status`). +//! / `clear_codex_cli_path` / `redetect_codex_cli_path` / +//! `build_codex_cli_status`). //! //! Before this module each platform (`mac/runtime/process.rs` + //! `mac/runtime/profile_actions.rs` and the Windows mirrors) carried @@ -12,12 +13,16 @@ //! managed-shim filtering — are kept per-platform and reached through //! the `CodexPathResolver` trait so this shared layer is OS-agnostic. +use std::io::Read; use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; use serde::{Deserialize, Serialize}; use crate::errors::AppResult; -use crate::models::CodexCliStatus; +use crate::models::{CodexCliCandidate, CodexCliRedetectResult, CodexCliStatus}; /// Persistent install metadata. Both mac and Windows used to declare /// this struct independently; consolidating here keeps the on-disk @@ -77,6 +82,14 @@ pub trait CodexPathResolver { /// Common install locations that exist on disk right now. Frontend /// renders these as click-to-fill chips in the dialog. fn suggested_paths(&self, codex_home: &Path) -> Vec; + + /// Force a fresh scan that ignores the cached/override path and + /// returns every candidate verified runnable via `codex --version` + /// (deduped, best-first, with the version string captured). Backs the + /// Settings "auto-detect" button: `resolve_with_source` trusts a + /// previously-saved path, so when that path is wrong — or there are + /// several installs — the user needs this to rescan from scratch. + fn redetect_runnable_paths(&self, codex_home: &Path) -> Vec; } /// Build the snapshot the front-end consumes. Used by both @@ -129,6 +142,125 @@ pub fn clear_codex_cli_path( build_codex_cli_status(resolver, codex_home) } +/// Force a fresh detection scan and report which candidates are +/// runnable, alongside a refreshed status snapshot. Backs the Settings +/// "auto-detect" button — the front-end auto-applies a lone candidate +/// and lets the user pick when several survive the probe. +pub fn redetect_codex_cli_path( + resolver: &dyn CodexPathResolver, + codex_home: &Path, +) -> CodexCliRedetectResult { + CodexCliRedetectResult { + candidates: resolver.redetect_runnable_paths(codex_home), + status: build_codex_cli_status(resolver, codex_home), + } +} + +/// Run a configured `codex --version` `command`, bounded by `timeout`, +/// and return its trimmed first stdout line on a successful exit. This is +/// the one-shot "is this a real, runnable codex, and which version" probe +/// behind auto-detect: `Some(version)` means it ran and exited 0 (the +/// string may be empty if it printed nothing parseable); `None` means it +/// couldn't spawn, exited non-zero, errored, or overran the timeout (the +/// child is then killed so a hung / input-waiting binary can't wedge the +/// scan). Shared so mac + Windows reuse the poll/kill/capture logic; each +/// platform only builds the `Command` (console hiding, extension res). +pub fn probe_version_with_timeout(command: Command, timeout: Duration) -> Option { + run_capturing_stdout_with_timeout(command, timeout).map(|stdout| { + stdout + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .unwrap_or("") + .to_owned() + }) +} + +/// After the probed child exits, how long to wait for its stdout to finish +/// draining before giving up on capturing it. A child that left a +/// background process holding the pipe never yields EOF, so this bounds +/// that case instead of blocking forever. +const STDOUT_DRAIN_GRACE: Duration = Duration::from_secs(1); + +/// Run `command` bounded by `timeout`, draining stdout on a reader thread +/// so a chatty child can't backpressure its own pipe and deadlock. Returns +/// the full captured stdout on a successful (exit-0) run; `None` if it +/// couldn't spawn, exited non-zero, errored, or overran the timeout (the +/// child is then killed). Shared by the `codex --version` probe (caller +/// takes the first line = version) and the login-shell resolver (caller +/// takes the last line = path), so the poll / kill / drain logic — and the +/// hard timeout that keeps an arbitrary user login shell from wedging the +/// scan — lives in exactly one place. +pub fn run_capturing_stdout_with_timeout( + mut command: Command, + timeout: Duration, +) -> Option { + command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + let mut child = match command.spawn() { + Ok(child) => child, + Err(error) => { + eprintln!("run_capturing_stdout_with_timeout: spawn failed: {error}"); + return None; + } + }; + // Drain stdout on a thread that posts to a channel — then wait with a + // bounded recv_timeout, NOT an unbounded join. Two hazards this guards: + // (1) a child that writes more than the pipe buffer (~64 KB) before + // exiting would block on write() while we poll try_wait; (2) a child + // that exits but leaves a background process holding the stdout pipe + // never yields EOF, so read_to_string — and an unbounded join() after + // it — would block forever and defeat the timeout. We abandon the + // (detached) reader after STDOUT_DRAIN_GRACE in that case. + let (tx, rx) = std::sync::mpsc::channel(); + let has_reader = child + .stdout + .take() + .map(|mut stdout| { + thread::spawn(move || { + let mut buf = String::new(); + let _ = stdout.read_to_string(&mut buf); + let _ = tx.send(buf); + }); + }) + .is_some(); + let deadline = Instant::now() + timeout; + loop { + match child.try_wait() { + Ok(Some(status)) => { + if !status.success() { + return None; + } + let stdout = if has_reader { + rx.recv_timeout(STDOUT_DRAIN_GRACE).unwrap_or_default() + } else { + String::new() + }; + return Some(stdout); + } + Ok(None) => { + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + thread::sleep(Duration::from_millis(20)); + } + Err(error) => { + // A real try_wait failure (EINTR, ECHILD, a Windows handle + // error) is indistinguishable from a clean timeout to the + // caller — both return None — so leave a diagnostic trail. + eprintln!("run_capturing_stdout_with_timeout: try_wait failed: {error}"); + let _ = child.kill(); + let _ = child.wait(); + return None; + } + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -148,6 +280,10 @@ mod tests { // return Err(that AppError) to test ? propagation. set_error: RefCell>, suggestions: Vec, + // What `redetect_runnable_paths` returns — the "verified + // runnable" candidates, independent of `suggestions`. RefCell so + // a test can vary it (empty / multiple) per case. + runnable: RefCell>, clear_calls: RefCell, } @@ -157,6 +293,10 @@ mod tests { state: RefCell::new(None), set_error: RefCell::new(None), suggestions: vec![PathBuf::from("/fake/suggested/codex")], + runnable: RefCell::new(vec![CodexCliCandidate { + path: "/fake/runnable/codex".to_string(), + version: Some("codex-cli 1.2.3".to_string()), + }]), clear_calls: RefCell::new(0), } } @@ -188,6 +328,10 @@ mod tests { fn suggested_paths(&self, _codex_home: &Path) -> Vec { self.suggestions.clone() } + + fn redetect_runnable_paths(&self, _codex_home: &Path) -> Vec { + self.runnable.borrow().clone() + } } #[test] @@ -256,4 +400,135 @@ mod tests { ); assert_eq!(status.source, "discovery"); } + + #[test] + fn redetect_returns_runnable_candidates_plus_refreshed_status() { + let resolver = FakeResolver::new(); + let codex_home = PathBuf::from("/fake/home"); + // Seed auto-discovery so the bundled status snapshot is non-empty. + *resolver.state.borrow_mut() = Some(( + PathBuf::from("/fake/discovered/codex"), + RealCodexPathSource::Discovery, + )); + + let result = redetect_codex_cli_path(&resolver, &codex_home); + + // Candidates come straight from the resolver's runnable probe, + // not from the suggestion list — version included. + assert_eq!( + result.candidates, + vec![CodexCliCandidate { + path: "/fake/runnable/codex".to_string(), + version: Some("codex-cli 1.2.3".to_string()), + }] + ); + // The bundled status is rebuilt live, so the Settings row can + // refresh from the same call. + assert_eq!( + result.status.resolved_path.as_deref(), + Some("/fake/discovered/codex") + ); + assert_eq!(result.status.source, "discovery"); + } + + #[test] + fn redetect_with_no_runnable_candidates_returns_empty_plus_status() { + let resolver = FakeResolver::new(); + let codex_home = PathBuf::from("/fake/home"); + // Nothing survives the runnable probe... + *resolver.runnable.borrow_mut() = vec![]; + // ...but auto-discovery still resolves a (stale) path, so the + // bundled status must stay populated for the Settings row. + *resolver.state.borrow_mut() = Some(( + PathBuf::from("/fake/stale/codex"), + RealCodexPathSource::Discovery, + )); + + let result = redetect_codex_cli_path(&resolver, &codex_home); + + assert!(result.candidates.is_empty()); + assert_eq!( + result.status.resolved_path.as_deref(), + Some("/fake/stale/codex") + ); + } + + #[test] + fn redetect_preserves_multiple_candidates_in_order() { + let resolver = FakeResolver::new(); + let codex_home = PathBuf::from("/fake/home"); + *resolver.runnable.borrow_mut() = vec![ + CodexCliCandidate { + path: "/fake/first/codex".to_string(), + version: Some("codex-cli 0.133.0".to_string()), + }, + CodexCliCandidate { + path: "/fake/second/codex".to_string(), + version: None, + }, + ]; + + let result = redetect_codex_cli_path(&resolver, &codex_home); + + // Order is the contract the front-end relies on (it prefills [0]). + let paths: Vec<&str> = result.candidates.iter().map(|c| c.path.as_str()).collect(); + assert_eq!(paths, vec!["/fake/first/codex", "/fake/second/codex"]); + // Version rides along per candidate (one known, one unparsed). + assert_eq!( + result.candidates[0].version.as_deref(), + Some("codex-cli 0.133.0") + ); + assert_eq!(result.candidates[1].version, None); + } + + #[cfg(unix)] + #[test] + fn probe_version_with_timeout_captures_version_on_success() { + // Absolute path + shell builtins so a sibling test that mutates + // PATH (the mac/win `discover_*` tests do) can't make these spawns + // fail with NotFound. `echo` / `exit` are builtins — no PATH lookup. + let mut ok = Command::new("/bin/sh"); + ok.args(["-c", "echo codex-cli 9.9.9"]); + assert_eq!( + probe_version_with_timeout(ok, Duration::from_secs(5)).as_deref(), + Some("codex-cli 9.9.9") + ); + + // A clean non-zero exit → None (never a captured string): redetect + // relies on this to reject "ran but failed" candidates. + let mut bad = Command::new("/bin/sh"); + bad.args(["-c", "exit 3"]); + assert_eq!(probe_version_with_timeout(bad, Duration::from_secs(5)), None); + } + + #[cfg(unix)] + #[test] + fn probe_version_with_timeout_kills_and_returns_none_on_overrun() { + let start = Instant::now(); + // Absolute path (present on macOS + Ubuntu) so a PATH-mutating + // sibling test can't break the spawn. + let mut sleeper = Command::new("/bin/sleep"); + sleeper.arg("30"); + // 200ms budget against a 30s sleep: it must be killed and reported + // as None, and we must not actually block anywhere near 30s. + assert_eq!( + probe_version_with_timeout(sleeper, Duration::from_millis(200)), + None + ); + assert!(start.elapsed() < Duration::from_secs(5)); + } + + #[cfg(unix)] + #[test] + fn run_capturing_does_not_deadlock_on_large_stdout() { + // Emit well over the ~64 KB pipe buffer before exiting. Without the + // reader thread the child would block on write() and we'd hit the + // timeout; with it, this drains and returns well under the budget. + let mut command = Command::new("/bin/sh"); + command.args(["-c", "yes 0123456789abcdef | head -n 20000; echo done"]); + let start = Instant::now(); + let out = run_capturing_stdout_with_timeout(command, Duration::from_secs(5)); + assert!(start.elapsed() < Duration::from_secs(4)); + assert!(out.expect("exit 0 with captured stdout").contains("done")); + } } diff --git a/src-tauri/shared/runtime/models.rs b/src-tauri/shared/runtime/models.rs index 0243193..6fc5431 100644 --- a/src-tauri/shared/runtime/models.rs +++ b/src-tauri/shared/runtime/models.rs @@ -214,6 +214,33 @@ pub struct CodexCliStatus { pub suggested_paths: Vec, } +/// A codex CLI candidate confirmed runnable by the re-detection scan. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CodexCliCandidate { + /// Absolute path to the verified-runnable codex binary. + pub path: String, + /// Version line from `codex --version` (e.g. "codex-cli 0.133.0"), or + /// None if the binary ran successfully but printed nothing parseable. + /// Shown next to the path so the user can tell several installs apart. + pub version: Option, +} + +/// Result of a forced re-detection scan triggered by the Settings +/// "auto-detect" button. Unlike `get_codex_cli_status` (which honours +/// the cached/override path), this rescans from scratch across every +/// known source and keeps only the candidates that pass a +/// `codex --version` runnable probe. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CodexCliRedetectResult { + /// Verified-runnable candidates, deduped and best-first. The + /// front-end auto-applies a lone hit and lets the user pick (with + /// versions shown) when there are several. + pub candidates: Vec, + /// Refreshed status snapshot so the Settings row and the dialog can + /// update in lock-step after the scan. + pub status: CodexCliStatus, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SetCodexCliPathPayload { pub path: String, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 71d3d36..579546b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -59,6 +59,7 @@ pub fn run() { commands::actions::get_codex_cli_status, commands::actions::set_codex_cli_path, commands::actions::clear_codex_cli_path, + commands::actions::redetect_codex_cli_path, commands::actions::cancel_codex_login, commands::switch::switch_profile, ]) diff --git a/src-tauri/win/front/index.html b/src-tauri/win/front/index.html index c4bfe79..9161f6c 100644 --- a/src-tauri/win/front/index.html +++ b/src-tauri/win/front/index.html @@ -170,6 +170,7 @@

Profiles

Codex CLI path

--

+
diff --git a/src-tauri/win/runtime/process.rs b/src-tauri/win/runtime/process.rs index 716bbad..d5315ae 100644 --- a/src-tauri/win/runtime/process.rs +++ b/src-tauri/win/runtime/process.rs @@ -11,6 +11,7 @@ use std::time::Duration; use crate::errors::{AppError, AppResult}; use crate::platform::hooks::PlatformHooks; use crate::shared::codex_app_server::{fetch_account_snapshot, AppServerSnapshot}; +use crate::models::CodexCliCandidate; use crate::shared::codex_cli_path::CodexPathResolver; pub use crate::shared::codex_cli_path::{InstallState, RealCodexPathSource}; use crate::shared::login_cancel::wait_for_login_or_cancel; @@ -511,6 +512,10 @@ impl CodexPathResolver for WindowsCodexPathResolver { fn suggested_paths(&self, codex_home: &Path) -> Vec { suggested_codex_cli_paths(Some(codex_home)) } + + fn redetect_runnable_paths(&self, codex_home: &Path) -> Vec { + redetect_runnable_codex_cli_paths(Some(codex_home)) + } } /// Return common codex CLI install locations on Windows that actually @@ -569,6 +574,61 @@ pub fn suggested_codex_cli_paths(codex_home: Option<&Path>) -> Vec { suggestions } +/// How long a single `codex --version` probe may run before we kill it +/// and treat the candidate as unusable. A little more generous than +/// macOS: a Windows `.cmd` shim plus npm wrapper has a slower cold +/// start, but a healthy codex still answers well under this. +const RUNNABLE_PROBE_TIMEOUT: Duration = Duration::from_secs(5); + +/// Upper bound on how many candidates the auto-detect scan will probe. +/// Each probe spawns a child (up to `RUNNABLE_PROBE_TIMEOUT`), so without +/// a cap a machine with many `where codex` hits could stall the scan. +/// Realistic machines have 1-3 candidates. +const MAX_PROBE_CANDIDATES: usize = 12; + +/// Probe whether `path` is a runnable codex CLI and capture its version. +/// `Some(version)` (possibly empty) means it's a file that ran and exited +/// 0; `None` means not-a-file, couldn't spawn, exited non-zero, or timed +/// out. The failure is logged so a broken install leaves a diagnostic +/// trail instead of looking identical to "not found". +fn probe_codex_version(path: &Path) -> Option { + if !path.is_file() { + return None; + } + let mut command = Command::new(path); + command.arg("--version"); + hide_console_window(&mut command); + let result = + crate::shared::codex_cli_path::probe_version_with_timeout(command, RUNNABLE_PROBE_TIMEOUT); + if result.is_none() { + eprintln!( + "codex probe: {} is not a runnable codex (spawn / non-zero exit / timeout)", + path.display() + ); + } + result +} + +/// Force a fresh scan for the Settings auto-detect button, keeping only +/// candidates that pass the runnable probe. Reuses +/// `suggested_codex_cli_paths`, which already resolves Windows +/// extensions, filters the managed shim / Windows Apps aliases, and +/// folds in `where codex` (every PATH match) — so it is the full +/// candidate set. Ignores the cached/override path so a wrong saved +/// path can be corrected. +pub fn redetect_runnable_codex_cli_paths(codex_home: Option<&Path>) -> Vec { + suggested_codex_cli_paths(codex_home) + .into_iter() + .take(MAX_PROBE_CANDIDATES) + .filter_map(|path| { + probe_codex_version(&path).map(|version| CodexCliCandidate { + path: path.to_string_lossy().into_owned(), + version: (!version.is_empty()).then_some(version), + }) + }) + .collect() +} + pub fn quit_codex_app_if_running() -> AppResult { if !is_codex_app_running() { return Ok(false); @@ -678,8 +738,9 @@ impl PlatformHooks for WindowsPlatformHooks { mod tests { use super::{ build_app_server_command, discover_real_codex_cli_path, is_acceptable_real_codex_cli_path, - load_install_state, resolve_real_codex_cli, resolve_windows_app_target, - windows_store_shell_target, AppLaunchTarget, InstallState, WINDOWS_STORE_APP_ID, + load_install_state, probe_codex_version, resolve_real_codex_cli, + resolve_windows_app_target, windows_store_shell_target, AppLaunchTarget, InstallState, + WINDOWS_STORE_APP_ID, }; use crate::windows::env_guard; use serde_json::to_string_pretty; @@ -695,6 +756,47 @@ mod tests { std::env::temp_dir().join(format!("codex-switch-process-{name}-{unique}")) } + // Runs on the Linux `cargo test --lib` job (the win module compiles on + // non-macOS): a `#!/bin/sh` candidate is spawnable there, and + // `hide_console_window` is a no-op off Windows. Pins the three + // behaviours auto-detect depends on: a non-file is rejected without + // spawning, a binary that runs but exits non-zero is rejected (broken + // install), and only a zero-exit binary is accepted. + #[cfg(unix)] + #[test] + fn probe_codex_version_rejects_missing_and_failing_captures_zero_exit() { + use std::os::unix::fs::PermissionsExt; + + let codex_home = temp_codex_home("probe-runnable"); + fs::create_dir_all(&codex_home).unwrap(); + + // (a) non-file path → None, never spawned. + assert_eq!(probe_codex_version(&codex_home.join("does-not-exist")), None); + + let set_exec = |path: &std::path::Path| { + let mut perm = fs::metadata(path).unwrap().permissions(); + perm.set_mode(0o755); + fs::set_permissions(path, perm).unwrap(); + }; + + // (b) exists + runs but exits non-zero → None (broken install). + let bad = codex_home.join("bad-codex"); + fs::write(&bad, "#!/bin/sh\nexit 3\n").unwrap(); + set_exec(&bad); + assert_eq!(probe_codex_version(&bad), None); + + // (c) exists + exits zero, prints a version → Some(version). + let good = codex_home.join("good-codex"); + fs::write(&good, "#!/bin/sh\necho codex-cli 0.133.0\n").unwrap(); + set_exec(&good); + assert_eq!( + probe_codex_version(&good).as_deref(), + Some("codex-cli 0.133.0") + ); + + let _ = fs::remove_dir_all(&codex_home); + } + #[test] fn discover_real_codex_cli_path_prefers_cmd_and_skips_managed_shim() { let _guard = env_guard();