Skip to main content

brainos_delegate/
discovery.rs

1//! Agent auto-discovery — scan `$PATH` for known CLI agents and probe
2//! their versions in parallel, so the registry can populate itself
3//! without user configuration.
4//!
5//! Discovery is data-only: it produces a [`DiscoveredBinary`] list.
6//! Turning those into [`AgentDelegate`] instances happens in the
7//! registry layer, which can also merge user overrides on top.
8
9use std::collections::HashSet;
10use std::path::{Path, PathBuf};
11use std::process::Stdio;
12use std::time::Duration;
13
14use tokio::process::Command;
15use tokio::task::JoinSet;
16use tokio::time::timeout;
17
18use crate::traits::AgentCapabilities;
19
20/// Per-binary version probe deadline. 3s gives Node-based CLIs
21/// (gemini-cli, qwen-code, opencode) enough time to cold-start and
22/// print --version; faster CLIs still return well under the cap.
23pub const DEFAULT_PROBE_TIMEOUT: Duration = Duration::from_millis(3000);
24
25/// How a discovered agent should be wired into a runnable delegate.
26///
27/// Defaults here are best-effort from public CLI conventions — users
28/// with unusual flags can override args/stdin through config.
29#[derive(Debug, Clone)]
30pub struct InvocationTemplate {
31    /// Args passed to the binary. `{prompt}` and `{task_id}` are
32    /// substituted at spawn time.
33    pub args: &'static [&'static str],
34    /// Whether the rendered prompt is written to the child's stdin
35    /// instead of being templated into `args`.
36    pub prompt_via_stdin: bool,
37}
38
39/// A static description of one known agent family. The discovery pass
40/// tries each `binary_names` entry on `$PATH`; the first hit is probed
41/// and the result becomes a [`DiscoveredBinary`].
42#[derive(Debug, Clone)]
43pub struct AgentFingerprint {
44    /// Canonical id used by the registry (e.g. `"aider"`).
45    pub id: &'static str,
46    /// Candidate binary names, in priority order.
47    pub binary_names: &'static [&'static str],
48    /// Args passed to the binary to extract a version banner. Reused as
49    /// the default `version_args` on the resulting delegate.
50    pub version_args: &'static [&'static str],
51    /// Default capabilities attached to a discovered agent. The
52    /// registry is free to override these from config.
53    pub capabilities: AgentCapabilities,
54    /// Default invocation shape used when the registry constructs a
55    /// runnable delegate from this fingerprint.
56    pub invocation: InvocationTemplate,
57}
58
59/// Why a candidate binary isn't usable.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum DiscoveryStatus {
62    /// Binary found on `$PATH` and the version probe succeeded.
63    Available,
64    /// Binary was located but the probe failed (timeout, non-zero exit,
65    /// missing runtime deps). The reason is operator-facing.
66    Unavailable(String),
67}
68
69/// Outcome of discovery for one fingerprinted agent.
70#[derive(Debug, Clone)]
71pub struct DiscoveredBinary {
72    pub agent_id: String,
73    /// The name actually found on `$PATH` (e.g. when the fingerprint
74    /// listed several aliases for the same binary, the one that matched).
75    pub binary_name: String,
76    pub path: PathBuf,
77    /// First non-empty line of the version probe's stdout, trimmed.
78    pub version: Option<String>,
79    pub status: DiscoveryStatus,
80    pub capabilities: AgentCapabilities,
81    pub invocation: InvocationTemplate,
82    /// Args used to probe the binary's version. Reused as the default
83    /// `version_args` on the resulting delegate's health probe.
84    pub version_args: Vec<String>,
85}
86
87impl DiscoveredBinary {
88    pub fn is_available(&self) -> bool {
89        matches!(self.status, DiscoveryStatus::Available)
90    }
91}
92
93/// Walks directories (usually from `$PATH`) looking for executable
94/// files by name. Caches the directory list so repeated lookups
95/// during a single discovery pass don't re-read the env var.
96#[derive(Debug, Clone)]
97pub struct PathScanner {
98    dirs: Vec<PathBuf>,
99}
100
101impl PathScanner {
102    /// Build a scanner from the current process `$PATH`. Empty on
103    /// platforms where `PATH` is unset.
104    pub fn from_env() -> Self {
105        let dirs = std::env::var_os("PATH")
106            .map(|p| std::env::split_paths(&p).collect())
107            .unwrap_or_default();
108        Self { dirs }
109    }
110
111    pub fn from_dirs(dirs: Vec<PathBuf>) -> Self {
112        Self { dirs }
113    }
114
115    pub fn dirs(&self) -> &[PathBuf] {
116        &self.dirs
117    }
118
119    /// First executable entry matching `name`, walking directories in
120    /// `$PATH` order. Skips unreadable directories silently.
121    pub fn find_first(&self, name: &str) -> Option<PathBuf> {
122        for dir in &self.dirs {
123            let candidate = dir.join(name);
124            if is_executable(&candidate) {
125                return Some(candidate);
126            }
127        }
128        None
129    }
130}
131
132#[cfg(unix)]
133fn is_executable(path: &Path) -> bool {
134    use std::os::unix::fs::PermissionsExt;
135    match std::fs::metadata(path) {
136        Ok(m) => m.is_file() && m.permissions().mode() & 0o111 != 0,
137        Err(_) => false,
138    }
139}
140
141#[cfg(not(unix))]
142fn is_executable(path: &Path) -> bool {
143    std::fs::metadata(path)
144        .map(|m| m.is_file())
145        .unwrap_or(false)
146}
147
148/// Top-level discovery coordinator. Holds the fingerprint table and
149/// probe budget; one instance can be reused across SIGHUP refreshes.
150#[derive(Debug, Clone)]
151pub struct DelegateDiscovery {
152    scanner: PathScanner,
153    fingerprints: Vec<AgentFingerprint>,
154    probe_timeout: Duration,
155}
156
157impl DelegateDiscovery {
158    /// Default: scan `$PATH`, use the built-in fingerprints, 500ms probe
159    /// budget per binary.
160    pub fn new() -> Self {
161        Self {
162            scanner: PathScanner::from_env(),
163            fingerprints: default_fingerprints(),
164            probe_timeout: DEFAULT_PROBE_TIMEOUT,
165        }
166    }
167
168    pub fn with_scanner(mut self, scanner: PathScanner) -> Self {
169        self.scanner = scanner;
170        self
171    }
172
173    pub fn with_fingerprints(mut self, fps: Vec<AgentFingerprint>) -> Self {
174        self.fingerprints = fps;
175        self
176    }
177
178    pub fn with_probe_timeout(mut self, d: Duration) -> Self {
179        self.probe_timeout = d;
180        self
181    }
182
183    pub fn fingerprints(&self) -> &[AgentFingerprint] {
184        &self.fingerprints
185    }
186
187    /// Run the discovery pass. Returns one entry per fingerprint whose
188    /// binary was found on `$PATH` — fingerprints with no hit are
189    /// omitted (callers treat absence as "not installed"). Unavailable
190    /// entries *are* included so the operator can see "installed but
191    /// broken" in doctor reports.
192    pub async fn discover(&self) -> Vec<DiscoveredBinary> {
193        let mut found: Vec<(AgentFingerprint, String, PathBuf)> = Vec::new();
194        let mut seen_paths: HashSet<PathBuf> = HashSet::new();
195
196        for fp in &self.fingerprints {
197            for name in fp.binary_names {
198                if let Some(path) = self.scanner.find_first(name) {
199                    // Same binary matched twice (e.g. `claude` and
200                    // `claude-code` both pointing at the same file) —
201                    // keep the first hit only.
202                    if seen_paths.insert(path.clone()) {
203                        found.push((fp.clone(), (*name).to_string(), path));
204                    }
205                    break;
206                }
207            }
208        }
209
210        let mut set: JoinSet<DiscoveredBinary> = JoinSet::new();
211        let probe_timeout = self.probe_timeout;
212        for (fp, binary_name, path) in found {
213            let version_args: Vec<String> = fp.version_args.iter().map(|s| s.to_string()).collect();
214            set.spawn(async move {
215                let (status, version) = probe(&path, &version_args, probe_timeout).await;
216                DiscoveredBinary {
217                    agent_id: fp.id.to_string(),
218                    binary_name,
219                    path,
220                    version,
221                    status,
222                    capabilities: fp.capabilities,
223                    invocation: fp.invocation,
224                    version_args,
225                }
226            });
227        }
228
229        let mut out = Vec::new();
230        while let Some(res) = set.join_next().await {
231            match res {
232                Ok(b) => out.push(b),
233                Err(e) => tracing::warn!(error = %e, "discovery probe task panicked"),
234            }
235        }
236        out.sort_by(|a, b| a.agent_id.cmp(&b.agent_id));
237        out
238    }
239}
240
241impl Default for DelegateDiscovery {
242    fn default() -> Self {
243        Self::new()
244    }
245}
246
247async fn probe(
248    path: &Path,
249    version_args: &[String],
250    budget: Duration,
251) -> (DiscoveryStatus, Option<String>) {
252    // Linux can return ETXTBSY (errno 26, "Text file busy") when execing a
253    // file whose write/close just happened — kernel-side state hasn't fully
254    // propagated. Hits in CI right after `std::fs::write` and in real
255    // life right after a fresh `cargo install`. A handful of short retries
256    // smooths it out without changing observable behavior elsewhere.
257    let mut attempt = 0u32;
258    loop {
259        let fut = Command::new(path)
260            .args(version_args)
261            .stdin(Stdio::null())
262            .stdout(Stdio::piped())
263            .stderr(Stdio::piped())
264            .kill_on_drop(true)
265            .output();
266
267        match timeout(budget, fut).await {
268            Ok(Ok(out)) if out.status.success() => {
269                let text = String::from_utf8_lossy(&out.stdout);
270                let v = text
271                    .lines()
272                    .map(str::trim)
273                    .find(|l| !l.is_empty())
274                    .map(|l| l.to_string());
275                return (DiscoveryStatus::Available, v);
276            }
277            Ok(Ok(out)) => {
278                let code = out.status.code().unwrap_or(-1);
279                let stderr = String::from_utf8_lossy(&out.stderr);
280                let msg = stderr.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
281                let reason = if msg.is_empty() {
282                    format!("version probe exited {code}")
283                } else {
284                    format!("version probe exited {code}: {}", msg.trim())
285                };
286                return (DiscoveryStatus::Unavailable(reason), None);
287            }
288            Ok(Err(e)) if e.raw_os_error() == Some(26) && attempt < 4 => {
289                tokio::time::sleep(Duration::from_millis(20 * (attempt as u64 + 1))).await;
290                attempt += 1;
291                continue;
292            }
293            Ok(Err(e)) => {
294                return (
295                    DiscoveryStatus::Unavailable(format!("probe spawn failed: {e}")),
296                    None,
297                );
298            }
299            Err(_) => {
300                return (
301                    DiscoveryStatus::Unavailable(format!("probe timed out after {budget:?}")),
302                    None,
303                );
304            }
305        }
306    }
307}
308
309/// Built-in fingerprints shipped with Brain. Extend this list when a new
310/// CLI agent becomes common enough to warrant zero-config wiring.
311pub fn default_fingerprints() -> Vec<AgentFingerprint> {
312    vec![
313        AgentFingerprint {
314            id: "claude-code",
315            binary_names: &["claude", "claude-code"],
316            version_args: &["--version"],
317            capabilities: AgentCapabilities {
318                tags: vec!["code-edit".to_string(), "plan".to_string()],
319                languages: vec!["rust".to_string(), "typescript".to_string()],
320                max_concurrency: 1,
321                needs_network: true,
322            },
323            // `-p -` reads the prompt from stdin.
324            invocation: InvocationTemplate {
325                args: &["-p", "-"],
326                prompt_via_stdin: true,
327            },
328        },
329        AgentFingerprint {
330            id: "codex",
331            binary_names: &["codex", "codex-cli"],
332            version_args: &["--version"],
333            capabilities: AgentCapabilities {
334                tags: vec!["code-edit".to_string()],
335                languages: vec![],
336                max_concurrency: 1,
337                needs_network: true,
338            },
339            // `codex exec` reads the prompt from stdin.
340            invocation: InvocationTemplate {
341                args: &["exec", "-"],
342                prompt_via_stdin: true,
343            },
344        },
345        AgentFingerprint {
346            id: "aider",
347            binary_names: &["aider"],
348            version_args: &["--version"],
349            capabilities: AgentCapabilities {
350                tags: vec!["code-edit".to_string()],
351                languages: vec![],
352                max_concurrency: 1,
353                needs_network: true,
354            },
355            // aider takes its prompt via `--message`, runs non-interactive with `--yes`.
356            invocation: InvocationTemplate {
357                args: &["--yes", "--no-stream", "--message", "{prompt}"],
358                prompt_via_stdin: false,
359            },
360        },
361        AgentFingerprint {
362            id: "qwen-code",
363            binary_names: &["qwen", "qwen-code"],
364            version_args: &["--version"],
365            capabilities: AgentCapabilities {
366                tags: vec!["code-edit".to_string()],
367                languages: vec![],
368                max_concurrency: 1,
369                needs_network: true,
370            },
371            // qwen-code follows the `-p -` convention for piped prompts.
372            invocation: InvocationTemplate {
373                args: &["-p", "-"],
374                prompt_via_stdin: true,
375            },
376        },
377        AgentFingerprint {
378            id: "gemini-cli",
379            binary_names: &["gemini"],
380            version_args: &["--version"],
381            capabilities: AgentCapabilities {
382                tags: vec!["code-edit".to_string()],
383                languages: vec![],
384                max_concurrency: 1,
385                needs_network: true,
386            },
387            invocation: InvocationTemplate {
388                args: &["-p", "-"],
389                prompt_via_stdin: true,
390            },
391        },
392        AgentFingerprint {
393            id: "opencode",
394            binary_names: &["opencode"],
395            version_args: &["--version"],
396            capabilities: AgentCapabilities {
397                tags: vec!["code-edit".to_string()],
398                languages: vec![],
399                max_concurrency: 1,
400                needs_network: true,
401            },
402            invocation: InvocationTemplate {
403                args: &["run", "-"],
404                prompt_via_stdin: true,
405            },
406        },
407    ]
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    #[test]
415    fn path_scanner_finds_executable() {
416        // Use a directory with a known layout via tempfile so the test
417        // doesn't depend on host `$PATH`.
418        let dir = tempfile::tempdir().unwrap();
419        let bin = dir.path().join("fakeagent");
420        std::fs::write(&bin, "#!/bin/sh\necho ok\n").unwrap();
421        #[cfg(unix)]
422        {
423            use std::os::unix::fs::PermissionsExt;
424            let mut p = std::fs::metadata(&bin).unwrap().permissions();
425            p.set_mode(0o755);
426            std::fs::set_permissions(&bin, p).unwrap();
427        }
428        let scanner = PathScanner::from_dirs(vec![dir.path().to_path_buf()]);
429        assert_eq!(scanner.find_first("fakeagent"), Some(bin));
430        assert!(scanner.find_first("nope").is_none());
431    }
432
433    #[cfg(unix)]
434    #[test]
435    fn path_scanner_skips_non_executable_files() {
436        let dir = tempfile::tempdir().unwrap();
437        let bin = dir.path().join("not-exec");
438        std::fs::write(&bin, "hi").unwrap();
439        let scanner = PathScanner::from_dirs(vec![dir.path().to_path_buf()]);
440        assert!(scanner.find_first("not-exec").is_none());
441    }
442
443    #[test]
444    fn default_fingerprints_are_non_empty_and_unique() {
445        let fps = default_fingerprints();
446        assert!(!fps.is_empty());
447        let ids: HashSet<_> = fps.iter().map(|f| f.id).collect();
448        assert_eq!(ids.len(), fps.len(), "fingerprint ids must be unique");
449        for fp in &fps {
450            assert!(!fp.binary_names.is_empty(), "{} has no binary names", fp.id);
451            assert!(!fp.version_args.is_empty(), "{} has no version args", fp.id);
452        }
453    }
454
455    #[cfg(unix)]
456    #[tokio::test]
457    async fn discover_reports_available_for_working_probe() {
458        let dir = tempfile::tempdir().unwrap();
459        let bin = dir.path().join("faux-claude");
460        std::fs::write(&bin, "#!/bin/sh\necho 'faux-claude 1.2.3'\n").unwrap();
461        use std::os::unix::fs::PermissionsExt;
462        let mut p = std::fs::metadata(&bin).unwrap().permissions();
463        p.set_mode(0o755);
464        std::fs::set_permissions(&bin, p).unwrap();
465
466        let fp = AgentFingerprint {
467            id: "faux",
468            binary_names: &["faux-claude"],
469            version_args: &["--version"],
470            capabilities: AgentCapabilities::default(),
471            invocation: InvocationTemplate {
472                args: &[],
473                prompt_via_stdin: true,
474            },
475        };
476        let discovery = DelegateDiscovery::new()
477            .with_scanner(PathScanner::from_dirs(vec![dir.path().to_path_buf()]))
478            .with_fingerprints(vec![fp])
479            .with_probe_timeout(Duration::from_secs(2));
480        let results = discovery.discover().await;
481        assert_eq!(results.len(), 1);
482        assert_eq!(results[0].agent_id, "faux");
483        assert_eq!(results[0].status, DiscoveryStatus::Available);
484        assert_eq!(results[0].version.as_deref(), Some("faux-claude 1.2.3"));
485    }
486
487    #[cfg(unix)]
488    #[tokio::test]
489    async fn discover_marks_failing_probe_unavailable() {
490        let dir = tempfile::tempdir().unwrap();
491        let bin = dir.path().join("broken-agent");
492        std::fs::write(&bin, "#!/bin/sh\necho 'bad happened' 1>&2\nexit 2\n").unwrap();
493        use std::os::unix::fs::PermissionsExt;
494        let mut p = std::fs::metadata(&bin).unwrap().permissions();
495        p.set_mode(0o755);
496        std::fs::set_permissions(&bin, p).unwrap();
497
498        let fp = AgentFingerprint {
499            id: "broken",
500            binary_names: &["broken-agent"],
501            version_args: &["--version"],
502            capabilities: AgentCapabilities::default(),
503            invocation: InvocationTemplate {
504                args: &[],
505                prompt_via_stdin: true,
506            },
507        };
508        let discovery = DelegateDiscovery::new()
509            .with_scanner(PathScanner::from_dirs(vec![dir.path().to_path_buf()]))
510            .with_fingerprints(vec![fp])
511            .with_probe_timeout(Duration::from_secs(2));
512        let results = discovery.discover().await;
513        assert_eq!(results.len(), 1);
514        match &results[0].status {
515            DiscoveryStatus::Unavailable(reason) => {
516                assert!(reason.contains("exited 2"), "reason was {reason}");
517            }
518            other => panic!("expected Unavailable, got {other:?}"),
519        }
520        assert!(results[0].version.is_none());
521    }
522
523    #[cfg(unix)]
524    #[tokio::test]
525    async fn discover_times_out_hung_probe() {
526        let dir = tempfile::tempdir().unwrap();
527        let bin = dir.path().join("hang-agent");
528        std::fs::write(&bin, "#!/bin/sh\nsleep 5\n").unwrap();
529        use std::os::unix::fs::PermissionsExt;
530        let mut p = std::fs::metadata(&bin).unwrap().permissions();
531        p.set_mode(0o755);
532        std::fs::set_permissions(&bin, p).unwrap();
533
534        let fp = AgentFingerprint {
535            id: "hang",
536            binary_names: &["hang-agent"],
537            version_args: &["--version"],
538            capabilities: AgentCapabilities::default(),
539            invocation: InvocationTemplate {
540                args: &[],
541                prompt_via_stdin: true,
542            },
543        };
544        let discovery = DelegateDiscovery::new()
545            .with_scanner(PathScanner::from_dirs(vec![dir.path().to_path_buf()]))
546            .with_fingerprints(vec![fp])
547            .with_probe_timeout(Duration::from_millis(100));
548        let results = discovery.discover().await;
549        assert_eq!(results.len(), 1);
550        match &results[0].status {
551            DiscoveryStatus::Unavailable(reason) => {
552                assert!(reason.contains("timed out"), "reason was {reason}");
553            }
554            other => panic!("expected Unavailable, got {other:?}"),
555        }
556    }
557
558    #[cfg(unix)]
559    #[tokio::test]
560    async fn discover_dedupes_when_two_fingerprints_hit_same_file() {
561        // Set up two names pointing at the same executable; only the first
562        // fingerprint that matches should register it.
563        let dir = tempfile::tempdir().unwrap();
564        let bin = dir.path().join("shared");
565        std::fs::write(&bin, "#!/bin/sh\necho 'shared 1.0'\n").unwrap();
566        use std::os::unix::fs::PermissionsExt;
567        let mut p = std::fs::metadata(&bin).unwrap().permissions();
568        p.set_mode(0o755);
569        std::fs::set_permissions(&bin, p).unwrap();
570
571        let fp_a = AgentFingerprint {
572            id: "alpha",
573            binary_names: &["shared"],
574            version_args: &["--version"],
575            capabilities: AgentCapabilities::default(),
576            invocation: InvocationTemplate {
577                args: &[],
578                prompt_via_stdin: true,
579            },
580        };
581        let fp_b = AgentFingerprint {
582            id: "beta",
583            binary_names: &["shared"],
584            version_args: &["--version"],
585            capabilities: AgentCapabilities::default(),
586            invocation: InvocationTemplate {
587                args: &[],
588                prompt_via_stdin: true,
589            },
590        };
591        let discovery = DelegateDiscovery::new()
592            .with_scanner(PathScanner::from_dirs(vec![dir.path().to_path_buf()]))
593            .with_fingerprints(vec![fp_a, fp_b])
594            .with_probe_timeout(Duration::from_secs(2));
595        let results = discovery.discover().await;
596        assert_eq!(results.len(), 1);
597        assert_eq!(results[0].agent_id, "alpha");
598    }
599}