* Hooks: add hook idempotency key resolution * Hooks: dedupe repeated agent deliveries by idempotency key * Tests: cover hook idempotency dedupe * Changelog: note hook idempotency dedupe * Hooks: cap hook idempotency key length * Gateway: hash hook replay cache keys * Tests: cover hook replay key hardening
992 lines
32 KiB
TypeScript
992 lines
32 KiB
TypeScript
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 { 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 {
|
|
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 { 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;
|
|
};
|
|
|
|
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<string, unknown>;
|
|
};
|
|
|
|
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"],
|
|
]);
|
|
const MATTERMOST_SLASH_CALLBACK_PATH = "/api/channels/mattermost/command";
|
|
|
|
function resolveMattermostSlashCallbackPaths(
|
|
configSnapshot: ReturnType<typeof loadConfig>,
|
|
): Set<string> {
|
|
const callbackPaths = new Set<string>([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<string, unknown> | undefined;
|
|
const addMmCommands = (raw: unknown) => {
|
|
if (raw == null || typeof raw !== "object") {
|
|
return;
|
|
}
|
|
const commands = raw as Record<string, unknown>;
|
|
const callbackPath = normalizeCallbackPath(commands.callbackPath);
|
|
if (isMattermostCommandCallbackPath(callbackPath)) {
|
|
callbackPaths.add(callbackPath);
|
|
}
|
|
tryAddCallbackUrlPath(commands.callbackUrl);
|
|
};
|
|
|
|
addMmCommands(mmRaw?.commands);
|
|
const accountsRaw = (mmRaw?.accounts ?? {}) as Record<string, unknown>;
|
|
for (const accountId of Object.keys(accountsRaw)) {
|
|
const accountCfg = accountsRaw[accountId] as Record<string, unknown> | 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<boolean> {
|
|
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<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.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<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;
|
|
mattermostSlashCallbackPaths: ReadonlySet<string>;
|
|
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<string, HookReplayEntry>();
|
|
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 <token> 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<string, unknown>,
|
|
headers,
|
|
});
|
|
const now = Date.now();
|
|
|
|
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 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<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 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<GatewayWsClient>;
|
|
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: "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<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();
|
|
});
|
|
});
|
|
}
|