feat(mcp/parity): DSH 桌面 GUI 按工作区连项目地址(会话级插件,P9) #26
@ -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/<id> 请求。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/<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 相同,但需按「管家准入」论证。
|
||||
- 可行性实测(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/<id> 的 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/<enterprise id> 的 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
|
||||
|
||||
@ -6,7 +6,7 @@ contracts:
|
||||
- label: API 接口(Axum)
|
||||
paths:
|
||||
- "src-tauri/src/mcp_server.rs"
|
||||
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 按钮体系文档。"
|
||||
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/桌面版一个进程可服务多个工作区,全局行一律走全局以防跨项目带认证;这些模式由同一托管块引用的会话级插件(随炼境打包,写到 %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:
|
||||
|
||||
146
src-tauri/resources/dsh/lianjing-session-mcp.v1.mjs
Normal file
146
src-tauri/resources/dsh/lianjing-session-mcp.v1.mjs
Normal file
@ -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}`);
|
||||
}
|
||||
}
|
||||
@ -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<DshSessionPlugin, String> {
|
||||
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>)]) -> String {
|
||||
fn render_dsh_block(
|
||||
port: u16,
|
||||
roots: &[(String, Option<String>)],
|
||||
session: Option<&DshSessionPlugin>,
|
||||
) -> String {
|
||||
// 逐行显式缩进:YAML 依赖缩进,不能用字符串续行(会吞掉下一行行首空白)。
|
||||
fn indent(spaces: usize, lines: &[&str]) -> Vec<String> {
|
||||
lines
|
||||
@ -790,7 +827,8 @@ fn render_dsh_block(port: u16, roots: &[(String, Option<String>)]) -> 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>)]) -> 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<bool, String> {
|
||||
.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(),
|
||||
"块外手写也须拒绝"
|
||||
);
|
||||
}
|
||||
|
||||
128
src/lib/dshSessionPlugin.test.ts
Normal file
128
src/lib/dshSessionPlugin.test.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// 炼境分发给 DSH 的会话级插件(src-tauri/resources/dsh/):用假的 DSH ctx 验证挂载边界与「永不抛错」约束。
|
||||
// 项目只在 Windows 上运行,路径断言按 Windows 语义书写。
|
||||
|
||||
type Roots = Record<string, string | null>;
|
||||
interface MountOptions {
|
||||
transport: string;
|
||||
serverName: string;
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
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<void>;
|
||||
}
|
||||
|
||||
const pluginUrl = new URL("../../src-tauri/resources/dsh/lianjing-session-mcp.v1.mjs", import.meta.url).href;
|
||||
const loadPlugin = async (): Promise<PluginModule> =>
|
||||
(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<string, Handler[]>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user