fix: normalize bundled plugin version reporting

This commit is contained in:
Peter Steinberger 2026-03-23 18:19:34 -07:00
parent e9905fd696
commit b4bda479a4
No known key found for this signature in database
5 changed files with 275 additions and 7 deletions

View File

@ -2,7 +2,9 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { VERSION } from "../version.js";
import { createConfigIO } from "./io.js";
import { parseOpenClawVersion } from "./version.js";
async function withTempHome(run: (home: string) => Promise<void>): Promise<void> {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-config-"));
@ -157,4 +159,76 @@ describe("config io paths", () => {
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("- gateway.port:"));
});
});
it("does not warn when config was last touched by a same-base correction publish", async () => {
const parsedVersion = parseOpenClawVersion(VERSION);
if (!parsedVersion) {
throw new Error(`Unable to parse VERSION: ${VERSION}`);
}
const touchedVersion = `${parsedVersion.major}.${parsedVersion.minor}.${parsedVersion.patch}-${(parsedVersion.revision ?? 0) + 1}`;
await withTempHome(async (home) => {
const configDir = path.join(home, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
const configPath = path.join(configDir, "openclaw.json");
await fs.writeFile(
configPath,
JSON.stringify({ meta: { lastTouchedVersion: touchedVersion } }, null, 2),
);
const logger = {
warn: vi.fn(),
error: vi.fn(),
};
const io = createConfigIO({
env: {} as NodeJS.ProcessEnv,
homedir: () => home,
logger,
});
io.loadConfig();
expect(logger.warn).not.toHaveBeenCalledWith(
expect.stringContaining("Config was last written by a newer OpenClaw"),
);
expect(io.configPath).toBe(configPath);
});
});
it("still warns for same-base prerelease configs", async () => {
const parsedVersion = parseOpenClawVersion(VERSION);
if (!parsedVersion) {
throw new Error(`Unable to parse VERSION: ${VERSION}`);
}
const touchedVersion = `${parsedVersion.major}.${parsedVersion.minor}.${parsedVersion.patch}-beta.1`;
await withTempHome(async (home) => {
const configDir = path.join(home, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
const configPath = path.join(configDir, "openclaw.json");
await fs.writeFile(
configPath,
JSON.stringify({ meta: { lastTouchedVersion: touchedVersion } }, null, 2),
);
const logger = {
warn: vi.fn(),
error: vi.fn(),
};
const io = createConfigIO({
env: {} as NodeJS.ProcessEnv,
homedir: () => home,
logger,
});
io.loadConfig();
expect(logger.warn).toHaveBeenCalledWith(
`Config was last written by a newer OpenClaw (${touchedVersion}); current version is ${VERSION}.`,
);
expect(io.configPath).toBe(configPath);
});
});
});

View File

