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
354 lines
12 KiB
Rust
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());
|
|
}
|
|
}
|