Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 19 additions & 10 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
148 changes: 138 additions & 10 deletions desktop/src-tauri/src/commands/agent_discovery/managed_node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,27 @@ const MANAGED_NODE_ARTIFACT: Option<ManagedNodeArtifact> = Some(ManagedNodeArtif
sha256: "4786d00c4d259d3ff0b2328307f764ef3ced65f2d6e9502d433e68d66238509d",
});

#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
const MANAGED_NODE_ARTIFACT: Option<ManagedNodeArtifact> = 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<ManagedNodeArtifact> = 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<ManagedNodeArtifact> = None;

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand All @@ -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<std::fs::File>,
) -> 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<R: std::io::Read>(
archive: &mut tar::Archive<R>,
) -> Result<(), String> {
Expand Down Expand Up @@ -324,13 +420,27 @@ fn validate_managed_node_archive_entries<R: std::io::Read>(
}

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(())
}
Expand Down Expand Up @@ -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"
);
}
}
73 changes: 62 additions & 11 deletions desktop/src-tauri/src/managed_agents/managed_node_paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,48 @@ pub(crate) fn buzz_managed_node_root() -> Option<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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<PathBuf> {
Expand Down Expand Up @@ -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);
}
}
3 changes: 2 additions & 1 deletion desktop/src/features/onboarding/ui/DefaultConfigStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,8 @@ export function DefaultConfigStep({

return (
<OnboardingSlideTransition
className="flex min-h-full w-full flex-col items-center"
// pb clears the docked footer when the step frame scrolls under it.
className="flex min-h-full w-full flex-col items-center pb-20"
data-testid="onboarding-page-config"
direction={direction}
transitionKey={`default-config-${direction}`}
Expand Down
7 changes: 6 additions & 1 deletion desktop/src/shared/styles/globals/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,13 @@
}

.buzz-onboarding-step-frame {
/* Fill the shell under the chrome + docked CTA, but keep tall steps
(e.g. machine onboarding model defaults) scrollable instead of clipped
when max-height binds. */
min-height: min(1000px, max(0px, calc(100dvh - 106px - 7rem)));
max-height: 1000px;
max-height: min(1000px, max(0px, calc(100dvh - 106px - 7rem)));
overflow-x: hidden;
overflow-y: auto;
}

.buzz-onboarding-step-frame > .buzz-onboarding-slide,
Expand Down