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:
Advait Paliwal
2026-02-16 02:36:00 -08:00
committed by GitHub
parent d841c9b26b
commit bc67af6ad8
33 changed files with 698 additions and 236 deletions

View File

@@ -443,4 +443,61 @@ describe("cron tool", () => {
};
expect(call?.params?.delivery).toEqual({ mode: "none" });
});
it("does not infer announce delivery when mode is webhook", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createCronTool({ agentSessionKey: "agent:main:discord:dm:buddy" });
await tool.execute("call-webhook-explicit", {
action: "add",
job: {
name: "reminder",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "agentTurn", message: "hello" },
delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" },
},
});
const call = callGatewayMock.mock.calls[0]?.[0] as {
params?: { delivery?: { mode?: string; channel?: string; to?: string } };
};
expect(call?.params?.delivery).toEqual({
mode: "webhook",
to: "https://example.invalid/cron-finished",
});
});
it("fails fast when webhook mode is missing delivery.to", async () => {
const tool = createCronTool({ agentSessionKey: "agent:main:discord:dm:buddy" });
await expect(
tool.execute("call-webhook-missing", {
action: "add",
job: {
name: "reminder",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "agentTurn", message: "hello" },
delivery: { mode: "webhook" },
},
}),
).rejects.toThrow('delivery.mode="webhook" requires delivery.to to be a valid http(s) URL');
expect(callGatewayMock).toHaveBeenCalledTimes(0);
});
it("fails fast when webhook mode uses a non-http URL", async () => {
const tool = createCronTool({ agentSessionKey: "agent:main:discord:dm:buddy" });
await expect(
tool.execute("call-webhook-invalid", {
action: "add",
job: {
name: "reminder",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "agentTurn", message: "hello" },
delivery: { mode: "webhook", to: "ftp://example.invalid/cron-finished" },
},
}),
).rejects.toThrow('delivery.mode="webhook" requires delivery.to to be a valid http(s) URL');
expect(callGatewayMock).toHaveBeenCalledTimes(0);
});
});

View File

