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>
147 lines
6.1 KiB
JavaScript
147 lines
6.1 KiB
JavaScript
// 炼境托管的 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}`);
|
||
}
|
||
}
|