fix(gateway): keep reserved channel ids immutable

This commit is contained in:
Rai Butera 2026-03-13 16:10:45 +00:00
parent f616055958
commit 9efa86be99
5 changed files with 33 additions and 14 deletions

View File

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

View File

@ -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) {

View File

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

View File

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

View File

@ -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<string> = new Set([
const RESERVED_CHANNEL_IDS: ReadonlySet<string> = 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);