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