mirror of
https://github.com/yynxxxxx/Codex-X.git
synced 2026-10-18 05:59:23 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4822237fb | ||
|
|
5d18b2555e | ||
|
|
2ace729801 | ||
|
|
488f27e095 | ||
|
|
826a142fc0 | ||
|
|
d1b40285dd | ||
|
|
3ac28c3193 | ||
|
|
39dc8fc535 |
@@ -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
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "codex-x",
|
||||
"version": "0.3.8",
|
||||
"version": "0.3.14",
|
||||
"private": true,
|
||||
"description": "Codex Switch & Instruct desktop manager",
|
||||
"type": "module",
|
||||
|
||||
Generated
+1
-1
@@ -490,7 +490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codex-x"
|
||||
version = "0.3.8"
|
||||
version = "0.3.14"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +216,3 @@ pub(crate) fn backups() -> Result<Vec<BackupEntry>> {
|
||||
entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
pub(crate) fn latest_backup() -> Result<Option<BackupEntry>> {
|
||||
Ok(backups()?.into_iter().next())
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ use crate::backups::create_backup;
|
||||
use crate::config_path;
|
||||
use crate::error::Result;
|
||||
use crate::file_io::parse_toml_document;
|
||||
use crate::live_config::{
|
||||
acquire_live_config_lock, atomic_write_if_unchanged, read_file_snapshot, text_from_snapshot,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use crate::live_config::acquire_live_config_lock;
|
||||
use crate::live_config::{atomic_write_if_unchanged, read_file_snapshot, text_from_snapshot};
|
||||
use std::path::Path;
|
||||
use toml_edit::{DocumentMut, Item, Table};
|
||||
|
||||
@@ -31,6 +31,7 @@ fn remove_markdown_path(table: &mut Table, key: &str) -> Option<Item> {
|
||||
|
||||
/// Repairs prompt paths appended by older Codex-X versions after a `[tui]`
|
||||
/// header. Only the exact legacy keys with Markdown path values are touched.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn migrate_legacy_prompt_config(codex_dir: &Path) -> Result<bool> {
|
||||
let _lock = acquire_live_config_lock(codex_dir)?;
|
||||
migrate_legacy_prompt_config_locked(codex_dir)
|
||||
|
||||
@@ -108,6 +108,7 @@ pub(crate) fn read_to_string_if_exists(path: &Path) -> Result<String> {
|
||||
fs::read_to_string(path).map_err(|e| io_err(path, e))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn harden_sensitive_file_permissions(path: &Path) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
|
||||
@@ -82,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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+1062
-124
File diff suppressed because it is too large
Load Diff
@@ -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, §ion, &provider_name, &model)?;
|
||||
Some(SavedProvider {
|
||||
id: custom_provider_id(&row.id),
|
||||
provider_name: if row.name.trim().is_empty() {
|
||||
section.name.unwrap_or_else(|| row.id.clone())
|
||||
} else {
|
||||
row.name.trim().to_string()
|
||||
},
|
||||
provider_name,
|
||||
base_url: section.base_url,
|
||||
model: section.model.unwrap_or_else(|| "gpt-5.5".to_string()),
|
||||
model,
|
||||
api_key,
|
||||
toml_config: None,
|
||||
toml_config: Some(toml_config),
|
||||
wire_api: section.wire_api,
|
||||
requires_openai_auth: section.requires_openai_auth,
|
||||
})
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
+1585
-137
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Codex-X",
|
||||
"version": "0.3.8",
|
||||
"version": "0.3.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
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
ArrowUpRight,
|
||||
CheckCircle2,
|
||||
CircleAlert,
|
||||
Code2,
|
||||
FileText,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
@@ -14,6 +16,7 @@ export type OverviewLanguage = "zh" | "en";
|
||||
|
||||
export type OverviewPageProps = {
|
||||
lang: OverviewLanguage;
|
||||
ready: boolean;
|
||||
model?: string | null;
|
||||
configDir: string;
|
||||
resolvedCodexDir: string;
|
||||
@@ -70,6 +73,7 @@ function ConfigRow({ label, value }: ConfigRowProps) {
|
||||
|
||||
export function OverviewPage({
|
||||
lang,
|
||||
ready,
|
||||
model,
|
||||
configDir,
|
||||
resolvedCodexDir,
|
||||
@@ -121,6 +125,8 @@ export function OverviewPage({
|
||||
model: "模型",
|
||||
providerName: "供应商标识",
|
||||
instructionFile: "指令文件",
|
||||
reading: "正在读取",
|
||||
readFailed: "读取失败",
|
||||
}
|
||||
: {
|
||||
eyebrow: "CODEX CONFIG MANAGER",
|
||||
@@ -153,20 +159,29 @@ export function OverviewPage({
|
||||
model: "Model",
|
||||
providerName: "Provider",
|
||||
instructionFile: "Instruction file",
|
||||
reading: "Loading",
|
||||
readFailed: "Load failed",
|
||||
};
|
||||
|
||||
const displayModel = model?.trim() || text.modelMissing;
|
||||
const displayProvider = providerLabel?.trim() || modelProvider?.trim() || text.official;
|
||||
const displayModelProvider = modelProvider?.trim() || text.notConfigured;
|
||||
const displayDirectory = resolvedCodexDir.trim() || configDir.trim() || text.notConfigured;
|
||||
const displayConfigPath = configPath?.trim() || text.notConfigured;
|
||||
const displayInstructionPath = instructionPath?.trim() || text.notConfigured;
|
||||
const unresolvedStatus = loading ? text.reading : text.readFailed;
|
||||
const displayModel = ready ? (model?.trim() || text.modelMissing) : unresolvedStatus;
|
||||
const displayProvider = ready
|
||||
? (providerLabel?.trim() || modelProvider?.trim() || text.official)
|
||||
: unresolvedStatus;
|
||||
const displayModelProvider = ready ? (modelProvider?.trim() || text.notConfigured) : unresolvedStatus;
|
||||
const displayDirectory = ready
|
||||
? (resolvedCodexDir.trim() || configDir.trim() || text.notConfigured)
|
||||
: (configDir.trim() || unresolvedStatus);
|
||||
const displayConfigPath = ready ? (configPath?.trim() || text.notConfigured) : unresolvedStatus;
|
||||
const displayInstructionPath = ready ? (instructionPath?.trim() || text.notConfigured) : unresolvedStatus;
|
||||
const updateVersion = latestVersion?.trim() || "";
|
||||
const homeInputValue = configDir || resolvedCodexDir;
|
||||
const authAvailable = authExists || officialAuthAvailable;
|
||||
const authStatus = authExists
|
||||
? text.authFile
|
||||
: officialAuthAvailable ? text.officialAuth : text.noAuth;
|
||||
const homeInputValue = configDir;
|
||||
const authAvailable = ready && (authExists || officialAuthAvailable);
|
||||
const authStatus = ready
|
||||
? authExists
|
||||
? text.authFile
|
||||
: officialAuthAvailable ? text.officialAuth : text.noAuth
|
||||
: unresolvedStatus;
|
||||
|
||||
return (
|
||||
<section className="cx-overview-page" aria-label={isChinese ? "概览" : "Overview"}>
|
||||
@@ -187,6 +202,7 @@ export function OverviewPage({
|
||||
value={homeInputValue}
|
||||
onChange={(event) => onConfigDirChange(event.target.value)}
|
||||
placeholder={text.directoryPlaceholder}
|
||||
disabled={loading}
|
||||
spellCheck={false}
|
||||
aria-label={text.codexHome}
|
||||
/>
|
||||
@@ -217,20 +233,20 @@ export function OverviewPage({
|
||||
<StatusCard
|
||||
icon={FileText}
|
||||
label={text.config}
|
||||
value={configExists ? text.found : text.missing}
|
||||
tone={configExists ? "success" : "muted"}
|
||||
value={ready ? (configExists ? text.found : text.missing) : unresolvedStatus}
|
||||
tone={ready && configExists ? "success" : "muted"}
|
||||
/>
|
||||
<StatusCard
|
||||
icon={Code2}
|
||||
label={text.provider}
|
||||
value={displayProvider}
|
||||
tone={modelProvider ? "active" : "muted"}
|
||||
tone={ready && modelProvider ? "active" : "muted"}
|
||||
/>
|
||||
<StatusCard
|
||||
icon={Sparkles}
|
||||
label={text.instruction}
|
||||
value={instructionEnabled ? text.enabled : text.disabled}
|
||||
tone={instructionEnabled ? "success" : "muted"}
|
||||
value={ready ? (instructionEnabled ? text.enabled : text.disabled) : unresolvedStatus}
|
||||
tone={ready && instructionEnabled ? "success" : "muted"}
|
||||
/>
|
||||
<StatusCard
|
||||
icon={KeyRound}
|
||||
@@ -246,9 +262,13 @@ export function OverviewPage({
|
||||
<p className="cx-overview-section-label">{text.liveStatus}</p>
|
||||
<h3>{text.currentConfig}</h3>
|
||||
</div>
|
||||
<span className={`cx-overview-instruction-pill${instructionEnabled ? " cx-overview-instruction-pill--active" : ""}`}>
|
||||
<CheckCircle2 size={14} strokeWidth={2} aria-hidden="true" />
|
||||
{instructionEnabled ? text.on : text.off}
|
||||
<span className={`cx-overview-instruction-pill${ready && instructionEnabled ? " cx-overview-instruction-pill--active" : ""}`}>
|
||||
{!ready && loading
|
||||
? <Loader2 className="cx-overview-spin" size={14} strokeWidth={2} aria-hidden="true" />
|
||||
: !ready
|
||||
? <CircleAlert size={14} strokeWidth={2} aria-hidden="true" />
|
||||
: <CheckCircle2 size={14} strokeWidth={2} aria-hidden="true" />}
|
||||
{ready ? (instructionEnabled ? text.on : text.off) : unresolvedStatus}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user