Files
agent-manager/src/probe.rs
T
bruno ee490f5eaf fix: détection externe — champ detect (sonde PATH explicite) pour les lanceurs interpréteurs
lazycodex (run: npx lazycodex-ai) et nanobot (run: python -m nanobot)
étaient déclarés « external » dès que npx/python étaient sur le PATH :
la sonde utilisait le premier mot de run au lieu du vrai binaire.

- AgentDef.detect : sonde PATH explicite, repli sur first_token sinon
- probe.rs : cache invalidé quand les tokens du catalogue changent
  (token_hash), plus besoin d'attendre un changement de PATH
- config.yaml : detect: lazycodex-ai / detect: nanobot
- tests : detect_token_prefers_explicit_binary + 432 tests verts
- v1.0.3, man pages régénérées
2026-08-20 10:53:59 -04:00

354 lines
12 KiB
Rust

//! External agent detection: PATH lookup for unmanaged agents, version
//! probing in parallel (one thread per binary), and an on-disk cache keyed
//! by the PATH string and by binary identity (path + mtime + size), so
//! repeated runs never re-scan the PATH or re-probe versions.
use crate::app::App;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use wait_timeout::ChildExt;
#[derive(Debug, Default)]
pub struct ExternalInfo {
/// agent name -> binary path (catalog agents found on PATH, unmanaged).
pub paths: BTreeMap<String, PathBuf>,
/// agent name -> detected version (None = unknown).
pub versions: BTreeMap<String, Option<String>>,
}
impl ExternalInfo {
pub fn path_of(&self, name: &str) -> Option<&PathBuf> {
self.paths.get(name)
}
pub fn version_of(&self, name: &str) -> Option<&Option<String>> {
self.versions.get(name)
}
}
/// Detect the version of an external binary (--version), bounded by a
/// timeout so a misbehaving agent can never hang the whole listing.
pub fn detect_external_version(bin_path: &Path) -> Option<String> {
use std::io::Read;
let (prog, prefix) = crate::runner::resolve_program(&bin_path.display().to_string());
let mut cmd = std::process::Command::new(&prog);
cmd.args(&prefix)
.arg("--version")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let Ok(mut child) = cmd.spawn() else {
return None;
};
const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(4);
let status: Option<std::process::ExitStatus> = match child.wait_timeout(PROBE_TIMEOUT) {
Ok(Some(s)) => Some(s),
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
None
}
Err(_) => None,
};
let Some(status) = status else {
return None;
};
if !status.success() {
return None;
}
let mut stdout = String::new();
let mut stderr = String::new();
if let Some(mut o) = child.stdout.take() {
let _ = o.read_to_string(&mut stdout);
}
if let Some(mut e) = child.stderr.take() {
let _ = e.read_to_string(&mut stderr);
}
let combined = format!("{stdout}
{stderr}");
crate::version::find_version(&combined)
}
// ---------------------------------------------------------------------------
// On-disk cache
// ---------------------------------------------------------------------------
#[derive(Debug, Default, Serialize, Deserialize)]
struct ProbeCache {
/// FNV-1a hash of the PATH string the token index was built from.
#[serde(default)]
path_hash: u64,
/// FNV-1a hash of the catalog's detect tokens. When the catalog changes
/// which binaries it probes (new agent, `detect` added), the token
/// index must be rebuilt even if the PATH is unchanged.
#[serde(default)]
token_hash: u64,
/// Catalog run-command first token -> resolved path (null = not found).
#[serde(default)]
tokens: BTreeMap<String, Option<String>>,
/// Binary identity key -> detected version.
#[serde(default)]
versions: BTreeMap<String, Option<String>>,
}
fn path_hash() -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
if let Some(p) = std::env::var_os("PATH") {
for b in p.to_string_lossy().bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
}
h
}
/// Hash of the sorted catalog detect tokens: cache invalidation for catalog
/// changes (new agent, added `detect` field) without re-scanning the PATH.
fn catalog_token_hash(app: &App) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
let mut tokens: Vec<String> = app
.catalog
.agents()
.iter()
.filter_map(|a| a.detect_token())
.collect();
tokens.sort();
for t in tokens {
for b in t.bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
}
h
}
/// Build an index of every executable on the PATH in a single pass.
/// Much cheaper than one which() call per agent when the catalog is large.
fn build_path_index() -> BTreeMap<String, PathBuf> {
let mut map: BTreeMap<String, PathBuf> = BTreeMap::new();
let Some(path) = std::env::var_os("PATH") else {
return map;
};
for dir in std::env::split_paths(&path) {
let Ok(rd) = std::fs::read_dir(&dir) else {
continue;
};
for entry in rd.flatten() {
let p = entry.path();
if !p.is_file() {
continue;
}
let Some(name) = p.file_name().and_then(|n| n.to_str()) else {
continue;
};
let lower = name.to_lowercase();
#[cfg(windows)]
{
let stem = if lower.ends_with(".exe") {
lower[..lower.len() - 4].to_string()
} else if lower.ends_with(".cmd") {
lower[..lower.len() - 4].to_string()
} else if lower.ends_with(".bat") {
lower[..lower.len() - 4].to_string()
} else if lower.ends_with(".com") {
lower[..lower.len() - 4].to_string()
} else {
continue;
};
map.entry(stem).or_insert(p);
}
#[cfg(not(windows))]
{
use std::os::unix::fs::PermissionsExt;
let executable = std::fs::metadata(&p)
.map(|m| m.permissions().mode() & 0o111 != 0)
.unwrap_or(false);
if executable {
map.entry(lower).or_insert(p);
}
}
}
}
map
}
/// Detect every unmanaged catalog agent present on the PATH.
///
/// The PATH is scanned once and its result cached (invalidated by the PATH
/// string hash); version probes run in parallel and are cached by binary
/// identity. A steady-state run does no PATH scanning and no subprocess.
pub fn detect_externals(app: &App) -> ExternalInfo {
let managed: BTreeMap<String, crate::state::InstalledEntry> =
app.state.all().unwrap_or_default();
let cache_path = app.paths.probe_cache_file.clone();
let mut cache: ProbeCache = std::fs::read_to_string(&cache_path)
.ok()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_default();
let mut dirty = false;
// Resolve the detect tokens of the catalog (once per PATH change or
// catalog change).
let current_hash = path_hash();
let current_tokens = catalog_token_hash(app);
if cache.path_hash != current_hash || cache.token_hash != current_tokens {
let index = build_path_index();
cache.tokens.clear();
for agent in app.catalog.agents() {
let Some(token) = agent.detect_token() else {
continue;
};
if !cache.tokens.contains_key(&token) {
let found = index
.get(&token.to_lowercase())
.map(|p| p.display().to_string());
cache.tokens.insert(token, found);
}
}
cache.path_hash = current_hash;
cache.token_hash = current_tokens;
dirty = true;
}
let mut info = ExternalInfo::default();
// Stale cached paths (binary removed since the cache was built) force a
// rebuild of the token index.
let stale = cache
.tokens
.values()
.flatten()
.any(|p| !Path::new(p).exists());
if stale {
let index = build_path_index();
cache.tokens.clear();
for agent in app.catalog.agents() {
let Some(token) = agent.detect_token() else {
continue;
};
if !cache.tokens.contains_key(&token) {
let found = index
.get(&token.to_lowercase())
.map(|p| p.display().to_string());
cache.tokens.insert(token, found);
}
}
dirty = true;
}
for agent in app.catalog.agents() {
if agent.hidden || !agent.installable || managed.contains_key(&agent.name) {
continue;
}
let Some(token) = agent.detect_token() else {
continue;
};
if let Some(Some(path)) = cache.tokens.get(&token) {
info.paths.insert(agent.name.clone(), PathBuf::from(path));
}
}
// Versions: on-disk cache first, then parallel probes for the misses.
let mut missing: Vec<(String, PathBuf)> = Vec::new();
for (name, bin) in &info.paths {
let key = bin_key(bin);
match cache.versions.get(&key) {
Some(v) => {
info.versions.insert(name.clone(), v.clone());
}
None => missing.push((name.clone(), bin.clone())),
}
}
if !missing.is_empty() {
let results = std::sync::Mutex::new(Vec::new());
std::thread::scope(|s| {
for (name, bin) in &missing {
let results = &results;
let name = name.clone();
let bin = bin.clone();
s.spawn(move || {
let version = detect_external_version(&bin);
results.lock().unwrap().push((name, bin, version));
});
}
});
for (name, bin, version) in results.into_inner().unwrap_or_default() {
let key = bin_key(&bin);
cache.versions.insert(key.clone(), version.clone());
info.versions.insert(name, version);
dirty = true;
}
}
// Keep the caches bounded.
if cache.versions.len() > 500 {
cache.versions.clear();
dirty = true;
}
if dirty {
if let Ok(text) = serde_json::to_string_pretty(&cache) {
if let Some(parent) = cache_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&cache_path, text);
}
}
info
}
/// Identity key for a binary: path + modification time + size, so a stale
/// cache entry is never reused after the binary changed.
fn bin_key(bin: &Path) -> String {
let meta = std::fs::metadata(bin).ok();
let mtime = meta
.as_ref()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
let size = meta.map(|m| m.len()).unwrap_or(0);
format!("{}|{mtime}|{size}", bin.display())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bin_key_is_stable_and_distinct() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.exe");
std::fs::write(&a, b"abc").unwrap();
let k1 = bin_key(&a);
let k2 = bin_key(&a);
assert_eq!(k1, k2);
std::fs::write(&a, b"abcd").unwrap();
let k3 = bin_key(&a);
assert_ne!(k1, k3);
}
#[test]
fn probe_cache_roundtrip() {
let mut cache = ProbeCache::default();
cache
.versions
.insert("C:/x|1|2".to_string(), Some("1.2.3".to_string()));
cache.tokens.insert("node".to_string(), None);
let text = serde_json::to_string(&cache).unwrap();
let back: ProbeCache = serde_json::from_str(&text).unwrap();
assert_eq!(
back.versions.get("C:/x|1|2").unwrap().as_deref(),
Some("1.2.3")
);
assert!(back.tokens.contains_key("node"));
}
#[test]
fn old_cache_format_is_ignored() {
// The previous cache format had only an "entries" field; loading it
// must not fail.
let text = r#"{ "entries": { "a": "1.0" } }"#;
let back: ProbeCache = serde_json::from_str(text).unwrap();
assert!(back.versions.is_empty());
assert!(back.tokens.is_empty());
}
}