diff --git a/crates/ui-egui/src/agent_chat/discover.rs b/crates/ui-egui/src/agent_chat/discover.rs index 1bf100fc..3b028ca2 100644 --- a/crates/ui-egui/src/agent_chat/discover.rs +++ b/crates/ui-egui/src/agent_chat/discover.rs @@ -218,8 +218,13 @@ pub fn runnable_sibling(program: &Path) -> Option { names_for(name, true).into_iter().map(|n| dir.join(n)).find(|p| p.is_file()) } -/// `program --version`'s first line, or `None` after `limit` (the probe is then stopped). -fn version(program: &Path, limit: Duration) -> Option { +/// Start probes and sessions with the same PATH used for discovery. Desktop launchers often +/// omit Node's install folder even when we found an adapter with a `#!/usr/bin/env node` shebang. +/// Only the child's environment changes; inherited absolute PATH entries keep precedence. +pub(super) fn command_for(program: &Path) -> Command { + // Older saved Windows commands may name npm's bare Unix script rather than its .cmd shim. + let shim = if cfg!(windows) { runnable_sibling(program) } else { None }; + let program = shim.as_deref().unwrap_or(program); let mut cmd = if cfg!(windows) && program.extension().is_some_and(|e| e.eq_ignore_ascii_case("cmd") || e.eq_ignore_ascii_case("bat")) { let mut c = Command::new("cmd"); c.arg("/C").arg(program); @@ -227,6 +232,15 @@ fn version(program: &Path, limit: Duration) -> Option { } else { Command::new(program) }; + if let Ok(path) = std::env::join_paths(search_dirs()) { + cmd.env("PATH", path); + } + cmd +} + +/// `program --version`'s first line, or `None` after `limit` (the probe is then stopped). +fn version(program: &Path, limit: Duration) -> Option { + let mut cmd = command_for(program); #[cfg(windows)] { use std::os::windows::process::CommandExt; @@ -376,4 +390,53 @@ mod tests { } assert_eq!(KNOWN.first().map(|k| k.id), Some("claude"), "the Claude Code adapter is the default"); } + + /// Finder's PATH lacks user-installed runtimes. Discovery, the version probe and a live + /// agent must all work with an env shebang whose interpreter is in ~/.local/bin. + #[test] + #[cfg(unix)] + fn desktop_path_can_start_an_agent_and_its_runtime() { + use super::super::process::{AgentProcess, FromAgent}; + use std::os::unix::fs::PermissionsExt; + + const CHILD: &str = "SOLIDCRAFT_AGENT_PATH_TEST"; + if let Some(root) = std::env::var_os(CHILD) { + let root = PathBuf::from(root); + let agent = find_in("solidcraft-test-agent", &search_dirs()).unwrap(); + assert_eq!(version(&agent, Duration::from_secs(3)).as_deref(), Some("1.2.3")); + let mut p = AgentProcess::spawn(&[agent.to_string_lossy().into_owned()], &root, &[], || {}).unwrap(); + let message = serde_json::json!({"id": 1, "method": "initialize"}); + p.send(&message); + let reply = p.from_agent.recv_timeout(Duration::from_secs(5)).unwrap(); + p.shutdown(true); + match reply { + FromAgent::Line(line) => assert_eq!(serde_json::from_str::(&line).unwrap(), message), + FromAgent::Exited(tail) => panic!("the agent could not start its runtime: {tail:?}"), + } + return; + } + + let root = std::env::temp_dir().join(format!("sc-agent-desktop-path-{}", std::process::id())); + let bin = root.join(".local/bin"); + std::fs::create_dir_all(&bin).unwrap(); + for (name, script) in [ + ("solidcraft-test-agent", "#!/usr/bin/env solidcraft-test-runtime\n"), + ("solidcraft-test-runtime", "#!/bin/sh\nif [ \"$2\" = \"--version\" ]; then printf 'test-agent 1.2.3\\n'; else cat; fi\n"), + ] { + let path = bin.join(name); + std::fs::write(&path, script).unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + // Change only a child process's environment: no global mutation or unsafe set_var. + let output = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "agent_chat::discover::tests::desktop_path_can_start_an_agent_and_its_runtime", "--nocapture"]) + .env(CHILD, &root) + .env("HOME", &root) + .env("PATH", "/usr/bin:/bin") + .env_remove("NPM_CONFIG_PREFIX") + .output() + .unwrap(); + let _ = std::fs::remove_dir_all(root); + assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); + } } diff --git a/crates/ui-egui/src/agent_chat/process.rs b/crates/ui-egui/src/agent_chat/process.rs index e7049972..fb0e7543 100644 --- a/crates/ui-egui/src/agent_chat/process.rs +++ b/crates/ui-egui/src/agent_chat/process.rs @@ -51,21 +51,6 @@ impl ProcessHandle { } } -/// On Windows, npm installs `.cmd` shims, which only `cmd.exe` runs. A command saved as the bare -/// npm path (a shell script there) runs the shim beside it. -fn command_for(program: &str) -> Command { - let shim = if cfg!(windows) { super::discover::runnable_sibling(std::path::Path::new(program)) } else { None }; - let program = shim.as_deref().and_then(|p| p.to_str()).unwrap_or(program); - let lower = program.to_ascii_lowercase(); - if cfg!(windows) && (lower.ends_with(".cmd") || lower.ends_with(".bat")) { - let mut c = Command::new("cmd"); - c.arg("/C").arg(program); - c - } else { - Command::new(program) - } -} - /// Start the agent in a process group of its own (Unix) or without a console window (Windows). fn detach_group(cmd: &mut Command) { #[cfg(unix)] @@ -161,7 +146,7 @@ impl AgentProcess { wake: impl Fn() + Send + Sync + 'static, ) -> Result { let Some((program, args)) = argv.split_first() else { return Err("no agent command".into()) }; - let mut cmd = command_for(program); + let mut cmd = super::discover::command_for(std::path::Path::new(program)); cmd.args(args).current_dir(cwd).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()); for v in env_remove { cmd.env_remove(v); diff --git a/docs/agent-chat.md b/docs/agent-chat.md index f3981244..005ff723 100644 --- a/docs/agent-chat.md +++ b/docs/agent-chat.md @@ -55,6 +55,9 @@ SolidCraft looks for agents that speak the [Agent Client Protocol](https://agent on your `PATH` and in the usual install folders a desktop app's `PATH` often misses (npm's global folder, nvm, Volta, bun, `~/.local/bin`, `~/.grok/bin`, Homebrew, `%APPDATA%\npm`). For each one it shows its **version** (from `--version`, given three seconds) and whether it looks **signed in**. +Version checks and agent sessions also use those folders on their `PATH`, so adapters can find +runtimes such as Node when SolidCraft is opened from Finder or the Dock. Existing absolute +`PATH` entries keep their order; no shell startup files are run. SolidCraft only checks whether the agent's own login file (`~/.claude/.credentials.json`, `~/.codex/auth.json`, `~/.gemini/oauth_creds.json`, …) or one of its key variables exists. It never reads the file and never starts the agent to find out. On macOS Claude Code keeps its login in