Files
yvgude--lean-ctx/rust/tests/mcp_optout_281.rs
T
wehub-resource-sync 26382a7ac6
CI / Clippy (push) Failing after 15m13s
CI / Test (ubuntu-latest) (push) Failing after 16m1s
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Build (no embeddings / no ORT) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Cookbook (Node) (push) Has been cancelled
CI / Pi Extension (Node) (push) Has been cancelled
CI / Rust SDK (lean-ctx-client) (push) Has been cancelled
CI / Embed SDK (lean-ctx-sdk) (push) Has been cancelled
CI / Python SDK (leanctx) (push) Has been cancelled
CI / Hermes Plugin (Python) (push) Has been cancelled
CI / SDK Conformance Matrix (push) Has been cancelled
CI / Coverage (push) Has been cancelled
CI / cargo-deny (push) Has been cancelled
CI / Adversarial Safety (push) Has been cancelled
CI / Benchmarks (push) Has been cancelled
CI / Output-Quality Gate (eval A/B) (push) Has been cancelled
CI / Documentation (push) Has been cancelled
CI / CI Green (push) Has been cancelled
JetBrains Plugin / Actionlint (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (rust) (push) Has been cancelled
JetBrains Plugin / Validation (push) Has been cancelled
JetBrains Plugin / Build (push) Has been cancelled
JetBrains Plugin / Test (push) Has been cancelled
Security Check / Security Scan (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:30 +08:00

166 lines
5.6 KiB
Rust

//! #281: `[setup] auto_update_mcp = false` must keep the lean-ctx MCP server out
//! of every agent/editor config, while hooks, rules and skills still install.
//!
//! These run the real binary as a subprocess so `Config::load()` reads the temp
//! config dir fresh (no in-process cache to fight). Every lean-ctx directory is
//! redirected into the tempdir so nothing touches the developer's real machine,
//! and `LEAN_CTX_HOOK_CHILD=1` suppresses daemon auto-start.
#![cfg(unix)]
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
/// A fully isolated lean-ctx environment rooted in a tempdir.
struct Sandbox {
_root: tempfile::TempDir,
home: PathBuf,
config: PathBuf,
data: PathBuf,
state: PathBuf,
cache: PathBuf,
}
impl Sandbox {
/// `config_body` is written verbatim to `config.toml`; pass `""` for defaults
/// (which means `auto_update_mcp = true`).
fn new(config_body: &str) -> Self {
let root = tempfile::tempdir().expect("tempdir");
let home = root.path().join("home");
let config = root.path().join("config");
let data = root.path().join("data");
let state = root.path().join("state");
let cache = root.path().join("cache");
for d in [&home, &config, &data, &state, &cache] {
std::fs::create_dir_all(d).unwrap();
}
std::fs::write(config.join("config.toml"), config_body).unwrap();
Self {
_root: root,
home,
config,
data,
state,
cache,
}
}
fn command(&self) -> Command {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_lean-ctx"));
cmd.env("HOME", &self.home)
.env("LEAN_CTX_CONFIG_DIR", &self.config)
.env("LEAN_CTX_DATA_DIR", &self.data)
.env("LEAN_CTX_STATE_DIR", &self.state)
.env("LEAN_CTX_CACHE_DIR", &self.cache)
// Never talk to (or start) the developer's daemon.
.env("LEAN_CTX_HOOK_CHILD", "1")
.env("LEAN_CTX_QUIET", "1");
cmd
}
fn jb_snippet(&self) -> PathBuf {
self.home.join(".jb-mcp.json")
}
}
fn assert_success(out: &Output, ctx: &str) {
assert!(
out.status.success(),
"{ctx} failed: {}\nstdout:\n{}\nstderr:\n{}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
}
/// `init --agent jetbrains` drives the hooks-layer MCP writer
/// (`install_jetbrains_hook`) — the path that bypassed the first #281 fix.
fn run_init_jetbrains(sandbox: &Sandbox) -> Output {
sandbox
.command()
.args(["init", "--agent", "jetbrains", "--global"])
.output()
.expect("spawn lean-ctx init --agent jetbrains")
}
#[test]
fn init_agent_skips_mcp_write_when_opted_out() {
let sandbox = Sandbox::new("[setup]\nauto_update_mcp = false\n");
let out = run_init_jetbrains(&sandbox);
assert_success(&out, "init --agent jetbrains (opted out)");
assert!(
!sandbox.jb_snippet().exists(),
"auto_update_mcp=false must not write the JetBrains MCP snippet (~/.jb-mcp.json)"
);
}
#[test]
fn init_agent_writes_mcp_by_default() {
// The positive control: with the default flag the writer must still run, so
// the opt-out test above is proving a real difference rather than a no-op.
let sandbox = Sandbox::new("");
let out = run_init_jetbrains(&sandbox);
assert_success(&out, "init --agent jetbrains (default)");
assert!(
sandbox.jb_snippet().exists(),
"default config must write the JetBrains MCP snippet"
);
}
#[test]
fn doctor_fix_skips_mcp_step_when_opted_out() {
let sandbox = Sandbox::new("[setup]\nauto_update_mcp = false\n");
let out = sandbox
.command()
.args(["doctor", "--fix", "--json"])
.output()
.expect("spawn lean-ctx doctor --fix --json");
// `doctor --fix` can exit non-zero in a bare sandbox (not every health check
// passes there), but it still emits the JSON report — and the mcp_config step
// must report the opt-out instead of registering anything.
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("MCP registration skipped (auto_update_mcp=false)"),
"doctor --fix must report the MCP opt-out in its mcp_config step; got:\n{stdout}"
);
}
/// Sanity: opting out must not leave any `mcp.json`-style file under HOME.
#[test]
fn opt_out_leaves_no_mcp_files_under_home() {
let sandbox = Sandbox::new("[setup]\nauto_update_mcp = false\n");
assert_success(&run_init_jetbrains(&sandbox), "init --agent jetbrains");
let leaked = find_mcp_files(&sandbox.home);
assert!(
leaked.is_empty(),
"auto_update_mcp=false must not write any MCP config files; found: {leaked:?}"
);
}
/// Collect any file whose name signals an MCP registration lean-ctx might write.
fn find_mcp_files(root: &Path) -> Vec<PathBuf> {
let mut stack = vec![root.to_path_buf()];
let mut hits = Vec::new();
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
continue;
}
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
let is_mcp_file = name == "mcp.json" || name == ".jb-mcp.json";
if is_mcp_file && std::fs::read_to_string(&path).is_ok_and(|c| c.contains("lean-ctx")) {
hits.push(path);
}
}
}
hits
}