mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-17 13:00:48 +00:00
629 lines
22 KiB
TypeScript
629 lines
22 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { clearPluginDiscoveryCache, discoverOpenClawPlugins } from "./discovery.js";
|
|
import {
|
|
cleanupTrackedTempDirs,
|
|
makeTrackedTempDir,
|
|
mkdirSafeDir,
|
|
} from "./test-helpers/fs-fixtures.js";
|
|
|
|
const tempDirs: string[] = [];
|
|
|
|
function makeTempDir() {
|
|
return makeTrackedTempDir("openclaw-plugins", tempDirs);
|
|
}
|
|
|
|
const mkdirSafe = mkdirSafeDir;
|
|
|
|
function buildDiscoveryEnv(stateDir: string): NodeJS.ProcessEnv {
|
|
return {
|
|
OPENCLAW_STATE_DIR: stateDir,
|
|
CLAWDBOT_STATE_DIR: undefined,
|
|
OPENCLAW_HOME: undefined,
|
|
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
|
|
};
|
|
}
|
|
|
|
async function discoverWithStateDir(
|
|
stateDir: string,
|
|
params: Parameters<typeof discoverOpenClawPlugins>[0],
|
|
) {
|
|
return discoverOpenClawPlugins({ ...params, env: buildDiscoveryEnv(stateDir) });
|
|
}
|
|
|
|
function writePluginPackageManifest(params: {
|
|
packageDir: string;
|
|
packageName: string;
|
|
extensions: string[];
|
|
}) {
|
|
fs.writeFileSync(
|
|
path.join(params.packageDir, "package.json"),
|
|
JSON.stringify({
|
|
name: params.packageName,
|
|
openclaw: { extensions: params.extensions },
|
|
}),
|
|
"utf-8",
|
|
);
|
|
}
|
|
|
|
function expectEscapesPackageDiagnostic(diagnostics: Array<{ message: string }>) {
|
|
expect(diagnostics.some((entry) => entry.message.includes("escapes package directory"))).toBe(
|
|
true,
|
|
);
|
|
}
|
|
|
|
afterEach(() => {
|
|
clearPluginDiscoveryCache();
|
|
cleanupTrackedTempDirs(tempDirs);
|
|
});
|
|
|
|
describe("discoverOpenClawPlugins", () => {
|
|
it("discovers global and workspace extensions", async () => {
|
|
const stateDir = makeTempDir();
|
|
const workspaceDir = path.join(stateDir, "workspace");
|
|
|
|
const globalExt = path.join(stateDir, "extensions");
|
|
mkdirSafe(globalExt);
|
|
fs.writeFileSync(path.join(globalExt, "alpha.ts"), "export default function () {}", "utf-8");
|
|
|
|
const workspaceExt = path.join(workspaceDir, ".openclaw", "extensions");
|
|
mkdirSafe(workspaceExt);
|
|
fs.writeFileSync(path.join(workspaceExt, "beta.ts"), "export default function () {}", "utf-8");
|
|
|
|
const { candidates } = await discoverWithStateDir(stateDir, { workspaceDir });
|
|
|
|
const ids = candidates.map((c) => c.idHint);
|
|
expect(ids).toContain("alpha");
|
|
expect(ids).toContain("beta");
|
|
});
|
|
|
|
it("resolves tilde workspace dirs against the provided env", () => {
|
|
const stateDir = makeTempDir();
|
|
const homeDir = makeTempDir();
|
|
const workspaceRoot = path.join(homeDir, "workspace");
|
|
const workspaceExt = path.join(workspaceRoot, ".openclaw", "extensions");
|
|
mkdirSafe(workspaceExt);
|
|
fs.writeFileSync(path.join(workspaceExt, "tilde-workspace.ts"), "export default {}", "utf-8");
|
|
|
|
const result = discoverOpenClawPlugins({
|
|
workspaceDir: "~/workspace",
|
|
env: {
|
|
...buildDiscoveryEnv(stateDir),
|
|
HOME: homeDir,
|
|
},
|
|
});
|
|
|
|
expect(result.candidates.some((candidate) => candidate.idHint === "tilde-workspace")).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("ignores backup and disabled plugin directories in scanned roots", async () => {
|
|
const stateDir = makeTempDir();
|
|
const globalExt = path.join(stateDir, "extensions");
|
|
mkdirSafe(globalExt);
|
|
|
|
const backupDir = path.join(globalExt, "feishu.backup-20260222");
|
|
mkdirSafe(backupDir);
|
|
fs.writeFileSync(path.join(backupDir, "index.ts"), "export default function () {}", "utf-8");
|
|
|
|
const disabledDir = path.join(globalExt, "telegram.disabled.20260222");
|
|
mkdirSafe(disabledDir);
|
|
fs.writeFileSync(path.join(disabledDir, "index.ts"), "export default function () {}", "utf-8");
|
|
|
|
const bakDir = path.join(globalExt, "discord.bak");
|
|
mkdirSafe(bakDir);
|
|
fs.writeFileSync(path.join(bakDir, "index.ts"), "export default function () {}", "utf-8");
|
|
|
|
const liveDir = path.join(globalExt, "live");
|
|
mkdirSafe(liveDir);
|
|
fs.writeFileSync(path.join(liveDir, "index.ts"), "export default function () {}", "utf-8");
|
|
|
|
const { candidates } = await discoverWithStateDir(stateDir, {});
|
|
|
|
const ids = candidates.map((candidate) => candidate.idHint);
|
|
expect(ids).toContain("live");
|
|
expect(ids).not.toContain("feishu.backup-20260222");
|
|
expect(ids).not.toContain("telegram.disabled.20260222");
|
|
expect(ids).not.toContain("discord.bak");
|
|
});
|
|
|
|
it("loads package extension packs", async () => {
|
|
const stateDir = makeTempDir();
|
|
const globalExt = path.join(stateDir, "extensions", "pack");
|
|
mkdirSafe(path.join(globalExt, "src"));
|
|
|
|
writePluginPackageManifest({
|
|
packageDir: globalExt,
|
|
packageName: "pack",
|
|
extensions: ["./src/one.ts", "./src/two.ts"],
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(globalExt, "src", "one.ts"),
|
|
"export default function () {}",
|
|
"utf-8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(globalExt, "src", "two.ts"),
|
|
"export default function () {}",
|
|
"utf-8",
|
|
);
|
|
|
|
const { candidates } = await discoverWithStateDir(stateDir, {});
|
|
|
|
const ids = candidates.map((c) => c.idHint);
|
|
expect(ids).toContain("pack/one");
|
|
expect(ids).toContain("pack/two");
|
|
});
|
|
|
|
it("derives unscoped ids for scoped packages", async () => {
|
|
const stateDir = makeTempDir();
|
|
const globalExt = path.join(stateDir, "extensions", "voice-call-pack");
|
|
mkdirSafe(path.join(globalExt, "src"));
|
|
|
|
writePluginPackageManifest({
|
|
packageDir: globalExt,
|
|
packageName: "@openclaw/voice-call",
|
|
extensions: ["./src/index.ts"],
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(globalExt, "src", "index.ts"),
|
|
"export default function () {}",
|
|
"utf-8",
|
|
);
|
|
|
|
const { candidates } = await discoverWithStateDir(stateDir, {});
|
|
|
|
const ids = candidates.map((c) => c.idHint);
|
|
expect(ids).toContain("voice-call");
|
|
});
|
|
|
|
it("normalizes bundled provider package ids to canonical plugin ids", async () => {
|
|
const stateDir = makeTempDir();
|
|
const globalExt = path.join(stateDir, "extensions", "ollama-provider-pack");
|
|
mkdirSafe(path.join(globalExt, "src"));
|
|
|
|
writePluginPackageManifest({
|
|
packageDir: globalExt,
|
|
packageName: "@openclaw/ollama-provider",
|
|
extensions: ["./src/index.ts"],
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(globalExt, "src", "index.ts"),
|
|
"export default function () {}",
|
|
"utf-8",
|
|
);
|
|
|
|
const { candidates } = await discoverWithStateDir(stateDir, {});
|
|
|
|
const ids = candidates.map((c) => c.idHint);
|
|
expect(ids).toContain("ollama");
|
|
expect(ids).not.toContain("ollama-provider");
|
|
});
|
|
|
|
it("treats configured directory paths as plugin packages", async () => {
|
|
const stateDir = makeTempDir();
|
|
const packDir = path.join(stateDir, "packs", "demo-plugin-dir");
|
|
mkdirSafe(packDir);
|
|
|
|
writePluginPackageManifest({
|
|
packageDir: packDir,
|
|
packageName: "@openclaw/demo-plugin-dir",
|
|
extensions: ["./index.js"],
|
|
});
|
|
fs.writeFileSync(path.join(packDir, "index.js"), "module.exports = {}", "utf-8");
|
|
|
|
const { candidates } = await discoverWithStateDir(stateDir, { extraPaths: [packDir] });
|
|
|
|
const ids = candidates.map((c) => c.idHint);
|
|
expect(ids).toContain("demo-plugin-dir");
|
|
});
|
|
|
|
it("auto-detects Codex bundles as bundle candidates", async () => {
|
|
const stateDir = makeTempDir();
|
|
const bundleDir = path.join(stateDir, "extensions", "sample-bundle");
|
|
mkdirSafe(path.join(bundleDir, ".codex-plugin"));
|
|
mkdirSafe(path.join(bundleDir, "skills"));
|
|
fs.writeFileSync(
|
|
path.join(bundleDir, ".codex-plugin", "plugin.json"),
|
|
JSON.stringify({
|
|
name: "Sample Bundle",
|
|
skills: "skills",
|
|
}),
|
|
"utf-8",
|
|
);
|
|
|
|
const { candidates } = await discoverWithStateDir(stateDir, {});
|
|
const bundle = candidates.find((candidate) => candidate.idHint === "sample-bundle");
|
|
|
|
expect(bundle).toBeDefined();
|
|
expect(bundle?.idHint).toBe("sample-bundle");
|
|
expect(bundle?.format).toBe("bundle");
|
|
expect(bundle?.bundleFormat).toBe("codex");
|
|
expect(bundle?.source).toBe(bundleDir);
|
|
expect(bundle?.rootDir).toBe(fs.realpathSync.native(bundleDir));
|
|
});
|
|
|
|
it("auto-detects manifestless Claude bundles from the default layout", async () => {
|
|
const stateDir = makeTempDir();
|
|
const bundleDir = path.join(stateDir, "extensions", "claude-bundle");
|
|
mkdirSafe(path.join(bundleDir, "commands"));
|
|
fs.writeFileSync(path.join(bundleDir, "settings.json"), '{"hideThinkingBlock":true}', "utf-8");
|
|
|
|
const { candidates } = await discoverWithStateDir(stateDir, {});
|
|
const bundle = candidates.find((candidate) => candidate.idHint === "claude-bundle");
|
|
|
|
expect(bundle).toBeDefined();
|
|
expect(bundle?.format).toBe("bundle");
|
|
expect(bundle?.bundleFormat).toBe("claude");
|
|
expect(bundle?.source).toBe(bundleDir);
|
|
});
|
|
|
|
it("auto-detects Cursor bundles as bundle candidates", async () => {
|
|
const stateDir = makeTempDir();
|
|
const bundleDir = path.join(stateDir, "extensions", "cursor-bundle");
|
|
mkdirSafe(path.join(bundleDir, ".cursor-plugin"));
|
|
mkdirSafe(path.join(bundleDir, ".cursor", "commands"));
|
|
fs.writeFileSync(
|
|
path.join(bundleDir, ".cursor-plugin", "plugin.json"),
|
|
JSON.stringify({
|
|
name: "Cursor Bundle",
|
|
}),
|
|
"utf-8",
|
|
);
|
|
|
|
const { candidates } = await discoverWithStateDir(stateDir, {});
|
|
const bundle = candidates.find((candidate) => candidate.idHint === "cursor-bundle");
|
|
|
|
expect(bundle).toBeDefined();
|
|
expect(bundle?.format).toBe("bundle");
|
|
expect(bundle?.bundleFormat).toBe("cursor");
|
|
expect(bundle?.source).toBe(bundleDir);
|
|
});
|
|
|
|
it("falls back to legacy index discovery when a scanned bundle sidecar is malformed", async () => {
|
|
const stateDir = makeTempDir();
|
|
const pluginDir = path.join(stateDir, "extensions", "legacy-with-bad-bundle");
|
|
mkdirSafe(path.join(pluginDir, ".claude-plugin"));
|
|
fs.writeFileSync(path.join(pluginDir, "index.ts"), "export default {}", "utf-8");
|
|
fs.writeFileSync(path.join(pluginDir, ".claude-plugin", "plugin.json"), "{", "utf-8");
|
|
|
|
const result = await discoverWithStateDir(stateDir, {});
|
|
const legacy = result.candidates.find(
|
|
(candidate) => candidate.idHint === "legacy-with-bad-bundle",
|
|
);
|
|
|
|
expect(legacy).toBeDefined();
|
|
expect(legacy?.format).toBe("openclaw");
|
|
expect(
|
|
result.diagnostics.some((entry) => entry.source?.endsWith(".claude-plugin/plugin.json")),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("falls back to legacy index discovery for configured paths with malformed bundle sidecars", async () => {
|
|
const stateDir = makeTempDir();
|
|
const pluginDir = path.join(stateDir, "plugins", "legacy-with-bad-bundle");
|
|
mkdirSafe(path.join(pluginDir, ".codex-plugin"));
|
|
fs.writeFileSync(path.join(pluginDir, "index.ts"), "export default {}", "utf-8");
|
|
fs.writeFileSync(path.join(pluginDir, ".codex-plugin", "plugin.json"), "{", "utf-8");
|
|
|
|
const result = await discoverWithStateDir(stateDir, {
|
|
extraPaths: [pluginDir],
|
|
});
|
|
const legacy = result.candidates.find(
|
|
(candidate) => candidate.idHint === "legacy-with-bad-bundle",
|
|
);
|
|
|
|
expect(legacy).toBeDefined();
|
|
expect(legacy?.format).toBe("openclaw");
|
|
expect(
|
|
result.diagnostics.some((entry) => entry.source?.endsWith(".codex-plugin/plugin.json")),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("blocks extension entries that escape package directory", async () => {
|
|
const stateDir = makeTempDir();
|
|
const globalExt = path.join(stateDir, "extensions", "escape-pack");
|
|
const outside = path.join(stateDir, "outside.js");
|
|
mkdirSafe(globalExt);
|
|
|
|
writePluginPackageManifest({
|
|
packageDir: globalExt,
|
|
packageName: "@openclaw/escape-pack",
|
|
extensions: ["../../outside.js"],
|
|
});
|
|
fs.writeFileSync(outside, "export default function () {}", "utf-8");
|
|
|
|
const result = await discoverWithStateDir(stateDir, {});
|
|
|
|
expect(result.candidates).toHaveLength(0);
|
|
expectEscapesPackageDiagnostic(result.diagnostics);
|
|
});
|
|
|
|
it("rejects package extension entries that escape via symlink", async () => {
|
|
const stateDir = makeTempDir();
|
|
const globalExt = path.join(stateDir, "extensions", "pack");
|
|
const outsideDir = path.join(stateDir, "outside");
|
|
const linkedDir = path.join(globalExt, "linked");
|
|
mkdirSafe(globalExt);
|
|
mkdirSafe(outsideDir);
|
|
fs.writeFileSync(path.join(outsideDir, "escape.ts"), "export default {}", "utf-8");
|
|
try {
|
|
fs.symlinkSync(outsideDir, linkedDir, process.platform === "win32" ? "junction" : "dir");
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
writePluginPackageManifest({
|
|
packageDir: globalExt,
|
|
packageName: "@openclaw/pack",
|
|
extensions: ["./linked/escape.ts"],
|
|
});
|
|
|
|
const { candidates, diagnostics } = await discoverWithStateDir(stateDir, {});
|
|
|
|
expect(candidates.some((candidate) => candidate.idHint === "pack")).toBe(false);
|
|
expectEscapesPackageDiagnostic(diagnostics);
|
|
});
|
|
|
|
it("rejects package extension entries that are hardlinked aliases", async () => {
|
|
if (process.platform === "win32") {
|
|
return;
|
|
}
|
|
const stateDir = makeTempDir();
|
|
const globalExt = path.join(stateDir, "extensions", "pack");
|
|
const outsideDir = path.join(stateDir, "outside");
|
|
const outsideFile = path.join(outsideDir, "escape.ts");
|
|
const linkedFile = path.join(globalExt, "escape.ts");
|
|
mkdirSafe(globalExt);
|
|
mkdirSafe(outsideDir);
|
|
fs.writeFileSync(outsideFile, "export default {}", "utf-8");
|
|
try {
|
|
fs.linkSync(outsideFile, linkedFile);
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code === "EXDEV") {
|
|
return;
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
writePluginPackageManifest({
|
|
packageDir: globalExt,
|
|
packageName: "@openclaw/pack",
|
|
extensions: ["./escape.ts"],
|
|
});
|
|
|
|
const { candidates, diagnostics } = await discoverWithStateDir(stateDir, {});
|
|
|
|
expect(candidates.some((candidate) => candidate.idHint === "pack")).toBe(false);
|
|
expectEscapesPackageDiagnostic(diagnostics);
|
|
});
|
|
|
|
it("ignores package manifests that are hardlinked aliases", async () => {
|
|
if (process.platform === "win32") {
|
|
return;
|
|
}
|
|
const stateDir = makeTempDir();
|
|
const globalExt = path.join(stateDir, "extensions", "pack");
|
|
const outsideDir = path.join(stateDir, "outside");
|
|
const outsideManifest = path.join(outsideDir, "package.json");
|
|
const linkedManifest = path.join(globalExt, "package.json");
|
|
mkdirSafe(globalExt);
|
|
mkdirSafe(outsideDir);
|
|
fs.writeFileSync(path.join(globalExt, "entry.ts"), "export default {}", "utf-8");
|
|
fs.writeFileSync(
|
|
outsideManifest,
|
|
JSON.stringify({
|
|
name: "@openclaw/pack",
|
|
openclaw: { extensions: ["./entry.ts"] },
|
|
}),
|
|
"utf-8",
|
|
);
|
|
try {
|
|
fs.linkSync(outsideManifest, linkedManifest);
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code === "EXDEV") {
|
|
return;
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
const { candidates } = await discoverWithStateDir(stateDir, {});
|
|
|
|
expect(candidates.some((candidate) => candidate.idHint === "pack")).toBe(false);
|
|
});
|
|
|
|
it.runIf(process.platform !== "win32")("blocks world-writable plugin paths", async () => {
|
|
const stateDir = makeTempDir();
|
|
const globalExt = path.join(stateDir, "extensions");
|
|
mkdirSafe(globalExt);
|
|
const pluginPath = path.join(globalExt, "world-open.ts");
|
|
fs.writeFileSync(pluginPath, "export default function () {}", "utf-8");
|
|
fs.chmodSync(pluginPath, 0o777);
|
|
|
|
const result = await discoverWithStateDir(stateDir, {});
|
|
|
|
expect(result.candidates).toHaveLength(0);
|
|
expect(result.diagnostics.some((diag) => diag.message.includes("world-writable path"))).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"repairs world-writable bundled plugin dirs before loading them",
|
|
async () => {
|
|
const stateDir = makeTempDir();
|
|
const bundledDir = path.join(stateDir, "bundled");
|
|
const packDir = path.join(bundledDir, "demo-pack");
|
|
mkdirSafe(packDir);
|
|
fs.writeFileSync(path.join(packDir, "index.ts"), "export default function () {}", "utf-8");
|
|
fs.chmodSync(packDir, 0o777);
|
|
|
|
const result = discoverOpenClawPlugins({
|
|
env: {
|
|
...process.env,
|
|
OPENCLAW_STATE_DIR: stateDir,
|
|
CLAWDBOT_STATE_DIR: undefined,
|
|
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir,
|
|
},
|
|
});
|
|
|
|
expect(result.candidates.some((candidate) => candidate.idHint === "demo-pack")).toBe(true);
|
|
expect(
|
|
result.diagnostics.some(
|
|
(diag) => diag.source === packDir && diag.message.includes("world-writable path"),
|
|
),
|
|
).toBe(false);
|
|
expect(fs.statSync(packDir).mode & 0o777).toBe(0o755);
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32" && typeof process.getuid === "function")(
|
|
"blocks suspicious ownership when uid mismatch is detected",
|
|
async () => {
|
|
const stateDir = makeTempDir();
|
|
const globalExt = path.join(stateDir, "extensions");
|
|
mkdirSafe(globalExt);
|
|
fs.writeFileSync(
|
|
path.join(globalExt, "owner-mismatch.ts"),
|
|
"export default function () {}",
|
|
"utf-8",
|
|
);
|
|
|
|
const actualUid = (process as NodeJS.Process & { getuid: () => number }).getuid();
|
|
const result = await discoverWithStateDir(stateDir, { ownershipUid: actualUid + 1 });
|
|
const shouldBlockForMismatch = actualUid !== 0;
|
|
expect(result.candidates).toHaveLength(shouldBlockForMismatch ? 0 : 1);
|
|
expect(result.diagnostics.some((diag) => diag.message.includes("suspicious ownership"))).toBe(
|
|
shouldBlockForMismatch,
|
|
);
|
|
},
|
|
);
|
|
|
|
it("reuses discovery results from cache until cleared", async () => {
|
|
const stateDir = makeTempDir();
|
|
const globalExt = path.join(stateDir, "extensions");
|
|
mkdirSafe(globalExt);
|
|
const pluginPath = path.join(globalExt, "cached.ts");
|
|
fs.writeFileSync(pluginPath, "export default function () {}", "utf-8");
|
|
|
|
const first = discoverOpenClawPlugins({
|
|
env: {
|
|
...buildDiscoveryEnv(stateDir),
|
|
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000",
|
|
},
|
|
});
|
|
expect(first.candidates.some((candidate) => candidate.idHint === "cached")).toBe(true);
|
|
|
|
fs.rmSync(pluginPath, { force: true });
|
|
|
|
const second = discoverOpenClawPlugins({
|
|
env: {
|
|
...buildDiscoveryEnv(stateDir),
|
|
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000",
|
|
},
|
|
});
|
|
expect(second.candidates.some((candidate) => candidate.idHint === "cached")).toBe(true);
|
|
|
|
clearPluginDiscoveryCache();
|
|
|
|
const third = discoverOpenClawPlugins({
|
|
env: {
|
|
...buildDiscoveryEnv(stateDir),
|
|
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000",
|
|
},
|
|
});
|
|
expect(third.candidates.some((candidate) => candidate.idHint === "cached")).toBe(false);
|
|
});
|
|
|
|
it("does not reuse discovery results across env root changes", () => {
|
|
const stateDirA = makeTempDir();
|
|
const stateDirB = makeTempDir();
|
|
const globalExtA = path.join(stateDirA, "extensions");
|
|
const globalExtB = path.join(stateDirB, "extensions");
|
|
mkdirSafe(globalExtA);
|
|
mkdirSafe(globalExtB);
|
|
fs.writeFileSync(path.join(globalExtA, "alpha.ts"), "export default function () {}", "utf-8");
|
|
fs.writeFileSync(path.join(globalExtB, "beta.ts"), "export default function () {}", "utf-8");
|
|
|
|
const first = discoverOpenClawPlugins({
|
|
env: {
|
|
...buildDiscoveryEnv(stateDirA),
|
|
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000",
|
|
},
|
|
});
|
|
const second = discoverOpenClawPlugins({
|
|
env: {
|
|
...buildDiscoveryEnv(stateDirB),
|
|
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000",
|
|
},
|
|
});
|
|
|
|
expect(first.candidates.some((candidate) => candidate.idHint === "alpha")).toBe(true);
|
|
expect(first.candidates.some((candidate) => candidate.idHint === "beta")).toBe(false);
|
|
expect(second.candidates.some((candidate) => candidate.idHint === "alpha")).toBe(false);
|
|
expect(second.candidates.some((candidate) => candidate.idHint === "beta")).toBe(true);
|
|
});
|
|
|
|
it("does not reuse extra-path discovery across env home changes", () => {
|
|
const stateDir = makeTempDir();
|
|
const homeA = makeTempDir();
|
|
const homeB = makeTempDir();
|
|
const pluginA = path.join(homeA, "plugins", "demo.ts");
|
|
const pluginB = path.join(homeB, "plugins", "demo.ts");
|
|
mkdirSafe(path.dirname(pluginA));
|
|
mkdirSafe(path.dirname(pluginB));
|
|
fs.writeFileSync(pluginA, "export default {}", "utf-8");
|
|
fs.writeFileSync(pluginB, "export default {}", "utf-8");
|
|
|
|
const first = discoverOpenClawPlugins({
|
|
extraPaths: ["~/plugins/demo.ts"],
|
|
env: {
|
|
...buildDiscoveryEnv(stateDir),
|
|
HOME: homeA,
|
|
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000",
|
|
},
|
|
});
|
|
const second = discoverOpenClawPlugins({
|
|
extraPaths: ["~/plugins/demo.ts"],
|
|
env: {
|
|
...buildDiscoveryEnv(stateDir),
|
|
HOME: homeB,
|
|
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000",
|
|
},
|
|
});
|
|
|
|
expect(first.candidates.find((candidate) => candidate.idHint === "demo")?.source).toBe(pluginA);
|
|
expect(second.candidates.find((candidate) => candidate.idHint === "demo")?.source).toBe(
|
|
pluginB,
|
|
);
|
|
});
|
|
|
|
it("treats configured load-path order as cache-significant", () => {
|
|
const stateDir = makeTempDir();
|
|
const pluginA = path.join(stateDir, "plugins", "alpha.ts");
|
|
const pluginB = path.join(stateDir, "plugins", "beta.ts");
|
|
mkdirSafe(path.dirname(pluginA));
|
|
fs.writeFileSync(pluginA, "export default {}", "utf-8");
|
|
fs.writeFileSync(pluginB, "export default {}", "utf-8");
|
|
|
|
const env = {
|
|
...buildDiscoveryEnv(stateDir),
|
|
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000",
|
|
};
|
|
|
|
const first = discoverOpenClawPlugins({
|
|
extraPaths: [pluginA, pluginB],
|
|
env,
|
|
});
|
|
const second = discoverOpenClawPlugins({
|
|
extraPaths: [pluginB, pluginA],
|
|
env,
|
|
});
|
|
|
|
expect(first.candidates.map((candidate) => candidate.idHint)).toEqual(["alpha", "beta"]);
|
|
expect(second.candidates.map((candidate) => candidate.idHint)).toEqual(["beta", "alpha"]);
|
|
});
|
|
});
|