From 9170fb048fba41798d2ca7b405152472ac6ed7ef Mon Sep 17 00:00:00 2001 From: k9ight000 <121161284+k9ight000@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:19:28 +0800 Subject: [PATCH 1/2] feat: add secure provider guard --- Cargo.lock | 4 + apps/codex-plus-launcher/src/main.rs | 7 + .../src-tauri/src/commands.rs | 35 ++ apps/codex-plus-manager/src-tauri/src/lib.rs | 2 + apps/codex-plus-manager/src/App.tsx | 116 ++++- apps/codex-plus-manager/src/i18n-en.ts | 17 + crates/codex-plus-core/Cargo.toml | 1 + crates/codex-plus-core/src/routes.rs | 4 + crates/codex-plus-core/src/script_market.rs | 59 ++- crates/codex-plus-core/tests/bridge_routes.rs | 31 +- crates/codex-plus-data/Cargo.toml | 3 + crates/codex-plus-data/src/lib.rs | 5 + crates/codex-plus-data/src/provider_guard.rs | 461 ++++++++++++++++++ 13 files changed, 731 insertions(+), 14 deletions(-) create mode 100644 crates/codex-plus-data/src/provider_guard.rs diff --git a/Cargo.lock b/Cargo.lock index e65b306e0..059ba3dea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -426,6 +426,7 @@ dependencies = [ "tokio-tungstenite", "toml 0.8.2", "toml_edit 0.22.27", + "url", "uuid", "windows 0.58.0", "zip", @@ -445,6 +446,9 @@ dependencies = [ "sha2", "tempfile", "thiserror 2.0.18", + "toml 0.8.2", + "toml_edit 0.22.27", + "url", "uuid", ] diff --git a/apps/codex-plus-launcher/src/main.rs b/apps/codex-plus-launcher/src/main.rs index 81b9bb134..b5dcefcd0 100644 --- a/apps/codex-plus-launcher/src/main.rs +++ b/apps/codex-plus-launcher/src/main.rs @@ -396,6 +396,13 @@ impl Default for LauncherDataService { #[async_trait::async_trait] impl BridgeDataService for LauncherDataService { + async fn provider_guard_status(&self) -> anyhow::Result { + let status = tokio::task::spawn_blocking(|| codex_plus_data::inspect_provider_guard(None)) + .await + .map_err(|error| anyhow::anyhow!("provider guard status task failed: {error}"))??; + Ok(serde_json::to_value(status)?) + } + async fn delete(&self, session: SessionRef) -> anyhow::Result { let db_paths = self.candidate_db_paths(); let backup_store = codex_plus_data::BackupStore::new(self.backup_dir.clone()); diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index ad737b871..b90b1cc77 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -1150,6 +1150,41 @@ pub async fn load_provider_sync_targets() -> CommandResult { } } +#[tauri::command] +pub async fn load_provider_guard_status() -> CommandResult { + let result = tauri::async_runtime::spawn_blocking(|| codex_plus_data::inspect_provider_guard(None)) + .await + .map_err(|error| anyhow::anyhow!("provider guard status task failed: {error}")); + match result { + Ok(Ok(status)) => ok( + "Provider Guard 状态已加载。", + serde_json::to_value(status).unwrap_or_else(|_| json!({})), + ), + Ok(Err(error)) | Err(error) => { + failed(&format!("Provider Guard 状态加载失败:{error}"), json!({})) + } + } +} + +#[tauri::command] +pub async fn repair_provider_guard(confirmed: bool) -> CommandResult { + if !confirmed { + return failed("必须在原生管理器中确认后才能执行 Provider Guard 修复。", json!({})); + } + let result = tauri::async_runtime::spawn_blocking(|| codex_plus_data::repair_provider_guard(None)) + .await + .map_err(|error| anyhow::anyhow!("provider guard repair task failed: {error}")); + match result { + Ok(Ok(repair)) => ok( + "Provider Guard 已完成备份和修复。", + serde_json::to_value(repair).unwrap_or_else(|_| json!({})), + ), + Ok(Err(error)) | Err(error) => { + failed(&format!("Provider Guard 修复失败:{error}"), json!({})) + } + } +} + fn merge_manual_provider_sync_targets( targets: &mut codex_plus_data::ProviderSyncTargetList, manual: &[String], diff --git a/apps/codex-plus-manager/src-tauri/src/lib.rs b/apps/codex-plus-manager/src-tauri/src/lib.rs index eb614fbf7..b5b52a794 100644 --- a/apps/codex-plus-manager/src-tauri/src/lib.rs +++ b/apps/codex-plus-manager/src-tauri/src/lib.rs @@ -65,6 +65,8 @@ pub fn run() { commands::forget_zed_remote_project, commands::delete_local_session, commands::load_provider_sync_targets, + commands::load_provider_guard_status, + commands::repair_provider_guard, commands::preview_session_index_cleanup, commands::apply_session_index_cleanup, commands::sync_providers_now, diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index 2ea274e7d..dbe66b75d 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -539,6 +539,28 @@ type ProviderSyncTargetsPayload = { type ProviderSyncTargetsResult = CommandResult; +type ProviderGuardFinding = { + code: string; + severity: "warning" | "critical" | string; + message: string; +}; + +type ProviderGuardStatusPayload = { + level: "ok" | "warning" | "critical" | string; + stableProvider: string; + currentProvider: string; + stableProviderConfigured: boolean; + totalThreads: number; + databasesScanned: number; + providerBuckets: Array<{ provider: string; threads: number }>; + endpoint: { kind: string; loopback: boolean; port?: number | null }; + findings: ProviderGuardFinding[]; + canRepair: boolean; + repairRequiresNativeConfirmation: boolean; +}; + +type ProviderGuardResult = CommandResult; + type ProviderSyncProgress = { active: boolean; percent: number; @@ -845,6 +867,7 @@ export function App() { message: t("尚未检查官方远端插件缓存。"), }); const [providerSyncTargets, setProviderSyncTargets] = useState(null); + const [providerGuard, setProviderGuard] = useState(null); const [selectedProviderSyncTarget, setSelectedProviderSyncTarget] = useState(""); const [removeOwnedData, setRemoveOwnedData] = useState(false); const [relaySwitching, setRelaySwitching] = useState(false); @@ -1217,6 +1240,7 @@ export function App() { await refreshSettings(true); await refreshLocalSessions(true); await refreshProviderSyncTargets(true); + await refreshProviderGuard(true); } if (next === "zedRemote") { await refreshSettings(true); @@ -1532,6 +1556,35 @@ export function App() { return result; }; + const refreshProviderGuard = async (silent = false) => { + const result = await run(() => call("load_provider_guard_status")); + if (result) { + setProviderGuard(result); + if (!silent && !isSuccessStatus(result.status)) showNotice(t("Provider Guard"), result.message, result.status); + } + return result; + }; + + const repairProviderGuard = async () => { + if (!providerGuard?.canRepair) { + showNotice(t("Provider Guard"), t("当前配置不满足安全修复条件,请先配置 model_providers.custom。"), "failed"); + return; + } + const confirmed = window.confirm( + t("修复前会备份 config.toml、会话文件和 SQLite 索引,并将稳定供应商 ID 设为 custom。是否继续?"), + ); + if (!confirmed) return; + const result = await run(() => + call>("repair_provider_guard", { confirmed: true }), + ); + if (result) { + showNotice(t("Provider Guard"), result.message, result.status); + await refreshProviderGuard(true); + await refreshProviderSyncTargets(true); + await refreshLocalSessions(true); + } + }; + const syncProvidersNow = async () => { if (providerSyncProgress.active) return; setProviderSyncProgress({ @@ -1948,6 +2001,7 @@ export function App() { await refreshRelay(true); await refreshEnvConflicts(true); await refreshProviderSyncTargets(true); + await refreshProviderGuard(true); await refreshPendingProviderImport(true); await refreshRemotePluginMarketplace(true); })(); @@ -2080,6 +2134,8 @@ export function App() { }, syncProvidersNow, refreshProviderSyncTargets, + refreshProviderGuard, + repairProviderGuard, setProviderSyncTarget: (provider: string) => { setSelectedProviderSyncTarget(provider); setSettingsForm((current) => ({ ...current, providerSyncLastSelectedProvider: provider })); @@ -2141,7 +2197,7 @@ export function App() { disableWatcher: () => watcherAction("disable_watcher"), toggleTheme: () => setTheme((current) => (current === "dark" ? "light" : "dark")), }), - [route, launchForm, settingsForm, settings, removeOwnedData, update, updateInstallProgress.active, logs, diagnostics, theme, relayFiles, localSessions, zedRemoteProjects, selectedProviderSyncTarget, envConflicts, relayEnvironment, ccsProviders], + [route, launchForm, settingsForm, settings, removeOwnedData, update, updateInstallProgress.active, logs, diagnostics, theme, relayFiles, localSessions, zedRemoteProjects, selectedProviderSyncTarget, providerGuard, envConflicts, relayEnvironment, ccsProviders], ); const hasUpdate = update?.updateAvailable === true; @@ -2251,6 +2307,7 @@ export function App() { sessions={localSessions} providerSyncProgress={providerSyncProgress} providerSyncTargets={providerSyncTargets} + providerGuard={providerGuard} selectedProviderSyncTarget={selectedProviderSyncTarget} onFormChange={setSettingsForm} actions={actions} @@ -2374,6 +2431,8 @@ type Actions = { saveManualCodexAppPath: () => Promise; syncProvidersNow: () => Promise; refreshProviderSyncTargets: (silent?: boolean) => Promise; + refreshProviderGuard: (silent?: boolean) => Promise; + repairProviderGuard: () => Promise; setProviderSyncTarget: (provider: string) => void; setLaunchMode: (launchMode: LaunchMode) => Promise; refreshRelay: () => Promise; @@ -3192,6 +3251,7 @@ function SessionsScreen({ sessions, providerSyncProgress, providerSyncTargets, + providerGuard, selectedProviderSyncTarget, onFormChange, actions, @@ -3201,6 +3261,7 @@ function SessionsScreen({ sessions: LocalSessionsResult | null; providerSyncProgress: ProviderSyncProgress; providerSyncTargets: ProviderSyncTargetsResult | null; + providerGuard: ProviderGuardResult | null; selectedProviderSyncTarget: string; onFormChange: (value: BackendSettings) => void; actions: Actions; @@ -3262,6 +3323,59 @@ function SessionsScreen({ return ( <> + + + +
+ + + + + +
+ {(providerGuard?.providerBuckets ?? []).length ? ( +
+ + + {t("会话分桶:")} + {providerGuard?.providerBuckets.map((bucket) => `${bucket.provider}=${bucket.threads}`).join(",")} + +
+ ) : null} + {(providerGuard?.findings ?? []).map((finding) => ( +
+ {finding.severity === "critical" ? : } + {finding.message} +
+ ))} + {!providerGuard?.findings?.length && providerGuard ? ( +
+ + {t("配置与会话分桶保持稳定。")} +
+ ) : null} + + + + +
+ + {t("修复只能从原生管理器执行;脚本市场仅拥有只读检查权限。")} +
+
+
diff --git a/apps/codex-plus-manager/src/i18n-en.ts b/apps/codex-plus-manager/src/i18n-en.ts index feb20b895..45cf7f6ba 100644 --- a/apps/codex-plus-manager/src/i18n-en.ts +++ b/apps/codex-plus-manager/src/i18n-en.ts @@ -5,6 +5,23 @@ // Plain strings: t("中文") -> EN_PLAIN["中文"]. export const EN_PLAIN: Record = { + "Provider Guard": "Provider Guard", + "当前配置不满足安全修复条件,请先配置 model_providers.custom。": + "The current configuration cannot be repaired safely. Configure model_providers.custom first.", + "修复前会备份 config.toml、会话文件和 SQLite 索引,并将稳定供应商 ID 设为 custom。是否继续?": + "Before repairing, Codex++ will back up config.toml, session files, and SQLite indexes, then set the stable provider ID to custom. Continue?", + "固定稳定供应商 ID,检查会话分桶,并阻止脚本市场静默修改配置或 SQLite": + "Keep a stable provider ID, inspect session buckets, and prevent marketplace scripts from silently changing config or SQLite.", + "安全状态": "Safety status", + "稳定 provider": "Stable provider", + "索引会话": "Indexed sessions", + "接口类型": "Endpoint type", + "会话分桶:": "Session buckets: ", + "配置与会话分桶保持稳定。": "Configuration and session buckets are stable.", + "重新检查": "Check again", + "备份并修复": "Back up and repair", + "修复只能从原生管理器执行;脚本市场仅拥有只读检查权限。": + "Repairs can only run from the native manager; marketplace scripts have read-only inspection access.", "API Key 模式下扩展插件市场请求,尽量显示完整插件列表;官方/混合模式通常不需要。": "Expands plugin marketplace requests in API Key mode to show the full plugin list. Usually unnecessary in official/mixed mode.", "API Key 环境变量": "API Key environment variable", diff --git a/crates/codex-plus-core/Cargo.toml b/crates/codex-plus-core/Cargo.toml index 7ef996ce1..8fdcf6d50 100644 --- a/crates/codex-plus-core/Cargo.toml +++ b/crates/codex-plus-core/Cargo.toml @@ -23,6 +23,7 @@ tokio = { workspace = true, features = ["net"] } tokio-tungstenite.workspace = true toml.workspace = true toml_edit.workspace = true +url.workspace = true uuid.workspace = true zip.workspace = true diff --git a/crates/codex-plus-core/src/routes.rs b/crates/codex-plus-core/src/routes.rs index d2ab9a315..58fc2092f 100644 --- a/crates/codex-plus-core/src/routes.rs +++ b/crates/codex-plus-core/src/routes.rs @@ -94,6 +94,9 @@ pub trait BridgeRuntimeService: Send + Sync { #[async_trait] pub trait BridgeDataService: Send + Sync { + async fn provider_guard_status(&self) -> anyhow::Result { + anyhow::bail!("provider guard is not wired in this launcher") + } async fn delete(&self, session: SessionRef) -> anyhow::Result; async fn undo(&self, undo_token: String) -> anyhow::Result; async fn export_markdown(&self, session: SessionRef) -> anyhow::Result; @@ -164,6 +167,7 @@ pub async fn handle_bridge_request( "/devtools/open" => ctx.runtime.open_devtools().await, "/manager/open" => ctx.runtime.open_manager().await, "/backend/status" => ctx.runtime.backend_status().await, + "/provider-guard/status" => ctx.data.provider_guard_status().await, "/codex-model-catalog" | "/codex-config-model" => ctx.runtime.codex_model_catalog().await, "/diagnostics/log" => diagnostic_log_value(payload.clone()), "/ads" => ctx.runtime.ads().await, diff --git a/crates/codex-plus-core/src/script_market.rs b/crates/codex-plus-core/src/script_market.rs index 35f51db50..2ee1d5d66 100644 --- a/crates/codex-plus-core/src/script_market.rs +++ b/crates/codex-plus-core/src/script_market.rs @@ -1,11 +1,15 @@ use anyhow::Context; use serde::{Deserialize, Serialize}; use serde_json::Value; +use sha2::{Digest, Sha256}; +use url::Url; use crate::user_scripts::UserScriptManager; pub const DEFAULT_MARKET_INDEX_URL: &str = "https://raw.githubusercontent.com/BigPizzaV3/CodexPlusPlusScriptMarket/main/index.json"; +const MAX_MARKET_SCRIPT_BYTES: usize = 1024 * 1024; +const MAX_MARKET_INDEX_BYTES: usize = 2 * 1024 * 1024; #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ScriptMarketManifest { @@ -57,27 +61,48 @@ pub fn parse_market_manifest(raw: Value) -> anyhow::Result } pub async fn fetch_market_manifest(url: &str) -> anyhow::Result { - let raw = reqwest::get(url) + validate_download_url(url)?; + let response = reqwest::get(url) .await .with_context(|| format!("failed to request script market index {url}"))? .error_for_status() - .with_context(|| format!("script market index returned an error status {url}"))? - .json::() + .with_context(|| format!("script market index returned an error status {url}"))?; + validate_download_url(response.url().as_str())?; + if response.content_length().unwrap_or(0) > MAX_MARKET_INDEX_BYTES as u64 { + anyhow::bail!("script market index exceeds the 2 MiB safety limit"); + } + let content = response + .bytes() .await + .context("failed to read script market index")?; + if content.len() > MAX_MARKET_INDEX_BYTES { + anyhow::bail!("script market index exceeds the 2 MiB safety limit"); + } + let raw = serde_json::from_slice::(&content) .context("failed to decode script market index JSON")?; parse_market_manifest(raw) } pub async fn download_script(url: &str) -> anyhow::Result> { - Ok(reqwest::get(url) + validate_download_url(url)?; + let response = reqwest::get(url) .await .with_context(|| format!("failed to request script {url}"))? .error_for_status() - .with_context(|| format!("script download returned an error status {url}"))? + .with_context(|| format!("script download returned an error status {url}"))?; + validate_download_url(response.url().as_str())?; + if response.content_length().unwrap_or(0) > MAX_MARKET_SCRIPT_BYTES as u64 { + anyhow::bail!("script download exceeds the 1 MiB safety limit"); + } + let content = response .bytes() .await .context("failed to read script download body")? - .to_vec()) + .to_vec(); + if content.len() > MAX_MARKET_SCRIPT_BYTES { + anyhow::bail!("script download exceeds the 1 MiB safety limit"); + } + Ok(content) } pub fn install_market_script_content( @@ -85,6 +110,7 @@ pub fn install_market_script_content( script: &MarketScript, content: &[u8], ) -> anyhow::Result<()> { + verify_script_checksum(script, content)?; let path = manager.user_script_path_for_market_id(&script.id); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).with_context(|| { @@ -100,6 +126,27 @@ pub fn install_market_script_content( Ok(()) } +fn validate_download_url(raw: &str) -> anyhow::Result<()> { + let url = Url::parse(raw).with_context(|| format!("invalid script market URL {raw:?}"))?; + let is_loopback = matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "::1")); + if url.scheme() != "https" && !(url.scheme() == "http" && is_loopback) { + anyhow::bail!("script market downloads require HTTPS or a loopback development URL"); + } + Ok(()) +} + +fn verify_script_checksum(script: &MarketScript, content: &[u8]) -> anyhow::Result<()> { + let expected = script.sha256.trim().to_ascii_lowercase(); + if expected.len() != 64 || !expected.bytes().all(|byte| byte.is_ascii_hexdigit()) { + anyhow::bail!("market script {} is missing a valid SHA-256 checksum", script.id); + } + let actual = format!("{:x}", Sha256::digest(content)); + if actual != expected { + anyhow::bail!("market script {} failed SHA-256 verification", script.id); + } + Ok(()) +} + pub async fn install_market_script( manager: &UserScriptManager, script: &MarketScript, diff --git a/crates/codex-plus-core/tests/bridge_routes.rs b/crates/codex-plus-core/tests/bridge_routes.rs index 619002ce3..40425325f 100644 --- a/crates/codex-plus-core/tests/bridge_routes.rs +++ b/crates/codex-plus-core/tests/bridge_routes.rs @@ -32,6 +32,7 @@ async fn bridge_routes_cover_all_current_paths() { ("/devtools/open", json!({})), ("/manager/open", json!({})), ("/backend/status", json!({})), + ("/provider-guard/status", json!({})), ("/codex-model-catalog", json!({})), ("/codex-config-model", json!({})), ("/ads", json!({})), @@ -308,6 +309,19 @@ async fn unknown_bridge_path_preserves_empty_session_id_shape() { ); } +#[tokio::test] +async fn provider_guard_repair_is_not_exposed_to_injected_user_scripts() { + let result = handle_bridge_request( + test_context(), + "/provider-guard/repair", + json!({"confirmed": true}), + ) + .await; + + assert_eq!(result["status"], "failed"); + assert_eq!(result["message"], "Unknown bridge path"); +} + #[tokio::test] async fn settings_routes_use_settings_service() { let ctx = test_context(); @@ -647,7 +661,7 @@ async fn user_script_manager_deletes_market_script_metadata_and_rejects_builtin_ tags: Vec::new(), homepage: "https://example.com/demo".to_string(), script_url: "https://example.com/demo.js".to_string(), - sha256: String::new(), + sha256: "5cc41099e023f38d76f44f1a0a4cfa1c11b931b59b25cc423e092117a27a132c".to_string(), }; codex_plus_core::script_market::install_market_script_content( @@ -895,7 +909,7 @@ fn install_market_script_writes_file_and_records_metadata() { tags: Vec::new(), homepage: "https://example.com/demo".to_string(), script_url: "https://example.com/demo.js".to_string(), - sha256: String::new(), + sha256: "5cc41099e023f38d76f44f1a0a4cfa1c11b931b59b25cc423e092117a27a132c".to_string(), }; codex_plus_core::script_market::install_market_script_content( @@ -914,7 +928,7 @@ fn install_market_script_writes_file_and_records_metadata() { } #[test] -fn install_market_script_ignores_checksum_mismatch_and_replaces_existing_file() { +fn install_market_script_rejects_checksum_mismatch_and_preserves_existing_file() { let temp = tempfile::tempdir().unwrap(); let user_dir = temp.path().join("user"); std::fs::create_dir_all(&user_dir).unwrap(); @@ -933,15 +947,18 @@ fn install_market_script_ignores_checksum_mismatch_and_replaces_existing_file() tags: Vec::new(), homepage: String::new(), script_url: "https://example.com/demo.js".to_string(), - sha256: "0000".to_string(), + sha256: "0000000000000000000000000000000000000000000000000000000000000000".to_string(), }; - codex_plus_core::script_market::install_market_script_content(&manager, &script, b"new") - .unwrap(); + let error = + codex_plus_core::script_market::install_market_script_content(&manager, &script, b"new") + .unwrap_err() + .to_string(); + assert!(error.contains("SHA-256")); assert_eq!( std::fs::read_to_string(user_dir.join("market-demo.js")).unwrap(), - "new" + "old" ); } diff --git a/crates/codex-plus-data/Cargo.toml b/crates/codex-plus-data/Cargo.toml index c0abc389b..8e5d96d19 100644 --- a/crates/codex-plus-data/Cargo.toml +++ b/crates/codex-plus-data/Cargo.toml @@ -15,6 +15,9 @@ serde.workspace = true serde_json = { workspace = true, features = ["preserve_order"] } sha2.workspace = true thiserror.workspace = true +toml.workspace = true +toml_edit.workspace = true +url.workspace = true uuid.workspace = true [dev-dependencies] diff --git a/crates/codex-plus-data/src/lib.rs b/crates/codex-plus-data/src/lib.rs index 9f712768d..916404c0f 100644 --- a/crates/codex-plus-data/src/lib.rs +++ b/crates/codex-plus-data/src/lib.rs @@ -1,10 +1,15 @@ pub mod backup; pub mod markdown; +pub mod provider_guard; pub mod provider_sync; pub mod storage; pub use backup::BackupStore; pub use markdown::{MarkdownExportService, export_markdown_from_paths}; +pub use provider_guard::{ + ProviderBucket, ProviderEndpoint, ProviderGuardFinding, ProviderGuardRepairResult, + ProviderGuardStatus, inspect_provider_guard, repair_provider_guard, +}; pub use provider_sync::{ ProviderSyncResult, ProviderSyncStatus, ProviderSyncTargetList, ProviderSyncTargetOption, ProviderSyncTargetSource, SessionIndexCleanupApplyError, SessionIndexCleanupCandidate, diff --git a/crates/codex-plus-data/src/provider_guard.rs b/crates/codex-plus-data/src/provider_guard.rs new file mode 100644 index 000000000..8a1435a23 --- /dev/null +++ b/crates/codex-plus-data/src/provider_guard.rs @@ -0,0 +1,461 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::Context; +use rusqlite::{Connection, OpenFlags}; +use serde::{Deserialize, Serialize}; +use toml_edit::{DocumentMut, value}; +use url::Url; + +use crate::{ProviderSyncStatus, run_provider_sync_with_target}; + +pub const STABLE_PROVIDER_ID: &str = "custom"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderBucket { + pub provider: String, + pub threads: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderEndpoint { + pub kind: String, + pub loopback: bool, + pub port: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderGuardFinding { + pub code: String, + pub severity: String, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderGuardStatus { + pub level: String, + pub stable_provider: String, + pub current_provider: String, + pub stable_provider_configured: bool, + pub total_threads: usize, + pub databases_scanned: usize, + pub provider_buckets: Vec, + pub endpoint: ProviderEndpoint, + pub findings: Vec, + pub can_repair: bool, + pub repair_requires_native_confirmation: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderGuardRepairResult { + pub outcome: String, + pub message: String, + pub backup_dir: Option, + pub sync_backup_dir: Option, + pub changed_session_files: usize, + pub sqlite_rows_updated: usize, + pub guard: ProviderGuardStatus, +} + +pub fn inspect_provider_guard(codex_home: Option<&Path>) -> anyhow::Result { + let home = codex_home + .map(Path::to_path_buf) + .unwrap_or_else(codex_plus_core::codex_home::default_codex_home_dir); + let config_path = home.join("config.toml"); + let config_text = fs::read_to_string(&config_path).context("failed to read Codex config")?; + let config = config_text + .parse::() + .context("failed to parse Codex config")?; + + let current_provider = config + .get("model_provider") + .and_then(toml::Value::as_str) + .map(str::trim) + .filter(|provider| !provider.is_empty()) + .unwrap_or("openai") + .to_string(); + let stable_provider_configured = provider_config(&config, STABLE_PROVIDER_ID).is_some(); + let endpoint = endpoint_for_provider(&config, ¤t_provider); + + let mut bucket_counts = BTreeMap::::new(); + let mut databases_scanned = 0; + let mut database_failures = 0; + for db_path in codex_plus_core::codex_sqlite::codex_session_db_paths_from_home(&home) { + match read_provider_buckets(&db_path) { + Ok(Some(buckets)) => { + databases_scanned += 1; + for (provider, count) in buckets { + *bucket_counts.entry(provider).or_default() += count; + } + } + Ok(None) => {} + Err(_) => database_failures += 1, + } + } + let provider_buckets = bucket_counts + .iter() + .map(|(provider, threads)| ProviderBucket { + provider: provider.clone(), + threads: *threads, + }) + .collect::>(); + let total_threads = provider_buckets.iter().map(|bucket| bucket.threads).sum(); + + let mut findings = Vec::new(); + if stable_provider_configured && current_provider != STABLE_PROVIDER_ID { + findings.push(finding( + "unstable_current_provider", + "critical", + format!( + "Current model_provider is {current_provider:?}; stable session visibility requires {STABLE_PROVIDER_ID:?}." + ), + )); + } + if !stable_provider_configured { + findings.push(finding( + "stable_provider_missing", + "warning", + "The custom provider configuration is missing. Provider Guard will remain read-only and automatic repair is disabled.", + )); + } + let foreign_threads = provider_buckets + .iter() + .filter(|bucket| bucket.provider != STABLE_PROVIDER_ID) + .map(|bucket| bucket.threads) + .sum::(); + if foreign_threads > 0 { + findings.push(finding( + "provider_buckets_diverged", + "warning", + format!( + "{foreign_threads} thread index row(s) are stored outside the stable {STABLE_PROVIDER_ID:?} bucket." + ), + )); + } + if total_threads == 0 { + findings.push(finding( + "no_threads_detected", + "warning", + "No indexed threads were detected. Verify the active CODEX_HOME before repairing.", + )); + } + if database_failures > 0 { + findings.push(finding( + "database_read_failed", + "warning", + format!("{database_failures} session database(s) could not be inspected read-only."), + )); + } + if endpoint.port == Some(6269) { + findings.push(finding( + "known_cockpit_port", + "warning", + "The active provider uses local port 6269, which is commonly owned by Cockpit Tools on this machine.", + )); + } + + let level = if findings.iter().any(|item| item.severity == "critical") { + "critical" + } else if findings.iter().any(|item| item.severity == "warning") { + "warning" + } else { + "ok" + }; + Ok(ProviderGuardStatus { + level: level.to_string(), + stable_provider: STABLE_PROVIDER_ID.to_string(), + current_provider, + stable_provider_configured, + total_threads, + databases_scanned, + provider_buckets, + endpoint, + findings, + can_repair: stable_provider_configured, + repair_requires_native_confirmation: true, + }) +} + +pub fn repair_provider_guard(codex_home: Option<&Path>) -> anyhow::Result { + let home = codex_home + .map(Path::to_path_buf) + .unwrap_or_else(codex_plus_core::codex_home::default_codex_home_dir); + let _lock = GuardLock::acquire(&home.join("tmp/provider-guard.lock"))?; + let before = inspect_provider_guard(Some(&home))?; + if !before.stable_provider_configured { + anyhow::bail!("refusing repair because [model_providers.custom] is not configured"); + } + + let config_path = home.join("config.toml"); + let original_config = fs::read(&config_path) + .context("failed to read Codex config")?; + let backup_dir = create_guard_backup(&home, &original_config)?; + let next_config = set_root_provider(&original_config, STABLE_PROVIDER_ID)?; + if fs::read(&config_path).context("failed to re-check Codex config before repair")? + != original_config + { + anyhow::bail!("Codex config changed during repair preparation; no changes were applied"); + } + if next_config != original_config { + codex_plus_core::settings::atomic_write(&config_path, &next_config) + .context("failed to write stable model_provider")?; + } + + let sync = run_provider_sync_with_target(Some(&home), Some(STABLE_PROVIDER_ID)); + if sync.status != ProviderSyncStatus::Synced { + let current_config = fs::read(&config_path).unwrap_or_default(); + if current_config == next_config { + codex_plus_core::settings::atomic_write(&config_path, &original_config) + .context("provider repair failed and the original config could not be restored")?; + anyhow::bail!("provider repair was rolled back: {}", sync.message); + } + anyhow::bail!( + "provider repair failed and config changed externally; the safety backup was retained: {}", + sync.message + ); + } + + let guard = inspect_provider_guard(Some(&home))?; + Ok(ProviderGuardRepairResult { + outcome: "repaired".to_string(), + message: "Provider guard repair completed with backups.".to_string(), + backup_dir: Some(backup_dir), + sync_backup_dir: sync.backup_dir, + changed_session_files: sync.changed_session_files, + sqlite_rows_updated: sync.sqlite_rows_updated, + guard, + }) +} + +fn provider_config<'a>(config: &'a toml::Value, provider: &str) -> Option<&'a toml::Value> { + config + .get("model_providers") + .and_then(toml::Value::as_table) + .and_then(|providers| providers.get(provider)) +} + +fn endpoint_for_provider(config: &toml::Value, provider: &str) -> ProviderEndpoint { + let base_url = provider_config(config, provider) + .and_then(|provider| provider.get("base_url")) + .and_then(toml::Value::as_str) + .unwrap_or_default(); + let Ok(url) = Url::parse(base_url) else { + return ProviderEndpoint { + kind: "unknown".to_string(), + loopback: false, + port: None, + }; + }; + let loopback = matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "::1")); + let port = url.port_or_known_default(); + let kind = match (loopback, port) { + (true, Some(8317)) => "cpa", + (true, Some(6269)) => "cockpit", + (true, _) => "loopback", + (false, _) => "remote", + }; + ProviderEndpoint { + kind: kind.to_string(), + loopback, + port, + } +} + +fn read_provider_buckets(path: &Path) -> anyhow::Result>> { + if !path.is_file() { + return Ok(None); + } + let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?; + let has_provider_column = connection + .query_row( + "SELECT 1 FROM pragma_table_info('threads') WHERE name = 'model_provider' LIMIT 1", + [], + |_| Ok(()), + ) + .is_ok(); + if !has_provider_column { + return Ok(None); + } + let mut statement = connection.prepare( + "SELECT COALESCE(NULLIF(model_provider, ''), ''), COUNT(*) FROM threads GROUP BY COALESCE(NULLIF(model_provider, ''), '')", + )?; + let rows = statement + .query_map([], |row| { + let count = row.get::<_, i64>(1)?.max(0) as usize; + Ok((row.get::<_, String>(0)?, count)) + })? + .collect::, _>>()?; + Ok(Some(rows)) +} + +fn create_guard_backup(home: &Path, config: &[u8]) -> anyhow::Result { + let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ"); + let backup_dir = home + .join("backups_state") + .join("provider-guard") + .join(format!("{timestamp}-{}", uuid::Uuid::new_v4().simple())); + fs::create_dir_all(&backup_dir).context("failed to create provider guard backup directory")?; + codex_plus_core::settings::atomic_write(&backup_dir.join("config.toml"), config) + .context("failed to back up Codex config")?; + Ok(backup_dir) +} + +fn set_root_provider(config: &[u8], provider: &str) -> anyhow::Result> { + let text = std::str::from_utf8(config).context("Codex config is not valid UTF-8")?; + let mut document = text + .parse::() + .context("failed to parse Codex config for repair")?; + document["model_provider"] = value(provider); + Ok(document.to_string().into_bytes()) +} + +fn finding(code: &str, severity: &str, message: impl Into) -> ProviderGuardFinding { + ProviderGuardFinding { + code: code.to_string(), + severity: severity.to_string(), + message: message.into(), + } +} + +struct GuardLock { + path: PathBuf, +} + +impl GuardLock { + fn acquire(path: &Path) -> anyhow::Result { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).context("failed to create provider guard lock parent")?; + } + fs::create_dir(path).context("another Provider Guard repair is already running")?; + Ok(Self { + path: path.to_path_buf(), + }) + } +} + +impl Drop for GuardLock { + fn drop(&mut self) { + let _ = fs::remove_dir(&self.path); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_threads_db(path: &Path, providers: &[(&str, usize)]) { + let connection = Connection::open(path).unwrap(); + connection + .execute( + "CREATE TABLE threads (id TEXT PRIMARY KEY, model_provider TEXT)", + [], + ) + .unwrap(); + let mut id = 0; + for (provider, count) in providers { + for _ in 0..*count { + id += 1; + connection + .execute( + "INSERT INTO threads (id, model_provider) VALUES (?1, ?2)", + (format!("thread-{id}"), *provider), + ) + .unwrap(); + } + } + } + + #[test] + fn status_detects_provider_drift_without_exposing_secrets() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path(); + fs::write( + home.join("config.toml"), + r#"model_provider = "apex" + +[model_providers.apex] +base_url = "https://user:password@example.test/v1?api_key=secret" + +[model_providers.custom] +base_url = "http://127.0.0.1:8317/v1" +api_key = "top-secret" +"#, + ) + .unwrap(); + write_threads_db(&home.join("state_5.sqlite"), &[("custom", 3), ("apex", 2)]); + + let status = inspect_provider_guard(Some(home)).unwrap(); + let serialized = serde_json::to_string(&status).unwrap(); + + assert_eq!(status.level, "critical"); + assert_eq!(status.current_provider, "apex"); + assert_eq!(status.total_threads, 5); + assert!(status.can_repair); + assert!(status.findings.iter().any(|item| item.code == "unstable_current_provider")); + assert!(!serialized.contains("top-secret")); + assert!(!serialized.contains("password")); + assert!(!serialized.contains("example.test")); + } + + #[test] + fn repair_refuses_when_custom_provider_is_missing() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path(); + fs::write( + home.join("config.toml"), + "model_provider = \"apex\"\n[model_providers.apex]\nbase_url = \"https://example.test/v1\"\n", + ) + .unwrap(); + write_threads_db(&home.join("state_5.sqlite"), &[("apex", 1)]); + + let error = repair_provider_guard(Some(home)).unwrap_err().to_string(); + + assert!(error.contains("model_providers.custom")); + assert!(!home.join("backups_state/provider-guard").exists()); + } + + #[test] + fn repair_backs_up_config_and_normalizes_provider_buckets() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path(); + fs::create_dir_all(home.join("sessions")).unwrap(); + fs::write( + home.join("config.toml"), + r#"model_provider = "apex" +[model_providers.apex] +base_url = "https://example.test/v1" +[model_providers.custom] +base_url = "http://127.0.0.1:8317/v1" +"#, + ) + .unwrap(); + fs::write( + home.join("sessions/rollout-test.jsonl"), + r#"{"type":"session_meta","payload":{"id":"thread-1","model_provider":"apex","cwd":"C:/workspace"}} +{"type":"event_msg","payload":{"type":"user_message","message":"hello"}} +"#, + ) + .unwrap(); + write_threads_db(&home.join("state_5.sqlite"), &[("apex", 1)]); + + let result = repair_provider_guard(Some(home)).unwrap(); + + assert_eq!(result.outcome, "repaired"); + assert_eq!(result.guard.current_provider, STABLE_PROVIDER_ID); + assert_eq!(result.guard.provider_buckets[0].provider, STABLE_PROVIDER_ID); + let backup = result.backup_dir.unwrap().join("config.toml"); + assert!(backup.is_file()); + assert!(fs::read_to_string(backup).unwrap().contains("model_provider = \"apex\"")); + assert!( + fs::read_to_string(home.join("config.toml")) + .unwrap() + .contains("model_provider = \"custom\"") + ); + } +} From d5df068181df9f0796dd0548e0ba78ad8a76e0b2 Mon Sep 17 00:00:00 2001 From: k9ight000 <121161284+k9ight000@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:47:40 +0800 Subject: [PATCH 2/2] fix: preserve providers and use provider model catalog --- .../src-tauri/src/commands.rs | 25 +--- assets/inject/renderer-inject.js | 32 +---- crates/codex-plus-core/src/ccs_import.rs | 79 +++++++++++- crates/codex-plus-core/src/model_catalog.rs | 122 +++++++++++++----- crates/codex-plus-core/tests/model_catalog.rs | 47 ++++--- 5 files changed, 209 insertions(+), 96 deletions(-) diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index b90b1cc77..b3099af2f 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -561,29 +561,8 @@ pub fn import_ccs_providers() -> CommandResult { let store = SettingsStore::default(); let mut settings = store.load().unwrap_or_default(); - let mut existing_keys: Vec = settings - .relay_profiles - .iter() - .map(codex_plus_core::ccs_import::imported_provider_identity) - .collect(); - let mut existing_ids: Vec = settings - .relay_profiles - .iter() - .map(|profile| profile.id.clone()) - .collect(); - let mut imported = 0usize; - - for provider in providers { - let key = codex_plus_core::ccs_import::provider_identity_from_ccs(&provider); - if existing_keys.iter().any(|existing| existing == &key) { - continue; - } - let profile = codex_plus_core::ccs_import::relay_profile_from_ccs(&provider, &existing_ids); - existing_ids.push(profile.id.clone()); - existing_keys.push(key); - settings.relay_profiles.push(profile); - imported += 1; - } + let imported = + codex_plus_core::ccs_import::append_new_relay_profiles_from_ccs(&mut settings, &providers); if imported == 0 { return settings_payload("没有新的 cc-switch 供应商配置需要导入。", "设置读取失败"); diff --git a/assets/inject/renderer-inject.js b/assets/inject/renderer-inject.js index 186bd9a75..c84c080a0 100644 --- a/assets/inject/renderer-inject.js +++ b/assets/inject/renderer-inject.js @@ -4737,11 +4737,7 @@ } function codexPlusModelNames() { - return uniqueValues([ - codexModelCatalog.default_model, - codexModelCatalog.model, - ...(Array.isArray(codexModelCatalog.models) ? codexModelCatalog.models : []), - ]); + return uniqueValues(Array.isArray(codexModelCatalog.models) ? codexModelCatalog.models : []); } async function loadCodexModelCatalog(force = false) { @@ -4750,27 +4746,7 @@ codexModelCatalogPromise = postJson("/codex-model-catalog", {}) .then(async (result) => { codexModelCatalog = result && typeof result === "object" ? result : { status: "failed", model: "", default_model: "", model_provider: "", provider_name: "", models: [], sources: [], responses_api: { status: "unknown", message: "" } }; - if ((!codexModelCatalog.models || codexModelCatalog.models.length === 0) && codexModelCatalog.status === "not_configured") { - try { - const settingsPromise = postJson("/settings/get", {}); - const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("fallback timeout")), 3000)); - const settingsResp = await Promise.race([settingsPromise, timeoutPromise]); - if (settingsResp && settingsResp.relayProfiles && Array.isArray(settingsResp.relayProfiles)) { - const activeId = settingsResp.activeRelayId || ""; - const profile = settingsResp.relayProfiles.find(p => p.id === activeId) || settingsResp.relayProfiles[0]; - if (profile && profile.modelList) { - const extraModels = profile.modelList.split(/[\r\n,]+/).map(s => s.trim()).filter(Boolean); - if (extraModels.length > 0) { - codexModelCatalog.models = extraModels; - codexModelCatalog.default_model = codexModelCatalog.default_model || extraModels[0]; - sendCodexPlusDiagnostic("model_catalog_fallback_applied", { count: extraModels.length }); - } - } - } - } catch (fallbackError) { - sendCodexPlusDiagnostic("model_catalog_fallback_error", { error: String(fallbackError?.message || fallbackError) }); - } - } + codexModelCatalogLoadedAt = Date.now(); renderCodexPlusMenu(); scheduleCodexModelWhitelistRefresh(); @@ -4798,9 +4774,9 @@ slug: modelName, name: modelName, displayName: modelName, - description: codexModelCatalog.provider_name || codexModelCatalog.model_provider || "Custom model", + description: codexModelCatalog.provider_name || codexModelCatalog.model_provider || "Provider model", hidden: false, - isDefault: (codexModelCatalog.default_model || codexModelCatalog.model) === modelName, + isDefault: codexModelCatalog.default_model === modelName, defaultReasoningEffort: "medium", supportedReasoningEfforts: modelReasoningEfforts(), }; diff --git a/crates/codex-plus-core/src/ccs_import.rs b/crates/codex-plus-core/src/ccs_import.rs index 0770e1f88..ce75b34be 100644 --- a/crates/codex-plus-core/src/ccs_import.rs +++ b/crates/codex-plus-core/src/ccs_import.rs @@ -4,7 +4,7 @@ use anyhow::Context; use rusqlite::Connection; use serde_json::Value; -use crate::settings::{RelayMode, RelayProfile, RelayProtocol}; +use crate::settings::{BackendSettings, RelayMode, RelayProfile, RelayProtocol}; #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] @@ -71,6 +71,36 @@ pub fn provider_identity_from_ccs(provider: &CcsProviderImport) -> String { ccs_import_key(&provider.name, &provider.base_url) } +pub fn append_new_relay_profiles_from_ccs( + settings: &mut BackendSettings, + providers: &[CcsProviderImport], +) -> usize { + let mut existing_keys = settings + .relay_profiles + .iter() + .map(imported_provider_identity) + .collect::>(); + let mut existing_ids = settings + .relay_profiles + .iter() + .map(|profile| profile.id.clone()) + .collect::>(); + let mut imported = 0usize; + + for provider in providers { + let key = provider_identity_from_ccs(provider); + if !existing_keys.insert(key) { + continue; + } + let profile = relay_profile_from_ccs(provider, &existing_ids); + existing_ids.push(profile.id.clone()); + settings.relay_profiles.push(profile); + imported += 1; + } + + imported +} + pub fn relay_profile_from_ccs( provider: &CcsProviderImport, existing_ids: &[String], @@ -393,6 +423,53 @@ mod tests { assert_eq!(providers[0].protocol, RelayProtocol::ChatCompletions); } + #[test] + fn append_ccs_imports_preserves_existing_profiles_and_adds_only_new_ones() { + let existing = RelayProfile { + id: "existing".to_string(), + name: "Existing(ccswitch)".to_string(), + base_url: "https://existing.example/v1".to_string(), + upstream_base_url: "https://existing.example/v1".to_string(), + api_key: "existing-key".to_string(), + config_contents: "existing config".to_string(), + model: "existing-model".to_string(), + model_list: "existing-model".to_string(), + ..RelayProfile::default() + }; + let mut settings = BackendSettings { + relay_profiles: vec![existing.clone()], + active_relay_id: existing.id.clone(), + ..BackendSettings::default() + }; + let providers = vec![ + CcsProviderImport { + source_id: "existing-source".to_string(), + name: "Existing".to_string(), + base_url: existing.base_url.clone(), + api_key: "replacement-key".to_string(), + protocol: RelayProtocol::Responses, + config_contents: "replacement config".to_string(), + auth_contents: "replacement auth".to_string(), + }, + CcsProviderImport { + source_id: "new-source".to_string(), + name: "New".to_string(), + base_url: "https://new.example/v1".to_string(), + api_key: "new-key".to_string(), + protocol: RelayProtocol::Responses, + config_contents: "new config".to_string(), + auth_contents: "new auth".to_string(), + }, + ]; + + let imported = append_new_relay_profiles_from_ccs(&mut settings, &providers); + + assert_eq!(imported, 1); + assert_eq!(settings.relay_profiles[0], existing); + assert_eq!(settings.relay_profiles.len(), 2); + assert_eq!(settings.relay_profiles[1].name, "New(ccswitch)"); + } + #[test] fn imports_toml_config_provider() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/codex-plus-core/src/model_catalog.rs b/crates/codex-plus-core/src/model_catalog.rs index 9c45bf99d..67399ea7f 100644 --- a/crates/codex-plus-core/src/model_catalog.rs +++ b/crates/codex-plus-core/src/model_catalog.rs @@ -37,19 +37,14 @@ struct CodexConfig { pub async fn read_codex_model_catalog() -> Value { let home = codex_home_dir(); let settings_path = crate::paths::default_settings_path(); - if settings_path.exists() { - if let Ok(settings) = SettingsStore::new(settings_path).load() { - let profile = settings.active_relay_profile(); - let catalog = relay_profile_model_catalog_value(&home, &profile); - if catalog - .get("models") - .and_then(Value::as_array) - .map_or(false, |m| !m.is_empty()) - { - return catalog; - } - } - } + let active_profile = if settings_path.exists() { + SettingsStore::new(settings_path) + .load() + .ok() + .map(|settings| settings.active_relay_profile()) + } else { + None + }; let env = std::env::vars().collect::>(); let client = match crate::http_client::proxied_client("CodexPlusPlus/1.0") { Ok(client) => client, @@ -68,6 +63,19 @@ pub async fn read_codex_model_catalog() -> Value { }); } }; + if let Some(profile) = active_profile.as_ref() { + if relay_profile_model_source(profile).is_some() { + return relay_profile_model_catalog(&home, profile, &client).await; + } + let catalog = relay_profile_model_catalog_value(&home, profile); + if catalog + .get("models") + .and_then(Value::as_array) + .map_or(false, |models| !models.is_empty()) + { + return catalog; + } + } read_codex_model_catalog_from_home(&home, &env, client).await } @@ -109,7 +117,6 @@ fn relay_profile_model_ids(profile: &RelayProfile) -> Vec { profile .model_list .split(['\r', '\n', ',']) - .chain(std::iter::once(profile.model.as_str())) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToString::to_string) @@ -117,6 +124,63 @@ fn relay_profile_model_ids(profile: &RelayProfile) -> Vec { ) } +fn relay_profile_model_source(profile: &RelayProfile) -> Option { + let base_url = if profile.upstream_base_url.trim().is_empty() { + profile.base_url.trim() + } else { + profile.upstream_base_url.trim() + }; + if base_url.is_empty() { + return None; + } + Some(ModelSource { + source_id: format!("relay-profile:{}", profile.id), + source_type: "relay_profile".to_string(), + name: if profile.name.trim().is_empty() { + profile.id.clone() + } else { + profile.name.trim().to_string() + }, + base_url: base_url.to_string(), + api_key: profile.api_key.trim().to_string(), + }) +} + +async fn relay_profile_model_catalog( + home: &Path, + profile: &RelayProfile, + client: &reqwest::Client, +) -> Value { + let Some(source) = relay_profile_model_source(profile) else { + return relay_profile_model_catalog_value(home, profile); + }; + let (models, mut source_status) = fetch_models_from_source(client, &source).await; + source_status["responses_api"] = responses_api_status("unknown", "", ""); + let selected_model = profile.model.trim(); + let default_model = models + .iter() + .find(|model| model.as_str() == selected_model) + .cloned() + .or_else(|| models.first().cloned()) + .unwrap_or_default(); + let model = if selected_model == default_model.as_str() { + selected_model.to_string() + } else { + String::new() + }; + json!({ + "status": if models.is_empty() { "failed" } else { "ok" }, + "path": home.join("config.toml").to_string_lossy(), + "model": model, + "model_provider": profile.id.trim(), + "provider_name": source.name, + "default_model": default_model, + "models": models, + "sources": [source_status], + "responses_api": responses_api_status("unknown", "", "") + }) +} + pub async fn read_codex_model_catalog_from_home( home: &Path, env: &HashMap, @@ -154,17 +218,18 @@ pub async fn read_codex_model_catalog_from_home( }); } - let mut sources = model_sources_from_environment(env, &auth_api_key); - if error.is_none() { - if let Some(source) = model_source_from_config(&config, &effective, env, &auth_api_key) { - if sources - .iter() - .all(|existing| trim_url(&existing.base_url) != trim_url(&source.base_url)) - { - sources.push(source); - } - } - } + let sources = if error.is_none() { + model_source_from_config(&config, &effective, env, &auth_api_key) + .into_iter() + .collect::>() + } else { + Vec::new() + }; + let sources = if sources.is_empty() { + model_sources_from_environment(env, &auth_api_key) + } else { + sources + }; let mut source_statuses = Vec::new(); let mut models = Vec::new(); @@ -175,7 +240,9 @@ pub async fn read_codex_model_catalog_from_home( source_statuses.push(source_status); } let (catalog_models, catalog_status) = models_from_config_model_catalog_json(home, &effective); - models.extend(catalog_models); + if sources.is_empty() { + models.extend(catalog_models); + } if let Some(status) = catalog_status { source_statuses.push(status); } @@ -761,9 +828,6 @@ fn safe_url_for_status(url: &str) -> String { cleaned } -fn trim_url(url: &str) -> String { - url.trim_end_matches('/').to_string() -} fn string_value(value: Option<&String>) -> String { value diff --git a/crates/codex-plus-core/tests/model_catalog.rs b/crates/codex-plus-core/tests/model_catalog.rs index a2f3a0d83..05703eba5 100644 --- a/crates/codex-plus-core/tests/model_catalog.rs +++ b/crates/codex-plus-core/tests/model_catalog.rs @@ -113,16 +113,24 @@ experimental_bearer_token = "ark-key" } #[tokio::test] -async fn model_catalog_uses_active_relay_profile_model_list_for_display() { +async fn model_catalog_uses_active_relay_provider_models_only() { let temp = tempfile::tempdir().unwrap(); let codex_home = temp.path().join("codex-home"); std::fs::create_dir_all(&codex_home).unwrap(); let settings_path = temp.path().join("settings.json"); + let server = spawn_models_server(json!({ + "data": [ + {"id": "provider-model"}, + {"id": "qwen3-coder"} + ] + })); let previous_codex_home = std::env::var_os("CODEX_HOME"); + let previous_no_proxy = std::env::var_os("NO_PROXY"); let previous_settings_path = codex_plus_core::paths::set_settings_path_for_tests(Some(settings_path.clone())); unsafe { std::env::set_var("CODEX_HOME", &codex_home); + std::env::set_var("NO_PROXY", "127.0.0.1,localhost"); } let result = async { @@ -132,12 +140,14 @@ async fn model_catalog_uses_active_relay_profile_model_list_for_display() { relay_profiles: vec![RelayProfile { id: "relay-a".to_string(), name: "Relay A".to_string(), - model: "qwen3-coder".to_string(), - base_url: "https://example.test/v1".to_string(), + model: "configured-only-model".to_string(), + base_url: server.base_url.clone(), + upstream_base_url: server.base_url.clone(), + api_key: "relay-key".to_string(), protocol: RelayProtocol::Responses, relay_mode: RelayMode::MixedApi, - model_list: "deepseek-coder\nqwen3-coder\nclaude-compatible".to_string(), - config_contents: "model = \"qwen3-coder\"\n".to_string(), + model_list: "manual-only-model\nqwen3-coder".to_string(), + config_contents: "model = \"configured-only-model\"\n".to_string(), ..RelayProfile::default() }], ..BackendSettings::default() @@ -156,17 +166,24 @@ async fn model_catalog_uses_active_relay_profile_model_list_for_display() { std::env::remove_var("CODEX_HOME"); }, } + match previous_no_proxy { + Some(value) => unsafe { + std::env::set_var("NO_PROXY", value); + }, + None => unsafe { + std::env::remove_var("NO_PROXY"); + }, + } codex_plus_core::paths::set_settings_path_for_tests(previous_settings_path); assert_eq!(result["status"], "ok"); assert_eq!(result["model_provider"], "relay-a"); - assert_eq!(result["provider_name"], "Relay A"); - assert_eq!(result["default_model"], "qwen3-coder"); - assert_eq!( - result["models"], - json!(["qwen3-coder", "deepseek-coder", "claude-compatible"]) - ); - assert_eq!(result["sources"][0]["type"], "relay_profile_model_list"); + assert_eq!(result["default_model"], "provider-model"); + assert_eq!(result["models"], json!(["provider-model", "qwen3-coder"])); + assert_eq!(result["sources"][0]["type"], "relay_profile"); + let requests = server.finish(); + assert_eq!(requests[0].path, "/v1/models"); + assert_eq!(requests[0].authorization, "Bearer relay-key"); } #[tokio::test] @@ -203,7 +220,7 @@ base_url = "{}/v1" } #[tokio::test] -async fn model_catalog_merges_models_from_config_model_catalog_json() { +async fn model_catalog_does_not_merge_local_catalog_into_provider_models() { let temp = tempfile::tempdir().unwrap(); let server = spawn_models_server(json!({ "data": [ @@ -252,8 +269,8 @@ experimental_bearer_token = "relay-key" .await; assert_eq!(result["status"], "ok"); - assert_eq!(result["default_model"], "gpt-5.6"); - assert_eq!(result["models"], json!(["qwen3-coder", "gpt-5.6"])); + assert_eq!(result["default_model"], "qwen3-coder"); + assert_eq!(result["models"], json!(["qwen3-coder"])); server.finish(); }