Harden Codex takeover ownership signaling and serialize switch/takeover

Gate provider sync and switching on the restore backup / live placeholder
("is this live file owned by takeover?") instead of the lagging
proxy_config.enabled and proxy-running flags. The backup is created
before enabled=true is committed, so during that activation window the
old guards were blind and a concurrent sync/switch could rewrite the
taken-over live file, clearing Codex auth.json for a mis-categorized
provider.

Acquire a per-app switch lock around both set_takeover_for_app and
provider switching so the two cannot interleave, splitting the locking
entry points into outer (lock) / inner (no-lock) pairs to stay
deadlock-free. Preserve the official OAuth auth in provider-rebuilt
restore backups by routing the provider token into config.toml. Refine
takeover idempotency to require the live config to point at the current
proxy URL, rebuilding from backup when it does not.

Add unit and integration tests covering the official -> DeepSeek ->
takeover on/off lifecycle and the stopped-proxy switch path.
This commit is contained in:
Jason
2026-05-31 21:01:09 +08:00
Unverified
parent a04e72a267
commit 2a131a5572
5 changed files with 1014 additions and 49 deletions
+24
View File
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
Development since v3.16.0 focuses on preserving Codex OAuth auth and model catalogs during provider switches / proxy takeover, improving Codex proxy error diagnostics, adding DeepSeek routing documentation, and fixing Windows tool version probing.
**Stats**: 12 commits | 41 files changed | +1,992 insertions | -739 deletions
### Added
- **Codex Official Auth Preservation Setting**: Added an opt-in setting that keeps the official ChatGPT / Codex OAuth login in `auth.json` when switching third-party Codex providers, while moving third-party provider tokens into `config.toml` when enabled.
- **Codex DeepSeek Routing Guides**: Added localized DeepSeek routing guides for Codex in English, Chinese, and Japanese, with screenshots covering provider routing requirements, Codex provider setup, and Local Routing takeover.
### Changed
- **Codex Auth Preservation Is Opt-In**: The new official-auth preservation setting now defaults to off, so third-party Codex switches keep the legacy behavior of writing the active provider auth unless users explicitly enable preservation.
- **Codex Provider Switch Restart Hint**: Successful Codex provider switches now tell users to restart the Codex client so catalog and config changes take effect.
- **Sponsor Ordering**: Swapped the Shengsuanyun and AICodeMirror sponsor blocks across README locales.
- **Docs Organization**: Moved the Chinese proxy guide under `docs/guides/` and removed the obsolete working-directory plan document.
### Fixed
- **Codex Provider Edit Dialog Under Takeover**: The Codex provider edit form now shows an explicit notice and storage-aware auth / config hints clarifying that it displays the stored provider config (not the proxy-managed live `auth.json` / `config.toml`), so the official OAuth token is no longer mistaken as lost while takeover is active. The dialog also treats takeover as active regardless of whether the proxy server is currently running.
- **Codex OAuth Auth During Proxy Takeover**: Fixed multiple preserve-mode takeover paths that could clear or overwrite the official ChatGPT / Codex OAuth `auth.json`. Takeover detection now recognizes `PROXY_MANAGED` in `config.toml`, cleanup only removes placeholder bearer tokens, config-only takeover writes are used consistently, and mis-categorized third-party providers no longer trigger the official-provider auth overwrite path. Provider sync and switching now treat the restore backup and live placeholders as the takeover-ownership signal (instead of the lagging `enabled` / proxy-running flags) and serialize switch/takeover per app, so a just-activated or proxy-stopped takeover can no longer be overwritten by a normal live write.
- **Codex Model Catalog Data Loss**: Fixed cases where Codex `modelCatalog` could be wiped by live-config backfill, active-provider edit dialogs, or proxy takeover-off restore. Snapshot backups now keep existing `model_catalog_json` pointers, while provider-rebuilt backups regenerate the catalog projection from the database source of truth.
- **Codex Proxy Error Diagnostics**: Codex forwarding failures now return richer JSON errors with provider, model, endpoint, upstream status, stable `cc_switch_*` codes, and HTTP statuses aligned with the canonical `ProxyError` response mapping.
- **Windows Tool Version Probing**: Fixed Windows version checks that could misquote `.cmd` / `.bat` commands and decode localized command output as mojibake, causing working tools to appear as "installed but not runnable".
## [3.16.0] - 2026-05-29
Development since v3.15.0 focuses on making third-party Codex providers work like first-class citizens through Chat Completions routing, stabilizing Codex provider identity and history, adding an in-app managed CLI tool lifecycle, expanding the partner preset catalog, refreshing the default model / pricing matrix around GPT-5.5 and Claude Opus 4.8, and improving usage observability, localization, docs, and proxy robustness.
+46 -13
View File
@@ -923,6 +923,48 @@ pub(crate) fn sync_current_provider_for_app_to_live(
Ok(())
}
fn sync_current_provider_for_app_respecting_takeover(
state: &AppState,
app_type: &AppType,
) -> Result<(), AppError> {
let current_id = match crate::settings::get_effective_current_provider(&state.db, app_type)? {
Some(id) => id,
None => return Ok(()),
};
let providers = state.db.get_all_providers(app_type.as_str())?;
let Some(provider) = providers.get(&current_id) else {
return Ok(());
};
let has_live_backup = futures::executor::block_on(state.db.get_live_backup(app_type.as_str()))
.ok()
.flatten()
.is_some();
let live_taken_over = state
.proxy_service
.detect_takeover_in_live_config_for_app(app_type);
// `enabled` is set only after takeover writes complete. During that
// activation window, backup/live placeholders are the authoritative signal
// 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)?;
} 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}")))?;
}
return Ok(());
}
write_live_with_common_config(state.db.as_ref(), app_type, provider)
}
/// Sync current provider to live configuration
///
/// 使用有效的当前供应商 ID(验证过存在性)。
@@ -937,19 +979,10 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
// Additive mode: sync ALL providers
sync_all_providers_to_live(state, &app_type)?;
} else {
// Switch mode: sync only current provider
let current_id =
match crate::settings::get_effective_current_provider(&state.db, &app_type)? {
Some(id) => id,
None => continue,
};
let providers = state.db.get_all_providers(app_type.as_str())?;
if let Some(provider) = providers.get(&current_id) {
write_live_with_common_config(state.db.as_ref(), &app_type, provider)?;
}
// Note: get_effective_current_provider already validates existence,
// so providers.get() should always succeed here
// Switch mode: sync only current provider. During proxy takeover,
// update the restore backup instead of rewriting the taken-over
// live file.
sync_current_provider_for_app_respecting_takeover(state, &app_type)?;
}
}
+28 -16
View File
@@ -1391,11 +1391,13 @@ impl ProviderService {
.ok()
.flatten()
.is_some();
let is_proxy_running = futures::executor::block_on(state.proxy_service.is_running());
let live_taken_over = state
.proxy_service
.detect_takeover_in_live_config_for_app(&app_type);
let should_sync_via_proxy = is_proxy_running && (has_live_backup || live_taken_over);
// Backup or live placeholders mean the live file is currently owned
// by proxy takeover, including the short activation window before
// proxy_config.enabled is committed.
let should_sync_via_proxy = has_live_backup || live_taken_over;
if should_sync_via_proxy {
if matches!(app_type, AppType::ClaudeDesktop) {
@@ -1409,7 +1411,9 @@ impl ProviderService {
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
}
if matches!(app_type, AppType::Claude) {
if matches!(app_type, AppType::Claude)
&& futures::executor::block_on(state.proxy_service.is_running())
{
futures::executor::block_on(
state
.proxy_service
@@ -1589,21 +1593,32 @@ impl ProviderService {
return Self::switch_normal(state, app_type, id, &providers);
}
// Check if proxy takeover mode is active AND proxy server is actually running
// Both conditions must be true to use hot-switch mode
// Use blocking wait since this is a sync function
// Provider switches and takeover toggles both mutate live config and the
// restore backup. Serialize them per app, then decide from the locked
// current state so a just-started takeover cannot be overwritten by a
// normal live write.
let _switch_guard =
if matches!(app_type, AppType::Claude | AppType::Codex | AppType::Gemini) {
Some(futures::executor::block_on(
state.proxy_service.lock_switch_for_app(app_type.as_str()),
))
} else {
None
};
// Backup or live placeholders mean the live file is owned by proxy
// takeover, even if the proxy server is temporarily stopped or is in the
// activation window before enabled=true is committed.
let is_app_taken_over =
futures::executor::block_on(state.db.get_live_backup(app_type.as_str()))
.ok()
.flatten()
.is_some();
let is_proxy_running = futures::executor::block_on(state.proxy_service.is_running());
let live_taken_over = state
.proxy_service
.detect_takeover_in_live_config_for_app(&app_type);
// Hot-switch only when BOTH: this app is taken over AND proxy server is actually running
let should_hot_switch = (is_app_taken_over || live_taken_over) && is_proxy_running;
let should_hot_switch = is_app_taken_over || live_taken_over;
// Block switching to official providers when proxy takeover is active.
// Using a proxy with official APIs (Anthropic/OpenAI/Google) may cause account bans.
@@ -1626,7 +1641,7 @@ impl ProviderService {
futures::executor::block_on(
state
.proxy_service
.hot_switch_provider(app_type.as_str(), id),
.hot_switch_provider_inner(app_type.as_str(), id),
)
.map_err(|e| AppError::Message(format!("热切换失败: {e}")))?;
@@ -1800,11 +1815,6 @@ impl ProviderService {
return Ok(());
};
let takeover_enabled =
futures::executor::block_on(state.db.get_proxy_config_for_app(app_type.as_str()))
.map(|config| config.enabled)
.unwrap_or(false);
let has_live_backup =
futures::executor::block_on(state.db.get_live_backup(app_type.as_str()))
.ok()
@@ -1815,7 +1825,9 @@ impl ProviderService {
.proxy_service
.detect_takeover_in_live_config_for_app(&app_type);
if takeover_enabled && (has_live_backup || live_taken_over) {
// See the save path above: backup/placeholders are the ownership signal
// 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)?;
return Ok(());
+602 -20
View File
@@ -385,6 +385,13 @@ impl ProxyService {
});
}
pub(crate) async fn lock_switch_for_app(
&self,
app_type: &str,
) -> tokio::sync::OwnedMutexGuard<()> {
self.switch_locks.lock_for_app(app_type).await
}
/// 启动代理服务器
pub async fn start(&self) -> Result<ProxyServerInfo, String> {
// 1. 启动时自动设置 proxy_enabled = true
@@ -535,6 +542,7 @@ impl ProxyService {
pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> {
let app = AppType::from_str(app_type).map_err(|e| format!("无效的应用类型: {e}"))?;
let app_type_str = app.as_str();
let _guard = self.switch_locks.lock_for_app(app_type_str).await;
if enabled {
// 1) 代理服务未运行则自动启动
@@ -549,6 +557,7 @@ impl ProxyService {
.await
.map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?;
let mut restore_existing_backup_before_takeover = false;
if current_config.enabled {
let has_backup = match self.db.get_live_backup(app_type_str).await {
Ok(v) => v.is_some(),
@@ -557,33 +566,45 @@ impl ProxyService {
false
}
};
let live_taken_over = self.detect_takeover_in_live_config_for_app(&app);
let live_matches_current_proxy =
match self.live_takeover_matches_current_proxy(&app).await {
Ok(value) => value,
Err(e) => {
log::warn!("检测 {app_type_str} 接管配置失败(将继续重建接管): {e}");
false
}
};
// 必须 backup AND live 占位符同时存在才算真接管。
// 只看其一会出现「UI 显示已接管但 Live 已被恢复」或「Live 仍是占位符但备份丢失」
// 两种脏角落,下面的重建分支会把这些情况修复成一致状态
if has_backup && live_taken_over {
// 必须 backup 存在,且 live 确实指向当前代理地址,才算真接管。
// 只看占位符会把半接管/旧端口残留误判为可复用,导致开启接管后
// live 文件仍停留在普通供应商配置
if has_backup && live_matches_current_proxy {
return Ok(());
}
restore_existing_backup_before_takeover = has_backup;
log::warn!(
"{app_type_str} 标记为已接管,但 backup={has_backup} live_taken_over={live_taken_over},正在重新接管并补齐备份"
"{app_type_str} 标记为已接管,但 backup={has_backup} live_matches_current_proxy={live_matches_current_proxy},正在重新接管并补齐 Live"
);
}
// 3) 备份 Live 配置(严格:目标 app 不存在则报错)
self.backup_live_config_strict(&app).await?;
if restore_existing_backup_before_takeover {
self.restore_live_config_for_app_inner(&app).await?;
} else {
self.backup_live_config_strict(&app).await?;
// 4) 同步 Live Token 到数据库(仅当前 app
if let Err(e) = self.sync_live_to_provider(&app).await {
let _ = self.db.delete_live_backup(app_type_str).await;
return Err(e);
// 4) 同步 Live Token 到数据库(仅当前 app
if let Err(e) = self.sync_live_to_provider(&app).await {
let _ = self.db.delete_live_backup(app_type_str).await;
return Err(e);
}
}
// 5) 写入接管配置(仅当前 app)
if let Err(e) = self.takeover_live_config_strict(&app).await {
log::error!("{app_type_str} 接管 Live 配置失败,尝试恢复: {e}");
match self.restore_live_config_for_app(&app).await {
match self.restore_live_config_for_app_inner(&app).await {
Ok(()) => {
// 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源
let _ = self.db.delete_live_backup(app_type_str).await;
@@ -650,7 +671,8 @@ impl ProxyService {
// 必须走 with_fallback 版本:备份 → SSOT → 清理占位符 的三层兜底。
// 简版 restore_live_config_for_app 在备份缺失时会静默 Ok(()),
// 留下接管时写入的占位符(代理地址/PROXY_MANAGED token),客户端无法工作。
self.restore_live_config_for_app_with_fallback(&app).await?;
self.restore_live_config_for_app_with_fallback_inner(&app)
.await?;
// 2) 删除该 app 的备份(避免长期存储敏感 Token)
self.db
@@ -1360,12 +1382,6 @@ impl ProxyService {
Ok(())
}
/// 恢复指定应用的 Live 配置(若无备份则不做任何操作)
async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {
let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;
self.restore_live_config_for_app_inner(app_type).await
}
async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {
match app_type {
AppType::Claude => {
@@ -1558,6 +1574,82 @@ impl ProxyService {
|| rest.starts_with("::")
}
fn proxy_urls_match(actual: &str, expected: &str) -> bool {
actual.trim().trim_end_matches('/') == expected.trim().trim_end_matches('/')
}
fn codex_config_has_base_url_matching(
config_text: &str,
predicate: impl Fn(&str) -> bool,
) -> bool {
let Ok(doc) = toml::from_str::<toml::Value>(config_text) else {
return false;
};
let active_provider = doc
.get("model_provider")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|id| !id.is_empty());
if let Some(provider_id) = active_provider {
if doc
.get("model_providers")
.and_then(|value| value.get(provider_id))
.and_then(|value| value.get("base_url"))
.and_then(|value| value.as_str())
.is_some_and(&predicate)
{
return true;
}
}
doc.get("base_url")
.and_then(|value| value.as_str())
.is_some_and(predicate)
}
async fn live_takeover_matches_current_proxy(
&self,
app_type: &AppType,
) -> Result<bool, String> {
let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;
match app_type {
AppType::Claude => {
let config = self.read_claude_live()?;
let base_url_matches = config
.get("env")
.and_then(|value| value.get("ANTHROPIC_BASE_URL"))
.and_then(|value| value.as_str())
.is_some_and(|url| Self::proxy_urls_match(url, &proxy_url));
Ok(Self::is_claude_live_taken_over(&config) && base_url_matches)
}
AppType::Codex => {
let config = self.read_codex_live()?;
let base_url_matches = config
.get("config")
.and_then(|value| value.as_str())
.is_some_and(|config_text| {
Self::codex_config_has_base_url_matching(config_text, |url| {
Self::proxy_urls_match(url, &proxy_codex_base_url)
})
});
Ok(Self::codex_live_has_proxy_placeholder(&config) && base_url_matches)
}
AppType::Gemini => {
let config = self.read_gemini_live()?;
let base_url_matches = config
.get("env")
.and_then(|value| value.get("GOOGLE_GEMINI_BASE_URL"))
.and_then(|value| value.as_str())
.is_some_and(|url| Self::proxy_urls_match(url, &proxy_url));
Ok(Self::is_gemini_live_taken_over(&config) && base_url_matches)
}
_ => Ok(false),
}
}
fn cleanup_claude_takeover_placeholders_in_live(&self) -> Result<(), String> {
let mut config = self.read_claude_live()?;
@@ -1718,7 +1810,7 @@ impl ProxyService {
false
}
fn is_codex_live_taken_over(config: &Value) -> bool {
fn codex_live_has_proxy_placeholder(config: &Value) -> bool {
if config
.get("auth")
.and_then(|v| v.as_object())
@@ -1737,6 +1829,10 @@ impl ProxyService {
== Some(PROXY_TOKEN_PLACEHOLDER)
}
fn is_codex_live_taken_over(config: &Value) -> bool {
Self::codex_live_has_proxy_placeholder(config)
}
fn is_gemini_live_taken_over(config: &Value) -> bool {
let env = match config.get("env").and_then(|v| v.as_object()) {
Some(env) => env,
@@ -1788,6 +1884,7 @@ impl ProxyService {
&mut effective_settings,
existing_value,
)?;
Self::preserve_codex_oauth_auth_in_backup(&mut effective_settings, existing_value)?;
}
}
@@ -1824,7 +1921,14 @@ impl ProxyService {
provider_id: &str,
) -> Result<HotSwitchOutcome, String> {
let _guard = self.switch_locks.lock_for_app(app_type).await;
self.hot_switch_provider_inner(app_type, provider_id).await
}
pub(crate) async fn hot_switch_provider_inner(
&self,
app_type: &str,
provider_id: &str,
) -> Result<HotSwitchOutcome, String> {
let app_type_enum =
AppType::from_str(app_type).map_err(|_| format!("无效的应用类型: {app_type}"))?;
let provider = self
@@ -1973,6 +2077,40 @@ impl ProxyService {
Ok(())
}
fn preserve_codex_oauth_auth_in_backup(
target_settings: &mut Value,
existing_backup: &Value,
) -> Result<(), String> {
if !crate::settings::preserve_codex_official_auth_on_switch() {
return Ok(());
}
let Some(existing_auth) = existing_backup
.get("auth")
.filter(|auth| crate::codex_config::codex_auth_has_oauth_login_material(auth))
.cloned()
else {
return Ok(());
};
let Some(target_obj) = target_settings.as_object_mut() else {
return Ok(());
};
let provider_auth = target_obj.get("auth").cloned().unwrap_or_else(|| json!({}));
if let Some(config_text) = target_obj.get("config").and_then(|value| value.as_str()) {
let live_config = crate::codex_config::prepare_codex_provider_live_config(
&provider_auth,
config_text,
)
.map_err(|e| format!("更新 Codex 备份配置失败: {e}"))?;
target_obj.insert("config".to_string(), json!(live_config));
}
target_obj.insert("auth".to_string(), existing_auth);
Ok(())
}
/// 代理模式下切换供应商(热切换,不写 Live)
pub async fn switch_proxy_target(
&self,
@@ -2941,6 +3079,450 @@ wire_api = "responses"
.expect("reset settings");
}
#[tokio::test]
#[serial]
async fn codex_set_takeover_for_app_preserves_oauth_auth_json_when_preserve_enabled() {
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 Codex official auth preservation");
let db = Arc::new(Database::memory().expect("init db"));
let service = ProxyService::new(db.clone());
let oauth_auth = json!({
"auth_mode": "chatgpt",
"tokens": {
"id_token": "oauth-id",
"access_token": "oauth-access"
}
});
let deepseek_live_config = r#"model_provider = "deepseek"
model = "deepseek-v4-flash"
[model_providers.deepseek]
name = "DeepSeek"
base_url = "https://api.deepseek.com/v1"
wire_api = "responses"
experimental_bearer_token = "deepseek-key"
"#;
crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))
.expect("seed live OAuth auth with DeepSeek config");
let mut provider = Provider::with_id(
"deepseek".to_string(),
"DeepSeek".to_string(),
json!({
"auth": {
"OPENAI_API_KEY": "deepseek-key"
},
"config": r#"model_provider = "deepseek"
model = "deepseek-v4-flash"
[model_providers.deepseek]
name = "DeepSeek"
base_url = "https://api.deepseek.com/v1"
wire_api = "responses"
"#
}),
None,
);
provider.category = Some("official".to_string());
db.save_provider("codex", &provider)
.expect("save misclassified DeepSeek provider");
db.set_current_provider("codex", "deepseek")
.expect("set current provider");
crate::settings::set_current_provider(&AppType::Codex, Some("deepseek"))
.expect("set local current provider");
service
.set_takeover_for_app("codex", true)
.await
.expect("enable Codex takeover");
let live_auth: Value =
crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())
.expect("read live auth");
assert_eq!(
live_auth, oauth_auth,
"the public takeover command path must not rewrite auth.json when preservation is enabled"
);
service
.set_takeover_for_app("codex", false)
.await
.expect("disable Codex takeover");
crate::settings::update_settings(crate::settings::AppSettings::default())
.expect("reset settings");
}
#[tokio::test]
#[serial]
async fn codex_sync_current_to_live_during_takeover_preserves_oauth_auth_json() {
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 Codex official auth preservation");
let db = Arc::new(Database::memory().expect("init db"));
let state = crate::store::AppState::new(db.clone());
let oauth_auth = json!({
"auth_mode": "chatgpt",
"tokens": {
"id_token": "oauth-id",
"access_token": "oauth-access"
}
});
let deepseek_live_config = r#"model_provider = "deepseek"
model = "deepseek-v4-flash"
[model_providers.deepseek]
name = "DeepSeek"
base_url = "https://api.deepseek.com/v1"
wire_api = "responses"
experimental_bearer_token = "deepseek-key"
"#;
crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))
.expect("seed live OAuth auth with DeepSeek config");
let mut provider = Provider::with_id(
"deepseek".to_string(),
"DeepSeek".to_string(),
json!({
"auth": {
"OPENAI_API_KEY": "deepseek-key"
},
"config": r#"model_provider = "deepseek"
model = "deepseek-v4-flash"
[model_providers.deepseek]
name = "DeepSeek"
base_url = "https://api.deepseek.com/v1"
wire_api = "responses"
"#
}),
None,
);
provider.category = Some("official".to_string());
db.save_provider("codex", &provider)
.expect("save misclassified DeepSeek provider");
db.set_current_provider("codex", "deepseek")
.expect("set current provider");
crate::settings::set_current_provider(&AppType::Codex, Some("deepseek"))
.expect("set local current provider");
state
.proxy_service
.set_takeover_for_app("codex", true)
.await
.expect("enable Codex takeover");
crate::services::provider::ProviderService::sync_current_to_live(&state)
.expect("sync current providers while Codex is taken over");
let live_auth: Value =
crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())
.expect("read live auth");
assert_eq!(
live_auth, oauth_auth,
"post-change provider sync must not rewrite Codex auth.json during takeover"
);
let backup = db
.get_live_backup("codex")
.await
.expect("get live backup")
.expect("backup exists");
let backup_value: Value =
serde_json::from_str(&backup.original_config).expect("parse backup");
assert_eq!(
backup_value.get("auth"),
Some(&oauth_auth),
"provider-derived takeover backup should preserve official OAuth auth"
);
assert!(
backup_value
.get("config")
.and_then(|value| value.as_str())
.is_some_and(|config| config.contains("deepseek-key")),
"provider token should be carried by config.toml in the restore backup"
);
state
.proxy_service
.set_takeover_for_app("codex", false)
.await
.expect("disable Codex takeover");
let restored_auth: Value =
crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())
.expect("read restored auth");
assert_eq!(
restored_auth, oauth_auth,
"turning takeover off should restore the preserved official OAuth auth"
);
crate::settings::update_settings(crate::settings::AppSettings::default())
.expect("reset settings");
}
#[tokio::test]
#[serial]
async fn codex_sync_current_to_live_during_takeover_activation_keeps_proxy_live_config() {
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 Codex official auth preservation");
let db = Arc::new(Database::memory().expect("init db"));
let state = crate::store::AppState::new(db.clone());
let oauth_auth = json!({
"auth_mode": "chatgpt",
"tokens": {
"id_token": "oauth-id",
"access_token": "oauth-access"
}
});
let deepseek_live_config = r#"model_provider = "deepseek"
model = "deepseek-v4-flash"
[model_providers.deepseek]
name = "DeepSeek"
base_url = "https://api.deepseek.com/v1"
wire_api = "responses"
experimental_bearer_token = "deepseek-key"
"#;
crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))
.expect("seed live OAuth auth with DeepSeek config");
let mut provider = Provider::with_id(
"deepseek".to_string(),
"DeepSeek".to_string(),
json!({
"auth": {
"OPENAI_API_KEY": "deepseek-key"
},
"config": r#"model_provider = "deepseek"
model = "deepseek-v4-flash"
[model_providers.deepseek]
name = "DeepSeek"
base_url = "https://api.deepseek.com/v1"
wire_api = "responses"
"#
}),
None,
);
provider.category = Some("official".to_string());
db.save_provider("codex", &provider)
.expect("save misclassified DeepSeek provider");
db.set_current_provider("codex", "deepseek")
.expect("set current provider");
crate::settings::set_current_provider(&AppType::Codex, Some("deepseek"))
.expect("set local current provider");
state
.proxy_service
.backup_live_config_strict(&AppType::Codex)
.await
.expect("backup Codex live config");
state
.proxy_service
.takeover_live_config_strict(&AppType::Codex)
.await
.expect("take over Codex live config");
assert!(
!db.get_proxy_config_for_app("codex")
.await
.expect("get Codex proxy config")
.enabled,
"this reproduces the activation window before set_takeover_for_app marks enabled=true"
);
crate::services::provider::ProviderService::sync_current_to_live(&state)
.expect("sync current providers during takeover activation");
let live_auth: Value =
crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())
.expect("read live auth");
assert_eq!(
live_auth, oauth_auth,
"activation-time provider sync must not rewrite Codex OAuth auth.json"
);
let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())
.expect("read live config");
assert!(
live_config.contains(PROXY_TOKEN_PLACEHOLDER),
"activation-time provider sync must keep the proxy bearer placeholder"
);
assert!(
live_config.contains("http://127.0.0.1"),
"activation-time provider sync must keep the local proxy base_url"
);
assert!(
state
.proxy_service
.detect_takeover_in_live_config_for_app(&AppType::Codex),
"Codex live config should still be detected as taken over"
);
crate::settings::update_settings(crate::settings::AppSettings::default())
.expect("reset settings");
}
#[tokio::test]
#[serial]
async fn codex_set_takeover_rebuilds_stale_enabled_state_without_overwriting_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 Codex official auth preservation");
let db = Arc::new(Database::memory().expect("init db"));
let service = ProxyService::new(db.clone());
let oauth_auth = json!({
"auth_mode": "chatgpt",
"tokens": {
"id_token": "oauth-id",
"access_token": "oauth-access"
}
});
let original_deepseek_config = r#"model_provider = "deepseek"
model = "deepseek-v4-flash"
[model_providers.deepseek]
name = "DeepSeek"
base_url = "https://api.deepseek.com/v1"
wire_api = "responses"
experimental_bearer_token = "deepseek-key"
"#;
let stale_live_config = r#"model_provider = "deepseek"
model = "deepseek-v4-flash"
[model_providers.deepseek]
name = "DeepSeek"
base_url = "https://api.deepseek.com/v1"
wire_api = "responses"
experimental_bearer_token = "PROXY_MANAGED"
"#;
crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(stale_live_config))
.expect("seed stale Codex live config");
let mut provider = Provider::with_id(
"deepseek".to_string(),
"DeepSeek".to_string(),
json!({
"auth": {
"OPENAI_API_KEY": "deepseek-key"
},
"config": r#"model_provider = "deepseek"
model = "deepseek-v4-flash"
[model_providers.deepseek]
name = "DeepSeek"
base_url = "https://api.deepseek.com/v1"
wire_api = "responses"
"#
}),
None,
);
provider.category = Some("official".to_string());
db.save_provider("codex", &provider)
.expect("save misclassified DeepSeek provider");
db.set_current_provider("codex", "deepseek")
.expect("set current provider");
crate::settings::set_current_provider(&AppType::Codex, Some("deepseek"))
.expect("set local current provider");
db.save_live_backup(
"codex",
&serde_json::to_string(&json!({
"auth": oauth_auth,
"config": original_deepseek_config
}))
.expect("serialize original backup"),
)
.await
.expect("seed original live backup");
let mut proxy_config = db
.get_proxy_config_for_app("codex")
.await
.expect("get Codex proxy config");
proxy_config.enabled = true;
db.update_proxy_config_for_app(proxy_config)
.await
.expect("mark Codex takeover enabled");
service
.set_takeover_for_app("codex", true)
.await
.expect("rebuild Codex takeover");
let live_auth: Value =
crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())
.expect("read live auth");
assert_eq!(
live_auth, oauth_auth,
"repairing stale takeover must restore the preserved OAuth auth from backup"
);
let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())
.expect("read live config");
assert!(
live_config.contains("http://127.0.0.1:15721/v1"),
"stale enabled takeover must be rebuilt to the current proxy base_url"
);
assert!(
live_config.contains(PROXY_TOKEN_PLACEHOLDER),
"rebuilt takeover should keep the proxy bearer placeholder"
);
assert!(
service
.live_takeover_matches_current_proxy(&AppType::Codex)
.await
.expect("detect rebuilt Codex takeover"),
"rebuilt Codex live config should match the active proxy address"
);
let backup = db
.get_live_backup("codex")
.await
.expect("get Codex live backup")
.expect("backup exists");
let backup_value: Value =
serde_json::from_str(&backup.original_config).expect("parse backup");
assert_eq!(
backup_value.get("auth"),
Some(&oauth_auth),
"rebuilding stale takeover must not overwrite the original OAuth backup"
);
assert!(
backup_value
.get("config")
.and_then(|value| value.as_str())
.is_some_and(|config| config.contains("deepseek-key")
&& !config.contains("http://127.0.0.1")),
"backup should remain the restorable DeepSeek config, not the proxy config"
);
service
.set_takeover_for_app("codex", false)
.await
.expect("disable Codex takeover");
crate::settings::update_settings(crate::settings::AppSettings::default())
.expect("reset settings");
}
#[tokio::test]
#[serial]
async fn codex_takeover_preserve_disabled_uses_legacy_auth_write_path() {
+314
View File
@@ -523,6 +523,173 @@ requires_openai_auth = true
);
}
#[tokio::test(flavor = "current_thread")]
#[allow(
clippy::await_holding_lock,
reason = "this integration-style test must serialize global test HOME and settings mutations across async takeover calls"
)]
async fn codex_official_to_deepseek_then_takeover_enters_and_restores_proxy_managed_live_config() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
enable_codex_official_auth_preservation();
let _home = ensure_test_home();
let oauth_auth = json!({
"auth_mode": "chatgpt",
"tokens": {
"access_token": "oauth-access",
"id_token": "oauth-id"
}
});
let official_config = r#"model_provider = "openai"
model = "gpt-5"
[model_providers.openai]
name = "OpenAI"
wire_api = "responses"
"#;
write_codex_live_atomic(&oauth_auth, Some(official_config))
.expect("seed official Codex OAuth live config");
let deepseek_provider_config = r#"model_provider = "deepseek"
model = "deepseek-chat"
[model_providers.deepseek]
name = "DeepSeek"
base_url = "https://api.deepseek.com/v1"
wire_api = "responses"
"#;
let mut initial_config = MultiAppConfig::default();
{
let manager = initial_config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.current = "official-provider".to_string();
let mut official_provider = Provider::with_id(
"official-provider".to_string(),
"OpenAI Official".to_string(),
json!({
"auth": oauth_auth,
"config": official_config
}),
None,
);
official_provider.category = Some("official".to_string());
manager
.providers
.insert("official-provider".to_string(), official_provider);
let mut deepseek_provider = Provider::with_id(
"deepseek-provider".to_string(),
"DeepSeek".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "deepseek-key"},
"config": deepseek_provider_config
}),
None,
);
deepseek_provider.category = Some("custom".to_string());
manager
.providers
.insert("deepseek-provider".to_string(), deepseek_provider);
}
let state = create_test_state_with_config(&initial_config).expect("create test state");
ProviderService::switch(&state, AppType::Codex, "deepseek-provider")
.expect("switch from official subscription to DeepSeek");
let auth_after_switch: serde_json::Value =
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth after switch");
assert_eq!(
auth_after_switch, oauth_auth,
"normal provider switch with Codex preservation enabled must keep OAuth auth.json"
);
let config_after_switch =
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config");
assert!(
config_after_switch.contains("https://api.deepseek.com/v1"),
"normal switch should write the DeepSeek endpoint before takeover"
);
assert!(
config_after_switch.contains("deepseek-key"),
"normal switch should inject the DeepSeek key into config.toml"
);
state
.proxy_service
.set_takeover_for_app("codex", true)
.await
.expect("enable Codex takeover");
let auth_after_takeover: serde_json::Value =
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth after takeover");
assert_eq!(
auth_after_takeover, oauth_auth,
"enabling takeover must not rewrite Codex OAuth auth.json"
);
let config_after_takeover =
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config");
assert!(
config_after_takeover.contains("http://127.0.0.1:15721/v1"),
"enabling takeover should point Codex config.toml at the local proxy"
);
assert!(
config_after_takeover.contains("PROXY_MANAGED"),
"enabling takeover should move the proxy placeholder into config.toml"
);
assert!(
!config_after_takeover.contains("https://api.deepseek.com/v1"),
"takeover live config should not keep the upstream DeepSeek endpoint"
);
let backup = state
.db
.get_live_backup("codex")
.await
.expect("read Codex backup")
.expect("backup exists after takeover");
let backup_value: serde_json::Value =
serde_json::from_str(&backup.original_config).expect("parse backup");
let backup_config = backup_value
.get("config")
.and_then(|value| value.as_str())
.unwrap_or_default();
assert!(
backup_config.contains("https://api.deepseek.com/v1")
&& backup_config.contains("deepseek-key"),
"takeover backup should remain the restorable DeepSeek config"
);
state
.proxy_service
.set_takeover_for_app("codex", false)
.await
.expect("disable Codex takeover");
let restored_auth: serde_json::Value =
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read restored auth");
assert_eq!(
restored_auth, oauth_auth,
"disabling takeover should restore without replacing OAuth auth.json"
);
let restored_config = std::fs::read_to_string(cc_switch_lib::get_codex_config_path())
.expect("read restored config");
assert!(
restored_config.contains("https://api.deepseek.com/v1")
&& restored_config.contains("deepseek-key"),
"disabling takeover should restore the selected DeepSeek live config"
);
assert!(
!restored_config.contains("PROXY_MANAGED"),
"restored live config must not keep the proxy placeholder"
);
}
#[test]
fn provider_service_switch_codex_default_overwrites_official_auth_when_preservation_off() {
let _guard = test_mutex().lock().expect("acquire test mutex");
@@ -1019,6 +1186,153 @@ fn sync_current_provider_for_app_keeps_live_takeover_and_updates_restore_backup(
);
}
#[test]
fn switch_codex_provider_with_takeover_live_but_stopped_proxy_keeps_proxy_live_config() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
enable_codex_official_auth_preservation();
let _home = ensure_test_home();
let oauth_auth = json!({
"auth_mode": "chatgpt",
"tokens": {
"access_token": "oauth-access",
"id_token": "oauth-id"
}
});
let old_provider_config = r#"model_provider = "deepseek"
model = "deepseek-chat"
[model_providers.deepseek]
name = "DeepSeek"
base_url = "https://api.deepseek.com/v1"
wire_api = "responses"
experimental_bearer_token = "old-key"
"#;
let proxy_live_config = r#"model_provider = "deepseek"
model = "deepseek-chat"
[model_providers.deepseek]
name = "DeepSeek"
base_url = "http://127.0.0.1:15721/v1"
wire_api = "responses"
experimental_bearer_token = "PROXY_MANAGED"
"#;
write_codex_live_atomic(&oauth_auth, Some(proxy_live_config))
.expect("seed taken-over Codex live config");
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.current = "old-provider".to_string();
let mut old_provider = Provider::with_id(
"old-provider".to_string(),
"DeepSeek Old".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "old-key"},
"config": old_provider_config
}),
None,
);
old_provider.category = Some("custom".to_string());
manager
.providers
.insert("old-provider".to_string(), old_provider);
let mut new_provider = Provider::with_id(
"new-provider".to_string(),
"DeepSeek New".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "new-key"},
"config": r#"model_provider = "deepseek-new"
model = "deepseek-reasoner"
[model_providers.deepseek-new]
name = "DeepSeek New"
base_url = "https://new.deepseek.example/v1"
wire_api = "responses"
"#
}),
None,
);
new_provider.category = Some("custom".to_string());
manager
.providers
.insert("new-provider".to_string(), new_provider);
}
let state = create_test_state_with_config(&config).expect("create test state");
futures::executor::block_on(
state.db.save_live_backup(
"codex",
&serde_json::to_string(&json!({
"auth": oauth_auth,
"config": old_provider_config
}))
.expect("serialize backup"),
),
)
.expect("seed Codex live backup");
assert!(
!futures::executor::block_on(state.proxy_service.is_running()),
"fixture keeps the proxy server stopped"
);
ProviderService::switch(&state, AppType::Codex, "new-provider")
.expect("switch should update takeover backup instead of writing normal live config");
let auth_after: serde_json::Value =
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth.json");
assert_eq!(
auth_after, oauth_auth,
"provider switch during takeover ownership must not rewrite Codex OAuth auth"
);
let live_config =
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
assert!(
live_config.contains("http://127.0.0.1:15721/v1"),
"live config should remain pointed at the local proxy"
);
assert!(
live_config.contains("PROXY_MANAGED"),
"live config should keep the proxy bearer placeholder"
);
assert!(
!live_config.contains("https://new.deepseek.example/v1"),
"normal provider base_url must not overwrite taken-over live config"
);
let backup = futures::executor::block_on(state.db.get_live_backup("codex"))
.expect("get Codex backup")
.expect("backup exists");
let backup_value: serde_json::Value =
serde_json::from_str(&backup.original_config).expect("parse backup");
assert_eq!(
backup_value.get("auth"),
Some(&auth_after),
"restore backup should preserve the official OAuth auth"
);
let backup_config = backup_value
.get("config")
.and_then(|v| v.as_str())
.unwrap_or_default();
assert!(
backup_config.contains("new-key") && backup_config.contains("deepseek-new"),
"restore backup should be rebuilt from the newly selected provider"
);
let current = state
.db
.get_current_provider(AppType::Codex.as_str())
.expect("get current provider");
assert_eq!(current.as_deref(), Some("new-provider"));
}
#[test]
fn explicitly_cleared_common_snippet_is_not_auto_extracted() {
let _guard = test_mutex().lock().expect("acquire test mutex");