// Cron flat-parameter tests cover model-friendly shorthand recovery before // gateway cron RPC dispatch. import { beforeEach, describe, expect, it, vi } from "vitest"; const { callGatewayToolMock } = vi.hoisted(() => ({ callGatewayToolMock: vi.fn(), })); vi.mock("../agent-scope.js", async () => { const actual = await vi.importActual("../agent-scope.js"); return { ...actual, resolveSessionAgentId: actual.resolveSessionAgentId, }; }); import { getToolTerminalPresentation } from "../tool-terminal-presentation.js"; import { createCronTool } from "./cron-tool.js"; describe("cron tool flat-params", () => { beforeEach(() => { callGatewayToolMock.mockClear(); callGatewayToolMock.mockResolvedValue({ ok: true }); }); function firstGatewayToolCall(): [string, unknown, TParams] { return gatewayToolCall(0); } function gatewayToolCall(index: number): [string, unknown, TParams] { const call = callGatewayToolMock.mock.calls[index]; if (!call) { throw new Error(`expected callGatewayTool call ${index + 1}`); } return call as [string, unknown, TParams]; } it("presents read-only cron metadata without job content", () => { const tool = createCronTool(); const terminalPresentation = getToolTerminalPresentation(tool); if (!terminalPresentation) { throw new Error("expected cron terminal presentation"); } expect( terminalPresentation( { action: "list" }, { content: [], details: { total: 2, jobs: [ { id: "one", name: "private reminder", payload: { text: "secret" } }, { id: "two", name: "another reminder" }, ], }, }, ), ).toEqual({ text: "Automations listed.\nCount: 2" }); expect( terminalPresentation( { action: "list" }, { content: [], details: { total: 250, jobs: [{ id: "one" }, { id: "two" }], }, }, ), ).toEqual({ text: "Automations listed.\nCount: 250" }); expect( terminalPresentation( { action: "add" }, { content: [], details: { id: "three", name: "private reminder" } }, ), ).toBeUndefined(); }); it("binds recovered agentTurn jobs to the creating conversation by default", async () => { const tool = createCronTool( { agentSessionKey: "agent:main:discord:channel:ops" }, { callGatewayTool: callGatewayToolMock }, ); await tool.execute("call-flat-session-key", { action: "add", sessionKey: "agent:main:telegram:group:-100123:topic:99", schedule: { kind: "at", at: new Date(123).toISOString() }, message: "do stuff", }); const [method, _gatewayOpts, params] = firstGatewayToolCall<{ sessionKey?: string; sessionTarget?: string; }>(); expect(method).toBe("cron.add"); expect(params.sessionTarget).toBe("current"); expect(params.sessionKey).toBe("agent:main:discord:channel:ops"); }); it("recovers flat cron schedule shorthand for add", async () => { const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); await tool.execute("call-flat-cron-add", { action: "add", name: "hourly report", cron: "0 * * * *", tz: "UTC", staggerMs: 5000, message: "send report", }); const [method, _gatewayOpts, params] = firstGatewayToolCall<{ schedule?: unknown; payload?: unknown; }>(); expect(method).toBe("cron.add"); expect(params.schedule).toEqual({ kind: "cron", expr: "0 * * * *", tz: "UTC", staggerMs: 5000, }); expect(params.payload).toEqual({ kind: "agentTurn", message: "send report", }); }); it("recovers flat script payload fields before agent-turn hints", async () => { const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); await tool.execute("call-flat-script-add", { action: "add", name: "queue watcher", everyMs: 60_000, script: "return { notify: 'changed' }", timeoutSeconds: 30, toolBudget: 12, }); const [method, _gatewayOpts, params] = firstGatewayToolCall<{ payload?: unknown; }>(); expect(method).toBe("cron.add"); expect(params.payload).toEqual({ kind: "script", script: "return { notify: 'changed' }", timeoutSeconds: 30, toolBudget: 12, }); }); it("recovers a flat trigger when adding a job", async () => { const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); await tool.execute("call-flat-trigger-add", { action: "add", name: "watcher", schedule: { kind: "every", everyMs: 60_000 }, message: "report the change", trigger: { script: "json({ fire: false })", once: true }, }); const [method, _gatewayOpts, params] = firstGatewayToolCall<{ trigger?: { script?: string; once?: boolean }; }>(); expect(method).toBe("cron.add"); expect(params.trigger).toEqual({ script: "json({ fire: false })", once: true }); }); it("rejects flat on-exit schedule shorthand for add", async () => { const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); await expect( tool.execute("call-flat-onexit-add", { action: "add", name: "rebuild on exit", kind: "on-exit", command: "pnpm build", cwd: "/repo", message: "rebuilt", }), ).rejects.toThrow("automation on-exit schedules cannot be created or edited"); expect(callGatewayToolMock).not.toHaveBeenCalled(); }); it("rejects flat command schedule shorthand for add", async () => { const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); await expect( tool.execute("call-flat-onexit-infer", { action: "add", name: "watch build", command: "make", message: "done", }), ).rejects.toThrow("automation on-exit schedules cannot be created or edited"); expect(callGatewayToolMock).not.toHaveBeenCalled(); }); it("rejects flat on-exit schedule shorthand for update", async () => { const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); await expect( tool.execute("call-flat-onexit-update", { action: "update", jobId: "job-onexit", kind: "on-exit", command: "pnpm build", cwd: "/repo", }), ).rejects.toThrow("automation on-exit schedules cannot be created or edited"); expect(callGatewayToolMock).not.toHaveBeenCalled(); }); it("rejects flat command schedule shorthand for update", async () => { const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); await expect( tool.execute("call-flat-onexit-update-infer", { action: "update", jobId: "job-infer", command: "make", }), ).rejects.toThrow("automation on-exit schedules cannot be created or edited"); expect(callGatewayToolMock).not.toHaveBeenCalled(); }); it("passes local cron wall-clock expression and timezone through add", async () => { const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); await tool.execute("call-local-cron-add", { action: "add", name: "shanghai reminder", cron: "0 18 * * *", tz: "Asia/Shanghai", message: "send reminder", }); const [method, _gatewayOpts, params] = firstGatewayToolCall<{ schedule?: unknown; }>(); expect(method).toBe("cron.add"); expect(params.schedule).toEqual({ kind: "cron", expr: "0 18 * * *", tz: "Asia/Shanghai", }); }); it("leaves out-of-range flat atMs for gateway validation", async () => { // The gateway owns final schedule validation; flat recovery should preserve // the supplied value instead of silently coercing an invalid date. const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); const invalidAtMs = 8_640_000_000_000_001; await tool.execute("call-flat-invalid-atms-add", { action: "add", name: "bad date", atMs: invalidAtMs, message: "send reminder", }); const [method, _gatewayOpts, params] = firstGatewayToolCall<{ schedule?: { at?: unknown; kind?: unknown }; }>(); expect(method).toBe("cron.add"); expect(params.schedule).toEqual({ kind: "at", at: invalidAtMs }); }); it("recovers flat cron schedule shorthand for update", async () => { const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); await tool.execute("call-flat-cron-update", { action: "update", jobId: "job-123", cron: "15 8 * * 1-5", tz: "America/Los_Angeles", staggerMs: 30_000, }); const [method, _gatewayOpts, params] = firstGatewayToolCall<{ id?: string; patch?: { schedule?: unknown }; }>(); expect(method).toBe("cron.update"); expect(params.id).toBe("job-123"); expect(params.patch?.schedule).toEqual({ kind: "cron", expr: "15 8 * * 1-5", tz: "America/Los_Angeles", staggerMs: 30_000, }); }); it("recovers flat script payload fields for update", async () => { const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); await tool.execute("call-flat-script-update", { action: "update", jobId: "job-script", script: "return { wake: 'now' }", timeoutSeconds: 45, toolBudget: 8, }); const [method, _gatewayOpts, params] = firstGatewayToolCall<{ id?: string; patch?: { payload?: unknown }; }>(); expect(method).toBe("cron.update"); expect(params).toEqual({ id: "job-script", patch: { payload: { kind: "script", script: "return { wake: 'now' }", timeoutSeconds: 45, toolBudget: 8, }, }, }); }); it("recovers a flat trigger when updating a job", async () => { callGatewayToolMock .mockResolvedValueOnce({ id: "job-trigger", configRevision: "sha256:flat-trigger-update", trigger: null, payload: { kind: "systemEvent", text: "before" }, }) .mockResolvedValueOnce({ ok: true }); const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); await tool.execute("call-flat-trigger-update", { action: "update", jobId: "job-trigger", trigger: { script: "json({ fire: true })", once: false }, }); const [getMethod, _getGatewayOpts, getParams] = firstGatewayToolCall<{ id?: string }>(); expect(getMethod).toBe("cron.get"); expect(getParams).toEqual({ id: "job-trigger" }); const [method, _gatewayOpts, params] = gatewayToolCall<{ id?: string; expectedConfigRevision?: string; patch?: { trigger?: { script?: string; once?: boolean } }; }>(1); expect(method).toBe("cron.update"); expect(params).toEqual({ id: "job-trigger", expectedConfigRevision: "sha256:flat-trigger-update", patch: { trigger: { script: "json({ fire: true })", once: false } }, }); }); it("recovers a flat trigger clear when updating a job", async () => { callGatewayToolMock .mockResolvedValueOnce({ id: "job-trigger", configRevision: "sha256:flat-trigger-clear", trigger: { script: "json({ fire: false })", once: true }, payload: { kind: "systemEvent", text: "before" }, }) .mockResolvedValueOnce({ ok: true }); const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); await tool.execute("call-flat-trigger-clear", { action: "update", jobId: "job-trigger", trigger: null, }); const [getMethod, _getGatewayOpts, getParams] = firstGatewayToolCall<{ id?: string }>(); expect(getMethod).toBe("cron.get"); expect(getParams).toEqual({ id: "job-trigger" }); const [method, _gatewayOpts, params] = gatewayToolCall<{ id?: string; expectedConfigRevision?: string; patch?: { trigger?: null }; }>(1); expect(method).toBe("cron.update"); expect(params).toEqual({ id: "job-trigger", expectedConfigRevision: "sha256:flat-trigger-clear", patch: { trigger: null }, }); }); it("trims trailing whitespace from recognized job object keys (#95407)", async () => { const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); await tool.execute("call-trailing-space", { action: "add", job: { name: "Holiday Check-in", description: "Casual check-in", "schedule ": { kind: "cron", expr: "30 10,20 * * *", tz: "Europe/Madrid" }, "sessionTarget ": "isolated", "payload ": { kind: "agentTurn", message: "How's it going?" }, "enabled ": true, }, }); const [method, _gatewayOpts, params] = firstGatewayToolCall<{ name?: string; schedule?: unknown; sessionTarget?: string; payload?: unknown; enabled?: boolean; }>(); expect(method).toBe("cron.add"); expect(params.name).toBe("Holiday Check-in"); expect(params.schedule).toBeDefined(); expect(params.sessionTarget).toBe("isolated"); expect(params.payload).toBeDefined(); expect(params.enabled).toBe(true); expect(params).not.toHaveProperty("schedule "); expect(params).not.toHaveProperty("sessionTarget "); expect(params).not.toHaveProperty("payload "); expect(params).not.toHaveProperty("enabled "); }); it("trims trailing whitespace from recognized patch object keys (#95407)", async () => { const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); await tool.execute("call-patch-trailing-space", { action: "update", jobId: "job-123", job: { "schedule ": { kind: "cron", expr: "0 9 * * 1-5", tz: "America/New_York" }, "enabled ": false, }, }); const [method, _gatewayOpts, params] = firstGatewayToolCall<{ id?: string; patch?: { schedule?: unknown; enabled?: boolean }; }>(); expect(method).toBe("cron.update"); expect(params.id).toBe("job-123"); expect(params.patch?.schedule).toBeDefined(); expect((params.patch?.schedule as Record)?.kind).toBe("cron"); expect(params.patch?.enabled).toBe(false); expect(params.patch).not.toHaveProperty("schedule "); expect(params.patch).not.toHaveProperty("enabled "); }); it("does not trim unrecognized keys to prevent prototype pollution (#95407)", async () => { const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); await tool.execute("call-unsafe-keys", { action: "add", job: { name: "Safe trim", schedule: { kind: "cron", expr: "0 12 * * *", tz: "UTC" }, payload: { kind: "agentTurn", message: "work" }, // Non-recognized keys with trailing spaces should NOT be trimmed // (prevents "__proto__ " → "__proto__" style attacks) "__proto__ ": { malicious: true }, "constructor ": "should not be trimmed", }, }); const [method, _gatewayOpts, params] = firstGatewayToolCall>(); expect(method).toBe("cron.add"); // Non-recognized padded keys should remain as-is expect(params).toHaveProperty("__proto__ "); expect(params).toHaveProperty("constructor "); }); it("preserves padded duplicate when canonical key already exists (#95407)", async () => { // When both canonical and padded forms exist, the padded key is preserved // so strict gateway validation rejects the ambiguous input rather than // silently picking one value. const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); await tool.execute("call-duplicate-keys", { action: "add", job: { name: "Duplicate test", schedule: { kind: "cron", expr: "0 9 * * 1-5", tz: "UTC" }, // Both "schedule" and "schedule " exist — padded preserved for rejection "schedule ": { kind: "every", everyMs: 60000 }, payload: { kind: "agentTurn", message: "work" }, "enabled ": true, enabled: false, }, }); const [method, _gatewayOpts, params] = firstGatewayToolCall>(); expect(method).toBe("cron.add"); // Canonical key is untouched expect((params.schedule as Record)?.kind).toBe("cron"); expect(params.enabled).toBe(false); // Padded keys are preserved so gateway schema validation sees the conflict // and rejects with "unexpected property 'schedule '" instead of silently // accepting one of the two conflicting values. expect(params).toHaveProperty("schedule "); expect(params).toHaveProperty("enabled "); }); it("preserves normal keys without any whitespace", async () => { const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); await tool.execute("call-clean-keys", { action: "add", job: { name: "Clean keys", schedule: { kind: "cron", expr: "0 12 * * *", tz: "UTC" }, payload: { kind: "agentTurn", message: "test" }, enabled: true, description: "All keys should be preserved as-is", }, }); const [method, _gatewayOpts, params] = firstGatewayToolCall>(); expect(method).toBe("cron.add"); expect(params.name).toBe("Clean keys"); expect(params.schedule).toBeDefined(); expect(params.payload).toBeDefined(); expect(params.enabled).toBe(true); expect(params.description).toBe("All keys should be preserved as-is"); }); });