fix(google-meet): surface chrome node readiness in setup

This commit is contained in:
Peter Steinberger
2026-04-25 02:17:09 +01:00
parent d9bd010e5e
commit 52cc1ebac7
8 changed files with 142 additions and 18 deletions

View File

@@ -63,6 +63,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Plugins/Google Meet: include live Chrome-node readiness in `googlemeet setup` and document the Parallels recovery checks, so stale node tokens or disconnected VM browsers are visible before an agent opens a meeting. Thanks @steipete.
- Plugins/runtime deps: isolate the internal npm cache used for bundled plugin runtime-dependency repair and let package updates refresh/verify already-current installs, so failed update or sudo doctor runs can be repaired by rerunning `openclaw update`. Thanks @steipete.
- Plugins/runtime deps: stage bundled plugin runtime dependencies for packaged/global installs in an external runtime root and retain already staged deps across repairs, avoiding package-tree update races and npm pruning after upgrades. Thanks @steipete.
- Plugins/runtime deps: log bundled plugin runtime-dependency staging before synchronous npm installs start and include elapsed timing afterward, so first boot after upgrades no longer looks hung while dependencies are being repaired. Thanks @steipete.

View File

@@ -728,11 +728,29 @@ openclaw googlemeet test-speech https://meet.google.com/abc-defg-hij \
Expected Chrome-node state:
- `googlemeet setup` is all green.
- `googlemeet setup` includes `chrome-node-connected` when Chrome-node is the
default transport or a node is pinned.
- `nodes status` shows the selected node connected.
- The selected node advertises both `googlemeet.chrome` and `browser.proxy`.
- The Meet tab joins the call and `test-speech` returns Chrome health with
`inCall: true`.
For a remote Chrome host such as a Parallels macOS VM, this is the shortest
safe check after updating the Gateway or the VM:
```bash
openclaw googlemeet setup
openclaw nodes status --connected
openclaw nodes invoke \
--node parallels-macos \
--command googlemeet.chrome \
--params '{"action":"setup"}'
```
That proves the Gateway plugin is loaded, the VM node is connected with the
current token, and the Meet audio bridge is available before an agent opens a
real meeting tab.
For a Twilio smoke, use a meeting that exposes phone dial-in details:
```bash
@@ -798,6 +816,26 @@ The Gateway config must allow those node commands:
}
```
If `googlemeet setup` fails `chrome-node-connected` or the Gateway log reports
`gateway token mismatch`, reinstall or restart the node with the current Gateway
token. For a LAN Gateway this usually means:
```bash
OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1 \
openclaw node install \
--host <gateway-lan-ip> \
--port 18789 \
--display-name parallels-macos \
--force
```
Then reload the node service and re-run:
```bash
openclaw googlemeet setup
openclaw nodes status --connected
```
### Browser opens but agent cannot join
Run `googlemeet test-speech` and inspect the returned Chrome health. If it

View File

