对抗审查(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>
184 lines
8.1 KiB
TypeScript
184 lines
8.1 KiB
TypeScript
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();
|
||
});
|
||
});
|