diff --git a/src-tauri/src/commands/projects.rs b/src-tauri/src/commands/projects.rs index 8764da2..d7a1d43 100644 --- a/src-tauri/src/commands/projects.rs +++ b/src-tauri/src/commands/projects.rs @@ -276,7 +276,12 @@ pub fn update_project(id: String, input: UpdateProjectInput) -> Result<(), Strin ) .map_err(|e| e.to_string())?; - // 2. 更新 workspace + // 2. 更新 workspace(路径或平台变化时,先记下旧根目录,供客户端侧配置迁移) + let root_may_change = + input.win_path.is_some() || input.wsl_path.is_some() || input.platform.is_some(); + let old_root = root_may_change + .then(|| crate::mcp_inject::project_root_with_conn(&conn, &id).ok()) + .flatten(); conn.execute( "UPDATE project_workspaces SET wsl_path = COALESCE(?1, wsl_path), @@ -297,6 +302,16 @@ pub fn update_project(id: String, input: UpdateProjectInput) -> Result<(), Strin ) .map_err(|e| e.to_string())?; + // 根目录变化:Claude local 条目按目录挂载、DSH 白名单按根目录匹配,都要迁移到新根。 + // 会调用 claude CLI,放到后台线程,避免同步命令卡住界面。 + if let Some(old_root) = old_root { + let new_root = crate::mcp_inject::project_root_with_conn(&conn, &id).ok(); + if new_root.as_ref() != Some(&old_root) { + drop(conn); + std::thread::spawn(move || crate::mcp_inject::on_project_root_changed(&id, Some(old_root))); + } + } + Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f06fca6..da49b8a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -126,7 +126,8 @@ pub fn run() { Some(r#"{"client":"shim"}"#), ); } - mcp_inject::refresh_dsh_patch(None); + // 刷新 DSH 白名单要读所有项目根目录的 context.json,放到后台线程,不拖慢窗口启动 + std::thread::spawn(|| mcp_inject::refresh_dsh_patch(None)); // ── MCP 单一服务器化一次性迁移(settings 标记位防重复)──── std::thread::spawn(|| { diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index ec5fab6..bbad37c 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -214,7 +214,7 @@ fn find_claude_bin() -> Option { } // 最后尝试 where 命令(GUI 进程 PATH 可能不含用户 bin,but worth trying) - if let Ok(out) = std::process::Command::new("where") + if let Ok(out) = crate::silent_cmd("where") .arg("claude.exe") .output() { @@ -252,7 +252,7 @@ fn find_git_bash() -> Option { } // 从 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 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()); @@ -292,7 +292,7 @@ fn try_register_globally(port: u16) -> bool { return false; }; - match std::process::Command::new(&claude_bin) + match crate::silent_cmd(&claude_bin) .args([ "mcp", "add", @@ -442,22 +442,49 @@ fn run_claude_cli( } } +/// 客户端侧同步(Claude local、DSH 托管块)的日志单独归类:不覆盖诊断里每个项目的「最后一次注入结果」。 +const CLIENT_SYNC_LOG: &str = "mcp_client_sync"; + +type ClaudeSyncJob = (String, PathBuf, Option<(String, String)>); +static CLAUDE_SYNC_QUEUE: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + +/// 所有 Claude local 同步投递到同一个后台线程,按提交顺序串行执行:避免多个 claude CLI 并发 +/// 读改写 ~/.claude.json,也保证「注入后立刻移除」时移除不会被更早的注册越过。调用方不阻塞。 +fn enqueue_claude_sync(project_id: &str, root: PathBuf, desired: Option<(String, String)>) { + let queue = CLAUDE_SYNC_QUEUE.get_or_init(|| { + let (sender, receiver) = std::sync::mpsc::channel::(); + std::thread::spawn(move || { + for (project_id, root, desired) in receiver { + sync_claude_local(&project_id, &root, desired); + } + }); + std::sync::Mutex::new(sender) + }); + if let Ok(sender) = queue.lock() { + let _ = sender.send((project_id.to_string(), root, desired)); + } +} + /// 让 Claude 的 local 条目与期望一致:desired=Some 时注册/刷新,None 时只删除炼境托管的条目。 -/// 手写条目一律不碰;替换失败时用原值回滚。best-effort,结果只记日志。 +/// 手写条目一律不碰;替换失败时用原值回滚。best-effort,结果只记日志。只应经 enqueue_claude_sync 调用。 fn sync_claude_local(project_id: &str, root: &std::path::Path, desired: Option<(String, String)>) { let context = inject_log_context(project_id, "claude"); + let state = read_claude_user_config() + .map(|config| classify_claude_local_entry(&config, root)) + .unwrap_or(ClaudeLocalEntry::Absent); + if matches!((&state, &desired), (ClaudeLocalEntry::Absent, None)) { + return; + } let (Some(claude_bin), Some(git_bash)) = (find_claude_bin(), find_git_bash()) else { db::log_event( - "mcp_inject", + CLIENT_SYNC_LOG, "warn", "claude CLI 或 git-bash 未找到,跳过 Claude 项目级 MCP 同步", Some(&context), ); return; }; - let state = read_claude_user_config() - .map(|config| classify_claude_local_entry(&config, root)) - .unwrap_or(ClaudeLocalEntry::Absent); let remove_args = ["mcp", "remove", MCP_KEY, "--scope", "local"]; let outcome = match (state, desired) { (ClaudeLocalEntry::Manual, _) => { @@ -474,7 +501,7 @@ fn sync_claude_local(project_id: &str, root: &std::path::Path, desired: Option<( let registered = if let ClaudeLocalEntry::Managed(old) = &previous { if let Err(error) = run_claude_cli(&claude_bin, &git_bash, root, &remove_args) { db::log_event( - "mcp_inject", + CLIENT_SYNC_LOG, "warn", &format!("Claude 项目级 MCP 旧条目移除失败: {error}"), Some(&context), @@ -498,9 +525,9 @@ fn sync_claude_local(project_id: &str, root: &std::path::Path, desired: Option<( } }; match outcome { - Ok(message) => db::log_event("mcp_inject", "info", &message, Some(&context)), + Ok(message) => db::log_event(CLIENT_SYNC_LOG, "info", &message, Some(&context)), Err(error) => db::log_event( - "mcp_inject", + CLIENT_SYNC_LOG, "warn", &format!("Claude 项目级 MCP 同步失败: {error}"), Some(&context), @@ -514,16 +541,33 @@ fn sync_claude_local(project_id: &str, root: &std::path::Path, desired: Option<( // 并经稳定 helper 携带认证;任意目录里的 .lian-jing/context.json 不作为授权依据。未命中则回退全局地址, // 保持 GUI 等非项目启动方式的现有行为。项目增删、端口变更时重写白名单。 -/// 与 JS 端 path.resolve(d).toLowerCase() 对齐:反斜杠分隔、去尾部分隔符、小写。 +/// 白名单键规范化,与 JS 端 `path.resolve(d).toLowerCase().replace(/\\+$/, '')` 对齐: +/// 反斜杠分隔、合并重复分隔符(保留 UNC 前缀 \\)、去尾部分隔符、小写。盘符根 E:\ 两端都得到 e:。 fn normalize_dsh_root(path: &str) -> String { - path.replace('/', "\\").trim_end_matches('\\').to_lowercase() + 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() } -/// 已接入(根目录 context.json 的 project_id 与登记一致)、非控制面、非 WSL 的 Windows 项目。 +/// 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, String)> { +) -> Vec<(String, Option)> { let rows: Vec<(String, String)> = conn .prepare( "SELECT id, win_path FROM project_workspaces @@ -539,21 +583,31 @@ fn dsh_project_roots( .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()?; - let bound = context.get("project_id").and_then(Value::as_str) == Some(id.as_str()); - (bound && !project_wants_global_scope(&root)) - .then(|| (normalize_dsh_root(&win_path), id)) + if context.get("project_id").and_then(Value::as_str) != Some(id.as_str()) { + return None; + } + let key = normalize_dsh_root(&win_path); + if context.get("mcp_scope").and_then(Value::as_str) == Some("global") { + return Some((key, None)); + } + let has_token = crate::credential_store::mcp_auth_digest(&id) + .and_then(|digest| crate::credential_store::read_mcp_auth_token_for_digest(store, &digest)) + .is_ok(); + has_token.then(|| (key, Some(id))) }) .collect() } -fn render_dsh_block(port: u16, roots: &[(String, String)]) -> String { +fn render_dsh_block(port: u16, roots: &[(String, Option)]) -> 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))) @@ -564,8 +618,8 @@ fn render_dsh_block(port: u16, roots: &[(String, String)]) -> String { roots_line.as_str(), "let id;", "for (let d = path.resolve(process.cwd()), p; d !== p; p = d, d = path.dirname(d)) {", - " const hit = roots[d.toLowerCase()];", - " if (hit) { id = hit; break; }", + " 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"); @@ -696,7 +750,10 @@ pub fn ensure_dsh_patch( 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, exclude)); + let block = render_dsh_block( + port, + &dsh_project_roots(conn, &crate::credential_store::WindowsCredentialStore, exclude), + ); let next = upsert_dsh_block(¤t, &block)?; if next != current { std::fs::write(&path, next).map_err(|error| format!("写入 DSH 补丁文件失败: {error}"))?; @@ -712,7 +769,7 @@ pub fn refresh_dsh_patch(exclude: Option<&str>) { .and_then(|conn| ensure_dsh_patch(&conn, crate::mcp_server::read_port(), exclude)); if let Err(error) = result { db::log_event( - "mcp_inject", + CLIENT_SYNC_LOG, "warn", &format!("DSH 托管块刷新失败: {error}"), Some(&json!({ "client": "dsh" }).to_string()), @@ -759,7 +816,7 @@ pub fn cleanup_legacy_global_registrations() -> Vec { names.push(format!("lian-jing-{}", &gid[..8])); } for name in names { - let ok = std::process::Command::new(&claude_bin) + let ok = crate::silent_cmd(&claude_bin) .args(["mcp", "remove", "-s", "user", &name]) .env("CLAUDE_CODE_GIT_BASH_PATH", &git_bash) .output() @@ -891,6 +948,11 @@ fn remove_codex_mcp_config(path: &PathBuf) -> Result<(), String> { /// 向项目的 .claude/settings.json 注入单一 lian-jing MCP 配置, /// 并清理旧组级 key(迁移内建于重注入) pub fn inject_project_mcp(project_id: &str) -> Result<(), String> { + inject_project_mcp_with(project_id, true) +} + +/// refresh_dsh=false 供批量重注入使用:逐项目跳过 DSH 刷新,由调用方在末尾统一刷新一次。 +fn inject_project_mcp_with(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)?; @@ -937,20 +999,23 @@ pub fn inject_project_mcp(project_id: &str) -> Result<(), String> { try_register_globally(port); let root = project_root(project_id)?; let desired = (!project_wants_global_scope(&root)).then(|| (server_url, header_helper)); - let owned_id = project_id.to_string(); - // claude CLI 每次调用约 1-2 秒,放到后台线程,避免同步命令阻塞界面 - std::thread::spawn(move || sync_claude_local(&owned_id, &root, desired)); - refresh_dsh_patch(None); + // claude CLI 每次调用约 1-2 秒:投递到串行后台队列,不阻塞界面,也不与其他同步竞态 + enqueue_claude_sync(project_id, root, desired); + if refresh_dsh { + refresh_dsh_patch(None); + } Ok(()) } /// 从项目的 .claude/settings.json 移除炼境 MCP 配置(含旧组级 key)。 /// 调用方需自行保证项目已不属于任何产品组时才调用。 pub fn remove_project_mcp(project_id: &str) -> Result<(), String> { - // 先于登记删除清理客户端侧条目:残留的 local 条目会遮蔽全局注册并指向 404 地址。 + // 先于登记删除算出根目录并排队清理客户端侧条目:残留的 local 条目会遮蔽全局注册并指向 404 地址。 + // 与注册共用同一串行队列,保证移除不会被更早提交的注册越过。 if let Ok(root) = project_root(project_id) { - sync_claude_local(project_id, &root, None); + enqueue_claude_sync(project_id, root, None); } + // 登记此时仍在,用 exclude 立即剔除;token 随后被删,之后的刷新也不会再把它加回白名单 refresh_dsh_patch(Some(project_id)); remove_codex_mcp_config(&codex_config_path(project_id)?)?; let path = settings_path(project_id)?; @@ -1043,15 +1108,43 @@ pub fn reinject_all_mcp() -> Vec { let mut errors = Vec::new(); for project_id in project_ids { - if let Err(e) = inject_project_mcp(&project_id) { + if let Err(e) = inject_project_mcp_with(&project_id, false) { errors.push(format!("项目 {}: {}", project_id, e)); } } - // 每次注入已刷新 DSH 托管块(内嵌端口与白名单);此处兜底一次,失败只记日志 + // 逐项目注入时跳过了 DSH 刷新(内嵌端口与白名单),此处统一刷新一次,失败只记日志 refresh_dsh_patch(None); errors } +/// 项目根目录或平台变更后调用:排队清理旧根下炼境托管的 Claude local 条目;已接入(有 token)的项目 +/// 在新根重新注入(会刷新 DSH 白名单),未接入的只刷新白名单。会调用 claude CLI,调用方应放在后台线程。 +pub fn on_project_root_changed(project_id: &str, old_root: Option) { + if let Some(root) = old_root { + enqueue_claude_sync(project_id, root, None); + } + let injected = crate::credential_store::mcp_auth_digest(project_id) + .and_then(|digest| { + crate::credential_store::read_mcp_auth_token_for_digest( + &crate::credential_store::WindowsCredentialStore, + &digest, + ) + }) + .is_ok(); + if !injected { + refresh_dsh_patch(None); + return; + } + if let Err(error) = inject_project_mcp(project_id) { + db::log_event( + CLIENT_SYNC_LOG, + "warn", + &format!("项目根目录变更后重新注入失败: {error}"), + Some(&inject_log_context(project_id, "all")), + ); + } +} + #[cfg(test)] mod tests { use super::*; @@ -1207,7 +1300,12 @@ mod tests { 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(); - for (id, scope, bound) in [("p1", None, true), ("ctrl", Some("global"), true), ("drift", None, false), ("gone", None, true)] { + 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 }); @@ -1227,13 +1325,24 @@ mod tests { .unwrap(); } assert!(project_wants_global_scope(&base.join("ctrl"))); - let roots = dsh_project_roots(&conn, Some("gone")); - let ids: Vec<&str> = roots.iter().map(|(_, id)| id.as_str()).collect(); - assert_eq!(ids, vec!["p1"], "只保留已绑定、非控制面、未被剔除的项目"); - assert_eq!(roots[0].0, normalize_dsh_root(&base.join("p1").display().to_string())); + let roots = dsh_project_roots(&conn, &store, Some("gone")); + let key = |id: &str| normalize_dsh_root(&base.join(id).display().to_string()); + assert_eq!( + roots, + vec![(key("ctrl"), None), (key("p1"), Some("p1".to_string()))], + "控制面写停止哨兵;只保留已绑定、有 token、未被剔除的项目" + ); let _ = std::fs::remove_dir_all(&base); } + #[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: Value = serde_json::from_str(&claude_local_server_json( @@ -1248,15 +1357,22 @@ mod tests { #[test] fn dsh_block_is_valid_yaml_with_registry_routing_and_shim_auth() { - let roots = vec![("e:\\code-project\\demo".to_string(), "p1".to_string())]; + let roots = vec![ + ("e:\\code-project".to_string(), None), + ("e:\\code-project\\demo".to_string(), Some("p1".to_string())), + ]; let block = render_dsh_block(27190, &roots); + 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(r#"const roots = {"e:\\code-project\\demo":"p1"};"#), "白名单内嵌"); 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';"), "未命中回退全局地址"); diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index b0c3ae3..ad4e572 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -1603,9 +1603,17 @@ async fn handle_scoped_sse_message( headers: HeaderMap, Json(req): Json, ) -> Response { - // 握手后项目可能被删除:每条消息复核登记,失效会话一并移除。 + // 握手后项目可能被删除:每条消息复核登记。只移除确实属于该项目的会话, + // 不能凭请求里任意给出的 session_id 删掉别的会话。 if !scoped_project_registered(&project_id) { - sessions.lock().await.remove(&query.session_id); + let mut guard = sessions.lock().await; + let owned = guard + .get(&query.session_id) + .and_then(|entry| entry.project_scope.as_deref()) + == Some(project_id.as_str()); + if owned { + guard.remove(&query.session_id); + } return unknown_project_response(&project_id, req.id.clone()); } if authorize_sensitive_request(