diff --git a/README.md b/README.md index 8243f3dc..9863ac7f 100644 --- a/README.md +++ b/README.md @@ -351,10 +351,33 @@ Manage ChatGPT/Codex OAuth accounts locally and reuse them across provider profi cc-switch auth status # Show managed account status cc-switch auth login # Sign in with ChatGPT/Codex OAuth cc-switch auth list # List signed-in accounts -cc-switch auth default # Set the default account +cc-switch auth default # Set the managed/proxy default only +cc-switch auth use # Activate the account for standalone Codex cc-switch auth remove # Remove an account ``` +`auth use` writes the selected ChatGPT login to the effective `CODEX_HOME/auth.json`, +updates the current official Codex provider's auth snapshot (used by quota and temporary +launches), and makes that account the managed default. It leaves `config.toml`, MCP, +skills, and other provider snapshots unchanged. `auth status --json` distinguishes +`default_account_id` from `active_codex_account_id`. + +In **Settings → Managed accounts**, press **u** or choose **Use in Codex** from the +account menu. **Space / Set default** retains its existing managed/proxy-only meaning. +Restart Codex or launch a new `codex` process after activation; `/new` in an existing +process does not reload its login. Close old Codex processes before switching if they +might still refresh the shared login file. + +Activation requires the official direct Codex provider and file-based credentials +(the default). Third-party routing, proxy takeover, keyring/auto credential storage, +and conflicting forced-login/workspace settings are rejected with an explanation. +Existing refresh-token-only accounts are upgraded on activation through OAuth refresh; +if the server cannot provide a complete login, sign in again with `cc-switch auth login`. +Credentials refreshed by Codex are retained when switching away and back. Pending +credential-copy updates are persisted with the new token and retried after temporary +write failures, including across process restarts. Removing a +managed account or changing `auth default` does not log out the standalone Codex process. + ### 🛠️ MCP Server Management Manage Model Context Protocol servers across Claude, Codex, Gemini, OpenCode, and Hermes. diff --git a/README_ZH.md b/README_ZH.md index e111c5e1..aeadbe13 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -770,3 +770,11 @@ src-tauri/src/ - MIT © 原作者:Jason Young - CLI 分支维护者:saladday + +### Codex 官方账号切换 + +`cc-switch auth use ` 将托管账号用于下一次独立 Codex 启动,同时更新当前官方供应商的认证快照和托管默认账号,不改写 `config.toml`、MCP 或其他供应商。 +在设置的托管账号页面按 **u**,或从账号菜单选择 **在 Codex 中使用**。原有空格和 `auth default` 仍只设置托管默认账号。 +切换后请重启 Codex 或启动新进程;已有进程内的 `/new` 不会重新读取登录。切换前请关闭可能刷新共享登录文件的旧 Codex 进程。 +仅支持官方直连及文件凭据存储;第三方路由、代理接管、keyring/auto 和冲突的强制登录配置会被拒绝。旧账号首次使用时会通过 OAuth 刷新补全凭据,必要时需重新登录。 +`auth status --json` 分别显示 `default_account_id` 和 `active_codex_account_id`。 diff --git a/src-tauri/src/cli/commands/auth.rs b/src-tauri/src/cli/commands/auth.rs index 62913fd6..20e35367 100644 --- a/src-tauri/src/cli/commands/auth.rs +++ b/src-tauri/src/cli/commands/auth.rs @@ -33,6 +33,8 @@ pub enum AuthCommand { /// Account id to make default account_id: String, }, + /// Activate an account for the next standalone Codex process + Use { account_id: String }, /// Remove a ChatGPT account Remove { /// Account id to remove @@ -64,6 +66,15 @@ pub fn execute(cmd: AuthCommand) -> Result<(), AppError> { AuthCommand::List { json } => list_accounts(&runtime, json), AuthCommand::Login { json } => login(&runtime, json), AuthCommand::Default { account_id } => set_default(&runtime, &account_id), + AuthCommand::Use { account_id } => { + runtime + .block_on(crate::services::codex_account::use_account( + normalize_account_id(&account_id)?, + )) + .map_err(AppError::Message)?; + println!("{}", success("Codex account activated. Restart Codex or launch a new Codex process; /new does not reload login.")); + Ok(()) + } AuthCommand::Remove { account_id, yes } => remove_account(&runtime, &account_id, yes), AuthCommand::Logout { yes } => logout(&runtime, yes), } @@ -105,6 +116,12 @@ fn status(runtime: &tokio::runtime::Runtime, json: bool) -> Result<(), AppError> { println!("Migration: {error}"); } + println!( + "Codex active: {}", + crate::services::codex_account::active_account_id() + .as_deref() + .unwrap_or("-") + ); println!("Accounts: {}", status.accounts.len()); if !status.accounts.is_empty() { diff --git a/src-tauri/src/cli/commands/internal.rs b/src-tauri/src/cli/commands/internal.rs index de1025c8..fcfe0c27 100644 --- a/src-tauri/src/cli/commands/internal.rs +++ b/src-tauri/src/cli/commands/internal.rs @@ -38,15 +38,41 @@ pub fn execute(cmd: InternalCommand) -> Result<(), AppError> { codex_home, auth_only, } => { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| AppError::Message(e.to_string()))?; + // Capture's DB write, accepted-result read and managed import must be + // serialized together with activation and managed credential refresh. + let _mutation_guard = runtime + .block_on(crate::services::state_coordination::acquire_restore_mutation_guard()) + .map_err(AppError::Message)?; + let db = crate::Database::init()?; if auth_only { - return ProviderService::capture_codex_launch_auth( - &crate::Database::init()?, + ProviderService::capture_codex_launch_auth(&db, &provider_id, &codex_home)?; + } else { + let state = AppState::try_new()?; + ProviderService::capture_codex_temp_launch_snapshot( + &state, &provider_id, &codex_home, - ); + )?; } - let state = AppState::try_new()?; - ProviderService::capture_codex_temp_launch_snapshot(&state, &provider_id, &codex_home) + // Use the accepted DB snapshot, not a launch file rejected by optimistic concurrency. + let providers = db.get_all_providers("codex")?; + if let Some(provider) = providers + .get(&provider_id) + .filter(|p| ProviderService::codex_live_write_category(p) == Some("official")) + { + if let Some(auth) = provider.settings_config.get("auth") { + runtime + .block_on(crate::services::codex_account::capture_native_auth_locked( + auth, + )) + .map_err(AppError::Message)?; + } + } + Ok(()) } } } diff --git a/src-tauri/src/cli/tui/app/app_state.rs b/src-tauri/src/cli/tui/app/app_state.rs index 8a779da4..653e48f8 100644 --- a/src-tauri/src/cli/tui/app/app_state.rs +++ b/src-tauri/src/cli/tui/app/app_state.rs @@ -157,6 +157,10 @@ pub enum Action { ManagedAuthStartLogin { auth_provider: String, }, + ManagedAuthUse { + auth_provider: String, + account_id: String, + }, ManagedAuthSetDefault { auth_provider: String, account_id: String, diff --git a/src-tauri/src/cli/tui/app/content_config.rs b/src-tauri/src/cli/tui/app/content_config.rs index 1a3b18ef..8bc4ef97 100644 --- a/src-tauri/src/cli/tui/app/content_config.rs +++ b/src-tauri/src/cli/tui/app/content_config.rs @@ -1263,6 +1263,16 @@ impl App { KeyCode::Char('r') => Action::ManagedAuthRefresh { auth_provider: "codex_oauth".to_string(), }, + KeyCode::Char('u') => match self.switch_selected_managed_account() { + Action::ManagedAuthSetDefault { + auth_provider, + account_id, + } => Action::ManagedAuthUse { + auth_provider, + account_id, + }, + action => action, + }, KeyCode::Char(' ') => self.switch_selected_managed_account(), KeyCode::Enter => self.activate_managed_account_row(), _ => Action::None, diff --git a/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs b/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs index c86fd7b2..a6d220fd 100644 --- a/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs +++ b/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs @@ -816,7 +816,7 @@ impl App { return None; }; - *selected = (*selected).min(1); + *selected = (*selected).min(2); Some(match key.code { KeyCode::Esc => { @@ -828,7 +828,7 @@ impl App { Action::None } KeyCode::Down => { - *selected = (*selected + 1).min(1); + *selected = (*selected + 1).min(2); Action::None } KeyCode::Enter => { @@ -839,6 +839,10 @@ impl App { auth_provider, account_id, }, + 1 => Action::ManagedAuthUse { + auth_provider, + account_id, + }, _ => Action::ManagedAuthRemove { auth_provider, account_id, diff --git a/src-tauri/src/cli/tui/app/tests.rs b/src-tauri/src/cli/tui/app/tests.rs index 1050e5b0..c1262799 100644 --- a/src-tauri/src/cli/tui/app/tests.rs +++ b/src-tauri/src/cli/tui/app/tests.rs @@ -94,6 +94,7 @@ mod tests { authenticated: true, default_account_id: Some("acc-default".to_string()), migration_error: None, + active_codex_account_id: None, accounts: vec![ crate::services::ManagedAuthAccount { id: "acc-default".to_string(), @@ -15500,6 +15501,15 @@ mod tests { account_id: "acc-alt".to_string(), selected: 1, }; + assert!( + matches!(app.on_key(key(KeyCode::Enter), &data()), Action::ManagedAuthUse { account_id, .. } if account_id == "acc-alt") + ); + + app.overlay = Overlay::ManagedAccountActionPicker { + auth_provider: "codex_oauth".to_string(), + account_id: "acc-alt".to_string(), + selected: 2, + }; let action = app.on_key(key(KeyCode::Enter), &data()); assert!(matches!( action, diff --git a/src-tauri/src/cli/tui/mod.rs b/src-tauri/src/cli/tui/mod.rs index 1894e473..20357e8a 100644 --- a/src-tauri/src/cli/tui/mod.rs +++ b/src-tauri/src/cli/tui/mod.rs @@ -2220,6 +2220,7 @@ fn cache_invalidation_for_action(action: &Action) -> CacheInvalidation { | Action::UsageLogDetailRefresh { .. } | Action::ManagedAuthRefresh { .. } | Action::ManagedAuthStartLogin { .. } + | Action::ManagedAuthUse { .. } | Action::ManagedAuthSetDefault { .. } | Action::ManagedAuthRemove { .. } | Action::SkillsInstall { .. } @@ -3662,7 +3663,17 @@ pub fn run(app_override: Option) -> Result<(), AppError> { if let Some(auth) = managed_auth.as_ref() { while let Ok(msg) = auth.result_rx.try_recv() { frame_scheduler.mark_dirty(); + let activated = matches!( + &msg, + runtime_systems::ManagedAuthMsg::Used { result: Ok(_) } + ); handle_managed_auth_msg(&mut app, msg); + if activated { + match data::UiData::load(&app.app_type) { + Ok(fresh) => data = fresh, + Err(err) => app.push_toast(err.to_string(), ToastKind::Error), + } + } } } diff --git a/src-tauri/src/cli/tui/runtime_actions/mod.rs b/src-tauri/src/cli/tui/runtime_actions/mod.rs index 898f8936..f672bdb7 100644 --- a/src-tauri/src/cli/tui/runtime_actions/mod.rs +++ b/src-tauri/src/cli/tui/runtime_actions/mod.rs @@ -1128,6 +1128,10 @@ pub(crate) fn handle_action( Action::ManagedAuthStartLogin { auth_provider } => { settings::managed_auth_start_login(&mut ctx, auth_provider) } + Action::ManagedAuthUse { + auth_provider, + account_id, + } => settings::managed_auth_use(&mut ctx, auth_provider, account_id), Action::ManagedAuthSetDefault { auth_provider, account_id, diff --git a/src-tauri/src/cli/tui/runtime_actions/settings.rs b/src-tauri/src/cli/tui/runtime_actions/settings.rs index 5a166fbb..51d60b2b 100644 --- a/src-tauri/src/cli/tui/runtime_actions/settings.rs +++ b/src-tauri/src/cli/tui/runtime_actions/settings.rs @@ -486,6 +486,36 @@ pub(super) fn managed_auth_set_default( Ok(()) } +pub(super) fn managed_auth_use( + ctx: &mut RuntimeActionContext<'_>, + auth_provider: String, + account_id: String, +) -> Result<(), AppError> { + let Some(tx) = ctx.managed_auth_req_tx else { + ctx.app.push_toast( + texts::tui_toast_managed_auth_worker_unavailable( + texts::tui_error_managed_auth_worker_unavailable(), + ), + ToastKind::Warning, + ); + return Ok(()); + }; + + ctx.app.managed_auth_loading = true; + if let Err(err) = tx.send(ManagedAuthReq::Use { + auth_provider, + account_id, + }) { + ctx.app.managed_auth_loading = false; + ctx.app.push_toast( + texts::tui_toast_managed_auth_request_failed(&err.to_string()), + ToastKind::Warning, + ); + } + + Ok(()) +} + pub(super) fn managed_auth_remove( ctx: &mut RuntimeActionContext<'_>, auth_provider: String, diff --git a/src-tauri/src/cli/tui/runtime_systems/handlers.rs b/src-tauri/src/cli/tui/runtime_systems/handlers.rs index a7dc2311..1b6205c8 100644 --- a/src-tauri/src/cli/tui/runtime_systems/handlers.rs +++ b/src-tauri/src/cli/tui/runtime_systems/handlers.rs @@ -1194,6 +1194,7 @@ pub(crate) fn handle_managed_auth_msg(app: &mut App, msg: ManagedAuthMsg) { authenticated: true, default_account_id: Some(account.id.clone()), migration_error: None, + active_codex_account_id: None, accounts: vec![account.clone()], }); } @@ -1222,6 +1223,22 @@ pub(crate) fn handle_managed_auth_msg(app: &mut App, msg: ManagedAuthMsg) { ); } }, + ManagedAuthMsg::Used { result } => { + app.managed_auth_loading = false; + match result { + Ok(status) => { + app.managed_auth_status = Some(status); + app.push_toast( + crate::t!( + "Codex account activated. Restart Codex or launch a new process.", + "Codex 账号已启用。请重启 Codex 或启动新进程。" + ), + ToastKind::Success, + ); + } + Err(err) => app.push_toast(err, ToastKind::Error), + } + } ManagedAuthMsg::DefaultSet { auth_provider: _, account_id: _, diff --git a/src-tauri/src/cli/tui/runtime_systems/mod.rs b/src-tauri/src/cli/tui/runtime_systems/mod.rs index 4ed048c1..5aeb1e5b 100644 --- a/src-tauri/src/cli/tui/runtime_systems/mod.rs +++ b/src-tauri/src/cli/tui/runtime_systems/mod.rs @@ -14,8 +14,7 @@ pub(crate) use handlers::{ #[cfg(test)] pub(crate) use types::{ build_model_fetch_candidate_urls, model_fetch_strategy_for_field, - parse_model_ids_from_response, ManagedAuthMsg, ManagedSessionOutcome, ProxyMsg, QuotaMsg, - UpdateMsg, + parse_model_ids_from_response, ManagedSessionOutcome, ProxyMsg, QuotaMsg, UpdateMsg, }; pub(crate) use types::{ build_stream_check_result_lines, fetch_provider_models_for_tui, ModelFetchStrategy, @@ -36,3 +35,5 @@ pub(crate) use workers::{ start_speedtest_system, start_stream_check_system, start_update_system, start_usage_pricing_system, start_webdav_system, }; + +pub(crate) use types::ManagedAuthMsg; diff --git a/src-tauri/src/cli/tui/runtime_systems/types.rs b/src-tauri/src/cli/tui/runtime_systems/types.rs index 65f94bbb..86c5925a 100644 --- a/src-tauri/src/cli/tui/runtime_systems/types.rs +++ b/src-tauri/src/cli/tui/runtime_systems/types.rs @@ -590,6 +590,10 @@ pub(crate) enum ManagedAuthReq { auth_provider: String, device_code: String, }, + Use { + auth_provider: String, + account_id: String, + }, SetDefault { auth_provider: String, account_id: String, @@ -614,6 +618,9 @@ pub(crate) enum ManagedAuthMsg { device_code: String, result: Result, String>, }, + Used { + result: Result, + }, DefaultSet { #[allow(dead_code)] auth_provider: String, diff --git a/src-tauri/src/cli/tui/runtime_systems/workers.rs b/src-tauri/src/cli/tui/runtime_systems/workers.rs index 6efb91f2..bcb41ac6 100644 --- a/src-tauri/src/cli/tui/runtime_systems/workers.rs +++ b/src-tauri/src/cli/tui/runtime_systems/workers.rs @@ -719,6 +719,9 @@ fn managed_auth_worker_loop(rx: mpsc::Receiver, tx: mpsc::Sender device_code, result: Err(err.clone()), }, + ManagedAuthReq::Use { .. } => ManagedAuthMsg::Used { + result: Err(err.clone()), + }, ManagedAuthReq::SetDefault { auth_provider, account_id, @@ -772,6 +775,16 @@ fn managed_auth_worker_loop(rx: mpsc::Receiver, tx: mpsc::Sender result, }); } + ManagedAuthReq::Use { + auth_provider, + account_id, + } => { + let result = rt.block_on(async { + crate::services::codex_account::use_account(&account_id).await?; + crate::services::AuthService::get_status(&auth_provider).await + }); + let _ = tx.send(ManagedAuthMsg::Used { result }); + } ManagedAuthReq::SetDefault { auth_provider, account_id, diff --git a/src-tauri/src/cli/tui/ui/config.rs b/src-tauri/src/cli/tui/ui/config.rs index e05eae14..f25f3e96 100644 --- a/src-tauri/src/cli/tui/ui/config.rs +++ b/src-tauri/src/cli/tui/ui/config.rs @@ -3731,7 +3731,8 @@ fn managed_account_key_items(app: &App) -> Vec<(&'static str, &'static str)> { .as_ref() .is_some_and(|status| !status.accounts.is_empty()) { - items.push(("Space", texts::tui_key_switch())); + items.push(("Space", texts::tui_key_set_default())); + items.push(("u", crate::t!("Use in Codex", "在 Codex 中使用"))); items.push(("Enter", texts::tui_key_open())); } @@ -3937,6 +3938,25 @@ fn managed_account_detail_lines(app: &App, theme: &super::theme::Theme) -> Vec crate::services::ManagedAuthStatus { authenticated: true, default_account_id: Some("acc-default".to_string()), migration_error: None, + active_codex_account_id: None, accounts: vec![ crate::services::ManagedAuthAccount { id: "acc-default".to_string(), diff --git a/src-tauri/src/proxy/providers/codex_oauth_auth.rs b/src-tauri/src/proxy/providers/codex_oauth_auth.rs index 3fe7c09a..a4aae2c0 100644 --- a/src-tauri/src/proxy/providers/codex_oauth_auth.rs +++ b/src-tauri/src/proxy/providers/codex_oauth_auth.rs @@ -153,6 +153,10 @@ struct CodexAccountData { pub email: Option, pub refresh_token: String, pub authenticated_at: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub codex_auth: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pending_native_sync: Option, } impl From<&CodexAccountData> for ManagedAuthAccount { @@ -186,6 +190,8 @@ pub struct CodexOAuthManager { refresh_locks: std::sync::Arc>>>>, pending_device_codes: std::sync::Arc>>, storage_path: PathBuf, + #[cfg(test)] + token_endpoint: Option, } impl CodexOAuthManager { @@ -198,6 +204,8 @@ impl CodexOAuthManager { refresh_locks: std::sync::Arc::new(RwLock::new(HashMap::new())), pending_device_codes: std::sync::Arc::new(RwLock::new(HashMap::new())), storage_path, + #[cfg(test)] + token_endpoint: None, }; if let Err(e) = manager.load_from_disk_sync() { @@ -207,6 +215,26 @@ impl CodexOAuthManager { manager } + pub(crate) async fn lock_store(&self) -> Result { + let path = self.storage_path.with_extension("lock"); + tokio::task::spawn_blocking(move || { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let file = fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path)?; + file.lock()?; + Ok::<_, std::io::Error>(file) + }) + .await + .map_err(|_| CodexOAuthError::IoError("Account lock worker failed".into()))? + .map_err(Into::into) + } + pub async fn start_device_flow( &self, ) -> Result { @@ -260,6 +288,15 @@ impl CodexOAuthManager { pub async fn poll_for_token( &self, device_code: &str, + ) -> Result, CodexOAuthError> { + let _lock = self.lock_store().await?; + self.reload_from_disk().await?; + self.poll_for_token_locked(device_code).await + } + + pub(crate) async fn poll_for_token_locked( + &self, + device_code: &str, ) -> Result, CodexOAuthError> { let entry = { let pending = self.pending_device_codes.read().await; @@ -339,6 +376,8 @@ impl CodexOAuthManager { .add_account_internal(account_id, refresh_token, email) .await?; + self.remember_token_response(&account.id, &tokens, None) + .await?; Ok(Some(account)) } @@ -375,12 +414,20 @@ impl CodexOAuthManager { .map_err(|e| CodexOAuthError::ParseError(e.to_string())) } + fn refresh_endpoint(&self) -> &str { + #[cfg(test)] + if let Some(endpoint) = self.token_endpoint.as_deref() { + return endpoint; + } + OAUTH_TOKEN_URL + } + async fn refresh_with_token( &self, refresh_token: &str, ) -> Result { let response = crate::proxy::http_client::get() - .post(OAUTH_TOKEN_URL) + .post(self.refresh_endpoint()) .header("Content-Type", "application/x-www-form-urlencoded") .header("User-Agent", CODEX_USER_AGENT) .form(&[ @@ -410,10 +457,213 @@ impl CodexOAuthManager { .map_err(|e| CodexOAuthError::ParseError(e.to_string())) } + /// Retain the full credential bundle needed by a standalone Codex process. + async fn remember_token_response( + &self, + account_id: &str, + tokens: &OAuthTokenResponse, + copies: Option, + ) -> Result<(), CodexOAuthError> { + let mut accounts = self.accounts.write().await; + let account = accounts + .get_mut(account_id) + .ok_or_else(|| CodexOAuthError::AccountNotFound(account_id.to_string()))?; + if let Some(refresh) = &tokens.refresh_token { + account.refresh_token = refresh.clone(); + } + let id_token = tokens.id_token.clone().or_else(|| { + account + .codex_auth + .as_ref() + .and_then(|a| a.pointer("/tokens/id_token")) + .and_then(|v| v.as_str()) + .map(str::to_owned) + }); + account.pending_native_sync = copies; + account.codex_auth = Some(serde_json::json!({ + "auth_mode": "chatgpt", "OPENAI_API_KEY": null, + "tokens": {"account_id": account_id, "access_token": tokens.access_token, + "refresh_token": account.refresh_token, "id_token": id_token}, + "last_refresh": chrono::Utc::now().to_rfc3339(), + })); + drop(accounts); + self.save_to_disk().await + } + + /// Import credentials refreshed by Codex without adding unrelated live accounts. + pub(crate) async fn capture_codex_auth( + &self, + auth: &serde_json::Value, + ) -> Result<(), CodexOAuthError> { + if auth + .get("auth_mode") + .and_then(|v| v.as_str()) + .is_some_and(|mode| mode != "chatgpt") + { + return Ok(()); + } + let Some(id) = auth.pointer("/tokens/account_id").and_then(|v| v.as_str()) else { + return Ok(()); + }; + let Some(refresh) = auth + .pointer("/tokens/refresh_token") + .and_then(|v| v.as_str()) + .filter(|v| !v.is_empty()) + else { + return Ok(()); + }; + self.reconcile_native_copies(id).await?; + let mut accounts = self.accounts.write().await; + let Some(account) = accounts.get_mut(id) else { + return Ok(()); + }; + if let Some(saved) = account.codex_auth.as_ref() { + if saved == auth { + return Ok(()); + } + let timestamp = |v: &serde_json::Value| { + v.get("last_refresh") + .and_then(|x| x.as_str()) + .and_then(|x| chrono::DateTime::parse_from_rfc3339(x).ok()) + }; + if timestamp(saved).is_some() && timestamp(auth) < timestamp(saved) { + return Ok(()); + } + } + let copies = + crate::services::codex_account::NativeAuthCopies::read(id, &account.refresh_token) + .map_err(CodexOAuthError::IoError)?; + account.pending_native_sync = Some(copies); + account.refresh_token = refresh.to_string(); + account.codex_auth = Some(auth.clone()); + drop(accounts); + self.access_tokens.write().await.remove(id); + self.save_to_disk().await?; + self.reconcile_native_copies(id).await + } + + /// The new token and its pending destinations are committed together. Retrying + /// is safe after partial publication: destination hashes protect explicit edits. + async fn reconcile_native_copies(&self, account_id: &str) -> Result<(), CodexOAuthError> { + let pending = self + .accounts + .read() + .await + .get(account_id) + .and_then(|account| { + account + .pending_native_sync + .clone() + .zip(account.codex_auth.clone()) + }); + let Some((copies, auth)) = pending else { + return Ok(()); + }; + copies.publish(&auth).map_err(CodexOAuthError::IoError)?; + if let Some(account) = self.accounts.write().await.get_mut(account_id) { + account.pending_native_sync = None; + } + self.save_to_disk().await + } + + pub(crate) async fn contains_account(&self, account_id: &str) -> bool { + self.accounts.read().await.contains_key(account_id) + } + + pub(crate) async fn export_codex_auth( + &self, + account_id: &str, + ) -> Result { + self.reconcile_native_copies(account_id).await?; + let saved = self + .accounts + .read() + .await + .get(account_id) + .ok_or_else(|| CodexOAuthError::AccountNotFound(account_id.to_string()))? + .codex_auth + .clone(); + let usable = saved + .as_ref() + .and_then(stored_access_expiration) + .is_some_and(|exp| { + exp > chrono::Utc::now().timestamp_millis() + TOKEN_REFRESH_BUFFER_MS + }); + if !usable { + self.access_tokens.write().await.remove(account_id); + self.get_valid_token_for_account_locked(account_id).await?; + } + let accounts = self.accounts.read().await; + let auth = accounts + .get(account_id) + .and_then(|a| a.codex_auth.clone()) + .ok_or_else(|| { + CodexOAuthError::ParseError("Missing Codex credentials; sign in again.".into()) + })?; + if auth.pointer("/tokens/account_id").and_then(|v| v.as_str()) != Some(account_id) { + return Err(CodexOAuthError::ParseError( + "Stored Codex account identity mismatch".into(), + )); + } + for key in ["id_token", "access_token", "refresh_token"] { + if auth["tokens"][key].as_str().is_none_or(str::is_empty) { + return Err(CodexOAuthError::ParseError( + "Incomplete Codex credentials; sign in again.".into(), + )); + } + } + Ok(auth) + } + pub async fn get_valid_token_for_account( &self, account_id: &str, ) -> Result { + let _state_guard = crate::services::state_coordination::acquire_restore_mutation_guard() + .await + .map_err(CodexOAuthError::IoError)?; + let _lock = self.lock_store().await?; + self.reload_from_disk().await?; + self.get_valid_token_for_account_locked(account_id).await + } + + pub(crate) async fn get_valid_token_for_account_locked( + &self, + account_id: &str, + ) -> Result { + self.reconcile_native_copies(account_id).await?; + let live_path = crate::codex_config::get_codex_auth_path(); + let live_before = fs::read(&live_path).ok(); + if let Some(live) = live_before + .as_ref() + .and_then(|bytes| serde_json::from_slice::(bytes).ok()) + { + if live.pointer("/tokens/account_id").and_then(|v| v.as_str()) == Some(account_id) { + self.capture_codex_auth(&live).await?; + } + } + if let Some(auth) = self + .accounts + .read() + .await + .get(account_id) + .and_then(|a| a.codex_auth.as_ref()) + { + if let Some(expires_at_ms) = stored_access_expiration(auth) { + if expires_at_ms > chrono::Utc::now().timestamp_millis() + TOKEN_REFRESH_BUFFER_MS { + self.access_tokens.write().await.insert( + account_id.to_string(), + CachedAccessToken { + token: auth["tokens"]["access_token"] + .as_str() + .unwrap_or_default() + .to_string(), + expires_at_ms, + }, + ); + } + } + } { let tokens = self.access_tokens.read().await; if let Some(cached) = tokens.get(account_id) { @@ -443,18 +693,14 @@ impl CodexOAuthManager { .ok_or_else(|| CodexOAuthError::AccountNotFound(account_id.to_string()))? }; + let copies = + crate::services::codex_account::NativeAuthCopies::read(account_id, &refresh_token) + .map_err(CodexOAuthError::IoError)?; let new_tokens = self.refresh_with_token(&refresh_token).await?; - if let Some(new_refresh) = new_tokens.refresh_token.clone() { - if new_refresh != refresh_token { - let mut accounts = self.accounts.write().await; - if let Some(account) = accounts.get_mut(account_id) { - account.refresh_token = new_refresh; - } - drop(accounts); - self.save_to_disk().await?; - } - } + self.remember_token_response(account_id, &new_tokens, Some(copies)) + .await?; + self.reconcile_native_copies(account_id).await?; let access_token = new_tokens.access_token.clone(); let expires_at_ms = compute_expires_at_ms(new_tokens.expires_in); @@ -474,8 +720,13 @@ impl CodexOAuthManager { } pub async fn get_valid_token(&self) -> Result { + let _state_guard = crate::services::state_coordination::acquire_restore_mutation_guard() + .await + .map_err(CodexOAuthError::IoError)?; + let _lock = self.lock_store().await?; + self.reload_from_disk().await?; match self.resolve_default_account_id().await { - Some(id) => self.get_valid_token_for_account(&id).await, + Some(id) => self.get_valid_token_for_account_locked(&id).await, None => Err(CodexOAuthError::AccountNotFound( "无可用的 ChatGPT 账号".to_string(), )), @@ -483,17 +734,24 @@ impl CodexOAuthManager { } pub async fn default_account_id(&self) -> Option { - self.resolve_default_account_id().await + self.get_status().await.default_account_id } #[allow(dead_code)] pub async fn list_accounts(&self) -> Vec { - let accounts = self.accounts.read().await.clone(); - let default_id = self.resolve_default_account_id().await; - Self::sorted_accounts(&accounts, default_id.as_deref()) + self.get_status().await.accounts } pub async fn remove_account(&self, account_id: &str) -> Result<(), CodexOAuthError> { + let _lock = self.lock_store().await?; + self.reload_from_disk().await?; + self.remove_account_locked(account_id).await + } + + pub(crate) async fn remove_account_locked( + &self, + account_id: &str, + ) -> Result<(), CodexOAuthError> { { let mut accounts = self.accounts.write().await; if accounts.remove(account_id).is_none() { @@ -517,6 +775,15 @@ impl CodexOAuthManager { } pub async fn set_default_account(&self, account_id: &str) -> Result<(), CodexOAuthError> { + let _lock = self.lock_store().await?; + self.reload_from_disk().await?; + self.set_default_account_locked(account_id).await + } + + pub(crate) async fn set_default_account_locked( + &self, + account_id: &str, + ) -> Result<(), CodexOAuthError> { { let accounts = self.accounts.read().await; if !accounts.contains_key(account_id) { @@ -524,12 +791,22 @@ impl CodexOAuthManager { } } + let previous = self.default_account_id.read().await.clone(); *self.default_account_id.write().await = Some(account_id.to_string()); - self.save_to_disk().await?; + if let Err(error) = self.save_to_disk().await { + *self.default_account_id.write().await = previous; + return Err(error); + } Ok(()) } pub async fn clear_auth(&self) -> Result<(), CodexOAuthError> { + let _lock = self.lock_store().await?; + self.reload_from_disk().await?; + self.clear_auth_locked().await + } + + pub(crate) async fn clear_auth_locked(&self) -> Result<(), CodexOAuthError> { self.accounts.write().await.clear(); *self.default_account_id.write().await = None; self.access_tokens.write().await.clear(); @@ -549,6 +826,10 @@ impl CodexOAuthManager { } pub async fn get_status(&self) -> CodexOAuthStatus { + let _lock = self.lock_store().await.ok(); + if _lock.is_some() { + let _ = self.reload_from_disk().await; + } let accounts_map = self.accounts.read().await.clone(); let default_id = self.resolve_default_account_id().await; let account_list = Self::sorted_accounts(&accounts_map, default_id.as_deref()); @@ -579,6 +860,8 @@ impl CodexOAuthManager { email, refresh_token, authenticated_at: now, + codex_auth: None, + pending_native_sync: None, }; let account = ManagedAuthAccount::from(&data); @@ -725,6 +1008,31 @@ impl CodexOAuthManager { Ok(()) } + pub(crate) async fn reload_from_disk(&self) -> Result<(), CodexOAuthError> { + let content = match fs::read_to_string(&self.storage_path) { + Ok(content) => content, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + self.accounts.write().await.clear(); + *self.default_account_id.write().await = None; + self.access_tokens.write().await.clear(); + return Ok(()); + } + Err(e) => return Err(e.into()), + }; + let store: CodexOAuthStore = serde_json::from_str(&content) + .map_err(|_| CodexOAuthError::ParseError("Invalid managed account store".into()))?; + let mut accounts = self.accounts.write().await; + self.access_tokens.write().await.retain(|id, _| { + accounts + .get(id) + .zip(store.accounts.get(id)) + .is_some_and(|(old, new)| old.refresh_token == new.refresh_token) + }); + *accounts = store.accounts; + *self.default_account_id.write().await = store.default_account_id; + Ok(()) + } + async fn save_to_disk(&self) -> Result<(), CodexOAuthError> { let accounts = self.accounts.read().await.clone(); let default = self.resolve_default_account_id().await; @@ -771,6 +1079,14 @@ impl CodexOAuthManager { } } +fn stored_access_expiration(auth: &serde_json::Value) -> Option { + let token = auth.pointer("/tokens/access_token")?.as_str()?; + let part = token.split('.').nth(1)?; + let bytes = URL_SAFE_NO_PAD.decode(part).ok()?; + let claims: serde_json::Value = serde_json::from_slice(&bytes).ok()?; + claims.get("exp")?.as_i64()?.checked_mul(1000) +} + fn parse_interval(value: Option<&serde_json::Value>) -> u64 { let raw = match value { Some(serde_json::Value::Number(n)) => n.as_u64().unwrap_or(5), @@ -935,4 +1251,164 @@ mod tests { Some("acc-456") ); } + #[tokio::test] + async fn native_codex_credentials_keep_id_token_across_refresh_and_reload() { + let temp = tempfile::tempdir().unwrap(); + let manager = CodexOAuthManager::new(temp.path().to_path_buf()); + manager + .seed_account_for_tests("a", "refresh-a", None, None, None) + .await + .unwrap(); + manager + .remember_token_response( + "a", + &OAuthTokenResponse { + access_token: "access-a".into(), + refresh_token: Some("rotated-1".into()), + id_token: Some("id-a".into()), + expires_in: Some(3600), + }, + None, + ) + .await + .unwrap(); + manager + .remember_token_response( + "a", + &OAuthTokenResponse { + access_token: "access-b".into(), + refresh_token: Some("rotated-2".into()), + id_token: None, + expires_in: Some(3600), + }, + None, + ) + .await + .unwrap(); + let reloaded = CodexOAuthManager::new(temp.path().to_path_buf()); + let accounts = reloaded.accounts.read().await; + let account = &accounts["a"]; + assert_eq!(account.refresh_token, "rotated-2"); + let auth = account.codex_auth.as_ref().unwrap(); + assert_eq!(auth["tokens"]["refresh_token"], "rotated-2"); + assert_eq!(auth["tokens"]["access_token"], "access-b"); + assert_eq!(auth["tokens"]["id_token"], "id-a"); + } + + #[tokio::test] + async fn native_codex_capture_does_not_restore_an_older_refresh_token() { + let temp = tempfile::tempdir().unwrap(); + let _env = crate::test_support::TestEnvGuard::isolated(temp.path()); + let manager = CodexOAuthManager::new(temp.path().to_path_buf()); + manager + .seed_account_for_tests("a", "initial", None, None, None) + .await + .unwrap(); + let newer = serde_json::json!({"tokens":{"account_id":"a","refresh_token":"new"},"last_refresh":"2026-02-01T00:00:00Z"}); + manager.capture_codex_auth(&newer).await.unwrap(); + let older = serde_json::json!({"tokens":{"account_id":"a","refresh_token":"old"},"last_refresh":"2026-01-01T00:00:00Z"}); + manager.capture_codex_auth(&older).await.unwrap(); + assert_eq!(manager.accounts.read().await["a"].refresh_token, "new"); + assert_eq!( + manager.accounts.read().await["a"].codex_auth.as_ref(), + Some(&newer) + ); + } + async fn exercise_native_refresh(obstruct_live: bool) { + use serde_json::json; + let temp = tempfile::tempdir().unwrap(); + let _env = crate::test_support::TestEnvGuard::isolated(temp.path()); + let db = crate::Database::init().unwrap(); + let jwt = |exp| { + format!( + "e30.{}.sig", + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&json!({"exp":exp})).unwrap()) + ) + }; + let expired = jwt(1); + let fresh = jwt(chrono::Utc::now().timestamp() + 3600); + let auth = json!({"auth_mode":"chatgpt","OPENAI_API_KEY":null,"tokens":{ + "account_id":"a","access_token":expired,"refresh_token":"old","id_token":"id-a"},"last_refresh":"2026-01-01T00:00:00Z"}); + crate::config::write_json_file(&crate::codex_config::get_codex_auth_path(), &auth).unwrap(); + let mut provider = crate::Provider::with_id( + "official".into(), + "Official".into(), + json!({"auth":auth,"config":""}), + None, + ); + provider.category = Some("official".into()); + db.save_provider("codex", &provider).unwrap(); + let response = json!({"access_token":fresh,"refresh_token":"rotated","expires_in":3600}); + let live_path = crate::codex_config::get_codex_auth_path(); + let obstruction_path = live_path.clone(); + let app = axum::Router::new().route( + "/token", + axum::routing::post(move || async move { + if obstruct_live { + std::fs::rename(&obstruction_path, obstruction_path.with_extension("old")) + .unwrap(); + std::fs::create_dir(&obstruction_path).unwrap(); + } + axum::Json(response) + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let mut manager = CodexOAuthManager::new(crate::config::get_app_config_dir()); + manager.token_endpoint = Some(format!("http://{address}/token")); + manager + .seed_account_for_tests("a", "old", None, None, None) + .await + .unwrap(); + let result = manager.get_valid_token_for_account("a").await; + if obstruct_live { + assert!(result.is_err()); + assert!(manager.accounts.read().await["a"] + .pending_native_sync + .is_some()); + std::fs::remove_dir(&live_path).unwrap(); + std::fs::rename(live_path.with_extension("old"), &live_path).unwrap(); + } else { + assert_eq!(result.unwrap(), fresh); + } + server.abort(); + // Retry in a different manager after the server is gone. It must finish the + // persisted publication, not rotate the token again. + let mut recovered = CodexOAuthManager::new(crate::config::get_app_config_dir()); + recovered.token_endpoint = Some(format!("http://{address}/closed")); + assert_eq!( + recovered.get_valid_token_for_account("a").await.unwrap(), + fresh + ); + assert!(recovered.accounts.read().await["a"] + .pending_native_sync + .is_none()); + let live: serde_json::Value = + crate::config::read_json_file(&crate::codex_config::get_codex_auth_path()).unwrap(); + assert_eq!(live["tokens"]["refresh_token"], "rotated"); + assert_eq!(live["tokens"]["id_token"], "id-a"); + assert_eq!( + db.get_all_providers("codex").unwrap()["official"].settings_config["auth"], + live + ); + // A fresh manager must use persisted access credentials, not refresh again. + let mut restarted = CodexOAuthManager::new(crate::config::get_app_config_dir()); + restarted.token_endpoint = Some(format!("http://{address}/closed")); + assert_eq!( + restarted.get_valid_token_for_account("a").await.unwrap(), + fresh + ); + } + #[tokio::test] + async fn native_codex_http_refresh_updates_live_and_launch_copy_and_survives_restart() { + exercise_native_refresh(false).await; + } + + #[tokio::test] + async fn native_codex_refresh_recovers_failed_publication_in_a_new_process_manager() { + exercise_native_refresh(true).await; + } } diff --git a/src-tauri/src/services/auth.rs b/src-tauri/src/services/auth.rs index 6e28a81b..b221b61f 100644 --- a/src-tauri/src/services/auth.rs +++ b/src-tauri/src/services/auth.rs @@ -19,6 +19,8 @@ pub struct ManagedAuthStatus { pub authenticated: bool, pub default_account_id: Option, pub migration_error: Option, + #[serde(default)] + pub active_codex_account_id: Option, pub accounts: Vec, } @@ -133,6 +135,7 @@ impl AuthService { authenticated: status.authenticated, default_account_id: default_account_id.clone(), migration_error: None, + active_codex_account_id: super::codex_account::active_account_id(), accounts: status .accounts .into_iter() diff --git a/src-tauri/src/services/codex_account.rs b/src-tauri/src/services/codex_account.rs new file mode 100644 index 00000000..7e924dad --- /dev/null +++ b/src-tauri/src/services/codex_account.rs @@ -0,0 +1,575 @@ +//! Explicit activation of a managed account for the next standalone Codex process. +use crate::services::{CodexOAuthService, ProviderService}; +use crate::{app_config::AppType, database::Database}; +use serde_json::Value; + +pub fn active_account_id() -> Option { + let auth: Value = + serde_json::from_slice(&std::fs::read(crate::codex_config::get_codex_auth_path()).ok()?) + .ok()?; + if auth + .get("auth_mode") + .and_then(Value::as_str) + .is_some_and(|mode| mode != "chatgpt") + { + return None; + } + auth.pointer("/tokens/account_id")? + .as_str() + .map(str::to_owned) +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub(crate) struct NativeAuthCopies { + live: Option<(std::path::PathBuf, String)>, + providers: Vec<(String, String)>, +} + +impl NativeAuthCopies { + pub(crate) fn read(account_id: &str, refresh_token: &str) -> Result { + use sha2::{Digest, Sha256}; + let matches = |auth: &Value| { + auth.pointer("/tokens/account_id").and_then(Value::as_str) == Some(account_id) + && auth + .pointer("/tokens/refresh_token") + .and_then(Value::as_str) + == Some(refresh_token) + }; + let path = crate::codex_config::get_codex_auth_path(); + let live = match std::fs::read(&path) { + Ok(bytes) => Some(bytes), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => return Err(e.to_string()), + } + .filter(|bytes| { + serde_json::from_slice::(bytes) + .ok() + .is_some_and(|auth| matches(&auth)) + }) + .map(|bytes| (path, format!("{:x}", Sha256::digest(&bytes)))); + let db = crate::config::get_app_config_dir() + .join("cc-switch.db") + .exists() + .then(Database::init) + .transpose() + .map_err(|e| e.to_string())?; + let mut providers = Vec::new(); + if let Some(db) = &db { + for (id, provider) in db.get_all_providers("codex").map_err(|e| e.to_string())? { + if ProviderService::codex_live_write_category(&provider) == Some("official") { + if let Some(auth) = provider + .settings_config + .get("auth") + .filter(|auth| matches(auth)) + { + providers.push(( + id, + format!("{:x}", Sha256::digest(auth.to_string().as_bytes())), + )); + } + } + } + } + Ok(Self { live, providers }) + } + + pub(crate) fn publish(&self, auth: &Value) -> Result<(), String> { + use sha2::{Digest, Sha256}; + if let Some((path, expected)) = &self.live { + let current = match std::fs::read(path) { + Ok(bytes) => Some(bytes), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => return Err(e.to_string()), + }; + if let Some(bytes) = current { + if &format!("{:x}", Sha256::digest(&bytes)) == expected + && serde_json::from_slice::(&bytes).ok().as_ref() != Some(auth) + { + crate::config::atomic_write_private( + path, + &serde_json::to_vec_pretty(auth).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + } + } + } + if !self.providers.is_empty() { + let db = Database::init().map_err(|e| e.to_string())?; + let target = format!("{:x}", Sha256::digest(auth.to_string().as_bytes())); + let providers = db.get_all_providers("codex").map_err(|e| e.to_string())?; + for (id, source) in &self.providers { + if source == &target || !providers.contains_key(id) { + continue; + } + db.update_codex_provider_auth(id, auth, source) + .map_err(|e| e.to_string())?; + } + } + Ok(()) + } +} + +/// Import accepted launch changes while the caller holds the state mutation guard. +#[cfg(feature = "cli")] +pub(crate) async fn capture_native_auth_locked(auth: &Value) -> Result<(), String> { + let manager = CodexOAuthService::manager(); + let _lock = manager.lock_store().await.map_err(|e| e.to_string())?; + manager + .reload_from_disk() + .await + .map_err(|e| e.to_string())?; + manager + .capture_codex_auth(auth) + .await + .map_err(|e| e.to_string()) +} + +/// Only auth and the current official provider snapshot change. config.toml is never rewritten. +pub async fn use_account(account_id: &str) -> Result<(), String> { + let _guard = super::state_coordination::acquire_restore_mutation_guard().await?; + // The legacy path helper falls back to ~/.codex when CODEX_HOME is absent on disk. + // Create an explicit home first so activation never writes to that fallback. + if crate::settings::get_codex_override_dir().is_none() { + if let Some(home) = std::env::var_os("CODEX_HOME").filter(|v| !v.is_empty()) { + std::fs::create_dir_all(home).map_err(|e| e.to_string())?; + } + } + let db = Database::init().map_err(|e| e.to_string())?; + let config_path = crate::codex_config::get_codex_config_path(); + let config = match std::fs::read_to_string(&config_path) { + Ok(s) => s, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(e) => return Err(e.to_string()), + }; + let mut parsed: toml::Value = config + .parse() + .map_err(|_| "Invalid Codex config.toml".to_string())?; + // Codex resolves the selected profile before root settings. Normalize only our + // exact official history bucket in this validation copy, never the live file. + if let Some(profile) = parsed.get("profile").and_then(|v| v.as_str()) { + let overrides = parsed + .get("profiles") + .and_then(|v| v.get(profile)) + .cloned() + .ok_or_else(|| "The selected Codex profile does not exist.".to_string())?; + let overrides = overrides + .as_table() + .ok_or_else(|| "Invalid Codex profile".to_string())?; + // Of the settings validated here, only model_provider is a Codex + // profile field. Credential storage and forced-login constraints are + // root settings; unknown profile keys must not override them. + if let Some(model_provider) = overrides.get("model_provider") { + parsed + .as_table_mut() + .ok_or_else(|| "Invalid Codex config table".to_string())? + .insert("model_provider".into(), model_provider.clone()); + } + } + let normalized = crate::codex_config::strip_codex_unified_session_bucket( + &toml::to_string(&parsed).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + let parsed: toml::Value = normalized + .parse() + .map_err(|e: toml::de::Error| e.to_string())?; + if let Some(workspace) = parsed.get("forced_chatgpt_workspace_id") { + let allowed = match workspace { + toml::Value::String(id) => id == account_id, + toml::Value::Array(ids) if ids.iter().all(|id| id.as_str().is_some()) => { + ids.iter().any(|id| id.as_str() == Some(account_id)) + } + _ => return Err("Invalid forced_chatgpt_workspace_id.".into()), + }; + if !allowed { + return Err("The selected account does not match forced_chatgpt_workspace_id.".into()); + } + } + if parsed + .get("cli_auth_credentials_store") + .and_then(|v| v.as_str()) + .is_some_and(|v| v != "file") + { + return Err( + "Codex account activation requires cli_auth_credentials_store = \"file\".".into(), + ); + } + if parsed.get("forced_login_method").and_then(|v| v.as_str()) == Some("api") { + return Err("Codex is configured for API-key login.".into()); + } + if parsed + .get("model_provider") + .and_then(|v| v.as_str()) + .is_some_and(|v| v != "openai") + { + return Err( + "Select the official Codex provider before activating a ChatGPT account.".into(), + ); + } + if db + .get_proxy_config_for_app_or_default("codex") + .await + .map_err(|e| e.to_string())? + .enabled + { + return Err( + "Disable Codex proxy takeover before activating a standalone Codex account.".into(), + ); + } + let current = crate::settings::get_effective_current_provider(&db, &AppType::Codex) + .map_err(|e| e.to_string())?; + let providers = db.get_all_providers("codex").map_err(|e| e.to_string())?; + let provider = current.as_ref().and_then(|id| providers.get(id)); + if provider.is_some_and(|p| ProviderService::codex_live_write_category(p) != Some("official")) { + return Err( + "Select the official Codex provider before activating a ChatGPT account.".into(), + ); + } + let auth_path = crate::codex_config::get_codex_auth_path(); + let previous = match std::fs::read(&auth_path) { + Ok(bytes) => Some(bytes), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => return Err(e.to_string()), + }; + let previous_auth = previous + .as_ref() + .map(|bytes| { + serde_json::from_slice::(bytes) + .map_err(|_| "Invalid Codex auth.json".to_string()) + }) + .transpose()?; + let outgoing_id = previous_auth + .as_ref() + .and_then(|a| a.pointer("/tokens/account_id")) + .and_then(Value::as_str); + let manager = CodexOAuthService::manager(); + let _account_lock = manager.lock_store().await.map_err(|e| e.to_string())?; + manager + .reload_from_disk() + .await + .map_err(|e| e.to_string())?; + if !manager.contains_account(account_id).await { + return Err(format!("Managed account not found: {account_id}")); + } + for provider in providers + .values() + .filter(|p| ProviderService::codex_live_write_category(p) == Some("official")) + { + if let Some(auth) = provider.settings_config.get("auth").filter(|auth| { + let id = auth.pointer("/tokens/account_id").and_then(Value::as_str); + id == Some(account_id) || (outgoing_id.is_some() && id == outgoing_id) + }) { + manager + .capture_codex_auth(auth) + .await + .map_err(|e| e.to_string())?; + } + } + if let Some(live) = &previous_auth { + manager + .capture_codex_auth(live) + .await + .map_err(|e| e.to_string())?; + } + let auth = manager + .export_codex_auth(account_id) + .await + .map_err(|e| e.to_string())?; + let previous = match std::fs::read(&auth_path) { + Ok(bytes) => Some(bytes), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => return Err(e.to_string()), + }; + let provider_before = provider + .map(|p| { + db.get_all_providers("codex") + .map(|all| all.get(&p.id).cloned()) + }) + .transpose() + .map_err(|e| e.to_string())? + .flatten(); + let bytes = serde_json::to_vec_pretty(&auth).map_err(|e| e.to_string())?; + crate::config::atomic_write_private(&auth_path, &bytes).map_err(|e| e.to_string())?; + let result = async { + if let Some(provider) = provider { + let mut settings = provider.settings_config.clone(); + settings["auth"] = auth; + db.update_provider_settings_config("codex", &provider.id, &settings) + .map_err(|e| e.to_string())?; + } + manager + .set_default_account_locked(account_id) + .await + .map_err(|e| e.to_string()) + } + .await; + if let Err(error) = result { + let restore = match previous { + Some(bytes) => { + crate::config::atomic_write_private(&auth_path, &bytes).map_err(|e| e.to_string()) + } + None => std::fs::remove_file(&auth_path).map_err(|e| e.to_string()), + }; + let provider_restore = provider_before + .as_ref() + .map(|p| db.update_provider_settings_config("codex", &p.id, &p.settings_config)) + .transpose(); + if restore.is_err() || provider_restore.is_err() { + return Err(format!( + "{error}; rollback failed; inspect Codex account status before starting Codex." + )); + } + return Err(error); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + use serde_json::json; + + fn auth(id: &str, refresh: &str) -> Value { + let claims = URL_SAFE_NO_PAD.encode( + serde_json::to_vec(&json!({"exp": chrono::Utc::now().timestamp() + 3600})).unwrap(), + ); + json!({"auth_mode":"chatgpt", "OPENAI_API_KEY":null, + "tokens":{"account_id":id, "access_token":format!("e30.{claims}.sig"), "refresh_token":refresh, "id_token":"test-id-token"}, + "last_refresh":"2026-01-01T00:00:00Z"}) + } + + fn seed() -> (tempfile::TempDir, crate::test_support::TestEnvGuard) { + let temp = tempfile::tempdir().unwrap(); + let env = crate::test_support::TestEnvGuard::isolated(temp.path()); + let dir = crate::config::get_app_config_dir(); + std::fs::create_dir_all(&dir).unwrap(); + let mut accounts = serde_json::Map::new(); + for id in ["a", "b"] { + accounts.insert( + id.into(), + json!({"account_id":id,"refresh_token":format!("refresh-{id}"), + "authenticated_at":1,"codex_auth":auth(id,&format!("refresh-{id}"))}), + ); + } + crate::config::write_json_file( + &dir.join("codex_oauth_auth.json"), + &json!({"version":1,"default_account_id":"a","accounts":accounts}), + ) + .unwrap(); + crate::config::write_json_file( + &crate::codex_config::get_codex_auth_path(), + &auth("a", "refresh-a"), + ) + .unwrap(); + (temp, env) + } + + #[tokio::test] + async fn use_account_round_trip_preserves_config_and_rotated_credentials() { + let (_temp, _env) = seed(); + let config = "# Keep comments\nmodel = \"gpt-5\"\n[projects.\"/work\"]\ntrust_level = \"trusted\"\n[mcp_servers.demo]\ncommand = \"demo\"\n"; + crate::config::write_text_file(&crate::codex_config::get_codex_config_path(), config) + .unwrap(); + use_account("b").await.unwrap(); + assert_eq!(active_account_id().as_deref(), Some("b")); + let mut refreshed = auth("b", "rotated-b"); + refreshed["last_refresh"] = json!("2026-02-01T00:00:00Z"); + crate::config::write_json_file(&crate::codex_config::get_codex_auth_path(), &refreshed) + .unwrap(); + use_account("a").await.unwrap(); + use_account("b").await.unwrap(); + let live: Value = serde_json::from_slice( + &std::fs::read(crate::codex_config::get_codex_auth_path()).unwrap(), + ) + .unwrap(); + assert_eq!(live["tokens"]["refresh_token"], "rotated-b"); + assert_eq!( + std::fs::read_to_string(crate::codex_config::get_codex_config_path()).unwrap(), + config + ); + let fresh = crate::proxy::providers::codex_oauth_auth::CodexOAuthManager::new( + crate::config::get_app_config_dir(), + ); + assert_eq!(fresh.default_account_id().await.as_deref(), Some("b")); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(crate::codex_config::get_codex_auth_path()) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + } + } + + #[tokio::test] + async fn use_account_updates_only_current_official_snapshot() { + let (_temp, _env) = seed(); + let db = Database::init().unwrap(); + for id in ["official", "other"] { + let mut provider = crate::provider::Provider::with_id( + id.into(), + id.into(), + json!({"auth":auth("a","refresh-a"),"config":"model = \"gpt-5\""}), + None, + ); + provider.category = Some("official".into()); + db.save_provider("codex", &provider).unwrap(); + } + db.set_current_provider("codex", "official").unwrap(); + use_account("b").await.unwrap(); + let providers = db.get_all_providers("codex").unwrap(); + assert_eq!( + providers["official"].settings_config["auth"]["tokens"]["account_id"], + "b" + ); + assert_eq!( + providers["other"].settings_config["auth"]["tokens"]["account_id"], + "a" + ); + } + + #[tokio::test] + async fn use_account_rejects_incompatible_config_without_changing_live_auth() { + let (_temp, _env) = seed(); + let path = crate::codex_config::get_codex_auth_path(); + let original = std::fs::read(&path).unwrap(); + for config in [ + "model_provider = \"third-party\"", + "cli_auth_credentials_store = \"keyring\"", + "forced_login_method = \"api\"", + "forced_chatgpt_workspace_id = \"different\"", + r#"forced_chatgpt_workspace_id = ["a"]"#, + r#"forced_chatgpt_workspace_id = []"#, + "cli_auth_credentials_store = \"keyring\"\nprofile = \"p\"\n[profiles.p]\ncli_auth_credentials_store = \"file\"", + "forced_login_method = \"api\"\nprofile = \"p\"\n[profiles.p]\nforced_login_method = \"chatgpt\"", + "forced_chatgpt_workspace_id = \"a\"\nprofile = \"p\"\n[profiles.p]\nforced_chatgpt_workspace_id = \"b\"", + ] { + crate::config::write_text_file(&crate::codex_config::get_codex_config_path(), config) + .unwrap(); + assert!(use_account("b").await.is_err(), "{config}"); + assert_eq!(std::fs::read(&path).unwrap(), original); + } + } + + #[tokio::test] + async fn use_account_accepts_allowed_workspace_list() { + let (_temp, _env) = seed(); + let config = "forced_chatgpt_workspace_id = [\"a\", \"b\"]"; + crate::config::write_text_file(&crate::codex_config::get_codex_config_path(), config) + .unwrap(); + use_account("b").await.unwrap(); + assert_eq!(active_account_id().as_deref(), Some("b")); + assert_eq!( + std::fs::read_to_string(crate::codex_config::get_codex_config_path()).unwrap(), + config + ); + } + + #[tokio::test] + async fn use_account_missing_account_keeps_current_login() { + let (_temp, _env) = seed(); + assert!(use_account("missing").await.is_err()); + assert_eq!(active_account_id().as_deref(), Some("a")); + assert_eq!( + CodexOAuthService::manager() + .default_account_id() + .await + .as_deref(), + Some("a") + ); + } + + #[tokio::test] + async fn auth_default_does_not_activate_codex() { + let (_temp, _env) = seed(); + CodexOAuthService::set_default_account("b").await.unwrap(); + assert_eq!(active_account_id().as_deref(), Some("a")); + let status = crate::services::AuthService::get_status("codex_oauth") + .await + .unwrap(); + assert_eq!(status.default_account_id.as_deref(), Some("b")); + assert_eq!(status.active_codex_account_id.as_deref(), Some("a")); + } + #[tokio::test] + async fn use_account_restores_live_login_on_snapshot_failure() { + let (_temp, _env) = seed(); + let db = Database::init().unwrap(); + let existing: Value = + crate::config::read_json_file(&crate::codex_config::get_codex_auth_path()).unwrap(); + let mut provider = crate::provider::Provider::with_id( + "official".into(), + "Official".into(), + json!({"auth":existing,"config":""}), + None, + ); + provider.category = Some("official".into()); + db.save_provider("codex", &provider).unwrap(); + db.set_current_provider("codex", "official").unwrap(); + let conn = + rusqlite::Connection::open(crate::config::get_app_config_dir().join("cc-switch.db")) + .unwrap(); + conn.execute_batch("CREATE TRIGGER reject_auth BEFORE UPDATE OF settings_config ON providers BEGIN SELECT RAISE(ABORT, 'test failure'); END;").unwrap(); + let before = std::fs::read(crate::codex_config::get_codex_auth_path()).unwrap(); + assert!(use_account("b").await.is_err()); + assert_eq!( + std::fs::read(crate::codex_config::get_codex_auth_path()).unwrap(), + before + ); + assert_eq!( + CodexOAuthService::manager() + .default_account_id() + .await + .as_deref(), + Some("a") + ); + } + + #[tokio::test] + async fn use_account_is_seen_by_an_already_running_manager() { + let (_temp, _env) = seed(); + let cached = crate::proxy::providers::codex_oauth_auth::CodexOAuthManager::new( + crate::config::get_app_config_dir(), + ); + assert_eq!(cached.default_account_id().await.as_deref(), Some("a")); + use_account("b").await.unwrap(); + assert_eq!(cached.default_account_id().await.as_deref(), Some("b")); + } + #[tokio::test] + async fn use_account_supports_official_unified_history_without_rewriting_config() { + let (_temp, _env) = seed(); + let config = + crate::codex_config::inject_codex_unified_session_bucket("model = \"gpt-5\"\n") + .unwrap(); + crate::config::write_text_file(&crate::codex_config::get_codex_config_path(), &config) + .unwrap(); + use_account("b").await.unwrap(); + assert_eq!(active_account_id().as_deref(), Some("b")); + assert_eq!( + std::fs::read_to_string(crate::codex_config::get_codex_config_path()).unwrap(), + config + ); + } + + #[tokio::test] + async fn use_account_honors_selected_profile_and_ignores_unused_profiles() { + let (_temp, _env) = seed(); + let config = "profile = \"third\"\n[profiles.third]\nmodel_provider = \"third-party\"\n"; + crate::config::write_text_file(&crate::codex_config::get_codex_config_path(), config) + .unwrap(); + assert!(use_account("b").await.is_err()); + assert_eq!(active_account_id().as_deref(), Some("a")); + let config = "profile = \"official\"\nmodel_provider = \"third-party\"\n[profiles.official]\nmodel_provider = \"openai\"\n[profiles.unused]\nmodel_provider = \"third-party\"\n"; + crate::config::write_text_file(&crate::codex_config::get_codex_config_path(), config) + .unwrap(); + use_account("b").await.unwrap(); + assert_eq!(active_account_id().as_deref(), Some("b")); + assert_eq!( + std::fs::read_to_string(crate::codex_config::get_codex_config_path()).unwrap(), + config + ); + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index dd7f9f68..42e9121c 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -1,5 +1,6 @@ pub mod auth; pub mod balance; +pub mod codex_account; pub mod codex_history; pub mod codex_oauth; pub mod codex_oauth_models; diff --git a/src-tauri/tests/auth_use.rs b/src-tauri/tests/auth_use.rs new file mode 100644 index 00000000..704a755a --- /dev/null +++ b/src-tauri/tests/auth_use.rs @@ -0,0 +1,250 @@ +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use serde_json::{json, Value}; +use std::{fs, path::Path, process::Command}; +#[path = "support.rs"] +mod support; + +fn run(home: &Path, args: &[&str]) -> std::process::Output { + command(home, args).output().unwrap() +} + +fn command(home: &Path, args: &[&str]) -> Command { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_cc-switch")); + cmd.args(args) + .env("HOME", home) + .env("CC_SWITCH_CONFIG_DIR", home.join(".cc-switch")) + .env("CODEX_HOME", home.join(".codex")) + .env("CLAUDE_CONFIG_DIR", home.join(".claude")) + .env("XDG_CONFIG_HOME", home.join(".config")) + .env("XDG_RUNTIME_DIR", home.join(".runtime")) + .env("XDG_STATE_HOME", home.join(".state")); + cmd +} + +#[test] +fn auth_use_persists_across_cli_processes_and_status_reports_active_account() { + let _lock = support::lock_test_mutex(); + support::reset_test_fs(); + let home = support::ensure_test_home(); + fs::create_dir_all(home.join(".cc-switch")).unwrap(); + // Intentionally leave CODEX_HOME absent: activation must create the selected home. + let access = format!( + "e30.{}.sig", + URL_SAFE_NO_PAD.encode( + serde_json::to_vec(&json!({"exp":chrono::Utc::now().timestamp()+3600})).unwrap() + ) + ); + let mut accounts = serde_json::Map::new(); + for id in ["a", "b"] { + accounts.insert(id.into(), json!({"account_id":id,"refresh_token":format!("secret-refresh-{id}"),"authenticated_at":1, + "codex_auth":{"auth_mode":"chatgpt","OPENAI_API_KEY":null,"tokens":{ + "account_id":id,"refresh_token":format!("secret-refresh-{id}"),"access_token":access,"id_token":"secret-id-token"}, + "last_refresh":"2026-01-01T00:00:00Z"}})); + } + fs::write( + home.join(".cc-switch/codex_oauth_auth.json"), + serde_json::to_vec(&json!({"version":1,"accounts":accounts,"default_account_id":"a"})) + .unwrap(), + ) + .unwrap(); + for id in ["b", "a", "b"] { + let output = run(home, &["--app", "codex", "auth", "use", id]); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(!String::from_utf8_lossy(&output.stdout).contains("secret-")); + let output = run(home, &["auth", "status", "--json"]); + assert!(output.status.success()); + let status: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(status["active_codex_account_id"], id); + assert_eq!(status["default_account_id"], id); + let auth: Value = + serde_json::from_slice(&fs::read(home.join(".codex/auth.json")).unwrap()).unwrap(); + assert_eq!(auth["tokens"]["account_id"], id); + } + assert!(run(home, &["auth", "default", "a"]).status.success()); + let status: Value = + serde_json::from_slice(&run(home, &["auth", "status", "--json"]).stdout).unwrap(); + assert_eq!(status["default_account_id"], "a"); + assert_eq!(status["active_codex_account_id"], "b"); + let before = fs::read(home.join(".codex/auth.json")).unwrap(); + assert!(!run(home, &["auth", "use", "missing"]).status.success()); + assert_eq!(fs::read(home.join(".codex/auth.json")).unwrap(), before); + // Credentials refreshed by cc-switch start must survive a later global switch. + let db = cc_switch_lib::Database::init().unwrap(); + let current: Value = serde_json::from_slice(&before).unwrap(); + let mut provider = cc_switch_lib::Provider::with_id( + "official".into(), + "Official".into(), + json!({"auth":current,"config":""}), + None, + ); + provider.category = Some("official".into()); + db.save_provider("codex", &provider).unwrap(); + db.set_current_provider("codex", "official").unwrap(); + let launch = home.join("launch"); + fs::create_dir_all(&launch).unwrap(); + let mut refreshed = current; + refreshed["tokens"]["refresh_token"] = json!("secret-from-temp-launch"); + refreshed["last_refresh"] = json!(chrono::Utc::now().to_rfc3339()); + fs::write( + launch.join("auth.json"), + serde_json::to_vec(&refreshed).unwrap(), + ) + .unwrap(); + let output = run( + home, + &[ + "internal", + "capture-codex-temp", + "official", + launch.to_str().unwrap(), + ], + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let after_capture: Value = + serde_json::from_slice(&fs::read(home.join(".codex/auth.json")).unwrap()).unwrap(); + assert_eq!( + after_capture["tokens"]["refresh_token"], + "secret-from-temp-launch" + ); + for id in ["a", "b"] { + let output = run(home, &["auth", "use", id]); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + } + let restored: Value = + serde_json::from_slice(&fs::read(home.join(".codex/auth.json")).unwrap()).unwrap(); + assert_eq!( + restored["tokens"]["refresh_token"], + "secret-from-temp-launch" + ); + // A publication failure must remain recoverable across CLI process restarts. + let primary = db.get_all_providers("codex").unwrap()["official"].clone(); + let mut secondary = primary.clone(); + secondary.id = "secondary".into(); + secondary.name = "Secondary".into(); + db.save_provider("codex", &secondary).unwrap(); + let mut next = primary.settings_config["auth"].clone(); + next["tokens"]["refresh_token"] = json!("secret-after-partial-sync"); + next["last_refresh"] = json!(chrono::Utc::now().to_rfc3339()); + fs::write(launch.join("auth.json"), serde_json::to_vec(&next).unwrap()).unwrap(); + use sha2::{Digest, Sha256}; + fs::write( + launch.join(".auth-source"), + format!( + "{:x}", + Sha256::digest(primary.settings_config["auth"].to_string().as_bytes()) + ), + ) + .unwrap(); + let connection = rusqlite::Connection::open(home.join(".cc-switch/cc-switch.db")).unwrap(); + connection.execute_batch("CREATE TRIGGER fail_secondary BEFORE UPDATE OF settings_config ON providers WHEN OLD.id = 'secondary' BEGIN SELECT RAISE(ABORT, 'synthetic temporary failure'); END;").unwrap(); + let capture_args = [ + "internal", + "capture-codex-temp", + "official", + launch.to_str().unwrap(), + "--auth-only", + ]; + assert!(!run(home, &capture_args).status.success()); + let store: Value = + serde_json::from_slice(&fs::read(home.join(".cc-switch/codex_oauth_auth.json")).unwrap()) + .unwrap(); + assert!(store["accounts"]["b"].get("pending_native_sync").is_some()); + connection + .execute_batch("DROP TRIGGER fail_secondary;") + .unwrap(); + let retry = run(home, &capture_args); + assert!( + retry.status.success(), + "{}", + String::from_utf8_lossy(&retry.stderr) + ); + assert_eq!( + db.get_all_providers("codex").unwrap()["secondary"].settings_config["auth"]["tokens"] + ["refresh_token"], + "secret-after-partial-sync" + ); + let store: Value = + serde_json::from_slice(&fs::read(home.join(".cc-switch/codex_oauth_auth.json")).unwrap()) + .unwrap(); + assert!(store["accounts"]["b"].get("pending_native_sync").is_none()); + // Observe the child waiting on the lock before checking for premature writes. + #[cfg(target_os = "linux")] + { + let initial = db.get_all_providers("codex").unwrap()["official"] + .settings_config + .clone(); + let mut captured = initial["auth"].clone(); + captured["tokens"]["refresh_token"] = json!("secret-after-lock"); + captured["last_refresh"] = json!(chrono::Utc::now().to_rfc3339()); + fs::write( + launch.join("auth.json"), + serde_json::to_vec(&captured).unwrap(), + ) + .unwrap(); + fs::write( + launch.join(".auth-source"), + format!( + "{:x}", + Sha256::digest(initial["auth"].to_string().as_bytes()) + ), + ) + .unwrap(); + let lock = fs::OpenOptions::new() + .read(true) + .write(true) + .open(home.join(".cc-switch/state-mutation.lock")) + .unwrap(); + lock.lock().unwrap(); + let mut child = command(home, &capture_args) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + let mut blocked = false; + while std::time::Instant::now() < deadline { + let wait = + fs::read_to_string(format!("/proc/{}/wchan", child.id())).unwrap_or_default(); + if wait.contains("locks_lock") { + blocked = true; + break; + } + if child.try_wait().unwrap().is_some() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let unchanged = + db.get_all_providers("codex").unwrap()["official"].settings_config == initial; + lock.unlock().unwrap(); + if !blocked { + let _ = child.kill(); + } + let output = child.wait_with_output().unwrap(); + assert!(blocked, "capture did not reach the shared lock"); + assert!( + unchanged, + "capture wrote the provider before acquiring the shared lock" + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let live: Value = + serde_json::from_slice(&fs::read(home.join(".codex/auth.json")).unwrap()).unwrap(); + assert_eq!(live["tokens"]["refresh_token"], "secret-after-lock"); + } +}