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
17 changes: 9 additions & 8 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}}

Expand Down
51 changes: 40 additions & 11 deletions desktop/src-tauri/src/managed_agents/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -372,26 +372,41 @@ pub fn normalize_agent_args(command: &str, agent_args: Vec<String>) -> Vec<Strin
normalized
}

fn profile_target_dirs(root: &Path) -> [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 {
[root.join("target/release"), root.join("target/debug")]
}
}

fn command_search_dirs() -> Vec<PathBuf> {
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(&current_dir));
fn command_search_dirs_for_profile(
workspace_root: &Path,
current_dir: Option<&Path>,
current_exe_parent: Option<&Path>,
debug_profile: bool,
) -> Vec<PathBuf> {
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);
Expand All @@ -400,6 +415,20 @@ fn command_search_dirs() -> Vec<PathBuf> {
})
}

fn command_search_dirs() -> Vec<PathBuf> {
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;
Expand Down
125 changes: 125 additions & 0 deletions desktop/src-tauri/src/managed_agents/discovery/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf> {
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(
Expand Down