Skip to content

Commit 2d5dd6c

Browse files
hugenshenNIOsteipete
authored
fix(googlechat): bound control and media requests (#102227)
* fix(googlechat): add 30 s request timeout to withGoogleChatResponse * fix(googlechat): satisfy ResolvedGoogleChatAccount in api.test.ts * fix(googlechat): bound control and media requests Co-authored-by: NIO <[email protected]> --------- Co-authored-by: NIO <[email protected]> Co-authored-by: Peter Steinberger <[email protected]>
1 parent 47cc6c9 commit 2d5dd6c

3 files changed

Lines changed: 150 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
2323

2424
### Fixes
2525

26+
- **Google Chat request deadlines:** bound control calls to 30 seconds while giving media transfers size-aware total budgets and a separate 30-second stalled-body guard, preventing hung Chat API requests without breaking large attachment uploads. (#102227) Thanks @hugenshen.
2627
- **Browser actions on Node 24:** keep browser request cancellation bound to the client and response lifetime instead of Node 24.16+'s prematurely aborted body-stream signal, preventing valid POST actions from failing after JSON parsing. Thanks @obviyus and @vincentkoc.
2728
- **SecretRef model credentials:** keep resolved provider secrets behind process-local sentinels through auth storage, stream setup, SDK configuration, and managed local-provider probing, then inject plaintext only at the final network or provider-plugin boundary while retaining exact-value log redaction. (#102008, #102009)
2829
- **Lean local model shell access:** keep `exec` directly visible beside the default structured Tool Search controls so coding-tuned local models can use their shell fallback instead of searching for missing domain tools. (#87587) Thanks @vincentkoc.

extensions/googlechat/src/api.ts

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
// Googlechat API module exposes the plugin public contract.
22
import crypto from "node:crypto";
33
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
4-
import { parseMediaContentLength } from "openclaw/plugin-sdk/media-runtime";
54
import {
6-
readProviderJsonResponse,
7-
readResponseTextLimited,
8-
} from "openclaw/plugin-sdk/provider-http";
5+
parseMediaContentLength,
6+
readResponseTextSnippet,
7+
} from "openclaw/plugin-sdk/media-runtime";
98
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
109
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
1110
import type { ResolvedGoogleChatAccount } from "./accounts.js";
@@ -15,9 +14,46 @@ import type { GoogleChatCardV2, GoogleChatReaction, GoogleChatSpace } from "./ty
1514

1615
const CHAT_API_BASE = "https://chat.googleapis.com/v1";
1716
const CHAT_UPLOAD_BASE = "https://chat.googleapis.com/upload/v1";
17+
const GOOGLECHAT_API_TIMEOUT_MS = 30_000;
18+
const GOOGLECHAT_MEDIA_TIMEOUT_GRACE_MS = 30_000;
19+
const GOOGLECHAT_MEDIA_MIN_BYTES_PER_SECOND = 256 * 1024;
20+
const GOOGLECHAT_MEDIA_MAX_TIMEOUT_MS = 15 * 60_000;
21+
const GOOGLECHAT_RESPONSE_READ_IDLE_TIMEOUT_MS = 30_000;
22+
const GOOGLECHAT_JSON_RESPONSE_MAX_BYTES = 16 * 1024 * 1024;
23+
const GOOGLECHAT_ERROR_BODY_MAX_BYTES = 16 * 1024;
24+
25+
function resolveGoogleChatMediaTimeoutMs(maxBytes?: number): number {
26+
if (!maxBytes) {
27+
return GOOGLECHAT_MEDIA_MAX_TIMEOUT_MS;
28+
}
29+
const transferMs = Math.ceil((maxBytes / GOOGLECHAT_MEDIA_MIN_BYTES_PER_SECOND) * 1000);
30+
return Math.min(GOOGLECHAT_MEDIA_TIMEOUT_GRACE_MS + transferMs, GOOGLECHAT_MEDIA_MAX_TIMEOUT_MS);
31+
}
1832

1933
async function readGoogleChatJsonResponse<T>(response: Response, label: string): Promise<T> {
20-
return readProviderJsonResponse<T>(response, label);
34+
const bytes = await readResponseWithLimit(response, GOOGLECHAT_JSON_RESPONSE_MAX_BYTES, {
35+
chunkTimeoutMs: GOOGLECHAT_RESPONSE_READ_IDLE_TIMEOUT_MS,
36+
onIdleTimeout: ({ chunkTimeoutMs }) =>
37+
new Error(`${label}: response body stalled after ${chunkTimeoutMs}ms`),
38+
onOverflow: ({ maxBytes }) => new Error(`${label}: JSON response exceeds ${maxBytes} bytes`),
39+
});
40+
try {
41+
return JSON.parse(new TextDecoder().decode(bytes)) as T;
42+
} catch (cause) {
43+
throw new Error(`${label}: malformed JSON response`, { cause });
44+
}
45+
}
46+
47+
async function readGoogleChatErrorResponse(response: Response, label: string): Promise<string> {
48+
return (
49+
(await readResponseTextSnippet(response, {
50+
maxBytes: GOOGLECHAT_ERROR_BODY_MAX_BYTES,
51+
maxChars: GOOGLECHAT_ERROR_BODY_MAX_BYTES,
52+
chunkTimeoutMs: GOOGLECHAT_RESPONSE_READ_IDLE_TIMEOUT_MS,
53+
onIdleTimeout: ({ chunkTimeoutMs }) =>
54+
new Error(`${label} error response stalled after ${chunkTimeoutMs}ms`),
55+
})) ?? ""
56+
);
2157
}
2258

2359
const headersToObject = (headers?: HeadersInit): Record<string, string> =>
@@ -33,6 +69,7 @@ async function withGoogleChatResponse<T>(params: {
3369
init?: RequestInit;
3470
auditContext: string;
3571
errorPrefix?: string;
72+
timeoutMs?: number;
3673
handleResponse: (response: Response) => Promise<T>;
3774
}): Promise<T> {
3875
const {
@@ -41,6 +78,7 @@ async function withGoogleChatResponse<T>(params: {
4178
init,
4279
auditContext,
4380
errorPrefix = "Google Chat API",
81+
timeoutMs = GOOGLECHAT_API_TIMEOUT_MS,
4482
handleResponse,
4583
} = params;
4684
const token = await getGoogleChatAccessToken(account);
@@ -54,10 +92,11 @@ async function withGoogleChatResponse<T>(params: {
5492
},
5593
},
5694
auditContext,
95+
timeoutMs,
5796
});
5897
try {
5998
if (!response.ok) {
60-
const text = await readResponseTextLimited(response).catch(() => "");
99+
const text = await readGoogleChatErrorResponse(response, errorPrefix);
61100
throw new Error(`${errorPrefix} ${response.status}: ${text || response.statusText}`);
62101
}
63102
return await handleResponse(response);
@@ -112,6 +151,9 @@ async function fetchBuffer(
112151
url,
113152
init,
114153
auditContext: "googlechat.api.buffer",
154+
// Media gets transfer time proportional to its accepted size, while a silent
155+
// response body is still bounded independently below.
156+
timeoutMs: resolveGoogleChatMediaTimeoutMs(options?.maxBytes),
115157
handleResponse: async (res) => {
116158
const maxBytes = options?.maxBytes;
117159
const lengthHeader = res.headers.get("content-length");
@@ -127,6 +169,7 @@ async function fetchBuffer(
127169
return { buffer, contentType };
128170
}
129171
const buffer = await readResponseWithLimit(res, maxBytes, {
172+
chunkTimeoutMs: GOOGLECHAT_RESPONSE_READ_IDLE_TIMEOUT_MS,
130173
onOverflow: () => new Error(`Google Chat media exceeds max bytes (${maxBytes})`),
131174
});
132175
const contentType = res.headers.get("content-type") ?? undefined;
@@ -255,6 +298,7 @@ export async function uploadGoogleChatAttachment(params: {
255298
},
256299
auditContext: "googlechat.upload",
257300
errorPrefix: "Google Chat upload",
301+
timeoutMs: resolveGoogleChatMediaTimeoutMs(body.length),
258302
handleResponse: async (response) =>
259303
await readGoogleChatJsonResponse<{
260304
attachmentDataRef?: { attachmentUploadToken?: string };

extensions/googlechat/src/targets.test.ts

Lines changed: 99 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
// Googlechat tests cover targets plugin behavior.
22
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
33
import type { ResolvedGoogleChatAccount } from "./accounts.js";
4-
import { downloadGoogleChatMedia, sendGoogleChatMessage, updateGoogleChatMessage } from "./api.js";
4+
import {
5+
downloadGoogleChatMedia,
6+
sendGoogleChatMessage,
7+
updateGoogleChatMessage,
8+
uploadGoogleChatAttachment,
9+
} from "./api.js";
510
import {
611
clearGoogleChatApprovalCardBindingsForTest,
712
registerGoogleChatManualApprovalFollowupSuppression,
@@ -20,10 +25,12 @@ const mocks = vi.hoisted(() => ({
2025
buildHostnameAllowlistPolicyFromSuffixAllowlist: vi.fn((hosts: string[]) => ({
2126
hostnameAllowlist: hosts,
2227
})),
23-
fetchWithSsrFGuard: vi.fn(async (params: { url: string; init?: RequestInit }) => ({
24-
response: await fetch(params.url, params.init),
25-
release: async () => {},
26-
})),
28+
fetchWithSsrFGuard: vi.fn(
29+
async (params: { url: string; init?: RequestInit; timeoutMs?: number }) => ({
30+
response: await fetch(params.url, params.init),
31+
release: async () => {},
32+
}),
33+
),
2734
googleAuthCtor: vi.fn(),
2835
gaxiosCtor: vi.fn(),
2936
getAccessToken: vi.fn().mockResolvedValue({ token: "access-token" }),
@@ -115,6 +122,15 @@ function stubSuccessfulSend(name: string, threadName?: string) {
115122
return fetchMock;
116123
}
117124

125+
function createStalledResponse(status = 200): Response {
126+
return new Response(
127+
new ReadableStream({
128+
start() {},
129+
}),
130+
{ status },
131+
);
132+
}
133+
118134
async function expectDownloadToRejectForResponse(
119135
response: Response,
120136
expected: string | RegExp = /max bytes/i,
@@ -133,6 +149,14 @@ function mockCallArg(mock: ReturnType<typeof vi.fn>, callIndex = 0, argIndex = 0
133149
return call[argIndex];
134150
}
135151

152+
function lastGuardedFetchOptions(): { timeoutMs?: number } {
153+
const call = mocks.fetchWithSsrFGuard.mock.calls.at(-1);
154+
if (!call) {
155+
throw new Error("Expected guarded fetch call");
156+
}
157+
return call[0] as { timeoutMs?: number };
158+
}
159+
136160
describe("normalizeGoogleChatTarget", () => {
137161
it("normalizes provider prefixes", () => {
138162
expect(normalizeGoogleChatTarget("googlechat:users/123")).toBe("users/123");
@@ -265,6 +289,7 @@ describe("downloadGoogleChatMedia", () => {
265289
authTesting.resetGoogleChatAuthForTests();
266290
mocks.fetchWithSsrFGuard.mockClear();
267291
vi.unstubAllGlobals();
292+
vi.useRealTimers();
268293
});
269294

270295
it("rejects when content-length exceeds max bytes", async () => {
@@ -279,6 +304,7 @@ describe("downloadGoogleChatMedia", () => {
279304
headers: { "content-length": "50", "content-type": "application/octet-stream" },
280305
});
281306
await expectDownloadToRejectForResponse(response);
307+
expect(lastGuardedFetchOptions().timeoutMs).toBe(30_001);
282308
});
283309

284310
it("rejects malformed content-length before reading media", async () => {
@@ -315,6 +341,73 @@ describe("downloadGoogleChatMedia", () => {
315341
});
316342
await expectDownloadToRejectForResponse(response);
317343
});
344+
345+
it("cancels a media body that stops producing chunks", async () => {
346+
vi.useFakeTimers();
347+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(createStalledResponse()));
348+
349+
const result = expect(
350+
downloadGoogleChatMedia({ account, resourceName: "media/123", maxBytes: 10 }),
351+
).rejects.toThrow("Media download stalled: no data received for 30000ms");
352+
await vi.advanceTimersByTimeAsync(30_001);
353+
await result;
354+
});
355+
356+
it("cancels a stalled media error body", async () => {
357+
vi.useFakeTimers();
358+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(createStalledResponse(500)));
359+
360+
const result = expect(
361+
downloadGoogleChatMedia({ account, resourceName: "media/123", maxBytes: 10 }),
362+
).rejects.toThrow("Google Chat API error response stalled after 30000ms");
363+
await vi.advanceTimersByTimeAsync(30_001);
364+
await result;
365+
});
366+
});
367+
368+
describe("uploadGoogleChatAttachment", () => {
369+
afterEach(() => {
370+
authTesting.resetGoogleChatAuthForTests();
371+
mocks.fetchWithSsrFGuard.mockClear();
372+
vi.unstubAllGlobals();
373+
vi.useRealTimers();
374+
});
375+
376+
it("derives a bounded transfer deadline from the payload size", async () => {
377+
vi.stubGlobal(
378+
"fetch",
379+
vi.fn().mockResolvedValue(
380+
new Response(JSON.stringify({ attachmentDataRef: { attachmentUploadToken: "token" } }), {
381+
status: 200,
382+
}),
383+
),
384+
);
385+
386+
await uploadGoogleChatAttachment({
387+
account,
388+
space: "spaces/AAA",
389+
filename: "recording.wav",
390+
buffer: Buffer.alloc(1024 * 1024),
391+
});
392+
393+
expect(lastGuardedFetchOptions().timeoutMs).toBeGreaterThan(34_000);
394+
});
395+
396+
it("cancels a stalled upload response body", async () => {
397+
vi.useFakeTimers();
398+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(createStalledResponse()));
399+
400+
const result = expect(
401+
uploadGoogleChatAttachment({
402+
account,
403+
space: "spaces/AAA",
404+
filename: "recording.wav",
405+
buffer: Buffer.alloc(1024),
406+
}),
407+
).rejects.toThrow("Google Chat upload failed: response body stalled after 30000ms");
408+
await vi.advanceTimersByTimeAsync(30_001);
409+
await result;
410+
});
318411
});
319412

320413
describe("sendGoogleChatMessage", () => {
@@ -350,6 +443,7 @@ describe("sendGoogleChatMessage", () => {
350443
messageName: "spaces/AAA/messages/123",
351444
threadName: "spaces/AAA/threads/xyz",
352445
});
446+
expect(lastGuardedFetchOptions().timeoutMs).toBe(30_000);
353447
});
354448

355449
it("does not set messageReplyOption for non-thread sends", async () => {

0 commit comments

Comments
 (0)