1use std::ffi::OsString;
41use std::path::{Path, PathBuf};
42use thiserror::Error;
43
44const ARG_MAX_SAFETY_MARGIN_BYTES: usize = 4_096;
47
48const DEFAULT_ARG_MAX_BYTES: usize = 32_768;
53
54const DEFAULT_OUTPUT_BUFFER_LIMIT_BYTES: usize = 65_536;
58
59const WALKUP_MAX_DEPTH: usize = 16;
62
63pub fn is_skipped() -> bool {
66 matches!(
67 std::env::var("SQLITE_GRAPHRAG_SKIP_PREFLIGHT")
68 .ok()
69 .as_deref(),
70 Some("1") | Some("true") | Some("TRUE") | Some("yes")
71 )
72}
73
74#[derive(Debug)]
81pub struct PreFlightArgs<'a> {
82 pub binary_path: &'a Path,
84 pub argv: &'a [OsString],
86 pub workspace_root: &'a Path,
88 pub mcp_config_inline_json: Option<&'a str>,
92 pub expected_output_bytes: usize,
95 pub spawner_name: &'static str,
99}
100
101#[derive(Debug, Error)]
107pub enum PreFlightError {
108 #[error("binary not found: {path}")]
110 BinaryNotFound { path: PathBuf },
111
112 #[error("argv exceeds ARG_MAX: total_bytes={total_bytes}, arg_max={arg_max}, safety_margin_bytes={ARG_MAX_SAFETY_MARGIN_BYTES}")]
115 ArgvExceedsArgMax { total_bytes: usize, arg_max: usize },
116
117 #[error("--mcp-config expects filepath, got inline JSON '{0}'; Claude Code 2.1.177 rejects this form; substitute suggested tempfile")]
121 McpConfigInlineJsonRejected(String),
122
123 #[error("--mcp-config path missing: {path}")]
125 McpConfigPathMissing { path: PathBuf },
126
127 #[error("--mcp-config path invalid JSON at {path}: {error}")]
129 McpConfigPathInvalidJson { path: PathBuf, error: String },
130
131 #[error(".mcp.json walk-up found invalid file at {path}: {error}; set CLAUDE_CONFIG_DIR to an empty directory or move the workspace to a parent without .mcp.json")]
134 WalkUpMcpJsonInvalid { path: PathBuf, error: String },
135
136 #[error("output buffer too small: expected={expected} bytes, configured_limit={configured} bytes; chunk the request or increase the buffer cap")]
139 OutputBufferTooSmall { expected: usize, configured: usize },
140
141 #[error("CLAUDE_CONFIG_DIR={path} contains settings.json with active MCP servers ({reason}); unset the env var or remove the offending entries")]
150 ClaudeConfigDirNotEmpty { path: PathBuf, reason: &'static str },
151}
152
153pub fn preflight_check(args: &PreFlightArgs) -> Result<(), PreFlightError> {
160 if is_skipped() {
161 tracing::warn!(
162 target: "preflight",
163 event = "preflight_skipped",
164 spawner = args.spawner_name,
165 "SQLITE_GRAPHRAG_SKIP_PREFLIGHT=1 — pre-flight checks bypassed; the 5-bug-class risk is accepted"
166 );
167 return Ok(());
168 }
169
170 let argv_total = compute_argv_bytes(args.argv);
173
174 check_argv_size(argv_total)?;
175 check_binary_exists(args.binary_path)?;
176 check_output_buffer(args.expected_output_bytes)?;
177 check_mcp_config_inline(args.mcp_config_inline_json)?;
178 check_mcp_config_path(args.argv)?;
179 check_walkup_mcp_json(args.workspace_root)?;
180 check_claude_config_dir()?;
181
182 tracing::info!(
183 target: "preflight",
184 event = "preflight_passed",
185 spawner = args.spawner_name,
186 argv_bytes = argv_total,
187 workspace_root = %args.workspace_root.display(),
188 "pre-flight validation passed"
189 );
190 Ok(())
191}
192
193pub fn write_empty_mcp_config_tempfile() -> Result<PathBuf, std::io::Error> {
201 use std::io::Write;
202 let mut tmp = tempfile::Builder::new()
203 .prefix("graphrag-mcp-")
204 .suffix(".json")
205 .tempfile()?;
206 tmp.write_all(br#"{"mcpServers":{}}"#)?;
207 tmp.flush()?;
208 let (_, path) = tmp.keep()?;
212 Ok(path)
213}
214
215fn compute_argv_bytes(argv: &[OsString]) -> usize {
222 argv.iter().map(|s| s.as_os_str().len() + 1).sum()
223}
224
225fn arg_max_bytes() -> usize {
226 #[cfg(unix)]
227 {
228 let n = unsafe { libc::sysconf(libc::_SC_ARG_MAX) };
232 if n > 0 {
233 n as usize
234 } else {
235 DEFAULT_ARG_MAX_BYTES
236 }
237 }
238 #[cfg(not(unix))]
239 {
240 DEFAULT_ARG_MAX_BYTES
241 }
242}
243
244fn check_argv_size(argv_total: usize) -> Result<(), PreFlightError> {
245 let max = arg_max_bytes();
246 if argv_total + ARG_MAX_SAFETY_MARGIN_BYTES > max {
247 return Err(PreFlightError::ArgvExceedsArgMax {
248 total_bytes: argv_total,
249 arg_max: max,
250 });
251 }
252 Ok(())
253}
254
255fn check_binary_exists(binary_path: &Path) -> Result<(), PreFlightError> {
256 if binary_path.exists() {
257 Ok(())
258 } else {
259 Err(PreFlightError::BinaryNotFound {
260 path: binary_path.to_path_buf(),
261 })
262 }
263}
264
265fn check_output_buffer(expected: usize) -> Result<(), PreFlightError> {
266 if expected > DEFAULT_OUTPUT_BUFFER_LIMIT_BYTES {
267 Err(PreFlightError::OutputBufferTooSmall {
268 expected,
269 configured: DEFAULT_OUTPUT_BUFFER_LIMIT_BYTES,
270 })
271 } else {
272 Ok(())
273 }
274}
275
276fn check_mcp_config_inline(inline: Option<&str>) -> Result<(), PreFlightError> {
277 if let Some(s) = inline {
278 let trimmed = s.trim();
281 if trimmed.starts_with('{') && trimmed.ends_with('}') {
282 return Err(PreFlightError::McpConfigInlineJsonRejected(s.to_string()));
283 }
284 }
285 Ok(())
286}
287
288fn check_mcp_config_path(argv: &[OsString]) -> Result<(), PreFlightError> {
289 let mut iter = argv.iter();
290 while let Some(arg) = iter.next() {
291 let path = if arg == "--mcp-config" {
296 match iter.next() {
297 Some(value) => PathBuf::from(value),
298 None => continue,
299 }
300 } else if let Some(stripped) = arg.to_str().and_then(|s| s.strip_prefix("--mcp-config=")) {
301 PathBuf::from(stripped)
302 } else {
303 continue;
304 };
305 validate_mcp_config_path(&path)?;
306 }
307 Ok(())
308}
309
310fn validate_mcp_config_path(path: &Path) -> Result<(), PreFlightError> {
311 if !path.exists() {
312 return Err(PreFlightError::McpConfigPathMissing {
313 path: path.to_path_buf(),
314 });
315 }
316 let contents =
317 std::fs::read_to_string(path).map_err(|e| PreFlightError::McpConfigPathInvalidJson {
318 path: path.to_path_buf(),
319 error: e.to_string(),
320 })?;
321 if let Err(e) = serde_json::from_str::<serde_json::Value>(&contents) {
322 return Err(PreFlightError::McpConfigPathInvalidJson {
323 path: path.to_path_buf(),
324 error: e.to_string(),
325 });
326 }
327 Ok(())
328}
329
330fn check_walkup_mcp_json(workspace_root: &Path) -> Result<(), PreFlightError> {
331 let mut current = workspace_root.to_path_buf();
332 for _ in 0..WALKUP_MAX_DEPTH {
333 let candidate = current.join(".mcp.json");
334 if candidate.exists() {
335 let contents = std::fs::read_to_string(&candidate).map_err(|e| {
336 PreFlightError::WalkUpMcpJsonInvalid {
337 path: candidate.clone(),
338 error: e.to_string(),
339 }
340 })?;
341 let parsed: serde_json::Value = serde_json::from_str(&contents).map_err(|e| {
348 PreFlightError::WalkUpMcpJsonInvalid {
349 path: candidate.clone(),
350 error: e.to_string(),
351 }
352 })?;
353 let has_active_mcps = parsed
354 .get("mcpServers")
355 .and_then(|v| v.as_object())
356 .map(|o| !o.is_empty())
357 .unwrap_or(false);
358 if has_active_mcps {
359 return Err(PreFlightError::WalkUpMcpJsonInvalid {
360 path: candidate,
361 error: "mcpServers declares active entries; set CLAUDE_CONFIG_DIR to an empty directory or remove the file".to_string(),
362 });
363 }
364 return Ok(());
365 }
366 match current.parent() {
367 Some(p) => current = p.to_path_buf(),
368 None => break,
369 }
370 }
371 Ok(())
372}
373
374fn check_claude_config_dir() -> Result<(), PreFlightError> {
375 let Some(dir) = std::env::var_os("CLAUDE_CONFIG_DIR") else {
376 return Ok(());
377 };
378 let path = PathBuf::from(&dir);
379 if !path.is_dir() {
380 return Ok(());
381 }
382 let settings = path.join("settings.json");
390 if !settings.exists() {
391 if std::fs::read_dir(&path)
395 .map(|mut i| i.next().is_some())
396 .unwrap_or(false)
397 {
398 tracing::warn!(
399 target: "preflight",
400 path = %path.display(),
401 "CLAUDE_CONFIG_DIR is populated but contains no settings.json; \
402 MCP servers and hooks will not be auto-loaded"
403 );
404 }
405 return Ok(());
406 }
407 let contents = match std::fs::read_to_string(&settings) {
408 Ok(c) => c,
409 Err(e) => {
410 tracing::warn!(
411 target: "preflight",
412 path = %settings.display(),
413 error = %e,
414 "CLAUDE_CONFIG_DIR/settings.json exists but could not be read; \
415 skipping semantic validation"
416 );
417 return Ok(());
418 }
419 };
420 let parsed: serde_json::Value = match serde_json::from_str(&contents) {
421 Ok(v) => v,
422 Err(e) => {
423 tracing::warn!(
424 target: "preflight",
425 path = %settings.display(),
426 error = %e,
427 "CLAUDE_CONFIG_DIR/settings.json is not valid JSON; \
428 skipping semantic validation"
429 );
430 return Ok(());
431 }
432 };
433 let has_mcp_servers = parsed
437 .get("mcpServers")
438 .and_then(|v| v.as_object())
439 .map(|o| !o.is_empty())
440 .unwrap_or(false);
441 if has_mcp_servers {
442 return Err(PreFlightError::ClaudeConfigDirNotEmpty {
443 path,
444 reason: "mcpServers",
445 });
446 }
447 Ok(())
448}
449
450#[cfg(test)]
455mod tests {
456 use super::*;
457 use std::ffi::OsString;
458
459 fn dummy_argv() -> Vec<OsString> {
460 vec![
461 OsString::from("/usr/bin/claude"),
462 OsString::from("-p"),
463 OsString::from("hello"),
464 ]
465 }
466
467 fn dummy_args<'a>(
468 binary: &'a Path,
469 argv: &'a [OsString],
470 inline_json: Option<&'a str>,
471 ) -> PreFlightArgs<'a> {
472 use std::sync::OnceLock;
477 static WORKSPACE: OnceLock<tempfile::TempDir> = OnceLock::new();
478 let workspace = WORKSPACE.get_or_init(|| tempfile::tempdir().expect("tempdir"));
479 PreFlightArgs {
480 binary_path: binary,
481 argv,
482 workspace_root: workspace.path(),
483 mcp_config_inline_json: inline_json,
484 expected_output_bytes: 1024,
485 spawner_name: "test",
486 }
487 }
488
489 #[test]
490 #[serial_test::serial(env)]
491 fn check_binary_exists_passes_when_path_valid() {
492 let saved = std::env::var_os("CLAUDE_CONFIG_DIR");
494 unsafe {
495 std::env::remove_var("CLAUDE_CONFIG_DIR");
496 }
497 let binary = if cfg!(windows) {
498 "C:\\Windows\\System32\\cmd.exe"
499 } else {
500 "/bin/sh"
501 };
502 let argv = dummy_argv();
503 let args = dummy_args(Path::new(binary), &argv, None);
504 let result = preflight_check(&args);
505 if let Some(v) = saved {
506 unsafe {
507 std::env::set_var("CLAUDE_CONFIG_DIR", v);
508 }
509 }
510 assert!(result.is_ok(), "preflight returned: {result:?}");
511 }
512
513 #[test]
514 fn check_binary_exists_fails_when_missing() {
515 let argv = dummy_argv();
516 let args = dummy_args(Path::new("/does/not/exist/claude-binary"), &argv, None);
517 let err = preflight_check(&args).unwrap_err();
518 assert!(
519 matches!(err, PreFlightError::BinaryNotFound { .. }),
520 "expected BinaryNotFound, got {err:?}"
521 );
522 }
523
524 #[test]
525 #[serial_test::serial(env)]
526 fn check_argv_size_passes_under_limit() {
527 let saved = std::env::var_os("CLAUDE_CONFIG_DIR");
528 unsafe {
529 std::env::remove_var("CLAUDE_CONFIG_DIR");
530 }
531 let argv = dummy_argv();
532 let args = dummy_args(Path::new("/bin/sh"), &argv, None);
533 let result = preflight_check(&args);
534 if let Some(v) = saved {
535 unsafe {
536 std::env::set_var("CLAUDE_CONFIG_DIR", v);
537 }
538 }
539 assert!(result.is_ok(), "preflight returned: {result:?}");
541 }
542
543 #[test]
544 #[serial_test::serial(env)]
545 fn check_argv_size_fails_when_exceeds_arg_max() {
546 let saved = std::env::var_os("CLAUDE_CONFIG_DIR");
547 unsafe {
548 std::env::remove_var("CLAUDE_CONFIG_DIR");
549 }
550 let huge = "x".repeat(64 * 1024 * 1024);
554 let argv = vec![OsString::from("/bin/sh"), OsString::from(huge)];
555 let args = dummy_args(Path::new("/bin/sh"), &argv, None);
556 let err = preflight_check(&args).unwrap_err();
557 if let Some(v) = saved {
558 unsafe {
559 std::env::set_var("CLAUDE_CONFIG_DIR", v);
560 }
561 }
562 assert!(
563 matches!(err, PreFlightError::ArgvExceedsArgMax { .. }),
564 "expected ArgvExceedsArgMax, got {err:?}"
565 );
566 }
567
568 #[test]
569 fn check_mcp_inline_json_detects_literal_braces() {
570 let argv = dummy_argv();
572 let args = dummy_args(Path::new("/bin/sh"), &argv, Some("{}"));
573 let err = preflight_check(&args).unwrap_err();
574 assert!(
575 matches!(err, PreFlightError::McpConfigInlineJsonRejected(_)),
576 "expected McpConfigInlineJsonRejected, got {err:?}"
577 );
578 }
579
580 #[test]
581 fn check_mcp_inline_json_writes_valid_tempfile() {
582 let path = write_empty_mcp_config_tempfile().expect("tempfile write");
585 let contents = std::fs::read_to_string(&path).expect("tempfile read");
586 let parsed: serde_json::Value =
587 serde_json::from_str(&contents).expect("tempfile valid JSON");
588 assert!(parsed.get("mcpServers").is_some());
589 assert!(parsed["mcpServers"].as_object().unwrap().is_empty());
590 let _ = std::fs::remove_file(&path);
592 }
593
594 #[test]
595 fn check_mcp_path_missing_returns_error() {
596 let argv = vec![
598 OsString::from("/bin/sh"),
599 OsString::from("--mcp-config"),
600 OsString::from("/nonexistent/path/mcp.json"),
601 ];
602 let args = dummy_args(Path::new("/bin/sh"), &argv, None);
603 let err = preflight_check(&args).unwrap_err();
604 assert!(
605 matches!(err, PreFlightError::McpConfigPathMissing { .. }),
606 "expected McpConfigPathMissing, got {err:?}"
607 );
608 }
609
610 #[test]
611 fn check_mcp_path_invalid_json_returns_error() {
612 let tmp = tempfile::NamedTempFile::new().expect("tempfile");
614 std::fs::write(tmp.path(), b"this is not json").expect("write");
615 let argv = vec![
616 OsString::from("/bin/sh"),
617 OsString::from("--mcp-config"),
618 OsString::from(tmp.path().to_string_lossy().into_owned()),
619 ];
620 let args = dummy_args(Path::new("/bin/sh"), &argv, None);
621 let err = preflight_check(&args).unwrap_err();
622 assert!(
623 matches!(err, PreFlightError::McpConfigPathInvalidJson { .. }),
624 "expected McpConfigPathInvalidJson, got {err:?}"
625 );
626 }
627
628 #[test]
629 fn check_walkup_mcp_json_passes_when_clean() {
630 let dir = tempfile::tempdir().expect("tempdir");
632 let argv = dummy_argv();
633 let args = PreFlightArgs {
634 workspace_root: dir.path(),
635 ..dummy_args(Path::new("/bin/sh"), &argv, None)
636 };
637 let result = preflight_check(&args);
638 if let Err(PreFlightError::WalkUpMcpJsonInvalid { .. }) = &result {
641 panic!("walk-up incorrectly flagged on clean workspace");
642 }
643 }
644
645 #[test]
646 fn check_walkup_mcp_json_fails_on_zod_invalid() {
647 let dir = tempfile::tempdir().expect("tempdir");
649 let bad = dir.path().join(".mcp.json");
650 std::fs::write(&bad, b"{not json").expect("write bad mcp.json");
651 let argv = dummy_argv();
652 let args = PreFlightArgs {
653 workspace_root: dir.path(),
654 ..dummy_args(Path::new("/bin/sh"), &argv, None)
655 };
656 let err = preflight_check(&args).unwrap_err();
657 assert!(
658 matches!(err, PreFlightError::WalkUpMcpJsonInvalid { .. }),
659 "expected WalkUpMcpJsonInvalid, got {err:?}"
660 );
661 }
662
663 #[test]
664 fn check_walkup_mcp_json_fails_on_active_mcp_servers() {
665 let dir = tempfile::tempdir().expect("tempdir");
668 let bad = dir.path().join(".mcp.json");
669 std::fs::write(
670 &bad,
671 r#"{"mcpServers":{"github":{"command":"gh","args":["mcp"]}}}"#,
672 )
673 .expect("write bad mcp.json");
674 let argv = dummy_argv();
675 let args = PreFlightArgs {
676 workspace_root: dir.path(),
677 ..dummy_args(Path::new("/bin/sh"), &argv, None)
678 };
679 let err = preflight_check(&args).unwrap_err();
680 assert!(
681 matches!(err, PreFlightError::WalkUpMcpJsonInvalid { .. }),
682 "expected WalkUpMcpJsonInvalid, got {err:?}"
683 );
684 }
685
686 #[test]
687 fn check_walkup_mcp_json_passes_with_empty_mcp_servers() {
688 let dir = tempfile::tempdir().expect("tempdir");
689 let ok = dir.path().join(".mcp.json");
690 std::fs::write(&ok, r#"{"mcpServers":{}}"#).expect("write");
691 let argv = dummy_argv();
692 let args = PreFlightArgs {
693 workspace_root: dir.path(),
694 ..dummy_args(Path::new("/bin/sh"), &argv, None)
695 };
696 let result = preflight_check(&args);
697 if let Err(PreFlightError::WalkUpMcpJsonInvalid { .. }) = &result {
698 panic!("empty mcpServers must pass walk-up: {result:?}");
699 }
700 }
701
702 #[test]
703 fn check_mcp_path_equals_form_detects_missing_file() {
704 let argv = vec![
707 OsString::from("/bin/sh"),
708 OsString::from("--mcp-config=/nonexistent/path/mcp.json"),
709 ];
710 let args = dummy_args(Path::new("/bin/sh"), &argv, None);
711 let err = preflight_check(&args).unwrap_err();
712 assert!(
713 matches!(err, PreFlightError::McpConfigPathMissing { .. }),
714 "expected McpConfigPathMissing, got {err:?}"
715 );
716 }
717
718 #[test]
719 fn check_output_buffer_warns_when_oversized() {
720 let argv = dummy_argv();
721 let args = PreFlightArgs {
722 expected_output_bytes: 100_000, ..dummy_args(Path::new("/bin/sh"), &argv, None)
724 };
725 let err = preflight_check(&args).unwrap_err();
726 assert!(
727 matches!(err, PreFlightError::OutputBufferTooSmall { .. }),
728 "expected OutputBufferTooSmall, got {err:?}"
729 );
730 }
731
732 #[test]
733 #[serial_test::serial(env)]
734 fn check_claude_config_dir_fails_when_settings_has_active_mcps() {
735 let dir = tempfile::tempdir().expect("tempdir");
737 let settings = dir.path().join("settings.json");
738 std::fs::write(
739 &settings,
740 r#"{"mcpServers":{"github":{"command":"gh","args":["mcp"]}}}"#,
741 )
742 .expect("write settings.json");
743 unsafe {
744 std::env::set_var("CLAUDE_CONFIG_DIR", dir.path());
745 }
746 let argv = dummy_argv();
747 let args = dummy_args(Path::new("/bin/sh"), &argv, None);
748 let err = preflight_check(&args);
749 unsafe {
750 std::env::remove_var("CLAUDE_CONFIG_DIR");
751 }
752 if let Err(PreFlightError::ClaudeConfigDirNotEmpty { reason, .. }) = err {
753 assert_eq!(reason, "mcpServers");
754 } else {
755 panic!("expected ClaudeConfigDirNotEmpty mcpServers, got {err:?}");
756 }
757 }
758
759 #[test]
760 #[serial_test::serial(env)]
761 fn check_claude_config_dir_passes_when_settings_empty() {
762 let dir = tempfile::tempdir().expect("tempdir");
764 let settings = dir.path().join("settings.json");
765 std::fs::write(&settings, r#"{"mcpServers":{},"hooks":{}}"#).expect("write");
766 unsafe {
767 std::env::set_var("CLAUDE_CONFIG_DIR", dir.path());
768 }
769 let argv = dummy_argv();
770 let args = dummy_args(Path::new("/bin/sh"), &argv, None);
771 let result = preflight_check(&args);
772 unsafe {
773 std::env::remove_var("CLAUDE_CONFIG_DIR");
774 }
775 assert!(result.is_ok(), "empty MCPs and hooks must pass: {result:?}");
776 }
777
778 #[test]
779 #[serial_test::serial(env)]
780 fn check_claude_config_dir_passes_when_no_settings_json() {
781 let dir = tempfile::tempdir().expect("tempdir");
783 std::fs::write(dir.path().join("CLAUDE.md"), "# project notes").expect("write");
785 unsafe {
786 std::env::set_var("CLAUDE_CONFIG_DIR", dir.path());
787 }
788 let argv = dummy_argv();
789 let args = dummy_args(Path::new("/bin/sh"), &argv, None);
790 let result = preflight_check(&args);
791 unsafe {
792 std::env::remove_var("CLAUDE_CONFIG_DIR");
793 }
794 assert!(
795 result.is_ok(),
796 "populated dir without settings.json must pass: {result:?}"
797 );
798 }
799
800 #[test]
801 #[serial_test::serial(env)]
802 fn check_claude_config_dir_passes_when_settings_has_only_hooks() {
803 let dir = tempfile::tempdir().expect("tempdir");
807 let settings = dir.path().join("settings.json");
808 std::fs::write(&settings, r#"{"hooks":{"PreToolUse":[]}}"#).expect("write");
809 unsafe {
810 std::env::set_var("CLAUDE_CONFIG_DIR", dir.path());
811 }
812 let argv = dummy_argv();
813 let args = dummy_args(Path::new("/bin/sh"), &argv, None);
814 let result = preflight_check(&args);
815 unsafe {
816 std::env::remove_var("CLAUDE_CONFIG_DIR");
817 }
818 assert!(result.is_ok(), "hooks must be tolerated: {result:?}");
819 }
820
821 #[test]
822 fn preflight_check_runs_all_guards_in_order() {
823 let dir = tempfile::tempdir().expect("tempdir");
825 let argv = dummy_argv();
826 let args = PreFlightArgs {
827 workspace_root: dir.path(),
828 ..dummy_args(Path::new("/bin/sh"), &argv, None)
829 };
830 assert!(preflight_check(&args).is_ok());
831 }
832
833 #[test]
834 fn preflight_check_short_circuits_on_first_failure() {
835 let argv = dummy_argv();
839 let args = dummy_args(Path::new("/does/not/exist/at/all"), &argv, Some("{}"));
840 let err = preflight_check(&args).unwrap_err();
841 assert!(
842 matches!(err, PreFlightError::BinaryNotFound { .. }),
843 "expected BinaryNotFound (short-circuit), got {err:?}"
844 );
845 }
846
847 #[test]
848 #[serial_test::serial(env)]
849 fn app_error_preflight_failed_has_exit_code_16() {
850 use crate::errors::AppError;
853 let err: AppError = crate::spawn::preflight::PreFlightError::BinaryNotFound {
854 path: "/bin/test".into(),
855 }
856 .into();
857 assert_eq!(err.exit_code(), 16);
858 assert!(err.is_permanent());
859 assert!(!err.is_retryable());
860 }
861}