Add Buzz-managed Node runtime for agent installs (#1930)

Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Tyler
2026-07-15 23:11:44 -04:00
committed by GitHub
parent ccf7f1a2d0
commit 0722346b1f
14 changed files with 799 additions and 340 deletions

View File

@@ -279,7 +279,10 @@ const overrides = new Map([
// +13: fetch_login_shell_path_inner Windows guard (POSIX PATH → None).
// resolve_git_bash made pub(crate) for Windows test access.
// +1: login_shell_candidates doc comment expanded for resolve_bash_path.
["src-tauri/src/managed_agents/discovery.rs", 1366],
// Buzz-managed Node path helpers and resolution tests moved to
// managed_node_paths.rs and discovery/tests/managed_path_resolution.rs;
// ratcheting 1366 -> 1392 after adding the managed-path probes to discovery.
["src-tauri/src/managed_agents/discovery.rs", 1393],
// rebase over codex-acp-package-swap: its version-probe tests union with the
// doctor-install-reliability nvm/login-shell/semver tests — each side alone
// stayed under the 1000 default; the union exceeds it.
@@ -289,9 +292,8 @@ const overrides = new Map([
// candidates, cli_install_commands_for_os PowerShell selection, login_shell_path
// None regression, .cmd shim resolution, no-git-bash error hint.
// +32: deterministic .cmd resolver + no-registry + install_shell_from tests.
// team-instructions-first-class: record_with test fixture gained the new
// ManagedAgentRecord.team_id field (+1 line) alongside persona_team_dir.
["src-tauri/src/managed_agents/discovery/tests.rs", 1271],
// Managed-path resolution test split to discovery/tests/managed_path_resolution.rs.
["src-tauri/src/managed_agents/discovery/tests.rs", 1273],
// identity-import-keyring: the identity resolution state machine's behavioral
// matrix (46 tests over FakeIdentityStore — probe × marker × file cells,
// adoption / read-back-corruption / marker-failure arms, recovery-mode

View File

@@ -970,6 +970,7 @@ dependencies = [
"dirs",
"earshot",
"ed25519-dalek",
"flate2",
"futures-util",
"hex",
"image",
@@ -7747,6 +7748,7 @@ dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-channel",
"futures-core",
"futures-util",
"h2",

View File

@@ -80,7 +80,7 @@ serde_yaml = "0.9"
toml = "0.8"
nostr = { version = "0.44", features = ["nip44"] }
zeroize = "1"
reqwest = { version = "0.13", features = ["json", "query", "stream"] }
reqwest = { version = "0.13", features = ["json", "query", "stream", "blocking"] }
url = "2"
buzz_core_pkg = { package = "buzz-core", path = "../../crates/buzz-core" }
buzz_persona_pkg = { package = "buzz-persona", path = "../../crates/buzz-persona" }
@@ -107,6 +107,7 @@ png = "0.18"
arboard = "3"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif"] }
zip = "8"
flate2 = "1"
sherpa-onnx = "1.12"
regex = "1"
rusqlite = { version = "0.37", features = ["bundled"] }

View File

@@ -145,11 +145,9 @@ fn install_acp_runtime_blocking(runtime_id: &str) -> Result<InstallRuntimeResult
let mut steps = Vec::new();
// Phase 1: Install CLI if missing and commands are available.
// NOTE: the npm EACCES preflight and `npm_eacces_hint` classifier only run
// in Phase 2 below. Today every entry in `cli_install_commands` is a
// curl-pipe; all `npm install -g` commands live in `adapter_install_commands`.
// If a future runtime adds an npm-global CLI install it must also add the
// preflight and classifier to this loop.
// Today every entry in `cli_install_commands` is a curl-pipe; npm-backed
// adapter installs live in Phase 2 below where they are rewritten to a
// Buzz-private prefix before execution.
if let Some(cli) = runtime.underlying_cli {
if crate::managed_agents::resolve_command(cli).is_none() {
for cmd in runtime.cli_install_commands_for_os() {
@@ -181,10 +179,30 @@ fn install_acp_runtime_blocking(runtime_id: &str) -> Result<InstallRuntimeResult
adapter_path.as_deref(),
runtime.adapter_install_commands,
) {
let use_managed_npm =
cmds.iter().any(|cmd| is_npm_global_install(cmd)) && managed_node_runtime_supported();
if use_managed_npm {
if let Err(step) = ensure_managed_node_runtime_blocking() {
steps.push(*step);
return Ok(InstallRuntimeResult {
success: false,
steps,
restarted_count: 0,
failed_restart_count: 0,
});
}
}
for cmd in cmds {
if is_npm_global_install(cmd) {
if let Some(step) = npm_preflight_check("adapter", cmd) {
steps.push(step);
let planned = match if use_managed_npm {
managed_npm_command(cmd)
} else {
Ok(None)
} {
Ok(Some(command)) => command,
Ok(None) => cmd.to_string(),
Err(step) => {
steps.push(*step);
return Ok(InstallRuntimeResult {
success: false,
steps,
@@ -192,8 +210,9 @@ fn install_acp_runtime_blocking(runtime_id: &str) -> Result<InstallRuntimeResult
failed_restart_count: 0,
});
}
}
let mut result = run_install_command("adapter", cmd);
};
let mut result = run_install_command("adapter", &planned);
if !result.success && result.hint.is_none() && is_npm_global_install(cmd) {
result.hint = npm_eacces_hint(&result.stderr, cmd);
}
@@ -536,11 +555,12 @@ fn persist_last_error_on_install(
save_managed_agents(app, &records)
}
/// Build a login-shell `Command` for `command` with the hermit env vars
/// stripped and the user's PATH set. This is the single source of truth for
/// Build a login-shell `Command` for `command` with hermit env vars stripped,
/// Buzz-managed npm locations set, and the user's PATH set. This is the
/// single source of truth for
/// the shell selection and environment cleanup shared by `run_install_command`
/// and `resolve_npm_prefix` — keeping them in sync so the hermit-strip list
/// can't drift between the two paths.
/// and managed npm install path — keeping them in sync so the hermit-strip list
/// can't drift between command execution paths.
///
/// On Windows, resolves Git Bash via `resolve_bash_path` (skips `BUZZ_SHELL`
/// since install commands require bash syntax). Returns `Err` when no shell
@@ -551,14 +571,36 @@ fn install_shell_command(command: &str) -> Result<std::process::Command, String>
let mut cmd = std::process::Command::new(&shell);
cmd.args(["-l", "-c", command]);
// Strip hermit env vars so npm/node use the user's normal registry and
// global prefix rather than the project-local hermit-managed paths.
// Strip hermit env vars so npm/node use the user's normal registry rather
// than the project-local hermit-managed paths, then give npm defaults for
// Buzz-owned app data. Adapter install commands also pass --prefix
// explicitly; these env vars keep subprocesses/cache/corepack aligned.
cmd.env_remove("NPM_CONFIG_PREFIX");
cmd.env_remove("NPM_CONFIG_CACHE");
cmd.env_remove("COREPACK_HOME");
if let Some(prefix) = crate::managed_agents::buzz_managed_npm_prefix() {
cmd.env("NPM_CONFIG_PREFIX", &prefix);
cmd.env("npm_config_prefix", &prefix);
cmd.env("COREPACK_HOME", prefix.join("corepack"));
cmd.env("NPM_CONFIG_CACHE", prefix.join("cache"));
cmd.env("npm_config_cache", prefix.join("cache"));
}
let mut path_parts = Vec::new();
if let Some(managed_node_bin) = crate::managed_agents::buzz_managed_node_bin_dir() {
path_parts.push(managed_node_bin);
}
if let Some(managed_bin) = crate::managed_agents::buzz_managed_npm_bin_dir() {
path_parts.push(managed_bin);
}
if let Some(ref path) = crate::managed_agents::login_shell_path() {
cmd.env("PATH", path);
path_parts.extend(std::env::split_paths(path));
}
if !path_parts.is_empty() {
if let Ok(path) = std::env::join_paths(path_parts) {
cmd.env("PATH", path);
}
}
// Detach from the controlling terminal so install scripts that read from
@@ -790,232 +832,12 @@ fn floor_char_boundary(s: &str, mut index: usize) -> usize {
index
}
// ── npm EACCES preflight ──────────────────────────────────────────────────────
/// Guidance text for the EACCES / unwritable-prefix case.
fn npm_eacces_guidance(command: &str) -> String {
format!(
"npm's global install directory isn't writable by your user.\n\
\n\
Fix (no sudo):\n\
1. Run: npm config set prefix ~/.npm-global\n\
2. Add to ~/.zprofile: export PATH=\"$HOME/.npm-global/bin:$PATH\"\n\
3. Restart Buzz, then click Install again.\n\
\n\
Or install manually, then click Refresh:\n\
sudo {command}"
)
}
/// Guidance text shown when npm / Node.js is not found in the login-shell PATH.
const NPM_MISSING_HINT: &str = "Node.js / npm was not found. Install Node.js \
(https://nodejs.org or your version manager), restart Buzz, then click Install again.\n\
If npm works in your terminal, make sure your Node version manager is initialized in \
~/.zprofile (not only ~/.zshrc) — Buzz resolves tools via non-interactive login shells.";
/// Result of probing `npm prefix -g` in the hermit-stripped login shell.
#[cfg(unix)]
enum NpmPrefix {
/// npm responded with a parseable prefix path.
Found(std::path::PathBuf),
/// npm was not found, the spawn failed, the command returned a non-zero
/// exit, or the output could not be parsed.
Unavailable,
/// The probe exceeded the 30-second deadline (e.g. a version-manager init
/// that blocks on `/dev/tty`). The install should proceed so the stderr
/// classifier remains the backstop.
TimedOut,
}
/// Spawn the same login shell used by `run_install_command` and run
/// `npm prefix -g` to discover where npm would install global packages.
#[cfg(unix)]
fn resolve_npm_prefix() -> NpmPrefix {
let mut cmd = match install_shell_command("npm prefix -g") {
Ok(cmd) => cmd,
Err(_) => return NpmPrefix::Unavailable,
};
let mut child = match cmd
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(_) => return NpmPrefix::Unavailable,
};
// Drain stdout/stderr on background threads to prevent pipe-buffer deadlock.
let stdout_pipe = child.stdout.take();
let stderr_pipe = child.stderr.take();
let stdout_thread = std::thread::spawn(move || {
let mut buf = Vec::new();
if let Some(mut pipe) = stdout_pipe {
let _ = pipe.read_to_end(&mut buf);
}
buf
});
let stderr_thread = std::thread::spawn(move || {
// Drain stderr so the child doesn't block on a full pipe.
if let Some(mut pipe) = stderr_pipe {
let _ = std::io::copy(&mut pipe, &mut std::io::sink());
}
});
let child_pid = child.id();
let (tx, rx) = std::sync::mpsc::channel();
let wait_thread = std::thread::spawn(move || {
let status = child.wait();
let _ = tx.send(status);
});
// 30-second timeout — plenty for `npm prefix -g`; intentionally shorter
// than the 5-minute install budget in `run_install_command`.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
let raw_bytes: Option<Vec<u8>> = loop {
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
if remaining.is_zero() {
// Timed out: send SIGTERM, clean up threads, signal the caller to
// fall through to the install path rather than abort.
unsafe { libc::kill(child_pid as i32, libc::SIGTERM) };
drop(rx);
let _ = wait_thread.join();
let _ = stdout_thread.join();
let _ = stderr_thread.join();
eprintln!(
"buzz: npm prefix probe timed out after 30s; \
proceeding to install (stderr classifier is the backstop)"
);
return NpmPrefix::TimedOut;
}
match rx.recv_timeout(std::time::Duration::from_millis(200).min(remaining)) {
Ok(Ok(status)) => {
let _ = wait_thread.join();
let stdout = stdout_thread.join().unwrap_or_default();
let _ = stderr_thread.join();
break if status.success() { Some(stdout) } else { None };
}
Ok(Err(_)) | Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
let _ = wait_thread.join();
let _ = stdout_thread.join();
let _ = stderr_thread.join();
break None;
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue,
}
};
let bytes = match raw_bytes {
Some(b) => b,
None => return NpmPrefix::Unavailable,
};
let raw = String::from_utf8_lossy(&bytes).into_owned();
// Version managers can print banner lines before the real prefix — take the
// last non-empty line to skip any preamble.
let prefix = match raw.lines().rfind(|l| !l.trim().is_empty()) {
Some(l) => l.trim().to_string(),
None => return NpmPrefix::Unavailable,
};
if prefix.is_empty() {
return NpmPrefix::Unavailable;
}
NpmPrefix::Found(std::path::PathBuf::from(prefix))
}
/// Check write access to a file-system path using the POSIX `access(2)` syscall.
#[cfg(unix)]
fn unix_is_writable(path: &std::path::Path) -> bool {
use std::os::unix::ffi::OsStrExt;
let bytes = path.as_os_str().as_bytes();
let Ok(c_path) = std::ffi::CString::new(bytes) else {
return false;
};
// SAFETY: `access` is a pure read-only syscall; we pass a valid NUL-terminated
// path and a standard flag constant. This mirrors the existing `setsid`/`kill`
// usage in this file.
unsafe { libc::access(c_path.as_ptr(), libc::W_OK) == 0 }
}
/// Returns true when the directory where npm would write global packages is
/// writable by the current process user.
///
/// On non-unix platforms always returns `true` — the EACCES preflight is a
/// no-op there; the stderr classifier still applies.
fn npm_install_target_is_writable(prefix: &std::path::Path) -> bool {
#[cfg(unix)]
{
// Probe the most specific candidate that exists; fall back up the tree.
for candidate in &[
prefix.join("lib/node_modules"),
prefix.join("lib"),
prefix.to_path_buf(),
] {
if candidate.exists() {
return unix_is_writable(candidate);
}
}
// Nothing exists — npm couldn't create it either.
unix_is_writable(prefix)
}
#[cfg(not(unix))]
{
let _ = prefix;
true
}
}
/// Inspect `stderr` for known npm EACCES patterns and return actionable
/// guidance if matched, or `None` when the error is unrelated.
fn npm_eacces_hint(stderr: &str, command: &str) -> Option<String> {
if stderr.contains("EACCES: permission denied") || stderr.contains("npm error EACCES") {
Some(npm_eacces_guidance(command))
} else {
None
}
}
/// Run the npm preflight before executing an npm global install command.
/// Returns `Some(failed InstallStepResult)` to abort, or `None` to proceed.
fn npm_preflight_check(step: &str, command: &str) -> Option<InstallStepResult> {
#[cfg(unix)]
{
match resolve_npm_prefix() {
NpmPrefix::Unavailable => Some(InstallStepResult {
step: step.to_string(),
command: command.to_string(),
success: false,
stdout: String::new(),
stderr: String::new(),
exit_code: None,
hint: Some(NPM_MISSING_HINT.to_string()),
}),
NpmPrefix::Found(prefix) if !npm_install_target_is_writable(&prefix) => {
Some(InstallStepResult {
step: step.to_string(),
command: command.to_string(),
success: false,
stdout: String::new(),
stderr: format!(
"npm global prefix '{}' is not writable by the current user.",
prefix.display()
),
exit_code: None,
hint: Some(npm_eacces_guidance(command)),
})
}
// `Found` + writable, or `TimedOut` — proceed; let the install run and
// the stderr classifier serve as the backstop.
_ => None,
}
}
#[cfg(not(unix))]
{
let _ = (step, command);
None
}
}
// ── end npm preflight ─────────────────────────────────────────────────────────
// ── managed Node/npm runtime ──────────────────────────────────────────────────
mod managed_node;
use managed_node::{
ensure_managed_node_runtime_blocking, managed_node_runtime_supported, managed_npm_command,
npm_eacces_hint,
};
#[tauri::command]
pub async fn discover_managed_agent_prereqs(
@@ -1090,6 +912,13 @@ mod tests {
assert!(is_npm_global_install("npm i -g some-package"));
}
#[test]
fn test_is_npm_global_install_accepts_uninstall() {
assert!(is_npm_global_install(
"npm uninstall -g @zed-industries/codex-acp"
));
}
#[test]
fn test_is_npm_global_install_accepts_leading_whitespace() {
assert!(is_npm_global_install(" npm install -g foo"));
@@ -1132,61 +961,6 @@ mod tests {
assert!(npm_eacces_hint(stderr, "npm install -g no-such-pkg").is_none());
}
#[test]
fn test_npm_eacces_hint_guidance_contains_npm_global_path() {
let hint = npm_eacces_hint("EACCES: permission denied", "npm install -g foo").unwrap();
assert!(hint.contains("~/.npm-global"), "hint: {hint}");
}
#[test]
fn test_npm_eacces_hint_guidance_contains_zprofile() {
let hint = npm_eacces_hint("EACCES: permission denied", "npm install -g foo").unwrap();
assert!(hint.contains("~/.zprofile"), "hint: {hint}");
}
#[test]
fn test_npm_eacces_hint_guidance_contains_sudo_command() {
let hint = npm_eacces_hint("EACCES: permission denied", "npm install -g foo").unwrap();
assert!(hint.contains("sudo npm install -g foo"), "hint: {hint}");
}
// ── npm_install_target_is_writable ────────────────────────────────────────
#[cfg(unix)]
#[test]
fn test_npm_install_target_is_writable_true_on_writable_dir() {
let dir = tempfile::tempdir().unwrap();
assert!(npm_install_target_is_writable(dir.path()));
}
#[cfg(unix)]
#[test]
fn test_npm_install_target_is_writable_false_when_lib_node_modules_unwritable() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let lib = dir.path().join("lib");
let node_modules = lib.join("node_modules");
std::fs::create_dir_all(&node_modules).unwrap();
// Make node_modules read-only.
std::fs::set_permissions(&node_modules, std::fs::Permissions::from_mode(0o555)).unwrap();
let result = npm_install_target_is_writable(dir.path());
// Restore before the dir is dropped so cleanup can delete it.
std::fs::set_permissions(&node_modules, std::fs::Permissions::from_mode(0o755)).unwrap();
// Skip this assertion when running as root (root can write to 0o555).
if unsafe { libc::getuid() } != 0 {
assert!(!result);
}
}
#[cfg(unix)]
#[test]
fn test_npm_install_target_is_writable_walks_up_to_lib() {
let dir = tempfile::tempdir().unwrap();
// Create only `lib/` — no `lib/node_modules`.
std::fs::create_dir(dir.path().join("lib")).unwrap();
assert!(npm_install_target_is_writable(dir.path()));
}
// ── adapter_needs_install (codex version gate) ────────────────────────────
/// plan_adapter_install is the pure install-plan seam used by

View File

@@ -0,0 +1,468 @@
use sha2::{Digest, Sha256};
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
use std::{io::Read, io::Write};
use crate::managed_agents::{is_npm_global_install, InstallStepResult};
const MANAGED_NODE_VERSION: &str = "v24.11.0";
const MANAGED_NODE_MAX_BYTES: u64 = 90 * 1024 * 1024;
#[derive(Debug, Clone, Copy)]
struct ManagedNodeArtifact {
platform: &'static str,
filename: &'static str,
sha256: &'static str,
}
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
const MANAGED_NODE_ARTIFACT: Option<ManagedNodeArtifact> = Some(ManagedNodeArtifact {
platform: "darwin-arm64",
filename: "node-v24.11.0-darwin-arm64.tar.gz",
sha256: "0be2ab2816a4fa02d1acff014a434f29f56d8d956f5af6a98b70ced6c5f4d201",
});
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
const MANAGED_NODE_ARTIFACT: Option<ManagedNodeArtifact> = Some(ManagedNodeArtifact {
platform: "darwin-x64",
filename: "node-v24.11.0-darwin-x64.tar.gz",
sha256: "3884671e87f46f773832d98a0a6cabcc5ec4f637084f0f3515b69e66ea27f2f1",
});
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
const MANAGED_NODE_ARTIFACT: Option<ManagedNodeArtifact> = Some(ManagedNodeArtifact {
platform: "linux-x64",
filename: "node-v24.11.0-linux-x64.tar.gz",
sha256: "b3c071cdf47aab867c3b2aa287257df12ec5d7c962bf922b32fd33226c4295fd",
});
#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
const MANAGED_NODE_ARTIFACT: Option<ManagedNodeArtifact> = Some(ManagedNodeArtifact {
platform: "linux-arm64",
filename: "node-v24.11.0-linux-arm64.tar.gz",
sha256: "4786d00c4d259d3ff0b2328307f764ef3ced65f2d6e9502d433e68d66238509d",
});
#[cfg(not(any(
all(target_os = "macos", target_arch = "aarch64"),
all(target_os = "macos", target_arch = "x86_64"),
all(target_os = "linux", target_arch = "x86_64"),
all(target_os = "linux", target_arch = "aarch64")
)))]
const MANAGED_NODE_ARTIFACT: Option<ManagedNodeArtifact> = None;
fn managed_node_unsupported_step() -> InstallStepResult {
InstallStepResult {
step: "node".to_string(),
command: "managed Node.js runtime".to_string(),
success: false,
stdout: String::new(),
stderr: format!(
"Buzz does not provide a managed Node.js runtime for {}-{} yet",
std::env::consts::OS,
std::env::consts::ARCH
),
exit_code: None,
hint: Some(
"Install Node.js from https://nodejs.org, restart Buzz, then click Install again."
.to_string(),
),
}
}
fn managed_node_install_hint() -> String {
"Buzz could not install its private Node.js runtime. Check your network and app-data directory permissions, then click Install again.".to_string()
}
fn managed_node_failed_step(stderr: String) -> InstallStepResult {
InstallStepResult {
step: "node".to_string(),
command: "managed Node.js runtime".to_string(),
success: false,
stdout: String::new(),
stderr,
exit_code: None,
hint: Some(managed_node_install_hint()),
}
}
fn managed_node_runtime_ready() -> bool {
let Some(node) = crate::managed_agents::buzz_managed_node_bin_path() else {
return false;
};
if !node.is_file() {
return false;
}
let output = std::process::Command::new(&node)
.arg("--version")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output();
output
.ok()
.filter(|output| output.status.success())
.map(|output| String::from_utf8_lossy(&output.stdout).trim() == MANAGED_NODE_VERSION)
.unwrap_or(false)
}
fn managed_node_install_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
pub(super) fn managed_node_runtime_supported() -> bool {
MANAGED_NODE_ARTIFACT.is_some() && crate::managed_agents::buzz_managed_node_bin_dir().is_some()
}
pub(super) fn ensure_managed_node_runtime_blocking() -> Result<(), Box<InstallStepResult>> {
if managed_node_runtime_ready() {
return Ok(());
}
let Some(artifact) = MANAGED_NODE_ARTIFACT else {
return Err(Box::new(managed_node_unsupported_step()));
};
let Some(root) = crate::managed_agents::buzz_managed_node_root() else {
return Err(Box::new(managed_node_failed_step(
"failed to resolve Buzz app-data directory for private Node.js runtime".to_string(),
)));
};
let _guard = managed_node_install_lock().lock().map_err(|_| {
Box::new(managed_node_failed_step(
"managed Node.js install lock poisoned".to_string(),
))
})?;
if managed_node_runtime_ready() {
return Ok(());
}
install_managed_node_runtime(&root, artifact)
.map_err(|err| Box::new(managed_node_failed_step(err)))?;
if managed_node_runtime_ready() {
Ok(())
} else {
Err(Box::new(managed_node_failed_step(
"managed Node.js runtime did not pass readiness after install".to_string(),
)))
}
}
fn install_managed_node_runtime(
root: &std::path::Path,
artifact: ManagedNodeArtifact,
) -> Result<(), String> {
let final_dir = root.join(MANAGED_NODE_VERSION).join(artifact.platform);
let temp_dir = root.join(format!(
"{}.{}.tmp",
MANAGED_NODE_VERSION, artifact.platform
));
let archive_path = root.join(format!("{}.download", artifact.filename));
if temp_dir.exists() {
std::fs::remove_dir_all(&temp_dir).map_err(|e| format!("remove stale temp dir: {e}"))?;
}
std::fs::create_dir_all(root).map_err(|e| format!("create runtime root: {e}"))?;
if let Some(parent) = final_dir.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("create runtime version dir: {e}"))?;
}
let url = format!(
"https://nodejs.org/dist/{MANAGED_NODE_VERSION}/{}",
artifact.filename
);
download_managed_node_archive(&url, &archive_path, artifact.sha256)?;
std::fs::create_dir_all(&temp_dir).map_err(|e| format!("create temp dir: {e}"))?;
extract_managed_node_archive(&archive_path, &temp_dir, artifact.filename)?;
let _ = std::fs::remove_file(&archive_path);
let extracted_dir = temp_dir.join(artifact.filename.trim_end_matches(".tar.gz"));
let source_dir = if extracted_dir.is_dir() {
extracted_dir
} else {
temp_dir.clone()
};
verify_node_tree(&source_dir)?;
let old_dir = final_dir.with_extension("old");
if old_dir.exists() {
std::fs::remove_dir_all(&old_dir).map_err(|e| format!("remove stale old dir: {e}"))?;
}
if final_dir.exists() {
std::fs::rename(&final_dir, &old_dir)
.map_err(|e| format!("stage previous runtime: {e}"))?;
}
if let Err(error) = std::fs::rename(&source_dir, &final_dir) {
if old_dir.exists() {
let _ = std::fs::rename(&old_dir, &final_dir);
}
return Err(format!("install runtime: {error}"));
}
let _ = std::fs::remove_dir_all(&old_dir);
let _ = std::fs::remove_dir_all(&temp_dir);
Ok(())
}
fn download_managed_node_archive(
url: &str,
dest: &std::path::Path,
expected_sha256: &str,
) -> Result<(), String> {
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(5 * 60))
.build()
.map_err(|e| format!("build Node.js download client: {e}"))?;
let response = client
.get(url)
.send()
.map_err(|e| format!("download Node.js request failed: {e}"))?;
if !response.status().is_success() {
return Err(format!(
"download Node.js HTTP {}: {}",
response.status().as_u16(),
response.status().canonical_reason().unwrap_or("unknown")
));
}
if let Some(total) = response.content_length() {
if total > MANAGED_NODE_MAX_BYTES {
return Err(format!(
"download Node.js too large: {total} bytes (max {MANAGED_NODE_MAX_BYTES})"
));
}
}
let mut response = response;
let mut file =
std::fs::File::create(dest).map_err(|e| format!("create Node.js archive: {e}"))?;
let mut hasher = Sha256::new();
let mut downloaded = 0_u64;
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = response
.read(&mut buffer)
.map_err(|e| format!("download Node.js stream error: {e}"))?;
if read == 0 {
break;
}
downloaded += read as u64;
if downloaded > MANAGED_NODE_MAX_BYTES {
let _ = std::fs::remove_file(dest);
return Err(format!(
"download Node.js exceeded max size: {downloaded} bytes (max {MANAGED_NODE_MAX_BYTES})"
));
}
file.write_all(&buffer[..read])
.map_err(|e| format!("write Node.js archive: {e}"))?;
hasher.update(&buffer[..read]);
}
file.flush()
.map_err(|e| format!("flush Node.js archive: {e}"))?;
let actual = hex::encode(hasher.finalize());
if actual != expected_sha256 {
let _ = std::fs::remove_file(dest);
return Err(format!(
"download Node.js hash mismatch: expected {expected_sha256}, got {actual}"
));
}
Ok(())
}
fn extract_managed_node_archive(
archive_path: &std::path::Path,
dest_dir: &std::path::Path,
filename: &str,
) -> Result<(), String> {
if !filename.ends_with(".tar.gz") {
return Err(format!("unsupported managed Node.js archive: {filename}"));
}
let file =
std::fs::File::open(archive_path).map_err(|e| format!("open Node.js archive: {e}"))?;
let decoder = flate2::read::GzDecoder::new(file);
let mut archive = tar::Archive::new(decoder);
validate_managed_node_archive_entries(&mut archive)?;
let file = std::fs::File::open(archive_path)
.map_err(|e| format!("open Node.js archive for extraction: {e}"))?;
let decoder = flate2::read::GzDecoder::new(file);
let mut archive = tar::Archive::new(decoder);
archive
.unpack(dest_dir)
.map_err(|e| format!("extract Node.js archive: {e}"))
}
fn validate_managed_node_archive_entries<R: std::io::Read>(
archive: &mut tar::Archive<R>,
) -> Result<(), String> {
let entries = archive
.entries()
.map_err(|e| format!("read Node.js archive entries: {e}"))?;
for entry in entries {
let entry = entry.map_err(|e| format!("Node.js archive entry: {e}"))?;
let path = entry
.path()
.map_err(|e| format!("Node.js archive entry path: {e}"))?;
let path_str = path.to_string_lossy();
if path.is_absolute() {
return Err(format!(
"Node.js archive contains absolute path: {path_str}"
));
}
if path
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
return Err(format!(
"Node.js archive contains path traversal: {path_str}"
));
}
}
Ok(())
}
fn verify_node_tree(dir: &std::path::Path) -> Result<(), String> {
let node = dir.join("bin").join("node");
let npm = dir.join("bin").join("npm");
if !node.is_file() {
return Err("Node.js archive missing bin/node".to_string());
}
if !npm.is_file() {
return Err("Node.js archive missing bin/npm".to_string());
}
Ok(())
}
// ── managed npm adapter installs ──────────────────────────────────────────────
/// Guidance text shown when the Buzz-private npm prefix is not available.
fn managed_npm_prefix_hint() -> String {
"Buzz could not create its private Node tools directory. Check app-data directory permissions, restart Buzz, then click Install again.".to_string()
}
pub(super) fn managed_npm_command(command: &str) -> Result<Option<String>, Box<InstallStepResult>> {
if !is_npm_global_install(command) {
return Ok(None);
}
let Some(prefix) = crate::managed_agents::buzz_managed_npm_prefix() else {
return Err(Box::new(InstallStepResult {
step: "adapter".to_string(),
command: command.to_string(),
success: false,
stdout: String::new(),
stderr: "failed to resolve Buzz app-data directory for private npm prefix".to_string(),
exit_code: None,
hint: Some(managed_npm_prefix_hint()),
}));
};
if let Err(error) = std::fs::create_dir_all(&prefix) {
return Err(Box::new(InstallStepResult {
step: "adapter".to_string(),
command: command.to_string(),
success: false,
stdout: String::new(),
stderr: format!(
"failed to create Buzz private npm prefix '{}': {error}",
prefix.display()
),
exit_code: None,
hint: Some(managed_npm_prefix_hint()),
}));
}
let prefix_arg = shell_quote(&prefix);
Ok(Some(rewrite_npm_global_install(command, &prefix_arg)))
}
fn rewrite_npm_global_install(command: &str, quoted_prefix: &str) -> String {
let trimmed = command.trim_start();
if let Some(rest) = trimmed.strip_prefix("npm install -g ") {
format!("npm install --global --prefix {quoted_prefix} {rest}")
} else if let Some(rest) = trimmed.strip_prefix("npm i -g ") {
format!("npm i --global --prefix {quoted_prefix} {rest}")
} else if let Some(rest) = trimmed.strip_prefix("npm uninstall -g ") {
format!("npm uninstall --global --prefix {quoted_prefix} {rest}")
} else {
trimmed.to_string()
}
}
fn shell_quote(path: &std::path::Path) -> String {
let value = path.to_string_lossy();
format!("'{}'", value.replace('\'', "'\\''"))
}
/// Inspect `stderr` for known npm EACCES patterns and return actionable
/// guidance if matched, or `None` when the error is unrelated.
pub(super) fn npm_eacces_hint(stderr: &str, _command: &str) -> Option<String> {
if stderr.contains("EACCES: permission denied") || stderr.contains("npm error EACCES") {
Some(
"npm could not write to Buzz's private Node tools directory. Check app-data directory permissions, restart Buzz, then click Install again."
.to_string(),
)
} else {
None
}
}
// ── end managed npm adapter installs ──────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_npm_eacces_hint_guidance_mentions_buzz_private_dir() {
let hint = npm_eacces_hint("EACCES: permission denied", "npm install -g foo").unwrap();
assert!(
hint.contains("Buzz's private Node tools directory"),
"hint: {hint}"
);
}
#[test]
fn test_rewrite_npm_install_uses_private_prefix() {
assert_eq!(
rewrite_npm_global_install(
"npm install -g @agentclientprotocol/codex-acp",
"'/tmp/Buzz Node'"
),
"npm install --global --prefix '/tmp/Buzz Node' @agentclientprotocol/codex-acp"
);
}
#[test]
fn test_rewrite_npm_i_uses_private_prefix() {
assert_eq!(
rewrite_npm_global_install("npm i -g some-package", "'/tmp/buzz'"),
"npm i --global --prefix '/tmp/buzz' some-package"
);
}
#[test]
fn test_rewrite_npm_uninstall_uses_private_prefix() {
assert_eq!(
rewrite_npm_global_install("npm uninstall -g @zed-industries/codex-acp", "'/tmp/buzz'"),
"npm uninstall --global --prefix '/tmp/buzz' @zed-industries/codex-acp"
);
}
#[test]
fn test_rewrite_ignores_non_global_command() {
assert_eq!(
rewrite_npm_global_install("npm install foo", "'/tmp/buzz'"),
"npm install foo"
);
}
#[test]
fn test_shell_quote_escapes_single_quotes() {
assert_eq!(
shell_quote(std::path::Path::new("/tmp/Buzz's Node")),
"'/tmp/Buzz'\\''s Node'"
);
}
}

