mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-23 07:51:33 +00:00
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:
@@ -42,7 +42,8 @@
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/bluebubbles",
|
||||
"localPath": "extensions/bluebubbles",
|
||||
"defaultChoice": "npm"
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.3.14"
|
||||
},
|
||||
"release": {
|
||||
"publishToNpm": true
|
||||
|
||||
@@ -39,7 +39,8 @@
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/discord",
|
||||
"localPath": "extensions/discord",
|
||||
"defaultChoice": "npm"
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.3.14"
|
||||
},
|
||||
"bundle": {
|
||||
"stageRuntimeDependencies": true
|
||||
|
||||
@@ -41,7 +41,8 @@
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/feishu",
|
||||
"localPath": "extensions/feishu",
|
||||
"defaultChoice": "npm"
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.3.14"
|
||||
},
|
||||
"bundle": {
|
||||
"stageRuntimeDependencies": true
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/googlechat",
|
||||
"localPath": "extensions/googlechat",
|
||||
"defaultChoice": "npm"
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.3.14"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"install": {
|
||||
"minHostVersion": ">=2026.3.14"
|
||||
},
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"channel": {
|
||||
"id": "irc",
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/line",
|
||||
"localPath": "extensions/line",
|
||||
"defaultChoice": "npm"
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.3.14"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/matrix",
|
||||
"localPath": "extensions/matrix",
|
||||
"defaultChoice": "npm"
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.3.14"
|
||||
},
|
||||
"releaseChecks": {
|
||||
"rootDependencyMirrorAllowlist": [
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/mattermost",
|
||||
"localPath": "extensions/mattermost",
|
||||
"defaultChoice": "npm"
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.3.14"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/memory-lancedb",
|
||||
"localPath": "extensions/memory-lancedb",
|
||||
"defaultChoice": "npm"
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.3.14"
|
||||
},
|
||||
"release": {
|
||||
"publishToNpm": true
|
||||
|
||||
@@ -39,7 +39,8 @@
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/msteams",
|
||||
"localPath": "extensions/msteams",
|
||||
"defaultChoice": "npm"
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.3.14"
|
||||
},
|
||||
"release": {
|
||||
"publishToNpm": true
|
||||
|
||||
@@ -39,7 +39,8 @@
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/nextcloud-talk",
|
||||
"localPath": "extensions/nextcloud-talk",
|
||||
"defaultChoice": "npm"
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.3.14"
|
||||
},
|
||||
"release": {
|
||||
"publishToNpm": true
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/nostr",
|
||||
"localPath": "extensions/nostr",
|
||||
"defaultChoice": "npm"
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.3.14"
|
||||
},
|
||||
"release": {
|
||||
"publishToNpm": true
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/synology-chat",
|
||||
"localPath": "extensions/synology-chat",
|
||||
"defaultChoice": "npm"
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.3.14"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,8 @@
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/tlon",
|
||||
"localPath": "extensions/tlon",
|
||||
"defaultChoice": "npm"
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.3.14"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"install": {
|
||||
"minHostVersion": ">=2026.3.14"
|
||||
},
|
||||
"channel": {
|
||||
"id": "twitch",
|
||||
"label": "Twitch",
|
||||
|
||||
@@ -24,6 +24,9 @@
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"install": {
|
||||
"minHostVersion": ">=2026.3.14"
|
||||
},
|
||||
"release": {
|
||||
"publishToNpm": true
|
||||
}
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/whatsapp",
|
||||
"localPath": "extensions/whatsapp",
|
||||
"defaultChoice": "npm"
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.3.14"
|
||||
},
|
||||
"release": {
|
||||
"publishToNpm": true
|
||||
|
||||
@@ -39,7 +39,8 @@
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/zalo",
|
||||
"localPath": "extensions/zalo",
|
||||
"defaultChoice": "npm"
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.3.14"
|
||||
},
|
||||
"release": {
|
||||
"publishToNpm": true
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/zalouser",
|
||||
"localPath": "extensions/zalouser",
|
||||
"defaultChoice": "npm"
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.3.14"
|
||||
},
|
||||
"release": {
|
||||
"publishToNpm": true
|
||||
|
||||
@@ -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;
|
||||
|
||||
72
src/plugins/install-min-host-version-guardrails.test.ts
Normal file
72
src/plugins/install-min-host-version-guardrails.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -255,6 +255,7 @@ export type PluginPackageInstall = {
|
||||
npmSpec?: string;
|
||||
localPath?: string;
|
||||
defaultChoice?: "npm" | "local";
|
||||
minHostVersion?: string;
|
||||
};
|
||||
|
||||
export type OpenClawPackageStartup = {
|
||||
|
||||
96
src/plugins/min-host-version.test.ts
Normal file
96
src/plugins/min-host-version.test.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
82
src/plugins/min-host-version.ts
Normal file
82
src/plugins/min-host-version.ts
Normal 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 };
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user