mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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") ||
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user