View File

@@ -1,9 +1,11 @@
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use crate::managed_agents::{
buzz_managed_command_path, buzz_managed_node_bin_dir, buzz_managed_npm_bin_dir,
AcpAvailabilityStatus, AcpRuntimeCatalogEntry, AuthStatus, CommandAvailabilityInfo,
};
@@ -94,7 +96,6 @@ const BUZZ_AGENT_AVATAR_URL: &str =
"https://raw.githubusercontent.com/block/buzz/refs/heads/main/crates/buzz-agent/buzz-agent.png";
fn common_binary_paths() -> &'static [PathBuf] {
use std::sync::OnceLock;
static PATHS: OnceLock<Vec<PathBuf>> = OnceLock::new();
PATHS.get_or_init(|| {
let mut paths = vec![
@@ -103,6 +104,12 @@ fn common_binary_paths() -> &'static [PathBuf] {
PathBuf::from("/usr/bin"),
PathBuf::from("/home/linuxbrew/.linuxbrew/bin"),
];
if let Some(managed_node_bin) = buzz_managed_node_bin_dir() {
paths.insert(0, managed_node_bin);
}
if let Some(managed_bin) = buzz_managed_npm_bin_dir() {
paths.insert(0, managed_bin);
}
if let Some(home) = dirs::home_dir() {
paths.extend([
home.join(".local/share/mise/shims"),
@@ -514,6 +521,10 @@ fn resolve_cache() -> &'static std::sync::Mutex<std::collections::HashMap<String
/// The cache eliminates redundant login-shell spawns when multiple agents share
/// the same binaries (e.g. `npx`, `uvx`).
pub fn resolve_command(command: &str) -> Option<PathBuf> {
if let Some(managed) = resolve_buzz_managed_command(command) {
return Some(managed);
}
let cache = resolve_cache();
// Fast path: return cached result without allocating a key.
@@ -628,16 +639,29 @@ fn command_basenames(command: &str) -> Vec<String> {
candidates
}
fn resolve_buzz_managed_command(command: &str) -> Option<PathBuf> {
let basenames = command_basenames(command);
basenames
.iter()
.find_map(|basename| buzz_managed_command_path(command, basename))
}
fn resolve_command_uncached(command: &str) -> Option<PathBuf> {
if let Some(path) = resolve_workspace_command(command) {
return Some(path);
}
let basenames = command_basenames(command);
if command_looks_like_path(command) {
let path = PathBuf::from(command);
return path.exists().then_some(path);
}
if let Some(managed) = resolve_buzz_managed_command(command) {
return Some(managed);
}
for candidate in path_candidates_from_env(command) {
if is_executable_file(&candidate) {
return Some(candidate);
@@ -659,7 +683,6 @@ fn resolve_command_uncached(command: &str) -> Option<PathBuf> {
if let Some(path) = find_via_login_shell(command) {
return Some(path);
}
let basenames = command_basenames(command);
for dir in common_binary_paths() {
for basename in &basenames {
let candidate = dir.join(basename);
@@ -937,13 +960,16 @@ fn runtime_needs_npm(runtime: &KnownAcpRuntime) -> bool {
.any(|cmd| is_npm_global_install(cmd))
}
/// Returns `true` when `cmd` is an `npm install -g` invocation.
/// Returns `true` when `cmd` is an npm global install/uninstall invocation.
///
/// Used by Doctor to determine whether Node.js is required before running an
/// install step, and by the npm EACCES preflight in the install command path.
/// Buzz rewrites these catalog commands to an app-private npm prefix before
/// execution; the global shape remains in the catalog so existing install plans
/// and Doctor's Node.js-required detection stay simple.
pub(crate) fn is_npm_global_install(cmd: &str) -> bool {
let t = cmd.trim_start();
t.starts_with("npm install -g ") || t.starts_with("npm i -g ")
t.starts_with("npm install -g ")
|| t.starts_with("npm i -g ")
|| t.starts_with("npm uninstall -g ")
}
/// Run a CLI auth probe with a 10-second process-level timeout.
@@ -1270,11 +1296,14 @@ pub fn discover_acp_runtimes() -> Vec<AcpRuntimeCatalogEntry> {
}
};
// node_required: an npm adapter step is pending AND node/npm are absent.
// node_required now means Buzz cannot provide npm for this platform.
// On supported desktop platforms, Buzz downloads a private Node/npm
// runtime into app data before running npm-backed adapter installs.
let node_required = matches!(
availability,
AcpAvailabilityStatus::AdapterMissing | AcpAvailabilityStatus::NotInstalled
) && runtime_needs_npm(runtime)
&& buzz_managed_node_bin_dir().is_none()
&& resolve_command("npm").is_none()
&& resolve_command("node").is_none();

View File

@@ -609,6 +609,8 @@ fn apply_agent_command_update_concrete_pin_keeps_materialized_runtime() {
// ── probe_codex_acp_major_version ─────────────────────────────────────────────
mod managed_path_resolution;
#[cfg(unix)]
#[test]
fn probe_codex_acp_major_version_parses_1x_output() {

View File

@@ -0,0 +1,64 @@
use crate::managed_agents::discovery::{clear_resolve_cache, resolve_command};
#[cfg(unix)]
#[test]
fn resolve_command_prefers_buzz_managed_npm_shim_over_path() {
use std::os::unix::fs::PermissionsExt;
let _guard = crate::managed_agents::lock_path_mutex();
let temp = tempfile::tempdir().expect("tempdir");
let home = temp.path().join("home");
let xdg_data = temp.path().join("xdg-data");
let global_bin = temp.path().join("global-bin");
std::fs::create_dir_all(&home).expect("create home");
std::fs::create_dir_all(&xdg_data).expect("create xdg data");
std::fs::create_dir_all(&global_bin).expect("create global bin");
let old_home = std::env::var_os("HOME");
let old_xdg_data = std::env::var_os("XDG_DATA_HOME");
let old_path = std::env::var_os("PATH").unwrap_or_default();
std::env::set_var("HOME", &home);
std::env::set_var("XDG_DATA_HOME", &xdg_data);
let managed_bin = dirs::data_dir()
.expect("data dir")
.join("Buzz")
.join("node-tools")
.join("bin");
std::fs::create_dir_all(&managed_bin).expect("create managed bin");
let managed_shim = managed_bin.join("codex-acp");
let global_shim = global_bin.join("codex-acp");
std::fs::write(&managed_shim, "#!/bin/sh\necho managed\n").expect("write managed shim");
std::fs::write(&global_shim, "#!/bin/sh\necho global\n").expect("write global shim");
std::fs::set_permissions(&managed_shim, std::fs::Permissions::from_mode(0o755))
.expect("chmod managed shim");
std::fs::set_permissions(&global_shim, std::fs::Permissions::from_mode(0o755))
.expect("chmod global shim");
let new_path = std::env::join_paths(
std::iter::once(global_bin.clone()).chain(std::env::split_paths(&old_path)),
)
.expect("join PATH");
std::env::set_var("PATH", new_path);
clear_resolve_cache();
let resolved = resolve_command("codex-acp");
std::env::set_var("PATH", &old_path);
match old_home {
Some(value) => std::env::set_var("HOME", value),
None => std::env::remove_var("HOME"),
}
match old_xdg_data {
Some(value) => std::env::set_var("XDG_DATA_HOME", value),
None => std::env::remove_var("XDG_DATA_HOME"),
}
clear_resolve_cache();
assert_eq!(
resolved.as_deref(),
Some(managed_shim.as_path()),
"Buzz-managed npm shim must win over PATH/global shims"
);
}

View File

@@ -0,0 +1,86 @@
use std::path::PathBuf;
pub(crate) fn buzz_managed_npm_prefix() -> Option<PathBuf> {
dirs::data_dir().map(|dir| dir.join("Buzz").join("node-tools"))
}
const BUZZ_MANAGED_NODE_VERSION: &str = "v24.11.0";
pub(crate) fn buzz_managed_node_root() -> Option<PathBuf> {
dirs::data_dir().map(|dir| dir.join("Buzz").join("runtimes").join("node"))
}
pub(crate) fn buzz_managed_node_bin_dir() -> Option<PathBuf> {
let platform = match (std::env::consts::OS, std::env::consts::ARCH) {
("macos", "aarch64") => "darwin-arm64",
("macos", "x86_64") => "darwin-x64",
("linux", "x86_64") => "linux-x64",
("linux", "aarch64") => "linux-arm64",
_ => return None,
};
buzz_managed_node_root().map(|root| {
root.join(BUZZ_MANAGED_NODE_VERSION)
.join(platform)
.join("bin")
})
}
pub(crate) fn buzz_managed_node_bin_path() -> Option<PathBuf> {
buzz_managed_node_bin_dir().map(|bin| bin.join("node"))
}
pub(crate) fn buzz_managed_npm_bin_dir() -> Option<PathBuf> {
buzz_managed_npm_prefix().map(|prefix| {
#[cfg(windows)]
{
prefix
}
#[cfg(not(windows))]
{
prefix.join("bin")
}
})
}
pub(crate) fn buzz_managed_command_path(command: &str, basename: &str) -> Option<PathBuf> {
if command.contains(std::path::MAIN_SEPARATOR)
|| !matches!(
command,
"codex-acp" | "claude-agent-acp" | "claude-code-acp" | "node" | "npm"
)
{
return None;
}
let mut dirs = Vec::new();
if let Some(managed_bin) = buzz_managed_npm_bin_dir() {
dirs.push(managed_bin);
}
if let Some(managed_node_bin) = buzz_managed_node_bin_dir() {
dirs.push(managed_node_bin);
}
dirs.into_iter()
.map(|dir| dir.join(basename))
.find(|candidate| is_executable_file(candidate))
}
fn is_executable_file(path: &std::path::Path) -> bool {
let Ok(metadata) = path.metadata() else {
return false;
};
if !metadata.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
{
true
}
}

View File

@@ -11,6 +11,7 @@ mod discovery;
mod env_vars;
pub(crate) mod git_bash;
pub(crate) mod global_config;
mod managed_node_paths;
mod nest;
mod persona_avatars;
pub(crate) mod persona_events;
@@ -50,6 +51,7 @@ pub(crate) use global_config::{
load_global_agent_config, resolve_effective_model_provider, save_global_agent_config,
validate_global_config, GlobalAgentConfig,
};
pub(crate) use managed_node_paths::*;
pub use nest::*;
pub use personas::*;
#[cfg(windows)]

View File

@@ -1105,6 +1105,12 @@ mod tests {
(dir, original_path)
}
#[cfg(unix)]
fn leaked_adapter_commands(bin: &std::path::Path) -> &'static [&'static str] {
let command = Box::leak(bin.display().to_string().into_boxed_str());
Box::leak(vec![command as &'static str].into_boxed_slice())
}
/// Restore PATH and clear the resolve cache after a PATH-mutating test.
#[cfg(unix)]
fn restore_path(original: &str) {
@@ -1121,8 +1127,13 @@ mod tests {
let (dir, orig) = setup_temp_codex_acp("#!/bin/sh\nexit 1\n");
let exe = present_binary_str();
// underlying_cli = running test binary (always present, never probed)
let rt = make_codex_runtime(&["codex-acp"], Some(exe));
// Use the fixture's absolute adapter path here. Bare `codex-acp`
// intentionally prefers Buzz's managed npm shim when it exists, which
// would make this version-gate regression test depend on machine state.
let rt = make_codex_runtime(
leaked_adapter_commands(&dir.path().join("codex-acp")),
Some(exe),
);
let reqs = cli_login::requirements(
&[exe, "--buzz-probe-must-not-run-xyz"],
"run `codex login`",
@@ -1159,7 +1170,10 @@ mod tests {
let (dir, orig) = setup_temp_codex_acp("#!/bin/sh\necho 'not a version string'\nexit 0\n");
let exe = present_binary_str();
let rt = make_codex_runtime(&["codex-acp"], Some(exe));
let rt = make_codex_runtime(
leaked_adapter_commands(&dir.path().join("codex-acp")),
Some(exe),
);
let reqs = cli_login::requirements(
&[exe, "--buzz-probe-must-not-run-xyz"],
"run `codex login`",

View File

@@ -25,11 +25,9 @@ pub(super) fn requirements(
.map(|cli| find_command(cli).is_some())
.unwrap_or(false);
let (availability, cmd, adapter_path) =
let (availability, _cmd, adapter_path) =
classify_runtime(adapter_result, runtime.underlying_cli, underlying_cli_found);
let availability = if runtime.id == "codex"
&& availability == AcpAvailabilityStatus::Available
&& cmd.as_deref() == Some("codex-acp")
let availability = if runtime.id == "codex" && availability == AcpAvailabilityStatus::Available
{
adapter_path
.as_deref()

View File

@@ -6,9 +6,11 @@ use std::path::PathBuf;
///
/// Concatenates, in priority order:
/// 1. `<home>/.local/bin` — bundled CLI symlink
/// 2. `nvm_bin` — nvm's default Node.js bin dir (if the user uses nvm)
/// 3. exe parent dir — DMG sidecars under `Contents/MacOS/`
/// 4. user's login-shell `PATH` — runtimes like node/python from other managers
/// 2. Buzz-managed npm prefix bin dir — app-private ACP adapter shims
/// 3. Buzz-managed Node.js bin dir — app-private Node/npm runtime
/// 4. `nvm_bin` — nvm's default Node.js bin dir (if the user uses nvm)
/// 5. exe parent dir — DMG sidecars under `Contents/MacOS/`
/// 6. user's login-shell `PATH` — runtimes like node/python from other managers
///
/// `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
@@ -23,9 +25,21 @@ pub(in crate::managed_agents) fn build_augmented_path(
nvm_bin: Option<PathBuf>,
) -> Option<String> {
let mut parts: Vec<PathBuf> = Vec::new();
let home_added = home.is_some();
if let Some(home) = home {
parts.push(home.join(".local").join("bin"));
}
// Only add managed runtime dirs when a home or executable context exists.
// This keeps tests/utility callers that intentionally pass no local context
// from manufacturing a PATH out of ambient platform dirs alone.
if home_added || exe_parent.is_some() {
if let Some(managed_npm_bin) = crate::managed_agents::buzz_managed_npm_bin_dir() {
parts.push(managed_npm_bin);
}
if let Some(managed_node_bin) = crate::managed_agents::buzz_managed_node_bin_dir() {
parts.push(managed_node_bin);
}
}
if let Some(nvm_bin) = nvm_bin {
parts.push(nvm_bin);
}
@@ -62,12 +76,15 @@ mod tests {
Some("/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin".to_string()),
None,
);
assert_eq!(
result.as_deref(),
Some(
"/home/agent/.local/bin:/Applications/Buzz.app/Contents/MacOS:\
/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"
),
let result = result.expect("path");
assert!(result.starts_with("/home/agent/.local/bin:"), "{result}");
assert!(
result.contains(":/Applications/Buzz.app/Contents/MacOS:"),
"{result}"
);
assert!(
result.ends_with(":/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"),
"{result}"
);
}
@@ -92,15 +109,16 @@ mod tests {
Some("/usr/bin:/bin".to_string()),
Some(PathBuf::from("/home/user/.nvm/versions/node/v20.0.0/bin")),
);
assert_eq!(
result.as_deref(),
Some(
"/home/user/.local/bin:\
/home/user/.nvm/versions/node/v20.0.0/bin:\
/Applications/Buzz.app/Contents/MacOS:\
/usr/bin:/bin"
),
);
let result = result.expect("path");
let local = result.find("/home/user/.local/bin").unwrap();
let nvm = result
.find("/home/user/.nvm/versions/node/v20.0.0/bin")
.unwrap();
let exe = result
.find("/Applications/Buzz.app/Contents/MacOS")
.unwrap();
assert!(local < nvm && nvm < exe, "{result}");
assert!(result.ends_with(":/usr/bin:/bin"), "{result}");
}
#[cfg(unix)]
@@ -112,9 +130,8 @@ mod tests {
None,
None,
);
assert_eq!(
result.as_deref(),
Some("/home/user/.local/bin:/usr/local/bin"),
);
let result = result.expect("path");
assert!(result.starts_with("/home/user/.local/bin:"), "{result}");
assert!(result.ends_with(":/usr/local/bin"), "{result}");
}
}

View File

@@ -597,7 +597,7 @@ pub struct InstallStepResult {
pub stderr: String,
pub exit_code: Option<i32>,
/// Actionable guidance shown in the UI when this step failed due to a
/// recognized condition (e.g. EACCES on a root-owned npm global prefix).
/// recognized condition (e.g. EACCES writing Buzz's private npm prefix).
/// `None` when the step succeeded or no pattern matched.
#[serde(skip_serializing_if = "Option::is_none")]
pub hint: Option<String>,