dev-manager-tauri/src/lib/dshSessionPlugin.test.ts
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

184 lines
8.1 KiB
TypeScript
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.

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; origin?: string } };
ctx: { plugin: (client: unknown, options: MountOptions) => { dispose: () => void } };
}
interface ToolView {
visible: Set<string>;
restrictableNames: Set<string>;
}
type Handler = (event: { agent: FakeAgent }) => unknown;
interface PluginModule {
name: string;
findProjectId: (roots: Roots | undefined, cwd: unknown) => string | undefined;
readAuthorization: (shim: string, projectId: string, timeoutMs: number) => Promise<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 fixturePath = (name: string) =>
decodeURIComponent(new URL(`./__fixtures__/${name}`, import.meta.url).pathname)
.replace(/^\/([A-Za-z]:)/, "$1")
.replace(/\//g, "\\");
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; toolView?: (agent: FakeAgent) => ToolView } = {},
) {
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;
},
},
tools: options.toolView ? { view: options.toolView } : undefined,
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[],
options: { disposed?: string[]; throws?: boolean; origin?: string } = {},
): FakeAgent {
return {
session: { header: { cwd, origin: options.origin } },
ctx: {
plugin: (_client, mountOptions) => {
if (options.throws) throw new Error("serverName already in use");
mounts.push(mountOptions);
return { dispose: () => options.disposed?.push(mountOptions.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();
// 分层:控制面(null)位于已登记项目之下时,命中即停,不越过它继承上级项目
const layered: Roots = { "e:\\top": "p-top", "e:\\top\\cp": null };
expect(plugin.findProjectId(layered, "E:\\top\\cp\\other")).toBeUndefined();
expect(plugin.findProjectId(layered, "E:\\top\\cp")).toBeUndefined();
expect(plugin.findProjectId(layered, "E:\\top\\x")).toBe("p-top");
});
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("取认证失败或超时都返回 undefined,且受超时上限约束", async () => {
const plugin = await loadPlugin();
await expect(plugin.readAuthorization(fixturePath("shim-fail.cmd"), "p1", 3000)).resolves.toBeUndefined();
const started = Date.now();
await expect(plugin.readAuthorization(fixturePath("shim-hang.cmd"), "p1", 300)).resolves.toBeUndefined();
expect(Date.now() - started).toBeLessThan(3000);
});
it("子代理:带工具限制或无法判断时不挂载,未受限时照常挂载", async () => {
const plugin = await loadPlugin();
const allTools = new Set(["job_output", "mcp__lian-jing__list_all_projects"]);
const restricted: ToolView = { visible: new Set(["job_output"]), restrictableNames: allTools };
const unrestricted: ToolView = { visible: new Set(allTools), restrictableNames: allTools };
const cwd = "E:\\code-project\\demo";
for (const [view, expected] of [
[restricted, 0],
[unrestricted, 1],
] as const) {
const dsh = fakeDsh({ profile: "desktop", toolView: () => view });
await plugin.apply(dsh.ctx, config);
const mounts: MountOptions[] = [];
await dsh.emit("agent/created", fakeAgent(cwd, mounts, { origin: "subagent" }));
expect(mounts).toHaveLength(expected);
}
const noToolsApi = fakeDsh({ profile: "desktop" });
await plugin.apply(noToolsApi.ctx, config);
const mounts: MountOptions[] = [];
await noToolsApi.emit("agent/created", fakeAgent(cwd, mounts, { origin: "subagent" }));
await noToolsApi.emit("agent/created", fakeAgent(cwd, mounts));
expect(mounts).toHaveLength(1);
});
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, { throws: true })),
).resolves.toBeUndefined();
});
});