From 22c1fe1f28877a41e4f79b3f8827edf09160c908 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 30 Jul 2026 03:11:12 -0700 Subject: [PATCH] fix(dispatch): make SSH CLI install work and return snapshot results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preparing an SSH dispatch target failed for every host, and a finished snapshot job had no way to give its work back. **Every fresh install failed, with an unreadable error.** `bitfun-cli` is a shim that resolves the real binary as its own sibling via `current_exe()`. The installer staged the pair under dotfile temp names in `~/.local/bin`, then smoke-tested the shim *before* committing — at which point no file named `bitfun` existed yet, so the shim exited 1 and the install rolled back with `staged bitfun-cli companion did not run`. The check discarded stderr, so the one actionable line never surfaced. Stage into a private subdirectory under their real filenames instead: sibling resolution then holds, the commit stays a same-filesystem atomic rename, and both companion checks now report the loader's own message. This also fixes a silent hole — on an upgrade the old check "passed" by exec'ing the *previous* binary, so it never validated the new one at all. **A build without dispatch installed as if it had worked.** This installer exists only to serve dispatch, so verify `dispatch --help` before replacing anything. Without it, installing a release that predates the feature succeeds, the target is then reported incompatible, and the user reinstalls forever. For the same reason, a target already running the release version is no longer offered that install; the message carries the probe's own error so a genuinely feature-less release is distinguishable from a transient failure. **The controller's uplink was the only path for the archive.** Let the target fetch the release itself when it can, falling back to the SFTP push on any failure. A target has no minisign and no trust root, so it only ever checks a plain SHA-256 — sound only because this machine verifies the signature over the tiny `.sha256` sidecar first and hands down a proven digest, the pattern already used for relay deploys. When that sidecar is unsigned the digest is unauthenticated and the archive's own signature becomes the sole protection, so the bytes must flow through here: that case is refused, not downgraded. **Unsupported hosts got a flat error.** Probe libc family, glibc version, toolchain and free space, and name the actual reason (unsupported platform, musl, glibc below the 2.35 release floor). Offer a source build whenever no prebuilt install can proceed, listing every missing prerequisite at once. It reuses the same staging, smoke-test and commit path, so rollback semantics cannot drift; it never installs a toolchain on someone's server. **Results were stranded on the target.** The design contract says the UI names the managed path and that applying results stays a confirmed local operation; neither existed. Persist the delivered manifest as the baseline, add a `workspace-result` verb that diffs the terminal tree against it, and pull the changed files as a bundle. The diff is content-addressed from the manifest's per-file digests, so it works for workspaces that are not git repositories. Nothing is written locally until the user reviews the list: when a path moved on both sides the apply aborts and reports the conflict rather than choosing a winner. `workspace_result_bundle` is advertised as an optional capability so older targets stay usable. **The dialog had no information hierarchy.** Two type sizes, no heading elements, three approval cards crushed into a 560px modal, and the consent gate coloured identically to the install action. Rebuild it with real heading levels, sectioned cards, single-column options, warning colour reserved for consent, and a pinned footer over an independently scrolling body. Verified against a real Ubuntu 24.04 target: fresh install, upgrade, rollback on a broken archive, target-side download of the real 35 MB release in ~3s, digest-mismatch rejection, and the dispatch-support gate refusing the real v0.2.14 release while leaving the existing install byte-identical. The snapshot -> commit -> result round trip was driven through the real CLI verbs. --- src/apps/cli/src/dispatch/mod.rs | 8 + src/apps/cli/src/dispatch/protocol.rs | 22 +- src/apps/cli/src/dispatch/workspace.rs | 61 +- src/apps/cli/src/main.rs | 2 + src/apps/cli/src/root_handlers.rs | 1 + src/apps/desktop/src/api/dispatch_api.rs | 55 +- src/apps/desktop/src/api/peer_host_invoke.rs | 3 + .../src/api/remote_workspace_policy.rs | 12 + src/apps/desktop/src/lib.rs | 3 + .../core/src/service/dispatch/controller.rs | 91 ++ .../src/service/dispatch/device_controller.rs | 4 + .../assembly/core/src/service/dispatch/mod.rs | 16 +- .../services-core/src/dispatch_workspace.rs | 538 +++++- .../src/remote_ssh/dispatch_ssh.rs | 1449 ++++++++++++++++- .../dispatch/DispatchInstallDialog.scss | 330 ++-- .../dispatch/DispatchInstallDialog.test.tsx | 102 ++ .../dispatch/DispatchInstallDialog.tsx | 608 ++++--- .../dispatch/DispatchResultDialog.scss | 161 ++ .../dispatch/DispatchResultDialog.test.tsx | 202 +++ .../dispatch/DispatchResultDialog.tsx | 260 +++ .../src/features/dispatch/dispatchApi.ts | 38 + src/web-ui/src/features/dispatch/types.ts | 45 + .../src/flow_chat/components/ChatInput.tsx | 15 + .../components/ChatInputWorkspaceStrip.scss | 40 + .../components/ChatInputWorkspaceStrip.tsx | 34 + src/web-ui/src/locales/en-US/common.json | 30 +- src/web-ui/src/locales/zh-CN/common.json | 30 +- src/web-ui/src/locales/zh-TW/common.json | 30 +- 28 files changed, 3720 insertions(+), 470 deletions(-) create mode 100644 src/web-ui/src/features/dispatch/DispatchResultDialog.scss create mode 100644 src/web-ui/src/features/dispatch/DispatchResultDialog.test.tsx create mode 100644 src/web-ui/src/features/dispatch/DispatchResultDialog.tsx 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' ? ( -