From b1f7d4cf7a3d98530394862a23c57333a6548f9e Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 29 Jul 2026 23:45:41 -0700 Subject: [PATCH 1/2] feat(dispatch): unblock one-click CLI install and add model config sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preparing an SSH dispatch target failed at two gates that had no path forward from the UI. **One-click CLI install was blocked by a missing trust root.** The release signing key was only injected at build time from `BITFUN_RELEASE_PUBKEY`, so any build without that secret refused to install with "this build has no BitFun release signing key". Embed the official minisign public key (`50F47CBE6CC0A376`) as the default trust root — it is public data that every release already ships as its `minisign.pub` asset, and downloads stay pinned to the official repository, so this does not widen what a build will execute. `BITFUN_RELEASE_PUBKEY` still takes precedence for forks publishing their own releases. The CLI self-updater uses the same default, which upgrades its previous checksum-only fallback to mandatory signature verification. Releases published before the CLI checksum sidecars were signed return 404 for `.tar.gz.sha256.sig`. Treat only a definite 404 as "unsigned sidecar" and keep every other failure fatal, so a flaky network cannot silently downgrade verification. The archive's own minisign signature stays mandatory — the install path never degrades to checksum-only. **No way to give the target a model.** Add `dispatch_sync_model_config`, which merges only the four `ai` model keys into the target's `app.json`, preserves every other target setting, aborts rather than overwrite an unreadable or unparseable config, and writes owner-only via a temp-file rename. The payload carries API credentials, so the UI gates it behind its own explicit confirmation that says so, and it is offered only when the target CLI answered but reported no usable model. The new command is registered in the Desktop invoke handler and the Server Host route table, and added to all three peer-host keep-local tables so the existing cross-language contract test keeps it off the wire. --- src/apps/cli/src/peer_host/deny.rs | 2 + src/apps/cli/src/self_update.rs | 18 +- src/apps/desktop/src/api/dispatch_api.rs | 15 + src/apps/desktop/src/api/peer_host_invoke.rs | 1 + src/apps/desktop/src/lib.rs | 1 + src/apps/server/src/routes/dispatch.rs | 12 +- .../core/src/service/dispatch/controller.rs | 42 +++ .../assembly/core/src/service/dispatch/mod.rs | 3 +- .../src/remote_ssh/dispatch_ssh.rs | 271 +++++++++++++++++- .../src/remote_ssh/relay_deploy.rs | 13 +- .../src/remote_ssh/release_verify.rs | 29 +- .../dispatch/DispatchInstallDialog.test.tsx | 140 +++++++++ .../dispatch/DispatchInstallDialog.tsx | 50 ++++ src/web-ui/src/features/dispatch/README.md | 5 + .../dispatch/dispatch.contract.test.ts | 1 + .../src/features/dispatch/dispatchApi.ts | 6 + .../api/adapters/peer-device-adapter.ts | 1 + src/web-ui/src/locales/en-US/common.json | 6 + src/web-ui/src/locales/zh-CN/common.json | 6 + src/web-ui/src/locales/zh-TW/common.json | 6 + 20 files changed, 604 insertions(+), 24 deletions(-) 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/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": "版本",

From ce7086e0b49a536c37bc179640f3f0d94656d418 Mon Sep 17 00:00:00 2001
From: Bob Lee 
Date: Thu, 30 Jul 2026 00:20:01 -0700
Subject: [PATCH 2/2] fix(dispatch): declare the remote workspace policy for
 model config sync
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

`every_registered_command_declares_a_remote_workspace_policy` failed on all
three platforms: `dispatch_sync_model_config` was registered in
`generate_handler!` but missing from `REMOTE_WORKSPACE_COMMAND_POLICIES`.

It is `WorkspaceAgnostic` like every other dispatch command — the target is
addressed by its own connection id, never by the currently open workspace.
---
 src/apps/desktop/src/api/remote_workspace_policy.rs | 4 ++++
 1 file changed, 4 insertions(+)

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),