dev-manager-tauri/src-tauri/resources/dsh/lianjing-session-mcp.v1.mjs
lanrtop f21345bf1a feat(mcp/parity): DSH 桌面 GUI 按工作区连项目地址(会话级插件,P9)
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 <noreply@anthropic.com>
2026-10-02 13:33:45 +09:00

147 lines
6.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 炼境托管的 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}`);
}
}