Skip to main content

sqlite_graphrag/commands/
codex_spawn.rs

1//! Codex CLI spawn + JSONL parsing helper shared by `enrich` and `ingest --mode codex`.
2//!
3//! G31 (v1.0.69): `enrich --mode codex` was missing five critical hardening
4//! flags compared to `ingest --mode codex`. This module extracts the
5//! spawn pipeline into a single helper that BOTH call-sites consume,
6//! guaranteeing the same defaults everywhere.
7//!
8//! G32 (v1.0.69): `enrich --mode codex` used `serde_json::from_str` on the
9//! raw stdout, but `codex exec --json` emits JSONL (one event per line).
10//! [`parse_codex_jsonl`] iterates line-by-line, picking the last
11//! `item.completed` of type `agent_message` as the assistant text.
12//!
13//! G33 (v1.0.69): validate the model against the ChatGPT Pro OAuth whitelist
14//! stored in `~/.codex/models_cache.json` BEFORE spawning the subprocess.
15
16use 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/// Token usage reported by Codex on `turn.completed` events.
26#[derive(Debug, Clone, Default, Deserialize, Serialize)]
27pub struct CodexUsage {
28    /// Input tokens.
29    #[serde(default)]
30    pub input_tokens: u64,
31    /// Cached input tokens.
32    #[serde(default)]
33    pub cached_input_tokens: u64,
34    /// Output tokens.
35    #[serde(default)]
36    pub output_tokens: u64,
37    /// Reasoning output tokens.
38    #[serde(default)]
39    pub reasoning_output_tokens: u64,
40}
41
42/// Combined result of one `codex exec` invocation.
43#[derive(Debug)]
44pub struct CodexResult {
45    /// Extraction.
46    pub extraction: ExtractionResult,
47    /// Raw text of the last `item.completed` of type `agent_message` (the
48    /// JSON payload the LLM produced). Callers that need a schema other
49    /// than the extraction shape (e.g. body-enrich's `enriched_body`)
50    /// should parse this directly.
51    pub last_agent_text: String,
52    /// Usage.
53    pub usage: Option<CodexUsage>,
54    /// Rate limited.
55    pub rate_limited: bool,
56    /// Schema error.
57    pub schema_error: bool,
58    /// Turn failed.
59    pub turn_failed: bool,
60    /// Failed message.
61    pub failed_message: String,
62}
63
64/// Configuration for the codex spawner.
65#[allow(rustdoc::broken_intra_doc_links)]
66pub struct CodexSpawnArgs<'a> {
67    /// Binary.
68    pub binary: &'a Path,
69    /// Prompt.
70    pub prompt: &'a str,
71    /// JSON schema.
72    pub json_schema: &'a str,
73    /// Input text.
74    pub input_text: &'a str,
75    /// Model name or identifier.
76    pub model: Option<&'a str>,
77    /// Timeout secs.
78    pub timeout_secs: u64,
79    /// Caller-provided schema path (must be inside a trusted directory
80    /// that codex recognises as sandbox-safe). Use [`trusted_schema_path`]
81    /// to compute one under the cache dir.
82    pub schema_path: PathBuf,
83}
84
85/// Computes a schema path under the cache dir so `codex exec` accepts it
86/// as part of a trusted directory (rejects `/tmp` on hardened installs).
87pub fn trusted_schema_path() -> Result<PathBuf, AppError> {
88    let cache = crate::paths::AppPaths::resolve(None)
89        .map(|p| p.models.parent().map(|m| m.to_path_buf()))
90        .ok()
91        .flatten()
92        .unwrap_or_else(std::env::temp_dir);
93    std::fs::create_dir_all(&cache).map_err(AppError::Io)?;
94    Ok(cache.join(format!("enrich-schema-{}.json", std::process::id())))
95}
96
97/// Models accepted by Codex CLI when using ChatGPT Pro OAuth.
98///
99/// Mirrored from `~/.codex/models_cache.json` (which the official CLI
100/// refreshes on every login). This list is intentionally narrow; passing
101/// a model not in this set with `--mode codex` returns
102/// `AppError::Validation` BEFORE any OAuth turn is spent.
103pub const CODEX_PRO_OAUTH_MODELS: &[&str] = &[
104    "codex-auto-review",
105    "gpt-5.3-codex-spark",
106    "gpt-5.4",
107    "gpt-5.4-mini",
108    "gpt-5.5",
109];
110
111/// Validates the requested model against [`CODEX_PRO_OAUTH_MODELS`].
112///
113/// # Errors
114/// Returns [`AppError::Validation`] listing the accepted models when the
115/// caller supplied a model outside the whitelist.
116pub fn validate_codex_model(model: Option<&str>) -> Result<(), AppError> {
117    let Some(m) = model else {
118        return Ok(()); // no override; codex picks its default
119    };
120    if CODEX_PRO_OAUTH_MODELS.contains(&m) {
121        Ok(())
122    } else {
123        Err(AppError::Validation(
124            crate::i18n::validation::codex_model_not_supported_oauth(
125                m,
126                &CODEX_PRO_OAUTH_MODELS.join(", "),
127            ),
128        ))
129    }
130}
131
132/// Returns the list of models accepted by Codex with ChatGPT Pro OAuth.
133///
134/// Tries to read `~/.codex/models_cache.json` (which the official CLI
135/// refreshes on every login) and falls back to the static
136/// [`CODEX_PRO_OAUTH_MODELS`] constant when the file is missing or
137/// malformed. The returned `Vec<String>` is the union of both sources,
138/// de-duplicated.
139///
140/// The official cache file is an object with the shape
141/// `{"fetched_at": "...", "etag": "...", "client_version": "...",
142/// "models": [{"slug": "gpt-5.5", ...}, ...]}` (v1.0.81 fix: previously we
143/// iterated `obj.keys()` which produced bogus entries like `client_version`
144/// and `etag` as "models"; now we extract only the `models` array).
145pub fn list_codex_models() -> Vec<String> {
146    use std::collections::BTreeSet;
147    let mut out: BTreeSet<String> = CODEX_PRO_OAUTH_MODELS
148        .iter()
149        .map(|s| s.to_string())
150        .collect();
151
152    if let Some(home) = std::env::var_os("HOME") {
153        let path = std::path::Path::new(&home)
154            .join(".codex")
155            .join("models_cache.json");
156        if let Ok(content) = std::fs::read_to_string(&path) {
157            if let Ok(value) = serde_json::from_str::<serde_json::Value>(&content) {
158                if let Some(obj) = value.as_object() {
159                    // v1.0.81 fix: prefer the well-known `models` array
160                    // (each item has a `slug` field). Fall back to keys
161                    // only when `models` is absent (legacy cache format).
162                    if let Some(models_arr) = obj.get("models").and_then(|m| m.as_array()) {
163                        for v in models_arr {
164                            if let Some(slug) = v.get("slug").and_then(|s| s.as_str()) {
165                                out.insert(slug.to_string());
166                            } else if let Some(s) = v.as_str() {
167                                out.insert(s.to_string());
168                            }
169                        }
170                    } else {
171                        for key in obj.keys() {
172                            out.insert(key.clone());
173                        }
174                    }
175                } else if let Some(arr) = value.as_array() {
176                    for v in arr {
177                        if let Some(s) = v.as_str() {
178                            out.insert(s.to_string());
179                        }
180                    }
181                }
182            }
183        }
184    }
185    out.into_iter().collect()
186}
187
188/// Suggests the closest codex OAuth model to a user-supplied substring
189/// (G33). Returns `None` when no candidate is close enough.
190///
191/// Match strategy: exact substring containment wins; otherwise Levenshtein
192/// distance below `max_distance = max(2, query.len() / 3)`.
193pub fn suggest_codex_model(query: &str) -> Option<String> {
194    let query_lc = query.to_ascii_lowercase();
195    let models = list_codex_model_lc();
196
197    // Exact substring match wins.
198    for m in &models {
199        if m.contains(&query_lc) {
200            return Some(m.clone());
201        }
202    }
203
204    // Levenshtein fallback.
205    let max_distance = (query.len() / 3).max(2);
206    let mut best: Option<(usize, String)> = None;
207    for m in &models {
208        let d = levenshtein(query_lc.as_str(), m.as_str());
209        if d <= max_distance && best.as_ref().is_none_or(|(bd, _)| d < *bd) {
210            best = Some((d, m.clone()));
211        }
212    }
213    best.map(|(_, m)| m)
214}
215
216fn list_codex_model_lc() -> Vec<String> {
217    list_codex_models()
218        .into_iter()
219        .map(|s| s.to_ascii_lowercase())
220        .collect()
221}
222
223fn levenshtein(a: &str, b: &str) -> usize {
224    let a_chars: Vec<char> = a.chars().collect();
225    let b_chars: Vec<char> = b.chars().collect();
226    if a_chars.is_empty() {
227        return b_chars.len();
228    }
229    if b_chars.is_empty() {
230        return a_chars.len();
231    }
232    let mut prev: Vec<usize> = (0..=b_chars.len()).collect();
233    let mut curr = vec![0; b_chars.len() + 1];
234    for (i, &ac) in a_chars.iter().enumerate() {
235        curr[0] = i + 1;
236        for (j, &bc) in b_chars.iter().enumerate() {
237            let cost = if ac == bc { 0 } else { 1 };
238            curr[j + 1] = (curr[j] + 1).min(prev[j + 1] + 1).min(prev[j] + cost);
239        }
240        std::mem::swap(&mut prev, &mut curr);
241    }
242    prev[b_chars.len()]
243}
244
245/// Builds the `codex exec` command with the canonical hardening flags.
246///
247/// G31 + OAuth-only hardening (v1.0.69, mandated by gaps.md lines 41-49):
248/// the command ALWAYS uses the OAuth `auth.json` flow. The flag set is
249/// the canonical one documented in gaps.md Fix A:
250///
251/// ```text
252/// codex exec \
253///   -c mcp_servers='{}' \
254///   --json --output-schema <SCHEMA> \
255///   --ephemeral \
256///   --skip-git-repo-check \
257///   --sandbox read-only \
258///   --ignore-user-config \
259///   --ignore-rules \
260///   --ask-for-approval never \
261///   -m <MODEL> \
262///   -
263/// ```
264///
265/// The combination zeroes MCP servers (via two complementary mechanisms:
266/// the inline `-c mcp_servers='{}'` override AND `--ignore-user-config`),
267/// disables user-defined rules, and never asks for interactive approval.
268///
269/// **`OPENAI_API_KEY` is FORBIDDEN** in the spawned environment (gaps.md:48).
270/// OAuth flows via `~/.codex/auth.json` and `CODEX_ACCESS_TOKEN` only.
271pub fn build_codex_command(args: &CodexSpawnArgs<'_>) -> Result<Command, crate::errors::AppError> {
272    let full_prompt = format!("{}\n\n{}", args.prompt, args.input_text);
273
274    // OAuth-only guard (gaps.md:48, ADR-0011). If `OPENAI_API_KEY` is set
275    // in the environment we MUST abort — that is the API-key path which is
276    // explicitly PROHIBITED. Use the OAuth `auth.json` flow exclusively.
277    if let Ok(_key) = std::env::var("OPENAI_API_KEY") {
278        let mut cmd = crate::spawn::failing_command();
279        cmd.env_clear();
280        cmd.env("PATH", "/nonexistent");
281        cmd.arg("--oauth-only-violation-openai-api-key-set");
282        cmd.arg("--oauth-only-resolution-use-codex-auth-json-or-openai-base-url");
283        return Ok(cmd);
284    }
285
286    // Write the JSON schema to a path the caller controls. Callers should
287    // pass a path under the cache dir (see [`trusted_schema_path`]).
288    std::fs::write(&args.schema_path, args.json_schema).ok();
289
290    let mut cmd = Command::new(args.binary);
291    // v1.0.83 (ADR-0041): env whitelist delegated to the shared helper.
292    // `OPENAI_API_KEY` is INTENTIONALLY ABSENT (defence-in-depth).
293    // `CODEX_ACCESS_TOKEN` and `OPENAI_BASE_URL` ARE whitelisted for
294    // custom providers via the canonical list in src/spawn/env_whitelist.rs.
295    apply_env_whitelist(&mut cmd, crate::spawn::env_whitelist::is_strict_env_clear());
296    crate::spawn::apply_cwd_isolation(&mut cmd)?;
297
298    // v1.0.77: point CODEX_HOME at an isolated dir that only contains
299    // auth.json — this prevents the codex subprocess from loading
300    // ~/.codex/config.toml (which has trust_level=trusted for the project,
301    // causing sandbox escalation per openai/codex#18113).
302    if let Some(isolated) = prepare_isolated_codex_home_spawn() {
303        cmd.env("CODEX_HOME", isolated);
304    }
305
306    // v1.0.77: `-c` TOML overrides bypass the codex exec --sandbox propagation
307    // bug (openai/codex#18113). CLI flags alone are insufficient — the exec
308    // subcommand may not inherit --sandbox from the parent codex command.
309    cmd.arg("exec")
310        .arg("-c")
311        .arg("sandbox_mode='read-only'")
312        .arg("-c")
313        .arg("approval_policy='never'")
314        .arg("--json")
315        .arg("--output-schema")
316        .arg(&args.schema_path)
317        .arg("--ephemeral")
318        .arg("--skip-git-repo-check")
319        .arg("--sandbox")
320        .arg("read-only")
321        .arg("--ignore-user-config")
322        .arg("--ignore-rules");
323
324    // Codex 0.134+ no longer accepts `-c mcp_servers='{}'` — it parses the
325    // value as a string and rejects it ("expected a map"). The
326    // `--ignore-user-config` flag already discards any user-defined MCP
327    // servers, so the override is redundant on all supported versions.
328
329    // Codex 0.134+ removed --ask-for-approval entirely (Issue #26602).
330    // Skip the flag on newer versions; sandbox=read-only already suppresses
331    // approval prompts. See src/extract/codex_compat.rs for the probe.
332    if codex_supports_ask_for_approval() {
333        cmd.arg("--ask-for-approval").arg("never");
334    }
335
336    if let Some(m) = args.model {
337        cmd.arg("-m").arg(m);
338    }
339
340    // `-` means: read the prompt from stdin (Codex Paperclip pattern)
341    cmd.arg("-");
342
343    cmd.stdin(Stdio::piped())
344        .stdout(Stdio::piped())
345        .stderr(Stdio::piped());
346    // Keep the prompt alive for the stdin thread spawned in `spawn_codex`.
347    let _ = full_prompt; // captured by closure below
348
349    // GAP-META-005 (v1.0.87, ADR-0045): pre-flight validation gate runs
350    // AFTER argv is fully built. Validates binary existence, argv size,
351    // walk-up of `.mcp.json`, and `CLAUDE_CONFIG_DIR` cleanliness.
352    // Pre-flight failure aborts the spawn with exit 16 — see ADR-0045.
353    let argv_refs: Vec<std::ffi::OsString> = cmd.get_args().map(|s| s.to_os_string()).collect();
354    let preflight_args = crate::spawn::preflight::PreFlightArgs {
355        binary_path: args.binary,
356        argv: &argv_refs,
357        workspace_root: std::path::Path::new("."),
358        mcp_config_inline_json: None, // Codex does not use --mcp-config flag
359        expected_output_bytes: 65_536,
360        spawner_name: "codex_spawn",
361    };
362    if let Err(e) = crate::spawn::preflight::preflight_check(&preflight_args) {
363        // v1.0.88 (BUG-6 fix, ADR-0046): propagate the structured
364        // `PreFlightError` via the `From` impl in `errors.rs` so callers
365        // receive `AppError::PreFlightFailed` (exit 16) instead of a
366        // bare `std::process::exit(16)` that discards the variant name,
367        // tracing context, and PT-BR i18n.
368        return Err(crate::errors::AppError::from(e));
369    }
370
371    Ok(cmd)
372}
373
374/// Parses JSONL output from `codex exec --json`.
375///
376/// Event format (DOTS notation):
377/// - `thread.started` — session init
378/// - `turn.started` — model turn begins
379/// - `item.completed` — message or tool call; last `agent_message` wins
380/// - `turn.completed` — includes usage stats
381/// - `turn.failed` — error with optional rate-limit indicator
382/// - `error` — schema or validation error
383///
384/// G32 (v1.0.69): this function is the single source of truth for JSONL
385/// parsing. Both `enrich` and `ingest --mode codex` consume it.
386pub fn parse_codex_jsonl(stdout: &str) -> Result<CodexResult, AppError> {
387    let mut last_agent_text: Option<String> = None;
388    let mut usage: Option<CodexUsage> = None;
389    let mut rate_limited = false;
390    let mut schema_error = false;
391    let mut turn_failed = false;
392    let mut failed_message = String::new();
393
394    for line in stdout.lines() {
395        let line = line.trim();
396        if line.is_empty() {
397            continue;
398        }
399
400        let event: serde_json::Value = match serde_json::from_str(line) {
401            Ok(v) => v,
402            Err(_) => {
403                tracing::warn!(target: "codex_spawn", line, "skipping malformed JSONL line");
404                continue;
405            }
406        };
407
408        let event_type = match event.get("type").and_then(|t| t.as_str()) {
409            Some(t) => t,
410            None => continue,
411        };
412
413        match event_type {
414            "item.completed" => {
415                if let Some(item) = event.get("item") {
416                    if item.get("type").and_then(|t| t.as_str()) == Some("agent_message") {
417                        if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
418                            last_agent_text = Some(text.to_string());
419                        }
420                    }
421                }
422            }
423            "turn.completed" => {
424                if let Some(u) = event.get("usage") {
425                    // Skip events that lack the recognised token fields
426                    // (e.g. partial broadcasts with `{}`) so the last
427                    // populated usage wins instead of being overwritten
428                    // by an empty one.
429                    let is_populated = u
430                        .get("input_tokens")
431                        .and_then(|v| v.as_u64())
432                        .map(|n| n > 0)
433                        .unwrap_or(false)
434                        || u.get("output_tokens")
435                            .and_then(|v| v.as_u64())
436                            .map(|n| n > 0)
437                            .unwrap_or(false);
438                    if is_populated {
439                        if let Ok(parsed) = serde_json::from_value::<CodexUsage>(u.clone()) {
440                            usage = Some(parsed);
441                        }
442                    }
443                }
444            }
445            "turn.failed" => {
446                turn_failed = true;
447                if let Some(err) = event.get("error") {
448                    let msg = err
449                        .get("message")
450                        .and_then(|m| m.as_str())
451                        .unwrap_or("unknown error");
452                    failed_message = msg.to_string();
453                    if msg.contains("rate_limit")
454                        || msg.contains("429")
455                        || msg.contains("Too Many Requests")
456                    {
457                        rate_limited = true;
458                    }
459                }
460            }
461            "error" => {
462                if let Some(msg) = event.get("message").and_then(|m| m.as_str()) {
463                    if msg.contains("invalid_json_schema") || msg.contains("schema") {
464                        schema_error = true;
465                    }
466                }
467            }
468            _ => {}
469        }
470    }
471
472    let text = last_agent_text.ok_or_else(|| {
473        AppError::Validation(crate::i18n::validation::no_agent_message_in_codex_jsonl(
474            rate_limited,
475            schema_error,
476            turn_failed,
477        ))
478    })?;
479
480    if turn_failed {
481        return Err(AppError::Validation(
482            crate::i18n::validation::codex_turn_failed(&failed_message),
483        ));
484    }
485    if schema_error {
486        return Err(AppError::Validation(
487            "codex reported invalid_json_schema; check the --output-schema file".to_string(),
488        ));
489    }
490    if rate_limited {
491        return Err(AppError::Validation(
492            crate::i18n::validation::codex_rate_limited(&failed_message),
493        ));
494    }
495
496    let extraction = parse_extraction_text(&text)?;
497    Ok(CodexResult {
498        extraction,
499        last_agent_text: text,
500        usage,
501        rate_limited,
502        schema_error,
503        turn_failed,
504        failed_message,
505    })
506}
507
508/// Parses the agent_message text as an `ExtractionResult` JSON payload.
509///
510/// The schema is shared by both `enrich` and `ingest --mode codex`; the
511/// `text` is the JSON value the assistant returned, not a wrapper object.
512pub fn parse_extraction_text(text: &str) -> Result<ExtractionResult, AppError> {
513    let value: serde_json::Value = serde_json::from_str(text).map_err(|e| {
514        AppError::Validation(crate::i18n::validation::failed_to_parse_codex_agent_message_json(&e))
515    })?;
516    let obj = value.as_object().ok_or_else(|| {
517        AppError::Validation(crate::i18n::validation::codex_agent_message_not_json_object())
518    })?;
519
520    let mut entities: Vec<NewEntity> = Vec::new();
521    if let Some(arr) = obj.get("entities").and_then(|v| v.as_array()) {
522        for e in arr {
523            if let Some(name) = e.get("name").and_then(|v| v.as_str()) {
524                // Accept either "type" or "entity_type" from the LLM payload
525                // and fall back to "concept" when the LLM omits it.
526                let entity_type_str = e
527                    .get("type")
528                    .or_else(|| e.get("entity_type"))
529                    .and_then(|v| v.as_str())
530                    .unwrap_or("concept");
531                // GAP-SG-47: fold non-canonical labels onto the nearest
532                // canonical kind (preserves aliases/case instead of collapsing
533                // every miss straight to concept).
534                let entity_type = crate::entity_type::EntityType::map_to_canonical(entity_type_str);
535                entities.push(NewEntity {
536                    name: name.to_string(),
537                    entity_type,
538                    description: None,
539                });
540            }
541        }
542    }
543
544    let mut relationships: Vec<NewRelationship> = Vec::new();
545    if let Some(arr) = obj.get("relationships").and_then(|v| v.as_array()) {
546        for r in arr {
547            let from = r.get("source").or_else(|| r.get("from"));
548            let to = r.get("target").or_else(|| r.get("to"));
549            let rel = r.get("relation").and_then(|v| v.as_str());
550            if let (Some(from_v), Some(to_v), Some(rel_v)) = (
551                from.and_then(|v| v.as_str()),
552                to.and_then(|v| v.as_str()),
553                rel,
554            ) {
555                relationships.push(NewRelationship {
556                    source: from_v.to_string(),
557                    target: to_v.to_string(),
558                    // GAP-SG-48: rewrite non-canonical relations to canonical.
559                    relation: crate::parsers::map_to_canonical_relation(rel_v),
560                    strength: r.get("strength").and_then(|v| v.as_f64()).unwrap_or(0.5),
561                    description: None,
562                });
563            }
564        }
565    }
566
567    let urls: Vec<ExtractedUrl> = obj
568        .get("urls")
569        .and_then(|v| v.as_array())
570        .map(|arr| {
571            arr.iter()
572                .filter_map(|u| {
573                    let url = u.get("url")?.as_str()?.to_string();
574                    let start = u.get("start").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
575                    let end = u
576                        .get("end")
577                        .and_then(|v| v.as_u64())
578                        .unwrap_or(start as u64) as usize;
579                    Some(ExtractedUrl { url, start, end })
580                })
581                .collect()
582        })
583        .unwrap_or_default();
584
585    // v1.0.76: ExtractionResult no longer carries relationships or
586    // relationships_truncated fields; those are LLM backend output
587    // (see `ExtractionOutput` in src/extract/mod.rs). The default
588    // build extracts URLs + entities only; relationships are an
589    // LLM-side concern.
590    //
591    // Convert `NewEntity` (storage-side) to `ExtractedEntity`
592    // (extraction-side). The LLM payload doesn't include byte offsets
593    // (the chunker is responsible for that), so start/end are 0.
594    let entities_ext: Vec<crate::extraction::ExtractedEntity> = entities
595        .into_iter()
596        .map(|e| crate::extraction::ExtractedEntity {
597            name: e.name,
598            entity_type: e.entity_type.as_str().to_string(),
599            start: 0,
600            end: 0,
601        })
602        .collect();
603
604    Ok(ExtractionResult {
605        entities: entities_ext,
606        urls,
607        elapsed_ms: 0,
608    })
609}
610
611fn prepare_isolated_codex_home_spawn() -> Option<std::path::PathBuf> {
612    let home = std::env::var("HOME").ok()?;
613    let real_auth = std::path::Path::new(&home).join(".codex/auth.json");
614    if !real_auth.exists() {
615        return None;
616    }
617    let isolated =
618        std::env::temp_dir().join(format!("sqlite-graphrag-codex-home-{}", std::process::id()));
619    let _ = std::fs::create_dir_all(&isolated);
620    let target = isolated.join("auth.json");
621    if !target.exists() {
622        let _ = std::fs::copy(&real_auth, &target);
623    }
624    Some(isolated)
625}
626#[cfg(test)]
627#[path = "codex_spawn_tests.rs"]
628mod tests;