fix(desktop): preserve login-shell PATH for managed agents (#1193)

Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
Tyler
2026-06-23 11:58:25 -04:00
committed by GitHub
parent b3b0704efb
commit 29978b6f93
2 changed files with 89 additions and 22 deletions

View File

@@ -11,6 +11,9 @@ use crate::{
util::now_iso,
};
mod path;
use path::build_augmented_path;
type RespondToEnv = (Vec<(&'static str, String)>, Vec<&'static str>);
/// Binary name fragments for all known agent/harness processes that Buzz
@@ -1535,28 +1538,13 @@ pub fn spawn_agent_child(
// - bundled CLI via ~/.local/bin symlink
// - bundled sidecars (buzz, buzz-acp, etc.) via exe parent (Contents/MacOS/)
// - runtimes (node, python, etc.) via login shell PATH
let augmented_path = {
let mut parts: Vec<std::path::PathBuf> = Vec::new();
if let Some(home) = dirs::home_dir() {
parts.push(home.join(".local").join("bin"));
}
if let Ok(exe) = std::env::current_exe() {
if let Some(parent) = exe.parent() {
parts.push(parent.to_path_buf());
}
}
if let Some(shell_path) = login_shell_path() {
parts.push(std::path::PathBuf::from(shell_path));
}
if parts.is_empty() {
None
} else {
// join_paths uses the platform separator (':' on Unix, ';' on Windows).
std::env::join_paths(parts)
.ok()
.map(|s| s.to_string_lossy().into_owned())
}
};
let augmented_path = build_augmented_path(
dirs::home_dir(),
std::env::current_exe()
.ok()
.and_then(|exe| exe.parent().map(std::path::Path::to_path_buf)),
login_shell_path(),
);
let mut command = std::process::Command::new(&resolved_acp_command);
if let Some(home) = super::default_agent_workdir() {

View File

@@ -0,0 +1,79 @@
//! PATH augmentation for launched managed-agent child processes.
use std::path::PathBuf;
/// Assemble the augmented `PATH` for a launched managed-agent child process.
///
/// Concatenates, in priority order: `<home>/.local/bin` (the bundled CLI
/// symlink), the running executable's parent dir (DMG sidecars under
/// `Contents/MacOS/`), and the user's login-shell `PATH` (runtimes like
/// node/python).
///
/// `shell_path` is the raw colon-delimited string from a login shell, so it is
/// split into individual entries before joining. Pushing it as a single segment
/// would make `join_paths` reject it (a segment containing the separator is an
/// error), collapsing the entire augmented `PATH` to `None` — the bug this
/// guards against, which left managed agents unable to find `buzz`. Returns
/// `None` only when no entries exist.
pub(super) fn build_augmented_path(
home: Option<PathBuf>,
exe_parent: Option<PathBuf>,
shell_path: Option<String>,
) -> Option<String> {
let mut parts: Vec<PathBuf> = Vec::new();
if let Some(home) = home {
parts.push(home.join(".local").join("bin"));
}
if let Some(parent) = exe_parent {
parts.push(parent);
}
if let Some(shell_path) = shell_path {
parts.extend(std::env::split_paths(&shell_path));
}
if parts.is_empty() {
return None;
}
// join_paths uses the platform separator (':' on Unix, ';' on Windows).
std::env::join_paths(parts)
.ok()
.map(|s| s.to_string_lossy().into_owned())
}
#[cfg(test)]
mod tests {
use super::build_augmented_path;
use std::path::PathBuf;
#[cfg(unix)]
#[test]
fn splits_colon_delimited_shell_path() {
// Regression: the shell PATH arrives as one colon-delimited string. It
// must be split into segments before join_paths, or join_paths rejects
// it and the whole augmented PATH collapses to None (managed agents then
// lose `buzz`).
let result = build_augmented_path(
Some(PathBuf::from("/home/agent")),
Some(PathBuf::from("/Applications/Buzz.app/Contents/MacOS")),
Some("/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin".to_string()),
);
assert_eq!(
result.as_deref(),
Some(
"/home/agent/.local/bin:/Applications/Buzz.app/Contents/MacOS:\
/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"
),
);
}
#[test]
fn none_when_no_inputs() {
assert_eq!(build_augmented_path(None, None, None), None);
}
#[cfg(unix)]
#[test]
fn shell_path_only() {
let result = build_augmented_path(None, None, Some("/usr/bin:/bin".to_string()));
assert_eq!(result.as_deref(), Some("/usr/bin:/bin"));
}
}