diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fd9f349799..f8e84b02ce0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ Docs: https://docs.openclaw.ai - 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. - fix: harden backend message action gateway routing [AI]. (#76374) Thanks @pgondhi987. - Gate QQBot streaming command auth [AI]. (#76375) Thanks @pgondhi987. +- 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. - 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. - 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. - 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. diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index 293dd15a6ef..6c91a0c5754 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -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"); diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index 2a3d058ef15..2be659f1ed5 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -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, diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index bcf6815e0d2..a33fbe57eaf 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -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" } }); diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 00267470200..8cb00aabdab 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -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, diff --git a/src/plugins/package-entry-resolution.ts b/src/plugins/package-entry-resolution.ts index 1d9160c5f91..aaf1fb6a815 100644 --- a/src/plugins/package-entry-resolution.ts +++ b/src/plugins/package-entry-resolution.ts @@ -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,