diff --git a/extensions/google-meet/src/drive.ts b/extensions/google-meet/src/drive.ts index 396f62c8517d..3c62c5153102 100644 --- a/extensions/google-meet/src/drive.ts +++ b/extensions/google-meet/src/drive.ts @@ -1,11 +1,12 @@ +import { readProviderTextResponse } from "openclaw/plugin-sdk/provider-http"; // Google Meet plugin module implements drive behavior. import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; -import { readProviderTextResponse } from "openclaw/plugin-sdk/provider-http"; import { googleApiError } from "./google-api-errors.js"; const GOOGLE_DRIVE_API_BASE_URL = "https://www.googleapis.com/drive/v3"; const GOOGLE_DRIVE_API_HOST = "www.googleapis.com"; const GOOGLE_DRIVE_MEET_SCOPE = "https://www.googleapis.com/auth/drive.meet.readonly"; +const GOOGLE_DRIVE_REQUEST_TIMEOUT_MS = 30_000; const TEXT_PLAIN_MIME = "text/plain"; function appendQuery(url: string, query: Record) { @@ -56,6 +57,7 @@ export async function exportGoogleDriveDocumentText(params: { }, policy: { allowedHostnames: [GOOGLE_DRIVE_API_HOST] }, auditContext: "google-meet.drive.files.export", + timeoutMs: GOOGLE_DRIVE_REQUEST_TIMEOUT_MS, }); try { if (!response.ok) { diff --git a/extensions/google-meet/src/meet.ts b/extensions/google-meet/src/meet.ts index 74b68475efe6..e73617315560 100644 --- a/extensions/google-meet/src/meet.ts +++ b/extensions/google-meet/src/meet.ts @@ -9,6 +9,7 @@ const GOOGLE_MEET_API_ORIGIN = "https://meet.googleapis.com"; const GOOGLE_MEET_API_BASE_URL = `${GOOGLE_MEET_API_ORIGIN}/v2`; const GOOGLE_MEET_URL_HOST = "meet.google.com"; const GOOGLE_MEET_API_HOST = "meet.googleapis.com"; +const GOOGLE_MEET_REQUEST_TIMEOUT_MS = 30_000; const GOOGLE_MEET_MEDIA_SCOPE = "https://www.googleapis.com/auth/meetings.conference.media.readonly"; const GOOGLE_MEET_SPACE_SCOPE = "https://www.googleapis.com/auth/meetings.space.readonly"; @@ -264,6 +265,31 @@ function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +async function requestGoogleMeetApi(params: { + accessToken: string; + path: string; + query?: Record; + method?: "GET" | "POST"; + body?: string; + auditContext: string; +}) { + return await fetchWithSsrFGuard({ + url: appendQuery(`${GOOGLE_MEET_API_BASE_URL}/${params.path}`, params.query), + init: { + method: params.method, + headers: { + Authorization: `Bearer ${params.accessToken}`, + Accept: "application/json", + ...(params.body === undefined ? {} : { "Content-Type": "application/json" }), + }, + body: params.body, + }, + policy: { allowedHostnames: [GOOGLE_MEET_API_HOST] }, + auditContext: params.auditContext, + timeoutMs: GOOGLE_MEET_REQUEST_TIMEOUT_MS, + }); +} + async function fetchGoogleMeetJson(params: { accessToken: string; path: string; @@ -271,15 +297,10 @@ async function fetchGoogleMeetJson(params: { auditContext: string; errorPrefix: string; }): Promise { - const { response, release } = await fetchWithSsrFGuard({ - url: appendQuery(`${GOOGLE_MEET_API_BASE_URL}/${params.path}`, params.query), - init: { - headers: { - Authorization: `Bearer ${params.accessToken}`, - Accept: "application/json", - }, - }, - policy: { allowedHostnames: [GOOGLE_MEET_API_HOST] }, + const { response, release } = await requestGoogleMeetApi({ + accessToken: params.accessToken, + path: params.path, + query: params.query, auditContext: params.auditContext, }); try { @@ -336,15 +357,9 @@ export async function fetchGoogleMeetSpace(params: { meeting: string; }): Promise { const name = normalizeGoogleMeetSpaceName(params.meeting); - const { response, release } = await fetchWithSsrFGuard({ - url: `${GOOGLE_MEET_API_BASE_URL}/${encodeSpaceNameForPath(name)}`, - init: { - headers: { - Authorization: `Bearer ${params.accessToken}`, - Accept: "application/json", - }, - }, - policy: { allowedHostnames: [GOOGLE_MEET_API_HOST] }, + const { response, release } = await requestGoogleMeetApi({ + accessToken: params.accessToken, + path: encodeSpaceNameForPath(name), auditContext: "google-meet.spaces.get", }); try { @@ -376,18 +391,11 @@ export async function createGoogleMeetSpace(params: { params.config && Object.keys(params.config).length > 0 ? JSON.stringify({ config: params.config }) : "{}"; - const { response, release } = await fetchWithSsrFGuard({ - url: `${GOOGLE_MEET_API_BASE_URL}/spaces`, - init: { - method: "POST", - headers: { - Authorization: `Bearer ${params.accessToken}`, - Accept: "application/json", - "Content-Type": "application/json", - }, - body, - }, - policy: { allowedHostnames: [GOOGLE_MEET_API_HOST] }, + const { response, release } = await requestGoogleMeetApi({ + accessToken: params.accessToken, + path: "spaces", + method: "POST", + body, auditContext: "google-meet.spaces.create", }); try { @@ -427,18 +435,11 @@ export async function endGoogleMeetActiveConference(params: { meeting: params.meeting, }); const space = resolved.name; - const { response, release } = await fetchWithSsrFGuard({ - url: `${GOOGLE_MEET_API_BASE_URL}/${encodeSpaceNameForPath(space)}:endActiveConference`, - init: { - method: "POST", - headers: { - Authorization: `Bearer ${params.accessToken}`, - Accept: "application/json", - "Content-Type": "application/json", - }, - body: "{}", - }, - policy: { allowedHostnames: [GOOGLE_MEET_API_HOST] }, + const { response, release } = await requestGoogleMeetApi({ + accessToken: params.accessToken, + path: `${encodeSpaceNameForPath(space)}:endActiveConference`, + method: "POST", + body: "{}", auditContext: "google-meet.spaces.endActiveConference", }); try { diff --git a/extensions/google-meet/src/oauth.ts b/extensions/google-meet/src/oauth.ts index 25954dbe69e4..0029af14237b 100644 --- a/extensions/google-meet/src/oauth.ts +++ b/extensions/google-meet/src/oauth.ts @@ -20,6 +20,7 @@ const GOOGLE_MEET_TOKEN_URL = "https://oauth2.googleapis.com/token"; const GOOGLE_MEET_TOKEN_HOST = "oauth2.googleapis.com"; const GOOGLE_MEET_DEFAULT_TOKEN_LIFETIME_SECONDS = 3600; const GOOGLE_OAUTH_TOKEN_JSON_MAX_BYTES = 256 * 1024; +const GOOGLE_OAUTH_REQUEST_TIMEOUT_MS = 30_000; const GOOGLE_MEET_SCOPES = [ "https://www.googleapis.com/auth/meetings.space.created", "https://www.googleapis.com/auth/meetings.space.readonly", @@ -85,6 +86,7 @@ async function executeGoogleTokenRequest(body: URLSearchParams): Promise { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +const requests = [ + { + name: "Meet v2", + run: () => fetchGoogleMeetSpace({ accessToken: "test-token", meeting: "abc-defg-hij" }), + }, + { + name: "Drive export", + run: () => + exportGoogleDriveDocumentText({ accessToken: "test-token", documentId: "document-id" }), + }, + { + name: "OAuth token", + run: () => + refreshGoogleMeetAccessToken({ + clientId: "client-id", + refreshToken: "refresh-token", + }), + }, +] as const; + +describe("Google API request timeouts", () => { + it.each(requests)("aborts a stalled $name request after 30 seconds", async ({ run }) => { + vi.useFakeTimers(); + const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => { + return new Promise((_resolve, reject) => { + const signal = init?.signal; + if (!signal) { + reject(new Error("expected Google API request abort signal")); + return; + } + const rejectAbort = () => + reject(signal.reason instanceof Error ? signal.reason : new Error("request aborted")); + if (signal.aborted) { + rejectAbort(); + return; + } + signal.addEventListener("abort", rejectAbort, { once: true }); + }); + }); + vi.stubGlobal("fetch", fetchMock); + + const request = run(); + const rejection = expect(request).rejects.toMatchObject({ + name: "TimeoutError", + message: "request timed out", + }); + await vi.advanceTimersByTimeAsync(0); + const signal = fetchMock.mock.calls[0]?.[1]?.signal; + expect(signal?.aborted).toBe(false); + + await vi.advanceTimersByTimeAsync(29_999); + expect(signal?.aborted).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + expect(signal?.aborted).toBe(true); + await rejection; + }); +});