diff --git a/CHANGELOG.md b/CHANGELOG.md index 176602bfa55..b5ea24867f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index cf8a4ef50de..878caf05584 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -134,7 +134,7 @@ is available, then fall back to `latest`. Use `npm:` 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`). diff --git a/src/infra/npm-registry-spec.test.ts b/src/infra/npm-registry-spec.test.ts index d04df129f6a..3b0a3ca6ef1 100644 --- a/src/infra/npm-registry-spec.test.ts +++ b/src/infra/npm-registry-spec.test.ts @@ -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", diff --git a/src/infra/npm-registry-spec.ts b/src/infra/npm-registry-spec.ts index 117917cd788..7e313485bc2 100644 --- a/src/infra/npm-registry-spec.ts +++ b/src/infra/npm-registry-spec.ts @@ -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 = + /^(?\d{4})\.(?[1-9]\d?)\.(?[1-9]\d?)-(?[1-9]\d*)$/; +const OPENCLAW_STABLE_VERSION_RE = /^(?\d{4})\.(?[1-9]\d?)\.(?[1-9]\d?)$/; +const OPENCLAW_ALPHA_VERSION_RE = + /^(?\d{4})\.(?[1-9]\d?)\.(?[1-9]\d?)-alpha\.(?[1-9]\d*)$/; +const OPENCLAW_BETA_VERSION_RE = + /^(?\d{4})\.(?[1-9]\d?)\.(?[1-9]\d?)-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: { diff --git a/src/plugins/install.npm-spec.test.ts b/src/plugins/install.npm-spec.test.ts index 2d2e7af3e8f..cae414ae0b1 100644 --- a/src/plugins/install.npm-spec.test.ts +++ b/src/plugins/install.npm-spec.test.ts @@ -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[] = []; diff --git a/src/plugins/install.ts b/src/plugins/install.ts index ba0e20eb95b..4c0e0a4a2d2 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -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; } diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 904696aa2fd..1f44d43c48f 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -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).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, diff --git a/src/plugins/update.ts b/src/plugins/update.ts index 2d09e41551a..4d7f098185c 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -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); diff --git a/test/npm-publish-plan.test.ts b/test/npm-publish-plan.test.ts index 05e6b1f8c81..34289012534 100644 --- a/test/npm-publish-plan.test.ts +++ b/test/npm-publish-plan.test.ts @@ -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({}); diff --git a/test/openclaw-npm-release-check.test.ts b/test/openclaw-npm-release-check.test.ts index 1e15f3aa997..98941946d3f 100644 --- a/test/openclaw-npm-release-check.test.ts +++ b/test/openclaw-npm-release-check.test.ts @@ -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",