openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
535 lines (534 loc) • 18.9 kB
JavaScript
import { c as normalizeOptionalString } from "./string-coerce-mnp54Vah.js";
import { M as resolveTimestampMsToIsoString, S as resolveDateTimestampMs } from "./number-coercion-CJQ8TR--.js";
import { t as safeEqualSecret } from "./secret-equal-DRsL8lKD.js";
import { _ as resolveRequestClientIp } from "./net-DTe7AQiu.js";
import { c as normalizeRateLimitClientIp, i as AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH, s as createAuthRateLimiter } from "./auth-rate-limit-Bwr29t2Y.js";
import { i as getRuntimeConfig } from "./io-Gi7-pyU-.js";
import { i as resolveMainSessionKey, n as resolveAgentMainSessionKey } from "./main-session-Eahn-btj.js";
import { u as resolveMainSessionKeyFromConfig } from "./sessions-D6zZvoxX.js";
import { o as requestHeartbeat } from "./heartbeat-wake-YhOjrxkC.js";
import { t as sanitizeInboundSystemTags } from "./system-tags-Q468PeYF.js";
import { a as enqueueSystemEvent } from "./system-events-C5WI3S5a.js";
import { r as resolveHookExternalContentSource } from "./external-content-source-CO610hNP.js";
import "./external-content-pX-Pk1Iu.js";
import { n as DEDUPE_TTL_MS, t as DEDUPE_MAX } from "./server-constants-BGwLM6XN.js";
import { _ as resolveHookTargetAgentId, a as isHookAgentAllowed, c as normalizeHookDispatchSessionKey, d as readJsonBody, f as resolveEffectiveHookTargetAgentId, g as resolveHookSessionKey, h as resolveHookIdempotencyKey, i as getHookSessionKeyPrefixError, l as normalizeHookHeaders, m as resolveHookDeliver, n as getHookAgentPolicyError, o as isSessionKeyAllowedByPrefix, p as resolveHookChannel, r as getHookChannelError, s as normalizeAgentPayload, t as extractHookToken, u as normalizeWakePayload, y as applyHookMappings } from "./hooks-BuwUpRxl.js";
import { createHash, randomUUID } from "node:crypto";
//#region src/gateway/server/hooks-request-handler.ts
const HOOK_AUTH_FAILURE_LIMIT = 20;
const HOOK_AUTH_FAILURE_WINDOW_MS = 6e4;
function sendJson(res, status, body) {
res.statusCode = status;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify(body));
}
function resolveMappedHookExternalContentSource(params) {
const payloadSource = typeof params.payload.source === "string" ? params.payload.source.trim().toLowerCase() : "";
if (params.subPath === "gmail" || payloadSource === "gmail") return "gmail";
return resolveHookExternalContentSource(params.sessionKey) ?? "webhook";
}
function createHooksRequestHandler(opts) {
const { getHooksConfig, logHooks, dispatchAgentHook, dispatchWakeHook, getClientIpConfig } = opts;
const hookReplayCache = /* @__PURE__ */ new Map();
const hookAuthLimiter = createAuthRateLimiter({
maxAttempts: HOOK_AUTH_FAILURE_LIMIT,
windowMs: HOOK_AUTH_FAILURE_WINDOW_MS,
lockoutMs: HOOK_AUTH_FAILURE_WINDOW_MS,
exemptLoopback: false,
pruneIntervalMs: 0
});
const resolveHookClientKey = (req) => {
const clientIpConfig = getClientIpConfig?.();
return normalizeRateLimitClientIp(resolveRequestClientIp(req, clientIpConfig?.trustedProxies, clientIpConfig?.allowRealIpFallback === true) ?? req.socket?.remoteAddress);
};
const pruneHookReplayCache = (now) => {
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) => {
const idem = params.idempotencyKey?.trim();
if (!idem) return;
const tokenFingerprint = createHash("sha256").update(params.token ?? "", "utf8").digest("hex");
const idempotencyFingerprint = createHash("sha256").update(idem, "utf8").digest("hex");
return `${tokenFingerprint}:${createHash("sha256").update(JSON.stringify({
pathKey: params.pathKey,
dispatchScope: params.dispatchScope
}), "utf8").digest("hex")}:${idempotencyFingerprint}`;
};
const resolveCachedHookRunId = (key, now) => {
if (!key) return;
pruneHookReplayCache(now);
const cached = hookReplayCache.get(key);
if (!cached) return;
hookReplayCache.delete(key);
hookReplayCache.set(key, cached);
return cached.runId;
};
const rememberHookRunId = (key, runId, now) => {
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;
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 / 1e3) : 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) {
sendJson(res, body.error === "payload too large" ? 413 : body.error === "request body timeout" ? 408 : 400, {
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,
headers
});
const now = Date.now();
const resolveDispatchSessionKeyOrRespond = (sessionKeyValue, targetAgentId) => {
const dispatchSessionKey = normalizeHookDispatchSessionKey({
sessionKey: sessionKeyValue,
targetAgentId
});
const allowedPrefixes = hooksConfig.sessionPolicy.allowedSessionKeyPrefixes;
if (allowedPrefixes && !isSessionKeyAllowedByPrefix(dispatchSessionKey, allowedPrefixes)) {
sendJson(res, 400, {
ok: false,
error: getHookSessionKeyPrefixError(allowedPrefixes)
});
return null;
}
return dispatchSessionKey;
};
if (subPath === "wake") {
const normalized = normalizeWakePayload(payload);
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);
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 effectiveTargetAgentId = resolveEffectiveHookTargetAgentId(hooksConfig, normalized.value.agentId);
const replayKey = buildHookReplayCacheKey({
pathKey: "agent",
token,
idempotencyKey,
dispatchScope: {
agentId: effectiveTargetAgentId,
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 dispatchSessionKey = resolveDispatchSessionKeyOrRespond(sessionKey.value, effectiveTargetAgentId);
if (dispatchSessionKey === null) return true;
const runId = dispatchAgentHook({
...normalized.value,
idempotencyKey,
sessionKey: dispatchSessionKey,
sourcePath: `${basePath}/agent`,
agentId: targetAgentId,
externalContentSource: "webhook"
});
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,
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: mapped.action.sessionKeySource === "static" ? "mapping-static" : "mapping-templated",
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 effectiveTargetAgentId = resolveEffectiveHookTargetAgentId(hooksConfig, mapped.action.agentId);
const dispatchSessionKey = resolveDispatchSessionKeyOrRespond(sessionKey.value, effectiveTargetAgentId);
if (dispatchSessionKey === null) return true;
const replayKey = buildHookReplayCacheKey({
pathKey: subPath || "mapping",
token,
idempotencyKey,
dispatchScope: {
agentId: effectiveTargetAgentId,
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: dispatchSessionKey,
sourcePath: `${basePath}/${subPath}`,
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,
externalContentSource: resolveMappedHookExternalContentSource({
subPath,
payload,
sessionKey: sessionKey.value
})
});
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;
};
}
//#endregion
//#region src/gateway/server/hooks.ts
function resolveHookEventSessionKey(params) {
return params.agentId ? resolveAgentMainSessionKey({
cfg: params.cfg,
agentId: params.agentId
}) : resolveMainSessionKey(params.cfg);
}
function shouldAnnounceHookRunResult(params) {
if (params.result.status !== "ok") return true;
return params.deliver && params.result.delivered !== true && params.result.deliveryAttempted !== true;
}
function resolveHookRunSummary(result) {
return (result.status !== "ok" ? normalizeOptionalString(result.diagnostics?.summary) : void 0) || normalizeOptionalString(result.summary) || normalizeOptionalString(result.error) || result.status;
}
function sanitizeHookConsoleValue(value) {
const normalized = normalizeOptionalString(value);
if (!normalized) return;
return Array.from(normalized, (char) => {
const code = char.charCodeAt(0);
return code < 32 || code === 127 ? " " : char;
}).join("").replace(/\s+/gu, " ").trim().slice(0, 500);
}
function formatHookRunWarningConsoleMessage(params) {
const parts = ["hook agent run returned non-ok status", `status=${sanitizeHookConsoleValue(params.status) ?? "unknown"}`];
const model = sanitizeHookConsoleValue(params.model);
if (model) parts.push(`model=${model}`);
const summary = sanitizeHookConsoleValue(params.summary);
if (summary) parts.push(`summary=${summary}`);
return parts.join(" ");
}
/** Creates the HTTP handler used by gateway hook endpoints. */
function createGatewayHooksRequestHandler(params) {
const { deps, getHooksConfig, getClientIpConfig, bindHost, port, logHooks } = params;
const dispatchWakeHook = (value) => {
const sessionKey = resolveMainSessionKeyFromConfig();
enqueueSystemEvent(value.text, { sessionKey });
if (value.mode === "now") requestHeartbeat({
source: "hook",
intent: "immediate",
reason: "hook:wake"
});
};
const dispatchAgentHook = (value) => {
const sessionKey = value.sessionKey;
const safeName = sanitizeInboundSystemTags(value.name);
const jobId = randomUUID();
const runId = randomUUID();
const nowMs = resolveDateTimestampMs(Date.now());
const delivery = value.deliver ? {
mode: "announce",
channel: value.channel,
to: value.to
} : { mode: "none" };
const job = {
id: jobId,
agentId: value.agentId,
name: safeName,
enabled: true,
createdAtMs: nowMs,
updatedAtMs: nowMs,
schedule: {
kind: "at",
at: resolveTimestampMsToIsoString(nowMs)
},
sessionTarget: "isolated",
wakeMode: value.wakeMode,
payload: {
kind: "agentTurn",
message: value.message,
model: value.model,
thinking: value.thinking,
timeoutSeconds: value.timeoutSeconds,
allowUnsafeExternalContent: value.allowUnsafeExternalContent,
externalContentSource: value.externalContentSource
},
delivery,
state: { nextRunAtMs: nowMs }
};
let hookEventSessionKey;
(async () => {
try {
const cfg = getRuntimeConfig();
hookEventSessionKey = resolveHookEventSessionKey({
cfg,
agentId: value.agentId
});
const { runCronIsolatedAgentTurn } = await import("./isolated-agent-HKk8O6rq.js");
const result = await runCronIsolatedAgentTurn({
cfg,
deps,
job,
message: value.message,
sessionKey,
lane: "cron"
});
const summary = resolveHookRunSummary(result);
const prefix = result.status === "ok" ? `Hook ${safeName}` : `Hook ${safeName} (${result.status})`;
const shouldAnnounce = shouldAnnounceHookRunResult({
deliver: value.deliver,
result
});
if (result.status !== "ok") logHooks.warn("hook agent run returned non-ok status", {
sourcePath: value.sourcePath,
name: safeName,
runId,
jobId,
agentId: value.agentId,
sessionKey,
status: result.status,
model: value.model,
summary,
consoleMessage: formatHookRunWarningConsoleMessage({
status: result.status,
model: value.model,
summary
})
});
if (shouldAnnounce) {
const eventSessionKey = hookEventSessionKey ?? resolveMainSessionKeyFromConfig();
enqueueSystemEvent(`${prefix}: ${summary}`.trim(), { sessionKey: eventSessionKey });
if (value.wakeMode === "now") requestHeartbeat({
source: "hook",
intent: "immediate",
reason: `hook:${jobId}`
});
} else if (result.status === "ok" && !value.deliver) logHooks.info("hook agent run completed without announcement", {
sourcePath: value.sourcePath,
name: safeName,
runId,
jobId,
agentId: value.agentId,
sessionKey,
completedAt: (/* @__PURE__ */ new Date()).toISOString()
});
} catch (err) {
logHooks.warn(`hook agent failed: ${String(err)}`);
enqueueSystemEvent(`Hook ${safeName} (error): ${String(err)}`, { sessionKey: hookEventSessionKey ?? resolveMainSessionKeyFromConfig() });
if (value.wakeMode === "now") requestHeartbeat({
source: "hook",
intent: "immediate",
reason: `hook:${jobId}:error`
});
}
})();
return runId;
};
return createHooksRequestHandler({
getHooksConfig,
bindHost,
port,
logHooks,
getClientIpConfig,
dispatchAgentHook,
dispatchWakeHook
});
}
//#endregion
export { createGatewayHooksRequestHandler };