Skip to main content

bamboo_subagent/
codex_discovery.rs

1//! Shared Codex CLI discovery and capability preflight.
2//!
3//! Both the worker executor and the settings HTTP endpoint call this module so
4//! a successful UI "Detect" result is exactly the binary the worker can use.
5
6use std::path::{Path, PathBuf};
7use std::time::Duration;
8
9use serde::{Deserialize, Serialize};
10use tokio::process::Command;
11
12/// The oldest Codex CLI schema Bamboo intentionally supports.
13pub const MIN_CODEX_VERSION: (u64, u64, u64) = (0, 144, 0);
14
15/// A discovery request must not leave a settings request or worker spawn
16/// waiting forever on a broken wrapper executable.
17const PREFLIGHT_COMMAND_TIMEOUT: Duration = Duration::from_secs(10);
18
19/// A resolved Codex executable that passed version and capability checks.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct CodexCliDiscovery {
22    pub path: String,
23    pub version: String,
24}
25
26/// Resolve `binary` (or `codex` from `PATH`) and verify the exact CLI surface
27/// required by the executor.
28pub async fn discover_codex_cli(binary: Option<&str>) -> Result<CodexCliDiscovery, String> {
29    let requested = binary
30        .map(str::trim)
31        .filter(|value| !value.is_empty())
32        .unwrap_or("codex");
33    let resolved = resolve_binary(requested).ok_or_else(|| missing_binary_error(requested))?;
34    ensure_executable(&resolved)?;
35
36    let version_output = command_output(&resolved, &["--version"]).await?;
37    if !version_output.status.success() {
38        return Err(format!(
39            "'{} --version' failed with status {}; reinstall or upgrade Codex CLI",
40            resolved.display(),
41            version_output.status
42        ));
43    }
44    let version = String::from_utf8_lossy(&version_output.stdout)
45        .trim()
46        .to_string();
47    let parsed_version = parse_codex_version(&version).ok_or_else(|| {
48        format!("could not parse Codex CLI version from {version:?}; expected `codex-cli X.Y.Z`")
49    })?;
50    if parsed_version < MIN_CODEX_VERSION {
51        return Err(format!(
52            "Codex CLI {version} is too old; Bamboo requires >= {}.{}.{} with `exec --json` and `exec resume`",
53            MIN_CODEX_VERSION.0, MIN_CODEX_VERSION.1, MIN_CODEX_VERSION.2
54        ));
55    }
56
57    verify_help_surface(
58        &resolved,
59        &["exec", "--help"],
60        &[
61            "--json",
62            "--output-last-message",
63            "--config",
64            "--sandbox",
65            "--dangerously-bypass-approvals-and-sandbox",
66            "stdin",
67        ],
68    )
69    .await?;
70    verify_help_surface(&resolved, &["exec", "resume", "--help"], &["--json"]).await?;
71
72    Ok(CodexCliDiscovery {
73        path: resolved.to_string_lossy().into_owned(),
74        version,
75    })
76}
77
78/// Resolve Codex and additionally require the long-lived app-server surface.
79/// Never falls back to exec mode because that would silently remove runtime
80/// approval semantics selected by the caller.
81pub async fn discover_codex_app_server(binary: Option<&str>) -> Result<CodexCliDiscovery, String> {
82    let discovery = discover_codex_cli(binary).await?;
83    let path = PathBuf::from(&discovery.path);
84    verify_help_surface(&path, &["app-server", "--help"], &["stdio", "--listen"])
85        .await
86        .map_err(|error| {
87            format!(
88                "{error}; Codex app-server mode is unavailable: use codex_mode = \"exec\" or upgrade Codex CLI"
89            )
90        })?;
91    Ok(discovery)
92}
93
94/// Parse `codex-cli X.Y.Z` while tolerating vendor prefixes and a missing
95/// patch component.
96pub fn parse_codex_version(text: &str) -> Option<(u64, u64, u64)> {
97    let token = text
98        .split_whitespace()
99        .map(|token| token.trim_start_matches('v'))
100        .find(|token| token.chars().next().is_some_and(|ch| ch.is_ascii_digit()))?;
101    let clean = token.split(['-', '+']).next()?;
102    let mut parts = clean.split('.');
103    let major = parts.next()?.parse().ok()?;
104    let minor = parts.next()?.parse().ok()?;
105    let patch = parts.next().unwrap_or("0").parse().ok()?;
106    Some((major, minor, patch))
107}
108
109fn missing_binary_error(requested: &str) -> String {
110    format!(
111        "Codex CLI binary {requested:?} was not found or is not executable; install it with `npm i -g @openai/codex`, `brew install codex`, or an official GitHub release, or set codex_binary"
112    )
113}
114
115fn resolve_binary(requested: &str) -> Option<PathBuf> {
116    let requested_path = Path::new(requested);
117    if requested_path.components().count() > 1 || requested_path.is_absolute() {
118        return requested_path
119            .exists()
120            .then(|| requested_path.to_path_buf());
121    }
122    let path = std::env::var_os("PATH")?;
123    for directory in std::env::split_paths(&path) {
124        let candidate = directory.join(requested);
125        if candidate.is_file() {
126            return Some(candidate);
127        }
128        #[cfg(windows)]
129        for extension in ["exe", "cmd", "bat"] {
130            let candidate = directory.join(format!("{requested}.{extension}"));
131            if candidate.is_file() {
132                return Some(candidate);
133            }
134        }
135    }
136    None
137}
138
139fn ensure_executable(path: &Path) -> Result<(), String> {
140    let metadata = std::fs::metadata(path)
141        .map_err(|error| format!("inspect Codex binary '{}': {error}", path.display()))?;
142    if !metadata.is_file() {
143        return Err(missing_binary_error(&path.display().to_string()));
144    }
145    #[cfg(unix)]
146    {
147        use std::os::unix::fs::PermissionsExt;
148        if metadata.permissions().mode() & 0o111 == 0 {
149            return Err(missing_binary_error(&path.display().to_string()));
150        }
151    }
152    Ok(())
153}
154
155async fn verify_help_surface(
156    binary: &Path,
157    args: &[&str],
158    required: &[&str],
159) -> Result<(), String> {
160    let output = command_output(binary, args).await?;
161    if !output.status.success() {
162        return Err(format!(
163            "'{} {}' failed with status {}; upgrade Codex CLI",
164            binary.display(),
165            args.join(" "),
166            output.status
167        ));
168    }
169    let help = format!(
170        "{}{}",
171        String::from_utf8_lossy(&output.stdout),
172        String::from_utf8_lossy(&output.stderr)
173    );
174    let missing: Vec<_> = required
175        .iter()
176        .copied()
177        .filter(|flag| !help.contains(flag))
178        .collect();
179    if missing.is_empty() {
180        Ok(())
181    } else {
182        Err(format!(
183            "Codex CLI '{}' lacks required `{}` capability flag(s): {}; upgrade to >= {}.{}.{}",
184            binary.display(),
185            args.join(" "),
186            missing.join(", "),
187            MIN_CODEX_VERSION.0,
188            MIN_CODEX_VERSION.1,
189            MIN_CODEX_VERSION.2
190        ))
191    }
192}
193
194async fn command_output(binary: &Path, args: &[&str]) -> Result<std::process::Output, String> {
195    let command_label = format!("'{} {}'", binary.display(), args.join(" "));
196    let mut command = Command::new(binary);
197    command.args(args).kill_on_drop(true);
198    tokio::time::timeout(PREFLIGHT_COMMAND_TIMEOUT, command.output())
199        .await
200        .map_err(|_| {
201            format!(
202                "{command_label} timed out after {} seconds; verify the Codex CLI installation or wrapper",
203                PREFLIGHT_COMMAND_TIMEOUT.as_secs()
204            )
205        })?
206        .map_err(|error| format!("run {command_label}: {error}"))
207}
208
209#[cfg(test)]
210mod tests {
211    use super::{discover_codex_app_server, parse_codex_version};
212
213    #[test]
214    fn version_parser_accepts_current_and_rejects_noise() {
215        assert_eq!(parse_codex_version("codex-cli 0.144.5"), Some((0, 144, 5)));
216        assert_eq!(parse_codex_version("vendor v0.144.5"), Some((0, 144, 5)));
217        assert_eq!(parse_codex_version("codex 1.2"), Some((1, 2, 0)));
218        assert_eq!(parse_codex_version("not-a-version"), None);
219    }
220
221    #[cfg(unix)]
222    #[tokio::test]
223    async fn app_server_preflight_never_silently_downgrades_to_exec() {
224        use std::os::unix::fs::PermissionsExt as _;
225        let directory = tempfile::tempdir().unwrap();
226        let binary = directory.path().join("codex-no-app-server.sh");
227        std::fs::write(
228            &binary,
229            r#"#!/bin/sh
230if [ "$1" = "--version" ]; then echo 'codex-cli 0.144.5'; exit 0; fi
231if [ "$1" = "exec" ]; then echo '--json --output-last-message --config --sandbox --dangerously-bypass-approvals-and-sandbox stdin'; exit 0; fi
232exit 2
233"#,
234        )
235        .unwrap();
236        let mut permissions = std::fs::metadata(&binary).unwrap().permissions();
237        permissions.set_mode(0o755);
238        std::fs::set_permissions(&binary, permissions).unwrap();
239
240        let error = discover_codex_app_server(binary.to_str())
241            .await
242            .expect_err("app-server capability must be required");
243        assert!(error.contains("use codex_mode = \"exec\" or upgrade"));
244    }
245}