openclaw/src/agents/skills/plugin-skills.test.ts
2026-03-02 07:13:10 +00:00

171 lines
5.2 KiB
TypeScript

import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import type { PluginManifestRegistry } from "../../plugins/manifest-registry.js";
import { createTrackedTempDirs } from "../../test-utils/tracked-temp-dirs.js";
const hoisted = vi.hoisted(() => ({
loadPluginManifestRegistry: vi.fn(),
}));
vi.mock("../../plugins/manifest-registry.js", () => ({
loadPluginManifestRegistry: (...args: unknown[]) => hoisted.loadPluginManifestRegistry(...args),
}));
const { resolvePluginSkillDirs } = await import("./plugin-skills.js");
const tempDirs = createTrackedTempDirs();
function buildRegistry(params: { acpxRoot: string; helperRoot: string }): PluginManifestRegistry {
return {
diagnostics: [],
plugins: [
{
id: "acpx",
name: "ACPX Runtime",
channels: [],
providers: [],
skills: ["./skills"],
origin: "workspace",
rootDir: params.acpxRoot,
source: params.acpxRoot,
manifestPath: path.join(params.acpxRoot, "openclaw.plugin.json"),
},
{
id: "helper",
name: "Helper",
channels: [],
providers: [],
skills: ["./skills"],
origin: "workspace",
rootDir: params.helperRoot,
source: params.helperRoot,
manifestPath: path.join(params.helperRoot, "openclaw.plugin.json"),
},
],
};
}
function createSinglePluginRegistry(params: {
pluginRoot: string;
skills: string[];
}): PluginManifestRegistry {
return {
diagnostics: [],
plugins: [
{
id: "helper",
name: "Helper",
channels: [],
providers: [],
skills: params.skills,
origin: "workspace",
rootDir: params.pluginRoot,
source: params.pluginRoot,
manifestPath: path.join(params.pluginRoot, "openclaw.plugin.json"),
},
],
};
}
async function setupAcpxAndHelperRegistry() {
const workspaceDir = await tempDirs.make("openclaw-");
const acpxRoot = await tempDirs.make("openclaw-acpx-plugin-");
const helperRoot = await tempDirs.make("openclaw-helper-plugin-");
await fs.mkdir(path.join(acpxRoot, "skills"), { recursive: true });
await fs.mkdir(path.join(helperRoot, "skills"), { recursive: true });
hoisted.loadPluginManifestRegistry.mockReturnValue(buildRegistry({ acpxRoot, helperRoot }));
return { workspaceDir, acpxRoot, helperRoot };
}
async function setupPluginOutsideSkills() {
const workspaceDir = await tempDirs.make("openclaw-");
const pluginRoot = await tempDirs.make("openclaw-plugin-");
const outsideDir = await tempDirs.make("openclaw-outside-");
const outsideSkills = path.join(outsideDir, "skills");
return { workspaceDir, pluginRoot, outsideSkills };
}
afterEach(async () => {
hoisted.loadPluginManifestRegistry.mockReset();
await tempDirs.cleanup();
});
describe("resolvePluginSkillDirs", () => {
it.each([
{
name: "keeps acpx plugin skills when ACP is enabled",
acpEnabled: true,
expectedDirs: ({ acpxRoot, helperRoot }: { acpxRoot: string; helperRoot: string }) => [
path.resolve(acpxRoot, "skills"),
path.resolve(helperRoot, "skills"),
],
},
{
name: "skips acpx plugin skills when ACP is disabled",
acpEnabled: false,
expectedDirs: ({ helperRoot }: { acpxRoot: string; helperRoot: string }) => [
path.resolve(helperRoot, "skills"),
],
},
])("$name", async ({ acpEnabled, expectedDirs }) => {
const { workspaceDir, acpxRoot, helperRoot } = await setupAcpxAndHelperRegistry();
const dirs = resolvePluginSkillDirs({
workspaceDir,
config: {
acp: { enabled: acpEnabled },
} as OpenClawConfig,
});
expect(dirs).toEqual(expectedDirs({ acpxRoot, helperRoot }));
});
it("rejects plugin skill paths that escape the plugin root", async () => {
const { workspaceDir, pluginRoot, outsideSkills } = await setupPluginOutsideSkills();
await fs.mkdir(path.join(pluginRoot, "skills"), { recursive: true });
await fs.mkdir(outsideSkills, { recursive: true });
const escapePath = path.relative(pluginRoot, outsideSkills);
hoisted.loadPluginManifestRegistry.mockReturnValue(
createSinglePluginRegistry({
pluginRoot,
skills: ["./skills", escapePath],
}),
);
const dirs = resolvePluginSkillDirs({
workspaceDir,
config: {} as OpenClawConfig,
});
expect(dirs).toEqual([path.resolve(pluginRoot, "skills")]);
});
it("rejects plugin skill symlinks that resolve outside plugin root", async () => {
const { workspaceDir, pluginRoot, outsideSkills } = await setupPluginOutsideSkills();
const linkPath = path.join(pluginRoot, "skills-link");
await fs.mkdir(outsideSkills, { recursive: true });
await fs.symlink(
outsideSkills,
linkPath,
process.platform === "win32" ? ("junction" as const) : ("dir" as const),
);
hoisted.loadPluginManifestRegistry.mockReturnValue(
createSinglePluginRegistry({
pluginRoot,
skills: ["./skills-link"],
}),
);
const dirs = resolvePluginSkillDirs({
workspaceDir,
config: {} as OpenClawConfig,
});
expect(dirs).toEqual([]);
});
});