Skip to content

Commit 07e0342

Browse files
committed
fix(update): trust catalog-matched npm updates
1 parent 423b5d6 commit 07e0342

3 files changed

Lines changed: 119 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Docs: https://docs.openclaw.ai
2222
- 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.
2323
- 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.
2424
- 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.
25-
- 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.
25+
- 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.
2626
- 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.
2727
- 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.
2828
- 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.

src/plugins/update.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,80 @@ describe("updateNpmInstalledPlugins", () => {
469469
});
470470
});
471471

472+
it("trusts official catalog npm updates when the installed package matches the catalog", async () => {
473+
const installPath = createInstalledPackageDir({
474+
name: "@openclaw/acpx",
475+
version: "2026.5.2-beta.1",
476+
});
477+
mockNpmViewMetadata({
478+
name: "@openclaw/acpx",
479+
version: "2026.5.2-beta.2",
480+
});
481+
installPluginFromNpmSpecMock.mockResolvedValue(
482+
createSuccessfulNpmUpdateResult({
483+
pluginId: "acpx",
484+
targetDir: installPath,
485+
version: "2026.5.2-beta.2",
486+
}),
487+
);
488+
489+
await updateNpmInstalledPlugins({
490+
config: createNpmInstallConfig({
491+
pluginId: "acpx",
492+
spec: "@openclaw/acpx@beta",
493+
installPath,
494+
resolvedName: "@openclaw/acpx",
495+
resolvedSpec: "@openclaw/[email protected]",
496+
resolvedVersion: "2026.5.2-beta.1",
497+
}),
498+
pluginIds: ["acpx"],
499+
});
500+
501+
expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith(
502+
expect.objectContaining({
503+
spec: "@openclaw/acpx@beta",
504+
expectedPluginId: "acpx",
505+
trustedSourceLinkedOfficialInstall: true,
506+
}),
507+
);
508+
});
509+
510+
it("does not trust official npm updates when the install record package mismatches", async () => {
511+
const installPath = createInstalledPackageDir({
512+
name: "@vendor/acpx-fork",
513+
version: "1.0.0",
514+
});
515+
mockNpmViewMetadata({
516+
name: "@vendor/acpx-fork",
517+
version: "1.0.1",
518+
});
519+
installPluginFromNpmSpecMock.mockResolvedValue(
520+
createSuccessfulNpmUpdateResult({
521+
pluginId: "acpx",
522+
targetDir: installPath,
523+
version: "1.0.1",
524+
}),
525+
);
526+
527+
await updateNpmInstalledPlugins({
528+
config: createNpmInstallConfig({
529+
pluginId: "acpx",
530+
spec: "@vendor/acpx-fork",
531+
installPath,
532+
resolvedName: "@vendor/acpx-fork",
533+
resolvedSpec: "@vendor/[email protected]",
534+
resolvedVersion: "1.0.0",
535+
}),
536+
pluginIds: ["acpx"],
537+
});
538+
539+
expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith(
540+
expect.not.objectContaining({
541+
trustedSourceLinkedOfficialInstall: true,
542+
}),
543+
);
544+
});
545+
472546
it("skips npm reinstall and config rewrite when the installed artifact is unchanged", async () => {
473547
const installPath = createInstalledPackageDir({
474548
name: "@martian-engineering/lossless-claw",

src/plugins/update.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ import {
3333
} from "./install.js";
3434
import { buildNpmResolutionInstallFields, recordPluginInstall } from "./installs.js";
3535
import { installPluginFromMarketplace } from "./marketplace.js";
36+
import {
37+
getOfficialExternalPluginCatalogEntry,
38+
resolveOfficialExternalPluginInstall,
39+
} from "./official-external-plugin-catalog.js";
3640

3741
export type PluginUpdateLogger = {
3842
info?: (message: string) => void;
@@ -424,6 +428,37 @@ function isDefaultNpmSpecForBetaUpdate(spec: string): { name: string } | null {
424428
return null;
425429
}
426430

431+
function resolveNpmSpecPackageName(spec: string | undefined): string | undefined {
432+
return spec ? parseRegistryNpmSpec(spec)?.name : undefined;
433+
}
434+
435+
function isTrustedSourceLinkedOfficialNpmUpdate(params: {
436+
pluginId: string;
437+
spec: string | undefined;
438+
record: PluginInstallRecord;
439+
}): boolean {
440+
if (params.record.source !== "npm") {
441+
return false;
442+
}
443+
const entry = getOfficialExternalPluginCatalogEntry(params.pluginId);
444+
if (!entry) {
445+
return false;
446+
}
447+
const officialPackageName = resolveNpmSpecPackageName(
448+
resolveOfficialExternalPluginInstall(entry)?.npmSpec,
449+
);
450+
const requestedPackageName = resolveNpmSpecPackageName(params.spec);
451+
if (!officialPackageName || requestedPackageName !== officialPackageName) {
452+
return false;
453+
}
454+
const recordedPackageNames = [
455+
params.record.resolvedName,
456+
resolveNpmSpecPackageName(params.record.spec),
457+
resolveNpmSpecPackageName(params.record.resolvedSpec),
458+
].filter((value): value is string => Boolean(value));
459+
return recordedPackageNames.includes(officialPackageName);
460+
}
461+
427462
function resolveNpmUpdateSpecs(params: {
428463
record: PluginInstallRecord;
429464
specOverride?: string;
@@ -727,6 +762,11 @@ export async function updateNpmInstalledPlugins(params: {
727762
record.source === "npm" && npmSpecs?.fallbackSpec === record.spec
728763
? expectedIntegrityForUpdate(record.spec, record.integrity)
729764
: undefined;
765+
const trustedSourceLinkedOfficialInstall = isTrustedSourceLinkedOfficialNpmUpdate({
766+
pluginId,
767+
spec: effectiveSpec,
768+
record,
769+
});
730770

731771
if (record.source === "npm" && !effectiveSpec) {
732772
outcomes.push({
@@ -851,6 +891,7 @@ export async function updateNpmInstalledPlugins(params: {
851891
timeoutMs: params.timeoutMs,
852892
dryRun: true,
853893
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
894+
trustedSourceLinkedOfficialInstall,
854895
expectedPluginId: pluginId,
855896
expectedIntegrity,
856897
onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({
@@ -919,6 +960,7 @@ export async function updateNpmInstalledPlugins(params: {
919960
timeoutMs: params.timeoutMs,
920961
dryRun: true,
921962
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
963+
trustedSourceLinkedOfficialInstall,
922964
expectedPluginId: pluginId,
923965
expectedIntegrity: fallbackExpectedIntegrity,
924966
onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({
@@ -1033,6 +1075,7 @@ export async function updateNpmInstalledPlugins(params: {
10331075
extensionsDir,
10341076
timeoutMs: params.timeoutMs,
10351077
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
1078+
trustedSourceLinkedOfficialInstall,
10361079
expectedPluginId: pluginId,
10371080
expectedIntegrity,
10381081
onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({
@@ -1097,6 +1140,7 @@ export async function updateNpmInstalledPlugins(params: {
10971140
extensionsDir,
10981141
timeoutMs: params.timeoutMs,
10991142
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
1143+
trustedSourceLinkedOfficialInstall,
11001144
expectedPluginId: pluginId,
11011145
expectedIntegrity: fallbackExpectedIntegrity,
11021146
onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({

0 commit comments

Comments
 (0)