Files
openclaw/src/gateway/server.cron.test.ts
Josh Avant 806803b7ef feat(secrets): expand SecretRef coverage across user-supplied credentials (#29580)
* feat(secrets): expand secret target coverage and gateway tooling

* docs(secrets): align gateway and CLI secret docs

* chore(protocol): regenerate swift gateway models for secrets methods

* fix(config): restore talk apiKey fallback and stabilize runner test

* ci(windows): reduce test worker count for shard stability

* ci(windows): raise node heap for test shard stability

* test(feishu): make proxy env precedence assertion windows-safe

* fix(gateway): resolve auth password SecretInput refs for clients

* fix(gateway): resolve remote SecretInput credentials for clients

* fix(secrets): skip inactive refs in command snapshot assignments

* fix(secrets): scope gateway.remote refs to effective auth surfaces

* fix(secrets): ignore memory defaults when enabled agents disable search

* fix(secrets): honor Google Chat serviceAccountRef inheritance

* fix(secrets): address tsgo errors in command and gateway collectors

* fix(secrets): avoid auth-store load in providers-only configure

* fix(gateway): defer local password ref resolution by precedence

* fix(secrets): gate telegram webhook secret refs by webhook mode

* fix(secrets): gate slack signing secret refs to http mode

* fix(secrets): skip telegram botToken refs when tokenFile is set

* fix(secrets): gate discord pluralkit refs by enabled flag

* fix(secrets): gate discord voice tts refs by voice enabled

* test(secrets): make runtime fixture modes explicit

* fix(cli): resolve local qr password secret refs

* fix(cli): fail when gateway leaves command refs unresolved

* fix(gateway): fail when local password SecretRef is unresolved

* fix(gateway): fail when required remote SecretRefs are unresolved

* fix(gateway): resolve local password refs only when password can win

* fix(cli): skip local password SecretRef resolution on qr token override

* test(gateway): cast SecretRef fixtures to OpenClawConfig

* test(secrets): activate mode-gated targets in runtime coverage fixture

* fix(cron): support SecretInput webhook tokens safely

* fix(bluebubbles): support SecretInput passwords across config paths

* fix(msteams): make appPassword SecretInput-safe in onboarding/token paths

* fix(bluebubbles): align SecretInput schema helper typing

* fix(cli): clarify secrets.resolve version-skew errors

* refactor(secrets): return structured inactive paths from secrets.resolve

* refactor(gateway): type onboarding secret writes as SecretInput

* chore(protocol): regenerate swift models for secrets.resolve

* feat(secrets): expand extension credential secretref support

* fix(secrets): gate web-search refs by active provider

* fix(onboarding): detect SecretRef credentials in extension status

* fix(onboarding): allow keeping existing ref in secret prompt

* fix(onboarding): resolve gateway password SecretRefs for probe and tui

* fix(onboarding): honor secret-input-mode for local gateway auth

* fix(acp): resolve gateway SecretInput credentials

* fix(secrets): gate gateway.remote refs to remote surfaces

* test(secrets): cover pattern matching and inactive array refs

* docs(secrets): clarify secrets.resolve and remote active surfaces

* fix(bluebubbles): keep existing SecretRef during onboarding

* fix(tests): resolve CI type errors in new SecretRef coverage

* fix(extensions): replace raw fetch with SSRF-guarded fetch

* test(secrets): mark gateway remote targets active in runtime coverage

* test(infra): normalize home-prefix expectation across platforms

* fix(cli): only resolve local qr password refs in password mode

* test(cli): cover local qr token mode with unresolved password ref

* docs(cli): clarify local qr password ref resolution behavior

* refactor(extensions): reuse sdk SecretInput helpers

* fix(wizard): resolve onboarding env-template secrets before plaintext

* fix(cli): surface secrets.resolve diagnostics in memory and qr

* test(secrets): repair post-rebase runtime and fixtures

* fix(gateway): skip remote password ref resolution when token wins

* fix(secrets): treat tailscale remote gateway refs as active

* fix(gateway): allow remote password fallback when token ref is unresolved

* fix(gateway): ignore stale local password refs for none and trusted-proxy

* fix(gateway): skip remote secret ref resolution on local call paths

* test(cli): cover qr remote tailscale secret ref resolution

* fix(secrets): align gateway password active-surface with auth inference

* fix(cli): resolve inferred local gateway password refs in qr

* fix(gateway): prefer resolvable remote password over token ref pre-resolution

* test(gateway): cover none and trusted-proxy stale password refs

* docs(secrets): sync qr and gateway active-surface behavior

* fix: restore stability blockers from pre-release audit

* Secrets: fix collector/runtime precedence contradictions

* docs: align secrets and web credential docs

* fix(rebase): resolve integration regressions after main rebase

* fix(node-host): resolve gateway secret refs for auth

* fix(secrets): harden secretinput runtime readers

* gateway: skip inactive auth secretref resolution

* cli: avoid gateway preflight for inactive secret refs

* extensions: allow unresolved refs in onboarding status

* tests: fix qr-cli module mock hoist ordering

* Security: align audit checks with SecretInput resolution

* Gateway: resolve local-mode remote fallback secret refs

* Node host: avoid resolving inactive password secret refs

* Secrets runtime: mark Slack appToken inactive for HTTP mode

* secrets: keep inactive gateway remote refs non-blocking

* cli: include agent memory secret targets in runtime resolution

* docs(secrets): sync docs with active-surface and web search behavior

* fix(secrets): keep telegram top-level token refs active for blank account tokens

* fix(daemon): resolve gateway password secret refs for probe auth

* fix(secrets): skip IRC NickServ ref resolution when NickServ is disabled

* fix(secrets): align token inheritance and exec timeout defaults

* docs(secrets): clarify active-surface notes in cli docs

* cli: require secrets.resolve gateway capability

* gateway: log auth secret surface diagnostics

* secrets: remove dead provider resolver module

* fix(secrets): restore gateway auth precedence and fallback resolution

* fix(tests): align plugin runtime mock typings

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-03-03 02:58:20 +00:00

788 lines
28 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { setImmediate as setImmediatePromise } from "node:timers/promises";
import { afterAll, beforeEach, describe, expect, test, vi } from "vitest";
import type WebSocket from "ws";
import type { GuardedFetchOptions } from "../infra/net/fetch-guard.js";
import {
connectOk,
cronIsolatedRun,
installGatewayTestHooks,
rpcReq,
startServerWithClient,
testState,
waitForSystemEvent,
} from "./test-helpers.js";
const fetchWithSsrFGuardMock = vi.hoisted(() =>
vi.fn(async (params: GuardedFetchOptions) => ({
response: new Response("ok", { status: 200 }),
finalUrl: params.url,
release: async () => {},
})),
);
vi.mock("../infra/net/fetch-guard.js", () => ({
fetchWithSsrFGuard: (...args: unknown[]) =>
(
fetchWithSsrFGuardMock as unknown as (...innerArgs: unknown[]) => Promise<{
response: Response;
finalUrl: string;
release: () => Promise<void>;
}>
)(...args),
}));
installGatewayTestHooks({ scope: "suite" });
const CRON_WAIT_INTERVAL_MS = 5;
const CRON_WAIT_TIMEOUT_MS = 3_000;
const EMPTY_CRON_STORE_CONTENT = JSON.stringify({ version: 1, jobs: [] });
let cronSuiteTempRootPromise: Promise<string> | null = null;
let cronSuiteCaseId = 0;
async function getCronSuiteTempRoot(): Promise<string> {
if (!cronSuiteTempRootPromise) {
cronSuiteTempRootPromise = fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-cron-suite-"));
}
return await cronSuiteTempRootPromise;
}
async function yieldToEventLoop() {
await setImmediatePromise();
}
async function rmTempDir(dir: string) {
for (let i = 0; i < 100; i += 1) {
try {
await fs.rm(dir, { recursive: true, force: true });
return;
} catch (err) {
const code = (err as { code?: unknown } | null)?.code;
if (code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM" || code === "EACCES") {
await yieldToEventLoop();
continue;
}
throw err;
}
}
await fs.rm(dir, { recursive: true, force: true });
}
async function waitForCondition(check: () => boolean | Promise<boolean>, timeoutMs = 2000) {
await vi.waitFor(
async () => {
const ok = await check();
if (!ok) {
throw new Error("condition not met");
}
},
{ timeout: timeoutMs, interval: CRON_WAIT_INTERVAL_MS },
);
}
async function createCronCasePaths(tempPrefix: string): Promise<{
dir: string;
storePath: string;
}> {
const suiteRoot = await getCronSuiteTempRoot();
const dir = path.join(suiteRoot, `${tempPrefix}${cronSuiteCaseId++}`);
const storePath = path.join(dir, "cron", "jobs.json");
await fs.mkdir(path.dirname(storePath), { recursive: true });
return { dir, storePath };
}
async function cleanupCronTestRun(params: {
ws: { close: () => void };
server: { close: () => Promise<void> };
prevSkipCron: string | undefined;
clearSessionConfig?: boolean;
}) {
params.ws.close();
await params.server.close();
testState.cronStorePath = undefined;
if (params.clearSessionConfig) {
testState.sessionConfig = undefined;
}
testState.cronEnabled = undefined;
if (params.prevSkipCron === undefined) {
delete process.env.OPENCLAW_SKIP_CRON;
return;
}
process.env.OPENCLAW_SKIP_CRON = params.prevSkipCron;
}
async function setupCronTestRun(params: {
tempPrefix: string;
cronEnabled?: boolean;
sessionConfig?: { mainKey: string };
jobs?: unknown[];
}): Promise<{ prevSkipCron: string | undefined; dir: string }> {
const prevSkipCron = process.env.OPENCLAW_SKIP_CRON;
process.env.OPENCLAW_SKIP_CRON = "0";
const { dir, storePath } = await createCronCasePaths(params.tempPrefix);
testState.cronStorePath = storePath;
testState.sessionConfig = params.sessionConfig;
testState.cronEnabled = params.cronEnabled;
await fs.writeFile(
testState.cronStorePath,
params.jobs ? JSON.stringify({ version: 1, jobs: params.jobs }) : EMPTY_CRON_STORE_CONTENT,
);
return { prevSkipCron, dir };
}
function expectCronJobIdFromResponse(response: { ok?: unknown; payload?: unknown }) {
expect(response.ok).toBe(true);
const value = (response.payload as { id?: unknown } | null)?.id;
const id = typeof value === "string" ? value : "";
expect(id.length > 0).toBe(true);
return id;
}
async function addMainSystemEventCronJob(params: { ws: WebSocket; name: string; text?: string }) {
const response = await rpcReq(params.ws, "cron.add", {
name: params.name,
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: params.text ?? "hello" },
});
return expectCronJobIdFromResponse(response);
}
function getWebhookCall(index: number) {
const [args] = fetchWithSsrFGuardMock.mock.calls[index] as unknown as [
{
url?: string;
init?: {
method?: string;
headers?: Record<string, string>;
body?: string;
};
},
];
const url = args.url ?? "";
const init = args.init ?? {};
const body = JSON.parse(init.body ?? "{}") as Record<string, unknown>;
return { url, init, body };
}
describe("gateway server cron", () => {
afterAll(async () => {
if (!cronSuiteTempRootPromise) {
return;
}
await rmTempDir(await cronSuiteTempRootPromise);
cronSuiteTempRootPromise = null;
cronSuiteCaseId = 0;
});
beforeEach(() => {
// Keep polling helpers deterministic even if other tests left fake timers enabled.
vi.useRealTimers();
});
test("handles cron CRUD, normalization, and patch semantics", { timeout: 20_000 }, async () => {
const { prevSkipCron } = await setupCronTestRun({
tempPrefix: "openclaw-gw-cron-",
sessionConfig: { mainKey: "primary" },
cronEnabled: false,
});
const { server, ws } = await startServerWithClient();
await connectOk(ws);
try {
const addRes = await rpcReq(ws, "cron.add", {
name: "daily",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "hello" },
delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" },
});
expect(addRes.ok).toBe(true);
expect(typeof (addRes.payload as { id?: unknown } | null)?.id).toBe("string");
const listRes = await rpcReq(ws, "cron.list", {
includeDisabled: true,
});
expect(listRes.ok).toBe(true);
const jobs = (listRes.payload as { jobs?: unknown } | null)?.jobs;
expect(Array.isArray(jobs)).toBe(true);
expect((jobs as unknown[]).length).toBe(1);
expect(((jobs as Array<{ name?: unknown }>)[0]?.name as string) ?? "").toBe("daily");
expect(
((jobs as Array<{ delivery?: { mode?: unknown } }>)[0]?.delivery?.mode as string) ?? "",
).toBe("webhook");
const routeAtMs = Date.now() - 1;
const routeRes = await rpcReq(ws, "cron.add", {
name: "route test",
enabled: true,
schedule: { kind: "at", at: new Date(routeAtMs).toISOString() },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "cron route check" },
});
expect(routeRes.ok).toBe(true);
const routeJobIdValue = (routeRes.payload as { id?: unknown } | null)?.id;
const routeJobId = typeof routeJobIdValue === "string" ? routeJobIdValue : "";
expect(routeJobId.length > 0).toBe(true);
const runRes = await rpcReq(ws, "cron.run", { id: routeJobId, mode: "force" }, 20_000);
expect(runRes.ok).toBe(true);
const events = await waitForSystemEvent();
expect(events.some((event) => event.includes("cron route check"))).toBe(true);
const wrappedAtMs = Date.now() + 1000;
const wrappedRes = await rpcReq(ws, "cron.add", {
data: {
name: "wrapped",
schedule: { at: new Date(wrappedAtMs).toISOString() },
payload: { kind: "systemEvent", text: "hello" },
},
});
expect(wrappedRes.ok).toBe(true);
const wrappedPayload = wrappedRes.payload as
| { schedule?: unknown; sessionTarget?: unknown; wakeMode?: unknown }
| undefined;
expect(wrappedPayload?.sessionTarget).toBe("main");
expect(wrappedPayload?.wakeMode).toBe("now");
expect((wrappedPayload?.schedule as { kind?: unknown } | undefined)?.kind).toBe("at");
const patchJobId = await addMainSystemEventCronJob({ ws, name: "patch test" });
const atMs = Date.now() + 1_000;
const updateRes = await rpcReq(ws, "cron.update", {
id: patchJobId,
patch: {
schedule: { at: new Date(atMs).toISOString() },
payload: { kind: "systemEvent", text: "updated" },
},
});
expect(updateRes.ok).toBe(true);
const updated = updateRes.payload as
| { schedule?: { kind?: unknown }; payload?: { kind?: unknown } }
| undefined;
expect(updated?.schedule?.kind).toBe("at");
expect(updated?.payload?.kind).toBe("systemEvent");
const mergeRes = await rpcReq(ws, "cron.add", {
name: "patch merge",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: { kind: "agentTurn", message: "hello", model: "opus" },
});
expect(mergeRes.ok).toBe(true);
const mergeJobIdValue = (mergeRes.payload as { id?: unknown } | null)?.id;
const mergeJobId = typeof mergeJobIdValue === "string" ? mergeJobIdValue : "";
expect(mergeJobId.length > 0).toBe(true);
const noTimeoutRes = await rpcReq(ws, "cron.add", {
name: "no-timeout payload",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: { kind: "agentTurn", message: "hello", timeoutSeconds: 0 },
});
expect(noTimeoutRes.ok).toBe(true);
const noTimeoutPayload = noTimeoutRes.payload as
| {
payload?: {
kind?: unknown;
timeoutSeconds?: unknown;
};
}
| undefined;
expect(noTimeoutPayload?.payload?.kind).toBe("agentTurn");
expect(noTimeoutPayload?.payload?.timeoutSeconds).toBe(0);
const mergeUpdateRes = await rpcReq(ws, "cron.update", {
id: mergeJobId,
patch: {
delivery: { mode: "announce", channel: "telegram", to: "19098680" },
},
});
expect(mergeUpdateRes.ok).toBe(true);
const merged = mergeUpdateRes.payload as
| {
payload?: { kind?: unknown; message?: unknown; model?: unknown };
delivery?: { mode?: unknown; channel?: unknown; to?: unknown };
}
| undefined;
expect(merged?.payload?.kind).toBe("agentTurn");
expect(merged?.payload?.message).toBe("hello");
expect(merged?.payload?.model).toBe("opus");
expect(merged?.delivery?.mode).toBe("announce");
expect(merged?.delivery?.channel).toBe("telegram");
expect(merged?.delivery?.to).toBe("19098680");
const modelOnlyPatchRes = await rpcReq(ws, "cron.update", {
id: mergeJobId,
patch: {
payload: {
model: "anthropic/claude-sonnet-4-5",
},
},
});
expect(modelOnlyPatchRes.ok).toBe(true);
const modelOnlyPatched = modelOnlyPatchRes.payload as
| {
payload?: {
kind?: unknown;
message?: unknown;
model?: unknown;
};
}
| undefined;
expect(modelOnlyPatched?.payload?.kind).toBe("agentTurn");
expect(modelOnlyPatched?.payload?.message).toBe("hello");
expect(modelOnlyPatched?.payload?.model).toBe("anthropic/claude-sonnet-4-5");
const legacyDeliveryPatchRes = await rpcReq(ws, "cron.update", {
id: mergeJobId,
patch: {
payload: {
kind: "agentTurn",
deliver: true,
channel: "signal",
to: "+15550001111",
bestEffortDeliver: true,
},
},
});
expect(legacyDeliveryPatchRes.ok).toBe(true);
const legacyDeliveryPatched = legacyDeliveryPatchRes.payload as
| {
payload?: { kind?: unknown; message?: unknown };
delivery?: { mode?: unknown; channel?: unknown; to?: unknown; bestEffort?: unknown };
}
| undefined;
expect(legacyDeliveryPatched?.payload?.kind).toBe("agentTurn");
expect(legacyDeliveryPatched?.payload?.message).toBe("hello");
expect(legacyDeliveryPatched?.delivery?.mode).toBe("announce");
expect(legacyDeliveryPatched?.delivery?.channel).toBe("signal");
expect(legacyDeliveryPatched?.delivery?.to).toBe("+15550001111");
expect(legacyDeliveryPatched?.delivery?.bestEffort).toBe(true);
const rejectJobId = await addMainSystemEventCronJob({ ws, name: "patch reject" });
const rejectUpdateRes = await rpcReq(ws, "cron.update", {
id: rejectJobId,
patch: {
payload: { kind: "agentTurn", message: "nope" },
},
});
expect(rejectUpdateRes.ok).toBe(false);
const jobId = await addMainSystemEventCronJob({ ws, name: "jobId test" });
const jobIdUpdateRes = await rpcReq(ws, "cron.update", {
jobId,
patch: {
schedule: { at: new Date(Date.now() + 2_000).toISOString() },
payload: { kind: "systemEvent", text: "updated" },
},
});
expect(jobIdUpdateRes.ok).toBe(true);
const disableJobId = await addMainSystemEventCronJob({ ws, name: "disable test" });
const disableUpdateRes = await rpcReq(ws, "cron.update", {
id: disableJobId,
patch: { enabled: false },
});
expect(disableUpdateRes.ok).toBe(true);
const disabled = disableUpdateRes.payload as { enabled?: unknown } | undefined;
expect(disabled?.enabled).toBe(false);
} finally {
await cleanupCronTestRun({
ws,
server,
prevSkipCron,
clearSessionConfig: true,
});
}
});
test("writes cron run history and auto-runs due jobs", async () => {
const { prevSkipCron, dir } = await setupCronTestRun({
tempPrefix: "openclaw-gw-cron-log-",
});
const { server, ws } = await startServerWithClient();
await connectOk(ws);
try {
const atMs = Date.now() - 1;
const addRes = await rpcReq(ws, "cron.add", {
name: "log test",
enabled: true,
schedule: { kind: "at", at: new Date(atMs).toISOString() },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "hello" },
});
expect(addRes.ok).toBe(true);
const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id;
const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
expect(jobId.length > 0).toBe(true);
const runRes = await rpcReq(ws, "cron.run", { id: jobId, mode: "force" }, 20_000);
expect(runRes.ok).toBe(true);
const logPath = path.join(dir, "cron", "runs", `${jobId}.jsonl`);
let raw = "";
await waitForCondition(async () => {
raw = await fs.readFile(logPath, "utf-8").catch(() => "");
return raw.trim().length > 0;
}, CRON_WAIT_TIMEOUT_MS);
const line = raw
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
.at(-1);
const last = JSON.parse(line ?? "{}") as {
jobId?: unknown;
action?: unknown;
status?: unknown;
summary?: unknown;
deliveryStatus?: unknown;
};
expect(last.action).toBe("finished");
expect(last.jobId).toBe(jobId);
expect(last.status).toBe("ok");
expect(last.summary).toBe("hello");
expect(last.deliveryStatus).toBe("not-requested");
const runsRes = await rpcReq(ws, "cron.runs", { id: jobId, limit: 50 });
expect(runsRes.ok).toBe(true);
const entries = (runsRes.payload as { entries?: unknown } | null)?.entries;
expect(Array.isArray(entries)).toBe(true);
expect((entries as Array<{ jobId?: unknown }>).at(-1)?.jobId).toBe(jobId);
expect((entries as Array<{ summary?: unknown }>).at(-1)?.summary).toBe("hello");
expect((entries as Array<{ deliveryStatus?: unknown }>).at(-1)?.deliveryStatus).toBe(
"not-requested",
);
const allRunsRes = await rpcReq(ws, "cron.runs", {
scope: "all",
limit: 50,
statuses: ["ok"],
});
expect(allRunsRes.ok).toBe(true);
const allEntries = (allRunsRes.payload as { entries?: unknown } | null)?.entries;
expect(Array.isArray(allEntries)).toBe(true);
expect(
(allEntries as Array<{ jobId?: unknown }>).some((entry) => entry.jobId === jobId),
).toBe(true);
const statusRes = await rpcReq(ws, "cron.status", {});
expect(statusRes.ok).toBe(true);
const statusPayload = statusRes.payload as
| { enabled?: unknown; storePath?: unknown }
| undefined;
expect(statusPayload?.enabled).toBe(true);
const storePath = typeof statusPayload?.storePath === "string" ? statusPayload.storePath : "";
expect(storePath).toContain("jobs.json");
const autoRes = await rpcReq(ws, "cron.add", {
name: "auto run test",
enabled: true,
schedule: { kind: "at", at: new Date(Date.now() + 50).toISOString() },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "auto" },
});
expect(autoRes.ok).toBe(true);
const autoJobIdValue = (autoRes.payload as { id?: unknown } | null)?.id;
const autoJobId = typeof autoJobIdValue === "string" ? autoJobIdValue : "";
expect(autoJobId.length > 0).toBe(true);
await waitForCondition(async () => {
const runsRes = await rpcReq(ws, "cron.runs", { id: autoJobId, limit: 10 });
const runsPayload = runsRes.payload as { entries?: unknown } | undefined;
return Array.isArray(runsPayload?.entries) && runsPayload.entries.length > 0;
}, CRON_WAIT_TIMEOUT_MS);
const autoEntries = (await rpcReq(ws, "cron.runs", { id: autoJobId, limit: 10 })).payload as
| { entries?: Array<{ jobId?: unknown }> }
| undefined;
expect(Array.isArray(autoEntries?.entries)).toBe(true);
const runs = autoEntries?.entries ?? [];
expect(runs.at(-1)?.jobId).toBe(autoJobId);
} finally {
await cleanupCronTestRun({ ws, server, prevSkipCron });
}
}, 45_000);
test("posts webhooks for delivery mode and legacy notify fallback only when summary exists", async () => {
const legacyNotifyJob = {
id: "legacy-notify-job",
name: "legacy notify job",
enabled: true,
notify: true,
createdAtMs: Date.now(),
updatedAtMs: Date.now(),
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "legacy webhook" },
state: {},
};
const { prevSkipCron } = await setupCronTestRun({
tempPrefix: "openclaw-gw-cron-webhook-",
cronEnabled: false,
jobs: [legacyNotifyJob],
});
const configPath = process.env.OPENCLAW_CONFIG_PATH;
expect(typeof configPath).toBe("string");
await fs.mkdir(path.dirname(configPath as string), { recursive: true });
await fs.writeFile(
configPath as string,
JSON.stringify(
{
cron: {
webhook: "https://legacy.example.invalid/cron-finished",
webhookToken: "cron-webhook-token",
},
},
null,
2,
),
"utf-8",
);
fetchWithSsrFGuardMock.mockClear();
const { server, ws } = await startServerWithClient();
await connectOk(ws);
try {
const invalidWebhookRes = await rpcReq(ws, "cron.add", {
name: "invalid webhook",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "invalid" },
delivery: { mode: "webhook", to: "ftp://example.invalid/cron-finished" },
});
expect(invalidWebhookRes.ok).toBe(false);
const notifyRes = await rpcReq(ws, "cron.add", {
name: "webhook enabled",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "send webhook" },
delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" },
});
expect(notifyRes.ok).toBe(true);
const notifyJobIdValue = (notifyRes.payload as { id?: unknown } | null)?.id;
const notifyJobId = typeof notifyJobIdValue === "string" ? notifyJobIdValue : "";
expect(notifyJobId.length > 0).toBe(true);
const notifyRunRes = await rpcReq(ws, "cron.run", { id: notifyJobId, mode: "force" }, 20_000);
expect(notifyRunRes.ok).toBe(true);
await waitForCondition(
() => fetchWithSsrFGuardMock.mock.calls.length === 1,
CRON_WAIT_TIMEOUT_MS,
);
const notifyCall = getWebhookCall(0);
expect(notifyCall.url).toBe("https://example.invalid/cron-finished");
expect(notifyCall.init.method).toBe("POST");
expect(notifyCall.init.headers?.Authorization).toBe("Bearer cron-webhook-token");
expect(notifyCall.init.headers?.["Content-Type"]).toBe("application/json");
const notifyBody = notifyCall.body;
expect(notifyBody.action).toBe("finished");
expect(notifyBody.jobId).toBe(notifyJobId);
const legacyRunRes = await rpcReq(
ws,
"cron.run",
{ id: "legacy-notify-job", mode: "force" },
20_000,
);
expect(legacyRunRes.ok).toBe(true);
await waitForCondition(
() => fetchWithSsrFGuardMock.mock.calls.length === 2,
CRON_WAIT_TIMEOUT_MS,
);
const legacyCall = getWebhookCall(1);
expect(legacyCall.url).toBe("https://legacy.example.invalid/cron-finished");
expect(legacyCall.init.method).toBe("POST");
expect(legacyCall.init.headers?.Authorization).toBe("Bearer cron-webhook-token");
const legacyBody = legacyCall.body;
expect(legacyBody.action).toBe("finished");
expect(legacyBody.jobId).toBe("legacy-notify-job");
const silentRes = await rpcReq(ws, "cron.add", {
name: "webhook disabled",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "do not send" },
});
expect(silentRes.ok).toBe(true);
const silentJobIdValue = (silentRes.payload as { id?: unknown } | null)?.id;
const silentJobId = typeof silentJobIdValue === "string" ? silentJobIdValue : "";
expect(silentJobId.length > 0).toBe(true);
const silentRunRes = await rpcReq(ws, "cron.run", { id: silentJobId, mode: "force" }, 20_000);
expect(silentRunRes.ok).toBe(true);
await yieldToEventLoop();
await yieldToEventLoop();
expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(2);
fetchWithSsrFGuardMock.mockClear();
cronIsolatedRun.mockResolvedValueOnce({ status: "error", summary: "delivery failed" });
const failureDestRes = await rpcReq(ws, "cron.add", {
name: "failure destination webhook",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: { kind: "agentTurn", message: "test" },
delivery: {
mode: "announce",
channel: "telegram",
to: "19098680",
failureDestination: {
mode: "webhook",
to: "https://example.invalid/failure-destination",
},
},
});
expect(failureDestRes.ok).toBe(true);
const failureDestJobIdValue = (failureDestRes.payload as { id?: unknown } | null)?.id;
const failureDestJobId =
typeof failureDestJobIdValue === "string" ? failureDestJobIdValue : "";
expect(failureDestJobId.length > 0).toBe(true);
const failureDestRunRes = await rpcReq(
ws,
"cron.run",
{ id: failureDestJobId, mode: "force" },
20_000,
);
expect(failureDestRunRes.ok).toBe(true);
await waitForCondition(
() => fetchWithSsrFGuardMock.mock.calls.length === 1,
CRON_WAIT_TIMEOUT_MS,
);
const failureDestCall = getWebhookCall(0);
expect(failureDestCall.url).toBe("https://example.invalid/failure-destination");
const failureDestBody = failureDestCall.body;
expect(failureDestBody.message).toBe(
'Cron job "failure destination webhook" failed: unknown error',
);
cronIsolatedRun.mockResolvedValueOnce({ status: "ok", summary: "" });
const noSummaryRes = await rpcReq(ws, "cron.add", {
name: "webhook no summary",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: { kind: "agentTurn", message: "test" },
delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" },
});
expect(noSummaryRes.ok).toBe(true);
const noSummaryJobIdValue = (noSummaryRes.payload as { id?: unknown } | null)?.id;
const noSummaryJobId = typeof noSummaryJobIdValue === "string" ? noSummaryJobIdValue : "";
expect(noSummaryJobId.length > 0).toBe(true);
const noSummaryRunRes = await rpcReq(
ws,
"cron.run",
{ id: noSummaryJobId, mode: "force" },
20_000,
);
expect(noSummaryRunRes.ok).toBe(true);
await yieldToEventLoop();
await yieldToEventLoop();
expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(1);
} finally {
await cleanupCronTestRun({ ws, server, prevSkipCron });
}
}, 60_000);
test("ignores non-string cron.webhookToken values without crashing webhook delivery", async () => {
const { prevSkipCron } = await setupCronTestRun({
tempPrefix: "openclaw-gw-cron-webhook-secretinput-",
cronEnabled: false,
});
const configPath = process.env.OPENCLAW_CONFIG_PATH;
expect(typeof configPath).toBe("string");
await fs.mkdir(path.dirname(configPath as string), { recursive: true });
await fs.writeFile(
configPath as string,
JSON.stringify(
{
cron: {
webhookToken: {
opaque: true,
},
},
},
null,
2,
),
"utf-8",
);
fetchWithSsrFGuardMock.mockClear();
const { server, ws } = await startServerWithClient();
await connectOk(ws);
try {
const notifyRes = await rpcReq(ws, "cron.add", {
name: "webhook secretinput object",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "send webhook" },
delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" },
});
expect(notifyRes.ok).toBe(true);
const notifyJobIdValue = (notifyRes.payload as { id?: unknown } | null)?.id;
const notifyJobId = typeof notifyJobIdValue === "string" ? notifyJobIdValue : "";
expect(notifyJobId.length > 0).toBe(true);
const notifyRunRes = await rpcReq(ws, "cron.run", { id: notifyJobId, mode: "force" }, 20_000);
expect(notifyRunRes.ok).toBe(true);
await waitForCondition(
() => fetchWithSsrFGuardMock.mock.calls.length === 1,
CRON_WAIT_TIMEOUT_MS,
);
const [notifyArgs] = fetchWithSsrFGuardMock.mock.calls[0] as unknown as [
{
url?: string;
init?: {
method?: string;
headers?: Record<string, string>;
};
},
];
expect(notifyArgs.url).toBe("https://example.invalid/cron-finished");
expect(notifyArgs.init?.method).toBe("POST");
expect(notifyArgs.init?.headers?.Authorization).toBeUndefined();
expect(notifyArgs.init?.headers?.["Content-Type"]).toBe("application/json");
} finally {
await cleanupCronTestRun({ ws, server, prevSkipCron });
}
}, 45_000);
});