fix(google-meet): harden observe mode speech health (#73256)

* fix(google-meet): harden observe mode speech health

* fix(google-meet): address observe speech review

* docs(google-meet): clarify observe mode guarantees
This commit is contained in:
Peter Steinberger
2026-04-28 06:21:10 +01:00
committed by GitHub
parent 2633b14914
commit 25851e3cae
10 changed files with 398 additions and 154 deletions

View File

@@ -110,7 +110,10 @@ function mockLocalMeetBrowserRequest(
params?: unknown,
_extra?: unknown,
): Promise<Record<string, unknown>> => {
const request = params as { path?: string; body?: { targetId?: string; url?: string } };
const request = params as {
path?: string;
body?: { fn?: string; targetId?: string; url?: string };
};
if (request.path === "/tabs") {
return { tabs: [] };
}
@@ -1298,6 +1301,52 @@ describe("google-meet plugin", () => {
}
});
it("skips local Chrome audio prerequisites for observe-only setup status", async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", { value: "darwin" });
try {
const { tools, runCommandWithTimeout } = setup(
{ defaultMode: "transcribe", defaultTransport: "chrome" },
{
runCommandWithTimeoutHandler: async () => ({
code: 1,
stdout: "Built-in Output",
stderr: "",
}),
},
);
const tool = tools[0] as {
execute: (
id: string,
params: unknown,
) => Promise<{ details: { ok?: boolean; checks?: Array<{ id?: string; ok?: boolean }> } }>;
};
const result = await tool.execute("id", {
action: "setup_status",
transport: "chrome",
mode: "transcribe",
});
expect(result.details.ok).toBe(true);
expect(result.details.checks).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "audio-bridge",
ok: true,
message: "Chrome observe-only mode does not require a realtime audio bridge",
}),
]),
);
expect(result.details.checks?.some((check) => check.id === "chrome-local-audio-device")).toBe(
false,
);
expect(runCommandWithTimeout).not.toHaveBeenCalled();
} finally {
Object.defineProperty(process, "platform", { value: originalPlatform });
}
});
it("reports Twilio delegation readiness when voice-call is enabled", async () => {
vi.stubEnv("TWILIO_ACCOUNT_SID", "AC123");
vi.stubEnv("TWILIO_AUTH_TOKEN", "secret");
@@ -1386,7 +1435,7 @@ describe("google-meet plugin", () => {
);
});
it("opens local Chrome Meet through browser control after the BlackHole check", async () => {
it("opens local Chrome Meet in observe-only mode without BlackHole checks", async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", { value: "darwin" });
try {
@@ -1408,12 +1457,7 @@ describe("google-meet plugin", () => {
});
expect(respond.mock.calls[0]?.[0]).toBe(true);
expect(runCommandWithTimeout).toHaveBeenNthCalledWith(
1,
["/usr/sbin/system_profiler", "SPAudioDataType"],
{ timeoutMs: 10000 },
);
expect(runCommandWithTimeout).toHaveBeenCalledTimes(1);
expect(runCommandWithTimeout).not.toHaveBeenCalled();
expect(callGatewayFromCli).toHaveBeenCalledWith(
"browser.request",
expect.any(Object),
@@ -1424,19 +1468,16 @@ 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 },
expect(
callGatewayFromCli.mock.calls.some(
([, , request]) => (request as { path?: string }).path === "/permissions/grant",
),
).toBe(false);
const actCall = callGatewayFromCli.mock.calls.find(
([, , request]) => (request as { path?: string }).path === "/act",
);
expect(String((actCall?.[2] as { body?: { fn?: string } } | undefined)?.body?.fn)).toContain(
"const allowMicrophone = false",
);
} finally {
Object.defineProperty(process, "platform", { value: originalPlatform });
@@ -1883,9 +1924,14 @@ describe("google-meet plugin", () => {
updatedAt: "2026-04-27T00:00:00.000Z",
participantIdentity: "signed-in Google Chrome profile",
realtime: { enabled: true, provider: "openai", toolPolicy: "safe-read-only" },
chrome: { audioBackend: "blackhole-2ch", launched: true },
chrome: {
audioBackend: "blackhole-2ch",
launched: true,
health: { audioOutputActive: true, lastOutputBytes: 10 },
},
notes: [],
};
vi.spyOn(runtime, "list").mockReturnValue([session]);
const join = vi.spyOn(runtime, "join").mockResolvedValue({ session, spoken: true });
const speak = vi.spyOn(runtime, "speak");
@@ -1894,9 +1940,32 @@ describe("google-meet plugin", () => {
message: "Say exactly: hello.",
});
expect(join).toHaveBeenCalledWith(expect.objectContaining({ message: "Say exactly: hello." }));
expect(join).toHaveBeenCalledWith(
expect.objectContaining({
message: "Say exactly: hello.",
mode: "realtime",
}),
);
expect(speak).not.toHaveBeenCalled();
expect(result.spoken).toBe(true);
expect(result.speechOutputVerified).toBe(false);
expect(result.speechOutputTimedOut).toBe(false);
});
it("rejects observe-only mode for test speech", async () => {
const runtime = new GoogleMeetRuntime({
config: resolveGoogleMeetConfig({}),
fullConfig: {} as never,
runtime: {} as never,
logger: noopLogger,
});
await expect(
runtime.testSpeech({
url: "https://meet.google.com/abc-defg-hij",
mode: "transcribe",
}),
).rejects.toThrow("test_speech requires mode: realtime");
});
it("reports manual action when the browser profile needs Google login", async () => {

View File

@@ -677,7 +677,13 @@ export default definePluginEntry({
async ({ params, respond }: GatewayRequestHandlerOptions) => {
try {
const rt = await ensureRuntime();
respond(true, await rt.setupStatus({ transport: normalizeTransport(params?.transport) }));
respond(
true,
await rt.setupStatus({
transport: normalizeTransport(params?.transport),
mode: normalizeMode(params?.mode),
}),
);
} catch (err) {
sendError(respond, err);
}

View File

@@ -1,3 +1,4 @@
import { spawnSync } from "node:child_process";
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
@@ -40,6 +41,35 @@ vi.mock("node:child_process", async (importOriginal) => {
});
describe("google-meet node host bridge sessions", () => {
it("starts observe-only Chrome without BlackHole or bridge processes", async () => {
const { handleGoogleMeetNodeHostCommand } = await import("./src/node-host.js");
const originalPlatform = process.platform;
children.length = 0;
vi.mocked(spawnSync).mockClear();
Object.defineProperty(process, "platform", { configurable: true, value: "darwin" });
try {
const start = JSON.parse(
await handleGoogleMeetNodeHostCommand(
JSON.stringify({
action: "start",
url: "https://meet.google.com/xyz-abcd-uvw",
mode: "transcribe",
launch: false,
audioInputCommand: ["mock-rec"],
audioOutputCommand: ["mock-play"],
}),
),
);
expect(start).toEqual({ launched: false });
expect(spawnSync).not.toHaveBeenCalled();
expect(children).toHaveLength(0);
} finally {
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
}
});
it("clears output playback without closing the active bridge when the old output exits", async () => {
const { handleGoogleMeetNodeHostCommand } = await import("./src/node-host.js");
const originalPlatform = process.platform;

View File

@@ -129,6 +129,7 @@ export type GoogleMeetExportManifest = {
type SetupOptions = {
json?: boolean;
mode?: GoogleMeetMode;
transport?: GoogleMeetTransport;
};
@@ -1986,10 +1987,11 @@ export function registerGoogleMeetCli(params: {
.command("setup")
.description("Show Google Meet transport setup status")
.option("--transport <transport>", "Transport to check: chrome, chrome-node, or twilio")
.option("--mode <mode>", "Mode to check: realtime or transcribe")
.option("--json", "Print JSON output", false)
.action(async (options: SetupOptions) => {
const rt = await params.ensureRuntime();
const status = await rt.setupStatus({ transport: options.transport });
const status = await rt.setupStatus({ transport: options.transport, mode: options.mode });
if (options.json) {
writeStdoutJson(status);
return;

View File

@@ -270,42 +270,46 @@ function startChrome(params: Record<string, unknown>) {
throw new Error("url required");
}
const timeoutMs = readNumber(params.joinTimeoutMs, 30_000);
assertBlackHoleAvailable(Math.min(timeoutMs, 10_000));
const healthCommand = readStringArray(params.audioBridgeHealthCommand);
if (healthCommand) {
const health = runCommandWithTimeout(healthCommand, timeoutMs);
if (health.code !== 0) {
throw new Error(
`Chrome audio bridge health check failed: ${health.stderr || health.stdout || health.code}`,
);
}
}
const mode = readString(params.mode);
let bridgeId: string | undefined;
let audioBridge: { type: "external-command" | "node-command-pair" } | undefined;
const bridgeCommand = readStringArray(params.audioBridgeCommand);
if (bridgeCommand) {
const bridge = runCommandWithTimeout(bridgeCommand, timeoutMs);
if (bridge.code !== 0) {
throw new Error(
`failed to start Chrome audio bridge: ${bridge.stderr || bridge.stdout || bridge.code}`,
);
if (mode === "realtime") {
assertBlackHoleAvailable(Math.min(timeoutMs, 10_000));
const healthCommand = readStringArray(params.audioBridgeHealthCommand);
if (healthCommand) {
const health = runCommandWithTimeout(healthCommand, timeoutMs);
if (health.code !== 0) {
throw new Error(
`Chrome audio bridge health check failed: ${health.stderr || health.stdout || health.code}`,
);
}
}
const bridgeCommand = readStringArray(params.audioBridgeCommand);
if (bridgeCommand) {
const bridge = runCommandWithTimeout(bridgeCommand, timeoutMs);
if (bridge.code !== 0) {
throw new Error(
`failed to start Chrome audio bridge: ${bridge.stderr || bridge.stdout || bridge.code}`,
);
}
audioBridge = { type: "external-command" };
} else {
const session = startCommandPair({
inputCommand: readStringArray(params.audioInputCommand) ?? [
...DEFAULT_GOOGLE_MEET_AUDIO_INPUT_COMMAND,
],
outputCommand: readStringArray(params.audioOutputCommand) ?? [
...DEFAULT_GOOGLE_MEET_AUDIO_OUTPUT_COMMAND,
],
url,
mode,
});
bridgeId = session.id;
audioBridge = { type: "node-command-pair" };
}
audioBridge = { type: "external-command" };
} else if (params.mode === "realtime") {
const session = startCommandPair({
inputCommand: readStringArray(params.audioInputCommand) ?? [
...DEFAULT_GOOGLE_MEET_AUDIO_INPUT_COMMAND,
],
outputCommand: readStringArray(params.audioOutputCommand) ?? [
...DEFAULT_GOOGLE_MEET_AUDIO_OUTPUT_COMMAND,
],
url,
mode: readString(params.mode),
});
bridgeId = session.id;
audioBridge = { type: "node-command-pair" };
}
if (params.launch !== false) {

View File

@@ -55,6 +55,17 @@ function resolveMode(input: GoogleMeetMode | undefined, config: GoogleMeetConfig
return input ?? config.defaultMode;
}
function hasRealtimeAudioOutputAdvanced(
health: GoogleMeetChromeHealth | undefined,
startOutputBytes: number,
): boolean {
return (health?.lastOutputBytes ?? 0) > startOutputBytes;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function collectChromeAudioCommands(config: GoogleMeetConfig): string[] {
const commands = config.chrome.audioBridgeCommand
? [config.chrome.audioBridgeCommand[0]]
@@ -103,13 +114,16 @@ export class GoogleMeetRuntime {
return session ? { found: true, session } : { found: false };
}
async setupStatus(options: { transport?: GoogleMeetTransport } = {}) {
async setupStatus(options: { transport?: GoogleMeetTransport; mode?: GoogleMeetMode } = {}) {
const transport = resolveTransport(options.transport, this.params.config);
const mode = resolveMode(options.mode, this.params.config);
const shouldCheckChromeNode =
transport === "chrome-node" ||
(!options.transport && Boolean(this.params.config.chromeNode.node));
let status = getGoogleMeetSetupStatus(this.params.config, {
fullConfig: this.params.fullConfig,
mode,
transport,
});
if (shouldCheckChromeNode) {
try {
@@ -131,7 +145,7 @@ export class GoogleMeetRuntime {
});
}
}
if (transport === "chrome") {
if (transport === "chrome" && mode === "realtime") {
try {
await assertBlackHole2chAvailable({
runtime: this.params.runtime,
@@ -302,7 +316,9 @@ export class GoogleMeetRuntime {
? transport === "chrome-node"
? "Chrome node transport joins as the signed-in Google profile on the selected node and routes realtime audio through the node bridge."
: "Chrome transport joins as the signed-in Google profile and routes realtime audio through the configured bridge."
: "Chrome transport joins as the signed-in Google profile and expects BlackHole 2ch audio routing.",
: mode === "realtime"
? "Chrome transport joins as the signed-in Google profile and expects BlackHole 2ch audio routing."
: "Chrome transport joins as the signed-in Google profile without starting the realtime audio bridge.",
);
} else {
const dialInNumber = normalizeDialInNumber(
@@ -398,14 +414,53 @@ export class GoogleMeetRuntime {
manualActionReason?: GoogleMeetChromeHealth["manualActionReason"];
manualActionMessage?: string;
spoken: boolean;
speechOutputVerified: boolean;
speechOutputTimedOut: boolean;
audioOutputActive?: boolean;
lastOutputBytes?: number;
session: GoogleMeetSession;
}> {
const before = new Set(this.list().map((session) => session.id));
if (request.mode === "transcribe") {
throw new Error(
"test_speech requires mode: realtime; use join mode: transcribe for observe-only sessions.",
);
}
const url = normalizeMeetUrl(request.url);
const transport = resolveTransport(request.transport, this.params.config);
const beforeSessions = this.list();
const before = new Set(beforeSessions.map((session) => session.id));
const existingSession = beforeSessions.find(
(session) =>
session.state === "active" &&
isSameMeetUrlForReuse(session.url, url) &&
session.transport === transport &&
session.mode === "realtime",
);
const startOutputBytes = existingSession?.chrome?.health?.lastOutputBytes ?? 0;
const result = await this.join({
...request,
transport,
url,
mode: "realtime",
message: request.message ?? "Say exactly: Google Meet speech test complete.",
});
const health = result.session.chrome?.health;
let health = result.session.chrome?.health;
const shouldWaitForOutput =
result.spoken === true &&
health?.manualActionRequired !== true &&
this.#sessionHealth.has(result.session.id);
if (shouldWaitForOutput && !hasRealtimeAudioOutputAdvanced(health, startOutputBytes)) {
const deadline = Date.now() + Math.min(this.params.config.chrome.joinTimeoutMs, 5_000);
while (Date.now() < deadline) {
await sleep(100);
this.#refreshHealth(result.session.id);
health = result.session.chrome?.health;
if (hasRealtimeAudioOutputAdvanced(health, startOutputBytes)) {
break;
}
}
}
const speechOutputVerified = hasRealtimeAudioOutputAdvanced(health, startOutputBytes);
return {
createdSession: !before.has(result.session.id),
inCall: health?.inCall,
@@ -413,6 +468,10 @@ export class GoogleMeetRuntime {
manualActionReason: health?.manualActionReason,
manualActionMessage: health?.manualActionMessage,
spoken: result.spoken ?? false,
speechOutputVerified,
speechOutputTimedOut: shouldWaitForOutput && !speechOutputVerified,
audioOutputActive: health?.audioOutputActive,
lastOutputBytes: health?.lastOutputBytes,
session: result.session,
};
}

View File

@@ -1,7 +1,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { GoogleMeetConfig } from "./config.js";
import type { GoogleMeetConfig, GoogleMeetMode, GoogleMeetTransport } from "./config.js";
export type SetupCheck = {
id: string;
@@ -33,6 +33,8 @@ export function getGoogleMeetSetupStatus(
options?: {
env?: NodeJS.ProcessEnv;
fullConfig?: unknown;
mode?: GoogleMeetMode;
transport?: GoogleMeetTransport;
},
): {
ok: boolean;
@@ -43,11 +45,17 @@ export function getGoogleMeetSetupStatus(
options?: {
env?: NodeJS.ProcessEnv;
fullConfig?: unknown;
mode?: GoogleMeetMode;
transport?: GoogleMeetTransport;
},
) {
const checks: SetupCheck[] = [];
const env = options?.env ?? process.env;
const fullConfig = asRecord(options?.fullConfig);
const mode = options?.mode ?? config.defaultMode;
const transport = options?.transport ?? config.defaultTransport;
const needsChromeRealtimeAudio =
mode === "realtime" && (transport === "chrome" || transport === "chrome-node");
const pluginEntries = asRecord(asRecord(fullConfig.plugins).entries);
const pluginAllow = asRecord(fullConfig.plugins).allow;
const voiceCallEntry = asRecord(pluginEntries["voice-call"]);
@@ -79,18 +87,26 @@ export function getGoogleMeetSetupStatus(
: "Local Chrome uses the OpenClaw browser profile; configure browser.defaultProfile to choose another profile",
});
checks.push({
id: "audio-bridge",
ok: Boolean(
config.chrome.audioBridgeCommand ||
(config.chrome.audioInputCommand && config.chrome.audioOutputCommand),
),
message: config.chrome.audioBridgeCommand
? "Chrome audio bridge command configured"
: config.chrome.audioInputCommand && config.chrome.audioOutputCommand
? `Chrome command-pair realtime audio bridge configured (${config.chrome.audioFormat})`
: "Chrome realtime audio bridge not configured",
});
if (needsChromeRealtimeAudio) {
checks.push({
id: "audio-bridge",
ok: Boolean(
config.chrome.audioBridgeCommand ||
(config.chrome.audioInputCommand && config.chrome.audioOutputCommand),
),
message: config.chrome.audioBridgeCommand
? "Chrome audio bridge command configured"
: config.chrome.audioInputCommand && config.chrome.audioOutputCommand
? `Chrome command-pair realtime audio bridge configured (${config.chrome.audioFormat})`
: "Chrome realtime audio bridge not configured",
});
} else if (transport === "chrome" || transport === "chrome-node") {
checks.push({
id: "audio-bridge",
ok: true,
message: "Chrome observe-only mode does not require a realtime audio bridge",
});
}
checks.push({
id: "guest-join-defaults",
@@ -114,14 +130,16 @@ export function getGoogleMeetSetupStatus(
: "Chrome node not pinned; automatic selection works when exactly one capable node is connected",
});
checks.push({
id: "intro-after-in-call",
ok: config.chrome.waitForInCallMs > 0,
message:
config.chrome.waitForInCallMs > 0
? `Realtime intro waits up to ${config.chrome.waitForInCallMs}ms for the Meet tab to be in-call`
: "Set chrome.waitForInCallMs to delay realtime intro until the Meet tab is in-call",
});
if (needsChromeRealtimeAudio) {
checks.push({
id: "intro-after-in-call",
ok: config.chrome.waitForInCallMs > 0,
message:
config.chrome.waitForInCallMs > 0
? `Realtime intro waits up to ${config.chrome.waitForInCallMs}ms for the Meet tab to be in-call`
: "Set chrome.waitForInCallMs to delay realtime intro until the Meet tab is in-call",
});
}
const shouldCheckTwilioDelegation =
config.voiceCall.enabled &&

View File

@@ -95,57 +95,59 @@ export async function launchChromeMeet(params: {
| ({ type: "command-pair" } & ChromeRealtimeAudioBridgeHandle);
browser?: GoogleMeetChromeHealth;
}> {
await assertBlackHole2chAvailable({
runtime: params.runtime,
timeoutMs: Math.min(params.config.chrome.joinTimeoutMs, 10_000),
});
if (params.config.chrome.audioBridgeHealthCommand) {
const health = await params.runtime.system.runCommandWithTimeout(
params.config.chrome.audioBridgeHealthCommand,
{ timeoutMs: params.config.chrome.joinTimeoutMs },
);
if (health.code !== 0) {
throw new Error(
`Chrome audio bridge health check failed: ${health.stderr || health.stdout || health.code}`,
);
}
}
let audioBridge:
| { type: "external-command" }
| ({ type: "command-pair" } & ChromeRealtimeAudioBridgeHandle)
| undefined;
if (params.config.chrome.audioBridgeCommand) {
const bridge = await params.runtime.system.runCommandWithTimeout(
params.config.chrome.audioBridgeCommand,
{ timeoutMs: params.config.chrome.joinTimeoutMs },
);
if (bridge.code !== 0) {
throw new Error(
`failed to start Chrome audio bridge: ${bridge.stderr || bridge.stdout || bridge.code}`,
if (params.mode === "realtime") {
await assertBlackHole2chAvailable({
runtime: params.runtime,
timeoutMs: Math.min(params.config.chrome.joinTimeoutMs, 10_000),
});
if (params.config.chrome.audioBridgeHealthCommand) {
const health = await params.runtime.system.runCommandWithTimeout(
params.config.chrome.audioBridgeHealthCommand,
{ timeoutMs: params.config.chrome.joinTimeoutMs },
);
if (health.code !== 0) {
throw new Error(
`Chrome audio bridge health check failed: ${health.stderr || health.stdout || health.code}`,
);
}
}
audioBridge = { type: "external-command" };
} else if (params.mode === "realtime") {
if (!params.config.chrome.audioInputCommand || !params.config.chrome.audioOutputCommand) {
throw new Error(
"Chrome realtime mode requires chrome.audioInputCommand and chrome.audioOutputCommand, or chrome.audioBridgeCommand for an external bridge.",
if (params.config.chrome.audioBridgeCommand) {
const bridge = await params.runtime.system.runCommandWithTimeout(
params.config.chrome.audioBridgeCommand,
{ timeoutMs: params.config.chrome.joinTimeoutMs },
);
if (bridge.code !== 0) {
throw new Error(
`failed to start Chrome audio bridge: ${bridge.stderr || bridge.stdout || bridge.code}`,
);
}
audioBridge = { type: "external-command" };
} else {
if (!params.config.chrome.audioInputCommand || !params.config.chrome.audioOutputCommand) {
throw new Error(
"Chrome realtime mode requires chrome.audioInputCommand and chrome.audioOutputCommand, or chrome.audioBridgeCommand for an external bridge.",
);
}
audioBridge = {
type: "command-pair",
...(await startCommandRealtimeAudioBridge({
config: params.config,
fullConfig: params.fullConfig,
runtime: params.runtime,
meetingSessionId: params.meetingSessionId,
inputCommand: params.config.chrome.audioInputCommand,
outputCommand: params.config.chrome.audioOutputCommand,
logger: params.logger,
})),
};
}
audioBridge = {
type: "command-pair",
...(await startCommandRealtimeAudioBridge({
config: params.config,
fullConfig: params.fullConfig,
runtime: params.runtime,
meetingSessionId: params.meetingSessionId,
inputCommand: params.config.chrome.audioInputCommand,
outputCommand: params.config.chrome.audioOutputCommand,
logger: params.logger,
})),
};
}
if (!params.config.chrome.launch) {
@@ -167,6 +169,7 @@ export async function launchChromeMeet(params: {
const result = await openMeetWithBrowserRequest({
callBrowser: callLocalBrowserRequest,
config: params.config,
mode: params.mode,
url: params.url,
});
return { ...result, audioBridge };
@@ -273,7 +276,11 @@ function parsePermissionGrantNotes(result: unknown): string[] {
async function grantMeetMediaPermissions(params: {
callBrowser: BrowserRequestCaller;
timeoutMs: number;
allowMicrophone: boolean;
}): Promise<string[]> {
if (!params.allowMicrophone) {
return ["Observe-only mode skips Meet microphone/camera permission grants."];
}
try {
const result = await params.callBrowser({
method: "POST",
@@ -296,9 +303,14 @@ async function grantMeetMediaPermissions(params: {
}
}
function meetStatusScript(params: { guestName: string; autoJoin: boolean }) {
function meetStatusScript(params: {
allowMicrophone: boolean;
autoJoin: boolean;
guestName: string;
}) {
return `() => {
const text = (node) => (node?.innerText || node?.textContent || "").trim();
const allowMicrophone = ${JSON.stringify(params.allowMicrophone)};
const buttons = [...document.querySelectorAll('button')];
const notes = [];
const findButton = (pattern) =>
@@ -325,16 +337,24 @@ function meetStatusScript(params: { guestName: string; autoJoin: boolean }) {
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 mic = buttons.find((button) => /turn off microphone|turn on microphone|microphone/i.test(button.getAttribute('aria-label') || text(button)));
if (!allowMicrophone && mic && /turn off microphone/i.test(mic.getAttribute('aria-label') || text(mic))) {
mic.click();
notes.push("Muted Meet microphone for observe-only mode.");
}
const join = ${JSON.stringify(params.autoJoin)}
? findButton(/join now|ask to join/i)
: null;
if (join) join.click();
const microphoneChoice = findButton(/\\buse microphone\\b/i);
if (microphoneChoice) {
const noMicrophoneChoice = findButton(/\\b(continue|join|use) without (microphone|mic)\\b|\\bnot now\\b/i);
if (allowMicrophone && microphoneChoice) {
microphoneChoice.click();
notes.push("Accepted Meet microphone prompt with browser automation.");
} else if (!allowMicrophone && noMicrophoneChoice) {
noMicrophoneChoice.click();
notes.push("Skipped Meet microphone prompt for observe-only mode.");
}
const mic = buttons.find((button) => /turn off microphone|turn on microphone|microphone/i.test(button.getAttribute('aria-label') || text(button)));
const inCall = buttons.some((button) => /leave call/i.test(button.getAttribute('aria-label') || text(button)));
let manualActionReason;
let manualActionMessage;
@@ -346,14 +366,18 @@ function meetStatusScript(params: { guestName: string; autoJoin: boolean }) {
manualActionMessage = "Admit the OpenClaw browser participant in Google Meet, then retry speech.";
} else if (permissionNeeded) {
manualActionReason = "meet-permission-required";
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)) {
manualActionMessage = allowMicrophone
? "Allow microphone/camera/speaker permissions for Meet in the OpenClaw browser profile, then retry."
: "Join without microphone/camera permissions in the OpenClaw browser profile, then retry.";
} else if (!inCall && (allowMicrophone ? !microphoneChoice : !noMicrophoneChoice) && /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.";
manualActionMessage = allowMicrophone
? "Meet is showing the microphone choice. Click Use microphone in the OpenClaw browser profile, then retry."
: "Meet is showing the microphone choice. Choose the no-microphone option in the OpenClaw browser profile, then retry.";
}
return JSON.stringify({
clickedJoin: Boolean(join),
clickedMicrophoneChoice: Boolean(microphoneChoice),
clickedMicrophoneChoice: Boolean(allowMicrophone && microphoneChoice),
inCall,
micMuted: mic ? /turn on microphone/i.test(mic.getAttribute('aria-label') || text(mic)) : undefined,
manualActionRequired: Boolean(manualActionReason),
@@ -370,6 +394,7 @@ async function openMeetWithBrowserProxy(params: {
runtime: PluginRuntime;
nodeId: string;
config: GoogleMeetConfig;
mode: "realtime" | "transcribe";
url: string;
}): Promise<{ launched: boolean; browser?: GoogleMeetChromeHealth }> {
return await openMeetWithBrowserRequest({
@@ -383,6 +408,7 @@ async function openMeetWithBrowserProxy(params: {
timeoutMs: request.timeoutMs,
}),
config: params.config,
mode: params.mode,
url: params.url,
});
}
@@ -390,6 +416,7 @@ async function openMeetWithBrowserProxy(params: {
async function openMeetWithBrowserRequest(params: {
callBrowser: BrowserRequestCaller;
config: GoogleMeetConfig;
mode: "realtime" | "transcribe";
url: string;
}): Promise<{ launched: boolean; browser?: GoogleMeetChromeHealth }> {
if (!params.config.chrome.launch) {
@@ -442,6 +469,7 @@ async function openMeetWithBrowserRequest(params: {
}
const permissionNotes = await grantMeetMediaPermissions({
allowMicrophone: params.mode === "realtime",
callBrowser: params.callBrowser,
timeoutMs,
});
@@ -461,6 +489,7 @@ async function openMeetWithBrowserRequest(params: {
kind: "evaluate",
targetId,
fn: meetStatusScript({
allowMicrophone: params.mode === "realtime",
guestName: params.config.chrome.guestName,
autoJoin: params.config.chrome.autoJoin,
}),
@@ -526,6 +555,7 @@ async function inspectRecoverableMeetTab(params: {
timeoutMs: Math.min(params.timeoutMs, 5_000),
});
const permissionNotes = await grantMeetMediaPermissions({
allowMicrophone: true,
callBrowser: params.callBrowser,
timeoutMs: params.timeoutMs,
});
@@ -536,6 +566,7 @@ async function inspectRecoverableMeetTab(params: {
kind: "evaluate",
targetId: params.targetId,
fn: meetStatusScript({
allowMicrophone: true,
guestName: params.config.chrome.guestName,
autoJoin: false,
}),
@@ -714,6 +745,7 @@ export async function launchChromeMeetOnNode(params: {
runtime: params.runtime,
nodeId,
config: params.config,
mode: params.mode,
url: params.url,
});
const raw = await params.runtime.nodes.invoke({