From 06056926a099dfcd9758205e30c536aa447ca887 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 21:39:23 +0100 Subject: [PATCH] fix(plugins): trust official diagnostics installs (#77516) --- CHANGELOG.md | 1 + src/plugins/loader-records.ts | 2 + src/plugins/loader.ts | 4 ++ src/plugins/manifest-registry.test.ts | 83 +++++++++++++++++++++++++++ src/plugins/manifest-registry.ts | 79 ++++++++++++++++++++++++- src/plugins/registry-types.ts | 2 + src/plugins/registry.ts | 1 + src/plugins/services.test.ts | 27 ++++++++- src/plugins/services.ts | 10 ++-- 9 files changed, 203 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5abe53398a0..fe67d116e21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Diagnostics: grant the internal diagnostics event bus to official installed diagnostics exporter plugins, so npm-installed `@openclaw/diagnostics-prometheus` can emit metrics without broadening the capability to arbitrary global plugins. Fixes #76628. Thanks @RayWoo. - Doctor/config: restore legacy group chat config migrations for `routing.allowFrom`, `routing.groupChat.*`, and `channels.telegram.requireMention` so upgrades keep WhatsApp, Telegram, and iMessage group mention gates and history settings instead of leaving configs invalid or silently blocked. Thanks @scoootscooob. - CLI/update: make package-update follow-up processes write completion results and exit explicitly, so Windows packaged upgrades do not hang after the new package finishes post-core plugin work. Thanks @vincentkoc. - Release validation: skip Slack live QA unless Slack credentials are explicitly configured, so release gates can keep proving non-Slack surfaces while Slack is still local and credential-gated. Thanks @vincentkoc. diff --git a/src/plugins/loader-records.ts b/src/plugins/loader-records.ts index 373981ec8bd..26ea17c217e 100644 --- a/src/plugins/loader-records.ts +++ b/src/plugins/loader-records.ts @@ -19,6 +19,7 @@ export function createPluginRecord(params: { rootDir?: string; origin: PluginRecord["origin"]; workspaceDir?: string; + trustedOfficialInstall?: boolean; enabled: boolean; compat?: readonly PluginCompatCode[]; activationState?: PluginActivationState; @@ -41,6 +42,7 @@ export function createPluginRecord(params: { rootDir: params.rootDir, origin: params.origin, workspaceDir: params.workspaceDir, + trustedOfficialInstall: params.trustedOfficialInstall, enabled: params.enabled, compat: params.compat, explicitlyEnabled: params.activationState?.explicitlyEnabled, diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 714d837b8bc..d3c4efff640 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -1739,6 +1739,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi rootDir: candidate.rootDir, origin: candidate.origin, workspaceDir: candidate.workspaceDir, + trustedOfficialInstall: manifestRecord.trustedOfficialInstall, enabled: false, compat: collectPluginManifestCompatCodes(manifestRecord), activationState, @@ -1777,6 +1778,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi rootDir: candidate.rootDir, origin: candidate.origin, workspaceDir: candidate.workspaceDir, + trustedOfficialInstall: manifestRecord.trustedOfficialInstall, enabled: enableState.enabled, compat: collectPluginManifestCompatCodes(manifestRecord), activationState, @@ -2563,6 +2565,7 @@ export async function loadOpenClawPluginCliRegistry( rootDir: candidate.rootDir, origin: candidate.origin, workspaceDir: candidate.workspaceDir, + trustedOfficialInstall: manifestRecord.trustedOfficialInstall, enabled: false, compat: collectPluginManifestCompatCodes(manifestRecord), activationState, @@ -2601,6 +2604,7 @@ export async function loadOpenClawPluginCliRegistry( rootDir: candidate.rootDir, origin: candidate.origin, workspaceDir: candidate.workspaceDir, + trustedOfficialInstall: manifestRecord.trustedOfficialInstall, enabled: enableState.enabled, compat: collectPluginManifestCompatCodes(manifestRecord), activationState, diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index a33fbe57eaf..e3d7d44ae3e 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -534,6 +534,89 @@ describe("loadPluginManifestRegistry", () => { expect(registry.plugins[0]?.origin).toBe("global"); }); + it("marks official installed npm globals as trusted official installs", () => { + const dir = makeTempDir(); + writeManifest(dir, { id: "diagnostics-prometheus", configSchema: { type: "object" } }); + + const registry = loadPluginManifestRegistry({ + installRecords: { + "diagnostics-prometheus": { + source: "npm", + installPath: dir, + resolvedName: "@openclaw/diagnostics-prometheus", + resolvedVersion: "2026.5.3", + }, + }, + candidates: [ + createPluginCandidate({ + idHint: "diagnostics-prometheus", + rootDir: dir, + packageName: "@openclaw/diagnostics-prometheus", + origin: "global", + }), + ], + }); + + expect(registry.plugins[0]?.trustedOfficialInstall).toBe(true); + }); + + it("preserves trusted official installs when a config path selects the installed package", () => { + const dir = makeTempDir(); + writeManifest(dir, { id: "diagnostics-prometheus", configSchema: { type: "object" } }); + + const registry = loadPluginManifestRegistry({ + installRecords: { + "diagnostics-prometheus": { + source: "npm", + installPath: dir, + resolvedName: "@openclaw/diagnostics-prometheus", + resolvedVersion: "2026.5.3", + }, + }, + candidates: [ + createPluginCandidate({ + idHint: "diagnostics-prometheus", + rootDir: dir, + packageName: "@openclaw/diagnostics-prometheus", + origin: "global", + }), + createPluginCandidate({ + idHint: "diagnostics-prometheus", + rootDir: dir, + packageName: "@openclaw/diagnostics-prometheus", + origin: "config", + }), + ], + }); + + expect(registry.plugins).toHaveLength(1); + expect(registry.plugins[0]).toEqual( + expect.objectContaining({ + origin: "config", + trustedOfficialInstall: true, + }), + ); + }); + + it("does not trust unrecorded globals that spoof official ids", () => { + const dir = makeTempDir(); + writeManifest(dir, { id: "diagnostics-prometheus", configSchema: { type: "object" } }); + + const registry = loadPluginManifestRegistry({ + installRecords: {}, + candidates: [ + createPluginCandidate({ + idHint: "diagnostics-prometheus", + rootDir: dir, + packageName: "@openclaw/diagnostics-prometheus", + origin: "global", + }), + ], + }); + + expect(registry.plugins[0]?.trustedOfficialInstall).toBeUndefined(); + }); + it("preserves provider auth env metadata from plugin manifests", () => { const dir = makeTempDir(); writeManifest(dir, { diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 8cb00aabdab..08aadabc5ee 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -46,6 +46,8 @@ import { checkMinHostVersion } from "./min-host-version.js"; import { getOfficialExternalPluginCatalogEntryForPackage, getOfficialExternalPluginCatalogManifest, + resolveOfficialExternalPluginId, + resolveOfficialExternalPluginInstall, } from "./official-external-plugin-catalog.js"; import { isPathInside, safeRealpathSync } from "./path-safety.js"; import type { PluginKind } from "./plugin-kind.types.js"; @@ -140,6 +142,7 @@ export type PluginManifestRecord = { packageOptionalDependencies?: PluginDependencySpecMap; packageChannel?: PluginPackageChannel; packageInstall?: PluginPackageInstall; + trustedOfficialInstall?: boolean; qaRunners?: PluginManifestQaRunner[]; skills: string[]; settingsFiles?: string[]; @@ -365,6 +368,7 @@ function buildRecord(params: { schemaCacheKey?: string; configSchema?: Record; bundledChannelConfigCollector?: BundledChannelConfigCollector; + trustedOfficialInstall?: boolean; }): PluginManifestRecord { const manifestChannelConfigs = params.candidate.origin === "bundled" && params.bundledChannelConfigCollector @@ -434,6 +438,7 @@ function buildRecord(params: { packageOptionalDependencies: params.candidate.packageOptionalDependencies, packageChannel: params.candidate.packageManifest?.channel, packageInstall: params.candidate.packageManifest?.install, + trustedOfficialInstall: params.trustedOfficialInstall === true ? true : undefined, qaRunners: params.manifest.qaRunners, skills: params.manifest.skills ?? [], settingsFiles: [], @@ -634,7 +639,7 @@ function matchesInstalledPluginRecord(params: { env: NodeJS.ProcessEnv; installRecords: Record; }): boolean { - if (params.candidate.origin !== "global") { + if (params.candidate.origin !== "global" && params.candidate.origin !== "config") { return false; } const record = params.installRecords[params.pluginId]; @@ -653,6 +658,72 @@ function matchesInstalledPluginRecord(params: { }); } +function npmSpecMatchesPackage(value: string | undefined, packageName: string): boolean { + const normalized = value?.trim(); + if (!normalized) { + return false; + } + if (normalized === packageName) { + return true; + } + return normalized.startsWith(`${packageName}@`); +} + +function isTrustedOfficialPluginInstall(params: { + pluginId: string; + candidate: PluginCandidate; + env: NodeJS.ProcessEnv; + installRecords: Record; +}): boolean { + if ( + (params.candidate.origin !== "global" && params.candidate.origin !== "config") || + !matchesInstalledPluginRecord({ + pluginId: params.pluginId, + candidate: params.candidate, + env: params.env, + installRecords: params.installRecords, + }) + ) { + return false; + } + const packageName = params.candidate.packageName?.trim(); + if (!packageName) { + return false; + } + const catalogEntry = getOfficialExternalPluginCatalogEntryForPackage(packageName); + if (!catalogEntry || resolveOfficialExternalPluginId(catalogEntry) !== params.pluginId) { + return false; + } + const officialInstall = resolveOfficialExternalPluginInstall(catalogEntry); + const installRecord = params.installRecords[params.pluginId]; + if (!installRecord) { + return false; + } + if ( + installRecord.source === "npm" && + officialInstall?.npmSpec === packageName && + [ + installRecord.resolvedName, + installRecord.spec, + installRecord.resolvedSpec, + params.candidate.packageName, + ].some((value) => npmSpecMatchesPackage(value, packageName)) + ) { + return true; + } + if ( + installRecord.source === "clawhub" && + officialInstall?.clawhubSpec && + installRecord.clawhubChannel === "official" && + (installRecord.clawhubPackage === packageName || + installRecord.spec === officialInstall.clawhubSpec || + installRecord.resolvedSpec === officialInstall.clawhubSpec) + ) { + return true; + } + return false; +} + function resolveDuplicatePrecedenceRank(params: { pluginId: string; candidate: PluginCandidate; @@ -858,6 +929,12 @@ export function loadPluginManifestRegistry( manifestPath: manifestRes.manifestPath, schemaCacheKey, configSchema, + trustedOfficialInstall: isTrustedOfficialPluginInstall({ + pluginId: manifest.id, + candidate, + env, + installRecords: getInstallRecords(), + }), ...(params.bundledChannelConfigCollector ? { bundledChannelConfigCollector: params.bundledChannelConfigCollector } : {}), diff --git a/src/plugins/registry-types.ts b/src/plugins/registry-types.ts index b8820c72481..d4eff1a0176 100644 --- a/src/plugins/registry-types.ts +++ b/src/plugins/registry-types.ts @@ -205,6 +205,7 @@ export type PluginServiceRegistration = { service: OpenClawPluginService; source: string; origin: PluginOrigin; + trustedOfficialInstall?: boolean; rootDir?: string; }; @@ -337,6 +338,7 @@ export type PluginRecord = { rootDir?: string; origin: PluginOrigin; workspaceDir?: string; + trustedOfficialInstall?: boolean; enabled: boolean; explicitlyEnabled?: boolean; activated?: boolean; diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 497e1d37661..fd734365a4d 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -1452,6 +1452,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { service, source: record.source, origin: record.origin, + trustedOfficialInstall: record.trustedOfficialInstall, rootDir: record.rootDir, }); }; diff --git a/src/plugins/services.test.ts b/src/plugins/services.test.ts index f6b8fbfc016..6615eae0acb 100644 --- a/src/plugins/services.test.ts +++ b/src/plugins/services.test.ts @@ -22,6 +22,7 @@ function createRegistry( services: OpenClawPluginService[], pluginId = "plugin:test", origin: PluginOrigin = "workspace", + trustedOfficialInstall = false, ) { const registry = createEmptyPluginRegistry(); registry.services = services.map((service) => ({ @@ -29,6 +30,7 @@ function createRegistry( service, source: "test", origin, + ...(trustedOfficialInstall ? { trustedOfficialInstall } : {}), rootDir: "/plugins/test-plugin", })) as typeof registry.services; return registry; @@ -181,7 +183,7 @@ describe("startPluginServices", () => { expect(stopThrows).toHaveBeenCalledOnce(); }); - it("grants internal diagnostics only to bundled diagnostics exporter services", async () => { + it("grants internal diagnostics only to trusted diagnostics exporter services", async () => { const contexts: OpenClawPluginServiceContext[] = []; const diagnosticsService = createTrackingService("diagnostics-otel", { contexts }); await startPluginServices({ @@ -204,6 +206,18 @@ describe("startPluginServices", () => { expect(prometheusContexts[0]?.internalDiagnostics?.onEvent).toBeTypeOf("function"); expect(prometheusContexts[0]?.internalDiagnostics?.emit).toBeTypeOf("function"); + const officialInstallContexts: OpenClawPluginServiceContext[] = []; + const officialInstallService = createTrackingService("diagnostics-prometheus", { + contexts: officialInstallContexts, + }); + await startPluginServices({ + registry: createRegistry([officialInstallService], "diagnostics-prometheus", "global", true), + config: createServiceConfig(), + }); + + expect(officialInstallContexts[0]?.internalDiagnostics?.onEvent).toBeTypeOf("function"); + expect(officialInstallContexts[0]?.internalDiagnostics?.emit).toBeTypeOf("function"); + const untrustedContexts: OpenClawPluginServiceContext[] = []; const untrustedService = createTrackingService("diagnostics-otel", { contexts: untrustedContexts, @@ -214,5 +228,16 @@ describe("startPluginServices", () => { }); expect(untrustedContexts[0]?.internalDiagnostics).toBeUndefined(); + + const spoofedContexts: OpenClawPluginServiceContext[] = []; + const spoofedService = createTrackingService("diagnostics-prometheus", { + contexts: spoofedContexts, + }); + await startPluginServices({ + registry: createRegistry([spoofedService], "not-diagnostics-prometheus", "global", true), + config: createServiceConfig(), + }); + + expect(spoofedContexts[0]?.internalDiagnostics).toBeUndefined(); }); }); diff --git a/src/plugins/services.ts b/src/plugins/services.ts index abbc0e9462d..a4179bc3806 100644 --- a/src/plugins/services.ts +++ b/src/plugins/services.ts @@ -24,11 +24,13 @@ function createServiceContext(params: { workspaceDir?: string; service?: PluginServiceRegistration; }): OpenClawPluginServiceContext { + const isDiagnosticsExporter = + params.service?.pluginId === params.service?.service.id && + (params.service?.service.id === "diagnostics-otel" || + params.service?.service.id === "diagnostics-prometheus"); const grantsInternalDiagnostics = - params.service?.origin === "bundled" && - params.service.pluginId === params.service.service.id && - (params.service.service.id === "diagnostics-otel" || - params.service.service.id === "diagnostics-prometheus"); + isDiagnosticsExporter && + (params.service?.origin === "bundled" || params.service?.trustedOfficialInstall === true); return { config: params.config,