@@ -451,6 +451,35 @@ describe("google-meet plugin", () => {
expect(result.details.ok).toBe(true);
});
it("fails setup status when the configured Chrome node is not connected", async () => {
const { tools } = setup(
{
defaultTransport: "chrome-node",
chromeNode: { node: "parallels-macos" },
},
{ nodesListResult: { nodes: [] } },
);
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" });
expect(result.details.ok).toBe(false);
expect(result.details.checks).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "chrome-node-connected",
ok: false,
message: expect.stringContaining("No connected Google Meet-capable node"),
}),
]),
);
});
it("reports Twilio delegation readiness when voice-call is enabled", async () => {
vi.stubEnv("TWILIO_ACCOUNT_SID", "AC123");
vi.stubEnv("TWILIO_AUTH_TOKEN", "secret");
@@ -547,7 +576,7 @@ describe("google-meet plugin", () => {
config: resolveGoogleMeetConfig({}),
ensureRuntime: async () =>
({
setupStatus: () => ({
setupStatus: async () => ({
ok: true,
checks: [
{
@@ -557,7 +586,7 @@ describe("google-meet plugin", () => {
},
],
}),
}) as GoogleMeetRuntime,
}) as unknown as GoogleMeetRuntime,
});
try {
@@ -580,11 +609,11 @@ describe("google-meet plugin", () => {
config: resolveGoogleMeetConfig({}),
ensureRuntime: async () =>
({
setupStatus: () => ({
setupStatus: async () => ({
ok: false,
checks: [{ id: "twilio-voice-call-plugin", ok: false, message: "missing" }],
}),
}) as GoogleMeetRuntime,
}) as unknown as GoogleMeetRuntime,
});
try {

View File

@@ -313,7 +313,7 @@ export default definePluginEntry({
async ({ respond }: GatewayRequestHandlerOptions) => {
try {
const rt = await ensureRuntime();
respond(true, rt.setupStatus());
respond(true, await rt.setupStatus());
} catch (err) {
sendError(respond, err);
}
@@ -430,7 +430,7 @@ export default definePluginEntry({
}
case "setup_status": {
const rt = await ensureRuntime();
return json(rt.setupStatus());
return json(await rt.setupStatus());
}
case "resolve_space": {
const { token: _token, ...result } = await resolveSpaceFromParams(config, raw);

View File

@@ -95,7 +95,7 @@ function parseOptionalNumber(value: string | undefined): number | undefined {
return parsed;
}
function writeSetupStatus(status: ReturnType<GoogleMeetRuntime["setupStatus"]>): void {
function writeSetupStatus(status: Awaited<ReturnType<GoogleMeetRuntime["setupStatus"]>>): void {
writeStdoutLine("Google Meet setup: %s", status.ok ? "OK" : "needs attention");
for (const check of status.checks) {
writeStdoutLine("[%s] %s: %s", check.ok ? "ok" : "fail", check.id, check.message);
@@ -485,7 +485,7 @@ export function registerGoogleMeetCli(params: {
.option("--json", "Print JSON output", false)
.action(async (options: SetupOptions) => {
const rt = await params.ensureRuntime();
const status = rt.setupStatus();
const status = await rt.setupStatus();
if (options.json) {
writeStdoutJson(status);
return;

View File

@@ -4,7 +4,8 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk/plugin-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type { GoogleMeetConfig, GoogleMeetMode, GoogleMeetTransport } from "./config.js";
import { getGoogleMeetSetupStatus } from "./setup.js";
import { addGoogleMeetSetupCheck, getGoogleMeetSetupStatus } from "./setup.js";
import { resolveChromeNodeInfo } from "./transports/chrome-browser-proxy.js";
import { createMeetWithBrowserProxyOnNode } from "./transports/chrome-create.js";
import { launchChromeMeet, launchChromeMeetOnNode } from "./transports/chrome.js";
import { buildMeetDtmfSequence, normalizeDialInNumber } from "./transports/twilio.js";
@@ -81,8 +82,34 @@ export class GoogleMeetRuntime {
return session ? { found: true, session } : { found: false };
}
setupStatus() {
return getGoogleMeetSetupStatus(this.params.config, { fullConfig: this.params.fullConfig });
async setupStatus() {
let status = getGoogleMeetSetupStatus(this.params.config, {
fullConfig: this.params.fullConfig,
});
if (
this.params.config.defaultTransport === "chrome-node" ||
Boolean(this.params.config.chromeNode.node)
) {
try {
const node = await resolveChromeNodeInfo({
runtime: this.params.runtime,
requestedNode: this.params.config.chromeNode.node,
});
const label = node.displayName ?? node.remoteIp ?? node.nodeId ?? "connected node";
status = addGoogleMeetSetupCheck(status, {
id: "chrome-node-connected",
ok: true,
message: `Connected Google Meet node ready: ${label}`,
});
} catch (error) {
status = addGoogleMeetSetupCheck(status, {
id: "chrome-node-connected",
ok: false,
message: formatErrorMessage(error),
});
}
}
return status;
}
async createViaBrowser() {

View File

@@ -3,12 +3,17 @@ import os from "node:os";
import path from "node:path";
import type { GoogleMeetConfig } from "./config.js";
type SetupCheck = {
export type SetupCheck = {
id: string;
ok: boolean;
message: string;
};
export type GoogleMeetSetupStatus = {
ok: boolean;
checks: SetupCheck[];
};
function resolveUserPath(input: string): string {
if (input === "~") {
return os.homedir();
@@ -177,6 +182,17 @@ export function getGoogleMeetSetupStatus(
};
}
export function addGoogleMeetSetupCheck(
status: GoogleMeetSetupStatus,
check: SetupCheck,
): GoogleMeetSetupStatus {
const checks = [...status.checks, check];
return {
ok: checks.every((item) => item.ok),
checks,
};
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)

View File

@@ -10,14 +10,16 @@ export type BrowserTab = {
url?: string;
};
function isGoogleMeetNode(node: {
export type GoogleMeetNodeInfo = {
caps?: string[];
commands?: string[];
connected?: boolean;
nodeId?: string;
displayName?: string;
remoteIp?: string;
}) {
};
function isGoogleMeetNode(node: GoogleMeetNodeInfo) {
const commands = Array.isArray(node.commands) ? node.commands : [];
const caps = Array.isArray(node.caps) ? node.caps : [];
return (
@@ -27,10 +29,10 @@ function isGoogleMeetNode(node: {
);
}
export async function resolveChromeNode(params: {
export async function resolveChromeNodeInfo(params: {
runtime: PluginRuntime;
requestedNode?: string;
}): Promise<string> {
}): Promise<GoogleMeetNodeInfo> {
const list = await params.runtime.nodes.list({ connected: true });
const nodes = list.nodes.filter(isGoogleMeetNode);
if (nodes.length === 0) {
@@ -44,18 +46,29 @@ export async function resolveChromeNode(params: {
[node.nodeId, node.displayName, node.remoteIp].some((value) => value === requested),
);
if (matches.length === 1) {
return matches[0].nodeId;
return matches[0];
}
throw new Error(`Google Meet node not found or ambiguous: ${requested}`);
}
if (nodes.length === 1) {
return nodes[0].nodeId;
return nodes[0];
}
throw new Error(
"Multiple Google Meet-capable nodes connected. Set plugins.entries.google-meet.config.chromeNode.node.",
);
}
export async function resolveChromeNode(params: {
runtime: PluginRuntime;
requestedNode?: string;
}): Promise<string> {
const node = await resolveChromeNodeInfo(params);
if (!node.nodeId) {
throw new Error("Google Meet node did not include a node id.");
}
return node.nodeId;
}
function unwrapNodeInvokePayload(raw: unknown): unknown {
const record = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
if (typeof record.payloadJSON === "string" && record.payloadJSON.trim()) {