diff --git a/CHANGELOG.md b/CHANGELOG.md index f987feeec35..9754f58a9e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Doctor/cron: keep `openclaw doctor` read-only in non-interactive mode unless repair is explicitly requested, and migrate legacy `notify: true` + `delivery.mode="none"` cron jobs to `cron.webhook` instead of preserving a non-webhook `delivery.to`. - macOS/LaunchAgent install: tighten LaunchAgent directory and plist permissions during install so launchd bootstrap does not fail when the target home path or generated plist inherited group/world-writable modes. ## 2026.3.8 diff --git a/src/commands/doctor-cron.test.ts b/src/commands/doctor-cron.test.ts index 8c9faf0e24d..84543c34f19 100644 --- a/src/commands/doctor-cron.test.ts +++ b/src/commands/doctor-cron.test.ts @@ -68,7 +68,6 @@ describe("maybeRepairLegacyCronStore", () => { await maybeRepairLegacyCronStore({ cfg, - options: {}, prompter: makePrompter(true), }); @@ -142,7 +141,6 @@ describe("maybeRepairLegacyCronStore", () => { webhook: "https://example.invalid/cron-finished", }, }, - options: { nonInteractive: true }, prompter: makePrompter(true), }); @@ -155,4 +153,107 @@ describe("maybeRepairLegacyCronStore", () => { "Doctor warnings", ); }); + + it("prefers cron.webhook over legacy delivery.to when notify fallback job is in mode none", async () => { + const storePath = await makeTempStorePath(); + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile( + storePath, + JSON.stringify( + { + version: 1, + jobs: [ + { + id: "notify-none", + name: "Notify none", + notify: true, + createdAtMs: Date.parse("2026-02-01T00:00:00.000Z"), + updatedAtMs: Date.parse("2026-02-02T00:00:00.000Z"), + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { kind: "agentTurn", message: "Status" }, + delivery: { mode: "none", to: "123" }, + state: {}, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + await maybeRepairLegacyCronStore({ + cfg: { + cron: { + store: storePath, + webhook: "https://example.invalid/cron-finished", + }, + }, + prompter: makePrompter(true), + }); + + const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")) as { + jobs: Array>; + }; + expect(persisted.jobs[0]?.notify).toBeUndefined(); + expect(persisted.jobs[0]?.delivery).toMatchObject({ + mode: "webhook", + to: "https://example.invalid/cron-finished", + }); + }); + + it("does not repair legacy cron storage in headless mode without explicit repair", async () => { + const storePath = await makeTempStorePath(); + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile( + storePath, + JSON.stringify( + { + version: 1, + jobs: [ + { + jobId: "legacy-job", + name: "Legacy job", + notify: true, + createdAtMs: Date.parse("2026-02-01T00:00:00.000Z"), + updatedAtMs: Date.parse("2026-02-02T00:00:00.000Z"), + schedule: { kind: "cron", cron: "0 7 * * *", tz: "UTC" }, + payload: { + kind: "systemEvent", + text: "Morning brief", + }, + state: {}, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); + + await maybeRepairLegacyCronStore({ + cfg: { + cron: { + store: storePath, + webhook: "https://example.invalid/cron-finished", + }, + }, + prompter: makePrompter(false), + }); + + const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")) as { + jobs: Array>; + }; + expect(persisted.jobs[0]?.jobId).toBe("legacy-job"); + expect(persisted.jobs[0]?.notify).toBe(true); + expect(noteSpy).not.toHaveBeenCalledWith( + expect.stringContaining("Cron store normalized"), + "Doctor changes", + ); + }); }); diff --git a/src/commands/doctor-cron.ts b/src/commands/doctor-cron.ts index 3dc6275e800..e0a1161bd73 100644 --- a/src/commands/doctor-cron.ts +++ b/src/commands/doctor-cron.ts @@ -5,7 +5,7 @@ import { resolveCronStorePath, loadCronStore, saveCronStore } from "../cron/stor import type { CronJob } from "../cron/types.js"; import { note } from "../terminal/note.js"; import { shortenHomePath } from "../utils.js"; -import type { DoctorPrompter, DoctorOptions } from "./doctor-prompter.js"; +import type { DoctorPrompter } from "./doctor-prompter.js"; type CronDoctorOutcome = { changed: boolean; @@ -96,7 +96,7 @@ function migrateLegacyNotifyFallback(params: { raw.delivery = { ...delivery, mode: "webhook", - to: to ?? params.legacyWebhook, + to: mode === "none" ? params.legacyWebhook : (to ?? params.legacyWebhook), }; delete raw.notify; changed = true; @@ -120,7 +120,6 @@ function migrateLegacyNotifyFallback(params: { export async function maybeRepairLegacyCronStore(params: { cfg: OpenClawConfig; - options: DoctorOptions; prompter: Pick; }) { const storePath = resolveCronStorePath(params.cfg.cron?.store); @@ -152,13 +151,10 @@ export async function maybeRepairLegacyCronStore(params: { "Cron", ); - const shouldRepair = - params.options.nonInteractive === true - ? true - : await params.prompter.confirm({ - message: "Repair legacy cron jobs now?", - initialValue: true, - }); + const shouldRepair = await params.prompter.confirm({ + message: "Repair legacy cron jobs now?", + initialValue: true, + }); if (!shouldRepair) { return; } diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index bdde2781ff9..c4698dc7772 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -223,7 +223,6 @@ export async function doctorCommand( await noteSessionLockHealth({ shouldRepair: prompter.shouldRepair }); await maybeRepairLegacyCronStore({ cfg, - options, prompter, });