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