diff --git a/CHANGELOG.md b/CHANGELOG.md index 696b015..4b98382 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- **Critical** — adapted macOS desktop/CLI discovery for the Codex → ChatGPT.app merge. OpenAI now ships Codex inside `/Applications/ChatGPT.app` (bundle id still `com.openai.codex`, process name `ChatGPT`, CLI at `Contents/Resources/codex`). The app previously only probed `Codex.app` and `pgrep -x Codex`, so after the merge: (1) auto-detect / login / app-server fallback reported `REAL_CODEX_NOT_FOUND` even with a healthy install, and (2) profile switch skipped quitting the running desktop host, leaving its in-memory app-server on the old account. Discovery now prefers `ChatGPT.app` over legacy `Codex.app` across both `/Applications` and `~/Applications`, and a bundle only qualifies when it actually embeds the codex CLI — a chat-only consumer ChatGPT.app (`com.openai.chat`) is never selected. is-running / quit key off the stable bundle id, and their process-name fallbacks verify each PID's bundle identity before trusting or signalling it — the consumer ChatGPT chat client is never killed as collateral, and a PID whose identity can't be established still counts as "running" (the switch aborts rather than proceeding over a possibly-live host) but is never signalled; the polite AppleScript quit is fired without waiting (a quit-confirmation dialog or the first-run Automation/TCC consent prompt can no longer hang the switch) and is cancelled once the host is gone so a consent granted later can't close the relaunched app. activate uses the bundle id; open tries the on-disk bundle path, then the bundle id, then the unambiguous legacy `Codex` display name (never `ChatGPT` by name — it could resolve to the consumer chat client), checking each `open`'s exit status so a silent launch failure surfaces as a warning instead of a fake success. macOS-only; Windows Store path still needs a separate pass if the Win host also renamed. - **Critical** — fixed account cross-contamination ("串号") during profile switch. Switching, and the launch-time `sync_root_state_to_current_profile`, used to copy the live `~/.codex` state back into whatever profile the `.current_profile` marker named, with no check that the account actually sitting in `~/.codex/auth.json` is the one that profile holds. If the live account had drifted away from the marker — a manual `codex login` outside the app, the official Codex app re-authing, or hand-edits to `~/.codex` — the next switch (or merely relaunching the app, since bootstrap runs the same write-back) silently overwrote an unrelated profile's stored credentials with the wrong account. Write-back is now gated by an identity check (`resolve_backup_target`): the live account is identified by its `tokens.account_id` and/or id_token `email` — matched on *either*, so a legacy email-only card still matches the same account after a later refresh adds an id — and only saved into the profile that genuinely owns it. A live account that drifted to a *different* managed profile is rerouted to its real owner and the marker is healed; a live account that belongs to no profile is refused rather than blind-copied. apikey / placeholder cards with no resolvable identity keep their previous behavior, so non-OAuth setups are unaffected. macOS + Windows symmetric. - When the live `~/.codex` account belongs to **no saved card** (e.g. a fresh `codex login` outside the app), the launch-time sync now clears the stale current-profile marker instead of leaving a wrong card flagged as "current", and the dashboard shows a one-time prompt naming the unmanaged account so you can switch to — or create — the matching card. diff --git a/macOS-backup/codex-switch.sh b/macOS-backup/codex-switch.sh index 2376eb4..2baba5b 100755 --- a/macOS-backup/codex-switch.sh +++ b/macOS-backup/codex-switch.sh @@ -6,8 +6,26 @@ BACKUP_ROOT="$CODHOME/account_backup" AUTO_SAVE_ROOT="$BACKUP_ROOT/_autosave" CURRENT_PROFILE_FILE="$BACKUP_ROOT/.current_profile" ACTIVE_MARKER_FILE=".active_profile" -APP_NAME="Codex" -APP_PATH="/Applications/Codex.app" +# OpenAI folded Codex into ChatGPT.app (bundle id still com.openai.codex). +# Keep the legacy Codex.app path as a fallback for older installs. A +# candidate only qualifies if it embeds the codex CLI: a directory check +# alone would match a chat-only ChatGPT.app (bundle id com.openai.chat) +# and we would open the consumer chat client instead of the codex host. +APP_BUNDLE_ID="com.openai.codex" +APP_NAME_CHATGPT="ChatGPT" +APP_NAME_CODEX="Codex" +APP_PATH="" +for app_candidate in \ + "/Applications/ChatGPT.app" \ + "$HOME/Applications/ChatGPT.app" \ + "/Applications/Codex.app" \ + "$HOME/Applications/Codex.app"; do + if [[ -f "$app_candidate/Contents/Resources/codex" ]]; then + APP_PATH="$app_candidate" + break + fi +done +unset app_candidate usage() { cat <<'USAGE' @@ -106,37 +124,115 @@ set_active_marker() { echo "$profile" > "$CURRENT_PROFILE_FILE" } +# Echo "pid class" lines for processes matching the known host names. +# class = ours — executable sits in a bundle whose CFBundleIdentifier +# is com.openai.codex (the only class we may signal) +# other — Info.plist positively names a different bundle id +# (e.g. the consumer ChatGPT chat client com.openai.chat, +# whose executable is also named "ChatGPT") +# unknown — identity could not be established (bare/truncated ps +# comm, non-bundle binary, defaults failure). Unknown +# COUNTS for is-running (proceeding with a possibly-live +# host risks account cross-contamination, so the switch +# must abort instead) but is NEVER signalled. +codex_desktop_pid_classes() { + local name pid exe bundle_root bundle_id + for name in "$APP_NAME_CHATGPT" "$APP_NAME_CODEX"; do + for pid in $(pgrep -x "$name" 2>/dev/null || true); do + # macOS ps prints the full executable path for LaunchServices-launched + # apps, but POSIX only guarantees a command *name* — treat bare values + # as unknown, not as "not ours". + exe="$(ps -o comm= -p "$pid" 2>/dev/null || true)" + if [[ "$exe" != *.app/* ]]; then + echo "$pid unknown" + continue + fi + bundle_root="${exe%.app/*}.app" + bundle_id="$(defaults read "$bundle_root/Contents/Info" CFBundleIdentifier 2>/dev/null || true)" + if [[ "$bundle_id" == "$APP_BUNDLE_ID" ]]; then + echo "$pid ours" + elif [[ -n "$bundle_id" ]]; then + echo "$pid other" + else + echo "$pid unknown" + fi + done + done + return 0 +} + +codex_desktop_pids() { + codex_desktop_pid_classes | awk '$2 == "ours" { print $1 }' + return 0 +} + +signal_codex_desktop() { + local signal="$1" pid + for pid in $(codex_desktop_pids); do + kill "$signal" "$pid" >/dev/null 2>&1 || true + done +} + is_codex_app_running() { - pgrep -x "$APP_NAME" >/dev/null 2>&1 + # Bundle-id check is rename-proof, and a definitive "false" from + # osascript is trusted as-is: com.openai.codex is shared by every + # known host bundle, so "not running" is authoritative and probing + # process names after it would only add false positives (a bare name + # match could be the consumer ChatGPT chat client). Identity-verified + # process probes are consulted solely when osascript itself is + # unavailable. Same semantics as the Rust implementation in + # src-tauri/mac/runtime/process.rs. + local answer + answer="$(osascript -e "application id \"$APP_BUNDLE_ID\" is running" 2>/dev/null | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]' || true)" + case "$answer" in + true) return 0 ;; + false) return 1 ;; + esac + # Anything not positively identified as another bundle counts as running: + # missing a live host would let the switch replace auth.json under it. + [[ -n "$(codex_desktop_pid_classes | awk '$2 != "other"')" ]] } quit_codex_app_if_running() { - local attempt + local attempt quit_osa_pid if ! is_codex_app_running; then return 1 fi - # Use process signals to avoid the app's interactive quit confirmation. - pkill -TERM -x "$APP_NAME" >/dev/null 2>&1 || true + # Polite AppleScript quit lets the host flush state, but must NOT be + # waited on: the host can pop an interactive quit-confirmation dialog, + # and the first Apple event may hang on the macOS Automation (TCC) + # consent prompt — either would block the switch for minutes. Run it + # in the background and TERM immediately (by verified PID), as the + # pre-merge script did; the wait loop below provides the grace period. + # Keep the osascript PID: a quit event still queued behind the TCC + # prompt must be cancelled before we relaunch, or it would be + # delivered to — and close — the fresh instance once consent is given. + osascript -e "tell application id \"$APP_BUNDLE_ID\" to quit" >/dev/null 2>&1 & + quit_osa_pid=$! + signal_codex_desktop -TERM for attempt in $(seq 1 20); do if ! is_codex_app_running; then + kill -KILL "$quit_osa_pid" >/dev/null 2>&1 || true return 0 fi sleep 0.2 done - pkill -KILL -x "$APP_NAME" >/dev/null 2>&1 || true + signal_codex_desktop -KILL for attempt in $(seq 1 10); do if ! is_codex_app_running; then + kill -KILL "$quit_osa_pid" >/dev/null 2>&1 || true return 0 fi sleep 0.2 done - echo "Error: $APP_NAME did not exit cleanly. Close it manually and retry." >&2 + kill -KILL "$quit_osa_pid" >/dev/null 2>&1 || true + echo "Error: Codex/ChatGPT did not exit cleanly. Close it manually and retry." >&2 exit 1 } @@ -144,7 +240,19 @@ reopen_codex_app_if_needed() { local app_was_running="$1" if [[ "$app_was_running" -eq 1 ]]; then - open -a "$APP_PATH" >/dev/null 2>&1 || open -a "$APP_NAME" >/dev/null 2>&1 + if [[ -n "$APP_PATH" ]]; then + open -a "$APP_PATH" >/dev/null 2>&1 && return 0 + fi + # "ChatGPT" by display name is deliberately NOT tried: LaunchServices + # could resolve it to the consumer chat client (com.openai.chat); + # "Codex" only resolves to codex-host bundles (alternate name). + # The trailing warning also keeps the chain's overall status zero — + # under `set -e` a fully failed open chain would otherwise abort the + # script here, AFTER the switch already happened but BEFORE the + # success output, making a completed switch look like a failure. + open -b "$APP_BUNDLE_ID" >/dev/null 2>&1 \ + || open -a "$APP_NAME_CODEX" >/dev/null 2>&1 \ + || echo "Warning: profile switch completed, but Codex/ChatGPT could not be relaunched — open it manually." >&2 fi } diff --git a/src-tauri/examples/live_chatgpt_merge_check.rs b/src-tauri/examples/live_chatgpt_merge_check.rs new file mode 100644 index 0000000..5a1431e --- /dev/null +++ b/src-tauri/examples/live_chatgpt_merge_check.rs @@ -0,0 +1,236 @@ +// Live e2e probe for the Codex → ChatGPT.app merge adaptation. +// +// Run while `npm run tauri:dev` is up (or independently): +// cargo run --manifest-path src-tauri/Cargo.toml --example live_chatgpt_merge_check +// +// Does NOT quit/relaunch the desktop host and does not print tokens. + +use std::fs; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[cfg(target_os = "macos")] +use codex_switch_lib::macos::process::{ + is_codex_app_running, redetect_runnable_codex_cli_paths, suggested_codex_cli_paths, + MACOS_CODEX_PATH_RESOLVER, +}; +#[cfg(target_os = "macos")] +use codex_switch_lib::shared::codex_cli_path::{ + build_codex_cli_status, redetect_codex_cli_path, +}; +#[cfg(target_os = "macos")] +use codex_switch_lib::shared::paths::get_codex_home; + +fn main() { + #[cfg(not(target_os = "macos"))] + { + eprintln!("live_chatgpt_merge_check is macOS-only"); + std::process::exit(2); + } + + #[cfg(target_os = "macos")] + { + let mut failures = 0usize; + let mut skipped = 0usize; + let codex_home = get_codex_home(); + println!("== live ChatGPT merge e2e =="); + println!("codex_home: {}", codex_home.display()); + + // 1) Desktop host process detection (ChatGPT host, not Codex process name). + // Keep spawn failures distinct from "not running": collapsing them + // into `false` would silently disarm the cross-check below and let + // the probe report green while it verified nothing. + let probe_pgrep = |name: &str| -> Result { + Command::new("pgrep") + .args(["-x", name]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .map_err(|error| error.to_string()) + }; + let running = is_codex_app_running(); + let pgrep_chatgpt = probe_pgrep("ChatGPT"); + let pgrep_codex = probe_pgrep("Codex"); + println!( + "\n[1] is_codex_app_running={running} (pgrep ChatGPT={pgrep_chatgpt:?}, pgrep Codex={pgrep_codex:?})" + ); + match (&pgrep_chatgpt, running) { + (Err(error), _) => { + eprintln!( + "SKIP: pgrep unavailable ({error}) — process-table cross-check is blind this run" + ); + skipped += 1; + } + (Ok(true), false) => { + eprintln!("FAIL: ChatGPT process is up but is_codex_app_running returned false"); + failures += 1; + } + (Ok(_), true) => println!("PASS: desktop host detected"), + (Ok(false), false) => { + println!("WARN: ChatGPT not running — detection path not fully exercised"); + } + } + + // 2) Suggested paths must include the ChatGPT-bundled CLI when present. + let suggestions = suggested_codex_cli_paths(Some(&codex_home)); + println!("\n[2] suggested_codex_cli_paths ({}):", suggestions.len()); + for path in &suggestions { + println!(" - {}", path.display()); + } + let chatgpt_cli = PathBuf::from("/Applications/ChatGPT.app/Contents/Resources/codex"); + if chatgpt_cli.is_file() { + if suggestions.iter().any(|p| p == &chatgpt_cli) { + println!("PASS: ChatGPT.app CLI is in suggestions"); + } else { + eprintln!("FAIL: ChatGPT.app CLI exists but is missing from suggestions"); + failures += 1; + } + } else { + println!("WARN: /Applications/ChatGPT.app CLI not on disk"); + } + + // 3) Redetect must verify the ChatGPT CLI is runnable and capture a version. + let redetect = redetect_runnable_codex_cli_paths(Some(&codex_home)); + println!("\n[3] redetect_runnable_codex_cli_paths ({}):", redetect.len()); + for candidate in &redetect { + println!( + " - {} ({})", + candidate.path, + candidate.version.as_deref().unwrap_or("") + ); + } + if chatgpt_cli.is_file() { + match redetect.iter().find(|c| c.path == chatgpt_cli.to_string_lossy()) { + Some(hit) if hit.version.as_deref().unwrap_or("").contains("codex") => { + println!("PASS: ChatGPT CLI probed runnable: {:?}", hit.version); + } + Some(hit) => { + eprintln!( + "FAIL: ChatGPT CLI found but version look wrong: {:?}", + hit.version + ); + failures += 1; + } + None => { + eprintln!("FAIL: ChatGPT CLI not returned by redetect probe"); + failures += 1; + } + } + } + + // 4) Stale user override (old Codex.app path) must fall through to discovery. + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let probe_home = std::env::temp_dir().join(format!("codex-switch-e2e-merge-{unique}")); + let runtime_dir = probe_home.join("account_backup").join("macos"); + fs::create_dir_all(&runtime_dir).expect("create probe runtime dir"); + let install_state = runtime_dir.join("install_state.json"); + fs::write( + &install_state, + r#"{ + "real_codex_path": null, + "path_added_by_installer": false, + "user_codex_path": "/Applications/Codex.app/Contents/Resources/codex" +} +"#, + ) + .expect("write stale install_state"); + + let status = build_codex_cli_status(&MACOS_CODEX_PATH_RESOLVER, &probe_home); + println!("\n[4] status with stale Codex.app user_override:"); + println!(" resolved_path = {:?}", status.resolved_path); + println!(" source = {}", status.source); + println!(" suggested = {:?}", status.suggested_paths); + + if chatgpt_cli.is_file() { + match status.resolved_path.as_deref() { + Some(path) if path == chatgpt_cli.to_string_lossy() => { + println!("PASS: stale override fell through to ChatGPT.app CLI"); + } + Some(path) if path.contains("ChatGPT.app") => { + println!("PASS: stale override fell through to a ChatGPT host CLI ({path})"); + } + Some(path) => { + // Discovery may still prefer a PATH/npm hit if present; require runnable. + if PathBuf::from(path).is_file() { + println!("PASS: stale override fell through to runnable path {path}"); + } else { + eprintln!("FAIL: resolved non-file path after stale override: {path}"); + failures += 1; + } + } + None => { + eprintln!("FAIL: resolver returned None despite ChatGPT.app CLI on disk"); + failures += 1; + } + } + if status.source == "user_override" { + eprintln!("FAIL: resolver still claims user_override for a missing file"); + failures += 1; + } + } + + // 5) Shared redetect wrapper (what Settings auto-detect button calls). + let redetect_result = redetect_codex_cli_path(&MACOS_CODEX_PATH_RESOLVER, &probe_home); + println!( + "\n[5] redetect_codex_cli_path candidates={} status.source={}", + redetect_result.candidates.len(), + redetect_result.status.source + ); + if redetect_result.candidates.is_empty() && chatgpt_cli.is_file() { + eprintln!("FAIL: Settings redetect returned zero candidates with ChatGPT CLI present"); + failures += 1; + } else if !redetect_result.candidates.is_empty() { + println!( + "PASS: Settings redetect first candidate = {} ({:?})", + redetect_result.candidates[0].path, redetect_result.candidates[0].version + ); + } + + // 6) Confirm the real CLI answers --version (same binary login/app-server use). + if let Some(resolved) = status.resolved_path.as_ref().map(PathBuf::from) { + let output = Command::new(&resolved).arg("--version").output(); + match output { + Ok(out) if out.status.success() => { + let version = String::from_utf8_lossy(&out.stdout).trim().to_string(); + println!("\n[6] `{resolved:?} --version` => {version}"); + if version.is_empty() { + eprintln!("FAIL: empty version string"); + failures += 1; + } else { + println!("PASS: resolved CLI is executable"); + } + } + Ok(out) => { + eprintln!( + "FAIL: --version exited {:?}: {}", + out.status.code(), + String::from_utf8_lossy(&out.stderr) + ); + failures += 1; + } + Err(error) => { + eprintln!("FAIL: failed to spawn resolved CLI: {error}"); + failures += 1; + } + } + } + + if let Err(error) = fs::remove_dir_all(&probe_home) { + eprintln!( + "WARN: failed to clean probe dir {}: {error}", + probe_home.display() + ); + } + + println!("\n== summary: {failures} failure(s), {skipped} skipped check(s) =="); + if failures > 0 { + std::process::exit(1); + } + } +} diff --git a/src-tauri/mac/runtime/process.rs b/src-tauri/mac/runtime/process.rs index ece196d..2d23cd8 100644 --- a/src-tauri/mac/runtime/process.rs +++ b/src-tauri/mac/runtime/process.rs @@ -16,7 +16,24 @@ use crate::shared::login_cancel::wait_for_login_or_cancel; use super::cli_shim::{get_install_state_file, managed_shim_path, real_codex_resolver_path}; -const APP_NAME: &str = "Codex"; +/// Historical standalone app name. Still works as a LaunchServices +/// alternate name for the merged ChatGPT desktop bundle, and remains a +/// process-name fallback for older installs that still ship as `Codex.app`. +const APP_NAME_CODEX: &str = "Codex"; +/// Post-merge desktop host process name. OpenAI folded Codex into the +/// ChatGPT macOS app (bundle display name `ChatGPT`, executable +/// `ChatGPT`); exact-name `pgrep -x Codex` no longer sees the running UI. +const APP_NAME_CHATGPT: &str = "ChatGPT"; +/// Stable bundle id shared by the old Codex.app and the current ChatGPT +/// host (`CFBundleIdentifier = com.openai.codex`). Prefer this over the +/// display name for open / activate / quit / is-running checks so a +/// future rename of the dock label does not break switch lifecycle. +const APP_BUNDLE_ID: &str = "com.openai.codex"; +/// App bundle basenames that may host the real `codex` CLI under +/// `Contents/Resources/codex`. Order is preference for discovery: the +/// current ChatGPT host first, then the legacy standalone Codex.app so +/// older machines keep working until they upgrade. +const APP_BUNDLE_NAMES: [&str; 2] = ["ChatGPT.app", "Codex.app"]; static MACOS_PLATFORM_HOOKS: MacosPlatformHooks = MacosPlatformHooks; static MACOS_APP_PATH_CACHE: OnceLock> = OnceLock::new(); @@ -261,10 +278,11 @@ impl CodexPathResolver for MacosCodexPathResolver { /// Soft cap on how long the PATH walk can spend stat'ing entries /// before we bail and return what we have. Each `is_file` probe blocks /// the Tauri command thread; an NFS / SMB entry on PATH can stall for -/// seconds. Fixed locations (Codex.app bundle, Homebrew, /usr/local, -/// npm-global / bun / volta) are checked first because they're -/// guaranteed-fast local stats — by the time we hit the bounded PATH -/// walk we've usually already collected the realistic candidates. +/// seconds. Fixed locations (ChatGPT.app / Codex.app bundle, Homebrew, +/// /usr/local, npm-global / bun / volta) are checked first because +/// they're guaranteed-fast local stats — by the time we hit the +/// bounded PATH walk we've usually already collected the realistic +/// candidates. const PATH_PROBE_DEADLINE: Duration = Duration::from_millis(500); pub fn suggested_codex_cli_paths(codex_home: Option<&Path>) -> Vec { @@ -383,10 +401,10 @@ fn discover_codex_via_login_shell(managed_shim_path: Option<&Path>) -> Option) -> Vec { let managed_shim = codex_home.map(managed_shim_path); let mut candidates: Vec = Vec::new(); @@ -397,8 +415,8 @@ pub fn redetect_runnable_codex_cli_paths(codex_home: Option<&Path>) -> Vec) -> Vec Vec { - let mut candidates = vec![PathBuf::from("/Applications/Codex.app")]; - if let Some(home) = env::var_os("HOME") { - candidates.push(PathBuf::from(home).join("Applications").join("Codex.app")); + // Name-major order: the current ChatGPT host wins over a legacy + // Codex.app regardless of which Applications folder each lives in + // (a stale system-level Codex.app must not shadow a user-level + // ChatGPT.app). Matches the shell twin in macOS-backup/codex-switch.sh. + let home_apps = env::var_os("HOME").map(|home| PathBuf::from(home).join("Applications")); + let mut candidates = Vec::with_capacity(APP_BUNDLE_NAMES.len() * 2); + for bundle_name in APP_BUNDLE_NAMES { + candidates.push(PathBuf::from("/Applications").join(bundle_name)); + if let Some(home_apps) = &home_apps { + candidates.push(home_apps.join(bundle_name)); + } } candidates } fn resolve_codex_app_path() -> Option { + // A bundle only qualifies if it embeds the codex CLI: `is_dir()` + // alone would match a chat-only ChatGPT.app (bundle id + // com.openai.chat), and open/reopen would then launch the consumer + // chat client instead of the codex host — cached for the whole + // process lifetime by the OnceLock below. MACOS_APP_PATH_CACHE .get_or_init(|| { codex_app_candidates() .into_iter() - .find(|path| path.is_dir()) + .find(|path| codex_cli_from_app_bundle(path).is_file()) .map(|path| path.to_string_lossy().into_owned()) }) .clone() } +/// Process names that may own the Codex desktop UI after the ChatGPT +/// merge. Order only matters for probe latency (the current host name +/// is listed first so modern installs match on the first `pgrep`); +/// signalling resolves concrete PIDs and treats every name equally. +fn desktop_app_process_names() -> &'static [&'static str] { + &[APP_NAME_CHATGPT, APP_NAME_CODEX] +} + +/// Bundle identity of a name-matched PID. `Other` is the only verdict +/// that positively rules a process out (its Info.plist names a +/// different bundle id). `Unknown` — bare/truncated `ps` comm, +/// non-bundle binary, `defaults` failure — is treated asymmetrically: +/// it COUNTS for is-running (proceeding with a possibly-live host risks +/// account cross-contamination, so the switch must abort instead), but +/// it is NEVER signalled (we do not kill what we cannot identify). +#[derive(PartialEq)] +enum PidBundleIdentity { + Ours, + Other, + Unknown, +} + +/// Name-matched desktop host PIDs with their bundle classification. +/// The name match alone is not enough: the consumer ChatGPT chat +/// client (`com.openai.chat`) also ships an executable named +/// `ChatGPT`, and trusting bare names would count — and later kill — +/// it as collateral. +fn desktop_app_pid_classifications() -> Vec<(u32, PidBundleIdentity)> { + let mut classified: Vec<(u32, PidBundleIdentity)> = Vec::new(); + for name in desktop_app_process_names() { + let output = match Command::new("pgrep") + .args(["-x", name]) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output() + { + Ok(output) if output.status.success() => output, + // Non-zero exit: no process by that name. Spawn error: pgrep + // unavailable — nothing to enumerate either way. + _ => continue, + }; + for line in String::from_utf8_lossy(&output.stdout).lines() { + let Ok(pid) = line.trim().parse::() else { + continue; + }; + if !classified.iter().any(|(existing, _)| *existing == pid) { + classified.push((pid, classify_pid_bundle(pid))); + } + } + } + classified +} + +/// PIDs verified to belong to a `com.openai.codex` bundle — the only +/// ones the signal path may touch. +fn codex_desktop_pids() -> Vec { + desktop_app_pid_classifications() + .into_iter() + .filter(|(_, identity)| *identity == PidBundleIdentity::Ours) + .map(|(pid, _)| pid) + .collect() +} + +fn classify_pid_bundle(pid: u32) -> PidBundleIdentity { + // On macOS `ps -o comm=` prints the full executable path for + // LaunchServices-launched apps (verified live), but POSIX only + // guarantees a command *name* — a bare/truncated value must map to + // Unknown, not Other, or a running host would be silently missed. + let output = match Command::new("ps") + .args(["-o", "comm=", "-p", &pid.to_string()]) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output() + { + Ok(output) if output.status.success() => output, + _ => return PidBundleIdentity::Unknown, + }; + let stdout = String::from_utf8_lossy(&output.stdout); + let Some(bundle_root) = bundle_root_from_executable_path(Path::new(stdout.trim())) else { + return PidBundleIdentity::Unknown; + }; + match bundle_identifier(&bundle_root) { + Some(id) if id == APP_BUNDLE_ID => PidBundleIdentity::Ours, + Some(_) => PidBundleIdentity::Other, + None => PidBundleIdentity::Unknown, + } +} + +/// Walk up from an executable path to the nearest enclosing `.app` +/// bundle root (`/Applications/ChatGPT.app/Contents/MacOS/ChatGPT` → +/// `/Applications/ChatGPT.app`). Returns `None` for non-bundle binaries. +fn bundle_root_from_executable_path(executable: &Path) -> Option { + executable + .ancestors() + .skip(1) + .find(|ancestor| { + ancestor + .extension() + .map(|extension| extension.eq_ignore_ascii_case("app")) + .unwrap_or(false) + }) + .map(Path::to_path_buf) +} + +fn bundle_identifier(bundle_root: &Path) -> Option { + // `defaults read` handles both XML and binary Info.plist shapes. + let output = Command::new("defaults") + .arg("read") + .arg(bundle_root.join("Contents").join("Info")) + .arg("CFBundleIdentifier") + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let id = String::from_utf8_lossy(&output.stdout).trim().to_string(); + (!id.is_empty()).then_some(id) +} + +/// Parse the stdout of +/// `osascript -e 'application id "com.openai.codex" is running'`. +/// Extracted so unit tests can cover the true/false/empty shapes without +/// spawning AppleScript. +fn parse_osascript_is_running(stdout: &str) -> Option { + match stdout.trim().to_ascii_lowercase().as_str() { + "true" => Some(true), + "false" => Some(false), + _ => None, + } +} + +/// One-shot marker so a persistent osascript failure (TCC denial, MDM +/// policy, headless session) is recorded once instead of spamming every +/// is-running poll — without it the degradation to process-name probes +/// is invisible when diagnosing quit/switch misbehaviour. +static OSASCRIPT_PROBE_DEGRADED_WARNING: OnceLock<()> = OnceLock::new(); + +fn warn_osascript_probe_degraded(detail: &str) { + OSASCRIPT_PROBE_DEGRADED_WARNING.get_or_init(|| { + eprintln!( + "codex_switch: bundle-id is-running probe unavailable, \ + falling back to process-name checks: {detail}" + ); + }); +} + +fn is_codex_app_running_via_bundle_id() -> Option { + let output = match Command::new("osascript") + .args(["-e", &format!("application id \"{APP_BUNDLE_ID}\" is running")]) + .stdin(Stdio::null()) + .output() + { + Ok(output) => output, + Err(error) => { + warn_osascript_probe_degraded(&format!("failed to spawn osascript: {error}")); + return None; + } + }; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + warn_osascript_probe_degraded(&format!( + "osascript exited {:?}: {}", + output.status.code(), + stderr.trim().chars().take(200).collect::() + )); + return None; + } + parse_osascript_is_running(&String::from_utf8_lossy(&output.stdout)) +} + +fn is_codex_app_running_via_process_name() -> bool { + // Anything not POSITIVELY identified as another bundle counts as + // running: missing a live host here would let the switch overwrite + // auth.json under a host whose in-memory app-server still holds the + // old account. Unknown PIDs therefore abort the switch (via the + // quit timeout) rather than being ignored — or killed. + desktop_app_pid_classifications() + .iter() + .any(|(_, identity)| *identity != PidBundleIdentity::Other) +} + pub fn is_codex_app_running() -> bool { - Command::new("pgrep") - .args(["-x", APP_NAME]) - .status() - .map(|status| status.success()) - .unwrap_or(false) + // Bundle-id check is rename-proof (works for both ChatGPT and Codex + // dock labels). A definitive `false` from osascript is trusted as-is: + // com.openai.codex is shared by every known host bundle, so "not + // running" is authoritative and falling through to process names + // would only add false positives. Identity-verified process probes + // are consulted solely when the AppleScript answer is unavailable + // (spawn failure, TCC denial, headless CI). The shell twin in + // macOS-backup/codex-switch.sh follows the same semantics. + is_codex_app_running_via_bundle_id().unwrap_or_else(is_codex_app_running_via_process_name) } fn activate_running_app() -> AppResult<()> { - let script = format!("tell application \"{APP_NAME}\" to activate"); + let script = format!("tell application id \"{APP_BUNDLE_ID}\" to activate"); let status = Command::new("osascript") .args(["-e", &script]) .status() @@ -469,24 +687,58 @@ fn activate_running_app() -> AppResult<()> { pub fn open_or_activate_codex_app(_codex_home: Option<&Path>) -> AppResult { if is_codex_app_running() { if activate_running_app().is_ok() { - return Ok(APP_NAME.to_string()); + return Ok(APP_BUNDLE_ID.to_string()); } } - let mut command = Command::new("open"); + // Prefer the concrete bundle path when we can see it on disk — this + // is the only way to disambiguate ChatGPT.app vs a leftover Codex.app + // without consulting LaunchServices. Fall back to the stable bundle + // id (works even when the app was moved / renamed on disk), then to + // the legacy `Codex` display name as a last resort — same chain as + // the shell twin in macOS-backup/codex-switch.sh. `ChatGPT` by name + // is deliberately NOT tried: LaunchServices could resolve it to the + // consumer chat client (com.openai.chat) and we would report + // "opened Codex" while launching an unrelated app; `Codex` only + // resolves to codex-host bundles (their CFBundleAlternateNames). + // Each `open` is checked for its exit status: `open` reports + // "application not found" *after* a successful spawn, so + // fire-and-forget would tell the caller (and the post-switch reopen + // warning channel) that the app launched when nothing did. + let mut attempts: Vec<(String, Vec)> = Vec::new(); if let Some(app_path) = resolve_codex_app_path() { - command.args(["-a", &app_path]); - command.spawn().map_err(|error| { - AppError::new("APP_OPEN_FAILED", format!("Failed to open Codex: {error}")) - })?; - return Ok(app_path); + attempts.push((app_path.clone(), vec!["-a".to_string(), app_path])); } - - command.args(["-a", APP_NAME]); - command.spawn().map_err(|error| { - AppError::new("APP_OPEN_FAILED", format!("Failed to open Codex: {error}")) - })?; - Ok(APP_NAME.to_string()) + attempts.push(( + APP_BUNDLE_ID.to_string(), + vec!["-b".to_string(), APP_BUNDLE_ID.to_string()], + )); + attempts.push(( + APP_NAME_CODEX.to_string(), + vec!["-a".to_string(), APP_NAME_CODEX.to_string()], + )); + + let mut last_error = String::from("no open strategy available"); + for (label, args) in attempts { + match Command::new("open").args(&args).stdin(Stdio::null()).output() { + Ok(output) if output.status.success() => return Ok(label), + Ok(output) => { + last_error = format!( + "`open {}` exited {:?}: {}", + args.join(" "), + output.status.code(), + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Err(error) => { + last_error = format!("failed to spawn `open {}`: {error}", args.join(" ")); + } + } + } + Err(AppError::new( + "APP_OPEN_FAILED", + format!("Failed to open Codex: {last_error}"), + )) } pub fn forward_to_real_codex(args: &[String], codex_home: Option<&Path>) -> AppResult { @@ -594,35 +846,114 @@ pub fn run_codex_login(cli_codex_home: &Path, runtime_codex_home: &Path) -> AppR Err(AppError::new("LOGIN_FAILED", message)) } +/// Send `signal` to every identity-verified desktop host PID (see +/// [`codex_desktop_pids`]) and return how many processes were +/// signalled. `-TERM` is sent immediately alongside the AppleScript +/// quit (it covers hosts where `osascript` itself is blocked); `-KILL` +/// is the escalation after the polite quit times out. +fn signal_desktop_app_processes(signal: &str) -> usize { + let mut signalled = 0; + for pid in codex_desktop_pids() { + match Command::new("kill") + .args([signal, &pid.to_string()]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + { + Ok(status) if status.success() => signalled += 1, + // Non-zero exit: the process exited between enumeration and + // signalling — the wait loop below re-checks, nothing to do. + Ok(_) => {} + Err(error) => { + eprintln!("codex_switch: failed to spawn kill for pid {pid}: {error}"); + } + } + } + signalled +} + +fn request_desktop_app_quit() -> (usize, Option) { + // Polite path first: AppleScript quit lets the desktop host flush + // auth / app-server state. It must NOT be waited on — the host can + // pop an interactive quit-confirmation dialog, and the very first + // Apple event may hang on the macOS Automation (TCC) consent + // prompt; either would block the switch thread for minutes. The + // child handle is returned so the caller can CANCEL it once the old + // host is gone: a quit event still queued behind the TCC prompt + // would otherwise be delivered to the instance we relaunch next, + // closing it the moment the user grants consent. + let script = format!("tell application id \"{APP_BUNDLE_ID}\" to quit"); + let quit_child = match Command::new("osascript") + .args(["-e", &script]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + { + Ok(child) => Some(child), + Err(error) => { + eprintln!("codex_switch: failed to spawn osascript quit: {error}"); + None + } + }; + // TERM immediately, as the pre-merge code did — the wait loop in + // quit_codex_app_if_running provides the grace period, and this + // path is the only quit channel when osascript is blocked. + (signal_desktop_app_processes("-TERM"), quit_child) +} + pub fn quit_codex_app_if_running() -> AppResult { if !is_codex_app_running() { return Ok(false); } - let _ = Command::new("pkill") - .args(["-TERM", "-x", APP_NAME]) - .status(); + let (mut signalled, quit_child) = request_desktop_app_quit(); + let mut exited = false; for _ in 0..20 { if !is_codex_app_running() { - return Ok(true); + exited = true; + break; } thread::sleep(Duration::from_millis(200)); } - let _ = Command::new("pkill") - .args(["-KILL", "-x", APP_NAME]) - .status(); - for _ in 0..10 { - if !is_codex_app_running() { - return Ok(true); + if !exited { + signalled += signal_desktop_app_processes("-KILL"); + for _ in 0..10 { + if !is_codex_app_running() { + exited = true; + break; + } + thread::sleep(Duration::from_millis(200)); } - thread::sleep(Duration::from_millis(200)); } - Err(AppError::new( - "APP_EXIT_FAILED", - "Codex did not exit cleanly. Close it manually and retry.", - )) + // The polite quit may still be pending behind the TCC consent + // prompt. Kill it on every exit path — delivered late, its quit + // event would target (and close) the freshly relaunched host. A + // normally-finished osascript is already gone; kill is a no-op then. + if let Some(mut child) = quit_child { + let _ = child.kill(); + thread::spawn(move || { + let _ = child.wait(); + }); + } + + if exited { + return Ok(true); + } + + // Distinguish "we signalled it and it would not die" from "we never + // managed to signal anything" — the latter means the failure is on + // our side (no identifiable PID / kill unavailable), not the app's. + let message = if signalled == 0 { + "Codex/ChatGPT still appears to be running, but no matching process could be \ + identified and signalled. Close it manually and retry." + } else { + "Codex/ChatGPT did not exit cleanly. Close it manually and retry." + }; + Err(AppError::new("APP_EXIT_FAILED", message)) } pub fn reopen_codex_app_if_needed(app_was_running: bool, codex_home: Option<&Path>) -> Vec { @@ -681,15 +1012,17 @@ impl PlatformHooks for MacosPlatformHooks { #[cfg(test)] mod tests { use super::{ - build_app_server_command, codex_app_candidates, codex_cli_from_app_bundle, - discover_real_codex_cli_path, resolve_real_codex_cli_with_source, - set_user_codex_cli_path, validate_user_codex_cli_path, RealCodexPathSource, + build_app_server_command, bundle_root_from_executable_path, codex_app_candidates, + codex_cli_from_app_bundle, desktop_app_process_names, discover_real_codex_cli_path, + parse_osascript_is_running, resolve_real_codex_cli_with_source, set_user_codex_cli_path, + validate_user_codex_cli_path, APP_BUNDLE_ID, APP_BUNDLE_NAMES, APP_NAME_CHATGPT, + APP_NAME_CODEX, RealCodexPathSource, }; use crate::macos::cli_shim::real_codex_resolver_path; use std::fs; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; - use std::path::PathBuf; + use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; fn temp_codex_home(name: &str) -> PathBuf { @@ -776,7 +1109,13 @@ mod tests { let codex_home = temp_codex_home("discover-real-cli-app-bundle"); let managed_bin = codex_home.join("bin"); let home_dir = codex_home.join("home"); - let app_path = home_dir.join("Applications").join("Codex.app"); + // Seed a HOME-local ChatGPT.app so machines without a system + // install still exercise the app-bundle fallback. When a real + // /Applications/ChatGPT.app is present (common after the Codex + // merge) discovery correctly prefers that earlier candidate — + // so the assertion compares against the first on-disk candidate + // rather than the synthetic HOME path alone. + let app_path = home_dir.join("Applications").join("ChatGPT.app"); let app_cli_path = codex_cli_from_app_bundle(&app_path); fs::create_dir_all(&managed_bin).unwrap(); fs::create_dir_all(app_cli_path.parent().unwrap()).unwrap(); @@ -806,9 +1145,168 @@ mod tests { } assert_eq!(resolved, expected); + assert!( + resolved.is_some(), + "app-bundle fallback must find ChatGPT.app or Codex.app CLI when PATH only has the managed shim" + ); + // The winner must itself be one of the known app-bundle CLI shapes. + let resolved = resolved.unwrap(); + assert!( + resolved.ends_with("Contents/Resources/codex"), + "resolved path should be an app-bundle CLI, got {}", + resolved.display() + ); let _ = fs::remove_dir_all(&codex_home); } + #[test] + fn discover_real_codex_cli_path_prefers_path_walk_over_app_bundle() { + let _guard = crate::macos::env_guard(); + let codex_home = temp_codex_home("discover-real-cli-path-vs-bundle"); + let managed_bin = codex_home.join("bin"); + let path_dir = codex_home.join("path-bin"); + let home_dir = codex_home.join("home"); + // Seed BOTH a real PATH hit and a HOME-local ChatGPT.app CLI. + // Unlike the fallback test above — whose `expected` is recomputed + // through codex_app_candidates() and therefore mirrors the + // implementation — the expected value here is a concrete fixture + // path, so a block-ordering regression in + // discover_real_codex_cli_path (app-bundle scan moving ahead of + // the PATH walk) turns this test red on any machine, including + // ones with a real /Applications/ChatGPT.app install. + let app_cli_path = + codex_cli_from_app_bundle(&home_dir.join("Applications").join("ChatGPT.app")); + fs::create_dir_all(&managed_bin).unwrap(); + fs::create_dir_all(&path_dir).unwrap(); + fs::create_dir_all(app_cli_path.parent().unwrap()).unwrap(); + fs::write(managed_bin.join("codex"), "#!/bin/sh\n").unwrap(); + fs::write(path_dir.join("codex"), "#!/bin/sh\n").unwrap(); + fs::write(&app_cli_path, "#!/bin/sh\n").unwrap(); + + let original_path = std::env::var_os("PATH"); + let original_home = std::env::var_os("HOME"); + std::env::set_var( + "PATH", + std::env::join_paths([managed_bin.clone(), path_dir.clone()]).unwrap(), + ); + std::env::set_var("HOME", &home_dir); + + let resolved = discover_real_codex_cli_path(Some(&managed_bin.join("codex"))); + + if let Some(path) = original_path { + std::env::set_var("PATH", path); + } else { + std::env::remove_var("PATH"); + } + if let Some(home) = original_home { + std::env::set_var("HOME", home); + } else { + std::env::remove_var("HOME"); + } + + assert_eq!(resolved, Some(path_dir.join("codex"))); + let _ = fs::remove_dir_all(&codex_home); + } + + #[test] + fn codex_cli_from_app_bundle_uses_contents_resources_layout() { + assert_eq!( + codex_cli_from_app_bundle(Path::new("/Applications/ChatGPT.app")), + PathBuf::from("/Applications/ChatGPT.app/Contents/Resources/codex") + ); + assert_eq!( + codex_cli_from_app_bundle(Path::new("/Applications/Codex.app")), + PathBuf::from("/Applications/Codex.app/Contents/Resources/codex") + ); + } + + #[test] + fn codex_app_candidates_prefer_chatgpt_host_then_legacy_codex() { + let _guard = crate::macos::env_guard(); + let home_dir = temp_codex_home("app-candidates-order").join("home"); + let original_home = std::env::var_os("HOME"); + std::env::set_var("HOME", &home_dir); + + let candidates = codex_app_candidates(); + + if let Some(home) = original_home { + std::env::set_var("HOME", home); + } else { + std::env::remove_var("HOME"); + } + + // Name-major: the ChatGPT host wins over legacy Codex.app in + // BOTH Applications folders before Codex.app is considered at + // all — a stale system-level Codex.app must not shadow a + // user-level ChatGPT.app. + assert_eq!(APP_BUNDLE_NAMES, ["ChatGPT.app", "Codex.app"]); + assert_eq!( + candidates[0], + PathBuf::from("/Applications").join("ChatGPT.app") + ); + assert_eq!( + candidates[1], + home_dir.join("Applications").join("ChatGPT.app") + ); + assert_eq!( + candidates[2], + PathBuf::from("/Applications").join("Codex.app") + ); + assert_eq!( + candidates[3], + home_dir.join("Applications").join("Codex.app") + ); + let _ = fs::remove_dir_all(home_dir.parent().unwrap()); + } + + #[test] + fn desktop_app_process_names_cover_chatgpt_host_and_legacy_codex() { + assert_eq!(APP_BUNDLE_ID, "com.openai.codex"); + assert_eq!( + desktop_app_process_names(), + &[APP_NAME_CHATGPT, APP_NAME_CODEX] + ); + } + + #[test] + fn bundle_root_from_executable_path_finds_nearest_app_ancestor() { + assert_eq!( + bundle_root_from_executable_path(Path::new( + "/Applications/ChatGPT.app/Contents/MacOS/ChatGPT" + )), + Some(PathBuf::from("/Applications/ChatGPT.app")) + ); + assert_eq!( + bundle_root_from_executable_path(Path::new( + "/Applications/Codex.app/Contents/MacOS/Codex" + )), + Some(PathBuf::from("/Applications/Codex.app")) + ); + // Nested bundles resolve to the nearest (deepest) .app root. + assert_eq!( + bundle_root_from_executable_path(Path::new( + "/Applications/Outer.app/Contents/Helpers/Inner.app/Contents/MacOS/Inner" + )), + Some(PathBuf::from( + "/Applications/Outer.app/Contents/Helpers/Inner.app" + )) + ); + // Non-bundle binaries have no .app ancestor. + assert_eq!( + bundle_root_from_executable_path(Path::new("/usr/local/bin/ChatGPT")), + None + ); + } + + #[test] + fn parse_osascript_is_running_accepts_true_false_only() { + assert_eq!(parse_osascript_is_running("true\n"), Some(true)); + assert_eq!(parse_osascript_is_running("TRUE"), Some(true)); + assert_eq!(parse_osascript_is_running(" false "), Some(false)); + assert_eq!(parse_osascript_is_running(""), None); + assert_eq!(parse_osascript_is_running("yes"), None); + } + #[test] fn build_app_server_command_targets_runtime_codex_home() { let real_codex_path = PathBuf::from("/opt/homebrew/bin/codex");