dev-manager-tauri/src-tauri/resources/dsh/lianjing-session-mcp.v1.mjs
lanrtop fe0458fe97
All checks were successful
Push & PR Check / check (push) Successful in 1m6s
Push & PR Check / check (pull_request) Successful in 1m4s
fix(mcp/parity): P9 审查修复——受限子代理不挂载、条目 id 带版本、插件缺失守卫、规范路径键
对抗审查(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 <noreply@anthropic.com>
2026-10-02 15:04:54 +09:00

167 lines
7.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 托管块中引用,勿手改,会被覆盖。
// 内容变化时文件名与托管块条目 id 的版本号必须一起递增:DSH 只在条目 id 变化时才卸载旧条目、import 新模块
// (同 id 只改 name 不会重载,Node 也按 URL 缓存 ESM);mcp_inject.rs 的内容哈希测试会拦住漏升版本。
//
// 约束: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", "tools"];
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";
}
/**
* 子代理是否带工具限制(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) => {
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;
if (isRestrictedSubagent(ctx, agent)) 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}`);
}
}