fix(google-meet): use OpenClaw browser for local joins

This commit is contained in:
Peter Steinberger
2026-04-27 14:03:30 +01:00
parent 8de458c6c0
commit 57401f1581
5 changed files with 115 additions and 66 deletions

View File

@@ -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 <id|name|ip>` and `node.pair.remove` so stale gateway-owned node pairing records can be cleaned without hand-editing state files. Thanks @openclaw.

View File

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

View File

@@ -94,6 +94,45 @@ function requestUrl(input: RequestInfo | URL): URL {
return new URL(input.url);
}
function mockLocalMeetBrowserRequest(
browserActResult: Record<string, unknown> = {
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<Record<string, unknown>> => {
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<string, unknown>;
@@ -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<string, unknown>;
@@ -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 });
}

View File

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

View File

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