| 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 |