Skip to main content

sqlite_graphrag/spawn/
mod.rs

1//! Spawn subsystem abstraction (v1.0.75 — G22 solution)
2//!
3//! Provides `VersionAdapter` trait that detects the version of external CLI
4//! executors (claude code, codex CLI, opencode headless) and adapts flags,
5//! schema and error handling accordingly.
6
7pub mod claude_adapter;
8pub mod codex_adapter;
9pub mod compat_matrix;
10pub mod env_whitelist;
11pub mod error_propagator;
12pub mod executor_version;
13pub mod opencode_adapter;
14pub mod preflight;
15
16use crate::errors::AppError;
17use async_trait::async_trait;
18use executor_version::ExecutorVersion;
19use std::collections::BTreeMap;
20use std::process::Stdio;
21
22/// Result of parsing a subprocess output stream.
23#[derive(Debug, Clone)]
24pub struct ParsedOutput {
25    pub items: Vec<serde_json::Value>,
26    pub raw_stdout: String,
27    pub raw_stderr: String,
28    pub exit_code: i32,
29}
30
31/// Detected capability of a given executor version.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct ExecutorCapabilities {
34    pub supports_mcp_map: bool,
35    pub supports_ask_for_approval_flag: bool,
36    pub supports_strict_schema: bool,
37    pub default_flags: Vec<String>,
38    pub removed_flags: Vec<String>,
39}
40
41impl ExecutorCapabilities {
42    pub fn empty() -> Self {
43        Self {
44            supports_mcp_map: false,
45            supports_ask_for_approval_flag: false,
46            supports_strict_schema: false,
47            default_flags: Vec::new(),
48            removed_flags: Vec::new(),
49        }
50    }
51}
52
53/// Trait for adapting spawn invocations to a particular executor's version.
54#[async_trait]
55pub trait VersionAdapter: Send + Sync {
56    /// Logical name of the executor (e.g. "codex", "claude", "opencode").
57    fn name(&self) -> &'static str;
58
59    /// Detect the version by invoking `<executor> --version` and parsing the output.
60    async fn detect(&self) -> Result<ExecutorVersion, AppError>;
61
62    /// Returns the capability matrix for the given version.
63    fn capabilities_for(&self, version: &ExecutorVersion) -> ExecutorCapabilities;
64
65    /// Build the CLI invocation arguments for a given prompt and capabilities.
66    fn build_args(
67        &self,
68        prompt: &str,
69        caps: &ExecutorCapabilities,
70        compat_mode: CompatMode,
71    ) -> Vec<String>;
72
73    /// Parses the executor output into structured items.
74    fn parse_output(&self, raw_stdout: &str, raw_stderr: &str, exit_code: i32) -> ParsedOutput;
75}
76
77/// Compatibility mode controlling how strict the adapter is with version drift.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum CompatMode {
80    /// Abort on unknown versions
81    Strict,
82    /// Try the invocation anyway
83    Lenient,
84    /// Auto-detect and adapt (default)
85    Auto,
86}
87
88impl CompatMode {
89    pub fn parse(s: &str) -> Self {
90        match s.to_ascii_lowercase().as_str() {
91            "strict" => Self::Strict,
92            "lenient" => Self::Lenient,
93            _ => Self::Auto,
94        }
95    }
96}
97
98/// In-memory cache of `executor -> ExecutorVersion` to avoid re-spawning
99/// `--version` on every command. Resettable via `--executor-version-check`.
100#[derive(Debug, Default)]
101pub struct VersionCache {
102    inner: std::sync::Mutex<BTreeMap<String, ExecutorVersion>>,
103}
104
105impl VersionCache {
106    pub fn new() -> Self {
107        Self::default()
108    }
109
110    pub fn get(&self, name: &str) -> Option<ExecutorVersion> {
111        self.inner.lock().ok().and_then(|m| m.get(name).cloned())
112    }
113
114    pub fn put(&self, name: &str, version: ExecutorVersion) {
115        if let Ok(mut m) = self.inner.lock() {
116            m.insert(name.to_string(), version);
117        }
118    }
119
120    pub fn clear(&self) {
121        if let Ok(mut m) = self.inner.lock() {
122            m.clear();
123        }
124    }
125}
126
127static VERSION_CACHE: std::sync::OnceLock<VersionCache> = std::sync::OnceLock::new();
128
129pub fn global_version_cache() -> &'static VersionCache {
130    VERSION_CACHE.get_or_init(VersionCache::new)
131}
132
133/// Reusable tokio command builder for subprocess invocation.
134pub fn base_command(binary: &str) -> std::process::Command {
135    let mut cmd = std::process::Command::new(binary);
136    cmd.stdin(Stdio::null())
137        .stdout(Stdio::piped())
138        .stderr(Stdio::piped());
139    cmd
140}
141
142/// GAP-SPAWN-001 (v1.0.91): isolation directory for LLM subprocesses.
143/// Prevents .mcp.json walk-up by anchoring CWD in a clean temp dir.
144pub fn spawn_isolation_dir() -> Result<std::path::PathBuf, AppError> {
145    let dir = std::env::temp_dir().join(format!("sqlite-graphrag-spawn-{}", std::process::id()));
146    std::fs::create_dir_all(&dir).map_err(|e| {
147        AppError::Io(std::io::Error::new(
148            e.kind(),
149            format!(
150                "failed to create spawn isolation dir {}: {e}",
151                dir.display()
152            ),
153        ))
154    })?;
155    Ok(dir)
156}
157
158/// Apply CWD isolation to a subprocess command.
159/// Sets current_dir to an ephemeral directory without .mcp.json ancestors
160/// and CLAUDE_CONFIG_DIR to block user-level MCP inheritance.
161pub fn apply_cwd_isolation(
162    cmd: &mut std::process::Command,
163) -> Result<std::path::PathBuf, AppError> {
164    let dir = spawn_isolation_dir()?;
165    cmd.current_dir(&dir);
166    cmd.env("CLAUDE_CONFIG_DIR", &dir);
167    Ok(dir)
168}
169
170/// Tokio variant of [`apply_cwd_isolation`] for async subprocess commands.
171pub fn apply_cwd_isolation_tokio(
172    cmd: &mut tokio::process::Command,
173) -> Result<std::path::PathBuf, AppError> {
174    let dir = spawn_isolation_dir()?;
175    cmd.current_dir(&dir);
176    cmd.env("CLAUDE_CONFIG_DIR", &dir);
177    Ok(dir)
178}
179
180#[cfg(test)]
181mod isolation_tests {
182    use super::*;
183
184    #[test]
185    fn test_spawn_isolation_dir_creates_in_temp() {
186        let dir = spawn_isolation_dir().unwrap();
187        assert!(dir.exists());
188        assert!(dir.starts_with(std::env::temp_dir()));
189        let mut check = dir.as_path();
190        while let Some(parent) = check.parent() {
191            assert!(!parent.join(".mcp.json").exists() || parent == std::path::Path::new("/"));
192            check = parent;
193            if parent == std::path::Path::new("/") {
194                break;
195            }
196        }
197    }
198
199    #[test]
200    fn test_apply_cwd_isolation_modifies_command() {
201        let mut cmd = std::process::Command::new("false");
202        let dir = apply_cwd_isolation(&mut cmd).unwrap();
203        assert!(dir.exists());
204        let debug = format!("{cmd:?}");
205        assert!(debug.contains("sqlite-graphrag-spawn-"));
206    }
207}