From f21345bf1ab1d3dfba54893adaac3809cba3f205 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Fri, 2 Oct 2026 13:33:45 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(mcp/parity):=20DSH=20=E6=A1=8C?= =?UTF-8?q?=E9=9D=A2=20GUI=20=E6=8C=89=E5=B7=A5=E4=BD=9C=E5=8C=BA=E8=BF=9E?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E5=9C=B0=E5=9D=80=EF=BC=88=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E7=BA=A7=E6=8F=92=E4=BB=B6=EF=BC=8CP9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DSH 桌面版只有一个 Host 进程、启动目录固定,托管块的 !!js 只在启动时求值,拿不到会话信息; web/acp 同样一个进程服务多个工作区。改由炼境分发一个会话级插件:会话创建(agent/created)时 按会话工作目录匹配炼境登记的项目白名单,命中才在该会话作用域挂连项目专用地址的 mcp-client, 同名工具覆盖全局 lian-jing,其他会话不受影响。 - 插件随二进制打包(resources/dsh/lianjing-session-mcp.v1.mjs),刷新 DSH 托管块时写到 %USERPROFILE%\.lian-jing\dsh\;文件名带版本号(Node 按 URL 缓存 ESM) - 托管块在全局行之后追加插件条目(port、shim、roots 以 JSON 写入);插件文件写失败时只保留全局行 - 插件防御:headless 交给全局行;特性探测不符即降级为全局;取认证 ≤3 秒、整次挂载 ≤5 秒; 所有回调吞掉异常(Host 对未处理异常会崩溃,agent/created 串行失败会让会话创建失败) - 块外手写的 lianjing-session-mcp 条目同样拒绝覆盖 验证:cargo test 246 passed;vitest 插件用例 6 条通过、typecheck 通过;端到端在 DSH 0.2.0-rc.2 与 0.1.5-rc.2 上,项目会话经真实稳定入口带认证连上项目地址,其他会话无请求。 Feature-Confirmed: true Co-Authored-By: Claude Opus 5.5 --- .blueprint/modules/agent-client-parity.md | 11 +- docs/ai-context/project-context.yaml | 2 +- .../resources/dsh/lianjing-session-mcp.v1.mjs | 146 ++++++++++++++++++ src-tauri/src/mcp_inject.rs | 134 ++++++++++++++-- src/lib/dshSessionPlugin.test.ts | 128 +++++++++++++++ 5 files changed, 401 insertions(+), 20 deletions(-) create mode 100644 src-tauri/resources/dsh/lianjing-session-mcp.v1.mjs create mode 100644 src/lib/dshSessionPlugin.test.ts diff --git a/.blueprint/modules/agent-client-parity.md b/.blueprint/modules/agent-client-parity.md index 30da788..0af4845 100644 --- a/.blueprint/modules/agent-client-parity.md +++ b/.blueprint/modules/agent-client-parity.md @@ -148,15 +148,18 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。 - 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 +### 🔵 P9 — DSH 桌面 GUI 按工作区连项目地址(会话级插件) +- status: in_progress - complexity: M -- files: src-tauri/src/mcp_inject.rs(render_dsh_block 追加插件行), src-tauri/resources/dsh/lianjing-session-mcp.v1.mjs(新增,随二进制打包) +- files: src-tauri/src/mcp_inject.rs(render_dsh_block 追加插件行), src-tauri/resources/dsh/lianjing-session-mcp.v1.mjs(新增,随二进制打包), src/lib/dshSessionPlugin.test.ts - 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 工作区新建会话能看到开发变量工具,在其他工作区看不到。" +- acceptance: "agent_verify: [cargo test] 托管块同时含 mcp-lian-jing 与 lianjing-session-mcp 两行、插件路径为绝对路径、roots 与 normalize_dsh_root 一致;[vitest] 假 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 相同,但需按「管家准入」论证。 - 可行性实测(2026-10-02,用户确认「先实测再实现」):临时 DSH_HOME + 记录请求的桩 MCP 服务 + 原型插件(%TEMP%\lj-p9-spike),经 ACP 在同一进程内新建两个不同 cwd 的会话。DSH 0.2.0-rc.2(桌面版内置)与 0.1.5-rc.2(enterprise 本地)结果一致:插件按绝对路径从 home patch 加载;ctx.loader.import('@deepseek-ai/dsh-mcp-client') 成功;每个会话触发 agent/created 且带 session.header.cwd;项目内会话挂载后桩服务收到 /mcp/projects/ 的 initialize 与 tools/list(带认证头),另一会话无任何请求。0.1.x 没有 profileContext,事件里也没有 source 字段。未实测:挂载工具对该会话模型的可见性、桌面 GUI 热加载(只有代码阅读结论)。实现需在 PR #24 合并后从 master 开分支。 +- 实现(2026-10-02,分支 feat/agent-client-parity-p9):插件随二进制打包(include_str!),ensure_dsh_patch 刷新时写到 %USERPROFILE%\.lian-jing\dsh\(内容不变不写),托管块在全局行之后追加 lianjing-session-mcp 条目(port、shim、roots 以 JSON 写入,路径含引号空格也合法);插件文件写失败时只保留全局行。插件行为:headless 交给全局行(不重复挂载);特性探测 agents/on/loader.import 与 mcp-client 的 Config/apply,不符即降级为全局;按会话 cwd 匹配白名单,命中才在 agent.ctx 挂 serverName=lian-jing 的 streamable-http 客户端,经稳定入口异步取认证(≤3 秒,失败则不带认证头挂载),整个挂载 ≤5 秒;所有回调吞掉异常;会话销毁时释放;子代理同样按 cwd 挂载(子代理看不到父会话作用域的工具)。 +- verification: "2026-10-02:cargo test 246 passed(托管块双条目结构与插件内容契约);vitest 6 条插件用例(匹配/null 哨兵、挂载 URL 与无认证降级、销毁释放、headless 不注册、接口缺失与 mcp-client 加载失败降级、挂载异常不外抛)通过,typecheck 通过。端到端(正式插件文件 + 炼境同款托管块 + 临时 DSH_HOME + 桩服务,认证经真实稳定入口):DSH 0.2.0-rc.2 与 0.1.5-rc.2 上,项目子目录会话产生 /mcp/projects/ 的 initialize 与 tools/list 且带认证头,其他目录会话无任何请求;0.2 在 session/new 返回前工具已就绪(167ms),0.1.5 约在返回后 70ms 就绪。" +- known_limits: "未实测桌面 GUI 内:挂载工具对会话模型的可见性(DSH 的 scope 覆盖只有代码阅读结论)、改托管块后的热加载(不生效时需重启 DSH)。0.1.x 上工具在会话创建后约几十毫秒才就绪,极端情况下首轮请求可能只看到全局工具。依赖 DSH 内部接口,升级后可能失效(插件会降级并在 stderr 打 [lian-jing] 提示)。" ### ✅ P3b — 接入写入幂等:同内容不重写、Codex 托管块不累积空行 - status: done diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index d7c2d32..312bdd4 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -6,7 +6,7 @@ contracts: - label: API 接口(Axum) paths: - "src-tauri/src/mcp_server.rs" - doc: "MCP 仅监听 127.0.0.1;新项目使用 /mcp/projects/ scoped route,连接 scope 拒绝跨项目参数和全局管理写工具。scoped route 只服务已登记项目,未知 project_id 返回 404(数据库不可用同样拒绝)。各客户端经 %USERPROFILE%/.lian-jing/bin/lianjing-mcp-header.cmd 稳定入口取认证头(炼境每次启动指向当前 exe;入口不放 AppData,因炼境若在 MSIX 应用的进程树里启动(如由 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 按钮体系文档。" + 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/桌面版一个进程可服务多个工作区,全局行一律走全局以防跨项目带认证;这些模式由同一托管块引用的会话级插件(随炼境打包,写到 %USERPROFILE%/.lian-jing/dsh/,文件名带版本号)在每个会话创建时按会话工作目录匹配白名单,命中才在该会话作用域挂项目地址并经稳定入口取认证,其他会话不受影响;任意目录里的 context.json 不作授权依据;未命中回退全局;项目增删与端口变更时重写)。Claude local 条目与 DSH 块只替换炼境托管的条目,手写同名配置不覆盖;移除项目时一并清理。旧 /mcp 只作兼容迁移。read_project_dev_variable 与 read_project_test_account 是两种有界开发值读取:仅 scoped 路由、匹配项目的本机 bearer 认证后单项读取;认证失败在 dispatch 前拒绝。路由变更需同步 Agent 按钮体系文档。" - label: MCP 工具接口 paths: diff --git a/src-tauri/resources/dsh/lianjing-session-mcp.v1.mjs b/src-tauri/resources/dsh/lianjing-session-mcp.v1.mjs new file mode 100644 index 0000000..ebbb081 --- /dev/null +++ b/src-tauri/resources/dsh/lianjing-session-mcp.v1.mjs @@ -0,0 +1,146 @@ +// 炼境托管的 DSH 插件:会话创建时按该会话的工作目录匹配炼境登记的项目根目录,命中则只在这个会话的 +// 作用域挂一个连项目专用地址的 mcp-client(同名工具覆盖全局 lian-jing),未命中不做任何事。 +// 由炼境写入 %USERPROFILE%\.lian-jing\dsh\ 并在 ~/.dsh/cordis.patch.yml 托管块中引用,勿手改,会被覆盖。 +// 内容变化时文件名里的版本号必须递增:Node 按 URL 缓存 ESM,同名覆盖不会被 DSH 热加载重新读取。 +// +// 约束:DSH 桌面 Host 对未处理异常会整体崩溃,agent/created 又是串行事件(监听器失败会让会话创建失败), +// 所以这里所有回调都吞掉异常、挂载有时间上限,炼境离线或 DSH 接口变化时静默降级为只有全局工具。 +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; +import path from "node:path"; + +export const name = "lianjing-session-mcp"; +export const inject = ["agents", "loader"]; + +const MCP_KEY = "lian-jing"; +/** 整个挂载(取认证 + 等工具就绪)的上限:agent/created 是串行事件,不能拖慢会话创建太久。 */ +const ATTACH_TIMEOUT_MS = 5000; +const AUTH_TIMEOUT_MS = 3000; + +const warn = (message) => { + try { + process.stderr.write(`[lian-jing] ${message}\n`); + } catch {} +}; + +/** 从工作目录逐级向上匹配白名单(键规则与炼境 normalize_dsh_root 一致),命中 null 停止哨兵即停。 */ +export function findProjectId(roots, cwd) { + if (!roots || typeof cwd !== "string" || cwd === "") return undefined; + for (let dir = path.resolve(cwd), prev; dir !== prev; prev = dir, dir = path.dirname(dir)) { + const key = dir.toLowerCase().replace(/\\+$/, ""); + if (Object.prototype.hasOwnProperty.call(roots, key)) return roots[key] || undefined; + } + return undefined; +} + +/** headless 由托管块里的全局行按启动目录路由,插件不重复挂载;DSH 0.1.x 没有 profileContext,退回看启动参数。 */ +export function isHeadless(ctx) { + let profile; + try { + profile = ctx.get("profileContext")?.name; + } catch {} + if (profile === undefined && process.argv.includes("headless")) profile = "headless"; + return profile === "headless"; +} + +/** 经炼境稳定入口取认证头;入口缺失、失败或超时都返回 undefined(只影响需认证的工具),不输出认证值。 */ +export function readAuthorization(shim, projectId, timeoutMs) { + return new Promise((resolve) => { + const systemRoot = process.env.SystemRoot; + if (typeof shim !== "string" || !existsSync(shim) || !systemRoot) { + resolve(undefined); + return; + } + const digest = createHash("sha256").update(projectId).digest("hex"); + try { + execFile( + path.join(systemRoot, "System32", "cmd.exe"), + // Node 拒绝直接执行 .cmd(EINVAL),须经 cmd /d /s /c 原样传参。 + ["/d", "/s", "/c", `""${shim}" ${digest}"`], + { encoding: "utf8", timeout: timeoutMs, windowsHide: true, windowsVerbatimArguments: true }, + (error, stdout) => { + if (error) { + resolve(undefined); + return; + } + try { + const authorization = JSON.parse(stdout).Authorization; + resolve(typeof authorization === "string" ? authorization : undefined); + } catch { + resolve(undefined); + } + }, + ); + } catch { + resolve(undefined); + } + }); +} + +const withTimeout = (promise, timeoutMs) => + Promise.race([promise, new Promise((resolve) => setTimeout(resolve, timeoutMs).unref?.())]); + +export async function apply(ctx, config) { + try { + if (isHeadless(ctx)) return; + if (typeof ctx.agents?.list !== "function" || typeof ctx.on !== "function" || typeof ctx.loader?.import !== "function") { + warn("当前 DSH 不支持会话级插件接口,lian-jing 只提供全局工具"); + return; + } + let McpClient; + try { + McpClient = await ctx.loader.import("@deepseek-ai/dsh-mcp-client"); + } catch (error) { + warn(`无法加载 dsh-mcp-client,lian-jing 只提供全局工具: ${error?.message ?? error}`); + return; + } + if (typeof McpClient?.Config !== "function" || typeof McpClient?.apply !== "function") { + warn("dsh-mcp-client 接口不符,lian-jing 只提供全局工具"); + return; + } + + const mounted = new Map(); + const attach = async (agent) => { + const id = findProjectId(config?.roots, agent?.session?.header?.cwd); + if (!id || mounted.has(agent) || typeof agent?.ctx?.plugin !== "function") return; + mounted.set(agent, undefined); + const authorization = await readAuthorization(config?.shim, id, AUTH_TIMEOUT_MS); + if (!authorization) warn(`未取到项目 ${id} 的认证头,需认证的开发变量工具将不可用`); + if (!mounted.has(agent)) return; + const options = McpClient.Config({ + transport: "streamable-http", + serverName: MCP_KEY, + url: `http://127.0.0.1:${config.port}/mcp/projects/${encodeURIComponent(id)}`, + headers: authorization ? { Authorization: authorization } : {}, + }); + const fiber = agent.ctx.plugin(McpClient, options); + mounted.set(agent, fiber); + // 尽量在首轮请求前等工具就绪;fiber 不可等待时立即返回 + await Promise.resolve(fiber).catch(() => {}); + }; + const safeAttach = (agent) => + withTimeout(attach(agent), ATTACH_TIMEOUT_MS).catch((error) => + warn(`会话级挂载失败: ${error?.message ?? error}`), + ); + + for (const agent of ctx.agents.list()) void safeAttach(agent); + ctx.on("agent/created", ({ agent }) => safeAttach(agent)); + ctx.on("agent/disposed", ({ agent }) => { + try { + mounted.get(agent)?.dispose?.(); + } catch {} + mounted.delete(agent); + }); + ctx.effect?.(() => () => { + for (const fiber of mounted.values()) { + try { + fiber?.dispose?.(); + } catch {} + } + mounted.clear(); + }, "lianjing-session-mcp.mounts"); + } catch (error) { + warn(`会话级插件初始化失败,lian-jing 只提供全局工具: ${error?.message ?? error}`); + } +} diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index 6e21e3c..d4cd123 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -711,6 +711,39 @@ fn mcp_op_lock() -> std::sync::MutexGuard<'static, ()> { // 「已登记项目根目录 → project_id」白名单(非秘密),从启动目录向上精确匹配根目录才连项目专用地址 // 并经稳定 helper 携带认证;任意目录里的 .lian-jing/context.json 不作为授权依据。未命中则回退全局地址, // 保持 GUI 等非项目启动方式的现有行为。项目增删、端口变更时重写白名单。 +// 全局行只对单会话的 headless 按启动目录路由;桌面 GUI / web / acp 一个进程服务多个工作区,改由会话级插件 +// (resources/dsh/,写到 %USERPROFILE%\.lian-jing\dsh\)在 agent/created 时按会话工作目录挂项目地址, +// 只对该会话可见并覆盖同名全局工具(蓝图 agent-client-parity P9)。 + +/// 会话级插件文件名。插件内容变化时必须递增版本号:Node 按 URL 缓存 ESM,同名覆盖不会被 DSH 热加载重读。 +const DSH_SESSION_PLUGIN_FILE: &str = "lianjing-session-mcp.v1.mjs"; +const DSH_SESSION_PLUGIN_SOURCE: &str = + include_str!("../resources/dsh/lianjing-session-mcp.v1.mjs"); +const DSH_SESSION_PLUGIN_ID: &str = "lianjing-session-mcp"; + +/// 托管块对会话级插件的引用:插件文件与稳定入口的本机绝对路径(只写进机器级的 ~/.dsh 配置,不进项目仓库)。 +struct DshSessionPlugin { + module: String, + shim: String, +} + +/// 写入会话级插件文件(内容不变不写),返回托管块要引用的路径。放用户目录而非 AppData,原因同稳定入口。 +fn ensure_dsh_session_plugin() -> Result { + let bin_dir = helper_bin_dir()?; + let dir = bin_dir + .parent() + .map(|lian_jing| lian_jing.join("dsh")) + .ok_or("无法定位炼境用户目录")?; + std::fs::create_dir_all(&dir).map_err(|error| format!("创建 DSH 插件目录失败: {error}"))?; + let module = dir.join(DSH_SESSION_PLUGIN_FILE); + crate::write_if_content_changed(&module, DSH_SESSION_PLUGIN_SOURCE) + .map_err(|error| format!("写入 DSH 会话级插件失败: {error}"))?; + Ok(DshSessionPlugin { + // DSH 把托管块里的绝对路径插件名转成 file URL;正斜杠写法已在 0.1/0.2 实测可加载 + module: module.display().to_string().replace('\\', "/"), + shim: bin_dir.join(MCP_HEADER_SHIM_NAME).display().to_string(), + }) +} /// 白名单键规范化,与 JS 端 `path.resolve(d).toLowerCase().replace(/\\+$/, '')` 对齐: /// 反斜杠分隔、合并重复分隔符(保留 UNC 前缀 \\)、去尾部分隔符、小写。盘符根 E:\ 两端都得到 e:。 @@ -777,7 +810,11 @@ fn dsh_project_roots( .collect() } -fn render_dsh_block(port: u16, roots: &[(String, Option)]) -> String { +fn render_dsh_block( + port: u16, + roots: &[(String, Option)], + session: Option<&DshSessionPlugin>, +) -> String { // 逐行显式缩进:YAML 依赖缩进,不能用字符串续行(会吞掉下一行行首空白)。 fn indent(spaces: usize, lines: &[&str]) -> Vec { lines @@ -790,7 +827,8 @@ fn render_dsh_block(port: u16, roots: &[(String, Option)]) -> String { .iter() .map(|(root, id)| (root.clone(), json!(id))) .collect(); - let roots_line = format!("const roots = {};", Value::Object(roots_map)); + let roots_json = Value::Object(roots_map).to_string(); + let roots_line = format!("const roots = {roots_json};"); // 只有单会话的 headless 按启动目录路由:web/acp/tui 一个进程可服务多个工作区,按启动目录会让别的工作区 // 的会话连到该项目并带上它的认证;桌面版启动目录固定为 profile 目录。DSH 0.2 起由 profileContext.name // 给出 profile,0.1.x 没有该服务,退回看启动参数。 @@ -855,6 +893,15 @@ fn render_dsh_block(port: u16, roots: &[(String, Option)]) -> String { )); lines.extend(indent(12, &["}"])); lines.extend(indent(10, &["})()"])); + if let Some(session) = session { + // 路径与白名单都以 JSON 写入:JSON 字符串即合法的 YAML 双引号标量,用户名里的引号、空格等无需另行转义 + lines.push(format!(" - id: {DSH_SESSION_PLUGIN_ID}")); + lines.push(format!(" name: {}", json!(session.module))); + lines.push(" config:".into()); + lines.push(format!(" port: {port}")); + lines.push(format!(" shim: {}", json!(session.shim))); + lines.push(format!(" roots: {roots_json}")); + } lines.push(DSH_MANAGED_END.to_string()); let mut block = lines.join("\n"); block.push('\n'); @@ -877,12 +924,14 @@ fn has_manual_lian_jing(outside_block: &str) -> Result { .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) + matches!( + entry.get("id").and_then(serde_yaml::Value::as_str), + Some("mcp-lian-jing" | DSH_SESSION_PLUGIN_ID) + ) || entry + .get("config") + .and_then(|config| config.get("serverName")) + .and_then(serde_yaml::Value::as_str) + == Some(MCP_KEY) }) }) })) @@ -935,6 +984,17 @@ pub fn ensure_dsh_patch( Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), Err(error) => return Err(format!("读取 DSH 补丁文件失败: {error}")), }; + // 插件文件写失败时托管块只保留全局行(headless 仍可用),不阻断整体刷新 + let session = ensure_dsh_session_plugin() + .map_err(|error| { + db::log_event( + CLIENT_SYNC_LOG, + "warn", + &format!("{error};DSH 桌面版暂不能按工作区连项目地址"), + Some(&json!({ "client": "dsh" }).to_string()), + ) + }) + .ok(); let block = render_dsh_block( port, &dsh_project_roots( @@ -942,6 +1002,7 @@ pub fn ensure_dsh_patch( &crate::credential_store::WindowsCredentialStore, exclude, ), + session.as_ref(), ); let next = upsert_dsh_block(¤t, &block)?; if next != current { @@ -1829,7 +1890,11 @@ mod tests { ("e:\\code-project".to_string(), None), ("e:\\code-project\\demo".to_string(), Some("p1".to_string())), ]; - let block = render_dsh_block(27190, &roots); + let session = DshSessionPlugin { + module: "C:/Users/o'brien x/.lian-jing/dsh/lianjing-session-mcp.v1.mjs".into(), + shim: "C:\\Users\\o'brien x\\.lian-jing\\bin\\lianjing-mcp-header.cmd".into(), + }; + let block = render_dsh_block(27190, &roots, Some(&session)); assert!( block.contains( r#"const roots = {"e:\\code-project":null,"e:\\code-project\\demo":"p1"};"# @@ -1871,17 +1936,55 @@ mod tests { && block.contains("windowsVerbatimArguments: true") ); assert!(!block.contains("Bearer "), "托管块不得含任何 token"); + + // 会话级插件条目:与全局行同一个 insert 列表,路径含引号与空格也必须是合法 YAML 并原样还原 + let plugin = &parsed[0]["insert"][1]; + assert_eq!(plugin["id"], DSH_SESSION_PLUGIN_ID); + assert_eq!(plugin["name"], session.module.as_str()); + assert!(session.module.ends_with(DSH_SESSION_PLUGIN_FILE)); + assert_eq!(plugin["config"]["port"], 27190); + assert_eq!(plugin["config"]["shim"], session.shim.as_str()); + assert_eq!( + plugin["config"]["roots"]["e:\\code-project"], + serde_yaml::Value::Null + ); + assert_eq!(plugin["config"]["roots"]["e:\\code-project\\demo"], "p1"); + + let without = render_dsh_block(27190, &roots, None); + let parsed: serde_yaml::Value = serde_yaml::from_str(&without).unwrap(); + assert_eq!( + parsed[0]["insert"].as_sequence().unwrap().len(), + 1, + "插件文件写不出时只保留全局行" + ); + } + + #[test] + fn dsh_session_plugin_source_matches_versioned_contract() { + // 插件内容随二进制打包;文件名带版本号,内容变化须同步递增(Node 按 URL 缓存 ESM) + assert!(DSH_SESSION_PLUGIN_FILE.starts_with("lianjing-session-mcp.v")); + assert!(DSH_SESSION_PLUGIN_SOURCE + .contains(&format!("export const name = \"{DSH_SESSION_PLUGIN_ID}\""))); + assert!(DSH_SESSION_PLUGIN_SOURCE.contains(&format!("const MCP_KEY = \"{MCP_KEY}\""))); + assert!( + DSH_SESSION_PLUGIN_SOURCE.contains("/mcp/projects/"), + "插件只挂项目专用地址" + ); + assert!( + !DSH_SESSION_PLUGIN_SOURCE.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(); + let first = upsert_dsh_block(user, &render_dsh_block(27190, &[], None)).unwrap(); assert!(first.starts_with(user.trim_end()), "用户条目原样保留"); let parsed: serde_yaml::Value = serde_yaml::from_str(&first).unwrap(); assert_eq!(parsed.as_sequence().unwrap().len(), 2); - let moved = upsert_dsh_block(&first, &render_dsh_block(28000, &[])).unwrap(); + let moved = upsert_dsh_block(&first, &render_dsh_block(28000, &[], None)).unwrap(); assert!(moved.contains("127.0.0.1:28000") && !moved.contains("127.0.0.1:27190")); assert_eq!( moved.matches(DSH_MANAGED_START).count(), @@ -1889,7 +1992,7 @@ mod tests { "原位替换不重复追加" ); assert_eq!( - upsert_dsh_block(&moved, &render_dsh_block(28000, &[])).unwrap(), + upsert_dsh_block(&moved, &render_dsh_block(28000, &[], None)).unwrap(), moved, "幂等" ); @@ -1898,14 +2001,15 @@ mod tests { "- 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", + "- insert:\n - id: lianjing-session-mcp\n name: ./x.mjs\n", ] { assert!( - upsert_dsh_block(manual, &render_dsh_block(27190, &[])).is_err(), + upsert_dsh_block(manual, &render_dsh_block(27190, &[], None)).is_err(), "{manual}" ); - let with_block = format!("{manual}{}", render_dsh_block(27190, &[])); + let with_block = format!("{manual}{}", render_dsh_block(27190, &[], None)); assert!( - upsert_dsh_block(&with_block, &render_dsh_block(27190, &[])).is_err(), + upsert_dsh_block(&with_block, &render_dsh_block(27190, &[], None)).is_err(), "块外手写也须拒绝" ); } diff --git a/src/lib/dshSessionPlugin.test.ts b/src/lib/dshSessionPlugin.test.ts new file mode 100644 index 0000000..238f5e3 --- /dev/null +++ b/src/lib/dshSessionPlugin.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; + +// 炼境分发给 DSH 的会话级插件(src-tauri/resources/dsh/):用假的 DSH ctx 验证挂载边界与「永不抛错」约束。 +// 项目只在 Windows 上运行,路径断言按 Windows 语义书写。 + +type Roots = Record; +interface MountOptions { + transport: string; + serverName: string; + url: string; + headers: Record; +} +interface FakeAgent { + session: { header: { cwd: string } }; + ctx: { plugin: (client: unknown, options: MountOptions) => { dispose: () => void } }; +} +type Handler = (event: { agent: FakeAgent }) => unknown; +interface PluginModule { + name: string; + findProjectId: (roots: Roots | undefined, cwd: unknown) => string | undefined; + apply: (ctx: unknown, config: unknown) => Promise; +} + +const pluginUrl = new URL("../../src-tauri/resources/dsh/lianjing-session-mcp.v1.mjs", import.meta.url).href; +const loadPlugin = async (): Promise => + (await import(/* @vite-ignore */ pluginUrl)) as PluginModule; + +const roots: Roots = { "e:\\code-project": null, "e:\\code-project\\demo": "p 1" }; +const config = { port: 27190, shim: "Z:\\lianjing-test-missing\\lianjing-mcp-header.cmd", roots }; +const fakeClient = { Config: (options: MountOptions) => options, apply: () => undefined }; + +function fakeDsh(options: { profile?: string; importFails?: boolean; agentsApi?: boolean } = {}) { + const handlers = new Map(); + const ctx = { + get: (key: string) => (key === "profileContext" && options.profile ? { name: options.profile } : undefined), + agents: options.agentsApi === false ? undefined : { list: (): FakeAgent[] => [] }, + loader: { + import: async () => { + if (options.importFails) throw new Error("module not found"); + return fakeClient; + }, + }, + on: (event: string, handler: Handler) => handlers.set(event, [...(handlers.get(event) ?? []), handler]), + effect: () => undefined, + }; + const emit = async (event: string, agent: FakeAgent) => { + for (const handler of handlers.get(event) ?? []) await handler({ agent }); + }; + return { ctx, handlers, emit }; +} + +function fakeAgent(cwd: string, mounts: MountOptions[], disposed: string[] = [], throws = false): FakeAgent { + return { + session: { header: { cwd } }, + ctx: { + plugin: (_client, options) => { + if (throws) throw new Error("serverName already in use"); + mounts.push(options); + return { dispose: () => disposed.push(options.url) }; + }, + }, + }; +} + +describe("DSH 会话级插件", () => { + it("按会话工作目录逐级向上匹配,null 停止哨兵与未登记目录都不命中", async () => { + const plugin = await loadPlugin(); + expect(plugin.name).toBe("lianjing-session-mcp"); + expect(plugin.findProjectId(roots, "E:\\code-project\\demo\\apps\\web")).toBe("p 1"); + expect(plugin.findProjectId(roots, "E:\\Code-Project\\Demo\\")).toBe("p 1"); + expect(plugin.findProjectId(roots, "E:\\code-project\\other")).toBeUndefined(); + expect(plugin.findProjectId(roots, "D:\\elsewhere")).toBeUndefined(); + expect(plugin.findProjectId(roots, "")).toBeUndefined(); + expect(plugin.findProjectId(undefined, "E:\\code-project\\demo")).toBeUndefined(); + }); + + it("只给命中项目的会话挂项目专用地址;取不到认证时仍挂载但不带认证头", async () => { + const plugin = await loadPlugin(); + const dsh = fakeDsh({ profile: "desktop" }); + await plugin.apply(dsh.ctx, config); + const mounts: MountOptions[] = []; + await dsh.emit("agent/created", fakeAgent("E:\\code-project\\demo\\src", mounts)); + await dsh.emit("agent/created", fakeAgent("E:\\code-project\\other", mounts)); + expect(mounts).toHaveLength(1); + expect(mounts[0].serverName).toBe("lian-jing"); + expect(mounts[0].transport).toBe("streamable-http"); + expect(mounts[0].url).toBe("http://127.0.0.1:27190/mcp/projects/p%201"); + expect(mounts[0].headers).toEqual({}); + }); + + it("会话销毁时释放挂载", async () => { + const plugin = await loadPlugin(); + const dsh = fakeDsh({ profile: "web" }); + await plugin.apply(dsh.ctx, config); + const mounts: MountOptions[] = []; + const disposed: string[] = []; + const agent = fakeAgent("E:\\code-project\\demo", mounts, disposed); + await dsh.emit("agent/created", agent); + await dsh.emit("agent/disposed", agent); + expect(disposed).toEqual(["http://127.0.0.1:27190/mcp/projects/p%201"]); + }); + + it("headless 交给全局行处理,插件不注册任何监听", async () => { + const plugin = await loadPlugin(); + const dsh = fakeDsh({ profile: "headless" }); + await plugin.apply(dsh.ctx, config); + expect(dsh.handlers.size).toBe(0); + }); + + it("DSH 接口缺失或 mcp-client 加载失败时静默降级,不抛错", async () => { + const plugin = await loadPlugin(); + for (const options of [{ importFails: true }, { agentsApi: false }]) { + const dsh = fakeDsh({ profile: "desktop", ...options }); + await expect(plugin.apply(dsh.ctx, config)).resolves.toBeUndefined(); + expect(dsh.handlers.has("agent/created")).toBe(false); + } + }); + + it("挂载异常不外抛:agent/created 是串行事件,监听器失败会让会话创建失败", async () => { + const plugin = await loadPlugin(); + const dsh = fakeDsh({ profile: "desktop" }); + await plugin.apply(dsh.ctx, config); + const mounts: MountOptions[] = []; + await expect( + dsh.emit("agent/created", fakeAgent("E:\\code-project\\demo", mounts, [], true)), + ).resolves.toBeUndefined(); + }); +}); From fe0458fe9754b39355e2aec3177dbe1f475c5796 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Fri, 2 Oct 2026 15:04:54 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(mcp/parity):=20P9=20=E5=AE=A1=E6=9F=A5?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E2=80=94=E2=80=94=E5=8F=97=E9=99=90=E5=AD=90?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E4=B8=8D=E6=8C=82=E8=BD=BD=E3=80=81=E6=9D=A1?= =?UTF-8?q?=E7=9B=AE=20id=20=E5=B8=A6=E7=89=88=E6=9C=AC=E3=80=81=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E7=BC=BA=E5=A4=B1=E5=AE=88=E5=8D=AB=E3=80=81=E8=A7=84?= =?UTF-8?q?=E8=8C=83=E8=B7=AF=E5=BE=84=E9=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对抗审查(11 个 agent,6 条成立,均 low): - 受限子代理(toolFilter)会绕过限制:挂在子代理自己作用域的工具不受过滤。改为经 tools.view 判断, 受限或无法判断即不挂;根会话与未受限子代理照常挂载 - 版本号只编进文件名时,运行中的 DSH 不会重载新插件(同 id 只改 name 不重载):条目 id 改为 lianjing-session-mcp-v1,与文件名同一版本;新增内容哈希测试,改插件漏升版本即失败;块外手写按前缀拒绝 - 插件文件缺失时 DSH 0.1.x 启动失败:条目加 `disabled: !!js` 文件存在性守卫 - GUI 会话工作目录是 realpath:白名单同时写入登记路径的规范形态(junction、8.3 短名) - 补测试:分层停止哨兵、取认证失败与超时(.cmd 夹具)、受限/未受限/无法判断的子代理 - 蓝图如实更正首轮端到端只有插件一行;补测完整托管块 验证:cargo test 247 passed;vitest 16 passed(插件 8 条)、typecheck 通过;完整托管块端到端在 DSH 0.2.0-rc.2 与 0.1.5-rc.2 上项目会话带认证连上项目地址、其他会话无请求;插件文件缺失时两版都正常启动。 Co-Authored-By: Claude Opus 5.5 --- .blueprint/modules/agent-client-parity.md | 7 +- .../resources/dsh/lianjing-session-mcp.v1.mjs | 24 +++- src-tauri/src/mcp_inject.rs | 134 ++++++++++++++---- src/lib/__fixtures__/shim-fail.cmd | 3 + src/lib/__fixtures__/shim-hang.cmd | 4 + src/lib/dshSessionPlugin.test.ts | 75 ++++++++-- 6 files changed, 208 insertions(+), 39 deletions(-) create mode 100644 src/lib/__fixtures__/shim-fail.cmd create mode 100644 src/lib/__fixtures__/shim-hang.cmd diff --git a/.blueprint/modules/agent-client-parity.md b/.blueprint/modules/agent-client-parity.md index 0af4845..ef427a7 100644 --- a/.blueprint/modules/agent-client-parity.md +++ b/.blueprint/modules/agent-client-parity.md @@ -157,9 +157,10 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。 - 为什么: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 相同,但需按「管家准入」论证。 - 可行性实测(2026-10-02,用户确认「先实测再实现」):临时 DSH_HOME + 记录请求的桩 MCP 服务 + 原型插件(%TEMP%\lj-p9-spike),经 ACP 在同一进程内新建两个不同 cwd 的会话。DSH 0.2.0-rc.2(桌面版内置)与 0.1.5-rc.2(enterprise 本地)结果一致:插件按绝对路径从 home patch 加载;ctx.loader.import('@deepseek-ai/dsh-mcp-client') 成功;每个会话触发 agent/created 且带 session.header.cwd;项目内会话挂载后桩服务收到 /mcp/projects/ 的 initialize 与 tools/list(带认证头),另一会话无任何请求。0.1.x 没有 profileContext,事件里也没有 source 字段。未实测:挂载工具对该会话模型的可见性、桌面 GUI 热加载(只有代码阅读结论)。实现需在 PR #24 合并后从 master 开分支。 -- 实现(2026-10-02,分支 feat/agent-client-parity-p9):插件随二进制打包(include_str!),ensure_dsh_patch 刷新时写到 %USERPROFILE%\.lian-jing\dsh\(内容不变不写),托管块在全局行之后追加 lianjing-session-mcp 条目(port、shim、roots 以 JSON 写入,路径含引号空格也合法);插件文件写失败时只保留全局行。插件行为:headless 交给全局行(不重复挂载);特性探测 agents/on/loader.import 与 mcp-client 的 Config/apply,不符即降级为全局;按会话 cwd 匹配白名单,命中才在 agent.ctx 挂 serverName=lian-jing 的 streamable-http 客户端,经稳定入口异步取认证(≤3 秒,失败则不带认证头挂载),整个挂载 ≤5 秒;所有回调吞掉异常;会话销毁时释放;子代理同样按 cwd 挂载(子代理看不到父会话作用域的工具)。 -- verification: "2026-10-02:cargo test 246 passed(托管块双条目结构与插件内容契约);vitest 6 条插件用例(匹配/null 哨兵、挂载 URL 与无认证降级、销毁释放、headless 不注册、接口缺失与 mcp-client 加载失败降级、挂载异常不外抛)通过,typecheck 通过。端到端(正式插件文件 + 炼境同款托管块 + 临时 DSH_HOME + 桩服务,认证经真实稳定入口):DSH 0.2.0-rc.2 与 0.1.5-rc.2 上,项目子目录会话产生 /mcp/projects/ 的 initialize 与 tools/list 且带认证头,其他目录会话无任何请求;0.2 在 session/new 返回前工具已就绪(167ms),0.1.5 约在返回后 70ms 就绪。" -- known_limits: "未实测桌面 GUI 内:挂载工具对会话模型的可见性(DSH 的 scope 覆盖只有代码阅读结论)、改托管块后的热加载(不生效时需重启 DSH)。0.1.x 上工具在会话创建后约几十毫秒才就绪,极端情况下首轮请求可能只看到全局工具。依赖 DSH 内部接口,升级后可能失效(插件会降级并在 stderr 打 [lian-jing] 提示)。" +- 实现(2026-10-02,分支 feat/agent-client-parity-p9):插件随二进制打包(include_str!),ensure_dsh_patch 刷新时写到 %USERPROFILE%\.lian-jing\dsh\(内容不变不写),托管块在全局行之后追加 lianjing-session-mcp 条目(port、shim、roots 以 JSON 写入,路径含引号空格也合法);插件文件写失败时只保留全局行。插件行为:headless 交给全局行(不重复挂载);特性探测 agents/on/loader.import 与 mcp-client 的 Config/apply,不符即降级为全局;按会话 cwd 匹配白名单,命中才在 agent.ctx 挂 serverName=lian-jing 的 streamable-http 客户端,经稳定入口异步取认证(≤3 秒,失败则不带认证头挂载),整个挂载 ≤5 秒;所有回调吞掉异常;会话销毁时释放;子代理(header.origin=subagent)按 cwd 挂载,但带工具限制(toolFilter)或无法判断时不挂——挂在子代理自己作用域的工具不受限制约束。 +- 对抗审查(11 个 agent,6 条成立均为 low、已修):受限子代理会绕过 toolFilter → 经 tools.view 判断,受限即不挂;版本号只编进文件名时运行中的 DSH 不会重载 → 条目 id 同带版本号(lianjing-session-mcp-v1),并加内容哈希测试拦漏升版本;插件文件缺失时 DSH 0.1.x 启动失败 → 条目加 `disabled: !!js` 文件存在性守卫;GUI 工作目录为 realpath → 白名单同时写入登记路径的规范形态(junction/8.3 短名);补分层停止哨兵、取认证失败/超时用例;验收记录更正(首轮端到端的补丁只有插件行,见下)。 +- verification: "2026-10-02:cargo test 247 passed(托管块双条目、版本化 id 与存在性守卫、插件内容哈希、规范路径键);vitest 8 条插件用例通过(含分层哨兵、取认证失败与超时、受限/未受限/无法判断的子代理),typecheck 通过。端到端第一轮的补丁只有插件一行;审查后补测完整托管块(从真实 ~/.dsh 复制的全局行 + 插件行 + 存在性守卫 + 停止哨兵,临时 DSH_HOME + 桩服务,认证经真实稳定入口):DSH 0.2.0-rc.2 与 0.1.5-rc.2 上项目子目录会话产生 /mcp/projects/ 的 initialize 与 tools/list 且带认证头,其他目录会话无任何请求;插件文件缺失时两个版本都正常启动、会话正常、插件条目不加载。审查者另测:全局行与插件共存时 headless 只有一组连接;项目地址 tools/list 很慢时会话创建在约 5 秒上限内返回。" +- known_limits: "未实测桌面 GUI 内:挂载工具对会话模型的可见性(DSH 的 scope 覆盖只有代码阅读与 ACP 探针结论)、改托管块后的热加载(审查者在 ACP 开启 hmr 实测 id 变化会重载;GUI 不生效时重启 DSH)。0.1.x 上工具在会话创建后约几十毫秒才就绪,极端情况下首轮请求可能只看到全局工具。依赖 DSH 内部接口,升级后可能失效(插件会降级并在 stderr 打 [lian-jing] 提示)。flaky:mcp_server 的 scoped_mcp_create_pull_request_uses_synthetic_credential 在全量并行测试中偶发失败(单跑通过,与本卡无关)。" ### ✅ P3b — 接入写入幂等:同内容不重写、Codex 托管块不累积空行 - status: done diff --git a/src-tauri/resources/dsh/lianjing-session-mcp.v1.mjs b/src-tauri/resources/dsh/lianjing-session-mcp.v1.mjs index ebbb081..a0fb1f6 100644 --- a/src-tauri/resources/dsh/lianjing-session-mcp.v1.mjs +++ b/src-tauri/resources/dsh/lianjing-session-mcp.v1.mjs @@ -1,7 +1,8 @@ // 炼境托管的 DSH 插件:会话创建时按该会话的工作目录匹配炼境登记的项目根目录,命中则只在这个会话的 // 作用域挂一个连项目专用地址的 mcp-client(同名工具覆盖全局 lian-jing),未命中不做任何事。 // 由炼境写入 %USERPROFILE%\.lian-jing\dsh\ 并在 ~/.dsh/cordis.patch.yml 托管块中引用,勿手改,会被覆盖。 -// 内容变化时文件名里的版本号必须递增:Node 按 URL 缓存 ESM,同名覆盖不会被 DSH 热加载重新读取。 +// 内容变化时文件名与托管块条目 id 的版本号必须一起递增:DSH 只在条目 id 变化时才卸载旧条目、import 新模块 +// (同 id 只改 name 不会重载,Node 也按 URL 缓存 ESM);mcp_inject.rs 的内容哈希测试会拦住漏升版本。 // // 约束:DSH 桌面 Host 对未处理异常会整体崩溃,agent/created 又是串行事件(监听器失败会让会话创建失败), // 所以这里所有回调都吞掉异常、挂载有时间上限,炼境离线或 DSH 接口变化时静默降级为只有全局工具。 @@ -11,7 +12,7 @@ import { existsSync } from "node:fs"; import path from "node:path"; export const name = "lianjing-session-mcp"; -export const inject = ["agents", "loader"]; +export const inject = ["agents", "loader", "tools"]; const MCP_KEY = "lian-jing"; /** 整个挂载(取认证 + 等工具就绪)的上限:agent/created 是串行事件,不能拖慢会话创建太久。 */ @@ -44,6 +45,24 @@ export function isHeadless(ctx) { return profile === "headless"; } +/** + * 子代理是否带工具限制(toolFilter)。插件挂在子代理自己作用域里的工具不受限制约束,所以受限子代理不挂载, + * 只保留经限制过滤后的全局 lian-jing 工具;无法判断时同样不挂(宁可少给)。根会话与未受限子代理照常挂载。 + */ +export function isRestrictedSubagent(ctx, agent) { + if (agent?.session?.header?.origin !== "subagent") return false; + try { + const view = ctx.tools?.view?.(agent); + if (!view?.restrictableNames || typeof view.visible?.has !== "function") return true; + for (const toolName of view.restrictableNames) { + if (!view.visible.has(toolName)) return true; + } + return false; + } catch { + return true; + } +} + /** 经炼境稳定入口取认证头;入口缺失、失败或超时都返回 undefined(只影响需认证的工具),不输出认证值。 */ export function readAuthorization(shim, projectId, timeoutMs) { return new Promise((resolve) => { @@ -104,6 +123,7 @@ export async function apply(ctx, config) { const attach = async (agent) => { const id = findProjectId(config?.roots, agent?.session?.header?.cwd); if (!id || mounted.has(agent) || typeof agent?.ctx?.plugin !== "function") return; + if (isRestrictedSubagent(ctx, agent)) return; mounted.set(agent, undefined); const authorization = await readAuthorization(config?.shim, id, AUTH_TIMEOUT_MS); if (!authorization) warn(`未取到项目 ${id} 的认证头,需认证的开发变量工具将不可用`); diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index d4cd123..a53b214 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -715,11 +715,15 @@ fn mcp_op_lock() -> std::sync::MutexGuard<'static, ()> { // (resources/dsh/,写到 %USERPROFILE%\.lian-jing\dsh\)在 agent/created 时按会话工作目录挂项目地址, // 只对该会话可见并覆盖同名全局工具(蓝图 agent-client-parity P9)。 -/// 会话级插件文件名。插件内容变化时必须递增版本号:Node 按 URL 缓存 ESM,同名覆盖不会被 DSH 热加载重读。 +/// 会话级插件的文件名与托管块条目 id 带同一个版本号。插件内容变化时两者必须一起递增:DSH 只在条目 id 变化时 +/// 才卸载旧条目并 import 新模块(同 id 只改 name 不会重载,Node 也按 URL 缓存 ESM)。 +/// 测试 dsh_session_plugin_source_matches_versioned_contract 钉住内容哈希,漏升版本会失败。 const DSH_SESSION_PLUGIN_FILE: &str = "lianjing-session-mcp.v1.mjs"; const DSH_SESSION_PLUGIN_SOURCE: &str = include_str!("../resources/dsh/lianjing-session-mcp.v1.mjs"); -const DSH_SESSION_PLUGIN_ID: &str = "lianjing-session-mcp"; +const DSH_SESSION_PLUGIN_ID: &str = "lianjing-session-mcp-v1"; +/// 任意版本的会话级插件条目 id 前缀(判断块外手写条目用)。 +const DSH_SESSION_PLUGIN_ID_PREFIX: &str = "lianjing-session-mcp"; /// 托管块对会话级插件的引用:插件文件与稳定入口的本机绝对路径(只写进机器级的 ~/.dsh 配置,不进项目仓库)。 struct DshSessionPlugin { @@ -796,20 +800,42 @@ fn dsh_project_roots( 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))) + let value = if context.get("mcp_scope").and_then(Value::as_str) == Some("global") { + None + } else { + 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(); + if !has_token { + return None; + } + Some(id) + }; + Some((win_path, root, value)) + }) + .flat_map(|(win_path, root, value)| { + // DSH GUI 会话的工作目录是 realpath;登记路径若经 junction/符号链接或 8.3 短名,两种键都写进白名单 + let key = normalize_dsh_root(win_path.trim()); + let canonical = std::fs::canonicalize(&root) + .ok() + .map(|path| normalize_dsh_root(&strip_verbatim_prefix(&path.display().to_string()))) + .filter(|canonical| canonical != &key); + std::iter::once((key, value.clone())) + .chain(canonical.map(|canonical| (canonical, value))) }) .collect() } +/// 去掉 std::fs::canonicalize 在 Windows 上返回的 \\?\ 前缀(\\?\UNC\ 还原为 \\),得到普通路径。 +fn strip_verbatim_prefix(path: &str) -> String { + match path.strip_prefix(r"\\?\UNC\") { + Some(rest) => format!(r"\\{rest}"), + None => path.strip_prefix(r"\\?\").unwrap_or(path).to_string(), + } +} + fn render_dsh_block( port: u16, roots: &[(String, Option)], @@ -897,6 +923,12 @@ fn render_dsh_block( // 路径与白名单都以 JSON 写入:JSON 字符串即合法的 YAML 双引号标量,用户名里的引号、空格等无需另行转义 lines.push(format!(" - id: {DSH_SESSION_PLUGIN_ID}")); lines.push(format!(" name: {}", json!(session.module))); + // 插件文件被删或移走时不加载该条目(DSH 0.1.x 加载缺失的插件文件会启动失败),只留全局行 + let exists_guard = format!( + "!process.getBuiltinModule('fs').existsSync({})", + json!(session.module) + ); + lines.push(format!(" disabled: !!js {}", json!(exists_guard))); lines.push(" config:".into()); lines.push(format!(" port: {port}")); lines.push(format!(" shim: {}", json!(session.shim))); @@ -924,14 +956,17 @@ fn has_manual_lian_jing(outside_block: &str) -> Result { .and_then(serde_yaml::Value::as_sequence) .is_some_and(|entries| { entries.iter().any(|entry| { - matches!( - entry.get("id").and_then(serde_yaml::Value::as_str), - Some("mcp-lian-jing" | DSH_SESSION_PLUGIN_ID) - ) || entry - .get("config") - .and_then(|config| config.get("serverName")) + entry + .get("id") .and_then(serde_yaml::Value::as_str) - == Some(MCP_KEY) + .is_some_and(|id| { + id == "mcp-lian-jing" || id.starts_with(DSH_SESSION_PLUGIN_ID_PREFIX) + }) + || entry + .get("config") + .and_then(|config| config.get("serverName")) + .and_then(serde_yaml::Value::as_str) + == Some(MCP_KEY) }) }) })) @@ -1720,14 +1755,37 @@ mod tests { 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()); + // 规范路径(%TEMP% 常是 8.3 短名)与登记的字面路径不同时,两种键都要在白名单里且取值相同 + let mut expected = Vec::new(); + for (id, value) in [("ctrl", None), ("p1", Some("p1".to_string()))] { + expected.push((key(id), value.clone())); + let canonical = normalize_dsh_root(&strip_verbatim_prefix( + &std::fs::canonicalize(base.join(id)) + .unwrap() + .display() + .to_string(), + )); + if canonical != key(id) { + expected.push((canonical, value)); + } + } assert_eq!( - roots, - vec![(key("ctrl"), None), (key("p1"), Some("p1".to_string()))], + roots, expected, "控制面写停止哨兵;只保留已绑定、有 token、未被剔除的项目" ); let _ = std::fs::remove_dir_all(&base); } + #[test] + fn verbatim_prefix_is_stripped_for_canonical_roots() { + assert_eq!(strip_verbatim_prefix(r"\\?\E:\code\demo"), r"E:\code\demo"); + assert_eq!( + strip_verbatim_prefix(r"\\?\UNC\server\share\x"), + r"\\server\share\x" + ); + assert_eq!(strip_verbatim_prefix(r"E:\plain"), r"E:\plain"); + } + #[test] fn dsh_root_keys_match_js_normalization() { assert_eq!(normalize_dsh_root("E:\\Code\\Demo"), "e:\\code\\demo"); @@ -1942,6 +2000,14 @@ mod tests { assert_eq!(plugin["id"], DSH_SESSION_PLUGIN_ID); assert_eq!(plugin["name"], session.module.as_str()); assert!(session.module.ends_with(DSH_SESSION_PLUGIN_FILE)); + let guard = format!( + "!process.getBuiltinModule('fs').existsSync({})", + json!(session.module) + ); + assert!( + block.contains(&format!(" disabled: !!js {}", json!(guard))), + "插件文件缺失时不加载该条目(DSH 0.1.x 会启动失败)" + ); assert_eq!(plugin["config"]["port"], 27190); assert_eq!(plugin["config"]["shim"], session.shim.as_str()); assert_eq!( @@ -1962,9 +2028,28 @@ mod tests { #[test] fn dsh_session_plugin_source_matches_versioned_contract() { // 插件内容随二进制打包;文件名带版本号,内容变化须同步递增(Node 按 URL 缓存 ESM) - assert!(DSH_SESSION_PLUGIN_FILE.starts_with("lianjing-session-mcp.v")); - assert!(DSH_SESSION_PLUGIN_SOURCE - .contains(&format!("export const name = \"{DSH_SESSION_PLUGIN_ID}\""))); + let version = DSH_SESSION_PLUGIN_FILE + .strip_prefix("lianjing-session-mcp.v") + .and_then(|rest| rest.strip_suffix(".mjs")) + .expect("文件名形如 lianjing-session-mcp.v.mjs"); + assert_eq!( + DSH_SESSION_PLUGIN_ID, + format!("{DSH_SESSION_PLUGIN_ID_PREFIX}-v{version}"), + "条目 id 与文件名同一版本号:DSH 只在 id 变化时重载插件" + ); + assert!(DSH_SESSION_PLUGIN_SOURCE.contains(&format!( + "export const name = \"{DSH_SESSION_PLUGIN_ID_PREFIX}\"" + ))); + // 钉住内容:改了插件却没升版本号时,运行中的 DSH 会一直跑旧代码。 + // 改插件后:文件名与 DSH_SESSION_PLUGIN_ID 的版本号一起 +1,再把这里换成新哈希(内容按 LF 计算)。 + use sha2::Digest; + let digest = + sha2::Sha256::digest(DSH_SESSION_PLUGIN_SOURCE.replace("\r\n", "\n").as_bytes()); + let hex: String = digest.iter().map(|byte| format!("{byte:02x}")).collect(); + assert_eq!( + hex, "b8a5c7b251c1cf28623f9c08d352593536fd5051be637c0b1357c270b1090f7d", + "插件内容已变化:请同时递增文件名与条目 id 的版本号,再更新此哈希" + ); assert!(DSH_SESSION_PLUGIN_SOURCE.contains(&format!("const MCP_KEY = \"{MCP_KEY}\""))); assert!( DSH_SESSION_PLUGIN_SOURCE.contains("/mcp/projects/"), @@ -2002,6 +2087,7 @@ mod tests { "- insert:\n - id: other\n config:\n serverName: 'lian-jing' # 手写\n", "- insert: [{id: mcp-lian-jing, config: {serverName: x}}]\n", "- insert:\n - id: lianjing-session-mcp\n name: ./x.mjs\n", + "- insert:\n - id: lianjing-session-mcp-v9\n name: ./x.mjs\n", ] { assert!( upsert_dsh_block(manual, &render_dsh_block(27190, &[], None)).is_err(), diff --git a/src/lib/__fixtures__/shim-fail.cmd b/src/lib/__fixtures__/shim-fail.cmd new file mode 100644 index 0000000..daa834c --- /dev/null +++ b/src/lib/__fixtures__/shim-fail.cmd @@ -0,0 +1,3 @@ +@echo off +rem test fixture: the stable header helper fails (non-zero exit) +exit /b 1 diff --git a/src/lib/__fixtures__/shim-hang.cmd b/src/lib/__fixtures__/shim-hang.cmd new file mode 100644 index 0000000..adde705 --- /dev/null +++ b/src/lib/__fixtures__/shim-hang.cmd @@ -0,0 +1,4 @@ +@echo off +rem test fixture: the stable header helper hangs (ping instead of timeout /t, which exits at once when stdin is redirected) +ping -n 6 127.0.0.1 >nul +exit /b 0 diff --git a/src/lib/dshSessionPlugin.test.ts b/src/lib/dshSessionPlugin.test.ts index 238f5e3..d0b5534 100644 --- a/src/lib/dshSessionPlugin.test.ts +++ b/src/lib/dshSessionPlugin.test.ts @@ -11,25 +11,36 @@ interface MountOptions { headers: Record; } interface FakeAgent { - session: { header: { cwd: string } }; + session: { header: { cwd: string; origin?: string } }; ctx: { plugin: (client: unknown, options: MountOptions) => { dispose: () => void } }; } +interface ToolView { + visible: Set; + restrictableNames: Set; +} type Handler = (event: { agent: FakeAgent }) => unknown; interface PluginModule { name: string; findProjectId: (roots: Roots | undefined, cwd: unknown) => string | undefined; + readAuthorization: (shim: string, projectId: string, timeoutMs: number) => Promise; apply: (ctx: unknown, config: unknown) => Promise; } const pluginUrl = new URL("../../src-tauri/resources/dsh/lianjing-session-mcp.v1.mjs", import.meta.url).href; const loadPlugin = async (): Promise => (await import(/* @vite-ignore */ pluginUrl)) as PluginModule; +const fixturePath = (name: string) => + decodeURIComponent(new URL(`./__fixtures__/${name}`, import.meta.url).pathname) + .replace(/^\/([A-Za-z]:)/, "$1") + .replace(/\//g, "\\"); const roots: Roots = { "e:\\code-project": null, "e:\\code-project\\demo": "p 1" }; const config = { port: 27190, shim: "Z:\\lianjing-test-missing\\lianjing-mcp-header.cmd", roots }; const fakeClient = { Config: (options: MountOptions) => options, apply: () => undefined }; -function fakeDsh(options: { profile?: string; importFails?: boolean; agentsApi?: boolean } = {}) { +function fakeDsh( + options: { profile?: string; importFails?: boolean; agentsApi?: boolean; toolView?: (agent: FakeAgent) => ToolView } = {}, +) { const handlers = new Map(); const ctx = { get: (key: string) => (key === "profileContext" && options.profile ? { name: options.profile } : undefined), @@ -40,6 +51,7 @@ function fakeDsh(options: { profile?: string; importFails?: boolean; agentsApi?: return fakeClient; }, }, + tools: options.toolView ? { view: options.toolView } : undefined, on: (event: string, handler: Handler) => handlers.set(event, [...(handlers.get(event) ?? []), handler]), effect: () => undefined, }; @@ -49,14 +61,18 @@ function fakeDsh(options: { profile?: string; importFails?: boolean; agentsApi?: return { ctx, handlers, emit }; } -function fakeAgent(cwd: string, mounts: MountOptions[], disposed: string[] = [], throws = false): FakeAgent { +function fakeAgent( + cwd: string, + mounts: MountOptions[], + options: { disposed?: string[]; throws?: boolean; origin?: string } = {}, +): FakeAgent { return { - session: { header: { cwd } }, + session: { header: { cwd, origin: options.origin } }, ctx: { - plugin: (_client, options) => { - if (throws) throw new Error("serverName already in use"); - mounts.push(options); - return { dispose: () => disposed.push(options.url) }; + plugin: (_client, mountOptions) => { + if (options.throws) throw new Error("serverName already in use"); + mounts.push(mountOptions); + return { dispose: () => options.disposed?.push(mountOptions.url) }; }, }, }; @@ -72,6 +88,11 @@ describe("DSH 会话级插件", () => { expect(plugin.findProjectId(roots, "D:\\elsewhere")).toBeUndefined(); expect(plugin.findProjectId(roots, "")).toBeUndefined(); expect(plugin.findProjectId(undefined, "E:\\code-project\\demo")).toBeUndefined(); + // 分层:控制面(null)位于已登记项目之下时,命中即停,不越过它继承上级项目 + const layered: Roots = { "e:\\top": "p-top", "e:\\top\\cp": null }; + expect(plugin.findProjectId(layered, "E:\\top\\cp\\other")).toBeUndefined(); + expect(plugin.findProjectId(layered, "E:\\top\\cp")).toBeUndefined(); + expect(plugin.findProjectId(layered, "E:\\top\\x")).toBe("p-top"); }); it("只给命中项目的会话挂项目专用地址;取不到认证时仍挂载但不带认证头", async () => { @@ -88,13 +109,47 @@ describe("DSH 会话级插件", () => { expect(mounts[0].headers).toEqual({}); }); + it("取认证失败或超时都返回 undefined,且受超时上限约束", async () => { + const plugin = await loadPlugin(); + await expect(plugin.readAuthorization(fixturePath("shim-fail.cmd"), "p1", 3000)).resolves.toBeUndefined(); + const started = Date.now(); + await expect(plugin.readAuthorization(fixturePath("shim-hang.cmd"), "p1", 300)).resolves.toBeUndefined(); + expect(Date.now() - started).toBeLessThan(3000); + }); + + it("子代理:带工具限制或无法判断时不挂载,未受限时照常挂载", async () => { + const plugin = await loadPlugin(); + const allTools = new Set(["job_output", "mcp__lian-jing__list_all_projects"]); + const restricted: ToolView = { visible: new Set(["job_output"]), restrictableNames: allTools }; + const unrestricted: ToolView = { visible: new Set(allTools), restrictableNames: allTools }; + const cwd = "E:\\code-project\\demo"; + + for (const [view, expected] of [ + [restricted, 0], + [unrestricted, 1], + ] as const) { + const dsh = fakeDsh({ profile: "desktop", toolView: () => view }); + await plugin.apply(dsh.ctx, config); + const mounts: MountOptions[] = []; + await dsh.emit("agent/created", fakeAgent(cwd, mounts, { origin: "subagent" })); + expect(mounts).toHaveLength(expected); + } + + const noToolsApi = fakeDsh({ profile: "desktop" }); + await plugin.apply(noToolsApi.ctx, config); + const mounts: MountOptions[] = []; + await noToolsApi.emit("agent/created", fakeAgent(cwd, mounts, { origin: "subagent" })); + await noToolsApi.emit("agent/created", fakeAgent(cwd, mounts)); + expect(mounts).toHaveLength(1); + }); + it("会话销毁时释放挂载", async () => { const plugin = await loadPlugin(); const dsh = fakeDsh({ profile: "web" }); await plugin.apply(dsh.ctx, config); const mounts: MountOptions[] = []; const disposed: string[] = []; - const agent = fakeAgent("E:\\code-project\\demo", mounts, disposed); + const agent = fakeAgent("E:\\code-project\\demo", mounts, { disposed }); await dsh.emit("agent/created", agent); await dsh.emit("agent/disposed", agent); expect(disposed).toEqual(["http://127.0.0.1:27190/mcp/projects/p%201"]); @@ -122,7 +177,7 @@ describe("DSH 会话级插件", () => { await plugin.apply(dsh.ctx, config); const mounts: MountOptions[] = []; await expect( - dsh.emit("agent/created", fakeAgent("E:\\code-project\\demo", mounts, [], true)), + dsh.emit("agent/created", fakeAgent("E:\\code-project\\demo", mounts, { throws: true })), ).resolves.toBeUndefined(); }); }); From f8d658b790efb094623eabd2bba63b9659f89cb0 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Fri, 2 Oct 2026 16:32:21 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs(blueprint/agent-client-parity):=20P9?= =?UTF-8?q?=20=E4=BA=BA=E7=BB=88=E9=AA=8C=E9=80=9A=E8=BF=87=EF=BC=8C?= =?UTF-8?q?=E6=A0=87=E8=AE=B0=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .blueprint/manifest.yaml | 8 ++++---- .blueprint/modules/agent-client-parity.md | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index f6b4dd6..cfe193a 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -6,7 +6,7 @@ description: > 项目自身保留业务规则、测试、部署声明与交付决策。炼境本身亦被纳入其中, 形成可自我进化的开发智能闭环。 iteration: 1 -updated: 2026-10-01 +updated: 2026-10-02 agent_button_carrier: tauri @@ -511,9 +511,9 @@ modules: name: 三端客户端对等 area: infra status: in_progress - 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 模式回退全局。" + progress: 50 + updated: 2026-10-02 + note: "P1/P2/P3/P3b/P9 完成:三端(Claude Code、Codex 含 Desktop、DSH 含桌面 GUI)在 Enterprise 经 %USERPROFILE%\\.lian-jing 稳定入口连项目专用地址,人终验通过。Claude 条目经 claude CLI 写入、以 claude mcp get 为准;DSH 由托管块全局行(headless)+ 会话级插件(GUI/web/acp 按会话工作目录)路由;接入写入幂等。待做 P4 探针固化(依赖 architecture-evolution A1)、P5–P8、P10。" position: [3500, 950] - id: architecture-evolution diff --git a/.blueprint/modules/agent-client-parity.md b/.blueprint/modules/agent-client-parity.md index ef427a7..c6f7bb4 100644 --- a/.blueprint/modules/agent-client-parity.md +++ b/.blueprint/modules/agent-client-parity.md @@ -148,8 +148,9 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。 - 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: in_progress +### ✅ P9 — DSH 桌面 GUI 按工作区连项目地址(会话级插件) +- status: done +- human_verify: "2026-10-02 用户在 DSH 桌面 GUI 终验通过:重启 DSH 后在 enterprise 工作区新建会话,实际调用 list_project_dev_variables(返回 1 条)与 read_project_test_account(name=LJ_PROBE_NONEXISTENT)(返回「开发需求未登记」,即项目地址与认证均生效);在 dev-manager-tauri 工作区(控制面,null 停止哨兵)新建会话,无 list_project_dev_variables 工具。" - complexity: M - files: src-tauri/src/mcp_inject.rs(render_dsh_block 追加插件行), src-tauri/resources/dsh/lianjing-session-mcp.v1.mjs(新增,随二进制打包), src/lib/dshSessionPlugin.test.ts - depends: P1