From 4491232874d836215facff93d48a2c150ac2c26b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 28 May 2026 23:20:32 +0100 Subject: [PATCH] fix: resolve compatible npm plugin versions * fix: resolve compatible npm plugin versions * fix: satisfy plugin install lint * fix: refresh plugin install tests on latest main --- docs/cli/plugins.md | 2 + docs/tools/plugin.md | 7 + src/plugins/install.npm-spec.e2e.test.ts | 71 ++++++++++- src/plugins/install.npm-spec.test.ts | 132 +++++++++++++++---- src/plugins/install.ts | 156 ++++++++++++++++++++--- 5 files changed, 323 insertions(+), 45 deletions(-) diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 32bc36f3fc00..ce582e9e12ae 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -177,6 +177,8 @@ is available, then fall back to `latest`. Bare specs and `@latest` stay on the stable track. OpenClaw date-stamped correction versions such as `2026.5.3-1` are stable releases for this check. If npm resolves either of those to a prerelease, OpenClaw stops and asks you to opt in explicitly with a prerelease tag such as `@beta`/`@rc` or an exact prerelease version such as `@1.2.3-beta.4`. + For npm installs without an exact version (`npm:` or `npm:@latest`), OpenClaw checks the resolved package metadata before install. If the latest stable package requires a newer OpenClaw plugin API or minimum host version, OpenClaw inspects older stable versions and installs the newest compatible release instead. Exact versions and explicit dist-tags such as `@beta` remain strict: if the selected package is incompatible, the command fails and asks you to upgrade OpenClaw or choose a compatible version. + If a bare install spec matches an official plugin id (for example `diffs`), OpenClaw installs the catalog entry directly. To install an npm package with the same name, use an explicit scoped spec (for example `@scope/diffs`). diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 819e64355bce..c0f23c0dd27f 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -136,6 +136,13 @@ bundled copy. Use `clawhub:`, `npm:`, `git:`, or `npm-pack:` when you need deterministic source selection. See [`openclaw plugins`](/cli/plugins#install) for the full command contract. +For npm installs, unpinned package specs and `@latest` choose the newest stable +package that advertises compatibility with this OpenClaw build. If npm's +current latest release declares a newer `openclaw.compat.pluginApi` or +`openclaw.install.minHostVersion`, OpenClaw scans older stable package versions +and installs the newest one that fits. Exact versions and explicit channel tags +such as `@beta` stay pinned to the selected package and fail when incompatible. + ### Configure plugin policy The common plugin config shape is: diff --git a/src/plugins/install.npm-spec.e2e.test.ts b/src/plugins/install.npm-spec.e2e.test.ts index 27cee8cc060b..b543d1583ea9 100644 --- a/src/plugins/install.npm-spec.e2e.test.ts +++ b/src/plugins/install.npm-spec.e2e.test.ts @@ -13,6 +13,7 @@ type PackedVersion = { archive: Buffer; dependencies?: Record; integrity: string; + openclaw?: Record; optionalDependencies?: Record; peerDependencies?: Record; peerDependenciesMeta?: Record; @@ -56,6 +57,7 @@ async function packPlugin(params: { dependencies?: Record; packageName: string; optionalDependencies?: Record; + openclaw?: Record; peerDependencies?: Record; peerDependenciesMeta?: Record; pluginId: string; @@ -78,7 +80,7 @@ async function packPlugin(params: { name: params.packageName, version: params.version, type: "module", - openclaw: { extensions: ["./dist/index.js"] }, + openclaw: params.openclaw ?? { extensions: ["./dist/index.js"] }, ...(params.dependencies ? { dependencies: params.dependencies } : {}), ...(params.optionalDependencies ? { optionalDependencies: params.optionalDependencies } @@ -129,6 +131,7 @@ async function packPlugin(params: { archive, ...(params.dependencies ? { dependencies: params.dependencies } : {}), integrity: `sha512-${crypto.createHash("sha512").update(archive).digest("base64")}`, + ...(params.openclaw ? { openclaw: params.openclaw } : {}), ...(params.optionalDependencies ? { optionalDependencies: params.optionalDependencies } : {}), ...(params.peerDependencies ? { peerDependencies: params.peerDependencies } : {}), ...(peerDependenciesMeta ? { peerDependenciesMeta } : {}), @@ -172,6 +175,7 @@ async function startStaticRegistry( { name: pkg.packageName, version, + ...(entry.openclaw ? { openclaw: entry.openclaw } : {}), ...(entry.dependencies ? { dependencies: entry.dependencies } : {}), ...(entry.optionalDependencies ? { optionalDependencies: entry.optionalDependencies } @@ -255,6 +259,7 @@ async function startMutableRegistry(params: { { name: params.packageName, version, + ...(entry.openclaw ? { openclaw: entry.openclaw } : {}), ...(entry.peerDependencies ? { peerDependencies: entry.peerDependencies } : {}), ...(entry.peerDependenciesMeta ? { peerDependenciesMeta: entry.peerDependenciesMeta } @@ -297,6 +302,70 @@ async function startMutableRegistry(params: { } describe("installPluginFromNpmSpec e2e", () => { + it("installs the newest compatible stable package when npm latest requires a newer plugin API", async () => { + const rootDir = await makeTempDir("npm-plugin-compatible-version-e2e"); + const npmRoot = path.join(rootDir, "managed-npm"); + const packageName = `compatible-plugin-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`; + const compatibleOpenClaw = { + extensions: ["./dist/index.js"], + install: { minHostVersion: ">=2026.4.25" }, + compat: { pluginApi: ">=2026.5.10-beta.1" }, + }; + const incompatibleOpenClaw = { + extensions: ["./dist/index.js"], + install: { minHostVersion: ">=2026.4.25" }, + compat: { pluginApi: ">=2026.5.27" }, + }; + const versions = [ + await packPlugin({ + packageName, + pluginId: packageName, + version: "2026.5.26", + rootDir, + openclaw: compatibleOpenClaw, + }), + await packPlugin({ + packageName, + pluginId: packageName, + version: "2026.5.27", + rootDir, + openclaw: incompatibleOpenClaw, + }), + ]; + const registry = await startStaticRegistry([{ packageName, latest: "2026.5.27", versions }]); + process.env.NPM_CONFIG_REGISTRY = registry; + process.env.npm_config_registry = registry; + const previousHostVersion = process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION; + process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION = "2026.5.10-beta.1"; + const warnings: string[] = []; + + try { + const result = await installPluginFromNpmSpec({ + spec: packageName, + npmDir: npmRoot, + logger: { warn: (message) => warnings.push(message) }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.npmResolution?.version).toBe("2026.5.26"); + expect(result.npmResolution?.resolvedSpec).toBe(`${packageName}@2026.5.26`); + expect(warnings.join("\n")).toContain(`using newest compatible ${packageName}@2026.5.26`); + const installedPackageJson = JSON.parse( + await fs.readFile(path.join(npmRoot, "node_modules", packageName, "package.json"), "utf8"), + ) as { version?: string }; + expect(installedPackageJson.version).toBe("2026.5.26"); + } finally { + if (previousHostVersion === undefined) { + delete process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION; + } else { + process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION = previousHostVersion; + } + } + }); + it("scrubs root openclaw materialized by required npm peers", async () => { const rootDir = await makeTempDir("npm-plugin-required-peer-e2e"); const npmRoot = path.join(rootDir, "managed-npm"); diff --git a/src/plugins/install.npm-spec.test.ts b/src/plugins/install.npm-spec.test.ts index 24f0fe25291a..2511457f28e4 100644 --- a/src/plugins/install.npm-spec.test.ts +++ b/src/plugins/install.npm-spec.test.ts @@ -923,13 +923,13 @@ describe("installPluginFromNpmSpec", () => { expect(managedManifest.dependencies?.["@openclaw/codex"]).toBeUndefined(); }); - it("rejects npm plugins whose package compatibility requires a newer host", async () => { + it("rejects exact npm plugins whose package compatibility requires a newer host", async () => { const stateDir = suiteTempRootTracker.makeTempDir(); const npmRoot = path.join(stateDir, "npm"); vi.stubEnv("OPENCLAW_COMPATIBILITY_HOST_VERSION", "2026.5.10-beta.1"); mockNpmViewAndInstall({ - spec: "@openclaw/whatsapp", + spec: "@openclaw/whatsapp@2026.5.27", packageName: "@openclaw/whatsapp", version: "2026.5.27", pluginId: "whatsapp", @@ -943,7 +943,7 @@ describe("installPluginFromNpmSpec", () => { }); const result = await installPluginFromNpmSpec({ - spec: "@openclaw/whatsapp", + spec: "@openclaw/whatsapp@2026.5.27", npmDir: npmRoot, logger: { info: () => {}, warn: () => {} }, }); @@ -963,12 +963,75 @@ describe("installPluginFromNpmSpec", () => { ).toBe(false); }); - it("preserves an existing npm plugin when update metadata requires a newer host", async () => { + it("installs the newest compatible npm version for unpinned plugins", async () => { const stateDir = suiteTempRootTracker.makeTempDir(); const npmRoot = path.join(stateDir, "npm"); - fs.mkdirSync(npmRoot, { recursive: true }); + const warnings: string[] = []; + vi.stubEnv("OPENCLAW_COMPATIBILITY_HOST_VERSION", "2026.5.10-beta.1"); + + mockNpmViewAndInstallMany([ + { + spec: "@openclaw/whatsapp", + packageName: "@openclaw/whatsapp", + version: "2026.5.27", + pluginId: "whatsapp", + npmRoot, + versions: ["2026.5.26", "2026.5.27"], + openclaw: { + extensions: ["./dist/index.js"], + install: { minHostVersion: ">=2026.4.25" }, + compat: { pluginApi: ">=2026.5.27" }, + }, + }, + { + spec: "@openclaw/whatsapp@2026.5.26", + packageName: "@openclaw/whatsapp", + version: "2026.5.26", + pluginId: "whatsapp", + npmRoot, + expectedDependencySpec: "2026.5.26", + openclaw: { + extensions: ["./dist/index.js"], + install: { minHostVersion: ">=2026.4.25" }, + compat: { pluginApi: ">=2026.5.10-beta.1" }, + }, + }, + ]); + + const result = await installPluginFromNpmSpec({ + spec: "@openclaw/whatsapp", + npmDir: npmRoot, + logger: { info: () => {}, warn: (message) => warnings.push(message) }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.npmResolution?.resolvedSpec).toBe("@openclaw/whatsapp@2026.5.26"); + expect(result.npmResolution?.version).toBe("2026.5.26"); + expect(warnings.join("\n")).toContain("using newest compatible @openclaw/whatsapp@2026.5.26"); + expect( + JSON.parse( + fs.readFileSync( + path.join(resolveTestPluginPackageDir(npmRoot, "@openclaw/whatsapp"), "package.json"), + "utf8", + ), + ).version, + ).toBe("2026.5.26"); + }); + + it("preserves an existing npm plugin by resolving update metadata to a compatible version", async () => { + const stateDir = suiteTempRootTracker.makeTempDir(); + const npmRoot = path.join(stateDir, "npm"); + const npmProjectRoot = resolvePluginNpmProjectDir({ + npmDir: npmRoot, + packageName: "@openclaw/whatsapp", + }); + const warnings: string[] = []; + fs.mkdirSync(npmProjectRoot, { recursive: true }); fs.writeFileSync( - path.join(npmRoot, "package.json"), + path.join(npmProjectRoot, "package.json"), JSON.stringify({ private: true, dependencies: { @@ -981,54 +1044,71 @@ describe("installPluginFromNpmSpec", () => { packageName: "@openclaw/whatsapp", version: "2026.5.26", pluginId: "whatsapp", - npmRoot, + npmRoot: npmProjectRoot, openclaw: { extensions: ["./dist/index.js"], install: { minHostVersion: ">=2026.4.25" }, - compat: { pluginApi: ">=2026.5.26" }, + compat: { pluginApi: ">=2026.5.10-beta.1" }, }, }); vi.stubEnv("OPENCLAW_COMPATIBILITY_HOST_VERSION", "2026.5.10-beta.1"); - mockNpmViewAndInstall({ - spec: "@openclaw/whatsapp", - packageName: "@openclaw/whatsapp", - version: "2026.5.27", - pluginId: "whatsapp", - npmRoot, - openclaw: { - extensions: ["./dist/index.js"], - install: { minHostVersion: ">=2026.4.25" }, - compat: { pluginApi: ">=2026.5.27" }, + mockNpmViewAndInstallMany([ + { + spec: "@openclaw/whatsapp", + packageName: "@openclaw/whatsapp", + version: "2026.5.27", + pluginId: "whatsapp", + npmRoot, + versions: ["2026.5.26", "2026.5.27"], + openclaw: { + extensions: ["./dist/index.js"], + install: { minHostVersion: ">=2026.4.25" }, + compat: { pluginApi: ">=2026.5.27" }, + }, }, - }); + { + spec: "@openclaw/whatsapp@2026.5.26", + packageName: "@openclaw/whatsapp", + version: "2026.5.26", + pluginId: "whatsapp", + npmRoot, + expectedDependencySpec: "2026.5.26", + openclaw: { + extensions: ["./dist/index.js"], + install: { minHostVersion: ">=2026.4.25" }, + compat: { pluginApi: ">=2026.5.10-beta.1" }, + }, + }, + ]); const result = await installPluginFromNpmSpec({ spec: "@openclaw/whatsapp", npmDir: npmRoot, mode: "update", - logger: { info: () => {}, warn: () => {} }, + logger: { info: () => {}, warn: (message) => warnings.push(message) }, }); - expect(result.ok).toBe(false); - if (result.ok) { + expect(result.ok).toBe(true); + if (!result.ok) { return; } - expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.INCOMPATIBLE_PLUGIN_API); + expect(result.npmResolution?.resolvedSpec).toBe("@openclaw/whatsapp@2026.5.26"); + expect(warnings.join("\n")).toContain("using newest compatible @openclaw/whatsapp@2026.5.26"); expect( JSON.parse( fs.readFileSync( - path.join(npmRoot, "node_modules", "@openclaw", "whatsapp", "package.json"), + path.join(resolveTestPluginPackageDir(npmRoot, "@openclaw/whatsapp"), "package.json"), "utf8", ), ).version, ).toBe("2026.5.26"); const managedManifest = JSON.parse( - fs.readFileSync(path.join(npmRoot, "package.json"), "utf8"), + fs.readFileSync(path.join(npmProjectRoot, "package.json"), "utf8"), ) as { dependencies?: Record }; expect(managedManifest.dependencies?.["@openclaw/whatsapp"]).toBe("2026.5.26"); expect( runCommandWithTimeoutMock.mock.calls.some(([argv]) => isManagedNpmInstallCommand(argv)), - ).toBe(false); + ).toBe(true); }); it.runIf(process.platform !== "win32")( diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 483fa896dd75..7e40dcf312a9 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -298,17 +298,12 @@ type TrustedOfficialPrereleaseResolution = | { kind: "prerelease-only"; resolution: NpmSpecResolution } | { kind: "allow-prerelease-only" }; -async function resolveTrustedOfficialPrereleaseResolution(params: { - spec: ParsedRegistryNpmSpec; - resolvedPrereleaseVersion: string; +async function loadNpmPackageVersions(params: { + packageName: string; timeoutMs: number; - logger: PluginInstallLogger; -}): Promise { - if (!params.spec.name.startsWith("@openclaw/")) { - return null; - } +}): Promise { const versions = await runCommandWithTimeout( - ["npm", "view", params.spec.name, "versions", "--json"], + ["npm", "view", params.packageName, "versions", "--json"], { timeoutMs: Math.max(params.timeoutMs, 60_000), env: createNpmMetadataEnv(), @@ -324,9 +319,27 @@ async function resolveTrustedOfficialPrereleaseResolution(params: { } catch { return null; } - const semverVersions = (Array.isArray(parsed) ? parsed : [parsed]).filter( + return (Array.isArray(parsed) ? parsed : [parsed]).filter( (value): value is string => typeof value === "string" && isExactSemverVersion(value), ); +} + +async function resolveTrustedOfficialPrereleaseResolution(params: { + spec: ParsedRegistryNpmSpec; + resolvedPrereleaseVersion: string; + timeoutMs: number; + logger: PluginInstallLogger; +}): Promise { + if (!params.spec.name.startsWith("@openclaw/")) { + return null; + } + const semverVersions = await loadNpmPackageVersions({ + packageName: params.spec.name, + timeoutMs: params.timeoutMs, + }); + if (!semverVersions) { + return null; + } const stableVersion = semverVersions .filter((value) => !isPrereleaseSemverVersion(value)) .toSorted(compareNpmSemver) @@ -373,6 +386,91 @@ async function resolveTrustedOfficialPrereleaseResolution(params: { return { kind: "stable", resolution: metadataResult.metadata }; } +function shouldResolveLatestCompatibleNpmVersion(spec: ParsedRegistryNpmSpec): boolean { + return ( + spec.selectorKind === "none" || + (spec.selectorKind === "tag" && (spec.selector ?? "").toLowerCase() === "latest") + ); +} + +function canResolveAroundCompatibilityError(error: PluginInstallFailureResult): boolean { + return ( + error.code === PLUGIN_INSTALL_ERROR_CODE.INCOMPATIBLE_HOST_VERSION || + error.code === PLUGIN_INSTALL_ERROR_CODE.INCOMPATIBLE_PLUGIN_API + ); +} + +function validateNpmResolutionCompatibility(params: { + runtime: PluginInstallRuntime; + parsedSpec: ParsedRegistryNpmSpec; + expectedPluginId?: string; + resolution: NpmSpecResolution; +}): PluginInstallFailureResult | null { + return validateOpenClawPackageInstallCompatibility({ + runtime: params.runtime, + pluginId: params.expectedPluginId ?? params.resolution.name ?? params.parsedSpec.name, + packageMetadata: params.resolution.packageOpenClaw as OpenClawPackageManifest | undefined, + }); +} + +async function resolveLatestCompatibleNpmResolution(params: { + runtime: PluginInstallRuntime; + parsedSpec: ParsedRegistryNpmSpec; + expectedPluginId?: string; + currentResolution: NpmSpecResolution; + timeoutMs: number; + logger: PluginInstallLogger; +}): Promise { + if ( + !shouldResolveLatestCompatibleNpmVersion(params.parsedSpec) || + !params.currentResolution.version + ) { + return null; + } + + const versions = await loadNpmPackageVersions({ + packageName: params.parsedSpec.name, + timeoutMs: params.timeoutMs, + }); + if (!versions) { + return null; + } + + const currentVersion = params.currentResolution.version; + const candidates = versions + .filter((version) => !isPrereleaseSemverVersion(version)) + .filter((version) => compareNpmSemver(version, currentVersion) < 0) + .toSorted(compareNpmSemver) + .toReversed(); + for (const version of candidates) { + const spec = `${params.parsedSpec.name}@${version}`; + const metadataResult = await resolveNpmSpecMetadata({ + spec, + timeoutMs: params.timeoutMs, + }); + if (!metadataResult.ok) { + params.logger.warn?.( + `Could not inspect ${spec} while looking for a compatible plugin version: ${metadataResult.error}`, + ); + continue; + } + const compatibilityError = validateNpmResolutionCompatibility({ + runtime: params.runtime, + parsedSpec: params.parsedSpec, + expectedPluginId: params.expectedPluginId, + resolution: metadataResult.metadata, + }); + if (!compatibilityError) { + params.logger.warn?.( + `Resolved ${params.parsedSpec.raw} to ${params.currentResolution.resolvedSpec ?? currentVersion}, but that version is incompatible with this OpenClaw runtime; using newest compatible ${metadataResult.metadata.resolvedSpec ?? spec}.`, + ); + return metadataResult.metadata; + } + } + + return null; +} + function buildFileInstallResult(pluginId: string, targetFile: string): InstallPluginResult { return { ok: true, @@ -1995,6 +2093,36 @@ export async function installPluginFromNpmSpec( }; } } + let compatibilityError = validateNpmResolutionCompatibility({ + runtime, + parsedSpec, + expectedPluginId, + resolution: npmResolution, + }); + if (compatibilityError && canResolveAroundCompatibilityError(compatibilityError)) { + const compatibleResolution = await resolveLatestCompatibleNpmResolution({ + runtime, + parsedSpec, + expectedPluginId, + currentResolution: npmResolution, + timeoutMs, + logger, + }); + if (compatibleResolution) { + Object.assign(npmResolution, compatibleResolution, { + resolvedAt: npmResolution.resolvedAt, + }); + compatibilityError = validateNpmResolutionCompatibility({ + runtime, + parsedSpec, + expectedPluginId, + resolution: npmResolution, + }); + } + } + if (compatibilityError) { + return compatibilityError; + } const driftResult = await resolveNpmIntegrityDriftWithDefaultMessage({ spec, expectedIntegrity: params.expectedIntegrity, @@ -2005,14 +2133,6 @@ export async function installPluginFromNpmSpec( if (driftResult.error) { return { ok: false, error: driftResult.error }; } - const compatibilityError = validateOpenClawPackageCompatibility({ - pluginId: expectedPluginId ?? npmResolution.name ?? parsedSpec.name, - currentHostVersion: runtime.resolveCompatibilityHostVersion(), - packageMetadata: npmResolution.packageOpenClaw as OpenClawPackageManifest | undefined, - }); - if (compatibilityError) { - return compatibilityError; - } return await installPluginFromManagedNpmRoot({ dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,