diff --git a/CHANGELOG.md b/CHANGELOG.md index 99f2eb42d3b..5a30cea62ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai - Plugins/externalization: keep official ACPX, Google Chat, and LINE install specs on production package names, leaving beta-tag probing to the explicit OpenClaw beta update channel. Thanks @vincentkoc. - CLI/doctor: keep missing-plugin repair from overriding official catalog metadata with runtime fallbacks, so ACPX repairs preserve the official 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/catalog: pin bare npm specs from prerelease external channel catalog entries to the catalog entry version, so beta catalogs do not silently install the latest stable package. - 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. - Plugins/onboarding: fall back from ClawHub to npm only for missing package/version errors, keeping integrity and verification failures fail-closed during storepack rollout. 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. diff --git a/src/channels/plugins/catalog.ts b/src/channels/plugins/catalog.ts index 4ff379345a6..c6d5d78b412 100644 --- a/src/channels/plugins/catalog.ts +++ b/src/channels/plugins/catalog.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { MANIFEST_KEY } from "../../compat/legacy-names.js"; +import { isPrereleaseSemverVersion, parseRegistryNpmSpec } from "../../infra/npm-registry-spec.js"; import { resolveOpenClawPackageRootSync } from "../../infra/openclaw-root.js"; import { listChannelCatalogEntries } from "../../plugins/channel-catalog-registry.js"; import { @@ -240,12 +241,26 @@ function toChannelMeta(params: { function resolveInstallInfo(params: { install?: PluginPackageInstall; packageName?: string; + packageVersion?: string; packageDir?: string; workspaceDir?: string; }): ChannelPluginCatalogEntry["install"] | null { const clawhubSpec = normalizeOptionalString(params.install?.clawhubSpec); - const npmSpec = + let npmSpec = normalizeOptionalString(params.install?.npmSpec) ?? normalizeOptionalString(params.packageName); + const packageVersion = normalizeOptionalString(params.packageVersion); + const parsedNpmSpec = npmSpec ? parseRegistryNpmSpec(npmSpec) : null; + const expectedPackageName = normalizeOptionalString(params.packageName); + const parsedPackageName = expectedPackageName ? parseRegistryNpmSpec(expectedPackageName) : null; + if ( + npmSpec && + packageVersion && + isPrereleaseSemverVersion(packageVersion) && + parsedNpmSpec?.selectorKind === "none" && + (!parsedPackageName || parsedNpmSpec.name === parsedPackageName.name) + ) { + npmSpec = `${parsedNpmSpec.name}@${packageVersion}`; + } if (!clawhubSpec && !npmSpec) { return null; } @@ -296,6 +311,7 @@ function resolveInstallInfo(params: { function buildCatalogEntryFromManifest(params: { pluginId?: string; packageName?: string; + packageVersion?: string; packageDir?: string; origin?: PluginOrigin; trustedSourceLinkedOfficialInstall?: boolean; @@ -317,6 +333,7 @@ function buildCatalogEntryFromManifest(params: { const install = resolveInstallInfo({ install: params.install, packageName: params.packageName, + packageVersion: params.packageVersion, packageDir: params.packageDir, workspaceDir: params.workspaceDir, }); @@ -349,6 +366,7 @@ function buildExternalCatalogEntry( return buildCatalogEntryFromManifest({ pluginId: manifest?.plugin?.id, packageName: entry.name, + packageVersion: entry.version, trustedSourceLinkedOfficialInstall: options?.trustedSourceLinkedOfficialInstall, channel: manifest?.channel, install: manifest?.install, diff --git a/src/channels/plugins/contracts/test-helpers/channel-plugin-catalog-contract-suites.ts b/src/channels/plugins/contracts/test-helpers/channel-plugin-catalog-contract-suites.ts index c60344f6fd3..00f18adaa3c 100644 --- a/src/channels/plugins/contracts/test-helpers/channel-plugin-catalog-contract-suites.ts +++ b/src/channels/plugins/contracts/test-helpers/channel-plugin-catalog-contract-suites.ts @@ -287,6 +287,40 @@ export function describeChannelPluginCatalogEntriesContract() { }; }, }, + { + name: "pins bare external prerelease package specs to the entry version", + setup: () => { + const dir = fs.mkdtempSync( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-catalog-prerelease-"), + ); + const catalogPath = path.join(dir, "catalog.json"); + writeCatalogFile(catalogPath, { + ...createCatalogEntry({ + packageName: "@openclaw/prerelease-demo-channel", + channelId: "prerelease-demo", + label: "Prerelease Demo", + blurb: "Prerelease package pinning fixture", + }), + version: "2026.5.3-beta.1", + }); + return { + channelId: "prerelease-demo", + catalogPaths: [catalogPath], + expected: { + install: { npmSpec: "@openclaw/prerelease-demo-channel@2026.5.3-beta.1" }, + installSource: { + npm: { + spec: "@openclaw/prerelease-demo-channel@2026.5.3-beta.1", + packageName: "@openclaw/prerelease-demo-channel", + selector: "2026.5.3-beta.1", + selectorKind: "exact-version", + exactVersion: true, + }, + }, + }, + }; + }, + }, { name: "accepts external manifest entries with ClawHub-only install metadata", setup: () => {