diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 97a6a6fe366c..cd231ac7d6d1 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -1147,6 +1147,55 @@ describe("update-cli", () => { expect(updateNpmInstalledPlugins).not.toHaveBeenCalled(); }); + it("clears stale npm resolution metadata before post-core downgrade resume", async () => { + const { root } = setupUpdatedRootRefresh(); + readPackageVersion.mockImplementation(async (pkgRoot: string) => + pkgRoot === root ? "0.0.1" : "2026.5.28", + ); + const pluginInstallRecords = { + msteams: { + source: "npm", + spec: "@openclaw/msteams", + installPath: "/tmp/openclaw-msteams-plugin", + version: "1.0.0", + resolvedName: "@openclaw/msteams", + resolvedVersion: "1.0.0", + resolvedSpec: "@openclaw/msteams@1.0.0", + integrity: "sha512-newer", + }, + } as const; + let capturedRecords: unknown; + loadInstalledPluginIndexInstallRecords.mockResolvedValueOnce(pluginInstallRecords); + spawn.mockImplementationOnce((_node, _argv, options) => { + const env = (options as { env?: NodeJS.ProcessEnv }).env; + const recordsPath = env?.OPENCLAW_UPDATE_POST_CORE_INSTALL_RECORDS_PATH; + if (!recordsPath) { + throw new Error("missing post-core install records path"); + } + capturedRecords = JSON.parse(fsSync.readFileSync(recordsPath, "utf-8")); + const child = new EventEmitter() as EventEmitter & { + once: EventEmitter["once"]; + }; + queueMicrotask(() => { + child.emit("exit", 0, null); + }); + return child; + }); + + await updateCommand({ yes: true, restart: false }); + + expect(capturedRecords).toEqual({ + msteams: { + source: "npm", + spec: "@openclaw/msteams", + installPath: "/tmp/openclaw-msteams-plugin", + version: "1.0.0", + resolvedName: "@openclaw/msteams", + integrity: "sha512-newer", + }, + }); + }); + it("respawns into the updated git root before requested channel persistence", async () => { const { entrypoints } = setupUpdatedRootRefresh({ gatewayUpdateImpl: async (root) => @@ -1215,6 +1264,47 @@ describe("update-cli", () => { expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1); }); + it("pins the compatibility host version to the downgraded target during current-process post-core plugin convergence (#87914)", async () => { + const downgradedRoot = createCaseDir("openclaw-downgraded-compat-root"); + setupUpdatedRootRefresh({ + gatewayUpdateImpl: async () => + makeOkUpdateResult({ + mode: "npm", + root: downgradedRoot, + before: { version: "2026.4.14" }, + after: { version: "2026.4.10" }, + }), + }); + // The old core is still installed at the invocation root; the freshly + // installed downgraded target lives at the post-update root. + readPackageVersion.mockImplementation(async (pkgRoot: string) => + pkgRoot === downgradedRoot ? "2026.4.10" : "2026.4.14", + ); + vi.mocked(resolveNpmChannelTag).mockResolvedValue({ tag: "latest", version: "2026.4.10" }); + + delete process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION; + let hostVersionDuringPluginUpdate: string | undefined = "unset"; + updateNpmInstalledPlugins.mockImplementation(async () => { + hostVersionDuringPluginUpdate = process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION; + return { changed: false, config: baseConfig, outcomes: [] }; + }); + + try { + await updateCommand({ yes: true, tag: "2026.4.10", restart: false }); + + expect(spawn).not.toHaveBeenCalled(); + expect(updateNpmInstalledPlugins).toHaveBeenCalledTimes(1); + // Compatibility is evaluated against the downgraded target core, not the + // still-running old VERSION, so incompatible newer plugins are disabled + // before restart. + expect(hostVersionDuringPluginUpdate).toBe("2026.4.10"); + // The override is scoped to the plugin convergence and restored afterward. + expect(process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION).toBeUndefined(); + } finally { + delete process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION; + } + }); + it("fails the update when the fresh process exits non-zero", async () => { setupUpdatedRootRefresh(); spawn.mockImplementationOnce(() => { diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index d4bb5df2885a..13c7f5ea7cd6 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -88,6 +88,7 @@ import { runGatewayUpdate, type UpdateRunResult } from "../../infra/update-runne import { normalizePluginsConfig, resolveEffectiveEnableState } from "../../plugins/config-state.js"; import { loadInstalledPluginIndexInstallRecords, + writePersistedInstalledPluginIndexInstallRecords, withoutPluginInstallRecords, withPluginInstallRecords, } from "../../plugins/installed-plugin-index-records.js"; @@ -2716,6 +2717,35 @@ export function resolvePostCoreUpdateChildStdio( return platform === "win32" ? "pipe" : "inherit"; } +function preparePostCorePluginInstallRecordsForFreshProcess(params: { + records: Record; + targetVersion: string | null; +}): Record { + if (!params.targetVersion) { + return params.records; + } + const runtimeComparison = compareSemverStrings(VERSION, params.targetVersion); + if (runtimeComparison === null || runtimeComparison <= 0) { + return params.records; + } + let changed = false; + const next: Record = {}; + for (const [pluginId, record] of Object.entries(params.records)) { + const installedVersion = record.resolvedVersion ?? record.version; + const comparison = installedVersion + ? compareSemverStrings(installedVersion, params.targetVersion) + : null; + if (record.source !== "npm" || comparison === null || comparison <= 0) { + next[pluginId] = record; + continue; + } + const { resolvedSpec: _resolvedSpec, resolvedVersion: _resolvedVersion, ...rest } = record; + next[pluginId] = rest; + changed = true; + } + return changed ? next : params.records; +} + async function continuePostCoreUpdateInFreshProcess(params: { root: string; channel: "stable" | "beta" | "dev"; @@ -2750,8 +2780,16 @@ async function continuePostCoreUpdateInFreshProcess(params: { const sourceConfigPath = path.join(resultDir, "source-config.json"); const postCoreHostVersion = await readPackageVersion(params.root); + const pluginInstallRecords = preparePostCorePluginInstallRecordsForFreshProcess({ + records: params.pluginInstallRecords, + targetVersion: postCoreHostVersion, + }); + try { - await writePostCorePluginInstallRecordsFile(installRecordsPath, params.pluginInstallRecords); + if (pluginInstallRecords && pluginInstallRecords !== params.pluginInstallRecords) { + await writePersistedInstalledPluginIndexInstallRecords(pluginInstallRecords); + } + await writePostCorePluginInstallRecordsFile(installRecordsPath, pluginInstallRecords); await writePostCoreSourceConfigFile(sourceConfigPath, params.preUpdateConfig); const childStdio = resolvePostCoreUpdateChildStdio(); const child = spawn(params.nodeRunner ?? resolveNodeRunner(), argv, { @@ -3520,16 +3558,40 @@ async function updateCommandInternal(opts: UpdateCommandOptions): Promise : undefined, ); postUpdateConfigSnapshot = restoredConfig.snapshot; - postCorePluginUpdate = await runPostCorePluginUpdate({ - root: postUpdateRoot, - channel, - configSnapshot: postUpdateConfigSnapshot, - configChanged: restoredConfig.changed, - restoredAuthoredChannels: restoredConfig.authoredChannels, - opts, - timeoutMs: updateStepTimeoutMs, - pluginInstallRecords: preUpdatePluginInstallRecords, - }); + // Current-process post-core convergence still reports the pre-update + // VERSION. During downgrades, pin compatibility checks to the installed + // target so incompatible newer plugins are disabled before restart. + const postUpdateInstalledVersion = await readPackageVersion(postUpdateRoot); + const versionComparison = + postUpdateInstalledVersion && VERSION + ? compareSemverStrings(VERSION, postUpdateInstalledVersion) + : null; + const compatibilityDowngradeTarget = + versionComparison != null && versionComparison > 0 ? postUpdateInstalledVersion : null; + const previousCompatibilityHostVersion = process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION; + if (compatibilityDowngradeTarget) { + process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION = compatibilityDowngradeTarget; + } + try { + postCorePluginUpdate = await runPostCorePluginUpdate({ + root: postUpdateRoot, + channel, + configSnapshot: postUpdateConfigSnapshot, + configChanged: restoredConfig.changed, + restoredAuthoredChannels: restoredConfig.authoredChannels, + opts, + timeoutMs: updateStepTimeoutMs, + pluginInstallRecords: preUpdatePluginInstallRecords, + }); + } finally { + if (compatibilityDowngradeTarget) { + if (previousCompatibilityHostVersion === undefined) { + delete process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION; + } else { + process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION = previousCompatibilityHostVersion; + } + } + } } const resultWithPostUpdate: UpdateRunResult = postCorePluginUpdate diff --git a/src/plugins/install.npm-spec.test.ts b/src/plugins/install.npm-spec.test.ts index b98fdaa7b3fe..a8d87bc4ed4f 100644 --- a/src/plugins/install.npm-spec.test.ts +++ b/src/plugins/install.npm-spec.test.ts @@ -1294,6 +1294,141 @@ describe("installPluginFromNpmSpec", () => { ).toBe(true); }); + it("resolves incompatible prerelease tags to a compatible prerelease version", async () => { + const stateDir = suiteTempRootTracker.makeTempDir(); + const npmRoot = path.join(stateDir, "npm"); + const warnings: string[] = []; + vi.stubEnv("OPENCLAW_COMPATIBILITY_HOST_VERSION", "2026.5.28-beta.3"); + + mockNpmViewAndInstallMany([ + { + spec: "@openclaw/msteams@beta", + packageName: "@openclaw/msteams", + version: "2026.5.28-beta.4", + pluginId: "msteams", + npmRoot, + versions: ["2026.5.28-beta.3", "2026.5.28-beta.4"], + openclaw: { + extensions: ["./dist/index.js"], + compat: { pluginApi: ">=2026.5.28-beta.4" }, + }, + }, + { + spec: "@openclaw/msteams@2026.5.28-beta.3", + packageName: "@openclaw/msteams", + version: "2026.5.28-beta.3", + pluginId: "msteams", + npmRoot, + expectedDependencySpec: "2026.5.28-beta.3", + openclaw: { + extensions: ["./dist/index.js"], + compat: { pluginApi: ">=2026.5.28-beta.3" }, + }, + }, + ]); + + const result = await installPluginFromNpmSpec({ + spec: "@openclaw/msteams@beta", + npmDir: npmRoot, + mode: "update", + logger: { info: () => {}, warn: (message) => warnings.push(message) }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.npmResolution?.resolvedSpec).toBe("@openclaw/msteams@2026.5.28-beta.3"); + expect(result.npmResolution?.version).toBe("2026.5.28-beta.3"); + expect(warnings.join("\n")).toContain( + "using newest compatible @openclaw/msteams@2026.5.28-beta.3", + ); + const npmProjectRoot = resolvePluginNpmProjectDir({ + npmDir: npmRoot, + packageName: "@openclaw/msteams", + }); + const managedManifest = JSON.parse( + fs.readFileSync(path.join(npmProjectRoot, "package.json"), "utf8"), + ) as { dependencies?: Record }; + expect(managedManifest.dependencies?.["@openclaw/msteams"]).toBe("2026.5.28-beta.3"); + }); + + it("does not resolve explicit prerelease tags to stable compatible versions", async () => { + const stateDir = suiteTempRootTracker.makeTempDir(); + const npmRoot = path.join(stateDir, "npm"); + vi.stubEnv("OPENCLAW_COMPATIBILITY_HOST_VERSION", "2026.5.28-beta.3"); + + mockNpmViewAndInstallMany([ + { + spec: "@openclaw/msteams@beta", + packageName: "@openclaw/msteams", + version: "2026.5.28-beta.4", + pluginId: "msteams", + npmRoot, + versions: ["2026.5.27", "2026.5.28-beta.4"], + openclaw: { + extensions: ["./dist/index.js"], + compat: { pluginApi: ">=2026.5.28-beta.4" }, + }, + }, + ]); + + const result = await installPluginFromNpmSpec({ + spec: "@openclaw/msteams@beta", + npmDir: npmRoot, + mode: "update", + logger: { info: () => {}, warn: () => {} }, + }); + + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.INCOMPATIBLE_PLUGIN_API); + expect(result.error).toContain("requires plugin API >=2026.5.28-beta.4"); + expect( + runCommandWithTimeoutMock.mock.calls.some(([argv]) => isManagedNpmInstallCommand(argv)), + ).toBe(false); + }); + + it("does not resolve explicit prerelease tags to a different prerelease channel", async () => { + const stateDir = suiteTempRootTracker.makeTempDir(); + const npmRoot = path.join(stateDir, "npm"); + vi.stubEnv("OPENCLAW_COMPATIBILITY_HOST_VERSION", "2026.5.28-beta.3"); + + mockNpmViewAndInstallMany([ + { + spec: "@openclaw/msteams@beta", + packageName: "@openclaw/msteams", + version: "2026.5.28-beta.4", + pluginId: "msteams", + npmRoot, + versions: ["2026.5.28-alpha.10", "2026.5.28-beta.4"], + openclaw: { + extensions: ["./dist/index.js"], + compat: { pluginApi: ">=2026.5.28-beta.4" }, + }, + }, + ]); + + const result = await installPluginFromNpmSpec({ + spec: "@openclaw/msteams@beta", + npmDir: npmRoot, + mode: "update", + logger: { info: () => {}, warn: () => {} }, + }); + + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.INCOMPATIBLE_PLUGIN_API); + expect(result.error).toContain("requires plugin API >=2026.5.28-beta.4"); + expect( + runCommandWithTimeoutMock.mock.calls.some(([argv]) => isManagedNpmInstallCommand(argv)), + ).toBe(false); + }); + it.runIf(process.platform !== "win32")( "repairs root openclaw materialized by npm peer handling", async () => { diff --git a/src/plugins/install.ts b/src/plugins/install.ts index bd1101de54db..40396ea69aaa 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -395,6 +395,29 @@ function shouldResolveLatestCompatibleNpmVersion(spec: ParsedRegistryNpmSpec): b ); } +function shouldResolveCompatiblePrereleaseNpmVersion(params: { + spec: ParsedRegistryNpmSpec; + currentVersion: string; +}): boolean { + if (!isPrereleaseSemverVersion(params.currentVersion)) { + return false; + } + if (params.spec.selectorKind === "none") { + return true; + } + return ( + params.spec.selectorKind === "tag" && (params.spec.selector ?? "").toLowerCase() !== "latest" + ); +} + +function resolvePrereleaseChannel(version: string): string | null { + if (!isPrereleaseSemverVersion(version)) { + return null; + } + const match = /^\s*v?\d+\.\d+\.\d+-([0-9A-Za-z]+)(?:[.-]|$)/.exec(version); + return match?.[1]?.toLowerCase() ?? null; +} + function canResolveAroundCompatibilityError(error: PluginInstallFailureResult): boolean { return ( error.code === PLUGIN_INSTALL_ERROR_CODE.INCOMPATIBLE_HOST_VERSION || @@ -423,10 +446,18 @@ async function resolveLatestCompatibleNpmResolution(params: { timeoutMs: number; logger: PluginInstallLogger; }): Promise { - if ( - !shouldResolveLatestCompatibleNpmVersion(params.parsedSpec) || - !params.currentResolution.version - ) { + if (!params.currentResolution.version) { + return null; + } + const currentVersion = params.currentResolution.version; + const allowPrereleaseCandidates = shouldResolveCompatiblePrereleaseNpmVersion({ + spec: params.parsedSpec, + currentVersion, + }); + const prereleaseChannel = allowPrereleaseCandidates + ? resolvePrereleaseChannel(currentVersion) + : null; + if (!shouldResolveLatestCompatibleNpmVersion(params.parsedSpec) && !allowPrereleaseCandidates) { return null; } @@ -438,9 +469,12 @@ async function resolveLatestCompatibleNpmResolution(params: { return null; } - const currentVersion = params.currentResolution.version; const candidates = versions - .filter((version) => !isPrereleaseSemverVersion(version)) + .filter((version) => + allowPrereleaseCandidates + ? resolvePrereleaseChannel(version) === prereleaseChannel + : !isPrereleaseSemverVersion(version), + ) .filter((version) => compareNpmSemver(version, currentVersion) < 0) .toSorted(compareNpmSemver) .toReversed(); diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 7cde5dc85ac4..92d077b96f08 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -319,6 +319,7 @@ function mockNpmViewMetadata(params: { version: string; integrity?: string; shasum?: string; + openclaw?: Record; }) { runCommandWithTimeoutMock.mockResolvedValueOnce({ code: 0, @@ -327,6 +328,7 @@ function mockNpmViewMetadata(params: { version: params.version, ...(params.integrity ? { "dist.integrity": params.integrity } : {}), ...(params.shasum ? { "dist.shasum": params.shasum } : {}), + ...(params.openclaw ? { openclaw: params.openclaw } : {}), }), stderr: "", }); @@ -805,6 +807,126 @@ describe("updateNpmInstalledPlugins", () => { ]); }); + it("does not skip unchanged npm plugins when package metadata requires a newer plugin API", async () => { + vi.stubEnv("OPENCLAW_COMPATIBILITY_HOST_VERSION", "2026.5.28-beta.3"); + const installPath = createInstalledPackageDir({ + name: "@openclaw/msteams", + version: "2026.5.28-beta.4", + }); + mockNpmViewMetadata({ + name: "@openclaw/msteams", + version: "2026.5.28-beta.4", + integrity: "sha512-newer", + shasum: "newer", + openclaw: { + extensions: ["./dist/index.js"], + compat: { pluginApi: ">=2026.5.28-beta.4" }, + }, + }); + installPluginFromNpmSpecMock.mockResolvedValue( + createSuccessfulNpmUpdateResult({ + pluginId: "msteams", + targetDir: installPath, + version: "2026.5.28-beta.3", + npmResolution: { + name: "@openclaw/msteams", + version: "2026.5.28-beta.3", + resolvedSpec: "@openclaw/msteams@2026.5.28-beta.3", + }, + }), + ); + + const result = await updateNpmInstalledPlugins({ + config: createNpmInstallConfig({ + pluginId: "msteams", + spec: "@openclaw/msteams", + installPath, + resolvedName: "@openclaw/msteams", + resolvedVersion: "2026.5.28-beta.4", + resolvedSpec: "@openclaw/msteams@2026.5.28-beta.4", + integrity: "sha512-newer", + shasum: "newer", + }), + pluginIds: ["msteams"], + }); + + expect(npmInstallCall()?.spec).toBe("@openclaw/msteams"); + expect(npmInstallCall()?.mode).toBe("update"); + expect(npmInstallCall()?.expectedPluginId).toBe("msteams"); + expect(result.changed).toBe(true); + expectRecordFields(result.config.plugins?.installs?.msteams, { + source: "npm", + version: "2026.5.28-beta.3", + resolvedName: "@openclaw/msteams", + resolvedVersion: "2026.5.28-beta.3", + resolvedSpec: "@openclaw/msteams@2026.5.28-beta.3", + }); + expect(result.outcomes).toEqual([ + { + pluginId: "msteams", + status: "updated", + currentVersion: "2026.5.28-beta.4", + nextVersion: "2026.5.28-beta.3", + message: "Updated msteams: 2026.5.28-beta.4 -> 2026.5.28-beta.3.", + }, + ]); + }); + + it("does not skip unchanged npm plugins when package metadata requires a newer host", async () => { + vi.stubEnv("OPENCLAW_COMPATIBILITY_HOST_VERSION", "2026.5.28-beta.3"); + const installPath = createInstalledPackageDir({ + name: "@openclaw/msteams", + version: "2026.5.28-beta.4", + }); + mockNpmViewMetadata({ + name: "@openclaw/msteams", + version: "2026.5.28-beta.4", + integrity: "sha512-newer", + shasum: "newer", + openclaw: { + extensions: ["./dist/index.js"], + install: { minHostVersion: ">=2026.5.28-beta.4" }, + }, + }); + installPluginFromNpmSpecMock.mockResolvedValue( + createSuccessfulNpmUpdateResult({ + pluginId: "msteams", + targetDir: installPath, + version: "2026.5.28-beta.3", + npmResolution: { + name: "@openclaw/msteams", + version: "2026.5.28-beta.3", + resolvedSpec: "@openclaw/msteams@2026.5.28-beta.3", + }, + }), + ); + + const result = await updateNpmInstalledPlugins({ + config: createNpmInstallConfig({ + pluginId: "msteams", + spec: "@openclaw/msteams", + installPath, + resolvedName: "@openclaw/msteams", + resolvedVersion: "2026.5.28-beta.4", + resolvedSpec: "@openclaw/msteams@2026.5.28-beta.4", + integrity: "sha512-newer", + shasum: "newer", + }), + pluginIds: ["msteams"], + }); + + expect(npmInstallCall()?.spec).toBe("@openclaw/msteams"); + expect(npmInstallCall()?.mode).toBe("update"); + expect(result.changed).toBe(true); + expectRecordFields(result.config.plugins?.installs?.msteams, { + source: "npm", + version: "2026.5.28-beta.3", + resolvedName: "@openclaw/msteams", + resolvedVersion: "2026.5.28-beta.3", + resolvedSpec: "@openclaw/msteams@2026.5.28-beta.3", + }); + }); + it("repairs missing openclaw peer links before skipping unchanged npm plugins", async () => { const installPath = createInstalledPackageDir({ name: "@openclaw/codex", diff --git a/src/plugins/update.ts b/src/plugins/update.ts index 923cdfe4f2cf..c61c10effe17 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -1,7 +1,9 @@ import path from "node:path"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import { parseClawHubPluginSpec } from "../infra/clawhub-spec.js"; +import { satisfiesPluginApiRange } from "../infra/clawhub.js"; import type { NpmSpecResolution } from "../infra/install-source-utils.js"; import { resolveNpmSpecMetadata } from "../infra/install-source-utils.js"; import { @@ -19,6 +21,7 @@ import { import { compareComparableSemver, parseComparableSemver } from "../infra/semver-compare.js"; import type { UpdateChannel } from "../infra/update-channels.js"; import { resolveUserPath } from "../utils.js"; +import { resolveCompatibilityHostVersion } from "../version.js"; import { resolveBundledPluginSources } from "./bundled-sources.js"; import { buildClawHubPluginInstallRecordFields } from "./clawhub-install-records.js"; import { CLAWHUB_INSTALL_ERROR_CODE, installPluginFromClawHub } from "./clawhub.js"; @@ -44,6 +47,7 @@ import { } from "./install.js"; import { buildNpmResolutionInstallFields, recordPluginInstall } from "./installs.js"; import { installPluginFromMarketplace } from "./marketplace.js"; +import { checkMinHostVersion } from "./min-host-version.js"; import { resolveTrustedSourceLinkedOfficialClawHubSpec, resolveTrustedSourceLinkedOfficialNpmSpec, @@ -52,6 +56,7 @@ import { getOfficialExternalPluginCatalogEntry, resolveOfficialExternalPluginInstall, } from "./official-external-plugin-catalog.js"; +import { resolvePackagePluginApiRange } from "./package-compat.js"; import { linkOpenClawPeerDependencies } from "./plugin-peer-link.js"; import { defaultSlotIdForKey } from "./slots.js"; @@ -214,6 +219,27 @@ function shouldBypassTrustedOfficialUnchangedNpmCheck(params: { ); } +function isNpmMetadataCompatibleWithCurrentHost(metadata: NpmSpecResolution): boolean { + const hostVersion = resolveCompatibilityHostVersion(); + const installMetadata = metadata.packageOpenClaw?.install; + const minHostVersionCheck = checkMinHostVersion({ + currentVersion: hostVersion, + minHostVersion: isRecord(installMetadata) ? installMetadata.minHostVersion : undefined, + }); + if (!minHostVersionCheck.ok) { + return false; + } + const pluginApiRangeCheck = resolvePackagePluginApiRange(metadata.packageOpenClaw); + if (!pluginApiRangeCheck.ok) { + return false; + } + const pluginApiRange = pluginApiRangeCheck.range; + if (!pluginApiRange) { + return true; + } + return satisfiesPluginApiRange(hostVersion, pluginApiRange); +} + function isBundledVersionNewer(bundledVersion: string, installedVersion: string): boolean { const releaseCmp = compareOpenClawReleaseVersions(bundledVersion, installedVersion); if (releaseCmp !== null) { @@ -1195,6 +1221,7 @@ export async function updateNpmInstalledPlugins(params: { spec: effectiveSpec!, trustedSourceLinkedOfficialInstall, }) && + isNpmMetadataCompatibleWithCurrentHost(metadataResult.metadata) && !installedPackageNeedsOpenClawPeerLinkRepair(installPath) && shouldSkipUnchangedNpmInstall({ currentVersion,