Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- **Critical** — fixed the quota backfill reverting fresh data and bleeding across accounts. The root `~/.codex/profile.json` copy was only ever written at switch-in, so every write-back (switch, app launch, window close) copied that frozen snapshot over the profile's card and silently reverted the quota / plan updates recorded while it was active — permanently for API-key profiles, which no API refresh repairs; Windows had patched the switch path only (keyed off the possibly-stale `.current_profile` marker) and macOS had nothing. The write-back now folds the freshest live-session usage into the identity-verified target's card and refreshes the root copy first, on both platforms and on all four write-back paths (switch, app launch, window close, current-profile login); the legacy `codex-switch.sh` no longer copies the root `profile.json` back at all, closing its variant of the same clobber. Live-session data is scoped to entries written since the profile's activation (`activated_at` from the active marker): `~/.codex/sessions` is not swapped on switch, so the previously-live account's newer entries used to win the freshness race and show up as the current profile's quota for minutes after a switch — and when a profile has no parseable activation marker (an identity-drift target, a hand-edited marker) the fold is skipped outright rather than attributing unattributable sessions. A present-but-unreadable `profile.json` now aborts the write-back loudly instead of laundering a transient read failure into a blanked card. The duplicated Windows display path (which had already drifted: it lacked the unmanaged-account guard) now re-exports the shared implementation; an empty stored card with a newer timestamp (the deliberate downgrade-to-free clear) is no longer overridden by older live sessions; and switching kicks an immediate silent API refresh for the new card instead of priming its stored timestamp and waiting out the 5-minute tick. Known residual: a codex CLI session started under the previous account that keeps running across the switch keeps appending to a session file whose mtime passes the activation cutoff, so its usage can still be misattributed until that session exits.
- **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.
Expand Down
6 changes: 6 additions & 0 deletions macOS-backup/codex-switch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@ backup_root_state_to_profile() {
[[ -e "$entry" ]] || continue
name="$(basename "$entry")"
[[ "$name" == ".DS_Store" || "$name" == "$ACTIVE_MARKER_FILE" ]] && continue
# Never copy the root profile.json back: it is frozen at switch-in
# time while the profile-side copy keeps moving via the app's quota
# refreshes — copying it back would revert those updates. The app
# refreshes the root copy before its own write-backs; this script
# simply leaves the profile-side copy authoritative.
[[ "$name" == "profile.json" ]] && continue
if [[ "$dedup" != *"::$name::"* ]]; then
managed_names+=("$name")
dedup+="${name}::"
Expand Down
8 changes: 8 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ semver = "1.0.28"
tauri = { version = "=2.10.1", features = [] }
tauri-plugin-opener = "2.5.3"

# False positives, not dead deps: the crate's module tree lives outside
# src/ (shared/, mac/, win/ are mounted via #[path] in src/lib.rs), and
# cargo-machete's file discovery only scans the conventional src/ layout,
# so it never sees the real `use` sites (verified: every entry below has
# active call sites under shared/ / mac/ / win/).
[package.metadata.cargo-machete]
ignored = ["base64", "chrono", "reqwest", "semver", "serde", "serde_json"]

[features]
custom-protocol = ["tauri/custom-protocol"]

Expand Down
14 changes: 9 additions & 5 deletions src-tauri/examples/live_chatgpt_merge_check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,7 @@ use codex_switch_lib::macos::process::{
MACOS_CODEX_PATH_RESOLVER,
};
#[cfg(target_os = "macos")]
use codex_switch_lib::shared::codex_cli_path::{
build_codex_cli_status, redetect_codex_cli_path,
};
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;

Expand Down Expand Up @@ -94,7 +92,10 @@ fn main() {

// 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());
println!(
"\n[3] redetect_runnable_codex_cli_paths ({}):",
redetect.len()
);
for candidate in &redetect {
println!(
" - {} ({})",
Expand All @@ -103,7 +104,10 @@ fn main() {
);
}
if chatgpt_cli.is_file() {
match redetect.iter().find(|c| c.path == chatgpt_cli.to_string_lossy()) {
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);
}
Expand Down
8 changes: 2 additions & 6 deletions src-tauri/examples/live_quota_check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,7 @@ fn main() {
}

if let Some(profile) = target_profile {
println!(
"\n--- chatgpt_api::profile_supports_api_refresh({profile}) ---"
);
println!("\n--- chatgpt_api::profile_supports_api_refresh({profile}) ---");
let backup_root = codex_home.join("account_backup").join(&profile);
let supports = chatgpt_api::profile_supports_api_refresh(&backup_root);
println!("supports api refresh: {supports}");
Expand Down Expand Up @@ -107,8 +105,6 @@ fn main() {
}
}
} else {
println!(
"\n(skip chatgpt_api refresh: pass a profile folder name as argv[1] to test)"
);
println!("\n(skip chatgpt_api refresh: pass a profile folder name as argv[1] to test)");
}
}
33 changes: 15 additions & 18 deletions src-tauri/mac/runtime/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ use std::thread;
use std::time::Duration;

use crate::errors::{AppError, AppResult};
use crate::models::CodexCliCandidate;
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;
Expand Down Expand Up @@ -221,10 +221,7 @@ pub(super) fn validate_user_codex_cli_path(
Ok(candidate)
}

pub fn set_user_codex_cli_path(
codex_home: Option<&Path>,
raw_input: &str,
) -> AppResult<PathBuf> {
pub fn set_user_codex_cli_path(codex_home: Option<&Path>, raw_input: &str) -> AppResult<PathBuf> {
let resolved = validate_user_codex_cli_path(codex_home, raw_input)?;
let mut state = load_install_state(codex_home);
let next = Some(resolved.to_string_lossy().into_owned());
Expand All @@ -251,10 +248,7 @@ pub struct MacosCodexPathResolver;
pub static MACOS_CODEX_PATH_RESOLVER: MacosCodexPathResolver = MacosCodexPathResolver;

impl CodexPathResolver for MacosCodexPathResolver {
fn resolve_with_source(
&self,
codex_home: &Path,
) -> Option<(PathBuf, RealCodexPathSource)> {
fn resolve_with_source(&self, codex_home: &Path) -> Option<(PathBuf, RealCodexPathSource)> {
resolve_real_codex_cli_with_source(Some(codex_home))
}

Expand Down Expand Up @@ -618,7 +612,10 @@ fn warn_osascript_probe_degraded(detail: &str) {

fn is_codex_app_running_via_bundle_id() -> Option<bool> {
let output = match Command::new("osascript")
.args(["-e", &format!("application id \"{APP_BUNDLE_ID}\" is running")])
.args([
"-e",
&format!("application id \"{APP_BUNDLE_ID}\" is running"),
])
.stdin(Stdio::null())
.output()
{
Expand Down Expand Up @@ -720,7 +717,11 @@ pub fn open_or_activate_codex_app(_codex_home: Option<&Path>) -> AppResult<Strin

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() {
match Command::new("open")
.args(&args)
.stdin(Stdio::null())
.output()
{
Ok(output) if output.status.success() => return Ok(label),
Ok(output) => {
last_error = format!(
Expand Down Expand Up @@ -988,11 +989,7 @@ impl PlatformHooks for MacosPlatformHooks {
reopen_codex_app_if_needed(app_was_running, codex_home)
}

fn run_codex_login(
&self,
cli_codex_home: &Path,
runtime_codex_home: &Path,
) -> AppResult<()> {
fn run_codex_login(&self, cli_codex_home: &Path, runtime_codex_home: &Path) -> AppResult<()> {
run_codex_login(cli_codex_home, runtime_codex_home)
}

Expand All @@ -1015,8 +1012,8 @@ mod tests {
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,
validate_user_codex_cli_path, RealCodexPathSource, APP_BUNDLE_ID, APP_BUNDLE_NAMES,
APP_NAME_CHATGPT, APP_NAME_CODEX,
};
use crate::macos::cli_shim::real_codex_resolver_path;
use std::fs;
Expand Down
6 changes: 5 additions & 1 deletion src-tauri/mac/runtime/profile_actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use crate::models::ProfileMetadata;
use crate::platform;
use crate::shared::config::sync_root_openai_base_url_from_profile_metadata;
use crate::shared::fs_ops::{backup_root_state_to_profile, remove_path};
use crate::shared::login_runtime::login_profile_with_home;
use crate::shared::metadata::{
load_profile_metadata, save_profile_metadata, sync_profile_metadata_from_auth,
sync_profile_openai_base_url,
Expand All @@ -16,7 +17,6 @@ use crate::shared::paths::{
get_backup_root, get_codex_home, validate_profile_name, ACTIVE_MARKER_FILE, CONTACT_URL,
RELEASES_URL, XIAOHONGSHU_URL,
};
use crate::shared::login_runtime::login_profile_with_home;
use crate::shared::profiles::resolve_current_profile;
use crate::shared::profiles_index::load_profiles_index;

Expand Down Expand Up @@ -78,6 +78,10 @@ pub fn login_current_profile() -> AppResult<String> {
));
}

// Same invariant as every other write-back: refresh the root
// profile.json copy first, or the frozen switch-in snapshot below
// reverts the quota updates recorded while this profile was active.
crate::shared::switch_core::sync_live_quota_and_refresh_root(&current_profile, &codex_home)?;
backup_root_state_to_profile(&current_profile, &codex_home, &backup_root)?;
sync_profile_metadata_from_auth(&current_profile, None, Some(&codex_home))?;
load_profiles_index(Some(&codex_home))?;
Expand Down
7 changes: 4 additions & 3 deletions src-tauri/mac/runtime/refresh_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,7 @@ fn should_force_oauth_rotation(profile_name: &str, codex_home: &Path) -> bool {
.and_then(|value| u64::try_from(value.as_millis()).ok());
match now_ms {
Some(now) => {
now.saturating_sub(last_check)
>= crate::shared::chatgpt_api::PLAN_FRESHNESS_TTL_MS
now.saturating_sub(last_check) >= crate::shared::chatgpt_api::PLAN_FRESHNESS_TTL_MS
}
// Clock unreadable: be conservative and rotate.
None => true,
Expand Down Expand Up @@ -208,7 +207,9 @@ pub fn refresh_profile(profile_name: &str) -> AppResult<String> {
// of paying for an LLM round-trip. Falls through to the app-server
// RPC path on any failure so existing behavior is preserved.
if crate::shared::chatgpt_api::profile_supports_api_refresh(&profile_dir) {
if let Some(profile_path) = try_refresh_via_chatgpt_api(&profile_name, &codex_home, &profile_dir)? {
if let Some(profile_path) =
try_refresh_via_chatgpt_api(&profile_name, &codex_home, &profile_dir)?
{
return Ok(profile_path);
}
}
Expand Down
7 changes: 6 additions & 1 deletion src-tauri/mac/runtime/windowing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ pub fn install(app: &mut App) -> tauri::Result<()> {

window.on_window_event(move |event| {
if let WindowEvent::CloseRequested { .. } = event {
let _ = crate::platform::sync_on_window_close();
// Last persistence opportunity before the app goes away —
// a swallowed failure here silently loses the session's
// quota / auth write-back, so leave a trace.
if let Err(error) = crate::platform::sync_on_window_close() {
eprintln!("codex_switch: window-close sync failed: {}", error.message);
}
}
});

Expand Down
4 changes: 1 addition & 3 deletions src-tauri/shared/commands/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,9 +202,7 @@ pub fn get_codex_cli_status() -> Result<CodexCliStatus, CommandError> {
}

#[tauri::command]
pub fn set_codex_cli_path(
payload: SetCodexCliPathPayload,
) -> Result<CodexCliStatus, CommandError> {
pub fn set_codex_cli_path(payload: SetCodexCliPathPayload) -> Result<CodexCliStatus, CommandError> {
let codex_home = platform_runtime::paths::get_codex_home();
Ok(crate::shared::codex_cli_path::set_codex_cli_path(
platform_runtime::codex_cli_resolver(),
Expand Down
15 changes: 5 additions & 10 deletions src-tauri/shared/commands/dashboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,22 +57,19 @@ fn refresh_active_profile_quota_silent_inner() -> Result<CurrentQuotaResponse, C
let index = crate::shared::profiles_index::load_profiles_index(Some(&codex_home))
.map_err(CommandError::from)?;
let Some(profile_name) = index.current_profile.clone() else {
return platform_runtime::profiles_index::load_current_live_quota(None)
.map_err(Into::into);
return platform_runtime::profiles_index::load_current_live_quota(None).map_err(Into::into);
};
let Some(entry) = index
.profiles
.iter()
.find(|profile| profile.folder_name == profile_name)
else {
return platform_runtime::profiles_index::load_current_live_quota(None)
.map_err(Into::into);
return platform_runtime::profiles_index::load_current_live_quota(None).map_err(Into::into);
};

let profile_dir = backup_root.join(&entry.folder_name);
if !crate::shared::chatgpt_api::profile_supports_api_refresh(&profile_dir) {
return platform_runtime::profiles_index::load_current_live_quota(None)
.map_err(Into::into);
return platform_runtime::profiles_index::load_current_live_quota(None).map_err(Into::into);
}

let now_ms = SystemTime::now()
Expand All @@ -85,8 +82,7 @@ fn refresh_active_profile_quota_silent_inner() -> Result<CurrentQuotaResponse, C
.map(|stored| now_ms.saturating_sub(stored))
.unwrap_or(u64::MAX);
if stored_age_ms < SILENT_REFRESH_MIN_AGE_MS {
return platform_runtime::profiles_index::load_current_live_quota(None)
.map_err(Into::into);
return platform_runtime::profiles_index::load_current_live_quota(None).map_err(Into::into);
}

if let Ok(snapshot) =
Expand Down Expand Up @@ -167,8 +163,7 @@ fn refresh_all_oauth_profile_plans_silent_inner() -> Result<u32, CommandError> {
// cards. With it, repeat launches within a working day are
// free.
if let Some(last_check) = entry.last_plan_check_ms {
if now_ms.saturating_sub(last_check)
< crate::shared::chatgpt_api::PLAN_FRESHNESS_TTL_MS
if now_ms.saturating_sub(last_check) < crate::shared::chatgpt_api::PLAN_FRESHNESS_TTL_MS
{
continue;
}
Expand Down
Loading
Loading