1use std::path::{Path, PathBuf};
7use std::time::Duration;
8
9use serde::{Deserialize, Serialize};
10use tokio::process::Command;
11
12pub const MIN_CODEX_VERSION: (u64, u64, u64) = (0, 144, 0);
14
15const PREFLIGHT_COMMAND_TIMEOUT: Duration = Duration::from_secs(10);
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct CodexCliDiscovery {
22 pub path: String,
23 pub version: String,
24}
25
26pub 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
78pub 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
94pub 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}