diff --git a/Justfile b/Justfile index 701a1f06b2..3dd5a4e514 100644 --- a/Justfile +++ b/Justfile @@ -149,7 +149,7 @@ fmt-all: fmt desktop-tauri-fmt mobile-fmt fix-all: fmt desktop-tauri-fmt desktop-fix web-fix mobile-fix # Ensure sidecar placeholder binaries exist (Tauri validates externalBin at compile time) -# Sidecar binary list must stay in sync with desktop-release-build below. +# Sidecar binary list must stay in sync with scripts/bundle-sidecars.sh. _ensure-sidecar-stubs: #!/usr/bin/env bash set -euo pipefail @@ -227,18 +227,19 @@ desktop-tauri-test-compiled-flags: _ensure-sidecar-stubs echo "Both compiled states verified." # Build the full desktop Tauri app locally (unsigned, for testing) -# Sidecar binary list must stay in sync with _ensure-sidecar-stubs above. +# Uses the same sidecar build/bundle sequence as the official release workflows. # pnpm install is unconditional here: release builds must start from a clean dep tree. desktop-release-build target="aarch64-apple-darwin": #!/usr/bin/env bash set -euo pipefail TARGET={{target}} - 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" + cargo build --release --target "$TARGET" \ + -p buzz-acp \ + -p buzz-agent \ + -p buzz-dev-mcp \ + -p git-credential-nostr \ + -p buzz-cli + ./scripts/bundle-sidecars.sh "$TARGET" pnpm install cd {{desktop_dir}} && pnpm tauri build --features mesh-llm --target {{target}} diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index f40eed5a13..9e92c9c5e0 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -372,8 +372,8 @@ pub fn normalize_agent_args(command: &str, agent_args: Vec) -> Vec [PathBuf; 2] { - if cfg!(debug_assertions) { +fn profile_target_dirs(root: &Path, debug_profile: bool) -> [PathBuf; 2] { + if debug_profile { // `just dev` builds fresh debug sidecars; never prefer stale release output. [root.join("target/debug"), root.join("target/release")] } else { @@ -381,17 +381,32 @@ fn profile_target_dirs(root: &Path) -> [PathBuf; 2] { } } -fn command_search_dirs() -> Vec { - let mut dirs = profile_target_dirs(&workspace_root_dir()).to_vec(); - if let Ok(current_dir) = std::env::current_dir() { - dirs.extend(profile_target_dirs(¤t_dir)); +fn command_search_dirs_for_profile( + workspace_root: &Path, + current_dir: Option<&Path>, + current_exe_parent: Option<&Path>, + debug_profile: bool, +) -> Vec { + let mut dirs = Vec::new(); + + // Release builds must use the sidecars shipped beside the app executable. + // Workspace paths can still exist on developer machines and may contain + // stale artifacts that do not match the packaged application. + if !debug_profile { + dirs.extend(current_exe_parent.map(Path::to_path_buf)); + } + + dirs.extend(profile_target_dirs(workspace_root, debug_profile)); + if let Some(current_dir) = current_dir { + dirs.extend(profile_target_dirs(current_dir, debug_profile)); + } + + // Debug builds prefer fresh workspace artifacts while retaining bundled + // sidecars as a fallback. + if debug_profile { + dirs.extend(current_exe_parent.map(Path::to_path_buf)); } - dirs.extend( - std::env::current_exe() - .ok() - .and_then(|path| path.parent().map(Path::to_path_buf)), - ); dirs.into_iter().fold(Vec::new(), |mut unique, dir| { if !unique.contains(&dir) { unique.push(dir); @@ -400,6 +415,20 @@ fn command_search_dirs() -> Vec { }) } +fn command_search_dirs() -> Vec { + let current_dir = std::env::current_dir().ok(); + let current_exe_parent = std::env::current_exe() + .ok() + .and_then(|path| path.parent().map(Path::to_path_buf)); + + command_search_dirs_for_profile( + &workspace_root_dir(), + current_dir.as_deref(), + current_exe_parent.as_deref(), + cfg!(debug_assertions), + ) +} + fn is_executable_file(path: &Path) -> bool { let Ok(metadata) = path.metadata() else { return false; diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 0ed4fe0f6a..3df1942035 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -141,6 +141,131 @@ fn explicit_path_resolution_ignores_non_executable_files() { let _ = std::fs::remove_dir_all(dir); } +#[test] +fn release_command_search_prefers_bundled_sidecars() { + let dirs = super::command_search_dirs_for_profile( + std::path::Path::new("/workspace"), + Some(std::path::Path::new("/working-copy")), + Some(std::path::Path::new( + "/Applications/Buzz.app/Contents/MacOS", + )), + false, + ); + + assert_eq!( + dirs, + vec![ + PathBuf::from("/Applications/Buzz.app/Contents/MacOS"), + PathBuf::from("/workspace/target/release"), + PathBuf::from("/workspace/target/debug"), + PathBuf::from("/working-copy/target/release"), + PathBuf::from("/working-copy/target/debug"), + ] + ); +} + +/// Resolve a command against an explicit, ordered dir list using the same +/// first-executable-wins rule as `resolve_workspace_command`. Keeps the +/// assertion on real files instead of only on directory ordering. +#[cfg(unix)] +fn first_executable_in(dirs: &[PathBuf], file_name: &str) -> Option { + dirs.iter() + .map(|dir| dir.join(file_name)) + .find(|candidate| super::is_executable_file(candidate)) +} + +/// Release profile must pick the packaged sidecar even when a workspace +/// artifact with the same name also exists. The ordering tests alone cannot +/// catch a regression here, because they never place real files on disk. +#[cfg(unix)] +#[test] +fn release_resolution_picks_bundled_sidecar_over_workspace_artifact() { + use std::os::unix::fs::PermissionsExt; + + let root = std::env::temp_dir().join(format!("buzz-sidecar-{}", uuid::Uuid::new_v4())); + let bundled = root.join("Buzz.app/Contents/MacOS"); + let workspace = root.join("workspace"); + let workspace_release = workspace.join("target/release"); + std::fs::create_dir_all(&bundled).expect("create bundled dir"); + std::fs::create_dir_all(&workspace_release).expect("create workspace target dir"); + + // Both locations hold an executable named buzz-acp. + for dir in [&bundled, &workspace_release] { + let bin = dir.join("buzz-acp"); + std::fs::write(&bin, "").expect("write sidecar"); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)) + .expect("chmod sidecar"); + } + + let dirs = super::command_search_dirs_for_profile(&workspace, None, Some(&bundled), false); + assert_eq!( + first_executable_in(&dirs, "buzz-acp"), + Some(bundled.join("buzz-acp")), + "release profile must resolve the packaged sidecar, not the workspace artifact" + ); + + let _ = std::fs::remove_dir_all(&root); +} + +/// Documents the fallback contract explicitly: when the packaged sidecar is +/// present but NOT executable, release resolution falls through to the +/// workspace artifact rather than failing. Locking this down makes the +/// behavior intentional and visible instead of incidental. +#[cfg(unix)] +#[test] +fn release_resolution_falls_back_when_bundled_sidecar_is_not_executable() { + use std::os::unix::fs::PermissionsExt; + + let root = std::env::temp_dir().join(format!("buzz-sidecar-{}", uuid::Uuid::new_v4())); + let bundled = root.join("Buzz.app/Contents/MacOS"); + let workspace = root.join("workspace"); + let workspace_release = workspace.join("target/release"); + std::fs::create_dir_all(&bundled).expect("create bundled dir"); + std::fs::create_dir_all(&workspace_release).expect("create workspace target dir"); + + let broken = bundled.join("buzz-acp"); + std::fs::write(&broken, "").expect("write bundled sidecar"); + std::fs::set_permissions(&broken, std::fs::Permissions::from_mode(0o644)) + .expect("chmod bundled sidecar non-executable"); + + let usable = workspace_release.join("buzz-acp"); + std::fs::write(&usable, "").expect("write workspace sidecar"); + std::fs::set_permissions(&usable, std::fs::Permissions::from_mode(0o755)) + .expect("chmod workspace sidecar"); + + let dirs = super::command_search_dirs_for_profile(&workspace, None, Some(&bundled), false); + assert_eq!( + first_executable_in(&dirs, "buzz-acp"), + Some(usable), + "a non-executable packaged sidecar must fall through to the workspace artifact" + ); + + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn debug_command_search_prefers_fresh_workspace_artifacts() { + let dirs = super::command_search_dirs_for_profile( + std::path::Path::new("/workspace"), + Some(std::path::Path::new("/working-copy")), + Some(std::path::Path::new( + "/Applications/Buzz.app/Contents/MacOS", + )), + true, + ); + + assert_eq!( + dirs, + vec![ + PathBuf::from("/workspace/target/debug"), + PathBuf::from("/workspace/target/release"), + PathBuf::from("/working-copy/target/debug"), + PathBuf::from("/working-copy/target/release"), + PathBuf::from("/Applications/Buzz.app/Contents/MacOS"), + ] + ); +} + #[test] fn classifies_available_when_adapter_found() { let (status, cmd, path) = classify_runtime(