fix(channels): guard loaded channel registry metadata

This commit is contained in:
Vincent Koc
2026-06-04 06:33:36 +02:00
parent 5820d105c9
commit 64d8b7d214
3 changed files with 201 additions and 24 deletions

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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");
});
});