refactor(gateway): hard-break plugin wildcard http handlers

This commit is contained in:
Peter Steinberger
2026-03-02 16:22:31 +00:00
parent b13d48987c
commit 2fd8264ab0
31 changed files with 347 additions and 174 deletions

View File

@@ -13,7 +13,6 @@ export function createMockPluginRegistry(
source: "test",
})),
tools: [],
httpHandlers: [],
httpRoutes: [],
channelRegistrations: [],
gatewayHandlers: {},

View File

@@ -16,6 +16,8 @@ describe("registerPluginHttpRoute", () => {
expect(registry.httpRoutes).toHaveLength(1);
expect(registry.httpRoutes[0]?.path).toBe("/plugins/demo");
expect(registry.httpRoutes[0]?.handler).toBe(handler);
expect(registry.httpRoutes[0]?.auth).toBe("gateway");
expect(registry.httpRoutes[0]?.match).toBe("exact");
unregister();
expect(registry.httpRoutes).toHaveLength(0);
@@ -64,7 +66,7 @@ describe("registerPluginHttpRoute", () => {
expect(registry.httpRoutes).toHaveLength(1);
expect(registry.httpRoutes[0]?.handler).toBe(secondHandler);
expect(logs).toContain(
'plugin: replacing stale webhook path /plugins/synology for account "default" (synology-chat)',
'plugin: replacing stale webhook path /plugins/synology (exact) for account "default" (synology-chat)',
);
// Old unregister must not remove the replacement route.

View File

@@ -6,12 +6,14 @@ import { requireActivePluginRegistry } from "./runtime.js";
export type PluginHttpRouteHandler = (
req: IncomingMessage,
res: ServerResponse,
) => Promise<void> | void;
) => Promise<boolean | void> | boolean | void;
export function registerPluginHttpRoute(params: {
path?: string | null;
fallbackPath?: string | null;
handler: PluginHttpRouteHandler;
auth?: PluginHttpRouteRegistration["auth"];
match?: PluginHttpRouteRegistration["match"];
pluginId?: string;
source?: string;
accountId?: string;
@@ -29,16 +31,23 @@ export function registerPluginHttpRoute(params: {
return () => {};
}
const existingIndex = routes.findIndex((entry) => entry.path === normalizedPath);
const routeMatch = params.match ?? "exact";
const existingIndex = routes.findIndex(
(entry) => entry.path === normalizedPath && entry.match === routeMatch,
);
if (existingIndex >= 0) {
const pluginHint = params.pluginId ? ` (${params.pluginId})` : "";
params.log?.(`plugin: replacing stale webhook path ${normalizedPath}${suffix}${pluginHint}`);
params.log?.(
`plugin: replacing stale webhook path ${normalizedPath} (${routeMatch})${suffix}${pluginHint}`,
);
routes.splice(existingIndex, 1);
}
const entry: PluginHttpRouteRegistration = {
path: normalizedPath,
handler: params.handler,
auth: params.auth ?? "gateway",
match: routeMatch,
pluginId: params.pluginId,
source: params.source,
};

View File

@@ -518,13 +518,18 @@ describe("loadOpenClawPlugins", () => {
expect(channel).toBeDefined();
});
it("registers http handlers", () => {
it("registers http routes with auth and match options", () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "http-demo",
filename: "http-demo.cjs",
body: `module.exports = { id: "http-demo", register(api) {
api.registerHttpHandler(async () => false);
api.registerHttpRoute({
path: "/webhook",
auth: "plugin",
match: "prefix",
handler: async () => false
});
} };`,
});
@@ -535,10 +540,13 @@ describe("loadOpenClawPlugins", () => {
},
});
const handler = registry.httpHandlers.find((entry) => entry.pluginId === "http-demo");
expect(handler).toBeDefined();
const route = registry.httpRoutes.find((entry) => entry.pluginId === "http-demo");
expect(route).toBeDefined();
expect(route?.path).toBe("/webhook");
expect(route?.auth).toBe("plugin");
expect(route?.match).toBe("prefix");
const httpPlugin = registry.plugins.find((entry) => entry.id === "http-demo");
expect(httpPlugin?.httpHandlers).toBe(1);
expect(httpPlugin?.httpRoutes).toBe(1);
});
it("registers http routes", () => {
@@ -561,8 +569,10 @@ describe("loadOpenClawPlugins", () => {
const route = registry.httpRoutes.find((entry) => entry.pluginId === "http-route-demo");
expect(route).toBeDefined();
expect(route?.path).toBe("/demo");
expect(route?.auth).toBe("gateway");
expect(route?.match).toBe("exact");
const httpPlugin = registry.plugins.find((entry) => entry.id === "http-route-demo");
expect(httpPlugin?.httpHandlers).toBe(1);
expect(httpPlugin?.httpRoutes).toBe(1);
});
it("respects explicit disable in config", () => {

View File

@@ -176,7 +176,7 @@ function createPluginRecord(params: {
cliCommands: [],
services: [],
commands: [],
httpHandlers: 0,
httpRoutes: 0,
hookCount: 0,
configSchema: params.configSchema,
configUiHints: undefined,

View File

@@ -17,8 +17,10 @@ import type {
OpenClawPluginChannelRegistration,
OpenClawPluginCliRegistrar,
OpenClawPluginCommandDefinition,
OpenClawPluginHttpHandler,
OpenClawPluginHttpRouteAuth,
OpenClawPluginHttpRouteMatch,
OpenClawPluginHttpRouteHandler,
OpenClawPluginHttpRouteParams,
OpenClawPluginHookOptions,
ProviderPlugin,
OpenClawPluginService,
@@ -49,16 +51,12 @@ export type PluginCliRegistration = {
source: string;
};
export type PluginHttpRegistration = {
pluginId: string;
handler: OpenClawPluginHttpHandler;
source: string;
};
export type PluginHttpRouteRegistration = {
pluginId?: string;
path: string;
handler: OpenClawPluginHttpRouteHandler;
auth: OpenClawPluginHttpRouteAuth;
match: OpenClawPluginHttpRouteMatch;
source?: string;
};
@@ -114,7 +112,7 @@ export type PluginRecord = {
cliCommands: string[];
services: string[];
commands: string[];
httpHandlers: number;
httpRoutes: number;
hookCount: number;
configSchema: boolean;
configUiHints?: Record<string, PluginConfigUiHint>;
@@ -129,7 +127,6 @@ export type PluginRegistry = {
channels: PluginChannelRegistration[];
providers: PluginProviderRegistration[];
gatewayHandlers: GatewayRequestHandlers;
httpHandlers: PluginHttpRegistration[];
httpRoutes: PluginHttpRouteRegistration[];
cliRegistrars: PluginCliRegistration[];
services: PluginServiceRegistration[];
@@ -152,7 +149,6 @@ export function createEmptyPluginRegistry(): PluginRegistry {
channels: [],
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
@@ -288,19 +284,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
record.gatewayMethods.push(trimmed);
};
const registerHttpHandler = (record: PluginRecord, handler: OpenClawPluginHttpHandler) => {
record.httpHandlers += 1;
registry.httpHandlers.push({
pluginId: record.id,
handler,
source: record.source,
});
};
const registerHttpRoute = (
record: PluginRecord,
params: { path: string; handler: OpenClawPluginHttpRouteHandler },
) => {
const registerHttpRoute = (record: PluginRecord, params: OpenClawPluginHttpRouteParams) => {
const normalizedPath = normalizePluginHttpPath(params.path);
if (!normalizedPath) {
pushDiagnostic({
@@ -311,20 +295,25 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
});
return;
}
if (registry.httpRoutes.some((entry) => entry.path === normalizedPath)) {
const match = params.match ?? "exact";
if (
registry.httpRoutes.some((entry) => entry.path === normalizedPath && entry.match === match)
) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `http route already registered: ${normalizedPath}`,
message: `http route already registered: ${normalizedPath} (${match})`,
});
return;
}
record.httpHandlers += 1;
record.httpRoutes += 1;
registry.httpRoutes.push({
pluginId: record.id,
path: normalizedPath,
handler: params.handler,
auth: params.auth ?? "gateway",
match,
source: record.source,
});
};
@@ -489,7 +478,6 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registerTool: (tool, opts) => registerTool(record, tool, opts),
registerHook: (events, handler, opts) =>
registerHook(record, events, handler, opts, params.config),
registerHttpHandler: (handler) => registerHttpHandler(record, handler),
registerHttpRoute: (params) => registerHttpRoute(record, params),
registerChannel: (registration) => registerChannel(record, registration),
registerProvider: (provider) => registerProvider(record, provider),

View File

@@ -194,15 +194,20 @@ export type OpenClawPluginCommandDefinition = {
handler: PluginCommandHandler;
};
export type OpenClawPluginHttpHandler = (
req: IncomingMessage,
res: ServerResponse,
) => Promise<boolean> | boolean;
export type OpenClawPluginHttpRouteAuth = "gateway" | "plugin";
export type OpenClawPluginHttpRouteMatch = "exact" | "prefix";
export type OpenClawPluginHttpRouteHandler = (
req: IncomingMessage,
res: ServerResponse,
) => Promise<void> | void;
) => Promise<boolean | void> | boolean | void;
export type OpenClawPluginHttpRouteParams = {
path: string;
handler: OpenClawPluginHttpRouteHandler;
auth?: OpenClawPluginHttpRouteAuth;
match?: OpenClawPluginHttpRouteMatch;
};
export type OpenClawPluginCliContext = {
program: Command;
@@ -265,8 +270,7 @@ export type OpenClawPluginApi = {
handler: InternalHookHandler,
opts?: OpenClawPluginHookOptions,
) => void;
registerHttpHandler: (handler: OpenClawPluginHttpHandler) => void;
registerHttpRoute: (params: { path: string; handler: OpenClawPluginHttpRouteHandler }) => void;
registerHttpRoute: (params: OpenClawPluginHttpRouteParams) => void;
registerChannel: (registration: OpenClawPluginChannelRegistration | ChannelPlugin) => void;
registerGatewayMethod: (method: string, handler: GatewayRequestHandler) => void;
registerCli: (registrar: OpenClawPluginCliRegistrar, opts?: { commands?: string[] }) => void;