diff --git a/Cargo.lock b/Cargo.lock index 2a744529b..63277b93c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -460,6 +460,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 d018be40f..5b1adbec5 100644 --- a/apps/codex-plus-launcher/src/main.rs +++ b/apps/codex-plus-launcher/src/main.rs @@ -621,6 +621,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 33e13265c..cca9b47b0 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -2947,6 +2947,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 245ad0650..edac62fc1 100644 --- a/apps/codex-plus-manager/src-tauri/src/lib.rs +++ b/apps/codex-plus-manager/src-tauri/src/lib.rs @@ -114,6 +114,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 c6ca522fe..2b89e5b0b 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -766,6 +766,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; @@ -1183,6 +1205,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); @@ -2151,6 +2174,7 @@ export function App() { await refreshSettings(true); await refreshLocalSessions(true); await refreshProviderSyncTargets(true); + await refreshProviderGuard(true); } if (next === "zedRemote") { await refreshSettings(true); @@ -2608,6 +2632,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({ @@ -3081,6 +3134,7 @@ export function App() { await refreshRelay(true); await refreshEnvConflicts(true); await refreshProviderSyncTargets(true); + await refreshProviderGuard(true); await refreshPendingProviderImport(true); await refreshPendingSessionShare(true); await refreshPendingDreamSkinCommunity(); @@ -3360,6 +3414,8 @@ export function App() { }, syncProvidersNow, refreshProviderSyncTargets, + refreshProviderGuard, + repairProviderGuard, setProviderSyncTarget: (provider: string) => { setSelectedProviderSyncTarget(provider); setSettingsForm((current) => ({ ...current, providerSyncLastSelectedProvider: provider })); @@ -3445,7 +3501,7 @@ export function App() { disableWatcher: () => watcherAction("disable_watcher"), toggleTheme: () => setTheme((current) => (current === "dark" ? "light" : "dark")), }), - [route, launchForm, settingsForm, settings, overview, removeOwnedData, update, updateInstallProgress.active, logs, diagnostics, theme, relayFiles, localSessions, sessionShareUrl, importSessionUrl, zedRemoteProjects, selectedProviderSyncTarget, envConflicts, relayEnvironment, ccsProviders, dreamSkinLibrary, dreamSkinMarket, dreamSkinCommunity, selectedDreamSkinTheme, savedDreamSkinThemeDraft, dreamSkinThemeDraft, dreamSkinDraftDirty, pendingDreamSkinRestart], + [route, launchForm, settingsForm, settings, overview, removeOwnedData, update, updateInstallProgress.active, logs, diagnostics, theme, relayFiles, localSessions, sessionShareUrl, importSessionUrl, zedRemoteProjects, selectedProviderSyncTarget, providerGuard, envConflicts, relayEnvironment, ccsProviders, dreamSkinLibrary, dreamSkinMarket, dreamSkinCommunity, selectedDreamSkinTheme, savedDreamSkinThemeDraft, dreamSkinThemeDraft, dreamSkinDraftDirty, pendingDreamSkinRestart], ); const hasUpdate = update?.updateAvailable === true; @@ -3568,6 +3624,7 @@ export function App() { sessions={localSessions} providerSyncProgress={providerSyncProgress} providerSyncTargets={providerSyncTargets} + providerGuard={providerGuard} selectedProviderSyncTarget={selectedProviderSyncTarget} onFormChange={setSettingsForm} actions={actions} @@ -3784,6 +3841,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; @@ -6838,6 +6897,7 @@ function SessionsScreen({ sessions, providerSyncProgress, providerSyncTargets, + providerGuard, selectedProviderSyncTarget, onFormChange, actions, @@ -6847,6 +6907,7 @@ function SessionsScreen({ sessions: LocalSessionsResult | null; providerSyncProgress: ProviderSyncProgress; providerSyncTargets: ProviderSyncTargetsResult | null; + providerGuard: ProviderGuardResult | null; selectedProviderSyncTarget: string; onFormChange: (value: BackendSettings) => void; actions: Actions; @@ -6909,6 +6970,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 f6273336f..677abc2e6 100644 --- a/apps/codex-plus-manager/src/i18n-en.ts +++ b/apps/codex-plus-manager/src/i18n-en.ts @@ -17,6 +17,23 @@ export const EN_PLAIN: Record = { "正在等待 Codex 重新启动…": "Waiting for Codex to restart...", "正在等待 Codex 启动结果…": "Waiting for the Codex startup result...", "运行中(增强等待中)": "Running (waiting for enhancements)", + "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/src/routes.rs b/crates/codex-plus-core/src/routes.rs index b9580114c..ba5ca589a 100644 --- a/crates/codex-plus-core/src/routes.rs +++ b/crates/codex-plus-core/src/routes.rs @@ -108,6 +108,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; @@ -188,6 +191,7 @@ pub async fn handle_bridge_request( ctx.runtime.backend_status().await, ctx.settings.get_settings().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()), "/llm-proxy" => llm_proxy_value(payload.clone()).await, diff --git a/crates/codex-plus-core/tests/bridge_routes.rs b/crates/codex-plus-core/tests/bridge_routes.rs index 4bc1976ce..384407750 100644 --- a/crates/codex-plus-core/tests/bridge_routes.rs +++ b/crates/codex-plus-core/tests/bridge_routes.rs @@ -33,6 +33,7 @@ async fn bridge_routes_cover_all_current_paths() { ("/manager/open", json!({})), ("/manager/open-transient", json!({})), ("/backend/status", json!({})), + ("/provider-guard/status", json!({})), ("/codex-model-catalog", json!({})), ("/codex-config-model", json!({})), ( @@ -412,6 +413,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(); 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 86b62e0a4..985e50df1 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::{ ProviderSyncAudit, ProviderSyncLockState, ProviderSyncResult, ProviderSyncStatus, ProviderSyncTargetList, ProviderSyncTargetOption, ProviderSyncTargetSource, 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..81984b9db --- /dev/null +++ b/crates/codex-plus-data/src/provider_guard.rs @@ -0,0 +1,540 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::Context; +use rusqlite::{Connection, OpenFlags, OptionalExtension}; +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 provider_guard_db_paths(&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", + [], + |row| row.get::<_, i64>(0), + ) + .optional()? + .is_some(); + 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 provider_guard_db_paths(home: &Path) -> Vec { + let mut paths = codex_plus_core::codex_sqlite::codex_session_db_paths_from_home(home); + let mut roots = vec![home.to_path_buf()]; + if let Some(sqlite_home) = std::env::var_os("CODEX_SQLITE_HOME") { + let path = PathBuf::from(sqlite_home); + if path.is_dir() { + roots.push(path); + } + } + for root in roots { + let sqlite_dir = root.join("sqlite"); + let Ok(entries) = fs::read_dir(sqlite_dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() + && matches!( + path.extension().and_then(|value| value.to_str()), + Some("db") | Some("sqlite") | Some("sqlite3") + ) + { + paths.push(path); + } + } + } + paths.sort(); + paths.dedup(); + paths +} + +fn create_guard_backup(home: &Path, config: &[u8]) -> anyhow::Result { + let canonical_home = fs::canonicalize(home).context("failed to resolve Codex home for backup")?; + let backup_root = canonical_home.join("backups_state").join("provider-guard"); + fs::create_dir_all(&backup_root).context("failed to create provider guard backup directory")?; + let canonical_root = fs::canonicalize(&backup_root) + .context("failed to resolve provider guard backup directory")?; + if !canonical_root.starts_with(&canonical_home) { + anyhow::bail!("provider guard backup directory must remain inside Codex home"); + } + let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ"); + let backup_dir = canonical_root.join(format!("{timestamp}-{}", uuid::Uuid::new_v4().simple())); + if !backup_dir.starts_with(&canonical_home) { + anyhow::bail!("provider guard backup path escaped Codex home"); + } + fs::create_dir(&backup_dir).context("failed to create provider guard backup directory")?; + let canonical_backup_dir = fs::canonicalize(&backup_dir) + .context("failed to resolve provider guard backup path")?; + if !canonical_backup_dir.starts_with(&canonical_home) { + anyhow::bail!("provider guard backup path escaped Codex home"); + } + 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")); + assert!(!serialized.contains("api_key")); + assert!(!serialized.contains("https://")); + } + + #[test] + fn read_only_inspection_preserves_readable_results_when_one_database_fails() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path(); + fs::create_dir_all(home.join("sqlite")).unwrap(); + fs::write( + home.join("config.toml"), + "model_provider = \"custom\"\n[model_providers.custom]\nbase_url = \"https://api.example.test/v1\"\n", + ) + .unwrap(); + write_threads_db(&home.join("sqlite/good.sqlite"), &[("custom", 2)]); + let corrupt_path = home.join("sqlite/corrupt.sqlite"); + fs::write(&corrupt_path, b"not sqlite").unwrap(); + let config_before = fs::read(home.join("config.toml")).unwrap(); + let corrupt_before = fs::read(&corrupt_path).unwrap(); + + let status = inspect_provider_guard(Some(home)).unwrap(); + + assert_eq!(status.total_threads, 2); + assert_eq!(status.provider_buckets, vec![ProviderBucket { + provider: STABLE_PROVIDER_ID.to_string(), + threads: 2, + }]); + assert!(status.findings.iter().any(|item| item.code == "database_read_failed")); + assert_eq!(fs::read(home.join("config.toml")).unwrap(), config_before); + assert_eq!(fs::read(corrupt_path).unwrap(), corrupt_before); + assert!(!home.join("backups_state").exists()); + assert!(!home.join("tmp/provider-guard.lock").exists()); + } + + #[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"); + let canonical_home = fs::canonicalize(home).unwrap(); + assert!(backup.starts_with(canonical_home)); + 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\"") + ); + } +}