From d37f165bee3d7aa796f8b58de5dff91cff06d6e5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 07:36:11 +0100 Subject: [PATCH] feat(google-meet): add oauth doctor --- CHANGELOG.md | 1 + docs/plugins/google-meet.md | 157 +++++++++++++++++++++++- extensions/google-meet/index.test.ts | 115 +++++++++++++++++ extensions/google-meet/src/cli.ts | 176 ++++++++++++++++++++++++++- 4 files changed, 445 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db79b8104e5..0646ae2ed5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,7 @@ Docs: https://docs.openclaw.ai - Plugins/Google Meet: default Chrome realtime sessions to OpenAI plus SoX `rec`/`play` audio bridge commands, so the usual setup only needs the plugin enabled and `OPENAI_API_KEY`. Thanks @steipete. - Plugins/Google Meet: add a `chrome-node` transport so a paired macOS node, such as a Parallels VM, can own Chrome, BlackHole, and SoX while the Gateway machine keeps the agent and model key. Thanks @steipete. - Plugins/Google Meet: add `googlemeet artifacts` and `googlemeet attendance` commands plus matching tool/gateway actions for conference records, recordings, transcripts, smart notes, and participant sessions. Thanks @steipete. +- Plugins/Google Meet: add `googlemeet doctor --oauth` so operators can verify OAuth token refresh, Meet space reads, and side-effecting space creation without printing secrets. Thanks @steipete. - Plugins/Voice Call: expose the shared `openclaw_agent_consult` realtime tool so live phone calls can ask the full OpenClaw agent for deeper/tool-backed answers. Thanks @steipete. - Plugins/Voice Call: add `voicecall setup` and a dry-run-by-default `voicecall smoke` command so Twilio/provider readiness can be checked before placing a live test call. Thanks @steipete. - Plugins/Google Meet: add `googlemeet doctor` and a `recover_current_tab`/`recover-tab` flow so agents can inspect an already-open Meet tab and report the blocker without opening another window. Thanks @steipete. diff --git a/docs/plugins/google-meet.md b/docs/plugins/google-meet.md index 930299d406e..96d342740fe 100644 --- a/docs/plugins/google-meet.md +++ b/docs/plugins/google-meet.md @@ -437,8 +437,51 @@ OAuth is optional for creating a Meet link because `googlemeet create` can fall back to browser automation. Configure OAuth when you want official API create, space resolution, or Meet Media API preflight checks. -Google Meet API access uses a personal OAuth client first. Configure -`oauth.clientId` and optionally `oauth.clientSecret`, then run: +Google Meet API access uses user OAuth: create a Google Cloud OAuth client, +request the required scopes, authorize a Google account, then store the +resulting refresh token in the Google Meet plugin config or provide the +`OPENCLAW_GOOGLE_MEET_*` environment variables. + +OAuth does not replace the Chrome join path. Chrome and Chrome-node transports +still join through a signed-in Chrome profile, BlackHole/SoX, and a connected +node when you use browser participation. OAuth is only for the official Google +Meet API path: create meeting spaces, resolve spaces, and run Meet Media API +preflight checks. + +### Create Google credentials + +In Google Cloud Console: + +1. Create or select a Google Cloud project. +2. Enable **Google Meet REST API** for that project. +3. Configure the OAuth consent screen. + - **Internal** is simplest for a Google Workspace organization. + - **External** works for personal/test setups; while the app is in Testing, + add each Google account that will authorize the app as a test user. +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.conference.media.readonly` +5. Create an OAuth client ID. + - Application type: **Web application**. + - Authorized redirect URI: + + ```text + http://localhost:8085/oauth2callback + ``` + +6. Copy the client ID and client secret. + +`meetings.space.created` is required by Google Meet `spaces.create`. +`meetings.space.readonly` lets OpenClaw resolve Meet URLs/codes to spaces. +`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. + +### Mint the refresh token + +Configure `oauth.clientId` and optionally `oauth.clientSecret`, or pass them as +environment variables, then run: ```bash openclaw googlemeet auth login --json @@ -448,11 +491,116 @@ The command prints an `oauth` config block with a refresh token. It uses PKCE, localhost callback on `http://localhost:8085/oauth2callback`, and a manual copy/paste flow with `--manual`. +Examples: + +```bash +OPENCLAW_GOOGLE_MEET_CLIENT_ID="your-client-id" \ +OPENCLAW_GOOGLE_MEET_CLIENT_SECRET="your-client-secret" \ +openclaw googlemeet auth login --json +``` + +Use manual mode when the browser cannot reach the local callback: + +```bash +OPENCLAW_GOOGLE_MEET_CLIENT_ID="your-client-id" \ +OPENCLAW_GOOGLE_MEET_CLIENT_SECRET="your-client-secret" \ +openclaw googlemeet auth login --json --manual +``` + +The JSON output includes: + +```json +{ + "oauth": { + "clientId": "your-client-id", + "clientSecret": "your-client-secret", + "refreshToken": "refresh-token", + "accessToken": "access-token", + "expiresAt": 1770000000000 + }, + "scope": "..." +} +``` + +Store the `oauth` object under the Google Meet plugin config: + +```json5 +{ + plugins: { + entries: { + "google-meet": { + enabled: true, + config: { + oauth: { + clientId: "your-client-id", + clientSecret: "your-client-secret", + refreshToken: "refresh-token", + }, + }, + }, + }, + }, +} +``` + +Prefer environment variables when you do not want the refresh token in config. +If both config and environment values are present, the plugin resolves config +first and then environment fallback. + The OAuth consent includes Meet space creation, Meet space read access, and Meet conference media read access. If you authenticated before meeting creation support existed, rerun `openclaw googlemeet auth login --json` so the refresh token has the `meetings.space.created` scope. +### Verify OAuth with doctor + +Run the OAuth doctor when you want a fast, non-secret health check: + +```bash +openclaw googlemeet doctor --oauth --json +``` + +This does not load the Chrome runtime or require a connected Chrome node. It +checks that OAuth config exists and that the refresh token can mint an access +token. The JSON report includes only status fields such as `ok`, `configured`, +`tokenSource`, `expiresAt`, and check messages; it does not print the access +token, refresh token, or client secret. + +Common results: + +| Check | Meaning | +| -------------------- | --------------------------------------------------------------------------------------- | +| `oauth-config` | `oauth.clientId` plus `oauth.refreshToken`, or a cached access token, is present. | +| `oauth-token` | The cached access token is still valid, or the refresh token minted a new access token. | +| `meet-spaces-get` | Optional `--meeting` check resolved an existing Meet space. | +| `meet-spaces-create` | Optional `--create-space` check created a new Meet space. | + +To prove Google Meet API enablement and `spaces.create` scope as well, run the +side-effecting create check: + +```bash +openclaw googlemeet doctor --oauth --create-space --json +openclaw googlemeet create --no-join --json +``` + +`--create-space` creates a throwaway Meet URL. Use it when you need to confirm +that the Google Cloud project has the Meet API enabled and that the authorized +account has the `meetings.space.created` scope. + +To prove read access for an existing meeting space: + +```bash +openclaw googlemeet doctor --oauth --meeting https://meet.google.com/abc-defg-hij --json +openclaw googlemeet resolve-space --meeting https://meet.google.com/abc-defg-hij +``` + +`doctor --oauth --meeting` and `resolve-space` prove read access to an existing +space that the authorized Google account can access. A `403` from these checks +usually means the Google Meet REST API is disabled, the consented refresh token +is missing the required scope, or the Google account cannot access that Meet +space. A refresh-token error means rerun `openclaw googlemeet auth login +--json` and store the new `oauth` block. + No OAuth credentials are needed for the browser fallback. In that mode, Google auth comes from the signed-in Chrome profile on the selected node, not from OpenClaw config. @@ -967,7 +1115,10 @@ Also verify: `googlemeet doctor [session-id]` prints the session, node, in-call state, manual action reason, realtime provider connection, `realtimeReady`, audio input/output activity, last audio timestamps, byte counters, and browser URL. -Use `googlemeet status [session-id]` when you need the raw JSON. +Use `googlemeet status [session-id]` when you need the raw JSON. Use +`googlemeet doctor --oauth` when you need to verify Google Meet OAuth refresh +without exposing tokens; add `--meeting` or `--create-space` when you need a +Google Meet API proof as well. If an agent timed out and you can see a Meet tab already open, inspect that tab without opening another one: diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index d5d97f5d90a..c5be3528d2a 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -966,6 +966,121 @@ describe("google-meet plugin", () => { } }); + it("CLI doctor verifies Google Meet OAuth refresh without printing secrets", async () => { + const program = new Command(); + const stdout = captureStdout(); + const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => { + return new Response( + JSON.stringify({ + access_token: "new-access-token", + expires_in: 3600, + token_type: "Bearer", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }); + vi.stubGlobal("fetch", fetchMock); + + const ensureRuntime = vi.fn(async () => { + throw new Error("runtime should not be loaded for OAuth doctor"); + }); + registerGoogleMeetCli({ + program, + config: resolveGoogleMeetConfig({ + oauth: { + clientId: "client-id", + clientSecret: "client-secret", + refreshToken: "rt-secret", + }, + }), + ensureRuntime: ensureRuntime as unknown as () => Promise, + }); + + try { + await program.parseAsync(["googlemeet", "doctor", "--oauth", "--json"], { from: "user" }); + const output = stdout.output(); + expect(output).not.toContain("new-access-token"); + expect(output).not.toContain("rt-secret"); + expect(output).not.toContain("client-secret"); + expect(JSON.parse(output)).toMatchObject({ + ok: true, + configured: true, + tokenSource: "refresh-token", + checks: [ + { id: "oauth-config", ok: true }, + { id: "oauth-token", ok: true }, + ], + }); + expect(ensureRuntime).not.toHaveBeenCalled(); + const body = fetchMock.mock.calls[0]?.[1]?.body as URLSearchParams; + expect(body.get("grant_type")).toBe("refresh_token"); + } finally { + stdout.restore(); + } + }); + + it("CLI doctor can prove Google Meet API create access", async () => { + const program = new Command(); + const stdout = captureStdout(); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url === "https://oauth2.googleapis.com/token") { + return new Response( + JSON.stringify({ + access_token: "new-access-token", + expires_in: 3600, + token_type: "Bearer", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (url === "https://meet.googleapis.com/v2/spaces") { + return new Response( + JSON.stringify({ + name: "spaces/new-space", + meetingUri: "https://meet.google.com/new-abcd-xyz", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + return new Response("not found", { status: 404 }); + }), + ); + + registerGoogleMeetCli({ + program, + config: resolveGoogleMeetConfig({ + oauth: { + clientId: "client-id", + refreshToken: "refresh-token", + }, + }), + ensureRuntime: async () => ({}) as GoogleMeetRuntime, + }); + + try { + await program.parseAsync(["googlemeet", "doctor", "--oauth", "--create-space", "--json"], { + from: "user", + }); + expect(JSON.parse(stdout.output())).toMatchObject({ + ok: true, + tokenSource: "refresh-token", + createdSpace: "spaces/new-space", + meetingUri: "https://meet.google.com/new-abcd-xyz", + checks: [ + { id: "oauth-config", ok: true }, + { id: "oauth-token", ok: true }, + { id: "meet-spaces-create", ok: true }, + ], + }); + } finally { + stdout.restore(); + } + }); + it("CLI recover-tab focuses and summarizes an existing Meet tab", async () => { const program = new Command(); const stdout = captureStdout(); diff --git a/extensions/google-meet/src/cli.ts b/extensions/google-meet/src/cli.ts index c2ce43744da..c2c6b7c7ba7 100644 --- a/extensions/google-meet/src/cli.ts +++ b/extensions/google-meet/src/cli.ts @@ -57,6 +57,18 @@ type SetupOptions = { json?: boolean; }; +type DoctorOptions = { + json?: boolean; + oauth?: boolean; + meeting?: string; + createSpace?: boolean; + accessToken?: string; + refreshToken?: string; + clientId?: string; + clientSecret?: string; + expiresAt?: string; +}; + type JsonOptions = { json?: boolean; }; @@ -173,6 +185,151 @@ function writeDoctorStatus(status: ReturnType): voi } } +type OAuthDoctorCheck = { + id: string; + ok: boolean; + message: string; +}; + +type OAuthDoctorReport = { + ok: boolean; + configured: boolean; + tokenSource?: "cached-access-token" | "refresh-token"; + expiresAt?: number; + scope?: string; + meetingUri?: string; + createdSpace?: string; + checks: OAuthDoctorCheck[]; +}; + +function sanitizeOAuthErrorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return message + .replace(/(access_token["'=:\s]+)[^"',\s&]+/gi, "$1[redacted]") + .replace(/(refresh_token["'=:\s]+)[^"',\s&]+/gi, "$1[redacted]") + .replace(/(client_secret["'=:\s]+)[^"',\s&]+/gi, "$1[redacted]"); +} + +async function buildOAuthDoctorReport( + config: GoogleMeetConfig, + options: DoctorOptions, +): Promise { + const clientId = options.clientId?.trim() || config.oauth.clientId; + const clientSecret = options.clientSecret?.trim() || config.oauth.clientSecret; + const refreshToken = options.refreshToken?.trim() || config.oauth.refreshToken; + const accessToken = options.accessToken?.trim() || config.oauth.accessToken; + const expiresAt = parseOptionalNumber(options.expiresAt) ?? config.oauth.expiresAt; + const checks: OAuthDoctorCheck[] = []; + + const hasRefreshConfig = Boolean(clientId && refreshToken); + const hasAccessConfig = Boolean(accessToken); + if (!hasRefreshConfig && !hasAccessConfig) { + checks.push({ + id: "oauth-config", + ok: false, + message: + "Missing Google Meet OAuth credentials. Configure oauth.clientId and oauth.refreshToken, or pass --client-id and --refresh-token.", + }); + return { ok: false, configured: false, checks }; + } + + checks.push({ + id: "oauth-config", + ok: true, + message: hasRefreshConfig + ? "Google Meet OAuth refresh credentials are configured" + : "Google Meet cached access token is configured", + }); + + let token: Awaited>; + try { + token = await resolveGoogleMeetAccessToken({ + clientId, + clientSecret, + refreshToken, + accessToken, + expiresAt, + }); + checks.push({ + id: "oauth-token", + ok: true, + message: token.refreshed + ? "Refresh token minted an access token" + : "Cached access token is still valid", + }); + } catch (error) { + checks.push({ + id: "oauth-token", + ok: false, + message: sanitizeOAuthErrorMessage(error), + }); + return { ok: false, configured: true, checks }; + } + + const report: OAuthDoctorReport = { + ok: true, + configured: true, + tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", + expiresAt: token.expiresAt, + checks, + }; + + const meeting = options.meeting?.trim(); + if (meeting) { + try { + const space = await fetchGoogleMeetSpace({ accessToken: token.accessToken, meeting }); + checks.push({ + id: "meet-spaces-get", + ok: true, + message: `Resolved ${space.name}`, + }); + report.meetingUri = space.meetingUri; + } catch (error) { + checks.push({ + id: "meet-spaces-get", + ok: false, + message: sanitizeOAuthErrorMessage(error), + }); + } + } + + if (options.createSpace) { + try { + const created = await createGoogleMeetSpace({ accessToken: token.accessToken }); + checks.push({ + id: "meet-spaces-create", + ok: true, + message: `Created ${created.space.name}`, + }); + report.createdSpace = created.space.name; + report.meetingUri = created.meetingUri; + } catch (error) { + checks.push({ + id: "meet-spaces-create", + ok: false, + message: sanitizeOAuthErrorMessage(error), + }); + } + } + + report.ok = checks.every((check) => check.ok); + return report; +} + +function writeOAuthDoctorReport(report: OAuthDoctorReport): void { + writeStdoutLine("Google Meet OAuth: %s", report.ok ? "OK" : "needs attention"); + writeStdoutLine("configured: %s", report.configured ? "yes" : "no"); + if (report.tokenSource) { + writeStdoutLine("token source: %s", report.tokenSource); + } + if (report.meetingUri) { + writeStdoutLine("meeting uri: %s", report.meetingUri); + } + for (const check of report.checks) { + writeStdoutLine("[%s] %s: %s", check.ok ? "ok" : "fail", check.id, check.message); + } +} + function writeRecoverCurrentTabResult( result: Awaited>, ): void { @@ -754,8 +911,25 @@ export function registerGoogleMeetCli(params: { .command("doctor") .description("Show human-readable Meet session/browser/realtime health") .argument("[session-id]", "Meet session ID") + .option("--oauth", "Verify Google Meet OAuth token refresh without printing secrets", false) + .option("--meeting ", "Also verify spaces.get for a Meet URL, code, or spaces/{id}") + .option("--create-space", "Also verify spaces.create by creating a throwaway Meet space", false) + .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 (sessionId: string | undefined, options: JsonOptions) => { + .action(async (sessionId: string | undefined, options: DoctorOptions) => { + if (options.oauth) { + const report = await buildOAuthDoctorReport(params.config, options); + if (options.json) { + writeStdoutJson(report); + return; + } + writeOAuthDoctorReport(report); + return; + } const rt = await params.ensureRuntime(); const status = rt.status(sessionId); if (options.json) {