@ -53,7 +53,7 @@ import {
validateConfigObjectRawWithPlugins,
validateConfigObjectWithPlugins,
} from "./validation.js";
import { compareOpenClawVersions } from "./version.js";
import { compareOpenClawVersions, isSameOpenClawStableFamily } from "./version.js";
// Re-export for backwards compatibility
export { CircularIncludeError, ConfigIncludeError } from "./includes.js";
@ -622,6 +622,9 @@ function warnIfConfigFromFuture(cfg: OpenClawConfig, logger: Pick<typeof console
if (!touched) {
return;
}
if (isSameOpenClawStableFamily(VERSION, touched)) {
return;
}
const cmp = compareOpenClawVersions(VERSION, touched);
if (cmp === null) {
return;

View File

@ -2,28 +2,59 @@ export type OpenClawVersion = {
major: number;
minor: number;
patch: number;
revision: number;
revision: number | null;
prerelease: string[] | null;
};
const VERSION_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-(\d+))?/;
const VERSION_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
export function parseOpenClawVersion(raw: string | null | undefined): OpenClawVersion | null {
if (!raw) {
return null;
}
const match = raw.trim().match(VERSION_RE);
const normalized = normalizeLegacyDotBetaVersion(raw.trim());
const match = normalized.match(VERSION_RE);
if (!match) {
return null;
}
const [, major, minor, patch, revision] = match;
const [, major, minor, patch, suffix] = match;
const revision = suffix && /^[0-9]+$/.test(suffix) ? Number.parseInt(suffix, 10) : null;
return {
major: Number.parseInt(major, 10),
minor: Number.parseInt(minor, 10),
patch: Number.parseInt(patch, 10),
revision: revision ? Number.parseInt(revision, 10) : 0,
revision,
prerelease: suffix && revision == null ? suffix.split(".").filter(Boolean) : null,
};
}
export function normalizeOpenClawVersionBase(raw: string | null | undefined): string | null {
const parsed = parseOpenClawVersion(raw);
if (!parsed) {
return null;
}
return `${parsed.major}.${parsed.minor}.${parsed.patch}`;
}
export function isSameOpenClawStableFamily(
a: string | null | undefined,
b: string | null | undefined,
): boolean {
const parsedA = parseOpenClawVersion(a);
const parsedB = parseOpenClawVersion(b);
if (!parsedA || !parsedB) {
return false;
}
if (parsedA.prerelease?.length || parsedB.prerelease?.length) {
return false;
}
return (
parsedA.major === parsedB.major &&
parsedA.minor === parsedB.minor &&
parsedA.patch === parsedB.patch
);
}
export function compareOpenClawVersions(
a: string | null | undefined,
b: string | null | undefined,
@ -42,8 +73,91 @@ export function compareOpenClawVersions(
if (parsedA.patch !== parsedB.patch) {
return parsedA.patch < parsedB.patch ? -1 : 1;
}
if (parsedA.revision !== parsedB.revision) {
const rankA = releaseRank(parsedA);
const rankB = releaseRank(parsedB);
if (rankA !== rankB) {
return rankA < rankB ? -1 : 1;
}
if (
parsedA.revision != null &&
parsedB.revision != null &&
parsedA.revision !== parsedB.revision
) {
return parsedA.revision < parsedB.revision ? -1 : 1;
}
if (parsedA.prerelease || parsedB.prerelease) {
return comparePrerelease(parsedA.prerelease, parsedB.prerelease);
}
return 0;
}
function normalizeLegacyDotBetaVersion(version: string): string {
const dotBetaMatch = /^([vV]?[0-9]+\.[0-9]+\.[0-9]+)\.beta(?:\.([0-9A-Za-z.-]+))?$/.exec(version);
if (!dotBetaMatch) {
return version;
}
const base = dotBetaMatch[1];
const suffix = dotBetaMatch[2];
return suffix ? `${base}-beta.${suffix}` : `${base}-beta`;
}
function releaseRank(version: OpenClawVersion): number {
if (version.prerelease?.length) {
return 0;
}
if (version.revision != null) {
return 2;
}
return 1;
}
function comparePrerelease(a: string[] | null, b: string[] | null): number {
if (!a?.length && !b?.length) {
return 0;
}
if (!a?.length) {
return 1;
}
if (!b?.length) {
return -1;
}
const max = Math.max(a.length, b.length);
for (let i = 0; i < max; i += 1) {
const ai = a[i];
const bi = b[i];
if (ai == null && bi == null) {
return 0;
}
if (ai == null) {
return -1;
}
if (bi == null) {
return 1;
}
if (ai === bi) {
continue;
}
const aiNumeric = /^[0-9]+$/.test(ai);
const biNumeric = /^[0-9]+$/.test(bi);
if (aiNumeric && biNumeric) {
const aiNum = Number.parseInt(ai, 10);
const biNum = Number.parseInt(bi, 10);
return aiNum < biNum ? -1 : 1;
}
if (aiNumeric && !biNumeric) {
return -1;
}
if (!aiNumeric && biNumeric) {
return 1;
}
return ai < bi ? -1 : 1;
}
return 0;
}

View File

@ -81,6 +81,63 @@ describe("buildPluginStatusReport", () => {
);
});
it("normalizes bundled plugin versions to the core base release", () => {
loadOpenClawPluginsMock.mockReturnValue({
plugins: [
{
id: "whatsapp",
name: "WhatsApp",
description: "Bundled channel plugin",
version: "2026.3.22",
source: "/tmp/whatsapp/index.ts",
origin: "bundled",
enabled: true,
status: "loaded",
toolNames: [],
hookNames: [],
channelIds: ["whatsapp"],
providerIds: [],
speechProviderIds: [],
mediaUnderstandingProviderIds: [],
imageGenerationProviderIds: [],
webSearchProviderIds: [],
gatewayMethods: [],
cliCommands: [],
services: [],
commands: [],
httpRoutes: 0,
hookCount: 0,
configSchema: false,
},
],
diagnostics: [],
channels: [],
providers: [],
speechProviders: [],
mediaUnderstandingProviders: [],
imageGenerationProviders: [],
webSearchProviders: [],
tools: [],
hooks: [],
typedHooks: [],
channelSetups: [],
httpRoutes: [],
gatewayHandlers: {},
cliRegistrars: [],
services: [],
commands: [],
});
const report = buildPluginStatusReport({
config: {},
env: {
OPENCLAW_VERSION: "2026.3.23-1",
} as NodeJS.ProcessEnv,
});
expect(report.plugins[0]?.version).toBe("2026.3.23");
});
it("builds an inspect report with capability shape and policy", () => {
loadConfigMock.mockReturnValue({
plugins: {

View File

@ -1,7 +1,9 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js";
import { loadConfig } from "../config/config.js";
import { normalizeOpenClawVersionBase } from "../config/version.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveRuntimeServiceVersion } from "../version.js";
import { inspectBundleLspRuntimeSupport } from "./bundle-lsp.js";
import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js";
import { normalizePluginsConfig } from "./config-state.js";
@ -114,6 +116,20 @@ function buildCompatibilityNoticesForInspect(
const log = createSubsystemLogger("plugins");
function resolveReportedPluginVersion(
plugin: PluginRegistry["plugins"][number],
env: NodeJS.ProcessEnv | undefined,
): string | undefined {
if (plugin.origin !== "bundled") {
return plugin.version;
}
return (
normalizeOpenClawVersionBase(resolveRuntimeServiceVersion(env)) ??
normalizeOpenClawVersionBase(plugin.version) ??
plugin.version
);
}
export function buildPluginStatusReport(params?: {
config?: ReturnType<typeof loadConfig>;
workspaceDir?: string;
@ -136,6 +152,10 @@ export function buildPluginStatusReport(params?: {
return {
workspaceDir,
...registry,
plugins: registry.plugins.map((plugin) => ({
...plugin,
version: resolveReportedPluginVersion(plugin, params?.env),
})),
};
}