Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
7
extensions/webhooks/api.ts
Normal file
7
extensions/webhooks/api.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// Webhooks API module exposes the plugin public contract.
|
||||
export {
|
||||
definePluginEntry,
|
||||
type OpenClawPluginApi,
|
||||
type PluginLogger,
|
||||
type PluginRuntime,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
75
extensions/webhooks/index.test.ts
Normal file
75
extensions/webhooks/index.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
// Webhooks tests cover index plugin behavior.
|
||||
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawPluginApi } from "./api.js";
|
||||
import plugin from "./index.js";
|
||||
|
||||
function createApi(params?: {
|
||||
pluginConfig?: OpenClawPluginApi["pluginConfig"];
|
||||
registerHttpRoute?: OpenClawPluginApi["registerHttpRoute"];
|
||||
logger?: OpenClawPluginApi["logger"];
|
||||
}): OpenClawPluginApi {
|
||||
return createTestPluginApi({
|
||||
id: "webhooks",
|
||||
name: "Webhooks",
|
||||
source: "test",
|
||||
pluginConfig: params?.pluginConfig ?? {},
|
||||
runtime: {
|
||||
tasks: {
|
||||
managedFlows: {
|
||||
bindSession: vi.fn(({ sessionKey }: { sessionKey: string }) => ({ sessionKey })),
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawPluginApi["runtime"],
|
||||
registerHttpRoute: params?.registerHttpRoute ?? vi.fn(),
|
||||
logger:
|
||||
params?.logger ??
|
||||
({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
} as OpenClawPluginApi["logger"]),
|
||||
});
|
||||
}
|
||||
|
||||
function requireFirstRouteRegistration(mock: ReturnType<typeof vi.fn>) {
|
||||
const [call] = mock.mock.calls;
|
||||
if (!call) {
|
||||
throw new Error("expected webhook route registration");
|
||||
}
|
||||
return call[0] as Parameters<OpenClawPluginApi["registerHttpRoute"]>[0];
|
||||
}
|
||||
|
||||
describe("webhooks plugin registration", () => {
|
||||
it("registers SecretRef-backed routes synchronously", () => {
|
||||
const registerHttpRoute = vi.fn();
|
||||
|
||||
const result = plugin.register(
|
||||
createApi({
|
||||
pluginConfig: {
|
||||
routes: {
|
||||
zapier: {
|
||||
sessionKey: "agent:main:main",
|
||||
secret: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "OPENCLAW_WEBHOOK_SECRET",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
registerHttpRoute,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(registerHttpRoute).toHaveBeenCalledTimes(1);
|
||||
const route = requireFirstRouteRegistration(registerHttpRoute);
|
||||
expect(route.path).toBe("/plugins/webhooks/zapier");
|
||||
expect(route.auth).toBe("plugin");
|
||||
expect(route.match).toBe("exact");
|
||||
expect(route.replaceExisting).toBe(true);
|
||||
expect(route.handler).toBeTypeOf("function");
|
||||
});
|
||||
});
|
||||
54
extensions/webhooks/index.ts
Normal file
54
extensions/webhooks/index.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
// Webhooks plugin entrypoint registers its OpenClaw integration.
|
||||
import { definePluginEntry, type OpenClawPluginApi } from "./api.js";
|
||||
import { resolveWebhooksPluginConfig } from "./src/config.js";
|
||||
import { createTaskFlowWebhookRequestHandler, type TaskFlowWebhookTarget } from "./src/http.js";
|
||||
|
||||
function registerWebhookRoutes(api: OpenClawPluginApi): void {
|
||||
const routes = resolveWebhooksPluginConfig({
|
||||
pluginConfig: api.pluginConfig,
|
||||
});
|
||||
if (routes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetsByPath = new Map<string, TaskFlowWebhookTarget[]>();
|
||||
const handler = createTaskFlowWebhookRequestHandler({
|
||||
cfg: api.config,
|
||||
targetsByPath,
|
||||
});
|
||||
|
||||
for (const route of routes) {
|
||||
const taskFlow = api.runtime.tasks.managedFlows.bindSession({
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
const target: TaskFlowWebhookTarget = {
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "webhooks",
|
||||
name: "Webhooks",
|
||||
description:
|
||||
"Authenticated inbound webhooks that bind external automation to OpenClaw TaskFlows.",
|
||||
register(api: OpenClawPluginApi) {
|
||||
registerWebhookRoutes(api);
|
||||
},
|
||||
});
|
||||
51
extensions/webhooks/openclaw.plugin.json
Normal file
51
extensions/webhooks/openclaw.plugin.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"id": "webhooks",
|
||||
"activation": {
|
||||
"onStartup": true
|
||||
},
|
||||
"name": "Webhooks",
|
||||
"description": "Authenticated inbound webhooks that bind external automation to OpenClaw TaskFlows.",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"$defs": {
|
||||
"secretRef": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"source": {
|
||||
"type": "string",
|
||||
"enum": ["env", "file", "exec"]
|
||||
},
|
||||
"provider": { "type": "string" },
|
||||
"id": { "type": "string" }
|
||||
},
|
||||
"required": ["source", "provider", "id"]
|
||||
},
|
||||
"secretInput": {
|
||||
"anyOf": [{ "type": "string", "minLength": 1 }, { "$ref": "#/$defs/secretRef" }]
|
||||
},
|
||||
"route": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"path": { "type": "string", "minLength": 1 },
|
||||
"sessionKey": { "type": "string", "minLength": 1 },
|
||||
"secret": { "$ref": "#/$defs/secretInput" },
|
||||
"controllerId": { "type": "string", "minLength": 1 },
|
||||
"description": { "type": "string" }
|
||||
},
|
||||
"required": ["sessionKey", "secret"]
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"routes": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/route"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
extensions/webhooks/package.json
Normal file
18
extensions/webhooks/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@openclaw/webhooks",
|
||||
"version": "2026.6.11",
|
||||
"private": true,
|
||||
"description": "OpenClaw webhook bridge plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "4.4.3"
|
||||
}
|
||||
}
|
||||
16
extensions/webhooks/runtime-api.ts
Normal file
16
extensions/webhooks/runtime-api.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// Webhooks API module exposes the plugin public contract.
|
||||
export {
|
||||
createFixedWindowRateLimiter,
|
||||
createWebhookInFlightLimiter,
|
||||
normalizeWebhookPath,
|
||||
readJsonWebhookBodyOrReject,
|
||||
resolveRequestClientIp,
|
||||
resolveWebhookTargetWithAuthOrReject,
|
||||
resolveWebhookTargetWithAuthOrRejectSync,
|
||||
withResolvedWebhookRequestPipeline,
|
||||
WEBHOOK_IN_FLIGHT_DEFAULTS,
|
||||
WEBHOOK_RATE_LIMIT_DEFAULTS,
|
||||
type WebhookInFlightLimiter,
|
||||
} from "openclaw/plugin-sdk/webhook-ingress";
|
||||
export { resolveConfiguredSecretInputString } from "openclaw/plugin-sdk/secret-input-runtime";
|
||||
export type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
88
extensions/webhooks/src/config.test.ts
Normal file
88
extensions/webhooks/src/config.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
// Webhooks tests cover config plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveWebhooksPluginConfig } from "./config.js";
|
||||
|
||||
describe("resolveWebhooksPluginConfig", () => {
|
||||
it("keeps SecretRef-backed secrets on the route config", () => {
|
||||
const routes = resolveWebhooksPluginConfig({
|
||||
pluginConfig: {
|
||||
routes: {
|
||||
zapier: {
|
||||
sessionKey: "agent:main:main",
|
||||
secret: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "OPENCLAW_WEBHOOK_SECRET",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(routes).toEqual([
|
||||
{
|
||||
routeId: "zapier",
|
||||
path: "/plugins/webhooks/zapier",
|
||||
sessionKey: "agent:main:main",
|
||||
secret: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "OPENCLAW_WEBHOOK_SECRET",
|
||||
},
|
||||
controllerId: "webhooks/zapier",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps routes whose secret needs runtime resolution", () => {
|
||||
const routes = resolveWebhooksPluginConfig({
|
||||
pluginConfig: {
|
||||
routes: {
|
||||
missing: {
|
||||
sessionKey: "agent:main:main",
|
||||
secret: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "MISSING_SECRET",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(routes).toEqual([
|
||||
{
|
||||
routeId: "missing",
|
||||
path: "/plugins/webhooks/missing",
|
||||
sessionKey: "agent:main:main",
|
||||
secret: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "MISSING_SECRET",
|
||||
},
|
||||
controllerId: "webhooks/missing",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects duplicate normalized paths", () => {
|
||||
expect(() =>
|
||||
resolveWebhooksPluginConfig({
|
||||
pluginConfig: {
|
||||
routes: {
|
||||
first: {
|
||||
path: "/plugins/webhooks/shared",
|
||||
sessionKey: "agent:main:main",
|
||||
secret: "a",
|
||||
},
|
||||
second: {
|
||||
path: "/plugins/webhooks/shared/",
|
||||
sessionKey: "agent:main:other",
|
||||
secret: "b",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toThrow(/conflicts with routes\.first\.path/i);
|
||||
});
|
||||
});
|
||||
74
extensions/webhooks/src/config.ts
Normal file
74
extensions/webhooks/src/config.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
// Webhooks helper module supports config behavior.
|
||||
import { z } from "zod";
|
||||
import { normalizeWebhookPath } from "../runtime-api.js";
|
||||
|
||||
const secretRefSchema = z
|
||||
.object({
|
||||
source: z.enum(["env", "file", "exec"]),
|
||||
provider: z.string().trim().min(1),
|
||||
id: z.string().trim().min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const secretInputSchema = z.union([z.string().trim().min(1), secretRefSchema]);
|
||||
|
||||
const webhookRouteConfigSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional().default(true),
|
||||
path: z.string().trim().min(1).optional(),
|
||||
sessionKey: z.string().trim().min(1),
|
||||
secret: secretInputSchema,
|
||||
controllerId: z.string().trim().min(1).optional(),
|
||||
description: z.string().trim().min(1).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const webhooksPluginConfigSchema = z
|
||||
.object({
|
||||
routes: z.record(z.string().trim().min(1), webhookRouteConfigSchema).default({}),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type WebhookSecretInput = z.infer<typeof secretInputSchema>;
|
||||
|
||||
type ConfiguredWebhookRouteConfig = {
|
||||
routeId: string;
|
||||
path: string;
|
||||
sessionKey: string;
|
||||
secret: WebhookSecretInput;
|
||||
controllerId: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export function resolveWebhooksPluginConfig(params: {
|
||||
pluginConfig: unknown;
|
||||
}): ConfiguredWebhookRouteConfig[] {
|
||||
const parsed = webhooksPluginConfigSchema.parse(params.pluginConfig ?? {});
|
||||
const configuredRoutes: ConfiguredWebhookRouteConfig[] = [];
|
||||
const seenPaths = new Map<string, string>();
|
||||
|
||||
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;
|
||||
}
|
||||
435
extensions/webhooks/src/http.test.ts
Normal file
435
extensions/webhooks/src/http.test.ts
Normal file
@@ -0,0 +1,435 @@
|
||||
// Webhooks tests cover http plugin behavior.
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { createRuntimeTaskFlow } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { createMockServerResponse } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import { createTaskFlowWebhookRequestHandler, type TaskFlowWebhookTarget } from "./http.js";
|
||||
|
||||
type BoundTaskFlow = TaskFlowWebhookTarget["taskFlow"];
|
||||
type ManagedFlow = NonNullable<ReturnType<BoundTaskFlow["createManaged"]>>;
|
||||
|
||||
function createManagedFlow(
|
||||
target: TaskFlowWebhookTarget,
|
||||
params: Parameters<BoundTaskFlow["createManaged"]>[0],
|
||||
): ManagedFlow {
|
||||
const flow = target.taskFlow.createManaged(params);
|
||||
if (!flow) {
|
||||
throw new Error("expected managed TaskFlow creation to succeed");
|
||||
}
|
||||
return flow;
|
||||
}
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const resolveConfiguredSecretInputStringMock = vi.fn();
|
||||
return {
|
||||
resolveConfiguredSecretInputStringMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../runtime-api.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../runtime-api.js")>();
|
||||
hoisted.resolveConfiguredSecretInputStringMock.mockImplementation(
|
||||
actual.resolveConfiguredSecretInputString,
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
resolveConfiguredSecretInputString: hoisted.resolveConfiguredSecretInputStringMock,
|
||||
};
|
||||
});
|
||||
|
||||
type MockIncomingMessage = IncomingMessage & {
|
||||
destroyed?: boolean;
|
||||
destroy: () => MockIncomingMessage;
|
||||
socket: { remoteAddress: string };
|
||||
};
|
||||
|
||||
let nextSessionId = 0;
|
||||
|
||||
function createJsonRequest(params: {
|
||||
path: string;
|
||||
secret?: string;
|
||||
body: unknown;
|
||||
}): MockIncomingMessage {
|
||||
const req = new EventEmitter() as MockIncomingMessage;
|
||||
req.method = "POST";
|
||||
req.url = params.path;
|
||||
req.headers = {
|
||||
"content-type": "application/json",
|
||||
...(params.secret ? { "x-openclaw-webhook-secret": params.secret } : {}),
|
||||
};
|
||||
req.socket = { remoteAddress: "127.0.0.1" } as MockIncomingMessage["socket"];
|
||||
req.destroyed = false;
|
||||
req.destroy = (() => {
|
||||
req.destroyed = true;
|
||||
return req;
|
||||
}) as MockIncomingMessage["destroy"];
|
||||
|
||||
setImmediate(() => {
|
||||
req.emit("data", Buffer.from(JSON.stringify(params.body), "utf8"));
|
||||
req.emit("end");
|
||||
});
|
||||
|
||||
return req;
|
||||
}
|
||||
|
||||
function createHandler(): {
|
||||
handler: ReturnType<typeof createTaskFlowWebhookRequestHandler>;
|
||||
target: TaskFlowWebhookTarget;
|
||||
secret: string;
|
||||
} {
|
||||
const runtime = createRuntimeTaskFlow();
|
||||
nextSessionId += 1;
|
||||
const secret = "shared-secret";
|
||||
const target: TaskFlowWebhookTarget = {
|
||||
routeId: "zapier",
|
||||
path: "/plugins/webhooks/zapier",
|
||||
secretInput: secret,
|
||||
secretConfigPath: "plugins.entries.webhooks.routes.zapier.secret",
|
||||
defaultControllerId: "webhooks/zapier",
|
||||
taskFlow: runtime.bindSession({
|
||||
sessionKey: `agent:main:webhook-test-${String(nextSessionId)}`,
|
||||
}),
|
||||
};
|
||||
const targetsByPath = new Map<string, TaskFlowWebhookTarget[]>([[target.path, [target]]]);
|
||||
return {
|
||||
handler: createTaskFlowWebhookRequestHandler({
|
||||
cfg: {} as OpenClawConfig,
|
||||
targetsByPath,
|
||||
}),
|
||||
target,
|
||||
secret,
|
||||
};
|
||||
}
|
||||
|
||||
function createHandlerWithTarget(
|
||||
target: TaskFlowWebhookTarget,
|
||||
cfg: OpenClawConfig = {} as OpenClawConfig,
|
||||
): ReturnType<typeof createTaskFlowWebhookRequestHandler> {
|
||||
const targetsByPath = new Map<string, TaskFlowWebhookTarget[]>([[target.path, [target]]]);
|
||||
return createTaskFlowWebhookRequestHandler({
|
||||
cfg,
|
||||
targetsByPath,
|
||||
});
|
||||
}
|
||||
|
||||
async function dispatchJsonRequest(params: {
|
||||
handler: ReturnType<typeof createTaskFlowWebhookRequestHandler>;
|
||||
path: string;
|
||||
secret?: string;
|
||||
body: unknown;
|
||||
}) {
|
||||
const req = createJsonRequest({
|
||||
path: params.path,
|
||||
secret: params.secret,
|
||||
body: params.body,
|
||||
});
|
||||
const res = createMockServerResponse();
|
||||
await params.handler(req, res);
|
||||
return res;
|
||||
}
|
||||
|
||||
function parseJsonBody(res: { body?: string | Buffer | null }) {
|
||||
return JSON.parse(String(res.body ?? ""));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("createTaskFlowWebhookRequestHandler", () => {
|
||||
it("rejects requests with the wrong secret", async () => {
|
||||
const { handler, target } = createHandler();
|
||||
const res = await dispatchJsonRequest({
|
||||
handler,
|
||||
path: target.path,
|
||||
secret: "wrong-secret",
|
||||
body: {
|
||||
action: "list_flows",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.body).toBe("unauthorized");
|
||||
expect(target.taskFlow.list()).toStrictEqual([]);
|
||||
expect(hoisted.resolveConfiguredSecretInputStringMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-resolves SecretRef-backed secrets across requests", async () => {
|
||||
const runtime = createRuntimeTaskFlow();
|
||||
const target: TaskFlowWebhookTarget = {
|
||||
routeId: "cached",
|
||||
path: "/plugins/webhooks/cached",
|
||||
secretInput: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "OPENCLAW_WEBHOOK_SECRET",
|
||||
},
|
||||
secretConfigPath: "plugins.entries.webhooks.routes.cached.secret",
|
||||
defaultControllerId: "webhooks/cached",
|
||||
taskFlow: runtime.bindSession({
|
||||
sessionKey: "agent:main:webhook-cached",
|
||||
}),
|
||||
};
|
||||
hoisted.resolveConfiguredSecretInputStringMock
|
||||
.mockResolvedValueOnce({ value: "shared-secret" })
|
||||
.mockResolvedValueOnce({ value: "rotated-secret" })
|
||||
.mockResolvedValueOnce({ value: "rotated-secret" });
|
||||
const handler = createHandlerWithTarget(target);
|
||||
|
||||
const first = await dispatchJsonRequest({
|
||||
handler,
|
||||
path: target.path,
|
||||
secret: "shared-secret",
|
||||
body: {
|
||||
action: "list_flows",
|
||||
},
|
||||
});
|
||||
const second = await dispatchJsonRequest({
|
||||
handler,
|
||||
path: target.path,
|
||||
secret: "shared-secret",
|
||||
body: {
|
||||
action: "list_flows",
|
||||
},
|
||||
});
|
||||
const third = await dispatchJsonRequest({
|
||||
handler,
|
||||
path: target.path,
|
||||
secret: "rotated-secret",
|
||||
body: {
|
||||
action: "list_flows",
|
||||
},
|
||||
});
|
||||
|
||||
expect(first.statusCode).toBe(200);
|
||||
expect(second.statusCode).toBe(401);
|
||||
expect(second.body).toBe("unauthorized");
|
||||
expect(third.statusCode).toBe(200);
|
||||
expect(hoisted.resolveConfiguredSecretInputStringMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("creates flows through the bound session and scrubs owner metadata from responses", async () => {
|
||||
const { handler, target, secret } = createHandler();
|
||||
const res = await dispatchJsonRequest({
|
||||
handler,
|
||||
path: target.path,
|
||||
secret,
|
||||
body: {
|
||||
action: "create_flow",
|
||||
goal: "Review inbound queue",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const parsed = parseJsonBody(res);
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.result.flow.syncMode).toBe("managed");
|
||||
expect(parsed.result.flow.controllerId).toBe("webhooks/zapier");
|
||||
expect(parsed.result.flow.goal).toBe("Review inbound queue");
|
||||
expect(parsed.result.flow.ownerKey).toBeUndefined();
|
||||
expect(parsed.result.flow.requesterOrigin).toBeUndefined();
|
||||
expect(target.taskFlow.get(parsed.result.flow.flowId)?.flowId).toBe(parsed.result.flow.flowId);
|
||||
});
|
||||
|
||||
it("runs child tasks and scrubs task ownership fields from responses", async () => {
|
||||
const { handler, target, secret } = createHandler();
|
||||
const flow = createManagedFlow(target, {
|
||||
controllerId: "webhooks/zapier",
|
||||
goal: "Triage inbox",
|
||||
});
|
||||
const res = await dispatchJsonRequest({
|
||||
handler,
|
||||
path: target.path,
|
||||
secret,
|
||||
body: {
|
||||
action: "run_task",
|
||||
flowId: flow.flowId,
|
||||
runtime: "acp",
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
task: "Inspect the next message batch",
|
||||
status: "running",
|
||||
startedAt: 10,
|
||||
lastEventAt: 10,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const parsed = parseJsonBody(res);
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.result.created).toBe(true);
|
||||
expect(parsed.result.task.parentFlowId).toBe(flow.flowId);
|
||||
expect(parsed.result.task.childSessionKey).toBe("agent:main:subagent:child");
|
||||
expect(parsed.result.task.runtime).toBe("acp");
|
||||
expect(parsed.result.task.ownerKey).toBeUndefined();
|
||||
expect(parsed.result.task.requesterSessionKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns 404 for missing flow mutations", async () => {
|
||||
const { handler, target, secret } = createHandler();
|
||||
const res = await dispatchJsonRequest({
|
||||
handler,
|
||||
path: target.path,
|
||||
secret,
|
||||
body: {
|
||||
action: "set_waiting",
|
||||
flowId: "flow-missing",
|
||||
expectedRevision: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
const parsed = parseJsonBody(res);
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(parsed.code).toBe("not_found");
|
||||
expect(parsed.error).toBe("TaskFlow not found.");
|
||||
expect(parsed.result.applied).toBe(false);
|
||||
expect(parsed.result.code).toBe("not_found");
|
||||
});
|
||||
|
||||
it("returns 409 for revision conflicts", async () => {
|
||||
const { handler, target, secret } = createHandler();
|
||||
const flow = createManagedFlow(target, {
|
||||
controllerId: "webhooks/zapier",
|
||||
goal: "Review inbox",
|
||||
});
|
||||
const res = await dispatchJsonRequest({
|
||||
handler,
|
||||
path: target.path,
|
||||
secret,
|
||||
body: {
|
||||
action: "set_waiting",
|
||||
flowId: flow.flowId,
|
||||
expectedRevision: flow.revision + 1,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(409);
|
||||
const parsed = parseJsonBody(res);
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(parsed.code).toBe("revision_conflict");
|
||||
expect(parsed.result.applied).toBe(false);
|
||||
expect(parsed.result.code).toBe("revision_conflict");
|
||||
expect(parsed.result.current.flowId).toBe(flow.flowId);
|
||||
expect(parsed.result.current.revision).toBe(flow.revision);
|
||||
});
|
||||
|
||||
it("rejects internal runtimes and running-only metadata from external callers", async () => {
|
||||
const { handler, target, secret } = createHandler();
|
||||
const flow = createManagedFlow(target, {
|
||||
controllerId: "webhooks/zapier",
|
||||
goal: "Review inbox",
|
||||
});
|
||||
|
||||
const runtimeRes = await dispatchJsonRequest({
|
||||
handler,
|
||||
path: target.path,
|
||||
secret,
|
||||
body: {
|
||||
action: "run_task",
|
||||
flowId: flow.flowId,
|
||||
runtime: "cli",
|
||||
task: "Inspect queue",
|
||||
},
|
||||
});
|
||||
expect(runtimeRes.statusCode).toBe(400);
|
||||
const runtimeParsed = parseJsonBody(runtimeRes);
|
||||
expect(runtimeParsed.ok).toBe(false);
|
||||
expect(runtimeParsed.code).toBe("invalid_request");
|
||||
|
||||
const queuedMetadataRes = await dispatchJsonRequest({
|
||||
handler,
|
||||
path: target.path,
|
||||
secret,
|
||||
body: {
|
||||
action: "run_task",
|
||||
flowId: flow.flowId,
|
||||
runtime: "acp",
|
||||
task: "Inspect queue",
|
||||
startedAt: 10,
|
||||
},
|
||||
});
|
||||
expect(queuedMetadataRes.statusCode).toBe(400);
|
||||
const queuedMetadataParsed = parseJsonBody(queuedMetadataRes);
|
||||
expect(queuedMetadataParsed.ok).toBe(false);
|
||||
expect(queuedMetadataParsed.code).toBe("invalid_request");
|
||||
expect(queuedMetadataParsed.error).toBe(
|
||||
"status: status must be running when startedAt, lastEventAt, or progressSummary is provided",
|
||||
);
|
||||
});
|
||||
|
||||
it("reuses the same task record when retried with the same runId", async () => {
|
||||
const { handler, target, secret } = createHandler();
|
||||
const flow = createManagedFlow(target, {
|
||||
controllerId: "webhooks/zapier",
|
||||
goal: "Triage inbox",
|
||||
});
|
||||
|
||||
const first = await dispatchJsonRequest({
|
||||
handler,
|
||||
path: target.path,
|
||||
secret,
|
||||
body: {
|
||||
action: "run_task",
|
||||
flowId: flow.flowId,
|
||||
runtime: "acp",
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
runId: "retry-me",
|
||||
task: "Inspect the next message batch",
|
||||
},
|
||||
});
|
||||
const second = await dispatchJsonRequest({
|
||||
handler,
|
||||
path: target.path,
|
||||
secret,
|
||||
body: {
|
||||
action: "run_task",
|
||||
flowId: flow.flowId,
|
||||
runtime: "acp",
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
runId: "retry-me",
|
||||
task: "Inspect the next message batch",
|
||||
},
|
||||
});
|
||||
|
||||
expect(first.statusCode).toBe(200);
|
||||
expect(second.statusCode).toBe(200);
|
||||
const firstParsed = parseJsonBody(first);
|
||||
const secondParsed = parseJsonBody(second);
|
||||
expect(firstParsed.result.task.taskId).toBe(secondParsed.result.task.taskId);
|
||||
expect(target.taskFlow.getTaskSummary(flow.flowId)?.total).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 409 when cancellation targets a terminal flow", async () => {
|
||||
const { handler, target, secret } = createHandler();
|
||||
const flow = createManagedFlow(target, {
|
||||
controllerId: "webhooks/zapier",
|
||||
goal: "Review inbox",
|
||||
});
|
||||
const finished = target.taskFlow.finish({
|
||||
flowId: flow.flowId,
|
||||
expectedRevision: flow.revision,
|
||||
});
|
||||
expect(finished.applied).toBe(true);
|
||||
|
||||
const res = await dispatchJsonRequest({
|
||||
handler,
|
||||
path: target.path,
|
||||
secret,
|
||||
body: {
|
||||
action: "cancel_flow",
|
||||
flowId: flow.flowId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(409);
|
||||
const parsed = parseJsonBody(res);
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(parsed.code).toBe("terminal");
|
||||
expect(parsed.error).toBe("Flow is already succeeded.");
|
||||
expect(parsed.result.found).toBe(true);
|
||||
expect(parsed.result.cancelled).toBe(false);
|
||||
expect(parsed.result.reason).toBe("Flow is already succeeded.");
|
||||
});
|
||||
});
|
||||
833
extensions/webhooks/src/http.ts
Normal file
833
extensions/webhooks/src/http.ts
Normal file
@@ -0,0 +1,833 @@
|
||||
// Webhooks plugin module implements http behavior.
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { z } from "zod";
|
||||
import type { PluginRuntime } from "../api.js";
|
||||
import {
|
||||
createFixedWindowRateLimiter,
|
||||
createWebhookInFlightLimiter,
|
||||
readJsonWebhookBodyOrReject,
|
||||
resolveRequestClientIp,
|
||||
resolveConfiguredSecretInputString,
|
||||
resolveWebhookTargetWithAuthOrReject,
|
||||
withResolvedWebhookRequestPipeline,
|
||||
WEBHOOK_IN_FLIGHT_DEFAULTS,
|
||||
WEBHOOK_RATE_LIMIT_DEFAULTS,
|
||||
type OpenClawConfig,
|
||||
type WebhookInFlightLimiter,
|
||||
} from "../runtime-api.js";
|
||||
import type { WebhookSecretInput } from "./config.js";
|
||||
|
||||
type BoundTaskFlowRuntime = ReturnType<PluginRuntime["tasks"]["managedFlows"]["bindSession"]>;
|
||||
|
||||
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
|
||||
|
||||
const jsonValueSchema: z.ZodType<JsonValue> = z.lazy(() =>
|
||||
z.union([
|
||||
z.null(),
|
||||
z.boolean(),
|
||||
z.number().finite(),
|
||||
z.string(),
|
||||
z.array(jsonValueSchema),
|
||||
z.record(z.string(), jsonValueSchema),
|
||||
]),
|
||||
);
|
||||
|
||||
const nullableStringSchema = z.string().trim().min(1).nullable().optional();
|
||||
|
||||
const createFlowRequestSchema = z
|
||||
.object({
|
||||
action: z.literal("create_flow"),
|
||||
controllerId: z.string().trim().min(1).optional(),
|
||||
goal: z.string().trim().min(1),
|
||||
status: z.enum(["queued", "running", "waiting", "blocked"]).optional(),
|
||||
notifyPolicy: z.enum(["done_only", "state_changes", "silent"]).optional(),
|
||||
currentStep: nullableStringSchema,
|
||||
stateJson: jsonValueSchema.nullable().optional(),
|
||||
waitJson: jsonValueSchema.nullable().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const getFlowRequestSchema = z
|
||||
.object({ action: z.literal("get_flow"), flowId: z.string().trim().min(1) })
|
||||
.strict();
|
||||
const listFlowsRequestSchema = z.object({ action: z.literal("list_flows") }).strict();
|
||||
const findLatestFlowRequestSchema = z.object({ action: z.literal("find_latest_flow") }).strict();
|
||||
const resolveFlowRequestSchema = z
|
||||
.object({ action: z.literal("resolve_flow"), token: z.string().trim().min(1) })
|
||||
.strict();
|
||||
const getTaskSummaryRequestSchema = z
|
||||
.object({ action: z.literal("get_task_summary"), flowId: z.string().trim().min(1) })
|
||||
.strict();
|
||||
|
||||
const setWaitingRequestSchema = z
|
||||
.object({
|
||||
action: z.literal("set_waiting"),
|
||||
flowId: z.string().trim().min(1),
|
||||
expectedRevision: z.number().int().nonnegative(),
|
||||
currentStep: nullableStringSchema,
|
||||
stateJson: jsonValueSchema.nullable().optional(),
|
||||
waitJson: jsonValueSchema.nullable().optional(),
|
||||
blockedTaskId: nullableStringSchema,
|
||||
blockedSummary: nullableStringSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const resumeFlowRequestSchema = z
|
||||
.object({
|
||||
action: z.literal("resume_flow"),
|
||||
flowId: z.string().trim().min(1),
|
||||
expectedRevision: z.number().int().nonnegative(),
|
||||
status: z.enum(["queued", "running"]).optional(),
|
||||
currentStep: nullableStringSchema,
|
||||
stateJson: jsonValueSchema.nullable().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const finishFlowRequestSchema = z
|
||||
.object({
|
||||
action: z.literal("finish_flow"),
|
||||
flowId: z.string().trim().min(1),
|
||||
expectedRevision: z.number().int().nonnegative(),
|
||||
stateJson: jsonValueSchema.nullable().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const failFlowRequestSchema = z
|
||||
.object({
|
||||
action: z.literal("fail_flow"),
|
||||
flowId: z.string().trim().min(1),
|
||||
expectedRevision: z.number().int().nonnegative(),
|
||||
stateJson: jsonValueSchema.nullable().optional(),
|
||||
blockedTaskId: nullableStringSchema,
|
||||
blockedSummary: nullableStringSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const requestCancelRequestSchema = z
|
||||
.object({
|
||||
action: z.literal("request_cancel"),
|
||||
flowId: z.string().trim().min(1),
|
||||
expectedRevision: z.number().int().nonnegative(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const cancelFlowRequestSchema = z
|
||||
.object({
|
||||
action: z.literal("cancel_flow"),
|
||||
flowId: z.string().trim().min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const runTaskRequestSchema = z
|
||||
.object({
|
||||
action: z.literal("run_task"),
|
||||
flowId: z.string().trim().min(1),
|
||||
runtime: z.enum(["subagent", "acp"]),
|
||||
sourceId: z.string().trim().min(1).optional(),
|
||||
childSessionKey: z.string().trim().min(1).optional(),
|
||||
parentTaskId: z.string().trim().min(1).optional(),
|
||||
agentId: z.string().trim().min(1).optional(),
|
||||
runId: z.string().trim().min(1).optional(),
|
||||
label: z.string().trim().min(1).optional(),
|
||||
task: z.string().trim().min(1),
|
||||
preferMetadata: z.boolean().optional(),
|
||||
notifyPolicy: z.enum(["done_only", "state_changes", "silent"]).optional(),
|
||||
status: z.enum(["queued", "running"]).optional(),
|
||||
startedAt: z.number().int().nonnegative().optional(),
|
||||
lastEventAt: z.number().int().nonnegative().optional(),
|
||||
progressSummary: nullableStringSchema,
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
if (
|
||||
value.status !== "running" &&
|
||||
(value.startedAt !== undefined ||
|
||||
value.lastEventAt !== undefined ||
|
||||
value.progressSummary !== undefined)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
"status must be running when startedAt, lastEventAt, or progressSummary is provided",
|
||||
path: ["status"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const webhookActionSchema = z.discriminatedUnion("action", [
|
||||
createFlowRequestSchema,
|
||||
getFlowRequestSchema,
|
||||
listFlowsRequestSchema,
|
||||
findLatestFlowRequestSchema,
|
||||
resolveFlowRequestSchema,
|
||||
getTaskSummaryRequestSchema,
|
||||
setWaitingRequestSchema,
|
||||
resumeFlowRequestSchema,
|
||||
finishFlowRequestSchema,
|
||||
failFlowRequestSchema,
|
||||
requestCancelRequestSchema,
|
||||
cancelFlowRequestSchema,
|
||||
runTaskRequestSchema,
|
||||
]);
|
||||
|
||||
type WebhookAction = z.infer<typeof webhookActionSchema>;
|
||||
|
||||
export type TaskFlowWebhookTarget = {
|
||||
routeId: string;
|
||||
path: string;
|
||||
secretInput: WebhookSecretInput;
|
||||
secretConfigPath: string;
|
||||
defaultControllerId: string;
|
||||
taskFlow: BoundTaskFlowRuntime;
|
||||
};
|
||||
|
||||
type FlowView = {
|
||||
flowId: string;
|
||||
syncMode: "task_mirrored" | "managed";
|
||||
controllerId?: string;
|
||||
revision: number;
|
||||
status: string;
|
||||
notifyPolicy: string;
|
||||
goal: string;
|
||||
currentStep?: string;
|
||||
blockedTaskId?: string;
|
||||
blockedSummary?: string;
|
||||
stateJson?: JsonValue;
|
||||
waitJson?: JsonValue;
|
||||
cancelRequestedAt?: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
endedAt?: number;
|
||||
};
|
||||
|
||||
type TaskView = {
|
||||
taskId: string;
|
||||
runtime: string;
|
||||
sourceId?: string;
|
||||
scopeKind: string;
|
||||
childSessionKey?: string;
|
||||
parentFlowId?: string;
|
||||
parentTaskId?: string;
|
||||
agentId?: string;
|
||||
runId?: string;
|
||||
label?: string;
|
||||
task: string;
|
||||
status: string;
|
||||
deliveryStatus: string;
|
||||
notifyPolicy: string;
|
||||
createdAt: number;
|
||||
startedAt?: number;
|
||||
endedAt?: number;
|
||||
lastEventAt?: number;
|
||||
cleanupAfter?: number;
|
||||
error?: string;
|
||||
progressSummary?: string;
|
||||
terminalSummary?: string;
|
||||
terminalOutcome?: string;
|
||||
};
|
||||
|
||||
function pickOptionalFields<T extends object, TKey extends keyof T & string>(
|
||||
source: T,
|
||||
keys: readonly TKey[],
|
||||
): Partial<Pick<T, TKey>> {
|
||||
const result: Partial<Pick<T, TKey>> = {};
|
||||
for (const key of keys) {
|
||||
const value = source[key];
|
||||
if (value !== undefined) {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function pickOptionalTruthyStringFields<T extends object, TKey extends keyof T & string>(
|
||||
source: T,
|
||||
keys: readonly TKey[],
|
||||
): Partial<Pick<T, TKey>> {
|
||||
const result: Partial<Pick<T, TKey>> = {};
|
||||
for (const key of keys) {
|
||||
const value = source[key];
|
||||
if (typeof value === "string" && value) {
|
||||
result[key] = value as T[TKey];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function toFlowView(flow: FlowView): FlowView {
|
||||
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: TaskView): TaskView {
|
||||
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: ServerResponse, statusCode: number, body: unknown): void {
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function extractSharedSecret(req: IncomingMessage): string {
|
||||
const authHeader = Array.isArray(req.headers.authorization)
|
||||
? (req.headers.authorization[0] ?? "")
|
||||
: (req.headers.authorization ?? "");
|
||||
if (normalizeLowercaseStringOrEmpty(authHeader).startsWith("bearer ")) {
|
||||
return authHeader.slice("bearer ".length).trim();
|
||||
}
|
||||
const sharedHeader = req.headers["x-openclaw-webhook-secret"];
|
||||
return Array.isArray(sharedHeader) ? (sharedHeader[0] ?? "").trim() : (sharedHeader ?? "").trim();
|
||||
}
|
||||
|
||||
function formatZodError(error: z.ZodError): string {
|
||||
const firstIssue = error.issues[0];
|
||||
if (!firstIssue) {
|
||||
return "invalid request";
|
||||
}
|
||||
const path = firstIssue.path.length > 0 ? `${firstIssue.path.join(".")}: ` : "";
|
||||
return `${path}${firstIssue.message}`;
|
||||
}
|
||||
|
||||
function mapMutationResult(
|
||||
result:
|
||||
| {
|
||||
applied: true;
|
||||
flow: FlowView;
|
||||
}
|
||||
| {
|
||||
applied: false;
|
||||
code: string;
|
||||
current?: FlowView;
|
||||
},
|
||||
): unknown {
|
||||
return result;
|
||||
}
|
||||
|
||||
function mapFlowMutationResult(
|
||||
result:
|
||||
| {
|
||||
applied: true;
|
||||
flow: Parameters<typeof toFlowView>[0];
|
||||
}
|
||||
| {
|
||||
applied: false;
|
||||
code: string;
|
||||
current?: Parameters<typeof toFlowView>[0];
|
||||
},
|
||||
): unknown {
|
||||
return mapMutationResult(
|
||||
result.applied
|
||||
? { applied: true, flow: toFlowView(result.flow) }
|
||||
: {
|
||||
applied: false,
|
||||
code: result.code,
|
||||
...(result.current ? { current: toFlowView(result.current) } : {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function mapMutationStatus(result: {
|
||||
applied: boolean;
|
||||
code?: "not_found" | "not_managed" | "revision_conflict" | "persist_failed";
|
||||
}): { statusCode: number; code?: string; error?: string } {
|
||||
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: { created: boolean; code?: "persist_failed" }): {
|
||||
statusCode: number;
|
||||
code?: string;
|
||||
error?: string;
|
||||
} {
|
||||
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: { created: boolean; found: boolean; reason?: string }): {
|
||||
statusCode: number;
|
||||
code?: string;
|
||||
error?: string;
|
||||
} {
|
||||
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: { found: boolean; cancelled: boolean; reason?: string }): {
|
||||
statusCode: number;
|
||||
code?: string;
|
||||
error?: string;
|
||||
} {
|
||||
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: { action: WebhookAction; result: unknown }): {
|
||||
statusCode: number;
|
||||
code?: string;
|
||||
error?: string;
|
||||
} {
|
||||
switch (params.action.action) {
|
||||
case "create_flow":
|
||||
return mapCreateFlowStatus(
|
||||
params.result as {
|
||||
created: boolean;
|
||||
code?: "persist_failed";
|
||||
},
|
||||
);
|
||||
case "set_waiting":
|
||||
case "resume_flow":
|
||||
case "finish_flow":
|
||||
case "fail_flow":
|
||||
case "request_cancel":
|
||||
return mapMutationStatus(
|
||||
params.result as {
|
||||
applied: boolean;
|
||||
code?: "not_found" | "not_managed" | "revision_conflict" | "persist_failed";
|
||||
},
|
||||
);
|
||||
case "cancel_flow":
|
||||
return mapCancelStatus(
|
||||
params.result as {
|
||||
found: boolean;
|
||||
cancelled: boolean;
|
||||
reason?: string;
|
||||
},
|
||||
);
|
||||
case "run_task":
|
||||
return mapRunTaskStatus(
|
||||
params.result as {
|
||||
created: boolean;
|
||||
found: boolean;
|
||||
reason?: string;
|
||||
},
|
||||
);
|
||||
default:
|
||||
return { statusCode: 200 };
|
||||
}
|
||||
}
|
||||
|
||||
async function executeWebhookAction(params: {
|
||||
action: WebhookAction;
|
||||
target: TaskFlowWebhookTarget;
|
||||
cfg: OpenClawConfig;
|
||||
}): Promise<unknown> {
|
||||
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 ?? undefined,
|
||||
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": {
|
||||
const result = target.taskFlow.setWaiting({
|
||||
flowId: action.flowId,
|
||||
expectedRevision: action.expectedRevision,
|
||||
currentStep: action.currentStep,
|
||||
stateJson: action.stateJson,
|
||||
waitJson: action.waitJson,
|
||||
blockedTaskId: action.blockedTaskId,
|
||||
blockedSummary: action.blockedSummary,
|
||||
});
|
||||
return mapFlowMutationResult(result);
|
||||
}
|
||||
case "resume_flow": {
|
||||
const result = target.taskFlow.resume({
|
||||
flowId: action.flowId,
|
||||
expectedRevision: action.expectedRevision,
|
||||
status: action.status,
|
||||
currentStep: action.currentStep,
|
||||
stateJson: action.stateJson,
|
||||
});
|
||||
return mapFlowMutationResult(result);
|
||||
}
|
||||
case "finish_flow": {
|
||||
const result = target.taskFlow.finish({
|
||||
flowId: action.flowId,
|
||||
expectedRevision: action.expectedRevision,
|
||||
stateJson: action.stateJson,
|
||||
});
|
||||
return mapFlowMutationResult(result);
|
||||
}
|
||||
case "fail_flow": {
|
||||
const result = target.taskFlow.fail({
|
||||
flowId: action.flowId,
|
||||
expectedRevision: action.expectedRevision,
|
||||
stateJson: action.stateJson,
|
||||
blockedTaskId: action.blockedTaskId,
|
||||
blockedSummary: action.blockedSummary,
|
||||
});
|
||||
return mapFlowMutationResult(result);
|
||||
}
|
||||
case "request_cancel": {
|
||||
const result = target.taskFlow.requestCancel({
|
||||
flowId: action.flowId,
|
||||
expectedRevision: action.expectedRevision,
|
||||
});
|
||||
return mapFlowMutationResult(result);
|
||||
}
|
||||
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");
|
||||
}
|
||||
|
||||
export function createTaskFlowWebhookRequestHandler(params: {
|
||||
cfg: OpenClawConfig;
|
||||
targetsByPath: Map<string, TaskFlowWebhookTarget[]>;
|
||||
inFlightLimiter?: WebhookInFlightLimiter;
|
||||
}): (req: IncomingMessage, res: ServerResponse) => Promise<boolean> {
|
||||
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: TaskFlowWebhookTarget,
|
||||
): Promise<string | undefined> => {
|
||||
if (typeof target.secretInput === "string") {
|
||||
return target.secretInput;
|
||||
}
|
||||
const resolved = await resolveConfiguredSecretInputString({
|
||||
config: params.cfg,
|
||||
env: process.env,
|
||||
value: target.secretInput,
|
||||
path: target.secretConfigPath,
|
||||
});
|
||||
return resolved.value;
|
||||
};
|
||||
|
||||
return async (req: IncomingMessage, res: ServerResponse): Promise<boolean> => {
|
||||
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 && safeEqualSecret(resolvedSecret, presentedSecret));
|
||||
},
|
||||
});
|
||||
if (!target) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const body = await readJsonWebhookBodyOrReject({
|
||||
req,
|
||||
res,
|
||||
maxBytes: 256 * 1024,
|
||||
timeoutMs: 15_000,
|
||||
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;
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
16
extensions/webhooks/tsconfig.json
Normal file
16
extensions/webhooks/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user