From 07e0342106a6cb61cf5bd14a1bb1d0b0c58025e6 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 3 May 2026 03:04:25 -0700 Subject: [PATCH] fix(update): trust catalog-matched npm updates --- CHANGELOG.md | 2 +- src/plugins/update.test.ts | 74 ++++++++++++++++++++++++++++++++++++++ src/plugins/update.ts | 44 +++++++++++++++++++++++ 3 files changed, 119 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20299c77e2f..f3c4ef94a61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ Docs: https://docs.openclaw.ai - Plugins/externalization: pin beta-only official launch packages for ACPX, Google Chat, and LINE to explicit npm beta specs so catalog-driven installs do not trip the prerelease safety guard while npm `latest` still points at beta. Thanks @vincentkoc. - CLI/doctor: keep missing-plugin repair from overriding official catalog metadata with runtime fallbacks, so ACPX repairs preserve the beta npm spec during the externalization rollout. Thanks @vincentkoc. - Plugins/catalog: preserve ClawHub install specs when generating the packaged channel catalog so future storepack-first channel plugins keep their remote source instead of becoming npm-only. Thanks @vincentkoc. -- Plugins/update: treat OpenClaw-authored externalized-bundled npm bridges as trusted official installs so launch-code plugins can migrate out of the bundled tree without scanner false positives. Thanks @vincentkoc. +- Plugins/update: treat catalog-matched official npm updates and OpenClaw-authored externalized-bundled npm bridges as trusted official installs so launch-code plugins can update or migrate out of the bundled tree without scanner false positives. Thanks @vincentkoc. - Control UI/Talk: fix Talk (OpenAI Realtime WebRTC) CORS failure by stripping server-side-only attribution headers (`originator`, `version`, `User-Agent`) from browser offer headers; `api.openai.com/v1/realtime/calls` only allows `authorization` and `content-type` in its CORS preflight, so forwarding these headers caused the browser SDP exchange to fail. Fixes #76435. Thanks @hclsys. - CLI/logs: auto-reconnect `openclaw logs --follow` on transient gateway disconnects (WebSocket close, timeout, connection drop) with bounded exponential backoff (up to 8 retries, capped at 30 s) and stderr retry warnings, while still exiting immediately on non-recoverable auth or configuration errors. Fixes #74782. (#75059) Thanks @shashank-poola. - Plugins/onboarding: trust optional official plugin and web-search installs selected from the official catalog so npm security scanning treats them like other source-linked official install paths. Thanks @vincentkoc. diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index c3446611716..89447f40b1a 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -469,6 +469,80 @@ describe("updateNpmInstalledPlugins", () => { }); }); + it("trusts official catalog npm updates when the installed package matches the catalog", async () => { + const installPath = createInstalledPackageDir({ + name: "@openclaw/acpx", + version: "2026.5.2-beta.1", + }); + mockNpmViewMetadata({ + name: "@openclaw/acpx", + version: "2026.5.2-beta.2", + }); + installPluginFromNpmSpecMock.mockResolvedValue( + createSuccessfulNpmUpdateResult({ + pluginId: "acpx", + targetDir: installPath, + version: "2026.5.2-beta.2", + }), + ); + + await updateNpmInstalledPlugins({ + config: createNpmInstallConfig({ + pluginId: "acpx", + spec: "@openclaw/acpx@beta", + installPath, + resolvedName: "@openclaw/acpx", + resolvedSpec: "@openclaw/acpx@2026.5.2-beta.1", + resolvedVersion: "2026.5.2-beta.1", + }), + pluginIds: ["acpx"], + }); + + expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@openclaw/acpx@beta", + expectedPluginId: "acpx", + trustedSourceLinkedOfficialInstall: true, + }), + ); + }); + + it("does not trust official npm updates when the install record package mismatches", async () => { + const installPath = createInstalledPackageDir({ + name: "@vendor/acpx-fork", + version: "1.0.0", + }); + mockNpmViewMetadata({ + name: "@vendor/acpx-fork", + version: "1.0.1", + }); + installPluginFromNpmSpecMock.mockResolvedValue( + createSuccessfulNpmUpdateResult({ + pluginId: "acpx", + targetDir: installPath, + version: "1.0.1", + }), + ); + + await updateNpmInstalledPlugins({ + config: createNpmInstallConfig({ + pluginId: "acpx", + spec: "@vendor/acpx-fork", + installPath, + resolvedName: "@vendor/acpx-fork", + resolvedSpec: "@vendor/acpx-fork@1.0.0", + resolvedVersion: "1.0.0", + }), + pluginIds: ["acpx"], + }); + + expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith( + expect.not.objectContaining({ + trustedSourceLinkedOfficialInstall: true, + }), + ); + }); + it("skips npm reinstall and config rewrite when the installed artifact is unchanged", async () => { const installPath = createInstalledPackageDir({ name: "@martian-engineering/lossless-claw", diff --git a/src/plugins/update.ts b/src/plugins/update.ts index e53c0b0afec..d8e3e3e2256 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -33,6 +33,10 @@ import { } from "./install.js"; import { buildNpmResolutionInstallFields, recordPluginInstall } from "./installs.js"; import { installPluginFromMarketplace } from "./marketplace.js"; +import { + getOfficialExternalPluginCatalogEntry, + resolveOfficialExternalPluginInstall, +} from "./official-external-plugin-catalog.js"; export type PluginUpdateLogger = { info?: (message: string) => void; @@ -424,6 +428,37 @@ function isDefaultNpmSpecForBetaUpdate(spec: string): { name: string } | null { return null; } +function resolveNpmSpecPackageName(spec: string | undefined): string | undefined { + return spec ? parseRegistryNpmSpec(spec)?.name : undefined; +} + +function isTrustedSourceLinkedOfficialNpmUpdate(params: { + pluginId: string; + spec: string | undefined; + record: PluginInstallRecord; +}): boolean { + if (params.record.source !== "npm") { + return false; + } + const entry = getOfficialExternalPluginCatalogEntry(params.pluginId); + if (!entry) { + return false; + } + const officialPackageName = resolveNpmSpecPackageName( + resolveOfficialExternalPluginInstall(entry)?.npmSpec, + ); + const requestedPackageName = resolveNpmSpecPackageName(params.spec); + if (!officialPackageName || requestedPackageName !== officialPackageName) { + return false; + } + const recordedPackageNames = [ + params.record.resolvedName, + resolveNpmSpecPackageName(params.record.spec), + resolveNpmSpecPackageName(params.record.resolvedSpec), + ].filter((value): value is string => Boolean(value)); + return recordedPackageNames.includes(officialPackageName); +} + function resolveNpmUpdateSpecs(params: { record: PluginInstallRecord; specOverride?: string; @@ -727,6 +762,11 @@ export async function updateNpmInstalledPlugins(params: { record.source === "npm" && npmSpecs?.fallbackSpec === record.spec ? expectedIntegrityForUpdate(record.spec, record.integrity) : undefined; + const trustedSourceLinkedOfficialInstall = isTrustedSourceLinkedOfficialNpmUpdate({ + pluginId, + spec: effectiveSpec, + record, + }); if (record.source === "npm" && !effectiveSpec) { outcomes.push({ @@ -851,6 +891,7 @@ export async function updateNpmInstalledPlugins(params: { timeoutMs: params.timeoutMs, dryRun: true, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, + trustedSourceLinkedOfficialInstall, expectedPluginId: pluginId, expectedIntegrity, onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({ @@ -919,6 +960,7 @@ export async function updateNpmInstalledPlugins(params: { timeoutMs: params.timeoutMs, dryRun: true, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, + trustedSourceLinkedOfficialInstall, expectedPluginId: pluginId, expectedIntegrity: fallbackExpectedIntegrity, onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({ @@ -1033,6 +1075,7 @@ export async function updateNpmInstalledPlugins(params: { extensionsDir, timeoutMs: params.timeoutMs, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, + trustedSourceLinkedOfficialInstall, expectedPluginId: pluginId, expectedIntegrity, onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({ @@ -1097,6 +1140,7 @@ export async function updateNpmInstalledPlugins(params: { extensionsDir, timeoutMs: params.timeoutMs, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, + trustedSourceLinkedOfficialInstall, expectedPluginId: pluginId, expectedIntegrity: fallbackExpectedIntegrity, onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({