Skip to content

Commit 318a874

Browse files
committed
fix(plugins): skip update when bundled plugin version is newer than installed clawhub/marketplace version
1 parent cc25646 commit 318a874

4 files changed

Lines changed: 129 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai
2626
- 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.
2727
- 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.
2828
- 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.
29+
- 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.
2930
- 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.
3031
- 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.
3132
- 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.

src/plugins/bundled-sources.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export type BundledPluginSource = {
66
pluginId: string;
77
localPath: string;
88
npmSpec?: string;
9+
version?: string;
910
configSchema?: Record<string, unknown>;
1011
requiresConfig?: boolean;
1112
};
@@ -62,10 +63,16 @@ export function resolveBundledPluginSources(params: {
6263
normalizeOptionalString(candidate.packageName) ||
6364
undefined;
6465

66+
const version =
67+
normalizeOptionalString(candidate.packageVersion) ||
68+
normalizeOptionalString(manifest.manifest.version) ||
69+
undefined;
70+
6571
bundled.set(pluginId, {
6672
pluginId,
6773
localPath: candidate.rootDir,
6874
npmSpec,
75+
version,
6976
...(isRecord(manifest.manifest.configSchema)
7077
? { configSchema: manifest.manifest.configSchema }
7178
: {}),

src/plugins/update.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,7 @@ describe("updateNpmInstalledPlugins", () => {
304304
installPluginFromClawHubMock.mockReset();
305305
installPluginFromGitSpecMock.mockReset();
306306
resolveBundledPluginSourcesMock.mockReset();
307+
resolveBundledPluginSourcesMock.mockReturnValue(new Map());
307308
runCommandWithTimeoutMock.mockReset();
308309
});
309310

@@ -1039,6 +1040,97 @@ describe("updateNpmInstalledPlugins", () => {
10391040
});
10401041
});
10411042

1043+
it("skips ClawHub plugin update when bundled version is newer", async () => {
1044+
resolveBundledPluginSourcesMock.mockReturnValue(
1045+
new Map([
1046+
[
1047+
"whatsapp",
1048+
{
1049+
pluginId: "whatsapp",
1050+
localPath: appBundledPluginRoot("whatsapp"),
1051+
version: "2026.4.20",
1052+
},
1053+
],
1054+
]),
1055+
);
1056+
1057+
const config = createClawHubInstallConfig({
1058+
pluginId: "whatsapp",
1059+
installPath: "/tmp/whatsapp",
1060+
clawhubUrl: "https://clawhub.ai",
1061+
clawhubPackage: "whatsapp",
1062+
clawhubFamily: "bundle-plugin",
1063+
clawhubChannel: "community",
1064+
});
1065+
(config.plugins!.installs!.whatsapp as Record<string, unknown>).version = "2026.2.9";
1066+
1067+
const warnMessages: string[] = [];
1068+
const result = await updateNpmInstalledPlugins({
1069+
config,
1070+
pluginIds: ["whatsapp"],
1071+
logger: { warn: (msg) => warnMessages.push(msg) },
1072+
});
1073+
1074+
expect(installPluginFromClawHubMock).not.toHaveBeenCalled();
1075+
expect(result.changed).toBe(false);
1076+
expect(result.outcomes).toEqual([
1077+
expect.objectContaining({
1078+
pluginId: "whatsapp",
1079+
status: "skipped",
1080+
message: expect.stringContaining("bundled version 2026.4.20 is newer"),
1081+
}),
1082+
]);
1083+
expect(warnMessages).toEqual([expect.stringContaining("bundled version 2026.4.20 is newer")]);
1084+
});
1085+
1086+
it("proceeds with ClawHub plugin update when bundled version is older", async () => {
1087+
resolveBundledPluginSourcesMock.mockReturnValue(
1088+
new Map([
1089+
[
1090+
"demo",
1091+
{
1092+
pluginId: "demo",
1093+
localPath: appBundledPluginRoot("demo"),
1094+
version: "1.0.0",
1095+
},
1096+
],
1097+
]),
1098+
);
1099+
installPluginFromClawHubMock.mockResolvedValue({
1100+
ok: true,
1101+
pluginId: "demo",
1102+
targetDir: "/tmp/demo",
1103+
version: "2.0.0",
1104+
clawhub: {
1105+
source: "clawhub",
1106+
clawhubUrl: "https://clawhub.ai",
1107+
clawhubPackage: "demo",
1108+
clawhubFamily: "code-plugin",
1109+
clawhubChannel: "official",
1110+
integrity: "sha256-new",
1111+
resolvedAt: "2026-04-30T00:00:00.000Z",
1112+
},
1113+
});
1114+
1115+
const config = createClawHubInstallConfig({
1116+
pluginId: "demo",
1117+
installPath: "/tmp/demo",
1118+
clawhubUrl: "https://clawhub.ai",
1119+
clawhubPackage: "demo",
1120+
clawhubFamily: "code-plugin",
1121+
clawhubChannel: "official",
1122+
});
1123+
(config.plugins!.installs!.demo as Record<string, unknown>).version = "1.5.0";
1124+
1125+
const result = await updateNpmInstalledPlugins({
1126+
config,
1127+
pluginIds: ["demo"],
1128+
});
1129+
1130+
expect(installPluginFromClawHubMock).toHaveBeenCalled();
1131+
expect(result.changed).toBe(true);
1132+
});
1133+
10421134
it("migrates legacy unscoped install keys when a scoped npm package updates", async () => {
10431135
installPluginFromNpmSpecMock.mockResolvedValue({
10441136
ok: true,

src/plugins/update.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
expectedIntegrityForUpdate,
88
readInstalledPackageVersion,
99
} from "../infra/package-update-utils.js";
10+
import { compareComparableSemver, parseComparableSemver } from "../infra/semver-compare.js";
1011
import type { UpdateChannel } from "../infra/update-channels.js";
1112
import { resolveUserPath } from "../utils.js";
1213
import { resolveBundledPluginSources } from "./bundled-sources.js";
@@ -167,6 +168,13 @@ function shouldSkipUnchangedNpmInstall(params: {
167168
);
168169
}
169170

171+
function isBundledVersionNewer(bundledVersion: string, installedVersion: string): boolean {
172+
const bundled = parseComparableSemver(bundledVersion);
173+
const installed = parseComparableSemver(installedVersion);
174+
const cmp = compareComparableSemver(bundled, installed);
175+
return cmp !== null && cmp > 0;
176+
}
177+
170178
function pathsEqual(
171179
left: string | undefined,
172180
right: string | undefined,
@@ -492,6 +500,7 @@ export async function updateNpmInstalledPlugins(params: {
492500
const normalizedPluginConfig = params.skipDisabledPlugins
493501
? normalizePluginsConfig(params.config.plugins)
494502
: undefined;
503+
const bundled = resolveBundledPluginSources({});
495504
const outcomes: PluginUpdateOutcome[] = [];
496505
let next = params.config;
497506
let changed = false;
@@ -581,6 +590,26 @@ export async function updateNpmInstalledPlugins(params: {
581590
continue;
582591
}
583592

593+
if (record.source === "clawhub" || record.source === "marketplace") {
594+
const bundledSource = bundled.get(pluginId);
595+
if (
596+
bundledSource?.version &&
597+
record.version &&
598+
isBundledVersionNewer(bundledSource.version, record.version)
599+
) {
600+
logger.warn?.(
601+
`Skipping "${pluginId}" update: bundled version ${bundledSource.version} is newer than the installed ${record.source} version ${record.version}. ` +
602+
`Uninstall the ${record.source} plugin to use the bundled version, or pin a newer version explicitly.`,
603+
);
604+
outcomes.push({
605+
pluginId,
606+
status: "skipped",
607+
message: `Skipping "${pluginId}": bundled version ${bundledSource.version} is newer than ${record.source} version ${record.version}.`,
608+
});
609+
continue;
610+
}
611+
}
612+
584613
if (
585614
record.source === "marketplace" &&
586615
(!record.marketplaceSource || !record.marketplacePlugin)

0 commit comments

Comments
 (0)