diff --git a/CHANGELOG.md b/CHANGELOG.md index 15c068e3417..ecf4b9675b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai - Voice Call/realtime: add default-off fast memory/session context for `openclaw_agent_consult`, giving live calls a bounded answer-or-miss path before the full agent consult. Fixes #71849. Thanks @amzzzzzzz. - Google Meet: interrupt Realtime provider output when local barge-in clears playback, so command-pair audio stops model speech instead of only restarting Chrome playback. Fixes #73850. (#73834) Thanks @shhtheonlyperson. - Gateway/config: cap oversized plugin-owned schemas in the full `config.schema` response so large installed plugin sets cannot balloon Gateway RSS or crash schema clients. Thanks @vincentkoc. +- Plugins/update: skip ClawHub and marketplace plugin updates when the bundled version is newer than the recorded installed version, so `openclaw update` no longer overwrites working bundled plugins with older external packages. Fixes #75447. Thanks @amknight. - Gateway/sessions: use bounded tail reads for sessions-list transcript usage fallbacks and cap bulk title/last-message hydration, keeping large session stores responsive when rows request derived previews. Thanks @vincentkoc. - Gateway/chat: bound chat-history transcript reads to the requested display window so large session logs no longer OOM the Gateway when clients ask for a small history page. Thanks @vincentkoc. - Voice Call/Twilio: honor stored pre-connect TwiML before realtime webhook shortcuts and reject DTMF sequences outside conversation mode, so Meet PIN entry cannot be skipped or silently dropped. Thanks @donkeykong91 and @PfanP. diff --git a/src/plugins/bundled-sources.ts b/src/plugins/bundled-sources.ts index 94a21e611ce..0fb0ccbf937 100644 --- a/src/plugins/bundled-sources.ts +++ b/src/plugins/bundled-sources.ts @@ -6,6 +6,7 @@ export type BundledPluginSource = { pluginId: string; localPath: string; npmSpec?: string; + version?: string; configSchema?: Record; requiresConfig?: boolean; }; @@ -62,10 +63,16 @@ export function resolveBundledPluginSources(params: { normalizeOptionalString(candidate.packageName) || undefined; + const version = + normalizeOptionalString(candidate.packageVersion) || + normalizeOptionalString(manifest.manifest.version) || + undefined; + bundled.set(pluginId, { pluginId, localPath: candidate.rootDir, npmSpec, + version, ...(isRecord(manifest.manifest.configSchema) ? { configSchema: manifest.manifest.configSchema } : {}), diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 657650645fd..0befad09b61 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -304,6 +304,7 @@ describe("updateNpmInstalledPlugins", () => { installPluginFromClawHubMock.mockReset(); installPluginFromGitSpecMock.mockReset(); resolveBundledPluginSourcesMock.mockReset(); + resolveBundledPluginSourcesMock.mockReturnValue(new Map()); runCommandWithTimeoutMock.mockReset(); }); @@ -1039,6 +1040,97 @@ describe("updateNpmInstalledPlugins", () => { }); }); + it("skips ClawHub plugin update when bundled version is newer", async () => { + resolveBundledPluginSourcesMock.mockReturnValue( + new Map([ + [ + "whatsapp", + { + pluginId: "whatsapp", + localPath: appBundledPluginRoot("whatsapp"), + version: "2026.4.20", + }, + ], + ]), + ); + + const config = createClawHubInstallConfig({ + pluginId: "whatsapp", + installPath: "/tmp/whatsapp", + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "whatsapp", + clawhubFamily: "bundle-plugin", + clawhubChannel: "community", + }); + (config.plugins!.installs!.whatsapp as Record).version = "2026.2.9"; + + const warnMessages: string[] = []; + const result = await updateNpmInstalledPlugins({ + config, + pluginIds: ["whatsapp"], + logger: { warn: (msg) => warnMessages.push(msg) }, + }); + + expect(installPluginFromClawHubMock).not.toHaveBeenCalled(); + expect(result.changed).toBe(false); + expect(result.outcomes).toEqual([ + expect.objectContaining({ + pluginId: "whatsapp", + status: "skipped", + message: expect.stringContaining("bundled version 2026.4.20 is newer"), + }), + ]); + expect(warnMessages).toEqual([expect.stringContaining("bundled version 2026.4.20 is newer")]); + }); + + it("proceeds with ClawHub plugin update when bundled version is older", async () => { + resolveBundledPluginSourcesMock.mockReturnValue( + new Map([ + [ + "demo", + { + pluginId: "demo", + localPath: appBundledPluginRoot("demo"), + version: "1.0.0", + }, + ], + ]), + ); + installPluginFromClawHubMock.mockResolvedValue({ + ok: true, + pluginId: "demo", + targetDir: "/tmp/demo", + version: "2.0.0", + clawhub: { + source: "clawhub", + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "demo", + clawhubFamily: "code-plugin", + clawhubChannel: "official", + integrity: "sha256-new", + resolvedAt: "2026-04-30T00:00:00.000Z", + }, + }); + + const config = createClawHubInstallConfig({ + pluginId: "demo", + installPath: "/tmp/demo", + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "demo", + clawhubFamily: "code-plugin", + clawhubChannel: "official", + }); + (config.plugins!.installs!.demo as Record).version = "1.5.0"; + + const result = await updateNpmInstalledPlugins({ + config, + pluginIds: ["demo"], + }); + + expect(installPluginFromClawHubMock).toHaveBeenCalled(); + expect(result.changed).toBe(true); + }); + it("migrates legacy unscoped install keys when a scoped npm package updates", async () => { installPluginFromNpmSpecMock.mockResolvedValue({ ok: true, diff --git a/src/plugins/update.ts b/src/plugins/update.ts index 040f82573a1..d551429344f 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -7,6 +7,7 @@ import { expectedIntegrityForUpdate, readInstalledPackageVersion, } from "../infra/package-update-utils.js"; +import { compareComparableSemver, parseComparableSemver } from "../infra/semver-compare.js"; import type { UpdateChannel } from "../infra/update-channels.js"; import { resolveUserPath } from "../utils.js"; import { resolveBundledPluginSources } from "./bundled-sources.js"; @@ -167,6 +168,13 @@ function shouldSkipUnchangedNpmInstall(params: { ); } +function isBundledVersionNewer(bundledVersion: string, installedVersion: string): boolean { + const bundled = parseComparableSemver(bundledVersion); + const installed = parseComparableSemver(installedVersion); + const cmp = compareComparableSemver(bundled, installed); + return cmp !== null && cmp > 0; +} + function pathsEqual( left: string | undefined, right: string | undefined, @@ -492,6 +500,7 @@ export async function updateNpmInstalledPlugins(params: { const normalizedPluginConfig = params.skipDisabledPlugins ? normalizePluginsConfig(params.config.plugins) : undefined; + const bundled = resolveBundledPluginSources({}); const outcomes: PluginUpdateOutcome[] = []; let next = params.config; let changed = false; @@ -581,6 +590,26 @@ export async function updateNpmInstalledPlugins(params: { continue; } + if (record.source === "clawhub" || record.source === "marketplace") { + const bundledSource = bundled.get(pluginId); + if ( + bundledSource?.version && + record.version && + isBundledVersionNewer(bundledSource.version, record.version) + ) { + logger.warn?.( + `Skipping "${pluginId}" update: bundled version ${bundledSource.version} is newer than the installed ${record.source} version ${record.version}. ` + + `Uninstall the ${record.source} plugin to use the bundled version, or pin a newer version explicitly.`, + ); + outcomes.push({ + pluginId, + status: "skipped", + message: `Skipping "${pluginId}": bundled version ${bundledSource.version} is newer than ${record.source} version ${record.version}.`, + }); + continue; + } + } + if ( record.source === "marketplace" && (!record.marketplaceSource || !record.marketplacePlugin)