Compare commits

...
3 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
yyyyyynnn 39dc8fc535 release: prepare v0.3.9 2026-08-11 15:46:25 +08:00
28 changed files with 4975 additions and 885 deletions
+18
View File
@@ -4,6 +4,24 @@ 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
### 更新
- 优化了软件的流畅度和部分 bug。
## [v0.3.8] - 2026-08-03
### 修复 Bug
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "codex-x",
"version": "0.3.8",
"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.8"
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.8"
version = "0.3.11"
description = "Codex Switch & Instruct desktop manager"
authors = ["yynxxxxx"]
license = "MIT"
+276 -13
View File
@@ -3,9 +3,101 @@ use crate::file_io::ensure_directory;
use crate::paths::app_home;
use crate::sqlite_utils::table_column_set;
use rusqlite::Connection;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
const APP_DB_SCHEMA_VERSION: i64 = 1;
struct DatabaseInitializer {
migration_lock: Mutex<()>,
}
impl DatabaseInitializer {
fn new() -> Self {
Self {
migration_lock: Mutex::new(()),
}
}
fn open_at(&self, path: &Path) -> Result<Connection> {
self.open_at_with(path, initialize_schema)
}
fn open_at_with(
&self,
path: &Path,
initialize: impl FnOnce(&Connection) -> Result<()>,
) -> Result<Connection> {
if let Some(parent) = path.parent() {
ensure_directory(parent)?;
}
let conn = Connection::open(path).map_err(|e| CodexxError::Database(e.to_string()))?;
conn.busy_timeout(Duration::from_secs(5))
.map_err(|e| CodexxError::Database(e.to_string()))?;
if schema_is_current(&conn)? {
return Ok(conn);
}
// Serialize first-time migrations inside this process. The persistent
// schema version also prevents repeated migrations across launches and
// detects a database replaced at the same path.
let _migration_guard = self
.migration_lock
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if !schema_is_current(&conn)? {
migrate_schema(&conn, initialize)?;
}
Ok(conn)
}
}
fn schema_version(conn: &Connection) -> Result<i64> {
conn.pragma_query_value(None, "user_version", |row| row.get(0))
.map_err(|error| CodexxError::Database(error.to_string()))
}
fn schema_is_current(conn: &Connection) -> Result<bool> {
Ok(schema_version(conn)? >= APP_DB_SCHEMA_VERSION)
}
fn migrate_schema(
conn: &Connection,
initialize: impl FnOnce(&Connection) -> Result<()>,
) -> Result<()> {
conn.execute_batch("BEGIN IMMEDIATE")
.map_err(|error| CodexxError::Database(error.to_string()))?;
let migration = (|| {
// Another Codex-X process may have completed the migration while this
// connection waited for SQLite's write lock.
if !schema_is_current(conn)? {
initialize(conn)?;
conn.pragma_update(None, "user_version", APP_DB_SCHEMA_VERSION)
.map_err(|error| CodexxError::Database(error.to_string()))?;
}
conn.execute_batch("COMMIT")
.map_err(|error| CodexxError::Database(error.to_string()))
})();
match migration {
Ok(()) => Ok(()),
Err(error) => match conn.execute_batch("ROLLBACK") {
Ok(()) => Err(error),
Err(rollback_error) => Err(CodexxError::Database(format!(
"{error}; app database migration rollback failed: {rollback_error}"
))),
},
}
}
fn database_initializer() -> &'static DatabaseInitializer {
static INITIALIZER: OnceLock<DatabaseInitializer> = OnceLock::new();
INITIALIZER.get_or_init(DatabaseInitializer::new)
}
#[cfg(test)]
pub(crate) fn test_db_guard() -> std::sync::MutexGuard<'static, ()> {
use std::sync::{Mutex, OnceLock};
@@ -45,14 +137,7 @@ fn ensure_sqlite_column(
}
}
pub(crate) fn open() -> Result<Connection> {
let path = db_path()?;
if let Some(parent) = path.parent() {
ensure_directory(parent)?;
}
let conn = Connection::open(&path).map_err(|e| CodexxError::Database(e.to_string()))?;
conn.busy_timeout(Duration::from_secs(5))
.map_err(|e| CodexxError::Database(e.to_string()))?;
fn initialize_schema(conn: &Connection) -> Result<()> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS providers (
id TEXT PRIMARY KEY,
@@ -112,19 +197,19 @@ pub(crate) fn open() -> Result<Connection> {
)
.map_err(|e| CodexxError::Database(e.to_string()))?;
ensure_sqlite_column(
&conn,
conn,
"providers",
"toml_config",
"ALTER TABLE providers ADD COLUMN toml_config TEXT",
)?;
ensure_sqlite_column(
&conn,
conn,
"providers",
"source",
"ALTER TABLE providers ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'",
)?;
ensure_sqlite_column(
&conn,
conn,
"providers",
"source_id",
"ALTER TABLE providers ADD COLUMN source_id TEXT",
@@ -170,5 +255,183 @@ pub(crate) fn open() -> Result<Connection> {
[],
)
.map_err(|e| CodexxError::Database(e.to_string()))?;
Ok(conn)
Ok(())
}
pub(crate) fn open() -> Result<Connection> {
database_initializer().open_at(&db_path()?)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Barrier};
use std::thread;
use std::time::Duration;
fn test_db_path(name: &str) -> PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let suffix = COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir()
.join(format!(
"codex-x-app-db-{name}-{}-{suffix}",
std::process::id()
))
.join("codexx.db")
}
fn remove_test_db(path: &Path) {
if let Some(parent) = path.parent() {
fs::remove_dir_all(parent).expect("remove app database test directory");
}
}
#[test]
fn repeated_opens_do_not_rerun_legacy_cleanup() {
let path = test_db_path("cleanup-once");
let initializer = DatabaseInitializer::new();
let conn = initializer.open_at(&path).expect("initialize database");
conn.execute_batch(
"INSERT INTO prompts (id, title, filename, content, created_at, updated_at)
VALUES
('kept', 'Kept', 'same.md', 'same', '1', '1'),
('external-duplicate', 'Duplicate', 'same.md', 'same', '2', '2');",
)
.expect("seed a post-migration duplicate");
drop(conn);
let reopened_initializer = DatabaseInitializer::new();
let conn = reopened_initializer
.open_at(&path)
.expect("reopen database in a new process lifecycle");
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM prompts", [], |row| row.get(0))
.expect("count prompts after reopen");
assert_eq!(count, 2, "reopen must not rerun migration cleanup");
drop(conn);
remove_test_db(&path);
}
#[test]
fn database_replaced_at_the_same_path_is_initialized_again() {
let path = test_db_path("replace-database");
let initializer = DatabaseInitializer::new();
let conn = initializer.open_at(&path).expect("initialize database");
assert_eq!(schema_version(&conn).expect("read schema version"), 1);
drop(conn);
fs::remove_file(&path).expect("replace initialized database");
let conn = initializer
.open_at(&path)
.expect("initialize replacement database");
let provider_count: i64 = conn
.query_row("SELECT COUNT(*) FROM providers", [], |row| row.get(0))
.expect("query replacement database schema");
assert_eq!(provider_count, 0);
assert_eq!(schema_version(&conn).expect("read replacement version"), 1);
drop(conn);
remove_test_db(&path);
}
#[test]
fn concurrent_first_opens_initialize_once() {
let path = test_db_path("concurrent-init");
let attempts = Arc::new(AtomicUsize::new(0));
let barrier = Arc::new(Barrier::new(3));
let mut workers = Vec::new();
for _ in 0..2 {
let path = path.clone();
// Separate initializers model independent Codex-X processes; the
// SQLite transaction and persistent version still permit one run.
let initializer = DatabaseInitializer::new();
let attempts = Arc::clone(&attempts);
let barrier = Arc::clone(&barrier);
workers.push(thread::spawn(move || {
barrier.wait();
initializer.open_at_with(&path, |conn| {
attempts.fetch_add(1, Ordering::SeqCst);
thread::sleep(Duration::from_millis(25));
conn.execute_batch("CREATE TABLE initialized_once (id INTEGER PRIMARY KEY);")
.map_err(|error| CodexxError::Database(error.to_string()))
})
}));
}
barrier.wait();
for worker in workers {
drop(
worker
.join()
.expect("join concurrent database opener")
.expect("open database concurrently"),
);
}
assert_eq!(attempts.load(Ordering::SeqCst), 1);
remove_test_db(&path);
}
#[test]
fn failed_initialization_can_retry() {
let path = test_db_path("retry-init");
let initializer = DatabaseInitializer::new();
let attempts = AtomicUsize::new(0);
let error = initializer
.open_at_with(&path, |_| {
attempts.fetch_add(1, Ordering::SeqCst);
Err(CodexxError::Database(
"injected transient initialization failure".to_string(),
))
})
.expect_err("first initialization must fail");
assert!(error
.to_string()
.contains("transient initialization failure"));
let conn = initializer
.open_at_with(&path, |conn| {
attempts.fetch_add(1, Ordering::SeqCst);
conn.execute_batch("CREATE TABLE retry_succeeded (id INTEGER PRIMARY KEY);")
.map_err(|error| CodexxError::Database(error.to_string()))
})
.expect("retry initialization");
drop(conn);
let conn = initializer
.open_at_with(&path, |_| {
attempts.fetch_add(1, Ordering::SeqCst);
Ok(())
})
.expect("open initialized database");
assert_eq!(attempts.load(Ordering::SeqCst), 2);
drop(conn);
remove_test_db(&path);
}
#[test]
fn initialized_read_connection_does_not_need_a_write_lock() {
let path = test_db_path("read-with-writer");
let initializer = DatabaseInitializer::new();
let writer = initializer.open_at(&path).expect("initialize database");
writer
.execute_batch("BEGIN IMMEDIATE")
.expect("hold database write reservation");
let reader = initializer
.open_at(&path)
.expect("open reader while write reservation is held");
let count: i64 = reader
.query_row("SELECT COUNT(*) FROM providers", [], |row| row.get(0))
.expect("read while another connection holds write reservation");
assert_eq!(count, 0);
drop(reader);
writer
.execute_batch("ROLLBACK")
.expect("release write lock");
drop(writer);
remove_test_db(&path);
}
}
-4
View File
@@ -216,7 +216,3 @@ pub(crate) fn backups() -> Result<Vec<BackupEntry>> {
entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(entries)
}
pub(crate) fn latest_backup() -> Result<Option<BackupEntry>> {
Ok(backups()?.into_iter().next())
}
@@ -2,9 +2,9 @@ use crate::backups::create_backup;
use crate::config_path;
use crate::error::Result;
use crate::file_io::parse_toml_document;
use crate::live_config::{
acquire_live_config_lock, atomic_write_if_unchanged, read_file_snapshot, text_from_snapshot,
};
#[cfg(test)]
use crate::live_config::acquire_live_config_lock;
use crate::live_config::{atomic_write_if_unchanged, read_file_snapshot, text_from_snapshot};
use std::path::Path;
use toml_edit::{DocumentMut, Item, Table};
@@ -31,6 +31,7 @@ fn remove_markdown_path(table: &mut Table, key: &str) -> Option<Item> {
/// Repairs prompt paths appended by older Codex-X versions after a `[tui]`
/// header. Only the exact legacy keys with Markdown path values are touched.
#[cfg(test)]
pub(crate) fn migrate_legacy_prompt_config(codex_dir: &Path) -> Result<bool> {
let _lock = acquire_live_config_lock(codex_dir)?;
migrate_legacy_prompt_config_locked(codex_dir)
+1
View File
@@ -108,6 +108,7 @@ pub(crate) fn read_to_string_if_exists(path: &Path) -> Result<String> {
fs::read_to_string(path).map_err(|e| io_err(path, e))
}
#[cfg(test)]
pub(crate) fn harden_sensitive_file_permissions(path: &Path) -> Result<()> {
#[cfg(unix)]
{
+29 -13
View File
@@ -82,7 +82,7 @@ use providers::{
upsert_provider_on_connection, CcSwitchCodexRow, ProviderUpsertKind, ProviderUpsertMode,
};
use providers::{
capture_live_chatgpt_config, delete_saved_provider_inner, fetch_provider_models_inner,
build_provider_toml_draft_inner, delete_saved_provider_inner, fetch_provider_models_inner,
get_official_config_draft_inner, import_ccswitch_codex_providers_inner,
list_saved_providers_inner, read_ccswitch_official_auth_inner, reset_official_provider_inner,
restore_official_provider_inner, save_active_provider_inner, save_official_config_inner,
@@ -115,9 +115,9 @@ use skills_mcp::{
};
#[cfg(test)]
use state::active_saved_provider_id_from_config;
use state::{
auth_has_material, build_state, build_state_after_migration, ActionResult, CodexState,
};
#[cfg(test)]
use state::build_state;
use state::{auth_has_material, build_state_after_migration, ActionResult, CodexState};
use toml_edit::{value, DocumentMut};
pub(crate) use toml_utils::string_value;
use updates::check_app_update;
@@ -183,7 +183,7 @@ pub(crate) fn now_rfc3339() -> String {
fn active_remote_builtin_prompt_id(config_dir: Option<String>) -> Option<String> {
let codex_dir = resolve_codex_dir(config_dir).ok()?;
let state = build_state(codex_dir).ok()?;
let state = build_state_after_migration(codex_dir).ok()?;
let template_key = state.instruction_template_key.as_deref()?;
let id = template_key.strip_prefix("builtin:")?.trim();
if id.is_empty() || bundled_prompt_meta(id).is_some() {
@@ -852,6 +852,18 @@ async fn list_saved_providers() -> Result<Vec<SavedProvider>> {
.map_err(|e| CodexxError::Config(format!("读取供应商列表失败: {e}")))?
}
#[tauri::command]
async fn build_provider_toml_draft(
provider: SavedProvider,
config_dir: Option<String>,
) -> Result<String> {
tauri::async_runtime::spawn_blocking(move || {
build_provider_toml_draft_inner(provider, config_dir)
})
.await
.map_err(|e| CodexxError::Config(format!("生成供应商 TOML 失败: {e}")))?
}
fn save_provider_command_inner(provider: SavedProvider) -> Result<SavedProvider> {
save_provider_inner(provider)
}
@@ -889,12 +901,6 @@ async fn get_codex_state(config_dir: Option<String>) -> Result<CodexState> {
fn get_codex_state_inner(config_dir: Option<String>) -> Result<CodexState> {
let codex_dir = resolve_codex_dir(config_dir)?;
ensure_directory(&codex_dir)?;
let _live_lock = acquire_live_config_lock(&codex_dir)?;
migrate_legacy_prompt_config_locked(&codex_dir)?;
// Read-only refreshes only capture high-confidence ChatGPT credentials.
// Explicit provider switches also preserve official API-key configs.
capture_live_chatgpt_config(&codex_dir)?;
build_state_after_migration(codex_dir)
}
@@ -924,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}")))?
@@ -933,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}")))?
@@ -1239,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)?;
@@ -1350,6 +1365,7 @@ pub fn run() {
delete_saved_prompt,
enable_saved_prompt,
list_saved_providers,
build_provider_toml_draft,
save_provider,
save_active_provider,
delete_saved_provider,
+511 -95
View File
@@ -1,7 +1,11 @@
use std::collections::HashSet;
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::process::{Child, Command, Output, Stdio};
use std::sync::{mpsc, OnceLock};
use std::thread;
use std::time::{Duration, Instant};
#[cfg(target_os = "windows")]
use std::env;
@@ -12,6 +16,13 @@ const WINDOWS_CODEX_PACKAGE_IDENTITIES: &[&str] =
#[cfg(target_os = "windows")]
const WINDOWS_CODEX_EXECUTABLES: &[&str] = &["ChatGPT.exe", "Codex.exe", "codex.exe"];
const CODEX_VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
const PROGRAM_TIMEOUT: Duration = Duration::from_secs(2);
const PROGRAM_POLL_INTERVAL: Duration = Duration::from_millis(10);
const CHILD_TERMINATION_GRACE: Duration = Duration::from_millis(250);
static CODEX_VERSION: OnceLock<String> = OnceLock::new();
fn version_line(stdout: &str, stderr: &str, success: bool) -> Option<String> {
let lines = stdout.lines().chain(stderr.lines()).map(str::trim);
let preferred = lines.clone().find(|line| {
@@ -79,14 +90,142 @@ pub fn program_command(program: &Path, args: &[&str]) -> Command {
command
}
fn run_program(program: &Path, args: &[&str]) -> Option<Output> {
program_command(program, args).output().ok()
fn remaining_timeout(deadline: Option<Instant>, maximum: Duration) -> Option<Duration> {
let remaining = deadline
.map(|deadline| deadline.saturating_duration_since(Instant::now()))
.unwrap_or(maximum)
.min(maximum);
(!remaining.is_zero()).then_some(remaining)
}
fn command_version(program: &Path) -> Option<String> {
run_program(program, &["--version"])
fn deadline_expired(deadline: Option<Instant>) -> bool {
deadline.is_some_and(|deadline| Instant::now() >= deadline)
}
fn wait_for_child_exit(child: &mut Child, deadline: Instant) {
loop {
match child.try_wait() {
Ok(Some(_)) | Err(_) => return,
Ok(None) if Instant::now() < deadline => {
let remaining = deadline.saturating_duration_since(Instant::now());
thread::sleep(remaining.min(PROGRAM_POLL_INTERVAL));
}
Ok(None) => return,
}
}
}
fn terminate_child(child: &mut Child) {
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let pid = child.id().to_string();
let mut taskkill = Command::new("taskkill.exe");
taskkill
.args(["/PID", pid.as_str(), "/T", "/F"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.creation_flags(CREATE_NO_WINDOW);
if let Ok(mut killer) = taskkill.spawn() {
let deadline = Instant::now() + CHILD_TERMINATION_GRACE;
wait_for_child_exit(&mut killer, deadline);
let _ = killer.kill();
}
}
let _ = child.kill();
wait_for_child_exit(child, Instant::now() + CHILD_TERMINATION_GRACE);
}
fn output_reader<R>(mut stream: R) -> Option<mpsc::Receiver<Option<Vec<u8>>>>
where
R: Read + Send + 'static,
{
let (sender, receiver) = mpsc::channel();
thread::Builder::new()
.name("codex-version-output".to_string())
.spawn(move || {
let mut output = Vec::new();
let result = stream.read_to_end(&mut output).ok().map(|_| output);
let _ = sender.send(result);
})
.ok()?;
Some(receiver)
}
fn receive_output(
receiver: &mpsc::Receiver<Option<Vec<u8>>>,
deadline: Instant,
) -> Option<Vec<u8>> {
match receiver.try_recv() {
Ok(output) => output,
Err(mpsc::TryRecvError::Disconnected) => None,
Err(mpsc::TryRecvError::Empty) => {
let remaining = deadline.saturating_duration_since(Instant::now());
(!remaining.is_zero())
.then(|| receiver.recv_timeout(remaining).ok().flatten())
.flatten()
}
}
}
fn run_program(program: &Path, args: &[&str], deadline: Option<Instant>) -> Option<Output> {
let timeout = remaining_timeout(deadline, PROGRAM_TIMEOUT)?;
let command_deadline = Instant::now().checked_add(timeout)?;
let mut command = program_command(program, args);
command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = command.spawn().ok()?;
let Some(stdout) = child.stdout.take() else {
terminate_child(&mut child);
return None;
};
let Some(stderr) = child.stderr.take() else {
terminate_child(&mut child);
return None;
};
// Drain both pipes while polling so verbose commands cannot block on a full pipe buffer.
let Some(stdout_receiver) = output_reader(stdout) else {
terminate_child(&mut child);
return None;
};
let Some(stderr_receiver) = output_reader(stderr) else {
terminate_child(&mut child);
return None;
};
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) if Instant::now() < command_deadline => {
let remaining = command_deadline.saturating_duration_since(Instant::now());
thread::sleep(remaining.min(PROGRAM_POLL_INTERVAL));
}
Ok(None) | Err(_) => {
terminate_child(&mut child);
return None;
}
}
};
let stdout = receive_output(&stdout_receiver, command_deadline)?;
let stderr = receive_output(&stderr_receiver, command_deadline)?;
Some(Output {
status,
stdout,
stderr,
})
}
fn command_version(program: &Path, deadline: Option<Instant>) -> Option<String> {
run_program(program, &["--version"], deadline)
.and_then(version_from_output)
.or_else(|| run_program(program, &["-V"]).and_then(version_from_output))
.or_else(|| run_program(program, &["-V"], deadline).and_then(version_from_output))
}
fn candidate_key(path: &Path) -> String {
@@ -143,34 +282,59 @@ fn latest_windows_package_version<'a>(
}
#[cfg(target_os = "windows")]
fn windows_store_app_version_from_roots(roots: &[PathBuf]) -> Option<String> {
fn windows_store_app_version_from_roots(
roots: &[PathBuf],
deadline: Option<Instant>,
) -> Option<String> {
let mut package_names = Vec::new();
for root in roots {
if deadline_expired(deadline) {
break;
}
let Ok(entries) = fs::read_dir(root) else {
continue;
};
package_names.extend(entries.flatten().filter_map(|entry| {
entry
.path()
.is_dir()
.then(|| entry.file_name().to_string_lossy().to_string())
}));
for entry in entries.flatten() {
if deadline_expired(deadline) {
break;
}
if entry.path().is_dir() {
package_names.push(entry.file_name().to_string_lossy().to_string());
}
}
}
latest_windows_package_version(package_names.iter().map(String::as_str))
.map(|version| format!("Codex app {version}"))
}
fn collect_named_files(root: &Path, names: &[&str], depth: usize, output: &mut Vec<PathBuf>) {
if depth == 0 || !root.is_dir() {
return;
fn visit_named_files<F>(
root: &Path,
names: &[&str],
depth: usize,
deadline: Option<Instant>,
visit: &mut F,
) -> bool
where
F: FnMut(PathBuf) -> bool,
{
if depth == 0 || deadline_expired(deadline) {
return !deadline_expired(deadline);
}
if !root.is_dir() {
return !deadline_expired(deadline);
}
let Ok(entries) = fs::read_dir(root) else {
return;
return !deadline_expired(deadline);
};
for entry in entries.flatten() {
if deadline_expired(deadline) {
return false;
}
let path = entry.path();
if path.is_dir() {
collect_named_files(&path, names, depth - 1, output);
if !visit_named_files(&path, names, depth - 1, deadline, visit) {
return false;
}
} else if path.is_file()
&& path
.file_name()
@@ -180,55 +344,81 @@ fn collect_named_files(root: &Path, names: &[&str], depth: usize, output: &mut V
.iter()
.any(|candidate| name.eq_ignore_ascii_case(candidate))
})
&& !visit(path)
{
output.push(path);
return false;
}
}
!deadline_expired(deadline)
}
fn extension_codex_candidates(home: &Path) -> Vec<PathBuf> {
fn visit_extension_codex_candidates<F>(
home: &Path,
deadline: Option<Instant>,
visit: &mut F,
) -> bool
where
F: FnMut(PathBuf) -> bool,
{
let roots = [
home.join(".cursor").join("extensions"),
home.join(".vscode").join("extensions"),
home.join(".vscode-insiders").join("extensions"),
home.join(".windsurf").join("extensions"),
];
let mut candidates = Vec::new();
for root in roots {
if deadline_expired(deadline) {
return false;
}
let Ok(entries) = fs::read_dir(root) else {
continue;
};
let mut extension_dirs = entries
.flatten()
.map(|entry| entry.path())
.filter(|path| {
path.is_dir()
&& path
.file_name()
.and_then(|value| value.to_str())
.is_some_and(|name| {
let lower = name.to_ascii_lowercase();
lower.starts_with("openai.chatgpt-")
|| lower.starts_with("openai.codex-")
})
})
.collect::<Vec<_>>();
let mut extension_dirs = Vec::new();
for entry in entries.flatten() {
if deadline_expired(deadline) {
break;
}
let path = entry.path();
if path.is_dir()
&& path
.file_name()
.and_then(|value| value.to_str())
.is_some_and(|name| {
let lower = name.to_ascii_lowercase();
lower.starts_with("openai.chatgpt-") || lower.starts_with("openai.codex-")
})
{
extension_dirs.push(path);
}
}
if deadline_expired(deadline) {
return false;
}
extension_dirs.sort_by(|a, b| b.file_name().cmp(&a.file_name()));
for extension_dir in extension_dirs {
collect_named_files(
if deadline_expired(deadline) {
return false;
}
if !visit_named_files(
&extension_dir,
&["codex", "codex.exe", "codex.cmd"],
5,
&mut candidates,
);
deadline,
visit,
) {
return false;
}
}
}
candidates
true
}
#[cfg(target_os = "macos")]
fn platform_candidates(home: &Path) -> Vec<PathBuf> {
let mut candidates = vec![
fn visit_platform_candidates<F>(home: &Path, deadline: Option<Instant>, visit: &mut F) -> bool
where
F: FnMut(PathBuf) -> bool,
{
let candidates = [
PathBuf::from("/Applications/ChatGPT.app/Contents/Resources/codex"),
home.join("Applications/ChatGPT.app/Contents/Resources/codex"),
PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"),
@@ -245,30 +435,49 @@ fn platform_candidates(home: &Path) -> Vec<PathBuf> {
home.join(".npm-global/bin/codex"),
home.join("Library/pnpm/codex"),
];
candidates.extend(extension_codex_candidates(home));
candidates
for candidate in candidates {
if deadline_expired(deadline) || !visit(candidate) {
return false;
}
}
visit_extension_codex_candidates(home, deadline, visit)
}
#[cfg(target_os = "windows")]
fn platform_candidates(home: &Path) -> Vec<PathBuf> {
let mut candidates = Vec::new();
fn visit_platform_candidates<F>(home: &Path, deadline: Option<Instant>, visit: &mut F) -> bool
where
F: FnMut(PathBuf) -> bool,
{
if let Ok(appdata) = env::var("APPDATA") {
let appdata = PathBuf::from(appdata);
candidates.push(appdata.join("npm").join("codex.cmd"));
candidates.push(appdata.join("npm").join("codex.exe"));
for candidate in [
appdata.join("npm").join("codex.cmd"),
appdata.join("npm").join("codex.exe"),
] {
if deadline_expired(deadline) || !visit(candidate) {
return false;
}
}
for target in ["x86_64-pc-windows-msvc", "aarch64-pc-windows-msvc"] {
candidates.push(
appdata
.join("npm/node_modules/@openai/codex/vendor")
.join(target)
.join("codex/codex.exe"),
);
let candidate = appdata
.join("npm/node_modules/@openai/codex/vendor")
.join(target)
.join("codex/codex.exe");
if deadline_expired(deadline) || !visit(candidate) {
return false;
}
}
}
if let Ok(localappdata) = env::var("LOCALAPPDATA") {
let localappdata = PathBuf::from(localappdata);
candidates.push(localappdata.join("Microsoft/WindowsApps/codex.exe"));
candidates.push(localappdata.join("Microsoft/WindowsApps/codex.cmd"));
for candidate in [
localappdata.join("Microsoft/WindowsApps/codex.exe"),
localappdata.join("Microsoft/WindowsApps/codex.cmd"),
] {
if deadline_expired(deadline) || !visit(candidate) {
return false;
}
}
for root in [
localappdata.join("Programs/ChatGPT"),
localappdata.join("Programs/Codex"),
@@ -276,28 +485,35 @@ fn platform_candidates(home: &Path) -> Vec<PathBuf> {
localappdata.join("OpenAI/ChatGPT"),
localappdata.join("OpenAI/Codex"),
] {
collect_named_files(&root, &["codex.exe", "codex.cmd"], 7, &mut candidates);
if !visit_named_files(&root, &["codex.exe", "codex.cmd"], 7, deadline, visit) {
return false;
}
}
}
for variable in ["ProgramFiles", "ProgramFiles(x86)"] {
if let Ok(program_files) = env::var(variable) {
for app in ["ChatGPT", "Codex"] {
collect_named_files(
if !visit_named_files(
&PathBuf::from(&program_files).join(app),
&["codex.exe", "codex.cmd"],
7,
&mut candidates,
);
deadline,
visit,
) {
return false;
}
}
}
}
candidates.extend(extension_codex_candidates(home));
candidates
visit_extension_codex_candidates(home, deadline, visit)
}
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
fn platform_candidates(home: &Path) -> Vec<PathBuf> {
let mut candidates = vec![
fn visit_platform_candidates<F>(home: &Path, deadline: Option<Instant>, visit: &mut F) -> bool
where
F: FnMut(PathBuf) -> bool,
{
let candidates = [
PathBuf::from("/usr/local/bin/codex"),
PathBuf::from("/usr/bin/codex"),
PathBuf::from("/snap/bin/codex"),
@@ -305,21 +521,17 @@ fn platform_candidates(home: &Path) -> Vec<PathBuf> {
home.join(".npm-global/bin/codex"),
home.join(".local/share/pnpm/codex"),
];
candidates.extend(extension_codex_candidates(home));
candidates
for candidate in candidates {
if deadline_expired(deadline) || !visit(candidate) {
return false;
}
}
visit_extension_codex_candidates(home, deadline, visit)
}
#[cfg(target_os = "windows")]
fn windows_where_candidates() -> Vec<PathBuf> {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let mut command = Command::new("where.exe");
let Ok(output) = command
.creation_flags(CREATE_NO_WINDOW)
.arg("codex")
.output()
else {
fn windows_where_candidates(deadline: Option<Instant>) -> Vec<PathBuf> {
let Some(output) = run_program(Path::new("where.exe"), &["codex"], deadline) else {
return Vec::new();
};
if !output.status.success() {
@@ -334,12 +546,12 @@ fn windows_where_candidates() -> Vec<PathBuf> {
}
#[cfg(not(target_os = "windows"))]
fn windows_where_candidates() -> Vec<PathBuf> {
fn windows_where_candidates(_deadline: Option<Instant>) -> Vec<PathBuf> {
Vec::new()
}
#[cfg(target_os = "macos")]
fn macos_app_version() -> Option<String> {
fn macos_app_version(deadline: Option<Instant>) -> Option<String> {
let home = dirs::home_dir().unwrap_or_default();
for root in [PathBuf::from("/Applications"), home.join("Applications")] {
for name in [
@@ -349,6 +561,9 @@ fn macos_app_version() -> Option<String> {
"ChatGPT Codex.app",
"ChatGPT.app",
] {
if deadline_expired(deadline) {
return None;
}
let app = root.join(name);
if !app.is_dir() {
continue;
@@ -360,8 +575,11 @@ fn macos_app_version() -> Option<String> {
};
if let Some(version) = macos_info_plist_version(&app).or_else(|| {
let app = app.to_str()?;
let output =
run_program(Path::new("mdls"), &["-name", "kMDItemVersion", "-raw", app])?;
let output = run_program(
Path::new("mdls"),
&["-name", "kMDItemVersion", "-raw", app],
deadline,
)?;
output
.status
.success()
@@ -394,12 +612,12 @@ fn plist_string_value(plist: &str, key: &str) -> Option<String> {
}
#[cfg(not(target_os = "macos"))]
fn macos_app_version() -> Option<String> {
fn macos_app_version(_deadline: Option<Instant>) -> Option<String> {
None
}
#[cfg(target_os = "windows")]
fn windows_app_version() -> Option<String> {
fn windows_app_version(deadline: Option<Instant>) -> Option<String> {
let mut roots = Vec::new();
for variable in ["ProgramFiles", "ProgramW6432"] {
if let Ok(program_files) = env::var(variable) {
@@ -409,14 +627,19 @@ fn windows_app_version() -> Option<String> {
roots.push(PathBuf::from(r"C:\Program Files\WindowsApps"));
roots.sort();
roots.dedup();
if let Some(version) = windows_store_app_version_from_roots(&roots) {
if let Some(version) = windows_store_app_version_from_roots(&roots, deadline) {
return Some(version);
}
if deadline_expired(deadline) {
return None;
}
let script = "Get-AppxPackage | Where-Object { $_.Name -in @('OpenAI.Codex','OpenAI.CodexBeta','OpenAI.ChatGPT-Desktop') } | ForEach-Object { $_.Version.ToString() }";
if let Some(output) = run_program(
Path::new("powershell.exe"),
&["-NoProfile", "-NonInteractive", "-Command", script],
deadline,
) {
if output.status.success() {
let versions = String::from_utf8_lossy(&output.stdout)
@@ -440,6 +663,9 @@ fn windows_app_version() -> Option<String> {
local_appdata.join("Programs/OpenAI/Codex"),
local_appdata.join("Programs/Codex"),
] {
if deadline_expired(deadline) {
return None;
}
if WINDOWS_CODEX_EXECUTABLES.iter().any(|name| {
directory.join(name).is_file() || directory.join("app").join(name).is_file()
}) {
@@ -450,41 +676,143 @@ fn windows_app_version() -> Option<String> {
}
#[cfg(not(target_os = "windows"))]
fn windows_app_version() -> Option<String> {
fn windows_app_version(_deadline: Option<Instant>) -> Option<String> {
None
}
pub fn codex_executable_candidates() -> Vec<PathBuf> {
let home = dirs::home_dir().unwrap_or_default();
fn path_codex_candidates(deadline: Option<Instant>) -> Vec<PathBuf> {
let mut candidates = ["codex", "codex.exe", "codex.cmd"]
.into_iter()
.map(PathBuf::from)
.collect::<Vec<_>>();
candidates.extend(windows_where_candidates());
candidates.extend(platform_candidates(&home));
if !deadline_expired(deadline) {
candidates.extend(windows_where_candidates(deadline));
}
candidates
}
fn append_unique_candidates(
unique: &mut Vec<PathBuf>,
seen: &mut HashSet<String>,
candidates: impl IntoIterator<Item = PathBuf>,
) {
for candidate in candidates {
push_candidate(unique, seen, candidate);
}
}
fn codex_executable_candidates_until(deadline: Option<Instant>) -> Vec<PathBuf> {
let home = dirs::home_dir().unwrap_or_default();
let mut seen = HashSet::new();
let mut unique = Vec::new();
for candidate in candidates {
push_candidate(&mut unique, &mut seen, candidate);
append_unique_candidates(&mut unique, &mut seen, path_codex_candidates(deadline));
if !deadline_expired(deadline) {
let mut collect = |candidate| {
if deadline_expired(deadline) {
return false;
}
push_candidate(&mut unique, &mut seen, candidate);
true
};
let _ = visit_platform_candidates(&home, deadline, &mut collect);
}
unique
}
pub fn detect_codex_version() -> Option<String> {
for candidate in codex_executable_candidates() {
pub fn codex_executable_candidates() -> Vec<PathBuf> {
codex_executable_candidates_until(None)
}
fn version_from_candidates(
candidates: impl IntoIterator<Item = PathBuf>,
seen: &mut HashSet<String>,
deadline: Instant,
) -> Option<String> {
for candidate in candidates {
if deadline_expired(Some(deadline)) {
return None;
}
if !seen.insert(candidate_key(&candidate)) {
continue;
}
let is_bare_command = candidate.components().count() == 1;
if is_bare_command || candidate.is_file() {
if let Some(version) = command_version(&candidate) {
if let Some(version) = command_version(&candidate, Some(deadline)) {
return Some(version);
}
}
}
macos_app_version().or_else(windows_app_version)
None
}
fn version_from_platform_candidates(
home: &Path,
seen: &mut HashSet<String>,
deadline: Instant,
) -> Option<String> {
let mut detected = None;
let mut probe = |candidate| {
if deadline_expired(Some(deadline)) {
return false;
}
detected = version_from_candidates([candidate], seen, deadline);
detected.is_none() && !deadline_expired(Some(deadline))
};
let _ = visit_platform_candidates(home, Some(deadline), &mut probe);
detected
}
fn detect_codex_version_uncached() -> Option<String> {
let deadline = Instant::now().checked_add(CODEX_VERSION_PROBE_TIMEOUT)?;
let mut seen = HashSet::new();
// Probe cheap PATH/where.exe results before walking redirected profiles or slow disks.
if let Some(version) =
version_from_candidates(path_codex_candidates(Some(deadline)), &mut seen, deadline)
{
return Some(version);
}
if deadline_expired(Some(deadline)) {
return None;
}
let home = dirs::home_dir().unwrap_or_default();
if let Some(version) = version_from_platform_candidates(&home, &mut seen, deadline) {
return Some(version);
}
if deadline_expired(Some(deadline)) {
return None;
}
macos_app_version(Some(deadline)).or_else(|| windows_app_version(Some(deadline)))
}
fn cached_codex_version(
cache: &OnceLock<String>,
detect: impl FnOnce() -> Option<String>,
) -> Option<String> {
if let Some(version) = cache.get() {
return Some(version.clone());
}
let detected = detect()?;
let _ = cache.set(detected.clone());
cache.get().cloned().or(Some(detected))
}
pub fn detect_codex_version() -> Option<String> {
cached_codex_version(&CODEX_VERSION, detect_codex_version_uncached)
}
#[cfg(test)]
mod tests {
use super::{latest_windows_package_version, plist_string_value, version_line};
use super::{
cached_codex_version, latest_windows_package_version, plist_string_value, run_program,
version_line, visit_named_files,
};
use std::fs;
use std::path::Path;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::OnceLock;
use std::time::{Duration, Instant};
#[test]
fn version_parser_prefers_codex_line_over_warning() {
@@ -515,6 +843,94 @@ mod tests {
);
}
#[test]
fn codex_version_cache_runs_probe_once() {
let cache = OnceLock::new();
let calls = AtomicUsize::new(0);
let first = cached_codex_version(&cache, || {
calls.fetch_add(1, Ordering::Relaxed);
Some("codex-cli 1.2.3".to_string())
});
let second = cached_codex_version(&cache, || {
calls.fetch_add(1, Ordering::Relaxed);
Some("codex-cli 9.9.9".to_string())
});
assert_eq!(first.as_deref(), Some("codex-cli 1.2.3"));
assert_eq!(second, first);
assert_eq!(calls.load(Ordering::Relaxed), 1);
}
#[test]
fn codex_version_cache_retries_after_a_missing_result() {
let cache = OnceLock::new();
let calls = AtomicUsize::new(0);
assert_eq!(
cached_codex_version(&cache, || {
calls.fetch_add(1, Ordering::Relaxed);
None
}),
None
);
assert_eq!(
cached_codex_version(&cache, || {
calls.fetch_add(1, Ordering::Relaxed);
Some("codex-cli 1.2.3".to_string())
}),
Some("codex-cli 1.2.3".to_string())
);
assert_eq!(
cached_codex_version(&cache, || {
calls.fetch_add(1, Ordering::Relaxed);
Some("codex-cli 9.9.9".to_string())
}),
Some("codex-cli 1.2.3".to_string())
);
assert_eq!(calls.load(Ordering::Relaxed), 2);
}
#[test]
fn recursive_candidate_scan_stops_immediately_after_visitor_finishes() {
static TEMP_SEQUENCE: AtomicUsize = AtomicUsize::new(0);
let root = std::env::temp_dir().join(format!(
"codex-x-platform-test-{}-{}",
std::process::id(),
TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(root.join("first")).expect("create first candidate directory");
fs::create_dir_all(root.join("second")).expect("create second candidate directory");
fs::write(root.join("first/codex.exe"), b"").expect("create first candidate");
fs::write(root.join("second/codex.exe"), b"").expect("create second candidate");
let mut visits = 0;
let completed = visit_named_files(&root, &["codex.exe"], 3, None, &mut |_| {
visits += 1;
false
});
fs::remove_dir_all(&root).expect("remove candidate test directory");
assert!(!completed);
assert_eq!(visits, 1);
}
#[cfg(unix)]
#[test]
fn program_runner_stops_hung_process_at_deadline() {
let started = Instant::now();
let deadline = started + Duration::from_millis(100);
assert!(run_program(
Path::new("/bin/sh"),
&["-c", "while :; do :; done"],
Some(deadline),
)
.is_none());
assert!(started.elapsed() < Duration::from_secs(1));
}
#[test]
fn windows_package_detection_accepts_supported_codex_packages() {
assert_eq!(
+282 -63
View File
@@ -1,19 +1,20 @@
use super::{
consolidate_legacy_provider_duplicates_on_connection, custom_provider_id,
experimental_bearer_token_from_doc, list_saved_providers_on_connection,
normalize_saved_provider, open_store, upsert_ccswitch_provider_on_connection,
ProviderUpsertKind, SavedProvider,
normalize_saved_provider, open_store, provider_template_from_document,
upsert_ccswitch_provider_on_connection, ProviderUpsertKind, SavedProvider,
};
use crate::ccswitch::{ccswitch_db_candidates, default_ccswitch_db_path};
use crate::error::{CodexxError, Result};
use crate::sqlite_utils::table_column_set;
use crate::string_value;
use crate::toml_utils::ensure_table;
use rusqlite::{Connection, OpenFlags, TransactionBehavior};
use serde::Serialize;
use serde_json::Value;
use std::collections::HashMap;
use std::path::PathBuf;
use toml_edit::{DocumentMut, Table};
use toml_edit::{value, DocumentMut, Item, Table};
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -31,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,
}
@@ -92,6 +94,7 @@ pub(crate) struct CcSwitchCodexSection {
pub(crate) wire_api: String,
pub(crate) requires_openai_auth: bool,
pub(crate) experimental_bearer_token: Option<String>,
pub(crate) provider_table: Table,
}
fn table_string(table: &Table, key: &str) -> Option<String> {
@@ -135,6 +138,7 @@ pub(super) fn codex_section_from_table(
.and_then(|item| item.as_bool())
.unwrap_or(false),
experimental_bearer_token: table_string(table, "experimental_bearer_token"),
provider_table: table.clone(),
})
}
@@ -161,79 +165,120 @@ fn select_ccswitch_section_for_row(
global_sections: &HashMap<String, CcSwitchCodexSection>,
) -> Option<CcSwitchCodexSection> {
let provider_id = custom_provider_id(&row.id);
if let Some(section) = global_sections.get(&provider_id) {
return Some(section.clone());
}
if let Some(section) = global_sections.get(row.id.trim()) {
return Some(section.clone());
}
let config_text = settings.get("config").and_then(Value::as_str).unwrap_or("");
let doc = config_text.parse::<DocumentMut>().ok()?;
let model = string_value(&doc, "model");
let active_provider = string_value(&doc, "model_provider");
let providers = doc.get("model_providers").and_then(|item| item.as_table());
let doc = config_text.parse::<DocumentMut>().ok();
if let Some(providers) = providers {
for exact_id in [provider_id.as_str(), row.id.trim()] {
if let Some(section) = providers
.get(exact_id)
.and_then(|item| item.as_table())
.and_then(|table| codex_section_from_table(exact_id, table, model.clone()))
{
return Some(section);
}
}
if let Some(doc) = doc.as_ref() {
let model = string_value(doc, "model");
let active_provider = string_value(doc, "model_provider");
let providers = doc.get("model_providers").and_then(|item| item.as_table());
if active_provider.as_deref() == Some(row.id.trim())
|| active_provider.as_deref() == Some(provider_id.as_str())
{
if let Some(active) = active_provider.as_deref() {
if let Some(providers) = providers {
for exact_id in [provider_id.as_str(), row.id.trim()] {
if let Some(section) = providers
.get(active)
.get(exact_id)
.and_then(|item| item.as_table())
.and_then(|table| codex_section_from_table(active, table, model.clone()))
.and_then(|table| codex_section_from_table(exact_id, table, model.clone()))
{
return Some(section);
}
}
if active_provider.as_deref() == Some(row.id.trim())
|| active_provider.as_deref() == Some(provider_id.as_str())
{
if let Some(active) = active_provider.as_deref() {
if let Some(section) = providers
.get(active)
.and_then(|item| item.as_table())
.and_then(|table| codex_section_from_table(active, table, model.clone()))
{
return Some(section);
}
}
}
// Legacy cc-switch templates store each third-party provider under
// `[model_providers.custom]` in that row's own complete config.
if active_provider
.as_deref()
.is_none_or(|active| active == "custom")
{
if let Some(section) = providers
.get("custom")
.and_then(|item| item.as_table())
.and_then(|table| codex_section_from_table("custom", table, model.clone()))
{
return Some(section);
}
}
}
}
// Legacy cc-switch/custom templates often store every third-party provider
// under `[model_providers.custom]`. Only use it when the row's own config
// explicitly activates custom or contains no other provider identity.
if active_provider
.as_deref()
.is_none_or(|active| active == "custom")
{
if let Some(section) = providers
.get("custom")
.and_then(|item| item.as_table())
.and_then(|table| codex_section_from_table("custom", table, model.clone()))
{
return Some(section);
}
for exact_id in [provider_id.as_str(), row.id.trim()] {
if let Some(section) = global_sections.get(exact_id) {
return Some(section.clone());
}
}
let doc = doc?;
let active_provider = string_value(&doc, "model_provider");
doc.get("base_url")
.and_then(|item| item.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|base_url| CcSwitchCodexSection {
id: provider_id,
name: None,
base_url: base_url.trim_end_matches('/').to_string(),
model,
wire_api: "responses".to_string(),
requires_openai_auth: false,
experimental_bearer_token: experimental_bearer_token_from_doc(
&doc,
active_provider.as_deref(),
),
.map(|base_url| {
let base_url = base_url.trim_end_matches('/').to_string();
let token = experimental_bearer_token_from_doc(&doc, active_provider.as_deref());
let mut provider_table = Table::new();
provider_table["base_url"] = value(base_url.clone());
provider_table["wire_api"] = value("responses");
provider_table["requires_openai_auth"] = value(false);
if let Some(token) = token.as_deref() {
provider_table["experimental_bearer_token"] = value(token);
}
CcSwitchCodexSection {
id: provider_id,
name: None,
base_url,
model: string_value(&doc, "model"),
wire_api: "responses".to_string(),
requires_openai_auth: false,
experimental_bearer_token: token,
provider_table,
}
})
}
fn ccswitch_provider_template(
settings: &Value,
section: &CcSwitchCodexSection,
provider_name: &str,
model: &str,
) -> Option<String> {
let config_text = settings.get("config").and_then(Value::as_str).unwrap_or("");
let mut doc = if config_text.trim().is_empty() {
DocumentMut::new()
} else {
config_text.parse::<DocumentMut>().ok()?
};
let provider_id = section.id.trim();
if provider_id.is_empty() {
return None;
}
doc["model_provider"] = value(provider_id);
doc["model"] = value(model);
let providers = ensure_table(doc.as_table_mut(), "model_providers").ok()?;
let mut table = section.provider_table.clone();
table["name"] = value(provider_name);
table["base_url"] = value(section.base_url.clone());
table["wire_api"] = value(section.wire_api.clone());
table["requires_openai_auth"] = value(section.requires_openai_auth);
providers.insert(provider_id, Item::Table(table));
provider_template_from_document(&doc, provider_id, model).ok()
}
pub(crate) fn build_ccswitch_codex_provider(
row: &CcSwitchCodexRow,
global_sections: &HashMap<String, CcSwitchCodexSection>,
@@ -241,17 +286,23 @@ pub(crate) fn build_ccswitch_codex_provider(
let settings: Value = serde_json::from_str(&row.settings_config).ok()?;
let section = select_ccswitch_section_for_row(row, &settings, global_sections)?;
let api_key = ccswitch_auth_api_key(&settings).or(section.experimental_bearer_token.clone());
let provider_name = if row.name.trim().is_empty() {
section.name.clone().unwrap_or_else(|| row.id.clone())
} else {
row.name.trim().to_string()
};
let model = section
.model
.clone()
.unwrap_or_else(|| "gpt-5.5".to_string());
let toml_config = ccswitch_provider_template(&settings, &section, &provider_name, &model)?;
Some(SavedProvider {
id: custom_provider_id(&row.id),
provider_name: if row.name.trim().is_empty() {
section.name.unwrap_or_else(|| row.id.clone())
} else {
row.name.trim().to_string()
},
provider_name,
base_url: section.base_url,
model: section.model.unwrap_or_else(|| "gpt-5.5".to_string()),
model,
api_key,
toml_config: None,
toml_config: Some(toml_config),
wire_api: section.wire_api,
requires_openai_auth: section.requires_openai_auth,
})
@@ -435,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"));
@@ -446,7 +500,172 @@ pub(crate) fn read_ccswitch_official_auth_inner(
Ok(Some(OfficialAuthCandidate {
auth_json,
config_text,
model,
source: format!("cc-switch:{name}:{id}"),
}))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn provider_import_round_trips_complete_config_without_bearer_tokens() {
let settings_config = json!({
"auth": {"OPENAI_API_KEY": "sk-from-auth"},
"config": r#"# keep-imported-comment
model_provider = "custom"
model = "gpt-5.5"
model_reasoning_effort = "xhigh"
service_tier = "priority"
experimental_bearer_token = "sk-top-level"
notify = ["C:\\Users\\Thy\\codex-computer-use.exe", "turn-ended"]
[model_providers.custom]
name = "Stale name"
base_url = "https://proxy.example.com/v1/"
wire_api = "responses"
requires_openai_auth = false
experimental_bearer_token = "sk-from-config"
request_max_retries = 7
[model_providers.other]
name = "Other provider"
base_url = "https://other.example.com/v1"
experimental_bearer_token = "sk-other"
[projects."/work/project"]
trust_level = "trusted"
[desktop]
followUpQueueMode = "queue"
localeOverride = "zh-CN"
[windows]
sandbox = "elevated"
shell_path = 'D:\Program Files\PowerShell\7\pwsh.exe'
[plugins."browser@openai-bundled"]
enabled = true
[features]
js_repl = false
[shell_environment_policy.set]
CODEX_HOME = 'C:\Users\Thy\.codex'
[mcp_servers.docs]
command = "docs-server"
"#,
})
.to_string();
let row = CcSwitchCodexRow {
id: "magicai-123".to_string(),
name: " MagicAI ".to_string(),
settings_config,
category: None,
};
let provider =
build_ccswitch_codex_provider(&row, &HashMap::new()).expect("build cc-switch provider");
assert_eq!(provider.id, "magicai-123");
assert_eq!(provider.provider_name, "MagicAI");
assert_eq!(provider.base_url, "https://proxy.example.com/v1");
assert_eq!(provider.model, "gpt-5.5");
assert_eq!(provider.api_key.as_deref(), Some("sk-from-auth"));
assert_eq!(provider.wire_api, "responses");
assert!(!provider.requires_openai_auth);
let text = provider.toml_config.expect("complete provider TOML");
let doc = text.parse::<DocumentMut>().expect("parse provider TOML");
assert!(text.contains("# keep-imported-comment"));
assert_eq!(doc["model_provider"].as_str(), Some("custom"));
assert_eq!(doc["model_reasoning_effort"].as_str(), Some("xhigh"));
assert_eq!(doc["service_tier"].as_str(), Some("priority"));
assert_eq!(doc["notify"].as_array().map(|values| values.len()), Some(2));
assert_eq!(
doc["model_providers"]["custom"]["name"].as_str(),
Some("MagicAI")
);
assert_eq!(
doc["model_providers"]["custom"]["base_url"].as_str(),
Some("https://proxy.example.com/v1")
);
assert_eq!(
doc["model_providers"]["custom"]["request_max_retries"].as_integer(),
Some(7)
);
assert_eq!(
doc["model_providers"]["other"]["base_url"].as_str(),
Some("https://other.example.com/v1")
);
assert_eq!(
doc["projects"]["/work/project"]["trust_level"].as_str(),
Some("trusted")
);
assert_eq!(doc["desktop"]["followUpQueueMode"].as_str(), Some("queue"));
assert_eq!(doc["desktop"]["localeOverride"].as_str(), Some("zh-CN"));
assert_eq!(doc["windows"]["sandbox"].as_str(), Some("elevated"));
assert_eq!(
doc["windows"]["shell_path"].as_str(),
Some(r"D:\Program Files\PowerShell\7\pwsh.exe")
);
assert_eq!(
doc["plugins"]["browser@openai-bundled"]["enabled"].as_bool(),
Some(true)
);
assert_eq!(doc["features"]["js_repl"].as_bool(), Some(false));
assert_eq!(
doc["shell_environment_policy"]["set"]["CODEX_HOME"].as_str(),
Some(r"C:\Users\Thy\.codex")
);
assert_eq!(
doc["mcp_servers"]["docs"]["command"].as_str(),
Some("docs-server")
);
assert!(doc.get("experimental_bearer_token").is_none());
assert!(doc["model_providers"]
.as_table()
.expect("model providers table")
.iter()
.all(|(_, item)| item
.as_table()
.is_none_or(|table| table.get("experimental_bearer_token").is_none())));
assert!(!text.contains("sk-from-auth"));
assert!(!text.contains("experimental_bearer_token"));
}
#[test]
fn malformed_row_config_does_not_create_a_sparse_provider() {
let section = codex_sections_from_config(
r#"model_provider = "broken-row"
model = "gpt-5.5"
[model_providers.broken-row]
name = "Recovered only from another row"
base_url = "https://proxy.example.com/v1"
wire_api = "responses"
requires_openai_auth = false
"#,
)
.into_iter()
.next()
.expect("global provider section");
let mut global_sections = HashMap::new();
global_sections.insert(section.id.clone(), section);
let row = CcSwitchCodexRow {
id: "broken-row".to_string(),
name: "Broken row".to_string(),
settings_config: json!({
"auth": {"OPENAI_API_KEY": "sk-must-not-import"},
"config": "model = ["
})
.to_string(),
category: None,
};
assert!(build_ccswitch_codex_provider(&row, &global_sections).is_none());
}
}
File diff suppressed because it is too large Load Diff
+10 -9
View File
@@ -24,10 +24,10 @@ pub(crate) use connection::{
};
pub(crate) use live::detected_live_custom_provider;
pub(crate) use live::{
delete_saved_provider_inner, reset_official_provider_inner, restore_official_provider_inner,
save_active_provider_inner, save_official_config_inner, save_provider_toml_config_inner,
switch_official_provider_inner, switch_provider_inner, OfficialConfigInput, ProviderInput,
ProviderTomlInput,
build_provider_toml_draft_inner, delete_saved_provider_inner, reset_official_provider_inner,
restore_official_provider_inner, save_active_provider_inner, save_official_config_inner,
save_provider_toml_config_inner, switch_official_provider_inner, switch_provider_inner,
OfficialConfigInput, ProviderInput, ProviderTomlInput,
};
#[cfg(test)]
pub(crate) use live::{
@@ -35,10 +35,10 @@ pub(crate) use live::{
switch_provider_with_pre_persist,
};
#[cfg(test)]
pub(crate) use official_auth::official_snapshot_path_for_test;
pub(crate) use official_auth::{capture_live_chatgpt_config, official_snapshot_path_for_test};
pub(crate) use official_auth::{
capture_live_chatgpt_config, document_is_official, get_official_config_draft_inner,
official_auth_available, OfficialConfigDraft,
document_is_official, get_official_config_draft_inner, official_auth_available,
OfficialConfigDraft,
};
#[cfg(test)]
pub(crate) use store::{
@@ -51,8 +51,9 @@ pub(crate) use store::{
list_saved_providers_inner, list_saved_providers_on_connection,
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, unique_saved_provider_id_for_live,
upsert_ccswitch_provider_on_connection, ProviderUpsertKind, SavedProvider,
save_provider_inner, save_provider_with_rollback_inner, strip_provider_bearer_tokens,
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,25 +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);
}
pub(crate) fn capture_live_chatgpt_config(codex_dir: &Path) -> Result<bool> {
capture_live_official_auth(codex_dir, is_chatgpt_auth)
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)]
fn capture_live_official_auth(
codex_dir: &Path,
is_trusted: impl FnOnce(&Value) -> bool,
@@ -234,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!(
@@ -269,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,
}))
}
@@ -289,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>> {
@@ -335,85 +523,282 @@ 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);
}
}
Ok(latest_official_backup(codex_dir)?.is_some())
// Historical backup discovery is intentionally deferred to restore. A
// status refresh must not traverse every backup on the startup path.
Ok(false)
}
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");
}
}
+267 -64
View File
@@ -4,7 +4,7 @@ use crate::{now_rfc3339, sanitize_id};
use rusqlite::{params, Connection, TransactionBehavior};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use toml_edit::{value, DocumentMut, Item, Table};
use toml_edit::{value, DocumentMut};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -35,6 +35,7 @@ pub(crate) struct ProviderStoreRollback {
const MANUAL_PROVIDER_SOURCE: &str = "manual";
pub(crate) const CCSWITCH_PROVIDER_SOURCE: &str = "cc-switch";
const CCSWITCH_LOCAL_PROVIDER_SOURCE: &str = "cc-switch-local";
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) enum ProviderIdentity {
@@ -165,27 +166,40 @@ pub(crate) fn provider_template_from_document(
provider_id: &str,
model: &str,
) -> Result<String> {
let mut provider_table = doc
let provider_exists = doc
.get("model_providers")
.and_then(|item| item.as_table())
.and_then(|providers| providers.get(provider_id))
.and_then(|item| item.as_table())
.cloned()
.ok_or_else(|| {
CodexxError::Config(format!("供应商 TOML 缺少 [model_providers.{provider_id}]"))
})?;
provider_table.remove("experimental_bearer_token");
.is_some();
if !provider_exists {
return Err(CodexxError::Config(format!(
"供应商 TOML 缺少 [model_providers.{provider_id}]"
)));
}
let mut template = DocumentMut::new();
let mut template = doc.clone();
template["model_provider"] = value(provider_id);
template["model"] = value(model);
let mut providers = Table::new();
providers.set_implicit(true);
providers.insert(provider_id, Item::Table(provider_table));
template["model_providers"] = Item::Table(providers);
strip_provider_bearer_tokens(&mut template);
Ok(template.to_string().trim_end().to_string())
}
pub(crate) fn strip_provider_bearer_tokens(doc: &mut DocumentMut) {
doc.as_table_mut().remove("experimental_bearer_token");
let Some(providers) = doc
.get_mut("model_providers")
.and_then(|item| item.as_table_mut())
else {
return;
};
for (_, item) in providers.iter_mut() {
if let Some(table) = item.as_table_mut() {
table.remove("experimental_bearer_token");
}
}
}
fn same_provider_endpoint(left: &SavedProvider, right: &SavedProvider) -> bool {
let left = canonical_provider_base_url(&left.base_url);
!left.is_empty() && left == canonical_provider_base_url(&right.base_url)
@@ -417,54 +431,44 @@ 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
}
fn source_matches(row: &StoredProvider, origin: (&str, &str)) -> bool {
row.source == origin.0 && row.source_id.as_deref() == Some(origin.1)
let source_matches = row.source == origin.0
|| (origin.0 == CCSWITCH_PROVIDER_SOURCE && row.source == CCSWITCH_LOCAL_PROVIDER_SOURCE);
source_matches && row.source_id.as_deref() == Some(origin.1)
}
fn source_can_merge(row: &StoredProvider, origin: Option<(&str, &str)>) -> bool {
@@ -547,16 +551,17 @@ fn upsert_provider_in_savepoint(
.or(identity_match)
.or(compatible_match),
};
let preserves_local_id = mode == ProviderUpsertMode::Imported
&& target.is_some_and(|candidate| {
candidate.source == MANUAL_PROVIDER_SOURCE
|| candidate.source == CCSWITCH_LOCAL_PROVIDER_SOURCE
});
let kind = if let Some(target) = target {
let existing = &target.provider;
let same_id = existing.id == requested_id;
provider.id = existing.id.clone();
if import_rehome_target.is_some() {
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
@@ -578,7 +583,13 @@ fn upsert_provider_in_savepoint(
.map_err(|e| CodexxError::Database(e.to_string()))?;
}
}
write_provider_with_origin(conn, &provider, origin)?;
let write_origin = match origin {
Some((CCSWITCH_PROVIDER_SOURCE, source_id)) if preserves_local_id => {
Some((CCSWITCH_LOCAL_PROVIDER_SOURCE, source_id))
}
_ => origin,
};
write_provider_with_origin(conn, &provider, write_origin)?;
for duplicate_id in conflicting_provider_ids(&stored, &provider, origin) {
conn.execute("DELETE FROM providers WHERE id = ?1", [&duplicate_id])
.map_err(|e| CodexxError::Database(e.to_string()))?;
@@ -674,12 +685,20 @@ pub(crate) fn consolidate_legacy_provider_duplicates_on_connection(
.collect::<Vec<_>>();
let mut merged = 0usize;
for mut group in duplicate_groups {
let preserves_local_profile = group.iter().any(|row| {
row.source == MANUAL_PROVIDER_SOURCE || row.source == CCSWITCH_LOCAL_PROVIDER_SOURCE
});
let mut origins = group
.iter()
.filter_map(|row| {
row.source_id
.as_ref()
.map(|source_id| (row.source.clone(), source_id.clone()))
row.source_id.as_ref().map(|source_id| {
let source = if row.source == CCSWITCH_LOCAL_PROVIDER_SOURCE {
CCSWITCH_PROVIDER_SOURCE.to_string()
} else {
row.source.clone()
};
(source, source_id.clone())
})
})
.collect::<Vec<_>>();
origins.sort();
@@ -688,8 +707,16 @@ pub(crate) fn consolidate_legacy_provider_duplicates_on_connection(
continue;
}
group.sort_by(|left, right| {
is_historical_custom_provider_id(&left.provider.id)
.cmp(&is_historical_custom_provider_id(&right.provider.id))
let left_is_local = left.source == MANUAL_PROVIDER_SOURCE
|| left.source == CCSWITCH_LOCAL_PROVIDER_SOURCE;
let right_is_local = right.source == MANUAL_PROVIDER_SOURCE
|| right.source == CCSWITCH_LOCAL_PROVIDER_SOURCE;
right_is_local
.cmp(&left_is_local)
.then_with(|| {
is_historical_custom_provider_id(&left.provider.id)
.cmp(&is_historical_custom_provider_id(&right.provider.id))
})
.then_with(|| right.updated_at.cmp(&left.updated_at))
.then_with(|| right.created_at.cmp(&left.created_at))
.then_with(|| left.provider.id.cmp(&right.provider.id))
@@ -737,9 +764,14 @@ pub(crate) fn consolidate_legacy_provider_duplicates_on_connection(
.map_err(|e| CodexxError::Database(e.to_string()))?;
merged += 1;
}
let origin = origins
.first()
.map(|(source, source_id)| (source.as_str(), source_id.as_str()));
let origin = origins.first().map(|(source, source_id)| {
let source = if preserves_local_profile && source == CCSWITCH_PROVIDER_SOURCE {
CCSWITCH_LOCAL_PROVIDER_SOURCE
} else {
source.as_str()
};
(source, source_id.as_str())
});
write_provider_with_origin(conn, &survivor.provider, origin)?;
}
@@ -1166,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();
@@ -1188,18 +1220,154 @@ 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\"")
);
let rows = stored_providers_on_connection(&conn).expect("read merged providers");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].source, CCSWITCH_PROVIDER_SOURCE);
assert_eq!(rows[0].source, CCSWITCH_LOCAL_PROVIDER_SOURCE);
assert_eq!(rows[0].source_id.as_deref(), Some("cc-row"));
}
#[test]
fn ccswitch_import_replaces_stale_local_values_with_complete_source() {
let conn = test_connection();
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://old.example.com/v1"
wire_api = "chat"
requires_openai_auth = true
request_max_retries = 3
"#
.to_string(),
);
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-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"
model = "remote-model"
service_tier = "priority"
[model_providers.custom]
name = "CC name"
base_url = "https://new.example.com/v1"
wire_api = "responses"
requires_openai_auth = false
request_max_retries = 7
[projects."/work/project"]
trust_level = "trusted"
[plugins."browser@openai-bundled"]
enabled = true
"#
.to_string(),
);
let result = upsert_ccswitch_provider_on_connection(&conn, imported, "cc-row")
.expect("replace stale local values from the authoritative cc-switch row");
assert_eq!(result.kind, ProviderUpsertKind::Updated);
assert_eq!(result.provider.id, "legacy-local");
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("complete cc-switch template");
let doc = template
.parse::<DocumentMut>()
.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("CC name")
);
assert_eq!(
doc["model_providers"]["custom"]["request_max_retries"].as_integer(),
Some(7)
);
assert_eq!(
doc["projects"]["/work/project"]["trust_level"].as_str(),
Some("trusted")
);
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();
@@ -1245,20 +1413,20 @@ mod tests {
}
#[test]
fn legacy_consolidation_transfers_one_external_source_to_the_latest_record() {
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();
write_provider_with_origin(&conn, &imported, Some((CCSWITCH_PROVIDER_SOURCE, "cc-row")))
.unwrap();
let mut manual = provider("manual-latest", "Edited locally", Some("sk-same"));
let mut manual = provider("manual-local", "Edited locally", Some("sk-same"));
manual.model = "latest-model".to_string();
manual.toml_config = Some("model = \"latest-model\"".to_string());
write_provider_on_connection(&conn, &manual).unwrap();
conn.execute(
"UPDATE providers SET updated_at = CASE id
WHEN 'imported-old' THEN '2026-01-01T00:00:00Z'
ELSE '2026-02-01T00:00:00Z' END",
WHEN 'imported-old' THEN '2026-02-01T00:00:00Z'
ELSE '2026-01-01T00:00:00Z' END",
[],
)
.unwrap();
@@ -1269,14 +1437,32 @@ mod tests {
);
let rows = stored_providers_on_connection(&conn).unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].provider.id, "manual-latest");
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.toml_config.as_deref(),
Some("model = \"latest-model\"")
);
assert_eq!(rows[0].source, CCSWITCH_PROVIDER_SOURCE);
assert_eq!(rows[0].source, CCSWITCH_LOCAL_PROVIDER_SOURCE);
assert_eq!(rows[0].source_id.as_deref(), Some("cc-row"));
let mut repeated = provider("imported-old", "Remote replacement", Some("sk-same"));
repeated.model = "remote-model".to_string();
repeated.toml_config = Some("model = \"remote-model\"".to_string());
upsert_ccswitch_provider_on_connection(&conn, repeated, "cc-row")
.expect("repeat import after legacy consolidation");
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, "Remote replacement");
assert_eq!(rows[0].provider.model, "remote-model");
assert_eq!(
rows[0].provider.toml_config.as_deref(),
Some("model = \"remote-model\"")
);
assert_eq!(rows[0].source, CCSWITCH_LOCAL_PROVIDER_SOURCE);
assert_eq!(rows[0].source_id.as_deref(), Some("cc-row"));
}
@@ -1330,6 +1516,7 @@ mod tests {
r#"model_provider = "proxy"
model = "stale-model"
approval_policy = "never"
experimental_bearer_token = "sk-top-level"
[model_providers.proxy]
name = "Stale Name"
@@ -1343,11 +1530,12 @@ request_max_retries = 7
X-Route = "keep-me"
[model_providers.unrelated]
name = "Do not store"
name = "Keep this provider"
base_url = "https://unrelated.example.com/v1"
experimental_bearer_token = "sk-unrelated"
[mcp_servers.docs]
command = "do-not-copy"
command = "keep-this-command"
"#
.to_string(),
);
@@ -1376,9 +1564,24 @@ command = "do-not-copy"
doc["model_providers"]["proxy"]["http_headers"]["X-Route"].as_str(),
Some("keep-me")
);
assert!(doc.get("approval_policy").is_none());
assert!(doc["model_providers"].get("unrelated").is_none());
assert!(doc.get("mcp_servers").is_none());
assert_eq!(doc["model_provider"].as_str(), Some("proxy"));
assert_eq!(doc["approval_policy"].as_str(), Some("never"));
assert_eq!(
doc["model_providers"]["unrelated"]["name"].as_str(),
Some("Keep this provider")
);
assert_eq!(
doc["mcp_servers"]["docs"]["command"].as_str(),
Some("keep-this-command")
);
assert!(doc.get("experimental_bearer_token").is_none());
assert!(doc["model_providers"]
.as_table()
.expect("model providers table")
.iter()
.all(|(_, item)| item
.as_table()
.is_none_or(|table| table.get("experimental_bearer_token").is_none())));
assert!(!text.contains("experimental_bearer_token"));
assert_eq!(normalized.api_key.as_deref(), Some("sk-explicit"));
}
+426 -39
View File
@@ -1,16 +1,25 @@
use super::build_skills_mcp_state_inner;
use super::types::{ManagedMcpServer, SkillsMcpState};
use crate::backups::create_backup;
use crate::ccswitch::default_ccswitch_db_path;
use crate::error::{CodexxError, Result};
use crate::file_io::{ensure_directory, parse_toml_document, read_to_string_if_exists, write_text};
use crate::file_io::{ensure_directory, parse_toml_document, read_to_string_if_exists};
use crate::live_config::{
acquire_live_config_lock, apply_file_change, fail_with_file_rollback, read_file_snapshot,
text_from_snapshot,
};
use crate::toml_utils::ensure_table;
use crate::{config_path, now_rfc3339, open_db, resolve_codex_dir};
use rusqlite::{params, Connection, OpenFlags};
use rusqlite::{
params, Connection, OpenFlags, OptionalExtension, Transaction, TransactionBehavior,
};
use serde_json::{json, Value};
use std::collections::HashSet;
use std::path::Path;
use toml_edit::{value, Item, Table};
type CcSwitchMcpCandidate = (String, String, Value, bool);
fn toml_value_to_json(value: &toml_edit::Value) -> Value {
if let Some(s) = value.as_str() {
return json!(s);
@@ -134,8 +143,13 @@ pub(super) fn mcp_summary(config: &Value) -> (String, Option<String>, Option<Str
(transport, command, url, summary)
}
pub(super) fn save_managed_mcp(id: &str, name: &str, config: &Value, enabled: bool) -> Result<()> {
let conn = open_db()?;
fn save_managed_mcp_on_connection(
conn: &Connection,
id: &str,
name: &str,
config: &Value,
enabled: bool,
) -> Result<()> {
conn.execute(
"INSERT INTO managed_mcp_servers (id, name, server_config, enabled, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5)
@@ -156,6 +170,83 @@ pub(super) fn save_managed_mcp(id: &str, name: &str, config: &Value, enabled: bo
Ok(())
}
pub(super) fn save_managed_mcp(id: &str, name: &str, config: &Value, enabled: bool) -> Result<()> {
let conn = open_db()?;
save_managed_mcp_on_connection(&conn, id, name, config, enabled)
}
fn managed_mcp_on_connection(conn: &Connection, id: &str) -> Result<Option<(String, Value, bool)>> {
conn.query_row(
"SELECT name, server_config, enabled FROM managed_mcp_servers WHERE id = ?1 LIMIT 1",
[id],
|row| {
let config_text: String = row.get(1)?;
Ok((
row.get(0)?,
serde_json::from_str(&config_text).unwrap_or(Value::Object(Default::default())),
row.get(2)?,
))
},
)
.optional()
.map_err(|error| CodexxError::Database(error.to_string()))
}
fn document_mcp_ids(doc: &toml_edit::DocumentMut) -> HashSet<String> {
doc.get("mcp_servers")
.and_then(|item| item.as_table())
.map(|table| {
table
.iter()
.filter(|(_, item)| item.is_table())
.map(|(id, _)| id.to_string())
.collect()
})
.unwrap_or_default()
}
fn document_bytes(snapshot: Option<&[u8]>, doc: &toml_edit::DocumentMut) -> Option<Vec<u8>> {
let bytes = doc.to_string().into_bytes();
if snapshot.is_none() && bytes.is_empty() {
None
} else {
Some(bytes)
}
}
fn commit_mcp_transaction_with_config<BeforeApply, BeforeCommit>(
transaction: Transaction<'_>,
codex_dir: &Path,
before: Option<Vec<u8>>,
after: Option<Vec<u8>>,
backup_action: &str,
before_apply: BeforeApply,
before_commit: BeforeCommit,
) -> Result<()>
where
BeforeApply: FnOnce(&Path) -> Result<()>,
BeforeCommit: FnOnce(&Transaction<'_>) -> Result<()>,
{
let cfg = config_path(codex_dir);
let mut changes = Vec::new();
if before != after {
create_backup(codex_dir, backup_action)?;
before_apply(&cfg)?;
changes.push(apply_file_change(&cfg, before, after)?);
}
if let Err(error) = before_commit(&transaction) {
return fail_with_file_rollback(error, &changes);
}
if let Err(error) = transaction
.commit()
.map_err(|error| CodexxError::Database(error.to_string()))
{
return fail_with_file_rollback(error, &changes);
}
Ok(())
}
pub(super) fn db_managed_mcp() -> Result<Vec<(String, String, Value, bool)>> {
let conn = open_db()?;
let mut stmt = conn.prepare("SELECT id, name, server_config, enabled FROM managed_mcp_servers ORDER BY name ASC, id ASC")
@@ -264,36 +355,76 @@ pub(super) fn import_ccswitch_mcp_servers_for_codex(
))
})
.map_err(|e| CodexxError::Database(e.to_string()))?;
let cfg = config_path(codex_dir);
let text = read_to_string_if_exists(&cfg)?;
let mut doc = parse_toml_document(&cfg, &text)?;
let live_enabled = list_mcp_from_config(codex_dir)?
.into_iter()
.map(|server| server.id)
.collect::<HashSet<_>>();
let mut imported = 0usize;
let mut changed_config = false;
let mut candidates = Vec::new();
for row in rows {
let (id, name, config_text, enabled_codex) =
row.map_err(|e| CodexxError::Database(e.to_string()))?;
let config: Value =
serde_json::from_str(&config_text).unwrap_or(Value::Object(Default::default()));
if !imported_ids.insert(id.clone()) {
continue;
}
let enabled = enabled_codex || live_enabled.contains(&id);
save_managed_mcp(&id, &name, &config, enabled)?;
if enabled_codex && !live_enabled.contains(&id) {
ensure_table(doc.as_table_mut(), "mcp_servers")?
.insert(&id, json_to_toml_item(&config));
changed_config = true;
}
imported += 1;
candidates.push((id, name, config, enabled_codex));
}
if changed_config {
write_text(&cfg, &doc.to_string())?;
import_ccswitch_mcp_candidates_with_hooks(
codex_dir,
imported_ids,
candidates,
|_| Ok(()),
|_| Ok(()),
)
}
fn import_ccswitch_mcp_candidates_with_hooks<BeforeApply, BeforeCommit>(
codex_dir: &Path,
imported_ids: &mut HashSet<String>,
candidates: Vec<CcSwitchMcpCandidate>,
before_apply: BeforeApply,
before_commit: BeforeCommit,
) -> Result<usize>
where
BeforeApply: FnOnce(&Path) -> Result<()>,
BeforeCommit: FnOnce(&Transaction<'_>) -> Result<()>,
{
let mut staged_ids = HashSet::new();
let candidates = candidates
.into_iter()
.filter(|(id, _, _, _)| !imported_ids.contains(id) && staged_ids.insert(id.clone()))
.collect::<Vec<_>>();
if candidates.is_empty() {
return Ok(0);
}
ensure_directory(codex_dir)?;
let _live_lock = acquire_live_config_lock(codex_dir)?;
let cfg = config_path(codex_dir);
let before = read_file_snapshot(&cfg)?;
let text = text_from_snapshot(&cfg, before.as_deref())?;
let mut doc = parse_toml_document(&cfg, &text)?;
let live_enabled = document_mcp_ids(&doc);
let mut app_conn = open_db()?;
let transaction = app_conn
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(|error| CodexxError::Database(error.to_string()))?;
for (id, name, config, enabled_codex) in &candidates {
let enabled = *enabled_codex || live_enabled.contains(id);
save_managed_mcp_on_connection(&transaction, id, name, config, enabled)?;
if *enabled_codex && !live_enabled.contains(id) {
ensure_table(doc.as_table_mut(), "mcp_servers")?.insert(id, json_to_toml_item(config));
}
}
let after = document_bytes(before.as_deref(), &doc);
commit_mcp_transaction_with_config(
transaction,
codex_dir,
before,
after,
"import-ccswitch-mcp",
before_apply,
before_commit,
)?;
let imported = candidates.len();
imported_ids.extend(staged_ids);
Ok(imported)
}
@@ -368,31 +499,287 @@ pub(crate) fn toggle_codex_mcp_inner(
id: String,
enabled: bool,
) -> Result<SkillsMcpState> {
toggle_codex_mcp_with_hooks(config_dir, id, enabled, |_| Ok(()), |_| Ok(()))
}
fn toggle_codex_mcp_with_hooks<BeforeApply, BeforeCommit>(
config_dir: Option<String>,
id: String,
enabled: bool,
before_apply: BeforeApply,
before_commit: BeforeCommit,
) -> Result<SkillsMcpState>
where
BeforeApply: FnOnce(&Path) -> Result<()>,
BeforeCommit: FnOnce(&Transaction<'_>) -> Result<()>,
{
let codex_dir = resolve_codex_dir(config_dir.clone())?;
ensure_directory(&codex_dir)?;
let _live_lock = acquire_live_config_lock(&codex_dir)?;
let cfg = config_path(&codex_dir);
let text = read_to_string_if_exists(&cfg)?;
let before = read_file_snapshot(&cfg)?;
let text = text_from_snapshot(&cfg, before.as_deref())?;
let mut doc = parse_toml_document(&cfg, &text)?;
let mut conn = open_db()?;
let transaction = conn
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(|error| CodexxError::Database(error.to_string()))?;
let stored = managed_mcp_on_connection(&transaction, &id)?;
if enabled {
let db = db_managed_mcp()?
.into_iter()
.find(|(sid, _, _, _)| sid == &id)
.ok_or_else(|| CodexxError::Config(format!("未找到 MCP: {id}")))?;
ensure_table(doc.as_table_mut(), "mcp_servers")?.insert(&id, json_to_toml_item(&db.2));
save_managed_mcp(&id, &db.1, &db.2, true)?;
let (name, config, _) =
stored.ok_or_else(|| CodexxError::Config(format!("未找到 MCP: {id}")))?;
ensure_table(doc.as_table_mut(), "mcp_servers")?.insert(&id, json_to_toml_item(&config));
save_managed_mcp_on_connection(&transaction, &id, &name, &config, true)?;
} else {
if let Some(item) = doc
let live_config = doc
.get("mcp_servers")
.and_then(|m| m.as_table())
.and_then(|tbl| tbl.get(&id))
{
let config = toml_item_to_json(item);
save_managed_mcp(&id, &id, &config, false)?;
.map(toml_item_to_json);
if let Some(config) = live_config {
let name = stored
.as_ref()
.map(|(name, _, _)| name.as_str())
.unwrap_or(&id);
save_managed_mcp_on_connection(&transaction, &id, name, &config, false)?;
} else if let Some((name, config, _)) = stored {
save_managed_mcp_on_connection(&transaction, &id, &name, &config, false)?;
}
if let Some(tbl) = doc.get_mut("mcp_servers").and_then(|m| m.as_table_mut()) {
tbl.remove(&id);
}
}
write_text(&cfg, &doc.to_string())?;
let after = document_bytes(before.as_deref(), &doc);
commit_mcp_transaction_with_config(
transaction,
&codex_dir,
before,
after,
"toggle-mcp",
before_apply,
before_commit,
)?;
build_skills_mcp_state_inner(config_dir)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
fn test_case(name: &str) -> (PathBuf, String) {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let suffix = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"codex-x-mcp-{name}-{}-{suffix}",
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("create MCP test directory");
(dir, format!("test-mcp-{name}-{suffix}"))
}
fn database_error(error: rusqlite::Error) -> CodexxError {
CodexxError::Database(error.to_string())
}
fn remove_test_mcp(id: &str) {
open_db()
.expect("open test database")
.execute("DELETE FROM managed_mcp_servers WHERE id = ?1", [id])
.expect("remove test MCP");
}
#[test]
fn toggle_updates_only_the_target_mcp_and_keeps_database_in_sync() {
let _db_guard = crate::app_db::test_db_guard();
let (codex_dir, id) = test_case("success");
let cfg = config_path(&codex_dir);
let original = b"# keep this comment\nmodel = \"gpt-5.5\"\n\n[features]\njs_repl = false\n\n[mcp_servers.existing]\ncommand = \"existing\"\n";
fs::write(&cfg, original).expect("seed original config");
save_managed_mcp(&id, "Managed", &json!({ "command": "managed" }), false)
.expect("seed managed MCP");
let enabled_state =
toggle_codex_mcp_inner(Some(codex_dir.display().to_string()), id.clone(), true)
.expect("enable managed MCP");
assert!(enabled_state
.mcp_servers
.iter()
.any(|server| server.id == id && server.enabled));
let enabled_text = fs::read_to_string(&cfg).expect("read enabled config");
let enabled_doc = enabled_text
.parse::<toml_edit::DocumentMut>()
.expect("parse enabled config");
assert!(enabled_text.contains("# keep this comment"));
assert_eq!(enabled_doc["features"]["js_repl"].as_bool(), Some(false));
assert_eq!(
enabled_doc["mcp_servers"]["existing"]["command"].as_str(),
Some("existing")
);
assert_eq!(
enabled_doc["mcp_servers"][&id]["command"].as_str(),
Some("managed")
);
let conn = open_db().expect("open test database");
assert!(
managed_mcp_on_connection(&conn, &id)
.expect("read enabled MCP")
.expect("enabled MCP exists")
.2
);
drop(conn);
toggle_codex_mcp_inner(Some(codex_dir.display().to_string()), id.clone(), false)
.expect("disable managed MCP");
let disabled_text = fs::read_to_string(&cfg).expect("read disabled config");
let disabled_doc = disabled_text
.parse::<toml_edit::DocumentMut>()
.expect("parse disabled config");
assert!(disabled_text.contains("# keep this comment"));
assert!(disabled_doc["mcp_servers"].get(&id).is_none());
assert_eq!(
disabled_doc["mcp_servers"]["existing"]["command"].as_str(),
Some("existing")
);
let conn = open_db().expect("open test database");
let (name, config, enabled) = managed_mcp_on_connection(&conn, &id)
.expect("read disabled MCP")
.expect("disabled MCP exists");
assert_eq!(name, "Managed");
assert_eq!(config["command"].as_str(), Some("managed"));
assert!(!enabled);
drop(conn);
assert!(codex_dir.join(".codexx-test-backups").is_dir());
remove_test_mcp(&id);
fs::remove_dir_all(codex_dir).expect("remove MCP test directory");
}
#[test]
fn toggle_rejects_a_stale_snapshot_without_changing_database_or_external_config() {
let _db_guard = crate::app_db::test_db_guard();
let (codex_dir, id) = test_case("stale");
let cfg = config_path(&codex_dir);
let original = b"# original\nmodel = \"gpt-5.5\"\n";
let external = b"# external update\nmodel = \"gpt-5.5\"\n\n[mcp_servers.external]\ncommand = \"external\"\n";
fs::write(&cfg, original).expect("seed original config");
save_managed_mcp(&id, "Managed", &json!({ "command": "managed" }), false)
.expect("seed managed MCP");
let error = toggle_codex_mcp_with_hooks(
Some(codex_dir.display().to_string()),
id.clone(),
true,
|path| {
fs::write(path, external).map_err(|error| CodexxError::Config(error.to_string()))
},
|_| Ok(()),
)
.expect_err("stale MCP toggle must fail");
assert!(error.to_string().contains("已被其他程序修改"));
assert_eq!(fs::read(&cfg).expect("read external config"), external);
let conn = open_db().expect("open test database");
let (_, config, enabled) = managed_mcp_on_connection(&conn, &id)
.expect("read managed MCP")
.expect("managed MCP still exists");
assert_eq!(config["command"].as_str(), Some("managed"));
assert!(!enabled);
drop(conn);
remove_test_mcp(&id);
fs::remove_dir_all(codex_dir).expect("remove MCP test directory");
}
#[test]
fn toggle_rolls_back_config_when_database_commit_fails() {
let _db_guard = crate::app_db::test_db_guard();
let (codex_dir, id) = test_case("commit");
let cfg = config_path(&codex_dir);
let original = b"# keep exact bytes\nmodel = \"gpt-5.5\"\n\n[features]\njs_repl = false\n";
fs::write(&cfg, original).expect("seed original config");
save_managed_mcp(&id, "Managed", &json!({ "command": "managed" }), false)
.expect("seed managed MCP");
toggle_codex_mcp_with_hooks(
Some(codex_dir.display().to_string()),
id.clone(),
true,
|_| Ok(()),
|transaction| {
transaction
.execute_batch("ROLLBACK")
.map_err(database_error)
},
)
.expect_err("database commit failure must fail the toggle");
assert_eq!(fs::read(&cfg).expect("read rolled-back config"), original);
let conn = open_db().expect("open test database");
let (_, _, enabled) = managed_mcp_on_connection(&conn, &id)
.expect("read managed MCP")
.expect("managed MCP still exists");
assert!(!enabled);
drop(conn);
assert!(codex_dir.join(".codexx-test-backups").is_dir());
remove_test_mcp(&id);
fs::remove_dir_all(codex_dir).expect("remove MCP test directory");
}
#[test]
fn ccswitch_import_rolls_back_file_database_and_ids_when_commit_fails() {
let _db_guard = crate::app_db::test_db_guard();
let (codex_dir, id) = test_case("import-commit");
let second_id = format!("{id}-second");
let cfg = config_path(&codex_dir);
let original = b"# import baseline\nmodel = \"gpt-5.5\"\n";
fs::write(&cfg, original).expect("seed original config");
let mut imported_ids = HashSet::new();
import_ccswitch_mcp_candidates_with_hooks(
&codex_dir,
&mut imported_ids,
vec![
(
id.clone(),
"Imported".to_string(),
json!({ "command": "imported" }),
true,
),
(
second_id.clone(),
"Imported second".to_string(),
json!({ "command": "imported-second" }),
true,
),
],
|_| Ok(()),
|transaction| {
transaction
.execute_batch("ROLLBACK")
.map_err(database_error)
},
)
.expect_err("database commit failure must fail the import");
assert_eq!(fs::read(&cfg).expect("read rolled-back config"), original);
assert!(imported_ids.is_empty());
let conn = open_db().expect("open test database");
assert!(managed_mcp_on_connection(&conn, &id)
.expect("read imported MCP")
.is_none());
assert!(managed_mcp_on_connection(&conn, &second_id)
.expect("read second imported MCP")
.is_none());
drop(conn);
assert!(codex_dir.join(".codexx-test-backups").is_dir());
fs::remove_dir_all(codex_dir).expect("remove MCP test directory");
}
}
+14 -9
View File
@@ -1,10 +1,8 @@
use crate::backups::{latest_backup, BackupEntry};
use crate::backups::BackupEntry;
#[cfg(test)]
use crate::config_migration::migrate_legacy_prompt_config;
use crate::error::Result;
use crate::file_io::{
harden_sensitive_file_permissions, io_err, json_err, parse_toml_document,
read_to_string_if_exists,
};
use crate::file_io::{io_err, json_err, parse_toml_document, read_to_string_if_exists};
use crate::prompts::{
agents_path, managed_agents_template_key, prompt_template_key_for_instruction,
};
@@ -69,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();
@@ -175,6 +179,7 @@ pub(crate) fn active_saved_provider_id_from_config(
(matches.len() == 1).then(|| matches[0].id.clone())
}
#[cfg(test)]
pub(crate) fn build_state(codex_dir: PathBuf) -> Result<CodexState> {
migrate_legacy_prompt_config(&codex_dir)?;
build_state_after_migration(codex_dir)
@@ -183,8 +188,6 @@ pub(crate) fn build_state(codex_dir: PathBuf) -> Result<CodexState> {
pub(crate) fn build_state_after_migration(codex_dir: PathBuf) -> Result<CodexState> {
let cfg = config_path(&codex_dir);
let auth = auth_path(&codex_dir);
harden_sensitive_file_permissions(&cfg)?;
harden_sensitive_file_permissions(&auth)?;
let text = read_to_string_if_exists(&cfg)?;
let doc = parse_toml_document(&cfg, &text)?;
let model = string_value(&doc, "model");
@@ -236,6 +239,8 @@ pub(crate) fn build_state_after_migration(codex_dir: PathBuf) -> Result<CodexSta
config_text: text,
auth_preview: redacted_auth_preview(&auth)?,
auth_text: read_to_string_if_exists(&auth)?,
last_backup: latest_backup()?,
// Backup discovery can traverse years of history. Keep core state fast;
// restore actions resolve history only when the user requests it.
last_backup: None,
})
}
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.8",
"version": "0.3.11",
"identifier": "com.yynxxxxx.codexx",
"build": {
"frontendDist": "../dist",
@@ -366,6 +366,7 @@ export function StartupWizardDialog({
value={configDir || diagnostics.codexDir}
onChange={(event) => onConfigDirChange(event.target.value)}
placeholder="~/.codex"
disabled={loading}
spellCheck={false}
/>
<Button
+476 -146
View File
File diff suppressed because it is too large Load Diff
+39 -19
View File
@@ -1,9 +1,11 @@
import {
ArrowUpRight,
CheckCircle2,
CircleAlert,
Code2,
FileText,
KeyRound,
Loader2,
RefreshCw,
Sparkles,
} from "lucide-react";
@@ -14,6 +16,7 @@ export type OverviewLanguage = "zh" | "en";
export type OverviewPageProps = {
lang: OverviewLanguage;
ready: boolean;
model?: string | null;
configDir: string;
resolvedCodexDir: string;
@@ -70,6 +73,7 @@ function ConfigRow({ label, value }: ConfigRowProps) {
export function OverviewPage({
lang,
ready,
model,
configDir,
resolvedCodexDir,
@@ -121,6 +125,8 @@ export function OverviewPage({
model: "模型",
providerName: "供应商标识",
instructionFile: "指令文件",
reading: "正在读取",
readFailed: "读取失败",
}
: {
eyebrow: "CODEX CONFIG MANAGER",
@@ -153,20 +159,29 @@ export function OverviewPage({
model: "Model",
providerName: "Provider",
instructionFile: "Instruction file",
reading: "Loading",
readFailed: "Load failed",
};
const displayModel = model?.trim() || text.modelMissing;
const displayProvider = providerLabel?.trim() || modelProvider?.trim() || text.official;
const displayModelProvider = modelProvider?.trim() || text.notConfigured;
const displayDirectory = resolvedCodexDir.trim() || configDir.trim() || text.notConfigured;
const displayConfigPath = configPath?.trim() || text.notConfigured;
const displayInstructionPath = instructionPath?.trim() || text.notConfigured;
const unresolvedStatus = loading ? text.reading : text.readFailed;
const displayModel = ready ? (model?.trim() || text.modelMissing) : unresolvedStatus;
const displayProvider = ready
? (providerLabel?.trim() || modelProvider?.trim() || text.official)
: unresolvedStatus;
const displayModelProvider = ready ? (modelProvider?.trim() || text.notConfigured) : unresolvedStatus;
const displayDirectory = ready
? (resolvedCodexDir.trim() || configDir.trim() || text.notConfigured)
: (configDir.trim() || unresolvedStatus);
const displayConfigPath = ready ? (configPath?.trim() || text.notConfigured) : unresolvedStatus;
const displayInstructionPath = ready ? (instructionPath?.trim() || text.notConfigured) : unresolvedStatus;
const updateVersion = latestVersion?.trim() || "";
const homeInputValue = configDir || resolvedCodexDir;
const authAvailable = authExists || officialAuthAvailable;
const authStatus = authExists
? text.authFile
: officialAuthAvailable ? text.officialAuth : text.noAuth;
const homeInputValue = configDir;
const authAvailable = ready && (authExists || officialAuthAvailable);
const authStatus = ready
? authExists
? text.authFile
: officialAuthAvailable ? text.officialAuth : text.noAuth
: unresolvedStatus;
return (
<section className="cx-overview-page" aria-label={isChinese ? "概览" : "Overview"}>
@@ -187,6 +202,7 @@ export function OverviewPage({
value={homeInputValue}
onChange={(event) => onConfigDirChange(event.target.value)}
placeholder={text.directoryPlaceholder}
disabled={loading}
spellCheck={false}
aria-label={text.codexHome}
/>
@@ -217,20 +233,20 @@ export function OverviewPage({
<StatusCard
icon={FileText}
label={text.config}
value={configExists ? text.found : text.missing}
tone={configExists ? "success" : "muted"}
value={ready ? (configExists ? text.found : text.missing) : unresolvedStatus}
tone={ready && configExists ? "success" : "muted"}
/>
<StatusCard
icon={Code2}
label={text.provider}
value={displayProvider}
tone={modelProvider ? "active" : "muted"}
tone={ready && modelProvider ? "active" : "muted"}
/>
<StatusCard
icon={Sparkles}
label={text.instruction}
value={instructionEnabled ? text.enabled : text.disabled}
tone={instructionEnabled ? "success" : "muted"}
value={ready ? (instructionEnabled ? text.enabled : text.disabled) : unresolvedStatus}
tone={ready && instructionEnabled ? "success" : "muted"}
/>
<StatusCard
icon={KeyRound}
@@ -246,9 +262,13 @@ export function OverviewPage({
<p className="cx-overview-section-label">{text.liveStatus}</p>
<h3>{text.currentConfig}</h3>
</div>
<span className={`cx-overview-instruction-pill${instructionEnabled ? " cx-overview-instruction-pill--active" : ""}`}>
<CheckCircle2 size={14} strokeWidth={2} aria-hidden="true" />
{instructionEnabled ? text.on : text.off}
<span className={`cx-overview-instruction-pill${ready && instructionEnabled ? " cx-overview-instruction-pill--active" : ""}`}>
{!ready && loading
? <Loader2 className="cx-overview-spin" size={14} strokeWidth={2} aria-hidden="true" />
: !ready
? <CircleAlert size={14} strokeWidth={2} aria-hidden="true" />
: <CheckCircle2 size={14} strokeWidth={2} aria-hidden="true" />}
{ready ? (instructionEnabled ? text.on : text.off) : unresolvedStatus}
</span>
</div>
+49 -23
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;
@@ -239,6 +243,7 @@ function ListPage({
}: Pick<ProvidersPageProps, "copy" | "providerRows" | "loading" | "testingId" | "actionBusy" | "onImportCcSwitch" | "onAddProvider" | "onRestoreOfficial" | "onEnableProvider" | "onTestProvider" | "onEditProvider" | "onDeleteProvider">) {
const [providerToDelete, setProviderToDelete] = useState<ProviderRow | null>(null);
const [deleting, setDeleting] = useState(false);
const providerActionsBusy = loading || Boolean(actionBusy);
const closeDeleteDialog = () => {
if (!deleting) setProviderToDelete(null);
@@ -267,12 +272,12 @@ function ListPage({
type="button"
className="cx-providers-button cx-providers-button--secondary"
onClick={onImportCcSwitch}
disabled={loading || Boolean(actionBusy)}
disabled={providerActionsBusy}
>
{actionBusy === "importCcSwitch" ? <Loader2 size={15} className="cx-providers-spin" aria-hidden="true" /> : <RefreshCw size={15} aria-hidden="true" />}
{copy.importLabel}
</button>
<button type="button" className="cx-providers-button cx-providers-button--dark" onClick={onAddProvider} disabled={loading}>
<button type="button" className="cx-providers-button cx-providers-button--dark" onClick={onAddProvider} disabled={providerActionsBusy}>
<Plus size={15} aria-hidden="true" />
{copy.addLabel}
</button>
@@ -306,7 +311,7 @@ function ListPage({
type="button"
className="cx-providers-button cx-providers-button--small cx-providers-button--secondary"
onClick={() => onEnableProvider(row)}
disabled={loading || row.isCurrent}
disabled={providerActionsBusy || row.isCurrent}
>
{copy.enableLabel}
</button>
@@ -315,7 +320,7 @@ function ListPage({
icon={RotateCcw}
label={copy.restoreOfficialLabel}
onClick={onRestoreOfficial}
disabled={loading}
disabled={providerActionsBusy}
/>
)}
{row.testable !== false && (
@@ -323,14 +328,14 @@ function ListPage({
icon={isTesting ? Loader2 : Activity}
label={copy.testLabel}
onClick={() => onTestProvider(row)}
disabled={loading || isTesting}
disabled={providerActionsBusy || isTesting}
/>
)}
{row.editable !== false && (
<ActionIconButton icon={PencilLine} label={copy.editLabel} onClick={() => onEditProvider(row)} disabled={loading} />
<ActionIconButton icon={PencilLine} label={copy.editLabel} onClick={() => onEditProvider(row)} disabled={providerActionsBusy} />
)}
{row.deletable && (
<ActionIconButton icon={Trash2} label={copy.removeLabel} onClick={() => setProviderToDelete(row)} disabled={loading} danger />
<ActionIconButton icon={Trash2} label={copy.removeLabel} onClick={() => setProviderToDelete(row)} disabled={providerActionsBusy} danger />
)}
</div>
</article>
@@ -374,7 +379,7 @@ function ListPage({
);
}
function ModeHeader({ eyebrow, title, description, cancelLabel, onCancel }: { eyebrow: string; title: string; description: string; cancelLabel: string; onCancel: () => void }) {
function ModeHeader({ eyebrow, title, description, cancelLabel, onCancel, disabled = false }: { eyebrow: string; title: string; description: string; cancelLabel: string; onCancel: () => void; disabled?: boolean }) {
return (
<header className="cx-providers-form-header">
<div>
@@ -382,7 +387,7 @@ function ModeHeader({ eyebrow, title, description, cancelLabel, onCancel }: { ey
<h2>{title}</h2>
<p>{description}</p>
</div>
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={onCancel}>
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={onCancel} disabled={disabled}>
<ArrowLeft size={15} aria-hidden="true" />
{cancelLabel}
</button>
@@ -394,16 +399,20 @@ function OfficialForm({
copy,
officialForm,
officialAuthRef,
officialTomlRef,
officialInfo,
loading,
actionBusy,
onCancelMode,
onOfficialModelChange,
onOfficialAuthChange,
onOfficialConfigChange,
onSaveOfficial,
onRestoreOfficial,
onResetOfficial,
}: Pick<ProvidersPageProps, "copy" | "officialForm" | "officialAuthRef" | "officialInfo" | "loading" | "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";
const confirmReset = () => {
setResetConfirmOpen(false);
@@ -412,33 +421,44 @@ function OfficialForm({
return (
<>
<ModeHeader eyebrow={copy.officialEyebrow} title={copy.officialTitle} description={copy.officialHint} cancelLabel={copy.cancelLabel} onCancel={onCancelMode} />
<ModeHeader eyebrow={copy.officialEyebrow} title={copy.officialTitle} description={copy.officialHint} cancelLabel={copy.cancelLabel} onCancel={onCancelMode} disabled={formBusy} />
<div className="cx-providers-info-grid">
<div><span>{copy.officialUrlLabel}</span><code>{officialInfo.officialUrl}</code></div>
<div><span>{copy.authPathLabel}</span><code>{officialInfo.authPath}</code></div>
<div><span>{copy.officialCurrentLabel}</span><code>{officialInfo.current}</code></div>
</div>
<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)} /></Field>
<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}
className="cx-providers-code-editor cx-providers-auth-editor"
value={officialForm.authJson}
onChange={(event) => onOfficialAuthChange(event.target.value)}
disabled={formBusy}
wrap="soft"
spellCheck={false}
/>
</Field>
<div className="cx-providers-form-actions cx-providers-form-actions--save cx-providers-official-actions">
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={onRestoreOfficial} disabled={loading}>
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={onRestoreOfficial} disabled={formBusy}>
<RotateCcw size={15} aria-hidden="true" />{copy.restoreOfficialLabel}
</button>
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={() => setResetConfirmOpen(true)} disabled={loading}>
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={() => setResetConfirmOpen(true)} disabled={formBusy}>
<FilePlus2 size={15} aria-hidden="true" />{copy.resetOfficialLabel}
</button>
<button type="button" className="cx-providers-button cx-providers-button--primary" onClick={onSaveOfficial} disabled={loading}><CheckCircle2 size={15} aria-hidden="true" />{copy.officialSaveLabel}</button>
<button type="button" className="cx-providers-button cx-providers-button--primary" onClick={onSaveOfficial} disabled={formBusy}><CheckCircle2 size={15} aria-hidden="true" />{copy.officialSaveLabel}</button>
</div>
<ModalShell
open={resetConfirmOpen}
@@ -456,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>
</>
@@ -489,6 +509,7 @@ function ProviderForm({
}: Pick<ProvidersPageProps, "copy" | "providerForm" | "loading" | "editingProviderId" | "providerAuthPreview" | "providerTomlDraft" | "providerTomlRef" | "apiKeyVisible" | "availableModels" | "fetchingModels" | "onCancelMode" | "onApiKeyChange" | "onBaseUrlChange" | "onProviderNameChange" | "onProviderModelChange" | "onFetchModels" | "onWireApiChange" | "onRequiresAuthChange" | "onToggleApiKeyVisibility" | "onProviderTomlDraftChange" | "onResetProviderToml" | "onSaveProvider">) {
const modelListId = useId();
const canFetchModels = Boolean(providerForm.baseUrl.trim() && providerForm.apiKey.trim());
const formBusy = loading || fetchingModels;
return (
<>
@@ -498,6 +519,7 @@ function ProviderForm({
description={copy.formHint}
cancelLabel={copy.cancelLabel}
onCancel={onCancelMode}
disabled={formBusy}
/>
<section className="cx-providers-form-section">
@@ -512,14 +534,15 @@ function ProviderForm({
value={providerForm.apiKey}
onChange={(event) => onApiKeyChange(event.target.value)}
placeholder={copy.apiKeyPlaceholder}
disabled={formBusy}
/>
<button type="button" onClick={onToggleApiKeyVisibility} title={apiKeyVisible ? copy.hideApiKeyLabel : copy.showApiKeyLabel} aria-label={apiKeyVisible ? copy.hideApiKeyLabel : copy.showApiKeyLabel}>
<button type="button" onClick={onToggleApiKeyVisibility} title={apiKeyVisible ? copy.hideApiKeyLabel : copy.showApiKeyLabel} aria-label={apiKeyVisible ? copy.hideApiKeyLabel : copy.showApiKeyLabel} disabled={formBusy}>
{apiKeyVisible ? <EyeOff size={15} aria-hidden="true" /> : <Eye size={15} aria-hidden="true" />}
</button>
</div>
</Field>
<Field label={copy.baseUrlLabel} className="cx-providers-field--full"><input value={providerForm.baseUrl} onChange={(event) => onBaseUrlChange(event.target.value)} /></Field>
<Field label={copy.nameLabel}><input value={providerForm.providerName} onChange={(event) => onProviderNameChange(event.target.value)} /></Field>
<Field label={copy.baseUrlLabel} className="cx-providers-field--full"><input value={providerForm.baseUrl} onChange={(event) => onBaseUrlChange(event.target.value)} disabled={formBusy} /></Field>
<Field label={copy.nameLabel}><input value={providerForm.providerName} onChange={(event) => onProviderNameChange(event.target.value)} disabled={formBusy} /></Field>
<Field label={copy.modelLabel}>
<div className="cx-providers-model-input-row">
<input
@@ -527,6 +550,7 @@ function ProviderForm({
list={availableModels.length ? modelListId : undefined}
aria-label={copy.modelLabel}
onChange={(event) => onProviderModelChange(event.target.value)}
disabled={formBusy}
/>
<button
type="button"
@@ -551,6 +575,7 @@ function ProviderForm({
className="cx-providers-model-select"
value=""
aria-label={copy.chooseModelLabel(availableModels.length)}
disabled={formBusy}
onChange={(event) => {
if (event.target.value) onProviderModelChange(event.target.value);
}}
@@ -562,7 +587,7 @@ function ProviderForm({
)}
</Field>
<Field label={copy.wireApiLabel}>
<select value={providerForm.wireApi} onChange={(event) => onWireApiChange(event.target.value)}>
<select value={providerForm.wireApi} onChange={(event) => onWireApiChange(event.target.value)} disabled={formBusy}>
<option value="responses">responses</option>
<option value="chat">chat</option>
</select>
@@ -572,6 +597,7 @@ function ProviderForm({
checked={providerForm.requiresOpenaiAuth}
onCheckedChange={onRequiresAuthChange}
label={copy.requiresAuthLabel}
disabled={formBusy}
/>
</div>
</section>
@@ -584,13 +610,13 @@ function ProviderForm({
<section className="cx-providers-form-section">
<div className="cx-providers-section-heading cx-providers-section-heading--with-action">
<div><h3>{copy.tomlTitle}</h3><p>{copy.tomlDescription}</p></div>
<button type="button" className="cx-providers-button cx-providers-button--secondary cx-providers-button--small" onClick={onResetProviderToml}><RefreshCw size={14} aria-hidden="true" />{copy.resetTomlLabel}</button>
<button type="button" className="cx-providers-button cx-providers-button--secondary cx-providers-button--small" onClick={onResetProviderToml} disabled={formBusy}><RefreshCw size={14} aria-hidden="true" />{copy.resetTomlLabel}</button>
</div>
<textarea ref={providerTomlRef} className="cx-providers-code-editor cx-providers-toml-editor" value={providerTomlDraft} onChange={(event) => onProviderTomlDraftChange(event.target.value)} spellCheck={false} />
<textarea ref={providerTomlRef} className="cx-providers-code-editor cx-providers-toml-editor" value={providerTomlDraft} onChange={(event) => onProviderTomlDraftChange(event.target.value)} disabled={formBusy} spellCheck={false} />
</section>
<div className="cx-providers-form-actions cx-providers-form-actions--save">
<button type="button" className="cx-providers-button cx-providers-button--primary" onClick={onSaveProvider} disabled={loading}>
<button type="button" className="cx-providers-button cx-providers-button--primary" onClick={onSaveProvider} disabled={formBusy}>
{loading ? <Loader2 size={15} className="cx-providers-spin" aria-hidden="true" /> : <CheckCircle2 size={15} aria-hidden="true" />}
{loading ? copy.savingLabel : copy.saveLabel}
</button>
+20
View File
@@ -426,6 +426,26 @@
transform-origin: center top;
}
.cx-state-loading {
min-width: 0;
min-height: 280px;
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
color: var(--ui-text-muted);
font-size: 13px;
font-weight: 620;
line-height: 1.45;
text-align: center;
}
.cx-state-loading svg {
flex: 0 0 auto;
color: var(--ui-accent);
}
.cx-page-transition--exit,
.cx-page-transition--enter {
will-change: opacity, transform;
@@ -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;
};
+31 -2
View File
@@ -8,6 +8,34 @@
日志不得包含 Token、认证文件内容、用户数据或仅适用于某台电脑的隐私路径。
## 2026-08-11:供应商认证往返、模板完整性与启动读取
### 决策
- 新建和 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 `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 配置并发与失败回滚
### 已确认根因
@@ -84,8 +112,9 @@ Tauri 事件循环随最后一个窗口关闭而结束,后台管理能力也
### 约束
- 供应商记录只拥有供应商相关字段,不拥有整份用户配置。
- 激活供应商必须基于最新 live 文档合并目标 provider 表,不回放历史整文件快照。
- 供应商记录可以保存完整 TOML,作为导入与编辑模板;其中的密钥必须剥离并由独立字段保存。
- 激活供应商必须基于最新 live 文档,仅合并目标 provider 与 model 字段,不回放模板中的
项目、插件、MCP 或其他通用设置。
- 官方认证快照与中转认证严格隔离,供应商切换不能覆盖可信官方认证。
- 自动捕获官方认证只接受明确的 ChatGPT 登录模式及 access/refresh/id token;代理路由下的
API key 属于不可信来源,不能晋升为官方快照。
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "codex-x-workspace",
"private": true,
"version": "0.3.8",
"version": "0.3.11",
"scripts": {
"dev": "pnpm --dir apps/desktop tauri dev",
"build": "pnpm --dir apps/desktop tauri build",