fix(plugins): require declared runtime setup entries

This commit is contained in:
Peter Steinberger
2026-05-01 22:36:08 +01:00
parent c2a2cfe314
commit d2ae2a3fb0
6 changed files with 140 additions and 5 deletions

View File

@@ -791,6 +791,37 @@ describe("discoverOpenClawPlugins", () => {
);
});
it("rejects missing explicit runtime setup entries for installed package plugins", async () => {
const stateDir = makeTempDir();
const pluginDir = path.join(stateDir, "extensions", "missing-runtime-setup-pack");
mkdirSafe(path.join(pluginDir, "src"));
mkdirSafe(path.join(pluginDir, "dist"));
writePluginPackageManifest({
packageDir: pluginDir,
packageName: "@openclaw/missing-runtime-setup-pack",
extensions: ["./dist/index.js"],
setupEntry: "./src/setup-entry.ts",
runtimeSetupEntry: "./dist/setup-entry.js",
});
writePluginEntry(path.join(pluginDir, "dist", "index.js"));
writePluginEntry(path.join(pluginDir, "src", "setup-entry.ts"));
const result = await discoverWithStateDir(stateDir, {});
const candidate = findCandidateById(result.candidates, "missing-runtime-setup-pack");
expect(candidate).toBeDefined();
expect(candidate?.setupSource).toBeUndefined();
expect(
result.diagnostics.some(
(entry) =>
entry.level === "error" &&
entry.message.includes("runtime setup entry not found") &&
entry.message.includes("./dist/setup-entry.js"),
),
).toBe(true);
});
it("rejects package runtimeExtensions that do not match extension entries", async () => {
const stateDir = makeTempDir();
const pluginDir = path.join(stateDir, "extensions", "runtime-mismatch-pack");

View File

@@ -920,6 +920,38 @@ describe("installPluginFromArchive", () => {
}
});
it("rejects package installs when runtimeSetupEntry is missing", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
fs.mkdirSync(path.join(pluginDir, "src"), { recursive: true });
fs.mkdirSync(path.join(pluginDir, "dist"), { recursive: true });
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "missing-runtime-setup-plugin",
version: "1.0.0",
openclaw: {
extensions: ["./dist/index.js"],
setupEntry: "./src/setup-entry.ts",
runtimeSetupEntry: "./dist/setup-entry.js",
},
}),
);
fs.writeFileSync(path.join(pluginDir, "dist", "index.js"), "export {};\n");
fs.writeFileSync(path.join(pluginDir, "src", "setup-entry.ts"), "export {};\n");
const result = await installPluginFromDir({
dirPath: pluginDir,
extensionsDir,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS);
expect(result.error).toContain("runtime setup entry not found");
expect(result.error).toContain("./dist/setup-entry.js");
}
});
it("rejects package installs when an extension entry is a symlink escape", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
const outsideDir = path.join(path.dirname(pluginDir), "outside-symlink");

View File

@@ -168,6 +168,64 @@ export async function validatePackageExtensionEntriesForInstall(params: {
}
}
const packageManifest = getPackageManifestMetadata(params.manifest);
const setupEntry = normalizeOptionalString(packageManifest?.setupEntry);
const runtimeSetupEntry = normalizeOptionalString(packageManifest?.runtimeSetupEntry);
if (runtimeSetupEntry && !setupEntry) {
return {
ok: false,
error: "package.json openclaw.runtimeSetupEntry requires openclaw.setupEntry",
};
}
if (setupEntry) {
const sourceEntry = await validatePackageExtensionEntry({
packageDir: params.packageDir,
entry: setupEntry,
label: "setup entry",
requireExisting: false,
});
if (!sourceEntry.ok) {
return sourceEntry;
}
if (runtimeSetupEntry) {
const runtimeResult = await validatePackageExtensionEntry({
packageDir: params.packageDir,
entry: runtimeSetupEntry,
label: "runtime setup entry",
requireExisting: true,
});
if (!runtimeResult.ok) {
return runtimeResult;
}
return { ok: true };
}
if (sourceEntry.exists) {
return { ok: true };
}
let foundBuiltSetupEntry = false;
for (const builtEntry of listBuiltRuntimeEntryCandidates(setupEntry)) {
const builtResult = await validatePackageExtensionEntry({
packageDir: params.packageDir,
entry: builtEntry,
label: "inferred runtime setup entry",
requireExisting: false,
});
if (!builtResult.ok) {
return builtResult;
}
if (builtResult.exists) {
foundBuiltSetupEntry = true;
break;
}
}
if (!foundBuiltSetupEntry) {
return { ok: false, error: `setup entry not found: ${setupEntry}` };
}
}
return { ok: true };
}
@@ -307,6 +365,7 @@ function resolvePackageRuntimeEntrySource(params: {
packageRootRealPath?: string;
entryPath: string;
runtimeEntryPath?: string;
runtimeEntryLabel?: string;
origin: PluginOrigin;
sourceLabel: string;
diagnostics: PluginDiagnostic[];
@@ -340,6 +399,12 @@ function resolvePackageRuntimeEntrySource(params: {
if (runtimeSource) {
return runtimeSource;
}
params.diagnostics.push({
level: "error",
message: `${params.runtimeEntryLabel ?? "runtime entry"} not found: ${params.runtimeEntryPath}`,
source: params.sourceLabel,
});
return null;
}
if (shouldInferBuiltRuntimeEntry(params.origin)) {
@@ -397,6 +462,7 @@ export function resolvePackageSetupSource(params: {
: {}),
entryPath: setupEntryPath,
runtimeEntryPath: normalizeOptionalString(packageManifest?.runtimeSetupEntry),
runtimeEntryLabel: "runtime setup entry",
origin: params.origin,
sourceLabel: params.sourceLabel,
diagnostics: params.diagnostics,
@@ -435,6 +501,7 @@ export function resolvePackageRuntimeExtensionSources(params: {
: {}),
entryPath,
runtimeEntryPath: runtimeResolution.runtimeExtensions[index],
runtimeEntryLabel: "runtime extension entry",
origin: params.origin,
sourceLabel: params.sourceLabel,
diagnostics: params.diagnostics,