Skip to content

Commit 5ca0aa1

Browse files
committed
fix(plugins): accept stable correction releases
1 parent 973e240 commit 5ca0aa1

10 files changed

Lines changed: 303 additions & 5 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ Docs: https://docs.openclaw.ai
4747
### Fixes
4848

4949
- 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.
50+
- 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.
5051
- Control UI: point the Appearance tweakcn browse action and docs at the live tweakcn editor route instead of the removed `/themes` page. Fixes #77048.
5152
- 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.
5253
- 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.

docs/cli/plugins.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ is available, then fall back to `latest`.
134134

135135
Use `npm:<package>` when you want to make npm resolution explicit. Bare package specs also install directly from npm during the launch cutover.
136136

137-
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`.
137+
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`.
138138

139139
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`).
140140

src/infra/npm-registry-spec.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { describe, expect, it } from "vitest";
22
import {
3+
compareOpenClawReleaseVersions,
34
formatPrereleaseResolutionError,
45
isExactSemverVersion,
6+
isOpenClawStableCorrectionVersion,
57
isPrereleaseSemverVersion,
68
isPrereleaseResolutionAllowed,
79
parseRegistryNpmSpec,
@@ -76,6 +78,16 @@ describe("npm registry spec parsing helpers", () => {
7678
selectorIsPrerelease: false,
7779
},
7880
},
81+
{
82+
spec: "@openclaw/voice-call@2026.5.3-1",
83+
expected: {
84+
name: "@openclaw/voice-call",
85+
raw: "@openclaw/voice-call@2026.5.3-1",
86+
selector: "2026.5.3-1",
87+
selectorKind: "exact-version",
88+
selectorIsPrerelease: false,
89+
},
90+
},
7991
{
8092
spec: "@openclaw/voice-call@1.2.3-beta.1",
8193
expected: {
@@ -99,10 +111,34 @@ describe("npm registry spec parsing helpers", () => {
99111

100112
it.each([
101113
{ value: "1.2.3-beta.1", expected: true },
114+
{ value: "1.2.3-1", expected: true },
115+
{ value: "2026.5.3-beta.1", expected: true },
116+
{ value: "2026.5.3-1", expected: false },
117+
{ value: "2026.2.30-1", expected: true },
102118
{ value: "1.2.3", expected: false },
103119
])("detects prerelease semver versions for %s", ({ value, expected }) => {
104120
expect(isPrereleaseSemverVersion(value)).toBe(expected);
105121
});
122+
123+
it.each([
124+
{ value: "2026.5.3-1", expected: true },
125+
{ value: "2026.5.3-2", expected: true },
126+
{ value: "2026.5.3-beta.1", expected: false },
127+
{ value: "1.2.3-1", expected: false },
128+
{ value: "2026.2.30-1", expected: false },
129+
])("detects OpenClaw stable correction versions for %s", ({ value, expected }) => {
130+
expect(isOpenClawStableCorrectionVersion(value)).toBe(expected);
131+
});
132+
133+
it.each([
134+
{ left: "2026.5.3-1", right: "2026.5.3", expected: 1 },
135+
{ left: "2026.5.3-2", right: "2026.5.3-1", expected: 1 },
136+
{ left: "2026.5.3", right: "2026.5.3-beta.3", expected: 1 },
137+
{ left: "2026.5.3-beta.3", right: "2026.5.3-alpha.9", expected: 1 },
138+
{ left: "1.2.3-1", right: "1.2.3", expected: null },
139+
])("compares OpenClaw release versions for %s and %s", ({ left, right, expected }) => {
140+
expect(compareOpenClawReleaseVersions(left, right)).toBe(expected);
141+
});
106142
});
107143

108144
describe("npm prerelease resolution policy", () => {
@@ -117,6 +153,11 @@ describe("npm prerelease resolution policy", () => {
117153
resolvedVersion: "1.2.3-rc.1",
118154
expected: false,
119155
},
156+
{
157+
spec: "@openclaw/voice-call@latest",
158+
resolvedVersion: "2026.5.3-1",
159+
expected: true,
160+
},
120161
{
121162
spec: "@openclaw/voice-call@beta",
122163
resolvedVersion: "1.2.3-beta.4",

src/infra/npm-registry-spec.ts

Lines changed: 97 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,23 @@ import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
22

33
const EXACT_SEMVER_VERSION_RE =
44
/^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/;
5+
const OPENCLAW_STABLE_CORRECTION_VERSION_RE =
6+
/^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<day>[1-9]\d?)-(?<correction>[1-9]\d*)$/;
7+
const OPENCLAW_STABLE_VERSION_RE = /^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<day>[1-9]\d?)$/;
8+
const OPENCLAW_ALPHA_VERSION_RE =
9+
/^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<day>[1-9]\d?)-alpha\.(?<alpha>[1-9]\d*)$/;
10+
const OPENCLAW_BETA_VERSION_RE =
11+
/^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<day>[1-9]\d?)-beta\.(?<beta>[1-9]\d*)$/;
512
const DIST_TAG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
613

14+
type OpenClawReleaseVersion = {
15+
channel: "alpha" | "beta" | "stable";
16+
dateTime: number;
17+
alphaNumber?: number;
18+
betaNumber?: number;
19+
correctionNumber?: number;
20+
};
21+
722
export type ParsedRegistryNpmSpec = {
823
name: string;
924
raw: string;
@@ -74,7 +89,8 @@ function parseRegistryNpmSpecInternal(
7489
raw: spec,
7590
selector,
7691
selectorKind: "exact-version",
77-
selectorIsPrerelease: Boolean(exactVersionMatch[4]),
92+
selectorIsPrerelease:
93+
Boolean(exactVersionMatch[4]) && !isOpenClawStableCorrectionVersion(selector),
7894
},
7995
};
8096
}
@@ -110,9 +126,87 @@ export function isExactSemverVersion(value: string): boolean {
110126
return EXACT_SEMVER_VERSION_RE.test(value.trim());
111127
}
112128

129+
function parseOpenClawReleaseVersion(value: string): OpenClawReleaseVersion | null {
130+
const trimmed = value.trim();
131+
const candidates = [
132+
{ match: OPENCLAW_STABLE_VERSION_RE.exec(trimmed), channel: "stable" as const },
133+
{ match: OPENCLAW_STABLE_CORRECTION_VERSION_RE.exec(trimmed), channel: "stable" as const },
134+
{ match: OPENCLAW_ALPHA_VERSION_RE.exec(trimmed), channel: "alpha" as const },
135+
{ match: OPENCLAW_BETA_VERSION_RE.exec(trimmed), channel: "beta" as const },
136+
];
137+
const candidate = candidates.find((entry) => entry.match?.groups);
138+
if (!candidate?.match?.groups) {
139+
return null;
140+
}
141+
142+
const year = Number.parseInt(candidate.match.groups.year ?? "", 10);
143+
const month = Number.parseInt(candidate.match.groups.month ?? "", 10);
144+
const day = Number.parseInt(candidate.match.groups.day ?? "", 10);
145+
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
146+
return null;
147+
}
148+
const date = new Date(Date.UTC(year, month - 1, day));
149+
if (
150+
date.getUTCFullYear() !== year ||
151+
date.getUTCMonth() !== month - 1 ||
152+
date.getUTCDate() !== day
153+
) {
154+
return null;
155+
}
156+
157+
const correctionNumber =
158+
candidate.channel === "stable" && candidate.match.groups.correction
159+
? Number.parseInt(candidate.match.groups.correction, 10)
160+
: undefined;
161+
const alphaNumber =
162+
candidate.channel === "alpha"
163+
? Number.parseInt(candidate.match.groups.alpha ?? "", 10)
164+
: undefined;
165+
const betaNumber =
166+
candidate.channel === "beta"
167+
? Number.parseInt(candidate.match.groups.beta ?? "", 10)
168+
: undefined;
169+
170+
return {
171+
channel: candidate.channel,
172+
dateTime: date.getTime(),
173+
correctionNumber,
174+
alphaNumber,
175+
betaNumber,
176+
};
177+
}
178+
179+
export function isOpenClawStableCorrectionVersion(value: string): boolean {
180+
const parsed = parseOpenClawReleaseVersion(value);
181+
return parsed?.channel === "stable" && parsed.correctionNumber !== undefined;
182+
}
183+
184+
export function compareOpenClawReleaseVersions(left: string, right: string): number | null {
185+
const parsedLeft = parseOpenClawReleaseVersion(left);
186+
const parsedRight = parseOpenClawReleaseVersion(right);
187+
if (!parsedLeft || !parsedRight) {
188+
return null;
189+
}
190+
if (parsedLeft.dateTime !== parsedRight.dateTime) {
191+
return parsedLeft.dateTime < parsedRight.dateTime ? -1 : 1;
192+
}
193+
if (parsedLeft.channel !== parsedRight.channel) {
194+
const rank = { alpha: 0, beta: 1, stable: 2 };
195+
return rank[parsedLeft.channel] < rank[parsedRight.channel] ? -1 : 1;
196+
}
197+
if (parsedLeft.channel === "alpha") {
198+
return Math.sign((parsedLeft.alphaNumber ?? 0) - (parsedRight.alphaNumber ?? 0));
199+
}
200+
if (parsedLeft.channel === "beta") {
201+
return Math.sign((parsedLeft.betaNumber ?? 0) - (parsedRight.betaNumber ?? 0));
202+
}
203+
return Math.sign((parsedLeft.correctionNumber ?? 0) - (parsedRight.correctionNumber ?? 0));
204+
}
205+
113206
export function isPrereleaseSemverVersion(value: string): boolean {
114-
const match = EXACT_SEMVER_VERSION_RE.exec(value.trim());
115-
return Boolean(match?.[4]);
207+
const trimmed = value.trim();
208+
const match = EXACT_SEMVER_VERSION_RE.exec(trimmed);
209+
return Boolean(match?.[4]) && !isOpenClawStableCorrectionVersion(trimmed);
116210
}
117211

118212
export function isPrereleaseResolutionAllowed(params: {

src/plugins/install.npm-spec.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -935,6 +935,39 @@ describe("installPluginFromNpmSpec", () => {
935935
expect(officialFallback.npmResolution?.resolvedSpec).toBe("@openclaw/voice-call@0.0.1");
936936
expect(warnings.join("\n")).toContain("falling back to stable @openclaw/voice-call@0.0.1");
937937

938+
runCommandWithTimeoutMock.mockReset();
939+
const correctionNpmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
940+
const correctionWarnings: string[] = [];
941+
mockNpmViewAndInstallMany([
942+
{
943+
spec: "@openclaw/voice-call",
944+
packageName: "@openclaw/voice-call",
945+
version: "2026.5.3-1",
946+
pluginId: "voice-call",
947+
npmRoot: correctionNpmRoot,
948+
versions: ["2026.5.3", "2026.5.3-1"],
949+
expectedDependencySpec: "2026.5.3-1",
950+
},
951+
]);
952+
953+
const stableCorrection = await installPluginFromNpmSpec({
954+
spec: "@openclaw/voice-call",
955+
npmDir: correctionNpmRoot,
956+
expectedPluginId: "voice-call",
957+
trustedSourceLinkedOfficialInstall: true,
958+
logger: {
959+
info: () => {},
960+
warn: (msg: string) => correctionWarnings.push(msg),
961+
},
962+
});
963+
expect(stableCorrection.ok).toBe(true);
964+
if (!stableCorrection.ok) {
965+
return;
966+
}
967+
expect(stableCorrection.npmResolution?.version).toBe("2026.5.3-1");
968+
expect(stableCorrection.npmResolution?.resolvedSpec).toBe("@openclaw/voice-call@2026.5.3-1");
969+
expect(correctionWarnings).toEqual([]);
970+
938971
runCommandWithTimeoutMock.mockReset();
939972
const prereleaseOnlyNpmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
940973
const prereleaseOnlyWarnings: string[] = [];

src/plugins/install.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
type ManagedNpmRootInstalledDependency,
1616
} from "../infra/npm-managed-root.js";
1717
import {
18+
compareOpenClawReleaseVersions,
1819
formatPrereleaseResolutionError,
1920
isExactSemverVersion,
2021
isPrereleaseSemverVersion,
@@ -162,6 +163,10 @@ function isNpmPackageNotFoundMessage(error: string): boolean {
162163
}
163164

164165
function compareNpmSemver(a: string, b: string): number {
166+
const releaseCmp = compareOpenClawReleaseVersions(a, b);
167+
if (releaseCmp !== null) {
168+
return releaseCmp;
169+
}
165170
return compareComparableSemver(parseComparableSemver(a), parseComparableSemver(b)) ?? 0;
166171
}
167172

src/plugins/update.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,57 @@ describe("updateNpmInstalledPlugins", () => {
560560
});
561561
});
562562

563+
it("updates trusted official npm plugins when latest resolves to a stable correction release", async () => {
564+
const installPath = createInstalledPackageDir({
565+
name: "@openclaw/acpx",
566+
version: "2026.5.3",
567+
});
568+
mockNpmViewMetadata({
569+
name: "@openclaw/acpx",
570+
version: "2026.5.3-1",
571+
integrity: "sha512-correction",
572+
shasum: "correction",
573+
});
574+
installPluginFromNpmSpecMock.mockResolvedValue(
575+
createSuccessfulNpmUpdateResult({
576+
pluginId: "acpx",
577+
targetDir: installPath,
578+
version: "2026.5.3-1",
579+
npmResolution: {
580+
name: "@openclaw/acpx",
581+
version: "2026.5.3-1",
582+
resolvedSpec: "@openclaw/acpx@2026.5.3-1",
583+
},
584+
}),
585+
);
586+
587+
const result = await updateNpmInstalledPlugins({
588+
config: createNpmInstallConfig({
589+
pluginId: "acpx",
590+
spec: "@openclaw/acpx",
591+
installPath,
592+
resolvedName: "@openclaw/acpx",
593+
resolvedSpec: "@openclaw/acpx@2026.5.3",
594+
resolvedVersion: "2026.5.3",
595+
}),
596+
pluginIds: ["acpx"],
597+
});
598+
599+
expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith(
600+
expect.objectContaining({
601+
spec: "@openclaw/acpx",
602+
expectedPluginId: "acpx",
603+
trustedSourceLinkedOfficialInstall: true,
604+
}),
605+
);
606+
expect(result.outcomes[0]).toMatchObject({
607+
pluginId: "acpx",
608+
status: "updated",
609+
currentVersion: "2026.5.3",
610+
nextVersion: "2026.5.3-1",
611+
});
612+
});
613+
563614
it("does not trust official npm updates when the install record package mismatches", async () => {
564615
const installPath = createInstalledPackageDir({
565616
name: "@vendor/acpx-fork",
@@ -1550,6 +1601,53 @@ describe("updateNpmInstalledPlugins", () => {
15501601
expect(result.changed).toBe(true);
15511602
});
15521603

1604+
it("does not treat an older bundled stable release as newer than an installed correction release", async () => {
1605+
resolveBundledPluginSourcesMock.mockReturnValue(
1606+
new Map([
1607+
[
1608+
"demo",
1609+
{
1610+
pluginId: "demo",
1611+
localPath: appBundledPluginRoot("demo"),
1612+
version: "2026.5.3",
1613+
},
1614+
],
1615+
]),
1616+
);
1617+
installPluginFromClawHubMock.mockResolvedValue(
1618+
createSuccessfulClawHubUpdateResult({
1619+
pluginId: "demo",
1620+
targetDir: "/tmp/demo",
1621+
version: "2026.5.3-2",
1622+
clawhubPackage: "demo",
1623+
}),
1624+
);
1625+
1626+
const config = createClawHubInstallConfig({
1627+
pluginId: "demo",
1628+
installPath: "/tmp/demo",
1629+
clawhubUrl: "https://clawhub.ai",
1630+
clawhubPackage: "demo",
1631+
clawhubFamily: "code-plugin",
1632+
clawhubChannel: "official",
1633+
});
1634+
(config.plugins!.installs!.demo as Record<string, unknown>).version = "2026.5.3-1";
1635+
1636+
const result = await updateNpmInstalledPlugins({
1637+
config,
1638+
pluginIds: ["demo"],
1639+
});
1640+
1641+
expect(installPluginFromClawHubMock).toHaveBeenCalled();
1642+
expect(result.changed).toBe(true);
1643+
expect(result.outcomes[0]).toMatchObject({
1644+
pluginId: "demo",
1645+
status: "updated",
1646+
currentVersion: undefined,
1647+
nextVersion: "2026.5.3-2",
1648+
});
1649+
});
1650+
15531651
it("migrates legacy unscoped install keys when a scoped npm package updates", async () => {
15541652
installPluginFromNpmSpecMock.mockResolvedValue({
15551653
ok: true,

0 commit comments

Comments
 (0)