From 60c0a22bedd3ee87d98a1442e5995bfc32423265 Mon Sep 17 00:00:00 2001 From: Taksh Date: Thu, 23 Jul 2026 13:22:56 +0300 Subject: [PATCH 1/3] fix(desktop): add .exe suffix to Windows sidecar stubs Signed-off-by: Taksh Co-authored-by: Cursor --- Justfile | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/Justfile b/Justfile index 317541e2f..27c5fb341 100644 --- a/Justfile +++ b/Justfile @@ -153,9 +153,12 @@ _ensure-sidecar-stubs: #!/usr/bin/env bash set -euo pipefail TARGET=$(rustc -vV | sed -n 's|host: ||p') + # Tauri externalBin on Windows requires the `.exe` suffix (#2492). + EXT="" + case "$TARGET" in *windows*) EXT=".exe";; esac mkdir -p desktop/src-tauri/binaries for bin in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz; do - touch "desktop/src-tauri/binaries/${bin}-${TARGET}" + touch "desktop/src-tauri/binaries/${bin}-${TARGET}${EXT}" done # Ensure Docker dev services (Postgres, Redis, etc.) are running and healthy @@ -225,12 +228,14 @@ desktop-release-build target="aarch64-apple-darwin": #!/usr/bin/env bash set -euo pipefail TARGET={{target}} + EXT="" + case "$TARGET" in *windows*) EXT=".exe";; esac mkdir -p desktop/src-tauri/binaries - touch "desktop/src-tauri/binaries/buzz-acp-$TARGET" - touch "desktop/src-tauri/binaries/buzz-agent-$TARGET" - touch "desktop/src-tauri/binaries/buzz-dev-mcp-$TARGET" - touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET" - touch "desktop/src-tauri/binaries/buzz-$TARGET" + touch "desktop/src-tauri/binaries/buzz-acp-$TARGET$EXT" + touch "desktop/src-tauri/binaries/buzz-agent-$TARGET$EXT" + touch "desktop/src-tauri/binaries/buzz-dev-mcp-$TARGET$EXT" + touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET$EXT" + touch "desktop/src-tauri/binaries/buzz-$TARGET$EXT" pnpm install cd {{desktop_dir}} && pnpm tauri build --features mesh-llm --target {{target}} @@ -469,10 +474,12 @@ desktop-standalone *ARGS: _ensure-sidecar-stubs export PATH="{{justfile_directory()}}/bin:$PATH" cargo build -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr TARGET=$(rustc -vV | sed -n 's|host: ||p') + EXT="" + case "$TARGET" in *windows*) EXT=".exe";; esac TARGET_DIR=$(cargo metadata --format-version 1 --no-deps | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).target_directory") for bin in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz; do - cp "${TARGET_DIR}/debug/${bin}" "desktop/src-tauri/binaries/${bin}-${TARGET}" - chmod +x "desktop/src-tauri/binaries/${bin}-${TARGET}" + cp "${TARGET_DIR}/debug/${bin}${EXT}" "desktop/src-tauri/binaries/${bin}-${TARGET}${EXT}" + chmod +x "desktop/src-tauri/binaries/${bin}-${TARGET}${EXT}" done cd {{desktop_dir}} [[ -d node_modules ]] || pnpm install @@ -504,9 +511,11 @@ staging *ARGS: bootstrap _ensure-sidecar-stubs fi # Replace the 0-byte sidecar stub with the real CLI binary so tauri dev picks it up. TARGET=$(rustc -vV | sed -n 's|host: ||p') + EXT="" + case "$TARGET" in *windows*) EXT=".exe";; esac TARGET_DIR=$(cargo metadata --format-version 1 --no-deps | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).target_directory") - cp "${TARGET_DIR}/release/buzz" "desktop/src-tauri/binaries/buzz-${TARGET}" - chmod +x "desktop/src-tauri/binaries/buzz-${TARGET}" + cp "${TARGET_DIR}/release/buzz${EXT}" "desktop/src-tauri/binaries/buzz-${TARGET}${EXT}" + chmod +x "desktop/src-tauri/binaries/buzz-${TARGET}${EXT}" cd {{desktop_dir}} export BUZZ_RELAY_URL="wss://sprout-oss.stage.blox.sqprod.co" source ../scripts/instance-env.sh From 7a44c2a98f46903f4fb44db617ef450b96d78c4e Mon Sep 17 00:00:00 2001 From: Taksh Date: Thu, 23 Jul 2026 13:39:25 +0300 Subject: [PATCH 2/3] fix(desktop): ship managed Node.js runtime for Windows Signed-off-by: Taksh Co-authored-by: Cursor --- .../commands/agent_discovery/managed_node.rs | 148 ++++++++++++++++-- .../src/managed_agents/managed_node_paths.rs | 73 +++++++-- 2 files changed, 200 insertions(+), 21 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs b/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs index fca5a602b..8ddd6734a 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs @@ -43,11 +43,27 @@ const MANAGED_NODE_ARTIFACT: Option = Some(ManagedNodeArtif sha256: "4786d00c4d259d3ff0b2328307f764ef3ced65f2d6e9502d433e68d66238509d", }); +#[cfg(all(target_os = "windows", target_arch = "x86_64"))] +const MANAGED_NODE_ARTIFACT: Option = Some(ManagedNodeArtifact { + platform: "win-x64", + filename: "node-v24.11.0-win-x64.zip", + sha256: "1054540bce22b54ec7e50ebc078ec5d090700a77657607a58f6a64df21f49fdd", +}); + +#[cfg(all(target_os = "windows", target_arch = "aarch64"))] +const MANAGED_NODE_ARTIFACT: Option = Some(ManagedNodeArtifact { + platform: "win-arm64", + filename: "node-v24.11.0-win-arm64.zip", + sha256: "12d3b1aa9696b7411e115a4fa2aef57f95560b5ee16bb62cd69843e535ec72be", +}); + #[cfg(not(any( all(target_os = "macos", target_arch = "aarch64"), all(target_os = "macos", target_arch = "x86_64"), all(target_os = "linux", target_arch = "x86_64"), - all(target_os = "linux", target_arch = "aarch64") + all(target_os = "linux", target_arch = "aarch64"), + all(target_os = "windows", target_arch = "x86_64"), + all(target_os = "windows", target_arch = "aarch64") )))] const MANAGED_NODE_ARTIFACT: Option = None; @@ -179,7 +195,12 @@ fn install_managed_node_runtime( extract_managed_node_archive(&archive_path, &temp_dir, artifact.filename)?; let _ = std::fs::remove_file(&archive_path); - let extracted_dir = temp_dir.join(artifact.filename.trim_end_matches(".tar.gz")); + let extracted_name = artifact + .filename + .strip_suffix(".tar.gz") + .or_else(|| artifact.filename.strip_suffix(".zip")) + .unwrap_or(artifact.filename); + let extracted_dir = temp_dir.join(extracted_name); let source_dir = if extracted_dir.is_dir() { extracted_dir } else { @@ -276,9 +297,19 @@ fn extract_managed_node_archive( dest_dir: &std::path::Path, filename: &str, ) -> Result<(), String> { - if !filename.ends_with(".tar.gz") { - return Err(format!("unsupported managed Node.js archive: {filename}")); + if filename.ends_with(".tar.gz") { + extract_managed_node_tar_gz(archive_path, dest_dir) + } else if filename.ends_with(".zip") { + extract_managed_node_zip(archive_path, dest_dir) + } else { + Err(format!("unsupported managed Node.js archive: {filename}")) } +} + +fn extract_managed_node_tar_gz( + archive_path: &std::path::Path, + dest_dir: &std::path::Path, +) -> Result<(), String> { let file = std::fs::File::open(archive_path).map_err(|e| format!("open Node.js archive: {e}"))?; let decoder = flate2::read::GzDecoder::new(file); @@ -294,6 +325,71 @@ fn extract_managed_node_archive( .map_err(|e| format!("extract Node.js archive: {e}")) } +fn extract_managed_node_zip( + archive_path: &std::path::Path, + dest_dir: &std::path::Path, +) -> Result<(), String> { + let file = + std::fs::File::open(archive_path).map_err(|e| format!("open Node.js zip archive: {e}"))?; + let mut archive = + zip::ZipArchive::new(file).map_err(|e| format!("read Node.js zip archive: {e}"))?; + validate_managed_node_zip_entries(&mut archive)?; + + for i in 0..archive.len() { + let mut entry = archive + .by_index(i) + .map_err(|e| format!("Node.js zip entry: {e}"))?; + let Some(rel) = entry.enclosed_name() else { + return Err(format!( + "Node.js zip contains unsafe path: {}", + entry.name() + )); + }; + let out_path = dest_dir.join(rel); + if entry.is_dir() { + std::fs::create_dir_all(&out_path) + .map_err(|e| format!("create Node.js zip dir: {e}"))?; + continue; + } + if let Some(parent) = out_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("create Node.js zip parent: {e}"))?; + } + let mut out = std::fs::File::create(&out_path) + .map_err(|e| format!("create Node.js zip file: {e}"))?; + std::io::copy(&mut entry, &mut out) + .map_err(|e| format!("extract Node.js zip file: {e}"))?; + } + Ok(()) +} + +fn validate_managed_node_zip_entries( + archive: &mut zip::ZipArchive, +) -> Result<(), String> { + for i in 0..archive.len() { + let entry = archive + .by_index(i) + .map_err(|e| format!("Node.js zip entry: {e}"))?; + let Some(path) = entry.enclosed_name() else { + return Err(format!( + "Node.js zip contains unsafe path: {}", + entry.name() + )); + }; + let path_str = path.to_string_lossy(); + if path.is_absolute() { + return Err(format!("Node.js zip contains absolute path: {path_str}")); + } + if path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err(format!("Node.js zip contains path traversal: {path_str}")); + } + } + Ok(()) +} + fn validate_managed_node_archive_entries( archive: &mut tar::Archive, ) -> Result<(), String> { @@ -324,13 +420,27 @@ fn validate_managed_node_archive_entries( } fn verify_node_tree(dir: &std::path::Path) -> Result<(), String> { - let node = dir.join("bin").join("node"); - let npm = dir.join("bin").join("npm"); - if !node.is_file() { - return Err("Node.js archive missing bin/node".to_string()); + #[cfg(windows)] + { + let node = dir.join("node.exe"); + let npm = dir.join("npm.cmd"); + if !node.is_file() { + return Err("Node.js archive missing node.exe".to_string()); + } + if !npm.is_file() { + return Err("Node.js archive missing npm.cmd".to_string()); + } } - if !npm.is_file() { - return Err("Node.js archive missing bin/npm".to_string()); + #[cfg(not(windows))] + { + let node = dir.join("bin").join("node"); + let npm = dir.join("bin").join("npm"); + if !node.is_file() { + return Err("Node.js archive missing bin/node".to_string()); + } + if !npm.is_file() { + return Err("Node.js archive missing bin/npm".to_string()); + } } Ok(()) } @@ -465,4 +575,22 @@ mod tests { "'/tmp/Buzz'\\''s Node'" ); } + + #[test] + fn extracted_archive_dirname_strips_known_suffixes() { + let zip = "node-v24.11.0-win-x64.zip"; + assert_eq!( + zip.strip_suffix(".tar.gz") + .or_else(|| zip.strip_suffix(".zip")) + .unwrap_or(zip), + "node-v24.11.0-win-x64" + ); + let tar = "node-v24.11.0-darwin-arm64.tar.gz"; + assert_eq!( + tar.strip_suffix(".tar.gz") + .or_else(|| tar.strip_suffix(".zip")) + .unwrap_or(tar), + "node-v24.11.0-darwin-arm64" + ); + } } diff --git a/desktop/src-tauri/src/managed_agents/managed_node_paths.rs b/desktop/src-tauri/src/managed_agents/managed_node_paths.rs index 6533f4fbe..b954d5cc7 100644 --- a/desktop/src-tauri/src/managed_agents/managed_node_paths.rs +++ b/desktop/src-tauri/src/managed_agents/managed_node_paths.rs @@ -10,23 +10,48 @@ pub(crate) fn buzz_managed_node_root() -> Option { dirs::data_dir().map(|dir| dir.join("Buzz").join("runtimes").join("node")) } +/// Map `(OS, ARCH)` to the Node.js official platform directory name, or +/// `None` when Buzz does not ship a managed runtime for that host. +pub(crate) fn managed_node_platform(os: &str, arch: &str) -> Option<&'static str> { + match (os, arch) { + ("macos", "aarch64") => Some("darwin-arm64"), + ("macos", "x86_64") => Some("darwin-x64"), + ("linux", "x86_64") => Some("linux-x64"), + ("linux", "aarch64") => Some("linux-arm64"), + ("windows", "x86_64") => Some("win-x64"), + ("windows", "aarch64") => Some("win-arm64"), + _ => None, + } +} + pub(crate) fn buzz_managed_node_bin_dir() -> Option { - let platform = match (std::env::consts::OS, std::env::consts::ARCH) { - ("macos", "aarch64") => "darwin-arm64", - ("macos", "x86_64") => "darwin-x64", - ("linux", "x86_64") => "linux-x64", - ("linux", "aarch64") => "linux-arm64", - _ => return None, - }; + let platform = managed_node_platform(std::env::consts::OS, std::env::consts::ARCH)?; buzz_managed_node_root().map(|root| { - root.join(BUZZ_MANAGED_NODE_VERSION) - .join(platform) - .join("bin") + let base = root.join(BUZZ_MANAGED_NODE_VERSION).join(platform); + // Official Windows Node zips put `node.exe` at the platform root (no + // `bin/` subdirectory). Unix tarballs keep the usual `bin/` layout. + #[cfg(windows)] + { + base + } + #[cfg(not(windows))] + { + base.join("bin") + } }) } pub(crate) fn buzz_managed_node_bin_path() -> Option { - buzz_managed_node_bin_dir().map(|bin| bin.join("node")) + buzz_managed_node_bin_dir().map(|bin| { + #[cfg(windows)] + { + bin.join("node.exe") + } + #[cfg(not(windows))] + { + bin.join("node") + } + }) } pub(crate) fn buzz_managed_npm_bin_dir() -> Option { @@ -84,3 +109,29 @@ fn is_executable_file(path: &std::path::Path) -> bool { true } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn managed_node_platform_covers_supported_hosts() { + assert_eq!( + managed_node_platform("macos", "aarch64"), + Some("darwin-arm64") + ); + assert_eq!(managed_node_platform("macos", "x86_64"), Some("darwin-x64")); + assert_eq!(managed_node_platform("linux", "x86_64"), Some("linux-x64")); + assert_eq!( + managed_node_platform("linux", "aarch64"), + Some("linux-arm64") + ); + assert_eq!(managed_node_platform("windows", "x86_64"), Some("win-x64")); + assert_eq!( + managed_node_platform("windows", "aarch64"), + Some("win-arm64") + ); + assert_eq!(managed_node_platform("windows", "x86"), None); + assert_eq!(managed_node_platform("freebsd", "x86_64"), None); + } +} From 8f12528dd29407ed24ba60c9d2e35ad738ac7c43 Mon Sep 17 00:00:00 2001 From: Taksh Date: Thu, 23 Jul 2026 13:39:25 +0300 Subject: [PATCH 3/3] fix(desktop): let machine onboarding steps scroll under the docked CTA Signed-off-by: Taksh Co-authored-by: Cursor --- desktop/src/features/onboarding/ui/DefaultConfigStep.tsx | 3 ++- desktop/src/shared/styles/globals/components.css | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx index e25d95e75..b00bdb20c 100644 --- a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx +++ b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx @@ -279,7 +279,8 @@ export function DefaultConfigStep({ return ( .buzz-onboarding-slide,