From afd0a7b403b7e565bdae076f006df421444e0556 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 08:07:05 +0100 Subject: [PATCH] fix(google-meet): guard linux chrome realtime tool actions --- CHANGELOG.md | 1 + docs/plugins/google-meet.md | 13 ++++++ extensions/google-meet/index.create.test.ts | 2 + extensions/google-meet/index.test.ts | 38 ++++++++++++++++ extensions/google-meet/index.ts | 45 ++++++++++++++++++- .../src/test-support/plugin-harness.ts | 12 ++++- 6 files changed, 109 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 442fd34b4a6..5a46abd6f60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai - Infer/media: report missing image-understanding and audio-transcription provider configuration for `image describe`, `image describe-many`, and `audio transcribe` instead of blaming the input path when no provider is available. Fixes #73569 and supersedes #73593, #74288, and #74495. Thanks @bittoby, @tmimmanuel, @Linux2010, and @vyctorbrzezowski. - Docs/health: clarify that session listing surfaces stored conversation rows rather than Discord/channel socket liveness, and point connectivity checks at channel status and health probes. Fixes #70420. Thanks @ashersoutherncities-art and @martingarramon. - WhatsApp/Cron: keep DM pairing-store approvals out of implicit cron and heartbeat recipient fallback, so scheduled automation only uses explicit targets, active configured recipients, or configured `allowFrom` entries. Fixes #62339. Thanks @kelvinisly-collab. +- Google Meet: keep the agent-facing `google_meet` tool visible on non-macOS hosts but block local Chrome realtime actions with guidance, so Linux agents can still use transcribe, Twilio, chrome-node, and artifact flows without choosing the macOS-only BlackHole path. Refs #75950. Thanks @actual-software-inc. - Active Memory: use the configured recall timeout as the blocking prompt-build hook budget by default and move cold-start setup grace behind explicit `setupGraceTimeoutMs` config, so the plugin no longer silently extends 15000 ms configs to 45000 ms on the main lane. Fixes #75843. Thanks @vishutdhar. - Plugins/web-provider: reuse the active gateway plugin registry for runtime web provider resolution after deriving the same candidate plugin ids as the loader path, avoiding a redundant `loadOpenClawPlugins` call on every request while preserving origin and scope filters. Fixes #75513. Thanks @jochen. - Crestodian/CLI: exit non-zero when interactive Crestodian is invoked without a TTY, so scripts and CI no longer treat the setup error as success. Fixes #73646 and supersedes #73928 and #74059. Thanks @bittoby, @luyao618, and @Linux2010. diff --git a/docs/plugins/google-meet.md b/docs/plugins/google-meet.md index 4846b371b49..82044bd6218 100644 --- a/docs/plugins/google-meet.md +++ b/docs/plugins/google-meet.md @@ -119,6 +119,13 @@ Or let an agent join through the `google_meet` tool: } ``` +The agent-facing `google_meet` tool stays available on non-macOS hosts for +artifact, calendar, setup, transcribe, Twilio, and `chrome-node` flows. Local +Chrome realtime actions are blocked there because the bundled realtime Chrome +audio path currently depends on macOS `BlackHole 2ch`. On Linux, use +`mode: "transcribe"`, Twilio dial-in, or a macOS `chrome-node` host for realtime +Chrome participation. + Create a new meeting and join it: ```bash @@ -1267,6 +1274,12 @@ If you just edited `plugins.entries.google-meet`, restart or reload the Gateway. The running agent only sees plugin tools registered by the current Gateway process. +On non-macOS Gateway hosts, the agent-facing `google_meet` tool stays visible, +but local Chrome realtime actions are blocked before they hit the audio bridge. +Local Chrome realtime audio currently depends on macOS `BlackHole 2ch`, so +Linux agents should use `mode: "transcribe"`, Twilio dial-in, or a macOS +`chrome-node` host instead of the default local Chrome realtime path. + ### No connected Google Meet-capable node On the node host, run: diff --git a/extensions/google-meet/index.create.test.ts b/extensions/google-meet/index.create.test.ts index bedc8882f65..a9fa1cc6943 100644 --- a/extensions/google-meet/index.create.test.ts +++ b/extensions/google-meet/index.create.test.ts @@ -62,6 +62,7 @@ function setup( unknown >, ); + googleMeetPluginTesting.setPlatformForTests(() => options?.registerPlatform ?? "darwin"); return harness; } @@ -106,6 +107,7 @@ describe("google-meet create flow", () => { afterEach(() => { vi.unstubAllGlobals(); googleMeetPluginTesting.setCallGatewayFromCliForTests(); + googleMeetPluginTesting.setPlatformForTests(); }); it("CLI create can configure API-created space access", async () => { diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index 96a0c630a37..7fdcc59f1b9 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -86,6 +86,7 @@ function setup( unknown >, ); + googleMeetPluginTesting.setPlatformForTests(() => options?.registerPlatform ?? "darwin"); return harness; } @@ -303,6 +304,7 @@ describe("google-meet plugin", () => { vi.unstubAllGlobals(); chromeTransportTesting.setDepsForTest(null); googleMeetPluginTesting.setCallGatewayFromCliForTests(); + googleMeetPluginTesting.setPlatformForTests(); }); it("defaults to chrome realtime with safe read-only tools", () => { @@ -507,6 +509,42 @@ describe("google-meet plugin", () => { ); }); + it("keeps the agent tool visible on non-macOS hosts but blocks local Chrome realtime joins", async () => { + const { cliRegistrations, methods, tools } = setup(undefined, { registerPlatform: "linux" }); + const tool = tools[0] as { + execute: (id: string, params: unknown) => Promise<{ isError?: boolean; content: unknown }>; + }; + + expect(tools).toHaveLength(1); + expect(cliRegistrations).toHaveLength(1); + expect(methods.has("googlemeet.setup")).toBe(true); + expect( + googleMeetPluginTesting.isGoogleMeetAgentToolActionUnsupportedOnHost({ + config: resolveGoogleMeetConfig({}), + raw: { action: "join" }, + platform: "linux", + }), + ).toBe(true); + + const blocked = await tool.execute("id", { action: "join" }); + expect(JSON.stringify(blocked)).toContain("local Chrome realtime audio is macOS-only"); + + expect( + googleMeetPluginTesting.isGoogleMeetAgentToolActionUnsupportedOnHost({ + config: resolveGoogleMeetConfig({}), + raw: { action: "join", mode: "transcribe" }, + platform: "linux", + }), + ).toBe(false); + expect( + googleMeetPluginTesting.isGoogleMeetAgentToolActionUnsupportedOnHost({ + config: resolveGoogleMeetConfig({}), + raw: { action: "join", transport: "chrome-node" }, + platform: "linux", + }), + ).toBe(false); + }); + it("returns structured gateway errors for missing session ids", async () => { const { methods } = setup(); for (const method of ["googlemeet.leave", "googlemeet.speak"]) { diff --git a/extensions/google-meet/index.ts b/extensions/google-meet/index.ts index b2662622328..932c5b13466 100644 --- a/extensions/google-meet/index.ts +++ b/extensions/google-meet/index.ts @@ -345,12 +345,17 @@ function shouldJoinCreatedMeet(raw: Record): boolean { const googleMeetToolDeps = { callGatewayFromCli, + platform: () => process.platform, }; export const __testing = { setCallGatewayFromCliForTests(next?: typeof callGatewayFromCli): void { googleMeetToolDeps.callGatewayFromCli = next ?? callGatewayFromCli; }, + setPlatformForTests(next?: () => NodeJS.Platform): void { + googleMeetToolDeps.platform = next ?? (() => process.platform); + }, + isGoogleMeetAgentToolActionUnsupportedOnHost, }; type GoogleMeetGatewayToolAction = @@ -382,6 +387,43 @@ function googleMeetGatewayMethodForToolAction(action: GoogleMeetGatewayToolActio } } +function isGoogleMeetAgentToolActionUnsupportedOnHost(params: { + config: GoogleMeetConfig; + raw: Record; + platform?: NodeJS.Platform; +}): boolean { + const platform = params.platform ?? googleMeetToolDeps.platform(); + if (platform === "darwin") { + return false; + } + const action = params.raw.action; + if ( + action !== "join" && + action !== "test_speech" && + !(action === "create" && shouldJoinCreatedMeet(params.raw)) + ) { + return false; + } + const transport = normalizeTransport(params.raw.transport) ?? params.config.defaultTransport; + const mode = + action === "test_speech" + ? "realtime" + : (normalizeMode(params.raw.mode) ?? params.config.defaultMode); + return transport === "chrome" && mode === "realtime"; +} + +function assertGoogleMeetAgentToolActionSupported(params: { + config: GoogleMeetConfig; + raw: Record; +}): void { + if (!isGoogleMeetAgentToolActionUnsupportedOnHost(params)) { + return; + } + throw new Error( + "Google Meet local Chrome realtime audio is macOS-only. On this host, use mode: transcribe, transport: twilio, or transport: chrome-node backed by a macOS node.", + ); +} + function resolveGoogleMeetToolGatewayTimeoutMs(config: GoogleMeetConfig): number { return Math.max( 60_000, @@ -944,11 +986,12 @@ export default definePluginEntry({ name: "google_meet", label: "Google Meet", description: - "Join and track Google Meet sessions through Chrome or Twilio. Call setup_status before join/create/test_listen/test_speech; if it reports a Chrome node offline or local audio missing, surface that blocker instead of retrying or switching transports. Offline nodes are diagnostics only, not usable candidates. If a Meet tab is already open after a timeout, call recover_current_tab before retrying join to report login, permission, or admission blockers without opening another tab.", + "Join and track Google Meet sessions through Chrome or Twilio. Call setup_status before join/create/test_listen/test_speech; if it reports a Chrome node offline or local audio missing, surface that blocker instead of retrying or switching transports. Offline nodes are diagnostics only, not usable candidates. If local Chrome realtime audio is unsupported on this OS, use mode=transcribe, transport=twilio, or a macOS chrome-node for realtime Chrome. If a Meet tab is already open after a timeout, call recover_current_tab before retrying join to report login, permission, or admission blockers without opening another tab.", parameters: GoogleMeetToolSchema, async execute(_toolCallId, params) { const raw = asParamRecord(params); try { + assertGoogleMeetAgentToolActionSupported({ config, raw }); switch (raw.action) { case "join": { return json(await callGoogleMeetGatewayFromTool({ config, action: "join", raw })); diff --git a/extensions/google-meet/src/test-support/plugin-harness.ts b/extensions/google-meet/src/test-support/plugin-harness.ts index 232206f90bf..2e1220301a4 100644 --- a/extensions/google-meet/src/test-support/plugin-harness.ts +++ b/extensions/google-meet/src/test-support/plugin-harness.ts @@ -60,6 +60,7 @@ export function setupGoogleMeetPlugin( argv: string[], options?: { timeoutMs?: number }, ) => Promise; + registerPlatform?: NodeJS.Platform; } = {}, ) { const methods = new Map(); @@ -157,7 +158,16 @@ export function setupGoogleMeetPlugin( registerCli: (_registrar: unknown, opts: unknown) => cliRegistrations.push(opts), registerNodeHostCommand: (command: unknown) => nodeHostCommands.push(command), }); - plugin.register(api); + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { + configurable: true, + value: options.registerPlatform ?? "darwin", + }); + try { + plugin.register(api); + } finally { + Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform }); + } return { cliRegistrations, methods,