fix(google-meet): bound Google API requests (#102149)

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
wangmiao0668000666 2026-07-09 16:39:34 +08:00 committed by GitHub
parent 42a8679c4d
commit 51b4e78c33
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 117 additions and 43 deletions

View file

@ -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) {

View file

@ -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 {

View file

@ -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) {

View 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;
});
});