mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-30 11:21:07 +00:00
805 lines
27 KiB
TypeScript
805 lines
27 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 normalizePathForAssertion(value: string | undefined): string | undefined {
|
|
if (!value) {
|
|
return value;
|
|
}
|
|
return value.replace(/\\/g, "/");
|
|
}
|
|
|
|
function hasDiagnosticSourceSuffix(
|
|
diagnostics: Array<{ source?: string }>,
|
|
suffix: string,
|
|
): boolean {
|
|
const normalizedSuffix = normalizePathForAssertion(suffix);
|
|
return diagnostics.some((entry) =>
|
|
normalizePathForAssertion(entry.source)?.endsWith(normalizedSuffix ?? suffix),
|
|
);
|
|
}
|
|
|
|
function buildDiscoveryEnv(stateDir: string): NodeJS.ProcessEnv {
|
|
return {
|
|
OPENCLAW_STATE_DIR: stateDir,
|
|
OPENCLAW_HOME: undefined,
|
|
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
|
|
};
|
|
}
|
|
|
|
function buildCachedDiscoveryEnv(
|
|
stateDir: string,
|
|
overrides: Partial<NodeJS.ProcessEnv> = {},
|
|
): NodeJS.ProcessEnv {
|
|
return {
|
|
...buildDiscoveryEnv(stateDir),
|
|
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
async function discoverWithStateDir(
|
|
stateDir: string,
|
|
params: Parameters<typeof discoverOpenClawPlugins>[0],
|
|
) {
|
|
return discoverOpenClawPlugins({ ...params, env: buildDiscoveryEnv(stateDir) });
|
|
}
|
|
|
|
function discoverWithCachedEnv(params: Parameters<typeof discoverOpenClawPlugins>[0]) {
|
|
return discoverOpenClawPlugins(params);
|
|
}
|
|
|
|
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 writePluginManifest(params: { pluginDir: string; id: string }) {
|
|
fs.writeFileSync(
|
|
path.join(params.pluginDir, "openclaw.plugin.json"),
|
|
JSON.stringify({
|
|
id: params.id,
|
|
configSchema: { type: "object" },
|
|
}),
|
|
"utf-8",
|
|
);
|
|
}
|
|
|
|
function writePluginEntry(filePath: string) {
|
|
fs.writeFileSync(filePath, "export default function () {}", "utf-8");
|
|
}
|
|
|
|
function writeStandalonePlugin(filePath: string, source = "export default function () {}") {
|
|
mkdirSafe(path.dirname(filePath));
|
|
fs.writeFileSync(filePath, source, "utf-8");
|
|
}
|
|
|
|
function createPackagePlugin(params: {
|
|
packageDir: string;
|
|
packageName: string;
|
|
extensions: string[];
|
|
pluginId?: string;
|
|
}) {
|
|
mkdirSafe(params.packageDir);
|
|
writePluginPackageManifest({
|
|
packageDir: params.packageDir,
|
|
packageName: params.packageName,
|
|
extensions: params.extensions,
|
|
});
|
|
if (params.pluginId) {
|
|
writePluginManifest({ pluginDir: params.packageDir, id: params.pluginId });
|
|
}
|
|
}
|
|
|
|
function createPackagePluginWithEntry(params: {
|
|
packageDir: string;
|
|
packageName: string;
|
|
pluginId?: string;
|
|
entryPath?: string;
|
|
}) {
|
|
const entryPath = params.entryPath ?? "src/index.ts";
|
|
mkdirSafe(path.dirname(path.join(params.packageDir, entryPath)));
|
|
createPackagePlugin({
|
|
packageDir: params.packageDir,
|
|
packageName: params.packageName,
|
|
extensions: [`./${entryPath}`],
|
|
...(params.pluginId ? { pluginId: params.pluginId } : {}),
|
|
});
|
|
writePluginEntry(path.join(params.packageDir, entryPath));
|
|
}
|
|
|
|
function createBundleRoot(bundleDir: string, markerPath: string, manifest?: unknown) {
|
|
mkdirSafe(path.dirname(path.join(bundleDir, markerPath)));
|
|
if (manifest) {
|
|
fs.writeFileSync(path.join(bundleDir, markerPath), JSON.stringify(manifest), "utf-8");
|
|
return;
|
|
}
|
|
mkdirSafe(path.join(bundleDir, markerPath));
|
|
}
|
|
|
|
function expectCandidateIds(
|
|
candidates: Array<{ idHint: string }>,
|
|
params: { includes?: readonly string[]; excludes?: readonly string[] },
|
|
) {
|
|
const ids = candidates.map((candidate) => candidate.idHint);
|
|
if (params.includes?.length) {
|
|
expect(ids).toEqual(expect.arrayContaining([...params.includes]));
|
|
}
|
|
params.excludes?.forEach((excludedId) => {
|
|
expect(ids).not.toContain(excludedId);
|
|
});
|
|
}
|
|
|
|
function findCandidateById<T extends { idHint?: string }>(candidates: T[], idHint: string) {
|
|
return candidates.find((candidate) => candidate.idHint === idHint);
|
|
}
|
|
|
|
function expectCandidateSource(
|
|
candidates: Array<{ idHint?: string; source?: string }>,
|
|
idHint: string,
|
|
source: string,
|
|
) {
|
|
expect(findCandidateById(candidates, idHint)?.source).toBe(source);
|
|
}
|
|
|
|
function expectEscapesPackageDiagnostic(diagnostics: Array<{ message: string }>) {
|
|
expect(diagnostics.some((entry) => entry.message.includes("escapes package directory"))).toBe(
|
|
true,
|
|
);
|
|
}
|
|
|
|
function expectCandidatePresence(
|
|
result: Awaited<ReturnType<typeof discoverOpenClawPlugins>>,
|
|
params: { present?: readonly string[]; absent?: readonly string[] },
|
|
) {
|
|
const ids = result.candidates.map((candidate) => candidate.idHint);
|
|
params.present?.forEach((pluginId) => {
|
|
expect(ids).toContain(pluginId);
|
|
});
|
|
params.absent?.forEach((pluginId) => {
|
|
expect(ids).not.toContain(pluginId);
|
|
});
|
|
}
|
|
|
|
async function expectRejectedPackageExtensionEntry(params: {
|
|
stateDir: string;
|
|
setup: (stateDir: string) => boolean | void;
|
|
expectedDiagnostic?: "escapes" | "none";
|
|
expectedId?: string;
|
|
}) {
|
|
if (params.setup(params.stateDir) === false) {
|
|
return;
|
|
}
|
|
const result = await discoverWithStateDir(params.stateDir, {});
|
|
|
|
if (params.expectedId) {
|
|
expectCandidatePresence(result, { absent: [params.expectedId] });
|
|
} else {
|
|
expect(result.candidates).toHaveLength(0);
|
|
}
|
|
if (params.expectedDiagnostic === "escapes") {
|
|
expectEscapesPackageDiagnostic(result.diagnostics);
|
|
return;
|
|
}
|
|
expect(result.diagnostics).toEqual([]);
|
|
}
|
|
|
|
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"],
|
|
});
|
|
writePluginEntry(path.join(globalExt, "src", "one.ts"));
|
|
writePluginEntry(path.join(globalExt, "src", "two.ts"));
|
|
|
|
const { candidates } = await discoverWithStateDir(stateDir, {});
|
|
expectCandidateIds(candidates, { includes: ["pack/one", "pack/two"] });
|
|
});
|
|
|
|
it("does not discover nested node_modules copies under installed plugins", async () => {
|
|
const stateDir = makeTempDir();
|
|
const pluginDir = path.join(stateDir, "extensions", "opik-openclaw");
|
|
const nestedDiffsDir = path.join(
|
|
pluginDir,
|
|
"node_modules",
|
|
"openclaw",
|
|
"dist",
|
|
"extensions",
|
|
"diffs",
|
|
);
|
|
mkdirSafe(path.join(pluginDir, "src"));
|
|
mkdirSafe(nestedDiffsDir);
|
|
|
|
writePluginPackageManifest({
|
|
packageDir: pluginDir,
|
|
packageName: "@opik/opik-openclaw",
|
|
extensions: ["./src/index.ts"],
|
|
});
|
|
writePluginManifest({ pluginDir, id: "opik-openclaw" });
|
|
fs.writeFileSync(
|
|
path.join(pluginDir, "src", "index.ts"),
|
|
"export default function () {}",
|
|
"utf-8",
|
|
);
|
|
|
|
writePluginPackageManifest({
|
|
packageDir: path.join(pluginDir, "node_modules", "openclaw"),
|
|
packageName: "openclaw",
|
|
extensions: ["./dist/extensions/diffs/index.js"],
|
|
});
|
|
writePluginManifest({ pluginDir: nestedDiffsDir, id: "diffs" });
|
|
fs.writeFileSync(
|
|
path.join(nestedDiffsDir, "index.js"),
|
|
"module.exports = { id: 'diffs', register() {} };",
|
|
"utf-8",
|
|
);
|
|
|
|
const { candidates } = await discoverWithStateDir(stateDir, {});
|
|
|
|
expect(candidates.map((candidate) => candidate.idHint)).toEqual(["opik-openclaw"]);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "derives unscoped ids for scoped packages",
|
|
setup: (stateDir: string) => {
|
|
const packageDir = path.join(stateDir, "extensions", "voice-call-pack");
|
|
createPackagePluginWithEntry({
|
|
packageDir,
|
|
packageName: "@openclaw/voice-call",
|
|
entryPath: "src/index.ts",
|
|
});
|
|
return {};
|
|
},
|
|
includes: ["voice-call"],
|
|
},
|
|
{
|
|
name: "strips provider suffixes from package-derived ids",
|
|
setup: (stateDir: string) => {
|
|
const packageDir = path.join(stateDir, "extensions", "ollama-provider-pack");
|
|
createPackagePluginWithEntry({
|
|
packageDir,
|
|
packageName: "@openclaw/ollama-provider",
|
|
pluginId: "ollama",
|
|
entryPath: "src/index.ts",
|
|
});
|
|
return {};
|
|
},
|
|
includes: ["ollama"],
|
|
excludes: ["ollama-provider"],
|
|
},
|
|
{
|
|
name: "normalizes bundled speech package ids to canonical plugin ids",
|
|
setup: (stateDir: string) => {
|
|
for (const [dirName, packageName, pluginId] of [
|
|
["elevenlabs-speech-pack", "@openclaw/elevenlabs-speech", "elevenlabs"],
|
|
["microsoft-speech-pack", "@openclaw/microsoft-speech", "microsoft"],
|
|
] as const) {
|
|
const packageDir = path.join(stateDir, "extensions", dirName);
|
|
createPackagePluginWithEntry({
|
|
packageDir,
|
|
packageName,
|
|
pluginId,
|
|
entryPath: "src/index.ts",
|
|
});
|
|
}
|
|
return {};
|
|
},
|
|
includes: ["elevenlabs", "microsoft"],
|
|
excludes: ["elevenlabs-speech", "microsoft-speech"],
|
|
},
|
|
{
|
|
name: "treats configured directory paths as plugin packages",
|
|
setup: (stateDir: string) => {
|
|
const packageDir = path.join(stateDir, "packs", "demo-plugin-dir");
|
|
createPackagePluginWithEntry({
|
|
packageDir,
|
|
packageName: "@openclaw/demo-plugin-dir",
|
|
entryPath: "index.js",
|
|
});
|
|
return { extraPaths: [packageDir] };
|
|
},
|
|
includes: ["demo-plugin-dir"],
|
|
},
|
|
] as const)("$name", async ({ setup, includes, excludes }) => {
|
|
const stateDir = makeTempDir();
|
|
const discoverParams = setup(stateDir);
|
|
const { candidates } = await discoverWithStateDir(stateDir, discoverParams);
|
|
expectCandidateIds(candidates, { includes, excludes });
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "auto-detects Codex bundles as bundle candidates",
|
|
idHint: "sample-bundle",
|
|
bundleFormat: "codex",
|
|
setup: (stateDir: string) => {
|
|
const bundleDir = path.join(stateDir, "extensions", "sample-bundle");
|
|
createBundleRoot(bundleDir, ".codex-plugin/plugin.json", {
|
|
name: "Sample Bundle",
|
|
skills: "skills",
|
|
});
|
|
mkdirSafe(path.join(bundleDir, "skills"));
|
|
return bundleDir;
|
|
},
|
|
expectRootDir: true,
|
|
},
|
|
{
|
|
name: "auto-detects manifestless Claude bundles from the default layout",
|
|
idHint: "claude-bundle",
|
|
bundleFormat: "claude",
|
|
setup: (stateDir: string) => {
|
|
const bundleDir = path.join(stateDir, "extensions", "claude-bundle");
|
|
mkdirSafe(path.join(bundleDir, "commands"));
|
|
fs.writeFileSync(
|
|
path.join(bundleDir, "settings.json"),
|
|
'{"hideThinkingBlock":true}',
|
|
"utf-8",
|
|
);
|
|
return bundleDir;
|
|
},
|
|
},
|
|
{
|
|
name: "auto-detects Cursor bundles as bundle candidates",
|
|
idHint: "cursor-bundle",
|
|
bundleFormat: "cursor",
|
|
setup: (stateDir: string) => {
|
|
const bundleDir = path.join(stateDir, "extensions", "cursor-bundle");
|
|
createBundleRoot(bundleDir, ".cursor-plugin/plugin.json", {
|
|
name: "Cursor Bundle",
|
|
});
|
|
mkdirSafe(path.join(bundleDir, ".cursor", "commands"));
|
|
return bundleDir;
|
|
},
|
|
},
|
|
] as const)("$name", async ({ idHint, bundleFormat, setup, expectRootDir }) => {
|
|
const stateDir = makeTempDir();
|
|
const bundleDir = setup(stateDir);
|
|
const { candidates } = await discoverWithStateDir(stateDir, {});
|
|
const bundle = findCandidateById(candidates, idHint);
|
|
|
|
expect(bundle).toBeDefined();
|
|
expect(bundle).toEqual(
|
|
expect.objectContaining({
|
|
idHint,
|
|
format: "bundle",
|
|
bundleFormat,
|
|
source: bundleDir,
|
|
}),
|
|
);
|
|
if (expectRootDir) {
|
|
expect(normalizePathForAssertion(bundle?.rootDir)).toBe(
|
|
normalizePathForAssertion(fs.realpathSync(bundleDir)),
|
|
);
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "falls back to legacy index discovery when a scanned bundle sidecar is malformed",
|
|
bundleMarker: ".claude-plugin/plugin.json",
|
|
setup: (stateDir: string) => {
|
|
const pluginDir = path.join(stateDir, "extensions", "legacy-with-bad-bundle");
|
|
mkdirSafe(path.dirname(path.join(pluginDir, ".claude-plugin", "plugin.json")));
|
|
fs.writeFileSync(path.join(pluginDir, "index.ts"), "export default {}", "utf-8");
|
|
fs.writeFileSync(path.join(pluginDir, ".claude-plugin", "plugin.json"), "{", "utf-8");
|
|
return {};
|
|
},
|
|
},
|
|
{
|
|
name: "falls back to legacy index discovery for configured paths with malformed bundle sidecars",
|
|
bundleMarker: ".codex-plugin/plugin.json",
|
|
setup: (stateDir: string) => {
|
|
const pluginDir = path.join(stateDir, "plugins", "legacy-with-bad-bundle");
|
|
mkdirSafe(path.dirname(path.join(pluginDir, ".codex-plugin", "plugin.json")));
|
|
fs.writeFileSync(path.join(pluginDir, "index.ts"), "export default {}", "utf-8");
|
|
fs.writeFileSync(path.join(pluginDir, ".codex-plugin", "plugin.json"), "{", "utf-8");
|
|
return { extraPaths: [pluginDir] };
|
|
},
|
|
},
|
|
] as const)("$name", async ({ setup, bundleMarker }) => {
|
|
const stateDir = makeTempDir();
|
|
const result = await discoverWithStateDir(stateDir, setup(stateDir));
|
|
const legacy = findCandidateById(result.candidates, "legacy-with-bad-bundle");
|
|
|
|
expect(legacy?.format).toBe("openclaw");
|
|
expect(hasDiagnosticSourceSuffix(result.diagnostics, bundleMarker)).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "blocks extension entries that escape package directory",
|
|
expectedDiagnostic: "escapes" as const,
|
|
setup: (stateDir: string) => {
|
|
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");
|
|
},
|
|
},
|
|
{
|
|
name: "skips missing package extension entries without escape diagnostics",
|
|
expectedDiagnostic: "none" as const,
|
|
setup: (stateDir: string) => {
|
|
const globalExt = path.join(stateDir, "extensions", "missing-entry-pack");
|
|
mkdirSafe(globalExt);
|
|
writePluginPackageManifest({
|
|
packageDir: globalExt,
|
|
packageName: "@openclaw/missing-entry-pack",
|
|
extensions: ["./missing.ts"],
|
|
});
|
|
},
|
|
},
|
|
{
|
|
name: "rejects package extension entries that escape via symlink",
|
|
expectedDiagnostic: "escapes" as const,
|
|
expectedId: "pack",
|
|
setup: (stateDir: string) => {
|
|
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 false;
|
|
}
|
|
writePluginPackageManifest({
|
|
packageDir: globalExt,
|
|
packageName: "@openclaw/pack",
|
|
extensions: ["./linked/escape.ts"],
|
|
});
|
|
},
|
|
},
|
|
{
|
|
name: "rejects package extension entries that are hardlinked aliases",
|
|
expectedDiagnostic: "escapes" as const,
|
|
expectedId: "pack",
|
|
setup: (stateDir: string) => {
|
|
if (process.platform === "win32") {
|
|
return false;
|
|
}
|
|
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 false;
|
|
}
|
|
throw err;
|
|
}
|
|
writePluginPackageManifest({
|
|
packageDir: globalExt,
|
|
packageName: "@openclaw/pack",
|
|
extensions: ["./escape.ts"],
|
|
});
|
|
},
|
|
},
|
|
] as const)("$name", async ({ setup, expectedDiagnostic, expectedId }) => {
|
|
const stateDir = makeTempDir();
|
|
await expectRejectedPackageExtensionEntry({
|
|
stateDir,
|
|
setup,
|
|
expectedDiagnostic,
|
|
...(expectedId ? { expectedId } : {}),
|
|
});
|
|
});
|
|
|
|
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,
|
|
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 cachedEnv = buildCachedDiscoveryEnv(stateDir);
|
|
const first = discoverWithCachedEnv({ env: cachedEnv });
|
|
expect(first.candidates.some((candidate) => candidate.idHint === "cached")).toBe(true);
|
|
|
|
fs.rmSync(pluginPath, { force: true });
|
|
|
|
const second = discoverWithCachedEnv({ env: cachedEnv });
|
|
expect(second.candidates.some((candidate) => candidate.idHint === "cached")).toBe(true);
|
|
|
|
clearPluginDiscoveryCache();
|
|
|
|
const third = discoverWithCachedEnv({ env: cachedEnv });
|
|
expect(third.candidates.some((candidate) => candidate.idHint === "cached")).toBe(false);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "does not reuse discovery results across env root changes",
|
|
setup: () => {
|
|
const stateDirA = makeTempDir();
|
|
const stateDirB = makeTempDir();
|
|
writeStandalonePlugin(path.join(stateDirA, "extensions", "alpha.ts"));
|
|
writeStandalonePlugin(path.join(stateDirB, "extensions", "beta.ts"));
|
|
return {
|
|
first: discoverWithCachedEnv({ env: buildCachedDiscoveryEnv(stateDirA) }),
|
|
second: discoverWithCachedEnv({ env: buildCachedDiscoveryEnv(stateDirB) }),
|
|
assert: (
|
|
first: ReturnType<typeof discoverWithCachedEnv>,
|
|
second: ReturnType<typeof discoverWithCachedEnv>,
|
|
) => {
|
|
expectCandidatePresence(first, { present: ["alpha"], absent: ["beta"] });
|
|
expectCandidatePresence(second, { present: ["beta"], absent: ["alpha"] });
|
|
},
|
|
};
|
|
},
|
|
},
|
|
{
|
|
name: "does not reuse extra-path discovery across env home changes",
|
|
setup: () => {
|
|
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");
|
|
writeStandalonePlugin(pluginA, "export default {}");
|
|
writeStandalonePlugin(pluginB, "export default {}");
|
|
return {
|
|
first: discoverWithCachedEnv({
|
|
extraPaths: ["~/plugins/demo.ts"],
|
|
env: buildCachedDiscoveryEnv(stateDir, { HOME: homeA }),
|
|
}),
|
|
second: discoverWithCachedEnv({
|
|
extraPaths: ["~/plugins/demo.ts"],
|
|
env: buildCachedDiscoveryEnv(stateDir, { HOME: homeB }),
|
|
}),
|
|
assert: (
|
|
first: ReturnType<typeof discoverWithCachedEnv>,
|
|
second: ReturnType<typeof discoverWithCachedEnv>,
|
|
) => {
|
|
expectCandidateSource(first.candidates, "demo", pluginA);
|
|
expectCandidateSource(second.candidates, "demo", pluginB);
|
|
},
|
|
};
|
|
},
|
|
},
|
|
] as const)("$name", ({ setup }) => {
|
|
const { first, second, assert } = setup();
|
|
assert(first, second);
|
|
});
|
|
|
|
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");
|
|
writeStandalonePlugin(pluginA, "export default {}");
|
|
writeStandalonePlugin(pluginB, "export default {}");
|
|
|
|
const env = buildCachedDiscoveryEnv(stateDir);
|
|
|
|
const first = discoverWithCachedEnv({
|
|
extraPaths: [pluginA, pluginB],
|
|
env,
|
|
});
|
|
const second = discoverWithCachedEnv({
|
|
extraPaths: [pluginB, pluginA],
|
|
env,
|
|
});
|
|
|
|
expect(first.candidates.map((candidate) => candidate.idHint)).toEqual(["alpha", "beta"]);
|
|
expect(second.candidates.map((candidate) => candidate.idHint)).toEqual(["beta", "alpha"]);
|
|
});
|
|
});
|