Skip to main content

kcode_codex_runtime/
catalog.rs

1//! Discovery and caching for a sanitized Codex model catalog.
2//!
3//! [`CatalogCache`] ensures that every Codex-backed service in one process
4//! shares one discovery. The sanitized result is also cached on disk
5//! by Codex version so ordinary restarts need only a cheap version check.
6
7use std::{
8    collections::HashMap,
9    io::ErrorKind,
10    path::{Path, PathBuf},
11    sync::Arc,
12};
13
14use anyhow::{Context, ensure};
15use serde_json::Value;
16use sha2::{Digest, Sha256};
17use tokio::{fs, process::Command, sync::OnceCell};
18
19/// Default sandboxed Codex launcher.
20pub const DEFAULT_CODEX_EXECUTABLE: &str = "codex-safe";
21
22const CACHE_SCHEMA: &str = "kcode-codex-catalog-v1";
23const CACHE_DIRECTORY: &str = "kcode-codex-catalogs";
24
25/// Effective limits advertised for one Codex model.
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub struct ModelLimits {
28    /// Effective context-window size after Codex's advertised percentage.
29    pub context_window_tokens: u64,
30    /// Maximum input size Kennedy may send to the model.
31    pub max_input_tokens: u64,
32}
33
34impl ModelLimits {
35    /// Effective context-window size after Codex's advertised percentage.
36    pub fn context_window_tokens(self) -> u64 {
37        self.context_window_tokens
38    }
39
40    /// Maximum input size Kennedy may send to the model.
41    pub fn max_input_tokens(self) -> u64 {
42        self.max_input_tokens
43    }
44}
45
46/// One verified, sanitized Codex model catalog.
47#[derive(Clone, Debug)]
48pub struct Catalog {
49    executable: Arc<str>,
50    path: Arc<PathBuf>,
51    limits: Arc<HashMap<String, ModelLimits>>,
52    cache_key: Arc<str>,
53    cache_directory: Arc<PathBuf>,
54}
55
56impl Catalog {
57    /// Codex executable whose version produced this catalog.
58    pub fn executable(&self) -> &str {
59        &self.executable
60    }
61
62    /// Path to the sanitized JSON catalog accepted by Codex.
63    pub fn path(&self) -> &Path {
64        &self.path
65    }
66
67    /// Returns the advertised limits for `model` when it exists.
68    pub fn model_limits(&self, model: &str) -> Option<ModelLimits> {
69        self.limits.get(model).copied()
70    }
71
72    /// Reports whether a successful compatibility validation is cached.
73    ///
74    /// The caller must change `scope` whenever the configuration being
75    /// validated changes. Catalog and Codex-version changes are incorporated
76    /// automatically.
77    pub async fn validation_is_cached(&self, scope: &str) -> anyhow::Result<bool> {
78        let path = self.validation_path(scope);
79        let expected = self.validation_record(scope);
80        match fs::read(&path).await {
81            Ok(actual) => Ok(actual == expected.as_bytes()),
82            Err(error) if error.kind() == ErrorKind::NotFound => Ok(false),
83            Err(error) => Err(error)
84                .with_context(|| format!("reading Codex validation cache {}", path.display())),
85        }
86    }
87
88    /// Records that the compatibility validation identified by `scope` passed.
89    pub async fn cache_validation(&self, scope: &str) -> anyhow::Result<()> {
90        fs::create_dir_all(self.cache_directory.as_ref())
91            .await
92            .with_context(|| {
93                format!(
94                    "creating Codex cache directory {}",
95                    self.cache_directory.display()
96                )
97            })?;
98        let path = self.validation_path(scope);
99        let temporary = path.with_extension(format!("tmp-{}", std::process::id()));
100        fs::write(&temporary, self.validation_record(scope))
101            .await
102            .with_context(|| {
103                format!(
104                    "writing temporary Codex validation cache {}",
105                    temporary.display()
106                )
107            })?;
108        fs::rename(&temporary, &path).await.with_context(|| {
109            format!(
110                "publishing Codex validation cache {} as {}",
111                temporary.display(),
112                path.display()
113            )
114        })?;
115        Ok(())
116    }
117
118    fn validation_path(&self, scope: &str) -> PathBuf {
119        let key = digest(&[self.cache_key.as_bytes(), scope.as_bytes()]);
120        self.cache_directory.join(format!("validated-{key}"))
121    }
122
123    fn validation_record(&self, scope: &str) -> String {
124        format!("{}\n{scope}\n", self.cache_key)
125    }
126}
127
128/// A cloneable process-wide handle for lazily loading one shared catalog.
129#[derive(Clone, Debug)]
130pub struct CatalogCache {
131    executable: Arc<str>,
132    cache_directory: Arc<PathBuf>,
133    catalog: Arc<OnceCell<Catalog>>,
134}
135
136impl CatalogCache {
137    /// Creates an unloaded cache for `executable`.
138    pub fn new(executable: impl Into<String>) -> Self {
139        let cache_directory = std::env::var_os("CODEX_SAFE_CATALOG_DIR")
140            .map(PathBuf::from)
141            .unwrap_or_else(|| std::env::temp_dir().join(CACHE_DIRECTORY));
142        Self::with_directory(executable, cache_directory)
143    }
144
145    /// Creates an unloaded cache using an explicit host/container-shared path.
146    pub fn with_directory(
147        executable: impl Into<String>,
148        cache_directory: impl Into<PathBuf>,
149    ) -> Self {
150        Self {
151            executable: Arc::from(executable.into()),
152            cache_directory: Arc::new(cache_directory.into()),
153            catalog: Arc::new(OnceCell::new()),
154        }
155    }
156
157    /// Loads one catalog, sharing concurrent initialization between callers.
158    pub async fn load(&self) -> anyhow::Result<Catalog> {
159        let executable = self.executable.clone();
160        let cache_directory = self.cache_directory.clone();
161        let catalog = self
162            .catalog
163            .get_or_try_init(|| async move { load_catalog(executable, cache_directory).await })
164            .await?;
165        Ok(catalog.clone())
166    }
167}
168
169/// Formats the Codex configuration override for a sanitized catalog path.
170pub fn model_catalog_config(path: &Path) -> String {
171    format!(
172        "model_catalog_json={}",
173        serde_json::to_string(path.to_string_lossy().as_ref())
174            .expect("serializing a path string cannot fail")
175    )
176}
177
178async fn load_catalog(
179    executable: Arc<str>,
180    cache_directory: Arc<PathBuf>,
181) -> anyhow::Result<Catalog> {
182    fs::create_dir_all(cache_directory.as_ref())
183        .await
184        .with_context(|| format!("creating Codex catalog cache {}", cache_directory.display()))?;
185    let identity = codex_identity(&executable, &cache_directory).await?;
186    let cache_key = digest(&[
187        CACHE_SCHEMA.as_bytes(),
188        executable.as_bytes(),
189        identity.as_bytes(),
190    ]);
191    let path = cache_directory.join(format!("models-{cache_key}.json"));
192
193    match fs::read(&path).await {
194        Ok(cached) => match verified_limits(&cached) {
195            Ok(limits) => {
196                tracing::info!(path=%path.display(), "Using cached sanitized Codex model catalog");
197                return Ok(Catalog {
198                    executable,
199                    path: Arc::new(path),
200                    limits: Arc::new(limits),
201                    cache_key: Arc::from(cache_key),
202                    cache_directory,
203                });
204            }
205            Err(error) => {
206                tracing::warn!(path=%path.display(), %error, "Ignoring invalid cached Codex model catalog");
207            }
208        },
209        Err(error) if error.kind() == ErrorKind::NotFound => {}
210        Err(error) => {
211            return Err(error)
212                .with_context(|| format!("reading Codex catalog cache {}", path.display()));
213        }
214    }
215
216    let source = Command::new(executable.as_ref())
217        .args(["debug", "models"])
218        .env("CODEX_SAFE_CATALOG_DIR", cache_directory.as_os_str())
219        .env_remove("OPENAI_API_KEY")
220        .env_remove("CODEX_API_KEY")
221        .output()
222        .await
223        .with_context(|| format!("discovering Codex models through '{executable}'"))?;
224    ensure!(
225        source.status.success(),
226        "Codex model discovery failed through {executable}"
227    );
228    let source_limits = parse_model_limits(&source.stdout)?;
229    let sanitized = sanitize_catalog(&source.stdout)?;
230    let sanitized_limits = verified_limits(&sanitized)?;
231    ensure!(
232        sanitized_limits == source_limits,
233        "sanitized Codex catalog changed advertised model limits"
234    );
235
236    let temporary = path.with_extension(format!("tmp-{}.json", std::process::id()));
237    fs::write(&temporary, &sanitized).await.with_context(|| {
238        format!(
239            "writing temporary sanitized Codex catalog {}",
240            temporary.display()
241        )
242    })?;
243    let probe = Command::new(executable.as_ref())
244        .arg("-c")
245        .arg(model_catalog_config(&temporary))
246        .args(["debug", "models"])
247        .env("CODEX_SAFE_CATALOG_DIR", cache_directory.as_os_str())
248        .env_remove("OPENAI_API_KEY")
249        .env_remove("CODEX_API_KEY")
250        .output()
251        .await
252        .with_context(|| format!("probing sanitized Codex catalog through '{executable}'"))?;
253    let verification = (|| -> anyhow::Result<()> {
254        ensure!(
255            probe.status.success(),
256            "{executable} cannot read {} inside its sandbox",
257            temporary.display()
258        );
259        ensure!(
260            verified_limits(&probe.stdout)? == source_limits,
261            "Codex changed model limits while loading the sanitized catalog"
262        );
263        Ok(())
264    })();
265    if let Err(error) = verification {
266        let _ = fs::remove_file(&temporary).await;
267        return Err(error);
268    }
269    fs::rename(&temporary, &path).await.with_context(|| {
270        format!(
271            "publishing sanitized Codex catalog {} as {}",
272            temporary.display(),
273            path.display()
274        )
275    })?;
276    tracing::info!(path=%path.display(), "Discovered and cached sanitized Codex model catalog");
277    Ok(Catalog {
278        executable,
279        path: Arc::new(path),
280        limits: Arc::new(source_limits),
281        cache_key: Arc::from(cache_key),
282        cache_directory,
283    })
284}
285
286async fn codex_identity(executable: &str, cache_directory: &Path) -> anyhow::Result<String> {
287    let output = Command::new(executable)
288        .arg("--version")
289        .env("CODEX_SAFE_CATALOG_DIR", cache_directory.as_os_str())
290        .env_remove("OPENAI_API_KEY")
291        .env_remove("CODEX_API_KEY")
292        .output()
293        .await
294        .with_context(|| format!("reading Codex version through '{executable}'"))?;
295    ensure!(
296        output.status.success(),
297        "Codex version check failed through {executable}"
298    );
299    let version = String::from_utf8(output.stdout)
300        .context("Codex returned a non-UTF-8 version")?
301        .trim()
302        .to_owned();
303    ensure!(!version.is_empty(), "Codex returned an empty version");
304    Ok(version)
305}
306
307fn digest(parts: &[&[u8]]) -> String {
308    let mut digest = Sha256::new();
309    for part in parts {
310        digest.update((part.len() as u64).to_be_bytes());
311        digest.update(part);
312    }
313    hex::encode(digest.finalize())
314}
315
316fn parse_model_limits(output: &[u8]) -> anyhow::Result<HashMap<String, ModelLimits>> {
317    let catalog: Value =
318        serde_json::from_slice(output).context("Codex returned an invalid model catalog")?;
319    let models = catalog
320        .get("models")
321        .and_then(Value::as_array)
322        .context("Codex model catalog has no models array")?;
323    let mut limits = HashMap::new();
324    for model in models {
325        let slug = model
326            .get("slug")
327            .and_then(Value::as_str)
328            .context("Codex model has no slug")?;
329        let context_window = model
330            .get("context_window")
331            .and_then(Value::as_u64)
332            .context("Codex model has no context window")?;
333        let effective_percent = model
334            .get("effective_context_window_percent")
335            .and_then(Value::as_u64)
336            .context("Codex model has no effective context percentage")?;
337        ensure!(
338            (1..=100).contains(&effective_percent),
339            "Codex model {slug} has an invalid effective context percentage"
340        );
341        let effective = context_window
342            .checked_mul(effective_percent)
343            .context("Codex model context limit overflowed")?
344            / 100;
345        ensure!(effective > 0, "Codex model {slug} has an empty context");
346        limits.insert(
347            slug.to_owned(),
348            ModelLimits {
349                context_window_tokens: effective,
350                max_input_tokens: effective,
351            },
352        );
353    }
354    Ok(limits)
355}
356
357fn sanitize_catalog(output: &[u8]) -> anyhow::Result<Vec<u8>> {
358    let mut catalog: Value =
359        serde_json::from_slice(output).context("Codex returned an invalid model catalog")?;
360    let models = catalog
361        .get_mut("models")
362        .and_then(Value::as_array_mut)
363        .context("Codex model catalog has no models array")?;
364    for model in models {
365        let model = model
366            .as_object_mut()
367            .context("Codex model catalog contains a non-object model")?;
368        model.remove("tool_mode");
369        model.remove("multi_agent_version");
370        model.remove("apply_patch_tool_type");
371        model.remove("model_messages");
372        model.insert("base_instructions".into(), Value::String(String::new()));
373        model.insert(
374            "include_skills_usage_instructions".into(),
375            Value::Bool(false),
376        );
377    }
378    serde_json::to_vec(&catalog).context("serializing the sanitized Codex model catalog")
379}
380
381fn verified_limits(output: &[u8]) -> anyhow::Result<HashMap<String, ModelLimits>> {
382    let catalog: Value =
383        serde_json::from_slice(output).context("Codex returned an invalid model catalog")?;
384    let models = catalog
385        .get("models")
386        .and_then(Value::as_array)
387        .context("Codex model catalog has no models array")?;
388    for model in models {
389        let model = model
390            .as_object()
391            .context("Codex model catalog contains a non-object model")?;
392        let slug = model
393            .get("slug")
394            .and_then(Value::as_str)
395            .unwrap_or("unknown model");
396        ensure!(
397            model.get("base_instructions").and_then(Value::as_str) == Some(""),
398            "Codex retained base instructions for {slug}"
399        );
400        ensure!(
401            !model.contains_key("model_messages"),
402            "Codex retained model messages for {slug}"
403        );
404        ensure!(
405            model
406                .get("include_skills_usage_instructions")
407                .and_then(Value::as_bool)
408                == Some(false),
409            "Codex retained skill usage instructions for {slug}"
410        );
411    }
412    parse_model_limits(output)
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418
419    const SOURCE: &[u8] = br#"{
420        "models":[{
421            "slug":"gpt-5.6-sol",
422            "context_window":272000,
423            "effective_context_window_percent":95,
424            "base_instructions":"provider instructions",
425            "model_messages":{"instructions_template":"more provider instructions"},
426            "include_skills_usage_instructions":true,
427            "tool_mode":"code_mode_only",
428            "multi_agent_version":"v2",
429            "apply_patch_tool_type":"freeform",
430            "unrelated":"preserved"
431        }]
432    }"#;
433
434    #[test]
435    fn sanitization_removes_hidden_prompts_and_preserves_limits() {
436        let sanitized = sanitize_catalog(SOURCE).unwrap();
437        assert_eq!(
438            parse_model_limits(&sanitized).unwrap(),
439            parse_model_limits(SOURCE).unwrap()
440        );
441        verified_limits(&sanitized).unwrap();
442        let catalog: Value = serde_json::from_slice(&sanitized).unwrap();
443        let model = catalog["models"][0].as_object().unwrap();
444        for removed in [
445            "tool_mode",
446            "multi_agent_version",
447            "apply_patch_tool_type",
448            "model_messages",
449        ] {
450            assert!(!model.contains_key(removed));
451        }
452        assert_eq!(model["base_instructions"], "");
453        assert_eq!(model["include_skills_usage_instructions"], false);
454        assert_eq!(model["unrelated"], "preserved");
455    }
456
457    #[test]
458    fn advertised_context_uses_the_effective_percentage() {
459        let limits = parse_model_limits(SOURCE).unwrap();
460        assert_eq!(
461            limits["gpt-5.6-sol"],
462            ModelLimits {
463                context_window_tokens: 258_400,
464                max_input_tokens: 258_400,
465            }
466        );
467    }
468
469    #[test]
470    fn catalog_configuration_quotes_paths() {
471        assert_eq!(
472            model_catalog_config(Path::new("/tmp/a catalog.json")),
473            "model_catalog_json=\"/tmp/a catalog.json\""
474        );
475    }
476
477    #[cfg(unix)]
478    #[tokio::test]
479    async fn concurrent_callers_share_discovery_and_restarts_use_the_disk_cache() {
480        use std::{
481            os::unix::fs::PermissionsExt,
482            time::{SystemTime, UNIX_EPOCH},
483        };
484
485        let nonce = SystemTime::now()
486            .duration_since(UNIX_EPOCH)
487            .unwrap()
488            .as_nanos();
489        let directory = std::env::temp_dir().join(format!(
490            "kcode-codex-runtime-test-{}-{nonce}",
491            std::process::id()
492        ));
493        std::fs::create_dir_all(&directory).unwrap();
494        let executable = directory.join("fake-codex");
495        let calls = directory.join("calls");
496        let sanitized_catalog = r#"{"models":[{"slug":"gpt-5.6-sol","context_window":272000,"effective_context_window_percent":95,"base_instructions":"","include_skills_usage_instructions":false}]}"#;
497        let script = format!(
498            "#!/bin/sh\nprintf '%s\\n' \"$1\" >> '{}'\nif [ \"$1\" = '--version' ]; then\n  printf '%s\\n' 'fake-codex 1.0'\nelse\n  printf '%s' '{}'\nfi\n",
499            calls.display(),
500            sanitized_catalog
501        );
502        std::fs::write(&executable, script).unwrap();
503        std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o700)).unwrap();
504
505        let cache = CatalogCache::with_directory(
506            executable.to_string_lossy().into_owned(),
507            directory.join("catalog-cache"),
508        );
509        let (left, right) = tokio::join!(cache.load(), cache.load());
510        let first = left.unwrap();
511        assert_eq!(first.path(), right.unwrap().path());
512        let cache_path = first.path().to_owned();
513        assert!(!first.validation_is_cached("prompt-v1").await.unwrap());
514        first.cache_validation("prompt-v1").await.unwrap();
515        assert!(first.validation_is_cached("prompt-v1").await.unwrap());
516        assert!(!first.validation_is_cached("prompt-v2").await.unwrap());
517
518        let restarted = CatalogCache::with_directory(
519            executable.to_string_lossy().into_owned(),
520            directory.join("catalog-cache"),
521        );
522        assert_eq!(restarted.load().await.unwrap().path(), cache_path);
523        assert_eq!(
524            std::fs::read_to_string(&calls)
525                .unwrap()
526                .lines()
527                .collect::<Vec<_>>(),
528            vec!["--version", "debug", "-c", "--version"]
529        );
530
531        std::fs::remove_file(cache_path).unwrap();
532        std::fs::remove_dir_all(directory).unwrap();
533    }
534}