diff --git a/CHANGELOG.md b/CHANGELOG.md index fe67d116e21..7b0b6aa4315 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,7 @@ Docs: https://docs.openclaw.ai - Plugins/discovery: ignore managed npm plugin packages that only expose TypeScript source entries without compiled runtime output, so stale/broken installs cannot hide a working bundled or reinstallable channel plugin during setup. Thanks @vincentkoc. - CLI/update: treat OpenClaw stable correction versions like `2026.5.3-1` as newer than their base stable release, so package updates no longer ask for downgrade confirmation. Thanks @vincentkoc. - Plugins/install: suppress dangerous-pattern scanner warnings for trusted official OpenClaw npm installs, so installing `@openclaw/discord` no longer prints credential-harvesting warnings for the official package. Thanks @vincentkoc. +- Plugins/commands: suppress dangerous-pattern scanner warnings for trusted catalog npm installs from owner-gated `/plugins install` commands, so chat-driven installs match the CLI install trust path. Thanks @vincentkoc. - Plugins/release: make the published npm runtime verifier reject blank `openclaw.runtimeExtensions` entries instead of treating them as absent and passing via inferred outputs. Thanks @vincentkoc. - Plugins/security: ignore inline and block comments when matching source-rule context in plugin install scans, so comment-only `fetch`/`post` references near environment defaults do not block clean plugins. Thanks @vincentkoc. - Doctor/plugins: remove stale managed install records for bundled plugins even when the bundled plugin is not explicitly configured, so doctor cleanup cannot leave orphaned install metadata behind. Thanks @vincentkoc. diff --git a/src/auto-reply/reply/commands-plugins.install.test.ts b/src/auto-reply/reply/commands-plugins.install.test.ts index e35311586a2..e979176293e 100644 --- a/src/auto-reply/reply/commands-plugins.install.test.ts +++ b/src/auto-reply/reply/commands-plugins.install.test.ts @@ -7,11 +7,13 @@ import { handlePluginsCommand } from "./commands-plugins.js"; import { buildPluginsCommandParams } from "./commands.test-harness.js"; const { + installPluginFromNpmSpecMock, installPluginFromPathMock, installPluginFromClawHubMock, installPluginFromGitSpecMock, persistPluginInstallMock, } = vi.hoisted(() => ({ + installPluginFromNpmSpecMock: vi.fn(), installPluginFromPathMock: vi.fn(), installPluginFromClawHubMock: vi.fn(), installPluginFromGitSpecMock: vi.fn(), @@ -24,6 +26,7 @@ vi.mock("../../plugins/install.js", async () => { ); return { ...actual, + installPluginFromNpmSpec: installPluginFromNpmSpecMock, installPluginFromPath: installPluginFromPathMock, }; }); @@ -64,6 +67,7 @@ function buildPluginsParams(commandBodyNormalized: string, workspaceDir: string) describe("handleCommands /plugins install", () => { afterEach(async () => { + installPluginFromNpmSpecMock.mockReset(); installPluginFromPathMock.mockReset(); installPluginFromClawHubMock.mockReset(); installPluginFromGitSpecMock.mockReset(); @@ -253,4 +257,60 @@ describe("handleCommands /plugins install", () => { ); }); }); + + it("trusts catalog npm package installs with alternate selectors", async () => { + installPluginFromNpmSpecMock.mockResolvedValue({ + ok: true, + pluginId: "wecom-openclaw-plugin", + targetDir: "/tmp/wecom-openclaw-plugin", + version: "2026.4.23", + extensions: ["index.js"], + npmResolution: { + name: "@wecom/wecom-openclaw-plugin", + version: "2026.4.23", + resolvedSpec: "@wecom/wecom-openclaw-plugin@2026.4.23", + integrity: "sha512-wecom", + resolvedAt: "2026-05-04T20:00:00.000Z", + }, + }); + persistPluginInstallMock.mockResolvedValue({}); + + await withTempHome("openclaw-command-plugins-home-", async () => { + const workspaceDir = await workspaceHarness.createWorkspace(); + const params = buildPluginsParams( + "/plugins install @wecom/wecom-openclaw-plugin@latest", + workspaceDir, + ); + const result = await handlePluginsCommand(params, true); + if (result === null) { + throw new Error("expected plugin install result"); + } + expect(result.reply?.text).toContain('Installed plugin "wecom-openclaw-plugin"'); + expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@wecom/wecom-openclaw-plugin@latest", + expectedPluginId: "wecom-openclaw-plugin", + trustedSourceLinkedOfficialInstall: true, + }), + ); + expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith( + expect.not.objectContaining({ + expectedIntegrity: expect.any(String), + }), + ); + expect(persistPluginInstallMock).toHaveBeenCalledWith( + expect.objectContaining({ + pluginId: "wecom-openclaw-plugin", + install: expect.objectContaining({ + source: "npm", + spec: "@wecom/wecom-openclaw-plugin@latest", + installPath: "/tmp/wecom-openclaw-plugin", + version: "2026.4.23", + resolvedName: "@wecom/wecom-openclaw-plugin", + resolvedVersion: "2026.4.23", + }), + }), + ); + }); + }); }); diff --git a/src/auto-reply/reply/commands-plugins.ts b/src/auto-reply/reply/commands-plugins.ts index d850f37bffd..05036afe148 100644 --- a/src/auto-reply/reply/commands-plugins.ts +++ b/src/auto-reply/reply/commands-plugins.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import { buildNpmInstallRecordFields } from "../../cli/npm-resolution.js"; +import { resolveOfficialExternalNpmPackageTrust } from "../../cli/plugin-install-plan.js"; import { createPluginInstallLogger, resolveFileNpmSpecToLocalPath, @@ -20,6 +21,11 @@ import { installPluginFromClawHub } from "../../plugins/clawhub.js"; import { installPluginFromGitSpec, parseGitPluginSpec } from "../../plugins/git-install.js"; import { installPluginFromNpmSpec, installPluginFromPath } from "../../plugins/install.js"; import { loadInstalledPluginIndexInstallRecords } from "../../plugins/installed-plugin-index-records.js"; +import { + getOfficialExternalPluginCatalogEntryForPackage, + resolveOfficialExternalPluginId, + resolveOfficialExternalPluginInstall, +} from "../../plugins/official-external-plugin-catalog.js"; import type { PluginRecord } from "../../plugins/registry.js"; import { buildAllPluginInspectReports, @@ -159,6 +165,29 @@ function looksLikeLocalPluginInstallSpec(raw: string): boolean { ); } +function findTrustedCatalogPackageInstall(packageName: string): + | { + pluginId: string; + npmSpec?: string; + expectedIntegrity?: string; + } + | undefined { + const entry = getOfficialExternalPluginCatalogEntryForPackage(packageName); + if (!entry) { + return undefined; + } + const pluginId = resolveOfficialExternalPluginId(entry); + if (!pluginId) { + return undefined; + } + const install = resolveOfficialExternalPluginInstall(entry); + return { + pluginId, + ...(install?.npmSpec ? { npmSpec: install.npmSpec } : {}), + ...(install?.expectedIntegrity ? { expectedIntegrity: install.expectedIntegrity } : {}), + }; +} + async function installPluginFromPluginsCommand(params: { raw: string; snapshot: ConfigSnapshotForInstallPersist; @@ -254,8 +283,21 @@ async function installPluginFromPluginsCommand(params: { return { ok: true, pluginId: result.pluginId }; } + const officialNpmTrust = resolveOfficialExternalNpmPackageTrust({ + npmSpec: params.raw, + findOfficialExternalPackage: findTrustedCatalogPackageInstall, + }); const result = await installPluginFromNpmSpec({ spec: params.raw, + ...(officialNpmTrust + ? { + expectedPluginId: officialNpmTrust.pluginId, + ...(officialNpmTrust.expectedIntegrity + ? { expectedIntegrity: officialNpmTrust.expectedIntegrity } + : {}), + trustedSourceLinkedOfficialInstall: true, + } + : {}), logger: createPluginInstallLogger(), }); if (!result.ok) {