返回 CodeWhale
codex_model_cache.rs
根目录 / crates / tui / src / codex_model_cache.rs
1 //! Secret-free OpenAI Codex / ChatGPT OAuth model roster discovery.
2 //!
3 //! The Codex CLI keeps its account-scoped roster in `models_cache.json`.
4 //! CodeWhale reads model metadata only. An explicit update can ask the Codex
5 //! CLI's documented stdio API for its ChatGPT account roster; credentials stay
6 //! with Codex. The query time is not proof of an upstream catalog refresh.
7
8 use std::collections::HashSet;
9 use std::io::Read;
10 use std::path::{Path, PathBuf};
11 use std::process::Stdio;
12 use std::sync::Mutex;
13 use std::time::SystemTime;
14
15 #[cfg(unix)]
16 use std::os::unix::fs::OpenOptionsExt;
17
18 use chrono::{DateTime, Duration, Utc};
19 use serde::{Deserialize, Serialize};
20 use serde_json::{Value, json};
21 use sha2::{Digest, Sha256};
22 use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
23
24 use crate::config::DEFAULT_OPENAI_CODEX_MODEL;
25
26 const MODEL_CACHE_FILE: &str = "models_cache.json";
27 const MAX_MODEL_CACHE_BYTES: u64 = 4 * 1024 * 1024;
28 /// Codex refreshes its own cache much more frequently. CodeWhale is an offline
29 /// consumer, so it accepts a last-known account roster for one day before
30 /// falling back to the single conservative compatibility model.
31 const MODEL_CACHE_MAX_AGE: Duration = Duration::hours(24);
32 const MAX_FUTURE_CLOCK_SKEW: Duration = Duration::minutes(5);
33
34 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
35 pub(crate) enum CodexModelCacheFreshness {
36 Fresh,
37 Missing,
38 Stale,
39 Invalid,
40 }
41
42 impl CodexModelCacheFreshness {
43 #[must_use]
44 pub(crate) const fn picker_label(self) -> &'static str {
45 match self {
46 Self::Fresh => "ChatGPT OAuth",
47 Self::Missing => "OAuth roster missing · fallback",
48 Self::Stale => "OAuth roster stale · fallback",
49 Self::Invalid => "OAuth roster invalid · fallback",
50 }
51 }
52 }
53
54 #[derive(Debug, Clone, PartialEq, Eq)]
55 pub(crate) struct CodexModelRoster {
56 pub(crate) models: Vec<CodexModelMetadata>,
57 pub(crate) freshness: CodexModelCacheFreshness,
58 pub(crate) fetched_at: Option<DateTime<Utc>>,
59 pub(crate) observed_at: Option<DateTime<Utc>>,
60 pub(crate) source: &'static str,
61 /// Applies only to observations from `model/list`, not Codex's own cache.
62 pub(crate) observation_persisted: bool,
63 }
64
65 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66 pub(crate) struct CodexModelMetadata {
67 pub(crate) id: String,
68 pub(crate) context_window: Option<u32>,
69 pub(crate) reasoning: Option<bool>,
70 /// Effort names the roster advertises for this model, lowercased and in
71 /// the order the cache lists them (`low`, `medium`, `high`, `xhigh`,
72 /// `max`, `ultra`). Empty when the roster published none, which keeps the
73 /// caller on its static ladder instead of inventing tiers.
74 pub(crate) efforts: Vec<String>,
75 }
76
77 impl CodexModelRoster {
78 fn fallback(freshness: CodexModelCacheFreshness, fetched_at: Option<DateTime<Utc>>) -> Self {
79 Self {
80 models: vec![CodexModelMetadata {
81 id: DEFAULT_OPENAI_CODEX_MODEL.to_string(),
82 context_window: None,
83 reasoning: None,
84 efforts: Vec::new(),
85 }],
86 freshness,
87 fetched_at,
88 observed_at: None,
89 source: "codex_cli_cache",
90 observation_persisted: false,
91 }
92 }
93
94 #[must_use]
95 pub(crate) fn model_ids(&self) -> Vec<String> {
96 self.models.iter().map(|model| model.id.clone()).collect()
97 }
98
99 #[must_use]
100 pub(crate) fn metadata_for(&self, id: &str) -> Option<&CodexModelMetadata> {
101 self.models
102 .iter()
103 .find(|model| model.id.eq_ignore_ascii_case(id.trim()))
104 }
105
106 /// The roster's preferred model: the highest-priority entry of a fresh
107 /// roster. Missing/stale/invalid rosters yield `None` so callers keep
108 /// the static seed default (#5034).
109 #[must_use]
110 pub(crate) fn preferred_model_id(&self) -> Option<&str> {
111 if self.freshness != CodexModelCacheFreshness::Fresh {
112 return None;
113 }
114 self.models.first().map(|model| model.id.as_str())
115 }
116 }
117
118 #[derive(Debug, Deserialize)]
119 struct CacheFile {
120 fetched_at: DateTime<Utc>,
121 #[serde(default)]
122 models: Vec<CacheModel>,
123 }
124
125 #[derive(Debug, Deserialize)]
126 struct CacheModel {
127 slug: String,
128 #[serde(default)]
129 priority: Option<i64>,
130 #[serde(default)]
131 context_window: Option<u32>,
132 #[serde(default)]
133 supported_reasoning_levels: Option<Vec<CacheReasoningLevel>>,
134 /// `hide` marks a roster entry the vendor does not offer for selection
135 /// (`gpt-reserve`, `codex-auto-review`). Absent means listable.
136 #[serde(default)]
137 visibility: Option<String>,
138 }
139
140 #[derive(Debug, Deserialize)]
141 struct CacheReasoningLevel {
142 #[serde(default)]
143 effort: Option<String>,
144 }
145
146 /// Resolve the Codex home without consulting OAuth-file overrides.
147 ///
148 /// `OPENAI_CODEX_AUTH_FILE` intentionally does not participate: it may point
149 /// at a standalone test/credential file while the model roster still belongs
150 /// to `$CODEX_HOME` (or the default `~/.codex`).
151 #[must_use]
152 pub(crate) fn codex_home_path() -> PathBuf {
153 std::env::var_os("CODEX_HOME")
154 .filter(|value| !value.is_empty())
155 .map(PathBuf::from)
156 .unwrap_or_else(|| {
157 crate::config::effective_home_dir()
158 .unwrap_or_else(|| PathBuf::from("."))
159 .join(".codex")
160 })
161 }
162
163 /// Last parsed roster, keyed by the identity of the file it came from.
164 ///
165 /// The roster is read from the picker's render path, and the cache file is a
166 /// couple hundred kilobytes of JSON (it carries full instruction templates),
167 /// so parsing it per frame is visible as input lag while a picker is open.
168 /// The key is the resolved path plus the file's mtime and length, so a Codex
169 /// refresh is picked up on the next call and a test pointing `CODEX_HOME`
170 /// somewhere else never reads another test's entry.
171 type RosterCacheKey = Vec<(PathBuf, Option<SystemTime>, u64)>;
172 static ROSTER_MEMO: Mutex<Option<(RosterCacheKey, CodexModelRoster)>> = Mutex::new(None);
173
174 #[must_use]
175 pub(crate) fn model_roster() -> CodexModelRoster {
176 let home = codex_home_path();
177 let snapshot_path = cli_snapshot_path(&home);
178 let key: RosterCacheKey = std::iter::once(home.join(MODEL_CACHE_FILE))
179 .chain(snapshot_path.iter().cloned())
180 .map(|path| match std::fs::symlink_metadata(&path) {
181 Ok(metadata) => (path, metadata.modified().ok(), metadata.len()),
182 Err(_) => (path, None, 0),
183 })
184 .collect();
185
186 // A `Fresh` roster ages into `Stale` on the clock, not on a file change,
187 // so the memo is only trusted while the entry it was built from still
188 // reads fresh.
189 if let Ok(memo) = ROSTER_MEMO.lock()
190 && let Some((cached_key, roster)) = memo.as_ref()
191 && *cached_key == key
192 && roster.freshness == CodexModelCacheFreshness::Fresh
193 && roster
194 .fetched_at
195 .or(roster.observed_at)
196 .is_some_and(|fetched| Utc::now().signed_duration_since(fetched) <= MODEL_CACHE_MAX_AGE)
197 {
198 return roster.clone();
199 }
200
201 let now = Utc::now();
202 let mut roster = load_model_roster_from_home_at(&home, now);
203 if let Some(snapshot) = snapshot_path.and_then(|path| load_cli_snapshot(&path, now))
204 && (roster.freshness != CodexModelCacheFreshness::Fresh
205 || snapshot.observed_at > roster.fetched_at)
206 {
207 roster = snapshot;
208 }
209 if let Ok(mut memo) = ROSTER_MEMO.lock() {
210 *memo = Some((key, roster.clone()));
211 }
212 roster
213 }
214
215 fn load_model_roster_from_home_at(home: &Path, now: DateTime<Utc>) -> CodexModelRoster {
216 let path = home.join(MODEL_CACHE_FILE);
217 let bytes = match read_cache_bytes(&path) {
218 Ok(bytes) => bytes,
219 Err(freshness) => return CodexModelRoster::fallback(freshness, None),
220 };
221 let cache: CacheFile = match serde_json::from_slice(&bytes) {
222 Ok(cache) => cache,
223 Err(_) => return CodexModelRoster::fallback(CodexModelCacheFreshness::Invalid, None),
224 };
225
226 let age = now.signed_duration_since(cache.fetched_at);
227 if age < -MAX_FUTURE_CLOCK_SKEW {
228 return CodexModelRoster::fallback(
229 CodexModelCacheFreshness::Invalid,
230 Some(cache.fetched_at),
231 );
232 }
233 if age > MODEL_CACHE_MAX_AGE {
234 return CodexModelRoster::fallback(CodexModelCacheFreshness::Stale, Some(cache.fetched_at));
235 }
236 // Codex owns this cache, but an observed file-based login replacement
237 // after its fetch invalidates the account attribution. No credential
238 // content is opened to check that boundary.
239 if std::fs::metadata(home.join("auth.json"))
240 .and_then(|metadata| metadata.modified())
241 .is_ok_and(|modified| DateTime::<Utc>::from(modified) > cache.fetched_at)
242 {
243 return CodexModelRoster::fallback(CodexModelCacheFreshness::Stale, Some(cache.fetched_at));
244 }
245
246 let mut indexed: Vec<_> = cache.models.into_iter().enumerate().collect();
247 indexed.sort_by_key(|(index, model)| (model.priority.unwrap_or(i64::MAX), *index));
248
249 let mut seen = HashSet::new();
250 let mut models = Vec::new();
251 for (_, model) in indexed {
252 let slug = model.slug.trim();
253 if !valid_model_id(slug) {
254 continue;
255 }
256 if model
257 .visibility
258 .as_deref()
259 .is_some_and(|visibility| visibility.trim().eq_ignore_ascii_case("hide"))
260 {
261 continue;
262 }
263 let identity = slug.to_ascii_lowercase();
264 if seen.insert(identity) {
265 let efforts: Vec<String> = model
266 .supported_reasoning_levels
267 .as_ref()
268 .map(|levels| {
269 levels
270 .iter()
271 .filter_map(|level| level.effort.as_deref())
272 .map(|effort| effort.trim().to_ascii_lowercase())
273 .filter(|effort| !effort.is_empty())
274 .collect()
275 })
276 .unwrap_or_default();
277 models.push(CodexModelMetadata {
278 id: slug.to_string(),
279 context_window: model
280 .context_window
281 .filter(|window| (1..=16_000_000).contains(window)),
282 reasoning: model
283 .supported_reasoning_levels
284 .as_ref()
285 .map(|levels| !levels.is_empty()),
286 efforts,
287 });
288 }
289 }
290 if models.is_empty() {
291 return CodexModelRoster::fallback(
292 CodexModelCacheFreshness::Invalid,
293 Some(cache.fetched_at),
294 );
295 }
296
297 CodexModelRoster {
298 models,
299 freshness: CodexModelCacheFreshness::Fresh,
300 fetched_at: Some(cache.fetched_at),
301 observed_at: None,
302 source: "codex_cli_cache",
303 observation_persisted: false,
304 }
305 }
306
307 fn read_cache_bytes(path: &Path) -> Result<Vec<u8>, CodexModelCacheFreshness> {
308 let path_metadata = match std::fs::symlink_metadata(path) {
309 Ok(metadata) => metadata,
310 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
311 return Err(CodexModelCacheFreshness::Missing);
312 }
313 Err(_) => return Err(CodexModelCacheFreshness::Invalid),
314 };
315 if !path_metadata.file_type().is_file() || path_metadata.len() > MAX_MODEL_CACHE_BYTES {
316 return Err(CodexModelCacheFreshness::Invalid);
317 }
318 let mut file = match open_cache_file(path) {
319 Ok(file) => file,
320 Err(_) => return Err(CodexModelCacheFreshness::Invalid),
321 };
322 let metadata = match file.metadata() {
323 Ok(metadata) => metadata,
324 Err(_) => return Err(CodexModelCacheFreshness::Invalid),
325 };
326 if !metadata.file_type().is_file() || metadata.len() > MAX_MODEL_CACHE_BYTES {
327 return Err(CodexModelCacheFreshness::Invalid);
328 }
329
330 let mut bytes = Vec::with_capacity(metadata.len().min(MAX_MODEL_CACHE_BYTES) as usize);
331 if file
332 .by_ref()
333 .take(MAX_MODEL_CACHE_BYTES + 1)
334 .read_to_end(&mut bytes)
335 .is_err()
336 || bytes.len() as u64 > MAX_MODEL_CACHE_BYTES
337 {
338 return Err(CodexModelCacheFreshness::Invalid);
339 }
340 Ok(bytes)
341 }
342
343 fn open_cache_file(path: &Path) -> std::io::Result<std::fs::File> {
344 let mut options = std::fs::OpenOptions::new();
345 options.read(true);
346 #[cfg(unix)]
347 options.custom_flags(libc::O_NOFOLLOW);
348 options.open(path)
349 }
350
351 fn valid_model_id(value: &str) -> bool {
352 !value.is_empty()
353 && value.len() <= 256
354 && value.bytes().any(|byte| byte.is_ascii_alphanumeric())
355 && value.bytes().all(|byte| {
356 byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'/' | b'-')
357 })
358 }
359
360 #[derive(Serialize, Deserialize)]
361 struct CliSnapshot {
362 observed_at: DateTime<Utc>,
363 models: Vec<CodexModelMetadata>,
364 }
365
366 fn cli_snapshot_path(home: &Path) -> Option<PathBuf> {
367 let catalog_path = crate::models_dev_live::cache_path()?;
368 let home = std::fs::canonicalize(home).ok()?;
369 // A URL sanitizer collapses absolute filesystem paths to its single
370 // invalid-URL value. Hash exact OS path bytes instead, with a new domain
371 // so the old unscoped observations are never imported.
372 let mut identity = Sha256::new();
373 identity.update(b"codewhale-codex-observation-v2\0");
374 identity.update(home.as_os_str().as_encoded_bytes());
375 identity.update(b"\0");
376 // Bind offline observations to the file-based login's version without
377 // reading tokens. Keyring-only accounts cannot prove an offline account
378 // binding here; their live command can still load a roster, with skipped
379 // persistence disclosed by the receipt.
380 let auth = std::fs::metadata(home.join("auth.json")).ok()?;
381 if !auth.is_file() {
382 return None;
383 }
384 identity.update(auth.len().to_le_bytes());
385 identity.update(
386 auth.modified()
387 .ok()?
388 .duration_since(SystemTime::UNIX_EPOCH)
389 .ok()?
390 .as_nanos()
391 .to_le_bytes(),
392 );
393 #[cfg(unix)]
394 {
395 use std::os::unix::fs::MetadataExt;
396 identity.update(auth.dev().to_le_bytes());
397 identity.update(auth.ino().to_le_bytes());
398 identity.update(auth.ctime().to_le_bytes());
399 identity.update(auth.ctime_nsec().to_le_bytes());
400 }
401 let identity: String = identity
402 .finalize()
403 .iter()
404 .map(|byte| format!("{byte:02x}"))
405 .collect();
406 Some(
407 catalog_path
408 .parent()?
409 .join(format!("codex-{identity}.json")),
410 )
411 }
412
413 fn valid_effort(effort: &str) -> bool {
414 !effort.is_empty()
415 && effort.len() <= 32
416 && effort
417 .bytes()
418 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-'))
419 }
420
421 fn load_cli_snapshot(path: &Path, now: DateTime<Utc>) -> Option<CodexModelRoster> {
422 let snapshot: CliSnapshot = serde_json::from_slice(&read_cache_bytes(path).ok()?).ok()?;
423 let age = now.signed_duration_since(snapshot.observed_at);
424 if age < -MAX_FUTURE_CLOCK_SKEW
425 || age > MODEL_CACHE_MAX_AGE
426 || snapshot.models.is_empty()
427 || snapshot.models.iter().any(|model| {
428 !valid_model_id(&model.id)
429 || model.efforts.len() > 16
430 || model.efforts.iter().any(|effort| !valid_effort(effort))
431 || model
432 .context_window
433 .is_some_and(|window| !(1..=16_000_000).contains(&window))
434 })
435 {
436 return None;
437 }
438 Some(CodexModelRoster {
439 models: snapshot.models,
440 freshness: CodexModelCacheFreshness::Fresh,
441 fetched_at: None,
442 observed_at: Some(snapshot.observed_at),
443 source: "codex_app_server",
444 observation_persisted: true,
445 })
446 }
447
448 /// Ask the credential-owning CLI for its account roster. This does not import
449 /// tokens, force token refresh, start a thread, or make an inference request.
450 pub(crate) async fn update_from_codex_cli() -> Result<CodexModelRoster, &'static str> {
451 update_from_codex_command(
452 tokio::process::Command::new("codex"),
453 std::time::Duration::from_secs(20),
454 )
455 .await
456 }
457
458 async fn update_from_codex_command(
459 command: tokio::process::Command,
460 timeout: std::time::Duration,
461 ) -> Result<CodexModelRoster, &'static str> {
462 let home = codex_home_path();
463 let path = cli_snapshot_path(&home);
464 let mut roster = query_codex_cli(command, timeout, &model_roster()).await?;
465 let Some(path) = path.filter(|before| cli_snapshot_path(&home).as_ref() == Some(before)) else {
466 // A login changed while querying, or its version cannot be observed.
467 // Preserve the command's live result, never an unbound offline copy.
468 return Ok(roster);
469 };
470 let snapshot = CliSnapshot {
471 observed_at: roster.observed_at.ok_or("codex_invalid_response")?,
472 models: roster.models.clone(),
473 };
474 let encoded = serde_json::to_vec(&snapshot).map_err(|_| "cache_write_failed")?;
475 if encoded.len() as u64 > MAX_MODEL_CACHE_BYTES {
476 return Err("codex_response_too_large");
477 }
478 codewhale_config::persistence::atomic_write(&path, &encoded)
479 .map_err(|_| "cache_write_failed")?;
480 if let Ok(mut memo) = ROSTER_MEMO.lock() {
481 *memo = None;
482 }
483 roster.observation_persisted = true;
484 Ok(roster)
485 }
486
487 struct CodexProcess(tokio::process::Child);
488
489 impl CodexProcess {
490 fn terminate(&mut self) {
491 // npm-installed CLIs can be wrappers. Kill only the process group
492 // created for this invocation, including a wrapped app-server.
493 #[cfg(unix)]
494 if let Some(pid) = self.0.id().and_then(|id| i32::try_from(id).ok()) {
495 // SAFETY: process_group(0) below gives this child its own group.
496 unsafe {
497 libc::kill(-pid, libc::SIGKILL);
498 }
499 }
500 let _ = self.0.start_kill();
501 }
502 }
503
504 impl Drop for CodexProcess {
505 fn drop(&mut self) {
506 self.terminate();
507 }
508 }
509
510 async fn query_codex_cli(
511 mut command: tokio::process::Command,
512 timeout: std::time::Duration,
513 previous: &CodexModelRoster,
514 ) -> Result<CodexModelRoster, &'static str> {
515 let directory = tempfile::tempdir().map_err(|_| "codex_temporary_directory_failed")?;
516 // Never forward Codewhale/provider keys or source paths to the CLI. Its
517 // own HOME/CODEX_HOME still selects its login and configuration.
518 command.env_clear();
519 for name in [
520 "PATH",
521 "HOME",
522 "USERPROFILE",
523 "CODEX_HOME",
524 "TMPDIR",
525 "TMP",
526 "TEMP",
527 "LANG",
528 "LC_ALL",
529 "SYSTEMROOT",
530 "WINDIR",
531 "PATHEXT",
532 ] {
533 if let Some(value) = std::env::var_os(name) {
534 command.env(name, value);
535 }
536 }
537 // A relative CODEX_HOME belongs to the caller's cwd, not the isolated
538 // directory used below to avoid forwarding workspace context to Codex.
539 command.env(
540 "CODEX_HOME",
541 std::path::absolute(codex_home_path()).map_err(|_| "codex_home_unavailable")?,
542 );
543 command
544 .args([
545 "app-server",
546 "--listen",
547 "stdio://",
548 "-c",
549 "analytics.enabled=false",
550 ])
551 .current_dir(directory.path())
552 .stdin(Stdio::piped())
553 .stdout(Stdio::piped())
554 .stderr(Stdio::null())
555 .kill_on_drop(true);
556 #[cfg(unix)]
557 command.process_group(0);
558 crate::utils::suppress_tokio_console_window(&mut command);
559 let mut child = CodexProcess(command.spawn().map_err(|_| "codex_cli_unavailable")?);
560 let mut stdin = child.0.stdin.take().ok_or("codex_stdio_unavailable")?;
561 let stdout = child.0.stdout.take().ok_or("codex_stdio_unavailable")?;
562 let mut stdout = BufReader::new(stdout.take(MAX_MODEL_CACHE_BYTES + 1));
563 let mut bytes_read = 0usize;
564 let result =
565 tokio::time::timeout(timeout, async {
566 send_rpc(&mut stdin, &json!({"id": 1, "method": "initialize", "params": {
567 "clientInfo": {"name": "codewhale_models", "version": env!("CARGO_PKG_VERSION")},
568 "capabilities": {"experimentalApi": false}
569 }})).await?;
570 read_rpc_result(&mut stdout, 1, &mut bytes_read).await?;
571 send_rpc(&mut stdin, &json!({"method": "initialized", "params": {}})).await?;
572 send_rpc(
573 &mut stdin,
574 &json!({"id": 2, "method": "account/read", "params": {"refreshToken": false}}),
575 )
576 .await?;
577 let account = read_rpc_result(&mut stdout, 2, &mut bytes_read).await?;
578 if account.pointer("/account/type").and_then(Value::as_str) != Some("chatgpt")
579 || account.get("requiresOpenaiAuth").and_then(Value::as_bool) != Some(true)
580 {
581 return Err("codex_chatgpt_login_required");
582 }
583 let mut models = Vec::new();
584 let mut cursor: Option<String> = None;
585 let mut cursors = HashSet::new();
586 for page in 0..20u64 {
587 let id = page + 3;
588 send_rpc(
589 &mut stdin,
590 &json!({"id": id, "method": "model/list", "params": {
591 "cursor": cursor, "limit": 100, "includeHidden": false
592 }}),
593 )
594 .await?;
595 let result = read_rpc_result(&mut stdout, id, &mut bytes_read).await?;
596 let page: CliModelPage =
597 serde_json::from_value(result).map_err(|_| "codex_invalid_response")?;
598 models.extend(page.data);
599 let Some(next) = page.next_cursor else {
600 return roster_from_cli_models(models, previous);
601 };
602 if next.is_empty() || next.len() > 4096 || !cursors.insert(next.clone()) {
603 return Err("codex_invalid_pagination");
604 }
605 cursor = Some(next);
606 }
607 Err("codex_pagination_limit")
608 })
609 .await
610 .unwrap_or(Err("codex_timeout"));
611 child.terminate();
612 // Bound both the conversation and process reaping. Drop also covers
613 // cancellation or errors before this cleanup point.
614 let _ = tokio::time::timeout(std::time::Duration::from_secs(2), child.0.wait()).await;
615 result
616 }
617
618 async fn send_rpc(
619 stdin: &mut tokio::process::ChildStdin,
620 value: &Value,
621 ) -> Result<(), &'static str> {
622 let mut bytes = serde_json::to_vec(value).map_err(|_| "codex_invalid_request")?;
623 bytes.push(b'\n');
624 stdin
625 .write_all(&bytes)
626 .await
627 .map_err(|_| "codex_stdio_failed")?;
628 stdin.flush().await.map_err(|_| "codex_stdio_failed")
629 }
630
631 async fn read_rpc_result<R: tokio::io::AsyncBufRead + Unpin>(
632 stdout: &mut R,
633 id: u64,
634 bytes_read: &mut usize,
635 ) -> Result<Value, &'static str> {
636 for _ in 0..256 {
637 let mut line = Vec::new();
638 let count = stdout
639 .read_until(b'\n', &mut line)
640 .await
641 .map_err(|_| "codex_stdio_failed")?;
642 *bytes_read += count;
643 if *bytes_read as u64 > MAX_MODEL_CACHE_BYTES {
644 return Err("codex_response_too_large");
645 }
646 if count == 0 {
647 return Err("codex_stdio_closed");
648 }
649 let response: Value =
650 serde_json::from_slice(&line).map_err(|_| "codex_invalid_response")?;
651 if let Some(response_id) = response.get("id") {
652 if response_id.as_u64() != Some(id) || response.get("method").is_some() {
653 return Err("codex_unexpected_request");
654 }
655 if response.get("error").is_some() {
656 return Err("codex_request_failed");
657 }
658 return response
659 .get("result")
660 .cloned()
661 .ok_or("codex_invalid_response");
662 }
663 if response.get("method").and_then(Value::as_str).is_none() {
664 return Err("codex_invalid_response");
665 }
666 }
667 Err("codex_notification_limit")
668 }
669
670 #[derive(Deserialize)]
671 #[serde(rename_all = "camelCase")]
672 struct CliModelPage {
673 data: Vec<CliModel>,
674 next_cursor: Option<String>,
675 }
676
677 #[derive(Deserialize)]
678 #[serde(rename_all = "camelCase")]
679 struct CliModel {
680 model: String,
681 hidden: bool,
682 is_default: bool,
683 supported_reasoning_efforts: Vec<CliReasoningEffort>,
684 }
685
686 #[derive(Deserialize)]
687 #[serde(rename_all = "camelCase")]
688 struct CliReasoningEffort {
689 reasoning_effort: String,
690 }
691
692 fn roster_from_cli_models(
693 mut entries: Vec<CliModel>,
694 previous: &CodexModelRoster,
695 ) -> Result<CodexModelRoster, &'static str> {
696 entries.sort_by_key(|model| !model.is_default);
697 let mut seen = HashSet::new();
698 let mut models = Vec::new();
699 for entry in entries.into_iter().filter(|model| !model.hidden) {
700 if !valid_model_id(&entry.model) || entry.supported_reasoning_efforts.len() > 16 {
701 return Err("codex_invalid_response");
702 }
703 let mut efforts = Vec::new();
704 for effort in entry.supported_reasoning_efforts {
705 let effort = effort.reasoning_effort.trim().to_ascii_lowercase();
706 if !valid_effort(&effort) {
707 return Err("codex_invalid_response");
708 }
709 if !efforts.contains(&effort) {
710 efforts.push(effort);
711 }
712 }
713 if seen.insert(entry.model.to_ascii_lowercase()) {
714 models.push(CodexModelMetadata {
715 context_window: previous
716 .metadata_for(&entry.model)
717 .and_then(|model| model.context_window),
718 id: entry.model,
719 reasoning: Some(!efforts.is_empty()),
720 efforts,
721 });
722 }
723 }
724 if models.is_empty() {
725 return Err("codex_empty_catalog");
726 }
727 Ok(CodexModelRoster {
728 models,
729 freshness: CodexModelCacheFreshness::Fresh,
730 fetched_at: None,
731 observed_at: Some(Utc::now()),
732 source: "codex_app_server",
733 observation_persisted: false,
734 })
735 }
736
737 #[cfg(test)]
738 mod tests {
739 use super::*;
740
741 const FIXTURE: &str = include_str!("../tests/fixtures/codex_models_cache.json");
742 const FIXTURE_TIME: &str = "2030-01-02T03:04:05Z";
743
744 fn fixture_time() -> DateTime<Utc> {
745 FIXTURE_TIME.parse().expect("fixture timestamp")
746 }
747
748 fn write_fixture(home: &Path) {
749 std::fs::write(home.join(MODEL_CACHE_FILE), FIXTURE).expect("write fixture");
750 }
751
752 #[cfg(unix)]
753 fn fake_codex(script: &str, trace: &Path) -> tokio::process::Command {
754 let mut command = tokio::process::Command::new("/bin/sh");
755 command.args(["-c", script, "fake-codex"]).arg(trace);
756 command
757 }
758
759 #[cfg(unix)]
760 const CLI_FIXTURE: &str = r#"
761 test -z "${OPENAI_API_KEY:-}${OPENAI_CODEX_ACCESS_TOKEN:-}${CODEWHALE_HOME:-}${PRIVATE_TASK_SECRET:-}" || exit 71
762 pwd > "$1"
763 while IFS= read -r line; do
764 printf '%s\n' "$line" >> "$1"
765 case "$line" in
766 *'"id":1,'*) printf '%s\n' '{"id":1,"result":{}}' ;;
767 *'"id":2,'*) printf '%s\n' '{"id":2,"result":{"account":{"type":"chatgpt","email":"private-account@example.invalid","planType":"pro"},"requiresOpenaiAuth":true}}' ;;
768 *'"id":3,'*) printf '%s\n' '{"method":"account/updated","params":{}}' '{"id":3,"result":{"data":[{"model":"gpt-test-secondary","hidden":false,"isDefault":false,"supportedReasoningEfforts":[]},{"model":"hidden-test-model","hidden":true,"isDefault":false,"supportedReasoningEfforts":[]}],"nextCursor":"page-two"}}' ;;
769 *'"id":4,'*) printf '%s\n' '{"id":4,"result":{"data":[{"model":"gpt-new-account-model","hidden":false,"isDefault":true,"supportedReasoningEfforts":[{"reasoningEffort":"high"},{"reasoningEffort":"ultra"},{"reasoningEffort":"high"}]}],"nextCursor":null}}' ;;
770 esac
771 done
772 "#;
773
774 #[cfg(unix)]
775 #[tokio::test]
776 async fn codex_cli_loads_paginated_roster_without_forwarding_credentials_and_persists_offline()
777 {
778 let _lock = crate::test_support::lock_test_env();
779 let home = tempfile::tempdir().unwrap();
780 let _codex = crate::test_support::EnvVarGuard::set("CODEX_HOME", home.path().join("codex"));
781 let _codewhale =
782 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path().join("codewhale"));
783 std::fs::create_dir_all(codex_home_path()).unwrap();
784 std::fs::write(codex_home_path().join("auth.json"), "fixture login").unwrap();
785 let _key = crate::test_support::EnvVarGuard::set("OPENAI_API_KEY", "private-test-key");
786 let _token = crate::test_support::EnvVarGuard::set(
787 "OPENAI_CODEX_ACCESS_TOKEN",
788 "private-test-token",
789 );
790 let _secret =
791 crate::test_support::EnvVarGuard::set("PRIVATE_TASK_SECRET", "private-task-value");
792 let trace = home.path().join("trace");
793 let roster = update_from_codex_command(
794 fake_codex(CLI_FIXTURE, &trace),
795 std::time::Duration::from_secs(3),
796 )
797 .await
798 .unwrap();
799 assert_eq!(
800 roster.model_ids(),
801 ["gpt-new-account-model", "gpt-test-secondary"]
802 );
803 assert_eq!(
804 roster
805 .metadata_for("gpt-new-account-model")
806 .unwrap()
807 .efforts,
808 ["high", "ultra"]
809 );
810 assert_eq!(roster.fetched_at, None);
811 assert!(roster.observed_at.is_some());
812 assert_eq!(roster.source, "codex_app_server");
813 let trace = std::fs::read_to_string(trace).unwrap();
814 let mut lines = trace.lines();
815 assert_ne!(
816 Path::new(lines.next().unwrap()),
817 std::env::current_dir().unwrap()
818 );
819 let requests: Vec<Value> = lines
820 .map(|line| serde_json::from_str(line).unwrap())
821 .collect();
822 assert_eq!(
823 requests
824 .iter()
825 .map(|r| r["method"].as_str().unwrap())
826 .collect::<Vec<_>>(),
827 [
828 "initialize",
829 "initialized",
830 "account/read",
831 "model/list",
832 "model/list"
833 ]
834 );
835 assert_eq!(requests[2]["params"]["refreshToken"], false);
836 assert_eq!(requests[4]["params"]["cursor"], "page-two");
837 assert_eq!(requests[3]["params"]["includeHidden"], false);
838 let path = cli_snapshot_path(&codex_home_path()).unwrap();
839 let bytes = std::fs::read_to_string(&path).unwrap();
840 assert!(!bytes.contains("private-"));
841 assert!(!bytes.contains("hidden-test-model"));
842 *ROSTER_MEMO.lock().unwrap() = None;
843 assert_eq!(model_roster(), roster);
844 let before = std::fs::read(&path).unwrap();
845 let failure = update_from_codex_command(
846 tokio::process::Command::new(home.path().join("missing-codex")),
847 std::time::Duration::from_secs(1),
848 )
849 .await;
850 assert_eq!(failure.unwrap_err(), "codex_cli_unavailable");
851 assert_eq!(std::fs::read(&path).unwrap(), before);
852 assert_eq!(model_roster(), roster);
853 }
854
855 #[cfg(unix)]
856 #[tokio::test]
857 async fn codex_observations_are_isolated_by_absolute_home_and_login_version() {
858 let _lock = crate::test_support::lock_test_env();
859 let fixture = tempfile::tempdir().unwrap();
860 let _codewhale = crate::test_support::EnvVarGuard::set(
861 "CODEWHALE_HOME",
862 fixture.path().join("codewhale"),
863 );
864 let first = fixture.path().join("account-a");
865 let second = fixture.path().join("account-b");
866 for home in [&first, &second] {
867 std::fs::create_dir(home).unwrap();
868 std::fs::write(home.join("auth.json"), "fixture login").unwrap();
869 }
870 let first_path = cli_snapshot_path(&first).unwrap();
871 let second_path = cli_snapshot_path(&second).unwrap();
872 assert_ne!(
873 first_path, second_path,
874 "absolute homes must not share an observation"
875 );
876 let trace = fixture.path().join("trace");
877 {
878 let _codex = crate::test_support::EnvVarGuard::set("CODEX_HOME", &first);
879 let roster = update_from_codex_command(
880 fake_codex(
881 &CLI_FIXTURE.replace("gpt-new-account-model", "gpt-account-a"),
882 &trace,
883 ),
884 std::time::Duration::from_secs(3),
885 )
886 .await
887 .unwrap();
888 assert!(roster.observation_persisted);
889 assert_eq!(model_roster().model_ids()[0], "gpt-account-a");
890 }
891 {
892 let _codex = crate::test_support::EnvVarGuard::set("CODEX_HOME", &second);
893 assert_eq!(model_roster().model_ids(), [DEFAULT_OPENAI_CODEX_MODEL]);
894 update_from_codex_command(
895 fake_codex(
896 &CLI_FIXTURE.replace("gpt-new-account-model", "gpt-account-b"),
897 &trace,
898 ),
899 std::time::Duration::from_secs(3),
900 )
901 .await
902 .unwrap();
903 assert_eq!(model_roster().model_ids()[0], "gpt-account-b");
904 }
905 let _codex = crate::test_support::EnvVarGuard::set("CODEX_HOME", &first);
906 assert_eq!(model_roster().model_ids()[0], "gpt-account-a");
907 let previous = std::fs::read(&first_path).unwrap();
908 std::fs::write(first.join("auth.json"), "replacement fixture login").unwrap();
909 assert_ne!(cli_snapshot_path(&first).unwrap(), first_path);
910 assert_eq!(model_roster().model_ids(), [DEFAULT_OPENAI_CODEX_MODEL]);
911 assert_eq!(
912 std::fs::read(&first_path).unwrap(),
913 previous,
914 "old cache remains untouched"
915 );
916
917 // A login replacement during the CLI query also prevents persistence.
918 let script =
919 format!("printf '%s' 'rotated fixture' > \"$CODEX_HOME/auth.json\"\n{CLI_FIXTURE}");
920 let roster = update_from_codex_command(
921 fake_codex(&script, &trace),
922 std::time::Duration::from_secs(3),
923 )
924 .await
925 .unwrap();
926 assert!(!roster.observation_persisted);
927 assert_eq!(roster.model_ids()[0], "gpt-new-account-model");
928 assert!(!cli_snapshot_path(&first).unwrap().exists());
929 }
930
931 #[cfg(unix)]
932 #[tokio::test]
933 async fn codex_live_observation_remains_usable_without_a_bound_login_file() {
934 let _lock = crate::test_support::lock_test_env();
935 let fixture = tempfile::tempdir().unwrap();
936 let _codewhale = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", fixture.path());
937 let _codex =
938 crate::test_support::EnvVarGuard::set("CODEX_HOME", "codex-relative-test-home");
939 let trace = fixture.path().join("trace");
940 let script = format!("test \"$CODEX_HOME\" = \"$2\" || exit 72\n{CLI_FIXTURE}");
941 let mut command = fake_codex(&script, &trace);
942 command.arg(std::path::absolute("codex-relative-test-home").unwrap());
943 let roster = update_from_codex_command(command, std::time::Duration::from_secs(3))
944 .await
945 .unwrap();
946 assert_eq!(roster.model_ids()[0], "gpt-new-account-model");
947 assert!(!roster.observation_persisted);
948 assert!(cli_snapshot_path(&codex_home_path()).is_none());
949 assert_eq!(model_roster().model_ids(), [DEFAULT_OPENAI_CODEX_MODEL]);
950 }
951
952 #[test]
953 fn codex_native_cache_predating_an_observed_login_change_is_stale() {
954 let home = tempfile::tempdir().unwrap();
955 std::fs::write(home.path().join("auth.json"), "fixture login").unwrap();
956 let now = Utc::now();
957 let mut cache: Value = serde_json::from_str(FIXTURE).unwrap();
958 cache["fetched_at"] = json!(now - Duration::minutes(1));
959 std::fs::write(
960 home.path().join(MODEL_CACHE_FILE),
961 serde_json::to_vec(&cache).unwrap(),
962 )
963 .unwrap();
964 assert_eq!(
965 load_model_roster_from_home_at(home.path(), now).freshness,
966 CodexModelCacheFreshness::Stale,
967 );
968 cache["fetched_at"] = json!(now);
969 std::fs::write(
970 home.path().join(MODEL_CACHE_FILE),
971 serde_json::to_vec(&cache).unwrap(),
972 )
973 .unwrap();
974 assert_eq!(
975 load_model_roster_from_home_at(home.path(), now).freshness,
976 CodexModelCacheFreshness::Fresh,
977 );
978 }
979
980 #[cfg(unix)]
981 #[tokio::test]
982 async fn codex_cli_rejects_other_auth_modes_and_invalid_pagination() {
983 let home = tempfile::tempdir().unwrap();
984 let fallback = CodexModelRoster::fallback(CodexModelCacheFreshness::Missing, None);
985 for (script, expected) in [
986 (
987 CLI_FIXTURE.replace("\"type\":\"chatgpt\"", "\"type\":\"apiKey\""),
988 "codex_chatgpt_login_required",
989 ),
990 (
991 CLI_FIXTURE.replace(
992 "\"requiresOpenaiAuth\":true",
993 "\"requiresOpenaiAuth\":false",
994 ),
995 "codex_chatgpt_login_required",
996 ),
997 (
998 CLI_FIXTURE.replace("\"nextCursor\":null", "\"nextCursor\":\"page-two\""),
999 "codex_invalid_pagination",
1000 ),
1001 (
1002 CLI_FIXTURE.replace("gpt-new-account-model", "bad model"),
1003 "codex_invalid_response",
1004 ),
1005 (
1006 CLI_FIXTURE.replace(
1007 "\"reasoningEffort\":\"ultra\"",
1008 "\"reasoningEffort\":\"bad effort\"",
1009 ),
1010 "codex_invalid_response",
1011 ),
1012 ] {
1013 let trace = home.path().join("trace");
1014 let error = query_codex_cli(
1015 fake_codex(&script, &trace),
1016 std::time::Duration::from_secs(3),
1017 &fallback,
1018 )
1019 .await
1020 .unwrap_err();
1021 assert_eq!(error, expected);
1022 if expected == "codex_chatgpt_login_required" {
1023 assert!(
1024 !std::fs::read_to_string(trace)
1025 .unwrap()
1026 .contains("model/list")
1027 );
1028 }
1029 }
1030 }
1031
1032 #[tokio::test]
1033 async fn codex_rpc_bounds_output_and_sanitizes_protocol_errors() {
1034 for (bytes, expected) in [
1035 (
1036 b"{\"id\":3,\"error\":{\"message\":\"private-secret\"}}\n".to_vec(),
1037 "codex_request_failed",
1038 ),
1039 (
1040 b"{\"id\":3,\"method\":\"account/chatgptAuthTokens/refresh\"}\n".to_vec(),
1041 "codex_unexpected_request",
1042 ),
1043 (
1044 b"{\"id\":9,\"result\":{}}\n".to_vec(),
1045 "codex_unexpected_request",
1046 ),
1047 (
1048 vec![b'x'; MAX_MODEL_CACHE_BYTES as usize + 1],
1049 "codex_response_too_large",
1050 ),
1051 (
1052 b"{\"method\":\"noise\"}\n".repeat(256),
1053 "codex_notification_limit",
1054 ),
1055 ] {
1056 let mut input = bytes.as_slice();
1057 assert_eq!(
1058 read_rpc_result(&mut input, 3, &mut 0).await.unwrap_err(),
1059 expected
1060 );
1061 }
1062 }
1063
1064 #[cfg(unix)]
1065 #[tokio::test]
1066 async fn codex_cli_timeout_and_cancellation_stop_the_wrapper_and_child() {
1067 let home = tempfile::tempdir().unwrap();
1068 let fallback = CodexModelRoster::fallback(CodexModelCacheFreshness::Missing, None);
1069 for cancel in [false, true] {
1070 let trace = home.path().join(if cancel { "cancel" } else { "timeout" });
1071 let command = fake_codex(
1072 "sleep 300 &\nprintf '%s %s' \"$$\" \"$!\" > \"$1\"\nwait",
1073 &trace,
1074 );
1075 let query = query_codex_cli(command, std::time::Duration::from_millis(500), &fallback);
1076 if cancel {
1077 let mut query = Box::pin(query);
1078 tokio::select! {
1079 _ = &mut query => panic!("CLI should remain pending"),
1080 _ = tokio::time::sleep(std::time::Duration::from_millis(250)) => {}
1081 }
1082 drop(query);
1083 } else {
1084 assert_eq!(query.await.unwrap_err(), "codex_timeout");
1085 }
1086 let pids = std::fs::read_to_string(trace).unwrap();
1087 for pid in pids.split_whitespace() {
1088 let mut running = true;
1089 for _ in 0..40 {
1090 let status = tokio::process::Command::new("/bin/ps")
1091 .args(["-o", "stat=", "-p", pid])
1092 .output()
1093 .await
1094 .unwrap();
1095 let state = String::from_utf8_lossy(&status.stdout);
1096 running = !state.trim().is_empty() && !state.trim().starts_with('Z');
1097 if !running {
1098 break;
1099 }
1100 tokio::time::sleep(std::time::Duration::from_millis(25)).await;
1101 }
1102 assert!(!running, "owned Codex child survived cleanup");
1103 }
1104 }
1105 }
1106
1107 #[test]
1108 fn codex_cli_snapshot_age_and_ids_are_validated() {
1109 let home = tempfile::tempdir().unwrap();
1110 let path = home.path().join("roster.json");
1111 let snapshot = CliSnapshot {
1112 observed_at: fixture_time(),
1113 models: vec![CodexModelMetadata {
1114 id: "gpt-account-model".to_string(),
1115 context_window: None,
1116 reasoning: Some(true),
1117 efforts: vec!["ultra".to_string()],
1118 }],
1119 };
1120 codewhale_config::persistence::atomic_write_json(&path, &snapshot).unwrap();
1121 assert!(load_cli_snapshot(&path, fixture_time()).is_some());
1122 assert!(load_cli_snapshot(&path, fixture_time() + Duration::hours(25)).is_none());
1123 assert!(load_cli_snapshot(&path, fixture_time() - Duration::hours(1)).is_none());
1124 let mut invalid = snapshot;
1125 invalid.models[0].id = "bad\u{1b}model".to_string();
1126 codewhale_config::persistence::atomic_write_json(&path, &invalid).unwrap();
1127 assert!(load_cli_snapshot(&path, fixture_time()).is_none());
1128 }
1129
1130 #[test]
1131 fn valid_cache_uses_priority_order_and_drops_vendor_hidden_models() {
1132 let home = tempfile::tempdir().expect("temp CODEX_HOME");
1133 write_fixture(home.path());
1134
1135 let roster =
1136 load_model_roster_from_home_at(home.path(), fixture_time() + Duration::minutes(30));
1137
1138 assert_eq!(roster.freshness, CodexModelCacheFreshness::Fresh);
1139 assert_eq!(roster.fetched_at, Some(fixture_time()));
1140 // `codex-test-review` is `visibility: hide` in the fixture, the same
1141 // marker the live roster puts on `gpt-reserve` and `codex-auto-review`.
1142 // A model the vendor does not offer must not reach the picker.
1143 assert_eq!(
1144 roster.model_ids(),
1145 ["gpt-test-primary", "gpt-test-secondary"]
1146 );
1147 assert!(roster.metadata_for("codex-test-review").is_none());
1148 let primary = roster
1149 .metadata_for("gpt-test-primary")
1150 .expect("primary metadata");
1151 assert_eq!(primary.context_window, Some(372_000));
1152 assert_eq!(primary.reasoning, Some(true));
1153 let secondary = roster
1154 .metadata_for("gpt-test-secondary")
1155 .expect("secondary metadata");
1156 assert_eq!(secondary.context_window, Some(128_000));
1157 // The effort names survive parsing: the picker builds a per-model
1158 // thinking ladder from them instead of one static list per provider.
1159 assert_eq!(primary.efforts, ["high"]);
1160 assert_eq!(secondary.efforts, ["medium"]);
1161 }
1162
1163 #[test]
1164 fn missing_cache_falls_back_conservatively() {
1165 let home = tempfile::tempdir().expect("temp CODEX_HOME");
1166 let roster = load_model_roster_from_home_at(home.path(), fixture_time());
1167
1168 assert_eq!(roster.freshness, CodexModelCacheFreshness::Missing);
1169 assert_eq!(roster.model_ids(), [DEFAULT_OPENAI_CODEX_MODEL]);
1170 }
1171
1172 #[test]
1173 fn preferred_model_is_the_fresh_roster_head_only() {
1174 let home = tempfile::tempdir().expect("temp CODEX_HOME");
1175 write_fixture(home.path());
1176
1177 let fresh =
1178 load_model_roster_from_home_at(home.path(), fixture_time() + Duration::minutes(30));
1179 assert_eq!(fresh.preferred_model_id(), Some("gpt-test-primary"));
1180
1181 // Stale and missing rosters must keep the static seed default so a
1182 // provider switch never trusts outdated route knowledge (#5034).
1183 let stale =
1184 load_model_roster_from_home_at(home.path(), fixture_time() + Duration::days(365));
1185 assert_eq!(stale.preferred_model_id(), None);
1186 let missing = load_model_roster_from_home_at(
1187 tempfile::tempdir().expect("empty home").path(),
1188 fixture_time(),
1189 );
1190 assert_eq!(missing.preferred_model_id(), None);
1191 }
1192
1193 #[test]
1194 fn malformed_cache_falls_back_conservatively() {
1195 let home = tempfile::tempdir().expect("temp CODEX_HOME");
1196 std::fs::write(home.path().join(MODEL_CACHE_FILE), b"{not-json")
1197 .expect("write malformed cache");
1198
1199 let roster = load_model_roster_from_home_at(home.path(), fixture_time());
1200
1201 assert_eq!(roster.freshness, CodexModelCacheFreshness::Invalid);
1202 assert_eq!(roster.model_ids(), [DEFAULT_OPENAI_CODEX_MODEL]);
1203 }
1204
1205 #[test]
1206 fn oversized_cache_is_rejected_without_unbounded_read() {
1207 let home = tempfile::tempdir().expect("temp CODEX_HOME");
1208 let file = std::fs::File::create(home.path().join(MODEL_CACHE_FILE)).expect("cache file");
1209 file.set_len(MAX_MODEL_CACHE_BYTES + 1)
1210 .expect("sparse oversized cache");
1211
1212 let roster = load_model_roster_from_home_at(home.path(), fixture_time());
1213
1214 assert_eq!(roster.freshness, CodexModelCacheFreshness::Invalid);
1215 }
1216
1217 #[cfg(unix)]
1218 #[test]
1219 fn symlink_cache_is_rejected_as_non_regular_input() {
1220 let home = tempfile::tempdir().expect("temp CODEX_HOME");
1221 let target = home.path().join("target.json");
1222 std::fs::write(&target, FIXTURE).expect("target fixture");
1223 std::os::unix::fs::symlink(&target, home.path().join(MODEL_CACHE_FILE))
1224 .expect("cache symlink");
1225
1226 let roster = load_model_roster_from_home_at(home.path(), fixture_time());
1227
1228 assert_eq!(roster.freshness, CodexModelCacheFreshness::Invalid);
1229 }
1230
1231 #[test]
1232 fn stale_cache_falls_back_conservatively() {
1233 let home = tempfile::tempdir().expect("temp CODEX_HOME");
1234 write_fixture(home.path());
1235
1236 let roster =
1237 load_model_roster_from_home_at(home.path(), fixture_time() + Duration::hours(25));
1238
1239 assert_eq!(roster.freshness, CodexModelCacheFreshness::Stale);
1240 assert_eq!(roster.model_ids(), [DEFAULT_OPENAI_CODEX_MODEL]);
1241 assert_eq!(roster.fetched_at, Some(fixture_time()));
1242 }
1243
1244 #[test]
1245 fn invalid_and_duplicate_model_ids_are_filtered() {
1246 let home = tempfile::tempdir().expect("temp CODEX_HOME");
1247 let cache = format!(
1248 r#"{{
1249 "fetched_at": "{FIXTURE_TIME}",
1250 "models": [
1251 {{"slug": "gpt-good", "priority": 3}},
1252 {{"slug": "GPT-GOOD", "priority": 4}},
1253 {{"slug": "bad model", "priority": 1}},
1254 {{"slug": "../bad\\path", "priority": 2}}
1255 ]
1256 }}"#
1257 );
1258 std::fs::write(home.path().join(MODEL_CACHE_FILE), cache).expect("write cache");
1259
1260 let roster = load_model_roster_from_home_at(home.path(), fixture_time());
1261
1262 assert_eq!(roster.freshness, CodexModelCacheFreshness::Fresh);
1263 assert_eq!(roster.model_ids(), ["gpt-good"]);
1264 }
1265
1266 #[test]
1267 fn codex_home_respects_environment_override() {
1268 let lock = crate::test_support::lock_test_env();
1269 let home = tempfile::tempdir().expect("temp CODEX_HOME");
1270 let guard = crate::test_support::EnvVarGuard::set("CODEX_HOME", home.path());
1271
1272 assert_eq!(codex_home_path(), home.path());
1273
1274 drop(guard);
1275 drop(lock);
1276 }
1277 }
1278
1278 lines RUST