fix(google-meet): recover local chrome tabs

This commit is contained in:
Peter Steinberger
2026-04-26 12:03:48 +01:00
parent 647e557869
commit a97ee5c1d3
7 changed files with 268 additions and 49 deletions

View File

@@ -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

View File

@@ -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<Record<string, unknown>> => {
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(
{

View File

@@ -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();

View File

@@ -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",

View File

@@ -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<ReturnType<GoogleMeetRuntime["recoverCurrentTab"]>>,
): 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>", "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;

View File

@@ -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,
});
}

View File

@@ -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<unknown>;
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<typeof recoverCurrentMeetTab | typeof recoverCurrentMeetTabOnNode>
>;
export async function launchChromeMeetOnNode(params: {
runtime: PluginRuntime;
config: GoogleMeetConfig;