diff --git a/docs/reviewed-core-updates.md b/docs/reviewed-core-updates.md new file mode 100644 index 000000000..8d3ecc565 --- /dev/null +++ b/docs/reviewed-core-updates.md @@ -0,0 +1,35 @@ +# Reviewed core updates + +A patched core can lose required model metadata or plugin behavior when the official updater replaces it. The optional reviewed channel points the existing core update button at a publisher's manifest and checks compatibility before accepting the new binary. Official releases remain the default. + +In Version management, enter the HTTPS manifest URL and select **Use reviewed channel**. Start the existing core, check for updates, then use the core update button. Downloads use only that manifest's source. Download failure never selects an official build automatically. The publisher is trusted to review the binary; the checksum proves it matches the manifest, not that its code is safe. + +A manifest has this format: + +```json +{ + "schema_version": 1, + "repository": "example/CLIProxyAPI", + "tag": "v7.3.9-review.abc1234", + "commit": "0123456789abcdef0123456789abcdef01234567", + "assets": [ + { + "os": "darwin", + "arch": "arm64", + "url": "https://github.com/example/CLIProxyAPI/releases/download/v7.3.9-review.abc1234/cli-proxy-api_darwin_arm64.tar.gz", + "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "size": 42000000 + } + ] +} +``` + +Replace the example commit, checksum and size with the actual values. Each platform occurs once. Supported OS values are `darwin`, `linux`, and `windows`; architectures are `arm64` and `amd64`. Windows uses ZIP; other platforms use tar.gz. Asset URLs must name the declared GitHub repository and exact release tag. SHA-256 and a positive size up to 512 MiB are required. The manifest rejects unknown fields. + +The app downloads, hashes and extracts the candidate while the existing core runs. It snapshots the visible rich model catalog and registered plugins, stops the core, saves a unique backup of the previous binary and installation metadata, and replaces only the executable. Configuration, OAuth files and plugin binaries remain in place. The new core starts using the existing configuration without applying GUI configuration defaults. A bounded health check verifies that existing model routes and their declared limits, input/output types and reasoning options remain, and that registered plugins keep their enabled state. These checks make no inference requests. A core that intentionally changes these capabilities needs a separate reviewed configuration migration. + +If replacement, startup or health validation fails, the updater stops the candidate, restores the previous binary and metadata, and restarts the previous core. If the candidate cannot be stopped or restoration fails, the error identifies the retained backup directory. Backups are retained after successful updates too. This is runtime failure recovery, not a journal for power loss or forced termination of the updater. The installer requires an already running core so it can capture a baseline without changing a stopped service. + +Installed provenance records the manifest URL, declared repository/tag/commit, archive and installed-binary hashes, timestamp and backup location. The UI shows this record only while its binary hash matches the deployed executable. + +This channel updates only the core. The Plugin updates panel in Version management lists installed plugins from their verified current registry. It pins the displayed version and source, leaves disabled plugins untouched, and checks runtime registration after installation. Refresh distinguishes a loaded version from a pending reload. Any requested restart waits for an explicit user action. Plugin installation shares the core operation lock so it cannot overlap core replacement or a restart. New plugin installation and source registration remain separate configuration tasks. Official app self-update and bundled-core replacement are blocked while the reviewed channel is selected because they may remove compatibility patches. macOS signature and Gatekeeper validation remain unchanged. Automatic reviewed app replacement still requires a trusted signed app distribution, or the patches to be included upstream. Selecting **Use official channel** explicitly removes this guard. diff --git a/src-tauri/src/app_update.rs b/src-tauri/src/app_update.rs index c5a6f281d..0b99b47d9 100644 --- a/src-tauri/src/app_update.rs +++ b/src-tauri/src/app_update.rs @@ -206,8 +206,12 @@ async fn resolve_app_update( let portable_support = target .map(|(_, arch)| validate_local_portable_app_manifest(arch)) .transpose()?; - let auto_update_supported = portable_support == Some(true) && asset.is_some(); - let unsupported_reason = if auto_update_supported { + let reviewed_block = reviewed_app_update_guard(&config).err(); + let auto_update_supported = + reviewed_block.is_none() && portable_support == Some(true) && asset.is_some(); + let unsupported_reason = if reviewed_block.is_some() { + reviewed_block + } else if auto_update_supported { None } else if portable_support != Some(true) { Some("This application is not a portable version that supports automatic upgrades. Manually download the first supported version".to_string()) @@ -671,6 +675,11 @@ pub(crate) async fn start_app_update( if portable_update_platform_key().is_none() { return Err("The current platform does not support in-app automatic upgrades".to_string()); } + // Serialize the channel guard with channel changes and core installation. + let core_state = app.state::(); + let _channel_guard = lock_core_operation(core_state.inner())?; + let config = app.state::().snapshot()?; + reviewed_app_update_guard(&config)?; let token = CancellationToken::new(); state.start(token.clone())?; let task = state.snapshot(); diff --git a/src-tauri/src/core_runtime.rs b/src-tauri/src/core_runtime.rs index 288dc33d1..a75129cd3 100644 --- a/src-tauri/src/core_runtime.rs +++ b/src-tauri/src/core_runtime.rs @@ -145,6 +145,9 @@ pub(crate) async fn check_latest_core( let _detection_guard = VERSION_SOURCE_DETECTION_LOCK.lock().await; let platform = current_core_platform()?; let config = gui_config_state.snapshot()?; + if !config.reviewed_core_manifest_url.is_empty() { + return check_reviewed_core(&config).await; + } let proxy_url = config.proxy_url.clone(); let client = http_client(&proxy_url, &config.custom_download_mirrors)?; let requested_source = config.selected_download_candidate(); @@ -164,6 +167,7 @@ pub(crate) async fn check_latest_core( let asset = select_release_asset(&release, &platform)?; Ok(CoreLatest { + reviewed: false, version: normalize_version(&release.tag_name), asset_name: asset.name.clone(), }) @@ -184,6 +188,7 @@ pub(crate) async fn install_bundled_core( let state = app.state::(); let process_state = app.state::(); let gui_config_state = app.state::(); + reviewed_official_core_guard(&gui_config_state.snapshot()?)?; let (info, archive_path) = bundled_core_archive()? .ok_or_else(|| "This distribution has no bundled kernel matching the system architecture".to_string())?; let token = CancellationToken::new(); @@ -309,12 +314,16 @@ pub(crate) async fn install_core_version( window: tauri::Window, version: Option, ) -> Result { + if !app.state::().snapshot()?.reviewed_core_manifest_url.is_empty() { + return install_reviewed_core(app, window, version).await; + } tauri::async_runtime::spawn_blocking(move || { let _guard = lock_core_operation(app.state::().inner())?; let state = app.state::(); let process_state = app.state::(); let gui_config_state = app.state::(); let config = gui_config_state.snapshot()?; + reviewed_official_core_guard(&config)?; let proxy_url = config.proxy_url.clone(); let token = CancellationToken::new(); state.start(token.clone(), version.clone())?; @@ -1210,6 +1219,7 @@ pub(crate) async fn download_asset_inner( ensure_not_cancelled(token, Some(archive_path))?; let chunk = chunk.map_err(|err| format!("Failed to read download data: {err}"))?; + validate_download_chunk_size(downloaded, chunk.len() as u64, expected_total)?; file.write_all(&chunk) .map_err(|err| format!("Failed to save download data: {err}"))?; hasher.update(&chunk); @@ -1502,6 +1512,21 @@ fn start_core_process_once( pub(crate) fn start_core_process_inner( process_state: &CoreProcessState, gui_config: &GuiConfigFile, +) -> Result<(), String> { + start_core_process_with_config_policy(process_state, gui_config, false) +} + +pub(crate) fn start_core_process_preserving_config( + process_state: &CoreProcessState, + gui_config: &GuiConfigFile, +) -> Result<(), String> { + start_core_process_with_config_policy(process_state, gui_config, true) +} + +fn start_core_process_with_config_policy( + process_state: &CoreProcessState, + gui_config: &GuiConfigFile, + preserve_config: bool, ) -> Result<(), String> { process_state.ensure_active()?; let mut resolved_config = gui_config.clone(); @@ -1531,7 +1556,15 @@ pub(crate) fn start_core_process_inner( )); } - let config_path = merge_core_config_for_start(&install_dir, gui_config)?; + let config_path = if preserve_config { + let path = install_dir.join(CORE_CONFIG_FILE); + if !path.is_file() { + return Err("Reviewed updates require an existing core configuration".into()); + } + path + } else { + merge_core_config_for_start(&install_dir, gui_config)? + }; let config_path = path_to_string(&config_path); let log_path = core_start_log_path(&install_dir, &gui_config.auth_dir)?; let start_once = || { @@ -2050,6 +2083,20 @@ pub(crate) fn validate_downloaded_asset( ) } +pub(crate) fn validate_download_chunk_size( + downloaded: u64, + incoming: u64, + expected: Option, +) -> Result<(), String> { + if downloaded.checked_add(incoming) + .is_none_or(|total| expected.is_some_and(|limit| total > limit)) + { + Err("Download exceeds the declared archive size".into()) + } else { + Ok(()) + } +} + pub(crate) fn validate_download_metadata( downloaded: u64, expected_total: Option, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index e2d7a9ffd..8ace3e326 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -9,6 +9,7 @@ mod codex_sessions; mod configuration_watcher; mod core_config; mod core_runtime; +mod reviewed_core; mod desktop_theme; mod instance_lock; mod management_api; @@ -49,6 +50,7 @@ use app_settings::*; use app_update::*; use core_config::*; use core_runtime::*; +use reviewed_core::*; use flate2::read::GzDecoder; use futures_util::StreamExt; use instance_lock::*; @@ -424,6 +426,7 @@ struct CoreStatus { struct CoreLatest { version: String, asset_name: String, + reviewed: bool, } #[derive(Clone, Serialize)] @@ -622,6 +625,7 @@ struct GuiConfigFile { proxy_url: String, proxy_override: bool, download_source: VersionDownloadSource, + reviewed_core_manifest_url: String, custom_download_mirrors: Vec, active_custom_download_mirror: String, prefer_gitcode_downloads: bool, @@ -928,6 +932,7 @@ impl Default for GuiConfigFile { proxy_url: String::new(), proxy_override: false, download_source: VersionDownloadSource::Github, + reviewed_core_manifest_url: String::new(), custom_download_mirrors: Vec::new(), active_custom_download_mirror: String::new(), prefer_gitcode_downloads: false, @@ -2720,6 +2725,8 @@ fn main() { check_latest_core, detect_bundled_core, install_core_version, + get_reviewed_core_settings, + set_reviewed_core_manifest, install_bundled_core, cancel_core_install, get_core_install_task, diff --git a/src-tauri/src/management_api.rs b/src-tauri/src/management_api.rs index ca06244bc..13e7264b4 100644 --- a/src-tauri/src/management_api.rs +++ b/src-tauri/src/management_api.rs @@ -77,10 +77,43 @@ where #[tauri::command] pub(crate) async fn management_request( + app: tauri::AppHandle, gui_config_state: tauri::State<'_, GuiConfigState>, request: ManagementRequest, ) -> Result { let config = gui_config_state.snapshot()?; + let path = request.path.trim().trim_start_matches('/'); + if request.method.trim().eq_ignore_ascii_case("POST") && path.starts_with("plugin-store/") && path.ends_with("/install") { + // Plugin replacement must not race core replacement or a manual restart. + return tauri::async_runtime::spawn_blocking(move || { + use tauri::Manager; + let _guard = super::lock_core_operation(app.state::().inner())?; + if app.state::().snapshot().running { + return Err("Wait for the app update to finish before updating plugins".into()); + } + tauri::async_runtime::block_on(async { + let current = management_request_inner(&config, ManagementRequest { + method: "GET".into(), path: "plugins".into(), query: None, body: None, timeout_ms: Some(5000), + }).await?; + let id = request.path.trim().trim_start_matches('/').strip_prefix("plugin-store/") + .and_then(|path| path.strip_suffix("/install")).unwrap_or_default(); + validate_plugin_update_enabled(¤t, id)?; + management_request_inner(&config, request).await + }) + }).await.map_err(|error| format!("Plugin update worker failed: {error}"))?; + } + management_request_inner(&config, request).await +} + +fn validate_plugin_update_enabled(current: &serde_json::Value, id: &str) -> Result<(), String> { + let enabled = current.get("plugins_enabled").and_then(serde_json::Value::as_bool) == Some(true) + && current.get("plugins").and_then(serde_json::Value::as_array).is_some_and(|plugins| plugins.iter().any(|plugin| + plugin.get("id").and_then(serde_json::Value::as_str) == Some(id) + && plugin.get("enabled").and_then(serde_json::Value::as_bool) == Some(true))); + if enabled { Ok(()) } else { Err("Plugin updates require an already enabled plugin; no enable setting was changed".into()) } +} + +async fn management_request_inner(config: &GuiConfigFile, request: ManagementRequest) -> Result { let method = match request.method.trim().to_ascii_uppercase().as_str() { "GET" => reqwest::Method::GET, "POST" => reqwest::Method::POST, @@ -99,7 +132,8 @@ pub(crate) async fn management_request( .request(method, management_endpoint(&config, path)?) .header("Authorization", management_authorization(&config)?); if let Some(timeout_ms) = request.timeout_ms { - builder = builder.timeout(Duration::from_millis(timeout_ms.clamp(1_000, 120_000))); + let max_timeout = if request.method.trim().eq_ignore_ascii_case("POST") && path.trim_start_matches('/').starts_with("plugin-store/") && path.ends_with("/install") { 300_000 } else { 120_000 }; + builder = builder.timeout(Duration::from_millis(timeout_ms.clamp(1_000, max_timeout))); } if let Some(query) = request.query { builder = builder.query(&query); @@ -742,3 +776,19 @@ mod tests { ); } } + + +#[cfg(test)] +mod plugin_update_tests { + use super::*; + use serde_json::json; + + #[test] + fn plugin_install_preflight_does_not_enable_disabled_or_unknown_plugins() { + let active = json!({"plugins_enabled":true,"plugins":[{"id":"example","enabled":true}]}); + assert!(validate_plugin_update_enabled(&active,"example").is_ok()); + assert!(validate_plugin_update_enabled(&active,"other").is_err()); + assert!(validate_plugin_update_enabled(&json!({"plugins_enabled":true,"plugins":[{"id":"example","enabled":false}]}),"example").is_err()); + assert!(validate_plugin_update_enabled(&json!({"plugins_enabled":false,"plugins":[{"id":"example","enabled":true}]}),"example").is_err()); + } +} diff --git a/src-tauri/src/reviewed_core.rs b/src-tauri/src/reviewed_core.rs new file mode 100644 index 000000000..4a623b801 --- /dev/null +++ b/src-tauri/src/reviewed_core.rs @@ -0,0 +1,812 @@ +//! Opt-in, checksum-pinned core releases. App and plugin updates stay independent. +use super::*; +use std::collections::{BTreeMap, BTreeSet}; + +const PROVENANCE_FILE: &str = "reviewed-core.json"; +const APP_UPDATE_BLOCK: &str = "Reviewed core channel is active. Official app updates may remove local compatibility patches. Use a trusted signed reviewed app, or switch back after those patches are upstream."; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ReviewedCoreManifest { + schema_version: u32, + repository: String, + tag: String, + commit: String, + assets: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct ReviewedCoreAsset { + os: String, + arch: String, + url: String, + sha256: String, + size: u64, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ReviewedCoreProvenance { + manifest_url: String, + repository: String, + tag: String, + commit: String, + archive_sha256: String, + binary_sha256: String, + installed_at_unix: u64, + backup_dir: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ReviewedCoreSettings { + manifest_url: String, + installed: Option, +} + +fn validate_https_url(value: &str) -> Result { + let url = reqwest::Url::parse(value).map_err(|_| "Invalid reviewed release URL".to_string())?; + if url.scheme() != "https" + || url.host_str().is_none() + || url.port().is_some() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err( + "Reviewed release URLs must use HTTPS without credentials, ports, queries or fragments" + .into(), + ); + } + Ok(url) +} + +fn is_hex(value: &str, length: usize) -> bool { + value.len() == length && value.bytes().all(|c| c.is_ascii_hexdigit()) +} + +fn validate_manifest(manifest: &ReviewedCoreManifest) -> Result<(), String> { + let repository_parts = manifest.repository.split('/').collect::>(); + if manifest.schema_version != 1 + || repository_parts.len() != 2 + || repository_parts.iter().any(|p| { + p.is_empty() + || *p == "." + || *p == ".." + || !p + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"-_.".contains(&c)) + }) + || !is_hex(&manifest.commit, 40) + { + return Err("Invalid reviewed manifest schema, repository or commit".into()); + } + if !manifest.tag.starts_with('v') || semver::Version::parse(&manifest.tag[1..]).is_err() { + return Err("Reviewed release tag must be v followed by a semantic version".into()); + } + if manifest.assets.is_empty() || manifest.assets.len() > 12 { + return Err("Reviewed manifest must contain platform assets".into()); + } + let mut platforms = BTreeSet::new(); + for asset in &manifest.assets { + if !["darwin", "linux", "windows"].contains(&asset.os.as_str()) + || !["arm64", "amd64"].contains(&asset.arch.as_str()) + || !platforms.insert((&asset.os, &asset.arch)) + || !is_hex(&asset.sha256, 64) + || asset.size == 0 + || asset.size > 512 * 1024 * 1024 + { + return Err("Invalid reviewed asset platform, SHA-256 or size".into()); + } + let url = validate_https_url(&asset.url)?; + let prefix = format!( + "/{}/releases/download/{}/", + manifest.repository, manifest.tag + ); + let filename = url.path().strip_prefix(&prefix).unwrap_or_default(); + let suffix = if asset.os == "windows" { + ".zip" + } else { + ".tar.gz" + }; + if url.host_str() != Some("github.com") + || filename.is_empty() + || !filename.ends_with(suffix) + || !filename + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"-_.".contains(&c)) + { + return Err( + "Reviewed asset must belong to the declared GitHub repository and release tag" + .into(), + ); + } + } + Ok(()) +} + +fn selected_asset(manifest: &ReviewedCoreManifest) -> Result<&ReviewedCoreAsset, String> { + let platform = current_core_platform()?; + let arch = if platform.asset_arch == "aarch64" { + "arm64" + } else { + &platform.asset_arch + }; + manifest + .assets + .iter() + .find(|asset| asset.os == platform.asset_os && asset.arch == arch) + .ok_or_else(|| "Reviewed release has no asset for this platform".into()) +} + +async fn fetch_manifest(config: &GuiConfigFile) -> Result { + validate_https_url(&config.reviewed_core_manifest_url)?; + // No mirrors or official-source fallback. Redirects must remain HTTPS. + let client = reviewed_http_client(config)?; + let mut response = client + .get(&config.reviewed_core_manifest_url) + .send() + .await + .map_err(|_| "Could not fetch reviewed core manifest".to_string())? + .error_for_status() + .map_err(|e| { + format!( + "Reviewed manifest HTTP status: {}", + e.status().map(|s| s.as_u16()).unwrap_or(0) + ) + })?; + let mut bytes = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| "Could not read reviewed manifest".to_string())? + { + if bytes.len() + chunk.len() > 512 * 1024 { + return Err("Reviewed manifest exceeds 512 KiB".into()); + } + bytes.extend_from_slice(&chunk); + } + let manifest = serde_json::from_slice(&bytes) + .map_err(|_| "Invalid reviewed core manifest JSON".to_string())?; + validate_manifest(&manifest)?; + selected_asset(&manifest)?; + Ok(manifest) +} + +fn reviewed_http_client(config: &GuiConfigFile) -> Result { + build_http_client_with_proxy( + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::custom(|attempt| { + if attempt.previous().len() >= 5 || attempt.url().scheme() != "https" { + attempt.stop() + } else { + attempt.follow() + } + })) + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(300)), + &config.proxy_url, + "Create reviewed update client", + ) +} + +pub(crate) fn reviewed_app_update_guard(config: &GuiConfigFile) -> Result<(), String> { + if config.reviewed_core_manifest_url.is_empty() { + Ok(()) + } else { + Err(APP_UPDATE_BLOCK.into()) + } +} + +pub(crate) fn reviewed_official_core_guard(config: &GuiConfigFile) -> Result<(), String> { + if config.reviewed_core_manifest_url.is_empty() { + Ok(()) + } else { + Err("Reviewed core channel is active. Use its verified release, or explicitly switch back to the official channel.".into()) + } +} + +fn settings(config: &GuiConfigFile) -> Result { + let install = core_install_dir()?; + let installed = fs::read(install.join(PROVENANCE_FILE)) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .filter(|record| { + find_core_binary(&install) + .and_then(|path| sha256_file(&path).ok()) + .as_deref() + == Some(record.binary_sha256.as_str()) + }); + Ok(ReviewedCoreSettings { + manifest_url: config.reviewed_core_manifest_url.clone(), + installed, + }) +} + +#[tauri::command] +pub(crate) fn get_reviewed_core_settings( + state: tauri::State<'_, GuiConfigState>, +) -> Result { + settings(&state.snapshot()?) +} + +#[tauri::command] +pub(crate) fn set_reviewed_core_manifest( + app: tauri::AppHandle, + url: String, +) -> Result { + let _guard = lock_core_operation(app.state::().inner())?; + if app.state::().snapshot().running { + return Err("Wait for the app update to finish".into()); + } + let url = url.trim().to_string(); + if !url.is_empty() { + validate_https_url(&url)?; + } + let config = app.state::().update(|config| { + config.reviewed_core_manifest_url = url; + Ok(()) + })?; + settings(&config) +} + +pub(crate) async fn check_reviewed_core(config: &GuiConfigFile) -> Result { + let manifest = fetch_manifest(config).await?; + let asset = selected_asset(&manifest)?; + Ok(CoreLatest { + reviewed: true, + version: normalize_version(&manifest.tag), + asset_name: asset.url.rsplit('/').next().unwrap().to_string(), + }) +} + +#[derive(Debug, PartialEq)] +struct HealthSnapshot { + models: BTreeMap>, + plugins: BTreeMap, +} + +fn parse_health( + models: &serde_json::Value, + plugins: &serde_json::Value, +) -> Result { + let list = models + .get("models") + .or_else(|| models.get("data")) + .and_then(serde_json::Value::as_array) + .ok_or("Core health check requires a model catalog")?; + let mut model_map = BTreeMap::new(); + for model in list { + let id = model + .get("slug") + .or_else(|| model.get("id")) + .and_then(serde_json::Value::as_str) + .ok_or("Model catalog entry has no ID")?; + let mut fields = BTreeMap::new(); + for name in [ + "canonical_model_id", + "context_window", + "max_context_window", + "max_tokens", + "max_output_tokens", + "input_modalities", + "supported_input_modalities", + "output_modalities", + "supported_reasoning_levels", + "default_reasoning_level", + ] { + if let Some(value) = model.get(name).filter(|value| !value.is_null()) { + let normalized = if name == "supported_reasoning_levels" { + value + .as_array() + .map(|levels| { + let efforts = levels + .iter() + .filter_map(|level| { + level.get("effort").and_then(serde_json::Value::as_str) + }) + .collect::>(); + serde_json::json!(efforts) + }) + .unwrap_or_else(|| value.clone()) + } else if name.ends_with("modalities") { + value + .as_array() + .map(|items| { + serde_json::json!(items + .iter() + .filter_map(serde_json::Value::as_str) + .collect::>()) + }) + .unwrap_or_else(|| value.clone()) + } else { + value.clone() + }; + fields.insert(name.into(), normalized); + } + } + model_map.insert(id.to_string(), fields); + } + let plugin_list = plugins + .get("plugins") + .and_then(serde_json::Value::as_array) + .ok_or("Core health check requires the plugin registry")?; + let mut plugin_map = BTreeMap::new(); + for plugin in plugin_list { + if plugin + .get("registered") + .and_then(serde_json::Value::as_bool) + != Some(true) + { + continue; + } + let id = plugin + .get("id") + .and_then(serde_json::Value::as_str) + .ok_or("Registered plugin has no ID")?; + plugin_map.insert( + id.to_string(), + plugin.get("effective_enabled").cloned().unwrap_or_default(), + ); + } + Ok(HealthSnapshot { + models: model_map, + plugins: plugin_map, + }) +} + +async fn read_health(config: &GuiConfigFile) -> Result { + let client = management_http_client()?; + let response = client + .get(format!( + "{}/v1/models", + managed_core_loopback_origin(config.port) + )) + .timeout(Duration::from_secs(4)) + .query(&[("client_version", "pi")]) + .bearer_auth(effective_agent_api_key(config)) + .send() + .await + .map_err(|_| "Model catalog health request failed".to_string())?; + if !response.status().is_success() { + return Err(format!("Model catalog health HTTP {}", response.status())); + } + let models = response + .json::() + .await + .map_err(|_| "Invalid model catalog health response".to_string())?; + let response = client + .get(management_endpoint(config, "plugins")?) + .timeout(Duration::from_secs(4)) + .header("Authorization", management_authorization(config)?) + .send() + .await + .map_err(|_| "Plugin health request failed".to_string())?; + if !response.status().is_success() { + return Err(format!("Plugin health HTTP {}", response.status())); + } + let plugins = response + .json::() + .await + .map_err(|_| "Invalid plugin health response".to_string())?; + parse_health(&models, &plugins) +} + +fn validate_health(before: &HealthSnapshot, after: &HealthSnapshot) -> Result<(), String> { + for (id, fields) in &before.models { + let Some(current) = after.models.get(id) else { + return Err(format!("Updated core lost model route {id}")); + }; + for (key, value) in fields { + if current.get(key) != Some(value) { + return Err(format!("Updated core changed {key} for {id}; review capability changes before updating")); + } + } + } + for (id, enabled) in &before.plugins { + if after.plugins.get(id) != Some(enabled) { + return Err(format!("Updated core lost registered plugin {id}")); + } + } + Ok(()) +} + +async fn retry_health(mut check: F, alive: A, timeout: Duration) -> Result<(), String> +where + F: FnMut() -> Fut, + Fut: std::future::Future>, + A: Fn() -> Result<(), String>, +{ + let deadline = Instant::now() + timeout; + loop { + alive()?; + let result = check().await; + if result.is_ok() || Instant::now() >= deadline { + return result; + } + tokio::time::sleep(Duration::from_millis(250)).await; + } +} + +async fn wait_healthy( + config: &GuiConfigFile, + baseline: &HealthSnapshot, + process: &CoreProcessState, +) -> Result<(), String> { + retry_health( + || async { + read_health(config) + .await + .and_then(|after| validate_health(baseline, &after)) + }, + || { + if current_core_status(Some(process), Some(config.port))?.running { + Ok(()) + } else { + Err("Candidate core exited during health checks".into()) + } + }, + Duration::from_secs(15), + ) + .await +} + +fn stop_if_running(running: bool, stop: F) -> Result<(), String> +where + F: FnOnce() -> Result<(), String>, +{ + if running { + stop() + } else { + Ok(()) + } +} + +fn stop_candidate(process: &CoreProcessState, port: u16) -> Result<(), String> { + let running = current_core_status(Some(process), Some(port))?.running; + stop_if_running(running, || stop_core_process_inner(process)) +} + +fn backup_files(install: &Path, binary: &Path, backup: &Path) -> Result<(), String> { + fs::create_dir(backup).map_err(|e| format!("Create core backup: {e}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(backup, fs::Permissions::from_mode(0o700)) + .map_err(|e| e.to_string())?; + } + fs::copy(binary, backup.join("core-binary")) + .map_err(|e| format!("Back up core binary: {e}"))?; + for name in [CORE_METADATA_FILE, PROVENANCE_FILE] { + if install.join(name).exists() { + fs::copy(install.join(name), backup.join(name)).map_err(|e| e.to_string())?; + } + } + Ok(()) +} + +fn restore_files(install: &Path, binary: &Path, backup: &Path) -> Result<(), String> { + copy_core_file_replace(&backup.join("core-binary"), binary)?; + for name in [CORE_METADATA_FILE, PROVENANCE_FILE] { + if backup.join(name).exists() { + copy_core_file_replace(&backup.join(name), &install.join(name))?; + } else if install.join(name).exists() { + fs::remove_file(install.join(name)).map_err(|e| e.to_string())?; + } + } + Ok(()) +} + +// Exercise replacement and restoration with injected lifecycle operations in tests. +fn replace_and_check( + install: &Path, + binary: &Path, + staged: &Path, + backup: &Path, + check: F, + stop_failed: R, +) -> Result<(), String> +where + F: FnOnce() -> Result<(), String>, + R: FnOnce() -> Result<(), String>, +{ + backup_files(install, binary, backup)?; + let result = copy_core_file_replace(staged, binary).and_then(|_| check()); + if let Err(error) = result { + stop_failed().map_err(|stop| { + format!( + "{error}; could not stop candidate for rollback: {stop}; backup retained at {}", + path_to_string(backup) + ) + })?; + restore_files(install, binary, backup).map_err(|rollback| { + format!( + "{error}; rollback failed: {rollback}; backup retained at {}", + path_to_string(backup) + ) + })?; + return Err(format!("{error}; previous core files restored")); + } + Ok(()) +} + +pub(crate) async fn install_reviewed_core( + app: tauri::AppHandle, + window: tauri::Window, + version: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let process = app.state::(); + let _guard = lock_core_operation(process.inner())?; + let config = app.state::().snapshot()?; + if config.reviewed_core_manifest_url.is_empty() { return Err("Reviewed core channel is not configured".into()); } + if !current_core_status(Some(process.inner()), Some(config.port))?.running { + return Err("Start the existing core before installing a reviewed update so its models and plugins can be checked.".into()); + } + let state = app.state::(); + let token = CancellationToken::new(); + state.start(token.clone(), version.clone())?; + let result = (|| { + let manifest = tauri::async_runtime::block_on(fetch_manifest(&config))?; + if version.as_ref().is_some_and(|v| normalize_version(v) != normalize_version(&manifest.tag)) { + return Err("Reviewed release changed since the update check. Check again before installing.".into()); + } + let asset = selected_asset(&manifest)?.clone(); + let install = core_install_dir()?; + let binary = find_core_binary(&install).ok_or("Install an initial core before switching to reviewed updates")?; + let work = core_base_dir()?.join(format!("reviewed-stage-{}-{}", std::process::id(), std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos())); + fs::create_dir(&work).map_err(|e| e.to_string())?; + let outcome = (|| { + let archive = work.join(if asset.os == "windows" { "core.zip" } else { "core.tar.gz" }); + let client = reviewed_http_client(&config)?; + tauri::async_runtime::block_on(download_asset_inner(&client, &asset.url, &archive, Some(asset.size), Some(&asset.sha256), &window, state.inner(), &token))?; + ensure_not_cancelled(&token, Some(&archive))?; + let unpacked = work.join("unpacked"); + fs::create_dir(&unpacked).map_err(|e| e.to_string())?; + if asset.os == "windows" { extract_zip(&archive, &unpacked)?; } else { extract_tar_gz(&archive, &unpacked)?; } + let staged = find_core_binary(&unpacked).ok_or("Reviewed archive has no core binary")?; + let binary_sha256 = sha256_file(&staged)?; + // The existing running core supplies the baseline. No credentials + // are copied to staging or sent to any release server. + let baseline = tauri::async_runtime::block_on(read_health(&config))?; + ensure_not_cancelled(&token, None)?; + state.progress(&window, "Verify reviewed core and preserve rollback", asset.size, Some(asset.size), false); + stop_core_process_inner(process.inner())?; + let backup = core_base_dir()?.join(format!("reviewed-backup-{}-{}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos(), &manifest.commit[..12])); + let replaced = replace_and_check(&install, &binary, &staged, &backup, || { + start_core_process_preserving_config(process.inner(), &config)?; + tauri::async_runtime::block_on(wait_healthy(&config, &baseline, process.inner()))?; + write_core_metadata(&install, &CoreMetadata { version: normalize_version(&manifest.tag), asset_name: asset.url.rsplit('/').next().unwrap().into(), installed_at_unix: unix_now() })?; + let provenance = ReviewedCoreProvenance { manifest_url: config.reviewed_core_manifest_url.clone(), repository: manifest.repository.clone(), tag: manifest.tag.clone(), commit: manifest.commit.clone(), archive_sha256: asset.sha256.clone(), binary_sha256, installed_at_unix: unix_now(), backup_dir: path_to_string(&backup) }; + fs::write(install.join(PROVENANCE_FILE), serde_json::to_vec_pretty(&provenance).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?; + Ok(()) + }, || stop_candidate(process.inner(), config.port)); + if replaced.is_err() && !current_core_status(Some(process.inner()), Some(config.port))?.running { + start_core_process_preserving_config(process.inner(), &config).map_err(|e| format!("{}; restart after rollback failed: {e}", replaced.as_ref().unwrap_err()))?; + } + replaced?; + Ok(CoreInstallResult { version: normalize_version(&manifest.tag), asset_name: asset.url.rsplit('/').next().unwrap().into(), install_dir: path_to_string(&install), binary_path: Some(path_to_string(&binary)) }) + })(); + let _ = fs::remove_dir_all(&work); + outcome + })(); + if let Ok(status) = current_core_status(Some(process.inner()), Some(config.port)) { emit_core_status(&app, &status); } + state.finish(&window, result.clone()); + result + }).await.map_err(|e| format!("Reviewed update worker failed: {e}"))? +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn manifest() -> ReviewedCoreManifest { + serde_json::from_value(json!({ + "schema_version":1,"repository":"example/CLIProxyAPI","tag":"v7.3.9-review.abc1234", + "commit":"a".repeat(40),"assets":[{"os":"darwin","arch":"arm64", + "url":"https://github.com/example/CLIProxyAPI/releases/download/v7.3.9-review.abc1234/core.tar.gz", + "sha256":"b".repeat(64),"size":42000}] + })).unwrap() + } + + #[test] + fn manifest_requires_pinned_repository_tag_hash_and_platform() { + assert!(validate_manifest(&manifest()).is_ok()); + assert!(validate_download_chunk_size(4, 6, Some(10)).is_ok()); + assert!(validate_download_chunk_size(4, 7, Some(10)).is_err()); + assert!(validate_download_chunk_size(u64::MAX, 1, None).is_err()); + let invalid = [ + ("url", json!("https://github.com/other/CLIProxyAPI/releases/download/v7.3.9-review.abc1234/core.tar.gz")), + ("url", json!("https://github.com/example/CLIProxyAPI/releases/latest/download/core.tar.gz")), + ("url", json!("https://github.com/example/CLIProxyAPI/releases/download/v7.3.9-review.abc1234/../core.tar.gz")), + ("url", json!("http://github.com/example/CLIProxyAPI/releases/download/v7.3.9-review.abc1234/core.tar.gz")), + ("url", json!("https://github.com/example/CLIProxyAPI/releases/download/v7.3.9-review.abc1234/%2e%2e.tar.gz")), + ("sha256", json!("")), ("sha256", json!("z".repeat(64))), + ("size", json!(0)), ("size", json!(513u64*1024*1024)), ("arch", json!("other")), + ]; + for (key, value) in invalid { + let mut raw = serde_json::to_value(manifest()).unwrap(); + raw["assets"][0][key] = value; + assert!( + validate_manifest(&serde_json::from_value(raw).unwrap()).is_err(), + "{key}" + ); + } + let mut raw = serde_json::to_value(manifest()).unwrap(); + raw["assets"][0].as_object_mut().unwrap().remove("sha256"); + assert!(serde_json::from_value::(raw).is_err()); + let mut value = manifest(); + value.assets.push(value.assets[0].clone()); + assert!(validate_manifest(&value).is_err()); + let mut value = manifest(); + value.commit = "HEAD".into(); + assert!(validate_manifest(&value).is_err()); + } + + #[test] + fn channel_round_trips_and_guards_official_updates() { + let mut config = GuiConfigFile::default(); + assert!(reviewed_app_update_guard(&config).is_ok()); + assert!(reviewed_official_core_guard(&config).is_ok()); + config.reviewed_core_manifest_url = + "https://raw.githubusercontent.com/example/CLIProxyAPI/reviewed-channel/core.json" + .into(); + let encoded = toml::to_string(&config).unwrap(); + let restored: GuiConfigFile = toml::from_str(&encoded).unwrap(); + assert_eq!( + restored.reviewed_core_manifest_url, + config.reviewed_core_manifest_url + ); + assert!(reviewed_app_update_guard(&restored).is_err()); + assert!(reviewed_official_core_guard(&restored).is_err()); + for invalid in [ + "http://example.org/core.json", + "https://user:secret@example.org/core.json", + "https://example.org/core.json?token=secret", + "https://example.org:443/core.json#fragment", + ] { + assert!(validate_https_url(invalid).is_err()); + } + } + + #[test] + fn rich_catalog_and_registered_plugins_must_survive() { + let catalog = json!({"models":[{"slug":"work/example","canonical_model_id":"gpt-example", + "context_window":1000000,"supported_reasoning_levels":[{"effort":"high"},{"effort":"max"}], + "input_modalities":["text","image"],"max_tokens":128000}]}); + let plugins = + json!({"plugins":[{"id":"binding","registered":true,"effective_enabled":true}]}); + let before = parse_health(&catalog, &plugins).unwrap(); + assert!(validate_health(&before, &parse_health(&catalog, &plugins).unwrap()).is_ok()); + let mut reordered = catalog.clone(); + reordered["models"][0]["supported_reasoning_levels"] = + json!([{"effort":"max","description":"Updated label"},{"effort":"high"}]); + reordered["models"][0]["input_modalities"] = json!(["image", "text"]); + assert!(validate_health(&before, &parse_health(&reordered, &plugins).unwrap()).is_ok()); + let mut dropped = catalog.clone(); + dropped["models"][0] + .as_object_mut() + .unwrap() + .remove("supported_reasoning_levels"); + assert!(validate_health(&before, &parse_health(&dropped, &plugins).unwrap()).is_err()); + let dropped = json!({"models":[]}); + assert!(validate_health(&before, &parse_health(&dropped, &plugins).unwrap()).is_err()); + let disabled = + json!({"plugins":[{"id":"binding","registered":false,"effective_enabled":false}]}); + assert!(validate_health(&before, &parse_health(&catalog, &disabled).unwrap()).is_err()); + } + + #[test] + fn health_check_retries_partial_registration_but_stops_on_process_exit() { + let attempts = std::cell::Cell::new(0); + tauri::async_runtime::block_on(retry_health( + || { + attempts.set(attempts.get() + 1); + std::future::ready(if attempts.get() < 2 { + Err("plugin route still registering".into()) + } else { + Ok(()) + }) + }, + || Ok(()), + Duration::from_secs(2), + )) + .unwrap(); + assert_eq!(attempts.get(), 2); + let result = tauri::async_runtime::block_on(retry_health( + || std::future::ready(Ok(())), + || Err("candidate exited".into()), + Duration::from_secs(2), + )); + assert_eq!(result.unwrap_err(), "candidate exited"); + } + + fn test_dir(name: &str) -> PathBuf { + let path = env::temp_dir().join(format!( + "reviewed-core-{name}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&path).unwrap(); + path + } + + #[test] + fn failed_health_restores_binary_metadata_and_preserves_config_plugins() { + let dir = test_dir("rollback"); + let binary = dir.join("core"); + let staged = dir.join("candidate"); + let backup = dir.join("backup"); + fs::write(&binary, b"previous").unwrap(); + fs::write(&staged, b"candidate").unwrap(); + fs::write(dir.join(CORE_METADATA_FILE), b"old-metadata").unwrap(); + fs::write(dir.join(CORE_CONFIG_FILE), b"user config").unwrap(); + fs::create_dir(dir.join("plugins")).unwrap(); + fs::write(dir.join("plugins/binding"), b"keep").unwrap(); + let result = replace_and_check( + &dir, + &binary, + &staged, + &backup, + || { + assert_eq!(fs::read(&binary).unwrap(), b"candidate"); + fs::write(dir.join(CORE_METADATA_FILE), b"new-metadata").unwrap(); + fs::write(dir.join(PROVENANCE_FILE), b"new-provenance").unwrap(); + Err("health failed".into()) + }, + || stop_if_running(false, || Err("already stopped".into())), + ); + assert!(result.unwrap_err().contains("previous core files restored")); + assert_eq!(fs::read(&binary).unwrap(), b"previous"); + assert_eq!( + fs::read(dir.join(CORE_METADATA_FILE)).unwrap(), + b"old-metadata" + ); + assert!(!dir.join(PROVENANCE_FILE).exists()); + assert_eq!( + fs::read(dir.join(CORE_CONFIG_FILE)).unwrap(), + b"user config" + ); + assert_eq!(fs::read(dir.join("plugins/binding")).unwrap(), b"keep"); + assert_eq!(fs::read(backup.join("core-binary")).unwrap(), b"previous"); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn failed_replacement_and_failed_stop_keep_recovery_material() { + let dir = test_dir("failure"); + let binary = dir.join("core"); + let backup = dir.join("backup"); + fs::write(&binary, b"previous").unwrap(); + let result = replace_and_check( + &dir, + &binary, + &dir.join("missing"), + &backup, + || panic!("must not check a failed replacement"), + || Ok(()), + ); + assert!(result.is_err()); + assert_eq!(fs::read(&binary).unwrap(), b"previous"); + let staged = dir.join("candidate"); + fs::write(&staged, b"candidate").unwrap(); + let backup = dir.join("backup2"); + let result = replace_and_check( + &dir, + &binary, + &staged, + &backup, + || Err("start failed".into()), + || Err("stop failed".into()), + ); + assert!(result.unwrap_err().contains("backup retained")); + assert_eq!(fs::read(backup.join("core-binary")).unwrap(), b"previous"); + fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/src-tauri/src/tests/core_config.rs b/src-tauri/src/tests/core_config.rs index cc3ea8d4d..a0cfbb801 100644 --- a/src-tauri/src/tests/core_config.rs +++ b/src-tauri/src/tests/core_config.rs @@ -1973,6 +1973,7 @@ fn startup_preserves_all_user_owned_yaml_and_only_applies_gui_managed_values() { proxy_url: "socks5://127.0.0.1:7890".to_string(), proxy_override: true, download_source: VersionDownloadSource::Github, + reviewed_core_manifest_url: String::new(), custom_download_mirrors: Vec::new(), active_custom_download_mirror: String::new(), prefer_gitcode_downloads: false, diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index 13f81b478..6296b2969 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -5222,6 +5222,7 @@ mod tests { proxy_url: String::new(), proxy_override: false, download_source: VersionDownloadSource::Github, + reviewed_core_manifest_url: String::new(), custom_download_mirrors: Vec::new(), active_custom_download_mirror: String::new(), prefer_gitcode_downloads: false, diff --git a/src/components/InstalledPluginUpdates.tsx b/src/components/InstalledPluginUpdates.tsx new file mode 100644 index 000000000..15069fa1f --- /dev/null +++ b/src/components/InstalledPluginUpdates.tsx @@ -0,0 +1,104 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { useI18n } from '../i18n'; +import { managementApi } from '../services/managementApi'; +import { installedPluginEntries, pluginInstallRequest, pluginUpdateBlock, pluginVersionLoaded, pluginHasUpdate, + type PluginPlatform, type PluginStoreSnapshot, type PluginUpdateEntry } from '../services/pluginUpdates'; + +type RuntimePlugins = Parameters[0]; +type InstallResult = { version: string; restart_required: boolean }; + +export function InstalledPluginUpdates({ busy, running, onBusyChange }: { + busy: boolean; running: boolean; onBusyChange: (busy: boolean) => void; +}) { + const { t } = useI18n(); + const [snapshot, setSnapshot] = useState(null); + const [platform, setPlatform] = useState(null); + const [loading, setLoading] = useState(false); + const [updating, setUpdating] = useState(''); + const [error, setError] = useState(''); + const [notice, setNotice] = useState(''); + const requestEpoch = useRef(0); + const updateLock = useRef(false); + const pending = useRef<{ id: string; name: string; version: string; restart: boolean } | null>(null); + const mounted = useRef(true); + + const refresh = useCallback(async () => { + const epoch = ++requestEpoch.current; + setLoading(true); setError(''); + try { + const [catalog, detected, runtime] = await Promise.all([ + managementApi.get('/plugin-store'), + invoke('detect_core_platform'), + pending.current ? managementApi.get('/plugins') : Promise.resolve(null), + ]); + if (!mounted.current || epoch !== requestEpoch.current) return; + setSnapshot(catalog); setPlatform(detected); + if (runtime && pending.current) { + const result = pending.current; + const loaded = pluginVersionLoaded(runtime, result.id, result.version); + setNotice(t(loaded ? 'kernel.plugins.loaded' : result.restart ? 'kernel.plugins.restart' : 'kernel.plugins.pending', { name: result.name, version: result.version })); + if (loaded) pending.current = null; + } + } catch (reason) { + if (mounted.current && epoch === requestEpoch.current) { setSnapshot(null); setError(String(reason)); } + } finally { + if (mounted.current && epoch === requestEpoch.current) setLoading(false); + } + }, [t]); + + useEffect(() => { + mounted.current = true; + return () => { mounted.current = false; requestEpoch.current += 1; }; + }, []); + + const update = async (entry: PluginUpdateEntry) => { + if (!snapshot || !running || busy || updateLock.current) return; + updateLock.current = true; + setUpdating(entry.id); setError(''); setNotice(''); onBusyChange(true); + try { + const request = pluginInstallRequest(entry, snapshot, platform); + const result = await managementApi.post(request.path, request.body, request.options); + pending.current = { id: entry.id, name: entry.name, version: result.version, restart: result.restart_required }; + if (mounted.current) setNotice(t(result.restart_required ? 'kernel.plugins.restart' : 'kernel.plugins.pending', { name: entry.name, version: result.version })); + await refresh(); + } catch (reason) { + if (mounted.current) setError(String(reason)); + } finally { + updateLock.current = false; + if (mounted.current) setUpdating(''); + onBusyChange(false); + } + }; + + const entries = snapshot ? installedPluginEntries(snapshot) : []; + const unmatched = snapshot?.plugins.filter((entry) => entry.installed && !entries.some((matched) => matched.id === entry.id)); + return
+
+
{t('kernel.plugins.title')}{t('kernel.plugins.hint')}
+ +
+ {!running &&

{t('kernel.plugins.start')}

} + {error &&

{error}

} + {notice &&

{notice}

} + {snapshot?.source_errors?.map((source) =>

{source.source_name}: {source.message}

)} + {unmatched?.length ?

{t('kernel.plugins.unmatched')}

: null} + {snapshot && entries.length === 0 &&

{t('kernel.plugins.empty')}

} + {entries.map((entry) => { + const blocked = pluginUpdateBlock(entry, snapshot!, platform); + return
+
+ {entry.name} + {entry.installed_version || '?'} → {entry.version} + {entry.source_name} · {entry.source_url} + {blocked && {t(`kernel.plugins.block.${blocked}`)}} +
+ +
; + })} +
; +} diff --git a/src/components/ReviewedCoreChannel.tsx b/src/components/ReviewedCoreChannel.tsx new file mode 100644 index 000000000..2930b1b5b --- /dev/null +++ b/src/components/ReviewedCoreChannel.tsx @@ -0,0 +1,71 @@ +import { useEffect, useState } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { useI18n } from '../i18n'; + +type Settings = { + manifestUrl: string; + installed: { + repository: string; + tag: string; + commit: string; + binarySha256: string; + backupDir: string; + } | null; +}; + +export function ReviewedCoreChannel({ busy, onChange }: { busy: boolean; onChange: () => void }) { + const { t } = useI18n(); + const [settings, setSettings] = useState(null); + const [url, setUrl] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + let cancelled = false; + void invoke('get_reviewed_core_settings').then((value) => { + if (cancelled) return; + setSettings(value); + setUrl(value.manifestUrl); + }).catch((reason) => { if (!cancelled) setError(String(reason)); }); + return () => { cancelled = true; }; + }, [busy]); + + const save = async (value: string) => { + setSaving(true); + setError(''); + try { + const next = await invoke('set_reviewed_core_manifest', { url: value }); + setSettings(next); + setUrl(next.manifestUrl); + onChange(); + } catch (reason) { + setError(String(reason)); + } finally { + setSaving(false); + } + }; + + return
+
+ {t('kernel.reviewed.title')} + {settings?.manifestUrl ? t('kernel.reviewed.active') : t('kernel.reviewed.official')} + {t('kernel.reviewed.hint')} + {settings?.manifestUrl && {t('kernel.reviewed.appGuard')}} + {settings?.installed && + {t('kernel.reviewed.installed')}: {settings.installed.repository} · {settings.installed.tag} · {settings.installed.commit.slice(0, 12)} + } + {error && {error}} +
+
+ + + {settings?.manifestUrl && } +
+
; +} diff --git a/src/coreUpdate.tsx b/src/coreUpdate.tsx index f714df76b..9b449e97e 100644 --- a/src/coreUpdate.tsx +++ b/src/coreUpdate.tsx @@ -14,6 +14,7 @@ import { useCoreRuntime } from './coreRuntime'; export type CoreLatest = { version: string; assetName: string; + reviewed?: boolean; }; type CoreUpdateContextValue = { @@ -91,7 +92,11 @@ function compareSemanticVersions(left: SemanticVersion, right: SemanticVersion) export function coreUpdateAvailable( currentVersion: string | null | undefined, latestVersion: string | null | undefined, + reviewed = false, ) { + if (reviewed) { + return Boolean(currentVersion && latestVersion && normalizeVersion(currentVersion) !== normalizeVersion(latestVersion)); + } const current = parseSemanticVersion(currentVersion); const latest = parseSemanticVersion(latestVersion); return Boolean(current && latest && compareSemanticVersions(latest, current) > 0); @@ -183,7 +188,7 @@ export function CoreUpdateProvider({ children }: { children: ReactNode }) { latest, error, checking, - hasUpdate: coreUpdateAvailable(status?.currentVersion, latest?.version), + hasUpdate: coreUpdateAvailable(status?.currentVersion, latest?.version, latest?.reviewed), check, reset, }), [check, checking, error, latest, reset, status?.currentVersion]); diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 68e11c23e..c435e9e29 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -58,6 +58,33 @@ export const jaOverrides = { "apiAccess.groups.sharedToggleHint": "共有モデルの有効状態を切り替えます。キー固有の除外設定が優先されます。", "apiAccess.groups.partial": "一部のキーが無効", "apiAccess.groups.healthKey": "検査するキー", + "kernel.plugins.title": "プラグイン更新", + "kernel.plugins.hint": "有効なインストール済みプラグインを現在のレジストリから更新します。バージョンを指定し、配信元を自動変更しません。", + "kernel.plugins.start": "コアを起動してインストール済みプラグインを確認してください。", + "kernel.plugins.loaded": "{name} {version} がインストールされ、有効になりました。", + "kernel.plugins.restart": "{name} {version} がインストールされました。コアは再起動が必要と報告しています。実行中のタスクが終了してから再起動してください。", + "kernel.plugins.pending": "{name} {version} がインストールされましたが、有効化は未確認です。更新表示で登録状態を確認し、必要ならログを確認してタスク終了後に再起動してください。", + "kernel.plugins.unmatched": "一部のプラグインに確認済みの配信元がありません。既存の配信元を登録してください。", + "kernel.plugins.empty": "配信元を確認できるインストール済みプラグインがありません。", + "kernel.plugins.update": "更新", + "kernel.plugins.block.source": "インストール時の配信元と一致しません。", + "kernel.plugins.block.disabled": "プラグインまたはプラグイン機能が無効なため更新できません。", + "kernel.plugins.block.platform": "このプラットフォーム向けの更新がありません。", + "kernel.plugins.block.auth": "配信元の認証を設定してから更新してください。", + "kernel.plugins.block.version": "リリース ID またはバージョンが無効です。", + + + "kernel.reviewed.title": "コアのリリースチャンネル", + "kernel.reviewed.active": "レビュー済みマニフェストを選択中です。コア更新はこの配信元のみを使用します。", + "kernel.reviewed.official": "公式リリースを選択中です。", + "kernel.reviewed.hint": "アーカイブのハッシュを検証し、設定とプラグインを保持します。起動やモデル一覧の検証に失敗すると元のコアを復元します。下のコア更新ボタンでインストールできます。", + "kernel.reviewed.appGuard": "このチャンネルでは公式アプリの自動更新を停止します。アプリのパッチには信頼された署名済みリリースまたは上流への統合が必要です。プラグインはストアで更新できます。", + "kernel.reviewed.backup": "以前のコアのバックアップ", + "kernel.reviewed.installed": "インストール済みレビューコア", + "kernel.reviewed.url": "レビューコアのマニフェストURL", + "kernel.reviewed.select": "レビューチャンネルを使用", + "kernel.reviewed.useOfficial": "公式チャンネルを使用", + ...easyModeJa, 'authFiles.settings.noModels': '該当するモデルはありません。上の欄で除外ルールを入力できます。', 'authFiles.settings.button': "設定", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 4d66f61ab..4535fa4d2 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -58,6 +58,31 @@ export const en: Record = { "apiAccess.groups.sharedToggleHint": "Controls shared model availability. Individual key exclusion overrides still take precedence.", "apiAccess.groups.partial": "Some keys disabled", "apiAccess.groups.healthKey": "Key to Check", + "kernel.plugins.title": "Plugin updates", + "kernel.plugins.hint": "Update enabled, installed plugins from their current registry. Versions are pinned and sources never switch automatically.", + "kernel.plugins.start": "Start the core to check installed plugins.", + "kernel.plugins.loaded": "{name} {version} is installed and active.", + "kernel.plugins.restart": "{name} {version} is installed. The core reports that a restart is required. Restart it when active tasks have finished.", + "kernel.plugins.pending": "{name} {version} is installed but not yet confirmed active. Refresh to check registration; inspect the core logs and restart if needed after active tasks finish.", + "kernel.plugins.unmatched": "Some installed plugins have no verified matching source. Register their existing source before using this panel.", + "kernel.plugins.empty": "No installed plugins with a verified matching registry source were found.", + "kernel.plugins.update": "Update", + "kernel.plugins.block.source": "The installed source does not match.", + "kernel.plugins.block.disabled": "Updates are disabled because this plugin or the plugin system is disabled.", + "kernel.plugins.block.platform": "No update is available for this platform.", + "kernel.plugins.block.auth": "This source requires authentication. Configure it before updating.", + "kernel.plugins.block.version": "The release ID or version is invalid.", + + "kernel.reviewed.title": "Core release channel", + "kernel.reviewed.active": "Reviewed manifest selected. Core updates use only this source.", + "kernel.reviewed.official": "Official releases selected.", + "kernel.reviewed.hint": "Reviewed updates verify the archive hash, preserve configuration and plugins, and restore the previous binary if startup or catalog checks fail. Use the core update button below to install.", + "kernel.reviewed.appGuard": "Official app self-update is blocked while this channel is active. The app patches need a trusted signed release or upstream inclusion. Plugin updates remain available in the plugin store.", + "kernel.reviewed.backup": "Previous binary backup", + "kernel.reviewed.installed": "Installed reviewed core", + "kernel.reviewed.url": "Reviewed core manifest URL", + "kernel.reviewed.select": "Use reviewed channel", + "kernel.reviewed.useOfficial": "Use official channel", ...easyModeEn, 'authFiles.settings.noModels': 'No matching models. Enter exclusion rules above.', 'authFiles.settings.button': "Settings", diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 0a652f894..691881595 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -57,6 +57,33 @@ export const zhCN = { "apiAccess.groups.sharedToggleHint": "控制组内共享模型的启用状态。密钥的独立模型排除设置仍然优先。", "apiAccess.groups.partial": "部分密钥已停用", "apiAccess.groups.healthKey": "检测密钥", + "kernel.plugins.title": "插件更新", + "kernel.plugins.hint": "从当前注册源更新已启用并安装的插件。更新指定版本,不会自动切换来源。", + "kernel.plugins.start": "启动内核后可检查已安装插件。", + "kernel.plugins.loaded": "{name} {version} 已安装并生效。", + "kernel.plugins.restart": "{name} {version} 已安装。内核提示需要重启。请在当前任务结束后重启。", + "kernel.plugins.pending": "{name} {version} 已安装,但尚未确认生效。请刷新检查注册状态;如仍未生效,请检查内核日志并在任务结束后按需重启。", + "kernel.plugins.unmatched": "部分已安装插件没有可验证的匹配来源。请先注册其原有来源。", + "kernel.plugins.empty": "没有找到已安装且来源可验证的插件。", + "kernel.plugins.update": "更新", + "kernel.plugins.block.source": "已安装来源不匹配。", + "kernel.plugins.block.disabled": "插件或插件系统已禁用,无法更新。", + "kernel.plugins.block.platform": "当前平台没有可用更新。", + "kernel.plugins.block.auth": "此来源需要身份验证。请先配置后再更新。", + "kernel.plugins.block.version": "发布 ID 或版本无效。", + + + "kernel.reviewed.title": "内核发布渠道", + "kernel.reviewed.active": "已选择审核清单。内核更新仅使用此来源。", + "kernel.reviewed.official": "已选择官方发布。", + "kernel.reviewed.hint": "审核更新会校验压缩包哈希、保留配置和插件,并在启动或模型目录检查失败时恢复原内核。请使用下方的内核更新按钮安装。", + "kernel.reviewed.appGuard": "启用此渠道时暂停官方应用自动更新。应用补丁需要受信任签名的发行版或上游合并。插件仍可在插件商店更新。", + "kernel.reviewed.backup": "原内核备份", + "kernel.reviewed.installed": "已安装的审核内核", + "kernel.reviewed.url": "审核内核清单地址", + "kernel.reviewed.select": "使用审核渠道", + "kernel.reviewed.useOfficial": "使用官方渠道", + ...easyModeZhCN, 'authFiles.settings.noModels': '没有匹配的候选模型,可在上方手动填写排除规则。', 'authFiles.settings.button': "设置", diff --git a/src/pages/VersionManagementPage.tsx b/src/pages/VersionManagementPage.tsx index 9685f1cef..ab4297911 100644 --- a/src/pages/VersionManagementPage.tsx +++ b/src/pages/VersionManagementPage.tsx @@ -19,6 +19,8 @@ import { MessageNotice, FloatingNotice, useAppNotice } from '../appNotice'; import { createVersionManagementVisitTracker } from '../services/versionManagementVisits'; import { AppReleaseNotes } from '../components/AppReleaseNotes'; import { useDialogFocusTrap } from '../components/useDialogFocusTrap'; +import { InstalledPluginUpdates } from '../components/InstalledPluginUpdates'; +import { ReviewedCoreChannel } from '../components/ReviewedCoreChannel'; export type CoreInstallResult = { version: string; @@ -108,6 +110,7 @@ export function VersionManagementPage() { const [bundledCoreError, setBundledCoreError] = useState(''); const [installing, setInstalling] = useState(false); + const [pluginUpdating, setPluginUpdating] = useState(false); const [progress, setProgress] = useState(null); const [installDialogOpen, setInstallDialogOpen] = useState(false); const [confirmUpdateOpen, setConfirmUpdateOpen] = useState(false); @@ -418,8 +421,8 @@ export function VersionManagementPage() { const currentVersion = coreStatus?.currentVersion ?? ''; const coreInstalled = Boolean(coreStatus?.installed); const coreProcessBusy = Boolean(coreStatus?.starting); - const busy = checkingLatest || installing || coreProcessBusy; - const installDisabled = installing || coreProcessBusy; + const busy = checkingLatest || installing || coreProcessBusy || pluginUpdating; + const installDisabled = installing || coreProcessBusy || pluginUpdating; const resolvedAppVersion = appUpdate?.currentVersion || installedAppVersion; const currentAppVersion = resolvedAppVersion ? displayAppVersion(resolvedAppVersion) : t('common.detecting'); @@ -529,6 +532,11 @@ export function VersionManagementPage() {
setVersionSourceError('')} />
+ { + resetLatest(); + void checkLatest(); + void checkAppUpdate(); + }} />
{t('kernel.versions.downloadSource')} @@ -617,7 +625,7 @@ export function VersionManagementPage() {
+ + {customMirrorDialogOpen ? (
( path: string, body?: ManagementJson, - options: Pick = {}, + options: Pick = {}, ) => request('POST', path, { ...options, body }), put: (path: string, body?: ManagementJson) => request('PUT', path, { body }), diff --git a/src/services/pluginUpdates.ts b/src/services/pluginUpdates.ts new file mode 100644 index 000000000..ca4821999 --- /dev/null +++ b/src/services/pluginUpdates.ts @@ -0,0 +1,53 @@ +export type PluginUpdateEntry = { + id: string; name: string; version: string; installed_version: string; + source_id: string; source_name: string; source_url: string; + installed_source_id?: string; install_source_status?: string; + installed: boolean; registered: boolean; enabled: boolean; effective_enabled: boolean; + update_available: boolean; install_type?: string; auth_required?: boolean; auth_configured?: boolean; + platforms?: { goos: string; goarch: string }[]; +}; +export type PluginStoreSnapshot = { + plugins_enabled: boolean; + plugins: PluginUpdateEntry[]; + source_errors?: { source_name: string; source_url: string; message: string }[]; +}; +export type PluginPlatform = { assetOs: string; assetArch: string }; + +export function installedPluginEntries(snapshot: PluginStoreSnapshot): PluginUpdateEntry[] { + // Alternate sources can share an ID. Never turn a normal update into a source switch. + return snapshot.plugins.filter((entry) => entry.installed + && entry.install_source_status === 'matched' + && Boolean(entry.source_id) && entry.installed_source_id === entry.source_id); +} + +export function pluginUpdateBlock(entry: PluginUpdateEntry, snapshot: PluginStoreSnapshot, platform: PluginPlatform | null) { + if (!installedPluginEntries(snapshot).includes(entry)) return 'source' as const; + if (!snapshot.plugins_enabled || !entry.enabled) return 'disabled' as const; + if (!platform) return 'platform' as const; + const arch = platform.assetArch === 'aarch64' ? 'arm64' : platform.assetArch; + if (entry.platforms?.length && !entry.platforms.some((value) => value.goos === platform.assetOs && value.goarch === arch)) return 'platform' as const; + if (entry.auth_required && !entry.auth_configured) return 'auth' as const; + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(entry.id) + || !/^[0-9][0-9A-Za-z.+-]*$/.test(entry.version)) return 'version' as const; + return null; +} + +export function pluginInstallRequest(entry: PluginUpdateEntry, snapshot: PluginStoreSnapshot, platform: PluginPlatform | null) { + if (pluginUpdateBlock(entry, snapshot, platform)) throw new Error('Plugin is not eligible for an update from its installed source'); + return { + path: `/plugin-store/${encodeURIComponent(entry.id)}/install`, + body: { version: entry.version }, + options: { query: { source: entry.source_id }, timeoutMs: 300000 }, + }; +} + +export function pluginHasUpdate(entry: PluginUpdateEntry) { + return entry.install_type === 'direct' + ? entry.version.replace(/^v/, '') !== entry.installed_version.replace(/^v/, '') + : entry.update_available; +} + +export function pluginVersionLoaded(payload: { plugins?: { id: string; registered: boolean; effective_enabled: boolean; metadata?: { version?: string } }[] }, id: string, version: string) { + return payload.plugins?.some((plugin) => plugin.id === id && plugin.registered + && plugin.effective_enabled && plugin.metadata?.version?.replace(/^v/, '') === version.replace(/^v/, '')) ?? false; +} diff --git a/tests/pluginUpdates.test.ts b/tests/pluginUpdates.test.ts new file mode 100644 index 000000000..d6715e0b1 --- /dev/null +++ b/tests/pluginUpdates.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'bun:test'; +import { installedPluginEntries, pluginInstallRequest, pluginUpdateBlock, pluginVersionLoaded, pluginHasUpdate, + type PluginStoreSnapshot, type PluginUpdateEntry } from '../src/services/pluginUpdates'; + +const entry: PluginUpdateEntry = { + id: 'reviewed-plugin', name: 'Reviewed plugin', installed: true, registered: true, + enabled: true, effective_enabled: true, installed_version: '1.0.0', version: '1.0.1', + source_id: 'source_123', source_name: 'Reviewed', source_url: 'https://example.org/registry.json', + installed_source_id: 'source_123', install_source_status: 'matched', update_available: true, + platforms: [{ goos: 'darwin', goarch: 'arm64' }], +}; +const snapshot: PluginStoreSnapshot = { plugins_enabled: true, plugins: [entry] }; +const platform = { assetOs: 'darwin', assetArch: 'aarch64' }; + +describe('installed plugin updates', () => { + it('does not advertise alternate sources, assumed identities or new installs', () => { + expect(installedPluginEntries({ ...snapshot, plugins: [entry, + { ...entry, source_id: 'official', install_source_status: 'different' }, + { ...entry, install_source_status: 'assumed', installed_source_id: '' }, + { ...entry, installed: false }, + ] })).toEqual([entry]); + }); + it('pins the exact version and passes source separately from the URL path', () => { + expect(pluginInstallRequest(entry, snapshot, platform)).toEqual({ + path: '/plugin-store/reviewed-plugin/install', body: { version: '1.0.1' }, + options: { query: { source: 'source_123' }, timeoutMs: 300000 }, + }); + }); + it('blocks disabled plugins, missing source auth and unsupported platforms', () => { + expect(pluginUpdateBlock(entry, { ...snapshot, plugins_enabled: false }, platform)).toBe('disabled'); + for (const modified of [{ ...entry, enabled: false }, { ...entry, auth_required: true, auth_configured: false }]) { + expect(() => pluginInstallRequest(modified, { ...snapshot, plugins: [modified] }, platform)).toThrow(); + } + expect(pluginUpdateBlock(entry, snapshot, { assetOs: 'linux', assetArch: 'amd64' })).toBe('platform'); + }); + it('rejects path or version injection before invoking management', () => { + for (const modified of [{ ...entry, id: '../other' }, { ...entry, version: 'latest' }]) { + expect(() => pluginInstallRequest(modified, { ...snapshot, plugins: [modified] }, platform)).toThrow(); + } + }); + it('requires actual registered runtime metadata before claiming the new version is active', () => { + expect(pluginVersionLoaded({ plugins: [{ id: entry.id, registered: true, effective_enabled: true, metadata: { version: '1.0.0' } }] }, entry.id, '1.0.1')).toBe(false); + expect(pluginVersionLoaded({ plugins: [{ id: entry.id, registered: true, effective_enabled: true, metadata: { version: 'v1.0.1' } }] }, entry.id, '1.0.1')).toBe(true); + }); +}); + + +it('offers the exact curated version even when its commit hash sorts lower', () => { + expect(pluginHasUpdate({ ...entry, install_type: 'direct', installed_version: '1.0.0-review.ffabcd0.20260920', version: '1.0.0-review.00abcd0.20260921', update_available: false })).toBe(true); + expect(pluginHasUpdate({ ...entry, install_type: 'direct', installed_version: '1.0.1', version: '1.0.1' })).toBe(false); +}); diff --git a/tests/providerConfig.test.ts b/tests/providerConfig.test.ts index 794b462ca..b4848e59c 100644 --- a/tests/providerConfig.test.ts +++ b/tests/providerConfig.test.ts @@ -773,3 +773,18 @@ describe('API 接入配置合并', () => { expect(Array.from(selected)).toEqual(['model-a', 'model-b']); }); }); + +it('preserves explicit Codex model capabilities when editing provider settings', () => { + const model = { + name: 'deployment-pro', alias: 'work/pro', + 'canonical-model-id': 'gpt-example-pro', + 'max-context-length': 1050000, 'max-completion-tokens': 128000, + 'input-modalities': ['text', 'image'], 'output-modalities': ['text'], + thinking: { levels: ['none', 'low', 'medium', 'high', 'xhigh', 'max'] }, + }; + const result = buildProviderRecord('codex-api-key', { + name: '', apiKey: 'test-key', baseUrl: 'https://gateway.example', priority: '1', + models: [{ name: model.name, alias: model.alias }], + }, { 'api-key': 'test-key', 'base-url': 'https://gateway.example', models: [model] }); + expect(result.models).toEqual([model]); +}); diff --git a/tests/versionManagement.test.ts b/tests/versionManagement.test.ts index 5cbd583f6..f4e50c186 100644 --- a/tests/versionManagement.test.ts +++ b/tests/versionManagement.test.ts @@ -62,9 +62,9 @@ describe('VersionManagement update triggers', () => { }; for (const name of ['checkAppUpdate', 'checkLatest']) { - it(`calls ${name} only from the visit effect and its manual check button`, () => { + it(`calls ${name} from visits, manual checks and explicit channel changes`, () => { const calls = findCalls(name); - expect(calls).toHaveLength(2); + expect(calls).toHaveLength(3); const triggers = calls.map((call) => { let ancestor: ts.Node | undefined = call.parent; while (ancestor) { @@ -75,7 +75,7 @@ describe('VersionManagement update triggers', () => { } return undefined; }); - expect(triggers.sort()).toEqual(['onClick', 'useEffect']); + expect(triggers.sort()).toEqual(['onChange', 'onClick', 'useEffect']); }); } @@ -170,3 +170,9 @@ describe('VersionManagement helper functions', () => { expect(coreUpdateAvailable('1.0.0.0', '1.0.1')).toBe(false); }); }); + + +it('detects a changed reviewed release without ordering commit hashes', () => { + expect(coreUpdateAvailable('7.3.9-review.ffabcd0', '7.3.9-review.00abcd0', true)).toBe(true); + expect(coreUpdateAvailable('7.3.9-review.00abcd0', '7.3.9-review.00abcd0', true)).toBe(false); +});