fix(update): trust catalog-matched npm updates

This commit is contained in:
Vincent Koc
2026-05-03 03:04:25 -07:00
parent 423b5d6981
commit 07e0342106
3 changed files with 119 additions and 1 deletions

View File

@@ -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.

View File

@@ -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",

View File

@@ -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({