From 7828b4bdaef67c4487c2d10e66af2f29897f2a55 Mon Sep 17 00:00:00 2001 From: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 07:02:31 +0000 Subject: [PATCH] fix(cron-cli): bound loadCronJobForShow pagination (#83856) --- CHANGELOG.md | 1 + src/cli/cron-cli/register.cron-simple.test.ts | 70 +++++++++++++++++++ src/cli/cron-cli/register.cron-simple.ts | 17 +++-- 3 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 src/cli/cron-cli/register.cron-simple.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 393a5ee4c2c..05cef4159a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai - fix(mattermost): fail closed on missing channel type [AI]. (#84091) Thanks @pgondhi987. - Recheck rebuilt system.run argv [AI]. (#84090) Thanks @pgondhi987. +- CLI/cron: bound `openclaw cron show` job lookup pagination so non-advancing or unbounded `cron.list` responses fail instead of hanging the command. Fixes #83856. (#83989) - Agents/messages: stop message-tool-only turns after a successful source-channel `message` send while keeping transcript mirrors under the session write lock. (#84289) - Agents: filter silent heartbeat response-tool transcript artifacts out of embedded context snapshots so later user turns are not polluted by heartbeat no-op messages. (#83477) Thanks @fuller-stack-dev. - Agents/OpenAI: log repeated strict tool-schema downgrade diagnostics once per provider/model/tool signature, reducing duplicate debug noise while preserving `strict=false` fallback behavior. Fixes #82930. (#82933) Thanks @galiniliev. diff --git a/src/cli/cron-cli/register.cron-simple.test.ts b/src/cli/cron-cli/register.cron-simple.test.ts new file mode 100644 index 00000000000..261cfee1adf --- /dev/null +++ b/src/cli/cron-cli/register.cron-simple.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CronJob } from "../../cron/types.js"; +import type { GatewayRpcOpts } from "../gateway-rpc.js"; + +const callGatewayFromCli = vi.fn(); + +vi.mock("../gateway-rpc.js", async () => { + const actual = await vi.importActual("../gateway-rpc.js"); + return { + ...actual, + callGatewayFromCli: (...args: Parameters) => + callGatewayFromCli(...args), + }; +}); + +const { loadCronJobForShow } = await import("./register.cron-simple.js"); + +const opts: GatewayRpcOpts = {} as GatewayRpcOpts; + +describe("loadCronJobForShow pagination guard (regression for #83856)", () => { + beforeEach(() => { + callGatewayFromCli.mockReset(); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("throws when nextOffset fails to advance", async () => { + callGatewayFromCli.mockResolvedValue({ + jobs: [], + hasMore: true, + nextOffset: 0, + }); + await expect(loadCronJobForShow(opts, "missing")).rejects.toThrow(/pagination did not advance/); + expect(callGatewayFromCli).toHaveBeenCalledTimes(1); + }); + + it("throws when pagination exceeds the max page count", async () => { + let nextOffset = 0; + callGatewayFromCli.mockImplementation(async () => { + nextOffset += 1; + return { jobs: [], hasMore: true, nextOffset }; + }); + await expect(loadCronJobForShow(opts, "missing")).rejects.toThrow( + /pagination exceeded maximum pages/, + ); + expect(callGatewayFromCli.mock.calls.length).toBeGreaterThan(1); + expect(callGatewayFromCli.mock.calls.length).toBeLessThanOrEqual(50); + }); + + it("returns the job when found on a later page", async () => { + const job: CronJob = { id: "abc", name: "wanted" } as unknown as CronJob; + callGatewayFromCli + .mockResolvedValueOnce({ jobs: [], hasMore: true, nextOffset: 200 }) + .mockResolvedValueOnce({ jobs: [job], hasMore: false, nextOffset: null }); + const result = await loadCronJobForShow(opts, "wanted"); + expect(result.job?.id).toBe("abc"); + expect(callGatewayFromCli).toHaveBeenCalledTimes(2); + }); + + it("returns empty result when pagination terminates without a match", async () => { + callGatewayFromCli.mockResolvedValueOnce({ + jobs: [], + hasMore: false, + nextOffset: null, + }); + const result = await loadCronJobForShow(opts, "missing"); + expect(result.job).toBeUndefined(); + }); +}); diff --git a/src/cli/cron-cli/register.cron-simple.ts b/src/cli/cron-cli/register.cron-simple.ts index 82d9e100423..7cc65146584 100644 --- a/src/cli/cron-cli/register.cron-simple.ts +++ b/src/cli/cron-cli/register.cron-simple.ts @@ -15,6 +15,7 @@ import { } from "./shared.js"; const CRON_SHOW_PAGE_SIZE = 200; +const CRON_SHOW_LOOKUP_MAX_PAGES = 50; const CRON_RUN_WAIT_TIMEOUT_DEFAULT = "10m"; const CRON_RUN_WAIT_POLL_INTERVAL_DEFAULT = "2s"; @@ -87,32 +88,36 @@ function findCronJobInPage(jobs: CronJob[], idOrName: string): CronJob | undefin ); } -async function loadCronJobForShow( +export async function loadCronJobForShow( opts: GatewayRpcOpts, idOrName: string, ): Promise<{ job?: CronJob; deliveryPreview?: CronDeliveryPreview }> { let offset = 0; - for (;;) { + for (let page = 0; page < CRON_SHOW_LOOKUP_MAX_PAGES; page += 1) { const res = await callGatewayFromCli("cron.list", opts, { includeDisabled: true, limit: CRON_SHOW_PAGE_SIZE, offset, }); - const page = res as { + const listed = res as { jobs?: CronJob[]; hasMore?: boolean; nextOffset?: number | null; }; - const jobs = page.jobs ?? []; + const jobs = listed.jobs ?? []; const job = findCronJobInPage(jobs, idOrName); if (job) { return { job, deliveryPreview: coerceCronDeliveryPreviews(res).get(job.id) }; } - if (!page.hasMore || typeof page.nextOffset !== "number") { + if (!listed.hasMore || typeof listed.nextOffset !== "number") { return {}; } - offset = page.nextOffset; + if (listed.nextOffset <= offset) { + throw new Error("cron.list pagination did not advance while looking up cron job"); + } + offset = listed.nextOffset; } + throw new Error("cron.list pagination exceeded maximum pages while looking up cron job"); } function registerCronToggleCommand(params: {