mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:40:44 +00:00
fix(plugins): forward install records to channel catalog registry (#77269)
Merged via squash.
Prepared head SHA: d06034b037
Co-authored-by: pumpkinxing1 <271513653+pumpkinxing1@users.noreply.github.com>
Co-authored-by: odysseus0 <8635094+odysseus0@users.noreply.github.com>
Reviewed-by: @odysseus0
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -51,6 +51,7 @@ const EXPECTED_EMPTY_CONFIG_GATEWAY_STARTUP_PLUGIN_IDS = [
|
||||
"acpx",
|
||||
"browser",
|
||||
"device-pair",
|
||||
"discord",
|
||||
"file-transfer",
|
||||
"memory-core",
|
||||
"phone-control",
|
||||
|
||||
116
src/plugins/channel-catalog-registry.test.ts
Normal file
116
src/plugins/channel-catalog-registry.test.ts
Normal file
@@ -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<string, PluginInstallRecord> = {
|
||||
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<string, PluginInstallRecord>;
|
||||
}): Promise<{
|
||||
module: typeof import("./channel-catalog-registry.js");
|
||||
discoverSpy: ReturnType<typeof vi.fn>;
|
||||
loadRecordsSpy: ReturnType<typeof vi.fn>;
|
||||
}> {
|
||||
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<string, PluginInstallRecord> = {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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<string, PluginInstallRecord>;
|
||||
} = {},
|
||||
): 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<string, PluginInstallRecord>;
|
||||
}): Record<string, PluginInstallRecord> | undefined {
|
||||
if (params.installRecords) {
|
||||
return params.installRecords;
|
||||
}
|
||||
if (params.origin === "bundled") {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return loadInstalledPluginIndexInstallRecordsSync(params.env ? { env: params.env } : {});
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user