Merge pull request 'feat(mcp/parity): 三端经稳定 helper 入口连项目专用地址(P1 + P3)' (#24) from feat/agent-client-parity into master
All checks were successful
Push & PR Check / check (push) Successful in 1m5s

This commit is contained in:
lanrtop 2026-10-02 12:05:16 +08:00
commit 53af8c36d8
16 changed files with 1457 additions and 94 deletions

View File

@ -6,7 +6,7 @@ description: >
项目自身保留业务规则、测试、部署声明与交付决策。炼境本身亦被纳入其中, 项目自身保留业务规则、测试、部署声明与交付决策。炼境本身亦被纳入其中,
形成可自我进化的开发智能闭环。 形成可自我进化的开发智能闭环。
iteration: 1 iteration: 1
updated: 2026-09-30 updated: 2026-10-01
agent_button_carrier: tauri agent_button_carrier: tauri
@ -511,11 +511,20 @@ modules:
name: 三端客户端对等 name: 三端客户端对等
area: infra area: infra
status: in_progress status: in_progress
progress: 0 progress: 38
updated: 2026-09-30 updated: 2026-10-01
note: "目标:子项目在 Claude Code、Codex、DSH 三端都能继续开发。2026-09-30 无头基线探针:Codex 全绿;Claude 与 DSH 连全局 /mcp、看不到开发变量工具;Claude 缺 lian-jing skill。先统一连接地址,再处理认证与 skill 投影,并把探针固化为可重跑检查。" 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] 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: edges:
- from: project-resource-broker - from: project-resource-broker
to: project-dev-variables to: project-dev-variables
@ -854,3 +863,7 @@ edges:
- from: project-dev-variables - from: project-dev-variables
to: agent-client-parity to: agent-client-parity
type: dependency type: dependency
# P4 探针固化、P8 一键接入以本体与投影分离(A1 的 CLI 投影)为承载。
- from: architecture-evolution
to: agent-client-parity
type: dependency

View File

@ -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 非零退出时连接不建立。 - **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。 - **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 复现。 - 第二轮两项的对抗复核 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) ### ✅ P1 — 统一连接项目专用地址(Claude + DSH)
- status: todo - status: done
- complexity: M - complexity: M
- files: src-tauri/src/mcp_inject.rs, docs/ai-context/agent-button-system.md - files: src-tauri/src/mcp_inject.rs, docs/ai-context/agent-button-system.md
- depends: H1, H2 - depends: H1, H2
- acceptance: "agent_verify: [三端无头探针] 在 enterprise 目录 Claude 与 DSH 的 C 项均看到开发变量工具(项目工具集),E 项成功;项目目录外的会话不获得他项目工具。" - acceptance: "agent_verify: [三端无头探针] 在 enterprise 目录 Claude 与 DSH 的 C 项均看到开发变量工具(项目工具集),E 项成功;项目目录外的会话不获得他项目工具。"
- 注:吸收原 project-dev-variables V17(Claude 以 local scope + headersHelper 取值)。 - 注:吸收原 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 投影 ### ✅ P2 — Claude skill 投影
- status: done - 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。" - 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(基线探针时为不存在)。" - 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 入口,项目配置去本机路径 ### ✅ P3 — 稳定 helper 入口,项目配置去本机路径
- status: in_progress - status: done
- complexity: M - complexity: M
- files: src-tauri/src/mcp_inject.rs, src-tauri/src/lib.rs, src-tauri/src/commands/onboarding.rs - files: src-tauri/src/mcp_inject.rs, src-tauri/src/lib.rs, src-tauri/src/commands/onboarding.rs
- depends: H4 - 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。 - 注:即原 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 — 三端对等探针固化为炼境能力 ### 📋 P4 — 三端对等探针固化为炼境能力
- status: todo - status: todo
@ -132,3 +139,20 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。
- depends: P1 - depends: P1
- acceptance: "agent_verify: [三端无头探针] 在 initialize 下发唯一标记的 instructions,判定三端是否把它带入模型上下文。全部支持则把引导提示迁到服务器说明,本地 skill 只保留 MCP 不可达时的排障步骤;部分支持则如实记录、保留 skill 引导。" - acceptance: "agent_verify: [三端无头探针] 在 initialize 下发唯一标记的 instructions,判定三端是否把它带入模型上下文。全部支持则把引导提示迁到服务器说明,本地 skill 只保留 MCP 不可达时的排障步骤;部分支持则如实记录、保留 skill 引导。"
- 为什么:Claude 已实证会把 lian-jing 的服务器说明带入上下文;若三端皆支持,引导提示可由炼境集中维护。 - 为什么: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/<id> 请求。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 相同,但需按「管家准入」论证。

View File

