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"));
}