mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-01 04:02:02 +08:00
fix(codex): clear stale auth on official switch
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user