From 57401f15818251b1b6b82266f5b0f7b5406ed5c2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 14:03:30 +0100 Subject: [PATCH] fix(google-meet): use OpenClaw browser for local joins --- CHANGELOG.md | 1 + docs/plugins/google-meet.md | 26 ++++--- extensions/google-meet/index.test.ts | 67 +++++++++++++++++-- extensions/google-meet/src/setup.ts | 30 ++------- .../google-meet/src/transports/chrome.ts | 57 ++++++++-------- 5 files changed, 115 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 416ba266f60..cb88db2482a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- 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. - Reply/link understanding: keep media and link preprocessing on stable runtime entrypoints and continue with raw message content if optional enrichment fails, so URL-bearing messages are no longer dropped after stale runtime chunk upgrades. Fixes #68466. Thanks @songshikang0111. - Discord: persist routed model-picker overrides when the hidden `/model` dispatch succeeds but the bound thread session store is still stale, including LM Studio suffixed model ids. Carries forward #61473. Thanks @Nanako0129. - Nodes/CLI: add `openclaw nodes remove --node ` and `node.pair.remove` so stale gateway-owned node pairing records can be cleaned without hand-editing state files. Thanks @openclaw. diff --git a/docs/plugins/google-meet.md b/docs/plugins/google-meet.md index eee000ee0bc..93304fa2839 100644 --- a/docs/plugins/google-meet.md +++ b/docs/plugins/google-meet.md @@ -155,10 +155,10 @@ appears, browser automation handles it when it can. Login, host admission, and browser/OS permission prompts are reported as manual action with a reason and message for the agent to relay. -Chrome joins as the signed-in Chrome profile. In Meet, pick `BlackHole 2ch` for -the microphone/speaker path used by OpenClaw. For clean duplex audio, use -separate virtual devices or a Loopback-style graph; a single BlackHole device is -enough for a first smoke test but can echo. +Local Chrome joins through the signed-in OpenClaw browser profile. In Meet, pick +`BlackHole 2ch` for the microphone/speaker path used by OpenClaw. For clean +duplex audio, use separate virtual devices or a Loopback-style graph; a single +BlackHole device is enough for a first smoke test but can echo. ### Local gateway + Parallels Chrome @@ -350,12 +350,14 @@ upstream licensing terms or get a separate license from Existential Audio. ### Chrome -Chrome transport opens the Meet URL in Google Chrome and joins as the signed-in -Chrome profile. On macOS, the plugin checks for `BlackHole 2ch` before launch. -If configured, it also runs an audio bridge health command and startup command -before opening Chrome. Use `chrome` when Chrome/audio live on the Gateway host; -use `chrome-node` when Chrome/audio live on a paired node such as a Parallels -macOS VM. +Chrome transport opens the Meet URL through OpenClaw browser control and joins +as the signed-in OpenClaw browser profile. On macOS, the plugin checks for +`BlackHole 2ch` before launch. If configured, it also runs an audio bridge +health command and startup command before opening Chrome. Use `chrome` when +Chrome/audio live on the Gateway host; use `chrome-node` when Chrome/audio live +on a paired node such as a Parallels macOS VM. For local Chrome, choose the +profile with `browser.defaultProfile`; `chrome.browserProfile` is passed to +`chrome-node` hosts. ```bash openclaw googlemeet join https://meet.google.com/abc-defg-hij --transport chrome @@ -910,8 +912,10 @@ Optional overrides: defaults: { meeting: "https://meet.google.com/abc-defg-hij", }, + browser: { + defaultProfile: "openclaw", + }, chrome: { - browserProfile: "Default", guestName: "OpenClaw Agent", waitForInCallMs: 30000, }, diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index cccdbeac4c1..feb2c30893c 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -94,6 +94,45 @@ function requestUrl(input: RequestInfo | URL): URL { return new URL(input.url); } +function mockLocalMeetBrowserRequest( + browserActResult: Record = { + inCall: true, + micMuted: false, + title: "Meet call", + url: "https://meet.google.com/abc-defg-hij", + }, +) { + const callGatewayFromCli = vi.fn( + async ( + _method: string, + _opts: unknown, + params?: unknown, + _extra?: unknown, + ): Promise> => { + const request = params as { path?: string; body?: { targetId?: string; url?: string } }; + if (request.path === "/tabs") { + return { tabs: [] }; + } + if (request.path === "/tabs/open") { + return { + targetId: "local-meet-tab", + title: "Meet", + url: request.body?.url ?? "https://meet.google.com/abc-defg-hij", + }; + } + if (request.path === "/tabs/focus") { + return { ok: true }; + } + if (request.path === "/act") { + return { result: JSON.stringify(browserActResult) }; + } + throw new Error(`unexpected browser request path ${request.path}`); + }, + ); + chromeTransportTesting.setDepsForTest({ callGatewayFromCli }); + return callGatewayFromCli; +} + function stubMeetArtifactsApi() { const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = requestUrl(input); @@ -1332,13 +1371,14 @@ describe("google-meet plugin", () => { ); }); - it("launches Chrome after the BlackHole check", async () => { + it("opens local Chrome Meet through browser control after the BlackHole check", async () => { const originalPlatform = process.platform; Object.defineProperty(process, "platform", { value: "darwin" }); try { const { methods, runCommandWithTimeout } = setup({ defaultMode: "transcribe", }); + const callGatewayFromCli = mockLocalMeetBrowserRequest(); const handler = methods.get("googlemeet.join") as | ((ctx: { params: Record; @@ -1358,10 +1398,16 @@ describe("google-meet plugin", () => { ["/usr/sbin/system_profiler", "SPAudioDataType"], { timeoutMs: 10000 }, ); - expect(runCommandWithTimeout).toHaveBeenNthCalledWith( - 2, - ["open", "-a", "Google Chrome", "https://meet.google.com/abc-defg-hij"], - { timeoutMs: 30000 }, + expect(runCommandWithTimeout).toHaveBeenCalledTimes(1); + expect(callGatewayFromCli).toHaveBeenCalledWith( + "browser.request", + expect.any(Object), + expect.objectContaining({ + method: "POST", + path: "/tabs/open", + body: { url: "https://meet.google.com/abc-defg-hij" }, + }), + { progress: false }, ); } finally { Object.defineProperty(process, "platform", { value: originalPlatform }); @@ -1919,6 +1965,7 @@ describe("google-meet plugin", () => { audioBridgeCommand: ["bridge", "start"], }, }); + const callGatewayFromCli = mockLocalMeetBrowserRequest(); const handler = methods.get("googlemeet.join") as | ((ctx: { params: Record; @@ -1939,6 +1986,16 @@ describe("google-meet plugin", () => { expect(runCommandWithTimeout).toHaveBeenNthCalledWith(3, ["bridge", "start"], { timeoutMs: 30000, }); + expect(callGatewayFromCli).toHaveBeenCalledWith( + "browser.request", + expect.any(Object), + expect.objectContaining({ + method: "POST", + path: "/tabs/open", + body: { url: "https://meet.google.com/abc-defg-hij" }, + }), + { progress: false }, + ); } finally { Object.defineProperty(process, "platform", { value: originalPlatform }); } diff --git a/extensions/google-meet/src/setup.ts b/extensions/google-meet/src/setup.ts index 1d3d20c03e4..cada2a3f355 100644 --- a/extensions/google-meet/src/setup.ts +++ b/extensions/google-meet/src/setup.ts @@ -71,29 +71,13 @@ export function getGoogleMeetSetupStatus( }); } - if (config.chrome.browserProfile) { - const profilePath = path.join( - os.homedir(), - "Library", - "Application Support", - "Google", - "Chrome", - config.chrome.browserProfile, - ); - checks.push({ - id: "chrome-profile", - ok: fs.existsSync(profilePath), - message: fs.existsSync(profilePath) - ? "Chrome profile found" - : `Chrome profile missing: ${config.chrome.browserProfile}`, - }); - } else { - checks.push({ - id: "chrome-profile", - ok: true, - message: "Chrome profile not pinned; default signed-in profile will be used", - }); - } + checks.push({ + id: "chrome-profile", + ok: true, + message: config.chrome.browserProfile + ? "Local Chrome uses the OpenClaw browser profile; chrome.browserProfile is passed to chrome-node hosts" + : "Local Chrome uses the OpenClaw browser profile; configure browser.defaultProfile to choose another profile", + }); checks.push({ id: "audio-bridge", diff --git a/extensions/google-meet/src/transports/chrome.ts b/extensions/google-meet/src/transports/chrome.ts index bcc3b0d1a00..b57bc9cfe21 100644 --- a/extensions/google-meet/src/transports/chrome.ts +++ b/extensions/google-meet/src/transports/chrome.ts @@ -93,6 +93,7 @@ export async function launchChromeMeet(params: { audioBridge?: | { type: "external-command" } | ({ type: "command-pair" } & ChromeRealtimeAudioBridgeHandle); + browser?: GoogleMeetChromeHealth; }> { await assertBlackHole2chAvailable({ runtime: params.runtime, @@ -151,12 +152,6 @@ export async function launchChromeMeet(params: { return { launched: false, audioBridge }; } - const argv = ["open", "-a", "Google Chrome"]; - if (params.config.chrome.browserProfile) { - argv.push("--args", `--profile-directory=${params.config.chrome.browserProfile}`); - } - argv.push(params.url); - let commandPairBridgeStopped = false; const stopCommandPairBridge = async () => { if (commandPairBridgeStopped) { @@ -169,16 +164,12 @@ export async function launchChromeMeet(params: { }; try { - const result = await params.runtime.system.runCommandWithTimeout(argv, { - timeoutMs: params.config.chrome.joinTimeoutMs, + const result = await openMeetWithBrowserRequest({ + callBrowser: callLocalBrowserRequest, + config: params.config, + url: params.url, }); - if (result.code === 0) { - return { launched: true, audioBridge }; - } - await stopCommandPairBridge(); - throw new Error( - `failed to launch Chrome for Meet: ${result.stderr || result.stdout || result.code}`, - ); + return { ...result, audioBridge }; } catch (error) { await stopCommandPairBridge(); throw error; @@ -328,6 +319,26 @@ async function openMeetWithBrowserProxy(params: { nodeId: string; config: GoogleMeetConfig; url: string; +}): Promise<{ launched: boolean; browser?: GoogleMeetChromeHealth }> { + return await openMeetWithBrowserRequest({ + callBrowser: async (request) => + await callBrowserProxyOnNode({ + runtime: params.runtime, + nodeId: params.nodeId, + method: request.method, + path: request.path, + body: request.body, + timeoutMs: request.timeoutMs, + }), + config: params.config, + url: params.url, + }); +} + +async function openMeetWithBrowserRequest(params: { + callBrowser: BrowserRequestCaller; + config: GoogleMeetConfig; + url: string; }): Promise<{ launched: boolean; browser?: GoogleMeetChromeHealth }> { if (!params.config.chrome.launch) { return { launched: false }; @@ -338,9 +349,7 @@ async function openMeetWithBrowserProxy(params: { let tab: BrowserTab | undefined; if (params.config.chrome.reuseExistingTab) { const tabs = asBrowserTabs( - await callBrowserProxyOnNode({ - runtime: params.runtime, - nodeId: params.nodeId, + await params.callBrowser({ method: "GET", path: "/tabs", timeoutMs: Math.min(timeoutMs, 5_000), @@ -349,9 +358,7 @@ async function openMeetWithBrowserProxy(params: { tab = tabs.find((entry) => isSameMeetUrlForReuse(entry.url, params.url)); targetId = tab?.targetId; if (targetId) { - await callBrowserProxyOnNode({ - runtime: params.runtime, - nodeId: params.nodeId, + await params.callBrowser({ method: "POST", path: "/tabs/focus", body: { targetId }, @@ -361,9 +368,7 @@ async function openMeetWithBrowserProxy(params: { } if (!targetId) { tab = readBrowserTab( - await callBrowserProxyOnNode({ - runtime: params.runtime, - nodeId: params.nodeId, + await params.callBrowser({ method: "POST", path: "/tabs/open", body: { url: params.url }, @@ -392,9 +397,7 @@ async function openMeetWithBrowserProxy(params: { }; do { try { - const evaluated = await callBrowserProxyOnNode({ - runtime: params.runtime, - nodeId: params.nodeId, + const evaluated = await params.callBrowser({ method: "POST", path: "/act", body: {