From 867a838081dbdcf36ffab7e7ebc137af6b18dc9f Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Thu, 9 Jul 2026 08:17:59 +0100 Subject: [PATCH 1/3] =?UTF-8?q?feat(desktop):=20slim=20updates=20=E2=80=94?= =?UTF-8?q?=20fetch=20the=20VM=20image=20on=20demand,=20don't=20bundle=20i?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 3GB sandbox disk image dominated every auto-update (~1GB compressed). Stop bundling it in the .app so the updater artifact drops to ~tens of MB; download it once per image version and verify against a SHA-256 baked into the notarized binary. - tauri.conf: drop resources/vm/sandbox.img from the bundle. - vm/image.rs: ensure_vm_image() resolves the image by (1) local dev/bundled resource, (2) cached data-dir image for the current version, (3) migration — adopt an image a prior install already staged (0 download), or (4) download the gz artifact, verify SHA-256, decompress. Progress is reported via callback. - vm-image.json: committed manifest (version + gz sha256 + url) embedded at build time; updated by the new publish-vm-image.sh, which gzips + uploads the image to a dedicated `vm-image-` release tag. - Existing v0.2.x installs adopt their already-staged image, so updating costs ~0 extra bytes; only fresh installs (or a genuine image change) download. NOTE: vm-image.json ships a PLACEHOLDER hash — a release that serves fresh installs must run publish-vm-image.sh first to publish the image + fill the hash. REVISION: vm-image-on-demand-v1 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- desktop/app/src-tauri/Cargo.lock | 4 +- desktop/app/src-tauri/Cargo.toml | 8 +- desktop/app/src-tauri/src/main.rs | 28 +++-- desktop/app/src-tauri/src/vm/error.rs | 3 + desktop/app/src-tauri/src/vm/image.rs | 156 +++++++++++++++++++++++++- desktop/app/src-tauri/tauri.conf.json | 3 +- desktop/app/src-tauri/vm-image.json | 5 + desktop/scripts/publish-vm-image.sh | 60 ++++++++++ 8 files changed, 251 insertions(+), 16 deletions(-) create mode 100644 desktop/app/src-tauri/vm-image.json create mode 100755 desktop/scripts/publish-vm-image.sh diff --git a/desktop/app/src-tauri/Cargo.lock b/desktop/app/src-tauri/Cargo.lock index f3f4c240..5821ece9 100644 --- a/desktop/app/src-tauri/Cargo.lock +++ b/desktop/app/src-tauri/Cargo.lock @@ -2305,7 +2305,7 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "orcabot-desktop" -version = "0.2.1" +version = "0.3.0" dependencies = [ "ctrlc", "filetime", @@ -2314,11 +2314,13 @@ dependencies = [ "ratatui", "serde", "serde_json", + "sha2", "signal-hook", "tauri", "tauri-build", "tauri-plugin-dialog", "tauri-plugin-updater", + "tempfile", "tungstenite", "ureq", "vt100", diff --git a/desktop/app/src-tauri/Cargo.toml b/desktop/app/src-tauri/Cargo.toml index 56b2d773..a652990e 100644 --- a/desktop/app/src-tauri/Cargo.toml +++ b/desktop/app/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "orcabot-desktop" -version = "0.2.1" +version = "0.3.0" edition = "2021" description = "Orcabot Desktop shell" license = "UNLICENSED" @@ -28,6 +28,8 @@ serde_json = "1" walkdir = "2" libc = "0.2" flate2 = "1" +# Verify the on-demand VM image download against a hash baked into the binary. +sha2 = "0.10" filetime = "0.2" ctrlc = "3" # SIGUSR1/SIGUSR2 surface toggle (show/hide the GUI window without restarting). @@ -42,6 +44,10 @@ tungstenite = { version = "0.21", default-features = false, features = ["handsha # VT/ANSI parser to render PTY output (agents draw full-screen TUIs). vt100 = "0.15" +[dev-dependencies] +# Used by the vm/image.rs unit tests (was referenced but never declared). +tempfile = "3" + # Note: virt-fwk for native Virtualization.framework on macOS is not yet # stable enough. The current implementation uses QEMU as a fallback. # Uncomment when virt-fwk is ready: diff --git a/desktop/app/src-tauri/src/main.rs b/desktop/app/src-tauri/src/main.rs index e6e0d405..a34ee4e0 100644 --- a/desktop/app/src-tauri/src/main.rs +++ b/desktop/app/src-tauri/src/main.rs @@ -499,19 +499,25 @@ impl DesktopServices { // Check if VM resources exist let vm_resource_paths = vm::image::VMResourcePaths::from_resource_root(resource_root); - // VM resources are optional - if not present, skip VM startup - if !vm_resource_paths.image.exists() { - eprintln!( - "VM image not found at {}; sandbox VM disabled.", - vm_resource_paths.image.display() - ); - return Ok(()); - } - eprintln!("Starting sandbox VM ({})...", vm::vm_backend_name()); - // Stage VM resources to app data directory - let staged_paths = vm::image::stage_vm_resources(&vm_resource_paths, data_dir)?; + // Stage VM resources. The disk image isn't bundled (it would bloat every + // auto-update); ensure_vm_image downloads + verifies it on first use, or + // adopts an image an earlier install already staged. Log download progress. + let last_pct = std::cell::Cell::new(-1i64); + let progress = |done: u64, total: u64| { + if total > 0 { + let pct = (done.saturating_mul(100) / total) as i64; + if pct != last_pct.get() && pct % 5 == 0 { + last_pct.set(pct); + eprintln!( + "[vm-image] downloading sandbox image… {}% ({}/{} bytes)", + pct, done, total + ); + } + } + }; + let staged_paths = vm::image::stage_vm_resources(&vm_resource_paths, data_dir, &progress)?; // Create workspace directory let workspace_dir = data_dir.join("workspace"); diff --git a/desktop/app/src-tauri/src/vm/error.rs b/desktop/app/src-tauri/src/vm/error.rs index 898385b8..b38dae1e 100644 --- a/desktop/app/src-tauri/src/vm/error.rs +++ b/desktop/app/src-tauri/src/vm/error.rs @@ -9,6 +9,8 @@ pub enum VMError { HealthTimeout(Duration), MountFailed(String), UnsupportedPlatform(String), + /// Fetching/verifying the on-demand VM image failed. + Download(String), Io(std::io::Error), } @@ -25,6 +27,7 @@ impl std::fmt::Display for VMError { VMError::UnsupportedPlatform(platform) => { write!(f, "Platform not supported: {}", platform) } + VMError::Download(msg) => write!(f, "VM image download failed: {}", msg), VMError::Io(err) => write!(f, "IO error: {}", err), } } diff --git a/desktop/app/src-tauri/src/vm/image.rs b/desktop/app/src-tauri/src/vm/image.rs index 21bc40ba..d97d9abe 100644 --- a/desktop/app/src-tauri/src/vm/image.rs +++ b/desktop/app/src-tauri/src/vm/image.rs @@ -5,6 +5,7 @@ //! modification times and sizes. use super::VMError; +use sha2::{Digest, Sha256}; use std::fs::{self, File}; use std::io::{self, BufReader, BufWriter, Read, Write}; use std::path::{Path, PathBuf}; @@ -259,11 +260,15 @@ impl VMResourcePaths { pub fn stage_vm_resources( resource_paths: &VMResourcePaths, data_dir: &Path, + progress: &dyn Fn(u64, u64), ) -> Result { let vm_dir = data_dir.join("vm"); fs::create_dir_all(&vm_dir)?; - let staged_image = stage_image(&resource_paths.image, &vm_dir)?; + // The disk image is NOT bundled in the app (it would bloat every + // auto-update), so fetch/adopt it on demand instead of staging from a + // bundled resource. + let staged_image = ensure_vm_image(&resource_paths.image, data_dir, progress)?; let staged_kernel = if let Some(ref kernel) = resource_paths.kernel { Some(stage_image(kernel, &vm_dir)?) @@ -310,6 +315,155 @@ pub fn stage_vm_resources( }) } +// --------------------------------------------------------------------------- +// VM image: fetched on demand, not bundled. +// +// The multi-GB disk image would bloat every auto-update if bundled in the .app, +// so it's hosted once per image version and downloaded on first use, verified +// against a SHA-256 baked into this (notarized) binary. Installs that already +// have the image staged adopt it with no download. Source of truth for the +// version + hash + URL is `vm-image.json`, embedded at build time. +// --------------------------------------------------------------------------- + +#[derive(serde::Deserialize)] +pub struct VmImageManifest { + /// Image version tag (e.g. "v1"); bumps only when the image content changes. + pub version: String, + /// SHA-256 (hex) of the gzipped download artifact. + pub sha256: String, + /// Download URL for the gzipped image (sandbox.img.gz). + pub url: String, +} + +/// The image manifest baked into the binary. Updated by +/// `desktop/scripts/publish-vm-image.sh` when a new image is published. +pub fn vm_image_manifest() -> VmImageManifest { + const MANIFEST: &str = include_str!("../../vm-image.json"); + serde_json::from_str(MANIFEST).expect("vm-image.json is valid JSON") +} + +fn read_marker(marker: &Path) -> Option { + fs::read_to_string(marker).ok().map(|s| s.trim().to_string()) +} + +fn write_marker(marker: &Path, version: &str) { + let _ = fs::write(marker, version); +} + +/// Ensure the VM disk image is present in the data dir and matches the required +/// version, downloading + verifying it on demand. Returns the staged image path. +/// +/// Resolution order: +/// 1. a local resource image (dev build / anything bundled) → stage it (trusted, +/// mtime-cached); +/// 2. the data-dir image already staged for THIS version → use (fast); +/// 3. a data-dir image from a PRIOR install with no version marker → adopt it as +/// the current version (migration — this build ships the same image content); +/// 4. otherwise download the gz artifact, verify its SHA-256, decompress. +pub fn ensure_vm_image( + resource_image: &Path, + data_dir: &Path, + progress: &dyn Fn(u64, u64), +) -> Result { + let manifest = vm_image_manifest(); + let vm_dir = data_dir.join("vm"); + fs::create_dir_all(&vm_dir)?; + let target = vm_dir.join("sandbox.img"); + let marker = vm_dir.join("sandbox.img.version"); + + // 1. Dev / bundled: a local resource image is the source of truth. stage_image + // is mtime-cached, so this is cheap when unchanged and re-stages on change. + if resource_image.exists() { + let staged = stage_image(resource_image, &vm_dir)?; + write_marker(&marker, &manifest.version); + return Ok(staged); + } + + // Packaged: no bundled image. + let marker_ver = read_marker(&marker); + if target.exists() { + if marker_ver.as_deref() == Some(manifest.version.as_str()) { + return Ok(target); // 2. correct version already staged + } + if marker_ver.is_none() { + // 3. Migration from a pre-versioning install: this build ships the + // same image content it always had, so the existing image IS the + // current one — adopt it with no download. + write_marker(&marker, &manifest.version); + return Ok(target); + } + // marker present but a different version → the image genuinely changed → + // fall through to download. + } + + // 4. Download + verify + decompress. + eprintln!( + "[vm-image] fetching sandbox image {} from {}", + manifest.version, manifest.url + ); + download_and_stage_image(&manifest, &vm_dir, &target, progress)?; + write_marker(&marker, &manifest.version); + Ok(target) +} + +fn download_and_stage_image( + manifest: &VmImageManifest, + vm_dir: &Path, + target: &Path, + progress: &dyn Fn(u64, u64), +) -> Result<(), VMError> { + let tmp_gz = vm_dir.join("sandbox.img.gz.part"); + + let resp = ureq::get(&manifest.url) + .call() + .map_err(|e| VMError::Download(format!("request failed: {e}")))?; + let total: u64 = resp + .header("Content-Length") + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + + // Stream to disk, hashing as we go. + let mut reader = resp.into_reader(); + let mut writer = BufWriter::new(File::create(&tmp_gz)?); + let mut hasher = Sha256::new(); + let mut buf = vec![0u8; 1 << 20]; + let mut downloaded: u64 = 0; + loop { + let n = reader.read(&mut buf)?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + writer.write_all(&buf[..n])?; + downloaded += n as u64; + progress(downloaded, total); + } + writer.flush()?; + + let digest = hasher.finalize(); + let got = hex_encode(digest.as_slice()); + if got.to_lowercase() != manifest.sha256.to_lowercase() { + let _ = fs::remove_file(&tmp_gz); + return Err(VMError::Download(format!( + "checksum mismatch: expected {}, got {}", + manifest.sha256, got + ))); + } + + // Verified — decompress into place, then drop the temp archive. + decompress_gzip(&tmp_gz, target)?; + let _ = fs::remove_file(&tmp_gz); + Ok(()) +} + +fn hex_encode(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push_str(&format!("{:02x}", b)); + } + s +} + #[cfg(test)] mod tests { use super::*; diff --git a/desktop/app/src-tauri/tauri.conf.json b/desktop/app/src-tauri/tauri.conf.json index a83677e6..d586c936 100644 --- a/desktop/app/src-tauri/tauri.conf.json +++ b/desktop/app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Orcabot", - "version": "0.2.1", + "version": "0.3.0", "identifier": "com.orcabot.desktop", "build": { "beforeDevCommand": "sh -c \"cd ../../frontend && NEXT_PUBLIC_API_URL=http://localhost:8787 NEXT_PUBLIC_SITE_URL=http://localhost:8788 NEXT_PUBLIC_DEV_MODE_ENABLED=true NEXT_PUBLIC_DESKTOP_MODE=true npx wrangler dev -c wrangler.toml --port 8788\"", @@ -38,7 +38,6 @@ "resources/workerd/dist/worker.js", "resources/workerd/assets-service/worker.js", "resources/d1-shim/d1-shim", - "resources/vm/sandbox.img", "resources/vm/vmlinuz", "resources/vm/initrd.img", "resources/vm/vz-helper", diff --git a/desktop/app/src-tauri/vm-image.json b/desktop/app/src-tauri/vm-image.json new file mode 100644 index 00000000..704fbeb2 --- /dev/null +++ b/desktop/app/src-tauri/vm-image.json @@ -0,0 +1,5 @@ +{ + "version": "v1", + "sha256": "0000000000000000000000000000000000000000000000000000000000000000", + "url": "https://github.com/Hyper-Int/OrcaBot/releases/download/vm-image-v1/sandbox.img.gz" +} diff --git a/desktop/scripts/publish-vm-image.sh b/desktop/scripts/publish-vm-image.sh new file mode 100755 index 00000000..d60609bf --- /dev/null +++ b/desktop/scripts/publish-vm-image.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env sh +set -eu +# Publish the sandbox VM disk image to its own GitHub release tag, and update the +# committed manifest (vm-image.json) with the version + checksum the app embeds. +# +# The image is NOT bundled in the app (it would bloat every ~40MB auto-update to +# ~1GB). Instead the app downloads it on demand, once per image VERSION, and +# verifies it against the SHA-256 baked into the notarized binary from the +# manifest below. +# +# Run this ONLY when the VM image content changes (rare — a sandbox rebuild). +# BUMP THE VERSION each time so existing installs know to re-download. +# +# Usage: +# VM_IMAGE_VERSION=v2 desktop/scripts/publish-vm-image.sh [path/to/sandbox.img] +# +# Then commit vm-image.json and build + release the app as usual. + +REPO="${ORCABOT_RELEASE_REPO:-Hyper-Int/OrcaBot}" +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +IMG="${1:-$SCRIPT_DIR/../vm/image/sandbox.img}" +VER="${VM_IMAGE_VERSION:-v1}" +MANIFEST="$SCRIPT_DIR/../app/src-tauri/vm-image.json" +TAG="vm-image-$VER" + +[ -f "$IMG" ] || { echo "image not found: $IMG (pass the path as arg 1)"; exit 1; } +command -v gh >/dev/null 2>&1 || { echo "gh CLI required"; exit 1; } + +GZ="$(dirname "$IMG")/sandbox.img.gz" +echo "Compressing $IMG -> $GZ (multi-GB; takes a minute)..." +gzip -c "$IMG" > "$GZ" + +SHA=$(shasum -a 256 "$GZ" | awk '{print $1}') +URL="https://github.com/$REPO/releases/download/$TAG/sandbox.img.gz" +echo "gz sha256: $SHA" +echo "size: $(du -h "$GZ" | awk '{print $1}')" + +echo "Writing manifest $MANIFEST (version=$VER)" +cat > "$MANIFEST" </dev/null 2>&1; then + gh release upload "$TAG" --repo "$REPO" --clobber "$GZ" +else + gh release create "$TAG" --repo "$REPO" \ + --title "VM image $VER" \ + --notes "Sandbox VM disk image ($VER). Downloaded on demand + checksum-verified by the desktop app; not bundled in the app itself." \ + "$GZ" +fi + +echo +echo "Done." +echo " Now: git add $MANIFEST && commit, then build + release the app." +echo " Existing installs on an older image version will download $VER on next launch." From 3eac648262cac7e02df7c5da3b47173c03b1f82e Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Thu, 9 Jul 2026 09:01:32 +0100 Subject: [PATCH 2/3] chore: vm-image v1 hash --- desktop/app/src-tauri/vm-image.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/app/src-tauri/vm-image.json b/desktop/app/src-tauri/vm-image.json index 704fbeb2..1154fe5a 100644 --- a/desktop/app/src-tauri/vm-image.json +++ b/desktop/app/src-tauri/vm-image.json @@ -1,5 +1,5 @@ { "version": "v1", - "sha256": "0000000000000000000000000000000000000000000000000000000000000000", + "sha256": "d5fb4c823f7e92020c9d85a2351898a10162e44400f4b44d92454252f2b43caa", "url": "https://github.com/Hyper-Int/OrcaBot/releases/download/vm-image-v1/sandbox.img.gz" } From bc6ed567f81d4f23276e4022b826828374ce175f Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Thu, 9 Jul 2026 09:17:14 +0100 Subject: [PATCH 3/3] fix(desktop): harden VM-image adoption + atomic decompress (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1: only adopt a marker-less staged image when the required version is still the legacy (v1) image; on a future v2+ a marker-less image is the OLD one and is now re-downloaded instead of being silently stamped as current. P2: decompress the verified download to sandbox.img.part and atomically rename into place, so a crash / disk-full mid-decompress can't leave a partial sandbox.img that a later launch would adopt as complete. P3: revision markers — bump main.rs to main-v11-vm-image-ondemand; add markers to vm/image.rs and vm/error.rs. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- desktop/app/src-tauri/src/main.rs | 4 +-- desktop/app/src-tauri/src/vm/error.rs | 1 + desktop/app/src-tauri/src/vm/image.rs | 41 +++++++++++++++++++-------- 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/desktop/app/src-tauri/src/main.rs b/desktop/app/src-tauri/src/main.rs index a34ee4e0..39fb0ea4 100644 --- a/desktop/app/src-tauri/src/main.rs +++ b/desktop/app/src-tauri/src/main.rs @@ -1,7 +1,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -// REVISION: main-v10-updater-prompt -const MODULE_REVISION: &str = "main-v10-updater-prompt"; +// REVISION: main-v11-vm-image-ondemand +const MODULE_REVISION: &str = "main-v11-vm-image-ondemand"; mod commands; mod vm; diff --git a/desktop/app/src-tauri/src/vm/error.rs b/desktop/app/src-tauri/src/vm/error.rs index b38dae1e..b9a411b2 100644 --- a/desktop/app/src-tauri/src/vm/error.rs +++ b/desktop/app/src-tauri/src/vm/error.rs @@ -1,3 +1,4 @@ +// REVISION: vm-image-ondemand-v1 use std::path::PathBuf; use std::time::Duration; diff --git a/desktop/app/src-tauri/src/vm/image.rs b/desktop/app/src-tauri/src/vm/image.rs index d97d9abe..639c85a5 100644 --- a/desktop/app/src-tauri/src/vm/image.rs +++ b/desktop/app/src-tauri/src/vm/image.rs @@ -1,8 +1,10 @@ -//! VM image staging and decompression utilities. +//! VM image staging, download-on-demand, and decompression utilities. //! -//! Handles extracting bundled VM images from app resources to -//! the app data directory, with smart caching based on file -//! modification times and sizes. +//! The multi-GB disk image is NOT bundled in the app — it's fetched on demand +//! per image version and verified against a SHA-256 baked into the binary. The +//! small resources (kernel/initrd/vz-helper) are still staged from the bundle. +// +// REVISION: vm-image-ondemand-v1 use super::VMError; use sha2::{Digest, Sha256}; @@ -350,6 +352,11 @@ fn write_marker(marker: &Path, version: &str) { let _ = fs::write(marker, version); } +/// The image version that pre-versioning installs (before this scheme existed) +/// actually shipped. A marker-less staged image can ONLY be that version, so it +/// may be adopted without a download only while it's still the required version. +const LEGACY_IMAGE_VERSION: &str = "v1"; + /// Ensure the VM disk image is present in the data dir and matches the required /// version, downloading + verifying it on demand. Returns the staged image path. /// @@ -385,15 +392,17 @@ pub fn ensure_vm_image( if marker_ver.as_deref() == Some(manifest.version.as_str()) { return Ok(target); // 2. correct version already staged } - if marker_ver.is_none() { - // 3. Migration from a pre-versioning install: this build ships the - // same image content it always had, so the existing image IS the - // current one — adopt it with no download. + if marker_ver.is_none() && manifest.version == LEGACY_IMAGE_VERSION { + // 3. Migration from a pre-versioning install. Those installs shipped + // the LEGACY_IMAGE_VERSION image, so a marker-less image is only + // safe to adopt while that's still the required version. Once the + // image moves on (v2+), a marker-less image is the OLD one and + // must be re-downloaded — fall through. write_marker(&marker, &manifest.version); return Ok(target); } - // marker present but a different version → the image genuinely changed → - // fall through to download. + // marker present but a different version (or a marker-less image on a + // newer required version) → the image genuinely differs → download. } // 4. Download + verify + decompress. @@ -450,8 +459,16 @@ fn download_and_stage_image( ))); } - // Verified — decompress into place, then drop the temp archive. - decompress_gzip(&tmp_gz, target)?; + // Verified — decompress to a temp file, then atomically rename into place. + // A crash / disk-full mid-decompress must NOT leave a partial sandbox.img, + // or a later launch could mistake it for a complete (adoptable) image. + let tmp_img = vm_dir.join("sandbox.img.part"); + if let Err(e) = decompress_gzip(&tmp_gz, &tmp_img) { + let _ = fs::remove_file(&tmp_img); + let _ = fs::remove_file(&tmp_gz); + return Err(e); + } + fs::rename(&tmp_img, target)?; let _ = fs::remove_file(&tmp_gz); Ok(()) }