diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index 3eb27363d34..dfd952829b8 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -582,10 +582,10 @@ plugin on older hosts. Exact npm version pinning already lives in `npmSpec`, for example `"npmSpec": "@wecom/wecom-openclaw-plugin@1.2.3"`. Pair that with `expectedIntegrity` when you want update flows to fail closed if the fetched -npm artifact no longer matches the pinned release. Interactive onboarding only -offers npm install choices from trusted catalog metadata when `npmSpec` is an -exact version and `expectedIntegrity` is present; otherwise it falls back to a -local source or skip. +npm artifact no longer matches the pinned release. Interactive onboarding +offers trusted registry npm specs, including bare package names and dist-tags. +When `expectedIntegrity` is present, install/update flows enforce it; when it +is omitted, the registry resolution is recorded without an integrity pin. Channel plugins should provide `openclaw.setupEntry` when status, channel list, or SecretRef scans need to identify configured accounts without loading the full diff --git a/docs/plugins/sdk-setup.md b/docs/plugins/sdk-setup.md index e904102fabf..4921986b57c 100644 --- a/docs/plugins/sdk-setup.md +++ b/docs/plugins/sdk-setup.md @@ -162,11 +162,11 @@ Interactive onboarding also uses `openclaw.install` for install-on-demand surfaces. If your plugin exposes provider auth choices or channel setup/catalog metadata before runtime loads, onboarding can show that choice, prompt for npm vs local install, install or enable the plugin, then continue the selected -flow. Npm onboarding choices require trusted catalog metadata with an exact -`npmSpec` version and `expectedIntegrity`; unpinned package names and dist-tags -are not offered for automatic onboarding installs. Keep the "what to show" -metadata in `openclaw.plugin.json` and the "how to install it" metadata in -`package.json`. +flow. Npm onboarding choices require trusted catalog metadata with a registry +`npmSpec`; exact versions and `expectedIntegrity` are optional pins. If +`expectedIntegrity` is present, install/update flows enforce it. Keep the "what +to show" metadata in `openclaw.plugin.json` and the "how to install it" +metadata in `package.json`. If `minHostVersion` is set, install and manifest-registry loading both enforce it. Older hosts skip the plugin; invalid version strings are rejected. diff --git a/scripts/lib/official-external-channel-catalog.json b/scripts/lib/official-external-channel-catalog.json new file mode 100644 index 00000000000..6ae4f2dd8b5 --- /dev/null +++ b/scripts/lib/official-external-channel-catalog.json @@ -0,0 +1,27 @@ +{ + "entries": [ + { + "name": "@wecom/wecom-openclaw-plugin", + "description": "OpenClaw WeCom channel plugin by the Tencent WeCom team.", + "source": "external", + "kind": "channel", + "openclaw": { + "channel": { + "id": "wecom", + "label": "WeCom", + "selectionLabel": "WeCom (Enterprise WeChat)", + "detailLabel": "WeCom", + "docsPath": "/plugins/community#wecom", + "docsLabel": "wecom", + "blurb": "Enterprise WeChat bot and conversation channel.", + "aliases": ["qywx", "wework", "enterprise-wechat"], + "order": 45 + }, + "install": { + "npmSpec": "@wecom/wecom-openclaw-plugin", + "defaultChoice": "npm" + } + } + } + ] +} diff --git a/scripts/write-official-channel-catalog.mjs b/scripts/write-official-channel-catalog.mjs index aaa64425990..e2d97d6e3f3 100644 --- a/scripts/write-official-channel-catalog.mjs +++ b/scripts/write-official-channel-catalog.mjs @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import officialExternalChannelCatalog from "./lib/official-external-channel-catalog.json" with { type: "json" }; import { isRecord, trimString } from "./lib/record-shared.mjs"; import { writeTextFileIfChanged } from "./runtime-postbuild-shared.mjs"; @@ -13,9 +14,14 @@ function toCatalogInstall(value, packageName) { return null; } const defaultChoice = trimString(install.defaultChoice); + const minHostVersion = trimString(install.minHostVersion); + const expectedIntegrity = trimString(install.expectedIntegrity); return { npmSpec, ...(defaultChoice === "npm" || defaultChoice === "local" ? { defaultChoice } : {}), + ...(minHostVersion ? { minHostVersion } : {}), + ...(expectedIntegrity ? { expectedIntegrity } : {}), + ...(install.allowInvalidConfigRecovery === true ? { allowInvalidConfigRecovery: true } : {}), }; } @@ -50,7 +56,9 @@ function buildCatalogEntry(packageJson) { export function buildOfficialChannelCatalog(params = {}) { const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd(); const extensionsRoot = path.join(repoRoot, "extensions"); - const entries = []; + const entries = Array.isArray(officialExternalChannelCatalog.entries) + ? [...officialExternalChannelCatalog.entries] + : []; if (!fs.existsSync(extensionsRoot)) { return { entries }; } diff --git a/src/channels/plugins/catalog.ts b/src/channels/plugins/catalog.ts index bb7451257ff..50fbc9606d5 100644 --- a/src/channels/plugins/catalog.ts +++ b/src/channels/plugins/catalog.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import officialExternalChannelCatalog from "../../../scripts/lib/official-external-channel-catalog.json" with { type: "json" }; import { MANIFEST_KEY } from "../../compat/legacy-names.js"; import { resolveOpenClawPackageRootSync } from "../../infra/openclaw-root.js"; import { listChannelCatalogEntries } from "../../plugins/channel-catalog-registry.js"; @@ -162,7 +163,9 @@ function resolveOfficialCatalogPaths(options: CatalogOptions): string[] { } function loadOfficialCatalogEntries(options: CatalogOptions): ChannelPluginCatalogEntry[] { - return loadCatalogEntriesFromPaths(resolveOfficialCatalogPaths(options)) + const builtInEntries = parseCatalogEntries(officialExternalChannelCatalog); + const fileEntries = loadCatalogEntriesFromPaths(resolveOfficialCatalogPaths(options)); + return [...builtInEntries, ...fileEntries] .map((entry) => buildExternalCatalogEntry(entry)) .filter((entry): entry is ChannelPluginCatalogEntry => Boolean(entry)); } diff --git a/src/channels/plugins/contracts/channel-catalog.contract.test.ts b/src/channels/plugins/contracts/channel-catalog.contract.test.ts index ffca2527625..8159ae77970 100644 --- a/src/channels/plugins/contracts/channel-catalog.contract.test.ts +++ b/src/channels/plugins/contracts/channel-catalog.contract.test.ts @@ -36,3 +36,9 @@ describeOfficialFallbackChannelCatalogContract({ externalNpmSpec: "@vendor/whatsapp-fork", externalLabel: "WhatsApp Fork", }); + +describeChannelCatalogEntryContract({ + channelId: "wecom", + npmSpec: "@wecom/wecom-openclaw-plugin", + alias: "wework", +}); diff --git a/src/commands/onboarding-plugin-install.test.ts b/src/commands/onboarding-plugin-install.test.ts index ed3f18f6dd7..528c278478a 100644 --- a/src/commands/onboarding-plugin-install.test.ts +++ b/src/commands/onboarding-plugin-install.test.ts @@ -52,7 +52,7 @@ describe("ensureOnboardingPluginInstalled", () => { withTimeout.mockImplementation(async (promise: Promise) => await promise); }); - it("passes pinned npm specs and expected integrity to npm installs with progress", async () => { + it("passes npm specs and optional expected integrity to npm installs with progress", async () => { installPluginFromNpmSpec.mockImplementation(async (params) => { params.logger?.info?.("Downloading demo-plugin…"); return { @@ -137,7 +137,7 @@ describe("ensureOnboardingPluginInstalled", () => { ); }); - it("does not offer npm installs without an exact version and integrity pin", async () => { + it("offers registry npm specs without requiring an exact version or integrity pin", async () => { let captured: | { options: Array<{ value: "npm" | "local" | "skip"; label: string; hint?: string }>; @@ -163,8 +163,11 @@ describe("ensureOnboardingPluginInstalled", () => { runtime: {} as never, }); - expect(captured?.options).toEqual([{ value: "skip", label: "Skip for now" }]); - expect(captured?.initialValue).toBe("skip"); + expect(captured?.options).toEqual([ + { value: "npm", label: "Download from npm (@demo/plugin)" }, + { value: "skip", label: "Skip for now" }, + ]); + expect(captured?.initialValue).toBe("npm"); expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); }); diff --git a/src/commands/onboarding-plugin-install.ts b/src/commands/onboarding-plugin-install.ts index 0ffd53699e0..ced610a7c8a 100644 --- a/src/commands/onboarding-plugin-install.ts +++ b/src/commands/onboarding-plugin-install.ts @@ -191,14 +191,13 @@ function resolveBundledLocalPath(params: { ); } -function resolvePinnedNpmSpecForOnboarding(install: PluginPackageInstall): string | null { +function resolveNpmSpecForOnboarding(install: PluginPackageInstall): string | null { const npmSpec = install.npmSpec?.trim(); - const expectedIntegrity = install.expectedIntegrity?.trim(); - if (!npmSpec || !expectedIntegrity) { + if (!npmSpec) { return null; } const parsed = parseRegistryNpmSpec(npmSpec); - return parsed?.selectorKind === "exact-version" ? npmSpec : null; + return parsed ? npmSpec : null; } function resolveInstallDefaultChoice(params: { @@ -241,7 +240,7 @@ async function promptInstallChoice(params: { defaultChoice: InstallChoice; prompter: WizardPrompter; }): Promise { - const npmSpec = resolvePinnedNpmSpecForOnboarding(params.entry.install); + const npmSpec = resolveNpmSpecForOnboarding(params.entry.install); const safeLabel = sanitizeTerminalText(params.entry.label); const safeNpmSpec = npmSpec ? sanitizeTerminalText(npmSpec) : null; const safeLocalPath = params.localPath ? sanitizeTerminalText(params.localPath) : null; @@ -399,7 +398,7 @@ export async function ensureOnboardingPluginInstalled(params: { workspaceDir, allowLocal, }); - const npmSpec = resolvePinnedNpmSpecForOnboarding(entry.install); + const npmSpec = resolveNpmSpecForOnboarding(entry.install); const defaultChoice = resolveInstallDefaultChoice({ cfg: next, entry, diff --git a/src/plugins/provider-install-catalog.test.ts b/src/plugins/provider-install-catalog.test.ts index c7c4ed1d997..25658e9a96b 100644 --- a/src/plugins/provider-install-catalog.test.ts +++ b/src/plugins/provider-install-catalog.test.ts @@ -219,6 +219,60 @@ describe("provider install catalog", () => { }); }); + it("exposes trusted registry npm specs without requiring an exact version or integrity pin", () => { + discoverOpenClawPlugins.mockReturnValue({ + candidates: [ + { + idHint: "vllm", + origin: "config", + rootDir: "/Users/test/.openclaw/extensions/vllm", + source: "/Users/test/.openclaw/extensions/vllm/index.js", + packageName: "@openclaw/vllm", + packageDir: "/Users/test/.openclaw/extensions/vllm", + packageManifest: { + install: { + npmSpec: "@openclaw/vllm", + }, + }, + }, + ], + diagnostics: [], + }); + loadPluginManifest.mockReturnValue({ + ok: true, + manifestPath: "/Users/test/.openclaw/extensions/vllm/openclaw.plugin.json", + manifest: { + id: "vllm", + configSchema: { + type: "object", + }, + }, + }); + resolveManifestProviderAuthChoices.mockReturnValue([ + { + pluginId: "vllm", + providerId: "vllm", + methodId: "server", + choiceId: "vllm", + choiceLabel: "vLLM", + }, + ]); + + expect(resolveProviderInstallCatalogEntry("vllm")).toEqual({ + pluginId: "vllm", + providerId: "vllm", + methodId: "server", + choiceId: "vllm", + choiceLabel: "vLLM", + label: "vLLM", + origin: "config", + install: { + npmSpec: "@openclaw/vllm", + defaultChoice: "npm", + }, + }); + }); + it("does not expose npm install specs from untrusted package metadata", () => { discoverOpenClawPlugins.mockReturnValue({ candidates: [ diff --git a/src/plugins/provider-install-catalog.ts b/src/plugins/provider-install-catalog.ts index c93810f6be6..41211ab3b4f 100644 --- a/src/plugins/provider-install-catalog.ts +++ b/src/plugins/provider-install-catalog.ts @@ -53,7 +53,7 @@ function resolvePluginManifest( return manifest.ok ? manifest : null; } -function resolveTrustedPinnedNpmSpec(params: { +function resolveTrustedNpmSpec(params: { origin: PluginOrigin; install?: PluginPackageInstall; }): string | undefined { @@ -61,12 +61,11 @@ function resolveTrustedPinnedNpmSpec(params: { return undefined; } const npmSpec = params.install?.npmSpec?.trim(); - const expectedIntegrity = params.install?.expectedIntegrity?.trim(); - if (!npmSpec || !expectedIntegrity) { + if (!npmSpec) { return undefined; } const parsed = parseRegistryNpmSpec(npmSpec); - return parsed?.selectorKind === "exact-version" ? npmSpec : undefined; + return parsed ? npmSpec : undefined; } function resolveInstallInfo(params: { @@ -75,7 +74,7 @@ function resolveInstallInfo(params: { packageDir?: string; workspaceDir?: string; }): PluginPackageInstall | null { - const npmSpec = resolveTrustedPinnedNpmSpec({ + const npmSpec = resolveTrustedNpmSpec({ origin: params.origin, install: params.install, }); diff --git a/test/official-channel-catalog.test.ts b/test/official-channel-catalog.test.ts index 594f5a34b6a..11286e31de6 100644 --- a/test/official-channel-catalog.test.ts +++ b/test/official-channel-catalog.test.ts @@ -68,8 +68,21 @@ describe("buildOfficialChannelCatalog", () => { }, }); - expect(buildOfficialChannelCatalog({ repoRoot })).toEqual({ - entries: [ + expect(buildOfficialChannelCatalog({ repoRoot }).entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "@wecom/wecom-openclaw-plugin", + openclaw: expect.objectContaining({ + channel: expect.objectContaining({ + id: "wecom", + label: "WeCom", + }), + install: { + npmSpec: "@wecom/wecom-openclaw-plugin", + defaultChoice: "npm", + }, + }), + }), { name: "@openclaw/whatsapp", version: "2026.3.23", @@ -89,8 +102,8 @@ describe("buildOfficialChannelCatalog", () => { }, }, }, - ], - }); + ]), + ); }); it("writes the official catalog under dist", () => { @@ -118,8 +131,11 @@ describe("buildOfficialChannelCatalog", () => { const outputPath = path.join(repoRoot, OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH); expect(fs.existsSync(outputPath)).toBe(true); - expect(JSON.parse(fs.readFileSync(outputPath, "utf8"))).toEqual({ - entries: [ + expect(JSON.parse(fs.readFileSync(outputPath, "utf8")).entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "@wecom/wecom-openclaw-plugin", + }), { name: "@openclaw/whatsapp", openclaw: { @@ -135,7 +151,7 @@ describe("buildOfficialChannelCatalog", () => { }, }, }, - ], - }); + ]), + ); }); });