sqlite_graphrag/spawn/
mod.rs1pub 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#[derive(Debug, Clone)]
29pub struct ParsedOutput {
30 pub items: Vec<serde_json::Value>,
32 pub raw_stdout: String,
34 pub raw_stderr: String,
36 pub exit_code: i32,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct ExecutorCapabilities {
43 pub supports_mcp_map: bool,
45 pub supports_ask_for_approval_flag: bool,
47 pub supports_strict_schema: bool,
49 pub default_flags: Vec<String>,
51 pub removed_flags: Vec<String>,
53}
54
55impl ExecutorCapabilities {
56 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#[async_trait]
70pub trait VersionAdapter: Send + Sync {
71 fn name(&self) -> &'static str;
73
74 async fn detect(&self) -> Result<ExecutorVersion, AppError>;
76
77 fn capabilities_for(&self, version: &ExecutorVersion) -> ExecutorCapabilities;
79
80 fn build_args(
82 &self,
83 prompt: &str,
84 caps: &ExecutorCapabilities,
85 compat_mode: CompatMode,
86 ) -> Vec<String>;
87
88 fn parse_output(&self, raw_stdout: &str, raw_stderr: &str, exit_code: i32) -> ParsedOutput;
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum CompatMode {
95 Strict,
97 Lenient,
99 Auto,
101}
102
103impl CompatMode {
104 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#[derive(Debug, Default)]
117pub struct VersionCache {
118 inner: std::sync::Mutex<BTreeMap<String, ExecutorVersion>>,
119}
120
121impl VersionCache {
122 pub fn new() -> Self {
124 Self::default()
125 }
126
127 pub fn get(&self, name: &str) -> Option<ExecutorVersion> {
129 self.inner.lock().ok().and_then(|m| m.get(name).cloned())
130 }
131
132 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 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
149pub fn global_version_cache() -> &'static VersionCache {
151 VERSION_CACHE.get_or_init(VersionCache::new)
152}
153
154pub 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
163pub 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
179pub 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
191pub 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
201pub 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}