mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix(cron): guard against year-rollback in croner nextRun (#30777)
* fix(cron): guard against year-rollback in croner nextRun Croner can return a past-year timestamp for some timezone/date combinations (e.g. Asia/Shanghai). When nextRun returns a value at or before nowMs, retry from the next whole second and, if still stale, from midnight-tomorrow UTC before giving up. Closes #30351 * googlechat: guard API calls with SSRF-safe fetch * test: fix hoisted plugin context mock setup --------- Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import crypto from "node:crypto";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import { getGoogleChatAccessToken } from "./auth.js";
|
||||
import type { GoogleChatReaction } from "./types.js";
|
||||
@@ -19,19 +20,27 @@ async function fetchJson<T>(
|
||||
init: RequestInit,
|
||||
): Promise<T> {
|
||||
const token = await getGoogleChatAccessToken(account);
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
...headersToObject(init.headers),
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
const { response: res, release } = await fetchWithSsrFGuard({
|
||||
url,
|
||||
init: {
|
||||
...init,
|
||||
headers: {
|
||||
...headersToObject(init.headers),
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
auditContext: "googlechat.api.json",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`);
|
||||
try {
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
async function fetchOk(
|
||||
@@ -40,16 +49,24 @@ async function fetchOk(
|
||||
init: RequestInit,
|
||||
): Promise<void> {
|
||||
const token = await getGoogleChatAccessToken(account);
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
...headersToObject(init.headers),
|
||||
Authorization: `Bearer ${token}`,
|
||||
const { response: res, release } = await fetchWithSsrFGuard({
|
||||
url,
|
||||
init: {
|
||||
...init,
|
||||
headers: {
|
||||
...headersToObject(init.headers),
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
auditContext: "googlechat.api.ok",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`);
|
||||
try {
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`);
|
||||
}
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,51 +77,59 @@ async function fetchBuffer(
|
||||
options?: { maxBytes?: number },
|
||||
): Promise<{ buffer: Buffer; contentType?: string }> {
|
||||
const token = await getGoogleChatAccessToken(account);
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
...headersToObject(init?.headers),
|
||||
Authorization: `Bearer ${token}`,
|
||||
const { response: res, release } = await fetchWithSsrFGuard({
|
||||
url,
|
||||
init: {
|
||||
...init,
|
||||
headers: {
|
||||
...headersToObject(init?.headers),
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
auditContext: "googlechat.api.buffer",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`);
|
||||
}
|
||||
const maxBytes = options?.maxBytes;
|
||||
const lengthHeader = res.headers.get("content-length");
|
||||
if (maxBytes && lengthHeader) {
|
||||
const length = Number(lengthHeader);
|
||||
if (Number.isFinite(length) && length > maxBytes) {
|
||||
throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`);
|
||||
try {
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`);
|
||||
}
|
||||
}
|
||||
if (!maxBytes || !res.body) {
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
const maxBytes = options?.maxBytes;
|
||||
const lengthHeader = res.headers.get("content-length");
|
||||
if (maxBytes && lengthHeader) {
|
||||
const length = Number(lengthHeader);
|
||||
if (Number.isFinite(length) && length > maxBytes) {
|
||||
throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`);
|
||||
}
|
||||
}
|
||||
if (!maxBytes || !res.body) {
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
const contentType = res.headers.get("content-type") ?? undefined;
|
||||
return { buffer, contentType };
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
total += value.length;
|
||||
if (total > maxBytes) {
|
||||
await reader.cancel();
|
||||
throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`);
|
||||
}
|
||||
chunks.push(Buffer.from(value));
|
||||
}
|
||||
const buffer = Buffer.concat(chunks, total);
|
||||
const contentType = res.headers.get("content-type") ?? undefined;
|
||||
return { buffer, contentType };
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
total += value.length;
|
||||
if (total > maxBytes) {
|
||||
await reader.cancel();
|
||||
throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`);
|
||||
}
|
||||
chunks.push(Buffer.from(value));
|
||||
}
|
||||
const buffer = Buffer.concat(chunks, total);
|
||||
const contentType = res.headers.get("content-type") ?? undefined;
|
||||
return { buffer, contentType };
|
||||
}
|
||||
|
||||
export async function sendGoogleChatMessage(params: {
|
||||
@@ -185,24 +210,32 @@ export async function uploadGoogleChatAttachment(params: {
|
||||
|
||||
const token = await getGoogleChatAccessToken(account);
|
||||
const url = `${CHAT_UPLOAD_BASE}/${space}/attachments:upload?uploadType=multipart`;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": `multipart/related; boundary=${boundary}`,
|
||||
const { response: res, release } = await fetchWithSsrFGuard({
|
||||
url,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": `multipart/related; boundary=${boundary}`,
|
||||
},
|
||||
body,
|
||||
},
|
||||
body,
|
||||
auditContext: "googlechat.upload",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`Google Chat upload ${res.status}: ${text || res.statusText}`);
|
||||
try {
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`Google Chat upload ${res.status}: ${text || res.statusText}`);
|
||||
}
|
||||
const payload = (await res.json()) as {
|
||||
attachmentDataRef?: { attachmentUploadToken?: string };
|
||||
};
|
||||
return {
|
||||
attachmentUploadToken: payload.attachmentDataRef?.attachmentUploadToken,
|
||||
};
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
const payload = (await res.json()) as {
|
||||
attachmentDataRef?: { attachmentUploadToken?: string };
|
||||
};
|
||||
return {
|
||||
attachmentUploadToken: payload.attachmentDataRef?.attachmentUploadToken,
|
||||
};
|
||||
}
|
||||
|
||||
export async function downloadGoogleChatMedia(params: {
|
||||
|
||||
@@ -6,7 +6,7 @@ const resolvePluginToolsMock = vi.fn((params?: unknown) => {
|
||||
});
|
||||
|
||||
vi.mock("../plugins/tools.js", () => ({
|
||||
resolvePluginTools: (params: unknown) => resolvePluginToolsMock(params),
|
||||
resolvePluginTools: resolvePluginToolsMock,
|
||||
}));
|
||||
|
||||
import { createOpenClawTools } from "./openclaw-tools.js";
|
||||
|
||||
@@ -73,6 +73,16 @@ describe("cron schedule", () => {
|
||||
expect(next).toBe(anchor + 30_000);
|
||||
});
|
||||
|
||||
it("never returns a past timestamp for Asia/Shanghai daily schedule (#30351)", () => {
|
||||
const nowMs = Date.parse("2026-03-01T00:00:00.000Z");
|
||||
const next = computeNextRunAtMs(
|
||||
{ kind: "cron", expr: "0 8 * * *", tz: "Asia/Shanghai" },
|
||||
nowMs,
|
||||
);
|
||||
expect(next).toBeDefined();
|
||||
expect(next!).toBeGreaterThan(nowMs);
|
||||
});
|
||||
|
||||
describe("cron with specific seconds (6-field pattern)", () => {
|
||||
// Pattern: fire at exactly second 0 of minute 0 of hour 12 every day
|
||||
const dailyNoon = { kind: "cron" as const, expr: "0 0 12 * * *", tz: "UTC" };
|
||||
|
||||
@@ -54,25 +54,39 @@ export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): numbe
|
||||
timezone: resolveCronTimezone(schedule.tz),
|
||||
catch: false,
|
||||
});
|
||||
const next = cron.nextRun(new Date(nowMs));
|
||||
let next = cron.nextRun(new Date(nowMs));
|
||||
if (!next) {
|
||||
return undefined;
|
||||
}
|
||||
const nextMs = next.getTime();
|
||||
let nextMs = next.getTime();
|
||||
if (!Number.isFinite(nextMs)) {
|
||||
return undefined;
|
||||
}
|
||||
if (nextMs > nowMs) {
|
||||
return nextMs;
|
||||
}
|
||||
|
||||
// Guard against same-second rescheduling loops: if croner returns
|
||||
// "now" (or an earlier instant), retry from the next whole second.
|
||||
const nextSecondMs = Math.floor(nowMs / 1000) * 1000 + 1000;
|
||||
const retry = cron.nextRun(new Date(nextSecondMs));
|
||||
if (!retry) {
|
||||
// Workaround for croner year-rollback bug: some timezone/date combinations
|
||||
// (e.g. Asia/Shanghai) cause nextRun to return a timestamp in a past year.
|
||||
// Retry from a later reference point when the returned time is not in the
|
||||
// future.
|
||||
if (nextMs <= nowMs) {
|
||||
const nextSecondMs = Math.floor(nowMs / 1000) * 1000 + 1000;
|
||||
const retry = cron.nextRun(new Date(nextSecondMs));
|
||||
if (retry) {
|
||||
const retryMs = retry.getTime();
|
||||
if (Number.isFinite(retryMs) && retryMs > nowMs) {
|
||||
return retryMs;
|
||||
}
|
||||
}
|
||||
// Still in the past — try from start of tomorrow (UTC) as a broader reset.
|
||||
const tomorrowMs = new Date(nowMs).setUTCHours(24, 0, 0, 0);
|
||||
const retry2 = cron.nextRun(new Date(tomorrowMs));
|
||||
if (retry2) {
|
||||
const retry2Ms = retry2.getTime();
|
||||
if (Number.isFinite(retry2Ms) && retry2Ms > nowMs) {
|
||||
return retry2Ms;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const retryMs = retry.getTime();
|
||||
return Number.isFinite(retryMs) && retryMs > nowMs ? retryMs : undefined;
|
||||
|
||||
return nextMs;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user