Skip to content
Merged
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
39 changes: 32 additions & 7 deletions desktop/src-tauri/src/managed_agents/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -361,16 +361,37 @@ fn profile_target_dirs(root: &Path) -> [PathBuf; 2] {
}

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));
}

dirs.extend(
ordered_search_dirs(
!cfg!(debug_assertions),
std::env::current_exe()
.ok()
.and_then(|path| path.parent().map(Path::to_path_buf)),
);
std::env::current_dir().ok(),
)
}

/// Where a bare command such as `buzz-acp` is looked for, in order.
///
/// A release build looks only next to its own executable, where the bundled
/// sidecars live. `workspace_root_dir` is `CARGO_MANIFEST_DIR`, the checkout
/// the binary was built from: on the owner's machine that made the installed
/// app spawn whatever `target/release/buzz-acp.exe` the last `cargo build` had
/// left there, so a rebuild in that checkout silently changed the harness the
/// live agents ran. A debug build keeps the target dirs first, since `just dev`
/// builds fresh sidecars there and never bundles them.
fn ordered_search_dirs(
release: bool,
exe_dir: Option<PathBuf>,
current_dir: Option<PathBuf>,
) -> Vec<PathBuf> {
if release {
return exe_dir.into_iter().collect();
}
let mut dirs = profile_target_dirs(&workspace_root_dir()).to_vec();
if let Some(current_dir) = current_dir {
dirs.extend(profile_target_dirs(&current_dir));
}
dirs.extend(exe_dir);
dirs.into_iter().fold(Vec::new(), |mut unique, dir| {
if !unique.contains(&dir) {
unique.push(dir);
Expand Down Expand Up @@ -1255,3 +1276,7 @@ pub fn managed_agent_avatar_url(command: &str) -> Option<String> {

#[cfg(test)]
mod tests;

#[cfg(test)]
#[path = "discovery/tests/search_dirs.rs"]
mod search_dirs_tests;
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
use std::path::PathBuf;

/// A release build must never look in the build machine's checkout for a
/// sidecar: on the owner's PC that made the installed app run whatever
/// `target/release/buzz-acp.exe` the last `cargo build` left behind.
#[test]
fn release_builds_look_for_sidecars_only_next_to_the_executable() {
let exe_dir = PathBuf::from("/app/bin");
let cwd = PathBuf::from("/somewhere/else");
assert_eq!(
super::ordered_search_dirs(true, Some(exe_dir.clone()), Some(cwd.clone())),
vec![exe_dir.clone()]
);
assert!(super::ordered_search_dirs(true, None, Some(cwd.clone())).is_empty());

let debug = super::ordered_search_dirs(false, Some(exe_dir.clone()), Some(cwd.clone()));
assert_eq!(
debug.last(),
Some(&exe_dir),
"debug builds fall back to the exe dir last"
);
assert!(debug.contains(&cwd.join("target/debug")));
assert!(debug.contains(&cwd.join("target/release")));
}
38 changes: 38 additions & 0 deletions desktop/src-tauri/src/managed_agents/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,20 @@ pub fn spawn_agent_child(
.map_err(|error| format!("failed to clone log handle: {error}"))?;
let resolved_acp_command = resolve_command(&record.acp_command)
.ok_or_else(|| missing_command_message(&record.acp_command, "ACP harness command"))?;
append_log_marker(
&log_path,
&format!("harness: {}", resolved_acp_command.display()),
)?;
require_bundled_harness(
record.respond_to == super::types::RespondTo::Allowlist,
!cfg!(debug_assertions),
&resolved_acp_command,
std::env::current_exe()
.ok()
.and_then(|exe| exe.parent().map(std::path::Path::to_path_buf))
.as_deref(),
)
.map_err(|error| format!("{}: {error}", record.name))?;
let effective_mcp_command = known_acp_runtime(effective_command)
.and_then(|r| r.mcp_command)
.unwrap_or("");
Expand Down Expand Up @@ -947,5 +961,29 @@ pub fn start_managed_agent_process(
#[cfg(test)]
mod test_fixtures;

/// A guest-facing agent runs only the harness that shipped with this app.
///
/// Its permission gate lives in that binary. A harness picked up from PATH, a
/// stale checkout or an override could predate the gate, and a guest would
/// never know the difference. Owner-only agents keep their freedom, and a debug
/// build has no bundle to insist on.
fn require_bundled_harness(
faces_guests: bool,
release: bool,
harness: &std::path::Path,
bundled_dir: Option<&std::path::Path>,
) -> Result<(), String> {
if !faces_guests || !release {
return Ok(());
}
if bundled_dir.is_some() && harness.parent() == bundled_dir {
return Ok(());
}
Err(format!(
"answers guests, so it only runs the harness bundled with this app; refusing {}",
harness.display()
))
}

#[cfg(test)]
mod tests;
22 changes: 22 additions & 0 deletions desktop/src-tauri/src/managed_agents/runtime/tests.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
use crate::managed_agents::known_acp_runtime;

#[test]
fn guest_facing_agents_only_run_the_bundled_harness_in_release() {
use std::path::Path;
let bundled = Path::new("/app");
let ok = Path::new("/app/buzz-acp");
let stray = Path::new("/checkout/target/release/buzz-acp");
assert!(super::require_bundled_harness(true, true, ok, Some(bundled)).is_ok());
assert!(super::require_bundled_harness(true, true, stray, Some(bundled)).is_err());
assert!(
super::require_bundled_harness(true, true, ok, None).is_err(),
"no bundle dir means refuse"
);
assert!(
super::require_bundled_harness(false, true, stray, Some(bundled)).is_ok(),
"owner-only agents are unaffected"
);
assert!(
super::require_bundled_harness(true, false, stray, Some(bundled)).is_ok(),
"debug builds have no bundle"
);
}

#[path = "cli_tests.rs"]
mod cli_tests;

Expand Down
Loading