对抗审查(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>
167 lines
7.1 KiB
JavaScript
167 lines
7.1 KiB
JavaScript
// 炼境托管的 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}`);
|
||
}
|
||
}
|