From 2c4ebf77f35f466fb5cfb09e06a9532953f0b03d Mon Sep 17 00:00:00 2001 From: Marcus Castro Date: Tue, 24 Feb 2026 11:12:12 -0300 Subject: [PATCH] fix(config): coerce numeric meta.lastTouchedAt to ISO string --- .../config.meta-timestamp-coercion.test.ts | 70 +++++++++++++++++++ src/config/zod-schema.ts | 16 ++++- 2 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 src/config/config.meta-timestamp-coercion.test.ts diff --git a/src/config/config.meta-timestamp-coercion.test.ts b/src/config/config.meta-timestamp-coercion.test.ts new file mode 100644 index 000000000..d87b16b45 --- /dev/null +++ b/src/config/config.meta-timestamp-coercion.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from "vitest"; + +describe("meta.lastTouchedAt numeric timestamp coercion", () => { + it("accepts a numeric Unix timestamp and coerces it to an ISO string", async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const numericTimestamp = 1770394758161; + const res = validateConfigObject({ + meta: { + lastTouchedAt: numericTimestamp, + }, + }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(typeof res.config.meta?.lastTouchedAt).toBe("string"); + expect(res.config.meta?.lastTouchedAt).toBe(new Date(numericTimestamp).toISOString()); + } + }); + + it("still accepts a string ISO timestamp unchanged", async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const isoTimestamp = "2026-02-07T01:39:18.161Z"; + const res = validateConfigObject({ + meta: { + lastTouchedAt: isoTimestamp, + }, + }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.config.meta?.lastTouchedAt).toBe(isoTimestamp); + } + }); + + it("rejects out-of-range numeric timestamps without throwing", async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + meta: { + lastTouchedAt: 1e20, + }, + }); + expect(res.ok).toBe(false); + }); + + it("passes non-date strings through unchanged (backwards-compatible)", async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + meta: { + lastTouchedAt: "not-a-date", + }, + }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.config.meta?.lastTouchedAt).toBe("not-a-date"); + } + }); + + it("accepts meta with only lastTouchedVersion (no lastTouchedAt)", async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + meta: { + lastTouchedVersion: "2026.2.6", + }, + }); + expect(res.ok).toBe(true); + }); +}); diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 70b528f90..dd6b1b1c1 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -129,7 +129,21 @@ export const OpenClawSchema = z meta: z .object({ lastTouchedVersion: z.string().optional(), - lastTouchedAt: z.string().optional(), + // Accept any string unchanged (backwards-compatible) and coerce numeric Unix + // timestamps to ISO strings (agent file edits may write Date.now()). + lastTouchedAt: z + .union([ + z.string(), + z.number().transform((n, ctx) => { + const d = new Date(n); + if (Number.isNaN(d.getTime())) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Invalid timestamp" }); + return z.NEVER; + } + return d.toISOString(); + }), + ]) + .optional(), }) .strict() .optional(),