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//! ## Invariante imposta
33//!
34//! `sqlite-graphrag` executa Claude Code e Codex CLI **obrigatoriamente
35//! headless sem MCP**. Pre-flight rejeita argv que carrega MCP servers
36//! explícitos antes do fork, fechando o caminho onde
37//! `~/.claude/settings.json` ou walk-up de `.mcp.json` herdado poderia
38//! reintroduzir plugins contra a 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    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/// Arguments for the pre-flight validation gate.
75///
76/// Each caller populates exactly what the gate needs to validate without
77/// relying on global env vars. The gate never mutates the argv in place —
78/// it only reads and reports. Callers act on `PreFlightError` to substitute
79/// alternatives (e.g. swap inline `--mcp-config '{}'` for a tempfile path).
80#[derive(Debug)]
81pub struct PreFlightArgs<'a> {
82    /// Resolved path to the binary that will be spawned.
83    pub binary_path: &'a Path,
84    /// argv after `build_argv` finished. Includes binary path as argv\[0\].
85    pub argv: &'a [OsString],
86    /// CWD-style anchor for walk-up detection of `.mcp.json`.
87    pub workspace_root: &'a Path,
88    /// If the spawner constructs `--mcp-config '{...}'` literally, the
89    /// gate returns `McpConfigInlineJsonRejected` with a suggested
90    /// tempfile path the caller can substitute.
91    pub mcp_config_inline_json: Option<&'a str>,
92    /// Caller's estimate of the maximum output payload size in bytes.
93    /// Triggers `OutputBufferTooSmall` when above the documented parser cap.
94    pub expected_output_bytes: usize,
95    /// Stable label emitted in telemetry. One of `"claude_runner"`,
96    /// `"codex_spawn"`, `"ingest_claude"`, `"ingest_codex"`,
97    /// `"llm_embedding"`.
98    pub spawner_name: &'static str,
99}
100
101/// Structured errors from the pre-flight gate. Each variant carries the
102/// data needed for an operator to diagnose without re-running.
103///
104/// `thiserror` produces the `Display` impl that `AppError::PreFlightFailed`
105/// captures into the `detail` field for i18n.
106#[derive(Debug, Error)]
107pub enum PreFlightError {
108    /// Binary at `path` does not exist on the filesystem.
109    #[error("binary not found: {path}")]
110    BinaryNotFound { path: PathBuf },
111
112    /// Total bytes of argv (binary + args + separators) exceed
113    /// `ARG_MAX - 4096`. Spawn would fail with `E2BIG` post-fork.
114    #[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    /// `--mcp-config '{...}'` was passed literally as the inline JSON.
118    /// Claude Code 2.1.177+ expects a filepath. Caller should use the
119    /// `suggested_tempfile` (already written with empty `mcpServers` map).
120    #[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    /// `--mcp-config <PATH>` was passed but the path does not exist.
124    #[error("--mcp-config path missing: {path}")]
125    McpConfigPathMissing { path: PathBuf },
126
127    /// `--mcp-config <PATH>` was passed but the file is not valid JSON.
128    #[error("--mcp-config path invalid JSON at {path}: {error}")]
129    McpConfigPathInvalidJson { path: PathBuf, error: String },
130
131    /// `.mcp.json` walk-up found an invalid file at `path`. Override
132    /// `CLAUDE_CONFIG_DIR` to an empty directory to suppress walk-up.
133    #[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    /// Caller's expected output exceeds the documented JSON parser cap.
137    /// The downstream parser truncates silently above this size.
138    #[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    /// `CLAUDE_CONFIG_DIR` is set and `settings.json` declares active
142    /// `mcpServers`. Claude Code would load them and defeat
143    /// `--strict-mcp-config --mcp-config <empty>`. Hooks are NOT
144    /// flagged here because the spawners pass
145    /// `--settings '{"hooks":{}}'` which overrides the user-level
146    /// hooks at the CLI invocation boundary; MCP servers are NOT
147    /// overridden by any flag we pass, so they are the only class of
148    /// `settings.json` entry that can leak into the subprocess.
149    #[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
153/// Returns `Ok(())` when all checks pass, or the first failing variant.
154///
155/// Short-circuits on first failure to give operators a single actionable
156/// diagnostic. When `SQLITE_GRAPHRAG_SKIP_PREFLIGHT=1` is set, returns
157/// `Ok(())` unconditionally after logging a warning (emergency escape
158/// hatch).
159pub 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    // Order matters: cheap in-memory checks first, I/O-bound checks last
171    // so a binary-missing operator sees the actionable error first.
172    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
193/// Writes an empty MCP config tempfile with `{"mcpServers":{}}` and
194/// returns the path. Callers should `cmd.arg(path.as_os_str())` to
195/// substitute for the inline `'{}'` literal rejected by Claude Code 2.1.177.
196///
197/// Tempfile lives in the OS temp dir with a `graphrag-mcp-` prefix.
198/// Caller is responsible for keeping the path alive until the spawned
199/// process terminates; `tempfile::NamedTempFile` cleans up on Drop.
200pub 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    // Persist (do not auto-delete) so the spawned claude can read it
209    // after this function returns. The caller spawns and waits, then
210    // the tempfile is dropped and cleaned.
211    let (_, path) = tmp.keep()?;
212    Ok(path)
213}
214
215// ---------------------------------------------------------------------------
216// Individual guards
217// ---------------------------------------------------------------------------
218
219/// Sums byte sizes of each argv element plus 1 byte for the NUL separator
220/// in the kernel's `execve` argument buffer layout.
221fn 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        // SAFETY: `sysconf(_SC_ARG_MAX)` is async-signal-safe per POSIX.1-2008
229        // §2.4.3. It returns -1 on error (which we treat as "use the safe
230        // fallback"); a positive value is the kernel's ARG_MAX in bytes.
231        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        // Any literal JSON starting with `{` and `}` is treated as
279        // inline. Caller must convert to filepath.
280        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        // BUG-5 fix (v1.0.88): accept the `--mcp-config=PATH` form
292        // (single argv slot) alongside the GNU `--mcp-config <PATH>`
293        // form. Without this, callers using clap's `--flag value`
294        // collapsing (or hand-rolled commands) bypass the guard.
295        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            // BUG-9 fix (v1.0.88): syntactic JSON validity is necessary
342            // but NOT sufficient — a valid `.mcp.json` can still declare
343            // MCP servers under `mcpServers`. Reject when the file is
344            // syntactically valid AND declares a non-empty `mcpServers`
345            // object. Keep the existing syntactic check for legacy
346            // callers that hand-roll untyped JSON.
347            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    // BUG-1 fix (v1.0.88): inspect `settings.json` semantically. A
383    // populated directory containing `CLAUDE.md`, custom `commands/`,
384    // or skills is harmless — Claude Code will not auto-load MCP
385    // servers or hooks unless `settings.json` declares them. The
386    // previous implementation rejected any non-empty directory, which
387    // broke every dev install that points `CLAUDE_CONFIG_DIR` at the
388    // real Claude Code configuration home.
389    let settings = path.join("settings.json");
390    if !settings.exists() {
391        // Directory populated with non-MCP files (CLAUDE.md,
392        // commands/, skills/, etc.) — emit a structured warning so
393        // operators can audit, but do NOT abort the spawn.
394        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    // Reject when settings.json declares active MCP servers. Hooks are
434    // tolerated because the spawners pass `--settings '{"hooks":{}}'`
435    // which overrides the user-level hooks at the CLI boundary.
436    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// ---------------------------------------------------------------------------
451// Tests (GAP-META-005 test plan, 15 cases)
452// ---------------------------------------------------------------------------
453
454#[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 a dedicated empty tempdir for workspace_root so walk-up of
473        // `.mcp.json` does not pick up unrelated files in the test's CWD.
474        // The tempdir is leaked (kept alive for the test lifetime) via
475        // `OnceLock` to keep the API simple.
476        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        // SAFETY: serial_test::serial(env) ensures no parallel mutation.
493        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        // dummy_argv() is tiny — well under ARG_MAX.
540        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        // Synthesize an argv that exceeds ARG_MAX regardless of the
551        // host value. We allocate 64 MiB to leave the 4 KiB safety
552        // margin well below `getconf ARG_MAX` on every supported OS.
553        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        // argv references /bin/sh (exists) so the binary check passes.
571        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        // Round-trip: write_empty_mcp_config_tempfile produces a file
583        // parseable as JSON containing `mcpServers: {}`.
584        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        // Cleanup
591        let _ = std::fs::remove_file(&path);
592    }
593
594    #[test]
595    fn check_mcp_path_missing_returns_error() {
596        // Build an argv with --mcp-config pointing at a nonexistent path.
597        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        // Write an invalid JSON tempfile then reference it.
613        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        // Use a dedicated tempdir created for the test (guaranteed empty).
631        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        // We only assert we did NOT return WalkUpMcpJsonInvalid for a
639        // clean workspace.
640        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        // Create a temp workspace dir with an invalid .mcp.json inside.
648        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        // BUG-9 regression: a syntactically valid `.mcp.json` that
666        // declares MCP servers under `mcpServers` must be rejected.
667        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        // BUG-5 regression: --mcp-config=PATH single-slot form must be
705        // caught the same as the GNU --mcp-config <PATH> form.
706        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, // > 65536 cap
723            ..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        // SAFETY: serial_test::serial(env) ensures no parallel mutation.
736        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        // SAFETY: serial_test::serial(env) ensures no parallel mutation.
763        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        // SAFETY: serial_test::serial(env) ensures no parallel mutation.
782        let dir = tempfile::tempdir().expect("tempdir");
783        // Populate with non-MCP files only (CLAUDE.md, commands/, etc).
784        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        // Hooks are tolerated because the spawners override
804        // `--settings '{"hooks":{}}'` at the CLI boundary; only MCP
805        // servers are flagged as a hard error.
806        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        // Valid path + clean argv + clean workspace + no inline JSON.
824        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        // Invalid binary + bad inline JSON — should report BinaryNotFound
836        // first (cheap in-memory check) NOT the McpConfigInlineJsonRejected
837        // (also cheap, but binary is checked earlier in the order).
838        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        // Cross-check the integration: AppError::PreFlightFailed maps to
851        // exit code 16 (validated by this test, not by preflight itself).
852        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}