openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
1,081 lines (1,080 loc) • 40.4 kB
JavaScript
import { l as normalizeOptionalString } from "./string-coerce-1VuiLaaR.mjs";
import { E as resolveDateTimestampMs, I as resolveTimestampMsToIsoString } from "./number-coercion-CuVhdCBs.mjs";
import { t as createDeferredCore } from "./deferred-D0La5CRk.mjs";
import { o as asRecord } from "./record-coerce-TIsquoVR.mjs";
import { r as truncateUtf16Safe } from "./utf16-slice-D_ngcYKd.mjs";
import { t as pruneMapToMaxSize } from "./map-size-BlrH_CnE.mjs";
import { b as redactToolPayloadText } from "./redact-DJ8j6ADa.mjs";
import { t as formatErrorMessage } from "./errors-O33D01XW.mjs";
import { o as sendHttpRequestRejection } from "./http-request-lifecycle-DO9mb9Gu.mjs";
import { _ as toAgentStoreSessionKey, s as isUnscopedSessionKeySentinel } from "./session-key-C09ow4h0.mjs";
import { i as listAgentIds } from "./agent-roster-BXD1uMJD.mjs";
import "./agent-scope-CG17uXFn.mjs";
import { r as getRuntimeConfig } from "./io.runtime-BsUXSsD4.mjs";
import "./io-BYWYXJXl.mjs";
import { C as runWithGatewayIndependentRootWorkContinuation } from "./gateway-work-admission-rc4ZgKq_.mjs";
import { p as requestSessionEventWake } from "./heartbeat-wake-ba6LAy2M.mjs";
import { l as withSystemEventOwner } from "./system-event-ownership-BkQcgSZJ.mjs";
import { n as canonicalizeMainSessionAlias, r as resolveAgentMainSessionKey } from "./main-session-00MgEWj5.mjs";
import { i as enqueueSystemEvent } from "./system-events-BD0JkU8V.mjs";
import { r as resolveHookExternalContentSource } from "./external-content-source-BKc9v74i.mjs";
import "./external-content-Bkm4w5qo.mjs";
import { x as resolveRequestClientIpFromHeaders } from "./net-C6WEu3pd.mjs";
import { t as DEDUPE_MAX } from "./server-constants-BH_EolwD.mjs";
import { t as safeEqualSecret } from "./secret-equal-DRsL8lKD.mjs";
import "./lanes-D7a4ntDu.mjs";
import "./sessions-DQtCC-5j.mjs";
import { r as resolveOutboundChannelPlugin } from "./channel-resolution-D9vhAPIJ.mjs";
import { t as resolveCronAgentSessionKey } from "./session-key-C18RZBZJ.mjs";
import { i as resolveChannelDefaultAccountId } from "./helpers-Bm4RKejp.mjs";
import { r as validateExplicitMessageAccountSelection } from "./message-account-selection-RjSwQQeV.mjs";
import { n as fenceScheduledGatewayContextResolver, t as createScheduledGatewayRunner } from "./scheduled-run-gateway-context-CxI8WTGy.mjs";
import { a as AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH, h as normalizeRateLimitClientIp, p as createGatewayAuthRateLimiter } from "./auth-rate-limit-CnTz49S4.mjs";
import { o as readPreparedGatewayIngressAttribution } from "./ingress-attribution-A8WUyxq2.mjs";
import { u as sendJson } from "./http-common-BJjsxTZ6.mjs";
import { _ as resolveHookSessionKey, a as isHookAgentAllowed, c as normalizeHookDispatchSessionKey, d as readJsonBody, f as resolveEffectiveHookTargetAgentId, g as resolveHookPathBodyLimit, 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-Bl-rXz6h.mjs";
import { createHash, randomUUID } from "node:crypto";
const FAN_OUT_PENDING = Symbol("hook-fanout-pending");
async function settleFanOutDispatches(dispatches, deadlineMs) {
const guarded = dispatches.map((dispatch) => dispatch.catch((err) => ({
ok: false,
statusCode: 502,
error: String(err)
})));
let deadlineTimer;
const deadline = new Promise((resolve) => {
deadlineTimer = setTimeout(() => resolve(FAN_OUT_PENDING), deadlineMs);
deadlineTimer.unref?.();
});
try {
return await Promise.all(guarded.map((dispatch) => Promise.race([dispatch, deadline])));
} finally {
if (deadlineTimer) clearTimeout(deadlineTimer);
}
}
function sendAgentResult(res, result, extra, waitForCompletion = false) {
if (!result.ok) {
const { statusCode, ...body } = result;
sendJson(res, statusCode, {
...body,
...extra
});
return;
}
const send = (completion) => sendJson(res, 200, {
ok: true,
runId: result.runId,
...extra,
...completion ? { completion } : {}
});
return waitForCompletion ? result.completion.then(send) : send();
}
function sendFanOutResult(res, settled, wake) {
const first = settled[0];
if (settled.length === 1 && first !== void 0 && first !== FAN_OUT_PENDING) {
sendAgentResult(res, first, wake);
return;
}
const runIds = [];
const failures = [];
let pending = 0;
for (const result of settled) if (result === FAN_OUT_PENDING) pending += 1;
else if (result.ok) runIds.push(result.runId);
else failures.push(result);
if (failures.length === 0 && pending === 0) {
const result = {
ok: true,
runId: runIds[0],
runIds,
dispatched: runIds.length
};
sendJson(res, 200, {
...result,
...wake
});
return;
}
const failure = failures[0];
sendJson(res, failure ? failure.statusCode : 503, {
ok: false,
error: `hook fan-out incomplete: ${runIds.length}/${settled.length} dispatched, ${failures.length} failed, ${pending} pending`,
runIds,
...failures.length > 0 ? { errors: failures.slice(0, 5).map((entry) => entry.error) } : {},
...wake
});
}
//#endregion
//#region src/gateway/server/hooks-request-handler.ts
const HOOK_AUTH_FAILURE_LIMIT = 20;
const HOOK_AUTH_FAILURE_WINDOW_MS = 6e4;
const HOOK_FAN_OUT_DERIVED_IDEMPOTENCY = "hook-fanout-item";
const HOOK_CONFIG_CHANGED_ERROR = "hook configuration changed; retry request";
const hashReplay = (value) => createHash("sha256").update(value, "utf8").digest("hex");
function createHooksRequestHandler(opts) {
const { getHooksConfig, logHooks, dispatchAgentHook, dispatchWakeHook, getClientIpConfig } = opts;
const fanoutResponseDeadlineMs = opts.fanoutResponseDeadlineMs ?? 8e3;
const hookReplayCache = /* @__PURE__ */ new Map();
const hookAuthLimiter = createGatewayAuthRateLimiter({
maxAttempts: HOOK_AUTH_FAILURE_LIMIT,
windowMs: HOOK_AUTH_FAILURE_WINDOW_MS,
lockoutMs: HOOK_AUTH_FAILURE_WINDOW_MS,
exemptLoopback: false,
pruneIntervalMs: 0
}, { scheduler: opts.scheduler });
const resolveHookClientKey = (req) => {
const attribution = readPreparedGatewayIngressAttribution(req);
if (attribution && attribution.kind !== "unattributable-proxy") return normalizeRateLimitClientIp(attribution.rateLimit.subject.key);
const clientIpConfig = getClientIpConfig?.();
const clientIp = resolveRequestClientIpFromHeaders(req, clientIpConfig?.trustedProxies, clientIpConfig?.allowRealIpFallback === true) ?? req.socket?.remoteAddress;
return normalizeRateLimitClientIp(clientIp);
};
const pruneHookReplayCache = (now) => {
for (const [key, entry] of hookReplayCache) if (entry.state === "terminal" && entry.ts < now - 3e5) hookReplayCache.delete(key);
const terminal = [...hookReplayCache].filter(([, entry]) => entry.state === "terminal");
for (const [key] of terminal.slice(0, Math.max(0, terminal.length - DEDUPE_MAX))) hookReplayCache.delete(key);
};
const buildHookReplayCacheKey = (params) => {
const idem = params.idempotencyKey?.trim();
if (!idem) return;
const scope = JSON.stringify({
pathKey: params.pathKey,
dispatchScope: params.dispatchScope
});
return `${hashReplay(params.token ?? "")}:${hashReplay(scope)}:${hashReplay(idem)}`;
};
const resolveHookReplay = (key) => {
if (!key) return;
pruneHookReplayCache(Date.now());
const cached = hookReplayCache.get(key);
if (!cached) return;
if (cached.state === "terminal") {
hookReplayCache.delete(key);
hookReplayCache.set(key, cached);
}
return cached.dispatch;
};
const dispatchAgentHookWithReplay = (key, dispatch) => {
if (!key) return dispatch();
const existing = resolveHookReplay(key);
if (existing) return existing;
const pending = Promise.resolve().then(dispatch).then((result) => {
const current = hookReplayCache.get(key);
if (current?.state === "pending" && current.dispatch === pending) {
if (result.ok) {
const active = {
state: "active",
dispatch: result
};
hookReplayCache.set(key, active);
const settle = () => {
if (hookReplayCache.get(key) !== active) return;
const terminal = {
state: "terminal",
ts: Date.now(),
dispatch: result
};
hookReplayCache.delete(key);
hookReplayCache.set(key, terminal);
pruneHookReplayCache(terminal.ts);
};
result.completion.then(settle, settle);
} else hookReplayCache.delete(key);
}
return result;
}).catch((err) => {
const current = hookReplayCache.get(key);
if (current?.state === "pending" && current.dispatch === pending) hookReplayCache.delete(key);
throw err;
});
hookReplayCache.set(key, {
state: "pending",
dispatch: pending
});
return pending;
};
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 isHooksConfigCurrent = () => getHooksConfig() === hooksConfig;
const rejectChangedHooksConfig = () => {
if (isHooksConfigCurrent()) return false;
sendJson(res, 409, {
ok: false,
error: HOOK_CONFIG_CHANGED_ERROR
});
return true;
};
const changedHooksConfigDispatchResult = () => ({
ok: false,
statusCode: 409,
error: HOOK_CONFIG_CHANGED_ERROR
});
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, resolveHookPathBodyLimit(hooksConfig, subPath));
if (!body.ok) {
const error = {
ok: false,
error: body.error
};
if (body.error === "payload too large" || body.error === "request body timeout") await sendHttpRequestRejection(req, res, body.error === "payload too large" ? 413 : 408, JSON.stringify(error), "application/json; charset=utf-8");
else sendJson(res, 400, error);
return true;
}
if (rejectChangedHooksConfig()) return true;
const payload = asRecord(body.value);
const headers = normalizeHookHeaders(req);
const idempotencyKey = resolveHookIdempotencyKey({
payload,
headers
});
let wakeResult;
const sendHookError = (error) => sendJson(res, 400, {
ok: false,
error,
...wakeResult
});
const resolveDispatchSessionKeyOrRespond = (sessionKeyValue, targetAgentId) => {
const dispatchSessionKey = normalizeHookDispatchSessionKey({
sessionKey: sessionKeyValue,
targetAgentId
});
const allowedPrefixes = hooksConfig.sessionPolicy.allowedSessionKeyPrefixes;
if (allowedPrefixes && !isSessionKeyAllowedByPrefix(dispatchSessionKey, allowedPrefixes)) {
sendHookError(getHookSessionKeyPrefixError(allowedPrefixes));
return null;
}
return dispatchSessionKey;
};
const resolveTargetAgentOrRespond = (agentId, source) => {
const resolution = resolveEffectiveHookTargetAgentId(hooksConfig, agentId, source);
if (!resolution.ok) {
sendHookError(resolution.error);
return null;
}
if (!isHookAgentAllowed(hooksConfig, resolution.effectiveAgentId)) {
sendHookError(getHookAgentPolicyError());
return null;
}
return resolution;
};
const dispatchWake = (value, targetAgentId, source) => {
let dispatchSessionKey;
if (value.sessionKey) {
const sessionKey = resolveHookSessionKey({
hooksConfig,
source,
sessionKey: value.sessionKey
});
if (!sessionKey.ok) {
sendHookError(sessionKey.error);
return null;
}
const resolvedSessionKey = resolveDispatchSessionKeyOrRespond(sessionKey.value, targetAgentId);
if (resolvedSessionKey === null) return null;
dispatchSessionKey = resolvedSessionKey;
}
const dispatchValue = {
...value,
sessionKey: dispatchSessionKey
};
if (rejectChangedHooksConfig()) return null;
return dispatchWakeHook(dispatchValue, targetAgentId);
};
if (subPath === "wake") {
const normalized = normalizeWakePayload(payload);
if (!normalized.ok) {
sendJson(res, 400, {
ok: false,
error: normalized.error
});
return true;
}
const target = resolveTargetAgentOrRespond(normalized.value.agentId, "request");
if (!target) return true;
const directWakeResult = dispatchWake(normalized.value, target.effectiveAgentId, "request");
if (!directWakeResult) return true;
sendJson(res, 200, {
ok: true,
mode: normalized.value.mode,
...directWakeResult
});
return true;
}
if (subPath === "agent") {
const waitForCompletion = payload.waitForCompletion;
if (waitForCompletion !== void 0 && typeof waitForCompletion !== "boolean") {
sendJson(res, 400, {
ok: false,
error: "waitForCompletion must be boolean"
});
return true;
}
const normalized = normalizeAgentPayload(payload);
if (!normalized.ok) {
sendJson(res, 400, {
ok: false,
error: normalized.error
});
return true;
}
const target = resolveTargetAgentOrRespond(normalized.value.agentId, "request");
if (!target) return true;
if (normalized.value.sessionMode === "persistent" && !normalized.value.sessionKey) {
sendJson(res, 400, {
ok: false,
error: "sessionKey is required when sessionMode is persistent"
});
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;
}
if (normalized.value.sessionMode === "persistent" && !hooksConfig.sessionPolicy.allowedSessionKeyPrefixes?.length) {
sendJson(res, 400, {
ok: false,
error: "hooks.allowedSessionKeyPrefixes is required when direct hook sessionMode is persistent"
});
return true;
}
const replayKey = buildHookReplayCacheKey({
pathKey: "agent",
token,
idempotencyKey,
dispatchScope: {
agentId: target.effectiveAgentId,
sessionKey: normalized.value.sessionKey ?? hooksConfig.sessionPolicy.defaultSessionKey ?? null,
message: normalized.value.message,
name: normalized.value.name,
wakeMode: normalized.value.wakeMode,
sessionMode: normalized.value.sessionMode,
deliver: normalized.value.deliver,
channel: normalized.value.channel,
to: normalized.value.to ?? null,
accountId: normalized.value.accountId ?? null,
model: normalized.value.model ?? null,
thinking: normalized.value.thinking ?? null,
timeoutSeconds: normalized.value.timeoutSeconds ?? null
}
});
const replay = resolveHookReplay(replayKey);
if (replay) {
await sendAgentResult(res, await replay, void 0, waitForCompletion === true);
return true;
}
const dispatchSessionKey = resolveDispatchSessionKeyOrRespond(sessionKey.value, target.effectiveAgentId);
if (dispatchSessionKey === null) return true;
await sendAgentResult(res, await dispatchAgentHookWithReplay(replayKey, () => {
if (!isHooksConfigCurrent()) return changedHooksConfigDispatchResult();
return dispatchAgentHook({
...normalized.value,
effectiveAgentId: target.effectiveAgentId,
idempotencyKey,
sessionKey: dispatchSessionKey,
sourcePath: `${basePath}/agent`,
agentId: target.selectedAgentId,
externalContentSource: "webhook",
replayKey
});
}), void 0, waitForCompletion === true);
return true;
}
if (hooksConfig.mappings.length > 0) try {
const mapped = await applyHookMappings(hooksConfig.mappings, {
payload,
headers,
url,
path: subPath
});
if (rejectChangedHooksConfig()) return true;
if (mapped) {
if (!mapped.ok) {
sendJson(res, 400, {
ok: false,
error: mapped.error
});
return true;
}
if (mapped.dropped > 0) logHooks.warn(`hook mapping ${subPath} fan-out dropped ${mapped.dropped} items beyond the 200-item cap`);
if (mapped.actions.length === 0) {
if (mapped.fanout) logHooks.info(`hook mapping ${subPath} matched with no items to dispatch`);
res.statusCode = 204;
res.end();
return true;
}
const fanOutScopeOccurrences = /* @__PURE__ */ new Map();
const prepareMappedAgentDispatchOrRespond = (action) => {
const channel = resolveHookChannel(action.channel);
if (!channel) {
sendHookError(getHookChannelError());
return null;
}
const deliver = resolveHookDeliver(action.deliver);
const delivery = deliver ? {
mode: "announce",
channel,
to: action.to
} : { mode: "none" };
const target = resolveTargetAgentOrRespond(action.agentId, "mapping");
if (!target) return null;
if (action.sessionMode === "persistent" && !action.sessionKey && !hooksConfig.sessionPolicy.defaultSessionKey) {
sendHookError("sessionKey or hooks.defaultSessionKey is required when mapped hook sessionMode is persistent");
return null;
}
const sessionKey = resolveHookSessionKey({
hooksConfig,
source: action.sessionKeySource === "static" ? "mapping-static" : "mapping-templated",
sessionKey: action.sessionKey
});
if (!sessionKey.ok) {
sendHookError(sessionKey.error);
return null;
}
const dispatchSessionKey = resolveDispatchSessionKeyOrRespond(sessionKey.value, target.effectiveAgentId);
if (dispatchSessionKey === null) return null;
const dispatchScope = {
agentId: target.effectiveAgentId,
sessionKey: action.sessionKey ?? hooksConfig.sessionPolicy.defaultSessionKey ?? null,
message: action.message,
name: action.name ?? "Hook",
wakeMode: action.wakeMode,
sessionMode: action.sessionMode,
deliver,
channel,
to: action.to ?? null,
model: action.model ?? null,
thinking: action.thinking ?? null,
timeoutSeconds: action.timeoutSeconds ?? null
};
if (mapped.fanout) {
const fingerprint = JSON.stringify(dispatchScope);
const occurrence = fanOutScopeOccurrences.get(fingerprint) ?? 0;
fanOutScopeOccurrences.set(fingerprint, occurrence + 1);
dispatchScope.occurrence = occurrence;
}
const replayKey = buildHookReplayCacheKey({
pathKey: subPath,
token,
idempotencyKey: mapped.fanout ? idempotencyKey ?? HOOK_FAN_OUT_DERIVED_IDEMPOTENCY : idempotencyKey,
dispatchScope
});
return () => dispatchAgentHookWithReplay(replayKey, () => {
if (!isHooksConfigCurrent()) return changedHooksConfigDispatchResult();
return dispatchAgentHook({
message: action.message,
name: action.name ?? "Hook",
idempotencyKey,
agentId: target.selectedAgentId,
effectiveAgentId: target.effectiveAgentId,
wakeMode: action.wakeMode,
sessionKey: dispatchSessionKey,
sessionMode: action.sessionMode,
sourcePath: `${basePath}/${subPath}`,
deliver,
channel,
to: action.to,
delivery,
model: action.model,
thinking: action.thinking,
timeoutSeconds: action.timeoutSeconds,
mappingId: action.mappingId,
allowUnsafeExternalContent: action.allowUnsafeExternalContent,
...mapped.fanout ? { admissionMode: "background" } : {},
replayKey,
externalContentSource: subPath === "gmail" ? "gmail" : resolveHookExternalContentSource(sessionKey.value) ?? "webhook"
});
});
};
const dispatches = [];
let wakeMode;
for (const action of mapped.actions) {
if (action.kind === "wake") {
const target = resolveTargetAgentOrRespond(action.agentId, "mapping");
if (!target) return true;
const dispatched = dispatchWake({
text: action.text,
mode: action.mode,
sessionKey: action.sessionKey
}, target.effectiveAgentId, action.sessionKeySource === "static" ? "mapping-static" : "mapping-templated");
if (!dispatched) return true;
if (!wakeResult || dispatched.eventOutcome === "queued") wakeResult = dispatched;
wakeMode = action.mode;
continue;
}
const prepared = prepareMappedAgentDispatchOrRespond(action);
if (!prepared) return true;
dispatches.push(prepared);
}
if (dispatches.length === 0) {
sendJson(res, 200, {
ok: true,
mode: wakeMode ?? "now",
...wakeResult
});
return true;
}
if (!mapped.fanout) {
sendAgentResult(res, await dispatches[0](), wakeResult);
return true;
}
sendFanOutResult(res, await settleFanOutDispatches(dispatches.map((dispatch) => Promise.resolve(dispatch())), fanoutResponseDeadlineMs), wakeResult);
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
const HOOK_AGENT_START_ADMISSION_TIMEOUT_MS = 15e3;
const HOOK_AGENT_START_ADMISSION_TIMEOUT_ERROR = "hook agent run did not start before admission timeout";
const HOOK_AGENT_SESSION_CONFLICT_ERROR = "hook agent run was rejected because the target session changed";
const HOOK_AGENT_PREPARATION_ERROR = "hook agent run failed before entering the agent runner";
function resolveHookEventTarget(params) {
if (params.cfg.session?.scope === "global") return {
eventSessionKey: "global",
heartbeatTarget: { agentId: params.resolvedAgentId }
};
const eventSessionKey = params.sessionKey ? canonicalizeMainSessionAlias({
cfg: params.cfg,
agentId: params.resolvedAgentId,
sessionKey: toAgentStoreSessionKey({
agentId: params.resolvedAgentId,
requestKey: params.sessionKey,
mainKey: params.cfg.session?.mainKey
})
}) : resolveAgentMainSessionKey({
cfg: params.cfg,
agentId: params.resolvedAgentId
});
return {
eventSessionKey,
heartbeatTarget: {
agentId: params.resolvedAgentId,
sessionKey: eventSessionKey
}
};
}
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;
const withoutControlChars = Array.from(normalized, (char) => {
const code = char.charCodeAt(0);
return code < 32 || code === 127 ? " " : char;
}).join("");
return truncateUtf16Safe(withoutControlChars.replace(/\s+/gu, " ").trim(), 500);
}
function sanitizeHookLogMetadata(meta) {
return Object.fromEntries(Object.entries(meta).filter(([, value]) => value !== void 0).map(([key, value]) => [key, typeof value === "string" ? sanitizeHookConsoleValue(redactToolPayloadText(value).replace(/\p{Cc}/gu, " ")) : value]));
}
function createHookAdmissionFailure(params) {
const statusCode = params.statusCode ?? (params.disposition === "session-conflict" ? 409 : 502);
return {
ok: false,
statusCode,
error: statusCode === 409 ? HOOK_AGENT_SESSION_CONFLICT_ERROR : statusCode === 503 ? HOOK_AGENT_START_ADMISSION_TIMEOUT_ERROR : HOOK_AGENT_PREPARATION_ERROR,
runId: params.runId
};
}
function createSessionKeyedHookDispatchQueue() {
const hookAgentDispatchTails = /* @__PURE__ */ new Map();
return (sessionKey, operation) => {
const previousTail = hookAgentDispatchTails.get(sessionKey);
const run = previousTail ? previousTail.catch(() => void 0).then(operation) : operation();
const tail = run.then(() => void 0, () => void 0);
hookAgentDispatchTails.set(sessionKey, tail);
tail.finally(() => {
if (hookAgentDispatchTails.get(sessionKey) === tail) hookAgentDispatchTails.delete(sessionKey);
});
return run;
};
}
async function validateHookAgentDeliveryAccount(params) {
if (params.value.delivery.mode !== "announce" || params.value.delivery.channel === "last" || !params.value.delivery.to) return params.value;
const accountId = params.value.delivery.accountId ? await validateExplicitMessageAccountSelection({
cfg: params.cfg,
channel: params.value.delivery.channel,
accountId: params.value.delivery.accountId
}) : (() => {
const plugin = resolveOutboundChannelPlugin({
channel: params.value.delivery.channel,
cfg: params.cfg
});
if (!plugin) throw new Error(`Channel ${params.value.delivery.channel} is unavailable.`);
return resolveChannelDefaultAccountId({
plugin,
cfg: params.cfg
});
})();
if (!accountId) throw new Error(`Channel ${params.value.delivery.channel} did not resolve an account.`);
return {
...params.value,
accountId,
delivery: {
...params.value.delivery,
accountId
}
};
}
/** Creates one lifecycle-owned dispatcher shared by HTTP hooks and trusted plugins. */
function createGatewayHookDispatcher(params) {
const { deps, logHooks, resolveGatewayContext, agentStartAdmissionTimeoutMs = HOOK_AGENT_START_ADMISSION_TIMEOUT_MS } = params;
const scheduledGatewayContextResolver = fenceScheduledGatewayContextResolver(resolveGatewayContext);
const runScheduledHook = createScheduledGatewayRunner(scheduledGatewayContextResolver);
const enqueueHookAgentDispatch = createSessionKeyedHookDispatchQueue();
const announcedFailureReplays = /* @__PURE__ */ new Map();
const claimFailureNotice = (replayKey) => {
if (!replayKey) return true;
const now = Date.now();
for (const [key, announcedAt] of announcedFailureReplays) if (announcedAt < now - 3e5) announcedFailureReplays.delete(key);
if (announcedFailureReplays.has(replayKey)) return false;
announcedFailureReplays.set(replayKey, now);
pruneMapToMaxSize(announcedFailureReplays, DEDUPE_MAX);
return true;
};
let isolatedAgentModulePromise;
const loadIsolatedAgentModule = () => isolatedAgentModulePromise ??= import("./isolated-agent-CjjYE4-q.mjs");
const dispatchWakeHook = (value, agentId) => {
const target = resolveHookEventTarget({
cfg: getRuntimeConfig(),
resolvedAgentId: agentId,
sessionKey: value.sessionKey
});
const sessionKey = target.eventSessionKey;
const eventOptions = { sessionKey };
const queued = enqueueSystemEvent(value.text, isUnscopedSessionKeySentinel(sessionKey) ? withSystemEventOwner(eventOptions, agentId) : eventOptions);
if (value.mode === "now") requestSessionEventWake({
source: "hook",
intent: "immediate",
reason: "hook:wake",
...target.heartbeatTarget
});
return { eventOutcome: queued ? "queued" : "coalesced" };
};
const dispatchAgentHook = async (value, pluginId) => {
const sessionKey = value.sessionKey;
const safeName = sanitizeHookConsoleValue(value.name) ?? "Hook";
const jobId = randomUUID();
const runId = randomUUID();
const completion = createDeferredCore();
const logContext = sanitizeHookLogMetadata({
runId,
jobId,
sourcePath: value.sourcePath,
name: value.name,
agentId: value.effectiveAgentId,
logicalSessionKey: sessionKey
});
const logHookRunTerminal = (result) => {
const meta = {
...logContext,
...sanitizeHookLogMetadata({
status: result.status,
sessionId: result.sessionId,
sessionKey: result.sessionKey,
deliver: value.deliver,
delivered: result.delivered,
deliveryAttempted: result.deliveryAttempted,
deliveryError: result.deliveryError,
deliverySuppressionReason: result.deliverySuppressionReason,
model: result.model ?? value.model,
summary: result.status !== "ok" ? resolveHookRunSummary(result) : void 0
})
};
const details = [
"runId",
"status",
"deliveryError",
"summary",
"model"
].flatMap((key) => meta[key] === void 0 ? [] : [`${key}=${String(meta[key])}`]);
const message = truncateUtf16Safe(["hook agent run completed", ...details].join(" "), 500);
const level = result.status !== "ok" || result.deliveryError ? "warn" : "info";
logHooks[level](message, meta);
return {
status: result.status,
replyDisposition: result.replyDisposition ?? "empty",
...result.delivered !== void 0 ? { delivered: result.delivered } : {},
...result.deliveryAttempted !== void 0 ? { deliveryAttempted: result.deliveryAttempted } : {},
...result.deliveryError ? { deliveryError: "delivery-failed" } : {},
...result.deliverySuppressionReason ? { deliverySuppressionReason: result.deliverySuppressionReason } : {}
};
};
const nowMs = resolveDateTimestampMs(Date.now());
const job = {
id: jobId,
agentId: value.effectiveAgentId,
name: safeName,
enabled: true,
createdAtMs: nowMs,
updatedAtMs: nowMs,
schedule: {
kind: "at",
at: resolveTimestampMsToIsoString(nowMs)
},
sessionTarget: value.sessionMode === "persistent" ? `session:${sessionKey}` : "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: value.delivery,
state: { nextRunAtMs: nowMs }
};
let hookEventTarget;
const resolveGlobalTerminalAgentId = (status) => {
const acceptedAgentId = hookEventTarget?.heartbeatTarget.agentId;
if (acceptedAgentId && listAgentIds(getRuntimeConfig()).includes(acceptedAgentId)) return acceptedAgentId;
logHooks.warn("hook agent terminal event suppressed", {
...logContext,
...sanitizeHookLogMetadata({
acceptedAgentId,
status
}),
reason: "accepted-agent-removed"
});
};
const announceHookEvent = (eventTarget, text, status, reason) => {
if (status !== "ok" && !claimFailureNotice(value.replayKey)) return;
const eventSessionKey = eventTarget.eventSessionKey;
const isGlobalEvent = isUnscopedSessionKeySentinel(eventSessionKey);
let heartbeatTarget = eventTarget.heartbeatTarget;
if (isGlobalEvent && hookEventTarget) {
const globalTerminalAgentId = resolveGlobalTerminalAgentId(status);
if (!globalTerminalAgentId) return;
heartbeatTarget = { agentId: globalTerminalAgentId };
}
const eventOptions = { sessionKey: eventSessionKey };
enqueueSystemEvent(text, isGlobalEvent && heartbeatTarget.agentId ? withSystemEventOwner(eventOptions, heartbeatTarget.agentId) : eventOptions);
if (value.wakeMode === "now") requestSessionEventWake({
source: "hook",
intent: "immediate",
reason,
...heartbeatTarget
});
};
const reportHookFailure = (err) => {
completion.resolve(logHookRunTerminal({
status: "error",
error: String(err)
}));
announceHookEvent(hookEventTarget ?? resolveHookEventTarget({
cfg: getRuntimeConfig(),
resolvedAgentId: value.effectiveAgentId
}), `Hook ${safeName} (error): ${String(err)}`, "error", `hook:${jobId}:error`);
};
let dispatchCfg;
try {
dispatchCfg = getRuntimeConfig();
} catch (err) {
runWithGatewayIndependentRootWorkContinuation(async () => reportHookFailure(err), "hooks:failure-report");
return createHookAdmissionFailure({ runId });
}
let acceptedValue;
try {
acceptedValue = await validateHookAgentDeliveryAccount({
cfg: dispatchCfg,
value
});
job.delivery = acceptedValue.delivery;
} catch (err) {
return {
ok: false,
statusCode: 400,
error: formatErrorMessage(err),
runId
};
}
const agentId = acceptedValue.effectiveAgentId;
const queueKey = resolveCronAgentSessionKey({
sessionKey,
agentId,
mainKey: dispatchCfg.session?.mainKey,
cfg: dispatchCfg
});
const admission = createDeferredCore();
let admissionSettled = false;
let admissionTimedOut = false;
let admissionTimer;
const settleAdmission = (result) => {
if (admissionSettled) return;
admissionSettled = true;
if (admissionTimer) {
clearTimeout(admissionTimer);
admissionTimer = void 0;
}
if (result.ok && value.replayKey) announcedFailureReplays.delete(value.replayKey);
admission.resolve(result);
};
const failAdmission = (err) => {
if (admissionTimedOut) return;
settleAdmission(createHookAdmissionFailure({ runId }));
reportHookFailure(err);
};
const admissionTimeoutError = /* @__PURE__ */ new Error(HOOK_AGENT_START_ADMISSION_TIMEOUT_ERROR);
const startupAbortController = new AbortController();
const settleSuccessfulAdmission = () => {
startupAbortController.signal.throwIfAborted();
settleAdmission({
ok: true,
runId,
completion: completion.promise
});
};
if (value.admissionMode !== "background") {
admissionTimer = setTimeout(() => {
admissionTimedOut = true;
startupAbortController.abort(admissionTimeoutError);
settleAdmission(createHookAdmissionFailure({
runId,
statusCode: 503
}));
}, agentStartAdmissionTimeoutMs);
admissionTimer.unref?.();
}
runWithGatewayIndependentRootWorkContinuation(() => enqueueHookAgentDispatch(queueKey, async () => {
if (startupAbortController.signal.aborted) return;
try {
const cfg = getRuntimeConfig();
try {
await validateHookAgentDeliveryAccount({
cfg,
value: acceptedValue
});
} catch (err) {
settleAdmission({
ok: false,
statusCode: 400,
error: formatErrorMessage(err),
runId
});
return;
}
if (startupAbortController.signal.aborted) return;
const eventTarget = resolveHookEventTarget({
cfg,
resolvedAgentId: agentId
});
hookEventTarget = eventTarget;
const { runCronIsolatedAgentTurn } = await loadIsolatedAgentModule();
if (startupAbortController.signal.aborted) return;
const runHookIsolatedTurn = async () => await runCronIsolatedAgentTurn({
cfg,
deps,
job,
deliveryAttemptFence: null,
message: acceptedValue.message,
sessionKey,
agentId,
lane: pluginId ? "cron-nested" : "hook-dispatch",
executionIdentity: { ingress: pluginId ? {
kind: "webhook",
boundary: "gateway.hooks.plugin",
state: "present",
rawSourceRef: `${pluginId}:${safeName}`
} : {
kind: "webhook",
boundary: "gateway.hooks.agent",
state: "present",
...acceptedValue.mappingId ? { rawSourceRef: acceptedValue.mappingId } : {}
} },
abortSignal: startupAbortController.signal,
onLaneWait: (info) => {
if (info?.waiting === false) settleSuccessfulAdmission();
},
onExecutionStarted: settleSuccessfulAdmission
});
const result = await runScheduledHook(runHookIsolatedTurn);
if (admissionTimedOut) return;
const summary = resolveHookRunSummary(result);
if (!admissionSettled) settleAdmission(result.status === "ok" || result.executionStarted === true ? {
ok: true,
runId,
completion: completion.promise
} : createHookAdmissionFailure({
runId,
disposition: result.admissionDisposition
}));
const prefix = result.status === "ok" ? `Hook ${safeName}` : `Hook ${safeName} (${result.status})`;
const shouldAnnounce = result.status !== "ok" || value.deliver && result.delivered !== true && result.deliveryAttempted !== true;
completion.resolve(logHookRunTerminal(result));
if (shouldAnnounce) announceHookEvent(eventTarget, `${prefix}: ${summary}`.trim(), result.status, `hook:${jobId}`);
} catch (err) {
failAdmission(err);
}
}), "hooks:agent-dispatch").catch(failAdmission);
return await admission.promise;
};
const pluginHookReplays = /* @__PURE__ */ new Map();
const dispatchHookAgentTurn = async (value, pluginId) => {
const agentId = normalizeOptionalString(value.agentId);
if (!agentId) return {
ok: false,
reason: "agentId is required"
};
const sessionKey = normalizeHookDispatchSessionKey({
sessionKey: value.sessionKey,
targetAgentId: agentId
});
if (sessionKey !== value.sessionKey || !sessionKey.startsWith("hook:") || sessionKey.length <= 5 || /[\s\p{Cc}]/u.test(sessionKey)) return {
ok: false,
reason: "sessionKey must start with hook: and contain no whitespace or control characters"
};
if (value.externalContentSource !== "email") return {
ok: false,
reason: "externalContentSource must be email"
};
const run = async () => {
const result = await dispatchAgentHook({
name: value.name,
agentId,
effectiveAgentId: agentId,
sessionKey,
message: value.message,
deliver: value.deliver,
model: value.model,
thinking: value.thinking,
timeoutSeconds: value.timeoutSeconds,
idempotencyKey: value.idempotencyKey,
sessionMode: "isolated",
sourcePath: `plugin:${pluginId}`,
wakeMode: "now",
channel: "last",
delivery: value.deliver ? {
mode: "announce",
channel: "last"
} : { mode: "none" },
externalContentSource: "email"
}, pluginId);
return result.ok ? {
ok: true,
runId: result.runId
} : {
ok: false,
reason: result.error
};
};
const idempotencyKey = normalizeOptionalString(value.idempotencyKey);
if (!idempotencyKey) return await run();
const now = Date.now();
for (const [key, entry] of pluginHookReplays) if (entry.createdAt < now - 3e5) pluginHookReplays.delete(key);
const replayKey = JSON.stringify({
pluginId,
idempotencyKey,
name: value.name,
agentId,
sessionKey,
message: value.message,
externalContentSource: value.externalContentSource,
deliver: value.deliver,
model: value.model,
thinking: value.thinking,
timeoutSeconds: value.timeoutSeconds
});
const replay = pluginHookReplays.get(replayKey);
if (replay) return await replay.result;
const result = run().then((outcome) => {
if (!outcome.ok) pluginHookReplays.delete(replayKey);
return outcome;
}, (error) => {
pluginHookReplays.delete(replayKey);
throw error;
});
pluginHookReplays.set(replayKey, {
createdAt: now,
result
});
pruneMapToMaxSize(pluginHookReplays, DEDUPE_MAX);
return await result;
};
return {
dispatchWakeHook,
dispatchAgentHook,
dispatchHookAgentTurn
};
}
/** Creates the HTTP handler used by gateway hook endpoints. */
function createGatewayHooksRequestHandler(params) {
const { getHooksConfig, bindHost, port, logHooks, getClientIpConfig } = params;
const { dispatchAgentHook, dispatchWakeHook } = params.dispatcher ?? createGatewayHookDispatcher(params);
return createHooksRequestHandler({
scheduler: params.scheduler,
getHooksConfig,
bindHost,
port,
logHooks,
getClientIpConfig,
dispatchAgentHook,
dispatchWakeHook
});
}
//#endregion
export { createGatewayHookDispatcher, createGatewayHooksRequestHandler };