From ebf43d71c0db4801702056523f832adccff5d48e Mon Sep 17 00:00:00 2001 From: lanrtop Date: Thu, 1 Oct 2026 10:40:08 +0900 Subject: [PATCH 01/15] =?UTF-8?q?feat(mcp/parity):=20=E4=B8=89=E7=AB=AF?= =?UTF-8?q?=E7=BB=8F=E7=A8=B3=E5=AE=9A=20helper=20=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=E8=BF=9E=E9=A1=B9=E7=9B=AE=E4=B8=93=E7=94=A8=E5=9C=B0=E5=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P3:炼境启动写 %LOCALAPPDATA%\lian-jing\bin\lianjing-mcp-header.cmd 指向当前 exe; Codex 项目配置改为引用该入口,不再写本机 exe 绝对路径;verify_onboarding 对旧写法报可升级 - P1:inject_mcp 以 local scope 为 Claude 注册项目专用地址 + headersHelper(不进仓库); DSH 机器级补丁写托管块,!!js 按启动目录选项目地址并经 cmd 调用同一入口,项目外回退全局 - 服务端:scoped route 拒绝未登记的 project_id(404,数据库不可用同样拒绝) Feature-Confirmed: true Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- docs/ai-context/project-context.yaml | 2 +- src-tauri/src/commands/onboarding.rs | 41 +++- src-tauri/src/lib.rs | 9 + src-tauri/src/mcp_inject.rs | 312 ++++++++++++++++++++++++++- src-tauri/src/mcp_server.rs | 75 ++++++- 6 files changed, 429 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ecefa32..60e869a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,7 @@ src-tauri/src/credential_store.rs Windows 本机受保护凭证存储与旧值 src-tauri/src/mcp_server.rs MCP Server 路由层 + SSE 协议(agent 调用炼境的入口) src-tauri/src/mcp_tools.rs MCP 工具 schema + dispatch + 实现(工具清单以 tools_list_result 为准) src-tauri/src/mcp_webhook.rs Gitea webhook 处理(push/PR 事件写入飞轮) -src-tauri/src/mcp_inject.rs 接入注入(写入 canonical `.lian-jing/context.json`、`.codex/config.toml` 与 Claude 兼容配置) +src-tauri/src/mcp_inject.rs 接入注入与三端适配(canonical context、Codex 项目配置、Claude local 条目、DSH 托管块、%LOCALAPPDATA% 稳定 helper 入口) src-tauri/resources/onboarding/ 接入包分发模板(AGENTS.md/lefthook/git-workflow) .blueprint/ 项目蓝图(manifest + 模块详情 + 任务卡) docs/ai-context/ AI 上下文文档(速查卡、契约映射) diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index 62ded75..16307fd 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -6,7 +6,7 @@ contracts: - label: API 接口(Axum) paths: - "src-tauri/src/mcp_server.rs" - doc: "MCP 仅监听 127.0.0.1;新项目使用 /mcp/projects/ scoped route,连接 scope 拒绝跨项目参数和全局管理写工具。旧 /mcp 只作兼容迁移。read_project_dev_variable 与 read_project_test_account 是两种有界开发值读取:仅 scoped 路由、匹配项目的本机 bearer 认证后单项读取;认证失败在 dispatch 前拒绝。路由变更需同步 Agent 按钮体系文档。" + doc: "MCP 仅监听 127.0.0.1;新项目使用 /mcp/projects/ scoped route,连接 scope 拒绝跨项目参数和全局管理写工具。scoped route 只服务已登记项目,未知 project_id 返回 404(数据库不可用同样拒绝)。各客户端经 %LOCALAPPDATA%/lian-jing/bin/lianjing-mcp-header.cmd 稳定入口取认证头(炼境每次启动指向当前 exe):Codex 写项目 .codex/config.toml,Claude 写 ~/.claude.json 的 local 条目(优先于 user 级全局),DSH 写 ~/.dsh/cordis.patch.yml 托管块(!!js 按启动目录选项目地址,项目外回退全局)。旧 /mcp 只作兼容迁移。read_project_dev_variable 与 read_project_test_account 是两种有界开发值读取:仅 scoped 路由、匹配项目的本机 bearer 认证后单项读取;认证失败在 dispatch 前拒绝。路由变更需同步 Agent 按钮体系文档。" - label: MCP 工具接口 paths: diff --git a/src-tauri/src/commands/onboarding.rs b/src-tauri/src/commands/onboarding.rs index 7411b06..f230bc0 100644 --- a/src-tauri/src/commands/onboarding.rs +++ b/src-tauri/src/commands/onboarding.rs @@ -670,6 +670,23 @@ fn codex_mcp_server_url(content: &str) -> Option<&str> { None } +/// 旧注入把本机 exe 绝对路径写进 helper(换构建即失效、换机器即错);新注入只引用 +/// %LOCALAPPDATA% 下的稳定入口。只判定显式的旧写法,未配置 helper 不在此处判定。 +fn codex_helper_uses_machine_path(content: &str) -> bool { + let mut in_lian_jing = false; + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('[') { + in_lian_jing = trimmed == "[mcp_servers.lian-jing]"; + continue; + } + if in_lian_jing && trimmed.starts_with("http_headers_helper") { + return trimmed.contains("--lianjing-mcp-header") && !trimmed.contains("%LOCALAPPDATA%"); + } + } + false +} + pub(crate) fn check_codex_mcp( content: Option<&str>, expected_project_id: &str, @@ -684,7 +701,13 @@ pub(crate) fn check_codex_mcp( }; let expected_url = crate::mcp_server::scoped_mcp_url(expected_port, expected_project_id); let configured_url = codex_mcp_server_url(content); - if configured_url == Some(expected_url.as_str()) { + if configured_url == Some(expected_url.as_str()) && codex_helper_uses_machine_path(content) { + VerifyCheck::with_state( + "Codex MCP 已配置", + VerifyState::Legacy, + "helper 仍写着本机 exe 绝对路径(换构建即失效,且会随仓库提交)——调用 inject_mcp 升级为 %LOCALAPPDATA% 稳定入口", + ) + } else if configured_url == Some(expected_url.as_str()) { VerifyCheck::with_state( "Codex MCP 已配置", VerifyState::Ready, @@ -1501,6 +1524,22 @@ mod tests { assert_eq!(wrong_port.state, VerifyState::Missing); } + #[test] + fn codex_mcp_check_flags_machine_path_helper_for_upgrade() { + let url = "url = \"http://127.0.0.1:27190/mcp/projects/p1\"\n"; + let machine = format!( + "[mcp_servers.lian-jing]\n{url}http_headers_helper = \"\\\"E:\\\\x\\\\target\\\\debug\\\\app.exe\\\" --lianjing-mcp-header d1\"\n" + ); + let legacy = check_codex_mcp(Some(&machine), "p1", 27190); + assert_eq!(legacy.state, VerifyState::Legacy); + assert!(legacy.detail.contains("%LOCALAPPDATA%")); + + let stable = format!( + "[mcp_servers.lian-jing]\n{url}http_headers_helper = \"\\\"%LOCALAPPDATA%\\\\lian-jing\\\\bin\\\\lianjing-mcp-header.cmd\\\" d1\"\n" + ); + assert_eq!(check_codex_mcp(Some(&stable), "p1", 27190).state, VerifyState::Ready); + } + /// 回归:空路径登记曾把接入包写进炼境进程 cwd(src-tauri/,2026-07-15 批量实调实锤)。 #[test] fn apply_rejects_empty_or_missing_path() { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d9dba65..b1e5342 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -116,6 +116,15 @@ pub fn run() { let mcp_port = mcp_server::read_port(); tauri::async_runtime::spawn(mcp_server::start(mcp_port)); + // ── 三端共用的稳定 helper 入口与 DSH 托管块(best-effort,失败只记日志)── + // 每次启动把 shim 指向当前 exe,换构建目录后各客户端无需逐项目重注入。 + if let Err(error) = mcp_inject::ensure_header_shim() { + db::log_event("mcp_inject", "warn", &format!("MCP helper 入口写入失败: {error}"), None); + } + if let Err(error) = mcp_inject::ensure_dsh_patch(mcp_port) { + db::log_event("mcp_inject", "warn", &format!("DSH 托管块写入失败: {error}"), None); + } + // ── MCP 单一服务器化一次性迁移(settings 标记位防重复)──── std::thread::spawn(|| { std::thread::sleep(std::time::Duration::from_secs(10)); // 等 DB 就绪 diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index 433563b..d4e1736 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -9,6 +9,52 @@ 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 入口文件名:位于 %LOCALAPPDATA%\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 并展开 %LOCALAPPDATA%;DSH 由 !!js 经 cmd 调用。 +// 项目配置只引用 %LOCALAPPDATA% 下的固定入口,不含本机 exe 路径与用户名,换构建无需逐项目重注入。 + +fn header_shim_path() -> Result { + let base = std::env::var("LOCALAPPDATA") + .map_err(|_| "LOCALAPPDATA 未设置,无法写入 MCP helper 入口".to_string())?; + Ok(PathBuf::from(base) + .join("lian-jing") + .join("bin") + .join(MCP_HEADER_SHIM_NAME)) +} + +/// cmd 按 OEM 代码页读批处理;exe 路径含非 ASCII 字符时先切到 UTF-8。 +fn render_header_shim(executable: &std::path::Path) -> String { + let exe = executable.display().to_string(); + let codepage = if exe.is_ascii() { "" } else { "chcp 65001 >nul\r\n" }; + format!("@echo off\r\n{codepage}\"{exe}\" --lianjing-mcp-header %1\r\nexit /b %ERRORLEVEL%\r\n") +} + +/// 写入或更新稳定 helper 入口;内容未变不重写。炼境启动与每次注入时调用。 +pub fn ensure_header_shim() -> Result { + let path = header_shim_path()?; + let executable = std::env::current_exe() + .map_err(|_| "无法定位炼境可执行文件,拒绝写入 MCP helper 入口".to_string())?; + let content = render_header_shim(&executable); + if std::fs::read_to_string(&path).ok().as_deref() != Some(content.as_str()) { + 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}"))?; + } + Ok(path) +} + +/// 客户端配置中的 helper 命令:只含 %LOCALAPPDATA% 与非秘密 digest,可安全提交进项目仓库。 +fn shim_helper_command(digest: &str) -> String { + format!("\"%LOCALAPPDATA%\\lian-jing\\bin\\{MCP_HEADER_SHIM_NAME}\" {digest}") +} // ── 路径解析 ────────────────────────────────────────────────────────────────── @@ -293,6 +339,188 @@ fn try_register_globally(port: u16) -> bool { } } +/// Claude Code local scope 条目:项目专用地址 + 稳定 helper。local 优先于 user 级全局注册且不进仓库。 +fn claude_local_server_json(server_url: &str, header_helper: &str) -> String { + json!({ + "type": "http", + "url": server_url, + "headersHelper": header_helper, + }) + .to_string() +} + +/// 在项目目录以 local scope 注册 lian-jing(先删后加,幂等)。best-effort:失败只记日志, +/// 不影响 Codex 与项目文件注入;返回是否成功。 +fn try_register_claude_local(root: &std::path::Path, server_url: &str, header_helper: &str) -> bool { + let (Some(claude_bin), Some(git_bash)) = (find_claude_bin(), find_git_bash()) else { + db::log_event( + "mcp_inject", + "warn", + "claude CLI 或 git-bash 未找到,跳过 Claude 项目级 MCP 注册", + None, + ); + return false; + }; + let _ = std::process::Command::new(&claude_bin) + .current_dir(root) + .args(["mcp", "remove", MCP_KEY, "--scope", "local"]) + .env("CLAUDE_CODE_GIT_BASH_PATH", &git_bash) + .output(); + let payload = claude_local_server_json(server_url, header_helper); + match std::process::Command::new(&claude_bin) + .current_dir(root) + .args(["mcp", "add-json", MCP_KEY, &payload, "--scope", "local"]) + .env("CLAUDE_CODE_GIT_BASH_PATH", &git_bash) + .output() + { + Ok(out) if out.status.success() => { + db::log_event("mcp_inject", "info", "Claude 项目级 MCP 注册成功", Some(MCP_KEY)); + true + } + Ok(out) => { + let combined = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + db::log_event( + "mcp_inject", + "warn", + &format!("claude mcp add-json 非零退出: {combined}"), + Some(MCP_KEY), + ); + false + } + Err(error) => { + db::log_event( + "mcp_inject", + "warn", + &format!("执行 claude mcp add-json 失败: {error}"), + Some(MCP_KEY), + ); + false + } + } +} + +// ── DSH 机器级配置托管块 ────────────────────────────────────────────────────── +// DSH 配置无法按项目拆分,但 !!js 在加载时以启动目录求值:在项目目录(或子目录)启动时连项目专用地址 +// 并经稳定 helper 携带认证;项目外回退全局地址,保持 GUI 等非项目启动方式的现有行为。 + +fn render_dsh_block(port: u16) -> String { + // 逐行显式缩进:YAML 依赖缩进,不能用字符串续行(会吞掉下一行行首空白)。 + fn indent(spaces: usize, lines: &[&str]) -> Vec { + lines.iter().map(|line| format!("{}{line}", " ".repeat(spaces))).collect() + } + let find_project_id = [ + "const fs = process.getBuiltinModule('fs'), path = process.getBuiltinModule('path');", + "let id;", + "for (let d = process.cwd(), p; d !== p; p = d, d = path.dirname(d)) {", + " const f = path.join(d, '.lian-jing', 'context.json');", + " if (fs.existsSync(f)) { try { id = JSON.parse(fs.readFileSync(f, 'utf8')).project_id; } catch {} 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(process.env.LOCALAPPDATA || '', 'lian-jing', 'bin', '{MCP_HEADER_SHIM_NAME}');" + ); + let header_body = [ + "if (!id) return {};", + "const crypto = process.getBuiltinModule('crypto'), cp = process.getBuiltinModule('child_process');", + "const digest = crypto.createHash('sha256').update(id).digest('hex');", + shim_line.as_str(), + "// Node 拒绝直接执行 .cmd(EINVAL),须经 cmd /d /s /c 原样传参。", + "const out = cp.execFileSync(process.env.ComSpec || 'cmd.exe', ['/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, &["})()"])); + lines.push(DSH_MANAGED_END.to_string()); + let mut block = lines.join("\n"); + block.push('\n'); + block +} + +/// 纯函数:把托管块合并进 DSH 补丁文件内容。已有托管块则原位替换; +/// 块外存在手写的 lian-jing 条目则拒绝覆盖(与 Codex 手工同名 server 的处理一致)。 +fn upsert_dsh_block(current: &str, block: &str) -> Result { + match (current.find(DSH_MANAGED_START), current.find(DSH_MANAGED_END)) { + (Some(start), Some(end)) if end >= start => Ok(format!( + "{}{}{}", + ¤t[..start], + block.trim_end_matches('\n'), + ¤t[end + DSH_MANAGED_END.len()..] + )), + (Some(_), _) | (_, Some(_)) => Err("DSH 补丁文件中的炼境托管块不完整或顺序错误".into()), + (None, None) + if current.lines().any(|line| { + let t = line.trim(); + t == "id: mcp-lian-jing" || t == format!("serverName: {MCP_KEY}") + }) => + { + Err("DSH 补丁文件已手工定义 lian-jing,炼境不会覆盖;移除手写条目后重试".into()) + } + (None, None) if current.trim().is_empty() => Ok(block.to_string()), + (None, None) => Ok(format!("{}\n{}", current.trim_end(), block)), + } +} + +/// 未安装 DSH(无 ~/.dsh)时跳过;返回是否写入或已是最新。 +pub fn ensure_dsh_patch(port: u16) -> Result { + let home = 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()) + })?; + if !home.is_dir() { + return Ok(false); + } + 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 next = upsert_dsh_block(¤t, &render_dsh_block(port))?; + if next != current { + std::fs::write(&path, next).map_err(|error| format!("写入 DSH 补丁文件失败: {error}"))?; + } + Ok(true) +} + /// 清理旧组级全局注册(lian-jing-{gid},含完整 UUID 与短 8 位两种历史命名)。 /// 幂等 best-effort:不存在的名字 remove 失败仅忽略。返回成功移除的名字列表。 pub fn cleanup_legacy_global_registrations() -> Vec { @@ -471,12 +699,8 @@ pub fn inject_project_mcp(project_id: &str) -> Result<(), String> { &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() - ); + ensure_header_shim()?; + let header_helper = shim_helper_command(&digest); write_canonical_context(project_id)?; upsert_codex_mcp_config(&codex_config_path(project_id)?, &server_url, &header_helper)?; let path = settings_path(project_id)?; @@ -508,8 +732,10 @@ pub fn inject_project_mcp(project_id: &str) -> Result<(), String> { // 保留 Claude 历史上下文作为兼容投影;项目身份以 .lian-jing/context.json 为准。 write_liangjing_context(project_id, port)?; - // 同步注册到 ~/.claude.json,让 Claude Code 能加载 MCP 工具(best-effort) + // 同步注册到 ~/.claude.json:user 级全局注册作兜底,local 级项目条目优先生效(均 best-effort)。 + // 项目 .claude/settings.json 的 mcpServers 不被 Claude Code 读取,仅保留作兼容。 try_register_globally(port); + try_register_claude_local(&project_root(project_id)?, &server_url, &header_helper); Ok(()) } @@ -611,6 +837,10 @@ pub fn reinject_all_mcp() -> Vec { errors.push(format!("项目 {}: {}", project_id, e)); } } + // DSH 托管块内嵌端口,端口变更后一并刷新 + if let Err(e) = ensure_dsh_patch(crate::mcp_server::read_port()) { + errors.push(format!("DSH: {e}")); + } errors } @@ -719,6 +949,74 @@ mod tests { 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, + "\"%LOCALAPPDATA%\\lian-jing\\bin\\lianjing-mcp-header.cmd\" qa-digest" + ); + assert!(!helper.contains(":\\"), "不得含盘符绝对路径"); + assert!(!helper.to_lowercase().contains("users"), "不得含用户目录"); + } + + #[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\r\nexit /b %ERRORLEVEL%\r\n" + ); + let unicode = render_header_shim(std::path::Path::new("C:\\炼境\\app.exe")); + assert!(unicode.contains("chcp 65001 >nul\r\n"), "非 ASCII 路径须切 UTF-8 代码页"); + } + + #[test] + fn claude_local_entry_uses_scoped_url_and_shim() { + let entry: Value = serde_json::from_str(&claude_local_server_json( + "http://127.0.0.1:27190/mcp/projects/p1", + &shim_helper_command("d1"), + )) + .unwrap(); + 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("%LOCALAPPDATA%")); + } + + #[test] + fn dsh_block_is_valid_yaml_with_cwd_routing_and_shim_auth() { + let block = render_dsh_block(27190); + 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("'http://127.0.0.1:27190/mcp/projects/' + encodeURIComponent(id)")); + assert!(block.contains(": 'http://127.0.0.1:27190/mcp';"), "项目外回退全局地址"); + assert!(block.contains(MCP_HEADER_SHIM_NAME) && block.contains("windowsVerbatimArguments: true")); + assert!(!block.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)).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)).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)).unwrap(), moved, "幂等"); + + let manual = "- insert:\n - id: mcp-lian-jing\n config:\n serverName: lian-jing\n"; + assert!(upsert_dsh_block(manual, &render_dsh_block(27190)).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 registered_path_accepts_absolute_and_unc() { assert!(validate_registered_path("E:\\code-project\\demo", "win_path").is_ok()); diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 52b80aa..d7fde69 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -318,11 +318,43 @@ async fn handle_mcp(headers: HeaderMap, Json(req): Json) -> Response } } +/// 项目专用路由只服务已登记项目。未知 project_id 一律拒绝:否则会给 agent 一套看似可用、 +/// 实则每次调用都越权失败的工具集(2026-09-30 实测 /mcp/projects/<任意字符串> 返回完整 32 个工具)。 +/// 数据库不可用时同样拒绝(fail closed)。 +fn scoped_project_registered(project_id: &str) -> bool { + crate::db::pool() + .get() + .ok() + .and_then(|conn| { + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM project_workspaces WHERE id = ?1)", + [project_id], + |row| row.get::<_, bool>(0), + ) + .ok() + }) + .unwrap_or(false) +} + +fn unknown_project_response(project_id: &str, id: Option) -> Response { + ( + StatusCode::NOT_FOUND, + Json(json!({ + "jsonrpc": "2.0", "id": id, + "error": { "code": -32001, "message": format!("项目未登记: {project_id}(检查 .lian-jing/context.json 或重跑 inject_mcp)") } + })), + ) + .into_response() +} + async fn handle_scoped_mcp( Path(project_id): Path, headers: HeaderMap, Json(req): Json, ) -> Response { + if !scoped_project_registered(&project_id) { + return unknown_project_response(&project_id, req.id.clone()); + } if authorize_sensitive_request( &req, Some(&project_id), @@ -377,7 +409,10 @@ async fn handle_sse_connect( async fn handle_scoped_sse_connect( Path(project_id): Path, State(sessions): State, -) -> Sse>> { +) -> Response { + if !scoped_project_registered(&project_id) { + return unknown_project_response(&project_id, None); + } let session_id = uuid::Uuid::new_v4().to_string(); let (tx, rx) = mpsc::channel::<(String, String)>(32); let endpoint_uri = scoped_sse_messages_url(read_port(), &project_id, &session_id); @@ -392,7 +427,9 @@ async fn handle_scoped_sse_connect( let stream = ReceiverStream::new(rx).map(|(evt_name, data)| { Ok::(Event::default().event(evt_name).data(data)) }); - Sse::new(stream).keep_alive(KeepAlive::default()) + Sse::new(stream) + .keep_alive(KeepAlive::default()) + .into_response() } #[cfg(test)] @@ -1455,6 +1492,40 @@ mod tests { assert!(has_initialized_project_client(project_id)); } + #[tokio::test] + async fn scoped_route_rejects_unregistered_project() { + db::init_test_pool(); + let known = format!("scoped-known-{}", uuid::Uuid::new_v4()); + { + let conn = db::pool().get().unwrap(); + conn.execute( + "INSERT INTO project_profiles (id, name) VALUES (?1, 'Scoped Known')", + [format!("{known}-profile")], + ) + .unwrap(); + conn.execute( + "INSERT INTO project_workspaces (id, profile_id) VALUES (?1, ?2)", + rusqlite::params![known, format!("{known}-profile")], + ) + .unwrap(); + } + let unknown = handle_scoped_mcp( + Path("__not_registered__".to_string()), + HeaderMap::new(), + Json(rpc("tools/list")), + ) + .await; + assert_eq!(unknown.status(), StatusCode::NOT_FOUND); + + let registered = + handle_scoped_mcp(Path(known.clone()), HeaderMap::new(), Json(rpc("tools/list"))).await; + assert_eq!(registered.status(), StatusCode::OK); + + let sessions: Sessions = Arc::new(Mutex::new(HashMap::new())); + let sse = handle_scoped_sse_connect(Path("__not_registered__".to_string()), State(sessions)).await; + assert_eq!(sse.status(), StatusCode::NOT_FOUND); + } + #[test] fn loopback_urls_share_one_ipv4_base() { let port = 27190; -- 2.45.2 From 9a1635a22576253c58e7c001c837f8b32a233bd8 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Thu, 1 Oct 2026 10:43:58 +0900 Subject: [PATCH 02/15] =?UTF-8?q?docs(blueprint/agent-client-parity):=20P1?= =?UTF-8?q?=20=E8=BF=9B=E8=A1=8C=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .blueprint/modules/agent-client-parity.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.blueprint/modules/agent-client-parity.md b/.blueprint/modules/agent-client-parity.md index 060077d..9adcb66 100644 --- a/.blueprint/modules/agent-client-parity.md +++ b/.blueprint/modules/agent-client-parity.md @@ -80,8 +80,8 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。 ## 任务拆解 -### 📋 P1 — 统一连接项目专用地址(Claude + DSH) -- status: todo +### 🔵 P1 — 统一连接项目专用地址(Claude + DSH) +- status: in_progress - complexity: M - files: src-tauri/src/mcp_inject.rs, docs/ai-context/agent-button-system.md - depends: H1, H2 -- 2.45.2 From 0c40e1a5c99c98d45c94e3442e8321fcb17837bf Mon Sep 17 00:00:00 2001 From: lanrtop Date: Thu, 1 Oct 2026 11:47:41 +0900 Subject: [PATCH 03/15] =?UTF-8?q?fix(mcp/parity):=20=E5=AF=B9=E6=8A=97?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=209=20=E9=A1=B9=E4=BF=AE=E5=A4=8D=E2=80=94?= =?UTF-8?q?=E2=80=94=E6=8E=A7=E5=88=B6=E9=9D=A2=E4=BF=9D=E7=95=99=E5=85=A8?= =?UTF-8?q?=E5=B1=80=E3=80=81DSH=20=E6=94=B9=E7=99=BB=E8=AE=B0=E7=99=BD?= =?UTF-8?q?=E5=90=8D=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 控制面项目(context.json mcp_scope=global,炼境自身已声明)不写 Claude local 条目、 不入 DSH 白名单,避免遮蔽全局注册与跨项目工具(审查 high) - DSH 托管块内嵌炼境登记的「项目根目录→project_id」白名单,不再以任意目录里的 context.json 作为取 token 依据;LOCALAPPDATA/SystemRoot 须为绝对路径 - remove_project_mcp 同步清理 Claude local 条目并剔除 DSH 白名单;SSE 消息路由复核登记 - Claude local 只替换炼境托管条目,手写不覆盖,替换失败回滚;改走 silent_cmd 并移至后台线程 - 手写 DSH 条目改用 serde_yaml 检测(引号/注释/流式写法),无法解析时保守拒绝 - shim 中 exe 路径的 % 转义为 %%;注入日志 context 统一为 JSON,诊断查询防御非 JSON 行 Co-Authored-By: Claude Opus 5.5 --- .lian-jing/context.json | 3 +- docs/ai-context/project-context.yaml | 4 +- src-tauri/src/lib.rs | 11 +- src-tauri/src/mcp_inject.rs | 451 +++++++++++++++++++++------ src-tauri/src/mcp_server.rs | 5 + src-tauri/src/mcp_tools.rs | 2 + 6 files changed, 381 insertions(+), 95 deletions(-) diff --git a/.lian-jing/context.json b/.lian-jing/context.json index 1f673e8..2afb1e1 100644 --- a/.lian-jing/context.json +++ b/.lian-jing/context.json @@ -1,4 +1,5 @@ { "project_id": "dev-manager-tauri", - "schema_version": 1 + "schema_version": 1, + "mcp_scope": "global" } \ No newline at end of file diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index 16307fd..0b615b3 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -6,7 +6,7 @@ contracts: - label: API 接口(Axum) paths: - "src-tauri/src/mcp_server.rs" - doc: "MCP 仅监听 127.0.0.1;新项目使用 /mcp/projects/ scoped route,连接 scope 拒绝跨项目参数和全局管理写工具。scoped route 只服务已登记项目,未知 project_id 返回 404(数据库不可用同样拒绝)。各客户端经 %LOCALAPPDATA%/lian-jing/bin/lianjing-mcp-header.cmd 稳定入口取认证头(炼境每次启动指向当前 exe):Codex 写项目 .codex/config.toml,Claude 写 ~/.claude.json 的 local 条目(优先于 user 级全局),DSH 写 ~/.dsh/cordis.patch.yml 托管块(!!js 按启动目录选项目地址,项目外回退全局)。旧 /mcp 只作兼容迁移。read_project_dev_variable 与 read_project_test_account 是两种有界开发值读取:仅 scoped 路由、匹配项目的本机 bearer 认证后单项读取;认证失败在 dispatch 前拒绝。路由变更需同步 Agent 按钮体系文档。" + doc: "MCP 仅监听 127.0.0.1;新项目使用 /mcp/projects/ scoped route,连接 scope 拒绝跨项目参数和全局管理写工具。scoped route 只服务已登记项目,未知 project_id 返回 404(数据库不可用同样拒绝)。各客户端经 %LOCALAPPDATA%/lian-jing/bin/lianjing-mcp-header.cmd 稳定入口取认证头(炼境每次启动指向当前 exe):Codex 写项目 .codex/config.toml,Claude 写 ~/.claude.json 的 local 条目(优先于 user 级全局),DSH 写 ~/.dsh/cordis.patch.yml 托管块(内嵌炼境登记的「项目根目录→project_id」白名单,!!js 从启动目录向上精确匹配根目录才选项目地址并取认证,任意目录里的 context.json 不作授权依据;未命中回退全局;项目增删与端口变更时重写)。Claude local 条目与 DSH 块只替换炼境托管的条目,手写同名配置不覆盖;移除项目时一并清理。旧 /mcp 只作兼容迁移。read_project_dev_variable 与 read_project_test_account 是两种有界开发值读取:仅 scoped 路由、匹配项目的本机 bearer 认证后单项读取;认证失败在 dispatch 前拒绝。路由变更需同步 Agent 按钮体系文档。" - label: MCP 工具接口 paths: @@ -25,7 +25,7 @@ contracts: - ".lian-jing/context.json" - ".lian-jing/capability-packs.json" - ".claude/liangjing.json" - doc: "`.lian-jing/context.json` 是 project_id 的工具无关单一事实来源;`.claude/liangjing.json` 仅为兼容投影;`.lian-jing/capability-packs.json` 是炼境分发基线的版本描述。lian-jing skill 以同一模板双投影:`.agents/skills/lian-jing/`(Codex、DSH 读取)与 `.claude/skills/lian-jing/`(Claude Code 不读 .agents/skills),两份内容必须一致。迁移必须保留未知字段;验证必须返回显式状态与 upgrade_hint;升级仅覆盖炼境托管文件,绝不覆盖项目定制或手工 MCP 配置,且不得以客户端全局配置或项目 UI 身份替代项目根目录上下文。" + doc: "`.lian-jing/context.json` 是 project_id 的工具无关单一事实来源;`.claude/liangjing.json` 仅为兼容投影;`.lian-jing/capability-packs.json` 是炼境分发基线的版本描述。lian-jing skill 以同一模板双投影:`.agents/skills/lian-jing/`(Codex、DSH 读取)与 `.claude/skills/lian-jing/`(Claude Code 不读 .agents/skills),两份内容必须一致。context.json 可选字段 mcp_scope=global 声明控制面项目(如炼境自身):Claude 不写 local 条目、DSH 不入白名单,保留全局地址与跨项目工具。迁移必须保留未知字段;验证必须返回显式状态与 upgrade_hint;升级仅覆盖炼境托管文件,绝不覆盖项目定制或手工 MCP 配置,且不得以客户端全局配置或项目 UI 身份替代项目根目录上下文。" - label: Tauri command 接口(前后端契约) paths: diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b1e5342..f06fca6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -119,11 +119,14 @@ pub fn run() { // ── 三端共用的稳定 helper 入口与 DSH 托管块(best-effort,失败只记日志)── // 每次启动把 shim 指向当前 exe,换构建目录后各客户端无需逐项目重注入。 if let Err(error) = mcp_inject::ensure_header_shim() { - db::log_event("mcp_inject", "warn", &format!("MCP helper 入口写入失败: {error}"), None); - } - if let Err(error) = mcp_inject::ensure_dsh_patch(mcp_port) { - db::log_event("mcp_inject", "warn", &format!("DSH 托管块写入失败: {error}"), None); + db::log_event( + "mcp_inject", + "warn", + &format!("MCP helper 入口写入失败: {error}"), + Some(r#"{"client":"shim"}"#), + ); } + 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 d4e1736..ec5fab6 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -28,8 +28,9 @@ fn header_shim_path() -> Result { } /// cmd 按 OEM 代码页读批处理;exe 路径含非 ASCII 字符时先切到 UTF-8。 +/// 路径里的 % 必须写成 %%,否则会和 %1 配对被当作变量展开吞掉。 fn render_header_shim(executable: &std::path::Path) -> String { - let exe = executable.display().to_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\r\nexit /b %ERRORLEVEL%\r\n") } @@ -306,7 +307,7 @@ fn try_register_globally(port: u16) -> bool { .output() { Ok(out) if out.status.success() => { - db::log_event("mcp_inject", "info", "全局 MCP 注册成功", Some(MCP_KEY)); + db::log_event("mcp_inject", "info", "全局 MCP 注册成功", Some(&global_log_context())); true } Ok(out) => { @@ -323,7 +324,7 @@ fn try_register_globally(port: u16) -> bool { "mcp_inject", "warn", &format!("claude mcp add 非零退出: {}", combined), - Some(MCP_KEY), + Some(&global_log_context()), ); false } @@ -332,13 +333,34 @@ fn try_register_globally(port: u16) -> bool { "mcp_inject", "warn", &format!("执行 claude mcp add 失败: {}", e), - Some(MCP_KEY), + 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_json(server_url: &str, header_helper: &str) -> String { json!({ @@ -349,75 +371,201 @@ fn claude_local_server_json(server_url: &str, header_helper: &str) -> String { .to_string() } -/// 在项目目录以 local scope 注册 lian-jing(先删后加,幂等)。best-effort:失败只记日志, -/// 不影响 Codex 与项目文件注入;返回是否成功。 -fn try_register_claude_local(root: &std::path::Path, server_url: &str, header_helper: &str) -> bool { +#[derive(Debug, PartialEq)] +enum ClaudeLocalEntry { + Absent, + /// 炼境写入的条目(项目路由 + 稳定 helper),可安全替换或删除;附原值供失败回滚。 + Managed(Value), + /// 用户手写的同名条目:炼境不覆盖、不删除。 + Manual, +} + +/// ~/.claude.json 的项目键是 Claude 记录的路径(实测为正斜杠盘符形式),比较时统一分隔符与大小写。 +fn normalize_root_key(path: &str) -> String { + path.replace('\\', "/").trim_end_matches('/').to_lowercase() +} + +fn classify_claude_local_entry(claude_json: &Value, root: &std::path::Path) -> ClaudeLocalEntry { + let wanted = normalize_root_key(&root.display().to_string()); + let entry = claude_json + .get("projects") + .and_then(Value::as_object) + .and_then(|projects| { + projects + .iter() + .find(|(key, _)| normalize_root_key(key) == wanted) + .map(|(_, value)| value) + }) + .and_then(|project| project.get("mcpServers")) + .and_then(|servers| servers.get(MCP_KEY)); + let Some(entry) = entry else { + return ClaudeLocalEntry::Absent; + }; + let url = entry.get("url").and_then(Value::as_str).unwrap_or(""); + let helper = entry.get("headersHelper").and_then(Value::as_str).unwrap_or(""); + if url.starts_with("http://127.0.0.1:") + && url.contains("/mcp/projects/") + && helper.contains(MCP_HEADER_SHIM_NAME) + { + ClaudeLocalEntry::Managed(entry.clone()) + } else { + ClaudeLocalEntry::Manual + } +} + +fn read_claude_user_config() -> Option { + let home = std::env::var("USERPROFILE").ok()?; + let text = std::fs::read_to_string(PathBuf::from(home).join(".claude.json")).ok()?; + serde_json::from_str(&text).ok() +} + +fn run_claude_cli( + claude_bin: &str, + git_bash: &str, + root: &std::path::Path, + args: &[&str], +) -> Result<(), String> { + let out = crate::silent_cmd(claude_bin) + .current_dir(root) + .args(args) + .env("CLAUDE_CODE_GIT_BASH_PATH", git_bash) + .output() + .map_err(|error| format!("执行 claude CLI 失败: {error}"))?; + if out.status.success() { + Ok(()) + } else { + Err(format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + )) + } +} + +/// 让 Claude 的 local 条目与期望一致:desired=Some 时注册/刷新,None 时只删除炼境托管的条目。 +/// 手写条目一律不碰;替换失败时用原值回滚。best-effort,结果只记日志。 +fn sync_claude_local(project_id: &str, root: &std::path::Path, desired: Option<(String, String)>) { + let context = inject_log_context(project_id, "claude"); let (Some(claude_bin), Some(git_bash)) = (find_claude_bin(), find_git_bash()) else { db::log_event( "mcp_inject", "warn", - "claude CLI 或 git-bash 未找到,跳过 Claude 项目级 MCP 注册", - None, + "claude CLI 或 git-bash 未找到,跳过 Claude 项目级 MCP 同步", + Some(&context), ); - return false; + return; }; - let _ = std::process::Command::new(&claude_bin) - .current_dir(root) - .args(["mcp", "remove", MCP_KEY, "--scope", "local"]) - .env("CLAUDE_CODE_GIT_BASH_PATH", &git_bash) - .output(); - let payload = claude_local_server_json(server_url, header_helper); - match std::process::Command::new(&claude_bin) - .current_dir(root) - .args(["mcp", "add-json", MCP_KEY, &payload, "--scope", "local"]) - .env("CLAUDE_CODE_GIT_BASH_PATH", &git_bash) - .output() - { - Ok(out) if out.status.success() => { - db::log_event("mcp_inject", "info", "Claude 项目级 MCP 注册成功", Some(MCP_KEY)); - true + 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, _) => { + Ok("本项目已有手写的 lian-jing local 条目,炼境不覆盖".to_string()) } - Ok(out) => { - let combined = format!( - "{}{}", - String::from_utf8_lossy(&out.stdout), - String::from_utf8_lossy(&out.stderr) - ); - db::log_event( - "mcp_inject", - "warn", - &format!("claude mcp add-json 非零退出: {combined}"), - Some(MCP_KEY), - ); - false + (ClaudeLocalEntry::Absent, None) => return, + (ClaudeLocalEntry::Managed(_), None) => { + run_claude_cli(&claude_bin, &git_bash, root, &remove_args) + .map(|_| "Claude 项目级 MCP 条目已移除".to_string()) } - Err(error) => { - db::log_event( - "mcp_inject", - "warn", - &format!("执行 claude mcp add-json 失败: {error}"), - Some(MCP_KEY), - ); - false + (previous, Some((server_url, header_helper))) => { + let payload = claude_local_server_json(&server_url, &header_helper); + let add_args = ["mcp", "add-json", MCP_KEY, payload.as_str(), "--scope", "local"]; + 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", + "warn", + &format!("Claude 项目级 MCP 旧条目移除失败: {error}"), + Some(&context), + ); + return; + } + run_claude_cli(&claude_bin, &git_bash, root, &add_args).or_else(|error| { + let restore = old.to_string(); + let _ = run_claude_cli( + &claude_bin, + &git_bash, + root, + &["mcp", "add-json", MCP_KEY, restore.as_str(), "--scope", "local"], + ); + Err(format!("{error}(已尝试恢复原条目)")) + }) + } else { + run_claude_cli(&claude_bin, &git_bash, root, &add_args) + }; + registered.map(|_| "Claude 项目级 MCP 注册成功".to_string()) } + }; + match outcome { + Ok(message) => db::log_event("mcp_inject", "info", &message, Some(&context)), + Err(error) => db::log_event( + "mcp_inject", + "warn", + &format!("Claude 项目级 MCP 同步失败: {error}"), + Some(&context), + ), } } // ── DSH 机器级配置托管块 ────────────────────────────────────────────────────── -// DSH 配置无法按项目拆分,但 !!js 在加载时以启动目录求值:在项目目录(或子目录)启动时连项目专用地址 -// 并经稳定 helper 携带认证;项目外回退全局地址,保持 GUI 等非项目启动方式的现有行为。 +// DSH 配置无法按项目拆分,但 !!js 在加载时以启动目录求值。项目身份只认炼境登记表:托管块内嵌 +// 「已登记项目根目录 → project_id」白名单(非秘密),从启动目录向上精确匹配根目录才连项目专用地址 +// 并经稳定 helper 携带认证;任意目录里的 .lian-jing/context.json 不作为授权依据。未命中则回退全局地址, +// 保持 GUI 等非项目启动方式的现有行为。项目增删、端口变更时重写白名单。 -fn render_dsh_block(port: u16) -> String { +/// 与 JS 端 path.resolve(d).toLowerCase() 对齐:反斜杠分隔、去尾部分隔符、小写。 +fn normalize_dsh_root(path: &str) -> String { + path.replace('/', "\\").trim_end_matches('\\').to_lowercase() +} + +/// 已接入(根目录 context.json 的 project_id 与登记一致)、非控制面、非 WSL 的 Windows 项目。 +fn dsh_project_roots( + conn: &rusqlite::Connection, + exclude: Option<&str>, +) -> Vec<(String, String)> { + 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()?; + 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)) + }) + .collect() +} + +fn render_dsh_block(port: u16, roots: &[(String, String)]) -> String { // 逐行显式缩进:YAML 依赖缩进,不能用字符串续行(会吞掉下一行行首空白)。 fn indent(spaces: usize, lines: &[&str]) -> Vec { lines.iter().map(|line| format!("{}{line}", " ".repeat(spaces))).collect() } + let roots_map: serde_json::Map = roots + .iter() + .map(|(root, id)| (root.clone(), json!(id))) + .collect(); + let roots_line = format!("const roots = {};", Value::Object(roots_map)); let find_project_id = [ - "const fs = process.getBuiltinModule('fs'), path = process.getBuiltinModule('path');", + "const path = process.getBuiltinModule('path');", + roots_line.as_str(), "let id;", - "for (let d = process.cwd(), p; d !== p; p = d, d = path.dirname(d)) {", - " const f = path.join(d, '.lian-jing', 'context.json');", - " if (fs.existsSync(f)) { try { id = JSON.parse(fs.readFileSync(f, 'utf8')).project_id; } catch {} break; }", + "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; }", "}", ]; let global_url = format!("http://127.0.0.1:{port}/mcp"); @@ -425,22 +573,25 @@ fn render_dsh_block(port: u16) -> String { "return id ? '{global_url}/projects/' + encodeURIComponent(id) : '{global_url}';" ); let shim_line = format!( - "const shim = path.join(process.env.LOCALAPPDATA || '', 'lian-jing', 'bin', '{MCP_HEADER_SHIM_NAME}');" + "const shim = path.join(base, 'lian-jing', 'bin', '{MCP_HEADER_SHIM_NAME}');" ); let header_body = [ "if (!id) return {};", + "const base = process.env.LOCALAPPDATA, 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');", - shim_line.as_str(), "// Node 拒绝直接执行 .cmd(EINVAL),须经 cmd /d /s /c 原样传参。", - "const out = cp.execFileSync(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', '\"\"' + shim + '\" ' + digest + '\"'],", + "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(), + "# 炼境托管:按 DSH 启动目录匹配已登记项目根目录,连项目专用地址并携带认证;未命中回退全局地址。勿手改,会被炼境覆盖。".into(), "- insert:".into(), " - id: mcp-lian-jing".into(), " name: '@deepseek-ai/dsh-mcp-client'".into(), @@ -474,32 +625,63 @@ fn render_dsh_block(port: u16) -> String { 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) == Some("mcp-lian-jing") + || 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 补丁文件已手工定义 lian-jing,炼境不会覆盖;移除手写条目后重试"; match (current.find(DSH_MANAGED_START), current.find(DSH_MANAGED_END)) { - (Some(start), Some(end)) if end >= start => Ok(format!( - "{}{}{}", - ¤t[..start], - block.trim_end_matches('\n'), - ¤t[end + DSH_MANAGED_END.len()..] - )), - (Some(_), _) | (_, Some(_)) => Err("DSH 补丁文件中的炼境托管块不完整或顺序错误".into()), - (None, None) - if current.lines().any(|line| { - let t = line.trim(); - t == "id: mcp-lian-jing" || t == format!("serverName: {MCP_KEY}") - }) => - { - Err("DSH 补丁文件已手工定义 lian-jing,炼境不会覆盖;移除手写条目后重试".into()) + (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)), } } -/// 未安装 DSH(无 ~/.dsh)时跳过;返回是否写入或已是最新。 -pub fn ensure_dsh_patch(port: u16) -> Result { +/// 未安装 DSH(无 ~/.dsh)时跳过;返回是否写入或已是最新。exclude 用于删除项目时提前剔除白名单。 +pub fn ensure_dsh_patch( + conn: &rusqlite::Connection, + port: u16, + exclude: Option<&str>, +) -> Result { let home = std::env::var("DSH_HOME").map(PathBuf::from).or_else(|_| { std::env::var("USERPROFILE") .map(|home| PathBuf::from(home).join(".dsh")) @@ -514,13 +696,30 @@ pub fn ensure_dsh_patch(port: u16) -> Result { Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), Err(error) => return Err(format!("读取 DSH 补丁文件失败: {error}")), }; - let next = upsert_dsh_block(¤t, &render_dsh_block(port))?; + let block = render_dsh_block(port, &dsh_project_roots(conn, exclude)); + let next = upsert_dsh_block(¤t, &block)?; if next != current { std::fs::write(&path, next).map_err(|error| format!("写入 DSH 补丁文件失败: {error}"))?; } Ok(true) } +/// 刷新 DSH 托管块的 best-effort 包装:失败只记日志。 +pub fn refresh_dsh_patch(exclude: Option<&str>) { + let result = db::pool() + .get() + .map_err(|error| error.to_string()) + .and_then(|conn| ensure_dsh_patch(&conn, crate::mcp_server::read_port(), exclude)); + if let Err(error) = result { + db::log_event( + "mcp_inject", + "warn", + &format!("DSH 托管块刷新失败: {error}"), + Some(&json!({ "client": "dsh" }).to_string()), + ); + } +} + /// 清理旧组级全局注册(lian-jing-{gid},含完整 UUID 与短 8 位两种历史命名)。 /// 幂等 best-effort:不存在的名字 remove 失败仅忽略。返回成功移除的名字列表。 pub fn cleanup_legacy_global_registrations() -> Vec { @@ -567,7 +766,7 @@ pub fn cleanup_legacy_global_registrations() -> Vec { .map(|out| out.status.success()) .unwrap_or(false); if ok { - db::log_event("mcp_inject", "info", "旧组级 MCP 注册已移除", Some(&name)); + db::log_event("mcp_inject", "info", "旧组级 MCP 注册已移除", Some(&json!({ "client": "claude-global", "server": &name }).to_string())); removed.push(name); } } @@ -734,14 +933,25 @@ pub fn inject_project_mcp(project_id: &str) -> Result<(), String> { // 同步注册到 ~/.claude.json:user 级全局注册作兜底,local 级项目条目优先生效(均 best-effort)。 // 项目 .claude/settings.json 的 mcpServers 不被 Claude Code 读取,仅保留作兼容。 + // 控制面项目(mcp_scope=global)不写 local 条目,并清理炼境曾写入的旧条目,保留全局与跨项目工具。 try_register_globally(port); - try_register_claude_local(&project_root(project_id)?, &server_url, &header_helper); + 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); Ok(()) } /// 从项目的 .claude/settings.json 移除炼境 MCP 配置(含旧组级 key)。 /// 调用方需自行保证项目已不属于任何产品组时才调用。 pub fn remove_project_mcp(project_id: &str) -> Result<(), String> { + // 先于登记删除清理客户端侧条目:残留的 local 条目会遮蔽全局注册并指向 404 地址。 + if let Ok(root) = project_root(project_id) { + sync_claude_local(project_id, &root, None); + } + refresh_dsh_patch(Some(project_id)); remove_codex_mcp_config(&codex_config_path(project_id)?)?; let path = settings_path(project_id)?; if path.exists() { @@ -837,10 +1047,8 @@ pub fn reinject_all_mcp() -> Vec { errors.push(format!("项目 {}: {}", project_id, e)); } } - // DSH 托管块内嵌端口,端口变更后一并刷新 - if let Err(e) = ensure_dsh_patch(crate::mcp_server::read_port()) { - errors.push(format!("DSH: {e}")); - } + // 每次注入已刷新 DSH 托管块(内嵌端口与白名单);此处兜底一次,失败只记日志 + refresh_dsh_patch(None); errors } @@ -969,6 +1177,61 @@ mod tests { ); 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 claude_local_entry_classification_protects_manual_entries() { + let root = std::path::Path::new("E:\\code-project\\Demo"); + let config = |entry: Value| json!({ "projects": { "e:/code-project/demo": { "mcpServers": { "lian-jing": entry } } } }); + assert_eq!(classify_claude_local_entry(&json!({}), root), ClaudeLocalEntry::Absent); + let managed = json!({ + "type": "http", + "url": "http://127.0.0.1:27190/mcp/projects/p1", + "headersHelper": shim_helper_command("d1"), + }); + assert_eq!( + classify_claude_local_entry(&config(managed.clone()), root), + ClaudeLocalEntry::Managed(managed), + "键名大小写与分隔符差异须视为同一项目" + ); + let manual = json!({ "type": "http", "url": "https://example.invalid/mcp" }); + assert_eq!(classify_claude_local_entry(&config(manual), root), ClaudeLocalEntry::Manual); + let other_project = json!({ "projects": { "e:/code-project/other": { "mcpServers": { "lian-jing": {} } } } }); + assert_eq!(classify_claude_local_entry(&other_project, root), ClaudeLocalEntry::Absent); + } + + #[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(); + for (id, scope, bound) in [("p1", None, true), ("ctrl", Some("global"), true), ("drift", None, false), ("gone", 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, 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 _ = std::fs::remove_dir_all(&base); } #[test] @@ -984,16 +1247,20 @@ mod tests { } #[test] - fn dsh_block_is_valid_yaml_with_cwd_routing_and_shim_auth() { - let block = render_dsh_block(27190); + 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 block = render_dsh_block(27190, &roots); 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';"), "项目外回退全局地址"); + assert!(block.contains(": 'http://127.0.0.1:27190/mcp';"), "未命中回退全局地址"); + assert!(block.contains("path.isAbsolute(base)") && block.contains("'System32', 'cmd.exe'")); assert!(block.contains(MCP_HEADER_SHIM_NAME) && block.contains("windowsVerbatimArguments: true")); assert!(!block.contains("Bearer "), "托管块不得含任何 token"); } @@ -1001,18 +1268,26 @@ mod tests { #[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)).unwrap(); + let first = upsert_dsh_block(user, &render_dsh_block(27190, &[])).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)).unwrap(); + let moved = upsert_dsh_block(&first, &render_dsh_block(28000, &[])).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)).unwrap(), moved, "幂等"); + assert_eq!(upsert_dsh_block(&moved, &render_dsh_block(28000, &[])).unwrap(), moved, "幂等"); - let manual = "- insert:\n - id: mcp-lian-jing\n config:\n serverName: lian-jing\n"; - assert!(upsert_dsh_block(manual, &render_dsh_block(27190)).is_err()); + 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", + ] { + assert!(upsert_dsh_block(manual, &render_dsh_block(27190, &[])).is_err(), "{manual}"); + let with_block = format!("{manual}{}", render_dsh_block(27190, &[])); + assert!(upsert_dsh_block(&with_block, &render_dsh_block(27190, &[])).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"); } diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index d7fde69..b0c3ae3 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -1603,6 +1603,11 @@ async fn handle_scoped_sse_message( headers: HeaderMap, Json(req): Json, ) -> Response { + // 握手后项目可能被删除:每条消息复核登记,失效会话一并移除。 + if !scoped_project_registered(&project_id) { + sessions.lock().await.remove(&query.session_id); + return unknown_project_response(&project_id, req.id.clone()); + } if authorize_sensitive_request( &req, Some(&project_id), diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index 90f9ccf..b389e77 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -1292,6 +1292,8 @@ fn get_diagnostics() -> Result { AND id IN ( SELECT MAX(id) FROM runtime_logs WHERE category = 'mcp_inject' + AND json_valid(context) + AND json_extract(context, '$.project_id') IS NOT NULL GROUP BY json_extract(context, '$.project_id') ) ORDER BY ts DESC -- 2.45.2 From 0bf5bd699431f4b30c9d03e7d8f3b75c38ef3d25 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Thu, 1 Oct 2026 12:58:09 +0900 Subject: [PATCH 04/15] =?UTF-8?q?docs(blueprint/agent-client-parity):=20?= =?UTF-8?q?=E7=AB=8B=E5=8D=A1=20P8=20=E4=B8=80=E9=94=AE=E6=8E=A5=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .blueprint/modules/agent-client-parity.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.blueprint/modules/agent-client-parity.md b/.blueprint/modules/agent-client-parity.md index 9adcb66..5b551bb 100644 --- a/.blueprint/modules/agent-client-parity.md +++ b/.blueprint/modules/agent-client-parity.md @@ -132,3 +132,11 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。 - depends: P1 - acceptance: "agent_verify: [三端无头探针] 在 initialize 下发唯一标记的 instructions,判定三端是否把它带入模型上下文。全部支持则把引导提示迁到服务器说明,本地 skill 只保留 MCP 不可达时的排障步骤;部分支持则如实记录、保留 skill 引导。" - 为什么:Claude 已实证会把 lian-jing 的服务器说明带入上下文;若三端皆支持,引导提示可由炼境集中维护。 + +### 📋 P8 — 一键接入:创建/添加子项目即完成三端可用 +- status: todo +- complexity: M +- files: src-tauri/src/mcp_inject.rs, src-tauri/src/commands/onboarding.rs, src-tauri/src/commands/projects.rs(实现时定) +- depends: P4 +- acceptance: "agent_verify: [Tauri command / MCP] 对新登记项目执行一次接入 → 依次完成 canonical context、接入包分发(规则、skill 双投影、Codex 托管块)、本机三端注册(Claude local、DSH 白名单、shim、token),并以三端无头探针给出 A–F 兼容矩阵;重复执行幂等;接入包对项目文件的改动只报告 diff、不自动提交。" +- 为什么:2026-10-01 Enterprise 验收时,接入散落在 inject_mcp、apply_onboarding_pack、verify_onboarding 与手工探针多个步骤。按「分发 vs 查询」:随仓库走的文件由炼境分发一次、按版本升级;与机器相关的客户端注册由炼境在本机维护、不进仓库;易变信息走 MCP 查询。默认三端都接入(成本小,保留随时换客户端的能力);能用标准的不做专门适配(Codex、DSH 原生读 AGENTS.md 与 .agents/skills,只有 Claude 需要 CLAUDE.md 与 .claude/skills 投影);炼境离线时规则与 skill 仍可用。 -- 2.45.2 From e4214cd83989ea1a46b24431b98392215fd8fb09 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Thu, 1 Oct 2026 13:39:23 +0900 Subject: [PATCH 05/15] =?UTF-8?q?fix(mcp/parity):=20=E7=AC=AC=E4=BA=8C?= =?UTF-8?q?=E8=BD=AE=E5=AE=A1=E6=9F=A5=E4=BF=AE=E5=A4=8D=E2=80=94=E2=80=94?= =?UTF-8?q?=E4=B8=B2=E8=A1=8C=E5=90=8C=E6=AD=A5=E9=98=9F=E5=88=97=E3=80=81?= =?UTF-8?q?=E7=99=BD=E5=90=8D=E5=8D=95=E6=8C=81=E4=B9=85=E5=89=94=E9=99=A4?= =?UTF-8?q?=E3=80=81=E8=AF=8A=E6=96=AD=E5=88=86=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Claude local 同步统一投递到单个后台串行队列(注册与移除同队列、按提交顺序), 消除并发 claude CLI 竞态与「注入后立即移除」留下孤儿条目;移除路径不再阻塞界面 - DSH 白名单只收有项目 token 的项目,退组/移除后剔除持久;控制面项目写 null 停止哨兵, 不再越过它匹配到已登记的上级目录;Rust 与 JS 两端键规范化对齐(重复分隔符、盘符根、UNC) - 客户端侧同步日志改为 mcp_client_sync 分类,不覆盖诊断中每个项目的注入结果 - 批量重注入只在末尾刷新一次 DSH;启动刷新移到后台线程 - 项目根目录/平台变更时迁移客户端配置(清旧根 Claude 条目,已接入项目在新根重新注入) - SSE 消息路由仅在会话属于该项目时才移除;注入模块的进程调用统一 silent_cmd Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/commands/projects.rs | 17 ++- src-tauri/src/lib.rs | 3 +- src-tauri/src/mcp_inject.rs | 194 +++++++++++++++++++++++------ src-tauri/src/mcp_server.rs | 12 +- 4 files changed, 183 insertions(+), 43 deletions(-) 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( -- 2.45.2 From 7b702a9d88b2435f8d766b7e5ec47613be175302 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Thu, 1 Oct 2026 13:49:59 +0900 Subject: [PATCH 06/15] =?UTF-8?q?docs(blueprint):=20=E6=96=B0=E5=BB=BA=20a?= =?UTF-8?q?rchitecture-evolution=20=E6=A8=A1=E5=9D=97=EF=BC=8C=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E6=8A=80=E6=9C=AF=E6=A0=88=E8=AF=84=E4=BC=B0=E4=B8=8E?= =?UTF-8?q?=20A1=E2=80=93A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .blueprint/manifest.yaml | 13 ++++++ .blueprint/modules/architecture-evolution.md | 45 ++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 .blueprint/modules/architecture-evolution.md diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index ce7abe4..21e4f75 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -516,6 +516,15 @@ modules: note: "目标:子项目在 Claude Code、Codex、DSH 三端都能继续开发。2026-09-30 无头基线探针:Codex 全绿;Claude 与 DSH 连全局 /mcp、看不到开发变量工具;Claude 缺 lian-jing skill。先统一连接地址,再处理认证与 skill 投影,并把探针固化为可重跑检查。" position: [3500, 950] + - id: architecture-evolution + name: 炼境架构演进 + area: infra + status: planned + progress: 0 + updated: 2026-10-01 + note: "技术栈评估:Tauri v2 + Rust + React/TS 适合管家方针,不换栈。按收益排序的结构改进:A1 本体与投影分离(core crate + CLI,P4/P8 依赖)、A2 前后端类型契约生成、A3 配置文本模板化、A4 MCP 改用官方 rmcp SDK。" + position: [3750, 950] + edges: - from: project-resource-broker to: project-dev-variables @@ -854,3 +863,7 @@ edges: - from: project-dev-variables to: agent-client-parity type: dependency + # P4 探针固化、P8 一键接入以本体与投影分离(A1 的 CLI 投影)为承载。 + - from: architecture-evolution + to: agent-client-parity + type: dependency diff --git a/.blueprint/modules/architecture-evolution.md b/.blueprint/modules/architecture-evolution.md new file mode 100644 index 0000000..849a192 --- /dev/null +++ b/.blueprint/modules/architecture-evolution.md @@ -0,0 +1,45 @@ +--- +id: architecture-evolution +name: 炼境架构演进 +status: planned +progress: 0 +--- + +## 概述 + +2026-10-01 评估结论:现有技术栈(Tauri v2 + Rust + React/TS + SQLite)适合「管家 / agent 客户端兼容层」方针,不换栈。常驻托盘服务、凭据安全、单文件分发、cargo test 快速回归都是 Rust/Tauri 的强项;真正的难点在 Windows 与各客户端自身机制(cmd 引号与 %VAR% 展开、CRLF、Claude 配置作用域、DSH !!js、Node 拒绝直接执行 .cmd),与语言无关。Electron 全 Node 栈(与三家客户端同生态、JSON/YAML 顺手)因常驻内存大、凭据需原生模块且须整体重写而否决;Go、Python 无明显收益。 + +本模块收集比「换语言」更影响实现效率的结构性改进,按收益排序。 + +## 任务拆解 + +### 📋 A1 — 本体与投影分离:core crate + CLI +- status: todo +- complexity: L(先 /architect 再拆) +- files: src-tauri/Cargo.toml(改为 workspace)、新增 core 与 cli crate(实现时定) +- acceptance: "agent_verify: [cargo test] 业务逻辑位于不依赖 Tauri 的 core crate;[CLI] 同一 exe 或独立 cli 在炼境界面未运行时可执行 onboard / probe / diagnostics 等命令并返回结构化结果。" +- 为什么:核心逻辑写在 Tauri 应用内,炼境不运行时 agent 与脚本无法调用。业务函数已按「连接由调用方注入」编写,拆出成本可控。拆分后 Tauri 界面、MCP 服务、命令行成为同一本体的三个投影,契合「一个本体 + 多个投影」原则;agent-client-parity 的 P4 探针、P8 一键接入可直接做成命令。shim 已在用 exe 的命令行模式(--lianjing-mcp-header),只是尚未系统化。 + +### 📋 A2 — 前后端类型契约自动生成 +- status: todo +- complexity: M +- files: src-tauri/src/commands/、src/lib/commands.ts +- acceptance: "agent_verify: [pnpm typecheck] commands.ts 的参数与返回类型由 Rust 定义生成(tauri-specta 或 ts-rs),新增 command 不再手抄类型;删改 Rust 字段时 typecheck 报出所有受影响处。" +- 为什么:现由规则 R01 要求手动同步 commands.ts,易漏;对应四支柱「可推理」。 + +### 📋 A3 — 配置文本生成模板化 +- status: todo +- complexity: M +- files: src-tauri/src/mcp_inject.rs、src-tauri/resources/ +- acceptance: "agent_verify: [cargo test] Codex 托管块、DSH 托管块、shim 等由模板(如 minijinja)渲染,并有「渲染结果是合法 YAML/TOML/JSON」测试。" +- 为什么:2026-10-01 实现 P1 时在 Rust 中手拼 YAML/JS,出现缩进被字符串续行吞掉、转义少一个反斜杠两个缺陷(均被测试拦下)。模板能从写法上避免这类问题。 + +### 📋 A4 — MCP 协议改用官方 Rust SDK(rmcp) +- status: todo +- complexity: M +- files: src-tauri/src/mcp_server.rs、src-tauri/src/mcp_tools.rs +- acceptance: "agent_verify: [cargo test + 三端无头探针] 换用 rmcp 后 scoped 路由、敏感读取认证、SSE 与 streamable HTTP 行为不变,三端 A–F 矩阵不回退。" +- 为什么:现为手写 JSON-RPC + SSE。各客户端对 MCP 的支持持续演进(streamable HTTP、认证、服务器说明等),自维护协议细节的成本会上升。 + +### 附:调用外部进程的 Tauri command 统一 async +- 同步 Tauri command 跑在主线程,内部调用 claude CLI 等外部进程会卡界面;agent-client-parity 审查已两次发现此类问题。新增此类 command 默认 async,存量随相关改动迁移,不单独立卡。 -- 2.45.2 From fc8492b88b6aa988a07b95a5b139e056fe348627 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Thu, 1 Oct 2026 14:37:37 +0900 Subject: [PATCH 07/15] =?UTF-8?q?fix(mcp/parity):=20=E7=AC=AC=E4=B8=89?= =?UTF-8?q?=E8=BD=AE=E5=AE=A1=E6=9F=A5=E4=BF=AE=E5=A4=8D=E2=80=94=E2=80=94?= =?UTF-8?q?Claude=20=E6=9D=A1=E7=9B=AE=E6=94=B9=E4=B8=BA=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E9=85=8D=E7=BD=AE=EF=BC=8C=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E5=8A=A0=E8=BF=9B=E7=A8=8B=E9=94=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Claude local 条目不再经 claude CLI(cwd=项目目录)+ 后台队列管理,改为直接编辑 ~/.claude.json 中本项目键下的 mcpServers.lian-jing:同步、毫秒级、原子替换,只动该字段; 不依赖项目目录存在,消除删除本地文件夹时的竞态、目录移动后旧条目无法清理、队列丢任务/卡死 - 项目键用真实长路径(实测 8.3 短路径键不被 Claude 识别);手写条目不碰 - 注入、移除、根目录迁移在进程锁内串行;移除时最先删 token,后续清理收集错误统一返回 - 根目录迁移:旧根解析不出也迁移;重新注入失败归入 mcp_inject 并补刷 DSH;DSH 补丁写入加锁 Co-Authored-By: Claude Opus 5.5 --- docs/ai-context/project-context.yaml | 2 +- src-tauri/src/commands/projects.rs | 6 +- src-tauri/src/mcp_inject.rs | 393 ++++++++++++++++++--------- 3 files changed, 275 insertions(+), 126 deletions(-) diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index 0b615b3..08a3cfb 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -6,7 +6,7 @@ contracts: - label: API 接口(Axum) paths: - "src-tauri/src/mcp_server.rs" - doc: "MCP 仅监听 127.0.0.1;新项目使用 /mcp/projects/ scoped route,连接 scope 拒绝跨项目参数和全局管理写工具。scoped route 只服务已登记项目,未知 project_id 返回 404(数据库不可用同样拒绝)。各客户端经 %LOCALAPPDATA%/lian-jing/bin/lianjing-mcp-header.cmd 稳定入口取认证头(炼境每次启动指向当前 exe):Codex 写项目 .codex/config.toml,Claude 写 ~/.claude.json 的 local 条目(优先于 user 级全局),DSH 写 ~/.dsh/cordis.patch.yml 托管块(内嵌炼境登记的「项目根目录→project_id」白名单,!!js 从启动目录向上精确匹配根目录才选项目地址并取认证,任意目录里的 context.json 不作授权依据;未命中回退全局;项目增删与端口变更时重写)。Claude local 条目与 DSH 块只替换炼境托管的条目,手写同名配置不覆盖;移除项目时一并清理。旧 /mcp 只作兼容迁移。read_project_dev_variable 与 read_project_test_account 是两种有界开发值读取:仅 scoped 路由、匹配项目的本机 bearer 认证后单项读取;认证失败在 dispatch 前拒绝。路由变更需同步 Agent 按钮体系文档。" + doc: "MCP 仅监听 127.0.0.1;新项目使用 /mcp/projects/ scoped route,连接 scope 拒绝跨项目参数和全局管理写工具。scoped route 只服务已登记项目,未知 project_id 返回 404(数据库不可用同样拒绝)。各客户端经 %LOCALAPPDATA%/lian-jing/bin/lianjing-mcp-header.cmd 稳定入口取认证头(炼境每次启动指向当前 exe):Codex 写项目 .codex/config.toml,Claude 写 ~/.claude.json 的 local 条目(优先于 user 级全局;直接编辑该文件中本项目键下的 mcpServers.lian-jing,不经 claude CLI、不依赖项目目录存在,项目键用真实长路径;原子替换写入),DSH 写 ~/.dsh/cordis.patch.yml 托管块(内嵌炼境登记的「项目根目录→project_id」白名单,!!js 从启动目录向上精确匹配根目录才选项目地址并取认证,任意目录里的 context.json 不作授权依据;未命中回退全局;项目增删与端口变更时重写)。Claude local 条目与 DSH 块只替换炼境托管的条目,手写同名配置不覆盖;移除项目时一并清理。旧 /mcp 只作兼容迁移。read_project_dev_variable 与 read_project_test_account 是两种有界开发值读取:仅 scoped 路由、匹配项目的本机 bearer 认证后单项读取;认证失败在 dispatch 前拒绝。路由变更需同步 Agent 按钮体系文档。" - label: MCP 工具接口 paths: diff --git a/src-tauri/src/commands/projects.rs b/src-tauri/src/commands/projects.rs index d7a1d43..cbedfc1 100644 --- a/src-tauri/src/commands/projects.rs +++ b/src-tauri/src/commands/projects.rs @@ -304,11 +304,11 @@ pub fn update_project(id: String, input: UpdateProjectInput) -> Result<(), Strin // 根目录变化:Claude local 条目按目录挂载、DSH 白名单按根目录匹配,都要迁移到新根。 // 会调用 claude CLI,放到后台线程,避免同步命令卡住界面。 - if let Some(old_root) = old_root { + if root_may_change { let new_root = crate::mcp_inject::project_root_with_conn(&conn, &id).ok(); - if new_root.as_ref() != Some(&old_root) { + if crate::mcp_inject::project_root_changed(old_root.as_ref(), new_root.as_ref()) { drop(conn); - std::thread::spawn(move || crate::mcp_inject::on_project_root_changed(&id, Some(old_root))); + std::thread::spawn(move || crate::mcp_inject::on_project_root_changed(&id, old_root)); } } diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index bbad37c..8233eef 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -362,19 +362,18 @@ fn global_log_context() -> String { } /// Claude Code local scope 条目:项目专用地址 + 稳定 helper。local 优先于 user 级全局注册且不进仓库。 -fn claude_local_server_json(server_url: &str, header_helper: &str) -> String { +fn claude_local_server_entry(server_url: &str, header_helper: &str) -> Value { json!({ "type": "http", "url": server_url, "headersHelper": header_helper, }) - .to_string() } #[derive(Debug, PartialEq)] enum ClaudeLocalEntry { Absent, - /// 炼境写入的条目(项目路由 + 稳定 helper),可安全替换或删除;附原值供失败回滚。 + /// 炼境写入的条目(项目路由 + 稳定 helper),可安全替换或删除。 Managed(Value), /// 用户手写的同名条目:炼境不覆盖、不删除。 Manual, @@ -413,128 +412,166 @@ fn classify_claude_local_entry(claude_json: &Value, root: &std::path::Path) -> C } } -fn read_claude_user_config() -> Option { - let home = std::env::var("USERPROFILE").ok()?; - let text = std::fs::read_to_string(PathBuf::from(home).join(".claude.json")).ok()?; - serde_json::from_str(&text).ok() +#[derive(Debug, PartialEq)] +enum ClaudeLocalOutcome { + Unchanged, + Registered, + Removed, + KeptManual, } -fn run_claude_cli( - claude_bin: &str, - git_bash: &str, +/// 纯函数:把期望状态写进 ~/.claude.json 的配置对象。只动本项目条目下的 mcpServers.lian-jing, +/// 项目对象里 Claude 自己维护的其他字段与其他 server 原样保留;手写条目不碰。 +fn apply_claude_local( + config: &mut Value, root: &std::path::Path, - args: &[&str], -) -> Result<(), String> { - let out = crate::silent_cmd(claude_bin) - .current_dir(root) - .args(args) - .env("CLAUDE_CODE_GIT_BASH_PATH", git_bash) - .output() - .map_err(|error| format!("执行 claude CLI 失败: {error}"))?; - if out.status.success() { - Ok(()) + desired: Option, +) -> Result { + match (classify_claude_local_entry(config, root), &desired) { + (ClaudeLocalEntry::Manual, _) => return Ok(ClaudeLocalOutcome::KeptManual), + (ClaudeLocalEntry::Absent, None) => return Ok(ClaudeLocalOutcome::Unchanged), + (ClaudeLocalEntry::Managed(current), Some(entry)) if ¤t == entry => { + return Ok(ClaudeLocalOutcome::Unchanged) + } + _ => {} + } + let wanted = normalize_root_key(&root.display().to_string()); + let projects = config + .as_object_mut() + .ok_or("~/.claude.json 顶层不是 JSON 对象")? + .entry("projects") + .or_insert_with(|| json!({})) + .as_object_mut() + .ok_or("~/.claude.json 的 projects 不是 JSON 对象")?; + // 复用 Claude 已记录的键(大小写、分隔符以它为准),没有才按它的惯例(正斜杠)新建 + let key = projects + .keys() + .find(|key| normalize_root_key(key) == wanted) + .cloned() + .unwrap_or_else(|| root.display().to_string().replace('\\', "/")); + match desired { + Some(entry) => { + projects + .entry(key) + .or_insert_with(|| json!({})) + .as_object_mut() + .ok_or("~/.claude.json 的项目条目不是 JSON 对象")? + .entry("mcpServers") + .or_insert_with(|| json!({})) + .as_object_mut() + .ok_or("~/.claude.json 的 mcpServers 不是 JSON 对象")? + .insert(MCP_KEY.to_string(), entry); + Ok(ClaudeLocalOutcome::Registered) + } + None => { + if let Some(servers) = projects + .get_mut(&key) + .and_then(|project| project.get_mut("mcpServers")) + .and_then(Value::as_object_mut) + { + servers.remove(MCP_KEY); + } + Ok(ClaudeLocalOutcome::Removed) + } + } +} + +/// 同目录临时文件 + 重命名替换:读者只会看到完整的旧文件或新文件。目标被短暂占用时重试几次。 +fn write_file_atomic(path: &std::path::Path, content: &str) -> Result<(), String> { + let tmp = path.with_file_name(format!( + "{}.lianjing-tmp", + path.file_name().and_then(|name| name.to_str()).unwrap_or("config") + )); + std::fs::write(&tmp, content).map_err(|error| format!("写临时文件失败: {error}"))?; + let mut last_error = None; + for _ in 0..5 { + match std::fs::rename(&tmp, path) { + Ok(()) => return Ok(()), + Err(error) => { + last_error = Some(error); + std::thread::sleep(std::time::Duration::from_millis(50)); + } + } + } + let _ = std::fs::remove_file(&tmp); + Err(format!("替换文件失败: {}", last_error.map(|e| e.to_string()).unwrap_or_default())) +} + +/// Claude 以真实长路径记录项目(8.3 短名与大小写都以文件系统为准;2026-10-01 实测短路径键不会被识别)。 +/// 目录存在时规范化为长路径(去掉 \\?\ 前缀),不存在(已移动或删除)时原样使用。 +fn claude_project_path(root: &std::path::Path) -> PathBuf { + let Ok(canonical) = std::fs::canonicalize(root) else { + return root.to_path_buf(); + }; + let text = canonical.display().to_string(); + if let Some(rest) = text.strip_prefix(r"\\?\UNC\") { + PathBuf::from(format!(r"\\{rest}")) + } else if let Some(rest) = text.strip_prefix(r"\\?\") { + PathBuf::from(rest) } else { - Err(format!( - "{}{}", - String::from_utf8_lossy(&out.stdout), - String::from_utf8_lossy(&out.stderr) - )) + canonical } } /// 客户端侧同步(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,结果只记日志。只应经 enqueue_claude_sync 调用。 +/// 直接编辑 ~/.claude.json(Claude Code 文档列出的配置方式之一),不经 claude CLI:不依赖项目目录 +/// 存在(目录已删除或移动也能清理旧条目)、不起子进程、毫秒级完成,因此可在调用方同步执行。 +/// Claude 自己也会写这个文件,读改写窗口压到最小(紧挨写入前读取 + 原子替换)。best-effort,只记日志。 +/// 调用方须持有 mcp_op_lock,保证本进程内对该文件的读改写串行。 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)) { + let Ok(home) = std::env::var("USERPROFILE") else { + return; + }; + let path = PathBuf::from(home).join(".claude.json"); + if !path.is_file() { + // 本机从未运行过 Claude Code:不替它创建配置 return; } - let (Some(claude_bin), Some(git_bash)) = (find_claude_bin(), find_git_bash()) else { - db::log_event( - CLIENT_SYNC_LOG, - "warn", - "claude CLI 或 git-bash 未找到,跳过 Claude 项目级 MCP 同步", - Some(&context), - ); - return; - }; - let remove_args = ["mcp", "remove", MCP_KEY, "--scope", "local"]; - let outcome = match (state, desired) { - (ClaudeLocalEntry::Manual, _) => { - Ok("本项目已有手写的 lian-jing local 条目,炼境不覆盖".to_string()) + let root = claude_project_path(root); + let root = root.as_path(); + let result = (|| -> Result { + let text = std::fs::read_to_string(&path).map_err(|error| format!("读取失败: {error}"))?; + let mut config: Value = + serde_json::from_str(&text).map_err(|error| format!("解析失败,炼境不写入: {error}"))?; + let entry = desired.map(|(url, helper)| claude_local_server_entry(&url, &helper)); + let outcome = apply_claude_local(&mut config, root, entry)?; + if matches!(outcome, ClaudeLocalOutcome::Registered | ClaudeLocalOutcome::Removed) { + let content = serde_json::to_string_pretty(&config).map_err(|error| error.to_string())?; + write_file_atomic(&path, &content)?; } - (ClaudeLocalEntry::Absent, None) => return, - (ClaudeLocalEntry::Managed(_), None) => { - run_claude_cli(&claude_bin, &git_bash, root, &remove_args) - .map(|_| "Claude 项目级 MCP 条目已移除".to_string()) - } - (previous, Some((server_url, header_helper))) => { - let payload = claude_local_server_json(&server_url, &header_helper); - let add_args = ["mcp", "add-json", MCP_KEY, payload.as_str(), "--scope", "local"]; - 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( - CLIENT_SYNC_LOG, - "warn", - &format!("Claude 项目级 MCP 旧条目移除失败: {error}"), - Some(&context), - ); - return; - } - run_claude_cli(&claude_bin, &git_bash, root, &add_args).or_else(|error| { - let restore = old.to_string(); - let _ = run_claude_cli( - &claude_bin, - &git_bash, - root, - &["mcp", "add-json", MCP_KEY, restore.as_str(), "--scope", "local"], - ); - Err(format!("{error}(已尝试恢复原条目)")) - }) - } else { - run_claude_cli(&claude_bin, &git_bash, root, &add_args) + Ok(outcome) + })(); + let context = inject_log_context(project_id, "claude"); + match result { + Ok(ClaudeLocalOutcome::Unchanged) => {} + Ok(outcome) => { + let message = match outcome { + ClaudeLocalOutcome::Registered => "Claude 项目级 MCP 条目已写入", + ClaudeLocalOutcome::Removed => "Claude 项目级 MCP 条目已移除", + _ => "本项目已有手写的 lian-jing local 条目,炼境不覆盖", }; - registered.map(|_| "Claude 项目级 MCP 注册成功".to_string()) + db::log_event(CLIENT_SYNC_LOG, "info", message, Some(&context)); } - }; - match outcome { - Ok(message) => db::log_event(CLIENT_SYNC_LOG, "info", &message, Some(&context)), Err(error) => db::log_event( CLIENT_SYNC_LOG, "warn", - &format!("Claude 项目级 MCP 同步失败: {error}"), + &format!("Claude 项目级 MCP 同步失败(~/.claude.json): {error}"), Some(&context), ), } } +/// 进程内所有「炼境 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」白名单(非秘密),从启动目录向上精确匹配根目录才连项目专用地址 @@ -761,8 +798,14 @@ pub fn ensure_dsh_patch( Ok(true) } +/// DSH 补丁文件的读-算-写在本进程内串行(启动线程与注入/移除可能同时刷新)。 +static DSH_PATCH_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + /// 刷新 DSH 托管块的 best-effort 包装:失败只记日志。 pub fn refresh_dsh_patch(exclude: Option<&str>) { + let _guard = DSH_PATCH_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); let result = db::pool() .get() .map_err(|error| error.to_string()) @@ -948,11 +991,13 @@ 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) + let _guard = mcp_op_lock(); + inject_project_mcp_locked(project_id, true) } -/// refresh_dsh=false 供批量重注入使用:逐项目跳过 DSH 刷新,由调用方在末尾统一刷新一次。 -fn inject_project_mcp_with(project_id: &str, refresh_dsh: bool) -> Result<(), String> { +/// 调用方须持有 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)?; @@ -999,8 +1044,7 @@ fn inject_project_mcp_with(project_id: &str, refresh_dsh: bool) -> Result<(), St try_register_globally(port); let root = project_root(project_id)?; let desired = (!project_wants_global_scope(&root)).then(|| (server_url, header_helper)); - // claude CLI 每次调用约 1-2 秒:投递到串行后台队列,不阻塞界面,也不与其他同步竞态 - enqueue_claude_sync(project_id, root, desired); + sync_claude_local(project_id, &root, desired); if refresh_dsh { refresh_dsh_patch(None); } @@ -1010,13 +1054,34 @@ fn inject_project_mcp_with(project_id: &str, refresh_dsh: bool) -> Result<(), St /// 从项目的 .claude/settings.json 移除炼境 MCP 配置(含旧组级 key)。 /// 调用方需自行保证项目已不属于任何产品组时才调用。 pub fn remove_project_mcp(project_id: &str) -> Result<(), String> { - // 先于登记删除算出根目录并排队清理客户端侧条目:残留的 local 条目会遮蔽全局注册并指向 404 地址。 - // 与注册共用同一串行队列,保证移除不会被更早提交的注册越过。 + let _guard = mcp_op_lock(); + // 先于登记删除、也先于删除本地文件夹同步清理客户端侧条目:残留的 local 条目会遮蔽全局注册并指向 404 地址。 if let Ok(root) = project_root(project_id) { - enqueue_claude_sync(project_id, root, None); + sync_claude_local(project_id, &root, None); } - // 登记此时仍在,用 exclude 立即剔除;token 随后被删,之后的刷新也不会再把它加回白名单 + // 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)?)?; let path = settings_path(project_id)?; if path.exists() { @@ -1030,10 +1095,7 @@ pub fn remove_project_mcp(project_id: &str) -> Result<(), String> { } write_json(&path, &settings)?; } - crate::credential_store::delete_mcp_auth_token_for_project( - &crate::credential_store::WindowsCredentialStore, - project_id, - ) + Ok(()) } /// 单一服务器化一次性迁移:批量重注入所有托管项目(换新 key + 清旧 key + liangjing.json 瘦身) @@ -1108,7 +1170,8 @@ pub fn reinject_all_mcp() -> Vec { let mut errors = Vec::new(); for project_id in project_ids { - if let Err(e) = inject_project_mcp_with(&project_id, false) { + let _guard = mcp_op_lock(); + if let Err(e) = inject_project_mcp_locked(&project_id, false) { errors.push(format!("项目 {}: {}", project_id, e)); } } @@ -1117,11 +1180,14 @@ pub fn reinject_all_mcp() -> Vec { errors } -/// 项目根目录或平台变更后调用:排队清理旧根下炼境托管的 Claude local 条目;已接入(有 token)的项目 -/// 在新根重新注入(会刷新 DSH 白名单),未接入的只刷新白名单。会调用 claude CLI,调用方应放在后台线程。 +/// 项目根目录或平台变更后调用:清理旧根下炼境托管的 Claude local 条目(直接编辑配置,旧目录不存在也能清理); +/// 已接入(有 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 { - enqueue_claude_sync(project_id, root, None); + sync_claude_local(project_id, &root, None); } let injected = crate::credential_store::mcp_auth_digest(project_id) .and_then(|digest| { @@ -1135,16 +1201,24 @@ pub fn on_project_root_changed(project_id: &str, old_root: Option) { refresh_dsh_patch(None); return; } - if let Err(error) = inject_project_mcp(project_id) { + if let Err(error) = inject_project_mcp_locked(project_id, true) { + // 属于注入结果,归入 mcp_inject;注入中途失败可能没走到刷新,这里补一次 db::log_event( - CLIENT_SYNC_LOG, + "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::*; @@ -1345,16 +1419,91 @@ mod tests { #[test] fn claude_local_entry_uses_scoped_url_and_shim() { - let entry: Value = serde_json::from_str(&claude_local_server_json( + let entry = claude_local_server_entry( "http://127.0.0.1:27190/mcp/projects/p1", &shim_helper_command("d1"), - )) - .unwrap(); + ); 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("%LOCALAPPDATA%")); } + #[test] + fn claude_local_direct_edit_touches_only_lian_jing_entry() { + let root = std::path::Path::new("E:\\code-project\\Demo"); + let entry = claude_local_server_entry( + "http://127.0.0.1:27190/mcp/projects/p1", + &shim_helper_command("d1"), + ); + // 已有项目键(大小写、分隔符与 root 不同):复用该键,保留 Claude 自己的字段与其他 server + let mut config = json!({ + "numStartups": 3, + "projects": { "e:/code-project/demo": { + "lastCost": 1.5, + "mcpServers": { "other": { "type": "stdio" } } + } } + }); + assert_eq!( + apply_claude_local(&mut config, root, Some(entry.clone())).unwrap(), + ClaudeLocalOutcome::Registered + ); + let project = &config["projects"]["e:/code-project/demo"]; + assert_eq!(config["projects"].as_object().unwrap().len(), 1, "不新增重复键"); + assert_eq!(project["lastCost"], 1.5); + assert_eq!(project["mcpServers"]["other"]["type"], "stdio"); + assert_eq!(project["mcpServers"][MCP_KEY], entry); + assert_eq!(config["numStartups"], 3); + assert_eq!( + apply_claude_local(&mut config, root, Some(entry.clone())).unwrap(), + ClaudeLocalOutcome::Unchanged, + "相同条目不重写" + ); + + assert_eq!(apply_claude_local(&mut config, root, None).unwrap(), ClaudeLocalOutcome::Removed); + let project = &config["projects"]["e:/code-project/demo"]; + assert!(project["mcpServers"].get(MCP_KEY).is_none()); + assert_eq!(project["mcpServers"]["other"]["type"], "stdio", "移除只删 lian-jing"); + assert_eq!(apply_claude_local(&mut config, root, None).unwrap(), ClaudeLocalOutcome::Unchanged); + + // 尚无项目键:按 Claude 惯例(正斜杠)新建 + let mut empty = json!({}); + apply_claude_local(&mut empty, root, Some(entry.clone())).unwrap(); + assert_eq!(empty["projects"]["E:/code-project/Demo"]["mcpServers"][MCP_KEY], entry); + + // 手写条目:注册与移除都不碰 + let manual = json!({ "type": "http", "url": "https://example.invalid/mcp" }); + let mut handwritten = json!({ "projects": { "E:/code-project/Demo": { "mcpServers": { "lian-jing": manual.clone() } } } }); + assert_eq!( + apply_claude_local(&mut handwritten, root, Some(entry)).unwrap(), + ClaudeLocalOutcome::KeptManual + ); + assert_eq!(apply_claude_local(&mut handwritten, root, None).unwrap(), ClaudeLocalOutcome::KeptManual); + assert_eq!(handwritten["projects"]["E:/code-project/Demo"]["mcpServers"][MCP_KEY], manual); + } + + #[test] + fn claude_project_path_is_long_form_without_verbatim_prefix() { + let dir = std::env::temp_dir().join(format!("lianjing_claude_path_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let resolved = claude_project_path(&dir).display().to_string(); + assert!(!resolved.starts_with(r"\\?\"), "不得带 verbatim 前缀:{resolved}"); + assert!(!resolved.contains('~'), "8.3 短名须展开为长路径:{resolved}"); + assert!(std::path::Path::new(&resolved).is_dir()); + let missing = std::path::Path::new("E:\\definitely\\missing\\root"); + assert_eq!(claude_project_path(missing), missing.to_path_buf(), "不存在时原样返回"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[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![ -- 2.45.2 From 1f90dcdeb0bdfb3dc2c925042eefb726751b6609 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Thu, 1 Oct 2026 14:44:00 +0900 Subject: [PATCH 08/15] =?UTF-8?q?docs(blueprint/agent-client-parity):=20P1?= =?UTF-8?q?/P3=20=E5=AE=8C=E6=88=90=EF=BC=8C=E8=AE=B0=E5=BD=95=E4=B8=89?= =?UTF-8?q?=E7=AB=AF=E9=AA=8C=E6=94=B6=E4=B8=8E=E5=AE=A1=E6=9F=A5=E8=AF=81?= =?UTF-8?q?=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .blueprint/manifest.yaml | 6 +++--- .blueprint/modules/agent-client-parity.md | 12 ++++++++---- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index 21e4f75..36c12fc 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -511,9 +511,9 @@ modules: name: 三端客户端对等 area: infra status: in_progress - progress: 0 - updated: 2026-09-30 - note: "目标:子项目在 Claude Code、Codex、DSH 三端都能继续开发。2026-09-30 无头基线探针:Codex 全绿;Claude 与 DSH 连全局 /mcp、看不到开发变量工具;Claude 缺 lian-jing skill。先统一连接地址,再处理认证与 skill 投影,并把探针固化为可重跑检查。" + progress: 38 + updated: 2026-10-01 + note: "P1/P2/P3 完成:三端(Claude Code、Codex、DSH)在 Enterprise 经稳定 shim 连项目专用地址,无头验收 B–F 全部通过(基线时 Claude、DSH 缺工具与认证、Claude 缺 skill)。Codex 项目配置不含本机路径;Claude 条目直接编辑 ~/.claude.json;DSH 托管块按登记白名单路由。四轮对抗审查问题均已修复。待做 P4 探针固化(依赖 architecture-evolution A1)、P5–P8。已知限制:DSH 桌面 GUI 模式回退全局。" position: [3500, 950] - id: architecture-evolution diff --git a/.blueprint/modules/agent-client-parity.md b/.blueprint/modules/agent-client-parity.md index 5b551bb..4974a9c 100644 --- a/.blueprint/modules/agent-client-parity.md +++ b/.blueprint/modules/agent-client-parity.md @@ -80,13 +80,16 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。 ## 任务拆解 -### 🔵 P1 — 统一连接项目专用地址(Claude + DSH) -- status: in_progress +### ✅ P1 — 统一连接项目专用地址(Claude + DSH) +- status: done - complexity: M - files: src-tauri/src/mcp_inject.rs, docs/ai-context/agent-button-system.md - depends: H1, H2 - acceptance: "agent_verify: [三端无头探针] 在 enterprise 目录 Claude 与 DSH 的 C 项均看到开发变量工具(项目工具集),E 项成功;项目目录外的会话不获得他项目工具。" - 注:吸收原 project-dev-variables V17(Claude 以 local scope + headersHelper 取值)。 +- verification: "2026-10-01 Enterprise 三端无头验收(零凭据:F 项以 read_project_test_account(name=LJ_PROBE_NONEXISTENT) 判定认证,返回「开发需求未登记」即认证通过):Codex 59s、Claude 40~56s、DSH 11~14s,三端 B/C/D/E/F 全部通过(基线时 Claude、DSH 的 C/E/F 失败、Claude 的 B 失败)。Claude 以 init 事件客观确认 lian-jing 32 个项目工具且含开发变量工具;claude mcp list 显示项目地址 ✔ Connected。DSH 托管块 url 表达式在项目根、子目录、带尾分隔符路径解析为项目地址,在上级/无关目录回退全局;控制面 null 哨兵命中即停。未知 project_id 返回 404。" +- review: "四轮对抗审查(23+18+17+最终 1 轮 agent):首轮 9 项问题、第二轮 13 条、第三轮 10 条均已修复;第三轮后将 Claude 条目管理由 claude CLI + 后台队列改为直接编辑 ~/.claude.json(消除 cwd 依赖与竞态),注入/移除/迁移加进程锁。" +- known_limits: "DSH 桌面 GUI 模式的 cwd 不随工作区切换,项目路由目前只对在项目目录启动的 headless/CLI 生效,GUI 回退全局;claude mcp list 会提示 lian-jing 同时存在 user 与 local 两个作用域(user 级全局为有意保留的兜底);~/.claude.json 由 Claude 自身频繁写入,炼境以「紧挨写入前读取 + 原子替换」将丢失更新窗口压到毫秒级,未做跨进程锁。" ### ✅ P2 — Claude skill 投影 - status: done @@ -96,13 +99,14 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。 - acceptance: "agent_verify: [cargo test onboarding] 接入包同时写 .claude/skills/lian-jing/SKILL.md,内容与 .agents/skills 单一来源一致;[Claude 无头探针] B 项看到 lian-jing。" - verification: "2026-10-01:同一模板双投影到 .agents/skills 与 .claude/skills;verify_onboarding 新增「Claude 炼境技能已分发」(检查链 12 项);base-onboarding 升至 1.6.0,supported_clients 增 dsh;skill 降级步骤改为三端通用并提示「无开发变量工具即连的是全局地址」。新增测试 skill_is_projected_to_codex_and_claude_from_one_source;cargo test --locked 229 passed / 5 ignored。Enterprise 分发后 verify_onboarding 14/14,两份 skill 逐字节一致;Claude 无头会话 init 事件 skills 与 slash_commands 均含 lian-jing(基线探针时为不存在)。" -### 🔵 P3 — 稳定 helper 入口,项目配置去本机路径 -- status: in_progress +### ✅ P3 — 稳定 helper 入口,项目配置去本机路径 +- status: done - complexity: M - files: src-tauri/src/mcp_inject.rs, src-tauri/src/lib.rs, src-tauri/src/commands/onboarding.rs - depends: H4 - acceptance: "agent_verify: [cargo test] 炼境启动写/更新 %LOCALAPPDATA% 下 shim 指向当前 exe;inject 后受跟踪文件不含本机绝对路径或用户名;[Codex 无头探针] F 项经 shim 成功;换构建目录启动后无需重注入仍成功。" - 注:即原 project-dev-variables V18;Claude headersHelper 与 DSH 共用同一 shim。 +- verification: "2026-10-01:shim 写入 %LOCALAPPDATA%\lian-jing\bin\lianjing-mcp-header.cmd(CRLF,exe 路径 % 转义),Enterprise 的 .codex/config.toml 托管块 helper 为 %LOCALAPPDATA% 形式、不含本机路径与用户名;Codex 无头 F 项经 shim 认证通过。H4 实测证实 Codex 经 cmd 展开 %LOCALAPPDATA%(替身目录因果对照)。换构建目录启动后免重注入:shim 每次启动重写指向当前 exe(实测在 worktree 构建启动时自动改指向)。" ### 📋 P4 — 三端对等探针固化为炼境能力 - status: todo -- 2.45.2 From 5f6ea586f8862a0419387a602cf32e2c504c8317 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Thu, 1 Oct 2026 15:38:36 +0900 Subject: [PATCH 09/15] =?UTF-8?q?fix(mcp/parity):=20=E6=9C=80=E7=BB=88?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E4=BF=AE=E5=A4=8D=E2=80=94=E2=80=94Claude=20?= =?UTF-8?q?=E6=9D=A1=E7=9B=AE=E5=86=99=E5=85=A5=E5=9B=9E=E5=BD=92=20claude?= =?UTF-8?q?=20CLI=EF=BC=8C=E5=8F=AA=E8=AF=BB=E6=89=AB=E6=8F=8F=E8=AE=A4?= =?UTF-8?q?=E9=A2=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 最终审查指出直接编辑 ~/.claude.json 须复刻 Claude 私有机制(git 根 / worktree→主仓库 / NFC 键推导、.claude.json.lock 锁协议、CLAUDE_CONFIG_DIR 与旧版 .config.json、BOM/符号链接/ fsync/键顺序),随 Claude 升级易失效,违背「标准优先、私有机制只进适配层」。 - 写入一律经 claude CLI(cwd=项目目录,同步、带 20 秒超时),由 Claude 自己负责键、锁与路径 - 只读扫描按 URL 中 /mcp/projects/ 认领炼境托管条目(不依赖键推导);已一致则不起 CLI (实测稳态注入 0.04 秒),手写条目不碰;项目目录已不存在时只记录残留、不写文件 - 全局注册只读确认已存在就跳过 CLI;配置路径按 Claude 规则解析,容忍 BOM - 批量重注入在锁内复查组成员关系,已移出组的项目不再注入、不重建 token Co-Authored-By: Claude Opus 5.5 --- docs/ai-context/project-context.yaml | 2 +- src-tauri/src/mcp_inject.rs | 489 ++++++++++++--------------- 2 files changed, 216 insertions(+), 275 deletions(-) diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index 08a3cfb..b754030 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -6,7 +6,7 @@ contracts: - label: API 接口(Axum) paths: - "src-tauri/src/mcp_server.rs" - doc: "MCP 仅监听 127.0.0.1;新项目使用 /mcp/projects/ scoped route,连接 scope 拒绝跨项目参数和全局管理写工具。scoped route 只服务已登记项目,未知 project_id 返回 404(数据库不可用同样拒绝)。各客户端经 %LOCALAPPDATA%/lian-jing/bin/lianjing-mcp-header.cmd 稳定入口取认证头(炼境每次启动指向当前 exe):Codex 写项目 .codex/config.toml,Claude 写 ~/.claude.json 的 local 条目(优先于 user 级全局;直接编辑该文件中本项目键下的 mcpServers.lian-jing,不经 claude CLI、不依赖项目目录存在,项目键用真实长路径;原子替换写入),DSH 写 ~/.dsh/cordis.patch.yml 托管块(内嵌炼境登记的「项目根目录→project_id」白名单,!!js 从启动目录向上精确匹配根目录才选项目地址并取认证,任意目录里的 context.json 不作授权依据;未命中回退全局;项目增删与端口变更时重写)。Claude local 条目与 DSH 块只替换炼境托管的条目,手写同名配置不覆盖;移除项目时一并清理。旧 /mcp 只作兼容迁移。read_project_dev_variable 与 read_project_test_account 是两种有界开发值读取:仅 scoped 路由、匹配项目的本机 bearer 认证后单项读取;认证失败在 dispatch 前拒绝。路由变更需同步 Agent 按钮体系文档。" + doc: "MCP 仅监听 127.0.0.1;新项目使用 /mcp/projects/ scoped route,连接 scope 拒绝跨项目参数和全局管理写工具。scoped route 只服务已登记项目,未知 project_id 返回 404(数据库不可用同样拒绝)。各客户端经 %LOCALAPPDATA%/lian-jing/bin/lianjing-mcp-header.cmd 稳定入口取认证头(炼境每次启动指向当前 exe):Codex 写项目 .codex/config.toml,Claude 写 local 条目(优先于 user 级全局;一律经 claude CLI、cwd=项目目录写入,项目键推导、文件锁与配置路径交给 Claude 自身,不直接编辑 ~/.claude.json;先只读扫描、按 URL 中的项目路由认领炼境托管条目,已一致则不起 CLI;项目目录已不存在时只记录残留不写入),DSH 写 ~/.dsh/cordis.patch.yml 托管块(内嵌炼境登记的「项目根目录→project_id」白名单,!!js 从启动目录向上精确匹配根目录才选项目地址并取认证,任意目录里的 context.json 不作授权依据;未命中回退全局;项目增删与端口变更时重写)。Claude local 条目与 DSH 块只替换炼境托管的条目,手写同名配置不覆盖;移除项目时一并清理。旧 /mcp 只作兼容迁移。read_project_dev_variable 与 read_project_test_account 是两种有界开发值读取:仅 scoped 路由、匹配项目的本机 bearer 认证后单项读取;认证失败在 dispatch 前拒绝。路由变更需同步 Agent 按钮体系文档。" - label: MCP 工具接口 paths: diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index 8233eef..9400d01 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -272,6 +272,10 @@ fn find_git_bash() -> Option { /// 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( @@ -292,47 +296,23 @@ fn try_register_globally(port: u16) -> bool { return false; }; - match crate::silent_cmd(&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() => { + 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 } - 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; - } + // 同名已存在不是故障——CLI 链路健康,注册目标已达成 + Err(output) if output.contains("already exists") => true, + Err(output) => { db::log_event( "mcp_inject", "warn", - &format!("claude mcp add 非零退出: {}", combined), - Some(&global_log_context()), - ); - false - } - Err(e) => { - db::log_event( - "mcp_inject", - "warn", - &format!("执行 claude mcp add 失败: {}", e), + &format!("claude mcp add 失败: {output}"), Some(&global_log_context()), ); false @@ -370,146 +350,107 @@ fn claude_local_server_entry(server_url: &str, header_helper: &str) -> Value { }) } -#[derive(Debug, PartialEq)] -enum ClaudeLocalEntry { - Absent, - /// 炼境写入的条目(项目路由 + 稳定 helper),可安全替换或删除。 - Managed(Value), - /// 用户手写的同名条目:炼境不覆盖、不删除。 - Manual, +/// 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.json 的项目键是 Claude 记录的路径(实测为正斜杠盘符形式),比较时统一分隔符与大小写。 -fn normalize_root_key(path: &str) -> String { - path.replace('\\', "/").trim_end_matches('/').to_lowercase() +/// 只读读取 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() } -fn classify_claude_local_entry(claude_json: &Value, root: &std::path::Path) -> ClaudeLocalEntry { - let wanted = normalize_root_key(&root.display().to_string()); - let entry = claude_json - .get("projects") - .and_then(Value::as_object) - .and_then(|projects| { - projects - .iter() - .find(|(key, _)| normalize_root_key(key) == wanted) - .map(|(_, value)| value) +/// 只读扫描:所有项目键下指向本项目专用地址、且经稳定 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())) }) - .and_then(|project| project.get("mcpServers")) - .and_then(|servers| servers.get(MCP_KEY)); - let Some(entry) = entry else { - return ClaudeLocalEntry::Absent; - }; - let url = entry.get("url").and_then(Value::as_str).unwrap_or(""); - let helper = entry.get("headersHelper").and_then(Value::as_str).unwrap_or(""); - if url.starts_with("http://127.0.0.1:") - && url.contains("/mcp/projects/") - && helper.contains(MCP_HEADER_SHIM_NAME) - { - ClaudeLocalEntry::Managed(entry.clone()) - } else { - ClaudeLocalEntry::Manual - } + .collect() } -#[derive(Debug, PartialEq)] -enum ClaudeLocalOutcome { - Unchanged, - Registered, - Removed, - KeptManual, +fn claude_global_registered(config: &Value) -> bool { + config + .get("mcpServers") + .and_then(|servers| servers.get(MCP_KEY)) + .is_some() } -/// 纯函数:把期望状态写进 ~/.claude.json 的配置对象。只动本项目条目下的 mcpServers.lian-jing, -/// 项目对象里 Claude 自己维护的其他字段与其他 server 原样保留;手写条目不碰。 -fn apply_claude_local( - config: &mut Value, - root: &std::path::Path, - desired: Option, -) -> Result { - match (classify_claude_local_entry(config, root), &desired) { - (ClaudeLocalEntry::Manual, _) => return Ok(ClaudeLocalOutcome::KeptManual), - (ClaudeLocalEntry::Absent, None) => return Ok(ClaudeLocalOutcome::Unchanged), - (ClaudeLocalEntry::Managed(current), Some(entry)) if ¤t == entry => { - return Ok(ClaudeLocalOutcome::Unchanged) - } - _ => {} +/// claude CLI 单次调用上限:卡住的子进程会被终止,不让它拖住持锁的接入操作与界面。 +const CLAUDE_CLI_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20); + +/// 运行 claude CLI(不弹窗、stdin 置空、带超时)。成功返回 Ok,失败返回合并后的输出。 +fn run_claude_cli( + claude_bin: &str, + git_bash: &str, + cwd: Option<&std::path::Path>, + args: &[&str], +) -> Result<(), String> { + use std::io::Read; + let mut command = crate::silent_cmd(claude_bin); + if let Some(dir) = cwd { + command.current_dir(dir); } - let wanted = normalize_root_key(&root.display().to_string()); - let projects = config - .as_object_mut() - .ok_or("~/.claude.json 顶层不是 JSON 对象")? - .entry("projects") - .or_insert_with(|| json!({})) - .as_object_mut() - .ok_or("~/.claude.json 的 projects 不是 JSON 对象")?; - // 复用 Claude 已记录的键(大小写、分隔符以它为准),没有才按它的惯例(正斜杠)新建 - let key = projects - .keys() - .find(|key| normalize_root_key(key) == wanted) - .cloned() - .unwrap_or_else(|| root.display().to_string().replace('\\', "/")); - match desired { - Some(entry) => { - projects - .entry(key) - .or_insert_with(|| json!({})) - .as_object_mut() - .ok_or("~/.claude.json 的项目条目不是 JSON 对象")? - .entry("mcpServers") - .or_insert_with(|| json!({})) - .as_object_mut() - .ok_or("~/.claude.json 的 mcpServers 不是 JSON 对象")? - .insert(MCP_KEY.to_string(), entry); - Ok(ClaudeLocalOutcome::Registered) - } - None => { - if let Some(servers) = projects - .get_mut(&key) - .and_then(|project| project.get_mut("mcpServers")) - .and_then(Value::as_object_mut) - { - servers.remove(MCP_KEY); + 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(ClaudeLocalOutcome::Removed) - } - } -} - -/// 同目录临时文件 + 重命名替换:读者只会看到完整的旧文件或新文件。目标被短暂占用时重试几次。 -fn write_file_atomic(path: &std::path::Path, content: &str) -> Result<(), String> { - let tmp = path.with_file_name(format!( - "{}.lianjing-tmp", - path.file_name().and_then(|name| name.to_str()).unwrap_or("config") - )); - std::fs::write(&tmp, content).map_err(|error| format!("写临时文件失败: {error}"))?; - let mut last_error = None; - for _ in 0..5 { - match std::fs::rename(&tmp, path) { - Ok(()) => return Ok(()), - Err(error) => { - last_error = Some(error); - std::thread::sleep(std::time::Duration::from_millis(50)); + 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 _ = std::fs::remove_file(&tmp); - Err(format!("替换文件失败: {}", last_error.map(|e| e.to_string()).unwrap_or_default())) -} - -/// Claude 以真实长路径记录项目(8.3 短名与大小写都以文件系统为准;2026-10-01 实测短路径键不会被识别)。 -/// 目录存在时规范化为长路径(去掉 \\?\ 前缀),不存在(已移动或删除)时原样使用。 -fn claude_project_path(root: &std::path::Path) -> PathBuf { - let Ok(canonical) = std::fs::canonicalize(root) else { - return root.to_path_buf(); }; - let text = canonical.display().to_string(); - if let Some(rest) = text.strip_prefix(r"\\?\UNC\") { - PathBuf::from(format!(r"\\{rest}")) - } else if let Some(rest) = text.strip_prefix(r"\\?\") { - PathBuf::from(rest) + 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(()) } else { - canonical + Err(output) } } @@ -517,48 +458,84 @@ fn claude_project_path(root: &std::path::Path) -> PathBuf { const CLIENT_SYNC_LOG: &str = "mcp_client_sync"; /// 让 Claude 的 local 条目与期望一致:desired=Some 时注册/刷新,None 时只删除炼境托管的条目。 -/// 直接编辑 ~/.claude.json(Claude Code 文档列出的配置方式之一),不经 claude CLI:不依赖项目目录 -/// 存在(目录已删除或移动也能清理旧条目)、不起子进程、毫秒级完成,因此可在调用方同步执行。 -/// Claude 自己也会写这个文件,读改写窗口压到最小(紧挨写入前读取 + 原子替换)。best-effort,只记日志。 -/// 调用方须持有 mcp_op_lock,保证本进程内对该文件的读改写串行。 +/// +/// 写入一律交给 claude CLI(cwd=项目目录):项目键推导、Claude 自己的文件锁、配置路径与读写细节都由 +/// Claude 负责,炼境不复刻其私有实现(2026-10-01 最终审查:直接编辑 ~/.claude.json 需要复刻 git 根 / +/// worktree / NFC 键推导、.claude.json.lock 锁协议、CLAUDE_CONFIG_DIR 等一整套内部机制,随 Claude 升级易失效)。 +/// 只读扫描判定现状:已一致则不起 CLI(稳态不阻塞);只替换/删除炼境托管条目,手写条目不碰。 +/// 项目目录已不存在时无法经 CLI 定位项目键,只记录残留、不写文件。 +/// 调用方须持有 mcp_op_lock,并在删除本地文件夹之前同步调用(避免与目录删除竞态)。 fn sync_claude_local(project_id: &str, root: &std::path::Path, desired: Option<(String, String)>) { - let Ok(home) = std::env::var("USERPROFILE") else { - return; - }; - let path = PathBuf::from(home).join(".claude.json"); - if !path.is_file() { - // 本机从未运行过 Claude Code:不替它创建配置 + let context = inject_log_context(project_id, "claude"); + let managed = read_claude_config() + .map(|config| managed_claude_entries(&config, project_id)) + .unwrap_or_default(); + let desired_entry = desired.map(|(url, helper)| claude_local_server_entry(&url, &helper)); + match &desired_entry { + None if managed.is_empty() => return, + Some(entry) if managed.iter().any(|(_, current)| current == entry) => return, + _ => {} + } + if !root.is_dir() { + let keys: Vec<&str> = managed.iter().map(|(key, _)| key.as_str()).collect(); + db::log_event( + CLIENT_SYNC_LOG, + "warn", + &format!( + "项目目录已不存在,无法经 claude CLI 同步 Claude 项目级条目;残留托管条目所在项目键 {keys:?},可在对应目录执行 `claude mcp remove lian-jing -s local` 清理" + ), + Some(&context), + ); return; } - let root = claude_project_path(root); - let root = root.as_path(); - let result = (|| -> Result { - let text = std::fs::read_to_string(&path).map_err(|error| format!("读取失败: {error}"))?; - let mut config: Value = - serde_json::from_str(&text).map_err(|error| format!("解析失败,炼境不写入: {error}"))?; - let entry = desired.map(|(url, helper)| claude_local_server_entry(&url, &helper)); - let outcome = apply_claude_local(&mut config, root, entry)?; - if matches!(outcome, ClaudeLocalOutcome::Registered | ClaudeLocalOutcome::Removed) { - let content = serde_json::to_string_pretty(&config).map_err(|error| error.to_string())?; - write_file_atomic(&path, &content)?; - } - Ok(outcome) - })(); - let context = inject_log_context(project_id, "claude"); - match result { - Ok(ClaudeLocalOutcome::Unchanged) => {} - Ok(outcome) => { - let message = match outcome { - ClaudeLocalOutcome::Registered => "Claude 项目级 MCP 条目已写入", - ClaudeLocalOutcome::Removed => "Claude 项目级 MCP 条目已移除", - _ => "本项目已有手写的 lian-jing local 条目,炼境不覆盖", - }; - db::log_event(CLIENT_SYNC_LOG, "info", message, Some(&context)); + let (Some(claude_bin), Some(git_bash)) = (find_claude_bin(), find_git_bash()) else { + db::log_event( + CLIENT_SYNC_LOG, + "warn", + "claude CLI 或 git-bash 未找到,跳过 Claude 项目级 MCP 同步", + Some(&context), + ); + return; + }; + let remove_error = if managed.is_empty() { + None + } else { + run_claude_cli( + &claude_bin, + &git_bash, + Some(root), + &["mcp", "remove", MCP_KEY, "--scope", "local"], + ) + .err() + }; + let outcome: Result<&str, String> = match &desired_entry { + None => match remove_error { + None => Ok("Claude 项目级 MCP 条目已移除"), + Some(error) => Err(error), + }, + Some(entry) => { + let payload = entry.to_string(); + match run_claude_cli( + &claude_bin, + &git_bash, + Some(root), + &["mcp", "add-json", MCP_KEY, payload.as_str(), "--scope", "local"], + ) { + Ok(()) => Ok("Claude 项目级 MCP 条目已写入"), + Err(error) if error.contains("already exists") => match remove_error { + None => Ok("本项目已有手写的 lian-jing local 条目,炼境不覆盖"), + Some(remove_error) => Err(format!("旧条目移除失败: {remove_error}")), + }, + Err(error) => Err(error), + } } + }; + match outcome { + Ok(message) => db::log_event(CLIENT_SYNC_LOG, "info", message, Some(&context)), Err(error) => db::log_event( CLIENT_SYNC_LOG, "warn", - &format!("Claude 项目级 MCP 同步失败(~/.claude.json): {error}"), + &format!("Claude 项目级 MCP 同步失败: {error}"), Some(&context), ), } @@ -1171,6 +1148,22 @@ pub fn reinject_all_mcp() -> Vec { let mut errors = Vec::new(); for project_id in project_ids { let _guard = mcp_op_lock(); + // 快照取自锁外:锁内复查成员关系,已被移出组的项目不再重新注入(避免重建刚删除的 token) + let still_member = db::pool() + .get() + .ok() + .and_then(|conn| { + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM project_group_map WHERE project_id = ?1)", + [&project_id], + |row| row.get::<_, bool>(0), + ) + .ok() + }) + .unwrap_or(false); + if !still_member { + continue; + } if let Err(e) = inject_project_mcp_locked(&project_id, false) { errors.push(format!("项目 {}: {}", project_id, e)); } @@ -1348,27 +1341,6 @@ mod tests { assert!(percent.contains("\"C:\\Users\\a%%b\\app.exe\" --lianjing-mcp-header %1"), "路径 % 须转义"); } - #[test] - fn claude_local_entry_classification_protects_manual_entries() { - let root = std::path::Path::new("E:\\code-project\\Demo"); - let config = |entry: Value| json!({ "projects": { "e:/code-project/demo": { "mcpServers": { "lian-jing": entry } } } }); - assert_eq!(classify_claude_local_entry(&json!({}), root), ClaudeLocalEntry::Absent); - let managed = json!({ - "type": "http", - "url": "http://127.0.0.1:27190/mcp/projects/p1", - "headersHelper": shim_helper_command("d1"), - }); - assert_eq!( - classify_claude_local_entry(&config(managed.clone()), root), - ClaudeLocalEntry::Managed(managed), - "键名大小写与分隔符差异须视为同一项目" - ); - let manual = json!({ "type": "http", "url": "https://example.invalid/mcp" }); - assert_eq!(classify_claude_local_entry(&config(manual), root), ClaudeLocalEntry::Manual); - let other_project = json!({ "projects": { "e:/code-project/other": { "mcpServers": { "lian-jing": {} } } } }); - assert_eq!(classify_claude_local_entry(&other_project, root), ClaudeLocalEntry::Absent); - } - #[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())); @@ -1429,69 +1401,38 @@ mod tests { } #[test] - fn claude_local_direct_edit_touches_only_lian_jing_entry() { - let root = std::path::Path::new("E:\\code-project\\Demo"); - let entry = claude_local_server_entry( - "http://127.0.0.1:27190/mcp/projects/p1", + 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"), ); - // 已有项目键(大小写、分隔符与 root 不同):复用该键,保留 Claude 自己的字段与其他 server - let mut config = json!({ - "numStartups": 3, - "projects": { "e:/code-project/demo": { - "lastCost": 1.5, - "mcpServers": { "other": { "type": "stdio" } } - } } + 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!( - apply_claude_local(&mut config, root, Some(entry.clone())).unwrap(), - ClaudeLocalOutcome::Registered + keys, + vec!["E:/repos/main", "E:/repos/other"], + "端口变化仍认领;前缀相同的他项目、无 shim 的手写条目不认领" ); - let project = &config["projects"]["e:/code-project/demo"]; - assert_eq!(config["projects"].as_object().unwrap().len(), 1, "不新增重复键"); - assert_eq!(project["lastCost"], 1.5); - assert_eq!(project["mcpServers"]["other"]["type"], "stdio"); - assert_eq!(project["mcpServers"][MCP_KEY], entry); - assert_eq!(config["numStartups"], 3); - assert_eq!( - apply_claude_local(&mut config, root, Some(entry.clone())).unwrap(), - ClaudeLocalOutcome::Unchanged, - "相同条目不重写" - ); - - assert_eq!(apply_claude_local(&mut config, root, None).unwrap(), ClaudeLocalOutcome::Removed); - let project = &config["projects"]["e:/code-project/demo"]; - assert!(project["mcpServers"].get(MCP_KEY).is_none()); - assert_eq!(project["mcpServers"]["other"]["type"], "stdio", "移除只删 lian-jing"); - assert_eq!(apply_claude_local(&mut config, root, None).unwrap(), ClaudeLocalOutcome::Unchanged); - - // 尚无项目键:按 Claude 惯例(正斜杠)新建 - let mut empty = json!({}); - apply_claude_local(&mut empty, root, Some(entry.clone())).unwrap(); - assert_eq!(empty["projects"]["E:/code-project/Demo"]["mcpServers"][MCP_KEY], entry); - - // 手写条目:注册与移除都不碰 - let manual = json!({ "type": "http", "url": "https://example.invalid/mcp" }); - let mut handwritten = json!({ "projects": { "E:/code-project/Demo": { "mcpServers": { "lian-jing": manual.clone() } } } }); - assert_eq!( - apply_claude_local(&mut handwritten, root, Some(entry)).unwrap(), - ClaudeLocalOutcome::KeptManual - ); - assert_eq!(apply_claude_local(&mut handwritten, root, None).unwrap(), ClaudeLocalOutcome::KeptManual); - assert_eq!(handwritten["projects"]["E:/code-project/Demo"]["mcpServers"][MCP_KEY], manual); - } - - #[test] - fn claude_project_path_is_long_form_without_verbatim_prefix() { - let dir = std::env::temp_dir().join(format!("lianjing_claude_path_{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let resolved = claude_project_path(&dir).display().to_string(); - assert!(!resolved.starts_with(r"\\?\"), "不得带 verbatim 前缀:{resolved}"); - assert!(!resolved.contains('~'), "8.3 短名须展开为长路径:{resolved}"); - assert!(std::path::Path::new(&resolved).is_dir()); - let missing = std::path::Path::new("E:\\definitely\\missing\\root"); - assert_eq!(claude_project_path(missing), missing.to_path_buf(), "不存在时原样返回"); - let _ = std::fs::remove_dir_all(&dir); + 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] -- 2.45.2 From 92e64504038db1e00c250f49aebf72cbed53489c Mon Sep 17 00:00:00 2001 From: lanrtop Date: Thu, 1 Oct 2026 15:41:08 +0900 Subject: [PATCH 10/15] =?UTF-8?q?docs(blueprint/agent-client-parity):=20?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=20P1=20=E5=AE=A1=E6=9F=A5=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E4=B8=8E=E5=B7=B2=E7=9F=A5=E9=99=90=E5=88=B6=E4=B8=BA=E6=9C=80?= =?UTF-8?q?=E7=BB=88=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .blueprint/modules/agent-client-parity.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.blueprint/modules/agent-client-parity.md b/.blueprint/modules/agent-client-parity.md index 4974a9c..dbccc9d 100644 --- a/.blueprint/modules/agent-client-parity.md +++ b/.blueprint/modules/agent-client-parity.md @@ -88,8 +88,8 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。 - acceptance: "agent_verify: [三端无头探针] 在 enterprise 目录 Claude 与 DSH 的 C 项均看到开发变量工具(项目工具集),E 项成功;项目目录外的会话不获得他项目工具。" - 注:吸收原 project-dev-variables V17(Claude 以 local scope + headersHelper 取值)。 - verification: "2026-10-01 Enterprise 三端无头验收(零凭据:F 项以 read_project_test_account(name=LJ_PROBE_NONEXISTENT) 判定认证,返回「开发需求未登记」即认证通过):Codex 59s、Claude 40~56s、DSH 11~14s,三端 B/C/D/E/F 全部通过(基线时 Claude、DSH 的 C/E/F 失败、Claude 的 B 失败)。Claude 以 init 事件客观确认 lian-jing 32 个项目工具且含开发变量工具;claude mcp list 显示项目地址 ✔ Connected。DSH 托管块 url 表达式在项目根、子目录、带尾分隔符路径解析为项目地址,在上级/无关目录回退全局;控制面 null 哨兵命中即停。未知 project_id 返回 404。" -- review: "四轮对抗审查(23+18+17+最终 1 轮 agent):首轮 9 项问题、第二轮 13 条、第三轮 10 条均已修复;第三轮后将 Claude 条目管理由 claude CLI + 后台队列改为直接编辑 ~/.claude.json(消除 cwd 依赖与竞态),注入/移除/迁移加进程锁。" -- known_limits: "DSH 桌面 GUI 模式的 cwd 不随工作区切换,项目路由目前只对在项目目录启动的 headless/CLI 生效,GUI 回退全局;claude mcp list 会提示 lian-jing 同时存在 user 与 local 两个作用域(user 级全局为有意保留的兜底);~/.claude.json 由 Claude 自身频繁写入,炼境以「紧挨写入前读取 + 原子替换」将丢失更新窗口压到毫秒级,未做跨进程锁。" +- review: "五轮对抗审查(首轮 9 项、第二轮 13 条、第三轮 10 条、最终 10 条,均已处理)。Claude 条目写入方式两度调整:CLI+后台队列(cwd 依赖与竞态)→ 直接编辑 ~/.claude.json(须复刻 Claude 的 git 根/worktree/NFC 键推导、.claude.json.lock、CLAUDE_CONFIG_DIR 等私有机制)→ 最终定为『只读扫描认领 + claude CLI 同步写入』:键、锁、路径交给 Claude 自身,炼境不复刻。实测稳态注入 0.04 秒(不起 CLI),缺失时 0.6 秒经 CLI 写回且 claude mcp list 显示 ✔ Connected。" +- known_limits: "DSH 桌面 GUI 模式的 cwd 不随工作区切换,项目路由目前只对在项目目录启动的 headless/CLI 生效,GUI 回退全局;claude mcp list 会提示 lian-jing 同时存在 user 与 local 两个作用域(user 级全局为有意保留的兜底);项目目录已被移动或删除后,Claude 中的残留托管条目无法经 CLI 定位,只记录 warn 与手工清理命令,不直接改写 ~/.claude.json。" ### ✅ P2 — Claude skill 投影 - status: done -- 2.45.2 From 0a92a5b695fb567c5fbf34e4a5f4ace29eb29143 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Thu, 1 Oct 2026 16:55:28 +0900 Subject: [PATCH 11/15] =?UTF-8?q?fix(mcp/parity):=20=E6=94=B6=E5=B0=BE?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E4=BF=AE=E5=A4=8D=E2=80=94=E2=80=94Claude=20?= =?UTF-8?q?=E7=8E=B0=E7=8A=B6=E4=BB=A5=20claude=20mcp=20get=20=E4=B8=BA?= =?UTF-8?q?=E5=87=86=EF=BC=8C=E6=8E=A5=E5=85=A5=E5=91=BD=E4=BB=A4=E7=A7=BB?= =?UTF-8?q?=E5=87=BA=E7=95=8C=E9=9D=A2=E7=BA=BF=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 收尾对抗审查指出:只读扫描 ~/.claude.json 的结论不能代表 Claude 在项目目录实际生效的条目 (共享项目键、worktree、目录迁移下会误删他项目条目或漏写),且同步 CLI 调用会阻塞界面线程。 - 快路径只认「项目根或其祖先目录键下」的一致托管条目;其余一律在项目目录执行 `claude mcp get lian-jing` 取实际生效条目,按归属决策:本项目托管条目 → 刷新/移除 (写入失败用原条目回滚);他项目托管条目或手写条目 → 不碰并记录;无 local 条目 → 写入 - 输出无法识别、CLI 出错或目录不存在时一律不写,只记 warn - 全局迁移需 claude CLI 与 git-bash 均可用才置完成标记;批量重注入的成员复查失败计入错误 - 删组/移出组先删成员关系再清 MCP 配置,避免并发重注入把项目加回;调 CLI 前释放数据库连接 - delete_group / add_group_member / remove_group_member / delete_project / update_settings / apply_blueprint_buff 改为 #[tauri::command(async)],在线程池执行 - 新增 parse_claude_mcp_get / lianjing_project_of / key_covers_root 单元测试 实测(enterprise):稳态注入 174ms 不起 CLI;条目缺失 → 写入;过期 helper → 刷新; 手写条目 → 保留;三种情况 claude mcp get 均为 Local 且 Connected;经 shim 零凭据探针认证通过。 Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/commands/buff.rs | 3 +- src-tauri/src/commands/groups.rs | 50 +-- src-tauri/src/commands/projects.rs | 3 +- src-tauri/src/commands/settings.rs | 3 +- src-tauri/src/mcp_inject.rs | 492 ++++++++++++++++++++++------- 5 files changed, 415 insertions(+), 136 deletions(-) diff --git a/src-tauri/src/commands/buff.rs b/src-tauri/src/commands/buff.rs index afb2e1c..2202fd3 100644 --- a/src-tauri/src/commands/buff.rs +++ b/src-tauri/src/commands/buff.rs @@ -17,7 +17,8 @@ pub struct BuffStatus { // ── Tauri 命令 ─────────────────────────────────────────────────────────────── /// 向项目施加蓝图 Buff:注入 CONVENTIONS + CLAUDE.md,写入 DB -#[tauri::command] +// 会经 claude CLI 写客户端配置:在线程池执行,不占界面线程 +#[tauri::command(async)] pub fn apply_blueprint_buff( project_path: String, project_id: String, diff --git a/src-tauri/src/commands/groups.rs b/src-tauri/src/commands/groups.rs index 25e02f8..4a6a0c0 100644 --- a/src-tauri/src/commands/groups.rs +++ b/src-tauri/src/commands/groups.rs @@ -72,10 +72,10 @@ pub fn update_group(id: String, input: GroupInput) -> Result<(), String> { Ok(()) } -#[tauri::command] +// 会经 claude CLI 清理客户端配置:在线程池执行,不占界面线程 +#[tauri::command(async)] pub fn delete_group(id: String) -> Result<(), String> { let conn = pool().get().map_err(|e| e.to_string())?; - // 先清除所有成员的 MCP 配置(DB cascade 删除之前) let member_ids: Vec = conn .prepare("SELECT project_id FROM project_group_map WHERE group_id = ?1") .map_err(|e| e.to_string())? @@ -83,18 +83,24 @@ pub fn delete_group(id: String) -> Result<(), String> { .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .collect(); - for project_id in &member_ids { - // 单一 lian-jing key 为全项目共用:成员还属于其他组时保留配置 - let in_other_group: i64 = conn - .query_row( + // 单一 lian-jing key 为全项目共用:成员还属于其他组时保留配置。删组前先算出需清理的成员 + let to_clear: Vec<&String> = member_ids + .iter() + .filter(|project_id| { + conn.query_row( "SELECT COUNT(*) FROM project_group_map WHERE project_id = ?1 AND group_id != ?2", params![project_id, id], - |row| row.get(0), + |row| row.get::<_, i64>(0), ) - .unwrap_or(0); - if in_other_group > 0 { - continue; - } + .unwrap_or(0) + == 0 + }) + .collect(); + // 先删组(级联删除成员关系)再清 MCP 配置:批量重注入在锁内复查成员关系,先删可避免把它们重新注入 + conn.execute("DELETE FROM product_groups WHERE id = ?1", params![id]) + .map_err(|e| e.to_string())?; + drop(conn); + for project_id in to_clear { if let Err(e) = mcp_inject::remove_project_mcp(project_id) { let ctx = serde_json::json!({"project_id": project_id, "group_id": id}).to_string(); log_event( @@ -105,8 +111,6 @@ pub fn delete_group(id: String) -> Result<(), String> { ); } } - conn.execute("DELETE FROM product_groups WHERE id = ?1", params![id]) - .map_err(|e| e.to_string())?; Ok(()) } @@ -181,6 +185,7 @@ pub fn ensure_standalone_group(project_id: &str) -> Result<(), String> { params![project_id, group_id], ) .map_err(|e| e.to_string())?; + drop(conn); if let Err(e) = mcp_inject::inject_project_mcp(project_id) { let ctx = serde_json::json!({"project_id": project_id, "group_id": &group_id}).to_string(); @@ -195,7 +200,8 @@ pub fn ensure_standalone_group(project_id: &str) -> Result<(), String> { Ok(()) } -#[tauri::command] +// 会经 claude CLI 写客户端配置:在线程池执行,不占界面线程 +#[tauri::command(async)] pub fn add_group_member( project_id: String, group_id: String, @@ -207,6 +213,7 @@ pub fn add_group_member( params![project_id, group_id, role], ) .map_err(|e| e.to_string())?; + drop(conn); // DB 写入成功后注入 MCP 配置(非致命,注入失败不影响成员关系) let inject_warning = match mcp_inject::inject_project_mcp(&project_id) { Ok(()) => None, @@ -220,7 +227,8 @@ pub fn add_group_member( Ok(inject_warning) } -#[tauri::command] +// 会经 claude CLI 清理客户端配置:在线程池执行,不占界面线程 +#[tauri::command(async)] pub fn remove_group_member(project_id: String, group_id: String) -> Result<(), String> { let conn = pool().get().map_err(|e| e.to_string())?; // 仅当项目退出后不再属于任何组时才清除 MCP 配置(单一 lian-jing key 为全项目共用) @@ -231,6 +239,13 @@ pub fn remove_group_member(project_id: String, group_id: String) -> Result<(), S |row| row.get(0), ) .unwrap_or(0); + // 先删成员关系再清 MCP 配置:批量重注入在锁内复查成员关系,先删可避免把它重新注入 + conn.execute( + "DELETE FROM project_group_map WHERE project_id = ?1 AND group_id = ?2", + params![project_id, group_id], + ) + .map_err(|e| e.to_string())?; + drop(conn); if in_other_group == 0 { if let Err(e) = mcp_inject::remove_project_mcp(&project_id) { let ctx = @@ -243,10 +258,5 @@ pub fn remove_group_member(project_id: String, group_id: String) -> Result<(), S ); } } - conn.execute( - "DELETE FROM project_group_map WHERE project_id = ?1 AND group_id = ?2", - params![project_id, group_id], - ) - .map_err(|e| e.to_string())?; Ok(()) } diff --git a/src-tauri/src/commands/projects.rs b/src-tauri/src/commands/projects.rs index cbedfc1..32e6c3a 100644 --- a/src-tauri/src/commands/projects.rs +++ b/src-tauri/src/commands/projects.rs @@ -327,7 +327,8 @@ pub fn update_project_tags(id: String, tags: String) -> Result<(), String> { Ok(()) } -#[tauri::command] +// 会经 claude CLI 清理客户端配置:在线程池执行,不占界面线程 +#[tauri::command(async)] pub fn delete_project(id: String, delete_local: Option) -> Result<(), String> { let conn = pool().get().map_err(|e| e.to_string())?; diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 8daeed1..aae9f83 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -76,7 +76,8 @@ pub fn get_settings(space_id: Option) -> Result, } /// 更新 settings:全局 key 写入 settings 表;其余写入 space_settings 表 -#[tauri::command] +// 端口变更会重注入全部项目(含 claude CLI):在线程池执行,不占界面线程 +#[tauri::command(async)] pub fn update_settings( settings: HashMap, space_id: Option, diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index 9400d01..988fe5b 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -31,7 +31,11 @@ fn header_shim_path() -> Result { /// 路径里的 % 必须写成 %%,否则会和 %1 配对被当作变量展开吞掉。 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" }; + let codepage = if exe.is_ascii() { + "" + } else { + "chcp 65001 >nul\r\n" + }; format!("@echo off\r\n{codepage}\"{exe}\" --lianjing-mcp-header %1\r\nexit /b %ERRORLEVEL%\r\n") } @@ -214,10 +218,7 @@ fn find_claude_bin() -> Option { } // 最后尝试 where 命令(GUI 进程 PATH 可能不含用户 bin,but worth trying) - if let Ok(out) = crate::silent_cmd("where") - .arg("claude.exe") - .output() - { + 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() { @@ -300,10 +301,24 @@ fn try_register_globally(port: u16) -> bool { &claude_bin, &git_bash, None, - &["mcp", "add", "--transport", "sse", "-s", "user", MCP_KEY, &server_url], + &[ + "mcp", + "add", + "--transport", + "sse", + "-s", + "user", + MCP_KEY, + &server_url, + ], ) { - Ok(()) => { - db::log_event("mcp_inject", "info", "全局 MCP 注册成功", Some(&global_log_context())); + Ok(_) => { + db::log_event( + "mcp_inject", + "info", + "全局 MCP 注册成功", + Some(&global_log_context()), + ); true } // 同名已存在不是故障——CLI 链路健康,注册目标已达成 @@ -328,7 +343,11 @@ fn project_wants_global_scope(root: &std::path::Path) -> bool { "canonical 项目上下文", ) .ok() - .and_then(|ctx| ctx.get("mcp_scope").and_then(Value::as_str).map(|scope| scope == "global")) + .and_then(|ctx| { + ctx.get("mcp_scope") + .and_then(Value::as_str) + .map(|scope| scope == "global") + }) .unwrap_or(false) } @@ -383,7 +402,10 @@ fn managed_claude_entries(config: &Value, project_id: &str) -> Vec<(String, Valu .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(""); + 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)) @@ -399,16 +421,16 @@ fn claude_global_registered(config: &Value) -> bool { .is_some() } -/// claude CLI 单次调用上限:卡住的子进程会被终止,不让它拖住持锁的接入操作与界面。 +/// claude CLI 单次调用上限:卡住的子进程会被终止,不让它拖住持锁的接入操作。 const CLAUDE_CLI_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20); -/// 运行 claude CLI(不弹窗、stdin 置空、带超时)。成功返回 Ok,失败返回合并后的输出。 +/// 运行 claude CLI(不弹窗、stdin 置空、带超时)。成功返回输出,失败返回合并后的输出。 fn run_claude_cli( claude_bin: &str, git_bash: &str, cwd: Option<&std::path::Path>, args: &[&str], -) -> Result<(), String> { +) -> Result { use std::io::Read; let mut command = crate::silent_cmd(claude_bin); if let Some(dir) = cwd { @@ -448,96 +470,178 @@ fn run_claude_cli( let _ = stderr.read_to_string(&mut output); } if status.success() { - Ok(()) + 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(按「未知」处理,不写入)。 +fn parse_claude_mcp_get(output: &str) -> Option { + if output.contains("No MCP server found") { + return Some(ClaudeEffective::NotLocal); + } + let field = |name: &str| { + output.lines().find_map(|line| { + line.trim() + .strip_prefix(name) + .map(|value| value.trim().to_string()) + }) + }; + let scope = field("Scope:")?; + if scope.starts_with("Local config") { + Some(ClaudeEffective::Local(field("URL:")?)) + } else { + Some(ClaudeEffective::NotLocal) + } +} + /// 客户端侧同步(Claude local、DSH 托管块)的日志单独归类:不覆盖诊断里每个项目的「最后一次注入结果」。 const CLIENT_SYNC_LOG: &str = "mcp_client_sync"; -/// 让 Claude 的 local 条目与期望一致:desired=Some 时注册/刷新,None 时只删除炼境托管的条目。 +/// 让 Claude 的 local 条目与期望一致:desired=Some 时注册/刷新,None 时只删除本项目的炼境托管条目。 /// -/// 写入一律交给 claude CLI(cwd=项目目录):项目键推导、Claude 自己的文件锁、配置路径与读写细节都由 -/// Claude 负责,炼境不复刻其私有实现(2026-10-01 最终审查:直接编辑 ~/.claude.json 需要复刻 git 根 / -/// worktree / NFC 键推导、.claude.json.lock 锁协议、CLAUDE_CONFIG_DIR 等一整套内部机制,随 Claude 升级易失效)。 -/// 只读扫描判定现状:已一致则不起 CLI(稳态不阻塞);只替换/删除炼境托管条目,手写条目不碰。 -/// 项目目录已不存在时无法经 CLI 定位项目键,只记录残留、不写文件。 -/// 调用方须持有 mcp_op_lock,并在删除本地文件夹之前同步调用(避免与目录删除竞态)。 +/// 原则:写入一律交给 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)>) { let context = inject_log_context(project_id, "claude"); - let managed = read_claude_config() - .map(|config| managed_claude_entries(&config, project_id)) + let warn = |message: String| db::log_event(CLIENT_SYNC_LOG, "warn", &message, Some(&context)); + let config = read_claude_config(); + let root_text = root.display().to_string(); + let managed_all = config + .as_ref() + .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 { - None if managed.is_empty() => return, - Some(entry) if managed.iter().any(|(_, current)| current == entry) => return, + Some(entry) if managed_here.iter().any(|(_, current)| current == entry) => return, + None if config.is_some() && managed_all.is_empty() => return, _ => {} } if !root.is_dir() { - let keys: Vec<&str> = managed.iter().map(|(key, _)| key.as_str()).collect(); - db::log_event( - CLIENT_SYNC_LOG, - "warn", - &format!( - "项目目录已不存在,无法经 claude CLI 同步 Claude 项目级条目;残留托管条目所在项目键 {keys:?},可在对应目录执行 `claude mcp remove lian-jing -s local` 清理" - ), - Some(&context), - ); + let keys: Vec<&str> = managed_all.iter().map(|(key, _)| key.as_str()).collect(); + warn(format!( + "项目目录不存在,无法向 Claude 确认本目录的实际条目,未改动;本项目的托管条目位于项目键 {keys:?},可在对应目录执行 `claude mcp remove lian-jing -s local` 清理" + )); return; } let (Some(claude_bin), Some(git_bash)) = (find_claude_bin(), find_git_bash()) else { - db::log_event( - CLIENT_SYNC_LOG, - "warn", - "claude CLI 或 git-bash 未找到,跳过 Claude 项目级 MCP 同步", - Some(&context), - ); + warn("claude CLI 或 git-bash 未找到,跳过 Claude 项目级 MCP 同步".into()); return; }; - let remove_error = if managed.is_empty() { - None - } else { + let effective = + match run_claude_cli(&claude_bin, &git_bash, Some(root), &["mcp", "get", MCP_KEY]) { + Ok(output) | Err(output) => parse_claude_mcp_get(&output), + }; + let Some(effective) = effective else { + warn("无法确认 Claude 在本目录实际生效的 lian-jing 条目(claude mcp get 输出无法识别),未改动".into()); + return; + }; + let remove = || { run_claude_cli( &claude_bin, &git_bash, Some(root), &["mcp", "remove", MCP_KEY, "--scope", "local"], ) - .err() }; - let outcome: Result<&str, String> = match &desired_entry { - None => match remove_error { - None => Ok("Claude 项目级 MCP 条目已移除"), - Some(error) => Err(error), - }, - Some(entry) => { - let payload = entry.to_string(); - match run_claude_cli( - &claude_bin, - &git_bash, - Some(root), - &["mcp", "add-json", MCP_KEY, payload.as_str(), "--scope", "local"], - ) { - Ok(()) => Ok("Claude 项目级 MCP 条目已写入"), - Err(error) if error.contains("already exists") => match remove_error { - None => Ok("本项目已有手写的 lian-jing local 条目,炼境不覆盖"), - Some(remove_error) => Err(format!("旧条目移除失败: {remove_error}")), + let add = |entry: &Value| { + let payload = entry.to_string(); + run_claude_cli( + &claude_bin, + &git_bash, + Some(root), + &[ + "mcp", + "add-json", + MCP_KEY, + payload.as_str(), + "--scope", + "local", + ], + ) + }; + let outcome: Result = match (effective, &desired_entry) { + (ClaudeEffective::Local(url), desired) => match lianjing_project_of(&url) { + Some(owner) if owner == project_id => match remove() { + Err(error) => Err(format!("旧条目移除失败: {error}")), + Ok(_) => match desired { + None => Ok("Claude 项目级 MCP 条目已移除".into()), + Some(entry) => match add(entry) { + Ok(_) => Ok("Claude 项目级 MCP 条目已刷新".into()), + Err(error) => { + // 回滚:用读到的原条目写回,避免项目失去 local 条目 + let restored = managed_here + .first() + .map(|(_, previous)| add(previous).is_ok()) + .unwrap_or(false); + Err(format!( + "新条目写入失败: {error}({})", + if restored { "已恢复原条目" } else { "原条目未能恢复" } + )) + } + }, }, - Err(error) => Err(error), + }, + Some(owner) => Err(format!( + "本目录生效的 lian-jing local 条目属于项目 {owner}(共享项目键,如 worktree 或嵌套项目),炼境不改动" + )), + None => Ok("本目录已有手写的 lian-jing local 条目,炼境不覆盖".into()), + }, + (ClaudeEffective::NotLocal, Some(entry)) => add(entry) + .map(|_| "Claude 项目级 MCP 条目已写入".into()) + .map_err(|error| format!("写入失败: {error}")), + (ClaudeEffective::NotLocal, None) => { + if managed_all.is_empty() { + return; } + let keys: Vec<&str> = managed_all.iter().map(|(key, _)| key.as_str()).collect(); + Err(format!( + "本目录没有生效的 local 条目,但其他项目键 {keys:?} 下残留本项目的托管条目,可在对应目录执行 `claude mcp remove lian-jing -s local` 清理" + )) } }; match outcome { - Ok(message) => db::log_event(CLIENT_SYNC_LOG, "info", message, Some(&context)), - Err(error) => db::log_event( - CLIENT_SYNC_LOG, - "warn", - &format!("Claude 项目级 MCP 同步失败: {error}"), - Some(&context), - ), + Ok(message) => db::log_event(CLIENT_SYNC_LOG, "info", &message, Some(&context)), + Err(error) => warn(format!("Claude 项目级 MCP 同步: {error}")), } } @@ -546,7 +650,9 @@ fn sync_claude_local(project_id: &str, root: &std::path::Path, desired: Option<( 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()) + MCP_OP_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) } // ── DSH 机器级配置托管块 ────────────────────────────────────────────────────── @@ -598,9 +704,11 @@ fn dsh_project_roots( .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 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; } @@ -609,7 +717,9 @@ fn dsh_project_roots( 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)) + .and_then(|digest| { + crate::credential_store::read_mcp_auth_token_for_digest(store, &digest) + }) .is_ok(); has_token.then(|| (key, Some(id))) }) @@ -619,7 +729,10 @@ fn dsh_project_roots( 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() + lines + .iter() + .map(|line| format!("{}{line}", " ".repeat(spaces))) + .collect() } // 值为 null 的键是控制面项目的停止哨兵:命中即停,回退全局地址。 let roots_map: serde_json::Map = roots @@ -637,12 +750,10 @@ fn render_dsh_block(port: u16, roots: &[(String, Option)]) -> String { "}", ]; 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 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.LOCALAPPDATA, sys = process.env.SystemRoot;", @@ -724,7 +835,10 @@ fn has_manual_lian_jing(outside_block: &str) -> Result { /// 块外存在手写的 lian-jing 条目则拒绝覆盖(与 Codex 手工同名 server 的处理一致)。 fn upsert_dsh_block(current: &str, block: &str) -> Result { const MANUAL: &str = "DSH 补丁文件已手工定义 lian-jing,炼境不会覆盖;移除手写条目后重试"; - match (current.find(DSH_MANAGED_START), current.find(DSH_MANAGED_END)) { + 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))? { @@ -766,7 +880,11 @@ pub fn ensure_dsh_patch( }; let block = render_dsh_block( port, - &dsh_project_roots(conn, &crate::credential_store::WindowsCredentialStore, exclude), + &dsh_project_roots( + conn, + &crate::credential_store::WindowsCredentialStore, + exclude, + ), ); let next = upsert_dsh_block(¤t, &block)?; if next != current { @@ -843,7 +961,12 @@ pub fn cleanup_legacy_global_registrations() -> Vec { .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())); + db::log_event( + "mcp_inject", + "info", + "旧组级 MCP 注册已移除", + Some(&json!({ "client": "claude-global", "server": &name }).to_string()), + ); removed.push(name); } } @@ -1114,8 +1237,10 @@ pub fn migrate_to_single_server() { db::log_event("mcp_inject", level, &summary, None); // 全局注册失败说明 claude CLI 链路不通(CLI 缺失/git-bash 缺失/命令报错), - // 此时旧注册也清不掉——不落标记位,等下次启动重试(整个流程幂等) - if !register_ok { + // 此时旧注册也清不掉——不落标记位,等下次启动重试(整个流程幂等)。 + // 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() { @@ -1151,18 +1276,22 @@ pub fn reinject_all_mcp() -> Vec { // 快照取自锁外:锁内复查成员关系,已被移出组的项目不再重新注入(避免重建刚删除的 token) let still_member = db::pool() .get() - .ok() + .map_err(|error| error.to_string()) .and_then(|conn| { conn.query_row( "SELECT EXISTS(SELECT 1 FROM project_group_map WHERE project_id = ?1)", [&project_id], |row| row.get::<_, bool>(0), ) - .ok() - }) - .unwrap_or(false); - if !still_member { - continue; + .map_err(|error| error.to_string()) + }); + match still_member { + Ok(true) => {} + Ok(false) => continue, + Err(error) => { + errors.push(format!("项目 {project_id}: 复查组成员关系失败: {error}")); + continue; + } } if let Err(e) = inject_project_mcp_locked(&project_id, false) { errors.push(format!("项目 {}: {}", project_id, e)); @@ -1173,8 +1302,9 @@ pub fn reinject_all_mcp() -> Vec { errors } -/// 项目根目录或平台变更后调用:清理旧根下炼境托管的 Claude local 条目(直接编辑配置,旧目录不存在也能清理); -/// 已接入(有 token)的项目在新根重新注入(会刷新 DSH 白名单),未接入的只刷新白名单。 +/// 项目根目录或平台变更后调用:清理旧根下本项目的 Claude local 托管条目(旧目录已不存在时无法向 Claude +/// 确认实际条目,只记录残留与清理命令);已接入(有 token)的项目在新根重新注入(会刷新 DSH 白名单), +/// 未接入的只刷新白名单。新根的「已同步」判定只认新根或其祖先目录键下的条目,旧键下的残留不会挡住写入。 /// 重新注入会跑 claude CLI 做全局注册,调用方应放在后台线程。持锁执行:与并发的移除/删除串行, /// 移除先完成则 token 已不在,这里就不会再注入。 pub fn on_project_root_changed(project_id: &str, old_root: Option) { @@ -1336,9 +1466,15 @@ mod tests { "@echo off\r\n\"C:\\Apps\\Lian Jing\\app.exe\" --lianjing-mcp-header %1\r\nexit /b %ERRORLEVEL%\r\n" ); let unicode = render_header_shim(std::path::Path::new("C:\\炼境\\app.exe")); - assert!(unicode.contains("chcp 65001 >nul\r\n"), "非 ASCII 路径须切 UTF-8 代码页"); + 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"), "路径 % 须转义"); + assert!( + percent.contains("\"C:\\Users\\a%%b\\app.exe\" --lianjing-mcp-header %1"), + "路径 % 须转义" + ); } #[test] @@ -1351,7 +1487,13 @@ mod tests { 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)] { + 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 }); @@ -1384,9 +1526,17 @@ mod tests { #[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:/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 前缀"); + assert_eq!( + normalize_dsh_root("\\\\Server\\Share\\x"), + "\\\\server\\share\\x", + "保留 UNC 前缀" + ); } #[test] @@ -1397,7 +1547,10 @@ mod tests { ); 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("%LOCALAPPDATA%")); + assert!(entry["headersHelper"] + .as_str() + .unwrap() + .contains("%LOCALAPPDATA%")); } #[test] @@ -1435,14 +1588,95 @@ mod tests { 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(None, Some(&new)), + "旧根解析不出也要迁移" + ); assert!(!project_root_changed(Some(&old), Some(&old))); - assert!(!project_root_changed(Some(&old), None), "新根解析不出则不动"); + assert!( + !project_root_changed(Some(&old), None), + "新根解析不出则不动" + ); } #[test] @@ -1453,21 +1687,36 @@ mod tests { ]; let block = render_dsh_block(27190, &roots); assert!( - block.contains(r#"const roots = {"e:\\code-project":null,"e:\\code-project\\demo":"p1"};"#), + 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"); + 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("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(": 'http://127.0.0.1:27190/mcp';"), + "未命中回退全局地址" + ); assert!(block.contains("path.isAbsolute(base)") && block.contains("'System32', 'cmd.exe'")); - assert!(block.contains(MCP_HEADER_SHIM_NAME) && block.contains("windowsVerbatimArguments: true")); + assert!( + block.contains(MCP_HEADER_SHIM_NAME) + && block.contains("windowsVerbatimArguments: true") + ); assert!(!block.contains("Bearer "), "托管块不得含任何 token"); } @@ -1481,19 +1730,36 @@ mod tests { let moved = upsert_dsh_block(&first, &render_dsh_block(28000, &[])).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, &[])).unwrap(), moved, "幂等"); + assert_eq!( + moved.matches(DSH_MANAGED_START).count(), + 1, + "原位替换不重复追加" + ); + assert_eq!( + upsert_dsh_block(&moved, &render_dsh_block(28000, &[])).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", ] { - assert!(upsert_dsh_block(manual, &render_dsh_block(27190, &[])).is_err(), "{manual}"); + assert!( + upsert_dsh_block(manual, &render_dsh_block(27190, &[])).is_err(), + "{manual}" + ); let with_block = format!("{manual}{}", render_dsh_block(27190, &[])); - assert!(upsert_dsh_block(&with_block, &render_dsh_block(27190, &[])).is_err(), "块外手写也须拒绝"); + assert!( + upsert_dsh_block(&with_block, &render_dsh_block(27190, &[])).is_err(), + "块外手写也须拒绝" + ); } - assert!(upsert_dsh_block("- insert: [unclosed\n", "b").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"); } -- 2.45.2 From 1888f71626851ae48a18e56b8db3f22ce1039672 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Thu, 1 Oct 2026 16:57:26 +0900 Subject: [PATCH 12/15] =?UTF-8?q?docs(blueprint/agent-client-parity):=20P1?= =?UTF-8?q?=20=E6=94=B6=E5=B0=BE=E5=AE=A1=E6=9F=A5=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E4=B8=8E=E5=B7=B2=E7=9F=A5=E9=99=90=E5=88=B6=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E4=B8=BA=20claude=20mcp=20get=20=E4=B8=BA=E5=87=86=E7=9A=84?= =?UTF-8?q?=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .blueprint/manifest.yaml | 2 +- .blueprint/modules/agent-client-parity.md | 4 ++-- docs/ai-context/project-context.yaml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index 36c12fc..f6b4dd6 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -6,7 +6,7 @@ description: > 项目自身保留业务规则、测试、部署声明与交付决策。炼境本身亦被纳入其中, 形成可自我进化的开发智能闭环。 iteration: 1 -updated: 2026-09-30 +updated: 2026-10-01 agent_button_carrier: tauri diff --git a/.blueprint/modules/agent-client-parity.md b/.blueprint/modules/agent-client-parity.md index dbccc9d..68f73fe 100644 --- a/.blueprint/modules/agent-client-parity.md +++ b/.blueprint/modules/agent-client-parity.md @@ -88,8 +88,8 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。 - acceptance: "agent_verify: [三端无头探针] 在 enterprise 目录 Claude 与 DSH 的 C 项均看到开发变量工具(项目工具集),E 项成功;项目目录外的会话不获得他项目工具。" - 注:吸收原 project-dev-variables V17(Claude 以 local scope + headersHelper 取值)。 - verification: "2026-10-01 Enterprise 三端无头验收(零凭据:F 项以 read_project_test_account(name=LJ_PROBE_NONEXISTENT) 判定认证,返回「开发需求未登记」即认证通过):Codex 59s、Claude 40~56s、DSH 11~14s,三端 B/C/D/E/F 全部通过(基线时 Claude、DSH 的 C/E/F 失败、Claude 的 B 失败)。Claude 以 init 事件客观确认 lian-jing 32 个项目工具且含开发变量工具;claude mcp list 显示项目地址 ✔ Connected。DSH 托管块 url 表达式在项目根、子目录、带尾分隔符路径解析为项目地址,在上级/无关目录回退全局;控制面 null 哨兵命中即停。未知 project_id 返回 404。" -- review: "五轮对抗审查(首轮 9 项、第二轮 13 条、第三轮 10 条、最终 10 条,均已处理)。Claude 条目写入方式两度调整:CLI+后台队列(cwd 依赖与竞态)→ 直接编辑 ~/.claude.json(须复刻 Claude 的 git 根/worktree/NFC 键推导、.claude.json.lock、CLAUDE_CONFIG_DIR 等私有机制)→ 最终定为『只读扫描认领 + claude CLI 同步写入』:键、锁、路径交给 Claude 自身,炼境不复刻。实测稳态注入 0.04 秒(不起 CLI),缺失时 0.6 秒经 CLI 写回且 claude mcp list 显示 ✔ Connected。" -- known_limits: "DSH 桌面 GUI 模式的 cwd 不随工作区切换,项目路由目前只对在项目目录启动的 headless/CLI 生效,GUI 回退全局;claude mcp list 会提示 lian-jing 同时存在 user 与 local 两个作用域(user 级全局为有意保留的兜底);项目目录已被移动或删除后,Claude 中的残留托管条目无法经 CLI 定位,只记录 warn 与手工清理命令,不直接改写 ~/.claude.json。" +- review: "六轮对抗审查(首轮 9 项、第二轮 13 条、第三轮 10 条、最终 10 条、收尾 13 条,均已处理)。Claude 条目写入方式三度调整:CLI+后台队列(cwd 依赖与竞态)→ 直接编辑 ~/.claude.json(须复刻 Claude 的 git 根/worktree/NFC 键推导、.claude.json.lock、CLAUDE_CONFIG_DIR 等私有机制)→ 只读扫描认领 + CLI 写入(扫描结论不代表本目录实际生效条目,共享键下会误删他项目)→ 最终定为『Claude 自己的回答为准』:快路径只认项目根或其祖先键下的一致托管条目,其余在项目目录执行 claude mcp get 取实际生效条目,按归属(本项目托管/他项目托管/手写/无)决策,写入经 claude CLI、失败回滚;涉及 CLI 的 Tauri 命令改到线程池执行,删组/移出组先删成员关系再清配置。实测(enterprise):稳态注入 174ms 不起 CLI;缺失 → 写入、过期 helper → 刷新、手写条目 → 保留,claude mcp get 均为 Local 且 ✔ Connected;经 shim 零凭据探针认证通过。" +- known_limits: "DSH 桌面 GUI 模式的 cwd 不随工作区切换,项目路由目前只对在项目目录启动的 headless/CLI 生效,GUI 回退全局;claude mcp list 会提示 lian-jing 同时存在 user 与 local 两个作用域(user 级全局为有意保留的兜底);项目目录已被移动或删除后,Claude 中的残留托管条目无法经 CLI 定位,只记录 warn 与手工清理命令,不直接改写 ~/.claude.json;共享 Claude 项目键的嵌套项目/worktree 只有键所属项目的条目生效,他项目不改动、只记 warn;非稳态同步依赖 claude mcp get 的人类可读输出(格式无法识别时按未知处理、不写入),Claude 改输出格式会退化为只告警。" ### ✅ P2 — Claude skill 投影 - status: done diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index b754030..ac511c5 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -6,7 +6,7 @@ contracts: - label: API 接口(Axum) paths: - "src-tauri/src/mcp_server.rs" - doc: "MCP 仅监听 127.0.0.1;新项目使用 /mcp/projects/ scoped route,连接 scope 拒绝跨项目参数和全局管理写工具。scoped route 只服务已登记项目,未知 project_id 返回 404(数据库不可用同样拒绝)。各客户端经 %LOCALAPPDATA%/lian-jing/bin/lianjing-mcp-header.cmd 稳定入口取认证头(炼境每次启动指向当前 exe):Codex 写项目 .codex/config.toml,Claude 写 local 条目(优先于 user 级全局;一律经 claude CLI、cwd=项目目录写入,项目键推导、文件锁与配置路径交给 Claude 自身,不直接编辑 ~/.claude.json;先只读扫描、按 URL 中的项目路由认领炼境托管条目,已一致则不起 CLI;项目目录已不存在时只记录残留不写入),DSH 写 ~/.dsh/cordis.patch.yml 托管块(内嵌炼境登记的「项目根目录→project_id」白名单,!!js 从启动目录向上精确匹配根目录才选项目地址并取认证,任意目录里的 context.json 不作授权依据;未命中回退全局;项目增删与端口变更时重写)。Claude local 条目与 DSH 块只替换炼境托管的条目,手写同名配置不覆盖;移除项目时一并清理。旧 /mcp 只作兼容迁移。read_project_dev_variable 与 read_project_test_account 是两种有界开发值读取:仅 scoped 路由、匹配项目的本机 bearer 认证后单项读取;认证失败在 dispatch 前拒绝。路由变更需同步 Agent 按钮体系文档。" + doc: "MCP 仅监听 127.0.0.1;新项目使用 /mcp/projects/ scoped route,连接 scope 拒绝跨项目参数和全局管理写工具。scoped route 只服务已登记项目,未知 project_id 返回 404(数据库不可用同样拒绝)。各客户端经 %LOCALAPPDATA%/lian-jing/bin/lianjing-mcp-header.cmd 稳定入口取认证头(炼境每次启动指向当前 exe):Codex 写项目 .codex/config.toml,Claude 写 local 条目(优先于 user 级全局;一律经 claude CLI、cwd=项目目录写入,项目键推导、文件锁与配置路径交给 Claude 自身,不直接编辑 ~/.claude.json;项目根或其祖先键下已有一致的托管条目则不起 CLI,否则以项目目录下 claude mcp get 的实际生效条目为准:本项目托管条目刷新/移除,他项目托管条目与手写条目不碰,无法确认现状一律不写;项目目录已不存在时只记录残留不写入),DSH 写 ~/.dsh/cordis.patch.yml 托管块(内嵌炼境登记的「项目根目录→project_id」白名单,!!js 从启动目录向上精确匹配根目录才选项目地址并取认证,任意目录里的 context.json 不作授权依据;未命中回退全局;项目增删与端口变更时重写)。Claude local 条目与 DSH 块只替换炼境托管的条目,手写同名配置不覆盖;移除项目时一并清理。旧 /mcp 只作兼容迁移。read_project_dev_variable 与 read_project_test_account 是两种有界开发值读取:仅 scoped 路由、匹配项目的本机 bearer 认证后单项读取;认证失败在 dispatch 前拒绝。路由变更需同步 Agent 按钮体系文档。" - label: MCP 工具接口 paths: -- 2.45.2 From 2b0a55adcdff0d9069546261353f6d201de9ae28 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Thu, 1 Oct 2026 18:58:50 +0900 Subject: [PATCH 13/15] =?UTF-8?q?fix(mcp/parity):=20helper=20=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=E8=BF=81=E8=87=B3=20%USERPROFILE%=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20Codex=20Desktop=EF=BC=88MSIX=EF=BC=89=E5=8F=96?= =?UTF-8?q?=E8=AE=A4=E8=AF=81=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 人终验发现 Codex Desktop 会话里 lian-jing 完全未加载,Codex 日志为 headers helper exit 1。 根因:Codex Desktop 是 MSIX 打包应用,看不见它安装之后在 %LOCALAPPDATA% / %APPDATA% 下新建的 顶层目录(Invoke-CommandInDesktopPackage 在包上下文对照实测:lian-jing 与新建对照目录均不可见, 与 ACL 无关;已有目录内的新文件、%USERPROFILE% 下的新目录可见)。原验收用包外的 codex exec,未覆盖。 - shim 迁至 %USERPROFILE%\.lian-jing\bin\;Codex 配置、Claude local 条目、DSH 托管块统一引用新入口 - 炼境 exe 位于 AppData 下(安装版默认位置)时,按大小+修改时间指纹复制到入口目录,清理旧副本; 复制失败退回直接调用当前 exe 并记 warn - 旧 %LOCALAPPDATA% 入口迁移期同步写入;verify_onboarding 把旧写法报 Legacy 引导 inject_mcp 升级 (测试断言随之由 Ready 改为 Legacy,理由同上) - 新增 helper 副本与 AppData 判定测试 复验:Codex 包内(等价 Desktop)、Codex 包外、Claude、DSH 无头四路零凭据探针均返回「开发需求未登记」 (修复前 Codex 包内为「工具不可用」);cargo test 245 passed / 5 ignored。 Co-Authored-By: Claude Opus 5.5 --- .blueprint/modules/agent-client-parity.md | 8 +- .blueprint/modules/project-dev-variables.md | 2 +- AGENTS.md | 2 +- docs/ai-context/project-context.yaml | 2 +- src-tauri/src/commands/onboarding.rs | 40 ++-- src-tauri/src/mcp_inject.rs | 229 ++++++++++++++++++-- 6 files changed, 239 insertions(+), 44 deletions(-) diff --git a/.blueprint/modules/agent-client-parity.md b/.blueprint/modules/agent-client-parity.md index 68f73fe..ccb28ff 100644 --- a/.blueprint/modules/agent-client-parity.md +++ b/.blueprint/modules/agent-client-parity.md @@ -68,7 +68,7 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。 - **H4 ✅ 成立(第二轮零凭据实测)**:首轮仅 cmd 层模拟、被复核判证据不足;第二轮用哑 helper + 本地请求记录器直接观测 Codex 0.155 自身行为:`"%LOCALAPPDATA%\...\hdr.cmd"` 被执行且输出成为请求头(记录器收到 `codex-mcp-client` 携带的标记头);`%NOT_A_REAL_VAR%` 不执行;父进程导出的自定义变量 `%LJPROBE_BASE%` 不执行(证实 env_clear 白名单);把 `LOCALAPPDATA` 指向替身目录后执行的是替身目录里的脚本(因果对照);cmd 独有的 `%LOCALAPPDATA:~0%` 子串语法也生效(展开由 cmd.exe 完成);helper 非零退出时连接不建立。 - **H2 ✅ 成立(第二轮零凭据实测)**:Claude Code 2.1.173 在 Windows 经 **cmd.exe** 执行 headersHelper——带引号反斜杠路径、正斜杠路径、`%TEMP%`、显式 `bash x.sh` 均执行且输出成为请求头;`$TEMP/...` 不执行,`%USERNAME%` 展开而 `$USERNAME` 保持字面。**local scope 覆盖 user scope**:init 事件中 `lian-jing` 工具数 35(全局)→ 加 local 条目后 32(项目路由)→ 删除后回到 35。经 `--mcp-config` 传入的 helper 在未信任目录也执行(文档所述 trust 限制针对项目 `.mcp.json` 来源)。`-p` 需设 `MCP_CONNECTION_NONBLOCKING=false` 才会等 MCP 连上再发 init。 - 第二轮两项的对抗复核 agent 未能运行(见下条),结论由主会话直接核对原始日志(记录器日志由独立进程写入)得出,未经第二个 agent 复现。 -- **结论:Codex 与 Claude 都经 cmd.exe 执行 helper,可共用同一个 `%LOCALAPPDATA%\lian-jing\bin\` shim 写法**;DSH 经 `!!js` + `execFileSync` 直接调用同一 shim(在 JS 里用 `process.env.LOCALAPPDATA` 拼路径)。 +- **结论:Codex 与 Claude 都经 cmd.exe 执行 helper,可共用同一个 shim 写法**;DSH 经 `!!js` + `execFileSync` 直接调用同一 shim。(入口位置 2026-10-01 由 `%LOCALAPPDATA%\lian-jing\bin\` 迁至 `%USERPROFILE%\.lian-jing\bin\`,原因见 P3 返工记录。) ### 实验附带发现 @@ -104,9 +104,11 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。 - complexity: M - files: src-tauri/src/mcp_inject.rs, src-tauri/src/lib.rs, src-tauri/src/commands/onboarding.rs - depends: H4 -- acceptance: "agent_verify: [cargo test] 炼境启动写/更新 %LOCALAPPDATA% 下 shim 指向当前 exe;inject 后受跟踪文件不含本机绝对路径或用户名;[Codex 无头探针] F 项经 shim 成功;换构建目录启动后无需重注入仍成功。" +- acceptance: "agent_verify: [cargo test] 炼境启动写/更新 %USERPROFILE%\.lian-jing\bin 下 shim 指向当前 exe(exe 在 AppData 下时指向入口目录里的副本);inject 后受跟踪文件不含本机绝对路径或用户名;[Codex 无头探针] F 项经 shim 成功,且在 Codex 包上下文(Invoke-CommandInDesktopPackage 运行 codex exec,等价 Codex Desktop)同样成功;换构建目录启动后无需重注入仍成功。" - 注:即原 project-dev-variables V18;Claude headersHelper 与 DSH 共用同一 shim。 -- verification: "2026-10-01:shim 写入 %LOCALAPPDATA%\lian-jing\bin\lianjing-mcp-header.cmd(CRLF,exe 路径 % 转义),Enterprise 的 .codex/config.toml 托管块 helper 为 %LOCALAPPDATA% 形式、不含本机路径与用户名;Codex 无头 F 项经 shim 认证通过。H4 实测证实 Codex 经 cmd 展开 %LOCALAPPDATA%(替身目录因果对照)。换构建目录启动后免重注入:shim 每次启动重写指向当前 exe(实测在 worktree 构建启动时自动改指向)。" +- verification: "2026-10-01:shim 写入 %LOCALAPPDATA%\lian-jing\bin\lianjing-mcp-header.cmd(CRLF,exe 路径 % 转义),Enterprise 的 .codex/config.toml 托管块 helper 为 %LOCALAPPDATA% 形式、不含本机路径与用户名;Codex 无头 F 项经 shim 认证通过。H4 实测证实 Codex 经 cmd 展开 %LOCALAPPDATA%(替身目录因果对照)。换构建目录启动后免重注入:shim 每次启动重写指向当前 exe(实测在 worktree 构建启动时自动改指向)。 + 返工(2026-10-01 人终验发现):Codex Desktop 会话里 lian-jing 完全未加载,日志为 headers helper exit 1。根因:Codex Desktop 是 MSIX 打包应用,看不见其安装后在 %LOCALAPPDATA% / %APPDATA% 下新建的顶层目录(包上下文对照实测:lian-jing 与两个新建对照目录均不可见,与 ACL 无关;已有目录内新建文件、%USERPROFILE% 下新目录均可见;Codex 启动晚于目录创建,排除重启因素)。原无头验收用包外的 codex exec,未覆盖该条件。修复:入口迁至 %USERPROFILE%\.lian-jing\bin;炼境 exe 位于 AppData 下(安装版默认)时按大小+修改时间指纹复制到入口目录并清理旧副本;旧入口迁移期同步保留,verify_onboarding 把 %LOCALAPPDATA% 写法报 Legacy。复验:四路零凭据探针(Codex 包内、Codex 包外、Claude、DSH 无头)均返回「开发需求未登记」,证实 USERPROFILE 在 Codex helper 环境白名单内;包内运行用户目录下的 exe 副本 exit 0。" +- known_limits: "旧 %LOCALAPPDATA% 入口仅为迁移期兼容,各子项目需重注入(会改动其受跟踪的 .codex/config.toml,需在子项目提交);Microsoft Store 版等其他 MSIX 打包客户端未逐一实测,规律同 Codex Desktop。" ### 📋 P4 — 三端对等探针固化为炼境能力 - status: todo diff --git a/.blueprint/modules/project-dev-variables.md b/.blueprint/modules/project-dev-variables.md index f0c7904..c200456 100644 --- a/.blueprint/modules/project-dev-variables.md +++ b/.blueprint/modules/project-dev-variables.md @@ -231,7 +231,7 @@ progress: 91 ### 方案 -1. **稳定 helper 入口(解 V18 根因)**:炼境每次启动(debug 或安装版)重写 `%LOCALAPPDATA%\lian-jing\bin\lianjing-mcp-header.cmd` 指向当前 exe。Codex 项目配置改为 `http_headers_helper = '"%LOCALAPPDATA%\lian-jing\bin\lianjing-mcp-header.cmd" '`——digest = sha256(project_id) 非秘密,整行在任何机器、任何构建下相同,可安全提交;换构建无需逐项目重注入。 +1. **稳定 helper 入口(解 V18 根因)**(2026-10-01 起入口位置改为 `%USERPROFILE%\.lian-jing\bin\`,以 agent-client-parity P3 为准):炼境每次启动(debug 或安装版)重写 `%LOCALAPPDATA%\lian-jing\bin\lianjing-mcp-header.cmd` 指向当前 exe。Codex 项目配置改为 `http_headers_helper = '"%LOCALAPPDATA%\lian-jing\bin\lianjing-mcp-header.cmd" '`——digest = sha256(project_id) 非秘密,整行在任何机器、任何构建下相同,可安全提交;换构建无需逐项目重注入。 2. **Claude(V17 主体)**:inject_mcp 在项目目录以 local scope 写入 `lian-jing`(scoped url + headersHelper 指向同一 shim;local 条目不进仓库,可用绝对路径);保留 user scope 全局注册作兜底。 3. **DSH:不写静态 token**。静态 header 意味着 bearer 明文落在用户目录配置文件,且 DSH 配置无法按项目拆分;DSH 取值改走 V14–V16 落地路径(写入被忽略文件、不返回值、无需认证),待 dsh-mcp-client 支持动态 header 再评估。 4. **推论:V14–V16 是唯一全客户端通用的路径**(落地不返回值,无需认证);读取明文的有界例外只服务支持动态 header 的客户端。 diff --git a/AGENTS.md b/AGENTS.md index 60e869a..aa3c38c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,7 @@ src-tauri/src/credential_store.rs Windows 本机受保护凭证存储与旧值 src-tauri/src/mcp_server.rs MCP Server 路由层 + SSE 协议(agent 调用炼境的入口) src-tauri/src/mcp_tools.rs MCP 工具 schema + dispatch + 实现(工具清单以 tools_list_result 为准) src-tauri/src/mcp_webhook.rs Gitea webhook 处理(push/PR 事件写入飞轮) -src-tauri/src/mcp_inject.rs 接入注入与三端适配(canonical context、Codex 项目配置、Claude local 条目、DSH 托管块、%LOCALAPPDATA% 稳定 helper 入口) +src-tauri/src/mcp_inject.rs 接入注入与三端适配(canonical context、Codex 项目配置、Claude local 条目、DSH 托管块、%USERPROFILE%.lian-jing 稳定 helper 入口) src-tauri/resources/onboarding/ 接入包分发模板(AGENTS.md/lefthook/git-workflow) .blueprint/ 项目蓝图(manifest + 模块详情 + 任务卡) docs/ai-context/ AI 上下文文档(速查卡、契约映射) diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index ac511c5..21b3f08 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -6,7 +6,7 @@ contracts: - label: API 接口(Axum) paths: - "src-tauri/src/mcp_server.rs" - doc: "MCP 仅监听 127.0.0.1;新项目使用 /mcp/projects/ scoped route,连接 scope 拒绝跨项目参数和全局管理写工具。scoped route 只服务已登记项目,未知 project_id 返回 404(数据库不可用同样拒绝)。各客户端经 %LOCALAPPDATA%/lian-jing/bin/lianjing-mcp-header.cmd 稳定入口取认证头(炼境每次启动指向当前 exe):Codex 写项目 .codex/config.toml,Claude 写 local 条目(优先于 user 级全局;一律经 claude CLI、cwd=项目目录写入,项目键推导、文件锁与配置路径交给 Claude 自身,不直接编辑 ~/.claude.json;项目根或其祖先键下已有一致的托管条目则不起 CLI,否则以项目目录下 claude mcp get 的实际生效条目为准:本项目托管条目刷新/移除,他项目托管条目与手写条目不碰,无法确认现状一律不写;项目目录已不存在时只记录残留不写入),DSH 写 ~/.dsh/cordis.patch.yml 托管块(内嵌炼境登记的「项目根目录→project_id」白名单,!!js 从启动目录向上精确匹配根目录才选项目地址并取认证,任意目录里的 context.json 不作授权依据;未命中回退全局;项目增删与端口变更时重写)。Claude local 条目与 DSH 块只替换炼境托管的条目,手写同名配置不覆盖;移除项目时一并清理。旧 /mcp 只作兼容迁移。read_project_dev_variable 与 read_project_test_account 是两种有界开发值读取:仅 scoped 路由、匹配项目的本机 bearer 认证后单项读取;认证失败在 dispatch 前拒绝。路由变更需同步 Agent 按钮体系文档。" + doc: "MCP 仅监听 127.0.0.1;新项目使用 /mcp/projects/ scoped route,连接 scope 拒绝跨项目参数和全局管理写工具。scoped route 只服务已登记项目,未知 project_id 返回 404(数据库不可用同样拒绝)。各客户端经 %USERPROFILE%/.lian-jing/bin/lianjing-mcp-header.cmd 稳定入口取认证头(炼境每次启动指向当前 exe;入口不放 AppData,因 MSIX 打包的 Codex Desktop 看不见其安装后在 AppData 下新建的顶层目录;炼境 exe 位于 AppData 下时复制一份到入口目录;旧 %LOCALAPPDATA% 入口在迁移期同步保留,verify_onboarding 报 Legacy 引导重注入):Codex 写项目 .codex/config.toml,Claude 写 local 条目(优先于 user 级全局;一律经 claude CLI、cwd=项目目录写入,项目键推导、文件锁与配置路径交给 Claude 自身,不直接编辑 ~/.claude.json;项目根或其祖先键下已有一致的托管条目则不起 CLI,否则以项目目录下 claude mcp get 的实际生效条目为准:本项目托管条目刷新/移除,他项目托管条目与手写条目不碰,无法确认现状一律不写;项目目录已不存在时只记录残留不写入),DSH 写 ~/.dsh/cordis.patch.yml 托管块(内嵌炼境登记的「项目根目录→project_id」白名单,!!js 从启动目录向上精确匹配根目录才选项目地址并取认证,任意目录里的 context.json 不作授权依据;未命中回退全局;项目增删与端口变更时重写)。Claude local 条目与 DSH 块只替换炼境托管的条目,手写同名配置不覆盖;移除项目时一并清理。旧 /mcp 只作兼容迁移。read_project_dev_variable 与 read_project_test_account 是两种有界开发值读取:仅 scoped 路由、匹配项目的本机 bearer 认证后单项读取;认证失败在 dispatch 前拒绝。路由变更需同步 Agent 按钮体系文档。" - label: MCP 工具接口 paths: diff --git a/src-tauri/src/commands/onboarding.rs b/src-tauri/src/commands/onboarding.rs index f230bc0..794d7dc 100644 --- a/src-tauri/src/commands/onboarding.rs +++ b/src-tauri/src/commands/onboarding.rs @@ -670,9 +670,10 @@ fn codex_mcp_server_url(content: &str) -> Option<&str> { None } -/// 旧注入把本机 exe 绝对路径写进 helper(换构建即失效、换机器即错);新注入只引用 -/// %LOCALAPPDATA% 下的稳定入口。只判定显式的旧写法,未配置 helper 不在此处判定。 -fn codex_helper_uses_machine_path(content: &str) -> bool { +/// helper 的旧写法需要重注入升级:最早把本机 exe 绝对路径写进 helper(换构建即失效、换机器即错); +/// 之后改为 %LOCALAPPDATA% 下的入口,但 Codex Desktop(MSIX)看不见该目录。新注入只引用 +/// %USERPROFILE%\.lian-jing 下的稳定入口。只判定显式的旧写法,未配置 helper 不在此处判定。 +fn codex_helper_upgrade_hint(content: &str) -> Option<&'static str> { let mut in_lian_jing = false; for line in content.lines() { let trimmed = line.trim(); @@ -681,10 +682,16 @@ fn codex_helper_uses_machine_path(content: &str) -> bool { continue; } if in_lian_jing && trimmed.starts_with("http_headers_helper") { - return trimmed.contains("--lianjing-mcp-header") && !trimmed.contains("%LOCALAPPDATA%"); + if trimmed.contains("%LOCALAPPDATA%") { + return Some("helper 仍引用 %LOCALAPPDATA% 下的旧入口(Codex Desktop 为 MSIX 应用,看不见该目录,取认证失败)——调用 inject_mcp 升级为 %USERPROFILE%\\.lian-jing 稳定入口"); + } + if trimmed.contains("--lianjing-mcp-header") { + return Some("helper 仍写着本机 exe 绝对路径(换构建即失效,且会随仓库提交)——调用 inject_mcp 升级为 %USERPROFILE%\\.lian-jing 稳定入口"); + } + return None; } } - false + None } pub(crate) fn check_codex_mcp( @@ -701,13 +708,10 @@ pub(crate) fn check_codex_mcp( }; let expected_url = crate::mcp_server::scoped_mcp_url(expected_port, expected_project_id); let configured_url = codex_mcp_server_url(content); - if configured_url == Some(expected_url.as_str()) && codex_helper_uses_machine_path(content) { - VerifyCheck::with_state( - "Codex MCP 已配置", - VerifyState::Legacy, - "helper 仍写着本机 exe 绝对路径(换构建即失效,且会随仓库提交)——调用 inject_mcp 升级为 %LOCALAPPDATA% 稳定入口", - ) - } else if configured_url == Some(expected_url.as_str()) { + let url_matches = configured_url == Some(expected_url.as_str()); + if let Some(hint) = codex_helper_upgrade_hint(content).filter(|_| url_matches) { + VerifyCheck::with_state("Codex MCP 已配置", VerifyState::Legacy, hint) + } else if url_matches { VerifyCheck::with_state( "Codex MCP 已配置", VerifyState::Ready, @@ -1532,10 +1536,18 @@ mod tests { ); let legacy = check_codex_mcp(Some(&machine), "p1", 27190); assert_eq!(legacy.state, VerifyState::Legacy); - assert!(legacy.detail.contains("%LOCALAPPDATA%")); + assert!(legacy.detail.contains("本机 exe 绝对路径")); + + // 2026-10-01:%LOCALAPPDATA% 入口在 Codex Desktop(MSIX)里不可见,由 Ready 改判 Legacy + let app_data = format!( + "[mcp_servers.lian-jing]\n{url}http_headers_helper = \"\\\"%LOCALAPPDATA%\\\\lian-jing\\\\bin\\\\lianjing-mcp-header.cmd\\\" d1\"\n" + ); + let app_data_check = check_codex_mcp(Some(&app_data), "p1", 27190); + assert_eq!(app_data_check.state, VerifyState::Legacy); + assert!(app_data_check.detail.contains("MSIX")); let stable = format!( - "[mcp_servers.lian-jing]\n{url}http_headers_helper = \"\\\"%LOCALAPPDATA%\\\\lian-jing\\\\bin\\\\lianjing-mcp-header.cmd\\\" d1\"\n" + "[mcp_servers.lian-jing]\n{url}http_headers_helper = \"\\\"%USERPROFILE%\\\\.lian-jing\\\\bin\\\\lianjing-mcp-header.cmd\\\" d1\"\n" ); assert_eq!(check_codex_mcp(Some(&stable), "p1", 27190).state, VerifyState::Ready); } diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index 988fe5b..182cba4 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -9,22 +9,97 @@ 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 入口文件名:位于 %LOCALAPPDATA%\lian-jing\bin\,每次启动指向当前 exe。 +/// 稳定 helper 入口文件名:位于 %USERPROFILE%\.lian-jing\bin\,每次启动指向当前 exe。 const MCP_HEADER_SHIM_NAME: &str = "lianjing-mcp-header.cmd"; +/// 炼境装在 AppData 下时,复制到入口目录的 helper exe 副本的文件名前缀。 +const HELPER_EXE_PREFIX: &str = "lianjing-helper-"; 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 并展开 %LOCALAPPDATA%;DSH 由 !!js 经 cmd 调用。 -// 项目配置只引用 %LOCALAPPDATA% 下的固定入口,不含本机 exe 路径与用户名,换构建无需逐项目重注入。 +// Codex 与 Claude 在 Windows 都经 cmd.exe 执行 helper 并展开环境变量;DSH 由 !!js 经 cmd 调用。 +// 项目配置只引用用户目录下的固定入口,不含本机 exe 路径与用户名,换构建无需逐项目重注入。 +// 入口放在 %USERPROFILE% 而非 AppData:Codex Desktop 是 MSIX 打包应用,看不见它安装之后才在 +// %LOCALAPPDATA% / %APPDATA% 下新建的顶层目录,用户目录根不受此限(2026-10-01 在 Codex 包上下文实测)。 -fn header_shim_path() -> Result { - let base = std::env::var("LOCALAPPDATA") - .map_err(|_| "LOCALAPPDATA 未设置,无法写入 MCP helper 入口".to_string())?; - Ok(PathBuf::from(base) - .join("lian-jing") - .join("bin") - .join(MCP_HEADER_SHIM_NAME)) +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\(非 MSIX 客户端可用), +/// 在项目重注入前继续同步写一份;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) + }) +} + +/// 纯函数:exe 是否位于 MSIX 客户端可能看不见的 AppData 目录下。 +fn under_app_data(executable: &std::path::Path, app_data_roots: &[PathBuf]) -> bool { + let exe = executable.display().to_string(); + app_data_roots + .iter() + .any(|root| key_covers_root(&root.display().to_string(), &exe)) +} + +/// 纯函数:helper exe 副本名,以源文件大小与修改时间作指纹。换构建即换名,不覆盖正被客户端执行的旧副本。 +fn helper_copy_name(len: u64, modified_nanos: u128) -> String { + format!("{HELPER_EXE_PREFIX}{len:x}-{modified_nanos:x}.exe") +} + +fn app_data_roots() -> Vec { + ["LOCALAPPDATA", "APPDATA"] + .iter() + .filter_map(|name| std::env::var_os(name)) + .map(PathBuf::from) + .filter(|root| root.is_absolute()) + .collect() +} + +/// shim 要调用的 exe:炼境装在 AppData 下(安装版默认位置)时复制一份到入口目录,否则直接用当前 exe。 +/// 副本只在缺失时复制,并尽力清理旧副本(被占用的留待下次启动)。 +fn helper_executable( + bin_dir: &std::path::Path, + executable: &std::path::Path, + app_data: &[PathBuf], +) -> Result { + if !under_app_data(executable, app_data) { + return Ok(executable.to_path_buf()); + } + let metadata = std::fs::metadata(executable) + .map_err(|error| format!("读取炼境可执行文件信息失败: {error}"))?; + let modified_nanos = metadata + .modified() + .ok() + .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|elapsed| elapsed.as_nanos()) + .unwrap_or(0); + let name = helper_copy_name(metadata.len(), modified_nanos); + let target = bin_dir.join(&name); + if !target.is_file() { + let partial = bin_dir.join(format!("{name}.partial")); + std::fs::copy(executable, &partial) + .and_then(|_| std::fs::rename(&partial, &target)) + .map_err(|error| format!("复制 helper exe 失败: {error}"))?; + } + if let Ok(entries) = std::fs::read_dir(bin_dir) { + for entry in entries.flatten() { + let file_name = entry.file_name().to_string_lossy().into_owned(); + if file_name.starts_with(HELPER_EXE_PREFIX) && file_name != name { + let _ = std::fs::remove_file(entry.path()); + } + } + } + Ok(target) } /// cmd 按 OEM 代码页读批处理;exe 路径含非 ASCII 字符时先切到 UTF-8。 @@ -39,26 +114,56 @@ fn render_header_shim(executable: &std::path::Path) -> String { format!("@echo off\r\n{codepage}\"{exe}\" --lianjing-mcp-header %1\r\nexit /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 path = header_shim_path()?; + let bin_dir = helper_bin_dir()?; + std::fs::create_dir_all(&bin_dir) + .map_err(|error| format!("创建 MCP helper 入口目录失败: {error}"))?; let executable = std::env::current_exe() .map_err(|_| "无法定位炼境可执行文件,拒绝写入 MCP helper 入口".to_string())?; - let content = render_header_shim(&executable); - if std::fs::read_to_string(&path).ok().as_deref() != Some(content.as_str()) { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .map_err(|error| format!("创建 MCP helper 入口目录失败: {error}"))?; + // 复制失败时退回直接调用当前 exe:非 MSIX 客户端仍可用,只记录告警 + let target = + helper_executable(&bin_dir, &executable, &app_data_roots()).unwrap_or_else(|error| { + log_event( + "mcp_inject", + "warn", + &format!( + "{error};helper 入口改为直接调用当前 exe(Codex Desktop 可能无法取认证)" + ), + Some(r#"{"client":"shim"}"#), + ); + executable.clone() + }); + let content = render_header_shim(&target); + 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"}"#), + ); } - std::fs::write(&path, content) - .map_err(|error| format!("写入 MCP helper 入口失败: {error}"))?; } Ok(path) } -/// 客户端配置中的 helper 命令:只含 %LOCALAPPDATA% 与非秘密 digest,可安全提交进项目仓库。 +/// 客户端配置中的 helper 命令:只含 %USERPROFILE% 与非秘密 digest,可安全提交进项目仓库。 fn shim_helper_command(digest: &str) -> String { - format!("\"%LOCALAPPDATA%\\lian-jing\\bin\\{MCP_HEADER_SHIM_NAME}\" {digest}") + format!("\"%USERPROFILE%\\.lian-jing\\bin\\{MCP_HEADER_SHIM_NAME}\" {digest}") } // ── 路径解析 ────────────────────────────────────────────────────────────────── @@ -753,10 +858,10 @@ fn render_dsh_block(port: u16, roots: &[(String, Option)]) -> String { 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}');"); + format!("const shim = path.join(base, '.lian-jing', 'bin', '{MCP_HEADER_SHIM_NAME}');"); let header_body = [ "if (!id) return {};", - "const base = process.env.LOCALAPPDATA, sys = process.env.SystemRoot;", + "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');", @@ -1452,10 +1557,82 @@ mod tests { let helper = shim_helper_command("qa-digest"); assert_eq!( helper, - "\"%LOCALAPPDATA%\\lian-jing\\bin\\lianjing-mcp-header.cmd\" qa-digest" + "\"%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 helper_exe_is_copied_out_of_app_data_once_per_build() { + let base = + std::env::temp_dir().join(format!("lianjing_helper_copy_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let app_data = base.join("AppData").join("Local"); + let install = app_data.join("dev-manager-tauri"); + let bin_dir = base.join(".lian-jing").join("bin"); + std::fs::create_dir_all(&install).unwrap(); + std::fs::create_dir_all(&bin_dir).unwrap(); + let exe = install.join("app.exe"); + std::fs::write(&exe, b"build-1").unwrap(); + std::fs::write( + bin_dir.join(format!("{HELPER_EXE_PREFIX}stale.exe")), + b"old", + ) + .unwrap(); + std::fs::write(bin_dir.join(MCP_HEADER_SHIM_NAME), b"keep").unwrap(); + + let outside = base.join("E-drive").join("app.exe"); + assert_eq!( + helper_executable(&bin_dir, &outside, &[app_data.clone()]).unwrap(), + outside, + "不在 AppData 下的 exe 直接使用" + ); + let copied = helper_executable(&bin_dir, &exe, &[app_data.clone()]).unwrap(); + assert!(copied.starts_with(&bin_dir) && copied.is_file()); + assert_eq!(std::fs::read(&copied).unwrap(), b"build-1"); + assert!( + !bin_dir + .join(format!("{HELPER_EXE_PREFIX}stale.exe")) + .exists(), + "旧副本被清理" + ); + assert!( + bin_dir.join(MCP_HEADER_SHIM_NAME).exists(), + "入口脚本不受清理影响" + ); + assert_eq!( + helper_executable(&bin_dir, &exe, &[app_data.clone()]).unwrap(), + copied, + "同一构建复用已有副本" + ); + let _ = std::fs::remove_dir_all(&base); + } + + #[test] + fn app_data_detection_is_case_insensitive_and_boundary_safe() { + let roots = [PathBuf::from("C:\\Users\\u\\AppData\\Local")]; + assert!(under_app_data( + std::path::Path::new("c:\\users\\U\\appdata\\local\\dev-manager-tauri\\a.exe"), + &roots + )); + assert!(!under_app_data( + std::path::Path::new("C:\\Users\\u\\AppData\\LocalLow\\a.exe"), + &roots + )); + assert!(!under_app_data( + std::path::Path::new("E:\\code\\a.exe"), + &roots + )); + assert_ne!( + helper_copy_name(1, 2), + helper_copy_name(1, 3), + "换构建即换名" + ); } #[test] @@ -1550,7 +1727,7 @@ mod tests { assert!(entry["headersHelper"] .as_str() .unwrap() - .contains("%LOCALAPPDATA%")); + .contains("%USERPROFILE%\\.lian-jing\\bin\\")); } #[test] @@ -1713,6 +1890,10 @@ mod tests { "未命中回退全局地址" ); assert!(block.contains("path.isAbsolute(base)") && block.contains("'System32', 'cmd.exe'")); + assert!( + block.contains("process.env.USERPROFILE") && block.contains("'.lian-jing', 'bin'"), + "与 Codex/Claude 共用用户目录下的入口" + ); assert!( block.contains(MCP_HEADER_SHIM_NAME) && block.contains("windowsVerbatimArguments: true") -- 2.45.2 From cca3bf9db1c8cb609d783b879b6f606aa7d8735d Mon Sep 17 00:00:00 2001 From: lanrtop Date: Thu, 1 Oct 2026 20:06:14 +0900 Subject: [PATCH 14/15] =?UTF-8?q?fix(mcp/parity):=20=E5=AE=A1=E6=9F=A5?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E2=80=94=E2=80=94shim=20=E5=8D=95=E8=A1=8C?= =?UTF-8?q?=E9=80=80=E5=87=BA=E3=80=81=E5=89=AF=E6=9C=AC=E5=88=B7=E7=9B=98?= =?UTF-8?q?=E5=90=8E=E6=94=B9=E5=90=8D=E3=80=81DSH=20=E4=BB=85=20headless?= =?UTF-8?q?=20=E6=8C=89=E7=9B=AE=E5=BD=95=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对抗审查(14 个 agent,3 条核实成立)与 DSH 桌面版只读调研的修复: - shim 改为调用与退出同一行(`& call exit /b %%ERRORLEVEL%%`):cmd 在命令返回后按字节偏移重读 批处理,入口恰在 helper 运行期间被改写时,分行写法会执行到错位片段(实测 exit 9009) - helper exe 副本 sync_all 后再改名:避免断电留下残缺副本却因同名被一直复用 - 旧副本改为入口写好之后再清理;旧位置入口写失败时暂不清理,避免入口悬空 - DSH 托管块只在 headless profile 按启动目录路由:web/acp/tui 一个进程可服务多个工作区, 按启动目录会让别的工作区会话连到该项目并带上认证(跨项目越权);profile 取 profileContext.name, DSH 0.1.x 无该服务时退回看启动参数 - AGENTS.md 入口路径补全反斜杠;蓝图立 P9(DSH 桌面 GUI 会话级插件,待人确认) 验证:cargo test 245 passed;shim 认证成功 exit 0 / 92 字节、错误 digest exit 1;DSH url 表达式按 Loader 方式求值 7 种情形均符合预期;四路零凭据探针(Codex 包内、Codex 包外、Claude、DSH 无头)复跑通过。 Co-Authored-By: Claude Opus 5.5 --- .blueprint/modules/agent-client-parity.md | 11 +++- AGENTS.md | 2 +- docs/ai-context/project-context.yaml | 2 +- src-tauri/src/mcp_inject.rs | 76 ++++++++++++++++------- 4 files changed, 67 insertions(+), 24 deletions(-) diff --git a/.blueprint/modules/agent-client-parity.md b/.blueprint/modules/agent-client-parity.md index ccb28ff..1e5150d 100644 --- a/.blueprint/modules/agent-client-parity.md +++ b/.blueprint/modules/agent-client-parity.md @@ -89,7 +89,7 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。 - 注:吸收原 project-dev-variables V17(Claude 以 local scope + headersHelper 取值)。 - verification: "2026-10-01 Enterprise 三端无头验收(零凭据:F 项以 read_project_test_account(name=LJ_PROBE_NONEXISTENT) 判定认证,返回「开发需求未登记」即认证通过):Codex 59s、Claude 40~56s、DSH 11~14s,三端 B/C/D/E/F 全部通过(基线时 Claude、DSH 的 C/E/F 失败、Claude 的 B 失败)。Claude 以 init 事件客观确认 lian-jing 32 个项目工具且含开发变量工具;claude mcp list 显示项目地址 ✔ Connected。DSH 托管块 url 表达式在项目根、子目录、带尾分隔符路径解析为项目地址,在上级/无关目录回退全局;控制面 null 哨兵命中即停。未知 project_id 返回 404。" - review: "六轮对抗审查(首轮 9 项、第二轮 13 条、第三轮 10 条、最终 10 条、收尾 13 条,均已处理)。Claude 条目写入方式三度调整:CLI+后台队列(cwd 依赖与竞态)→ 直接编辑 ~/.claude.json(须复刻 Claude 的 git 根/worktree/NFC 键推导、.claude.json.lock、CLAUDE_CONFIG_DIR 等私有机制)→ 只读扫描认领 + CLI 写入(扫描结论不代表本目录实际生效条目,共享键下会误删他项目)→ 最终定为『Claude 自己的回答为准』:快路径只认项目根或其祖先键下的一致托管条目,其余在项目目录执行 claude mcp get 取实际生效条目,按归属(本项目托管/他项目托管/手写/无)决策,写入经 claude CLI、失败回滚;涉及 CLI 的 Tauri 命令改到线程池执行,删组/移出组先删成员关系再清配置。实测(enterprise):稳态注入 174ms 不起 CLI;缺失 → 写入、过期 helper → 刷新、手写条目 → 保留,claude mcp get 均为 Local 且 ✔ Connected;经 shim 零凭据探针认证通过。" -- known_limits: "DSH 桌面 GUI 模式的 cwd 不随工作区切换,项目路由目前只对在项目目录启动的 headless/CLI 生效,GUI 回退全局;claude mcp list 会提示 lian-jing 同时存在 user 与 local 两个作用域(user 级全局为有意保留的兜底);项目目录已被移动或删除后,Claude 中的残留托管条目无法经 CLI 定位,只记录 warn 与手工清理命令,不直接改写 ~/.claude.json;共享 Claude 项目键的嵌套项目/worktree 只有键所属项目的条目生效,他项目不改动、只记 warn;非稳态同步依赖 claude mcp get 的人类可读输出(格式无法识别时按未知处理、不写入),Claude 改输出格式会退化为只告警。" +- known_limits: "DSH 项目路由只对在项目目录启动的 headless 生效(2026-10-01 收紧:web/acp/tui 一个进程可服务多个工作区,按启动目录会让别的工作区会话连到该项目并带上认证,属跨项目越权,故与桌面版一样回退全局;判定用 profileContext.name,DSH 0.1.x 无该服务时退回看启动参数)。桌面 GUI 只有一个 Host 进程、启动目录固定为 profile 目录,按工作区路由需会话级插件,见 P9;claude mcp list 会提示 lian-jing 同时存在 user 与 local 两个作用域(user 级全局为有意保留的兜底);项目目录已被移动或删除后,Claude 中的残留托管条目无法经 CLI 定位,只记录 warn 与手工清理命令,不直接改写 ~/.claude.json;共享 Claude 项目键的嵌套项目/worktree 只有键所属项目的条目生效,他项目不改动、只记 warn;非稳态同步依赖 claude mcp get 的人类可读输出(格式无法识别时按未知处理、不写入),Claude 改输出格式会退化为只告警。" ### ✅ P2 — Claude skill 投影 - status: done @@ -146,3 +146,12 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。 - depends: P4 - acceptance: "agent_verify: [Tauri command / MCP] 对新登记项目执行一次接入 → 依次完成 canonical context、接入包分发(规则、skill 双投影、Codex 托管块)、本机三端注册(Claude local、DSH 白名单、shim、token),并以三端无头探针给出 A–F 兼容矩阵;重复执行幂等;接入包对项目文件的改动只报告 diff、不自动提交。" - 为什么:2026-10-01 Enterprise 验收时,接入散落在 inject_mcp、apply_onboarding_pack、verify_onboarding 与手工探针多个步骤。按「分发 vs 查询」:随仓库走的文件由炼境分发一次、按版本升级;与机器相关的客户端注册由炼境在本机维护、不进仓库;易变信息走 MCP 查询。默认三端都接入(成本小,保留随时换客户端的能力);能用标准的不做专门适配(Codex、DSH 原生读 AGENTS.md 与 .agents/skills,只有 Claude 需要 CLAUDE.md 与 .claude/skills 投影);炼境离线时规则与 skill 仍可用。 + +### 📋 P9 — DSH 桌面 GUI 按工作区连项目地址(会话级插件) +- status: todo +- complexity: M +- files: src-tauri/src/mcp_inject.rs(render_dsh_block 追加插件行), src-tauri/resources/dsh/lianjing-session-mcp.v1.mjs(新增,随二进制打包) +- depends: P1 +- acceptance: "agent_verify: [cargo test] 托管块同时含 mcp-lian-jing 与 lianjing-session-mcp 两行、插件路径为绝对路径、roots 与 normalize_dsh_root 一致;[node --test] 假 ctx 下按会话 cwd 命中项目才挂载、null 哨兵与未命中不挂、shim 失败/超时不抛错且无未处理 rejection;[DSH 0.2 与 0.1 运行时] 临时 DSH_HOME + stub MCP 服务,ACP session/new 两个不同 cwd 的会话只有项目会话产生 /mcp/projects/ 请求。human_verify: DSH GUI 在 enterprise 工作区新建会话能看到开发变量工具,在其他工作区看不到。" +- 为什么:2026-10-01 人终验确认 DSH 桌面版会话只连全局地址。只读调研(两路独立 + 对抗复核)结论:DSH 桌面版只有一个 Host 进程,启动目录固定为 profile 目录,!!js 只在启动时求值、拿不到会话信息,mcp-client 的地址在实例化时固定,也不读任何项目级 MCP 配置,所以只改托管块做不到。可行路径是 DSH 自带的 Agent 作用域:每个 GUI 会话的 session.header.cwd 即工作区路径,会话创建时串行发出 agent/created;在 agent.ctx 上挂 dsh-mcp-client,工具只对该会话可见并覆盖同名全局工具(DSH 内置的 ACP 就这样按会话挂 MCP)。 +- 风险(实施前需人确认是否值得):依赖 DSH 内部接口(agent/created、agent.ctx.plugin、ctx.loader.import、按 scope 分账),DSH 升级可能失效,须特性探测并降级为全局;Host 有 installFailLoud,插件任何未处理异常都会打崩桌面 Host;agent/created 是串行事件,挂载须加短超时,炼境离线时静默降级;Node 按 URL 缓存 ESM,插件升级须换文件名;子代理看不到父会话作用域的工具,是否给子代理单独挂载需人定;属于往 DSH 进程注入代码,信任边界与现有 !!js + execFileSync 相同,但需按「管家准入」论证。 diff --git a/AGENTS.md b/AGENTS.md index aa3c38c..b7d4226 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,7 @@ src-tauri/src/credential_store.rs Windows 本机受保护凭证存储与旧值 src-tauri/src/mcp_server.rs MCP Server 路由层 + SSE 协议(agent 调用炼境的入口) src-tauri/src/mcp_tools.rs MCP 工具 schema + dispatch + 实现(工具清单以 tools_list_result 为准) src-tauri/src/mcp_webhook.rs Gitea webhook 处理(push/PR 事件写入飞轮) -src-tauri/src/mcp_inject.rs 接入注入与三端适配(canonical context、Codex 项目配置、Claude local 条目、DSH 托管块、%USERPROFILE%.lian-jing 稳定 helper 入口) +src-tauri/src/mcp_inject.rs 接入注入与三端适配(canonical context、Codex 项目配置、Claude local 条目、DSH 托管块、%USERPROFILE%\.lian-jing\bin 稳定 helper 入口) src-tauri/resources/onboarding/ 接入包分发模板(AGENTS.md/lefthook/git-workflow) .blueprint/ 项目蓝图(manifest + 模块详情 + 任务卡) docs/ai-context/ AI 上下文文档(速查卡、契约映射) diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index 21b3f08..cb1b12a 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -6,7 +6,7 @@ contracts: - label: API 接口(Axum) paths: - "src-tauri/src/mcp_server.rs" - doc: "MCP 仅监听 127.0.0.1;新项目使用 /mcp/projects/ scoped route,连接 scope 拒绝跨项目参数和全局管理写工具。scoped route 只服务已登记项目,未知 project_id 返回 404(数据库不可用同样拒绝)。各客户端经 %USERPROFILE%/.lian-jing/bin/lianjing-mcp-header.cmd 稳定入口取认证头(炼境每次启动指向当前 exe;入口不放 AppData,因 MSIX 打包的 Codex Desktop 看不见其安装后在 AppData 下新建的顶层目录;炼境 exe 位于 AppData 下时复制一份到入口目录;旧 %LOCALAPPDATA% 入口在迁移期同步保留,verify_onboarding 报 Legacy 引导重注入):Codex 写项目 .codex/config.toml,Claude 写 local 条目(优先于 user 级全局;一律经 claude CLI、cwd=项目目录写入,项目键推导、文件锁与配置路径交给 Claude 自身,不直接编辑 ~/.claude.json;项目根或其祖先键下已有一致的托管条目则不起 CLI,否则以项目目录下 claude mcp get 的实际生效条目为准:本项目托管条目刷新/移除,他项目托管条目与手写条目不碰,无法确认现状一律不写;项目目录已不存在时只记录残留不写入),DSH 写 ~/.dsh/cordis.patch.yml 托管块(内嵌炼境登记的「项目根目录→project_id」白名单,!!js 从启动目录向上精确匹配根目录才选项目地址并取认证,任意目录里的 context.json 不作授权依据;未命中回退全局;项目增删与端口变更时重写)。Claude local 条目与 DSH 块只替换炼境托管的条目,手写同名配置不覆盖;移除项目时一并清理。旧 /mcp 只作兼容迁移。read_project_dev_variable 与 read_project_test_account 是两种有界开发值读取:仅 scoped 路由、匹配项目的本机 bearer 认证后单项读取;认证失败在 dispatch 前拒绝。路由变更需同步 Agent 按钮体系文档。" + doc: "MCP 仅监听 127.0.0.1;新项目使用 /mcp/projects/ scoped route,连接 scope 拒绝跨项目参数和全局管理写工具。scoped route 只服务已登记项目,未知 project_id 返回 404(数据库不可用同样拒绝)。各客户端经 %USERPROFILE%/.lian-jing/bin/lianjing-mcp-header.cmd 稳定入口取认证头(炼境每次启动指向当前 exe;入口不放 AppData,因 MSIX 打包的 Codex Desktop 看不见其安装后在 AppData 下新建的顶层目录;炼境 exe 位于 AppData 下时复制一份到入口目录;旧 %LOCALAPPDATA% 入口在迁移期同步保留,verify_onboarding 报 Legacy 引导重注入):Codex 写项目 .codex/config.toml,Claude 写 local 条目(优先于 user 级全局;一律经 claude CLI、cwd=项目目录写入,项目键推导、文件锁与配置路径交给 Claude 自身,不直接编辑 ~/.claude.json;项目根或其祖先键下已有一致的托管条目则不起 CLI,否则以项目目录下 claude mcp get 的实际生效条目为准:本项目托管条目刷新/移除,他项目托管条目与手写条目不碰,无法确认现状一律不写;项目目录已不存在时只记录残留不写入),DSH 写 ~/.dsh/cordis.patch.yml 托管块(内嵌炼境登记的「项目根目录→project_id」白名单,仅 headless profile 由 !!js 从启动目录向上精确匹配根目录才选项目地址并取认证,web/acp/tui/桌面版一个进程可服务多个工作区,一律走全局以防跨项目带认证,任意目录里的 context.json 不作授权依据;未命中回退全局;项目增删与端口变更时重写)。Claude local 条目与 DSH 块只替换炼境托管的条目,手写同名配置不覆盖;移除项目时一并清理。旧 /mcp 只作兼容迁移。read_project_dev_variable 与 read_project_test_account 是两种有界开发值读取:仅 scoped 路由、匹配项目的本机 bearer 认证后单项读取;认证失败在 dispatch 前拒绝。路由变更需同步 Agent 按钮体系文档。" - label: MCP 工具接口 paths: diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index 182cba4..eabc6f1 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -66,7 +66,7 @@ fn app_data_roots() -> Vec { } /// shim 要调用的 exe:炼境装在 AppData 下(安装版默认位置)时复制一份到入口目录,否则直接用当前 exe。 -/// 副本只在缺失时复制,并尽力清理旧副本(被占用的留待下次启动)。 +/// 副本只在缺失时复制;刷盘后再改名,避免断电留下残缺副本却因同名被一直复用。 fn helper_executable( bin_dir: &std::path::Path, executable: &std::path::Path, @@ -83,27 +83,42 @@ fn helper_executable( .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) .map(|elapsed| elapsed.as_nanos()) .unwrap_or(0); - let name = helper_copy_name(metadata.len(), modified_nanos); - let target = bin_dir.join(&name); + let target = bin_dir.join(helper_copy_name(metadata.len(), modified_nanos)); if !target.is_file() { - let partial = bin_dir.join(format!("{name}.partial")); + let partial = target.with_extension("partial"); std::fs::copy(executable, &partial) + .and_then(|_| { + std::fs::OpenOptions::new() + .write(true) + .open(&partial)? + .sync_all() + }) .and_then(|_| std::fs::rename(&partial, &target)) .map_err(|error| format!("复制 helper exe 失败: {error}"))?; } - if let Ok(entries) = std::fs::read_dir(bin_dir) { - for entry in entries.flatten() { - let file_name = entry.file_name().to_string_lossy().into_owned(); - if file_name.starts_with(HELPER_EXE_PREFIX) && file_name != name { - let _ = std::fs::remove_file(entry.path()); - } - } - } Ok(target) } +/// 入口已指向 current 之后再清理旧副本(被占用的留待下次启动);先删后写会让写入失败时入口悬空。 +fn remove_stale_helper_copies(bin_dir: &std::path::Path, current: &std::path::Path) { + let Ok(entries) = std::fs::read_dir(bin_dir) else { + return; + }; + for entry in entries.flatten() { + let is_copy = entry + .file_name() + .to_string_lossy() + .starts_with(HELPER_EXE_PREFIX); + if is_copy && entry.path() != current { + let _ = std::fs::remove_file(entry.path()); + } + } +} + /// 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() { @@ -111,7 +126,9 @@ fn render_header_shim(executable: &std::path::Path) -> String { } else { "chcp 65001 >nul\r\n" }; - format!("@echo off\r\n{codepage}\"{exe}\" --lianjing-mcp-header %1\r\nexit /b %ERRORLEVEL%\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> { @@ -148,8 +165,10 @@ pub fn ensure_header_shim() -> Result { let content = render_header_shim(&target); let path = bin_dir.join(MCP_HEADER_SHIM_NAME); write_if_changed(&path, &content)?; + let mut legacy_written = true; if let Some(legacy) = legacy_shim_path() { if let Err(error) = write_if_changed(&legacy, &content) { + legacy_written = false; log_event( "mcp_inject", "warn", @@ -158,6 +177,10 @@ pub fn ensure_header_shim() -> Result { ); } } + // 旧位置入口没更新成功时它可能仍指向旧副本,暂不清理 + if legacy_written { + remove_stale_helper_copies(&bin_dir, &target); + } Ok(path) } @@ -845,11 +868,17 @@ fn render_dsh_block(port: u16, roots: &[(String, Option)]) -> String { .map(|(root, id)| (root.clone(), json!(id))) .collect(); let roots_line = format!("const roots = {};", Value::Object(roots_map)); + // 只有单会话的 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;", - "for (let d = path.resolve(process.cwd()), p; d !== p; p = d, d = path.dirname(d)) {", + "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; }", "}", @@ -1595,12 +1624,11 @@ mod tests { let copied = helper_executable(&bin_dir, &exe, &[app_data.clone()]).unwrap(); assert!(copied.starts_with(&bin_dir) && copied.is_file()); assert_eq!(std::fs::read(&copied).unwrap(), b"build-1"); - assert!( - !bin_dir - .join(format!("{HELPER_EXE_PREFIX}stale.exe")) - .exists(), - "旧副本被清理" - ); + let stale = bin_dir.join(format!("{HELPER_EXE_PREFIX}stale.exe")); + assert!(stale.exists(), "入口写好之前不清理旧副本"); + remove_stale_helper_copies(&bin_dir, &copied); + assert!(!stale.exists(), "旧副本被清理"); + assert!(copied.exists(), "当前副本保留"); assert!( bin_dir.join(MCP_HEADER_SHIM_NAME).exists(), "入口脚本不受清理影响" @@ -1640,7 +1668,8 @@ mod tests { 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\r\nexit /b %ERRORLEVEL%\r\n" + "@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!( @@ -1890,6 +1919,11 @@ mod tests { "未命中回退全局地址" ); 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 共用用户目录下的入口" -- 2.45.2 From c4241f075d09fb8184ca15d8f53ecda3d76fcd1c Mon Sep 17 00:00:00 2001 From: lanrtop Date: Fri, 2 Oct 2026 11:31:59 +0900 Subject: [PATCH 15/15] =?UTF-8?q?fix(mcp/parity):=20=E6=9B=B4=E6=AD=A3=20C?= =?UTF-8?q?odex=20Desktop=20=E5=8F=96=E8=AE=A4=E8=AF=81=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E7=9A=84=E6=A0=B9=E5=9B=A0=EF=BC=8C=E6=92=A4=E9=94=80=E5=9F=BA?= =?UTF-8?q?=E4=BA=8E=E9=94=99=E8=AF=AF=E5=89=8D=E6=8F=90=E7=9A=84=20exe=20?= =?UTF-8?q?=E5=89=AF=E6=9C=AC=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2b0a55a 判断「Codex Desktop(MSIX)看不见其安装后在 AppData 下新建的顶层目录」是错的:当时的对照 目录本身也是在 Claude 桌面版的包环境里建的。真正原因:worktree 版炼境由 Claude 桌面版(MSIX)里的 agent 启动,继承了其包环境,写到 %LOCALAPPDATA%\lian-jing 的入口被重定向进 %LOCALAPPDATA%\Packages\Claude_pzs8sxrjxfjjc\LocalCache\Local\,只有 Claude 的进程树看得到。 对照:经 WMI 启动的非包进程在 AppData 下新建的顶层目录,Codex 包内可见;%USERPROFILE% 根不做重定向。 - 入口迁至 %USERPROFILE%\.lian-jing\bin 仍是正解(与炼境在什么环境里启动无关),保留 - 撤销「exe 在 AppData 下就复制副本」逻辑及其测试:exe 总在真实路径上,前提不成立(按「退出」原则) - 更正代码注释、verify_onboarding 的 Legacy 提示、契约文档与蓝图 P3 返工记录 验证:cargo test 243 passed / 5 ignored(删去 2 个只测副本逻辑的测试)。 Co-Authored-By: Claude Opus 5.5 --- .blueprint/modules/agent-client-parity.md | 7 +- docs/ai-context/project-context.yaml | 2 +- src-tauri/src/commands/onboarding.rs | 4 +- src-tauri/src/mcp_inject.rs | 174 +--------------------- 4 files changed, 15 insertions(+), 172 deletions(-) diff --git a/.blueprint/modules/agent-client-parity.md b/.blueprint/modules/agent-client-parity.md index 1e5150d..8d6c69e 100644 --- a/.blueprint/modules/agent-client-parity.md +++ b/.blueprint/modules/agent-client-parity.md @@ -104,11 +104,12 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。 - complexity: M - files: src-tauri/src/mcp_inject.rs, src-tauri/src/lib.rs, src-tauri/src/commands/onboarding.rs - depends: H4 -- acceptance: "agent_verify: [cargo test] 炼境启动写/更新 %USERPROFILE%\.lian-jing\bin 下 shim 指向当前 exe(exe 在 AppData 下时指向入口目录里的副本);inject 后受跟踪文件不含本机绝对路径或用户名;[Codex 无头探针] F 项经 shim 成功,且在 Codex 包上下文(Invoke-CommandInDesktopPackage 运行 codex exec,等价 Codex Desktop)同样成功;换构建目录启动后无需重注入仍成功。" +- acceptance: "agent_verify: [cargo test] 炼境启动写/更新 %USERPROFILE%\.lian-jing\bin 下 shim 指向当前 exe;inject 后受跟踪文件不含本机绝对路径或用户名;[Codex 无头探针] F 项经 shim 成功,且在 Codex 包上下文(Invoke-CommandInDesktopPackage 运行 codex exec,等价 Codex Desktop)同样成功;换构建目录启动后无需重注入仍成功。" - 注:即原 project-dev-variables V18;Claude headersHelper 与 DSH 共用同一 shim。 - verification: "2026-10-01:shim 写入 %LOCALAPPDATA%\lian-jing\bin\lianjing-mcp-header.cmd(CRLF,exe 路径 % 转义),Enterprise 的 .codex/config.toml 托管块 helper 为 %LOCALAPPDATA% 形式、不含本机路径与用户名;Codex 无头 F 项经 shim 认证通过。H4 实测证实 Codex 经 cmd 展开 %LOCALAPPDATA%(替身目录因果对照)。换构建目录启动后免重注入:shim 每次启动重写指向当前 exe(实测在 worktree 构建启动时自动改指向)。 - 返工(2026-10-01 人终验发现):Codex Desktop 会话里 lian-jing 完全未加载,日志为 headers helper exit 1。根因:Codex Desktop 是 MSIX 打包应用,看不见其安装后在 %LOCALAPPDATA% / %APPDATA% 下新建的顶层目录(包上下文对照实测:lian-jing 与两个新建对照目录均不可见,与 ACL 无关;已有目录内新建文件、%USERPROFILE% 下新目录均可见;Codex 启动晚于目录创建,排除重启因素)。原无头验收用包外的 codex exec,未覆盖该条件。修复:入口迁至 %USERPROFILE%\.lian-jing\bin;炼境 exe 位于 AppData 下(安装版默认)时按大小+修改时间指纹复制到入口目录并清理旧副本;旧入口迁移期同步保留,verify_onboarding 把 %LOCALAPPDATA% 写法报 Legacy。复验:四路零凭据探针(Codex 包内、Codex 包外、Claude、DSH 无头)均返回「开发需求未登记」,证实 USERPROFILE 在 Codex helper 环境白名单内;包内运行用户目录下的 exe 副本 exit 0。" -- known_limits: "旧 %LOCALAPPDATA% 入口仅为迁移期兼容,各子项目需重注入(会改动其受跟踪的 .codex/config.toml,需在子项目提交);Microsoft Store 版等其他 MSIX 打包客户端未逐一实测,规律同 Codex Desktop。" + 返工(2026-10-01 人终验发现):Codex Desktop 会话里 lian-jing 完全未加载,日志为 headers helper exit 1。修复:入口迁至 %USERPROFILE%\.lian-jing\bin;旧入口迁移期同步保留,verify_onboarding 把 %LOCALAPPDATA% 写法报 Legacy。复验:四路零凭据探针(Codex 包内、Codex 包外、Claude、DSH 无头)均返回「开发需求未登记」,证实 USERPROFILE 在 Codex helper 环境白名单内。 + 根因更正(2026-10-02):首次判断「Codex Desktop(MSIX)看不见其安装后在 AppData 下新建的顶层目录」是错的——当时的对照目录本身也是在 Claude 桌面版的包环境里建的。真正原因:worktree 版炼境由 Claude 桌面版(MSIX,Claude_pzs8sxrjxfjjc)里的 agent 启动,继承了其包环境,写到 %LOCALAPPDATA%\lian-jing 的入口被 Windows 重定向进 %LOCALAPPDATA%\Packages\Claude_pzs8sxrjxfjjc\LocalCache\Local\,只有 Claude 的进程树看得到。对照:经 WMI 启动的非包进程在 AppData 下新建的顶层目录,Codex 包内可见;%USERPROFILE% 根不做这种重定向。入口迁至 %USERPROFILE% 因此仍是正解(与炼境在什么环境里启动无关);据错误前提加的「exe 在 AppData 下就复制副本」逻辑已按「退出」原则撤销(exe 总在真实路径上)。" +- known_limits: "旧 %LOCALAPPDATA% 入口仅为迁移期兼容,各子项目需重注入(会改动其受跟踪的 .codex/config.toml,需在子项目提交)。炼境若在 MSIX 应用进程树里启动,它写的其他 AppData 内容(如安装目录外的缓存)同样只对该应用可见,凡给其他客户端读的文件都不应放在 AppData。" ### 📋 P4 — 三端对等探针固化为炼境能力 - status: todo diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index cb1b12a..d7c2d32 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -6,7 +6,7 @@ contracts: - label: API 接口(Axum) paths: - "src-tauri/src/mcp_server.rs" - doc: "MCP 仅监听 127.0.0.1;新项目使用 /mcp/projects/ scoped route,连接 scope 拒绝跨项目参数和全局管理写工具。scoped route 只服务已登记项目,未知 project_id 返回 404(数据库不可用同样拒绝)。各客户端经 %USERPROFILE%/.lian-jing/bin/lianjing-mcp-header.cmd 稳定入口取认证头(炼境每次启动指向当前 exe;入口不放 AppData,因 MSIX 打包的 Codex Desktop 看不见其安装后在 AppData 下新建的顶层目录;炼境 exe 位于 AppData 下时复制一份到入口目录;旧 %LOCALAPPDATA% 入口在迁移期同步保留,verify_onboarding 报 Legacy 引导重注入):Codex 写项目 .codex/config.toml,Claude 写 local 条目(优先于 user 级全局;一律经 claude CLI、cwd=项目目录写入,项目键推导、文件锁与配置路径交给 Claude 自身,不直接编辑 ~/.claude.json;项目根或其祖先键下已有一致的托管条目则不起 CLI,否则以项目目录下 claude mcp get 的实际生效条目为准:本项目托管条目刷新/移除,他项目托管条目与手写条目不碰,无法确认现状一律不写;项目目录已不存在时只记录残留不写入),DSH 写 ~/.dsh/cordis.patch.yml 托管块(内嵌炼境登记的「项目根目录→project_id」白名单,仅 headless profile 由 !!js 从启动目录向上精确匹配根目录才选项目地址并取认证,web/acp/tui/桌面版一个进程可服务多个工作区,一律走全局以防跨项目带认证,任意目录里的 context.json 不作授权依据;未命中回退全局;项目增删与端口变更时重写)。Claude local 条目与 DSH 块只替换炼境托管的条目,手写同名配置不覆盖;移除项目时一并清理。旧 /mcp 只作兼容迁移。read_project_dev_variable 与 read_project_test_account 是两种有界开发值读取:仅 scoped 路由、匹配项目的本机 bearer 认证后单项读取;认证失败在 dispatch 前拒绝。路由变更需同步 Agent 按钮体系文档。" + doc: "MCP 仅监听 127.0.0.1;新项目使用 /mcp/projects/ scoped route,连接 scope 拒绝跨项目参数和全局管理写工具。scoped route 只服务已登记项目,未知 project_id 返回 404(数据库不可用同样拒绝)。各客户端经 %USERPROFILE%/.lian-jing/bin/lianjing-mcp-header.cmd 稳定入口取认证头(炼境每次启动指向当前 exe;入口不放 AppData,因炼境若在 MSIX 应用的进程树里启动(如由 Claude 桌面版里的 agent 启动),AppData 下新建的目录会被重定向到该应用的私有目录,其他客户端看不到,用户目录根不受此影响;旧 %LOCALAPPDATA% 入口在迁移期同步保留,verify_onboarding 报 Legacy 引导重注入):Codex 写项目 .codex/config.toml,Claude 写 local 条目(优先于 user 级全局;一律经 claude CLI、cwd=项目目录写入,项目键推导、文件锁与配置路径交给 Claude 自身,不直接编辑 ~/.claude.json;项目根或其祖先键下已有一致的托管条目则不起 CLI,否则以项目目录下 claude mcp get 的实际生效条目为准:本项目托管条目刷新/移除,他项目托管条目与手写条目不碰,无法确认现状一律不写;项目目录已不存在时只记录残留不写入),DSH 写 ~/.dsh/cordis.patch.yml 托管块(内嵌炼境登记的「项目根目录→project_id」白名单,仅 headless profile 由 !!js 从启动目录向上精确匹配根目录才选项目地址并取认证,web/acp/tui/桌面版一个进程可服务多个工作区,一律走全局以防跨项目带认证,任意目录里的 context.json 不作授权依据;未命中回退全局;项目增删与端口变更时重写)。Claude local 条目与 DSH 块只替换炼境托管的条目,手写同名配置不覆盖;移除项目时一并清理。旧 /mcp 只作兼容迁移。read_project_dev_variable 与 read_project_test_account 是两种有界开发值读取:仅 scoped 路由、匹配项目的本机 bearer 认证后单项读取;认证失败在 dispatch 前拒绝。路由变更需同步 Agent 按钮体系文档。" - label: MCP 工具接口 paths: diff --git a/src-tauri/src/commands/onboarding.rs b/src-tauri/src/commands/onboarding.rs index 794d7dc..9db5343 100644 --- a/src-tauri/src/commands/onboarding.rs +++ b/src-tauri/src/commands/onboarding.rs @@ -683,7 +683,7 @@ fn codex_helper_upgrade_hint(content: &str) -> Option<&'static str> { } if in_lian_jing && trimmed.starts_with("http_headers_helper") { if trimmed.contains("%LOCALAPPDATA%") { - return Some("helper 仍引用 %LOCALAPPDATA% 下的旧入口(Codex Desktop 为 MSIX 应用,看不见该目录,取认证失败)——调用 inject_mcp 升级为 %USERPROFILE%\\.lian-jing 稳定入口"); + return Some("helper 仍引用 %LOCALAPPDATA% 下的旧入口(炼境若在 MSIX 应用的进程树里启动,例如由 Claude 桌面版里的 agent 启动,该目录会被重定向到那个应用的私有目录,Codex Desktop 等其他客户端取认证失败)——调用 inject_mcp 升级为 %USERPROFILE%\\.lian-jing 稳定入口"); } if trimmed.contains("--lianjing-mcp-header") { return Some("helper 仍写着本机 exe 绝对路径(换构建即失效,且会随仓库提交)——调用 inject_mcp 升级为 %USERPROFILE%\\.lian-jing 稳定入口"); @@ -1538,7 +1538,7 @@ mod tests { assert_eq!(legacy.state, VerifyState::Legacy); assert!(legacy.detail.contains("本机 exe 绝对路径")); - // 2026-10-01:%LOCALAPPDATA% 入口在 Codex Desktop(MSIX)里不可见,由 Ready 改判 Legacy + // 2026-10-01:%LOCALAPPDATA% 入口可能被重定向进某个 MSIX 应用的私有目录,由 Ready 改判 Legacy let app_data = format!( "[mcp_servers.lian-jing]\n{url}http_headers_helper = \"\\\"%LOCALAPPDATA%\\\\lian-jing\\\\bin\\\\lianjing-mcp-header.cmd\\\" d1\"\n" ); diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index eabc6f1..5cd25b9 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -11,16 +11,15 @@ 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"; -/// 炼境装在 AppData 下时,复制到入口目录的 helper exe 副本的文件名前缀。 -const HELPER_EXE_PREFIX: &str = "lianjing-helper-"; 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:Codex Desktop 是 MSIX 打包应用,看不见它安装之后才在 -// %LOCALAPPDATA% / %APPDATA% 下新建的顶层目录,用户目录根不受此限(2026-10-01 在 Codex 包上下文实测)。 +// 入口放在 %USERPROFILE% 而非 AppData:炼境若是在 MSIX 打包应用的进程树里启动的(例如由 Claude 桌面版 +// 里的 agent 启动 dev 构建),它在 AppData 下新建的目录会被 Windows 重定向到该应用的私有目录, +// Codex Desktop 等其他客户端看不到;用户目录根不做这种重定向(2026-10-01/02 包上下文对照实测)。 fn helper_bin_dir() -> Result { std::env::var_os("USERPROFILE") @@ -30,8 +29,8 @@ fn helper_bin_dir() -> Result { .ok_or_else(|| "USERPROFILE 未设置,无法写入 MCP helper 入口".to_string()) } -/// 迁移期兼容:已注入的项目配置仍引用旧入口 %LOCALAPPDATA%\lian-jing\bin\(非 MSIX 客户端可用), -/// 在项目重注入前继续同步写一份;verify_onboarding 会把旧写法报为 Legacy 引导升级。 +/// 迁移期兼容:已注入的项目配置仍引用旧入口 %LOCALAPPDATA%\lian-jing\bin\(炼境不在包环境里启动时 +/// 各客户端都能看到),在项目重注入前继续同步写一份;verify_onboarding 会把旧写法报为 Legacy 引导升级。 fn legacy_shim_path() -> Option { std::env::var_os("LOCALAPPDATA") .map(PathBuf::from) @@ -43,78 +42,6 @@ fn legacy_shim_path() -> Option { }) } -/// 纯函数:exe 是否位于 MSIX 客户端可能看不见的 AppData 目录下。 -fn under_app_data(executable: &std::path::Path, app_data_roots: &[PathBuf]) -> bool { - let exe = executable.display().to_string(); - app_data_roots - .iter() - .any(|root| key_covers_root(&root.display().to_string(), &exe)) -} - -/// 纯函数:helper exe 副本名,以源文件大小与修改时间作指纹。换构建即换名,不覆盖正被客户端执行的旧副本。 -fn helper_copy_name(len: u64, modified_nanos: u128) -> String { - format!("{HELPER_EXE_PREFIX}{len:x}-{modified_nanos:x}.exe") -} - -fn app_data_roots() -> Vec { - ["LOCALAPPDATA", "APPDATA"] - .iter() - .filter_map(|name| std::env::var_os(name)) - .map(PathBuf::from) - .filter(|root| root.is_absolute()) - .collect() -} - -/// shim 要调用的 exe:炼境装在 AppData 下(安装版默认位置)时复制一份到入口目录,否则直接用当前 exe。 -/// 副本只在缺失时复制;刷盘后再改名,避免断电留下残缺副本却因同名被一直复用。 -fn helper_executable( - bin_dir: &std::path::Path, - executable: &std::path::Path, - app_data: &[PathBuf], -) -> Result { - if !under_app_data(executable, app_data) { - return Ok(executable.to_path_buf()); - } - let metadata = std::fs::metadata(executable) - .map_err(|error| format!("读取炼境可执行文件信息失败: {error}"))?; - let modified_nanos = metadata - .modified() - .ok() - .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|elapsed| elapsed.as_nanos()) - .unwrap_or(0); - let target = bin_dir.join(helper_copy_name(metadata.len(), modified_nanos)); - if !target.is_file() { - let partial = target.with_extension("partial"); - std::fs::copy(executable, &partial) - .and_then(|_| { - std::fs::OpenOptions::new() - .write(true) - .open(&partial)? - .sync_all() - }) - .and_then(|_| std::fs::rename(&partial, &target)) - .map_err(|error| format!("复制 helper exe 失败: {error}"))?; - } - Ok(target) -} - -/// 入口已指向 current 之后再清理旧副本(被占用的留待下次启动);先删后写会让写入失败时入口悬空。 -fn remove_stale_helper_copies(bin_dir: &std::path::Path, current: &std::path::Path) { - let Ok(entries) = std::fs::read_dir(bin_dir) else { - return; - }; - for entry in entries.flatten() { - let is_copy = entry - .file_name() - .to_string_lossy() - .starts_with(HELPER_EXE_PREFIX); - if is_copy && entry.path() != current { - let _ = std::fs::remove_file(entry.path()); - } - } -} - /// cmd 按 OEM 代码页读批处理;exe 路径含非 ASCII 字符时先切到 UTF-8。 /// 路径里的 % 必须写成 %%,否则会和 %1 配对被当作变量展开吞掉。 /// 调用与退出写在同一行:cmd 在命令返回后按字节偏移重读批处理文件,入口若恰在 helper 运行期间被改写, @@ -147,28 +74,14 @@ 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())?; - // 复制失败时退回直接调用当前 exe:非 MSIX 客户端仍可用,只记录告警 - let target = - helper_executable(&bin_dir, &executable, &app_data_roots()).unwrap_or_else(|error| { - log_event( - "mcp_inject", - "warn", - &format!( - "{error};helper 入口改为直接调用当前 exe(Codex Desktop 可能无法取认证)" - ), - Some(r#"{"client":"shim"}"#), - ); - executable.clone() - }); - let content = render_header_shim(&target); + let content = render_header_shim(&executable); let path = bin_dir.join(MCP_HEADER_SHIM_NAME); write_if_changed(&path, &content)?; - let mut legacy_written = true; if let Some(legacy) = legacy_shim_path() { if let Err(error) = write_if_changed(&legacy, &content) { - legacy_written = false; log_event( "mcp_inject", "warn", @@ -177,10 +90,6 @@ pub fn ensure_header_shim() -> Result { ); } } - // 旧位置入口没更新成功时它可能仍指向旧副本,暂不清理 - if legacy_written { - remove_stale_helper_copies(&bin_dir, &target); - } Ok(path) } @@ -1592,74 +1501,7 @@ mod tests { assert!(!helper.to_lowercase().contains("users"), "不得含用户目录"); assert!( !helper.contains("%LOCALAPPDATA%"), - "MSIX 客户端看不见 AppData 下后建的目录" - ); - } - - #[test] - fn helper_exe_is_copied_out_of_app_data_once_per_build() { - let base = - std::env::temp_dir().join(format!("lianjing_helper_copy_{}", std::process::id())); - let _ = std::fs::remove_dir_all(&base); - let app_data = base.join("AppData").join("Local"); - let install = app_data.join("dev-manager-tauri"); - let bin_dir = base.join(".lian-jing").join("bin"); - std::fs::create_dir_all(&install).unwrap(); - std::fs::create_dir_all(&bin_dir).unwrap(); - let exe = install.join("app.exe"); - std::fs::write(&exe, b"build-1").unwrap(); - std::fs::write( - bin_dir.join(format!("{HELPER_EXE_PREFIX}stale.exe")), - b"old", - ) - .unwrap(); - std::fs::write(bin_dir.join(MCP_HEADER_SHIM_NAME), b"keep").unwrap(); - - let outside = base.join("E-drive").join("app.exe"); - assert_eq!( - helper_executable(&bin_dir, &outside, &[app_data.clone()]).unwrap(), - outside, - "不在 AppData 下的 exe 直接使用" - ); - let copied = helper_executable(&bin_dir, &exe, &[app_data.clone()]).unwrap(); - assert!(copied.starts_with(&bin_dir) && copied.is_file()); - assert_eq!(std::fs::read(&copied).unwrap(), b"build-1"); - let stale = bin_dir.join(format!("{HELPER_EXE_PREFIX}stale.exe")); - assert!(stale.exists(), "入口写好之前不清理旧副本"); - remove_stale_helper_copies(&bin_dir, &copied); - assert!(!stale.exists(), "旧副本被清理"); - assert!(copied.exists(), "当前副本保留"); - assert!( - bin_dir.join(MCP_HEADER_SHIM_NAME).exists(), - "入口脚本不受清理影响" - ); - assert_eq!( - helper_executable(&bin_dir, &exe, &[app_data.clone()]).unwrap(), - copied, - "同一构建复用已有副本" - ); - let _ = std::fs::remove_dir_all(&base); - } - - #[test] - fn app_data_detection_is_case_insensitive_and_boundary_safe() { - let roots = [PathBuf::from("C:\\Users\\u\\AppData\\Local")]; - assert!(under_app_data( - std::path::Path::new("c:\\users\\U\\appdata\\local\\dev-manager-tauri\\a.exe"), - &roots - )); - assert!(!under_app_data( - std::path::Path::new("C:\\Users\\u\\AppData\\LocalLow\\a.exe"), - &roots - )); - assert!(!under_app_data( - std::path::Path::new("E:\\code\\a.exe"), - &roots - )); - assert_ne!( - helper_copy_name(1, 2), - helper_copy_name(1, 3), - "换构建即换名" + "炼境在 MSIX 应用进程树里启动时,AppData 下新建的目录会被重定向到该应用的私有目录" ); } -- 2.45.2