Compare commits

...
8 Commits
Author SHA1 Message Date
yyyyyynnn e4822237fb ci: serialize Windows release tests 2026-08-20 13:41:09 +08:00
yyyyyynnn 5d18b2555e test: isolate duplicate provider fixture 2026-08-20 13:39:23 +08:00
yyyyyynnn 2ace729801 release: prepare v0.3.14 2026-08-20 13:23:11 +08:00
yyyyyynnn 488f27e095 release: prepare v0.3.13 2026-08-18 18:31:46 +08:00
yyyyyynnn 826a142fc0 release: prepare v0.3.12 2026-08-11 20:11:59 +08:00
yyyyyynnn d1b40285dd release: prepare v0.3.11 2026-08-11 18:18:38 +08:00
yyyyyynnn 3ac28c3193 release: prepare v0.3.10 2026-08-11 17:18:28 +08:00
yyyyyynnn 39dc8fc535 release: prepare v0.3.9 2026-08-11 15:46:25 +08:00
49 changed files with 9316 additions and 1542 deletions
+1 -1
View File
@@ -223,7 +223,7 @@ jobs:
- name: Test Windows filesystem compatibility
if: matrix.platform == 'windows-latest'
run: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --lib --locked
run: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --lib --locked -- --test-threads=1
- name: Import Apple signing certificate and notarization key
if: matrix.macos
+44
View File
@@ -4,6 +4,50 @@ All notable changes to Codex-X will be documented here.
## [Unreleased]
## [v0.3.14] - 2026-08-20
### 更新
- MCP 和 Skills 支持添加自定义备注。
- 供应商支持复制,同一 API 平台可保存多个模型或相同配置,复制项会添加到列表末尾。
- 供应商列表不再显示重复的模型信息。
- 设置页新增“重启 Codex”按钮,支持 macOS 和 Windows。
### 修复 Bug
- 修复相同供应商配置被覆盖,以及复制当前供应商后启用状态丢失的问题。
- 修复子代理会话被误判为“待同步”的问题。
## [v0.3.13] - 2026-08-18
### 修复 Bug
- 修复归档会话被错误标记为“待同步”并参与供应商同步的问题;归档会话现在会保持归档状态,不再作为同步候选。
## [v0.3.12] - 2026-08-11
### 更新
- 优化了软件的流畅度和部分 bug。
## [v0.3.11] - 2026-08-11
### 更新
- 优化了软件的流畅度和部分 bug。
## [v0.3.10] - 2026-08-11
### 更新
- 优化了软件的流畅度和部分 bug。
## [v0.3.9] - 2026-08-11
### 更新
- 优化了软件的流畅度和部分 bug。
## [v0.3.8] - 2026-08-03
### 修复 Bug
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "codex-x",
"version": "0.3.8",
"version": "0.3.14",
"private": true,
"description": "Codex Switch & Instruct desktop manager",
"type": "module",
+1 -1
View File
@@ -490,7 +490,7 @@ dependencies = [
[[package]]
name = "codex-x"
version = "0.3.8"
version = "0.3.14"
dependencies = [
"chrono",
"dirs 5.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "codex-x"
version = "0.3.8"
version = "0.3.14"
description = "Codex Switch & Instruct desktop manager"
authors = ["yynxxxxx"]
license = "MIT"
+358 -13
View File
@@ -3,9 +3,101 @@ use crate::file_io::ensure_directory;
use crate::paths::app_home;
use crate::sqlite_utils::table_column_set;
use rusqlite::Connection;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
const APP_DB_SCHEMA_VERSION: i64 = 3;
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,
@@ -108,23 +193,36 @@ pub(crate) fn open() -> Result<Connection> {
content_hash TEXT,
enabled INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS skills_mcp_notes (
codex_dir TEXT NOT NULL,
item_kind TEXT NOT NULL CHECK(item_kind IN ('skill', 'mcp')),
item_id TEXT NOT NULL,
note TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY(codex_dir, item_kind, item_id)
);
CREATE TABLE IF NOT EXISTS active_provider_selections (
codex_dir TEXT PRIMARY KEY,
provider_id TEXT NOT NULL,
updated_at TEXT NOT NULL
);",
)
.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 +268,252 @@ 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"),
APP_DB_SCHEMA_VERSION
);
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"),
APP_DB_SCHEMA_VERSION
);
drop(conn);
remove_test_db(&path);
}
#[test]
fn version_one_database_migrates_new_metadata_tables_without_data_loss() {
let path = test_db_path("skills-mcp-notes-migration");
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create legacy database directory");
}
let legacy = Connection::open(&path).expect("create legacy database");
legacy
.execute_batch(
"CREATE TABLE managed_skills (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
directory TEXT NOT NULL,
source_path TEXT,
content_hash TEXT,
enabled INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
);
INSERT INTO managed_skills
(id, name, directory, enabled, updated_at)
VALUES ('legacy-skill', 'Legacy skill', 'legacy-skill', 1, '2026-01-01T00:00:00Z');
PRAGMA user_version = 1;",
)
.expect("seed version one database");
drop(legacy);
let initializer = DatabaseInitializer::new();
let migrated = initializer.open_at(&path).expect("migrate database");
assert_eq!(
schema_version(&migrated).expect("read migrated schema version"),
APP_DB_SCHEMA_VERSION
);
let skill_count: i64 = migrated
.query_row(
"SELECT COUNT(*) FROM managed_skills WHERE id = 'legacy-skill'",
[],
|row| row.get(0),
)
.expect("count preserved skill");
assert_eq!(skill_count, 1);
migrated
.execute(
"INSERT INTO skills_mcp_notes
(codex_dir, item_kind, item_id, note, updated_at)
VALUES
('/tmp/legacy-codex', 'skill', 'legacy-skill', 'my note', '2026-01-02T00:00:00Z')",
[],
)
.expect("write note after migration");
migrated
.execute(
"INSERT INTO active_provider_selections
(codex_dir, provider_id, updated_at)
VALUES
('/tmp/legacy-codex', 'legacy-provider', '2026-01-02T00:00:00Z')",
[],
)
.expect("write active provider selection after migration");
drop(migrated);
remove_test_db(&path);
}
#[test]
fn concurrent_first_opens_initialize_once() {
let path = test_db_path("concurrent-init");
let attempts = Arc::new(AtomicUsize::new(0));
let barrier = Arc::new(Barrier::new(3));
let mut workers = Vec::new();
for _ in 0..2 {
let path = path.clone();
// Separate initializers model independent Codex-X processes; the
// SQLite transaction and persistent version still permit one run.
let initializer = DatabaseInitializer::new();
let attempts = Arc::clone(&attempts);
let barrier = Arc::clone(&barrier);
workers.push(thread::spawn(move || {
barrier.wait();
initializer.open_at_with(&path, |conn| {
attempts.fetch_add(1, Ordering::SeqCst);
thread::sleep(Duration::from_millis(25));
conn.execute_batch("CREATE TABLE initialized_once (id INTEGER PRIMARY KEY);")
.map_err(|error| CodexxError::Database(error.to_string()))
})
}));
}
barrier.wait();
for worker in workers {
drop(
worker
.join()
.expect("join concurrent database opener")
.expect("open database concurrently"),
);
}
assert_eq!(attempts.load(Ordering::SeqCst), 1);
remove_test_db(&path);
}
#[test]
fn failed_initialization_can_retry() {
let path = test_db_path("retry-init");
let initializer = DatabaseInitializer::new();
let attempts = AtomicUsize::new(0);
let error = initializer
.open_at_with(&path, |_| {
attempts.fetch_add(1, Ordering::SeqCst);
Err(CodexxError::Database(
"injected transient initialization failure".to_string(),
))
})
.expect_err("first initialization must fail");
assert!(error
.to_string()
.contains("transient initialization failure"));
let conn = initializer
.open_at_with(&path, |conn| {
attempts.fetch_add(1, Ordering::SeqCst);
conn.execute_batch("CREATE TABLE retry_succeeded (id INTEGER PRIMARY KEY);")
.map_err(|error| CodexxError::Database(error.to_string()))
})
.expect("retry initialization");
drop(conn);
let conn = initializer
.open_at_with(&path, |_| {
attempts.fetch_add(1, Ordering::SeqCst);
Ok(())
})
.expect("open initialized database");
assert_eq!(attempts.load(Ordering::SeqCst), 2);
drop(conn);
remove_test_db(&path);
}
#[test]
fn initialized_read_connection_does_not_need_a_write_lock() {
let path = test_db_path("read-with-writer");
let initializer = DatabaseInitializer::new();
let writer = initializer.open_at(&path).expect("initialize database");
writer
.execute_batch("BEGIN IMMEDIATE")
.expect("hold database write reservation");
let reader = initializer
.open_at(&path)
.expect("open reader while write reservation is held");
let count: i64 = reader
.query_row("SELECT COUNT(*) FROM providers", [], |row| row.get(0))
.expect("read while another connection holds write reservation");
assert_eq!(count, 0);
drop(reader);
writer
.execute_batch("ROLLBACK")
.expect("release write lock");
drop(writer);
remove_test_db(&path);
}
}
-4
View File
@@ -216,7 +216,3 @@ pub(crate) fn backups() -> Result<Vec<BackupEntry>> {
entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(entries)
}
pub(crate) fn latest_backup() -> Result<Option<BackupEntry>> {
Ok(backups()?.into_iter().next())
}
@@ -2,9 +2,9 @@ use crate::backups::create_backup;
use crate::config_path;
use crate::error::Result;
use crate::file_io::parse_toml_document;
use crate::live_config::{
acquire_live_config_lock, atomic_write_if_unchanged, read_file_snapshot, text_from_snapshot,
};
#[cfg(test)]
use crate::live_config::acquire_live_config_lock;
use crate::live_config::{atomic_write_if_unchanged, read_file_snapshot, text_from_snapshot};
use std::path::Path;
use toml_edit::{DocumentMut, Item, Table};
@@ -31,6 +31,7 @@ fn remove_markdown_path(table: &mut Table, key: &str) -> Option<Item> {
/// Repairs prompt paths appended by older Codex-X versions after a `[tui]`
/// header. Only the exact legacy keys with Markdown path values are touched.
#[cfg(test)]
pub(crate) fn migrate_legacy_prompt_config(codex_dir: &Path) -> Result<bool> {
let _lock = acquire_live_config_lock(codex_dir)?;
migrate_legacy_prompt_config_locked(codex_dir)
+1
View File
@@ -108,6 +108,7 @@ pub(crate) fn read_to_string_if_exists(path: &Path) -> Result<String> {
fs::read_to_string(path).map_err(|e| io_err(path, e))
}
#[cfg(test)]
pub(crate) fn harden_sensitive_file_permissions(path: &Path) -> Result<()> {
#[cfg(unix)]
{
+188 -38
View File
@@ -82,14 +82,16 @@ use providers::{
upsert_provider_on_connection, CcSwitchCodexRow, ProviderUpsertKind, ProviderUpsertMode,
};
use providers::{
capture_live_chatgpt_config, 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,
save_provider_inner, save_provider_toml_config_inner, switch_official_provider_inner,
switch_provider_inner, test_provider_connection_inner, ImportResult, OfficialAuthCandidate,
OfficialConfigDraft, OfficialConfigInput, ProviderConnectionResult, ProviderInput,
ProviderModelsResult, ProviderTomlInput, SavedProvider,
build_provider_toml_draft_inner, clear_active_provider_on_connection,
delete_saved_provider_inner, fetch_provider_models_inner, get_official_config_draft_inner,
import_ccswitch_codex_providers_inner, list_saved_providers_inner, open_store,
read_ccswitch_official_auth_inner, remember_active_provider_on_connection,
reset_official_provider_inner, restore_official_provider_inner, save_active_provider_inner,
save_official_config_inner, save_provider_inner, save_provider_toml_config_inner,
switch_official_provider_inner, switch_provider_inner, test_provider_connection_inner,
ImportResult, OfficialAuthCandidate, OfficialConfigDraft, OfficialConfigInput,
ProviderConnectionResult, ProviderInput, ProviderModelsResult, ProviderTomlInput,
SavedProvider,
};
#[cfg(test)]
use sessions::{
@@ -105,8 +107,9 @@ use sessions::{
};
use skills_mcp::{
build_skills_mcp_state_inner, check_skill_updates_inner, import_existing_skills_mcp_inner,
install_skill_zip_inner, preview_existing_skills_mcp_inner, toggle_codex_mcp_inner,
toggle_codex_skill_inner, SkillsMcpActionResult, SkillsMcpImportPreview, SkillsMcpState,
install_skill_zip_inner, preview_existing_skills_mcp_inner, save_skills_mcp_note_inner,
toggle_codex_mcp_inner, toggle_codex_skill_inner, SkillsMcpActionResult,
SkillsMcpImportPreview, SkillsMcpState,
};
#[cfg(test)]
use skills_mcp::{
@@ -115,9 +118,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;
@@ -154,6 +157,13 @@ struct AboutInfo {
native_updater_supported: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct CodexDesktopRestartResult {
app_name: String,
was_running: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct DiagnosticItem {
@@ -183,7 +193,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() {
@@ -473,6 +483,20 @@ async fn toggle_codex_mcp(
.map_err(|e| CodexxError::Config(format!("切换 MCP 失败: {e}")))?
}
#[tauri::command]
async fn save_skills_mcp_note(
config_dir: Option<String>,
item_kind: String,
id: String,
note: String,
) -> Result<SkillsMcpState> {
tauri::async_runtime::spawn_blocking(move || {
save_skills_mcp_note_inner(config_dir, item_kind, id, note)
})
.await
.map_err(|e| CodexxError::Config(format!("保存 Skills/MCP 备注失败: {e}")))?
}
#[tauri::command]
async fn install_skill_zip(
config_dir: Option<String>,
@@ -578,6 +602,18 @@ async fn get_about_info(config_dir: Option<String>) -> Result<AboutInfo> {
.map_err(|e| CodexxError::Config(format!("读取关于信息失败: {e}")))?
}
#[tauri::command]
async fn restart_codex_desktop() -> std::result::Result<CodexDesktopRestartResult, String> {
let (app_name, was_running) =
tauri::async_runtime::spawn_blocking(platform::restart_codex_desktop)
.await
.map_err(|error| format!("重启 Codex 桌面客户端失败: {error}"))??;
Ok(CodexDesktopRestartResult {
app_name,
was_running,
})
}
#[tauri::command]
async fn list_saved_prompts() -> Result<Vec<SavedPrompt>> {
tauri::async_runtime::spawn_blocking(list_saved_prompts_inner)
@@ -852,6 +888,66 @@ async fn list_saved_providers() -> Result<Vec<SavedProvider>> {
.map_err(|e| CodexxError::Config(format!("读取供应商列表失败: {e}")))?
}
enum ActiveProviderSelectionUpdate {
Set(String),
ClearIfOfficial,
}
fn finish_provider_selection(
mut result: ActionResult,
update: ActiveProviderSelectionUpdate,
) -> ActionResult {
if matches!(update, ActiveProviderSelectionUpdate::ClearIfOfficial)
&& !result.state.is_official_provider
{
return result;
}
match &update {
ActiveProviderSelectionUpdate::Set(provider_id) => {
result.state.active_saved_provider_id = Some(provider_id.clone());
}
ActiveProviderSelectionUpdate::ClearIfOfficial => {
result.state.active_saved_provider_id = None;
}
}
let codex_dir = PathBuf::from(&result.state.codex_dir);
let refreshed_state = (|| -> Result<CodexState> {
let conn = open_store()?;
match update {
ActiveProviderSelectionUpdate::Set(provider_id) => {
remember_active_provider_on_connection(&conn, &codex_dir, &provider_id)?;
}
ActiveProviderSelectionUpdate::ClearIfOfficial => {
clear_active_provider_on_connection(&conn, &codex_dir)?;
}
}
drop(conn);
build_state_after_migration(codex_dir)
})();
match refreshed_state {
Ok(state) => result.state = state,
Err(error) => {
result
.message
.push_str(&format!(";当前供应商状态记录失败:{error}"));
}
}
result
}
#[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)
}
@@ -868,9 +964,16 @@ async fn save_active_provider(
provider: SavedProvider,
config_dir: Option<String>,
) -> Result<ActionResult> {
tauri::async_runtime::spawn_blocking(move || save_active_provider_inner(provider, config_dir))
.await
.map_err(|e| CodexxError::Config(format!("保存活动供应商失败: {e}")))?
tauri::async_runtime::spawn_blocking(move || {
let provider_id = provider.id.clone();
let result = save_active_provider_inner(provider, config_dir)?;
Ok(finish_provider_selection(
result,
ActiveProviderSelectionUpdate::Set(provider_id),
))
})
.await
.map_err(|e| CodexxError::Config(format!("保存活动供应商失败: {e}")))?
}
#[tauri::command]
@@ -889,20 +992,20 @@ 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)
}
#[tauri::command]
async fn switch_official_provider(config_dir: Option<String>) -> Result<ActionResult> {
tauri::async_runtime::spawn_blocking(move || switch_official_provider_inner(config_dir))
.await
.map_err(|e| CodexxError::Config(format!("切换官方配置失败: {e}")))?
tauri::async_runtime::spawn_blocking(move || {
let result = switch_official_provider_inner(config_dir)?;
Ok(finish_provider_selection(
result,
ActiveProviderSelectionUpdate::ClearIfOfficial,
))
})
.await
.map_err(|e| CodexxError::Config(format!("切换官方配置失败: {e}")))?
}
#[tauri::command]
@@ -916,15 +1019,26 @@ async fn get_official_config_draft(
#[tauri::command]
async fn restore_official_provider(config_dir: Option<String>) -> Result<ActionResult> {
tauri::async_runtime::spawn_blocking(move || restore_official_provider_inner(config_dir))
.await
.map_err(|e| CodexxError::Config(format!("还原官方配置失败: {e}")))?
tauri::async_runtime::spawn_blocking(move || {
let result = restore_official_provider_inner(config_dir)?;
Ok(finish_provider_selection(
result,
ActiveProviderSelectionUpdate::ClearIfOfficial,
))
})
.await
.map_err(|e| CodexxError::Config(format!("还原官方配置失败: {e}")))?
}
#[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)
let result =
reset_official_provider_inner(input.config_dir, input.model, input.config_text)?;
Ok(finish_provider_selection(
result,
ActiveProviderSelectionUpdate::ClearIfOfficial,
))
})
.await
.map_err(|e| CodexxError::Config(format!("新建官方配置失败: {e}")))?
@@ -933,7 +1047,16 @@ 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)
let result = save_official_config_inner(
input.config_dir,
input.model,
input.auth_json,
input.config_text,
)?;
Ok(finish_provider_selection(
result,
ActiveProviderSelectionUpdate::ClearIfOfficial,
))
})
.await
.map_err(|e| CodexxError::Config(format!("保存官方配置失败: {e}")))?
@@ -1113,10 +1236,21 @@ async fn disable_external_instruction(config_dir: Option<String>) -> Result<Acti
}
#[tauri::command]
async fn save_provider_toml_config(input: ProviderTomlInput) -> Result<ActionResult> {
tauri::async_runtime::spawn_blocking(move || save_provider_toml_config_inner(input))
.await
.map_err(|e| CodexxError::Config(format!("保存供应商 TOML 失败: {e}")))?
async fn save_provider_toml_config(
input: ProviderTomlInput,
provider_id: Option<String>,
) -> Result<ActionResult> {
tauri::async_runtime::spawn_blocking(move || {
let result = save_provider_toml_config_inner(input)?;
Ok(match provider_id {
Some(provider_id) => {
finish_provider_selection(result, ActiveProviderSelectionUpdate::Set(provider_id))
}
None => result,
})
})
.await
.map_err(|e| CodexxError::Config(format!("保存供应商 TOML 失败: {e}")))?
}
#[tauri::command]
@@ -1141,9 +1275,18 @@ async fn fetch_provider_models(
#[tauri::command]
async fn switch_provider(input: ProviderInput) -> Result<ActionResult> {
tauri::async_runtime::spawn_blocking(move || switch_provider_inner(input))
.await
.map_err(|e| CodexxError::Config(format!("切换供应商失败: {e}")))?
tauri::async_runtime::spawn_blocking(move || {
let provider_id = input.provider_id.clone();
let result = switch_provider_inner(input)?;
Ok(match provider_id {
Some(provider_id) => {
finish_provider_selection(result, ActiveProviderSelectionUpdate::Set(provider_id))
}
None => result,
})
})
.await
.map_err(|e| CodexxError::Config(format!("切换供应商失败: {e}")))?
}
#[tauri::command]
@@ -1239,6 +1382,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)?;
@@ -1326,12 +1473,14 @@ pub fn run() {
.on_window_event(desktop_lifecycle::handle_window_event)
.invoke_handler(tauri::generate_handler![
get_about_info,
restart_codex_desktop,
check_app_update,
get_skills_mcp_state,
preview_existing_skills_mcp,
import_existing_skills_mcp,
toggle_codex_skill,
toggle_codex_mcp,
save_skills_mcp_note,
install_skill_zip,
check_skill_updates,
get_startup_diagnostics,
@@ -1350,6 +1499,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,
+11 -1
View File
@@ -1,7 +1,7 @@
use crate::error::{CodexxError, Result};
#[cfg(test)]
use chrono::Local;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
pub(crate) fn home_dir() -> Result<PathBuf> {
dirs::home_dir().ok_or(CodexxError::NoHomeDir)
@@ -33,3 +33,13 @@ pub(crate) fn app_home() -> Result<PathBuf> {
Ok(home_dir()?.join(".codexx"))
}
}
pub(crate) fn normalized_path_scope(path: &Path) -> String {
let resolved = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let normalized = resolved.to_string_lossy().replace('\\', "/");
if cfg!(target_os = "windows") {
normalized.to_ascii_lowercase()
} else {
normalized
}
}
File diff suppressed because it is too large Load Diff
+315 -64
View File
@@ -1,19 +1,20 @@
use super::{
consolidate_legacy_provider_duplicates_on_connection, custom_provider_id,
experimental_bearer_token_from_doc, list_saved_providers_on_connection,
normalize_saved_provider, open_store, upsert_ccswitch_provider_on_connection,
ProviderUpsertKind, SavedProvider,
normalize_saved_provider, open_store, strip_provider_bearer_tokens,
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,130 @@ 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;
}
if string_value(&doc, "model_provider").as_deref() != Some(provider_id) {
doc["model_provider"] = value(provider_id);
}
if string_value(&doc, "model").as_deref() != Some(model) {
doc["model"] = value(model);
}
let providers = ensure_table(doc.as_table_mut(), "model_providers").ok()?;
if providers
.get(provider_id)
.and_then(|item| item.as_table())
.is_none()
{
let mut table = section.provider_table.clone();
if table_string(&table, "name").is_none() && !provider_name.trim().is_empty() {
table["name"] = value(provider_name.trim());
}
providers.insert(provider_id, Item::Table(table));
}
strip_provider_bearer_tokens(&mut doc);
Some(doc.to_string().trim_end().to_string())
}
pub(crate) fn build_ccswitch_codex_provider(
row: &CcSwitchCodexRow,
global_sections: &HashMap<String, CcSwitchCodexSection>,
@@ -241,17 +296,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 = section
.name
.clone()
.or_else(|| {
let name = row.name.trim();
(!name.is_empty()).then(|| name.to_string())
})
.unwrap_or_else(|| row.id.clone());
let model = section.model.clone()?;
let toml_config = ccswitch_provider_template(&settings, &section, &provider_name, &model)?;
Some(SavedProvider {
id: custom_provider_id(&row.id),
provider_name: if row.name.trim().is_empty() {
section.name.unwrap_or_else(|| row.id.clone())
} else {
row.name.trim().to_string()
},
provider_name,
base_url: section.base_url,
model: section.model.unwrap_or_else(|| "gpt-5.5".to_string()),
model,
api_key,
toml_config: None,
toml_config: Some(toml_config),
wire_api: section.wire_api,
requires_openai_auth: section.requires_openai_auth,
})
@@ -334,7 +395,6 @@ pub(crate) fn import_ccswitch_codex_providers_inner(path: Option<String>) -> Res
match result.kind {
ProviderUpsertKind::Added => added += 1,
ProviderUpsertKind::Updated => updated += 1,
ProviderUpsertKind::Merged => merged += 1,
}
imported += 1;
}
@@ -435,9 +495,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 +509,195 @@ 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.6-sol"
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 = "Sky2api"
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: " Sky2_free ".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, "Sky2api");
assert_eq!(provider.base_url, "https://proxy.example.com/v1");
assert_eq!(provider.model, "gpt-5.6-sol");
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("Sky2api")
);
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 complete_row_without_a_model_is_not_silently_downgraded() {
let row = CcSwitchCodexRow {
id: "missing-model".to_string(),
name: "Database label".to_string(),
settings_config: json!({
"auth": {"OPENAI_API_KEY": "sk-test"},
"config": r#"model_provider = "custom"
[model_providers.custom]
name = "TOML label"
base_url = "https://proxy.example.com/v1"
wire_api = "responses"
requires_openai_auth = false
"#,
})
.to_string(),
category: None,
};
assert!(build_ccswitch_codex_provider(&row, &HashMap::new()).is_none());
}
#[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
+17 -10
View File
@@ -2,6 +2,7 @@ mod ccswitch;
mod connection;
mod live;
mod official_auth;
mod selection;
mod store;
use crate::error::Result;
@@ -24,10 +25,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 +36,14 @@ 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,
};
pub(crate) use selection::{
clear_active_provider_on_connection, clear_provider_selections_on_connection,
reconcile_active_provider_on_connection, remember_active_provider_on_connection,
};
#[cfg(test)]
pub(crate) use store::{
@@ -50,9 +55,11 @@ pub(crate) use store::{
delete_provider_inner, experimental_bearer_token_from_doc, is_placeholder_provider,
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,
normalize_saved_provider_for_save, provider_template_from_document, reserved_codex_provider_id,
rollback_provider_store_inner, save_provider_inner, save_provider_with_rollback_inner,
strip_provider_bearer_tokens, unique_saved_provider_id_for_live,
upsert_ccswitch_provider_on_connection, 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,30 +85,61 @@ 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 {
let mode = value.get("auth_mode").and_then(Value::as_str);
let has_api_key = value
.get("OPENAI_API_KEY")
.and_then(Value::as_str)
.is_some_and(|key| !key.trim().is_empty());
chatgpt_mode
&& !has_api_key
&& value
.get("tokens")
.and_then(Value::as_object)
.is_some_and(|tokens| {
["access_token", "refresh_token", "id_token"]
.iter()
.any(|key| {
tokens
.get(*key)
.and_then(Value::as_str)
.is_some_and(|value| !value.trim().is_empty())
})
let has_bedrock_key = value.get("bedrock_api_key").is_some_and(value_has_material);
if has_api_key || has_bedrock_key {
return false;
}
let has_tokens = value
.get("tokens")
.and_then(Value::as_object)
.is_some_and(|tokens| {
["access_token", "refresh_token", "id_token"]
.iter()
.any(|key| {
tokens
.get(*key)
.and_then(Value::as_str)
.is_some_and(|token| !token.trim().is_empty())
})
});
let has_agent_identity = value.get("agent_identity").is_some_and(|identity| {
identity.as_str().is_some_and(|jwt| !jwt.trim().is_empty())
|| identity.as_object().is_some_and(|record| {
["agent_runtime_id", "agent_private_key"].iter().all(|key| {
record
.get(*key)
.and_then(Value::as_str)
.is_some_and(|value| !value.trim().is_empty())
})
})
});
let has_personal_access_token = value
.get("personal_access_token")
.and_then(Value::as_str)
.is_some_and(|token| !token.trim().is_empty());
match mode {
// This matches Codex AuthDotJson::resolved_mode: legacy token files
// default to ChatGPT, while PAT infers its own mode. Agent Identity
// requires an explicit auth_mode.
None => has_tokens || has_personal_access_token,
Some(mode)
if mode.eq_ignore_ascii_case("chatgpt")
|| mode.eq_ignore_ascii_case("chatgptAuthTokens") =>
{
has_tokens
}
Some(mode) if mode.eq_ignore_ascii_case("agentIdentity") => has_agent_identity,
Some(mode) if mode.eq_ignore_ascii_case("personalAccessToken") => has_personal_access_token,
Some(_) => false,
}
}
fn has_openai_api_key(value: &Value) -> bool {
@@ -116,21 +154,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 +300,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 +315,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 +325,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 +334,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 +409,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 +486,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 +507,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 +551,307 @@ 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"
})));
assert!(is_chatgpt_auth(&json!({
"auth_mode": "chatgptAuthTokens",
"tokens": {"access_token": "external-access"}
})));
assert!(is_chatgpt_auth(&json!({
"auth_mode": "agentIdentity",
"agent_identity": {
"agent_runtime_id": "runtime-id",
"agent_private_key": "private-key"
}
})));
assert!(is_chatgpt_auth(&json!({
"auth_mode": "personalAccessToken",
"personal_access_token": "pat-test"
})));
assert!(!is_chatgpt_auth(&json!({
"auth_mode": "bedrockApiKey",
"bedrock_api_key": {"api_key": "bedrock-test", "region": "us-east-1"}
})));
assert!(!is_chatgpt_auth(&json!({
"agent_identity": {
"agent_runtime_id": "runtime-id",
"agent_private_key": "private-key"
}
})));
}
#[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");
}
}
@@ -0,0 +1,204 @@
use crate::error::{CodexxError, Result};
use crate::now_rfc3339;
use crate::paths::normalized_path_scope;
use rusqlite::{params, Connection, OptionalExtension};
use std::collections::HashSet;
use std::path::Path;
fn selected_provider_id_on_connection(
conn: &Connection,
codex_dir: &Path,
) -> Result<Option<String>> {
conn.query_row(
"SELECT provider_id
FROM active_provider_selections
WHERE codex_dir = ?1",
[normalized_path_scope(codex_dir)],
|row| row.get(0),
)
.optional()
.map_err(|error| CodexxError::Database(error.to_string()))
}
pub(crate) fn remember_active_provider_on_connection(
conn: &Connection,
codex_dir: &Path,
provider_id: &str,
) -> Result<()> {
let provider_id = provider_id.trim();
if provider_id.is_empty() {
return Err(CodexxError::Config("当前供应商 ID 不能为空".to_string()));
}
let exists = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM providers WHERE id = ?1)",
[provider_id],
|row| row.get::<_, bool>(0),
)
.map_err(|error| CodexxError::Database(error.to_string()))?;
if !exists {
return Err(CodexxError::Config(format!(
"无法记录当前供应商,未找到 ID {provider_id}"
)));
}
conn.execute(
"INSERT INTO active_provider_selections (codex_dir, provider_id, updated_at)
VALUES (?1, ?2, ?3)
ON CONFLICT(codex_dir) DO UPDATE SET
provider_id = excluded.provider_id,
updated_at = excluded.updated_at",
params![normalized_path_scope(codex_dir), provider_id, now_rfc3339()],
)
.map_err(|error| CodexxError::Database(error.to_string()))?;
Ok(())
}
pub(crate) fn clear_active_provider_on_connection(
conn: &Connection,
codex_dir: &Path,
) -> Result<()> {
conn.execute(
"DELETE FROM active_provider_selections WHERE codex_dir = ?1",
[normalized_path_scope(codex_dir)],
)
.map_err(|error| CodexxError::Database(error.to_string()))?;
Ok(())
}
pub(crate) fn clear_provider_selections_on_connection(
conn: &Connection,
provider_id: &str,
) -> Result<()> {
conn.execute(
"DELETE FROM active_provider_selections WHERE provider_id = ?1",
[provider_id],
)
.map_err(|error| CodexxError::Database(error.to_string()))?;
Ok(())
}
pub(crate) fn reconcile_active_provider_on_connection(
conn: &Connection,
codex_dir: &Path,
candidate_ids: &[String],
) -> Result<Option<String>> {
let candidates = candidate_ids
.iter()
.map(|id| id.trim())
.filter(|id| !id.is_empty())
.collect::<HashSet<_>>();
let selected = selected_provider_id_on_connection(conn, codex_dir)?;
if let Some(selected) = selected.as_deref() {
if candidates.contains(selected) {
return Ok(Some(selected.to_string()));
}
clear_active_provider_on_connection(conn, codex_dir)?;
}
if candidates.len() != 1 {
return Ok(None);
}
let provider_id = candidates
.into_iter()
.next()
.expect("one candidate must be present");
remember_active_provider_on_connection(conn, codex_dir, provider_id)?;
Ok(Some(provider_id.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn test_connection() -> Connection {
let conn = Connection::open_in_memory().expect("open selection test database");
conn.execute_batch(
"CREATE TABLE providers (id TEXT PRIMARY KEY);
CREATE TABLE active_provider_selections (
codex_dir TEXT PRIMARY KEY,
provider_id TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT INTO providers (id) VALUES ('original'), ('copy'), ('other');",
)
.expect("create selection test schema");
conn
}
fn scope(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("codex-x-selection-{name}"))
}
#[test]
fn exact_duplicate_does_not_replace_the_remembered_current_provider() {
let conn = test_connection();
let codex_dir = scope("duplicate");
remember_active_provider_on_connection(&conn, &codex_dir, "original")
.expect("remember original provider");
let active = reconcile_active_provider_on_connection(
&conn,
&codex_dir,
&["original".to_string(), "copy".to_string()],
)
.expect("reconcile duplicate profiles");
assert_eq!(active.as_deref(), Some("original"));
}
#[test]
fn selections_are_independent_for_each_codex_directory() {
let conn = test_connection();
let first = scope("first");
let second = scope("second");
remember_active_provider_on_connection(&conn, &first, "original")
.expect("remember first selection");
remember_active_provider_on_connection(&conn, &second, "copy")
.expect("remember second selection");
let candidates = ["original".to_string(), "copy".to_string()];
assert_eq!(
reconcile_active_provider_on_connection(&conn, &first, &candidates)
.expect("reconcile first selection")
.as_deref(),
Some("original")
);
assert_eq!(
reconcile_active_provider_on_connection(&conn, &second, &candidates)
.expect("reconcile second selection")
.as_deref(),
Some("copy")
);
}
#[test]
fn stale_selection_falls_back_only_when_the_live_match_is_unique() {
let conn = test_connection();
let codex_dir = scope("stale");
remember_active_provider_on_connection(&conn, &codex_dir, "original")
.expect("remember stale selection");
assert_eq!(
reconcile_active_provider_on_connection(&conn, &codex_dir, &["other".to_string()])
.expect("reconcile unique replacement")
.as_deref(),
Some("other")
);
assert_eq!(
selected_provider_id_on_connection(&conn, &codex_dir)
.expect("read repaired selection")
.as_deref(),
Some("other")
);
assert!(
reconcile_active_provider_on_connection(&conn, &codex_dir, &[])
.expect("clear unmatched selection")
.is_none()
);
assert!(selected_provider_id_on_connection(&conn, &codex_dir)
.expect("read cleared selection")
.is_none());
}
}
File diff suppressed because it is too large Load Diff
+48 -19
View File
@@ -84,6 +84,7 @@ fn timestamp_expr(columns: &HashSet<String>, ms_column: &str, seconds_column: &s
fn collect_catalog_repair_threads(
paths: &[PathBuf],
target_provider: &str,
syncable_thread_ids: &HashSet<String>,
) -> Result<HashMap<String, CatalogRepairThread>> {
let mut threads = HashMap::new();
for path in paths {
@@ -117,10 +118,15 @@ fn collect_catalog_repair_threads(
let source_detail = text_expr(&columns, "rollout_path", "''");
let git_branch = text_expr(&columns, "git_branch", "NULL");
let thread_source = text_expr(&columns, "thread_source", "NULL");
let archived_filter = if columns.contains("archived") {
" AND COALESCE(archived, 0) = 0"
} else {
""
};
let sql = format!(
"SELECT id, {display_title}, {source_created_at}, {source_updated_at}, \
{source_recency_at}, {cwd}, {source_kind}, {source_detail}, {git_branch}, \
{thread_source} FROM threads WHERE COALESCE(id, '') <> ''"
{thread_source} FROM threads WHERE COALESCE(id, '') <> ''{archived_filter}"
);
let mut statement = conn.prepare(&sql).map_err(database_error)?;
let rows = statement
@@ -144,6 +150,9 @@ fn collect_catalog_repair_threads(
.map_err(database_error)?;
for row in rows {
let thread = row.map_err(database_error)?;
if !syncable_thread_ids.contains(&thread.id) {
continue;
}
let replace = threads
.get(&thread.id)
.map(|current: &CatalogRepairThread| {
@@ -227,8 +236,10 @@ pub(super) fn scan_catalog_sync(
thread_paths: &[PathBuf],
catalog_paths: &[PathBuf],
target_provider: &str,
syncable_thread_ids: &HashSet<String>,
) -> Result<CatalogSyncScan> {
let sources = collect_catalog_repair_threads(thread_paths, target_provider)?;
let sources =
collect_catalog_repair_threads(thread_paths, target_provider, syncable_thread_ids)?;
let mut scan = CatalogSyncScan {
sources,
..CatalogSyncScan::default()
@@ -255,8 +266,8 @@ pub(super) fn scan_catalog_sync(
.map_err(database_error)?;
for row in rows {
let id = row.map_err(database_error)?;
scan.mismatched_rows += 1;
if !id.trim().is_empty() {
if syncable_thread_ids.contains(&id) {
scan.mismatched_rows += 1;
scan.mismatched_thread_ids.insert(id);
}
}
@@ -546,6 +557,7 @@ fn snapshot_catalog_provider_changes(
conn: &Connection,
columns: &HashSet<String>,
target_provider: &str,
provider_thread_ids: &HashSet<String>,
) -> Result<()> {
if !columns.contains("model_provider") || !columns.contains("thread_id") {
return Ok(());
@@ -564,10 +576,13 @@ fn snapshot_catalog_provider_changes(
"INSERT INTO temp.codexx_catalog_provider_rollback \
(host_id, thread_id, model_provider, missing_candidate) \
SELECT {host_expr}, thread_id, model_provider, {missing_expr} \
FROM local_thread_catalog WHERE COALESCE(model_provider, '') <> ?1"
FROM local_thread_catalog WHERE COALESCE(model_provider, '') <> ?1 \
AND thread_id = ?2"
);
conn.execute(&sql, [target_provider])
.map_err(database_error)?;
for thread_id in provider_thread_ids {
conn.execute(&sql, (target_provider, thread_id))
.map_err(database_error)?;
}
Ok(())
}
@@ -663,6 +678,7 @@ pub(super) fn apply_catalog_updates(
columns: &HashSet<String>,
target_provider: &str,
sources: &HashMap<String, CatalogRepairThread>,
provider_thread_ids: &HashSet<String>,
) -> Result<CatalogUpdateCounts> {
let mut counts = CatalogUpdateCounts::default();
let repair_host = if !sources.is_empty() && catalog_supports_repair(columns) {
@@ -670,17 +686,19 @@ pub(super) fn apply_catalog_updates(
} else {
None
};
snapshot_catalog_provider_changes(conn, columns, target_provider)?;
snapshot_catalog_provider_changes(conn, columns, target_provider, provider_thread_ids)?;
if let Some(host_id) = repair_host.as_deref() {
snapshot_hidden_catalog_sources(conn, columns, host_id, sources)?;
}
if columns.contains("model_provider") && columns.contains("thread_id") {
conn.execute(
"UPDATE local_thread_catalog SET model_provider = ?1 \
WHERE COALESCE(model_provider, '') <> ?1",
[target_provider],
)
.map_err(database_error)?;
for thread_id in provider_thread_ids {
conn.execute(
"UPDATE local_thread_catalog SET model_provider = ?1 \
WHERE thread_id = ?2 AND COALESCE(model_provider, '') <> ?1",
(target_provider, thread_id),
)
.map_err(database_error)?;
}
}
if let Some(host_id) = repair_host.as_deref() {
reactivate_catalog_sources(conn, columns, host_id, sources)?;
@@ -939,13 +957,20 @@ mod tests {
conn: &Connection,
target_provider: &str,
sources: &HashMap<String, CatalogRepairThread>,
provider_thread_ids: &HashSet<String>,
) -> CatalogUpdateCounts {
conn.execute_batch("BEGIN IMMEDIATE")
.expect("begin catalog update");
create_catalog_rollback_tables(conn).expect("create rollback tables");
let columns = catalog_columns(conn).expect("read catalog columns");
let counts = apply_catalog_updates(conn, &columns, target_provider, sources)
.expect("apply catalog update");
let counts = apply_catalog_updates(
conn,
&columns,
target_provider,
sources,
provider_thread_ids,
)
.expect("apply catalog update");
conn.execute_batch("COMMIT").expect("commit catalog update");
counts
}
@@ -983,10 +1008,12 @@ mod tests {
insert_catalog_row(&catalog, "thread-hidden", "custom", 1);
drop(catalog);
let syncable_thread_ids = HashSet::from(["thread-hidden".to_string()]);
let scan = scan_catalog_sync(
&[thread_path],
std::slice::from_ref(&catalog_path),
"custom",
&syncable_thread_ids,
)
.expect("scan hidden catalog row");
assert_eq!(scan.mismatched_rows, 0);
@@ -994,7 +1021,7 @@ mod tests {
assert!(scan.mismatched_thread_ids.contains("thread-hidden"));
let catalog = Connection::open(&catalog_path).expect("open catalog for update");
let counts = apply_and_commit(&catalog, "custom", &scan.sources);
let counts = apply_and_commit(&catalog, "custom", &scan.sources, &syncable_thread_ids);
assert_eq!(counts.provider_rows, 1);
assert_eq!(counts.inserted_rows, 0);
assert_eq!(
@@ -1016,8 +1043,9 @@ mod tests {
let conn = Connection::open_in_memory().expect("open in-memory catalog");
create_catalog_schema(&conn);
insert_catalog_row(&conn, "thread-provider", "openai", 0);
let provider_thread_ids = HashSet::from(["thread-provider".to_string()]);
let counts = apply_and_commit(&conn, "custom", &HashMap::new());
let counts = apply_and_commit(&conn, "custom", &HashMap::new(), &provider_thread_ids);
assert_eq!(counts.provider_rows, 1);
assert_eq!(counts.inserted_rows, 0);
assert_eq!(
@@ -1070,8 +1098,9 @@ mod tests {
"thread-combined".to_string(),
repair_source("thread-combined", "custom"),
)]);
let provider_thread_ids = HashSet::from(["thread-combined".to_string()]);
let counts = apply_and_commit(&conn, "custom", &sources);
let counts = apply_and_commit(&conn, "custom", &sources, &provider_thread_ids);
assert_eq!(counts.provider_rows, 1);
assert_eq!(counts.inserted_rows, 0);
assert_eq!(
@@ -314,7 +314,9 @@ fn active_session_storage_snapshot(
if cols.contains("model_provider") {
let cwd_col = sql_select_column(&cols, "cwd", "NULL");
let query = format!("SELECT id, model_provider, {cwd_col} FROM threads");
let archived_col = sql_select_column(&cols, "archived", "0");
let query =
format!("SELECT id, model_provider, {cwd_col}, {archived_col} FROM threads");
let mut mismatch_stmt = conn
.prepare(&query)
.map_err(|error| CodexxError::Database(error.to_string()))?;
@@ -324,11 +326,12 @@ fn active_session_storage_snapshot(
row.get::<_, String>(0)?,
row.get::<_, Option<String>>(1)?,
row.get::<_, Option<String>>(2)?,
row.get::<_, i64>(3)?,
))
})
.map_err(|error| CodexxError::Database(error.to_string()))?;
for row in mismatches {
let (id, provider, cwd) =
let (id, provider, cwd, archived) =
row.map_err(|error| CodexxError::Database(error.to_string()))?;
if sqlite_thread_needs_alignment(
&rollouts,
@@ -338,6 +341,7 @@ fn active_session_storage_snapshot(
provider: provider.as_deref(),
cwd: cwd.as_deref(),
cwd_column: cols.contains("cwd"),
archived: archived != 0,
},
) {
snapshot.mismatched_ids.insert(id);
+136 -37
View File
@@ -62,6 +62,7 @@ pub(super) fn is_canonical_rollout_storage_path(codex_dir: &Path, path: &Path) -
fn referenced_rollout_paths(
codex_dir: &Path,
rollout_paths_by_thread_id: &HashMap<String, String>,
include_archived_storage: bool,
failures: &mut Vec<String>,
) -> HashMap<PathBuf, HashSet<String>> {
let mut referenced = HashMap::<PathBuf, HashSet<String>>::new();
@@ -106,11 +107,23 @@ fn referenced_rollout_paths(
continue;
}
};
if !is_canonical_rollout_storage_path(codex_dir, &canonical) {
failures.push(format!(
"活动 SQLite 引用的会话文件超出 Codex 会话目录: {}",
path.display()
));
let within_codex_storage = is_canonical_rollout_storage_path(codex_dir, &canonical);
let allowed_root = if include_archived_storage {
within_codex_storage
} else {
within_codex_storage
&& codex_dir
.join("sessions")
.canonicalize()
.is_ok_and(|root| canonical.starts_with(root))
};
if !allowed_root {
let reason = if within_codex_storage {
"活动 SQLite 引用了归档会话文件"
} else {
"活动 SQLite 引用的会话文件超出 Codex 会话目录"
};
failures.push(format!("{reason}: {}", path.display()));
continue;
}
let expected_thread_ids = referenced.entry(canonical.clone()).or_default();
@@ -181,7 +194,23 @@ fn is_locked_io_error(error: &std::io::Error) -> bool {
}
pub(crate) fn scan_rollouts(codex_dir: &Path, target_provider: &str) -> Result<RolloutScan> {
scan_rollouts_with_thread_filter(codex_dir, target_provider, None, None)
scan_rollouts_with_thread_filter(codex_dir, target_provider, None, None, true, None, false)
}
pub(super) fn scan_provider_rollouts(
codex_dir: &Path,
target_provider: &str,
excluded_thread_ids: &HashSet<String>,
) -> Result<RolloutScan> {
scan_rollouts_with_thread_filter(
codex_dir,
target_provider,
None,
None,
false,
Some(excluded_thread_ids),
true,
)
}
pub(super) fn scan_rollouts_for_thread_ids(
@@ -195,6 +224,9 @@ pub(super) fn scan_rollouts_for_thread_ids(
target_provider,
Some(thread_ids),
Some(rollout_paths_by_thread_id),
false,
None,
false,
)
}
@@ -203,15 +235,34 @@ fn scan_rollouts_with_thread_filter(
target_provider: &str,
allowed_thread_ids: Option<&HashSet<String>>,
rollout_paths_by_thread_id: Option<&HashMap<String, String>>,
include_archived_storage: bool,
excluded_thread_ids: Option<&HashSet<String>>,
exclude_source_marked_subagents: bool,
) -> Result<RolloutScan> {
let mut paths = Vec::new();
let mut scan = RolloutScan::default();
let mut referenced = HashMap::new();
for dir in ["sessions", "archived_sessions"] {
collect_rollout_paths(&codex_dir.join(dir), &mut paths, &mut scan.scan_failures);
collect_rollout_paths(
&codex_dir.join("sessions"),
&mut paths,
&mut scan.scan_failures,
);
if include_archived_storage {
collect_rollout_paths(
&codex_dir.join("archived_sessions"),
&mut paths,
&mut scan.scan_failures,
);
}
paths.sort();
scan.discovered_rollout_files = paths.len();
if let Some(excluded_thread_ids) = excluded_thread_ids {
paths.retain(|path| {
!excluded_thread_ids
.iter()
.any(|id| rollout_filename_matches_id(path, id))
});
}
if let Some(allowed_thread_ids) = allowed_thread_ids {
let empty_rollout_paths = HashMap::new();
let rollout_paths_by_thread_id = rollout_paths_by_thread_id.unwrap_or(&empty_rollout_paths);
@@ -223,6 +274,7 @@ fn scan_rollouts_with_thread_filter(
referenced = referenced_rollout_paths(
codex_dir,
rollout_paths_by_thread_id,
include_archived_storage,
&mut scan.scan_failures,
);
paths.retain(|path| {
@@ -264,6 +316,7 @@ fn scan_rollouts_with_thread_filter(
let mut invalid_session_meta = 0usize;
let mut thread_id = None;
let mut cwd = None;
let mut is_subagent = false;
for segment in text.split_inclusive('\n') {
let (line, line_ending) = split_line_ending(segment);
@@ -287,6 +340,9 @@ fn scan_rollouts_with_thread_filter(
.and_then(Value::as_str)
.and_then(normalize_workspace_path);
}
if payload.get("source").is_some_and(source_value_is_subagent) {
is_subagent = true;
}
if payload.get("model_provider").and_then(Value::as_str)
!= Some(target_provider)
{
@@ -311,6 +367,13 @@ fn scan_rollouts_with_thread_filter(
next_text.push_str(line_ending);
}
if (exclude_source_marked_subagents && is_subagent)
|| thread_id
.as_ref()
.is_some_and(|id| excluded_thread_ids.is_some_and(|excluded| excluded.contains(id)))
{
continue;
}
if invalid_json_lines > 0 {
scan.scan_failures.push(format!(
"会话文件包含 {invalid_json_lines} 行无法解析的 JSON: {}",
@@ -353,6 +416,7 @@ fn scan_rollouts_with_thread_filter(
continue;
}
scan.session_meta_count += file_session_meta_count;
scan.thread_ids.insert(thread_id.clone());
if let Some(cwd) = cwd {
scan.cwd_by_thread_id.insert(thread_id.clone(), cwd);
}
@@ -915,15 +979,7 @@ pub(super) fn sqlite_subagent_thread_ids(
.map(str::trim)
.filter(|value| !value.is_empty())
{
let source_is_subagent = source.eq_ignore_ascii_case("subagent")
|| serde_json::from_str::<Value>(source)
.ok()
.is_some_and(|value| {
value
.as_object()
.is_some_and(|object| object.contains_key("subagent"))
});
if source_is_subagent {
if source_text_is_subagent(source) {
ids.insert(id);
} else {
ids.remove(&id);
@@ -935,11 +991,29 @@ pub(super) fn sqlite_subagent_thread_ids(
Ok(ids)
}
fn source_value_is_subagent(source: &Value) -> bool {
match source {
Value::String(source) => source.trim().eq_ignore_ascii_case("subagent"),
Value::Object(source) => source.contains_key("subagent"),
_ => false,
}
}
fn source_text_is_subagent(source: &str) -> bool {
let source = source.trim();
source.eq_ignore_ascii_case("subagent")
|| serde_json::from_str::<Value>(source)
.ok()
.as_ref()
.is_some_and(source_value_is_subagent)
}
pub(super) struct SqliteThreadIndexState<'a> {
pub(super) thread_id: &'a str,
pub(super) provider: Option<&'a str>,
pub(super) cwd: Option<&'a str>,
pub(super) cwd_column: bool,
pub(super) archived: bool,
}
pub(super) fn sqlite_thread_needs_alignment(
@@ -947,6 +1021,9 @@ pub(super) fn sqlite_thread_needs_alignment(
target_provider: &str,
state: &SqliteThreadIndexState<'_>,
) -> bool {
if state.archived {
return false;
}
if state.provider.map(str::trim).unwrap_or_default() != target_provider {
return true;
}
@@ -979,6 +1056,8 @@ pub(super) fn scan_sqlite_with_paths(
) -> Result<SqliteScan> {
let mut scan = SqliteScan::default();
let mut thread_ids = HashSet::new();
let mut syncable_thread_ids = HashSet::new();
let mut archived_thread_ids = HashSet::new();
let mut rollout_paths_by_thread_id = HashMap::new();
let mut subagent_ids = HashSet::new();
let mut mismatched_ids = HashSet::new();
@@ -1008,8 +1087,10 @@ pub(super) fn scan_sqlite_with_paths(
scan.sqlite_dbs += 1;
let cwd_col = sql_select_column(&cols, "cwd", "NULL");
let rollout_col = sql_select_column(&cols, "rollout_path", "NULL");
let query =
format!("SELECT \"id\", \"model_provider\", {cwd_col}, {rollout_col} FROM threads");
let archived_col = sql_select_column(&cols, "archived", "0");
let query = format!(
"SELECT \"id\", \"model_provider\", {cwd_col}, {rollout_col}, {archived_col} FROM threads"
);
let mut stmt = conn
.prepare(&query)
.map_err(|e| CodexxError::Database(e.to_string()))?;
@@ -1020,18 +1101,25 @@ pub(super) fn scan_sqlite_with_paths(
row.get::<_, Option<String>>(1)?,
row.get::<_, Option<String>>(2)?,
row.get::<_, Option<String>>(3)?,
row.get::<_, i64>(4)?,
))
})
.map_err(|e| CodexxError::Database(e.to_string()))?;
for row in rows {
let (id, provider, cwd, rollout_path) =
let (id, provider, cwd, rollout_path, archived) =
row.map_err(|e| CodexxError::Database(e.to_string()))?;
thread_ids.insert(id.clone());
if let Some(rollout_path) = rollout_path
.map(|path| path.trim().to_string())
.filter(|path| !path.is_empty())
{
rollout_paths_by_thread_id.insert(id.clone(), rollout_path);
let archived = archived != 0;
if archived {
archived_thread_ids.insert(id.clone());
} else {
syncable_thread_ids.insert(id.clone());
if let Some(rollout_path) = rollout_path
.map(|path| path.trim().to_string())
.filter(|path| !path.is_empty())
{
rollout_paths_by_thread_id.insert(id.clone(), rollout_path);
}
}
if sqlite_thread_needs_alignment(
rollouts,
@@ -1041,6 +1129,7 @@ pub(super) fn scan_sqlite_with_paths(
provider: provider.as_deref(),
cwd: cwd.as_deref(),
cwd_column: cols.contains("cwd"),
archived,
},
) {
mismatched_ids.insert(id);
@@ -1049,11 +1138,17 @@ pub(super) fn scan_sqlite_with_paths(
subagent_ids.extend(sqlite_subagent_thread_ids(&conn, &cols)?);
}
subagent_ids.retain(|id| thread_ids.contains(id));
syncable_thread_ids.retain(|id| !subagent_ids.contains(id));
rollout_paths_by_thread_id.retain(|id, _| !subagent_ids.contains(id));
mismatched_ids.retain(|id| !subagent_ids.contains(id));
scan.sqlite_threads = thread_ids.len();
scan.subagent_threads = subagent_ids.len();
scan.top_level_threads = thread_ids.len().saturating_sub(subagent_ids.len());
scan.mismatched_threads = mismatched_ids.len();
scan.thread_ids = thread_ids;
scan.syncable_thread_ids = syncable_thread_ids;
scan.archived_thread_ids = archived_thread_ids;
scan.subagent_thread_ids = subagent_ids;
scan.rollout_paths_by_thread_id = rollout_paths_by_thread_id;
scan.mismatched_thread_ids = mismatched_ids;
Ok(scan)
@@ -1160,18 +1255,22 @@ pub(super) fn list_session_previews_with_paths(
.as_ref()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty());
let needs_sync = rollouts.mismatched_thread_ids.contains(&id)
|| sqlite_thread_needs_alignment(
rollouts,
target_provider,
&SqliteThreadIndexState {
thread_id: &id,
provider: normalized_provider.as_deref(),
cwd: normalized_cwd.as_deref(),
cwd_column: cols.contains("cwd"),
},
);
let is_archived = archived != 0;
let is_subagent = subagent_thread_ids.contains(&id);
let needs_sync = !is_archived
&& !is_subagent
&& (rollouts.mismatched_thread_ids.contains(&id)
|| sqlite_thread_needs_alignment(
rollouts,
target_provider,
&SqliteThreadIndexState {
thread_id: &id,
provider: normalized_provider.as_deref(),
cwd: normalized_cwd.as_deref(),
cwd_column: cols.contains("cwd"),
archived: is_archived,
},
));
Ok(SessionPreview {
id,
title: clean_title,
@@ -1183,7 +1282,7 @@ pub(super) fn list_session_previews_with_paths(
cwd: normalized_cwd,
rollout_path: normalized_rollout_path,
updated_at_ms: updated_at_ms.or_else(|| updated_at.map(|v| v * 1000)),
archived: archived != 0,
archived: is_archived,
has_user_event: has_user_event != 0,
is_subagent,
needs_sync,
+705 -14
View File
@@ -2,7 +2,7 @@ use super::backup::{create_provider_sync_backup, prune_provider_sync_backups};
use super::catalog::{scan_catalog_sync, CatalogSyncScan};
use super::storage::{
current_model_provider, discover_sqlite_databases, ensure_sqlite_discovery_writable,
list_session_previews_with_paths, scan_rollouts, scan_rollouts_for_thread_ids,
list_session_previews_with_paths, scan_provider_rollouts, scan_rollouts_for_thread_ids,
scan_sqlite_with_paths, SqliteDiscovery,
};
use super::transaction::{
@@ -35,7 +35,7 @@ fn scan_provider_buckets(
let mut rollouts = scan_rollouts_for_thread_ids(
codex_dir,
target_provider,
&sqlite.thread_ids,
&sqlite.syncable_thread_ids,
&sqlite.rollout_paths_by_thread_id,
)?;
// Provider synchronization must not repair or rewrite the independent cwd index.
@@ -75,6 +75,7 @@ struct ProviderSyncScan {
sqlite: SqliteScan,
rollouts: RolloutScan,
catalog: CatalogSyncScan,
syncable_thread_ids: HashSet<String>,
scan_failures: Vec<String>,
warnings: Vec<String>,
}
@@ -89,24 +90,52 @@ fn scan_provider_sync_data(
&RolloutScan::default(),
target_provider,
)?;
let sqlite = scan_sqlite_with_paths(
let mut sqlite = scan_sqlite_with_paths(
&discovery.thread_paths,
&RolloutScan::default(),
target_provider,
)?;
let mut syncable_thread_ids = sqlite.syncable_thread_ids.clone();
let mut archived_thread_ids = sqlite.archived_thread_ids.clone();
let mut subagent_thread_ids = sqlite.subagent_thread_ids.clone();
let mut mismatched_thread_ids = sqlite.mismatched_thread_ids.clone();
if active_sqlite.sqlite_dbs > 0 {
syncable_thread_ids.retain(|id| !active_sqlite.thread_ids.contains(id));
archived_thread_ids.retain(|id| !active_sqlite.thread_ids.contains(id));
subagent_thread_ids.retain(|id| !active_sqlite.thread_ids.contains(id));
mismatched_thread_ids.retain(|id| !active_sqlite.thread_ids.contains(id));
syncable_thread_ids.extend(active_sqlite.syncable_thread_ids.iter().cloned());
archived_thread_ids.extend(active_sqlite.archived_thread_ids.iter().cloned());
subagent_thread_ids.extend(active_sqlite.subagent_thread_ids.iter().cloned());
mismatched_thread_ids.extend(active_sqlite.mismatched_thread_ids.iter().cloned());
}
let mut excluded_thread_ids = archived_thread_ids.clone();
excluded_thread_ids.extend(subagent_thread_ids.iter().cloned());
// Without an active authority, conflicting legacy classifications stay conservatively excluded.
syncable_thread_ids.retain(|id| !excluded_thread_ids.contains(id));
mismatched_thread_ids.retain(|id| syncable_thread_ids.contains(id));
sqlite.syncable_thread_ids = syncable_thread_ids.clone();
sqlite.archived_thread_ids = archived_thread_ids;
sqlite.subagent_thread_ids = subagent_thread_ids;
sqlite.mismatched_thread_ids = mismatched_thread_ids;
sqlite.mismatched_threads = sqlite.mismatched_thread_ids.len();
let indexed_rollouts = if active_sqlite.sqlite_dbs > 0 {
scan_provider_buckets(codex_dir, target_provider, &active_sqlite)?
} else {
scan_provider_buckets(codex_dir, target_provider, &sqlite)?
};
let legacy_index_warnings = scan_legacy_index_warnings(codex_dir, target_provider, discovery);
let mut rollouts = scan_rollouts(codex_dir, target_provider)?;
let mut rollouts = scan_provider_rollouts(codex_dir, target_provider, &excluded_thread_ids)?;
// Provider synchronization changes provider routing only; cwd remains independently managed.
rollouts.cwd_by_thread_id.clear();
syncable_thread_ids.extend(rollouts.thread_ids.iter().cloned());
syncable_thread_ids.retain(|id| !excluded_thread_ids.contains(id));
let catalog = scan_catalog_sync(
&discovery.thread_paths,
&discovery.related_paths,
target_provider,
&syncable_thread_ids,
)?;
let mut scan_failures = discovery.active_scan_failures.clone();
@@ -143,6 +172,7 @@ fn scan_provider_sync_data(
sqlite,
rollouts,
catalog,
syncable_thread_ids,
scan_failures,
warnings,
})
@@ -217,7 +247,7 @@ pub(super) fn session_sync_status_with_discovery(
let mut mismatched_ids = sqlite_mismatch_ids.clone();
mismatched_ids.extend(scan.rollouts.mismatched_thread_ids.iter().cloned());
for session in &mut sessions {
if mismatched_ids.contains(&session.id) {
if !session.archived && !session.is_subagent && mismatched_ids.contains(&session.id) {
session.needs_sync = true;
}
}
@@ -314,13 +344,13 @@ where
if !initial_status.scan_complete {
return Err(scan_failure_error(&initial_status.scan_failures));
}
let scan = scan_provider_sync_data(&codex_dir, &target_provider, &discovery)?;
if !scan.scan_failures.is_empty() {
return Err(scan_failure_error(&scan.scan_failures));
let preflight_scan = scan_provider_sync_data(&codex_dir, &target_provider, &discovery)?;
if !preflight_scan.scan_failures.is_empty() {
return Err(scan_failure_error(&preflight_scan.scan_failures));
}
if scan.rollouts.changes.is_empty()
&& scan.sqlite.mismatched_threads == 0
&& scan.catalog.total_updates() == 0
if preflight_scan.rollouts.changes.is_empty()
&& preflight_scan.sqlite.mismatched_threads == 0
&& preflight_scan.catalog.total_updates() == 0
{
return Ok(SessionSyncResult {
status: initial_status,
@@ -330,13 +360,38 @@ where
});
}
hook(MutationPoint::BeforeSqliteLock)?;
let mut pending_sqlite = prepare_sqlite_updates(&discovery.related_paths)?;
let scan = match scan_provider_sync_data(&codex_dir, &target_provider, &discovery) {
Ok(scan) => scan,
Err(error) => {
rollback_open_transactions(&mut pending_sqlite);
return Err(error);
}
};
if !scan.scan_failures.is_empty() {
rollback_open_transactions(&mut pending_sqlite);
return Err(scan_failure_error(&scan.scan_failures));
}
if scan.rollouts.changes.is_empty()
&& scan.sqlite.mismatched_threads == 0
&& scan.catalog.total_updates() == 0
{
rollback_open_transactions(&mut pending_sqlite);
let status = session_sync_status_with_discovery(&codex_dir, target_provider, &discovery)?;
return Ok(SessionSyncResult {
status,
updated_rollouts: 0,
updated_threads: 0,
backup_dir: String::new(),
});
}
let changed_rollouts = scan
.rollouts
.changes
.iter()
.map(|change| change.path.clone())
.collect::<Vec<_>>();
let mut pending_sqlite = prepare_sqlite_updates(&discovery.related_paths)?;
let sqlite_snapshot_paths = pending_sqlite
.iter()
.map(|update| update.path().to_path_buf())
@@ -359,6 +414,7 @@ where
&mut pending_sqlite,
&target_provider,
&scan.catalog.sources,
&scan.syncable_thread_ids,
&mut journal,
&mut hook,
);
@@ -434,7 +490,8 @@ mod tests {
"CREATE TABLE threads (
id TEXT PRIMARY KEY,
model_provider TEXT NOT NULL,
title TEXT
title TEXT,
archived INTEGER NOT NULL DEFAULT 0
);",
)
.expect("create threads table");
@@ -455,7 +512,8 @@ mod tests {
id TEXT PRIMARY KEY,
model_provider TEXT NOT NULL,
title TEXT,
rollout_path TEXT
rollout_path TEXT,
archived INTEGER NOT NULL DEFAULT 0
);",
)
.expect("create threads table with rollout path");
@@ -546,6 +604,29 @@ mod tests {
.expect("read thread provider")
}
fn thread_provider_and_archived(path: &Path, id: &str) -> (String, i64) {
Connection::open(path)
.expect("open session database")
.query_row(
"SELECT model_provider, archived FROM threads WHERE id = ?1",
[id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.expect("read thread provider and archive state")
}
fn catalog_provider_and_visibility(path: &Path, id: &str) -> Option<(String, i64)> {
Connection::open(path)
.expect("open catalog database")
.query_row(
"SELECT model_provider, missing_candidate FROM local_thread_catalog \
WHERE thread_id = ?1",
[id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.ok()
}
fn write_rollout(codex_dir: &Path, id: &str, provider: &str) -> std::path::PathBuf {
let path = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
write_rollout_at(&path, id, provider);
@@ -563,6 +644,34 @@ mod tests {
.expect("write rollout");
}
fn write_subagent_rollout(
codex_dir: &Path,
id: &str,
parent_id: &str,
provider: &str,
) -> std::path::PathBuf {
let path = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
fs::create_dir_all(path.parent().expect("rollout parent")).expect("create rollout parent");
let record = serde_json::json!({
"type": "session_meta",
"payload": {
"id": id,
"model_provider": provider,
"source": {
"subagent": {
"thread_spawn": {
"parent_thread_id": parent_id,
"depth": 1,
"agent_path": "/root/test-agent"
}
}
}
}
});
fs::write(&path, format!("{record}\n")).expect("write subagent rollout");
path
}
#[test]
fn live_provider_is_used_as_the_sync_target() {
let codex_dir = temp_codex_dir("live-provider-target");
@@ -963,6 +1072,68 @@ mod tests {
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn active_user_thread_overrides_stale_legacy_subagent_marker() {
let codex_dir = temp_codex_dir("active-user-legacy-subagent");
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
let id = "019f6000-0000-7000-8000-000000000575";
let active = codex_dir.join("state_5.sqlite");
let legacy = codex_dir.join("sqlite/state_5.sqlite");
for database in [&active, &legacy] {
create_thread_database(database, id, "openai");
Connection::open(database)
.expect("open thread classification database")
.execute_batch("ALTER TABLE threads ADD COLUMN thread_source TEXT;")
.expect("add thread source column");
}
Connection::open(&active)
.expect("open active user database")
.execute(
"UPDATE threads SET thread_source = 'user' WHERE id = ?1",
[id],
)
.expect("mark active thread as user");
Connection::open(&legacy)
.expect("open legacy subagent database")
.execute(
"UPDATE threads SET thread_source = 'subagent' WHERE id = ?1",
[id],
)
.expect("mark legacy thread as subagent");
let rollout = write_rollout(&codex_dir, id, "openai");
let catalog = codex_dir.join("sqlite/codex-dev.db");
create_catalog_database(&catalog, &[(id, "openai")]);
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
.expect("scan active user with stale legacy subagent marker");
assert!(status.scan_complete, "{:?}", status.scan_failures);
assert!(status.needs_sync);
assert_eq!(status.subagent_threads, 0);
assert_eq!(status.mismatched_threads, 1);
assert_eq!(status.mismatched_sessions, 1);
let preview = status
.sessions
.iter()
.find(|session| session.id == id)
.expect("active user preview");
assert!(!preview.is_subagent);
assert!(preview.needs_sync);
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
.expect("synchronize authoritative active user thread");
assert!(!result.status.needs_sync);
assert_eq!(result.updated_rollouts, 1);
assert!(result.updated_threads > 0);
assert_eq!(thread_provider(&active, id), SHARED_SESSION_PROVIDER);
assert_eq!(thread_provider(&legacy, id), SHARED_SESSION_PROVIDER);
assert_eq!(catalog_provider(&catalog, id), SHARED_SESSION_PROVIDER);
assert!(fs::read_to_string(rollout)
.expect("read synchronized active user rollout")
.contains("\"model_provider\":\"custom\""));
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn stale_legacy_rollout_reference_warns_without_blocking_active_sync() {
let codex_dir = temp_codex_dir("stale-legacy-rollout");
@@ -1048,6 +1219,47 @@ mod tests {
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn conflicting_legacy_archive_state_is_not_syncable_without_active_database() {
let codex_dir = temp_codex_dir("legacy-archive-conflict");
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
let id = "019f6000-0000-7000-8000-000000000522";
let archived_database = codex_dir.join("sqlite/state_4.sqlite");
let active_database = codex_dir.join("sqlite/state_5.sqlite");
create_thread_database(&archived_database, id, "openai");
create_thread_database(&active_database, id, "openai");
Connection::open(&archived_database)
.expect("open archived legacy database")
.execute("UPDATE threads SET archived = 1 WHERE id = ?1", [id])
.expect("archive legacy thread");
let rollout = write_rollout(&codex_dir, id, "openai");
let original_rollout = fs::read(&rollout).expect("read conflicting legacy rollout");
let catalog = codex_dir.join("sqlite/catalog.db");
create_catalog_database(&catalog, &[(id, "openai")]);
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
.expect("scan conflicting legacy archive state");
assert!(status.scan_complete, "{:?}", status.scan_failures);
assert!(!status.needs_sync);
assert_eq!(status.mismatched_sessions, 0);
assert!(status.sessions.iter().all(|session| !session.needs_sync));
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
.expect("conflicting legacy state is a no-op");
assert_eq!(result.updated_rollouts, 0);
assert_eq!(result.updated_threads, 0);
assert!(result.backup_dir.is_empty());
assert_eq!(thread_provider(&archived_database, id), "openai");
assert_eq!(thread_provider(&active_database, id), "openai");
assert_eq!(catalog_provider(&catalog, id), "openai");
assert_eq!(
fs::read(rollout).expect("read preserved conflicting legacy rollout"),
original_rollout
);
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn orphan_rollout_is_synchronized_like_codex_plusplus() {
let codex_dir = temp_codex_dir("orphan-rollout");
@@ -1078,6 +1290,485 @@ mod tests {
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn active_orphan_catalog_row_is_synchronized() {
let codex_dir = temp_codex_dir("orphan-catalog");
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
let indexed_id = "019f6000-0000-7000-8000-000000000539";
create_thread_database(
&codex_dir.join("state_5.sqlite"),
indexed_id,
SHARED_SESSION_PROVIDER,
);
let orphan_id = "019f6000-0000-7000-8000-000000000540";
let orphan = write_rollout(&codex_dir, orphan_id, SHARED_SESSION_PROVIDER);
let original_rollout = fs::read(&orphan).expect("read matching orphan rollout");
let catalog = codex_dir.join("sqlite/codex-dev.db");
create_catalog_database(
&catalog,
&[(indexed_id, SHARED_SESSION_PROVIDER), (orphan_id, "openai")],
);
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
.expect("scan orphan catalog mismatch");
assert!(status.scan_complete, "{:?}", status.scan_failures);
assert!(status.needs_sync);
assert_eq!(status.mismatched_rollouts, 0);
assert_eq!(status.mismatched_sessions, 1);
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
.expect("synchronize orphan catalog row");
assert_eq!(result.updated_rollouts, 0);
assert_eq!(result.updated_threads, 1);
assert!(!result.status.needs_sync);
assert_eq!(
catalog_provider(&catalog, orphan_id),
SHARED_SESSION_PROVIDER
);
assert_eq!(
fs::read(orphan).expect("read preserved matching orphan rollout"),
original_rollout
);
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn archived_thread_is_visible_but_not_a_sync_candidate() {
let codex_dir = temp_codex_dir("archived-not-syncable");
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
let id = "019f6000-0000-7000-8000-000000000533";
let rollout = codex_dir.join(format!(
"archived_sessions/2026/08/18/rollout-test-{id}.jsonl"
));
write_rollout_at(&rollout, id, "openai");
let original_rollout = fs::read(&rollout).expect("read archived rollout");
let state = codex_dir.join("state_5.sqlite");
create_thread_database_with_rollout(&state, id, "openai", &rollout);
Connection::open(&state)
.expect("open archived thread database")
.execute("UPDATE threads SET archived = 1 WHERE id = ?1", [id])
.expect("archive thread");
let catalog = codex_dir.join("sqlite/codex-dev.db");
create_catalog_database(&catalog, &[(id, "openai")]);
Connection::open(&catalog)
.expect("open archived catalog")
.execute(
"UPDATE local_thread_catalog SET missing_candidate = 1 WHERE thread_id = ?1",
[id],
)
.expect("hide archived catalog row");
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
.expect("scan archived thread");
assert!(status.scan_complete, "{:?}", status.scan_failures);
assert!(!status.needs_sync);
assert_eq!(status.mismatched_threads, 0);
assert_eq!(status.mismatched_rollouts, 0);
assert_eq!(status.mismatched_sessions, 0);
let preview = status
.sessions
.iter()
.find(|session| session.id == id)
.expect("archived preview remains visible");
assert!(preview.archived);
assert!(!preview.needs_sync);
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
.expect("archived-only sync is a no-op");
assert_eq!(result.updated_rollouts, 0);
assert_eq!(result.updated_threads, 0);
assert!(result.backup_dir.is_empty());
assert_eq!(
thread_provider_and_archived(&state, id),
("openai".to_string(), 1)
);
assert_eq!(
catalog_provider_and_visibility(&catalog, id),
Some(("openai".to_string(), 1))
);
assert_eq!(
fs::read(&rollout).expect("read preserved archived rollout"),
original_rollout
);
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn subagents_are_visible_but_excluded_from_provider_sync() {
let codex_dir = temp_codex_dir("subagents-not-syncable");
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
let parent_id = "019f6000-0000-7000-8000-000000000570";
let edge_child_id = "019f6000-0000-7000-8000-000000000571";
let source_child_id = "019f6000-0000-7000-8000-000000000572";
let parent_rollout = write_rollout(&codex_dir, parent_id, SHARED_SESSION_PROVIDER);
let edge_child_rollout =
write_subagent_rollout(&codex_dir, edge_child_id, parent_id, "openai");
let source_child_rollout =
write_subagent_rollout(&codex_dir, source_child_id, parent_id, "openai");
let edge_child_original = fs::read(&edge_child_rollout).expect("read edge child rollout");
let source_child_original =
fs::read(&source_child_rollout).expect("read source child rollout");
let state = codex_dir.join("state_5.sqlite");
create_thread_database_with_rollout(
&state,
parent_id,
SHARED_SESSION_PROVIDER,
&parent_rollout,
);
let state_conn = Connection::open(&state).expect("open subagent thread database");
state_conn
.execute_batch(
"ALTER TABLE threads ADD COLUMN thread_source TEXT;
ALTER TABLE threads ADD COLUMN source TEXT;
CREATE TABLE thread_spawn_edges (
parent_thread_id TEXT NOT NULL,
child_thread_id TEXT NOT NULL
);",
)
.expect("add subagent metadata");
state_conn
.execute(
"INSERT INTO threads (id, model_provider, title, rollout_path)
VALUES (?1, 'openai', 'edge child', ?2)",
(edge_child_id, edge_child_rollout.display().to_string()),
)
.expect("insert edge child thread");
state_conn
.execute(
"INSERT INTO thread_spawn_edges (parent_thread_id, child_thread_id)
VALUES (?1, ?2)",
(parent_id, edge_child_id),
)
.expect("insert child spawn edge");
let source = serde_json::json!({
"subagent": {
"thread_spawn": {
"parent_thread_id": parent_id,
"depth": 1
}
}
})
.to_string();
state_conn
.execute(
"INSERT INTO threads (id, model_provider, title, rollout_path, source)
VALUES (?1, 'openai', 'source child', ?2, ?3)",
(
source_child_id,
source_child_rollout.display().to_string(),
source,
),
)
.expect("insert source child thread");
drop(state_conn);
let catalog = codex_dir.join("sqlite/codex-dev.db");
create_catalog_database(
&catalog,
&[
(parent_id, SHARED_SESSION_PROVIDER),
(edge_child_id, "openai"),
(source_child_id, "openai"),
],
);
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
.expect("scan subagent threads");
assert!(status.scan_complete, "{:?}", status.scan_failures);
assert!(!status.needs_sync);
assert_eq!(status.sqlite_threads, 3);
assert_eq!(status.top_level_threads, 1);
assert_eq!(status.subagent_threads, 2);
assert_eq!(status.mismatched_threads, 0);
assert_eq!(status.mismatched_rollouts, 0);
assert_eq!(status.mismatched_session_meta, 0);
assert_eq!(status.mismatched_sessions, 0);
for child_id in [edge_child_id, source_child_id] {
let preview = status
.sessions
.iter()
.find(|session| session.id == child_id)
.expect("subagent preview remains visible");
assert!(preview.is_subagent);
assert!(!preview.needs_sync);
}
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
.expect("subagent-only mismatch is a no-op");
assert_eq!(result.updated_rollouts, 0);
assert_eq!(result.updated_threads, 0);
assert!(result.backup_dir.is_empty());
assert_eq!(thread_provider(&state, edge_child_id), "openai");
assert_eq!(thread_provider(&state, source_child_id), "openai");
assert_eq!(catalog_provider(&catalog, edge_child_id), "openai");
assert_eq!(catalog_provider(&catalog, source_child_id), "openai");
assert_eq!(
fs::read(edge_child_rollout).expect("read preserved edge child rollout"),
edge_child_original
);
assert_eq!(
fs::read(source_child_rollout).expect("read preserved source child rollout"),
source_child_original
);
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn rollout_only_subagent_source_is_excluded_from_provider_sync() {
let codex_dir = temp_codex_dir("rollout-only-subagent");
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
let parent_id = "019f6000-0000-7000-8000-000000000573";
let child_id = "019f6000-0000-7000-8000-000000000574";
create_thread_database(
&codex_dir.join("state_5.sqlite"),
parent_id,
SHARED_SESSION_PROVIDER,
);
write_rollout(&codex_dir, parent_id, SHARED_SESSION_PROVIDER);
let child_rollout = write_subagent_rollout(&codex_dir, child_id, parent_id, "openai");
let child_original = fs::read(&child_rollout).expect("read rollout-only subagent");
let catalog = codex_dir.join("sqlite/codex-dev.db");
create_catalog_database(
&catalog,
&[(parent_id, SHARED_SESSION_PROVIDER), (child_id, "openai")],
);
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
.expect("scan rollout-only subagent");
assert!(status.scan_complete, "{:?}", status.scan_failures);
assert!(!status.needs_sync);
assert_eq!(status.mismatched_threads, 0);
assert_eq!(status.mismatched_rollouts, 0);
assert_eq!(status.mismatched_session_meta, 0);
assert_eq!(status.mismatched_sessions, 0);
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
.expect("rollout-only subagent is a no-op");
assert_eq!(result.updated_rollouts, 0);
assert_eq!(result.updated_threads, 0);
assert!(result.backup_dir.is_empty());
assert_eq!(catalog_provider(&catalog, child_id), "openai");
assert_eq!(
fs::read(child_rollout).expect("read preserved rollout-only subagent"),
child_original
);
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn archive_between_scan_and_sqlite_lock_is_excluded_from_sync() {
let codex_dir = temp_codex_dir("archive-during-sync");
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
let id = "019f6000-0000-7000-8000-000000000543";
let rollout = write_rollout(&codex_dir, id, "openai");
let original_rollout = fs::read(&rollout).expect("read pre-archive rollout");
let state = codex_dir.join("state_5.sqlite");
create_thread_database_with_rollout(&state, id, "openai", &rollout);
let catalog = codex_dir.join("sqlite/codex-dev.db");
create_catalog_database(&catalog, &[(id, "openai")]);
let mut archive_hook_called = false;
let result = sync_sessions_provider_with_hook(
Some(codex_dir.display().to_string()),
None,
|point| match point {
MutationPoint::BeforeSqliteLock => {
archive_hook_called = true;
Connection::open(&state)
.expect("open thread database before sync lock")
.execute("UPDATE threads SET archived = 1 WHERE id = ?1", [id])
.expect("archive thread before sync lock");
Connection::open(&catalog)
.expect("open catalog before sync lock")
.execute(
"UPDATE local_thread_catalog SET missing_candidate = 1 \
WHERE thread_id = ?1",
[id],
)
.expect("hide catalog row before sync lock");
Ok(())
}
_ => Ok(()),
},
)
.expect("newly archived thread is a no-op");
assert!(archive_hook_called);
assert_eq!(result.updated_rollouts, 0);
assert_eq!(result.updated_threads, 0);
assert!(result.backup_dir.is_empty());
assert!(!result.status.needs_sync);
let preview = result
.status
.sessions
.iter()
.find(|session| session.id == id)
.expect("newly archived preview remains visible");
assert!(preview.archived);
assert!(!preview.needs_sync);
assert_eq!(
thread_provider_and_archived(&state, id),
("openai".to_string(), 1)
);
assert_eq!(
catalog_provider_and_visibility(&catalog, id),
Some(("openai".to_string(), 1))
);
assert_eq!(
fs::read(rollout).expect("read preserved newly archived rollout"),
original_rollout
);
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn sync_repairs_active_thread_without_mutating_archived_storage_or_catalog() {
let codex_dir = temp_codex_dir("mixed-active-archived");
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
let active_id = "019f6000-0000-7000-8000-000000000534";
let hidden_archived_id = "019f6000-0000-7000-8000-000000000535";
let missing_archived_id = "019f6000-0000-7000-8000-000000000536";
let active_rollout = write_rollout(&codex_dir, active_id, "openai");
let hidden_archived_rollout = codex_dir.join(format!(
"archived_sessions/2026/08/18/rollout-test-{hidden_archived_id}.jsonl"
));
let missing_archived_rollout = codex_dir.join(format!(
"archived_sessions/2026/08/18/rollout-test-{missing_archived_id}.jsonl"
));
write_rollout_at(&hidden_archived_rollout, hidden_archived_id, "openai");
write_rollout_at(&missing_archived_rollout, missing_archived_id, "openai");
let hidden_original =
fs::read(&hidden_archived_rollout).expect("read hidden archived rollout");
let missing_original =
fs::read(&missing_archived_rollout).expect("read missing archived rollout");
let state = codex_dir.join("state_5.sqlite");
create_thread_database_with_rollout(&state, active_id, "openai", &active_rollout);
let state_conn = Connection::open(&state).expect("open mixed thread database");
for (id, rollout) in [
(hidden_archived_id, &hidden_archived_rollout),
(missing_archived_id, &missing_archived_rollout),
] {
state_conn
.execute(
"INSERT INTO threads (id, model_provider, title, rollout_path, archived) \
VALUES (?1, 'openai', 'archived', ?2, 1)",
(id, rollout.display().to_string()),
)
.expect("insert archived thread");
}
drop(state_conn);
let catalog = codex_dir.join("sqlite/codex-dev.db");
create_catalog_database(
&catalog,
&[(active_id, "openai"), (hidden_archived_id, "openai")],
);
Connection::open(&catalog)
.expect("open mixed catalog")
.execute(
"UPDATE local_thread_catalog SET missing_candidate = 1 WHERE thread_id = ?1",
[hidden_archived_id],
)
.expect("hide archived catalog row");
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
.expect("scan mixed threads");
assert!(status.scan_complete, "{:?}", status.scan_failures);
assert!(status.needs_sync);
assert_eq!(status.mismatched_sessions, 1);
assert!(status
.sessions
.iter()
.filter(|session| session.archived)
.all(|session| !session.needs_sync));
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
.expect("sync active thread only");
assert_eq!(result.updated_rollouts, 1);
assert!(!result.status.needs_sync);
assert_eq!(thread_provider(&state, active_id), SHARED_SESSION_PROVIDER);
assert_eq!(
thread_provider_and_archived(&state, hidden_archived_id),
("openai".to_string(), 1)
);
assert_eq!(
thread_provider_and_archived(&state, missing_archived_id),
("openai".to_string(), 1)
);
assert!(fs::read_to_string(&active_rollout)
.expect("read synchronized active rollout")
.contains("\"model_provider\":\"custom\""));
assert_eq!(
fs::read(&hidden_archived_rollout).expect("read preserved hidden archived rollout"),
hidden_original
);
assert_eq!(
fs::read(&missing_archived_rollout).expect("read preserved missing archived rollout"),
missing_original
);
assert_eq!(
catalog_provider(&catalog, active_id),
SHARED_SESSION_PROVIDER
);
assert_eq!(
catalog_provider_and_visibility(&catalog, hidden_archived_id),
Some(("openai".to_string(), 1))
);
assert_eq!(
catalog_provider_and_visibility(&catalog, missing_archived_id),
None
);
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn archived_orphan_rollout_is_ignored_while_active_orphan_still_syncs() {
let codex_dir = temp_codex_dir("orphan-storage-scope");
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
let indexed_id = "019f6000-0000-7000-8000-000000000537";
create_thread_database(
&codex_dir.join("state_5.sqlite"),
indexed_id,
SHARED_SESSION_PROVIDER,
);
let active_orphan_id = "019f6000-0000-7000-8000-000000000538";
let archived_orphan_id = "019f6000-0000-7000-8000-000000000539";
let active_orphan = write_rollout(&codex_dir, active_orphan_id, "openai");
let archived_orphan = codex_dir.join(format!(
"archived_sessions/2026/08/18/rollout-test-{archived_orphan_id}.jsonl"
));
write_rollout_at(&archived_orphan, archived_orphan_id, "openai");
let archived_original = fs::read(&archived_orphan).expect("read archived orphan");
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
.expect("scan orphan storage scope");
assert!(status.scan_complete, "{:?}", status.scan_failures);
assert_eq!(status.mismatched_rollouts, 1);
assert_eq!(status.mismatched_sessions, 1);
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
.expect("sync active orphan only");
assert_eq!(result.updated_rollouts, 1);
assert!(!result.status.needs_sync);
assert!(fs::read_to_string(active_orphan)
.expect("read synchronized active orphan")
.contains("\"model_provider\":\"custom\""));
assert_eq!(
fs::read(archived_orphan).expect("read preserved archived orphan"),
archived_original
);
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn mismatch_count_is_the_union_of_active_session_ids() {
let codex_dir = temp_codex_dir("mismatch-union");
@@ -177,54 +177,73 @@ fn apply_sqlite_updates(
rollouts: &RolloutScan,
target_provider: &str,
catalog_sources: &HashMap<String, CatalogRepairThread>,
syncable_thread_ids: &HashSet<String>,
) -> Result<()> {
let mut sorted_thread_ids = syncable_thread_ids.iter().collect::<Vec<_>>();
sorted_thread_ids.sort();
for update in pending.iter_mut() {
if update.thread_columns.contains("id") && update.thread_columns.contains("model_provider")
{
update
.conn
.execute(
"INSERT INTO temp.codexx_session_rollback
let archived_filter = if update.thread_columns.contains("archived") {
" AND COALESCE(archived, 0) = 0"
} else {
""
};
let snapshot_sql = format!(
"INSERT INTO temp.codexx_session_rollback
(id, model_provider, provider_changed)
SELECT id, model_provider, 1 FROM threads
WHERE COALESCE(model_provider, '') <> ?1
WHERE id = ?2 AND COALESCE(model_provider, '') <> ?1{archived_filter}
ON CONFLICT(id) DO UPDATE SET
model_provider = excluded.model_provider,
provider_changed = 1",
[target_provider],
)
.map_err(|error| CodexxError::Database(error.to_string()))?;
update.counts.provider_rows += update
.conn
.execute(
"UPDATE threads SET model_provider = ?1 \
WHERE COALESCE(model_provider, '') <> ?1",
[target_provider],
)
.map_err(|error| CodexxError::Database(error.to_string()))?;
provider_changed = 1"
);
let update_sql = format!(
"UPDATE threads SET model_provider = ?1 \
WHERE id = ?2 AND COALESCE(model_provider, '') <> ?1{archived_filter}"
);
for thread_id in &sorted_thread_ids {
update
.conn
.execute(&snapshot_sql, (target_provider, thread_id))
.map_err(|error| CodexxError::Database(error.to_string()))?;
update.counts.provider_rows += update
.conn
.execute(&update_sql, (target_provider, thread_id))
.map_err(|error| CodexxError::Database(error.to_string()))?;
}
}
if update.thread_columns.contains("id") && update.thread_columns.contains("cwd") {
let archived_filter = if update.thread_columns.contains("archived") {
" AND COALESCE(archived, 0) = 0"
} else {
""
};
let snapshot_sql = format!(
"INSERT INTO temp.codexx_session_rollback
(id, cwd, cwd_changed)
SELECT id, cwd, 1 FROM threads
WHERE id = ?2 AND COALESCE(cwd, '') <> ?1{archived_filter}
ON CONFLICT(id) DO UPDATE SET
cwd = excluded.cwd,
cwd_changed = 1"
);
let update_sql = format!(
"UPDATE threads SET cwd = ?1 \
WHERE id = ?2 AND COALESCE(cwd, '') <> ?1{archived_filter}"
);
for (thread_id, cwd) in &rollouts.cwd_by_thread_id {
if !syncable_thread_ids.contains(thread_id) {
continue;
}
update
.conn
.execute(
"INSERT INTO temp.codexx_session_rollback
(id, cwd, cwd_changed)
SELECT id, cwd, 1 FROM threads
WHERE id = ?2 AND COALESCE(cwd, '') <> ?1
ON CONFLICT(id) DO UPDATE SET
cwd = excluded.cwd,
cwd_changed = 1",
(cwd, thread_id),
)
.execute(&snapshot_sql, (cwd, thread_id))
.map_err(|error| CodexxError::Database(error.to_string()))?;
update.counts.cwd_rows += update
.conn
.execute(
"UPDATE threads SET cwd = ?1 WHERE id = ?2 AND COALESCE(cwd, '') <> ?1",
(cwd, thread_id),
)
.execute(&update_sql, (cwd, thread_id))
.map_err(|error| CodexxError::Database(error.to_string()))?;
}
}
@@ -233,6 +252,7 @@ fn apply_sqlite_updates(
&update.catalog_columns,
target_provider,
catalog_sources,
syncable_thread_ids,
)?;
update.counts.provider_rows += catalog_counts.provider_rows;
update.counts.catalog_insert_rows += catalog_counts.inserted_rows;
@@ -381,6 +401,7 @@ pub(super) fn mutation_error(original: CodexxError, recovery_errors: Vec<String>
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum MutationPoint {
BeforeSqliteLock,
AfterSqliteCommit(usize),
}
@@ -395,6 +416,7 @@ pub(super) fn execute_provider_sync_mutation<F>(
pending_sqlite: &mut [PendingSqliteUpdate],
target_provider: &str,
catalog_sources: &HashMap<String, CatalogRepairThread>,
syncable_thread_ids: &HashSet<String>,
journal: &mut MutationJournal,
hook: &mut F,
) -> Result<MutationResult>
@@ -404,7 +426,13 @@ where
let result = (|| -> Result<MutationResult> {
let (applied_rollouts, skipped_rollouts) = apply_session_changes(&rollouts.changes)?;
journal.applied_rollouts = applied_rollouts;
apply_sqlite_updates(pending_sqlite, rollouts, target_provider, catalog_sources)?;
apply_sqlite_updates(
pending_sqlite,
rollouts,
target_provider,
catalog_sources,
syncable_thread_ids,
)?;
let sqlite_updates = commit_sqlite_updates(pending_sqlite, journal, hook)?;
Ok(MutationResult {
applied_rollouts: journal.applied_rollouts.len(),
@@ -60,6 +60,7 @@ pub(crate) struct RolloutScan {
pub(crate) mismatched_session_meta: usize,
pub(crate) changes: Vec<SessionFileChange>,
pub(crate) cwd_by_thread_id: HashMap<String, String>,
pub(crate) thread_ids: HashSet<String>,
pub(crate) mismatched_thread_ids: HashSet<String>,
pub(crate) warnings: Vec<String>,
pub(crate) scan_failures: Vec<String>,
@@ -81,6 +82,9 @@ pub(crate) struct SqliteScan {
pub(crate) subagent_threads: usize,
pub(crate) mismatched_threads: usize,
pub(crate) thread_ids: HashSet<String>,
pub(crate) syncable_thread_ids: HashSet<String>,
pub(crate) archived_thread_ids: HashSet<String>,
pub(crate) subagent_thread_ids: HashSet<String>,
pub(crate) rollout_paths_by_thread_id: HashMap<String, String>,
pub(crate) mismatched_thread_ids: HashSet<String>,
pub(crate) warnings: Vec<String>,
+428 -39
View File
@@ -1,16 +1,25 @@
use super::build_skills_mcp_state_inner;
use super::types::{ManagedMcpServer, SkillsMcpState};
use crate::backups::create_backup;
use crate::ccswitch::default_ccswitch_db_path;
use crate::error::{CodexxError, Result};
use crate::file_io::{ensure_directory, parse_toml_document, read_to_string_if_exists, write_text};
use crate::file_io::{ensure_directory, parse_toml_document, read_to_string_if_exists};
use crate::live_config::{
acquire_live_config_lock, apply_file_change, fail_with_file_rollback, read_file_snapshot,
text_from_snapshot,
};
use crate::toml_utils::ensure_table;
use crate::{config_path, now_rfc3339, open_db, resolve_codex_dir};
use rusqlite::{params, Connection, OpenFlags};
use rusqlite::{
params, Connection, OpenFlags, OptionalExtension, Transaction, TransactionBehavior,
};
use serde_json::{json, Value};
use std::collections::HashSet;
use std::path::Path;
use toml_edit::{value, Item, Table};
type CcSwitchMcpCandidate = (String, String, Value, bool);
fn toml_value_to_json(value: &toml_edit::Value) -> Value {
if let Some(s) = value.as_str() {
return json!(s);
@@ -134,8 +143,13 @@ pub(super) fn mcp_summary(config: &Value) -> (String, Option<String>, Option<Str
(transport, command, url, summary)
}
pub(super) fn save_managed_mcp(id: &str, name: &str, config: &Value, enabled: bool) -> Result<()> {
let conn = open_db()?;
fn save_managed_mcp_on_connection(
conn: &Connection,
id: &str,
name: &str,
config: &Value,
enabled: bool,
) -> Result<()> {
conn.execute(
"INSERT INTO managed_mcp_servers (id, name, server_config, enabled, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5)
@@ -156,6 +170,83 @@ pub(super) fn save_managed_mcp(id: &str, name: &str, config: &Value, enabled: bo
Ok(())
}
pub(super) fn save_managed_mcp(id: &str, name: &str, config: &Value, enabled: bool) -> Result<()> {
let conn = open_db()?;
save_managed_mcp_on_connection(&conn, id, name, config, enabled)
}
fn managed_mcp_on_connection(conn: &Connection, id: &str) -> Result<Option<(String, Value, bool)>> {
conn.query_row(
"SELECT name, server_config, enabled FROM managed_mcp_servers WHERE id = ?1 LIMIT 1",
[id],
|row| {
let config_text: String = row.get(1)?;
Ok((
row.get(0)?,
serde_json::from_str(&config_text).unwrap_or(Value::Object(Default::default())),
row.get(2)?,
))
},
)
.optional()
.map_err(|error| CodexxError::Database(error.to_string()))
}
fn document_mcp_ids(doc: &toml_edit::DocumentMut) -> HashSet<String> {
doc.get("mcp_servers")
.and_then(|item| item.as_table())
.map(|table| {
table
.iter()
.filter(|(_, item)| item.is_table())
.map(|(id, _)| id.to_string())
.collect()
})
.unwrap_or_default()
}
fn document_bytes(snapshot: Option<&[u8]>, doc: &toml_edit::DocumentMut) -> Option<Vec<u8>> {
let bytes = doc.to_string().into_bytes();
if snapshot.is_none() && bytes.is_empty() {
None
} else {
Some(bytes)
}
}
fn commit_mcp_transaction_with_config<BeforeApply, BeforeCommit>(
transaction: Transaction<'_>,
codex_dir: &Path,
before: Option<Vec<u8>>,
after: Option<Vec<u8>>,
backup_action: &str,
before_apply: BeforeApply,
before_commit: BeforeCommit,
) -> Result<()>
where
BeforeApply: FnOnce(&Path) -> Result<()>,
BeforeCommit: FnOnce(&Transaction<'_>) -> Result<()>,
{
let cfg = config_path(codex_dir);
let mut changes = Vec::new();
if before != after {
create_backup(codex_dir, backup_action)?;
before_apply(&cfg)?;
changes.push(apply_file_change(&cfg, before, after)?);
}
if let Err(error) = before_commit(&transaction) {
return fail_with_file_rollback(error, &changes);
}
if let Err(error) = transaction
.commit()
.map_err(|error| CodexxError::Database(error.to_string()))
{
return fail_with_file_rollback(error, &changes);
}
Ok(())
}
pub(super) fn db_managed_mcp() -> Result<Vec<(String, String, Value, bool)>> {
let conn = open_db()?;
let mut stmt = conn.prepare("SELECT id, name, server_config, enabled FROM managed_mcp_servers ORDER BY name ASC, id ASC")
@@ -206,6 +297,7 @@ pub(super) fn list_mcp_from_config(codex_dir: &Path) -> Result<Vec<ManagedMcpSer
enabled: true,
source: "config.toml".to_string(),
summary,
note: None,
command,
url,
config_json: config,
@@ -264,36 +356,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)
}
@@ -355,6 +487,7 @@ pub(super) fn preview_ccswitch_mcp_servers_for_codex(
enabled: enabled_codex || live_enabled.contains(&id),
source: "cc-switch".to_string(),
summary,
note: None,
command,
url,
config_json: config,
@@ -368,31 +501,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");
}
}
+276 -2
View File
@@ -18,17 +18,74 @@ pub(crate) use types::ManagedSkill;
use crate::error::Result;
use crate::file_io::{ensure_directory, io_err};
use crate::paths::home_dir;
use crate::paths::{home_dir, normalized_path_scope};
use crate::resolve_codex_dir;
use crate::{now_rfc3339, open_db};
use mcp::{
db_managed_mcp, import_ccswitch_mcp_servers_for_codex, list_mcp_from_config, mcp_summary,
preview_ccswitch_mcp_servers_for_codex, save_managed_mcp,
};
use rusqlite::params;
use skills::{
codex_skills_dir, copy_dir_recursive, disabled_skills_dir, sanitize_dir_name, scan_skill_dir,
};
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::Path;
const SKILLS_MCP_NOTE_MAX_CHARS: usize = 1000;
fn normalized_note_item_kind(item_kind: &str) -> Result<&'static str> {
match item_kind.trim().to_ascii_lowercase().as_str() {
"skill" => Ok("skill"),
"mcp" => Ok("mcp"),
_ => Err(crate::error::CodexxError::Config(
"备注类型必须是 skill 或 mcp".to_string(),
)),
}
}
fn skills_mcp_notes(codex_dir: &Path) -> Result<HashMap<(String, String), String>> {
let conn = open_db()?;
let mut stmt = conn
.prepare(
"SELECT item_kind, item_id, note
FROM skills_mcp_notes
WHERE codex_dir = ?1
ORDER BY item_kind ASC, item_id ASC",
)
.map_err(|error| crate::error::CodexxError::Database(error.to_string()))?;
let rows = stmt
.query_map([normalized_path_scope(codex_dir)], |row| {
Ok((
(row.get::<_, String>(0)?, row.get::<_, String>(1)?),
row.get::<_, String>(2)?,
))
})
.map_err(|error| crate::error::CodexxError::Database(error.to_string()))?;
let mut notes = HashMap::new();
for row in rows {
let (key, note) =
row.map_err(|error| crate::error::CodexxError::Database(error.to_string()))?;
notes.insert(key, note);
}
Ok(notes)
}
fn attach_skills_mcp_notes(
codex_dir: &Path,
skills: &mut [types::ManagedSkill],
mcp_servers: &mut [ManagedMcpServer],
) -> Result<()> {
let notes = skills_mcp_notes(codex_dir)?;
for skill in skills {
skill.note = notes.get(&("skill".to_string(), skill.id.clone())).cloned();
}
for server in mcp_servers {
server.note = notes.get(&("mcp".to_string(), server.id.clone())).cloned();
}
Ok(())
}
fn extend_unmanaged_mcp_candidates(
output: &mut Vec<ManagedMcpServer>,
@@ -82,11 +139,13 @@ pub(crate) fn build_skills_mcp_state_inner(config_dir: Option<String>) -> Result
enabled,
source: "Codex-X".to_string(),
summary,
note: None,
command,
url,
config_json: config,
});
}
attach_skills_mcp_notes(&codex_dir, &mut skills, &mut mcp_servers)?;
sort_managed_mcp_servers(&mut mcp_servers);
sort_managed_skills(&mut skills);
Ok(SkillsMcpState {
@@ -99,6 +158,63 @@ pub(crate) fn build_skills_mcp_state_inner(config_dir: Option<String>) -> Result
})
}
pub(crate) fn save_skills_mcp_note_inner(
config_dir: Option<String>,
item_kind: String,
id: String,
note: String,
) -> Result<SkillsMcpState> {
let item_kind = normalized_note_item_kind(&item_kind)?;
if id.trim().is_empty() {
return Err(crate::error::CodexxError::Config(
"备注对象 ID 不能为空".to_string(),
));
}
let id = id.as_str();
let note = note.trim();
if note.chars().count() > SKILLS_MCP_NOTE_MAX_CHARS {
return Err(crate::error::CodexxError::Config(format!(
"备注不能超过 {SKILLS_MCP_NOTE_MAX_CHARS} 个字符"
)));
}
let codex_dir = resolve_codex_dir(config_dir.clone())?;
let codex_dir_scope = normalized_path_scope(&codex_dir);
let current = build_skills_mcp_state_inner(config_dir.clone())?;
let exists = match item_kind {
"skill" => current.skills.iter().any(|skill| skill.id == id),
"mcp" => current.mcp_servers.iter().any(|server| server.id == id),
_ => false,
};
if !exists {
return Err(crate::error::CodexxError::Config(format!(
"未找到要备注的 {item_kind}: {id}"
)));
}
let conn = open_db()?;
if note.is_empty() {
conn.execute(
"DELETE FROM skills_mcp_notes
WHERE codex_dir = ?1 AND item_kind = ?2 AND item_id = ?3",
params![codex_dir_scope, item_kind, id],
)
.map_err(|error| crate::error::CodexxError::Database(error.to_string()))?;
} else {
conn.execute(
"INSERT INTO skills_mcp_notes
(codex_dir, item_kind, item_id, note, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(codex_dir, item_kind, item_id) DO UPDATE SET
note = excluded.note,
updated_at = excluded.updated_at",
params![codex_dir_scope, item_kind, id, note, now_rfc3339()],
)
.map_err(|error| crate::error::CodexxError::Database(error.to_string()))?;
}
build_skills_mcp_state_inner(config_dir)
}
pub(crate) fn import_existing_skills_mcp_inner(
config_dir: Option<String>,
) -> Result<SkillsMcpActionResult> {
@@ -223,6 +339,7 @@ mod tests {
enabled: true,
source: source.to_string(),
summary: id.to_string(),
note: None,
command: Some(id.to_string()),
url: None,
config_json: json!({ "command": id }),
@@ -271,4 +388,161 @@ mod tests {
);
assert!(second_preview.is_empty());
}
#[test]
fn custom_notes_are_isolated_by_kind_and_codex_home_and_empty_text_clears_them() {
let _db_guard = crate::app_db::test_db_guard();
let item_id = format!("shared-note-item-{}", std::process::id());
let spaced_id = " spaced-mcp ";
let codex_dir =
std::env::temp_dir().join(format!("codex-x-note-state-{}", std::process::id()));
let other_codex_dir =
std::env::temp_dir().join(format!("codex-x-note-state-other-{}", std::process::id()));
let prepare_codex_dir = |dir: &Path| {
let _ = fs::remove_dir_all(dir);
let skill_dir = dir.join("skills").join(&item_id);
fs::create_dir_all(&skill_dir).expect("create test skill directory");
fs::write(
skill_dir.join("SKILL.md"),
format!("---\nname: {item_id}\ndescription: test\n---\n"),
)
.expect("write test skill");
fs::write(
dir.join("config.toml"),
format!(
"[mcp_servers.{item_id}]\ncommand = \"test-server\"\n\
[mcp_servers.\"{spaced_id}\"]\ncommand = \"spaced-server\"\n"
),
)
.expect("write test MCP config");
};
prepare_codex_dir(&codex_dir);
prepare_codex_dir(&other_codex_dir);
let conn = open_db().expect("open app database");
conn.execute(
"DELETE FROM skills_mcp_notes WHERE item_id IN (?1, ?2)",
params![&item_id, spaced_id],
)
.expect("clear stale test notes");
drop(conn);
let config_dir = Some(codex_dir.display().to_string());
save_skills_mcp_note_inner(
config_dir.clone(),
"skill".to_string(),
item_id.clone(),
" 我的 Skill 备注 ".to_string(),
)
.expect("save skill note");
let state = save_skills_mcp_note_inner(
config_dir.clone(),
"mcp".to_string(),
item_id.clone(),
"MCP note".to_string(),
)
.expect("save MCP note");
assert_eq!(
state
.skills
.iter()
.find(|skill| skill.id == item_id)
.and_then(|skill| skill.note.as_deref()),
Some("我的 Skill 备注")
);
assert_eq!(
state
.mcp_servers
.iter()
.find(|server| server.id == item_id)
.and_then(|server| server.note.as_deref()),
Some("MCP note")
);
let state = save_skills_mcp_note_inner(
config_dir.clone(),
"mcp".to_string(),
spaced_id.to_string(),
"Spaced ID note".to_string(),
)
.expect("save note without normalizing the MCP ID");
assert_eq!(
state
.mcp_servers
.iter()
.find(|server| server.id == spaced_id)
.and_then(|server| server.note.as_deref()),
Some("Spaced ID note")
);
let other_config_dir = Some(other_codex_dir.display().to_string());
let other_state = build_skills_mcp_state_inner(other_config_dir.clone())
.expect("load other CODEX_HOME state");
assert!(other_state
.mcp_servers
.iter()
.find(|server| server.id == item_id)
.is_some_and(|server| server.note.is_none()));
save_skills_mcp_note_inner(
other_config_dir,
"mcp".to_string(),
item_id.clone(),
"Other MCP note".to_string(),
)
.expect("save note for other CODEX_HOME");
let original_state = build_skills_mcp_state_inner(config_dir.clone())
.expect("reload original CODEX_HOME state");
assert_eq!(
original_state
.mcp_servers
.iter()
.find(|server| server.id == item_id)
.and_then(|server| server.note.as_deref()),
Some("MCP note")
);
let state = save_skills_mcp_note_inner(
config_dir.clone(),
"skill".to_string(),
item_id.clone(),
" ".to_string(),
)
.expect("clear skill note");
assert!(state
.skills
.iter()
.find(|skill| skill.id == item_id)
.is_some_and(|skill| skill.note.is_none()));
assert_eq!(
state
.mcp_servers
.iter()
.find(|server| server.id == item_id)
.and_then(|server| server.note.as_deref()),
Some("MCP note")
);
assert!(save_skills_mcp_note_inner(
config_dir.clone(),
"unknown".to_string(),
item_id.clone(),
"note".to_string(),
)
.is_err());
assert!(save_skills_mcp_note_inner(
config_dir,
"mcp".to_string(),
item_id.clone(),
"x".repeat(SKILLS_MCP_NOTE_MAX_CHARS + 1),
)
.is_err());
let conn = open_db().expect("reopen app database");
conn.execute(
"DELETE FROM skills_mcp_notes WHERE item_id IN (?1, ?2)",
params![&item_id, spaced_id],
)
.expect("remove test notes");
drop(conn);
fs::remove_dir_all(&codex_dir).expect("remove test Codex directory");
fs::remove_dir_all(&other_codex_dir).expect("remove other test Codex directory");
}
}
@@ -258,6 +258,7 @@ pub(super) fn scan_skill_dir(
id: id.clone(),
name,
description,
note: None,
directory,
enabled,
source: source.to_string(),
@@ -10,6 +10,7 @@ pub(crate) struct ManagedMcpServer {
pub(crate) enabled: bool,
pub(crate) source: String,
pub(crate) summary: String,
pub(crate) note: Option<String>,
pub(crate) command: Option<String>,
pub(crate) url: Option<String>,
pub(crate) config_json: Value,
@@ -21,6 +22,7 @@ pub(crate) struct ManagedSkill {
pub(crate) id: String,
pub(crate) name: String,
pub(crate) description: Option<String>,
pub(crate) note: Option<String>,
pub(crate) directory: String,
pub(crate) enabled: bool,
pub(crate) source: String,
+41 -22
View File
@@ -1,16 +1,15 @@
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,
};
use crate::providers::{
detected_live_custom_provider, document_is_official, list_saved_providers_inner,
official_auth_available, unique_saved_provider_id_for_live, SavedProvider,
clear_active_provider_on_connection, detected_live_custom_provider, document_is_official,
list_saved_providers_on_connection, matching_saved_provider_ids_for_live,
official_auth_available, open_store, reconcile_active_provider_on_connection, SavedProvider,
};
use crate::{auth_path, config_path, string_value};
use serde::Serialize;
@@ -33,7 +32,7 @@ struct ProviderSummary {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CodexState {
codex_dir: String,
pub(crate) codex_dir: String,
config_path: String,
auth_path: String,
config_exists: bool,
@@ -47,7 +46,7 @@ pub(crate) struct CodexState {
pub(crate) instruction_injection_mode: Option<String>,
pub(crate) instruction_template_key: Option<String>,
agents_path: String,
active_saved_provider_id: Option<String>,
pub(crate) active_saved_provider_id: Option<String>,
providers: Vec<ProviderSummary>,
pub(crate) config_text: String,
auth_preview: Option<Value>,
@@ -69,7 +68,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();
@@ -155,15 +160,15 @@ fn normalized_provider_toml_for_match(text: &str) -> String {
.to_string()
}
pub(crate) fn active_saved_provider_id_from_config(
fn matching_saved_provider_ids_from_config(
config_text: &str,
providers: &[SavedProvider],
) -> Option<String> {
) -> Vec<String> {
let live = normalized_provider_toml_for_match(config_text);
if live.is_empty() {
return None;
return Vec::new();
}
let matches = providers
providers
.iter()
.filter(|provider| {
provider
@@ -171,10 +176,20 @@ pub(crate) fn active_saved_provider_id_from_config(
.as_deref()
.is_some_and(|toml| normalized_provider_toml_for_match(toml) == live)
})
.collect::<Vec<_>>();
(matches.len() == 1).then(|| matches[0].id.clone())
.map(|provider| provider.id.clone())
.collect()
}
#[cfg(test)]
pub(crate) fn active_saved_provider_id_from_config(
config_text: &str,
providers: &[SavedProvider],
) -> Option<String> {
let matches = matching_saved_provider_ids_from_config(config_text, providers);
(matches.len() == 1).then(|| matches[0].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 +198,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");
@@ -207,13 +220,17 @@ pub(crate) fn build_state_after_migration(codex_dir: PathBuf) -> Result<CodexSta
};
let instruction_enabled = instruction_template_key.is_some();
let providers = extract_providers(&doc, model_provider.as_deref());
let saved_providers = list_saved_providers_inner()?;
let conn = open_store()?;
let saved_providers = list_saved_providers_on_connection(&conn)?;
let active_saved_provider_id = if is_official_provider {
clear_active_provider_on_connection(&conn, &codex_dir)?;
None
} else if let Some(live) = detected_live_custom_provider(&codex_dir)? {
unique_saved_provider_id_for_live(&live, &saved_providers)
let candidates = matching_saved_provider_ids_for_live(&live, &saved_providers);
reconcile_active_provider_on_connection(&conn, &codex_dir, &candidates)?
} else {
active_saved_provider_id_from_config(&text, &saved_providers)
let candidates = matching_saved_provider_ids_from_config(&text, &saved_providers);
reconcile_active_provider_on_connection(&conn, &codex_dir, &candidates)?
};
Ok(CodexState {
@@ -236,6 +253,8 @@ pub(crate) fn build_state_after_migration(codex_dir: PathBuf) -> Result<CodexSta
config_text: text,
auth_preview: redacted_auth_preview(&auth)?,
auth_text: read_to_string_if_exists(&auth)?,
last_backup: latest_backup()?,
// Backup discovery can traverse years of history. Keep core state fast;
// restore actions resolve history only when the user requests it.
last_backup: None,
})
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Codex-X",
"version": "0.3.8",
"version": "0.3.14",
"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
+662 -230
View File
File diff suppressed because it is too large Load Diff
+39 -19
View File
@@ -1,9 +1,11 @@
import {
ArrowUpRight,
CheckCircle2,
CircleAlert,
Code2,
FileText,
KeyRound,
Loader2,
RefreshCw,
Sparkles,
} from "lucide-react";
@@ -14,6 +16,7 @@ export type OverviewLanguage = "zh" | "en";
export type OverviewPageProps = {
lang: OverviewLanguage;
ready: boolean;
model?: string | null;
configDir: string;
resolvedCodexDir: string;
@@ -70,6 +73,7 @@ function ConfigRow({ label, value }: ConfigRowProps) {
export function OverviewPage({
lang,
ready,
model,
configDir,
resolvedCodexDir,
@@ -121,6 +125,8 @@ export function OverviewPage({
model: "模型",
providerName: "供应商标识",
instructionFile: "指令文件",
reading: "正在读取",
readFailed: "读取失败",
}
: {
eyebrow: "CODEX CONFIG MANAGER",
@@ -153,20 +159,29 @@ export function OverviewPage({
model: "Model",
providerName: "Provider",
instructionFile: "Instruction file",
reading: "Loading",
readFailed: "Load failed",
};
const displayModel = model?.trim() || text.modelMissing;
const displayProvider = providerLabel?.trim() || modelProvider?.trim() || text.official;
const displayModelProvider = modelProvider?.trim() || text.notConfigured;
const displayDirectory = resolvedCodexDir.trim() || configDir.trim() || text.notConfigured;
const displayConfigPath = configPath?.trim() || text.notConfigured;
const displayInstructionPath = instructionPath?.trim() || text.notConfigured;
const unresolvedStatus = loading ? text.reading : text.readFailed;
const displayModel = ready ? (model?.trim() || text.modelMissing) : unresolvedStatus;
const displayProvider = ready
? (providerLabel?.trim() || modelProvider?.trim() || text.official)
: unresolvedStatus;
const displayModelProvider = ready ? (modelProvider?.trim() || text.notConfigured) : unresolvedStatus;
const displayDirectory = ready
? (resolvedCodexDir.trim() || configDir.trim() || text.notConfigured)
: (configDir.trim() || unresolvedStatus);
const displayConfigPath = ready ? (configPath?.trim() || text.notConfigured) : unresolvedStatus;
const displayInstructionPath = ready ? (instructionPath?.trim() || text.notConfigured) : unresolvedStatus;
const updateVersion = latestVersion?.trim() || "";
const homeInputValue = configDir || resolvedCodexDir;
const authAvailable = authExists || officialAuthAvailable;
const authStatus = authExists
? text.authFile
: officialAuthAvailable ? text.officialAuth : text.noAuth;
const homeInputValue = configDir;
const authAvailable = ready && (authExists || officialAuthAvailable);
const authStatus = ready
? authExists
? text.authFile
: officialAuthAvailable ? text.officialAuth : text.noAuth
: unresolvedStatus;
return (
<section className="cx-overview-page" aria-label={isChinese ? "概览" : "Overview"}>
@@ -187,6 +202,7 @@ export function OverviewPage({
value={homeInputValue}
onChange={(event) => onConfigDirChange(event.target.value)}
placeholder={text.directoryPlaceholder}
disabled={loading}
spellCheck={false}
aria-label={text.codexHome}
/>
@@ -217,20 +233,20 @@ export function OverviewPage({
<StatusCard
icon={FileText}
label={text.config}
value={configExists ? text.found : text.missing}
tone={configExists ? "success" : "muted"}
value={ready ? (configExists ? text.found : text.missing) : unresolvedStatus}
tone={ready && configExists ? "success" : "muted"}
/>
<StatusCard
icon={Code2}
label={text.provider}
value={displayProvider}
tone={modelProvider ? "active" : "muted"}
tone={ready && modelProvider ? "active" : "muted"}
/>
<StatusCard
icon={Sparkles}
label={text.instruction}
value={instructionEnabled ? text.enabled : text.disabled}
tone={instructionEnabled ? "success" : "muted"}
value={ready ? (instructionEnabled ? text.enabled : text.disabled) : unresolvedStatus}
tone={ready && instructionEnabled ? "success" : "muted"}
/>
<StatusCard
icon={KeyRound}
@@ -246,9 +262,13 @@ export function OverviewPage({
<p className="cx-overview-section-label">{text.liveStatus}</p>
<h3>{text.currentConfig}</h3>
</div>
<span className={`cx-overview-instruction-pill${instructionEnabled ? " cx-overview-instruction-pill--active" : ""}`}>
<CheckCircle2 size={14} strokeWidth={2} aria-hidden="true" />
{instructionEnabled ? text.on : text.off}
<span className={`cx-overview-instruction-pill${ready && instructionEnabled ? " cx-overview-instruction-pill--active" : ""}`}>
{!ready && loading
? <Loader2 className="cx-overview-spin" size={14} strokeWidth={2} aria-hidden="true" />
: !ready
? <CircleAlert size={14} strokeWidth={2} aria-hidden="true" />
: <CheckCircle2 size={14} strokeWidth={2} aria-hidden="true" />}
{ready ? (instructionEnabled ? text.on : text.off) : unresolvedStatus}
</span>
</div>
+69 -24
View File
@@ -5,6 +5,8 @@ import {
AlertTriangle,
ArrowLeft,
CheckCircle2,
Copy,
Download,
Eye,
EyeOff,
FilePlus2,
@@ -36,6 +38,7 @@ export type ProviderRow = {
isCurrent: boolean;
sourceLabel?: string;
editable?: boolean;
duplicable?: boolean;
deletable?: boolean;
testable?: boolean;
testingKey?: string;
@@ -54,6 +57,7 @@ export type ProviderFormValue = {
export type OfficialFormValue = {
model: string;
authJson: string;
configText: string;
};
export type ProviderCopy = {
@@ -67,6 +71,7 @@ export type ProviderCopy = {
enableLabel: string;
testLabel: string;
editLabel: string;
duplicateLabel: string;
removeLabel: string;
deleteTitle: string;
deleteDescription: (providerName: string) => string;
@@ -81,7 +86,9 @@ export type ProviderCopy = {
authPathLabel: string;
officialCurrentLabel: string;
officialAuthLabel: string;
officialTomlLabel: string;
officialSaveLabel: string;
loadCcSwitchOfficialLabel: string;
restoreOfficialLabel: string;
resetOfficialLabel: string;
resetOfficialTitle: string;
@@ -134,6 +141,7 @@ export type ProvidersPageProps = {
providerForm: ProviderFormValue;
officialForm: OfficialFormValue;
officialAuthRef?: Ref<HTMLTextAreaElement>;
officialTomlRef?: Ref<HTMLTextAreaElement>;
officialInfo: ProviderOfficialInfo;
providerAuthPreview: ReactNode;
providerTomlDraft: string;
@@ -143,15 +151,18 @@ export type ProvidersPageProps = {
fetchingModels: boolean;
onImportCcSwitch: () => void;
onAddProvider: () => void;
onLoadCcSwitchOfficial: () => void;
onRestoreOfficial: () => void;
onResetOfficial: () => void;
onEnableProvider: (row: ProviderRow) => void;
onTestProvider: (row: ProviderRow) => void;
onEditProvider: (row: ProviderRow) => void;
onDuplicateProvider: (row: ProviderRow) => void;
onDeleteProvider: (row: ProviderRow) => Promise<boolean>;
onCancelMode: () => void;
onOfficialModelChange: (value: string) => void;
onOfficialAuthChange: (value: string) => void;
onOfficialConfigChange: (value: string) => void;
onSaveOfficial: () => void;
onApiKeyChange: (value: string) => void;
onBaseUrlChange: (value: string) => void;
@@ -235,10 +246,12 @@ function ListPage({
onEnableProvider,
onTestProvider,
onEditProvider,
onDuplicateProvider,
onDeleteProvider,
}: Pick<ProvidersPageProps, "copy" | "providerRows" | "loading" | "testingId" | "actionBusy" | "onImportCcSwitch" | "onAddProvider" | "onRestoreOfficial" | "onEnableProvider" | "onTestProvider" | "onEditProvider" | "onDeleteProvider">) {
}: Pick<ProvidersPageProps, "copy" | "providerRows" | "loading" | "testingId" | "actionBusy" | "onImportCcSwitch" | "onAddProvider" | "onRestoreOfficial" | "onEnableProvider" | "onTestProvider" | "onEditProvider" | "onDuplicateProvider" | "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 +280,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 +319,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 +328,7 @@ function ListPage({
icon={RotateCcw}
label={copy.restoreOfficialLabel}
onClick={onRestoreOfficial}
disabled={loading}
disabled={providerActionsBusy}
/>
)}
{row.testable !== false && (
@@ -323,14 +336,17 @@ 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.duplicable && (
<ActionIconButton icon={Copy} label={copy.duplicateLabel} onClick={() => onDuplicateProvider(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 +390,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 +398,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 +410,22 @@ function OfficialForm({
copy,
officialForm,
officialAuthRef,
officialTomlRef,
officialInfo,
loading,
actionBusy,
onCancelMode,
onOfficialModelChange,
onOfficialAuthChange,
onOfficialConfigChange,
onSaveOfficial,
onLoadCcSwitchOfficial,
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" | "onLoadCcSwitchOfficial" | "onRestoreOfficial" | "onResetOfficial">) {
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
const loadingCcSwitch = actionBusy === "loadCcSwitchOfficial";
const formBusy = loading || actionBusy === "loadOfficialDraft" || loadingCcSwitch;
const confirmReset = () => {
setResetConfirmOpen(false);
@@ -412,33 +434,50 @@ 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={onLoadCcSwitchOfficial} disabled={formBusy}>
{loadingCcSwitch
? <Loader2 size={15} className="cx-providers-spin" aria-hidden="true" />
: <Download size={15} aria-hidden="true" />}
{copy.loadCcSwitchOfficialLabel}
</button>
<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 +495,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 +528,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 +538,7 @@ function ProviderForm({
description={copy.formHint}
cancelLabel={copy.cancelLabel}
onCancel={onCancelMode}
disabled={formBusy}
/>
<section className="cx-providers-form-section">
@@ -512,14 +553,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 +569,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 +594,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 +606,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 +616,7 @@ function ProviderForm({
checked={providerForm.requiresOpenaiAuth}
onCheckedChange={onRequiresAuthChange}
label={copy.requiresAuthLabel}
disabled={formBusy}
/>
</div>
</section>
@@ -584,13 +629,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>
+127 -7
View File
@@ -1,4 +1,4 @@
import { useId } from "react";
import { useId, useState } from "react";
import type { ChangeEvent, RefObject } from "react";
import {
AlertCircle,
@@ -7,6 +7,7 @@ import {
FolderInput,
Loader2,
PackageOpen,
PencilLine,
PlugZap,
RefreshCw,
Sparkles,
@@ -14,11 +15,12 @@ import {
} from "lucide-react";
import { PageTransition } from "../components/PageTransition";
import { Button, ModalShell, StatusBadge, Toggle, cx } from "../components/ui";
import { Button, IconButton, ModalShell, StatusBadge, Toggle, cx } from "../components/ui";
import type { Lang, ManagedMcpServer, ManagedSkill, SkillsMcpImportPreview, SkillsMcpState } from "../types";
import "../styles/skills-prompts-pages.css";
export type SkillsMcpTab = "mcp" | "skills";
export type SkillsMcpNoteKind = "mcp" | "skill";
type MaybeAsyncAction = () => void | Promise<void>;
@@ -27,6 +29,7 @@ export type SkillsMcpPageProps = {
state: SkillsMcpState | null;
activeTab: SkillsMcpTab;
actionBusy: string;
noteBusyKey: string;
importOpen: boolean;
importPreview: SkillsMcpImportPreview | null;
zipInputRef: RefObject<HTMLInputElement>;
@@ -40,6 +43,14 @@ export type SkillsMcpPageProps = {
onCheckUpdates: MaybeAsyncAction;
onToggleSkill: (id: string, enabled: boolean) => void | Promise<void>;
onToggleMcp: (id: string, enabled: boolean) => void | Promise<void>;
onSaveNote: (itemKind: SkillsMcpNoteKind, id: string, note: string) => Promise<boolean>;
};
type NoteTarget = {
itemKind: SkillsMcpNoteKind;
id: string;
name: string;
note: string;
};
type SkillsMcpCopy = ReturnType<typeof getCopy>;
@@ -65,6 +76,16 @@ function getCopy(lang: Lang) {
enableSkill: "启用 Skill",
disableSkill: "禁用 Skill",
updateStatus: "更新状态",
addNote: "添加备注",
editNote: "编辑备注",
noteTitle: "自定义备注",
noteDescription: (name: string) => `为“${name}”添加便于识别的说明。`,
noteLabel: "备注内容",
notePlaceholder: "例如:用于查询项目文档,依赖本地 Node.js",
noteEmptyHint: "留空保存将清除现有备注",
noteCount: (count: number) => `${count} / 1000`,
saveNote: "保存备注",
savingNote: "保存中",
importTitle: "确认导入已有内容",
importDescription: "以下内容来自本机现有配置。导入后可在此统一启用或禁用。",
noImportItems: "没有发现可导入的已有 Skills / MCP。",
@@ -94,6 +115,16 @@ function getCopy(lang: Lang) {
enableSkill: "Enable Skill",
disableSkill: "Disable Skill",
updateStatus: "Update status",
addNote: "Add note",
editNote: "Edit note",
noteTitle: "Custom note",
noteDescription: (name: string) => `Add a description that helps you recognize “${name}”.`,
noteLabel: "Note",
notePlaceholder: "For example: searches project docs; requires local Node.js",
noteEmptyHint: "Save an empty note to remove it",
noteCount: (count: number) => `${count} / 1000`,
saveNote: "Save note",
savingNote: "Saving",
importTitle: "Confirm import",
importDescription: "These items were found in the existing local configuration. Import them to manage their state here.",
noImportItems: "No existing Skills / MCP items were found.",
@@ -116,22 +147,33 @@ function McpRow({
disabled,
copy,
onToggle,
onEditNote,
}: {
server: ManagedMcpServer;
busy: boolean;
disabled: boolean;
copy: SkillsMcpCopy;
onToggle: SkillsMcpPageProps["onToggleMcp"];
onEditNote: (target: NoteTarget) => void;
}) {
const name = server.name || server.id;
const details = [server.summary, server.transport, server.source].filter(Boolean).join("\n");
const details = [server.note, server.summary, server.transport, server.source].filter(Boolean).join("\n");
return (
<article className="cx-skills-row">
<div className="cx-skills-row-copy">
<div className={cx("cx-skills-row-copy", Boolean(server.note) && "cx-skills-row-copy--noted")}>
<strong title={details ? `${name}\n${details}` : name}>{name}</strong>
{server.note && <span className="cx-skills-row-note" title={server.note}>{server.note}</span>}
</div>
<div className="cx-skills-row-control">
{busy && <Loader2 size={15} className="cx-skills-spin" aria-hidden="true" />}
<IconButton
icon={<PencilLine size={15} strokeWidth={1.9} />}
label={server.note ? copy.editNote : copy.addNote}
size="sm"
className="cx-skills-note-button"
onClick={() => onEditNote({ itemKind: "mcp", id: server.id, name, note: server.note || "" })}
disabled={disabled}
/>
<Toggle
checked={server.enabled}
onCheckedChange={(enabled) => void onToggle(server.id, enabled)}
@@ -149,12 +191,14 @@ function SkillRow({
disabled,
copy,
onToggle,
onEditNote,
}: {
skill: ManagedSkill;
busy: boolean;
disabled: boolean;
copy: SkillsMcpCopy;
onToggle: SkillsMcpPageProps["onToggleSkill"];
onEditNote: (target: NoteTarget) => void;
}) {
const name = skill.name || skill.directory;
const updateTone = skill.updateStatus.includes("失败") || skill.updateStatus.toLowerCase().includes("fail")
@@ -164,20 +208,29 @@ function SkillRow({
: "neutral";
const showUpdateStatus = Boolean(skill.updateStatus && skill.updateStatus !== "未检查");
const details = [skill.description, skill.source].filter(Boolean).join("\n");
const details = [skill.note, skill.description, skill.source].filter(Boolean).join("\n");
return (
<article className="cx-skills-row">
<div className="cx-skills-row-copy">
<div className={cx("cx-skills-row-copy", Boolean(skill.note) && "cx-skills-row-copy--noted")}>
<strong title={details ? `${name}\n${details}` : name}>{name}</strong>
{skill.note && <span className="cx-skills-row-note" title={skill.note}>{skill.note}</span>}
{showUpdateStatus && (
<StatusBadge tone={updateTone} dot={false} title={copy.updateStatus}>
<StatusBadge tone={updateTone} dot={false} title={`${copy.updateStatus}: ${skill.updateStatus}`}>
{skill.updateStatus}
</StatusBadge>
)}
</div>
<div className="cx-skills-row-control">
{busy && <Loader2 size={15} className="cx-skills-spin" aria-hidden="true" />}
<IconButton
icon={<PencilLine size={15} strokeWidth={1.9} />}
label={skill.note ? copy.editNote : copy.addNote}
size="sm"
className="cx-skills-note-button"
onClick={() => onEditNote({ itemKind: "skill", id: skill.id, name, note: skill.note || "" })}
disabled={disabled}
/>
<Toggle
checked={skill.enabled}
onCheckedChange={(enabled) => void onToggle(skill.id, enabled)}
@@ -255,6 +308,7 @@ export function SkillsMcpPage({
state,
activeTab,
actionBusy,
noteBusyKey,
importOpen,
importPreview,
zipInputRef,
@@ -268,12 +322,34 @@ export function SkillsMcpPage({
onCheckUpdates,
onToggleSkill,
onToggleMcp,
onSaveNote,
}: SkillsMcpPageProps) {
const copy = getCopy(lang);
const tabId = useId();
const anyBusy = Boolean(actionBusy);
const importBusy = actionBusy === "importExistingSkillsMcp";
const activeItems = activeTab === "mcp" ? state?.mcpServers ?? [] : state?.skills ?? [];
const [noteTarget, setNoteTarget] = useState<NoteTarget | null>(null);
const [noteDraft, setNoteDraft] = useState("");
const noteBusy = noteTarget
? noteBusyKey === `note:${noteTarget.itemKind}:${noteTarget.id}`
: false;
const openNoteEditor = (target: NoteTarget) => {
setNoteTarget(target);
setNoteDraft(target.note);
};
const closeNoteEditor = () => {
if (!noteBusy) setNoteTarget(null);
};
const updateNoteDraft = (value: string) => {
setNoteDraft(Array.from(value).slice(0, 1000).join(""));
};
const saveNote = async () => {
if (!noteTarget || noteBusy) return;
if (await onSaveNote(noteTarget.itemKind, noteTarget.id, noteDraft)) {
setNoteTarget(null);
}
};
const handleZipChange = (event: ChangeEvent<HTMLInputElement>) => {
void onInstallZip(event.currentTarget.files?.[0]);
};
@@ -395,6 +471,7 @@ export function SkillsMcpPage({
disabled={anyBusy}
copy={copy}
onToggle={onToggleMcp}
onEditNote={openNoteEditor}
/>
))
) : state.skills.length === 0 ? (
@@ -407,6 +484,7 @@ export function SkillsMcpPage({
disabled={anyBusy}
copy={copy}
onToggle={onToggleSkill}
onEditNote={openNoteEditor}
/>
))}
</div>
@@ -453,6 +531,48 @@ export function SkillsMcpPage({
>
<ImportPreviewContent preview={importPreview} copy={copy} />
</ModalShell>
<ModalShell
open={Boolean(noteTarget)}
onClose={closeNoteEditor}
title={copy.noteTitle}
description={noteTarget ? copy.noteDescription(noteTarget.name) : undefined}
size="sm"
closeOnBackdrop={!noteBusy}
closeOnEscape={!noteBusy}
showCloseButton={!noteBusy}
className="cx-skills-note-modal"
footer={(
<>
<Button variant="secondary" onClick={closeNoteEditor} disabled={noteBusy}>
{copy.cancel}
</Button>
<Button
icon={noteBusy ? <Loader2 className="cx-skills-spin" /> : <PencilLine />}
onClick={() => void saveNote()}
disabled={noteBusy}
>
{noteBusy ? copy.savingNote : copy.saveNote}
</Button>
</>
)}
>
<label className="cx-skills-note-field">
<span>{copy.noteLabel}</span>
<textarea
value={noteDraft}
onChange={(event) => updateNoteDraft(event.target.value)}
placeholder={copy.notePlaceholder}
rows={5}
disabled={noteBusy}
data-initial-focus
/>
</label>
<div className="cx-skills-note-meta">
<span>{copy.noteEmptyHint}</span>
<span>{copy.noteCount(Array.from(noteDraft).length)}</span>
</div>
</ModalShell>
</section>
);
}
+74
View File
@@ -1,3 +1,4 @@
import { useState } from "react";
import type { ReactNode } from "react";
import {
CheckCircle2,
@@ -5,10 +6,12 @@ import {
ExternalLink,
Globe2,
Loader2,
Power,
RefreshCw,
Sparkles,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { Button, ModalShell } from "../components/ui";
import "../styles/utility-pages.css";
export type UtilityLanguage = "zh" | "en";
@@ -84,6 +87,15 @@ export type SettingsCopy = {
recheckTitle: ReactNode;
recheckDescription: ReactNode;
recheckLabel: ReactNode;
restartTitle: ReactNode;
restartDescription: ReactNode;
restartLabel: ReactNode;
restartTargetLabel: ReactNode;
restartConfirmTitle: ReactNode;
restartConfirmDescription: ReactNode;
restartCancelLabel: string;
restartConfirmLabel: ReactNode;
restartingLabel: ReactNode;
};
export type SettingsPageProps = {
@@ -91,7 +103,9 @@ export type SettingsPageProps = {
copy: SettingsCopy;
onLanguageChange: (lang: UtilityLanguage) => void;
onRecheck: () => void;
onRestartCodex: () => Promise<boolean>;
recheckBusy?: boolean;
restartBusy?: boolean;
};
type SettingRowProps = {
@@ -121,8 +135,19 @@ export function SettingsPage({
copy,
onLanguageChange,
onRecheck,
onRestartCodex,
recheckBusy = false,
restartBusy = false,
}: SettingsPageProps) {
const [restartConfirmOpen, setRestartConfirmOpen] = useState(false);
const closeRestartConfirm = () => {
if (!restartBusy) setRestartConfirmOpen(false);
};
const confirmRestart = async () => {
if (restartBusy) return;
if (await onRestartCodex()) setRestartConfirmOpen(false);
};
return (
<section className="cx-utility cx-page cx-page--settings">
<PageHeader eyebrow={copy.eyebrow} title={copy.title} />
@@ -176,7 +201,56 @@ export function SettingsPage({
</button>
)}
/>
<SettingRow
icon={Power}
title={copy.restartTitle}
description={copy.restartDescription}
action={(
<button
type="button"
className="cx-page-button cx-page-button--secondary"
onClick={() => setRestartConfirmOpen(true)}
disabled={restartBusy}
>
{restartBusy ? <Loader2 size={15} className="cx-page-spin" aria-hidden="true" /> : <Power size={15} aria-hidden="true" />}
{restartBusy ? copy.restartingLabel : copy.restartLabel}
</button>
)}
/>
</div>
<ModalShell
open={restartConfirmOpen}
onClose={closeRestartConfirm}
title={copy.restartConfirmTitle}
description={copy.restartConfirmDescription}
size="sm"
closeLabel={copy.restartCancelLabel}
closeOnBackdrop={!restartBusy}
closeOnEscape={!restartBusy}
showCloseButton={!restartBusy}
footer={(
<>
<Button variant="secondary" onClick={closeRestartConfirm} disabled={restartBusy} data-initial-focus>
{copy.restartCancelLabel}
</Button>
<Button
variant="danger"
icon={restartBusy ? <Loader2 size={16} className="cx-page-spin" /> : <Power size={16} />}
onClick={() => void confirmRestart()}
disabled={restartBusy}
>
{restartBusy ? copy.restartingLabel : copy.restartConfirmLabel}
</Button>
</>
)}
>
<div className="cx-page-restart-target">
<Power size={18} aria-hidden="true" />
<strong>{copy.restartTargetLabel}</strong>
</div>
</ModalShell>
</section>
);
}
+36
View File
@@ -0,0 +1,36 @@
export type ProviderProfile = {
baseUrl?: string | null;
providerName?: string | null;
model?: string | null;
apiKey?: string | null;
};
function normalizeProviderBaseUrl(value?: string | null) {
const raw = (value || "").trim();
if (!raw) return "";
try {
const parsed = new URL(raw);
const credentials = parsed.username
? `${parsed.username}${parsed.password ? `:${parsed.password}` : ""}@`
: "";
const path = parsed.pathname.replace(/\/+$/, "");
return `${parsed.protocol.toLowerCase()}//${credentials}${parsed.host.toLowerCase()}${path}${parsed.search}`;
} catch {
return raw.replace(/\/+$/, "");
}
}
function normalizeProviderName(value?: string | null) {
return (value || "").trim().replace(/\s+/gu, " ").toLowerCase();
}
export function providerProfilesMatch(left: ProviderProfile, right: ProviderProfile) {
const leftUrl = normalizeProviderBaseUrl(left.baseUrl);
if (!leftUrl || leftUrl !== normalizeProviderBaseUrl(right.baseUrl)) return false;
if ((left.model || "").trim() !== (right.model || "").trim()) return false;
const leftKey = (left.apiKey || "").trim();
const rightKey = (right.apiKey || "").trim();
if (leftKey && rightKey) return leftKey === rightKey;
return normalizeProviderName(left.providerName) === normalizeProviderName(right.providerName);
}
+18
View File
@@ -0,0 +1,18 @@
type OrderedProviderRow = {
isCurrent: boolean;
};
export function orderProviderRows<
Official extends OrderedProviderRow,
Detected extends OrderedProviderRow,
Local extends OrderedProviderRow,
>(
official: Official,
detected: readonly Detected[],
local: readonly Local[],
): Array<Official | Detected | Local> {
const rows: Array<Official | Detected | Local> = [official];
if (!local.some((row) => row.isCurrent)) rows.push(...detected);
rows.push(...local);
return rows;
}
+20
View File
@@ -426,6 +426,26 @@
transform-origin: center top;
}
.cx-state-loading {
min-width: 0;
min-height: 280px;
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
color: var(--ui-text-muted);
font-size: 13px;
font-weight: 620;
line-height: 1.45;
text-align: center;
}
.cx-state-loading svg {
flex: 0 0 auto;
color: var(--ui-accent);
}
.cx-page-transition--exit,
.cx-page-transition--enter {
will-change: opacity, transform;
+18
View File
@@ -438,6 +438,10 @@
border-color: transparent;
}
:root[data-theme="dark"] .cx-providers-row-meta {
color: #aeb3bc;
}
:root[data-theme="dark"] .cx-providers-info-grid code {
color: var(--cx-dark-muted);
background: transparent;
@@ -770,6 +774,20 @@
box-shadow: 0 7px 18px rgba(0, 0, 0, 0.18);
}
:root[data-theme="dark"] .cx-skills-note-field > span {
color: #d8dbe0;
}
:root[data-theme="dark"] .cx-skills-note-field textarea {
color: #e5e7eb;
background: var(--cx-dark-surface-recessed);
border-color: var(--cx-dark-border);
}
:root[data-theme="dark"] .cx-skills-note-meta {
color: var(--cx-dark-subtle);
}
:root[data-theme="dark"] .cx-prompts-row:hover {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(139, 92, 246, 0.4);
@@ -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;
}
@@ -328,10 +328,13 @@
min-width: 0;
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 9px;
}
.cx-skills-row-copy > strong {
min-width: 0;
flex: 0 1 auto;
display: block;
overflow: hidden;
color: #2a2e34;
@@ -343,14 +346,38 @@
white-space: nowrap;
}
.cx-skills-row-copy--noted > strong {
max-width: min(40%, 360px);
flex: 0 1 auto;
}
.cx-skills-row-note {
min-width: 0;
flex: 1 1 180px;
overflow: hidden;
color: var(--cx-tool-muted);
font-size: 13px;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.cx-skills-row-copy .ui-status-badge {
flex: 0 0 auto;
max-width: min(30%, 220px);
overflow: hidden;
min-height: 20px;
padding: 0 6px;
border-radius: 5px;
font-size: 11px;
}
.cx-skills-row-copy .ui-status-badge > span:last-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.cx-skills-row-control,
.cx-prompts-row-actions {
min-width: 58px;
@@ -360,6 +387,67 @@
gap: 9px;
}
.cx-skills-note-button {
flex: 0 0 auto;
}
.cx-skills-note-modal .ui-modal__body {
display: grid;
gap: 8px;
}
.cx-skills-note-field {
display: grid;
gap: 8px;
}
.cx-skills-note-field > span {
color: #3e434b;
font-size: 12px;
font-weight: 680;
}
.cx-skills-note-field textarea {
width: 100%;
min-height: 118px;
resize: vertical;
padding: 11px 12px;
border: 1px solid #d8dce2;
border-radius: 7px;
color: #292d33;
background: #ffffff;
font: inherit;
font-size: 13px;
line-height: 1.55;
overflow-wrap: anywhere;
}
.cx-skills-note-field textarea:focus {
border-color: #8db2f5;
outline: 2px solid rgba(37, 99, 235, 0.16);
outline-offset: 1px;
}
.cx-skills-note-field textarea:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.cx-skills-note-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
color: #858b94;
font-size: 11px;
line-height: 1.4;
}
.cx-skills-note-meta span:last-child {
flex: 0 0 auto;
font-variant-numeric: tabular-nums;
}
.cx-skills-empty {
min-height: 150px;
display: flex;
@@ -1444,12 +1532,37 @@
}
.cx-skills-row {
grid-template-columns: 34px minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
}
.cx-skills-row-control {
grid-column: 2;
justify-content: flex-start;
grid-row: 1;
justify-content: flex-end;
}
.cx-skills-row-copy {
grid-column: 1;
grid-row: 1;
}
.cx-skills-row-copy--noted {
flex-wrap: wrap;
}
.cx-skills-row-copy--noted > strong {
max-width: 100%;
flex: 1 1 auto;
}
.cx-skills-row-note {
flex-basis: 100%;
display: -webkit-box;
overflow: hidden;
white-space: normal;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.cx-prompts-list {
+12
View File
@@ -232,6 +232,18 @@
line-height: 1.3;
}
.cx-page-restart-target {
display: flex;
align-items: center;
gap: 9px;
color: var(--cx-page-text);
}
.cx-page-restart-target > svg {
flex: 0 0 auto;
color: var(--cx-page-blue);
}
.cx-page-setting-copy p {
margin: 5px 0 0;
color: var(--cx-page-muted);
+15
View File
@@ -106,10 +106,18 @@ export type ActionResult = {
export type OfficialConfigDraft = {
authJson: string;
configText: string;
model?: string;
source: string;
};
export type OfficialAuthCandidate = {
authJson: string;
configText?: string | null;
model?: string | null;
source: string;
};
export type ImportResult = {
imported: number;
added: number;
@@ -129,6 +137,11 @@ export type AboutInfo = {
nativeUpdaterSupported: boolean;
};
export type CodexDesktopRestartResult = {
appName: string;
wasRunning: boolean;
};
export type ReleaseInfo = {
status: "idle" | "checking" | "ok" | "error";
latestVersion?: string;
@@ -183,6 +196,7 @@ export type ManagedSkill = {
id: string;
name: string;
description?: string | null;
note?: string | null;
directory: string;
enabled: boolean;
source: string;
@@ -198,6 +212,7 @@ export type ManagedMcpServer = {
enabled: boolean;
source: string;
summary: string;
note?: string | null;
command?: string | null;
url?: string | null;
configJson: unknown;
@@ -0,0 +1,31 @@
import assert from "node:assert/strict";
import test from "node:test";
import { providerProfilesMatch } from "../src/providerProfiles.ts";
const saved = {
baseUrl: "https://api.example.com/v1/",
providerName: "Example API",
model: "gpt-5.6",
apiKey: "sk-saved",
};
test("remembered provider still matches when the live key is unavailable", () => {
assert.equal(providerProfilesMatch(saved, {
baseUrl: "https://API.EXAMPLE.com/v1",
providerName: " example api ",
model: "gpt-5.6",
apiKey: "",
}), true);
});
test("different models or two different explicit keys do not match", () => {
assert.equal(providerProfilesMatch(saved, { ...saved, model: "deepseek-v3" }), false);
assert.equal(providerProfilesMatch(saved, { ...saved, apiKey: "sk-other" }), false);
});
test("matching explicit credentials identify the same profile even after a rename", () => {
assert.equal(providerProfilesMatch(saved, {
...saved,
providerName: "Renamed copy",
}), true);
});
@@ -0,0 +1,27 @@
import assert from "node:assert/strict";
import test from "node:test";
import { orderProviderRows } from "../src/providerRowOrder.ts";
const official = { id: "official", isCurrent: false };
const detected = { id: "detected", isCurrent: true };
test("a copied provider is last while the selected saved ID stays current", () => {
const rows = orderProviderRows(official, [detected], [
{ id: "older", isCurrent: false },
{ id: "selected", isCurrent: true },
{ id: "copy", isCurrent: false },
]);
assert.deepEqual(rows.map((row) => row.id), ["official", "older", "selected", "copy"]);
assert.deepEqual(rows.filter((row) => row.isCurrent).map((row) => row.id), ["selected"]);
});
test("an unresolved detected current remains visible before saved rows", () => {
const rows = orderProviderRows(official, [detected], [
{ id: "original", isCurrent: false },
{ id: "copy", isCurrent: false },
]);
assert.deepEqual(rows.map((row) => row.id), ["official", "detected", "original", "copy"]);
assert.deepEqual(rows.filter((row) => row.isCurrent).map((row) => row.id), ["detected"]);
});
+33 -2
View File
@@ -8,6 +8,36 @@
日志不得包含 Token、认证文件内容、用户数据或仅适用于某台电脑的隐私路径。
## 2026-08-11:供应商认证往返、模板完整性与启动读取
### 决策
- 新建和 CC Switch 导入的供应商保存完整、无密钥的 TOML 模板;API Key 单独保存在供应商
数据库字段中。完整模板是该供应商的权威配置,切换时恢复项目、插件、MCP、桌面、功能和
环境设置;旧版稀疏模板仍以当前 live 配置为底稿兼容合并。
- 官方 OAuth 只保存在 Codex-X 的独立可信快照和官方 live `auth.json` 中。切换第三方时,
live `auth.json` 改写为只含 `OPENAI_API_KEY` 的第三方认证;第三方 TOML 不保留
`auth_mode` 或 `experimental_bearer_token`,切回官方时再恢复完整官方认证。
- 官方配置独立保存完整 `config.toml`、模型和 OAuth/API Key `auth.json`。官方与第三方往返
按目标方向写入:第三方凭据先于第三方路由,官方路由先于官方凭据;失败时只回滚已经写入
的第一步。未登录的官方 Reset 快照也必须保留完整 TOML。自动捕获不得让第三方 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 `076c2744ceb622b85771bff57668d43ed70809f8` 的完整 provider config 和
默认 `preserveCodexOfficialAuthOnSwitch = false` 路径:第三方认证直接写入 `auth.json`。
`95f2dd41262f01209100128ea647dbd054b5624a` 的 OAuth + provider-scoped bearer 行为仅作为
兼容策略参考,不再作为 Codex-X 默认切换语义;
Codex-X 额外保留官方独立快照、live 配置并发检查与条件原子回滚。本节决策替代 2026-07-29
中关于第三方 bearer token 和模板激活的旧约束。
## 2026-07-30:live 配置并发与失败回滚
### 已确认根因
@@ -84,8 +114,9 @@ Tauri 事件循环随最后一个窗口关闭而结束,后台管理能力也
### 约束
- 供应商记录只拥有供应商相关字段,不拥有整份用户配置。
- 激活供应商必须基于最新 live 文档合并目标 provider 表,不回放历史整文件快照。
- 供应商记录可以保存完整 TOML,作为导入与编辑模板;其中的密钥必须剥离并由独立字段保存。
- 激活供应商必须基于最新 live 文档,仅合并目标 provider 与 model 字段,不回放模板中的
项目、插件、MCP 或其他通用设置。
- 官方认证快照与中转认证严格隔离,供应商切换不能覆盖可信官方认证。
- 自动捕获官方认证只接受明确的 ChatGPT 登录模式及 access/refresh/id token;代理路由下的
API key 属于不可信来源,不能晋升为官方快照。
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "codex-x-workspace",
"private": true,
"version": "0.3.8",
"version": "0.3.14",
"scripts": {
"dev": "pnpm --dir apps/desktop tauri dev",
"build": "pnpm --dir apps/desktop tauri build",