diff --git a/src/crestodian/rescue-message.test.ts b/src/crestodian/rescue-message.test.ts index 680e2538537..efc5a8c0872 100644 --- a/src/crestodian/rescue-message.test.ts +++ b/src/crestodian/rescue-message.test.ts @@ -297,6 +297,52 @@ describe("Crestodian rescue message", () => { expect(audit.details?.senderId).toBe("user:owner"); }); + it("does not queue persistent rescue approval when expiry would exceed the Date range", async () => { + const tempDir = await makeStateDir("overflow-expiry-"); + vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); + vi.useFakeTimers(); + vi.setSystemTime(new Date(8_640_000_000_000_000)); + try { + const cfg: OpenClawConfig = { crestodian: { rescue: { enabled: true } } }; + + await expect( + runRescue("/crestodian restart gateway", cfg, commandContext()), + ).resolves.toContain("expiry clock is invalid"); + + await expect(fs.readdir(path.join(tempDir, "crestodian", "rescue-pending"))).rejects.toThrow( + /ENOENT/, + ); + } finally { + vi.useRealTimers(); + } + }); + + it("rejects pending rescue approvals with invalid persisted expiry", async () => { + const tempDir = await makeStateDir("invalid-expiry-"); + vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); + const cfg: OpenClawConfig = { crestodian: { rescue: { enabled: true } } }; + const deps = { runGatewayRestart: vi.fn(async () => {}) }; + + await expect( + runRescue("/crestodian restart gateway", cfg, commandContext(), deps), + ).resolves.toContain("Reply /crestodian yes to apply"); + const pendingDir = path.join(tempDir, "crestodian", "rescue-pending"); + const [pendingFile] = await fs.readdir(pendingDir); + if (!pendingFile) { + throw new Error("expected pending rescue file"); + } + const pendingPath = path.join(pendingDir, pendingFile); + const pending = JSON.parse(await fs.readFile(pendingPath, "utf8")) as { expiresAt?: string }; + pending.expiresAt = "not-a-date"; + await fs.writeFile(pendingPath, `${JSON.stringify(pending, null, 2)}\n`, "utf8"); + + await expect(runRescue("/crestodian yes", cfg, commandContext(), deps)).resolves.toBe( + "No pending Crestodian rescue change is waiting for approval.", + ); + expect(deps.runGatewayRestart).not.toHaveBeenCalled(); + await expect(fs.stat(pendingPath)).rejects.toThrow(/ENOENT/); + }); + it("queues and applies agent creation through conversational approval", async () => { const tempDir = await makeStateDir("agent-"); vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); diff --git a/src/crestodian/rescue-message.ts b/src/crestodian/rescue-message.ts index 4402eb70b62..0caa04e2f32 100644 --- a/src/crestodian/rescue-message.ts +++ b/src/crestodian/rescue-message.ts @@ -6,6 +6,7 @@ import { resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { tryReadJson, writeJson } from "../infra/json-files.js"; import type { RuntimeEnv } from "../runtime.js"; +import { asDateTimestampMs, resolveExpiresAtMsFromDurationMs } from "../shared/number-coercion.js"; import { executeCrestodianOperation, formatCrestodianPersistentPlan, @@ -86,7 +87,9 @@ async function readPending( if (!parsed) { return null; } - if (Date.parse(parsed.expiresAt) <= now.getTime()) { + const expiresAtMs = asDateTimestampMs(Date.parse(parsed.expiresAt)); + const nowMs = asDateTimestampMs(now.getTime()); + if (expiresAtMs === undefined || nowMs === undefined || expiresAtMs <= nowMs) { await fs.rm(pendingPath, { force: true }); return null; } @@ -182,11 +185,18 @@ export async function runCrestodianRescueMessage( } if (isPersistentCrestodianOperation(operation)) { const now = new Date(); - const expiresAt = new Date(now.getTime() + policy.pendingTtlMinutes * 60_000); + const nowMs = asDateTimestampMs(now.getTime()); + const expiresAtMs = + nowMs === undefined + ? undefined + : resolveExpiresAtMsFromDurationMs(policy.pendingTtlMinutes * 60_000, { nowMs }); + if (expiresAtMs === undefined) { + return "Crestodian rescue could not create a pending approval because the expiry clock is invalid."; + } await writePending(pendingPath, { id: randomUUID(), createdAt: now.toISOString(), - expiresAt: expiresAt.toISOString(), + expiresAt: new Date(expiresAtMs).toISOString(), operation, auditDetails: buildAuditDetails(input), });