openclaw/src/gateway/server-http.ts
Bradley Priest 45b41672c6 feat(hooks): support custom verifyAuth in transform modules
Add ability for hook mapping transform modules to export a verifyAuth
function for custom webhook authentication (e.g., GitHub HMAC signatures).

When a mapping's transform exports verifyAuth, it replaces standard
token auth for that mapping. Returns true to allow, false to reject.

Flow in server-http.ts:
1. Read raw body + parse JSON
2. findMapping() to match on path/source
3. authenticateHook() with matched transform
4. Route: wake / agent / applyMapping()

Changes:
- hooks.ts: Split readJsonBody into readRawBody + parseJsonBody;
  add authenticateHook() for custom or token auth
- hooks-mapping.ts: Add verifyAuth types, loadVerifyAuth(),
  findMapping(), applyMapping(); CachedTransform for caching
- server-http.ts: Linear flow using the above
- Tests for authenticateHook and loadVerifyAuth
- Document verifyAuth with GitHub HMAC example
2026-01-29 19:35:48 -08:00

346 lines
11 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 { 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 { handleSlackHttpRequest } from "../slack/http/index.js";
import { resolveAgentAvatar } from "../agents/identity-avatar.js";
import { handleControlUiAvatarRequest, handleControlUiHttpRequest } from "./control-ui.js";
import {
authenticateHook,
getHookChannelError,
type HookMessageChannel,
type HooksConfigResolved,
normalizeAgentPayload,
normalizeHookHeaders,
normalizeWakePayload,
parseJsonBody,
readRawBody,
resolveHookChannel,
resolveHookDeliver,
} from "./hooks.js";
import { applyMapping, findMapping } from "./hooks-mapping.js";
import { handleOpenAiHttpRequest } from "./openai-http.js";
import { handleOpenResponsesHttpRequest } from "./openresponses-http.js";
import { handleToolsInvokeHttpRequest } from "./tools-invoke-http.js";
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
type HookDispatchers = {
dispatchWakeHook: (value: { text: string; mode: "now" | "next-heartbeat" }) => void;
dispatchAgentHook: (value: {
message: string;
name: string;
wakeMode: "now" | "next-heartbeat";
sessionKey: string;
deliver: boolean;
channel: HookMessageChannel;
to?: string;
model?: string;
thinking?: string;
timeoutSeconds?: number;
allowUnsafeExternalContent?: boolean;
}) => 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));
}
export type HooksRequestHandler = (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
export function createHooksRequestHandler(
opts: {
getHooksConfig: () => HooksConfigResolved | null;
bindHost: string;
port: number;
logHooks: SubsystemLogger;
} & HookDispatchers,
): HooksRequestHandler {
const { getHooksConfig, bindHost, port, logHooks, dispatchAgentHook, dispatchWakeHook } = opts;
return async (req, res) => {
const hooksConfig = getHooksConfig();
if (!hooksConfig) return false;
const url = new URL(req.url ?? "/", `http://${bindHost}:${port}`);
const basePath = hooksConfig.basePath;
if (url.pathname !== basePath && !url.pathname.startsWith(`${basePath}/`)) {
return false;
}
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 headers = normalizeHookHeaders(req);
// Read and parse body
const rawBody = await readRawBody(req, hooksConfig.maxBodyBytes);
if (!rawBody.ok) {
const status = rawBody.error === "payload too large" ? 413 : 400;
sendJson(res, status, { ok: false, error: rawBody.error });
return true;
}
const parsed = parseJsonBody(rawBody.value);
if (!parsed.ok) {
sendJson(res, 400, { ok: false, error: parsed.error });
return true;
}
const payload =
typeof parsed.value === "object" && parsed.value !== null
? (parsed.value as Record<string, unknown>)
: {};
// Find matching mapping
const matched = findMapping(hooksConfig.mappings, { payload, headers, url, path: subPath });
// Authenticate (custom verifyAuth or token)
const auth = await authenticateHook({
req,
url,
subPath,
headers,
rawBody: rawBody.value,
transform: matched?.transform,
expectedToken: hooksConfig.token,
});
if (!auth.ok) {
if (auth.status === 401) {
res.statusCode = 401;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Unauthorized");
} else {
sendJson(res, auth.status, { ok: false, error: auth.error });
}
return true;
}
if (auth.tokenFromQuery) {
logHooks.warn(
"Hook token provided via query parameter is deprecated for security reasons. " +
"Tokens in URLs appear in logs, browser history, and referrer headers. " +
"Use Authorization: Bearer <token> or X-Moltbot-Token header instead.",
);
}
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;
}
const runId = dispatchAgentHook(normalized.value);
sendJson(res, 202, { ok: true, runId });
return true;
}
if (matched) {
try {
const mapped = await applyMapping(matched, {
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;
}
const runId = dispatchAgentHook({
message: mapped.action.message,
name: mapped.action.name ?? "Hook",
wakeMode: mapped.action.wakeMode,
sessionKey: mapped.action.sessionKey ?? "",
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;
controlUiEnabled: boolean;
controlUiBasePath: string;
openAiChatCompletionsEnabled: boolean;
openResponsesEnabled: boolean;
openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig;
handleHooksRequest: HooksRequestHandler;
handlePluginRequest?: HooksRequestHandler;
resolvedAuth: import("./auth.js").ResolvedGatewayAuth;
tlsOptions?: TlsOptions;
}): HttpServer {
const {
canvasHost,
controlUiEnabled,
controlUiBasePath,
openAiChatCompletionsEnabled,
openResponsesEnabled,
openResponsesConfig,
handleHooksRequest,
handlePluginRequest,
resolvedAuth,
} = 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) {
// 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 ?? [];
if (await handleHooksRequest(req, res)) return;
if (
await handleToolsInvokeHttpRequest(req, res, {
auth: resolvedAuth,
trustedProxies,
})
)
return;
if (await handleSlackHttpRequest(req, res)) return;
if (handlePluginRequest && (await handlePluginRequest(req, res))) return;
if (openResponsesEnabled) {
if (
await handleOpenResponsesHttpRequest(req, res, {
auth: resolvedAuth,
config: openResponsesConfig,
trustedProxies,
})
)
return;
}
if (openAiChatCompletionsEnabled) {
if (
await handleOpenAiHttpRequest(req, res, {
auth: resolvedAuth,
trustedProxies,
})
)
return;
}
if (canvasHost) {
if (await handleA2uiHttpRequest(req, res)) return;
if (await canvasHost.handleHttpRequest(req, res)) return;
}
if (controlUiEnabled) {
if (
handleControlUiAvatarRequest(req, res, {
basePath: controlUiBasePath,
resolveAvatar: (agentId) => resolveAgentAvatar(configSnapshot, agentId),
})
)
return;
if (
handleControlUiHttpRequest(req, res, {
basePath: controlUiBasePath,
config: configSnapshot,
})
)
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;
}) {
const { httpServer, wss, canvasHost } = opts;
httpServer.on("upgrade", (req, socket, head) => {
if (canvasHost?.handleUpgrade(req, socket, head)) return;
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
});
}