Skip to content

Commit 89a15fd

Browse files
committed
fix(plugins): ignore invalid managed runtime shadows
1 parent b8f6e16 commit 89a15fd

6 files changed

Lines changed: 128 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ Docs: https://docs.openclaw.ai
6666
- Plugins/commands: scope QQBot framework slash commands to the QQBot channel so `/bot-*` command handlers and native specs do not leak onto unrelated chat surfaces. Thanks @vincentkoc.
6767
- fix: harden backend message action gateway routing [AI]. (#76374) Thanks @pgondhi987.
6868
- Gate QQBot streaming command auth [AI]. (#76375) Thanks @pgondhi987.
69+
- Plugins/discovery: ignore managed npm plugin packages that only expose TypeScript source entries without compiled runtime output, so stale/broken installs cannot hide a working bundled or reinstallable channel plugin during setup.
6970
- CLI/update: treat OpenClaw stable correction versions like `2026.5.3-1` as newer than their base stable release, so package updates no longer ask for downgrade confirmation.
7071
- Plugins/install: suppress dangerous-pattern scanner warnings for trusted official OpenClaw npm installs, so installing `@openclaw/discord` no longer prints credential-harvesting warnings for the official package.
7172
- Plugins/release: make the published npm runtime verifier reject blank `openclaw.runtimeExtensions` entries instead of treating them as absent and passing via inferred outputs. Thanks @vincentkoc.

src/plugins/discovery.test.ts

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -748,7 +748,7 @@ describe("discoverOpenClawPlugins", () => {
748748
expectCandidateIds(candidates, { includes: ["pack/one", "pack/two"] });
749749
});
750750

751-
it("warns but still loads source-only TypeScript entries for installed package plugins", async () => {
751+
it("skips source-only TypeScript entries for installed package plugins", async () => {
752752
const stateDir = makeTempDir();
753753
const pluginDir = path.join(stateDir, "extensions", "source-only-pack");
754754
mkdirSafe(path.join(pluginDir, "src"));
@@ -762,17 +762,66 @@ describe("discoverOpenClawPlugins", () => {
762762

763763
const result = await discoverWithStateDir(stateDir, {});
764764

765-
expectCandidateIds(result.candidates, { includes: ["source-only-pack"] });
765+
expectCandidateIds(result.candidates, { excludes: ["source-only-pack"] });
766766
expect(
767767
result.diagnostics.some(
768768
(entry) =>
769769
entry.level === "warn" &&
770+
entry.pluginId === "source-only-pack" &&
770771
entry.message.includes("requires compiled runtime output") &&
771772
entry.message.includes("./dist/index.js"),
772773
),
773774
).toBe(true);
774775
});
775776

777+
it("lets a valid bundled plugin win when a managed package is source-only TypeScript", async () => {
778+
const stateDir = makeTempDir();
779+
const bundledDir = path.join(stateDir, "bundled");
780+
const bundledPluginDir = path.join(bundledDir, "discord");
781+
const installedPluginDir = path.join(stateDir, "extensions", "discord");
782+
mkdirSafe(bundledPluginDir);
783+
mkdirSafe(path.join(installedPluginDir, "src"));
784+
785+
writePluginPackageManifest({
786+
packageDir: bundledPluginDir,
787+
packageName: "@openclaw/discord",
788+
extensions: ["./index.js"],
789+
});
790+
writePluginManifest({ pluginDir: bundledPluginDir, id: "discord" });
791+
writePluginEntry(path.join(bundledPluginDir, "index.js"));
792+
793+
writePluginPackageManifest({
794+
packageDir: installedPluginDir,
795+
packageName: "@openclaw/discord",
796+
extensions: ["./src/index.ts"],
797+
});
798+
writePluginManifest({ pluginDir: installedPluginDir, id: "discord" });
799+
writePluginEntry(path.join(installedPluginDir, "src", "index.ts"));
800+
801+
const result = discoverOpenClawPlugins({
802+
env: buildDiscoveryEnvWithOverrides(stateDir, {
803+
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir,
804+
}),
805+
});
806+
807+
const discordCandidates = result.candidates.filter(
808+
(candidate) => candidate.idHint === "discord",
809+
);
810+
expect(discordCandidates).toEqual([
811+
expect.objectContaining({
812+
origin: "bundled",
813+
source: fs.realpathSync(path.join(bundledPluginDir, "index.js")),
814+
}),
815+
]);
816+
expect(
817+
result.diagnostics.some(
818+
(entry) =>
819+
entry.pluginId === "discord" &&
820+
entry.message.includes("requires compiled runtime output"),
821+
),
822+
).toBe(true);
823+
});
824+
776825
it("reuses one filesystem realpath lookup per package root within a discovery run", () => {
777826
const stateDir = makeTempDir();
778827
const packageDir = path.join(stateDir, "extensions", "pack");

src/plugins/discovery.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,26 @@ function deriveIdHint(params: {
487487
return `${normalizedPackageId}/${base}`;
488488
}
489489

490+
function derivePackagePluginIdHint(params: {
491+
manifestId?: string;
492+
packageName?: string;
493+
}): string | undefined {
494+
const rawManifestId = params.manifestId?.trim();
495+
if (rawManifestId) {
496+
return rawManifestId;
497+
}
498+
const rawPackageName = params.packageName?.trim();
499+
if (!rawPackageName) {
500+
return undefined;
501+
}
502+
const unscoped = rawPackageName.includes("/")
503+
? (rawPackageName.split("/").pop() ?? rawPackageName)
504+
: rawPackageName;
505+
return unscoped.endsWith("-provider") && unscoped.length > "-provider".length
506+
? unscoped.slice(0, -"-provider".length)
507+
: unscoped;
508+
}
509+
490510
function resolveIdHintManifestId(
491511
rootDir: string,
492512
rejectHardlinks: boolean,
@@ -706,6 +726,7 @@ function discoverInDirectory(params: {
706726
manifest,
707727
extensions,
708728
origin: params.origin,
729+
pluginIdHint: derivePackagePluginIdHint({ manifestId, packageName: manifest?.name }),
709730
sourceLabel: fullPath,
710731
diagnostics: params.diagnostics,
711732
rejectHardlinks,
@@ -911,6 +932,7 @@ function discoverFromPath(params: {
911932
manifest,
912933
extensions,
913934
origin: params.origin,
935+
pluginIdHint: derivePackagePluginIdHint({ manifestId, packageName: manifest?.name }),
914936
sourceLabel: resolved,
915937
diagnostics: params.diagnostics,
916938
rejectHardlinks,

src/plugins/manifest-registry.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ function createPluginCandidate(params: {
6363
format?: "openclaw" | "bundle";
6464
bundleFormat?: "codex" | "claude" | "cursor";
6565
packageName?: string;
66+
packageVersion?: string;
6667
packageManifest?: OpenClawPackageManifest;
6768
packageDir?: string;
6869
bundledManifest?: PluginCandidate["bundledManifest"];
@@ -76,6 +77,7 @@ function createPluginCandidate(params: {
7677
format: params.format,
7778
bundleFormat: params.bundleFormat,
7879
packageName: params.packageName,
80+
packageVersion: params.packageVersion,
7981
packageManifest: params.packageManifest,
8082
packageDir: params.packageDir,
8183
bundledManifest: params.bundledManifest,
@@ -1945,6 +1947,33 @@ describe("loadPluginManifestRegistry", () => {
19451947
expect(countDuplicateWarnings(loadRegistry(candidates))).toBe(0);
19461948
});
19471949

1950+
it("suppresses duplicate warning when global candidates come from the same package artifact", () => {
1951+
const firstDir = makeTempDir();
1952+
const secondDir = makeTempDir();
1953+
const manifest = { id: "opik-openclaw", configSchema: { type: "object" } };
1954+
writeManifest(firstDir, manifest);
1955+
writeManifest(secondDir, manifest);
1956+
1957+
const candidates: PluginCandidate[] = [
1958+
createPluginCandidate({
1959+
idHint: "opik-openclaw",
1960+
rootDir: firstDir,
1961+
origin: "global",
1962+
packageName: "@opik/opik-openclaw",
1963+
packageVersion: "0.2.14",
1964+
}),
1965+
createPluginCandidate({
1966+
idHint: "opik-openclaw",
1967+
rootDir: secondDir,
1968+
origin: "global",
1969+
packageName: "@opik/opik-openclaw",
1970+
packageVersion: "0.2.14",
1971+
}),
1972+
];
1973+
1974+
expect(countDuplicateWarnings(loadRegistry(candidates))).toBe(0);
1975+
});
1976+
19481977
it("does not warn for id hint mismatches when manifest id is authoritative", () => {
19491978
const dir = makeTempDir();
19501979
writeManifest(dir, { id: "openai", configSchema: { type: "object" } });

src/plugins/manifest-registry.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -713,6 +713,22 @@ function isIntentionalInstalledBundledDuplicate(params: {
713713
);
714714
}
715715

716+
function isSameGlobalPackageDuplicate(left: PluginCandidate, right: PluginCandidate): boolean {
717+
if (left.origin !== "global" || right.origin !== "global") {
718+
return false;
719+
}
720+
const leftPackageName = normalizeOptionalString(left.packageName);
721+
const rightPackageName = normalizeOptionalString(right.packageName);
722+
if (!leftPackageName || leftPackageName !== rightPackageName) {
723+
return false;
724+
}
725+
const leftPackageVersion = normalizeOptionalString(left.packageVersion);
726+
const rightPackageVersion = normalizeOptionalString(right.packageVersion);
727+
return Boolean(
728+
leftPackageVersion && rightPackageVersion && leftPackageVersion === rightPackageVersion,
729+
);
730+
}
731+
716732
export function loadPluginManifestRegistry(
717733
params: {
718734
config?: OpenClawConfig;
@@ -906,6 +922,9 @@ export function loadPluginManifestRegistry(
906922
) {
907923
continue;
908924
}
925+
if (isSameGlobalPackageDuplicate(candidate, existing.candidate)) {
926+
continue;
927+
}
909928
diagnostics.push({
910929
level: "warn",
911930
pluginId: manifest.id,

src/plugins/package-entry-resolution.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,7 @@ function resolvePackageRuntimeEntrySource(params: {
456456
entryPath: string;
457457
runtimeEntryPath?: string;
458458
runtimeEntryLabel?: string;
459+
pluginIdHint?: string;
459460
origin: PluginOrigin;
460461
sourceLabel: string;
461462
diagnostics: PluginDiagnostic[];
@@ -523,13 +524,15 @@ function resolvePackageRuntimeEntrySource(params: {
523524
) {
524525
params.diagnostics.push({
525526
level: "warn",
527+
...(params.pluginIdHint ? { pluginId: params.pluginIdHint } : {}),
526528
message: missingCompiledRuntimeEntryMessage({
527529
label: "installed plugin package",
528530
entry: safeEntry.relativePath,
529531
candidates: builtEntryCandidates,
530532
}),
531533
source: params.sourceLabel,
532534
});
535+
return null;
533536
}
534537
}
535538

@@ -571,6 +574,7 @@ export function resolvePackageSetupSource(params: {
571574
entryPath: setupEntryPath,
572575
runtimeEntryPath: normalizeOptionalString(packageManifest?.runtimeSetupEntry),
573576
runtimeEntryLabel: "runtime setup entry",
577+
pluginIdHint: packageManifest?.plugin?.id ?? packageManifest?.channel?.id,
574578
origin: params.origin,
575579
sourceLabel: params.sourceLabel,
576580
diagnostics: params.diagnostics,
@@ -584,6 +588,7 @@ export function resolvePackageRuntimeExtensionSources(params: {
584588
manifest: PackageManifest | null;
585589
extensions: readonly string[];
586590
origin: PluginOrigin;
591+
pluginIdHint?: string;
587592
sourceLabel: string;
588593
diagnostics: PluginDiagnostic[];
589594
rejectHardlinks?: boolean;
@@ -610,6 +615,7 @@ export function resolvePackageRuntimeExtensionSources(params: {
610615
entryPath,
611616
runtimeEntryPath: runtimeResolution.runtimeExtensions[index],
612617
runtimeEntryLabel: "runtime extension entry",
618+
pluginIdHint: params.pluginIdHint,
613619
origin: params.origin,
614620
sourceLabel: params.sourceLabel,
615621
diagnostics: params.diagnostics,

0 commit comments

Comments
 (0)