1use crate::errors::AppError;
17use crate::extract::codex_compat::codex_supports_ask_for_approval;
18use crate::extraction::{ExtractedUrl, ExtractionResult};
19use crate::spawn::env_whitelist::apply_env_whitelist;
20use crate::storage::entities::{NewEntity, NewRelationship};
21use serde::{Deserialize, Serialize};
22use std::path::{Path, PathBuf};
23use std::process::{Command, Stdio};
24
25#[derive(Debug, Clone, Default, Deserialize, Serialize)]
27pub struct CodexUsage {
28 #[serde(default)]
29 pub input_tokens: u64,
30 #[serde(default)]
31 pub cached_input_tokens: u64,
32 #[serde(default)]
33 pub output_tokens: u64,
34 #[serde(default)]
35 pub reasoning_output_tokens: u64,
36}
37
38#[derive(Debug)]
40pub struct CodexResult {
41 pub extraction: ExtractionResult,
42 pub last_agent_text: String,
47 pub usage: Option<CodexUsage>,
48 pub rate_limited: bool,
49 pub schema_error: bool,
50 pub turn_failed: bool,
51 pub failed_message: String,
52}
53
54#[allow(rustdoc::broken_intra_doc_links)]
56pub struct CodexSpawnArgs<'a> {
57 pub binary: &'a Path,
58 pub prompt: &'a str,
59 pub json_schema: &'a str,
60 pub input_text: &'a str,
61 pub model: Option<&'a str>,
62 pub timeout_secs: u64,
63 pub schema_path: PathBuf,
67}
68
69pub fn trusted_schema_path() -> Result<PathBuf, AppError> {
72 let cache = crate::paths::AppPaths::resolve(None)
73 .map(|p| p.models.parent().map(|m| m.to_path_buf()))
74 .ok()
75 .flatten()
76 .unwrap_or_else(std::env::temp_dir);
77 std::fs::create_dir_all(&cache).map_err(AppError::Io)?;
78 Ok(cache.join(format!("enrich-schema-{}.json", std::process::id())))
79}
80
81pub const CODEX_PRO_OAUTH_MODELS: &[&str] = &[
88 "codex-auto-review",
89 "gpt-5.3-codex-spark",
90 "gpt-5.4",
91 "gpt-5.4-mini",
92 "gpt-5.5",
93];
94
95pub fn validate_codex_model(model: Option<&str>) -> Result<(), AppError> {
101 let Some(m) = model else {
102 return Ok(()); };
104 if CODEX_PRO_OAUTH_MODELS.contains(&m) {
105 Ok(())
106 } else {
107 Err(AppError::Validation(format!(
108 "--codex-model {m:?} is not supported with ChatGPT Pro OAuth. \
109 Accepted: {}",
110 CODEX_PRO_OAUTH_MODELS.join(", ")
111 )))
112 }
113}
114
115pub fn list_codex_models() -> Vec<String> {
129 use std::collections::BTreeSet;
130 let mut out: BTreeSet<String> = CODEX_PRO_OAUTH_MODELS
131 .iter()
132 .map(|s| s.to_string())
133 .collect();
134
135 if let Some(home) = std::env::var_os("HOME") {
136 let path = std::path::Path::new(&home)
137 .join(".codex")
138 .join("models_cache.json");
139 if let Ok(content) = std::fs::read_to_string(&path) {
140 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&content) {
141 if let Some(obj) = value.as_object() {
142 if let Some(models_arr) = obj.get("models").and_then(|m| m.as_array()) {
146 for v in models_arr {
147 if let Some(slug) = v.get("slug").and_then(|s| s.as_str()) {
148 out.insert(slug.to_string());
149 } else if let Some(s) = v.as_str() {
150 out.insert(s.to_string());
151 }
152 }
153 } else {
154 for key in obj.keys() {
155 out.insert(key.clone());
156 }
157 }
158 } else if let Some(arr) = value.as_array() {
159 for v in arr {
160 if let Some(s) = v.as_str() {
161 out.insert(s.to_string());
162 }
163 }
164 }
165 }
166 }
167 }
168 out.into_iter().collect()
169}
170
171pub fn suggest_codex_model(query: &str) -> Option<String> {
177 let query_lc = query.to_ascii_lowercase();
178 let models = list_codex_model_lc();
179
180 for m in &models {
182 if m.contains(&query_lc) {
183 return Some(m.clone());
184 }
185 }
186
187 let max_distance = (query.len() / 3).max(2);
189 let mut best: Option<(usize, String)> = None;
190 for m in &models {
191 let d = levenshtein(query_lc.as_str(), m.as_str());
192 if d <= max_distance && best.as_ref().is_none_or(|(bd, _)| d < *bd) {
193 best = Some((d, m.clone()));
194 }
195 }
196 best.map(|(_, m)| m)
197}
198
199fn list_codex_model_lc() -> Vec<String> {
200 list_codex_models()
201 .into_iter()
202 .map(|s| s.to_ascii_lowercase())
203 .collect()
204}
205
206fn levenshtein(a: &str, b: &str) -> usize {
207 let a_chars: Vec<char> = a.chars().collect();
208 let b_chars: Vec<char> = b.chars().collect();
209 if a_chars.is_empty() {
210 return b_chars.len();
211 }
212 if b_chars.is_empty() {
213 return a_chars.len();
214 }
215 let mut prev: Vec<usize> = (0..=b_chars.len()).collect();
216 let mut curr = vec![0; b_chars.len() + 1];
217 for (i, &ac) in a_chars.iter().enumerate() {
218 curr[0] = i + 1;
219 for (j, &bc) in b_chars.iter().enumerate() {
220 let cost = if ac == bc { 0 } else { 1 };
221 curr[j + 1] = (curr[j] + 1).min(prev[j + 1] + 1).min(prev[j] + cost);
222 }
223 std::mem::swap(&mut prev, &mut curr);
224 }
225 prev[b_chars.len()]
226}
227
228pub fn build_codex_command(args: &CodexSpawnArgs<'_>) -> Result<Command, crate::errors::AppError> {
255 let full_prompt = format!("{}\n\n{}", args.prompt, args.input_text);
256
257 if let Ok(_key) = std::env::var("OPENAI_API_KEY") {
261 let mut cmd = Command::new("false");
262 cmd.env_clear();
263 cmd.env("PATH", "/nonexistent");
264 cmd.arg("--oauth-only-violation-openai-api-key-set");
265 cmd.arg("--oauth-only-resolution-use-codex-auth-json-or-openai-base-url");
266 return Ok(cmd);
267 }
268
269 std::fs::write(&args.schema_path, args.json_schema).ok();
272
273 let mut cmd = Command::new(args.binary);
274 apply_env_whitelist(&mut cmd, crate::spawn::env_whitelist::is_strict_env_clear());
279 crate::spawn::apply_cwd_isolation(&mut cmd)?;
280
281 if let Some(isolated) = prepare_isolated_codex_home_spawn() {
286 cmd.env("CODEX_HOME", isolated);
287 }
288
289 cmd.arg("exec")
293 .arg("-c")
294 .arg("sandbox_mode='read-only'")
295 .arg("-c")
296 .arg("approval_policy='never'")
297 .arg("--json")
298 .arg("--output-schema")
299 .arg(&args.schema_path)
300 .arg("--ephemeral")
301 .arg("--skip-git-repo-check")
302 .arg("--sandbox")
303 .arg("read-only")
304 .arg("--ignore-user-config")
305 .arg("--ignore-rules");
306
307 if codex_supports_ask_for_approval() {
316 cmd.arg("--ask-for-approval").arg("never");
317 }
318
319 if let Some(m) = args.model {
320 cmd.arg("-m").arg(m);
321 }
322
323 cmd.arg("-");
325
326 cmd.stdin(Stdio::piped())
327 .stdout(Stdio::piped())
328 .stderr(Stdio::piped());
329 let _ = full_prompt; let argv_refs: Vec<std::ffi::OsString> = cmd.get_args().map(|s| s.to_os_string()).collect();
337 let preflight_args = crate::spawn::preflight::PreFlightArgs {
338 binary_path: args.binary,
339 argv: &argv_refs,
340 workspace_root: std::path::Path::new("."),
341 mcp_config_inline_json: None, expected_output_bytes: 65_536,
343 spawner_name: "codex_spawn",
344 };
345 if let Err(e) = crate::spawn::preflight::preflight_check(&preflight_args) {
346 return Err(crate::errors::AppError::from(e));
352 }
353
354 Ok(cmd)
355}
356
357pub fn parse_codex_jsonl(stdout: &str) -> Result<CodexResult, AppError> {
370 let mut last_agent_text: Option<String> = None;
371 let mut usage: Option<CodexUsage> = None;
372 let mut rate_limited = false;
373 let mut schema_error = false;
374 let mut turn_failed = false;
375 let mut failed_message = String::new();
376
377 for line in stdout.lines() {
378 let line = line.trim();
379 if line.is_empty() {
380 continue;
381 }
382
383 let event: serde_json::Value = match serde_json::from_str(line) {
384 Ok(v) => v,
385 Err(_) => {
386 tracing::warn!(target: "codex_spawn", line, "skipping malformed JSONL line");
387 continue;
388 }
389 };
390
391 let event_type = match event.get("type").and_then(|t| t.as_str()) {
392 Some(t) => t,
393 None => continue,
394 };
395
396 match event_type {
397 "item.completed" => {
398 if let Some(item) = event.get("item") {
399 if item.get("type").and_then(|t| t.as_str()) == Some("agent_message") {
400 if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
401 last_agent_text = Some(text.to_string());
402 }
403 }
404 }
405 }
406 "turn.completed" => {
407 if let Some(u) = event.get("usage") {
408 let is_populated = u
413 .get("input_tokens")
414 .and_then(|v| v.as_u64())
415 .map(|n| n > 0)
416 .unwrap_or(false)
417 || u.get("output_tokens")
418 .and_then(|v| v.as_u64())
419 .map(|n| n > 0)
420 .unwrap_or(false);
421 if is_populated {
422 if let Ok(parsed) = serde_json::from_value::<CodexUsage>(u.clone()) {
423 usage = Some(parsed);
424 }
425 }
426 }
427 }
428 "turn.failed" => {
429 turn_failed = true;
430 if let Some(err) = event.get("error") {
431 let msg = err
432 .get("message")
433 .and_then(|m| m.as_str())
434 .unwrap_or("unknown error");
435 failed_message = msg.to_string();
436 if msg.contains("rate_limit")
437 || msg.contains("429")
438 || msg.contains("Too Many Requests")
439 {
440 rate_limited = true;
441 }
442 }
443 }
444 "error" => {
445 if let Some(msg) = event.get("message").and_then(|m| m.as_str()) {
446 if msg.contains("invalid_json_schema") || msg.contains("schema") {
447 schema_error = true;
448 }
449 }
450 }
451 _ => {}
452 }
453 }
454
455 let text = last_agent_text.ok_or_else(|| {
456 AppError::Validation(format!(
457 "no agent_message in codex JSONL output (rate_limited={rate_limited}, schema_error={schema_error}, turn_failed={turn_failed})"
458 ))
459 })?;
460
461 if turn_failed {
462 return Err(AppError::Validation(format!(
463 "codex turn failed: {failed_message}"
464 )));
465 }
466 if schema_error {
467 return Err(AppError::Validation(
468 "codex reported invalid_json_schema; check the --output-schema file".to_string(),
469 ));
470 }
471 if rate_limited {
472 return Err(AppError::Validation(format!(
473 "codex rate-limited: {failed_message}"
474 )));
475 }
476
477 let extraction = parse_extraction_text(&text)?;
478 Ok(CodexResult {
479 extraction,
480 last_agent_text: text,
481 usage,
482 rate_limited,
483 schema_error,
484 turn_failed,
485 failed_message,
486 })
487}
488
489pub fn parse_extraction_text(text: &str) -> Result<ExtractionResult, AppError> {
494 let value: serde_json::Value = serde_json::from_str(text).map_err(|e| {
495 AppError::Validation(format!("failed to parse codex agent_message as JSON: {e}"))
496 })?;
497 let obj = value.as_object().ok_or_else(|| {
498 AppError::Validation("codex agent_message is not a JSON object".to_string())
499 })?;
500
501 let mut entities: Vec<NewEntity> = Vec::new();
502 if let Some(arr) = obj.get("entities").and_then(|v| v.as_array()) {
503 for e in arr {
504 if let Some(name) = e.get("name").and_then(|v| v.as_str()) {
505 let entity_type_str = e
508 .get("type")
509 .or_else(|| e.get("entity_type"))
510 .and_then(|v| v.as_str())
511 .unwrap_or("concept");
512 let entity_type = serde_json::from_value::<crate::entity_type::EntityType>(
513 serde_json::Value::String(entity_type_str.to_string()),
514 )
515 .unwrap_or(crate::entity_type::EntityType::Concept);
516 entities.push(NewEntity {
517 name: name.to_string(),
518 entity_type,
519 description: None,
520 });
521 }
522 }
523 }
524
525 let mut relationships: Vec<NewRelationship> = Vec::new();
526 if let Some(arr) = obj.get("relationships").and_then(|v| v.as_array()) {
527 for r in arr {
528 let from = r.get("source").or_else(|| r.get("from"));
529 let to = r.get("target").or_else(|| r.get("to"));
530 let rel = r.get("relation").and_then(|v| v.as_str());
531 if let (Some(from_v), Some(to_v), Some(rel_v)) = (
532 from.and_then(|v| v.as_str()),
533 to.and_then(|v| v.as_str()),
534 rel,
535 ) {
536 relationships.push(NewRelationship {
537 source: from_v.to_string(),
538 target: to_v.to_string(),
539 relation: rel_v.to_string(),
540 strength: r.get("strength").and_then(|v| v.as_f64()).unwrap_or(0.5),
541 description: None,
542 });
543 }
544 }
545 }
546
547 let urls: Vec<ExtractedUrl> = obj
548 .get("urls")
549 .and_then(|v| v.as_array())
550 .map(|arr| {
551 arr.iter()
552 .filter_map(|u| {
553 let url = u.get("url")?.as_str()?.to_string();
554 let start = u.get("start").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
555 let end = u
556 .get("end")
557 .and_then(|v| v.as_u64())
558 .unwrap_or(start as u64) as usize;
559 Some(ExtractedUrl { url, start, end })
560 })
561 .collect()
562 })
563 .unwrap_or_default();
564
565 let entities_ext: Vec<crate::extraction::ExtractedEntity> = entities
575 .into_iter()
576 .map(|e| crate::extraction::ExtractedEntity {
577 name: e.name,
578 entity_type: e.entity_type.as_str().to_string(),
579 start: 0,
580 end: 0,
581 })
582 .collect();
583
584 Ok(ExtractionResult {
585 entities: entities_ext,
586 urls,
587 elapsed_ms: 0,
588 })
589}
590
591fn prepare_isolated_codex_home_spawn() -> Option<std::path::PathBuf> {
592 let home = std::env::var("HOME").ok()?;
593 let real_auth = std::path::Path::new(&home).join(".codex/auth.json");
594 if !real_auth.exists() {
595 return None;
596 }
597 let isolated =
598 std::env::temp_dir().join(format!("sqlite-graphrag-codex-home-{}", std::process::id()));
599 let _ = std::fs::create_dir_all(&isolated);
600 let target = isolated.join("auth.json");
601 if !target.exists() {
602 let _ = std::fs::copy(&real_auth, &target);
603 }
604 Some(isolated)
605}
606
607#[cfg(test)]
608mod tests {
609 use super::*;
610
611 const SAMPLE_JSONL: &str = r#"{"type":"thread.started","thread_id":"abc"}
612{"type":"turn.started"}
613{"type":"item.completed","item":{"type":"reasoning","text":"thinking"}}
614{"type":"item.completed","item":{"type":"agent_message","text":"{\"entities\":[{\"name\":\"alpha\",\"type\":\"concept\"}],\"relationships\":[{\"source\":\"alpha\",\"target\":\"beta\",\"relation\":\"uses\",\"strength\":0.7}],\"extraction_method\":\"codex\",\"urls\":[]}"}}
615{"type":"turn.completed","usage":{"input_tokens":120,"output_tokens":45}}
616{"type":"turn.completed","usage":{}}
617"#;
618
619 #[test]
620 fn parse_codex_jsonl_extracts_last_agent_message() {
621 let result = parse_codex_jsonl(SAMPLE_JSONL).expect("parse must succeed");
626 assert_eq!(result.extraction.entities.len(), 1);
627 assert_eq!(result.extraction.entities[0].name, "alpha");
628 }
629
630 #[test]
631 fn parse_codex_jsonl_collects_usage() {
632 let result = parse_codex_jsonl(SAMPLE_JSONL).expect("parse must succeed");
633 let usage = result.usage.expect("usage must be populated");
634 assert_eq!(usage.input_tokens, 120);
635 assert_eq!(usage.output_tokens, 45);
636 }
637
638 #[test]
639 fn parse_codex_jsonl_detects_rate_limit() {
640 let r = parse_codex_jsonl(
641 "{\"type\":\"turn.failed\",\"error\":{\"message\":\"rate_limit: 429 too many\"}}\n{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"{}\"}}",
642 );
643 assert!(matches!(r, Err(AppError::Validation(_))));
644 }
645
646 #[test]
647 fn parse_codex_jsonl_handles_no_agent_message() {
648 let r = parse_codex_jsonl("{\"type\":\"thread.started\"}");
649 assert!(matches!(r, Err(AppError::Validation(_))));
650 }
651
652 #[test]
653 fn parse_codex_jsonl_skips_malformed_lines() {
654 let r = parse_codex_jsonl(
655 "{not valid json\n{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"{\\\"entities\\\":[],\\\"relationships\\\":[],\\\"extraction_method\\\":\\\"codex\\\"}\"}}",
656 );
657 assert!(r.is_ok(), "malformed lines must be skipped, got {r:?}");
658 }
659
660 #[test]
661 fn validate_codex_model_accepts_known() {
662 assert!(validate_codex_model(Some("gpt-5.5")).is_ok());
663 assert!(validate_codex_model(Some("gpt-5.4")).is_ok());
664 assert!(validate_codex_model(None).is_ok()); }
666
667 #[test]
668 fn validate_codex_model_rejects_unknown() {
669 let err = validate_codex_model(Some("gpt-4")).unwrap_err();
670 let msg = format!("{err}");
671 assert!(msg.contains("not supported"));
672 assert!(msg.contains("gpt-5.5"));
673 }
674
675 #[test]
676 fn list_codex_models_includes_all_static_whitelist() {
677 let models = list_codex_models();
678 for m in CODEX_PRO_OAUTH_MODELS {
679 assert!(models.contains(&m.to_string()), "missing {m} in {models:?}");
680 }
681 }
682
683 #[test]
684 fn suggest_codex_model_substring_match() {
685 let s = suggest_codex_model("gpt-5");
686 assert!(s.is_some(), "must suggest a gpt-5.x model");
687 }
688
689 #[test]
690 fn suggest_codex_model_fuzzy_match() {
691 let s = suggest_codex_model("gpt5.5");
693 assert!(s.is_some(), "fuzzy must suggest gpt-5.5 for 'gpt5.5'");
694 assert_eq!(s.unwrap(), "gpt-5.5");
695 }
696
697 #[test]
698 fn suggest_codex_model_unrelated_returns_none() {
699 let s = suggest_codex_model("totally-unrelated-zzz");
700 assert!(s.is_none());
701 }
702
703 #[test]
704 fn build_codex_command_includes_hardening_flags() {
705 let args = CodexSpawnArgs {
706 binary: Path::new("/bin/true"),
707 prompt: "p",
708 json_schema: "{}",
709 input_text: "i",
710 model: Some("gpt-5.5"),
711 timeout_secs: 60,
712 schema_path: std::env::temp_dir().join("test-schema.json"),
713 };
714 let cmd = build_codex_command(&args).expect("preflight gate accepts valid args");
715 let collected: Vec<String> = cmd
716 .get_args()
717 .filter_map(|a| a.to_str().map(|s| s.to_string()))
718 .collect();
719 for required in &[
720 "exec",
721 "-c",
722 "sandbox_mode='read-only'",
723 "approval_policy='never'",
724 "--json",
725 "--output-schema",
726 "--ephemeral",
727 "--skip-git-repo-check",
728 "--sandbox",
729 "read-only",
730 "--ignore-user-config",
731 "--ignore-rules",
732 "-m",
733 "gpt-5.5",
734 "-",
735 ] {
736 assert!(
737 collected.iter().any(|a| a == required),
738 "missing flag {required} in {collected:?}"
739 );
740 }
741 }
742
743 #[test]
744 fn list_codex_models_dedupes_with_cache_file() {
745 let models = list_codex_models();
749 let unique: std::collections::HashSet<_> = models.iter().collect();
750 assert_eq!(unique.len(), models.len(), "list_codex_models must dedupe");
751 }
752 #[test]
753 fn list_codex_models_extracts_from_models_array_v1_0_81_regression() {
754 let tmp =
762 std::env::temp_dir().join(format!("codex-models-array-test-{}", std::process::id()));
763 std::fs::create_dir_all(tmp.join(".codex")).expect("mkdir");
764 let cache_body = r#"{
765 "fetched_at": "2026-06-14T06:43:56.639903114Z",
766 "etag": "W/\"deadbeef\"",
767 "client_version": "0.139.0",
768 "models": [
769 {"slug": "gpt-5.5", "display_name": "GPT-5.5"},
770 {"slug": "gpt-5.4-mini", "display_name": "GPT-5.4 mini"}
771 ]
772 }"#;
773 std::fs::write(tmp.join(".codex/models_cache.json"), cache_body).expect("write cache");
774 let prev_home = std::env::var("HOME");
776 unsafe {
777 std::env::set_var("HOME", &tmp);
778 }
779 let models = list_codex_models();
780 unsafe {
781 if let Ok(h) = prev_home {
782 std::env::set_var("HOME", h);
783 } else {
784 std::env::remove_var("HOME");
785 }
786 }
787 let _ = std::fs::remove_dir_all(&tmp);
788
789 for forbidden in &["client_version", "etag", "fetched_at", "models"] {
790 assert!(
791 !models.contains(&forbidden.to_string()),
792 "metadata key {forbidden:?} leaked into model list: {models:?}"
793 );
794 }
795 assert!(
796 models.contains(&"gpt-5.5".to_string()),
797 "gpt-5.5 missing from extracted list: {models:?}"
798 );
799 assert!(
800 models.contains(&"gpt-5.4-mini".to_string()),
801 "gpt-5.4-mini missing from extracted list: {models:?}"
802 );
803 }
804
805 #[test]
806 fn list_codex_models_falls_back_to_keys_when_models_field_absent() {
807 let tmp =
810 std::env::temp_dir().join(format!("codex-models-legacy-test-{}", std::process::id()));
811 std::fs::create_dir_all(tmp.join(".codex")).expect("mkdir");
812 let cache_body = r#"{"legacy-model-x": 1, "legacy-model-y": 2}"#;
813 std::fs::write(tmp.join(".codex/models_cache.json"), cache_body).expect("write cache");
814 let prev_home = std::env::var("HOME");
815 unsafe {
816 std::env::set_var("HOME", &tmp);
817 }
818 let models = list_codex_models();
819 unsafe {
820 if let Ok(h) = prev_home {
821 std::env::set_var("HOME", h);
822 } else {
823 std::env::remove_var("HOME");
824 }
825 }
826 let _ = std::fs::remove_dir_all(&tmp);
827
828 assert!(
829 models.contains(&"legacy-model-x".to_string()),
830 "legacy-model-x missing: {models:?}"
831 );
832 assert!(
833 models.contains(&"legacy-model-y".to_string()),
834 "legacy-model-y missing: {models:?}"
835 );
836 }
837
838 #[test]
843 #[serial_test::serial(env)]
844 fn build_command_oauth_only_mandatory_flags() {
845 unsafe {
847 std::env::remove_var("OPENAI_API_KEY");
848 }
849 let schema = std::env::temp_dir().join("codex-test-schema.json");
850 let _ = std::fs::remove_file(&schema);
851 let args = CodexSpawnArgs {
852 binary: std::path::Path::new("/usr/bin/false"),
853 prompt: "p",
854 json_schema: "{}",
855 input_text: "i",
856 model: Some("gpt-5.4-mini"),
857 timeout_secs: 60,
858 schema_path: schema.clone(),
859 };
860 let cmd = build_codex_command(&args).expect("preflight gate accepts valid args");
861 let argv: Vec<&str> = cmd.get_args().filter_map(|a| a.to_str()).collect();
862 assert!(
868 argv.contains(&"--ignore-user-config"),
869 "must have --ignore-user-config (gaps.md:266)"
870 );
871 let ask_for_approval_present = argv.contains(&"--ask-for-approval");
875 if !crate::extract::codex_compat::codex_supports_ask_for_approval() {
876 assert!(
877 !ask_for_approval_present,
878 "codex 0.134+ must NOT include --ask-for-approval"
879 );
880 }
881 assert!(
882 argv.contains(&"--sandbox"),
883 "must have --sandbox read-only (G31)"
884 );
885 assert!(argv.contains(&"--ephemeral"), "must have --ephemeral (G31)");
886 assert!(
887 argv.contains(&"--skip-git-repo-check"),
888 "must have --skip-git-repo-check (G31)"
889 );
890 assert!(
891 argv.contains(&"--ignore-rules"),
892 "must have --ignore-rules (G31)"
893 );
894 assert!(
896 argv.contains(&"-c") && argv.contains(&"sandbox_mode='read-only'"),
897 "must have -c sandbox_mode='read-only' (v1.0.77, codex#18113)"
898 );
899 assert!(
900 argv.contains(&"approval_policy='never'"),
901 "must have -c approval_policy='never' (v1.0.77)"
902 );
903 }
904
905 #[test]
909 #[serial_test::serial(env)]
910 fn build_command_aborts_when_openai_api_key_set() {
911 unsafe {
913 std::env::set_var("OPENAI_API_KEY", "sk-violation-test");
914 }
915 let schema = std::env::temp_dir().join("codex-test-schema-abort.json");
916 let _ = std::fs::remove_file(&schema);
917 let args = CodexSpawnArgs {
918 binary: std::path::Path::new("/usr/bin/codex"),
919 prompt: "p",
920 json_schema: "{}",
921 input_text: "i",
922 model: Some("gpt-5.4-mini"),
923 timeout_secs: 60,
924 schema_path: schema.clone(),
925 };
926 let cmd = build_codex_command(&args).expect("preflight gate accepts valid args");
927 let program = cmd.get_program().to_string_lossy().to_string();
928 let argv: Vec<&str> = cmd.get_args().filter_map(|a| a.to_str()).collect();
929 assert_eq!(
930 program, "false",
931 "when OPENAI_API_KEY is set, build_codex_command must abort"
932 );
933 assert!(
934 argv.contains(&"--oauth-only-violation-openai-api-key-set"),
935 "aborted command must carry violation marker"
936 );
937 unsafe {
938 std::env::remove_var("OPENAI_API_KEY");
939 }
940 }
941}