UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

1,038 lines (1,037 loc) 38.3 kB
import { E as resolveDateTimestampMs, I as resolveTimestampMsToIsoString } from "./number-coercion-CLj0HTDM.js"; import { l as normalizeOptionalString } from "./string-coerce-CIXf7egm.js"; import { r as truncateUtf16Safe } from "./utf16-slice-D_ngcYKd.js"; import { n as getRuntimeConfig } from "./io.runtime-B9iJRs3w.js"; import { h as redactToolPayloadText } from "./redact-BtvPPfTi.js"; import { o as listAgentIds } from "./agent-scope-config-DcbEhP0R.js"; import { t as pruneMapToMaxSize } from "./map-size-CNcWiFKu.js"; import { b as toAgentStoreSessionKey, l as isUnscopedSessionKeySentinel } from "./session-key-BnWWjqNc.js"; import { t as formatErrorMessage } from "./errors-Db3Ymjlb.js"; import { p as sendHttpRequestRejection } from "./http-body-D3IMwTJJ.js"; import "./agent-scope-DbtJyKUL.js"; import "./io-bdCzpGWJ.js"; import { t as safeEqualSecret } from "./secret-equal-DRsL8lKD.js"; import { x as resolveRequestClientIpFromHeaders } from "./net-DbNPs6Xm.js"; import { a as AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH, h as normalizeRateLimitClientIp, p as createAuthRateLimiter } from "./auth-rate-limit-C2k5aTEA.js"; import { o as readPreparedGatewayIngressAttribution } from "./ingress-attribution-C3JDfhmX.js"; import { x as runWithGatewayIndependentRootWorkContinuation } from "./gateway-work-admission-R1IpuDim.js"; import { l as requestHeartbeat } from "./heartbeat-wake-dlWl8dXS.js"; import { a as enqueueSystemEvent, h as withSystemEventOwner } from "./system-events-C03jsM0j.js"; import { n as DEDUPE_TTL_MS, t as DEDUPE_MAX } from "./server-constants-BrVEC7RW.js"; import { n as canonicalizeMainSessionAlias, r as resolveAgentMainSessionKey } from "./main-session-Br0F9dzh.js"; import { r as resolveHookExternalContentSource } from "./external-content-source-DI01uOKv.js"; import "./external-content-CpqslxXH.js"; import "./sessions-9nxpeTwt.js"; import { t as resolveCronAgentSessionKey } from "./session-key-VAvgVMk2.js"; import { r as resolveOutboundChannelPlugin } from "./channel-resolution-DfSowVxJ.js"; import { i as resolveChannelDefaultAccountId } from "./helpers-VcK_WJ24.js"; import { r as validateExplicitMessageAccountSelection } from "./message-account-selection-1gun6iXS.js"; import { s as sendJson } from "./http-common-BaZaosnr.js"; import { _ as resolveHookPathBodyLimit, a as getHookSessionKeyPrefixError, b as applyHookMappings, c as normalizeAgentPayload, d as normalizeWakePayload, f as readJsonBody, g as resolveHookIdempotencyKey, h as resolveHookDeliver, i as getHookChannelError, l as normalizeHookDispatchSessionKey, m as resolveHookChannel, n as extractHookToken, o as isHookAgentAllowed, p as resolveEffectiveHookTargetAgentId, r as getHookAgentPolicyError, s as isSessionKeyAllowedByPrefix, u as normalizeHookHeaders, v as resolveHookSessionKey } from "./hooks-CdPzVOnm.js"; import { n as runWithScheduledGatewayContext, t as fenceScheduledGatewayContextResolver } from "./scheduled-run-gateway-context-DXYV7Oco.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; const HOOK_FAN_OUT_RESPONSE_DEADLINE_MS = 8e3; const HOOK_FAN_OUT_DERIVED_IDEMPOTENCY = "hook-fanout-item"; 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) { if (result.ok) { sendJson(res, 200, result); return; } const { statusCode, ...body } = result; sendJson(res, statusCode, body); } 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 }); } function resolveMappedHookExternalContentSource(params) { if (params.subPath === "gmail") return "gmail"; return resolveHookExternalContentSource(params.sessionKey) ?? "webhook"; } function createHooksRequestHandler(opts) { const { getHooksConfig, logHooks, dispatchAgentHook, dispatchWakeHook, getClientIpConfig } = opts; const fanoutResponseDeadlineMs = opts.fanoutResponseDeadlineMs ?? HOOK_FAN_OUT_RESPONSE_DEADLINE_MS; const hookReplayCache = /* @__PURE__ */ new Map(); const pendingHookReplays = /* @__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 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) => { const cutoff = now - DEDUPE_TTL_MS; for (const [key, entry] of hookReplayCache) if (entry.ts < cutoff) hookReplayCache.delete(key); pruneMapToMaxSize(hookReplayCache, DEDUPE_MAX); }; 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); }; const resolveHookReplay = (key, now) => { if (!key) return; const cachedRunId = resolveCachedHookRunId(key, now); if (cachedRunId) return { ok: true, runId: cachedRunId }; return pendingHookReplays.get(key); }; const dispatchAgentHookWithReplay = (key, now, dispatch) => { if (!key) return dispatch(); const existing = resolveHookReplay(key, now); if (existing) return existing; const pending = Promise.resolve().then(dispatch).then((result) => { if (result.ok) rememberHookRunId(key, result.runId, now); return result; }).finally(() => { if (pendingHookReplays.get(key) === pending) pendingHookReplays.delete(key); }); pendingHookReplays.set(key, 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 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; } const payload = typeof body.value === "object" && body.value !== null ? body.value : {}; const headers = normalizeHookHeaders(req); const idempotencyKey = resolveHookIdempotencyKey({ payload, headers }); const now = Date.now(); 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 }; 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 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, now); if (replay) { sendAgentResult(res, await replay); return true; } const dispatchSessionKey = resolveDispatchSessionKeyOrRespond(sessionKey.value, target.effectiveAgentId); if (dispatchSessionKey === null) return true; sendAgentResult(res, await dispatchAgentHookWithReplay(replayKey, now, () => dispatchAgentHook({ ...normalized.value, effectiveAgentId: target.effectiveAgentId, idempotencyKey, sessionKey: dispatchSessionKey, sourcePath: `${basePath}/agent`, agentId: target.selectedAgentId, externalContentSource: "webhook" }))); 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.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 || "mapping", token, idempotencyKey: mapped.fanout ? idempotencyKey ?? HOOK_FAN_OUT_DERIVED_IDEMPOTENCY : idempotencyKey, dispatchScope }); return () => dispatchAgentHookWithReplay(replayKey, now, () => 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" } : {}, externalContentSource: resolveMappedHookExternalContentSource({ subPath, sessionKey: sessionKey.value }) })); }; 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 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; 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; }; } 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 ? 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 enqueueHookAgentDispatch = createSessionKeyedHookDispatchQueue(); let isolatedAgentModulePromise; const loadIsolatedAgentModule = () => isolatedAgentModulePromise ??= import("./isolated-agent-Bx1YWAHS.js"); 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") requestHeartbeat({ 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 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); }; 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 reportHookFailure = (err) => { logHookRunTerminal({ status: "error", error: String(err) }); const eventTarget = hookEventTarget ?? resolveHookEventTarget({ cfg: getRuntimeConfig(), resolvedAgentId: value.effectiveAgentId }); const eventSessionKey = eventTarget.eventSessionKey; const isGlobalEvent = isUnscopedSessionKeySentinel(eventSessionKey); let heartbeatTarget; if (isGlobalEvent && hookEventTarget) { const globalTerminalAgentId = resolveGlobalTerminalAgentId("error"); if (!globalTerminalAgentId) return; heartbeatTarget = { agentId: globalTerminalAgentId }; } else heartbeatTarget = eventTarget.heartbeatTarget; const failureEventOptions = { sessionKey: eventSessionKey }; enqueueSystemEvent(`Hook ${safeName} (error): ${String(err)}`, isGlobalEvent && heartbeatTarget.agentId ? withSystemEventOwner(failureEventOptions, heartbeatTarget.agentId) : failureEventOptions); if (value.wakeMode === "now") requestHeartbeat({ source: "hook", intent: "immediate", reason: `hook:${jobId}:error`, ...heartbeatTarget }); }; let dispatchCfg; try { dispatchCfg = getRuntimeConfig(); } catch (err) { runWithGatewayIndependentRootWorkContinuation(async () => reportHookFailure(err), "hooks:failure-report"); return createHookAdmissionFailure({ runId }); } let acceptedValue; try { acceptedValue = 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 }); let settleAdmission; let admissionSettled = false; let admissionTimedOut = false; let admissionTimer; const admission = new Promise((resolve) => { settleAdmission = (result) => { if (admissionSettled) return; admissionSettled = true; if (admissionTimer) { clearTimeout(admissionTimer); admissionTimer = void 0; } resolve(result); }; }); const admissionTimeoutError = /* @__PURE__ */ new Error(HOOK_AGENT_START_ADMISSION_TIMEOUT_ERROR); const startupAbortController = new AbortController(); const settleSuccessfulAdmission = () => { startupAbortController.signal.throwIfAborted(); settleAdmission({ ok: true, runId }); }; 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 { validateHookAgentDeliveryAccount({ cfg, value: acceptedValue }); } catch (err) { settleAdmission({ ok: false, statusCode: 400, error: formatErrorMessage(err), runId }); 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, 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 runWithScheduledGatewayContext({ ...scheduledGatewayContextResolver ? { resolveGatewayContext: scheduledGatewayContextResolver } : {}, run: runHookIsolatedTurn }); if (admissionTimedOut) return; const summary = resolveHookRunSummary(result); if (!admissionSettled) settleAdmission(result.status === "ok" || result.executionStarted === true ? { ok: true, runId } : createHookAdmissionFailure({ runId, disposition: result.admissionDisposition })); const prefix = result.status === "ok" ? `Hook ${safeName}` : `Hook ${safeName} (${result.status})`; const shouldAnnounce = shouldAnnounceHookRunResult({ deliver: value.deliver, result }); logHookRunTerminal(result); if (shouldAnnounce) { const eventSessionKey = eventTarget.eventSessionKey; const isGlobalEvent = isUnscopedSessionKeySentinel(eventSessionKey); let announceEventOptions = { sessionKey: eventSessionKey }; let heartbeatTarget; if (isGlobalEvent) { const globalTerminalAgentId = resolveGlobalTerminalAgentId(result.status); if (!globalTerminalAgentId) return; announceEventOptions = withSystemEventOwner(announceEventOptions, globalTerminalAgentId); heartbeatTarget = { agentId: globalTerminalAgentId }; } else heartbeatTarget = eventTarget.heartbeatTarget; enqueueSystemEvent(`${prefix}: ${summary}`.trim(), announceEventOptions); if (value.wakeMode === "now") requestHeartbeat({ source: "hook", intent: "immediate", reason: `hook:${jobId}`, ...heartbeatTarget }); } } catch (err) { if (admissionTimedOut) return; settleAdmission(createHookAdmissionFailure({ runId })); reportHookFailure(err); } }), "hooks:agent-dispatch").catch((err) => { if (admissionTimedOut) return; settleAdmission(createHookAdmissionFailure({ runId })); reportHookFailure(err); }); return await admission; }; 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 ? result : { 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({ getHooksConfig, bindHost, port, logHooks, getClientIpConfig, dispatchAgentHook, dispatchWakeHook }); } //#endregion export { createGatewayHookDispatcher, createGatewayHooksRequestHandler };