Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions docs/reviewed-core-updates.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 11 additions & 2 deletions src-tauri/src/app_update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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::<CoreProcessState>();
let _channel_guard = lock_core_operation(core_state.inner())?;
let config = app.state::<GuiConfigState>().snapshot()?;
reviewed_app_update_guard(&config)?;
let token = CancellationToken::new();
state.start(token.clone())?;
let task = state.snapshot();
Expand Down
49 changes: 48 additions & 1 deletion src-tauri/src/core_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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(),
})
Expand All @@ -184,6 +188,7 @@ pub(crate) async fn install_bundled_core(
let state = app.state::<CoreDownloadState>();
let process_state = app.state::<CoreProcessState>();
let gui_config_state = app.state::<GuiConfigState>();
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();
Expand Down Expand Up @@ -309,12 +314,16 @@ pub(crate) async fn install_core_version(
window: tauri::Window,
version: Option<String>,
) -> Result<CoreInstallResult, String> {
if !app.state::<GuiConfigState>().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::<CoreProcessState>().inner())?;
let state = app.state::<CoreDownloadState>();
let process_state = app.state::<CoreProcessState>();
let gui_config_state = app.state::<GuiConfigState>();
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())?;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 = || {
Expand Down Expand Up @@ -2050,6 +2083,20 @@ pub(crate) fn validate_downloaded_asset(
)
}

pub(crate) fn validate_download_chunk_size(
downloaded: u64,
incoming: u64,
expected: Option<u64>,
) -> 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<u64>,
Expand Down
7 changes: 7 additions & 0 deletions src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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::*;
Expand Down Expand Up @@ -424,6 +426,7 @@ struct CoreStatus {
struct CoreLatest {
version: String,
asset_name: String,
reviewed: bool,
}

#[derive(Clone, Serialize)]
Expand Down Expand Up @@ -622,6 +625,7 @@ struct GuiConfigFile {
proxy_url: String,
proxy_override: bool,
download_source: VersionDownloadSource,
reviewed_core_manifest_url: String,
custom_download_mirrors: Vec<String>,
active_custom_download_mirror: String,
prefer_gitcode_downloads: bool,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
52 changes: 51 additions & 1 deletion src-tauri/src/management_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<serde_json::Value, String> {
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::<super::CoreProcessState>().inner())?;
if app.state::<super::AppUpdateState>().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(&current, 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<serde_json::Value, String> {
let method = match request.method.trim().to_ascii_uppercase().as_str() {
"GET" => reqwest::Method::GET,
"POST" => reqwest::Method::POST,
Expand All @@ -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);
Expand Down Expand Up @@ -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());
}
}
Loading