fix(cron): validate failureAlert channel at the gateway boundary (#103866)

* fix(cron): validate failureAlert channel at the gateway boundary

`--failure-alert-channel` writes patch.failureAlert.channel, a field distinct from
delivery (own store columns, own delivery path in service/failure-alerts.ts). The
gateway validator only ran channel checks for delivery patches, so a
failure-alert-only patch stored an unknown channel unvalidated and only failed
later at delivery time with channel_not_found.

Add assertValidCronFailureAlert (reusing the announce-channel validation), wired
into create + update. It mirrors resolveFailureAlert() runtime resolution:
resolves the effective mode (job or global cron failure-alert mode; webhook alerts
skip channel-type validation), inherits the job delivery channel/target when the
alert names none (rejecting a routing-changing edit that would activate a
legacy-invalid inherited channel, while unrelated/threshold edits and already-
active alerts are not blocked), resolves a provider-prefixed target like the
delivery/failureDestination paths, and honors the channel:null last-fallback. Maps
failureAlert.channel errors to INVALID_REQUEST. Regression tests included.

Closes #103864

* fix(cron): align failure alert route validation

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Nikhil Patel
2026-07-22 10:54:41 +05:30
committed by GitHub
parent 7014d72752
commit 13716ad4f4
4 changed files with 644 additions and 12 deletions
+63 -3
View File
@@ -6,7 +6,7 @@ import {
validateTargetProviderPrefix,
} from "../infra/outbound/channel-target-prefix.js";
import { isDeliverableMessageChannel, normalizeMessageChannel } from "../utils/message-channel.js";
import type { CronDelivery, CronJobCreate } from "./types.js";
import type { CronDelivery, CronFailureAlert, CronJobCreate } from "./types.js";
function hasExplicitChannelConfigEntry(cfg: OpenClawConfig): boolean {
const channels = cfg.channels;
@@ -26,7 +26,7 @@ function hasExplicitChannelConfigEntry(cfg: OpenClawConfig): boolean {
async function assertConfiguredAnnounceChannel(params: {
cfg: OpenClawConfig;
channel?: string;
field: "delivery.channel" | "delivery.failureDestination.channel";
field: "delivery.channel" | "delivery.failureDestination.channel" | "failureAlert.channel";
}) {
if (params.channel === "last") {
return;
@@ -67,7 +67,7 @@ function resolveAnnounceValidationChannel(params: {
function assertCompatibleAnnounceTarget(params: {
channel?: string;
to?: string;
field: "delivery.channel" | "delivery.failureDestination.channel";
field: "delivery.channel" | "delivery.failureDestination.channel" | "failureAlert.channel";
}) {
if (!params.channel || params.channel === "last") {
return;
@@ -118,6 +118,66 @@ export async function assertValidCronAnnounceDelivery(params: {
}
}
/**
* Validates the per-job `failureAlert` channel the same way announce delivery is
* validated. `failureAlert` is a distinct field from `delivery.failureDestination`
* (its own store columns and delivery path), so it needs its own check - otherwise
* an explicit unknown channel (e.g. a Slack `C0...` id passed to
* `--failure-alert-channel`) is stored and only fails later as `channel_not_found`.
*/
export async function assertValidCronFailureAlert(params: {
cfg: OpenClawConfig;
failureAlert?: CronFailureAlert | false;
delivery?: CronDelivery;
}) {
const failureAlert = params.failureAlert;
const globalFailureAlert = params.cfg.cron?.failureAlert;
// `false` disables alerts. An unset job alert still inherits an enabled global
// alert, so validate its effective route rather than allowing it to bypass the
// same channel checks as an explicit per-job alert.
if (failureAlert === false || (!failureAlert && globalFailureAlert?.enabled !== true)) {
return;
}
// Only announce alerts route through a channel type; webhook alerts POST to
// `to`. Resolve the effective mode exactly as runtime does in
// resolveFailureAlert(): a job that omits `mode` inherits the global cron
// failure-alert mode, so validating with a hard "announce" default would
// wrongly reject a channel that a globally webhook-mode alert never uses.
const effectiveMode = failureAlert?.mode ?? globalFailureAlert?.mode;
if (effectiveMode === "webhook") {
return;
}
// Mirror resolveFailureAlert(): the alert inherits the job delivery channel and
// `to`, then the final send channel is resolved from that effective (channel,
// to) pair - a provider prefix in `to` only wins when the effective channel is
// unset/"last". Inheriting even when the alert names no route of its own means a
// routing-changing edit (e.g. flipping mode to announce) that activates a
// legacy-invalid inherited delivery channel is rejected up front rather than
// only when the alert fires.
const effectiveChannel = failureAlert?.channel ?? params.delivery?.channel;
const effectiveTo = failureAlert?.to ?? params.delivery?.to;
const resolvedChannel =
resolveAnnounceValidationChannel({
channel: effectiveChannel,
to: effectiveTo,
}) ?? "last";
assertCompatibleAnnounceTarget({
channel: effectiveChannel,
to: effectiveTo,
field: "failureAlert.channel",
});
await assertConfiguredAnnounceChannel({
cfg: params.cfg,
channel: resolvedChannel,
field: "failureAlert.channel",
});
}
export async function assertValidCronCreateDelivery(cfg: OpenClawConfig, job: CronJobCreate) {
await assertValidCronAnnounceDelivery({ cfg, delivery: job.delivery });
await assertValidCronFailureAlert({
cfg,
failureAlert: job.failureAlert,
delivery: job.delivery,
});
}
@@ -22,6 +22,7 @@ export function isCronInvalidRequestError(err: unknown): boolean {
message.includes("cron webhook delivery requires") ||
message.includes("delivery.channel") ||
message.includes("delivery.failureDestination.channel") ||
message.includes("failureAlert.channel") ||
message.includes("cron completion destination webhook requires") ||
message.includes("cron failure destination webhook requires") ||
message.includes("cron channel delivery config is only supported") ||
+75 -9
View File
@@ -19,6 +19,7 @@ import { resolveCronJobConfigRevision } from "../../cron/config-revision.js";
import {
assertValidCronAnnounceDelivery,
assertValidCronCreateDelivery,
assertValidCronFailureAlert,
} from "../../cron/delivery-channel-validation.js";
import { resolveCronDeliveryPreviews } from "../../cron/delivery-preview.js";
import { assertCronDeliveryInputNonBlankFields } from "../../cron/delivery-target-validation.js";
@@ -160,18 +161,83 @@ async function assertValidCronUpdatePatch(params: {
) {
assertCronDoesNotTargetAgentHarness(nextJob);
}
// Clearing a concrete channel (channel: null) while keeping a bare announce `to`
// intentionally falls back to "last" in multi-channel configs. Use the same
// adjusted delivery for both the delivery check and the inherited-alert check so
// an alert that inherits the route is judged identically to the delivery itself.
const effectiveDelivery =
params.patch.delivery?.channel === null &&
nextJob.delivery &&
(nextJob.delivery.mode ?? "announce") === "announce" &&
nextJob.delivery.channel === undefined &&
resolveTargetPrefixedChannel(nextJob.delivery.to) === undefined
? { ...nextJob.delivery, channel: "last" as const }
: nextJob.delivery;
if ("delivery" in params.patch) {
const delivery =
params.patch.delivery?.channel === null &&
nextJob.delivery &&
(nextJob.delivery.mode ?? "announce") === "announce" &&
nextJob.delivery.channel === undefined &&
resolveTargetPrefixedChannel(nextJob.delivery.to) === undefined
? { ...nextJob.delivery, channel: "last" as const }
: nextJob.delivery;
await assertValidCronAnnounceDelivery({
cfg: params.cfg,
delivery,
delivery: effectiveDelivery,
});
}
// failureAlert is a separate field from delivery, so a failureAlert-only patch
// skips the delivery check above. Validate when this edit touches a field that
// can change the announce channel routing: the alert's own channel/target/mode,
// or delivery itself (an alert without its own channel/target inherits the job
// delivery channel, so a delivery change can invalidate it). Editing unrelated
// alert fields (after/cooldown/includeSkipped) must not be blocked by a channel
// stored before this validation existed. The merged value carries the effective
// mode, and the validator no-ops for alerts that only inherit delivery.
const failureAlertPatch = params.patch.failureAlert;
const failureAlertRoutingPatched =
failureAlertPatch &&
("channel" in failureAlertPatch || "to" in failureAlertPatch || "mode" in failureAlertPatch);
// Enabling a previously OFF alert makes it start inheriting the job delivery
// route, so validate even when the enabling patch (`--failure-alert`,
// `--failure-alert-after`) carries no routing key of its own. An alert is
// already ON - so an object-only edit only changes threshold/cooldown - when it
// has per-job config or when global `cron.failureAlert.enabled` is true;
// resolveFailureAlert() treats those as active, so re-validating their inherited
// route would block unrelated edits on a legacy channel that already delivers.
const globalAlertsEnabled = params.cfg.cron?.failureAlert?.enabled === true;
const currentAlertActive =
params.currentJob.failureAlert !== false &&
(params.currentJob.failureAlert !== undefined || globalAlertsEnabled);
const nextAlertActive =
nextJob.failureAlert !== false && (nextJob.failureAlert !== undefined || globalAlertsEnabled);
const alertNewlyEnabled = !currentAlertActive && nextAlertActive;
// A delivery change only affects the alert when the alert inherits the changed
// delivery field (its own channel/to is unset). Gating on that avoids blocking
// unrelated delivery edits (bestEffort, failureDestination) on jobs that carry
// a stale explicit alert channel. A delivery `mode` change is included because
// switching to/from webhook clears the inherited channel/target in
// mergeCronDelivery, which can make an inheriting alert ambiguous.
const deliveryPatch = params.patch.delivery;
const mergedAlert = nextJob.failureAlert;
const alertUsesInheritedChannel = !mergedAlert || mergedAlert.channel === undefined;
const alertUsesInheritedTarget = !mergedAlert || mergedAlert.to === undefined;
const deliveryAffectsInheritedAlert =
deliveryPatch &&
nextAlertActive &&
(("channel" in deliveryPatch && alertUsesInheritedChannel) ||
("to" in deliveryPatch && alertUsesInheritedTarget) ||
("mode" in deliveryPatch && alertUsesInheritedChannel));
const currentAlertRoutingOverride =
params.currentJob.failureAlert &&
(params.currentJob.failureAlert.channel !== undefined ||
params.currentJob.failureAlert.to !== undefined ||
params.currentJob.failureAlert.mode !== undefined);
const alertResetToGlobal =
failureAlertPatch === null && nextAlertActive && currentAlertRoutingOverride;
if (
failureAlertRoutingPatched ||
alertNewlyEnabled ||
deliveryAffectsInheritedAlert ||
alertResetToGlobal
) {
await assertValidCronFailureAlert({
cfg: params.cfg,
failureAlert: nextJob.failureAlert,
delivery: effectiveDelivery,
});
}
}
@@ -1260,6 +1260,34 @@ describe("cron method validation", () => {
expectResponseError(respond, { messageIncludes: "delivery.channel" });
});
it("classifies a failureAlert validation error from the locked cron.update snapshot as INVALID_REQUEST", async () => {
const currentJob = createCronJob();
const context = createCronContext(currentJob);
context.cron.updateWithPrecondition.mockImplementationOnce(
async (_id, _patch, precondition) => {
await precondition(createCronJob(), Date.now());
return currentJob;
},
);
const { respond } = await invokeCron(
"cron.update",
{
id: "cron-1",
// Provider mismatch fails alert validation without a configured-channel
// dependency, so the locked-snapshot revalidation error must still map to
// INVALID_REQUEST via the cron error classifier, not an internal error.
patch: { failureAlert: { channel: "discord", to: "telegram:123" } },
},
{ context },
);
expect(context.cron.update).not.toHaveBeenCalled();
expectResponseError(respond, {
code: "INVALID_REQUEST",
messageIncludes: "failureAlert.channel",
});
});
it("projects declaration metadata and existing run and delivery state in compact lists", async () => {
const job = createCronJob({
declarationKey: "daily-report",
@@ -1707,6 +1735,483 @@ describe("cron method validation", () => {
});
});
it("rejects an unknown failureAlert channel on cron.update before the mutation (#103864)", async () => {
setRuntimeConfig(telegramSlackConfig());
const { context, respond } = await invokeCronUpdate(
{ id: "cron-1", patch: { failureAlert: { channel: "C0EXAMPLE01" } } },
createCronJob(),
);
// Regression: --failure-alert-channel writes patch.failureAlert (not delivery),
// so it must be validated even though the patch has no delivery key.
expect(context.cron.update).not.toHaveBeenCalled();
expectResponseError(respond, {
code: "INVALID_REQUEST",
messageIncludes: "failureAlert.channel",
});
});
it("accepts a configured failureAlert channel on cron.update", async () => {
setRuntimeConfig(telegramSlackConfig());
const { context, respond } = await invokeCronUpdate(
{ id: "cron-1", patch: { failureAlert: { channel: "slack" } } },
createCronJob(),
);
expect(context.cron.update).toHaveBeenCalled();
expectCronSuccess(respond);
});
it("does not channel-type-validate a webhook-mode failureAlert on cron.update", async () => {
setRuntimeConfig(telegramSlackConfig());
const { context, respond } = await invokeCronUpdate(
{
id: "cron-1",
patch: {
// A channel is set, but webhook mode POSTs to `to`, so the channel type
// is not validated even though it is not a known channel.
failureAlert: {
mode: "webhook",
channel: "C0EXAMPLE01",
to: "https://example.invalid/hook",
},
},
},
createCronJob(),
);
expect(context.cron.update).toHaveBeenCalled();
expectCronSuccess(respond);
});
it("does not block an unrelated failureAlert edit on a job with a pre-existing invalid channel", async () => {
setRuntimeConfig(telegramSlackConfig());
// Editing --failure-alert-after must not re-validate a channel stored before
// this validation existed; the patch carries no channel key.
const { context, respond } = await invokeCronUpdate(
{ id: "cron-1", patch: { failureAlert: { after: 3 } } },
createCronJob({ failureAlert: { channel: "c0example01", mode: "announce" } }),
);
expect(context.cron.update).toHaveBeenCalled();
expectCronSuccess(respond);
});
it("accepts correcting a pre-existing invalid failureAlert channel to a configured one", async () => {
setRuntimeConfig(telegramSlackConfig());
const { context, respond } = await invokeCronUpdate(
{ id: "cron-1", patch: { failureAlert: { channel: "slack" } } },
createCronJob({ failureAlert: { channel: "c0example01", mode: "announce" } }),
);
expect(context.cron.update).toHaveBeenCalled();
expectCronSuccess(respond);
});
it("does not validate an inherited-webhook failureAlert channel on cron.update (global mode)", async () => {
// Job omits mode and inherits the global webhook mode, so runtime never uses
// the channel; validation must not reject it (matches resolveFailureAlert).
setRuntimeConfig({
...telegramSlackConfig(),
cron: { failureAlert: { enabled: true, mode: "webhook" } },
} as OpenClawConfig);
const { context, respond } = await invokeCronUpdate(
{
id: "cron-1",
patch: { failureAlert: { channel: "C0EXAMPLE01", to: "https://example.invalid/hook" } },
},
createCronJob(),
);
expect(context.cron.update).toHaveBeenCalled();
expectCronSuccess(respond);
});
it("does not validate an inherited-webhook failureAlert channel on cron.add (global mode)", async () => {
setRuntimeConfig({
...slackConfig(),
cron: { failureAlert: { enabled: true, mode: "webhook" } },
} as OpenClawConfig);
const { context, respond } = await invokeCronAdd(
agentTurnCronParams({
name: "inherited webhook alert",
failureAlert: { channel: "C0EXAMPLE01", to: "https://example.invalid/hook" },
}),
);
expect(context.cron.add).toHaveBeenCalled();
expectCronSuccess(respond);
});
it("still validates the failureAlert channel when the job sets announce mode over a global webhook default", async () => {
// Job mode wins over the global default, so an explicit announce alert with an
// unknown channel is still rejected even when global mode is webhook.
setRuntimeConfig({
...telegramSlackConfig(),
cron: { failureAlert: { enabled: true, mode: "webhook" } },
} as OpenClawConfig);
const { context, respond } = await invokeCronUpdate(
{ id: "cron-1", patch: { failureAlert: { mode: "announce", channel: "C0EXAMPLE01" } } },
createCronJob(),
);
expect(context.cron.update).not.toHaveBeenCalled();
expectResponseError(respond, {
code: "INVALID_REQUEST",
messageIncludes: "failureAlert.channel",
});
});
it("validates a mode-only flip to announce that makes a stored channel live", async () => {
// Storing a channel under webhook mode is allowed (unused). Flipping to
// announce activates it, so a mode-only patch must re-validate the channel.
setRuntimeConfig(telegramSlackConfig());
const { context, respond } = await invokeCronUpdate(
{ id: "cron-1", patch: { failureAlert: { mode: "announce" } } },
createCronJob({ failureAlert: { channel: "c0example01", mode: "webhook" } }),
);
expect(context.cron.update).not.toHaveBeenCalled();
expectResponseError(respond, {
code: "INVALID_REQUEST",
messageIncludes: "failureAlert.channel",
});
});
it("rejects a provider-prefixed failureAlert.to for an unconfigured channel", async () => {
// No explicit channel, but `slack:...` resolves to slack, which is not
// configured here, so it must be rejected up front rather than at delivery.
setRuntimeConfig(telegramConfig());
const { context, respond } = await invokeCronUpdate(
{ id: "cron-1", patch: { failureAlert: { to: "slack:C123" } } },
createCronJob(),
);
expect(context.cron.update).not.toHaveBeenCalled();
expectResponseError(respond, {
code: "INVALID_REQUEST",
messageIncludes: "failureAlert.channel",
});
});
it("accepts a provider-prefixed failureAlert.to for a configured channel", async () => {
setRuntimeConfig(telegramConfig());
const { context, respond } = await invokeCronUpdate(
{ id: "cron-1", patch: { failureAlert: { to: "telegram:123" } } },
createCronJob(),
);
expect(context.cron.update).toHaveBeenCalled();
expectCronSuccess(respond);
});
it("accepts a bare failureAlert.to that inherits the job delivery channel (multi-channel)", async () => {
// No own channel and no provider prefix: runtime falls back to the job
// delivery channel (already validated), so this must not be rejected as
// "channel required" even though multiple channels are configured.
setRuntimeConfig(telegramSlackConfig());
const { context, respond } = await invokeCronUpdate(
{ id: "cron-1", patch: { failureAlert: { to: "C123" } } },
createCronJob({ delivery: { mode: "announce", channel: "slack", to: "slack:C1" } }),
);
expect(context.cron.update).toHaveBeenCalled();
expectCronSuccess(respond);
});
it("rejects a routing-changing alert edit that would activate a legacy-invalid inherited delivery channel", async () => {
// Legacy job: delivery.channel was stored before validation existed and the
// alert has no route of its own. Flipping the alert to announce makes runtime
// route through that invalid inherited channel, so it must be rejected now.
setRuntimeConfig(telegramSlackConfig());
const { context, respond } = await invokeCronUpdate(
{ id: "cron-1", patch: { failureAlert: { mode: "announce" } } },
createCronJob({
delivery: { mode: "announce", channel: "c0legacyinvalid", to: "123" },
failureAlert: { mode: "webhook", after: 2 },
}),
);
expect(context.cron.update).not.toHaveBeenCalled();
expectResponseError(respond, {
code: "INVALID_REQUEST",
messageIncludes: "failureAlert.channel",
});
});
it("accepts a routing-changing alert edit that inherits a valid delivery channel", async () => {
setRuntimeConfig(telegramSlackConfig());
const { context, respond } = await invokeCronUpdate(
{ id: "cron-1", patch: { failureAlert: { mode: "announce" } } },
createCronJob({
delivery: { mode: "announce", channel: "slack", to: "slack:C1" },
failureAlert: { mode: "webhook", after: 2 },
}),
);
expect(context.cron.update).toHaveBeenCalled();
expectCronSuccess(respond);
});
it("validates a newly enabled alert (--failure-alert-after) that inherits a legacy-invalid delivery channel", async () => {
// Enabling an alert with no routing key of its own makes it inherit the job
// delivery channel; a legacy-invalid one must be rejected, not persisted.
setRuntimeConfig(telegramSlackConfig());
const { context, respond } = await invokeCronUpdate(
{ id: "cron-1", patch: { failureAlert: { after: 3 } } },
createCronJob({ delivery: { mode: "announce", channel: "c0legacyinvalid", to: "123" } }),
);
expect(context.cron.update).not.toHaveBeenCalled();
expectResponseError(respond, {
code: "INVALID_REQUEST",
messageIncludes: "failureAlert.channel",
});
});
it("does not block a threshold-only edit when global alerts already deliver via the inherited route", async () => {
// Global alerts are enabled, so a job with no per-job alert is already sending
// via its (legacy) delivery channel. A --failure-alert-after edit is not newly
// enabling and must not be blocked by that pre-existing inherited channel.
setRuntimeConfig({
...telegramSlackConfig(),
cron: { failureAlert: { enabled: true } },
} as OpenClawConfig);
const { context, respond } = await invokeCronUpdate(
{ id: "cron-1", patch: { failureAlert: { after: 3 } } },
createCronJob({ delivery: { mode: "announce", channel: "c0legacyinvalid", to: "123" } }),
);
expect(context.cron.update).toHaveBeenCalled();
expectCronSuccess(respond);
});
it("accepts clearing delivery.channel to a bare-`to` `last` route with an inheriting alert (multi-channel)", async () => {
// Clearing the concrete channel keeps a bare `to` and routes via `last`; the
// delivery validator accepts this, so an alert inheriting the same route must
// be judged identically and not rejected as ambiguous.
setRuntimeConfig(telegramSlackConfig());
const { context, respond } = await invokeCronUpdate(
{ id: "cron-1", patch: { delivery: { channel: null } } },
createCronJob({
delivery: { mode: "announce", channel: "slack", to: "123" },
failureAlert: { after: 2 },
}),
);
expect(context.cron.update).toHaveBeenCalled();
expectCronSuccess(respond);
});
it("accepts enabling an alert that inherits a valid delivery channel", async () => {
setRuntimeConfig(telegramSlackConfig());
const { context, respond } = await invokeCronUpdate(
{ id: "cron-1", patch: { failureAlert: { after: 3 } } },
createCronJob({ delivery: { mode: "announce", channel: "slack", to: "slack:C1" } }),
);
expect(context.cron.update).toHaveBeenCalled();
expectCronSuccess(respond);
});
it("preserves the last-channel fallback when a delivery mode change clears inheritance", async () => {
// The alert has its own bare `to` but no channel. Switching delivery to webhook
// clears the inherited channel, and runtime then routes through `last`.
setRuntimeConfig(telegramSlackConfig());
const { context, respond } = await invokeCronUpdate(
{
id: "cron-1",
patch: { delivery: { mode: "webhook", to: "https://example.invalid/hook" } },
},
createCronJob({
delivery: { mode: "announce", channel: "slack", to: "slack:X" },
failureAlert: { to: "C123" },
}),
);
expect(context.cron.update).toHaveBeenCalled();
expectCronSuccess(respond);
});
it("rejects a failureAlert.to whose prefix conflicts with the inherited delivery channel", async () => {
// The alert omits its own channel, so runtime sends via the delivery channel
// (telegram); a `slack:`-prefixed target would route to the wrong place, so
// reject it up front instead of letting it fail at delivery.
setRuntimeConfig(telegramSlackConfig());
const { context, respond } = await invokeCronUpdate(
{ id: "cron-1", patch: { failureAlert: { to: "slack:C123" } } },
createCronJob({ delivery: { mode: "announce", channel: "telegram", to: "telegram:1" } }),
);
expect(context.cron.update).not.toHaveBeenCalled();
expectResponseError(respond, {
code: "INVALID_REQUEST",
messageIncludes: "failureAlert.channel",
});
});
it("accepts a bare failureAlert.to through the runtime last-channel fallback", async () => {
// No own channel, no delivery channel, and no provider prefix: runtime uses
// its remembered last channel, so gateway validation must preserve that path.
setRuntimeConfig(telegramSlackConfig());
const { context, respond } = await invokeCronUpdate(
{ id: "cron-1", patch: { failureAlert: { to: "C123" } } },
createCronJob(),
);
expect(context.cron.update).toHaveBeenCalled();
expectCronSuccess(respond);
});
it("validates a null failureAlert reset that reactivates global alert delivery", async () => {
setRuntimeConfig({
...telegramSlackConfig(),
cron: { failureAlert: { enabled: true } },
} as OpenClawConfig);
const { context, respond } = await invokeCronUpdate(
{ id: "cron-1", patch: { failureAlert: null } },
createCronJob({
delivery: { mode: "announce", channel: "c0legacyinvalid", to: "123" },
failureAlert: { channel: "slack", mode: "announce" },
}),
);
expect(context.cron.update).not.toHaveBeenCalled();
expectResponseError(respond, {
code: "INVALID_REQUEST",
messageIncludes: "failureAlert.channel",
});
});
it("does not revalidate a no-op null reset on an inherited global alert", async () => {
setRuntimeConfig({
...telegramSlackConfig(),
cron: { failureAlert: { enabled: true } },
} as OpenClawConfig);
const { context, respond } = await invokeCronUpdate(
{ id: "cron-1", patch: { failureAlert: null } },
createCronJob({ delivery: { mode: "announce", channel: "c0legacyinvalid", to: "123" } }),
);
expect(context.cron.update).toHaveBeenCalled();
expectCronSuccess(respond);
});
it("does not revalidate a threshold-only reset on an inherited global alert", async () => {
setRuntimeConfig({
...telegramSlackConfig(),
cron: { failureAlert: { enabled: true } },
} as OpenClawConfig);
const { context, respond } = await invokeCronUpdate(
{ id: "cron-1", patch: { failureAlert: null } },
createCronJob({
delivery: { mode: "announce", channel: "c0legacyinvalid", to: "123" },
failureAlert: { after: 2 },
}),
);
expect(context.cron.update).toHaveBeenCalled();
expectCronSuccess(respond);
});
it("revalidates an inherited failureAlert when a delivery-only patch changes the channel", async () => {
// The alert has no own channel, so it inherits delivery. Switching delivery
// from slack to telegram makes its slack-prefixed target route wrong, so the
// delivery-only edit must re-check the alert even though the patch omits it.
setRuntimeConfig(telegramSlackConfig());
const { context, respond } = await invokeCronUpdate(
{ id: "cron-1", patch: { delivery: { channel: "telegram", to: "telegram:9" } } },
createCronJob({
delivery: { mode: "announce", channel: "slack", to: "slack:C1" },
failureAlert: { to: "slack:C123" },
}),
);
expect(context.cron.update).not.toHaveBeenCalled();
expectResponseError(respond, {
code: "INVALID_REQUEST",
messageIncludes: "failureAlert.channel",
});
});
it("does not block a non-routing delivery edit on a job with a stale explicit alert channel", async () => {
// Editing delivery.bestEffort must not revalidate an alert that has its own
// (stale) channel, since it does not inherit the changed delivery field.
setRuntimeConfig(telegramSlackConfig());
const { context, respond } = await invokeCronUpdate(
{ id: "cron-1", patch: { delivery: { bestEffort: true } } },
createCronJob({
delivery: { mode: "announce", channel: "slack", to: "slack:C1" },
failureAlert: { channel: "c0example01", mode: "announce" },
}),
);
expect(context.cron.update).toHaveBeenCalled();
expectCronSuccess(respond);
});
it("does not block a delivery-only patch when the alert has no own routing (pure inheritance)", async () => {
// The alert only sets a threshold and inherits delivery, so a delivery edit
// that stays valid must not be blocked by the failureAlert revalidation.
setRuntimeConfig(telegramSlackConfig());
const { context, respond } = await invokeCronUpdate(
{ id: "cron-1", patch: { delivery: { channel: "telegram", to: "telegram:9" } } },
createCronJob({
delivery: { mode: "announce", channel: "slack", to: "slack:C1" },
failureAlert: { after: 2 },
}),
);
expect(context.cron.update).toHaveBeenCalled();
expectCronSuccess(respond);
});
it("rejects an unknown failureAlert channel on cron.add before the mutation (#103864)", async () => {
// Single configured channel so the default announce delivery passes and
// validation reaches the failureAlert channel.
setRuntimeConfig(slackConfig());
const { context, respond } = await invokeCronAdd(
agentTurnCronParams({
name: "unknown failure-alert channel",
failureAlert: { channel: "C0EXAMPLE01" },
}),
);
expect(context.cron.add).not.toHaveBeenCalled();
expectResponseError(respond, {
code: "INVALID_REQUEST",
messageIncludes: "failureAlert.channel",
});
});
it("rejects announce targets prefixed for a different explicit delivery channel", async () => {
setRuntimeConfig(telegramSlackConfig());