From 3ce5a8366a67d4a3d0fead2045a643d1cab066ea Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 22 Mar 2026 09:12:08 -0700 Subject: [PATCH] fix(plugins): enforce minimum host versions for installable plugins (#52094) * fix(plugins): enforce min host versions * fix(plugins): tighten min host version validation * chore(plugins): trim dead min host version code * fix(plugins): handle malformed min host metadata * fix(plugins): key manifest cache by host version --- extensions/bluebubbles/package.json | 3 +- extensions/discord/package.json | 3 +- extensions/feishu/package.json | 3 +- extensions/googlechat/package.json | 3 +- extensions/irc/package.json | 3 + extensions/line/package.json | 3 +- extensions/matrix/package.json | 3 +- extensions/mattermost/package.json | 3 +- extensions/memory-lancedb/package.json | 3 +- extensions/msteams/package.json | 3 +- extensions/nextcloud-talk/package.json | 3 +- extensions/nostr/package.json | 3 +- extensions/synology-chat/package.json | 3 +- extensions/tlon/package.json | 3 +- extensions/twitch/package.json | 3 + extensions/voice-call/package.json | 3 + extensions/whatsapp/package.json | 3 +- extensions/zalo/package.json | 3 +- extensions/zalouser/package.json | 3 +- scripts/lib/bundled-extension-manifest.ts | 23 ++- ...nstall-min-host-version-guardrails.test.ts | 72 +++++++++ src/plugins/install.test.ts | 90 +++++++++++ src/plugins/install.ts | 33 ++++ src/plugins/manifest-registry.test.ts | 144 ++++++++++++++++++ src/plugins/manifest-registry.ts | 29 +++- src/plugins/manifest.ts | 1 + src/plugins/min-host-version.test.ts | 96 ++++++++++++ src/plugins/min-host-version.ts | 82 ++++++++++ test/release-check.test.ts | 47 ++++++ 29 files changed, 653 insertions(+), 21 deletions(-) create mode 100644 src/plugins/install-min-host-version-guardrails.test.ts create mode 100644 src/plugins/min-host-version.test.ts create mode 100644 src/plugins/min-host-version.ts diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json index ee974b3b81c..7e926861b9e 100644 --- a/extensions/bluebubbles/package.json +++ b/extensions/bluebubbles/package.json @@ -42,7 +42,8 @@ "install": { "npmSpec": "@openclaw/bluebubbles", "localPath": "extensions/bluebubbles", - "defaultChoice": "npm" + "defaultChoice": "npm", + "minHostVersion": ">=2026.3.14" }, "release": { "publishToNpm": true diff --git a/extensions/discord/package.json b/extensions/discord/package.json index 27404f99f72..a8bf21e8c71 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -39,7 +39,8 @@ "install": { "npmSpec": "@openclaw/discord", "localPath": "extensions/discord", - "defaultChoice": "npm" + "defaultChoice": "npm", + "minHostVersion": ">=2026.3.14" }, "bundle": { "stageRuntimeDependencies": true diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json index 7111cf7e54b..33b3df36ced 100644 --- a/extensions/feishu/package.json +++ b/extensions/feishu/package.json @@ -41,7 +41,8 @@ "install": { "npmSpec": "@openclaw/feishu", "localPath": "extensions/feishu", - "defaultChoice": "npm" + "defaultChoice": "npm", + "minHostVersion": ">=2026.3.14" }, "bundle": { "stageRuntimeDependencies": true diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json index f94193071f4..3b59e6ed197 100644 --- a/extensions/googlechat/package.json +++ b/extensions/googlechat/package.json @@ -40,7 +40,8 @@ "install": { "npmSpec": "@openclaw/googlechat", "localPath": "extensions/googlechat", - "defaultChoice": "npm" + "defaultChoice": "npm", + "minHostVersion": ">=2026.3.14" } } } diff --git a/extensions/irc/package.json b/extensions/irc/package.json index ac861d0a90f..bb73c52b270 100644 --- a/extensions/irc/package.json +++ b/extensions/irc/package.json @@ -10,6 +10,9 @@ "extensions": [ "./index.ts" ], + "install": { + "minHostVersion": ">=2026.3.14" + }, "setupEntry": "./setup-entry.ts", "channel": { "id": "irc", diff --git a/extensions/line/package.json b/extensions/line/package.json index 1d82e5bc172..4d918bd656b 100644 --- a/extensions/line/package.json +++ b/extensions/line/package.json @@ -33,7 +33,8 @@ "install": { "npmSpec": "@openclaw/line", "localPath": "extensions/line", - "defaultChoice": "npm" + "defaultChoice": "npm", + "minHostVersion": ">=2026.3.14" } } } diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index 5c8ac08647b..132243d71f1 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -40,7 +40,8 @@ "install": { "npmSpec": "@openclaw/matrix", "localPath": "extensions/matrix", - "defaultChoice": "npm" + "defaultChoice": "npm", + "minHostVersion": ">=2026.3.14" }, "releaseChecks": { "rootDependencyMirrorAllowlist": [ diff --git a/extensions/mattermost/package.json b/extensions/mattermost/package.json index 8d6a463c0ca..e483300b785 100644 --- a/extensions/mattermost/package.json +++ b/extensions/mattermost/package.json @@ -35,7 +35,8 @@ "install": { "npmSpec": "@openclaw/mattermost", "localPath": "extensions/mattermost", - "defaultChoice": "npm" + "defaultChoice": "npm", + "minHostVersion": ">=2026.3.14" } } } diff --git a/extensions/memory-lancedb/package.json b/extensions/memory-lancedb/package.json index 9944ecbd342..a58ae010cce 100644 --- a/extensions/memory-lancedb/package.json +++ b/extensions/memory-lancedb/package.json @@ -15,7 +15,8 @@ "install": { "npmSpec": "@openclaw/memory-lancedb", "localPath": "extensions/memory-lancedb", - "defaultChoice": "npm" + "defaultChoice": "npm", + "minHostVersion": ">=2026.3.14" }, "release": { "publishToNpm": true diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index 185c7a8268e..5c12fc54e6e 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -39,7 +39,8 @@ "install": { "npmSpec": "@openclaw/msteams", "localPath": "extensions/msteams", - "defaultChoice": "npm" + "defaultChoice": "npm", + "minHostVersion": ">=2026.3.14" }, "release": { "publishToNpm": true diff --git a/extensions/nextcloud-talk/package.json b/extensions/nextcloud-talk/package.json index 5e887c6adfd..7c41d99bae3 100644 --- a/extensions/nextcloud-talk/package.json +++ b/extensions/nextcloud-talk/package.json @@ -39,7 +39,8 @@ "install": { "npmSpec": "@openclaw/nextcloud-talk", "localPath": "extensions/nextcloud-talk", - "defaultChoice": "npm" + "defaultChoice": "npm", + "minHostVersion": ">=2026.3.14" }, "release": { "publishToNpm": true diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json index 61f3d663896..105c4a32c65 100644 --- a/extensions/nostr/package.json +++ b/extensions/nostr/package.json @@ -36,7 +36,8 @@ "install": { "npmSpec": "@openclaw/nostr", "localPath": "extensions/nostr", - "defaultChoice": "npm" + "defaultChoice": "npm", + "minHostVersion": ">=2026.3.14" }, "release": { "publishToNpm": true diff --git a/extensions/synology-chat/package.json b/extensions/synology-chat/package.json index d8ff22d6361..71c9a2dc798 100644 --- a/extensions/synology-chat/package.json +++ b/extensions/synology-chat/package.json @@ -23,7 +23,8 @@ "install": { "npmSpec": "@openclaw/synology-chat", "localPath": "extensions/synology-chat", - "defaultChoice": "npm" + "defaultChoice": "npm", + "minHostVersion": ">=2026.3.14" } } } diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index 8b1b3219aaa..a9487b0740a 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -39,7 +39,8 @@ "install": { "npmSpec": "@openclaw/tlon", "localPath": "extensions/tlon", - "defaultChoice": "npm" + "defaultChoice": "npm", + "minHostVersion": ">=2026.3.14" } } } diff --git a/extensions/twitch/package.json b/extensions/twitch/package.json index 6288b6fa2bb..1c572354de0 100644 --- a/extensions/twitch/package.json +++ b/extensions/twitch/package.json @@ -13,6 +13,9 @@ "extensions": [ "./index.ts" ], + "install": { + "minHostVersion": ">=2026.3.14" + }, "channel": { "id": "twitch", "label": "Twitch", diff --git a/extensions/voice-call/package.json b/extensions/voice-call/package.json index 375ab3e80d8..9f414151573 100644 --- a/extensions/voice-call/package.json +++ b/extensions/voice-call/package.json @@ -24,6 +24,9 @@ "extensions": [ "./index.ts" ], + "install": { + "minHostVersion": ">=2026.3.14" + }, "release": { "publishToNpm": true } diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index d19f576417b..b3d3cd85f5c 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -35,7 +35,8 @@ "install": { "npmSpec": "@openclaw/whatsapp", "localPath": "extensions/whatsapp", - "defaultChoice": "npm" + "defaultChoice": "npm", + "minHostVersion": ">=2026.3.14" }, "release": { "publishToNpm": true diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json index 71b649fdc38..9c67d3541e8 100644 --- a/extensions/zalo/package.json +++ b/extensions/zalo/package.json @@ -39,7 +39,8 @@ "install": { "npmSpec": "@openclaw/zalo", "localPath": "extensions/zalo", - "defaultChoice": "npm" + "defaultChoice": "npm", + "minHostVersion": ">=2026.3.14" }, "release": { "publishToNpm": true diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json index 2548ad29075..d93dad63be3 100644 --- a/extensions/zalouser/package.json +++ b/extensions/zalouser/package.json @@ -40,7 +40,8 @@ "install": { "npmSpec": "@openclaw/zalouser", "localPath": "extensions/zalouser", - "defaultChoice": "npm" + "defaultChoice": "npm", + "minHostVersion": ">=2026.3.14" }, "release": { "publishToNpm": true diff --git a/scripts/lib/bundled-extension-manifest.ts b/scripts/lib/bundled-extension-manifest.ts index b82ce3ff10c..673bf442313 100644 --- a/scripts/lib/bundled-extension-manifest.ts +++ b/scripts/lib/bundled-extension-manifest.ts @@ -1,12 +1,16 @@ +import { validateMinHostVersion } from "../../src/plugins/min-host-version.ts"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + export type ExtensionPackageJson = { name?: string; version?: string; dependencies?: Record; optionalDependencies?: Record; openclaw?: { - install?: { - npmSpec?: string; - }; + install?: unknown; }; }; @@ -17,14 +21,25 @@ export function collectBundledExtensionManifestErrors(extensions: BundledExtensi for (const extension of extensions) { const install = extension.packageJson.openclaw?.install; + if (install !== undefined && !isRecord(install)) { + errors.push( + `bundled extension '${extension.id}' manifest invalid | openclaw.install must be an object`, + ); + continue; + } + const hasNpmSpec = isRecord(install) && "npmSpec" in install; if ( - install && + hasNpmSpec && (!install.npmSpec || typeof install.npmSpec !== "string" || !install.npmSpec.trim()) ) { errors.push( `bundled extension '${extension.id}' manifest invalid | openclaw.install.npmSpec must be a non-empty string`, ); } + const minHostVersionError = validateMinHostVersion(install?.minHostVersion); + if (minHostVersionError) { + errors.push(`bundled extension '${extension.id}' manifest invalid | ${minHostVersionError}`); + } } return errors; diff --git a/src/plugins/install-min-host-version-guardrails.test.ts b/src/plugins/install-min-host-version-guardrails.test.ts new file mode 100644 index 00000000000..263449e2ea6 --- /dev/null +++ b/src/plugins/install-min-host-version-guardrails.test.ts @@ -0,0 +1,72 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { isAtLeast, parseSemver } from "../infra/runtime-guard.js"; +import { parseMinHostVersionRequirement } from "./min-host-version.js"; + +const MIN_HOST_VERSION_BASELINE = "2026.3.14"; +const PLUGIN_MANIFEST_PATHS_REQUIRING_MIN_HOST_VERSION = [ + "extensions/bluebubbles/package.json", + "extensions/discord/package.json", + "extensions/feishu/package.json", + "extensions/googlechat/package.json", + "extensions/irc/package.json", + "extensions/line/package.json", + "extensions/matrix/package.json", + "extensions/mattermost/package.json", + "extensions/memory-lancedb/package.json", + "extensions/msteams/package.json", + "extensions/nextcloud-talk/package.json", + "extensions/nostr/package.json", + "extensions/synology-chat/package.json", + "extensions/tlon/package.json", + "extensions/twitch/package.json", + "extensions/voice-call/package.json", + "extensions/whatsapp/package.json", + "extensions/zalo/package.json", + "extensions/zalouser/package.json", +] as const; + +type PackageJsonLike = { + openclaw?: { + install?: { + minHostVersion?: string; + }; + }; +}; + +describe("install minHostVersion guardrails", () => { + it("requires published plugins that depend on new sdk subpaths to declare a host floor", () => { + const baseline = parseSemver(MIN_HOST_VERSION_BASELINE); + expect(baseline).not.toBeNull(); + if (!baseline) { + return; + } + + for (const relativePath of PLUGIN_MANIFEST_PATHS_REQUIRING_MIN_HOST_VERSION) { + const manifest = JSON.parse( + fs.readFileSync(path.resolve(relativePath), "utf-8"), + ) as PackageJsonLike; + const requirement = parseMinHostVersionRequirement( + manifest.openclaw?.install?.minHostVersion, + ); + + expect( + requirement, + `${relativePath} should declare openclaw.install.minHostVersion`, + ).not.toBeNull(); + if (!requirement) { + continue; + } + const minimum = parseSemver(requirement.minimumLabel); + expect(minimum, `${relativePath} should use a parseable semver floor`).not.toBeNull(); + if (!minimum) { + continue; + } + expect( + isAtLeast(minimum, baseline), + `${relativePath} should require at least OpenClaw ${MIN_HOST_VERSION_BASELINE}`, + ).toBe(true); + } + }); +}); diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index 3deae1c99fe..1a8b156f7ee 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -493,6 +493,7 @@ beforeAll(async () => { beforeEach(() => { vi.clearAllMocks(); + vi.unstubAllEnvs(); }); describe("installPluginFromArchive", () => { @@ -775,6 +776,95 @@ describe("installPluginFromDir", () => { expect(manifest.devDependencies?.vitest).toBe("^3.0.0"); }); + it("rejects plugins whose minHostVersion is newer than the current host", async () => { + vi.stubEnv("OPENCLAW_VERSION", "2026.3.13"); + const { pluginDir, extensionsDir } = setupInstallPluginFromDirFixture(); + const packageJsonPath = path.join(pluginDir, "package.json"); + const manifest = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) as { + openclaw?: { install?: Record }; + }; + manifest.openclaw = { + ...manifest.openclaw, + install: { + ...manifest.openclaw?.install, + minHostVersion: ">=2026.3.14", + }, + }; + fs.writeFileSync(packageJsonPath, JSON.stringify(manifest), "utf-8"); + + const result = await installPluginFromDir({ + dirPath: pluginDir, + extensionsDir, + }); + + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.INCOMPATIBLE_HOST_VERSION); + expect(result.error).toContain("requires OpenClaw >=2026.3.14, but this host is 2026.3.13"); + expect(vi.mocked(runCommandWithTimeout)).not.toHaveBeenCalled(); + }); + + it("rejects plugins with invalid minHostVersion metadata", async () => { + const { pluginDir, extensionsDir } = setupInstallPluginFromDirFixture(); + const packageJsonPath = path.join(pluginDir, "package.json"); + const manifest = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) as { + openclaw?: { install?: Record }; + }; + manifest.openclaw = { + ...manifest.openclaw, + install: { + ...manifest.openclaw?.install, + minHostVersion: "2026.3.14", + }, + }; + fs.writeFileSync(packageJsonPath, JSON.stringify(manifest), "utf-8"); + + const result = await installPluginFromDir({ + dirPath: pluginDir, + extensionsDir, + }); + + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.INVALID_MIN_HOST_VERSION); + expect(result.error).toContain("invalid package.json openclaw.install.minHostVersion"); + expect(vi.mocked(runCommandWithTimeout)).not.toHaveBeenCalled(); + }); + + it("reports unknown host versions distinctly for minHostVersion-gated plugins", async () => { + vi.stubEnv("OPENCLAW_VERSION", "unknown"); + const { pluginDir, extensionsDir } = setupInstallPluginFromDirFixture(); + const packageJsonPath = path.join(pluginDir, "package.json"); + const manifest = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) as { + openclaw?: { install?: Record }; + }; + manifest.openclaw = { + ...manifest.openclaw, + install: { + ...manifest.openclaw?.install, + minHostVersion: ">=2026.3.14", + }, + }; + fs.writeFileSync(packageJsonPath, JSON.stringify(manifest), "utf-8"); + + const result = await installPluginFromDir({ + dirPath: pluginDir, + extensionsDir, + }); + + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.UNKNOWN_HOST_VERSION); + expect(result.error).toContain("host version could not be determined"); + expect(vi.mocked(runCommandWithTimeout)).not.toHaveBeenCalled(); + }); + it("uses openclaw.plugin.json id as install key when it differs from package name", async () => { const { pluginDir, extensionsDir } = setupManifestInstallFixture({ manifestId: "memory-cognee", diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 52ae9ebf2e1..1ae80c40a1e 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -31,12 +31,15 @@ import { validateRegistryNpmSpec } from "../infra/npm-registry-spec.js"; import { extensionUsesSkippedScannerPath, isPathInside } from "../security/scan-paths.js"; import * as skillScanner from "../security/skill-scanner.js"; import { CONFIG_DIR, resolveUserPath } from "../utils.js"; +import { resolveRuntimeServiceVersion } from "../version.js"; import { detectBundleManifestFormat, loadBundleManifest } from "./bundle-manifest.js"; import { + getPackageManifestMetadata, loadPluginManifest, resolvePackageExtensionEntries, type PackageManifest as PluginPackageManifest, } from "./manifest.js"; +import { checkMinHostVersion } from "./min-host-version.js"; type PluginInstallLogger = { info?: (message: string) => void; @@ -52,6 +55,9 @@ const MISSING_EXTENSIONS_ERROR = export const PLUGIN_INSTALL_ERROR_CODE = { INVALID_NPM_SPEC: "invalid_npm_spec", + INVALID_MIN_HOST_VERSION: "invalid_min_host_version", + UNKNOWN_HOST_VERSION: "unknown_host_version", + INCOMPATIBLE_HOST_VERSION: "incompatible_host_version", MISSING_OPENCLAW_EXTENSIONS: "missing_openclaw_extensions", EMPTY_OPENCLAW_EXTENSIONS: "empty_openclaw_extensions", NPM_PACKAGE_NOT_FOUND: "npm_package_not_found", @@ -525,6 +531,33 @@ async function installPluginFromPackageDir( ); } + const packageMetadata = getPackageManifestMetadata(manifest); + const minHostVersionCheck = checkMinHostVersion({ + currentVersion: resolveRuntimeServiceVersion(), + minHostVersion: packageMetadata?.install?.minHostVersion, + }); + if (!minHostVersionCheck.ok) { + if (minHostVersionCheck.kind === "invalid") { + return { + ok: false, + error: `invalid package.json openclaw.install.minHostVersion: ${minHostVersionCheck.error}`, + code: PLUGIN_INSTALL_ERROR_CODE.INVALID_MIN_HOST_VERSION, + }; + } + if (minHostVersionCheck.kind === "unknown_host_version") { + return { + ok: false, + error: `plugin "${pluginId}" requires OpenClaw >=${minHostVersionCheck.requirement.minimumLabel}, but this host version could not be determined. Re-run from a released build or set OPENCLAW_VERSION and retry.`, + code: PLUGIN_INSTALL_ERROR_CODE.UNKNOWN_HOST_VERSION, + }; + } + return { + ok: false, + error: `plugin "${pluginId}" requires OpenClaw >=${minHostVersionCheck.requirement.minimumLabel}, but this host is ${minHostVersionCheck.currentVersion}. Upgrade OpenClaw and retry.`, + code: PLUGIN_INSTALL_ERROR_CODE.INCOMPATIBLE_HOST_VERSION, + }; + } + const packageDir = path.resolve(params.packageDir); const forcedScanEntries: string[] = []; for (const entry of extensions) { diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index 14a571c9250..14c9e8d0f60 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -6,6 +6,7 @@ import { clearPluginManifestRegistryCache, loadPluginManifestRegistry, } from "./manifest-registry.js"; +import type { OpenClawPackageManifest } from "./manifest.js"; import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js"; const tempDirs: string[] = []; @@ -37,6 +38,8 @@ function createPluginCandidate(params: { origin: "bundled" | "global" | "workspace" | "config"; format?: "openclaw" | "bundle"; bundleFormat?: "codex" | "claude" | "cursor"; + packageManifest?: OpenClawPackageManifest; + packageDir?: string; }): PluginCandidate { return { idHint: params.idHint, @@ -45,6 +48,8 @@ function createPluginCandidate(params: { origin: params.origin, format: params.format, bundleFormat: params.bundleFormat, + packageManifest: params.packageManifest, + packageDir: params.packageDir, }; } @@ -239,6 +244,99 @@ describe("loadPluginManifestRegistry", () => { ]); }); + it("skips plugins whose minHostVersion is newer than the current host", () => { + const dir = makeTempDir(); + writeManifest(dir, { id: "synology-chat", configSchema: { type: "object" } }); + + const registry = loadPluginManifestRegistry({ + cache: false, + env: { OPENCLAW_VERSION: "2026.3.13" }, + candidates: [ + createPluginCandidate({ + idHint: "synology-chat", + rootDir: dir, + packageDir: dir, + origin: "global", + packageManifest: { + install: { + npmSpec: "@openclaw/synology-chat", + minHostVersion: ">=2026.3.14", + }, + }, + }), + ], + }); + + expect(registry.plugins).toEqual([]); + expect( + registry.diagnostics.some((diag) => + diag.message.includes("plugin requires OpenClaw >=2026.3.14, but this host is 2026.3.13"), + ), + ).toBe(true); + }); + + it("rejects invalid minHostVersion metadata", () => { + const dir = makeTempDir(); + writeManifest(dir, { id: "synology-chat", configSchema: { type: "object" } }); + + const registry = loadPluginManifestRegistry({ + cache: false, + candidates: [ + createPluginCandidate({ + idHint: "synology-chat", + rootDir: dir, + packageDir: dir, + origin: "global", + packageManifest: { + install: { + npmSpec: "@openclaw/synology-chat", + minHostVersion: "2026.3.14", + }, + }, + }), + ], + }); + + expect(registry.plugins).toEqual([]); + expect( + registry.diagnostics.some((diag) => + diag.message.includes("plugin manifest invalid | openclaw.install.minHostVersion must use"), + ), + ).toBe(true); + }); + + it("warns distinctly when host version cannot be determined", () => { + const dir = makeTempDir(); + writeManifest(dir, { id: "synology-chat", configSchema: { type: "object" } }); + + const registry = loadPluginManifestRegistry({ + cache: false, + env: { OPENCLAW_VERSION: "unknown" }, + candidates: [ + createPluginCandidate({ + idHint: "synology-chat", + rootDir: dir, + packageDir: dir, + origin: "global", + packageManifest: { + install: { + npmSpec: "@openclaw/synology-chat", + minHostVersion: ">=2026.3.14", + }, + }, + }), + ], + }); + + expect(registry.plugins).toEqual([]); + expect( + registry.diagnostics.some((diag) => + diag.message.includes("host version could not be determined"), + ), + ).toBe(true); + expect(registry.diagnostics.some((diag) => diag.level === "warn")).toBe(true); + }); + it("reports bundled plugins as the duplicate winner for auto-discovered globals", () => { const bundledDir = makeTempDir(); const globalDir = makeTempDir(); @@ -724,4 +822,50 @@ describe("loadPluginManifestRegistry", () => { fs.realpathSync(second.plugins.find((plugin) => plugin.id === "demo")?.rootDir ?? ""), ).toBe(fs.realpathSync(demoB)); }); + + it("does not reuse cached manifests across host version changes", () => { + const dir = makeTempDir(); + writeManifest(dir, { id: "synology-chat", configSchema: { type: "object" } }); + fs.writeFileSync(path.join(dir, "index.ts"), "export default {}", "utf-8"); + const candidates = [ + createPluginCandidate({ + idHint: "synology-chat", + rootDir: dir, + packageDir: dir, + origin: "global", + packageManifest: { + install: { + npmSpec: "@openclaw/synology-chat", + minHostVersion: ">=2026.3.14", + }, + }, + }), + ]; + + const olderHost = loadPluginManifestRegistry({ + cache: true, + candidates, + env: { + ...process.env, + OPENCLAW_VERSION: "2026.3.13", + }, + }); + const newerHost = loadPluginManifestRegistry({ + cache: true, + candidates, + env: { + ...process.env, + OPENCLAW_VERSION: "2026.3.14", + }, + }); + + expect(olderHost.plugins).toEqual([]); + expect( + olderHost.diagnostics.some((diag) => diag.message.includes("this host is 2026.3.13")), + ).toBe(true); + expect(newerHost.plugins.some((plugin) => plugin.id === "synology-chat")).toBe(true); + expect( + newerHost.diagnostics.some((diag) => diag.message.includes("this host is 2026.3.13")), + ).toBe(false); + }); }); diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index febac61201c..7e771b5c1c4 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -1,10 +1,13 @@ import fs from "node:fs"; +import path from "node:path"; import type { OpenClawConfig } from "../config/config.js"; import { resolveUserPath } from "../utils.js"; +import { resolveRuntimeServiceVersion } from "../version.js"; import { loadBundleManifest } from "./bundle-manifest.js"; import { normalizePluginsConfig, type NormalizedPluginsConfig } from "./config-state.js"; import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js"; import { loadPluginManifest, type PluginManifest } from "./manifest.js"; +import { checkMinHostVersion } from "./min-host-version.js"; import { isPathInside, safeRealpathSync } from "./path-safety.js"; import { resolvePluginCacheInputs } from "./roots.js"; import type { @@ -113,9 +116,10 @@ function buildCacheKey(params: { const workspaceKey = roots.workspace ?? ""; const configExtensionsRoot = roots.global; const bundledRoot = roots.stock ?? ""; + const runtimeServiceVersion = resolveRuntimeServiceVersion(params.env); // The manifest registry only depends on where plugins are discovered from (workspace + load paths). // It does not depend on allow/deny/entries enable-state, so exclude those for higher cache hit rates. - return `${workspaceKey}::${configExtensionsRoot}::${bundledRoot}::${JSON.stringify(loadPaths)}`; + return `${workspaceKey}::${configExtensionsRoot}::${bundledRoot}::${runtimeServiceVersion}::${JSON.stringify(loadPaths)}`; } function safeStatMtimeMs(filePath: string): number | null { @@ -326,6 +330,7 @@ export function loadPluginManifestRegistry( const records: PluginManifestRecord[] = []; const seenIds = new Map(); const realpathCache = new Map(); + const currentHostVersion = resolveRuntimeServiceVersion(env); for (const candidate of candidates) { const rejectHardlinks = candidate.origin !== "bundled"; @@ -347,6 +352,28 @@ export function loadPluginManifestRegistry( continue; } const manifest = manifestRes.manifest; + const minHostVersionCheck = checkMinHostVersion({ + currentVersion: currentHostVersion, + minHostVersion: candidate.packageManifest?.install?.minHostVersion, + }); + if (!minHostVersionCheck.ok) { + const packageManifestSource = path.join( + candidate.packageDir ?? candidate.rootDir, + "package.json", + ); + diagnostics.push({ + level: minHostVersionCheck.kind === "unknown_host_version" ? "warn" : "error", + pluginId: manifest.id, + source: packageManifestSource, + message: + minHostVersionCheck.kind === "invalid" + ? `plugin manifest invalid | ${minHostVersionCheck.error}` + : minHostVersionCheck.kind === "unknown_host_version" + ? `plugin requires OpenClaw >=${minHostVersionCheck.requirement.minimumLabel}, but this host version could not be determined; skipping load` + : `plugin requires OpenClaw >=${minHostVersionCheck.requirement.minimumLabel}, but this host is ${minHostVersionCheck.currentVersion}; skipping load`, + }); + continue; + } if (!isCompatiblePluginIdHint(candidate.idHint, manifest.id)) { diagnostics.push({ diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 6498c8eb876..df6b0b8dd44 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -255,6 +255,7 @@ export type PluginPackageInstall = { npmSpec?: string; localPath?: string; defaultChoice?: "npm" | "local"; + minHostVersion?: string; }; export type OpenClawPackageStartup = { diff --git a/src/plugins/min-host-version.test.ts b/src/plugins/min-host-version.test.ts new file mode 100644 index 00000000000..8ec584209f2 --- /dev/null +++ b/src/plugins/min-host-version.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { + checkMinHostVersion, + MIN_HOST_VERSION_FORMAT, + parseMinHostVersionRequirement, + validateMinHostVersion, +} from "./min-host-version.js"; + +describe("min-host-version", () => { + it("accepts empty metadata", () => { + expect(validateMinHostVersion(undefined)).toBeNull(); + expect(parseMinHostVersionRequirement(undefined)).toBeNull(); + expect(checkMinHostVersion({ currentVersion: "2026.3.14", minHostVersion: undefined })).toEqual( + { + ok: true, + requirement: null, + }, + ); + }); + + it("parses semver floors", () => { + expect(parseMinHostVersionRequirement(">=2026.3.14")).toEqual({ + raw: ">=2026.3.14", + minimumLabel: "2026.3.14", + }); + }); + + it("rejects invalid floor syntax", () => { + expect(validateMinHostVersion("2026.3.14")).toBe(MIN_HOST_VERSION_FORMAT); + expect(validateMinHostVersion(123)).toBe(MIN_HOST_VERSION_FORMAT); + expect(validateMinHostVersion(">=2026.3.14 garbage")).toBe(MIN_HOST_VERSION_FORMAT); + expect( + checkMinHostVersion({ currentVersion: "2026.3.14", minHostVersion: "2026.3.14" }), + ).toEqual({ + ok: false, + kind: "invalid", + error: MIN_HOST_VERSION_FORMAT, + }); + }); + + it("treats non-string host floor metadata as invalid instead of throwing", () => { + expect(checkMinHostVersion({ currentVersion: "2026.3.14", minHostVersion: 123 })).toEqual({ + ok: false, + kind: "invalid", + error: MIN_HOST_VERSION_FORMAT, + }); + }); + + it("reports unknown host versions distinctly", () => { + expect( + checkMinHostVersion({ currentVersion: "unknown", minHostVersion: ">=2026.3.14" }), + ).toEqual({ + ok: false, + kind: "unknown_host_version", + requirement: { + raw: ">=2026.3.14", + minimumLabel: "2026.3.14", + }, + }); + }); + + it("reports incompatible hosts", () => { + expect( + checkMinHostVersion({ currentVersion: "2026.3.13", minHostVersion: ">=2026.3.14" }), + ).toEqual({ + ok: false, + kind: "incompatible", + currentVersion: "2026.3.13", + requirement: { + raw: ">=2026.3.14", + minimumLabel: "2026.3.14", + }, + }); + }); + + it("accepts equal or newer hosts", () => { + expect( + checkMinHostVersion({ currentVersion: "2026.3.14", minHostVersion: ">=2026.3.14" }), + ).toEqual({ + ok: true, + requirement: { + raw: ">=2026.3.14", + minimumLabel: "2026.3.14", + }, + }); + expect( + checkMinHostVersion({ currentVersion: "2026.4.0", minHostVersion: ">=2026.3.14" }), + ).toEqual({ + ok: true, + requirement: { + raw: ">=2026.3.14", + minimumLabel: "2026.3.14", + }, + }); + }); +}); diff --git a/src/plugins/min-host-version.ts b/src/plugins/min-host-version.ts new file mode 100644 index 00000000000..a3a361d5eee --- /dev/null +++ b/src/plugins/min-host-version.ts @@ -0,0 +1,82 @@ +import { isAtLeast, parseSemver } from "../infra/runtime-guard.js"; + +export const MIN_HOST_VERSION_FORMAT = + 'openclaw.install.minHostVersion must use a semver floor in the form ">=x.y.z"'; +const MIN_HOST_VERSION_RE = /^>=(\d+)\.(\d+)\.(\d+)$/; + +export type MinHostVersionRequirement = { + raw: string; + minimumLabel: string; +}; + +export type MinHostVersionCheckResult = + | { ok: true; requirement: MinHostVersionRequirement | null } + | { ok: false; kind: "invalid"; error: string } + | { ok: false; kind: "unknown_host_version"; requirement: MinHostVersionRequirement } + | { + ok: false; + kind: "incompatible"; + requirement: MinHostVersionRequirement; + currentVersion: string; + }; + +export function parseMinHostVersionRequirement(raw: unknown): MinHostVersionRequirement | null { + if (typeof raw !== "string") { + return null; + } + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + const match = trimmed.match(MIN_HOST_VERSION_RE); + if (!match) { + return null; + } + const minimumLabel = `${match[1]}.${match[2]}.${match[3]}`; + if (!parseSemver(minimumLabel)) { + return null; + } + return { + raw: trimmed, + minimumLabel, + }; +} + +export function validateMinHostVersion(raw: unknown): string | null { + if (raw === undefined) { + return null; + } + return parseMinHostVersionRequirement(raw) ? null : MIN_HOST_VERSION_FORMAT; +} + +export function checkMinHostVersion(params: { + currentVersion: string | undefined; + minHostVersion: unknown; +}): MinHostVersionCheckResult { + if (params.minHostVersion === undefined) { + return { ok: true, requirement: null }; + } + const requirement = parseMinHostVersionRequirement(params.minHostVersion); + if (!requirement) { + return { ok: false, kind: "invalid", error: MIN_HOST_VERSION_FORMAT }; + } + const currentVersion = params.currentVersion?.trim() || "unknown"; + const currentSemver = parseSemver(currentVersion); + if (!currentSemver) { + return { + ok: false, + kind: "unknown_host_version", + requirement, + }; + } + const minimumSemver = parseSemver(requirement.minimumLabel)!; + if (!isAtLeast(currentSemver, minimumSemver)) { + return { + ok: false, + kind: "incompatible", + requirement, + currentVersion, + }; + } + return { ok: true, requirement }; +} diff --git a/test/release-check.test.ts b/test/release-check.test.ts index fb518d6afe7..09f19d2babb 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -53,6 +53,53 @@ describe("collectBundledExtensionManifestErrors", () => { "bundled extension 'broken' manifest invalid | openclaw.install.npmSpec must be a non-empty string", ]); }); + + it("flags invalid bundled extension minHostVersion metadata", () => { + expect( + collectBundledExtensionManifestErrors([ + { + id: "broken", + packageJson: { + openclaw: { + install: { npmSpec: "@openclaw/broken", minHostVersion: "2026.3.14" }, + }, + }, + }, + ]), + ).toEqual([ + "bundled extension 'broken' manifest invalid | openclaw.install.minHostVersion must use a semver floor in the form \">=x.y.z\"", + ]); + }); + + it("allows install metadata without npmSpec when only non-publish metadata is present", () => { + expect( + collectBundledExtensionManifestErrors([ + { + id: "irc", + packageJson: { + openclaw: { + install: { minHostVersion: ">=2026.3.14" }, + }, + }, + }, + ]), + ).toEqual([]); + }); + + it("flags non-object install metadata instead of throwing", () => { + expect( + collectBundledExtensionManifestErrors([ + { + id: "broken", + packageJson: { + openclaw: { + install: 123, + }, + }, + }, + ]), + ).toEqual(["bundled extension 'broken' manifest invalid | openclaw.install must be an object"]); + }); }); describe("collectForbiddenPackPaths", () => {