Compare commits

...
4 Commits
Author SHA1 Message Date
yyyyyynnn d7610f9b12 fix: restart Codex reliably on Windows 2026-08-20 14:28:03 +08:00
yyyyyynnn e4822237fb ci: serialize Windows release tests 2026-08-20 13:41:09 +08:00
yyyyyynnn 5d18b2555e test: isolate duplicate provider fixture 2026-08-20 13:39:23 +08:00
yyyyyynnn 2ace729801 release: prepare v0.3.14 2026-08-20 13:23:11 +08:00
37 changed files with 2961 additions and 607 deletions
+1 -1
View File
@@ -223,7 +223,7 @@ jobs:
- name: Test Windows filesystem compatibility
if: matrix.platform == 'windows-latest'
run: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --lib --locked
run: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --lib --locked -- --test-threads=1
- name: Import Apple signing certificate and notarization key
if: matrix.macos
+20
View File
@@ -4,6 +4,26 @@ All notable changes to Codex-X will be documented here.
## [Unreleased]
## [v0.3.15] - 2026-08-20
### 修复 Bug
- 修复 Windows 重启 Codex 时仅隐藏到托盘、后台未退出,以及再次重启报错的问题。
## [v0.3.14] - 2026-08-20
### 更新
- MCP 和 Skills 支持添加自定义备注。
- 供应商支持复制,同一 API 平台可保存多个模型或相同配置,复制项会添加到列表末尾。
- 供应商列表不再显示重复的模型信息。
- 设置页新增“重启 Codex”按钮,支持 macOS 和 Windows。
### 修复 Bug
- 修复相同供应商配置被覆盖,以及复制当前供应商后启用状态丢失的问题。
- 修复子代理会话被误判为“待同步”的问题。
## [v0.3.13] - 2026-08-18
### 修复 Bug
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "codex-x",
"version": "0.3.13",
"version": "0.3.15",
"private": true,
"description": "Codex Switch & Instruct desktop manager",
"type": "module",
+1 -1
View File
@@ -490,7 +490,7 @@ dependencies = [
[[package]]
name = "codex-x"
version = "0.3.13"
version = "0.3.15"
dependencies = [
"chrono",
"dirs 5.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "codex-x"
version = "0.3.13"
version = "0.3.15"
description = "Codex Switch & Instruct desktop manager"
authors = ["yynxxxxx"]
license = "MIT"
+85 -3
View File
@@ -7,7 +7,7 @@ use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
const APP_DB_SCHEMA_VERSION: i64 = 1;
const APP_DB_SCHEMA_VERSION: i64 = 3;
struct DatabaseInitializer {
migration_lock: Mutex<()>,
@@ -193,6 +193,19 @@ fn initialize_schema(conn: &Connection) -> Result<()> {
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()))?;
@@ -319,7 +332,10 @@ mod tests {
let path = test_db_path("replace-database");
let initializer = DatabaseInitializer::new();
let conn = initializer.open_at(&path).expect("initialize database");
assert_eq!(schema_version(&conn).expect("read schema version"), 1);
assert_eq!(
schema_version(&conn).expect("read schema version"),
APP_DB_SCHEMA_VERSION
);
drop(conn);
fs::remove_file(&path).expect("replace initialized database");
@@ -330,11 +346,77 @@ mod tests {
.query_row("SELECT COUNT(*) FROM providers", [], |row| row.get(0))
.expect("query replacement database schema");
assert_eq!(provider_count, 0);
assert_eq!(schema_version(&conn).expect("read replacement version"), 1);
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");
+163 -29
View File
@@ -82,14 +82,16 @@ use providers::{
upsert_provider_on_connection, CcSwitchCodexRow, ProviderUpsertKind, ProviderUpsertMode,
};
use providers::{
build_provider_toml_draft_inner, delete_saved_provider_inner, fetch_provider_models_inner,
get_official_config_draft_inner, import_ccswitch_codex_providers_inner,
list_saved_providers_inner, read_ccswitch_official_auth_inner, reset_official_provider_inner,
restore_official_provider_inner, save_active_provider_inner, save_official_config_inner,
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::{
@@ -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 {
@@ -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,54 @@ 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,
@@ -880,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]
@@ -906,9 +997,15 @@ fn get_codex_state_inner(config_dir: Option<String>) -> Result<CodexState> {
#[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]
@@ -922,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, input.config_text)
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}")))?
@@ -939,12 +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(
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}")))?
@@ -1124,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]
@@ -1152,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]
@@ -1341,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,
+11 -1
View File
@@ -1,7 +1,7 @@
use crate::error::{CodexxError, Result};
#[cfg(test)]
use chrono::Local;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
pub(crate) fn home_dir() -> Result<PathBuf> {
dirs::home_dir().ok_or(CodexxError::NoHomeDir)
@@ -33,3 +33,13 @@ pub(crate) fn app_home() -> Result<PathBuf> {
Ok(home_dir()?.join(".codexx"))
}
}
pub(crate) fn normalized_path_scope(path: &Path) -> String {
let resolved = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let normalized = resolved.to_string_lossy().replace('\\', "/");
if cfg!(target_os = "windows") {
normalized.to_ascii_lowercase()
} else {
normalized
}
}
+572 -41
View File
@@ -15,11 +15,46 @@ const WINDOWS_CODEX_PACKAGE_IDENTITIES: &[&str] =
&["OpenAI.Codex", "OpenAI.CodexBeta", "OpenAI.ChatGPT-Desktop"];
#[cfg(target_os = "windows")]
const WINDOWS_CODEX_EXECUTABLES: &[&str] = &["ChatGPT.exe", "Codex.exe", "codex.exe"];
#[cfg(any(target_os = "macos", test))]
const MACOS_CODEX_APP_NAMES: &[&str] = &[
"Codex.app",
"OpenAI Codex.app",
"OpenAI.Codex.app",
"ChatGPT Codex.app",
"ChatGPT.app",
];
#[cfg(any(target_os = "macos", test))]
const MACOS_CODEX_BUNDLE_IDS: &[&str] = &["com.openai.codex", "com.openai.codex.beta"];
const CODEX_VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
const PROGRAM_TIMEOUT: Duration = Duration::from_secs(2);
const PROGRAM_POLL_INTERVAL: Duration = Duration::from_millis(10);
const CHILD_TERMINATION_GRACE: Duration = Duration::from_millis(250);
#[cfg(any(target_os = "macos", target_os = "windows"))]
const CODEX_DESKTOP_COMMAND_TIMEOUT: Duration = Duration::from_secs(5);
#[cfg(any(target_os = "macos", target_os = "windows"))]
const CODEX_DESKTOP_QUIT_TIMEOUT: Duration = Duration::from_secs(8);
#[cfg(target_os = "macos")]
const CODEX_DESKTOP_POLL_INTERVAL: Duration = Duration::from_millis(100);
#[cfg(any(target_os = "windows", test))]
const WINDOWS_RESTART_RESULT_PREFIX: &str = "CODEX_X_DESKTOP_RESTART";
#[cfg(any(target_os = "macos", test))]
#[derive(Debug, Clone, PartialEq, Eq)]
struct MacosAppMetadata {
bundle_id: String,
executable: String,
display_name: Option<String>,
}
#[cfg(target_os = "macos")]
#[derive(Debug, Clone)]
struct MacosCodexApp {
bundle_id: String,
bundle_path: PathBuf,
executable_path: PathBuf,
app_name: String,
}
static CODEX_VERSION: OnceLock<String> = OnceLock::new();
@@ -172,8 +207,13 @@ fn receive_output(
}
}
fn run_program(program: &Path, args: &[&str], deadline: Option<Instant>) -> Option<Output> {
let timeout = remaining_timeout(deadline, PROGRAM_TIMEOUT)?;
fn run_program_with_timeout(
program: &Path,
args: &[&str],
deadline: Option<Instant>,
maximum: Duration,
) -> Option<Output> {
let timeout = remaining_timeout(deadline, maximum)?;
let command_deadline = Instant::now().checked_add(timeout)?;
let mut command = program_command(program, args);
command
@@ -222,6 +262,10 @@ fn run_program(program: &Path, args: &[&str], deadline: Option<Instant>) -> Opti
})
}
fn run_program(program: &Path, args: &[&str], deadline: Option<Instant>) -> Option<Output> {
run_program_with_timeout(program, args, deadline, PROGRAM_TIMEOUT)
}
fn command_version(program: &Path, deadline: Option<Instant>) -> Option<String> {
run_program(program, &["--version"], deadline)
.and_then(version_from_output)
@@ -550,47 +594,90 @@ fn windows_where_candidates(_deadline: Option<Instant>) -> Vec<PathBuf> {
Vec::new()
}
#[cfg(any(target_os = "macos", test))]
fn macos_codex_app_paths(home: &Path) -> Vec<PathBuf> {
[PathBuf::from("/Applications"), home.join("Applications")]
.into_iter()
.flat_map(|root| {
MACOS_CODEX_APP_NAMES
.iter()
.map(move |name| root.join(name))
})
.collect()
}
#[cfg(any(target_os = "macos", test))]
fn macos_app_metadata_from_plist(plist: &str) -> Option<MacosAppMetadata> {
Some(MacosAppMetadata {
bundle_id: plist_string_value(plist, "CFBundleIdentifier")?,
executable: plist_string_value(plist, "CFBundleExecutable")?,
display_name: plist_string_value(plist, "CFBundleDisplayName")
.or_else(|| plist_string_value(plist, "CFBundleName")),
})
}
#[cfg(any(target_os = "macos", test))]
fn is_supported_macos_codex_bundle_id(bundle_id: &str) -> bool {
MACOS_CODEX_BUNDLE_IDS
.iter()
.any(|supported| bundle_id.eq_ignore_ascii_case(supported))
}
#[cfg(any(target_os = "macos", test))]
fn parse_macos_main_process_ids(output: &str, executable_path: &Path) -> Vec<u32> {
let executable = executable_path.to_string_lossy();
output
.lines()
.filter_map(|line| {
let line = line.trim_start();
let pid_end = line.find(char::is_whitespace)?;
let pid = line[..pid_end].parse::<u32>().ok()?;
let command = line[pid_end..].trim_start();
let matches = command == executable
|| command
.strip_prefix(executable.as_ref())
.is_some_and(|rest| rest.chars().next().is_some_and(char::is_whitespace));
matches.then_some(pid)
})
.collect()
}
#[cfg(target_os = "macos")]
fn macos_app_version(deadline: Option<Instant>) -> Option<String> {
let home = dirs::home_dir().unwrap_or_default();
for root in [PathBuf::from("/Applications"), home.join("Applications")] {
for name in [
"Codex.app",
"OpenAI Codex.app",
"OpenAI.Codex.app",
"ChatGPT Codex.app",
"ChatGPT.app",
] {
if deadline_expired(deadline) {
return None;
}
let app = root.join(name);
if !app.is_dir() {
continue;
}
let app_name = if name == "ChatGPT.app" {
"ChatGPT app"
} else {
"Codex app"
};
if let Some(version) = macos_info_plist_version(&app).or_else(|| {
let app = app.to_str()?;
let output = run_program(
Path::new("mdls"),
&["-name", "kMDItemVersion", "-raw", app],
deadline,
)?;
output
.status
.success()
.then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
}) {
if !version.is_empty() && version != "(null)" {
return Some(format!("{app_name} {version}"));
}
}
return Some(format!("{app_name} installed"));
for app in macos_codex_app_paths(&home) {
if deadline_expired(deadline) {
return None;
}
if !app.is_dir() {
continue;
}
let app_name = if app
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name == "ChatGPT.app")
{
"ChatGPT app"
} else {
"Codex app"
};
if let Some(version) = macos_info_plist_version(&app).or_else(|| {
let app = app.to_str()?;
let output = run_program(
Path::new("mdls"),
&["-name", "kMDItemVersion", "-raw", app],
deadline,
)?;
output
.status
.success()
.then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
}) {
if !version.is_empty() && version != "(null)" {
return Some(format!("{app_name} {version}"));
}
}
return Some(format!("{app_name} installed"));
}
None
}
@@ -611,11 +698,365 @@ fn plist_string_value(plist: &str, key: &str) -> Option<String> {
(!value.is_empty()).then(|| value.to_string())
}
#[cfg(target_os = "macos")]
fn macos_plist_raw_value(app: &Path, key: &str, deadline: Option<Instant>) -> Option<String> {
let plist_path = app.join("Contents/Info.plist");
if let Ok(plist) = fs::read_to_string(&plist_path) {
if let Some(value) = plist_string_value(&plist, key) {
return Some(value);
}
}
let plist_path = plist_path.to_str()?;
let output = run_program(
Path::new("/usr/bin/plutil"),
&["-extract", key, "raw", "-o", "-", plist_path],
deadline,
)?;
if !output.status.success() {
return None;
}
let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!value.is_empty()).then_some(value)
}
#[cfg(target_os = "macos")]
fn macos_codex_apps(deadline: Option<Instant>) -> Result<Vec<MacosCodexApp>, String> {
let home = dirs::home_dir().unwrap_or_default();
let mut apps = Vec::new();
for app in macos_codex_app_paths(&home) {
if deadline_expired(deadline) {
return Err("检测 Codex/ChatGPT 客户端超时".to_string());
}
if !app.is_dir() {
continue;
}
let plist = fs::read_to_string(app.join("Contents/Info.plist")).ok();
let metadata = plist.as_deref().and_then(macos_app_metadata_from_plist);
let bundle_id = metadata
.as_ref()
.map(|metadata| metadata.bundle_id.clone())
.or_else(|| macos_plist_raw_value(&app, "CFBundleIdentifier", deadline));
let executable = metadata
.as_ref()
.map(|metadata| metadata.executable.clone())
.or_else(|| macos_plist_raw_value(&app, "CFBundleExecutable", deadline));
let Some(bundle_id) = bundle_id.filter(|value| is_supported_macos_codex_bundle_id(value))
else {
continue;
};
let Some(executable) = executable.filter(|value| {
!value.is_empty()
&& !value.contains('/')
&& !value.contains('\\')
&& !value.contains('\0')
}) else {
continue;
};
let executable_path = app.join("Contents/MacOS").join(executable);
if !executable_path.is_file() {
continue;
}
let executable_path = executable_path.canonicalize().unwrap_or(executable_path);
let display_name = metadata
.and_then(|metadata| metadata.display_name)
.or_else(|| macos_plist_raw_value(&app, "CFBundleDisplayName", deadline))
.or_else(|| macos_plist_raw_value(&app, "CFBundleName", deadline));
let app_name = if display_name
.as_deref()
.is_some_and(|name| name.to_ascii_lowercase().contains("chatgpt"))
|| app
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.to_ascii_lowercase().contains("chatgpt"))
{
"ChatGPT"
} else {
"Codex"
};
let bundle_path = app.canonicalize().unwrap_or(app);
apps.push(MacosCodexApp {
bundle_id,
bundle_path,
executable_path,
app_name: app_name.to_string(),
});
}
if apps.is_empty() {
Err("未找到可重启的 Codex/ChatGPT 桌面客户端".to_string())
} else {
Ok(apps)
}
}
#[cfg(target_os = "macos")]
fn macos_main_process_ids(
executable_path: &Path,
deadline: Option<Instant>,
) -> Result<Vec<u32>, String> {
let output = run_program_with_timeout(
Path::new("/bin/ps"),
&["-axo", "pid=,command="],
deadline,
CODEX_DESKTOP_COMMAND_TIMEOUT,
)
.ok_or_else(|| "读取 Codex/ChatGPT 客户端进程失败或超时".to_string())?;
if !output.status.success() {
let message = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(if message.is_empty() {
"读取 Codex/ChatGPT 客户端进程失败".to_string()
} else {
format!("读取 Codex/ChatGPT 客户端进程失败: {message}")
});
}
Ok(parse_macos_main_process_ids(
&String::from_utf8_lossy(&output.stdout),
executable_path,
))
}
#[cfg(target_os = "macos")]
fn launch_macos_codex_app(app: &MacosCodexApp) -> Result<(), String> {
let deadline = Instant::now()
.checked_add(CODEX_DESKTOP_COMMAND_TIMEOUT)
.ok_or_else(|| "创建 Codex/ChatGPT 启动超时时间失败".to_string())?;
let bundle_path = app
.bundle_path
.to_str()
.ok_or_else(|| "Codex/ChatGPT 客户端路径不是有效文本".to_string())?;
let output = run_program_with_timeout(
Path::new("/usr/bin/open"),
&["-a", bundle_path],
Some(deadline),
CODEX_DESKTOP_COMMAND_TIMEOUT,
)
.ok_or_else(|| "启动 Codex/ChatGPT 客户端失败或超时".to_string())?;
if output.status.success() {
return Ok(());
}
let message = String::from_utf8_lossy(&output.stderr).trim().to_string();
Err(if message.is_empty() {
"启动 Codex/ChatGPT 客户端失败".to_string()
} else {
format!("启动 Codex/ChatGPT 客户端失败: {message}")
})
}
#[cfg(target_os = "macos")]
pub fn restart_codex_desktop() -> Result<(String, bool), String> {
let detection_deadline = Instant::now()
.checked_add(CODEX_DESKTOP_COMMAND_TIMEOUT)
.ok_or_else(|| "创建 Codex/ChatGPT 检测超时时间失败".to_string())?;
let apps = macos_codex_apps(Some(detection_deadline))?;
let mut first_installed = None;
let mut running_app = None;
for app in apps {
let is_running =
!macos_main_process_ids(&app.executable_path, Some(detection_deadline))?.is_empty();
if is_running {
running_app = Some(app);
break;
}
if first_installed.is_none() {
first_installed = Some(app);
}
}
let was_running = running_app.is_some();
let app = running_app
.or(first_installed)
.ok_or_else(|| "未找到可重启的 Codex/ChatGPT 桌面客户端".to_string())?;
if !was_running {
launch_macos_codex_app(&app)?;
return Ok((app.app_name, false));
}
let quit_script = format!("tell application id \"{}\" to quit", app.bundle_id);
let command_deadline = Instant::now()
.checked_add(CODEX_DESKTOP_COMMAND_TIMEOUT)
.ok_or_else(|| "创建 Codex/ChatGPT 退出命令超时时间失败".to_string())?;
let output = run_program_with_timeout(
Path::new("/usr/bin/osascript"),
&["-e", quit_script.as_str()],
Some(command_deadline),
CODEX_DESKTOP_COMMAND_TIMEOUT,
)
.ok_or_else(|| "请求 Codex/ChatGPT 客户端正常退出失败或超时".to_string())?;
if !output.status.success() {
let message = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(if message.is_empty() {
"请求 Codex/ChatGPT 客户端正常退出失败".to_string()
} else {
format!("请求 Codex/ChatGPT 客户端正常退出失败: {message}")
});
}
let quit_deadline = Instant::now()
.checked_add(CODEX_DESKTOP_QUIT_TIMEOUT)
.ok_or_else(|| "创建 Codex/ChatGPT 退出等待时间失败".to_string())?;
loop {
if macos_main_process_ids(&app.executable_path, Some(quit_deadline))?.is_empty() {
break;
}
if Instant::now() >= quit_deadline {
return Err("Codex/ChatGPT 客户端未在限定时间内正常退出,已取消重新启动".to_string());
}
thread::sleep(CODEX_DESKTOP_POLL_INTERVAL);
}
launch_macos_codex_app(&app)?;
Ok((app.app_name, true))
}
#[cfg(not(target_os = "macos"))]
fn macos_app_version(_deadline: Option<Instant>) -> Option<String> {
None
}
#[cfg(any(target_os = "windows", test))]
fn parse_windows_restart_result(output: &str) -> Option<(String, bool)> {
output.lines().rev().find_map(|line| {
let payload = line
.trim()
.strip_prefix(WINDOWS_RESTART_RESULT_PREFIX)?
.strip_prefix('\t')?;
let mut fields = payload.split('\t');
let app_name = fields.next()?.trim();
let was_running = match fields.next()?.trim() {
"0" => false,
"1" => true,
_ => return None,
};
if fields.next().is_some() || !matches!(app_name, "Codex" | "ChatGPT") {
return None;
}
Some((app_name.to_string(), was_running))
})
}
#[cfg(any(target_os = "windows", test))]
fn windows_restart_script() -> String {
let identities = WINDOWS_CODEX_PACKAGE_IDENTITIES
.iter()
.map(|identity| format!("'{}'", identity.replace('\'', "''")))
.collect::<Vec<_>>()
.join(", ");
r#"
$ErrorActionPreference = 'Stop'
$utf8 = [System.Text.UTF8Encoding]::new($false)
[Console]::OutputEncoding = $utf8
$OutputEncoding = $utf8
$identities = @(__IDENTITIES__)
$packages = @(Get-AppxPackage |
Where-Object { $identities -contains $_.Name } |
Sort-Object -Property Version -Descending |
Select-Object -Unique)
if ($packages.Count -eq 0) { throw 'Supported Codex/ChatGPT Appx package was not found.' }
function Get-CodexDesktopProcesses([string]$root) {
$appRoot = $root + 'app\'
$resourcesRoot = $appRoot + 'resources\'
@(Get-Process -ErrorAction SilentlyContinue | Where-Object {
try {
$processPath = [IO.Path]::GetFullPath($_.MainModule.FileName)
$executableName = [IO.Path]::GetFileName($processPath)
($executableName -ieq 'Codex.exe' -or $executableName -ieq 'ChatGPT.exe') -and
$processPath.StartsWith($appRoot, [StringComparison]::OrdinalIgnoreCase) -and
-not $processPath.StartsWith($resourcesRoot, [StringComparison]::OrdinalIgnoreCase)
} catch {
$false
}
})
}
$package = $null
$installRoot = $null
$processes = @()
foreach ($candidate in $packages) {
$candidateRoot = [IO.Path]::GetFullPath([string]$candidate.InstallLocation).TrimEnd('\') + '\'
$candidateProcesses = @(Get-CodexDesktopProcesses $candidateRoot)
if ($candidateProcesses.Count -gt 0) {
$package = $candidate
$installRoot = $candidateRoot
$processes = $candidateProcesses
break
}
}
if ($null -eq $package) {
$package = $packages[0]
$installRoot = [IO.Path]::GetFullPath([string]$package.InstallLocation).TrimEnd('\') + '\'
$processes = @(Get-CodexDesktopProcesses $installRoot)
}
$manifest = Get-AppxPackageManifest -Package $package.PackageFullName
$application = @($manifest.Package.Applications.Application) | Select-Object -First 1
$applicationId = [string]$application.Id
$packageFamily = [string]$package.PackageFamilyName
if ([string]::IsNullOrWhiteSpace($applicationId) -or [string]::IsNullOrWhiteSpace($packageFamily)) {
throw 'Codex/ChatGPT Appx package does not expose a launchable application id.'
}
$wasRunning = $processes.Count -gt 0
if ($wasRunning) {
foreach ($process in $processes) {
Stop-Process -InputObject $process -Force -ErrorAction SilentlyContinue
}
$quitDeadline = [DateTime]::UtcNow.AddSeconds(8)
do {
$remaining = @(Get-CodexDesktopProcesses $installRoot)
if ($remaining.Count -eq 0) { break }
Start-Sleep -Milliseconds 100
} while ([DateTime]::UtcNow -lt $quitDeadline)
$remaining = @(Get-CodexDesktopProcesses $installRoot)
if ($remaining.Count -gt 0) {
throw 'Codex/ChatGPT background processes could not be stopped before the timeout; relaunch was cancelled.'
}
}
$aumid = $packageFamily + '!' + $applicationId
Start-Process -FilePath 'explorer.exe' -ArgumentList ('shell:AppsFolder\' + $aumid) | Out-Null
$appName = if ($package.Name -eq 'OpenAI.ChatGPT-Desktop') { 'ChatGPT' } else { 'Codex' }
$runningFlag = if ($wasRunning) { '1' } else { '0' }
[Console]::Out.WriteLine('__RESULT_PREFIX__' + "`t" + $appName + "`t" + $runningFlag)
"#
.replace("__IDENTITIES__", &identities)
.replace("__RESULT_PREFIX__", WINDOWS_RESTART_RESULT_PREFIX)
}
#[cfg(target_os = "windows")]
pub fn restart_codex_desktop() -> Result<(String, bool), String> {
let script = windows_restart_script();
let timeout = CODEX_DESKTOP_QUIT_TIMEOUT + CODEX_DESKTOP_COMMAND_TIMEOUT;
let deadline = Instant::now()
.checked_add(timeout)
.ok_or_else(|| "创建 Codex/ChatGPT 重启超时时间失败".to_string())?;
let output = run_program_with_timeout(
Path::new("powershell.exe"),
&[
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
script.as_str(),
],
Some(deadline),
timeout,
)
.ok_or_else(|| "重启 Codex/ChatGPT 客户端失败或超时".to_string())?;
if !output.status.success() {
let message = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(if message.is_empty() {
"重启 Codex/ChatGPT 客户端失败".to_string()
} else {
format!("重启 Codex/ChatGPT 客户端失败: {message}")
});
}
parse_windows_restart_result(&String::from_utf8_lossy(&output.stdout))
.ok_or_else(|| "无法确认 Codex/ChatGPT 客户端的重启结果".to_string())
}
#[cfg(target_os = "windows")]
fn windows_app_version(deadline: Option<Instant>) -> Option<String> {
let mut roots = Vec::new();
@@ -680,6 +1121,11 @@ fn windows_app_version(_deadline: Option<Instant>) -> Option<String> {
None
}
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
pub fn restart_codex_desktop() -> Result<(String, bool), String> {
Err("当前平台暂不支持重启 Codex/ChatGPT 桌面客户端".to_string())
}
fn path_codex_candidates(deadline: Option<Instant>) -> Vec<PathBuf> {
let mut candidates = ["codex", "codex.exe", "codex.cmd"]
.into_iter()
@@ -805,8 +1251,10 @@ pub fn detect_codex_version() -> Option<String> {
#[cfg(test)]
mod tests {
use super::{
cached_codex_version, latest_windows_package_version, plist_string_value, run_program,
version_line, visit_named_files,
cached_codex_version, is_supported_macos_codex_bundle_id, latest_windows_package_version,
macos_app_metadata_from_plist, macos_codex_app_paths, parse_macos_main_process_ids,
parse_windows_restart_result, plist_string_value, run_program, version_line,
visit_named_files, windows_restart_script,
};
use std::fs;
use std::path::Path;
@@ -943,6 +1391,40 @@ mod tests {
);
}
#[test]
fn windows_restart_result_parser_ignores_noise_and_reads_state() {
let output = "PowerShell startup text\nCODEX_X_DESKTOP_RESTART\tChatGPT\t1\n";
assert_eq!(
parse_windows_restart_result(output),
Some(("ChatGPT".to_string(), true))
);
assert_eq!(
parse_windows_restart_result("CODEX_X_DESKTOP_RESTART\tCodex\t0\n"),
Some(("Codex".to_string(), false))
);
assert_eq!(
parse_windows_restart_result("CODEX_X_DESKTOP_RESTART\tOther\t1\n"),
None
);
}
#[test]
fn windows_restart_script_only_stops_processes_in_the_selected_package_directory() {
let script = windows_restart_script();
assert!(script.contains("$processPath.StartsWith($appRoot"));
assert!(script.contains("$executableName -ieq 'Codex.exe'"));
assert!(script.contains("$executableName -ieq 'ChatGPT.exe'"));
assert!(script.contains("-not $processPath.StartsWith($resourcesRoot"));
assert!(script.contains("Stop-Process -InputObject $process -Force"));
assert!(script.contains("[Console]::OutputEncoding = $utf8"));
assert!(script.contains("shell:AppsFolder\\"));
assert!(!script.contains("CloseMainWindow"));
assert!(!script.contains("Get-Process -Name"));
assert!(!script.contains("Stop-Process -Name"));
assert!(!script.contains("taskkill"));
assert!(!script.contains("ApplicationFrameHost"));
}
#[test]
fn plist_parser_reads_codex_bundle_version() {
let plist = r#"<plist><dict>
@@ -954,4 +1436,53 @@ mod tests {
Some("1.2026.204")
);
}
#[test]
fn macos_app_metadata_parser_reads_exact_desktop_identity() {
let plist = r#"<plist><dict>
<key>CFBundleDisplayName</key>
<string>ChatGPT</string>
<key>CFBundleExecutable</key>
<string>ChatGPT</string>
<key>CFBundleIdentifier</key>
<string>com.openai.codex</string>
</dict></plist>"#;
let metadata = macos_app_metadata_from_plist(plist).expect("parse app metadata");
assert_eq!(metadata.bundle_id, "com.openai.codex");
assert_eq!(metadata.executable, "ChatGPT");
assert_eq!(metadata.display_name.as_deref(), Some("ChatGPT"));
assert!(is_supported_macos_codex_bundle_id(&metadata.bundle_id));
assert!(!is_supported_macos_codex_bundle_id("com.example.codex"));
assert!(macos_app_metadata_from_plist(
"<plist><dict><key>CFBundleIdentifier</key><string>com.openai.codex</string></dict></plist>"
)
.is_none());
}
#[test]
fn macos_app_candidates_reuse_supported_names_in_stable_order() {
let paths = macos_codex_app_paths(Path::new("/Users/tester"));
assert_eq!(
paths.first(),
Some(&Path::new("/Applications/Codex.app").to_path_buf())
);
assert_eq!(
paths.last(),
Some(&Path::new("/Users/tester/Applications/ChatGPT.app").to_path_buf())
);
}
#[test]
fn macos_process_parser_only_matches_the_exact_main_executable() {
let executable = Path::new("/Applications/ChatGPT.app/Contents/MacOS/ChatGPT");
let output = " 101 /Applications/ChatGPT.app/Contents/MacOS/ChatGPT\n\
102 /Applications/ChatGPT.app/Contents/MacOS/ChatGPT --started-from-launch-services\n\
103 /Applications/ChatGPT.app/Contents/Frameworks/Codex (Renderer).app/Contents/MacOS/Codex (Renderer)\n\
104 /Applications/ChatGPT.app/Contents/MacOS/ChatGPT-helper\n\
105 /Applications/Other.app/Contents/MacOS/ChatGPT\n";
assert_eq!(
parse_macos_main_process_ids(output, executable),
vec![101, 102]
);
}
}
@@ -395,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;
}
+158 -27
View File
@@ -8,9 +8,10 @@ use super::official_auth::{
use super::{
custom_provider_id, 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, open_store,
provider_template_from_document, reserved_codex_provider_id, rollback_provider_store_inner,
save_provider_with_rollback_inner, strip_provider_bearer_tokens,
matching_saved_provider_ids_for_live, normalize_saved_provider,
normalize_saved_provider_for_save, open_store, provider_template_from_document,
reconcile_active_provider_on_connection, reserved_codex_provider_id,
rollback_provider_store_inner, save_provider_with_rollback_inner, strip_provider_bearer_tokens,
unique_saved_provider_id_for_live, ProviderStoreRollback, SavedProvider,
};
use crate::backups::create_backup;
@@ -1224,42 +1225,33 @@ fn save_active_provider_with_apply<F>(
where
F: FnOnce(&SavedProvider, &Path, Option<Vec<u8>>) -> Result<ActionResult>,
{
let provider = normalize_saved_provider(provider)?;
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)?;
let active_config = read_file_snapshot(&config_path(&codex_dir))?;
let conn = open_store()?;
let provider = normalize_saved_provider_for_save(&conn, provider)?;
let saved_before = list_saved_providers_on_connection(&conn)?;
let live = detected_live_custom_provider(&codex_dir)?.ok_or_else(|| {
CodexxError::Config("当前不是可编辑的第三方供应商,未修改保存记录".to_string())
})?;
let matches = matching_saved_provider_ids_for_live(&live, &saved_before);
match matches.as_slice() {
[] => {
if saved_before
.iter()
.any(|candidate| candidate.id == provider.id)
{
return Err(CodexxError::Config(format!(
"供应商 ID {} 已被另一条配置使用,请更换名称后再保存",
provider.id
)));
}
}
[active_id] if active_id == &provider.id => {}
[active_id] => {
if matches.is_empty() {
if saved_before
.iter()
.any(|candidate| candidate.id == provider.id)
{
return Err(CodexxError::Config(format!(
"当前启用的是 {active_id},不能把 {} 作为活动配置保存",
"供应商 ID {} 已被另一条配置使用,请更换名称后再保存",
provider.id
)));
}
_ => {
return Err(CodexxError::Config(
"当前 live 供应商匹配到多条保存记录,请先清理重复配置".to_string(),
));
}
} else if !matches.iter().any(|active_id| active_id == &provider.id) {
return Err(CodexxError::Config(format!(
"当前 live 配置不匹配供应商 {},不能作为活动配置保存",
provider.id
)));
}
let (saved, rollback) = save_provider_with_rollback_inner(provider)?;
@@ -1319,15 +1311,18 @@ pub(crate) fn delete_saved_provider_inner(id: &str, config_dir: Option<String>)
return Err(CodexxError::Config("供应商 ID 不能为空".to_string()));
}
let codex_dir = resolve_codex_dir(config_dir)?;
let providers = list_saved_providers_inner()?;
let conn = open_store()?;
let providers = list_saved_providers_on_connection(&conn)?;
if let Some(live) = detected_live_custom_provider(&codex_dir)? {
let active_ids = matching_saved_provider_ids_for_live(&live, &providers);
if live.id == id || active_ids.iter().any(|active_id| active_id == id) {
let active_id = reconcile_active_provider_on_connection(&conn, &codex_dir, &active_ids)?;
if live.id == id || active_id.as_deref() == Some(id) {
return Err(CodexxError::Config(
"不能直接删除当前启用的供应商,请先切换到官方配置或其他供应商".to_string(),
));
}
}
drop(conn);
delete_provider_inner(id)
}
@@ -1335,7 +1330,7 @@ pub(crate) fn delete_saved_provider_inner(id: &str, config_dir: Option<String>)
mod tests {
use super::*;
use crate::file_io::{write_json, write_text};
use crate::providers::delete_provider_inner;
use crate::providers::{delete_provider_inner, remember_active_provider_on_connection};
use crate::providers::{list_saved_providers_inner, save_provider_inner};
use serde_json::json;
use std::fs;
@@ -1980,6 +1975,142 @@ command = "docs-server"
fs::remove_dir_all(codex_dir).expect("remove active provider test directory");
}
#[test]
fn active_provider_save_uses_explicit_id_when_live_profile_has_duplicates() {
let _db_guard = crate::app_db::test_db_guard();
let tag = 50_001;
let first_id = format!("active-duplicate-first-{tag}");
let target_id = format!("active-duplicate-target-{tag}");
let codex_dir = active_provider_test_dir("duplicate-explicit-edit", tag);
let first =
active_provider_fixture(tag, &first_id, "Shared Before", "model-before", "sk-before");
let target = active_provider_fixture(
tag,
&target_id,
"Shared Before",
"model-before",
"sk-before",
);
save_provider_inner(first.clone()).expect("save first duplicate provider");
save_provider_inner(target).expect("save target duplicate provider");
write_active_provider_files(&codex_dir, &first);
let updated = active_provider_fixture(
tag,
&target_id,
"Explicit Target",
"model-after",
"sk-after",
);
let result = save_active_provider_inner(updated, Some(codex_dir.display().to_string()))
.expect("edit the explicitly selected active duplicate");
let untouched = saved_provider(&first_id);
assert_eq!(untouched.provider_name, "Shared Before");
assert_eq!(untouched.model, "model-before");
assert_eq!(untouched.api_key.as_deref(), Some("sk-before"));
let saved_target = saved_provider(&target_id);
assert_eq!(saved_target.provider_name, "Explicit Target");
assert_eq!(saved_target.model, "model-after");
assert_eq!(saved_target.api_key.as_deref(), Some("sk-after"));
let state = serde_json::to_value(&result.state).expect("serialize updated state");
assert_eq!(
state["activeSavedProviderId"].as_str(),
Some(target_id.as_str())
);
delete_provider_inner(&first_id).expect("delete first duplicate provider");
delete_provider_inner(&target_id).expect("delete target duplicate provider");
fs::remove_dir_all(codex_dir).expect("remove duplicate edit test directory");
}
#[test]
fn explicit_current_id_protects_only_the_active_duplicate_from_deletion() {
let _db_guard = crate::app_db::test_db_guard();
let tag = 50_002;
let first_id = format!("active-delete-duplicate-first-{tag}");
let remaining_id = format!("active-delete-duplicate-remaining-{tag}");
let codex_dir = active_provider_test_dir("duplicate-delete-guard", tag);
let first = active_provider_fixture(
tag,
&first_id,
"Shared Provider",
"shared-model",
"sk-shared",
);
let remaining = active_provider_fixture(
tag,
&remaining_id,
"Shared Provider",
"shared-model",
"sk-shared",
);
save_provider_inner(first.clone()).expect("save first duplicate provider");
save_provider_inner(remaining).expect("save remaining duplicate provider");
write_active_provider_files(&codex_dir, &first);
let conn = open_store().expect("open provider store");
remember_active_provider_on_connection(&conn, &codex_dir, &first_id)
.expect("remember the explicit current provider");
drop(conn);
delete_saved_provider_inner(&remaining_id, Some(codex_dir.display().to_string()))
.expect("delete the inactive duplicate");
assert!(list_saved_providers_inner()
.expect("list providers after duplicate deletion")
.iter()
.all(|provider| provider.id != remaining_id));
let delete_error =
delete_saved_provider_inner(&first_id, Some(codex_dir.display().to_string()))
.expect_err("the explicitly selected provider must be protected");
assert!(delete_error.to_string().contains("不能直接删除当前启用"));
assert_eq!(saved_provider(&first_id).provider_name, "Shared Provider");
delete_provider_inner(&first_id).expect("delete active duplicate during cleanup");
fs::remove_dir_all(codex_dir).expect("remove duplicate delete test directory");
}
#[test]
fn active_provider_save_preserves_a_legacy_custom_record_id() {
let _db_guard = crate::app_db::test_db_guard();
let tag = 50_003;
let temporary_id = format!("legacy-custom-seed-{tag}");
let codex_dir = active_provider_test_dir("legacy-custom-edit", tag);
let temporary = active_provider_fixture(
tag,
&temporary_id,
"Legacy Before",
"model-before",
"sk-before",
);
save_provider_inner(temporary.clone()).expect("seed temporary legacy provider");
let conn = open_store().expect("open provider store");
conn.execute(
"UPDATE providers SET id = 'custom' WHERE id = ?1",
[&temporary_id],
)
.expect("restore historical custom ID");
drop(conn);
let mut live = temporary;
live.id = "custom".to_string();
write_active_provider_files(&codex_dir, &live);
let updated =
active_provider_fixture(tag, "custom", "Legacy After", "model-after", "sk-after");
let result = save_active_provider_inner(updated, Some(codex_dir.display().to_string()))
.expect("edit active provider with a legacy custom ID");
assert_eq!(saved_provider("custom").provider_name, "Legacy After");
assert!(list_saved_providers_inner()
.expect("list providers after legacy edit")
.iter()
.all(|provider| provider.id != "custom-custom"));
let state = serde_json::to_value(&result.state).expect("serialize updated state");
assert_eq!(state["activeSavedProviderId"].as_str(), Some("custom"));
delete_provider_inner("custom").expect("delete legacy custom provider");
fs::remove_dir_all(codex_dir).expect("remove legacy custom test directory");
}
#[test]
fn active_provider_save_rolls_back_record_when_apply_fails_before_writing() {
let _db_guard = crate::app_db::test_db_guard();
+10 -4
View File
@@ -2,6 +2,7 @@ mod ccswitch;
mod connection;
mod live;
mod official_auth;
mod selection;
mod store;
use crate::error::Result;
@@ -40,6 +41,10 @@ pub(crate) use official_auth::{
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::{
canonical_provider_base_url, provider_by_id_on_connection, provider_identity,
@@ -50,10 +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, strip_provider_bearer_tokens,
unique_saved_provider_id_for_live, upsert_ccswitch_provider_on_connection,
ProviderStoreRollback, 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> {
@@ -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());
}
}
+327 -330
View File
@@ -1,9 +1,9 @@
use super::open_store as open_db;
use super::{clear_provider_selections_on_connection, open_store as open_db};
use crate::error::{CodexxError, Result};
use crate::{now_rfc3339, sanitize_id};
use rusqlite::{params, Connection, TransactionBehavior};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::collections::HashSet;
use toml_edit::{value, DocumentMut};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -43,6 +43,13 @@ pub(crate) enum ProviderIdentity {
Unauthenticated { base_url: String, name: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct ProviderProfileIdentity {
// Matching helper only; manually saved rows are always addressed by ID.
provider: ProviderIdentity,
model: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ProviderUpsertMode {
Manual,
@@ -53,7 +60,6 @@ pub(crate) enum ProviderUpsertMode {
pub(crate) enum ProviderUpsertKind {
Added,
Updated,
Merged,
}
#[derive(Debug, Clone)]
@@ -161,6 +167,13 @@ pub(crate) fn provider_identity(provider: &SavedProvider) -> Option<ProviderIden
(!name.is_empty()).then_some(ProviderIdentity::Unauthenticated { base_url, name })
}
fn provider_profile_identity(provider: &SavedProvider) -> Option<ProviderProfileIdentity> {
provider_identity(provider).map(|identity| ProviderProfileIdentity {
provider: identity,
model: provider.model.trim().to_string(),
})
}
pub(crate) fn provider_template_from_document(
doc: &DocumentMut,
provider_id: &str,
@@ -211,8 +224,12 @@ fn same_provider_endpoint_and_name(left: &SavedProvider, right: &SavedProvider)
== normalized_provider_name(&right.provider_name)
}
fn same_provider_profile_fallback(left: &SavedProvider, right: &SavedProvider) -> bool {
same_provider_endpoint_and_name(left, right) && left.model.trim() == right.model.trim()
}
fn compatible_provider_match(left: &SavedProvider, right: &SavedProvider) -> bool {
if !same_provider_endpoint_and_name(left, right) {
if !same_provider_profile_fallback(left, right) {
return false;
}
match (
@@ -224,30 +241,16 @@ fn compatible_provider_match(left: &SavedProvider, right: &SavedProvider) -> boo
}
}
fn unique_compatible_provider<'a>(
stored: &'a [StoredProvider],
incoming: &SavedProvider,
) -> Option<&'a StoredProvider> {
let mut matches = stored
.iter()
.filter(|candidate| compatible_provider_match(&candidate.provider, incoming));
let candidate = matches.next()?;
matches.next().is_none().then_some(candidate)
}
pub(crate) fn matching_saved_provider_ids_for_live(
live: &SavedProvider,
providers: &[SavedProvider],
) -> Vec<String> {
let stable = providers
.iter()
.filter(|candidate| !is_historical_custom_provider_id(&candidate.id))
.collect::<Vec<_>>();
if let Some(identity) = provider_identity(live) {
let exact = stable
let candidates = providers.iter().collect::<Vec<_>>();
if let Some(identity) = provider_profile_identity(live) {
let exact = candidates
.iter()
.copied()
.filter(|candidate| provider_identity(candidate).as_ref() == Some(&identity))
.filter(|candidate| provider_profile_identity(candidate).as_ref() == Some(&identity))
.collect::<Vec<_>>();
if !exact.is_empty() {
return exact
@@ -257,7 +260,7 @@ pub(crate) fn matching_saved_provider_ids_for_live(
}
}
let compatible = stable
let compatible = candidates
.iter()
.copied()
.filter(|candidate| compatible_provider_match(live, candidate))
@@ -272,9 +275,9 @@ pub(crate) fn matching_saved_provider_ids_for_live(
if !is_historical_custom_provider_id(&live.id) {
return Vec::new();
}
stable
candidates
.into_iter()
.filter(|candidate| same_provider_endpoint_and_name(live, candidate))
.filter(|candidate| same_provider_profile_fallback(live, candidate))
.map(|candidate| candidate.id.clone())
.collect()
}
@@ -306,7 +309,7 @@ fn stored_providers_on_connection(conn: &Connection) -> Result<Vec<StoredProvide
"SELECT id, provider_name, base_url, model, api_key, toml_config, wire_api,
requires_openai_auth, created_at, updated_at, source, source_id
FROM providers
ORDER BY created_at ASC, updated_at ASC, id ASC",
ORDER BY created_at ASC, rowid ASC",
)
.map_err(|e| CodexxError::Database(e.to_string()))?;
let rows = stmt
@@ -478,34 +481,6 @@ fn source_matches(row: &StoredProvider, origin: (&str, &str)) -> bool {
source_matches && row.source_id.as_deref() == Some(origin.1)
}
fn source_can_merge(row: &StoredProvider, origin: Option<(&str, &str)>) -> bool {
match origin {
None => true,
Some(origin) => row.source == MANUAL_PROVIDER_SOURCE || source_matches(row, origin),
}
}
fn conflicting_provider_ids(
stored: &[StoredProvider],
provider: &SavedProvider,
origin: Option<(&str, &str)>,
) -> Vec<String> {
let identity = provider_identity(provider);
stored
.iter()
.filter(|candidate| candidate.provider.id != provider.id)
.filter(|candidate| source_can_merge(candidate, origin))
.filter(|candidate| {
identity.as_ref().is_some_and(|identity| {
provider_identity(&candidate.provider).as_ref() == Some(identity)
}) || (is_historical_custom_provider_id(&candidate.provider.id)
&& !is_historical_custom_provider_id(&provider.id)
&& same_provider_endpoint_and_name(&candidate.provider, provider))
})
.map(|candidate| candidate.provider.id.clone())
.collect()
}
fn upsert_provider_in_savepoint(
conn: &Connection,
mut provider: SavedProvider,
@@ -513,70 +488,27 @@ fn upsert_provider_in_savepoint(
origin: Option<(&str, &str)>,
) -> Result<ProviderUpsertResult> {
let requested_id = provider.id.clone();
let identity = provider_identity(&provider);
let stored = stored_providers_on_connection(conn)?;
let source_match = origin.and_then(|origin| {
stored
.iter()
.find(|candidate| source_matches(candidate, origin))
});
let merge_candidates = stored
.iter()
.filter(|candidate| source_can_merge(candidate, origin))
.cloned()
.collect::<Vec<_>>();
let identity_match = identity.as_ref().and_then(|identity| {
merge_candidates
.iter()
.find(|candidate| provider_identity(&candidate.provider).as_ref() == Some(identity))
});
let manual_candidates = stored
.iter()
.filter(|candidate| candidate.source == MANUAL_PROVIDER_SOURCE)
.cloned()
.collect::<Vec<_>>();
let manual_identity_match = identity.as_ref().and_then(|identity| {
manual_candidates
.iter()
.find(|candidate| provider_identity(&candidate.provider).as_ref() == Some(identity))
});
let manual_compatible_match = unique_compatible_provider(&manual_candidates, &provider);
let exact_id_match = stored
.iter()
.find(|candidate| candidate.provider.id == requested_id);
let compatible_match = unique_compatible_provider(&merge_candidates, &provider);
let import_rehome_target = source_match.and_then(|source| {
manual_identity_match
.or(manual_compatible_match)
.filter(|candidate| candidate.provider.id != source.provider.id)
});
let target = match mode {
ProviderUpsertMode::Manual => exact_id_match.or(identity_match).or(compatible_match),
ProviderUpsertMode::Imported => import_rehome_target
.or(source_match)
.or(identity_match)
.or(compatible_match),
ProviderUpsertMode::Manual => exact_id_match,
ProviderUpsertMode::Imported => source_match,
};
let preserves_local_id = mode == ProviderUpsertMode::Imported
&& target.is_some_and(|candidate| {
candidate.source == MANUAL_PROVIDER_SOURCE
|| candidate.source == CCSWITCH_LOCAL_PROVIDER_SOURCE
});
let kind = if let Some(target) = target {
let existing = &target.provider;
let same_id = existing.id == requested_id;
provider.id = existing.id.clone();
if mode == ProviderUpsertMode::Imported {
provider = merge_authoritative_import(provider, existing);
}
if import_rehome_target.is_some() {
ProviderUpsertKind::Merged
} else if source_match.is_some() || same_id {
ProviderUpsertKind::Updated
} else {
ProviderUpsertKind::Merged
}
ProviderUpsertKind::Updated
} else {
if exact_id_match.is_some() {
provider.id = unique_provider_id_on_connection(conn, &provider.id)?;
@@ -584,23 +516,7 @@ fn upsert_provider_in_savepoint(
ProviderUpsertKind::Added
};
if let (Some(source), Some(target)) = (source_match, import_rehome_target) {
if source.provider.id != target.provider.id {
conn.execute("DELETE FROM providers WHERE id = ?1", [&source.provider.id])
.map_err(|e| CodexxError::Database(e.to_string()))?;
}
}
let write_origin = match origin {
Some((CCSWITCH_PROVIDER_SOURCE, source_id)) if preserves_local_id => {
Some((CCSWITCH_LOCAL_PROVIDER_SOURCE, source_id))
}
_ => origin,
};
write_provider_with_origin(conn, &provider, write_origin)?;
for duplicate_id in conflicting_provider_ids(&stored, &provider, origin) {
conn.execute("DELETE FROM providers WHERE id = ?1", [&duplicate_id])
.map_err(|e| CodexxError::Database(e.to_string()))?;
}
write_provider_with_origin(conn, &provider, origin)?;
let provider = provider_by_id_on_connection(conn, &provider.id)?
.ok_or_else(|| CodexxError::Database("provider saved but not found".to_string()))?;
Ok(ProviderUpsertResult { provider, kind })
@@ -671,164 +587,11 @@ fn is_historical_custom_provider_id(id: &str) -> bool {
}
pub(crate) fn consolidate_legacy_provider_duplicates_on_connection(
conn: &Connection,
_conn: &Connection,
) -> Result<usize> {
const SAVEPOINT: &str = "codex_x_provider_cleanup";
conn.execute_batch(&format!("SAVEPOINT {SAVEPOINT}"))
.map_err(|e| CodexxError::Database(e.to_string()))?;
let result = (|| -> Result<usize> {
let rows = stored_providers_on_connection(conn)?;
let mut groups: HashMap<ProviderIdentity, Vec<StoredProvider>> = HashMap::new();
for row in rows {
if let Some(identity @ ProviderIdentity::Credential(_)) =
provider_identity(&row.provider)
{
groups.entry(identity).or_default().push(row);
}
}
let duplicate_groups = groups
.into_values()
.filter(|group| group.len() > 1)
.collect::<Vec<_>>();
let mut merged = 0usize;
for mut group in duplicate_groups {
let preserves_local_profile = group.iter().any(|row| {
row.source == MANUAL_PROVIDER_SOURCE || row.source == CCSWITCH_LOCAL_PROVIDER_SOURCE
});
let mut origins = group
.iter()
.filter_map(|row| {
row.source_id.as_ref().map(|source_id| {
let source = if row.source == CCSWITCH_LOCAL_PROVIDER_SOURCE {
CCSWITCH_PROVIDER_SOURCE.to_string()
} else {
row.source.clone()
};
(source, source_id.clone())
})
})
.collect::<Vec<_>>();
origins.sort();
origins.dedup();
if origins.len() > 1 {
continue;
}
group.sort_by(|left, right| {
let left_is_local = left.source == MANUAL_PROVIDER_SOURCE
|| left.source == CCSWITCH_LOCAL_PROVIDER_SOURCE;
let right_is_local = right.source == MANUAL_PROVIDER_SOURCE
|| right.source == CCSWITCH_LOCAL_PROVIDER_SOURCE;
right_is_local
.cmp(&left_is_local)
.then_with(|| {
is_historical_custom_provider_id(&left.provider.id)
.cmp(&is_historical_custom_provider_id(&right.provider.id))
})
.then_with(|| right.updated_at.cmp(&left.updated_at))
.then_with(|| right.created_at.cmp(&left.created_at))
.then_with(|| left.provider.id.cmp(&right.provider.id))
});
let mut survivor = group[0].clone();
for duplicate in group.iter().skip(1) {
if survivor.provider.provider_name.trim().is_empty()
&& !duplicate.provider.provider_name.trim().is_empty()
{
survivor.provider.provider_name = duplicate.provider.provider_name.clone();
}
if survivor.provider.model.trim().is_empty()
&& !duplicate.provider.model.trim().is_empty()
{
survivor.provider.model = duplicate.provider.model.clone();
}
if survivor
.provider
.toml_config
.as_deref()
.is_none_or(|value| value.trim().is_empty())
&& duplicate
.provider
.toml_config
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
{
survivor.provider.toml_config = duplicate.provider.toml_config.clone();
}
if survivor.provider.api_key.is_none() && duplicate.provider.api_key.is_some() {
survivor.provider.api_key = duplicate.provider.api_key.clone();
}
}
survivor.provider.base_url = canonical_provider_base_url(&survivor.provider.base_url);
survivor.provider.api_key = survivor
.provider
.api_key
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
for duplicate in group.iter().skip(1) {
conn.execute(
"DELETE FROM providers WHERE id = ?1",
[&duplicate.provider.id],
)
.map_err(|e| CodexxError::Database(e.to_string()))?;
merged += 1;
}
let origin = origins.first().map(|(source, source_id)| {
let source = if preserves_local_profile && source == CCSWITCH_PROVIDER_SOURCE {
CCSWITCH_LOCAL_PROVIDER_SOURCE
} else {
source.as_str()
};
(source, source_id.as_str())
});
write_provider_with_origin(conn, &survivor.provider, origin)?;
}
loop {
let rows = stored_providers_on_connection(conn)?;
let ghost_id = rows.iter().find_map(|ghost| {
if !is_historical_custom_provider_id(&ghost.provider.id) {
return None;
}
if ghost.source != MANUAL_PROVIDER_SOURCE || ghost.source_id.is_some() {
return None;
}
let mut candidates = rows.iter().filter(|candidate| {
!is_historical_custom_provider_id(&candidate.provider.id)
&& same_provider_endpoint_and_name(&ghost.provider, &candidate.provider)
});
candidates.next()?;
candidates
.next()
.is_none()
.then(|| ghost.provider.id.clone())
});
let Some(ghost_id) = ghost_id else {
break;
};
conn.execute("DELETE FROM providers WHERE id = ?1", [&ghost_id])
.map_err(|e| CodexxError::Database(e.to_string()))?;
merged += 1;
}
Ok(merged)
})();
match result {
Ok(merged) => {
conn.execute_batch(&format!("RELEASE SAVEPOINT {SAVEPOINT}"))
.map_err(|e| CodexxError::Database(e.to_string()))?;
Ok(merged)
}
Err(error) => {
let rollback = conn.execute_batch(&format!(
"ROLLBACK TO SAVEPOINT {SAVEPOINT}; RELEASE SAVEPOINT {SAVEPOINT}"
));
match rollback {
Ok(()) => Err(error),
Err(rollback_error) => Err(CodexxError::Database(format!(
"{error}; provider cleanup rollback failed: {rollback_error}"
))),
}
}
}
// Distinct IDs are independent records, even when every configuration
// field matches. Keep the legacy hook for import-result compatibility.
Ok(0)
}
fn apply_provider_toml_authority(provider: &mut SavedProvider) -> Result<()> {
@@ -1003,18 +766,32 @@ pub(crate) fn normalize_saved_provider(provider: SavedProvider) -> Result<SavedP
Ok(normalized)
}
pub(crate) fn save_manual_provider_on_connection(
pub(crate) fn normalize_saved_provider_for_save(
conn: &Connection,
provider: SavedProvider,
) -> Result<SavedProvider> {
let requested_id = provider.id.trim().to_string();
let provider = normalize_saved_provider(provider)?;
if requested_id != provider.id && provider_by_id_on_connection(conn, &provider.id)?.is_some() {
let mut normalized = normalize_saved_provider(provider)?;
if provider_by_id_on_connection(conn, &requested_id)?.is_some() {
// Existing IDs are record identities. Preserve legacy reserved IDs such
// as `custom` instead of treating an edit as a new normalized record.
normalized.id = requested_id;
} else if requested_id != normalized.id
&& provider_by_id_on_connection(conn, &normalized.id)?.is_some()
{
return Err(CodexxError::Config(format!(
"供应商 ID {} 规范化后与现有供应商冲突,请更换名称或 ID",
requested_id
)));
}
Ok(normalized)
}
pub(crate) fn save_manual_provider_on_connection(
conn: &Connection,
provider: SavedProvider,
) -> Result<SavedProvider> {
let provider = normalize_saved_provider_for_save(conn, provider)?;
Ok(upsert_provider_on_connection(conn, provider, ProviderUpsertMode::Manual)?.provider)
}
@@ -1121,9 +898,17 @@ pub(crate) fn rollback_provider_store_inner(rollback: ProviderStoreRollback) ->
}
pub(crate) fn delete_provider_inner(id: &str) -> Result<()> {
let conn = open_db()?;
conn.execute("DELETE FROM providers WHERE id = ?1", params![id])
let mut conn = open_db()?;
let transaction = conn
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(|error| CodexxError::Database(error.to_string()))?;
clear_provider_selections_on_connection(&transaction, id)?;
transaction
.execute("DELETE FROM providers WHERE id = ?1", params![id])
.map_err(|e| CodexxError::Database(e.to_string()))?;
transaction
.commit()
.map_err(|error| CodexxError::Database(error.to_string()))?;
Ok(())
}
@@ -1204,7 +989,7 @@ mod tests {
}
#[test]
fn provider_upsert_deduplicates_safe_matches_and_keeps_distinct_credentials() {
fn provider_upsert_uses_record_ids_and_stable_import_sources() {
let stable = test_connection();
let mut existing = provider("cc-stable-id", "Old name", Some("sk-old"));
existing.toml_config = Some("model = \"locally-preserved\"".to_string());
@@ -1263,16 +1048,16 @@ mod tests {
ProviderUpsertMode::Manual,
)
.unwrap();
let merged = upsert_ccswitch_provider_on_connection(
let added = upsert_ccswitch_provider_on_connection(
&compatible,
provider("cc-import", " same api ", Some("sk-imported")),
"compatible-row",
)
.unwrap();
assert_eq!(merged.kind, ProviderUpsertKind::Merged);
assert_eq!(merged.provider.id, "local");
assert_eq!(merged.provider.api_key.as_deref(), Some("sk-imported"));
assert_eq!(provider_count(&compatible), 1);
assert_eq!(added.kind, ProviderUpsertKind::Added);
assert_eq!(added.provider.id, "cc-import");
assert_eq!(added.provider.api_key.as_deref(), Some("sk-imported"));
assert_eq!(provider_count(&compatible), 2);
let distinct = test_connection();
upsert_provider_on_connection(
@@ -1292,7 +1077,166 @@ mod tests {
}
#[test]
fn stable_import_collision_keeps_manual_id_and_applies_source_values() {
fn provider_upsert_keeps_manual_profiles_with_distinct_ids() {
let conn = test_connection();
let mut gpt = provider("gpt-profile", "Same API", Some("sk-shared"));
gpt.model = "gpt-5.6".to_string();
let added = upsert_provider_on_connection(&conn, gpt.clone(), ProviderUpsertMode::Manual)
.expect("save GPT profile");
assert_eq!(added.kind, ProviderUpsertKind::Added);
let exact_copy = SavedProvider {
id: "gpt-copy".to_string(),
..gpt.clone()
};
let added = upsert_provider_on_connection(&conn, exact_copy, ProviderUpsertMode::Manual)
.expect("save an identical profile under a different ID");
assert_eq!(added.kind, ProviderUpsertKind::Added);
assert_eq!(added.provider.id, "gpt-copy");
let renamed_copy = SavedProvider {
id: "gpt-renamed".to_string(),
provider_name: "Renamed API".to_string(),
..gpt.clone()
};
let added = upsert_provider_on_connection(&conn, renamed_copy, ProviderUpsertMode::Manual)
.expect("save a renamed profile under a different ID");
assert_eq!(added.kind, ProviderUpsertKind::Added);
let edited = SavedProvider {
provider_name: "Edited GPT".to_string(),
..gpt
};
let updated = upsert_provider_on_connection(&conn, edited, ProviderUpsertMode::Manual)
.expect("update the profile with the same ID");
assert_eq!(updated.kind, ProviderUpsertKind::Updated);
assert_eq!(updated.provider.id, "gpt-profile");
assert_eq!(updated.provider.provider_name, "Edited GPT");
let mut deepseek = provider("deepseek-profile", "Renamed API", Some("sk-shared"));
deepseek.model = " deepseek-v3 ".to_string();
let added = upsert_provider_on_connection(&conn, deepseek, ProviderUpsertMode::Manual)
.expect("save DeepSeek profile with shared credentials");
assert_eq!(added.kind, ProviderUpsertKind::Added);
assert_eq!(provider_count(&conn), 4);
}
#[test]
fn provider_listing_preserves_insertion_order_when_timestamps_match() {
let conn = test_connection();
for id in ["z-original", "m-sibling", "a-copy"] {
write_provider_on_connection(&conn, &provider(id, id, Some("sk-shared")))
.expect("insert provider");
}
conn.execute(
"UPDATE providers
SET created_at = '2026-01-01T00:00:00Z',
updated_at = CASE id
WHEN 'z-original' THEN '2026-03-01T00:00:00Z'
WHEN 'm-sibling' THEN '2026-02-01T00:00:00Z'
ELSE '2026-01-01T00:00:00Z'
END",
[],
)
.expect("align creation timestamps");
let listed_ids = list_saved_providers_on_connection(&conn)
.expect("list providers")
.into_iter()
.map(|provider| provider.id)
.collect::<Vec<_>>();
assert_eq!(listed_ids, ["z-original", "m-sibling", "a-copy"]);
let mut edited = provider("z-original", "Edited original", Some("sk-shared"));
edited.model = "gpt-5.6".to_string();
upsert_provider_on_connection(&conn, edited, ProviderUpsertMode::Manual)
.expect("update first provider");
let listed_ids = list_saved_providers_on_connection(&conn)
.expect("list providers after edit")
.into_iter()
.map(|provider| provider.id)
.collect::<Vec<_>>();
assert_eq!(listed_ids, ["z-original", "m-sibling", "a-copy"]);
}
#[test]
fn manual_save_preserves_an_existing_legacy_reserved_id() {
let conn = test_connection();
let legacy = provider("custom", "Legacy Provider", Some("sk-legacy"));
write_provider_on_connection(&conn, &legacy).expect("seed legacy custom record");
let edited = SavedProvider {
provider_name: "Edited Legacy Provider".to_string(),
..legacy
};
let saved = save_manual_provider_on_connection(&conn, edited)
.expect("edit the existing legacy custom record");
assert_eq!(saved.id, "custom");
assert_eq!(saved.provider_name, "Edited Legacy Provider");
assert_eq!(provider_count(&conn), 1);
assert!(provider_by_id_on_connection(&conn, "custom-custom")
.expect("look up normalized duplicate")
.is_none());
}
#[test]
fn ccswitch_import_keeps_ambiguous_manual_profiles_and_updates_by_source_id() {
let conn = test_connection();
let first = provider("manual-one", "Same API", Some("sk-shared"));
let second = SavedProvider {
id: "manual-two".to_string(),
..first.clone()
};
upsert_provider_on_connection(&conn, first, ProviderUpsertMode::Manual)
.expect("save first manual profile");
upsert_provider_on_connection(&conn, second, ProviderUpsertMode::Manual)
.expect("save second manual profile");
let imported = provider("cc-profile", "Same API", Some("sk-shared"));
let added = upsert_ccswitch_provider_on_connection(&conn, imported.clone(), "cc-source-id")
.expect("import alongside ambiguous manual profiles");
assert_eq!(added.kind, ProviderUpsertKind::Added);
assert_eq!(added.provider.id, "cc-profile");
assert_eq!(provider_count(&conn), 3);
let repeated = SavedProvider {
provider_name: "Updated from CC Switch".to_string(),
..imported
};
let updated = upsert_ccswitch_provider_on_connection(&conn, repeated, "cc-source-id")
.expect("update the same imported source row");
assert_eq!(updated.kind, ProviderUpsertKind::Updated);
assert_eq!(updated.provider.id, "cc-profile");
assert_eq!(updated.provider.provider_name, "Updated from CC Switch");
assert_eq!(provider_count(&conn), 3);
}
#[test]
fn ccswitch_import_keeps_a_manual_copy_of_its_current_profile() {
let conn = test_connection();
let imported = provider("cc-profile", "Same API", Some("sk-shared"));
upsert_ccswitch_provider_on_connection(&conn, imported.clone(), "cc-source-id")
.expect("seed imported profile");
let manual_copy = SavedProvider {
id: "manual-copy".to_string(),
..imported.clone()
};
upsert_provider_on_connection(&conn, manual_copy, ProviderUpsertMode::Manual)
.expect("save manual copy");
let updated = upsert_ccswitch_provider_on_connection(&conn, imported, "cc-source-id")
.expect("refresh imported profile by source ID");
assert_eq!(updated.kind, ProviderUpsertKind::Updated);
assert_eq!(updated.provider.id, "cc-profile");
assert_eq!(provider_count(&conn), 2);
assert!(provider_by_id_on_connection(&conn, "manual-copy")
.expect("read manual copy")
.is_some());
}
#[test]
fn stable_import_updates_its_source_without_claiming_a_manual_id() {
let conn = test_connection();
let mut imported = provider("cc-old", "Imported old", Some("sk-old"));
imported.base_url = "https://old.example.com/v1".to_string();
@@ -1301,8 +1245,8 @@ mod tests {
let mut manual = provider("manual-local", "Locally edited", Some("sk-shared"));
manual.base_url = "https://shared.example.com/v1".to_string();
manual.model = "local-model".to_string();
manual.toml_config = Some("model = \"local-model\"".to_string());
manual.model = "remote-model".to_string();
manual.toml_config = Some("model = \"remote-model\"".to_string());
upsert_provider_on_connection(&conn, manual, ProviderUpsertMode::Manual)
.expect("seed manual provider");
@@ -1310,20 +1254,26 @@ mod tests {
changed.base_url = "https://shared.example.com/v1".to_string();
changed.model = "remote-model".to_string();
let result = upsert_ccswitch_provider_on_connection(&conn, changed, "cc-row")
.expect("merge source update into manual record");
.expect("update the imported source record");
assert_eq!(result.kind, ProviderUpsertKind::Merged);
assert_eq!(result.provider.id, "manual-local");
assert_eq!(result.kind, ProviderUpsertKind::Updated);
assert_eq!(result.provider.id, "cc-old");
assert_eq!(result.provider.provider_name, "Imported changed");
assert_eq!(result.provider.model, "remote-model");
assert_eq!(
result.provider.toml_config.as_deref(),
Some("model = \"local-model\"")
);
let rows = stored_providers_on_connection(&conn).expect("read merged providers");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].source, CCSWITCH_LOCAL_PROVIDER_SOURCE);
assert_eq!(rows[0].source_id.as_deref(), Some("cc-row"));
let rows = stored_providers_on_connection(&conn).expect("read independent providers");
assert_eq!(rows.len(), 2);
let manual = rows
.iter()
.find(|row| row.provider.id == "manual-local")
.expect("manual provider remains");
assert_eq!(manual.provider.provider_name, "Locally edited");
assert_eq!(manual.source, MANUAL_PROVIDER_SOURCE);
let imported = rows
.iter()
.find(|row| row.provider.id == "cc-old")
.expect("imported provider remains");
assert_eq!(imported.source, CCSWITCH_PROVIDER_SOURCE);
assert_eq!(imported.source_id.as_deref(), Some("cc-row"));
}
#[test]
@@ -1417,7 +1367,7 @@ enabled = true
);
let rows = stored_providers_on_connection(&conn).expect("read imported row");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].source, CCSWITCH_LOCAL_PROVIDER_SOURCE);
assert_eq!(rows[0].source, CCSWITCH_PROVIDER_SOURCE);
assert_eq!(rows[0].source_id.as_deref(), Some("cc-row"));
}
@@ -1463,7 +1413,7 @@ enabled = true
}
#[test]
fn historical_custom_ghost_cleanup_requires_one_stable_endpoint_match() {
fn legacy_cleanup_preserves_historical_custom_ids() {
let conn = test_connection();
write_provider_on_connection(&conn, &provider("local", "Same API", Some("sk-current")))
.unwrap();
@@ -1475,12 +1425,12 @@ enabled = true
assert_eq!(
consolidate_legacy_provider_duplicates_on_connection(&conn).unwrap(),
1
0
);
let rows = list_saved_providers_on_connection(&conn).unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].id, "local");
assert_eq!(rows[0].api_key.as_deref(), Some("sk-current"));
assert_eq!(rows.len(), 2);
assert!(rows.iter().any(|row| row.id == "local"));
assert!(rows.iter().any(|row| row.id == "custom"));
let ambiguous = test_connection();
write_provider_on_connection(
@@ -1504,13 +1454,44 @@ enabled = true
0
);
assert_eq!(provider_count(&ambiguous), 3);
let different_models = test_connection();
let mut stable = provider("local", "Same API", Some("sk-current"));
stable.model = "deepseek-v3".to_string();
write_provider_on_connection(&different_models, &stable).unwrap();
let mut ghost = provider("custom", " same api ", Some("sk-old-live"));
ghost.model = "gpt-5.6".to_string();
write_provider_on_connection(&different_models, &ghost).unwrap();
assert_eq!(
consolidate_legacy_provider_duplicates_on_connection(&different_models).unwrap(),
0
);
assert_eq!(provider_count(&different_models), 2);
}
#[test]
fn legacy_consolidation_keeps_local_id_but_next_import_is_authoritative() {
fn legacy_consolidation_keeps_distinct_models_for_shared_credentials() {
let conn = test_connection();
let mut gpt = provider("gpt-profile", "Same API", Some("sk-shared"));
gpt.model = "gpt-5.6".to_string();
write_provider_on_connection(&conn, &gpt).unwrap();
let mut deepseek = provider("deepseek-profile", "Same API", Some("sk-shared"));
deepseek.model = "deepseek-v3".to_string();
write_provider_on_connection(&conn, &deepseek).unwrap();
assert_eq!(
consolidate_legacy_provider_duplicates_on_connection(&conn).unwrap(),
0
);
assert_eq!(provider_count(&conn), 2);
}
#[test]
fn legacy_consolidation_preserves_manual_and_imported_ids() {
let conn = test_connection();
let mut imported = provider("imported-old", "Imported old", Some("sk-same"));
imported.model = "old-model".to_string();
imported.model = "latest-model".to_string();
write_provider_with_origin(&conn, &imported, Some((CCSWITCH_PROVIDER_SOURCE, "cc-row")))
.unwrap();
let mut manual = provider("manual-local", "Edited locally", Some("sk-same"));
@@ -1527,19 +1508,12 @@ enabled = true
assert_eq!(
consolidate_legacy_provider_duplicates_on_connection(&conn).unwrap(),
1
0
);
let rows = stored_providers_on_connection(&conn).unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].provider.id, "manual-local");
assert_eq!(rows[0].provider.provider_name, "Edited locally");
assert_eq!(rows[0].provider.model, "latest-model");
assert_eq!(
rows[0].provider.toml_config.as_deref(),
Some("model = \"latest-model\"")
);
assert_eq!(rows[0].source, CCSWITCH_LOCAL_PROVIDER_SOURCE);
assert_eq!(rows[0].source_id.as_deref(), Some("cc-row"));
assert_eq!(rows.len(), 2);
assert!(rows.iter().any(|row| row.provider.id == "manual-local"));
assert!(rows.iter().any(|row| row.provider.id == "imported-old"));
let mut repeated = provider("imported-old", "Remote replacement", Some("sk-same"));
repeated.model = "remote-model".to_string();
@@ -1548,16 +1522,24 @@ enabled = true
.expect("repeat import after legacy consolidation");
let rows = stored_providers_on_connection(&conn).unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].provider.id, "manual-local");
assert_eq!(rows[0].provider.provider_name, "Remote replacement");
assert_eq!(rows[0].provider.model, "remote-model");
assert_eq!(rows.len(), 2);
let manual = rows
.iter()
.find(|row| row.provider.id == "manual-local")
.expect("manual provider remains");
assert_eq!(manual.provider.provider_name, "Edited locally");
assert_eq!(
rows[0].provider.toml_config.as_deref(),
Some("model = \"remote-model\"")
manual.provider.toml_config.as_deref(),
Some("model = \"latest-model\"")
);
assert_eq!(rows[0].source, CCSWITCH_LOCAL_PROVIDER_SOURCE);
assert_eq!(rows[0].source_id.as_deref(), Some("cc-row"));
let imported = rows
.iter()
.find(|row| row.provider.id == "imported-old")
.expect("imported provider remains");
assert_eq!(imported.provider.provider_name, "Remote replacement");
assert_eq!(imported.provider.model, "remote-model");
assert_eq!(imported.source, CCSWITCH_PROVIDER_SOURCE);
assert_eq!(imported.source_id.as_deref(), Some("cc-row"));
}
#[test]
@@ -1584,13 +1566,13 @@ enabled = true
}
#[test]
fn live_custom_matches_only_one_stable_provider_without_trusting_a_stale_key() {
fn live_custom_matches_a_preserved_historical_record_by_its_profile() {
let stable = provider("stable", "Same API", Some("sk-current"));
let persisted_ghost = provider("custom", "Same API", Some("sk-old-live"));
let historical = provider("custom", "Same API", Some("sk-old-live"));
let live = provider("custom", " same api ", Some("sk-old-live"));
assert_eq!(
unique_saved_provider_id_for_live(&live, &[stable.clone(), persisted_ghost]),
Some("stable".to_string())
unique_saved_provider_id_for_live(&live, &[stable.clone(), historical]),
Some("custom".to_string())
);
let second = provider("second", "Same API", Some("sk-second"));
@@ -1600,6 +1582,21 @@ enabled = true
);
}
#[test]
fn live_custom_matches_the_saved_profile_with_the_same_model() {
let mut gpt = provider("gpt-profile", "Same API", Some("sk-shared"));
gpt.model = "gpt-5.6".to_string();
let mut deepseek = provider("deepseek-profile", "Same API", Some("sk-shared"));
deepseek.model = "deepseek-v3".to_string();
let mut live = provider("custom", "Same API", Some("sk-shared"));
live.model = " deepseek-v3 ".to_string();
assert_eq!(
unique_saved_provider_id_for_live(&live, &[gpt, deepseek]),
Some("deepseek-profile".to_string())
);
}
#[test]
fn provider_toml_keeps_extensions_and_syncs_explicit_field_edits() {
let mut item = provider("saved", "Edited Name", Some("sk-explicit"));
+39 -16
View File
@@ -194,13 +194,13 @@ 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, true, 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,
archived_thread_ids: &HashSet<String>,
excluded_thread_ids: &HashSet<String>,
) -> Result<RolloutScan> {
scan_rollouts_with_thread_filter(
codex_dir,
@@ -208,7 +208,8 @@ pub(super) fn scan_provider_rollouts(
None,
None,
false,
Some(archived_thread_ids),
Some(excluded_thread_ids),
true,
)
}
@@ -225,6 +226,7 @@ pub(super) fn scan_rollouts_for_thread_ids(
Some(rollout_paths_by_thread_id),
false,
None,
false,
)
}
@@ -235,6 +237,7 @@ fn scan_rollouts_with_thread_filter(
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();
@@ -313,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);
@@ -336,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)
{
@@ -360,9 +367,10 @@ fn scan_rollouts_with_thread_filter(
next_text.push_str(line_ending);
}
if thread_id
.as_ref()
.is_some_and(|id| excluded_thread_ids.is_some_and(|excluded| excluded.contains(id)))
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;
}
@@ -971,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);
@@ -991,6 +991,23 @@ 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>,
@@ -1121,12 +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)
@@ -1234,7 +1256,9 @@ pub(super) fn list_session_previews_with_paths(
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty());
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,
@@ -1247,7 +1271,6 @@ pub(super) fn list_session_previews_with_paths(
archived: is_archived,
},
));
let is_subagent = subagent_thread_ids.contains(&id);
Ok(SessionPreview {
id,
title: clean_title,
+277 -10
View File
@@ -97,15 +97,27 @@ fn scan_provider_sync_data(
)?;
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.archived_thread_ids.contains(id));
archived_thread_ids.retain(|id| !active_sqlite.syncable_thread_ids.contains(id));
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());
}
// Without an authoritative active database, conflicting legacy rows are unsafe to mutate.
syncable_thread_ids.retain(|id| !archived_thread_ids.contains(id));
sqlite
.mismatched_thread_ids
.retain(|id| syncable_thread_ids.contains(id));
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 {
@@ -114,11 +126,11 @@ fn scan_provider_sync_data(
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_provider_rollouts(codex_dir, target_provider, &archived_thread_ids)?;
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| !archived_thread_ids.contains(id));
syncable_thread_ids.retain(|id| !excluded_thread_ids.contains(id));
let catalog = scan_catalog_sync(
&discovery.thread_paths,
&discovery.related_paths,
@@ -235,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 !session.archived && mismatched_ids.contains(&session.id) {
if !session.archived && !session.is_subagent && mismatched_ids.contains(&session.id) {
session.needs_sync = true;
}
}
@@ -632,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");
@@ -1032,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");
@@ -1293,6 +1395,171 @@ mod tests {
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");
@@ -81,8 +81,10 @@ pub(crate) struct SqliteScan {
pub(crate) top_level_threads: usize,
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>,
@@ -297,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,
@@ -486,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,
+276 -2
View File
@@ -18,17 +18,74 @@ pub(crate) use types::ManagedSkill;
use crate::error::Result;
use crate::file_io::{ensure_directory, io_err};
use crate::paths::home_dir;
use crate::paths::{home_dir, normalized_path_scope};
use crate::resolve_codex_dir;
use crate::{now_rfc3339, open_db};
use mcp::{
db_managed_mcp, import_ccswitch_mcp_servers_for_codex, list_mcp_from_config, mcp_summary,
preview_ccswitch_mcp_servers_for_codex, save_managed_mcp,
};
use rusqlite::params;
use skills::{
codex_skills_dir, copy_dir_recursive, disabled_skills_dir, sanitize_dir_name, scan_skill_dir,
};
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::Path;
const SKILLS_MCP_NOTE_MAX_CHARS: usize = 1000;
fn normalized_note_item_kind(item_kind: &str) -> Result<&'static str> {
match item_kind.trim().to_ascii_lowercase().as_str() {
"skill" => Ok("skill"),
"mcp" => Ok("mcp"),
_ => Err(crate::error::CodexxError::Config(
"备注类型必须是 skill 或 mcp".to_string(),
)),
}
}
fn skills_mcp_notes(codex_dir: &Path) -> Result<HashMap<(String, String), String>> {
let conn = open_db()?;
let mut stmt = conn
.prepare(
"SELECT item_kind, item_id, note
FROM skills_mcp_notes
WHERE codex_dir = ?1
ORDER BY item_kind ASC, item_id ASC",
)
.map_err(|error| crate::error::CodexxError::Database(error.to_string()))?;
let rows = stmt
.query_map([normalized_path_scope(codex_dir)], |row| {
Ok((
(row.get::<_, String>(0)?, row.get::<_, String>(1)?),
row.get::<_, String>(2)?,
))
})
.map_err(|error| crate::error::CodexxError::Database(error.to_string()))?;
let mut notes = HashMap::new();
for row in rows {
let (key, note) =
row.map_err(|error| crate::error::CodexxError::Database(error.to_string()))?;
notes.insert(key, note);
}
Ok(notes)
}
fn attach_skills_mcp_notes(
codex_dir: &Path,
skills: &mut [types::ManagedSkill],
mcp_servers: &mut [ManagedMcpServer],
) -> Result<()> {
let notes = skills_mcp_notes(codex_dir)?;
for skill in skills {
skill.note = notes.get(&("skill".to_string(), skill.id.clone())).cloned();
}
for server in mcp_servers {
server.note = notes.get(&("mcp".to_string(), server.id.clone())).cloned();
}
Ok(())
}
fn extend_unmanaged_mcp_candidates(
output: &mut Vec<ManagedMcpServer>,
@@ -82,11 +139,13 @@ pub(crate) fn build_skills_mcp_state_inner(config_dir: Option<String>) -> Result
enabled,
source: "Codex-X".to_string(),
summary,
note: None,
command,
url,
config_json: config,
});
}
attach_skills_mcp_notes(&codex_dir, &mut skills, &mut mcp_servers)?;
sort_managed_mcp_servers(&mut mcp_servers);
sort_managed_skills(&mut skills);
Ok(SkillsMcpState {
@@ -99,6 +158,63 @@ pub(crate) fn build_skills_mcp_state_inner(config_dir: Option<String>) -> Result
})
}
pub(crate) fn save_skills_mcp_note_inner(
config_dir: Option<String>,
item_kind: String,
id: String,
note: String,
) -> Result<SkillsMcpState> {
let item_kind = normalized_note_item_kind(&item_kind)?;
if id.trim().is_empty() {
return Err(crate::error::CodexxError::Config(
"备注对象 ID 不能为空".to_string(),
));
}
let id = id.as_str();
let note = note.trim();
if note.chars().count() > SKILLS_MCP_NOTE_MAX_CHARS {
return Err(crate::error::CodexxError::Config(format!(
"备注不能超过 {SKILLS_MCP_NOTE_MAX_CHARS} 个字符"
)));
}
let codex_dir = resolve_codex_dir(config_dir.clone())?;
let codex_dir_scope = normalized_path_scope(&codex_dir);
let current = build_skills_mcp_state_inner(config_dir.clone())?;
let exists = match item_kind {
"skill" => current.skills.iter().any(|skill| skill.id == id),
"mcp" => current.mcp_servers.iter().any(|server| server.id == id),
_ => false,
};
if !exists {
return Err(crate::error::CodexxError::Config(format!(
"未找到要备注的 {item_kind}: {id}"
)));
}
let conn = open_db()?;
if note.is_empty() {
conn.execute(
"DELETE FROM skills_mcp_notes
WHERE codex_dir = ?1 AND item_kind = ?2 AND item_id = ?3",
params![codex_dir_scope, item_kind, id],
)
.map_err(|error| crate::error::CodexxError::Database(error.to_string()))?;
} else {
conn.execute(
"INSERT INTO skills_mcp_notes
(codex_dir, item_kind, item_id, note, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(codex_dir, item_kind, item_id) DO UPDATE SET
note = excluded.note,
updated_at = excluded.updated_at",
params![codex_dir_scope, item_kind, id, note, now_rfc3339()],
)
.map_err(|error| crate::error::CodexxError::Database(error.to_string()))?;
}
build_skills_mcp_state_inner(config_dir)
}
pub(crate) fn import_existing_skills_mcp_inner(
config_dir: Option<String>,
) -> Result<SkillsMcpActionResult> {
@@ -223,6 +339,7 @@ mod tests {
enabled: true,
source: source.to_string(),
summary: id.to_string(),
note: None,
command: Some(id.to_string()),
url: None,
config_json: json!({ "command": id }),
@@ -271,4 +388,161 @@ mod tests {
);
assert!(second_preview.is_empty());
}
#[test]
fn custom_notes_are_isolated_by_kind_and_codex_home_and_empty_text_clears_them() {
let _db_guard = crate::app_db::test_db_guard();
let item_id = format!("shared-note-item-{}", std::process::id());
let spaced_id = " spaced-mcp ";
let codex_dir =
std::env::temp_dir().join(format!("codex-x-note-state-{}", std::process::id()));
let other_codex_dir =
std::env::temp_dir().join(format!("codex-x-note-state-other-{}", std::process::id()));
let prepare_codex_dir = |dir: &Path| {
let _ = fs::remove_dir_all(dir);
let skill_dir = dir.join("skills").join(&item_id);
fs::create_dir_all(&skill_dir).expect("create test skill directory");
fs::write(
skill_dir.join("SKILL.md"),
format!("---\nname: {item_id}\ndescription: test\n---\n"),
)
.expect("write test skill");
fs::write(
dir.join("config.toml"),
format!(
"[mcp_servers.{item_id}]\ncommand = \"test-server\"\n\
[mcp_servers.\"{spaced_id}\"]\ncommand = \"spaced-server\"\n"
),
)
.expect("write test MCP config");
};
prepare_codex_dir(&codex_dir);
prepare_codex_dir(&other_codex_dir);
let conn = open_db().expect("open app database");
conn.execute(
"DELETE FROM skills_mcp_notes WHERE item_id IN (?1, ?2)",
params![&item_id, spaced_id],
)
.expect("clear stale test notes");
drop(conn);
let config_dir = Some(codex_dir.display().to_string());
save_skills_mcp_note_inner(
config_dir.clone(),
"skill".to_string(),
item_id.clone(),
" 我的 Skill 备注 ".to_string(),
)
.expect("save skill note");
let state = save_skills_mcp_note_inner(
config_dir.clone(),
"mcp".to_string(),
item_id.clone(),
"MCP note".to_string(),
)
.expect("save MCP note");
assert_eq!(
state
.skills
.iter()
.find(|skill| skill.id == item_id)
.and_then(|skill| skill.note.as_deref()),
Some("我的 Skill 备注")
);
assert_eq!(
state
.mcp_servers
.iter()
.find(|server| server.id == item_id)
.and_then(|server| server.note.as_deref()),
Some("MCP note")
);
let state = save_skills_mcp_note_inner(
config_dir.clone(),
"mcp".to_string(),
spaced_id.to_string(),
"Spaced ID note".to_string(),
)
.expect("save note without normalizing the MCP ID");
assert_eq!(
state
.mcp_servers
.iter()
.find(|server| server.id == spaced_id)
.and_then(|server| server.note.as_deref()),
Some("Spaced ID note")
);
let other_config_dir = Some(other_codex_dir.display().to_string());
let other_state = build_skills_mcp_state_inner(other_config_dir.clone())
.expect("load other CODEX_HOME state");
assert!(other_state
.mcp_servers
.iter()
.find(|server| server.id == item_id)
.is_some_and(|server| server.note.is_none()));
save_skills_mcp_note_inner(
other_config_dir,
"mcp".to_string(),
item_id.clone(),
"Other MCP note".to_string(),
)
.expect("save note for other CODEX_HOME");
let original_state = build_skills_mcp_state_inner(config_dir.clone())
.expect("reload original CODEX_HOME state");
assert_eq!(
original_state
.mcp_servers
.iter()
.find(|server| server.id == item_id)
.and_then(|server| server.note.as_deref()),
Some("MCP note")
);
let state = save_skills_mcp_note_inner(
config_dir.clone(),
"skill".to_string(),
item_id.clone(),
" ".to_string(),
)
.expect("clear skill note");
assert!(state
.skills
.iter()
.find(|skill| skill.id == item_id)
.is_some_and(|skill| skill.note.is_none()));
assert_eq!(
state
.mcp_servers
.iter()
.find(|server| server.id == item_id)
.and_then(|server| server.note.as_deref()),
Some("MCP note")
);
assert!(save_skills_mcp_note_inner(
config_dir.clone(),
"unknown".to_string(),
item_id.clone(),
"note".to_string(),
)
.is_err());
assert!(save_skills_mcp_note_inner(
config_dir,
"mcp".to_string(),
item_id.clone(),
"x".repeat(SKILLS_MCP_NOTE_MAX_CHARS + 1),
)
.is_err());
let conn = open_db().expect("reopen app database");
conn.execute(
"DELETE FROM skills_mcp_notes WHERE item_id IN (?1, ?2)",
params![&item_id, spaced_id],
)
.expect("remove test notes");
drop(conn);
fs::remove_dir_all(&codex_dir).expect("remove test Codex directory");
fs::remove_dir_all(&other_codex_dir).expect("remove other test Codex directory");
}
}
@@ -258,6 +258,7 @@ pub(super) fn scan_skill_dir(
id: id.clone(),
name,
description,
note: None,
directory,
enabled,
source: source.to_string(),
@@ -10,6 +10,7 @@ pub(crate) struct ManagedMcpServer {
pub(crate) enabled: bool,
pub(crate) source: String,
pub(crate) summary: String,
pub(crate) note: Option<String>,
pub(crate) command: Option<String>,
pub(crate) url: Option<String>,
pub(crate) config_json: Value,
@@ -21,6 +22,7 @@ pub(crate) struct ManagedSkill {
pub(crate) id: String,
pub(crate) name: String,
pub(crate) description: Option<String>,
pub(crate) note: Option<String>,
pub(crate) directory: String,
pub(crate) enabled: bool,
pub(crate) source: String,
+27 -13
View File
@@ -7,8 +7,9 @@ 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;
@@ -31,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,
@@ -45,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>,
@@ -159,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
@@ -175,8 +176,17 @@ 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)]
@@ -210,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 {
+159 -34
View File
@@ -261,7 +261,7 @@ experimental_bearer_token = "sk-same"
}
#[test]
fn manual_provider_save_upserts_same_url_and_key_but_keeps_different_keys() {
fn manual_provider_save_keeps_independent_ids_with_any_configuration() {
let conn = provider_test_connection();
let first = normalize_saved_provider(provider_fixture(
"first",
@@ -272,11 +272,29 @@ fn manual_provider_save_upserts_same_url_and_key_but_keeps_different_keys() {
None,
))
.expect("normalize first");
let added =
upsert_provider_on_connection(&conn, first, ProviderUpsertMode::Manual).expect("add first");
let added = upsert_provider_on_connection(&conn, first.clone(), ProviderUpsertMode::Manual)
.expect("add first");
assert_eq!(added.kind, ProviderUpsertKind::Added);
let renamed = normalize_saved_provider(provider_fixture(
let exact_copy = SavedProvider {
id: "exact-copy".to_string(),
..first.clone()
};
let exact_add = upsert_provider_on_connection(&conn, exact_copy, ProviderUpsertMode::Manual)
.expect("keep exact copy with a distinct ID");
assert_eq!(exact_add.kind, ProviderUpsertKind::Added);
let renamed_copy = SavedProvider {
id: "renamed-copy".to_string(),
provider_name: "Only Name Changed".to_string(),
..first
};
let renamed_add =
upsert_provider_on_connection(&conn, renamed_copy, ProviderUpsertMode::Manual)
.expect("keep renamed copy with a distinct ID");
assert_eq!(renamed_add.kind, ProviderUpsertKind::Added);
let different_model = normalize_saved_provider(provider_fixture(
"second",
"Second Name",
"HTTPS://EXAMPLE.COM:443/v1",
@@ -284,13 +302,13 @@ fn manual_provider_save_upserts_same_url_and_key_but_keeps_different_keys() {
"model-b",
None,
))
.expect("normalize renamed");
let merged = upsert_provider_on_connection(&conn, renamed, ProviderUpsertMode::Manual)
.expect("merge same identity");
assert_eq!(merged.kind, ProviderUpsertKind::Merged);
assert_eq!(merged.provider.id, "first");
assert_eq!(merged.provider.provider_name, "Second Name");
assert_eq!(merged.provider.model, "model-b");
.expect("normalize different model");
let model_add =
upsert_provider_on_connection(&conn, different_model, ProviderUpsertMode::Manual)
.expect("keep different model");
assert_eq!(model_add.kind, ProviderUpsertKind::Added);
assert_eq!(model_add.provider.id, "second");
assert_eq!(model_add.provider.model, "model-b");
let other_key = normalize_saved_provider(provider_fixture(
"third",
@@ -304,11 +322,11 @@ fn manual_provider_save_upserts_same_url_and_key_but_keeps_different_keys() {
let second_add = upsert_provider_on_connection(&conn, other_key, ProviderUpsertMode::Manual)
.expect("keep different credential");
assert_eq!(second_add.kind, ProviderUpsertKind::Added);
assert_eq!(list_saved_providers_on_connection(&conn).unwrap().len(), 2);
assert_eq!(list_saved_providers_on_connection(&conn).unwrap().len(), 5);
}
#[test]
fn imported_provider_merge_keeps_local_id_and_applies_complete_source() {
fn imported_provider_keeps_its_source_id_beside_a_matching_manual_record() {
let conn = provider_test_connection();
let local_toml = r#"model_provider = "custom"
model = "local-model"
@@ -329,7 +347,7 @@ experimental_bearer_token = "sk-same"
let imported_toml = r#"# authoritative cc-switch template
model_provider = "custom"
model = "cc-model"
model = "local-model"
service_tier = "priority"
[model_providers.custom]
@@ -346,17 +364,17 @@ trust_level = "trusted"
"CC Name",
"https://EXAMPLE.com:443/v1/",
Some("sk-same"),
"cc-model",
"local-model",
Some(imported_toml),
);
imported_fixture.requires_openai_auth = false;
let imported = normalize_saved_provider(imported_fixture).expect("normalize import");
let result = upsert_ccswitch_provider_on_connection(&conn, imported.clone(), "cc-switch-id")
.expect("merge import");
assert_eq!(result.kind, ProviderUpsertKind::Merged);
assert_eq!(result.provider.id, "local");
.expect("import independent source record");
assert_eq!(result.kind, ProviderUpsertKind::Added);
assert_eq!(result.provider.id, "cc-switch-id");
assert_eq!(result.provider.provider_name, "CC Name");
assert_eq!(result.provider.model, "cc-model");
assert_eq!(result.provider.model, "local-model");
assert!(!result.provider.requires_openai_auth);
let normalized_toml = result.provider.toml_config.as_deref().unwrap();
assert!(normalized_toml.contains("# authoritative cc-switch template"));
@@ -365,27 +383,28 @@ trust_level = "trusted"
assert!(normalized_toml.contains("[projects.\"/work/project\"]"));
assert!(normalized_toml.contains("wire_api = \"responses\""));
assert!(!normalized_toml.contains("experimental_bearer_token"));
assert_eq!(list_saved_providers_on_connection(&conn).unwrap().len(), 1);
assert_eq!(list_saved_providers_on_connection(&conn).unwrap().len(), 2);
let repeated = upsert_ccswitch_provider_on_connection(&conn, imported, "cc-switch-id")
.expect("repeat identical import");
assert_eq!(repeated.provider.id, "local");
assert_eq!(repeated.kind, ProviderUpsertKind::Updated);
assert_eq!(repeated.provider.id, "cc-switch-id");
assert_eq!(
repeated.provider.toml_config.as_deref(),
Some(normalized_toml)
);
assert_eq!(list_saved_providers_on_connection(&conn).unwrap().len(), 1);
assert_eq!(list_saved_providers_on_connection(&conn).unwrap().len(), 2);
}
#[test]
fn provider_migration_keeps_the_latest_exact_credential_record() {
fn provider_migration_preserves_independent_manual_profiles() {
let conn = provider_test_connection();
let first = provider_fixture(
"first-id",
"Local Name",
"HTTPS://EXAMPLE.com:443/v1/",
Some("sk-same"),
"local-model",
"imported-model",
None,
);
let duplicate = provider_fixture(
@@ -458,21 +477,15 @@ fn provider_migration_keeps_the_latest_exact_credential_record() {
);
assert_eq!(
consolidate_legacy_provider_duplicates_on_connection(&conn).unwrap(),
1
0
);
let rows = list_saved_providers_on_connection(&conn).unwrap();
assert_eq!(rows.len(), 4);
let survivor = rows.iter().find(|row| row.id == "later-id").unwrap();
assert_eq!(survivor.provider_name, "Imported Name");
assert_eq!(survivor.model, "imported-model");
assert_eq!(
survivor.toml_config.as_deref(),
Some("local preserved toml")
);
assert_eq!(rows.len(), 5);
assert!(rows.iter().any(|row| row.id == "first-id"));
assert!(rows.iter().any(|row| row.id == "later-id"));
assert!(rows.iter().any(|row| row.id == "different-key"));
assert!(rows.iter().any(|row| row.id == "anonymous-a"));
assert!(rows.iter().any(|row| row.id == "anonymous-b"));
assert!(!rows.iter().any(|row| row.id == "first-id"));
}
#[test]
@@ -588,6 +601,7 @@ fn skills_and_mcp_order_does_not_depend_on_enabled_state() {
id: id.to_string(),
name: name.to_string(),
description: None,
note: None,
directory: id.to_string(),
enabled,
source: "test".to_string(),
@@ -602,6 +616,7 @@ fn skills_and_mcp_order_does_not_depend_on_enabled_state() {
enabled,
source: "test".to_string(),
summary: String::new(),
note: None,
command: None,
url: None,
config_json: json!({}),
@@ -3146,6 +3161,116 @@ requires_openai_auth = true
let _ = fs::remove_dir_all(codex_dir);
}
#[test]
fn duplicate_save_and_snapshot_commands_preserve_current_until_explicit_switch() {
let _db_guard = crate::app_db::test_db_guard();
let codex_dir = temp_codex_dir("official-snapshot-preserves-current-duplicate");
let custom_config = r#"model_provider = "custom"
model = "duplicate-current-model"
[model_providers.custom]
name = "Duplicate Current Provider"
base_url = "https://duplicate-current.example.com/v1"
wire_api = "responses"
requires_openai_auth = true
"#;
write_text(&config_path(&codex_dir), custom_config).expect("write custom config");
write_json(
&auth_path(&codex_dir),
&json!({"OPENAI_API_KEY": "sk-duplicate-current", "auth_mode": "apikey"}),
)
.expect("write proxy auth");
let original_id = "official-snapshot-current-original";
let copy_id = "official-snapshot-current-copy";
let original = SavedProvider {
id: original_id.to_string(),
provider_name: "Duplicate Current Provider".to_string(),
base_url: "https://duplicate-current.example.com/v1".to_string(),
model: "duplicate-current-model".to_string(),
api_key: Some("sk-duplicate-current".to_string()),
toml_config: Some(custom_config.trim_end().to_string()),
wire_api: "responses".to_string(),
requires_openai_auth: true,
};
save_provider_inner(original.clone()).expect("save original provider");
assert_eq!(
build_state(codex_dir.clone())
.expect("discover and remember the unique current provider")
.active_saved_provider_id
.as_deref(),
Some(original_id)
);
save_provider_inner(SavedProvider {
id: copy_id.to_string(),
..original
})
.expect("save identical copy");
assert_eq!(
build_state(codex_dir.clone())
.expect("refresh after saving copy")
.active_saved_provider_id
.as_deref(),
Some(original_id)
);
let saved = save_official_config_inner(
Some(codex_dir.display().to_string()),
Some("official-model".to_string()),
Some(
json!({
"auth_mode": "chatgpt",
"tokens": {"access_token": "official-access-token"}
})
.to_string(),
),
None,
)
.expect("save independent official snapshot");
let saved = finish_provider_selection(saved, ActiveProviderSelectionUpdate::ClearIfOfficial);
assert!(!saved.state.is_official_provider);
assert_eq!(
saved.state.active_saved_provider_id.as_deref(),
Some(original_id)
);
let restored = restore_official_provider_inner(Some(codex_dir.display().to_string()))
.expect("restore independent official snapshot");
let restored =
finish_provider_selection(restored, ActiveProviderSelectionUpdate::ClearIfOfficial);
assert!(!restored.state.is_official_provider);
assert_eq!(
restored.state.active_saved_provider_id.as_deref(),
Some(original_id)
);
assert_eq!(
build_state(codex_dir.clone())
.expect("refresh current provider state")
.active_saved_provider_id
.as_deref(),
Some(original_id)
);
let switched = save_provider_toml_config_inner(ProviderTomlInput {
config_dir: Some(codex_dir.display().to_string()),
config_text: custom_config.to_string(),
api_key: Some("sk-duplicate-current".to_string()),
})
.expect("apply the identical copied provider");
let switched = finish_provider_selection(
switched,
ActiveProviderSelectionUpdate::Set(copy_id.to_string()),
);
assert_eq!(
switched.state.active_saved_provider_id.as_deref(),
Some(copy_id)
);
providers::delete_provider_inner(copy_id).expect("delete copied provider");
providers::delete_provider_inner(original_id).expect("delete original provider");
let _ = fs::remove_dir_all(codex_dir);
}
#[test]
fn first_official_draft_from_proxy_preserves_complete_live_config() {
let codex_dir = temp_codex_dir("first-official-draft-from-proxy");
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Codex-X",
"version": "0.3.13",
"version": "0.3.15",
"identifier": "com.yynxxxxx.codexx",
"build": {
"frontendDist": "../dist",
+146 -80
View File
@@ -11,7 +11,7 @@ import {
import { OverviewPage } from "./pages/OverviewPage";
import { AboutPage, SettingsPage, TomlConfigPage } from "./pages/UtilityPages";
import { PromptsPage } from "./pages/PromptsPage";
import { SkillsMcpPage } from "./pages/SkillsMcpPage";
import { SkillsMcpPage, type SkillsMcpNoteKind } from "./pages/SkillsMcpPage";
import { ProvidersPage, type ProviderCopy, type ProviderRow } from "./pages/ProvidersPage";
import { AppShell, type AppTab, type AppTheme } from "./components/AppShell";
import {
@@ -22,12 +22,15 @@ import {
import { PageTransition } from "./components/PageTransition";
import { cx } from "./components/ui";
import { appUpdater, useAppUpdater } from "./appUpdater";
import { providerProfilesMatch, type ProviderProfile } from "./providerProfiles";
import { orderProviderRows } from "./providerRowOrder";
import type {
AboutInfo,
ActionResult,
AppUpdateInfo,
BuiltinPromptDetail,
BuiltinPromptStatus,
CodexDesktopRestartResult,
CodexState,
ImportResult,
InstructionMode,
@@ -355,6 +358,7 @@ function getProviderPageCopy(lang: Lang): ProviderCopy {
enableLabel: isChinese ? "启用" : "Enable",
testLabel: isChinese ? "测试连接" : "Test connection",
editLabel: t.provider.edit,
duplicateLabel: isChinese ? "复制供应商" : "Duplicate provider",
removeLabel: t.provider.remove,
deleteTitle: isChinese ? "删除供应商" : "Delete provider",
deleteDescription: (providerName) => isChinese
@@ -481,25 +485,6 @@ function extractOpenAiApiKey(authText?: string) {
}
}
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();
}
function parseTomlStringValue(value: string) {
const raw = value.trim();
if (raw.startsWith('"')) {
@@ -558,14 +543,13 @@ function savedProviderApiKey(provider: SavedProvider) {
|| extractTomlProviderApiKey(provider.tomlConfig, providerId || undefined);
}
function providerIdentityKey(baseUrl?: string | null, apiKey?: string | null, providerName?: string | null) {
const normalizedUrl = normalizeProviderBaseUrl(baseUrl);
if (!normalizedUrl) return "";
const normalizedKey = (apiKey || "").trim();
return JSON.stringify([
normalizedUrl,
normalizedKey ? `key:${normalizedKey}` : `name:${normalizeProviderName(providerName)}`,
]);
function savedProviderMatchesProfile(provider: SavedProvider, profile: ProviderProfile) {
return providerProfilesMatch({
baseUrl: provider.baseUrl,
providerName: provider.providerName,
model: provider.model,
apiKey: savedProviderApiKey(provider),
}, profile);
}
function buildProviderTomlPreview(provider: SavedProvider) {
@@ -798,6 +782,8 @@ function App() {
const [availableProviderModels, setAvailableProviderModels] = React.useState<ProviderModel[]>([]);
const [providerModelsLoading, setProviderModelsLoading] = React.useState(false);
const [actionBusy, setActionBusy] = React.useState<string>("");
const [skillsMcpNoteBusy, setSkillsMcpNoteBusy] = React.useState("");
const [restartCodexBusy, setRestartCodexBusy] = React.useState(false);
const [promptSyncing, setPromptSyncing] = React.useState(false);
const [promptCatalogReady, setPromptCatalogReady] = React.useState(false);
const [promptForm, setPromptForm] = React.useState<SavedPrompt>(blankPromptForm);
@@ -820,6 +806,8 @@ function App() {
const loadingTokensRef = React.useRef(new Set<number>());
const actionBusyGenerationRef = React.useRef(0);
const actionBusyTokensRef = React.useRef(new Map<number, string>());
const skillsMcpNoteBusyRef = React.useRef("");
const restartCodexBusyRef = React.useRef(false);
const promptModeHelpRef = React.useRef<HTMLDivElement | null>(null);
const promptRefreshRequestRef = React.useRef(0);
const refreshRequestRef = React.useRef(0);
@@ -1084,8 +1072,17 @@ function App() {
if (state?.activeSavedProviderId && savedProviders.some((item) => item.id === state.activeSavedProviderId)) {
return state.activeSavedProviderId;
}
return liveProviderId !== "custom" ? liveProviderId : "";
}, [liveProviderId, savedProviders, state?.activeSavedProviderId, state?.isOfficialProvider]);
const fallback = liveProviderId === "custom"
? savedProviders.find((item) => item.id === activeProviderId)
: savedProviders.find((item) => item.id === liveProviderId);
if (!fallback || !currentProvider) return "";
return savedProviderMatchesProfile(fallback, {
baseUrl: currentProvider.baseUrl,
apiKey: liveProviderApiKey,
providerName: currentProvider.name,
model: state?.model,
}) ? fallback.id : "";
}, [activeProviderId, currentProvider, liveProviderApiKey, liveProviderId, savedProviders, state?.activeSavedProviderId, state?.isOfficialProvider, state?.model]);
const effectiveActiveProviderId = state?.isOfficialProvider ? "" : inferredActiveProviderId;
const currentInstructionPath = (state?.instructionFile || "").replace(/\\/g, "/");
const currentInstructionFilename = currentInstructionPath.split("/").pop() || "";
@@ -1104,22 +1101,6 @@ function App() {
|| currentInstructionFilename
|| (lang === "zh" ? "当前提示词" : "Current prompt");
}, [currentInstructionFilename, instructionTemplates, lang, savedPrompts, state?.instructionTemplateKey]);
const canonicalSavedProviders = React.useMemo(() => {
const groups = new Map<string, SavedProvider[]>();
savedProviders.forEach((provider) => {
const identity = providerIdentityKey(provider.baseUrl, savedProviderApiKey(provider), provider.providerName);
const key = identity || `id:${provider.id}`;
const group = groups.get(key);
if (group) group.push(provider);
else groups.set(key, [provider]);
});
return Array.from(groups.values()).map((group) =>
group.find((item) => item.id === effectiveActiveProviderId)
|| group.find((item) => item.id === activeProviderId)
|| group[0],
);
}, [activeProviderId, effectiveActiveProviderId, savedProviders]);
const detectedRows = React.useMemo(() => {
if (state?.isOfficialProvider) return [];
return (state?.providers || []).filter((p) => p.isCurrent).map((p) => {
@@ -1140,12 +1121,12 @@ function App() {
}, [liveProviderApiKey, state?.configText, state?.isOfficialProvider, state?.model, state?.providers]);
const localRows = React.useMemo(() => {
return canonicalSavedProviders.map((p) => ({
return savedProviders.map((p) => ({
...p,
source: "local" as const,
isCurrent: effectiveActiveProviderId === p.id,
}));
}, [canonicalSavedProviders, effectiveActiveProviderId]);
}, [effectiveActiveProviderId, savedProviders]);
const providerRows = React.useMemo(() => {
const officialRow = {
@@ -1159,42 +1140,36 @@ function App() {
requiresOpenaiAuth: false,
isCurrent: Boolean(state?.isOfficialProvider),
};
const seen = new Set<string>();
const rows: Array<typeof officialRow | (typeof detectedRows)[number] | (typeof localRows)[number]> = [officialRow];
localRows.forEach((row) => {
const key = providerIdentityKey(row.baseUrl, savedProviderApiKey(row), row.providerName);
if (key) seen.add(key);
rows.push(row);
});
detectedRows.forEach((row) => {
if (row.id === "detected-custom" && inferredActiveProviderId) return;
const key = providerIdentityKey(row.baseUrl, row.apiKey, row.providerName);
if (key && seen.has(key)) return;
if (key) seen.add(key);
rows.push(row);
});
return rows;
}, [detectedRows, inferredActiveProviderId, localRows, state?.isOfficialProvider, state?.model]);
return orderProviderRows(officialRow, detectedRows, localRows);
}, [detectedRows, localRows, state?.isOfficialProvider, state?.model]);
const findLocalProviderForRow = React.useCallback((row: ProviderRow) => {
if (row.source === "official") return undefined;
return canonicalSavedProviders.find((item) =>
row.source === "local"
? item.id === row.id
: providerIdentityKey(item.baseUrl, savedProviderApiKey(item), item.providerName)
=== providerIdentityKey(row.baseUrl, row.apiKey, row.providerName),
);
}, [canonicalSavedProviders]);
if (row.source === "local") return savedProviders.find((item) => item.id === row.id);
const matches = savedProviders.filter((item) => savedProviderMatchesProfile(item, row));
return matches.find((item) => item.id === effectiveActiveProviderId)
|| matches.find((item) => item.id === activeProviderId)
|| (matches.length === 1 ? matches[0] : undefined);
}, [activeProviderId, effectiveActiveProviderId, savedProviders]);
const providerCopySourceForRow = React.useCallback((row: ProviderRow): SavedProvider | undefined => {
const local = findLocalProviderForRow(row);
if (local) return local;
if (row.source !== "detected") return undefined;
return {
id: customProviderId(row.providerName || row.baseUrl),
providerName: row.providerName,
baseUrl: row.baseUrl,
model: row.model,
apiKey: row.apiKey || "",
tomlConfig: state?.configText || "",
wireApi: row.wireApi,
requiresOpenaiAuth: row.requiresOpenaiAuth,
};
}, [findLocalProviderForRow, state?.configText]);
const providerPageRows = React.useMemo<ProviderRow[]>(() => providerRows.map((row) => {
const local = row.source === "official"
? undefined
: canonicalSavedProviders.find((item) =>
row.source === "local"
? item.id === row.id
: providerIdentityKey(item.baseUrl, savedProviderApiKey(item), item.providerName)
=== providerIdentityKey(row.baseUrl, row.apiKey, row.providerName),
);
const local = findLocalProviderForRow(row);
return {
id: row.id,
source: row.source,
@@ -1207,11 +1182,12 @@ function App() {
isCurrent: row.isCurrent,
sourceLabel: row.source === "official" ? (lang === "zh" ? "Codex 登录" : "Codex login") : undefined,
editable: row.source === "official" || Boolean(local) || row.source === "detected",
duplicable: Boolean(providerCopySourceForRow(row)),
deletable: Boolean(local),
testable: row.source !== "official",
testingKey: `${row.source}-${row.id}`,
};
}), [canonicalSavedProviders, lang, providerRows]);
}), [findLocalProviderForRow, lang, providerCopySourceForRow, providerRows]);
const visibleSessions = React.useMemo(
() => (sessionStatus?.sessions || []).filter((item) => showInternalSessions || !item.isSubagent),
@@ -1645,6 +1621,7 @@ function App() {
configText: tomlConfig,
apiKey: provider.apiKey || "",
},
providerId: provider.id,
});
}
return invoke<ActionResult>("switch_provider", {
@@ -2113,6 +2090,60 @@ function App() {
}
};
const saveSkillsMcpNote = async (itemKind: SkillsMcpNoteKind, id: string, note: string) => {
if (skillsMcpNoteBusyRef.current) return false;
const noteBusyKey = `note:${itemKind}:${id}`;
skillsMcpNoteBusyRef.current = noteBusyKey;
setSkillsMcpNoteBusy(noteBusyKey);
const request = beginSkillsMcpRequest();
const actionToken = beginActionBusy(noteBusyKey);
setError("");
try {
const result = await invoke<SkillsMcpState>("save_skills_mcp_note", {
configDir: request.configDir,
itemKind,
id,
note,
});
if (!isCurrentSkillsMcpRequest(request.requestId, request.configDirKey)) return false;
skillsMcpLoadedRef.current = request.configDirKey;
setSkillsMcpState(result);
setToast(lang === "zh" ? "备注已保存" : "Note saved");
return true;
} catch (e) {
if (isCurrentSkillsMcpRequest(request.requestId, request.configDirKey)) setError(String(e));
return false;
} finally {
endActionBusy(actionToken);
if (skillsMcpNoteBusyRef.current === noteBusyKey) {
skillsMcpNoteBusyRef.current = "";
setSkillsMcpNoteBusy("");
}
}
};
const restartCodexDesktop = async () => {
if (restartCodexBusyRef.current) return false;
restartCodexBusyRef.current = true;
setRestartCodexBusy(true);
const actionToken = beginActionBusy("restartCodexDesktop");
setError("");
try {
const result = await invoke<CodexDesktopRestartResult>("restart_codex_desktop");
setToast(result.wasRunning
? (lang === "zh" ? `${result.appName} 已重新启动` : `${result.appName} restarted`)
: (lang === "zh" ? `${result.appName} 已启动` : `${result.appName} started`));
return true;
} catch (e) {
setError(String(e));
return false;
} finally {
endActionBusy(actionToken);
restartCodexBusyRef.current = false;
setRestartCodexBusy(false);
}
};
const installSkillZipFile = async (file?: File | null) => {
if (!file) return;
if (!file.name.toLowerCase().endsWith(".zip")) {
@@ -2224,6 +2255,20 @@ function App() {
setProviderMode("form");
};
const openDuplicateProvider = (provider: SavedProvider) => {
resetAvailableProviderModels();
const duplicate = {
...provider,
id: uniqueId(provider.id || provider.providerName, savedProviders.map((item) => item.id)),
};
setEditingProviderId(null);
setEditingDetectedProvider(false);
setProviderForm(duplicate);
setProviderTomlDraft(duplicate.tomlConfig?.trim() || buildProviderTomlPreview(duplicate));
setProviderTomlDirty(false);
setProviderMode("form");
};
const openEditDetectedProvider = (provider: { id: string; providerName: string; baseUrl: string; model: string; apiKey?: string; wireApi: string; requiresOpenaiAuth: boolean }) => {
resetAvailableProviderModels();
const id = uniqueId(
@@ -2575,6 +2620,10 @@ function App() {
if (local) openEditProvider(local);
else if (row.source === "detected") openEditDetectedProvider(row);
}}
onDuplicateProvider={(row) => {
const copySource = providerCopySourceForRow(row);
if (copySource) openDuplicateProvider(copySource);
}}
onDeleteProvider={(row) => {
const local = findLocalProviderForRow(row);
return local ? removeProvider(local.id, row.isCurrent) : Promise.resolve(false);
@@ -2697,6 +2746,8 @@ function App() {
onCheckUpdates={checkSkillUpdatesAction}
onToggleSkill={toggleSkillEnabled}
onToggleMcp={toggleMcpEnabled}
noteBusyKey={skillsMcpNoteBusy}
onSaveNote={saveSkillsMcpNote}
/>
)}
@@ -2846,9 +2897,24 @@ function App() {
? "重新检测 CODEX_HOME、config.toml、auth.json 和 SQLite 会话库。"
: "Recheck CODEX_HOME, config.toml, auth.json and SQLite session stores.",
recheckLabel: lang === "zh" ? "重新检测" : "Recheck",
restartTitle: lang === "zh" ? "Codex 桌面客户端" : "Codex desktop app",
restartDescription: lang === "zh"
? "重新启动本机的 Codex(ChatGPT)桌面客户端,不会重启 Codex-X。"
: "Restart the local Codex (ChatGPT) desktop app without restarting Codex-X.",
restartLabel: lang === "zh" ? "重启 Codex" : "Restart Codex",
restartTargetLabel: lang === "zh" ? "Codex(ChatGPT)桌面客户端" : "Codex (ChatGPT) desktop app",
restartConfirmTitle: lang === "zh" ? "重启 Codex?" : "Restart Codex?",
restartConfirmDescription: lang === "zh"
? "正在运行的 Codex 窗口会关闭并重新打开,未保存的输入可能丢失。"
: "The running Codex window will close and reopen. Unsaved input may be lost.",
restartCancelLabel: lang === "zh" ? "取消" : "Cancel",
restartConfirmLabel: lang === "zh" ? "确认重启" : "Restart",
restartingLabel: lang === "zh" ? "正在重启" : "Restarting",
}}
onLanguageChange={setLang}
recheckBusy={loading || refreshing}
restartBusy={restartCodexBusy}
onRestartCodex={restartCodexDesktop}
onRecheck={() => {
localStorage.removeItem(STARTUP_WIZARD_SEEN_KEY);
setStartupWizardOpen(true);
+9 -1
View File
@@ -5,6 +5,7 @@ import {
AlertTriangle,
ArrowLeft,
CheckCircle2,
Copy,
Download,
Eye,
EyeOff,
@@ -37,6 +38,7 @@ export type ProviderRow = {
isCurrent: boolean;
sourceLabel?: string;
editable?: boolean;
duplicable?: boolean;
deletable?: boolean;
testable?: boolean;
testingKey?: string;
@@ -69,6 +71,7 @@ export type ProviderCopy = {
enableLabel: string;
testLabel: string;
editLabel: string;
duplicateLabel: string;
removeLabel: string;
deleteTitle: string;
deleteDescription: (providerName: string) => string;
@@ -154,6 +157,7 @@ export type ProvidersPageProps = {
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;
@@ -242,8 +246,9 @@ 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);
@@ -337,6 +342,9 @@ function ListPage({
{row.editable !== false && (
<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={providerActionsBusy} danger />
)}
+127 -7
View File
@@ -1,4 +1,4 @@
import { useId } from "react";
import { useId, useState } from "react";
import type { ChangeEvent, RefObject } from "react";
import {
AlertCircle,
@@ -7,6 +7,7 @@ import {
FolderInput,
Loader2,
PackageOpen,
PencilLine,
PlugZap,
RefreshCw,
Sparkles,
@@ -14,11 +15,12 @@ import {
} from "lucide-react";
import { PageTransition } from "../components/PageTransition";
import { Button, ModalShell, StatusBadge, Toggle, cx } from "../components/ui";
import { Button, IconButton, ModalShell, StatusBadge, Toggle, cx } from "../components/ui";
import type { Lang, ManagedMcpServer, ManagedSkill, SkillsMcpImportPreview, SkillsMcpState } from "../types";
import "../styles/skills-prompts-pages.css";
export type SkillsMcpTab = "mcp" | "skills";
export type SkillsMcpNoteKind = "mcp" | "skill";
type MaybeAsyncAction = () => void | Promise<void>;
@@ -27,6 +29,7 @@ export type SkillsMcpPageProps = {
state: SkillsMcpState | null;
activeTab: SkillsMcpTab;
actionBusy: string;
noteBusyKey: string;
importOpen: boolean;
importPreview: SkillsMcpImportPreview | null;
zipInputRef: RefObject<HTMLInputElement>;
@@ -40,6 +43,14 @@ export type SkillsMcpPageProps = {
onCheckUpdates: MaybeAsyncAction;
onToggleSkill: (id: string, enabled: boolean) => void | Promise<void>;
onToggleMcp: (id: string, enabled: boolean) => void | Promise<void>;
onSaveNote: (itemKind: SkillsMcpNoteKind, id: string, note: string) => Promise<boolean>;
};
type NoteTarget = {
itemKind: SkillsMcpNoteKind;
id: string;
name: string;
note: string;
};
type SkillsMcpCopy = ReturnType<typeof getCopy>;
@@ -65,6 +76,16 @@ function getCopy(lang: Lang) {
enableSkill: "启用 Skill",
disableSkill: "禁用 Skill",
updateStatus: "更新状态",
addNote: "添加备注",
editNote: "编辑备注",
noteTitle: "自定义备注",
noteDescription: (name: string) => `为“${name}”添加便于识别的说明。`,
noteLabel: "备注内容",
notePlaceholder: "例如:用于查询项目文档,依赖本地 Node.js",
noteEmptyHint: "留空保存将清除现有备注",
noteCount: (count: number) => `${count} / 1000`,
saveNote: "保存备注",
savingNote: "保存中",
importTitle: "确认导入已有内容",
importDescription: "以下内容来自本机现有配置。导入后可在此统一启用或禁用。",
noImportItems: "没有发现可导入的已有 Skills / MCP。",
@@ -94,6 +115,16 @@ function getCopy(lang: Lang) {
enableSkill: "Enable Skill",
disableSkill: "Disable Skill",
updateStatus: "Update status",
addNote: "Add note",
editNote: "Edit note",
noteTitle: "Custom note",
noteDescription: (name: string) => `Add a description that helps you recognize “${name}”.`,
noteLabel: "Note",
notePlaceholder: "For example: searches project docs; requires local Node.js",
noteEmptyHint: "Save an empty note to remove it",
noteCount: (count: number) => `${count} / 1000`,
saveNote: "Save note",
savingNote: "Saving",
importTitle: "Confirm import",
importDescription: "These items were found in the existing local configuration. Import them to manage their state here.",
noImportItems: "No existing Skills / MCP items were found.",
@@ -116,22 +147,33 @@ function McpRow({
disabled,
copy,
onToggle,
onEditNote,
}: {
server: ManagedMcpServer;
busy: boolean;
disabled: boolean;
copy: SkillsMcpCopy;
onToggle: SkillsMcpPageProps["onToggleMcp"];
onEditNote: (target: NoteTarget) => void;
}) {
const name = server.name || server.id;
const details = [server.summary, server.transport, server.source].filter(Boolean).join("\n");
const details = [server.note, server.summary, server.transport, server.source].filter(Boolean).join("\n");
return (
<article className="cx-skills-row">
<div className="cx-skills-row-copy">
<div className={cx("cx-skills-row-copy", Boolean(server.note) && "cx-skills-row-copy--noted")}>
<strong title={details ? `${name}\n${details}` : name}>{name}</strong>
{server.note && <span className="cx-skills-row-note" title={server.note}>{server.note}</span>}
</div>
<div className="cx-skills-row-control">
{busy && <Loader2 size={15} className="cx-skills-spin" aria-hidden="true" />}
<IconButton
icon={<PencilLine size={15} strokeWidth={1.9} />}
label={server.note ? copy.editNote : copy.addNote}
size="sm"
className="cx-skills-note-button"
onClick={() => onEditNote({ itemKind: "mcp", id: server.id, name, note: server.note || "" })}
disabled={disabled}
/>
<Toggle
checked={server.enabled}
onCheckedChange={(enabled) => void onToggle(server.id, enabled)}
@@ -149,12 +191,14 @@ function SkillRow({
disabled,
copy,
onToggle,
onEditNote,
}: {
skill: ManagedSkill;
busy: boolean;
disabled: boolean;
copy: SkillsMcpCopy;
onToggle: SkillsMcpPageProps["onToggleSkill"];
onEditNote: (target: NoteTarget) => void;
}) {
const name = skill.name || skill.directory;
const updateTone = skill.updateStatus.includes("失败") || skill.updateStatus.toLowerCase().includes("fail")
@@ -164,20 +208,29 @@ function SkillRow({
: "neutral";
const showUpdateStatus = Boolean(skill.updateStatus && skill.updateStatus !== "未检查");
const details = [skill.description, skill.source].filter(Boolean).join("\n");
const details = [skill.note, skill.description, skill.source].filter(Boolean).join("\n");
return (
<article className="cx-skills-row">
<div className="cx-skills-row-copy">
<div className={cx("cx-skills-row-copy", Boolean(skill.note) && "cx-skills-row-copy--noted")}>
<strong title={details ? `${name}\n${details}` : name}>{name}</strong>
{skill.note && <span className="cx-skills-row-note" title={skill.note}>{skill.note}</span>}
{showUpdateStatus && (
<StatusBadge tone={updateTone} dot={false} title={copy.updateStatus}>
<StatusBadge tone={updateTone} dot={false} title={`${copy.updateStatus}: ${skill.updateStatus}`}>
{skill.updateStatus}
</StatusBadge>
)}
</div>
<div className="cx-skills-row-control">
{busy && <Loader2 size={15} className="cx-skills-spin" aria-hidden="true" />}
<IconButton
icon={<PencilLine size={15} strokeWidth={1.9} />}
label={skill.note ? copy.editNote : copy.addNote}
size="sm"
className="cx-skills-note-button"
onClick={() => onEditNote({ itemKind: "skill", id: skill.id, name, note: skill.note || "" })}
disabled={disabled}
/>
<Toggle
checked={skill.enabled}
onCheckedChange={(enabled) => void onToggle(skill.id, enabled)}
@@ -255,6 +308,7 @@ export function SkillsMcpPage({
state,
activeTab,
actionBusy,
noteBusyKey,
importOpen,
importPreview,
zipInputRef,
@@ -268,12 +322,34 @@ export function SkillsMcpPage({
onCheckUpdates,
onToggleSkill,
onToggleMcp,
onSaveNote,
}: SkillsMcpPageProps) {
const copy = getCopy(lang);
const tabId = useId();
const anyBusy = Boolean(actionBusy);
const importBusy = actionBusy === "importExistingSkillsMcp";
const activeItems = activeTab === "mcp" ? state?.mcpServers ?? [] : state?.skills ?? [];
const [noteTarget, setNoteTarget] = useState<NoteTarget | null>(null);
const [noteDraft, setNoteDraft] = useState("");
const noteBusy = noteTarget
? noteBusyKey === `note:${noteTarget.itemKind}:${noteTarget.id}`
: false;
const openNoteEditor = (target: NoteTarget) => {
setNoteTarget(target);
setNoteDraft(target.note);
};
const closeNoteEditor = () => {
if (!noteBusy) setNoteTarget(null);
};
const updateNoteDraft = (value: string) => {
setNoteDraft(Array.from(value).slice(0, 1000).join(""));
};
const saveNote = async () => {
if (!noteTarget || noteBusy) return;
if (await onSaveNote(noteTarget.itemKind, noteTarget.id, noteDraft)) {
setNoteTarget(null);
}
};
const handleZipChange = (event: ChangeEvent<HTMLInputElement>) => {
void onInstallZip(event.currentTarget.files?.[0]);
};
@@ -395,6 +471,7 @@ export function SkillsMcpPage({
disabled={anyBusy}
copy={copy}
onToggle={onToggleMcp}
onEditNote={openNoteEditor}
/>
))
) : state.skills.length === 0 ? (
@@ -407,6 +484,7 @@ export function SkillsMcpPage({
disabled={anyBusy}
copy={copy}
onToggle={onToggleSkill}
onEditNote={openNoteEditor}
/>
))}
</div>
@@ -453,6 +531,48 @@ export function SkillsMcpPage({
>
<ImportPreviewContent preview={importPreview} copy={copy} />
</ModalShell>
<ModalShell
open={Boolean(noteTarget)}
onClose={closeNoteEditor}
title={copy.noteTitle}
description={noteTarget ? copy.noteDescription(noteTarget.name) : undefined}
size="sm"
closeOnBackdrop={!noteBusy}
closeOnEscape={!noteBusy}
showCloseButton={!noteBusy}
className="cx-skills-note-modal"
footer={(
<>
<Button variant="secondary" onClick={closeNoteEditor} disabled={noteBusy}>
{copy.cancel}
</Button>
<Button
icon={noteBusy ? <Loader2 className="cx-skills-spin" /> : <PencilLine />}
onClick={() => void saveNote()}
disabled={noteBusy}
>
{noteBusy ? copy.savingNote : copy.saveNote}
</Button>
</>
)}
>
<label className="cx-skills-note-field">
<span>{copy.noteLabel}</span>
<textarea
value={noteDraft}
onChange={(event) => updateNoteDraft(event.target.value)}
placeholder={copy.notePlaceholder}
rows={5}
disabled={noteBusy}
data-initial-focus
/>
</label>
<div className="cx-skills-note-meta">
<span>{copy.noteEmptyHint}</span>
<span>{copy.noteCount(Array.from(noteDraft).length)}</span>
</div>
</ModalShell>
</section>
);
}
+74
View File
@@ -1,3 +1,4 @@
import { useState } from "react";
import type { ReactNode } from "react";
import {
CheckCircle2,
@@ -5,10 +6,12 @@ import {
ExternalLink,
Globe2,
Loader2,
Power,
RefreshCw,
Sparkles,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { Button, ModalShell } from "../components/ui";
import "../styles/utility-pages.css";
export type UtilityLanguage = "zh" | "en";
@@ -84,6 +87,15 @@ export type SettingsCopy = {
recheckTitle: ReactNode;
recheckDescription: ReactNode;
recheckLabel: ReactNode;
restartTitle: ReactNode;
restartDescription: ReactNode;
restartLabel: ReactNode;
restartTargetLabel: ReactNode;
restartConfirmTitle: ReactNode;
restartConfirmDescription: ReactNode;
restartCancelLabel: string;
restartConfirmLabel: ReactNode;
restartingLabel: ReactNode;
};
export type SettingsPageProps = {
@@ -91,7 +103,9 @@ export type SettingsPageProps = {
copy: SettingsCopy;
onLanguageChange: (lang: UtilityLanguage) => void;
onRecheck: () => void;
onRestartCodex: () => Promise<boolean>;
recheckBusy?: boolean;
restartBusy?: boolean;
};
type SettingRowProps = {
@@ -121,8 +135,19 @@ export function SettingsPage({
copy,
onLanguageChange,
onRecheck,
onRestartCodex,
recheckBusy = false,
restartBusy = false,
}: SettingsPageProps) {
const [restartConfirmOpen, setRestartConfirmOpen] = useState(false);
const closeRestartConfirm = () => {
if (!restartBusy) setRestartConfirmOpen(false);
};
const confirmRestart = async () => {
if (restartBusy) return;
if (await onRestartCodex()) setRestartConfirmOpen(false);
};
return (
<section className="cx-utility cx-page cx-page--settings">
<PageHeader eyebrow={copy.eyebrow} title={copy.title} />
@@ -176,7 +201,56 @@ export function SettingsPage({
</button>
)}
/>
<SettingRow
icon={Power}
title={copy.restartTitle}
description={copy.restartDescription}
action={(
<button
type="button"
className="cx-page-button cx-page-button--secondary"
onClick={() => setRestartConfirmOpen(true)}
disabled={restartBusy}
>
{restartBusy ? <Loader2 size={15} className="cx-page-spin" aria-hidden="true" /> : <Power size={15} aria-hidden="true" />}
{restartBusy ? copy.restartingLabel : copy.restartLabel}
</button>
)}
/>
</div>
<ModalShell
open={restartConfirmOpen}
onClose={closeRestartConfirm}
title={copy.restartConfirmTitle}
description={copy.restartConfirmDescription}
size="sm"
closeLabel={copy.restartCancelLabel}
closeOnBackdrop={!restartBusy}
closeOnEscape={!restartBusy}
showCloseButton={!restartBusy}
footer={(
<>
<Button variant="secondary" onClick={closeRestartConfirm} disabled={restartBusy} data-initial-focus>
{copy.restartCancelLabel}
</Button>
<Button
variant="danger"
icon={restartBusy ? <Loader2 size={16} className="cx-page-spin" /> : <Power size={16} />}
onClick={() => void confirmRestart()}
disabled={restartBusy}
>
{restartBusy ? copy.restartingLabel : copy.restartConfirmLabel}
</Button>
</>
)}
>
<div className="cx-page-restart-target">
<Power size={18} aria-hidden="true" />
<strong>{copy.restartTargetLabel}</strong>
</div>
</ModalShell>
</section>
);
}
+36
View File
@@ -0,0 +1,36 @@
export type ProviderProfile = {
baseUrl?: string | null;
providerName?: string | null;
model?: string | null;
apiKey?: string | null;
};
function normalizeProviderBaseUrl(value?: string | null) {
const raw = (value || "").trim();
if (!raw) return "";
try {
const parsed = new URL(raw);
const credentials = parsed.username
? `${parsed.username}${parsed.password ? `:${parsed.password}` : ""}@`
: "";
const path = parsed.pathname.replace(/\/+$/, "");
return `${parsed.protocol.toLowerCase()}//${credentials}${parsed.host.toLowerCase()}${path}${parsed.search}`;
} catch {
return raw.replace(/\/+$/, "");
}
}
function normalizeProviderName(value?: string | null) {
return (value || "").trim().replace(/\s+/gu, " ").toLowerCase();
}
export function providerProfilesMatch(left: ProviderProfile, right: ProviderProfile) {
const leftUrl = normalizeProviderBaseUrl(left.baseUrl);
if (!leftUrl || leftUrl !== normalizeProviderBaseUrl(right.baseUrl)) return false;
if ((left.model || "").trim() !== (right.model || "").trim()) return false;
const leftKey = (left.apiKey || "").trim();
const rightKey = (right.apiKey || "").trim();
if (leftKey && rightKey) return leftKey === rightKey;
return normalizeProviderName(left.providerName) === normalizeProviderName(right.providerName);
}
+18
View File
@@ -0,0 +1,18 @@
type OrderedProviderRow = {
isCurrent: boolean;
};
export function orderProviderRows<
Official extends OrderedProviderRow,
Detected extends OrderedProviderRow,
Local extends OrderedProviderRow,
>(
official: Official,
detected: readonly Detected[],
local: readonly Local[],
): Array<Official | Detected | Local> {
const rows: Array<Official | Detected | Local> = [official];
if (!local.some((row) => row.isCurrent)) rows.push(...detected);
rows.push(...local);
return rows;
}
+18
View File
@@ -438,6 +438,10 @@
border-color: transparent;
}
:root[data-theme="dark"] .cx-providers-row-meta {
color: #aeb3bc;
}
:root[data-theme="dark"] .cx-providers-info-grid code {
color: var(--cx-dark-muted);
background: transparent;
@@ -770,6 +774,20 @@
box-shadow: 0 7px 18px rgba(0, 0, 0, 0.18);
}
:root[data-theme="dark"] .cx-skills-note-field > span {
color: #d8dbe0;
}
:root[data-theme="dark"] .cx-skills-note-field textarea {
color: #e5e7eb;
background: var(--cx-dark-surface-recessed);
border-color: var(--cx-dark-border);
}
:root[data-theme="dark"] .cx-skills-note-meta {
color: var(--cx-dark-subtle);
}
:root[data-theme="dark"] .cx-prompts-row:hover {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(139, 92, 246, 0.4);
@@ -328,10 +328,13 @@
min-width: 0;
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 9px;
}
.cx-skills-row-copy > strong {
min-width: 0;
flex: 0 1 auto;
display: block;
overflow: hidden;
color: #2a2e34;
@@ -343,14 +346,38 @@
white-space: nowrap;
}
.cx-skills-row-copy--noted > strong {
max-width: min(40%, 360px);
flex: 0 1 auto;
}
.cx-skills-row-note {
min-width: 0;
flex: 1 1 180px;
overflow: hidden;
color: var(--cx-tool-muted);
font-size: 13px;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.cx-skills-row-copy .ui-status-badge {
flex: 0 0 auto;
max-width: min(30%, 220px);
overflow: hidden;
min-height: 20px;
padding: 0 6px;
border-radius: 5px;
font-size: 11px;
}
.cx-skills-row-copy .ui-status-badge > span:last-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.cx-skills-row-control,
.cx-prompts-row-actions {
min-width: 58px;
@@ -360,6 +387,67 @@
gap: 9px;
}
.cx-skills-note-button {
flex: 0 0 auto;
}
.cx-skills-note-modal .ui-modal__body {
display: grid;
gap: 8px;
}
.cx-skills-note-field {
display: grid;
gap: 8px;
}
.cx-skills-note-field > span {
color: #3e434b;
font-size: 12px;
font-weight: 680;
}
.cx-skills-note-field textarea {
width: 100%;
min-height: 118px;
resize: vertical;
padding: 11px 12px;
border: 1px solid #d8dce2;
border-radius: 7px;
color: #292d33;
background: #ffffff;
font: inherit;
font-size: 13px;
line-height: 1.55;
overflow-wrap: anywhere;
}
.cx-skills-note-field textarea:focus {
border-color: #8db2f5;
outline: 2px solid rgba(37, 99, 235, 0.16);
outline-offset: 1px;
}
.cx-skills-note-field textarea:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.cx-skills-note-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
color: #858b94;
font-size: 11px;
line-height: 1.4;
}
.cx-skills-note-meta span:last-child {
flex: 0 0 auto;
font-variant-numeric: tabular-nums;
}
.cx-skills-empty {
min-height: 150px;
display: flex;
@@ -1444,12 +1532,37 @@
}
.cx-skills-row {
grid-template-columns: 34px minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
}
.cx-skills-row-control {
grid-column: 2;
justify-content: flex-start;
grid-row: 1;
justify-content: flex-end;
}
.cx-skills-row-copy {
grid-column: 1;
grid-row: 1;
}
.cx-skills-row-copy--noted {
flex-wrap: wrap;
}
.cx-skills-row-copy--noted > strong {
max-width: 100%;
flex: 1 1 auto;
}
.cx-skills-row-note {
flex-basis: 100%;
display: -webkit-box;
overflow: hidden;
white-space: normal;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.cx-prompts-list {
+12
View File
@@ -232,6 +232,18 @@
line-height: 1.3;
}
.cx-page-restart-target {
display: flex;
align-items: center;
gap: 9px;
color: var(--cx-page-text);
}
.cx-page-restart-target > svg {
flex: 0 0 auto;
color: var(--cx-page-blue);
}
.cx-page-setting-copy p {
margin: 5px 0 0;
color: var(--cx-page-muted);
+7
View File
@@ -137,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;
@@ -191,6 +196,7 @@ export type ManagedSkill = {
id: string;
name: string;
description?: string | null;
note?: string | null;
directory: string;
enabled: boolean;
source: string;
@@ -206,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"]);
});
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "codex-x-workspace",
"private": true,
"version": "0.3.13",
"version": "0.3.15",
"scripts": {
"dev": "pnpm --dir apps/desktop tauri dev",
"build": "pnpm --dir apps/desktop tauri build",