diff --git a/src/channels/plugins/registry-loaded-read.ts b/src/channels/plugins/registry-loaded-read.ts index 19064c5cf55c..20ccee11fa40 100644 --- a/src/channels/plugins/registry-loaded-read.ts +++ b/src/channels/plugins/registry-loaded-read.ts @@ -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; } } diff --git a/src/channels/plugins/registry-loaded.ts b/src/channels/plugins/registry-loaded.ts index 385225eae85b..68e1dd3ac2fa 100644 --- a/src/channels/plugins/registry-loaded.ts +++ b/src/channels/plugins/registry-loaded.ts @@ -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(); 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(); const entriesById = new Map(); - const unsortedEntriesById = new Map(pluginEntries.map((entry) => [entry.plugin.id, entry])); + const unsortedEntriesById = new Map(); + 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); } } diff --git a/src/channels/plugins/registry.test.ts b/src/channels/plugins/registry.test.ts index 8b04f4906807..12e2ef08f9b2 100644 --- a/src/channels/plugins/registry.test.ts +++ b/src/channels/plugins/registry.test.ts @@ -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"); + }); });