diff --git a/.gitattributes b/.gitattributes index 561487b9d..2e47b8a64 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,10 +7,7 @@ *.sh text eol=lf .github/workflows/*.yml text eol=lf -# Preserve the original Snow Skin asset line endings for byte-exact bundling. -assets/inject/upstream/snow-skin/*.js text eol=lf -assets/inject/upstream/snow-skin/*.css text eol=lf - -# Keep byte-exact macOS theme assets identical on every checkout platform. -assets/inject/upstream/*/macos/*.js text eol=lf -assets/inject/upstream/*/macos/*.css text eol=lf +# Keep every byte-exact upstream theme asset stable on all checkout platforms. +assets/inject/upstream/**/*.js text eol=lf +assets/inject/upstream/**/*.css text eol=lf +assets/inject/upstream/skin-packs/packs/*/theme.json text eol=lf diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index b41a66b31..e37971433 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -81,6 +81,7 @@ import { type ImageHandling, type ModelWindowRow, } from "./model-windows"; +import { relayAuthForLiveDraft } from "./relay-live-files"; import { resolveProviderSyncCompletion } from "./provider-sync-flow"; import { defaultDreamSkinTheme, @@ -5517,17 +5518,22 @@ function RelayProfileDetail({ const isActive = !isNew && profile.id === form.activeRelayId; const profileUsesLiveFiles = relayProfileUsesLiveFiles(profile); useEffect(() => { - const nextDraft = isAggregateRelayProfile(profile) + const useLiveFiles = isActive && profileUsesLiveFiles && relayFiles; + const liveDraft = isAggregateRelayProfile(profile) ? normalizeAggregateRelayProfile(profile, form) : deriveRelayProfileFromFiles( - isActive && profileUsesLiveFiles && relayFiles + useLiveFiles ? { ...profile, configContents: relayFiles.configContents, - authContents: relayFiles.authContents, + authContents: relayAuthForLiveDraft(profile, relayFiles.authContents), } : profile, ); + const storedApiKey = useLiveFiles ? profile.apiKey.trim() : ""; + const nextDraft = useLiveFiles && !isAggregateRelayProfile(liveDraft) + ? applyRelayProfilePatchToFiles(liveDraft, { apiKey: storedApiKey }) + : liveDraft; setDraft(nextDraft); setModelWindowRows(modelWindowRowsFromProfile(nextDraft.modelList, nextDraft.modelWindows || "", nextDraft.modelVlm)); }, [profile.id, profile.modelList, profile.modelWindows, profileUsesLiveFiles, isActive, isNew, relayFiles?.configContents, relayFiles?.authContents]); @@ -6512,7 +6518,11 @@ function RelayFileEditors({
auth.json - {isActive ? t("当前使用中:打开时从 ~/.codex/auth.json 回填,保存后会作为此供应商 auth 存档") : t("切换到此供应商时会写入 ~/.codex/auth.json")} + {isActive + ? profile.relayMode === "pureApi" + ? t("当前使用中:保留此供应商的 auth 存档,避免 Codex 登录密钥覆盖供应商密钥") + : t("当前使用中:打开时从 ~/.codex/auth.json 回填,保存后会作为此供应商 auth 存档") + : t("切换到此供应商时会写入 ~/.codex/auth.json")}
{ + it("preserves the complete pure API provider auth snapshot", () => { + assert.strictEqual(relayAuthForLiveDraft({ + relayMode: "pureApi", + authContents: '{"OPENAI_API_KEY":"provider-key","vendor":"stored"}', + }, '{"OPENAI_API_KEY":"login-key","tokens":"live"}'), + '{"OPENAI_API_KEY":"provider-key","vendor":"stored"}'); + }); + + it("keeps the current official auth state for mixed API mode", () => { + assert.strictEqual(relayAuthForLiveDraft({ + relayMode: "official", + authContents: '{"tokens":"stored"}', + }, '{"tokens":"live"}'), '{"tokens":"live"}'); + }); +}); diff --git a/apps/codex-plus-manager/src/relay-live-files.ts b/apps/codex-plus-manager/src/relay-live-files.ts new file mode 100644 index 000000000..a5464f529 --- /dev/null +++ b/apps/codex-plus-manager/src/relay-live-files.ts @@ -0,0 +1,16 @@ +export type RelayProfileFileSnapshot = { + relayMode: "official" | "pureApi" | "mixedApi" | "aggregate"; + authContents: string; +}; + +/** + * Pure API credentials belong to the stored provider, not Codex's current + * login state. Other live-file modes still use the current auth.json so an + * official login refresh is not replaced by an archived token set. + */ +export function relayAuthForLiveDraft( + profile: RelayProfileFileSnapshot, + liveAuthContents: string, +): string { + return profile.relayMode === "pureApi" ? profile.authContents : liveAuthContents; +} diff --git a/crates/codex-plus-core/src/relay_config.rs b/crates/codex-plus-core/src/relay_config.rs index 77409d0b1..8b1c41e7a 100644 --- a/crates/codex-plus-core/src/relay_config.rs +++ b/crates/codex-plus-core/src/relay_config.rs @@ -747,6 +747,7 @@ pub fn backfill_relay_profile_from_home_with_common( let live_config = read_optional_text(&home.join("config.toml"))?; let template_config = profile.config_contents.clone(); let template_auth = profile.auth_contents.clone(); + let template_api_key = relay_profile_api_key(profile); let template_base_url = relay_profile_base_url(profile); profile.config_contents = if profile.use_common_config { strip_common_config_from_config(&live_config, common_config_contents)? @@ -772,8 +773,13 @@ pub fn backfill_relay_profile_from_home_with_common( profile.config_contents = move_model_providers_before_profiles(&ensure_trailing_newline(doc.to_string())); } - profile.auth_contents = read_optional_text(&home.join("auth.json"))?; - restore_profile_auth_from_live_config(profile, &template_auth)?; + let live_auth = read_optional_text(&home.join("auth.json"))?; + restore_profile_credentials_after_backfill( + profile, + &template_auth, + &template_api_key, + &live_auth, + )?; sync_profile_mode_from_backfilled_live(profile); sync_context_limits_from_config(profile, &live_config); if profile.model.trim().is_empty() { @@ -2026,20 +2032,35 @@ fn provider_id_with_table_from_config(config_text: &str) -> anyhow::Result anyhow::Result<()> { - let Some(token) = experimental_bearer_token_from_config(&profile.config_contents)? else { + if profile.relay_mode == crate::settings::RelayMode::PureApi { + profile.config_contents = + remove_experimental_bearer_token_from_config(&profile.config_contents)?; + profile.auth_contents = + set_openai_api_key_in_auth_contents(template_auth, template_api_key)?; + profile.api_key = template_api_key.trim().to_string(); return Ok(()); - }; - profile.api_key = token.clone(); + } if profile.relay_mode == crate::settings::RelayMode::Official && profile.official_mix_api_key { - profile.auth_contents = remove_openai_api_key_from_auth_contents(&profile.auth_contents)?; + profile.auth_contents = remove_openai_api_key_from_auth_contents(live_auth)?; + profile.config_contents = + set_experimental_bearer_token_in_config(&profile.config_contents, template_api_key)?; + profile.api_key = template_api_key.trim().to_string(); return Ok(()); } + profile.auth_contents = live_auth.to_string(); + let Some(token) = experimental_bearer_token_from_config(&profile.config_contents)? else { + return Ok(()); + }; + profile.api_key = token.clone(); + if !profile.auth_contents.trim().is_empty() { if codex_auth_api_key(&profile.auth_contents).is_none() { return Ok(()); @@ -2051,22 +2072,52 @@ fn restore_profile_auth_from_live_config( profile.config_contents = remove_experimental_bearer_token_from_config(&profile.config_contents)?; + profile.auth_contents = set_openai_api_key_in_auth_contents(template_auth, &token)?; + Ok(()) +} - let mut auth = if template_auth.trim().is_empty() { +fn set_openai_api_key_in_auth_contents( + auth_contents: &str, + api_key: &str, +) -> anyhow::Result { + let mut auth = if auth_contents.trim().is_empty() { json!({}) } else { - serde_json::from_str::(template_auth).with_context(|| "auth.json JSON 解析失败")? + serde_json::from_str::(auth_contents).with_context(|| "auth.json JSON 解析失败")? }; if !auth.is_object() { auth = json!({}); } if let Some(auth_object) = auth.as_object_mut() { - auth_object.insert("OPENAI_API_KEY".to_string(), Value::String(token)); + if api_key.trim().is_empty() { + auth_object.remove("OPENAI_API_KEY"); + } else { + auth_object.insert( + "OPENAI_API_KEY".to_string(), + Value::String(api_key.trim().to_string()), + ); + } } else { anyhow::bail!("auth.json 必须是 JSON 对象"); } - profile.auth_contents = serde_json::to_string_pretty(&auth)?; - Ok(()) + Ok(serde_json::to_string_pretty(&auth)?) +} + +fn set_experimental_bearer_token_in_config( + config_contents: &str, + api_key: &str, +) -> anyhow::Result { + let mut doc = parse_toml_document(config_contents)?; + let provider_id = active_or_default_provider_id(&doc); + let provider = ensure_provider_table(&mut doc, &provider_id)?; + if api_key.trim().is_empty() { + provider.remove("experimental_bearer_token"); + } else { + provider["experimental_bearer_token"] = toml_edit::value(api_key.trim()); + } + Ok(move_model_providers_before_profiles( + &ensure_trailing_newline(doc.to_string()), + )) } fn sync_profile_mode_from_backfilled_live(profile: &mut RelayProfile) { diff --git a/crates/codex-plus-core/tests/relay_config.rs b/crates/codex-plus-core/tests/relay_config.rs index 22c9194b3..02364d631 100644 --- a/crates/codex-plus-core/tests/relay_config.rs +++ b/crates/codex-plus-core/tests/relay_config.rs @@ -9,7 +9,7 @@ use codex_plus_core::relay_config::{ clear_relay_config_to_home_with_auth, delete_context_entry_from_common_config, extract_common_config_from_config, filter_common_config_for_selection, list_context_entries_from_common_config, normalize_relay_profile_for_storage, - relay_config_status_from_home, sanitize_common_config_contents, + relay_config_status_from_home, relay_profile_api_key, sanitize_common_config_contents, set_codex_goals_feature_in_home, strip_common_config_from_config, sync_live_config_context_entries, upsert_context_entry_in_common_config, }; @@ -1568,6 +1568,7 @@ model_provider = "custom" ) .unwrap(); let mut profile = RelayProfile { + relay_mode: RelayMode::PureApi, config_contents: r#"model_provider = "vendor_alpha" [model_providers.vendor_alpha] @@ -1604,7 +1605,7 @@ model_provider = "vendor_alpha" .contains(r#"model_provider = "vendor_alpha""#) ); let auth: serde_json::Value = serde_json::from_str(&profile.auth_contents).unwrap(); - assert_eq!(auth["OPENAI_API_KEY"], "sk-new"); + assert_eq!(auth["OPENAI_API_KEY"], "old"); } #[test] @@ -1993,6 +1994,110 @@ command = "npx" assert_eq!(profile.auth_contents, r#"{"OPENAI_API_KEY":"sk-live"}"#); } +#[test] +fn backfill_pure_api_profile_preserves_archived_credentials() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write( + temp.path().join("config.toml"), + r#"model = "gpt-live" +model_provider = "custom" + +[model_providers.custom] +name = "custom" +wire_api = "responses" +requires_openai_auth = true +base_url = "https://relay.example/v1" +"#, + ) + .unwrap(); + std::fs::write( + temp.path().join("auth.json"), + r#"{"OPENAI_API_KEY":"sk-login","tokens":{"access_token":"live"}}"#, + ) + .unwrap(); + let mut profile = RelayProfile { + relay_mode: RelayMode::PureApi, + config_contents: r#"model_provider = "custom" + +[model_providers.custom] +name = "custom" +wire_api = "responses" +requires_openai_auth = true +base_url = "https://relay.example/v1" +"# + .to_string(), + auth_contents: r#"{"OPENAI_API_KEY":"sk-provider","vendor":"stored"}"#.to_string(), + ..RelayProfile::default() + }; + let mut common = String::new(); + + backfill_relay_profile_from_home_with_common(temp.path(), &mut profile, &mut common).unwrap(); + + let auth: serde_json::Value = serde_json::from_str(&profile.auth_contents).unwrap(); + assert_eq!(auth["OPENAI_API_KEY"], "sk-provider"); + assert_eq!(auth["vendor"], "stored"); + assert!(auth.get("tokens").is_none()); + assert_eq!(relay_profile_api_key(&profile), "sk-provider"); +} + +#[test] +fn backfill_official_mix_profile_preserves_provider_key_and_live_login() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write( + temp.path().join("config.toml"), + r#"model = "gpt-live" +model_provider = "custom" + +[model_providers.custom] +name = "custom" +wire_api = "responses" +requires_openai_auth = true +base_url = "https://relay.example/v1" +experimental_bearer_token = "sk-login" +"#, + ) + .unwrap(); + std::fs::write( + temp.path().join("auth.json"), + r#"{"OPENAI_API_KEY":"sk-login","tokens":{"access_token":"live"}}"#, + ) + .unwrap(); + let mut profile = RelayProfile { + relay_mode: RelayMode::Official, + official_mix_api_key: true, + config_contents: r#"model_provider = "custom" + +[model_providers.custom] +name = "custom" +wire_api = "responses" +requires_openai_auth = true +base_url = "https://relay.example/v1" +experimental_bearer_token = "sk-provider" +"# + .to_string(), + auth_contents: r#"{"tokens":{"access_token":"stored"}}"#.to_string(), + ..RelayProfile::default() + }; + let mut common = String::new(); + + backfill_relay_profile_from_home_with_common(temp.path(), &mut profile, &mut common).unwrap(); + + let auth: serde_json::Value = serde_json::from_str(&profile.auth_contents).unwrap(); + assert_eq!(auth["tokens"]["access_token"], "live"); + assert!(auth.get("OPENAI_API_KEY").is_none()); + assert_eq!(relay_profile_api_key(&profile), "sk-provider"); + assert!( + profile + .config_contents + .contains("experimental_bearer_token = \"sk-provider\"") + ); + assert!( + !profile + .config_contents + .contains("experimental_bearer_token = \"sk-login\"") + ); +} + #[test] fn backfill_relay_profile_with_common_reads_live_context_limits() { let temp = tempfile::tempdir().unwrap(); @@ -2117,7 +2222,7 @@ experimental_bearer_token = "sk-live-token" } #[test] -fn backfill_relay_profile_prefers_live_auth_over_provider_token() { +fn backfill_relay_profile_preserves_provider_auth_over_live_login_key() { let temp = tempfile::tempdir().unwrap(); std::fs::write( temp.path().join("config.toml"), @@ -2147,7 +2252,7 @@ experimental_bearer_token = "sk-old" backfill_relay_profile_from_home_with_common(temp.path(), &mut profile, &mut common).unwrap(); let auth: serde_json::Value = serde_json::from_str(&profile.auth_contents).unwrap(); - assert_eq!(auth["OPENAI_API_KEY"], "sk-edited"); + assert_eq!(auth["OPENAI_API_KEY"], "sk-old"); assert!( !profile .config_contents @@ -2276,7 +2381,7 @@ requires_openai_auth = true assert!(current.config_contents.contains(r#"name = "Manual Edit""#)); assert!(!current.config_contents.contains("old_snapshot")); let auth: serde_json::Value = serde_json::from_str(¤t.auth_contents).unwrap(); - assert_eq!(auth["OPENAI_API_KEY"], "sk-live"); + assert_eq!(auth["OPENAI_API_KEY"], "sk-old"); } #[test] @@ -2554,11 +2659,11 @@ experimental_bearer_token = "22222222222222222222222222222222222" assert_eq!(profile.relay_mode, RelayMode::Official); assert!(profile.official_mix_api_key); - assert_eq!(profile.api_key, "333333333333333333333"); + assert_eq!(profile.api_key, "22222222222222222222222222222222222"); assert!( profile .config_contents - .contains(r#"experimental_bearer_token = "333333333333333333333""#) + .contains(r#"experimental_bearer_token = "22222222222222222222222222222222222""#) ); assert!(!profile.auth_contents.contains("OPENAI_API_KEY")); }