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; // ── 路径解析 ────────────────────────────────────────────────────────────────── /// 校验并解析登记路径:空串/空白/相对路径一律拒绝。 /// 空路径经 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 liangjing_path(project_id: &str) -> Result { Ok(project_root(project_id)? .join(".claude") .join("liangjing.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 { match std::fs::read_to_string(path) { Ok(content) if content.trim().is_empty() => Ok(json!({})), Ok(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() )) } }), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(json!({})), Err(error) => Err(format!("读取 {label} 失败({}): {error}", path.display())), } } fn write_context_object(path: &PathBuf, value: &Value, label: &str) -> Result<(), String> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .map_err(|error| format!("创建 {label} 目录失败: {error}"))?; } let content = serde_json::to_string_pretty(value).map_err(|error| error.to_string())?; std::fs::write(path, content).map_err(|error| format!("写入 {label} 失败: {error}")) } /// 写工具无关的项目身份锚点。项目根目录是边界,客户端项目 UI 或用户级配置均不是身份来源。 /// 合并写入:保留未来字段与项目定制字段,只有炼境拥有的身份字段会被刷新。 fn write_canonical_context_at(root: &std::path::Path, project_id: &str) -> Result<(), String> { let path = root.join(".lian-jing").join("context.json"); let mut ctx = read_context_object(&path, "canonical 项目上下文")?; 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_context_object(&path, &ctx, "canonical 项目上下文") } fn write_canonical_context(project_id: &str) -> Result<(), String> { let root = project_root(project_id)?; write_canonical_context_at(&root, project_id) } /// 写旧 Claude 上下文投影。它不是项目身份的单一事实来源,保留是为了兼容已接入项目。 fn write_liangjing_context(project_id: &str, port: u16) -> Result<(), String> { let path = liangjing_path(project_id)?; let mut ctx = read_context_object(&path, "Claude 兼容上下文")?; 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_context_object(&path, &ctx, "Claude 兼容上下文") } /// 查找 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) = std::process::Command::new("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) = std::process::Command::new("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); 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 std::process::Command::new(&claude_bin) .args([ "mcp", "add", "--transport", "sse", "-s", "user", MCP_KEY, &server_url, ]) .env("CLAUDE_CODE_GIT_BASH_PATH", &git_bash) .output() { Ok(out) if out.status.success() => { db::log_event("mcp_inject", "info", "全局 MCP 注册成功", Some(MCP_KEY)); true } Ok(out) => { let combined = format!( "{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr) ); // 同名已存在(exit 1)不是故障——CLI 链路健康,注册目标已达成 if combined.contains("already exists") { return true; } db::log_event( "mcp_inject", "warn", &format!("claude mcp add 非零退出: {}", combined), Some(MCP_KEY), ); false } Err(e) => { db::log_event( "mcp_inject", "warn", &format!("执行 claude mcp add 失败: {}", e), Some(MCP_KEY), ); false } } } /// 清理旧组级全局注册(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 = std::process::Command::new(&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(&name)); 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> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|e| format!("创建 .claude 目录失败: {}", e))?; } let content = serde_json::to_string_pretty(value).map_err(|e| e.to_string())?; std::fs::write(path, content).map_err(|e| format!("写入 settings.json 失败: {}", e)) } fn upsert_codex_mcp_config( path: &PathBuf, server_url: &str, header_helper: &str, ) -> Result<(), String> { let helper_literal = serde_json::to_string(header_helper) .map_err(|_| "MCP helper 配置序列化失败".to_string())?; let block = format!( "{CODEX_MCP_MANAGED_START}\n[mcp_servers.{MCP_KEY}]\nurl = \"{server_url}\"\nhttp_headers_helper = {helper_literal}\n{CODEX_MCP_MANAGED_END}\n" ); 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!("读取 Codex config.toml 失败: {error}")), }; 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 => format!( "{}{}{}", ¤t[..start], block, ¤t[end + CODEX_MCP_MANAGED_END.len()..] ), (Some(_), _) | (_, Some(_)) => { return Err(format!( "{} 的炼境 Codex 管理块不完整或顺序错误,请修复标记后重试", path.display() )) } (None, None) if current .lines() .any(|line| line.trim() == format!("[mcp_servers.{MCP_KEY}]")) => { return Err(format!( "{} 已手工定义 [mcp_servers.{MCP_KEY}],炼境不会覆盖", path.display() )); } (None, None) if current.trim().is_empty() => block, (None, None) => format!("{}\n{}", current.trim_end(), block), }; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .map_err(|error| format!("创建 .codex 目录失败: {error}"))?; } std::fs::write(path, next).map_err(|error| format!("写入 Codex config.toml 失败: {error}")) } fn remove_codex_mcp_config(path: &PathBuf) -> 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 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}")) } // ── 公开接口 ────────────────────────────────────────────────────────────────── /// 向项目的 .claude/settings.json 注入单一 lian-jing MCP 配置, /// 并清理旧组级 key(迁移内建于重注入) pub fn inject_project_mcp(project_id: &str) -> 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, )?; let executable = std::env::current_exe() .map_err(|_| "无法定位炼境可执行文件,拒绝写入 MCP 认证配置".to_string())?; let header_helper = format!( "\"{}\" --lianjing-mcp-header {digest}", executable.display() ); write_canonical_context(project_id)?; upsert_codex_mcp_config(&codex_config_path(project_id)?, &server_url, &header_helper)?; let path = settings_path(project_id)?; let mut settings = read_json(&path)?; 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(&path, &settings).inspect(|_| { let ctx = serde_json::json!({"project_id": project_id}).to_string(); log_event("mcp_inject", "info", "MCP 配置注入成功", Some(&ctx)); })?; // 保留 Claude 历史上下文作为兼容投影;项目身份以 .lian-jing/context.json 为准。 write_liangjing_context(project_id, port)?; // 同步注册到 ~/.claude.json,让 Claude Code 能加载 MCP 工具(best-effort) try_register_globally(port); Ok(()) } /// 从项目的 .claude/settings.json 移除炼境 MCP 配置(含旧组级 key)。 /// 调用方需自行保证项目已不属于任何产品组时才调用。 pub fn remove_project_mcp(project_id: &str) -> Result<(), String> { remove_codex_mcp_config(&codex_config_path(project_id)?)?; let path = settings_path(project_id)?; if path.exists() { let mut settings = read_json(&path)?; if let Some(servers) = settings .get_mut("mcpServers") .and_then(|v| v.as_object_mut()) { servers.remove(MCP_KEY); servers.retain(|k, _| !k.starts_with(LEGACY_KEY_PREFIX)); } write_json(&path, &settings)?; } crate::credential_store::delete_mcp_auth_token_for_project( &crate::credential_store::WindowsCredentialStore, project_id, ) } /// 单一服务器化一次性迁移:批量重注入所有托管项目(换新 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 缺失/命令报错), // 此时旧注册也清不掉——不落标记位,等下次启动重试(整个流程幂等) if !register_ok { return; } if let Ok(conn) = db::pool().get() { let _ = conn.execute( "INSERT OR REPLACE INTO settings (key, value) VALUES ('mcp_single_migrated', '1')", [], ); } } /// 端口变更后重新注入所有产品组成员的 MCP 配置 /// 返回注入失败的错误列表(部分失败不影响其他项目) pub fn reinject_all_mcp() -> Vec { let conn = match db::pool().get() { Ok(c) => c, Err(e) => return vec![e.to_string()], }; let project_ids: Vec = { let mut stmt = match conn.prepare("SELECT DISTINCT project_id FROM project_group_map") { Ok(s) => s, Err(e) => return vec![e.to_string()], }; stmt.query_map([], |row| row.get(0)) .map(|iter| iter.filter_map(|r| r.ok()).collect()) .unwrap_or_default() }; drop(conn); let mut errors = Vec::new(); for project_id in project_ids { if let Err(e) = inject_project_mcp(&project_id) { errors.push(format!("项目 {}: {}", project_id, e)); } } errors } #[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 解析)" ); } #[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).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(); assert!(upsert_codex_mcp_config( &path, "http://127.0.0.1:27190/mcp/projects/project%20one", helper, ) .is_err()); assert_eq!(std::fs::read_to_string(&path).unwrap(), manual); let _ = std::fs::remove_dir_all(&base); } #[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 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 路径应通过" ); } }