From cca3bf9db1c8cb609d783b879b6f606aa7d8735d Mon Sep 17 00:00:00 2001 From: lanrtop Date: Thu, 1 Oct 2026 20:06:14 +0900 Subject: [PATCH] =?UTF-8?q?fix(mcp/parity):=20=E5=AE=A1=E6=9F=A5=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E2=80=94=E2=80=94shim=20=E5=8D=95=E8=A1=8C=E9=80=80?= =?UTF-8?q?=E5=87=BA=E3=80=81=E5=89=AF=E6=9C=AC=E5=88=B7=E7=9B=98=E5=90=8E?= =?UTF-8?q?=E6=94=B9=E5=90=8D=E3=80=81DSH=20=E4=BB=85=20headless=20?= =?UTF-8?q?=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 共用用户目录下的入口"