sqlite_graphrag/commands/
claude_runner.rs1use crate::errors::AppError;
12use crate::spawn::env_whitelist::apply_env_whitelist;
13use std::path::Path;
14use std::process::{Command, Stdio};
15
16const MIN_CLAUDE_VERSION: &str = "2.1.0";
18
19#[cfg(target_os = "linux")]
21const DEFAULT_SUBPROCESS_MEMORY_LIMIT_MB: u64 = 4096;
22
23#[cfg(target_os = "linux")]
48pub fn spawn_with_memory_limit(cmd: &mut Command) -> std::io::Result<std::process::Child> {
49 use std::os::unix::process::CommandExt;
50 let max_mb: u64 = crate::runtime_config::resolve_u64(
51 None,
52 "spawn.subprocess_memory_limit_mb",
53 DEFAULT_SUBPROCESS_MEMORY_LIMIT_MB,
54 );
55 let max_bytes = max_mb * 1024 * 1024;
56 unsafe {
63 cmd.pre_exec(move || {
64 let sid = libc::setsid();
65 if sid == -1 {
66 let err = std::io::Error::last_os_error();
67 if err.raw_os_error() != Some(libc::EPERM) {
68 return Err(err);
69 }
70 }
71 let limit = libc::rlimit {
72 rlim_cur: max_bytes,
73 rlim_max: max_bytes,
74 };
75 if libc::setrlimit(libc::RLIMIT_AS, &limit) != 0 {
76 return Err(std::io::Error::last_os_error());
77 }
78 Ok(())
79 });
80 }
81 tracing::debug!(
82 target: "process",
83 program = ?cmd.get_program(),
84 args = ?cmd.get_args().collect::<Vec<_>>(),
85 "spawning external process"
86 );
87 cmd.spawn()
88}
89
90#[cfg(not(target_os = "linux"))]
93pub fn spawn_with_memory_limit(cmd: &mut Command) -> std::io::Result<std::process::Child> {
94 #[cfg(unix)]
95 {
96 use std::os::unix::process::CommandExt;
97 unsafe {
100 cmd.pre_exec(|| {
101 let sid = libc::setsid();
102 if sid == -1 {
103 let err = std::io::Error::last_os_error();
104 if err.raw_os_error() != Some(libc::EPERM) {
105 return Err(err);
106 }
107 }
108 Ok(())
109 });
110 }
111 }
112 tracing::debug!(
113 target: "process",
114 program = ?cmd.get_program(),
115 args = ?cmd.get_args().collect::<Vec<_>>(),
116 "spawning external process"
117 );
118 cmd.spawn()
119}
120
121#[derive(Debug, serde::Deserialize)]
123pub struct ClaudeOutputElement {
124 pub r#type: Option<String>,
125 pub subtype: Option<String>,
126 #[serde(default)]
127 pub is_error: bool,
128 pub structured_output: Option<serde_json::Value>,
129 pub result: Option<String>,
130 pub total_cost_usd: Option<f64>,
131 pub error: Option<String>,
132 pub terminal_reason: Option<String>,
133 #[serde(rename = "apiKeySource")]
134 pub api_key_source: Option<String>,
135}
136
137#[derive(Debug)]
139pub struct ClaudeResult {
140 pub value: serde_json::Value,
141 pub cost_usd: f64,
142 pub is_oauth: bool,
143}
144
145pub fn validate_claude_version(binary: &Path) -> Result<String, AppError> {
147 let resolved = which::which(binary).map_err(|_| {
148 AppError::Validation(format!(
149 "executable '{}' not found in PATH; ensure it is installed and accessible",
150 binary.display()
151 ))
152 })?;
153 let output = Command::new(&resolved)
154 .arg("--version")
155 .stdin(Stdio::null())
156 .stdout(Stdio::piped())
157 .stderr(Stdio::piped())
158 .output()
159 .map_err(AppError::Io)?;
160
161 if !output.status.success() {
162 return Err(AppError::Validation(
163 "failed to run 'claude --version'".to_string(),
164 ));
165 }
166
167 let version_str = String::from_utf8(output.stdout)
168 .map_err(|_| AppError::Validation("claude --version output is not UTF-8".to_string()))?;
169 let version = version_str.trim().to_string();
170 let numeric = version.split([' ', '(']).next().unwrap_or("").trim();
171
172 fn parse_semver(s: &str) -> Option<(u64, u64, u64)> {
173 let parts: Vec<&str> = s.splitn(3, '.').collect();
174 if parts.len() < 2 {
175 return None;
176 }
177 let major = parts[0].parse::<u64>().ok()?;
178 let minor = parts[1].parse::<u64>().ok()?;
179 let patch = parts
180 .get(2)
181 .and_then(|p| p.parse::<u64>().ok())
182 .unwrap_or(0);
183 Some((major, minor, patch))
184 }
185
186 if let (Some(actual), Some(min)) = (parse_semver(numeric), parse_semver(MIN_CLAUDE_VERSION)) {
187 if actual < min {
188 return Err(AppError::Validation(format!(
189 "Claude Code version {numeric} is below minimum required {MIN_CLAUDE_VERSION}"
190 )));
191 }
192 }
193
194 Ok(version)
195}
196
197pub fn build_claude_command(
234 binary: &Path,
235 prompt: &str,
236 json_schema: &str,
237 model: Option<&str>,
238 max_turns: u32,
239) -> Result<Command, crate::errors::AppError> {
240 if let Ok(_key) = std::env::var("ANTHROPIC_API_KEY") {
244 let mut cmd = Command::new("false");
249 cmd.env_clear();
250 cmd.env("PATH", "/nonexistent");
251 cmd.arg("--oauth-only-violation-anthropic-api-key-set");
252 cmd.arg("--oauth-only-resolution-use-anthropic-auth-token");
253 return Ok(cmd);
254 }
255
256 let mut cmd = Command::new(binary);
257
258 apply_env_whitelist(&mut cmd, crate::spawn::env_whitelist::is_strict_env_clear());
263 crate::spawn::apply_cwd_isolation(&mut cmd)?;
264
265 let mcp_config_path = crate::spawn::preflight::write_empty_mcp_config_tempfile()?;
274
275 cmd.arg("-p")
276 .arg(prompt)
277 .arg("--strict-mcp-config")
278 .arg("--mcp-config")
279 .arg(mcp_config_path.as_os_str())
280 .arg("--dangerously-skip-permissions")
281 .arg("--settings")
282 .arg(r#"{"hooks":{}}"#)
283 .arg("--output-format")
284 .arg("json")
285 .arg("--json-schema")
286 .arg(json_schema)
287 .arg("--max-turns")
288 .arg(max_turns.to_string())
289 .arg("--no-session-persistence");
290
291 if let Some(m) = model {
292 cmd.arg("--model").arg(m);
293 }
294
295 cmd.stdin(Stdio::null())
296 .stdout(Stdio::piped())
297 .stderr(Stdio::piped());
298
299 let argv_refs: Vec<std::ffi::OsString> = cmd.get_args().map(|s| s.to_os_string()).collect();
305 let preflight_args = crate::spawn::preflight::PreFlightArgs {
306 binary_path: binary,
307 argv: &argv_refs,
308 workspace_root: std::path::Path::new("."),
309 mcp_config_inline_json: None,
310 expected_output_bytes: 65_536,
311 spawner_name: "claude_runner",
312 };
313 if let Err(e) = crate::spawn::preflight::preflight_check(&preflight_args) {
314 return Err(crate::errors::AppError::from(e));
320 }
321
322 Ok(cmd)
323}
324
325pub fn parse_claude_output(stdout: &str) -> Result<ClaudeResult, AppError> {
330 parse_claude_output_opts(stdout, false)
331}
332
333pub fn parse_claude_output_opts(
339 stdout: &str,
340 tolerate_max_turns: bool,
341) -> Result<ClaudeResult, AppError> {
342 let elements: Vec<ClaudeOutputElement> = serde_json::from_str(stdout).map_err(|e| {
343 AppError::Validation(format!("failed to parse claude output as JSON array: {e}"))
344 })?;
345
346 let is_oauth = elements
347 .iter()
348 .find(|e| e.r#type.as_deref() == Some("system") && e.subtype.as_deref() == Some("init"))
349 .and_then(|e| e.api_key_source.as_deref())
350 .map(|s| s == "none")
351 .unwrap_or(false);
352
353 let result_elem = elements
354 .iter()
355 .find(|e| e.r#type.as_deref() == Some("result"))
356 .ok_or_else(|| {
357 AppError::Validation("claude output missing 'result' element".to_string())
358 })?;
359
360 if !tolerate_max_turns && result_elem.terminal_reason.as_deref() == Some("max_turns") {
362 tracing::warn!(
363 target: "claude_runner",
364 "claude -p hit max_turns limit — hooks may have consumed turns"
365 );
366 return Err(AppError::Validation(
367 "claude -p hit max_turns: hooks may be consuming turns; increase --max-turns or disable hooks".to_string(),
368 ));
369 }
370
371 if result_elem.is_error {
372 let err_msg = result_elem
373 .error
374 .as_deref()
375 .or(result_elem.result.as_deref())
376 .unwrap_or("unknown error");
377 if err_msg.contains("rate_limit") || err_msg.contains("overloaded") {
378 return Err(AppError::RateLimited {
379 detail: err_msg.to_string(),
380 });
381 }
382 if err_msg.contains("Not logged in") || err_msg.contains("authentication") {
383 tracing::warn!(
384 target: "claude_runner",
385 "Claude Code authentication failed. Re-authenticate interactively with: claude"
386 );
387 }
388 return Err(AppError::Validation(format!(
389 "claude extraction failed: {err_msg}"
390 )));
391 }
392
393 let value = if let Some(v) = result_elem.structured_output.clone() {
394 v
395 } else if let Some(text) = &result_elem.result {
396 serde_json::from_str(text).map_err(|e| {
397 AppError::Validation(format!("failed to parse claude result field as JSON: {e}"))
398 })?
399 } else {
400 return Err(AppError::Validation(
401 "claude result missing structured_output and result field".into(),
402 ));
403 };
404
405 let cost = result_elem.total_cost_usd.unwrap_or(0.0);
406 Ok(ClaudeResult {
407 value,
408 cost_usd: cost,
409 is_oauth,
410 })
411}
412
413pub fn run_claude(
419 binary: &Path,
420 prompt: &str,
421 json_schema: &str,
422 input_text: &str,
423 model: Option<&str>,
424 timeout_secs: u64,
425 max_turns: u32,
426) -> Result<ClaudeResult, AppError> {
427 use wait_timeout::ChildExt;
428
429 let full_prompt = format!("{prompt}\n\n{input_text}");
430 let mut cmd = build_claude_command(binary, &full_prompt, json_schema, model, max_turns)?;
431
432 let mut child = spawn_with_memory_limit(&mut cmd).map_err(|e| {
433 AppError::Io(std::io::Error::new(
434 e.kind(),
435 format!("failed to spawn claude: {e}"),
436 ))
437 })?;
438
439 let start = std::time::Instant::now();
440 let timeout = std::time::Duration::from_secs(timeout_secs);
441 let status = child.wait_timeout(timeout).map_err(AppError::Io)?;
442
443 if status.is_none() {
444 #[cfg(unix)]
450 unsafe {
451 libc::kill(child.id() as i32, libc::SIGTERM);
452 }
453 let _ = child.kill();
454 let _ = child.wait();
455 }
456
457 match status {
458 Some(exit_status) => {
459 tracing::debug!(
460 target: "process",
461 exit_code = ?exit_status.code(),
462 elapsed_ms = start.elapsed().as_millis() as u64,
463 "external process completed"
464 );
465
466 let mut stdout_buf = Vec::new();
467 let mut stderr_buf = Vec::new();
468 if let Some(mut out) = child.stdout.take() {
469 std::io::Read::read_to_end(&mut out, &mut stdout_buf).map_err(AppError::Io)?;
470 }
471 if let Some(mut err) = child.stderr.take() {
472 std::io::Read::read_to_end(&mut err, &mut stderr_buf).map_err(AppError::Io)?;
473 }
474
475 let stdout_str = String::from_utf8(stdout_buf)
476 .map_err(|_| AppError::Validation("claude -p stdout is not valid UTF-8".into()))?;
477
478 if !exit_status.success() {
480 if let Ok(result) = parse_claude_output(&stdout_str) {
481 return Ok(result);
482 }
483 let stderr_str = String::from_utf8_lossy(&stderr_buf);
484 if stderr_str.contains("auth") || stderr_str.contains("login") {
485 tracing::warn!(
486 target: "claude_runner",
487 "Claude Code authentication may have failed. Re-authenticate with: claude"
488 );
489 }
490 return Err(AppError::Validation(format!(
491 "claude -p exited with code {:?}: {}",
492 exit_status.code(),
493 stderr_str.trim()
494 )));
495 }
496
497 parse_claude_output(&stdout_str)
498 }
499 None => {
500 tracing::warn!(target: "claude_runner", timeout_secs, "claude -p timed out, terminating");
501 terminate_gracefully(&mut child, 3);
502 Err(AppError::Validation(format!(
503 "claude -p timed out after {timeout_secs} seconds"
504 )))
505 }
506 }
507}
508
509#[cfg(unix)]
511pub fn terminate_gracefully(child: &mut std::process::Child, grace_secs: u64) {
512 use wait_timeout::ChildExt;
513 unsafe {
514 libc::kill(child.id() as i32, libc::SIGTERM);
515 }
516 match child.wait_timeout(std::time::Duration::from_secs(grace_secs)) {
517 Ok(Some(_)) => {}
518 _ => {
519 tracing::warn!(target: "process", pid = child.id(), "child ignored SIGTERM, sending SIGKILL");
520 let _ = child.kill();
521 let _ = child.wait();
522 }
523 }
524}
525
526#[cfg(not(unix))]
528pub fn terminate_gracefully(child: &mut std::process::Child, _grace_secs: u64) {
529 let _ = child.kill();
530 let _ = child.wait();
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536
537 #[test]
538 fn parse_output_detects_max_turns() {
539 let stdout = r#"[{"type":"system","subtype":"init","apiKeySource":"none"},{"type":"result","is_error":false,"terminal_reason":"max_turns","structured_output":{"name":"t"}}]"#;
540 let err = parse_claude_output(stdout).unwrap_err();
541 assert!(
542 format!("{err}").contains("max_turns"),
543 "must detect max_turns in output"
544 );
545 }
546
547 #[test]
548 fn parse_output_extracts_structured_value() {
549 let stdout = r#"[{"type":"system","subtype":"init","apiKeySource":"none"},{"type":"result","is_error":false,"structured_output":{"key":"val"},"total_cost_usd":0.01}]"#;
550 let result = parse_claude_output(stdout).unwrap();
551 assert_eq!(result.value["key"], "val");
552 assert!((result.cost_usd - 0.01).abs() < f64::EPSILON);
553 assert!(result.is_oauth);
554 }
555
556 #[test]
557 fn parse_output_detects_rate_limit() {
558 let stdout = r#"[{"type":"result","is_error":true,"error":"rate_limit exceeded"}]"#;
559 let err = parse_claude_output(stdout).unwrap_err();
560 assert!(
561 matches!(err, AppError::RateLimited { .. }),
562 "expected AppError::RateLimited, got: {err}"
563 );
564 }
565
566 #[test]
570 #[serial_test::serial(env)]
571 fn build_command_oauth_only_mandatory_flags() {
572 unsafe {
574 std::env::remove_var("ANTHROPIC_API_KEY");
575 std::env::remove_var("CLAUDE_CONFIG_DIR");
578 }
579 let cmd = build_claude_command(
580 std::path::Path::new("/usr/bin/false"),
581 "test prompt",
582 "{}",
583 Some("sonnet"),
584 4,
585 )
586 .expect("preflight gate accepts valid args");
587 let args: Vec<&str> = cmd.get_args().filter_map(|a| a.to_str()).collect();
588 assert!(args.contains(&"-p"), "must have -p");
590 assert!(
591 args.contains(&"--strict-mcp-config"),
592 "must have --strict-mcp-config (gaps.md:206)"
593 );
594 assert!(
595 args.contains(&"--mcp-config"),
596 "must have --mcp-config (gaps.md:207)"
597 );
598 assert!(
599 args.contains(&"--dangerously-skip-permissions"),
600 "must have --dangerously-skip-permissions (gaps.md:208)"
601 );
602 assert!(
603 args.contains(&"--settings"),
604 "must have --settings (gaps.md:209)"
605 );
606 assert!(
607 args.contains(&"--output-format"),
608 "must have --output-format json (gaps.md:213)"
609 );
610 assert!(args.contains(&"--json-schema"), "must have --json-schema");
611 assert!(
612 args.contains(&"--max-turns"),
613 "must have --max-turns (gaps.md:212)"
614 );
615 assert!(
616 args.contains(&"--no-session-persistence"),
617 "must have --no-session-persistence"
618 );
619 assert!(
620 args.contains(&"--model"),
621 "must have --model when model is Some"
622 );
623 assert!(
625 !args.contains(&"--bare"),
626 "--bare is PROHIBITED (gaps.md:49)"
627 );
628 }
629
630 #[test]
634 #[serial_test::serial(env)]
635 fn build_command_aborts_when_anthropic_api_key_set() {
636 unsafe {
638 std::env::set_var("ANTHROPIC_API_KEY", "sk-test-violation");
639 std::env::remove_var("CLAUDE_CONFIG_DIR");
643 }
644 let cmd = build_claude_command(
645 std::path::Path::new("/usr/bin/claude"),
646 "test prompt",
647 "{}",
648 Some("sonnet"),
649 4,
650 )
651 .expect("preflight gate accepts valid args");
652 let program = cmd.get_program().to_string_lossy().to_string();
653 let args: Vec<&str> = cmd.get_args().filter_map(|a| a.to_str()).collect();
654 assert_eq!(
655 program, "false",
656 "when ANTHROPIC_API_KEY is set, build_claude_command must abort"
657 );
658 assert!(
659 args.contains(&"--oauth-only-violation-anthropic-api-key-set"),
660 "aborted command must carry violation marker"
661 );
662 unsafe {
663 std::env::remove_var("ANTHROPIC_API_KEY");
664 }
665 }
666}