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