feat(codex): preserve OAuth login state during third-party provider switching

Codex provider switches now only write config.toml for third-party providers,
injecting the API key as experimental_bearer_token. The user's auth.json
(ChatGPT OAuth tokens) is preserved. Official providers with login material
still write auth.json normally. Backfill restores bearer tokens into stored
provider auth.OPENAI_API_KEY to maintain canonical shape.
This commit is contained in:
Jason
2026-05-25 17:46:23 +08:00
parent 88ba908bc4
commit 95f2dd4126
17 changed files with 1664 additions and 154 deletions
+85 -15
View File
@@ -70,14 +70,14 @@ fn sync_claude_provider_writes_live_settings() {
}
#[test]
fn sync_codex_provider_writes_auth_and_config() {
fn sync_codex_provider_writes_config_without_touching_auth() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let mut config = MultiAppConfig::default();
// 注意:v3.7.0 后 MCP 同步由 McpService 独立处理,不再通过 provider 切换触发
// 此测试仅验证 auth.json 和 config.toml 基础配置的写入
// Codex provider 切换只写 config.tomlauth.json 保留用户登录态。
let provider_config = json!({
"auth": {
@@ -105,8 +105,8 @@ fn sync_codex_provider_writes_auth_and_config() {
let config_path = cc_switch_lib::get_codex_config_path();
assert!(
auth_path.exists(),
"auth.json should exist at {}",
!auth_path.exists(),
"auth.json should not be created by provider switching at {}",
auth_path.display()
);
assert!(
@@ -115,20 +115,16 @@ fn sync_codex_provider_writes_auth_and_config() {
config_path.display()
);
let auth_value: serde_json::Value = read_json_file(&auth_path).expect("read auth");
assert_eq!(
auth_value,
provider_config.get("auth").cloned().expect("auth object")
);
let toml_text = fs::read_to_string(&config_path).expect("read config.toml");
// 验证基础配置正确写入
assert!(
toml_text.contains("base_url"),
"config.toml should contain base_url from provider config"
);
assert!(
toml_text.contains("experimental_bearer_token"),
"config.toml should contain provider-scoped bearer token"
);
// 当前供应商应同步最新 config 文本
let manager = config.get_manager(&AppType::Codex).expect("codex manager");
let synced = manager.providers.get("codex-1").expect("codex provider");
let synced_cfg = synced
@@ -136,7 +132,81 @@ fn sync_codex_provider_writes_auth_and_config() {
.get("config")
.and_then(|v| v.as_str())
.expect("config string");
assert_eq!(synced_cfg, toml_text);
assert!(
!synced_cfg.contains("experimental_bearer_token"),
"provider storage should not persist generated live bearer token"
);
assert!(
toml_text.contains("experimental_bearer_token"),
"live config should include generated bearer token"
);
}
#[test]
fn sync_codex_provider_with_config_only_token_backfills_auth() {
// P2-2 回归: stored provider 的 token 只藏在 config.toml 的 experimental_bearer_token 时,
// sync 路径必须把 token 从 live config 提取并写回 stored auth.OPENAI_API_KEY,
// 否则下一轮 sync 会在 cleaned config + 空 auth 之间丢失 token。
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let mut config = MultiAppConfig::default();
let stored_config = r#"model_provider = "thirdparty"
model = "gpt-5.4"
[model_providers.thirdparty]
name = "Thirdparty"
base_url = "https://thirdparty.example/v1"
wire_api = "responses"
requires_openai_auth = true
experimental_bearer_token = "stored-bearer-key"
"#;
let provider = Provider::with_id(
"thirdparty-1".to_string(),
"Thirdparty".to_string(),
json!({
"auth": {},
"config": stored_config,
}),
None,
);
let manager = config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager
.providers
.insert("thirdparty-1".to_string(), provider);
manager.current = "thirdparty-1".to_string();
ConfigService::sync_current_providers_to_live(&mut config).expect("sync codex live");
let manager = config.get_manager(&AppType::Codex).expect("codex manager");
let synced = manager
.providers
.get("thirdparty-1")
.expect("provider survives sync");
assert_eq!(
synced
.settings_config
.pointer("/auth/OPENAI_API_KEY")
.and_then(|v| v.as_str()),
Some("stored-bearer-key"),
"config-only bearer token must be backfilled into stored auth.OPENAI_API_KEY"
);
let synced_cfg = synced
.settings_config
.get("config")
.and_then(|v| v.as_str())
.expect("config string");
assert!(
!synced_cfg.contains("experimental_bearer_token"),
"live-only bearer token should not be persisted in stored provider config"
);
}
#[test]
@@ -221,8 +291,8 @@ requires_openai_auth = true
.and_then(|v| v.as_str())
.expect("synced config string");
assert!(
synced_cfg.contains("[model_providers.custom]"),
"ConfigService keeps its existing behavior of syncing provider config from live"
synced_cfg.contains("[model_providers.aihubmix]"),
"ConfigService should restore the provider-specific id before writing stored config"
);
}