use crate::onboard::clients::{ ClientAdapter, ClientId, ClientOutcome, ClientReport, DesiredMcp, NotApplicableReason, ProjectFacts, }; use crate::onboard::probe_l1::{ self, HelperCommand, L1ClientFacts, L1Source, Located, StaticFinding, }; use crate::{db, db::log_event}; use serde_json::{json, Value}; use std::path::PathBuf; /// 单一全局服务器的 settings.json key / 全局注册名 const MCP_KEY: &str = "lian-jing"; /// 旧组级 key 前缀——仅用于注入时清理存量配置,T6 收尾后可删 const LEGACY_KEY_PREFIX: &str = "lian-jing:group-"; const CODEX_MCP_MANAGED_START: &str = "# LIANJING_MCP_MANAGED_START"; const CODEX_MCP_MANAGED_END: &str = "# LIANJING_MCP_MANAGED_END"; const CONTEXT_SCHEMA_VERSION: u8 = 1; /// 稳定 helper 入口文件名:位于 %USERPROFILE%\.lian-jing\bin\,每次启动指向当前 exe。 const MCP_HEADER_SHIM_NAME: &str = "lianjing-mcp-header.cmd"; const DSH_MANAGED_START: &str = "# LIANJING_DSH_MANAGED_START"; const DSH_MANAGED_END: &str = "# LIANJING_DSH_MANAGED_END"; // ── 稳定 helper 入口(三端共用)──────────────────────────────────────────────── // Codex 与 Claude 在 Windows 都经 cmd.exe 执行 helper 并展开环境变量;DSH 由 !!js 经 cmd 调用。 // 项目配置只引用用户目录下的固定入口,不含本机 exe 路径与用户名,换构建无需逐项目重注入。 // 入口放在 %USERPROFILE% 而非 AppData:炼境若是在 MSIX 打包应用的进程树里启动的(例如由 Claude 桌面版 // 里的 agent 启动 dev 构建),它在 AppData 下新建的目录会被 Windows 重定向到该应用的私有目录, // Codex Desktop 等其他客户端看不到;用户目录根不做这种重定向(2026-10-01/02 包上下文对照实测)。 fn helper_bin_dir() -> Result { std::env::var_os("USERPROFILE") .map(PathBuf::from) .filter(|home| home.is_absolute()) .map(|home| home.join(".lian-jing").join("bin")) .ok_or_else(|| "USERPROFILE 未设置,无法写入 MCP helper 入口".to_string()) } /// 迁移期兼容:已注入的项目配置仍引用旧入口 %LOCALAPPDATA%\lian-jing\bin\(炼境不在包环境里启动时 /// 各客户端都能看到),在项目重注入前继续同步写一份;verify_onboarding 会把旧写法报为 Legacy 引导升级。 fn legacy_shim_path() -> Option { std::env::var_os("LOCALAPPDATA") .map(PathBuf::from) .filter(|base| base.is_absolute()) .map(|base| { base.join("lian-jing") .join("bin") .join(MCP_HEADER_SHIM_NAME) }) } /// cmd 按 OEM 代码页读批处理;exe 路径含非 ASCII 字符时先切到 UTF-8。 /// 路径里的 % 必须写成 %%,否则会和 %1 配对被当作变量展开吞掉。 /// 调用与退出写在同一行:cmd 在命令返回后按字节偏移重读批处理文件,入口若恰在 helper 运行期间被改写, /// 分行写法会执行到错位片段(实测 exit 9009)。`call exit /b %%ERRORLEVEL%%` 在执行时才展开,能传递 exe 退出码。 fn render_header_shim(executable: &std::path::Path) -> String { let exe = executable.display().to_string().replace('%', "%%"); let codepage = if exe.is_ascii() { "" } else { "chcp 65001 >nul\r\n" }; format!( "@echo off\r\n{codepage}\"{exe}\" --lianjing-mcp-header %1 & call exit /b %%ERRORLEVEL%%\r\n" ) } fn write_if_changed(path: &std::path::Path, content: &str) -> Result<(), String> { if std::fs::read_to_string(path).ok().as_deref() == Some(content) { return Ok(()); } if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .map_err(|error| format!("创建 MCP helper 入口目录失败: {error}"))?; } std::fs::write(path, content).map_err(|error| format!("写入 MCP helper 入口失败: {error}")) } /// 写入或更新稳定 helper 入口;内容未变不重写。炼境启动与每次注入时调用。 pub fn ensure_header_shim() -> Result { let bin_dir = helper_bin_dir()?; std::fs::create_dir_all(&bin_dir) .map_err(|error| format!("创建 MCP helper 入口目录失败: {error}"))?; // exe 本身总在真实路径上(安装目录或构建目录),各客户端都能看到,直接引用即可 let executable = std::env::current_exe() .map_err(|_| "无法定位炼境可执行文件,拒绝写入 MCP helper 入口".to_string())?; let content = render_header_shim(&executable); let path = bin_dir.join(MCP_HEADER_SHIM_NAME); write_if_changed(&path, &content)?; if let Some(legacy) = legacy_shim_path() { if let Err(error) = write_if_changed(&legacy, &content) { log_event( "mcp_inject", "warn", &format!("旧位置 helper 入口更新失败(仅影响尚未重注入的项目): {error}"), Some(r#"{"client":"shim"}"#), ); } } Ok(path) } /// 客户端配置中的 helper 命令:只含 %USERPROFILE% 与非秘密 digest,可安全提交进项目仓库。 fn shim_helper_command(digest: &str) -> String { format!("\"%USERPROFILE%\\.lian-jing\\bin\\{MCP_HEADER_SHIM_NAME}\" {digest}") } /// 迁移期旧写法(%LOCALAPPDATA% 入口,ensure_header_shim 仍同步写入):L1 视同托管写法、照常执行。 fn legacy_shim_helper_command(digest: &str) -> String { format!("\"%LOCALAPPDATA%\\lian-jing\\bin\\{MCP_HEADER_SHIM_NAME}\" {digest}") } // ── 路径解析 ────────────────────────────────────────────────────────────────── /// 校验并解析登记路径:空串/空白/相对路径一律拒绝。 /// 空路径经 PathBuf::from("") 会解析到炼境进程 cwd——2026-07-15 空路径登记 /// 曾把 settings.json/liangjing.json 写进 src-tauri/(apply 链路同日已拦, /// inject 链路本处单点补防)。 fn validate_registered_path(raw: &str, field: &str) -> Result { let trimmed = raw.trim(); if trimmed.is_empty() { return Err(format!("{field} 为空,拒绝解析(会写进炼境进程 cwd)")); } let path = PathBuf::from(trimmed); if !path.is_absolute() { return Err(format!("{field} 非绝对路径,拒绝解析: {trimmed:?}")); } Ok(path) } /// 解析项目根目录路径(WSL 项目自动转为 Windows UNC 路径) fn project_root(project_id: &str) -> Result { let conn = db::pool().get().map_err(|e| e.to_string())?; project_root_with_conn(&conn, project_id) } /// project_root 的依赖注入版:连接由调用方传入(业务层复用 + in-memory 测试) pub(crate) fn project_root_with_conn( conn: &rusqlite::Connection, project_id: &str, ) -> Result { let row: rusqlite::Result<(Option, Option, Option)> = conn.query_row( "SELECT win_path, wsl_path, platform FROM project_workspaces WHERE id = ?1", [project_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), ); let (win_path, wsl_path, platform) = row.map_err(|_| format!("项目 {} 不存在", project_id))?; if platform.as_deref() == Some("wsl") { let wsl = wsl_path.ok_or_else(|| "WSL 项目未配置 wsl_path".to_string())?; if wsl.trim().is_empty() { return Err("wsl_path 为空,拒绝解析(会指向 WSL 发行版根目录)".to_string()); } let distro: String = conn .query_row( "SELECT value FROM settings WHERE key = 'wsl_distro'", [], |r| r.get(0), ) .unwrap_or_else(|_| "Ubuntu".to_string()); let inner = wsl.trim_start_matches('/').replace('/', "\\"); Ok(PathBuf::from(format!( "\\\\wsl.localhost\\{}\\{}", distro, inner ))) } else { let win = win_path.ok_or_else(|| "Windows 项目未配置 win_path".to_string())?; validate_registered_path(&win, "win_path") } } fn settings_path(project_id: &str) -> Result { Ok(project_root(project_id)? .join(".claude") .join("settings.json")) } fn codex_config_path(project_id: &str) -> Result { Ok(project_root(project_id)?.join(".codex").join("config.toml")) } fn read_context_object(path: &PathBuf, label: &str) -> Result { read_context_object_with(path, label, &crate::DiskSink) } fn read_context_object_with( path: &std::path::Path, label: &str, sink: &dyn crate::WriteSink, ) -> Result { match sink.read(path) { Ok(Some(content)) if content.trim().is_empty() => Ok(json!({})), Ok(Some(content)) => serde_json::from_str::(&content) .map_err(|error| format!("读取 {label} 失败({}): {error}", path.display())) .and_then(|value| { if value.is_object() { Ok(value) } else { Err(format!( "{label} 顶层必须是 JSON 对象({})", path.display() )) } }), Ok(None) => Ok(json!({})), Err(error) => Err(format!("读取 {label} 失败({}): {error}", path.display())), } } /// 现有 JSON 文本与待写值语义相同(与键序、缩进、行尾、末尾换行无关)时不重写: /// Claude Code 等客户端改写这些文件时用自己的键序与格式,按文本比较会让炼境每次注入都整文件重排。 fn json_text_equals(existing: Option<&str>, value: &Value) -> bool { existing .and_then(|text| serde_json::from_str::(text.trim_start_matches('\u{feff}')).ok()) .is_some_and(|existing| &existing == value) } /// 语义相同不写;否则以 pretty JSON 经写入汇写入(父目录由写入汇按需创建)。 fn write_json_with( path: &std::path::Path, value: &Value, label: &str, sink: &dyn crate::WriteSink, ) -> Result<(), String> { let existing = sink .read(path) .map_err(|error| format!("读取 {label} 失败: {error}"))?; if json_text_equals(existing.as_deref(), value) { return Ok(()); } let content = serde_json::to_string_pretty(value).map_err(|error| error.to_string())?; sink.write(path, &content) .map(|_| ()) .map_err(|error| format!("写入 {label} 失败: {error}")) } /// 写工具无关的项目身份锚点。项目根目录是边界,客户端项目 UI 或用户级配置均不是身份来源。 /// 合并写入:保留未来字段与项目定制字段,只有炼境拥有的身份字段会被刷新。 #[cfg(test)] fn write_canonical_context_at(root: &std::path::Path, project_id: &str) -> Result<(), String> { write_canonical_context_with(root, project_id, &crate::DiskSink) } pub(crate) fn write_canonical_context_with( root: &std::path::Path, project_id: &str, sink: &dyn crate::WriteSink, ) -> Result<(), String> { let path = root.join(".lian-jing").join("context.json"); let mut ctx = read_context_object_with(&path, "canonical 项目上下文", sink)?; let obj = ctx.as_object_mut().expect("ctx 必为 JSON 对象"); obj.insert("schema_version".into(), json!(CONTEXT_SCHEMA_VERSION)); obj.insert("project_id".into(), json!(project_id)); write_json_with(&path, &ctx, "canonical 项目上下文", sink) } /// 写旧 Claude 上下文投影。它不是项目身份的单一事实来源,保留是为了兼容已接入项目。 pub(crate) fn write_liangjing_context_with( root: &std::path::Path, project_id: &str, port: u16, sink: &dyn crate::WriteSink, ) -> Result<(), String> { let path = root.join(".claude").join("liangjing.json"); let mut ctx = read_context_object_with(&path, "Claude 兼容上下文", sink)?; let obj = ctx.as_object_mut().expect("ctx 必为 JSON 对象"); obj.insert("project_id".into(), json!(project_id)); obj.insert("mcp_port".into(), json!(port)); obj.remove("group_id"); write_json_with(&path, &ctx, "Claude 兼容上下文", sink) } /// 项目 .claude/settings.json 的兼容条目(Claude Code 不读其 mcpServers,仅保留作兼容): /// 无人值守默认值只在缺省时写入,清理旧组级 key,写入单一 lian-jing 条目。 pub(crate) fn write_settings_compat_with( root: &std::path::Path, server_url: &str, sink: &dyn crate::WriteSink, ) -> Result<(), String> { let path = root.join(".claude").join("settings.json"); let mut settings = match sink.read(&path) { Ok(None) => json!({}), Ok(Some(content)) if content.trim().is_empty() => json!({}), Ok(Some(content)) => serde_json::from_str(&content).map_err(|e| { format!( "settings.json 格式错误,请手动修复后重试({}): {}", path.display(), e ) })?, Err(e) => return Err(format!("读取 settings.json 失败: {}", e)), }; let obj = settings .as_object_mut() .ok_or("settings.json 顶层必须是 JSON 对象")?; // 炼境管理的项目需要无人值守运行(/ship 等技能不应被 permission 弹窗打断) obj.entry("permissions") .or_insert(json!({"defaultMode": "bypassPermissions"})); let servers = obj.entry("mcpServers").or_insert(json!({})); if let Some(map) = servers.as_object_mut() { map.retain(|k, _| !k.starts_with(LEGACY_KEY_PREFIX)); } servers[MCP_KEY] = json!({ "type": "sse", "url": server_url }); write_json_with(&path, &settings, "settings.json", sink) } /// 项目身份与兼容投影(canonical context、settings.json 兼容条目、Claude 兼容上下文)按固定顺序写齐。 /// inject_project_mcp_locked 与 onboard::plan::write_project_files 都只调这一个函数,写入序列只有一份; /// Codex 托管块另由 upsert_codex_mcp_config_with 写(inject 经 CodexAdapter 调,plan 直接调)。 /// 失败时带出真正出错的文件,供计划的阻断项指向它。 pub(crate) fn write_project_identity_files_with( root: &std::path::Path, project_id: &str, port: u16, server_url: &str, sink: &dyn crate::WriteSink, ) -> Result<(), crate::WriteFailure> { let context = root.join(".lian-jing").join("context.json"); write_canonical_context_with(root, project_id, sink) .map_err(crate::WriteFailure::at(&context))?; let settings = root.join(".claude").join("settings.json"); write_settings_compat_with(root, server_url, sink) .map_err(crate::WriteFailure::at(&settings))?; let liangjing = root.join(".claude").join("liangjing.json"); write_liangjing_context_with(root, project_id, port, sink) .map_err(crate::WriteFailure::at(&liangjing)) } /// 查找 claude CLI 路径(优先环境变量 CLAUDE_BIN,然后常用安装目录,最后 where 命令) fn find_claude_bin() -> Option { if let Ok(path) = std::env::var("CLAUDE_BIN") { if std::path::Path::new(&path).exists() { return Some(path); } } let home = std::env::var("USERPROFILE") .or_else(|_| std::env::var("HOME")) .unwrap_or_default(); let candidates = [ format!(r"{}\.local\bin\claude.exe", home), format!(r"{}\AppData\Local\AnthropicClaude\claude.exe", home), r"C:\Program Files\AnthropicClaude\claude.exe".to_string(), ]; for c in &candidates { if std::path::Path::new(c).exists() { return Some(c.clone()); } } // 最后尝试 where 命令(GUI 进程 PATH 可能不含用户 bin,but worth trying) if let Ok(out) = crate::silent_cmd("where").arg("claude.exe").output() { if out.status.success() { if let Ok(stdout) = String::from_utf8(out.stdout) { if let Some(line) = stdout.lines().next() { return Some(line.trim().to_string()); } } } } None } /// 查找 git-bash 路径(claude CLI 在 Windows 上必需)。 /// 顺序:环境变量(校验存在)→ 常见安装位置(Program Files / Scoop)→ 从 git.exe 位置推导 fn find_git_bash() -> Option { if let Ok(p) = std::env::var("CLAUDE_CODE_GIT_BASH_PATH") { if std::path::Path::new(&p).exists() { return Some(p); } } let home = std::env::var("USERPROFILE").unwrap_or_default(); let scoop = std::env::var("SCOOP").unwrap_or_else(|_| format!(r"{}\scoop", home)); let candidates = [ r"C:\Program Files\Git\bin\bash.exe".to_string(), format!(r"{}\apps\git\current\bin\bash.exe", scoop), ]; for c in &candidates { if std::path::Path::new(c).exists() { return Some(c.clone()); } } // 从 git.exe 推导:\cmd\git.exe / \mingw64\bin\git.exe → \bin\bash.exe if let Ok(out) = crate::silent_cmd("where").arg("git.exe").output() { if out.status.success() { for line in String::from_utf8_lossy(&out.stdout).lines() { let git = std::path::Path::new(line.trim()); if let Some(root) = git.parent().and_then(|d| d.parent()) { let bash = root.join("bin").join("bash.exe"); if bash.exists() { return Some(bash.to_string_lossy().to_string()); } } } } } None } /// 将单一全局 lian-jing 服务器注册到 Claude 全局配置(~/.claude.json)。 /// best-effort,失败仅记录日志;返回是否成功(同名已存在视为成功,作为 CLI 链路健康信号) fn try_register_globally(port: u16) -> bool { let server_url = format!("http://127.0.0.1:{}/mcp", port); // 机器级、幂等:只读确认已注册就不起 CLI(每次注入都会走到这里,避免无谓的 1–2 秒阻塞) if read_claude_config().is_some_and(|config| claude_global_registered(&config)) { return true; } let Some(claude_bin) = find_claude_bin() else { db::log_event( "mcp_inject", "warn", "claude CLI 未找到,跳过全局 MCP 注册", None, ); return false; }; let Some(git_bash) = find_git_bash() else { db::log_event( "mcp_inject", "warn", "git-bash 未找到(claude CLI 依赖),跳过全局 MCP 注册", None, ); return false; }; match run_claude_cli( &claude_bin, &git_bash, None, &[ "mcp", "add", "--transport", "sse", "-s", "user", MCP_KEY, &server_url, ], ) { Ok(_) => { db::log_event( "mcp_inject", "info", "全局 MCP 注册成功", Some(&global_log_context()), ); true } // 同名已存在不是故障——CLI 链路健康,注册目标已达成 Err(output) if output.contains("already exists") => true, Err(output) => { db::log_event( "mcp_inject", "warn", &format!("claude mcp add 失败: {output}"), Some(&global_log_context()), ); false } } } /// 项目在 .lian-jing/context.json 声明 "mcp_scope": "global" 即为控制面项目(如炼境自身): /// Claude 与 DSH 保留全局地址与跨项目工具,不写项目级覆盖(否则 local 条目会遮蔽全局注册)。 fn project_wants_global_scope(root: &std::path::Path) -> bool { read_context_object( &root.join(".lian-jing").join("context.json"), "canonical 项目上下文", ) .ok() .and_then(|ctx| { ctx.get("mcp_scope") .and_then(Value::as_str) .map(|scope| scope == "global") }) .unwrap_or(false) } /// 注入日志的 context 必须是 JSON:get_diagnostics 按 json_extract(context,'$.project_id') 分组。 fn inject_log_context(project_id: &str, client: &str) -> String { json!({ "project_id": project_id, "client": client }).to_string() } fn global_log_context() -> String { json!({ "client": "claude-global", "server": MCP_KEY }).to_string() } /// Claude Code local scope 条目:项目专用地址 + 稳定 helper。local 优先于 user 级全局注册且不进仓库。 fn claude_local_server_entry(server_url: &str, header_helper: &str) -> Value { json!({ "type": "http", "url": server_url, "headersHelper": header_helper, }) } /// Claude 全局配置文件位置,与 Claude 的解析规则一致:旧版 `<配置目录>/.config.json` 存在则优先, /// 否则为 `$CLAUDE_CONFIG_DIR`(或用户目录)下的 `.claude.json`。只用于只读检查。 fn claude_config_path() -> Option { let config_dir = std::env::var_os("CLAUDE_CONFIG_DIR").map(PathBuf::from); let home = std::env::var_os("USERPROFILE").map(PathBuf::from)?; let legacy = config_dir .clone() .unwrap_or_else(|| home.join(".claude")) .join(".config.json"); if legacy.is_file() { return Some(legacy); } Some(config_dir.unwrap_or(home).join(".claude.json")) } /// 只读读取 Claude 全局配置(容忍 BOM)。读不到或解析失败返回 None,调用方按「未知」保守处理。 fn read_claude_config() -> Option { let text = std::fs::read_to_string(claude_config_path()?).ok()?; serde_json::from_str(text.trim_start_matches('\u{feff}')).ok() } /// 只读扫描:所有项目键下指向本项目专用地址、且经稳定 helper 认证的炼境托管条目。按 URL 匹配, /// 不复刻 Claude 的项目键推导(Claude 以 git 根为键、worktree 映射到主仓库根、路径做 NFC 归一化)。 fn managed_claude_entries(config: &Value, project_id: &str) -> Vec<(String, Value)> { let suffix = format!("/mcp/projects/{}", urlencoding::encode(project_id)); let Some(projects) = config.get("projects").and_then(Value::as_object) else { return Vec::new(); }; projects .iter() .filter_map(|(key, project)| { let entry = project.get("mcpServers")?.get(MCP_KEY)?; let url = entry.get("url").and_then(Value::as_str)?; let helper = entry .get("headersHelper") .and_then(Value::as_str) .unwrap_or(""); (url.starts_with("http://127.0.0.1:") && url.ends_with(&suffix) && helper.contains(MCP_HEADER_SHIM_NAME)) .then(|| (key.clone(), entry.clone())) }) .collect() } fn claude_global_registered(config: &Value) -> bool { config .get("mcpServers") .and_then(|servers| servers.get(MCP_KEY)) .is_some() } /// claude CLI 单次调用上限:卡住的子进程会被终止,不让它拖住持锁的接入操作。 const CLAUDE_CLI_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20); /// 运行 claude CLI(不弹窗、stdin 置空、带超时)。成功返回输出,失败返回合并后的输出。 fn run_claude_cli( claude_bin: &str, git_bash: &str, cwd: Option<&std::path::Path>, args: &[&str], ) -> Result { use std::io::Read; let mut command = crate::silent_cmd(claude_bin); if let Some(dir) = cwd { command.current_dir(dir); } let mut child = command .args(args) .env("CLAUDE_CODE_GIT_BASH_PATH", git_bash) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() .map_err(|error| format!("执行 claude CLI 失败: {error}"))?; let deadline = std::time::Instant::now() + CLAUDE_CLI_TIMEOUT; let status = loop { match child.try_wait() { Ok(Some(status)) => break status, Ok(None) if std::time::Instant::now() < deadline => { std::thread::sleep(std::time::Duration::from_millis(100)); } Ok(None) => { let _ = child.kill(); let _ = child.wait(); return Err(format!( "claude CLI 超过 {} 秒未结束,已终止", CLAUDE_CLI_TIMEOUT.as_secs() )); } Err(error) => return Err(format!("等待 claude CLI 失败: {error}")), } }; let mut output = String::new(); if let Some(mut stdout) = child.stdout.take() { let _ = stdout.read_to_string(&mut output); } if let Some(mut stderr) = child.stderr.take() { let _ = stderr.read_to_string(&mut output); } if status.success() { Ok(output) } else { Err(output) } } /// 统一分隔符与大小写,便于比较 ~/.claude.json 的项目键与项目根目录。 fn normalize_root_key(path: &str) -> String { path.replace('\\', "/").trim_end_matches('/').to_lowercase() } /// Claude 以 git 根为项目键,必为项目根本身或其祖先目录。 fn key_covers_root(key: &str, root: &str) -> bool { let key = normalize_root_key(key); let root = normalize_root_key(root); root == key || root.starts_with(&format!("{key}/")) } /// 炼境项目路由 URL 里的 project_id;不是炼境项目路由则为 None(视为手写配置)。 fn lianjing_project_of(url: &str) -> Option { let rest = url.strip_prefix("http://127.0.0.1:")?; let encoded = rest.split_once("/mcp/projects/")?.1.trim_end_matches('/'); urlencoding::decode(encoded).ok().map(|id| id.into_owned()) } #[derive(Debug, PartialEq)] enum ClaudeEffective { /// 本目录生效的是 local 条目,附其 URL Local(String), /// 本目录没有 local 条目(生效的是 user/project 级,或根本没有 lian-jing) NotLocal, } /// 纯函数:解析 `claude mcp get lian-jing` 的输出。格式无法识别时返回 None(按「未知」处理,不写入)。 /// 输出格式的解析只有一份(onboard::probe_l1,L1 自省同用),这里只映射成同步要的两种情形。 fn parse_claude_mcp_get(output: &str) -> Option { use crate::onboard::probe_l1::ClaudeScope; let parsed = crate::onboard::probe_l1::parse_claude_mcp_get(output)?; match parsed.scope { ClaudeScope::Local => Some(ClaudeEffective::Local(parsed.url?)), ClaudeScope::Other(_) | ClaudeScope::Absent => Some(ClaudeEffective::NotLocal), } } /// 客户端侧同步(Claude local、DSH 托管块)的日志单独归类:不覆盖诊断里每个项目的「最后一次注入结果」。 const CLIENT_SYNC_LOG: &str = "mcp_client_sync"; /// 让 Claude 的 local 条目与期望一致:desired=Some 时注册/刷新,None 时只删除本项目的炼境托管条目。 /// /// 原则:写入一律交给 claude CLI(cwd=项目目录),判定以 Claude 自己的回答为准——炼境不复刻 Claude 的项目键 /// 推导、文件锁与配置路径(2026-10-01 多轮审查结论:任何自行推断都会在 worktree、目录迁移、共享键等边界出错)。 /// - 快路径(不起 CLI):本项目的托管条目已一致地位于项目根或其祖先目录的键下(Claude 的键是 git 根),视为已同步; /// 只读确认没有任何托管条目时,移除无需动作。 /// - 其余情况在项目目录执行 `claude mcp get lian-jing` 取实际生效条目:本项目的托管条目 → 移除或刷新; /// 他项目的托管条目(共享项目键)或手写条目 → 不碰并记录;没有 local 条目 → 按需写入。 /// - 刷新时先删后加,写入失败用原条目回滚;无法确认现状(读取失败、CLI 出错、目录不存在)一律不写,只记 warn。 /// /// 调用方须持有 mcp_op_lock,并在删除本地文件夹之前同步调用。 fn sync_claude_local( project_id: &str, root: &std::path::Path, desired: Option<(String, String)>, ) -> ClientOutcome { let config = read_claude_config(); sync_claude_local_with(project_id, root, desired, config.as_ref(), &|| { let (claude_bin, git_bash) = (find_claude_bin()?, find_git_bash()?); let cli: ClaudeCli = Box::new(move |args: &[&str]| run_claude_cli(&claude_bin, &git_bash, Some(root), args)); Some(cli) }) } /// 在项目目录执行一条 claude 子命令,Ok/Err 都携带 CLI 输出。测试注入假实现,不起真实 CLI。 type ClaudeCli<'a> = Box Result + 'a>; /// sync_claude_local 的依赖注入版:Claude 全局配置与 CLI 执行器由调用方给出; /// resolve_cli 返回 None 表示 claude CLI 或 git-bash 未找到。快路径不解析 CLI。 fn sync_claude_local_with<'a>( project_id: &str, root: &'a std::path::Path, desired: Option<(String, String)>, config: Option<&Value>, resolve_cli: &dyn Fn() -> Option>, ) -> ClientOutcome { let context = inject_log_context(project_id, "claude"); let warn = |message: &str| db::log_event(CLIENT_SYNC_LOG, "warn", message, Some(&context)); let unavailable = |reason: String| { warn(&reason); ClientOutcome::Unavailable { reason } }; let root_text = root.display().to_string(); let managed_all = config .map(|config| managed_claude_entries(config, project_id)) .unwrap_or_default(); let managed_here: Vec<&(String, Value)> = managed_all .iter() .filter(|(key, _)| key_covers_root(key, &root_text)) .collect(); let desired_entry = desired.map(|(url, helper)| claude_local_server_entry(&url, &helper)); match &desired_entry { Some(entry) if managed_here.iter().any(|(_, current)| current == entry) => { return ClientOutcome::Converged } None if config.is_some() && managed_all.is_empty() => return ClientOutcome::Converged, _ => {} } if !root.is_dir() { let keys: Vec<&str> = managed_all.iter().map(|(key, _)| key.as_str()).collect(); return unavailable(format!( "项目目录不存在,无法向 Claude 确认本目录的实际条目,未改动;本项目的托管条目位于项目键 {keys:?},可在对应目录执行 `claude mcp remove lian-jing -s local` 清理" )); } let Some(cli) = resolve_cli() else { return unavailable("claude CLI 或 git-bash 未找到,跳过 Claude 项目级 MCP 同步".into()); }; let effective = match cli(&["mcp", "get", MCP_KEY]) { Ok(output) | Err(output) => parse_claude_mcp_get(&output), }; let Some(effective) = effective else { return unavailable( "无法确认 Claude 在本目录实际生效的 lian-jing 条目(claude mcp get 输出无法识别),未改动" .into(), ); }; let remove = || cli(&["mcp", "remove", MCP_KEY, "--scope", "local"]); let add = |entry: &Value| { let payload = entry.to_string(); cli(&[ "mcp", "add-json", MCP_KEY, payload.as_str(), "--scope", "local", ]) }; let failed = |reason: String| -> (ClientOutcome, Result) { ( ClientOutcome::Failed { reason: reason.clone(), }, Err(reason), ) }; // (结构化结果, 日志):日志类别与文案保持原样 let (outcome, log): (ClientOutcome, Result) = match (effective, &desired_entry) { (ClaudeEffective::Local(url), desired) => match lianjing_project_of(&url) { Some(owner) if owner == project_id => match remove() { Err(error) => failed(format!("旧条目移除失败: {error}")), Ok(_) => match desired { None => ( ClientOutcome::Removed, Ok("Claude 项目级 MCP 条目已移除".into()), ), Some(entry) => match add(entry) { Ok(_) => ( ClientOutcome::Refreshed, Ok("Claude 项目级 MCP 条目已刷新".into()), ), Err(error) => { // 回滚:用读到的原条目写回,避免项目失去 local 条目 let restored = managed_here .first() .map(|(_, previous)| add(previous).is_ok()) .unwrap_or(false); failed(format!( "新条目写入失败: {error}({})", if restored { "已恢复原条目" } else { "原条目未能恢复" } )) } }, }, }, Some(owner) => { let message = format!( "本目录生效的 lian-jing local 条目属于项目 {owner}(共享项目键,如 worktree 或嵌套项目),炼境不改动" ); (ClientOutcome::OtherProject { owner }, Err(message)) } None => ( ClientOutcome::ManualKept, Ok("本目录已有手写的 lian-jing local 条目,炼境不覆盖".into()), ), }, (ClaudeEffective::NotLocal, Some(entry)) => match add(entry) { Ok(_) => ( ClientOutcome::Applied, Ok("Claude 项目级 MCP 条目已写入".into()), ), Err(error) => failed(format!("写入失败: {error}")), }, (ClaudeEffective::NotLocal, None) => { if managed_all.is_empty() { return ClientOutcome::Converged; } let keys: Vec<&str> = managed_all.iter().map(|(key, _)| key.as_str()).collect(); let reason = format!( "本目录没有生效的 local 条目,但其他项目键 {keys:?} 下残留本项目的托管条目,可在对应目录执行 `claude mcp remove lian-jing -s local` 清理" ); ( ClientOutcome::NeedsHuman { reason: reason.clone(), }, Err(reason), ) } }; match log { Ok(message) => db::log_event(CLIENT_SYNC_LOG, "info", &message, Some(&context)), Err(error) => warn(&format!("Claude 项目级 MCP 同步: {error}")), } outcome } /// 只读判定 Claude 在本项目根目录下的 local 条目状态(不起 CLI):只看覆盖本根目录的项目键。 fn claude_static_outcome( config: &Value, project_id: &str, root: &std::path::Path, desired: &Value, ) -> ClientOutcome { let root_text = root.display().to_string(); // Claude 以 git 根为项目键:根目录自身是 git 根时只认等于根目录的键,祖先目录的条目不在本目录生效 let own_git_root = root.join(".git").exists(); let applies = |key: &str| { if own_git_root { normalize_root_key(key) == normalize_root_key(&root_text) } else { key_covers_root(key, &root_text) } }; let mut entries: Vec<(String, &Value)> = config .get("projects") .and_then(Value::as_object) .map(|projects| { projects .iter() .filter(|(key, _)| applies(key)) .filter_map(|(key, project)| { Some(( normalize_root_key(key), project.get("mcpServers")?.get(MCP_KEY)?, )) }) .collect() }) .unwrap_or_default(); // 嵌套项目:最具体(最长)的键才是本目录生效的条目,祖先目录的条目属于外层项目 entries.sort_by_key(|(key, _)| std::cmp::Reverse(key.len())); let Some((_, effective)) = entries.first() else { return ClientOutcome::Missing; }; if *effective == desired { return ClientOutcome::Converged; } match effective .get("url") .and_then(Value::as_str) .and_then(lianjing_project_of) { None => ClientOutcome::ManualKept, Some(owner) if owner == project_id => ClientOutcome::Missing, Some(owner) => ClientOutcome::OtherProject { owner }, } } /// 进程内所有「炼境 MCP 接入」写操作(注入、移除、根目录迁移)串行执行: /// 检查 token → 写项目文件 → 写客户端配置作为一个整体,不与其他接入操作交错。 static MCP_OP_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); fn mcp_op_lock() -> std::sync::MutexGuard<'static, ()> { MCP_OP_LOCK .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) } // ── DSH 机器级配置托管块 ────────────────────────────────────────────────────── // DSH 配置无法按项目拆分,但 !!js 在加载时以启动目录求值。项目身份只认炼境登记表:托管块内嵌 // 「已登记项目根目录 → project_id」白名单(非秘密),从启动目录向上精确匹配根目录才连项目专用地址 // 并经稳定 helper 携带认证;任意目录里的 .lian-jing/context.json 不作为授权依据。未命中则回退全局地址, // 保持 GUI 等非项目启动方式的现有行为。项目增删、端口变更时重写白名单。 // 全局行只对单会话的 headless 按启动目录路由;桌面 GUI / web / acp 一个进程服务多个工作区,改由会话级插件 // (resources/dsh/,写到 %USERPROFILE%\.lian-jing\dsh\)在 agent/created 时按会话工作目录挂项目地址, // 只对该会话可见并覆盖同名全局工具(蓝图 agent-client-parity P9)。 /// 会话级插件的文件名与托管块条目 id 带同一个版本号。插件内容变化时两者必须一起递增:DSH 只在条目 id 变化时 /// 才卸载旧条目并 import 新模块(同 id 只改 name 不会重载,Node 也按 URL 缓存 ESM)。 /// 测试 dsh_session_plugin_source_matches_versioned_contract 钉住内容哈希,漏升版本会失败。 const DSH_SESSION_PLUGIN_FILE: &str = "lianjing-session-mcp.v1.mjs"; const DSH_SESSION_PLUGIN_SOURCE: &str = include_str!("../resources/dsh/lianjing-session-mcp.v1.mjs"); const DSH_SESSION_PLUGIN_ID: &str = "lianjing-session-mcp-v1"; /// 任意版本的会话级插件条目 id 前缀(判断块外手写条目用)。 const DSH_SESSION_PLUGIN_ID_PREFIX: &str = "lianjing-session-mcp"; /// 托管块对会话级插件的引用:插件文件与稳定入口的本机绝对路径(只写进机器级的 ~/.dsh 配置,不进项目仓库)。 struct DshSessionPlugin { module: String, shim: String, } /// 写入会话级插件文件(内容不变不写),返回托管块要引用的路径。放用户目录而非 AppData,原因同稳定入口。 fn ensure_dsh_session_plugin() -> Result { let bin_dir = helper_bin_dir()?; let dir = bin_dir .parent() .map(|lian_jing| lian_jing.join("dsh")) .ok_or("无法定位炼境用户目录")?; std::fs::create_dir_all(&dir).map_err(|error| format!("创建 DSH 插件目录失败: {error}"))?; let module = dir.join(DSH_SESSION_PLUGIN_FILE); crate::write_if_content_changed(&module, DSH_SESSION_PLUGIN_SOURCE) .map_err(|error| format!("写入 DSH 会话级插件失败: {error}"))?; Ok(DshSessionPlugin { // DSH 把托管块里的绝对路径插件名转成 file URL;正斜杠写法已在 0.1/0.2 实测可加载 module: module.display().to_string().replace('\\', "/"), shim: bin_dir.join(MCP_HEADER_SHIM_NAME).display().to_string(), }) } /// 白名单键规范化,与 JS 端 `path.resolve(d).toLowerCase().replace(/\\+$/, '')` 对齐: /// 反斜杠分隔、合并重复分隔符(保留 UNC 前缀 \\)、去尾部分隔符、小写。盘符根 E:\ 两端都得到 e:。 /// 登记的同目录判定(projects::registration_path_key)复用本函数,两处口径不得分叉。 pub(crate) fn normalize_dsh_root(path: &str) -> String { let unified = path.replace('/', "\\"); let (prefix, rest) = match unified.strip_prefix("\\\\") { Some(rest) => ("\\\\", rest), None => ("", unified.as_str()), }; let mut collapsed = String::with_capacity(rest.len()); for ch in rest.chars() { if ch == '\\' && collapsed.ends_with('\\') { continue; } collapsed.push(ch); } format!("{prefix}{}", collapsed.trim_end_matches('\\')).to_lowercase() } /// DSH 白名单:已登记、非 WSL、根目录 context.json 的 project_id 与登记一致的 Windows 项目。 /// - 普通项目须已有项目 token(helper 能工作的前提)才进入白名单——退组或移除时 token 被删,剔除即持久; /// - 控制面项目(mcp_scope=global)写入 None 作为停止哨兵:命中即停止向上查找,回退全局地址, /// 不会越过它匹配到已登记的上级目录。 fn dsh_project_roots( conn: &rusqlite::Connection, store: &impl crate::credential_store::CredentialStore, exclude: Option<&str>, ) -> Vec<(String, Option)> { let rows: Vec<(String, String)> = conn .prepare( "SELECT id, win_path FROM project_workspaces WHERE win_path IS NOT NULL AND COALESCE(platform, '') != 'wsl' ORDER BY id", ) .and_then(|mut stmt| { stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?))) .map(|rows| rows.filter_map(Result::ok).collect()) }) .unwrap_or_default(); rows.into_iter() .filter(|(id, _)| Some(id.as_str()) != exclude) .filter_map(|(id, win_path)| { let root = validate_registered_path(&win_path, "win_path").ok()?; // 每个项目只读一次 context.json,同时取 project_id 与 mcp_scope let context = read_context_object( &root.join(".lian-jing").join("context.json"), "canonical 项目上下文", ) .ok()?; if context.get("project_id").and_then(Value::as_str) != Some(id.as_str()) { return None; } let value = if context.get("mcp_scope").and_then(Value::as_str) == Some("global") { None } else { if !is_project_injected(store, &id) { return None; } Some(id) }; Some((win_path, root, value)) }) .flat_map(|(win_path, root, value)| { // DSH GUI 会话的工作目录是 realpath;登记路径若经 junction/符号链接或 8.3 短名,两种键都写进白名单 let key = normalize_dsh_root(win_path.trim()); let canonical = std::fs::canonicalize(&root) .ok() .map(|path| normalize_dsh_root(&strip_verbatim_prefix(&path.display().to_string()))) .filter(|canonical| canonical != &key); std::iter::once((key, value.clone())) .chain(canonical.map(|canonical| (canonical, value))) }) .collect() } /// 去掉 std::fs::canonicalize 在 Windows 上返回的 \\?\ 前缀(\\?\UNC\ 还原为 \\),得到普通路径。 pub(crate) fn strip_verbatim_prefix(path: &str) -> String { match path.strip_prefix(r"\\?\UNC\") { Some(rest) => format!(r"\\{rest}"), None => path.strip_prefix(r"\\?\").unwrap_or(path).to_string(), } } fn render_dsh_block( port: u16, roots: &[(String, Option)], session: Option<&DshSessionPlugin>, ) -> String { // 逐行显式缩进:YAML 依赖缩进,不能用字符串续行(会吞掉下一行行首空白)。 fn indent(spaces: usize, lines: &[&str]) -> Vec { lines .iter() .map(|line| format!("{}{line}", " ".repeat(spaces))) .collect() } // 值为 null 的键是控制面项目的停止哨兵:命中即停,回退全局地址。 let roots_map: serde_json::Map = roots .iter() .map(|(root, id)| (root.clone(), json!(id))) .collect(); let roots_json = Value::Object(roots_map).to_string(); let roots_line = format!("const roots = {roots_json};"); // 只有单会话的 headless 按启动目录路由:web/acp/tui 一个进程可服务多个工作区,按启动目录会让别的工作区 // 的会话连到该项目并带上它的认证;桌面版启动目录固定为 profile 目录。DSH 0.2 起由 profileContext.name // 给出 profile,0.1.x 没有该服务,退回看启动参数。 let find_project_id = [ "const path = process.getBuiltinModule('path');", roots_line.as_str(), "let profile;", "try { profile = ctx.get('profileContext')?.name; } catch {}", "if (profile === undefined && process.argv.includes('headless')) profile = 'headless';", "let id;", "if (profile === 'headless') for (let d = path.resolve(process.cwd()), p; d !== p; p = d, d = path.dirname(d)) {", " const key = d.toLowerCase().replace(/\\\\+$/, '');", " if (Object.prototype.hasOwnProperty.call(roots, key)) { id = roots[key] || undefined; break; }", "}", ]; let global_url = format!("http://127.0.0.1:{port}/mcp"); let url_return = format!("return id ? '{global_url}/projects/' + encodeURIComponent(id) : '{global_url}';"); let shim_line = format!("const shim = path.join(base, '.lian-jing', 'bin', '{MCP_HEADER_SHIM_NAME}');"); let header_body = [ "if (!id) return {};", "const base = process.env.USERPROFILE, sys = process.env.SystemRoot;", "if (!base || !path.isAbsolute(base) || !sys || !path.isAbsolute(sys)) return {};", shim_line.as_str(), "const cmd = path.join(sys, 'System32', 'cmd.exe');", "const crypto = process.getBuiltinModule('crypto'), cp = process.getBuiltinModule('child_process');", "const digest = crypto.createHash('sha256').update(id).digest('hex');", "// Node 拒绝直接执行 .cmd(EINVAL),须经 cmd /d /s /c 原样传参。", "const out = cp.execFileSync(cmd, ['/d', '/s', '/c', '\"\"' + shim + '\" ' + digest + '\"'],", " { encoding: 'utf8', timeout: 15000, windowsHide: true, windowsVerbatimArguments: true, stdio: ['ignore', 'pipe', 'ignore'] });", "return { Authorization: String(JSON.parse(out).Authorization) };", ]; let mut lines: Vec = vec![ DSH_MANAGED_START.to_string(), "# 炼境托管:按 DSH 启动目录匹配已登记项目根目录,连项目专用地址并携带认证;未命中回退全局地址。勿手改,会被炼境覆盖。".into(), "- insert:".into(), " - id: mcp-lian-jing".into(), " name: '@deepseek-ai/dsh-mcp-client'".into(), " config:".into(), " transport: streamable-http".into(), format!(" serverName: {MCP_KEY}"), " url: !!js |".into(), " (() => {".into(), ]; lines.extend(indent(12, &find_project_id)); lines.extend(indent(12, &[url_return.as_str()])); lines.extend(indent(10, &["})()"])); lines.extend(indent(8, &["headers: !!js |"])); lines.extend(indent(10, &["(() => {"])); lines.extend(indent(12, &["try {"])); lines.extend(indent(14, &find_project_id)); lines.extend(indent(14, &header_body)); lines.extend(indent(12, &["} catch (e) {"])); lines.extend(indent( 14, &[ "process.stderr.write('[lian-jing] mcp header helper failed: status=' + (e.status ?? '') + ' code=' + (e.code ?? '') + '\\n');", "return {};", ], )); lines.extend(indent(12, &["}"])); lines.extend(indent(10, &["})()"])); if let Some(session) = session { // 路径与白名单都以 JSON 写入:JSON 字符串即合法的 YAML 双引号标量,用户名里的引号、空格等无需另行转义 lines.push(format!(" - id: {DSH_SESSION_PLUGIN_ID}")); lines.push(format!(" name: {}", json!(session.module))); // 插件文件被删或移走时不加载该条目(DSH 0.1.x 加载缺失的插件文件会启动失败),只留全局行 let exists_guard = format!( "!process.getBuiltinModule('fs').existsSync({})", json!(session.module) ); lines.push(format!(" disabled: !!js {}", json!(exists_guard))); lines.push(" config:".into()); lines.push(format!(" port: {port}")); lines.push(format!(" shim: {}", json!(session.shim))); lines.push(format!(" roots: {roots_json}")); } lines.push(DSH_MANAGED_END.to_string()); let mut block = lines.join("\n"); block.push('\n'); block } /// 用 YAML 解析判断托管块之外是否已有 lian-jing 条目(id 或 serverName,任意引号/注释/流式写法)。 /// 解析失败时保守拒绝写入,不冒险追加。 fn has_manual_lian_jing(outside_block: &str) -> Result { if outside_block.trim().is_empty() { return Ok(false); } let doc: serde_yaml::Value = serde_yaml::from_str(outside_block) .map_err(|error| format!("DSH 补丁文件无法解析,炼境不写入: {error}"))?; let Some(items) = doc.as_sequence() else { return Ok(false); }; Ok(items.iter().any(|item| { item.get("insert") .and_then(serde_yaml::Value::as_sequence) .is_some_and(|entries| { entries.iter().any(|entry| { entry .get("id") .and_then(serde_yaml::Value::as_str) .is_some_and(|id| { id == "mcp-lian-jing" || id.starts_with(DSH_SESSION_PLUGIN_ID_PREFIX) }) || entry .get("config") .and_then(|config| config.get("serverName")) .and_then(serde_yaml::Value::as_str) == Some(MCP_KEY) }) }) })) } /// 纯函数:把托管块合并进 DSH 补丁文件内容。已有托管块则原位替换; /// 块外存在手写的 lian-jing 条目则拒绝覆盖(与 Codex 手工同名 server 的处理一致)。 fn upsert_dsh_block(current: &str, block: &str) -> Result { const MANUAL: &str = DSH_MANUAL_MESSAGE; match ( current.find(DSH_MANAGED_START), current.find(DSH_MANAGED_END), ) { (Some(start), Some(end)) if end >= start => { let after = ¤t[end + DSH_MANAGED_END.len()..]; if has_manual_lian_jing(&format!("{}{}", ¤t[..start], after))? { return Err(MANUAL.into()); } Ok(format!( "{}{}{}", ¤t[..start], block.trim_end_matches('\n'), after )) } (Some(_), _) | (_, Some(_)) => Err("DSH 补丁文件中的炼境托管块不完整或顺序错误".into()), (None, None) if has_manual_lian_jing(current)? => Err(MANUAL.into()), (None, None) if current.trim().is_empty() => Ok(block.to_string()), (None, None) => Ok(format!("{}\n{}", current.trim_end(), block)), } } const DSH_MANUAL_MESSAGE: &str = "DSH 补丁文件已手工定义 lian-jing,炼境不会覆盖;移除手写条目后重试"; /// DSH 配置目录:DSH_HOME 优先,否则 %USERPROFILE%\.dsh。 fn dsh_home() -> Result { std::env::var("DSH_HOME").map(PathBuf::from).or_else(|_| { std::env::var("USERPROFILE") .map(|home| PathBuf::from(home).join(".dsh")) .map_err(|_| "USERPROFILE 未设置".to_string()) }) } /// 未安装 DSH(无 ~/.dsh)时记 not_installed;块外手写 lian-jing 记 manual_kept;内容不变记 converged。 /// exclude 用于删除项目时提前剔除白名单。 pub fn ensure_dsh_patch( conn: &rusqlite::Connection, port: u16, exclude: Option<&str>, ) -> Result { let home = dsh_home()?; if !home.is_dir() { return Ok(ClientOutcome::NotInstalled); } // 插件文件写失败时托管块只保留全局行(headless 仍可用),不阻断整体刷新 let session = ensure_dsh_session_plugin() .map_err(|error| { db::log_event( CLIENT_SYNC_LOG, "warn", &format!("{error};DSH 桌面版暂不能按工作区连项目地址"), Some(&json!({ "client": "dsh" }).to_string()), ) }) .ok(); ensure_dsh_patch_at( conn, &crate::credential_store::WindowsCredentialStore, port, exclude, &home, session.as_ref(), ) } /// ensure_dsh_patch 的依赖注入版:DSH 目录、凭据存储与会话插件引用由调用方给出(测试不碰用户 ~/.dsh)。 fn ensure_dsh_patch_at( conn: &rusqlite::Connection, store: &impl crate::credential_store::CredentialStore, port: u16, exclude: Option<&str>, home: &std::path::Path, session: Option<&DshSessionPlugin>, ) -> Result { if !home.is_dir() { return Ok(ClientOutcome::NotInstalled); } let path = home.join("cordis.patch.yml"); let current = match std::fs::read_to_string(&path) { Ok(value) => value, Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), Err(error) => return Err(format!("读取 DSH 补丁文件失败: {error}")), }; let block = render_dsh_block(port, &dsh_project_roots(conn, store, exclude), session); let next = match upsert_dsh_block(¤t, &block) { Ok(next) => next, Err(error) if error == DSH_MANUAL_MESSAGE => return Ok(ClientOutcome::ManualKept), Err(error) => return Err(error), }; if next == current { return Ok(ClientOutcome::Converged); } std::fs::write(&path, next).map_err(|error| format!("写入 DSH 补丁文件失败: {error}"))?; Ok(ClientOutcome::Applied) } /// 只读判定 DSH 托管块是否已把本项目根目录映射到本项目、且指向当前端口(不写文件)。 fn dsh_static_outcome( patch: &str, root_keys: &[String], project_id: &str, port: u16, ) -> ClientOutcome { let (Some(start), Some(end)) = (patch.find(DSH_MANAGED_START), patch.find(DSH_MANAGED_END)) else { return match has_manual_lian_jing(patch) { Ok(true) => ClientOutcome::ManualKept, Ok(false) => ClientOutcome::Missing, Err(reason) => ClientOutcome::Unavailable { reason }, }; }; if end < start { // apply 遇到同样的标记错序会报 failed;静态判定如实标需人工 return ClientOutcome::NeedsHuman { reason: "DSH 补丁文件中的炼境托管块不完整或顺序错误".into(), }; } // 块外手写 lian-jing 时 apply 永远保留手写、不更新块:静态判定与之同口径 let outside = format!( "{}{}", &patch[..start], &patch[end + DSH_MANAGED_END.len()..] ); match has_manual_lian_jing(&outside) { Ok(true) => return ClientOutcome::ManualKept, Ok(false) => {} Err(reason) => return ClientOutcome::Unavailable { reason }, } let block = &patch[start..end]; if !block.contains(&format!("http://127.0.0.1:{port}/mcp")) { return ClientOutcome::Missing; } let roots: Option> = block.lines().find_map(|line| { let json = line .trim() .strip_prefix("const roots = ")? .strip_suffix(';')?; serde_json::from_str(json).ok() }); let mapped = roots.is_some_and(|roots| { root_keys .iter() .any(|key| roots.get(key).and_then(Value::as_str) == Some(project_id)) }); if mapped { ClientOutcome::Converged } else { ClientOutcome::Missing } } /// DSH 补丁文件的读-算-写在本进程内串行(启动线程与注入/移除可能同时刷新)。 static DSH_PATCH_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); /// 刷新 DSH 托管块的 best-effort 包装:失败记日志并返回 failed,不中断调用方。 pub fn refresh_dsh_patch(exclude: Option<&str>) -> ClientOutcome { let _guard = DSH_PATCH_LOCK .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); let result = db::pool() .get() .map_err(|error| error.to_string()) .and_then(|conn| ensure_dsh_patch(&conn, crate::mcp_server::read_port(), exclude)); let log_warn = |error: &str| { db::log_event( CLIENT_SYNC_LOG, "warn", &format!("DSH 托管块刷新失败: {error}"), Some(&json!({ "client": "dsh" }).to_string()), ) }; match result { Ok(ClientOutcome::ManualKept) => { // 原为报错路径:手写条目保留后仍按原文案留痕 log_warn(DSH_MANUAL_MESSAGE); ClientOutcome::ManualKept } Ok(outcome) => outcome, Err(error) => { log_warn(&error); ClientOutcome::Failed { reason: error } } } } /// 清理旧组级全局注册(lian-jing-{gid},含完整 UUID 与短 8 位两种历史命名)。 /// 幂等 best-effort:不存在的名字 remove 失败仅忽略。返回成功移除的名字列表。 pub fn cleanup_legacy_global_registrations() -> Vec { let Some(claude_bin) = find_claude_bin() else { db::log_event( "mcp_inject", "warn", "claude CLI 未找到,跳过旧注册清理", None, ); return Vec::new(); }; let Some(git_bash) = find_git_bash() else { db::log_event( "mcp_inject", "warn", "git-bash 未找到,跳过旧注册清理", None, ); return Vec::new(); }; let Ok(conn) = db::pool().get() else { return Vec::new(); }; let group_ids: Vec = conn .prepare("SELECT id FROM product_groups") .and_then(|mut stmt| { stmt.query_map([], |r| r.get::<_, String>(0)) .map(|iter| iter.filter_map(|r| r.ok()).collect()) }) .unwrap_or_default(); let mut removed = Vec::new(); for gid in &group_ids { let mut names = vec![format!("lian-jing-{}", gid)]; if gid.len() > 8 { names.push(format!("lian-jing-{}", &gid[..8])); } for name in names { let ok = crate::silent_cmd(&claude_bin) .args(["mcp", "remove", "-s", "user", &name]) .env("CLAUDE_CODE_GIT_BASH_PATH", &git_bash) .output() .map(|out| out.status.success()) .unwrap_or(false); if ok { db::log_event( "mcp_inject", "info", "旧组级 MCP 注册已移除", Some(&json!({ "client": "claude-global", "server": &name }).to_string()), ); removed.push(name); } } } removed } // ── JSON 读写 ───────────────────────────────────────────────────────────────── fn read_json(path: &PathBuf) -> Result { if !path.exists() { return Ok(json!({})); } let content = std::fs::read_to_string(path).map_err(|e| format!("读取 settings.json 失败: {}", e))?; if content.trim().is_empty() { return Ok(json!({})); } serde_json::from_str(&content).map_err(|e| { format!( "settings.json 格式错误,请手动修复后重试({}): {}", path.display(), e ) }) } fn write_json(path: &PathBuf, value: &Value) -> Result<(), String> { write_json_with(path, value, "settings.json", &crate::DiskSink) } fn render_codex_block(server_url: &str, header_helper: &str) -> Result { let helper_literal = serde_json::to_string(header_helper) .map_err(|_| "MCP helper 配置序列化失败".to_string())?; Ok(format!( "{CODEX_MCP_MANAGED_START}\n[mcp_servers.{MCP_KEY}]\nurl = \"{server_url}\"\nhttp_headers_helper = {helper_literal}\n{CODEX_MCP_MANAGED_END}\n" )) } fn has_manual_codex_entry(text: &str) -> bool { probe_l1::codex_has_table(text) } /// 只读判定项目 .codex/config.toml 的托管块是否与期望一致(行尾差异忽略)。项目是否受 Codex 信任不在此判定 /// ——Codex 只在目录受信任时加载项目配置(P4-0 H-P8-1),由 L1 的 `codex mcp get` 自省给出。 fn codex_static_outcome( current: Option<&str>, server_url: &str, header_helper: &str, ) -> ClientOutcome { let Some(current) = current else { return ClientOutcome::Missing; }; let normalized = current.replace("\r\n", "\n"); let outside_block = match ( normalized.find(CODEX_MCP_MANAGED_START), normalized.find(CODEX_MCP_MANAGED_END), ) { (Some(start), Some(end)) if end >= start => format!( "{}{}", &normalized[..start], &normalized[end + CODEX_MCP_MANAGED_END.len()..] ), (None, None) => normalized.clone(), _ => { return ClientOutcome::NeedsHuman { reason: "炼境 Codex 管理块不完整或顺序错误".into(), } } }; let manual = has_manual_codex_entry(&outside_block); let has_block = normalized.contains(CODEX_MCP_MANAGED_START); match render_codex_block(server_url, header_helper) { // 托管块与块外手写同名表并存:Codex 加载配置会因重复表失败 _ if has_block && manual => ClientOutcome::NeedsHuman { reason: format!("项目 .codex/config.toml 托管块外另有手写 [mcp_servers.{MCP_KEY}],重复定义会使 Codex 配置加载失败"), }, Ok(block) if normalized.contains(&block) => ClientOutcome::Converged, _ if manual => ClientOutcome::ManualKept, _ => ClientOutcome::Missing, } } /// 写入或原位更新 Codex 托管块。内容不变记 converged;块外已手工定义同名 server 记 manual_kept(不覆盖)。 fn upsert_codex_mcp_config( path: &PathBuf, server_url: &str, header_helper: &str, ) -> Result { upsert_codex_mcp_config_with(path, server_url, header_helper, &crate::DiskSink) } pub(crate) fn upsert_codex_mcp_config_with( path: &std::path::Path, server_url: &str, header_helper: &str, sink: &dyn crate::WriteSink, ) -> Result { let block = render_codex_block(server_url, header_helper)?; let current = sink .read(path) .map_err(|error| format!("读取 Codex config.toml 失败: {error}"))? .unwrap_or_default(); let (start, end) = ( current.find(CODEX_MCP_MANAGED_START), current.find(CODEX_MCP_MANAGED_END), ); let next = match (start, end) { (Some(start), Some(end)) if end >= start => { // 块自带结尾换行:吸收 END 之后原有的空行再接上块外内容,否则每次注入都多一个空行 let tail = current[end + CODEX_MCP_MANAGED_END.len()..].trim_start_matches(['\r', '\n']); let separator = if tail.is_empty() { "" } else { "\n" }; format!("{}{block}{separator}{tail}", ¤t[..start]) } (Some(_), _) | (_, Some(_)) => { return Err(format!( "{} 的炼境 Codex 管理块不完整或顺序错误,请修复标记后重试", path.display() )) } // 已手工定义同名 server:炼境不覆盖(原为整体报错,会连带阻断 Claude 与 DSH 的接入) (None, None) if has_manual_codex_entry(¤t) => return Ok(ClientOutcome::ManualKept), (None, None) if current.trim().is_empty() => block, (None, None) => format!("{}\n{}", current.trim_end(), block), }; sink.write(path, &next) .map(|written| { if written { ClientOutcome::Applied } else { ClientOutcome::Converged } }) .map_err(|error| format!("写入 Codex config.toml 失败: {error}")) } /// 条目 URL 指向别的炼境项目路由:同路径重复登记时,删除一条登记不得连带清掉另一条的接入。 /// 不是炼境项目路由的旧地址(全局 /mcp、组级地址)视为本项目遗留,照旧清理。 fn owned_by_other_project(url: &str, project_id: &str) -> bool { lianjing_project_of(url).is_some_and(|owner| owner != project_id) } fn remove_codex_mcp_config(path: &PathBuf, project_id: &str) -> Result<(), String> { let current = match std::fs::read_to_string(path) { Ok(value) => value, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), Err(error) => return Err(format!("读取 Codex config.toml 失败: {error}")), }; let (Some(start), Some(end)) = ( current.find(CODEX_MCP_MANAGED_START), current.find(CODEX_MCP_MANAGED_END), ) else { return Ok(()); }; if end < start { return Err(format!("{} 的炼境 Codex 管理块不完整", path.display())); } let block_url = current[start..end] .lines() .find_map(|line| line.trim().strip_prefix("url = \"")?.strip_suffix('"')); if block_url.is_some_and(|url| owned_by_other_project(url, project_id)) { return Ok(()); } let next = format!( "{}{}", ¤t[..start], ¤t[end + CODEX_MCP_MANAGED_END.len()..] ) .trim_matches(['\r', '\n']) .to_string(); let output = if next.is_empty() { String::new() } else { format!("{next}\n") }; std::fs::write(path, output).map_err(|error| format!("移除 Codex MCP 配置失败: {error}")) } // ── 公开接口 ────────────────────────────────────────────────────────────────── /// 向项目注入单一 lian-jing MCP 配置(canonical context、Codex 托管块、Claude 兼容投影与 local 条目、 /// DSH 白名单),并清理旧组级 key(迁移内建于重注入)。只有 Codex 托管块写入失败算整体失败(沿用旧语义), /// Claude 与 DSH 一直是 best-effort;需要逐端结果时用 inject_project_mcp_report。 pub fn inject_project_mcp(project_id: &str) -> Result<(), String> { inject_project_mcp_report(project_id).and_then(|reports| match hard_failure(&reports) { Some(error) => Err(error), None => Ok(()), }) } /// 注入并返回三端逐端结果(agent-client-parity P8b)。 pub fn inject_project_mcp_report(project_id: &str) -> Result, String> { let _guard = mcp_op_lock(); inject_project_mcp_locked(project_id, true) } /// 项目文件里的 Codex 托管块写入失败沿用旧语义作为整体失败。 fn hard_failure(reports: &[ClientReport]) -> Option { reports .iter() .find_map(|report| match (report.client, &report.outcome) { (ClientId::Codex, ClientOutcome::Failed { reason }) => Some(reason.clone()), _ => None, }) } /// 调用方须持有 mcp_op_lock。refresh_dsh=false 供批量重注入使用:逐项目跳过 DSH 刷新, /// 由调用方在末尾统一刷新一次。项目身份与兼容投影写失败整体报错;三端客户端配置经适配器注册表逐端收敛, /// 一端失败不中断其他端。 fn inject_project_mcp_locked( project_id: &str, refresh_dsh: bool, ) -> Result, String> { let port = crate::mcp_server::read_port(); let server_url = crate::mcp_server::scoped_mcp_url(port, project_id); let digest = crate::credential_store::mcp_auth_digest(project_id)?; crate::credential_store::get_or_create_mcp_auth_token( &crate::credential_store::WindowsCredentialStore, project_id, )?; ensure_header_shim()?; let header_helper = shim_helper_command(&digest); let root = project_root(project_id)?; // 保留 Claude 历史上下文作为兼容投影;项目身份以 .lian-jing/context.json 为准。 write_project_identity_files_with(&root, project_id, port, &server_url, &crate::DiskSink)?; let facts = project_facts(project_id, &root); let desired = DesiredMcp { port, server_url, header_helper, refresh_dsh, }; let reports: Vec = crate::onboard::clients::registry() .iter() .map(|adapter| ClientReport::new(adapter.id(), adapter.apply(&facts, &desired))) .collect(); // 诊断按 project_id 取最后一条 mcp_inject 事件:成功日志须在三端收敛之后、且只在没有整体失败时写 let ctx = json!({ "project_id": project_id }).to_string(); match hard_failure(&reports) { None => log_event("mcp_inject", "info", "MCP 配置注入成功", Some(&ctx)), Some(error) => log_event( "mcp_inject", "warn", &format!("注入失败: {error}"), Some(&ctx), ), } Ok(reports) } fn project_facts<'a>(project_id: &'a str, root: &'a std::path::Path) -> ProjectFacts<'a> { ProjectFacts { project_id, root, control_plane: project_wants_global_scope(root), wsl: crate::onboard::clients::is_wsl_root(root), } } /// 只读:三端对本项目的配置状态(verify_onboarding 的 clients 字段)。不写文件、不起子进程。 /// 根目录与注入同源(project_root 校验登记路径),避免两边对同一项目算出不同的键。 pub fn client_static_status(project_id: &str) -> Vec { let port = crate::mcp_server::read_port(); let resolved = crate::credential_store::mcp_auth_digest(project_id) .and_then(|digest| project_root(project_id).map(|root| (digest, root))); let (digest, root) = match resolved { Ok(resolved) => resolved, Err(reason) => { return crate::onboard::clients::registry() .iter() .map(|adapter| { ClientReport::new( adapter.id(), ClientOutcome::Unavailable { reason: reason.clone(), }, ) }) .collect() } }; let header_helper = shim_helper_command(&digest); let facts = project_facts(project_id, &root); let desired = DesiredMcp { port, server_url: crate::mcp_server::scoped_mcp_url(port, project_id), header_helper, refresh_dsh: false, }; crate::onboard::clients::registry() .iter() .map(|adapter| ClientReport::new(adapter.id(), adapter.static_status(&facts, &desired))) .collect() } // ── 一键接入计划(P8c):本机侧预计动作 + 项目文件演算 ────────────────────────── /// 一键接入写入前计划(只读:不写文件、不起客户端 CLI;混合文件预警会起只读的 git status /// --no-optional-locks 与 git show,Codex 信任预判会起只读的 git rev-parse)。P8e 接入 MCP / Tauri 投影前暂无生产调用方。 #[allow(dead_code)] pub(crate) fn plan_onboarding( project_id: &str, ) -> Result { let root = project_root(project_id)?; let port = crate::mcp_server::read_port(); let digest = crate::credential_store::mcp_auth_digest(project_id)?; let server_url = crate::mcp_server::scoped_mcp_url(port, project_id); let header_helper = shim_helper_command(&digest); let project = crate::onboard::plan::ProjectWrite { project_id, port, server_url: &server_url, header_helper: &header_helper, }; Ok(crate::onboard::plan::plan_onboarding( &root, &project, plan_machine_steps(project_id), codex_trust(&root), )) } /// 本机侧(不进仓库)的预计动作:项目 token、稳定 helper 入口、三端客户端配置(只读判定)。 /// Codex 信任不在此列(不影响炼境写入,见 codex_trust)。 #[allow(dead_code)] fn plan_machine_steps(project_id: &str) -> Vec { let mut steps = vec![ token_step(crate::credential_store::mcp_auth_token_state( &crate::credential_store::WindowsCredentialStore, project_id, )), shim_step(), ]; for report in client_static_status(project_id) { let name = match report.client { ClientId::Claude => "claude", ClientId::Codex => "codex", ClientId::Dsh => "dsh", }; steps.push(crate::onboard::plan::MachineStep { step: name.into(), status: outcome_status(&report.outcome).into(), detail: report.outcome.describe(), }); } steps } /// token 步的预计动作,与 apply 时 get_or_create_mcp_auth_token 的分支一一对应:只有「未配置」会生成; /// 存储不可用或 token 损坏时 apply 报 needs_repair、不会重新生成,计划如实记 needs_human(阻断确认)。 /// 「已接入」判据仍是 is_project_injected,这里不复用它——它把「未配置」与 needs_repair 合成了一个 false。 #[allow(dead_code)] fn token_step( state: crate::credential_store::McpAuthTokenState, ) -> crate::onboard::plan::MachineStep { use crate::credential_store::McpAuthTokenState; let (status, detail) = match state { McpAuthTokenState::Present => ("noop", "项目 MCP 认证 token 已在本机受保护存储".to_string()), McpAuthTokenState::Missing => ( "apply", "将在本机受保护存储生成项目 MCP 认证 token".to_string(), ), McpAuthTokenState::NeedsRepair(reason) => ( "needs_human", format!("{reason};apply 的 token 步会失败(不会重新生成),须先修复本机受保护存储中的项目 token"), ), }; crate::onboard::plan::MachineStep { step: "token".into(), status: status.into(), detail, } } #[allow(dead_code)] fn shim_step() -> crate::onboard::plan::MachineStep { let (status, detail) = match (helper_bin_dir(), std::env::current_exe()) { (Ok(dir), Ok(exe)) => { let path = dir.join(MCP_HEADER_SHIM_NAME); let expected = render_header_shim(&exe); if std::fs::read_to_string(&path).ok().as_deref() == Some(expected.as_str()) { ("noop", format!("稳定入口已是最新:{}", path.display())) } else { ("apply", format!("将写入稳定入口:{}", path.display())) } } (Err(error), _) => ("unavailable", error), (_, Err(_)) => ("unavailable", "无法定位炼境可执行文件".into()), }; crate::onboard::plan::MachineStep { step: "shim".into(), status: status.into(), detail, } } /// 只读判定结果 → 计划里的本机动作状态。 #[allow(dead_code)] fn outcome_status(outcome: &ClientOutcome) -> &'static str { match outcome { ClientOutcome::Converged => "noop", ClientOutcome::Missing | ClientOutcome::Applied | ClientOutcome::Refreshed | ClientOutcome::Removed | ClientOutcome::Deferred => "apply", ClientOutcome::NotInstalled => "not_installed", ClientOutcome::NotApplicable { .. } => "not_applicable", ClientOutcome::ManualKept => "manual_kept", ClientOutcome::OtherProject { .. } => "other_project", ClientOutcome::NeedsHuman { .. } => "needs_human", ClientOutcome::Unavailable { .. } | ClientOutcome::Failed { .. } => "unavailable", } } /// Codex 配置目录(CODEX_HOME 或 ~/.codex)。 fn codex_home() -> Option { std::env::var_os("CODEX_HOME") .map(PathBuf::from) .or_else(|| std::env::var_os("USERPROFILE").map(|home| PathBuf::from(home).join(".codex"))) } /// Codex 是否信任项目目录(只读预判,P4-0 H-P8-1:未信任目录不加载项目 .codex/config.toml)。 /// 候选按 Codex 的判定方式取:项目根目录本身,再取 git 主仓库根(worktree 由此继承主仓库的信任, /// 仓库子目录取所在仓库根);任一在 ~/.codex/config.toml 的 [projects] 表里 trust_level = "trusted" 即已信任。 /// 不逐级查祖先目录(P8c 审查:Codex 不这样判定)。权威结论以 P4a 的 `codex mcp get --json` 自省为准。 #[allow(dead_code)] fn codex_trust(root: &std::path::Path) -> crate::onboard::plan::CodexTrust { use crate::onboard::plan::CodexTrust; let Some(home) = codex_home().filter(|dir| dir.is_dir()) else { return CodexTrust::NotInstalled; }; let config = match std::fs::read_to_string(home.join("config.toml")) { Ok(text) => text, // 装了 Codex 但还没有用户配置:没有任何目录受信任 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), Err(_) => return CodexTrust::Unknown, }; let mut candidates = vec![root.to_path_buf()]; candidates.extend(git_main_worktree_root(root)); if codex_config_trusts(&config, &candidates) { CodexTrust::Trusted } else { CodexTrust::Untrusted } } /// git 主仓库根:`git rev-parse --git-common-dir` 的上级目录(普通仓库为仓库根,worktree 为主仓库根, /// 仓库子目录为所在仓库根)。git 不可用或不在 git 仓库内返回 None。 #[allow(dead_code)] fn git_main_worktree_root(root: &std::path::Path) -> Option { let output = crate::silent_cmd("git") .args(["rev-parse", "--path-format=absolute", "--git-common-dir"]) .current_dir(root) .output() .ok()?; if !output.status.success() { return None; } let common = String::from_utf8(output.stdout).ok()?; PathBuf::from(common.trim()).parent().map(PathBuf::from) } #[allow(dead_code)] fn codex_config_trusts(config: &str, candidates: &[PathBuf]) -> bool { let trusted = codex_trusted_projects(config); candidates .iter() .any(|candidate| trusted.contains(&normalize_trust_path(&candidate.to_string_lossy()))) } /// Windows 路径比较口径:去 \\?\ 前缀、统一反斜杠、去尾部分隔符、不区分大小写。 fn normalize_trust_path(path: &str) -> String { path.strip_prefix(r"\\?\") .unwrap_or(path) .replace('/', "\\") .trim_end_matches('\\') .to_lowercase() } /// ~/.codex/config.toml 中 trust_level = "trusted" 的项目路径(已归一化)。不引入 TOML 依赖的最小解析: /// 认 `[projects.'<路径>']`(字面量字符串)与 `[projects."<路径>"]`(基本字符串,按 TOML 规则反转义)段, /// 以及 `[projects]` 段内的内联表 `'<路径>' = { trust_level = "trusted" }` 与点号键写法。 /// 键里有非法转义(TOML 本身不合法,Codex 也加载不了)时忽略该项。 fn codex_trusted_projects(config: &str) -> Vec { // None:不在 projects 段;Some(None):[projects];Some(Some(key)):[projects.] let mut section: Option> = None; let mut trusted = Vec::new(); for line in config.lines().map(str::trim) { if line.starts_with('[') { section = parse_projects_header(line); continue; } match §ion { Some(Some(key)) if is_trusted_assignment(line) => { trusted.push(normalize_trust_path(key)); } Some(None) => { let Some((key, rest)) = parse_toml_key(line) else { continue; }; let rest = rest.trim_start(); let here = if let Some(dotted) = rest.strip_prefix('.') { is_trusted_assignment(dotted.trim_start()) } else if let Some(value) = rest.strip_prefix('=') { value .trim() .strip_prefix('{') .and_then(|inner| inner.split('}').next()) .is_some_and(|inner| { inner .split(',') .any(|pair| is_trusted_assignment(pair.trim())) }) } else { false }; if here { trusted.push(normalize_trust_path(&key)); } } _ => {} } } trusted } /// `[projects]` → Some(None);`[projects.<键>]` → Some(Some(键));其他段(含数组表)→ None。 fn parse_projects_header(line: &str) -> Option> { let inner = line.strip_prefix('[')?.trim_start(); if inner.starts_with('[') { return None; } let rest = inner.strip_prefix("projects")?.trim_start(); if rest.starts_with(']') { return Some(None); } let (key, after) = parse_toml_key(rest.strip_prefix('.')?.trim_start())?; after.trim_start().starts_with(']').then_some(Some(key)) } /// `trust_level = "trusted"`(允许单引号与行尾注释)。 fn is_trusted_assignment(text: &str) -> bool { text.split_once('=').is_some_and(|(name, value)| { let value = value.trim_start(); name.trim() == "trust_level" && (value.starts_with("\"trusted\"") || value.starts_with("'trusted'")) }) } /// 解析行首的一个 TOML 键(字面量字符串、基本字符串或裸键),返回(键,剩余文本)。 /// 基本字符串按 TOML 规则反转义;非法转义或未闭合返回 None。 fn parse_toml_key(text: &str) -> Option<(String, &str)> { if let Some(rest) = text.strip_prefix('\'') { let end = rest.find('\'')?; return Some((rest[..end].to_string(), &rest[end + 1..])); } if let Some(rest) = text.strip_prefix('"') { let mut key = String::new(); let mut chars = rest.char_indices(); while let Some((index, ch)) = chars.next() { match ch { '"' => return Some((key, &rest[index + 1..])), '\\' => { let (_, escaped) = chars.next()?; let decoded = match escaped { '\\' => '\\', '"' => '"', 'b' => '\u{8}', 't' => '\t', 'n' => '\n', 'f' => '\u{c}', 'r' => '\r', 'e' => '\u{1b}', 'u' | 'U' => { let len = if escaped == 'u' { 4 } else { 8 }; let hex = (0..len) .map(|_| chars.next().map(|(_, c)| c)) .collect::>()?; char::from_u32(u32::from_str_radix(&hex, 16).ok()?)? } _ => return None, }; key.push(decoded); } _ => key.push(ch), } } return None; } let end = text .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '-')) .unwrap_or(text.len()); (end > 0).then(|| (text[..end].to_string(), &text[end..])) } // ── 三端客户端适配器(注册表见 onboard::clients::registry)────────────────────── pub(crate) struct ClaudeAdapter; pub(crate) struct CodexAdapter; pub(crate) struct DshAdapter; pub(crate) static CLAUDE_ADAPTER: ClaudeAdapter = ClaudeAdapter; pub(crate) static CODEX_ADAPTER: CodexAdapter = CodexAdapter; pub(crate) static DSH_ADAPTER: DshAdapter = DshAdapter; const NA_CONTROL_PLANE: ClientOutcome = ClientOutcome::NotApplicable { reason: NotApplicableReason::ControlPlane, }; const NA_WSL: ClientOutcome = ClientOutcome::NotApplicable { reason: NotApplicableReason::Wsl, }; impl ClientAdapter for ClaudeAdapter { fn id(&self) -> ClientId { ClientId::Claude } /// ~/.claude.json(或 CLAUDE_CONFIG_DIR 下的配置)存在即视为已装;不起子进程。 fn detect(&self) -> bool { claude_config_path().is_some_and(|path| path.is_file()) } fn static_status(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome { if project.wsl { return NA_WSL; } if !self.detect() { return ClientOutcome::NotInstalled; } let Some(config) = read_claude_config() else { return ClientOutcome::Unavailable { reason: "无法读取 Claude 全局配置".into(), }; }; if project.control_plane { return claude_control_plane_outcome(&config, project.project_id, project.root); } let entry = claude_local_server_entry(&desired.server_url, &desired.header_helper); claude_static_outcome(&config, project.project_id, project.root, &entry) } /// 同步注册到 ~/.claude.json:user 级全局注册作兜底,local 级项目条目优先生效(均 best-effort)。 /// 项目 .claude/settings.json 的 mcpServers 不被 Claude Code 读取,仅保留作兼容。 /// 控制面项目(mcp_scope=global)不写 local 条目,并清理炼境曾写入的旧条目,保留全局与跨项目工具。 fn apply(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome { try_register_globally(desired.port); if project.wsl { return NA_WSL; } // 未装 Claude(无全局配置、也找不到 CLI)与 DSH 未装同口径记 not_installed,不计失败 if !self.detect() && find_claude_bin().is_none() { return ClientOutcome::NotInstalled; } let wanted = (!project.control_plane) .then(|| (desired.server_url.clone(), desired.header_helper.clone())); finalize_for_project( project, sync_claude_local(project.project_id, project.root, wanted), ) } /// 生效条目以 claude mcp get 的回答为准(L1 执行时取),这里只给出可回查 headersHelper 的候选条目。 fn l1_facts(&self, project: &ProjectFacts, desired: &DesiredMcp) -> L1ClientFacts { let not_applicable = l1_not_applicable(project); let entries = match not_applicable { Some(_) => Vec::new(), None => { let mcp_json = read_optional(&project.root.join(".mcp.json")) .ok() .flatten() .and_then(|text| { serde_json::from_str::(text.trim_start_matches('\u{feff}')).ok() }); claude_l1_entries( &read_claude_config().unwrap_or(Value::Null), mcp_json.as_ref(), project.root, &desired.server_url, ) } }; L1ClientFacts { client: ClientId::Claude, not_applicable, rules: probe_l1::rules_finding(project.root, "CLAUDE.md", true), skill: probe_l1::skill_finding(project.root, ".claude/skills/lian-jing/SKILL.md"), source: L1Source::Claude { entries }, } } } /// Claude 与 DSH 的 L1 不适用情形(与各自 static_status 同口径):WSL 路径项目不支持;控制面项目走全局地址。 fn l1_not_applicable(project: &ProjectFacts) -> Option { if project.wsl { Some(NotApplicableReason::Wsl) } else if project.control_plane { Some(NotApplicableReason::ControlPlane) } else { None } } /// L1 回查 headersHelper 的候选条目:URL 等于本项目地址的 lian-jing 条目(含手写),按 URL 匹配、不复刻 /// Claude 的项目键推导(与 managed_claude_entries 同一原则:worktree 映射到主仓库根、NFC 归一化的键也能找到)。 /// 顺序:覆盖本根目录的项目键(最具体在前)→ 其余项目键 → 项目 .mcp.json(project 作用域)→ 顶层 mcpServers /// (user 作用域)。生效哪一条由 claude mcp get 报告的作用域决定,顺序只是兜底。 fn claude_l1_entries( config: &Value, mcp_json: Option<&Value>, root: &std::path::Path, url: &str, ) -> Vec { use probe_l1::{ClaudeEntry, ClaudeEntrySource}; let root_text = root.display().to_string(); let lian_jing = |servers: Option<&Value>| { servers .and_then(|servers| servers.get(MCP_KEY)) .filter(|entry| entry.get("url").and_then(Value::as_str) == Some(url)) .cloned() }; // (覆盖本根目录, 键长度) 降序:覆盖者在前、越具体越前 let mut local: Vec<((bool, usize), Value)> = config .get("projects") .and_then(Value::as_object) .map(|projects| { projects .iter() .filter_map(|(key, project)| { let entry = lian_jing(project.get("mcpServers"))?; let rank = ( key_covers_root(key, &root_text), normalize_root_key(key).len(), ); Some((rank, entry)) }) .collect() }) .unwrap_or_default(); local.sort_by(|(left, _), (right, _)| right.cmp(left)); local .into_iter() .map(|(_, entry)| ClaudeEntry { source: ClaudeEntrySource::Local, entry, }) .chain( lian_jing(mcp_json.and_then(|json| json.get("mcpServers"))).map(|entry| ClaudeEntry { source: ClaudeEntrySource::Project, entry, }), ) .chain( lian_jing(config.get("mcpServers")).map(|entry| ClaudeEntry { source: ClaudeEntrySource::User, entry, }), ) .collect() } /// 控制面不该有本项目的 local 托管条目:残留会遮蔽 user 级全局注册、丢掉跨项目工具(apply 会清理), /// 有残留报 missing,没有才是 not_applicable。 fn claude_control_plane_outcome( config: &Value, project_id: &str, root: &std::path::Path, ) -> ClientOutcome { let root_text = root.display().to_string(); let residual = managed_claude_entries(config, project_id) .iter() .any(|(key, _)| key_covers_root(key, &root_text)); if residual { ClientOutcome::Missing } else { NA_CONTROL_PLANE } } /// 机器级或按边界保留的成功结果,在控制面 / WSL 项目上报 not_applicable;未成功的结果原样保留。 fn finalize_for_project(project: &ProjectFacts, outcome: ClientOutcome) -> ClientOutcome { match (project.control_plane, project.wsl, outcome.is_settled()) { (true, _, true) => NA_CONTROL_PLANE, (_, true, true) => NA_WSL, _ => outcome, } } impl ClientAdapter for CodexAdapter { fn id(&self) -> ClientId { ClientId::Codex } /// Codex 配置目录(CODEX_HOME 或 ~/.codex)存在即视为已装;不起子进程。 fn detect(&self) -> bool { codex_home().is_some_and(|dir| dir.is_dir()) } fn static_status(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome { if !self.detect() { return ClientOutcome::NotInstalled; } let current = match read_optional(&project.root.join(".codex").join("config.toml")) { Ok(current) => current, Err(reason) => return ClientOutcome::Unavailable { reason }, }; codex_static_outcome( current.as_deref(), &desired.server_url, &desired.header_helper, ) } /// 项目 .codex/config.toml 随仓库分发:Codex 未装也照写,保证换客户端时即可用。 fn apply(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome { let path = project.root.join(".codex").join("config.toml"); let outcome = upsert_codex_mcp_config(&path, &desired.server_url, &desired.header_helper) .unwrap_or_else(|reason| ClientOutcome::Failed { reason }); if outcome == ClientOutcome::ManualKept { // 原为整体报错(调用方记日志);改为保留后仍留痕,诊断里看得到 db::log_event( CLIENT_SYNC_LOG, "warn", &format!( "{} 已手工定义 [mcp_servers.{MCP_KEY}],炼境不会覆盖", path.display() ), Some(&inject_log_context(project.project_id, "codex")), ); } outcome } /// 生效配置以 codex mcp get --json 为准(L1 执行时取);它把 helper 脱敏,helper 只能回查项目配置。 fn l1_facts(&self, project: &ProjectFacts, _desired: &DesiredMcp) -> L1ClientFacts { let config = read_optional(&project.root.join(".codex").join("config.toml")) .ok() .flatten() .unwrap_or_default(); L1ClientFacts { client: ClientId::Codex, // 控制面与 WSL 项目的 Codex 同样写项目地址(P8b),照常检查 not_applicable: None, rules: probe_l1::rules_finding(project.root, "AGENTS.md", false), skill: probe_l1::skill_finding(project.root, ".agents/skills/lian-jing/SKILL.md"), source: L1Source::Codex { project_entry: has_manual_codex_entry(&config), project_url: probe_l1::codex_table_url(&config), project_helper: probe_l1::codex_table_helper(&config), }, } } } /// 读取可选文件:不存在为 Ok(None),其他读取错误(权限、占用)为 Err——只读判定据此报 unavailable 而非 missing。 fn read_optional(path: &std::path::Path) -> Result, String> { match std::fs::read_to_string(path) { Ok(text) => Ok(Some(text)), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(error) => Err(format!("读取 {} 失败: {error}", path.display())), } } impl ClientAdapter for DshAdapter { fn id(&self) -> ClientId { ClientId::Dsh } fn detect(&self) -> bool { dsh_home().is_ok_and(|home| home.is_dir()) } fn static_status(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome { if project.control_plane { return NA_CONTROL_PLANE; } if project.wsl { return NA_WSL; } let Some(home) = dsh_home().ok().filter(|home| home.is_dir()) else { return ClientOutcome::NotInstalled; }; let patch = match read_optional(&home.join("cordis.patch.yml")) { Ok(patch) => patch.unwrap_or_default(), Err(reason) => return ClientOutcome::Unavailable { reason }, }; let key = normalize_dsh_root(&project.root.display().to_string()); let canonical = std::fs::canonicalize(project.root) .ok() .map(|path| normalize_dsh_root(&strip_verbatim_prefix(&path.display().to_string()))); let keys: Vec = std::iter::once(key).chain(canonical).collect(); dsh_static_outcome(&patch, &keys, project.project_id, desired.port) } /// DSH 托管块是机器级的:刷新一次即同步全部已接入项目的白名单。 fn apply(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome { if !desired.refresh_dsh { return ClientOutcome::Deferred; } finalize_for_project(project, refresh_dsh_patch(None)) } /// DSH 无零模型自省入口:白名单复用 static_status,另核对会话级插件;helper 按托管块 !!js 的方式执行。 fn l1_facts(&self, project: &ProjectFacts, desired: &DesiredMcp) -> L1ClientFacts { let not_applicable = l1_not_applicable(project); let (whitelist, session_plugin) = match not_applicable { Some(reason) => ( ClientOutcome::NotApplicable { reason }, StaticFinding { ok: true, detail: "不适用".into(), }, ), None => ( self.static_status(project, desired), dsh_session_plugin_finding(), ), }; L1ClientFacts { client: ClientId::Dsh, not_applicable, rules: probe_l1::rules_finding(project.root, "AGENTS.md", false), skill: probe_l1::skill_finding(project.root, ".agents/skills/lian-jing/SKILL.md"), source: L1Source::Dsh { whitelist, session_plugin, // 与托管块 !!js 同源:%USERPROFILE%\.lian-jing\bin 下的稳定入口 + sha256(project_id) helper: HelperCommand::DshShim { shim: helper_bin_dir() .map(|dir| dir.join(MCP_HEADER_SHIM_NAME)) .unwrap_or_else(|_| PathBuf::from(MCP_HEADER_SHIM_NAME)), digest: crate::credential_store::mcp_auth_digest(project.project_id) .unwrap_or_default(), }, }, } } } /// DSH 会话级插件(P9,桌面 GUI 按工作区连项目地址)的完整性:托管块引用了当前版本的插件条目, /// 且插件文件与炼境内置版本的 sha256 一致(忽略行尾)。只读。 fn dsh_session_plugin_finding() -> StaticFinding { use sha2::Digest; let hash = |text: &str| -> String { sha2::Sha256::digest(text.replace("\r\n", "\n").as_bytes()) .iter() .map(|byte| format!("{byte:02x}")) .collect() }; let patch = dsh_home() .ok() .and_then(|home| read_optional(&home.join("cordis.patch.yml")).ok().flatten()) .unwrap_or_default(); if !patch.contains(&format!("- id: {DSH_SESSION_PLUGIN_ID}")) { return StaticFinding { ok: false, detail: format!("DSH 托管块未引用会话级插件 {DSH_SESSION_PLUGIN_ID};重跑 inject_mcp"), }; } let expected = hash(DSH_SESSION_PLUGIN_SOURCE); let path = helper_bin_dir().ok().and_then(|bin| { bin.parent() .map(|dir| dir.join("dsh").join(DSH_SESSION_PLUGIN_FILE)) }); match path .as_ref() .map(|path| (path, std::fs::read_to_string(path))) { Some((_, Ok(content))) if hash(&content) == expected => StaticFinding { ok: true, detail: format!( "会话级插件 {DSH_SESSION_PLUGIN_FILE} 与炼境内置版本一致(sha256 {}…)", &expected[..12] ), }, Some((path, Ok(_))) => StaticFinding { ok: false, detail: format!( "会话级插件 {} 与炼境内置版本不一致;重启炼境或重跑 inject_mcp", path.display() ), }, _ => StaticFinding { ok: false, detail: "会话级插件文件缺失;重启炼境或重跑 inject_mcp".into(), }, } } // ── L1 零模型三端检查(P4a):本体入口与本机客户端定位 ───────────────────────── /// 三端对等 L1 零模型检查的本体入口(MCP probe_client_parity 与 Tauri command 共用,边界只在这里)。 /// 只读三端配置,起零模型的客户端自省进程,经稳定 shim 取认证头后在项目地址上重放(认证头不出本进程)。 pub fn probe_client_parity( project_id: &str, tier: Option<&str>, ) -> Result { match tier.unwrap_or("l1") { "l1" => {} other => { return Err(format!( "tier={other} 不支持:目前只有 l1(零模型检查);L3 无头会话由后续 start_parity_probe 提供" )) } } let root = project_root(project_id)?; probe_client_parity_at(project_id, &root, crate::mcp_server::read_port()) } /// probe_client_parity 去掉登记查询的部分(根目录与端口由调用方给出;实机冒烟测试直接调用)。 fn probe_client_parity_at( project_id: &str, root: &std::path::Path, port: u16, ) -> Result { let server_url = crate::mcp_server::scoped_mcp_url(port, project_id); let digest = crate::credential_store::mcp_auth_digest(project_id)?; let facts = project_facts(project_id, root); let desired = DesiredMcp { port, server_url: server_url.clone(), header_helper: shim_helper_command(&digest), refresh_dsh: false, }; let clients: Vec = crate::onboard::clients::registry() .iter() .map(|adapter| adapter.l1_facts(&facts, &desired)) .collect(); let expected_tools = crate::mcp_tools::tools_list_result_for_project(project_id)["tools"] .as_array() .map(|tools| { tools .iter() .filter_map(|tool| tool["name"].as_str().map(str::to_string)) .collect() }) .unwrap_or_default(); let project = probe_l1::L1Project { project_id, root, control_plane: facts.control_plane, expected_url: server_url, expected_tools, managed_helpers: vec![ shim_helper_command(&digest), legacy_shim_helper_command(&digest), ], }; let env = |name: &str| std::env::var(name).ok(); let deps = probe_l1::L1Deps { runner: &probe_l1::SystemRunner, http: &probe_l1::UreqHttp, locator: &SystemLocator, env: &env, budget: probe_l1::L1_BUDGET, }; // 探针的 Claude 自省(claude mcp get 的健康检查与零模型 init)以 claude-code 身份连项目地址,服务端无法与 // 真实会话区分:检查期间本项目的 initialize 不计入「真实客户端已连接」,免得抹掉 reload_required 证据(审查 ③) let _quiet = crate::mcp_server::quiet_project_initializes(project_id); Ok(probe_l1::run_l1(&project, &clients, &deps)) } /// 本机客户端定位(R15:客户端私有安装位置集中在这里,格式变化归因 adapter_drift)。 struct SystemLocator; impl probe_l1::ClientLocator for SystemLocator { fn locate(&self, client: ClientId) -> Option { match client { ClientId::Claude => find_claude_bin().map(|bin| Located { program: Some(PathBuf::from(bin)), version: None, }), ClientId::Codex => find_codex_bin().map(|bin| Located { program: Some(bin), version: None, }), ClientId::Dsh => DSH_ADAPTER.detect().then(|| Located { program: None, version: dsh_install_dir().and_then(|dir| { probe_l1::read_asar_package_version(&dir.join("resources").join("app.asar")) }), }), } } fn git_bash(&self) -> Option { find_git_bash().map(PathBuf::from) } } /// codex 可执行文件:CODEX_BIN → Codex 桌面版自带的 %LOCALAPPDATA%\OpenAI\Codex\bin\<哈希>\codex.exe /// (本机可能有多个哈希目录,取最新)→ PATH 中的 codex.exe。npm 全局安装的 codex.cmd 不在此列。 fn find_codex_bin() -> Option { if let Some(path) = std::env::var_os("CODEX_BIN") .map(PathBuf::from) .filter(|path| path.is_file()) { return Some(path); } let bundled = std::env::var_os("LOCALAPPDATA") .map(|base| PathBuf::from(base).join("OpenAI").join("Codex").join("bin")) .and_then(|bin| std::fs::read_dir(bin).ok()) .into_iter() .flatten() .flatten() .map(|entry| entry.path().join("codex.exe")) .filter(|exe| exe.is_file()) .max_by_key(|exe| exe.metadata().and_then(|meta| meta.modified()).ok()); if bundled.is_some() { return bundled; } let output = crate::silent_cmd("where").arg("codex.exe").output().ok()?; if !output.status.success() { return None; } String::from_utf8_lossy(&output.stdout) .lines() .next() .map(|line| PathBuf::from(line.trim())) .filter(|path| path.is_file()) } /// DSH 桌面版安装目录(读版本用):DSH_INSTALL_DIR → %LOCALAPPDATA%\Programs\DeepSeek Harness。 fn dsh_install_dir() -> Option { std::env::var_os("DSH_INSTALL_DIR") .map(PathBuf::from) .or_else(|| { std::env::var_os("LOCALAPPDATA").map(|base| { PathBuf::from(base) .join("Programs") .join("DeepSeek Harness") }) }) .filter(|dir| dir.is_dir()) } /// 从项目的 .claude/settings.json 移除炼境 MCP 配置(含旧组级 key)。 /// 调用方需自行保证项目已不属于任何产品组时才调用。 pub fn remove_project_mcp(project_id: &str) -> Result<(), String> { let _guard = mcp_op_lock(); // 先于登记删除、也先于删除本地文件夹同步清理客户端侧条目:残留的 local 条目会遮蔽全局注册并指向 404 地址。 if let Ok(root) = project_root(project_id) { sync_claude_local(project_id, &root, None); } // token 最先删:DSH 白名单剔除与根目录迁移判定都以 token 为准,后面的文件清理即使失败也不影响这一点 let token_removed = crate::credential_store::delete_mcp_auth_token_for_project( &crate::credential_store::WindowsCredentialStore, project_id, ); // 登记此时仍在,exclude 立即剔除;token 已删,之后的刷新也不会再把它加回白名单 refresh_dsh_patch(Some(project_id)); let mut errors: Vec = Vec::new(); if let Err(error) = remove_project_files_mcp(project_id) { errors.push(error); } if let Err(error) = token_removed { errors.push(error); } if errors.is_empty() { Ok(()) } else { Err(errors.join(";")) } } /// 清理项目文件中的炼境 MCP 配置(Codex 托管块与 .claude/settings.json 兼容条目)。 fn remove_project_files_mcp(project_id: &str) -> Result<(), String> { remove_codex_mcp_config(&codex_config_path(project_id)?, project_id)?; remove_settings_mcp_entry(&settings_path(project_id)?, project_id) } fn remove_settings_mcp_entry(path: &PathBuf, project_id: &str) -> Result<(), String> { if !path.exists() { return Ok(()); } let mut settings = read_json(path)?; if let Some(servers) = settings .get_mut("mcpServers") .and_then(|v| v.as_object_mut()) { let foreign = servers .get(MCP_KEY) .and_then(|entry| entry.get("url")) .and_then(Value::as_str) .is_some_and(|url| owned_by_other_project(url, project_id)); if !foreign { servers.remove(MCP_KEY); } servers.retain(|k, _| !k.starts_with(LEGACY_KEY_PREFIX)); } write_json(path, &settings) } /// 单一服务器化一次性迁移:批量重注入所有托管项目(换新 key + 清旧 key + liangjing.json 瘦身) /// 并清理旧组级全局注册。settings 表 mcp_single_migrated 标记位保证只执行一次; /// 部分项目失败不阻塞(记日志,可对单个项目手动重注入补救)。 pub fn migrate_to_single_server() { let already: Option = db::pool().get().ok().and_then(|c| { c.query_row( "SELECT value FROM settings WHERE key = 'mcp_single_migrated'", [], |r| r.get(0), ) .ok() }); if already.as_deref() == Some("1") { return; } let errors = reinject_all_mcp(); let removed = cleanup_legacy_global_registrations(); let register_ok = try_register_globally(crate::mcp_server::read_port()); let summary = format!( "单一服务器迁移完成:重注入失败 {} 个,移除旧全局注册 {} 条,全局注册 {}{}", errors.len(), removed.len(), if register_ok { "成功" } else { "失败" }, if errors.is_empty() { String::new() } else { format!(";失败详情: {}", errors.join("; ")) } ); let level = if errors.is_empty() && register_ok { "info" } else { "warn" }; db::log_event("mcp_inject", level, &summary, None); // 全局注册失败说明 claude CLI 链路不通(CLI 缺失/git-bash 缺失/命令报错), // 此时旧注册也清不掉——不落标记位,等下次启动重试(整个流程幂等)。 // try_register_globally 在已注册时会只读早返回,不再证明 CLI 可用,所以另外确认 CLI 与 git-bash 都在。 let cli_available = find_claude_bin().is_some() && find_git_bash().is_some(); if !register_ok || !cli_available { return; } if let Ok(conn) = db::pool().get() { let _ = conn.execute( "INSERT OR REPLACE INTO settings (key, value) VALUES ('mcp_single_migrated', '1')", [], ); } } /// 「已接入」的单一判定:本机受保护存储里有该项目的 MCP 认证 token。 /// token 由注入创建、由 remove_project_mcp 最先删除;DSH 白名单、根目录迁移、批量重注入与 /// list_all_projects 都以此为准(P8a)。项目里的 .claude/liangjing.json 只是兼容投影,可能残留或缺失,不作判据。 pub(crate) fn is_project_injected( store: &impl crate::credential_store::CredentialStore, project_id: &str, ) -> bool { crate::credential_store::mcp_auth_digest(project_id) .and_then(|digest| crate::credential_store::read_mcp_auth_token_for_digest(store, &digest)) .is_ok() } /// 批量重注入的目标:产品组成员(历史上入组即注入),加上已接入(有 token)但不在任何组的登记项目。 /// 后者此前被漏掉——独立接入的项目在端口变更后不会被重注入,项目配置仍指向旧端口。 fn reinject_targets( conn: &rusqlite::Connection, store: &impl crate::credential_store::CredentialStore, ) -> Result, String> { let query_ids = |sql: &str| -> Result, String> { let mut stmt = conn.prepare(sql).map_err(|e| e.to_string())?; let ids = stmt .query_map([], |row| row.get(0)) .map_err(|e| e.to_string())? .filter_map(Result::ok) .collect(); Ok(ids) }; let mut targets: std::collections::BTreeSet = query_ids("SELECT DISTINCT project_id FROM project_group_map")? .into_iter() .collect(); for id in query_ids("SELECT id FROM project_workspaces")? { if is_project_injected(store, &id) { targets.insert(id); } } Ok(targets.into_iter().collect()) } /// 锁内复查:目标快照取自锁外,期间被移出组且 token 已删的项目不再注入(避免重建刚删除的 token)。 fn is_reinject_target( conn: &rusqlite::Connection, store: &impl crate::credential_store::CredentialStore, project_id: &str, ) -> Result { let member = conn .query_row( "SELECT EXISTS(SELECT 1 FROM project_group_map WHERE project_id = ?1)", [project_id], |row| row.get::<_, bool>(0), ) .map_err(|error| error.to_string())?; Ok(member || is_project_injected(store, project_id)) } /// 端口变更后重新注入所有已接入项目(产品组成员 + 有 token 的独立项目)的 MCP 配置 /// 返回注入失败的错误列表(部分失败不影响其他项目) pub fn reinject_all_mcp() -> Vec { let store = crate::credential_store::WindowsCredentialStore; let project_ids = match db::pool() .get() .map_err(|e| e.to_string()) .and_then(|conn| reinject_targets(&conn, &store)) { Ok(ids) => ids, Err(e) => return vec![e], }; let mut errors = Vec::new(); for project_id in project_ids { let _guard = mcp_op_lock(); let still_target = db::pool() .get() .map_err(|error| error.to_string()) .and_then(|conn| is_reinject_target(&conn, &store, &project_id)); match still_target { Ok(true) => {} Ok(false) => continue, Err(error) => { errors.push(format!("项目 {project_id}: 复查接入状态失败: {error}")); continue; } } let result = inject_project_mcp_locked(&project_id, false) .and_then(|reports| hard_failure(&reports).map_or(Ok(()), Err)); if let Err(e) = result { errors.push(format!("项目 {}: {}", project_id, e)); } } // 逐项目注入时跳过了 DSH 刷新(内嵌端口与白名单),此处统一刷新一次,失败只记日志 refresh_dsh_patch(None); errors } /// 项目根目录或平台变更后调用:清理旧根下本项目的 Claude local 托管条目(旧目录已不存在时无法向 Claude /// 确认实际条目,只记录残留与清理命令);已接入(有 token)的项目在新根重新注入(会刷新 DSH 白名单), /// 未接入的只刷新白名单。新根的「已同步」判定只认新根或其祖先目录键下的条目,旧键下的残留不会挡住写入。 /// 重新注入会跑 claude CLI 做全局注册,调用方应放在后台线程。持锁执行:与并发的移除/删除串行, /// 移除先完成则 token 已不在,这里就不会再注入。 pub fn on_project_root_changed(project_id: &str, old_root: Option) { let _guard = mcp_op_lock(); if let Some(root) = old_root { sync_claude_local(project_id, &root, None); } if !is_project_injected(&crate::credential_store::WindowsCredentialStore, project_id) { refresh_dsh_patch(None); return; } let result = inject_project_mcp_locked(project_id, true) .and_then(|reports| hard_failure(&reports).map_or(Ok(()), Err)); if let Err(error) = result { // 属于注入结果,归入 mcp_inject;注入中途失败可能没走到刷新,这里补一次 db::log_event( "mcp_inject", "warn", &format!("项目根目录变更后重新注入失败: {error}"), Some(&inject_log_context(project_id, "all")), ); refresh_dsh_patch(None); } } /// 纯函数:update_project 后是否需要迁移客户端配置。只要能解析出新根且与旧根不同就迁移; /// 旧根解析不出(例如旧路径为空)也照样迁移,只是没有旧条目要清理。 pub(crate) fn project_root_changed(old_root: Option<&PathBuf>, new_root: Option<&PathBuf>) -> bool { new_root.is_some() && new_root != old_root } #[cfg(test)] mod tests { use super::*; /// 回归:空路径登记曾使 inject 链路把 settings.json/liangjing.json 写进 /// 炼境进程 cwd(src-tauri/,2026-07-15 实锤,apply 链路同日已拦本处漏拦)。 #[test] fn registered_path_rejects_empty_and_relative() { assert!( validate_registered_path("", "win_path").is_err(), "空串应拒绝" ); assert!( validate_registered_path(" ", "win_path").is_err(), "空白应拒绝" ); assert!( validate_registered_path("src-tauri", "win_path").is_err(), "相对路径应拒绝(相对炼境 cwd 解析)" ); } /// 回归:托管块原先每次注入都在 END 之后多留一个空行(enterprise 已累积 15 行), /// 且内容不变也会用 LF 重写 CRLF 检出的文件。 /// 回归:Claude Code 改写 settings.json 后键序与末尾换行不同,语义未变时注入不应整文件重排。 #[test] fn json_writes_skip_semantically_equal_files() { let base = std::env::temp_dir().join(format!("lianjing_json_idem_{}", std::process::id())); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&base).unwrap(); let path = base.join("settings.json"); let client_written = "{\r\n \"permissions\": {\"allow\": []},\r\n \"mcpServers\": {\"lian-jing\": {\"type\": \"sse\", \"url\": \"u\"}}\r\n}\r\n"; std::fs::write(&path, client_written).unwrap(); let value: Value = serde_json::from_str(client_written).unwrap(); write_json(&path, &value).unwrap(); write_json_with(&path, &value, "settings.json", &crate::DiskSink).unwrap(); assert_eq!( std::fs::read_to_string(&path).unwrap(), client_written, "语义相同,保留客户端的键序、行尾与末尾换行" ); let mut changed = value.clone(); changed["mcpServers"]["lian-jing"]["url"] = json!("u2"); write_json(&path, &changed).unwrap(); let written: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); assert_eq!(written, changed, "语义变化时照常写入"); let _ = std::fs::remove_dir_all(&base); } #[test] fn codex_mcp_upsert_is_idempotent_and_absorbs_accumulated_blank_lines() { let base = std::env::temp_dir().join(format!("lianjing_codex_idem_{}", std::process::id())); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(base.join(".codex")).unwrap(); let path = base.join(".codex/config.toml"); let url = "http://127.0.0.1:27190/mcp/projects/p1"; let helper = shim_helper_command("d1"); // 块在末尾、END 之后堆了 15 个空行(CRLF 检出) let block = format!( "{CODEX_MCP_MANAGED_START}\n[mcp_servers.{MCP_KEY}]\nurl = \"{url}\"\nhttp_headers_helper = {}\n{CODEX_MCP_MANAGED_END}", serde_json::to_string(&helper).unwrap() ); let accumulated = format!("model = \"gpt-6\"\n{block}{}", "\n".repeat(16)).replace('\n', "\r\n"); std::fs::write(&path, &accumulated).unwrap(); upsert_codex_mcp_config(&path, url, &helper).unwrap(); let cleaned = std::fs::read_to_string(&path).unwrap(); assert!( cleaned.ends_with(&format!("{CODEX_MCP_MANAGED_END}\n")), "END 之后的累积空行被吸收" ); // 再注入一次:内容与字节都不变 let before = std::fs::read(&path).unwrap(); upsert_codex_mcp_config(&path, url, &helper).unwrap(); assert_eq!(std::fs::read(&path).unwrap(), before); // 块在中间:块外后续内容保留,并且重复注入稳定 std::fs::write( &path, format!("{cleaned}\n\n\n[profiles.x]\nmodel = \"y\"\n"), ) .unwrap(); upsert_codex_mcp_config(&path, url, &helper).unwrap(); let middle = std::fs::read_to_string(&path).unwrap(); assert!(middle.contains(&format!( "{CODEX_MCP_MANAGED_END}\n\n[profiles.x]\nmodel = \"y\"\n" ))); upsert_codex_mcp_config(&path, url, &helper).unwrap(); assert_eq!(std::fs::read_to_string(&path).unwrap(), middle); let _ = std::fs::remove_dir_all(&base); } #[test] fn codex_mcp_config_preserves_user_content_and_rejects_handwritten_conflict() { let base = std::env::temp_dir().join(format!("lianjing_codex_mcp_test_{}", std::process::id())); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(base.join(".codex")).unwrap(); let path = base.join(".codex/config.toml"); std::fs::write(&path, "model = \"gpt-6\"\n").unwrap(); let helper = "\"C:/Lianjing.exe\" --lianjing-mcp-header qa-only-digest"; upsert_codex_mcp_config(&path, "http://127.0.0.1:27190/mcp", helper).unwrap(); let configured = std::fs::read_to_string(&path).unwrap(); assert!(configured.contains("model = \"gpt-6\"")); assert!(configured.contains("[mcp_servers.lian-jing]")); assert!(configured.contains("url = \"http://127.0.0.1:27190/mcp\"")); assert!(configured.contains("http_headers_helper")); assert!(!configured.contains("qa-only-synthetic-mcp-token")); upsert_codex_mcp_config( &path, "http://127.0.0.1:27190/mcp/projects/project%20one", helper, ) .unwrap(); let migrated = std::fs::read_to_string(&path).unwrap(); assert!(migrated.contains("model = \"gpt-6\"")); assert!(migrated.contains("url = \"http://127.0.0.1:27190/mcp/projects/project%20one\"")); assert!(!migrated.contains("url = \"http://127.0.0.1:27190/mcp\"\n")); remove_codex_mcp_config(&path, "project one").unwrap(); assert_eq!( std::fs::read_to_string(&path).unwrap(), "model = \"gpt-6\"\n" ); let manual = "[mcp_servers.lian-jing]\nurl = \"http://manual/mcp\"\n"; std::fs::write(&path, manual).unwrap(); // 手写同名 server:记 manual_kept 不覆盖(P8b 起不再整体报错,不连带阻断 Claude 与 DSH) assert_eq!( upsert_codex_mcp_config( &path, "http://127.0.0.1:27190/mcp/projects/project%20one", helper, ), Ok(ClientOutcome::ManualKept) ); assert_eq!(std::fs::read_to_string(&path).unwrap(), manual); let _ = std::fs::remove_dir_all(&base); } /// 2026-10-03 真机:同一目录登记了 dev-manager-tauri 与 2c5b54ef 两条,删 UUID 那条会把 /// 指向规范 ID 的 Codex 管理块一并删掉。删除只清 URL 属于本项目(或旧格式)的条目。 #[test] fn remove_keeps_entries_owned_by_another_project_at_same_root() { let base = std::env::temp_dir().join(format!( "lianjing_remove_scoped_test_{}", std::process::id() )); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(base.join(".codex")).unwrap(); std::fs::create_dir_all(base.join(".claude")).unwrap(); let helper = "\"C:/Lianjing.exe\" --lianjing-mcp-header qa-only-digest"; let codex = base.join(".codex/config.toml"); let other_url = "http://127.0.0.1:27190/mcp/projects/canonical-id"; std::fs::write(&codex, "model = \"gpt-6\"\n").unwrap(); upsert_codex_mcp_config(&codex, other_url, helper).unwrap(); let before = std::fs::read_to_string(&codex).unwrap(); remove_codex_mcp_config(&codex, "duplicate-uuid").unwrap(); assert_eq!( std::fs::read_to_string(&codex).unwrap(), before, "别的项目的 Codex 管理块必须原样保留" ); remove_codex_mcp_config(&codex, "canonical-id").unwrap(); assert_eq!( std::fs::read_to_string(&codex).unwrap(), "model = \"gpt-6\"\n" ); // 旧格式全局地址不属于任何项目路由,视为遗留照旧清理 upsert_codex_mcp_config(&codex, "http://127.0.0.1:27190/mcp", helper).unwrap(); remove_codex_mcp_config(&codex, "duplicate-uuid").unwrap(); assert_eq!( std::fs::read_to_string(&codex).unwrap(), "model = \"gpt-6\"\n" ); let settings = base.join(".claude/settings.json"); let write_settings = |url: &str| { std::fs::write( &settings, json!({ "mcpServers": { MCP_KEY: { "type": "http", "url": url }, "lian-jing:group-old": { "type": "http", "url": "http://127.0.0.1:27190/mcp/group/g" }, "other": { "command": "x" } } }) .to_string(), ) .unwrap(); }; let servers = || read_json(&settings).unwrap()["mcpServers"].clone(); write_settings(other_url); remove_settings_mcp_entry(&settings, "duplicate-uuid").unwrap(); let kept = servers(); assert_eq!( kept[MCP_KEY]["url"], other_url, "别的项目的 settings 条目必须保留" ); assert!( kept.get("lian-jing:group-old").is_none(), "旧组级 key 照旧清理" ); assert!(kept.get("other").is_some()); write_settings("http://127.0.0.1:27190/mcp/projects/duplicate-uuid"); remove_settings_mcp_entry(&settings, "duplicate-uuid").unwrap(); let removed = servers(); assert!(removed.get(MCP_KEY).is_none()); assert!(removed.get("other").is_some()); let _ = std::fs::remove_dir_all(&base); } // ── P8b:三端结构化结果 ── fn claude_get_local(url: &str) -> String { format!("lian-jing:\n Scope: Local config (private to you in this project)\n Type: http\n URL: {url}\n") } /// 假 claude CLI:按子命令给出预设结果并记录调用;resolve 被调用时返回它。 struct FakeClaude { get: String, remove_ok: bool, add_ok: bool, calls: std::cell::RefCell>, } impl FakeClaude { fn new(get: &str, remove_ok: bool, add_ok: bool) -> Self { Self { get: get.into(), remove_ok, add_ok, calls: Default::default(), } } fn run(&self, args: &[&str]) -> Result { self.calls.borrow_mut().push(args[1].to_string()); match args[1] { "get" => Ok(self.get.clone()), "remove" if self.remove_ok => Ok(String::new()), "add-json" if self.add_ok => Ok(String::new()), other => Err(format!("{other} failed")), } } } fn sync_with_fake( project_id: &str, root: &std::path::Path, desired: Option<(String, String)>, config: Option<&Value>, fake: Option<&FakeClaude>, ) -> ClientOutcome { sync_claude_local_with(project_id, root, desired, config, &|| { fake.map(|fake| { let cli: ClaudeCli = Box::new(move |args: &[&str]| fake.run(args)); cli }) }) } #[test] fn claude_sync_maps_every_branch_to_an_outcome() { let root = std::env::temp_dir().join(format!("lj_p8b_claude_{}", std::process::id())); std::fs::create_dir_all(&root).unwrap(); let url = "http://127.0.0.1:27190/mcp/projects/p1"; let helper = "\"C:\\Users\\u\\.lian-jing\\bin\\lianjing-mcp-header.cmd\" abc"; let desired = || Some((url.to_string(), helper.to_string())); let root_key = root.display().to_string().replace('\\', "/"); let entry = claude_local_server_entry(url, helper); let config_with = |entry: Value| json!({ "projects": { root_key.clone(): { "mcpServers": { "lian-jing": entry } } } }); let empty = json!({ "projects": {} }); let no_cli: Option<&FakeClaude> = None; // 快路径:已一致 → converged,不解析 CLI assert_eq!( sync_with_fake( "p1", &root, desired(), Some(&config_with(entry.clone())), no_cli ), ClientOutcome::Converged ); // 移除且确认无托管条目 → converged assert_eq!( sync_with_fake("p1", &root, None, Some(&empty), no_cli), ClientOutcome::Converged ); // 缺 CLI → unavailable assert!(matches!( sync_with_fake("p1", &root, desired(), Some(&empty), no_cli), ClientOutcome::Unavailable { reason } if reason.contains("claude CLI") )); // 目录不存在 → unavailable:CLI 在也不起(审查修复:原用例缺 CLI,删掉目录守卫也会通过) let not_local = "No MCP server found with name: \"lian-jing\"."; let untouched = FakeClaude::new(not_local, true, true); assert!(matches!( sync_with_fake("p1", &root.join("gone"), desired(), Some(&empty), Some(&untouched)), ClientOutcome::Unavailable { reason } if reason.contains("项目目录不存在") )); assert!( untouched.calls.borrow().is_empty(), "目录不存在时不得起 CLI" ); // get 输出无法识别 → unavailable let garbled = FakeClaude::new("???", true, true); assert!(matches!( sync_with_fake("p1", &root, desired(), Some(&empty), Some(&garbled)), ClientOutcome::Unavailable { reason } if reason.contains("无法识别") )); // 本目录无 local 条目:写入成功 → applied,失败 → failed assert_eq!( sync_with_fake( "p1", &root, desired(), Some(&empty), Some(&FakeClaude::new(not_local, true, true)) ), ClientOutcome::Applied ); assert!(matches!( sync_with_fake( "p1", &root, desired(), Some(&empty), Some(&FakeClaude::new(not_local, true, false)) ), ClientOutcome::Failed { .. } )); // 本项目旧托管条目:刷新 → refreshed;移除 → removed;移除失败 → failed let stale = config_with(claude_local_server_entry( "http://127.0.0.1:1/mcp/projects/p1", helper, )); let own = claude_get_local("http://127.0.0.1:1/mcp/projects/p1"); assert_eq!( sync_with_fake( "p1", &root, desired(), Some(&stale), Some(&FakeClaude::new(&own, true, true)) ), ClientOutcome::Refreshed ); assert_eq!( sync_with_fake( "p1", &root, None, Some(&stale), Some(&FakeClaude::new(&own, true, true)) ), ClientOutcome::Removed ); assert!(matches!( sync_with_fake("p1", &root, desired(), Some(&stale), Some(&FakeClaude::new(&own, false, true))), ClientOutcome::Failed { reason } if reason.contains("旧条目移除失败") )); // 新条目写入失败:用原条目回滚并记 failed let rollback = FakeClaude::new(&own, true, false); assert!(matches!( sync_with_fake("p1", &root, desired(), Some(&stale), Some(&rollback)), ClientOutcome::Failed { reason } if reason.contains("新条目写入失败") )); assert_eq!( *rollback.calls.borrow(), vec!["get", "remove", "add-json", "add-json"] ); // 共享项目键属于他项目 → other_project;手写条目 → manual_kept,均不写 let other = FakeClaude::new( &claude_get_local("http://127.0.0.1:27190/mcp/projects/p2"), true, true, ); assert_eq!( sync_with_fake("p1", &root, desired(), Some(&empty), Some(&other)), ClientOutcome::OtherProject { owner: "p2".into() } ); assert_eq!(*other.calls.borrow(), vec!["get"]); let manual = FakeClaude::new(&claude_get_local("http://example.com/mcp"), true, true); assert_eq!( sync_with_fake("p1", &root, desired(), Some(&empty), Some(&manual)), ClientOutcome::ManualKept ); // 移除时本目录无条目、但他处项目键残留本项目托管条目 → needs_human let elsewhere = json!({ "projects": { "D:/other": { "mcpServers": { "lian-jing": entry } } } }); assert!(matches!( sync_with_fake( "p1", &root, None, Some(&elsewhere), Some(&FakeClaude::new(not_local, true, true)) ), ClientOutcome::NeedsHuman { .. } )); let _ = std::fs::remove_dir_all(&root); } #[test] fn static_outcomes_are_read_only_judgments() { let url = "http://127.0.0.1:27190/mcp/projects/p1"; let helper = "\"C:\\x\\lianjing-mcp-header.cmd\" abc"; let root = std::path::Path::new("E:\\code\\p1"); let entry = claude_local_server_entry(url, helper); let config = |entry: Value| json!({ "projects": { "E:/code/p1": { "mcpServers": { "lian-jing": entry } } } }); assert_eq!( claude_static_outcome(&config(entry.clone()), "p1", root, &entry), ClientOutcome::Converged ); assert_eq!( claude_static_outcome(&json!({}), "p1", root, &entry), ClientOutcome::Missing ); let stale = claude_local_server_entry("http://127.0.0.1:1/mcp/projects/p1", helper); assert_eq!( claude_static_outcome(&config(stale), "p1", root, &entry), ClientOutcome::Missing ); let other = claude_local_server_entry("http://127.0.0.1:27190/mcp/projects/p2", helper); assert_eq!( claude_static_outcome(&config(other), "p1", root, &entry), ClientOutcome::OtherProject { owner: "p2".into() } ); let manual = json!({ "type": "http", "url": "http://example.com/mcp" }); assert_eq!( claude_static_outcome(&config(manual), "p1", root, &entry), ClientOutcome::ManualKept ); let block = render_codex_block(url, helper).unwrap(); assert_eq!( codex_static_outcome(Some(&block.replace('\n', "\r\n")), url, helper), ClientOutcome::Converged ); assert_eq!( codex_static_outcome(None, url, helper), ClientOutcome::Missing ); assert_eq!( codex_static_outcome(Some("[mcp_servers.lian-jing]\nurl = \"x\"\n"), url, helper), ClientOutcome::ManualKept ); assert_eq!( codex_static_outcome(Some(&block.replace("27190", "1")), url, helper), ClientOutcome::Missing ); let keys = vec![normalize_dsh_root("E:\\code\\p1")]; let dsh = render_dsh_block(27190, &[(keys[0].clone(), Some("p1".into()))], None); assert_eq!( dsh_static_outcome(&dsh, &keys, "p1", 27190), ClientOutcome::Converged ); assert_eq!( dsh_static_outcome(&dsh, &keys, "p1", 1), ClientOutcome::Missing, "端口已变" ); let without = render_dsh_block(27190, &[], None); assert_eq!( dsh_static_outcome(&without, &keys, "p1", 27190), ClientOutcome::Missing ); assert_eq!( dsh_static_outcome("", &keys, "p1", 27190), ClientOutcome::Missing ); } #[test] fn dsh_patch_reports_not_installed_applied_converged_and_manual_kept() { let base = std::env::temp_dir().join(format!("lj_p8b_dsh_{}", std::process::id())); let _ = std::fs::remove_dir_all(&base); let conn = crate::db::conn_with_schema(); let store = crate::credential_store::MemoryCredentialStore::new(); let home = base.join(".dsh"); assert_eq!( ensure_dsh_patch_at(&conn, &store, 27190, None, &home, None), Ok(ClientOutcome::NotInstalled) ); std::fs::create_dir_all(&home).unwrap(); assert_eq!( ensure_dsh_patch_at(&conn, &store, 27190, None, &home, None), Ok(ClientOutcome::Applied) ); assert_eq!( ensure_dsh_patch_at(&conn, &store, 27190, None, &home, None), Ok(ClientOutcome::Converged) ); let manual = "- insert:\n - id: mcp-lian-jing\n name: x\n"; std::fs::write(home.join("cordis.patch.yml"), manual).unwrap(); assert_eq!( ensure_dsh_patch_at(&conn, &store, 27190, None, &home, None), Ok(ClientOutcome::ManualKept) ); assert_eq!( std::fs::read_to_string(home.join("cordis.patch.yml")).unwrap(), manual, "手写条目不被覆盖" ); let _ = std::fs::remove_dir_all(&base); } #[test] fn adapters_skip_control_plane_and_wsl_without_touching_disk() { let root = std::path::Path::new("E:\\nowhere\\ctrl"); let desired = DesiredMcp { port: 27190, server_url: "http://127.0.0.1:27190/mcp/projects/ctrl".into(), header_helper: "h".into(), refresh_dsh: false, }; let control = ProjectFacts { project_id: "ctrl", root, control_plane: true, wsl: false, }; let skip_ctrl = ClientOutcome::NotApplicable { reason: NotApplicableReason::ControlPlane, }; // Claude 的控制面判定要读本机配置,改测纯函数(见 control_plane_claude_residual_is_not_hidden) assert_eq!(DSH_ADAPTER.static_status(&control, &desired), skip_ctrl); let wsl_root = std::path::Path::new(r"\\wsl.localhost\Ubuntu\home\p"); let wsl = ProjectFacts { project_id: "w", root: wsl_root, control_plane: false, wsl: true, }; let skip_wsl = ClientOutcome::NotApplicable { reason: NotApplicableReason::Wsl, }; assert_eq!(CLAUDE_ADAPTER.static_status(&wsl, &desired), skip_wsl); assert_eq!(DSH_ADAPTER.static_status(&wsl, &desired), skip_wsl); // 批量重注入:DSH 逐项目跳过,由调用方末尾统一刷新 assert_eq!(DSH_ADAPTER.apply(&wsl, &desired), ClientOutcome::Deferred); assert!( [skip_ctrl.clone(), skip_wsl.clone()] .iter() .all(ClientOutcome::is_settled), "不适用不计失败" ); // apply 的结果映射:成功态在控制面 / WSL 上报 not_applicable,失败原样保留 let failed = ClientOutcome::Failed { reason: "x".into() }; assert_eq!( finalize_for_project(&control, ClientOutcome::Removed), skip_ctrl ); assert_eq!( finalize_for_project(&control, ClientOutcome::NotInstalled), skip_ctrl ); assert_eq!(finalize_for_project(&control, failed.clone()), failed); assert_eq!( finalize_for_project(&wsl, ClientOutcome::Converged), skip_wsl ); let normal = ProjectFacts { project_id: "p", root, control_plane: false, wsl: false, }; assert_eq!( finalize_for_project(&normal, ClientOutcome::Applied), ClientOutcome::Applied ); } #[test] fn control_plane_claude_residual_is_not_hidden() { let root = std::path::Path::new("E:\\code\\ctrl"); let helper = "\"C:\\x\\lianjing-mcp-header.cmd\" abc"; let residual = claude_local_server_entry("http://127.0.0.1:27190/mcp/projects/ctrl", helper); let with = json!({ "projects": { "E:/code/ctrl": { "mcpServers": { "lian-jing": residual } } } }); assert_eq!( claude_control_plane_outcome(&with, "ctrl", root), ClientOutcome::Missing ); assert_eq!( claude_control_plane_outcome(&json!({ "projects": {} }), "ctrl", root), ClientOutcome::NotApplicable { reason: NotApplicableReason::ControlPlane } ); } #[test] fn claude_static_prefers_own_git_root_over_ancestor_entry() { let base = std::env::temp_dir().join(format!("lj_p8b_nested_{}", std::process::id())); let _ = std::fs::remove_dir_all(&base); let child = base.join("sub").join("child"); std::fs::create_dir_all(child.join(".git")).unwrap(); let helper = "\"C:\\x\\lianjing-mcp-header.cmd\" abc"; let desired = claude_local_server_entry("http://127.0.0.1:27190/mcp/projects/child", helper); let parent_entry = claude_local_server_entry("http://127.0.0.1:27190/mcp/projects/parent", helper); let key = |path: &std::path::Path| path.display().to_string().replace('\\', "/"); // 子项目是独立 git 根、自己还没有条目:祖先目录的条目不在本目录生效 → missing(apply 会写入) let only_parent = json!({ "projects": { key(&base): { "mcpServers": { "lian-jing": parent_entry } } } }); assert_eq!( claude_static_outcome(&only_parent, "child", &child, &desired), ClientOutcome::Missing ); // 子项目自己的条目已一致 → converged(不被祖先条目干扰) let both = json!({ "projects": { key(&base): { "mcpServers": { "lian-jing": parent_entry } }, key(&child): { "mcpServers": { "lian-jing": desired } } } }); assert_eq!( claude_static_outcome(&both, "child", &child, &desired), ClientOutcome::Converged ); // 非 git 根的子目录(共享外层项目键):外层条目生效 → other_project let plain = base.join("plain"); std::fs::create_dir_all(&plain).unwrap(); assert_eq!( claude_static_outcome(&only_parent, "plain", &plain, &desired), ClientOutcome::OtherProject { owner: "parent".into() } ); let _ = std::fs::remove_dir_all(&base); } #[test] fn static_outcomes_flag_coexisting_manual_entries_and_unreadable_patch() { let url = "http://127.0.0.1:27190/mcp/projects/p1"; let helper = "\"C:\\x\\lianjing-mcp-header.cmd\" abc"; let block = render_codex_block(url, helper).unwrap(); let duplicated = format!("{block}\n[mcp_servers.lian-jing]\nurl = \"x\"\n"); assert!(matches!( codex_static_outcome(Some(&duplicated), url, helper), ClientOutcome::NeedsHuman { .. } )); let keys = vec![normalize_dsh_root("E:\\code\\p1")]; let dsh = render_dsh_block(27190, &[(keys[0].clone(), Some("p1".into()))], None); let with_manual = format!("- insert:\n - id: mcp-lian-jing\n name: x\n{dsh}"); assert_eq!( dsh_static_outcome(&with_manual, &keys, "p1", 27190), ClientOutcome::ManualKept ); assert!(matches!( dsh_static_outcome("- [unclosed", &keys, "p1", 27190), ClientOutcome::Unavailable { .. } )); let misordered = format!("{DSH_MANAGED_END}\n{DSH_MANAGED_START}\n"); assert!(matches!( dsh_static_outcome(&misordered, &keys, "p1", 27190), ClientOutcome::NeedsHuman { .. } )); } /// Codex 信任表解析:字面量键、按 TOML 规则转义的基本字符串键、[projects] 段内的内联表与点号键; /// 只认精确目录(及调用方给出的 git 主仓库根候选),不逐级查祖先目录。 #[test] fn codex_trust_matches_exact_dirs_with_real_toml_keys() { let config = r#"model = "x" [projects.'e:\code-project\enterprise-system'] trust_level = "trusted" [projects."D:\\Work"] trust_level = "trusted" # 基本字符串里的反斜杠必须转义 [projects."D:\Bad"] trust_level = "trusted" [projects.'e:\other'] trust_level = "untrusted" [projects] 'f:\inline' = { trust_level = "trusted" } "f:\\dotted".trust_level = 'trusted' 'f:\inline-untrusted' = { trust_level = "untrusted" } [[profiles]] trust_level = "trusted" "#; let trusts = |paths: &[&str]| { codex_config_trusts(config, &paths.iter().map(PathBuf::from).collect::>()) }; assert!(trusts(&[r"E:\code-project\enterprise-system"])); assert!(trusts(&["E:/code-project/enterprise-system/"])); assert!(trusts(&[r"\\?\E:\code-project\enterprise-system"])); assert!(trusts(&[r"D:\Work"]), "转义的基本字符串键按反转义后匹配"); assert!( !trusts(&[r"D:\Work\sub\proj"]), "受信任的祖先目录不覆盖子目录(只认精确目录与 git 主仓库根)" ); assert!(!trusts(&[r"D:\Bad"]), "非法转义的键忽略"); assert!(!trusts(&[r"E:\other"]), "untrusted 不算"); assert!( !trusts(&[r"E:\code-project\enterprise-system-wt"]), "前缀不是同一目录" ); assert!(trusts(&[r"F:\inline"]), "[projects] 内联表"); assert!(trusts(&[r"F:\dotted"]), "[projects] 点号键"); assert!(!trusts(&[r"F:\inline-untrusted"])); assert!(!trusts(&[r"C:\new"])); assert!( trusts(&[r"C:\wt", r"E:\code-project\enterprise-system"]), "任一候选(git 主仓库根)受信任即可" ); assert!(!codex_config_trusts( "", &[PathBuf::from(r"E:\code-project\enterprise-system")] )); } /// git 主仓库根:普通仓库为仓库根,worktree 为主仓库根(继承信任),仓库子目录为所在仓库根。 #[test] fn git_main_worktree_root_resolves_worktree_and_subdir() { let base = std::env::temp_dir().join(format!("lj_p8c_gitroot_{}", std::process::id())); let _ = std::fs::remove_dir_all(&base); let main = base.join("main"); std::fs::create_dir_all(main.join("sub")).unwrap(); let git = |dir: &std::path::Path, args: &[&str]| { let out = std::process::Command::new("git") .args([ "-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", ]) .args(args) .current_dir(dir) .output() .expect("git 可执行"); assert!( out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr) ); }; git(&main, &["init", "-q"]); std::fs::write(main.join("sub").join("a.txt"), "a\n").unwrap(); git(&main, &["add", "-A"]); git(&main, &["commit", "-q", "--no-verify", "-m", "init"]); git(&main, &["worktree", "add", "-q", "../wt", "-b", "wt"]); let canonical = |path: PathBuf| std::fs::canonicalize(path).unwrap(); let expected = canonical(main.clone()); assert_eq!(canonical(git_main_worktree_root(&main).unwrap()), expected); assert_eq!( canonical(git_main_worktree_root(&base.join("wt")).unwrap()), expected, "worktree 取主仓库根" ); assert_eq!( canonical(git_main_worktree_root(&main.join("sub")).unwrap()), expected, "仓库子目录取所在仓库根" ); let _ = std::fs::remove_dir_all(&base); } #[test] fn token_step_tells_missing_from_needs_repair() { use crate::credential_store::McpAuthTokenState; use crate::onboard::plan::{machine_step_blocker, BlockReason}; let present = token_step(McpAuthTokenState::Present); assert_eq!( (present.step.as_str(), present.status.as_str()), ("token", "noop") ); assert!(machine_step_blocker(&present).is_none()); let missing = token_step(McpAuthTokenState::Missing); assert_eq!(missing.status, "apply", "只有未配置才会生成"); assert!(machine_step_blocker(&missing).is_none()); let repair = token_step(McpAuthTokenState::NeedsRepair( "MCP 认证材料 needs_repair".into(), )); assert_eq!(repair.status, "needs_human"); assert!(repair.detail.contains("needs_repair") && repair.detail.contains("不会重新生成")); let blocked = machine_step_blocker(&repair).expect("needs_repair 阻断确认"); assert_eq!( (blocked.target.as_str(), blocked.reason), ("token", BlockReason::NeedsHuman) ); } #[test] fn hard_failure_is_only_codex_failed() { let report = |client, outcome| ClientReport::new(client, outcome); let failed = || ClientOutcome::Failed { reason: "boom".into(), }; assert_eq!( hard_failure(&[report(ClientId::Codex, failed())]), Some("boom".into()) ); assert_eq!( hard_failure(&[ report(ClientId::Claude, failed()), report(ClientId::Dsh, failed()), report(ClientId::Codex, ClientOutcome::ManualKept), ]), None, "Claude 与 DSH 一直是 best-effort,不算整体失败" ); } #[test] fn canonical_context_migrates_legacy_project_id_and_preserves_unknown_fields() { let base = std::env::temp_dir().join(format!( "lianjing_canonical_context_test_{}", std::process::id() )); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(base.join(".claude")).unwrap(); std::fs::write( base.join(".claude/liangjing.json"), r#"{"project_id":"legacy-project","mcp_port":27190}"#, ) .unwrap(); std::fs::create_dir_all(base.join(".lian-jing")).unwrap(); std::fs::write( base.join(".lian-jing/context.json"), r#"{"project_setting":"preserve-me"}"#, ) .unwrap(); write_canonical_context_at(&base, "legacy-project").unwrap(); let context: Value = serde_json::from_str( &std::fs::read_to_string(base.join(".lian-jing/context.json")).unwrap(), ) .unwrap(); assert_eq!(context["schema_version"], CONTEXT_SCHEMA_VERSION); assert_eq!(context["project_id"], "legacy-project"); assert_eq!(context["project_setting"], "preserve-me"); assert!( !context.as_object().unwrap().contains_key("mcp_port"), "运行时端口不应进入工具无关的 canonical context" ); let _ = std::fs::remove_dir_all(&base); } #[test] fn shim_helper_command_has_no_machine_path() { let helper = shim_helper_command("qa-digest"); assert_eq!( helper, "\"%USERPROFILE%\\.lian-jing\\bin\\lianjing-mcp-header.cmd\" qa-digest" ); assert!(!helper.contains(":\\"), "不得含盘符绝对路径"); assert!(!helper.to_lowercase().contains("users"), "不得含用户目录"); assert!( !helper.contains("%LOCALAPPDATA%"), "炼境在 MSIX 应用进程树里启动时,AppData 下新建的目录会被重定向到该应用的私有目录" ); } #[test] fn header_shim_forwards_digest_and_exit_code() { let ascii = render_header_shim(std::path::Path::new("C:\\Apps\\Lian Jing\\app.exe")); assert_eq!( ascii, "@echo off\r\n\"C:\\Apps\\Lian Jing\\app.exe\" --lianjing-mcp-header %1 & call exit /b %%ERRORLEVEL%%\r\n", "调用与退出同一行:入口被并发改写时 cmd 不会再按旧偏移读后续行" ); let unicode = render_header_shim(std::path::Path::new("C:\\炼境\\app.exe")); assert!( unicode.contains("chcp 65001 >nul\r\n"), "非 ASCII 路径须切 UTF-8 代码页" ); let percent = render_header_shim(std::path::Path::new("C:\\Users\\a%b\\app.exe")); assert!( percent.contains("\"C:\\Users\\a%%b\\app.exe\" --lianjing-mcp-header %1"), "路径 % 须转义" ); } #[test] fn reinject_targets_follow_single_injected_judgment() { let conn = crate::db::conn_with_schema(); let store = crate::credential_store::MemoryCredentialStore::new(); conn.execute( "INSERT INTO product_groups (id, name) VALUES ('g1', 'G')", [], ) .unwrap(); for id in ["grouped", "standalone", "removed"] { conn.execute( "INSERT INTO project_profiles (id, name) VALUES (?1, 'QA')", [format!("{id}-profile")], ) .unwrap(); conn.execute( "INSERT INTO project_workspaces (id, profile_id, win_path) VALUES (?1, ?2, ?3)", rusqlite::params![id, format!("{id}-profile"), format!("C:\\qa\\{id}")], ) .unwrap(); } conn.execute( "INSERT INTO project_group_map (project_id, group_id) VALUES ('grouped', 'g1')", [], ) .unwrap(); // standalone:独立接入(有 token、不在任何组),此前被批量重注入漏掉;removed:从未接入或已移除 crate::credential_store::get_or_create_mcp_auth_token(&store, "standalone").unwrap(); assert!(is_project_injected(&store, "standalone")); assert!(!is_project_injected(&store, "removed")); assert_eq!( reinject_targets(&conn, &store).unwrap(), vec!["grouped".to_string(), "standalone".to_string()] ); // 锁内复查:组成员仍是目标;独立项目的 token 先被 remove_project_mcp 删掉后不再注入 assert!(is_reinject_target(&conn, &store, "grouped").unwrap()); assert!(is_reinject_target(&conn, &store, "standalone").unwrap()); crate::credential_store::delete_mcp_auth_token_for_project(&store, "standalone").unwrap(); assert!(!is_reinject_target(&conn, &store, "standalone").unwrap()); assert_eq!( reinject_targets(&conn, &store).unwrap(), vec!["grouped".to_string()] ); } #[test] fn global_scope_projects_are_excluded_and_roots_follow_registry() { let base = std::env::temp_dir().join(format!("lianjing_dsh_roots_{}", std::process::id())); let _ = std::fs::remove_dir_all(&base); let conn = crate::db::conn_with_schema(); let store = crate::credential_store::MemoryCredentialStore::new(); for id in ["p1", "drift", "gone"] { crate::credential_store::get_or_create_mcp_auth_token(&store, id).unwrap(); } // notoken:已绑定但没有 token(例如退组后 token 已删),不得进入白名单 for (id, scope, bound) in [ ("p1", None, true), ("ctrl", Some("global"), true), ("drift", None, false), ("gone", None, true), ("notoken", None, true), ] { let root = base.join(id); std::fs::create_dir_all(root.join(".lian-jing")).unwrap(); let mut ctx = json!({ "project_id": if bound { id } else { "someone-else" }, "schema_version": 1 }); if let Some(scope) = scope { ctx["mcp_scope"] = json!(scope); } std::fs::write(root.join(".lian-jing/context.json"), ctx.to_string()).unwrap(); conn.execute( "INSERT INTO project_profiles (id, name) VALUES (?1, 'QA')", [format!("{id}-profile")], ) .unwrap(); conn.execute( "INSERT INTO project_workspaces (id, profile_id, win_path) VALUES (?1, ?2, ?3)", rusqlite::params![id, format!("{id}-profile"), root.display().to_string()], ) .unwrap(); } assert!(project_wants_global_scope(&base.join("ctrl"))); let roots = dsh_project_roots(&conn, &store, Some("gone")); let key = |id: &str| normalize_dsh_root(&base.join(id).display().to_string()); // 规范路径(%TEMP% 常是 8.3 短名)与登记的字面路径不同时,两种键都要在白名单里且取值相同 let mut expected = Vec::new(); for (id, value) in [("ctrl", None), ("p1", Some("p1".to_string()))] { expected.push((key(id), value.clone())); let canonical = normalize_dsh_root(&strip_verbatim_prefix( &std::fs::canonicalize(base.join(id)) .unwrap() .display() .to_string(), )); if canonical != key(id) { expected.push((canonical, value)); } } assert_eq!( roots, expected, "控制面写停止哨兵;只保留已绑定、有 token、未被剔除的项目" ); let _ = std::fs::remove_dir_all(&base); } #[test] fn verbatim_prefix_is_stripped_for_canonical_roots() { assert_eq!(strip_verbatim_prefix(r"\\?\E:\code\demo"), r"E:\code\demo"); assert_eq!( strip_verbatim_prefix(r"\\?\UNC\server\share\x"), r"\\server\share\x" ); assert_eq!(strip_verbatim_prefix(r"E:\plain"), r"E:\plain"); } #[test] fn dsh_root_keys_match_js_normalization() { assert_eq!(normalize_dsh_root("E:\\Code\\Demo"), "e:\\code\\demo"); assert_eq!( normalize_dsh_root("E:/Code//Demo/"), "e:\\code\\demo", "正斜杠、重复与尾部分隔符" ); assert_eq!(normalize_dsh_root("E:\\"), "e:", "盘符根与 JS 去尾后一致"); assert_eq!( normalize_dsh_root("\\\\Server\\Share\\x"), "\\\\server\\share\\x", "保留 UNC 前缀" ); } #[test] fn claude_local_entry_uses_scoped_url_and_shim() { let entry = claude_local_server_entry( "http://127.0.0.1:27190/mcp/projects/p1", &shim_helper_command("d1"), ); assert_eq!(entry["type"], "http"); assert_eq!(entry["url"], "http://127.0.0.1:27190/mcp/projects/p1"); assert!(entry["headersHelper"] .as_str() .unwrap() .contains("%USERPROFILE%\\.lian-jing\\bin\\")); } #[test] fn managed_claude_entries_match_by_project_url_under_any_key() { let managed = claude_local_server_entry( "http://127.0.0.1:27190/mcp/projects/p%201", &shim_helper_command("d1"), ); let moved_port = claude_local_server_entry( "http://127.0.0.1:28000/mcp/projects/p%201", &shim_helper_command("d1"), ); let config = json!({ "mcpServers": { "lian-jing": { "type": "sse", "url": "http://127.0.0.1:27190/mcp" } }, "projects": { // Claude 以 git 根 / 主仓库根为键:条目可能在任意键下,按 URL 认领 "E:/repos/main": { "mcpServers": { "lian-jing": managed.clone() } }, "E:/repos/other": { "mcpServers": { "lian-jing": moved_port.clone() } }, "E:/repos/p10": { "mcpServers": { "lian-jing": claude_local_server_entry( "http://127.0.0.1:27190/mcp/projects/p%2010", &shim_helper_command("d2")) } }, "E:/repos/manual": { "mcpServers": { "lian-jing": { "type": "http", "url": "http://127.0.0.1:27190/mcp/projects/p%201" } } }, "E:/repos/none": { "lastCost": 1 } } }); let found = managed_claude_entries(&config, "p 1"); let keys: Vec<&str> = found.iter().map(|(key, _)| key.as_str()).collect(); assert_eq!( keys, vec!["E:/repos/main", "E:/repos/other"], "端口变化仍认领;前缀相同的他项目、无 shim 的手写条目不认领" ); assert_eq!(found[0].1, managed); assert!(claude_global_registered(&config)); assert!(!claude_global_registered(&json!({ "projects": {} }))); assert!(managed_claude_entries(&json!({}), "p 1").is_empty()); } #[test] fn claude_mcp_get_output_is_parsed_by_effective_scope() { // 样本取自 claude 2.x 真实输出(2026-10-01) let local = "lian-jing:\n Scope: Local config (private to you in this project)\n Status: ✔ Connected\n Type: http\n URL: http://127.0.0.1:27190/mcp/projects/p%201\n\nTo remove this server, run: claude mcp remove \"lian-jing\" -s local\n"; assert_eq!( parse_claude_mcp_get(local), Some(ClaudeEffective::Local( "http://127.0.0.1:27190/mcp/projects/p%201".into() )) ); let user = "lian-jing:\r\n Scope: User config (available in all your projects)\r\n Type: sse\r\n URL: http://127.0.0.1:27190/mcp\r\n"; assert_eq!(parse_claude_mcp_get(user), Some(ClaudeEffective::NotLocal)); let missing = "No MCP server found with name: \"lian-jing\". Configured servers: a, b"; assert_eq!( parse_claude_mcp_get(missing), Some(ClaudeEffective::NotLocal) ); assert_eq!( parse_claude_mcp_get("Error: spawn EPERM"), None, "无法识别则按未知处理,不写入" ); assert_eq!( parse_claude_mcp_get("lian-jing:\n Scope: Local config\n Type: stdio\n"), None, "local 条目缺 URL 时无法判定归属" ); } #[test] fn lianjing_project_routes_are_recognized_and_decoded() { assert_eq!( lianjing_project_of("http://127.0.0.1:27190/mcp/projects/p%201"), Some("p 1".into()) ); assert_eq!( lianjing_project_of("http://127.0.0.1:1/mcp/projects/abc/"), Some("abc".into()) ); assert_eq!( lianjing_project_of("http://127.0.0.1:27190/mcp"), None, "全局路由不属于任何项目" ); assert_eq!( lianjing_project_of("http://localhost:27190/mcp/projects/abc"), None, "非炼境地址视为手写" ); assert_eq!( lianjing_project_of("https://example.com/mcp/projects/abc"), None ); } #[test] fn claude_key_covers_root_and_its_descendants_only() { assert!(key_covers_root( "E:/code-project/demo", "E:\\code-project\\demo\\" )); assert!( key_covers_root("e:\\code-project", "E:\\Code-Project\\demo"), "git 根是祖先目录" ); assert!( !key_covers_root("E:/code-project/demo", "E:\\code-project\\demo-2"), "前缀相同的兄弟目录不算" ); assert!( !key_covers_root("E:/code-project/demo/sub", "E:\\code-project\\demo"), "子目录的键不覆盖父目录" ); } #[test] fn project_root_change_detection() { let old = PathBuf::from("E:\\old"); let new = PathBuf::from("E:\\new"); assert!(project_root_changed(Some(&old), Some(&new))); assert!( project_root_changed(None, Some(&new)), "旧根解析不出也要迁移" ); assert!(!project_root_changed(Some(&old), Some(&old))); assert!( !project_root_changed(Some(&old), None), "新根解析不出则不动" ); } #[test] fn dsh_block_is_valid_yaml_with_registry_routing_and_shim_auth() { let roots = vec![ ("e:\\code-project".to_string(), None), ("e:\\code-project\\demo".to_string(), Some("p1".to_string())), ]; let session = DshSessionPlugin { module: "C:/Users/o'brien x/.lian-jing/dsh/lianjing-session-mcp.v1.mjs".into(), shim: "C:\\Users\\o'brien x\\.lian-jing\\bin\\lianjing-mcp-header.cmd".into(), }; let block = render_dsh_block(27190, &roots, Some(&session)); assert!( block.contains( r#"const roots = {"e:\\code-project":null,"e:\\code-project\\demo":"p1"};"# ), "控制面以 null 作停止哨兵" ); assert!( block.contains("hasOwnProperty.call(roots, key)") && block.contains(r"replace(/\\+$/, '')") ); let parsed: serde_yaml::Value = serde_yaml::from_str(&block).expect("托管块必须是合法 YAML"); let entry = &parsed[0]["insert"][0]; assert_eq!(entry["id"], "mcp-lian-jing"); assert_eq!(entry["config"]["serverName"], MCP_KEY); assert_eq!(entry["config"]["transport"], "streamable-http"); assert!(block.contains("url: !!js |") && block.contains("headers: !!js |")); assert!( !block.contains("context.json"), "身份只认登记白名单,不读目录里的 context.json" ); assert!(block.contains("'http://127.0.0.1:27190/mcp/projects/' + encodeURIComponent(id)")); assert!( block.contains(": 'http://127.0.0.1:27190/mcp';"), "未命中回退全局地址" ); assert!(block.contains("path.isAbsolute(base)") && block.contains("'System32', 'cmd.exe'")); assert!( block.contains("ctx.get('profileContext')?.name") && block.contains("if (profile === 'headless') for ("), "多工作区 profile(web/acp/tui/桌面版)不按启动目录路由,防止跨项目带认证" ); assert!( block.contains("process.env.USERPROFILE") && block.contains("'.lian-jing', 'bin'"), "与 Codex/Claude 共用用户目录下的入口" ); assert!( block.contains(MCP_HEADER_SHIM_NAME) && block.contains("windowsVerbatimArguments: true") ); assert!(!block.contains("Bearer "), "托管块不得含任何 token"); // 会话级插件条目:与全局行同一个 insert 列表,路径含引号与空格也必须是合法 YAML 并原样还原 let plugin = &parsed[0]["insert"][1]; assert_eq!(plugin["id"], DSH_SESSION_PLUGIN_ID); assert_eq!(plugin["name"], session.module.as_str()); assert!(session.module.ends_with(DSH_SESSION_PLUGIN_FILE)); let guard = format!( "!process.getBuiltinModule('fs').existsSync({})", json!(session.module) ); assert!( block.contains(&format!(" disabled: !!js {}", json!(guard))), "插件文件缺失时不加载该条目(DSH 0.1.x 会启动失败)" ); assert_eq!(plugin["config"]["port"], 27190); assert_eq!(plugin["config"]["shim"], session.shim.as_str()); assert_eq!( plugin["config"]["roots"]["e:\\code-project"], serde_yaml::Value::Null ); assert_eq!(plugin["config"]["roots"]["e:\\code-project\\demo"], "p1"); let without = render_dsh_block(27190, &roots, None); let parsed: serde_yaml::Value = serde_yaml::from_str(&without).unwrap(); assert_eq!( parsed[0]["insert"].as_sequence().unwrap().len(), 1, "插件文件写不出时只保留全局行" ); } #[test] fn dsh_session_plugin_source_matches_versioned_contract() { // 插件内容随二进制打包;文件名带版本号,内容变化须同步递增(Node 按 URL 缓存 ESM) let version = DSH_SESSION_PLUGIN_FILE .strip_prefix("lianjing-session-mcp.v") .and_then(|rest| rest.strip_suffix(".mjs")) .expect("文件名形如 lianjing-session-mcp.v.mjs"); assert_eq!( DSH_SESSION_PLUGIN_ID, format!("{DSH_SESSION_PLUGIN_ID_PREFIX}-v{version}"), "条目 id 与文件名同一版本号:DSH 只在 id 变化时重载插件" ); assert!(DSH_SESSION_PLUGIN_SOURCE.contains(&format!( "export const name = \"{DSH_SESSION_PLUGIN_ID_PREFIX}\"" ))); // 钉住内容:改了插件却没升版本号时,运行中的 DSH 会一直跑旧代码。 // 改插件后:文件名与 DSH_SESSION_PLUGIN_ID 的版本号一起 +1,再把这里换成新哈希(内容按 LF 计算)。 use sha2::Digest; let digest = sha2::Sha256::digest(DSH_SESSION_PLUGIN_SOURCE.replace("\r\n", "\n").as_bytes()); let hex: String = digest.iter().map(|byte| format!("{byte:02x}")).collect(); assert_eq!( hex, "b8a5c7b251c1cf28623f9c08d352593536fd5051be637c0b1357c270b1090f7d", "插件内容已变化:请同时递增文件名与条目 id 的版本号,再更新此哈希" ); assert!(DSH_SESSION_PLUGIN_SOURCE.contains(&format!("const MCP_KEY = \"{MCP_KEY}\""))); assert!( DSH_SESSION_PLUGIN_SOURCE.contains("/mcp/projects/"), "插件只挂项目专用地址" ); assert!( !DSH_SESSION_PLUGIN_SOURCE.contains("Bearer "), "插件不得含任何 token" ); } #[test] fn dsh_block_upsert_preserves_user_entries_and_refuses_manual_lian_jing() { let user = "- insert:\n - id: mcp-yljt\n config:\n serverName: yljt\n"; let first = upsert_dsh_block(user, &render_dsh_block(27190, &[], None)).unwrap(); assert!(first.starts_with(user.trim_end()), "用户条目原样保留"); let parsed: serde_yaml::Value = serde_yaml::from_str(&first).unwrap(); assert_eq!(parsed.as_sequence().unwrap().len(), 2); let moved = upsert_dsh_block(&first, &render_dsh_block(28000, &[], None)).unwrap(); assert!(moved.contains("127.0.0.1:28000") && !moved.contains("127.0.0.1:27190")); assert_eq!( moved.matches(DSH_MANAGED_START).count(), 1, "原位替换不重复追加" ); assert_eq!( upsert_dsh_block(&moved, &render_dsh_block(28000, &[], None)).unwrap(), moved, "幂等" ); for manual in [ "- insert:\n - id: mcp-lian-jing\n config:\n serverName: lian-jing\n", "- insert:\n - id: other\n config:\n serverName: 'lian-jing' # 手写\n", "- insert: [{id: mcp-lian-jing, config: {serverName: x}}]\n", "- insert:\n - id: lianjing-session-mcp\n name: ./x.mjs\n", "- insert:\n - id: lianjing-session-mcp-v9\n name: ./x.mjs\n", ] { assert!( upsert_dsh_block(manual, &render_dsh_block(27190, &[], None)).is_err(), "{manual}" ); let with_block = format!("{manual}{}", render_dsh_block(27190, &[], None)); assert!( upsert_dsh_block(&with_block, &render_dsh_block(27190, &[], None)).is_err(), "块外手写也须拒绝" ); } assert!( upsert_dsh_block("- insert: [unclosed\n", "b").is_err(), "无法解析时保守拒绝" ); assert!(upsert_dsh_block(&format!("{DSH_MANAGED_START}\n- x\n"), "b").is_err()); assert_eq!(upsert_dsh_block("", "BLOCK\n").unwrap(), "BLOCK\n"); } #[test] fn l1_facts_skip_control_plane_and_wsl_and_codex_helper_round_trips() { let desired = DesiredMcp { port: 27190, server_url: "http://127.0.0.1:27190/mcp/projects/l1".into(), header_helper: shim_helper_command("abc"), refresh_dsh: false, }; let control = ProjectFacts { project_id: "l1", root: std::path::Path::new("E:\\nowhere\\l1"), control_plane: true, wsl: false, }; // 不适用时不读客户端配置(Claude、DSH 的判定都不碰 ~/.claude.json 与 ~/.dsh) for adapter in [&CLAUDE_ADAPTER as &dyn ClientAdapter, &DSH_ADAPTER] { assert_eq!( adapter.l1_facts(&control, &desired).not_applicable, Some(NotApplicableReason::ControlPlane) ); } let wsl = ProjectFacts { project_id: "w", root: std::path::Path::new(r"\\wsl.localhost\Ubuntu\home\p"), control_plane: false, wsl: true, }; for adapter in [&CLAUDE_ADAPTER as &dyn ClientAdapter, &DSH_ADAPTER] { assert_eq!( adapter.l1_facts(&wsl, &desired).not_applicable, Some(NotApplicableReason::Wsl) ); } assert_eq!( CODEX_ADAPTER.l1_facts(&control, &desired).not_applicable, None ); // Codex 的 helper 被 codex mcp get 脱敏:L1 从项目托管块回查,须与写入时逐字一致 let root = std::env::temp_dir().join(format!("lj-l1-codex-{}", uuid::Uuid::new_v4())); let config = root.join(".codex").join("config.toml"); upsert_codex_mcp_config_with( &config, &desired.server_url, &desired.header_helper, &crate::DiskSink, ) .unwrap(); let normal = ProjectFacts { project_id: "l1", root: &root, control_plane: false, wsl: false, }; let facts = CODEX_ADAPTER.l1_facts(&normal, &desired); let _ = std::fs::remove_dir_all(&root); match facts.source { L1Source::Codex { project_entry, project_url, project_helper, } => { assert!(project_entry); assert_eq!(project_url.as_deref(), Some(desired.server_url.as_str())); assert_eq!( project_helper.as_deref(), Some(desired.header_helper.as_str()) ); } other => panic!("Codex 应给出 Codex 来源:{other:?}"), } } #[test] fn claude_l1_entries_match_project_url_in_every_scope() { use probe_l1::ClaudeEntrySource::{Local, Project, User}; let url = "http://127.0.0.1:27190/mcp/projects/p"; let entry = |helper: &str| claude_local_server_entry(url, helper); let config = json!({ "mcpServers": { "lian-jing": entry("user") }, "projects": { "E:/code": { "mcpServers": { "lian-jing": entry("ancestor") } }, "E:/code/proj": { "mcpServers": { "lian-jing": entry("exact") } }, // worktree 被 Claude 映射到主仓库根:键不覆盖本根目录,但 URL 是本项目地址 "E:/code/proj-main": { "mcpServers": { "lian-jing": entry("main-repo") } }, // 覆盖本根目录、却指向别处:不是本项目地址,不作候选 "E:/": { "mcpServers": { "lian-jing": { "type": "http", "url": "http://127.0.0.1:9/mcp" } } } } }); let mcp_json = json!({ "mcpServers": { "lian-jing": entry("project") } }); let entries = claude_l1_entries( &config, Some(&mcp_json), std::path::Path::new("E:\\code\\proj"), url, ); let got: Vec<(probe_l1::ClaudeEntrySource, &str)> = entries .iter() .map(|candidate| { ( candidate.source, candidate.entry["headersHelper"].as_str().unwrap(), ) }) .collect(); assert_eq!( got, vec![ (Local, "exact"), (Local, "ancestor"), (Local, "main-repo"), (Project, "project"), (User, "user"), ] ); // ~/.claude.json 读不到时仍回查 .mcp.json assert_eq!( claude_l1_entries( &Value::Null, Some(&mcp_json), std::path::Path::new("E:\\x"), url ) .len(), 1 ); } #[test] fn managed_helpers_cover_current_and_legacy_shim_forms() { assert!(legacy_shim_helper_command("d1").starts_with("\"%LOCALAPPDATA%\\lian-jing\\bin\\")); assert!(legacy_shim_helper_command("d1").ends_with(&format!("{MCP_HEADER_SHIM_NAME}\" d1"))); assert_ne!(legacy_shim_helper_command("d1"), shim_helper_command("d1")); } /// 实机冒烟(需运行中的炼境、已接入的项目与本机三端客户端): /// LJ_L1_LIVE="|<项目根目录>" cargo test l1_live -- --ignored --nocapture /// 只打印矩阵(不含认证头),由人或统筹会话判读。 #[test] #[ignore = "实机冒烟:起真实客户端自省进程并连运行中的炼境"] fn l1_live_probe_against_running_lianjing() { let spec = std::env::var("LJ_L1_LIVE").expect("设置 LJ_L1_LIVE=|"); let (project_id, root) = spec.split_once('|').expect("格式 |"); let started = std::time::Instant::now(); let report = probe_client_parity_at(project_id, std::path::Path::new(root), 27190).unwrap(); println!("{}", serde_json::to_string_pretty(&report).unwrap()); println!("elapsed_ms={}", started.elapsed().as_millis()); } #[test] fn registered_path_accepts_absolute_and_unc() { assert!(validate_registered_path("E:\\code-project\\demo", "win_path").is_ok()); assert!(validate_registered_path("E:/code-project/demo", "win_path").is_ok()); assert!( validate_registered_path("\\\\wsl.localhost\\Ubuntu\\home\\u\\p", "win_path").is_ok(), "WSL UNC 路径应通过" ); } }