From 13716ad4f4c395ab0d3e6cd78d9bee537bc7c430 Mon Sep 17 00:00:00 2001 From: Nikhil Patel Date: Wed, 22 Jul 2026 10:54:41 +0530 Subject: [PATCH] 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 --- src/cron/delivery-channel-validation.ts | 66 ++- .../cron-error-classification.ts | 1 + src/gateway/server-methods/cron.ts | 84 ++- .../server-methods/cron.validation.test.ts | 505 ++++++++++++++++++ 4 files changed, 644 insertions(+), 12 deletions(-) diff --git a/src/cron/delivery-channel-validation.ts b/src/cron/delivery-channel-validation.ts index 2e77c0065f6c..39e78d94b6c9 100644 --- a/src/cron/delivery-channel-validation.ts +++ b/src/cron/delivery-channel-validation.ts @@ -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, + }); } diff --git a/src/gateway/server-methods/cron-error-classification.ts b/src/gateway/server-methods/cron-error-classification.ts index 7208d1376dea..767da6521aee 100644 --- a/src/gateway/server-methods/cron-error-classification.ts +++ b/src/gateway/server-methods/cron-error-classification.ts @@ -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") || diff --git a/src/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts index f4cc23582e0c..032f2232985c 100644 --- a/src/gateway/server-methods/cron.ts +++ b/src/gateway/server-methods/cron.ts @@ -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, }); } } diff --git a/src/gateway/server-methods/cron.validation.test.ts b/src/gateway/server-methods/cron.validation.test.ts index 0f9bb6df9c87..fa4098d9bc0a 100644 --- a/src/gateway/server-methods/cron.validation.test.ts +++ b/src/gateway/server-methods/cron.validation.test.ts @@ -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());