diff --git a/src/apps/cli/src/peer_host/deny.rs b/src/apps/cli/src/peer_host/deny.rs index ff4b20d943..37741f4b36 100644 --- a/src/apps/cli/src/peer_host/deny.rs +++ b/src/apps/cli/src/peer_host/deny.rs @@ -77,6 +77,7 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ "dispatch_install_cli_start", "dispatch_install_cli_poll", "dispatch_install_cli_cancel", + "dispatch_sync_model_config", "dispatch_submit", "dispatch_status", "dispatch_cancel", @@ -133,6 +134,7 @@ mod tests { "dispatch_install_cli_start", "dispatch_install_cli_poll", "dispatch_install_cli_cancel", + "dispatch_sync_model_config", "dispatch_submit", "dispatch_status", "dispatch_cancel", diff --git a/src/apps/cli/src/self_update.rs b/src/apps/cli/src/self_update.rs index d0256b5be6..96af934f63 100644 --- a/src/apps/cli/src/self_update.rs +++ b/src/apps/cli/src/self_update.rs @@ -822,14 +822,22 @@ async fn download_text(client: &Client, url: &str) -> Result { /// Ed25519 (minisign) public key for official release archives, injected at /// build time from the same `TAURI_UPDATER_PUBKEY` the Desktop updater trusts. /// -/// Absent in local and fork builds; those fall back to checksum-only, which is -/// why `signature_required` gates on it rather than assuming. +/// Forks that publish their own releases override this with their own key. const RELEASE_PUBKEY: Option<&str> = option_env!("BITFUN_RELEASE_PUBKEY"); -/// The trust root this binary was built with, if any. `Some` means an official -/// release build, and signature verification is then mandatory. +/// The official BitFun release public key (minisign key ID `50F47CBE6CC0A376`), +/// base64-wrapped the way Tauri wraps `minisign.pub`. Public data — each +/// release ships it as the `minisign.pub` asset — and the update source above +/// is pinned to the official repository, so local and fork builds verifying +/// against it is strictly stronger than their old checksum-only fallback. +const OFFICIAL_RELEASE_PUBKEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDUwRjQ3Q0JFNkNDMEEzNzYKUldSMm84QnN2bnowVU9CYzNOb1RWVzA2d2RpR003cExQM0xwaUw0QTNTcDRueGtCc1dsSlJUeG4K"; + +/// The trust root for release archives. Always present, so signature +/// verification is mandatory on every update path. fn release_pubkey() -> Option<&'static str> { - RELEASE_PUBKEY.filter(|key| !key.trim().is_empty()) + RELEASE_PUBKEY + .filter(|key| !key.trim().is_empty()) + .or(Some(OFFICIAL_RELEASE_PUBKEY)) } /// Verify a Tauri-format `.sig` (base64 of a minisign signature file) over the diff --git a/src/apps/desktop/src/api/dispatch_api.rs b/src/apps/desktop/src/api/dispatch_api.rs index 9ce1527402..bdf5094ca8 100644 --- a/src/apps/desktop/src/api/dispatch_api.rs +++ b/src/apps/desktop/src/api/dispatch_api.rs @@ -14,6 +14,7 @@ use bitfun_core::service::dispatch::{ get_device_dispatch_status, get_dispatch_status, list_device_dispatch_jobs, list_dispatch_jobs, list_dispatch_targets, poll_dispatch_cli_install, probe_device_dispatch_target, probe_dispatch_target, start_dispatch_cli_install, submit_device_dispatch, submit_dispatch, + sync_dispatch_model_config, DeviceDispatchRpc, DispatchAnswerRequest, DispatchAppendRequest, DispatchConnectionRequest, DispatchInstallPollRequest, DispatchInstallStartRequest, DispatchJobRequest, DispatchListJobsRequest, DispatchListTargetsRequest, DispatchProbeTargetRequest, @@ -172,6 +173,20 @@ pub async fn dispatch_install_cli_cancel( .map_err(|error| error.to_string()) } +#[tauri::command] +pub async fn dispatch_sync_model_config( + state: State<'_, AppState>, + request: DispatchConnectionRequest, +) -> Result<(), String> { + let manager = state + .get_ssh_manager_async() + .await + .map_err(|error| error.to_string())?; + sync_dispatch_model_config(&manager, request) + .await + .map_err(|error| error.to_string()) +} + #[tauri::command] pub async fn dispatch_submit( state: State<'_, AppState>, diff --git a/src/apps/desktop/src/api/peer_host_invoke.rs b/src/apps/desktop/src/api/peer_host_invoke.rs index c936838df0..59a586bbf6 100644 --- a/src/apps/desktop/src/api/peer_host_invoke.rs +++ b/src/apps/desktop/src/api/peer_host_invoke.rs @@ -100,6 +100,7 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ "dispatch_install_cli_start", "dispatch_install_cli_poll", "dispatch_install_cli_cancel", + "dispatch_sync_model_config", "dispatch_submit", "dispatch_status", "dispatch_cancel", diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 8e2b41dc1a..ab48c12a14 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -365,6 +365,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "dispatch_probe_target", RemoteWorkspacePolicy::WorkspaceAgnostic, ), + ( + "dispatch_sync_model_config", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), ("dispatch_answer", RemoteWorkspacePolicy::WorkspaceAgnostic), ("dispatch_append", RemoteWorkspacePolicy::WorkspaceAgnostic), ("dispatch_status", RemoteWorkspacePolicy::WorkspaceAgnostic), diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 6cbc766a58..724608a0d0 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1747,6 +1747,7 @@ pub async fn run() { api::dispatch_api::dispatch_install_cli_start, api::dispatch_api::dispatch_install_cli_poll, api::dispatch_api::dispatch_install_cli_cancel, + api::dispatch_api::dispatch_sync_model_config, api::dispatch_api::dispatch_submit, api::dispatch_api::dispatch_status, api::dispatch_api::dispatch_cancel, diff --git a/src/apps/server/src/routes/dispatch.rs b/src/apps/server/src/routes/dispatch.rs index 65b3818345..52abddf68d 100644 --- a/src/apps/server/src/routes/dispatch.rs +++ b/src/apps/server/src/routes/dispatch.rs @@ -10,7 +10,8 @@ use bitfun_core::external_sources::{ use bitfun_core::service::dispatch::{ answer_dispatch, append_dispatch, cancel_dispatch, cancel_dispatch_cli_install, get_dispatch_status, list_dispatch_jobs, list_dispatch_targets, poll_dispatch_cli_install, - probe_dispatch_target, start_dispatch_cli_install, submit_dispatch, DispatchAnswerRequest, + probe_dispatch_target, start_dispatch_cli_install, submit_dispatch, + sync_dispatch_model_config, DispatchAnswerRequest, DispatchAppendRequest, DispatchConnectionRequest, DispatchInstallPollRequest, DispatchInstallStartRequest, DispatchJobRequest, DispatchListJobsRequest, DispatchListTargetsRequest, DispatchProbeTargetRequest, DispatchStatusRequest, @@ -28,6 +29,7 @@ pub(crate) fn supports(method: &str) -> bool { | "dispatch_install_cli_start" | "dispatch_install_cli_poll" | "dispatch_install_cli_cancel" + | "dispatch_sync_model_config" | "dispatch_submit" | "dispatch_status" | "dispatch_cancel" @@ -90,6 +92,13 @@ pub(crate) async fn dispatch( .map_err(operation_error)?; Ok(serde_json::Value::Null) } + "dispatch_sync_model_config" => { + let request = parse_request::(¶ms)?; + sync_dispatch_model_config(&host.ssh_manager, request) + .await + .map_err(operation_error)?; + Ok(serde_json::Value::Null) + } "dispatch_submit" => { let request = parse_request::(¶ms)?; submit_dispatch(&host.ssh_manager, &store(host), request) @@ -179,6 +188,7 @@ mod tests { "dispatch_install_cli_start", "dispatch_install_cli_poll", "dispatch_install_cli_cancel", + "dispatch_sync_model_config", "dispatch_submit", "dispatch_status", "dispatch_cancel", diff --git a/src/crates/assembly/core/src/service/dispatch/controller.rs b/src/crates/assembly/core/src/service/dispatch/controller.rs index 9696a2fc17..0ea9c27f05 100644 --- a/src/crates/assembly/core/src/service/dispatch/controller.rs +++ b/src/crates/assembly/core/src/service/dispatch/controller.rs @@ -198,6 +198,48 @@ pub async fn install_cli_cancel( dispatch_ssh::install_cli_cancel(manager, request.connection_id.trim()).await } +/// Copy this controller's model configuration (catalog, credentials, and +/// default-model selections) onto the SSH target so its CLI can resolve a +/// ready model. Explicit, credential-bearing operation: the UI must confirm +/// before calling it, mirroring CLI installation. +pub async fn sync_model_config( + manager: &SSHConnectionManager, + request: DispatchConnectionRequest, +) -> anyhow::Result<()> { + crate::service::config::initialize_global_config() + .await + .map_err(|error| anyhow::anyhow!("initialize controller configuration: {error}"))?; + let config_service = crate::service::config::get_global_config_service() + .await + .map_err(|error| anyhow::anyhow!("read controller configuration: {error}"))?; + let config: crate::service::config::GlobalConfig = config_service + .get_config(None) + .await + .map_err(|error| anyhow::anyhow!("load controller configuration: {error}"))?; + if !config.ai.models.iter().any(|model| model.enabled) { + anyhow::bail!("no enabled AI model is configured on this device to sync"); + } + let ai = serde_json::to_value(&config.ai) + .map_err(|error| anyhow::anyhow!("encode controller model configuration: {error}"))?; + let mut payload = serde_json::Map::new(); + for key in [ + "models", + "default_models", + "agent_model_defaults", + "func_agent_models", + ] { + if let Some(value) = ai.get(key) { + payload.insert(key.to_string(), value.clone()); + } + } + dispatch_ssh::sync_model_config( + manager, + request.connection_id.trim(), + &Value::Object(payload), + ) + .await +} + pub async fn submit( manager: &SSHConnectionManager, store: &OutboundDispatchStore, diff --git a/src/crates/assembly/core/src/service/dispatch/mod.rs b/src/crates/assembly/core/src/service/dispatch/mod.rs index 4910e41ecf..7523a27c53 100644 --- a/src/crates/assembly/core/src/service/dispatch/mod.rs +++ b/src/crates/assembly/core/src/service/dispatch/mod.rs @@ -25,7 +25,8 @@ pub use controller::{ install_cli_poll as poll_dispatch_cli_install, install_cli_start as start_dispatch_cli_install, list_jobs as list_dispatch_jobs, list_targets as list_dispatch_targets, probe_target as probe_dispatch_target, status as get_dispatch_status, - submit as submit_dispatch, DispatchAnswerRequest, DispatchAppendRequest, + submit as submit_dispatch, sync_model_config as sync_dispatch_model_config, + DispatchAnswerRequest, DispatchAppendRequest, DispatchConnectionRequest, DispatchInstallPollRequest, DispatchInstallStartRequest, DispatchJobRequest, DispatchListJobsRequest, DispatchListTargetsRequest, DispatchPermissionReplyKind, DispatchProbeTargetRequest, DispatchStatusRequest, diff --git a/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs b/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs index 6437b1027d..447500c168 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs @@ -7,8 +7,9 @@ //! //! Installing the CLI is a separate, explicit operation. `probe` never installs //! anything; `install_cli_start` downloads an official archive locally, verifies -//! both its signed SHA256 sidecar and archive minisign signature, then stages it -//! under the SSH user's home before starting an owner-only installer. +//! its SHA256 sidecar (signed, when the release ships `.sha256.sig`) and the +//! mandatory archive minisign signature, then stages it under the SSH user's +//! home before starting an owner-only installer. use anyhow::{anyhow, Context, Result}; use serde::{Deserialize, Serialize}; @@ -17,7 +18,7 @@ use std::time::Duration; use super::manager::SSHConnectionManager; use super::release_verify::{ - release_tag_for_version, require_release_pubkey, verify_minisign, verify_sha256, + parse_sha256, release_tag_for_version, require_release_pubkey, verify_minisign, verify_sha256, verify_signed_checksum, }; use super::remote_git::shell_quote_posix; @@ -479,6 +480,187 @@ pub async fn install_cli_cancel(manager: &SSHConnectionManager, connection_id: & Ok(()) } +/// Keys of the `ai` config section that make up "model configuration": the +/// model catalog (credentials included) plus every default-selection table the +/// target consults when resolving a ready model. +const MODEL_CONFIG_KEYS: [&str; 4] = [ + "models", + "default_models", + "agent_model_defaults", + "func_agent_models", +]; + +/// Write the controller's model configuration into the target's global config +/// so `bitfun dispatch probe` can report a ready model. +/// +/// `ai_model_config` is the snake_case `ai` slice restricted to +/// [`MODEL_CONFIG_KEYS`], exactly as `app.json` stores it. Everything else in +/// an existing target `app.json` is preserved; a target file that exists but +/// cannot be read or parsed aborts the sync instead of being overwritten. The +/// write is atomic (temp file + rename) and owner-only, because model entries +/// carry API credentials. +pub async fn sync_model_config( + manager: &SSHConnectionManager, + connection_id: &str, + ai_model_config: &Value, +) -> Result<()> { + let payload = validate_model_config_payload(ai_model_config)?; + ensure_plain_ssh_target(manager, connection_id).await?; + + let locate = exec_lines(manager, connection_id, locate_target_config_script()).await?; + let get = |key: &str| { + locate + .lines() + .find_map(|line| { + line.strip_prefix(key) + .and_then(|rest| rest.strip_prefix('=')) + }) + .unwrap_or("") + .trim() + .to_string() + }; + if get("os") == "unsupported" { + return Err(anyhow!( + "model configuration sync supports only Linux and macOS SSH targets" + )); + } + let config_dir = get("dir"); + if config_dir.is_empty() { + return Err(anyhow!("could not resolve the target BitFun config directory")); + } + let config_path = format!("{config_dir}/app.json"); + + let existing = if get("config") == "1" { + let bytes = manager + .sftp_read(connection_id, &config_path) + .await + .context("read existing target app.json; refusing to overwrite it blindly")?; + Some(String::from_utf8(bytes).context("target app.json is not UTF-8")?) + } else { + None + }; + let merged = merge_model_config(existing.as_deref(), payload)?; + + exec_ok( + manager, + connection_id, + &format!( + "mkdir -p {dir} && chmod 700 \"$(dirname {dir})\" {dir}", + dir = shell_quote_posix(&config_dir), + ), + ) + .await?; + let staging_path = format!("{config_path}.bitfun-sync.tmp"); + manager + .sftp_write(connection_id, &staging_path, merged.as_bytes()) + .await + .context("stage merged target app.json")?; + exec_ok( + manager, + connection_id, + &format!( + "chmod 600 {staged} && mv -f {staged} {config}", + staged = shell_quote_posix(&staging_path), + config = shell_quote_posix(&config_path), + ), + ) + .await +} + +fn validate_model_config_payload( + ai_model_config: &Value, +) -> Result<&serde_json::Map> { + let payload = ai_model_config + .as_object() + .ok_or_else(|| anyhow!("model configuration payload must be a JSON object"))?; + if let Some(unexpected) = payload + .keys() + .find(|key| !MODEL_CONFIG_KEYS.contains(&key.as_str())) + { + return Err(anyhow!( + "model configuration payload has unexpected key '{unexpected}'" + )); + } + if payload + .get("models") + .and_then(Value::as_array).is_none_or(|models| models.is_empty()) + { + return Err(anyhow!( + "the controller has no configured AI models to sync" + )); + } + Ok(payload) +} + +/// Graft the model-configuration keys onto an existing target config document, +/// leaving every other setting untouched. +fn merge_model_config( + existing: Option<&str>, + payload: &serde_json::Map, +) -> Result { + let mut root = match existing.map(str::trim).filter(|text| !text.is_empty()) { + Some(text) => serde_json::from_str::(text) + .context("target app.json exists but is not valid JSON; refusing to overwrite it")?, + None => Value::Object(serde_json::Map::new()), + }; + let root_map = root + .as_object_mut() + .ok_or_else(|| anyhow!("target app.json is not a JSON object; refusing to overwrite it"))?; + let ai = root_map + .entry("ai") + .or_insert_with(|| Value::Object(serde_json::Map::new())); + let ai_map = ai.as_object_mut().ok_or_else(|| { + anyhow!("target app.json has a non-object `ai` section; refusing to overwrite it") + })?; + for (key, value) in payload { + ai_map.insert(key.clone(), value.clone()); + } + serde_json::to_string_pretty(&root).context("encode merged target app.json") +} + +/// Where the target CLI reads its global config from, mirroring the +/// `dirs::config_dir()` resolution inside the CLI itself. +fn locate_target_config_script() -> &'static str { + r#" +LC_ALL=C +case "$(uname -s 2>/dev/null)" in + Darwin) CONFIG_DIR="$HOME/Library/Application Support/bitfun/config" ;; + Linux) CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/bitfun/config" ;; + *) printf 'os=unsupported\n'; exit 0 ;; +esac +printf 'os=supported\n' +printf 'dir=%s\n' "$CONFIG_DIR" +if [ -f "$CONFIG_DIR/app.json" ]; then printf 'config=1\n'; else printf 'config=0\n'; fi +"# +} + +async fn exec_lines( + manager: &SSHConnectionManager, + connection_id: &str, + script: &str, +) -> Result { + let result = manager + .execute_command_with_options( + connection_id, + script, + SSHCommandOptions { + timeout_ms: Some(COMMAND_TIMEOUT_MS), + cancellation_token: None, + }, + ) + .await?; + ensure_command_completed(&result, "inspect SSH dispatch target")?; + if result.exit_code != 0 { + return Err(remote_command_error( + "inspect SSH dispatch target", + result.exit_code, + &result.stdout, + &result.stderr, + )); + } + Ok(result.stdout) +} + pub async fn submit( manager: &SSHConnectionManager, connection_id: &str, @@ -892,8 +1074,14 @@ async fn resolve_release(os: &str, arch: &str) -> Result { let archive_signature_url = format!("{url}.sig"); let client = release_http_client()?; let checksum = fetch_required_text(&client, &checksum_url).await?; - let signature = fetch_required_text(&client, &checksum_signature_url).await?; - let sha256 = verify_signed_checksum(&checksum, &signature, pubkey, &filename)?; + let sha256 = match fetch_optional_text(&client, &checksum_signature_url).await? { + Some(signature) => verify_signed_checksum(&checksum, &signature, pubkey, &filename)?, + // Releases published before the CLI checksum sidecars were signed have + // no `.sha256.sig`. The digest shown for consent is then provisional; + // install still verifies the archive's own minisign signature before + // staging anything, so a tampered sidecar can only fail the install. + None => parse_sha256(&checksum, &filename)?, + }; Ok(ResolvedRelease { public: DispatchCliRelease { @@ -945,6 +1133,27 @@ async fn fetch_required_text(client: &reqwest::Client, url: &str) -> Result Result> { + let response = client + .get(url) + .send() + .await + .with_context(|| format!("request {url}"))?; + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + let text = response + .error_for_status() + .with_context(|| format!("download {url}"))? + .text() + .await + .with_context(|| format!("read {url}"))?; + Ok(Some(text)) +} + async fn download_verified_archive(release: &ResolvedRelease) -> Result> { let pubkey = require_release_pubkey()?; let client = release_http_client()?; @@ -952,9 +1161,14 @@ async fn download_verified_archive(release: &ResolvedRelease) -> Result> // Re-fetch and verify the signed sidecar at install time instead of trusting // a possibly stale preflight result. let checksum = fetch_required_text(&client, &release.checksum_url).await?; - let checksum_signature = fetch_required_text(&client, &release.checksum_signature_url).await?; - let expected = - verify_signed_checksum(&checksum, &checksum_signature, pubkey, &release.filename)?; + let expected = match fetch_optional_text(&client, &release.checksum_signature_url).await? { + Some(signature) => { + verify_signed_checksum(&checksum, &signature, pubkey, &release.filename)? + } + // No `.sha256.sig` on this release: the confirmed digest and the + // mandatory archive minisign check below carry the verification. + None => parse_sha256(&checksum, &release.filename)?, + }; if !expected.eq_ignore_ascii_case(&release.public.sha256) { return Err(anyhow!( "release checksum changed after preflight; refusing to install" @@ -1736,6 +1950,47 @@ mod tests { ); } + #[test] + fn model_config_sync_merges_only_the_ai_keys() { + let payload = serde_json::json!({ + "models": [{"id": "m1", "enabled": true}], + "default_models": {"primary": "m1"}, + }); + let payload = validate_model_config_payload(&payload).expect("valid payload"); + + // Fresh target: a minimal document containing only the ai section. + let fresh: Value = + serde_json::from_str(&merge_model_config(None, payload).unwrap()).unwrap(); + assert_eq!(fresh["ai"]["models"][0]["id"], "m1"); + + // Existing target: everything outside the synced keys is preserved. + let existing = r#"{ + "editor": {"font_size": 11}, + "ai": {"models": [], "max_rounds": 7} + }"#; + let merged: Value = + serde_json::from_str(&merge_model_config(Some(existing), payload).unwrap()).unwrap(); + assert_eq!(merged["editor"]["font_size"], 11); + assert_eq!(merged["ai"]["max_rounds"], 7); + assert_eq!(merged["ai"]["models"][0]["id"], "m1"); + assert_eq!(merged["ai"]["default_models"]["primary"], "m1"); + + // A corrupt target config aborts instead of being replaced. + assert!(merge_model_config(Some("not json"), payload).is_err()); + assert!(merge_model_config(Some("[]"), payload).is_err()); + } + + #[test] + fn model_config_payload_rejects_unknown_keys_and_empty_catalogs() { + assert!(validate_model_config_payload(&serde_json::json!({ + "models": [{"id": "m1"}], + "tool_permissions": {} + })) + .is_err()); + assert!(validate_model_config_payload(&serde_json::json!({ "models": [] })).is_err()); + assert!(validate_model_config_payload(&serde_json::json!("models")).is_err()); + } + #[test] fn installation_is_bound_to_the_exact_confirmed_release() { let confirmed = DispatchCliRelease { diff --git a/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs b/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs index 52bf777337..b2f3ff8b4d 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs @@ -1802,7 +1802,7 @@ mod tests { install_docker_body_script, interactive_driver_script, parse_preflight, prepare_helpers_bash, release_binary_deploy_bash, release_tag_for_version, split_poll_stdout, stage_scripts_command, sync_source_bash, to_unix_script, - verified_checksum_exports, verified_release_checksums, verify_minisign, DockerAccessMode, + verified_checksum_exports, verify_minisign, DockerAccessMode, RelayTaskStatus, RELAY_MIRROR_SH, RELAY_RELEASE_DOWNLOAD_SH, RELEASE_PUBKEY, }; @@ -2254,12 +2254,13 @@ sh -c "$(bitfun_shell_join printf '%s\n' 'a b' "it's" '{{{{.State.Running}}}}' ' assert!(script.contains("BITFUN_EXPECTED_SHA256_AARCH64_UNKNOWN_LINUX_GNU")); } - /// Without a trust root there is nothing to verify against, so no hash may - /// be asserted to the remote. - #[tokio::test] - async fn unsigned_builds_supply_no_checksums() { + /// The official key is embedded as the default trust root, so even keyless + /// development builds can verify published checksums before asserting a + /// hash to the remote. + #[test] + fn builds_always_carry_a_release_trust_root() { assert!(RELEASE_PUBKEY.is_none() || RELEASE_PUBKEY == Some("")); - assert!(verified_release_checksums("v0.0.0").await.is_empty()); + assert!(super::release_pubkey().is_some()); } #[cfg(unix)] diff --git a/src/crates/services/services-integrations/src/remote_ssh/release_verify.rs b/src/crates/services/services-integrations/src/remote_ssh/release_verify.rs index 6b4610e83d..0aa1be4c19 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/release_verify.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/release_verify.rs @@ -10,12 +10,23 @@ use sha2::{Digest, Sha256}; /// Ed25519 (minisign) public key injected into official builds from the same /// `TAURI_UPDATER_PUBKEY` used by the Desktop and CLI updaters. /// -/// Fork and development builds normally have no key. Callers that install -/// executable code must use [`require_release_pubkey`] and fail closed. +/// Forks that publish their own releases set this at build time to their own +/// key. When absent, [`OFFICIAL_RELEASE_PUBKEY`] applies. pub(crate) const RELEASE_PUBKEY: Option<&str> = option_env!("BITFUN_RELEASE_PUBKEY"); +/// The official BitFun release public key (minisign key ID `50F47CBE6CC0A376`), +/// base64-wrapped the way Tauri wraps `minisign.pub`. This is public data — +/// every release publishes it as the `minisign.pub` asset — so compiling it in +/// lets development builds verify and install official releases instead of +/// failing closed with no trust root. Downloads still come only from the +/// pinned official repository, so trusting the matching official key here does +/// not widen what a build will execute. +pub(crate) const OFFICIAL_RELEASE_PUBKEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDUwRjQ3Q0JFNkNDMEEzNzYKUldSMm84QnN2bnowVU9CYzNOb1RWVzA2d2RpR003cExQM0xwaUw0QTNTcDRueGtCc1dsSlJUeG4K"; + pub(crate) fn release_pubkey() -> Option<&'static str> { - RELEASE_PUBKEY.filter(|key| !key.trim().is_empty()) + RELEASE_PUBKEY + .filter(|key| !key.trim().is_empty()) + .or(Some(OFFICIAL_RELEASE_PUBKEY)) } pub(crate) fn require_release_pubkey() -> Result<&'static str> { @@ -101,6 +112,18 @@ mod tests { const FIXTURE_SIGNATURE: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIG1pbmlzaWduIHNlY3JldCBrZXkKUlVUREloenNUSWZnNDBMTitwb25aT3RCVy9VYmJtNWhkR1poM0lCb3IwUDBKaVZmZmM1cFJaNlZSNUpaSzNUUm1yWWpYMXFLQ2svWTdZUDhHdkRZT3YvanVoZlpnZmhyWEFRPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg0OTUxOTM1CWZpbGU6YXJjaGl2ZS50YXIuZ3oJaGFzaGVkCjhWL21EUVAwZGdlZXVNU1lxWlpsOWdFSGUwOTJQTk9yRG1BMUV6ZHNQOUlEYkcyT1dneTFsQ1puUDBJaFIwQnJpMFBCeENRcUdDR2dpb0l0UGtSMUN3PT0K"; const FIXTURE_DATA: &[u8] = b"hello-bitfun\n"; + #[test] + fn embedded_trust_root_is_always_available_and_well_formed() { + use base64::Engine as _; + + let pubkey = require_release_pubkey().expect("every build carries a trust root"); + let decoded = base64::engine::general_purpose::STANDARD + .decode(pubkey.trim().as_bytes()) + .expect("trust root is base64"); + let text = String::from_utf8(decoded).expect("trust root decodes as UTF-8"); + minisign_verify::PublicKey::decode(&text).expect("trust root is a minisign public key"); + } + #[test] fn minisign_wire_format_accepts_authentic_data_and_rejects_tampering() { verify_minisign(FIXTURE_DATA, FIXTURE_SIGNATURE, FIXTURE_PUBKEY) diff --git a/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx b/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx index 0f211fb4a3..ea71f4e0e1 100644 --- a/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx +++ b/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx @@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({ installCliStart: vi.fn(), installCliPoll: vi.fn(), installCliCancel: vi.fn(), + syncModelConfig: vi.fn(), confirmWarning: vi.fn(), modalOnClose: null as (() => void) | null, modalLifecycleProps: null as { @@ -27,6 +28,7 @@ vi.mock('./dispatchApi', () => ({ installCliStart: mocks.installCliStart, installCliPoll: mocks.installCliPoll, installCliCancel: mocks.installCliCancel, + syncModelConfig: mocks.syncModelConfig, }, })); @@ -270,3 +272,141 @@ describe('DispatchInstallDialog installation lifecycle', () => { expect(mocks.installCliCancel).toHaveBeenCalledTimes(1); }); }); + +describe('DispatchInstallDialog model configuration sync', () => { + let container: HTMLDivElement; + let root: Root; + let modelConfigured: boolean; + + const target = { + kind: 'ssh' as const, + connectionId: 'ssh-1', + displayName: 'build-host', + }; + + function probeResult() { + return { + cliInstalled: true, + os: 'linux', + arch: 'x86_64', + installSupported: true, + protocol: { + protocolVersion: 2, + cliVersion: '1.2.3', + os: 'linux', + arch: 'x86_64', + capabilities: [ + 'persistent_jobs', + 'cursor_events', + 'detached_worker', + 'frontend_event_projection', + 'workspace_serialization', + ], + modelConfigured, + availableModels: modelConfigured ? ['claude'] : [], + defaultModel: modelConfigured ? 'claude' : undefined, + }, + }; + } + + function syncButton() { + return Array.from(container.querySelectorAll('button')) + .find(button => button.textContent?.includes('dispatch.syncModelConfirm')); + } + + async function mount() { + await act(async () => { + root.render( + , + ); + await Promise.resolve(); + await Promise.resolve(); + }); + } + + beforeEach(() => { + vi.clearAllMocks(); + modelConfigured = false; + mocks.modalOnClose = null; + mocks.probeTarget.mockImplementation(async () => probeResult()); + mocks.confirmWarning.mockResolvedValue(true); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('offers the sync only while the target CLI answers without a usable model', async () => { + await mount(); + expect(syncButton()).toBeDefined(); + + mocks.syncModelConfig.mockImplementation(async () => { + modelConfigured = true; + }); + const probesBeforeSync = mocks.probeTarget.mock.calls.length; + + await act(async () => { + syncButton()?.click(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mocks.confirmWarning).toHaveBeenCalledTimes(1); + expect(mocks.syncModelConfig).toHaveBeenCalledWith('ssh-1'); + // The sync re-probes so the model check reflects the target, not the write. + expect(mocks.probeTarget.mock.calls.length).toBeGreaterThan(probesBeforeSync); + expect(syncButton()).toBeUndefined(); + }); + + it('does not write the credential-bearing config when the confirmation is declined', async () => { + await mount(); + mocks.confirmWarning.mockResolvedValue(false); + + await act(async () => { + syncButton()?.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mocks.syncModelConfig).not.toHaveBeenCalled(); + expect(syncButton()).toBeDefined(); + }); + + it('discards a late sync acknowledgement after the dialog closes', async () => { + const sync = createDeferred(); + mocks.syncModelConfig.mockReturnValue(sync.promise); + await mount(); + + await act(async () => { + syncButton()?.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(mocks.syncModelConfig).toHaveBeenCalledTimes(1); + const probesBeforeClose = mocks.probeTarget.mock.calls.length; + + await act(async () => { + mocks.modalOnClose?.(); + await Promise.resolve(); + }); + + await act(async () => { + modelConfigured = true; + sync.resolve(undefined); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mocks.probeTarget.mock.calls.length).toBe(probesBeforeClose); + }); +}); diff --git a/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx b/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx index b6e5209ec7..e09d58255f 100644 --- a/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx +++ b/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx @@ -76,6 +76,7 @@ export const DispatchInstallDialog: React.FC = ({ const [probedWorkspaceInput, setProbedWorkspaceInput] = useState(null); const [probing, setProbing] = useState(false); const [installing, setInstalling] = useState(false); + const [syncingModel, setSyncingModel] = useState(false); const [installStart, setInstallStart] = useState(null); const [installOutput, setInstallOutput] = useState(''); const [error, setError] = useState(null); @@ -133,6 +134,7 @@ export const DispatchInstallDialog: React.FC = ({ setInstallStart(null); setInstallOutput(''); setInstalling(false); + setSyncingModel(false); setError(null); void runProbe(initialPath); }, [open, runProbe, target?.defaultWorkspace, targetId]); @@ -263,9 +265,39 @@ export const DispatchInstallDialog: React.FC = ({ } }, [clearActiveInstall, connectionId, pollInstallation, probe?.release, t]); + const syncModelConfiguration = useCallback(async () => { + if (!connectionId) return; + const generation = generationRef.current; + const confirmed = await confirmWarning( + t('dispatch.syncModelConfirmTitle'), + t('dispatch.syncModelConfirmMessage'), + { + confirmText: t('dispatch.syncModelConfirm'), + cancelText: t('dispatch.cancel'), + }, + ); + if (!confirmed || generation !== generationRef.current) return; + setSyncingModel(true); + setError(null); + try { + await dispatchApi.syncModelConfig(connectionId); + } catch (nextError) { + if (generation === generationRef.current) { + setSyncingModel(false); + setError(errorMessage(nextError)); + } + return; + } + if (generation !== generationRef.current) return; + // runProbe advances the generation, so leave the syncing state first. + setSyncingModel(false); + await runProbe(); + }, [connectionId, runProbe, t]); + const close = useCallback(() => { invalidateInstallLifecycle(); setInstalling(false); + setSyncingModel(false); onClose(); }, [invalidateInstallLifecycle, onClose]); @@ -514,6 +546,24 @@ export const DispatchInstallDialog: React.FC = ({ ) : null} + {target?.kind === 'ssh' && probe?.protocol && !modelReady ? ( +
+
+ {t('dispatch.syncModelRequired')} + {t('dispatch.syncModelDescription')} +
+ +
+ ) : null} + {installStart || installOutput ? (
             {installOutput || t('dispatch.installWaiting')}
diff --git a/src/web-ui/src/features/dispatch/README.md b/src/web-ui/src/features/dispatch/README.md
index b0973adcdb..1ad093d48e 100644
--- a/src/web-ui/src/features/dispatch/README.md
+++ b/src/web-ui/src/features/dispatch/README.md
@@ -47,3 +47,8 @@ dispatch.
 17. Listing jobs for an explicitly selected target adopts only outbound
     observer routing records. It never restores the target session into the
     controller's backend store or acquires local runtime ownership.
+18. Model configuration sync is a separate, explicit, credential-bearing
+    operation with its own confirmation. It merges only the `ai` model keys
+    into the target's `app.json`, preserves every other target setting, aborts
+    rather than overwrite an unreadable or unparseable target config, and
+    writes owner-only via a temp-file rename.
diff --git a/src/web-ui/src/features/dispatch/dispatch.contract.test.ts b/src/web-ui/src/features/dispatch/dispatch.contract.test.ts
index 784c37968e..41f8b25f21 100644
--- a/src/web-ui/src/features/dispatch/dispatch.contract.test.ts
+++ b/src/web-ui/src/features/dispatch/dispatch.contract.test.ts
@@ -12,6 +12,7 @@ const OUTBOUND_DISPATCH_COMMANDS = [
   'dispatch_install_cli_start',
   'dispatch_install_cli_poll',
   'dispatch_install_cli_cancel',
+  'dispatch_sync_model_config',
   'dispatch_submit',
   'dispatch_status',
   'dispatch_cancel',
diff --git a/src/web-ui/src/features/dispatch/dispatchApi.ts b/src/web-ui/src/features/dispatch/dispatchApi.ts
index 532a048a3a..db3d886194 100644
--- a/src/web-ui/src/features/dispatch/dispatchApi.ts
+++ b/src/web-ui/src/features/dispatch/dispatchApi.ts
@@ -49,6 +49,12 @@ export const dispatchApi = {
     });
   },
 
+  async syncModelConfig(connectionId: string): Promise {
+    return api.invoke('dispatch_sync_model_config', {
+      request: { connectionId },
+    });
+  },
+
   async submit(request: {
     target: DispatchTargetRequest;
     workspaceDelivery: DispatchWorkspaceDeliveryRequest;
diff --git a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts
index cb4d966747..57f220b936 100644
--- a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts
+++ b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts
@@ -64,6 +64,7 @@ const LOCAL_ONLY_COMMANDS = new Set([
   'dispatch_install_cli_start',
   'dispatch_install_cli_poll',
   'dispatch_install_cli_cancel',
+  'dispatch_sync_model_config',
   'dispatch_submit',
   'dispatch_status',
   'dispatch_cancel',
diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json
index 74e3eb69c4..1b0ac83e49 100644
--- a/src/web-ui/src/locales/en-US/common.json
+++ b/src/web-ui/src/locales/en-US/common.json
@@ -1471,6 +1471,12 @@
     "modelReady": "Ready ({{model}})",
     "modelAutomatic": "target default",
     "modelMissing": "No usable model is configured on the target",
+    "syncModelRequired": "Sync model configuration",
+    "syncModelDescription": "Copy this device's model configuration (including API credentials) to the target.",
+    "syncModelConfirmTitle": "Sync model configuration to this target?",
+    "syncModelConfirmMessage": "This device's model catalog and default model selections, including API credentials, will be written to the target user's BitFun config file with owner-only permissions.",
+    "syncModelConfirm": "Sync",
+    "syncingModel": "Syncing…",
     "installRequired": "Install or update BitFun CLI",
     "installDescription": "BitFun will install the verified release in the target user's home directory.",
     "version": "Version",
diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json
index 51c2acb153..2a34a0980f 100644
--- a/src/web-ui/src/locales/zh-CN/common.json
+++ b/src/web-ui/src/locales/zh-CN/common.json
@@ -1471,6 +1471,12 @@
     "modelReady": "就绪({{model}})",
     "modelAutomatic": "目标默认模型",
     "modelMissing": "目标上没有可用的模型配置",
+    "syncModelRequired": "同步模型配置",
+    "syncModelDescription": "将本机的模型配置(含 API 密钥)复制到目标。",
+    "syncModelConfirmTitle": "同步模型配置到此目标?",
+    "syncModelConfirmMessage": "本机的模型列表与默认模型选择(包含 API 密钥)将写入目标用户的 BitFun 配置文件,且仅目标用户可读。",
+    "syncModelConfirm": "同步",
+    "syncingModel": "正在同步…",
     "installRequired": "安装或更新 BitFun CLI",
     "installDescription": "BitFun 会将已验证的发行版安装到目标用户的主目录。",
     "version": "版本",
diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json
index 2a6423f38c..d9257af3d5 100644
--- a/src/web-ui/src/locales/zh-TW/common.json
+++ b/src/web-ui/src/locales/zh-TW/common.json
@@ -1471,6 +1471,12 @@
     "modelReady": "就緒({{model}})",
     "modelAutomatic": "目標預設模型",
     "modelMissing": "目標上沒有可用的模型設定",
+    "syncModelRequired": "同步模型設定",
+    "syncModelDescription": "將本機的模型設定(含 API 金鑰)複製到目標。",
+    "syncModelConfirmTitle": "同步模型設定到此目標?",
+    "syncModelConfirmMessage": "本機的模型清單與預設模型選擇(包含 API 金鑰)將寫入目標使用者的 BitFun 設定檔,且僅目標使用者可讀。",
+    "syncModelConfirm": "同步",
+    "syncingModel": "正在同步…",
     "installRequired": "安裝或更新 BitFun CLI",
     "installDescription": "BitFun 會將已驗證的發行版安裝到目標使用者的主目錄。",
     "version": "版本",