mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-29 01:25:33 +08:00
Add managed ChatGPT account binding for Codex
This commit is contained in:
@@ -6,7 +6,9 @@ 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;
|
||||
@@ -14,6 +16,14 @@ use toml_edit::DocumentMut;
|
||||
pub const CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "custom";
|
||||
pub const CC_SWITCH_CODEX_MODEL_CATALOG_FILENAME: &str = "cc-switch-model-catalog.json";
|
||||
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,
|
||||
}
|
||||
|
||||
/// Reserved built-in provider IDs from OpenAI Codex's config/model-provider
|
||||
/// catalog. Keep in sync with Codex `RESERVED_MODEL_PROVIDER_IDS` and legacy
|
||||
@@ -41,6 +51,121 @@ 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())?;
|
||||
|
||||
if tokens
|
||||
.keys()
|
||||
.any(|key| !matches!(key.as_str(), "access_token" | "account_id"))
|
||||
{
|
||||
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))
|
||||
}
|
||||
|
||||
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)? {
|
||||
return Ok(());
|
||||
}
|
||||
delete_file(&auth_path)?;
|
||||
let _ = delete_file(&get_codex_managed_oauth_live_auth_marker_path());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取 Codex config.toml 路径
|
||||
pub fn get_codex_config_path() -> PathBuf {
|
||||
get_codex_config_dir().join("config.toml")
|
||||
@@ -1253,6 +1378,36 @@ 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 prepare_provider_live_config_rejects_key_without_config() {
|
||||
@@ -1265,6 +1420,47 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn recorded_managed_oauth_marker_matches_only_same_account_and_token() {
|
||||
let _home = TempHome::new();
|
||||
let managed_auth = 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]
|
||||
fn prepare_provider_live_config_uses_top_level_token_for_reserved_provider() {
|
||||
let input = r#"model_provider = "openai"
|
||||
|
||||
@@ -921,13 +921,11 @@ pub fn run() {
|
||||
|
||||
// 初始化 CodexOAuthManager (ChatGPT Plus/Pro 反代)
|
||||
{
|
||||
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
|
||||
use commands::CodexOAuthState;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
let app_config_dir = crate::config::get_app_config_dir();
|
||||
let codex_oauth_manager = CodexOAuthManager::new(app_config_dir);
|
||||
app.manage(CodexOAuthState(Arc::new(RwLock::new(codex_oauth_manager))));
|
||||
let codex_oauth_manager =
|
||||
app.state::<AppState>().codex_oauth_manager.clone();
|
||||
app.manage(CodexOAuthState(codex_oauth_manager));
|
||||
log::info!("✓ CodexOAuthManager initialized");
|
||||
}
|
||||
|
||||
|
||||
@@ -695,6 +695,31 @@ impl CodexOAuthManager {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn add_test_account_with_access_token(
|
||||
&self,
|
||||
account_id: &str,
|
||||
access_token: &str,
|
||||
) -> Result<(), CodexOAuthError> {
|
||||
self.add_account_internal(
|
||||
account_id.to_string(),
|
||||
"test-refresh-token".to_string(),
|
||||
Some(format!("{account_id}@example.test")),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut tokens = self.access_tokens.write().await;
|
||||
tokens.insert(
|
||||
account_id.to_string(),
|
||||
CachedAccessToken {
|
||||
token: access_token.to_string(),
|
||||
expires_at_ms: chrono::Utc::now().timestamp_millis() + 3_600_000,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ==================== 内部方法 ====================
|
||||
|
||||
async fn add_account_internal(
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
//! Handles reading and writing live configuration files for Claude, Codex, and Gemini.
|
||||
|
||||
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;
|
||||
@@ -13,6 +15,7 @@ use crate::config::{delete_file, get_claude_settings_path, read_json_file, write
|
||||
use crate::database::Database;
|
||||
use crate::error::AppError;
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
|
||||
use crate::services::mcp::McpService;
|
||||
use crate::store::AppState;
|
||||
|
||||
@@ -506,14 +509,31 @@ pub(crate) fn build_effective_settings_with_common_config(
|
||||
Ok(effective_settings)
|
||||
}
|
||||
|
||||
pub(crate) fn write_live_with_common_config(
|
||||
db: &Database,
|
||||
pub(crate) fn write_live_with_common_config_for_state(
|
||||
state: &AppState,
|
||||
app_type: &AppType,
|
||||
provider: &Provider,
|
||||
) -> Result<(), AppError> {
|
||||
let mut effective_provider = provider.clone();
|
||||
effective_provider.settings_config =
|
||||
build_effective_settings_with_common_config(db, app_type, provider)?;
|
||||
write_live_with_common_config_for_codex_oauth_manager(
|
||||
state.db.as_ref(),
|
||||
app_type,
|
||||
provider,
|
||||
&state.codex_oauth_manager,
|
||||
)
|
||||
}
|
||||
|
||||
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>>,
|
||||
) -> Result<(), AppError> {
|
||||
let effective_provider = build_effective_provider_for_live_with_codex_oauth_manager(
|
||||
db,
|
||||
app_type,
|
||||
provider,
|
||||
codex_oauth_manager,
|
||||
)?;
|
||||
|
||||
if matches!(app_type, AppType::ClaudeDesktop) {
|
||||
crate::claude_desktop_config::apply_provider(db, &effective_provider)?;
|
||||
@@ -528,6 +548,92 @@ pub(crate) fn write_live_with_common_config(
|
||||
write_live_snapshot(app_type, &effective_provider)
|
||||
}
|
||||
|
||||
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>>,
|
||||
) -> Result<Provider, AppError> {
|
||||
let mut effective_provider = provider.clone();
|
||||
effective_provider.settings_config =
|
||||
build_effective_settings_with_common_config(db, app_type, provider)?;
|
||||
apply_codex_managed_oauth_auth(app_type, &mut effective_provider, Some(codex_oauth_manager))?;
|
||||
Ok(effective_provider)
|
||||
}
|
||||
|
||||
fn apply_codex_managed_oauth_auth(
|
||||
app_type: &AppType,
|
||||
provider: &mut Provider,
|
||||
codex_oauth_manager: Option<&Arc<RwLock<CodexOAuthManager>>>,
|
||||
) -> Result<(), AppError> {
|
||||
if !matches!(app_type, AppType::Codex) || provider.category.as_deref() != Some("official") {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
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 Ok(());
|
||||
};
|
||||
|
||||
let Some(manager) = codex_oauth_manager else {
|
||||
return Err(AppError::Message(
|
||||
"Codex OAuth 托管账号不可用,请重启应用后重试".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
let access_token = get_codex_managed_oauth_token(manager.clone(), account_id.clone())?;
|
||||
let auth = codex_managed_oauth_live_auth(&account_id, &access_token);
|
||||
|
||||
let Some(settings_obj) = provider.settings_config.as_object_mut() else {
|
||||
return Err(AppError::Config(
|
||||
"Codex 供应商配置必须是 JSON 对象".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
settings_obj.insert("auth".to_string(), auth);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_codex_managed_oauth_token(
|
||||
manager: Arc<RwLock<CodexOAuthManager>>,
|
||||
account_id: String,
|
||||
) -> Result<String, AppError> {
|
||||
let result = 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_for_account(&account_id)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
format!(
|
||||
"Codex OAuth 账号 {error_account_id} 认证失败,请重新登录 ChatGPT 账号: {err}"
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
.join()
|
||||
.map_err(|_| AppError::Message("Codex OAuth token 获取线程异常退出".to_string()))?;
|
||||
|
||||
result.map_err(AppError::Message)
|
||||
}
|
||||
|
||||
pub(crate) fn codex_managed_oauth_live_auth(account_id: &str, access_token: &str) -> Value {
|
||||
json!({
|
||||
"auth_mode": "chatgpt",
|
||||
"OPENAI_API_KEY": null,
|
||||
"tokens": {
|
||||
"access_token": access_token,
|
||||
"account_id": account_id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn strip_common_config_from_live_settings(
|
||||
db: &Database,
|
||||
app_type: &AppType,
|
||||
@@ -596,6 +702,8 @@ fn restore_live_settings_for_provider_backfill(
|
||||
);
|
||||
}
|
||||
|
||||
strip_codex_managed_oauth_auth_for_backfill(provider, &mut settings);
|
||||
|
||||
// `modelCatalog` is a cc-switch–private field whose SSOT is the DB. Live's
|
||||
// `config.toml` only carries a lossy projection (`model_catalog_json` →
|
||||
// generated catalog file) that proxy takeover/restore cycles and Codex.app
|
||||
@@ -612,6 +720,41 @@ fn restore_live_settings_for_provider_backfill(
|
||||
settings
|
||||
}
|
||||
|
||||
fn strip_codex_managed_oauth_auth_for_backfill(provider: &Provider, settings: &mut Value) {
|
||||
let Some(account_id) = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.managed_account_id_for("codex_oauth"))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(auth) = settings.get("auth") else {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_provider_common_config_for_storage(
|
||||
db: &Database,
|
||||
app_type: &AppType,
|
||||
@@ -753,6 +896,14 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
||||
auth,
|
||||
config_str,
|
||||
)?;
|
||||
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
|
||||
@@ -884,7 +1035,7 @@ fn sync_all_providers_to_live(state: &AppState, app_type: &AppType) -> Result<()
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(e) = write_live_with_common_config(state.db.as_ref(), app_type, provider) {
|
||||
if let Err(e) = write_live_with_common_config_for_state(state, app_type, provider) {
|
||||
log::warn!(
|
||||
"Failed to sync {:?} provider '{}' to live: {e}",
|
||||
app_type,
|
||||
@@ -914,7 +1065,7 @@ pub(crate) fn sync_current_provider_for_app_to_live(
|
||||
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
if let Some(provider) = providers.get(¤t_id) {
|
||||
write_live_with_common_config(state.db.as_ref(), app_type, provider)?;
|
||||
write_live_with_common_config_for_state(state, app_type, provider)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -950,7 +1101,7 @@ fn sync_current_provider_for_app_respecting_takeover(
|
||||
// that normal provider sync must not rewrite the managed live file.
|
||||
if has_live_backup || live_taken_over {
|
||||
if matches!(app_type, AppType::ClaudeDesktop) {
|
||||
write_live_with_common_config(state.db.as_ref(), app_type, provider)?;
|
||||
write_live_with_common_config_for_state(state, app_type, provider)?;
|
||||
} else {
|
||||
futures::executor::block_on(
|
||||
state
|
||||
@@ -962,7 +1113,7 @@ fn sync_current_provider_for_app_respecting_takeover(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
write_live_with_common_config(state.db.as_ref(), app_type, provider)
|
||||
write_live_with_common_config_for_state(state, app_type, provider)
|
||||
}
|
||||
|
||||
/// Sync current provider to live configuration
|
||||
@@ -1569,6 +1720,7 @@ pub fn remove_openclaw_provider_from_live(provider_id: &str) -> Result<(), AppEr
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::provider::{AuthBinding, AuthBindingSource, ProviderMeta};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
@@ -1615,6 +1767,114 @@ mod tests {
|
||||
assert_eq!(stripped, settings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_managed_oauth_live_auth_matches_codex_cli_shape() {
|
||||
assert_eq!(
|
||||
codex_managed_oauth_live_auth("acct-managed", "access-token"),
|
||||
json!({
|
||||
"auth_mode": "chatgpt",
|
||||
"OPENAI_API_KEY": null,
|
||||
"tokens": {
|
||||
"access_token": "access-token",
|
||||
"account_id": "acct-managed"
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[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(),
|
||||
)));
|
||||
tauri::async_runtime::block_on(async {
|
||||
manager
|
||||
.read()
|
||||
.await
|
||||
.add_test_account_with_access_token("acct-managed", "managed-token")
|
||||
.await
|
||||
.expect("seed managed account");
|
||||
});
|
||||
|
||||
let mut provider = Provider::with_id(
|
||||
"openai-official".to_string(),
|
||||
"OpenAI Official".to_string(),
|
||||
json!({
|
||||
"auth": {
|
||||
"OPENAI_API_KEY": "stale-key"
|
||||
},
|
||||
"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()
|
||||
});
|
||||
|
||||
apply_codex_managed_oauth_auth(&AppType::Codex, &mut provider, Some(&manager))
|
||||
.expect("apply managed OAuth auth");
|
||||
|
||||
assert_eq!(
|
||||
provider.settings_config.get("auth"),
|
||||
Some(&codex_managed_oauth_live_auth(
|
||||
"acct-managed",
|
||||
"managed-token"
|
||||
)),
|
||||
"explicit account binding should write the managed ChatGPT token to Codex live auth"
|
||||
);
|
||||
}
|
||||
|
||||
#[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(),
|
||||
)));
|
||||
tauri::async_runtime::block_on(async {
|
||||
manager
|
||||
.read()
|
||||
.await
|
||||
.add_test_account_with_access_token("acct-managed", "managed-token")
|
||||
.await
|
||||
.expect("seed managed account");
|
||||
});
|
||||
|
||||
let original_auth = json!({
|
||||
"auth_mode": "chatgpt",
|
||||
"OPENAI_API_KEY": null,
|
||||
"tokens": {
|
||||
"access_token": "native-codex-token",
|
||||
"account_id": "acct-native"
|
||||
}
|
||||
});
|
||||
let mut provider = Provider::with_id(
|
||||
"openai-official".to_string(),
|
||||
"OpenAI Official".to_string(),
|
||||
json!({
|
||||
"auth": original_auth.clone(),
|
||||
"config": ""
|
||||
}),
|
||||
None,
|
||||
);
|
||||
provider.category = Some("official".to_string());
|
||||
|
||||
apply_codex_managed_oauth_auth(&AppType::Codex, &mut provider, Some(&manager))
|
||||
.expect("no binding should be a no-op");
|
||||
|
||||
assert_eq!(
|
||||
provider.settings_config.get("auth"),
|
||||
Some(&original_auth),
|
||||
"unbound official providers must keep native Codex auth instead of using the managed default account"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_common_config_flag_overrides_legacy_subset_detection() {
|
||||
let mut provider = Provider::with_id(
|
||||
|
||||
@@ -30,9 +30,11 @@ pub use live::{
|
||||
// Internal re-exports (pub(crate))
|
||||
pub(crate) use live::sanitize_claude_settings_for_live;
|
||||
pub(crate) use live::{
|
||||
build_effective_provider_for_live_with_codex_oauth_manager,
|
||||
build_effective_settings_with_common_config, normalize_provider_common_config_for_storage,
|
||||
provider_exists_in_live_config, strip_common_config_from_live_settings,
|
||||
sync_current_provider_for_app_to_live, write_live_with_common_config,
|
||||
sync_current_provider_for_app_to_live, write_live_with_common_config_for_codex_oauth_manager,
|
||||
write_live_with_common_config_for_state,
|
||||
};
|
||||
|
||||
// Internal re-exports
|
||||
@@ -59,7 +61,7 @@ mod tests {
|
||||
use crate::claude_desktop_config::PROFILE_ID;
|
||||
use crate::config::{get_claude_settings_path, read_json_file, write_json_file};
|
||||
use crate::database::Database;
|
||||
use crate::provider::ProviderMeta;
|
||||
use crate::provider::{AuthBinding, AuthBindingSource, ProviderMeta};
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
use crate::provider::{ClaudeDesktopMode, ClaudeDesktopModelRoute};
|
||||
use crate::proxy::types::ProxyConfig;
|
||||
@@ -831,6 +833,93 @@ base_url = "http://localhost:8080"
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn switch_from_managed_codex_official_to_unbound_clears_live_without_backfilling_token() {
|
||||
with_test_home(|state, _| {
|
||||
crate::settings::reload_settings().expect("reload settings");
|
||||
tauri::async_runtime::block_on(async {
|
||||
state
|
||||
.codex_oauth_manager
|
||||
.read()
|
||||
.await
|
||||
.add_test_account_with_access_token("acct-managed", "managed-token")
|
||||
.await
|
||||
.expect("seed managed Codex OAuth account");
|
||||
});
|
||||
|
||||
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-managed".to_string()),
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let mut unbound = Provider::with_id(
|
||||
"unbound-official".to_string(),
|
||||
"Unbound Official".to_string(),
|
||||
json!({
|
||||
"auth": {},
|
||||
"config": ""
|
||||
}),
|
||||
None,
|
||||
);
|
||||
unbound.category = Some("official".to_string());
|
||||
|
||||
state
|
||||
.db
|
||||
.save_provider(AppType::Codex.as_str(), &managed)
|
||||
.expect("save managed provider");
|
||||
state
|
||||
.db
|
||||
.save_provider(AppType::Codex.as_str(), &unbound)
|
||||
.expect("save unbound provider");
|
||||
|
||||
ProviderService::switch(state, AppType::Codex, "managed-official")
|
||||
.expect("switch to managed official");
|
||||
let live_auth: Value = read_json_file(&crate::codex_config::get_codex_auth_path())
|
||||
.expect("read managed live auth");
|
||||
assert_eq!(
|
||||
live_auth
|
||||
.pointer("/tokens/access_token")
|
||||
.and_then(Value::as_str),
|
||||
Some("managed-token"),
|
||||
"managed switch should write the selected ChatGPT token to live auth"
|
||||
);
|
||||
|
||||
ProviderService::switch(state, AppType::Codex, "unbound-official")
|
||||
.expect("switch to unbound official");
|
||||
|
||||
assert!(
|
||||
!crate::codex_config::get_codex_auth_path().exists(),
|
||||
"switching to an unbound official provider should clear the recorded managed live auth"
|
||||
);
|
||||
|
||||
let saved_managed = state
|
||||
.db
|
||||
.get_provider_by_id("managed-official", AppType::Codex.as_str())
|
||||
.expect("query managed provider")
|
||||
.expect("managed provider should exist");
|
||||
assert_eq!(
|
||||
saved_managed.settings_config.get("auth"),
|
||||
Some(&json!({})),
|
||||
"switch-away backfill must not persist the managed access token into provider storage"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn import_opencode_providers_from_live_marks_provider_as_live_managed() {
|
||||
@@ -1116,6 +1205,35 @@ base_url = "http://localhost:8080"
|
||||
}
|
||||
|
||||
impl ProviderService {
|
||||
fn managed_codex_oauth_account_id(provider: &Provider) -> Option<String> {
|
||||
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())
|
||||
}
|
||||
|
||||
fn unbound_managed_codex_oauth_account_id(
|
||||
app_type: &AppType,
|
||||
existing_provider: Option<&Provider>,
|
||||
provider: &Provider,
|
||||
) -> Option<String> {
|
||||
if !matches!(app_type, AppType::Codex) || provider.category.as_deref() != Some("official") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let existing_provider = existing_provider
|
||||
.filter(|existing| existing.category.as_deref() == Some("official"))?;
|
||||
let old_account_id = Self::managed_codex_oauth_account_id(existing_provider)?;
|
||||
|
||||
if Self::managed_codex_oauth_account_id(provider).is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(old_account_id)
|
||||
}
|
||||
|
||||
fn normalize_provider_if_claude(app_type: &AppType, provider: &mut Provider) {
|
||||
if matches!(app_type, AppType::Claude) {
|
||||
let mut v = provider.settings_config.clone();
|
||||
@@ -1209,7 +1327,7 @@ impl ProviderService {
|
||||
if !add_to_live {
|
||||
return Ok(true);
|
||||
}
|
||||
write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;
|
||||
write_live_with_common_config_for_state(state, &app_type, &provider)?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
@@ -1220,7 +1338,7 @@ impl ProviderService {
|
||||
state
|
||||
.db
|
||||
.set_current_provider(app_type.as_str(), &provider.id)?;
|
||||
write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;
|
||||
write_live_with_common_config_for_state(state, &app_type, &provider)?;
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
@@ -1370,10 +1488,16 @@ impl ProviderService {
|
||||
if !live_config_managed {
|
||||
return Ok(true);
|
||||
}
|
||||
write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;
|
||||
write_live_with_common_config_for_state(state, &app_type, &provider)?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let unbound_codex_managed_account_id = Self::unbound_managed_codex_oauth_account_id(
|
||||
&app_type,
|
||||
existing_provider.as_ref(),
|
||||
&provider,
|
||||
);
|
||||
|
||||
// Save to database
|
||||
state.db.save_provider(app_type.as_str(), &provider)?;
|
||||
|
||||
@@ -1401,14 +1525,32 @@ impl ProviderService {
|
||||
|
||||
if should_sync_via_proxy {
|
||||
if matches!(app_type, AppType::ClaudeDesktop) {
|
||||
write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;
|
||||
write_live_with_common_config_for_state(state, &app_type, &provider)?;
|
||||
} else {
|
||||
futures::executor::block_on(
|
||||
state
|
||||
.proxy_service
|
||||
.update_live_backup_from_provider(app_type.as_str(), &provider),
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
|
||||
let update_backup_result =
|
||||
if let Some(account_id) = unbound_codex_managed_account_id.as_deref() {
|
||||
futures::executor::block_on(
|
||||
state
|
||||
.proxy_service
|
||||
.update_live_backup_from_provider_clearing_codex_auth(
|
||||
app_type.as_str(),
|
||||
&provider,
|
||||
account_id,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
futures::executor::block_on(
|
||||
state
|
||||
.proxy_service
|
||||
.update_live_backup_from_provider(app_type.as_str(), &provider),
|
||||
)
|
||||
};
|
||||
update_backup_result
|
||||
.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)?;
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(app_type, AppType::Claude)
|
||||
@@ -1422,7 +1564,10 @@ impl ProviderService {
|
||||
.map_err(|e| AppError::Message(format!("同步 Claude Live 配置失败: {e}")))?;
|
||||
}
|
||||
} else {
|
||||
write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;
|
||||
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)?;
|
||||
}
|
||||
// Sync MCP
|
||||
McpService::sync_all_enabled(state)?;
|
||||
}
|
||||
@@ -1691,6 +1836,10 @@ impl ProviderService {
|
||||
// Backfill: Backfill current live config to current provider
|
||||
// Use effective current provider (validated existence) to ensure backfill targets valid provider
|
||||
let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?;
|
||||
let current_managed_codex_account_id = current_id
|
||||
.as_deref()
|
||||
.and_then(|current_id| providers.get(current_id))
|
||||
.and_then(Self::managed_codex_oauth_account_id);
|
||||
|
||||
if let Some(current_id) = current_id {
|
||||
if current_id != id {
|
||||
@@ -1731,7 +1880,14 @@ impl ProviderService {
|
||||
}
|
||||
|
||||
// Sync to live (write_gemini_live handles security flag internally for Gemini)
|
||||
write_live_with_common_config(state.db.as_ref(), &app_type, provider)?;
|
||||
write_live_with_common_config_for_state(state, &app_type, provider)?;
|
||||
if matches!(app_type, AppType::Codex)
|
||||
&& 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)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Hermes is additive, so "switching" doesn't overwrite a live config file
|
||||
// — we instead update the top-level `model:` section to point at this
|
||||
@@ -1831,7 +1987,7 @@ impl ProviderService {
|
||||
// here, not just proxy_config.enabled.
|
||||
if has_live_backup || live_taken_over {
|
||||
if matches!(app_type, AppType::ClaudeDesktop) {
|
||||
write_live_with_common_config(state.db.as_ref(), &app_type, provider)?;
|
||||
write_live_with_common_config_for_state(state, &app_type, provider)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,14 @@ use crate::app_config::AppType;
|
||||
use crate::config::{get_claude_settings_path, read_json_file, write_json_file};
|
||||
use crate::database::Database;
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
|
||||
use crate::proxy::server::ProxyServer;
|
||||
use crate::proxy::switch_lock::SwitchLockManager;
|
||||
use crate::proxy::types::*;
|
||||
use crate::services::provider::{
|
||||
build_effective_settings_with_common_config, write_live_with_common_config,
|
||||
build_effective_provider_for_live_with_codex_oauth_manager,
|
||||
build_effective_settings_with_common_config,
|
||||
write_live_with_common_config_for_codex_oauth_manager,
|
||||
};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::str::FromStr;
|
||||
@@ -54,6 +57,7 @@ enum ClaudeTakeoverAuthPolicy {
|
||||
#[derive(Clone)]
|
||||
pub struct ProxyService {
|
||||
db: Arc<Database>,
|
||||
codex_oauth_manager: Arc<RwLock<CodexOAuthManager>>,
|
||||
server: Arc<RwLock<Option<ProxyServer>>>,
|
||||
/// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新
|
||||
app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,
|
||||
@@ -67,8 +71,20 @@ 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(),
|
||||
)));
|
||||
|
||||
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>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
db,
|
||||
codex_oauth_manager,
|
||||
server: Arc::new(RwLock::new(None)),
|
||||
app_handle: Arc::new(RwLock::new(None)),
|
||||
switch_locks: SwitchLockManager::new(),
|
||||
@@ -1650,8 +1666,13 @@ impl ProxyService {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
write_live_with_common_config(self.db.as_ref(), app_type, provider)
|
||||
.map_err(|e| format!("写入 {app_type:?} Live 配置失败: {e}"))?;
|
||||
write_live_with_common_config_for_codex_oauth_manager(
|
||||
self.db.as_ref(),
|
||||
app_type,
|
||||
provider,
|
||||
&self.codex_oauth_manager,
|
||||
)
|
||||
.map_err(|e| format!("写入 {app_type:?} Live 配置失败: {e}"))?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
@@ -1975,7 +1996,18 @@ impl ProxyService {
|
||||
provider: &Provider,
|
||||
) -> Result<(), String> {
|
||||
let _guard = self.switch_locks.lock_for_app(app_type).await;
|
||||
self.update_live_backup_from_provider_inner(app_type, provider)
|
||||
self.update_live_backup_from_provider_inner(app_type, provider, None)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn update_live_backup_from_provider_clearing_codex_auth(
|
||||
&self,
|
||||
app_type: &str,
|
||||
provider: &Provider,
|
||||
account_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let _guard = self.switch_locks.lock_for_app(app_type).await;
|
||||
self.update_live_backup_from_provider_inner(app_type, provider, Some(account_id))
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -1984,12 +2016,23 @@ impl ProxyService {
|
||||
&self,
|
||||
app_type: &str,
|
||||
provider: &Provider,
|
||||
clear_codex_auth_for_account: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let app_type_enum =
|
||||
AppType::from_str(app_type).map_err(|_| format!("未知的应用类型: {app_type}"))?;
|
||||
let mut effective_settings =
|
||||
let mut effective_settings = if matches!(app_type_enum, AppType::Codex) {
|
||||
build_effective_provider_for_live_with_codex_oauth_manager(
|
||||
self.db.as_ref(),
|
||||
&app_type_enum,
|
||||
provider,
|
||||
&self.codex_oauth_manager,
|
||||
)
|
||||
.map_err(|e| format!("构建 {app_type} 有效配置失败: {e}"))?
|
||||
.settings_config
|
||||
} else {
|
||||
build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)
|
||||
.map_err(|e| format!("构建 {app_type} 有效配置失败: {e}"))?;
|
||||
.map_err(|e| format!("构建 {app_type} 有效配置失败: {e}"))?
|
||||
};
|
||||
|
||||
if matches!(app_type_enum, AppType::Codex) {
|
||||
let existing_backup_value = self
|
||||
@@ -2008,7 +2051,19 @@ impl ProxyService {
|
||||
&mut effective_settings,
|
||||
existing_value,
|
||||
)?;
|
||||
Self::preserve_codex_oauth_auth_in_backup(&mut effective_settings, existing_value)?;
|
||||
if let Some(account_id) = clear_codex_auth_for_account {
|
||||
Self::clear_codex_auth_in_backup(
|
||||
&mut effective_settings,
|
||||
existing_value,
|
||||
account_id,
|
||||
)?;
|
||||
} else {
|
||||
Self::preserve_codex_oauth_auth_in_backup(
|
||||
&mut effective_settings,
|
||||
existing_value,
|
||||
provider,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2035,6 +2090,19 @@ 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(())
|
||||
}
|
||||
@@ -2091,7 +2159,7 @@ impl ProxyService {
|
||||
.map_err(|e| format!("更新本地当前供应商失败: {e}"))?;
|
||||
|
||||
if should_sync_backup {
|
||||
self.update_live_backup_from_provider_inner(app_type, &provider)
|
||||
self.update_live_backup_from_provider_inner(app_type, &provider, None)
|
||||
.await?;
|
||||
|
||||
if matches!(app_type_enum, AppType::Claude) {
|
||||
@@ -2201,14 +2269,48 @@ impl ProxyService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear_codex_auth_in_backup(
|
||||
target_settings: &mut Value,
|
||||
existing_backup: &Value,
|
||||
account_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let Some(existing_auth) = existing_backup.get("auth") else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(target_obj) = target_settings.as_object_mut() else {
|
||||
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}"))?
|
||||
{
|
||||
target_obj.insert("auth".to_string(), json!({}));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
target_obj.insert("auth".to_string(), existing_auth.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn preserve_codex_oauth_auth_in_backup(
|
||||
target_settings: &mut Value,
|
||||
existing_backup: &Value,
|
||||
provider: &Provider,
|
||||
) -> Result<(), String> {
|
||||
if !crate::settings::preserve_codex_official_auth_on_switch() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.managed_account_id_for("codex_oauth"))
|
||||
.map(|id| !id.trim().is_empty())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(existing_auth) = existing_backup
|
||||
.get("auth")
|
||||
.filter(|auth| crate::codex_config::codex_auth_has_oauth_login_material(auth))
|
||||
@@ -2668,7 +2770,7 @@ impl ProxyService {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::provider::ProviderMeta;
|
||||
use crate::provider::{AuthBinding, AuthBindingSource, ProviderMeta};
|
||||
use serial_test::serial;
|
||||
use std::env;
|
||||
use tempfile::TempDir;
|
||||
@@ -4826,6 +4928,161 @@ base_url = "https://codex.example/v1"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn update_live_backup_from_provider_uses_managed_codex_oauth_binding_over_existing_oauth_backup(
|
||||
) {
|
||||
let _home = TempHome::new();
|
||||
crate::settings::reload_settings().expect("reload settings");
|
||||
crate::settings::update_settings(crate::settings::AppSettings {
|
||||
preserve_codex_official_auth_on_switch: true,
|
||||
..Default::default()
|
||||
})
|
||||
.expect("enable official auth preservation");
|
||||
|
||||
let db = Arc::new(Database::memory().expect("init db"));
|
||||
let service = ProxyService::new(db.clone());
|
||||
service
|
||||
.codex_oauth_manager
|
||||
.read()
|
||||
.await
|
||||
.add_test_account_with_access_token("acct-managed", "managed-token")
|
||||
.await
|
||||
.expect("seed managed Codex OAuth account");
|
||||
|
||||
db.save_live_backup(
|
||||
"codex",
|
||||
&serde_json::to_string(&json!({
|
||||
"auth": {
|
||||
"auth_mode": "chatgpt",
|
||||
"OPENAI_API_KEY": null,
|
||||
"tokens": {
|
||||
"access_token": "old-native-token",
|
||||
"account_id": "acct-native"
|
||||
}
|
||||
},
|
||||
"config": ""
|
||||
}))
|
||||
.expect("serialize seed backup"),
|
||||
)
|
||||
.await
|
||||
.expect("seed live backup");
|
||||
|
||||
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()
|
||||
});
|
||||
|
||||
service
|
||||
.update_live_backup_from_provider("codex", &provider)
|
||||
.await
|
||||
.expect("update live backup");
|
||||
|
||||
let backup = db
|
||||
.get_live_backup("codex")
|
||||
.await
|
||||
.expect("get live backup")
|
||||
.expect("backup exists");
|
||||
let stored: Value =
|
||||
serde_json::from_str(&backup.original_config).expect("parse backup json");
|
||||
|
||||
assert_eq!(
|
||||
stored
|
||||
.pointer("/auth/tokens/access_token")
|
||||
.and_then(|value| value.as_str()),
|
||||
Some("managed-token"),
|
||||
"explicit managed account binding must replace stale native OAuth backup auth"
|
||||
);
|
||||
assert_eq!(
|
||||
stored
|
||||
.pointer("/auth/tokens/account_id")
|
||||
.and_then(|value| value.as_str()),
|
||||
Some("acct-managed")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn update_live_backup_clearing_managed_codex_auth_preserves_native_backup_auth() {
|
||||
let _home = TempHome::new();
|
||||
crate::settings::reload_settings().expect("reload settings");
|
||||
crate::settings::update_settings(crate::settings::AppSettings {
|
||||
preserve_codex_official_auth_on_switch: true,
|
||||
..Default::default()
|
||||
})
|
||||
.expect("enable official auth preservation");
|
||||
|
||||
let db = Arc::new(Database::memory().expect("init db"));
|
||||
let service = ProxyService::new(db.clone());
|
||||
let native_auth = json!({
|
||||
"auth_mode": "chatgpt",
|
||||
"OPENAI_API_KEY": null,
|
||||
"tokens": {
|
||||
"access_token": "native-token",
|
||||
"account_id": "acct-managed"
|
||||
}
|
||||
});
|
||||
|
||||
db.save_live_backup(
|
||||
"codex",
|
||||
&serde_json::to_string(&json!({
|
||||
"auth": native_auth,
|
||||
"config": ""
|
||||
}))
|
||||
.expect("serialize seed backup"),
|
||||
)
|
||||
.await
|
||||
.expect("seed live backup");
|
||||
|
||||
let mut provider = Provider::with_id(
|
||||
"openai-official".to_string(),
|
||||
"OpenAI Official".to_string(),
|
||||
json!({
|
||||
"auth": {},
|
||||
"config": ""
|
||||
}),
|
||||
None,
|
||||
);
|
||||
provider.category = Some("official".to_string());
|
||||
|
||||
service
|
||||
.update_live_backup_from_provider_clearing_codex_auth(
|
||||
"codex",
|
||||
&provider,
|
||||
"acct-managed",
|
||||
)
|
||||
.await
|
||||
.expect("update live backup");
|
||||
|
||||
let backup = db
|
||||
.get_live_backup("codex")
|
||||
.await
|
||||
.expect("get live backup")
|
||||
.expect("backup exists");
|
||||
let stored: Value =
|
||||
serde_json::from_str(&backup.original_config).expect("parse backup json");
|
||||
|
||||
assert_eq!(
|
||||
stored.get("auth"),
|
||||
Some(&native_auth),
|
||||
"unbinding should not overwrite a later native Codex login in the backup"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {
|
||||
|
||||
@@ -1,23 +1,31 @@
|
||||
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>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// 创建新的应用状态
|
||||
pub fn new(db: Arc<Database>) -> Self {
|
||||
let proxy_service = ProxyService::new(db.clone());
|
||||
let codex_oauth_manager = Arc::new(RwLock::new(CodexOAuthManager::new(
|
||||
crate::config::get_app_config_dir(),
|
||||
)));
|
||||
let proxy_service =
|
||||
ProxyService::new_with_codex_oauth_manager(db.clone(), codex_oauth_manager.clone());
|
||||
|
||||
Self {
|
||||
db,
|
||||
proxy_service,
|
||||
usage_cache: Arc::new(UsageCache::new()),
|
||||
codex_oauth_manager,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user