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"
);
}
+98 -2
View File
@@ -93,6 +93,98 @@ fn codex_startup_import_fresh_install_imports_once_and_syncs_current_setting() {
);
}
#[test]
fn codex_startup_import_accepts_config_without_auth_file() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let config_path = get_codex_config_path();
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent).expect("create codex config dir");
}
std::fs::write(
&config_path,
r#"model_provider = "aihubmix"
[model_providers.aihubmix]
name = "AiHubMix"
base_url = "https://aihubmix.example/v1"
wire_api = "responses"
requires_openai_auth = true
experimental_bearer_token = "live-key"
"#,
)
.expect("seed config.toml without auth.json");
assert!(
!get_codex_auth_path().exists(),
"test should not seed auth.json"
);
let state = create_test_state().expect("create test state");
import_default_config_test_hook(&state, AppType::Codex)
.expect("import codex config-only default");
let providers = state
.db
.get_all_providers(AppType::Codex.as_str())
.expect("get codex providers after import");
let provider = providers.get("default").expect("default provider exists");
assert_eq!(
provider.settings_config.pointer("/auth"),
Some(&json!({})),
"missing auth.json should import as an empty auth object"
);
assert!(
provider
.settings_config
.get("config")
.and_then(|value| value.as_str())
.unwrap_or_default()
.contains("experimental_bearer_token"),
"config.toml content should still be imported"
);
}
#[test]
fn codex_startup_import_marks_oauth_only_default_official() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let auth = json!({
"auth_mode": "chatgpt",
"tokens": {
"id_token": "oauth-id",
"access_token": "oauth-access"
}
});
let config = r#"[mcp_servers.echo]
command = "echo"
"#;
write_codex_live_atomic(&auth, Some(config)).expect("seed oauth-only codex live config");
let state = create_test_state().expect("create test state");
import_default_config_test_hook(&state, AppType::Codex).expect("import codex default");
let providers = state
.db
.get_all_providers(AppType::Codex.as_str())
.expect("get codex providers after import");
let provider = providers.get("default").expect("default provider exists");
assert_eq!(
provider.category.as_deref(),
Some("official"),
"OAuth-only live Codex installs should keep official behavior"
);
assert_eq!(
provider.settings_config.pointer("/auth/tokens/id_token"),
Some(&json!("oauth-id")),
"import should preserve OAuth login material"
);
}
#[test]
fn codex_startup_import_skips_when_only_official_seed_exists() {
let _guard = test_mutex().lock().expect("acquire test mutex");
@@ -228,8 +320,8 @@ command = "say"
.get("OPENAI_API_KEY")
.and_then(|v| v.as_str())
.unwrap_or(""),
"fresh-key",
"live auth.json should reflect new provider"
"legacy-key",
"Codex provider switching should preserve the existing live auth.json"
);
let config_text = std::fs::read_to_string(get_codex_config_path()).expect("read config.toml");
@@ -237,6 +329,10 @@ command = "say"
config_text.contains("mcp_servers.echo-server"),
"config.toml should contain synced MCP servers"
);
assert!(
config_text.contains("experimental_bearer_token"),
"config.toml should carry the selected provider API key as bearer token"
);
let current_id = app_state
.db
+356 -2
View File
@@ -181,8 +181,8 @@ command = "say"
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth.json");
assert_eq!(
auth_value.get("OPENAI_API_KEY").and_then(|v| v.as_str()),
Some("fresh-key"),
"live auth.json should reflect new provider"
Some("legacy-key"),
"Codex provider switching should preserve the existing live auth.json"
);
let config_text =
@@ -191,6 +191,10 @@ command = "say"
config_text.contains("mcp_servers.echo-server"),
"config.toml should contain synced MCP servers"
);
assert!(
config_text.contains("experimental_bearer_token"),
"config.toml should carry the selected provider API key"
);
let current_id = state
.db
@@ -347,6 +351,356 @@ requires_openai_auth = true
);
}
#[test]
fn provider_service_switch_codex_preserves_oauth_and_backfills_api_key_from_live_token() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let live_auth = json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": {
"access_token": "oauth-token",
"account_id": "acct-1"
}
});
let legacy_config = r#"model_provider = "rightcode"
model = "gpt-5.4"
[model_providers.rightcode]
name = "RightCode"
base_url = "https://rightcode.example/v1"
wire_api = "responses"
requires_openai_auth = true
"#;
write_codex_live_atomic(&live_auth, Some(legacy_config))
.expect("seed existing Codex OAuth live config");
let bridge_provider = Provider::with_id(
"bridge-provider".to_string(),
"Bridge Provider".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "bridge-key"},
"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
"#
}),
None,
);
let mut initial_config = MultiAppConfig::default();
{
let manager = initial_config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.current = "legacy-provider".to_string();
manager.providers.insert(
"legacy-provider".to_string(),
Provider::with_id(
"legacy-provider".to_string(),
"RightCode".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "rightcode-key"},
"config": legacy_config
}),
None,
),
);
manager
.providers
.insert("bridge-provider".to_string(), bridge_provider);
manager.providers.insert(
"plain-provider".to_string(),
Provider::with_id(
"plain-provider".to_string(),
"Plain Provider".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "plain-key"},
"config": r#"model_provider = "plain"
model = "gpt-5.4"
[model_providers.plain]
name = "Plain"
base_url = "https://plain.example/v1"
wire_api = "responses"
requires_openai_auth = true
"#
}),
None,
),
);
}
let state = create_test_state_with_config(&initial_config).expect("create test state");
ProviderService::switch(&state, AppType::Codex, "bridge-provider")
.expect("switch to bridge provider should succeed");
let auth_value: serde_json::Value =
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth.json");
assert_eq!(
auth_value.get("auth_mode").and_then(|v| v.as_str()),
Some("chatgpt")
);
assert!(
auth_value
.get("OPENAI_API_KEY")
.is_some_and(|v| v.is_null()),
"provider switching should keep OPENAI_API_KEY null in live auth.json"
);
assert_eq!(
auth_value
.pointer("/tokens/access_token")
.and_then(|v| v.as_str()),
Some("oauth-token"),
"existing ChatGPT OAuth token should be preserved"
);
let live_config =
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
let parsed_live: toml::Value = toml::from_str(&live_config).expect("parse live config");
assert_eq!(
parsed_live
.get("model_providers")
.and_then(|v| v.get("custom"))
.and_then(|v| v.get("experimental_bearer_token"))
.and_then(|v| v.as_str()),
Some("bridge-key"),
"third-party key should be injected into the stable live provider table"
);
assert_eq!(
parsed_live
.get("model_providers")
.and_then(|v| v.get("custom"))
.and_then(|v| v.get("requires_openai_auth"))
.and_then(|v| v.as_bool()),
Some(true)
);
ProviderService::switch(&state, AppType::Codex, "plain-provider")
.expect("switch away should backfill bridge provider");
let providers = state
.db
.get_all_providers(AppType::Codex.as_str())
.expect("read providers");
let stored_bridge = providers
.get("bridge-provider")
.expect("bridge provider exists after backfill");
assert_eq!(
stored_bridge
.settings_config
.pointer("/auth/OPENAI_API_KEY")
.and_then(|v| v.as_str()),
Some("bridge-key"),
"backfill should restore the API key into stored provider auth"
);
assert!(
stored_bridge
.settings_config
.pointer("/auth/tokens")
.is_none(),
"backfill should not persist ChatGPT OAuth tokens into provider storage"
);
assert!(
!stored_bridge
.settings_config
.get("config")
.and_then(|v| v.as_str())
.unwrap_or_default()
.contains("experimental_bearer_token"),
"stored provider config should stay clean; bridge token is generated only for live config"
);
}
#[test]
fn provider_service_switch_codex_supports_official_login_provider_without_auth_write() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let live_auth = json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": {
"access_token": "official-oauth-token",
"account_id": "acct-official"
}
});
write_codex_live_atomic(&live_auth, Some("")).expect("seed official OAuth live config");
let mut initial_config = MultiAppConfig::default();
{
let manager = initial_config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.current = "legacy-provider".to_string();
manager.providers.insert(
"legacy-provider".to_string(),
Provider::with_id(
"legacy-provider".to_string(),
"Legacy".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "legacy-key"},
"config": r#"model_provider = "legacy"
[model_providers.legacy]
name = "Legacy"
base_url = "https://legacy.example/v1"
wire_api = "responses"
requires_openai_auth = true
"#
}),
None,
),
);
manager.providers.insert(
"official-provider".to_string(),
Provider::with_id(
"official-provider".to_string(),
"OpenAI Official".to_string(),
json!({
"auth": {},
"config": ""
}),
None,
),
);
}
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 without API key");
let auth_value: serde_json::Value =
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth.json");
assert_eq!(
auth_value.get("auth_mode").and_then(|v| v.as_str()),
Some("chatgpt")
);
assert!(
auth_value
.get("OPENAI_API_KEY")
.is_some_and(|v| v.is_null()),
"official provider switching should keep OPENAI_API_KEY null"
);
assert_eq!(
auth_value
.pointer("/tokens/access_token")
.and_then(|v| v.as_str()),
Some("official-oauth-token"),
"official provider should preserve the existing ChatGPT OAuth token"
);
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 login provider has no API key to inject"
);
}
#[test]
fn provider_service_switch_codex_official_accounts_write_auth_json() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let live_auth_a = json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": {
"access_token": "official-a-live-token",
"account_id": "acct-a"
}
});
write_codex_live_atomic(&live_auth_a, Some("")).expect("seed official account A live auth");
let mut official_a = Provider::with_id(
"official-a".to_string(),
"Official A".to_string(),
json!({
"auth": {
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": {
"access_token": "stale-a-token",
"account_id": "acct-a"
}
},
"config": ""
}),
None,
);
official_a.category = Some("official".to_string());
let mut official_b = Provider::with_id(
"official-b".to_string(),
"Official B".to_string(),
json!({
"auth": {
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": {
"access_token": "official-b-token",
"account_id": "acct-b"
}
},
"config": ""
}),
None,
);
official_b.category = Some("official".to_string());
let mut initial_config = MultiAppConfig::default();
{
let manager = initial_config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.current = "official-a".to_string();
manager
.providers
.insert("official-a".to_string(), official_a);
manager
.providers
.insert("official-b".to_string(), official_b);
}
let state = create_test_state_with_config(&initial_config).expect("create test state");
ProviderService::switch(&state, AppType::Codex, "official-b")
.expect("switch to official account B should write auth.json");
let auth_b: serde_json::Value =
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth B");
assert_eq!(
auth_b
.pointer("/tokens/access_token")
.and_then(|v| v.as_str()),
Some("official-b-token"),
"switching official accounts must replace auth.json with the selected account"
);
ProviderService::switch(&state, AppType::Codex, "official-a")
.expect("switch back to official account A should use backfilled live auth");
let auth_a: serde_json::Value =
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth A");
assert_eq!(
auth_a
.pointer("/tokens/access_token")
.and_then(|v| v.as_str()),
Some("official-a-live-token"),
"backfill should preserve account A's latest live token for later official switches"
);
}
#[test]
fn provider_service_switch_codex_backfill_keeps_provider_specific_model_provider_id() {
let _guard = test_mutex().lock().expect("acquire test mutex");