From 411df59916084acc596a065654de98b0fca7f07a Mon Sep 17 00:00:00 2001 From: Bek <66288351+bek91@users.noreply.github.com> Date: Sun, 3 May 2026 01:27:13 -0400 Subject: [PATCH] fix(plugins): resolve official plugin install aliases Resolve bare official external plugin IDs through the official catalog before generic npm fallback, preserving explicit npm semantics and catalog integrity through the hook-pack fallback.\n\nFixes #76373.\n\nThanks @bek91 and @vincentkoc. --- CHANGELOG.md | 1 + src/cli/plugin-install-plan.test.ts | 50 ++++++++++++ src/cli/plugin-install-plan.ts | 27 +++++++ src/cli/plugins-cli-test-helpers.ts | 22 ++++++ src/cli/plugins-cli.install.test.ts | 115 +++++++++++++++++++++++++++- src/cli/plugins-install-command.ts | 48 ++++++++++++ 6 files changed, 262 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ec796f569d..00074dfa075 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai - Docker/Gateway: pass Docker setup `.env` values into gateway and CLI containers and preserve exec SecretRef `passEnv` keys in managed service plans, so 1Password Connect-backed Discord tokens keep resolving after doctor or plugin repair. Thanks @vincentkoc. - Control UI/WebChat: explain compaction boundaries in chat history and link directly to session checkpoint controls so pre-compaction turns no longer look silently lost after refresh. Fixes #76415. Thanks @BunsDev. - Channels/WhatsApp: attach native outbound mention metadata for group text and media captions by resolving `@+` and `@` tokens against WhatsApp participant data, including LID groups. Fixes #39879; carries forward #56863. Thanks @kengi1437, @joe2643, and @fridayck. +- Plugins/install: resolve bare official external plugin IDs such as `brave` through the official catalog when no bundled source is available, so packaged installs fetch the intended scoped npm package instead of an unrelated unscoped package. Fixes #76373. Thanks @bek91 and @vincentkoc. - Gateway/sessions: keep async `sessions.list` title and preview hydration bounded to transcript head/tail reads so Control UI polling cannot full-scan large session transcripts every refresh. Thanks @vincentkoc. - Gateway/sessions: keep agent runtime metadata on lightweight `sessions.list` rows so model-only session patches do not make Control UI lose runtime identity. Thanks @vincentkoc. - Gateway/sessions: keep bulk `sessions.list` rows lightweight by skipping per-row transcript usage fallback, display model inference, and plugin projection, avoiding event-loop stalls in large session stores. Thanks @Marvinthebored and @vincentkoc. diff --git a/src/cli/plugin-install-plan.test.ts b/src/cli/plugin-install-plan.test.ts index ff30d515b5d..2358f707b87 100644 --- a/src/cli/plugin-install-plan.test.ts +++ b/src/cli/plugin-install-plan.test.ts @@ -5,6 +5,7 @@ import { resolveBundledInstallPlanForCatalogEntry, resolveBundledInstallPlanBeforeNpm, resolveBundledInstallPlanForNpmFailure, + resolveOfficialExternalInstallPlanBeforeNpm, } from "./plugin-install-plan.js"; describe("plugin install plan helpers", () => { @@ -36,6 +37,55 @@ describe("plugin install plan helpers", () => { expect(result).toBeNull(); }); + it("resolves exact official external plugin ids before npm fallback", () => { + const findOfficialExternalPlugin = vi.fn().mockReturnValue({ + pluginId: "brave", + npmSpec: "@openclaw/brave-plugin", + expectedIntegrity: "sha512-brave", + }); + + const result = resolveOfficialExternalInstallPlanBeforeNpm({ + rawSpec: "brave", + findOfficialExternalPlugin, + }); + + expect(findOfficialExternalPlugin).toHaveBeenCalledWith("brave"); + expect(result).toEqual({ + pluginId: "brave", + npmSpec: "@openclaw/brave-plugin", + expectedIntegrity: "sha512-brave", + }); + }); + + it("skips official external plan for explicit npm selectors", () => { + const findOfficialExternalPlugin = vi.fn(); + + expect( + resolveOfficialExternalInstallPlanBeforeNpm({ + rawSpec: "brave@beta", + findOfficialExternalPlugin, + }), + ).toBeNull(); + expect( + resolveOfficialExternalInstallPlanBeforeNpm({ + rawSpec: "@openclaw/brave-plugin", + findOfficialExternalPlugin, + }), + ).toBeNull(); + expect(findOfficialExternalPlugin).not.toHaveBeenCalled(); + }); + + it("skips official external plan without an npm install spec", () => { + const result = resolveOfficialExternalInstallPlanBeforeNpm({ + rawSpec: "brave", + findOfficialExternalPlugin: vi.fn().mockReturnValue({ + pluginId: "brave", + }), + }); + + expect(result).toBeNull(); + }); + it("prefers bundled catalog plugin by id before npm spec", () => { const findBundledSource = vi .fn() diff --git a/src/cli/plugin-install-plan.ts b/src/cli/plugin-install-plan.ts index 22dafa3f820..039c5e43771 100644 --- a/src/cli/plugin-install-plan.ts +++ b/src/cli/plugin-install-plan.ts @@ -7,6 +7,14 @@ type BundledLookup = (params: { value: string; }) => BundledPluginSource | undefined; +type OfficialExternalPluginLookup = (pluginId: string) => + | { + pluginId: string; + npmSpec?: string; + expectedIntegrity?: string; + } + | undefined; + function isBareNpmPackageName(spec: string): boolean { const trimmed = spec.trim(); return /^[a-z0-9][a-z0-9-._~]*$/.test(trimmed); @@ -65,6 +73,25 @@ export function resolveBundledInstallPlanBeforeNpm(params: { }; } +export function resolveOfficialExternalInstallPlanBeforeNpm(params: { + rawSpec: string; + findOfficialExternalPlugin: OfficialExternalPluginLookup; +}): { pluginId: string; npmSpec: string; expectedIntegrity?: string } | null { + if (!isBareNpmPackageName(params.rawSpec)) { + return null; + } + const entry = params.findOfficialExternalPlugin(params.rawSpec); + const npmSpec = entry?.npmSpec?.trim(); + if (!entry?.pluginId || !npmSpec) { + return null; + } + return { + pluginId: entry.pluginId, + npmSpec, + ...(entry.expectedIntegrity ? { expectedIntegrity: entry.expectedIntegrity } : {}), + }; +} + export function resolveBundledInstallPlanForNpmFailure(params: { rawSpec: string; code?: string; diff --git a/src/cli/plugins-cli-test-helpers.ts b/src/cli/plugins-cli-test-helpers.ts index d572d316966..4de1bbe1389 100644 --- a/src/cli/plugins-cli-test-helpers.ts +++ b/src/cli/plugins-cli-test-helpers.ts @@ -78,6 +78,7 @@ export const installPluginFromNpmSpec: AsyncUnknownMock = vi.fn(); export const installPluginFromPath: AsyncUnknownMock = vi.fn(); export const installPluginFromClawHub: AsyncUnknownMock = vi.fn(); export const parseClawHubPluginSpec: Mock = vi.fn(); +export const findBundledPluginSourceMock: UnknownMock = vi.fn(); export const installHooksFromNpmSpec: AsyncUnknownMock = vi.fn(); export const installHooksFromPath: AsyncUnknownMock = vi.fn(); export const recordHookInstall: UnknownMock = vi.fn(); @@ -485,6 +486,26 @@ vi.mock("../plugins/install.js", () => ({ )) as (typeof import("../plugins/install.js"))["installPluginFromPath"], })); +vi.mock("../plugins/bundled-sources.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + findBundledPluginSource: (( + ...args: Parameters< + (typeof import("../plugins/bundled-sources.js"))["findBundledPluginSource"] + > + ) => { + if (findBundledPluginSourceMock.getMockImplementation()) { + return invokeMock< + Parameters<(typeof import("../plugins/bundled-sources.js"))["findBundledPluginSource"]>, + ReturnType<(typeof import("../plugins/bundled-sources.js"))["findBundledPluginSource"]> + >(findBundledPluginSourceMock, ...args); + } + return actual.findBundledPluginSource(...args); + }) as (typeof import("../plugins/bundled-sources.js"))["findBundledPluginSource"], + }; +}); + vi.mock("../plugins/git-install.js", () => ({ installPluginFromGitSpec: (( ...args: Parameters<(typeof import("../plugins/git-install.js"))["installPluginFromGitSpec"]> @@ -621,6 +642,7 @@ export function resetPluginsCliTestState() { installPluginFromPath.mockReset(); installPluginFromClawHub.mockReset(); parseClawHubPluginSpec.mockReset(); + findBundledPluginSourceMock.mockReset(); installHooksFromNpmSpec.mockReset(); installHooksFromPath.mockReset(); recordHookInstall.mockReset(); diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts index f43bc2ac16d..5ce11dee4df 100644 --- a/src/cli/plugins-cli.install.test.ts +++ b/src/cli/plugins-cli.install.test.ts @@ -8,6 +8,7 @@ import { applyExclusiveSlotSelection, buildPluginSnapshotReport, enablePluginInConfig, + findBundledPluginSourceMock, installHooksFromNpmSpec, installHooksFromPath, installPluginFromClawHub, @@ -652,7 +653,91 @@ describe("plugins cli install", () => { }); }); - it("installs bare plugin specs through npm without ClawHub lookup", async () => { + it("resolves exact official external plugin ids through their npm package", async () => { + const cfg = createEmptyPluginConfig(); + const enabledCfg = createEnabledPluginConfig("brave"); + loadConfig.mockReturnValue(cfg); + findBundledPluginSourceMock.mockReturnValue(undefined); + installPluginFromNpmSpec.mockResolvedValue(createNpmPluginInstallResult("brave")); + enablePluginInConfig.mockReturnValue({ config: enabledCfg }); + applyExclusiveSlotSelection.mockReturnValue({ + config: enabledCfg, + warnings: [], + }); + + await runPluginsCommand(["plugins", "install", "brave"]); + + expect(findBundledPluginSourceMock).toHaveBeenCalledWith({ + lookup: { kind: "pluginId", value: "brave" }, + }); + expect(installPluginFromClawHub).not.toHaveBeenCalled(); + expect(installPluginFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@openclaw/brave-plugin", + expectedPluginId: "brave", + }), + ); + expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({ + brave: expect.objectContaining({ + source: "npm", + spec: "@openclaw/brave-plugin", + installPath: cliInstallPath("brave"), + version: "1.2.3", + }), + }); + expect(writeConfigFile).toHaveBeenCalledWith(enabledCfg); + }); + + it("passes official external catalog integrity to npm installs", async () => { + const cfg = createEmptyPluginConfig(); + const enabledCfg = createEnabledPluginConfig("wecom"); + loadConfig.mockReturnValue(cfg); + findBundledPluginSourceMock.mockReturnValue(undefined); + installPluginFromNpmSpec.mockResolvedValue(createNpmPluginInstallResult("wecom")); + enablePluginInConfig.mockReturnValue({ config: enabledCfg }); + applyExclusiveSlotSelection.mockReturnValue({ + config: enabledCfg, + warnings: [], + }); + + await runPluginsCommand(["plugins", "install", "wecom"]); + + expect(installPluginFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@wecom/wecom-openclaw-plugin@2026.4.23", + expectedPluginId: "wecom", + expectedIntegrity: + "sha512-bnzfdIEEu1/LFvcdyjaTkyxt27w6c7dqhkPezU62OWaqmcdFsUGR3T55USK/O9pIKsNcnL1Tnu1pqKYCWHFgWQ==", + }), + ); + }); + + it("passes official external catalog integrity to hook-pack fallback", async () => { + loadConfig.mockReturnValue(createEmptyPluginConfig()); + findBundledPluginSourceMock.mockReturnValue(undefined); + installPluginFromNpmSpec.mockResolvedValue({ + ok: false, + error: "package.json missing openclaw.extensions", + code: "missing_openclaw_extensions", + }); + installHooksFromNpmSpec.mockResolvedValue({ + ok: false, + error: + "aborted: npm package integrity drift detected for @wecom/wecom-openclaw-plugin@2026.4.23", + }); + + await expect(runPluginsCommand(["plugins", "install", "wecom"])).rejects.toThrow("__exit__:1"); + + expect(installHooksFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@wecom/wecom-openclaw-plugin@2026.4.23", + expectedIntegrity: + "sha512-bnzfdIEEu1/LFvcdyjaTkyxt27w6c7dqhkPezU62OWaqmcdFsUGR3T55USK/O9pIKsNcnL1Tnu1pqKYCWHFgWQ==", + }), + ); + }); + + it("installs ordinary bare plugin specs through npm without ClawHub lookup", async () => { const cfg = createEmptyPluginConfig(); const enabledCfg = createEnabledPluginConfig("demo"); loadConfig.mockReturnValue(cfg); @@ -735,6 +820,34 @@ describe("plugins cli install", () => { expect(writeConfigFile).toHaveBeenCalledWith(enabledCfg); }); + it("keeps npm-prefixed official plugin ids on explicit npm semantics", async () => { + const cfg = createEmptyPluginConfig(); + const enabledCfg = createEnabledPluginConfig("brave"); + + loadConfig.mockReturnValue(cfg); + installPluginFromNpmSpec.mockResolvedValue(createNpmPluginInstallResult("brave")); + enablePluginInConfig.mockReturnValue({ config: enabledCfg }); + recordPluginInstall.mockReturnValue(enabledCfg); + applyExclusiveSlotSelection.mockReturnValue({ + config: enabledCfg, + warnings: [], + }); + + await runPluginsCommand(["plugins", "install", "npm:brave"]); + + expect(installPluginFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "brave", + }), + ); + expect(installPluginFromNpmSpec).toHaveBeenCalledWith( + expect.not.objectContaining({ + expectedPluginId: "brave", + }), + ); + expect(installPluginFromClawHub).not.toHaveBeenCalled(); + }); + it("passes the active profile extensions dir to npm installs", async () => { const extensionsDir = useProfileExtensionsDir(); const cfg = createEmptyPluginConfig(); diff --git a/src/cli/plugins-install-command.ts b/src/cli/plugins-install-command.ts index 1224e3d4315..060d0c29648 100644 --- a/src/cli/plugins-install-command.ts +++ b/src/cli/plugins-install-command.ts @@ -21,6 +21,11 @@ import { installPluginFromMarketplace, resolveMarketplaceInstallShortcut, } from "../plugins/marketplace.js"; +import { + getOfficialExternalPluginCatalogEntry, + resolveOfficialExternalPluginId, + resolveOfficialExternalPluginInstall, +} from "../plugins/official-external-plugin-catalog.js"; import { tracePluginLifecyclePhaseAsync } from "../plugins/plugin-lifecycle-trace.js"; import { validateJsonSchemaValue } from "../plugins/schema-validator.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; @@ -36,6 +41,7 @@ import { import { resolveBundledInstallPlanBeforeNpm, resolveBundledInstallPlanForNpmFailure, + resolveOfficialExternalInstallPlanBeforeNpm, } from "./plugin-install-plan.js"; import { createHookPackInstallLogger, @@ -231,11 +237,13 @@ async function tryInstallHookPackFromNpmSpec(params: { installMode: "install" | "update"; spec: string; pin?: boolean; + expectedIntegrity?: string; runtime?: RuntimeEnv; }): Promise<{ ok: true } | { ok: false; error: string }> { const result = await installHooksFromNpmSpec({ spec: params.spec, mode: params.installMode, + ...(params.expectedIntegrity ? { expectedIntegrity: params.expectedIntegrity } : {}), logger: createHookPackInstallLogger(params.runtime), }); if (!result.ok) { @@ -269,12 +277,16 @@ async function tryInstallPluginOrHookPackFromNpmSpec(params: { safetyOverrides: InstallSafetyOverrides; allowBundledFallback: boolean; extensionsDir: string; + expectedPluginId?: string; + expectedIntegrity?: string; runtime?: RuntimeEnv; }): Promise<{ ok: true } | { ok: false }> { const result = await installPluginFromNpmSpec({ ...params.safetyOverrides, mode: params.installMode, spec: params.spec, + ...(params.expectedPluginId ? { expectedPluginId: params.expectedPluginId } : {}), + ...(params.expectedIntegrity ? { expectedIntegrity: params.expectedIntegrity } : {}), extensionsDir: params.extensionsDir, logger: createPluginInstallLogger(params.runtime), }); @@ -305,6 +317,7 @@ async function tryInstallPluginOrHookPackFromNpmSpec(params: { installMode: params.installMode, spec: params.spec, pin: params.pin, + expectedIntegrity: params.expectedIntegrity, runtime: params.runtime, }); if (hookFallback.ok) { @@ -747,6 +760,41 @@ export async function runPluginInstallCommand(params: { return; } + const officialExternalPlan = resolveOfficialExternalInstallPlanBeforeNpm({ + rawSpec: raw, + findOfficialExternalPlugin: (pluginId) => { + const entry = getOfficialExternalPluginCatalogEntry(pluginId); + const resolvedPluginId = entry ? resolveOfficialExternalPluginId(entry) : undefined; + const install = entry ? resolveOfficialExternalPluginInstall(entry) : null; + const npmSpec = install?.npmSpec; + return resolvedPluginId && npmSpec + ? { + pluginId: resolvedPluginId, + npmSpec, + ...(install.expectedIntegrity ? { expectedIntegrity: install.expectedIntegrity } : {}), + } + : undefined; + }, + }); + if (officialExternalPlan) { + const npmResult = await tryInstallPluginOrHookPackFromNpmSpec({ + snapshot, + installMode, + spec: officialExternalPlan.npmSpec, + pin: opts.pin, + safetyOverrides, + allowBundledFallback: false, + extensionsDir, + expectedPluginId: officialExternalPlan.pluginId, + expectedIntegrity: officialExternalPlan.expectedIntegrity, + runtime, + }); + if (!npmResult.ok) { + return runtime.exit(1); + } + return; + } + const clawhubSpec = parseClawHubPluginSpec(raw); if (clawhubSpec) { const result = await installPluginFromClawHub({