import { createHash } from "node:crypto"; 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 { handleSlackHttpRequest } from "../../extensions/slack/api.js"; 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 { AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH, createAuthRateLimiter, normalizeRateLimitClientIp, type AuthRateLimiter, } from "./auth-rate-limit.js"; import { authorizeHttpGatewayConnect, isLocalDirectRequest, 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, resolveHookIdempotencyKey, normalizeWakePayload, readJsonBody, normalizeHookDispatchSessionKey, resolveHookSessionKey, resolveHookTargetAgentId, resolveHookChannel, resolveHookDeliver, } from "./hooks.js"; import { sendGatewayAuthFailure, setDefaultSecurityHeaders } from "./http-common.js"; import { getBearerToken } from "./http-utils.js"; import { resolveRequestClientIp } from "./net.js"; import { handleOpenAiHttpRequest } from "./openai-http.js"; import { handleOpenResponsesHttpRequest } from "./openresponses-http.js"; import { DEDUPE_MAX, DEDUPE_TTL_MS } from "./server-constants.js"; import { authorizeCanvasRequest, enforcePluginRouteGatewayAuth, isCanvasPath, } from "./server/http-auth.js"; import { isProtectedPluginRoutePathFromContext, resolvePluginRoutePathContext, type PluginHttpRequestHandler, type PluginRoutePathContext, } from "./server/plugins-http.js"; import type { ReadinessChecker } from "./server/readiness.js"; import type { GatewayWsClient } from "./server/ws-types.js"; import { handleSessionKillHttpRequest } from "./session-kill-http.js"; import { handleSessionHistoryHttpRequest } from "./sessions-history-http.js"; import { handleToolsInvokeHttpRequest } from "./tools-invoke-http.js"; type SubsystemLogger = ReturnType; 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; }; export type HookClientIpConfig = Readonly<{ trustedProxies?: string[]; allowRealIpFallback?: boolean; }>; type HookReplayEntry = { ts: number; runId: string; }; type HookReplayScope = { pathKey: string; token: string | undefined; idempotencyKey?: string; dispatchScope: Record; }; 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([ ["/health", "live"], ["/healthz", "live"], ["/ready", "ready"], ["/readyz", "ready"], ]); const MATTERMOST_SLASH_CALLBACK_PATH = "/api/channels/mattermost/command"; function resolveMattermostSlashCallbackPaths( configSnapshot: ReturnType, ): Set { const callbackPaths = new Set([MATTERMOST_SLASH_CALLBACK_PATH]); const isMattermostCommandCallbackPath = (path: string): boolean => path === MATTERMOST_SLASH_CALLBACK_PATH || path.startsWith("/api/channels/mattermost/"); const normalizeCallbackPath = (value: unknown): string => { const trimmed = typeof value === "string" ? value.trim() : ""; if (!trimmed) { return MATTERMOST_SLASH_CALLBACK_PATH; } return trimmed.startsWith("/") ? trimmed : `/${trimmed}`; }; const tryAddCallbackUrlPath = (rawUrl: unknown) => { if (typeof rawUrl !== "string") { return; } const trimmed = rawUrl.trim(); if (!trimmed) { return; } try { const pathname = new URL(trimmed).pathname; if (pathname && isMattermostCommandCallbackPath(pathname)) { callbackPaths.add(pathname); } } catch { // Ignore invalid callback URLs in config and keep default path behavior. } }; const mmRaw = configSnapshot.channels?.mattermost as Record | undefined; const addMmCommands = (raw: unknown) => { if (raw == null || typeof raw !== "object") { return; } const commands = raw as Record; const callbackPath = normalizeCallbackPath(commands.callbackPath); if (isMattermostCommandCallbackPath(callbackPath)) { callbackPaths.add(callbackPath); } tryAddCallbackUrlPath(commands.callbackUrl); }; addMmCommands(mmRaw?.commands); const accountsRaw = (mmRaw?.accounts ?? {}) as Record; for (const accountId of Object.keys(accountsRaw)) { const accountCfg = accountsRaw[accountId] as Record | undefined; addMmCommands(accountCfg?.commands); } return callbackPaths; } function shouldEnforceDefaultPluginGatewayAuth(pathContext: PluginRoutePathContext): boolean { return ( pathContext.malformedEncoding || pathContext.decodePassLimitReached || isProtectedPluginRoutePathFromContext(pathContext) ); } async function canRevealReadinessDetails(params: { req: IncomingMessage; resolvedAuth: ResolvedGatewayAuth; trustedProxies: string[]; allowRealIpFallback: boolean; }): Promise { if (isLocalDirectRequest(params.req, params.trustedProxies, params.allowRealIpFallback)) { return true; } if (params.resolvedAuth.mode === "none") { return false; } const bearerToken = getBearerToken(params.req); const authResult = await authorizeHttpGatewayConnect({ auth: params.resolvedAuth, connectAuth: bearerToken ? { token: bearerToken, password: bearerToken } : null, req: params.req, trustedProxies: params.trustedProxies, allowRealIpFallback: params.allowRealIpFallback, }); return authResult.ok; } async function handleGatewayProbeRequest( req: IncomingMessage, res: ServerResponse, requestPath: string, resolvedAuth: ResolvedGatewayAuth, trustedProxies: string[], allowRealIpFallback: boolean, getReadiness?: ReadinessChecker, ): Promise { 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.setHeader("Content-Type", "application/json; charset=utf-8"); res.setHeader("Cache-Control", "no-store"); let statusCode: number; let body: string; if (status === "ready" && getReadiness) { const includeDetails = await canRevealReadinessDetails({ req, resolvedAuth, trustedProxies, allowRealIpFallback, }); try { const result = getReadiness(); statusCode = result.ready ? 200 : 503; body = JSON.stringify(includeDetails ? result : { ready: result.ready }); } catch { statusCode = 503; body = JSON.stringify( includeDetails ? { ready: false, failing: ["internal"], uptimeMs: 0 } : { ready: false }, ); } } else { statusCode = 200; body = JSON.stringify({ ok: true, status }); } res.statusCode = statusCode; res.end(method === "HEAD" ? undefined : body); 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; type GatewayHttpRequestStage = { name: string; run: () => Promise | boolean; }; async function runGatewayHttpRequestStages( stages: readonly GatewayHttpRequestStage[], ): Promise { for (const stage of stages) { if (await stage.run()) { return true; } } return false; } function buildPluginRequestStages(params: { req: IncomingMessage; res: ServerResponse; requestPath: string; mattermostSlashCallbackPaths: ReadonlySet; pluginPathContext: PluginRoutePathContext | null; handlePluginRequest?: PluginHttpRequestHandler; shouldEnforcePluginGatewayAuth?: (pathContext: PluginRoutePathContext) => boolean; resolvedAuth: ResolvedGatewayAuth; trustedProxies: string[]; allowRealIpFallback: boolean; rateLimiter?: AuthRateLimiter; }): GatewayHttpRequestStage[] { if (!params.handlePluginRequest) { return []; } let pluginGatewayAuthSatisfied = false; return [ { name: "plugin-auth", run: async () => { if (params.mattermostSlashCallbackPaths.has(params.requestPath)) { return false; } 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; } pluginGatewayAuthSatisfied = true; return false; }, }, { name: "plugin-http", run: () => { const pathContext = params.pluginPathContext ?? resolvePluginRoutePathContext(params.requestPath); return ( params.handlePluginRequest?.(params.req, params.res, pathContext, { gatewayAuthSatisfied: pluginGatewayAuthSatisfied, }) ?? false ); }, }, ]; } export function createHooksRequestHandler( opts: { getHooksConfig: () => HooksConfigResolved | null; bindHost: string; port: number; logHooks: SubsystemLogger; getClientIpConfig?: () => HookClientIpConfig; } & HookDispatchers, ): HooksRequestHandler { const { getHooksConfig, logHooks, dispatchAgentHook, dispatchWakeHook, getClientIpConfig } = opts; const hookReplayCache = new Map(); 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 => { const clientIpConfig = getClientIpConfig?.(); const clientIp = resolveRequestClientIp( req, clientIpConfig?.trustedProxies, clientIpConfig?.allowRealIpFallback === true, ) ?? req.socket?.remoteAddress; return normalizeRateLimitClientIp(clientIp); }; const pruneHookReplayCache = (now: number) => { const cutoff = now - DEDUPE_TTL_MS; for (const [key, entry] of hookReplayCache) { if (entry.ts < cutoff) { hookReplayCache.delete(key); } } while (hookReplayCache.size > DEDUPE_MAX) { const oldestKey = hookReplayCache.keys().next().value; if (!oldestKey) { break; } hookReplayCache.delete(oldestKey); } }; const buildHookReplayCacheKey = (params: HookReplayScope): string | undefined => { const idem = params.idempotencyKey?.trim(); if (!idem) { return undefined; } const tokenFingerprint = createHash("sha256") .update(params.token ?? "", "utf8") .digest("hex"); const idempotencyFingerprint = createHash("sha256").update(idem, "utf8").digest("hex"); const scopeFingerprint = createHash("sha256") .update( JSON.stringify({ pathKey: params.pathKey, dispatchScope: params.dispatchScope, }), "utf8", ) .digest("hex"); return `${tokenFingerprint}:${scopeFingerprint}:${idempotencyFingerprint}`; }; const resolveCachedHookRunId = (key: string | undefined, now: number): string | undefined => { if (!key) { return undefined; } pruneHookReplayCache(now); const cached = hookReplayCache.get(key); if (!cached) { return undefined; } hookReplayCache.delete(key); hookReplayCache.set(key, cached); return cached.runId; }; const rememberHookRunId = (key: string | undefined, runId: string, now: number) => { if (!key) { return; } hookReplayCache.delete(key); hookReplayCache.set(key, { ts: now, runId }); pruneHookReplayCache(now); }; 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 or X-OpenClaw-Token header (query parameters are not allowed).", ); return true; } 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 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); 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); const idempotencyKey = resolveHookIdempotencyKey({ payload: payload as Record, headers, }); const now = Date.now(); if (subPath === "wake") { const normalized = normalizeWakePayload(payload as Record); 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); 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 replayKey = buildHookReplayCacheKey({ pathKey: "agent", token, idempotencyKey, dispatchScope: { agentId: targetAgentId ?? null, sessionKey: normalized.value.sessionKey ?? hooksConfig.sessionPolicy.defaultSessionKey ?? null, message: normalized.value.message, name: normalized.value.name, wakeMode: normalized.value.wakeMode, deliver: normalized.value.deliver, channel: normalized.value.channel, to: normalized.value.to ?? null, model: normalized.value.model ?? null, thinking: normalized.value.thinking ?? null, timeoutSeconds: normalized.value.timeoutSeconds ?? null, }, }); const cachedRunId = resolveCachedHookRunId(replayKey, now); if (cachedRunId) { sendJson(res, 200, { ok: true, runId: cachedRunId }); return true; } const normalizedDispatchSessionKey = normalizeHookDispatchSessionKey({ sessionKey: sessionKey.value, targetAgentId, }); const runId = dispatchAgentHook({ ...normalized.value, idempotencyKey, sessionKey: normalizedDispatchSessionKey, agentId: targetAgentId, }); rememberHookRunId(replayKey, runId, now); sendJson(res, 200, { ok: true, runId }); return true; } if (hooksConfig.mappings.length > 0) { try { const mapped = await applyHookMappings(hooksConfig.mappings, { payload: payload as Record, 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 normalizedDispatchSessionKey = normalizeHookDispatchSessionKey({ sessionKey: sessionKey.value, targetAgentId, }); const replayKey = buildHookReplayCacheKey({ pathKey: subPath || "mapping", token, idempotencyKey, dispatchScope: { agentId: targetAgentId ?? null, sessionKey: mapped.action.sessionKey ?? hooksConfig.sessionPolicy.defaultSessionKey ?? null, message: mapped.action.message, name: mapped.action.name ?? "Hook", wakeMode: mapped.action.wakeMode, deliver: resolveHookDeliver(mapped.action.deliver), channel, to: mapped.action.to ?? null, model: mapped.action.model ?? null, thinking: mapped.action.thinking ?? null, timeoutSeconds: mapped.action.timeoutSeconds ?? null, }, }); const cachedRunId = resolveCachedHookRunId(replayKey, now); if (cachedRunId) { sendJson(res, 200, { ok: true, runId: cachedRunId }); return true; } const runId = dispatchAgentHook({ message: mapped.action.message, name: mapped.action.name ?? "Hook", idempotencyKey, agentId: targetAgentId, wakeMode: mapped.action.wakeMode, sessionKey: normalizedDispatchSessionKey, 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, }); rememberHookRunId(replayKey, runId, now); sendJson(res, 200, { 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; controlUiEnabled: boolean; controlUiBasePath: string; controlUiRoot?: ControlUiRootState; openAiChatCompletionsEnabled: boolean; openAiChatCompletionsConfig?: import("../config/types.gateway.js").GatewayHttpChatCompletionsConfig; 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; getReadiness?: ReadinessChecker; tlsOptions?: TlsOptions; }): HttpServer { const { canvasHost, clients, controlUiEnabled, controlUiBasePath, controlUiRoot, openAiChatCompletionsEnabled, openAiChatCompletionsConfig, openResponsesEnabled, openResponsesConfig, strictTransportSecurityHeader, handleHooksRequest, handlePluginRequest, shouldEnforcePluginGatewayAuth, resolvedAuth, rateLimiter, getReadiness, } = 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 mattermostSlashCallbackPaths = resolveMattermostSlashCallbackPaths(configSnapshot); 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: "sessions-kill", run: () => handleSessionKillHttpRequest(req, res, { auth: resolvedAuth, trustedProxies, allowRealIpFallback, rateLimiter, }), }, { name: "sessions-history", run: () => handleSessionHistoryHttpRequest(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, config: openAiChatCompletionsConfig, 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, mattermostSlashCallbackPaths, 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, resolvedAuth, trustedProxies, allowRealIpFallback, getReadiness, ), }); 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; 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(); }); }); }