Files
Moltbot/src/gateway/server-http.ts
Sid 41c8734afd fix(gateway): move plugin HTTP routes before Control UI SPA catch-all (#31885)
* fix(gateway): move plugin HTTP routes before Control UI SPA catch-all

The Control UI handler (`handleControlUiHttpRequest`) acts as an SPA
catch-all that matches every path, returning HTML for GET requests and
405 for other methods.  Because it ran before `handlePluginRequest` in
the request chain, any plugin HTTP route that did not live under
`/plugins` or `/api` was unreachable — shadowed by the catch-all.

Reorder the handlers so plugin routes are evaluated first.  Core
built-in routes (hooks, tools, Slack, Canvas, etc.) still take
precedence because they are checked even earlier in the chain.
Unmatched plugin paths continue to fall through to Control UI as before.

Closes #31766

* fix: add changelog for plugin route precedence landing (#31885) (thanks @Sid-Qin)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-03-02 18:16:14 +00:00

705 lines
22 KiB
TypeScript

import {
createServer as createHttpServer,
type Server as HttpServer,
type IncomingMessage,
type ServerResponse,
} from "node:http";
import { createServer as createHttpsServer } from "node:https";
import type { TlsOptions } from "node:tls";
import type { WebSocketServer } from "ws";
import { resolveAgentAvatar } from "../agents/identity-avatar.js";
import { CANVAS_WS_PATH, handleA2uiHttpRequest } from "../canvas-host/a2ui.js";
import type { CanvasHostHandler } from "../canvas-host/server.js";
import { loadConfig } from "../config/config.js";
import type { createSubsystemLogger } from "../logging/subsystem.js";
import { safeEqualSecret } from "../security/secret-equal.js";
import { handleSlackHttpRequest } from "../slack/http/index.js";
import {
AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH,
createAuthRateLimiter,
normalizeRateLimitClientIp,
type AuthRateLimiter,
} from "./auth-rate-limit.js";
import { type GatewayAuthResult, type ResolvedGatewayAuth } from "./auth.js";
import { normalizeCanvasScopedUrl } from "./canvas-capability.js";
import {
handleControlUiAvatarRequest,
handleControlUiHttpRequest,
type ControlUiRootState,
} from "./control-ui.js";
import { applyHookMappings } from "./hooks-mapping.js";
import {
extractHookToken,
getHookAgentPolicyError,
getHookChannelError,
type HookAgentDispatchPayload,
type HooksConfigResolved,
isHookAgentAllowed,
normalizeAgentPayload,
normalizeHookHeaders,
normalizeWakePayload,
readJsonBody,
normalizeHookDispatchSessionKey,
resolveHookSessionKey,
resolveHookTargetAgentId,
resolveHookChannel,
resolveHookDeliver,
} from "./hooks.js";
import { sendGatewayAuthFailure, setDefaultSecurityHeaders } from "./http-common.js";
import { handleOpenAiHttpRequest } from "./openai-http.js";
import { handleOpenResponsesHttpRequest } from "./openresponses-http.js";
import {
authorizeCanvasRequest,
enforcePluginRouteGatewayAuth,
isCanvasPath,
} from "./server/http-auth.js";
import {
isProtectedPluginRoutePathFromContext,
resolvePluginRoutePathContext,
type PluginHttpRequestHandler,
type PluginRoutePathContext,
} from "./server/plugins-http.js";
import type { GatewayWsClient } from "./server/ws-types.js";
import { handleToolsInvokeHttpRequest } from "./tools-invoke-http.js";
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
const HOOK_AUTH_FAILURE_LIMIT = 20;
const HOOK_AUTH_FAILURE_WINDOW_MS = 60_000;
type HookDispatchers = {
dispatchWakeHook: (value: { text: string; mode: "now" | "next-heartbeat" }) => void;
dispatchAgentHook: (value: HookAgentDispatchPayload) => string;
};
function sendJson(res: ServerResponse, status: number, body: unknown) {
res.statusCode = status;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify(body));
}
const GATEWAY_PROBE_STATUS_BY_PATH = new Map<string, "live" | "ready">([
["/health", "live"],
["/healthz", "live"],
["/ready", "ready"],
["/readyz", "ready"],
]);
function shouldEnforceDefaultPluginGatewayAuth(pathContext: PluginRoutePathContext): boolean {
return (
pathContext.malformedEncoding ||
pathContext.decodePassLimitReached ||
isProtectedPluginRoutePathFromContext(pathContext)
);
}
function handleGatewayProbeRequest(
req: IncomingMessage,
res: ServerResponse,
requestPath: string,
): boolean {
const status = GATEWAY_PROBE_STATUS_BY_PATH.get(requestPath);
if (!status) {
return false;
}
const method = (req.method ?? "GET").toUpperCase();
if (method !== "GET" && method !== "HEAD") {
res.statusCode = 405;
res.setHeader("Allow", "GET, HEAD");
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Method Not Allowed");
return true;
}
res.statusCode = 200;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.setHeader("Cache-Control", "no-store");
if (method === "HEAD") {
res.end();
return true;
}
res.end(JSON.stringify({ ok: true, status }));
return true;
}
function writeUpgradeAuthFailure(
socket: { write: (chunk: string) => void },
auth: GatewayAuthResult,
) {
if (auth.rateLimited) {
const retryAfterSeconds =
auth.retryAfterMs && auth.retryAfterMs > 0 ? Math.ceil(auth.retryAfterMs / 1000) : undefined;
socket.write(
[
"HTTP/1.1 429 Too Many Requests",
retryAfterSeconds ? `Retry-After: ${retryAfterSeconds}` : undefined,
"Content-Type: application/json; charset=utf-8",
"Connection: close",
"",
JSON.stringify({
error: {
message: "Too many failed authentication attempts. Please try again later.",
type: "rate_limited",
},
}),
]
.filter(Boolean)
.join("\r\n"),
);
return;
}
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
}
export type HooksRequestHandler = (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
type GatewayHttpRequestStage = {
name: string;
run: () => Promise<boolean> | boolean;
};
async function runGatewayHttpRequestStages(
stages: readonly GatewayHttpRequestStage[],
): Promise<boolean> {
for (const stage of stages) {
if (await stage.run()) {
return true;
}
}
return false;
}
function buildPluginRequestStages(params: {
req: IncomingMessage;
res: ServerResponse;
requestPath: string;
pluginPathContext: PluginRoutePathContext | null;
handlePluginRequest?: PluginHttpRequestHandler;
shouldEnforcePluginGatewayAuth?: (pathContext: PluginRoutePathContext) => boolean;
resolvedAuth: ResolvedGatewayAuth;
trustedProxies: string[];
allowRealIpFallback: boolean;
rateLimiter?: AuthRateLimiter;
}): GatewayHttpRequestStage[] {
if (!params.handlePluginRequest) {
return [];
}
return [
{
name: "plugin-auth",
run: async () => {
const pathContext =
params.pluginPathContext ?? resolvePluginRoutePathContext(params.requestPath);
if (
!(params.shouldEnforcePluginGatewayAuth ?? shouldEnforceDefaultPluginGatewayAuth)(
pathContext,
)
) {
return false;
}
const pluginAuthOk = await enforcePluginRouteGatewayAuth({
req: params.req,
res: params.res,
auth: params.resolvedAuth,
trustedProxies: params.trustedProxies,
allowRealIpFallback: params.allowRealIpFallback,
rateLimiter: params.rateLimiter,
});
if (!pluginAuthOk) {
return true;
}
return false;
},
},
{
name: "plugin-http",
run: () => {
const pathContext =
params.pluginPathContext ?? resolvePluginRoutePathContext(params.requestPath);
return params.handlePluginRequest?.(params.req, params.res, pathContext) ?? false;
},
},
];
}
export function createHooksRequestHandler(
opts: {
getHooksConfig: () => HooksConfigResolved | null;
bindHost: string;
port: number;
logHooks: SubsystemLogger;
} & HookDispatchers,
): HooksRequestHandler {
const { getHooksConfig, logHooks, dispatchAgentHook, dispatchWakeHook } = opts;
const hookAuthLimiter = createAuthRateLimiter({
maxAttempts: HOOK_AUTH_FAILURE_LIMIT,
windowMs: HOOK_AUTH_FAILURE_WINDOW_MS,
lockoutMs: HOOK_AUTH_FAILURE_WINDOW_MS,
exemptLoopback: false,
// Handler lifetimes are tied to gateway runtime/tests; skip background timer fanout.
pruneIntervalMs: 0,
});
const resolveHookClientKey = (req: IncomingMessage): string => {
return normalizeRateLimitClientIp(req.socket?.remoteAddress);
};
return async (req, res) => {
const hooksConfig = getHooksConfig();
if (!hooksConfig) {
return false;
}
// Only pathname/search are used here; keep the base host fixed so bind-host
// representation (e.g. IPv6 wildcards) cannot break request parsing.
const url = new URL(req.url ?? "/", "http://localhost");
const basePath = hooksConfig.basePath;
if (url.pathname !== basePath && !url.pathname.startsWith(`${basePath}/`)) {
return false;
}
if (url.searchParams.has("token")) {
res.statusCode = 400;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end(
"Hook token must be provided via Authorization: Bearer <token> or X-OpenClaw-Token header (query parameters are not allowed).",
);
return true;
}
const token = extractHookToken(req);
const clientKey = resolveHookClientKey(req);
if (!safeEqualSecret(token, hooksConfig.token)) {
const throttle = hookAuthLimiter.check(clientKey, AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH);
if (!throttle.allowed) {
const retryAfter = throttle.retryAfterMs > 0 ? Math.ceil(throttle.retryAfterMs / 1000) : 1;
res.statusCode = 429;
res.setHeader("Retry-After", String(retryAfter));
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Too Many Requests");
logHooks.warn(`hook auth throttled for ${clientKey}; retry-after=${retryAfter}s`);
return true;
}
hookAuthLimiter.recordFailure(clientKey, AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH);
res.statusCode = 401;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Unauthorized");
return true;
}
hookAuthLimiter.reset(clientKey, AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH);
if (req.method !== "POST") {
res.statusCode = 405;
res.setHeader("Allow", "POST");
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Method Not Allowed");
return true;
}
const subPath = url.pathname.slice(basePath.length).replace(/^\/+/, "");
if (!subPath) {
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Not Found");
return true;
}
const body = await readJsonBody(req, hooksConfig.maxBodyBytes);
if (!body.ok) {
const status =
body.error === "payload too large"
? 413
: body.error === "request body timeout"
? 408
: 400;
sendJson(res, status, { ok: false, error: body.error });
return true;
}
const payload = typeof body.value === "object" && body.value !== null ? body.value : {};
const headers = normalizeHookHeaders(req);
if (subPath === "wake") {
const normalized = normalizeWakePayload(payload as Record<string, unknown>);
if (!normalized.ok) {
sendJson(res, 400, { ok: false, error: normalized.error });
return true;
}
dispatchWakeHook(normalized.value);
sendJson(res, 200, { ok: true, mode: normalized.value.mode });
return true;
}
if (subPath === "agent") {
const normalized = normalizeAgentPayload(payload as Record<string, unknown>);
if (!normalized.ok) {
sendJson(res, 400, { ok: false, error: normalized.error });
return true;
}
if (!isHookAgentAllowed(hooksConfig, normalized.value.agentId)) {
sendJson(res, 400, { ok: false, error: getHookAgentPolicyError() });
return true;
}
const sessionKey = resolveHookSessionKey({
hooksConfig,
source: "request",
sessionKey: normalized.value.sessionKey,
});
if (!sessionKey.ok) {
sendJson(res, 400, { ok: false, error: sessionKey.error });
return true;
}
const targetAgentId = resolveHookTargetAgentId(hooksConfig, normalized.value.agentId);
const runId = dispatchAgentHook({
...normalized.value,
sessionKey: normalizeHookDispatchSessionKey({
sessionKey: sessionKey.value,
targetAgentId,
}),
agentId: targetAgentId,
});
sendJson(res, 202, { ok: true, runId });
return true;
}
if (hooksConfig.mappings.length > 0) {
try {
const mapped = await applyHookMappings(hooksConfig.mappings, {
payload: payload as Record<string, unknown>,
headers,
url,
path: subPath,
});
if (mapped) {
if (!mapped.ok) {
sendJson(res, 400, { ok: false, error: mapped.error });
return true;
}
if (mapped.action === null) {
res.statusCode = 204;
res.end();
return true;
}
if (mapped.action.kind === "wake") {
dispatchWakeHook({
text: mapped.action.text,
mode: mapped.action.mode,
});
sendJson(res, 200, { ok: true, mode: mapped.action.mode });
return true;
}
const channel = resolveHookChannel(mapped.action.channel);
if (!channel) {
sendJson(res, 400, { ok: false, error: getHookChannelError() });
return true;
}
if (!isHookAgentAllowed(hooksConfig, mapped.action.agentId)) {
sendJson(res, 400, { ok: false, error: getHookAgentPolicyError() });
return true;
}
const sessionKey = resolveHookSessionKey({
hooksConfig,
source: "mapping",
sessionKey: mapped.action.sessionKey,
});
if (!sessionKey.ok) {
sendJson(res, 400, { ok: false, error: sessionKey.error });
return true;
}
const targetAgentId = resolveHookTargetAgentId(hooksConfig, mapped.action.agentId);
const runId = dispatchAgentHook({
message: mapped.action.message,
name: mapped.action.name ?? "Hook",
agentId: targetAgentId,
wakeMode: mapped.action.wakeMode,
sessionKey: normalizeHookDispatchSessionKey({
sessionKey: sessionKey.value,
targetAgentId,
}),
deliver: resolveHookDeliver(mapped.action.deliver),
channel,
to: mapped.action.to,
model: mapped.action.model,
thinking: mapped.action.thinking,
timeoutSeconds: mapped.action.timeoutSeconds,
allowUnsafeExternalContent: mapped.action.allowUnsafeExternalContent,
});
sendJson(res, 202, { ok: true, runId });
return true;
}
} catch (err) {
logHooks.warn(`hook mapping failed: ${String(err)}`);
sendJson(res, 500, { ok: false, error: "hook mapping failed" });
return true;
}
}
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Not Found");
return true;
};
}
export function createGatewayHttpServer(opts: {
canvasHost: CanvasHostHandler | null;
clients: Set<GatewayWsClient>;
controlUiEnabled: boolean;
controlUiBasePath: string;
controlUiRoot?: ControlUiRootState;
openAiChatCompletionsEnabled: boolean;
openResponsesEnabled: boolean;
openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig;
strictTransportSecurityHeader?: string;
handleHooksRequest: HooksRequestHandler;
handlePluginRequest?: PluginHttpRequestHandler;
shouldEnforcePluginGatewayAuth?: (pathContext: PluginRoutePathContext) => boolean;
resolvedAuth: ResolvedGatewayAuth;
/** Optional rate limiter for auth brute-force protection. */
rateLimiter?: AuthRateLimiter;
tlsOptions?: TlsOptions;
}): HttpServer {
const {
canvasHost,
clients,
controlUiEnabled,
controlUiBasePath,
controlUiRoot,
openAiChatCompletionsEnabled,
openResponsesEnabled,
openResponsesConfig,
strictTransportSecurityHeader,
handleHooksRequest,
handlePluginRequest,
shouldEnforcePluginGatewayAuth,
resolvedAuth,
rateLimiter,
} = opts;
const httpServer: HttpServer = opts.tlsOptions
? createHttpsServer(opts.tlsOptions, (req, res) => {
void handleRequest(req, res);
})
: createHttpServer((req, res) => {
void handleRequest(req, res);
});
async function handleRequest(req: IncomingMessage, res: ServerResponse) {
setDefaultSecurityHeaders(res, {
strictTransportSecurity: strictTransportSecurityHeader,
});
// Don't interfere with WebSocket upgrades; ws handles the 'upgrade' event.
if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket") {
return;
}
try {
const configSnapshot = loadConfig();
const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
const allowRealIpFallback = configSnapshot.gateway?.allowRealIpFallback === true;
const scopedCanvas = normalizeCanvasScopedUrl(req.url ?? "/");
if (scopedCanvas.malformedScopedPath) {
sendGatewayAuthFailure(res, { ok: false, reason: "unauthorized" });
return;
}
if (scopedCanvas.rewrittenUrl) {
req.url = scopedCanvas.rewrittenUrl;
}
const requestPath = new URL(req.url ?? "/", "http://localhost").pathname;
const pluginPathContext = handlePluginRequest
? resolvePluginRoutePathContext(requestPath)
: null;
const requestStages: GatewayHttpRequestStage[] = [
{
name: "hooks",
run: () => handleHooksRequest(req, res),
},
{
name: "tools-invoke",
run: () =>
handleToolsInvokeHttpRequest(req, res, {
auth: resolvedAuth,
trustedProxies,
allowRealIpFallback,
rateLimiter,
}),
},
{
name: "slack",
run: () => handleSlackHttpRequest(req, res),
},
];
if (openResponsesEnabled) {
requestStages.push({
name: "openresponses",
run: () =>
handleOpenResponsesHttpRequest(req, res, {
auth: resolvedAuth,
config: openResponsesConfig,
trustedProxies,
allowRealIpFallback,
rateLimiter,
}),
});
}
if (openAiChatCompletionsEnabled) {
requestStages.push({
name: "openai",
run: () =>
handleOpenAiHttpRequest(req, res, {
auth: resolvedAuth,
trustedProxies,
allowRealIpFallback,
rateLimiter,
}),
});
}
if (canvasHost) {
requestStages.push({
name: "canvas-auth",
run: async () => {
if (!isCanvasPath(requestPath)) {
return false;
}
const ok = await authorizeCanvasRequest({
req,
auth: resolvedAuth,
trustedProxies,
allowRealIpFallback,
clients,
canvasCapability: scopedCanvas.capability,
malformedScopedPath: scopedCanvas.malformedScopedPath,
rateLimiter,
});
if (!ok.ok) {
sendGatewayAuthFailure(res, ok);
return true;
}
return false;
},
});
requestStages.push({
name: "a2ui",
run: () => handleA2uiHttpRequest(req, res),
});
requestStages.push({
name: "canvas-http",
run: () => canvasHost.handleHttpRequest(req, res),
});
}
// Plugin routes run before the Control UI SPA catch-all so explicitly
// registered plugin endpoints stay reachable. Core built-in gateway
// routes above still keep precedence on overlapping paths.
requestStages.push(
...buildPluginRequestStages({
req,
res,
requestPath,
pluginPathContext,
handlePluginRequest,
shouldEnforcePluginGatewayAuth,
resolvedAuth,
trustedProxies,
allowRealIpFallback,
rateLimiter,
}),
);
if (controlUiEnabled) {
requestStages.push({
name: "control-ui-avatar",
run: () =>
handleControlUiAvatarRequest(req, res, {
basePath: controlUiBasePath,
resolveAvatar: (agentId) => resolveAgentAvatar(configSnapshot, agentId),
}),
});
requestStages.push({
name: "control-ui-http",
run: () =>
handleControlUiHttpRequest(req, res, {
basePath: controlUiBasePath,
config: configSnapshot,
root: controlUiRoot,
}),
});
}
requestStages.push({
name: "gateway-probes",
run: () => handleGatewayProbeRequest(req, res, requestPath),
});
if (await runGatewayHttpRequestStages(requestStages)) {
return;
}
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Not Found");
} catch {
res.statusCode = 500;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Internal Server Error");
}
}
return httpServer;
}
export function attachGatewayUpgradeHandler(opts: {
httpServer: HttpServer;
wss: WebSocketServer;
canvasHost: CanvasHostHandler | null;
clients: Set<GatewayWsClient>;
resolvedAuth: ResolvedGatewayAuth;
/** Optional rate limiter for auth brute-force protection. */
rateLimiter?: AuthRateLimiter;
}) {
const { httpServer, wss, canvasHost, clients, resolvedAuth, rateLimiter } = opts;
httpServer.on("upgrade", (req, socket, head) => {
void (async () => {
const scopedCanvas = normalizeCanvasScopedUrl(req.url ?? "/");
if (scopedCanvas.malformedScopedPath) {
writeUpgradeAuthFailure(socket, { ok: false, reason: "unauthorized" });
socket.destroy();
return;
}
if (scopedCanvas.rewrittenUrl) {
req.url = scopedCanvas.rewrittenUrl;
}
if (canvasHost) {
const url = new URL(req.url ?? "/", "http://localhost");
if (url.pathname === CANVAS_WS_PATH) {
const configSnapshot = loadConfig();
const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
const allowRealIpFallback = configSnapshot.gateway?.allowRealIpFallback === true;
const ok = await authorizeCanvasRequest({
req,
auth: resolvedAuth,
trustedProxies,
allowRealIpFallback,
clients,
canvasCapability: scopedCanvas.capability,
malformedScopedPath: scopedCanvas.malformedScopedPath,
rateLimiter,
});
if (!ok.ok) {
writeUpgradeAuthFailure(socket, ok);
socket.destroy();
return;
}
}
if (canvasHost.handleUpgrade(req, socket, head)) {
return;
}
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
})().catch(() => {
socket.destroy();
});
});
}