use crate::adapters::{self, Adapter};
use crate::health::{ExternalAgentHealth, HealthStatus};
use crate::types::{ExecutableStatus, ExternalAgentSpec};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
fn production_scratch_prefixes() -> Vec<String> {
#[cfg(not(windows))]
{
["/tmp/", "/private/tmp/", "/var/tmp/", "/dev/shm/"]
.iter()
.map(|s| s.to_string())
.collect()
}
#[cfg(windows)]
{
fn norm(s: &str) -> String {
format!("{}\\", s.replace('/', "\\").trim_end_matches('\\')).to_ascii_lowercase()
}
let mut v = vec![norm(&std::env::temp_dir().to_string_lossy())];
for var in ["TEMP", "TMP"] {
if let Some(t) = std::env::var_os(var) {
v.push(norm(&Path::new(&t).to_string_lossy()));
}
}
let sysroot = std::env::var_os("SystemRoot")
.map(|s| Path::new(&s).to_string_lossy().into_owned())
.unwrap_or_else(|| r"C:\Windows".to_string());
v.push(norm(&format!("{}\\Temp", sysroot.trim_end_matches('\\'))));
let drive = std::env::var_os("SystemDrive")
.map(|s| Path::new(&s).to_string_lossy().into_owned())
.unwrap_or_else(|| "C:".to_string());
v.push(norm(&format!(
"{}\\Users\\Public",
drive.trim_end_matches('\\')
)));
v
}
}
fn under_scratch(candidate: &str, scratch_prefixes: &[String]) -> bool {
#[cfg(windows)]
let candidate = candidate.replace('/', "\\").to_ascii_lowercase();
#[cfg(windows)]
let candidate = candidate.as_str();
scratch_prefixes
.iter()
.any(|p| candidate.starts_with(p.as_str()))
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn candidate_names(bin_name: &str) -> Vec<String> {
#[cfg(not(windows))]
{
vec![bin_name.to_string()]
}
#[cfg(windows)]
{
if Path::new(bin_name).extension().is_some() {
return vec![bin_name.to_string()];
}
let pathext =
std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string());
let mut names: Vec<String> = pathext
.split(';')
.filter(|e| !e.is_empty())
.map(|ext| format!("{bin_name}{}", ext.to_ascii_lowercase()))
.collect();
names.push(bin_name.to_string());
names
}
}
fn resolve_candidates(bin_name: &str, path_var: &str, scratch_prefixes: &[String]) -> Vec<PathBuf> {
let separator = if cfg!(windows) { ';' } else { ':' };
let names = candidate_names(bin_name);
let mut out: Vec<PathBuf> = Vec::new();
let mut seen: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
for dir in path_var.split(separator) {
if dir.is_empty() {
continue;
}
for name in &names {
let candidate = Path::new(dir).join(name);
let candidate_str = candidate.to_string_lossy();
if under_scratch(&candidate_str, scratch_prefixes) {
continue;
}
if std::fs::symlink_metadata(&candidate).is_err() {
continue;
}
let key = std::fs::canonicalize(&candidate).unwrap_or_else(|_| candidate.clone());
if seen.insert(key) {
out.push(candidate);
}
}
}
out
}
fn precheck_reason(path: &Path) -> Option<String> {
match std::fs::metadata(path) {
Ok(meta) => {
if !meta.is_file() {
return Some("not a regular file".to_string());
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if meta.permissions().mode() & 0o111 == 0 {
return Some("file is not executable (no +x bit)".to_string());
}
}
None
}
Err(e) => {
if std::fs::symlink_metadata(path).is_ok() {
Some(format!(
"dangling symlink — the target no longer exists ({e}); \
an app bundle it pointed into was probably moved or updated"
))
} else {
Some(format!("cannot stat: {e}"))
}
}
}
}
pub fn pin_env_var(adapter_id: &str) -> String {
format!("CAR_{}_BIN", adapter_id.replace('-', "_").to_uppercase())
}
fn pinned_binary(adapter_id: &str) -> Option<PathBuf> {
let raw = std::env::var(pin_env_var(adapter_id)).ok()?;
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
Some(PathBuf::from(trimmed))
}
pub(crate) fn is_batch_shim(bin: &Path) -> bool {
cfg!(windows)
&& bin
.extension()
.and_then(|e| e.to_str())
.map(|e| {
let e = e.to_ascii_lowercase();
e == "cmd" || e == "bat"
})
.unwrap_or(false)
}
pub(crate) fn base_command(bin: &Path) -> tokio::process::Command {
if is_batch_shim(bin) {
let mut c = tokio::process::Command::new("cmd");
c.arg("/C").arg(bin);
if let Some(path) = car_engine::win_env::cmd_path_override() {
c.env("PATH", path);
}
c
} else {
tokio::process::Command::new(bin)
}
}
#[derive(Debug)]
enum ProbeOutcome {
Version(String),
Unusable(String),
Inconclusive,
}
async fn probe_version(bin: &Path) -> ProbeOutcome {
let mut cmd = base_command(bin);
cmd.arg("--version");
cmd.stdin(std::process::Stdio::null());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::null());
cmd.kill_on_drop(true);
let child = match cmd.spawn() {
Ok(c) => c,
Err(e) => return ProbeOutcome::Unusable(format!("cannot execute: {e}")),
};
let output = match tokio::time::timeout(VERSION_PROBE_TIMEOUT, child.wait_with_output()).await {
Ok(Ok(out)) => out,
_ => return ProbeOutcome::Inconclusive,
};
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if let Some(sig) = output.status.signal() {
if sig != 9 {
return ProbeOutcome::Inconclusive;
}
return ProbeOutcome::Unusable(format!(
"killed by signal {sig} — binary was killed at exec; on macOS this is \
usually Gatekeeper quarantine (check `xattr -l <path>` for \
com.apple.quarantine)"
));
}
}
if !output.status.success() {
return ProbeOutcome::Inconclusive;
}
let Ok(stdout) = String::from_utf8(output.stdout) else {
return ProbeOutcome::Inconclusive;
};
let trimmed = stdout.trim();
if trimmed.is_empty() {
ProbeOutcome::Inconclusive
} else {
ProbeOutcome::Version(trimmed.to_string())
}
}
fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
}
async fn detect_one(
adapter: &Adapter,
path_var: &str,
home: &Path,
scratch_prefixes: &[String],
) -> Option<ExternalAgentSpec> {
let id = adapter.id.as_str();
let (candidates, from_pin) = match pinned_binary(id) {
Some(pinned) => (vec![pinned], true),
None => (
resolve_candidates(adapter.bin_name, path_var, scratch_prefixes),
false,
),
};
if candidates.is_empty() {
return None;
}
let pin_note = if from_pin {
format!(" (pinned by ${})", pin_env_var(id))
} else {
String::new()
};
let mut inconclusive: Option<PathBuf> = None;
let mut unusable: Option<(PathBuf, String)> = None;
let mut chosen: Option<(PathBuf, Option<String>)> = None;
for candidate in candidates {
if let Some(why) = precheck_reason(&candidate) {
if unusable.is_none() {
unusable = Some((candidate, format!("{why}{pin_note}")));
}
continue;
}
match probe_version(&candidate).await {
ProbeOutcome::Version(raw) => {
chosen = Some((candidate, (adapter.parse_version)(&raw)));
break;
}
ProbeOutcome::Inconclusive => {
if inconclusive.is_none() {
inconclusive = Some(candidate);
}
}
ProbeOutcome::Unusable(reason) => {
let reason = format!("{reason}{pin_note}");
if unusable.is_none() {
unusable = Some((candidate, reason));
}
}
}
}
let auth_kind = (adapter.probe_auth)(home);
let mut spec = ExternalAgentSpec {
id: id.to_string(),
display_name: adapter.id.display_name().to_string(),
binary_path: PathBuf::new(),
version: None,
auth_kind,
capabilities: adapter.capabilities.clone(),
detected_at: now_secs(),
health: None,
execution: ExecutableStatus::Runnable,
};
if let Some((path, version)) = chosen {
spec.binary_path = path;
spec.version = version;
return Some(spec);
}
if let Some(path) = inconclusive {
spec.binary_path = path;
return Some(spec);
}
let (path, reason) = unusable?;
let detail = format!("{} at {}", reason, path.display());
let checked_at = now_secs();
spec.execution = ExecutableStatus::Unusable {
reason: detail.clone(),
checked_at,
};
spec.health = Some(ExternalAgentHealth {
id: id.to_string(),
status: HealthStatus::NotExecutable,
details: serde_json::json!({ "binary_path": path.to_string_lossy() }),
reason: Some(detail),
checked_at,
});
spec.binary_path = path;
Some(spec)
}
pub async fn detect_runnable() -> Vec<ExternalAgentSpec> {
detect()
.await
.into_iter()
.filter(|spec| spec.unusable_reason().is_none())
.collect()
}
pub async fn detect() -> Vec<ExternalAgentSpec> {
let path_var = std::env::var("PATH").unwrap_or_default();
let Some(home) = home_dir() else {
return detect_with_paths(&path_var, Path::new("/")).await;
};
detect_with_paths(&path_var, &home).await
}
pub(crate) async fn detect_with_paths(path_var: &str, home: &Path) -> Vec<ExternalAgentSpec> {
detect_with_paths_filtered(path_var, home, &production_scratch_prefixes()).await
}
pub(crate) async fn detect_with_paths_filtered(
path_var: &str,
home: &Path,
scratch_prefixes: &[String],
) -> Vec<ExternalAgentSpec> {
let probes = adapters::all()
.iter()
.map(|adapter| detect_one(adapter, path_var, home, scratch_prefixes));
let mut specs: Vec<ExternalAgentSpec> = futures::future::join_all(probes)
.await
.into_iter()
.flatten()
.collect();
specs.sort_by(|a, b| a.id.cmp(&b.id));
specs
}
pub async fn detect_with_health(force: bool) -> Vec<ExternalAgentSpec> {
let mut specs = detect().await;
let runnable: Vec<ExternalAgentSpec> = specs
.iter()
.filter(|s| !is_not_executable(s))
.cloned()
.collect();
let healths = crate::health::check_all(&runnable, force).await;
let by_id: std::collections::HashMap<&str, &crate::health::ExternalAgentHealth> =
healths.iter().map(|h| (h.id.as_str(), h)).collect();
for spec in specs.iter_mut() {
if is_not_executable(spec) {
continue;
}
if let Some(h) = by_id.get(spec.id.as_str()) {
spec.health = Some((*h).clone());
}
}
specs
}
fn is_not_executable(spec: &ExternalAgentSpec) -> bool {
spec.unusable_reason().is_some()
}
#[cfg(test)]
mod tests {
use super::*;
fn make_fake_bin(dir: &Path, name: &str, version_output: &str) -> PathBuf {
#[cfg(windows)]
{
let path = dir.join(format!("{name}.cmd"));
std::fs::write(&path, format!("@echo off\r\necho {version_output}\r\n")).unwrap();
path
}
#[cfg(not(windows))]
{
let path = dir.join(name);
let script = format!("#!/bin/sh\necho '{version_output}'\n");
std::fs::write(&path, script).unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
path
}
}
fn make_failing_bin(dir: &Path, name: &str) -> PathBuf {
#[cfg(windows)]
{
let path = dir.join(format!("{name}.cmd"));
std::fs::write(&path, "@echo off\r\nexit /b 1\r\n").unwrap();
path
}
#[cfg(not(windows))]
{
let path = dir.join(name);
std::fs::write(&path, "#!/bin/sh\nexit 1\n").unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
path
}
}
#[cfg(windows)]
#[test]
fn candidate_names_expands_pathext_on_windows() {
std::env::set_var("PATHEXT", ".COM;.EXE;.BAT;.CMD");
let names = candidate_names("claude");
assert!(names.contains(&"claude.exe".to_string()), "{names:?}");
assert!(names.contains(&"claude.cmd".to_string()), "{names:?}");
assert_eq!(names.last().unwrap(), "claude", "bare shim tried last");
assert_eq!(
candidate_names("claude.cmd"),
vec!["claude.cmd".to_string()]
);
}
#[cfg(windows)]
#[test]
fn batch_shim_routed_through_cmd() {
assert!(is_batch_shim(Path::new(r"C:\x\claude.cmd")));
assert!(is_batch_shim(Path::new(r"C:\x\claude.bat")));
assert!(is_batch_shim(Path::new(r"C:\x\CLAUDE.CMD"))); assert!(!is_batch_shim(Path::new(r"C:\x\claude.exe")));
let c = base_command(Path::new(r"C:\x\claude.cmd"));
assert_eq!(c.as_std().get_program(), "cmd");
let c = base_command(Path::new(r"C:\x\claude.exe"));
assert_eq!(c.as_std().get_program(), r"C:\x\claude.exe");
}
#[cfg(not(windows))]
#[test]
fn batch_shim_never_on_unix() {
assert!(!is_batch_shim(Path::new("/x/claude.cmd")));
let c = base_command(Path::new("/x/claude"));
assert_eq!(c.as_std().get_program(), "/x/claude");
}
#[cfg(windows)]
#[tokio::test]
async fn detect_finds_cmd_shim_on_path() {
let bin_dir = tempfile::TempDir::new().unwrap();
let home_dir = tempfile::TempDir::new().unwrap();
make_fake_bin(bin_dir.path(), "claude", "1.0.51 (Claude Code)");
let path_var = bin_dir.path().to_string_lossy().to_string();
let specs = detect_with_paths_filtered(&path_var, home_dir.path(), &[]).await;
let claude = specs.iter().find(|s| s.id == "claude-code");
assert!(
claude.is_some(),
"expected claude-code via .cmd shim in {specs:?}"
);
assert_eq!(claude.unwrap().version.as_deref(), Some("1.0.51"));
}
#[cfg(windows)]
#[tokio::test]
async fn detect_rejects_windows_temp_dir_binaries() {
let bin_dir = tempfile::TempDir::new().unwrap();
let home_dir = tempfile::TempDir::new().unwrap();
make_fake_bin(bin_dir.path(), "claude", "1.0.51 (Claude Code)");
let path_var = bin_dir.path().to_string_lossy().to_string();
let specs = detect_with_paths(&path_var, home_dir.path()).await;
assert!(
specs.iter().all(|s| s.id != "claude-code"),
"temp-dir binary must be rejected by the production denylist, got {specs:?}"
);
}
#[tokio::test]
async fn detect_finds_fake_binary_on_path() {
let bin_dir = tempfile::TempDir::new().unwrap();
let home_dir = tempfile::TempDir::new().unwrap();
make_fake_bin(bin_dir.path(), "claude", "1.0.51 (Claude Code)");
let path_var = bin_dir.path().to_string_lossy().to_string();
let specs = detect_with_paths_filtered(&path_var, home_dir.path(), &[]).await;
let claude = specs.iter().find(|s| s.id == "claude-code");
assert!(claude.is_some(), "expected claude-code in {specs:?}");
let claude = claude.unwrap();
assert_eq!(claude.version.as_deref(), Some("1.0.51"));
assert!(matches!(
claude.auth_kind,
crate::types::AuthKind::Unauthenticated
));
}
#[tokio::test]
async fn detect_omits_uninstalled_binaries() {
let bin_dir = tempfile::TempDir::new().unwrap();
let home_dir = tempfile::TempDir::new().unwrap();
let path_var = bin_dir.path().to_string_lossy().to_string();
let specs = detect_with_paths(&path_var, home_dir.path()).await;
assert!(specs.is_empty(), "expected no detections, got {specs:?}");
}
#[tokio::test]
async fn detect_picks_subscription_when_oauth_creds_present() {
let bin_dir = tempfile::TempDir::new().unwrap();
let home_dir = tempfile::TempDir::new().unwrap();
make_fake_bin(bin_dir.path(), "claude", "1.0.51");
let claude_dir = home_dir.path().join(".claude");
std::fs::create_dir_all(&claude_dir).unwrap();
std::fs::write(
claude_dir.join(".credentials.json"),
r#"{"oauthAccount": {"email": "[email protected]"}}"#,
)
.unwrap();
let path_var = bin_dir.path().to_string_lossy().to_string();
let specs = detect_with_paths_filtered(&path_var, home_dir.path(), &[]).await;
let claude = specs.iter().find(|s| s.id == "claude-code").unwrap();
assert!(matches!(
claude.auth_kind,
crate::types::AuthKind::Subscription
));
}
#[tokio::test]
async fn detect_picks_apikey_when_only_apikey_present() {
let bin_dir = tempfile::TempDir::new().unwrap();
let home_dir = tempfile::TempDir::new().unwrap();
make_fake_bin(bin_dir.path(), "claude", "1.0.51");
let claude_dir = home_dir.path().join(".claude");
std::fs::create_dir_all(&claude_dir).unwrap();
std::fs::write(
claude_dir.join(".credentials.json"),
r#"{"apiKey": "sk-ant-..."}"#,
)
.unwrap();
let path_var = bin_dir.path().to_string_lossy().to_string();
let specs = detect_with_paths_filtered(&path_var, home_dir.path(), &[]).await;
let claude = specs.iter().find(|s| s.id == "claude-code").unwrap();
assert!(matches!(claude.auth_kind, crate::types::AuthKind::ApiKey));
}
#[tokio::test]
async fn detect_rejects_scratch_dir_binaries() {
let tmp = std::env::temp_dir();
if !tmp.starts_with("/tmp") && !tmp.starts_with("/private/tmp") {
return;
}
let bin_dir = tempfile::TempDir::new_in("/tmp").unwrap();
let home_dir = tempfile::TempDir::new().unwrap();
make_fake_bin(bin_dir.path(), "claude", "1.0.51");
let path_var = bin_dir.path().to_string_lossy().to_string();
let specs = detect_with_paths(&path_var, home_dir.path()).await;
assert!(
specs.iter().all(|s| s.id != "claude-code"),
"scratch-dir binary must be rejected, got {specs:?}"
);
}
#[tokio::test]
async fn detect_keeps_entry_when_version_probe_fails() {
let bin_dir = tempfile::TempDir::new().unwrap();
let home_dir = tempfile::TempDir::new().unwrap();
make_failing_bin(bin_dir.path(), "claude");
let path_var = bin_dir.path().to_string_lossy().to_string();
let specs = detect_with_paths_filtered(&path_var, home_dir.path(), &[]).await;
let claude = specs.iter().find(|s| s.id == "claude-code");
assert!(claude.is_some(), "entry must survive failed version probe");
let claude = claude.unwrap();
assert!(claude.version.is_none());
assert!(
!is_not_executable(claude),
"non-zero exit must not be classified as unrunnable"
);
}
#[cfg(not(windows))]
fn make_unrunnable_bin(dir: &Path, name: &str) -> PathBuf {
let path = dir.join(name);
std::fs::write(&path, "#!/nonexistent/interpreter\n").unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
path
}
#[cfg(not(windows))]
#[tokio::test]
async fn unrunnable_binary_is_flagged_not_executable() {
let bin_dir = tempfile::TempDir::new().unwrap();
let home_dir = tempfile::TempDir::new().unwrap();
make_unrunnable_bin(bin_dir.path(), "claude");
let path_var = bin_dir.path().to_string_lossy().to_string();
let specs = detect_with_paths_filtered(&path_var, home_dir.path(), &[]).await;
let claude = specs
.iter()
.find(|s| s.id == "claude-code")
.expect("broken install must still be reported so the user can find it");
assert!(
is_not_executable(claude),
"a binary that cannot exec must be NotExecutable, got {:?}",
claude.health
);
let reason = claude.health.as_ref().unwrap().reason.as_deref().unwrap();
assert!(
reason.contains("claude"),
"reason must name the offending path, got {reason:?}"
);
}
#[cfg(not(windows))]
#[tokio::test]
async fn working_binary_later_on_path_beats_dead_one_first() {
let dead_dir = tempfile::TempDir::new().unwrap();
let live_dir = tempfile::TempDir::new().unwrap();
let home_dir = tempfile::TempDir::new().unwrap();
make_unrunnable_bin(dead_dir.path(), "claude");
make_fake_bin(live_dir.path(), "claude", "1.0.51 (Claude Code)");
let path_var = format!(
"{}:{}",
dead_dir.path().to_string_lossy(),
live_dir.path().to_string_lossy()
);
let specs = detect_with_paths_filtered(&path_var, home_dir.path(), &[]).await;
let claude = specs.iter().find(|s| s.id == "claude-code").unwrap();
assert_eq!(
claude.version.as_deref(),
Some("1.0.51"),
"must fall through to the working binary"
);
assert!(claude.binary_path.starts_with(live_dir.path()));
assert!(!is_not_executable(claude));
}
#[cfg(not(windows))]
#[tokio::test]
async fn duplicate_path_entries_probed_once() {
let bin_dir = tempfile::TempDir::new().unwrap();
make_fake_bin(bin_dir.path(), "claude", "1.0.51");
let dir = bin_dir.path().to_string_lossy().to_string();
let path_var = format!("{dir}:{dir}:{dir}");
let candidates = resolve_candidates("claude", &path_var, &[]);
assert_eq!(candidates.len(), 1, "got {candidates:?}");
}
#[cfg(unix)]
#[tokio::test]
async fn dangling_symlink_is_diagnosed_not_omitted() {
let bin_dir = tempfile::TempDir::new().unwrap();
let home_dir = tempfile::TempDir::new().unwrap();
let gone = bin_dir.path().join("relocated-app-binary");
std::os::unix::fs::symlink(&gone, bin_dir.path().join("claude")).unwrap();
let path_var = bin_dir.path().to_string_lossy().to_string();
let specs = detect_with_paths_filtered(&path_var, home_dir.path(), &[]).await;
let claude = specs
.iter()
.find(|s| s.id == "claude-code")
.expect("a dangling link must not read as 'not installed'");
assert!(is_not_executable(claude), "got {:?}", claude.health);
let reason = claude.health.as_ref().unwrap().reason.as_deref().unwrap();
assert!(
reason.contains("dangling symlink"),
"reason must identify the dangling link, got {reason:?}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn dangling_symlink_loses_to_a_working_binary() {
let dead_dir = tempfile::TempDir::new().unwrap();
let live_dir = tempfile::TempDir::new().unwrap();
let home_dir = tempfile::TempDir::new().unwrap();
std::os::unix::fs::symlink(dead_dir.path().join("gone"), dead_dir.path().join("claude"))
.unwrap();
make_fake_bin(live_dir.path(), "claude", "1.0.51 (Claude Code)");
let path_var = format!(
"{}:{}",
dead_dir.path().to_string_lossy(),
live_dir.path().to_string_lossy()
);
let specs = detect_with_paths_filtered(&path_var, home_dir.path(), &[]).await;
let claude = specs.iter().find(|s| s.id == "claude-code").unwrap();
assert_eq!(claude.version.as_deref(), Some("1.0.51"));
assert!(!is_not_executable(claude));
}
#[cfg(unix)]
#[tokio::test]
async fn non_kill_signal_death_is_inconclusive_not_unusable() {
let bin_dir = tempfile::TempDir::new().unwrap();
let home_dir = tempfile::TempDir::new().unwrap();
let path = bin_dir.path().join("claude");
std::fs::write(&path, "#!/bin/sh\nkill -TERM $$\n").unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
let path_var = bin_dir.path().to_string_lossy().to_string();
let specs = detect_with_paths_filtered(&path_var, home_dir.path(), &[]).await;
let claude = specs.iter().find(|s| s.id == "claude-code").unwrap();
assert!(
!is_not_executable(claude),
"SIGTERM must not be read as 'cannot execute', got {:?}",
claude.health
);
}
#[test]
fn pin_env_var_derives_from_adapter_id() {
assert_eq!(pin_env_var("codex"), "CAR_CODEX_BIN");
assert_eq!(pin_env_var("claude-code"), "CAR_CLAUDE_CODE_BIN");
assert_eq!(pin_env_var("gemini"), "CAR_GEMINI_BIN");
}
}