Cron: normalize cron.add inputs + align channels (#256)

* fix: harden cron add and align channels

* fix: keep cron tool id params

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Marcus Neves
2026-01-05 23:09:48 -03:00
committed by GitHub
parent 00061b2fd3
commit 67e1452f4a
21 changed files with 457 additions and 48 deletions

View File

@@ -635,6 +635,8 @@ export const CronPayloadSchema = Type.Union([
Type.Literal("telegram"),
Type.Literal("discord"),
Type.Literal("slack"),
Type.Literal("signal"),
Type.Literal("imessage"),
]),
),
to: Type.Optional(Type.String()),

View File

@@ -17,6 +17,10 @@ import {
validateWakeParams,
} from "../protocol/index.js";
import type { GatewayRequestHandlers } from "./types.js";
import {
normalizeCronJobCreate,
normalizeCronJobPatch,
} from "../../cron/normalize.js";
export const cronHandlers: GatewayRequestHandlers = {
wake: ({ params, respond, context }) => {
@@ -72,7 +76,8 @@ export const cronHandlers: GatewayRequestHandlers = {
respond(true, status, undefined);
},
"cron.add": async ({ params, respond, context }) => {
if (!validateCronAddParams(params)) {
const normalized = normalizeCronJobCreate(params) ?? params;
if (!validateCronAddParams(normalized)) {
respond(
false,
undefined,
@@ -83,11 +88,20 @@ export const cronHandlers: GatewayRequestHandlers = {
);
return;
}
const job = await context.cron.add(params as unknown as CronJobCreate);
const job = await context.cron.add(
normalized as unknown as CronJobCreate,
);
respond(true, job, undefined);
},
"cron.update": async ({ params, respond, context }) => {
if (!validateCronUpdateParams(params)) {
const normalizedPatch = normalizeCronJobPatch(
(params as { patch?: unknown } | null)?.patch,
);
const candidate =
normalizedPatch && typeof params === "object" && params !== null
? { ...(params as Record<string, unknown>), patch: normalizedPatch }
: params;
if (!validateCronUpdateParams(candidate)) {
respond(
false,
undefined,
@@ -98,7 +112,7 @@ export const cronHandlers: GatewayRequestHandlers = {
);
return;
}
const p = params as {
const p = candidate as {
id: string;
patch: Record<string, unknown>;
};

View File

@@ -68,6 +68,88 @@ describe("gateway server cron", () => {
testState.cronStorePath = undefined;
});
test("normalizes wrapped cron.add payloads", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-"));
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
await fs.writeFile(
testState.cronStorePath,
JSON.stringify({ version: 1, jobs: [] }),
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const atMs = Date.now() + 1000;
const addRes = await rpcReq(ws, "cron.add", {
data: {
name: "wrapped",
schedule: { atMs },
payload: { text: "hello" },
},
});
expect(addRes.ok).toBe(true);
const payload = addRes.payload as
| { schedule?: unknown; sessionTarget?: unknown; wakeMode?: unknown }
| undefined;
expect(payload?.sessionTarget).toBe("main");
expect(payload?.wakeMode).toBe("next-heartbeat");
expect((payload?.schedule as { kind?: unknown } | undefined)?.kind).toBe(
"at",
);
ws.close();
await server.close();
await fs.rm(dir, { recursive: true, force: true });
testState.cronStorePath = undefined;
});
test("normalizes cron.update patch payloads", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-"));
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
await fs.writeFile(
testState.cronStorePath,
JSON.stringify({ version: 1, jobs: [] }),
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const addRes = await rpcReq(ws, "cron.add", {
name: "patch test",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
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 atMs = Date.now() + 1_000;
const updateRes = await rpcReq(ws, "cron.update", {
id: jobId,
patch: {
schedule: { atMs },
payload: { 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");
ws.close();
await server.close();
await fs.rm(dir, { recursive: true, force: true });
testState.cronStorePath = undefined;
});
test("writes cron run history to runs/<jobId>.jsonl", async () => {
const dir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-gw-cron-log-"),