diff --git a/CHANGELOG.md b/CHANGELOG.md index b75f0c29463..23681779a2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,10 @@ Docs: https://docs.openclaw.ai ### Fixes - Plugins/Bonjour: stop the gateway from crash-looping on `CIAO PROBING CANCELLED` when the mDNS watchdog cancels a stuck probe. Restores the rejection-handler wiring dropped during the bonjour plugin migration and shares unhandled-rejection state across module instances so plugin-staged copies of `openclaw/plugin-sdk/runtime` register into the same handler set the host consults. Especially affects Docker on macOS, where mDNS probing reliably hits the watchdog. Thanks @troyhitch. +- Google Meet: report pinned Chrome nodes as offline or missing capabilities in + setup/join diagnostics, keep inaccessible nodes out of auto-selection, and + preflight local BlackHole/SoX requirements before agents try local Chrome. + Thanks @steipete. - Plugins/startup: remove ownerless bundled runtime-dependency install locks after a short grace window and include lock owner details when startup times out waiting for a plugin runtime-deps lock. diff --git a/docs/plugins/google-meet.md b/docs/plugins/google-meet.md index 0cf41222cfc..00707f980bc 100644 --- a/docs/plugins/google-meet.md +++ b/docs/plugins/google-meet.md @@ -79,6 +79,8 @@ audio bridge, node pinning, delayed realtime intro, and, when Twilio delegation is configured, whether the `voice-call` plugin and Twilio credentials are ready. Treat any `ok: false` check as a blocker before asking an agent to join. Use `openclaw googlemeet setup --json` for scripts or machine-readable output. +Use `--transport chrome`, `--transport chrome-node`, or `--transport twilio` +to preflight a specific transport before an agent tries it. Join a meeting: @@ -303,11 +305,17 @@ display name, or remote IP. Common failure checks: +- `Configured Google Meet node ... is not usable: offline`: the pinned node is + known to the Gateway but unavailable. Agents should treat that node as + diagnostic state, not as a usable Chrome host, and report the setup blocker + instead of falling back to another transport unless the user asked for that. - `No connected Google Meet-capable node`: start `openclaw node run` in the VM, approve pairing, and make sure `openclaw plugins enable google-meet` and `openclaw plugins enable browser` were run in the VM. Also confirm the Gateway host allows both node commands with `gateway.nodes.allowCommands: ["googlemeet.chrome", "browser.proxy"]`. +- `BlackHole 2ch audio device not found`: install `blackhole-2ch` on the host + being checked and reboot before using local Chrome audio. - `BlackHole 2ch audio device not found on the node`: install `blackhole-2ch` in the VM and reboot the VM. - Chrome opens but cannot join: sign in to the browser profile inside the VM, or diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index 7a689c88b91..678eefd5cca 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -858,19 +858,25 @@ describe("google-meet plugin", () => { }); it("reports setup status through the tool", async () => { - const { tools } = setup({ - chrome: { - audioInputCommand: ["openclaw-audio-bridge", "capture"], - audioOutputCommand: ["openclaw-audio-bridge", "play"], - }, - }); - const tool = tools[0] as { - execute: (id: string, params: unknown) => Promise<{ details: { ok?: boolean } }>; - }; + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "darwin" }); + try { + const { tools } = setup({ + chrome: { + audioInputCommand: ["openclaw-audio-bridge", "capture"], + audioOutputCommand: ["openclaw-audio-bridge", "play"], + }, + }); + const tool = tools[0] as { + execute: (id: string, params: unknown) => Promise<{ details: { ok?: boolean } }>; + }; - const result = await tool.execute("id", { action: "setup_status" }); + const result = await tool.execute("id", { action: "setup_status" }); - expect(result.details.ok).toBe(true); + expect(result.details.ok).toBe(true); + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform }); + } }); it("reports attendance through the tool", async () => { @@ -1045,7 +1051,20 @@ describe("google-meet plugin", () => { defaultTransport: "chrome-node", chromeNode: { node: "parallels-macos" }, }, - { nodesListResult: { nodes: [] } }, + { + nodesListResult: { + nodes: [ + { + nodeId: "node-1", + displayName: "parallels-macos", + connected: false, + caps: [], + commands: [], + remoteIp: "192.168.0.25", + }, + ], + }, + }, ); const tool = tools[0] as { execute: ( @@ -1062,10 +1081,97 @@ describe("google-meet plugin", () => { expect.objectContaining({ id: "chrome-node-connected", ok: false, - message: expect.stringContaining("No connected Google Meet-capable node"), + message: expect.stringContaining("parallels-macos"), }), ]), ); + const check = result.details.checks?.find( + (item) => (item as { id?: unknown }).id === "chrome-node-connected", + ) as { message?: string } | undefined; + expect(check?.message).toContain("offline"); + expect(check?.message).toContain("missing googlemeet.chrome"); + expect(check?.message).toContain("missing browser.proxy/browser capability"); + }); + + it("reports missing local Chrome audio prerequisites in setup status", async () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "darwin" }); + try { + const { tools } = setup( + { defaultTransport: "chrome" }, + { + runCommandWithTimeoutHandler: async (argv) => { + if (argv[0] === "/usr/sbin/system_profiler") { + return { code: 0, stdout: "Built-in Output", stderr: "" }; + } + return { code: 0, stdout: "", stderr: "" }; + }, + }, + ); + const tool = tools[0] as { + execute: ( + id: string, + params: unknown, + ) => Promise<{ details: { ok?: boolean; checks?: unknown[] } }>; + }; + + const result = await tool.execute("id", { action: "setup_status", transport: "chrome" }); + + expect(result.details.ok).toBe(false); + expect(result.details.checks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "chrome-local-audio-device", + ok: false, + message: expect.stringContaining("BlackHole 2ch audio device not found"), + }), + ]), + ); + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform }); + } + }); + + it("reports missing local Chrome audio commands in setup status", async () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "darwin" }); + try { + const { tools } = setup( + { defaultTransport: "chrome" }, + { + runCommandWithTimeoutHandler: async (argv) => { + if (argv[0] === "/usr/sbin/system_profiler") { + return { code: 0, stdout: "BlackHole 2ch", stderr: "" }; + } + if (argv[0] === "/bin/sh" && argv.at(-1) === "play") { + return { code: 1, stdout: "", stderr: "" }; + } + return { code: 0, stdout: "", stderr: "" }; + }, + }, + ); + const tool = tools[0] as { + execute: ( + id: string, + params: unknown, + ) => Promise<{ details: { ok?: boolean; checks?: unknown[] } }>; + }; + + const result = await tool.execute("id", { action: "setup_status", transport: "chrome" }); + + expect(result.details.ok).toBe(false); + expect(result.details.checks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "chrome-local-audio-commands", + ok: false, + message: "Chrome audio command missing: play", + }), + ]), + ); + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform }); + } }); it("reports Twilio delegation readiness when voice-call is enabled", async () => { @@ -1217,7 +1323,7 @@ describe("google-meet plugin", () => { }); expect(respond.mock.calls[0]?.[0]).toBe(true); - expect(nodesList).toHaveBeenCalledWith({ connected: true }); + expect(nodesList.mock.calls[0]).toEqual([]); expect(nodesInvoke).toHaveBeenCalledWith( expect.objectContaining({ nodeId: "node-1", diff --git a/extensions/google-meet/index.ts b/extensions/google-meet/index.ts index 53a0767d746..077736e1b08 100644 --- a/extensions/google-meet/index.ts +++ b/extensions/google-meet/index.ts @@ -566,10 +566,10 @@ export default definePluginEntry({ api.registerGatewayMethod( "googlemeet.setup", - async ({ respond }: GatewayRequestHandlerOptions) => { + async ({ params, respond }: GatewayRequestHandlerOptions) => { try { const rt = await ensureRuntime(); - respond(true, await rt.setupStatus()); + respond(true, await rt.setupStatus({ transport: normalizeTransport(params?.transport) })); } catch (err) { sendError(respond, err); } @@ -741,7 +741,7 @@ export default definePluginEntry({ name: "google_meet", label: "Google Meet", description: - "Join and track Google Meet sessions through Chrome or Twilio. 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_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.", parameters: GoogleMeetToolSchema, async execute(_toolCallId, params) { const raw = asParamRecord(params); @@ -797,7 +797,7 @@ export default definePluginEntry({ } case "setup_status": { const rt = await ensureRuntime(); - return json(await rt.setupStatus()); + return json(await rt.setupStatus({ transport: normalizeTransport(raw.transport) })); } case "resolve_space": { const { token: _token, ...result } = await resolveSpaceFromParams(config, raw); diff --git a/extensions/google-meet/src/cli.ts b/extensions/google-meet/src/cli.ts index d9fff749daf..db68bf9e6a7 100644 --- a/extensions/google-meet/src/cli.ts +++ b/extensions/google-meet/src/cli.ts @@ -129,6 +129,7 @@ export type GoogleMeetExportManifest = { type SetupOptions = { json?: boolean; + transport?: GoogleMeetTransport; }; type DoctorOptions = { @@ -1975,10 +1976,11 @@ export function registerGoogleMeetCli(params: { root .command("setup") .description("Show Google Meet transport setup status") + .option("--transport ", "Transport to check: chrome, chrome-node, or twilio") .option("--json", "Print JSON output", false) .action(async (options: SetupOptions) => { const rt = await params.ensureRuntime(); - const status = await rt.setupStatus(); + const status = await rt.setupStatus({ transport: options.transport }); if (options.json) { writeStdoutJson(status); return; diff --git a/extensions/google-meet/src/runtime.ts b/extensions/google-meet/src/runtime.ts index a053812b98e..503f715be2b 100644 --- a/extensions/google-meet/src/runtime.ts +++ b/extensions/google-meet/src/runtime.ts @@ -8,6 +8,7 @@ import { addGoogleMeetSetupCheck, getGoogleMeetSetupStatus } from "./setup.js"; import { isSameMeetUrlForReuse, resolveChromeNodeInfo } from "./transports/chrome-browser-proxy.js"; import { createMeetWithBrowserProxyOnNode } from "./transports/chrome-create.js"; import { + assertBlackHole2chAvailable, launchChromeMeet, launchChromeMeetOnNode, recoverCurrentMeetTabOnNode, @@ -53,6 +54,21 @@ function resolveMode(input: GoogleMeetMode | undefined, config: GoogleMeetConfig return input ?? config.defaultMode; } +function collectChromeAudioCommands(config: GoogleMeetConfig): string[] { + const commands = config.chrome.audioBridgeCommand + ? [config.chrome.audioBridgeCommand[0]] + : [config.chrome.audioInputCommand?.[0], config.chrome.audioOutputCommand?.[0]]; + return [...new Set(commands.filter((value): value is string => Boolean(value?.trim())))]; +} + +async function commandExists(runtime: PluginRuntime, command: string): Promise { + const result = await runtime.system.runCommandWithTimeout( + ["/bin/sh", "-lc", 'command -v "$1" >/dev/null 2>&1', "sh", command], + { timeoutMs: 5_000 }, + ); + return result.code === 0; +} + export class GoogleMeetRuntime { readonly #sessions = new Map(); readonly #sessionStops = new Map Promise>(); @@ -86,14 +102,15 @@ export class GoogleMeetRuntime { return session ? { found: true, session } : { found: false }; } - async setupStatus() { + async setupStatus(options: { transport?: GoogleMeetTransport } = {}) { + const transport = resolveTransport(options.transport, this.params.config); + const shouldCheckChromeNode = + transport === "chrome-node" || + (!options.transport && Boolean(this.params.config.chromeNode.node)); let status = getGoogleMeetSetupStatus(this.params.config, { fullConfig: this.params.fullConfig, }); - if ( - this.params.config.defaultTransport === "chrome-node" || - Boolean(this.params.config.chromeNode.node) - ) { + if (shouldCheckChromeNode) { try { const node = await resolveChromeNodeInfo({ runtime: this.params.runtime, @@ -113,6 +130,47 @@ export class GoogleMeetRuntime { }); } } + if (transport === "chrome") { + try { + await assertBlackHole2chAvailable({ + runtime: this.params.runtime, + timeoutMs: Math.min(this.params.config.chrome.joinTimeoutMs, 10_000), + }); + status = addGoogleMeetSetupCheck(status, { + id: "chrome-local-audio-device", + ok: true, + message: "BlackHole 2ch audio device found", + }); + } catch (error) { + status = addGoogleMeetSetupCheck(status, { + id: "chrome-local-audio-device", + ok: false, + message: formatErrorMessage(error), + }); + } + + const commands = collectChromeAudioCommands(this.params.config); + const missingCommands: string[] = []; + for (const command of commands) { + try { + if (!(await commandExists(this.params.runtime, command))) { + missingCommands.push(command); + } + } catch { + missingCommands.push(command); + } + } + status = addGoogleMeetSetupCheck(status, { + id: "chrome-local-audio-commands", + ok: commands.length > 0 && missingCommands.length === 0, + message: + commands.length === 0 + ? "Chrome realtime audio commands are not configured" + : missingCommands.length === 0 + ? `Chrome audio command${commands.length === 1 ? "" : "s"} available: ${commands.join(", ")}` + : `Chrome audio command${missingCommands.length === 1 ? "" : "s"} missing: ${missingCommands.join(", ")}`, + }); + } return status; } diff --git a/extensions/google-meet/src/test-support/plugin-harness.ts b/extensions/google-meet/src/test-support/plugin-harness.ts index 3d4066e5022..da9b586c57b 100644 --- a/extensions/google-meet/src/test-support/plugin-harness.ts +++ b/extensions/google-meet/src/test-support/plugin-harness.ts @@ -24,6 +24,12 @@ export type GoogleMeetTestNodeListResult = { }>; }; +type CommandResult = { + code: number; + stdout?: string; + stderr?: string; +}; + export function captureStdout() { let output = ""; const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => { @@ -50,6 +56,10 @@ export function setupGoogleMeetPlugin( params?: unknown; timeoutMs?: number; }) => Promise; + runCommandWithTimeoutHandler?: ( + argv: string[], + options?: { timeoutMs?: number }, + ) => Promise; } = {}, ) { const methods = new Map(); @@ -112,12 +122,17 @@ export function setupGoogleMeetPlugin( } return options.nodesInvokeResult ?? { launched: true }; }); - const runCommandWithTimeout = vi.fn(async (argv: string[]) => { - if (argv[0] === "/usr/sbin/system_profiler") { - return { code: 0, stdout: "BlackHole 2ch", stderr: "" }; - } - return { code: 0, stdout: "", stderr: "" }; - }); + const runCommandWithTimeout = vi.fn( + async (argv: string[], runOptions?: { timeoutMs?: number }) => { + if (options.runCommandWithTimeoutHandler) { + return options.runCommandWithTimeoutHandler(argv, runOptions); + } + if (argv[0] === "/usr/sbin/system_profiler") { + return { code: 0, stdout: "BlackHole 2ch", stderr: "" }; + } + return { code: 0, stdout: "", stderr: "" }; + }, + ); const api = createTestPluginApi({ id: "google-meet", name: "Google Meet", diff --git a/extensions/google-meet/src/transports/chrome-browser-proxy.ts b/extensions/google-meet/src/transports/chrome-browser-proxy.ts index 04383a87536..77ad9b13ea1 100644 --- a/extensions/google-meet/src/transports/chrome-browser-proxy.ts +++ b/extensions/google-meet/src/transports/chrome-browser-proxy.ts @@ -54,27 +54,78 @@ function isGoogleMeetNode(node: GoogleMeetNodeInfo) { ); } +function matchesRequestedNode(node: GoogleMeetNodeInfo, requested: string): boolean { + return [node.nodeId, node.displayName, node.remoteIp].some((value) => value === requested); +} + +function formatNodeLabel(node: GoogleMeetNodeInfo): string { + const parts = [node.displayName, node.nodeId, node.remoteIp].filter(Boolean); + return parts.length > 0 ? parts.join(" / ") : "unknown node"; +} + +function describeNodeUsabilityIssues(node: GoogleMeetNodeInfo): string[] { + const commands = Array.isArray(node.commands) ? node.commands : []; + const caps = Array.isArray(node.caps) ? node.caps : []; + const issues: string[] = []; + if (node.connected !== true) { + issues.push("offline"); + } + if (!commands.includes("googlemeet.chrome")) { + issues.push("missing googlemeet.chrome"); + } + if (!commands.includes("browser.proxy") && !caps.includes("browser")) { + issues.push("missing browser.proxy/browser capability"); + } + return issues; +} + +async function listGoogleMeetNodes( + runtime: PluginRuntime, + params?: { connected?: boolean }, +): Promise<{ nodes: GoogleMeetNodeInfo[] }> { + try { + return params ? await runtime.nodes.list(params) : await runtime.nodes.list(); + } catch (error) { + throw new Error("Google Meet node inventory unavailable", { + cause: error, + }); + } +} + export async function resolveChromeNodeInfo(params: { runtime: PluginRuntime; requestedNode?: string; }): Promise { - const list = await params.runtime.nodes.list({ connected: true }); + const requested = params.requestedNode?.trim(); + if (requested) { + const list = await listGoogleMeetNodes(params.runtime); + const matches = list.nodes.filter((node) => matchesRequestedNode(node, requested)); + if (matches.length === 1) { + const [node] = matches; + if (isGoogleMeetNode(node)) { + return node; + } + throw new Error( + `Configured Google Meet node ${requested} is not usable (${formatNodeLabel(node)}): ${describeNodeUsabilityIssues(node).join("; ")}. Start or reinstall \`openclaw node run\` on that Chrome host, approve pairing, and allow googlemeet.chrome plus browser.proxy.`, + ); + } + if (matches.length > 1) { + throw new Error( + `Configured Google Meet node ${requested} is ambiguous (${matches.length} matches). Pin chromeNode.node to a unique node id, display name, or remote IP.`, + ); + } + throw new Error( + `Configured Google Meet node ${requested} was not found. Run \`openclaw nodes status\` and start or approve the Chrome node.`, + ); + } + + const list = await listGoogleMeetNodes(params.runtime, { connected: true }); const nodes = list.nodes.filter(isGoogleMeetNode); if (nodes.length === 0) { throw new Error( "No connected Google Meet-capable node with browser proxy. Run `openclaw node run` on the Chrome host with browser proxy enabled, approve pairing, and allow googlemeet.chrome plus browser.proxy.", ); } - const requested = params.requestedNode?.trim(); - if (requested) { - const matches = nodes.filter((node) => - [node.nodeId, node.displayName, node.remoteIp].some((value) => value === requested), - ); - if (matches.length === 1) { - return matches[0]; - } - throw new Error(`Google Meet node not found or ambiguous: ${requested}`); - } if (nodes.length === 1) { return nodes[0]; }