mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 16:50:43 +00:00
fix: improve google meet setup diagnostics
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user