fix(plugins): accept stable correction releases

This commit is contained in:
Vincent Koc
2026-05-03 20:52:56 -07:00
parent 973e240bb3
commit 5ca0aa1d15
10 changed files with 303 additions and 5 deletions

View File

@@ -47,6 +47,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Channels/streaming: keep `streaming.progress.toolProgress` scoped to progress draft mode, so disabling compact progress lines does not silence partial/block preview tool updates. Thanks @vincentkoc.
- Plugins/update: treat OpenClaw stable correction versions like `2026.5.3-1` as stable releases for npm installs, plugin updates, and bundled-version comparisons, so `latest` can advance official plugins without prerelease opt-in. Thanks @vincentkoc.
- Control UI: point the Appearance tweakcn browse action and docs at the live tweakcn editor route instead of the removed `/themes` page. Fixes #77048.
- Control UI: render Dream Diary prose through the sanitized markdown pipeline, so diary bold/italic/header markdown no longer appears as literal source text. Fixes #62413.
- Control UI: render tool results whose output arrives as text-block arrays and give expanded tool output a scrollable block, so read/exec output remains visible in WebChat. Fixes #77054.

View File

@@ -134,7 +134,7 @@ is available, then fall back to `latest`.
Use `npm:<package>` when you want to make npm resolution explicit. Bare package specs also install directly from npm during the launch cutover.
Bare specs and `@latest` stay on the stable track. If npm resolves either of those to a prerelease, OpenClaw stops and asks you to opt in explicitly with a prerelease tag such as `@beta`/`@rc` or an exact prerelease version such as `@1.2.3-beta.4`.
Bare specs and `@latest` stay on the stable track. OpenClaw date-stamped correction versions such as `2026.5.3-1` are stable releases for this check. If npm resolves either of those to a prerelease, OpenClaw stops and asks you to opt in explicitly with a prerelease tag such as `@beta`/`@rc` or an exact prerelease version such as `@1.2.3-beta.4`.
If a bare install spec matches an official plugin id (for example `diffs`), OpenClaw installs the catalog entry directly. To install an npm package with the same name, use an explicit scoped spec (for example `@scope/diffs`).

View File

