diff --git a/src/apps/cli/src/dispatch/mod.rs b/src/apps/cli/src/dispatch/mod.rs index 9fa331ff7f..e98c66a3e9 100644 --- a/src/apps/cli/src/dispatch/mod.rs +++ b/src/apps/cli/src/dispatch/mod.rs @@ -19,6 +19,7 @@ use protocol::{ DispatchListRequest, DispatchProbeRequest, DispatchProbeResponse, DispatchStatusRequest, DispatchStatusResponse, DispatchSubmitRequest, DispatchSubmitResponse, DispatchWorkspaceBeginRequest, DispatchWorkspaceChunkRequest, DispatchWorkspaceCommitRequest, + DispatchWorkspaceResultRequest, DispatchWorkspaceProbe, DISPATCH_PROTOCOL_VERSION, MAX_DISPATCH_TEXT_BYTES, }; use store::{CreateJobOutcome, DispatchStateRecord, DispatchStore}; @@ -71,6 +72,10 @@ pub(crate) async fn run_dispatch_verb( DispatchWorkspaceCommitRequest, >(input)?)?) .context("encode workspace commit response"), + "workspace-result" => serde_json::to_value(workspace::result(parse::< + DispatchWorkspaceResultRequest, + >(input)?)?) + .context("encode workspace result response"), _ => bail!("unsupported dispatch verb: {verb}"), } } @@ -102,6 +107,9 @@ async fn probe(request: DispatchProbeRequest) -> Result { "event_log_completeness".to_string(), "workspace_snapshot_exact".to_string(), "workspace_snapshot_chunked".to_string(), + // Optional on purpose: controllers must feature-detect this rather than + // require it, so an older target stays usable for everything else. + "workspace_result_bundle".to_string(), ]; if runner::is_supported() { capabilities.push("detached_worker".to_string()); diff --git a/src/apps/cli/src/dispatch/protocol.rs b/src/apps/cli/src/dispatch/protocol.rs index e57363c492..4537f49714 100644 --- a/src/apps/cli/src/dispatch/protocol.rs +++ b/src/apps/cli/src/dispatch/protocol.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use bitfun_agent_runtime::sdk::{PermissionReply, PermissionRequest}; -use bitfun_services_core::dispatch_workspace::WorkspaceSnapshotMetadata; +use bitfun_services_core::dispatch_workspace::{WorkspaceResultSummary, WorkspaceSnapshotMetadata}; pub(crate) const DISPATCH_PROTOCOL_VERSION: u32 = 2; pub(crate) const MAX_DISPATCH_TEXT_BYTES: usize = 32 * 1024; @@ -186,6 +186,26 @@ pub(crate) struct DispatchWorkspaceCommitResponse { pub(crate) metadata: WorkspaceSnapshotMetadata, } +/// Ask the target to diff its terminal tree against the delivered snapshot. +/// +/// Read-only on the target: it builds a bundle and reports what changed. The +/// controller decides whether to fetch it, and applying it locally is a +/// separate step the user confirms. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct DispatchWorkspaceResultRequest { + pub(crate) job_id: String, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DispatchWorkspaceResultResponse { + /// Absolute path of the bundle on the target, for the controller to fetch. + pub(crate) bundle_path: String, + pub(crate) workspace_path: String, + pub(crate) summary: WorkspaceResultSummary, +} + #[derive(Clone, Debug, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct DispatchCancelRequest { diff --git a/src/apps/cli/src/dispatch/workspace.rs b/src/apps/cli/src/dispatch/workspace.rs index 36573ce4ac..c42c0457c0 100644 --- a/src/apps/cli/src/dispatch/workspace.rs +++ b/src/apps/cli/src/dispatch/workspace.rs @@ -5,16 +5,17 @@ use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; use base64::Engine as _; use bitfun_services_core::dispatch_workspace::{ - extract_workspace_snapshot, WorkspaceSnapshotMetadata, MAX_SNAPSHOT_ARCHIVE_BYTES, - MAX_SNAPSHOT_DIRECTORIES, MAX_SNAPSHOT_FILES, MAX_SNAPSHOT_UNCOMPRESSED_BYTES, - WORKSPACE_SNAPSHOT_FORMAT_VERSION, + create_workspace_result_bundle, extract_workspace_snapshot, WorkspaceSnapshotManifest, + WorkspaceSnapshotMetadata, MAX_SNAPSHOT_ARCHIVE_BYTES, MAX_SNAPSHOT_DIRECTORIES, + MAX_SNAPSHOT_FILES, MAX_SNAPSHOT_UNCOMPRESSED_BYTES, WORKSPACE_SNAPSHOT_FORMAT_VERSION, }; use serde::{Deserialize, Serialize}; use super::protocol::{ DispatchWorkspaceBeginRequest, DispatchWorkspaceBeginResponse, DispatchWorkspaceChunkRequest, DispatchWorkspaceChunkResponse, DispatchWorkspaceCommitRequest, - DispatchWorkspaceCommitResponse, DISPATCH_PROTOCOL_VERSION, + DispatchWorkspaceCommitResponse, DispatchWorkspaceResultRequest, + DispatchWorkspaceResultResponse, DISPATCH_PROTOCOL_VERSION, }; use super::store::{ atomic_write_json, create_private_dir, read_json, remove_file_if_present, @@ -24,6 +25,10 @@ use super::store::{ const UPLOAD_RECORD_FILE: &str = "upload.json"; const UPLOAD_ARCHIVE_FILE: &str = "workspace.tar.gz"; const CURRENT_WORKSPACE_DIR: &str = "current"; +/// The delivered snapshot's manifest, kept as the baseline a result diff is +/// computed against. +const BASELINE_MANIFEST_FILE: &str = "baseline-manifest.json"; +const RESULT_BUNDLE_FILE: &str = "result.tar.gz"; const MAX_CHUNK_BYTES: usize = 256 * 1024; const MAX_CHUNK_BASE64_BYTES: usize = 384 * 1024; const MAX_MATERIALIZATION_ERROR_BYTES: usize = 16 * 1024; @@ -297,6 +302,45 @@ pub(crate) fn commit( Ok(pending_commit_response(&record)) } +/// Diff the terminal workspace against the snapshot it was given and package +/// what changed. +/// +/// Only valid for snapshot-delivered jobs: a job that ran against a directory +/// the user already had has no baseline to diff against, and BitFun never took +/// ownership of that directory. +pub(crate) fn result( + request: DispatchWorkspaceResultRequest, +) -> Result { + let store = DispatchStore::open_default()?; + let upload_dir = store.workspace_upload_dir(&request.job_id)?; + let lock_path = workspace_upload_lock_path(&store, &request.job_id); + let _lock = JobLock::exclusive(&lock_path)?; + + let record: WorkspaceUploadRecord = read_json(&upload_dir.join(UPLOAD_RECORD_FILE)) + .context("this job did not receive a workspace snapshot")?; + ensure_upload_identity(&record, &request.job_id)?; + if record.state != WorkspaceUploadState::Committed { + bail!("workspace snapshot is not committed yet"); + } + let baseline: WorkspaceSnapshotManifest = + read_json(&upload_dir.join(BASELINE_MANIFEST_FILE)).context( + "this job predates result bundles; its baseline manifest was not recorded", + )?; + + let current = upload_dir.join(CURRENT_WORKSPACE_DIR); + if !is_real_directory(¤t) { + bail!("managed dispatch workspace is missing"); + } + let bundle_path = upload_dir.join(RESULT_BUNDLE_FILE); + let summary = create_workspace_result_bundle(¤t, &baseline, &bundle_path)?; + set_private_file_permissions(&bundle_path)?; + Ok(DispatchWorkspaceResultResponse { + bundle_path: bundle_path.to_string_lossy().to_string(), + workspace_path: current.to_string_lossy().to_string(), + summary, + }) +} + /// Detached target-side materialization. The short `workspace-commit` RPC /// starts this process and subsequent commit calls poll the durable record, so /// extraction is not bounded by an SSH or Relay request timeout. @@ -329,7 +373,14 @@ fn materialize_in_store(store: &DispatchStore, job_id: &str) -> Result<()> { let archive_path = upload_dir.join(UPLOAD_ARCHIVE_FILE); validate_complete_archive(&archive_path, &record.metadata)?; let staging = upload_dir.join(format!(".staging-{}", uuid::Uuid::new_v4().as_simple())); - extract_workspace_snapshot(&archive_path, &staging, &record.metadata)?; + let manifest = extract_workspace_snapshot(&archive_path, &staging, &record.metadata)?; + // Persist S0 as the baseline for a later result diff. The controller + // deletes its own copy of the archive as soon as the job is durably + // owned here, so this is the only surviving record of what was sent — + // and its per-file digests are what make a diff possible for workspaces + // that are not git repositories. + atomic_write_json(&upload_dir.join(BASELINE_MANIFEST_FILE), &manifest) + .context("persist dispatch workspace baseline manifest")?; fs::rename(&staging, ¤t).with_context(|| { format!( "publish dispatch workspace {} -> {}", diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index 0cbd9dc212..f90d7b6a22 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -604,6 +604,8 @@ pub(crate) enum DispatchAction { WorkspaceChunk, #[command(name = "__workspace_commit", hide = true)] WorkspaceCommit, + #[command(name = "__workspace_result", hide = true)] + WorkspaceResult, #[command(name = "__workspace_materialize", hide = true)] WorkspaceMaterialize { #[arg(long)] diff --git a/src/apps/cli/src/root_handlers.rs b/src/apps/cli/src/root_handlers.rs index cdd487153a..f161b6a1f3 100644 --- a/src/apps/cli/src/root_handlers.rs +++ b/src/apps/cli/src/root_handlers.rs @@ -59,6 +59,7 @@ pub(crate) async fn handle_dispatch_action(action: DispatchAction) -> Result<()> DispatchAction::WorkspaceBegin => "workspace-begin", DispatchAction::WorkspaceChunk => "workspace-chunk", DispatchAction::WorkspaceCommit => "workspace-commit", + DispatchAction::WorkspaceResult => "workspace-result", }; let result = async { use std::io::{IsTerminal, Read}; diff --git a/src/apps/desktop/src/api/dispatch_api.rs b/src/apps/desktop/src/api/dispatch_api.rs index bdf5094ca8..53ab8c09e6 100644 --- a/src/apps/desktop/src/api/dispatch_api.rs +++ b/src/apps/desktop/src/api/dispatch_api.rs @@ -10,10 +10,12 @@ use async_trait::async_trait; use bitfun_core::infrastructure::PathManager; use bitfun_core::service::dispatch::{ answer_device_dispatch, answer_dispatch, append_device_dispatch, append_dispatch, + apply_dispatch_result, DispatchApplyResultRequest, WorkspaceResultApplyOutcome, cancel_device_dispatch, cancel_dispatch, cancel_dispatch_cli_install, 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, + probe_dispatch_target, pull_dispatch_result, start_dispatch_cli_install, + start_dispatch_cli_source_build, submit_device_dispatch, submit_dispatch, sync_dispatch_model_config, DeviceDispatchRpc, DispatchAnswerRequest, DispatchAppendRequest, DispatchConnectionRequest, DispatchInstallPollRequest, DispatchInstallStartRequest, DispatchJobRequest, @@ -145,6 +147,22 @@ pub async fn dispatch_install_cli_start( .map_err(|error| error.to_string()) } +/// Build the CLI from source on the target. Offered when no published binary +/// can run there. +#[tauri::command] +pub async fn dispatch_install_cli_source_start( + state: State<'_, AppState>, + request: DispatchConnectionRequest, +) -> Result { + let manager = state + .get_ssh_manager_async() + .await + .map_err(|error| error.to_string())?; + start_dispatch_cli_source_build(&manager, request) + .await + .map_err(|error| error.to_string()) +} + #[tauri::command] pub async fn dispatch_install_cli_poll( state: State<'_, AppState>, @@ -248,6 +266,41 @@ pub async fn dispatch_status( .map_err(|error| error.to_string()) } +/// Download what a finished snapshot job changed on its target. +/// +/// Fetch and report only — the caller shows the diff and the user decides +/// whether any of it reaches their workspace. +#[tauri::command] +pub async fn dispatch_pull_result( + state: State<'_, AppState>, + path_manager: State<'_, Arc>, + request: DispatchJobRequest, +) -> Result { + let store = OutboundDispatchStore::new(path_manager.as_ref()); + let manager = state + .get_ssh_manager_async() + .await + .map_err(|error| error.to_string())?; + pull_dispatch_result(&manager, &store, request) + .await + .map_err(|error| error.to_string()) +} + +/// Apply a pulled result bundle to a local workspace. +/// +/// Aborts without writing when a path changed on both sides, unless the user +/// explicitly chose to take the target's version. +#[tauri::command] +pub async fn dispatch_apply_result( + path_manager: State<'_, Arc>, + request: DispatchApplyResultRequest, +) -> Result { + let store = OutboundDispatchStore::new(path_manager.as_ref()); + apply_dispatch_result(&store, request) + .await + .map_err(|error| error.to_string()) +} + #[tauri::command] pub async fn dispatch_cancel( 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 a9adbc0892..25e3bf6b51 100644 --- a/src/apps/desktop/src/api/peer_host_invoke.rs +++ b/src/apps/desktop/src/api/peer_host_invoke.rs @@ -98,12 +98,15 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ "dispatch_list_targets", "dispatch_probe_target", "dispatch_install_cli_start", + "dispatch_install_cli_source_start", "dispatch_install_cli_poll", "dispatch_install_cli_cancel", "dispatch_sync_model_config", "dispatch_submit", "dispatch_status", "dispatch_cancel", + "dispatch_pull_result", + "dispatch_apply_result", "dispatch_list_jobs", "dispatch_answer", "dispatch_append", diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 6e33603c13..ecc7b0443f 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -349,6 +349,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "dispatch_install_cli_poll", RemoteWorkspacePolicy::WorkspaceAgnostic, ), + ( + "dispatch_install_cli_source_start", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), ( "dispatch_install_cli_start", RemoteWorkspacePolicy::WorkspaceAgnostic, @@ -357,6 +361,14 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "dispatch_list_jobs", RemoteWorkspacePolicy::WorkspaceAgnostic, ), + ( + "dispatch_apply_result", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "dispatch_pull_result", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), ( "dispatch_list_targets", RemoteWorkspacePolicy::WorkspaceAgnostic, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 93d040a755..0f086330ac 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1746,12 +1746,15 @@ pub async fn run() { api::dispatch_api::dispatch_list_targets, api::dispatch_api::dispatch_probe_target, api::dispatch_api::dispatch_install_cli_start, + api::dispatch_api::dispatch_install_cli_source_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, + api::dispatch_api::dispatch_pull_result, + api::dispatch_api::dispatch_apply_result, api::dispatch_api::dispatch_list_jobs, api::dispatch_api::dispatch_answer, api::dispatch_api::dispatch_append, diff --git a/src/crates/assembly/core/src/service/dispatch/controller.rs b/src/crates/assembly/core/src/service/dispatch/controller.rs index 0ea9c27f05..2d364fb821 100644 --- a/src/crates/assembly/core/src/service/dispatch/controller.rs +++ b/src/crates/assembly/core/src/service/dispatch/controller.rs @@ -1,3 +1,7 @@ +use anyhow::Context as _; +use bitfun_services_core::dispatch_workspace::{ + apply_workspace_result_bundle, WorkspaceResultApplyOutcome, WorkspaceResultSummary, +}; use bitfun_services_integrations::remote_ssh::{ dispatch_ssh::{ self, DispatchCliRelease, DispatchInstallPoll, DispatchInstallStart, DispatchSshProbe, @@ -77,6 +81,17 @@ pub struct DispatchJobRequest { pub job_id: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DispatchApplyResultRequest { + pub job_id: String, + /// Local workspace the bundle is applied to. + pub workspace_path: String, + /// Take the target's version for paths that changed on both sides. + #[serde(default)] + pub overwrite_conflicts: bool, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum DispatchPermissionReplyKind { @@ -184,6 +199,14 @@ pub async fn install_cli_start( dispatch_ssh::install_cli_start(manager, request.connection_id.trim(), &request.release).await } +/// Build and install the CLI from source, for targets no published binary fits. +pub async fn install_cli_source_start( + manager: &SSHConnectionManager, + request: DispatchConnectionRequest, +) -> anyhow::Result { + dispatch_ssh::install_cli_source_start(manager, request.connection_id.trim()).await +} + pub async fn install_cli_poll( manager: &SSHConnectionManager, request: DispatchInstallPollRequest, @@ -459,6 +482,74 @@ pub async fn status( Ok(response) } +/// Fetch what a finished snapshot job changed on its target. +/// +/// Download and inspection only. The bundle lands in this controller's own +/// staging area; nothing touches the user's workspace until they review the +/// reported diff and explicitly apply it. The target tree and the local tree +/// have diverged independently since the snapshot, so silently merging would +/// be the one thing detached execution must never do. +pub async fn pull_result( + manager: &SSHConnectionManager, + store: &OutboundDispatchStore, + request: DispatchJobRequest, +) -> anyhow::Result { + let record = store + .get(&request.job_id) + .await? + .ok_or_else(|| anyhow::anyhow!("Outbound dispatch job was not found"))?; + let DispatchTarget::Ssh { connection_id, .. } = &record.target else { + anyhow::bail!("Pulling dispatch results requires an SSH target"); + }; + let destination = result_bundle_path(store, &request.job_id); + let response = + dispatch_ssh::pull_result(manager, connection_id, &request.job_id, &destination).await?; + // Persist the summary next to the bundle so applying reads both from disk. + // The digests that decide whether a local file may be overwritten must come + // from the verified pull, not from whatever the caller hands back later. + if let Some(summary) = response.get("summary") { + let summary_path = result_summary_path(store, &request.job_id); + std::fs::write(&summary_path, serde_json::to_vec(summary)?) + .with_context(|| format!("record result summary {}", summary_path.display()))?; + } + Ok(response) +} + +fn result_bundle_path(store: &OutboundDispatchStore, job_id: &str) -> std::path::PathBuf { + store.root().join(".results").join(format!("{job_id}.tar.gz")) +} + +fn result_summary_path(store: &OutboundDispatchStore, job_id: &str) -> std::path::PathBuf { + store.root().join(".results").join(format!("{job_id}.json")) +} + +/// Apply a pulled result bundle to a local workspace. +/// +/// Refuses to write anything when a path changed on both sides unless the user +/// explicitly chose to take the target's version. +pub async fn apply_result( + store: &OutboundDispatchStore, + request: DispatchApplyResultRequest, +) -> anyhow::Result { + let workspace = request.workspace_path.trim(); + if workspace.is_empty() { + anyhow::bail!("Applying dispatch results requires a workspacePath"); + } + let bundle = result_bundle_path(store, &request.job_id); + if !bundle.is_file() { + anyhow::bail!("Pull the dispatch result before applying it"); + } + let summary: WorkspaceResultSummary = + serde_json::from_slice(&std::fs::read(result_summary_path(store, &request.job_id))?) + .context("read recorded dispatch result summary")?; + apply_workspace_result_bundle( + &bundle, + std::path::Path::new(workspace), + &summary, + request.overwrite_conflicts, + ) +} + pub async fn cancel( manager: &SSHConnectionManager, store: &OutboundDispatchStore, diff --git a/src/crates/assembly/core/src/service/dispatch/device_controller.rs b/src/crates/assembly/core/src/service/dispatch/device_controller.rs index c745f2b632..0c15504170 100644 --- a/src/crates/assembly/core/src/service/dispatch/device_controller.rs +++ b/src/crates/assembly/core/src/service/dispatch/device_controller.rs @@ -74,6 +74,10 @@ pub async fn probe_device( protocol_error, release: None, protocol: Some(protocol), + // An account device runs its own already-installed CLI; this controller + // neither installs nor builds anything for it. + prebuilt_incompatible: None, + source_build: None, }) } diff --git a/src/crates/assembly/core/src/service/dispatch/mod.rs b/src/crates/assembly/core/src/service/dispatch/mod.rs index c1e1cd962f..3b4109912a 100644 --- a/src/crates/assembly/core/src/service/dispatch/mod.rs +++ b/src/crates/assembly/core/src/service/dispatch/mod.rs @@ -18,15 +18,25 @@ use tokio::fs; use crate::infrastructure::PathManager; +/// Result-bundle shapes the desktop layer returns to the renderer. +#[cfg(feature = "ssh-remote")] +pub use bitfun_services_core::dispatch_workspace::{ + WorkspaceResultApplyOutcome, WorkspaceResultConflict, WorkspaceResultConflictReason, + WorkspaceResultSummary, +}; #[cfg(feature = "ssh-remote")] pub use controller::{ answer as answer_dispatch, append as append_dispatch, cancel as cancel_dispatch, install_cli_cancel as cancel_dispatch_cli_install, - install_cli_poll as poll_dispatch_cli_install, install_cli_start as start_dispatch_cli_install, + install_cli_poll as poll_dispatch_cli_install, + install_cli_source_start as start_dispatch_cli_source_build, + 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, + apply_result as apply_dispatch_result, probe_target as probe_dispatch_target, + pull_result as pull_dispatch_result, + status as get_dispatch_status, submit as submit_dispatch, sync_model_config as sync_dispatch_model_config, - DispatchAnswerRequest, DispatchAppendRequest, + DispatchAnswerRequest, DispatchApplyResultRequest, DispatchAppendRequest, DispatchConnectionRequest, DispatchInstallPollRequest, DispatchInstallStartRequest, DispatchJobRequest, DispatchListJobsRequest, DispatchListTargetsRequest, DispatchPermissionReplyKind, DispatchProbeTargetRequest, DispatchStatusRequest, diff --git a/src/crates/services/services-core/src/dispatch_workspace.rs b/src/crates/services/services-core/src/dispatch_workspace.rs index aaba4e555d..ef3f26f6db 100644 --- a/src/crates/services/services-core/src/dispatch_workspace.rs +++ b/src/crates/services/services-core/src/dispatch_workspace.rs @@ -3,7 +3,7 @@ //! A snapshot is a one-shot input boundary. It deliberately does not contain //! Git metadata and never follows links outside the selected workspace. -use std::collections::{BTreeMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::fs::{self, File, OpenOptions}; use std::io::{self, Read, Write}; use std::path::{Component, Path}; @@ -25,6 +25,7 @@ pub const MAX_SNAPSHOT_UNCOMPRESSED_BYTES: u64 = 2 * 1024 * 1024 * 1024; pub const MAX_SNAPSHOT_ARCHIVE_BYTES: u64 = 1024 * 1024 * 1024; const MAX_MANIFEST_BYTES: u64 = 16 * 1024 * 1024; const MANIFEST_ARCHIVE_PATH: &str = ".bitfun-dispatch/manifest.json"; +const RESULT_SUMMARY_ARCHIVE_PATH: &str = ".bitfun-dispatch/result.json"; const WORKSPACE_ARCHIVE_ROOT: &str = "workspace"; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -70,6 +71,336 @@ pub struct WorkspaceSnapshotMetadata { pub uncompressed_bytes: u64, } +/// What the target changed, relative to the snapshot it was given. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WorkspaceResultSummary { + pub added: Vec, + pub modified: Vec, + pub deleted: Vec, + /// Snapshot digest of every path the target changed or removed. + /// + /// Carried so the controller can tell a clean apply from one that would + /// discard local edits: if the local file still matches this, the target's + /// change is the only one; if it does not, both sides moved. + #[serde(default)] + pub baseline_sha256: BTreeMap, + pub archive_size: u64, + pub archive_sha256: String, +} + +impl WorkspaceResultSummary { + pub fn is_empty(&self) -> bool { + self.added.is_empty() && self.modified.is_empty() && self.deleted.is_empty() + } +} + +/// Diff the terminal target tree against the delivered snapshot and package +/// only what changed. +/// +/// Content-addressed rather than git-based: the baseline manifest already +/// carries a SHA-256 per file, so this works for workspaces that are not +/// repositories — which is most of the reason snapshot mode exists. +/// +/// Produces the bundle only; applying it locally stays a separate, explicitly +/// confirmed step. +pub fn create_workspace_result_bundle( + workspace: &Path, + baseline: &WorkspaceSnapshotManifest, + archive_path: &Path, +) -> Result { + let workspace = workspace + .canonicalize() + .with_context(|| format!("resolve dispatch workspace {}", workspace.display()))?; + let baseline_files: BTreeMap<&str, &WorkspaceSnapshotEntry> = baseline + .entries + .iter() + .filter(|entry| entry.kind == WorkspaceSnapshotEntryKind::File) + .map(|entry| (entry.path.as_str(), entry)) + .collect(); + + let archive_file = File::create(archive_path) + .with_context(|| format!("create result bundle {}", archive_path.display()))?; + let encoder = GzEncoder::new(archive_file, Compression::default()); + let mut archive = Builder::new(encoder); + archive.mode(tar::HeaderMode::Deterministic); + + let mut walk = WalkBuilder::new(&workspace); + walk.hidden(false) + .ignore(false) + .git_ignore(false) + .git_global(false) + .git_exclude(false) + .parents(false) + .follow_links(false) + .sort_by_file_path(|left, right| left.cmp(right)); + let filter_root = workspace.clone(); + walk.filter_entry(move |entry| { + entry.path() == filter_root || entry.file_name().to_str() != Some(".git") + }); + + let mut summary = WorkspaceResultSummary::default(); + let mut seen = BTreeSet::new(); + let mut file_count = 0_u64; + let mut changed_bytes = 0_u64; + for walked in walk.build() { + let walked = walked.context("walk dispatch workspace for result bundle")?; + let path = walked.path(); + if path == workspace { + continue; + } + let relative = path + .strip_prefix(&workspace) + .with_context(|| format!("resolve result path {}", path.display()))?; + let relative_wire = portable_relative_path(relative)?; + let metadata = fs::symlink_metadata(path) + .with_context(|| format!("inspect result entry {}", path.display()))?; + // Same rule as packaging: an unsupported entry fails the whole + // operation rather than yielding a bundle that silently omits it. + if metadata.file_type().is_symlink() { + bail!("dispatch result contains a symlink: {relative_wire}"); + } + if metadata.is_dir() { + continue; + } + if !metadata.is_file() { + bail!("dispatch result contains an unsupported entry: {relative_wire}"); + } + seen.insert(relative_wire.clone()); + file_count += 1; + if file_count > MAX_SNAPSHOT_FILES { + bail!("dispatch result exceeds the {MAX_SNAPSHOT_FILES} file limit"); + } + if metadata.len() > MAX_SNAPSHOT_FILE_BYTES { + bail!("dispatch result file exceeds the size limit: {relative_wire}"); + } + + let digest = sha256_file(path)?; + let existing = baseline_files.get(relative_wire.as_str()); + let unchanged = existing + .and_then(|entry| entry.sha256.as_deref()) + .is_some_and(|baseline_digest| baseline_digest.eq_ignore_ascii_case(&digest)); + if unchanged { + continue; + } + + changed_bytes = changed_bytes.saturating_add(metadata.len()); + if changed_bytes > MAX_SNAPSHOT_UNCOMPRESSED_BYTES { + bail!("dispatch result exceeds the uncompressed size limit"); + } + let executable = is_executable(&metadata); + append_file(&mut archive, path, &relative_wire, &metadata, executable)?; + if let Some(entry) = existing { + if let Some(baseline_digest) = entry.sha256.as_deref() { + summary + .baseline_sha256 + .insert(relative_wire.clone(), baseline_digest.to_string()); + } + summary.modified.push(relative_wire); + } else { + summary.added.push(relative_wire); + } + } + + for (path, entry) in &baseline_files { + if seen.contains(*path) { + continue; + } + summary.deleted.push((*path).to_string()); + if let Some(baseline_digest) = entry.sha256.as_deref() { + summary + .baseline_sha256 + .insert((*path).to_string(), baseline_digest.to_string()); + } + } + + let summary_bytes = serde_json::to_vec(&WorkspaceResultSummary { + archive_sha256: String::new(), + archive_size: 0, + ..summary.clone() + }) + .context("encode dispatch result summary")?; + append_bytes(&mut archive, RESULT_SUMMARY_ARCHIVE_PATH, &summary_bytes, false)?; + + archive + .into_inner() + .context("finalize dispatch result bundle")? + .finish() + .context("compress dispatch result bundle")? + .sync_all() + .context("flush dispatch result bundle")?; + + let archive_metadata = fs::metadata(archive_path) + .with_context(|| format!("inspect result bundle {}", archive_path.display()))?; + summary.archive_size = archive_metadata.len(); + if summary.archive_size > MAX_SNAPSHOT_ARCHIVE_BYTES { + bail!("dispatch result bundle exceeds the archive size limit"); + } + summary.archive_sha256 = sha256_file(archive_path)?; + Ok(summary) +} + +/// A local path both sides changed since the snapshot was taken. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WorkspaceResultConflict { + pub path: String, + /// Why the local file no longer matches the snapshot. + pub reason: WorkspaceResultConflictReason, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum WorkspaceResultConflictReason { + /// Edited locally after the snapshot, and edited on the target too. + LocallyModified, + /// Deleted locally, but the target changed it. + LocallyMissing, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceResultApplyOutcome { + pub written: Vec, + pub removed: Vec, + pub conflicts: Vec, + /// True when nothing was touched because conflicts were found. + pub aborted: bool, +} + +/// Report which paths a result bundle would overwrite that also changed locally. +/// +/// The controller and the target diverged independently after `S0`, so this is +/// the difference between "apply the target's work" and "silently discard mine". +pub fn inspect_workspace_result_conflicts( + workspace: &Path, + summary: &WorkspaceResultSummary, +) -> Result> { + let workspace = workspace + .canonicalize() + .with_context(|| format!("resolve workspace {}", workspace.display()))?; + let mut conflicts = Vec::new(); + for (path, baseline_digest) in &summary.baseline_sha256 { + let local = resolve_workspace_child(&workspace, path)?; + match fs::symlink_metadata(&local) { + Ok(metadata) if metadata.is_file() => { + if !sha256_file(&local)?.eq_ignore_ascii_case(baseline_digest) { + conflicts.push(WorkspaceResultConflict { + path: path.clone(), + reason: WorkspaceResultConflictReason::LocallyModified, + }); + } + } + // A path the snapshot contained that is now gone or is no longer a + // regular file: applying would resurrect or clobber it. + Ok(_) | Err(_) => conflicts.push(WorkspaceResultConflict { + path: path.clone(), + reason: WorkspaceResultConflictReason::LocallyMissing, + }), + } + } + Ok(conflicts) +} + +/// Apply a verified result bundle to a local workspace. +/// +/// Refuses to touch anything when a conflict is found unless `overwrite` is +/// set, so the default outcome of a surprise is nothing rather than a +/// half-merged tree. +pub fn apply_workspace_result_bundle( + bundle_path: &Path, + workspace: &Path, + summary: &WorkspaceResultSummary, + overwrite: bool, +) -> Result { + let actual = sha256_file(bundle_path)?; + if !actual.eq_ignore_ascii_case(&summary.archive_sha256) { + bail!("dispatch result bundle does not match the reported digest"); + } + let conflicts = inspect_workspace_result_conflicts(workspace, summary)?; + if !conflicts.is_empty() && !overwrite { + return Ok(WorkspaceResultApplyOutcome { + conflicts, + aborted: true, + ..Default::default() + }); + } + let workspace = workspace + .canonicalize() + .with_context(|| format!("resolve workspace {}", workspace.display()))?; + + let expected: BTreeSet<&str> = summary + .added + .iter() + .chain(summary.modified.iter()) + .map(String::as_str) + .collect(); + let file = File::open(bundle_path) + .with_context(|| format!("open result bundle {}", bundle_path.display()))?; + let mut archive = Archive::new(GzDecoder::new(file)); + let mut outcome = WorkspaceResultApplyOutcome { + conflicts, + ..Default::default() + }; + for entry in archive.entries().context("read result bundle")? { + let mut entry = entry.context("read result bundle entry")?; + let entry_path = entry.path().context("read result bundle entry path")?; + let Ok(relative) = entry_path.strip_prefix(WORKSPACE_ARCHIVE_ROOT) else { + continue; // the bundle's own metadata + }; + let relative_wire = portable_relative_path(relative)?; + if !expected.contains(relative_wire.as_str()) { + bail!("dispatch result bundle contains an unreported path: {relative_wire}"); + } + if entry.header().entry_type() != EntryType::Regular { + bail!("dispatch result bundle contains a non-regular entry: {relative_wire}"); + } + let destination = resolve_workspace_child(&workspace, &relative_wire)?; + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("create {}", parent.display()))?; + } + let mut bytes = Vec::new(); + entry + .read_to_end(&mut bytes) + .with_context(|| format!("read {relative_wire} from result bundle"))?; + fs::write(&destination, &bytes) + .with_context(|| format!("write {}", destination.display()))?; + outcome.written.push(relative_wire); + } + + for path in &summary.deleted { + let destination = resolve_workspace_child(&workspace, path)?; + match fs::symlink_metadata(&destination) { + Ok(metadata) if metadata.is_file() => { + fs::remove_file(&destination) + .with_context(|| format!("remove {}", destination.display()))?; + outcome.removed.push(path.clone()); + } + // Already gone locally: the desired end state, nothing to do. + _ => {} + } + } + Ok(outcome) +} + +/// Join a manifest-relative path under the workspace, refusing anything that +/// would land outside it. +fn resolve_workspace_child(workspace: &Path, relative_wire: &str) -> Result { + if relative_wire.is_empty() { + bail!("dispatch result path is empty"); + } + let mut resolved = workspace.to_path_buf(); + for part in relative_wire.split('/') { + if part.is_empty() || part == "." || part == ".." { + bail!("dispatch result path is not workspace-relative: {relative_wire}"); + } + resolved.push(part); + } + ensure_path_below(workspace, &resolved)?; + Ok(resolved) +} + /// Package every regular workspace file, including hidden and ignored files. /// /// `.git` entries are the one explicit metadata exclusion. Unsupported entries @@ -829,6 +1160,211 @@ mod tests { assert!(manifest.excludes_git_metadata); } + #[test] + fn result_bundle_reports_adds_edits_and_deletes_without_git() { + let temp = tempfile::tempdir().expect("tempdir"); + let source = temp.path().join("source"); + fs::create_dir_all(source.join("nested")).expect("nested"); + fs::write(source.join("keep.txt"), b"unchanged").expect("keep"); + fs::write(source.join("edit.txt"), b"before").expect("edit"); + fs::write(source.join("gone.txt"), b"remove me").expect("gone"); + fs::write(source.join("nested/deep.txt"), b"deep").expect("deep"); + let archive = temp.path().join("snapshot.tar.gz"); + let metadata = create_exact_workspace_snapshot(&source, &archive).expect("snapshot"); + let target = temp.path().join("current"); + let baseline = extract_workspace_snapshot(&archive, &target, &metadata).expect("extract"); + + // Stand in for what the agent did on the target. + fs::write(target.join("edit.txt"), b"after").expect("modify"); + fs::remove_file(target.join("gone.txt")).expect("delete"); + fs::write(target.join("new.txt"), b"created").expect("add"); + // A rewrite with identical bytes must not count as a change. + fs::write(target.join("keep.txt"), b"unchanged").expect("rewrite"); + + let bundle = temp.path().join("result.tar.gz"); + let summary = + create_workspace_result_bundle(&target, &baseline, &bundle).expect("result bundle"); + + assert_eq!(summary.added, vec!["new.txt".to_string()]); + assert_eq!(summary.modified, vec!["edit.txt".to_string()]); + assert_eq!(summary.deleted, vec!["gone.txt".to_string()]); + assert!(!summary.is_empty()); + assert_eq!(summary.archive_sha256.len(), 64); + assert!(summary.archive_size > 0); + + // Only changed content travels back; untouched files are not resent. + let listed = list_archive_paths(&bundle); + assert!(listed.contains(&"new.txt".to_string())); + assert!(listed.contains(&"edit.txt".to_string())); + assert!( + !listed.contains(&"keep.txt".to_string()), + "unchanged files must not be included: {listed:?}" + ); + assert!(!listed.contains(&"nested/deep.txt".to_string())); + } + + #[test] + fn an_untouched_workspace_produces_an_empty_result() { + let temp = tempfile::tempdir().expect("tempdir"); + let source = temp.path().join("source"); + fs::create_dir_all(&source).expect("source"); + fs::write(source.join("a.txt"), b"a").expect("a"); + let archive = temp.path().join("snapshot.tar.gz"); + let metadata = create_exact_workspace_snapshot(&source, &archive).expect("snapshot"); + let target = temp.path().join("current"); + let baseline = extract_workspace_snapshot(&archive, &target, &metadata).expect("extract"); + + let bundle = temp.path().join("result.tar.gz"); + let summary = + create_workspace_result_bundle(&target, &baseline, &bundle).expect("result bundle"); + assert!( + summary.is_empty(), + "a job that changed nothing must not report changes: {summary:?}" + ); + } + + /// Snapshot a workspace, mutate the "target" copy, and bundle the result. + fn snapshot_and_diff( + temp: &Path, + seed: &[(&str, &[u8])], + mutate: impl FnOnce(&Path), + ) -> (WorkspaceResultSummary, std::path::PathBuf, std::path::PathBuf) { + let source = temp.join("source"); + fs::create_dir_all(&source).expect("source"); + for (name, bytes) in seed { + fs::write(source.join(name), bytes).expect("seed file"); + } + let archive = temp.join("snapshot.tar.gz"); + let metadata = create_exact_workspace_snapshot(&source, &archive).expect("snapshot"); + let target = temp.join("current"); + let baseline = extract_workspace_snapshot(&archive, &target, &metadata).expect("extract"); + mutate(&target); + let bundle = temp.join("result.tar.gz"); + let summary = + create_workspace_result_bundle(&target, &baseline, &bundle).expect("bundle"); + // A second extraction stands in for the controller's own copy of S0. + let local = temp.join("local"); + extract_workspace_snapshot(&archive, &local, &metadata).expect("extract local"); + (summary, bundle, local) + } + + #[test] + fn applying_a_result_writes_adds_and_edits_and_removes_deletions() { + let temp = tempfile::tempdir().expect("tempdir"); + let (summary, bundle, local) = snapshot_and_diff( + temp.path(), + &[("keep.txt", b"same"), ("edit.txt", b"before"), ("gone.txt", b"bye")], + |target| { + fs::write(target.join("edit.txt"), b"after").expect("edit"); + fs::remove_file(target.join("gone.txt")).expect("delete"); + fs::write(target.join("new.txt"), b"created").expect("add"); + }, + ); + + let outcome = apply_workspace_result_bundle(&bundle, &local, &summary, false) + .expect("apply"); + assert!(!outcome.aborted, "an untouched local tree has no conflicts"); + assert!(outcome.conflicts.is_empty()); + assert_eq!(fs::read(local.join("edit.txt")).expect("edit"), b"after"); + assert_eq!(fs::read(local.join("new.txt")).expect("new"), b"created"); + assert!(!local.join("gone.txt").exists(), "deletions must be applied"); + assert_eq!( + fs::read(local.join("keep.txt")).expect("keep"), + b"same", + "untouched files must be left alone" + ); + } + + #[test] + fn a_locally_edited_file_blocks_the_apply_instead_of_being_overwritten() { + let temp = tempfile::tempdir().expect("tempdir"); + let (summary, bundle, local) = snapshot_and_diff( + temp.path(), + &[("shared.txt", b"before")], + |target| { + fs::write(target.join("shared.txt"), b"target edit").expect("edit"); + }, + ); + // The user kept working locally while the job ran. + fs::write(local.join("shared.txt"), b"my local work").expect("local edit"); + + let outcome = apply_workspace_result_bundle(&bundle, &local, &summary, false) + .expect("apply"); + assert!(outcome.aborted, "a conflict must stop the apply"); + assert_eq!( + outcome.conflicts, + vec![WorkspaceResultConflict { + path: "shared.txt".to_string(), + reason: WorkspaceResultConflictReason::LocallyModified, + }] + ); + assert!(outcome.written.is_empty() && outcome.removed.is_empty()); + assert_eq!( + fs::read(local.join("shared.txt")).expect("local"), + b"my local work", + "nothing may be written when the apply aborts" + ); + + // The user can still choose the target's version explicitly. + let forced = + apply_workspace_result_bundle(&bundle, &local, &summary, true).expect("forced apply"); + assert!(!forced.aborted); + assert_eq!(fs::read(local.join("shared.txt")).expect("local"), b"target edit"); + } + + #[test] + fn a_tampered_bundle_is_rejected_before_anything_is_written() { + let temp = tempfile::tempdir().expect("tempdir"); + let (summary, bundle, local) = snapshot_and_diff( + temp.path(), + &[("a.txt", b"before")], + |target| { + fs::write(target.join("a.txt"), b"after").expect("edit"); + }, + ); + fs::write(&bundle, b"not the bundle you verified").expect("tamper"); + + let error = apply_workspace_result_bundle(&bundle, &local, &summary, false) + .expect_err("a tampered bundle must be refused"); + assert!( + error.to_string().contains("does not match the reported digest"), + "{error}" + ); + assert_eq!(fs::read(local.join("a.txt")).expect("local"), b"before"); + } + + #[test] + fn result_paths_cannot_escape_the_workspace() { + let temp = tempfile::tempdir().expect("tempdir"); + let workspace = temp.path().join("ws"); + fs::create_dir_all(&workspace).expect("workspace"); + for hostile in ["../outside", "a/../../outside", "/etc/passwd", ""] { + assert!( + resolve_workspace_child(&workspace, hostile).is_err(), + "must reject {hostile:?}" + ); + } + assert!(resolve_workspace_child(&workspace, "nested/ok.txt").is_ok()); + } + + fn list_archive_paths(archive_path: &Path) -> Vec { + let file = File::open(archive_path).expect("open bundle"); + let mut archive = Archive::new(GzDecoder::new(file)); + archive + .entries() + .expect("entries") + .map(|entry| { + let entry = entry.expect("entry"); + entry + .path() + .expect("path") + .strip_prefix(WORKSPACE_ARCHIVE_ROOT) + .map(|path| path.to_string_lossy().to_string()) + .unwrap_or_default() + }) + .collect() + } + #[test] fn exact_snapshot_round_trips_paths_longer_than_a_legacy_tar_header() { let temp = tempfile::tempdir().expect("tempdir"); 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 447500c168..c0166ff8ac 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 @@ -32,10 +32,23 @@ const INSTALL_STEM: &str = "install-cli"; const INSTALL_DONE_MARKER: &str = "BITFUN_DISPATCH_CLI_INSTALL_DONE"; const INSTALL_PREPARE_GRACE_SECONDS: u64 = 30; const COMMAND_TIMEOUT_MS: u64 = 30_000; +/// A release archive is tens of megabytes and the target's uplink is unknown, +/// so this is far longer than an ordinary setup command. +const TARGET_DOWNLOAD_TIMEOUT_MS: u64 = 10 * 60 * 1000; const WORKSPACE_COMMIT_POLL_INTERVAL: Duration = Duration::from_millis(750); const WORKSPACE_COMMIT_WAIT: Duration = Duration::from_secs(15 * 60); const RELEASE_READ_TIMEOUT_SECONDS: u64 = 30; const MAX_ARCHIVE_BYTES: usize = 512 * 1024 * 1024; +/// A result bundle carries only changed files, so it is bounded well below a +/// full workspace snapshot. +const MAX_RESULT_BUNDLE_BYTES: u64 = 1024 * 1024 * 1024; +/// Oldest glibc the published Linux binaries run against. Kept in step with +/// `scripts/ci/check-glibc-floor.sh`, which enforces it at release time. +const GLIBC_FLOOR: &str = "2.35"; +/// A release build of the workspace needs roughly this much scratch space. +/// Same figure the relay source build uses. +const SOURCE_BUILD_FREE_KB: u64 = 6 * 1024 * 1024; +const REPO_GIT_URL: &str = "https://github.com/GCWing/BitFun.git"; const DISPATCH_PROTOCOL_VERSION: u64 = 2; const REQUIRED_DISPATCH_CAPABILITIES: [&str; 12] = [ "persistent_jobs", @@ -73,6 +86,11 @@ pub struct DispatchSshProbe { pub protocol_error: Option, pub release: Option, pub protocol: Option, + /// Present only when the published binaries cannot run here, so the UI can + /// explain why instead of offering an install that would fail the same way. + pub prebuilt_incompatible: Option, + /// Offered as the way forward when a prebuilt install cannot work. + pub source_build: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -109,7 +127,41 @@ struct RemoteTarget { arch: String, home: String, cli_path: Option, + /// Version string of the installed CLI, when one is present and runnable. + cli_version: Option, tar_available: bool, + /// Fetcher the target can use to pull the release itself, if any. + downloader: Option, + /// Command the target can use to check a SHA256 digest, if any. + digest_tool: Option, + /// C library family on Linux targets; `None` off Linux or when unknown. + libc: Option, + /// glibc version, when the target reported one. + libc_version: Option, + cargo_version: Option, + git_available: bool, + cc_available: bool, + free_kb: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RemoteLibc { + Glibc, + Musl, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RemoteDownloader { + Curl, + Wget, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RemoteDigestTool { + /// GNU coreutils, present on Linux. + Sha256Sum, + /// Perl-based, what macOS ships instead. + Shasum, } #[derive(Debug)] @@ -119,6 +171,14 @@ struct ResolvedRelease { checksum_url: String, checksum_signature_url: String, archive_signature_url: String, + /// Whether `public.sha256` came from a minisign signature this machine + /// verified, rather than from an unauthenticated sidecar. + /// + /// The target has no minisign and no trust root, so letting it fetch the + /// archive itself is only safe when the digest we hand it is provably the + /// publisher's. Without that proof the archive's own signature is the only + /// protection, and only this machine can check it. + checksum_signature_verified: bool, } /// Probe the remote OS/architecture and, when present, the target CLI dispatch @@ -160,15 +220,40 @@ pub async fn probe( || !protocol .as_ref() .is_some_and(dispatch_protocol_is_compatible); + // A platform mismatch is decided before any network work: no release exists + // that would install successfully, so resolving one only hides the reason. + let incompatibility = needs_install.then(|| prebuilt_incompatibility(&target)).flatten(); let (release, install_error) = if needs_install { - if !target.tar_available { + if let Some(incompatibility) = &incompatibility { + (None, Some(incompatibility.describe())) + } else if !target.tar_available { ( None, Some("remote target has no tar executable; install tar and retry".to_string()), ) } else { match resolve_release(&target.os, &target.arch).await { - Ok(release) => (Some(release.public), None), + Ok(release) => match already_at_release_version(&target, &release) { + // Reinstalling a release the target already runs cannot add + // a protocol it does not implement. Offering the install + // anyway traps the user in a loop of successful installs + // that never clear the incompatibility. + // + // Carry the probe's own error: a release that genuinely + // predates dispatch and a target that failed to answer for + // some other reason look identical from here, and only the + // underlying message tells them apart. + Some(version) => { + let detail = protocol_error.as_deref().unwrap_or("no dispatch protocol"); + ( + None, + Some(format!( + "target already runs BitFun CLI {version}, which did not answer the dispatch protocol ({detail}); reinstalling the same release cannot change this" + )), + ) + } + None => (Some(release.public), None), + }, Err(error) => (None, Some(error.to_string())), } } @@ -176,6 +261,13 @@ pub async fn probe( (None, None) }; let install_supported = release.is_some(); + // Offer the source build whenever the target needs a CLI but no prebuilt + // install can deliver one — an unsupported platform, a libc floor, a + // missing tar, an unreachable release, or a release that does not carry + // dispatch. Gating this on platform incompatibility alone left the last + // case with a warning and no way forward. + let source_build = + (needs_install && release.is_none()).then(|| source_build_availability(&target)); Ok(DispatchSshProbe { cli_installed: target.cli_path.is_some(), @@ -187,9 +279,143 @@ pub async fn probe( protocol_error, release, protocol, + prebuilt_incompatible: incompatibility.as_ref().map(PrebuiltIncompatibility::describe), + source_build, }) } +/// Why the published binaries cannot run on this target. +/// +/// Kept structured rather than a flat string so the UI can say what is actually +/// wrong — and, when a source build could fix it, offer that instead of leaving +/// the user with an unexplained failure. +#[derive(Debug, Clone, PartialEq, Eq)] +enum PrebuiltIncompatibility { + UnsupportedPlatform { os: String, arch: String }, + MuslLibc, + GlibcTooOld { found: String }, +} + +impl PrebuiltIncompatibility { + fn describe(&self) -> String { + match self { + Self::UnsupportedPlatform { os, arch } => format!( + "BitFun publishes no CLI binary for {os} {arch}" + ), + Self::MuslLibc => format!( + "target uses musl libc; published binaries are linked against glibc {GLIBC_FLOOR} or newer" + ), + Self::GlibcTooOld { found } => format!( + "target has glibc {found}; published binaries require {GLIBC_FLOOR} or newer" + ), + } + } +} + +/// Whether a source build could produce a working CLI on this target. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DispatchSourceBuild { + /// Whether a build could start right now. + pub supported: bool, + /// What the user must install or free up first, when it cannot. + pub blockers: Vec, + pub cargo_version: Option, + /// The git ref that would be built. + pub git_ref: String, +} + +/// Detect an incompatibility that no amount of reinstalling can fix. +fn prebuilt_incompatibility(target: &RemoteTarget) -> Option { + if release_target(&target.os, &target.arch).is_err() { + return Some(PrebuiltIncompatibility::UnsupportedPlatform { + os: target.os.clone(), + arch: target.arch.clone(), + }); + } + // Only Linux binaries carry a libc requirement; macOS builds do not. + if !target.os.trim().eq_ignore_ascii_case("Linux") { + return None; + } + match target.libc { + Some(RemoteLibc::Musl) => Some(PrebuiltIncompatibility::MuslLibc), + Some(RemoteLibc::Glibc) => { + let found = target.libc_version.as_deref()?; + (compare_versions(found, GLIBC_FLOOR) == std::cmp::Ordering::Less).then(|| { + PrebuiltIncompatibility::GlibcTooOld { + found: found.to_string(), + } + }) + } + None => None, + } +} + +/// Numeric dotted-version comparison. `2.9` must order below `2.35`, which a +/// lexicographic comparison would get backwards. +fn compare_versions(left: &str, right: &str) -> std::cmp::Ordering { + let parse = |value: &str| { + value + .split('.') + .map(|part| part.trim().parse::().unwrap_or(0)) + .collect::>() + }; + let (left, right) = (parse(left), parse(right)); + for index in 0..left.len().max(right.len()) { + let ordering = left + .get(index) + .copied() + .unwrap_or(0) + .cmp(&right.get(index).copied().unwrap_or(0)); + if ordering != std::cmp::Ordering::Equal { + return ordering; + } + } + std::cmp::Ordering::Equal +} + +fn source_build_availability(target: &RemoteTarget) -> DispatchSourceBuild { + let mut blockers = Vec::new(); + if target.cargo_version.is_none() { + blockers.push( + "no cargo on the target; install a Rust toolchain (https://rustup.rs) and retry" + .to_string(), + ); + } + if !target.git_available { + blockers.push("no git on the target".to_string()); + } + if !target.cc_available { + blockers.push("no C compiler on the target (install build-essential or equivalent)".to_string()); + } + if let Some(free_kb) = target.free_kb { + if free_kb < SOURCE_BUILD_FREE_KB { + blockers.push(format!( + "needs about {} GB free under $HOME, found {} GB", + SOURCE_BUILD_FREE_KB / 1024 / 1024, + free_kb / 1024 / 1024 + )); + } + } + DispatchSourceBuild { + supported: blockers.is_empty(), + blockers, + cargo_version: target.cargo_version.clone(), + git_ref: release_tag_for_version(RELEASE_VERSION), + } +} + +/// The version the target already runs, when it matches the release we would +/// install and therefore makes installing pointless. +/// +/// A CLI that answered `--version` with the exact release version is a working +/// binary, so the incompatibility is a missing feature in that release rather +/// than a damaged install. +fn already_at_release_version(target: &RemoteTarget, release: &ResolvedRelease) -> Option { + let installed = target.cli_version.as_deref()?; + (installed == release.public.version).then(|| installed.to_string()) +} + fn dispatch_protocol_is_compatible(protocol: &Value) -> bool { validate_dispatch_protocol(protocol, None).is_ok() } @@ -274,7 +500,6 @@ pub async fn install_cli_start( } let release = resolve_release(&target.os, &target.arch).await?; ensure_confirmed_release(&release.public, expected_release)?; - let archive = download_verified_archive(&release).await?; // Stop an earlier attempt before replacing any of its staged files. A // cancellation transport error is not safe to ignore: the old installer @@ -287,11 +512,6 @@ pub async fn install_cli_start( let archive_path = format!("{dir}/{}", release.filename); let body_path = format!("{dir}/{INSTALL_STEM}-body.sh"); let script_path = format!("{dir}/{INSTALL_STEM}.sh"); - let log_path = format!("{dir}/{INSTALL_STEM}.log"); - let pid_path = format!("{dir}/{INSTALL_STEM}.pid"); - let driver_pid_path = format!("{dir}/{INSTALL_STEM}.driver.pid"); - let prepare_path = format!("{dir}/{INSTALL_STEM}.preparing"); - let exit_path = format!("{dir}/{INSTALL_STEM}.exit"); let install_token = format!("bitfun-install-{}", uuid::Uuid::new_v4().as_simple()); exec_ok( @@ -306,22 +526,220 @@ pub async fn install_cli_start( ) .await?; + // Prefer letting the target pull the release straight from the publisher: + // the controller's uplink is usually the slowest hop, and pushing tens of + // megabytes through it is the worst available topology. Fall back to the + // push path whenever that is not safely possible. + let archive_source = match target_download_blocker(&target, &release) { + Some(reason) => ArchiveSource::ControllerPush { reason }, + None => match download_archive_on_target( + manager, + connection_id, + &target, + &release, + &archive_path, + ) + .await + { + Ok(()) => ArchiveSource::TargetDownload, + Err(error) => ArchiveSource::ControllerPush { + reason: bounded_detail(&error.to_string()), + }, + }, + }; + let body = to_unix_script(&install_body_script( &dir, &archive_path, &release.public.version, + &archive_source, )); let driver = to_unix_script(&install_driver_script(&dir, &body_path, &install_token)); + if let ArchiveSource::ControllerPush { reason } = &archive_source { + log::info!("BitFun CLI dispatch install is pushing the archive over SFTP: {reason}"); + let archive = download_verified_archive(&release).await?; + manager + .sftp_write(connection_id, &archive_path, &archive) + .await + .context("stage verified BitFun CLI archive")?; + } + stage_and_launch_installer( + manager, + connection_id, + &dir, + Some(&archive_path), + &body_path, + &script_path, + &body, + &driver, + &install_token, + ) + .await?; + + Ok(DispatchInstallStart { + script_path, + version: release.public.version, + target: release.public.target, + url: release.public.url, + sha256: release.public.sha256, + }) +} + +/// How the verified archive reached the target. +#[derive(Debug, Clone, PartialEq, Eq)] +enum ArchiveSource { + /// The target fetched the release itself and checked it against a digest + /// this machine proved with the publisher's signature. + TargetDownload, + /// This machine downloaded, fully verified, and pushed the bytes over SFTP. + /// Carries why the target could not fetch it, for the install log. + ControllerPush { reason: String }, +} + +/// Why this target cannot safely fetch the release itself, if it cannot. +/// +/// The signature rule is the load-bearing one. A target has no minisign and no +/// trust root, so it can only check a plain SHA256. That is sound when the +/// digest is provably the publisher's, which is exactly what a verified +/// `.sha256.sig` gives us. When that signature is absent the digest is +/// unauthenticated, and the archive's own signature — checkable only here — +/// becomes the sole protection, so the bytes must flow through this machine. +fn target_download_blocker(target: &RemoteTarget, release: &ResolvedRelease) -> Option { + if !release.checksum_signature_verified { + return Some("release checksum sidecar is unsigned".to_string()); + } + if target.downloader.is_none() { + return Some("target has neither curl nor wget".to_string()); + } + if target.digest_tool.is_none() { + return Some("target cannot verify a SHA256 digest".to_string()); + } + None +} + +/// Have the target fetch and digest-check the release into `archive_path`. +/// +/// Synchronous on purpose: it replaces the SFTP upload, which was synchronous +/// too, so a dropped session is no worse than before and the detached installer +/// below still only ever sees a fully verified archive already on disk. +async fn download_archive_on_target( + manager: &SSHConnectionManager, + connection_id: &str, + target: &RemoteTarget, + release: &ResolvedRelease, + archive_path: &str, +) -> Result<()> { + let downloader = target + .downloader + .context("target download requires curl or wget")?; + let digest_tool = target + .digest_tool + .context("target download requires a SHA256 checker")?; + let script = target_download_script( + downloader, + digest_tool, + archive_path, + &release.public.url, + &release.public.sha256, + ); + let result = manager + .execute_command_with_options( + connection_id, + &script, + SSHCommandOptions { + timeout_ms: Some(TARGET_DOWNLOAD_TIMEOUT_MS), + cancellation_token: None, + }, + ) + .await?; + ensure_command_completed(&result, "download BitFun CLI release on the target")?; + if result.exit_code != 0 { + return Err(remote_command_error( + "download BitFun CLI release on the target", + result.exit_code, + &result.stdout, + &result.stderr, + )); + } + Ok(()) +} + +fn target_download_script( + downloader: RemoteDownloader, + digest_tool: RemoteDigestTool, + archive_path: &str, + url: &str, + sha256: &str, +) -> String { + // Download to a scratch name and only publish it once the digest matches, + // so a truncated or tampered body can never be handed to the installer. + let fetch = match downloader { + RemoteDownloader::Curl => format!( + "curl -fsSL --retry 3 --retry-delay 1 --max-time {timeout} --max-filesize {max} -o \"$PART\" {url}", + timeout = TARGET_DOWNLOAD_TIMEOUT_MS / 1000, + max = MAX_ARCHIVE_BYTES, + url = shell_quote_posix(url), + ), + // wget has no --max-filesize; the size ceiling is enforced below. + RemoteDownloader::Wget => format!( + "wget -q --tries=3 --timeout={timeout} -O \"$PART\" {url}", + timeout = TARGET_DOWNLOAD_TIMEOUT_MS / 1000, + url = shell_quote_posix(url), + ), + }; + let verify = match digest_tool { + RemoteDigestTool::Sha256Sum => "sha256sum -c -", + RemoteDigestTool::Shasum => "shasum -a 256 -c -", + }; + format!( + r#"set -eu +umask 077 +ARCHIVE={archive} +PART="$ARCHIVE.part" +EXPECTED={sha} +MAX={max} +rm -f "$PART" +cleanup() {{ rm -f "$PART"; }} +trap cleanup EXIT +{fetch} +SIZE=$(wc -c <"$PART" | tr -d '[:space:]') +if [ "$SIZE" -gt "$MAX" ]; then + echo "ERROR: downloaded archive is larger than $MAX bytes" >&2 + exit 1 +fi +printf '%s %s\n' "$EXPECTED" "$PART" | {verify} +mv -f "$PART" "$ARCHIVE" +chmod 600 "$ARCHIVE" +trap - EXIT +"#, + archive = shell_quote_posix(archive_path), + sha = shell_quote_posix(sha256), + max = MAX_ARCHIVE_BYTES, + ) +} + +/// Stage the installer scripts and launch the detached body. +/// +/// Shared by the release and source-build paths so both get the same token +/// handshake, log truncation, and channel-leak-free launch. +#[allow(clippy::too_many_arguments)] +async fn stage_and_launch_installer( + manager: &SSHConnectionManager, + connection_id: &str, + dir: &str, + archive_path: Option<&str>, + body_path: &str, + script_path: &str, + body: &str, + driver: &str, + install_token: &str, +) -> Result<()> { manager - .sftp_write(connection_id, &archive_path, &archive) - .await - .context("stage verified BitFun CLI archive")?; - manager - .sftp_write(connection_id, &body_path, body.as_bytes()) + .sftp_write(connection_id, body_path, body.as_bytes()) .await .context("stage BitFun CLI install body")?; manager - .sftp_write(connection_id, &script_path, driver.as_bytes()) + .sftp_write(connection_id, script_path, driver.as_bytes()) .await .context("stage BitFun CLI install driver")?; @@ -329,15 +747,15 @@ pub async fn install_cli_start( manager, connection_id, &stage_install_command( - &archive_path, - &body_path, - &script_path, - &log_path, - &pid_path, - &driver_pid_path, - &prepare_path, - &exit_path, - &install_token, + archive_path, + body_path, + script_path, + &format!("{dir}/{INSTALL_STEM}.log"), + &format!("{dir}/{INSTALL_STEM}.pid"), + &format!("{dir}/{INSTALL_STEM}.driver.pid"), + &format!("{dir}/{INSTALL_STEM}.preparing"), + &format!("{dir}/{INSTALL_STEM}.exit"), + install_token, ), ) .await?; @@ -350,8 +768,8 @@ pub async fn install_cli_start( connection_id, &format!( "bash {} {}", - shell_quote_posix(&script_path), - shell_quote_posix(&install_token) + shell_quote_posix(script_path), + shell_quote_posix(install_token) ), 100, 30, @@ -368,13 +786,79 @@ pub async fn install_cli_start( let mut channel = channel; while channel.wait().await.is_some() {} }); + Ok(()) +} + +/// Build and install the CLI from source on the target. +/// +/// The way forward when no published binary can run there. Shares the install +/// driver, log, and poll/cancel machinery with the release path, so progress +/// reporting and cancellation behave identically. +pub async fn install_cli_source_start( + manager: &SSHConnectionManager, + connection_id: &str, +) -> Result { + ensure_plain_ssh_target(manager, connection_id).await?; + let target = probe_remote_target(manager, connection_id).await?; + let availability = source_build_availability(&target); + if !availability.supported { + return Err(anyhow!( + "target cannot build BitFun from source: {}", + availability.blockers.join("; ") + )); + } + + install_cli_cancel(manager, connection_id) + .await + .context("stop an earlier BitFun CLI installation")?; + + let dir = format!("{}/{}", target.home, INSTALL_STATE_DIR); + let body_path = format!("{dir}/{INSTALL_STEM}-body.sh"); + let script_path = format!("{dir}/{INSTALL_STEM}.sh"); + let install_token = format!("bitfun-install-{}", uuid::Uuid::new_v4().as_simple()); + let version = RELEASE_VERSION + .split('+') + .next() + .unwrap_or(RELEASE_VERSION) + .to_string(); + + exec_ok( + manager, + connection_id, + &format!( + "mkdir -p {dir} && chmod 700 {root} {dispatch} {dir}", + root = shell_quote_posix(&format!("{}/.bitfun", target.home)), + dispatch = shell_quote_posix(&format!("{}/.bitfun/dispatch", target.home)), + dir = shell_quote_posix(&dir), + ), + ) + .await?; + + let body = to_unix_script(&source_build_body_script( + &dir, + &version, + &availability.git_ref, + )); + let driver = to_unix_script(&install_driver_script(&dir, &body_path, &install_token)); + stage_and_launch_installer( + manager, + connection_id, + &dir, + None, + &body_path, + &script_path, + &body, + &driver, + &install_token, + ) + .await?; Ok(DispatchInstallStart { script_path, - version: release.public.version, - target: release.public.target, - url: release.public.url, - sha256: release.public.sha256, + version, + target: format!("{} {}", target.os, target.arch), + url: REPO_GIT_URL.to_string(), + sha256: String::new(), }) } @@ -709,6 +1193,84 @@ pub async fn append( invoke_json(manager, connection_id, "append", request).await } +/// Ask the target what a finished job changed, and fetch the bundle. +/// +/// Downloads only; nothing is written into the user's workspace here. Applying +/// the bundle is a separate operation the user confirms after seeing the diff, +/// because the local tree may have moved on since the snapshot was taken. +pub async fn pull_result( + manager: &SSHConnectionManager, + connection_id: &str, + job_id: &str, + destination: &std::path::Path, +) -> Result { + ensure_plain_ssh_target(manager, connection_id).await?; + let target = probe_remote_target(manager, connection_id).await?; + let cli_path = target.cli_path.as_deref().ok_or_else(|| { + anyhow!("BitFun CLI is not installed on the SSH target; confirm installation first") + })?; + let response = invoke_json_at_path( + manager, + connection_id, + &target.home, + cli_path, + // The target CLI exposes workspace data-plane verbs under reserved + // names, matching `__workspace_begin` and `__workspace_commit` above. + "__workspace_result", + &serde_json::json!({ "jobId": job_id }), + ) + .await?; + + let bundle_path = response + .get("bundlePath") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("dispatch target returned no result bundle path"))?; + // The path comes from the target, so bound it to the managed job directory + // before reading it, exactly as the upload path is bounded. + validate_managed_result_path(&target.home, job_id, bundle_path)?; + + let bytes = manager + .sftp_read(connection_id, bundle_path) + .await + .context("download dispatch result bundle")?; + if bytes.len() as u64 > MAX_RESULT_BUNDLE_BYTES { + return Err(anyhow!( + "dispatch result bundle exceeds the {} MB safety limit", + MAX_RESULT_BUNDLE_BYTES / (1024 * 1024) + )); + } + if let Some(parent) = destination.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("create result staging {}", parent.display()))?; + } + std::fs::write(destination, &bytes) + .with_context(|| format!("store result bundle {}", destination.display()))?; + + let mut response = response; + if let Some(object) = response.as_object_mut() { + object.insert( + "localBundlePath".to_string(), + Value::String(destination.to_string_lossy().to_string()), + ); + } + Ok(response) +} + +/// A result bundle may only be read from the managed directory of the job it +/// belongs to. +fn validate_managed_result_path(home: &str, job_id: &str, bundle_path: &str) -> Result<()> { + let expected = format!( + "{}/.bitfun/dispatch/workspaces/{job_id}/result.tar.gz", + home.trim_end_matches('/') + ); + if bundle_path != expected { + return Err(anyhow!( + "dispatch target returned an unexpected result bundle path" + )); + } + Ok(()) +} + /// Stage and atomically materialize a controller-created workspace snapshot. /// /// The target CLI chooses the owner-only upload path. This adapter validates @@ -1037,12 +1599,39 @@ async fn probe_remote_target( return Err(anyhow!("could not resolve remote $HOME")); } let cli_path = get("cli"); + // `bitfun --version` prints "bitfun "; keep only the version. + let cli_version = get("cliversion") + .split_whitespace() + .next_back() + .unwrap_or_default() + .to_string(); Ok(RemoteTarget { os: get("os"), arch: get("arch"), home, cli_path: (!cli_path.is_empty()).then_some(cli_path), + cli_version: (!cli_version.is_empty()).then_some(cli_version), tar_available: get("tar") == "1", + downloader: match get("downloader").as_str() { + "curl" => Some(RemoteDownloader::Curl), + "wget" => Some(RemoteDownloader::Wget), + _ => None, + }, + digest_tool: match get("digest").as_str() { + "sha256sum" => Some(RemoteDigestTool::Sha256Sum), + "shasum" => Some(RemoteDigestTool::Shasum), + _ => None, + }, + libc: match get("libc").as_str() { + "glibc" => Some(RemoteLibc::Glibc), + "musl" => Some(RemoteLibc::Musl), + _ => None, + }, + libc_version: (!get("libcversion").is_empty()).then(|| get("libcversion")), + cargo_version: (!get("cargo").is_empty()).then(|| get("cargo")), + git_available: get("git") == "1", + cc_available: get("cc") == "1", + free_kb: get("freekb").parse().ok(), }) } @@ -1053,12 +1642,39 @@ printf 'os=%s\n' "$(uname -s 2>/dev/null || true)" printf 'arch=%s\n' "$(uname -m 2>/dev/null || true)" printf 'home=%s\n' "$HOME" if command -v tar >/dev/null 2>&1; then printf 'tar=1\n'; else printf 'tar=0\n'; fi +if command -v curl >/dev/null 2>&1; then printf 'downloader=curl\n' +elif command -v wget >/dev/null 2>&1; then printf 'downloader=wget\n' +else printf 'downloader=\n'; fi +if command -v sha256sum >/dev/null 2>&1; then printf 'digest=sha256sum\n' +elif command -v shasum >/dev/null 2>&1; then printf 'digest=shasum\n' +else printf 'digest=\n'; fi if [ -x "$HOME/.local/bin/bitfun" ]; then BITFUN_BIN="$HOME/.local/bin/bitfun" else BITFUN_BIN="$(command -v bitfun 2>/dev/null || true)" fi printf 'cli=%s\n' "$BITFUN_BIN" +if [ -n "$BITFUN_BIN" ]; then + printf 'cliversion=%s\n' "$("$BITFUN_BIN" --version 2>/dev/null || true)" +fi +if [ "$(uname -s 2>/dev/null || true)" = "Linux" ]; then + if ls /lib/ld-musl-* >/dev/null 2>&1 || ldd --version 2>&1 | head -n1 | grep -qi musl; then + printf 'libc=musl\n' + else + printf 'libc=glibc\n' + printf 'libcversion=%s\n' "$(getconf GNU_LIBC_VERSION 2>/dev/null | awk '{print $NF}' || true)" + fi +fi +if command -v cargo >/dev/null 2>&1; then + printf 'cargo=%s\n' "$(cargo --version 2>/dev/null | awk '{print $2}' || true)" +fi +if command -v git >/dev/null 2>&1; then printf 'git=1\n'; else printf 'git=0\n'; fi +if command -v cc >/dev/null 2>&1 || command -v gcc >/dev/null 2>&1; then + printf 'cc=1\n' +else + printf 'cc=0\n' +fi +printf 'freekb=%s\n' "$(df -Pk "$HOME" 2>/dev/null | awk 'NR==2 {print $4}' || true)" "# } @@ -1074,14 +1690,18 @@ 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 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)?, - }; + let (sha256, checksum_signature_verified) = + match fetch_optional_text(&client, &checksum_signature_url).await? { + Some(signature) => ( + verify_signed_checksum(&checksum, &signature, pubkey, &filename)?, + true, + ), + // 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)?, false), + }; Ok(ResolvedRelease { public: DispatchCliRelease { @@ -1094,6 +1714,7 @@ async fn resolve_release(os: &str, arch: &str) -> Result { checksum_url, checksum_signature_url, archive_signature_url, + checksum_signature_verified, }) } @@ -1217,13 +1838,16 @@ fn extend_bounded_archive(archive: &mut Vec, chunk: &[u8], limit: usize) -> Ok(()) } -fn install_body_script(dir: &str, archive_path: &str, expected_version: &str) -> String { +/// Everything both install paths share: state paths, staging layout, rollback, +/// and the exit trap. A source build and a release archive differ only in how +/// they produce `$PRIMARY` and `$LEGACY`; keeping one copy of the dangerous part +/// means the atomic-replace and rollback semantics cannot drift between them. +fn install_preamble_fragment(dir: &str, expected_version: &str) -> String { format!( r#"#!/bin/bash set -euo pipefail umask 077 D={dir} -ARCHIVE={archive} EXPECTED_VERSION={version} TOKEN="${{1:-}}" PIDF="$D/{INSTALL_STEM}.pid" @@ -1231,8 +1855,15 @@ EXITF="$D/{INSTALL_STEM}.exit" TMP="$D/unpack.$$" PRIMARY_TARGET="$HOME/.local/bin/bitfun" LEGACY_TARGET="$HOME/.local/bin/bitfun-cli" -PRIMARY_NEW="$HOME/.local/bin/.bitfun-dispatch-new-$$" -LEGACY_NEW="$HOME/.local/bin/.bitfun-cli-dispatch-new-$$" +# Stage under real filenames in a private directory on the same filesystem as +# the targets. `bitfun-cli` is a shim that resolves the real binary as its own +# sibling, so it can only be smoke-tested next to a file actually named +# `bitfun`. Renaming either binary while staging breaks that lookup on any host +# without an existing install. Same filesystem keeps the commit below an +# atomic rename. +STAGE="$HOME/.local/bin/.bitfun-dispatch-stage-$$" +PRIMARY_NEW="$STAGE/bitfun" +LEGACY_NEW="$STAGE/bitfun-cli" PRIMARY_BACKUP="$D/previous-bitfun.$$" LEGACY_BACKUP="$D/previous-bitfun-cli.$$" HAD_PRIMARY=0 @@ -1256,7 +1887,7 @@ finish() {{ code=$? trap - EXIT HUP INT TERM if [ "$code" -ne 0 ] && [ "$COMMITTED" != "1" ]; then rollback_install; fi - rm -f "$PRIMARY_NEW" "$LEGACY_NEW" + rm -rf "$STAGE" if [ "$COMMITTED" = "1" ]; then rm -f "$PRIMARY_BACKUP" "$LEGACY_BACKUP"; fi rm -rf "$TMP" printf '%s\n' "$code" >"$EXITF" @@ -1267,24 +1898,20 @@ finish() {{ trap finish EXIT trap 'exit 130' HUP INT TERM rm -f "$EXITF" -mkdir -p "$TMP" "$HOME/.local/bin" "$HOME/.bitfun" -chmod 700 "$HOME/.bitfun" -tar -xzf "$ARCHIVE" -C "$TMP" -PRIMARY="" -LEGACY="" -for candidate in "$TMP"/*/bitfun; do - [ -f "$candidate" ] || continue - [ -z "$PRIMARY" ] || {{ echo "ERROR: archive contains multiple bitfun binaries" >&2; exit 1; }} - PRIMARY="$candidate" -done -for candidate in "$TMP"/*/bitfun-cli; do - [ -f "$candidate" ] || continue - [ -z "$LEGACY" ] || {{ echo "ERROR: archive contains multiple bitfun-cli binaries" >&2; exit 1; }} - LEGACY="$candidate" -done -[ -n "$PRIMARY" ] || {{ echo "ERROR: archive contains no bitfun binary" >&2; exit 1; }} -[ -n "$LEGACY" ] || {{ echo "ERROR: archive contains no bitfun-cli binary" >&2; exit 1; }} -cp "$PRIMARY" "$PRIMARY_NEW" +mkdir -p "$TMP" "$STAGE" "$HOME/.local/bin" "$HOME/.bitfun" +chmod 700 "$HOME/.bitfun" "$STAGE" +"#, + dir = shell_quote_posix(dir), + version = shell_quote_posix(expected_version), + ) +} + +/// Stage, smoke-test, and atomically commit `$PRIMARY` and `$LEGACY`. +/// +/// `post_commit` runs once the swap has succeeded, for path-specific cleanup. +fn install_commit_fragment(post_commit: &str) -> String { + format!( + r#"cp "$PRIMARY" "$PRIMARY_NEW" cp "$LEGACY" "$LEGACY_NEW" chmod 755 "$PRIMARY_NEW" "$LEGACY_NEW" staged="$("$PRIMARY_NEW" --version 2>/dev/null || true)" @@ -1292,8 +1919,20 @@ case "$staged" in *"$EXPECTED_VERSION"*) ;; *) echo "ERROR: staged CLI version did not match $EXPECTED_VERSION: $staged" >&2; exit 1 ;; esac -"$LEGACY_NEW" --version >/dev/null 2>&1 \ - || {{ echo "ERROR: staged bitfun-cli companion did not run" >&2; exit 1; }} +# Keep stderr: the loader message ("GLIBC_2.xx not found", "cannot execute +# binary file") is the only actionable part of this failure. +if ! staged_companion="$("$LEGACY_NEW" --version 2>&1 >/dev/null)"; then + echo "ERROR: staged bitfun-cli companion did not run: $staged_companion" >&2 + exit 1 +fi +# This installer exists only to serve dispatch, so a build without the +# subcommand is a failed install, not a successful one. Checking here — before +# anything is replaced — turns "install succeeded but the target is still +# reported incompatible" into one honest error at the point of cause. +if ! staged_dispatch="$("$PRIMARY_NEW" dispatch --help 2>&1 >/dev/null)"; then + echo "ERROR: this BitFun build does not provide dispatch support: $staged_dispatch" >&2 + exit 1 +fi if [ -e "$PRIMARY_TARGET" ]; then mv -f "$PRIMARY_TARGET" "$PRIMARY_BACKUP" HAD_PRIMARY=1 @@ -1311,16 +1950,99 @@ case "$installed" in *"$EXPECTED_VERSION"*) ;; *) echo "ERROR: installed CLI version did not match $EXPECTED_VERSION: $installed" >&2; exit 1 ;; esac -"$LEGACY_TARGET" --version >/dev/null 2>&1 \ - || {{ echo "ERROR: installed bitfun-cli companion did not run" >&2; exit 1; }} +if ! installed_companion="$("$LEGACY_TARGET" --version 2>&1 >/dev/null)"; then + echo "ERROR: installed bitfun-cli companion did not run: $installed_companion" >&2 + exit 1 +fi COMMITTED=1 -rm -f "$ARCHIVE" +{post_commit} echo "Installed $installed at $HOME/.local/bin/bitfun" echo {INSTALL_DONE_MARKER} +"# + ) +} + +fn install_body_script( + dir: &str, + archive_path: &str, + expected_version: &str, + archive_source: &ArchiveSource, +) -> String { + // Surfaced in the install output so the topology actually used is visible + // rather than guessed at. + let source_note = match archive_source { + ArchiveSource::TargetDownload => { + "Archive downloaded on the target and verified against the signed checksum.".to_string() + } + ArchiveSource::ControllerPush { reason } => { + format!("Archive uploaded from this device ({reason}).") + } + }; + let extract = format!( + r#"ARCHIVE={archive} +echo {source_note} +tar -xzf "$ARCHIVE" -C "$TMP" +PRIMARY="" +LEGACY="" +for candidate in "$TMP"/*/bitfun; do + [ -f "$candidate" ] || continue + [ -z "$PRIMARY" ] || {{ echo "ERROR: archive contains multiple bitfun binaries" >&2; exit 1; }} + PRIMARY="$candidate" +done +for candidate in "$TMP"/*/bitfun-cli; do + [ -f "$candidate" ] || continue + [ -z "$LEGACY" ] || {{ echo "ERROR: archive contains multiple bitfun-cli binaries" >&2; exit 1; }} + LEGACY="$candidate" +done +[ -n "$PRIMARY" ] || {{ echo "ERROR: archive contains no bitfun binary" >&2; exit 1; }} +[ -n "$LEGACY" ] || {{ echo "ERROR: archive contains no bitfun-cli binary" >&2; exit 1; }} "#, - dir = shell_quote_posix(dir), archive = shell_quote_posix(archive_path), - version = shell_quote_posix(expected_version), + source_note = shell_quote_posix(&source_note), + ); + format!( + "{preamble}{extract}{commit}", + preamble = install_preamble_fragment(dir, expected_version), + commit = install_commit_fragment(r#"rm -f "$ARCHIVE""#), + ) +} + +/// Build the CLI from source on the target, for hosts no published binary fits. +/// +/// Deliberately does not install a Rust toolchain: fetching and running an +/// installer script on someone's server is a bigger decision than this flow +/// should make silently. A missing toolchain is reported as a blocker instead. +fn source_build_body_script(dir: &str, expected_version: &str, git_ref: &str) -> String { + let build = format!( + r#"SRC="$D/source" +GIT_REF={git_ref} +echo "Building BitFun CLI {git_ref_plain} from source on the target. This can take a while." +FREE_KB="$(df -Pk "$HOME" 2>/dev/null | awk 'NR==2 {{print $4}}' || echo 0)" +if [ "${{FREE_KB:-0}}" -lt {free_kb} ]; then + echo "ERROR: source build needs about {free_gb} GB free under $HOME, found $((FREE_KB / 1024 / 1024)) GB" >&2 + exit 1 +fi +rm -rf "$SRC" +git clone --depth 1 --branch "$GIT_REF" {repo} "$SRC" +echo ">>> cargo build --release (bitfun, bitfun-cli)" +( cd "$SRC" && cargo build --release --locked -p bitfun-cli --bin bitfun --bin bitfun-cli ) +PRIMARY="$SRC/target/release/bitfun" +LEGACY="$SRC/target/release/bitfun-cli" +[ -f "$PRIMARY" ] || {{ echo "ERROR: source build produced no bitfun binary" >&2; exit 1; }} +[ -f "$LEGACY" ] || {{ echo "ERROR: source build produced no bitfun-cli binary" >&2; exit 1; }} +"#, + git_ref = shell_quote_posix(git_ref), + git_ref_plain = git_ref, + repo = shell_quote_posix(REPO_GIT_URL), + free_kb = SOURCE_BUILD_FREE_KB, + free_gb = SOURCE_BUILD_FREE_KB / 1024 / 1024, + ); + format!( + "{preamble}{build}{commit}", + preamble = install_preamble_fragment(dir, expected_version), + // The checkout is many gigabytes; leaving it behind would silently fill + // the target's home directory after a few installs. + commit = install_commit_fragment(r#"rm -rf "$SRC""#), ) } @@ -1380,7 +2102,8 @@ exit 0 #[allow(clippy::too_many_arguments)] fn stage_install_command( - archive_path: &str, + // Absent for a source build, which has no archive to protect. + archive_path: Option<&str>, body_path: &str, script_path: &str, log_path: &str, @@ -1390,13 +2113,14 @@ fn stage_install_command( exit_path: &str, install_token: &str, ) -> String { + let archive = archive_path + .map(|path| format!("chmod 600 {} && ", shell_quote_posix(path))) + .unwrap_or_default(); format!( - "chmod 600 {archive} \ - && chmod 700 {body} {script} \ + "{archive}chmod 700 {body} {script} \ && rm -f {pid} {driver_pid} {exit} \ && : > {log} && chmod 600 {log} \ && printf '%s\\n' {token} > {prepare} && chmod 600 {prepare}", - archive = shell_quote_posix(archive_path), body = shell_quote_posix(body_path), script = shell_quote_posix(script_path), pid = shell_quote_posix(pid_path), @@ -1669,13 +2393,19 @@ mod tests { "/home/user/.bitfun/dispatch/install", "/home/user/.bitfun/dispatch/install/archive.tar.gz", "1.2.3", + &ArchiveSource::TargetDownload, ); let driver = install_driver_script( "/home/user/.bitfun/dispatch/install", "/home/user/.bitfun/dispatch/install/install-cli-body.sh", "bitfun-install-test-token", ); - for (name, script) in [("body", body), ("driver", driver)] { + let source = source_build_body_script( + "/home/user/.bitfun/dispatch/install", + "1.2.3", + "v1.2.3", + ); + for (name, script) in [("body", body), ("driver", driver), ("source", source)] { let script = to_unix_script(&script); assert!(!script.contains('\r'), "{name} must be LF-only"); assert!( @@ -1686,6 +2416,565 @@ mod tests { } } + fn test_release(checksum_signature_verified: bool) -> ResolvedRelease { + ResolvedRelease { + public: DispatchCliRelease { + version: "1.2.3".to_string(), + target: "x86_64-unknown-linux-gnu".to_string(), + url: "https://example.invalid/bitfun-cli.tar.gz".to_string(), + sha256: "a".repeat(64), + }, + filename: "bitfun-cli.tar.gz".to_string(), + checksum_url: "https://example.invalid/bitfun-cli.tar.gz.sha256".to_string(), + checksum_signature_url: "https://example.invalid/bitfun-cli.tar.gz.sha256.sig" + .to_string(), + archive_signature_url: "https://example.invalid/bitfun-cli.tar.gz.sig".to_string(), + checksum_signature_verified, + } + } + + fn test_target( + downloader: Option, + digest_tool: Option, + ) -> RemoteTarget { + RemoteTarget { + os: "Linux".to_string(), + arch: "x86_64".to_string(), + home: "/home/user".to_string(), + cli_path: None, + cli_version: None, + tar_available: true, + downloader, + digest_tool, + libc: Some(RemoteLibc::Glibc), + libc_version: Some("2.39".to_string()), + cargo_version: None, + git_available: true, + cc_available: true, + free_kb: Some(SOURCE_BUILD_FREE_KB * 2), + } + } + + #[test] + fn a_result_bundle_is_only_read_from_its_own_managed_directory() { + // The path is chosen by the target, so a compromised or buggy one must + // not be able to point this at an arbitrary file to exfiltrate. + assert!(validate_managed_result_path( + "/home/user", + "job-1", + "/home/user/.bitfun/dispatch/workspaces/job-1/result.tar.gz" + ) + .is_ok()); + for hostile in [ + "/home/user/.ssh/id_ed25519", + "/home/user/.bitfun/dispatch/workspaces/job-2/result.tar.gz", + "/home/user/.bitfun/dispatch/workspaces/job-1/../../../.ssh/id_ed25519", + "/home/user/.bitfun/dispatch/workspaces/job-1/current/secret", + ] { + assert!( + validate_managed_result_path("/home/user", "job-1", hostile).is_err(), + "must reject {hostile}" + ); + } + } + + #[test] + fn glibc_versions_compare_numerically_not_lexicographically() { + use std::cmp::Ordering; + // The reason this needs its own function: as strings, "2.9" sorts above + // "2.35", which would call a too-old host compatible. + assert_eq!(compare_versions("2.9", "2.35"), Ordering::Less); + assert_eq!(compare_versions("2.35", "2.35"), Ordering::Equal); + assert_eq!(compare_versions("2.39", "2.35"), Ordering::Greater); + assert_eq!(compare_versions("3.0", "2.35"), Ordering::Greater); + assert_eq!(compare_versions("2.35.1", "2.35"), Ordering::Greater); + } + + #[test] + fn incompatible_targets_are_named_precisely() { + let mut target = test_target( + Some(RemoteDownloader::Curl), + Some(RemoteDigestTool::Sha256Sum), + ); + + assert!( + prebuilt_incompatibility(&target).is_none(), + "a supported glibc host has no incompatibility" + ); + + target.libc = Some(RemoteLibc::Musl); + assert_eq!( + prebuilt_incompatibility(&target), + Some(PrebuiltIncompatibility::MuslLibc) + ); + + target.libc = Some(RemoteLibc::Glibc); + target.libc_version = Some("2.31".to_string()); + assert_eq!( + prebuilt_incompatibility(&target), + Some(PrebuiltIncompatibility::GlibcTooOld { + found: "2.31".to_string() + }) + ); + + target.libc_version = Some("2.39".to_string()); + target.arch = "riscv64".to_string(); + assert!(matches!( + prebuilt_incompatibility(&target), + Some(PrebuiltIncompatibility::UnsupportedPlatform { .. }) + )); + + // macOS binaries carry no libc floor, so a musl reading must not leak in. + let mut mac = test_target(None, None); + mac.os = "Darwin".to_string(); + mac.arch = "arm64".to_string(); + mac.libc = Some(RemoteLibc::Musl); + assert!(prebuilt_incompatibility(&mac).is_none()); + } + + #[test] + fn source_build_reports_every_missing_prerequisite() { + let mut target = test_target(None, None); + target.cargo_version = None; + target.git_available = false; + target.cc_available = false; + target.free_kb = Some(1024); + + let availability = source_build_availability(&target); + assert!(!availability.supported); + assert_eq!( + availability.blockers.len(), + 4, + "every prerequisite must be listed at once, not one per retry: {:?}", + availability.blockers + ); + assert!( + availability.blockers.iter().any(|b| b.contains("rustup.rs")), + "a missing toolchain must say where to get one" + ); + + target.cargo_version = Some("1.90.0".to_string()); + target.git_available = true; + target.cc_available = true; + target.free_kb = Some(SOURCE_BUILD_FREE_KB * 2); + let availability = source_build_availability(&target); + assert!(availability.supported, "{:?}", availability.blockers); + assert!(availability.git_ref.starts_with('v') || availability.git_ref == "nightly"); + } + + #[test] + fn both_install_paths_share_one_staging_and_commit_implementation() { + let release = install_body_script( + "/home/user/.bitfun/dispatch/install", + "/home/user/.bitfun/dispatch/install/archive.tar.gz", + "1.2.3", + &ArchiveSource::TargetDownload, + ); + let source = source_build_body_script("/home/user/.bitfun/dispatch/install", "1.2.3", "v1.2.3"); + // The atomic-replace and rollback semantics must not be able to drift + // between the two paths. + let commit = install_commit_fragment(r#"rm -f "$ARCHIVE""#); + let shared = commit + .lines() + .find(|line| line.contains("mv -f \"$PRIMARY_NEW\"")) + .expect("commit fragment swaps the primary"); + for (name, script) in [("release", &release), ("source", &source)] { + assert!(script.contains(shared), "{name} must use the shared commit"); + assert!( + script.contains(r#"PRIMARY_NEW="$STAGE/bitfun""#), + "{name} must stage under real filenames" + ); + assert!( + script.contains("rollback_install"), + "{name} must keep rollback" + ); + } + assert!( + source.contains("cargo build --release --locked"), + "source build must be reproducible" + ); + assert!( + !source.contains("rustup") && !source.contains("sudo"), + "source build must not install a toolchain or escalate" + ); + assert!( + source.contains(r#"rm -rf "$SRC""#), + "the checkout must be cleaned up after a successful build" + ); + } + + #[test] + fn reinstalling_the_version_already_present_is_not_offered() { + let release = test_release(true); + let mut target = test_target( + Some(RemoteDownloader::Curl), + Some(RemoteDigestTool::Sha256Sum), + ); + + target.cli_version = Some("1.2.3".to_string()); + assert_eq!( + already_at_release_version(&target, &release).as_deref(), + Some("1.2.3"), + "an install that cannot change anything must not be offered" + ); + + target.cli_version = Some("1.2.2".to_string()); + assert!( + already_at_release_version(&target, &release).is_none(), + "an older target must still be offered the upgrade" + ); + + target.cli_version = None; + assert!( + already_at_release_version(&target, &release).is_none(), + "a target with no runnable CLI must still be offered the install" + ); + } + + #[test] + fn an_unsigned_checksum_never_lets_the_target_fetch_the_release() { + // The target can only check a plain digest. If that digest is not + // provably the publisher's, the archive's own signature is the only + // protection and only this machine can verify it. + let capable = test_target( + Some(RemoteDownloader::Curl), + Some(RemoteDigestTool::Sha256Sum), + ); + let blocker = target_download_blocker(&capable, &test_release(false)); + assert_eq!( + blocker.as_deref(), + Some("release checksum sidecar is unsigned"), + "an unsigned sidecar must force the archive through this machine" + ); + assert!( + target_download_blocker(&capable, &test_release(true)).is_none(), + "a signed sidecar on a capable target should download remotely" + ); + } + + #[test] + fn a_target_missing_its_tools_falls_back_to_the_push_path() { + let signed = test_release(true); + assert!( + target_download_blocker(&test_target(None, Some(RemoteDigestTool::Sha256Sum)), &signed) + .is_some(), + "no curl or wget must fall back" + ); + assert!( + target_download_blocker(&test_target(Some(RemoteDownloader::Wget), None), &signed) + .is_some(), + "no digest checker must fall back" + ); + } + + #[cfg(unix)] + #[test] + fn target_download_publishes_the_archive_only_when_the_digest_matches() { + let available = |tool: &str| { + std::process::Command::new(tool) + .arg("--version") + .output() + .map(|out| out.status.success()) + .unwrap_or(false) + }; + let Some((digest_command, digest_tool)) = [ + ("sha256sum", RemoteDigestTool::Sha256Sum), + ("shasum -a 256", RemoteDigestTool::Shasum), + ] + .into_iter() + .find(|(command, _)| available(command.split_whitespace().next().unwrap_or(command))) + else { + return; // no digest tool on this host + }; + if !available("curl") { + return; // no curl on this host + } + + let temp = tempfile::tempdir().expect("temp dir"); + let source = temp.path().join("release.tar.gz"); + std::fs::write(&source, b"pretend release bytes").expect("write source"); + // Derive the expected digest with the same tool the script will use, + // so this test needs no hashing dependency of its own. + let digest_output = std::process::Command::new("bash") + .args([ + "-c", + &format!("{digest_command} {}", shell_quote_posix(&source.to_string_lossy())), + ]) + .output() + .expect("compute digest"); + assert!(digest_output.status.success(), "digest tool failed"); + let digest = String::from_utf8_lossy(&digest_output.stdout) + .split_whitespace() + .next() + .expect("digest value") + .to_string(); + let url = format!("file://{}", source.display()); + let archive = temp.path().join("staged.tar.gz"); + + let run = |sha: &str| { + let script = target_download_script( + RemoteDownloader::Curl, + digest_tool, + &archive.to_string_lossy(), + &url, + sha, + ); + std::process::Command::new("bash") + .args(["-c", &script]) + .output() + .expect("run target download script") + }; + + let tampered = run(&"b".repeat(64)); + assert!( + !tampered.status.success(), + "a digest mismatch must fail the download" + ); + assert!( + !archive.exists(), + "a mismatched download must never be published to the installer" + ); + + let matched = run(&digest); + assert!( + matched.status.success(), + "a matching digest must succeed:\n{}", + String::from_utf8_lossy(&matched.stderr) + ); + assert_eq!( + std::fs::read(&archive).expect("read staged archive"), + b"pretend release bytes", + "the verified bytes must be published unchanged" + ); + } + + #[test] + fn install_body_stages_binaries_under_their_real_names() { + let body = install_body_script( + "/home/user/.bitfun/dispatch/install", + "/home/user/.bitfun/dispatch/install/archive.tar.gz", + "1.2.3", + &ArchiveSource::TargetDownload, + ); + // `bitfun-cli` resolves the real binary as its own sibling, so both must + // be staged under their real filenames or the pre-commit smoke test can + // never pass on a host that has no `bitfun` installed yet. + assert!( + body.contains(r#"PRIMARY_NEW="$STAGE/bitfun""#), + "primary must stage as a file literally named bitfun" + ); + assert!( + body.contains(r#"LEGACY_NEW="$STAGE/bitfun-cli""#), + "companion must stage beside its sibling under its real name" + ); + assert!( + !body.contains("dispatch-new-$$"), + "staging must not rename the binaries" + ); + // The loader error is the only actionable part of a companion failure. + for check in ["$staged_companion", "$installed_companion"] { + assert!( + body.contains(check), + "companion failure must report captured stderr ({check})" + ); + } + } + + /// Mirrors a real `bitfun`: answers `--version` and has a `dispatch` + /// subcommand. + const DISPATCH_CAPABLE_PRIMARY: &str = "#!/bin/bash\n\ + if [ \"${1:-}\" = dispatch ]; then exit 0; fi\n\ + echo \"bitfun 1.2.3\"\n"; + + /// Mirrors a release that predates dispatch: the binary is healthy and + /// reports the right version, but clap rejects the subcommand. + const DISPATCH_LESS_PRIMARY: &str = "#!/bin/bash\n\ + if [ \"${1:-}\" = dispatch ]; then\n\ + echo \"error: unrecognized subcommand 'dispatch'\" >&2\n\ + exit 2\n\ + fi\n\ + echo \"bitfun 1.2.3\"\n"; + + const SIBLING_RESOLVING_COMPANION: &str = "#!/bin/bash\n\ + echo 'Warning: `bitfun-cli` is deprecated; use `bitfun` instead.' >&2\n\ + here=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n\ + if [ ! -f \"$here/bitfun\" ]; then\n\ + echo \"Error: incomplete installation: $here/bitfun is missing\" >&2\n\ + exit 1\n\ + fi\n\ + exec \"$here/bitfun\" \"$@\"\n"; + + #[cfg(unix)] + #[test] + fn a_build_without_dispatch_fails_the_install_instead_of_looking_healthy() { + // The scenario that motivated this check: a release whose binary runs + // and reports the expected version, but carries no dispatch support. + // Without the guard the install "succeeds" and the target is then + // reported incompatible, inviting an endless reinstall loop. + let (output, temp) = run_install_body_fixture(DISPATCH_LESS_PRIMARY); + let home = temp.path(); + assert!( + !output.status.success(), + "an install that cannot serve dispatch must fail" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("does not provide dispatch support"), + "the failure must name the cause: {stderr}" + ); + assert!( + stderr.contains("unrecognized subcommand"), + "the underlying message must survive: {stderr}" + ); + assert!( + !home.join(".local/bin/bitfun").exists(), + "nothing may be published when the build cannot serve dispatch" + ); + } + + #[cfg(unix)] + fn run_install_body_fixture( + primary: &str, + ) -> (std::process::Output, tempfile::TempDir) { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let home = temp.path().to_path_buf(); + let install_dir = home.join(INSTALL_STATE_DIR); + std::fs::create_dir_all(&install_dir).expect("install dir"); + let pkg = temp.path().join("pkg/bitfun-cli-1.2.3-test"); + std::fs::create_dir_all(&pkg).expect("package dir"); + std::fs::write(pkg.join("bitfun"), primary).expect("write primary"); + std::fs::write(pkg.join("bitfun-cli"), SIBLING_RESOLVING_COMPANION).expect("write companion"); + for name in ["bitfun", "bitfun-cli"] { + std::fs::set_permissions(pkg.join(name), std::fs::Permissions::from_mode(0o755)) + .expect("chmod package binary"); + } + let archive = install_dir.join("archive.tar.gz"); + assert!(std::process::Command::new("tar") + .arg("-czf") + .arg(&archive) + .arg("-C") + .arg(temp.path().join("pkg")) + .arg("bitfun-cli-1.2.3-test") + .status() + .expect("run tar") + .success()); + std::fs::write( + install_dir.join(format!("{INSTALL_STEM}.pid")), + "1\ntest-token\n", + ) + .expect("write pid marker"); + let script = to_unix_script(&install_body_script( + &install_dir.to_string_lossy(), + &archive.to_string_lossy(), + "1.2.3", + &ArchiveSource::TargetDownload, + )); + let output = std::process::Command::new("bash") + .args(["-c", &script, "install-body", "test-token"]) + .env("HOME", &home) + .output() + .expect("run install body"); + (output, temp) + } + + #[cfg(unix)] + #[test] + fn install_body_installs_a_sibling_resolving_companion_onto_a_bare_host() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let home = temp.path(); + let install_dir = home.join(INSTALL_STATE_DIR); + std::fs::create_dir_all(&install_dir).expect("install dir"); + + // Stand-ins for the shipped binaries. The companion mirrors the real + // shim in src/apps/cli/src/bin/bitfun_cli_compat.rs: it locates `bitfun` + // as its own sibling and fails loudly when that sibling is absent. + let pkg = temp.path().join("pkg/bitfun-cli-1.2.3-test"); + std::fs::create_dir_all(&pkg).expect("package dir"); + std::fs::write(pkg.join("bitfun"), DISPATCH_CAPABLE_PRIMARY).expect("write primary"); + std::fs::write( + pkg.join("bitfun-cli"), + "#!/bin/bash\n\ + echo 'Warning: `bitfun-cli` is deprecated; use `bitfun` instead.' >&2\n\ + here=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n\ + if [ ! -f \"$here/bitfun\" ]; then\n\ + echo \"Error: incomplete installation: $here/bitfun is missing\" >&2\n\ + exit 1\n\ + fi\n\ + exec \"$here/bitfun\" \"$@\"\n", + ) + .expect("write companion"); + for name in ["bitfun", "bitfun-cli"] { + std::fs::set_permissions(pkg.join(name), std::fs::Permissions::from_mode(0o755)) + .expect("chmod package binary"); + } + + let archive = install_dir.join("archive.tar.gz"); + let packed = std::process::Command::new("tar") + .arg("-czf") + .arg(&archive) + .arg("-C") + .arg(temp.path().join("pkg")) + .arg("bitfun-cli-1.2.3-test") + .status() + .expect("run tar"); + assert!(packed.success(), "packaging the fixture archive failed"); + + // The driver normally publishes this before spawning the body; the body's + // exit trap reads line 2 to decide whether the marker is still its own. + std::fs::write( + install_dir.join(format!("{INSTALL_STEM}.pid")), + "1\ntest-token\n", + ) + .expect("write pid marker"); + + let script = to_unix_script(&install_body_script( + &install_dir.to_string_lossy(), + &archive.to_string_lossy(), + "1.2.3", + &ArchiveSource::TargetDownload, + )); + let output = std::process::Command::new("bash") + .args(["-c", &script, "install-body", "test-token"]) + .env("HOME", home) + .output() + .expect("run install body"); + + assert!( + output.status.success(), + "install must succeed on a host with no pre-existing bitfun:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains(INSTALL_DONE_MARKER), + "install must report completion" + ); + + // Both entrypoints must work, and the staging directory must be gone. + for name in ["bitfun", "bitfun-cli"] { + let installed = home.join(".local/bin").join(name); + let run = std::process::Command::new(&installed) + .arg("--version") + .output() + .expect("run installed binary"); + assert!(run.status.success(), "{name} must run after install"); + assert!( + String::from_utf8_lossy(&run.stdout).contains("bitfun 1.2.3"), + "{name} must resolve to the installed primary" + ); + } + let leftovers = std::fs::read_dir(home.join(".local/bin")) + .expect("read bin dir") + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().starts_with('.')) + .count(); + assert_eq!(leftovers, 0, "staging directory must be cleaned up"); + } + #[cfg(unix)] #[test] fn generated_install_scripts_parse_as_bash() { @@ -1694,12 +2983,28 @@ mod tests { "/home/user/.bitfun/dispatch/install", "/home/user/.bitfun/dispatch/install/archive.tar.gz", "1.2.3", + &ArchiveSource::TargetDownload, ), install_driver_script( "/home/user/.bitfun/dispatch/install", "/home/user/.bitfun/dispatch/install/install-cli-body.sh", "bitfun-install-test-token", ), + source_build_body_script("/home/user/.bitfun/dispatch/install", "1.2.3", "v1.2.3"), + target_download_script( + RemoteDownloader::Curl, + RemoteDigestTool::Sha256Sum, + "/home/user/.bitfun/dispatch/install/archive.tar.gz", + "https://example.invalid/archive.tar.gz", + &"a".repeat(64), + ), + target_download_script( + RemoteDownloader::Wget, + RemoteDigestTool::Shasum, + "/home/user/.bitfun/dispatch/install/archive.tar.gz", + "https://example.invalid/archive.tar.gz", + &"a".repeat(64), + ), install_poll_script(17), install_cancel_script(), ] { diff --git a/src/web-ui/src/features/dispatch/DispatchInstallDialog.scss b/src/web-ui/src/features/dispatch/DispatchInstallDialog.scss index bc5deec635..1ff3b8fa05 100644 --- a/src/web-ui/src/features/dispatch/DispatchInstallDialog.scss +++ b/src/web-ui/src/features/dispatch/DispatchInstallDialog.scss @@ -1,16 +1,105 @@ @use '../../component-library/styles/tokens' as *; +// Three deliberate type steps, so "section" reads louder than "item" and +// "item" reads louder than "explanation": +// title 18px/600 — the dialog's own heading +// section 13px/600 — section headings +// body 13px/400 — values and labels +// support 12px — descriptions under a title +// Nothing uses 10px; at that size the descriptions were unreadable and every +// level looked the same. .dispatch-install-dialog { display: flex; + min-height: 0; + max-height: min(78vh, 720px); flex-direction: column; - gap: $size-gap-3; - padding: $size-gap-3; color: var(--color-text-primary); + font-size: var(--font-size-sm); + + &__header { + display: flex; + flex-direction: column; + gap: 2px; + padding: $size-gap-4 $size-gap-4 $size-gap-3; + } + + &__title { + margin: 0; + color: var(--color-text-primary); + font-size: 18px; + font-weight: 600; + line-height: 1.3; + } + + &__subtitle { + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + } + + // The one scrolling region. Keeps the footer reachable once the install card + // and the output console are both open, which used to push it off-screen. + &__body { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; + gap: $size-gap-3; + padding: 0 $size-gap-4 $size-gap-4; + overflow-y: auto; + + // The body is a height-constrained column flex container, so its children + // would otherwise shrink below their content instead of scrolling. + > * { + flex-shrink: 0; + } + } + + &__section { + display: flex; + flex-direction: column; + border: 1px solid var(--border-subtle); + border-radius: $size-radius-base; + // Lets the header fill bleed to the card edge. + overflow: hidden; + } + + &__section-header { + display: flex; + min-height: 38px; + align-items: center; + justify-content: space-between; + gap: $size-gap-2; + padding: $size-gap-2 $size-gap-3; + border-bottom: 1px solid var(--border-subtle); + background: var(--element-bg-subtle); + } + + &__section-title { + margin: 0; + font-size: var(--font-size-sm); + font-weight: 600; + } + + &__section-body { + display: flex; + flex-direction: column; + gap: $size-gap-2; + padding: $size-gap-3; + } + + &__hint { + color: var(--color-text-muted); + font-size: var(--font-size-xs); + line-height: 1.45; + } &__field { display: flex; flex-direction: column; gap: $size-gap-1; + } + + &__field-label { color: var(--color-text-secondary); font-size: var(--font-size-xs); font-weight: 600; @@ -23,68 +112,80 @@ align-items: center; } - &__delivery { + // Selectable option cards, shared by delivery mode and approval policy. + // Single column: three across a 560px modal left ~165px per card, which is + // not enough for a title plus a two-line description. + &__options { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: 1fr; gap: $size-gap-2; min-width: 0; margin: 0; - padding: $size-gap-2; - border: 1px solid var(--border-subtle); - border-radius: $size-radius-base; + padding: 0; + border: 0; + } - legend { - padding: 0 $size-gap-1; - font-size: var(--font-size-xs); - font-weight: 600; + &__option { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: start; + gap: $size-gap-2; + min-width: 0; + padding: $size-gap-2 $size-gap-3; + border: 1px solid var(--border-subtle); + border-radius: $size-radius-sm; + background: transparent; + color: var(--color-text-primary); + text-align: left; + cursor: pointer; + + &:hover:not(:disabled), + &:focus-visible, + &[data-selected='true'] { + border-color: var(--color-accent-500); + background: color-mix(in srgb, var(--color-accent-500) 9%, transparent); + outline: none; } - > button { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: $size-gap-2; - min-width: 0; - padding: $size-gap-2; - border: 1px solid var(--border-subtle); - border-radius: $size-radius-sm; - background: transparent; - color: var(--color-text-primary); - text-align: left; - cursor: pointer; + &:disabled { + cursor: not-allowed; + opacity: 0.55; + } - &:hover:not(:disabled), - &:focus-visible, - &[data-selected='true'] { - border-color: var(--color-accent-500); - background: color-mix(in srgb, var(--color-accent-500) 9%, transparent); - outline: none; - } + // Icon column is optional; delivery cards have no icon. + > svg:first-child { + margin-top: 1px; + color: var(--color-text-muted); + } - &:disabled { - cursor: not-allowed; - opacity: 0.55; - } + > span { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; + // Fill the icon column when this card has no icon. + grid-column: 2 / 3; + } - > span { - display: flex; - min-width: 0; - flex-direction: column; - gap: 2px; - } + &[data-selected='true'] > svg:first-child { + color: var(--color-accent-500); + } - strong { - font-size: var(--font-size-xs); - } + strong { + font-size: var(--font-size-sm); + font-weight: 600; + } - small { - color: var(--color-text-muted); - font-size: var(--font-size-xxs); - line-height: 1.35; - } + small { + color: var(--color-text-muted); + font-size: var(--font-size-xs); + line-height: 1.4; } } - &__snapshot-warning { + // Consent gate. Warning colour is reserved for this — it is the only place + // the user is accepting a risk rather than triggering an action. + &__consent { display: flex; flex-direction: column; gap: $size-gap-2; @@ -97,6 +198,7 @@ code { overflow-wrap: anywhere; color: var(--color-text-secondary); + font-family: var(--font-family-mono); } > span { @@ -116,20 +218,21 @@ &__checks { display: grid; - gap: $size-gap-1; + gap: 0; > div { display: grid; - grid-template-columns: minmax(96px, auto) minmax(0, 1fr); + grid-template-columns: minmax(104px, auto) minmax(0, 1fr); gap: $size-gap-2; - padding: $size-gap-2; - border: 1px solid var(--border-subtle); - border-radius: $size-radius-sm; - background: var(--element-bg-soft); - font-size: var(--font-size-xs); + padding: $size-gap-2 0; + + + div { + border-top: 1px solid var(--border-subtle); + } > span { color: var(--color-text-muted); + font-size: var(--font-size-xs); } > strong { @@ -148,38 +251,25 @@ } } - &__install-card { + // Neutral action panel. Deliberately not warning-coloured: this is something + // to do, not something to accept. + &__action-panel { display: flex; flex-direction: column; align-items: flex-start; gap: $size-gap-2; - padding: $size-gap-3; - border: 1px solid var(--color-warning-border); - border-radius: $size-radius-base; - background: var(--color-warning-bg); - - > div:first-child { - display: flex; - flex-direction: column; - gap: $size-gap-1; - - > span { - color: var(--color-text-secondary); - font-size: var(--font-size-xs); - } - } dl { display: grid; gap: $size-gap-1; width: 100%; margin: 0; - font-size: var(--font-size-xxs); + font-size: var(--font-size-xs); } dl > div { display: grid; - grid-template-columns: 78px minmax(0, 1fr); + grid-template-columns: 84px minmax(0, 1fr); gap: $size-gap-2; } @@ -195,9 +285,17 @@ } } + &__blockers { + margin: 0; + padding-left: 18px; + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + line-height: 1.5; + } + &__output { box-sizing: border-box; - max-height: 140px; + max-height: 180px; margin: 0; padding: $size-gap-2; overflow: auto; @@ -205,78 +303,18 @@ border-radius: $size-radius-sm; background: var(--color-bg-secondary); color: var(--color-text-secondary); - font: var(--font-size-xxs)/1.45 var(--font-family-mono); + font: var(--font-size-xs)/1.5 var(--font-family-mono); white-space: pre-wrap; } - &__approval { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: $size-gap-2; - min-width: 0; - margin: 0; - padding: $size-gap-2; - border: 1px solid var(--border-subtle); - border-radius: $size-radius-base; - - legend { - padding: 0 $size-gap-1; - font-size: var(--font-size-xs); - font-weight: 600; - } - - > .dispatch-install-dialog__approval-hint { - grid-column: 1 / -1; - color: var(--color-text-muted); - font-size: var(--font-size-xxs); - } - - > button { - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - align-items: start; - gap: $size-gap-2; - min-width: 0; - padding: $size-gap-2; - border: 1px solid var(--border-subtle); - border-radius: $size-radius-sm; - background: transparent; - color: var(--color-text-primary); - text-align: left; - cursor: pointer; - - &:hover, - &:focus-visible, - &[data-selected='true'] { - border-color: var(--color-accent-500); - background: color-mix(in srgb, var(--color-accent-500) 9%, transparent); - outline: none; - } - - > span { - display: flex; - min-width: 0; - flex-direction: column; - gap: 2px; - } - - strong { - font-size: var(--font-size-xs); - font-weight: 600; - } - - small { - color: var(--color-text-muted); - font-size: var(--font-size-xxs); - line-height: 1.35; - } - } - } - + // Pinned so the primary action never scrolls away. &__actions { display: flex; justify-content: flex-end; gap: $size-gap-2; + padding: $size-gap-3 $size-gap-4; + border-top: 1px solid var(--border-subtle); + background: var(--color-bg-primary); } &__spin { @@ -284,18 +322,6 @@ } } -@media (max-width: 620px) { - .dispatch-install-dialog { - &__approval { - grid-template-columns: 1fr; - } - - &__delivery { - grid-template-columns: 1fr; - } - } -} - @keyframes dispatch-install-spin { to { transform: rotate(360deg); diff --git a/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx b/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx index ea71f4e0e1..44fd2a4ffc 100644 --- a/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx +++ b/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx @@ -11,6 +11,7 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true; const mocks = vi.hoisted(() => ({ probeTarget: vi.fn(), installCliStart: vi.fn(), + installCliSourceStart: vi.fn(), installCliPoll: vi.fn(), installCliCancel: vi.fn(), syncModelConfig: vi.fn(), @@ -26,6 +27,7 @@ vi.mock('./dispatchApi', () => ({ dispatchApi: { probeTarget: mocks.probeTarget, installCliStart: mocks.installCliStart, + installCliSourceStart: mocks.installCliSourceStart, installCliPoll: mocks.installCliPoll, installCliCancel: mocks.installCliCancel, syncModelConfig: mocks.syncModelConfig, @@ -204,6 +206,106 @@ describe('DispatchInstallDialog installation lifecycle', () => { expect(container.querySelector('pre')).toBeNull(); }); + it('offers a source build only when the target can actually run one', async () => { + // A target no published binary fits: the release install is not offered, + // and the source build is gated on its prerequisites rather than failing + // partway through. + mocks.probeTarget.mockResolvedValue({ + cliInstalled: false, + os: 'linux', + arch: 'x86_64', + installSupported: false, + prebuiltIncompatible: 'target uses musl libc', + sourceBuild: { + supported: false, + blockers: ['no cargo on the target'], + gitRef: 'v1.2.3', + }, + }); + + await act(async () => { + root.render( + , + ); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('target uses musl libc'); + expect(container.textContent).toContain('no cargo on the target'); + const buttons = () => Array.from(container.querySelectorAll('button')); + expect( + buttons().find(button => button.textContent?.includes('dispatch.installConfirm')), + 'a prebuilt install that cannot work must not be offered', + ).toBeUndefined(); + const blocked = buttons() + .find(button => button.textContent?.includes('dispatch.sourceBuildConfirm')); + expect(blocked?.disabled).toBe(true); + + // Same target once a toolchain is present. + mocks.probeTarget.mockResolvedValue({ + cliInstalled: false, + os: 'linux', + arch: 'x86_64', + installSupported: false, + prebuiltIncompatible: 'target uses musl libc', + sourceBuild: { supported: true, blockers: [], gitRef: 'v1.2.3', cargoVersion: '1.90.0' }, + }); + mocks.installCliSourceStart.mockResolvedValue({ + scriptPath: '/tmp/install-bitfun.sh', + version: '1.2.3', + target: 'linux x86_64', + url: 'https://github.com/GCWing/BitFun.git', + sha256: '', + }); + mocks.installCliPoll.mockResolvedValue({ cursor: 0, output: '', status: 'running' }); + + const checkButton = Array.from(container.querySelectorAll('button')) + .find(button => button.textContent?.includes('dispatch.check')); + await act(async () => { + checkButton?.click(); + await Promise.resolve(); + }); + + const ready = Array.from(container.querySelectorAll('button')) + .find(button => button.textContent?.includes('dispatch.sourceBuildConfirm')); + expect(ready?.disabled).toBe(false); + await act(async () => { + ready?.click(); + await Promise.resolve(); + }); + expect(mocks.confirmWarning).toHaveBeenCalled(); + expect(mocks.installCliSourceStart).toHaveBeenCalledWith('ssh-1'); + }); + + it('names where snapshot results stay, since nothing is synced back', async () => { + await act(async () => { + root.render( + , + ); + await Promise.resolve(); + }); + + const snapshot = Array.from(container.querySelectorAll('button')) + .find(button => button.textContent?.includes('dispatch.deliverySnapshot')); + await act(async () => { + snapshot?.click(); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('dispatch.snapshotResultLocationHint'); + }); + it('cancels an acknowledged installer when the parent closes the dialog during polling', async () => { const poll = createDeferred<{ cursor: number; diff --git a/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx b/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx index e09d58255f..bfeb330963 100644 --- a/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx +++ b/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx @@ -34,6 +34,7 @@ import './DispatchInstallDialog.scss'; const log = createLogger('DispatchInstallDialog'); const INSTALL_POLL_INTERVAL_MS = 1200; +const DIALOG_TITLE_ID = 'dispatch-install-dialog-title'; interface ActiveInstall { connectionId: string; @@ -265,6 +266,45 @@ export const DispatchInstallDialog: React.FC = ({ } }, [clearActiveInstall, connectionId, pollInstallation, probe?.release, t]); + // Same lifecycle as a release install — it shares the target-side driver, + // log, and poll/cancel machinery, so only the start call differs. + const startSourceBuild = useCallback(async () => { + if (!connectionId) return; + const generation = ++generationRef.current; + const confirmed = await confirmWarning( + t('dispatch.sourceBuildConfirmTitle'), + t('dispatch.sourceBuildConfirmMessage'), + { + confirmText: t('dispatch.sourceBuildConfirm'), + cancelText: t('dispatch.cancel'), + }, + ); + if (!confirmed || generation !== generationRef.current) return; + + setError(null); + setInstallOutput(''); + setInstalling(true); + activeInstallRef.current = { connectionId, generation, phase: 'starting' }; + try { + const started = await dispatchApi.installCliSourceStart(connectionId); + if (generation !== generationRef.current) { + clearActiveInstall(generation); + await dispatchApi.installCliCancel(connectionId).catch(nextError => { + log.warn('Failed to cancel stale SSH CLI source build', { error: nextError }); + }); + return; + } + setInstallStart(started); + void pollInstallation(generation); + } catch (nextError) { + clearActiveInstall(generation); + if (generation === generationRef.current) { + setInstalling(false); + setError(errorMessage(nextError)); + } + } + }, [clearActiveInstall, connectionId, pollInstallation, t]); + const syncModelConfiguration = useCallback(async () => { if (!connectionId) return; const generation = generationRef.current; @@ -370,254 +410,358 @@ export const DispatchInstallDialog: React.FC = ({ }); }; + const sourceBuild = probe?.sourceBuild; + return (
- {error ? ( - setError(null)} /> - ) : null} - -
- {t('dispatch.deliveryTitle')} - - -
- - {deliveryKind === 'existing' ? ( -