Skip to main content

codex/capabilities/
cache.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::env;
4use std::ffi::OsString;
5use std::fs as std_fs;
6use std::path::{Path, PathBuf};
7use std::sync::{Mutex, OnceLock};
8use std::time::SystemTime;
9
10#[cfg(unix)]
11use std::os::unix::fs::PermissionsExt;
12
13use super::{
14    CapabilityFeatureOverrides, CapabilityOverrides, CapabilityProbeStep, CodexCapabilities,
15    CodexFeatureFlags,
16};
17
18/// Cache interaction policy for capability probes.
19#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
20pub enum CapabilityCachePolicy {
21    /// Use cached entries when fingerprints match; fall back to probing when
22    /// fingerprints differ or are missing and write fresh snapshots back.
23    #[default]
24    PreferCache,
25    /// Always run probes, overwriting any existing cache entry for the binary (useful for TTL/backoff windows or hot-swaps that keep the same path).
26    Refresh,
27    /// Skip cache reads and writes to force an isolated snapshot.
28    Bypass,
29}
30
31/// Cache key for capability snapshots derived from a specific Codex binary path.
32///
33/// Cache lookups should canonicalize the path when possible so symlinked binaries
34/// collapse to a single entry.
35#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
36pub struct CapabilityCacheKey {
37    /// Canonical binary path when resolvable; otherwise the original path.
38    pub binary_path: PathBuf,
39}
40
41/// File metadata used to invalidate cached capability snapshots when the binary changes.
42#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
43pub struct BinaryFingerprint {
44    /// Canonical path if the binary resolves through symlinks.
45    pub canonical_path: Option<PathBuf>,
46    /// Last modification time of the binary on disk (`metadata().modified()`).
47    pub modified: Option<SystemTime>,
48    /// File length from `metadata().len()`, useful for cheap change detection.
49    pub len: Option<u64>,
50}
51
52const PATH_ENV: &str = "PATH";
53
54pub(crate) fn capability_cache() -> &'static Mutex<HashMap<CapabilityCacheKey, CodexCapabilities>> {
55    static CACHE: OnceLock<Mutex<HashMap<CapabilityCacheKey, CodexCapabilities>>> = OnceLock::new();
56    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
57}
58
59pub(crate) fn effective_path_env(env_overrides: &[(String, String)]) -> Option<OsString> {
60    env_overrides
61        .iter()
62        .rev()
63        .find_map(|(key, value)| path_env_key_matches(key).then(|| OsString::from(value)))
64        .or_else(|| env::var_os(PATH_ENV))
65}
66
67#[cfg(windows)]
68fn path_env_key_matches(key: &str) -> bool {
69    key.eq_ignore_ascii_case(PATH_ENV)
70}
71
72#[cfg(not(windows))]
73fn path_env_key_matches(key: &str) -> bool {
74    key == PATH_ENV
75}
76
77pub(crate) fn resolve_binary_path(
78    binary: &Path,
79    current_dir: Option<&Path>,
80    path_env: Option<OsString>,
81) -> PathBuf {
82    if is_path_qualified(binary) {
83        return resolve_path_qualified_binary(binary.to_path_buf(), current_dir);
84    }
85
86    find_binary_on_path(binary, path_env, current_dir).unwrap_or_else(|| binary.to_path_buf())
87}
88
89pub(crate) fn capability_cache_key(binary: &Path) -> CapabilityCacheKey {
90    capability_cache_key_for_current_dir(binary, None)
91}
92
93pub(crate) fn capability_cache_key_for_current_dir(
94    binary: &Path,
95    current_dir: Option<&Path>,
96) -> CapabilityCacheKey {
97    capability_cache_key_for_current_dir_with_env(binary, current_dir, &[])
98}
99
100pub(crate) fn capability_cache_key_for_current_dir_with_env(
101    binary: &Path,
102    current_dir: Option<&Path>,
103    env_overrides: &[(String, String)],
104) -> CapabilityCacheKey {
105    let resolved = resolve_binary_path(binary, current_dir, effective_path_env(env_overrides));
106    let canonical = std_fs::canonicalize(&resolved).unwrap_or(resolved);
107    CapabilityCacheKey {
108        binary_path: canonical,
109    }
110}
111
112fn is_path_qualified(path: &Path) -> bool {
113    path.is_absolute()
114        || path
115            .parent()
116            .is_some_and(|parent| !parent.as_os_str().is_empty())
117}
118
119fn resolve_path_qualified_binary(binary_path: PathBuf, current_dir: Option<&Path>) -> PathBuf {
120    if binary_path.is_absolute() {
121        return binary_path;
122    }
123
124    let joined = effective_base_dir(current_dir)
125        .map(|cwd| cwd.join(&binary_path))
126        .unwrap_or(binary_path);
127
128    std_fs::canonicalize(&joined).unwrap_or(joined)
129}
130
131fn effective_base_dir(base_dir: Option<&Path>) -> Option<PathBuf> {
132    match base_dir {
133        Some(path) if path.is_absolute() => Some(path.to_path_buf()),
134        Some(path) => env::current_dir().ok().map(|cwd| cwd.join(path)),
135        None => env::current_dir().ok(),
136    }
137}
138
139fn find_binary_on_path(
140    binary_name: &Path,
141    path_env: Option<OsString>,
142    current_dir: Option<&Path>,
143) -> Option<PathBuf> {
144    let path_env = path_env?;
145    let effective_cwd = effective_base_dir(current_dir);
146    env::split_paths(&path_env)
147        .find_map(|directory| {
148            let search_dir = if directory.is_absolute() {
149                directory
150            } else if let Some(cwd) = effective_cwd.as_deref() {
151                cwd.join(directory)
152            } else {
153                directory
154            };
155            candidate_binary_path(&search_dir, binary_name)
156        })
157        .map(|candidate| std_fs::canonicalize(&candidate).unwrap_or(candidate))
158}
159
160fn candidate_binary_path(directory: &Path, binary_name: &Path) -> Option<PathBuf> {
161    let candidate = directory.join(binary_name);
162    if is_runnable_path_candidate(&candidate) {
163        return Some(candidate);
164    }
165
166    #[cfg(windows)]
167    {
168        if candidate.extension().is_some() {
169            return None;
170        }
171
172        let pathext =
173            env::var_os("PATHEXT").unwrap_or_else(|| OsString::from(".COM;.EXE;.BAT;.CMD"));
174        for extension in pathext.to_string_lossy().split(';') {
175            let extension = extension.trim();
176            if extension.is_empty() {
177                continue;
178            }
179
180            let suffixed = candidate.with_extension(extension.trim_start_matches('.'));
181            if is_runnable_path_candidate(&suffixed) {
182                return Some(suffixed);
183            }
184        }
185    }
186
187    None
188}
189
190fn is_runnable_path_candidate(candidate: &Path) -> bool {
191    let Ok(metadata) = std_fs::metadata(candidate) else {
192        return false;
193    };
194
195    if !metadata.is_file() {
196        return false;
197    }
198
199    #[cfg(unix)]
200    {
201        metadata.permissions().mode() & 0o111 != 0
202    }
203
204    #[cfg(not(unix))]
205    {
206        true
207    }
208}
209
210pub(crate) fn has_fingerprint_metadata(fingerprint: &Option<BinaryFingerprint>) -> bool {
211    fingerprint.is_some()
212}
213
214pub(crate) fn cached_capabilities(
215    key: &CapabilityCacheKey,
216    fingerprint: &Option<BinaryFingerprint>,
217) -> Option<CodexCapabilities> {
218    let cache = capability_cache().lock().ok()?;
219    let cached = cache.get(key)?;
220    if !has_fingerprint_metadata(&cached.fingerprint) || !has_fingerprint_metadata(fingerprint) {
221        return None;
222    }
223    if fingerprints_match(&cached.fingerprint, fingerprint) {
224        Some(cached.clone())
225    } else {
226        None
227    }
228}
229
230pub(crate) fn update_capability_cache(capabilities: CodexCapabilities) {
231    if !has_fingerprint_metadata(&capabilities.fingerprint) {
232        return;
233    }
234    if let Ok(mut cache) = capability_cache().lock() {
235        cache.insert(capabilities.cache_key.clone(), capabilities);
236    }
237}
238
239/// Returns all capability cache entries keyed by canonical binary path.
240pub fn capability_cache_entries() -> Vec<CodexCapabilities> {
241    capability_cache()
242        .lock()
243        .map(|cache| cache.values().cloned().collect())
244        .unwrap_or_default()
245}
246
247/// Returns the cached capabilities for a specific binary path if present.
248pub fn capability_cache_entry(binary: &Path) -> Option<CodexCapabilities> {
249    let key = capability_cache_key(binary);
250    capability_cache()
251        .lock()
252        .ok()
253        .and_then(|cache| cache.get(&key).cloned())
254}
255
256/// Removes the cached capabilities for a specific binary. Returns true when an entry was removed.
257pub fn clear_capability_cache_entry(binary: &Path) -> bool {
258    let key = capability_cache_key(binary);
259    capability_cache()
260        .lock()
261        .ok()
262        .map(|mut cache| cache.remove(&key).is_some())
263        .unwrap_or(false)
264}
265
266/// Clears all cached capability snapshots.
267pub fn clear_capability_cache() {
268    if let Ok(mut cache) = capability_cache().lock() {
269        cache.clear();
270    }
271}
272
273pub(crate) fn current_fingerprint(key: &CapabilityCacheKey) -> Option<BinaryFingerprint> {
274    let canonical = std_fs::canonicalize(&key.binary_path).ok();
275    let metadata_path = canonical.as_deref().unwrap_or(key.binary_path.as_path());
276    let metadata = std_fs::metadata(metadata_path).ok()?;
277    Some(BinaryFingerprint {
278        canonical_path: canonical,
279        modified: metadata.modified().ok(),
280        len: Some(metadata.len()),
281    })
282}
283
284pub(crate) fn fingerprints_match(
285    cached: &Option<BinaryFingerprint>,
286    fresh: &Option<BinaryFingerprint>,
287) -> bool {
288    cached == fresh
289}
290
291pub(crate) fn finalize_capabilities_with_overrides(
292    mut capabilities: CodexCapabilities,
293    overrides: &CapabilityOverrides,
294    cache_key: CapabilityCacheKey,
295    fingerprint: Option<BinaryFingerprint>,
296    manual_source: bool,
297) -> CodexCapabilities {
298    capabilities.cache_key = cache_key;
299    capabilities.fingerprint = fingerprint;
300
301    let mut applied = manual_source;
302
303    if let Some(version) = overrides.version.clone() {
304        capabilities.version = Some(version);
305        applied = true;
306    }
307
308    if apply_feature_overrides(&mut capabilities.features, &overrides.features) {
309        applied = true;
310    }
311
312    if applied
313        && !capabilities
314            .probe_plan
315            .steps
316            .contains(&CapabilityProbeStep::ManualOverride)
317    {
318        capabilities
319            .probe_plan
320            .steps
321            .push(CapabilityProbeStep::ManualOverride);
322    }
323
324    capabilities
325}
326
327fn apply_feature_overrides(
328    features: &mut CodexFeatureFlags,
329    overrides: &CapabilityFeatureOverrides,
330) -> bool {
331    let mut applied = false;
332
333    if let Some(value) = overrides.supports_features_list {
334        features.supports_features_list = value;
335        applied = true;
336    }
337
338    if let Some(value) = overrides.supports_output_schema {
339        features.supports_output_schema = value;
340        applied = true;
341    }
342
343    if let Some(value) = overrides.supports_add_dir {
344        features.supports_add_dir = value;
345        applied = true;
346    }
347
348    if let Some(value) = overrides.supports_mcp_login {
349        features.supports_mcp_login = value;
350        applied = true;
351    }
352
353    applied
354}