From 3af34316f212707b18a938de272f3d16cce4a6af Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 10:25:16 +0100 Subject: [PATCH] fix: preserve clawhub install selectors --- CHANGELOG.md | 1 + docs/cli/plugins.md | 1 + src/cli/plugins-cli-test-helpers.ts | 2 - src/cli/plugins-cli.install.test.ts | 87 ++++++++++++++++++++++++++++- src/cli/plugins-install-command.ts | 12 +--- 5 files changed, 90 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d401e686a4..d7aef465a30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- CLI/plugins: preserve unversioned ClawHub install specs so `plugins update` can follow newer ClawHub releases instead of pinning to the initially resolved version. Fixes #63010; supersedes #58426. Thanks @kangsen1234 and @robinspt. - Gateway/models: move local-provider pricing opt-outs, OpenRouter/LiteLLM aliases, and proxy passthrough pricing lookup into plugin manifest metadata so core no longer carries extension-specific pricing tables. Thanks @codex. - CLI/update: honor `OPENCLAW_NO_AUTO_UPDATE=1` as a gateway startup kill-switch for configured background package auto-updates, so operators can hold a deliberate downgrade during incident recovery without editing config first. Fixes #72715. Thanks @Xivi08. - Agents/Claude CLI: force live-session launches to include `--output-format stream-json` whenever OpenClaw adds `--input-format stream-json`, so new Claude CLI sessions no longer fail immediately while reusable sessions keep working. Fixes #72206. Thanks @kwangwonkoh and @Xivi08. diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 7b1bbdb909e..5090ed5da01 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -138,6 +138,7 @@ openclaw plugins install npm:@scope/plugin-name@1.0.1 ``` OpenClaw downloads the package archive from ClawHub, checks the advertised plugin API / minimum gateway compatibility, then installs it through the normal archive path. Recorded installs keep their ClawHub source metadata for later updates. +Unversioned ClawHub installs keep an unversioned recorded spec so `openclaw plugins update` can follow newer ClawHub releases; explicit version or tag selectors such as `clawhub:pkg@1.2.3` and `clawhub:pkg@beta` remain pinned to that selector. #### Marketplace shorthand diff --git a/src/cli/plugins-cli-test-helpers.ts b/src/cli/plugins-cli-test-helpers.ts index 4b96322d54b..897364c4568 100644 --- a/src/cli/plugins-cli-test-helpers.ts +++ b/src/cli/plugins-cli-test-helpers.ts @@ -479,8 +479,6 @@ vi.mock("../plugins/clawhub.js", () => ({ installPluginFromClawHub, ...args, )) as (typeof import("../plugins/clawhub.js"))["installPluginFromClawHub"], - formatClawHubSpecifier: ({ name, version }: { name: string; version?: string }) => - `clawhub:${name}${version ? `@${version}` : ""}`, })); vi.mock("../infra/clawhub.js", () => ({ diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts index 3b8939dd74a..607d342a3fd 100644 --- a/src/cli/plugins-cli.install.test.ts +++ b/src/cli/plugins-cli.install.test.ts @@ -408,8 +408,9 @@ describe("plugins cli install", () => { expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({ demo: expect.objectContaining({ source: "clawhub", - spec: "clawhub:demo@1.2.3", + spec: "clawhub:demo", installPath: cliInstallPath("demo"), + version: "1.2.3", clawhubPackage: "demo", clawhubFamily: "code-plugin", clawhubChannel: "official", @@ -491,6 +492,48 @@ describe("plugins cli install", () => { ); }); + it("keeps explicit ClawHub versions pinned in install records", async () => { + const cfg = { + plugins: { + entries: {}, + }, + } as OpenClawConfig; + const enabledCfg = createEnabledPluginConfig("demo"); + + loadConfig.mockReturnValue(cfg); + parseClawHubPluginSpec.mockReturnValue({ name: "demo", version: "1.2.3" }); + installPluginFromClawHub.mockResolvedValue( + createClawHubInstallResult({ + pluginId: "demo", + packageName: "demo", + version: "1.2.3", + channel: "official", + }), + ); + enablePluginInConfig.mockReturnValue({ config: enabledCfg }); + applyExclusiveSlotSelection.mockReturnValue({ + config: enabledCfg, + warnings: [], + }); + + await runPluginsCommand(["plugins", "install", "clawhub:demo@1.2.3"]); + + expect(installPluginFromClawHub).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "clawhub:demo@1.2.3", + }), + ); + expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({ + demo: expect.objectContaining({ + source: "clawhub", + spec: "clawhub:demo@1.2.3", + installPath: cliInstallPath("demo"), + version: "1.2.3", + clawhubPackage: "demo", + }), + }); + }); + it("prefers ClawHub before npm for bare plugin specs", async () => { const cfg = { plugins: { @@ -524,14 +567,54 @@ describe("plugins cli install", () => { expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({ demo: expect.objectContaining({ source: "clawhub", - spec: "clawhub:demo@1.2.3", + spec: "clawhub:demo", installPath: cliInstallPath("demo"), + version: "1.2.3", clawhubPackage: "demo", }), }); expect(writeConfigFile).toHaveBeenCalledWith(enabledCfg); }); + it("keeps explicit bare ClawHub selectors in install records", async () => { + const cfg = { + plugins: { + entries: {}, + }, + } as OpenClawConfig; + const enabledCfg = createEnabledPluginConfig("demo"); + loadConfig.mockReturnValue(cfg); + installPluginFromClawHub.mockResolvedValue( + createClawHubInstallResult({ + pluginId: "demo", + packageName: "demo", + version: "1.2.3-beta.1", + channel: "community", + }), + ); + enablePluginInConfig.mockReturnValue({ config: enabledCfg }); + applyExclusiveSlotSelection.mockReturnValue({ + config: enabledCfg, + warnings: [], + }); + + await runPluginsCommand(["plugins", "install", "demo@beta"]); + + expect(installPluginFromClawHub).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "clawhub:demo@beta", + }), + ); + expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({ + demo: expect.objectContaining({ + source: "clawhub", + spec: "clawhub:demo@beta", + version: "1.2.3-beta.1", + clawhubPackage: "demo", + }), + }); + }); + it("falls back to npm when ClawHub does not have the package", async () => { primeNpmPluginFallback(); diff --git a/src/cli/plugins-install-command.ts b/src/cli/plugins-install-command.ts index 18749473608..99c00f7a91a 100644 --- a/src/cli/plugins-install-command.ts +++ b/src/cli/plugins-install-command.ts @@ -7,7 +7,7 @@ import { resolveArchiveKind } from "../infra/archive.js"; import { parseClawHubPluginSpec } from "../infra/clawhub.js"; import { formatErrorMessage } from "../infra/errors.js"; import { type BundledPluginSource, findBundledPluginSource } from "../plugins/bundled-sources.js"; -import { formatClawHubSpecifier, installPluginFromClawHub } from "../plugins/clawhub.js"; +import { installPluginFromClawHub } from "../plugins/clawhub.js"; import type { InstallSafetyOverrides } from "../plugins/install-security-scan.js"; import { PLUGIN_INSTALL_ERROR_CODE, @@ -672,10 +672,7 @@ export async function runPluginInstallCommand(params: { pluginId: result.pluginId, install: { source: "clawhub", - spec: formatClawHubSpecifier({ - name: result.clawhub.clawhubPackage, - version: result.clawhub.version, - }), + spec: raw, installPath: result.targetDir, version: result.version, integrity: result.clawhub.integrity, @@ -704,10 +701,7 @@ export async function runPluginInstallCommand(params: { pluginId: clawhubResult.pluginId, install: { source: "clawhub", - spec: formatClawHubSpecifier({ - name: clawhubResult.clawhub.clawhubPackage, - version: clawhubResult.clawhub.version, - }), + spec: preferredClawHubSpec, installPath: clawhubResult.targetDir, version: clawhubResult.version, integrity: clawhubResult.clawhub.integrity,