diff --git a/CHANGELOG.md b/CHANGELOG.md index e65c260e832..dff17f821da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - Plugins/beta: prepare diagnostics OpenTelemetry, Discord, Diffs, Lobster, Memory LanceDB, Microsoft Teams, QQ Bot, Voice Call, and WhatsApp for `2026.5.1-beta.1` npm and ClawHub publishing. Thanks @vincentkoc. - Plugins/beta: prepare Brave, Codex, Feishu, Synology Chat, Tlon, and Twitch for `2026.5.1-beta.1` npm and ClawHub publishing. Thanks @vincentkoc. - Providers/xAI: add Grok 4.3 to the bundled catalog and make it the default xAI chat model. +- Google Meet: let API-created rooms set `accessType` and `entryPointAccess`, and add `googlemeet end-active-conference` for closing managed spaces after a call. (#74824) Thanks @BsnizND. - Plugins/ClawHub: prefer versioned ClawPack artifacts when ClawHub publishes digest metadata, verifying the ClawPack response header and downloaded bytes before installing. Thanks @vincentkoc. - Plugins/ClawHub: persist ClawPack digest metadata on ClawHub plugin install and update records so registry refreshes and download verification can reuse stored artifact facts. Thanks @vincentkoc. - Plugins/ClawHub: allow official bundled-plugin cutovers to prefer ClawHub installs with npm fallback only when the ClawHub package or version is absent. Thanks @vincentkoc. diff --git a/docs/plugins/google-meet.md b/docs/plugins/google-meet.md index 45849e3c7bf..f68cb3851cc 100644 --- a/docs/plugins/google-meet.md +++ b/docs/plugins/google-meet.md @@ -125,6 +125,24 @@ Create a new meeting and join it: openclaw googlemeet create --transport chrome-node --mode realtime ``` +For API-created rooms, use Google Meet `SpaceConfig.accessType` when you want +the room's no-knock policy to be explicit instead of inherited from the Google +account defaults: + +```bash +openclaw googlemeet create --access-type OPEN --transport chrome-node --mode realtime +``` + +`OPEN` lets anyone with the Meet URL join without knocking. `TRUSTED` lets the +host organization's trusted users, invited external users, and dial-in users +join without knocking. `RESTRICTED` limits no-knock entry to invitees. These +settings only apply to the official Google Meet API creation path, so OAuth +credentials must be configured. + +If you authenticated Google Meet before this option was available, rerun +`openclaw googlemeet auth login --json` after adding the +`meetings.space.settings` scope to your Google OAuth consent screen. + Create only the URL without joining: ```bash @@ -504,6 +522,7 @@ In Google Cloud Console: 4. Add the scopes OpenClaw requests: - `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` 5. Create an OAuth client ID. - Application type: **Web application**. @@ -517,6 +536,8 @@ In Google Cloud Console: `meetings.space.created` is required by Google Meet `spaces.create`. `meetings.space.readonly` lets OpenClaw resolve Meet URLs/codes to spaces. +`meetings.space.settings` lets OpenClaw pass `SpaceConfig` settings such as +`accessType` during API room creation. `meetings.conference.media.readonly` is for Meet Media API preflight and media work; Google may require Developer Preview enrollment for actual Media API use. If you only need browser-based Chrome joins, skip OAuth entirely. @@ -708,6 +729,21 @@ openclaw googlemeet artifacts --conference-record conferenceRecords/abc123 --jso openclaw googlemeet attendance --conference-record conferenceRecords/abc123 --json ``` +End an active conference for an API-created space when you want to close the +room after the call: + +```bash +openclaw googlemeet end-active-conference https://meet.google.com/abc-defg-hij +``` + +This calls Google Meet `spaces.endActiveConference` and requires OAuth with the +`meetings.space.created` scope for a space the authorized account can manage. +OpenClaw accepts a Meet URL, meeting code, or `spaces/{id}` input and resolves it +to the API space resource before ending the active conference. +It is separate from `googlemeet leave`: `leave` stops OpenClaw's local/session +participation, while `end-active-conference` asks Google Meet to end the active +conference for the space. + Write a readable report: ```bash @@ -764,6 +800,26 @@ Agents can also create the same bundle through the `google_meet` tool: Set `"dryRun": true` to return only the export manifest and skip file writes. +Agents can also create an API-backed room with an explicit access policy: + +```json +{ + "action": "create", + "transport": "chrome-node", + "mode": "realtime", + "accessType": "OPEN" +} +``` + +And they can end the active conference for a known room: + +```json +{ + "action": "end_active_conference", + "meeting": "https://meet.google.com/abc-defg-hij" +} +``` + Run the guarded live smoke against a real retained meeting: ```bash @@ -1502,6 +1558,8 @@ argument list, and do not point it at scripts from untrusted locations. `googlemeet speak` triggers the active realtime audio bridge for a Chrome session. `googlemeet leave` stops that bridge. For Twilio sessions delegated through the Voice Call plugin, `leave` also hangs up the underlying voice call. +Use `googlemeet end-active-conference` when you also want to close the active +Google Meet conference for an API-managed space. ## Related diff --git a/extensions/google-meet/index.create.test.ts b/extensions/google-meet/index.create.test.ts index 727dbf51f68..bedc8882f65 100644 --- a/extensions/google-meet/index.create.test.ts +++ b/extensions/google-meet/index.create.test.ts @@ -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( { diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index 0afe9781ab2..f4ace2358bf 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -557,6 +557,7 @@ describe("google-meet plugin", () => { "export", "recover_current_tab", "leave", + "end_active_conference", "speak", "test_speech", ], diff --git a/extensions/google-meet/index.ts b/extensions/google-meet/index.ts index 866ed0fc76b..a45a973f86e 100644 --- a/extensions/google-meet/index.ts +++ b/extensions/google-meet/index.ts @@ -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) { diff --git a/extensions/google-meet/src/cli.test.ts b/extensions/google-meet/src/cli.test.ts index 75d4ce2abb4..6c3e441f51d 100644 --- a/extensions/google-meet/src/cli.test.ts +++ b/extensions/google-meet/src/cli.test.ts @@ -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(); diff --git a/extensions/google-meet/src/cli.ts b/extensions/google-meet/src/cli.ts index 0f317f022d9..7073712dae0 100644 --- a/extensions/google-meet/src/cli.ts +++ b/extensions/google-meet/src/cli.ts @@ -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 ", "OAuth client id override") .option("--client-secret ", "OAuth client secret override") .option("--expires-at ", "Cached access token expiry as unix epoch milliseconds") + .option( + "--access-type ", + "Google Meet SpaceConfig accessType for API create: OPEN, TRUSTED, or RESTRICTED", + ) + .option( + "--entry-point-access ", + "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 ", "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)) { + 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), + }); 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 ", "Access token override") + .option("--refresh-token ", "Refresh token override") + .option("--client-id ", "OAuth client id override") + .option("--client-secret ", "OAuth client secret override") + .option("--expires-at ", "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") diff --git a/extensions/google-meet/src/create.ts b/extensions/google-meet/src/create.ts index b5f892c6f89..613dbbb9f28 100644 --- a/extensions/google-meet/src/create.ts +++ b/extensions/google-meet/src/create.ts @@ -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, +): 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): boolean { + return ( + normalizeOptionalString(raw.accessType) !== undefined || + normalizeOptionalString(raw.entryPointAccess) !== undefined + ); +} + async function createSpaceFromParams(config: GoogleMeetConfig, raw: Record) { const token = await resolveGoogleMeetAccessToken({ clientId: normalizeOptionalString(raw.clientId) ?? config.oauth.clientId, @@ -22,7 +68,10 @@ async function createSpaceFromParams(config: GoogleMeetConfig, raw: Record; - config?: Record; + config?: GoogleMeetSpaceConfig & Record; }; -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 { + 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 { + 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 { diff --git a/extensions/google-meet/src/oauth.ts b/extensions/google-meet/src/oauth.ts index 941627c514c..935756ec6a3 100644 --- a/extensions/google-meet/src/oauth.ts +++ b/extensions/google-meet/src/oauth.ts @@ -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",