From 6167413561cad40d92eb970c7ac18f924ed33ee5 Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 20 Apr 2026 12:12:10 -0700 Subject: [PATCH 1/3] fix(desktop): resolve agent command path for DMG builds The desktop now resolves the agent command (e.g. claude-agent-acp) to a full path via resolve_command() before passing it to sprout-acp via SPROUT_ACP_AGENT_COMMAND. This matches how we already handle the harness (sprout-acp) and MCP server (sprout-mcp-server) commands. Fixes ACP agent startup when launched from Finder where PATH is minimal (/usr/bin:/bin:/usr/sbin:/sbin) and npm-installed binaries aren't found. Uses soft fallback (unwrap_or_else) so custom/unknown agent paths still pass through to sprout-acp for its own error handling. --- desktop/src-tauri/src/managed_agents/runtime.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 53b6ddea9..e42dc937b 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -482,6 +482,10 @@ pub fn start_managed_agent_process( .ok_or_else(|| missing_command_message(&record.acp_command, "ACP harness command"))?; let resolved_mcp_command = resolve_command(&record.mcp_command, Some(app)) .ok_or_else(|| missing_command_message(&record.mcp_command, "MCP server command"))?; + // Resolve agent command to a full path (DMG launches have minimal PATH). + let resolved_agent_command = resolve_command(&record.agent_command, Some(app)) + .map(|p| p.display().to_string()) + .unwrap_or_else(|| record.agent_command.clone()); let mut command = std::process::Command::new(&resolved_acp_command); if let Some(home) = super::default_agent_workdir() { @@ -492,7 +496,7 @@ pub fn start_managed_agent_process( command.stderr(std::process::Stdio::from(stderr)); command.env("SPROUT_PRIVATE_KEY", &record.private_key_nsec); command.env("SPROUT_RELAY_URL", &record.relay_url); - command.env("SPROUT_ACP_AGENT_COMMAND", &record.agent_command); + command.env("SPROUT_ACP_AGENT_COMMAND", &resolved_agent_command); command.env("SPROUT_ACP_AGENT_ARGS", agent_args.join(",")); command.env("SPROUT_ACP_MCP_COMMAND", &resolved_mcp_command); // Desktop-managed agents should favor the latest owner mention in a From 3925f327291b5893a48d03d66ef598b6e9a6f5ab Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 20 Apr 2026 12:15:27 -0700 Subject: [PATCH 2/3] fix(desktop): resolve agent command in model discovery for DMG builds Same fix as the runtime path: resolve the agent command to a full path via resolve_command() before passing it to sprout-acp via SPROUT_ACP_AGENT_COMMAND in get_agent_models(). Without this, model discovery fails on DMG builds launched from Finder where PATH is minimal. --- desktop/src-tauri/src/commands/agent_models.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 530ce10f3..826be6331 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -49,12 +49,11 @@ pub async fn get_agent_models( let args = normalize_agent_args(&record.agent_command, record.agent_args.clone()); - ( - resolved, - record.agent_command.clone(), - args, - record.model.clone(), - ) + let resolved_agent = resolve_command(&record.agent_command, Some(&app)) + .map(|p| p.display().to_string()) + .unwrap_or_else(|| record.agent_command.clone()); + + (resolved, resolved_agent, args, record.model.clone()) }; // store lock released — subprocess runs without holding the lock // Use spawn_blocking because the desktop Tauri crate doesn't enable From c5cb52d1d07ee29dcad5c223b71802c69d6ffc5d Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 20 Apr 2026 13:03:27 -0700 Subject: [PATCH 3/3] fix(desktop): augment PATH for spawned agent processes on DMG builds When Sprout.app launches from a DMG via Finder, the inherited PATH is minimal (/usr/bin:/bin:/usr/sbin:/sbin). PR #372 resolved agent binaries to full paths, but spawned processes (sprout-acp) still inherited the anemic PATH. This meant #!/usr/bin/env node scripts like claude-agent-acp couldn't find their runtime. Adds login_shell_path() which probes the user's login shell for the full PATH (cached via OnceLock for the app lifetime, noise-filtered to handle chatty shell profiles). Injects this PATH into both agent startup (runtime.rs) and model discovery (agent_models.rs). Also refactors find_via_login_shell() and login_shell_path() to share a common run_in_login_shell() helper. Co-Authored-By: Claude Opus 4.6 (1M context) --- desktop/scripts/check-file-sizes.mjs | 2 +- .../src-tauri/src/commands/agent_models.rs | 3 ++ .../src-tauri/src/managed_agents/discovery.rs | 43 ++++++++++++------- .../src-tauri/src/managed_agents/runtime.rs | 12 ++++-- 4 files changed, 41 insertions(+), 19 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 6fe53e0b3..7e4c10184 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -45,7 +45,7 @@ const overrides = new Map([ ["src-tauri/src/lib.rs", 710], // sprout-media:// proxy + Range headers + Sprout nest init (ensure_nest) in setup() + huddle command registration + PTT global shortcut handler + persona pack commands + app_handle storage for event emission ["src-tauri/src/commands/media.rs", 720], // ffmpeg video transcode + poster frame extraction + run_ffmpeg_with_timeout (find_ffmpeg, is_video_file, transcode_to_mp4, extract_poster_frame, transcode_and_extract_poster) + spawn_blocking wrappers + tests ["src-tauri/src/commands/agents.rs", 880], // remote agent lifecycle routing (local + provider branches) + scope enforcement + persona pack metadata wiring + mcp_toolsets field - ["src-tauri/src/managed_agents/runtime.rs", 690], // KNOWN_AGENT_BINARIES const + process_belongs_to_us FFI (macOS proc_name + Linux /proc/comm) + terminate_process + start/stop/sync lifecycle + pack persona live-read + ["src-tauri/src/managed_agents/runtime.rs", 700], // KNOWN_AGENT_BINARIES const + process_belongs_to_us FFI (macOS proc_name + Linux /proc/comm) + terminate_process + start/stop/sync lifecycle + pack persona live-read + login shell PATH augmentation ["src-tauri/src/managed_agents/backend.rs", 530], // provider IPC, validation, discovery, binary resolution + tests ["src/features/huddle/HuddleContext.tsx", 650], // huddle lifecycle context + joinHuddle + connectAndSetupMedia shared helper + activeSpeakers/isReconnecting state + PTT (reusable AudioContext) + TTS subscription + mic level analyser (10fps throttle) + agent pubkey refresh ["src/features/agents/hooks.ts", 540], // agent query/mutation surface now includes built-in persona library activation + useUpdateManagedAgentMutation diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 826be6331..304b7c05d 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -64,6 +64,9 @@ pub async fn get_agent_models( if let Some(home) = default_agent_workdir() { cmd.current_dir(home); } + if let Some(ref path) = crate::managed_agents::login_shell_path() { + cmd.env("PATH", path); + } cmd.arg("models") .arg("--json") .env("SPROUT_ACP_AGENT_COMMAND", &agent_command) diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index ce42baadb..e1e751786 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -232,32 +232,45 @@ fn path_candidates_from_env(command: &str) -> Vec { .unwrap_or_default() } -fn find_via_login_shell(command: &str) -> Option { +/// Run a command in a login shell (tries zsh then bash). +/// Returns trimmed stdout if the command succeeds with non-empty output. +fn run_in_login_shell(args: &[&str]) -> Option { for shell in ["/bin/zsh", "/bin/bash"] { - let Ok(output) = Command::new(shell) - .args(["-l", "-c", r#"command -v -- "$1""#, "_", command]) - .output() - else { + let Ok(output) = Command::new(shell).args(args).output() else { continue; }; - if !output.status.success() { continue; } - - let stdout = String::from_utf8_lossy(&output.stdout); - let Some(resolved) = stdout.lines().rfind(|line| !line.trim().is_empty()) else { - continue; - }; - let path = PathBuf::from(resolved.trim()); - if path.is_absolute() && path.exists() { - return Some(path); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !stdout.is_empty() { + return Some(stdout); } } - None } +fn find_via_login_shell(command: &str) -> Option { + let stdout = run_in_login_shell(&["-l", "-c", r#"command -v -- "$1""#, "_", command])?; + let resolved = stdout.lines().rfind(|line| !line.trim().is_empty())?; + let path = PathBuf::from(resolved.trim()); + (path.is_absolute() && path.exists()).then_some(path) +} + +/// Return the user's full PATH from a login shell. +/// Cached via OnceLock so we only spawn one shell per app lifetime. +pub fn login_shell_path() -> Option { + use std::sync::OnceLock; + static CACHED: OnceLock> = OnceLock::new(); + CACHED + .get_or_init(|| { + let stdout = run_in_login_shell(&["-l", "-c", "echo $PATH"])?; + let last_line = stdout.lines().rfind(|l| !l.trim().is_empty())?; + Some(last_line.trim().to_string()) + }) + .clone() +} + fn find_command(command: &str) -> Option { resolve_command(command, None) } diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index e42dc937b..29c4fcc45 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -4,9 +4,9 @@ use tauri::AppHandle; use crate::{ managed_agents::{ - append_log_marker, managed_agent_log_path, missing_command_message, normalize_agent_args, - open_log_file, resolve_command, ManagedAgentProcess, ManagedAgentRecord, - ManagedAgentSummary, + append_log_marker, login_shell_path, managed_agent_log_path, missing_command_message, + normalize_agent_args, open_log_file, resolve_command, ManagedAgentProcess, + ManagedAgentRecord, ManagedAgentSummary, }, util::now_iso, }; @@ -487,6 +487,9 @@ pub fn start_managed_agent_process( .map(|p| p.display().to_string()) .unwrap_or_else(|| record.agent_command.clone()); + // Augment PATH for DMG launches so child processes (e.g. #!/usr/bin/env node) can find their runtimes. + let augmented_path = login_shell_path(); + let mut command = std::process::Command::new(&resolved_acp_command); if let Some(home) = super::default_agent_workdir() { command.current_dir(home); @@ -494,6 +497,9 @@ pub fn start_managed_agent_process( command.stdin(std::process::Stdio::null()); command.stdout(std::process::Stdio::from(stdout)); command.stderr(std::process::Stdio::from(stderr)); + if let Some(ref path) = augmented_path { + command.env("PATH", path); + } command.env("SPROUT_PRIVATE_KEY", &record.private_key_nsec); command.env("SPROUT_RELAY_URL", &record.relay_url); command.env("SPROUT_ACP_AGENT_COMMAND", &resolved_agent_command);