* chore(release): stop publishing legacy Windows update packages

* feat(agents): support OpenCode Desktop across platforms

Detect CLI and desktop installations on Windows, macOS, and Linux, and add separate launch and restart controls.

Closes #156

* fix(codex): align model display names and show total session count

- preserve runtime model identity in generated catalogs
- count distinct sessions across database and rollout sources
- update session count labels in all supported languages

* fix(codex): keep config and auth files in sync

- generate auth.json for API key configuration
- preserve and validate OAuth credentials
- include auth.json in transactional updates and rollback
- retain compatibility with legacy Codex configuration states

* fix(cpa-gui): update version to 0.2.59 in Cargo files
This commit is contained in:
lzt404 authored and GitHub committed 2026-08-23 16:19:03 +08:00
1 parent 93fe5baa54
commit d0bd5a890a
15 files changed
+205 -20

No files matched your search

+1 -1
View File
@@ -593,7 +593,7 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
[[package]]
name = "cpa-gui"
version = "0.2.26"
version = "0.2.59"
dependencies = [
"base64 0.22.1",
"chrono",
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "cpa-gui"
# Single source of truth for the desktop app version.
version = "0.2.26"
version = "0.2.59"
description = "CPA desktop management shell"
authors = ["lzt404"]
edition = "2021"
+27
View File
@@ -145,6 +145,7 @@ pub(crate) fn build_agent_updates_with_oauth(
path: codex_model_catalog_path(home),
after: catalog.to_string(),
});
updates.push(build_codex_auth_update(home, api_key, oauth_configuration)?);
Ok(updates)
}
AgentClient::OpenCode => {
@@ -2524,6 +2525,32 @@ pub(crate) fn build_codex_agent_config_with_oauth(
Ok(rendered)
}
pub(crate) fn build_codex_api_auth(api_key: &str) -> Result<String, String> {
let mut rendered = serde_json::to_string_pretty(&serde_json::json!({
"auth_mode": "apikey",
"OPENAI_API_KEY": api_key,
}))
.map_err(|error| format!("生成 Codex auth.json 失败: {error}"))?;
rendered.push('\n');
Ok(rendered)
}
pub(crate) fn build_codex_auth_update(
home: &Path,
api_key: &str,
oauth_configuration: bool,
) -> Result<AgentFileUpdate, String> {
let path = codex_configuration_directory(home).join("auth.json");
let after = if oauth_configuration {
validate_codex_oauth_login_at(&path)?;
read_optional_text(&path)?
.ok_or_else(|| "Codex OAuth 登录凭据在应用配置前已被删除,请重新登录".to_string())?
} else {
build_codex_api_auth(api_key)?
};
Ok(AgentFileUpdate { path, after })
}
const CODEX_MANAGED_ROOT_KEYS: [&str; 3] = ["model_provider", "model", "model_catalog_json"];
const CODEX_MANAGED_PROVIDER_KEYS: [&str; 5] = [
"name",
+26 -1
View File
@@ -686,6 +686,23 @@ pub(crate) fn codex_auth_file_has_oauth_tokens(path: &Path) -> bool {
})
}
pub(crate) fn codex_auth_file_has_api_key(path: &Path, api_key: &str) -> bool {
let Ok(content) = fs::read_to_string(path) else {
return false;
};
let Ok(root) = serde_json::from_str::<serde_json::Value>(&content) else {
return false;
};
let api_key_matches = root
.get("OPENAI_API_KEY")
.and_then(serde_json::Value::as_str)
.is_some_and(|value| value == api_key);
let mode_matches = root
.get("auth_mode")
.is_none_or(|value| value.as_str() == Some("apikey"));
api_key_matches && mode_matches
}
pub(crate) fn validate_codex_oauth_login(home: &Path) -> Result<(), String> {
validate_codex_oauth_login_at(&codex_configuration_directory(home).join("auth.json"))
}
@@ -738,6 +755,7 @@ pub(crate) fn expected_agent_record_paths(client: AgentClient, paths: &[PathBuf]
if client == AgentClient::Codex && paths.len() == 1 {
let mut expected = paths.to_vec();
expected.push(paths[0].with_file_name(CODEX_MODEL_CATALOG_FILE));
expected.push(paths[0].with_file_name("auth.json"));
expected
} else {
paths.to_vec()
@@ -3187,7 +3205,13 @@ pub(crate) fn inspect_codex_agent_config(
.and_then(|provider| provider.get("requires_openai_auth"))
.and_then(toml::Value::as_bool)
== Some(true);
let configured = root.get("model_provider").and_then(toml::Value::as_str)
let auth_path = path.with_file_name("auth.json");
let auth_configured = if oauth_configuration {
codex_auth_file_has_oauth_tokens(&auth_path)
} else {
codex_auth_file_has_api_key(&auth_path, api_key)
};
let configured_without_auth = root.get("model_provider").and_then(toml::Value::as_str)
== Some(MANAGED_AGENT_PROVIDER_ID)
&& root.get("model_catalog_json").and_then(toml::Value::as_str)
== Some(CODEX_MODEL_CATALOG_FILE)
@@ -3204,6 +3228,7 @@ pub(crate) fn inspect_codex_agent_config(
.and_then(|provider| provider.get("wire_api"))
.and_then(toml::Value::as_str)
== Some("responses");
let configured = configured_without_auth && auth_configured;
let model = root
.get("model")
.and_then(toml::Value::as_str)
+8
View File
@@ -189,7 +189,14 @@ pub(crate) fn validate_agent_applied_state(
.iter()
.map(|file| file.path.clone())
.collect::<Vec<_>>();
let codex_paths_without_auth = (client == AgentClient::Codex
&& expected_paths
.last()
.and_then(|path| path.file_name())
.is_some_and(|name| name == "auth.json"))
.then(|| &expected_paths[..expected_paths.len().saturating_sub(1)]);
let valid_paths = state_paths == expected_paths
|| codex_paths_without_auth.is_some_and(|expected| state_paths == expected)
|| (client == AgentClient::Codex && state_paths.as_slice() == paths)
|| (client == AgentClient::ZCode
&& paths
@@ -1686,6 +1693,7 @@ pub(crate) fn reset_agent_configuration_to_default_with_oauth(
path: codex_model_catalog_path(home),
after: catalog.to_string(),
});
updates.push(build_codex_auth_update(home, api_key, oauth_configuration)?);
}
commit_agent_configuration(
client,
+26 -6
View File
@@ -145,7 +145,7 @@ pub(crate) fn parse_runtime_models(payload: &Value) -> Result<Vec<CodexRuntimeMo
models.push(make_runtime_model(slug, display_name));
}
if seen.insert(normalize_id(&model_alias)) {
models.push(make_runtime_model(&model_alias, Some(slug.to_string())));
models.push(make_runtime_model(&model_alias, None));
}
} else if seen.insert(normalize_id(slug)) {
models.push(make_runtime_model(slug, display_name));
@@ -345,6 +345,15 @@ fn prepare_catalog_with_sources(
if let Some(template) = sources.templates.get(&key) {
let mut value = template.value.clone();
value.insert("slug".to_string(), Value::String(runtime.slug.clone()));
value.insert(
"display_name".to_string(),
Value::String(
runtime
.display_name
.clone()
.unwrap_or_else(|| runtime.slug.clone()),
),
);
enable_fast_mode(&mut value);
entries.push(CatalogEntry {
value,
@@ -877,9 +886,9 @@ mod tests {
.collect::<Vec<_>>(),
vec!["gpt-5.5", "gpt-5.5-xhigh", "visible-alias"]
);
assert_eq!(models[1].display_name.as_deref(), Some("gpt-5.5"));
assert_eq!(models[1].display_name, None);
assert_eq!(models[1].context_window, Some(200_000));
assert_eq!(models[2].display_name.as_deref(), Some("hidden-source"));
assert_eq!(models[2].display_name, None);
let catalog = prepare_catalog_with_sources(&models, &test_sources()).unwrap();
assert_eq!(
@@ -890,7 +899,7 @@ mod tests {
.collect::<Vec<_>>(),
vec!["gpt-5.5", "gpt-5.5-xhigh", "visible-alias"]
);
assert_eq!(catalog.models[1].alias.as_deref(), Some("gpt-5.5"));
assert_eq!(catalog.models[1].alias, None);
}
#[test]
@@ -975,7 +984,7 @@ mod tests {
}
#[test]
fn known_template_is_preserved_except_for_runtime_slug() {
fn known_template_is_preserved_except_for_runtime_identity() {
let sources = test_sources();
let runtime = runtime(serde_json::json!({"data":[{
"id":"a",
@@ -989,12 +998,13 @@ mod tests {
for (key, expected) in template {
if !matches!(
key.as_str(),
"slug" | "service_tiers" | "additional_speed_tiers"
"slug" | "display_name" | "service_tiers" | "additional_speed_tiers"
) {
assert_eq!(model.get(key), Some(expected), "changed field {key}");
}
}
assert_eq!(model["slug"], "a");
assert_eq!(model["display_name"], "Overwrite");
assert_eq!(model["nested"]["unknown"], true);
assert_eq!(
model["service_tiers"],
@@ -1007,6 +1017,16 @@ mod tests {
assert_eq!(model["additional_speed_tiers"], serde_json::json!(["fast"]));
}
#[test]
fn known_template_uses_runtime_slug_when_display_name_is_missing() {
let runtime = runtime(serde_json::json!({"models":[{"id":"A"}]}));
let catalog = prepare_catalog_with_sources(&runtime, &test_sources()).unwrap();
let model = &output_models(&catalog)[0];
assert_eq!(model["slug"], "A");
assert_eq!(model["display_name"], "A");
}
#[test]
fn fallback_accepts_only_safe_valid_metadata() {
let runtime = runtime(serde_json::json!({"models":[{
+55 -6
View File
@@ -71,6 +71,7 @@ pub(crate) struct CodexSessionPage {
codex_home: String,
database_paths: Vec<String>,
sessions: Vec<CodexSessionSummary>,
total_count: usize,
offset: usize,
limit: usize,
has_more: bool,
@@ -446,15 +447,26 @@ fn list_codex_sessions_from_home(
let fetch_limit = offset.saturating_add(limit).saturating_add(1);
let (database_paths, mut warnings) = discover_database_paths(codex_home, false);
let mut sessions = Vec::new();
let mut total_session_ids = HashSet::new();
for path in &database_paths {
match list_sessions_from_database(path, fetch_limit) {
Ok(mut items) => sessions.append(&mut items),
Err(error) => warnings.push(error),
}
match list_session_ids_from_database(path) {
Ok(ids) => total_session_ids.extend(ids),
Err(error) => warnings.push(error),
}
}
let (mut rollout_sessions, rollout_warnings) = list_sessions_from_rollouts(codex_home);
sessions.append(&mut rollout_sessions);
warnings.extend(rollout_warnings);
total_session_ids.extend(
sessions
.iter()
.map(|session| session_identity_key(&session.id))
.filter(|id| !id.is_empty()),
);
let mut sessions = merge_session_summaries(sessions);
sessions.sort_by(|left, right| {
right
@@ -472,6 +484,7 @@ fn list_codex_sessions_from_home(
.map(|path| path.to_string_lossy().to_string())
.collect(),
sessions,
total_count: total_session_ids.len(),
offset,
limit,
has_more,
@@ -483,12 +496,7 @@ fn merge_session_summaries(sessions: Vec<CodexSessionSummary>) -> Vec<CodexSessi
let mut merged = Vec::<CodexSessionSummary>::new();
let mut positions = HashMap::<String, usize>::new();
for session in sessions {
let key = normalize_thread_id(&session.id);
let key = if key.is_empty() {
session.id.clone()
} else {
key
};
let key = session_identity_key(&session.id);
let Some(index) = positions.get(&key).copied() else {
positions.insert(key, merged.len());
merged.push(session);
@@ -719,6 +727,36 @@ fn list_sessions_from_database(
Ok(sessions)
}
fn list_session_ids_from_database(path: &Path) -> Result<HashSet<String>, String> {
let connection = open_read_only(path)?;
let mut ids = HashSet::new();
for (table, column) in [("threads", "id"), ("automation_runs", "thread_id")] {
let columns = table_columns(&connection, table)?;
if !columns.contains(column) {
continue;
}
let sql = format!(
"SELECT DISTINCT \"{column}\" FROM \"{table}\" WHERE COALESCE(\"{column}\", '') <> ''"
);
let mut statement = connection
.prepare(&sql)
.map_err(|error| format!("统计会话总数失败 {}: {error}", path.display()))?;
let rows = statement
.query_map([], |row| row.get::<_, String>(0))
.map_err(|error| format!("统计会话总数失败 {}: {error}", path.display()))?;
for id in rows
.collect::<rusqlite::Result<Vec<_>>>()
.map_err(|error| format!("统计会话总数失败 {}: {error}", path.display()))?
{
let id = session_identity_key(&id);
if !id.is_empty() {
ids.insert(id);
}
}
}
Ok(ids)
}
fn list_thread_rows(
connection: &Connection,
path: &Path,
@@ -822,6 +860,15 @@ fn normalize_thread_id(value: &str) -> String {
.to_string()
}
fn session_identity_key(value: &str) -> String {
let normalized = normalize_thread_id(value);
if normalized.is_empty() {
value.trim().to_string()
} else {
normalized
}
}
fn delete_codex_sessions_from_home(
codex_home: &Path,
session_ids: Vec<String>,
@@ -2421,6 +2468,7 @@ mod tests {
let page = list_codex_sessions_from_home(&root, 0, 50).unwrap();
assert_eq!(page.sessions.len(), 2);
assert_eq!(page.total_count, 2);
assert_eq!(page.sessions[0].id, "thread-1");
assert_eq!(page.sessions[0].title, "First new");
assert_eq!(page.sessions[0].updated_at_ms, Some(4000));
@@ -2428,6 +2476,7 @@ mod tests {
assert!(page.sessions[1].archived);
let second_page = list_codex_sessions_from_home(&root, 1, 1).unwrap();
assert_eq!(second_page.sessions.len(), 1);
assert_eq!(second_page.total_count, 2);
assert_eq!(second_page.sessions[0].id, "thread-2");
assert!(!second_page.has_more);
fs::remove_dir_all(root).unwrap();
@@ -332,6 +332,11 @@ fn codex_oauth_configuration_uses_openai_auth_with_bearer_token() {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, rendered).unwrap();
fs::write(
path.with_file_name("auth.json"),
r#"{"tokens":{"access_token":"oauth-access-token"}}"#,
)
.unwrap();
let (configured, model, oauth_configuration) =
inspect_codex_agent_config(&path, 8317, DEFAULT_API_KEY).unwrap();
assert!(configured);
@@ -343,6 +348,12 @@ fn codex_oauth_configuration_uses_openai_auth_with_bearer_token() {
#[test]
fn codex_api_and_oauth_modes_write_the_same_catalog() {
let home = agent_test_home("codex-auth-mode-catalog");
fs::create_dir_all(home.join(".codex")).unwrap();
fs::write(
home.join(".codex/auth.json"),
r#"{"tokens":{"access_token":"oauth-access-token"}}"#,
)
.unwrap();
let models = test_agent_models(&["gpt-5.5", "third-party-model"]);
let catalog = test_codex_models(&["gpt-5.5", "third-party-model"]);
let build = |oauth_configuration| {
@@ -366,6 +377,19 @@ fn codex_api_and_oauth_modes_write_the_same_catalog() {
let oauth_updates = build(true);
assert_eq!(api_updates[1].after, oauth_updates[1].after);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&api_updates[2].after).unwrap()["OPENAI_API_KEY"],
DEFAULT_API_KEY
);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&api_updates[2].after).unwrap()["auth_mode"],
"apikey"
);
assert!(
serde_json::from_str::<serde_json::Value>(&oauth_updates[2].after).unwrap()["tokens"]
["access_token"]
.is_string()
);
assert!(api_updates[0].after.contains("model_catalog_json"));
assert!(oauth_updates[0].after.contains("model_catalog_json"));
assert!(!api_updates[0].after.contains("service_tier"));
@@ -394,6 +418,11 @@ fn codex_status_requires_a_valid_catalog_containing_the_default_model() {
.unwrap(),
)
.unwrap();
fs::write(
config_path.with_file_name("auth.json"),
build_codex_api_auth(DEFAULT_API_KEY).unwrap(),
)
.unwrap();
let missing = inspect_agent_config(AgentClient::Codex, &home, 8317, DEFAULT_API_KEY);
assert!(!missing.configured);
+23
View File
@@ -1324,6 +1324,11 @@ fn direct_agent_apply_preserves_unrelated_fields_and_default_reset_removes_them(
assert!(applied.contains("[model_providers.other]"));
assert!(applied.contains("model = \"gpt-test\""));
assert!(applied.contains("experimental_bearer_token = \"custom-agent-key\""));
let auth_path = home.join(".codex/auth.json");
let auth = serde_json::from_str::<serde_json::Value>(&fs::read_to_string(&auth_path).unwrap())
.unwrap();
assert_eq!(auth["OPENAI_API_KEY"], api_key);
assert_eq!(auth["auth_mode"], "apikey");
assert!(!agent_backup_path(&path).unwrap().exists());
assert!(!agent_backup_path(&codex_model_catalog_path(&home))
.unwrap()
@@ -1400,6 +1405,24 @@ fn clear_codex_config_removes_only_requested_config_files_and_application_state(
assert!(!state_path.exists());
assert_eq!(fs::read_to_string(&preserved_path).unwrap(), "keep");
assert!(clear_codex_config_files(&home).unwrap().is_empty());
let models = test_agent_models(&["gpt-test"]);
let catalog = test_codex_models(&["gpt-test"]);
apply_agent_configuration(
AgentClient::Codex,
&home,
8317,
"reapplied-api-key",
"gpt-test",
&models,
Some(&catalog),
)
.unwrap();
assert!(config_path.is_file());
let auth = serde_json::from_str::<serde_json::Value>(&fs::read_to_string(&auth_path).unwrap())
.unwrap();
assert_eq!(auth["OPENAI_API_KEY"], "reapplied-api-key");
assert_eq!(auth["auth_mode"], "apikey");
fs::remove_dir_all(home).unwrap();
}
+4 -1
View File
@@ -384,7 +384,7 @@ fn updating_legacy_codex_state_adds_catalog_without_replacing_original_backup()
.unwrap()
.unwrap();
assert_eq!(upgraded.version, AGENT_MODIFICATION_STATE_VERSION);
assert_eq!(upgraded.files.len(), 2);
assert_eq!(upgraded.files.len(), 3);
assert_eq!(
fs::read(agent_backup_path(&path).unwrap()).unwrap(),
original_config
@@ -394,10 +394,13 @@ fn updating_legacy_codex_state_adds_catalog_without_replacing_original_backup()
original_catalog
);
fs::write(&catalog_path, b"{\"models\":[]}\n").unwrap();
let auth_path = path.with_file_name("auth.json");
assert!(auth_path.is_file());
disable_agent_modification(AgentClient::Codex, &home, 8317, false).unwrap();
assert_eq!(fs::read(&path).unwrap(), original_config);
assert_eq!(fs::read(&catalog_path).unwrap(), original_catalog);
assert!(!auth_path.exists());
fs::remove_dir_all(home).unwrap();
}
+1 -1
View File
@@ -541,7 +541,7 @@ export const jaOverrides = {
'agents.sessions.title': 'Codex セッション管理',
'agents.sessions.description': 'ローカルの Codex セッションをスキャンし、過去のセッションの復元、バックアップ、削除を安全に行います。',
'agents.sessions.refresh': '更新',
'agents.sessions.currentPage': '現在のページ',
'agents.sessions.total': 'セッション総数',
'agents.sessions.active': '未アーカイブ',
'agents.sessions.archived': 'アーカイブ済み',
'agents.sessions.database': 'データベース',
+1 -1
View File
@@ -541,7 +541,7 @@ export const en: Record<MessageKey, string> = {
'agents.sessions.title': 'Codex Session Management',
'agents.sessions.description': 'Scan local Codex sessions and safely restore historical sessions, back them up, or delete them.',
'agents.sessions.refresh': 'Refresh',
'agents.sessions.currentPage': 'Current page',
'agents.sessions.total': 'Total sessions',
'agents.sessions.active': 'Active',
'agents.sessions.archived': 'Archived',
'agents.sessions.database': 'Database source',
+1 -1
View File
@@ -539,7 +539,7 @@ export const zhCN = {
'agents.sessions.title': 'Codex 会话管理',
'agents.sessions.description': '扫描本地 Codex 会话,并安全地恢复历史会话、备份或删除。',
'agents.sessions.refresh': '刷新',
'agents.sessions.currentPage': '当前页',
'agents.sessions.total': '会话总数',
'agents.sessions.active': '未归档',
'agents.sessions.archived': '已归档',
'agents.sessions.database': '数据库来源',
+1 -1
View File
@@ -287,7 +287,7 @@ export function CodexSessionsPanel() {
</div>
<div className="codex-session-metrics">
<div><span>{t('agents.sessions.currentPage')}</span><strong>{sessions.length}</strong></div>
<div><span>{t('agents.sessions.total')}</span><strong>{page?.totalCount ?? sessions.length}</strong></div>
<div><span>{t('agents.sessions.active')}</span><strong>{pageCounts.active}</strong></div>
<div><span>{t('agents.sessions.archived')}</span><strong>{pageCounts.archived}</strong></div>
<div
+1
View File
@@ -12,6 +12,7 @@ export type CodexSessionPage = {
codexHome: string;
databasePaths: string[];
sessions: CodexSessionSummary[];
totalCount: number;
offset: number;
limit: number;
hasMore: boolean;