From 14e97d2b3b326cb095fd5946215679bcbf62f535 Mon Sep 17 00:00:00 2001 From: GCWing Date: Sun, 6 Sep 2026 18:22:24 +0800 Subject: [PATCH] fix(config): hand off installer preferences safely --- OpenBitFun-Installer/src-tauri/Cargo.toml | 5 +- .../src-tauri/src/installer/commands.rs | 361 ++++++++--------- .../core/src/service/config/manager.rs | 372 ++++++++++++++---- .../core/src/service/config/service.rs | 97 +---- .../assembly/core/src/service/config/types.rs | 2 + .../src/installer_config_handoff.rs | 34 ++ src/crates/contracts/core-types/src/lib.rs | 1 + 7 files changed, 507 insertions(+), 365 deletions(-) create mode 100644 src/crates/contracts/core-types/src/installer_config_handoff.rs diff --git a/OpenBitFun-Installer/src-tauri/Cargo.toml b/OpenBitFun-Installer/src-tauri/Cargo.toml index ae5b641f5d..e76b8edcff 100644 --- a/OpenBitFun-Installer/src-tauri/Cargo.toml +++ b/OpenBitFun-Installer/src-tauri/Cargo.toml @@ -27,14 +27,17 @@ anyhow = "1.0" log = "0.4" dirs = "5.0" zip = "0.6" -chrono = "0.4" openbitfun-core-types = { path = "../../src/crates/contracts/core-types" } openbitfun-ai-adapters = { path = "../../src/crates/adapters/ai-adapters" } +openbitfun-services-core = { path = "../../src/crates/services/services-core", features = ["json-io"] } [target.'cfg(windows)'.dependencies] winreg = "0.52" mslnk = "0.1" +[dev-dependencies] +tempfile = "3" + [lints.rust] unsafe_op_in_unsafe_fn = "warn" unexpected_cfgs = "warn" diff --git a/OpenBitFun-Installer/src-tauri/src/installer/commands.rs b/OpenBitFun-Installer/src-tauri/src/installer/commands.rs index e5e0925f88..1576058fab 100644 --- a/OpenBitFun-Installer/src-tauri/src/installer/commands.rs +++ b/OpenBitFun-Installer/src-tauri/src/installer/commands.rs @@ -7,11 +7,17 @@ use super::types::{ RemoteModelInfo, }; use super::MAIN_APP_EXE; -use openbitfun_core_types::product_identity::{data_namespace, hidden_data_directory}; +use openbitfun_core_types::{ + installer_config_handoff::{ + InstallerConfigHandoff, InstallerModelHandoff, INSTALLER_CONFIG_HANDOFF_FILE_NAME, + }, + product_identity::{data_namespace, hidden_data_directory}, +}; +use openbitfun_services_core::json_store::JsonFileStore; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use sha2::{Digest, Sha256}; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::fs::File; use std::io::{Cursor, Read}; use std::path::{Path, PathBuf}; @@ -37,7 +43,6 @@ const REQUIRED_PAYLOAD_FILES: [&str; 5] = [ "resources/worker_host.js", ]; const INSTALLER_STATE_FILE: &str = "installer-state.json"; -const DEFAULT_MODEL_CONTEXT_WINDOW: u64 = 200_000; const EMBEDDED_PAYLOAD_ZIP: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/embedded_payload.zip")); @@ -471,7 +476,7 @@ pub(crate) async fn start_installation( #[cfg(target_os = "windows")] let mut windows_state = WindowsInstallState::default(); - let result: Result<(), String> = (|| { + let result: Result<(), String> = async { // Step 1: Create target directory emit_progress(&window, "prepare", 5, "Creating installation directory..."); std::fs::create_dir_all(&install_path) @@ -621,14 +626,14 @@ pub(crate) async fn start_installation( } } - // Step 4: Save first-launch language preference for OpenBitFun. + // Step 4: Pass startup preferences to the desktop configuration manager, + // which remains the only writer of the canonical app.json. emit_progress(&window, "config", 92, "Applying startup preferences..."); apply_first_launch_language(&options.app_language) - .map_err(|e| format!("Failed to apply startup preferences: {}", e))?; - // Step 5: Done - emit_progress(&window, "complete", 100, "Installation complete!"); - Ok(()) - })(); + .await + .map_err(|e| format!("Failed to apply startup preferences: {}", e)) + } + .await; if let Err(err) = result { #[cfg(target_os = "windows")] @@ -639,6 +644,7 @@ pub(crate) async fn start_installation( } persist_last_install_path(&install_path); + emit_progress(&window, "complete", 100, "Installation complete!"); Ok(()) } @@ -838,7 +844,7 @@ pub(crate) fn close_installer(window: Window) { /// Save theme preference for first launch (called after installation). #[tauri::command] -pub(crate) fn set_theme_preference(theme_preference: String) -> Result<(), String> { +pub(crate) async fn set_theme_preference(theme_preference: String) -> Result<(), String> { let allowed = [ "system", "openbitfun-dark", @@ -854,27 +860,14 @@ pub(crate) fn set_theme_preference(theme_preference: String) -> Result<(), Strin return Err("Unsupported theme preference".to_string()); } - let app_config_file = ensure_app_config_path()?; - let mut root = read_or_create_root_config(&app_config_file)?; - - let root_obj = root - .as_object_mut() - .ok_or_else(|| "Invalid root config object".to_string())?; - - let themes_obj = root_obj - .entry("themes".to_string()) - .or_insert_with(|| Value::Object(Map::new())) - .as_object_mut() - .ok_or_else(|| "Invalid themes config object".to_string())?; - themes_obj.insert("current".to_string(), Value::String(theme_preference)); - - write_root_config(&app_config_file, &root) + let handoff_path = installer_config_handoff_path()?; + apply_theme_preference_at(&handoff_path, &theme_preference).await } /// Save default model configuration for first launch (called after installation). #[tauri::command] -pub(crate) fn set_model_config(model_config: ModelConfig) -> Result<(), String> { - apply_first_launch_model(&model_config) +pub(crate) async fn set_model_config(model_config: ModelConfig) -> Result<(), String> { + apply_first_launch_model(&model_config).await } /// Validate model configuration connectivity from installer (same stack as desktop `test_ai_config_connection`). @@ -1248,22 +1241,22 @@ fn directory_has_entries(path: &Path) -> Result { .is_some()) } -fn ensure_app_config_path() -> Result { +fn ensure_config_directory() -> Result { let config_root = dirs::config_dir() .ok_or_else(|| "Failed to get user config directory".to_string())? .join(data_namespace()) .join("config"); std::fs::create_dir_all(&config_root) .map_err(|e| format!("Failed to create OpenBitFun config directory: {}", e))?; - Ok(config_root.join("app.json")) + Ok(config_root) +} + +fn installer_config_handoff_path() -> Result { + Ok(ensure_config_directory()?.join(INSTALLER_CONFIG_HANDOFF_FILE_NAME)) } fn installer_state_path() -> Result { - let app_config_file = ensure_app_config_path()?; - let parent = app_config_file - .parent() - .ok_or_else(|| "Invalid app config path".to_string())?; - Ok(parent.join(INSTALLER_STATE_FILE)) + Ok(ensure_config_directory()?.join(INSTALLER_STATE_FILE)) } fn read_last_install_path() -> Option { @@ -1301,7 +1294,17 @@ fn persist_last_install_path(install_path: &Path) { } fn read_saved_app_language() -> Option { - let app_config_file = ensure_app_config_path().ok()?; + let config_directory = ensure_config_directory().ok()?; + let handoff_path = config_directory.join(INSTALLER_CONFIG_HANDOFF_FILE_NAME); + if let Ok(content) = std::fs::read_to_string(&handoff_path) { + if let Ok(handoff) = serde_json::from_str::(&content) { + if let Some(language) = handoff.language.as_deref().and_then(normalize_app_language) { + return Some(language.to_string()); + } + } + } + + let app_config_file = config_directory.join("app.json"); if !app_config_file.exists() { return None; } @@ -1329,53 +1332,55 @@ fn normalize_app_language(lang: &str) -> Option<&'static str> { }) } -fn read_or_create_root_config(app_config_file: &Path) -> Result { - let mut root = if app_config_file.exists() { - let content = std::fs::read_to_string(app_config_file) - .map_err(|e| format!("Failed to read app config: {}", e))?; - serde_json::from_str(&content).unwrap_or_else(|_| Value::Object(Map::new())) - } else { - Value::Object(Map::new()) - }; - - if !root.is_object() { - root = Value::Object(Map::new()); - } - Ok(root) +async fn update_installer_handoff_at( + handoff_path: &Path, + update: impl FnOnce(&mut InstallerConfigHandoff), +) -> Result<(), String> { + JsonFileStore + .update_locked(handoff_path, InstallerConfigHandoff::default(), update) + .await + .map(|_| ()) + .map_err(|error| format!("Failed to write installer handoff: {error}")) } -fn write_root_config(app_config_file: &Path, root: &Value) -> Result<(), String> { - let formatted = serde_json::to_string_pretty(root) - .map_err(|e| format!("Failed to serialize app config: {}", e))?; - std::fs::write(app_config_file, formatted) - .map_err(|e| format!("Failed to write app config: {}", e)) +async fn apply_first_launch_language(app_language: &str) -> Result<(), String> { + let handoff_path = installer_config_handoff_path()?; + apply_first_launch_language_at(&handoff_path, app_language).await } -fn apply_first_launch_language(app_language: &str) -> Result<(), String> { +async fn apply_first_launch_language_at( + handoff_path: &Path, + app_language: &str, +) -> Result<(), String> { let Some(app_language) = normalize_app_language(app_language) else { return Err("Unsupported app language".to_string()); }; - let app_config_file = ensure_app_config_path()?; - let mut root = read_or_create_root_config(&app_config_file)?; - - let root_obj = root - .as_object_mut() - .ok_or_else(|| "Invalid root config object".to_string())?; - let app_obj = root_obj - .entry("app".to_string()) - .or_insert_with(|| Value::Object(Map::new())) - .as_object_mut() - .ok_or_else(|| "Invalid app config object".to_string())?; - app_obj.insert( - "language".to_string(), - Value::String(app_language.to_string()), - ); + update_installer_handoff_at(handoff_path, |handoff| { + handoff.language = Some(app_language.to_string()); + }) + .await +} - write_root_config(&app_config_file, &root) +async fn apply_theme_preference_at( + handoff_path: &Path, + theme_preference: &str, +) -> Result<(), String> { + update_installer_handoff_at(handoff_path, |handoff| { + handoff.appearance_selection = Some(theme_preference.to_string()); + }) + .await } -fn apply_first_launch_model(model: &ModelConfig) -> Result<(), String> { +async fn apply_first_launch_model(model: &ModelConfig) -> Result<(), String> { + let handoff_path = installer_config_handoff_path()?; + apply_first_launch_model_at(&handoff_path, model).await +} + +async fn apply_first_launch_model_at( + handoff_path: &Path, + model: &ModelConfig, +) -> Result<(), String> { if model.provider.trim().is_empty() || model.api_key.trim().is_empty() || model.base_url.trim().is_empty() @@ -1384,135 +1389,58 @@ fn apply_first_launch_model(model: &ModelConfig) -> Result<(), String> { return Ok(()); } - let app_config_file = ensure_app_config_path()?; - let mut root = read_or_create_root_config(&app_config_file)?; - let root_obj = root - .as_object_mut() - .ok_or_else(|| "Invalid root config object".to_string())?; - - let ai_obj = root_obj - .entry("ai".to_string()) - .or_insert_with(|| Value::Object(Map::new())) - .as_object_mut() - .ok_or_else(|| "Invalid ai config object".to_string())?; - - let model_id = format!( - "installer_{}_{}", - model.provider, - chrono::Utc::now().timestamp() - ); - let display_name = model - .config_name - .as_deref() - .map(str::trim) - .filter(|v| !v.is_empty()) - .map(|v| v.to_string()) - .unwrap_or_else(|| format!("{} - {}", model.provider, model.model_name)); - let _ = parse_custom_request_body(&model.custom_request_body)?; - let stored_fmt = storage_format(model); - let request_url = resolve_stored_request_url(model.base_url.trim(), &stored_fmt); - let mut model_map = Map::new(); - model_map.insert("id".to_string(), Value::String(model_id.clone())); - model_map.insert("name".to_string(), Value::String(display_name)); - model_map.insert("provider".to_string(), Value::String(stored_fmt)); - model_map.insert( - "model_name".to_string(), - Value::String(model.model_name.trim().to_string()), - ); - model_map.insert( - "base_url".to_string(), - Value::String(model.base_url.trim().to_string()), - ); - model_map.insert("request_url".to_string(), Value::String(request_url)); - model_map.insert( - "api_key".to_string(), - Value::String(model.api_key.trim().to_string()), - ); - model_map.insert("enabled".to_string(), Value::Bool(true)); - model_map.insert( - "category".to_string(), - Value::String("general_chat".to_string()), - ); - model_map.insert( - "capabilities".to_string(), - Value::Array(vec![ - Value::String("text_chat".to_string()), - Value::String("function_calling".to_string()), - ]), - ); - model_map.insert("recommended_for".to_string(), Value::Array(Vec::new())); - model_map.insert("metadata".to_string(), Value::Null); - model_map.insert("inline_think_in_text".to_string(), Value::Bool(false)); - model_map.insert( - "context_window".to_string(), - Value::Number(DEFAULT_MODEL_CONTEXT_WINDOW.into()), - ); - - if let Some(skip_ssl_verify) = model.skip_ssl_verify { - model_map.insert("skip_ssl_verify".to_string(), Value::Bool(skip_ssl_verify)); - } - if let Some(headers) = &model.custom_headers { - let mut header_map = Map::new(); - for (key, value) in headers { - let key_trimmed = key.trim(); - if key_trimmed.is_empty() { - continue; - } - header_map.insert( - key_trimmed.to_string(), - Value::String(value.trim().to_string()), - ); - } - if !header_map.is_empty() { - model_map.insert("custom_headers".to_string(), Value::Object(header_map)); - let mode = model - .custom_headers_mode - .as_deref() - .unwrap_or("merge") - .trim() - .to_ascii_lowercase(); - if mode == "merge" || mode == "replace" { - model_map.insert("custom_headers_mode".to_string(), Value::String(mode)); - } - } - } - if let Some(raw) = &model.custom_request_body { - let trimmed = raw.trim(); - if !trimmed.is_empty() { - model_map.insert( - "custom_request_body".to_string(), - Value::String(trimmed.to_string()), - ); - } + let provider = storage_format(model); + if provider.is_empty() { + return Err("Model format is required".to_string()); } + let custom_headers = model.custom_headers.as_ref().and_then(|headers| { + let headers = headers + .iter() + .filter_map(|(key, value)| { + let key = key.trim(); + (!key.is_empty()).then(|| (key.to_string(), value.trim().to_string())) + }) + .collect::>(); + (!headers.is_empty()).then_some(headers) + }); + let custom_headers_mode = custom_headers.as_ref().and_then(|_| { + let mode = model + .custom_headers_mode + .as_deref() + .unwrap_or("merge") + .trim() + .to_ascii_lowercase(); + matches!(mode.as_str(), "merge" | "replace").then_some(mode) + }); + let model_handoff = InstallerModelHandoff { + name: model + .config_name + .as_deref() + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("{} - {}", model.provider, model.model_name)), + provider: provider.clone(), + model_name: model.model_name.trim().to_string(), + base_url: model.base_url.trim().to_string(), + request_url: resolve_stored_request_url(&model.base_url, &provider), + api_key: model.api_key.trim().to_string(), + custom_headers, + custom_headers_mode, + skip_ssl_verify: model.skip_ssl_verify.unwrap_or(false), + custom_request_body: model + .custom_request_body + .as_deref() + .map(str::trim) + .filter(|body| !body.is_empty()) + .map(str::to_string), + }; - let model_json = Value::Object(model_map); - - let models_entry = ai_obj - .entry("models".to_string()) - .or_insert_with(|| Value::Array(Vec::new())); - if !models_entry.is_array() { - *models_entry = Value::Array(Vec::new()); - } - let models_arr = models_entry - .as_array_mut() - .ok_or_else(|| "Invalid ai.models type".to_string())?; - models_arr.push(model_json); - - let default_models_entry = ai_obj - .entry("default_models".to_string()) - .or_insert_with(|| Value::Object(Map::new())); - if !default_models_entry.is_object() { - *default_models_entry = Value::Object(Map::new()); - } - let default_models_obj = default_models_entry - .as_object_mut() - .ok_or_else(|| "Invalid ai.default_models type".to_string())?; - default_models_obj.insert("primary".to_string(), Value::String(model_id.clone())); - default_models_obj.insert("fast".to_string(), Value::String(model_id)); - - write_root_config(&app_config_file, &root) + update_installer_handoff_at(handoff_path, move |handoff| { + handoff.model = Some(model_handoff); + }) + .await } fn preflight_validate_payload_zip_bytes( @@ -1940,9 +1868,12 @@ fn rollback_installation(install_path: &Path, install_dir_was_absent: bool) { #[cfg(test)] mod tests { use super::{ - normalize_app_language, preflight_validate_payload_zip_archive, - INSTALLER_APP_LANGUAGE_ALIASES_BY_PRIORITY, MAIN_APP_EXE, MIN_WINDOWS_APP_EXE_BYTES, - REQUIRED_PAYLOAD_FILES, + apply_first_launch_language_at, apply_theme_preference_at, normalize_app_language, + preflight_validate_payload_zip_archive, INSTALLER_APP_LANGUAGE_ALIASES_BY_PRIORITY, + MAIN_APP_EXE, MIN_WINDOWS_APP_EXE_BYTES, REQUIRED_PAYLOAD_FILES, + }; + use openbitfun_core_types::installer_config_handoff::{ + InstallerConfigHandoff, INSTALLER_CONFIG_HANDOFF_FILE_NAME, }; use std::io::{Cursor, Write}; use zip::write::FileOptions; @@ -1993,6 +1924,34 @@ mod tests { assert_eq!(normalize_app_language(""), None); } + #[test] + fn installer_preferences_do_not_create_app_config() { + tauri::async_runtime::block_on(async { + let temp = tempfile::tempdir().unwrap(); + let config_dir = temp.path().join("config"); + let handoff_path = config_dir.join(INSTALLER_CONFIG_HANDOFF_FILE_NAME); + + apply_first_launch_language_at(&handoff_path, "en") + .await + .unwrap(); + apply_theme_preference_at(&handoff_path, "openbitfun-light") + .await + .unwrap(); + + assert!(!config_dir.join("app.json").exists()); + let handoff: InstallerConfigHandoff = serde_json::from_slice( + &std::fs::read(&handoff_path).expect("handoff should be persisted"), + ) + .expect("handoff should be valid JSON"); + assert_eq!(handoff.language.as_deref(), Some("en-US")); + assert_eq!( + handoff.appearance_selection.as_deref(), + Some("openbitfun-light") + ); + assert!(handoff.model.is_none()); + }); + } + #[test] fn payload_zip_preflight_scans_entries_after_main_executable() { let mut bytes = Cursor::new(Vec::new()); diff --git a/src/crates/assembly/core/src/service/config/manager.rs b/src/crates/assembly/core/src/service/config/manager.rs index e2d62e0a15..de3ac2b3d5 100644 --- a/src/crates/assembly/core/src/service/config/manager.rs +++ b/src/crates/assembly/core/src/service/config/manager.rs @@ -8,7 +8,12 @@ use super::types::*; use crate::infrastructure::{try_get_path_manager_arc, PathManager}; use crate::util::errors::*; use log::{debug, info, warn}; -use openbitfun_core_types::product_identity; +use openbitfun_core_types::{ + installer_config_handoff::{ + InstallerConfigHandoff, InstallerModelHandoff, INSTALLER_CONFIG_HANDOFF_FILE_NAME, + }, + product_identity, +}; use openbitfun_services_core::json_store::JsonFileStore; use serde::{Deserialize, Serialize}; @@ -27,28 +32,8 @@ fn invalid_config_error(context: &str, result: &ConfigValidationResult) -> OpenB OpenBitFunError::validation(format!("{context}: {messages}")) } -const MIN_OPENBITFUN_CONFIG_VERSION: (u64, u64, u64) = (1, 0, 0); - -fn parse_semver_floor(version: &str) -> Option<(u64, u64, u64)> { - let without_build = version.split_once('+').map_or(version, |(value, _)| value); - let core = without_build - .split_once('-') - .map_or(without_build, |(value, _)| value); - let mut parts = core.split('.'); - let parsed = ( - parts.next()?.parse().ok()?, - parts.next()?.parse().ok()?, - parts.next()?.parse().ok()?, - ); - if parts.next().is_some() { - return None; - } - Some(parsed) -} - -pub(crate) fn validate_openbitfun_product_version( +pub(crate) fn validate_openbitfun_product_identity( persisted_product_id: &str, - version: &str, context: &str, ) -> OpenBitFunResult<()> { let expected_product_id = product_identity::product_id(); @@ -58,25 +43,13 @@ pub(crate) fn validate_openbitfun_product_version( ))); } - let Some(parsed) = parse_semver_floor(version) else { - return Err(OpenBitFunError::validation(format!( - "{context} version '{version}' is not a valid OpenBitFun version" - ))); - }; - // This floor separates product generations, not release-channel precedence. - // OpenBitFun 1.0 prereleases write the same product identity and schema. - if parsed < MIN_OPENBITFUN_CONFIG_VERSION { - return Err(OpenBitFunError::validation(format!( - "{context} version '{version}' predates OpenBitFun 1.0.0" - ))); - } Ok(()) } pub(crate) fn validate_current_config_value(value: &Value, context: &str) -> OpenBitFunResult<()> { - let root = value.as_object().ok_or_else(|| { - OpenBitFunError::validation(format!("{context} must be a JSON object")) - })?; + let root = value + .as_object() + .ok_or_else(|| OpenBitFunError::validation(format!("{context} must be a JSON object")))?; let product_id = root .get("product_id") .and_then(Value::as_str) @@ -98,20 +71,17 @@ pub(crate) fn validate_current_config_value(value: &Value, context: &str) -> Ope "{context} schema_version must be {CURRENT_CONFIG_SCHEMA_VERSION}, found {schema_version}" ))); } - let version = root - .get("version") - .and_then(Value::as_str) - .ok_or_else(|| { - OpenBitFunError::validation(format!( - "{context} is missing required string field 'version'" - )) - })?; + root.get("version").and_then(Value::as_str).ok_or_else(|| { + OpenBitFunError::validation(format!( + "{context} is missing required string field 'version'" + )) + })?; if !root.contains_key("last_modified") { return Err(OpenBitFunError::validation(format!( "{context} is missing required field 'last_modified'" ))); } - validate_openbitfun_product_version(product_id, version, context)?; + validate_openbitfun_product_identity(product_id, context)?; reject_retired_config_fields(root, context) } @@ -134,9 +104,7 @@ fn reject_retired_config_fields( if app .get("ai_experience") .and_then(Value::as_object) - .is_some_and(|ai_experience| { - ai_experience.contains_key("agent_companion_display_mode") - }) + .is_some_and(|ai_experience| ai_experience.contains_key("agent_companion_display_mode")) { return Err(retired_config_field( context, @@ -195,6 +163,31 @@ fn reject_retired_config_fields( Ok(()) } +const INSTALLER_MODEL_ID: &str = "installer:default"; +const INSTALLER_MODEL_CONTEXT_WINDOW: u32 = 200_000; + +fn model_from_installer_handoff(model: InstallerModelHandoff) -> AIModelConfig { + AIModelConfig { + id: INSTALLER_MODEL_ID.to_string(), + name: model.name, + provider: model.provider, + model_name: model.model_name, + base_url: model.base_url, + request_url: Some(model.request_url), + api_key: model.api_key, + context_window: Some(INSTALLER_MODEL_CONTEXT_WINDOW), + enabled: true, + category: ModelCategory::GeneralChat, + capabilities: vec![ModelCapability::TextChat, ModelCapability::FunctionCalling], + inline_think_in_text: false, + custom_headers: model.custom_headers, + custom_headers_mode: model.custom_headers_mode, + skip_ssl_verify: model.skip_ssl_verify, + custom_request_body: model.custom_request_body, + ..AIModelConfig::default() + } +} + fn reject_protected_metadata_path(path: &str) -> OpenBitFunResult<()> { if matches!( path, @@ -411,6 +404,10 @@ impl ConfigManager { self.create_default_config().await?; } + if let Err(error) = self.consume_installer_config_handoff().await { + warn!("Could not apply installer configuration handoff: {error}"); + } + Ok(()) } @@ -425,7 +422,80 @@ impl ConfigManager { Ok(()) } - /// Loads an existing OpenBitFun config without rewriting or repairing it. + async fn consume_installer_config_handoff(&mut self) -> OpenBitFunResult<()> { + let handoff_file = self.config_dir.join(INSTALLER_CONFIG_HANDOFF_FILE_NAME); + if !handoff_file.exists() { + return Ok(()); + } + + let store = JsonFileStore; + let _cross_process_lock = store + .acquire_cross_process_lock(&handoff_file) + .await + .map_err(|error| OpenBitFunError::config(error.to_string()))?; + let Some(handoff) = store + .read_optional::(&handoff_file) + .await + .map_err(|error| OpenBitFunError::config(error.to_string()))? + else { + return Ok(()); + }; + + let mut config = self.config.clone(); + if let Some(language) = handoff.language { + config.app.language = language; + } + if let Some(selection) = handoff.appearance_selection { + config.appearance.selection = selection; + } + if let Some(model) = handoff.model { + let model = model_from_installer_handoff(model); + if let Some(existing) = config + .ai + .models + .iter_mut() + .find(|existing| existing.id == INSTALLER_MODEL_ID) + { + *existing = model; + } else { + config.ai.models.push(model); + } + config.ai.default_models.primary = Some(INSTALLER_MODEL_ID.to_string()); + config.ai.default_models.fast = Some(INSTALLER_MODEL_ID.to_string()); + } + + let mut diagnostics = normalize_typed_config(&mut config); + diagnostics.extend(reconcile_model_references(&mut config).diagnostics); + let validation_result = self.providers.validate_config(&config).await?; + if !validation_result.valid { + return Err(invalid_config_error( + "Invalid installer configuration handoff", + &validation_result, + )); + } + + config.product_id = product_identity::product_id().to_string(); + config.schema_version = CURRENT_CONFIG_SCHEMA_VERSION; + config.version = env!("CARGO_PKG_VERSION").to_string(); + config.last_modified = chrono::Utc::now(); + self.persist_config(&config).await?; + self.config = config; + self.load_diagnostics.extend(diagnostics); + + match fs::remove_file(&handoff_file).await { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => warn!( + "Applied installer configuration but could not remove handoff file: path={}, error={}", + handoff_file.display(), + error + ), + } + Ok(()) + } + + /// Loads an existing OpenBitFun config, with a narrow repair for the sparse + /// document written by released standalone installers. async fn load_existing_config(&mut self) -> OpenBitFunResult<()> { let content = fs::read_to_string(&self.config_file) .await @@ -434,7 +504,18 @@ impl ConfigManager { let config_value: Value = serde_json::from_str(&content).map_err(|error| { OpenBitFunError::config(format!("Failed to parse config file as JSON: {error}")) })?; - validate_current_config_value(&config_value, "Configuration file")?; + if let Err(contract_error) = + validate_current_config_value(&config_value, "Configuration file") + { + if let Some(config) = self + .try_repair_sparse_installer_config(&content, &config_value) + .await? + { + self.config = config; + return Ok(()); + } + return Err(contract_error); + } let config: GlobalConfig = serde_json::from_value(config_value).map_err(|error| { OpenBitFunError::config(format!("Failed to deserialize config file: {error}")) @@ -453,6 +534,62 @@ impl ConfigManager { Ok(()) } + async fn try_repair_sparse_installer_config( + &mut self, + content: &str, + config_value: &Value, + ) -> OpenBitFunResult> { + let Some(root) = config_value.as_object() else { + return Ok(None); + }; + if !root + .keys() + .all(|key| matches!(key.as_str(), "app" | "ai" | "themes")) + { + return Ok(None); + } + let Some(app) = root.get("app").and_then(Value::as_object) else { + return Ok(None); + }; + let Some(language) = app.get("language").and_then(Value::as_str) else { + return Ok(None); + }; + + let mut config = GlobalConfig::default(); + config.app.language = language.to_string(); + if let Some(themes) = root.get("themes") { + let Some(selection) = themes.get("current").and_then(Value::as_str) else { + return Ok(None); + }; + config.appearance.selection = selection.to_string(); + } + if let Some(ai) = root.get("ai") { + let Ok(ai) = serde_json::from_value(ai.clone()) else { + return Ok(None); + }; + config.ai = ai; + } + + let mut diagnostics = normalize_typed_config(&mut config); + diagnostics.extend(reconcile_model_references(&mut config).diagnostics); + let validation_result = self.providers.validate_config(&config).await?; + if !validation_result.valid { + return Ok(None); + } + + let backup = self + .backup_raw_config(content, "installer-config-repair") + .await?; + self.persist_config(&config).await?; + self.load_diagnostics = diagnostics; + info!( + "Repaired sparse installer configuration: config={}, backup={}", + self.config_file.display(), + backup.display() + ); + Ok(Some(config)) + } + /// Saves the configuration file. async fn save_config(&self) -> OpenBitFunResult<()> { self.persist_config(&self.config).await @@ -903,8 +1040,44 @@ pub struct ConfigStatistics { #[cfg(test)] mod tests { - use super::{config_value_for_persistence, validate_current_config_value}; + use super::{ + config_value_for_persistence, validate_current_config_value, ConfigManager, + ConfigManagerSettings, INSTALLER_MODEL_ID, + }; + use crate::infrastructure::PathManager; use crate::service::config::types::GlobalConfig; + use openbitfun_core_types::installer_config_handoff::{ + InstallerConfigHandoff, InstallerModelHandoff, INSTALLER_CONFIG_HANDOFF_FILE_NAME, + }; + use std::sync::Arc; + + fn manager_settings(path_manager: Arc) -> ConfigManagerSettings { + ConfigManagerSettings { + path_manager: Some(path_manager), + auto_save: true, + backup_count: 5, + } + } + + fn complete_installer_handoff() -> InstallerConfigHandoff { + InstallerConfigHandoff { + language: Some("en-US".to_string()), + appearance_selection: Some("openbitfun-light".to_string()), + model: Some(InstallerModelHandoff { + name: "Installer model".to_string(), + provider: "openai".to_string(), + model_name: "fixture-model".to_string(), + base_url: "https://example.com/v1".to_string(), + request_url: "https://example.com/v1/chat/completions".to_string(), + api_key: "fixture-secret".to_string(), + custom_headers: None, + custom_headers_mode: None, + skip_ssl_verify: false, + custom_request_body: None, + }), + ..InstallerConfigHandoff::default() + } + } #[test] fn current_config_contract_requires_openbitfun_identity_and_format() { @@ -912,9 +1085,12 @@ mod tests { validate_current_config_value(¤t, "test config").unwrap(); for (field, value, expected) in [ - ("product_id", serde_json::json!("other-product"), "product_id"), + ( + "product_id", + serde_json::json!("other-product"), + "product_id", + ), ("schema_version", serde_json::json!(0), "schema_version"), - ("version", serde_json::json!("0.9.9"), "predates OpenBitFun 1.0.0"), ] { let mut invalid = current.clone(); invalid[field] = value; @@ -935,29 +1111,79 @@ mod tests { } #[test] - fn current_config_contract_accepts_openbitfun_prerelease_versions() { - for version in [ - "1.0.0-beta.1", - "1.0.0-beta.2+build.7", - "1.0.0-nightly.20260906", - "1.0.0-rc.1", - "1.0.0", - "1.0.1-beta.1", - ] { - let mut current = serde_json::to_value(GlobalConfig::default()).unwrap(); - current["version"] = serde_json::json!(version); - validate_current_config_value(¤t, "test config") - .unwrap_or_else(|error| panic!("{version}: {error}")); - } + fn application_version_is_informational_metadata() { + let mut current = serde_json::to_value(GlobalConfig::default()).unwrap(); + current["version"] = serde_json::json!("1.0.0-beta.1"); + validate_current_config_value(¤t, "test config").unwrap(); + } - for version in ["0.2.19", "0.9.9-beta.1", "0.9.9+build.7"] { - let mut legacy = serde_json::to_value(GlobalConfig::default()).unwrap(); - legacy["version"] = serde_json::json!(version); - let error = validate_current_config_value(&legacy, "test config").unwrap_err(); - assert!(error.to_string().contains("predates OpenBitFun 1.0.0")); + #[tokio::test] + async fn installer_handoff_is_applied_idempotently() { + let temp = tempfile::tempdir().unwrap(); + let path_manager = Arc::new(PathManager::with_user_root_for_tests( + temp.path().join("handoff"), + )); + path_manager.initialize_user_directories().await.unwrap(); + let handoff_path = path_manager + .user_config_dir() + .join(INSTALLER_CONFIG_HANDOFF_FILE_NAME); + let handoff = complete_installer_handoff(); + + for _ in 0..2 { + tokio::fs::write(&handoff_path, serde_json::to_vec(&handoff).unwrap()) + .await + .unwrap(); + let manager = ConfigManager::new(manager_settings(path_manager.clone())) + .await + .unwrap(); + assert_eq!(manager.config.app.language, "en-US"); + assert_eq!(manager.config.appearance.selection, "openbitfun-light"); + assert_eq!( + manager + .config + .ai + .models + .iter() + .filter(|model| model.id == INSTALLER_MODEL_ID) + .count(), + 1 + ); + assert!(!handoff_path.exists()); } } + #[tokio::test] + async fn sparse_installer_config_is_backed_up_and_repaired() { + let temp = tempfile::tempdir().unwrap(); + let path_manager = Arc::new(PathManager::with_user_root_for_tests( + temp.path().join("repair"), + )); + path_manager.initialize_user_directories().await.unwrap(); + let original = r#"{ + "app": { "language": "en-US" }, + "themes": { "current": "openbitfun-midnight" } +}"#; + tokio::fs::write(path_manager.app_config_file(), original) + .await + .unwrap(); + + let manager = ConfigManager::new(manager_settings(path_manager.clone())) + .await + .unwrap(); + assert_eq!(manager.config.app.language, "en-US"); + assert_eq!(manager.config.appearance.selection, "openbitfun-midnight"); + assert_eq!( + manager.config.schema_version, + super::CURRENT_CONFIG_SCHEMA_VERSION + ); + + let mut backups = tokio::fs::read_dir(path_manager.user_config_dir().join("backups")) + .await + .unwrap(); + let backup = backups.next_entry().await.unwrap().unwrap().path(); + assert_eq!(tokio::fs::read_to_string(backup).await.unwrap(), original); + } + #[test] fn persistence_omits_default_memories_config() { let config = GlobalConfig::default(); diff --git a/src/crates/assembly/core/src/service/config/service.rs b/src/crates/assembly/core/src/service/config/service.rs index 0d48ac5150..760a39b523 100644 --- a/src/crates/assembly/core/src/service/config/service.rs +++ b/src/crates/assembly/core/src/service/config/service.rs @@ -3,7 +3,7 @@ //! Provides comprehensive configuration management functionality. use super::manager::{ - validate_current_config_value, validate_openbitfun_product_version, ConfigManager, + validate_current_config_value, validate_openbitfun_product_identity, ConfigManager, ConfigManagerSettings, ConfigStatistics, }; use super::types::*; @@ -71,11 +71,7 @@ enum ConfigImportSource { } fn validate_config_export(export: &ConfigExport) -> OpenBitFunResult<()> { - validate_openbitfun_product_version( - &export.product_id, - &export.version, - "Configuration export", - )?; + validate_openbitfun_product_identity(&export.product_id, "Configuration export")?; if export.format_version != CURRENT_CONFIG_EXPORT_FORMAT_VERSION { return Err(OpenBitFunError::validation(format!( "Configuration export format_version must be {CURRENT_CONFIG_EXPORT_FORMAT_VERSION}, found {}", @@ -1043,85 +1039,6 @@ mod tests { } } - #[tokio::test] - async fn prerelease_config_survives_load_save_and_restart() { - for version in ["1.0.0-beta.1", "1.0.0-nightly.20260906"] { - let name = "prerelease-config-restart"; - let (service, dir) = test_service(name).await; - let mut config: GlobalConfig = service.get_config(None).await.unwrap(); - config.version = version.to_string(); - config.app.language = "zh-CN".to_string(); - drop(service); - - let config_file = dir.path().join(name).join("config").join("app.json"); - let original = serde_json::to_string_pretty(&config).unwrap(); - tokio::fs::write(&config_file, &original).await.unwrap(); - let service = restart_test_service(&dir, name).await; - let loaded: GlobalConfig = service.get_config(None).await.unwrap(); - assert_eq!(loaded.version, version); - assert_eq!(loaded.app.language, "zh-CN"); - assert_eq!( - tokio::fs::read_to_string(&config_file).await.unwrap(), - original - ); - - service.set_config("app.language", &"en-US").await.unwrap(); - drop(service); - let restarted = restart_test_service(&dir, name).await; - let saved: GlobalConfig = restarted.get_config(None).await.unwrap(); - assert_eq!(saved.version, env!("CARGO_PKG_VERSION")); - assert_eq!(saved.app.language, "en-US"); - } - } - - #[tokio::test] - async fn prerelease_exports_support_explicit_import_and_account_settings() { - for version in ["1.0.0-beta.1", "1.0.0-nightly.20260906"] { - for account_sync in [false, true] { - let (service, _dir) = test_service("prerelease-config-import").await; - let mut export = current_export(GlobalConfig::default()); - export.version = version.to_string(); - export.config.version = version.to_string(); - export.config.app.language = "zh-CN".to_string(); - let export: ConfigExport = - serde_json::from_str(&serde_json::to_string(&export).unwrap()).unwrap(); - - let imported = if account_sync { - service.import_account_settings(export).await.unwrap() - } else { - service.import_config(export).await.unwrap() - }; - assert!(imported.success, "{:?}", imported.errors); - let config: GlobalConfig = service.get_config(None).await.unwrap(); - assert_eq!(config.app.language, "zh-CN"); - assert_eq!(config.version, env!("CARGO_PKG_VERSION")); - } - } - } - - #[tokio::test] - async fn pre_1_0_exports_are_rejected_without_changing_current_config() { - for account_sync in [false, true] { - let (service, _dir) = test_service("pre-1-export-rejected").await; - let mut local = GlobalConfig::default(); - local.app.hooks.enabled = false; - service.set_config("", &local).await.unwrap(); - let before: serde_json::Value = service.get_config(None).await.unwrap(); - - let mut export = current_export(GlobalConfig::default()); - export.version = "0.2.18".to_string(); - let imported = if account_sync { - service.import_account_settings(export).await.unwrap() - } else { - service.import_config(export).await.unwrap() - }; - assert!(!imported.success); - assert!(imported.errors[0].contains("predates OpenBitFun 1.0.0")); - let after: serde_json::Value = service.get_config(None).await.unwrap(); - assert_eq!(after, before); - } - } - #[tokio::test] async fn imports_still_honor_explicit_deletions_and_default_elision_in_backups() { for account_sync in [false, true] { @@ -1547,7 +1464,7 @@ mod tests { for (path, value) in [ ("product_id", serde_json::json!("another-product")), ("schema_version", serde_json::json!(2)), - ("version", serde_json::json!("0.2.18")), + ("version", serde_json::json!("tampered-build")), ("last_modified", serde_json::json!(0)), ] { let error = service.set_config(path, value).await.unwrap_err(); @@ -1908,8 +1825,8 @@ mod tests { config.ai.default_models.speech_recognition = Some("speech".to_string()); let original = serde_json::to_string_pretty(&config).expect("serialize config"); tokio::fs::write(path_manager.app_config_file(), &original) - .await - .expect("seed config"); + .await + .expect("seed config"); let error = match ConfigService::with_settings(ConfigManagerSettings { path_manager: Some(path_manager.clone()), @@ -2370,8 +2287,8 @@ mod tests { let original = serde_json::to_string_pretty(&raw_config).expect("retired config should serialize"); tokio::fs::write(path_manager.app_config_file(), &original) - .await - .expect("retired config should be written"); + .await + .expect("retired config should be written"); let error = match ConfigService::with_settings(settings()).await { Ok(_) => panic!("retired reasoning fields must fail startup"), diff --git a/src/crates/assembly/core/src/service/config/types.rs b/src/crates/assembly/core/src/service/config/types.rs index ad77b9068b..87d078c9d4 100644 --- a/src/crates/assembly/core/src/service/config/types.rs +++ b/src/crates/assembly/core/src/service/config/types.rs @@ -75,6 +75,8 @@ pub struct GlobalConfig { /// Version of the persisted configuration schema. This is intentionally /// independent from the OpenBitFun application version stored in `version`. pub schema_version: u32, + /// Application build that most recently wrote this document. Informational + /// only; compatibility is determined by `schema_version`. pub version: String, #[serde(with = "chrono::serde::ts_milliseconds")] pub last_modified: chrono::DateTime, diff --git a/src/crates/contracts/core-types/src/installer_config_handoff.rs b/src/crates/contracts/core-types/src/installer_config_handoff.rs new file mode 100644 index 0000000000..5f63e892ae --- /dev/null +++ b/src/crates/contracts/core-types/src/installer_config_handoff.rs @@ -0,0 +1,34 @@ +//! Preferences passed from the installer to the application. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +pub const INSTALLER_CONFIG_HANDOFF_FILE_NAME: &str = "installer-config-handoff.json"; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct InstallerConfigHandoff { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub language: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub appearance_selection: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstallerModelHandoff { + pub name: String, + pub provider: String, + pub model_name: String, + pub base_url: String, + pub request_url: String, + pub api_key: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub custom_headers: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub custom_headers_mode: Option, + #[serde(default)] + pub skip_ssl_verify: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub custom_request_body: Option, +} diff --git a/src/crates/contracts/core-types/src/lib.rs b/src/crates/contracts/core-types/src/lib.rs index c36b41085d..ff08aad5e3 100644 --- a/src/crates/contracts/core-types/src/lib.rs +++ b/src/crates/contracts/core-types/src/lib.rs @@ -5,6 +5,7 @@ pub mod ai; pub mod errors; +pub mod installer_config_handoff; pub mod model; pub mod product_identity; pub mod session;