mirror of
https://github.com/block/buzz.git
synced 2026-11-02 07:22:55 +00:00
Merge remote-tracking branch 'origin/main' into micn/bundled-goose-acp
* origin/main: Add Buzz-managed Node runtime for agent installs (#1930) feat(desktop): return nostr binding proof in browser fragment (#1933) Use selected relay for desktop pairing socket (#1934)
This commit is contained in:
@@ -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
|
||||
|
||||
2
desktop/src-tauri/Cargo.lock
generated
2
desktop/src-tauri/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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
|
||||
|
||||
468
desktop/src-tauri/src/commands/agent_discovery/managed_node.rs
Normal file
468
desktop/src-tauri/src/commands/agent_discovery/managed_node.rs
Normal 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'"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -92,19 +92,9 @@ pub async fn start_pairing(
|
||||
// own NIP-11 declaration of NIP-43 support rather than `auth_required`,
|
||||
// which is also true for plain NIP-42 / NIP-OA relays where the main
|
||||
// relay is reachable.
|
||||
let qr_relay_url = match probe_pairing_relay(&ws_url).await {
|
||||
PairingRelay::Configured(url) => url,
|
||||
PairingRelay::LegacyPath => {
|
||||
let mut url =
|
||||
url::Url::parse(&ws_url).map_err(|e| format!("invalid relay URL: {e}"))?;
|
||||
let path = url.path().trim_end_matches('/').to_string();
|
||||
url.set_path(&format!("{path}/pair"));
|
||||
url.to_string()
|
||||
}
|
||||
PairingRelay::MainRelay => ws_url.clone(),
|
||||
};
|
||||
let pairing_relay_url = resolve_pairing_relay_url(&ws_url, probe_pairing_relay(&ws_url).await)?;
|
||||
|
||||
let (session, qr_payload) = PairingSession::new_source(qr_relay_url);
|
||||
let (session, qr_payload) = PairingSession::new_source(pairing_relay_url.clone());
|
||||
let qr_uri = encode_qr(&qr_payload);
|
||||
|
||||
let payload_json = serde_json::json!({
|
||||
@@ -128,7 +118,7 @@ pub async fn start_pairing(
|
||||
|
||||
let session_arc = Arc::clone(&pairing.session);
|
||||
tauri::async_runtime::spawn(pairing_ws_task(
|
||||
ws_url,
|
||||
pairing_relay_url,
|
||||
session_arc,
|
||||
cancel,
|
||||
outbound_rx,
|
||||
@@ -459,6 +449,23 @@ async fn probe_pairing_relay(relay_url: &str) -> PairingRelay {
|
||||
pairing_relay_from_nip11(&json)
|
||||
}
|
||||
|
||||
fn resolve_pairing_relay_url(
|
||||
main_relay_url: &str,
|
||||
pairing_relay: PairingRelay,
|
||||
) -> Result<String, String> {
|
||||
match pairing_relay {
|
||||
PairingRelay::Configured(url) => Ok(url),
|
||||
PairingRelay::LegacyPath => {
|
||||
let mut url =
|
||||
url::Url::parse(main_relay_url).map_err(|e| format!("invalid relay URL: {e}"))?;
|
||||
let path = url.path().trim_end_matches('/').to_string();
|
||||
url.set_path(&format!("{path}/pair"));
|
||||
Ok(url.to_string())
|
||||
}
|
||||
PairingRelay::MainRelay => Ok(main_relay_url.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn pairing_relay_from_nip11(json: &serde_json::Value) -> PairingRelay {
|
||||
if let Some(value) = json
|
||||
.get("pairing_relay_url")
|
||||
@@ -522,7 +529,9 @@ where
|
||||
|
||||
#[cfg(test)]
|
||||
mod pairing_relay_tests {
|
||||
use super::{pairing_relay_from_nip11, probe_pairing_relay, PairingRelay};
|
||||
use super::{
|
||||
pairing_relay_from_nip11, probe_pairing_relay, resolve_pairing_relay_url, PairingRelay,
|
||||
};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
#[tokio::test]
|
||||
@@ -591,4 +600,37 @@ mod pairing_relay_tests {
|
||||
|
||||
assert_eq!(pairing_relay_from_nip11(&document), PairingRelay::MainRelay);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_pairing_relay_resolves_to_configured_url() {
|
||||
let resolved = resolve_pairing_relay_url(
|
||||
"wss://flint.communities.buzz.xyz",
|
||||
PairingRelay::Configured("wss://pairing.buzz.xyz".to_string()),
|
||||
)
|
||||
.expect("resolve configured pairing relay");
|
||||
|
||||
assert_eq!(resolved, "wss://pairing.buzz.xyz");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_pairing_relay_appends_pair_path() {
|
||||
let resolved = resolve_pairing_relay_url(
|
||||
"wss://flint.communities.buzz.xyz/community",
|
||||
PairingRelay::LegacyPath,
|
||||
)
|
||||
.expect("resolve legacy pairing relay");
|
||||
|
||||
assert_eq!(resolved, "wss://flint.communities.buzz.xyz/community/pair");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn main_relay_pairing_uses_main_relay_url() {
|
||||
let resolved = resolve_pairing_relay_url(
|
||||
"wss://sprout-oss.stage.blox.sqprod.co",
|
||||
PairingRelay::MainRelay,
|
||||
)
|
||||
.expect("resolve main pairing relay");
|
||||
|
||||
assert_eq!(resolved, "wss://sprout-oss.stage.blox.sqprod.co");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,8 +155,13 @@ fn parse_nostr_bind_deep_link(url: &Url) -> Result<NostrBindDeepLinkPayload, Str
|
||||
// Expired links still reach the consent surface so the user gets an explicit
|
||||
// failure instead of a silent stderr-only rejection from a launched app.
|
||||
nostr_bind::validate_expires_at_format(&expires_at)?;
|
||||
if return_mode != nostr_bind::RETURN_MODE {
|
||||
return Err("unsupported return mode".into());
|
||||
match return_mode.as_str() {
|
||||
nostr_bind::RETURN_MODE_CLIPBOARD => {}
|
||||
nostr_bind::RETURN_MODE_BROWSER_FRAGMENT_V1 if callback_url.is_some() => {}
|
||||
nostr_bind::RETURN_MODE_BROWSER_FRAGMENT_V1 => {
|
||||
return Err("browser_fragment_v1 requires callback_url".into());
|
||||
}
|
||||
_ => return Err("unsupported return mode".into()),
|
||||
}
|
||||
if let Some(callback_url) = callback_url.as_deref() {
|
||||
validate_nostr_bind_callback_url(callback_url, &origin)?;
|
||||
@@ -389,6 +394,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_nostr_bind_deep_link_accepts_browser_fragment_return() {
|
||||
let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=browser_fragment_v1&callback_url=https%3A%2F%2Fexample.com%2Fbuzz").unwrap();
|
||||
let payload = parse_nostr_bind_deep_link(&url).unwrap();
|
||||
|
||||
assert_eq!(payload.return_mode, "browser_fragment_v1");
|
||||
assert_eq!(
|
||||
payload.callback_url.as_deref(),
|
||||
Some("https://example.com/buzz")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_nostr_bind_deep_link_requires_callback_for_browser_fragment_return() {
|
||||
let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=browser_fragment_v1").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parse_nostr_bind_deep_link(&url).unwrap_err(),
|
||||
"browser_fragment_v1 requires callback_url"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_nostr_bind_deep_link_rejects_cross_origin_callback_url() {
|
||||
let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=https%3A%2F%2Fevil.example%2Fbuzz").unwrap();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
86
desktop/src-tauri/src/managed_agents/managed_node_paths.rs
Normal file
86
desktop/src-tauri/src/managed_agents/managed_node_paths.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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`",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -6,7 +6,8 @@ pub(crate) const ACTION: &str = "bind_nostr_identity";
|
||||
pub(crate) const CONTENT: &str = "";
|
||||
pub(crate) const KIND: u16 = buzz_core_pkg::kind::KIND_NOSTR_IDENTITY_BINDING as u16;
|
||||
pub(crate) const PROTOCOL: &str = "buzz-nostr-identity";
|
||||
pub(crate) const RETURN_MODE: &str = "clipboard";
|
||||
pub(crate) const RETURN_MODE_CLIPBOARD: &str = "clipboard";
|
||||
pub(crate) const RETURN_MODE_BROWSER_FRAGMENT_V1: &str = "browser_fragment_v1";
|
||||
pub(crate) const VERSION: &str = "1";
|
||||
|
||||
const NONCE_CHARS: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-";
|
||||
|
||||
48
desktop/src/features/profile/lib/nostrBindCallback.test.mjs
Normal file
48
desktop/src/features/profile/lib/nostrBindCallback.test.mjs
Normal file
@@ -0,0 +1,48 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { buildNostrBindCallbackUrl } from "./nostrBindCallback.ts";
|
||||
|
||||
function decodePayload(callbackUrl) {
|
||||
const encoded = new URL(callbackUrl).hash.split("v1.")[1];
|
||||
const padded = encoded
|
||||
.replaceAll("-", "+")
|
||||
.replaceAll("_", "/")
|
||||
.padEnd(Math.ceil(encoded.length / 4) * 4, "=");
|
||||
return new TextDecoder().decode(
|
||||
Uint8Array.from(atob(padded), (character) => character.charCodeAt(0)),
|
||||
);
|
||||
}
|
||||
|
||||
test("buildNostrBindCallbackUrl returns a UTF-8 base64url payload in the fragment", () => {
|
||||
const response = JSON.stringify({ content: "Buzz ⚡", sig: "+/=" });
|
||||
const result = buildNostrBindCallbackUrl(
|
||||
"https://example.com/buzz?source=bind",
|
||||
response,
|
||||
);
|
||||
const url = new URL(result);
|
||||
|
||||
assert.equal(url.origin, "https://example.com");
|
||||
assert.equal(url.pathname, "/buzz");
|
||||
assert.equal(url.search, "?source=bind");
|
||||
assert.match(url.hash, /^#buzz_bind=v1\.[A-Za-z0-9_-]+$/);
|
||||
assert.equal(decodePayload(result), response);
|
||||
assert.equal(url.searchParams.has("buzz_bind"), false);
|
||||
});
|
||||
|
||||
test("buildNostrBindCallbackUrl replaces an existing fragment", () => {
|
||||
const result = buildNostrBindCallbackUrl(
|
||||
"https://example.com/buzz#stale-fragment",
|
||||
"signed",
|
||||
);
|
||||
|
||||
assert.equal(new URL(result).hash, "#buzz_bind=v1.c2lnbmVk");
|
||||
});
|
||||
|
||||
test("buildNostrBindCallbackUrl rejects callback URLs beyond the opener ceiling", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
buildNostrBindCallbackUrl("https://example.com/buzz", "x".repeat(4_096)),
|
||||
/too large/,
|
||||
);
|
||||
});
|
||||
28
desktop/src/features/profile/lib/nostrBindCallback.ts
Normal file
28
desktop/src/features/profile/lib/nostrBindCallback.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
const MAX_CALLBACK_URL_LENGTH = 4_096;
|
||||
const CALLBACK_FRAGMENT_KEY = "buzz_bind";
|
||||
const CALLBACK_PAYLOAD_VERSION = "v1";
|
||||
|
||||
function encodeBase64Url(value: string): string {
|
||||
const bytes = new TextEncoder().encode(value);
|
||||
let binary = "";
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return btoa(binary)
|
||||
.replaceAll("+", "-")
|
||||
.replaceAll("/", "_")
|
||||
.replace(/=+$/, "");
|
||||
}
|
||||
|
||||
export function buildNostrBindCallbackUrl(
|
||||
callbackUrl: string,
|
||||
signedResponse: string,
|
||||
): string {
|
||||
const url = new URL(callbackUrl);
|
||||
url.hash = `${CALLBACK_FRAGMENT_KEY}=${CALLBACK_PAYLOAD_VERSION}.${encodeBase64Url(signedResponse)}`;
|
||||
const result = url.toString();
|
||||
if (result.length > MAX_CALLBACK_URL_LENGTH) {
|
||||
throw new Error("Signed response is too large to return to the browser.");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import type { Identity } from "@/shared/api/types";
|
||||
import type { NostrBindDeepLinkPayload } from "@/shared/deep-link";
|
||||
import { listenForNostrBindDeepLinks } from "@/shared/deep-link";
|
||||
import { OnboardingSlideTransition } from "@/features/onboarding/ui/OnboardingSlideTransition";
|
||||
import { buildNostrBindCallbackUrl } from "@/features/profile/lib/nostrBindCallback";
|
||||
import { signNostrIdentityBinding } from "@/features/profile/lib/nostrIdentityBinding";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme";
|
||||
@@ -121,6 +122,19 @@ async function notifySignedResponseReady(callbackUrl: string | undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
async function returnSignedResponseToBrowser(
|
||||
callbackUrl: string,
|
||||
signedResponse: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
await openUrl(buildNostrBindCallbackUrl(callbackUrl, signedResponse));
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.warn("return signed nostr binding response failed:", error);
|
||||
return "Could not open the browser. Copy the response below to finish manually.";
|
||||
}
|
||||
}
|
||||
|
||||
export function NostrBindConsentDialog() {
|
||||
const isPreview = isNostrBindPreviewEnabled();
|
||||
const [payload, setPayload] = React.useState<NostrBindDeepLinkPayload | null>(
|
||||
@@ -448,6 +462,11 @@ export function NostrBindConsentDialog() {
|
||||
expiresAt: payload.expiresAt,
|
||||
});
|
||||
setSignedResponse(signed);
|
||||
if (payload.returnMode === "browser_fragment_v1" && payload.callbackUrl) {
|
||||
setError(
|
||||
await returnSignedResponseToBrowser(payload.callbackUrl, signed),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
setError(formatError(error) || "Failed to sign binding response.");
|
||||
} finally {
|
||||
@@ -473,14 +492,22 @@ export function NostrBindConsentDialog() {
|
||||
setCopyFailed(!copied);
|
||||
if (copied) {
|
||||
showCopiedState();
|
||||
await notifySignedResponseReady(payload?.callbackUrl);
|
||||
if (payload?.returnMode === "clipboard") {
|
||||
await notifySignedResponseReady(payload.callbackUrl);
|
||||
}
|
||||
toast.success(
|
||||
isPreview ? PREVIEW_COPY_SUCCESS_MESSAGE : COPY_SUCCESS_MESSAGE,
|
||||
);
|
||||
} else {
|
||||
toast.warning(COPY_FAILURE_MESSAGE);
|
||||
}
|
||||
}, [isPreview, payload?.callbackUrl, showCopiedState, signedResponse]);
|
||||
}, [
|
||||
isPreview,
|
||||
payload?.callbackUrl,
|
||||
payload?.returnMode,
|
||||
showCopiedState,
|
||||
signedResponse,
|
||||
]);
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Root
|
||||
@@ -512,16 +539,25 @@ export function NostrBindConsentDialog() {
|
||||
transitionKey="nostr-bind-finish"
|
||||
>
|
||||
<DialogPrimitive.Title className="mt-6 text-3xl font-semibold tracking-tight">
|
||||
Finish on the Buzz website
|
||||
{payload.returnMode === "browser_fragment_v1"
|
||||
? "Continue in your browser"
|
||||
: "Finish on the Buzz website"}
|
||||
</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Description
|
||||
className="mt-3 max-w-[440px] text-sm leading-6 text-muted-foreground"
|
||||
id="nostr-bind-description"
|
||||
>
|
||||
Copy the response below, then paste it into the Buzz website
|
||||
to finish verification.
|
||||
{payload.returnMode === "browser_fragment_v1"
|
||||
? "Buzz opened your browser to finish verification. If it did not open, copy the response below."
|
||||
: "Copy the response below, then paste it into the Buzz website to finish verification."}
|
||||
</DialogPrimitive.Description>
|
||||
|
||||
{error ? (
|
||||
<p className="mt-4 w-full rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-left text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<pre
|
||||
className="mt-10 max-h-56 w-full overflow-auto rounded-2xl border border-border/70 bg-muted/60 p-4 text-left shadow-xs"
|
||||
data-testid="nostr-bind-signed-response"
|
||||
|
||||
@@ -38,7 +38,7 @@ export type NostrBindDeepLinkPayload = {
|
||||
version: "1";
|
||||
origin: string;
|
||||
expiresAt: string;
|
||||
returnMode: "clipboard";
|
||||
returnMode: "clipboard" | "browser_fragment_v1";
|
||||
callbackUrl?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -9476,6 +9476,8 @@ export function maybeInstallE2eTauriMocks() {
|
||||
return sendToMockSocket(
|
||||
payload as Parameters<typeof sendToMockSocket>[0],
|
||||
);
|
||||
case "plugin:opener|open_url":
|
||||
return null;
|
||||
case "plugin:window|show":
|
||||
case "plugin:window|unminimize":
|
||||
case "plugin:window|set_focus":
|
||||
|
||||
@@ -4,6 +4,7 @@ import { installMockBridge } from "../helpers/bridge";
|
||||
type NostrBindPayload = {
|
||||
action: string;
|
||||
audience: string;
|
||||
callbackUrl?: string;
|
||||
challengeId: string;
|
||||
expiresAt: string;
|
||||
nonce: string;
|
||||
@@ -297,6 +298,60 @@ test("signs a valid request, shows the response, and copies it", async ({
|
||||
.toBe(signedResponse);
|
||||
});
|
||||
|
||||
test("returns a signed response in the callback fragment after consent", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openNostrBind(page, {
|
||||
...VALID_REQUEST,
|
||||
callbackUrl: "https://admin.example.com/buzz?source=bind#stale",
|
||||
returnMode: "browser_fragment_v1",
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(async () =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(
|
||||
(
|
||||
window as Window & {
|
||||
__BUZZ_E2E_COMMAND_LOG__?: Array<{ command: string }>;
|
||||
}
|
||||
).__BUZZ_E2E_COMMAND_LOG__ ?? []
|
||||
).filter(({ command }) => command === "plugin:opener|open_url")
|
||||
.length,
|
||||
),
|
||||
)
|
||||
.toBe(0);
|
||||
await pasteCode(
|
||||
page.getByTestId("nostr-bind-code-digit-1"),
|
||||
VALID_REQUEST.verificationCode,
|
||||
);
|
||||
await page.getByTestId("nostr-bind-sign-and-copy").click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Continue in your browser" }),
|
||||
).toBeVisible();
|
||||
const callback = await page.evaluate(() => {
|
||||
const command = (
|
||||
(
|
||||
window as Window & {
|
||||
__BUZZ_E2E_COMMAND_LOG__?: Array<{
|
||||
command: string;
|
||||
payload: { url?: string };
|
||||
}>;
|
||||
}
|
||||
).__BUZZ_E2E_COMMAND_LOG__ ?? []
|
||||
).find(({ command }) => command === "plugin:opener|open_url");
|
||||
return command?.payload.url;
|
||||
});
|
||||
const callbackUrl = new URL(callback ?? "");
|
||||
expect(callbackUrl.origin).toBe("https://admin.example.com");
|
||||
expect(callbackUrl.pathname).toBe("/buzz");
|
||||
expect(callbackUrl.search).toBe("?source=bind");
|
||||
expect(callbackUrl.searchParams.has("buzz_bind")).toBe(false);
|
||||
expect(callbackUrl.hash).toMatch(/^#buzz_bind=v1\.[A-Za-z0-9_-]+$/);
|
||||
});
|
||||
|
||||
test("keeps the signed response available when clipboard access fails", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user