Skip to main content

sqlite_graphrag/spawn/
preflight.rs

1//! Pre-flight validation layer for LLM subprocess spawners (v1.0.87, ADR-0045).
2//!
3//! GAP-META-005: closes the architectural gap between `build_argv` and
4//! `cmd.spawn()` in the four real subprocess spawn sites
5//! (`claude_runner.rs:255`, `codex_spawn.rs:273`, `ingest_claude.rs:297`,
6//! `extract/llm_embedding.rs:671`). Before this module, the 4-stage pipeline
7//! was:
8//!
9//! ```text
10//! 1. build_argv(mode, prompt, body)  -> Vec<OsString>
11//! 2. apply_env_whitelist(cmd)         -> void (helper v1.0.83, ADR-0041)
12//! 3. Command::spawn()                 -> io::Result<Child>
13//! 4. child.wait_with_output()         -> io::Result<Output>
14//! ```
15//!
16//! Stage 3 discovered failures AFTER the kernel fork and AFTER Claude Code
17//! started executing, wasting tokens, locking job-singleton, and producing
18//! opaque diagnostics. This module inserts a gate between stages 2 and 3
19//! that catches the 5 bug-symptom classes documented in `gaps.md` BEFORE
20//! the fork:
21//!
22//! - Bug 1 — `ingest --extraction-backend llm` extracts 0 entities silently
23//! - Bug 2 — `--mcp-config '{}'` rejected by Claude Code 2.1.177
24//! - Bug 3 — argv > ARG_MAX post-fork E2BIG
25//! - Bug 4 — output parser truncates at 65536 chars
26//! - Bug 5 — `.mcp.json` walk-up fails Zod validation
27//!
28//! Pattern: sibling of `env_whitelist.rs` (v1.0.83, ADR-0041). Same
29//! design philosophy (helper consumed by all 4 spawn sites, no
30//! caller-local reimplementation, opt-out via env var for emergencies).
31//!
32//! ## Enforced invariant
33//!
34//! `sqlite-graphrag` runs Claude Code and the Codex CLI **mandatorily
35//! headless without MCP**. Pre-flight rejects argv that carries explicit
36//! MCP servers before the fork, closing the path where
37//! `~/.claude/settings.json` or an inherited `.mcp.json` walk-up could
38//! reintroduce plugins against the policy.
39
40use std::ffi::OsString;
41use std::path::{Path, PathBuf};
42use thiserror::Error;
43
44/// Safety margin subtracted from `ARG_MAX` to leave room for env vars
45/// and the binary path itself (those flow through a different syscall).
46const ARG_MAX_SAFETY_MARGIN_BYTES: usize = 4_096;
47
48/// Default fallback when `libc::sysconf(_SC_ARG_MAX)` returns -1 (rare
49/// but documented on hardened kernels). Matches the Windows `CreateProcess`
50/// cap of 32767 chars per command line. Visible on both unix and non-unix
51/// so `arg_max_bytes()` can reference it from either branch.
52const DEFAULT_ARG_MAX_BYTES: usize = 32_768;
53
54/// Default max output bytes that downstream JSON parsers tolerate
55/// without truncation. Matches the previous 64 KiB parser cap that
56/// `serde_json::from_str` silently truncated in v1.0.86.
57const DEFAULT_OUTPUT_BUFFER_LIMIT_BYTES: usize = 65_536;
58
59/// Walk-up depth cap for `.mcp.json` traversal. Prevents pathological
60/// `..` climbs on hosts with deeply nested CWDs.
61const WALKUP_MAX_DEPTH: usize = 16;
62
63/// Skip pre-flight checks entirely. Emergency escape hatch — strongly
64/// discouraged. Operators accept the 5-bug-class risk by setting this.
65pub 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/// Arguments for the pre-flight validation gate.
78///
79/// Each caller populates exactly what the gate needs to validate without
80/// relying on global env vars. The gate never mutates the argv in place —
81/// it only reads and reports. Callers act on `PreFlightError` to substitute
82/// alternatives (e.g. swap inline `--mcp-config '{}'` for a tempfile path).
83#[derive(Debug)]
84pub struct PreFlightArgs<'a> {
85    /// Resolved path to the binary that will be spawned.
86    pub binary_path: &'a Path,
87    /// argv after `build_argv` finished. Includes binary path as argv\[0\].
88    pub argv: &'a [OsString],
89    /// CWD-style anchor for walk-up detection of `.mcp.json`.
90    pub workspace_root: &'a Path,
91    /// If the spawner constructs `--mcp-config '{...}'` literally, the
92    /// gate returns `McpConfigInlineJsonRejected` with a suggested
93    /// tempfile path the caller can substitute.
94    pub mcp_config_inline_json: Option<&'a str>,
95    /// Caller's estimate of the maximum output payload size in bytes.
96    /// Triggers `OutputBufferTooSmall` when above the documented parser cap.
97    pub expected_output_bytes: usize,
98    /// Stable label emitted in telemetry. One of `"claude_runner"`,
99    /// `"codex_spawn"`, `"ingest_claude"`, `"ingest_codex"`,
100    /// `"llm_embedding"`.
101    pub spawner_name: &'static str,
102}
103
104/// Structured errors from the pre-flight gate. Each variant carries the
105/// data needed for an operator to diagnose without re-running.
106///
107/// `thiserror` produces the `Display` impl that `AppError::PreFlightFailed`
108/// captures into the `detail` field for i18n.
109#[derive(Debug, Error)]
110pub enum PreFlightError {
111    /// Binary at `path` does not exist on the filesystem.
112    #[error("binary not found: {path}")]
113    BinaryNotFound { path: PathBuf },
114
115    /// Total bytes of argv (binary + args + separators) exceed
116    /// `ARG_MAX - 4096`. Spawn would fail with `E2BIG` post-fork.
117    #[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    /// `--mcp-config '{...}'` was passed literally as the inline JSON.
121    /// Claude Code 2.1.177+ expects a filepath. Caller should use the
122    /// `suggested_tempfile` (already written with empty `mcpServers` map).
123    #[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    /// `--mcp-config <PATH>` was passed but the path does not exist.
127    #[error("--mcp-config path missing: {path}")]
128    McpConfigPathMissing { path: PathBuf },
129
130    /// `--mcp-config <PATH>` was passed but the file is not valid JSON.
131    #[error("--mcp-config path invalid JSON at {path}: {error}")]
132    McpConfigPathInvalidJson { path: PathBuf, error: String },
133
134    /// `.mcp.json` walk-up found an invalid file at `path`. Override
135    /// `CLAUDE_CONFIG_DIR` to an empty directory to suppress walk-up.
136    #[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    /// Caller's expected output exceeds the documented JSON parser cap.
140    /// The downstream parser truncates silently above this size.
141    #[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    /// `CLAUDE_CONFIG_DIR` is set and `settings.json` declares active
145    /// `mcpServers`. Claude Code would load them and defeat
146    /// `--strict-mcp-config --mcp-config <empty>`. Hooks are NOT
147    /// flagged here because the spawners pass
148    /// `--settings '{"hooks":{}}'` which overrides the user-level
149    /// hooks at the CLI invocation boundary; MCP servers are NOT
150    /// overridden by any flag we pass, so they are the only class of
151    /// `settings.json` entry that can leak into the subprocess.
152    #[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
156/// Returns `Ok(())` when all checks pass, or the first failing variant.
157///
158/// Short-circuits on first failure to give operators a single actionable
159/// diagnostic. When `SQLITE_GRAPHRAG_SKIP_PREFLIGHT=1` is set, returns
160/// `Ok(())` unconditionally after logging a warning (emergency escape
161/// hatch).
162pub 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    // Order matters: cheap in-memory checks first, I/O-bound checks last
174    // so a binary-missing operator sees the actionable error first.
175    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
196/// Writes an empty MCP config tempfile with `{"mcpServers":{}}` and
197/// returns the path. Callers should `cmd.arg(path.as_os_str())` to
198/// substitute for the inline `'{}'` literal rejected by Claude Code 2.1.177.
199///
200/// Tempfile lives in the OS temp dir with a `graphrag-mcp-` prefix.
201/// Caller is responsible for keeping the path alive until the spawned
202/// process terminates; `tempfile::NamedTempFile` cleans up on Drop.
203pub 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    // Persist (do not auto-delete) so the spawned claude can read it
212    // after this function returns. The caller spawns and waits, then
213    // the tempfile is dropped and cleaned.
214    let (_, path) = tmp.keep()?;
215    Ok(path)
216}
217
218// ---------------------------------------------------------------------------
219// Individual guards
220// ---------------------------------------------------------------------------
221
222/// Sums byte sizes of each argv element plus 1 byte for the NUL separator
223/// in the kernel's `execve` argument buffer layout.
224fn 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        // SAFETY: `sysconf(_SC_ARG_MAX)` is async-signal-safe per POSIX.1-2008
232        // §2.4.3. It returns -1 on error (which we treat as "use the safe
233        // fallback"); a positive value is the kernel's ARG_MAX in bytes.
234        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        // Any literal JSON starting with `{` and `}` is treated as
282        // inline. Caller must convert to filepath.
283        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        // BUG-5 fix (v1.0.88): accept the `--mcp-config=PATH` form
295        // (single argv slot) alongside the GNU `--mcp-config <PATH>`
296        // form. Without this, callers using clap's `--flag value`
297        // collapsing (or hand-rolled commands) bypass the guard.
298        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            // BUG-9 fix (v1.0.88): syntactic JSON validity is necessary
345            // but NOT sufficient — a valid `.mcp.json` can still declare
346            // MCP servers under `mcpServers`. Reject when the file is
347            // syntactically valid AND declares a non-empty `mcpServers`
348            // object. Keep the existing syntactic check for legacy
349            // callers that hand-roll untyped JSON.
350            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    // BUG-1 fix (v1.0.88): inspect `settings.json` semantically. A
386    // populated directory containing `CLAUDE.md`, custom `commands/`,
387    // or skills is harmless — Claude Code will not auto-load MCP
388    // servers or hooks unless `settings.json` declares them. The
389    // previous implementation rejected any non-empty directory, which
390    // broke every dev install that points `CLAUDE_CONFIG_DIR` at the
391    // real Claude Code configuration home.
392    let settings = path.join("settings.json");
393    if !settings.exists() {
394        // Directory populated with non-MCP files (CLAUDE.md,
395        // commands/, skills/, etc.) — emit a structured warning so
396        // operators can audit, but do NOT abort the spawn.
397        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    // Reject when settings.json declares active MCP servers. Hooks are
437    // tolerated because the spawners pass `--settings '{"hooks":{}}'`
438    // which overrides the user-level hooks at the CLI boundary.
439    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// ---------------------------------------------------------------------------
454// Tests (GAP-META-005 test plan, 15 cases)
455// ---------------------------------------------------------------------------
456
457#[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 a dedicated empty tempdir for workspace_root so walk-up of
476        // `.mcp.json` does not pick up unrelated files in the test's CWD.
477        // The tempdir is leaked (kept alive for the test lifetime) via
478        // `OnceLock` to keep the API simple.
479        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        // SAFETY: serial_test::serial(env) ensures no parallel mutation.
496        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        // dummy_argv() is tiny — well under ARG_MAX.
543        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        // Synthesize an argv that exceeds ARG_MAX regardless of the
554        // host value. We allocate 64 MiB to leave the 4 KiB safety
555        // margin well below `getconf ARG_MAX` on every supported OS.
556        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        // argv references /bin/sh (exists) so the binary check passes.
574        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        // Round-trip: write_empty_mcp_config_tempfile produces a file
586        // parseable as JSON containing `mcpServers: {}`.
587        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        // Cleanup
594        let _ = std::fs::remove_file(&path);
595    }
596
597    #[test]
598    fn check_mcp_path_missing_returns_error() {
599        // Build an argv with --mcp-config pointing at a nonexistent path.
600        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        // Write an invalid JSON tempfile then reference it.
616        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        // Use a dedicated tempdir created for the test (guaranteed empty).
634        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        // We only assert we did NOT return WalkUpMcpJsonInvalid for a
642        // clean workspace.
643        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        // Create a temp workspace dir with an invalid .mcp.json inside.
651        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        // BUG-9 regression: a syntactically valid `.mcp.json` that
669        // declares MCP servers under `mcpServers` must be rejected.
670        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        // BUG-5 regression: --mcp-config=PATH single-slot form must be
708        // caught the same as the GNU --mcp-config <PATH> form.
709        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, // > 65536 cap
726            ..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        // SAFETY: serial_test::serial(env) ensures no parallel mutation.
739        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        // SAFETY: serial_test::serial(env) ensures no parallel mutation.
766        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        // SAFETY: serial_test::serial(env) ensures no parallel mutation.
785        let dir = tempfile::tempdir().expect("tempdir");
786        // Populate with non-MCP files only (CLAUDE.md, commands/, etc).
787        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        // Hooks are tolerated because the spawners override
807        // `--settings '{"hooks":{}}'` at the CLI boundary; only MCP
808        // servers are flagged as a hard error.
809        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        // Valid path + clean argv + clean workspace + no inline JSON.
827        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        // Invalid binary + bad inline JSON — should report BinaryNotFound
839        // first (cheap in-memory check) NOT the McpConfigInlineJsonRejected
840        // (also cheap, but binary is checked earlier in the order).
841        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        // Cross-check the integration: AppError::PreFlightFailed maps to
854        // exit code 16 (validated by this test, not by preflight itself).
855        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}