@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest";
import {
compareOpenClawReleaseVersions,
formatPrereleaseResolutionError,
isExactSemverVersion,
isOpenClawStableCorrectionVersion,
isPrereleaseSemverVersion,
isPrereleaseResolutionAllowed,
parseRegistryNpmSpec,
@@ -76,6 +78,16 @@ describe("npm registry spec parsing helpers", () => {
selectorIsPrerelease: false,
},
},
{
spec: "@openclaw/voice-call@2026.5.3-1",
expected: {
name: "@openclaw/voice-call",
raw: "@openclaw/voice-call@2026.5.3-1",
selector: "2026.5.3-1",
selectorKind: "exact-version",
selectorIsPrerelease: false,
},
},
{
spec: "@openclaw/voice-call@1.2.3-beta.1",
expected: {
@@ -99,10 +111,34 @@ describe("npm registry spec parsing helpers", () => {
it.each([
{ value: "1.2.3-beta.1", expected: true },
{ value: "1.2.3-1", expected: true },
{ value: "2026.5.3-beta.1", expected: true },
{ value: "2026.5.3-1", expected: false },
{ value: "2026.2.30-1", expected: true },
{ value: "1.2.3", expected: false },
])("detects prerelease semver versions for %s", ({ value, expected }) => {
expect(isPrereleaseSemverVersion(value)).toBe(expected);
});
it.each([
{ value: "2026.5.3-1", expected: true },
{ value: "2026.5.3-2", expected: true },
{ value: "2026.5.3-beta.1", expected: false },
{ value: "1.2.3-1", expected: false },
{ value: "2026.2.30-1", expected: false },
])("detects OpenClaw stable correction versions for %s", ({ value, expected }) => {
expect(isOpenClawStableCorrectionVersion(value)).toBe(expected);
});
it.each([
{ left: "2026.5.3-1", right: "2026.5.3", expected: 1 },
{ left: "2026.5.3-2", right: "2026.5.3-1", expected: 1 },
{ left: "2026.5.3", right: "2026.5.3-beta.3", expected: 1 },
{ left: "2026.5.3-beta.3", right: "2026.5.3-alpha.9", expected: 1 },
{ left: "1.2.3-1", right: "1.2.3", expected: null },
])("compares OpenClaw release versions for %s and %s", ({ left, right, expected }) => {
expect(compareOpenClawReleaseVersions(left, right)).toBe(expected);
});
});
describe("npm prerelease resolution policy", () => {
@@ -117,6 +153,11 @@ describe("npm prerelease resolution policy", () => {
resolvedVersion: "1.2.3-rc.1",
expected: false,
},
{
spec: "@openclaw/voice-call@latest",
resolvedVersion: "2026.5.3-1",
expected: true,
},
{
spec: "@openclaw/voice-call@beta",
resolvedVersion: "1.2.3-beta.4",

View File

@@ -2,8 +2,23 @@ import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
const EXACT_SEMVER_VERSION_RE =
/^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/;
const OPENCLAW_STABLE_CORRECTION_VERSION_RE =
/^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<day>[1-9]\d?)-(?<correction>[1-9]\d*)$/;
const OPENCLAW_STABLE_VERSION_RE = /^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<day>[1-9]\d?)$/;
const OPENCLAW_ALPHA_VERSION_RE =
/^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<day>[1-9]\d?)-alpha\.(?<alpha>[1-9]\d*)$/;
const OPENCLAW_BETA_VERSION_RE =
/^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<day>[1-9]\d?)-beta\.(?<beta>[1-9]\d*)$/;
const DIST_TAG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
type OpenClawReleaseVersion = {
channel: "alpha" | "beta" | "stable";
dateTime: number;
alphaNumber?: number;
betaNumber?: number;
correctionNumber?: number;
};
export type ParsedRegistryNpmSpec = {
name: string;
raw: string;
@@ -74,7 +89,8 @@ function parseRegistryNpmSpecInternal(
raw: spec,
selector,
selectorKind: "exact-version",
selectorIsPrerelease: Boolean(exactVersionMatch[4]),
selectorIsPrerelease:
Boolean(exactVersionMatch[4]) && !isOpenClawStableCorrectionVersion(selector),
},
};
}
@@ -110,9 +126,87 @@ export function isExactSemverVersion(value: string): boolean {
return EXACT_SEMVER_VERSION_RE.test(value.trim());
}
function parseOpenClawReleaseVersion(value: string): OpenClawReleaseVersion | null {
const trimmed = value.trim();
const candidates = [
{ match: OPENCLAW_STABLE_VERSION_RE.exec(trimmed), channel: "stable" as const },
{ match: OPENCLAW_STABLE_CORRECTION_VERSION_RE.exec(trimmed), channel: "stable" as const },
{ match: OPENCLAW_ALPHA_VERSION_RE.exec(trimmed), channel: "alpha" as const },
{ match: OPENCLAW_BETA_VERSION_RE.exec(trimmed), channel: "beta" as const },
];
const candidate = candidates.find((entry) => entry.match?.groups);
if (!candidate?.match?.groups) {
return null;
}
const year = Number.parseInt(candidate.match.groups.year ?? "", 10);
const month = Number.parseInt(candidate.match.groups.month ?? "", 10);
const day = Number.parseInt(candidate.match.groups.day ?? "", 10);
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
return null;
}
const date = new Date(Date.UTC(year, month - 1, day));
if (
date.getUTCFullYear() !== year ||
date.getUTCMonth() !== month - 1 ||
date.getUTCDate() !== day
) {
return null;
}
const correctionNumber =
candidate.channel === "stable" && candidate.match.groups.correction
? Number.parseInt(candidate.match.groups.correction, 10)
: undefined;
const alphaNumber =
candidate.channel === "alpha"
? Number.parseInt(candidate.match.groups.alpha ?? "", 10)
: undefined;
const betaNumber =
candidate.channel === "beta"
? Number.parseInt(candidate.match.groups.beta ?? "", 10)
: undefined;
return {
channel: candidate.channel,
dateTime: date.getTime(),
correctionNumber,
alphaNumber,
betaNumber,
};
}
export function isOpenClawStableCorrectionVersion(value: string): boolean {
const parsed = parseOpenClawReleaseVersion(value);
return parsed?.channel === "stable" && parsed.correctionNumber !== undefined;
}
export function compareOpenClawReleaseVersions(left: string, right: string): number | null {
const parsedLeft = parseOpenClawReleaseVersion(left);
const parsedRight = parseOpenClawReleaseVersion(right);
if (!parsedLeft || !parsedRight) {
return null;
}
if (parsedLeft.dateTime !== parsedRight.dateTime) {
return parsedLeft.dateTime < parsedRight.dateTime ? -1 : 1;
}
if (parsedLeft.channel !== parsedRight.channel) {
const rank = { alpha: 0, beta: 1, stable: 2 };
return rank[parsedLeft.channel] < rank[parsedRight.channel] ? -1 : 1;
}
if (parsedLeft.channel === "alpha") {
return Math.sign((parsedLeft.alphaNumber ?? 0) - (parsedRight.alphaNumber ?? 0));
}
if (parsedLeft.channel === "beta") {
return Math.sign((parsedLeft.betaNumber ?? 0) - (parsedRight.betaNumber ?? 0));
}
return Math.sign((parsedLeft.correctionNumber ?? 0) - (parsedRight.correctionNumber ?? 0));
}
export function isPrereleaseSemverVersion(value: string): boolean {
const match = EXACT_SEMVER_VERSION_RE.exec(value.trim());
return Boolean(match?.[4]);
const trimmed = value.trim();
const match = EXACT_SEMVER_VERSION_RE.exec(trimmed);
return Boolean(match?.[4]) && !isOpenClawStableCorrectionVersion(trimmed);
}
export function isPrereleaseResolutionAllowed(params: {

View File

@@ -935,6 +935,39 @@ describe("installPluginFromNpmSpec", () => {
expect(officialFallback.npmResolution?.resolvedSpec).toBe("@openclaw/voice-call@0.0.1");
expect(warnings.join("\n")).toContain("falling back to stable @openclaw/voice-call@0.0.1");
runCommandWithTimeoutMock.mockReset();
const correctionNpmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
const correctionWarnings: string[] = [];
mockNpmViewAndInstallMany([
{
spec: "@openclaw/voice-call",
packageName: "@openclaw/voice-call",
version: "2026.5.3-1",
pluginId: "voice-call",
npmRoot: correctionNpmRoot,
versions: ["2026.5.3", "2026.5.3-1"],
expectedDependencySpec: "2026.5.3-1",
},
]);
const stableCorrection = await installPluginFromNpmSpec({
spec: "@openclaw/voice-call",
npmDir: correctionNpmRoot,
expectedPluginId: "voice-call",
trustedSourceLinkedOfficialInstall: true,
logger: {
info: () => {},
warn: (msg: string) => correctionWarnings.push(msg),
},
});
expect(stableCorrection.ok).toBe(true);
if (!stableCorrection.ok) {
return;
}
expect(stableCorrection.npmResolution?.version).toBe("2026.5.3-1");
expect(stableCorrection.npmResolution?.resolvedSpec).toBe("@openclaw/voice-call@2026.5.3-1");
expect(correctionWarnings).toEqual([]);
runCommandWithTimeoutMock.mockReset();
const prereleaseOnlyNpmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
const prereleaseOnlyWarnings: string[] = [];

View File

@@ -15,6 +15,7 @@ import {
type ManagedNpmRootInstalledDependency,
} from "../infra/npm-managed-root.js";
import {
compareOpenClawReleaseVersions,
formatPrereleaseResolutionError,
isExactSemverVersion,
isPrereleaseSemverVersion,
@@ -162,6 +163,10 @@ function isNpmPackageNotFoundMessage(error: string): boolean {
}
function compareNpmSemver(a: string, b: string): number {
const releaseCmp = compareOpenClawReleaseVersions(a, b);
if (releaseCmp !== null) {
return releaseCmp;
}
return compareComparableSemver(parseComparableSemver(a), parseComparableSemver(b)) ?? 0;
}

View File

@@ -560,6 +560,57 @@ describe("updateNpmInstalledPlugins", () => {
});
});
it("updates trusted official npm plugins when latest resolves to a stable correction release", async () => {
const installPath = createInstalledPackageDir({
name: "@openclaw/acpx",
version: "2026.5.3",
});
mockNpmViewMetadata({
name: "@openclaw/acpx",
version: "2026.5.3-1",
integrity: "sha512-correction",
shasum: "correction",
});
installPluginFromNpmSpecMock.mockResolvedValue(
createSuccessfulNpmUpdateResult({
pluginId: "acpx",
targetDir: installPath,
version: "2026.5.3-1",
npmResolution: {
name: "@openclaw/acpx",
version: "2026.5.3-1",
resolvedSpec: "@openclaw/acpx@2026.5.3-1",
},
}),
);
const result = await updateNpmInstalledPlugins({
config: createNpmInstallConfig({
pluginId: "acpx",
spec: "@openclaw/acpx",
installPath,
resolvedName: "@openclaw/acpx",
resolvedSpec: "@openclaw/acpx@2026.5.3",
resolvedVersion: "2026.5.3",
}),
pluginIds: ["acpx"],
});
expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@openclaw/acpx",
expectedPluginId: "acpx",
trustedSourceLinkedOfficialInstall: true,
}),
);
expect(result.outcomes[0]).toMatchObject({
pluginId: "acpx",
status: "updated",
currentVersion: "2026.5.3",
nextVersion: "2026.5.3-1",
});
});
it("does not trust official npm updates when the install record package mismatches", async () => {
const installPath = createInstalledPackageDir({
name: "@vendor/acpx-fork",
@@ -1550,6 +1601,53 @@ describe("updateNpmInstalledPlugins", () => {
expect(result.changed).toBe(true);
});
it("does not treat an older bundled stable release as newer than an installed correction release", async () => {
resolveBundledPluginSourcesMock.mockReturnValue(
new Map([
[
"demo",
{
pluginId: "demo",
localPath: appBundledPluginRoot("demo"),
version: "2026.5.3",
},
],
]),
);
installPluginFromClawHubMock.mockResolvedValue(
createSuccessfulClawHubUpdateResult({
pluginId: "demo",
targetDir: "/tmp/demo",
version: "2026.5.3-2",
clawhubPackage: "demo",
}),
);
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 = "2026.5.3-1";
const result = await updateNpmInstalledPlugins({
config,
pluginIds: ["demo"],
});
expect(installPluginFromClawHubMock).toHaveBeenCalled();
expect(result.changed).toBe(true);
expect(result.outcomes[0]).toMatchObject({
pluginId: "demo",
status: "updated",
currentVersion: undefined,
nextVersion: "2026.5.3-2",
});
});
it("migrates legacy unscoped install keys when a scoped npm package updates", async () => {
installPluginFromNpmSpecMock.mockResolvedValue({
ok: true,

View File

@@ -4,7 +4,11 @@ import type { PluginInstallRecord } from "../config/types.plugins.js";
import { parseClawHubPluginSpec } from "../infra/clawhub-spec.js";
import type { NpmSpecResolution } from "../infra/install-source-utils.js";
import { resolveNpmSpecMetadata } from "../infra/install-source-utils.js";
import { isPrereleaseResolutionAllowed, parseRegistryNpmSpec } from "../infra/npm-registry-spec.js";
import {
compareOpenClawReleaseVersions,
isPrereleaseResolutionAllowed,
parseRegistryNpmSpec,
} from "../infra/npm-registry-spec.js";
import {
expectedIntegrityForUpdate,
readInstalledPackageVersion,
@@ -198,6 +202,10 @@ function shouldBypassTrustedOfficialUnchangedNpmCheck(params: {
}
function isBundledVersionNewer(bundledVersion: string, installedVersion: string): boolean {
const releaseCmp = compareOpenClawReleaseVersions(bundledVersion, installedVersion);
if (releaseCmp !== null) {
return releaseCmp > 0;
}
const bundled = parseComparableSemver(bundledVersion);
const installed = parseComparableSemver(installedVersion);
const cmp = compareComparableSemver(bundled, installed);

View File

@@ -32,6 +32,16 @@ describe("shouldRequireNpmDistTagMirrorAuth", () => {
).toBe(true);
});
it("treats stable correction releases as latest publishes with beta mirroring", () => {
const plan = resolveNpmPublishPlan("2026.4.1-1");
expect(plan).toEqual({
channel: "stable",
publishTag: "latest",
mirrorDistTags: ["beta"],
});
});
it("does not require auth when there are no mirror dist-tags", () => {
const plan = resolveNpmPublishPlan("2026.4.1-beta.1");
const auth = resolveNpmDistTagMirrorAuth({});

View File

@@ -145,6 +145,14 @@ describe("resolveNpmPublishPlan", () => {
});
});
it("can publish stable correction releases directly to latest when requested", () => {
expect(resolveNpmPublishPlan("2026.3.29-1", undefined, "latest")).toEqual({
channel: "stable",
publishTag: "latest",
mirrorDistTags: [],
});
});
it("ignores current beta dist-tag state for stable publishes", () => {
expect(resolveNpmPublishPlan("2026.3.29", "2026.4.1-beta.1")).toEqual({
channel: "stable",