Skip to content

Commit 4691aab

Browse files
SidTakhoffman
andauthored
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 <[email protected]>
1 parent 6fc0787 commit 4691aab

File tree

4 files changed

+141
-84
lines changed

4 files changed

+141
-84
lines changed

extensions/googlechat/src/api.ts

Lines changed: 104 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import crypto from "node:crypto";
2+
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk";
23
import type { ResolvedGoogleChatAccount } from "./accounts.js";
34
import { getGoogleChatAccessToken } from "./auth.js";
45
import type { GoogleChatReaction } from "./types.js";
@@ -19,19 +20,27 @@ async function fetchJson<T>(
1920
init: RequestInit,
2021
): Promise<T> {
2122
const token = await getGoogleChatAccessToken(account);
22-
const res = await fetch(url, {
23-
...init,
24-
headers: {
25-
...headersToObject(init.headers),
26-
Authorization: `Bearer ${token}`,
27-
"Content-Type": "application/json",
23+
const { response: res, release } = await fetchWithSsrFGuard({
24+
url,
25+
init: {
26+
...init,
27+
headers: {
28+
...headersToObject(init.headers),
29+
Authorization: `Bearer ${token}`,
30+
"Content-Type": "application/json",
31+
},
2832
},
33+
auditContext: "googlechat.api.json",
2934
});
30-
if (!res.ok) {
31-
const text = await res.text().catch(() => "");
32-
throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`);
35+
try {
36+
if (!res.ok) {
37+
const text = await res.text().catch(() => "");
38+
throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`);
39+
}
40+
return (await res.json()) as T;
41+
} finally {
42+
await release();
3343
}
34-
return (await res.json()) as T;
3544
}
3645

3746
async function fetchOk(
@@ -40,16 +49,24 @@ async function fetchOk(
4049
init: RequestInit,
4150
): Promise<void> {
4251
const token = await getGoogleChatAccessToken(account);
43-
const res = await fetch(url, {
44-
...init,
45-
headers: {
46-
...headersToObject(init.headers),
47-
Authorization: `Bearer ${token}`,
52+
const { response: res, release } = await fetchWithSsrFGuard({
53+
url,
54+
init: {
55+
...init,
56+
headers: {
57+
...headersToObject(init.headers),
58+
Authorization: `Bearer ${token}`,
59+
},
4860
},
61+
auditContext: "googlechat.api.ok",
4962
});
50-
if (!res.ok) {
51-
const text = await res.text().catch(() => "");
52-
throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`);
63+
try {
64+
if (!res.ok) {
65+
const text = await res.text().catch(() => "");
66+
throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`);
67+
}
68+
} finally {
69+
await release();
5370
}
5471
}
5572

@@ -60,51 +77,59 @@ async function fetchBuffer(
6077
options?: { maxBytes?: number },
6178
): Promise<{ buffer: Buffer; contentType?: string }> {
6279
const token = await getGoogleChatAccessToken(account);
63-
const res = await fetch(url, {
64-
...init,
65-
headers: {
66-
...headersToObject(init?.headers),
67-
Authorization: `Bearer ${token}`,
80+
const { response: res, release } = await fetchWithSsrFGuard({
81+
url,
82+
init: {
83+
...init,
84+
headers: {
85+
...headersToObject(init?.headers),
86+
Authorization: `Bearer ${token}`,
87+
},
6888
},
89+
auditContext: "googlechat.api.buffer",
6990
});
70-
if (!res.ok) {
71-
const text = await res.text().catch(() => "");
72-
throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`);
73-
}
74-
const maxBytes = options?.maxBytes;
75-
const lengthHeader = res.headers.get("content-length");
76-
if (maxBytes && lengthHeader) {
77-
const length = Number(lengthHeader);
78-
if (Number.isFinite(length) && length > maxBytes) {
79-
throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`);
91+
try {
92+
if (!res.ok) {
93+
const text = await res.text().catch(() => "");
94+
throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`);
8095
}
81-
}
82-
if (!maxBytes || !res.body) {
83-
const buffer = Buffer.from(await res.arrayBuffer());
84-
const contentType = res.headers.get("content-type") ?? undefined;
85-
return { buffer, contentType };
86-
}
87-
const reader = res.body.getReader();
88-
const chunks: Buffer[] = [];
89-
let total = 0;
90-
while (true) {
91-
const { done, value } = await reader.read();
92-
if (done) {
93-
break;
96+
const maxBytes = options?.maxBytes;
97+
const lengthHeader = res.headers.get("content-length");
98+
if (maxBytes && lengthHeader) {
99+
const length = Number(lengthHeader);
100+
if (Number.isFinite(length) && length > maxBytes) {
101+
throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`);
102+
}
94103
}
95-
if (!value) {
96-
continue;
104+
if (!maxBytes || !res.body) {
105+
const buffer = Buffer.from(await res.arrayBuffer());
106+
const contentType = res.headers.get("content-type") ?? undefined;
107+
return { buffer, contentType };
97108
}
98-
total += value.length;
99-
if (total > maxBytes) {
100-
await reader.cancel();
101-
throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`);
109+
const reader = res.body.getReader();
110+
const chunks: Buffer[] = [];
111+
let total = 0;
112+
while (true) {
113+
const { done, value } = await reader.read();
114+
if (done) {
115+
break;
116+
}
117+
if (!value) {
118+
continue;
119+
}
120+
total += value.length;
121+
if (total > maxBytes) {
122+
await reader.cancel();
123+
throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`);
124+
}
125+
chunks.push(Buffer.from(value));
102126
}
103-
chunks.push(Buffer.from(value));
127+
const buffer = Buffer.concat(chunks, total);
128+
const contentType = res.headers.get("content-type") ?? undefined;
129+
return { buffer, contentType };
130+
} finally {
131+
await release();
104132
}
105-
const buffer = Buffer.concat(chunks, total);
106-
const contentType = res.headers.get("content-type") ?? undefined;
107-
return { buffer, contentType };
108133
}
109134

110135
export async function sendGoogleChatMessage(params: {
@@ -185,24 +210,32 @@ export async function uploadGoogleChatAttachment(params: {
185210

186211
const token = await getGoogleChatAccessToken(account);
187212
const url = `${CHAT_UPLOAD_BASE}/${space}/attachments:upload?uploadType=multipart`;
188-
const res = await fetch(url, {
189-
method: "POST",
190-
headers: {
191-
Authorization: `Bearer ${token}`,
192-
"Content-Type": `multipart/related; boundary=${boundary}`,
213+
const { response: res, release } = await fetchWithSsrFGuard({
214+
url,
215+
init: {
216+
method: "POST",
217+
headers: {
218+
Authorization: `Bearer ${token}`,
219+
"Content-Type": `multipart/related; boundary=${boundary}`,
220+
},
221+
body,
193222
},
194-
body,
223+
auditContext: "googlechat.upload",
195224
});
196-
if (!res.ok) {
197-
const text = await res.text().catch(() => "");
198-
throw new Error(`Google Chat upload ${res.status}: ${text || res.statusText}`);
225+
try {
226+
if (!res.ok) {
227+
const text = await res.text().catch(() => "");
228+
throw new Error(`Google Chat upload ${res.status}: ${text || res.statusText}`);
229+
}
230+
const payload = (await res.json()) as {
231+
attachmentDataRef?: { attachmentUploadToken?: string };
232+
};
233+
return {
234+
attachmentUploadToken: payload.attachmentDataRef?.attachmentUploadToken,
235+
};
236+
} finally {
237+
await release();
199238
}
200-
const payload = (await res.json()) as {
201-
attachmentDataRef?: { attachmentUploadToken?: string };
202-
};
203-
return {
204-
attachmentUploadToken: payload.attachmentDataRef?.attachmentUploadToken,
205-
};
206239
}
207240

208241
export async function downloadGoogleChatMedia(params: {

src/agents/openclaw-tools.plugin-context.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const resolvePluginToolsMock = vi.fn((params?: unknown) => {
66
});
77

88
vi.mock("../plugins/tools.js", () => ({
9-
resolvePluginTools: (params: unknown) => resolvePluginToolsMock(params),
9+
resolvePluginTools: resolvePluginToolsMock,
1010
}));
1111

1212
import { createOpenClawTools } from "./openclaw-tools.js";

src/cron/schedule.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,16 @@ describe("cron schedule", () => {
7373
expect(next).toBe(anchor + 30_000);
7474
});
7575

76+
it("never returns a past timestamp for Asia/Shanghai daily schedule (#30351)", () => {
77+
const nowMs = Date.parse("2026-03-01T00:00:00.000Z");
78+
const next = computeNextRunAtMs(
79+
{ kind: "cron", expr: "0 8 * * *", tz: "Asia/Shanghai" },
80+
nowMs,
81+
);
82+
expect(next).toBeDefined();
83+
expect(next!).toBeGreaterThan(nowMs);
84+
});
85+
7686
describe("cron with specific seconds (6-field pattern)", () => {
7787
// Pattern: fire at exactly second 0 of minute 0 of hour 12 every day
7888
const dailyNoon = { kind: "cron" as const, expr: "0 0 12 * * *", tz: "UTC" };

src/cron/schedule.ts

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -54,25 +54,39 @@ export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): numbe
5454
timezone: resolveCronTimezone(schedule.tz),
5555
catch: false,
5656
});
57-
const next = cron.nextRun(new Date(nowMs));
57+
let next = cron.nextRun(new Date(nowMs));
5858
if (!next) {
5959
return undefined;
6060
}
61-
const nextMs = next.getTime();
61+
let nextMs = next.getTime();
6262
if (!Number.isFinite(nextMs)) {
6363
return undefined;
6464
}
65-
if (nextMs > nowMs) {
66-
return nextMs;
67-
}
6865

69-
// Guard against same-second rescheduling loops: if croner returns
70-
// "now" (or an earlier instant), retry from the next whole second.
71-
const nextSecondMs = Math.floor(nowMs / 1000) * 1000 + 1000;
72-
const retry = cron.nextRun(new Date(nextSecondMs));
73-
if (!retry) {
66+
// Workaround for croner year-rollback bug: some timezone/date combinations
67+
// (e.g. Asia/Shanghai) cause nextRun to return a timestamp in a past year.
68+
// Retry from a later reference point when the returned time is not in the
69+
// future.
70+
if (nextMs <= nowMs) {
71+
const nextSecondMs = Math.floor(nowMs / 1000) * 1000 + 1000;
72+
const retry = cron.nextRun(new Date(nextSecondMs));
73+
if (retry) {
74+
const retryMs = retry.getTime();
75+
if (Number.isFinite(retryMs) && retryMs > nowMs) {
76+
return retryMs;
77+
}
78+
}
79+
// Still in the past — try from start of tomorrow (UTC) as a broader reset.
80+
const tomorrowMs = new Date(nowMs).setUTCHours(24, 0, 0, 0);
81+
const retry2 = cron.nextRun(new Date(tomorrowMs));
82+
if (retry2) {
83+
const retry2Ms = retry2.getTime();
84+
if (Number.isFinite(retry2Ms) && retry2Ms > nowMs) {
85+
return retry2Ms;
86+
}
87+
}
7488
return undefined;
7589
}
76-
const retryMs = retry.getTime();
77-
return Number.isFinite(retryMs) && retryMs > nowMs ? retryMs : undefined;
90+
91+
return nextMs;
7892
}

0 commit comments

Comments
 (0)