From 9efa86be998a58351d45db89b0a1ef36d324fef6 Mon Sep 17 00:00:00 2001 From: Rai Butera Date: Fri, 13 Mar 2026 16:10:45 +0000 Subject: [PATCH] fix(gateway): keep reserved channel ids immutable --- src/infra/outbound/agent-delivery.ts | 4 ++-- src/plugins/install.ts | 8 +++++--- src/plugins/registry.ts | 11 ++++------- src/utils/message-channel.test.ts | 11 +++++++++++ src/utils/message-channel.ts | 13 +++++++++++-- 5 files changed, 33 insertions(+), 14 deletions(-) diff --git a/src/infra/outbound/agent-delivery.ts b/src/infra/outbound/agent-delivery.ts index 16e530a4e09..d318ef96a98 100644 --- a/src/infra/outbound/agent-delivery.ts +++ b/src/infra/outbound/agent-delivery.ts @@ -4,8 +4,8 @@ import type { SessionEntry } from "../../config/sessions.js"; import { normalizeAccountId } from "../../utils/account-id.js"; import { INTERNAL_MESSAGE_CHANNEL, - RESERVED_CHANNEL_IDS, isDeliverableMessageChannel, + isReservedChannelId, isGatewayMessageChannel, normalizeMessageChannel, type GatewayMessageChannel, @@ -94,7 +94,7 @@ export function resolveAgentDeliveryPlan(params: { // before reaching this planner. if ( requestedChannel && - RESERVED_CHANNEL_IDS.has(requestedChannel.toLowerCase()) && + isReservedChannelId(requestedChannel) && requestedChannel !== INTERNAL_MESSAGE_CHANNEL ) { return INTERNAL_MESSAGE_CHANNEL; diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 1998083fc56..7e541bb3b64 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -31,7 +31,7 @@ import { validateRegistryNpmSpec } from "../infra/npm-registry-spec.js"; import { extensionUsesSkippedScannerPath, isPathInside } from "../security/scan-paths.js"; import * as skillScanner from "../security/skill-scanner.js"; import { CONFIG_DIR, resolveUserPath } from "../utils.js"; -import { RESERVED_CHANNEL_IDS } from "../utils/message-channel.js"; +import { isReservedChannelId } from "../utils/message-channel.js"; import { loadPluginManifest, resolvePackageExtensionEntries, @@ -86,6 +86,8 @@ function safeFileName(input: string): string { return safeDirName(input); } +// Reserved plugin ids are checked through message-channel.ts so plugin install +// and runtime registration share one immutable source of truth. function encodePluginInstallDirName(pluginId: string): string { const trimmed = pluginId.trim(); if (!trimmed.includes("/")) { @@ -115,12 +117,12 @@ function validatePluginId(pluginId: string): string | null { if (trimmed.startsWith("@")) { return "invalid plugin name: scoped ids must use @scope/name format"; } - if (RESERVED_CHANNEL_IDS.has(trimmed.toLowerCase())) { + if (isReservedChannelId(trimmed)) { return `invalid plugin name: "${pluginId}" is a reserved internal channel id`; } return null; } - if (RESERVED_CHANNEL_IDS.has(unscopedPackageName(trimmed).toLowerCase())) { + if (isReservedChannelId(unscopedPackageName(trimmed))) { return `invalid plugin name: "${pluginId}" is a reserved internal channel id`; } if (segments.length !== 2) { diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 987657d953a..9cb7b7e56be 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -10,7 +10,7 @@ import type { import { registerInternalHook } from "../hooks/internal-hooks.js"; import type { HookEntry } from "../hooks/types.js"; import { resolveUserPath } from "../utils.js"; -import { RESERVED_CHANNEL_IDS } from "../utils/message-channel.js"; +import { isReservedChannelId } from "../utils/message-channel.js"; import { registerPluginCommand } from "./commands.js"; import { normalizePluginHttpPath } from "./http-path.js"; import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js"; @@ -431,7 +431,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }); return; } - if (RESERVED_CHANNEL_IDS.has(id.toLowerCase())) { + if (isReservedChannelId(id)) { pushDiagnostic({ level: "error", pluginId: record.id, @@ -444,8 +444,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { // "inter_session" or "webchat" would cause normalizeMessageChannel to // remap the sentinel into a real deliverable channel, bypassing the guards // in resolveLastChannelRaw / resolveLastToRaw. - const reservedAliases = - plugin.meta?.aliases?.filter((a) => RESERVED_CHANNEL_IDS.has(a.trim().toLowerCase())) ?? []; + const reservedAliases = plugin.meta?.aliases?.filter((a) => isReservedChannelId(a)) ?? []; if (reservedAliases.length > 0) { pushDiagnostic({ level: "error", @@ -456,9 +455,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { // Strip reserved aliases rather than blocking the whole registration so // the rest of the channel can still be used normally. if (plugin.meta?.aliases) { - plugin.meta.aliases = plugin.meta.aliases.filter( - (a) => !RESERVED_CHANNEL_IDS.has(a.trim().toLowerCase()), - ); + plugin.meta.aliases = plugin.meta.aliases.filter((a) => !isReservedChannelId(a)); } } const existing = registry.channels.find((entry) => entry.plugin.id === id); diff --git a/src/utils/message-channel.test.ts b/src/utils/message-channel.test.ts index 1850328ae67..bba89320365 100644 --- a/src/utils/message-channel.test.ts +++ b/src/utils/message-channel.test.ts @@ -4,6 +4,7 @@ import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createMSTeamsTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import { INTER_SESSION_CHANNEL, + listReservedChannelIds, normalizeMessageChannel, resolveGatewayMessageChannel, } from "./message-channel.js"; @@ -50,4 +51,14 @@ describe("message-channel", () => { expect(normalizeMessageChannel(INTER_SESSION_CHANNEL)).toBe(INTER_SESSION_CHANNEL); }); + + it("does not let callers mutate the reserved channel source of truth", () => { + const reserved = listReservedChannelIds(); + + reserved.length = 0; + reserved.push("discord"); + + expect(normalizeMessageChannel(INTER_SESSION_CHANNEL)).toBe(INTER_SESSION_CHANNEL); + expect(normalizeMessageChannel("webchat")).toBe("webchat"); + }); }); diff --git a/src/utils/message-channel.ts b/src/utils/message-channel.ts index be51242452c..3314a31c501 100644 --- a/src/utils/message-channel.ts +++ b/src/utils/message-channel.ts @@ -33,11 +33,20 @@ export type InterSessionChannel = typeof INTER_SESSION_CHANNEL; * Checked in both plugin install (validatePluginId) and runtime channel * registration (registerChannel) to cover all registration paths. */ -export const RESERVED_CHANNEL_IDS: Set = new Set([ +const RESERVED_CHANNEL_IDS: ReadonlySet = new Set([ INTER_SESSION_CHANNEL, INTERNAL_MESSAGE_CHANNEL, ]); +export function isReservedChannelId(raw?: string | null): boolean { + const normalized = raw?.trim().toLowerCase(); + return Boolean(normalized) && RESERVED_CHANNEL_IDS.has(normalized); +} + +export function listReservedChannelIds(): string[] { + return Array.from(RESERVED_CHANNEL_IDS); +} + export function isInterSessionChannel(raw?: string | null): boolean { // Guard against collision with real deliverable plugin channels: a plugin // could theoretically register a channel named "inter_session", which must @@ -88,7 +97,7 @@ export function normalizeMessageChannel(raw?: string | null): string | undefined if (!normalized) { return undefined; } - if (RESERVED_CHANNEL_IDS.has(normalized)) { + if (isReservedChannelId(normalized)) { return normalized; } const builtIn = normalizeChatChannelId(normalized);