use std::ffi::OsString;
use std::path::{Path, PathBuf};
use thiserror::Error;
const ARG_MAX_SAFETY_MARGIN_BYTES: usize = 4_096;
const DEFAULT_ARG_MAX_BYTES: usize = 32_768;
const DEFAULT_OUTPUT_BUFFER_LIMIT_BYTES: usize = 65_536;
const WALKUP_MAX_DEPTH: usize = 16;
pub fn is_skipped() -> bool {
matches!(
std::env::var("SQLITE_GRAPHRAG_SKIP_PREFLIGHT")
.ok()
.as_deref(),
Some("1") | Some("true") | Some("TRUE") | Some("yes")
)
}
#[derive(Debug)]
pub struct PreFlightArgs<'a> {
pub binary_path: &'a Path,
pub argv: &'a [OsString],
pub workspace_root: &'a Path,
pub mcp_config_inline_json: Option<&'a str>,
pub expected_output_bytes: usize,
pub spawner_name: &'static str,
}
#[derive(Debug, Error)]
pub enum PreFlightError {
#[error("binary not found: {path}")]
BinaryNotFound { path: PathBuf },
#[error("argv exceeds ARG_MAX: total_bytes={total_bytes}, arg_max={arg_max}, safety_margin_bytes={ARG_MAX_SAFETY_MARGIN_BYTES}")]
ArgvExceedsArgMax { total_bytes: usize, arg_max: usize },
#[error("--mcp-config expects filepath, got inline JSON '{0}'; Claude Code 2.1.177 rejects this form; substitute suggested tempfile")]
McpConfigInlineJsonRejected(String),
#[error("--mcp-config path missing: {path}")]
McpConfigPathMissing { path: PathBuf },
#[error("--mcp-config path invalid JSON at {path}: {error}")]
McpConfigPathInvalidJson { path: PathBuf, error: String },
#[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")]
WalkUpMcpJsonInvalid { path: PathBuf, error: String },
#[error("output buffer too small: expected={expected} bytes, configured_limit={configured} bytes; chunk the request or increase the buffer cap")]
OutputBufferTooSmall { expected: usize, configured: usize },
#[error("CLAUDE_CONFIG_DIR={path} contains settings.json with active MCP servers ({reason}); unset the env var or remove the offending entries")]
ClaudeConfigDirNotEmpty { path: PathBuf, reason: &'static str },
}
pub fn preflight_check(args: &PreFlightArgs) -> Result<(), PreFlightError> {
if is_skipped() {
tracing::warn!(
target: "preflight",
event = "preflight_skipped",
spawner = args.spawner_name,
"SQLITE_GRAPHRAG_SKIP_PREFLIGHT=1 — pre-flight checks bypassed; the 5-bug-class risk is accepted"
);
return Ok(());
}
let argv_total = compute_argv_bytes(args.argv);
check_argv_size(argv_total)?;
check_binary_exists(args.binary_path)?;
check_output_buffer(args.expected_output_bytes)?;
check_mcp_config_inline(args.mcp_config_inline_json)?;
check_mcp_config_path(args.argv)?;
check_walkup_mcp_json(args.workspace_root)?;
check_claude_config_dir()?;
tracing::info!(
target: "preflight",
event = "preflight_passed",
spawner = args.spawner_name,
argv_bytes = argv_total,
workspace_root = %args.workspace_root.display(),
"pre-flight validation passed"
);
Ok(())
}
pub fn write_empty_mcp_config_tempfile() -> Result<PathBuf, std::io::Error> {
use std::io::Write;
let mut tmp = tempfile::Builder::new()
.prefix("graphrag-mcp-")
.suffix(".json")
.tempfile()?;
tmp.write_all(br#"{"mcpServers":{}}"#)?;
tmp.flush()?;
let (_, path) = tmp.keep()?;
Ok(path)
}
fn compute_argv_bytes(argv: &[OsString]) -> usize {
argv.iter().map(|s| s.as_os_str().len() + 1).sum()
}
fn arg_max_bytes() -> usize {
#[cfg(unix)]
{
let n = unsafe { libc::sysconf(libc::_SC_ARG_MAX) };
if n > 0 {
n as usize
} else {
DEFAULT_ARG_MAX_BYTES
}
}
#[cfg(not(unix))]
{
DEFAULT_ARG_MAX_BYTES
}
}
fn check_argv_size(argv_total: usize) -> Result<(), PreFlightError> {
let max = arg_max_bytes();
if argv_total + ARG_MAX_SAFETY_MARGIN_BYTES > max {
return Err(PreFlightError::ArgvExceedsArgMax {
total_bytes: argv_total,
arg_max: max,
});
}
Ok(())
}
fn check_binary_exists(binary_path: &Path) -> Result<(), PreFlightError> {
if binary_path.exists() {
Ok(())
} else {
Err(PreFlightError::BinaryNotFound {
path: binary_path.to_path_buf(),
})
}
}
fn check_output_buffer(expected: usize) -> Result<(), PreFlightError> {
if expected > DEFAULT_OUTPUT_BUFFER_LIMIT_BYTES {
Err(PreFlightError::OutputBufferTooSmall {
expected,
configured: DEFAULT_OUTPUT_BUFFER_LIMIT_BYTES,
})
} else {
Ok(())
}
}
fn check_mcp_config_inline(inline: Option<&str>) -> Result<(), PreFlightError> {
if let Some(s) = inline {
let trimmed = s.trim();
if trimmed.starts_with('{') && trimmed.ends_with('}') {
return Err(PreFlightError::McpConfigInlineJsonRejected(s.to_string()));
}
}
Ok(())
}
fn check_mcp_config_path(argv: &[OsString]) -> Result<(), PreFlightError> {
let mut iter = argv.iter();
while let Some(arg) = iter.next() {
let path = if arg == "--mcp-config" {
match iter.next() {
Some(value) => PathBuf::from(value),
None => continue,
}
} else if let Some(stripped) = arg.to_str().and_then(|s| s.strip_prefix("--mcp-config=")) {
PathBuf::from(stripped)
} else {
continue;
};
validate_mcp_config_path(&path)?;
}
Ok(())
}
fn validate_mcp_config_path(path: &Path) -> Result<(), PreFlightError> {
if !path.exists() {
return Err(PreFlightError::McpConfigPathMissing {
path: path.to_path_buf(),
});
}
let contents =
std::fs::read_to_string(path).map_err(|e| PreFlightError::McpConfigPathInvalidJson {
path: path.to_path_buf(),
error: e.to_string(),
})?;
if let Err(e) = serde_json::from_str::<serde_json::Value>(&contents) {
return Err(PreFlightError::McpConfigPathInvalidJson {
path: path.to_path_buf(),
error: e.to_string(),
});
}
Ok(())
}
fn check_walkup_mcp_json(workspace_root: &Path) -> Result<(), PreFlightError> {
let mut current = workspace_root.to_path_buf();
for _ in 0..WALKUP_MAX_DEPTH {
let candidate = current.join(".mcp.json");
if candidate.exists() {
let contents = std::fs::read_to_string(&candidate).map_err(|e| {
PreFlightError::WalkUpMcpJsonInvalid {
path: candidate.clone(),
error: e.to_string(),
}
})?;
let parsed: serde_json::Value = serde_json::from_str(&contents).map_err(|e| {
PreFlightError::WalkUpMcpJsonInvalid {
path: candidate.clone(),
error: e.to_string(),
}
})?;
let has_active_mcps = parsed
.get("mcpServers")
.and_then(|v| v.as_object())
.map(|o| !o.is_empty())
.unwrap_or(false);
if has_active_mcps {
return Err(PreFlightError::WalkUpMcpJsonInvalid {
path: candidate,
error: "mcpServers declares active entries; set CLAUDE_CONFIG_DIR to an empty directory or remove the file".to_string(),
});
}
return Ok(());
}
match current.parent() {
Some(p) => current = p.to_path_buf(),
None => break,
}
}
Ok(())
}
fn check_claude_config_dir() -> Result<(), PreFlightError> {
let Some(dir) = std::env::var_os("CLAUDE_CONFIG_DIR") else {
return Ok(());
};
let path = PathBuf::from(&dir);
if !path.is_dir() {
return Ok(());
}
let settings = path.join("settings.json");
if !settings.exists() {
if std::fs::read_dir(&path)
.map(|mut i| i.next().is_some())
.unwrap_or(false)
{
tracing::warn!(
target: "preflight",
path = %path.display(),
"CLAUDE_CONFIG_DIR is populated but contains no settings.json; \
MCP servers and hooks will not be auto-loaded"
);
}
return Ok(());
}
let contents = match std::fs::read_to_string(&settings) {
Ok(c) => c,
Err(e) => {
tracing::warn!(
target: "preflight",
path = %settings.display(),
error = %e,
"CLAUDE_CONFIG_DIR/settings.json exists but could not be read; \
skipping semantic validation"
);
return Ok(());
}
};
let parsed: serde_json::Value = match serde_json::from_str(&contents) {
Ok(v) => v,
Err(e) => {
tracing::warn!(
target: "preflight",
path = %settings.display(),
error = %e,
"CLAUDE_CONFIG_DIR/settings.json is not valid JSON; \
skipping semantic validation"
);
return Ok(());
}
};
let has_mcp_servers = parsed
.get("mcpServers")
.and_then(|v| v.as_object())
.map(|o| !o.is_empty())
.unwrap_or(false);
if has_mcp_servers {
return Err(PreFlightError::ClaudeConfigDirNotEmpty {
path,
reason: "mcpServers",
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsString;
fn dummy_argv() -> Vec<OsString> {
vec![
OsString::from("/usr/bin/claude"),
OsString::from("-p"),
OsString::from("hello"),
]
}
fn dummy_args<'a>(
binary: &'a Path,
argv: &'a [OsString],
inline_json: Option<&'a str>,
) -> PreFlightArgs<'a> {
use std::sync::OnceLock;
static WORKSPACE: OnceLock<tempfile::TempDir> = OnceLock::new();
let workspace = WORKSPACE.get_or_init(|| tempfile::tempdir().expect("tempdir"));
PreFlightArgs {
binary_path: binary,
argv,
workspace_root: workspace.path(),
mcp_config_inline_json: inline_json,
expected_output_bytes: 1024,
spawner_name: "test",
}
}
#[test]
#[serial_test::serial(env)]
fn check_binary_exists_passes_when_path_valid() {
let saved = std::env::var_os("CLAUDE_CONFIG_DIR");
unsafe {
std::env::remove_var("CLAUDE_CONFIG_DIR");
}
let binary = if cfg!(windows) {
"C:\\Windows\\System32\\cmd.exe"
} else {
"/bin/sh"
};
let argv = dummy_argv();
let args = dummy_args(Path::new(binary), &argv, None);
let result = preflight_check(&args);
if let Some(v) = saved {
unsafe {
std::env::set_var("CLAUDE_CONFIG_DIR", v);
}
}
assert!(result.is_ok(), "preflight returned: {result:?}");
}
#[test]
fn check_binary_exists_fails_when_missing() {
let argv = dummy_argv();
let args = dummy_args(Path::new("/does/not/exist/claude-binary"), &argv, None);
let err = preflight_check(&args).unwrap_err();
assert!(
matches!(err, PreFlightError::BinaryNotFound { .. }),
"expected BinaryNotFound, got {err:?}"
);
}
#[test]
#[serial_test::serial(env)]
fn check_argv_size_passes_under_limit() {
let saved = std::env::var_os("CLAUDE_CONFIG_DIR");
unsafe {
std::env::remove_var("CLAUDE_CONFIG_DIR");
}
let argv = dummy_argv();
let args = dummy_args(Path::new("/bin/sh"), &argv, None);
let result = preflight_check(&args);
if let Some(v) = saved {
unsafe {
std::env::set_var("CLAUDE_CONFIG_DIR", v);
}
}
assert!(result.is_ok(), "preflight returned: {result:?}");
}
#[test]
#[serial_test::serial(env)]
fn check_argv_size_fails_when_exceeds_arg_max() {
let saved = std::env::var_os("CLAUDE_CONFIG_DIR");
unsafe {
std::env::remove_var("CLAUDE_CONFIG_DIR");
}
let huge = "x".repeat(64 * 1024 * 1024);
let argv = vec![OsString::from("/bin/sh"), OsString::from(huge)];
let args = dummy_args(Path::new("/bin/sh"), &argv, None);
let err = preflight_check(&args).unwrap_err();
if let Some(v) = saved {
unsafe {
std::env::set_var("CLAUDE_CONFIG_DIR", v);
}
}
assert!(
matches!(err, PreFlightError::ArgvExceedsArgMax { .. }),
"expected ArgvExceedsArgMax, got {err:?}"
);
}
#[test]
fn check_mcp_inline_json_detects_literal_braces() {
let argv = dummy_argv();
let args = dummy_args(Path::new("/bin/sh"), &argv, Some("{}"));
let err = preflight_check(&args).unwrap_err();
assert!(
matches!(err, PreFlightError::McpConfigInlineJsonRejected(_)),
"expected McpConfigInlineJsonRejected, got {err:?}"
);
}
#[test]
fn check_mcp_inline_json_writes_valid_tempfile() {
let path = write_empty_mcp_config_tempfile().expect("tempfile write");
let contents = std::fs::read_to_string(&path).expect("tempfile read");
let parsed: serde_json::Value =
serde_json::from_str(&contents).expect("tempfile valid JSON");
assert!(parsed.get("mcpServers").is_some());
assert!(parsed["mcpServers"].as_object().unwrap().is_empty());
let _ = std::fs::remove_file(&path);
}
#[test]
fn check_mcp_path_missing_returns_error() {
let argv = vec![
OsString::from("/bin/sh"),
OsString::from("--mcp-config"),
OsString::from("/nonexistent/path/mcp.json"),
];
let args = dummy_args(Path::new("/bin/sh"), &argv, None);
let err = preflight_check(&args).unwrap_err();
assert!(
matches!(err, PreFlightError::McpConfigPathMissing { .. }),
"expected McpConfigPathMissing, got {err:?}"
);
}
#[test]
fn check_mcp_path_invalid_json_returns_error() {
let tmp = tempfile::NamedTempFile::new().expect("tempfile");
std::fs::write(tmp.path(), b"this is not json").expect("write");
let argv = vec![
OsString::from("/bin/sh"),
OsString::from("--mcp-config"),
OsString::from(tmp.path().to_string_lossy().into_owned()),
];
let args = dummy_args(Path::new("/bin/sh"), &argv, None);
let err = preflight_check(&args).unwrap_err();
assert!(
matches!(err, PreFlightError::McpConfigPathInvalidJson { .. }),
"expected McpConfigPathInvalidJson, got {err:?}"
);
}
#[test]
fn check_walkup_mcp_json_passes_when_clean() {
let dir = tempfile::tempdir().expect("tempdir");
let argv = dummy_argv();
let args = PreFlightArgs {
workspace_root: dir.path(),
..dummy_args(Path::new("/bin/sh"), &argv, None)
};
let result = preflight_check(&args);
if let Err(PreFlightError::WalkUpMcpJsonInvalid { .. }) = &result {
panic!("walk-up incorrectly flagged on clean workspace");
}
}
#[test]
fn check_walkup_mcp_json_fails_on_zod_invalid() {
let dir = tempfile::tempdir().expect("tempdir");
let bad = dir.path().join(".mcp.json");
std::fs::write(&bad, b"{not json").expect("write bad mcp.json");
let argv = dummy_argv();
let args = PreFlightArgs {
workspace_root: dir.path(),
..dummy_args(Path::new("/bin/sh"), &argv, None)
};
let err = preflight_check(&args).unwrap_err();
assert!(
matches!(err, PreFlightError::WalkUpMcpJsonInvalid { .. }),
"expected WalkUpMcpJsonInvalid, got {err:?}"
);
}
#[test]
fn check_walkup_mcp_json_fails_on_active_mcp_servers() {
let dir = tempfile::tempdir().expect("tempdir");
let bad = dir.path().join(".mcp.json");
std::fs::write(
&bad,
r#"{"mcpServers":{"github":{"command":"gh","args":["mcp"]}}}"#,
)
.expect("write bad mcp.json");
let argv = dummy_argv();
let args = PreFlightArgs {
workspace_root: dir.path(),
..dummy_args(Path::new("/bin/sh"), &argv, None)
};
let err = preflight_check(&args).unwrap_err();
assert!(
matches!(err, PreFlightError::WalkUpMcpJsonInvalid { .. }),
"expected WalkUpMcpJsonInvalid, got {err:?}"
);
}
#[test]
fn check_walkup_mcp_json_passes_with_empty_mcp_servers() {
let dir = tempfile::tempdir().expect("tempdir");
let ok = dir.path().join(".mcp.json");
std::fs::write(&ok, r#"{"mcpServers":{}}"#).expect("write");
let argv = dummy_argv();
let args = PreFlightArgs {
workspace_root: dir.path(),
..dummy_args(Path::new("/bin/sh"), &argv, None)
};
let result = preflight_check(&args);
if let Err(PreFlightError::WalkUpMcpJsonInvalid { .. }) = &result {
panic!("empty mcpServers must pass walk-up: {result:?}");
}
}
#[test]
fn check_mcp_path_equals_form_detects_missing_file() {
let argv = vec![
OsString::from("/bin/sh"),
OsString::from("--mcp-config=/nonexistent/path/mcp.json"),
];
let args = dummy_args(Path::new("/bin/sh"), &argv, None);
let err = preflight_check(&args).unwrap_err();
assert!(
matches!(err, PreFlightError::McpConfigPathMissing { .. }),
"expected McpConfigPathMissing, got {err:?}"
);
}
#[test]
fn check_output_buffer_warns_when_oversized() {
let argv = dummy_argv();
let args = PreFlightArgs {
expected_output_bytes: 100_000, ..dummy_args(Path::new("/bin/sh"), &argv, None)
};
let err = preflight_check(&args).unwrap_err();
assert!(
matches!(err, PreFlightError::OutputBufferTooSmall { .. }),
"expected OutputBufferTooSmall, got {err:?}"
);
}
#[test]
#[serial_test::serial(env)]
fn check_claude_config_dir_fails_when_settings_has_active_mcps() {
let dir = tempfile::tempdir().expect("tempdir");
let settings = dir.path().join("settings.json");
std::fs::write(
&settings,
r#"{"mcpServers":{"github":{"command":"gh","args":["mcp"]}}}"#,
)
.expect("write settings.json");
unsafe {
std::env::set_var("CLAUDE_CONFIG_DIR", dir.path());
}
let argv = dummy_argv();
let args = dummy_args(Path::new("/bin/sh"), &argv, None);
let err = preflight_check(&args);
unsafe {
std::env::remove_var("CLAUDE_CONFIG_DIR");
}
if let Err(PreFlightError::ClaudeConfigDirNotEmpty { reason, .. }) = err {
assert_eq!(reason, "mcpServers");
} else {
panic!("expected ClaudeConfigDirNotEmpty mcpServers, got {err:?}");
}
}
#[test]
#[serial_test::serial(env)]
fn check_claude_config_dir_passes_when_settings_empty() {
let dir = tempfile::tempdir().expect("tempdir");
let settings = dir.path().join("settings.json");
std::fs::write(&settings, r#"{"mcpServers":{},"hooks":{}}"#).expect("write");
unsafe {
std::env::set_var("CLAUDE_CONFIG_DIR", dir.path());
}
let argv = dummy_argv();
let args = dummy_args(Path::new("/bin/sh"), &argv, None);
let result = preflight_check(&args);
unsafe {
std::env::remove_var("CLAUDE_CONFIG_DIR");
}
assert!(result.is_ok(), "empty MCPs and hooks must pass: {result:?}");
}
#[test]
#[serial_test::serial(env)]
fn check_claude_config_dir_passes_when_no_settings_json() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("CLAUDE.md"), "# project notes").expect("write");
unsafe {
std::env::set_var("CLAUDE_CONFIG_DIR", dir.path());
}
let argv = dummy_argv();
let args = dummy_args(Path::new("/bin/sh"), &argv, None);
let result = preflight_check(&args);
unsafe {
std::env::remove_var("CLAUDE_CONFIG_DIR");
}
assert!(
result.is_ok(),
"populated dir without settings.json must pass: {result:?}"
);
}
#[test]
#[serial_test::serial(env)]
fn check_claude_config_dir_passes_when_settings_has_only_hooks() {
let dir = tempfile::tempdir().expect("tempdir");
let settings = dir.path().join("settings.json");
std::fs::write(&settings, r#"{"hooks":{"PreToolUse":[]}}"#).expect("write");
unsafe {
std::env::set_var("CLAUDE_CONFIG_DIR", dir.path());
}
let argv = dummy_argv();
let args = dummy_args(Path::new("/bin/sh"), &argv, None);
let result = preflight_check(&args);
unsafe {
std::env::remove_var("CLAUDE_CONFIG_DIR");
}
assert!(result.is_ok(), "hooks must be tolerated: {result:?}");
}
#[test]
fn preflight_check_runs_all_guards_in_order() {
let dir = tempfile::tempdir().expect("tempdir");
let argv = dummy_argv();
let args = PreFlightArgs {
workspace_root: dir.path(),
..dummy_args(Path::new("/bin/sh"), &argv, None)
};
assert!(preflight_check(&args).is_ok());
}
#[test]
fn preflight_check_short_circuits_on_first_failure() {
let argv = dummy_argv();
let args = dummy_args(Path::new("/does/not/exist/at/all"), &argv, Some("{}"));
let err = preflight_check(&args).unwrap_err();
assert!(
matches!(err, PreFlightError::BinaryNotFound { .. }),
"expected BinaryNotFound (short-circuit), got {err:?}"
);
}
#[test]
#[serial_test::serial(env)]
fn app_error_preflight_failed_has_exit_code_16() {
use crate::errors::AppError;
let err: AppError = crate::spawn::preflight::PreFlightError::BinaryNotFound {
path: "/bin/test".into(),
}
.into();
assert_eq!(err.exit_code(), 16);
assert!(err.is_permanent());
assert!(!err.is_retryable());
}
}