fix: improve google meet setup diagnostics

This commit is contained in:
Peter Steinberger
2026-04-25 19:57:31 +01:00
parent e36b77c13e
commit 5eab16e086
8 changed files with 285 additions and 41 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<boolean> {
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<string, GoogleMeetSession>();
readonly #sessionStops = new Map<string, () => Promise<void>>();
@@ -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;
}

View File

@@ -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<unknown>;
runCommandWithTimeoutHandler?: (
argv: string[],
options?: { timeoutMs?: number },
) => Promise<CommandResult>;
} = {},
) {
const methods = new Map<string, unknown>();
@@ -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",

View File

@@ -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<GoogleMeetNodeInfo> {
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];
}