From a97ee5c1d322a31862a2c34d2e536b6269dd0812 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 12:03:48 +0100 Subject: [PATCH] fix(google-meet): recover local chrome tabs --- docs/plugins/google-meet.md | 10 +- extensions/google-meet/index.test.ts | 72 +++++++ extensions/google-meet/index.ts | 15 +- extensions/google-meet/src/cli.test.ts | 1 + extensions/google-meet/src/cli.ts | 21 ++- extensions/google-meet/src/runtime.ts | 20 +- .../google-meet/src/transports/chrome.ts | 178 ++++++++++++++---- 7 files changed, 268 insertions(+), 49 deletions(-) diff --git a/docs/plugins/google-meet.md b/docs/plugins/google-meet.md index 00707f980bc..a2b4d1d5997 100644 --- a/docs/plugins/google-meet.md +++ b/docs/plugins/google-meet.md @@ -1238,10 +1238,12 @@ openclaw googlemeet recover-tab https://meet.google.com/abc-defg-hij ``` The equivalent tool action is `recover_current_tab`. It focuses and inspects an -existing Meet tab on the configured Chrome node. It does not open a new tab or -create a new session; it reports the current blocker, such as login, admission, -permissions, or audio-choice state. The CLI command talks to the configured -Gateway, so the Gateway must be running and the Chrome node must be connected. +existing Meet tab for the selected transport. With `chrome`, it uses local +browser control through the Gateway; with `chrome-node`, it uses the configured +Chrome node. It does not open a new tab or create a new session; it reports the +current blocker, such as login, admission, permissions, or audio-choice state. +The CLI command talks to the configured Gateway, so the Gateway must be running; +`chrome-node` also requires the Chrome node to be connected. ### Twilio setup checks fail diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index 678eefd5cca..c83211300fc 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -25,6 +25,7 @@ import { startNodeRealtimeAudioBridge } from "./src/realtime-node.js"; import { startCommandRealtimeAudioBridge } from "./src/realtime.js"; import { normalizeMeetUrl } from "./src/runtime.js"; import { noopLogger, setupGoogleMeetPlugin } from "./src/test-support/plugin-harness.js"; +import { __testing as chromeTransportTesting } from "./src/transports/chrome.js"; import { buildMeetDtmfSequence, normalizeDialInNumber } from "./src/transports/twilio.js"; const voiceCallMocks = vi.hoisted(() => ({ @@ -224,6 +225,7 @@ describe("google-meet plugin", () => { afterEach(() => { vi.unstubAllGlobals(); + chromeTransportTesting.setDepsForTest(null); }); it("defaults to chrome realtime with safe read-only tools", () => { @@ -1607,6 +1609,76 @@ describe("google-meet plugin", () => { ); }); + it("recovers and inspects an existing local Chrome Meet tab", async () => { + const callGatewayFromCli = vi.fn( + async ( + _method: string, + _opts: unknown, + params?: unknown, + _extra?: unknown, + ): Promise> => { + const request = params as { path?: string; body?: { targetId?: string } }; + if (request.path === "/tabs") { + return { + tabs: [ + { + targetId: "local-meet-tab", + title: "Meet", + url: "https://meet.google.com/abc-defg-hij?authuser=me@example.com", + }, + ], + }; + } + if (request.path === "/tabs/focus") { + return { ok: true }; + } + if (request.path === "/act") { + return { + result: JSON.stringify({ + inCall: false, + manualActionRequired: true, + manualActionReason: "meet-admission-required", + manualActionMessage: "Admit the OpenClaw browser participant in Google Meet.", + title: "Meet", + url: "https://meet.google.com/abc-defg-hij?authuser=me@example.com", + }), + }; + } + throw new Error(`unexpected browser request path ${request.path}`); + }, + ); + chromeTransportTesting.setDepsForTest({ callGatewayFromCli }); + const { tools, nodesInvoke } = setup({ defaultTransport: "chrome" }); + const tool = tools[0] as { + execute: ( + id: string, + params: unknown, + ) => Promise<{ details: { transport?: string; found?: boolean; browser?: unknown } }>; + }; + + const result = await tool.execute("id", { + action: "recover_current_tab", + url: "https://meet.google.com/abc-defg-hij", + }); + + expect(result.details).toMatchObject({ + transport: "chrome", + found: true, + targetId: "local-meet-tab", + browser: { + manualActionRequired: true, + manualActionReason: "meet-admission-required", + }, + }); + expect(callGatewayFromCli).toHaveBeenCalledWith( + "browser.request", + expect.any(Object), + expect.objectContaining({ method: "POST", path: "/tabs/focus" }), + { progress: false }, + ); + expect(nodesInvoke).not.toHaveBeenCalled(); + }); + it("exposes a test-speech action that joins the requested meeting", async () => { const { tools, nodesInvoke } = setup( { diff --git a/extensions/google-meet/index.ts b/extensions/google-meet/index.ts index 077736e1b08..aeeacda25c6 100644 --- a/extensions/google-meet/index.ts +++ b/extensions/google-meet/index.ts @@ -557,7 +557,13 @@ export default definePluginEntry({ async ({ params, respond }: GatewayRequestHandlerOptions) => { try { const rt = await ensureRuntime(); - respond(true, await rt.recoverCurrentTab({ url: normalizeOptionalString(params?.url) })); + respond( + true, + await rt.recoverCurrentTab({ + url: normalizeOptionalString(params?.url), + transport: normalizeTransport(params?.transport), + }), + ); } catch (err) { sendError(respond, err); } @@ -793,7 +799,12 @@ export default definePluginEntry({ } case "recover_current_tab": { const rt = await ensureRuntime(); - return json(await rt.recoverCurrentTab({ url: normalizeOptionalString(raw.url) })); + return json( + await rt.recoverCurrentTab({ + url: normalizeOptionalString(raw.url), + transport: normalizeTransport(raw.transport), + }), + ); } case "setup_status": { const rt = await ensureRuntime(); diff --git a/extensions/google-meet/src/cli.test.ts b/extensions/google-meet/src/cli.test.ts index 673bf1bed40..446a60ffe66 100644 --- a/extensions/google-meet/src/cli.test.ts +++ b/extensions/google-meet/src/cli.test.ts @@ -793,6 +793,7 @@ describe("google-meet CLI", () => { config: { defaultTransport: "chrome-node" }, runtime: { recoverCurrentTab: async () => ({ + transport: "chrome-node", nodeId: "node-1", found: true, targetId: "tab-1", diff --git a/extensions/google-meet/src/cli.ts b/extensions/google-meet/src/cli.ts index db68bf9e6a7..4bde834a7a3 100644 --- a/extensions/google-meet/src/cli.ts +++ b/extensions/google-meet/src/cli.ts @@ -148,6 +148,10 @@ type JsonOptions = { json?: boolean; }; +type RecoverTabOptions = JsonOptions & { + transport?: GoogleMeetTransport; +}; + type CreateOptions = { accessToken?: string; refreshToken?: string; @@ -431,7 +435,8 @@ function writeRecoverCurrentTabResult( result: Awaited>, ): void { writeStdoutLine("Google Meet current tab: %s", result.found ? "found" : "not found"); - writeStdoutLine("node: %s", result.nodeId); + writeStdoutLine("transport: %s", result.transport); + writeStdoutLine("node: %s", result.nodeId ?? "local/none"); if (result.targetId) { writeStdoutLine("target: %s", result.targetId); } @@ -445,12 +450,15 @@ function writeRecoverCurrentTabResult( session: { id: "current-tab", url: result.browser.browserUrl ?? result.tab?.url ?? "unknown", - transport: "chrome-node", + transport: result.transport, mode: "transcribe", state: "active", createdAt: "", updatedAt: "", - participantIdentity: "signed-in Google Chrome profile on a paired node", + participantIdentity: + result.transport === "chrome-node" + ? "signed-in Google Chrome profile on a paired node" + : "signed-in Google Chrome profile", realtime: { enabled: false, toolPolicy: "safe-read-only" }, chrome: { audioBackend: "blackhole-2ch", @@ -1960,12 +1968,13 @@ export function registerGoogleMeetCli(params: { root .command("recover-tab") - .description("Focus and inspect an existing Google Meet tab on the Chrome node") + .description("Focus and inspect an existing Google Meet tab") .argument("[url]", "Optional Meet URL to match") + .option("--transport ", "Transport to inspect: chrome or chrome-node") .option("--json", "Print JSON output", false) - .action(async (url: string | undefined, options: JsonOptions) => { + .action(async (url: string | undefined, options: RecoverTabOptions) => { const rt = await params.ensureRuntime(); - const result = await rt.recoverCurrentTab({ url }); + const result = await rt.recoverCurrentTab({ url, transport: options.transport }); if (options.json) { writeStdoutJson(result); return; diff --git a/extensions/google-meet/src/runtime.ts b/extensions/google-meet/src/runtime.ts index 503f715be2b..ce9491ff259 100644 --- a/extensions/google-meet/src/runtime.ts +++ b/extensions/google-meet/src/runtime.ts @@ -11,6 +11,7 @@ import { assertBlackHole2chAvailable, launchChromeMeet, launchChromeMeetOnNode, + recoverCurrentMeetTab, recoverCurrentMeetTabOnNode, } from "./transports/chrome.js"; import { buildMeetDtmfSequence, normalizeDialInNumber } from "./transports/twilio.js"; @@ -181,11 +182,22 @@ export class GoogleMeetRuntime { }); } - async recoverCurrentTab(request: { url?: string } = {}) { - return recoverCurrentMeetTabOnNode({ - runtime: this.params.runtime, + async recoverCurrentTab(request: { url?: string; transport?: GoogleMeetTransport } = {}) { + const transport = resolveTransport(request.transport, this.params.config); + if (transport === "twilio") { + throw new Error("recover_current_tab only supports chrome or chrome-node transports"); + } + const url = request.url ? normalizeMeetUrl(request.url) : undefined; + if (transport === "chrome-node") { + return recoverCurrentMeetTabOnNode({ + runtime: this.params.runtime, + config: this.params.config, + url, + }); + } + return recoverCurrentMeetTab({ config: this.params.config, - url: request.url ? normalizeMeetUrl(request.url) : undefined, + url, }); } diff --git a/extensions/google-meet/src/transports/chrome.ts b/extensions/google-meet/src/transports/chrome.ts index 63b7bf71cb4..abf7c434f62 100644 --- a/extensions/google-meet/src/transports/chrome.ts +++ b/extensions/google-meet/src/transports/chrome.ts @@ -1,3 +1,4 @@ +import { callGatewayFromCli } from "openclaw/plugin-sdk/browser-node-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime"; import type { RuntimeLogger } from "openclaw/plugin-sdk/plugin-runtime"; @@ -23,6 +24,27 @@ import type { GoogleMeetChromeHealth } from "./types.js"; export const GOOGLE_MEET_SYSTEM_PROFILER_COMMAND = "/usr/sbin/system_profiler"; +type BrowserRequestParams = { + method: "GET" | "POST" | "DELETE"; + path: string; + body?: unknown; + timeoutMs: number; +}; + +type BrowserRequestCaller = (params: BrowserRequestParams) => Promise; + +const chromeTransportDeps: { + callGatewayFromCli: typeof callGatewayFromCli; +} = { + callGatewayFromCli, +}; + +export const __testing = { + setDepsForTest(deps: { callGatewayFromCli?: typeof callGatewayFromCli } | null) { + chromeTransportDeps.callGatewayFromCli = deps?.callGatewayFromCli ?? callGatewayFromCli; + }, +}; + export function outputMentionsBlackHole2ch(output: string): boolean { return /\bBlackHole\s+2ch\b/i.test(output); } @@ -215,6 +237,23 @@ function parseMeetBrowserStatus(result: unknown): GoogleMeetChromeHealth | undef }; } +async function callLocalBrowserRequest(params: BrowserRequestParams) { + return await chromeTransportDeps.callGatewayFromCli( + "browser.request", + { + json: true, + timeout: String(params.timeoutMs + 5_000), + }, + { + method: params.method, + path: params.path, + body: params.body, + timeoutMs: params.timeoutMs, + }, + { progress: false }, + ); +} + function meetStatusScript(params: { guestName: string; autoJoin: boolean }) { return `() => { const text = (node) => (node?.innerText || node?.textContent || "").trim(); @@ -412,11 +451,96 @@ function isRecoverableMeetTab(tab: BrowserTab, url?: string): boolean { ); } +async function inspectRecoverableMeetTab(params: { + callBrowser: BrowserRequestCaller; + config: GoogleMeetConfig; + timeoutMs: number; + tab: BrowserTab; + targetId: string; +}) { + await params.callBrowser({ + method: "POST", + path: "/tabs/focus", + body: { targetId: params.targetId }, + timeoutMs: Math.min(params.timeoutMs, 5_000), + }); + const evaluated = await params.callBrowser({ + method: "POST", + path: "/act", + body: { + kind: "evaluate", + targetId: params.targetId, + fn: meetStatusScript({ + guestName: params.config.chrome.guestName, + autoJoin: false, + }), + }, + timeoutMs: Math.min(params.timeoutMs, 10_000), + }); + const browser = parseMeetBrowserStatus(evaluated); + const manual = browser?.manualActionRequired + ? browser.manualActionMessage || browser.manualActionReason + : undefined; + return { + found: true, + targetId: params.targetId, + tab: params.tab, + browser, + message: + manual ?? (browser?.inCall ? "Existing Meet tab is in-call." : "Existing Meet tab focused."), + }; +} + +export async function recoverCurrentMeetTab(params: { + config: GoogleMeetConfig; + url?: string; +}): Promise<{ + transport: "chrome"; + nodeId?: undefined; + found: boolean; + targetId?: string; + tab?: BrowserTab; + browser?: GoogleMeetChromeHealth; + message: string; +}> { + const timeoutMs = Math.max(1_000, params.config.chrome.joinTimeoutMs); + const tabs = asBrowserTabs( + await callLocalBrowserRequest({ + method: "GET", + path: "/tabs", + timeoutMs: Math.min(timeoutMs, 5_000), + }), + ); + const tab = tabs.find((entry) => isRecoverableMeetTab(entry, params.url)); + const targetId = tab?.targetId; + if (!tab || !targetId) { + return { + transport: "chrome", + found: false, + tab, + message: params.url + ? `No existing Meet tab matched ${params.url}.` + : "No existing Meet tab found in local Chrome.", + }; + } + return { + transport: "chrome", + ...(await inspectRecoverableMeetTab({ + callBrowser: callLocalBrowserRequest, + config: params.config, + timeoutMs, + tab, + targetId, + })), + }; +} + export async function recoverCurrentMeetTabOnNode(params: { runtime: PluginRuntime; config: GoogleMeetConfig; url?: string; }): Promise<{ + transport: "chrome-node"; nodeId: string; found: boolean; targetId?: string; @@ -442,6 +566,7 @@ export async function recoverCurrentMeetTabOnNode(params: { const targetId = tab?.targetId; if (!tab || !targetId) { return { + transport: "chrome-node", nodeId, found: false, tab, @@ -450,44 +575,31 @@ export async function recoverCurrentMeetTabOnNode(params: { : "No existing Meet tab found on the selected Chrome node.", }; } - await callBrowserProxyOnNode({ - runtime: params.runtime, - nodeId, - method: "POST", - path: "/tabs/focus", - body: { targetId }, - timeoutMs: Math.min(timeoutMs, 5_000), - }); - const evaluated = await callBrowserProxyOnNode({ - runtime: params.runtime, - nodeId, - method: "POST", - path: "/act", - body: { - kind: "evaluate", - targetId, - fn: meetStatusScript({ - guestName: params.config.chrome.guestName, - autoJoin: false, - }), - }, - timeoutMs: Math.min(timeoutMs, 10_000), - }); - const browser = parseMeetBrowserStatus(evaluated); - const manual = browser?.manualActionRequired - ? browser.manualActionMessage || browser.manualActionReason - : undefined; return { + transport: "chrome-node", nodeId, - found: true, - targetId, - tab, - browser, - message: - manual ?? (browser?.inCall ? "Existing Meet tab is in-call." : "Existing Meet tab focused."), + ...(await inspectRecoverableMeetTab({ + callBrowser: async (request) => + await callBrowserProxyOnNode({ + runtime: params.runtime, + nodeId, + method: request.method, + path: request.path, + body: request.body, + timeoutMs: request.timeoutMs, + }), + config: params.config, + timeoutMs, + tab, + targetId, + })), }; } +export type GoogleMeetCurrentTabRecoveryResult = Awaited< + ReturnType +>; + export async function launchChromeMeetOnNode(params: { runtime: PluginRuntime; config: GoogleMeetConfig;