mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:10:45 +00:00
fix(plugins): accept stable correction releases
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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`).
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user