mirror of
https://github.com/yynxxxxx/Codex-X.git
synced 2026-10-18 05:59:23 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1b40285dd | ||
|
|
3ac28c3193 |
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "codex-x",
|
||||
"version": "0.3.9",
|
||||
"version": "0.3.11",
|
||||
"private": true,
|
||||
"description": "Codex Switch & Instruct desktop manager",
|
||||
"type": "module",
|
||||
|
||||
Generated
+1
-1
@@ -490,7 +490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codex-x"
|
||||
version = "0.3.9"
|
||||
version = "0.3.11"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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();
|
||||
|
||||
+1048
-77
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@ export type ActionResult = {
|
||||
|
||||
export type OfficialConfigDraft = {
|
||||
authJson: string;
|
||||
configText: string;
|
||||
model?: string;
|
||||
source: string;
|
||||
};
|
||||
|
||||
+18
-6
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user