Add Google Meet space access controls

This commit is contained in:
BSnizND
2026-04-29 21:11:23 -07:00
committed by Peter Steinberger
parent 53c4217110
commit f2c1a56bbd
10 changed files with 386 additions and 13 deletions

View File

@@ -108,7 +108,7 @@ describe("google-meet create flow", () => {
googleMeetPluginTesting.setCallGatewayFromCliForTests();
});
it("CLI create prints the new meeting URL", async () => {
it("CLI create can configure API-created space access", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) => {
const url = input instanceof Request ? input.url : input.toString();
if (url.includes("oauth2.googleapis.com")) {
@@ -142,9 +142,27 @@ describe("google-meet create flow", () => {
});
try {
await program.parseAsync(["googlemeet", "create", "--no-join"], { from: "user" });
await program.parseAsync(
[
"googlemeet",
"create",
"--no-join",
"--access-type",
"OPEN",
"--entry-point-access",
"ALL",
],
{ from: "user" },
);
expect(stdout.output()).toContain("meeting uri: https://meet.google.com/new-abcd-xyz");
expect(stdout.output()).toContain("space: spaces/new-space");
expect(fetchMock).toHaveBeenCalledWith(
"https://meet.googleapis.com/v2/spaces",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ config: { accessType: "OPEN", entryPointAccess: "ALL" } }),
}),
);
} finally {
stdout.restore();
}
@@ -220,6 +238,27 @@ describe("google-meet create flow", () => {
);
});
it("rejects access policy flags when tool create would use browser fallback", async () => {
const { methods } = setup(
{
defaultTransport: "chrome-node",
chromeNode: { node: "parallels-macos" },
},
{
nodesInvokeHandler: async () => {
throw new Error("browser fallback should not run");
},
},
);
await expect(
invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.create", {
join: false,
accessType: "OPEN",
}),
).rejects.toThrow("access policy options require OAuth/API room creation");
});
it("reports structured manual action when browser creation needs Google login", async () => {
const { methods } = setup(
{

View File

@@ -557,6 +557,7 @@ describe("google-meet plugin", () => {
"export",
"recover_current_tab",
"leave",
"end_active_conference",
"speak",
"test_speech",
],

View File

@@ -22,6 +22,7 @@ import {
} from "./src/config.js";
import {
buildGoogleMeetPreflightReport,
endGoogleMeetActiveConference,
fetchGoogleMeetArtifacts,
fetchGoogleMeetAttendance,
fetchLatestGoogleMeetConferenceRecord,
@@ -201,6 +202,7 @@ const GoogleMeetToolSchema = Type.Object({
"export",
"recover_current_tab",
"leave",
"end_active_conference",
"speak",
"test_speech",
],
@@ -212,6 +214,19 @@ const GoogleMeetToolSchema = Type.Object({
description: "For action=create, set false to create the URL without joining.",
}),
),
accessType: Type.Optional(
Type.String({
enum: ["OPEN", "TRUSTED", "RESTRICTED"],
description:
"For action=create with Google Meet OAuth, configure who can join without knocking.",
}),
),
entryPointAccess: Type.Optional(
Type.String({
enum: ["ALL", "CREATOR_APP_ONLY"],
description: "For action=create with Google Meet OAuth, configure allowed join entry points.",
}),
),
url: Type.Optional(Type.String({ description: "Explicit https://meet.google.com/... URL" })),
transport: Type.Optional(
Type.String({ enum: ["chrome", "chrome-node", "twilio"], description: "Join transport" }),
@@ -343,6 +358,7 @@ type GoogleMeetGatewayToolAction =
| "recover_current_tab"
| "setup_status"
| "leave"
| "end_active_conference"
| "speak"
| "test_speech";
@@ -354,6 +370,8 @@ function googleMeetGatewayMethodForToolAction(action: GoogleMeetGatewayToolActio
return "googlemeet.setup";
case "test_speech":
return "googlemeet.testSpeech";
case "end_active_conference":
return "googlemeet.endActiveConference";
default:
return `googlemeet.${action}`;
}
@@ -842,6 +860,25 @@ export default definePluginEntry({
},
);
api.registerGatewayMethod(
"googlemeet.endActiveConference",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
try {
const raw = asParamRecord(params);
const token = await resolveGoogleMeetTokenFromParams(config, raw);
respond(
true,
await endGoogleMeetActiveConference({
accessToken: token.accessToken,
meeting: resolveMeetingInput(config, raw.meeting),
}),
);
} catch (err) {
sendError(respond, err);
}
},
);
api.registerGatewayMethod(
"googlemeet.speak",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
@@ -999,6 +1036,15 @@ export default definePluginEntry({
}
return json(await callGoogleMeetGatewayFromTool({ config, action: "leave", raw }));
}
case "end_active_conference": {
return json(
await callGoogleMeetGatewayFromTool({
config,
action: "end_active_conference",
raw,
}),
);
}
case "speak": {
const sessionId = normalizeOptionalString(raw.sessionId);
if (!sessionId) {

View File

@@ -324,6 +324,64 @@ describe("google-meet CLI", () => {
}
});
it("ends an active conference for a Meet space", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) => {
const url = requestUrl(input);
if (url.pathname === "/v2/spaces/abc-defg-hij") {
return jsonResponse({
name: "spaces/space-resource-123",
meetingCode: "abc-defg-hij",
meetingUri: "https://meet.google.com/abc-defg-hij",
});
}
if (url.pathname === "/v2/spaces/space-resource-123:endActiveConference") {
return jsonResponse({});
}
return new Response("not found", { status: 404 });
});
vi.stubGlobal("fetch", fetchMock);
const stdout = captureStdout();
try {
await setupCli({}).parseAsync(
[
"googlemeet",
"end-active-conference",
"https://meet.google.com/abc-defg-hij",
"--access-token",
"token",
"--expires-at",
String(Date.now() + 120_000),
"--json",
],
{ from: "user" },
);
expect(JSON.parse(stdout.output())).toMatchObject({
space: "spaces/space-resource-123",
ended: true,
tokenSource: "cached-access-token",
});
expect(fetchMock).toHaveBeenCalledWith(
"https://meet.googleapis.com/v2/spaces/space-resource-123:endActiveConference",
expect.objectContaining({ method: "POST", body: "{}" }),
);
} finally {
stdout.restore();
}
});
it("rejects access policy flags when create would use browser fallback", async () => {
await expect(
setupCli({
runtime: {
createViaBrowser: vi.fn(async () => {
throw new Error("browser fallback should not run");
}),
},
}).parseAsync(["googlemeet", "create", "--access-type", "OPEN"], { from: "user" }),
).rejects.toThrow("access policy options require OAuth/API room creation");
});
it("prints the latest conference record", async () => {
stubMeetArtifactsApi();
const stdout = captureStdout();

View File

@@ -10,9 +10,11 @@ import {
type GoogleMeetCalendarLookupResult,
} from "./calendar.js";
import type { GoogleMeetConfig, GoogleMeetMode, GoogleMeetTransport } from "./config.js";
import { hasCreateSpaceConfigInput, resolveCreateSpaceConfig } from "./create.js";
import {
buildGoogleMeetPreflightReport,
createGoogleMeetSpace,
endGoogleMeetActiveConference,
fetchGoogleMeetArtifacts,
fetchGoogleMeetAttendance,
fetchLatestGoogleMeetConferenceRecord,
@@ -159,6 +161,8 @@ type CreateOptions = {
clientId?: string;
clientSecret?: string;
expiresAt?: string;
accessType?: string;
entryPointAccess?: string;
join?: boolean;
transport?: GoogleMeetTransport;
mode?: GoogleMeetMode;
@@ -1367,6 +1371,14 @@ export function registerGoogleMeetCli(params: {
.option("--client-id <id>", "OAuth client id override")
.option("--client-secret <secret>", "OAuth client secret override")
.option("--expires-at <ms>", "Cached access token expiry as unix epoch milliseconds")
.option(
"--access-type <type>",
"Google Meet SpaceConfig accessType for API create: OPEN, TRUSTED, or RESTRICTED",
)
.option(
"--entry-point-access <type>",
"Google Meet SpaceConfig entryPointAccess for API create: ALL or CREATOR_APP_ONLY",
)
.option("--no-join", "Only create the meeting URL; do not join it")
.option("--transport <transport>", "Join transport: chrome, chrome-node, or twilio")
.option(
@@ -1380,6 +1392,11 @@ export function registerGoogleMeetCli(params: {
.option("--json", "Print JSON output", false)
.action(async (options: CreateOptions) => {
if (!hasCreateOAuth(params.config, options)) {
if (hasCreateSpaceConfigInput(options as Record<string, unknown>)) {
throw new Error(
"Google Meet access policy options require OAuth/API room creation. Configure Google Meet OAuth or remove --access-type/--entry-point-access.",
);
}
const rt = await params.ensureRuntime();
const result = await rt.createViaBrowser();
const join =
@@ -1423,7 +1440,10 @@ export function registerGoogleMeetCli(params: {
const token = await resolveGoogleMeetAccessToken(
resolveCreateTokenOptions(params.config, options),
);
const result = await createGoogleMeetSpace({ accessToken: token.accessToken });
const result = await createGoogleMeetSpace({
accessToken: token.accessToken,
config: resolveCreateSpaceConfig(options as Record<string, unknown>),
});
const join =
options.join !== false
? await (
@@ -1463,6 +1483,39 @@ export function registerGoogleMeetCli(params: {
}
});
root
.command("end-active-conference")
.description("End the active conference for a Google Meet space")
.argument("[meeting]", "Meet URL, meeting code, or spaces/{id}")
.option("--access-token <token>", "Access token override")
.option("--refresh-token <token>", "Refresh token override")
.option("--client-id <id>", "OAuth client id override")
.option("--client-secret <secret>", "OAuth client secret override")
.option("--expires-at <ms>", "Cached access token expiry as unix epoch milliseconds")
.option("--json", "Print JSON output", false)
.action(async (meeting: string | undefined, options: ResolveSpaceOptions & JsonOptions) => {
const token = await resolveGoogleMeetAccessToken(
resolveOAuthTokenOptions(params.config, options),
);
const result = await endGoogleMeetActiveConference({
accessToken: token.accessToken,
meeting: resolveMeetingInput(params.config, meeting ?? options.meeting),
});
if (options.json) {
writeStdoutJson({
...result,
tokenSource: token.refreshed ? "refresh-token" : "cached-access-token",
});
return;
}
writeStdoutLine("space: %s", result.space);
writeStdoutLine("ended: yes");
writeStdoutLine(
"token source: %s",
token.refreshed ? "refresh-token" : "cached-access-token",
);
});
root
.command("join")
.argument("[url]", "Explicit https://meet.google.com/... URL")

View File

@@ -1,7 +1,12 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type { GoogleMeetConfig, GoogleMeetMode, GoogleMeetTransport } from "./config.js";
import { createGoogleMeetSpace } from "./meet.js";
import {
createGoogleMeetSpace,
type GoogleMeetAccessType,
type GoogleMeetEntryPointAccess,
type GoogleMeetSpaceConfig,
} from "./meet.js";
import { resolveGoogleMeetAccessToken } from "./oauth.js";
import type { GoogleMeetRuntime } from "./runtime.js";
import { createMeetWithBrowserProxyOnNode } from "./transports/chrome-create.js";
@@ -14,6 +19,47 @@ function normalizeMode(value: unknown): GoogleMeetMode | undefined {
return value === "realtime" || value === "transcribe" ? value : undefined;
}
export function normalizeGoogleMeetAccessType(value: unknown): GoogleMeetAccessType | undefined {
const normalized = normalizeOptionalString(value)?.toUpperCase().replaceAll("-", "_");
return normalized === "OPEN" || normalized === "TRUSTED" || normalized === "RESTRICTED"
? normalized
: undefined;
}
export function normalizeGoogleMeetEntryPointAccess(
value: unknown,
): GoogleMeetEntryPointAccess | undefined {
const normalized = normalizeOptionalString(value)?.toUpperCase().replaceAll("-", "_");
return normalized === "ALL" || normalized === "CREATOR_APP_ONLY" ? normalized : undefined;
}
export function resolveCreateSpaceConfig(
raw: Record<string, unknown>,
): GoogleMeetSpaceConfig | undefined {
const rawAccessType = normalizeOptionalString(raw.accessType);
const rawEntryPointAccess = normalizeOptionalString(raw.entryPointAccess);
const accessType = normalizeGoogleMeetAccessType(raw.accessType);
const entryPointAccess = normalizeGoogleMeetEntryPointAccess(raw.entryPointAccess);
if (rawAccessType !== undefined && !accessType) {
throw new Error("Invalid Google Meet accessType. Expected OPEN, TRUSTED, or RESTRICTED.");
}
if (rawEntryPointAccess !== undefined && !entryPointAccess) {
throw new Error("Invalid Google Meet entryPointAccess. Expected ALL or CREATOR_APP_ONLY.");
}
const config = {
...(accessType ? { accessType } : {}),
...(entryPointAccess ? { entryPointAccess } : {}),
};
return Object.keys(config).length > 0 ? config : undefined;
}
export function hasCreateSpaceConfigInput(raw: Record<string, unknown>): boolean {
return (
normalizeOptionalString(raw.accessType) !== undefined ||
normalizeOptionalString(raw.entryPointAccess) !== undefined
);
}
async function createSpaceFromParams(config: GoogleMeetConfig, raw: Record<string, unknown>) {
const token = await resolveGoogleMeetAccessToken({
clientId: normalizeOptionalString(raw.clientId) ?? config.oauth.clientId,
@@ -22,7 +68,10 @@ async function createSpaceFromParams(config: GoogleMeetConfig, raw: Record<strin
accessToken: normalizeOptionalString(raw.accessToken) ?? config.oauth.accessToken,
expiresAt: typeof raw.expiresAt === "number" ? raw.expiresAt : config.oauth.expiresAt,
});
const result = await createGoogleMeetSpace({ accessToken: token.accessToken });
const result = await createGoogleMeetSpace({
accessToken: token.accessToken,
config: resolveCreateSpaceConfig(raw),
});
return { source: "api" as const, token, ...result };
}
@@ -53,6 +102,11 @@ export async function createMeetFromParams(params: {
"URL-only creation was requested. Call google_meet with action=join and url=meetingUri to enter the meeting.",
};
}
if (hasCreateSpaceConfigInput(params.raw)) {
throw new Error(
"Google Meet access policy options require OAuth/API room creation. Configure Google Meet OAuth or remove accessType/entryPointAccess.",
);
}
const browser = await createMeetWithBrowserProxyOnNode({
runtime: params.runtime,
config: params.config,

View File

@@ -9,16 +9,26 @@ const GOOGLE_MEET_API_HOST = "meet.googleapis.com";
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";
const GOOGLE_MEET_SPACE_CREATED_SCOPE = "https://www.googleapis.com/auth/meetings.space.created";
const GOOGLE_MEET_SPACE_SETTINGS_SCOPE = "https://www.googleapis.com/auth/meetings.space.settings";
type GoogleMeetSpace = {
export type GoogleMeetAccessType = "OPEN" | "TRUSTED" | "RESTRICTED";
export type GoogleMeetEntryPointAccess = "ALL" | "CREATOR_APP_ONLY";
export type GoogleMeetSpaceConfig = {
accessType?: GoogleMeetAccessType;
entryPointAccess?: GoogleMeetEntryPointAccess;
};
export type GoogleMeetSpace = {
name: string;
meetingCode?: string;
meetingUri?: string;
activeConference?: Record<string, unknown>;
config?: Record<string, unknown>;
config?: GoogleMeetSpaceConfig & Record<string, unknown>;
};
type GoogleMeetPreflightReport = {
export type GoogleMeetPreflightReport = {
input: string;
resolvedSpaceName: string;
meetingCode?: string;
@@ -29,12 +39,17 @@ type GoogleMeetPreflightReport = {
blockers: string[];
};
type GoogleMeetCreateSpaceResult = {
export type GoogleMeetCreateSpaceResult = {
space: GoogleMeetSpace;
meetingUri: string;
};
type GoogleMeetConferenceRecord = {
export type GoogleMeetEndActiveConferenceResult = {
space: string;
ended: true;
};
export type GoogleMeetConferenceRecord = {
name: string;
space?: string;
startTime?: string;
@@ -353,7 +368,12 @@ export async function fetchGoogleMeetSpace(params: {
export async function createGoogleMeetSpace(params: {
accessToken: string;
config?: GoogleMeetSpaceConfig;
}): Promise<GoogleMeetCreateSpaceResult> {
const body =
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: {
@@ -363,7 +383,7 @@ export async function createGoogleMeetSpace(params: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: "{}",
body,
},
policy: { allowedHostnames: [GOOGLE_MEET_API_HOST] },
auditContext: "google-meet.spaces.create",
@@ -375,7 +395,10 @@ export async function createGoogleMeetSpace(params: {
response,
detail,
prefix: "Google Meet spaces.create",
scopes: ["https://www.googleapis.com/auth/meetings.space.created"],
scopes:
params.config && Object.keys(params.config).length > 0
? [GOOGLE_MEET_SPACE_CREATED_SCOPE, GOOGLE_MEET_SPACE_SETTINGS_SCOPE]
: [GOOGLE_MEET_SPACE_CREATED_SCOPE],
});
}
const payload = (await response.json()) as GoogleMeetSpace;
@@ -392,7 +415,46 @@ export async function createGoogleMeetSpace(params: {
}
}
async function fetchGoogleMeetConferenceRecord(params: {
export async function endGoogleMeetActiveConference(params: {
accessToken: string;
meeting: string;
}): Promise<GoogleMeetEndActiveConferenceResult> {
const resolved = await fetchGoogleMeetSpace({
accessToken: params.accessToken,
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] },
auditContext: "google-meet.spaces.endActiveConference",
});
try {
if (!response.ok) {
const detail = await response.text();
throw await googleApiError({
response,
detail,
prefix: "Google Meet spaces.endActiveConference",
scopes: [GOOGLE_MEET_SPACE_CREATED_SCOPE],
});
}
return { space, ended: true };
} finally {
await release();
}
}
export async function fetchGoogleMeetConferenceRecord(params: {
accessToken: string;
conferenceRecord: string;
}): Promise<GoogleMeetConferenceRecord> {

View File

@@ -13,6 +13,7 @@ const GOOGLE_MEET_TOKEN_HOST = "oauth2.googleapis.com";
export const GOOGLE_MEET_SCOPES = [
"https://www.googleapis.com/auth/meetings.space.created",
"https://www.googleapis.com/auth/meetings.space.readonly",
"https://www.googleapis.com/auth/meetings.space.settings",
"https://www.googleapis.com/auth/meetings.conference.media.readonly",
"https://www.googleapis.com/auth/calendar.events.readonly",
"https://www.googleapis.com/auth/drive.meet.readonly",