From 1f7b7c249ad3c128384eebb17fc40e19d177b0df Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 14:53:33 +0100 Subject: [PATCH] fix(google-meet): grant browser media permissions --- CHANGELOG.md | 3 +- docs/plugins/google-meet.md | 18 +-- docs/tools/browser-control.md | 1 + .../browser/src/browser/routes/index.ts | 2 + .../src/browser/routes/permissions.test.ts | 133 +++++++++++++++++ .../browser/src/browser/routes/permissions.ts | 135 ++++++++++++++++++ extensions/google-meet/index.test.ts | 36 ++++- extensions/google-meet/openclaw.plugin.json | 12 +- extensions/google-meet/src/config.ts | 10 +- .../google-meet/src/transports/chrome.ts | 77 +++++++++- 10 files changed, 404 insertions(+), 23 deletions(-) create mode 100644 extensions/browser/src/browser/routes/permissions.test.ts create mode 100644 extensions/browser/src/browser/routes/permissions.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 630fdf397bd..706469d3a84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,8 @@ Docs: https://docs.openclaw.ai - Agents/subagents: enforce `subagents.allowAgents` for explicit same-agent `sessions_spawn(agentId=...)` calls instead of auto-allowing requester self-targets. Fixes #72827. Thanks @oiGaDio. - ACP/sessions_spawn: let explicit `sessions_spawn(runtime="acp")` bootstrap turns run while `acp.dispatch.enabled=false` still blocks automatic ACP thread dispatch. Fixes #63591. Thanks @moeedahmed. -- Google Meet: route local Chrome joins through OpenClaw browser control instead of raw default Chrome, so agents use the configured OpenClaw browser profile when opening Meet. +- Google Meet: grant Meet media permissions through browser control and pin local Chrome audio defaults to `BlackHole 2ch`, so joined agents no longer show `Permission needed` or use macOS default audio devices. Thanks @openclaw. +- Google Meet: route local Chrome joins through OpenClaw browser control instead of raw default Chrome, so agents use the configured OpenClaw browser profile when opening Meet. Thanks @openclaw. - Plugins/discovery: follow symlinked plugin directories in global and workspace plugin roots while keeping broken links ignored and existing package safety checks in place. Fixes #36754; carries forward #72695 and #63206. Thanks @Quackstro, @ming1523, and @xsfX20. - Plugins/install: allow exact package-manager peer links back to the trusted OpenClaw host package during install security scans while continuing to block spoofed or nested escaping `node_modules` symlinks. Carries forward #70819. Thanks @fgabelmannjr. - Plugins/install: resolve plugin install destinations from the active profile state dir across CLI, ClawHub, marketplace, local path, and channel setup installs, so `openclaw --profile plugins install ...` no longer writes into the default profile. Fixes #69960; carries forward #69971. Thanks @FrancisLyman and @Sanjays2402. diff --git a/docs/plugins/google-meet.md b/docs/plugins/google-meet.md index 93304fa2839..264b5e9b979 100644 --- a/docs/plugins/google-meet.md +++ b/docs/plugins/google-meet.md @@ -50,7 +50,7 @@ After reboot, verify both pieces: ```bash system_profiler SPAudioDataType | grep -i BlackHole -command -v rec play +command -v sox ``` Enable the plugin: @@ -192,7 +192,7 @@ After reboot, verify the VM can see the audio device and SoX commands: ```bash system_profiler SPAudioDataType | grep -i BlackHole -command -v rec play +command -v sox ``` Install or update OpenClaw in the VM, then enable the bundled plugin there: @@ -335,8 +335,8 @@ Common failure checks: The Chrome realtime default uses two external tools: -- `sox`: command-line audio utility. The plugin uses its `rec` and `play` - commands for the default 24 kHz PCM16 audio bridge. +- `sox`: command-line audio utility. The plugin uses explicit CoreAudio + device commands for the default 24 kHz PCM16 audio bridge. - `blackhole-2ch`: macOS virtual audio driver. It creates the `BlackHole 2ch` audio device that Chrome/Meet can route through. @@ -892,10 +892,10 @@ Defaults: - `chrome.audioFormat: "pcm16-24khz"`: command-pair audio format. Use `"g711-ulaw-8khz"` only for legacy/custom command pairs that still emit telephony audio. -- `chrome.audioInputCommand`: SoX `rec` command writing audio in - `chrome.audioFormat` -- `chrome.audioOutputCommand`: SoX `play` command reading audio in - `chrome.audioFormat` +- `chrome.audioInputCommand`: SoX command reading from CoreAudio `BlackHole 2ch` + and writing audio in `chrome.audioFormat` +- `chrome.audioOutputCommand`: SoX command reading audio in `chrome.audioFormat` + and writing to CoreAudio `BlackHole 2ch` - `realtime.provider: "openai"` - `realtime.toolPolicy: "safe-read-only"` - `realtime.instructions`: brief spoken replies, with @@ -1231,7 +1231,7 @@ Also verify: - A realtime provider key is available on the Gateway host, such as `OPENAI_API_KEY` or `GEMINI_API_KEY`. - `BlackHole 2ch` is visible on the Chrome host. -- `rec` and `play` exist on the Chrome host. +- `sox` exists on the Chrome host. - Meet microphone and speaker are routed through the virtual audio path used by OpenClaw. diff --git a/docs/tools/browser-control.md b/docs/tools/browser-control.md index 4df81217457..f817213f4dd 100644 --- a/docs/tools/browser-control.md +++ b/docs/tools/browser-control.md @@ -21,6 +21,7 @@ For local integrations only, the Gateway exposes a small loopback HTTP API: - Actions: `POST /navigate`, `POST /act` - Hooks: `POST /hooks/file-chooser`, `POST /hooks/dialog` - Downloads: `POST /download`, `POST /wait/download` +- Permissions: `POST /permissions/grant` - Debugging: `GET /console`, `POST /pdf` - Debugging: `GET /errors`, `GET /requests`, `POST /trace/start`, `POST /trace/stop`, `POST /highlight` - Network: `POST /response/body` diff --git a/extensions/browser/src/browser/routes/index.ts b/extensions/browser/src/browser/routes/index.ts index 3c20ef1c646..c3b6cdaf7db 100644 --- a/extensions/browser/src/browser/routes/index.ts +++ b/extensions/browser/src/browser/routes/index.ts @@ -1,11 +1,13 @@ import type { BrowserRouteContext } from "../server-context.js"; import { registerBrowserAgentRoutes } from "./agent.js"; import { registerBrowserBasicRoutes } from "./basic.js"; +import { registerBrowserPermissionRoutes } from "./permissions.js"; import { registerBrowserTabRoutes } from "./tabs.js"; import type { BrowserRouteRegistrar } from "./types.js"; export function registerBrowserRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) { registerBrowserBasicRoutes(app, ctx); registerBrowserTabRoutes(app, ctx); + registerBrowserPermissionRoutes(app, ctx); registerBrowserAgentRoutes(app, ctx); } diff --git a/extensions/browser/src/browser/routes/permissions.test.ts b/extensions/browser/src/browser/routes/permissions.test.ts new file mode 100644 index 00000000000..4e52ca2ec06 --- /dev/null +++ b/extensions/browser/src/browser/routes/permissions.test.ts @@ -0,0 +1,133 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createBrowserRouteApp, createBrowserRouteResponse } from "./test-helpers.js"; + +const cdpMocks = vi.hoisted(() => ({ + getChromeWebSocketUrl: vi.fn(async () => "ws://127.0.0.1:18800/devtools/browser/test"), + send: vi.fn( + async ( + _method: string, + _params?: Record, + ): Promise> => ({}), + ), + withCdpSocket: vi.fn( + async ( + _wsUrl: string, + fn: ( + send: (method: string, params?: Record) => Promise, + ) => Promise, + ) => await fn(cdpMocks.send), + ), +})); + +vi.mock("../chrome.js", () => ({ + getChromeWebSocketUrl: cdpMocks.getChromeWebSocketUrl, +})); + +vi.mock("../cdp.helpers.js", () => ({ + withCdpSocket: cdpMocks.withCdpSocket, +})); + +const { registerBrowserPermissionRoutes } = await import("./permissions.js"); + +function createProfileContext() { + return { + profile: { + name: "openclaw", + cdpUrl: "http://127.0.0.1:18800", + }, + ensureBrowserAvailable: vi.fn(async () => {}), + ensureTabAvailable: vi.fn(), + isHttpReachable: vi.fn(), + isTransportAvailable: vi.fn(), + isReachable: vi.fn(), + listTabs: vi.fn(), + openTab: vi.fn(), + labelTab: vi.fn(), + focusTab: vi.fn(), + closeTab: vi.fn(), + stopRunningBrowser: vi.fn(), + resetProfile: vi.fn(), + }; +} + +function createRouteContext(profileCtx: ReturnType) { + return { + state: () => ({ resolved: { ssrfPolicy: { allowPrivateNetwork: false } } }), + forProfile: () => profileCtx, + listProfiles: vi.fn(async () => []), + mapTabError: vi.fn(() => null), + ...profileCtx, + }; +} + +async function callGrant(body: Record) { + const { app, postHandlers } = createBrowserRouteApp(); + const profileCtx = createProfileContext(); + registerBrowserPermissionRoutes(app, createRouteContext(profileCtx) as never); + const handler = postHandlers.get("/permissions/grant"); + expect(handler).toBeTypeOf("function"); + + const response = createBrowserRouteResponse(); + await handler?.({ params: {}, query: {}, body }, response.res); + return { response, profileCtx }; +} + +describe("browser permission routes", () => { + beforeEach(() => { + cdpMocks.getChromeWebSocketUrl.mockClear(); + cdpMocks.send.mockReset().mockResolvedValue({}); + cdpMocks.withCdpSocket.mockClear(); + }); + + it("grants required and optional Chrome permissions for an origin", async () => { + const { response, profileCtx } = await callGrant({ + origin: "https://meet.google.com/abc-defg-hij", + permissions: ["audioCapture", "videoCapture"], + optionalPermissions: ["speakerSelection"], + timeoutMs: 1234, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toMatchObject({ + ok: true, + origin: "https://meet.google.com", + grantedPermissions: ["audioCapture", "videoCapture", "speakerSelection"], + unsupportedPermissions: [], + }); + expect(profileCtx.ensureBrowserAvailable).toHaveBeenCalled(); + expect(cdpMocks.getChromeWebSocketUrl).toHaveBeenCalledWith("http://127.0.0.1:18800", 1234, { + allowPrivateNetwork: false, + }); + expect(cdpMocks.send).toHaveBeenCalledWith("Browser.grantPermissions", { + origin: "https://meet.google.com", + permissions: ["audioCapture", "videoCapture", "speakerSelection"], + }); + }); + + it("keeps required permissions when an optional permission is unsupported", async () => { + cdpMocks.send.mockImplementation(async (_method: string, params?: Record) => { + const permissions = Array.isArray(params?.permissions) ? params.permissions : []; + if (permissions.includes("speakerSelection")) { + throw new Error("Unknown permission type"); + } + return {}; + }); + + const { response } = await callGrant({ + origin: "https://meet.google.com", + permissions: ["audioCapture", "videoCapture"], + optionalPermissions: ["speakerSelection"], + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toMatchObject({ + ok: true, + grantedPermissions: ["audioCapture", "videoCapture"], + unsupportedPermissions: ["speakerSelection"], + }); + expect(cdpMocks.send).toHaveBeenNthCalledWith(2, "Browser.grantPermissions", { + origin: "https://meet.google.com", + permissions: ["audioCapture", "videoCapture"], + }); + }); +}); diff --git a/extensions/browser/src/browser/routes/permissions.ts b/extensions/browser/src/browser/routes/permissions.ts new file mode 100644 index 00000000000..71d3e4ea2e8 --- /dev/null +++ b/extensions/browser/src/browser/routes/permissions.ts @@ -0,0 +1,135 @@ +import { withCdpSocket } from "../cdp.helpers.js"; +import { getChromeWebSocketUrl } from "../chrome.js"; +import type { BrowserRouteContext } from "../server-context.js"; +import type { BrowserRouteRegistrar } from "./types.js"; +import { + asyncBrowserRoute, + getProfileContext, + jsonError, + toNumber, + toStringOrEmpty, +} from "./utils.js"; + +type GrantPermissionsBody = { + origin?: unknown; + permissions?: unknown; + optionalPermissions?: unknown; + timeoutMs?: unknown; +}; + +function readOrigin(raw: unknown): string | null { + const value = toStringOrEmpty(raw); + if (!value) { + return null; + } + try { + const parsed = new URL(value); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return null; + } + return parsed.origin; + } catch { + return null; + } +} + +function readPermissions(raw: unknown): string[] | null { + if (!Array.isArray(raw)) { + return null; + } + const permissions = raw + .map((value) => (typeof value === "string" ? value.trim() : "")) + .filter(Boolean); + if (permissions.length !== raw.length) { + return null; + } + return [...new Set(permissions)]; +} + +async function grantPermissions(params: { + wsUrl: string; + origin: string; + requiredPermissions: string[]; + optionalPermissions: string[]; + timeoutMs: number; +}) { + const allPermissions = [ + ...new Set([...params.requiredPermissions, ...params.optionalPermissions]), + ]; + let unsupportedPermissions: string[] = []; + await withCdpSocket( + params.wsUrl, + async (send) => { + try { + await send("Browser.grantPermissions", { + origin: params.origin, + permissions: allPermissions, + }); + return; + } catch (error) { + if (params.optionalPermissions.length === 0) { + throw error; + } + } + await send("Browser.grantPermissions", { + origin: params.origin, + permissions: params.requiredPermissions, + }); + unsupportedPermissions = params.optionalPermissions; + }, + { commandTimeoutMs: params.timeoutMs }, + ); + return { + grantedPermissions: allPermissions.filter((value) => !unsupportedPermissions.includes(value)), + unsupportedPermissions, + }; +} + +export function registerBrowserPermissionRoutes( + app: BrowserRouteRegistrar, + ctx: BrowserRouteContext, +) { + app.post( + "/permissions/grant", + asyncBrowserRoute(async (req, res) => { + const profileCtx = getProfileContext(req, ctx); + if ("error" in profileCtx) { + return jsonError(res, profileCtx.status, profileCtx.error); + } + + const body = (req.body ?? {}) as GrantPermissionsBody; + const origin = readOrigin(body.origin); + if (!origin) { + return jsonError(res, 400, "origin must be an http(s) origin"); + } + const requiredPermissions = readPermissions(body.permissions); + if (!requiredPermissions || requiredPermissions.length === 0) { + return jsonError(res, 400, "permissions must be a non-empty string array"); + } + const optionalPermissions = readPermissions(body.optionalPermissions ?? []) ?? []; + const timeoutMs = Math.max(1_000, toNumber(body.timeoutMs) ?? 5_000); + + try { + await profileCtx.ensureBrowserAvailable(); + const wsUrl = await getChromeWebSocketUrl( + profileCtx.profile.cdpUrl, + timeoutMs, + ctx.state().resolved.ssrfPolicy, + ); + if (!wsUrl) { + return jsonError(res, 409, "browser CDP WebSocket unavailable"); + } + const granted = await grantPermissions({ + wsUrl, + origin, + requiredPermissions, + optionalPermissions, + timeoutMs, + }); + return res.json({ ok: true, origin, ...granted }); + } catch (error) { + return jsonError(res, 500, error instanceof Error ? error.message : String(error)); + } + }), + ); +} diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index 84786ef3d59..eb604497156 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -124,6 +124,14 @@ function mockLocalMeetBrowserRequest( if (request.path === "/tabs/focus") { return { ok: true }; } + if (request.path === "/permissions/grant") { + return { + ok: true, + origin: "https://meet.google.com", + grantedPermissions: ["audioCapture", "videoCapture", "speakerSelection"], + unsupportedPermissions: [], + }; + } if (request.path === "/act") { return { result: JSON.stringify(browserActResult) }; } @@ -299,9 +307,12 @@ describe("google-meet plugin", () => { waitForInCallMs: 20000, audioFormat: "pcm16-24khz", audioInputCommand: [ - "rec", + "sox", "-q", "-t", + "coreaudio", + "BlackHole 2ch", + "-t", "raw", "-r", "24000", @@ -315,7 +326,7 @@ describe("google-meet plugin", () => { "-", ], audioOutputCommand: [ - "play", + "sox", "-q", "-t", "raw", @@ -329,6 +340,9 @@ describe("google-meet plugin", () => { "16", "-L", "-", + "-t", + "coreaudio", + "BlackHole 2ch", ], }, voiceCall: { enabled: true, requestTimeoutMs: 30000, dtmfDelayMs: 2500 }, @@ -1253,7 +1267,7 @@ describe("google-meet plugin", () => { if (argv[0] === "/usr/sbin/system_profiler") { return { code: 0, stdout: "BlackHole 2ch", stderr: "" }; } - if (argv[0] === "/bin/sh" && argv.at(-1) === "play") { + if (argv[0] === "/bin/sh" && argv.at(-1) === "sox") { return { code: 1, stdout: "", stderr: "" }; } return { code: 0, stdout: "", stderr: "" }; @@ -1275,7 +1289,7 @@ describe("google-meet plugin", () => { expect.objectContaining({ id: "chrome-local-audio-commands", ok: false, - message: "Chrome audio command missing: play", + message: "Chrome audio command missing: sox", }), ]), ); @@ -1410,6 +1424,20 @@ describe("google-meet plugin", () => { }), { progress: false }, ); + expect(callGatewayFromCli).toHaveBeenCalledWith( + "browser.request", + expect.any(Object), + expect.objectContaining({ + method: "POST", + path: "/permissions/grant", + body: expect.objectContaining({ + origin: "https://meet.google.com", + permissions: ["audioCapture", "videoCapture"], + optionalPermissions: ["speakerSelection"], + }), + }), + { progress: false }, + ); } finally { Object.defineProperty(process, "platform", { value: originalPlatform }); } diff --git a/extensions/google-meet/openclaw.plugin.json b/extensions/google-meet/openclaw.plugin.json index 18a40ee20e7..decfb20eef5 100644 --- a/extensions/google-meet/openclaw.plugin.json +++ b/extensions/google-meet/openclaw.plugin.json @@ -245,9 +245,12 @@ "audioInputCommand": { "type": "array", "default": [ - "rec", + "sox", "-q", "-t", + "coreaudio", + "BlackHole 2ch", + "-t", "raw", "-r", "24000", @@ -267,7 +270,7 @@ "audioOutputCommand": { "type": "array", "default": [ - "play", + "sox", "-q", "-t", "raw", @@ -280,7 +283,10 @@ "-b", "16", "-L", - "-" + "-", + "-t", + "coreaudio", + "BlackHole 2ch" ], "items": { "type": "string" diff --git a/extensions/google-meet/src/config.ts b/extensions/google-meet/src/config.ts index 43a419899b8..57b28191b47 100644 --- a/extensions/google-meet/src/config.ts +++ b/extensions/google-meet/src/config.ts @@ -79,9 +79,12 @@ export type GoogleMeetConfig = { }; export const DEFAULT_GOOGLE_MEET_AUDIO_INPUT_COMMAND = [ - "rec", + "sox", "-q", "-t", + "coreaudio", + "BlackHole 2ch", + "-t", "raw", "-r", "24000", @@ -96,7 +99,7 @@ export const DEFAULT_GOOGLE_MEET_AUDIO_INPUT_COMMAND = [ ] as const; export const DEFAULT_GOOGLE_MEET_AUDIO_OUTPUT_COMMAND = [ - "play", + "sox", "-q", "-t", "raw", @@ -110,6 +113,9 @@ export const DEFAULT_GOOGLE_MEET_AUDIO_OUTPUT_COMMAND = [ "16", "-L", "-", + "-t", + "coreaudio", + "BlackHole 2ch", ] as const; export const LEGACY_GOOGLE_MEET_AUDIO_INPUT_COMMAND = [ diff --git a/extensions/google-meet/src/transports/chrome.ts b/extensions/google-meet/src/transports/chrome.ts index b57bc9cfe21..65b0575eda4 100644 --- a/extensions/google-meet/src/transports/chrome.ts +++ b/extensions/google-meet/src/transports/chrome.ts @@ -245,6 +245,57 @@ async function callLocalBrowserRequest(params: BrowserRequestParams) { ); } +function mergeBrowserNotes( + browser: GoogleMeetChromeHealth | undefined, + notes: string[], +): GoogleMeetChromeHealth | undefined { + if (!browser || notes.length === 0) { + return browser; + } + return { + ...browser, + notes: [...new Set([...(browser.notes ?? []), ...notes])], + }; +} + +function parsePermissionGrantNotes(result: unknown): string[] { + const record = result && typeof result === "object" ? (result as Record) : {}; + const unsupportedPermissions = Array.isArray(record.unsupportedPermissions) + ? record.unsupportedPermissions.filter((value): value is string => typeof value === "string") + : []; + const notes = ["Granted Meet microphone/camera permissions through browser control."]; + if (unsupportedPermissions.includes("speakerSelection")) { + notes.push("Chrome did not accept the optional Meet speaker-selection permission."); + } + return notes; +} + +async function grantMeetMediaPermissions(params: { + callBrowser: BrowserRequestCaller; + timeoutMs: number; +}): Promise { + try { + const result = await params.callBrowser({ + method: "POST", + path: "/permissions/grant", + body: { + origin: "https://meet.google.com", + permissions: ["audioCapture", "videoCapture"], + optionalPermissions: ["speakerSelection"], + timeoutMs: Math.min(params.timeoutMs, 5_000), + }, + timeoutMs: Math.min(params.timeoutMs, 5_000), + }); + return parsePermissionGrantNotes(result); + } catch (error) { + return [ + `Could not grant Meet media permissions automatically: ${ + error instanceof Error ? error.message : String(error) + }`, + ]; + } +} + function meetStatusScript(params: { guestName: string; autoJoin: boolean }) { return `() => { const text = (node) => (node?.innerText || node?.textContent || "").trim(); @@ -273,6 +324,7 @@ function meetStatusScript(params: { guestName: string; autoJoin: boolean }) { const pageText = text(document.body).toLowerCase(); const host = location.hostname.toLowerCase(); const pageUrl = location.href; + const permissionNeeded = /permission needed|allow.*(microphone|camera)|blocked.*(microphone|camera)|permission.*(microphone|camera|speaker)/i.test(pageText); const join = ${JSON.stringify(params.autoJoin)} ? findButton(/join now|ask to join/i) : null; @@ -292,9 +344,9 @@ function meetStatusScript(params: { guestName: string; autoJoin: boolean }) { } else if (!inCall && /asking to be let in|you.?ll join when someone lets you in|waiting to be let in|ask to join/i.test(pageText)) { manualActionReason = "meet-admission-required"; manualActionMessage = "Admit the OpenClaw browser participant in Google Meet, then retry speech."; - } else if (!inCall && /allow.*(microphone|camera)|blocked.*(microphone|camera)|permission.*(microphone|camera)/i.test(pageText)) { + } else if (permissionNeeded) { manualActionReason = "meet-permission-required"; - manualActionMessage = "Allow microphone/camera permissions for Meet in the OpenClaw browser profile, then retry."; + manualActionMessage = "Allow microphone/camera/speaker permissions for Meet in the OpenClaw browser profile, then retry."; } else if (!inCall && !microphoneChoice && /do you want people to hear you in the meeting/i.test(pageText)) { manualActionReason = "meet-audio-choice-required"; manualActionMessage = "Meet is showing the microphone choice. Click Use microphone in the OpenClaw browser profile, then retry."; @@ -389,11 +441,16 @@ async function openMeetWithBrowserRequest(params: { }; } + const permissionNotes = await grantMeetMediaPermissions({ + callBrowser: params.callBrowser, + timeoutMs, + }); const deadline = Date.now() + Math.max(0, params.config.chrome.waitForInCallMs); let browser: GoogleMeetChromeHealth | undefined = { status: "browser-control", browserUrl: tab?.url, browserTitle: tab?.title, + notes: permissionNotes, }; do { try { @@ -410,7 +467,7 @@ async function openMeetWithBrowserRequest(params: { }, timeoutMs: Math.min(timeoutMs, 10_000), }); - browser = parseMeetBrowserStatus(evaluated) ?? browser; + browser = mergeBrowserNotes(parseMeetBrowserStatus(evaluated) ?? browser, permissionNotes); if (browser?.inCall === true) { return { launched: true, browser }; } @@ -426,6 +483,7 @@ async function openMeetWithBrowserRequest(params: { manualActionMessage: "Open the OpenClaw browser profile, finish Google Meet login, admission, or permission prompts, then retry.", notes: [ + ...permissionNotes, `Browser control could not inspect or auto-join Meet: ${ error instanceof Error ? error.message : String(error) }`, @@ -467,6 +525,10 @@ async function inspectRecoverableMeetTab(params: { body: { targetId: params.targetId }, timeoutMs: Math.min(params.timeoutMs, 5_000), }); + const permissionNotes = await grantMeetMediaPermissions({ + callBrowser: params.callBrowser, + timeoutMs: params.timeoutMs, + }); const evaluated = await params.callBrowser({ method: "POST", path: "/act", @@ -480,7 +542,14 @@ async function inspectRecoverableMeetTab(params: { }, timeoutMs: Math.min(params.timeoutMs, 10_000), }); - const browser = parseMeetBrowserStatus(evaluated); + const browser = mergeBrowserNotes( + parseMeetBrowserStatus(evaluated) ?? { + status: "browser-control", + browserUrl: params.tab.url, + browserTitle: params.tab.title, + }, + permissionNotes, + ); const manual = browser?.manualActionRequired ? browser.manualActionMessage || browser.manualActionReason : undefined;