openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
639 lines (638 loc) • 20.5 kB
JavaScript
import { a as normalizeLowercaseStringOrEmpty } from "../../string-coerce-mnp54Vah.js";
import { At as boolean, Bt as discriminatedUnion, Et as array, Nn as record, Rn as string, St as _null, Tn as object, Xn as union, dn as literal, un as lazy, wn as number, yt as _enum } from "../../schemas-6cH6bZ7o.js";
import { n as ZodIssueCode } from "../../compat-B-THjfQ8.js";
import { t as resolveConfiguredSecretInputString } from "../../resolve-configured-secret-input-string-9uwQlTwC.js";
import { t as safeEqualSecret } from "../../secret-equal-DRsL8lKD.js";
import { _ as resolveRequestClientIp } from "../../net-DTe7AQiu.js";
import "../../security-runtime-CQm7DD1u.js";
import "../../string-coerce-runtime-CEGJWkQ_.js";
import { t as definePluginEntry } from "../../plugin-entry-C7DUzV0e.js";
import { a as createWebhookInFlightLimiter, n as WEBHOOK_IN_FLIGHT_DEFAULTS, s as readJsonWebhookBodyOrReject } from "../../webhook-request-guards-gS2khsvD.js";
import { a as createFixedWindowRateLimiter, r as WEBHOOK_RATE_LIMIT_DEFAULTS } from "../../webhook-ingress-Dgd3Jtks.js";
import { t as normalizeWebhookPath } from "../../webhook-path-CaYfbDPb.js";
import { l as withResolvedWebhookRequestPipeline, o as resolveWebhookTargetWithAuthOrReject } from "../../webhook-targets-Beqkt_FW.js";
import "../../api-DSO-lYqn.js";
import "../../runtime-api-BLH-Pb9i.js";
//#region extensions/webhooks/src/config.ts
const secretRefSchema = object({
source: _enum([
"env",
"file",
"exec"
]),
provider: string().trim().min(1),
id: string().trim().min(1)
}).strict();
const secretInputSchema = union([string().trim().min(1), secretRefSchema]);
const webhookRouteConfigSchema = object({
enabled: boolean().optional().default(true),
path: string().trim().min(1).optional(),
sessionKey: string().trim().min(1),
secret: secretInputSchema,
controllerId: string().trim().min(1).optional(),
description: string().trim().min(1).optional()
}).strict();
const webhooksPluginConfigSchema = object({ routes: record(string().trim().min(1), webhookRouteConfigSchema).default({}) }).strict();
function resolveWebhooksPluginConfig(params) {
const parsed = webhooksPluginConfigSchema.parse(params.pluginConfig ?? {});
const configuredRoutes = [];
const seenPaths = /* @__PURE__ */ new Map();
for (const [routeId, route] of Object.entries(parsed.routes)) {
if (!route.enabled) continue;
const path = normalizeWebhookPath(route.path ?? `/plugins/webhooks/${routeId}`);
const existingRouteId = seenPaths.get(path);
if (existingRouteId) throw new Error(`webhooks.routes.${routeId}.path conflicts with routes.${existingRouteId}.path (${path}).`);
seenPaths.set(path, routeId);
configuredRoutes.push({
routeId,
path,
sessionKey: route.sessionKey,
secret: route.secret,
controllerId: route.controllerId ?? `webhooks/${routeId}`,
...route.description ? { description: route.description } : {}
});
}
return configuredRoutes;
}
//#endregion
//#region extensions/webhooks/src/http.ts
const jsonValueSchema = lazy(() => union([
_null(),
boolean(),
number().finite(),
string(),
array(jsonValueSchema),
record(string(), jsonValueSchema)
]));
const nullableStringSchema = string().trim().min(1).nullable().optional();
const webhookActionSchema = discriminatedUnion("action", [
object({
action: literal("create_flow"),
controllerId: string().trim().min(1).optional(),
goal: string().trim().min(1),
status: _enum([
"queued",
"running",
"waiting",
"blocked"
]).optional(),
notifyPolicy: _enum([
"done_only",
"state_changes",
"silent"
]).optional(),
currentStep: nullableStringSchema,
stateJson: jsonValueSchema.nullable().optional(),
waitJson: jsonValueSchema.nullable().optional()
}).strict(),
object({
action: literal("get_flow"),
flowId: string().trim().min(1)
}).strict(),
object({ action: literal("list_flows") }).strict(),
object({ action: literal("find_latest_flow") }).strict(),
object({
action: literal("resolve_flow"),
token: string().trim().min(1)
}).strict(),
object({
action: literal("get_task_summary"),
flowId: string().trim().min(1)
}).strict(),
object({
action: literal("set_waiting"),
flowId: string().trim().min(1),
expectedRevision: number().int().nonnegative(),
currentStep: nullableStringSchema,
stateJson: jsonValueSchema.nullable().optional(),
waitJson: jsonValueSchema.nullable().optional(),
blockedTaskId: nullableStringSchema,
blockedSummary: nullableStringSchema
}).strict(),
object({
action: literal("resume_flow"),
flowId: string().trim().min(1),
expectedRevision: number().int().nonnegative(),
status: _enum(["queued", "running"]).optional(),
currentStep: nullableStringSchema,
stateJson: jsonValueSchema.nullable().optional()
}).strict(),
object({
action: literal("finish_flow"),
flowId: string().trim().min(1),
expectedRevision: number().int().nonnegative(),
stateJson: jsonValueSchema.nullable().optional()
}).strict(),
object({
action: literal("fail_flow"),
flowId: string().trim().min(1),
expectedRevision: number().int().nonnegative(),
stateJson: jsonValueSchema.nullable().optional(),
blockedTaskId: nullableStringSchema,
blockedSummary: nullableStringSchema
}).strict(),
object({
action: literal("request_cancel"),
flowId: string().trim().min(1),
expectedRevision: number().int().nonnegative()
}).strict(),
object({
action: literal("cancel_flow"),
flowId: string().trim().min(1)
}).strict(),
object({
action: literal("run_task"),
flowId: string().trim().min(1),
runtime: _enum(["subagent", "acp"]),
sourceId: string().trim().min(1).optional(),
childSessionKey: string().trim().min(1).optional(),
parentTaskId: string().trim().min(1).optional(),
agentId: string().trim().min(1).optional(),
runId: string().trim().min(1).optional(),
label: string().trim().min(1).optional(),
task: string().trim().min(1),
preferMetadata: boolean().optional(),
notifyPolicy: _enum([
"done_only",
"state_changes",
"silent"
]).optional(),
status: _enum(["queued", "running"]).optional(),
startedAt: number().int().nonnegative().optional(),
lastEventAt: number().int().nonnegative().optional(),
progressSummary: nullableStringSchema
}).strict().superRefine((value, ctx) => {
if (value.status !== "running" && (value.startedAt !== void 0 || value.lastEventAt !== void 0 || value.progressSummary !== void 0)) ctx.addIssue({
code: ZodIssueCode.custom,
message: "status must be running when startedAt, lastEventAt, or progressSummary is provided",
path: ["status"]
});
})
]);
function pickOptionalFields(source, keys) {
const result = {};
for (const key of keys) {
const value = source[key];
if (value !== void 0) result[key] = value;
}
return result;
}
function pickOptionalTruthyStringFields(source, keys) {
const result = {};
for (const key of keys) {
const value = source[key];
if (typeof value === "string" && value) result[key] = value;
}
return result;
}
function toFlowView(flow) {
return {
flowId: flow.flowId,
syncMode: flow.syncMode,
...pickOptionalTruthyStringFields(flow, [
"controllerId",
"currentStep",
"blockedTaskId",
"blockedSummary"
]),
revision: flow.revision,
status: flow.status,
notifyPolicy: flow.notifyPolicy,
goal: flow.goal,
...pickOptionalFields(flow, [
"stateJson",
"waitJson",
"cancelRequestedAt"
]),
createdAt: flow.createdAt,
updatedAt: flow.updatedAt,
...pickOptionalFields(flow, ["endedAt"])
};
}
function toTaskView(task) {
return {
taskId: task.taskId,
runtime: task.runtime,
...pickOptionalTruthyStringFields(task, [
"sourceId",
"childSessionKey",
"parentFlowId",
"parentTaskId",
"agentId",
"runId",
"label",
"error",
"progressSummary",
"terminalSummary",
"terminalOutcome"
]),
scopeKind: task.scopeKind,
task: task.task,
status: task.status,
deliveryStatus: task.deliveryStatus,
notifyPolicy: task.notifyPolicy,
createdAt: task.createdAt,
...pickOptionalFields(task, [
"startedAt",
"endedAt",
"lastEventAt",
"cleanupAfter"
])
};
}
function writeJson(res, statusCode, body) {
res.statusCode = statusCode;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify(body));
}
function extractSharedSecret(req) {
const authHeader = Array.isArray(req.headers.authorization) ? req.headers.authorization[0] ?? "" : req.headers.authorization ?? "";
if (normalizeLowercaseStringOrEmpty(authHeader).startsWith("bearer ")) return authHeader.slice(7).trim();
const sharedHeader = req.headers["x-openclaw-webhook-secret"];
return Array.isArray(sharedHeader) ? (sharedHeader[0] ?? "").trim() : (sharedHeader ?? "").trim();
}
function timingSafeEquals(left, right) {
return safeEqualSecret(left, right);
}
function formatZodError(error) {
const firstIssue = error.issues[0];
if (!firstIssue) return "invalid request";
return `${firstIssue.path.length > 0 ? `${firstIssue.path.join(".")}: ` : ""}${firstIssue.message}`;
}
function mapMutationResult(result) {
return result;
}
function mapFlowMutationResult(result) {
return mapMutationResult(result.applied ? {
applied: true,
flow: toFlowView(result.flow)
} : {
applied: false,
code: result.code,
...result.current ? { current: toFlowView(result.current) } : {}
});
}
function mapMutationStatus(result) {
if (result.applied) return { statusCode: 200 };
switch (result.code) {
case "not_found": return {
statusCode: 404,
code: "not_found",
error: "TaskFlow not found."
};
case "not_managed": return {
statusCode: 409,
code: "not_managed",
error: "TaskFlow is not managed by this webhook surface."
};
case "revision_conflict": return {
statusCode: 409,
code: "revision_conflict",
error: "TaskFlow changed since the caller's expected revision."
};
case "persist_failed": return {
statusCode: 503,
code: "persist_failed",
error: "TaskFlow persistence failed."
};
default: return {
statusCode: 409,
code: "mutation_rejected",
error: "TaskFlow mutation was rejected."
};
}
}
function mapCreateFlowStatus(result) {
if (result.created) return { statusCode: 200 };
if (result.code === "persist_failed") return {
statusCode: 503,
code: "persist_failed",
error: "TaskFlow persistence failed."
};
return {
statusCode: 409,
code: "create_rejected",
error: "TaskFlow creation was rejected."
};
}
function mapRunTaskStatus(result) {
if (result.created) return { statusCode: 200 };
if (!result.found) return {
statusCode: 404,
code: "not_found",
error: "TaskFlow not found."
};
if (result.reason === "Flow cancellation has already been requested.") return {
statusCode: 409,
code: "cancel_requested",
error: result.reason
};
if (result.reason === "Flow does not accept managed child tasks.") return {
statusCode: 409,
code: "not_managed",
error: result.reason
};
if (result.reason?.startsWith("Flow is already ")) return {
statusCode: 409,
code: "terminal",
error: result.reason
};
if (result.reason === "Task persistence failed.") return {
statusCode: 503,
code: "persist_failed",
error: result.reason
};
return {
statusCode: 409,
code: "task_not_created",
error: result.reason ?? "TaskFlow task was not created."
};
}
function mapCancelStatus(result) {
if (result.cancelled) return { statusCode: 200 };
if (!result.found) return {
statusCode: 404,
code: "not_found",
error: "TaskFlow not found."
};
if (result.reason === "One or more child tasks are still active.") return {
statusCode: 202,
code: "cancel_pending",
error: result.reason
};
if (result.reason === "Flow changed while cancellation was in progress.") return {
statusCode: 409,
code: "revision_conflict",
error: result.reason
};
if (result.reason?.startsWith("Flow is already ")) return {
statusCode: 409,
code: "terminal",
error: result.reason
};
if (result.reason === "Flow persistence failed.") return {
statusCode: 503,
code: "persist_failed",
error: result.reason
};
return {
statusCode: 409,
code: "cancel_rejected",
error: result.reason ?? "TaskFlow cancellation was rejected."
};
}
function describeWebhookOutcome(params) {
switch (params.action.action) {
case "create_flow": return mapCreateFlowStatus(params.result);
case "set_waiting":
case "resume_flow":
case "finish_flow":
case "fail_flow":
case "request_cancel": return mapMutationStatus(params.result);
case "cancel_flow": return mapCancelStatus(params.result);
case "run_task": return mapRunTaskStatus(params.result);
default: return { statusCode: 200 };
}
}
async function executeWebhookAction(params) {
const { action, target } = params;
switch (action.action) {
case "create_flow": {
const flow = target.taskFlow.tryCreateManaged({
controllerId: action.controllerId ?? target.defaultControllerId,
goal: action.goal,
status: action.status,
notifyPolicy: action.notifyPolicy,
currentStep: action.currentStep ?? void 0,
stateJson: action.stateJson,
waitJson: action.waitJson
});
return flow ? {
created: true,
flow: toFlowView(flow)
} : {
created: false,
code: "persist_failed"
};
}
case "get_flow": {
const flow = target.taskFlow.get(action.flowId);
return { flow: flow ? toFlowView(flow) : null };
}
case "list_flows": return { flows: target.taskFlow.list().map(toFlowView) };
case "find_latest_flow": {
const flow = target.taskFlow.findLatest();
return { flow: flow ? toFlowView(flow) : null };
}
case "resolve_flow": {
const flow = target.taskFlow.resolve(action.token);
return { flow: flow ? toFlowView(flow) : null };
}
case "get_task_summary": return { summary: target.taskFlow.getTaskSummary(action.flowId) ?? null };
case "set_waiting": return mapFlowMutationResult(target.taskFlow.setWaiting({
flowId: action.flowId,
expectedRevision: action.expectedRevision,
currentStep: action.currentStep,
stateJson: action.stateJson,
waitJson: action.waitJson,
blockedTaskId: action.blockedTaskId,
blockedSummary: action.blockedSummary
}));
case "resume_flow": return mapFlowMutationResult(target.taskFlow.resume({
flowId: action.flowId,
expectedRevision: action.expectedRevision,
status: action.status,
currentStep: action.currentStep,
stateJson: action.stateJson
}));
case "finish_flow": return mapFlowMutationResult(target.taskFlow.finish({
flowId: action.flowId,
expectedRevision: action.expectedRevision,
stateJson: action.stateJson
}));
case "fail_flow": return mapFlowMutationResult(target.taskFlow.fail({
flowId: action.flowId,
expectedRevision: action.expectedRevision,
stateJson: action.stateJson,
blockedTaskId: action.blockedTaskId,
blockedSummary: action.blockedSummary
}));
case "request_cancel": return mapFlowMutationResult(target.taskFlow.requestCancel({
flowId: action.flowId,
expectedRevision: action.expectedRevision
}));
case "cancel_flow": {
const result = await target.taskFlow.cancel({
flowId: action.flowId,
cfg: params.cfg
});
return {
found: result.found,
cancelled: result.cancelled,
...result.reason ? { reason: result.reason } : {},
...result.flow ? { flow: toFlowView(result.flow) } : {},
...result.tasks ? { tasks: result.tasks.map(toTaskView) } : {}
};
}
case "run_task": {
const result = target.taskFlow.runTask({
flowId: action.flowId,
runtime: action.runtime,
sourceId: action.sourceId,
childSessionKey: action.childSessionKey,
parentTaskId: action.parentTaskId,
agentId: action.agentId,
runId: action.runId,
label: action.label,
task: action.task,
preferMetadata: action.preferMetadata,
notifyPolicy: action.notifyPolicy,
status: action.status,
startedAt: action.startedAt,
lastEventAt: action.lastEventAt,
progressSummary: action.progressSummary
});
if (result.created) return {
created: true,
flow: toFlowView(result.flow),
task: toTaskView(result.task)
};
return {
found: result.found,
created: false,
reason: result.reason,
...result.flow ? { flow: toFlowView(result.flow) } : {}
};
}
}
throw new Error("Unsupported webhook action");
}
function createTaskFlowWebhookRequestHandler(params) {
const rateLimiter = createFixedWindowRateLimiter({
windowMs: WEBHOOK_RATE_LIMIT_DEFAULTS.windowMs,
maxRequests: WEBHOOK_RATE_LIMIT_DEFAULTS.maxRequests,
maxTrackedKeys: WEBHOOK_RATE_LIMIT_DEFAULTS.maxTrackedKeys
});
const inFlightLimiter = params.inFlightLimiter ?? createWebhookInFlightLimiter({
maxInFlightPerKey: WEBHOOK_IN_FLIGHT_DEFAULTS.maxInFlightPerKey,
maxTrackedKeys: WEBHOOK_IN_FLIGHT_DEFAULTS.maxTrackedKeys
});
const resolveTargetSecret = async (target) => {
if (typeof target.secretInput === "string") return target.secretInput;
return (await resolveConfiguredSecretInputString({
config: params.cfg,
env: process.env,
value: target.secretInput,
path: target.secretConfigPath
})).value;
};
return async (req, res) => {
return await withResolvedWebhookRequestPipeline({
req,
res,
targetsByPath: params.targetsByPath,
allowMethods: ["POST"],
requireJsonContentType: true,
rateLimiter,
rateLimitKey: (() => {
const clientIp = resolveRequestClientIp(req, params.cfg.gateway?.trustedProxies, params.cfg.gateway?.allowRealIpFallback === true) ?? req.socket.remoteAddress ?? "unknown";
return `${new URL(req.url ?? "/", "http://localhost").pathname}:${clientIp}`;
})(),
inFlightLimiter,
handle: async ({ targets }) => {
const presentedSecret = extractSharedSecret(req);
const target = await resolveWebhookTargetWithAuthOrReject({
targets,
res,
isMatch: async (candidate) => {
if (presentedSecret.length === 0) return false;
const resolvedSecret = await resolveTargetSecret(candidate);
return Boolean(resolvedSecret && timingSafeEquals(resolvedSecret, presentedSecret));
}
});
if (!target) return true;
const body = await readJsonWebhookBodyOrReject({
req,
res,
maxBytes: 256 * 1024,
timeoutMs: 15e3,
emptyObjectOnEmpty: false,
invalidJsonMessage: "invalid request body"
});
if (!body.ok) return true;
const parsed = webhookActionSchema.safeParse(body.value);
if (!parsed.success) {
writeJson(res, 400, {
ok: false,
code: "invalid_request",
error: formatZodError(parsed.error)
});
return true;
}
const result = await executeWebhookAction({
action: parsed.data,
target,
cfg: params.cfg
});
const outcome = describeWebhookOutcome({
action: parsed.data,
result
});
writeJson(res, outcome.statusCode, outcome.statusCode < 400 ? {
ok: true,
routeId: target.routeId,
...outcome.code ? { code: outcome.code } : {},
result
} : {
ok: false,
routeId: target.routeId,
code: outcome.code ?? "request_rejected",
error: outcome.error ?? "request rejected",
result
});
return true;
}
});
};
}
//#endregion
//#region extensions/webhooks/index.ts
function registerWebhookRoutes(api) {
const routes = resolveWebhooksPluginConfig({ pluginConfig: api.pluginConfig });
if (routes.length === 0) return;
const targetsByPath = /* @__PURE__ */ new Map();
const handler = createTaskFlowWebhookRequestHandler({
cfg: api.config,
targetsByPath
});
for (const route of routes) {
const taskFlow = api.runtime.tasks.managedFlows.bindSession({ sessionKey: route.sessionKey });
const target = {
routeId: route.routeId,
path: route.path,
secretInput: route.secret,
secretConfigPath: `plugins.entries.webhooks.routes.${route.routeId}.secret`,
defaultControllerId: route.controllerId,
taskFlow
};
targetsByPath.set(target.path, [...targetsByPath.get(target.path) ?? [], target]);
api.registerHttpRoute({
path: target.path,
auth: "plugin",
match: "exact",
replaceExisting: true,
handler
});
api.logger.info?.(`[webhooks] registered route ${route.routeId} on ${route.path} for session ${route.sessionKey}`);
}
}
var webhooks_default = definePluginEntry({
id: "webhooks",
name: "Webhooks",
description: "Authenticated inbound webhooks that bind external automation to OpenClaw TaskFlows.",
register(api) {
registerWebhookRoutes(api);
}
});
//#endregion
export { webhooks_default as default };