Skip to main content

codex/capabilities/
guard.rs

1use serde::{Deserialize, Serialize};
2use std::time::{Duration, SystemTime};
3use tracing::warn;
4
5use super::{CapabilityCachePolicy, CodexCapabilities, CodexFeatureFlags, CodexVersionInfo};
6
7/// Result of applying a TTL/backoff window to a capability snapshot.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub struct CapabilityTtlDecision {
10    /// True when the snapshot is outside the TTL window and callers should re-run probes.
11    pub should_probe: bool,
12    /// Recommended cache policy for the next probe (`Refresh` when fingerprints exist, `Bypass` when metadata is missing).
13    pub policy: CapabilityCachePolicy,
14}
15
16/// Decides whether a cached capability snapshot should be refreshed based on `collected_at`.
17///
18/// Callers can use this to apply a TTL/backoff in environments where filesystem metadata is
19/// missing or unreliable (e.g., FUSE/overlay filesystems) and when binaries are hot-swapped
20/// without changing fingerprints. When the TTL has not elapsed, reuse the provided snapshot;
21/// when expired, force a probe with [`CapabilityCachePolicy::Refresh`] (fingerprints present)
22/// or [`CapabilityCachePolicy::Bypass`] (metadata missing).
23///
24/// Recommended defaults: start with a 5 minute TTL when fingerprints exist and prefer
25/// `Refresh` for hot-swaps that reuse the same path; when metadata is missing, expect `Bypass`
26/// and back off further (e.g., stretch the TTL toward 10-15 minutes) to avoid tight probe loops.
27pub fn capability_cache_ttl_decision(
28    snapshot: Option<&CodexCapabilities>,
29    ttl: Duration,
30    now: SystemTime,
31) -> CapabilityTtlDecision {
32    let default_policy = CapabilityCachePolicy::PreferCache;
33    let Some(snapshot) = snapshot else {
34        return CapabilityTtlDecision {
35            should_probe: true,
36            policy: default_policy,
37        };
38    };
39
40    let expired = now
41        .duration_since(snapshot.collected_at)
42        .map(|elapsed| elapsed >= ttl)
43        .unwrap_or(true);
44
45    if !expired {
46        return CapabilityTtlDecision {
47            should_probe: false,
48            policy: default_policy,
49        };
50    }
51
52    let policy = if snapshot.fingerprint.is_some() {
53        CapabilityCachePolicy::Refresh
54    } else {
55        CapabilityCachePolicy::Bypass
56    };
57
58    CapabilityTtlDecision {
59        should_probe: true,
60        policy,
61    }
62}
63
64/// High-level view of whether a specific feature can be used safely.
65#[derive(Clone, Copy, Debug, Eq, PartialEq)]
66pub enum CapabilitySupport {
67    Supported,
68    Unsupported,
69    Unknown,
70}
71
72impl CapabilitySupport {
73    /// True when it is safe to enable the guarded feature or flag.
74    pub const fn is_supported(self) -> bool {
75        matches!(self, CapabilitySupport::Supported)
76    }
77
78    /// True when support could not be confirmed due to missing probes.
79    pub const fn is_unknown(self) -> bool {
80        matches!(self, CapabilitySupport::Unknown)
81    }
82}
83
84/// Feature/flag tokens that can be guarded based on probed capabilities.
85#[derive(Clone, Copy, Debug, Eq, PartialEq)]
86pub enum CapabilityFeature {
87    OutputSchema,
88    AddDir,
89    McpLogin,
90    FeaturesList,
91}
92
93impl CapabilityFeature {
94    fn label(self) -> &'static str {
95        match self {
96            CapabilityFeature::OutputSchema => "--output-schema",
97            CapabilityFeature::AddDir => "codex add-dir",
98            CapabilityFeature::McpLogin => "codex login --mcp",
99            CapabilityFeature::FeaturesList => "codex features list",
100        }
101    }
102}
103
104/// Result of gating a Codex feature/flag against probed capabilities.
105#[derive(Clone, Debug, Eq, PartialEq)]
106pub struct CapabilityGuard {
107    /// Feature being checked.
108    pub feature: CapabilityFeature,
109    /// Whether the feature is safe to enable.
110    pub support: CapabilitySupport,
111    /// Notes explaining how the guard was derived.
112    pub notes: Vec<String>,
113}
114
115impl CapabilityGuard {
116    fn supported(feature: CapabilityFeature, note: impl Into<String>) -> Self {
117        CapabilityGuard {
118            feature,
119            support: CapabilitySupport::Supported,
120            notes: vec![note.into()],
121        }
122    }
123
124    fn unsupported(feature: CapabilityFeature, note: impl Into<String>) -> Self {
125        CapabilityGuard {
126            feature,
127            support: CapabilitySupport::Unsupported,
128            notes: vec![note.into()],
129        }
130    }
131
132    fn unknown(feature: CapabilityFeature, notes: Vec<String>) -> Self {
133        CapabilityGuard {
134            feature,
135            support: CapabilitySupport::Unknown,
136            notes,
137        }
138    }
139
140    /// Convenience wrapper for `support.is_supported()`.
141    pub const fn is_supported(&self) -> bool {
142        self.support.is_supported()
143    }
144
145    /// Convenience wrapper for `support.is_unknown()`.
146    pub const fn is_unknown(&self) -> bool {
147        self.support.is_unknown()
148    }
149}
150
151/// Description of how we interrogate the CLI to populate a [`CodexCapabilities`] snapshot.
152///
153/// Probes should prefer an explicit feature list when available, fall back to parsing
154/// `codex --help` flags, and finally rely on coarse version heuristics. Each attempted
155/// step is recorded so hosts can trace why a particular flag was enabled or skipped.
156#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
157pub struct CapabilityProbePlan {
158    /// Steps attempted in order; consumers should push entries as probes run.
159    pub steps: Vec<CapabilityProbeStep>,
160}
161
162/// Command-level probes used to infer feature support.
163#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
164pub enum CapabilityProbeStep {
165    /// Invoke `codex --version` to capture version/build metadata.
166    VersionFlag,
167    /// Prefer `codex features list --json` when supported for structured output.
168    FeaturesListJson,
169    /// Fallback to `codex features list` when only plain text is available.
170    FeaturesListText,
171    /// Parse `codex --help` to spot known flags (e.g., `--output-schema`, `add-dir`, `login --mcp`) when the features list is missing.
172    HelpFallback,
173    /// Caller-supplied capability overrides were applied to the snapshot.
174    ManualOverride,
175}
176
177impl CodexCapabilities {
178    /// Guards whether `--output-schema` should be passed to `codex exec`.
179    pub fn guard_output_schema(&self) -> CapabilityGuard {
180        self.guard_feature(CapabilityFeature::OutputSchema)
181    }
182
183    /// Guards whether `codex add-dir` can be invoked safely.
184    pub fn guard_add_dir(&self) -> CapabilityGuard {
185        self.guard_feature(CapabilityFeature::AddDir)
186    }
187
188    /// Guards whether `codex login --mcp` is available.
189    pub fn guard_mcp_login(&self) -> CapabilityGuard {
190        self.guard_feature(CapabilityFeature::McpLogin)
191    }
192
193    /// Guards whether `codex features list` is supported by the probed binary.
194    pub fn guard_features_list(&self) -> CapabilityGuard {
195        self.guard_feature(CapabilityFeature::FeaturesList)
196    }
197
198    /// Returns a guard describing if a feature/flag is supported by the probed binary.
199    ///
200    /// The guard treats missing `features list` support as `Unknown` so hosts can
201    /// degrade gracefully on older binaries instead of passing unsupported flags.
202    pub fn guard_feature(&self, feature: CapabilityFeature) -> CapabilityGuard {
203        guard_feature_support(feature, &self.features, self.version.as_ref())
204    }
205}
206
207fn guard_feature_support(
208    feature: CapabilityFeature,
209    flags: &CodexFeatureFlags,
210    version: Option<&CodexVersionInfo>,
211) -> CapabilityGuard {
212    let supported = match feature {
213        CapabilityFeature::OutputSchema => flags.supports_output_schema,
214        CapabilityFeature::AddDir => flags.supports_add_dir,
215        CapabilityFeature::McpLogin => flags.supports_mcp_login,
216        CapabilityFeature::FeaturesList => flags.supports_features_list,
217    };
218
219    if supported {
220        return CapabilityGuard::supported(
221            feature,
222            format!("Support for {} reported by Codex probe.", feature.label()),
223        );
224    }
225
226    if feature == CapabilityFeature::FeaturesList {
227        let mut notes = vec![format!(
228            "Support for {} could not be confirmed; feature list probes failed or were unavailable.",
229            feature.label()
230        )];
231        if version.is_none() {
232            notes.push(
233                "Version was unavailable; assuming compatibility with older Codex builds."
234                    .to_string(),
235            );
236        }
237        return CapabilityGuard::unknown(feature, notes);
238    }
239
240    if flags.supports_features_list {
241        return CapabilityGuard::unsupported(
242            feature,
243            format!(
244                "`{}` did not advertise {}; skipping related flag to stay compatible.",
245                CapabilityFeature::FeaturesList.label(),
246                feature.label()
247            ),
248        );
249    }
250
251    let mut notes = vec![format!(
252        "Support for {} is unknown because {} is unavailable; disable the flag for compatibility.",
253        feature.label(),
254        CapabilityFeature::FeaturesList.label()
255    )];
256    if version.is_none() {
257        notes.push(
258            "Version could not be parsed; treating feature support conservatively to avoid CLI errors."
259                .to_string(),
260        );
261    }
262
263    CapabilityGuard::unknown(feature, notes)
264}
265
266pub(crate) fn guard_is_supported(guard: &CapabilityGuard) -> bool {
267    matches!(guard.support, CapabilitySupport::Supported)
268}
269
270pub(crate) fn log_guard_skip(guard: &CapabilityGuard) {
271    warn!(
272        feature = guard.feature.label(),
273        support = ?guard.support,
274        notes = ?guard.notes,
275        "Skipping requested Codex capability because support was not confirmed"
276    );
277}