fix(codex-oauth): make managed accounts safe & usable with the bare CLI

Addresses review feedback on #3879. These backend changes are deeply
intertwined across shared files, so they are committed together.

Refreshable managed auth (bare `codex` support):
- Write a full, native-shaped auth.json bundle (tokens.refresh_token + top-level
  last_refresh) instead of an access-only token, so the Codex CLI can self-refresh
  and the managed account keeps working past ~1h without a proxy.
- last_refresh reflects the access token's real obtained-at time (not write time),
  so the CLI doesn't treat a cached token as freshly refreshed.
- Read back the CLI-rotated refresh_token from ~/.codex/auth.json before writing
  (account_id-matched, chatgpt-mode guarded) so re-switching doesn't clobber the
  CLI's valid login with a stale refresh_token.

Never persist managed tokens at rest:
- Backfill of a managed provider always replaces live auth with the stored
  placeholder (even on marker mismatch), so native tokens can't leak into the
  provider's DB config.
- Sanitize managed auth out of Live backups on the initial/strict snapshot paths
  and in update_live_backup_from_provider_inner before serialization.

Concurrency / blocking:
- Drop the redundant outer Arc<RwLock<CodexOAuthManager>> (all methods are &self)
  so token refresh no longer holds a coarse lock across the network; give OAuth
  token/device requests a 30s timeout instead of the shared 600s client default.
- Add a persistence lock and linearize the access-token cache under a consistent
  accounts -> access_tokens order (existence-checked reads/writes; remove/clear
  clear the cache atomically) to prevent stale or resurrected cache entries.

Switch ordering:
- Preflight the managed token before committing the current provider on
  switch/add/update, so a failed token fetch can't leave DB/UI pointing at a
  provider whose live config was never written.

Cleanup:
- Remove the now-dead content-fingerprint marker machinery; managed writes are
  now shape-identical to a native login, so ownership is judged by account_id.

Known, documented limitations (B-scheme, narrow & recoverable): a remove+re-login
of the same account within an in-flight refresh window (ABA), a login authorized
after logout, and switch-away cleanup being unable to distinguish our write from a
user's native login of the same account.
This commit is contained in:
SaladDay
2026-07-01 15:19:12 +00:00
parent d3df16b5d1
commit c72bd49213
9 changed files with 763 additions and 380 deletions
+99 -217
View File
@@ -6,9 +6,7 @@ use crate::config::{
write_json_file, write_text_file,
};
use crate::error::AppError;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::fs;
use std::process::Command;
use toml_edit::DocumentMut;
@@ -96,14 +94,6 @@ fn codex_native_gateway_rejects_web_search(config_text: &str) -> bool {
false
}
const CODEX_MODEL_CATALOG_TEMPLATE_SLUG: &str = "gpt-5.5";
const CODEX_MANAGED_OAUTH_LIVE_AUTH_MARKER_FILENAME: &str = "codex_managed_oauth_live_auth.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CodexManagedOAuthLiveAuthMarker {
version: u32,
account_id: String,
access_token_sha256: String,
}
/// Which Codex tool surface the generated model catalog should target.
///
@@ -158,123 +148,96 @@ pub fn get_codex_auth_path() -> PathBuf {
get_codex_config_dir().join("auth.json")
}
fn get_codex_managed_oauth_live_auth_marker_path() -> PathBuf {
crate::config::get_app_config_dir().join(CODEX_MANAGED_OAUTH_LIVE_AUTH_MARKER_FILENAME)
}
fn sha256_hex(text: &str) -> String {
let digest = Sha256::digest(text.as_bytes());
digest
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
}
fn extract_codex_managed_oauth_auth(auth: &Value) -> Option<(String, String)> {
let auth_obj = auth.as_object()?;
if auth_obj
.keys()
.any(|key| !matches!(key.as_str(), "auth_mode" | "OPENAI_API_KEY" | "tokens"))
{
return None;
}
if auth.get("auth_mode").and_then(|value| value.as_str()) != Some("chatgpt") {
return None;
}
let api_key_is_clearable = auth
.get("OPENAI_API_KEY")
.is_none_or(|value| value.is_null() || value.as_str() == Some("PROXY_MANAGED"));
if !api_key_is_clearable {
return None;
}
let tokens = auth.get("tokens").and_then(|value| value.as_object())?;
// id_token 与原生浏览器登录一致,允许存在;但原生登录特有的
// refresh_token 等字段仍会被拒绝,从而保留「托管 vs 原生」的指纹区分。
if tokens
.keys()
.any(|key| !matches!(key.as_str(), "access_token" | "account_id" | "id_token"))
{
return None;
}
let account_id = tokens
.get("account_id")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|id| !id.is_empty())?;
let access_token = tokens
.get("access_token")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|token| !token.is_empty())?;
Some((account_id.to_string(), access_token.to_string()))
}
pub fn record_codex_managed_oauth_live_auth(auth: &Value) -> Result<(), AppError> {
let Some((account_id, access_token)) = extract_codex_managed_oauth_auth(auth) else {
return Ok(());
};
let marker = CodexManagedOAuthLiveAuthMarker {
version: 1,
account_id,
access_token_sha256: sha256_hex(&access_token),
};
crate::config::write_json_file(&get_codex_managed_oauth_live_auth_marker_path(), &marker)
}
pub fn codex_auth_matches_recorded_managed_oauth(
auth: &Value,
account_id: &str,
) -> Result<bool, AppError> {
let account_id = account_id.trim();
if account_id.is_empty() {
return Ok(false);
}
let Some((auth_account_id, access_token)) = extract_codex_managed_oauth_auth(auth) else {
return Ok(false);
};
if auth_account_id != account_id {
return Ok(false);
}
let marker_path = get_codex_managed_oauth_live_auth_marker_path();
let marker: CodexManagedOAuthLiveAuthMarker = match read_json_file(&marker_path) {
Ok(marker) => marker,
Err(err) => {
log::warn!(
"Failed to read Codex managed OAuth auth marker at {}: {err}",
marker_path.display()
);
return Ok(false);
}
};
Ok(marker.version == 1
&& marker.account_id == account_id
&& marker.access_token_sha256 == sha256_hex(&access_token))
}
/// 切走托管 provider 时删除其残留在 `~/.codex/auth.json` 的登录。
///
/// 已知边界(B 方案固有):完整可刷新 bundle 与原生浏览器登录形状一致,无法可靠
/// 区分「我们写的托管登录」与「用户手动 `codex login` 的同一账号」。因此这里按
/// account_id 内容判定;在「用户手动登录了同一账号,又切到一个不写 auth.json 的
/// config-only provider」这一罕见组合下,可能删除用户的原生登录。常见路径(切到
/// 会写 auth 的 provider)由 `clear_stale_managed_codex_live_auth` 的守卫覆盖。
pub fn clear_codex_live_auth_for_managed_account(account_id: &str) -> Result<(), AppError> {
let auth_path = get_codex_auth_path();
if auth_path.exists() {
let auth = read_json_file(&auth_path)?;
if !codex_auth_matches_recorded_managed_oauth(&auth, account_id)? {
let auth: Value = read_json_file(&auth_path)?;
// 完整可刷新 bundle 无法凭形状/哈希区分,按 account_id 内容判定是否为该
// 托管账号的登录;不是则不动(保护用户其它账号的原生登录)。
if !codex_live_auth_is_managed_chatgpt_login(&auth, account_id) {
return Ok(());
}
delete_file(&auth_path)?;
let _ = delete_file(&get_codex_managed_oauth_live_auth_marker_path());
}
Ok(())
}
/// 判断给定的 Codex `auth`(来自 live auth.json 或 Live 备份)是否是「属于
/// `account_id` 的 ChatGPT 托管登录」。
///
/// 托管账号写入的是**完整可刷新 bundle**,与原生浏览器登录形状一致(都含
/// refresh_token),且 Codex CLI 会轮换 token 使旧的 access_token 指纹失效,因此
/// 无法再凭形状/哈希区分。这里采用**基于内容的 account_id 判定**:只要是 chatgpt
/// 模式、且 `tokens.account_id` 命中托管账号,即视为该账号的登录。对同一账号的原生
/// 登录会被同等处理(同账号,无损)。
///
/// 用于 Live 备份剥离:避免把托管账号的可刷新 token 持久化进备份配置。
pub fn codex_live_auth_is_managed_chatgpt_login(auth: &Value, account_id: &str) -> bool {
let account_id = account_id.trim();
if account_id.is_empty() {
return false;
}
let Some(obj) = auth.as_object() else {
return false;
};
if obj.get("auth_mode").and_then(|value| value.as_str()) != Some("chatgpt") {
return false;
}
let api_key_clearable = obj
.get("OPENAI_API_KEY")
.is_none_or(|value| value.is_null() || value.as_str() == Some("PROXY_MANAGED"));
if !api_key_clearable {
return false;
}
obj.get("tokens")
.and_then(|tokens| tokens.as_object())
.and_then(|tokens| tokens.get("account_id"))
.and_then(|value| value.as_str())
.map(str::trim)
== Some(account_id)
}
/// 读回 Codex CLI 当前 `~/.codex/auth.json` 中属于 `account_id` 的 refresh_token /
/// id_token(仅当磁盘上的登录账号与之一致时)。
///
/// 用于切换回托管 provider 前,采纳 CLI 自行刷新时轮换出的最新 refresh_token,避免
/// 用陈腐 token 覆盖 CLI 的有效登录(“裸跑 codex” 反复切换场景)。
pub fn read_codex_live_auth_refresh_for_account(
account_id: &str,
) -> Option<(String, Option<String>)> {
let account_id = account_id.trim();
if account_id.is_empty() {
return None;
}
let auth_path = get_codex_auth_path();
if !auth_path.exists() {
return None;
}
let auth: Value = read_json_file(&auth_path).ok()?;
// 仅在磁盘上确是「该 account_id 的 ChatGPT 登录」时才采纳其 refresh_token
// 避免从非 chatgpt/异常 auth 里误取 token。
if !codex_live_auth_is_managed_chatgpt_login(&auth, account_id) {
return None;
}
let tokens = auth.get("tokens")?.as_object()?;
let refresh_token = tokens.get("refresh_token")?.as_str()?.trim().to_string();
if refresh_token.is_empty() {
return None;
}
let id_token = tokens
.get("id_token")
.and_then(|value| value.as_str())
.map(|token| token.to_string());
Some((refresh_token, id_token))
}
/// 获取 Codex config.toml 路径
pub fn get_codex_config_path() -> PathBuf {
get_codex_config_dir().join("config.toml")
@@ -1826,36 +1789,6 @@ pub fn remove_codex_toml_base_url_if(toml_str: &str, predicate: impl Fn(&str) ->
mod tests {
use super::*;
use serde_json::json;
use serial_test::serial;
use std::env;
struct TempHome {
#[allow(dead_code)]
dir: tempfile::TempDir,
original_test_home: Option<String>,
}
impl TempHome {
fn new() -> Self {
let dir = tempfile::tempdir().expect("create temp home");
let original_test_home = env::var("CC_SWITCH_TEST_HOME").ok();
env::set_var("CC_SWITCH_TEST_HOME", dir.path());
Self {
dir,
original_test_home,
}
}
}
impl Drop for TempHome {
fn drop(&mut self) {
match &self.original_test_home {
Some(value) => env::set_var("CC_SWITCH_TEST_HOME", value),
None => env::remove_var("CC_SWITCH_TEST_HOME"),
}
}
}
#[test]
fn unified_session_bucket_injects_for_empty_official_config() {
@@ -2016,86 +1949,35 @@ base_url = "https://single.example.com/v1"
}
#[test]
#[serial]
fn recorded_managed_oauth_marker_matches_only_same_account_and_token() {
let _home = TempHome::new();
let managed_auth = json!({
fn managed_chatgpt_login_matched_by_account_id_including_full_refresh_bundle() {
// ① 之后托管写入的是含 refresh_token 的完整 bundle;备份剥离必须凭 account_id
// 认出它,避免把可刷新 token 持久化进 Live 备份。
let full_bundle = json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": {
"access_token": "managed-token",
"account_id": "acct-managed"
}
});
let same_account_native_auth = json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": {
"access_token": "native-token",
"account_id": "acct-managed"
}
});
assert!(
!codex_auth_matches_recorded_managed_oauth(&managed_auth, "acct-managed")
.expect("marker check before record"),
"without a cc-switch marker, OAuth-shaped auth must not be treated as managed"
);
record_codex_managed_oauth_live_auth(&managed_auth).expect("record marker");
assert!(
codex_auth_matches_recorded_managed_oauth(&managed_auth, "acct-managed")
.expect("marker should match"),
"the recorded managed token should be clearable"
);
assert!(
!codex_auth_matches_recorded_managed_oauth(&same_account_native_auth, "acct-managed")
.expect("same-account native token should not match"),
"a later native login for the same account must not be cleared"
);
}
#[test]
#[serial]
fn recorded_managed_oauth_marker_tolerates_id_token_but_rejects_native_login_shape() {
let _home = TempHome::new();
// 托管写入:tokens 仅含 id_token + access_token + account_id
let managed_auth_with_id_token = json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": {
"id_token": "managed-id-token",
"access_token": "managed-token",
"account_id": "acct-managed"
}
});
// 原生浏览器登录:即使 access_token 巧合相同,也带有 refresh_token
// 与顶层 last_refresh,必须被判定为非托管、永不清除。
let native_login_auth = json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": {
"id_token": "native-id-token",
"access_token": "managed-token",
"refresh_token": "native-refresh-token",
"id_token": "id",
"access_token": "access",
"refresh_token": "refresh-secret",
"account_id": "acct-managed"
},
"last_refresh": "2026-01-01T00:00:00Z"
"last_refresh": "2026-01-02T03:04:05.000000000Z"
});
record_codex_managed_oauth_live_auth(&managed_auth_with_id_token).expect("record marker");
assert!(
codex_auth_matches_recorded_managed_oauth(&managed_auth_with_id_token, "acct-managed")
.expect("managed auth with id_token should match"),
"an id_token alongside access_token/account_id must still be treated as managed"
codex_live_auth_is_managed_chatgpt_login(&full_bundle, "acct-managed"),
"a full refreshable bundle for the managed account must be recognized"
);
assert!(
!codex_auth_matches_recorded_managed_oauth(&native_login_auth, "acct-managed")
.expect("native login should not match"),
"a real browser login (refresh_token + last_refresh) must never be treated as managed"
!codex_live_auth_is_managed_chatgpt_login(&full_bundle, "acct-other"),
"a login for a different account must not match"
);
// 非 chatgpt 模式(API key)不应命中。
let api_key_auth = json!({ "OPENAI_API_KEY": "sk-live" });
assert!(!codex_live_auth_is_managed_chatgpt_login(
&api_key_auth,
"acct-managed"
));
}
#[test]
+7 -7
View File
@@ -99,7 +99,7 @@ pub async fn auth_start_login(
Ok(map_device_code_response(auth_provider, response))
}
AUTH_PROVIDER_CODEX_OAUTH => {
let auth_manager = codex_state.0.read().await;
let auth_manager = &codex_state.0;
let response = auth_manager
.start_device_flow()
.await
@@ -137,7 +137,7 @@ pub async fn auth_poll_for_account(
}
}
AUTH_PROVIDER_CODEX_OAUTH => {
let auth_manager = codex_state.0.write().await;
let auth_manager = &codex_state.0;
match auth_manager.poll_for_token(&device_code).await {
Ok(account) => {
let default_account_id = auth_manager.get_status().await.default_account_id;
@@ -172,7 +172,7 @@ pub async fn auth_list_accounts(
.collect())
}
AUTH_PROVIDER_CODEX_OAUTH => {
let auth_manager = codex_state.0.read().await;
let auth_manager = &codex_state.0;
let status = auth_manager.get_status().await;
let default_account_id = status.default_account_id.clone();
Ok(status
@@ -212,7 +212,7 @@ pub async fn auth_get_status(
})
}
AUTH_PROVIDER_CODEX_OAUTH => {
let auth_manager = codex_state.0.read().await;
let auth_manager = &codex_state.0;
let status = auth_manager.get_status().await;
let default_account_id = status.default_account_id.clone();
Ok(ManagedAuthStatus {
@@ -250,7 +250,7 @@ pub async fn auth_remove_account(
.map_err(|e| e.to_string())
}
AUTH_PROVIDER_CODEX_OAUTH => {
let auth_manager = codex_state.0.write().await;
let auth_manager = &codex_state.0;
auth_manager
.remove_account(&account_id)
.await
@@ -277,7 +277,7 @@ pub async fn auth_set_default_account(
.map_err(|e| e.to_string())
}
AUTH_PROVIDER_CODEX_OAUTH => {
let auth_manager = codex_state.0.write().await;
let auth_manager = &codex_state.0;
auth_manager
.set_default_account(&account_id)
.await
@@ -300,7 +300,7 @@ pub async fn auth_logout(
auth_manager.clear_auth().await.map_err(|e| e.to_string())
}
AUTH_PROVIDER_CODEX_OAUTH => {
let auth_manager = codex_state.0.write().await;
let auth_manager = &codex_state.0;
auth_manager.clear_auth().await.map_err(|e| e.to_string())
}
_ => unreachable!(),
+7 -4
View File
@@ -10,10 +10,13 @@ use crate::services::model_fetch::FetchedModel;
use crate::services::subscription::{query_codex_quota, CredentialStatus, SubscriptionQuota};
use std::sync::Arc;
use tauri::State;
use tokio::sync::RwLock;
/// Codex OAuth 认证状态
pub struct CodexOAuthState(pub Arc<RwLock<CodexOAuthManager>>);
///
/// `CodexOAuthManager` 内部已使用细粒度锁且所有方法均为 `&self`,因此这里
/// 直接持有 `Arc`,不再包一层 `RwLock`——避免任一命令持有粗粒度锁跨网络刷新
/// 时阻塞其他命令(切换 / 认证中心操作 / token 读取)。
pub struct CodexOAuthState(pub Arc<CodexOAuthManager>);
/// 查询 Codex OAuth (ChatGPT Plus/Pro) 订阅额度
///
@@ -26,7 +29,7 @@ pub async fn get_codex_oauth_quota(
account_id: Option<String>,
state: State<'_, CodexOAuthState>,
) -> Result<SubscriptionQuota, String> {
let manager = state.0.read().await;
let manager = &state.0;
// 解析最终使用的账号 ID:显式 > 默认账号 > 无账号 (not_found)
let resolved = match account_id {
@@ -68,7 +71,7 @@ pub async fn get_codex_oauth_models(
account_id: Option<String>,
state: State<'_, CodexOAuthState>,
) -> Result<Vec<FetchedModel>, String> {
let manager = state.0.read().await;
let manager = &state.0;
let resolved = match account_id
.as_deref()
.map(str::trim)
+1 -3
View File
@@ -23,7 +23,6 @@ use super::{
ProxyError,
};
use crate::commands::{CodexOAuthState, CopilotAuthState};
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
use crate::proxy::providers::copilot_auth::CopilotAuthManager;
use crate::{
app_config::AppType,
@@ -1484,8 +1483,7 @@ impl RequestForwarder {
if auth.strategy == AuthStrategy::CodexOAuth {
if let Some(app_handle) = &self.app_handle {
let codex_state = app_handle.state::<CodexOAuthState>();
let codex_auth: tokio::sync::RwLockReadGuard<'_, CodexOAuthManager> =
codex_state.0.read().await;
let codex_auth = &codex_state.0;
// 从 provider.meta 获取关联的 ChatGPT 账号 ID
let account_id = provider
+233 -36
View File
@@ -23,6 +23,7 @@ use std::fs;
use std::io::Write;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{Mutex, RwLock};
use super::copilot_auth::{GitHubAccount, GitHubDeviceCodeResponse};
@@ -48,6 +49,10 @@ const DEVICE_REDIRECT_URI: &str = "https://auth.openai.com/deviceauth/callback";
/// Token 刷新提前量(毫秒)
const TOKEN_REFRESH_BUFFER_MS: i64 = 60_000;
/// OAuth token/device 端点的单请求超时。共享 HTTP client 默认 600s 超时是给
/// 大模型流式响应用的,对认证请求过长;网络卡住时应尽快失败而非长时间阻塞。
const OAUTH_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
/// Device Code 默认有效时长(秒),OpenAI 文档约定 15 分钟
const DEVICE_CODE_DEFAULT_EXPIRES_IN: u64 = 900;
@@ -160,6 +165,10 @@ struct CachedAccessToken {
token: String,
/// 过期时间戳(毫秒)
expires_at_ms: i64,
/// 获取(刷新)时间戳(毫秒)。用于写入托管 auth.json 的 `last_refresh`
/// 使其如实反映 access_token 的真实获取时间,而非写盘时刻——否则 Codex CLI
/// 会误判一个旧 token 是刚刷新的。
obtained_at_ms: i64,
}
impl CachedAccessToken {
@@ -225,6 +234,17 @@ struct CodexOAuthStore {
default_account_id: Option<String>,
}
/// 写入托管 Codex `auth.json` 所需的完整可刷新 token 束。
#[derive(Debug, Clone)]
pub(crate) struct ManagedTokenBundle {
pub access_token: String,
pub id_token: Option<String>,
pub refresh_token: String,
/// access_token 的真实获取时间,RFC3339 纳秒精度 + `Z`(与原生 auth.json 的
/// `last_refresh` 形状一致)。反映 token 何时真正刷新,而非写盘时刻。
pub last_refresh: String,
}
/// Codex OAuth 认证管理器(多账号)
pub struct CodexOAuthManager {
accounts: Arc<RwLock<HashMap<String, CodexAccountData>>>,
@@ -237,6 +257,10 @@ pub struct CodexOAuthManager {
/// 过期条目会在 start_device_flow 时被清理,防止放弃的登录流程导致无界增长
pending_device_codes: Arc<RwLock<HashMap<String, PendingDeviceCode>>>,
storage_path: PathBuf,
/// 持久化串行锁:`save_to_disk` 与 `clear_auth` 的「快照+写盘/删文件」都在此锁内
/// 完成。此前由外层 `RwLock<CodexOAuthManager>` 的写锁隐式串行化;去掉外层锁后
/// 需要它防止并发保存/清除交错,导致已删账号被旧快照复活。
storage_lock: Arc<Mutex<()>>,
}
impl CodexOAuthManager {
@@ -250,6 +274,7 @@ impl CodexOAuthManager {
refresh_locks: Arc::new(RwLock::new(HashMap::new())),
pending_device_codes: Arc::new(RwLock::new(HashMap::new())),
storage_path,
storage_lock: Arc::new(Mutex::new(())),
};
if let Err(e) = manager.load_from_disk_sync() {
@@ -272,6 +297,7 @@ impl CodexOAuthManager {
let response = crate::proxy::http_client::get()
.post(DEVICE_AUTH_USERCODE_URL)
.timeout(OAUTH_HTTP_TIMEOUT)
.header("Content-Type", "application/json")
.header("User-Agent", CODEX_USER_AGENT)
.json(&serde_json::json!({ "client_id": CODEX_CLIENT_ID }))
@@ -354,6 +380,7 @@ impl CodexOAuthManager {
let poll_response = crate::proxy::http_client::get()
.post(DEVICE_AUTH_TOKEN_URL)
.timeout(OAUTH_HTTP_TIMEOUT)
.header("Content-Type", "application/json")
.header("User-Agent", CODEX_USER_AGENT)
.json(&serde_json::json!({
@@ -408,21 +435,11 @@ impl CodexOAuthManager {
CodexOAuthError::ParseError("无法从 token 中提取 account_id".to_string())
})?;
// 缓存 access_token
{
let mut tokens_cache = self.access_tokens.write().await;
tokens_cache.insert(
account_id.clone(),
CachedAccessToken {
token: tokens.access_token.clone(),
expires_at_ms: compute_expires_at_ms(tokens.expires_in),
},
);
}
// 先落账号(写 accounts + 持久化),再按全局 accounts -> access_tokens 顺序、
// 在存在性确认下写 token 缓存,遵守「缓存条目只对应存在的账号」。
let account = self
.add_account_internal(
account_id,
account_id.clone(),
refresh_token,
email,
// 空字符串视为缺失,避免写出空的 id_token
@@ -430,6 +447,21 @@ impl CodexOAuthManager {
)
.await?;
{
let accounts = self.accounts.read().await;
if accounts.contains_key(&account_id) {
let mut tokens_cache = self.access_tokens.write().await;
tokens_cache.insert(
account_id.clone(),
CachedAccessToken {
token: tokens.access_token.clone(),
expires_at_ms: compute_expires_at_ms(tokens.expires_in),
obtained_at_ms: chrono::Utc::now().timestamp_millis(),
},
);
}
}
Ok(Some(account))
}
@@ -441,6 +473,7 @@ impl CodexOAuthManager {
) -> Result<OAuthTokenResponse, CodexOAuthError> {
let response = crate::proxy::http_client::get()
.post(OAUTH_TOKEN_URL)
.timeout(OAUTH_HTTP_TIMEOUT)
.header("Content-Type", "application/x-www-form-urlencoded")
.header("User-Agent", CODEX_USER_AGENT)
.form(&[
@@ -474,6 +507,7 @@ impl CodexOAuthManager {
) -> Result<OAuthTokenResponse, CodexOAuthError> {
let response = crate::proxy::http_client::get()
.post(OAUTH_TOKEN_URL)
.timeout(OAUTH_HTTP_TIMEOUT)
.header("Content-Type", "application/x-www-form-urlencoded")
.header("User-Agent", CODEX_USER_AGENT)
.form(&[
@@ -510,12 +544,36 @@ impl CodexOAuthManager {
&self,
account_id: &str,
) -> Result<String, CodexOAuthError> {
// 先检查缓存
Ok(self.resolve_valid_cached_token(account_id).await?.token)
}
/// 解析账号的有效缓存 token(含真实获取时间),必要时刷新。
///
/// 返回完整 `CachedAccessToken`,使 token 与其 `obtained_at_ms` 天然配套(写托管
/// auth.json 的 `last_refresh` 直接取用),避免分两次读缓存造成的错配。
///
/// 并发正确性:统一按 `accounts -> access_tokens` 顺序加锁——读/写 token 缓存前都
/// 在 `accounts` 读锁下确认账号仍存在。配合 `remove_account`/`clear_auth` 在
/// `accounts` 写锁内原子清缓存,杜绝「已删账号的 token 被写回或被继续返回」。
///
/// 已知未覆盖边界(ABA,极窄且可恢复):若一次刷新已用旧 refresh_token 在飞(≤30s
/// 超时),期间同一 `account_id` 被 remove 后又重新登录,则旧刷新返回时可能把旧
/// generation 的 token 写进新账号。需要 generation/version 校验才能彻底关闭;因触发
/// 需要「刷新在飞窗口内 remove+重加同一账号」且结果可通过重新登录恢复,暂不引入。
async fn resolve_valid_cached_token(
&self,
account_id: &str,
) -> Result<CachedAccessToken, CodexOAuthError> {
// 快路径:确认账号存在后读缓存
{
let accounts = self.accounts.read().await;
if !accounts.contains_key(account_id) {
return Err(CodexOAuthError::AccountNotFound(account_id.to_string()));
}
let tokens = self.access_tokens.read().await;
if let Some(cached) = tokens.get(account_id) {
if !cached.is_expiring_soon() {
return Ok(cached.token.clone());
return Ok(cached.clone());
}
}
}
@@ -525,12 +583,16 @@ impl CodexOAuthManager {
let refresh_lock = self.get_refresh_lock(account_id).await;
let _guard = refresh_lock.lock().await;
// double-check
// double-check(同样在 accounts 读锁下)
{
let accounts = self.accounts.read().await;
if !accounts.contains_key(account_id) {
return Err(CodexOAuthError::AccountNotFound(account_id.to_string()));
}
let tokens = self.access_tokens.read().await;
if let Some(cached) = tokens.get(account_id) {
if !cached.is_expiring_soon() {
return Ok(cached.token.clone());
return Ok(cached.clone());
}
}
}
@@ -574,21 +636,24 @@ impl CodexOAuthManager {
self.save_to_disk().await?;
}
let access_token = new_tokens.access_token.clone();
let expires_at_ms = compute_expires_at_ms(new_tokens.expires_in);
let cached = CachedAccessToken {
token: new_tokens.access_token.clone(),
expires_at_ms: compute_expires_at_ms(new_tokens.expires_in),
obtained_at_ms: chrono::Utc::now().timestamp_millis(),
};
// 在 accounts 读锁下确认账号仍存在,再写缓存:与 remove/clear(持 accounts
// 写锁并原子清缓存)互斥,杜绝把已删账号的 token 写回缓存。
{
let accounts = self.accounts.read().await;
if !accounts.contains_key(account_id) {
return Err(CodexOAuthError::AccountNotFound(account_id.to_string()));
}
let mut tokens = self.access_tokens.write().await;
tokens.insert(
account_id.to_string(),
CachedAccessToken {
token: access_token.clone(),
expires_at_ms,
},
);
tokens.insert(account_id.to_string(), cached.clone());
}
Ok(access_token)
Ok(cached)
}
/// 获取指定账号的有效 access_token 与 id_token(必要时自动刷新)
@@ -609,6 +674,89 @@ impl CodexOAuthManager {
Ok((access_token, id_token))
}
/// 获取写入托管 Codex `auth.json` 所需的完整可刷新 token 束
/// access_token + id_token + refresh_token)。
///
/// 与仅返回 access_token 不同:写入 Codex CLI 的 auth.json 必须携带
/// refresh_token,否则 CLI 在 access_token 过期后无法自刷新(详见托管直连
/// 场景 “裸跑 codex”)。
pub(crate) async fn get_valid_token_bundle_for_account(
&self,
account_id: &str,
) -> Result<ManagedTokenBundle, CodexOAuthError> {
// access_token 与其获取时间来自同一次解析(缓存命中即同一条目、刷新即新铸),
// 天然配套,杜绝「旧 token + 新时间戳」的错配。
let cached = self.resolve_valid_cached_token(account_id).await?;
let last_refresh =
chrono::DateTime::<chrono::Utc>::from_timestamp_millis(cached.obtained_at_ms)
.unwrap_or_else(chrono::Utc::now)
.to_rfc3339_opts(chrono::SecondsFormat::Nanos, true);
let (id_token, refresh_token) = {
let accounts = self.accounts.read().await;
let account = accounts
.get(account_id)
.ok_or_else(|| CodexOAuthError::AccountNotFound(account_id.to_string()))?;
(account.id_token.clone(), account.refresh_token.clone())
};
Ok(ManagedTokenBundle {
access_token: cached.token,
id_token,
refresh_token,
last_refresh,
})
}
/// 采纳(读回)Codex CLI 轮换后的 refresh_token / id_token。
///
/// 托管账号以「完整 bundle」写入 auth.json 后,Codex CLI 会自行刷新并把新的
/// refresh_token 回写 auth.json。切换回该 provider 前调用本方法,把盘上的最新
/// refresh_token 采纳进本地存储,避免用陈腐 token 覆盖 CLI 的有效登录。
///
/// 仅当账号确由本 manager 托管、且值确有变化时才更新并落盘;返回是否更新。
pub async fn adopt_account_refresh_token(
&self,
account_id: &str,
refresh_token: String,
id_token: Option<String>,
) -> Result<bool, CodexOAuthError> {
let refresh_token = refresh_token.trim().to_string();
if refresh_token.is_empty() {
return Ok(false);
}
// 与该账号的刷新串行化:若一个 refresh 正持旧 refresh_token 在飞,避免它返回后
// 覆盖我们刚采纳的 CLI 轮换值。
let refresh_lock = self.get_refresh_lock(account_id).await;
let _guard = refresh_lock.lock().await;
let mut changed = false;
{
let mut accounts = self.accounts.write().await;
let Some(account) = accounts.get_mut(account_id) else {
// 不是本 manager 托管的账号:不接管、不落盘。
return Ok(false);
};
if account.refresh_token != refresh_token {
account.refresh_token = refresh_token;
changed = true;
}
if let Some(id_token) = id_token.filter(|token| !token.trim().is_empty()) {
if account.id_token.as_deref() != Some(id_token.as_str()) {
account.id_token = Some(id_token);
changed = true;
}
}
// 采纳了 CLI 轮换后的 refresh_token:与之配套的旧 access_token 可能已被
// 服务端提前失效。在同一 accounts 写锁内(accounts -> access_tokens 顺序)
// 清缓存,避免释放锁后被快路径读到旧 token;下次按新 refresh_token 重取。
if changed {
self.access_tokens.write().await.remove(account_id);
}
}
if changed {
self.save_to_disk().await?;
}
Ok(changed)
}
/// 获取默认账号的有效 token
pub async fn get_valid_token(&self) -> Result<String, CodexOAuthError> {
match self.resolve_default_account_id().await {
@@ -636,15 +784,13 @@ impl CodexOAuthManager {
log::info!("[CodexOAuth] 移除账号: {account_id}");
{
// 在 accounts 写锁内原子清除该账号的 token 缓存(accounts -> access_tokens
// 顺序),确保不存在「账号已删但缓存仍在」的窗口。
let mut accounts = self.accounts.write().await;
if accounts.remove(account_id).is_none() {
return Err(CodexOAuthError::AccountNotFound(account_id.to_string()));
}
}
{
let mut tokens = self.access_tokens.write().await;
tokens.remove(account_id);
self.access_tokens.write().await.remove(account_id);
}
{
let mut locks = self.refresh_locks.write().await;
@@ -683,18 +829,21 @@ impl CodexOAuthManager {
pub async fn clear_auth(&self) -> Result<(), CodexOAuthError> {
log::info!("[CodexOAuth] 清除所有认证");
// 与 save_to_disk 共用持久化锁:确保「清内存 + 删文件」相对于并发保存原子,
// 不会被一个持有旧快照的 save 复活已清除的账号。
let _persist = self.storage_lock.lock().await;
{
// 在 accounts 写锁内原子清除 accounts 与 token 缓存(accounts ->
// access_tokens 顺序),杜绝「账号已清但缓存仍在」及并发 refresh 回填。
let mut accounts = self.accounts.write().await;
accounts.clear();
self.access_tokens.write().await.clear();
}
{
let mut default = self.default_account_id.write().await;
*default = None;
}
{
let mut tokens = self.access_tokens.write().await;
tokens.clear();
}
{
let mut locks = self.refresh_locks.write().await;
locks.clear();
@@ -757,6 +906,7 @@ impl CodexOAuthManager {
CachedAccessToken {
token: access_token.to_string(),
expires_at_ms: chrono::Utc::now().timestamp_millis() + 3_600_000,
obtained_at_ms: chrono::Utc::now().timestamp_millis(),
},
);
@@ -937,6 +1087,9 @@ impl CodexOAuthManager {
}
async fn save_to_disk(&self) -> Result<(), CodexOAuthError> {
// 串行化「快照 + 写盘」:在持久化锁内取快照,确保并发保存/清除不会用
// 陈旧快照覆盖,避免已删账号被复活。
let _persist = self.storage_lock.lock().await;
let accounts = self.accounts.read().await.clone();
let default = self.resolve_default_account_id().await;
@@ -1093,6 +1246,7 @@ mod tests {
let expiring = CachedAccessToken {
token: "t".to_string(),
expires_at_ms: now + 30_000,
obtained_at_ms: now,
};
assert!(expiring.is_expiring_soon());
@@ -1100,6 +1254,7 @@ mod tests {
let valid = CachedAccessToken {
token: "t".to_string(),
expires_at_ms: now + 3_600_000,
obtained_at_ms: now,
};
assert!(!valid.is_expiring_soon());
}
@@ -1203,4 +1358,46 @@ mod tests {
assert_eq!(accounts.len(), 1);
assert_eq!(accounts[0].id, "acc-456");
}
#[tokio::test]
async fn adopt_account_refresh_token_syncs_rotated_value() {
let temp = tempfile::tempdir().unwrap();
let manager = CodexOAuthManager::new(temp.path().to_path_buf());
manager
.add_test_account_with_access_token("acc-1", "access-cached", Some("id-1"))
.await
.unwrap();
// 采纳 Codex CLI 轮换后的 refresh_token / id_token。
let changed = manager
.adopt_account_refresh_token(
"acc-1",
"rotated-rt".to_string(),
Some("id-2".to_string()),
)
.await
.unwrap();
assert!(changed, "rotated refresh_token should be adopted");
// 完整 bundle 反映新的 refresh_tokenaccess_token 仍取自缓存(无需联网)。
let bundle = manager
.get_valid_token_bundle_for_account("acc-1")
.await
.unwrap();
assert_eq!(bundle.refresh_token, "rotated-rt");
assert_eq!(bundle.access_token, "access-cached");
assert_eq!(bundle.id_token.as_deref(), Some("id-2"));
// 未知账号不接管。
assert!(!manager
.adopt_account_refresh_token("acc-unknown", "x".to_string(), None)
.await
.unwrap());
// 相同值不算变化。
assert!(!manager
.adopt_account_refresh_token("acc-1", "rotated-rt".to_string(), None)
.await
.unwrap());
}
}
+213 -76
View File
@@ -6,7 +6,6 @@ use std::collections::HashMap;
use std::sync::Arc;
use serde_json::{json, Value};
use tokio::sync::RwLock;
use toml_edit::{DocumentMut, Item, TableLike};
use crate::app_config::AppType;
@@ -526,7 +525,7 @@ pub(crate) fn write_live_with_common_config_for_codex_oauth_manager(
db: &Database,
app_type: &AppType,
provider: &Provider,
codex_oauth_manager: &Arc<RwLock<CodexOAuthManager>>,
codex_oauth_manager: &Arc<CodexOAuthManager>,
) -> Result<(), AppError> {
let effective_provider = build_effective_provider_for_live_with_codex_oauth_manager(
db,
@@ -552,7 +551,7 @@ pub(crate) fn build_effective_provider_for_live_with_codex_oauth_manager(
db: &Database,
app_type: &AppType,
provider: &Provider,
codex_oauth_manager: &Arc<RwLock<CodexOAuthManager>>,
codex_oauth_manager: &Arc<CodexOAuthManager>,
) -> Result<Provider, AppError> {
let mut effective_provider = provider.clone();
effective_provider.settings_config =
@@ -564,7 +563,7 @@ pub(crate) fn build_effective_provider_for_live_with_codex_oauth_manager(
fn apply_codex_managed_oauth_auth(
app_type: &AppType,
provider: &mut Provider,
codex_oauth_manager: Option<&Arc<RwLock<CodexOAuthManager>>>,
codex_oauth_manager: Option<&Arc<CodexOAuthManager>>,
) -> Result<(), AppError> {
if !matches!(app_type, AppType::Codex) || provider.category.as_deref() != Some("official") {
return Ok(());
@@ -586,9 +585,7 @@ fn apply_codex_managed_oauth_auth(
));
};
let (access_token, id_token) =
get_codex_managed_oauth_token(manager.clone(), account_id.clone())?;
let auth = codex_managed_oauth_live_auth(&account_id, &access_token, id_token.as_deref());
let auth = get_codex_managed_oauth_live_auth_value(manager.clone(), account_id.clone())?;
let Some(settings_obj) = provider.settings_config.as_object_mut() else {
return Err(AppError::Config(
@@ -600,36 +597,68 @@ fn apply_codex_managed_oauth_auth(
Ok(())
}
fn get_codex_managed_oauth_token(
manager: Arc<RwLock<CodexOAuthManager>>,
/// 构建写入托管 Codex `auth.json` 的完整可刷新 auth(含 refresh_token + last_refresh)。
///
/// 步骤:
/// 1. **读回**:若 Codex CLI 已自行刷新并轮换 refresh_token,先采纳盘上最新值,避免
/// 用陈腐 refresh_token 覆盖 CLI 的有效登录(反复切换场景)。
/// 2. 取有效 token 束(必要时刷新 access_token)。
/// 3. 按原生浏览器登录形状生成完整 auth。
///
/// 不再持有外层锁:manager 内部按账号加锁刷新,网络阻塞不会波及其他账号操作或
/// token 读取。
fn get_codex_managed_oauth_live_auth_value(
manager: Arc<CodexOAuthManager>,
account_id: String,
) -> Result<(String, Option<String>), AppError> {
let result = std::thread::spawn(move || {
) -> Result<Value, AppError> {
std::thread::spawn(move || {
tauri::async_runtime::block_on(async move {
let error_account_id = account_id.clone();
let manager = manager.read().await;
manager
.get_valid_token_and_id_token_for_account(&account_id)
if let Some((refresh_token, id_token)) =
crate::codex_config::read_codex_live_auth_refresh_for_account(&account_id)
{
if let Err(err) = manager
.adopt_account_refresh_token(&account_id, refresh_token, id_token)
.await
{
log::warn!(
"读回 Codex CLI 轮换后的 refresh_token 失败(account={account_id}: {err}"
);
}
}
let bundle = manager
.get_valid_token_bundle_for_account(&account_id)
.await
.map_err(|err| {
format!(
"Codex OAuth 账号 {error_account_id} 认证失败,请重新登录 ChatGPT 账号: {err}"
)
})
"Codex OAuth 账号 {account_id} 认证失败,请重新登录 ChatGPT 账号: {err}"
)
})?;
Ok::<Value, String>(codex_managed_oauth_live_auth(
&account_id,
&bundle.access_token,
bundle.id_token.as_deref(),
&bundle.refresh_token,
&bundle.last_refresh,
))
})
})
.join()
.map_err(|_| AppError::Message("Codex OAuth token 获取线程异常退出".to_string()))?;
result.map_err(AppError::Message)
.map_err(|_| AppError::Message("Codex OAuth token 获取线程异常退出".to_string()))?
.map_err(AppError::Message)
}
pub(crate) fn codex_managed_oauth_live_auth(
account_id: &str,
access_token: &str,
id_token: Option<&str>,
refresh_token: &str,
last_refresh: &str,
) -> Value {
// 与原生 Codex 浏览器登录的 tokens 字段顺序对齐:id_token 在前。
// 与原生 Codex 浏览器登录的形状对齐:tokens 字段顺序 id_token、access_token、
// refresh_token、account_id,并带顶层 last_refresh。**必须**包含 refresh_token
// 否则 Codex CLI 在 access_token 过期后无法自刷新(“裸跑 codex” 会静默失效)。
let mut tokens = serde_json::Map::new();
if let Some(id_token) = id_token {
tokens.insert("id_token".to_string(), Value::String(id_token.to_string()));
@@ -638,6 +667,10 @@ pub(crate) fn codex_managed_oauth_live_auth(
"access_token".to_string(),
Value::String(access_token.to_string()),
);
tokens.insert(
"refresh_token".to_string(),
Value::String(refresh_token.to_string()),
);
tokens.insert(
"account_id".to_string(),
Value::String(account_id.to_string()),
@@ -646,6 +679,7 @@ pub(crate) fn codex_managed_oauth_live_auth(
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": Value::Object(tokens),
"last_refresh": last_refresh,
})
}
@@ -748,38 +782,37 @@ fn restore_live_settings_for_provider_backfill(
settings
}
/// 回填(backfill)托管 Codex 官方 provider 时,剥离 live 的 `auth`。
///
/// 托管 provider 的存储配置**永远不应**持久化真实 OAuth tokentoken 由
/// `CodexOAuthManager` 按账号集中保管,provider 配置只保留绑定与占位 auth。
///
/// 因此无论 live 里当前是什么(我们写入的托管 auth、被 CLI 轮换过的 token、
/// 还是用户自己浏览器登录的原生 auth,后者含真实 access/refresh_token),
/// 都统一替换为 provider 存储的占位 auth,避免把真实凭据回填进 DB 配置。
fn strip_codex_managed_oauth_auth_for_backfill(provider: &Provider, settings: &mut Value) {
let Some(account_id) = provider
let is_managed = provider
.meta
.as_ref()
.and_then(|meta| meta.managed_account_id_for("codex_oauth"))
else {
.is_some();
if !is_managed {
return;
};
}
let Some(auth) = settings.get("auth") else {
// live 里没有 auth 就无需处理。
if settings.get("auth").is_none() {
return;
};
}
match crate::codex_config::codex_auth_matches_recorded_managed_oauth(auth, &account_id) {
Ok(true) => {
let stored_auth = provider
.settings_config
.get("auth")
.filter(|auth| auth.is_object())
.cloned()
.unwrap_or_else(|| json!({}));
if let Some(obj) = settings.as_object_mut() {
obj.insert("auth".to_string(), stored_auth);
}
}
Ok(false) => {}
Err(err) => {
log::warn!(
"Failed to check Codex managed OAuth marker while backfilling '{}': {err}",
provider.id
);
}
let stored_auth = provider
.settings_config
.get("auth")
.filter(|auth| auth.is_object())
.cloned()
.unwrap_or_else(|| json!({}));
if let Some(obj) = settings.as_object_mut() {
obj.insert("auth".to_string(), stored_auth);
}
}
@@ -932,14 +965,6 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
config_str,
profile,
)?;
if provider
.meta
.as_ref()
.and_then(|meta| meta.managed_account_id_for("codex_oauth"))
.is_some()
{
crate::codex_config::record_codex_managed_oauth_live_auth(auth)?;
}
}
AppType::Gemini => {
// Delegate to write_gemini_live which handles env file writing correctly
@@ -1822,31 +1847,47 @@ mod tests {
#[test]
fn codex_managed_oauth_live_auth_matches_codex_cli_shape() {
assert_eq!(
codex_managed_oauth_live_auth("acct-managed", "access-token", Some("id-token")),
codex_managed_oauth_live_auth(
"acct-managed",
"access-token",
Some("id-token"),
"refresh-token",
"2026-01-02T03:04:05.000000000Z",
),
json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": {
"id_token": "id-token",
"access_token": "access-token",
"refresh_token": "refresh-token",
"account_id": "acct-managed"
}
},
"last_refresh": "2026-01-02T03:04:05.000000000Z"
}),
"managed live auth should include id_token to match native browser login"
"managed live auth must carry refresh_token + last_refresh so the Codex CLI can self-refresh"
);
}
#[test]
fn codex_managed_oauth_live_auth_without_id_token_omits_it() {
assert_eq!(
codex_managed_oauth_live_auth("acct-managed", "access-token", None),
codex_managed_oauth_live_auth(
"acct-managed",
"access-token",
None,
"refresh-token",
"2026-01-02T03:04:05.000000000Z",
),
json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": {
"access_token": "access-token",
"refresh_token": "refresh-token",
"account_id": "acct-managed"
}
},
"last_refresh": "2026-01-02T03:04:05.000000000Z"
}),
"without a stored id_token the field is omitted rather than written as null"
);
@@ -1855,13 +1896,9 @@ mod tests {
#[test]
fn codex_official_managed_oauth_binding_replaces_auth_with_selected_account_token() {
let temp = tempfile::tempdir().expect("tempdir");
let manager = Arc::new(RwLock::new(CodexOAuthManager::new(
temp.path().to_path_buf(),
)));
let manager = Arc::new(CodexOAuthManager::new(temp.path().to_path_buf()));
tauri::async_runtime::block_on(async {
manager
.read()
.await
.add_test_account_with_access_token(
"acct-managed",
"managed-token",
@@ -1895,27 +1932,48 @@ mod tests {
apply_codex_managed_oauth_auth(&AppType::Codex, &mut provider, Some(&manager))
.expect("apply managed OAuth auth");
// last_refresh 是写入时刻的时间戳(非确定),因此逐字段断言而非整体等值。
let auth = provider.settings_config.get("auth").expect("auth written");
assert_eq!(
provider.settings_config.get("auth"),
Some(&codex_managed_oauth_live_auth(
"acct-managed",
"managed-token",
Some("managed-id-token")
)),
"explicit account binding should write the managed ChatGPT token to Codex live auth"
auth.get("auth_mode").and_then(|v| v.as_str()),
Some("chatgpt")
);
assert!(auth.get("OPENAI_API_KEY").is_some_and(|v| v.is_null()));
let tokens = auth
.get("tokens")
.and_then(|v| v.as_object())
.expect("tokens object");
assert_eq!(
tokens.get("account_id").and_then(|v| v.as_str()),
Some("acct-managed")
);
assert_eq!(
tokens.get("access_token").and_then(|v| v.as_str()),
Some("managed-token")
);
assert_eq!(
tokens.get("id_token").and_then(|v| v.as_str()),
Some("managed-id-token")
);
assert_eq!(
tokens.get("refresh_token").and_then(|v| v.as_str()),
Some("test-refresh-token"),
"managed live auth must carry the account's refresh_token so the Codex CLI can self-refresh"
);
assert!(
auth.get("last_refresh")
.and_then(|v| v.as_str())
.is_some_and(|s| s.ends_with('Z')),
"managed live auth must include an RFC3339 last_refresh timestamp"
);
}
#[test]
fn codex_official_without_binding_does_not_fall_back_to_managed_default_account() {
let temp = tempfile::tempdir().expect("tempdir");
let manager = Arc::new(RwLock::new(CodexOAuthManager::new(
temp.path().to_path_buf(),
)));
let manager = Arc::new(CodexOAuthManager::new(temp.path().to_path_buf()));
tauri::async_runtime::block_on(async {
manager
.read()
.await
.add_test_account_with_access_token("acct-managed", "managed-token", None)
.await
.expect("seed managed account");
@@ -1950,6 +2008,85 @@ mod tests {
);
}
#[test]
fn backfill_never_persists_native_tokens_into_managed_provider_config() {
// 托管 provider 的存储配置以占位 auth 表示;但 live 里此刻是用户自己
// 浏览器登录的原生 auth(含真实 refresh_token)。backfill 必须把 live
// auth 换回存储占位,绝不把原生 access/refresh_token 回填进 DB 配置。
let mut provider = Provider::with_id(
"openai-official".to_string(),
"OpenAI Official".to_string(),
json!({
"auth": {},
"config": ""
}),
None,
);
provider.category = Some("official".to_string());
provider.meta = Some(ProviderMeta {
auth_binding: Some(AuthBinding {
source: AuthBindingSource::ManagedAccount,
auth_provider: Some("codex_oauth".to_string()),
account_id: Some("acct-managed".to_string()),
}),
..Default::default()
});
let mut live_settings = json!({
"auth": {
"OPENAI_API_KEY": null,
"tokens": {
"id_token": "native-id",
"access_token": "native-access-secret",
"refresh_token": "native-refresh-secret",
"account_id": "acct-native"
},
"last_refresh": "2026-01-01T00:00:00Z"
},
"config": ""
});
strip_codex_managed_oauth_auth_for_backfill(&provider, &mut live_settings);
assert_eq!(
live_settings.get("auth"),
Some(&json!({})),
"managed provider backfill must reset live auth to the stored placeholder"
);
let serialized = live_settings.to_string();
assert!(
!serialized.contains("native-refresh-secret"),
"native refresh_token must not leak into a managed provider's backfilled config"
);
assert!(
!serialized.contains("native-access-secret"),
"native access_token must not leak into a managed provider's backfilled config"
);
}
#[test]
fn backfill_leaves_non_managed_provider_auth_untouched() {
// 非托管 provider 不受此剥离影响:其 auth 就该原样保留。
let mut provider = Provider::with_id(
"custom".to_string(),
"Custom".to_string(),
json!({ "auth": { "OPENAI_API_KEY": "user-key" }, "config": "" }),
None,
);
provider.category = Some("custom".to_string());
let mut live_settings = json!({
"auth": { "OPENAI_API_KEY": "user-key" },
"config": ""
});
let before = live_settings.clone();
strip_codex_managed_oauth_auth_for_backfill(&provider, &mut live_settings);
assert_eq!(
live_settings, before,
"non-managed provider auth must be left untouched by managed-oauth backfill stripping"
);
}
#[test]
fn explicit_common_config_flag_overrides_legacy_subset_detection() {
let mut provider = Provider::with_id(
+124 -8
View File
@@ -1272,8 +1272,6 @@ base_url = "http://localhost:8080"
tauri::async_runtime::block_on(async {
state
.codex_oauth_manager
.read()
.await
.add_test_account_with_access_token(
"acct-managed",
"managed-token",
@@ -1355,6 +1353,70 @@ base_url = "http://localhost:8080"
});
}
#[test]
#[serial]
fn switch_to_managed_codex_official_with_unresolvable_account_keeps_current_unchanged() {
with_test_home(|state, _| {
crate::settings::reload_settings().expect("reload settings");
// 基线:一个普通第三方 provider,可正常切换,作为初始 current。
let mut baseline = Provider::with_id(
"baseline".to_string(),
"Baseline".to_string(),
json!({ "auth": { "OPENAI_API_KEY": "sk-baseline" }, "config": "" }),
None,
);
baseline.category = Some("custom".to_string());
// 托管 official provider,绑定一个 manager 中不存在的账号:切换预检
// 取 token 必然失败。
let mut managed = Provider::with_id(
"managed-official".to_string(),
"Managed Official".to_string(),
json!({ "auth": {}, "config": "" }),
None,
);
managed.category = Some("official".to_string());
managed.meta = Some(ProviderMeta {
auth_binding: Some(AuthBinding {
source: AuthBindingSource::ManagedAccount,
auth_provider: Some("codex_oauth".to_string()),
account_id: Some("acct-missing".to_string()),
}),
..Default::default()
});
state
.db
.save_provider(AppType::Codex.as_str(), &baseline)
.expect("save baseline");
state
.db
.save_provider(AppType::Codex.as_str(), &managed)
.expect("save managed");
ProviderService::switch(state, AppType::Codex, "baseline").expect("switch to baseline");
// 切到绑定了不存在账号的托管 provider:预检失败 → 返回 Err。
let result = ProviderService::switch(state, AppType::Codex, "managed-official");
assert!(
result.is_err(),
"switch must fail when the managed OAuth token cannot be resolved"
);
// current 必须仍是 baseline:预检在提交 current 之前失败,不留下
// 「DB/UI 指向新 provider、但 live 仍是旧 provider」的不一致状态。
let current =
crate::settings::get_effective_current_provider(&state.db, &AppType::Codex)
.expect("read current");
assert_eq!(
current.as_deref(),
Some("baseline"),
"a failed managed switch must not move current off the previous provider"
);
});
}
#[test]
#[serial]
fn import_opencode_providers_from_live_marks_provider_as_live_managed() {
@@ -1649,6 +1711,49 @@ impl ProviderService {
.filter(|id| !id.is_empty())
}
/// 切走托管 Codex provider 时,清理其残留在 `~/.codex/auth.json` 的托管登录。
///
/// 但**跳过**「新 provider 自身就写入了同一账号 ChatGPT 登录」的情况(例如非托管
/// 官方 provider 保存了该账号的原生登录)——那是它刚写入的有效 auth,不应误删。
fn clear_stale_managed_codex_live_auth(
new_provider: &Provider,
account_id: &str,
) -> Result<(), AppError> {
let new_provider_owns_same_login =
new_provider
.settings_config
.get("auth")
.is_some_and(|auth| {
crate::codex_config::codex_live_auth_is_managed_chatgpt_login(auth, account_id)
});
if new_provider_owns_same_login {
return Ok(());
}
crate::codex_config::clear_codex_live_auth_for_managed_account(account_id)
}
/// 提交 currentsettings/DB)前的预检:若目标是托管 Codex official provider
/// 先解析一次有效 live 配置(会联网换取并缓存 token)。失败即返回 Err,从而避免
/// 留下「DB/UI 指向新 provider,但 live 仍是旧 provider」的不一致状态;后续真正
/// 写 live 会复用缓存的 token。非托管路径为空操作。
fn preflight_managed_codex_live(
state: &AppState,
app_type: &AppType,
provider: &Provider,
) -> Result<(), AppError> {
if matches!(app_type, AppType::Codex)
&& Self::managed_codex_oauth_account_id(provider).is_some()
{
build_effective_provider_for_live_with_codex_oauth_manager(
state.db.as_ref(),
app_type,
provider,
&state.codex_oauth_manager,
)?;
}
Ok(())
}
fn unbound_managed_codex_oauth_account_id(
app_type: &AppType,
existing_provider: Option<&Provider>,
@@ -1836,6 +1941,8 @@ impl ProviderService {
// For other apps: Check if sync is needed (if this is current provider, or no current provider)
let current = state.db.get_current_provider(app_type.as_str())?;
if current.is_none() {
// 预检托管 Codex token:失败则不将其设为 current(见 switch)。
Self::preflight_managed_codex_live(state, &app_type, &provider)?;
// No current provider, set as current and sync
state
.db
@@ -2001,14 +2108,20 @@ impl ProviderService {
&provider,
);
// Save to database
state.db.save_provider(app_type.as_str(), &provider)?;
// For other apps: Check if this is current provider (use effective current, not just DB)
let effective_current =
crate::settings::get_effective_current_provider(&state.db, &app_type)?;
let is_current = effective_current.as_deref() == Some(provider.id.as_str());
// 编辑当前托管 Codex provider:写库前先预检 token(见 preflight_managed_codex_live),
// 失败则不落库、不改动 live,避免 DB 与 live 不一致。
if is_current {
Self::preflight_managed_codex_live(state, &app_type, &provider)?;
}
// Save to database
state.db.save_provider(app_type.as_str(), &provider)?;
if is_current {
// 如果 Claude 代理接管处于激活状态,并且代理服务正在运行:
// - 不直接走普通 Live 写入逻辑
@@ -2052,7 +2165,7 @@ impl ProviderService {
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
if let Some(account_id) = unbound_codex_managed_account_id.as_deref() {
crate::codex_config::clear_codex_live_auth_for_managed_account(account_id)?;
Self::clear_stale_managed_codex_live_auth(&provider, account_id)?;
}
}
@@ -2069,7 +2182,7 @@ impl ProviderService {
} else {
write_live_with_common_config_for_state(state, &app_type, &provider)?;
if let Some(account_id) = unbound_codex_managed_account_id.as_deref() {
crate::codex_config::clear_codex_live_auth_for_managed_account(account_id)?;
Self::clear_stale_managed_codex_live_auth(&provider, account_id)?;
}
// Sync MCP
McpService::sync_all_enabled(state)?;
@@ -2384,6 +2497,9 @@ impl ProviderService {
}
}
// 提交 current 前预检托管 Codex token(见 preflight_managed_codex_live)。
Self::preflight_managed_codex_live(state, &app_type, provider)?;
// Additive mode apps skip setting is_current (no such concept)
if !app_type.is_additive_mode() {
// Update local settings (device-level, takes priority)
@@ -2399,7 +2515,7 @@ impl ProviderService {
&& Self::managed_codex_oauth_account_id(provider).is_none()
{
if let Some(account_id) = current_managed_codex_account_id.as_deref() {
crate::codex_config::clear_codex_live_auth_for_managed_account(account_id)?;
Self::clear_stale_managed_codex_live_auth(provider, account_id)?;
}
}
+74 -24
View File
@@ -57,7 +57,7 @@ enum ClaudeTakeoverAuthPolicy {
#[derive(Clone)]
pub struct ProxyService {
db: Arc<Database>,
codex_oauth_manager: Arc<RwLock<CodexOAuthManager>>,
codex_oauth_manager: Arc<CodexOAuthManager>,
server: Arc<RwLock<Option<ProxyServer>>>,
/// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新
app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,
@@ -71,16 +71,15 @@ pub struct HotSwitchOutcome {
impl ProxyService {
pub fn new(db: Arc<Database>) -> Self {
let codex_oauth_manager = Arc::new(RwLock::new(CodexOAuthManager::new(
crate::config::get_app_config_dir(),
)));
let codex_oauth_manager =
Arc::new(CodexOAuthManager::new(crate::config::get_app_config_dir()));
Self::new_with_codex_oauth_manager(db, codex_oauth_manager)
}
pub fn new_with_codex_oauth_manager(
db: Arc<Database>,
codex_oauth_manager: Arc<RwLock<CodexOAuthManager>>,
codex_oauth_manager: Arc<CodexOAuthManager>,
) -> Self {
Self {
db,
@@ -1171,6 +1170,48 @@ impl ProxyService {
}
/// 备份各应用的 Live 配置
/// 若当前 Codex provider 是托管账号绑定,则把待备份的 live 配置中属于该账号的
/// ChatGPT 登录 auth 清空。托管账号的可刷新 token 由 CodexOAuthManager 集中保管,
/// 绝不应持久化进 Live 备份(否则完整 bundle 的 refresh_token 会落进 DB 备份槽)。
fn sanitize_codex_backup_auth(&self, config: &mut Value) {
let Some(current_id) =
crate::settings::get_effective_current_provider(&self.db, &AppType::Codex)
.ok()
.flatten()
else {
return;
};
let Ok(providers) = self.db.get_all_providers(AppType::Codex.as_str()) else {
return;
};
let Some(provider) = providers.get(&current_id) else {
return;
};
if provider.category.as_deref() != Some("official") {
return;
}
let Some(account_id) = provider
.meta
.as_ref()
.and_then(|meta| meta.managed_account_id_for("codex_oauth"))
.map(|id| id.trim().to_string())
.filter(|id| !id.is_empty())
else {
return;
};
let should_strip = config
.get("auth")
.map(|auth| {
crate::codex_config::codex_live_auth_is_managed_chatgpt_login(auth, &account_id)
})
.unwrap_or(false);
if should_strip {
if let Some(obj) = config.as_object_mut() {
obj.insert("auth".to_string(), json!({}));
}
}
}
async fn backup_live_configs(&self) -> Result<(), String> {
// Claude
if let Ok(config) = self.read_claude_live() {
@@ -1191,10 +1232,11 @@ impl ProxyService {
}
// Codex
if let Ok(config) = self.read_codex_live() {
if let Ok(mut config) = self.read_codex_live() {
if Self::live_has_proxy_placeholder_for_app(&AppType::Codex, &config) {
log::warn!("codex Live 已被代理接管,不备份(避免把代理配置固化进备份槽);下次 stop 会从 SSOT 重建 Live");
} else {
self.sanitize_codex_backup_auth(&mut config);
let json_str = serde_json::to_string(&config)
.map_err(|e| format!("序列化 Codex 配置失败: {e}"))?;
self.db
@@ -1224,7 +1266,7 @@ impl ProxyService {
/// 备份指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)
async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {
let (app_type_str, config) = match app_type {
let (app_type_str, mut config) = match app_type {
AppType::Claude => ("claude", self.read_claude_live()?),
AppType::Codex => ("codex", self.read_codex_live()?),
AppType::Gemini => ("gemini", self.read_gemini_live()?),
@@ -1240,6 +1282,11 @@ impl ProxyService {
return Ok(());
}
// 托管 Codex 账号的可刷新 token 不入备份(见 sanitize_codex_backup_auth)。
if matches!(app_type, AppType::Codex) {
self.sanitize_codex_backup_auth(&mut config);
}
let json_str = serde_json::to_string(&config)
.map_err(|e| format!("序列化 {app_type_str} 配置失败: {e}"))?;
self.db
@@ -2096,6 +2143,22 @@ impl ProxyService {
&mut effective_settings,
)
.map_err(|e| format!("注入统一会话路由失败: {e}"))?;
// 托管账号:build 出的 effective_settings.auth 是完整可刷新 bundle(含
// refresh_token),绝不能写进 DB 备份(token 由 CodexOAuthManager 集中保管)。
// 覆盖上面的 clear/preserve 分支,统一剥离为占位;恢复时 live 由 manager
// 重新派生。
let provider_is_managed_codex = provider
.meta
.as_ref()
.and_then(|meta| meta.managed_account_id_for("codex_oauth"))
.map(|id| !id.trim().is_empty())
.unwrap_or(false);
if provider_is_managed_codex {
if let Some(obj) = effective_settings.as_object_mut() {
obj.insert("auth".to_string(), json!({}));
}
}
}
let backup_json = match app_type_enum {
@@ -2121,19 +2184,6 @@ impl ProxyService {
.await
.map_err(|e| format!("更新 {app_type} 备份失败: {e}"))?;
if matches!(app_type_enum, AppType::Codex)
&& provider
.meta
.as_ref()
.and_then(|meta| meta.managed_account_id_for("codex_oauth"))
.is_some()
{
if let Some(auth) = effective_settings.get("auth") {
crate::codex_config::record_codex_managed_oauth_live_auth(auth)
.map_err(|e| format!("记录 Codex 托管认证标记失败: {e}"))?;
}
}
log::info!("已更新 {app_type} Live 备份(热切换)");
Ok(())
}
@@ -2316,8 +2366,10 @@ impl ProxyService {
return Err("Codex 备份必须是 JSON 对象".to_string());
};
if crate::codex_config::codex_auth_matches_recorded_managed_oauth(existing_auth, account_id)
.map_err(|e| format!("检查 Codex 托管认证标记失败: {e}"))?
// 托管账号写入的是完整可刷新 bundle(含 refresh_token),必须避免它被
// 持久化进 Live 备份。按 account_id 内容判定:命中托管账号则清空备份 auth,
// 否则原样保留用户自己的登录以便接管结束后还原。
if crate::codex_config::codex_live_auth_is_managed_chatgpt_login(existing_auth, account_id)
{
target_obj.insert("auth".to_string(), json!({}));
return Ok(());
@@ -5096,8 +5148,6 @@ base_url = "https://codex.example/v1"
let service = ProxyService::new(db.clone());
service
.codex_oauth_manager
.read()
.await
.add_test_account_with_access_token(
"acct-managed",
"managed-token",
+5 -5
View File
@@ -2,22 +2,22 @@ use crate::database::Database;
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
use crate::services::{ProxyService, UsageCache};
use std::sync::Arc;
use tokio::sync::RwLock;
/// 全局应用状态
pub struct AppState {
pub db: Arc<Database>,
pub proxy_service: ProxyService,
pub usage_cache: Arc<UsageCache>,
pub codex_oauth_manager: Arc<RwLock<CodexOAuthManager>>,
// 内部已使用细粒度锁(accounts/access_tokens/refresh_locks),所有方法均为
// `&self`,无需外层 RwLock;避免持有粗粒度锁跨网络刷新导致的连锁阻塞。
pub codex_oauth_manager: Arc<CodexOAuthManager>,
}
impl AppState {
/// 创建新的应用状态
pub fn new(db: Arc<Database>) -> Self {
let codex_oauth_manager = Arc::new(RwLock::new(CodexOAuthManager::new(
crate::config::get_app_config_dir(),
)));
let codex_oauth_manager =
Arc::new(CodexOAuthManager::new(crate::config::get_app_config_dir()));
let proxy_service =
ProxyService::new_with_codex_oauth_manager(db.clone(), codex_oauth_manager.clone());