From 5fae1c32b5f8e33c9fabcad0cc3cbdbc6e899051 Mon Sep 17 00:00:00 2001 From: Pumpkin Xing Date: Wed, 6 May 2026 01:47:01 +0800 Subject: [PATCH] fix(plugins): forward install records to channel catalog registry (#77269) Merged via squash. Prepared head SHA: d06034b037c20139267003b149d331b50179b8ed Co-authored-by: pumpkinxing1 <271513653+pumpkinxing1@users.noreply.github.com> Co-authored-by: odysseus0 <8635094+odysseus0@users.noreply.github.com> Reviewed-by: @odysseus0 --- CHANGELOG.md | 2 + .../official-external-channel-catalog.json | 39 ++++++ .../doctor/shared/stale-plugin-config.test.ts | 34 ++--- src/plugins/bundled-plugin-metadata.test.ts | 1 + src/plugins/channel-catalog-registry.test.ts | 116 ++++++++++++++++++ src/plugins/channel-catalog-registry.ts | 29 +++++ 6 files changed, 204 insertions(+), 17 deletions(-) create mode 100644 src/plugins/channel-catalog-registry.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b677bfb514d..60dcb753f32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ Docs: https://docs.openclaw.ai - Docs: clarify that IRC uses raw TCP/TLS sockets outside operator-managed forward proxy routing, so direct IRC egress should be explicitly approved before enabling IRC. Thanks @jesse-merhi. - Dependencies: refresh runtime and provider packages including Pi 0.73.0, ACPX adapters, OpenAI, Anthropic, Slack, and TypeScript native preview, while keeping the Bedrock runtime installer override pinned below the Windows ARM Node 24 npm resolver failure. - Contributor PRs: require external pull requests to include after-fix real behavior proof from a real OpenClaw setup, with terminal screenshots, console output, redacted runtime logs, linked artifacts, and copied live output treated as valid evidence while unit tests, mocks, lint, typechecks, snapshots, and CI remain supplemental only. +- Plugins/catalog: add an `@tencent-weixin/openclaw-weixin` external entry pinned to `2.4.1` so onboarding and `openclaw channels add` can install the Tencent Weixin (personal WeChat) channel by default. (#77269) Thanks @pumpkinxing1. ### Fixes @@ -333,6 +334,7 @@ Docs: https://docs.openclaw.ai - Install/postinstall: skip noisy compile-cache prune warnings when `EACCES`/`EPERM` prevent removing shared `/tmp/node-compile-cache` entries owned by another user. Fixes #76353. (#76362) Thanks @RayWoo and @neeravmakwana. - Agents/messaging: surface CLI subprocess watchdog/turn timeout messages to chat users when verbose failures are off, instead of collapsing them into generic external-run failure copy. Fixes #77007. (#77015) Thanks @neeravmakwana. - Agents/sessions: after embedded Pi runs, append assistant-visible reply text to session JSONL only when Pi did not already persist an equivalent tail assistant entry, without re-mirroring the user prompt Pi owns. Fixes #77823. (#77839) Thanks @neeravmakwana. +- Plugins/CLI: load the install-records ledger when listing channel-catalog entries, so npm-installed third-party channel plugins resolve through `openclaw channels login`/`channels add` instead of failing with `Unsupported channel`. (#77269) Thanks @pumpkinxing1. ## 2026.5.3-1 diff --git a/scripts/lib/official-external-channel-catalog.json b/scripts/lib/official-external-channel-catalog.json index ef34e291507..49232fb61d1 100644 --- a/scripts/lib/official-external-channel-catalog.json +++ b/scripts/lib/official-external-channel-catalog.json @@ -82,6 +82,45 @@ } } }, + { + "name": "@tencent-weixin/openclaw-weixin", + "description": "OpenClaw Weixin channel plugin by the Tencent Weixin team.", + "source": "external", + "kind": "channel", + "openclaw": { + "plugin": { + "id": "openclaw-weixin", + "label": "Weixin" + }, + "channel": { + "id": "openclaw-weixin", + "label": "Weixin", + "selectionLabel": "Weixin(微信)", + "detailLabel": "Weixin", + "docsPath": "/channels/wechat", + "docsLabel": "weixin", + "blurb": "Personal WeChat messaging via QR-code login.", + "aliases": ["weixin", "wechat", "微信"], + "order": 75 + }, + "channelConfigs": { + "openclaw-weixin": { + "label": "Weixin", + "description": "Personal WeChat conversation channel.", + "schema": { + "type": "object", + "additionalProperties": true + } + } + }, + "install": { + "npmSpec": "@tencent-weixin/openclaw-weixin@2.4.1", + "defaultChoice": "npm", + "expectedIntegrity": "sha512-FZnUVMQRpKGTKezeplr/DYal+5RSif2tXE51pljIFrO8rn7bVnnvpbj81/i9UMrYbuGiom1sl8OeSDzWRDKGhQ==", + "minHostVersion": ">=2026.3.22" + } + } + }, { "name": "@openclaw/bluebubbles", "description": "OpenClaw BlueBubbles channel plugin", diff --git a/src/commands/doctor/shared/stale-plugin-config.test.ts b/src/commands/doctor/shared/stale-plugin-config.test.ts index 2205d96828e..7b3f997cf89 100644 --- a/src/commands/doctor/shared/stale-plugin-config.test.ts +++ b/src/commands/doctor/shared/stale-plugin-config.test.ts @@ -200,14 +200,14 @@ describe("doctor stale plugin config helpers", () => { it("removes stale third-party channel config and dependent channel refs", () => { const result = maybeRepairStalePluginConfig({ plugins: { - allow: ["discord", "openclaw-weixin"], + allow: ["discord", "missing-chat-plugin"], entries: { discord: { enabled: true }, - "openclaw-weixin": { enabled: true }, + "missing-chat-plugin": { enabled: true }, }, }, channels: { - "openclaw-weixin": { + "missing-chat-plugin": { enabled: true, token: "stale", }, @@ -216,7 +216,7 @@ describe("doctor stale plugin config helpers", () => { }, modelByChannel: { openai: { - "openclaw-weixin": "openai/gpt-5.4", + "missing-chat-plugin": "openai/gpt-5.4", telegram: "openai/gpt-5.4", }, }, @@ -224,7 +224,7 @@ describe("doctor stale plugin config helpers", () => { agents: { defaults: { heartbeat: { - target: "openclaw-weixin", + target: "missing-chat-plugin", every: "30m", }, }, @@ -232,7 +232,7 @@ describe("doctor stale plugin config helpers", () => { { id: "pi", heartbeat: { - target: "openclaw-weixin", + target: "missing-chat-plugin", }, }, { @@ -246,17 +246,17 @@ describe("doctor stale plugin config helpers", () => { } as OpenClawConfig); expect(result.changes).toEqual([ - "- plugins.allow: removed 1 stale plugin id (openclaw-weixin)", - "- plugins.entries: removed 1 stale plugin entry (openclaw-weixin)", - "- channels: removed 1 stale channel config (openclaw-weixin)", - "- agents heartbeat: removed 2 stale heartbeat targets (openclaw-weixin)", - "- channels.modelByChannel: removed 1 stale channel model override (openclaw-weixin)", + "- plugins.allow: removed 1 stale plugin id (missing-chat-plugin)", + "- plugins.entries: removed 1 stale plugin entry (missing-chat-plugin)", + "- channels: removed 1 stale channel config (missing-chat-plugin)", + "- agents heartbeat: removed 2 stale heartbeat targets (missing-chat-plugin)", + "- channels.modelByChannel: removed 1 stale channel model override (missing-chat-plugin)", ]); expect(result.config.plugins?.allow).toEqual(["discord"]); expect(result.config.plugins?.entries).toEqual({ discord: { enabled: true }, }); - expect(result.config.channels?.["openclaw-weixin"]).toBeUndefined(); + expect(result.config.channels?.["missing-chat-plugin"]).toBeUndefined(); expect(result.config.channels?.telegram).toEqual({ botToken: "keep" }); expect(result.config.channels?.modelByChannel).toEqual({ openai: { @@ -304,25 +304,25 @@ describe("doctor stale plugin config helpers", () => { it("uses missing persisted install records as stale channel evidence", () => { installedPluginIndexMocks.loadInstalledPluginIndexInstallRecordsSync.mockReturnValue({ - "openclaw-weixin": { + "missing-chat-plugin": { source: "npm", - resolvedName: "@tencent-weixin/openclaw-weixin", + resolvedName: "@example/missing-chat-plugin", installedAt: "2026-04-12T00:00:00.000Z", }, }); const result = maybeRepairStalePluginConfig({ channels: { - "openclaw-weixin": { + "missing-chat-plugin": { enabled: true, }, }, } as OpenClawConfig); expect(result.changes).toEqual([ - "- channels: removed 1 stale channel config (openclaw-weixin)", + "- channels: removed 1 stale channel config (missing-chat-plugin)", ]); - expect(result.config.channels?.["openclaw-weixin"]).toBeUndefined(); + expect(result.config.channels?.["missing-chat-plugin"]).toBeUndefined(); }); it("does not auto-repair stale refs while plugin discovery has errors", () => { diff --git a/src/plugins/bundled-plugin-metadata.test.ts b/src/plugins/bundled-plugin-metadata.test.ts index 9a419366b61..08608d899d3 100644 --- a/src/plugins/bundled-plugin-metadata.test.ts +++ b/src/plugins/bundled-plugin-metadata.test.ts @@ -51,6 +51,7 @@ const EXPECTED_EMPTY_CONFIG_GATEWAY_STARTUP_PLUGIN_IDS = [ "acpx", "browser", "device-pair", + "discord", "file-transfer", "memory-core", "phone-control", diff --git a/src/plugins/channel-catalog-registry.test.ts b/src/plugins/channel-catalog-registry.test.ts new file mode 100644 index 00000000000..377e4c645bd --- /dev/null +++ b/src/plugins/channel-catalog-registry.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { PluginInstallRecord } from "../config/types.plugins.js"; +import type { PluginCandidate, PluginDiscoveryResult } from "./discovery.js"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + vi.doUnmock("./discovery.js"); + vi.doUnmock("./installed-plugin-index-record-reader.js"); +}); + +const ENV: NodeJS.ProcessEnv = { HOME: "/tmp/openclaw-test-home" }; + +const RECORDS: Record = { + weixin: { + source: "npm", + spec: "@tencent-weixin/openclaw-weixin@2.3.7", + installPath: + "/tmp/openclaw-test-home/.openclaw/npm/node_modules/@tencent-weixin/openclaw-weixin", + } as PluginInstallRecord, +}; + +function emptyDiscoveryResult(): PluginDiscoveryResult { + return { + candidates: [] as PluginCandidate[], + diagnostics: [], + }; +} + +async function loadWithMocks(params: { + loadRecords?: (env: NodeJS.ProcessEnv | undefined) => Record; +}): Promise<{ + module: typeof import("./channel-catalog-registry.js"); + discoverSpy: ReturnType; + loadRecordsSpy: ReturnType; +}> { + vi.resetModules(); + const discoverSpy = vi.fn(() => emptyDiscoveryResult()); + const loadRecordsSpy = vi.fn((opts: { env?: NodeJS.ProcessEnv } = {}) => { + return params.loadRecords ? params.loadRecords(opts.env) : RECORDS; + }); + + vi.doMock("./discovery.js", () => ({ discoverOpenClawPlugins: discoverSpy })); + vi.doMock("./installed-plugin-index-record-reader.js", () => ({ + loadInstalledPluginIndexInstallRecordsSync: loadRecordsSpy, + })); + + const module = await import("./channel-catalog-registry.js"); + return { module, discoverSpy, loadRecordsSpy }; +} + +describe("listChannelCatalogEntries", () => { + it("forwards lazily loaded install records to discovery when origin is unspecified", async () => { + const { module, discoverSpy, loadRecordsSpy } = await loadWithMocks({}); + + module.listChannelCatalogEntries({ env: ENV }); + + expect(loadRecordsSpy).toHaveBeenCalledTimes(1); + expect(loadRecordsSpy).toHaveBeenCalledWith({ env: ENV }); + expect(discoverSpy).toHaveBeenCalledTimes(1); + expect(discoverSpy.mock.calls[0][0]).toMatchObject({ + env: ENV, + installRecords: RECORDS, + }); + }); + + it("skips ledger lookup when origin is 'bundled' and omits installRecords", async () => { + const { module, discoverSpy, loadRecordsSpy } = await loadWithMocks({}); + + module.listChannelCatalogEntries({ origin: "bundled", env: ENV }); + + expect(loadRecordsSpy).not.toHaveBeenCalled(); + expect(discoverSpy).toHaveBeenCalledTimes(1); + expect(discoverSpy.mock.calls[0][0]).not.toHaveProperty("installRecords"); + }); + + it("uses caller-supplied install records verbatim and does not load the ledger", async () => { + const { module, discoverSpy, loadRecordsSpy } = await loadWithMocks({}); + const supplied: Record = { + slack: { + source: "npm", + spec: "@openclaw/slack@1.0.0", + } as PluginInstallRecord, + }; + + module.listChannelCatalogEntries({ env: ENV, installRecords: supplied }); + + expect(loadRecordsSpy).not.toHaveBeenCalled(); + expect(discoverSpy.mock.calls[0][0]).toMatchObject({ installRecords: supplied }); + }); + + it("omits installRecords from discovery when the ledger is empty", async () => { + const { module, discoverSpy, loadRecordsSpy } = await loadWithMocks({ + loadRecords: () => ({}), + }); + + module.listChannelCatalogEntries({ env: ENV }); + + expect(loadRecordsSpy).toHaveBeenCalledTimes(1); + expect(discoverSpy.mock.calls[0][0]).not.toHaveProperty("installRecords"); + }); + + it("treats ledger read errors as a soft fallback (no installRecords propagated)", async () => { + const { module, discoverSpy, loadRecordsSpy } = await loadWithMocks({ + loadRecords: () => { + throw new Error("simulated reader failure"); + }, + }); + + expect(() => module.listChannelCatalogEntries({ env: ENV })).not.toThrow(); + + expect(loadRecordsSpy).toHaveBeenCalledTimes(1); + expect(discoverSpy).toHaveBeenCalledTimes(1); + expect(discoverSpy.mock.calls[0][0]).not.toHaveProperty("installRecords"); + }); +}); diff --git a/src/plugins/channel-catalog-registry.ts b/src/plugins/channel-catalog-registry.ts index f1c65050cf1..6fa47fe70a1 100644 --- a/src/plugins/channel-catalog-registry.ts +++ b/src/plugins/channel-catalog-registry.ts @@ -1,4 +1,6 @@ +import type { PluginInstallRecord } from "../config/types.plugins.js"; import { discoverOpenClawPlugins } from "./discovery.js"; +import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-record-reader.js"; import { loadPluginManifest, type PluginPackageChannel, @@ -21,11 +23,20 @@ export function listChannelCatalogEntries( origin?: PluginOrigin; workspaceDir?: string; env?: NodeJS.ProcessEnv; + /** + * Optional override. When omitted and `origin !== "bundled"`, the persisted + * plugin install ledger is loaded synchronously so that npm-installed + * channels stored outside the discovery roots are visible to the catalog. + * Bundled-only callers skip the load to avoid the disk read. + */ + installRecords?: Record; } = {}, ): PluginChannelCatalogEntry[] { + const installRecords = resolveInstallRecords(params); return discoverOpenClawPlugins({ workspaceDir: params.workspaceDir, env: params.env, + ...(installRecords && Object.keys(installRecords).length > 0 ? { installRecords } : {}), }).candidates.flatMap((candidate) => { if (params.origin && candidate.origin !== params.origin) { return []; @@ -53,3 +64,21 @@ export function listChannelCatalogEntries( ]; }); } + +function resolveInstallRecords(params: { + origin?: PluginOrigin; + env?: NodeJS.ProcessEnv; + installRecords?: Record; +}): Record | undefined { + if (params.installRecords) { + return params.installRecords; + } + if (params.origin === "bundled") { + return undefined; + } + try { + return loadInstalledPluginIndexInstallRecordsSync(params.env ? { env: params.env } : {}); + } catch { + return undefined; + } +}