Skip to main content

sqlite_graphrag/extract/llm_embedding/
ops.rs

1//! Embedding orchestration and headless LLM subprocess invokers.
2
3use super::timeout::extract_exit_info;
4use super::types::EmbeddingFlavour;
5use super::wire::{
6    build_batch_schema, build_single_schema, parse_llm_json, BatchEmbeddingResponse,
7    EmbeddingResponse,
8};
9use super::LlmEmbedding;
10use crate::errors::AppError;
11use std::process::Stdio;
12use std::sync::Arc;
13use tokio::io::AsyncWriteExt;
14use tokio::process::Command;
15
16impl LlmEmbedding {
17    /// LLM call. Returns `(global_index, vector)` pairs. Async — this
18    /// is the unit of work scheduled by the bounded fan-out in
19    /// `crate::embedder`.
20    ///
21    /// Cancel safety: the future owns its subprocess via
22    /// `kill_on_drop(true)`, so dropping it (e.g. losing a
23    /// `tokio::select!` race against a cancellation token) kills the
24    /// child and leaks nothing.
25    pub async fn embed_batch_async(
26        &self,
27        prefix: &str,
28        batch: &[(usize, String)],
29    ) -> Result<Vec<(usize, Vec<f32>)>, AppError> {
30        let dim = crate::constants::embedding_dim();
31        if batch.is_empty() {
32            return Ok(Vec::new());
33        }
34        if batch.len() == 1 {
35            let (idx, text) = (&batch[0].0, &batch[0].1);
36            let v = self.invoke_single_async(prefix, text, dim).await?;
37            return Ok(vec![(*idx, v)]);
38        }
39
40        let mut prompt = format!(
41            "Generate {dim}-dimensional semantic embedding vectors for each numbered text below.\n\
42             Return a JSON object with an \"items\" array containing EXACTLY {n} items.\n\
43             Each item has \"i\" (the 1-based index) and \"v\" (the {dim}-float vector, values between -1 and 1).\n\n",
44            n = batch.len()
45        );
46        for (pos, (_, text)) in batch.iter().enumerate() {
47            prompt.push_str(&format!("{}: {prefix}{text}\n", pos + 1));
48        }
49
50        // BUG-TIMEOUT-HARDCODE-001: batch timeout is now instance-scoped
51        // (no more std::env::set_var which was unsafe in multi-thread).
52        let _batch_timeout = self.instance_embed_timeout_for_batch(batch.len());
53        let stdout = match self.flavour {
54            EmbeddingFlavour::Claude => {
55                self.invoke_claude(&prompt, &build_batch_schema(dim))
56                    .await?
57            }
58            EmbeddingFlavour::Codex => {
59                let schema = self.codex_schema_file(dim, true)?;
60                self.invoke_codex(&prompt, schema.path()).await?
61            }
62            EmbeddingFlavour::Opencode => {
63                let opencode_prompt = format!(
64                    "You are a batch embedding function. For each numbered text item below, \
65                     generate an array of exactly {dim} floating-point numbers between -1 and 1 \
66                     representing its semantic meaning. Output ONLY a JSON object with key \"items\" \
67                     containing an array of objects, each with \"i\" (the 1-based index) and \
68                     \"v\" (the {dim}-element float array). No markdown, no explanation.\n\n\
69                     {prompt}"
70                );
71                self.invoke_opencode(&opencode_prompt).await?
72            }
73        };
74        let parsed: BatchEmbeddingResponse = parse_llm_json(&stdout).map_err(|e| {
75            AppError::Embedding(crate::i18n::validation::embedding_llm_batch_parse_failed(
76                e, &stdout,
77            ))
78        })?;
79        if parsed.items.len() != batch.len() {
80            return Err(AppError::Embedding(
81                crate::i18n::validation::embedding_llm_batch_item_count(
82                    parsed.items.len(),
83                    batch.len(),
84                ),
85            ));
86        }
87        let mut out: Vec<Option<Vec<f32>>> = vec![None; batch.len()];
88        for item in parsed.items {
89            if item.i == 0 || item.i > batch.len() {
90                return Err(AppError::Embedding(
91                    crate::i18n::validation::embedding_llm_batch_index_out_of_range(
92                        item.i,
93                        batch.len(),
94                    ),
95                ));
96            }
97            if item.v.len() != dim {
98                return Err(AppError::Embedding(
99                    crate::i18n::validation::embedding_llm_batch_item_dims(
100                        item.i,
101                        item.v.len(),
102                        dim,
103                    ),
104                ));
105            }
106            out[item.i - 1] = Some(item.v);
107        }
108        let mut result = Vec::with_capacity(batch.len());
109        for (pos, slot) in out.into_iter().enumerate() {
110            let v = slot.ok_or_else(|| {
111                AppError::Embedding(crate::i18n::validation::embedding_llm_batch_missing_item(
112                    pos + 1,
113                ))
114            })?;
115            result.push((batch[pos].0, v));
116        }
117        Ok(result)
118    }
119
120    pub(crate) fn invoke_with_prefix(
121        &self,
122        prefix: &str,
123        text: &str,
124    ) -> Result<Vec<f32>, AppError> {
125        let dim = crate::constants::embedding_dim();
126        let inner = self.invoke_single_async(prefix, text, dim);
127        // v1.0.79 (G42/A2): reuse the process-wide multi-thread runtime
128        // instead of building a current-thread runtime PER CALL. Inside
129        // an existing runtime (tests, async commands) block_in_place
130        // keeps the worker pool healthy.
131        match tokio::runtime::Handle::try_current() {
132            Ok(handle) => tokio::task::block_in_place(|| handle.block_on(inner)),
133            Err(_) => crate::embedder::shared_runtime()?.block_on(inner),
134        }
135    }
136
137    async fn invoke_single_async(
138        &self,
139        prefix: &str,
140        text: &str,
141        dim: usize,
142    ) -> Result<Vec<f32>, AppError> {
143        let prompt = format!("{prefix}{text}");
144        let stdout = match self.flavour {
145            EmbeddingFlavour::Claude => {
146                self.invoke_claude(&prompt, &build_single_schema(dim))
147                    .await?
148            }
149            EmbeddingFlavour::Codex => {
150                let schema = self.codex_schema_file(dim, false)?;
151                self.invoke_codex(&prompt, schema.path()).await?
152            }
153            EmbeddingFlavour::Opencode => {
154                let opencode_prompt = format!(
155                    "You are an embedding function. Given the input text, output a JSON object \
156                     with a single key \"embedding\" containing an array of exactly {dim} \
157                     floating-point numbers between -1 and 1 that represent the semantic meaning \
158                     of the text. Output ONLY the JSON object, nothing else.\n\n\
159                     Input text: \"{prompt}\""
160                );
161                self.invoke_opencode(&opencode_prompt).await?
162            }
163        };
164        let parsed: EmbeddingResponse = parse_llm_json(&stdout).map_err(|e| {
165            AppError::Embedding(crate::i18n::validation::embedding_llm_parse_failed(
166                e, &stdout,
167            ))
168        })?;
169        if parsed.embedding.len() != dim {
170            return Err(AppError::Embedding(
171                crate::i18n::validation::embedding_llm_returned_dims(parsed.embedding.len(), dim),
172            ));
173        }
174        Ok(parsed.embedding)
175    }
176
177    /// G42/S4: returns the lazily-created, process-shared codex schema
178    /// tempfile for the requested mode. `NamedTempFile` randomises the
179    /// filename (no PID-based collisions) and removes the file on drop
180    /// of the last `Arc` clone.
181    pub(crate) fn codex_schema_file(
182        &self,
183        dim: usize,
184        batch: bool,
185    ) -> Result<Arc<tempfile::NamedTempFile>, AppError> {
186        let mut guard = self.codex_schemas.lock();
187        let slot = if batch {
188            &mut guard.batch
189        } else {
190            &mut guard.single
191        };
192        if let Some((cached_dim, file)) = slot {
193            if *cached_dim == dim {
194                return Ok(Arc::clone(file));
195            }
196        }
197        let content = if batch {
198            build_batch_schema(dim)
199        } else {
200            build_single_schema(dim)
201        };
202        let file = tempfile::Builder::new()
203            .prefix("sqlite-graphrag-embed-schema-")
204            .suffix(".json")
205            .tempfile()
206            .map_err(|e| {
207                AppError::Embedding(
208                    crate::i18n::validation::embedding_schema_tempfile_create_failed(e),
209                )
210            })?;
211        std::fs::write(file.path(), content).map_err(|e| {
212            AppError::Embedding(crate::i18n::validation::embedding_schema_tempfile_write_failed(e))
213        })?;
214        let file = Arc::new(file);
215        *slot = Some((dim, Arc::clone(&file)));
216        Ok(file)
217    }
218
219    async fn invoke_claude(&self, prompt: &str, schema: &str) -> Result<String, AppError> {
220        // v1.0.69 hardening: --strict-mcp-config --mcp-config <PATH> --settings
221        // '{"hooks":{}}' --dangerously-skip-permissions.
222        //
223        // v1.0.76 hardening: Claude Code 2.1+ renamed --output-schema to
224        // --json-schema and accepts the schema as an inline JSON string
225        // (NOT a file path). Also pass --output-format json so the
226        // response is a single JSON object on stdout.
227        //
228        // v1.0.79 (G42/S6): CLAUDE_CONFIG_DIR points at an empty managed
229        // directory BY DEFAULT — the MCP-isolation flags above are
230        // silently ignored upstream (anthropics/claude-code#10787) and a
231        // populated ~/.claude costs ~223k cache-creation tokens per call.
232        //
233        // v1.0.88 (BUG-2 fix, ADR-0046): the inline `--mcp-config '{}'`
234        // form was rejected by Claude Code 2.1.177 (ADR-0045 Bug 2).
235        // Substitute a tempfile path produced by
236        // `write_empty_mcp_config_tempfile()` and run the full
237        // preflight gate BEFORE `Command::spawn()`, mirroring what
238        // `invoke_codex` already does for the codex backend.
239        let spawn_dir = crate::spawn::spawn_isolation_dir()?;
240        let mcp_config_path = crate::spawn::preflight::write_empty_mcp_config_tempfile()?;
241        let argv_refs: [std::ffi::OsString; 0] = [];
242        let preflight_args = crate::spawn::preflight::PreFlightArgs {
243            binary_path: &self.binary,
244            argv: &argv_refs,
245            workspace_root: &spawn_dir,
246            mcp_config_inline_json: None,
247            expected_output_bytes: 65_536,
248            spawner_name: "llm_embedding",
249        };
250        crate::spawn::preflight::preflight_check(&preflight_args)?;
251        let mut cmd = Command::new(&self.binary);
252        cmd.arg("-p")
253            .arg(prompt)
254            .arg("--model")
255            .arg(&self.model)
256            .arg("--json-schema")
257            .arg(schema)
258            .arg("--output-format")
259            .arg("json")
260            .arg("--strict-mcp-config")
261            .arg("--mcp-config")
262            .arg(mcp_config_path.as_os_str())
263            .arg("--settings")
264            .arg(r#"{"hooks":{}}"#)
265            .arg("--dangerously-skip-permissions")
266            .env_clear()
267            .env("PATH", std::env::var("PATH").unwrap_or_default())
268            .env("HOME", std::env::var("HOME").unwrap_or_default())
269            .stdin(Stdio::null())
270            .stdout(Stdio::piped())
271            .stderr(Stdio::piped())
272            // BLOCO 4: cancellation (dropped future) must kill the child.
273            .kill_on_drop(true);
274        // GAP-SPAWN-001: isolate CWD so child never inherits .mcp.json
275        cmd.current_dir(&spawn_dir);
276        cmd.env("CLAUDE_CONFIG_DIR", &spawn_dir);
277        if let Some(config_dir) = claude_embedding_config_dir() {
278            cmd.env("CLAUDE_CONFIG_DIR", &config_dir);
279        }
280        let binary_str = self.binary.to_string_lossy().into_owned();
281        let output = match tokio::time::timeout(self.instance_embed_timeout(), cmd.output()).await {
282            Err(_elapsed) => {
283                return Err(crate::llm::exit_code_hints::into_legacy_embedding(
284                    &crate::llm::exit_code_hints::LlmBackendError::Timeout {
285                        secs: self.instance_embed_timeout().as_secs(),
286                        binary: binary_str.clone(),
287                    },
288                ));
289            }
290            Ok(Err(e)) => {
291                return Err(crate::llm::exit_code_hints::into_legacy_embedding(
292                    &crate::llm::exit_code_hints::LlmBackendError::SpawnFailed {
293                        binary: binary_str.clone(),
294                        source: e.to_string(),
295                    },
296                ));
297            }
298            Ok(Ok(o)) => o,
299        };
300        // G45-CR5 / ADR-0043 (v1.0.85): parse the JSON envelope from
301        // `claude -p --output-format json` and detect OAuth quota
302        // exhaustion by looking for the `rate_limit_error` or
303        // `usage` overflow markers before checking the subprocess
304        // exit status. This lets the deterministic fallback in
305        // hybrid-search and recall swap to codex immediately.
306        let stdout_str = String::from_utf8_lossy(&output.stdout);
307        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&stdout_str) {
308            let is_rate_limited = parsed
309                .get("is_error")
310                .and_then(|v| v.as_bool())
311                .unwrap_or(false)
312                && parsed
313                    .get("result")
314                    .and_then(|v| v.as_str())
315                    .map(|s| {
316                        s.contains("rate limit")
317                            || s.contains("quota")
318                            || s.contains("anthropic-ratelimit")
319                    })
320                    .unwrap_or(false);
321            if is_rate_limited {
322                let snippet: String = parsed
323                    .get("result")
324                    .and_then(|v| v.as_str())
325                    .unwrap_or("")
326                    .chars()
327                    .take(120)
328                    .collect();
329                return Err(AppError::Embedding(
330                    crate::i18n::validation::embedding_oauth_usage_quota_exhausted_claude(&snippet),
331                ));
332            }
333        }
334        if !output.status.success() {
335            let (exit_code, signal) = if let Some(code) = output.status.code() {
336                (Some(code), None)
337            } else {
338                extract_exit_info(&output.status)
339            };
340            let stdout_tail = crate::llm::exit_code_hints::LlmBackendError::truncate_tail(
341                &output.stdout,
342                crate::llm::exit_code_hints::DIAG_TAIL_BYTES,
343            );
344            let stderr_tail = crate::llm::exit_code_hints::LlmBackendError::truncate_tail(
345                &output.stderr,
346                crate::llm::exit_code_hints::DIAG_TAIL_BYTES,
347            );
348            let mut hint = crate::llm::exit_code_hints::diagnose_exit_code(exit_code, signal);
349            // v1.0.89 (GAP-5): detect expired OAuth and suggest actionable fix.
350            if stderr_tail.contains("401")
351                || stderr_tail.contains("Unauthorized")
352                || stderr_tail.contains("expired")
353                || stderr_tail.contains("login")
354                || stdout_tail.contains("401")
355                || stdout_tail.contains("Unauthorized")
356            {
357                hint.push_str(" | Claude OAuth token may be expired; run `claude login` to renew");
358            }
359            return Err(crate::llm::exit_code_hints::into_legacy_embedding(
360                &crate::llm::exit_code_hints::LlmBackendError::NonZeroExit {
361                    exit_code,
362                    signal,
363                    stdout_tail,
364                    stderr_tail,
365                    binary: binary_str,
366                    hint,
367                },
368            ));
369        }
370        Ok(String::from_utf8_lossy(&output.stdout).into_owned())
371    }
372
373    async fn invoke_codex(
374        &self,
375        prompt: &str,
376        schema_path: &std::path::Path,
377    ) -> Result<String, AppError> {
378        let binary_str = self.binary.to_string_lossy().into_owned();
379        let mut cmd = build_codex_embedding_command(&self.binary, &self.model, schema_path)?;
380
381        // GAP-META-005 (v1.0.87, ADR-0045): pre-flight gate before spawn.
382        // `tokio::process::Command` does not expose `get_args()`, so we
383        // skip the argv-size check here and rely on binary + workspace
384        // root + output buffer guards. Embedding prompts are bounded by
385        // the schema validator so argv overflow is not a real risk here.
386        //
387        // v1.0.88 (BUG-7 fix, ADR-0046): propagate the preflight error
388        // directly via `AppError::PreFlightFailed` (via the `From`
389        // impl added in `errors.rs`) so callers and operators see the
390        // structured `PreFlightError` variant and the canonical exit
391        // code 16. The previous implementation wrapped the error in
392        // `LlmBackendError::SpawnFailed`, which mapped to a different
393        // exit code and masked the preflight signal.
394        let argv_refs: [std::ffi::OsString; 0] = [];
395        let preflight_args = crate::spawn::preflight::PreFlightArgs {
396            binary_path: &self.binary,
397            argv: &argv_refs,
398            workspace_root: std::path::Path::new("."),
399            mcp_config_inline_json: None,
400            expected_output_bytes: 65_536,
401            spawner_name: "llm_embedding",
402        };
403        crate::spawn::preflight::preflight_check(&preflight_args)?;
404        let _ = binary_str; // silenced: preflight does not need it
405
406        let mut child = match cmd.spawn() {
407            Ok(c) => c,
408            Err(e) => {
409                return Err(crate::llm::exit_code_hints::into_legacy_embedding(
410                    &crate::llm::exit_code_hints::LlmBackendError::SpawnFailed {
411                        binary: binary_str,
412                        source: e.to_string(),
413                    },
414                ));
415            }
416        };
417        if let Some(mut stdin) = child.stdin.take() {
418            stdin.write_all(prompt.as_bytes()).await.map_err(|e| {
419                AppError::Embedding(crate::i18n::validation::embedding_codex_stdin_write_failed(
420                    e,
421                ))
422            })?;
423            drop(stdin);
424        }
425        let output =
426            match tokio::time::timeout(self.instance_embed_timeout(), child.wait_with_output())
427                .await
428            {
429                Err(_elapsed) => {
430                    return Err(crate::llm::exit_code_hints::into_legacy_embedding(
431                        &crate::llm::exit_code_hints::LlmBackendError::Timeout {
432                            secs: self.instance_embed_timeout().as_secs(),
433                            binary: binary_str,
434                        },
435                    ));
436                }
437                Ok(Err(e)) => {
438                    return Err(crate::llm::exit_code_hints::into_legacy_embedding(
439                        &crate::llm::exit_code_hints::LlmBackendError::SpawnFailed {
440                            binary: binary_str,
441                            source: format!("codex wait failed: {e}"),
442                        },
443                    ));
444                }
445                Ok(Ok(o)) => o,
446            };
447        if !output.status.success() {
448            let (exit_code, signal) = if let Some(code) = output.status.code() {
449                (Some(code), None)
450            } else {
451                extract_exit_info(&output.status)
452            };
453            let stdout_tail = crate::llm::exit_code_hints::LlmBackendError::truncate_tail(
454                &output.stdout,
455                crate::llm::exit_code_hints::DIAG_TAIL_BYTES,
456            );
457            let stderr_tail = crate::llm::exit_code_hints::LlmBackendError::truncate_tail(
458                &output.stderr,
459                crate::llm::exit_code_hints::DIAG_TAIL_BYTES,
460            );
461            let hint = crate::llm::exit_code_hints::diagnose_exit_code(exit_code, signal);
462            // G42/S7: the headless spawn can still hit interactive
463            // prompts on some codex builds; keep the legacy request_user_input
464            // branch as a special-case hint, and stamp the diagnostic
465            // tail on top of the canonical NonZeroExit envelope.
466            let mut combined_hint = hint;
467            if stderr_tail.contains("request_user_input") {
468                combined_hint.push_str(
469                    " | codex requested interactive input in a headless embedding call; \
470                     upgrade codex (>= 0.134) or switch the embedding backend to claude",
471                );
472            }
473            return Err(crate::llm::exit_code_hints::into_legacy_embedding(
474                &crate::llm::exit_code_hints::LlmBackendError::NonZeroExit {
475                    exit_code,
476                    signal,
477                    stdout_tail,
478                    stderr_tail,
479                    binary: binary_str,
480                    hint: combined_hint,
481                },
482            ));
483        }
484        Ok(String::from_utf8_lossy(&output.stdout).into_owned())
485    }
486
487    async fn invoke_opencode(&self, prompt: &str) -> Result<String, AppError> {
488        let binary_str = self.binary.to_string_lossy().into_owned();
489        let spawn_dir = crate::spawn::spawn_isolation_dir()?;
490        let mut cmd = Command::new(&self.binary);
491        cmd.current_dir(&spawn_dir);
492        cmd.arg("run")
493            .arg("--format")
494            .arg("json")
495            .arg("-m")
496            .arg(&self.model)
497            .arg("--dangerously-skip-permissions")
498            .arg(prompt)
499            .env_clear()
500            .env("PATH", std::env::var("PATH").unwrap_or_default())
501            .env("HOME", std::env::var("HOME").unwrap_or_default())
502            .stdin(Stdio::null())
503            .stdout(Stdio::piped())
504            .stderr(Stdio::piped())
505            .kill_on_drop(true);
506        crate::commands::opencode_runner::propagate_opencode_env(&mut cmd);
507
508        let output = match tokio::time::timeout(self.instance_embed_timeout(), cmd.output()).await {
509            Err(_elapsed) => {
510                return Err(crate::llm::exit_code_hints::into_legacy_embedding(
511                    &crate::llm::exit_code_hints::LlmBackendError::Timeout {
512                        secs: self.instance_embed_timeout().as_secs(),
513                        binary: binary_str.clone(),
514                    },
515                ));
516            }
517            Ok(Err(e)) => {
518                return Err(crate::llm::exit_code_hints::into_legacy_embedding(
519                    &crate::llm::exit_code_hints::LlmBackendError::SpawnFailed {
520                        binary: binary_str.clone(),
521                        source: e.to_string(),
522                    },
523                ));
524            }
525            Ok(Ok(o)) => o,
526        };
527        if !output.status.success() {
528            let (exit_code, signal) = if let Some(code) = output.status.code() {
529                (Some(code), None)
530            } else {
531                extract_exit_info(&output.status)
532            };
533            let stdout_tail = crate::llm::exit_code_hints::LlmBackendError::truncate_tail(
534                &output.stdout,
535                crate::llm::exit_code_hints::DIAG_TAIL_BYTES,
536            );
537            let stderr_tail = crate::llm::exit_code_hints::LlmBackendError::truncate_tail(
538                &output.stderr,
539                crate::llm::exit_code_hints::DIAG_TAIL_BYTES,
540            );
541            let hint = crate::llm::exit_code_hints::diagnose_exit_code(exit_code, signal);
542            return Err(crate::llm::exit_code_hints::into_legacy_embedding(
543                &crate::llm::exit_code_hints::LlmBackendError::NonZeroExit {
544                    exit_code,
545                    signal,
546                    stdout_tail,
547                    stderr_tail,
548                    binary: binary_str,
549                    hint,
550                },
551            ));
552        }
553        Ok(String::from_utf8_lossy(&output.stdout).into_owned())
554    }
555}
556
557/// G42/S6: resolves the empty `CLAUDE_CONFIG_DIR` used for embedding
558/// subprocesses.
559///
560/// - XDG `llm.claude_empty_config_dir` is honoured when set and
561///   pointing at a directory (same contract as G28-A in claude_runner);
562/// - otherwise a managed directory is created at
563///   `~/.local/state/sqlite-graphrag/claude-empty-config` (mode 0700).
564///   If `~/.claude/.credentials.json` exists (Linux OAuth storage) it is
565///   copied in so authentication still works; on macOS credentials live
566///   in the Keychain and the empty dir is sufficient.
567///
568/// Returns `None` only when HOME is unset AND no override is given —
569/// in that case the subprocess falls back to claude's own default.
570pub(super) fn claude_embedding_config_dir() -> Option<std::path::PathBuf> {
571    if let Ok(Some(dir)) = crate::config::get_setting("llm.claude_empty_config_dir") {
572        let path = std::path::PathBuf::from(dir);
573        if path.is_dir() {
574            return Some(path);
575        }
576        tracing::warn!(
577            target: "embedding",
578            path = %path.display(),
579            "llm.claude_empty_config_dir is set but not a directory; \
580             falling back to the managed empty config dir"
581        );
582    }
583    let home = std::env::var("HOME").ok()?;
584    let dir = std::path::Path::new(&home)
585        .join(".local/state/sqlite-graphrag")
586        .join("claude-empty-config");
587    if std::fs::create_dir_all(&dir).is_err() {
588        return None;
589    }
590    #[cfg(unix)]
591    {
592        use std::os::unix::fs::PermissionsExt;
593        let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
594    }
595    // Linux stores OAuth credentials on disk; copy them so the isolated
596    // config dir still authenticates. Best-effort: macOS uses Keychain.
597    // v1.0.89: ALWAYS copy (was: skip if target exists). OAuth tokens
598    // expire and the stale copy causes 401 until manually deleted.
599    let creds = std::path::Path::new(&home).join(".claude/.credentials.json");
600    if creds.exists() {
601        let target = dir.join(".credentials.json");
602        let _ = std::fs::copy(&creds, &target);
603    }
604    Some(dir)
605}
606
607pub(crate) fn build_codex_embedding_command(
608    binary: &std::path::Path,
609    model: &str,
610    schema_path: &std::path::Path,
611) -> Result<Command, AppError> {
612    let spawn_dir = crate::spawn::spawn_isolation_dir()?;
613    let mut cmd = Command::new(binary);
614    cmd.current_dir(&spawn_dir);
615    cmd.arg("exec")
616        .arg("-c")
617        .arg("sandbox_mode='read-only'")
618        .arg("-c")
619        .arg("approval_policy='never'")
620        .arg("--json")
621        .arg("--output-schema")
622        .arg(schema_path)
623        .arg("--ephemeral")
624        .arg("--skip-git-repo-check")
625        .arg("--sandbox")
626        .arg("read-only")
627        .arg("--ignore-user-config")
628        .arg("--ignore-rules");
629    if crate::extract::codex_compat::codex_supports_ask_for_approval() {
630        cmd.arg("--ask-for-approval").arg("never");
631    }
632    // v1.0.89: use the real CODEX_HOME (~/.codex) instead of an isolated
633    // per-PID directory. The isolated dir caused cold-start overhead (codex
634    // creates ~6 SQLite databases on first run) that regularly exceeded
635    // the 30s embedding timeout. The --ignore-user-config + --ephemeral
636    // flags already prevent config pollution; CODEX_HOME only needs auth.
637    cmd.arg("--model")
638        .arg(model)
639        .arg("-")
640        .env_clear()
641        .env("PATH", std::env::var("PATH").unwrap_or_default())
642        .env("HOME", std::env::var("HOME").unwrap_or_default());
643    if let Ok(codex_home) = std::env::var("CODEX_HOME") {
644        cmd.env("CODEX_HOME", codex_home);
645    } else if let Ok(home) = std::env::var("HOME") {
646        let default_home = std::path::Path::new(&home).join(".codex");
647        if default_home.exists() {
648            cmd.env("CODEX_HOME", &default_home);
649        }
650    }
651    cmd.stdin(Stdio::piped())
652        .stdout(Stdio::piped())
653        .stderr(Stdio::piped())
654        // BLOCO 4: cancellation (dropped future) must kill the child.
655        .kill_on_drop(true);
656    Ok(cmd)
657}
658
659// prepare_isolated_codex_home removed in v1.0.89: the per-PID isolated
660// CODEX_HOME caused cold-start overhead that exceeded the 30s embedding
661// timeout. The real ~/.codex is now used directly (see build_codex_embedding_command).