mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix(channels): guard loaded channel registry metadata
This commit is contained in:
@@ -11,18 +11,29 @@ import type { ChannelId } from "./types.public.js";
|
||||
function coerceLoadedChannelPlugin(
|
||||
plugin: ActiveChannelPluginRuntimeShape | null | undefined,
|
||||
): ChannelPlugin | undefined {
|
||||
const id = normalizeOptionalString(plugin?.id) ?? "";
|
||||
if (!plugin || !id) {
|
||||
if (!plugin || typeof plugin !== "object" || !readLoadedChannelId(plugin)) {
|
||||
return undefined;
|
||||
}
|
||||
if (!plugin.meta || typeof plugin.meta !== "object") {
|
||||
// Normalize optional metadata for callers that inspect labels/capabilities
|
||||
// without requiring a full registry view materialization.
|
||||
plugin.meta = {};
|
||||
try {
|
||||
if (!plugin.meta || typeof plugin.meta !== "object") {
|
||||
// Normalize optional metadata for callers that inspect labels/capabilities
|
||||
// without requiring a full registry view materialization.
|
||||
plugin.meta = {};
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return plugin as ChannelPlugin;
|
||||
}
|
||||
|
||||
function readLoadedChannelId(plugin: ActiveChannelPluginRuntimeShape): string {
|
||||
try {
|
||||
return normalizeOptionalString(plugin.id) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads one loaded channel plugin directly from active runtime state.
|
||||
*/
|
||||
@@ -37,7 +48,7 @@ export function getLoadedChannelPluginForRead(id: ChannelId): ChannelPlugin | un
|
||||
}
|
||||
for (const entry of registry.channels) {
|
||||
const plugin = coerceLoadedChannelPlugin(entry?.plugin);
|
||||
if (plugin && plugin.id === resolvedId) {
|
||||
if (plugin && readLoadedChannelId(plugin) === resolvedId) {
|
||||
return plugin;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,23 +34,51 @@ type ChannelPluginView = {
|
||||
function coerceLoadedChannelPlugin(
|
||||
plugin: ActiveChannelPluginRuntimeShape | null | undefined,
|
||||
): LoadedChannelPlugin | null {
|
||||
const id = normalizeOptionalString(plugin?.id) ?? "";
|
||||
if (!plugin || !id) {
|
||||
if (!plugin || typeof plugin !== "object") {
|
||||
return null;
|
||||
}
|
||||
if (!plugin.meta || typeof plugin.meta !== "object") {
|
||||
// Loaded plugin metadata is optional at the runtime-state boundary, but
|
||||
// channel sorting expects an object so normalize it once at read time.
|
||||
plugin.meta = {};
|
||||
const id = readLoadedChannelId(plugin);
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
return normalizeLoadedChannelMeta(plugin) ? (plugin as LoadedChannelPlugin) : null;
|
||||
}
|
||||
|
||||
function readLoadedChannelId(plugin: ActiveChannelPluginRuntimeShape): string {
|
||||
try {
|
||||
return normalizeOptionalString(plugin.id) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLoadedChannelMeta(plugin: ActiveChannelPluginRuntimeShape): boolean {
|
||||
try {
|
||||
if (!plugin.meta || typeof plugin.meta !== "object") {
|
||||
// Loaded plugin metadata is optional at the runtime-state boundary, but
|
||||
// channel sorting expects an object so normalize it once at read time.
|
||||
plugin.meta = {};
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function readLoadedChannelOrder(plugin: LoadedChannelPlugin): number | undefined {
|
||||
try {
|
||||
const order = plugin.meta.order;
|
||||
return typeof order === "number" && Number.isFinite(order) ? order : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return plugin as LoadedChannelPlugin;
|
||||
}
|
||||
|
||||
function dedupeChannels(channels: LoadedChannelPlugin[]): LoadedChannelPlugin[] {
|
||||
const seen = new Set<string>();
|
||||
const resolved: LoadedChannelPlugin[] = [];
|
||||
for (const plugin of channels) {
|
||||
const id = normalizeOptionalString(plugin.id) ?? "";
|
||||
const id = readLoadedChannelId(plugin);
|
||||
if (!id || seen.has(id)) {
|
||||
continue;
|
||||
}
|
||||
@@ -76,25 +104,37 @@ function resolveChannelPlugins(): ChannelPluginView {
|
||||
}
|
||||
|
||||
const sorted = dedupeChannels(channelPlugins).toSorted((a, b) => {
|
||||
const indexA = CHAT_CHANNEL_ORDER.indexOf(a.id);
|
||||
const indexB = CHAT_CHANNEL_ORDER.indexOf(b.id);
|
||||
const idA = readLoadedChannelId(a);
|
||||
const idB = readLoadedChannelId(b);
|
||||
const indexA = CHAT_CHANNEL_ORDER.indexOf(idA);
|
||||
const indexB = CHAT_CHANNEL_ORDER.indexOf(idB);
|
||||
// Explicit plugin order wins; known built-ins keep their product order;
|
||||
// unknown extension channels sort after them by id for deterministic lists.
|
||||
const orderA = a.meta.order ?? (indexA === -1 ? 999 : indexA);
|
||||
const orderB = b.meta.order ?? (indexB === -1 ? 999 : indexB);
|
||||
const orderA = readLoadedChannelOrder(a) ?? (indexA === -1 ? 999 : indexA);
|
||||
const orderB = readLoadedChannelOrder(b) ?? (indexB === -1 ? 999 : indexB);
|
||||
if (orderA !== orderB) {
|
||||
return orderA - orderB;
|
||||
}
|
||||
return a.id.localeCompare(b.id);
|
||||
return idA.localeCompare(idB);
|
||||
});
|
||||
const byId = new Map<string, LoadedChannelPlugin>();
|
||||
const entriesById = new Map<string, LoadedChannelPluginEntry>();
|
||||
const unsortedEntriesById = new Map(pluginEntries.map((entry) => [entry.plugin.id, entry]));
|
||||
const unsortedEntriesById = new Map<string, LoadedChannelPluginEntry>();
|
||||
for (const entry of pluginEntries) {
|
||||
const id = readLoadedChannelId(entry.plugin);
|
||||
if (id) {
|
||||
unsortedEntriesById.set(id, entry);
|
||||
}
|
||||
}
|
||||
for (const plugin of sorted) {
|
||||
byId.set(plugin.id, plugin);
|
||||
const entry = unsortedEntriesById.get(plugin.id);
|
||||
const id = readLoadedChannelId(plugin);
|
||||
if (!id) {
|
||||
continue;
|
||||
}
|
||||
byId.set(id, plugin);
|
||||
const entry = unsortedEntriesById.get(id);
|
||||
if (entry) {
|
||||
entriesById.set(plugin.id, entry);
|
||||
entriesById.set(id, entry);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js";
|
||||
import type { PluginRegistry } from "../../plugins/registry.js";
|
||||
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import { getLoadedChannelPluginForRead } from "./registry-loaded-read.js";
|
||||
import { getChannelPlugin, listChannelPlugins } from "./registry.js";
|
||||
|
||||
vi.mock("./bundled.js", () => ({
|
||||
@@ -72,4 +73,129 @@ describe("listChannelPlugins", () => {
|
||||
expect(getChannelPlugin("beta")?.meta.label).toBe("beta");
|
||||
expect(listChannelPlugins().map((plugin) => plugin.id)).toEqual(["beta"]);
|
||||
});
|
||||
|
||||
it("keeps loaded channel plugin identities stable across registry reads", () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
const plugin = {
|
||||
id: "alpha",
|
||||
meta: { label: "alpha" },
|
||||
};
|
||||
registry.channels = [
|
||||
{
|
||||
pluginId: "alpha",
|
||||
plugin: plugin as never,
|
||||
source: "test",
|
||||
},
|
||||
];
|
||||
setActivePluginRegistry(registry);
|
||||
|
||||
const first = listChannelPlugins()[0];
|
||||
expect(first).toBe(plugin);
|
||||
expect(listChannelPlugins()[0]).toBe(first);
|
||||
expect(getChannelPlugin("alpha")).toBe(first);
|
||||
});
|
||||
|
||||
it("skips loaded channel plugins with unreadable ids", () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
const brokenPlugin = Object.defineProperty(
|
||||
{
|
||||
meta: { label: "broken" },
|
||||
},
|
||||
"id",
|
||||
{
|
||||
get() {
|
||||
throw new Error("channel id getter exploded");
|
||||
},
|
||||
},
|
||||
);
|
||||
registry.channels = [
|
||||
{
|
||||
pluginId: "broken",
|
||||
plugin: brokenPlugin as never,
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "healthy",
|
||||
plugin: {
|
||||
id: "healthy",
|
||||
meta: { label: "healthy" },
|
||||
} as never,
|
||||
source: "test",
|
||||
},
|
||||
];
|
||||
setActivePluginRegistry(registry);
|
||||
|
||||
expect(listChannelPlugins().map((plugin) => plugin.id)).toEqual(["healthy"]);
|
||||
});
|
||||
|
||||
it("skips unreadable loaded channel ids in the direct read path", () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
const brokenPlugin = Object.defineProperty(
|
||||
{
|
||||
meta: { label: "broken" },
|
||||
},
|
||||
"id",
|
||||
{
|
||||
get() {
|
||||
throw new Error("direct read channel id getter exploded");
|
||||
},
|
||||
},
|
||||
);
|
||||
registry.channels = [
|
||||
{
|
||||
pluginId: "broken",
|
||||
plugin: brokenPlugin as never,
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "healthy",
|
||||
plugin: {
|
||||
id: "healthy",
|
||||
meta: { label: "healthy" },
|
||||
} as never,
|
||||
source: "test",
|
||||
},
|
||||
];
|
||||
setActivePluginRegistry(registry);
|
||||
|
||||
expect(getLoadedChannelPluginForRead("healthy")?.id).toBe("healthy");
|
||||
expect(getLoadedChannelPluginForRead("broken")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back when loaded channel order metadata is unreadable", () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
const brokenOrderMeta = Object.defineProperty(
|
||||
{
|
||||
label: "broken-order",
|
||||
},
|
||||
"order",
|
||||
{
|
||||
get() {
|
||||
throw new Error("channel order getter exploded");
|
||||
},
|
||||
},
|
||||
);
|
||||
registry.channels = [
|
||||
{
|
||||
pluginId: "broken-order",
|
||||
plugin: {
|
||||
id: "broken-order",
|
||||
meta: brokenOrderMeta,
|
||||
} as never,
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "healthy",
|
||||
plugin: {
|
||||
id: "healthy",
|
||||
meta: { label: "healthy", order: 1 },
|
||||
} as never,
|
||||
source: "test",
|
||||
},
|
||||
];
|
||||
setActivePluginRegistry(registry);
|
||||
|
||||
expect(listChannelPlugins().map((plugin) => plugin.id)).toEqual(["healthy", "broken-order"]);
|
||||
expect(getChannelPlugin("broken-order")?.meta.label).toBe("broken-order");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user