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
This commit is contained in:
Vincent Koc
2026-03-22 09:12:08 -07:00
committed by GitHub
parent 6b7206ed35
commit 3ce5a8366a
29 changed files with 653 additions and 21 deletions

View File

@@ -42,7 +42,8 @@
"install": {
"npmSpec": "@openclaw/bluebubbles",
"localPath": "extensions/bluebubbles",
"defaultChoice": "npm"
"defaultChoice": "npm",
"minHostVersion": ">=2026.3.14"
},
"release": {
"publishToNpm": true

View File

@@ -39,7 +39,8 @@
"install": {
"npmSpec": "@openclaw/discord",
"localPath": "extensions/discord",
"defaultChoice": "npm"
"defaultChoice": "npm",
"minHostVersion": ">=2026.3.14"
},
"bundle": {
"stageRuntimeDependencies": true

View File

@@ -41,7 +41,8 @@
"install": {
"npmSpec": "@openclaw/feishu",
"localPath": "extensions/feishu",
"defaultChoice": "npm"
"defaultChoice": "npm",
"minHostVersion": ">=2026.3.14"
},
"bundle": {
"stageRuntimeDependencies": true

View File

@@ -40,7 +40,8 @@
"install": {
"npmSpec": "@openclaw/googlechat",
"localPath": "extensions/googlechat",
"defaultChoice": "npm"
"defaultChoice": "npm",
"minHostVersion": ">=2026.3.14"
}
}
}

View File

@@ -10,6 +10,9 @@
"extensions": [
"./index.ts"
],
"install": {
"minHostVersion": ">=2026.3.14"
},
"setupEntry": "./setup-entry.ts",
"channel": {
"id": "irc",

View File

@@ -33,7 +33,8 @@
"install": {
"npmSpec": "@openclaw/line",
"localPath": "extensions/line",
"defaultChoice": "npm"
"defaultChoice": "npm",
"minHostVersion": ">=2026.3.14"
}
}
}

View File

@@ -40,7 +40,8 @@
"install": {
"npmSpec": "@openclaw/matrix",
"localPath": "extensions/matrix",
"defaultChoice": "npm"
"defaultChoice": "npm",
"minHostVersion": ">=2026.3.14"
},
"releaseChecks": {
"rootDependencyMirrorAllowlist": [

View File

@@ -35,7 +35,8 @@
"install": {
"npmSpec": "@openclaw/mattermost",
"localPath": "extensions/mattermost",
"defaultChoice": "npm"
"defaultChoice": "npm",
"minHostVersion": ">=2026.3.14"
}
}
}

View File

@@ -15,7 +15,8 @@
"install": {
"npmSpec": "@openclaw/memory-lancedb",
"localPath": "extensions/memory-lancedb",
"defaultChoice": "npm"
"defaultChoice": "npm",
"minHostVersion": ">=2026.3.14"
},
"release": {
"publishToNpm": true

View File

@@ -39,7 +39,8 @@
"install": {
"npmSpec": "@openclaw/msteams",
"localPath": "extensions/msteams",
"defaultChoice": "npm"
"defaultChoice": "npm",
"minHostVersion": ">=2026.3.14"
},
"release": {
"publishToNpm": true

View File

@@ -39,7 +39,8 @@
"install": {
"npmSpec": "@openclaw/nextcloud-talk",
"localPath": "extensions/nextcloud-talk",
"defaultChoice": "npm"
"defaultChoice": "npm",
"minHostVersion": ">=2026.3.14"
},
"release": {
"publishToNpm": true

View File

@@ -36,7 +36,8 @@
"install": {
"npmSpec": "@openclaw/nostr",
"localPath": "extensions/nostr",
"defaultChoice": "npm"
"defaultChoice": "npm",
"minHostVersion": ">=2026.3.14"
},
"release": {
"publishToNpm": true

View File

@@ -23,7 +23,8 @@
"install": {
"npmSpec": "@openclaw/synology-chat",
"localPath": "extensions/synology-chat",
"defaultChoice": "npm"
"defaultChoice": "npm",
"minHostVersion": ">=2026.3.14"
}
}
}

View File

@@ -39,7 +39,8 @@
"install": {
"npmSpec": "@openclaw/tlon",
"localPath": "extensions/tlon",
"defaultChoice": "npm"
"defaultChoice": "npm",
"minHostVersion": ">=2026.3.14"
}
}
}

View File

@@ -13,6 +13,9 @@
"extensions": [
"./index.ts"
],
"install": {
"minHostVersion": ">=2026.3.14"
},
"channel": {
"id": "twitch",
"label": "Twitch",

View File

@@ -24,6 +24,9 @@
"extensions": [
"./index.ts"
],
"install": {
"minHostVersion": ">=2026.3.14"
},
"release": {
"publishToNpm": true
}

View File

@@ -35,7 +35,8 @@
"install": {
"npmSpec": "@openclaw/whatsapp",
"localPath": "extensions/whatsapp",
"defaultChoice": "npm"
"defaultChoice": "npm",
"minHostVersion": ">=2026.3.14"
},
"release": {
"publishToNpm": true

View File

@@ -39,7 +39,8 @@
"install": {
"npmSpec": "@openclaw/zalo",
"localPath": "extensions/zalo",
"defaultChoice": "npm"
"defaultChoice": "npm",
"minHostVersion": ">=2026.3.14"
},
"release": {
"publishToNpm": true

View File

@@ -40,7 +40,8 @@
"install": {
"npmSpec": "@openclaw/zalouser",
"localPath": "extensions/zalouser",
"defaultChoice": "npm"
"defaultChoice": "npm",
"minHostVersion": ">=2026.3.14"
},
"release": {
"publishToNpm": true

View File

@@ -1,12 +1,16 @@
import { validateMinHostVersion } from "../../src/plugins/min-host-version.ts";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export type ExtensionPackageJson = {
name?: string;
version?: string;
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
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;

View File

@@ -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);
}
});
});

View File

@@ -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<string, unknown> };
};
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<string, unknown> };
};
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<string, unknown> };
};
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",

View File

@@ -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) {

View File

@@ -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);
});
});

View File

@@ -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<string, SeenIdEntry>();
const realpathCache = new Map<string, string>();
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({

View File

@@ -255,6 +255,7 @@ export type PluginPackageInstall = {
npmSpec?: string;
localPath?: string;
defaultChoice?: "npm" | "local";
minHostVersion?: string;
};
export type OpenClawPackageStartup = {

View File

@@ -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",
},
});
});
});

View File

@@ -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 };
}

View File

@@ -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", () => {