fix(commitments): reject calendar-invalid due timestamps (#106236)

* fix(commitments): reject calendar-invalid due timestamps

* test(commitments): fix calendar validation clock

* fix(commitments): preserve timestamp interpretation

* fix(commitments): preserve valid RFC 3339 dates

* chore(commitments): remove contributor changelog edit

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Wynne668
2026-07-13 17:08:53 -07:00
committed by GitHub
co-authored by Peter Steinberger
parent bdd060a40c
commit 78305b65b1
4 changed files with 62 additions and 3 deletions
+49
View File
@@ -172,6 +172,55 @@ describe("commitment extraction", () => {
expect(valid.map((entry) => entry.candidate.dedupeKey)).toEqual(["interview:2026-04-30"]);
});
it("rejects calendar-invalid due timestamps", () => {
const valid = validateCommitmentCandidates({
items: [item()],
result: {
candidates: [
candidate({
dedupeKey: "invalid-earliest",
dueWindow: { earliest: "2026-04-31T17:00:00.000Z" },
}),
candidate({
dedupeKey: "invalid-latest",
dueWindow: {
earliest: "2026-04-30T17:00:00.000Z",
latest: "2026-04-31T23:00:00.000Z",
},
}),
],
},
nowMs,
});
const validCandidate = expectSingleValidCandidate(valid);
expect(validCandidate.candidate.dedupeKey).toBe("invalid-latest");
expect(validCandidate.earliestMs).toBe(Date.parse("2026-04-30T17:00:00.000Z"));
expect(validCandidate.latestMs).toBe(validCandidate.earliestMs + 12 * 60 * 60 * 1000);
});
it("accepts calendar-valid leap-day, offset, and lowercase RFC 3339 timestamps", () => {
const valid = validateCommitmentCandidates({
items: [item()],
result: {
candidates: [
candidate({
dedupeKey: "leap-day-offset",
dueWindow: {
earliest: "2028-02-29t09:00:00-08:00",
latest: "2028-02-29t20:00:00z",
},
}),
],
},
nowMs,
});
const validCandidate = expectSingleValidCandidate(valid);
expect(validCandidate.earliestMs).toBe(Date.parse("2028-02-29t09:00:00-08:00"));
expect(validCandidate.latestMs).toBe(Date.parse("2028-02-29t20:00:00z"));
});
it("clamps inferred due time to at least one heartbeat interval after write time", () => {
const writeMs = nowMs + 5_000;
const valid = validateCommitmentCandidates({
+6 -1
View File
@@ -6,6 +6,7 @@ import {
import { normalizeOptionalString as asString } from "@openclaw/normalization-core/string-coerce";
import { resolveAgentConfig } from "../agents/agent-scope.js";
import type { OpenClawConfig } from "../config/config.js";
import { parseAbsoluteTimeMs } from "../cron/parse.js";
import { resolveHeartbeatIntervalMs } from "../infra/heartbeat-summary.js";
import { isRecord } from "../utils.js";
import { resolveCommitmentsConfig } from "./config.js";
@@ -252,7 +253,11 @@ function parseDueMs(raw: string | undefined): number | undefined {
return undefined;
}
const parsed = Date.parse(raw);
return Number.isFinite(parsed) ? parsed : undefined;
if (!Number.isFinite(parsed) || parseAbsoluteTimeMs(raw) === null) {
return undefined;
}
// The cron parser validates the ISO shape and calendar; preserve Date.parse's existing interpretation.
return parsed;
}
function resolveMinimumDueMs(params: {
+5
View File
@@ -55,6 +55,11 @@ describe("parseAbsoluteTimeMs", () => {
expect(parseAbsoluteTimeMs("2024-01-15T10:30:00z")).toBe(expected);
});
it("parses RFC 3339 lowercase t and z separators", () => {
const expected = Date.parse("2024-01-15T10:30:00Z");
expect(parseAbsoluteTimeMs("2024-01-15t10:30:00z")).toBe(expected);
});
it("parses datetime with milliseconds and Z", () => {
const expected = Date.parse("2024-01-15T10:30:45.123Z");
expect(parseAbsoluteTimeMs("2024-01-15T10:30:45.123Z")).toBe(expected);
+2 -2
View File
@@ -3,9 +3,9 @@ import { parseStrictPositiveInteger } from "../infra/parse-finite-number.js";
const ISO_TZ_RE = /(Z|[+-]\d{2}:?\d{2})$/i;
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const ISO_DATE_TIME_RE = /^\d{4}-\d{2}-\d{2}T/;
const ISO_DATE_TIME_RE = /^\d{4}-\d{2}-\d{2}[Tt]/;
const ISO_ABSOLUTE_RE =
/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(\.\d+)?)?(?:[Zz]|[+-]\d{2}:?\d{2})?)?$/;
/^(\d{4})-(\d{2})-(\d{2})(?:[Tt](\d{2}):(\d{2})(?::(\d{2})(\.\d+)?)?(?:[Zz]|[+-]\d{2}:?\d{2})?)?$/;
function normalizeUtcIso(raw: string) {
if (ISO_TZ_RE.test(raw)) {