@@ -2,6 +2,7 @@ import { Type } from "@sinclair/typebox";
import type { CronDelivery, CronMessageChannel } from "../../cron/types.js";
import { loadConfig } from "../../config/config.js";
import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js";
import { normalizeHttpWebhookUrl } from "../../cron/webhook-url.js";
import { parseAgentSessionKey } from "../../sessions/session-key-utils.js";
import { extractTextFromChatContent } from "../../shared/chat-content.js";
import { isRecord, truncateUtf16Safe } from "../../utils.js";
@@ -217,10 +218,9 @@ JOB SCHEMA (for add action):
"name": "string (optional)",
"schedule": { ... }, // Required: when to run
"payload": { ... }, // Required: what to execute
"delivery": { ... }, // Optional: announce summary (isolated only)
"delivery": { ... }, // Optional: announce summary or webhook POST
"sessionTarget": "main" | "isolated", // Required
"enabled": true | false, // Optional, default true
"notify": true | false // Optional webhook opt-in; set true for user-facing reminders
"enabled": true | false // Optional, default true
}
SCHEDULE TYPES (schedule.kind):
@@ -239,15 +239,17 @@ PAYLOAD TYPES (payload.kind):
- "agentTurn": Runs agent with message (isolated sessions only)
{ "kind": "agentTurn", "message": "<prompt>", "model": "<optional>", "thinking": "<optional>", "timeoutSeconds": <optional> }
DELIVERY (isolated-only, top-level):
{ "mode": "none|announce", "channel": "<optional>", "to": "<optional>", "bestEffort": <optional-bool> }
DELIVERY (top-level):
{ "mode": "none|announce|webhook", "channel": "<optional>", "to": "<optional>", "bestEffort": <optional-bool> }
- Default for isolated agentTurn jobs (when delivery omitted): "announce"
- If the task needs to send to a specific chat/recipient, set delivery.channel/to here; do not call messaging tools inside the run.
- announce: send to chat channel (optional channel/to target)
- webhook: send finished-run event as HTTP POST to delivery.to (URL required)
- If the task needs to send to a specific chat/recipient, set announce delivery.channel/to; do not call messaging tools inside the run.
CRITICAL CONSTRAINTS:
- sessionTarget="main" REQUIRES payload.kind="systemEvent"
- sessionTarget="isolated" REQUIRES payload.kind="agentTurn"
- For reminders users should be notified about, set notify=true.
- For webhook callbacks, use delivery.mode="webhook" with delivery.to set to a URL.
Default: prefer isolated agentTurn jobs unless the user explicitly wants a main-session system event.
WAKE MODES (for wake action):
@@ -294,7 +296,6 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con
"payload",
"delivery",
"enabled",
"notify",
"description",
"deleteAfterRun",
"agentId",
@@ -352,11 +353,25 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con
const delivery = isRecord(deliveryValue) ? deliveryValue : undefined;
const modeRaw = typeof delivery?.mode === "string" ? delivery.mode : "";
const mode = modeRaw.trim().toLowerCase();
if (mode === "webhook") {
const webhookUrl = normalizeHttpWebhookUrl(delivery?.to);
if (!webhookUrl) {
throw new Error(
'delivery.mode="webhook" requires delivery.to to be a valid http(s) URL',
);
}
if (delivery) {
delivery.to = webhookUrl;
}
}
const hasTarget =
(typeof delivery?.channel === "string" && delivery.channel.trim()) ||
(typeof delivery?.to === "string" && delivery.to.trim());
const shouldInfer =
(deliveryValue == null || delivery) && mode !== "none" && !hasTarget;
(deliveryValue == null || delivery) &&
(mode === "" || mode === "announce") &&
!hasTarget;
if (shouldInfer) {
const inferred = inferDeliveryFromSessionKey(opts.agentSessionKey);
if (inferred) {

View File

@@ -154,11 +154,11 @@ describe("gateway.tools config", () => {
});
describe("cron webhook schema", () => {
it("accepts cron.webhook and cron.webhookToken", () => {
it("accepts cron.webhookToken and legacy cron.webhook", () => {
const res = OpenClawSchema.safeParse({
cron: {
enabled: true,
webhook: "https://example.invalid/cron",
webhook: "https://example.invalid/legacy-cron-webhook",
webhookToken: "secret-token",
},
});
@@ -166,10 +166,10 @@ describe("cron webhook schema", () => {
expect(res.success).toBe(true);
});
it("rejects non-http(s) cron.webhook URLs", () => {
it("rejects non-http cron.webhook URLs", () => {
const res = OpenClawSchema.safeParse({
cron: {
webhook: "ftp://example.invalid/cron",
webhook: "ftp://example.invalid/legacy-cron-webhook",
},
});

View File

@@ -2,7 +2,12 @@ export type CronConfig = {
enabled?: boolean;
store?: string;
maxConcurrentRuns?: number;
/**
* Deprecated legacy fallback webhook URL used only for stored jobs with notify=true.
* Prefer per-job delivery.mode="webhook" with delivery.to.
*/
webhook?: string;
/** Bearer token for cron webhook POST delivery. */
webhookToken?: string;
/**
* How long to retain completed cron run sessions before automatic pruning.

View File

@@ -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"];

View File

@@ -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");
});
});

View File

@@ -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",

View File

@@ -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",

View File

@@ -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;

View File

@@ -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();
}

View File

@@ -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" });
});
});

View File

@@ -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) {

View File

@@ -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
View 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;
}
}

View File

@@ -67,24 +67,51 @@ export const CronPayloadPatchSchema = Type.Union([
cronAgentTurnPayloadSchema({ message: Type.Optional(NonEmptyString) }),
]);
const CronDeliveryBaseProperties = {
const CronDeliverySharedProperties = {
channel: Type.Optional(Type.Union([Type.Literal("last"), NonEmptyString])),
to: Type.Optional(Type.String()),
bestEffort: Type.Optional(Type.Boolean()),
};
export const CronDeliverySchema = Type.Object(
const CronDeliveryNoopSchema = Type.Object(
{
mode: Type.Union([Type.Literal("none"), Type.Literal("announce")]),
...CronDeliveryBaseProperties,
mode: Type.Literal("none"),
...CronDeliverySharedProperties,
to: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);
const CronDeliveryAnnounceSchema = Type.Object(
{
mode: Type.Literal("announce"),
...CronDeliverySharedProperties,
to: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);
const CronDeliveryWebhookSchema = Type.Object(
{
mode: Type.Literal("webhook"),
...CronDeliverySharedProperties,
to: NonEmptyString,
},
{ additionalProperties: false },
);
export const CronDeliverySchema = Type.Union([
CronDeliveryNoopSchema,
CronDeliveryAnnounceSchema,
CronDeliveryWebhookSchema,
]);
export const CronDeliveryPatchSchema = Type.Object(
{
mode: Type.Optional(Type.Union([Type.Literal("none"), Type.Literal("announce")])),
...CronDeliveryBaseProperties,
mode: Type.Optional(
Type.Union([Type.Literal("none"), Type.Literal("announce"), Type.Literal("webhook")]),
),
...CronDeliverySharedProperties,
to: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);
@@ -111,7 +138,6 @@ export const CronJobSchema = Type.Object(
name: NonEmptyString,
description: Type.Optional(Type.String()),
enabled: Type.Boolean(),
notify: Type.Optional(Type.Boolean()),
deleteAfterRun: Type.Optional(Type.Boolean()),
createdAtMs: Type.Integer({ minimum: 0 }),
updatedAtMs: Type.Integer({ minimum: 0 }),
@@ -140,7 +166,6 @@ export const CronAddParamsSchema = Type.Object(
agentId: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
description: Type.Optional(Type.String()),
enabled: Type.Optional(Type.Boolean()),
notify: Type.Optional(Type.Boolean()),
deleteAfterRun: Type.Optional(Type.Boolean()),
schedule: CronScheduleSchema,
sessionTarget: Type.Union([Type.Literal("main"), Type.Literal("isolated")]),
@@ -157,7 +182,6 @@ export const CronJobPatchSchema = Type.Object(
agentId: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
description: Type.Optional(Type.String()),
enabled: Type.Optional(Type.Boolean()),
notify: Type.Optional(Type.Boolean()),
deleteAfterRun: Type.Optional(Type.Boolean()),
schedule: Type.Optional(CronScheduleSchema),
sessionTarget: Type.Optional(Type.Union([Type.Literal("main"), Type.Literal("isolated")])),

View File

@@ -7,6 +7,7 @@ import { runCronIsolatedAgentTurn } from "../cron/isolated-agent.js";
import { appendCronRunLog, resolveCronRunLogPath } from "../cron/run-log.js";
import { CronService } from "../cron/service.js";
import { resolveCronStorePath } from "../cron/store.js";
import { normalizeHttpWebhookUrl } from "../cron/webhook-url.js";
import { runHeartbeatOnce } from "../infra/heartbeat-runner.js";
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
@@ -31,6 +32,32 @@ function redactWebhookUrl(url: string): string {
}
}
type CronWebhookTarget = {
url: string;
source: "delivery" | "legacy";
};
function resolveCronWebhookTarget(params: {
delivery?: { mode?: string; to?: string };
legacyNotify?: boolean;
legacyWebhook?: string;
}): CronWebhookTarget | null {
const mode = params.delivery?.mode?.trim().toLowerCase();
if (mode === "webhook") {
const url = normalizeHttpWebhookUrl(params.delivery?.to);
return url ? { url, source: "delivery" } : null;
}
if (params.legacyNotify) {
const legacyUrl = normalizeHttpWebhookUrl(params.legacyWebhook);
if (legacyUrl) {
return { url: legacyUrl, source: "legacy" };
}
}
return null;
}
export function buildGatewayCronService(params: {
cfg: ReturnType<typeof loadConfig>;
deps: CliDeps;
@@ -61,6 +88,7 @@ export function buildGatewayCronService(params: {
agentId: agentId ?? defaultAgentId,
});
const sessionStorePath = resolveSessionStorePath(defaultAgentId);
const warnedLegacyWebhookJobs = new Set<string>();
const cron = new CronService({
storePath,
@@ -104,10 +132,41 @@ export function buildGatewayCronService(params: {
onEvent: (evt) => {
params.broadcast("cron", evt, { dropIfSlow: true });
if (evt.action === "finished") {
const webhookUrl = params.cfg.cron?.webhook?.trim();
const webhookToken = params.cfg.cron?.webhookToken?.trim();
const legacyWebhook = params.cfg.cron?.webhook?.trim();
const job = cron.getJob(evt.jobId);
if (webhookUrl && evt.summary && job?.notify === true) {
const legacyNotify = (job as { notify?: unknown } | undefined)?.notify === true;
const webhookTarget = resolveCronWebhookTarget({
delivery:
job?.delivery && typeof job.delivery.mode === "string"
? { mode: job.delivery.mode, to: job.delivery.to }
: undefined,
legacyNotify,
legacyWebhook,
});
if (!webhookTarget && job?.delivery?.mode === "webhook") {
cronLogger.warn(
{
jobId: evt.jobId,
deliveryTo: job.delivery.to,
},
"cron: skipped webhook delivery, delivery.to must be a valid http(s) URL",
);
}
if (webhookTarget?.source === "legacy" && !warnedLegacyWebhookJobs.has(evt.jobId)) {
warnedLegacyWebhookJobs.add(evt.jobId);
cronLogger.warn(
{
jobId: evt.jobId,
legacyWebhook: redactWebhookUrl(webhookTarget.url),
},
"cron: deprecated notify+cron.webhook fallback in use, migrate to delivery.mode=webhook with delivery.to",
);
}
if (webhookTarget && evt.summary) {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
@@ -118,7 +177,7 @@ export function buildGatewayCronService(params: {
const timeout = setTimeout(() => {
abortController.abort();
}, CRON_WEBHOOK_TIMEOUT_MS);
void fetch(webhookUrl, {
void fetch(webhookTarget.url, {
method: "POST",
headers,
body: JSON.stringify(evt),
@@ -129,7 +188,7 @@ export function buildGatewayCronService(params: {
{
err: String(err),
jobId: evt.jobId,
webhookUrl: redactWebhookUrl(webhookUrl),
webhookUrl: redactWebhookUrl(webhookTarget.url),
},
"cron: webhook delivery failed",
);

View File

@@ -83,11 +83,11 @@ describe("gateway server cron", () => {
const addRes = await rpcReq(ws, "cron.add", {
name: "daily",
enabled: true,
notify: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "hello" },
delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" },
});
expect(addRes.ok).toBe(true);
expect(typeof (addRes.payload as { id?: unknown } | null)?.id).toBe("string");
@@ -101,8 +101,8 @@ describe("gateway server cron", () => {
expect((jobs as unknown[]).length).toBe(1);
expect(((jobs as Array<{ name?: unknown }>)[0]?.name as string) ?? "").toBe("daily");
expect(
((jobs as Array<{ notify?: unknown }>)[0]?.notify as boolean | undefined) ?? false,
).toBe(true);
((jobs as Array<{ delivery?: { mode?: unknown } }>)[0]?.delivery?.mode as string) ?? "",
).toBe("webhook");
const routeAtMs = Date.now() - 1;
const routeRes = await rpcReq(ws, "cron.add", {
@@ -423,14 +423,31 @@ describe("gateway server cron", () => {
}
}, 45_000);
test("posts webhooks only when notify is true and summary exists", async () => {
test("posts webhooks for delivery mode and legacy notify fallback only when summary exists", async () => {
const prevSkipCron = process.env.OPENCLAW_SKIP_CRON;
process.env.OPENCLAW_SKIP_CRON = "0";
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-cron-webhook-"));
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
testState.cronEnabled = false;
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
const legacyNotifyJob = {
id: "legacy-notify-job",
name: "legacy notify job",
enabled: true,
notify: true,
createdAtMs: Date.now(),
updatedAtMs: Date.now(),
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "legacy webhook" },
state: {},
};
await fs.writeFile(
testState.cronStorePath,
JSON.stringify({ version: 1, jobs: [legacyNotifyJob] }),
);
const configPath = process.env.OPENCLAW_CONFIG_PATH;
expect(typeof configPath).toBe("string");
@@ -440,7 +457,7 @@ describe("gateway server cron", () => {
JSON.stringify(
{
cron: {
webhook: "https://example.invalid/cron-finished",
webhook: "https://legacy.example.invalid/cron-finished",
webhookToken: "cron-webhook-token",
},
},
@@ -457,14 +474,25 @@ describe("gateway server cron", () => {
await connectOk(ws);
try {
const notifyRes = await rpcReq(ws, "cron.add", {
name: "notify true",
const invalidWebhookRes = await rpcReq(ws, "cron.add", {
name: "invalid webhook",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "invalid" },
delivery: { mode: "webhook", to: "ftp://example.invalid/cron-finished" },
});
expect(invalidWebhookRes.ok).toBe(false);
const notifyRes = await rpcReq(ws, "cron.add", {
name: "webhook enabled",
enabled: true,
notify: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "send webhook" },
delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" },
});
expect(notifyRes.ok).toBe(true);
const notifyJobIdValue = (notifyRes.payload as { id?: unknown } | null)?.id;
@@ -491,10 +519,32 @@ describe("gateway server cron", () => {
expect(notifyBody.action).toBe("finished");
expect(notifyBody.jobId).toBe(notifyJobId);
const legacyRunRes = await rpcReq(
ws,
"cron.run",
{ id: "legacy-notify-job", mode: "force" },
20_000,
);
expect(legacyRunRes.ok).toBe(true);
await waitForCondition(() => fetchMock.mock.calls.length === 2, 5000);
const [legacyUrl, legacyInit] = fetchMock.mock.calls[1] as [
string,
{
method?: string;
headers?: Record<string, string>;
body?: string;
},
];
expect(legacyUrl).toBe("https://legacy.example.invalid/cron-finished");
expect(legacyInit.method).toBe("POST");
expect(legacyInit.headers?.Authorization).toBe("Bearer cron-webhook-token");
const legacyBody = JSON.parse(legacyInit.body ?? "{}");
expect(legacyBody.action).toBe("finished");
expect(legacyBody.jobId).toBe("legacy-notify-job");
const silentRes = await rpcReq(ws, "cron.add", {
name: "notify false",
name: "webhook disabled",
enabled: true,
notify: false,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
@@ -509,17 +559,17 @@ describe("gateway server cron", () => {
expect(silentRunRes.ok).toBe(true);
await yieldToEventLoop();
await yieldToEventLoop();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledTimes(2);
cronIsolatedRun.mockResolvedValueOnce({ status: "ok" });
const noSummaryRes = await rpcReq(ws, "cron.add", {
name: "notify no summary",
name: "webhook no summary",
enabled: true,
notify: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: { kind: "agentTurn", message: "test" },
delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" },
});
expect(noSummaryRes.ok).toBe(true);
const noSummaryJobIdValue = (noSummaryRes.payload as { id?: unknown } | null)?.id;
@@ -535,7 +585,7 @@ describe("gateway server cron", () => {
expect(noSummaryRunRes.ok).toBe(true);
await yieldToEventLoop();
await yieldToEventLoop();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledTimes(2);
} finally {
ws.close();
await server.close();