mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 20:58:26 +00:00
cron: separate webhook POST delivery from announce (#17901)
* cron: split webhook delivery from announce mode * cron: validate webhook delivery target * cron: remove legacy webhook fallback config * fix: finalize cron webhook delivery prep (#17901) (thanks @advaitpaliwal) --------- Co-authored-by: Tyler Yust <TYTYYUST@YAHOO.COM>
This commit is contained in:
@@ -5,16 +5,28 @@ import { MACOS_APP_SOURCES_DIR } from "../compat/legacy-names.js";
|
||||
import { CronDeliverySchema } from "../gateway/protocol/schema.js";
|
||||
|
||||
type SchemaLike = {
|
||||
anyOf?: Array<{ properties?: Record<string, unknown>; const?: unknown }>;
|
||||
anyOf?: Array<SchemaLike>;
|
||||
properties?: Record<string, unknown>;
|
||||
const?: unknown;
|
||||
};
|
||||
|
||||
function extractDeliveryModes(schema: SchemaLike): string[] {
|
||||
const modeSchema = schema.properties?.mode as SchemaLike | undefined;
|
||||
return (modeSchema?.anyOf ?? [])
|
||||
const directModes = (modeSchema?.anyOf ?? [])
|
||||
.map((entry) => entry?.const)
|
||||
.filter((value): value is string => typeof value === "string");
|
||||
if (directModes.length > 0) {
|
||||
return directModes;
|
||||
}
|
||||
|
||||
const unionModes = (schema.anyOf ?? [])
|
||||
.map((entry) => {
|
||||
const mode = entry.properties?.mode as SchemaLike | undefined;
|
||||
return mode?.const;
|
||||
})
|
||||
.filter((value): value is string => typeof value === "string");
|
||||
|
||||
return Array.from(new Set(unionModes));
|
||||
}
|
||||
|
||||
const UI_FILES = ["ui/src/ui/types.ts", "ui/src/ui/ui-types.ts", "ui/src/ui/views/cron.ts"];
|
||||
|
||||
@@ -42,4 +42,16 @@ describe("resolveCronDeliveryPlan", () => {
|
||||
expect(plan.mode).toBe("none");
|
||||
expect(plan.requested).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves webhook mode without channel routing", () => {
|
||||
const plan = resolveCronDeliveryPlan(
|
||||
makeJob({
|
||||
delivery: { mode: "webhook", to: "https://example.invalid/cron" },
|
||||
}),
|
||||
);
|
||||
expect(plan.mode).toBe("webhook");
|
||||
expect(plan.requested).toBe(false);
|
||||
expect(plan.channel).toBeUndefined();
|
||||
expect(plan.to).toBe("https://example.invalid/cron");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { CronDeliveryMode, CronJob, CronMessageChannel } from "./types.js";
|
||||
|
||||
export type CronDeliveryPlan = {
|
||||
mode: CronDeliveryMode;
|
||||
channel: CronMessageChannel;
|
||||
channel?: CronMessageChannel;
|
||||
to?: string;
|
||||
source: "delivery" | "payload";
|
||||
requested: boolean;
|
||||
@@ -36,11 +36,13 @@ export function resolveCronDeliveryPlan(job: CronJob): CronDeliveryPlan {
|
||||
const mode =
|
||||
normalizedMode === "announce"
|
||||
? "announce"
|
||||
: normalizedMode === "none"
|
||||
? "none"
|
||||
: normalizedMode === "deliver"
|
||||
? "announce"
|
||||
: undefined;
|
||||
: normalizedMode === "webhook"
|
||||
? "webhook"
|
||||
: normalizedMode === "none"
|
||||
? "none"
|
||||
: normalizedMode === "deliver"
|
||||
? "announce"
|
||||
: undefined;
|
||||
|
||||
const payloadChannel = normalizeChannel(payload?.channel);
|
||||
const payloadTo = normalizeTo(payload?.to);
|
||||
@@ -55,7 +57,7 @@ export function resolveCronDeliveryPlan(job: CronJob): CronDeliveryPlan {
|
||||
const resolvedMode = mode ?? "announce";
|
||||
return {
|
||||
mode: resolvedMode,
|
||||
channel,
|
||||
channel: resolvedMode === "announce" ? channel : undefined,
|
||||
to,
|
||||
source: "delivery",
|
||||
requested: resolvedMode === "announce",
|
||||
|
||||
@@ -163,6 +163,25 @@ describe("normalizeCronJobCreate", () => {
|
||||
expect(delivery.to).toBe("7200373102");
|
||||
});
|
||||
|
||||
it("normalizes webhook delivery mode and target URL", () => {
|
||||
const normalized = normalizeCronJobCreate({
|
||||
name: "webhook delivery",
|
||||
enabled: true,
|
||||
schedule: { kind: "every", everyMs: 60_000 },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "systemEvent", text: "hello" },
|
||||
delivery: {
|
||||
mode: " WeBhOoK ",
|
||||
to: " https://example.invalid/cron ",
|
||||
},
|
||||
}) as unknown as Record<string, unknown>;
|
||||
|
||||
const delivery = normalized.delivery as Record<string, unknown>;
|
||||
expect(delivery.mode).toBe("webhook");
|
||||
expect(delivery.to).toBe("https://example.invalid/cron");
|
||||
});
|
||||
|
||||
it("defaults isolated agentTurn delivery to announce", () => {
|
||||
const normalized = normalizeCronJobCreate({
|
||||
name: "default-announce",
|
||||
|
||||
@@ -151,7 +151,7 @@ function coerceDelivery(delivery: UnknownRecord) {
|
||||
const mode = delivery.mode.trim().toLowerCase();
|
||||
if (mode === "deliver") {
|
||||
next.mode = "announce";
|
||||
} else if (mode === "announce" || mode === "none") {
|
||||
} else if (mode === "announce" || mode === "none" || mode === "webhook") {
|
||||
next.mode = mode;
|
||||
} else {
|
||||
delete next.mode;
|
||||
|
||||
@@ -44,42 +44,25 @@ describe("CronService.getJob", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves notify on create for true, false, and omitted", async () => {
|
||||
it("preserves webhook delivery on create", async () => {
|
||||
const { storePath } = await makeStorePath();
|
||||
const cron = createCronService(storePath);
|
||||
await cron.start();
|
||||
|
||||
try {
|
||||
const notifyTrue = await cron.add({
|
||||
name: "notify-true",
|
||||
enabled: true,
|
||||
notify: true,
|
||||
schedule: { kind: "every", everyMs: 60_000 },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: { kind: "systemEvent", text: "ping" },
|
||||
});
|
||||
const notifyFalse = await cron.add({
|
||||
name: "notify-false",
|
||||
enabled: true,
|
||||
notify: false,
|
||||
schedule: { kind: "every", everyMs: 60_000 },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: { kind: "systemEvent", text: "ping" },
|
||||
});
|
||||
const notifyOmitted = await cron.add({
|
||||
name: "notify-omitted",
|
||||
const webhookJob = await cron.add({
|
||||
name: "webhook-job",
|
||||
enabled: true,
|
||||
schedule: { kind: "every", everyMs: 60_000 },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: { kind: "systemEvent", text: "ping" },
|
||||
delivery: { mode: "webhook", to: "https://example.invalid/cron" },
|
||||
});
|
||||
expect(cron.getJob(webhookJob.id)?.delivery).toEqual({
|
||||
mode: "webhook",
|
||||
to: "https://example.invalid/cron",
|
||||
});
|
||||
|
||||
expect(cron.getJob(notifyTrue.id)?.notify).toBe(true);
|
||||
expect(cron.getJob(notifyFalse.id)?.notify).toBe(false);
|
||||
expect(cron.getJob(notifyOmitted.id)?.notify).toBeUndefined();
|
||||
} finally {
|
||||
cron.stop();
|
||||
}
|
||||
|
||||
@@ -30,6 +30,32 @@ describe("applyJobPatch", () => {
|
||||
expect(job.delivery).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps webhook delivery when switching to main session", () => {
|
||||
const now = Date.now();
|
||||
const job: CronJob = {
|
||||
id: "job-webhook",
|
||||
name: "job-webhook",
|
||||
enabled: true,
|
||||
createdAtMs: now,
|
||||
updatedAtMs: now,
|
||||
schedule: { kind: "every", everyMs: 60_000 },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "agentTurn", message: "do it" },
|
||||
delivery: { mode: "webhook", to: "https://example.invalid/cron" },
|
||||
state: {},
|
||||
};
|
||||
|
||||
const patch: CronJobPatch = {
|
||||
sessionTarget: "main",
|
||||
payload: { kind: "systemEvent", text: "ping" },
|
||||
};
|
||||
|
||||
expect(() => applyJobPatch(job, patch)).not.toThrow();
|
||||
expect(job.sessionTarget).toBe("main");
|
||||
expect(job.delivery).toEqual({ mode: "webhook", to: "https://example.invalid/cron" });
|
||||
});
|
||||
|
||||
it("maps legacy payload delivery updates onto delivery", () => {
|
||||
const now = Date.now();
|
||||
const job: CronJob = {
|
||||
@@ -101,23 +127,55 @@ describe("applyJobPatch", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("updates notify via patch", () => {
|
||||
it("rejects webhook delivery without a valid http(s) target URL", () => {
|
||||
const now = Date.now();
|
||||
const job: CronJob = {
|
||||
id: "job-4",
|
||||
name: "job-4",
|
||||
id: "job-webhook-invalid",
|
||||
name: "job-webhook-invalid",
|
||||
enabled: true,
|
||||
notify: false,
|
||||
createdAtMs: now,
|
||||
updatedAtMs: now,
|
||||
schedule: { kind: "every", everyMs: 60_000 },
|
||||
sessionTarget: "isolated",
|
||||
sessionTarget: "main",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "agentTurn", message: "do it" },
|
||||
payload: { kind: "systemEvent", text: "ping" },
|
||||
delivery: { mode: "webhook" },
|
||||
state: {},
|
||||
};
|
||||
|
||||
expect(() => applyJobPatch(job, { notify: true })).not.toThrow();
|
||||
expect(job.notify).toBe(true);
|
||||
expect(() => applyJobPatch(job, { enabled: true })).toThrow(
|
||||
"cron webhook delivery requires delivery.to to be a valid http(s) URL",
|
||||
);
|
||||
expect(() => applyJobPatch(job, { delivery: { mode: "webhook", to: "" } })).toThrow(
|
||||
"cron webhook delivery requires delivery.to to be a valid http(s) URL",
|
||||
);
|
||||
expect(() =>
|
||||
applyJobPatch(job, { delivery: { mode: "webhook", to: "ftp://example.invalid" } }),
|
||||
).toThrow("cron webhook delivery requires delivery.to to be a valid http(s) URL");
|
||||
expect(() => applyJobPatch(job, { delivery: { mode: "webhook", to: "not-a-url" } })).toThrow(
|
||||
"cron webhook delivery requires delivery.to to be a valid http(s) URL",
|
||||
);
|
||||
});
|
||||
|
||||
it("trims webhook delivery target URLs", () => {
|
||||
const now = Date.now();
|
||||
const job: CronJob = {
|
||||
id: "job-webhook-trim",
|
||||
name: "job-webhook-trim",
|
||||
enabled: true,
|
||||
createdAtMs: now,
|
||||
updatedAtMs: now,
|
||||
schedule: { kind: "every", everyMs: 60_000 },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "systemEvent", text: "ping" },
|
||||
delivery: { mode: "webhook", to: "https://example.invalid/original" },
|
||||
state: {},
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
applyJobPatch(job, { delivery: { mode: "webhook", to: " https://example.invalid/trim " } }),
|
||||
).not.toThrow();
|
||||
expect(job.delivery).toEqual({ mode: "webhook", to: "https://example.invalid/trim" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
import type { CronServiceState } from "./state.js";
|
||||
import { parseAbsoluteTimeMs } from "../parse.js";
|
||||
import { computeNextRunAtMs } from "../schedule.js";
|
||||
import { normalizeHttpWebhookUrl } from "../webhook-url.js";
|
||||
import {
|
||||
normalizeOptionalAgentId,
|
||||
normalizeOptionalText,
|
||||
@@ -41,8 +42,19 @@ export function assertSupportedJobSpec(job: Pick<CronJob, "sessionTarget" | "pay
|
||||
}
|
||||
|
||||
function assertDeliverySupport(job: Pick<CronJob, "sessionTarget" | "delivery">) {
|
||||
if (job.delivery && job.sessionTarget !== "isolated") {
|
||||
throw new Error('cron delivery config is only supported for sessionTarget="isolated"');
|
||||
if (!job.delivery) {
|
||||
return;
|
||||
}
|
||||
if (job.delivery.mode === "webhook") {
|
||||
const target = normalizeHttpWebhookUrl(job.delivery.to);
|
||||
if (!target) {
|
||||
throw new Error("cron webhook delivery requires delivery.to to be a valid http(s) URL");
|
||||
}
|
||||
job.delivery.to = target;
|
||||
return;
|
||||
}
|
||||
if (job.sessionTarget !== "isolated") {
|
||||
throw new Error('cron channel delivery config is only supported for sessionTarget="isolated"');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,7 +270,6 @@ export function createJob(state: CronServiceState, input: CronJobCreate): CronJo
|
||||
name: normalizeRequiredName(input.name),
|
||||
description: normalizeOptionalText(input.description),
|
||||
enabled,
|
||||
notify: typeof input.notify === "boolean" ? input.notify : undefined,
|
||||
deleteAfterRun,
|
||||
createdAtMs: now,
|
||||
updatedAtMs: now,
|
||||
@@ -287,9 +298,6 @@ export function applyJobPatch(job: CronJob, patch: CronJobPatch) {
|
||||
if (typeof patch.enabled === "boolean") {
|
||||
job.enabled = patch.enabled;
|
||||
}
|
||||
if (typeof patch.notify === "boolean") {
|
||||
job.notify = patch.notify;
|
||||
}
|
||||
if (typeof patch.deleteAfterRun === "boolean") {
|
||||
job.deleteAfterRun = patch.deleteAfterRun;
|
||||
}
|
||||
@@ -319,7 +327,7 @@ export function applyJobPatch(job: CronJob, patch: CronJobPatch) {
|
||||
if (patch.delivery) {
|
||||
job.delivery = mergeCronDelivery(job.delivery, patch.delivery);
|
||||
}
|
||||
if (job.sessionTarget === "main" && job.delivery) {
|
||||
if (job.sessionTarget === "main" && job.delivery?.mode !== "webhook") {
|
||||
job.delivery = undefined;
|
||||
}
|
||||
if (patch.state) {
|
||||
|
||||
@@ -10,7 +10,7 @@ export type CronWakeMode = "next-heartbeat" | "now";
|
||||
|
||||
export type CronMessageChannel = ChannelId | "last";
|
||||
|
||||
export type CronDeliveryMode = "none" | "announce";
|
||||
export type CronDeliveryMode = "none" | "announce" | "webhook";
|
||||
|
||||
export type CronDelivery = {
|
||||
mode: CronDeliveryMode;
|
||||
@@ -71,7 +71,6 @@ export type CronJob = {
|
||||
name: string;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
notify?: boolean;
|
||||
deleteAfterRun?: boolean;
|
||||
createdAtMs: number;
|
||||
updatedAtMs: number;
|
||||
|
||||
22
src/cron/webhook-url.ts
Normal file
22
src/cron/webhook-url.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
function isAllowedWebhookProtocol(protocol: string) {
|
||||
return protocol === "http:" || protocol === "https:";
|
||||
}
|
||||
|
||||
export function normalizeHttpWebhookUrl(value: unknown): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (!isAllowedWebhookProtocol(parsed.protocol)) {
|
||||
return null;
|
||||
}
|
||||
return trimmed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user