diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index ce7abe4..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 @@ -511,11 +511,20 @@ 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 + 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/agent-client-parity.md b/.blueprint/modules/agent-client-parity.md index 060077d..8d6c69e 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 返工记录。) ### 实验附带发现 @@ -80,13 +80,16 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。 ## 任务拆解 -### 📋 P1 — 统一连接项目专用地址(Claude + DSH) -- status: todo +### ✅ 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: "六轮对抗审查(首轮 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 项目路由只对在项目目录启动的 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 @@ -96,13 +99,17 @@ 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 成功;换构建目录启动后无需重注入仍成功。" +- 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。修复:入口迁至 %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 @@ -132,3 +139,20 @@ 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 仍可用。 + +### 📋 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/.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,存量随相关改动迁移,不单独立卡。 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/.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/AGENTS.md b/AGENTS.md index ecefa32..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 `.lian-jing/context.json`、`.codex/config.toml` 与 Claude 兼容配置) +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 62ded75..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 拒绝跨项目参数和全局管理写工具。旧 /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: @@ -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/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/onboarding.rs b/src-tauri/src/commands/onboarding.rs index 7411b06..9db5343 100644 --- a/src-tauri/src/commands/onboarding.rs +++ b/src-tauri/src/commands/onboarding.rs @@ -670,6 +670,30 @@ fn codex_mcp_server_url(content: &str) -> Option<&str> { None } +/// 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(); + if trimmed.starts_with('[') { + in_lian_jing = trimmed == "[mcp_servers.lian-jing]"; + continue; + } + if in_lian_jing && trimmed.starts_with("http_headers_helper") { + if trimmed.contains("%LOCALAPPDATA%") { + 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 稳定入口"); + } + return None; + } + } + None +} + pub(crate) fn check_codex_mcp( content: Option<&str>, expected_project_id: &str, @@ -684,7 +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()) { + 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, @@ -1501,6 +1528,30 @@ 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("本机 exe 绝对路径")); + + // 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" + ); + 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 = \"\\\"%USERPROFILE%\\\\.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/commands/projects.rs b/src-tauri/src/commands/projects.rs index 8764da2..32e6c3a 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 root_may_change { + let new_root = crate::mcp_inject::project_root_with_conn(&conn, &id).ok(); + 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, old_root)); + } + } + Ok(()) } @@ -312,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/lib.rs b/src-tauri/src/lib.rs index d9dba65..da49b8a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -116,6 +116,19 @@ 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}"), + Some(r#"{"client":"shim"}"#), + ); + } + // 刷新 DSH 白名单要读所有项目根目录的 context.json,放到后台线程,不拖慢窗口启动 + std::thread::spawn(|| mcp_inject::refresh_dsh_patch(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..5cd25b9 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -9,6 +9,94 @@ const LEGACY_KEY_PREFIX: &str = "lian-jing:group-"; const CODEX_MCP_MANAGED_START: &str = "# LIANJING_MCP_MANAGED_START"; const CODEX_MCP_MANAGED_END: &str = "# LIANJING_MCP_MANAGED_END"; const CONTEXT_SCHEMA_VERSION: u8 = 1; +/// 稳定 helper 入口文件名:位于 %USERPROFILE%\.lian-jing\bin\,每次启动指向当前 exe。 +const MCP_HEADER_SHIM_NAME: &str = "lianjing-mcp-header.cmd"; +const DSH_MANAGED_START: &str = "# LIANJING_DSH_MANAGED_START"; +const DSH_MANAGED_END: &str = "# LIANJING_DSH_MANAGED_END"; + +// ── 稳定 helper 入口(三端共用)──────────────────────────────────────────────── +// Codex 与 Claude 在 Windows 都经 cmd.exe 执行 helper 并展开环境变量;DSH 由 !!js 经 cmd 调用。 +// 项目配置只引用用户目录下的固定入口,不含本机 exe 路径与用户名,换构建无需逐项目重注入。 +// 入口放在 %USERPROFILE% 而非 AppData:炼境若是在 MSIX 打包应用的进程树里启动的(例如由 Claude 桌面版 +// 里的 agent 启动 dev 构建),它在 AppData 下新建的目录会被 Windows 重定向到该应用的私有目录, +// Codex Desktop 等其他客户端看不到;用户目录根不做这种重定向(2026-10-01/02 包上下文对照实测)。 + +fn helper_bin_dir() -> Result { + std::env::var_os("USERPROFILE") + .map(PathBuf::from) + .filter(|home| home.is_absolute()) + .map(|home| home.join(".lian-jing").join("bin")) + .ok_or_else(|| "USERPROFILE 未设置,无法写入 MCP helper 入口".to_string()) +} + +/// 迁移期兼容:已注入的项目配置仍引用旧入口 %LOCALAPPDATA%\lian-jing\bin\(炼境不在包环境里启动时 +/// 各客户端都能看到),在项目重注入前继续同步写一份;verify_onboarding 会把旧写法报为 Legacy 引导升级。 +fn legacy_shim_path() -> Option { + std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .filter(|base| base.is_absolute()) + .map(|base| { + base.join("lian-jing") + .join("bin") + .join(MCP_HEADER_SHIM_NAME) + }) +} + +/// cmd 按 OEM 代码页读批处理;exe 路径含非 ASCII 字符时先切到 UTF-8。 +/// 路径里的 % 必须写成 %%,否则会和 %1 配对被当作变量展开吞掉。 +/// 调用与退出写在同一行:cmd 在命令返回后按字节偏移重读批处理文件,入口若恰在 helper 运行期间被改写, +/// 分行写法会执行到错位片段(实测 exit 9009)。`call exit /b %%ERRORLEVEL%%` 在执行时才展开,能传递 exe 退出码。 +fn render_header_shim(executable: &std::path::Path) -> String { + let exe = executable.display().to_string().replace('%', "%%"); + let codepage = if exe.is_ascii() { + "" + } else { + "chcp 65001 >nul\r\n" + }; + format!( + "@echo off\r\n{codepage}\"{exe}\" --lianjing-mcp-header %1 & call exit /b %%ERRORLEVEL%%\r\n" + ) +} + +fn write_if_changed(path: &std::path::Path, content: &str) -> Result<(), String> { + if std::fs::read_to_string(path).ok().as_deref() == Some(content) { + return Ok(()); + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|error| format!("创建 MCP helper 入口目录失败: {error}"))?; + } + std::fs::write(path, content).map_err(|error| format!("写入 MCP helper 入口失败: {error}")) +} + +/// 写入或更新稳定 helper 入口;内容未变不重写。炼境启动与每次注入时调用。 +pub fn ensure_header_shim() -> Result { + let bin_dir = helper_bin_dir()?; + std::fs::create_dir_all(&bin_dir) + .map_err(|error| format!("创建 MCP helper 入口目录失败: {error}"))?; + // exe 本身总在真实路径上(安装目录或构建目录),各客户端都能看到,直接引用即可 + let executable = std::env::current_exe() + .map_err(|_| "无法定位炼境可执行文件,拒绝写入 MCP helper 入口".to_string())?; + let content = render_header_shim(&executable); + let path = bin_dir.join(MCP_HEADER_SHIM_NAME); + write_if_changed(&path, &content)?; + if let Some(legacy) = legacy_shim_path() { + if let Err(error) = write_if_changed(&legacy, &content) { + log_event( + "mcp_inject", + "warn", + &format!("旧位置 helper 入口更新失败(仅影响尚未重注入的项目): {error}"), + Some(r#"{"client":"shim"}"#), + ); + } + } + Ok(path) +} + +/// 客户端配置中的 helper 命令:只含 %USERPROFILE% 与非秘密 digest,可安全提交进项目仓库。 +fn shim_helper_command(digest: &str) -> String { + format!("\"%USERPROFILE%\\.lian-jing\\bin\\{MCP_HEADER_SHIM_NAME}\" {digest}") +} // ── 路径解析 ────────────────────────────────────────────────────────────────── @@ -167,10 +255,7 @@ fn find_claude_bin() -> Option { } // 最后尝试 where 命令(GUI 进程 PATH 可能不含用户 bin,but worth trying) - if let Ok(out) = std::process::Command::new("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() { @@ -205,7 +290,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()); @@ -225,6 +310,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( @@ -245,8 +334,11 @@ fn try_register_globally(port: u16) -> bool { return false; }; - match std::process::Command::new(&claude_bin) - .args([ + match run_claude_cli( + &claude_bin, + &git_bash, + None, + &[ "mcp", "add", "--transport", @@ -255,41 +347,614 @@ fn try_register_globally(port: u16) -> bool { "user", MCP_KEY, &server_url, - ]) - .env("CLAUDE_CODE_GIT_BASH_PATH", &git_bash) - .output() - { - Ok(out) if out.status.success() => { - db::log_event("mcp_inject", "info", "全局 MCP 注册成功", Some(MCP_KEY)); + ], + ) { + 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) + // 同名已存在不是故障——CLI 链路健康,注册目标已达成 + Err(output) if output.contains("already exists") => true, + Err(output) => { + db::log_event( + "mcp_inject", + "warn", + &format!("claude mcp add 失败: {output}"), + Some(&global_log_context()), ); - // 同名已存在(exit 1)不是故障——CLI 链路健康,注册目标已达成 - if combined.contains("already exists") { - return true; + false + } + } +} + +/// 项目在 .lian-jing/context.json 声明 "mcp_scope": "global" 即为控制面项目(如炼境自身): +/// Claude 与 DSH 保留全局地址与跨项目工具,不写项目级覆盖(否则 local 条目会遮蔽全局注册)。 +fn project_wants_global_scope(root: &std::path::Path) -> bool { + read_context_object( + &root.join(".lian-jing").join("context.json"), + "canonical 项目上下文", + ) + .ok() + .and_then(|ctx| { + ctx.get("mcp_scope") + .and_then(Value::as_str) + .map(|scope| scope == "global") + }) + .unwrap_or(false) +} + +/// 注入日志的 context 必须是 JSON:get_diagnostics 按 json_extract(context,'$.project_id') 分组。 +fn inject_log_context(project_id: &str, client: &str) -> String { + json!({ "project_id": project_id, "client": client }).to_string() +} + +fn global_log_context() -> String { + json!({ "client": "claude-global", "server": MCP_KEY }).to_string() +} + +/// Claude Code local scope 条目:项目专用地址 + 稳定 helper。local 优先于 user 级全局注册且不进仓库。 +fn claude_local_server_entry(server_url: &str, header_helper: &str) -> Value { + json!({ + "type": "http", + "url": server_url, + "headersHelper": header_helper, + }) +} + +/// Claude 全局配置文件位置,与 Claude 的解析规则一致:旧版 `<配置目录>/.config.json` 存在则优先, +/// 否则为 `$CLAUDE_CONFIG_DIR`(或用户目录)下的 `.claude.json`。只用于只读检查。 +fn claude_config_path() -> Option { + let config_dir = std::env::var_os("CLAUDE_CONFIG_DIR").map(PathBuf::from); + let home = std::env::var_os("USERPROFILE").map(PathBuf::from)?; + let legacy = config_dir + .clone() + .unwrap_or_else(|| home.join(".claude")) + .join(".config.json"); + if legacy.is_file() { + return Some(legacy); + } + Some(config_dir.unwrap_or(home).join(".claude.json")) +} + +/// 只读读取 Claude 全局配置(容忍 BOM)。读不到或解析失败返回 None,调用方按「未知」保守处理。 +fn read_claude_config() -> Option { + let text = std::fs::read_to_string(claude_config_path()?).ok()?; + serde_json::from_str(text.trim_start_matches('\u{feff}')).ok() +} + +/// 只读扫描:所有项目键下指向本项目专用地址、且经稳定 helper 认证的炼境托管条目。按 URL 匹配, +/// 不复刻 Claude 的项目键推导(Claude 以 git 根为键、worktree 映射到主仓库根、路径做 NFC 归一化)。 +fn managed_claude_entries(config: &Value, project_id: &str) -> Vec<(String, Value)> { + let suffix = format!("/mcp/projects/{}", urlencoding::encode(project_id)); + let Some(projects) = config.get("projects").and_then(Value::as_object) else { + return Vec::new(); + }; + projects + .iter() + .filter_map(|(key, project)| { + let entry = project.get("mcpServers")?.get(MCP_KEY)?; + let url = entry.get("url").and_then(Value::as_str)?; + let helper = entry + .get("headersHelper") + .and_then(Value::as_str) + .unwrap_or(""); + (url.starts_with("http://127.0.0.1:") + && url.ends_with(&suffix) + && helper.contains(MCP_HEADER_SHIM_NAME)) + .then(|| (key.clone(), entry.clone())) + }) + .collect() +} + +fn claude_global_registered(config: &Value) -> bool { + config + .get("mcpServers") + .and_then(|servers| servers.get(MCP_KEY)) + .is_some() +} + +/// claude CLI 单次调用上限:卡住的子进程会被终止,不让它拖住持锁的接入操作。 +const CLAUDE_CLI_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20); + +/// 运行 claude CLI(不弹窗、stdin 置空、带超时)。成功返回输出,失败返回合并后的输出。 +fn run_claude_cli( + claude_bin: &str, + git_bash: &str, + cwd: Option<&std::path::Path>, + args: &[&str], +) -> Result { + use std::io::Read; + let mut command = crate::silent_cmd(claude_bin); + if let Some(dir) = cwd { + command.current_dir(dir); + } + let mut child = command + .args(args) + .env("CLAUDE_CODE_GIT_BASH_PATH", git_bash) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|error| format!("执行 claude CLI 失败: {error}"))?; + let deadline = std::time::Instant::now() + CLAUDE_CLI_TIMEOUT; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if std::time::Instant::now() < deadline => { + std::thread::sleep(std::time::Duration::from_millis(100)); } - db::log_event( - "mcp_inject", - "warn", - &format!("claude mcp add 非零退出: {}", combined), - Some(MCP_KEY), - ); - false + 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}")), } - Err(e) => { - db::log_event( - "mcp_inject", - "warn", - &format!("执行 claude mcp add 失败: {}", e), - Some(MCP_KEY), - ); - false + }; + let mut output = String::new(); + if let Some(mut stdout) = child.stdout.take() { + let _ = stdout.read_to_string(&mut output); + } + if let Some(mut stderr) = child.stderr.take() { + let _ = stderr.read_to_string(&mut output); + } + if status.success() { + Ok(output) + } else { + Err(output) + } +} + +/// 统一分隔符与大小写,便于比较 ~/.claude.json 的项目键与项目根目录。 +fn normalize_root_key(path: &str) -> String { + path.replace('\\', "/").trim_end_matches('/').to_lowercase() +} + +/// Claude 以 git 根为项目键,必为项目根本身或其祖先目录。 +fn key_covers_root(key: &str, root: &str) -> bool { + let key = normalize_root_key(key); + let root = normalize_root_key(root); + root == key || root.starts_with(&format!("{key}/")) +} + +/// 炼境项目路由 URL 里的 project_id;不是炼境项目路由则为 None(视为手写配置)。 +fn lianjing_project_of(url: &str) -> Option { + let rest = url.strip_prefix("http://127.0.0.1:")?; + let encoded = rest.split_once("/mcp/projects/")?.1.trim_end_matches('/'); + urlencoding::decode(encoded).ok().map(|id| id.into_owned()) +} + +#[derive(Debug, PartialEq)] +enum ClaudeEffective { + /// 本目录生效的是 local 条目,附其 URL + Local(String), + /// 本目录没有 local 条目(生效的是 user/project 级,或根本没有 lian-jing) + NotLocal, +} + +/// 纯函数:解析 `claude mcp get lian-jing` 的输出。格式无法识别时返回 None(按「未知」处理,不写入)。 +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 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 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 { + 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_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 { + warn("claude CLI 或 git-bash 未找到,跳过 Claude 项目级 MCP 同步".into()); + return; + }; + 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"], + ) + }; + 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 { "原条目未能恢复" } + )) + } + }, + }, + }, + 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) => warn(format!("Claude 项目级 MCP 同步: {error}")), + } +} + +/// 进程内所有「炼境 MCP 接入」写操作(注入、移除、根目录迁移)串行执行: +/// 检查 token → 写项目文件 → 写客户端配置作为一个整体,不与其他接入操作交错。 +static MCP_OP_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn mcp_op_lock() -> std::sync::MutexGuard<'static, ()> { + MCP_OP_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +// ── DSH 机器级配置托管块 ────────────────────────────────────────────────────── +// DSH 配置无法按项目拆分,但 !!js 在加载时以启动目录求值。项目身份只认炼境登记表:托管块内嵌 +// 「已登记项目根目录 → project_id」白名单(非秘密),从启动目录向上精确匹配根目录才连项目专用地址 +// 并经稳定 helper 携带认证;任意目录里的 .lian-jing/context.json 不作为授权依据。未命中则回退全局地址, +// 保持 GUI 等非项目启动方式的现有行为。项目增删、端口变更时重写白名单。 + +/// 白名单键规范化,与 JS 端 `path.resolve(d).toLowerCase().replace(/\\+$/, '')` 对齐: +/// 反斜杠分隔、合并重复分隔符(保留 UNC 前缀 \\)、去尾部分隔符、小写。盘符根 E:\ 两端都得到 e:。 +fn normalize_dsh_root(path: &str) -> String { + let unified = path.replace('/', "\\"); + let (prefix, rest) = match unified.strip_prefix("\\\\") { + Some(rest) => ("\\\\", rest), + None => ("", unified.as_str()), + }; + let mut collapsed = String::with_capacity(rest.len()); + for ch in rest.chars() { + if ch == '\\' && collapsed.ends_with('\\') { + continue; + } + collapsed.push(ch); + } + format!("{prefix}{}", collapsed.trim_end_matches('\\')).to_lowercase() +} + +/// DSH 白名单:已登记、非 WSL、根目录 context.json 的 project_id 与登记一致的 Windows 项目。 +/// - 普通项目须已有项目 token(helper 能工作的前提)才进入白名单——退组或移除时 token 被删,剔除即持久; +/// - 控制面项目(mcp_scope=global)写入 None 作为停止哨兵:命中即停止向上查找,回退全局地址, +/// 不会越过它匹配到已登记的上级目录。 +fn dsh_project_roots( + conn: &rusqlite::Connection, + store: &impl crate::credential_store::CredentialStore, + exclude: Option<&str>, +) -> Vec<(String, Option)> { + let rows: Vec<(String, String)> = conn + .prepare( + "SELECT id, win_path FROM project_workspaces + WHERE win_path IS NOT NULL AND COALESCE(platform, '') != 'wsl' + ORDER BY id", + ) + .and_then(|mut stmt| { + stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .map(|rows| rows.filter_map(Result::ok).collect()) + }) + .unwrap_or_default(); + rows.into_iter() + .filter(|(id, _)| Some(id.as_str()) != exclude) + .filter_map(|(id, win_path)| { + let root = validate_registered_path(&win_path, "win_path").ok()?; + // 每个项目只读一次 context.json,同时取 project_id 与 mcp_scope + let context = read_context_object( + &root.join(".lian-jing").join("context.json"), + "canonical 项目上下文", + ) + .ok()?; + if context.get("project_id").and_then(Value::as_str) != Some(id.as_str()) { + return None; + } + let 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, 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))) + .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;", + "if (profile === 'headless') for (let d = path.resolve(process.cwd()), p; d !== p; p = d, d = path.dirname(d)) {", + " const key = d.toLowerCase().replace(/\\\\+$/, '');", + " if (Object.prototype.hasOwnProperty.call(roots, key)) { id = roots[key] || undefined; break; }", + "}", + ]; + let global_url = format!("http://127.0.0.1:{port}/mcp"); + let url_return = + format!("return id ? '{global_url}/projects/' + encodeURIComponent(id) : '{global_url}';"); + let shim_line = + format!("const shim = path.join(base, '.lian-jing', 'bin', '{MCP_HEADER_SHIM_NAME}');"); + let header_body = [ + "if (!id) return {};", + "const base = process.env.USERPROFILE, sys = process.env.SystemRoot;", + "if (!base || !path.isAbsolute(base) || !sys || !path.isAbsolute(sys)) return {};", + shim_line.as_str(), + "const cmd = path.join(sys, 'System32', 'cmd.exe');", + "const crypto = process.getBuiltinModule('crypto'), cp = process.getBuiltinModule('child_process');", + "const digest = crypto.createHash('sha256').update(id).digest('hex');", + "// Node 拒绝直接执行 .cmd(EINVAL),须经 cmd /d /s /c 原样传参。", + "const out = cp.execFileSync(cmd, ['/d', '/s', '/c', '\"\"' + shim + '\" ' + digest + '\"'],", + " { encoding: 'utf8', timeout: 15000, windowsHide: true, windowsVerbatimArguments: true, stdio: ['ignore', 'pipe', 'ignore'] });", + "return { Authorization: String(JSON.parse(out).Authorization) };", + ]; + + let mut lines: Vec = vec![ + DSH_MANAGED_START.to_string(), + "# 炼境托管:按 DSH 启动目录匹配已登记项目根目录,连项目专用地址并携带认证;未命中回退全局地址。勿手改,会被炼境覆盖。".into(), + "- insert:".into(), + " - id: mcp-lian-jing".into(), + " name: '@deepseek-ai/dsh-mcp-client'".into(), + " config:".into(), + " transport: streamable-http".into(), + format!(" serverName: {MCP_KEY}"), + " url: !!js |".into(), + " (() => {".into(), + ]; + lines.extend(indent(12, &find_project_id)); + lines.extend(indent(12, &[url_return.as_str()])); + lines.extend(indent(10, &["})()"])); + lines.extend(indent(8, &["headers: !!js |"])); + lines.extend(indent(10, &["(() => {"])); + lines.extend(indent(12, &["try {"])); + lines.extend(indent(14, &find_project_id)); + lines.extend(indent(14, &header_body)); + lines.extend(indent(12, &["} catch (e) {"])); + lines.extend(indent( + 14, + &[ + "process.stderr.write('[lian-jing] mcp header helper failed: status=' + (e.status ?? '') + ' code=' + (e.code ?? '') + '\\n');", + "return {};", + ], + )); + lines.extend(indent(12, &["}"])); + lines.extend(indent(10, &["})()"])); + lines.push(DSH_MANAGED_END.to_string()); + let mut block = lines.join("\n"); + block.push('\n'); + block +} + +/// 用 YAML 解析判断托管块之外是否已有 lian-jing 条目(id 或 serverName,任意引号/注释/流式写法)。 +/// 解析失败时保守拒绝写入,不冒险追加。 +fn has_manual_lian_jing(outside_block: &str) -> Result { + if outside_block.trim().is_empty() { + return Ok(false); + } + let doc: serde_yaml::Value = serde_yaml::from_str(outside_block) + .map_err(|error| format!("DSH 补丁文件无法解析,炼境不写入: {error}"))?; + let Some(items) = doc.as_sequence() else { + return Ok(false); + }; + Ok(items.iter().any(|item| { + item.get("insert") + .and_then(serde_yaml::Value::as_sequence) + .is_some_and(|entries| { + entries.iter().any(|entry| { + entry.get("id").and_then(serde_yaml::Value::as_str) == 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 => { + 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)时跳过;返回是否写入或已是最新。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")) + .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 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}"))?; + } + 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()) + .and_then(|conn| ensure_dsh_patch(&conn, crate::mcp_server::read_port(), exclude)); + if let Err(error) = result { + db::log_event( + CLIENT_SYNC_LOG, + "warn", + &format!("DSH 托管块刷新失败: {error}"), + Some(&json!({ "client": "dsh" }).to_string()), + ); } } @@ -332,14 +997,19 @@ 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() .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); } } @@ -464,6 +1134,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> { + let _guard = mcp_op_lock(); + inject_project_mcp_locked(project_id, true) +} + +/// 调用方须持有 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)?; @@ -471,12 +1148,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,14 +1181,50 @@ 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 读取,仅保留作兼容。 + // 控制面项目(mcp_scope=global)不写 local 条目,并清理炼境曾写入的旧条目,保留全局与跨项目工具。 try_register_globally(port); + let root = project_root(project_id)?; + let desired = (!project_wants_global_scope(&root)).then(|| (server_url, header_helper)); + sync_claude_local(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> { + let _guard = mcp_op_lock(); + // 先于登记删除、也先于删除本地文件夹同步清理客户端侧条目:残留的 local 条目会遮蔽全局注册并指向 404 地址。 + if let Ok(root) = project_root(project_id) { + sync_claude_local(project_id, &root, None); + } + // token 最先删:DSH 白名单剔除与根目录迁移判定都以 token 为准,后面的文件清理即使失败也不影响这一点 + let token_removed = crate::credential_store::delete_mcp_auth_token_for_project( + &crate::credential_store::WindowsCredentialStore, + project_id, + ); + // 登记此时仍在,exclude 立即剔除;token 已删,之后的刷新也不会再把它加回白名单 + refresh_dsh_patch(Some(project_id)); + let mut errors: Vec = Vec::new(); + if let Err(error) = remove_project_files_mcp(project_id) { + errors.push(error); + } + if let Err(error) = token_removed { + errors.push(error); + } + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join(";")) + } +} + +/// 清理项目文件中的炼境 MCP 配置(Codex 托管块与 .claude/settings.json 兼容条目)。 +fn remove_project_files_mcp(project_id: &str) -> Result<(), String> { remove_codex_mcp_config(&codex_config_path(project_id)?)?; let path = settings_path(project_id)?; if path.exists() { @@ -529,10 +1238,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 瘦身) @@ -574,8 +1280,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() { @@ -607,13 +1315,76 @@ 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) { + let _guard = mcp_op_lock(); + // 快照取自锁外:锁内复查成员关系,已被移出组的项目不再重新注入(避免重建刚删除的 token) + let still_member = db::pool() + .get() + .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), + ) + .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)); } } + // 逐项目注入时跳过了 DSH 刷新(内嵌端口与白名单),此处统一刷新一次,失败只记日志 + refresh_dsh_patch(None); errors } +/// 项目根目录或平台变更后调用:清理旧根下本项目的 Claude local 托管条目(旧目录已不存在时无法向 Claude +/// 确认实际条目,只记录残留与清理命令);已接入(有 token)的项目在新根重新注入(会刷新 DSH 白名单), +/// 未接入的只刷新白名单。新根的「已同步」判定只认新根或其祖先目录键下的条目,旧键下的残留不会挡住写入。 +/// 重新注入会跑 claude CLI 做全局注册,调用方应放在后台线程。持锁执行:与并发的移除/删除串行, +/// 移除先完成则 token 已不在,这里就不会再注入。 +pub fn on_project_root_changed(project_id: &str, old_root: Option) { + let _guard = mcp_op_lock(); + if let Some(root) = old_root { + sync_claude_local(project_id, &root, None); + } + 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_locked(project_id, true) { + // 属于注入结果,归入 mcp_inject;注入中途失败可能没走到刷新,这里补一次 + db::log_event( + "mcp_inject", + "warn", + &format!("项目根目录变更后重新注入失败: {error}"), + Some(&inject_log_context(project_id, "all")), + ); + refresh_dsh_patch(None); + } +} + +/// 纯函数:update_project 后是否需要迁移客户端配置。只要能解析出新根且与旧根不同就迁移; +/// 旧根解析不出(例如旧路径为空)也照样迁移,只是没有旧条目要清理。 +pub(crate) fn project_root_changed(old_root: Option<&PathBuf>, new_root: Option<&PathBuf>) -> bool { + new_root.is_some() && new_root != old_root +} + #[cfg(test)] mod tests { use super::*; @@ -719,6 +1490,337 @@ 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, + "\"%USERPROFILE%\\.lian-jing\\bin\\lianjing-mcp-header.cmd\" qa-digest" + ); + assert!(!helper.contains(":\\"), "不得含盘符绝对路径"); + assert!(!helper.to_lowercase().contains("users"), "不得含用户目录"); + assert!( + !helper.contains("%LOCALAPPDATA%"), + "炼境在 MSIX 应用进程树里启动时,AppData 下新建的目录会被重定向到该应用的私有目录" + ); + } + + #[test] + fn header_shim_forwards_digest_and_exit_code() { + let ascii = render_header_shim(std::path::Path::new("C:\\Apps\\Lian Jing\\app.exe")); + assert_eq!( + ascii, + "@echo off\r\n\"C:\\Apps\\Lian Jing\\app.exe\" --lianjing-mcp-header %1 & call exit /b %%ERRORLEVEL%%\r\n", + "调用与退出同一行:入口被并发改写时 cmd 不会再按旧偏移读后续行" + ); + let unicode = render_header_shim(std::path::Path::new("C:\\炼境\\app.exe")); + assert!( + unicode.contains("chcp 65001 >nul\r\n"), + "非 ASCII 路径须切 UTF-8 代码页" + ); + let percent = render_header_shim(std::path::Path::new("C:\\Users\\a%b\\app.exe")); + assert!( + percent.contains("\"C:\\Users\\a%%b\\app.exe\" --lianjing-mcp-header %1"), + "路径 % 须转义" + ); + } + + #[test] + fn global_scope_projects_are_excluded_and_roots_follow_registry() { + let base = std::env::temp_dir().join(format!("lianjing_dsh_roots_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let conn = crate::db::conn_with_schema(); + let store = crate::credential_store::MemoryCredentialStore::new(); + for id in ["p1", "drift", "gone"] { + crate::credential_store::get_or_create_mcp_auth_token(&store, id).unwrap(); + } + // notoken:已绑定但没有 token(例如退组后 token 已删),不得进入白名单 + for (id, scope, bound) in [ + ("p1", None, true), + ("ctrl", Some("global"), true), + ("drift", None, false), + ("gone", None, true), + ("notoken", None, true), + ] { + let root = base.join(id); + std::fs::create_dir_all(root.join(".lian-jing")).unwrap(); + let mut ctx = json!({ "project_id": if bound { id } else { "someone-else" }, "schema_version": 1 }); + if let Some(scope) = scope { + ctx["mcp_scope"] = json!(scope); + } + std::fs::write(root.join(".lian-jing/context.json"), ctx.to_string()).unwrap(); + conn.execute( + "INSERT INTO project_profiles (id, name) VALUES (?1, 'QA')", + [format!("{id}-profile")], + ) + .unwrap(); + conn.execute( + "INSERT INTO project_workspaces (id, profile_id, win_path) VALUES (?1, ?2, ?3)", + rusqlite::params![id, format!("{id}-profile"), root.display().to_string()], + ) + .unwrap(); + } + assert!(project_wants_global_scope(&base.join("ctrl"))); + let roots = dsh_project_roots(&conn, &store, Some("gone")); + let key = |id: &str| normalize_dsh_root(&base.join(id).display().to_string()); + 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 = claude_local_server_entry( + "http://127.0.0.1:27190/mcp/projects/p1", + &shim_helper_command("d1"), + ); + assert_eq!(entry["type"], "http"); + assert_eq!(entry["url"], "http://127.0.0.1:27190/mcp/projects/p1"); + assert!(entry["headersHelper"] + .as_str() + .unwrap() + .contains("%USERPROFILE%\\.lian-jing\\bin\\")); + } + + #[test] + fn managed_claude_entries_match_by_project_url_under_any_key() { + let managed = claude_local_server_entry( + "http://127.0.0.1:27190/mcp/projects/p%201", + &shim_helper_command("d1"), + ); + let moved_port = claude_local_server_entry( + "http://127.0.0.1:28000/mcp/projects/p%201", + &shim_helper_command("d1"), + ); + let config = json!({ + "mcpServers": { "lian-jing": { "type": "sse", "url": "http://127.0.0.1:27190/mcp" } }, + "projects": { + // Claude 以 git 根 / 主仓库根为键:条目可能在任意键下,按 URL 认领 + "E:/repos/main": { "mcpServers": { "lian-jing": managed.clone() } }, + "E:/repos/other": { "mcpServers": { "lian-jing": moved_port.clone() } }, + "E:/repos/p10": { "mcpServers": { "lian-jing": claude_local_server_entry( + "http://127.0.0.1:27190/mcp/projects/p%2010", &shim_helper_command("d2")) } }, + "E:/repos/manual": { "mcpServers": { "lian-jing": { "type": "http", "url": "http://127.0.0.1:27190/mcp/projects/p%201" } } }, + "E:/repos/none": { "lastCost": 1 } + } + }); + let found = managed_claude_entries(&config, "p 1"); + let keys: Vec<&str> = found.iter().map(|(key, _)| key.as_str()).collect(); + assert_eq!( + keys, + vec!["E:/repos/main", "E:/repos/other"], + "端口变化仍认领;前缀相同的他项目、无 shim 的手写条目不认领" + ); + assert_eq!(found[0].1, managed); + assert!(claude_global_registered(&config)); + assert!(!claude_global_registered(&json!({ "projects": {} }))); + assert!(managed_claude_entries(&json!({}), "p 1").is_empty()); + } + + #[test] + fn claude_mcp_get_output_is_parsed_by_effective_scope() { + // 样本取自 claude 2.x 真实输出(2026-10-01) + let local = "lian-jing:\n Scope: Local config (private to you in this project)\n Status: ✔ Connected\n Type: http\n URL: http://127.0.0.1:27190/mcp/projects/p%201\n\nTo remove this server, run: claude mcp remove \"lian-jing\" -s local\n"; + assert_eq!( + parse_claude_mcp_get(local), + Some(ClaudeEffective::Local( + "http://127.0.0.1:27190/mcp/projects/p%201".into() + )) + ); + let user = "lian-jing:\r\n Scope: User config (available in all your projects)\r\n Type: sse\r\n URL: http://127.0.0.1:27190/mcp\r\n"; + assert_eq!(parse_claude_mcp_get(user), Some(ClaudeEffective::NotLocal)); + let missing = "No MCP server found with name: \"lian-jing\". Configured servers: a, b"; + assert_eq!( + parse_claude_mcp_get(missing), + Some(ClaudeEffective::NotLocal) + ); + assert_eq!( + parse_claude_mcp_get("Error: spawn EPERM"), + None, + "无法识别则按未知处理,不写入" + ); + assert_eq!( + parse_claude_mcp_get("lian-jing:\n Scope: Local config\n Type: stdio\n"), + None, + "local 条目缺 URL 时无法判定归属" + ); + } + + #[test] + fn lianjing_project_routes_are_recognized_and_decoded() { + assert_eq!( + lianjing_project_of("http://127.0.0.1:27190/mcp/projects/p%201"), + Some("p 1".into()) + ); + assert_eq!( + lianjing_project_of("http://127.0.0.1:1/mcp/projects/abc/"), + Some("abc".into()) + ); + assert_eq!( + lianjing_project_of("http://127.0.0.1:27190/mcp"), + None, + "全局路由不属于任何项目" + ); + assert_eq!( + lianjing_project_of("http://localhost:27190/mcp/projects/abc"), + None, + "非炼境地址视为手写" + ); + assert_eq!( + lianjing_project_of("https://example.com/mcp/projects/abc"), + None + ); + } + + #[test] + fn claude_key_covers_root_and_its_descendants_only() { + assert!(key_covers_root( + "E:/code-project/demo", + "E:\\code-project\\demo\\" + )); + assert!( + key_covers_root("e:\\code-project", "E:\\Code-Project\\demo"), + "git 根是祖先目录" + ); + assert!( + !key_covers_root("E:/code-project/demo", "E:\\code-project\\demo-2"), + "前缀相同的兄弟目录不算" + ); + assert!( + !key_covers_root("E:/code-project/demo/sub", "E:\\code-project\\demo"), + "子目录的键不覆盖父目录" + ); + } + + #[test] + fn project_root_change_detection() { + let old = PathBuf::from("E:\\old"); + let new = PathBuf::from("E:\\new"); + assert!(project_root_changed(Some(&old), Some(&new))); + assert!( + project_root_changed(None, Some(&new)), + "旧根解析不出也要迁移" + ); + assert!(!project_root_changed(Some(&old), Some(&old))); + assert!( + !project_root_changed(Some(&old), None), + "新根解析不出则不动" + ); + } + + #[test] + fn dsh_block_is_valid_yaml_with_registry_routing_and_shim_auth() { + let roots = vec![ + ("e:\\code-project".to_string(), None), + ("e:\\code-project\\demo".to_string(), Some("p1".to_string())), + ]; + let 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("context.json"), + "身份只认登记白名单,不读目录里的 context.json" + ); + assert!(block.contains("'http://127.0.0.1:27190/mcp/projects/' + encodeURIComponent(id)")); + assert!( + block.contains(": 'http://127.0.0.1:27190/mcp';"), + "未命中回退全局地址" + ); + assert!(block.contains("path.isAbsolute(base)") && block.contains("'System32', 'cmd.exe'")); + assert!( + block.contains("ctx.get('profileContext')?.name") + && block.contains("if (profile === 'headless') for ("), + "多工作区 profile(web/acp/tui/桌面版)不按启动目录路由,防止跨项目带认证" + ); + assert!( + block.contains("process.env.USERPROFILE") && block.contains("'.lian-jing', 'bin'"), + "与 Codex/Claude 共用用户目录下的入口" + ); + assert!( + block.contains(MCP_HEADER_SHIM_NAME) + && block.contains("windowsVerbatimArguments: true") + ); + assert!(!block.contains("Bearer "), "托管块不得含任何 token"); + } + + #[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, + "幂等" + ); + + 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"); + } + #[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..ad4e572 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; @@ -1532,6 +1603,19 @@ async fn handle_scoped_sse_message( headers: HeaderMap, Json(req): Json, ) -> Response { + // 握手后项目可能被删除:每条消息复核登记。只移除确实属于该项目的会话, + // 不能凭请求里任意给出的 session_id 删掉别的会话。 + if !scoped_project_registered(&project_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( &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