Skip to main content

agent_profile/
exe.rs

1// Copyright (c) 2026 Costas Kirgoussios
2//
3// This software is licensed under the PolyForm Noncommercial License 1.0.0.
4// You may obtain a copy of the License at:
5// https://polyformproject.org/licenses/noncommercial/1.0.0/
6
7//! Executable discovery (spec §20): an explicit configured override, then the absolute entries of `PATH`.
8
9use std::ffi::OsStr;
10use std::fs;
11use std::path::{Path, PathBuf};
12
13use crate::adapter::AdapterMetadata;
14use crate::error::{Error, NotInstalledReason, Result};
15
16/// Where a discovered executable came from.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Origin {
19    Configured,
20    Path,
21}
22
23/// A discovered executable.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct Found {
26    pub path: PathBuf,
27    pub origin: Origin,
28}
29
30/// Where `agents` resolved an agent's executable to (SP5a design §3). Each variant is a listed fact,
31/// not an error.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum ExecutableStatus {
34    /// `exe::discover` resolved it: the path, and whether it came from the configuration or `PATH`.
35    Found(Found),
36    /// Nothing configured, and no absolute `PATH` entry holds it.
37    NotOnPath,
38    /// The configured path is not a file.
39    ConfiguredMissing(PathBuf),
40    /// The resolved file would launch through a shell (a `.cmd`/`.bat`/`.ps1` script or shim).
41    Unsupported(PathBuf),
42}
43
44impl ExecutableStatus {
45    /// Resolves an agent's executable to a listed fact. Not-installed and unsupported outcomes become
46    /// variants; any other discovery error propagates.
47    pub fn discover(
48        metadata: &AdapterMetadata,
49        explicit: Option<&Path>,
50        path_var: Option<&OsStr>,
51        config_file: &Path,
52    ) -> Result<ExecutableStatus> {
53        match discover(metadata.id, metadata.executable, explicit, path_var, config_file) {
54            Ok(found) => Ok(ExecutableStatus::Found(found)),
55            Err(Error::AgentNotInstalled {
56                reason: NotInstalledReason::NotOnPath { .. }, ..
57            }) => Ok(ExecutableStatus::NotOnPath),
58            Err(Error::AgentNotInstalled {
59                reason: NotInstalledReason::ExplicitMissing(path),
60                ..
61            }) => Ok(ExecutableStatus::ConfiguredMissing(path)),
62            Err(Error::UnsupportedExecutable { path, .. }) => {
63                Ok(ExecutableStatus::Unsupported(path))
64            }
65            Err(error) => Err(error),
66        }
67    }
68}
69
70/// Extensions std would run through a shell or that need an interpreter: refused on every platform
71/// (spec §23.2, SP2 design §7.2).
72const SHELL_EXTENSIONS: [&str; 3] = ["bat", "cmd", "ps1"];
73
74/// Windows `PATH` forms checked in each directory; the first directory holding any of them decides.
75#[cfg(windows)]
76const WINDOWS_FORMS: [&str; 5] = ["exe", "com", "cmd", "bat", "ps1"];
77
78/// Finds `name` for `agent`: the `explicit` override when given, otherwise the first absolute `PATH`
79/// directory that holds it. A shell script or a Windows shim is refused with a hint naming `config_file`.
80pub fn discover(
81    agent: &str,
82    name: &str,
83    explicit: Option<&Path>,
84    path_var: Option<&OsStr>,
85    config_file: &Path,
86) -> Result<Found> {
87    let found = match explicit {
88        Some(path) => {
89            if !fs::metadata(path).is_ok_and(|metadata| metadata.is_file()) {
90                return Err(not_installed(
91                    agent,
92                    NotInstalledReason::ExplicitMissing(path.to_path_buf()),
93                ));
94            }
95            Found { path: path.to_path_buf(), origin: Origin::Configured }
96        }
97        None => search_path(agent, name, path_var, config_file)?,
98    };
99    if has_shell_extension(&found.path) {
100        return Err(unsupported(agent, found.path, config_file));
101    }
102    Ok(found)
103}
104
105/// Whether the file name ends in a shell extension. Trailing spaces and dots are ignored first, because
106/// Windows strips them when it opens or launches the path (`codex.cmd ` runs `codex.cmd` through cmd.exe).
107fn has_shell_extension(path: &Path) -> bool {
108    let Some(name) = path.file_name() else { return false };
109    let name = name.to_string_lossy();
110    let Some((_, ext)) = name.trim_end_matches([' ', '.']).rsplit_once('.') else { return false };
111    SHELL_EXTENSIONS.iter().any(|shell| ext.eq_ignore_ascii_case(shell))
112}
113
114fn search_path(
115    agent: &str,
116    name: &str,
117    path_var: Option<&OsStr>,
118    config_file: &Path,
119) -> Result<Found> {
120    // Only a Windows shim on `PATH` needs the hint.
121    #[cfg(not(windows))]
122    let _ = config_file;
123    let mut ignored_relative = 0;
124    for dir in path_var.into_iter().flat_map(std::env::split_paths) {
125        if dir.as_os_str().is_empty() {
126            continue;
127        }
128        // A relative entry resolves against the working directory: repository-local discovery (spec §20, §36).
129        if !dir.is_absolute() {
130            ignored_relative += 1;
131            continue;
132        }
133        match search_dir(&dir, name) {
134            Some(Hit::Native(path)) => return Ok(Found { path, origin: Origin::Path }),
135            #[cfg(windows)]
136            Some(Hit::Unsupported(path)) => return Err(unsupported(agent, path, config_file)),
137            None => {}
138        }
139    }
140    Err(not_installed(agent, NotInstalledReason::NotOnPath { ignored_relative }))
141}
142
143/// Finds `name` through the RELATIVE entries of `path_var`, each resolved against `cwd`, in order; the first
144/// native (or, on Windows, unsupported) form wins. Absolute entries are skipped. Never runs anything.
145pub fn repository_local(name: &str, path_var: Option<&OsStr>, cwd: &Path) -> Option<PathBuf> {
146    for dir in path_var.into_iter().flat_map(std::env::split_paths) {
147        if dir.as_os_str().is_empty() || dir.is_absolute() {
148            continue;
149        }
150        match search_dir(&cwd.join(&dir), name) {
151            Some(Hit::Native(path)) => return Some(path),
152            #[cfg(windows)]
153            Some(Hit::Unsupported(path)) => return Some(path),
154            None => {}
155        }
156    }
157    None
158}
159
160/// What a `PATH` directory holds for an agent.
161enum Hit {
162    Native(PathBuf),
163    /// The first Windows non-`.exe` form (`.com`, `.cmd`, `.bat`, `.ps1`) in a directory without an `.exe`;
164    /// the user's shell would run it, so a later `.exe` is never chosen instead.
165    #[cfg(windows)]
166    Unsupported(PathBuf),
167}
168
169#[cfg(windows)]
170fn search_dir(dir: &Path, name: &str) -> Option<Hit> {
171    let mut forms = WINDOWS_FORMS
172        .iter()
173        .map(|ext| dir.join(format!("{name}.{ext}")))
174        .filter(|candidate| is_executable(candidate));
175    let first = forms.next()?;
176    if first.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("exe")) {
177        Some(Hit::Native(first))
178    } else {
179        Some(Hit::Unsupported(first))
180    }
181}
182
183#[cfg(unix)]
184fn search_dir(dir: &Path, name: &str) -> Option<Hit> {
185    let candidate = dir.join(name);
186    is_executable(&candidate).then_some(Hit::Native(candidate))
187}
188
189fn not_installed(agent: &str, reason: NotInstalledReason) -> Error {
190    Error::AgentNotInstalled { agent: agent.to_owned(), reason, unknown_configured: Vec::new() }
191}
192
193fn unsupported(agent: &str, path: PathBuf, config_file: &Path) -> Error {
194    Error::UnsupportedExecutable {
195        agent: agent.to_owned(),
196        path,
197        config_file: config_file.to_path_buf(),
198    }
199}
200
201#[cfg(unix)]
202pub(crate) fn is_executable(path: &Path) -> bool {
203    use std::os::unix::fs::PermissionsExt;
204    fs::metadata(path)
205        .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
206}
207
208#[cfg(windows)]
209pub(crate) fn is_executable(path: &Path) -> bool {
210    fs::metadata(path).is_ok_and(|metadata| metadata.is_file())
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    const CONFIG: &str = "/root/config.toml";
218
219    fn make_file(dir: &Path, name: &str, executable: bool) -> PathBuf {
220        let path = dir.join(name);
221        fs::write(&path, b"x").unwrap();
222        #[cfg(unix)]
223        {
224            use std::os::unix::fs::PermissionsExt;
225            let mode = if executable { 0o755 } else { 0o644 };
226            fs::set_permissions(&path, fs::Permissions::from_mode(mode)).unwrap();
227        }
228        #[cfg(windows)]
229        let _ = executable;
230        path
231    }
232
233    fn native(name: &str) -> String {
234        if cfg!(windows) { format!("{name}.exe") } else { name.to_owned() }
235    }
236
237    fn find(name: &str, explicit: Option<&Path>, path_var: Option<&OsStr>) -> Result<Found> {
238        discover("fake", name, explicit, path_var, Path::new(CONFIG))
239    }
240
241    #[test]
242    fn explicit_override_wins_and_must_be_a_file() {
243        let dir = tempfile::tempdir().unwrap();
244        let exe = make_file(dir.path(), "agent-bin", true);
245        let found = find("fake-agent", Some(&exe), None).unwrap();
246        assert_eq!(found, Found { path: exe, origin: Origin::Configured });
247
248        let missing = dir.path().join("missing");
249        let error = find("fake-agent", Some(&missing), None).unwrap_err();
250        assert!(matches!(
251            error,
252            Error::AgentNotInstalled { reason: NotInstalledReason::ExplicitMissing(ref p), .. } if *p == missing
253        ));
254        let error = find("fake-agent", Some(dir.path()), None).unwrap_err();
255        assert!(
256            matches!(error, Error::AgentNotInstalled { .. }),
257            "a directory is not an executable"
258        );
259    }
260
261    #[test]
262    fn path_search_takes_the_first_match_in_order() {
263        let first = tempfile::tempdir().unwrap();
264        let second = tempfile::tempdir().unwrap();
265        let empty = tempfile::tempdir().unwrap();
266        make_file(first.path(), &native("tool"), true);
267        let expected = make_file(second.path(), &native("tool"), true);
268        let path_var = std::env::join_paths([empty.path(), second.path(), first.path()]).unwrap();
269        let found = find("tool", None, Some(&path_var)).unwrap();
270        assert_eq!(found, Found { path: expected, origin: Origin::Path });
271    }
272
273    #[test]
274    fn missing_from_path_is_not_installed() {
275        let empty = tempfile::tempdir().unwrap();
276        let path_var = std::env::join_paths([empty.path()]).unwrap();
277        for path_var in [Some(path_var.as_os_str()), Some(OsStr::new("")), None] {
278            let error = find("tool", None, path_var).unwrap_err();
279            assert!(matches!(
280                error,
281                Error::AgentNotInstalled {
282                    reason: NotInstalledReason::NotOnPath { ignored_relative: 0 },
283                    ..
284                }
285            ));
286        }
287    }
288
289    #[test]
290    fn relative_path_entries_are_ignored_and_counted() {
291        let mut entries =
292            vec![PathBuf::from("."), PathBuf::from("bin"), PathBuf::from("..").join("tools")];
293        if cfg!(windows) {
294            entries.push(PathBuf::from(r"\tools"));
295            entries.push(PathBuf::from("C:tools"));
296        }
297        let expected = entries.len();
298        let path_var = std::env::join_paths(&entries).unwrap();
299        let error = find("tool", None, Some(&path_var)).unwrap_err();
300        match error {
301            Error::AgentNotInstalled {
302                reason: NotInstalledReason::NotOnPath { ignored_relative },
303                ..
304            } => assert_eq!(ignored_relative, expected),
305            other => panic!("{other:?}"),
306        }
307    }
308
309    #[cfg(unix)]
310    #[test]
311    fn path_search_skips_files_without_an_execute_bit() {
312        let dir = tempfile::tempdir().unwrap();
313        make_file(dir.path(), "tool", false);
314        let path_var = std::env::join_paths([dir.path()]).unwrap();
315        assert!(find("tool", None, Some(&path_var)).is_err());
316    }
317
318    #[test]
319    fn shell_scripts_are_refused_when_configured() {
320        let dir = tempfile::tempdir().unwrap();
321        for name in ["agent.cmd", "agent.BAT", "agent.Cmd", "agent.ps1", "agent.PS1"] {
322            let path = make_file(dir.path(), name, true);
323            match find("fake-agent", Some(&path), None).unwrap_err() {
324                Error::UnsupportedExecutable { agent, path: refused, config_file } => {
325                    assert_eq!((agent.as_str(), refused), ("fake", path));
326                    assert_eq!(config_file, PathBuf::from(CONFIG));
327                }
328                other => panic!("{name}: {other:?}"),
329            }
330        }
331    }
332
333    #[test]
334    fn trailing_spaces_and_dots_do_not_hide_a_shell_extension() {
335        let dir = tempfile::tempdir().unwrap();
336        for name in ["agent.cmd ", "agent.cmd.", "agent.BAT. .", "agent.ps1  "] {
337            let path = make_file(dir.path(), name, true);
338            assert!(
339                matches!(
340                    find("fake-agent", Some(&path), None),
341                    Err(Error::UnsupportedExecutable { .. })
342                ),
343                "{name:?}"
344            );
345        }
346        let native = make_file(dir.path(), "agent.cmd.exe", true);
347        assert!(find("fake-agent", Some(&native), None).is_ok());
348    }
349
350    #[test]
351    fn a_configured_com_is_a_native_executable() {
352        let dir = tempfile::tempdir().unwrap();
353        let com = make_file(dir.path(), "agent.COM", true);
354        assert_eq!(
355            find("fake-agent", Some(&com), None).unwrap(),
356            Found { path: com, origin: Origin::Configured }
357        );
358    }
359
360    #[cfg(windows)]
361    fn refused_on_path(dirs: &[&Path]) -> PathBuf {
362        let path_var = std::env::join_paths(dirs).unwrap();
363        match find("codex", None, Some(&path_var)).unwrap_err() {
364            Error::UnsupportedExecutable { agent, path, config_file } => {
365                assert_eq!(agent, "fake");
366                assert_eq!(config_file, PathBuf::from(CONFIG));
367                path
368            }
369            other => panic!("{other:?}"),
370        }
371    }
372
373    #[cfg(windows)]
374    #[test]
375    fn windows_shim_only_directories_are_refused() {
376        for form in ["codex.cmd", "codex.bat", "codex.ps1", "codex.com"] {
377            let dir = tempfile::tempdir().unwrap();
378            let shim = make_file(dir.path(), form, true);
379            assert_eq!(refused_on_path(&[dir.path()]), shim, "{form}");
380        }
381    }
382
383    #[cfg(windows)]
384    #[test]
385    fn windows_first_directory_decides_and_names_the_first_form() {
386        let both = tempfile::tempdir().unwrap();
387        make_file(both.path(), "codex.ps1", true);
388        let cmd = make_file(both.path(), "codex.cmd", true);
389        assert_eq!(refused_on_path(&[both.path()]), cmd);
390
391        let later = tempfile::tempdir().unwrap();
392        make_file(later.path(), "codex.exe", true);
393        assert_eq!(refused_on_path(&[both.path(), later.path()]), cmd);
394
395        let beside = tempfile::tempdir().unwrap();
396        make_file(beside.path(), "codex.cmd", true);
397        let exe = make_file(beside.path(), "codex.exe", true);
398        let path_var = std::env::join_paths([beside.path()]).unwrap();
399        assert_eq!(
400            find("codex", None, Some(&path_var)).unwrap(),
401            Found { path: exe, origin: Origin::Path }
402        );
403    }
404
405    #[cfg(unix)]
406    #[test]
407    fn repository_local_finds_an_executable_through_a_relative_entry_only() {
408        let dir = tempfile::tempdir().unwrap();
409        let dir = dir.path().to_path_buf();
410        let bin = dir.join("bin");
411        let other = dir.join("other");
412        fs::create_dir_all(&bin).unwrap();
413        fs::create_dir_all(&other).unwrap();
414        make_file(&bin, "fake-agent", true);
415        make_file(&other, "only-absolute", true);
416        make_file(&bin, "not-executable", false);
417        let path_var = std::env::join_paths([Path::new("bin"), other.as_path()]).unwrap();
418        assert_eq!(
419            repository_local("fake-agent", Some(path_var.as_os_str()), &dir),
420            Some(dir.join("bin").join("fake-agent"))
421        );
422        // the same kind of file reachable only through an ABSOLUTE entry -> None
423        assert_eq!(repository_local("only-absolute", Some(path_var.as_os_str()), &dir), None);
424        // a non-executable file through the relative entry -> None
425        assert_eq!(repository_local("not-executable", Some(path_var.as_os_str()), &dir), None);
426    }
427
428    #[cfg(windows)]
429    #[test]
430    fn repository_local_uses_the_windows_forms() {
431        let dir = tempfile::tempdir().unwrap();
432        let dir = dir.path().to_path_buf();
433        let bin = dir.join("bin");
434        fs::create_dir_all(&bin).unwrap();
435        make_file(&bin, "fake-agent.exe", true);
436        make_file(&bin, "bare-agent", true);
437        let path_var = std::env::join_paths([Path::new("bin")]).unwrap();
438        assert_eq!(
439            repository_local("fake-agent", Some(path_var.as_os_str()), &dir),
440            Some(dir.join("bin").join("fake-agent.exe"))
441        );
442        assert_eq!(repository_local("bare-agent", Some(path_var.as_os_str()), &dir), None);
443    }
444}