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