mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
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:
+99
-217
@@ -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]
|
||||
|
||||
@@ -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!(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_token;access_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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 token:token 由
|
||||
/// `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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/// 提交 current(settings/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)?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(¤t_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",
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user