Skip to content

Commit 5fae1c3

Browse files
fix(plugins): forward install records to channel catalog registry (#77269)
Merged via squash. Prepared head SHA: d06034b Co-authored-by: pumpkinxing1 <[email protected]> Co-authored-by: odysseus0 <[email protected]> Reviewed-by: @odysseus0
1 parent 7188e4f commit 5fae1c3

6 files changed

Lines changed: 204 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ Docs: https://docs.openclaw.ai
6666
- 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.
6767
- 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.
6868
- 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.
69+
- 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.
6970

7071
### Fixes
7172

@@ -333,6 +334,7 @@ Docs: https://docs.openclaw.ai
333334
- 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.
334335
- 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.
335336
- 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.
337+
- 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.
336338

337339
## 2026.5.3-1
338340

scripts/lib/official-external-channel-catalog.json

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,45 @@
8282
}
8383
}
8484
},
85+
{
86+
"name": "@tencent-weixin/openclaw-weixin",
87+
"description": "OpenClaw Weixin channel plugin by the Tencent Weixin team.",
88+
"source": "external",
89+
"kind": "channel",
90+
"openclaw": {
91+
"plugin": {
92+
"id": "openclaw-weixin",
93+
"label": "Weixin"
94+
},
95+
"channel": {
96+
"id": "openclaw-weixin",
97+
"label": "Weixin",
98+
"selectionLabel": "Weixin(微信)",
99+
"detailLabel": "Weixin",
100+
"docsPath": "/channels/wechat",
101+
"docsLabel": "weixin",
102+
"blurb": "Personal WeChat messaging via QR-code login.",
103+
"aliases": ["weixin", "wechat", "微信"],
104+
"order": 75
105+
},
106+
"channelConfigs": {
107+
"openclaw-weixin": {
108+
"label": "Weixin",
109+
"description": "Personal WeChat conversation channel.",
110+
"schema": {
111+
"type": "object",
112+
"additionalProperties": true
113+
}
114+
}
115+
},
116+
"install": {
117+
"npmSpec": "@tencent-weixin/[email protected]",
118+
"defaultChoice": "npm",
119+
"expectedIntegrity": "sha512-FZnUVMQRpKGTKezeplr/DYal+5RSif2tXE51pljIFrO8rn7bVnnvpbj81/i9UMrYbuGiom1sl8OeSDzWRDKGhQ==",
120+
"minHostVersion": ">=2026.3.22"
121+
}
122+
}
123+
},
85124
{
86125
"name": "@openclaw/bluebubbles",
87126
"description": "OpenClaw BlueBubbles channel plugin",

src/commands/doctor/shared/stale-plugin-config.test.ts

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -200,14 +200,14 @@ describe("doctor stale plugin config helpers", () => {
200200
it("removes stale third-party channel config and dependent channel refs", () => {
201201
const result = maybeRepairStalePluginConfig({
202202
plugins: {
203-
allow: ["discord", "openclaw-weixin"],
203+
allow: ["discord", "missing-chat-plugin"],
204204
entries: {
205205
discord: { enabled: true },
206-
"openclaw-weixin": { enabled: true },
206+
"missing-chat-plugin": { enabled: true },
207207
},
208208
},
209209
channels: {
210-
"openclaw-weixin": {
210+
"missing-chat-plugin": {
211211
enabled: true,
212212
token: "stale",
213213
},
@@ -216,23 +216,23 @@ describe("doctor stale plugin config helpers", () => {
216216
},
217217
modelByChannel: {
218218
openai: {
219-
"openclaw-weixin": "openai/gpt-5.4",
219+
"missing-chat-plugin": "openai/gpt-5.4",
220220
telegram: "openai/gpt-5.4",
221221
},
222222
},
223223
},
224224
agents: {
225225
defaults: {
226226
heartbeat: {
227-
target: "openclaw-weixin",
227+
target: "missing-chat-plugin",
228228
every: "30m",
229229
},
230230
},
231231
list: [
232232
{
233233
id: "pi",
234234
heartbeat: {
235-
target: "openclaw-weixin",
235+
target: "missing-chat-plugin",
236236
},
237237
},
238238
{
@@ -246,17 +246,17 @@ describe("doctor stale plugin config helpers", () => {
246246
} as OpenClawConfig);
247247

248248
expect(result.changes).toEqual([
249-
"- plugins.allow: removed 1 stale plugin id (openclaw-weixin)",
250-
"- plugins.entries: removed 1 stale plugin entry (openclaw-weixin)",
251-
"- channels: removed 1 stale channel config (openclaw-weixin)",
252-
"- agents heartbeat: removed 2 stale heartbeat targets (openclaw-weixin)",
253-
"- channels.modelByChannel: removed 1 stale channel model override (openclaw-weixin)",
249+
"- plugins.allow: removed 1 stale plugin id (missing-chat-plugin)",
250+
"- plugins.entries: removed 1 stale plugin entry (missing-chat-plugin)",
251+
"- channels: removed 1 stale channel config (missing-chat-plugin)",
252+
"- agents heartbeat: removed 2 stale heartbeat targets (missing-chat-plugin)",
253+
"- channels.modelByChannel: removed 1 stale channel model override (missing-chat-plugin)",
254254
]);
255255
expect(result.config.plugins?.allow).toEqual(["discord"]);
256256
expect(result.config.plugins?.entries).toEqual({
257257
discord: { enabled: true },
258258
});
259-
expect(result.config.channels?.["openclaw-weixin"]).toBeUndefined();
259+
expect(result.config.channels?.["missing-chat-plugin"]).toBeUndefined();
260260
expect(result.config.channels?.telegram).toEqual({ botToken: "keep" });
261261
expect(result.config.channels?.modelByChannel).toEqual({
262262
openai: {
@@ -304,25 +304,25 @@ describe("doctor stale plugin config helpers", () => {
304304

305305
it("uses missing persisted install records as stale channel evidence", () => {
306306
installedPluginIndexMocks.loadInstalledPluginIndexInstallRecordsSync.mockReturnValue({
307-
"openclaw-weixin": {
307+
"missing-chat-plugin": {
308308
source: "npm",
309-
resolvedName: "@tencent-weixin/openclaw-weixin",
309+
resolvedName: "@example/missing-chat-plugin",
310310
installedAt: "2026-04-12T00:00:00.000Z",
311311
},
312312
});
313313

314314
const result = maybeRepairStalePluginConfig({
315315
channels: {
316-
"openclaw-weixin": {
316+
"missing-chat-plugin": {
317317
enabled: true,
318318
},
319319
},
320320
} as OpenClawConfig);
321321

322322
expect(result.changes).toEqual([
323-
"- channels: removed 1 stale channel config (openclaw-weixin)",
323+
"- channels: removed 1 stale channel config (missing-chat-plugin)",
324324
]);
325-
expect(result.config.channels?.["openclaw-weixin"]).toBeUndefined();
325+
expect(result.config.channels?.["missing-chat-plugin"]).toBeUndefined();
326326
});
327327

328328
it("does not auto-repair stale refs while plugin discovery has errors", () => {

src/plugins/bundled-plugin-metadata.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ const EXPECTED_EMPTY_CONFIG_GATEWAY_STARTUP_PLUGIN_IDS = [
5151
"acpx",
5252
"browser",
5353
"device-pair",
54+
"discord",
5455
"file-transfer",
5556
"memory-core",
5657
"phone-control",
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import type { PluginInstallRecord } from "../config/types.plugins.js";
3+
import type { PluginCandidate, PluginDiscoveryResult } from "./discovery.js";
4+
5+
afterEach(() => {
6+
vi.restoreAllMocks();
7+
vi.resetModules();
8+
vi.doUnmock("./discovery.js");
9+
vi.doUnmock("./installed-plugin-index-record-reader.js");
10+
});
11+
12+
const ENV: NodeJS.ProcessEnv = { HOME: "/tmp/openclaw-test-home" };
13+
14+
const RECORDS: Record<string, PluginInstallRecord> = {
15+
weixin: {
16+
source: "npm",
17+
spec: "@tencent-weixin/[email protected]",
18+
installPath:
19+
"/tmp/openclaw-test-home/.openclaw/npm/node_modules/@tencent-weixin/openclaw-weixin",
20+
} as PluginInstallRecord,
21+
};
22+
23+
function emptyDiscoveryResult(): PluginDiscoveryResult {
24+
return {
25+
candidates: [] as PluginCandidate[],
26+
diagnostics: [],
27+
};
28+
}
29+
30+
async function loadWithMocks(params: {
31+
loadRecords?: (env: NodeJS.ProcessEnv | undefined) => Record<string, PluginInstallRecord>;
32+
}): Promise<{
33+
module: typeof import("./channel-catalog-registry.js");
34+
discoverSpy: ReturnType<typeof vi.fn>;
35+
loadRecordsSpy: ReturnType<typeof vi.fn>;
36+
}> {
37+
vi.resetModules();
38+
const discoverSpy = vi.fn(() => emptyDiscoveryResult());
39+
const loadRecordsSpy = vi.fn((opts: { env?: NodeJS.ProcessEnv } = {}) => {
40+
return params.loadRecords ? params.loadRecords(opts.env) : RECORDS;
41+
});
42+
43+
vi.doMock("./discovery.js", () => ({ discoverOpenClawPlugins: discoverSpy }));
44+
vi.doMock("./installed-plugin-index-record-reader.js", () => ({
45+
loadInstalledPluginIndexInstallRecordsSync: loadRecordsSpy,
46+
}));
47+
48+
const module = await import("./channel-catalog-registry.js");
49+
return { module, discoverSpy, loadRecordsSpy };
50+
}
51+
52+
describe("listChannelCatalogEntries", () => {
53+
it("forwards lazily loaded install records to discovery when origin is unspecified", async () => {
54+
const { module, discoverSpy, loadRecordsSpy } = await loadWithMocks({});
55+
56+
module.listChannelCatalogEntries({ env: ENV });
57+
58+
expect(loadRecordsSpy).toHaveBeenCalledTimes(1);
59+
expect(loadRecordsSpy).toHaveBeenCalledWith({ env: ENV });
60+
expect(discoverSpy).toHaveBeenCalledTimes(1);
61+
expect(discoverSpy.mock.calls[0][0]).toMatchObject({
62+
env: ENV,
63+
installRecords: RECORDS,
64+
});
65+
});
66+
67+
it("skips ledger lookup when origin is 'bundled' and omits installRecords", async () => {
68+
const { module, discoverSpy, loadRecordsSpy } = await loadWithMocks({});
69+
70+
module.listChannelCatalogEntries({ origin: "bundled", env: ENV });
71+
72+
expect(loadRecordsSpy).not.toHaveBeenCalled();
73+
expect(discoverSpy).toHaveBeenCalledTimes(1);
74+
expect(discoverSpy.mock.calls[0][0]).not.toHaveProperty("installRecords");
75+
});
76+
77+
it("uses caller-supplied install records verbatim and does not load the ledger", async () => {
78+
const { module, discoverSpy, loadRecordsSpy } = await loadWithMocks({});
79+
const supplied: Record<string, PluginInstallRecord> = {
80+
slack: {
81+
source: "npm",
82+
spec: "@openclaw/[email protected]",
83+
} as PluginInstallRecord,
84+
};
85+
86+
module.listChannelCatalogEntries({ env: ENV, installRecords: supplied });
87+
88+
expect(loadRecordsSpy).not.toHaveBeenCalled();
89+
expect(discoverSpy.mock.calls[0][0]).toMatchObject({ installRecords: supplied });
90+
});
91+
92+
it("omits installRecords from discovery when the ledger is empty", async () => {
93+
const { module, discoverSpy, loadRecordsSpy } = await loadWithMocks({
94+
loadRecords: () => ({}),
95+
});
96+
97+
module.listChannelCatalogEntries({ env: ENV });
98+
99+
expect(loadRecordsSpy).toHaveBeenCalledTimes(1);
100+
expect(discoverSpy.mock.calls[0][0]).not.toHaveProperty("installRecords");
101+
});
102+
103+
it("treats ledger read errors as a soft fallback (no installRecords propagated)", async () => {
104+
const { module, discoverSpy, loadRecordsSpy } = await loadWithMocks({
105+
loadRecords: () => {
106+
throw new Error("simulated reader failure");
107+
},
108+
});
109+
110+
expect(() => module.listChannelCatalogEntries({ env: ENV })).not.toThrow();
111+
112+
expect(loadRecordsSpy).toHaveBeenCalledTimes(1);
113+
expect(discoverSpy).toHaveBeenCalledTimes(1);
114+
expect(discoverSpy.mock.calls[0][0]).not.toHaveProperty("installRecords");
115+
});
116+
});

src/plugins/channel-catalog-registry.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import type { PluginInstallRecord } from "../config/types.plugins.js";
12
import { discoverOpenClawPlugins } from "./discovery.js";
3+
import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-record-reader.js";
24
import {
35
loadPluginManifest,
46
type PluginPackageChannel,
@@ -21,11 +23,20 @@ export function listChannelCatalogEntries(
2123
origin?: PluginOrigin;
2224
workspaceDir?: string;
2325
env?: NodeJS.ProcessEnv;
26+
/**
27+
* Optional override. When omitted and `origin !== "bundled"`, the persisted
28+
* plugin install ledger is loaded synchronously so that npm-installed
29+
* channels stored outside the discovery roots are visible to the catalog.
30+
* Bundled-only callers skip the load to avoid the disk read.
31+
*/
32+
installRecords?: Record<string, PluginInstallRecord>;
2433
} = {},
2534
): PluginChannelCatalogEntry[] {
35+
const installRecords = resolveInstallRecords(params);
2636
return discoverOpenClawPlugins({
2737
workspaceDir: params.workspaceDir,
2838
env: params.env,
39+
...(installRecords && Object.keys(installRecords).length > 0 ? { installRecords } : {}),
2940
}).candidates.flatMap((candidate) => {
3041
if (params.origin && candidate.origin !== params.origin) {
3142
return [];
@@ -53,3 +64,21 @@ export function listChannelCatalogEntries(
5364
];
5465
});
5566
}
67+
68+
function resolveInstallRecords(params: {
69+
origin?: PluginOrigin;
70+
env?: NodeJS.ProcessEnv;
71+
installRecords?: Record<string, PluginInstallRecord>;
72+
}): Record<string, PluginInstallRecord> | undefined {
73+
if (params.installRecords) {
74+
return params.installRecords;
75+
}
76+
if (params.origin === "bundled") {
77+
return undefined;
78+
}
79+
try {
80+
return loadInstalledPluginIndexInstallRecordsSync(params.env ? { env: params.env } : {});
81+
} catch {
82+
return undefined;
83+
}
84+
}

0 commit comments

Comments
 (0)