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>,
126 pub subtype: Option<String>,
128 #[serde(default)]
130 pub is_error: bool,
131 pub structured_output: Option<serde_json::Value>,
133 pub result: Option<String>,
135 pub total_cost_usd: Option<f64>,
137 pub error: Option<String>,
139 pub terminal_reason: Option<String>,
141 #[serde(rename = "apiKeySource")]
143 pub api_key_source: Option<String>,
144}
145
146#[derive(Debug)]
148pub struct ClaudeResult {
149 pub value: serde_json::Value,
151 pub cost_usd: f64,
153 pub is_oauth: bool,
155}
156
157pub fn validate_claude_version(binary: &Path) -> Result<String, AppError> {
159 let resolved = which::which(binary).map_err(|_| {
160 AppError::Validation(crate::i18n::validation::executable_not_in_path_generic(
161 &binary.display().to_string(),
162 ))
163 })?;
164 let output = Command::new(&resolved)
165 .arg("--version")
166 .stdin(Stdio::null())
167 .stdout(Stdio::piped())
168 .stderr(Stdio::piped())
169 .output()
170 .map_err(AppError::Io)?;
171
172 if !output.status.success() {
173 return Err(AppError::Validation(
174 "failed to run 'claude --version'".to_string(),
175 ));
176 }
177
178 let version_str = String::from_utf8(output.stdout)
179 .map_err(|_| AppError::Validation(crate::i18n::validation::claude_version_not_utf8()))?;
180 let version = version_str.trim().to_string();
181 let numeric = version.split([' ', '(']).next().unwrap_or("").trim();
182
183 fn parse_semver(s: &str) -> Option<(u64, u64, u64)> {
184 let parts: Vec<&str> = s.splitn(3, '.').collect();
185 if parts.len() < 2 {
186 return None;
187 }
188 let major = parts[0].parse::<u64>().ok()?;
189 let minor = parts[1].parse::<u64>().ok()?;
190 let patch = parts
191 .get(2)
192 .and_then(|p| p.parse::<u64>().ok())
193 .unwrap_or(0);
194 Some((major, minor, patch))
195 }
196
197 if let (Some(actual), Some(min)) = (parse_semver(numeric), parse_semver(MIN_CLAUDE_VERSION)) {
198 if actual < min {
199 return Err(AppError::Validation(
200 crate::i18n::validation::version_below_minimum(
201 "Claude Code",
202 numeric,
203 MIN_CLAUDE_VERSION,
204 ),
205 ));
206 }
207 }
208
209 Ok(version)
210}
211
212pub fn build_claude_command(
249 binary: &Path,
250 prompt: &str,
251 json_schema: &str,
252 model: Option<&str>,
253 max_turns: u32,
254) -> Result<Command, crate::errors::AppError> {
255 if let Ok(_key) = std::env::var("ANTHROPIC_API_KEY") {
259 let mut cmd = crate::spawn::failing_command();
264 cmd.env_clear();
265 cmd.env("PATH", "/nonexistent");
266 cmd.arg("--oauth-only-violation-anthropic-api-key-set");
267 cmd.arg("--oauth-only-resolution-use-anthropic-auth-token");
268 return Ok(cmd);
269 }
270
271 let mut cmd = Command::new(binary);
272
273 apply_env_whitelist(&mut cmd, crate::spawn::env_whitelist::is_strict_env_clear());
278 crate::spawn::apply_cwd_isolation(&mut cmd)?;
279
280 let mcp_config_path = crate::spawn::preflight::write_empty_mcp_config_tempfile()?;
289
290 cmd.arg("-p")
291 .arg(prompt)
292 .arg("--strict-mcp-config")
293 .arg("--mcp-config")
294 .arg(mcp_config_path.as_os_str())
295 .arg("--dangerously-skip-permissions")
296 .arg("--settings")
297 .arg(r#"{"hooks":{}}"#)
298 .arg("--output-format")
299 .arg("json")
300 .arg("--json-schema")
301 .arg(json_schema)
302 .arg("--max-turns")
303 .arg(max_turns.to_string())
304 .arg("--no-session-persistence");
305
306 if let Some(m) = model {
307 cmd.arg("--model").arg(m);
308 }
309
310 cmd.stdin(Stdio::null())
311 .stdout(Stdio::piped())
312 .stderr(Stdio::piped());
313
314 let argv_refs: Vec<std::ffi::OsString> = cmd.get_args().map(|s| s.to_os_string()).collect();
320 let preflight_args = crate::spawn::preflight::PreFlightArgs {
321 binary_path: binary,
322 argv: &argv_refs,
323 workspace_root: std::path::Path::new("."),
324 mcp_config_inline_json: None,
325 expected_output_bytes: 65_536,
326 spawner_name: "claude_runner",
327 };
328 if let Err(e) = crate::spawn::preflight::preflight_check(&preflight_args) {
329 return Err(crate::errors::AppError::from(e));
335 }
336
337 Ok(cmd)
338}
339
340pub fn parse_claude_output(stdout: &str) -> Result<ClaudeResult, AppError> {
345 parse_claude_output_opts(stdout, false)
346}
347
348pub fn parse_claude_output_opts(
354 stdout: &str,
355 tolerate_max_turns: bool,
356) -> Result<ClaudeResult, AppError> {
357 let elements: Vec<ClaudeOutputElement> = serde_json::from_str(stdout).map_err(|e| {
358 AppError::Validation(crate::i18n::validation::failed_to_parse_claude_json_array(
359 &e,
360 ))
361 })?;
362
363 let is_oauth = elements
364 .iter()
365 .find(|e| e.r#type.as_deref() == Some("system") && e.subtype.as_deref() == Some("init"))
366 .and_then(|e| e.api_key_source.as_deref())
367 .map(|s| s == "none")
368 .unwrap_or(false);
369
370 let result_elem = elements
371 .iter()
372 .find(|e| e.r#type.as_deref() == Some("result"))
373 .ok_or_else(|| {
374 AppError::Validation(crate::i18n::validation::claude_output_missing_result())
375 })?;
376
377 if !tolerate_max_turns && result_elem.terminal_reason.as_deref() == Some("max_turns") {
379 tracing::warn!(
380 target: "claude_runner",
381 "claude -p hit max_turns limit — hooks may have consumed turns"
382 );
383 return Err(AppError::Validation(
384 "claude -p hit max_turns: hooks may be consuming turns; increase --max-turns or disable hooks".to_string(),
385 ));
386 }
387
388 if result_elem.is_error {
389 let err_msg = result_elem
390 .error
391 .as_deref()
392 .or(result_elem.result.as_deref())
393 .unwrap_or("unknown error");
394 if err_msg.contains("rate_limit") || err_msg.contains("overloaded") {
395 return Err(AppError::RateLimited {
396 detail: err_msg.to_string(),
397 });
398 }
399 if err_msg.contains("Not logged in") || err_msg.contains("authentication") {
400 tracing::warn!(
401 target: "claude_runner",
402 "Claude Code authentication failed. Re-authenticate interactively with: claude"
403 );
404 }
405 return Err(AppError::Validation(
406 crate::i18n::validation::claude_extraction_failed(err_msg),
407 ));
408 }
409
410 let value = if let Some(v) = result_elem.structured_output.clone() {
411 v
412 } else if let Some(text) = &result_elem.result {
413 serde_json::from_str(text).map_err(|e| {
414 AppError::Validation(crate::i18n::validation::failed_to_parse_claude_result_field(&e))
415 })?
416 } else {
417 return Err(AppError::Validation(
418 "claude result missing structured_output and result field".into(),
419 ));
420 };
421
422 let cost = result_elem.total_cost_usd.unwrap_or(0.0);
423 Ok(ClaudeResult {
424 value,
425 cost_usd: cost,
426 is_oauth,
427 })
428}
429
430pub fn run_claude(
436 binary: &Path,
437 prompt: &str,
438 json_schema: &str,
439 input_text: &str,
440 model: Option<&str>,
441 timeout_secs: u64,
442 max_turns: u32,
443) -> Result<ClaudeResult, AppError> {
444 use wait_timeout::ChildExt;
445
446 let full_prompt = format!("{prompt}\n\n{input_text}");
447 let mut cmd = build_claude_command(binary, &full_prompt, json_schema, model, max_turns)?;
448
449 let mut child = spawn_with_memory_limit(&mut cmd).map_err(|e| {
450 AppError::Io(std::io::Error::new(
451 e.kind(),
452 format!("failed to spawn claude: {e}"),
453 ))
454 })?;
455
456 let start = std::time::Instant::now();
457 let timeout = std::time::Duration::from_secs(timeout_secs);
458 let status = child.wait_timeout(timeout).map_err(AppError::Io)?;
459
460 if status.is_none() {
461 #[cfg(unix)]
467 unsafe {
468 libc::kill(child.id() as i32, libc::SIGTERM);
469 }
470 let _ = child.kill();
471 let _ = child.wait();
472 }
473
474 match status {
475 Some(exit_status) => {
476 tracing::debug!(
477 target: "process",
478 exit_code = ?exit_status.code(),
479 elapsed_ms = start.elapsed().as_millis() as u64,
480 "external process completed"
481 );
482
483 let mut stdout_buf = Vec::new();
484 let mut stderr_buf = Vec::new();
485 if let Some(mut out) = child.stdout.take() {
486 std::io::Read::read_to_end(&mut out, &mut stdout_buf).map_err(AppError::Io)?;
487 }
488 if let Some(mut err) = child.stderr.take() {
489 std::io::Read::read_to_end(&mut err, &mut stderr_buf).map_err(AppError::Io)?;
490 }
491
492 let stdout_str = String::from_utf8(stdout_buf).map_err(|_| {
493 AppError::Validation(crate::i18n::validation::claude_p_stdout_not_utf8())
494 })?;
495
496 if !exit_status.success() {
498 if let Ok(result) = parse_claude_output(&stdout_str) {
499 return Ok(result);
500 }
501 let stderr_str = String::from_utf8_lossy(&stderr_buf);
502 if stderr_str.contains("auth") || stderr_str.contains("login") {
503 tracing::warn!(
504 target: "claude_runner",
505 "Claude Code authentication may have failed. Re-authenticate with: claude"
506 );
507 }
508 return Err(AppError::Validation(
509 crate::i18n::validation::process_exited(
510 "claude -p",
511 exit_status.code(),
512 stderr_str.trim(),
513 ),
514 ));
515 }
516
517 parse_claude_output(&stdout_str)
518 }
519 None => {
520 tracing::warn!(target: "claude_runner", timeout_secs, "claude -p timed out, terminating");
521 terminate_gracefully(&mut child, 3);
522 Err(AppError::Validation(
523 crate::i18n::validation::process_timed_out("claude -p", timeout_secs),
524 ))
525 }
526 }
527}
528
529#[cfg(unix)]
531pub fn terminate_gracefully(child: &mut std::process::Child, grace_secs: u64) {
532 use wait_timeout::ChildExt;
533 unsafe {
534 libc::kill(child.id() as i32, libc::SIGTERM);
535 }
536 match child.wait_timeout(std::time::Duration::from_secs(grace_secs)) {
537 Ok(Some(_)) => {}
538 _ => {
539 tracing::warn!(target: "process", pid = child.id(), "child ignored SIGTERM, sending SIGKILL");
540 let _ = child.kill();
541 let _ = child.wait();
542 }
543 }
544}
545
546#[cfg(not(unix))]
548pub fn terminate_gracefully(child: &mut std::process::Child, _grace_secs: u64) {
549 let _ = child.kill();
550 let _ = child.wait();
551}
552
553#[cfg(test)]
554mod tests {
555 use super::*;
556
557 #[test]
558 fn parse_output_detects_max_turns() {
559 let stdout = r#"[{"type":"system","subtype":"init","apiKeySource":"none"},{"type":"result","is_error":false,"terminal_reason":"max_turns","structured_output":{"name":"t"}}]"#;
560 let err = parse_claude_output(stdout).unwrap_err();
561 assert!(
562 format!("{err}").contains("max_turns"),
563 "must detect max_turns in output"
564 );
565 }
566
567 #[test]
568 fn parse_output_extracts_structured_value() {
569 let stdout = r#"[{"type":"system","subtype":"init","apiKeySource":"none"},{"type":"result","is_error":false,"structured_output":{"key":"val"},"total_cost_usd":0.01}]"#;
570 let result = parse_claude_output(stdout).unwrap();
571 assert_eq!(result.value["key"], "val");
572 assert!((result.cost_usd - 0.01).abs() < f64::EPSILON);
573 assert!(result.is_oauth);
574 }
575
576 #[test]
577 fn parse_output_detects_rate_limit() {
578 let stdout = r#"[{"type":"result","is_error":true,"error":"rate_limit exceeded"}]"#;
579 let err = parse_claude_output(stdout).unwrap_err();
580 assert!(
581 matches!(err, AppError::RateLimited { .. }),
582 "expected AppError::RateLimited, got: {err}"
583 );
584 }
585
586 #[test]
590 #[serial_test::serial(env)]
591 fn build_command_oauth_only_mandatory_flags() {
592 unsafe {
594 std::env::remove_var("ANTHROPIC_API_KEY");
595 std::env::remove_var("CLAUDE_CONFIG_DIR");
598 }
599 let cmd = build_claude_command(
600 std::path::Path::new("/usr/bin/false"),
601 "test prompt",
602 "{}",
603 Some("sonnet"),
604 4,
605 )
606 .expect("preflight gate accepts valid args");
607 let args: Vec<&str> = cmd.get_args().filter_map(|a| a.to_str()).collect();
608 assert!(args.contains(&"-p"), "must have -p");
610 assert!(
611 args.contains(&"--strict-mcp-config"),
612 "must have --strict-mcp-config (gaps.md:206)"
613 );
614 assert!(
615 args.contains(&"--mcp-config"),
616 "must have --mcp-config (gaps.md:207)"
617 );
618 assert!(
619 args.contains(&"--dangerously-skip-permissions"),
620 "must have --dangerously-skip-permissions (gaps.md:208)"
621 );
622 assert!(
623 args.contains(&"--settings"),
624 "must have --settings (gaps.md:209)"
625 );
626 assert!(
627 args.contains(&"--output-format"),
628 "must have --output-format json (gaps.md:213)"
629 );
630 assert!(args.contains(&"--json-schema"), "must have --json-schema");
631 assert!(
632 args.contains(&"--max-turns"),
633 "must have --max-turns (gaps.md:212)"
634 );
635 assert!(
636 args.contains(&"--no-session-persistence"),
637 "must have --no-session-persistence"
638 );
639 assert!(
640 args.contains(&"--model"),
641 "must have --model when model is Some"
642 );
643 assert!(
645 !args.contains(&"--bare"),
646 "--bare is PROHIBITED (gaps.md:49)"
647 );
648 }
649
650 #[test]
654 #[serial_test::serial(env)]
655 fn build_command_aborts_when_anthropic_api_key_set() {
656 unsafe {
658 std::env::set_var("ANTHROPIC_API_KEY", "sk-test-violation");
659 std::env::remove_var("CLAUDE_CONFIG_DIR");
663 }
664 let cmd = build_claude_command(
665 std::path::Path::new("/usr/bin/claude"),
666 "test prompt",
667 "{}",
668 Some("sonnet"),
669 4,
670 )
671 .expect("preflight gate accepts valid args");
672 let program = cmd.get_program().to_string_lossy().to_string();
673 let args: Vec<&str> = cmd.get_args().filter_map(|a| a.to_str()).collect();
674 assert_eq!(
675 program, "false",
676 "when ANTHROPIC_API_KEY is set, build_claude_command must abort"
677 );
678 assert!(
679 args.contains(&"--oauth-only-violation-anthropic-api-key-set"),
680 "aborted command must carry violation marker"
681 );
682 unsafe {
683 std::env::remove_var("ANTHROPIC_API_KEY");
684 }
685 }
686}