From d4268b1b2b746b17d60d315223e56a1e387e6bcf Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 2 May 2026 13:20:26 -0700 Subject: [PATCH] fix(plugins): catalog externalized npm installs --- CHANGELOG.md | 1 + package.json | 6 +- .../official-external-channel-catalog.json | 351 ++++++++++++++++++ .../lib/official-external-plugin-catalog.json | 142 +++++++ .../official-external-provider-catalog.json | 30 ++ scripts/lib/tsgo-sparse-guard.mjs | 8 + scripts/release-check.ts | 3 + scripts/write-official-channel-catalog.mjs | 10 +- src/channels/plugins/catalog.ts | 4 +- src/cli/plugins-location-bridges.test.ts | 34 +- src/cli/plugins-location-bridges.ts | 30 +- .../missing-configured-plugin-install.test.ts | 114 ++++++ .../missing-configured-plugin-install.ts | 65 +++- .../official-external-plugin-catalog.ts | 173 +++++++++ src/plugins/provider-install-catalog.ts | 93 ++++- test/official-channel-catalog.test.ts | 72 ++-- test/release-check.test.ts | 6 + 17 files changed, 1100 insertions(+), 42 deletions(-) create mode 100644 scripts/lib/official-external-plugin-catalog.json create mode 100644 scripts/lib/official-external-provider-catalog.json create mode 100644 src/plugins/official-external-plugin-catalog.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ec4833d4e0b..efb6a7b0894 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Plugins/externalization: add official npm-first catalogs for externalized channel, provider, and generic plugins, keep unpublished ACPX/Google Chat/LINE bundled, and make missing-plugin repair honor npm-first metadata while ClawHub pack files roll out. Thanks @vincentkoc. - Agents/sessions: preserve terminal lifecycle state when final run metadata persists from a stale in-memory snapshot, preventing `main` sessions from staying stuck as running after completed or timed-out turns. - Gateway/CLI: make `openclaw gateway start` repair stale managed service definitions that point at old OpenClaw versions, missing binaries, or temporary installer paths before starting. - Status: show the `openai-codex` OAuth profile for `openai/gpt-*` sessions running through the native Codex runtime instead of reporting auth as unknown. (#76197) Thanks @mbelinky. diff --git a/package.json b/package.json index b4e900561ca..e0cdc0c9086 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,6 @@ "!dist/.runtime-postbuildstamp", "!dist/**/*.map", "!dist/plugin-sdk/.tsbuildinfo", - "!dist/extensions/acpx/**", "!dist/extensions/node_modules/**", "!dist/extensions/*/node_modules/**", "!dist/extensions/bluebubbles/**", @@ -43,8 +42,6 @@ "!dist/extensions/discord/**", "!dist/extensions/feishu/**", "!dist/extensions/google-meet/**", - "!dist/extensions/googlechat/**", - "!dist/extensions/line/**", "!dist/extensions/lobster/**", "!dist/extensions/matrix/**", "!dist/extensions/mattermost/**", @@ -82,6 +79,9 @@ "skills/", "scripts/npm-runner.mjs", "scripts/preinstall-package-manager-warning.mjs", + "scripts/lib/official-external-channel-catalog.json", + "scripts/lib/official-external-plugin-catalog.json", + "scripts/lib/official-external-provider-catalog.json", "scripts/lib/package-dist-imports.mjs", "scripts/postinstall-bundled-plugins.mjs", "scripts/windows-cmd-helpers.mjs" diff --git a/scripts/lib/official-external-channel-catalog.json b/scripts/lib/official-external-channel-catalog.json index df8724db803..0e302323270 100644 --- a/scripts/lib/official-external-channel-catalog.json +++ b/scripts/lib/official-external-channel-catalog.json @@ -47,6 +47,357 @@ "expectedIntegrity": "sha512-lYmBrU71ox3v7dzRqaltvzTXPcMjjgYrNqpBj5HIBkXgEFkXRRG8wplXg9Fub41/FjsSPn3WAbYpdTc+k+jsHg==" } } + }, + { + "name": "@openclaw/bluebubbles", + "description": "OpenClaw BlueBubbles channel plugin", + "source": "official", + "kind": "channel", + "openclaw": { + "channel": { + "id": "bluebubbles", + "label": "BlueBubbles", + "selectionLabel": "BlueBubbles (macOS app)", + "detailLabel": "BlueBubbles", + "docsPath": "/channels/bluebubbles", + "docsLabel": "bluebubbles", + "blurb": "iMessage via the BlueBubbles mac app + REST API.", + "aliases": ["bb"], + "preferOver": ["imessage"], + "systemImage": "bubble.left.and.text.bubble.right", + "order": 75 + }, + "install": { + "npmSpec": "@openclaw/bluebubbles", + "defaultChoice": "npm", + "minHostVersion": ">=2026.4.10" + } + } + }, + { + "name": "@openclaw/discord", + "description": "OpenClaw Discord channel plugin", + "source": "official", + "kind": "channel", + "openclaw": { + "channel": { + "id": "discord", + "label": "Discord", + "selectionLabel": "Discord (Bot API)", + "detailLabel": "Discord Bot", + "docsPath": "/channels/discord", + "docsLabel": "discord", + "blurb": "very well supported right now.", + "systemImage": "bubble.left.and.bubble.right", + "markdownCapable": true, + "preferSessionLookupForAnnounceTarget": true + }, + "install": { + "npmSpec": "@openclaw/discord", + "defaultChoice": "npm", + "minHostVersion": ">=2026.4.10" + } + } + }, + { + "name": "@openclaw/feishu", + "description": "OpenClaw Feishu/Lark channel plugin", + "source": "official", + "kind": "channel", + "openclaw": { + "channel": { + "id": "feishu", + "label": "Feishu", + "selectionLabel": "Feishu/Lark (飞书)", + "docsPath": "/channels/feishu", + "docsLabel": "feishu", + "blurb": "飞书/Lark enterprise messaging with doc/wiki/drive tools.", + "aliases": ["lark"], + "order": 35, + "quickstartAllowFrom": true + }, + "install": { + "npmSpec": "@openclaw/feishu", + "defaultChoice": "npm", + "minHostVersion": ">=2026.4.25" + } + } + }, + { + "name": "@openclaw/matrix", + "description": "OpenClaw Matrix channel plugin", + "source": "official", + "kind": "channel", + "openclaw": { + "channel": { + "id": "matrix", + "label": "Matrix", + "selectionLabel": "Matrix (plugin)", + "docsPath": "/channels/matrix", + "docsLabel": "matrix", + "blurb": "open protocol; install the plugin to enable.", + "order": 70, + "quickstartAllowFrom": true + }, + "install": { + "npmSpec": "@openclaw/matrix", + "defaultChoice": "npm", + "minHostVersion": ">=2026.4.10", + "allowInvalidConfigRecovery": true + } + } + }, + { + "name": "@openclaw/mattermost", + "description": "OpenClaw Mattermost channel plugin", + "source": "official", + "kind": "channel", + "openclaw": { + "channel": { + "id": "mattermost", + "label": "Mattermost", + "selectionLabel": "Mattermost (plugin)", + "docsPath": "/channels/mattermost", + "docsLabel": "mattermost", + "blurb": "self-hosted Slack-style chat; install the plugin to enable.", + "order": 65 + }, + "install": { + "npmSpec": "@openclaw/mattermost", + "defaultChoice": "npm", + "minHostVersion": ">=2026.4.10" + } + } + }, + { + "name": "@openclaw/msteams", + "description": "OpenClaw Microsoft Teams channel plugin", + "source": "official", + "kind": "channel", + "openclaw": { + "channel": { + "id": "msteams", + "label": "Microsoft Teams", + "selectionLabel": "Microsoft Teams (Teams SDK)", + "docsPath": "/channels/msteams", + "docsLabel": "msteams", + "blurb": "Teams SDK; enterprise support.", + "aliases": ["teams"], + "order": 60 + }, + "install": { + "npmSpec": "@openclaw/msteams", + "defaultChoice": "npm", + "minHostVersion": ">=2026.4.10" + } + } + }, + { + "name": "@openclaw/nextcloud-talk", + "description": "OpenClaw Nextcloud Talk channel plugin", + "source": "official", + "kind": "channel", + "openclaw": { + "channel": { + "id": "nextcloud-talk", + "label": "Nextcloud Talk", + "selectionLabel": "Nextcloud Talk (self-hosted)", + "docsPath": "/channels/nextcloud-talk", + "docsLabel": "nextcloud-talk", + "blurb": "Self-hosted chat via Nextcloud Talk webhook bots.", + "aliases": ["nc-talk", "nc"], + "order": 65, + "quickstartAllowFrom": true + }, + "install": { + "npmSpec": "@openclaw/nextcloud-talk", + "defaultChoice": "npm", + "minHostVersion": ">=2026.4.10" + } + } + }, + { + "name": "@openclaw/nostr", + "description": "OpenClaw Nostr channel plugin for NIP-04 encrypted DMs", + "source": "official", + "kind": "channel", + "openclaw": { + "channel": { + "id": "nostr", + "label": "Nostr", + "selectionLabel": "Nostr (NIP-04 DMs)", + "docsPath": "/channels/nostr", + "docsLabel": "nostr", + "blurb": "Decentralized protocol; encrypted DMs via NIP-04.", + "order": 55, + "quickstartAllowFrom": true + }, + "install": { + "npmSpec": "@openclaw/nostr", + "defaultChoice": "npm", + "minHostVersion": ">=2026.4.10" + } + } + }, + { + "name": "@openclaw/qqbot", + "description": "OpenClaw QQ Bot channel plugin", + "source": "official", + "kind": "channel", + "openclaw": { + "channel": { + "id": "qqbot", + "label": "QQ Bot", + "selectionLabel": "QQ Bot (Official API)", + "detailLabel": "QQ Bot", + "docsPath": "/channels/qqbot", + "docsLabel": "qqbot", + "blurb": "connect to QQ via official QQ Bot API with group chat and direct message support.", + "systemImage": "bubble.left.and.bubble.right" + }, + "install": { + "npmSpec": "@openclaw/qqbot", + "defaultChoice": "npm", + "minHostVersion": ">=2026.4.10" + } + } + }, + { + "name": "@openclaw/synology-chat", + "description": "Synology Chat channel plugin for OpenClaw", + "source": "official", + "kind": "channel", + "openclaw": { + "channel": { + "id": "synology-chat", + "label": "Synology Chat", + "selectionLabel": "Synology Chat (Webhook)", + "docsPath": "/channels/synology-chat", + "docsLabel": "synology-chat", + "blurb": "Connect your Synology NAS Chat to OpenClaw with full agent capabilities.", + "order": 90 + }, + "install": { + "npmSpec": "@openclaw/synology-chat", + "defaultChoice": "npm", + "minHostVersion": ">=2026.4.10" + } + } + }, + { + "name": "@openclaw/tlon", + "description": "OpenClaw Tlon/Urbit channel plugin", + "source": "official", + "kind": "channel", + "openclaw": { + "channel": { + "id": "tlon", + "label": "Tlon", + "selectionLabel": "Tlon (Urbit)", + "docsPath": "/channels/tlon", + "docsLabel": "tlon", + "blurb": "decentralized messaging on Urbit; install the plugin to enable.", + "order": 90, + "quickstartAllowFrom": true + }, + "install": { + "npmSpec": "@openclaw/tlon", + "defaultChoice": "npm", + "minHostVersion": ">=2026.4.10" + } + } + }, + { + "name": "@openclaw/twitch", + "description": "OpenClaw Twitch channel plugin", + "source": "official", + "kind": "channel", + "openclaw": { + "channel": { + "id": "twitch", + "label": "Twitch", + "selectionLabel": "Twitch (Chat)", + "docsPath": "/channels/twitch", + "blurb": "Twitch chat integration", + "aliases": ["twitch-chat"] + }, + "install": { + "npmSpec": "@openclaw/twitch", + "defaultChoice": "npm", + "minHostVersion": ">=2026.4.10" + } + } + }, + { + "name": "@openclaw/whatsapp", + "description": "OpenClaw WhatsApp channel plugin", + "source": "official", + "kind": "channel", + "openclaw": { + "channel": { + "id": "whatsapp", + "label": "WhatsApp", + "selectionLabel": "WhatsApp (QR link)", + "detailLabel": "WhatsApp Web", + "docsPath": "/channels/whatsapp", + "docsLabel": "whatsapp", + "blurb": "works with your own number; recommend a separate phone + eSIM.", + "systemImage": "message" + }, + "install": { + "npmSpec": "@openclaw/whatsapp", + "defaultChoice": "npm", + "minHostVersion": ">=2026.4.25" + } + } + }, + { + "name": "@openclaw/zalo", + "description": "OpenClaw Zalo channel plugin", + "source": "official", + "kind": "channel", + "openclaw": { + "channel": { + "id": "zalo", + "label": "Zalo", + "selectionLabel": "Zalo (Bot API)", + "docsPath": "/channels/zalo", + "docsLabel": "zalo", + "blurb": "Vietnam-focused messaging platform with Bot API.", + "aliases": ["zl"], + "order": 80, + "quickstartAllowFrom": true + }, + "install": { + "npmSpec": "@openclaw/zalo", + "defaultChoice": "npm", + "minHostVersion": ">=2026.4.10" + } + } + }, + { + "name": "@openclaw/zalouser", + "description": "OpenClaw Zalo Personal Account plugin via native zca-js integration", + "source": "official", + "kind": "channel", + "openclaw": { + "channel": { + "id": "zalouser", + "label": "Zalo Personal", + "selectionLabel": "Zalo (Personal Account)", + "docsPath": "/channels/zalouser", + "docsLabel": "zalouser", + "blurb": "Zalo personal account via QR code login.", + "aliases": ["zlu"], + "order": 85, + "quickstartAllowFrom": false + }, + "install": { + "npmSpec": "@openclaw/zalouser", + "defaultChoice": "npm", + "minHostVersion": ">=2026.4.10" + } + } } ] } diff --git a/scripts/lib/official-external-plugin-catalog.json b/scripts/lib/official-external-plugin-catalog.json new file mode 100644 index 00000000000..d29d85ebda7 --- /dev/null +++ b/scripts/lib/official-external-plugin-catalog.json @@ -0,0 +1,142 @@ +{ + "entries": [ + { + "name": "@openclaw/brave-plugin", + "description": "OpenClaw Brave plugin", + "source": "official", + "kind": "plugin", + "openclaw": { + "plugin": { + "id": "brave", + "label": "Brave" + }, + "install": { + "npmSpec": "@openclaw/brave-plugin", + "defaultChoice": "npm", + "minHostVersion": ">=2026.4.10" + } + } + }, + { + "name": "@openclaw/diagnostics-otel", + "description": "OpenClaw diagnostics OpenTelemetry exporter", + "source": "official", + "kind": "plugin", + "openclaw": { + "plugin": { + "id": "diagnostics-otel", + "label": "Diagnostics OpenTelemetry" + }, + "install": { + "clawhubSpec": "clawhub:@openclaw/diagnostics-otel", + "npmSpec": "@openclaw/diagnostics-otel", + "defaultChoice": "npm", + "minHostVersion": ">=2026.4.25" + } + } + }, + { + "name": "@openclaw/diagnostics-prometheus", + "description": "OpenClaw diagnostics Prometheus exporter", + "source": "official", + "kind": "plugin", + "openclaw": { + "plugin": { + "id": "diagnostics-prometheus", + "label": "Diagnostics Prometheus" + }, + "install": { + "clawhubSpec": "clawhub:@openclaw/diagnostics-prometheus", + "npmSpec": "@openclaw/diagnostics-prometheus", + "defaultChoice": "npm", + "minHostVersion": ">=2026.4.25" + } + } + }, + { + "name": "@openclaw/diffs", + "description": "OpenClaw diff viewer plugin", + "source": "official", + "kind": "plugin", + "openclaw": { + "plugin": { + "id": "diffs", + "label": "Diffs" + }, + "install": { + "npmSpec": "@openclaw/diffs", + "defaultChoice": "npm", + "minHostVersion": ">=2026.4.30" + } + } + }, + { + "name": "@openclaw/google-meet", + "description": "OpenClaw Google Meet participant plugin", + "source": "official", + "kind": "plugin", + "openclaw": { + "plugin": { + "id": "google-meet", + "label": "Google Meet" + }, + "install": { + "npmSpec": "@openclaw/google-meet", + "defaultChoice": "npm", + "minHostVersion": ">=2026.4.20" + } + } + }, + { + "name": "@openclaw/lobster", + "description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)", + "source": "official", + "kind": "plugin", + "openclaw": { + "plugin": { + "id": "lobster", + "label": "Lobster" + }, + "install": { + "npmSpec": "@openclaw/lobster", + "defaultChoice": "npm", + "minHostVersion": ">=2026.4.25" + } + } + }, + { + "name": "@openclaw/memory-lancedb", + "description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture", + "source": "official", + "kind": "plugin", + "openclaw": { + "plugin": { + "id": "memory-lancedb", + "label": "Memory LanceDB" + }, + "install": { + "npmSpec": "@openclaw/memory-lancedb", + "defaultChoice": "npm", + "minHostVersion": ">=2026.4.10" + } + } + }, + { + "name": "@openclaw/voice-call", + "description": "OpenClaw voice-call plugin", + "source": "official", + "kind": "plugin", + "openclaw": { + "plugin": { + "id": "voice-call", + "label": "Voice Call" + }, + "install": { + "npmSpec": "@openclaw/voice-call", + "defaultChoice": "npm", + "minHostVersion": ">=2026.4.10" + } + } + } + ] +} diff --git a/scripts/lib/official-external-provider-catalog.json b/scripts/lib/official-external-provider-catalog.json new file mode 100644 index 00000000000..86124eb8c57 --- /dev/null +++ b/scripts/lib/official-external-provider-catalog.json @@ -0,0 +1,30 @@ +{ + "entries": [ + { + "name": "@openclaw/codex", + "description": "OpenClaw Codex harness and model provider plugin", + "source": "official", + "kind": "provider", + "openclaw": { + "plugin": { + "id": "codex", + "label": "Codex" + }, + "providers": [ + { + "id": "codex", + "name": "Codex", + "docs": "/providers/models", + "categories": ["cloud", "llm"], + "authChoices": [] + } + ], + "install": { + "npmSpec": "@openclaw/codex", + "defaultChoice": "npm", + "minHostVersion": ">=2026.5.1-beta.1" + } + } + } + ] +} diff --git a/scripts/lib/tsgo-sparse-guard.mjs b/scripts/lib/tsgo-sparse-guard.mjs index 28e2a670bda..ee0a5e000e2 100644 --- a/scripts/lib/tsgo-sparse-guard.mjs +++ b/scripts/lib/tsgo-sparse-guard.mjs @@ -26,6 +26,14 @@ const CORE_PROD_REQUIRED_PATHS = [ path: "scripts/lib/official-external-channel-catalog.json", whenPresent: "src/channels/plugins/catalog.ts", }, + { + path: "scripts/lib/official-external-plugin-catalog.json", + whenPresent: "src/plugins/official-external-plugin-catalog.ts", + }, + { + path: "scripts/lib/official-external-provider-catalog.json", + whenPresent: "src/plugins/official-external-plugin-catalog.ts", + }, { path: "scripts/lib/plugin-sdk-entrypoints.json", whenPresent: "src/plugin-sdk/entrypoints.ts", diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 43260c1a8fd..ff2917aa4bf 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -55,6 +55,9 @@ const requiredPathGroups = [ ...WORKSPACE_TEMPLATE_PACK_PATHS, "scripts/npm-runner.mjs", "scripts/preinstall-package-manager-warning.mjs", + "scripts/lib/official-external-channel-catalog.json", + "scripts/lib/official-external-plugin-catalog.json", + "scripts/lib/official-external-provider-catalog.json", "scripts/lib/package-dist-imports.mjs", "scripts/postinstall-bundled-plugins.mjs", "dist/plugin-sdk/compat.js", diff --git a/scripts/write-official-channel-catalog.mjs b/scripts/write-official-channel-catalog.mjs index e2d97d6e3f3..ab40d233b9f 100644 --- a/scripts/write-official-channel-catalog.mjs +++ b/scripts/write-official-channel-catalog.mjs @@ -53,6 +53,10 @@ function buildCatalogEntry(packageJson) { }; } +function getCatalogChannelId(entry) { + return trimString(entry?.openclaw?.channel?.id) || trimString(entry?.name); +} + export function buildOfficialChannelCatalog(params = {}) { const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd(); const extensionsRoot = path.join(repoRoot, "extensions"); @@ -74,7 +78,11 @@ export function buildOfficialChannelCatalog(params = {}) { try { const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); const entry = buildCatalogEntry(packageJson); - if (entry) { + const channelId = entry ? getCatalogChannelId(entry) : ""; + const alreadyPresent = channelId + ? entries.some((existing) => getCatalogChannelId(existing) === channelId) + : false; + if (entry && !alreadyPresent) { entries.push(entry); } } catch { diff --git a/src/channels/plugins/catalog.ts b/src/channels/plugins/catalog.ts index fd94cf8cc55..3dfc228b44e 100644 --- a/src/channels/plugins/catalog.ts +++ b/src/channels/plugins/catalog.ts @@ -1,6 +1,5 @@ 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"; @@ -10,6 +9,7 @@ import { } from "../../plugins/install-source-info.js"; import type { OpenClawPackageManifest } from "../../plugins/manifest.js"; import type { PluginPackageChannel, PluginPackageInstall } from "../../plugins/manifest.js"; +import { listOfficialExternalChannelCatalogEntries } from "../../plugins/official-external-plugin-catalog.js"; import type { PluginOrigin } from "../../plugins/plugin-origin.types.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { isRecord, resolveConfigDir, resolveUserPath } from "../../utils.js"; @@ -196,7 +196,7 @@ function resolveOfficialCatalogPaths(options: CatalogOptions): string[] { } function loadOfficialCatalogEntries(options: CatalogOptions): ChannelPluginCatalogEntry[] { - const builtInEntries = parseCatalogEntries(officialExternalChannelCatalog); + const builtInEntries = listOfficialExternalChannelCatalogEntries(); const officialPaths = resolveOfficialCatalogPaths(options); const fileEntries = options.officialCatalogPaths && options.officialCatalogPaths.length > 0 diff --git a/src/cli/plugins-location-bridges.test.ts b/src/cli/plugins-location-bridges.test.ts index 033f810a017..cf3fa38808c 100644 --- a/src/cli/plugins-location-bridges.test.ts +++ b/src/cli/plugins-location-bridges.test.ts @@ -117,12 +117,13 @@ describe("listPersistedBundledPluginLocationBridges", () => { pluginId: "diagnostics-otel", preferredSource: "npm", npmSpec: "@openclaw/diagnostics-otel", + clawhubSpec: "clawhub:@openclaw/diagnostics-otel", channelIds: ["diagnostics-otel"], }, ]); }); - it("does not create a relocation bridge without npm metadata", async () => { + it("uses official external catalog metadata when the persisted bundled row lacks npm metadata", async () => { readPersistedInstalledPluginIndexMock.mockResolvedValue( makeIndex({ pluginId: "diagnostics-otel", @@ -149,6 +150,37 @@ describe("listPersistedBundledPluginLocationBridges", () => { makeRegistry("diagnostics-otel"), ); + await expect(listPersistedBundledPluginLocationBridges({})).resolves.toEqual([ + { + bundledPluginId: "diagnostics-otel", + pluginId: "diagnostics-otel", + preferredSource: "npm", + npmSpec: "@openclaw/diagnostics-otel", + clawhubSpec: "clawhub:@openclaw/diagnostics-otel", + channelIds: ["diagnostics-otel"], + }, + ]); + }); + + it("does not create a relocation bridge without persisted or official install metadata", async () => { + readPersistedInstalledPluginIndexMock.mockResolvedValue( + makeIndex({ + pluginId: "local-only", + manifestPath: "/app/dist/extensions/local-only/openclaw.plugin.json", + manifestHash: "hash", + source: "/app/dist/extensions/local-only/index.js", + rootDir: "/app/dist/extensions/local-only", + origin: "bundled", + enabled: true, + startup: startupInfo, + compat: [], + packageInstall: { + warnings: [], + }, + }), + ); + loadPluginManifestRegistryForInstalledIndexMock.mockReturnValue(makeRegistry("local-only")); + await expect(listPersistedBundledPluginLocationBridges({})).resolves.toEqual([]); }); }); diff --git a/src/cli/plugins-location-bridges.ts b/src/cli/plugins-location-bridges.ts index 971206d4ccc..51fd5f66f1f 100644 --- a/src/cli/plugins-location-bridges.ts +++ b/src/cli/plugins-location-bridges.ts @@ -3,6 +3,11 @@ import { readPersistedInstalledPluginIndex } from "../plugins/installed-plugin-i import type { InstalledPluginIndexRecord } from "../plugins/installed-plugin-index.js"; import { loadPluginManifestRegistryForInstalledIndex } from "../plugins/manifest-registry-installed.js"; import type { PluginManifestRecord } from "../plugins/manifest-registry.js"; +import { + getOfficialExternalPluginCatalogEntry, + getOfficialExternalPluginCatalogManifest, + resolveOfficialExternalPluginInstall, +} from "../plugins/official-external-plugin-catalog.js"; function buildBridgeFromPersistedBundledRecord( record: InstalledPluginIndexRecord, @@ -14,17 +19,32 @@ function buildBridgeFromPersistedBundledRecord( if (record.origin !== "bundled" || !record.enabled) { return null; } - const npmSpec = record.packageInstall?.npm?.spec; - if (!npmSpec) { + const officialEntry = getOfficialExternalPluginCatalogEntry(record.pluginId); + const officialInstall = officialEntry + ? resolveOfficialExternalPluginInstall(officialEntry) + : null; + const npmSpec = officialInstall?.npmSpec?.trim() ?? record.packageInstall?.npm?.spec; + const clawhubSpec = officialInstall?.clawhubSpec?.trim(); + if (!npmSpec && !clawhubSpec) { return null; } + const officialChannelId = officialEntry + ? getOfficialExternalPluginCatalogManifest(officialEntry)?.channel?.id?.trim() + : undefined; + const channelIds = manifest?.channels.length + ? manifest.channels + : officialChannelId + ? [officialChannelId] + : []; return { bundledPluginId: record.pluginId, pluginId: record.pluginId, - preferredSource: "npm", - npmSpec, + preferredSource: + officialInstall?.defaultChoice === "clawhub" && clawhubSpec ? "clawhub" : "npm", + ...(npmSpec ? { npmSpec } : {}), + ...(clawhubSpec ? { clawhubSpec } : {}), ...(record.enabledByDefault ? { enabledByDefault: true } : {}), - ...(manifest?.channels.length ? { channelIds: manifest.channels } : {}), + ...(channelIds.length ? { channelIds } : {}), }; } diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts index a17f738384c..cd5ea1dbcc9 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts @@ -4,8 +4,16 @@ const mocks = vi.hoisted(() => ({ installPluginFromClawHub: vi.fn(), installPluginFromNpmSpec: vi.fn(), listChannelPluginCatalogEntries: vi.fn(), + listOfficialExternalPluginCatalogEntries: vi.fn(), loadInstalledPluginIndexInstallRecords: vi.fn(), loadPluginMetadataSnapshot: vi.fn(), + resolveOfficialExternalPluginId: vi.fn((entry: { id?: string }) => entry.id), + resolveOfficialExternalPluginInstall: vi.fn( + (entry: { install?: unknown }) => entry.install ?? null, + ), + resolveOfficialExternalPluginLabel: vi.fn( + (entry: { label?: string; id?: string }) => entry.label ?? entry.id ?? "plugin", + ), resolveDefaultPluginExtensionsDir: vi.fn(() => "/tmp/openclaw-plugins"), resolveProviderInstallCatalogEntries: vi.fn(), updateNpmInstalledPlugins: vi.fn(), @@ -42,6 +50,13 @@ vi.mock("../../../plugins/plugin-metadata-snapshot.js", () => ({ loadPluginMetadataSnapshot: mocks.loadPluginMetadataSnapshot, })); +vi.mock("../../../plugins/official-external-plugin-catalog.js", () => ({ + listOfficialExternalPluginCatalogEntries: mocks.listOfficialExternalPluginCatalogEntries, + resolveOfficialExternalPluginId: mocks.resolveOfficialExternalPluginId, + resolveOfficialExternalPluginInstall: mocks.resolveOfficialExternalPluginInstall, + resolveOfficialExternalPluginLabel: mocks.resolveOfficialExternalPluginLabel, +})); + vi.mock("../../../plugins/provider-install-catalog.js", () => ({ resolveProviderInstallCatalogEntries: mocks.resolveProviderInstallCatalogEntries, })); @@ -59,6 +74,7 @@ describe("repairMissingConfiguredPluginInstalls", () => { }); mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue({}); mocks.listChannelPluginCatalogEntries.mockReturnValue([]); + mocks.listOfficialExternalPluginCatalogEntries.mockReturnValue([]); mocks.resolveProviderInstallCatalogEntries.mockReturnValue([]); mocks.installPluginFromClawHub.mockResolvedValue({ ok: true, @@ -221,6 +237,104 @@ describe("repairMissingConfiguredPluginInstalls", () => { expect(result.warnings).toEqual([]); }); + it("honors npm-first catalog metadata for missing OpenClaw channel plugins", async () => { + mocks.installPluginFromNpmSpec.mockResolvedValueOnce({ + ok: true, + pluginId: "twitch", + targetDir: "/tmp/openclaw-plugins/twitch", + version: "2026.5.2", + npmResolution: { + name: "@openclaw/twitch", + version: "2026.5.2", + resolvedSpec: "@openclaw/twitch@2026.5.2", + integrity: "sha512-twitch", + resolvedAt: "2026-05-01T00:00:00.000Z", + }, + }); + mocks.listChannelPluginCatalogEntries.mockReturnValue([ + { + id: "twitch", + pluginId: "twitch", + meta: { label: "Twitch" }, + install: { + npmSpec: "@openclaw/twitch", + defaultChoice: "npm", + }, + }, + ]); + + const { repairMissingPluginInstallsForIds } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingPluginInstallsForIds({ + cfg: {}, + pluginIds: [], + channelIds: ["twitch"], + env: {}, + }); + + expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled(); + expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@openclaw/twitch", + expectedPluginId: "twitch", + }), + ); + expect(result.changes).toEqual([ + 'Installed missing configured plugin "twitch" from @openclaw/twitch.', + ]); + }); + + it("installs missing configured non-channel plugins from the official external catalog", async () => { + mocks.installPluginFromNpmSpec.mockResolvedValueOnce({ + ok: true, + pluginId: "diagnostics-otel", + targetDir: "/tmp/openclaw-plugins/diagnostics-otel", + version: "2026.5.2", + npmResolution: { + name: "@openclaw/diagnostics-otel", + version: "2026.5.2", + resolvedSpec: "@openclaw/diagnostics-otel@2026.5.2", + integrity: "sha512-otel", + resolvedAt: "2026-05-01T00:00:00.000Z", + }, + }); + mocks.listOfficialExternalPluginCatalogEntries.mockReturnValue([ + { + id: "diagnostics-otel", + label: "Diagnostics OpenTelemetry", + install: { + clawhubSpec: "clawhub:@openclaw/diagnostics-otel", + npmSpec: "@openclaw/diagnostics-otel", + defaultChoice: "npm", + }, + }, + ]); + + const { repairMissingConfiguredPluginInstalls } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingConfiguredPluginInstalls({ + cfg: { + plugins: { + entries: { + "diagnostics-otel": { enabled: true }, + }, + }, + }, + env: {}, + }); + + expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled(); + expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@openclaw/diagnostics-otel", + expectedPluginId: "diagnostics-otel", + }), + ); + expect(result.changes).toEqual([ + 'Installed missing configured plugin "diagnostics-otel" from @openclaw/diagnostics-otel.', + ]); + }); + it("installs a missing third-party downloadable plugin from npm only", async () => { mocks.installPluginFromNpmSpec.mockResolvedValueOnce({ ok: true, diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.ts b/src/commands/doctor/shared/missing-configured-plugin-install.ts index d6d17fc3aba..196b762b7e6 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.ts @@ -10,6 +10,13 @@ import { loadInstalledPluginIndexInstallRecords } from "../../../plugins/install import { writePersistedInstalledPluginIndexInstallRecords } from "../../../plugins/installed-plugin-index-records.js"; import { buildNpmResolutionInstallFields } from "../../../plugins/installs.js"; import { loadManifestMetadataSnapshot } from "../../../plugins/manifest-contract-eligibility.js"; +import type { PluginPackageInstall } from "../../../plugins/manifest.js"; +import { + listOfficialExternalPluginCatalogEntries, + resolveOfficialExternalPluginId, + resolveOfficialExternalPluginInstall, + resolveOfficialExternalPluginLabel, +} from "../../../plugins/official-external-plugin-catalog.js"; import { resolveProviderInstallCatalogEntries } from "../../../plugins/provider-install-catalog.js"; import { updateNpmInstalledPlugins } from "../../../plugins/update.js"; import { asObjectRecord } from "./object.js"; @@ -20,6 +27,7 @@ type DownloadableInstallCandidate = { npmSpec?: string; clawhubSpec?: string; expectedIntegrity?: string; + defaultChoice?: PluginPackageInstall["defaultChoice"]; }; function buildOpenClawClawHubSpec(npmSpec: string): string | undefined { @@ -37,6 +45,24 @@ function shouldFallbackClawHubToNpm(result: { ok: false; code?: string }): boole ); } +function normalizeInstallDefaultChoice( + value: PluginPackageInstall["defaultChoice"] | undefined, +): PluginPackageInstall["defaultChoice"] | undefined { + return value === "clawhub" || value === "npm" || value === "local" ? value : undefined; +} + +function resolveCandidateClawHubSpec(install: PluginPackageInstall): string | undefined { + const explicit = install.clawhubSpec?.trim(); + if (explicit) { + return explicit; + } + const npmSpec = install.npmSpec?.trim(); + if (!npmSpec || normalizeInstallDefaultChoice(install.defaultChoice) === "npm") { + return undefined; + } + return buildOpenClawClawHubSpec(npmSpec); +} + function collectConfiguredPluginIds(cfg: OpenClawConfig): Set { const ids = new Set(); const plugins = asObjectRecord(cfg.plugins); @@ -95,9 +121,7 @@ function collectDownloadableInstallCandidates(params: { continue; } const npmSpec = entry.install.npmSpec?.trim(); - const clawhubSpec = - entry.install.clawhubSpec?.trim() ?? - (npmSpec ? buildOpenClawClawHubSpec(npmSpec) : undefined); + const clawhubSpec = resolveCandidateClawHubSpec(entry.install); if (!npmSpec && !clawhubSpec) { continue; } @@ -109,6 +133,7 @@ function collectDownloadableInstallCandidates(params: { ...(entry.install.expectedIntegrity ? { expectedIntegrity: entry.install.expectedIntegrity } : {}), + ...(entry.install.defaultChoice ? { defaultChoice: entry.install.defaultChoice } : {}), }); } @@ -124,9 +149,7 @@ function collectDownloadableInstallCandidates(params: { continue; } const npmSpec = entry.install.npmSpec?.trim(); - const clawhubSpec = - entry.install.clawhubSpec?.trim() ?? - (npmSpec ? buildOpenClawClawHubSpec(npmSpec) : undefined); + const clawhubSpec = resolveCandidateClawHubSpec(entry.install); if (!npmSpec && !clawhubSpec) { continue; } @@ -138,6 +161,34 @@ function collectDownloadableInstallCandidates(params: { ...(entry.install.expectedIntegrity ? { expectedIntegrity: entry.install.expectedIntegrity } : {}), + ...(entry.install.defaultChoice ? { defaultChoice: entry.install.defaultChoice } : {}), + }); + } + + for (const entry of listOfficialExternalPluginCatalogEntries()) { + const pluginId = resolveOfficialExternalPluginId(entry); + if (!pluginId || candidates.has(pluginId) || params.blockedPluginIds?.has(pluginId)) { + continue; + } + if (!configuredPluginIds.has(pluginId) && !params.missingPluginIds.has(pluginId)) { + continue; + } + const install = resolveOfficialExternalPluginInstall(entry); + if (!install) { + continue; + } + const npmSpec = install.npmSpec?.trim(); + const clawhubSpec = resolveCandidateClawHubSpec(install); + if (!npmSpec && !clawhubSpec) { + continue; + } + candidates.set(pluginId, { + pluginId, + label: resolveOfficialExternalPluginLabel(entry), + ...(npmSpec ? { npmSpec } : {}), + ...(clawhubSpec ? { clawhubSpec } : {}), + ...(install.expectedIntegrity ? { expectedIntegrity: install.expectedIntegrity } : {}), + ...(install.defaultChoice ? { defaultChoice: install.defaultChoice } : {}), }); } @@ -157,7 +208,7 @@ async function installCandidate(params: { const { candidate } = params; const extensionsDir = resolveDefaultPluginExtensionsDir(); const changes: string[] = []; - if (candidate.clawhubSpec) { + if (candidate.clawhubSpec && candidate.defaultChoice !== "npm") { const clawhubResult = await installPluginFromClawHub({ spec: candidate.clawhubSpec, extensionsDir, diff --git a/src/plugins/official-external-plugin-catalog.ts b/src/plugins/official-external-plugin-catalog.ts new file mode 100644 index 00000000000..8d56e25e6f8 --- /dev/null +++ b/src/plugins/official-external-plugin-catalog.ts @@ -0,0 +1,173 @@ +import officialExternalChannelCatalog from "../../scripts/lib/official-external-channel-catalog.json" with { type: "json" }; +import officialExternalPluginCatalog from "../../scripts/lib/official-external-plugin-catalog.json" with { type: "json" }; +import officialExternalProviderCatalog from "../../scripts/lib/official-external-provider-catalog.json" with { type: "json" }; +import { MANIFEST_KEY } from "../compat/legacy-names.js"; +import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { isRecord } from "../utils.js"; +import type { PluginPackageInstall } from "./manifest.js"; + +type ManifestKey = typeof MANIFEST_KEY; + +export type OfficialExternalProviderAuthChoice = { + method?: string; + choiceId?: string; + choiceLabel?: string; + choiceHint?: string; + assistantPriority?: number; + assistantVisibility?: "visible" | "manual-only"; + groupId?: string; + groupLabel?: string; + groupHint?: string; + optionKey?: string; + cliFlag?: string; + cliOption?: string; + cliDescription?: string; + onboardingScopes?: readonly ("text-inference" | "image-generation")[]; +}; + +export type OfficialExternalProviderCatalogProvider = { + id?: string; + name?: string; + docs?: string; + categories?: readonly string[]; + authChoices?: readonly OfficialExternalProviderAuthChoice[]; +}; + +export type OfficialExternalPluginCatalogManifest = { + plugin?: { + id?: string; + label?: string; + }; + channel?: { + id?: string; + label?: string; + }; + providers?: readonly OfficialExternalProviderCatalogProvider[]; + install?: PluginPackageInstall; +}; + +export type OfficialExternalPluginCatalogEntry = { + name?: string; + version?: string; + description?: string; + source?: string; + kind?: string; +} & Partial>; + +const OFFICIAL_CATALOG_SOURCES = [ + officialExternalChannelCatalog, + officialExternalProviderCatalog, + officialExternalPluginCatalog, +] as const; + +function parseCatalogEntries(raw: unknown): OfficialExternalPluginCatalogEntry[] { + if (Array.isArray(raw)) { + return raw.filter((entry): entry is OfficialExternalPluginCatalogEntry => isRecord(entry)); + } + if (!isRecord(raw)) { + return []; + } + const list = raw.entries ?? raw.packages ?? raw.plugins; + if (!Array.isArray(list)) { + return []; + } + return list.filter((entry): entry is OfficialExternalPluginCatalogEntry => isRecord(entry)); +} + +function normalizeDefaultChoice(value: unknown): PluginPackageInstall["defaultChoice"] | undefined { + return value === "clawhub" || value === "npm" || value === "local" ? value : undefined; +} + +export function getOfficialExternalPluginCatalogManifest( + entry: OfficialExternalPluginCatalogEntry, +): OfficialExternalPluginCatalogManifest | undefined { + const manifest = entry[MANIFEST_KEY]; + return isRecord(manifest) ? (manifest as OfficialExternalPluginCatalogManifest) : undefined; +} + +export function resolveOfficialExternalPluginId( + entry: OfficialExternalPluginCatalogEntry, +): string | undefined { + const manifest = getOfficialExternalPluginCatalogManifest(entry); + return ( + normalizeOptionalString(manifest?.plugin?.id) ?? + normalizeOptionalString(manifest?.channel?.id) ?? + normalizeOptionalString(manifest?.providers?.[0]?.id) + ); +} + +export function resolveOfficialExternalPluginLabel( + entry: OfficialExternalPluginCatalogEntry, +): string { + const manifest = getOfficialExternalPluginCatalogManifest(entry); + return ( + normalizeOptionalString(manifest?.plugin?.label) ?? + normalizeOptionalString(manifest?.channel?.label) ?? + normalizeOptionalString(manifest?.providers?.[0]?.name) ?? + normalizeOptionalString(entry.name) ?? + resolveOfficialExternalPluginId(entry) ?? + "plugin" + ); +} + +export function resolveOfficialExternalPluginInstall( + entry: OfficialExternalPluginCatalogEntry, +): PluginPackageInstall | null { + const manifest = getOfficialExternalPluginCatalogManifest(entry); + const install = manifest?.install; + const clawhubSpec = normalizeOptionalString(install?.clawhubSpec); + const npmSpec = normalizeOptionalString(install?.npmSpec) ?? normalizeOptionalString(entry.name); + const localPath = normalizeOptionalString(install?.localPath); + if (!clawhubSpec && !npmSpec && !localPath) { + return null; + } + const defaultChoice = + normalizeDefaultChoice(install?.defaultChoice) ?? + (npmSpec ? "npm" : clawhubSpec ? "clawhub" : localPath ? "local" : undefined); + return { + ...(clawhubSpec ? { clawhubSpec } : {}), + ...(npmSpec ? { npmSpec } : {}), + ...(localPath ? { localPath } : {}), + ...(defaultChoice ? { defaultChoice } : {}), + ...(install?.minHostVersion ? { minHostVersion: install.minHostVersion } : {}), + ...(install?.expectedIntegrity ? { expectedIntegrity: install.expectedIntegrity } : {}), + ...(install?.allowInvalidConfigRecovery === true ? { allowInvalidConfigRecovery: true } : {}), + }; +} + +export function listOfficialExternalPluginCatalogEntries(): OfficialExternalPluginCatalogEntry[] { + const entries = OFFICIAL_CATALOG_SOURCES.flatMap((source) => parseCatalogEntries(source)); + const resolved = new Map(); + for (const entry of entries) { + const pluginId = resolveOfficialExternalPluginId(entry); + const key = pluginId ? `${entry.kind ?? "plugin"}:${pluginId}` : `${entry.name ?? ""}`; + if (key && !resolved.has(key)) { + resolved.set(key, entry); + } + } + return [...resolved.values()]; +} + +export function listOfficialExternalChannelCatalogEntries(): OfficialExternalPluginCatalogEntry[] { + return listOfficialExternalPluginCatalogEntries().filter((entry) => + Boolean(getOfficialExternalPluginCatalogManifest(entry)?.channel), + ); +} + +export function listOfficialExternalProviderCatalogEntries(): OfficialExternalPluginCatalogEntry[] { + return listOfficialExternalPluginCatalogEntries().filter( + (entry) => (getOfficialExternalPluginCatalogManifest(entry)?.providers?.length ?? 0) > 0, + ); +} + +export function getOfficialExternalPluginCatalogEntry( + pluginId: string, +): OfficialExternalPluginCatalogEntry | undefined { + const normalized = pluginId.trim(); + if (!normalized) { + return undefined; + } + return listOfficialExternalPluginCatalogEntries().find( + (entry) => resolveOfficialExternalPluginId(entry) === normalized, + ); +} diff --git a/src/plugins/provider-install-catalog.ts b/src/plugins/provider-install-catalog.ts index ae17e904a9c..e5222231b33 100644 --- a/src/plugins/provider-install-catalog.ts +++ b/src/plugins/provider-install-catalog.ts @@ -9,6 +9,12 @@ import { } from "./install-source-info.js"; import type { InstalledPluginInstallRecordInfo } from "./installed-plugin-index.js"; import type { PluginPackageInstall } from "./manifest.js"; +import { + getOfficialExternalPluginCatalogManifest, + listOfficialExternalProviderCatalogEntries, + resolveOfficialExternalPluginInstall, + type OfficialExternalProviderAuthChoice, +} from "./official-external-plugin-catalog.js"; import type { PluginOrigin } from "./plugin-origin.types.js"; import { loadPluginRegistrySnapshot, type PluginRegistryRecord } from "./plugin-registry.js"; import { @@ -248,6 +254,84 @@ function resolveProviderIndexInstallCatalogEntries(params: { return entries; } +function isProviderFlowScope(value: unknown): value is "text-inference" | "image-generation" { + return value === "text-inference" || value === "image-generation"; +} + +function normalizeProviderAuthChoiceScopes( + scopes: OfficialExternalProviderAuthChoice["onboardingScopes"], +): ("text-inference" | "image-generation")[] | undefined { + if (!Array.isArray(scopes)) { + return undefined; + } + const normalized = scopes.filter(isProviderFlowScope); + return normalized.length > 0 ? normalized : undefined; +} + +function resolveOfficialExternalProviderInstallCatalogEntries(params: { + installedPluginIds: ReadonlySet; + seenChoiceIds: ReadonlySet; +}): ProviderInstallCatalogEntry[] { + const entries: ProviderInstallCatalogEntry[] = []; + for (const entry of listOfficialExternalProviderCatalogEntries()) { + const manifest = getOfficialExternalPluginCatalogManifest(entry); + const pluginId = manifest?.plugin?.id?.trim(); + if (!manifest || !pluginId || params.installedPluginIds.has(pluginId)) { + continue; + } + const install = resolveOfficialExternalPluginInstall(entry); + if (!install) { + continue; + } + for (const provider of manifest?.providers ?? []) { + const providerId = provider.id?.trim(); + const label = provider.name?.trim() || manifest.plugin?.label?.trim() || entry.name?.trim(); + if (!providerId || !label) { + continue; + } + for (const choice of provider.authChoices ?? []) { + const methodId = choice.method?.trim(); + const choiceId = choice.choiceId?.trim(); + const choiceLabel = choice.choiceLabel?.trim(); + if (!methodId || !choiceId || !choiceLabel || params.seenChoiceIds.has(choiceId)) { + continue; + } + entries.push({ + pluginId, + providerId, + methodId, + choiceId, + choiceLabel, + ...(choice.choiceHint ? { choiceHint: choice.choiceHint } : {}), + ...(choice.assistantPriority !== undefined + ? { assistantPriority: choice.assistantPriority } + : {}), + ...(choice.assistantVisibility + ? { assistantVisibility: choice.assistantVisibility } + : {}), + ...(choice.groupId ? { groupId: choice.groupId } : {}), + ...(choice.groupLabel ? { groupLabel: choice.groupLabel } : {}), + ...(choice.groupHint ? { groupHint: choice.groupHint } : {}), + ...(choice.optionKey ? { optionKey: choice.optionKey } : {}), + ...(choice.cliFlag ? { cliFlag: choice.cliFlag } : {}), + ...(choice.cliOption ? { cliOption: choice.cliOption } : {}), + ...(choice.cliDescription ? { cliDescription: choice.cliDescription } : {}), + ...(normalizeProviderAuthChoiceScopes(choice.onboardingScopes) + ? { onboardingScopes: normalizeProviderAuthChoiceScopes(choice.onboardingScopes) } + : {}), + label, + origin: "bundled", + install, + installSource: describePluginInstallSource(install, { + expectedPackageName: entry.name, + }), + }); + } + } + } + return entries; +} + export function resolveProviderInstallCatalogEntries( params?: ProviderInstallCatalogParams, ): ProviderInstallCatalogEntry[] { @@ -274,11 +358,18 @@ export function resolveProviderInstallCatalogEntries( }) .toSorted((left, right) => left.choiceLabel.localeCompare(right.choiceLabel)); const seenChoiceIds = new Set(manifestEntries.map((entry) => entry.choiceId)); + const officialEntries = resolveOfficialExternalProviderInstallCatalogEntries({ + installedPluginIds, + seenChoiceIds, + }); + for (const entry of officialEntries) { + seenChoiceIds.add(entry.choiceId); + } const indexEntries = resolveProviderIndexInstallCatalogEntries({ installedPluginIds, seenChoiceIds, }); - return [...manifestEntries, ...indexEntries].toSorted((left, right) => + return [...manifestEntries, ...officialEntries, ...indexEntries].toSorted((left, right) => left.choiceLabel.localeCompare(right.choiceLabel), ); } diff --git a/test/official-channel-catalog.test.ts b/test/official-channel-catalog.test.ts index c48ae7e222c..ceaeacc742a 100644 --- a/test/official-channel-catalog.test.ts +++ b/test/official-channel-catalog.test.ts @@ -101,33 +101,32 @@ describe("buildOfficialChannelCatalog", () => { }, }), }), - { + expect.objectContaining({ name: "@openclaw/whatsapp", - version: "2026.3.23", description: "OpenClaw WhatsApp channel plugin", - openclaw: { - channel: { + source: "official", + openclaw: expect.objectContaining({ + channel: expect.objectContaining({ id: "whatsapp", label: "WhatsApp", selectionLabel: "WhatsApp (QR link)", detailLabel: "WhatsApp Web", docsPath: "/channels/whatsapp", - blurb: "works with your own number; recommend a separate phone + eSIM.", - }, - install: { + }), + install: expect.objectContaining({ npmSpec: "@openclaw/whatsapp", defaultChoice: "npm", - }, - }, - }, + }), + }), + }), ]), ); }); - it("keeps official external catalog npm sources exactly pinned", () => { + it("keeps third-party official external catalog npm sources exactly pinned", () => { const repoRoot = makeRepoRoot("openclaw-official-channel-catalog-policy-"); const entries = buildOfficialChannelCatalog({ repoRoot }).entries.filter( - (entry) => entry.source === "external", + (entry) => entry.source === "external" && !entry.name?.startsWith("@openclaw/"), ); expect(entries.length).toBeGreaterThan(0); @@ -138,6 +137,29 @@ describe("buildOfficialChannelCatalog", () => { } }); + it("allows official OpenClaw channel npm specs without integrity during launch", () => { + const repoRoot = makeRepoRoot("openclaw-official-channel-catalog-openclaw-policy-"); + const twitch = buildOfficialChannelCatalog({ repoRoot }).entries.find( + (entry) => entry.openclaw?.channel?.id === "twitch", + ); + + expect(twitch).toEqual( + expect.objectContaining({ + name: "@openclaw/twitch", + openclaw: expect.objectContaining({ + install: { + npmSpec: "@openclaw/twitch", + defaultChoice: "npm", + minHostVersion: ">=2026.4.10", + }, + }), + }), + ); + const installSource = describePluginInstallSource(twitch?.openclaw?.install ?? {}); + expect(installSource.npm?.pinState).toBe("floating-without-integrity"); + expect(installSource.warnings).toEqual(["npm-spec-floating", "npm-spec-missing-integrity"]); + }); + it("writes the official catalog under dist", () => { const repoRoot = makeRepoRoot("openclaw-official-channel-catalog-write-"); writeJson(path.join(repoRoot, "extensions", "whatsapp", "package.json"), { @@ -171,22 +193,28 @@ describe("buildOfficialChannelCatalog", () => { expect.objectContaining({ name: "openclaw-plugin-yuanbao", }), - { + expect.objectContaining({ name: "@openclaw/whatsapp", - openclaw: { - channel: { + source: "official", + openclaw: expect.objectContaining({ + channel: expect.objectContaining({ id: "whatsapp", label: "WhatsApp", - selectionLabel: "WhatsApp", + selectionLabel: "WhatsApp (QR link)", docsPath: "/channels/whatsapp", - blurb: "wa", - }, - install: { + }), + install: expect.objectContaining({ npmSpec: "@openclaw/whatsapp", - }, - }, - }, + defaultChoice: "npm", + }), + }), + }), ]), ); + const whatsappEntries = JSON.parse(fs.readFileSync(outputPath, "utf8")).entries.filter( + (entry: { openclaw?: { channel?: { id?: string } } }) => + entry.openclaw?.channel?.id === "whatsapp", + ); + expect(whatsappEntries).toHaveLength(1); }); }); diff --git a/test/release-check.test.ts b/test/release-check.test.ts index 6865a7165d2..b44d57ae198 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -485,6 +485,9 @@ describe("collectMissingPackPaths", () => { "dist/control-ui/index.html", "scripts/npm-runner.mjs", "scripts/preinstall-package-manager-warning.mjs", + "scripts/lib/official-external-channel-catalog.json", + "scripts/lib/official-external-plugin-catalog.json", + "scripts/lib/official-external-provider-catalog.json", "scripts/lib/package-dist-imports.mjs", "scripts/postinstall-bundled-plugins.mjs", "dist/task-registry-control.runtime.js", @@ -517,6 +520,9 @@ describe("collectMissingPackPaths", () => { ...WORKSPACE_TEMPLATE_PACK_PATHS, "scripts/npm-runner.mjs", "scripts/preinstall-package-manager-warning.mjs", + "scripts/lib/official-external-channel-catalog.json", + "scripts/lib/official-external-plugin-catalog.json", + "scripts/lib/official-external-provider-catalog.json", "scripts/lib/package-dist-imports.mjs", "scripts/postinstall-bundled-plugins.mjs", "dist/plugin-sdk/root-alias.cjs",