fix(plugins): ignore invalid managed runtime shadows

This commit is contained in:
Vincent Koc
2026-05-04 03:17:57 -07:00
parent b8f6e16ba5
commit 89a15fddaf
6 changed files with 128 additions and 2 deletions

View File

@@ -748,7 +748,7 @@ describe("discoverOpenClawPlugins", () => {
expectCandidateIds(candidates, { includes: ["pack/one", "pack/two"] });
});
it("warns but still loads source-only TypeScript entries for installed package plugins", async () => {
it("skips source-only TypeScript entries for installed package plugins", async () => {
const stateDir = makeTempDir();
const pluginDir = path.join(stateDir, "extensions", "source-only-pack");
mkdirSafe(path.join(pluginDir, "src"));
@@ -762,17 +762,66 @@ describe("discoverOpenClawPlugins", () => {
const result = await discoverWithStateDir(stateDir, {});
expectCandidateIds(result.candidates, { includes: ["source-only-pack"] });
expectCandidateIds(result.candidates, { excludes: ["source-only-pack"] });
expect(
result.diagnostics.some(
(entry) =>
entry.level === "warn" &&
entry.pluginId === "source-only-pack" &&
entry.message.includes("requires compiled runtime output") &&
entry.message.includes("./dist/index.js"),
),
).toBe(true);
});
it("lets a valid bundled plugin win when a managed package is source-only TypeScript", async () => {
const stateDir = makeTempDir();
const bundledDir = path.join(stateDir, "bundled");
const bundledPluginDir = path.join(bundledDir, "discord");
const installedPluginDir = path.join(stateDir, "extensions", "discord");
mkdirSafe(bundledPluginDir);
mkdirSafe(path.join(installedPluginDir, "src"));
writePluginPackageManifest({
packageDir: bundledPluginDir,
packageName: "@openclaw/discord",
extensions: ["./index.js"],
});
writePluginManifest({ pluginDir: bundledPluginDir, id: "discord" });
writePluginEntry(path.join(bundledPluginDir, "index.js"));
writePluginPackageManifest({
packageDir: installedPluginDir,
packageName: "@openclaw/discord",
extensions: ["./src/index.ts"],
});
writePluginManifest({ pluginDir: installedPluginDir, id: "discord" });
writePluginEntry(path.join(installedPluginDir, "src", "index.ts"));
const result = discoverOpenClawPlugins({
env: buildDiscoveryEnvWithOverrides(stateDir, {
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir,
}),
});
const discordCandidates = result.candidates.filter(
(candidate) => candidate.idHint === "discord",
);
expect(discordCandidates).toEqual([
expect.objectContaining({
origin: "bundled",
source: fs.realpathSync(path.join(bundledPluginDir, "index.js")),
}),
]);
expect(
result.diagnostics.some(
(entry) =>
entry.pluginId === "discord" &&
entry.message.includes("requires compiled runtime output"),
),
).toBe(true);
});
it("reuses one filesystem realpath lookup per package root within a discovery run", () => {
const stateDir = makeTempDir();
const packageDir = path.join(stateDir, "extensions", "pack");

View File

@@ -487,6 +487,26 @@ function deriveIdHint(params: {
return `${normalizedPackageId}/${base}`;
}
function derivePackagePluginIdHint(params: {
manifestId?: string;
packageName?: string;
}): string | undefined {
const rawManifestId = params.manifestId?.trim();
if (rawManifestId) {
return rawManifestId;
}
const rawPackageName = params.packageName?.trim();
if (!rawPackageName) {
return undefined;
}
const unscoped = rawPackageName.includes("/")
? (rawPackageName.split("/").pop() ?? rawPackageName)
: rawPackageName;
return unscoped.endsWith("-provider") && unscoped.length > "-provider".length
? unscoped.slice(0, -"-provider".length)
: unscoped;
}
function resolveIdHintManifestId(
rootDir: string,
rejectHardlinks: boolean,
@@ -706,6 +726,7 @@ function discoverInDirectory(params: {
manifest,
extensions,
origin: params.origin,
pluginIdHint: derivePackagePluginIdHint({ manifestId, packageName: manifest?.name }),
sourceLabel: fullPath,
diagnostics: params.diagnostics,
rejectHardlinks,
@@ -911,6 +932,7 @@ function discoverFromPath(params: {
manifest,
extensions,
origin: params.origin,
pluginIdHint: derivePackagePluginIdHint({ manifestId, packageName: manifest?.name }),
sourceLabel: resolved,
diagnostics: params.diagnostics,
rejectHardlinks,

View File

@@ -63,6 +63,7 @@ function createPluginCandidate(params: {
format?: "openclaw" | "bundle";
bundleFormat?: "codex" | "claude" | "cursor";
packageName?: string;
packageVersion?: string;
packageManifest?: OpenClawPackageManifest;
packageDir?: string;
bundledManifest?: PluginCandidate["bundledManifest"];
@@ -76,6 +77,7 @@ function createPluginCandidate(params: {
format: params.format,
bundleFormat: params.bundleFormat,
packageName: params.packageName,
packageVersion: params.packageVersion,
packageManifest: params.packageManifest,
packageDir: params.packageDir,
bundledManifest: params.bundledManifest,
@@ -1945,6 +1947,33 @@ describe("loadPluginManifestRegistry", () => {
expect(countDuplicateWarnings(loadRegistry(candidates))).toBe(0);
});
it("suppresses duplicate warning when global candidates come from the same package artifact", () => {
const firstDir = makeTempDir();
const secondDir = makeTempDir();
const manifest = { id: "opik-openclaw", configSchema: { type: "object" } };
writeManifest(firstDir, manifest);
writeManifest(secondDir, manifest);
const candidates: PluginCandidate[] = [
createPluginCandidate({
idHint: "opik-openclaw",
rootDir: firstDir,
origin: "global",
packageName: "@opik/opik-openclaw",
packageVersion: "0.2.14",
}),
createPluginCandidate({
idHint: "opik-openclaw",
rootDir: secondDir,
origin: "global",
packageName: "@opik/opik-openclaw",
packageVersion: "0.2.14",
}),
];
expect(countDuplicateWarnings(loadRegistry(candidates))).toBe(0);
});
it("does not warn for id hint mismatches when manifest id is authoritative", () => {
const dir = makeTempDir();
writeManifest(dir, { id: "openai", configSchema: { type: "object" } });

View File

@@ -713,6 +713,22 @@ function isIntentionalInstalledBundledDuplicate(params: {
);
}
function isSameGlobalPackageDuplicate(left: PluginCandidate, right: PluginCandidate): boolean {
if (left.origin !== "global" || right.origin !== "global") {
return false;
}
const leftPackageName = normalizeOptionalString(left.packageName);
const rightPackageName = normalizeOptionalString(right.packageName);
if (!leftPackageName || leftPackageName !== rightPackageName) {
return false;
}
const leftPackageVersion = normalizeOptionalString(left.packageVersion);
const rightPackageVersion = normalizeOptionalString(right.packageVersion);
return Boolean(
leftPackageVersion && rightPackageVersion && leftPackageVersion === rightPackageVersion,
);
}
export function loadPluginManifestRegistry(
params: {
config?: OpenClawConfig;
@@ -906,6 +922,9 @@ export function loadPluginManifestRegistry(
) {
continue;
}
if (isSameGlobalPackageDuplicate(candidate, existing.candidate)) {
continue;
}
diagnostics.push({
level: "warn",
pluginId: manifest.id,

View File

@@ -456,6 +456,7 @@ function resolvePackageRuntimeEntrySource(params: {
entryPath: string;
runtimeEntryPath?: string;
runtimeEntryLabel?: string;
pluginIdHint?: string;
origin: PluginOrigin;
sourceLabel: string;
diagnostics: PluginDiagnostic[];
@@ -523,6 +524,7 @@ function resolvePackageRuntimeEntrySource(params: {
) {
params.diagnostics.push({
level: "warn",
...(params.pluginIdHint ? { pluginId: params.pluginIdHint } : {}),
message: missingCompiledRuntimeEntryMessage({
label: "installed plugin package",
entry: safeEntry.relativePath,
@@ -530,6 +532,7 @@ function resolvePackageRuntimeEntrySource(params: {
}),
source: params.sourceLabel,
});
return null;
}
}
@@ -571,6 +574,7 @@ export function resolvePackageSetupSource(params: {
entryPath: setupEntryPath,
runtimeEntryPath: normalizeOptionalString(packageManifest?.runtimeSetupEntry),
runtimeEntryLabel: "runtime setup entry",
pluginIdHint: packageManifest?.plugin?.id ?? packageManifest?.channel?.id,
origin: params.origin,
sourceLabel: params.sourceLabel,
diagnostics: params.diagnostics,
@@ -584,6 +588,7 @@ export function resolvePackageRuntimeExtensionSources(params: {
manifest: PackageManifest | null;
extensions: readonly string[];
origin: PluginOrigin;
pluginIdHint?: string;
sourceLabel: string;
diagnostics: PluginDiagnostic[];
rejectHardlinks?: boolean;
@@ -610,6 +615,7 @@ export function resolvePackageRuntimeExtensionSources(params: {
entryPath,
runtimeEntryPath: runtimeResolution.runtimeExtensions[index],
runtimeEntryLabel: "runtime extension entry",
pluginIdHint: params.pluginIdHint,
origin: params.origin,
sourceLabel: params.sourceLabel,
diagnostics: params.diagnostics,