@ -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,存量随相关改动迁移,不单独立卡。

View File

@ -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>'`——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>'`——digest = sha256(project_id) 非秘密,整行在任何机器、任何构建下相同,可安全提交;换构建无需逐项目重注入。
2. **Claude(V17 主体)**:inject_mcp 在项目目录以 local scope 写入 `lian-jing`(scoped url + headersHelper 指向同一 shim;local 条目不进仓库,可用绝对路径);保留 user scope 全局注册作兜底。 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 再评估。 3. **DSH:不写静态 token**。静态 header 意味着 bearer 明文落在用户目录配置文件,且 DSH 配置无法按项目拆分;DSH 取值改走 V14–V16 落地路径(写入被忽略文件、不返回值、无需认证),待 dsh-mcp-client 支持动态 header 再评估。
4. **推论:V14–V16 是唯一全客户端通用的路径**(落地不返回值,无需认证);读取明文的有界例外只服务支持动态 header 的客户端。 4. **推论:V14–V16 是唯一全客户端通用的路径**(落地不返回值,无需认证);读取明文的有界例外只服务支持动态 header 的客户端。

View File

@ -1,4 +1,5 @@
{ {
"project_id": "dev-manager-tauri", "project_id": "dev-manager-tauri",
"schema_version": 1 "schema_version": 1,
"mcp_scope": "global"
} }

View File

@ -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_server.rs MCP Server 路由层 + SSE 协议(agent 调用炼境的入口)
src-tauri/src/mcp_tools.rs MCP 工具 schema + dispatch + 实现(工具清单以 tools_list_result 为准) 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_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) src-tauri/resources/onboarding/ 接入包分发模板(AGENTS.md/lefthook/git-workflow)
.blueprint/ 项目蓝图(manifest + 模块详情 + 任务卡) .blueprint/ 项目蓝图(manifest + 模块详情 + 任务卡)
docs/ai-context/ AI 上下文文档(速查卡、契约映射) docs/ai-context/ AI 上下文文档(速查卡、契约映射)

View File

@ -6,7 +6,7 @@ contracts:
- label: API 接口(Axum) - label: API 接口(Axum)
paths: paths:
- "src-tauri/src/mcp_server.rs" - "src-tauri/src/mcp_server.rs"
doc: "MCP 仅监听 127.0.0.1;新项目使用 /mcp/projects/<project_id> scoped route,连接 scope 拒绝跨项目参数和全局管理写工具。旧 /mcp 只作兼容迁移。read_project_dev_variable 与 read_project_test_account 是两种有界开发值读取:仅 scoped 路由、匹配项目的本机 bearer 认证后单项读取;认证失败在 dispatch 前拒绝。路由变更需同步 Agent 按钮体系文档。" doc: "MCP 仅监听 127.0.0.1;新项目使用 /mcp/projects/<project_id> 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 工具接口 - label: MCP 工具接口
paths: paths:
@ -25,7 +25,7 @@ contracts:
- ".lian-jing/context.json" - ".lian-jing/context.json"
- ".lian-jing/capability-packs.json" - ".lian-jing/capability-packs.json"
- ".claude/liangjing.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 接口(前后端契约) - label: Tauri command 接口(前后端契约)
paths: paths:

View File

@ -17,7 +17,8 @@ pub struct BuffStatus {
// ── Tauri 命令 ─────────────────────────────────────────────────────────────── // ── Tauri 命令 ───────────────────────────────────────────────────────────────
/// 向项目施加蓝图 Buff:注入 CONVENTIONS + CLAUDE.md,写入 DB /// 向项目施加蓝图 Buff:注入 CONVENTIONS + CLAUDE.md,写入 DB
#[tauri::command] // 会经 claude CLI 写客户端配置:在线程池执行,不占界面线程
#[tauri::command(async)]
pub fn apply_blueprint_buff( pub fn apply_blueprint_buff(
project_path: String, project_path: String,
project_id: String, project_id: String,

View File

@ -72,10 +72,10 @@ pub fn update_group(id: String, input: GroupInput) -> Result<(), String> {
Ok(()) Ok(())
} }
#[tauri::command] // 会经 claude CLI 清理客户端配置:在线程池执行,不占界面线程
#[tauri::command(async)]
pub fn delete_group(id: String) -> Result<(), String> { pub fn delete_group(id: String) -> Result<(), String> {
let conn = pool().get().map_err(|e| e.to_string())?; let conn = pool().get().map_err(|e| e.to_string())?;
// 先清除所有成员的 MCP 配置(DB cascade 删除之前)
let member_ids: Vec<String> = conn let member_ids: Vec<String> = conn
.prepare("SELECT project_id FROM project_group_map WHERE group_id = ?1") .prepare("SELECT project_id FROM project_group_map WHERE group_id = ?1")
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
@ -83,18 +83,24 @@ pub fn delete_group(id: String) -> Result<(), String> {
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
.filter_map(|r| r.ok()) .filter_map(|r| r.ok())
.collect(); .collect();
for project_id in &member_ids { // 单一 lian-jing key 为全项目共用:成员还属于其他组时保留配置。删组前先算出需清理的成员
// 单一 lian-jing key 为全项目共用:成员还属于其他组时保留配置 let to_clear: Vec<&String> = member_ids
let in_other_group: i64 = conn .iter()
.query_row( .filter(|project_id| {
conn.query_row(
"SELECT COUNT(*) FROM project_group_map WHERE project_id = ?1 AND group_id != ?2", "SELECT COUNT(*) FROM project_group_map WHERE project_id = ?1 AND group_id != ?2",
params![project_id, id], params![project_id, id],
|row| row.get(0), |row| row.get::<_, i64>(0),
) )
.unwrap_or(0); .unwrap_or(0)
if in_other_group > 0 { == 0
continue; })
} .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) { 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(); let ctx = serde_json::json!({"project_id": project_id, "group_id": id}).to_string();
log_event( 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(()) Ok(())
} }
@ -181,6 +185,7 @@ pub fn ensure_standalone_group(project_id: &str) -> Result<(), String> {
params![project_id, group_id], params![project_id, group_id],
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
drop(conn);
if let Err(e) = mcp_inject::inject_project_mcp(project_id) { 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(); 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(()) Ok(())
} }
#[tauri::command] // 会经 claude CLI 写客户端配置:在线程池执行,不占界面线程
#[tauri::command(async)]
pub fn add_group_member( pub fn add_group_member(
project_id: String, project_id: String,
group_id: String, group_id: String,
@ -207,6 +213,7 @@ pub fn add_group_member(
params![project_id, group_id, role], params![project_id, group_id, role],
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
drop(conn);
// DB 写入成功后注入 MCP 配置(非致命,注入失败不影响成员关系) // DB 写入成功后注入 MCP 配置(非致命,注入失败不影响成员关系)
let inject_warning = match mcp_inject::inject_project_mcp(&project_id) { let inject_warning = match mcp_inject::inject_project_mcp(&project_id) {
Ok(()) => None, Ok(()) => None,
@ -220,7 +227,8 @@ pub fn add_group_member(
Ok(inject_warning) Ok(inject_warning)
} }
#[tauri::command] // 会经 claude CLI 清理客户端配置:在线程池执行,不占界面线程
#[tauri::command(async)]
pub fn remove_group_member(project_id: String, group_id: String) -> Result<(), String> { pub fn remove_group_member(project_id: String, group_id: String) -> Result<(), String> {
let conn = pool().get().map_err(|e| e.to_string())?; let conn = pool().get().map_err(|e| e.to_string())?;
// 仅当项目退出后不再属于任何组时才清除 MCP 配置(单一 lian-jing key 为全项目共用) // 仅当项目退出后不再属于任何组时才清除 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), |row| row.get(0),
) )
.unwrap_or(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 in_other_group == 0 {
if let Err(e) = mcp_inject::remove_project_mcp(&project_id) { if let Err(e) = mcp_inject::remove_project_mcp(&project_id) {
let ctx = 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(()) Ok(())
} }

View File

@ -670,6 +670,30 @@ fn codex_mcp_server_url(content: &str) -> Option<&str> {
None 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( pub(crate) fn check_codex_mcp(
content: Option<&str>, content: Option<&str>,
expected_project_id: &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 expected_url = crate::mcp_server::scoped_mcp_url(expected_port, expected_project_id);
let configured_url = codex_mcp_server_url(content); 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( VerifyCheck::with_state(
"Codex MCP 已配置", "Codex MCP 已配置",
VerifyState::Ready, VerifyState::Ready,
@ -1501,6 +1528,30 @@ mod tests {
assert_eq!(wrong_port.state, VerifyState::Missing); 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 批量实调实锤)。 /// 回归:空路径登记曾把接入包写进炼境进程 cwd(src-tauri/,2026-07-15 批量实调实锤)。
#[test] #[test]
fn apply_rejects_empty_or_missing_path() { fn apply_rejects_empty_or_missing_path() {

View File

@ -276,7 +276,12 @@ pub fn update_project(id: String, input: UpdateProjectInput) -> Result<(), Strin
) )
.map_err(|e| e.to_string())?; .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( conn.execute(
"UPDATE project_workspaces SET "UPDATE project_workspaces SET
wsl_path = COALESCE(?1, wsl_path), 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())?; .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(()) Ok(())
} }
@ -312,7 +327,8 @@ pub fn update_project_tags(id: String, tags: String) -> Result<(), String> {
Ok(()) Ok(())
} }
#[tauri::command] // 会经 claude CLI 清理客户端配置:在线程池执行,不占界面线程
#[tauri::command(async)]
pub fn delete_project(id: String, delete_local: Option<bool>) -> Result<(), String> { pub fn delete_project(id: String, delete_local: Option<bool>) -> Result<(), String> {
let conn = pool().get().map_err(|e| e.to_string())?; let conn = pool().get().map_err(|e| e.to_string())?;

View File

@ -76,7 +76,8 @@ pub fn get_settings(space_id: Option<String>) -> Result<HashMap<String, String>,
} }
/// 更新 settings:全局 key 写入 settings 表;其余写入 space_settings 表 /// 更新 settings:全局 key 写入 settings 表;其余写入 space_settings 表
#[tauri::command] // 端口变更会重注入全部项目(含 claude CLI):在线程池执行,不占界面线程
#[tauri::command(async)]
pub fn update_settings( pub fn update_settings(
settings: HashMap<String, String>, settings: HashMap<String, String>,
space_id: Option<String>, space_id: Option<String>,

View File

@ -116,6 +116,19 @@ pub fn run() {
let mcp_port = mcp_server::read_port(); let mcp_port = mcp_server::read_port();
tauri::async_runtime::spawn(mcp_server::start(mcp_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 标记位防重复)──── // ── MCP 单一服务器化一次性迁移(settings 标记位防重复)────
std::thread::spawn(|| { std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(10)); // 等 DB 就绪 std::thread::sleep(std::time::Duration::from_secs(10)); // 等 DB 就绪

File diff suppressed because it is too large Load Diff

View File

@ -318,11 +318,43 @@ async fn handle_mcp(headers: HeaderMap, Json(req): Json<RpcRequest>) -> 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<Value>) -> 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( async fn handle_scoped_mcp(
Path(project_id): Path<String>, Path(project_id): Path<String>,
headers: HeaderMap, headers: HeaderMap,
Json(req): Json<RpcRequest>, Json(req): Json<RpcRequest>,
) -> Response { ) -> Response {
if !scoped_project_registered(&project_id) {
return unknown_project_response(&project_id, req.id.clone());
}
if authorize_sensitive_request( if authorize_sensitive_request(
&req, &req,
Some(&project_id), Some(&project_id),
@ -377,7 +409,10 @@ async fn handle_sse_connect(
async fn handle_scoped_sse_connect( async fn handle_scoped_sse_connect(
Path(project_id): Path<String>, Path(project_id): Path<String>,
State(sessions): State<Sessions>, State(sessions): State<Sessions>,
) -> Sse<impl futures_core::Stream<Item = Result<Event, Infallible>>> { ) -> Response {
if !scoped_project_registered(&project_id) {
return unknown_project_response(&project_id, None);
}
let session_id = uuid::Uuid::new_v4().to_string(); let session_id = uuid::Uuid::new_v4().to_string();
let (tx, rx) = mpsc::channel::<(String, String)>(32); let (tx, rx) = mpsc::channel::<(String, String)>(32);
let endpoint_uri = scoped_sse_messages_url(read_port(), &project_id, &session_id); 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)| { let stream = ReceiverStream::new(rx).map(|(evt_name, data)| {
Ok::<Event, Infallible>(Event::default().event(evt_name).data(data)) Ok::<Event, Infallible>(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)] #[cfg(test)]
@ -1455,6 +1492,40 @@ mod tests {
assert!(has_initialized_project_client(project_id)); 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] #[test]
fn loopback_urls_share_one_ipv4_base() { fn loopback_urls_share_one_ipv4_base() {
let port = 27190; let port = 27190;
@ -1532,6 +1603,19 @@ async fn handle_scoped_sse_message(
headers: HeaderMap, headers: HeaderMap,
Json(req): Json<RpcRequest>, Json(req): Json<RpcRequest>,
) -> Response { ) -> 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( if authorize_sensitive_request(
&req, &req,
Some(&project_id), Some(&project_id),

View File

@ -1292,6 +1292,8 @@ fn get_diagnostics() -> Result<String, String> {
AND id IN ( AND id IN (
SELECT MAX(id) FROM runtime_logs SELECT MAX(id) FROM runtime_logs
WHERE category = 'mcp_inject' WHERE category = 'mcp_inject'
AND json_valid(context)
AND json_extract(context, '$.project_id') IS NOT NULL
GROUP BY json_extract(context, '$.project_id') GROUP BY json_extract(context, '$.project_id')
) )
ORDER BY ts DESC ORDER BY ts DESC