From f5927cbb43c4011ae9416115a96e7aa0e9dc6420 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 3 May 2026 17:16:58 -0700 Subject: [PATCH] fix(plugins): update trusted prerelease installs --- CHANGELOG.md | 1 + src/plugins/update.test.ts | 53 ++++++++++++++++++++++++++++++++++++++ src/plugins/update.ts | 25 +++++++++++++++++- 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b55bc066d8..30ecc76e3f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ Docs: https://docs.openclaw.ai - Channels/WhatsApp: allow `@whiskeysockets/libsignal-node` in `onlyBuiltDependencies` so pnpm v9+ `blockExoticSubdeps` no longer rejects the baileys git-tarball subdep and silences all inbound agent replies. Fixes #76539. Thanks @ottodeng and @vincentkoc. - Gateway/systemd: preserve operator-added secrets in the Gateway env file across re-stage while clearing OpenClaw-managed keys (such as `OPENCLAW_GATEWAY_TOKEN`) so a fresh staging value is never shadowed by a stale env-file copy; operator secrets are also retained when the state-dir `.env` is empty. Fixes #76860. Thanks @hclsys. +- Plugin updates: do not short-circuit trusted official npm updates as unchanged when the default/latest spec still resolves to an already-installed prerelease that the installer should replace with a stable fallback. Thanks @vincentkoc. - Plugin tools: keep auth-unavailable optional tools hidden even when another default tool from the same plugin is available and `tools.alsoAllow` names the optional tool. Thanks @vincentkoc. - Realtime transcription: report socket closes before provider readiness as closed-before-ready failures instead of mislabeling them as connection timeouts for OpenAI, xAI, and Deepgram streaming transcription. Thanks @vincentkoc. - OpenAI/Google Meet: fail realtime voice connection attempts when the socket closes before `session.updated`, avoiding stuck Meet joins waiting on a bridge that never became ready. Thanks @vincentkoc. diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 0edabb9fbf6..b3cba4ff4f1 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -507,6 +507,59 @@ describe("updateNpmInstalledPlugins", () => { ); }); + it("does not skip trusted official default updates when latest resolves to the installed prerelease", async () => { + const installPath = createInstalledPackageDir({ + name: "@openclaw/acpx", + version: "2026.5.2-beta.2", + }); + mockNpmViewMetadata({ + name: "@openclaw/acpx", + version: "2026.5.2-beta.2", + integrity: "sha512-beta", + shasum: "beta", + }); + installPluginFromNpmSpecMock.mockResolvedValue( + createSuccessfulNpmUpdateResult({ + pluginId: "acpx", + targetDir: installPath, + version: "2026.5.2", + npmResolution: { + name: "@openclaw/acpx", + version: "2026.5.2", + resolvedSpec: "@openclaw/acpx@2026.5.2", + }, + }), + ); + + const result = await updateNpmInstalledPlugins({ + config: createNpmInstallConfig({ + pluginId: "acpx", + spec: "@openclaw/acpx", + installPath, + integrity: "sha512-beta", + shasum: "beta", + resolvedName: "@openclaw/acpx", + resolvedSpec: "@openclaw/acpx@2026.5.2-beta.2", + resolvedVersion: "2026.5.2-beta.2", + }), + pluginIds: ["acpx"], + }); + + expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@openclaw/acpx", + expectedPluginId: "acpx", + trustedSourceLinkedOfficialInstall: true, + }), + ); + expect(result.outcomes[0]).toMatchObject({ + pluginId: "acpx", + status: "updated", + currentVersion: "2026.5.2-beta.2", + nextVersion: "2026.5.2", + }); + }); + it("does not trust official npm updates when the install record package mismatches", async () => { const installPath = createInstalledPackageDir({ name: "@vendor/acpx-fork", diff --git a/src/plugins/update.ts b/src/plugins/update.ts index bc715192e55..a29e5b30462 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -4,7 +4,7 @@ import type { PluginInstallRecord } from "../config/types.plugins.js"; import { parseClawHubPluginSpec } from "../infra/clawhub-spec.js"; import type { NpmSpecResolution } from "../infra/install-source-utils.js"; import { resolveNpmSpecMetadata } from "../infra/install-source-utils.js"; -import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js"; +import { isPrereleaseResolutionAllowed, parseRegistryNpmSpec } from "../infra/npm-registry-spec.js"; import { expectedIntegrityForUpdate, readInstalledPackageVersion, @@ -179,6 +179,24 @@ function shouldSkipUnchangedNpmInstall(params: { ); } +function shouldBypassTrustedOfficialUnchangedNpmCheck(params: { + metadata: NpmSpecResolution; + spec: string; + trustedSourceLinkedOfficialInstall: boolean; +}): boolean { + if (!params.trustedSourceLinkedOfficialInstall || !params.metadata.version) { + return false; + } + const parsedSpec = parseRegistryNpmSpec(params.spec); + return Boolean( + parsedSpec && + !isPrereleaseResolutionAllowed({ + spec: parsedSpec, + resolvedVersion: params.metadata.version, + }), + ); +} + function isBundledVersionNewer(bundledVersion: string, installedVersion: string): boolean { const bundled = parseComparableSemver(bundledVersion); const installed = parseComparableSemver(installedVersion); @@ -853,6 +871,11 @@ export async function updateNpmInstalledPlugins(params: { }); if (metadataResult.ok) { if ( + !shouldBypassTrustedOfficialUnchangedNpmCheck({ + metadata: metadataResult.metadata, + spec: effectiveSpec!, + trustedSourceLinkedOfficialInstall, + }) && shouldSkipUnchangedNpmInstall({ currentVersion, record,