fix(core/agents): resolve claude from home fallback paths

This commit is contained in:
James Peter
2026-02-10 20:36:28 +10:00
parent 1261263044
commit c560d6acf7
4 changed files with 150 additions and 151 deletions
+139 -84
View File
@@ -88,6 +88,92 @@ fn resolve_in_path(command: &str) -> Option<std::path::PathBuf> {
None
}
#[cfg(target_os = "windows")]
fn is_executable_file(path: &std::path::Path) -> bool {
path.is_file()
}
#[cfg(not(target_os = "windows"))]
fn is_executable_file(path: &std::path::Path) -> bool {
use std::os::unix::fs::PermissionsExt;
let Ok(meta) = std::fs::metadata(path) else {
return false;
};
meta.is_file() && (meta.permissions().mode() & 0o111 != 0)
}
#[cfg(target_os = "windows")]
fn resolve_command_on_path(command: &str) -> Option<std::path::PathBuf> {
resolve_in_path(command)
}
#[cfg(not(target_os = "windows"))]
fn resolve_command_on_path(command: &str) -> Option<std::path::PathBuf> {
let path_os = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path_os) {
if dir.as_os_str().is_empty() {
continue;
}
let candidate = dir.join(command);
if is_executable_file(&candidate) {
return Some(candidate);
}
}
None
}
fn home_fallback_command_candidates(command: &str) -> Vec<std::path::PathBuf> {
let mut candidates = Vec::new();
let home = std::env::var_os("HOME").map(std::path::PathBuf::from);
if let Some(home) = home.as_ref() {
candidates.push(home.join(".local/bin").join(command));
candidates.push(home.join(".n/bin").join(command));
candidates.push(home.join(".npm-global/bin").join(command));
}
if command.eq_ignore_ascii_case("claude") {
if let Some(claude_config_dir) = std::env::var_os("CLAUDE_CONFIG_DIR") {
candidates.push(std::path::PathBuf::from(claude_config_dir).join("local").join(command));
}
if let Some(home) = home {
candidates.push(home.join(".claude/local").join(command));
}
}
candidates
}
pub(crate) fn resolve_external_agent_command_path(command: &str) -> Option<std::path::PathBuf> {
let trimmed = command.trim();
if trimmed.is_empty() {
return None;
}
if trimmed.contains(std::path::MAIN_SEPARATOR) || trimmed.contains('/') || trimmed.contains('\\') {
let path = std::path::PathBuf::from(trimmed);
return is_executable_file(&path).then_some(path);
}
if let Some(path) = resolve_command_on_path(trimmed) {
return Some(path);
}
for candidate in home_fallback_command_candidates(trimmed) {
if is_executable_file(&candidate) {
return Some(candidate);
}
}
None
}
pub fn external_agent_command_exists(command: &str) -> bool {
resolve_external_agent_command_path(command).is_some()
}
use crate::agent_defaults::{agent_model_spec, default_params_for};
use shlex::split as shlex_split;
use crate::config_types::AgentConfig;
@@ -1099,83 +1185,6 @@ async fn execute_model_with_permissions(
source_kind: Option<AgentSourceKind>,
log_tag: Option<&str>,
) -> Result<String, String> {
// Helper: cross‑platform check whether an executable is available in PATH
// and is directly spawnable by std::process::Command (no shell wrappers).
fn command_exists(cmd: &str) -> bool {
// Absolute/relative path with separators: check directly (files only).
if cmd.contains(std::path::MAIN_SEPARATOR) || cmd.contains('/') || cmd.contains('\\') {
let path = std::path::Path::new(cmd);
if path.extension().is_some() {
return std::fs::metadata(path).map(|m| m.is_file()).unwrap_or(false);
}
#[cfg(target_os = "windows")]
{
const DEFAULT_EXTS: &[&str] = &[".exe", ".com", ".cmd", ".bat"];
for ext in default_pathext_or_default() {
let candidate = path.with_extension("");
let candidate = candidate.with_extension(ext.trim_start_matches('.'));
if std::fs::metadata(&candidate)
.map(|m| m.is_file())
.unwrap_or(false)
{
return true;
}
}
}
return std::fs::metadata(path).map(|m| m.is_file()).unwrap_or(false);
}
#[cfg(target_os = "windows")]
{
let exts = default_pathext_or_default();
let path_var = std::env::var_os("PATH");
let path_iter = path_var
.as_ref()
.map(std::env::split_paths)
.into_iter()
.flatten();
let candidates: Vec<String> = if std::path::Path::new(cmd).extension().is_some() {
vec![cmd.to_string()]
} else {
exts
.iter()
.map(|ext| format!("{cmd}{ext}"))
.collect()
};
for dir in path_iter {
for candidate in &candidates {
let p = dir.join(candidate);
if p.is_file() {
return true;
}
}
}
false
}
#[cfg(not(target_os = "windows"))]
{
use std::os::unix::fs::PermissionsExt;
let Some(path_os) = std::env::var_os("PATH") else { return false; };
for dir in std::env::split_paths(&path_os) {
if dir.as_os_str().is_empty() { continue; }
let candidate = dir.join(cmd);
if let Ok(meta) = std::fs::metadata(&candidate) {
if meta.is_file() {
let mode = meta.permissions().mode();
if mode & 0o111 != 0 { return true; }
}
}
}
false
}
}
let spec_opt = agent_model_spec(model)
.or_else(|| config.as_ref().and_then(|cfg| agent_model_spec(&cfg.name)))
.or_else(|| config.as_ref().and_then(|cfg| agent_model_spec(&cfg.command)));
@@ -1238,7 +1247,7 @@ fn command_exists(cmd: &str) -> bool {
model_lower.as_str()
};
let command_missing = !command_exists(&command_for_spawn);
let command_missing = !external_agent_command_exists(&command_for_spawn);
let use_current_exe = should_use_current_exe_for_agent(family, command_missing, config.as_ref());
let mut final_args: Vec<String> = command_extra_args;
@@ -1378,7 +1387,7 @@ fn command_exists(cmd: &str) -> bool {
// using the current executable fallback. This avoids confusing OS errors
// like "program not found" and lets us surface a cleaner message.
if !(family == "codex" || family == "code" || (family == "cloud" && config.is_none()))
&& !command_exists(&command_for_spawn)
&& !external_agent_command_exists(&command_for_spawn)
{
return Err(format_agent_not_found_error(&command, &command_for_spawn));
}
@@ -1693,11 +1702,8 @@ fn resolve_program_path(use_current_exe: bool, command_for_spawn: &str) -> Resul
return current_code_binary_path();
}
#[cfg(target_os = "windows")]
{
if let Some(p) = resolve_in_path(command_for_spawn) {
return Ok(p);
}
if let Some(path) = resolve_external_agent_command_path(command_for_spawn) {
return Ok(path);
}
Ok(std::path::PathBuf::from(command_for_spawn))
@@ -2550,6 +2556,55 @@ mod tests {
assert_eq!(output.trim(), "current");
}
#[cfg(not(target_os = "windows"))]
#[tokio::test]
async fn claude_agent_uses_local_install_when_not_on_path() {
let _lock = env_lock().lock().expect("env lock");
let _reset_path = EnvReset::capture("PATH");
let _reset_home = EnvReset::capture("HOME");
let dir = tempdir().expect("tempdir");
let claude_dir = dir.path().join(".claude").join("local");
std::fs::create_dir_all(&claude_dir).expect("create claude dir");
let claude_script = claude_dir.join("claude");
write_script(&claude_script, "local-claude");
unsafe {
std::env::set_var("HOME", dir.path());
std::env::set_var("PATH", "/usr/bin:/bin");
}
let cfg = AgentConfig {
name: "claude-sonnet-4.5".to_string(),
command: "claude".to_string(),
args: Vec::new(),
read_only: true,
enabled: true,
description: None,
env: None,
args_read_only: None,
args_write: None,
instructions: None,
};
let output = execute_model_with_permissions(
"agent-test",
"claude-sonnet-4.5",
"ok",
true,
None,
Some(cfg),
ReasoningEffort::Low,
None,
None,
None,
)
.await
.expect("execute claude with local fallback");
assert_eq!(output.trim(), "local-claude");
}
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
+3 -32
View File
@@ -18,6 +18,7 @@ use super::session::{
use crate::auth;
use crate::auth_accounts;
use crate::account_switching::RateLimitSwitchState;
use crate::agent_tool::external_agent_command_exists;
use crate::protocol::McpListToolsResponseEvent;
use code_app_server_protocol::AuthMode as AppAuthMode;
use code_protocol::models::FunctionCallOutputContentItem;
@@ -6532,36 +6533,6 @@ pub(crate) async fn handle_run_agent(
models.sort_by(|a, b| a.to_ascii_lowercase().cmp(&b.to_ascii_lowercase()));
models.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
// Helper: PATH lookup to determine if a command exists.
fn command_exists(cmd: &str) -> bool {
// Absolute/relative path with separators: verify it points to a file.
if cmd.contains(std::path::MAIN_SEPARATOR) || cmd.contains('/') || cmd.contains('\\') {
return std::fs::metadata(cmd).map(|m| m.is_file()).unwrap_or(false);
}
#[cfg(target_os = "windows")]
{
return which::which(cmd).map(|p| p.is_file()).unwrap_or(false);
}
#[cfg(not(target_os = "windows"))]
{
use std::os::unix::fs::PermissionsExt;
let Some(path_os) = std::env::var_os("PATH") else { return false; };
for dir in std::env::split_paths(&path_os) {
if dir.as_os_str().is_empty() { continue; }
let candidate = dir.join(cmd);
if let Ok(meta) = std::fs::metadata(&candidate) {
if meta.is_file() {
let mode = meta.permissions().mode();
if mode & 0o111 != 0 { return true; }
}
}
}
false
}
}
let multi_model = models.len() > 1;
let display_label_for = |model: &str| -> String {
agent_name
@@ -6596,7 +6567,7 @@ pub(crate) async fn handle_run_agent(
let (cmd_to_check, is_builtin) =
resolve_agent_command_for_check(&model, Some(config));
if !is_builtin && !command_exists(&cmd_to_check) {
if !is_builtin && !external_agent_command_exists(&cmd_to_check) {
skipped.push(format!("{} (missing: {})", model, cmd_to_check));
continue;
}
@@ -6628,7 +6599,7 @@ pub(crate) async fn handle_run_agent(
} else {
// Use default configuration for unknown agents
let (cmd_to_check, is_builtin) = resolve_agent_command_for_check(&model, None);
if !is_builtin && !command_exists(&cmd_to_check) {
if !is_builtin && !external_agent_command_exists(&cmd_to_check) {
skipped.push(format!("{} (missing: {})", model, cmd_to_check));
continue;
}
+1
View File
@@ -138,6 +138,7 @@ pub mod util;
pub use apply_patch::CODEX_APPLY_PATCH_ARG1;
pub use command_safety::is_safe_command;
pub use agent_tool::external_agent_command_exists;
pub use agent_tool::smoke_test_agent_blocking;
pub use agent_tool::split_command_and_args;
pub use safety::get_platform_sandbox;
+7 -35
View File
@@ -58,6 +58,7 @@ use code_protocol::mcp_protocol::AuthMode as McpAuthMode;
use code_protocol::dynamic_tools::DynamicToolResponse;
use code_protocol::protocol::SessionSource;
use code_protocol::num_format::format_with_separators;
use code_core::external_agent_command_exists;
use code_core::split_command_and_args;
use serde_json::Value as JsonValue;
@@ -23910,35 +23911,6 @@ Have we met every part of this goal and is there no further work to do?"#
}
fn collect_agents_overview_rows(&self) -> (Vec<AgentOverviewRow>, Vec<String>) {
fn command_exists(cmd: &str) -> bool {
if cmd.contains(std::path::MAIN_SEPARATOR) || cmd.contains('/') || cmd.contains('\\') {
return std::fs::metadata(cmd).map(|m| m.is_file()).unwrap_or(false);
}
#[cfg(target_os = "windows")]
{
which::which(cmd).map(|p| p.is_file()).unwrap_or(false)
}
#[cfg(not(target_os = "windows"))]
{
use std::os::unix::fs::PermissionsExt;
let Some(path_os) = std::env::var_os("PATH") else {
return false;
};
for dir in std::env::split_paths(&path_os) {
if dir.as_os_str().is_empty() {
continue;
}
let candidate = dir.join(cmd);
if let Ok(meta) = std::fs::metadata(&candidate) {
if meta.is_file() && (meta.permissions().mode() & 0o111 != 0) {
return true;
}
}
}
false
}
}
fn command_for_check(command: &str) -> String {
let (base, _) = split_command_and_args(command);
if base.trim().is_empty() {
@@ -23989,10 +23961,10 @@ Have we met every part of this goal and is there no further work to do?"#
let command_to_check = command_for_check(&cfg.command);
let installed = if builtin {
true
} else if command_exists(&command_to_check) {
} else if external_agent_command_exists(&command_to_check) {
true
} else if let Some(cli) = spec_cli {
command_exists(cli)
external_agent_command_exists(cli)
} else {
false
};
@@ -24014,10 +23986,10 @@ Have we met every part of this goal and is there no further work to do?"#
let command_to_check = command_for_check(&cfg.command);
let installed = if builtin {
true
} else if command_exists(&command_to_check) {
} else if external_agent_command_exists(&command_to_check) {
true
} else if let Some(cli) = spec_cli {
command_exists(cli)
external_agent_command_exists(cli)
} else {
false
};
@@ -24038,9 +24010,9 @@ Have we met every part of this goal and is there no further work to do?"#
let installed = if builtin {
true
} else if let Some(cli) = spec_cli {
command_exists(cli)
external_agent_command_exists(cli)
} else {
command_exists(&cmd)
external_agent_command_exists(&cmd)
};
agent_rows.push(AgentOverviewRow {
name: name.clone(),