Skip to content

Commit 29978b6

Browse files
tlongwell-blocknpub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67dTyler Longwell
authored
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>
1 parent b3b0704 commit 29978b6

2 files changed

Lines changed: 89 additions & 22 deletions

File tree

desktop/src-tauri/src/managed_agents/runtime.rs

Lines changed: 10 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ use crate::{
1111
util::now_iso,
1212
};
1313

14+
mod path;
15+
use path::build_augmented_path;
16+
1417
type RespondToEnv = (Vec<(&'static str, String)>, Vec<&'static str>);
1518

1619
/// Binary name fragments for all known agent/harness processes that Buzz
@@ -1535,28 +1538,13 @@ pub fn spawn_agent_child(
15351538
// - bundled CLI via ~/.local/bin symlink
15361539
// - bundled sidecars (buzz, buzz-acp, etc.) via exe parent (Contents/MacOS/)
15371540
// - runtimes (node, python, etc.) via login shell PATH
1538-
let augmented_path = {
1539-
let mut parts: Vec<std::path::PathBuf> = Vec::new();
1540-
if let Some(home) = dirs::home_dir() {
1541-
parts.push(home.join(".local").join("bin"));
1542-
}
1543-
if let Ok(exe) = std::env::current_exe() {
1544-
if let Some(parent) = exe.parent() {
1545-
parts.push(parent.to_path_buf());
1546-
}
1547-
}
1548-
if let Some(shell_path) = login_shell_path() {
1549-
parts.push(std::path::PathBuf::from(shell_path));
1550-
}
1551-
if parts.is_empty() {
1552-
None
1553-
} else {
1554-
// join_paths uses the platform separator (':' on Unix, ';' on Windows).
1555-
std::env::join_paths(parts)
1556-
.ok()
1557-
.map(|s| s.to_string_lossy().into_owned())
1558-
}
1559-
};
1541+
let augmented_path = build_augmented_path(
1542+
dirs::home_dir(),
1543+
std::env::current_exe()
1544+
.ok()
1545+
.and_then(|exe| exe.parent().map(std::path::Path::to_path_buf)),
1546+
login_shell_path(),
1547+
);
15601548

15611549
let mut command = std::process::Command::new(&resolved_acp_command);
15621550
if let Some(home) = super::default_agent_workdir() {
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
//! PATH augmentation for launched managed-agent child processes.
2+
3+
use std::path::PathBuf;
4+
5+
/// Assemble the augmented `PATH` for a launched managed-agent child process.
6+
///
7+
/// Concatenates, in priority order: `<home>/.local/bin` (the bundled CLI
8+
/// symlink), the running executable's parent dir (DMG sidecars under
9+
/// `Contents/MacOS/`), and the user's login-shell `PATH` (runtimes like
10+
/// node/python).
11+
///
12+
/// `shell_path` is the raw colon-delimited string from a login shell, so it is
13+
/// split into individual entries before joining. Pushing it as a single segment
14+
/// would make `join_paths` reject it (a segment containing the separator is an
15+
/// error), collapsing the entire augmented `PATH` to `None` — the bug this
16+
/// guards against, which left managed agents unable to find `buzz`. Returns
17+
/// `None` only when no entries exist.
18+
pub(super) fn build_augmented_path(
19+
home: Option<PathBuf>,
20+
exe_parent: Option<PathBuf>,
21+
shell_path: Option<String>,
22+
) -> Option<String> {
23+
let mut parts: Vec<PathBuf> = Vec::new();
24+
if let Some(home) = home {
25+
parts.push(home.join(".local").join("bin"));
26+
}
27+
if let Some(parent) = exe_parent {
28+
parts.push(parent);
29+
}
30+
if let Some(shell_path) = shell_path {
31+
parts.extend(std::env::split_paths(&shell_path));
32+
}
33+
if parts.is_empty() {
34+
return None;
35+
}
36+
// join_paths uses the platform separator (':' on Unix, ';' on Windows).
37+
std::env::join_paths(parts)
38+
.ok()
39+
.map(|s| s.to_string_lossy().into_owned())
40+
}
41+
42+
#[cfg(test)]
43+
mod tests {
44+
use super::build_augmented_path;
45+
use std::path::PathBuf;
46+
47+
#[cfg(unix)]
48+
#[test]
49+
fn splits_colon_delimited_shell_path() {
50+
// Regression: the shell PATH arrives as one colon-delimited string. It
51+
// must be split into segments before join_paths, or join_paths rejects
52+
// it and the whole augmented PATH collapses to None (managed agents then
53+
// lose `buzz`).
54+
let result = build_augmented_path(
55+
Some(PathBuf::from("/home/agent")),
56+
Some(PathBuf::from("/Applications/Buzz.app/Contents/MacOS")),
57+
Some("/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin".to_string()),
58+
);
59+
assert_eq!(
60+
result.as_deref(),
61+
Some(
62+
"/home/agent/.local/bin:/Applications/Buzz.app/Contents/MacOS:\
63+
/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"
64+
),
65+
);
66+
}
67+
68+
#[test]
69+
fn none_when_no_inputs() {
70+
assert_eq!(build_augmented_path(None, None, None), None);
71+
}
72+
73+
#[cfg(unix)]
74+
#[test]
75+
fn shell_path_only() {
76+
let result = build_augmented_path(None, None, Some("/usr/bin:/bin".to_string()));
77+
assert_eq!(result.as_deref(), Some("/usr/bin:/bin"));
78+
}
79+
}

0 commit comments

Comments
 (0)