Fix Codex OAuth auth being cleared during preserve-mode takeover

When "preserve official auth on switch" is enabled, proxy takeover routes
the PROXY_MANAGED placeholder into config.toml's experimental_bearer_token
and leaves auth.json (the ChatGPT OAuth login) untouched. Takeover detection
only inspected auth.json's OPENAI_API_KEY, so it never recognized this state
and returned a false negative, which led downstream paths to clobber the
preserved OAuth login.

- Detection: is_codex_live_taken_over now also matches a config.toml
  experimental_bearer_token equal to the placeholder, fixing detect/cleanup/
  restore/startup-recovery in one place.
- Cleanup: remove the config.toml bearer token only when it equals the
  placeholder (new remove_codex_experimental_bearer_token_if predicate), so a
  real third-party key is never stripped.
- Write: under preservation, the None-provider takeover path writes only
  config.toml and keeps auth.json intact, matching the provider path.
- Settings: rename the section to "Codex App Enhancements" and reword the
  description across all four locales.
- Add tests covering OAuth preservation on takeover and placeholder-only
  cleanup.
This commit is contained in:
Jason
2026-05-30 22:56:36 +08:00
Unverified
parent 60a9b330e5
commit c9cadd6e09
7 changed files with 224 additions and 25 deletions
+24 -3
View File
@@ -739,7 +739,10 @@ fn set_codex_experimental_bearer_token(config_text: &str, token: &str) -> Result
Ok(doc.to_string())
}
fn remove_codex_experimental_bearer_token(config_text: &str) -> Result<String, AppError> {
pub fn remove_codex_experimental_bearer_token_if(
config_text: &str,
predicate: impl Fn(&str) -> bool,
) -> Result<String, AppError> {
if config_text.trim().is_empty() || !config_text.contains("experimental_bearer_token") {
return Ok(config_text.to_string());
}
@@ -755,14 +758,32 @@ fn remove_codex_experimental_bearer_token(config_text: &str) -> Result<String, A
.and_then(|table| table.get_mut(provider_id.as_str()))
.and_then(|item| item.as_table_mut())
{
provider_table.remove("experimental_bearer_token");
let should_remove = provider_table
.get("experimental_bearer_token")
.and_then(|item| item.as_str())
.map(str::trim)
.is_some_and(&predicate);
if should_remove {
provider_table.remove("experimental_bearer_token");
}
}
}
doc.as_table_mut().remove("experimental_bearer_token");
let should_remove_top_level = doc
.get("experimental_bearer_token")
.and_then(|item| item.as_str())
.map(str::trim)
.is_some_and(&predicate);
if should_remove_top_level {
doc.as_table_mut().remove("experimental_bearer_token");
}
Ok(doc.to_string())
}
fn remove_codex_experimental_bearer_token(config_text: &str) -> Result<String, AppError> {
remove_codex_experimental_bearer_token_if(config_text, |_| true)
}
/// Read the current Codex live settings as a `{ auth, config }` object.
///
/// Missing `auth.json` collapses to `{}` so a config-only third-party install
+190 -12
View File
@@ -1242,22 +1242,19 @@ impl ProxyService {
.get("config")
.and_then(|v| v.as_str())
.unwrap_or("");
let codex_provider = self
.get_current_provider_for_app(&AppType::Codex)
.ok()
.flatten();
let codex_provider = self.require_current_provider_for_app(&AppType::Codex)?;
let updated_config = Self::apply_codex_proxy_toml_config_for_provider(
config_str,
&proxy_codex_base_url,
codex_provider.as_ref(),
Some(&codex_provider),
);
live_config["config"] = json!(updated_config);
Self::attach_codex_model_catalog_from_provider(
&mut live_config,
codex_provider.as_ref(),
Some(&codex_provider),
);
self.write_codex_live_for_provider(&live_config, codex_provider.as_ref())?;
self.write_codex_live_for_provider(&live_config, Some(&codex_provider))?;
log::info!("Codex Live 配置已接管,代理地址: {proxy_codex_base_url}");
}
AppType::Gemini => {
@@ -1602,6 +1599,11 @@ impl ProxyService {
if let Some(cfg_str) = config.get("config").and_then(|v| v.as_str()) {
let updated = Self::remove_local_toml_base_url(cfg_str);
let updated =
crate::codex_config::remove_codex_experimental_bearer_token_if(&updated, |token| {
token == PROXY_TOKEN_PLACEHOLDER
})
.map_err(|e| format!("清理 Codex 接管占位符失败: {e}"))?;
config["config"] = json!(updated);
}
@@ -1715,11 +1717,22 @@ impl ProxyService {
}
fn is_codex_live_taken_over(config: &Value) -> bool {
let auth = match config.get("auth").and_then(|v| v.as_object()) {
Some(auth) => auth,
None => return false,
};
auth.get("OPENAI_API_KEY").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)
if config
.get("auth")
.and_then(|v| v.as_object())
.and_then(|auth| auth.get("OPENAI_API_KEY"))
.and_then(|v| v.as_str())
== Some(PROXY_TOKEN_PLACEHOLDER)
{
return true;
}
config
.get("config")
.and_then(|v| v.as_str())
.and_then(crate::codex_config::extract_codex_experimental_bearer_token)
.as_deref()
== Some(PROXY_TOKEN_PLACEHOLDER)
}
fn is_gemini_live_taken_over(config: &Value) -> bool {
@@ -2055,6 +2068,25 @@ impl ProxyService {
provider: Option<&Provider>,
) -> Result<(), String> {
let Some(provider) = provider else {
if crate::settings::preserve_codex_official_auth_on_switch() {
if let (Some(auth), Some(config_str)) = (
config.get("auth"),
config.get("config").and_then(|v| v.as_str()),
) {
if auth.get("OPENAI_API_KEY").and_then(|v| v.as_str())
== Some(PROXY_TOKEN_PLACEHOLDER)
{
let live_config = crate::codex_config::prepare_codex_provider_live_config(
auth, config_str,
)
.map_err(|e| format!("写入 Codex 配置失败: {e}"))?;
crate::codex_config::write_codex_live_config_atomic(Some(&live_config))
.map_err(|e| format!("写入 Codex 配置失败: {e}"))?;
return Ok(());
}
}
}
return self.write_codex_live_verbatim(config);
};
@@ -2645,6 +2677,152 @@ wire_api = "responses"
.expect("reset settings");
}
#[tokio::test]
#[serial]
async fn codex_takeover_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("cn_official".to_string());
db.save_provider("codex", &provider)
.expect("save 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
.takeover_live_config_strict(&AppType::Codex)
.await
.expect("take over Codex live config");
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,
"Codex takeover should not overwrite ChatGPT OAuth auth when preservation is enabled"
);
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),
"takeover placeholder should move into config.toml"
);
assert!(
service.detect_takeover_in_live_config_for_app(&AppType::Codex),
"Codex takeover detection should recognize config.toml placeholders"
);
crate::settings::update_settings(crate::settings::AppSettings::default())
.expect("reset settings");
}
#[test]
#[serial]
fn codex_takeover_cleanup_removes_config_placeholder_without_touching_oauth_auth() {
let _home = TempHome::new();
crate::settings::reload_settings().expect("reload settings");
let db = Arc::new(Database::memory().expect("init db"));
let service = ProxyService::new(db);
let oauth_auth = json!({
"auth_mode": "chatgpt",
"tokens": {
"id_token": "oauth-id",
"access_token": "oauth-access"
}
});
crate::codex_config::write_codex_live_atomic(
&oauth_auth,
Some(
r#"model_provider = "deepseek"
model = "deepseek-v4-flash"
[model_providers.deepseek]
name = "DeepSeek"
base_url = "http://127.0.0.1:15721/v1"
wire_api = "responses"
experimental_bearer_token = "PROXY_MANAGED"
"#,
),
)
.expect("seed taken-over Codex live config");
assert!(
service.detect_takeover_in_live_config_for_app(&AppType::Codex),
"config.toml placeholder should be detected before cleanup"
);
service
.cleanup_codex_takeover_placeholders_in_live()
.expect("cleanup Codex takeover placeholders");
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,
"cleanup should preserve ChatGPT OAuth auth"
);
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),
"cleanup should remove config.toml proxy bearer placeholder"
);
assert!(
!live_config.contains("http://127.0.0.1:15721"),
"cleanup should remove local proxy base_url"
);
}
#[test]
#[serial]
fn codex_custom_provider_live_write_can_overwrite_auth_when_preserve_disabled() {
+2 -2
View File
@@ -242,11 +242,11 @@ export function SettingsPage({
handleAutoSave({ skillSyncMethod: method })
}
/>
<WindowSettings
<CodexAuthSettings
settings={settings}
onChange={handleAutoSave}
/>
<CodexAuthSettings
<WindowSettings
settings={settings}
onChange={handleAutoSave}
/>
+2 -2
View File
@@ -562,9 +562,9 @@
"enableClaudePluginIntegrationDescription": "When enabled, the VS Code Claude Code extension provider will switch with this app",
"skipClaudeOnboarding": "Skip Claude Code first-run confirmation",
"skipClaudeOnboardingDescription": "When enabled, Claude Code will skip the first-run confirmation",
"codexAuth": "Codex Authentication",
"codexAuth": "Codex App Enhancements",
"preserveCodexOfficialAuthOnSwitch": "Keep official login when switching third-party providers",
"preserveCodexOfficialAuthOnSwitchDescription": "When enabled, third-party Codex switches update only config.toml and keep the ChatGPT login in auth.json. When disabled, auth.json is overwritten with the active provider auth.",
"preserveCodexOfficialAuthOnSwitchDescription": "When enabled, you can use the Codex app's official plugins, mobile remote control, and other features while using third-party APIs.",
"appVisibility": {
"title": "Homepage Display",
"description": "Choose which apps to show on the homepage",
+2 -2
View File
@@ -562,9 +562,9 @@
"enableClaudePluginIntegrationDescription": "オンにすると VS Code の Claude Code 拡張のプロバイダーも同期します",
"skipClaudeOnboarding": "Claude Code の初回確認をスキップ",
"skipClaudeOnboardingDescription": "オンにすると Claude Code の初回インストール確認をスキップします",
"codexAuth": "Codex 認証",
"codexAuth": "Codex アプリ拡張",
"preserveCodexOfficialAuthOnSwitch": "サードパーティ切替時に公式ログインを保持",
"preserveCodexOfficialAuthOnSwitchDescription": "オンにするとサードパーティ Codex への切替では config.toml のみ更新し、auth.json の ChatGPT ログインを保持します。オフにすると auth.json を現在のプロバイダー認証で上書きします。",
"preserveCodexOfficialAuthOnSwitchDescription": "オンにするとサードパーティ API を使用する際に Codex アプリの公式プラグインやスマホからのリモート操作などの機能を利用できます。",
"appVisibility": {
"title": "ホームページ表示",
"description": "ホームページに表示するアプリを選択",
+2 -2
View File
@@ -562,9 +562,9 @@
"enableClaudePluginIntegrationDescription": "開啟後 Vscode Claude Code 外掛程式的供應商將隨本軟體切換",
"skipClaudeOnboarding": "跳過 Claude Code 初次安裝確認",
"skipClaudeOnboardingDescription": "開啟後跳過 Claude Code 初次安裝確認",
"codexAuth": "Codex 認證",
"codexAuth": "Codex 應用增強",
"preserveCodexOfficialAuthOnSwitch": "切換第三方時保留官方登入",
"preserveCodexOfficialAuthOnSwitchDescription": "開啟後切換第三方 Codex 供應商只更新 config.toml,並保留 auth.json 中的 ChatGPT 登入狀態;關閉後會用目前供應商 auth 覆寫 auth.json。",
"preserveCodexOfficialAuthOnSwitchDescription": "開啟後可以在使用第三方 API 的時候使用 Codex 應用的官方外掛、手機遠端操作等功能",
"appVisibility": {
"title": "主頁面顯示",
"description": "選擇在主頁面顯示的應用程式",
+2 -2
View File
@@ -562,9 +562,9 @@
"enableClaudePluginIntegrationDescription": "开启后 Vscode Claude Code 插件的供应商将随本软件切换",
"skipClaudeOnboarding": "跳过 Claude Code 初次安装确认",
"skipClaudeOnboardingDescription": "开启后跳过 Claude Code 初次安装确认",
"codexAuth": "Codex 认证",
"codexAuth": "Codex 应用增强",
"preserveCodexOfficialAuthOnSwitch": "切换第三方时保留官方登录",
"preserveCodexOfficialAuthOnSwitchDescription": "开启后切换第三方 Codex 供应商只更新 config.toml,并保留 auth.json 中的 ChatGPT 登录态;关闭后会用当前供应商 auth 覆盖 auth.json。",
"preserveCodexOfficialAuthOnSwitchDescription": "开启后可以在使用第三方 API 的时候使用 Codex 应用的官方插件、手机远程操作等功能",
"appVisibility": {
"title": "主页面显示",
"description": "选择在主页面显示的应用",