fix(plugins): skip update when bundled plugin version is newer than installed clawhub/marketplace version (#75604)

This commit is contained in:
Alex Knight
2026-05-01 21:00:49 +10:00
committed by GitHub
parent 0a74037f6f
commit bbc47cb9e1
4 changed files with 129 additions and 0 deletions

View File

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

View File

@@ -6,6 +6,7 @@ export type BundledPluginSource = {
pluginId: string;
localPath: string;
npmSpec?: string;
version?: string;
configSchema?: Record<string, unknown>;
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 }
: {}),

View File

@@ -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<string, unknown>).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<string, unknown>).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,

View File

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