From d493819846e7941400cb573561b19240a6d76778 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Sun, 10 May 2026 14:47:54 +0800 Subject: [PATCH 1/3] fix(refresh): replace `codex exec` fallback with app-server JSON-RPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-card Refresh button's fallback path used to spawn `codex exec "Reply with the single word OK."` whenever the direct ChatGPT HTTP refresh failed. That path: * burned ~30–90 s on the user's clock (real LLM round-trip) * consumed real ChatGPT quota * relied on parsing the resulting session JSONL to extract rate limits, which only worked because codex happened to write that data on every request It is replaced by `codex app-server`'s JSON-RPC interface: `account/read` (refreshToken=true) for plan tier + auth.json refresh, followed by `account/rateLimits/read` for the live primary/secondary rate-limit windows. Same backend endpoint, no LLM round-trip, returns within seconds. Implementation lives in `shared/runtime/codex_app_server.rs`: a stdio JSON-RPC 2.0 client driving the upstream protocol verified against `openai/codex` `codex-rs/app-server` (newline-delimited JSON, mandatory `initialize` + `initialized` handshake, monotonic id matching with per-request and overall session deadlines, ChildGuard that always kills + reaps on every exit path, dedicated reader and stderr-drain threads to avoid pipe back-pressure deadlocks). Mac and Windows `process.rs` build the `Command` (Windows hides the console window via the existing `hide_console_window` helper); the shared client runs the rest. The `PlatformHooks` trait method renamed `run_codex_auth_refresh` → `fetch_account_via_app_server` with a return type that surfaces both plan + quota in one shot, so both `mac/runtime/refresh_runtime.rs` and `win/runtime/refresh_runtime.rs` drop the post-spawn JSONL scan and sandbox-cleanup dance. Requires `codex` ≥ 0.130.0 on the fallback path; older CLIs surface the new `APP_SERVER_METHOD_UNSUPPORTED` error so the user can upgrade instead of hitting an opaque hang. Also: hardened three `discover_real_codex_cli_path_*` macOS tests that mutate `HOME` / `PATH` against parallel-execution races by routing them through the existing `env_guard()` mutex (these were flaky before this PR; touching the same file made it cheap to fix). Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 5 + src-tauri/mac/runtime/process.rs | 98 +-- src-tauri/mac/runtime/refresh_runtime.rs | 134 ++--- src-tauri/shared/platform/hooks.rs | 13 +- src-tauri/shared/platform/mod.rs | 8 +- src-tauri/shared/runtime/codex_app_server.rs | 599 +++++++++++++++++++ src-tauri/shared/runtime/login_runtime.rs | 10 +- src-tauri/shared/runtime/mod.rs | 1 + src-tauri/shared/runtime/switch_core.rs | 4 +- src-tauri/win/runtime/process.rs | 142 +---- src-tauri/win/runtime/refresh_runtime.rs | 133 ++-- src-tauri/win/runtime/switch.rs | 4 +- 12 files changed, 758 insertions(+), 393 deletions(-) create mode 100644 src-tauri/shared/runtime/codex_app_server.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 68f7f8c..079f7cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +- Refresh fallback no longer burns user quota or runs an LLM round-trip. The legacy `codex exec "Reply with the single word OK."` path took 30–90 s and consumed real ChatGPT quota whenever the direct HTTP refresh failed (slow network, transient 401, GFW). It is now replaced by `codex app-server`'s JSON-RPC `account/read` + `account/rateLimits/read`, which return the same plan + rate-limit data in well under a second without touching the model. Requires `codex` ≥ 0.130.0 on this fallback path; older CLIs surface `APP_SERVER_METHOD_UNSUPPORTED` so the user can upgrade. +- Hardened three macOS `discover_real_codex_cli_path_*` tests against parallel `HOME` / `PATH` env-var races by routing them through the existing `env_guard()` mutex. + ## 1.5.7 - 2026-05-10 - **Critical** — fixed a latent hang in `codex login` cancellation. The previous PID-based cancel had a microsecond race window where a recycled PID could be SIGTERM'd between `wait_with_output` returning and the slot being cleared (worse on Windows where `taskkill /F /T` would nuke an unrelated process tree). The slot now holds the actual `Child` handle, and cancel calls `Child::kill()` directly. Both cancel and natural-exit paths funnel through a `drop_killed_child` helper that does `kill()` + `wait()` so we don't leak zombies on Unix. diff --git a/src-tauri/mac/runtime/process.rs b/src-tauri/mac/runtime/process.rs index ace3ecb..2d28aa5 100644 --- a/src-tauri/mac/runtime/process.rs +++ b/src-tauri/mac/runtime/process.rs @@ -8,6 +8,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::shared::codex_cli_path::CodexPathResolver; pub use crate::shared::codex_cli_path::{InstallState, RealCodexPathSource}; use crate::shared::login_cancel::wait_for_login_or_cancel; @@ -15,7 +16,6 @@ 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"; -const AUTH_REFRESH_PROMPT: &str = "Reply with the single word OK."; static MACOS_PLATFORM_HOOKS: MacosPlatformHooks = MacosPlatformHooks; static MACOS_APP_PATH_CACHE: OnceLock> = OnceLock::new(); @@ -393,15 +393,14 @@ pub fn forward_to_real_codex(args: &[String], codex_home: Option<&Path>) -> AppR Ok(status.code().unwrap_or(1)) } -fn build_auth_refresh_command(real_codex_path: &Path, runtime_codex_home: &Path) -> Command { +fn build_app_server_command(real_codex_path: &Path, runtime_codex_home: &Path) -> Command { let mut command = Command::new(real_codex_path); - command.args([ - "exec", - "--skip-git-repo-check", - "--color", - "never", - AUTH_REFRESH_PROMPT, - ]); + // `codex app-server` is the canonical control-plane subcommand + // (verified against `openai/codex` `codex-rs/cli/src/main.rs`). It + // takes no sandbox/approval flags — the `-s` / `-a` flags only bind + // to the interactive TUI and are silently ignored here, so we omit + // them rather than carry dead weight on every refresh. + command.arg("app-server"); command.current_dir(runtime_codex_home); command.env("CODEX_HOME", runtime_codex_home); command @@ -423,26 +422,10 @@ fn build_login_command(real_codex_path: &Path, runtime_codex_home: &Path) -> Com command } -fn classify_auth_refresh_failure(message: &str) -> Option { - let normalized = message.to_ascii_lowercase(); - let requires_relogin = normalized.contains("token_invalidated") - || normalized.contains("refresh_token_reused") - || normalized.contains("authentication token has been invalidated") - || normalized.contains("refresh token has already been used") - || normalized.contains("please try signing in again") - || normalized.contains("please log out and sign in again"); - - if requires_relogin { - return Some(AppError::new( - "AUTH_REFRESH_RELOGIN_REQUIRED", - "This account session has expired. Please log in again.", - )); - } - - None -} - -pub fn run_codex_auth_refresh(cli_codex_home: &Path, runtime_codex_home: &Path) -> AppResult<()> { +pub fn fetch_account_via_app_server( + cli_codex_home: &Path, + runtime_codex_home: &Path, +) -> AppResult { let Some(real_codex_path) = resolve_real_codex_cli(Some(cli_codex_home)) else { return Err(AppError::new( "REAL_CODEX_NOT_FOUND", @@ -450,34 +433,8 @@ pub fn run_codex_auth_refresh(cli_codex_home: &Path, runtime_codex_home: &Path) )); }; - let output = build_auth_refresh_command(&real_codex_path, runtime_codex_home) - .output() - .map_err(|error| { - AppError::new( - "AUTH_REFRESH_COMMAND_FAILED", - format!("Failed to start `codex exec` for auth refresh: {error}"), - ) - })?; - - if output.status.success() { - return Ok(()); - } - - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let message = if !stderr.is_empty() { - stderr - } else if !stdout.is_empty() { - stdout - } else { - "`codex exec` exited without a success status while refreshing auth.".to_string() - }; - - if let Some(error) = classify_auth_refresh_failure(&message) { - return Err(error); - } - - Err(AppError::new("AUTH_REFRESH_FAILED", message)) + let command = build_app_server_command(&real_codex_path, runtime_codex_home); + fetch_account_snapshot(command) } pub fn run_codex_login(cli_codex_home: &Path, runtime_codex_home: &Path) -> AppResult<()> { @@ -591,12 +548,12 @@ impl PlatformHooks for MacosPlatformHooks { run_codex_login(cli_codex_home, runtime_codex_home) } - fn run_codex_auth_refresh( + fn fetch_account_via_app_server( &self, cli_codex_home: &Path, runtime_codex_home: &Path, - ) -> AppResult<()> { - run_codex_auth_refresh(cli_codex_home, runtime_codex_home) + ) -> AppResult { + fetch_account_via_app_server(cli_codex_home, runtime_codex_home) } fn sync_on_window_close(&self) -> AppResult<()> { @@ -607,10 +564,9 @@ impl PlatformHooks for MacosPlatformHooks { #[cfg(test)] mod tests { use super::{ - build_auth_refresh_command, codex_app_candidates, codex_cli_from_app_bundle, + 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, - AUTH_REFRESH_PROMPT, }; use crate::macos::cli_shim::real_codex_resolver_path; use std::fs; @@ -629,6 +585,7 @@ mod tests { #[test] fn discover_real_codex_cli_path_skips_managed_shim() { + let _guard = crate::macos::env_guard(); let codex_home = temp_codex_home("discover-real-cli"); let managed_bin = codex_home.join("bin"); let npm_dir = codex_home.join("npm"); @@ -657,6 +614,7 @@ mod tests { #[test] fn discover_real_codex_cli_path_prefers_macos_shell_resolver() { + let _guard = crate::macos::env_guard(); let codex_home = temp_codex_home("discover-real-cli-shell"); let managed_bin = codex_home.join("bin"); let runtime_dir = codex_home.join("account_backup").join("macos"); @@ -697,6 +655,7 @@ mod tests { #[test] fn discover_real_codex_cli_path_falls_back_to_app_bundle_cli() { + let _guard = crate::macos::env_guard(); 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"); @@ -734,11 +693,11 @@ mod tests { } #[test] - fn build_auth_refresh_command_targets_runtime_codex_home() { + fn build_app_server_command_targets_runtime_codex_home() { let real_codex_path = PathBuf::from("/opt/homebrew/bin/codex"); let runtime_codex_home = PathBuf::from("/tmp/codex-home"); - let command = build_auth_refresh_command(&real_codex_path, &runtime_codex_home); + let command = build_app_server_command(&real_codex_path, &runtime_codex_home); let args = command .get_args() .map(|arg| arg.to_string_lossy().into_owned()) @@ -757,16 +716,7 @@ mod tests { command.get_program().to_string_lossy(), real_codex_path.to_string_lossy() ); - assert_eq!( - args, - vec![ - "exec".to_string(), - "--skip-git-repo-check".to_string(), - "--color".to_string(), - "never".to_string(), - AUTH_REFRESH_PROMPT.to_string(), - ] - ); + assert_eq!(args, vec!["app-server".to_string()]); assert_eq!( command.get_current_dir(), Some(runtime_codex_home.as_path()) diff --git a/src-tauri/mac/runtime/refresh_runtime.rs b/src-tauri/mac/runtime/refresh_runtime.rs index c453b2b..8e78136 100644 --- a/src-tauri/mac/runtime/refresh_runtime.rs +++ b/src-tauri/mac/runtime/refresh_runtime.rs @@ -3,6 +3,7 @@ use std::path::{Path, PathBuf}; use std::time::UNIX_EPOCH; use crate::errors::{AppError, AppResult}; +use crate::models::QuotaSummary; use crate::platform; use crate::shared::fs_ops::{copy_entry, remove_path}; use crate::shared::metadata::{sync_profile_metadata_from_auth, sync_profile_quota}; @@ -12,40 +13,12 @@ use crate::shared::runtime_isolation::{ prune_runtime_extra_features, seed_runtime_shared_assets, RUNTIME_AUTH_FILENAME, RUNTIME_PROFILE_METADATA_FILENAME, }; -use crate::shared::session_files::{collect_jsonl_files, file_modified_ms}; -use crate::shared::session_usage::load_latest_local_quota_snapshot_since; use super::cli_shim::get_refresh_runtime_dir; const REFRESH_RUNTIME_PROFILE_FILES: [&str; 2] = [RUNTIME_AUTH_FILENAME, RUNTIME_PROFILE_METADATA_FILENAME]; -fn generated_refresh_session_files( - runtime_home: &Path, - min_source_mtime_ms: Option, -) -> Vec { - let sessions_root = runtime_home.join("sessions"); - if !sessions_root.is_dir() { - return Vec::new(); - } - - let mut files = Vec::new(); - collect_jsonl_files(&sessions_root, &mut files); - files - .into_iter() - .filter(|path| { - !min_source_mtime_ms - .is_some_and(|min_mtime| file_modified_ms(path).unwrap_or(0) < min_mtime) - }) - .collect() -} - -fn cleanup_generated_refresh_sessions(session_files: &[PathBuf]) { - for path in session_files { - let _ = remove_path(path); - } -} - fn ensure_refreshable_auth(auth_path: &Path) -> AppResult<()> { let raw = fs::read_to_string(auth_path).map_err(|error| { AppError::new( @@ -183,58 +156,67 @@ pub fn refresh_profile(profile_name: &str) -> AppResult { // Fast path for ChatGPT/OAuth profiles: hit the same private backend // endpoint the Codex CLI itself uses to read live rate limits, instead - // of running a real `codex exec` round-trip just to provoke a session - // file write. Falls through to the legacy path on any failure so - // existing behavior is preserved as a safety net. + // 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)? { return Ok(profile_path); } } - let runtime_codex_home = prepare_refresh_runtime_home(&codex_home, &profile_dir)?; - let refresh_started_at_ms = std::time::SystemTime::now() - .duration_since(UNIX_EPOCH) - .ok() - .and_then(|value| u64::try_from(value.as_millis()).ok()); - platform::run_codex_auth_refresh(&codex_home, &runtime_codex_home)?; - let generated_session_files = - generated_refresh_session_files(&runtime_codex_home, refresh_started_at_ms); - let refreshed_quota = - load_latest_local_quota_snapshot_since(Some(&runtime_codex_home), refresh_started_at_ms); - cleanup_generated_refresh_sessions(&generated_session_files); + refresh_via_app_server(&profile_name, &codex_home, &profile_dir, &auth_path) +} + +/// Fallback path when the direct ChatGPT HTTP call failed. Uses +/// `codex app-server`'s JSON-RPC interface to fetch the same data +/// (account plan + rate limits) without spawning a real LLM session. +/// Replaces the historical `codex exec "Reply with the single word OK."` +/// hack which burned ~30–90 s and real user quota on every fallback. +fn refresh_via_app_server( + profile_name: &str, + codex_home: &Path, + profile_dir: &Path, + auth_path: &Path, +) -> AppResult { + let runtime_codex_home = prepare_refresh_runtime_home(codex_home, profile_dir)?; + let snapshot = platform::fetch_account_via_app_server(codex_home, &runtime_codex_home)?; + // app-server rotates the per-profile `auth.json` inside the sandbox + // when `account/read` runs with `refreshToken: true`. Copy it back so + // the user's profile picks up fresh tokens, mirroring the prior + // copy-back step from the legacy `codex exec` flow. let refreshed_auth_path = runtime_codex_home.join("auth.json"); if !refreshed_auth_path.is_file() { return Err(AppError::new( "AUTH_REFRESH_MISSING", - "Codex refresh completed but no auth.json was found in the refresh runtime home.", + "codex app-server completed but no auth.json was found in the refresh runtime home.", )); } + copy_entry(&refreshed_auth_path, auth_path)?; - copy_entry(&refreshed_auth_path, &auth_path)?; - // Legacy `codex exec` refresh has no API plan_type to feed in; the - // id_token claim that codex just rotated is the only fresh signal. - sync_profile_metadata_from_auth(&profile_name, None, Some(&codex_home))?; - if let Some(snapshot) = refreshed_quota { - sync_profile_quota( - &profile_name, - snapshot.quota, - snapshot.source_mtime_ms, - Some(&codex_home), - )?; - } - load_profiles_index(Some(&codex_home))?; + let now_ms = std::time::SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|value| u64::try_from(value.as_millis()).ok()); + // Always write the quota snapshot, including the empty default for + // accounts whose payload had no rate-limit data (free tier + // downgrade): keeps stale paid-window numbers from sticking next to + // a freshly-updated plan label. + sync_profile_quota( + profile_name, + snapshot.quota.unwrap_or_else(QuotaSummary::default), + now_ms, + Some(codex_home), + )?; + sync_profile_metadata_from_auth(profile_name, snapshot.plan_type, Some(codex_home))?; + load_profiles_index(Some(codex_home))?; Ok(profile_dir.to_string_lossy().into_owned()) } #[cfg(test)] mod tests { - use super::{ - cleanup_generated_refresh_sessions, generated_refresh_session_files, - prepare_refresh_runtime_home, - }; + use super::prepare_refresh_runtime_home; use crate::macos::cli_shim::get_refresh_runtime_dir; use std::fs; use std::path::PathBuf; @@ -321,36 +303,4 @@ mod tests { let _ = fs::remove_dir_all(&codex_home); } - #[test] - fn cleanup_generated_refresh_sessions_only_removes_new_sessions() { - let codex_home = temp_codex_home("cleanup-refresh-sessions"); - let runtime_home = get_refresh_runtime_dir(&codex_home); - let sessions_dir = runtime_home - .join("sessions") - .join("2026") - .join("04") - .join("08"); - fs::create_dir_all(&sessions_dir).unwrap(); - - let old_session = sessions_dir.join("rollout-old.jsonl"); - let new_session = sessions_dir.join("rollout-new.jsonl"); - fs::write(&old_session, "{\"type\":\"event_msg\"}\n").unwrap(); - std::thread::sleep(std::time::Duration::from_millis(5)); - let threshold = fs::metadata(&old_session) - .unwrap() - .modified() - .unwrap() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_millis() as u64 - + 1; - fs::write(&new_session, "{\"type\":\"event_msg\"}\n").unwrap(); - - let generated = generated_refresh_session_files(&runtime_home, Some(threshold)); - cleanup_generated_refresh_sessions(&generated); - - assert!(old_session.is_file()); - assert!(!new_session.exists()); - let _ = fs::remove_dir_all(&codex_home); - } } diff --git a/src-tauri/shared/platform/hooks.rs b/src-tauri/shared/platform/hooks.rs index bee1cd2..20a5141 100644 --- a/src-tauri/shared/platform/hooks.rs +++ b/src-tauri/shared/platform/hooks.rs @@ -1,6 +1,7 @@ use std::path::Path; use crate::errors::AppResult; +use crate::shared::codex_app_server::AppServerSnapshot; pub trait PlatformHooks: Send + Sync { fn open_or_activate_codex_app(&self, codex_home: Option<&Path>) -> AppResult; @@ -21,11 +22,19 @@ pub trait PlatformHooks: Send + Sync { cli_codex_home: &Path, runtime_codex_home: &Path, ) -> AppResult<()>; - fn run_codex_auth_refresh( + /// Drive `codex app-server` to fetch the live account plan + rate + /// limits without paying for an LLM round-trip. Replaces the + /// historical `codex exec "Reply with the single word OK."` hack + /// that would burn user quota for ~30–90 s on every refresh + /// fallback. Implementations resolve the real codex binary via + /// `cli_codex_home` and point the spawned child at + /// `runtime_codex_home` as its `CODEX_HOME` (sandboxed sibling), + /// matching the existing login/refresh isolation model. + fn fetch_account_via_app_server( &self, cli_codex_home: &Path, runtime_codex_home: &Path, - ) -> AppResult<()>; + ) -> AppResult; fn sync_root_openai_base_url_for_profile( &self, profile_name: &str, diff --git a/src-tauri/shared/platform/mod.rs b/src-tauri/shared/platform/mod.rs index 49f615b..c5af72c 100644 --- a/src-tauri/shared/platform/mod.rs +++ b/src-tauri/shared/platform/mod.rs @@ -3,6 +3,7 @@ pub mod hooks; use std::path::Path; use crate::errors::AppResult; +use crate::shared::codex_app_server::AppServerSnapshot; use hooks::PlatformHooks; use tauri::App; @@ -48,8 +49,11 @@ pub fn run_codex_login(cli_codex_home: &Path, runtime_codex_home: &Path) -> AppR current_hooks().run_codex_login(cli_codex_home, runtime_codex_home) } -pub fn run_codex_auth_refresh(cli_codex_home: &Path, runtime_codex_home: &Path) -> AppResult<()> { - current_hooks().run_codex_auth_refresh(cli_codex_home, runtime_codex_home) +pub fn fetch_account_via_app_server( + cli_codex_home: &Path, + runtime_codex_home: &Path, +) -> AppResult { + current_hooks().fetch_account_via_app_server(cli_codex_home, runtime_codex_home) } pub fn sync_on_window_close() -> AppResult<()> { diff --git a/src-tauri/shared/runtime/codex_app_server.rs b/src-tauri/shared/runtime/codex_app_server.rs new file mode 100644 index 0000000..6819d83 --- /dev/null +++ b/src-tauri/shared/runtime/codex_app_server.rs @@ -0,0 +1,599 @@ +//! JSON-RPC client for the upstream `codex app-server` subcommand. +//! +//! Replaces the legacy `codex exec "Reply with the single word OK."` +//! fallback used to provoke a session-file write so we could sample +//! quota. That path costs the user a real LLM round-trip (30–90 s) and +//! actual token quota; this one calls the same backend through a stdio +//! JSON-RPC handshake and returns within seconds without touching the +//! model. +//! +//! Wire format (verified against `openai/codex` `codex-rs/app-server`): +//! * stdio transport, newline-delimited JSON (one JSON object per line) +//! * mandatory `initialize` request + `initialized` notification +//! handshake before any other call +//! * methods used: `account/read` (with `refreshToken: true` so the +//! OAuth refresh runs server-side and `auth.json` is rewritten with +//! fresh `id_token` / `access_token` / `refresh_token`), +//! `account/rateLimits/read` (no params) +//! +//! On error the server returns standard JSON-RPC error objects. Codes we +//! care about: `-32600` ("authentication required") → relogin signal, +//! `-32601` (method not found) → outdated codex CLI. + +use std::io::{BufRead, BufReader, Write}; +use std::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command, Stdio}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; +use std::thread; +use std::time::{Duration, Instant}; + +use chrono::{Local, TimeZone, Utc}; +use serde_json::{json, Value}; + +use crate::errors::{AppError, AppResult}; +use crate::models::{QuotaSummary, QuotaWindow}; + +use super::quota_routing::{slot_from_window_minutes, QuotaSlot}; + +const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(8); +/// Hard upper bound on the whole RPC session. Defends against a child +/// that hangs after responding to one method but not the next. +const SESSION_TIMEOUT: Duration = Duration::from_secs(30); + +/// Pulled from `account/rateLimits/read` + `account/read`. `quota` is +/// `None` when the response carried no rate-limit data (typical for free +/// tier or accounts without an enforced window) — callers should clear +/// stale paid-window numbers in that case. +#[derive(Debug, Clone, Default)] +pub struct AppServerSnapshot { + pub plan_type: Option, + pub quota: Option, +} + +/// Drive a one-shot JSON-RPC session against `codex app-server`. +/// Caller-provided `command` must already point at the resolved real +/// codex binary (callers also typically set `CODEX_HOME` and any +/// platform-specific stdio flags such as Windows' CREATE_NO_WINDOW). +/// +/// The function pipes stdio internally and kills the child on every +/// exit path, so caller does not need a Drop guard of its own. +pub fn fetch_account_snapshot(mut command: Command) -> AppResult { + command.stdin(Stdio::piped()); + command.stdout(Stdio::piped()); + command.stderr(Stdio::piped()); + // RUST_LOG=error silences the protocol-level chatter codex emits on + // stderr by default. Without this the pipe buffer can fill on a + // slow client and back-pressure the child into a deadlock; a + // background drain thread (below) backs this up belt-and-braces. + command.env("RUST_LOG", "error"); + + let child = command.spawn().map_err(|error| { + AppError::new( + "APP_SERVER_SPAWN_FAILED", + format!("Failed to spawn `codex app-server`: {error}"), + ) + })?; + + let mut guard = ChildGuard::new(child); + drive_session(guard.child_mut()) +} + +struct ChildGuard { + child: Option, +} + +impl ChildGuard { + fn new(child: Child) -> Self { + Self { child: Some(child) } + } + + fn child_mut(&mut self) -> &mut Child { + self.child + .as_mut() + .expect("ChildGuard outlived its Child handle") + } +} + +impl Drop for ChildGuard { + fn drop(&mut self) { + if let Some(mut child) = self.child.take() { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +fn drive_session(child: &mut Child) -> AppResult { + let stdin = child.stdin.take().ok_or_else(|| { + AppError::new( + "APP_SERVER_PIPE_FAILED", + "Failed to acquire stdin for codex app-server.", + ) + })?; + let stdout = child.stdout.take().ok_or_else(|| { + AppError::new( + "APP_SERVER_PIPE_FAILED", + "Failed to acquire stdout for codex app-server.", + ) + })?; + let stderr = child.stderr.take().ok_or_else(|| { + AppError::new( + "APP_SERVER_PIPE_FAILED", + "Failed to acquire stderr for codex app-server.", + ) + })?; + + drain_stderr_in_background(stderr); + let stdout_rx = spawn_stdout_reader(stdout); + let session_deadline = Instant::now() + SESSION_TIMEOUT; + let mut session = Session { + stdin, + stdout_rx, + next_id: 0, + session_deadline, + }; + + handshake(&mut session)?; + let plan_type = read_account_plan(&mut session)?; + let quota = read_rate_limits(&mut session)?; + + Ok(AppServerSnapshot { plan_type, quota }) +} + +struct Session { + stdin: ChildStdin, + stdout_rx: Receiver>, + next_id: i64, + session_deadline: Instant, +} + +impl Session { + fn call( + &mut self, + method: &'static str, + params: Option, + per_request_timeout: Duration, + ) -> AppResult { + let id = self.next_id; + self.next_id += 1; + + let mut payload = json!({ + "jsonrpc": "2.0", + "method": method, + "id": id, + }); + if let Some(params) = params { + payload["params"] = params; + } + write_frame(&mut self.stdin, &payload)?; + + let request_deadline = (Instant::now() + per_request_timeout).min(self.session_deadline); + loop { + let timeout = request_deadline + .checked_duration_since(Instant::now()) + .ok_or_else(|| timeout_error(method))?; + let line = match self.stdout_rx.recv_timeout(timeout) { + Ok(Ok(line)) => line, + Ok(Err(error)) => { + return Err(AppError::new( + "APP_SERVER_READ_FAILED", + format!("Failed to read codex app-server stdout: {error}"), + )); + } + Err(RecvTimeoutError::Timeout) => return Err(timeout_error(method)), + Err(RecvTimeoutError::Disconnected) => { + return Err(AppError::new( + "APP_SERVER_PIPE_CLOSED", + "codex app-server stdout closed unexpectedly.", + )); + } + }; + + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let parsed: Value = serde_json::from_str(trimmed).map_err(|error| { + AppError::new( + "APP_SERVER_PARSE_FAILED", + format!( + "Failed to parse codex app-server message ({error}): {trimmed}" + ), + ) + })?; + // Skip notifications or replies addressed to a different id — + // the server may emit progress events while a request is in + // flight. Safe because `next_id` is monotonic and calls are + // serialized through `&mut self`, so a stale reply's id can + // only be lower than the current one and never collide. + if parsed.get("id").and_then(Value::as_i64) != Some(id) { + continue; + } + if let Some(error_obj) = parsed.get("error") { + return Err(map_rpc_error(method, error_obj)); + } + return Ok(parsed.get("result").cloned().unwrap_or(Value::Null)); + } + } + + fn notify(&mut self, method: &str, params: Option) -> AppResult<()> { + let mut payload = json!({ + "jsonrpc": "2.0", + "method": method, + }); + if let Some(params) = params { + payload["params"] = params; + } + write_frame(&mut self.stdin, &payload) + } +} + +fn timeout_error(method: &'static str) -> AppError { + AppError::new( + "APP_SERVER_TIMEOUT", + format!("`codex app-server` `{method}` timed out."), + ) +} + +fn write_frame(stdin: &mut ChildStdin, value: &Value) -> AppResult<()> { + let mut bytes = serde_json::to_vec(value).map_err(|error| { + AppError::new( + "APP_SERVER_SERIALIZE_FAILED", + format!("Failed to serialize app-server request: {error}"), + ) + })?; + bytes.push(b'\n'); + stdin.write_all(&bytes).map_err(|error| { + AppError::new( + "APP_SERVER_WRITE_FAILED", + format!("Failed to write to codex app-server stdin: {error}"), + ) + })?; + stdin.flush().map_err(|error| { + AppError::new( + "APP_SERVER_WRITE_FAILED", + format!("Failed to flush codex app-server stdin: {error}"), + ) + }) +} + +fn spawn_stdout_reader(stdout: ChildStdout) -> Receiver> { + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let mut reader = BufReader::new(stdout); + loop { + let mut line = String::new(); + match reader.read_line(&mut line) { + Ok(0) => break, + Ok(_) => { + if tx.send(Ok(line)).is_err() { + break; + } + } + Err(error) => { + let _ = tx.send(Err(error)); + break; + } + } + } + }); + rx +} + +fn drain_stderr_in_background(stderr: ChildStderr) { + thread::spawn(move || { + let mut reader = BufReader::new(stderr); + let mut sink = std::io::sink(); + let _ = std::io::copy(&mut reader, &mut sink); + }); +} + +fn handshake(session: &mut Session) -> AppResult<()> { + let params = json!({ + "clientInfo": { + "name": "codex_account_switch", + "title": "Codex Account Switch", + "version": env!("CARGO_PKG_VERSION"), + } + }); + session.call("initialize", Some(params), HANDSHAKE_TIMEOUT)?; + // `ClientNotification::Initialized` serializes (via serde + // `rename_all = "camelCase"`) as method `"initialized"` with no + // params payload — matches `codex-rs/app-server-protocol`'s wire + // format, not the LSP-style `notifications/initialized`. + session.notify("initialized", None) +} + +fn read_account_plan(session: &mut Session) -> AppResult> { + // `refreshToken: true` forces an OAuth refresh server-side, so the + // id_token claims (plan tier, subscription expiry) and `auth.json` + // tokens move within a single click rather than waiting on the + // cached access_token to expire. + let params = json!({ "refreshToken": true }); + let result = session.call("account/read", Some(params), REQUEST_TIMEOUT)?; + Ok(extract_plan_type(&result)) +} + +fn extract_plan_type(result: &Value) -> Option { + let account = result.get("account")?; + if account.is_null() { + return None; + } + account + .get("planType") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn read_rate_limits(session: &mut Session) -> AppResult> { + let result = session.call("account/rateLimits/read", None, REQUEST_TIMEOUT)?; + Ok(parse_rate_limits_response(&result)) +} + +fn parse_rate_limits_response(value: &Value) -> Option { + let rate_limits = value.get("rateLimits").filter(|v| !v.is_null())?; + let mut summary = QuotaSummary::default(); + let mut any_data = false; + + // Position is fallback only — `windowDurationMins` is authoritative, + // mirrors the JSONL routing in `session_usage` and the HTTP path's + // `chatgpt_api::quota_summary_from_payload`. + for (window_key, fallback) in [ + ("primary", QuotaSlot::FiveHour), + ("secondary", QuotaSlot::Weekly), + ] { + let Some(window) = rate_limits.get(window_key).filter(|v| !v.is_null()) else { + continue; + }; + let mapped = quota_window_from_app_server(window); + if mapped.remaining_percent.is_none() && mapped.refresh_at.is_none() { + continue; + } + any_data = true; + let window_minutes = window.get("windowDurationMins").and_then(Value::as_i64); + match slot_from_window_minutes(window_minutes, fallback) { + QuotaSlot::FiveHour => summary.five_hour = mapped, + QuotaSlot::Weekly => summary.weekly = mapped, + } + } + + if any_data { + Some(summary) + } else { + None + } +} + +fn quota_window_from_app_server(window: &Value) -> QuotaWindow { + let used_percent = window.get("usedPercent").and_then(|value| { + value + .as_f64() + .or_else(|| value.as_i64().map(|v| v as f64)) + }); + let remaining_percent = + used_percent.map(|used| (100.0 - used).round().clamp(0.0, 100.0) as u8); + let refresh_at = window + .get("resetsAt") + .and_then(Value::as_i64) + .and_then(|seconds| { + Utc.timestamp_opt(seconds, 0) + .single() + .map(|datetime| { + datetime + .with_timezone(&Local) + .format("%Y-%m-%d %H:%M") + .to_string() + }) + }); + QuotaWindow { + remaining_percent, + refresh_at, + } +} + +fn map_rpc_error(method: &'static str, error: &Value) -> AppError { + let code = error.get("code").and_then(Value::as_i64).unwrap_or(0); + let raw_message = error + .get("message") + .and_then(Value::as_str) + .unwrap_or_default(); + let normalized = raw_message.to_ascii_lowercase(); + + if normalized.contains("token_invalidated") + || normalized.contains("refresh_token_reused") + || normalized.contains("authentication token has been invalidated") + || normalized.contains("refresh token has already been used") + || normalized.contains("please try signing in again") + || normalized.contains("please log out and sign in again") + { + return AppError::new( + "AUTH_REFRESH_RELOGIN_REQUIRED", + "This account session has expired. Please log in again.", + ); + } + + // `-32600 codex account authentication required to read rate limits` is + // the canonical "auth missing" path the upstream tests exercise; treat + // it the same as the relogin-required errors above so the dashboard + // surfaces a consistent toast. + if code == -32600 && normalized.contains("authentication required") { + return AppError::new( + "AUTH_REFRESH_RELOGIN_REQUIRED", + "This account session has expired. Please log in again.", + ); + } + + if code == -32601 { + return AppError::new( + "APP_SERVER_METHOD_UNSUPPORTED", + format!( + "`{method}` not supported by your codex CLI. Upgrade `codex` to the latest version." + ), + ); + } + + AppError::new( + "APP_SERVER_RPC_ERROR", + format!("`{method}` returned error {code}: {raw_message}"), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_rate_limits_routes_primary_5h_and_secondary_weekly() { + let payload = json!({ + "rateLimits": { + "primary": { + "usedPercent": 36.5, + "resetsAt": 1_715_000_000_i64, + "windowDurationMins": 300_i64, + }, + "secondary": { + "usedPercent": 7, + "resetsAt": 1_716_000_000_i64, + "windowDurationMins": 10080_i64, + } + } + }); + let quota = parse_rate_limits_response(&payload).expect("quota"); + assert_eq!(quota.five_hour.remaining_percent, Some(64)); + assert_eq!(quota.weekly.remaining_percent, Some(93)); + assert!(quota.five_hour.refresh_at.is_some()); + assert!(quota.weekly.refresh_at.is_some()); + } + + #[test] + fn parse_rate_limits_routes_by_window_minutes_when_weekly_in_primary_slot() { + let payload = json!({ + "rateLimits": { + "primary": { + "usedPercent": 12, + "resetsAt": 1_716_000_000_i64, + "windowDurationMins": 10080_i64, + }, + "secondary": null + } + }); + let quota = parse_rate_limits_response(&payload).expect("quota"); + assert_eq!(quota.weekly.remaining_percent, Some(88)); + assert!(quota.five_hour.remaining_percent.is_none()); + assert!(quota.five_hour.refresh_at.is_none()); + } + + #[test] + fn parse_rate_limits_returns_none_for_empty_payload() { + let payload = json!({ "rateLimits": null }); + assert!(parse_rate_limits_response(&payload).is_none()); + let payload = json!({ "rateLimits": { "primary": null, "secondary": null } }); + assert!(parse_rate_limits_response(&payload).is_none()); + } + + #[test] + fn extract_plan_type_handles_chatgpt_account() { + let result = json!({ + "account": { "type": "chatgpt", "email": "u@example.com", "planType": "plus" }, + "requiresOpenaiAuth": false + }); + assert_eq!(extract_plan_type(&result).as_deref(), Some("plus")); + } + + #[test] + fn extract_plan_type_returns_none_for_null_account() { + let result = json!({ "account": null, "requiresOpenaiAuth": true }); + assert!(extract_plan_type(&result).is_none()); + } + + #[test] + fn map_rpc_error_classifies_authentication_required_as_relogin() { + let error = json!({ + "code": -32600_i64, + "message": "codex account authentication required to read rate limits" + }); + let mapped = map_rpc_error("account/rateLimits/read", &error); + assert_eq!(mapped.error_code, "AUTH_REFRESH_RELOGIN_REQUIRED"); + } + + #[test] + fn map_rpc_error_classifies_method_not_found_as_unsupported() { + let error = json!({ "code": -32601_i64, "message": "method not found" }); + let mapped = map_rpc_error("account/read", &error); + assert_eq!(mapped.error_code, "APP_SERVER_METHOD_UNSUPPORTED"); + } + + #[test] + fn map_rpc_error_classifies_invalidated_token_message_as_relogin() { + let error = json!({ + "code": -32603_i64, + "message": "Your refresh token has already been used. Please try signing in again." + }); + let mapped = map_rpc_error("account/read", &error); + assert_eq!(mapped.error_code, "AUTH_REFRESH_RELOGIN_REQUIRED"); + } + + #[test] + fn map_rpc_error_falls_through_to_generic_for_unknown_codes() { + let error = json!({ "code": -32603_i64, "message": "something broke" }); + let mapped = map_rpc_error("account/read", &error); + assert_eq!(mapped.error_code, "APP_SERVER_RPC_ERROR"); + assert!(mapped.message.contains("something broke")); + } + + #[test] + fn quota_window_from_app_server_handles_integer_used_percent() { + let window = json!({ "usedPercent": 12, "resetsAt": 1_715_000_000_i64 }); + let mapped = quota_window_from_app_server(&window); + assert_eq!(mapped.remaining_percent, Some(88)); + assert!(mapped.refresh_at.is_some()); + } + + #[test] + fn map_rpc_error_classifies_token_invalidated_substring_as_relogin() { + // The legacy `codex exec` path used to surface this exact substring + // verbatim from the CLI's stderr; covering it here keeps that + // regression-catch alive after the test moved off the renamed + // `classify_auth_refresh_failure` helper. + let error = json!({ + "code": -32603_i64, + "message": "401 Unauthorized: token_invalidated" + }); + let mapped = map_rpc_error("account/read", &error); + assert_eq!(mapped.error_code, "AUTH_REFRESH_RELOGIN_REQUIRED"); + } + + #[test] + fn parse_rate_limits_keeps_window_when_only_used_percent_is_set() { + // `resetsAt` may be absent on some plans; partial data is still + // useful (the dashboard renders the percent without a reset + // time). Asserts the partial-data branch in + // `quota_window_from_app_server` remains live. + let payload = json!({ + "rateLimits": { + "primary": { "usedPercent": 25, "windowDurationMins": 300_i64 }, + "secondary": null, + } + }); + let quota = parse_rate_limits_response(&payload).expect("quota"); + assert_eq!(quota.five_hour.remaining_percent, Some(75)); + assert!(quota.five_hour.refresh_at.is_none()); + assert!(quota.weekly.remaining_percent.is_none()); + } + + #[test] + fn parse_rate_limits_skips_window_with_no_data_fields() { + // A window object with neither `usedPercent` nor `resetsAt` must + // be treated as missing — `any_data` should stay false and the + // function returns None instead of an empty-but-allocated quota. + let payload = json!({ + "rateLimits": { + "primary": { "windowDurationMins": 300_i64 }, + "secondary": { "windowDurationMins": 10080_i64 }, + } + }); + assert!(parse_rate_limits_response(&payload).is_none()); + } +} diff --git a/src-tauri/shared/runtime/login_runtime.rs b/src-tauri/shared/runtime/login_runtime.rs index 8433768..fe29348 100644 --- a/src-tauri/shared/runtime/login_runtime.rs +++ b/src-tauri/shared/runtime/login_runtime.rs @@ -287,11 +287,11 @@ mod tests { fs::write(runtime_codex_home.join("auth.json"), &self.auth_payload).unwrap(); Ok(()) } - fn run_codex_auth_refresh( + fn fetch_account_via_app_server( &self, _cli_codex_home: &Path, _runtime_codex_home: &Path, - ) -> AppResult<()> { + ) -> AppResult { unreachable!("not used in login_runtime tests") } fn sync_on_window_close(&self) -> AppResult<()> { @@ -425,7 +425,11 @@ mod tests { fn run_codex_login(&self, _: &Path, _: &Path) -> AppResult<()> { Ok(()) } - fn run_codex_auth_refresh(&self, _: &Path, _: &Path) -> AppResult<()> { + fn fetch_account_via_app_server( + &self, + _: &Path, + _: &Path, + ) -> AppResult { unreachable!() } fn sync_on_window_close(&self) -> AppResult<()> { diff --git a/src-tauri/shared/runtime/mod.rs b/src-tauri/shared/runtime/mod.rs index a6d53f6..2470a47 100644 --- a/src-tauri/shared/runtime/mod.rs +++ b/src-tauri/shared/runtime/mod.rs @@ -1,4 +1,5 @@ pub mod chatgpt_api; +pub mod codex_app_server; pub mod codex_cli_path; pub mod config; pub mod fs_ops; diff --git a/src-tauri/shared/runtime/switch_core.rs b/src-tauri/shared/runtime/switch_core.rs index a9c1988..3a5389b 100644 --- a/src-tauri/shared/runtime/switch_core.rs +++ b/src-tauri/shared/runtime/switch_core.rs @@ -127,11 +127,11 @@ mod tests { unreachable!("not used in switch_core tests") } - fn run_codex_auth_refresh( + fn fetch_account_via_app_server( &self, _cli_codex_home: &Path, _runtime_codex_home: &Path, - ) -> AppResult<()> { + ) -> AppResult { unreachable!("not used in switch_core tests") } diff --git a/src-tauri/win/runtime/process.rs b/src-tauri/win/runtime/process.rs index 00184a7..716bbad 100644 --- a/src-tauri/win/runtime/process.rs +++ b/src-tauri/win/runtime/process.rs @@ -10,13 +10,13 @@ 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::shared::codex_cli_path::CodexPathResolver; pub use crate::shared::codex_cli_path::{InstallState, RealCodexPathSource}; use crate::shared::login_cancel::wait_for_login_or_cancel; use super::paths::{get_codex_home, get_install_state_file}; -const AUTH_REFRESH_PROMPT: &str = "Reply with the single word OK."; const APP_PROCESS_NAME: &str = "Codex.exe"; const WINDOWS_INVOKABLE_SUFFIXES: [&str; 4] = ["cmd", "exe", "bat", "com"]; const WINDOWS_APPS_PATH_SEGMENT: &str = r"\microsoft\windowsapps\"; @@ -338,15 +338,13 @@ pub fn forward_to_real_codex(args: &[String], codex_home: Option<&Path>) -> AppR Ok(status.code().unwrap_or(1)) } -fn build_auth_refresh_command(real_codex_path: &Path, runtime_codex_home: &Path) -> Command { +fn build_app_server_command(real_codex_path: &Path, runtime_codex_home: &Path) -> Command { let mut command = Command::new(real_codex_path); - command.args([ - "exec", - "--skip-git-repo-check", - "--color", - "never", - AUTH_REFRESH_PROMPT, - ]); + // `codex app-server` is the upstream control-plane subcommand; it + // takes no sandbox/approval flags (those bind only to the TUI). See + // `openai/codex` `codex-rs/cli/src/main.rs` for the subcommand + // wiring. + command.arg("app-server"); hide_console_window(&mut command); command.current_dir(runtime_codex_home); command.env("CODEX_HOME", runtime_codex_home); @@ -373,72 +371,19 @@ fn build_login_command(real_codex_path: &Path, runtime_codex_home: &Path) -> Com command } -fn classify_auth_refresh_failure(message: &str) -> Option { - let normalized = message.to_ascii_lowercase(); - let requires_relogin = normalized.contains("token_invalidated") - || normalized.contains("refresh_token_reused") - || normalized.contains("authentication token has been invalidated") - || normalized.contains("refresh token has already been used") - || normalized.contains("please try signing in again") - || normalized.contains("please log out and sign in again"); - - if requires_relogin { - return Some(AppError::new( - "AUTH_REFRESH_RELOGIN_REQUIRED", - "This account session has expired. Please log in again.", - )); - } - - None -} - -pub fn run_codex_auth_refresh(cli_codex_home: &Path, runtime_codex_home: &Path) -> AppResult<()> { - let Some((real_codex_path, real_codex_source)) = - resolve_real_codex_cli_with_source(Some(cli_codex_home)) - else { +pub fn fetch_account_via_app_server( + cli_codex_home: &Path, + runtime_codex_home: &Path, +) -> AppResult { + let Some(real_codex_path) = resolve_real_codex_cli(Some(cli_codex_home)) else { return Err(AppError::new( "REAL_CODEX_NOT_FOUND", "Real Codex CLI path not found. Run `codex_switch_cli.exe install` first.", )); }; - let source_label = match real_codex_source { - RealCodexPathSource::UserOverride => "user override", - RealCodexPathSource::InstallState => "install_state.json", - RealCodexPathSource::Discovery => "CLI discovery", - }; - - let output = build_auth_refresh_command(&real_codex_path, runtime_codex_home) - .output() - .map_err(|error| { - AppError::new( - "AUTH_REFRESH_COMMAND_FAILED", - format!( - "Failed to start `codex exec` for auth refresh via {} (source: {}): {error}", - real_codex_path.display(), - source_label - ), - ) - })?; - - if output.status.success() { - return Ok(()); - } - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let message = if !stderr.is_empty() { - stderr - } else if !stdout.is_empty() { - stdout - } else { - "`codex exec` exited without a success status while refreshing auth.".to_string() - }; - - if let Some(error) = classify_auth_refresh_failure(&message) { - return Err(error); - } - - Err(AppError::new("AUTH_REFRESH_FAILED", message)) + let command = build_app_server_command(&real_codex_path, runtime_codex_home); + fetch_account_snapshot(command) } pub fn run_codex_login(cli_codex_home: &Path, runtime_codex_home: &Path) -> AppResult<()> { @@ -705,12 +650,12 @@ impl PlatformHooks for WindowsPlatformHooks { run_codex_login(cli_codex_home, runtime_codex_home) } - fn run_codex_auth_refresh( + fn fetch_account_via_app_server( &self, cli_codex_home: &Path, runtime_codex_home: &Path, - ) -> AppResult<()> { - run_codex_auth_refresh(cli_codex_home, runtime_codex_home) + ) -> AppResult { + fetch_account_via_app_server(cli_codex_home, runtime_codex_home) } fn sync_root_openai_base_url_for_profile( @@ -732,10 +677,9 @@ impl PlatformHooks for WindowsPlatformHooks { #[cfg(test)] mod tests { use super::{ - build_auth_refresh_command, classify_auth_refresh_failure, 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, - AUTH_REFRESH_PROMPT, WINDOWS_STORE_APP_ID, + 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, }; use crate::windows::env_guard; use serde_json::to_string_pretty; @@ -904,10 +848,10 @@ mod tests { } #[test] - fn build_auth_refresh_command_targets_runtime_codex_home() { - let runtime_codex_home = temp_codex_home("auth-refresh-command"); + fn build_app_server_command_targets_runtime_codex_home() { + let runtime_codex_home = temp_codex_home("app-server-command"); let real_codex_path = runtime_codex_home.join("bin").join("codex.exe"); - let command = build_auth_refresh_command(&real_codex_path, &runtime_codex_home); + let command = build_app_server_command(&real_codex_path, &runtime_codex_home); let args = command .get_args() @@ -924,16 +868,7 @@ mod tests { .collect::>(); assert_eq!(command.get_program(), real_codex_path.as_os_str()); - assert_eq!( - args, - vec![ - "exec".to_string(), - "--skip-git-repo-check".to_string(), - "--color".to_string(), - "never".to_string(), - AUTH_REFRESH_PROMPT.to_string(), - ] - ); + assert_eq!(args, vec!["app-server".to_string()]); assert_eq!( command.get_current_dir(), Some(runtime_codex_home.as_path()) @@ -956,33 +891,4 @@ mod tests { ); let _ = fs::remove_dir_all(&codex_home); } - - #[test] - fn classify_auth_refresh_failure_detects_invalidated_token_errors() { - let error = classify_auth_refresh_failure( - "401 Unauthorized: Your authentication token has been invalidated. Please try signing in again. code: token_invalidated", - ) - .unwrap(); - - assert_eq!(error.error_code, "AUTH_REFRESH_RELOGIN_REQUIRED"); - assert_eq!( - error.message, - "This account session has expired. Please log in again." - ); - } - - #[test] - fn classify_auth_refresh_failure_detects_reused_refresh_token_errors() { - let error = classify_auth_refresh_failure( - "Failed to refresh token: 401 Unauthorized: {\"error\":{\"message\":\"Your refresh token has already been used to generate a new access token. Please try signing in again.\",\"code\":\"refresh_token_reused\"}}", - ) - .unwrap(); - - assert_eq!(error.error_code, "AUTH_REFRESH_RELOGIN_REQUIRED"); - } - - #[test] - fn classify_auth_refresh_failure_ignores_non_auth_messages() { - assert!(classify_auth_refresh_failure("network timeout").is_none()); - } } diff --git a/src-tauri/win/runtime/refresh_runtime.rs b/src-tauri/win/runtime/refresh_runtime.rs index b8387e2..dcb79e7 100644 --- a/src-tauri/win/runtime/refresh_runtime.rs +++ b/src-tauri/win/runtime/refresh_runtime.rs @@ -3,6 +3,7 @@ use std::path::{Path, PathBuf}; use std::time::UNIX_EPOCH; use crate::errors::{AppError, AppResult}; +use crate::models::QuotaSummary; use crate::platform; use super::fs_ops::{copy_entry, remove_path}; @@ -14,38 +15,10 @@ use super::runtime_isolation::{ prune_runtime_extra_features, seed_runtime_shared_assets, RUNTIME_AUTH_FILENAME, RUNTIME_PROFILE_METADATA_FILENAME, }; -use super::session_files::{collect_jsonl_files, file_modified_ms}; -use super::session_usage::load_latest_local_quota_snapshot_since; const REFRESH_RUNTIME_PROFILE_FILES: [&str; 2] = [RUNTIME_AUTH_FILENAME, RUNTIME_PROFILE_METADATA_FILENAME]; -fn generated_refresh_session_files( - runtime_home: &Path, - min_source_mtime_ms: Option, -) -> Vec { - let sessions_root = runtime_home.join("sessions"); - if !sessions_root.is_dir() { - return Vec::new(); - } - - let mut files = Vec::new(); - collect_jsonl_files(&sessions_root, &mut files); - files - .into_iter() - .filter(|path| { - !min_source_mtime_ms - .is_some_and(|min_mtime| file_modified_ms(path).unwrap_or(0) < min_mtime) - }) - .collect() -} - -fn cleanup_generated_refresh_sessions(session_files: &[PathBuf]) { - for path in session_files { - let _ = remove_path(path); - } -} - fn ensure_refreshable_auth(auth_path: &Path) -> AppResult<()> { let raw = fs::read_to_string(auth_path).map_err(|error| { AppError::new( @@ -176,10 +149,9 @@ pub fn refresh_profile(profile_name: &str) -> AppResult { ensure_refreshable_auth(&auth_path)?; // Fast path for ChatGPT/OAuth profiles: read live quota from the same - // private backend endpoint the Codex CLI uses, instead of running a - // real `codex exec` round-trip just to provoke a session file write. - // Falls through to the legacy path on any failure so existing behavior - // is preserved as a safety net. + // private backend endpoint the Codex CLI uses, instead 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)? @@ -188,54 +160,51 @@ pub fn refresh_profile(profile_name: &str) -> AppResult { } } - let runtime_codex_home = prepare_refresh_runtime_home(&codex_home, &profile_dir)?; - let refresh_started_at_ms = std::time::SystemTime::now() - .duration_since(UNIX_EPOCH) - .ok() - .and_then(|value| u64::try_from(value.as_millis()).ok()); - platform::run_codex_auth_refresh(&codex_home, &runtime_codex_home)?; - let generated_session_files = - generated_refresh_session_files(&runtime_codex_home, refresh_started_at_ms); - let refreshed_quota = - load_latest_local_quota_snapshot_since(Some(&runtime_codex_home), refresh_started_at_ms); - cleanup_generated_refresh_sessions(&generated_session_files); + refresh_via_app_server(&profile_name, &codex_home, &profile_dir, &auth_path) +} + +/// Fallback path when the direct ChatGPT HTTP call failed. Uses +/// `codex app-server`'s JSON-RPC interface to fetch the same data +/// (account plan + rate limits) without spawning a real LLM session. +/// Replaces the historical `codex exec "Reply with the single word OK."` +/// hack which burned ~30–90 s and real user quota on every fallback. +fn refresh_via_app_server( + profile_name: &str, + codex_home: &Path, + profile_dir: &Path, + auth_path: &Path, +) -> AppResult { + let runtime_codex_home = prepare_refresh_runtime_home(codex_home, profile_dir)?; + let snapshot = platform::fetch_account_via_app_server(codex_home, &runtime_codex_home)?; let refreshed_auth_path = runtime_codex_home.join("auth.json"); if !refreshed_auth_path.is_file() { return Err(AppError::new( "AUTH_REFRESH_MISSING", - "Codex refresh completed but no auth.json was found in the refresh runtime home.", + "codex app-server completed but no auth.json was found in the refresh runtime home.", )); } + copy_entry(&refreshed_auth_path, auth_path)?; - copy_entry(&refreshed_auth_path, &auth_path)?; - // Legacy `codex exec` path: no fresher plan signal than the id_token - // codex just rotated. Quota update is independent — the D1 split - // means we issue two writes here on the with-snapshot branch, one - // for quota and one for the auth-derived plan slice. The cost is - // ~1KB of disk per refresh, the gain is that the plan path no - // longer has to thread quota arguments through call sites that - // don't care about quota. - sync_profile_metadata_from_auth(&profile_name, None, Some(&codex_home))?; - if let Some(snapshot) = refreshed_quota { - sync_profile_quota( - &profile_name, - snapshot.quota, - snapshot.source_mtime_ms, - Some(&codex_home), - )?; - } - super::profiles_index::load_profiles_index(Some(&codex_home))?; + let now_ms = std::time::SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|value| u64::try_from(value.as_millis()).ok()); + sync_profile_quota( + profile_name, + snapshot.quota.unwrap_or_else(QuotaSummary::default), + now_ms, + Some(codex_home), + )?; + sync_profile_metadata_from_auth(profile_name, snapshot.plan_type, Some(codex_home))?; + super::profiles_index::load_profiles_index(Some(codex_home))?; Ok(profile_dir.to_string_lossy().into_owned()) } #[cfg(test)] mod tests { - use super::{ - cleanup_generated_refresh_sessions, generated_refresh_session_files, - prepare_refresh_runtime_home, - }; + use super::prepare_refresh_runtime_home; use crate::windows::paths::get_refresh_runtime_dir; use std::fs; use std::path::PathBuf; @@ -322,36 +291,4 @@ mod tests { let _ = fs::remove_dir_all(&codex_home); } - #[test] - fn cleanup_generated_refresh_sessions_only_removes_new_sessions() { - let codex_home = temp_codex_home("cleanup-refresh-sessions"); - let runtime_home = get_refresh_runtime_dir(Some(&codex_home)); - let sessions_dir = runtime_home - .join("sessions") - .join("2026") - .join("04") - .join("08"); - fs::create_dir_all(&sessions_dir).unwrap(); - - let old_session = sessions_dir.join("rollout-old.jsonl"); - let new_session = sessions_dir.join("rollout-new.jsonl"); - fs::write(&old_session, "{\"type\":\"event_msg\"}\n").unwrap(); - std::thread::sleep(std::time::Duration::from_millis(5)); - let threshold = fs::metadata(&old_session) - .unwrap() - .modified() - .unwrap() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_millis() as u64 - + 1; - fs::write(&new_session, "{\"type\":\"event_msg\"}\n").unwrap(); - - let generated = generated_refresh_session_files(&runtime_home, Some(threshold)); - cleanup_generated_refresh_sessions(&generated); - - assert!(old_session.is_file()); - assert!(!new_session.exists()); - let _ = fs::remove_dir_all(&codex_home); - } } diff --git a/src-tauri/win/runtime/switch.rs b/src-tauri/win/runtime/switch.rs index 76a2205..637b390 100644 --- a/src-tauri/win/runtime/switch.rs +++ b/src-tauri/win/runtime/switch.rs @@ -161,11 +161,11 @@ mod tests { unreachable!("not used in switch tests") } - fn run_codex_auth_refresh( + fn fetch_account_via_app_server( &self, _cli_codex_home: &Path, _runtime_codex_home: &Path, - ) -> AppResult<()> { + ) -> AppResult { unreachable!("not used in switch tests") } From d22bff38b4e93e273d125595b323bc6e2e844348 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Sun, 10 May 2026 15:12:47 +0800 Subject: [PATCH 2/3] fix(refresh): block same-profile Refresh while login is in flight `handleRefreshProfile` was missing the symmetric counterpart of `handleLoginProfile`'s `isRefreshPending(profile)` guard. With the old code, clicking Refresh on a profile whose login was still in flight would push it onto the refresh queue; once the worker drained, refresh and login could race on writing the same per-profile `auth.json`. Atomic writes prevented torn data, but whichever lost the race got silently overwritten. Add `state.loginActiveProfile === profile` to the early-return. Cross-profile Refresh during a login is still allowed (different sandboxes, different `auth.json`s). Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 1 + src-tauri/shared/front/actions.ts | 11 ++++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 079f7cb..4cfc090 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - Refresh fallback no longer burns user quota or runs an LLM round-trip. The legacy `codex exec "Reply with the single word OK."` path took 30–90 s and consumed real ChatGPT quota whenever the direct HTTP refresh failed (slow network, transient 401, GFW). It is now replaced by `codex app-server`'s JSON-RPC `account/read` + `account/rateLimits/read`, which return the same plan + rate-limit data in well under a second without touching the model. Requires `codex` ≥ 0.130.0 on this fallback path; older CLIs surface `APP_SERVER_METHOD_UNSUPPORTED` so the user can upgrade. +- Closed a same-profile race between Refresh and Login: clicking Refresh on a card whose login is already in flight is now a no-op, mirroring the existing reverse guard (`handleLoginProfile` already blocks Login when the same profile has a Refresh pending). Cross-profile Refresh during a login remains allowed. - Hardened three macOS `discover_real_codex_cli_path_*` tests against parallel `HOME` / `PATH` env-var races by routing them through the existing `env_guard()` mutex. ## 1.5.7 - 2026-05-10 diff --git a/src-tauri/shared/front/actions.ts b/src-tauri/shared/front/actions.ts index 5b6c6ed..b06600a 100644 --- a/src-tauri/shared/front/actions.ts +++ b/src-tauri/shared/front/actions.ts @@ -294,7 +294,16 @@ async function drainRefreshQueue(): Promise { } function handleRefreshProfile(profile: string): void { - if (state.loading || isRefreshPending(profile)) { + // Mirror `handleLoginProfile`'s `isRefreshPending(profile)` guard in + // the opposite direction: when the same profile already has a login + // in flight, both flows would otherwise race on writing per-profile + // `auth.json`. Cross-profile refresh during a login is still allowed + // (different sandbox + different `auth.json`). + if ( + state.loading + || state.loginActiveProfile === profile + || isRefreshPending(profile) + ) { return; } From 1f848bf8b39237a08e9f5fb87a7b8a59f07a2ad6 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Sun, 10 May 2026 15:18:40 +0800 Subject: [PATCH 3/3] fix(refresh): widen app-server RPC timeouts to match HTTP_TIMEOUT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex bot raised a P2 on PR #33: the new fallback's hard 8 s deadline on every JSON-RPC call could fail on the exact "slow network" scenarios it is meant to recover from, since `account/read` with `refreshToken: true` chains an OAuth refresh round-trip + account read on the server side, and either leg can legitimately take 5–15 s on a high-latency link. Split the per-method budget so `account/read` gets 25 s (covers chained OAuth refresh + read) while the lighter `account/rateLimits/read` GET keeps a 15 s ceiling — matching `chatgpt_api.rs:109` `HTTP_TIMEOUT`. Session ceiling raised to 60 s with margin to cover handshake + both calls at their per-method maxima. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 1 + src-tauri/shared/runtime/codex_app_server.rs | 22 +++++++++++++++----- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cfc090..0edfc6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Refresh fallback no longer burns user quota or runs an LLM round-trip. The legacy `codex exec "Reply with the single word OK."` path took 30–90 s and consumed real ChatGPT quota whenever the direct HTTP refresh failed (slow network, transient 401, GFW). It is now replaced by `codex app-server`'s JSON-RPC `account/read` + `account/rateLimits/read`, which return the same plan + rate-limit data in well under a second without touching the model. Requires `codex` ≥ 0.130.0 on this fallback path; older CLIs surface `APP_SERVER_METHOD_UNSUPPORTED` so the user can upgrade. - Closed a same-profile race between Refresh and Login: clicking Refresh on a card whose login is already in flight is now a no-op, mirroring the existing reverse guard (`handleLoginProfile` already blocks Login when the same profile has a Refresh pending). Cross-profile Refresh during a login remains allowed. +- Tuned the app-server RPC fallback's per-method timeouts so slow-network users (the population the fallback exists for) don't trip `APP_SERVER_TIMEOUT` on a legitimately slow OAuth refresh. `account/read` (chains an OAuth refresh + account read server-side) now allows 25 s; `account/rateLimits/read` (single GET) gets 15 s to mirror `chatgpt_api`'s `HTTP_TIMEOUT`. Overall session ceiling raised to 60 s. - Hardened three macOS `discover_real_codex_cli_path_*` tests against parallel `HOME` / `PATH` env-var races by routing them through the existing `env_guard()` mutex. ## 1.5.7 - 2026-05-10 diff --git a/src-tauri/shared/runtime/codex_app_server.rs b/src-tauri/shared/runtime/codex_app_server.rs index 6819d83..0bc498b 100644 --- a/src-tauri/shared/runtime/codex_app_server.rs +++ b/src-tauri/shared/runtime/codex_app_server.rs @@ -35,10 +35,22 @@ use crate::models::{QuotaSummary, QuotaWindow}; use super::quota_routing::{slot_from_window_minutes, QuotaSlot}; const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10); -const REQUEST_TIMEOUT: Duration = Duration::from_secs(8); +/// `account/rateLimits/read` is one HTTPS GET to the same endpoint +/// `chatgpt_api.rs` polls with `HTTP_TIMEOUT = 15s`, so match that +/// budget — slow-network users are exactly the population this +/// fallback exists to serve. +const RATE_LIMITS_TIMEOUT: Duration = Duration::from_secs(15); +/// `account/read` with `refreshToken: true` chains an OAuth refresh +/// POST plus the account read itself. Each leg can legitimately take +/// the full 15s on a high-latency link, so allow a wider window than +/// the read-only call to avoid producing `APP_SERVER_TIMEOUT` in the +/// exact "slow network" case the fallback was meant to recover from. +const ACCOUNT_READ_TIMEOUT: Duration = Duration::from_secs(25); /// Hard upper bound on the whole RPC session. Defends against a child -/// that hangs after responding to one method but not the next. -const SESSION_TIMEOUT: Duration = Duration::from_secs(30); +/// that hangs after responding to one method but not the next. Sized +/// to comfortably cover handshake + account/read + rateLimits/read at +/// their per-method ceilings, with a small margin. +const SESSION_TIMEOUT: Duration = Duration::from_secs(60); /// Pulled from `account/rateLimits/read` + `account/read`. `quota` is /// `None` when the response carried no rate-limit data (typical for free @@ -310,7 +322,7 @@ fn read_account_plan(session: &mut Session) -> AppResult> { // tokens move within a single click rather than waiting on the // cached access_token to expire. let params = json!({ "refreshToken": true }); - let result = session.call("account/read", Some(params), REQUEST_TIMEOUT)?; + let result = session.call("account/read", Some(params), ACCOUNT_READ_TIMEOUT)?; Ok(extract_plan_type(&result)) } @@ -328,7 +340,7 @@ fn extract_plan_type(result: &Value) -> Option { } fn read_rate_limits(session: &mut Session) -> AppResult> { - let result = session.call("account/rateLimits/read", None, REQUEST_TIMEOUT)?; + let result = session.call("account/rateLimits/read", None, RATE_LIMITS_TIMEOUT)?; Ok(parse_rate_limits_response(&result)) }