mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-10 00:11:19 +00:00
fix(google-meet): bound Google API requests (#102149)
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
parent
42a8679c4d
commit
51b4e78c33
4 changed files with 117 additions and 43 deletions
|
|
@ -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<string, string | undefined>) {
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<string, string | number | boolean | undefined>;
|
||||
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<T>(params: {
|
||||
accessToken: string;
|
||||
path: string;
|
||||
|
|
@ -271,15 +297,10 @@ async function fetchGoogleMeetJson<T>(params: {
|
|||
auditContext: string;
|
||||
errorPrefix: string;
|
||||
}): Promise<T> {
|
||||
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<GoogleMeetSpace> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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<GoogleM
|
|||
},
|
||||
policy: { allowedHostnames: [GOOGLE_MEET_TOKEN_HOST] },
|
||||
auditContext: "google-meet.oauth.token",
|
||||
timeoutMs: GOOGLE_OAUTH_REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
try {
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
69
extensions/google-meet/src/request-timeouts.test.ts
Normal file
69
extensions/google-meet/src/request-timeouts.test.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// Google Meet tests cover request deadlines across Google API surfaces.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { exportGoogleDriveDocumentText } from "./drive.js";
|
||||
import { fetchGoogleMeetSpace } from "./meet.js";
|
||||
import { refreshGoogleMeetAccessToken } from "./oauth.js";
|
||||
|
||||
afterEach(() => {
|
||||
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<Response>((_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;
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue