Skip to content

Commit 5fa7d3b

Browse files
committed
fix: repair Google Meet media permission grants
1 parent 3e80805 commit 5fa7d3b

5 files changed

Lines changed: 178 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai
3131

3232
### Fixes
3333

34+
- Google Meet: grant Meet media permissions through the Playwright browser context when CDP grants do not affect the attached Chrome page, and report in-call microphone/speaker permission problems instead of marking realtime speech ready.
3435
- Control UI/WebChat: collapse duplicate in-flight internal text sends onto the active Gateway run so rapid repeat submits do not start fresh `agent:main:main` dispatches. Fixes #75737. Thanks @dsdsddd1 and @BunsDev.
3536
- Channels/streaming: expose `streaming.progress.label`, `labels`, `maxLines`, and `toolProgress` in bundled channel config metadata so progress draft settings appear in config, docs, and control surfaces. Thanks @vincentkoc.
3637
- Channels/streaming: normalize whitespace and case for `streaming.progress.label: "auto"` so progress draft labels keep using the built-in label pool instead of rendering a literal `auto` title. Thanks @vincentkoc.

extensions/browser/src/browser/routes/permissions.test.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@ const cdpMocks = vi.hoisted(() => ({
1919
),
2020
}));
2121

22+
const pwMocks = vi.hoisted(() => ({
23+
getPwAiModule: vi.fn(async () => null),
24+
grantPermissions: vi.fn(async () => {}),
25+
getPageForTargetId: vi.fn(async () => ({
26+
context: () => ({
27+
grantPermissions: pwMocks.grantPermissions,
28+
}),
29+
})),
30+
}));
31+
2232
vi.mock("../chrome.js", () => ({
2333
getChromeWebSocketUrl: cdpMocks.getChromeWebSocketUrl,
2434
}));
@@ -27,7 +37,7 @@ vi.mock("../cdp.helpers.js", () => ({
2737
withCdpSocket: cdpMocks.withCdpSocket,
2838
}));
2939

30-
const { registerBrowserPermissionRoutes } = await import("./permissions.js");
40+
const { registerBrowserPermissionRoutes, __testing } = await import("./permissions.js");
3141

3242
function createProfileContext() {
3343
return {
@@ -77,6 +87,42 @@ describe("browser permission routes", () => {
7787
cdpMocks.getChromeWebSocketUrl.mockClear();
7888
cdpMocks.send.mockReset().mockResolvedValue({});
7989
cdpMocks.withCdpSocket.mockClear();
90+
__testing.setDepsForTest(null);
91+
pwMocks.getPwAiModule.mockReset().mockResolvedValue(null);
92+
pwMocks.getPageForTargetId.mockClear();
93+
pwMocks.grantPermissions.mockClear();
94+
});
95+
96+
it("uses Playwright context permissions for attached pages when available", async () => {
97+
pwMocks.getPwAiModule.mockResolvedValue({
98+
getPageForTargetId: pwMocks.getPageForTargetId,
99+
} as never);
100+
__testing.setDepsForTest({ getPwAiModule: pwMocks.getPwAiModule as never });
101+
102+
const { response } = await callGrant({
103+
origin: "https://meet.google.com/abc-defg-hij",
104+
permissions: ["audioCapture", "videoCapture"],
105+
optionalPermissions: ["speakerSelection"],
106+
targetId: "meet-tab",
107+
});
108+
109+
expect(response.statusCode).toBe(200);
110+
expect(response.body).toMatchObject({
111+
ok: true,
112+
origin: "https://meet.google.com",
113+
grantedPermissions: ["audioCapture", "videoCapture"],
114+
unsupportedPermissions: ["speakerSelection"],
115+
grantMethod: "playwright",
116+
});
117+
expect(pwMocks.getPageForTargetId).toHaveBeenCalledWith({
118+
cdpUrl: "http://127.0.0.1:18800",
119+
targetId: "meet-tab",
120+
ssrfPolicy: { allowPrivateNetwork: false },
121+
});
122+
expect(pwMocks.grantPermissions).toHaveBeenCalledWith(["microphone", "camera"], {
123+
origin: "https://meet.google.com",
124+
});
125+
expect(cdpMocks.send).not.toHaveBeenCalled();
80126
});
81127

82128
it("grants required and optional Chrome permissions for an origin", async () => {

extensions/browser/src/browser/routes/permissions.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import type { SsrFPolicy } from "../../infra/net/ssrf.js";
12
import { withCdpSocket } from "../cdp.helpers.js";
23
import { getChromeWebSocketUrl } from "../chrome.js";
4+
import { getPwAiModule } from "../pw-ai-module.js";
35
import type { BrowserRouteContext } from "../server-context.js";
6+
import type { ProfileContext } from "../server-context.js";
47
import type { BrowserRouteRegistrar } from "./types.js";
58
import {
69
asyncBrowserRoute,
@@ -10,11 +13,22 @@ import {
1013
toStringOrEmpty,
1114
} from "./utils.js";
1215

16+
const permissionRouteDeps = {
17+
getPwAiModule,
18+
};
19+
20+
export const __testing = {
21+
setDepsForTest(deps: { getPwAiModule?: typeof getPwAiModule } | null) {
22+
permissionRouteDeps.getPwAiModule = deps?.getPwAiModule ?? getPwAiModule;
23+
},
24+
};
25+
1326
type GrantPermissionsBody = {
1427
origin?: unknown;
1528
permissions?: unknown;
1629
optionalPermissions?: unknown;
1730
timeoutMs?: unknown;
31+
targetId?: unknown;
1832
};
1933

2034
function readOrigin(raw: unknown): string | null {
@@ -47,15 +61,45 @@ function readPermissions(raw: unknown): string[] | null {
4761
}
4862

4963
async function grantPermissions(params: {
64+
profileCtx: ProfileContext;
65+
targetId?: string;
5066
wsUrl: string;
5167
origin: string;
5268
requiredPermissions: string[];
5369
optionalPermissions: string[];
5470
timeoutMs: number;
71+
ssrfPolicy?: SsrFPolicy;
5572
}) {
5673
const allPermissions = [
5774
...new Set([...params.requiredPermissions, ...params.optionalPermissions]),
5875
];
76+
const playwrightRequiredPermissions = params.requiredPermissions.map(toPlaywrightPermission);
77+
const canUsePlaywright =
78+
playwrightRequiredPermissions.every((value): value is string => Boolean(value)) &&
79+
params.requiredPermissions.length > 0;
80+
if (canUsePlaywright) {
81+
const pw = await permissionRouteDeps.getPwAiModule({ mode: "soft" });
82+
if (pw) {
83+
try {
84+
const page = await pw.getPageForTargetId({
85+
cdpUrl: params.profileCtx.profile.cdpUrl,
86+
targetId: params.targetId,
87+
ssrfPolicy: params.ssrfPolicy,
88+
});
89+
await page.context().grantPermissions(playwrightRequiredPermissions, {
90+
origin: params.origin,
91+
});
92+
return {
93+
grantedPermissions: params.requiredPermissions,
94+
unsupportedPermissions: params.optionalPermissions,
95+
grantMethod: "playwright",
96+
};
97+
} catch {
98+
// Fall back to the raw CDP browser command below. Some routes call this
99+
// before a page exists, while attached browser profiles need Playwright.
100+
}
101+
}
102+
}
59103
let unsupportedPermissions: string[] = [];
60104
await withCdpSocket(
61105
params.wsUrl,
@@ -82,9 +126,21 @@ async function grantPermissions(params: {
82126
return {
83127
grantedPermissions: allPermissions.filter((value) => !unsupportedPermissions.includes(value)),
84128
unsupportedPermissions,
129+
grantMethod: "cdp",
85130
};
86131
}
87132

133+
function toPlaywrightPermission(permission: string): string | undefined {
134+
switch (permission) {
135+
case "audioCapture":
136+
return "microphone";
137+
case "videoCapture":
138+
return "camera";
139+
default:
140+
return undefined;
141+
}
142+
}
143+
88144
export function registerBrowserPermissionRoutes(
89145
app: BrowserRouteRegistrar,
90146
ctx: BrowserRouteContext,
@@ -107,6 +163,7 @@ export function registerBrowserPermissionRoutes(
107163
return jsonError(res, 400, "permissions must be a non-empty string array");
108164
}
109165
const optionalPermissions = readPermissions(body.optionalPermissions ?? []) ?? [];
166+
const targetId = toStringOrEmpty(body.targetId) || undefined;
110167
const timeoutMs = Math.max(1_000, toNumber(body.timeoutMs) ?? 5_000);
111168

112169
try {
@@ -120,11 +177,14 @@ export function registerBrowserPermissionRoutes(
120177
return jsonError(res, 409, "browser CDP WebSocket unavailable");
121178
}
122179
const granted = await grantPermissions({
180+
profileCtx,
181+
targetId,
123182
wsUrl,
124183
origin,
125184
requiredPermissions,
126185
optionalPermissions,
127186
timeoutMs,
187+
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
128188
});
129189
return res.json({ ok: true, origin, ...granted });
130190
} catch (error) {

extensions/google-meet/index.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2118,6 +2118,64 @@ describe("google-meet plugin", () => {
21182118
expect(captionButton.click).toHaveBeenCalledTimes(1);
21192119
});
21202120

2121+
it("reports in-call Meet audio permission problems from button labels", () => {
2122+
const makeButton = (label: string) => ({
2123+
disabled: false,
2124+
innerText: "",
2125+
textContent: "",
2126+
click: vi.fn(),
2127+
getAttribute: vi.fn((name: string) => (name === "aria-label" ? label : null)),
2128+
});
2129+
const document = {
2130+
body: { innerText: "", textContent: "" },
2131+
title: "Meet",
2132+
querySelector: vi.fn(() => null),
2133+
querySelectorAll: vi.fn((selector: string) => {
2134+
if (selector === "button") {
2135+
return [
2136+
makeButton("Leave call"),
2137+
makeButton("Microphone problem. Show more info"),
2138+
makeButton("Microphone: Permission needed"),
2139+
makeButton("Speaker: Permission needed"),
2140+
];
2141+
}
2142+
if (selector === "input") {
2143+
return [];
2144+
}
2145+
return [];
2146+
}),
2147+
};
2148+
const context = createContext({
2149+
JSON,
2150+
document,
2151+
location: {
2152+
href: "https://meet.google.com/abc-defg-hij",
2153+
hostname: "meet.google.com",
2154+
},
2155+
window: {},
2156+
});
2157+
const inspect = new Script(
2158+
`(${chromeTransportTesting.meetStatusScriptForTest({
2159+
allowMicrophone: true,
2160+
autoJoin: false,
2161+
captureCaptions: false,
2162+
guestName: "OpenClaw Agent",
2163+
})})`,
2164+
).runInContext(context) as () => string;
2165+
2166+
const result = JSON.parse(inspect()) as {
2167+
inCall?: boolean;
2168+
manualActionRequired?: boolean;
2169+
manualActionReason?: string;
2170+
manualActionMessage?: string;
2171+
};
2172+
2173+
expect(result.inCall).toBe(true);
2174+
expect(result.manualActionRequired).toBe(true);
2175+
expect(result.manualActionReason).toBe("meet-permission-required");
2176+
expect(result.manualActionMessage).toContain("Allow microphone/camera/speaker permissions");
2177+
});
2178+
21212179
it("joins Chrome on a paired node without local Chrome or BlackHole", async () => {
21222180
const { methods, nodesList, nodesInvoke } = setup(
21232181
{

extensions/google-meet/src/transports/chrome.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -333,16 +333,19 @@ function meetStatusScript(params: {
333333
const allowMicrophone = ${JSON.stringify(params.allowMicrophone)};
334334
const captureCaptions = ${JSON.stringify(params.captureCaptions)};
335335
const buttons = [...document.querySelectorAll('button')];
336+
const buttonLabel = (button) =>
337+
[
338+
button.getAttribute("aria-label"),
339+
button.getAttribute("data-tooltip"),
340+
text(button),
341+
]
342+
.filter(Boolean)
343+
.join(" ");
344+
const buttonLabels = buttons.map(buttonLabel).filter(Boolean);
336345
const notes = [];
337346
const findButton = (pattern) =>
338347
buttons.find((button) => {
339-
const label = [
340-
button.getAttribute("aria-label"),
341-
button.getAttribute("data-tooltip"),
342-
text(button),
343-
]
344-
.filter(Boolean)
345-
.join(" ");
348+
const label = buttonLabel(button);
346349
return pattern.test(label) && !button.disabled;
347350
});
348351
const input = [...document.querySelectorAll('input')].find((el) =>
@@ -355,9 +358,10 @@ function meetStatusScript(params: {
355358
input.dispatchEvent(new Event('change', { bubbles: true }));
356359
}
357360
const pageText = text(document.body).toLowerCase();
361+
const permissionText = [pageText, ...buttonLabels].join("\\n");
358362
const host = location.hostname.toLowerCase();
359363
const pageUrl = location.href;
360-
const permissionNeeded = /permission needed|allow.*(microphone|camera)|blocked.*(microphone|camera)|permission.*(microphone|camera|speaker)/i.test(pageText);
364+
const permissionNeeded = /permission needed|microphone problem|speaker problem|allow.*(microphone|camera)|blocked.*(microphone|camera)|permission.*(microphone|camera|speaker)/i.test(permissionText);
361365
const mic = buttons.find((button) => /turn off microphone|turn on microphone|microphone/i.test(button.getAttribute('aria-label') || text(button)));
362366
if (!allowMicrophone && mic && /turn off microphone/i.test(mic.getAttribute('aria-label') || text(mic))) {
363367
mic.click();

0 commit comments

Comments
 (0)