fix(codex): clear stale auth on official switch

This commit is contained in:
Jason
2026-07-31 10:58:32 +08:00
parent f07edc7680
commit e3f80a98f3
4 changed files with 342 additions and 3 deletions
+150 -2
View File
@@ -417,6 +417,93 @@ pub fn codex_auth_has_oauth_login_material(auth: &Value) -> bool {
})
}
/// True only when the auth carries material Codex itself authenticates with
/// ahead of the API-key fallback: OAuth tokens or another first-class login
/// carrier. Unlike `codex_auth_has_oauth_login_material`, pure metadata such
/// as `last_refresh` or `tokens.account_id` does NOT count — metadata must not
/// shield a stale third-party `OPENAI_API_KEY` from post-switch cleanup.
pub fn codex_auth_has_credential_login_material(auth: &Value) -> bool {
let Some(obj) = auth.as_object() else {
return false;
};
let value_present = |value: &Value| match value {
Value::Null => false,
Value::String(text) => !text.trim().is_empty(),
Value::Array(items) => !items.is_empty(),
Value::Object(map) => !map.is_empty(),
_ => true,
};
if ["personal_access_token", "agent_identity", "bedrock_api_key"]
.iter()
.any(|key| obj.get(*key).is_some_and(value_present))
{
return true;
}
obj.get("tokens")
.and_then(Value::as_object)
.is_some_and(|tokens| {
["id_token", "access_token", "refresh_token"]
.iter()
.any(|key| tokens.get(*key).is_some_and(value_present))
})
}
/// True when live `auth.json` is the shape a preserve-off third-party switch
/// leaves behind: an `OPENAI_API_KEY` (possibly alongside metadata like
/// `auth_mode` / `last_refresh`) with no real login credential next to it.
pub fn codex_live_auth_is_stale_third_party_residue(live_auth: &Value) -> bool {
if codex_auth_has_credential_login_material(live_auth) {
return false;
}
live_auth
.get("OPENAI_API_KEY")
.and_then(Value::as_str)
.map(str::trim)
.is_some_and(|key| !key.is_empty())
}
/// After a normal switch to an official provider that carries no login
/// material of its own, delete a live `auth.json` that only holds a stale
/// third-party API key, so Codex shows its login screen instead of sending
/// the wrong key to the official endpoint (401 with no way to re-login).
///
/// Deleting the file — not writing `{}` — is deliberate: Codex resolves an
/// empty object to ChatGPT mode without tokens and errors at bootstrap,
/// while a missing file yields NotAuthenticated and the login screen,
/// matching Codex's own logout.
///
/// Callers must only invoke this after the outgoing provider was
/// successfully backfilled into the DB — that backfill holds the only other
/// copy of the third-party key. The switch backfill intentionally lacks the
/// proxy-side "no credentials in the builtin official row" guard
/// (`services/proxy.rs` `sync_live_config_to_provider`): that asymmetry is
/// what heals official API-key logins into the DB row, and this cleanup's
/// safety depends on it — do not align the two guards.
///
/// Returns Ok(true) when the file was deleted.
pub fn clear_stale_codex_live_auth_after_official_switch(
db_auth: &Value,
) -> Result<bool, AppError> {
if codex_auth_has_login_material(db_auth) {
// A material-carrying official provider gets a full auth write;
// nothing stale can remain.
return Ok(false);
}
let auth_path = get_codex_auth_path();
if !auth_path.exists() {
return Ok(false);
}
let live_auth: Value = read_json_file(&auth_path)?;
if !codex_live_auth_is_stale_third_party_residue(&live_auth) {
return Ok(false);
}
delete_file(&auth_path)?;
Ok(true)
}
pub fn should_restore_codex_provider_token_for_backfill(
category: Option<&str>,
template_settings: &Value,
@@ -1424,7 +1511,9 @@ fn remove_codex_experimental_bearer_token(config_text: &str) -> Result<String, A
/// Read the current Codex live settings as a `{ auth, config }` object.
///
/// Missing `auth.json` collapses to `{}` so a config-only third-party install
/// is still importable; both files empty is treated as "no live install".
/// is still importable; both files missing is treated as "no live install".
/// A `config.toml` that exists but is empty is a valid state — e.g. the
/// official seed after stale-auth cleanup — and must stay readable.
pub fn read_codex_live_settings() -> Result<Value, AppError> {
let auth_path = get_codex_auth_path();
let auth_present = auth_path.exists();
@@ -1434,7 +1523,7 @@ pub fn read_codex_live_settings() -> Result<Value, AppError> {
json!({})
};
let cfg_text = read_and_validate_codex_config_text()?;
if !auth_present && cfg_text.trim().is_empty() {
if !auth_present && !get_codex_config_path().exists() {
return Err(AppError::localized(
"codex.live.missing",
"Codex 配置文件不存在",
@@ -2403,6 +2492,65 @@ experimental_bearer_token = "stale-table-key"
);
}
#[test]
fn credential_login_material_only_counts_real_credentials() {
assert!(codex_auth_has_credential_login_material(&json!({
"tokens": { "access_token": "t" }
})));
assert!(codex_auth_has_credential_login_material(&json!({
"tokens": { "refresh_token": "r" }
})));
assert!(codex_auth_has_credential_login_material(&json!({
"personal_access_token": "pat"
})));
// API key and pure metadata are not credentials in this predicate's
// sense — they must not shield a stale key from cleanup.
assert!(!codex_auth_has_credential_login_material(&json!({
"OPENAI_API_KEY": "sk-x"
})));
assert!(!codex_auth_has_credential_login_material(&json!({
"OPENAI_API_KEY": "sk-x",
"last_refresh": "2026-01-01T00:00:00Z",
"tokens": { "account_id": "acct-meta-only" }
})));
assert!(!codex_auth_has_credential_login_material(&json!({})));
}
#[test]
fn stale_third_party_residue_detection() {
// Shapes a preserve-off third-party switch leaves behind: cleared.
assert!(codex_live_auth_is_stale_third_party_residue(&json!({
"OPENAI_API_KEY": "sk-third-party"
})));
assert!(codex_live_auth_is_stale_third_party_residue(&json!({
"auth_mode": "apikey",
"OPENAI_API_KEY": "sk-third-party"
})));
assert!(codex_live_auth_is_stale_third_party_residue(&json!({
"OPENAI_API_KEY": "sk-third-party",
"last_refresh": "2026-01-01T00:00:00Z",
"tokens": { "account_id": "acct-meta-only" }
})));
// Anything carrying a real credential must survive untouched.
assert!(!codex_live_auth_is_stale_third_party_residue(&json!({
"OPENAI_API_KEY": "sk-x",
"tokens": { "access_token": "t" }
})));
assert!(!codex_live_auth_is_stale_third_party_residue(&json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": { "access_token": "official-oauth-token" }
})));
// Nothing to clear.
assert!(!codex_live_auth_is_stale_third_party_residue(&json!({})));
assert!(!codex_live_auth_is_stale_third_party_residue(&json!({
"OPENAI_API_KEY": ""
})));
}
#[test]
fn prepare_provider_live_config_does_not_create_incomplete_provider_table() {
let input = r#"model_provider = "vendor_x"
+3 -1
View File
@@ -39,7 +39,9 @@ mod usage_events;
mod usage_script;
pub use app_config::{AppType, InstalledSkill, McpApps, McpServer, MultiAppConfig, SkillApps};
pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic};
pub use codex_config::{
get_codex_auth_path, get_codex_config_path, read_codex_live_settings, write_codex_live_atomic,
};
pub use commands::open_provider_terminal;
pub use commands::*;
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
+27
View File
@@ -3090,6 +3090,7 @@ impl ProviderService {
// 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 mut backfill_completed = false;
if let Some(current_id) = current_id {
if current_id != id {
// Additive mode apps - all providers coexist in the same file,
@@ -3123,6 +3124,8 @@ impl ProviderService {
result
.warnings
.push(format!("backfill_failed:{current_id}"));
} else {
backfill_completed = true;
}
}
}
@@ -3142,6 +3145,30 @@ 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)?;
// A material-less official Codex provider gets a config-only live
// write, which can leave the previous third-party key in
// ~/.codex/auth.json and strand the user on a 401 with no login
// screen. Only clean up after a successful backfill — the DB copy
// made above is what keeps that key recoverable. Failures degrade to
// a log entry: config.toml and is_current are already committed, so
// failing the switch here would report a switch that in fact happened.
if matches!(app_type, AppType::Codex)
&& backfill_completed
&& provider.category.as_deref() == Some("official")
{
let db_auth = provider.settings_config.get("auth");
match crate::codex_config::clear_stale_codex_live_auth_after_official_switch(
db_auth.unwrap_or(&serde_json::Value::Null),
) {
Ok(true) => log::info!(
"Removed stale third-party auth.json after switching to official Codex provider '{}'",
provider.id
),
Ok(false) => {}
Err(e) => log::warn!("Failed to clean stale Codex auth.json: {e}"),
}
}
// Hermes is additive, so "switching" doesn't overwrite a live config file
// — we instead update the top-level `model:` section to point at this
// provider's first declared model. Without this, clicking "switch" would
+162
View File
@@ -882,6 +882,168 @@ requires_openai_auth = true
);
}
#[test]
fn provider_service_switch_codex_official_clears_stale_third_party_auth() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
// preservation stays OFF (default): switching to the third-party provider
// wrote its key into live auth.json, and that residue is what this test
// expects the official switch to clean up.
let _home = ensure_test_home();
let third_party_config = r#"model_provider = "aihubmix"
model = "gpt-5.4"
[model_providers.aihubmix]
name = "AiHubMix"
base_url = "https://aihubmix.example/v1"
wire_api = "responses"
requires_openai_auth = true
"#;
// Live key intentionally differs from the DB row so the assertion below
// proves the backfill preserved the live copy before it was deleted.
let live_auth = json!({ "OPENAI_API_KEY": "stale-live-key" });
write_codex_live_atomic(&live_auth, Some(third_party_config))
.expect("seed third-party live config");
let mut initial_config = MultiAppConfig::default();
{
let manager = initial_config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.current = "third-party".to_string();
manager.providers.insert(
"third-party".to_string(),
Provider::with_id(
"third-party".to_string(),
"AiHubMix".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "old-db-key"},
"config": third_party_config
}),
None,
),
);
let mut official_provider = Provider::with_id(
"official-provider".to_string(),
"OpenAI Official".to_string(),
json!({
"auth": {},
"config": ""
}),
None,
);
official_provider.category = Some("official".to_string());
manager
.providers
.insert("official-provider".to_string(), official_provider);
}
let state = create_test_state_with_config(&initial_config).expect("create test state");
ProviderService::switch(&state, AppType::Codex, "official-provider")
.expect("switch to official provider should succeed");
assert!(
!cc_switch_lib::get_codex_auth_path().exists(),
"switching to a material-less official provider must delete the stale \
third-party auth.json so Codex shows its login screen"
);
let providers = state
.db
.get_all_providers(AppType::Codex.as_str())
.expect("read providers after switch");
assert_eq!(
providers
.get("third-party")
.expect("third-party provider exists")
.settings_config
.pointer("/auth/OPENAI_API_KEY")
.and_then(|v| v.as_str()),
Some("stale-live-key"),
"the live key must be backfilled into the outgoing provider before deletion"
);
let live_config =
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
assert!(
!live_config.contains("experimental_bearer_token"),
"official provider has no API key to inject"
);
}
#[test]
fn provider_service_reswitch_current_official_keeps_live_auth() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
// Re-selecting the already-current provider performs no backfill, so the
// cleanup must not run either: without a fresh DB copy of whatever sits
// in live auth.json, deleting it would destroy the only copy.
let live_auth = json!({ "OPENAI_API_KEY": "residue-key" });
write_codex_live_atomic(&live_auth, Some("")).expect("seed live config");
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": {},
"config": ""
}),
None,
);
official_provider.category = Some("official".to_string());
manager
.providers
.insert("official-provider".to_string(), official_provider);
}
let state = create_test_state_with_config(&initial_config).expect("create test state");
ProviderService::switch(&state, AppType::Codex, "official-provider")
.expect("re-switch to current official provider should succeed");
let auth_value: serde_json::Value =
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("auth.json must survive");
assert_eq!(
auth_value.get("OPENAI_API_KEY").and_then(|v| v.as_str()),
Some("residue-key"),
"no backfill happened, so live auth.json must be left untouched"
);
}
#[test]
fn read_codex_live_settings_tolerates_missing_auth_when_config_file_exists() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
assert!(
cc_switch_lib::read_codex_live_settings().is_err(),
"both files missing is still 'no live install'"
);
// auth.json deleted + empty config.toml is the exact state the official
// switch cleanup leaves behind; it must stay readable or the next
// backfill / hot switch would treat Codex as uninstalled.
let config_path = cc_switch_lib::get_codex_config_path();
std::fs::create_dir_all(config_path.parent().expect("codex dir")).expect("create codex dir");
std::fs::write(&config_path, "").expect("write empty config.toml");
let live = cc_switch_lib::read_codex_live_settings()
.expect("config file present but empty must be readable");
assert_eq!(live.get("auth"), Some(&json!({})));
assert_eq!(live.get("config").and_then(|v| v.as_str()), Some(""));
}
#[test]
fn reapply_codex_official_live_resyncs_mcp_servers() {
let _guard = test_mutex().lock().expect("acquire test mutex");