Compare commits

..
2 Commits
Author SHA1 Message Date
yyyyyynnn d1b40285dd release: prepare v0.3.11 2026-08-11 18:18:38 +08:00
yyyyyynnn 3ac28c3193 release: prepare v0.3.10 2026-08-11 17:18:28 +08:00
19 changed files with 2373 additions and 467 deletions
+12
View File
@@ -4,6 +4,18 @@ All notable changes to Codex-X will be documented here.
## [Unreleased]
## [v0.3.11] - 2026-08-11
### 更新
- 优化了软件的流畅度和部分 bug。
## [v0.3.10] - 2026-08-11
### 更新
- 优化了软件的流畅度和部分 bug。
## [v0.3.9] - 2026-08-11
### 更新
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "codex-x",
"version": "0.3.9",
"version": "0.3.11",
"private": true,
"description": "Codex Switch & Instruct desktop manager",
"type": "module",
+1 -1
View File
@@ -490,7 +490,7 @@ dependencies = [
[[package]]
name = "codex-x"
version = "0.3.9"
version = "0.3.11"
dependencies = [
"chrono",
"dirs 5.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "codex-x"
version = "0.3.9"
version = "0.3.11"
description = "Codex Switch & Instruct desktop manager"
authors = ["yynxxxxx"]
license = "MIT"
+11 -2
View File
@@ -930,7 +930,7 @@ async fn restore_official_provider(config_dir: Option<String>) -> Result<ActionR
#[tauri::command]
async fn reset_official_provider(input: OfficialConfigInput) -> Result<ActionResult> {
tauri::async_runtime::spawn_blocking(move || {
reset_official_provider_inner(input.config_dir, input.model)
reset_official_provider_inner(input.config_dir, input.model, input.config_text)
})
.await
.map_err(|e| CodexxError::Config(format!("新建官方配置失败: {e}")))?
@@ -939,7 +939,12 @@ async fn reset_official_provider(input: OfficialConfigInput) -> Result<ActionRes
#[tauri::command]
async fn save_official_config(input: OfficialConfigInput) -> Result<ActionResult> {
tauri::async_runtime::spawn_blocking(move || {
save_official_config_inner(input.config_dir, input.model, input.auth_json)
save_official_config_inner(
input.config_dir,
input.model,
input.auth_json,
input.config_text,
)
})
.await
.map_err(|e| CodexxError::Config(format!("保存官方配置失败: {e}")))?
@@ -1245,6 +1250,10 @@ fn restore_backup_inner(config_dir: Option<String>, backup_id: String) -> Result
None => None,
};
let backup_auth = declared_backup_file_snapshot(&backup_auth, backup_meta.had_auth)?;
if let Some(bytes) = backup_auth.as_deref() {
serde_json::from_slice::<serde_json::Value>(bytes)
.map_err(|error| file_io::json_err(&dir.join("auth.json"), error))?;
}
let backup_agents = declared_backup_file_snapshot(&backup_agents, backup_meta.had_agents)?;
ensure_directory(&codex_dir)?;
@@ -32,6 +32,7 @@ pub(crate) struct ImportResult {
#[serde(rename_all = "camelCase")]
pub(crate) struct OfficialAuthCandidate {
auth_json: String,
config_text: Option<String>,
model: Option<String>,
source: String,
}
@@ -485,9 +486,12 @@ pub(crate) fn read_ccswitch_official_auth_inner(
CodexxError::Database("cc-switch official provider 缺少 auth object".to_string())
})?;
let model = settings
let config_text = settings
.get("config")
.and_then(Value::as_str)
.map(ToString::to_string);
let model = config_text
.as_deref()
.and_then(|text| text.parse::<DocumentMut>().ok())
.and_then(|doc| string_value(&doc, "model"));
@@ -496,6 +500,7 @@ pub(crate) fn read_ccswitch_official_auth_inner(
Ok(Some(OfficialAuthCandidate {
auth_json,
config_text,
model,
source: format!("cc-switch:{name}:{id}"),
}))
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -52,8 +52,8 @@ pub(crate) use store::{
matching_saved_provider_ids_for_live, normalize_saved_provider,
provider_template_from_document, reserved_codex_provider_id, rollback_provider_store_inner,
save_provider_inner, save_provider_with_rollback_inner, strip_provider_bearer_tokens,
unique_saved_provider_id_for_live, upsert_ccswitch_provider_on_connection, ProviderUpsertKind,
SavedProvider,
unique_saved_provider_id_for_live, upsert_ccswitch_provider_on_connection,
ProviderStoreRollback, ProviderUpsertKind, SavedProvider,
};
pub(crate) fn open_store() -> Result<Connection> {
@@ -1,7 +1,8 @@
use crate::backups::{action_backup_root, BackupMeta};
use crate::error::{CodexxError, Result};
use crate::file_io::{ensure_directory, io_err, json_err, parse_toml_document, write_private_json};
use crate::file_io::{ensure_directory, io_err, parse_toml_document, write_private_json};
use crate::paths::app_home;
use crate::toml_utils::ensure_table;
use crate::{auth_path, config_path, string_value};
use chrono::Local;
use serde::{Deserialize, Serialize};
@@ -9,13 +10,16 @@ use serde_json::Value;
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
use toml_edit::value;
const SNAPSHOT_VERSION: u32 = 2;
const SNAPSHOT_VERSION: u32 = 3;
const MODEL_ONLY_SNAPSHOT_VERSION: u32 = 2;
const LEGACY_SNAPSHOT_VERSION: u32 = 1;
#[derive(Debug, Clone)]
pub(crate) struct OfficialConfigCandidate {
pub(crate) auth: Value,
pub(crate) auth: Option<Value>,
pub(crate) config_text: Option<String>,
pub(crate) model: Option<String>,
pub(crate) source: String,
}
@@ -24,6 +28,7 @@ pub(crate) struct OfficialConfigCandidate {
#[serde(rename_all = "camelCase")]
pub(crate) struct OfficialConfigDraft {
auth_json: String,
config_text: String,
model: Option<String>,
source: String,
}
@@ -36,12 +41,14 @@ struct OfficialConfigSnapshot {
captured_at: String,
model: Option<String>,
#[serde(default)]
config: Option<String>,
#[serde(default)]
auth: Option<Value>,
}
enum SnapshotState {
Missing,
Reset,
Reset(OfficialConfigCandidate),
Ready(OfficialConfigCandidate),
}
@@ -78,11 +85,14 @@ pub(crate) fn auth_value_has_material(value: &Value) -> bool {
})
}
fn is_chatgpt_auth(value: &Value) -> bool {
let chatgpt_mode = value
.get("auth_mode")
.and_then(Value::as_str)
.is_some_and(|mode| mode.eq_ignore_ascii_case("chatgpt"));
pub(crate) fn is_chatgpt_auth(value: &Value) -> bool {
// Older Codex releases wrote OAuth tokens without auth_mode. Accept that
// unambiguous legacy shape, but reject any explicitly non-ChatGPT mode.
let chatgpt_mode = match value.get("auth_mode") {
None => true,
Some(Value::String(mode)) => mode.eq_ignore_ascii_case("chatgpt"),
Some(_) => false,
};
let has_api_key = value
.get("OPENAI_API_KEY")
.and_then(Value::as_str)
@@ -116,21 +126,108 @@ fn read_auth_value(path: &Path) -> Result<Option<Value>> {
return Ok(None);
}
let text = fs::read_to_string(path).map_err(|error| io_err(path, error))?;
let value: Value = serde_json::from_str(&text).map_err(|error| json_err(path, error))?;
// A malformed live auth file is not trusted authentication. Treat it as
// absent so state reads and provider switches can continue and repair it.
// Snapshot parsing remains strict in load_snapshot.
let value: Value = match serde_json::from_str(&text) {
Ok(value) => value,
Err(_) => return Ok(None),
};
if !value.is_object() || !auth_value_has_material(&value) {
return Ok(None);
}
Ok(Some(value))
}
fn official_model(codex_dir: &Path) -> Result<Option<String>> {
fn model_from_config(path: &Path, text: &str) -> Result<Option<String>> {
let doc = parse_toml_document(path, text)?;
Ok(string_value(&doc, "model"))
}
fn live_official_config_text(codex_dir: &Path) -> Result<Option<String>> {
let path = config_path(codex_dir);
if !path.is_file() {
return Ok(None);
}
let text = fs::read_to_string(&path).map_err(|error| io_err(&path, error))?;
let doc = parse_toml_document(&path, &text)?;
Ok(string_value(&doc, "model"))
Ok(document_is_official(&doc).then_some(text))
}
fn remove_bearer_tokens(doc: &mut toml_edit::DocumentMut) {
doc.as_table_mut().remove("experimental_bearer_token");
if let Some(providers) = doc
.get_mut("model_providers")
.and_then(|item| item.as_table_mut())
{
for (_, item) in providers.iter_mut() {
if let Some(table) = item.as_table_mut() {
table.remove("experimental_bearer_token");
}
}
}
}
pub(crate) fn build_official_config_text(
codex_dir: &Path,
model: Option<&str>,
clear_model_if_none: bool,
) -> Result<String> {
let path = config_path(codex_dir);
let text = if path.is_file() {
fs::read_to_string(&path).map_err(|error| io_err(&path, error))?
} else {
String::new()
};
let mut doc = parse_toml_document(&path, &text)?;
doc["model_provider"] = value("custom");
doc.as_table_mut().remove("base_url");
remove_bearer_tokens(&mut doc);
let providers = ensure_table(doc.as_table_mut(), "model_providers")?;
providers.remove("custom");
let official = ensure_table(providers, "custom")?;
official["name"] = value("OpenAI");
official["requires_openai_auth"] = value(true);
official["supports_websockets"] = value(true);
official["wire_api"] = value("responses");
if let Some(model) = model.map(str::trim).filter(|model| !model.is_empty()) {
doc["model"] = value(model);
} else if clear_model_if_none {
doc.as_table_mut().remove("model");
}
Ok(doc.to_string())
}
pub(crate) fn validate_official_config_text(
codex_dir: &Path,
config_text: &str,
model: Option<&str>,
) -> Result<(String, Option<String>)> {
let path = config_path(codex_dir);
let mut doc = parse_toml_document(&path, config_text)?;
let has_legacy_proxy_endpoint = doc
.get("base_url")
.and_then(|item| item.as_str())
.is_some_and(|value| !value.trim().is_empty());
if !document_is_official(&doc) || has_legacy_proxy_endpoint {
return Err(CodexxError::Config(
"官方 config.toml 必须使用 OpenAI 官方路由,不能包含第三方 base_url".to_string(),
));
}
remove_bearer_tokens(&mut doc);
// A supplied complete TOML document is authoritative. The separate model
// field only fills a missing value for compatibility with older clients.
if string_value(&doc, "model").is_none() {
if let Some(model) = model.map(str::trim).filter(|model| !model.is_empty()) {
doc["model"] = value(model);
}
}
let text = doc.to_string();
let model = string_value(&doc, "model");
Ok((text, model))
}
pub(crate) fn live_config_is_official(codex_dir: &Path) -> Result<bool> {
@@ -175,7 +272,12 @@ pub(crate) fn document_is_official(doc: &toml_edit::DocumentMut) -> bool {
})
}
fn write_snapshot(codex_dir: &Path, model: Option<String>, auth: Option<Value>) -> Result<()> {
fn write_snapshot(
codex_dir: &Path,
config: Option<String>,
model: Option<String>,
auth: Option<Value>,
) -> Result<()> {
let path = official_snapshot_path(codex_dir)?;
if let Some(parent) = path.parent() {
ensure_directory(parent)?;
@@ -185,6 +287,7 @@ fn write_snapshot(codex_dir: &Path, model: Option<String>, auth: Option<Value>)
codex_dir: canonical_identity(codex_dir),
captured_at: Local::now().to_rfc3339(),
model,
config,
auth,
};
let value = serde_json::to_value(snapshot)
@@ -194,6 +297,7 @@ fn write_snapshot(codex_dir: &Path, model: Option<String>, auth: Option<Value>)
pub(crate) fn save_official_config_snapshot(
codex_dir: &Path,
config: Option<String>,
model: Option<String>,
auth: &Value,
) -> Result<()> {
@@ -202,26 +306,68 @@ pub(crate) fn save_official_config_snapshot(
"官方 auth.json 没有可用认证信息,请先完成官方登录".to_string(),
));
}
write_snapshot(codex_dir, model, Some(auth.clone()))
write_snapshot(codex_dir, config, model, Some(auth.clone()))
}
pub(crate) fn mark_official_config_reset(codex_dir: &Path, model: Option<String>) -> Result<()> {
write_snapshot(codex_dir, model, None)
pub(crate) fn mark_official_config_reset(
codex_dir: &Path,
config: Option<String>,
model: Option<String>,
) -> Result<()> {
write_snapshot(codex_dir, config, model, None)
}
pub(crate) fn capture_live_official_config_before_provider_switch(
codex_dir: &Path,
) -> Result<bool> {
capture_live_official_auth(codex_dir, |auth| {
is_chatgpt_auth(auth) || has_openai_api_key(auth)
})
if !live_config_is_official(codex_dir)? {
return Ok(false);
}
let live_config = live_official_config_text(codex_dir)?;
let live_model = live_config
.as_deref()
.map(|text| model_from_config(&config_path(codex_dir), text))
.transpose()?
.flatten();
let has_explicit_official_route = live_config
.as_deref()
.map(|text| {
let doc = parse_toml_document(&config_path(codex_dir), text)?;
Ok::<_, CodexxError>(string_value(&doc, "model_provider").is_some())
})
.transpose()?
.unwrap_or(false);
let live_auth = read_auth_value(&auth_path(codex_dir))?.filter(|auth| {
is_chatgpt_auth(auth) || (has_explicit_official_route && has_openai_api_key(auth))
});
let previous = match load_snapshot(codex_dir)? {
SnapshotState::Ready(candidate) | SnapshotState::Reset(candidate) => Some(candidate),
SnapshotState::Missing => None,
};
let config = live_config.or_else(|| {
previous
.as_ref()
.and_then(|candidate| candidate.config_text.clone())
});
let model = live_model.or_else(|| {
previous
.as_ref()
.and_then(|candidate| candidate.model.clone())
});
let previous_auth = previous
.as_ref()
.and_then(|candidate| candidate.auth.clone());
let auth = prefer_chatgpt_auth(live_auth, previous_auth);
if config.is_none() && auth.is_none() {
return Ok(false);
}
write_snapshot(codex_dir, config, model, auth)?;
Ok(true)
}
#[cfg(test)]
pub(crate) fn capture_live_chatgpt_config(codex_dir: &Path) -> Result<bool> {
capture_live_official_auth(codex_dir, is_chatgpt_auth)
}
fn capture_live_official_auth(
codex_dir: &Path,
is_trusted: impl FnOnce(&Value) -> bool,
@@ -235,28 +381,70 @@ fn capture_live_official_auth(
if !is_trusted(&auth) {
return Ok(false);
}
save_official_config_snapshot(codex_dir, official_model(codex_dir)?, &auth)?;
let config = live_official_config_text(codex_dir)?;
let model = config
.as_deref()
.map(|text| model_from_config(&config_path(codex_dir), text))
.transpose()?
.flatten();
save_official_config_snapshot(codex_dir, config, model, &auth)?;
Ok(true)
}
#[cfg(test)]
pub(crate) fn capture_live_chatgpt_config(codex_dir: &Path) -> Result<bool> {
capture_live_official_auth(codex_dir, is_chatgpt_auth)
}
fn load_snapshot(codex_dir: &Path) -> Result<SnapshotState> {
let path = official_snapshot_path(codex_dir)?;
if !path.is_file() {
return Ok(SnapshotState::Missing);
}
let text = fs::read_to_string(&path).map_err(|error| io_err(&path, error))?;
let snapshot: OfficialConfigSnapshot =
serde_json::from_str(&text).map_err(|error| json_err(&path, error))?;
if !matches!(snapshot.version, SNAPSHOT_VERSION | LEGACY_SNAPSHOT_VERSION)
|| snapshot.codex_dir != canonical_identity(codex_dir)
let snapshot: OfficialConfigSnapshot = match serde_json::from_str(&text) {
Ok(snapshot) => snapshot,
Err(_) => return Ok(SnapshotState::Missing),
};
// This is an app-owned recovery cache, not the live source of truth. A
// truncated, stale, or incompatible cache must never block startup or a
// provider switch; valid live auth/history can repair it on the next write.
Ok(snapshot_state(codex_dir, &path, snapshot).unwrap_or(SnapshotState::Missing))
}
fn prefer_chatgpt_auth(primary: Option<Value>, fallback: Option<Value>) -> Option<Value> {
if primary.as_ref().is_some_and(is_chatgpt_auth) {
return primary;
}
if fallback.as_ref().is_some_and(is_chatgpt_auth) {
return fallback;
}
primary.or(fallback)
}
fn snapshot_state(
codex_dir: &Path,
path: &Path,
snapshot: OfficialConfigSnapshot,
) -> Result<SnapshotState> {
if !matches!(
snapshot.version,
SNAPSHOT_VERSION | MODEL_ONLY_SNAPSHOT_VERSION | LEGACY_SNAPSHOT_VERSION
) || snapshot.codex_dir != canonical_identity(codex_dir)
{
return Err(CodexxError::Config(format!(
"官方配置快照与当前 CODEX_HOME 不匹配: {}",
path.display()
)));
}
let source = "Codex-X 官方配置快照".to_string();
let Some(auth) = snapshot.auth else {
return Ok(SnapshotState::Reset);
return Ok(SnapshotState::Reset(OfficialConfigCandidate {
auth: None,
config_text: snapshot.config,
model: snapshot.model,
source,
}));
};
if !auth.is_object() || !auth_value_has_material(&auth) {
return Err(CodexxError::Config(format!(
@@ -270,9 +458,10 @@ fn load_snapshot(codex_dir: &Path) -> Result<SnapshotState> {
return Ok(SnapshotState::Missing);
}
Ok(SnapshotState::Ready(OfficialConfigCandidate {
auth,
auth: Some(auth),
config_text: snapshot.config,
model: snapshot.model,
source: "Codex-X 官方配置快照".to_string(),
source,
}))
}
@@ -290,14 +479,12 @@ fn backup_config_is_official(dir: &Path, meta: &BackupMeta) -> bool {
document_is_official(&doc)
}
fn backup_model(dir: &Path, meta: &BackupMeta) -> Option<String> {
fn backup_config(dir: &Path, meta: &BackupMeta) -> Option<String> {
if !meta.had_config {
return None;
}
let path = dir.join("config.toml");
let text = fs::read_to_string(&path).ok()?;
let doc = parse_toml_document(&path, &text).ok()?;
string_value(&doc, "model")
fs::read_to_string(&path).ok()
}
fn latest_official_backup(codex_dir: &Path) -> Result<Option<OfficialConfigCandidate>> {
@@ -336,60 +523,165 @@ fn latest_official_backup(codex_dir: &Path) -> Result<Option<OfficialConfigCandi
if !is_chatgpt_auth(&auth) {
continue;
}
candidates.push((meta.created_at.clone(), dir, meta, auth));
let config_text = backup_config(&dir, &meta);
let model = config_text.as_deref().and_then(|text| {
model_from_config(&dir.join("config.toml"), text)
.ok()
.flatten()
});
candidates.push((meta.created_at.clone(), auth, config_text, model));
}
candidates.sort_by(|left, right| right.0.cmp(&left.0));
Ok(candidates
.into_iter()
.next()
.map(|(_, dir, meta, auth)| OfficialConfigCandidate {
auth,
model: backup_model(&dir, &meta),
source: format!("Codex-X 历史备份 {}", meta.created_at),
}))
.map(
|(created_at, auth, config_text, model)| OfficialConfigCandidate {
auth: Some(auth),
config_text,
model,
source: format!("Codex-X 历史备份 {created_at}"),
},
))
}
fn live_chatgpt_candidate(codex_dir: &Path) -> Result<Option<OfficialConfigCandidate>> {
fn live_auth_candidate(
codex_dir: &Path,
require_official_route: bool,
is_trusted: impl FnOnce(&Value) -> bool,
) -> Result<Option<OfficialConfigCandidate>> {
let is_official = live_config_is_official(codex_dir)?;
if require_official_route && !is_official {
return Ok(None);
}
let Some(auth) = read_auth_value(&auth_path(codex_dir))? else {
return Ok(None);
};
if !is_chatgpt_auth(&auth) {
if !is_trusted(&auth) {
return Ok(None);
}
let model = if live_config_is_official(codex_dir)? {
official_model(codex_dir)?
let config_text = if is_official {
live_official_config_text(codex_dir)?
} else {
None
};
let model = config_text
.as_deref()
.map(|text| model_from_config(&config_path(codex_dir), text))
.transpose()?
.flatten();
Ok(Some(OfficialConfigCandidate {
auth,
auth: Some(auth),
config_text,
model,
source: "当前 ChatGPT 官方登录".to_string(),
source: "当前 OpenAI 官方认证".to_string(),
}))
}
fn live_chatgpt_candidate(codex_dir: &Path) -> Result<Option<OfficialConfigCandidate>> {
live_auth_candidate(codex_dir, false, is_chatgpt_auth)
}
fn live_official_auth_candidate(codex_dir: &Path) -> Result<Option<OfficialConfigCandidate>> {
live_auth_candidate(codex_dir, true, |auth| {
is_chatgpt_auth(auth) || has_openai_api_key(auth)
})
}
fn complete_candidate_config(
codex_dir: &Path,
mut candidate: OfficialConfigCandidate,
backup: Option<&OfficialConfigCandidate>,
) -> Result<OfficialConfigCandidate> {
if candidate.config_text.is_none() {
candidate.config_text = live_official_config_text(codex_dir)?
.or_else(|| backup.and_then(|value| value.config_text.clone()));
}
if candidate.config_text.is_none() {
candidate.config_text = Some(build_official_config_text(
codex_dir,
candidate.model.as_deref(),
false,
)?);
}
if let Some(config_text) = candidate.config_text.as_deref() {
candidate.model =
model_from_config(&config_path(codex_dir), config_text)?.or(candidate.model);
}
Ok(candidate)
}
pub(crate) fn official_config_candidate(
codex_dir: &Path,
include_history_after_reset: bool,
) -> Result<Option<OfficialConfigCandidate>> {
match load_snapshot(codex_dir)? {
SnapshotState::Ready(candidate) => return Ok(Some(candidate)),
SnapshotState::Reset if !include_history_after_reset => return Ok(None),
SnapshotState::Missing | SnapshotState::Reset => {}
SnapshotState::Ready(candidate) => {
// A third-party route may deliberately keep the user's ChatGPT
// login in auth.json and carry its own key in
// experimental_bearer_token. Prefer that live OAuth value so a
// token refresh during proxy use is not replaced by an older
// official snapshot when switching back.
if let Some(live) = live_chatgpt_candidate(codex_dir)? {
return complete_candidate_config(codex_dir, live, Some(&candidate)).map(Some);
}
// Never let an API-key-only live file silently downgrade a trusted
// OAuth snapshot. Older Codex-X builds could leave exactly that
// polluted state while config.toml already pointed at OpenAI.
if candidate.auth.as_ref().is_some_and(is_chatgpt_auth) {
return complete_candidate_config(codex_dir, candidate, None).map(Some);
}
if let Some(live) = live_official_auth_candidate(codex_dir)? {
return complete_candidate_config(codex_dir, live, Some(&candidate)).map(Some);
}
return complete_candidate_config(codex_dir, candidate, None).map(Some);
}
SnapshotState::Reset(reset) if !include_history_after_reset => {
if let Some(live) = live_official_auth_candidate(codex_dir)? {
return complete_candidate_config(codex_dir, live, Some(&reset)).map(Some);
}
if let Some(live) = live_chatgpt_candidate(codex_dir)? {
return complete_candidate_config(codex_dir, live, Some(&reset)).map(Some);
}
return complete_candidate_config(codex_dir, reset, None).map(Some);
}
SnapshotState::Reset(reset) => {
if let Some(live) = live_official_auth_candidate(codex_dir)? {
return complete_candidate_config(codex_dir, live, Some(&reset)).map(Some);
}
if let Some(live) = live_chatgpt_candidate(codex_dir)? {
return complete_candidate_config(codex_dir, live, Some(&reset)).map(Some);
}
if let Some(mut backup) = latest_official_backup(codex_dir)? {
if reset.config_text.is_some() {
backup.config_text = reset.config_text;
backup.model = reset.model.or(backup.model);
}
return complete_candidate_config(codex_dir, backup, None).map(Some);
}
return complete_candidate_config(codex_dir, reset, None).map(Some);
}
SnapshotState::Missing => {}
}
let backup = latest_official_backup(codex_dir)?;
if let Some(candidate) = live_chatgpt_candidate(codex_dir)? {
return Ok(Some(candidate));
return complete_candidate_config(codex_dir, candidate, backup.as_ref()).map(Some);
}
latest_official_backup(codex_dir)
backup
.map(|candidate| complete_candidate_config(codex_dir, candidate, None))
.transpose()
}
pub(crate) fn official_auth_available(codex_dir: &Path) -> Result<bool> {
match load_snapshot(codex_dir)? {
SnapshotState::Ready(_) => return Ok(true),
SnapshotState::Reset => return Ok(false),
SnapshotState::Reset(_) => return Ok(live_official_auth_candidate(codex_dir)?.is_some()),
SnapshotState::Missing => {}
}
if live_official_auth_candidate(codex_dir)?.is_some() {
return Ok(true);
}
if let Some(auth) = read_auth_value(&auth_path(codex_dir))? {
if is_chatgpt_auth(&auth) {
return Ok(true);
@@ -404,19 +696,109 @@ pub(crate) fn get_official_config_draft_inner(
config_dir: Option<String>,
) -> Result<Option<OfficialConfigDraft>> {
let codex_dir = crate::resolve_codex_dir(config_dir)?;
let Some(candidate) = official_config_candidate(&codex_dir, true)? else {
return Ok(None);
let candidate = match official_config_candidate(&codex_dir, false)? {
Some(candidate) => candidate,
None => {
let config_text = build_official_config_text(&codex_dir, None, false)?;
let model = model_from_config(&config_path(&codex_dir), &config_text)?;
OfficialConfigCandidate {
auth: None,
config_text: Some(config_text),
model,
source: "根据当前 config.toml 生成".to_string(),
}
}
};
let auth_json = serde_json::to_string_pretty(&candidate.auth)
.map_err(|error| CodexxError::Config(format!("格式化官方配置快照失败: {error}")))?;
Ok(Some(OfficialConfigDraft {
official_config_draft(candidate).map(Some)
}
fn official_config_draft(candidate: OfficialConfigCandidate) -> Result<OfficialConfigDraft> {
let auth_json = candidate
.auth
.as_ref()
.map(serde_json::to_string_pretty)
.transpose()
.map_err(|error| CodexxError::Config(format!("格式化官方配置快照失败: {error}")))?
.unwrap_or_default();
Ok(OfficialConfigDraft {
auth_json,
config_text: candidate.config_text.unwrap_or_default(),
model: candidate.model,
source: candidate.source,
}))
})
}
#[cfg(test)]
pub(crate) fn official_snapshot_path_for_test(codex_dir: &Path) -> Result<PathBuf> {
official_snapshot_path(codex_dir)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn legacy_oauth_without_auth_mode_is_trusted() {
assert!(is_chatgpt_auth(&json!({
"OPENAI_API_KEY": null,
"tokens": {"access_token": "legacy-access"},
"last_refresh": "2026-08-11T00:00:00Z"
})));
assert!(!is_chatgpt_auth(&json!({
"auth_mode": "apikey",
"OPENAI_API_KEY": null,
"tokens": {"access_token": "must-not-trust"}
})));
assert!(!is_chatgpt_auth(&json!({
"tokens": {"access_token": "legacy-access"},
"OPENAI_API_KEY": "sk-third-party"
})));
}
#[test]
fn v3_reset_snapshot_keeps_complete_config_without_restoring_auth() {
let codex_dir = std::env::temp_dir().join(format!(
"codex-x-official-reset-snapshot-{}",
std::process::id()
));
fs::create_dir_all(&codex_dir).expect("create test Codex directory");
let config = r#"# keep-reset-config
model_provider = "openai"
model = "official-model"
approval_policy = "on-request"
[features]
js_repl = false
"#;
let snapshot = OfficialConfigSnapshot {
version: SNAPSHOT_VERSION,
codex_dir: canonical_identity(&codex_dir),
captured_at: "2026-08-11T00:00:00+08:00".to_string(),
model: Some("official-model".to_string()),
config: Some(config.to_string()),
auth: None,
};
let candidate = match snapshot_state(&codex_dir, Path::new("snapshot.json"), snapshot)
.expect("classify reset snapshot")
{
SnapshotState::Reset(candidate) => candidate,
SnapshotState::Missing | SnapshotState::Ready(_) => {
panic!("v3 auth-less snapshot must remain an explicit reset")
}
};
let candidate = complete_candidate_config(&codex_dir, candidate, None)
.expect("complete reset candidate");
assert!(candidate.auth.is_none());
assert_eq!(candidate.model.as_deref(), Some("official-model"));
assert_eq!(candidate.config_text.as_deref(), Some(config));
let draft = official_config_draft(candidate).expect("build reset draft");
assert!(draft.auth_json.is_empty());
assert_eq!(draft.model.as_deref(), Some("official-model"));
assert_eq!(draft.config_text, config);
fs::remove_dir_all(codex_dir).expect("remove test Codex directory");
}
}
+101 -93
View File
@@ -431,49 +431,37 @@ fn unique_provider_id_on_connection(conn: &Connection, preferred: &str) -> Resul
}
}
fn preserve_existing_provider_config(
fn merge_authoritative_import(
mut incoming: SavedProvider,
existing: &SavedProvider,
) -> SavedProvider {
incoming.id = existing.id.clone();
if !existing.provider_name.trim().is_empty() {
if incoming.provider_name.trim().is_empty() {
incoming.provider_name = existing.provider_name.clone();
}
if !existing.base_url.trim().is_empty() {
if incoming.base_url.trim().is_empty() {
incoming.base_url = existing.base_url.clone();
}
if !existing.model.trim().is_empty() {
if incoming.model.trim().is_empty() {
incoming.model = existing.model.clone();
}
if existing
if incoming
.api_key
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
.is_none_or(|value| value.trim().is_empty())
{
incoming.api_key = existing.api_key.clone();
}
if existing
if incoming
.toml_config
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
.is_none_or(|value| value.trim().is_empty())
{
incoming.toml_config = existing.toml_config.clone();
}
if !existing.wire_api.trim().is_empty() {
if incoming.wire_api.trim().is_empty() {
incoming.wire_api = existing.wire_api.clone();
}
incoming.requires_openai_auth = existing.requires_openai_auth;
incoming
}
fn update_stable_import(mut incoming: SavedProvider, existing: &SavedProvider) -> SavedProvider {
incoming.id = existing.id.clone();
if incoming.api_key.is_none() {
incoming.api_key = existing.api_key.clone();
}
if incoming.toml_config.is_none() {
incoming.toml_config = existing.toml_config.clone();
}
incoming
}
@@ -563,7 +551,7 @@ fn upsert_provider_in_savepoint(
.or(identity_match)
.or(compatible_match),
};
let preserves_local_profile = mode == ProviderUpsertMode::Imported
let preserves_local_id = mode == ProviderUpsertMode::Imported
&& target.is_some_and(|candidate| {
candidate.source == MANUAL_PROVIDER_SOURCE
|| candidate.source == CCSWITCH_LOCAL_PROVIDER_SOURCE
@@ -572,12 +560,8 @@ fn upsert_provider_in_savepoint(
let existing = &target.provider;
let same_id = existing.id == requested_id;
provider.id = existing.id.clone();
if preserves_local_profile {
provider = preserve_existing_provider_config(provider, existing);
} else if source_match.is_some_and(|candidate| candidate.provider.id == existing.id) {
provider = update_stable_import(provider, existing);
} else if mode != ProviderUpsertMode::Manual {
provider = preserve_existing_provider_config(provider, existing);
if mode == ProviderUpsertMode::Imported {
provider = merge_authoritative_import(provider, existing);
}
if import_rehome_target.is_some() {
ProviderUpsertKind::Merged
@@ -600,7 +584,7 @@ fn upsert_provider_in_savepoint(
}
}
let write_origin = match origin {
Some((CCSWITCH_PROVIDER_SOURCE, source_id)) if preserves_local_profile => {
Some((CCSWITCH_PROVIDER_SOURCE, source_id)) if preserves_local_id => {
Some((CCSWITCH_LOCAL_PROVIDER_SOURCE, source_id))
}
_ => origin,
@@ -1214,7 +1198,7 @@ mod tests {
}
#[test]
fn stable_import_collision_keeps_manual_record_and_rehomes_source_identity() {
fn stable_import_collision_keeps_manual_id_and_applies_source_values() {
let conn = test_connection();
let mut imported = provider("cc-old", "Imported old", Some("sk-old"));
imported.base_url = "https://old.example.com/v1".to_string();
@@ -1236,8 +1220,8 @@ mod tests {
assert_eq!(result.kind, ProviderUpsertKind::Merged);
assert_eq!(result.provider.id, "manual-local");
assert_eq!(result.provider.provider_name, "Locally edited");
assert_eq!(result.provider.model, "local-model");
assert_eq!(result.provider.provider_name, "Imported changed");
assert_eq!(result.provider.model, "remote-model");
assert_eq!(
result.provider.toml_config.as_deref(),
Some("model = \"local-model\"")
@@ -1249,28 +1233,36 @@ mod tests {
}
#[test]
fn ccswitch_import_preserves_an_intentional_minimal_manual_template() {
fn ccswitch_import_replaces_stale_local_values_with_complete_source() {
let conn = test_connection();
let mut manual = provider("legacy-local", "Local name", Some("sk-shared"));
manual.model = "local-model".to_string();
manual.toml_config = Some(
let mut stale = provider("legacy-local", "Local name", Some("sk-local"));
stale.base_url = "https://old.example.com/v1".to_string();
stale.model = "local-model".to_string();
stale.toml_config = Some(
r#"model_provider = "custom"
model = "local-model"
[model_providers.custom]
name = "Local name"
base_url = "https://example.com/v1"
wire_api = "responses"
base_url = "https://old.example.com/v1"
wire_api = "chat"
requires_openai_auth = true
request_max_retries = 3
"#
.to_string(),
);
upsert_provider_on_connection(&conn, manual, ProviderUpsertMode::Manual)
.expect("seed intentional minimal provider");
stale.wire_api = "chat".to_string();
write_provider_with_origin(
&conn,
&stale,
Some((CCSWITCH_LOCAL_PROVIDER_SOURCE, "cc-row")),
)
.expect("seed stale local import");
let mut imported = provider("cc-row", "CC name", Some("sk-shared"));
let mut imported = provider("cc-row", "CC name", Some("sk-imported"));
imported.base_url = "https://new.example.com/v1".to_string();
imported.model = "remote-model".to_string();
imported.requires_openai_auth = false;
imported.toml_config = Some(
r#"# complete cc-switch template
model_provider = "custom"
@@ -1279,7 +1271,7 @@ service_tier = "priority"
[model_providers.custom]
name = "CC name"
base_url = "https://example.com/v1"
base_url = "https://new.example.com/v1"
wire_api = "responses"
requires_openai_auth = false
request_max_retries = 7
@@ -1293,73 +1285,89 @@ enabled = true
.to_string(),
);
let result = upsert_ccswitch_provider_on_connection(&conn, imported, "cc-row")
.expect("merge cc-switch source without replacing the manual template");
.expect("replace stale local values from the authoritative cc-switch row");
assert_eq!(result.kind, ProviderUpsertKind::Merged);
assert_eq!(result.kind, ProviderUpsertKind::Updated);
assert_eq!(result.provider.id, "legacy-local");
assert_eq!(result.provider.provider_name, "Local name");
assert_eq!(result.provider.model, "local-model");
assert_eq!(result.provider.provider_name, "CC name");
assert_eq!(result.provider.base_url, "https://new.example.com/v1");
assert_eq!(result.provider.model, "remote-model");
assert_eq!(result.provider.api_key.as_deref(), Some("sk-imported"));
assert_eq!(result.provider.wire_api, "responses");
assert!(!result.provider.requires_openai_auth);
let template = result
.provider
.toml_config
.expect("preserved manual provider template");
.expect("complete cc-switch template");
let doc = template
.parse::<DocumentMut>()
.expect("parse preserved provider template");
assert!(!template.contains("# complete cc-switch template"));
assert!(doc.get("service_tier").is_none());
assert!(doc.get("projects").is_none());
assert!(doc.get("plugins").is_none());
assert_eq!(doc["model"].as_str(), Some("local-model"));
.expect("parse imported provider template");
assert!(template.contains("# complete cc-switch template"));
assert_eq!(doc["service_tier"].as_str(), Some("priority"));
assert_eq!(doc["model"].as_str(), Some("remote-model"));
assert_eq!(
doc["model_providers"]["custom"]["name"].as_str(),
Some("Local name")
Some("CC name")
);
assert_eq!(
doc["model_providers"]["custom"]["request_max_retries"].as_integer(),
Some(3)
Some(7)
);
let mut repeated = provider("cc-row", "CC renamed", Some("sk-shared"));
repeated.model = "new-remote-model".to_string();
repeated.toml_config = Some(
r#"model_provider = "custom"
model = "new-remote-model"
service_tier = "flex"
[model_providers.custom]
name = "CC renamed"
base_url = "https://example.com/v1"
wire_api = "responses"
requires_openai_auth = false
[projects."/remote/replacement"]
trust_level = "untrusted"
"#
.to_string(),
);
let repeated = upsert_ccswitch_provider_on_connection(&conn, repeated, "cc-row")
.expect("repeat cc-switch import without replacing local profile");
assert_eq!(repeated.provider.id, "legacy-local");
assert_eq!(repeated.provider.provider_name, "Local name");
assert_eq!(repeated.provider.model, "local-model");
let repeated_template = repeated.provider.toml_config.expect("keep local template");
let repeated_doc = repeated_template
.parse::<DocumentMut>()
.expect("parse repeated import template");
assert!(repeated_doc.get("service_tier").is_none());
assert!(repeated_doc.get("projects").is_none());
assert!(repeated_doc.get("plugins").is_none());
assert_eq!(
repeated_doc["model_providers"]["custom"]["request_max_retries"].as_integer(),
Some(3)
doc["projects"]["/work/project"]["trust_level"].as_str(),
Some("trusted")
);
let rows = stored_providers_on_connection(&conn).expect("read repeated import row");
assert_eq!(
doc["plugins"]["browser@openai-bundled"]["enabled"].as_bool(),
Some(true)
);
let rows = stored_providers_on_connection(&conn).expect("read imported row");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].source, CCSWITCH_LOCAL_PROVIDER_SOURCE);
assert_eq!(rows[0].source_id.as_deref(), Some("cc-row"));
}
#[test]
fn ccswitch_import_falls_back_only_when_source_fields_are_missing() {
let conn = test_connection();
let mut existing = provider("local-id", "Existing name", Some("sk-existing"));
existing.base_url = "https://existing.example.com/v1".to_string();
existing.model = "existing-model".to_string();
existing.toml_config = Some("model = \"existing-model\"".to_string());
existing.wire_api = "responses".to_string();
write_provider_with_origin(
&conn,
&existing,
Some((CCSWITCH_LOCAL_PROVIDER_SOURCE, "cc-row")),
)
.expect("seed existing source row");
let missing = SavedProvider {
id: "remote-id".to_string(),
provider_name: " ".to_string(),
base_url: String::new(),
model: "\t".to_string(),
api_key: Some(" ".to_string()),
toml_config: Some("\n".to_string()),
wire_api: String::new(),
requires_openai_auth: false,
};
let result = upsert_ccswitch_provider_on_connection(&conn, missing, "cc-row")
.expect("fill missing import fields from the existing source row");
assert_eq!(result.provider.id, "local-id");
assert_eq!(result.provider.provider_name, "Existing name");
assert_eq!(result.provider.base_url, "https://existing.example.com/v1");
assert_eq!(result.provider.model, "existing-model");
assert_eq!(result.provider.api_key.as_deref(), Some("sk-existing"));
assert_eq!(
result.provider.toml_config.as_deref(),
Some("model = \"existing-model\"")
);
assert_eq!(result.provider.wire_api, "responses");
assert!(!result.provider.requires_openai_auth);
}
#[test]
fn historical_custom_ghost_cleanup_requires_one_stable_endpoint_match() {
let conn = test_connection();
@@ -1405,7 +1413,7 @@ trust_level = "untrusted"
}
#[test]
fn legacy_consolidation_keeps_the_local_profile_across_the_next_import() {
fn legacy_consolidation_keeps_local_id_but_next_import_is_authoritative() {
let conn = test_connection();
let mut imported = provider("imported-old", "Imported old", Some("sk-same"));
imported.model = "old-model".to_string();
@@ -1448,11 +1456,11 @@ trust_level = "untrusted"
let rows = stored_providers_on_connection(&conn).unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].provider.id, "manual-local");
assert_eq!(rows[0].provider.provider_name, "Edited locally");
assert_eq!(rows[0].provider.model, "latest-model");
assert_eq!(rows[0].provider.provider_name, "Remote replacement");
assert_eq!(rows[0].provider.model, "remote-model");
assert_eq!(
rows[0].provider.toml_config.as_deref(),
Some("model = \"latest-model\"")
Some("model = \"remote-model\"")
);
assert_eq!(rows[0].source, CCSWITCH_LOCAL_PROVIDER_SOURCE);
assert_eq!(rows[0].source_id.as_deref(), Some("cc-row"));
+7 -1
View File
@@ -67,7 +67,13 @@ fn redacted_auth_preview(path: &Path) -> Result<Option<Value>> {
return Ok(None);
}
let text = fs::read_to_string(path).map_err(|e| io_err(path, e))?;
let mut value: Value = serde_json::from_str(&text).map_err(|e| json_err(path, e))?;
// Keep the UI responsive when another tool or an interrupted write leaves
// auth.json malformed. Mutating flows validate or replace auth explicitly;
// the read-only state path should still report the rest of the config.
let mut value: Value = match serde_json::from_str(&text) {
Ok(value) => value,
Err(_) => return Ok(None),
};
if let Some(obj) = value.as_object_mut() {
for (key, val) in obj.iter_mut() {
let lower = key.to_ascii_lowercase();
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Codex-X",
"version": "0.3.9",
"version": "0.3.11",
"identifier": "com.yynxxxxx.codexx",
"build": {
"frontendDist": "../dist",
+43 -8
View File
@@ -198,7 +198,7 @@ const dict = {
model: "模型",
wireApi: "Wire API",
apiKey: "API Key",
apiKeyPlaceholder: "留空则不覆盖 auth.json",
apiKeyPlaceholder: "用于写入 auth.json",
requiresAuth: "requires_openai_auth",
save: "保存到列表",
saveAndSwitch: "保存",
@@ -299,7 +299,7 @@ const dict = {
model: "Model",
wireApi: "Wire API",
apiKey: "API Key",
apiKeyPlaceholder: "Leave blank to keep auth.json unchanged",
apiKeyPlaceholder: "Written to auth.json",
requiresAuth: "requires_openai_auth",
save: "Save",
saveAndSwitch: "Save",
@@ -372,6 +372,7 @@ function getProviderPageCopy(lang: Lang): ProviderCopy {
authPathLabel: "auth.json",
officialCurrentLabel: t.provider.current,
officialAuthLabel: "auth.json (JSON)",
officialTomlLabel: "config.toml (TOML)",
officialSaveLabel: isChinese ? "保存官方配置" : "Save official config",
restoreOfficialLabel: isChinese ? "还原官方配置" : "Restore official config",
resetOfficialLabel: isChinese ? "新建官方配置" : "Create official config",
@@ -404,8 +405,8 @@ function getProviderPageCopy(lang: Lang): ProviderCopy {
requiresAuthLabel: t.provider.requiresAuth,
authPreviewTitle: "auth.json (JSON)",
authPreviewDescription: isChinese
? "预览保存时写入或保留的认证配置;API Key 留空不会覆盖现有认证。"
: "Preview the authentication data. An empty API key keeps the current auth file.",
? "启用该供应商时,auth.json 只写入 OPENAI_API_KEY;API Key 留空会移除第三方认证文件。"
: "When enabled, auth.json contains only OPENAI_API_KEY. Leaving the API key empty removes third-party authentication.",
tomlTitle: "config.toml (TOML)",
tomlDescription: isChinese
? "上方标准字段是启用时的权威值;已有模板中的其他扩展字段会保留。只有点击“重置生成”才会替换为标准模板。"
@@ -584,9 +585,22 @@ function buildProviderTomlPreview(provider: SavedProvider) {
].join("\n");
}
function buildOfficialTomlPreview(model: string) {
return [
`model_provider = "custom"`,
`model = "${tomlEscape(model.trim() || "gpt-5.5")}"`,
"",
"[model_providers.custom]",
`name = "OpenAI"`,
`wire_api = "responses"`,
`requires_openai_auth = true`,
`supports_websockets = true`,
].join("\n");
}
function buildProviderAuthPreview(provider: SavedProvider) {
const key = provider.apiKey?.trim();
return JSON.stringify({ OPENAI_API_KEY: key || null, auth_mode: key ? "apikey" : undefined }, null, 2);
return JSON.stringify(key ? { OPENAI_API_KEY: key } : {}, null, 2);
}
function sessionMismatchCount(status: SessionSyncStatus | null) {
@@ -785,12 +799,17 @@ function App() {
const [promptSyncing, setPromptSyncing] = React.useState(false);
const [promptCatalogReady, setPromptCatalogReady] = React.useState(false);
const [promptForm, setPromptForm] = React.useState<SavedPrompt>(blankPromptForm);
const [officialForm, setOfficialForm] = React.useState({ model: "gpt-5.5", authJson: "" });
const [officialForm, setOfficialForm] = React.useState({
model: "gpt-5.5",
authJson: "",
configText: buildOfficialTomlPreview("gpt-5.5"),
});
const [promptModeHelpOpen, setPromptModeHelpOpen] = React.useState(false);
const autoUpdateCheckedRef = React.useRef(false);
const promptImportRef = React.useRef<HTMLInputElement | null>(null);
const skillZipImportRef = React.useRef<HTMLInputElement | null>(null);
const officialAuthEditorRef = React.useRef<HTMLTextAreaElement | null>(null);
const officialTomlEditorRef = React.useRef<HTMLTextAreaElement | null>(null);
const providerTomlEditorRef = React.useRef<HTMLTextAreaElement | null>(null);
const providerModelsRequestRef = React.useRef(0);
const providerDraftRequestRef = React.useRef(0);
@@ -1022,14 +1041,17 @@ function App() {
React.useEffect(() => {
if (providerMode !== "official") return;
const fit = () => fitCodeEditorHeight(officialAuthEditorRef.current, 420);
const fit = () => {
fitCodeEditorHeight(officialTomlEditorRef.current, 360);
fitCodeEditorHeight(officialAuthEditorRef.current, 420);
};
const frame = window.requestAnimationFrame(fit);
window.addEventListener("resize", fit);
return () => {
window.cancelAnimationFrame(frame);
window.removeEventListener("resize", fit);
};
}, [officialForm.authJson, providerMode]);
}, [officialForm.authJson, officialForm.configText, providerMode]);
React.useEffect(() => {
if (!state || promptModeSyncedRef.current === state.codexDir) return;
@@ -1775,6 +1797,7 @@ function App() {
setOfficialForm({
model: draft.model || "gpt-5.5",
authJson: draft.authJson,
configText: draft.configText || buildOfficialTomlPreview(draft.model || "gpt-5.5"),
});
}
handleActionResult(result);
@@ -1788,6 +1811,7 @@ function App() {
configDir: configDir || null,
model: officialForm.model,
authJson: null,
configText: officialForm.configText,
},
}),
(result) => {
@@ -1796,6 +1820,9 @@ function App() {
setOfficialForm({
model: result.state.model || officialForm.model || "gpt-5.5",
authJson: officialAuthPlaceholder,
configText: result.state.configText
|| officialForm.configText
|| buildOfficialTomlPreview(result.state.model || officialForm.model || "gpt-5.5"),
});
handleActionResult(result);
},
@@ -2093,10 +2120,14 @@ function App() {
const actionToken = beginActionBusy("loadOfficialDraft");
const liveIsOfficial = Boolean(state?.isOfficialProvider);
const fallbackModel = state?.model || "gpt-5.5";
const fallbackConfigText = liveIsOfficial && state?.configText?.trim()
? state.configText
: "";
setEditingDetectedProvider(false);
setOfficialForm({
model: fallbackModel,
authJson: liveIsOfficial && state?.authText ? state.authText : officialAuthPlaceholder,
configText: fallbackConfigText,
});
setProviderMode("official");
setError("");
@@ -2109,6 +2140,7 @@ function App() {
setOfficialForm({
model: draft.model || fallbackModel,
authJson: draft.authJson,
configText: draft.configText || fallbackConfigText,
});
}
} catch (e) {
@@ -2126,6 +2158,7 @@ function App() {
configDir: configDir || null,
model: officialForm.model,
authJson: officialForm.authJson,
configText: officialForm.configText,
},
}),
(result) => {
@@ -2462,6 +2495,7 @@ function App() {
}}
officialForm={officialForm}
officialAuthRef={officialAuthEditorRef}
officialTomlRef={officialTomlEditorRef}
officialInfo={{
officialUrl: "https://chatgpt.com/codex",
authPath: state.authPath,
@@ -2519,6 +2553,7 @@ function App() {
}}
onOfficialModelChange={(value) => setOfficialForm((current) => ({ ...current, model: value }))}
onOfficialAuthChange={(value) => setOfficialForm((current) => ({ ...current, authJson: value }))}
onOfficialConfigChange={(value) => setOfficialForm((current) => ({ ...current, configText: value }))}
onSaveOfficial={saveOfficialConfig}
onApiKeyChange={(value) => {
resetAvailableProviderModels();
+18 -2
View File
@@ -54,6 +54,7 @@ export type ProviderFormValue = {
export type OfficialFormValue = {
model: string;
authJson: string;
configText: string;
};
export type ProviderCopy = {
@@ -81,6 +82,7 @@ export type ProviderCopy = {
authPathLabel: string;
officialCurrentLabel: string;
officialAuthLabel: string;
officialTomlLabel: string;
officialSaveLabel: string;
restoreOfficialLabel: string;
resetOfficialLabel: string;
@@ -134,6 +136,7 @@ export type ProvidersPageProps = {
providerForm: ProviderFormValue;
officialForm: OfficialFormValue;
officialAuthRef?: Ref<HTMLTextAreaElement>;
officialTomlRef?: Ref<HTMLTextAreaElement>;
officialInfo: ProviderOfficialInfo;
providerAuthPreview: ReactNode;
providerTomlDraft: string;
@@ -152,6 +155,7 @@ export type ProvidersPageProps = {
onCancelMode: () => void;
onOfficialModelChange: (value: string) => void;
onOfficialAuthChange: (value: string) => void;
onOfficialConfigChange: (value: string) => void;
onSaveOfficial: () => void;
onApiKeyChange: (value: string) => void;
onBaseUrlChange: (value: string) => void;
@@ -395,16 +399,18 @@ function OfficialForm({
copy,
officialForm,
officialAuthRef,
officialTomlRef,
officialInfo,
loading,
actionBusy,
onCancelMode,
onOfficialModelChange,
onOfficialAuthChange,
onOfficialConfigChange,
onSaveOfficial,
onRestoreOfficial,
onResetOfficial,
}: Pick<ProvidersPageProps, "copy" | "officialForm" | "officialAuthRef" | "officialInfo" | "loading" | "actionBusy" | "onCancelMode" | "onOfficialModelChange" | "onOfficialAuthChange" | "onSaveOfficial" | "onRestoreOfficial" | "onResetOfficial">) {
}: Pick<ProvidersPageProps, "copy" | "officialForm" | "officialAuthRef" | "officialTomlRef" | "officialInfo" | "loading" | "actionBusy" | "onCancelMode" | "onOfficialModelChange" | "onOfficialAuthChange" | "onOfficialConfigChange" | "onSaveOfficial" | "onRestoreOfficial" | "onResetOfficial">) {
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
const formBusy = loading || actionBusy === "loadOfficialDraft";
@@ -424,6 +430,16 @@ function OfficialForm({
<div className="cx-providers-form-grid cx-providers-form-grid--single">
<Field label={copy.modelLabel}><input value={officialForm.model} onChange={(event) => onOfficialModelChange(event.target.value)} disabled={formBusy} /></Field>
</div>
<Field label={copy.officialTomlLabel} className="cx-providers-editor-field">
<textarea
ref={officialTomlRef}
className="cx-providers-code-editor cx-providers-toml-editor"
value={officialForm.configText}
onChange={(event) => onOfficialConfigChange(event.target.value)}
disabled={formBusy}
spellCheck={false}
/>
</Field>
<Field label={copy.officialAuthLabel} className="cx-providers-editor-field">
<textarea
ref={officialAuthRef}
@@ -460,7 +476,7 @@ function OfficialForm({
>
<div className="cx-provider-delete-warning">
<span aria-hidden="true"><AlertTriangle size={22} /></span>
<strong>auth.json</strong>
<strong>config.toml + auth.json</strong>
</div>
</ModalShell>
</>
@@ -714,6 +714,10 @@
min-height: 360px;
}
.cx-providers-editor-field + .cx-providers-editor-field {
margin-top: 14px;
}
.cx-providers-form-actions {
padding: 2px 0 4px;
}
+1
View File
@@ -106,6 +106,7 @@ export type ActionResult = {
export type OfficialConfigDraft = {
authJson: string;
configText: string;
model?: string;
source: string;
};
+18 -6
View File
@@ -8,21 +8,33 @@
日志不得包含 Token、认证文件内容、用户数据或仅适用于某台电脑的隐私路径。
## 2026-08-11:供应商模板完整性与启动读取
## 2026-08-11:供应商认证往返、模板完整性与启动读取
### 决策
- 新建和 CC Switch 导入的供应商保存完整、无密钥的 TOML 模板;API Key 单独保存。
- 激活模板时以最新 live `config.toml` 为底稿,只替换 model/provider 字段,不能回放模板里的
项目、插件、MCP、桌面或环境设置。
- 新建和 CC Switch 导入的供应商保存完整、无密钥的 TOML 模板;API Key 单独保存在供应商
数据库字段中。完整模板是该供应商的权威配置,切换时恢复项目、插件、MCP、桌面、功能和
环境设置;旧版稀疏模板仍以当前 live 配置为底稿兼容合并。
- 有可信 ChatGPT OAuth 时,第三方切换保留 live `auth.json`,并把目标 Key 写入活动
provider 的 `experimental_bearer_token`;没有 OAuth 时继续写 `OPENAI_API_KEY` 以维持
Codex 登录状态,同时也写 provider 作用域 Key,避免两文件切换窗口使用错凭据。
- 官方配置独立保存完整 `config.toml`、模型和 OAuth/API Key `auth.json`。官方与第三方往返
先发布目标路由,再按需更新 auth,失败时按条件原子回滚;未登录的官方 Reset 快照也必须
保留完整 TOML。自动捕获不得让官方 live 中残留的中转 API Key 覆盖可信 OAuth 快照。
- 兼容无 `auth_mode` 但包含有效 token、且不含 API Key 的旧版官方 OAuth;损坏的 live
`auth.json` 与损坏的应用内官方快照都不阻塞状态读取或供应商切换,且绝不能被晋升为官方
认证快照。完整官方 TOML 是模型字段的权威源,独立模型输入只补齐缺失值。
- 启动状态读取不得迁移提示词、修改文件权限、捕获认证或扫描历史备份;概览在结果返回前显示
“正在读取”,失败后显示“读取失败”。
- Codex-X 内部数据库使用持久 `user_version`。已升级数据库的普通打开只读版本号;迁移、历史
清理和版本更新在同一个 `BEGIN IMMEDIATE` 事务完成,失败可重试,同路径替换后可重新初始化。
- Codex 版本探测在 blocking worker 中执行,固定候选先探测,目录候选流式探测,所有阶段共享
同一个总 deadline,避免 Windows 慢盘或重定向用户目录拖住启动。
- 对照 CC Switch `8673e9d8d8508b89c48056523c5f86e7916b4c3c` 的完整 provider config
存储与官方 `auth.json` 保留策略;Codex-X 额外保留 live 配置的并发检查与原子回滚。
- 对照 CC Switch `95f2dd41262f01209100128ea647dbd054b5624a` 的完整 provider config、
官方登录保留和 provider-scoped bearer token 路径,并用 Codex CLI `0.147.0-alpha.6.5` 的
隔离回环请求验证作用域 Key 优先级;
Codex-X 额外保留官方独立快照、live 配置并发检查与条件原子回滚。本节决策替代 2026-07-29
中关于第三方 bearer token 和模板激活的旧约束。
## 2026-07-30:live 配置并发与失败回滚
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "codex-x-workspace",
"private": true,
"version": "0.3.9",
"version": "0.3.11",
"scripts": {
"dev": "pnpm --dir apps/desktop tauri dev",
"build": "pnpm --dir apps/desktop tauri build",