Fix agent runtime lookup when launched from the desktop (#1551)

Thanks @cfaulkingham.
This commit is contained in:
Colin Faulkingham authored and GitHub committed 2026-10-15 22:44:13 -04:00
1 parent 7ef78f960a
commit 904bf19e83
3 files changed
+69 -18

No files matched your search

+65 -2
View File
@@ -218,8 +218,13 @@ pub fn runnable_sibling(program: &Path) -> Option<PathBuf> {
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<String> {
/// 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<String> {
} 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<String> {
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::<serde_json::Value>(&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));
}
}
+1 -16
View File
@@ -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<AgentProcess, String> {
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);
+3
View File
@@ -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