fix(google-meet): surface browser create manual actions

This commit is contained in:
Peter Steinberger
2026-04-25 04:56:55 +01:00
parent 70fd1c91aa
commit 0f0c855a8b
5 changed files with 222 additions and 6 deletions

View File

@@ -117,6 +117,7 @@ Docs: https://docs.openclaw.ai
- Agents/TTS: suppress successful spoken transcripts from verbose chat tool output when structured voice media is already queued, while preserving text output for non-builtin tool-name collisions. Fixes #71282. Thanks @neeravmakwana.
- Plugins/Google Meet: reuse existing Meet tabs and active sessions across harmless URL query differences, avoiding duplicate Chrome windows when agents retry a join. Thanks @steipete.
- Plugins/Google Meet: tell agents to recover already-open Meet tabs after browser timeouts, and make the dev CLI release its build lock if compiler spawning fails. Thanks @steipete.
- Plugins/Google Meet: return structured manual-action details when browser-based meeting creation needs login or permissions, so agents can guide the operator without opening duplicate Meet tabs. Thanks @steipete.
- Plugins/CLI: provide Gateway-backed node inspection to plugin commands, so `googlemeet recover-tab` can inspect paired browser nodes from the terminal. Thanks @steipete.
- Gateway/sessions: recover main-agent turns interrupted by a gateway restart from stale transcript-lock evidence, avoiding stuck `status: "running"` sessions without broad post-boot transcript scans. Fixes #70555. Thanks @bitloi.
- Codex approvals: sanitize MCP elicitation approval titles, descriptions, and display parameters before forwarding them to OpenClaw approval prompts. (#71343) Thanks @Lucenx9.

View File

@@ -512,6 +512,30 @@ Example JSON output from the browser fallback:
}
```
If the browser fallback hits Google login or a Meet permission blocker before it
can create the URL, the Gateway method returns a failed response and the
`google_meet` tool returns structured details instead of a plain string:
```json
{
"source": "browser",
"error": "google-login-required: Sign in to Google in the OpenClaw browser profile, then retry meeting creation.",
"manualActionRequired": true,
"manualActionReason": "google-login-required",
"manualActionMessage": "Sign in to Google in the OpenClaw browser profile, then retry meeting creation.",
"browser": {
"nodeId": "ba0f4e4bc...",
"targetId": "tab-1",
"browserUrl": "https://accounts.google.com/signin",
"browserTitle": "Sign in - Google Accounts"
}
}
```
When an agent sees `manualActionRequired: true`, it should report the
`manualActionMessage` plus the browser node/tab context and stop opening new
Meet tabs until the operator completes the browser step.
Example JSON output from API create:
```json
@@ -888,6 +912,10 @@ to the pinned Chrome node browser. Confirm:
- For browser fallback: retries reuse an existing `https://meet.google.com/new`
or Google account prompt tab before opening a new tab. If an agent times out,
retry the tool call rather than manually opening another Meet tab.
- For browser fallback: if the tool returns `manualActionRequired: true`, use
the returned `browser.nodeId`, `browser.targetId`, `browserUrl`, and
`manualActionMessage` to guide the operator. Do not retry in a loop until that
action is complete.
- For browser fallback: if Meet shows "Do you want people to hear you in the
meeting?", leave the tab open. OpenClaw should click **Use microphone** or, for
create-only fallback, **Continue without microphone** through browser

View File

@@ -197,6 +197,80 @@ describe("google-meet create flow", () => {
);
});
it("reports structured manual action when browser creation needs Google login", async () => {
const { methods } = setup(
{
defaultTransport: "chrome-node",
chromeNode: { node: "parallels-macos" },
},
{
nodesInvokeHandler: async (params) => {
const proxy = params.params as { path?: string; body?: { url?: string } };
if (proxy.path === "/tabs") {
return { payload: { result: { tabs: [] } } };
}
if (proxy.path === "/tabs/open") {
return {
payload: {
result: {
targetId: "login-tab",
title: "New Tab",
url: proxy.body?.url,
},
},
};
}
if (proxy.path === "/act") {
return {
payload: {
result: {
ok: true,
targetId: "login-tab",
result: {
manualActionReason: "google-login-required",
manualAction:
"Sign in to Google in the OpenClaw browser profile, then retry meeting creation.",
browserUrl: "https://accounts.google.com/signin",
browserTitle: "Sign in - Google Accounts",
notes: ["Sign-in page detected."],
},
},
},
};
}
throw new Error(`unexpected browser proxy path ${proxy.path}`);
},
},
);
const handler = methods.get("googlemeet.create") as
| ((ctx: {
params: Record<string, unknown>;
respond: ReturnType<typeof vi.fn>;
}) => Promise<void>)
| undefined;
const respond = vi.fn();
await handler?.({ params: {}, respond });
expect(respond.mock.calls[0]?.[0]).toBe(false);
expect(respond.mock.calls[0]?.[1]).toMatchObject({
source: "browser",
error:
"google-login-required: Sign in to Google in the OpenClaw browser profile, then retry meeting creation.",
manualActionRequired: true,
manualActionReason: "google-login-required",
manualActionMessage:
"Sign in to Google in the OpenClaw browser profile, then retry meeting creation.",
browser: {
nodeId: "node-1",
targetId: "login-tab",
browserUrl: "https://accounts.google.com/signin",
browserTitle: "Sign in - Google Accounts",
notes: ["Sign-in page detected."],
},
});
});
it("creates and joins a Meet through the create tool action by default", async () => {
const { tools, nodesInvoke } = setup(
{
@@ -292,6 +366,71 @@ describe("google-meet create flow", () => {
);
});
it("returns structured manual action from the create tool action", async () => {
const { tools } = setup(
{
defaultTransport: "chrome-node",
chromeNode: { node: "parallels-macos" },
},
{
nodesInvokeHandler: async (params) => {
const proxy = params.params as { path?: string; body?: { url?: string } };
if (proxy.path === "/tabs") {
return { payload: { result: { tabs: [] } } };
}
if (proxy.path === "/tabs/open") {
return {
payload: {
result: {
targetId: "permission-tab",
title: "Meet",
url: proxy.body?.url,
},
},
};
}
if (proxy.path === "/act") {
return {
payload: {
result: {
ok: true,
targetId: "permission-tab",
result: {
manualActionReason: "meet-permission-required",
manualAction:
"Allow microphone/camera permissions for Meet in the OpenClaw browser profile, then retry meeting creation.",
browserUrl: "https://meet.google.com/new",
browserTitle: "Meet",
},
},
},
};
}
throw new Error(`unexpected browser proxy path ${proxy.path}`);
},
},
);
const tool = tools[0] as {
execute: (id: string, params: unknown) => Promise<{ details: Record<string, unknown> }>;
};
const result = await tool.execute("id", { action: "create" });
expect(result.details).toMatchObject({
source: "browser",
manualActionRequired: true,
manualActionReason: "meet-permission-required",
manualActionMessage:
"Allow microphone/camera permissions for Meet in the OpenClaw browser profile, then retry meeting creation.",
browser: {
nodeId: "node-1",
targetId: "permission-tab",
browserUrl: "https://meet.google.com/new",
browserTitle: "Meet",
},
});
});
it("reuses an existing browser create tab instead of opening duplicates", async () => {
const { methods, nodesInvoke } = setup(
{

View File

@@ -19,6 +19,7 @@ import { buildGoogleMeetPreflightReport, fetchGoogleMeetSpace } from "./src/meet
import { handleGoogleMeetNodeHostCommand } from "./src/node-host.js";
import { resolveGoogleMeetAccessToken } from "./src/oauth.js";
import { GoogleMeetRuntime } from "./src/runtime.js";
import { isGoogleMeetBrowserManualActionError } from "./src/transports/chrome-create.js";
const googleMeetConfigSchema = {
parse(value: unknown) {
@@ -250,8 +251,11 @@ export default definePluginEntry({
return runtime;
};
const formatGatewayError = (err: unknown) =>
isGoogleMeetBrowserManualActionError(err) ? err.payload : { error: formatErrorMessage(err) };
const sendError = (respond: (ok: boolean, payload?: unknown) => void, err: unknown) => {
respond(false, { error: formatErrorMessage(err) });
respond(false, formatGatewayError(err));
};
api.registerGatewayMethod(
@@ -485,7 +489,7 @@ export default definePluginEntry({
throw new Error("unknown google_meet action");
}
} catch (err) {
return json({ error: formatErrorMessage(err) });
return json(formatGatewayError(err));
}
},
});

View File

@@ -35,6 +35,42 @@ export type GoogleMeetBrowserCreateResult = {
source: "browser";
};
export type GoogleMeetBrowserManualAction = {
source: "browser";
error: string;
manualActionRequired: true;
manualActionReason?: GoogleMeetChromeHealth["manualActionReason"];
manualActionMessage: string;
browser: {
nodeId: string;
targetId?: string;
browserUrl?: string;
browserTitle?: string;
notes?: string[];
};
};
export class GoogleMeetBrowserManualActionError extends Error {
readonly payload: GoogleMeetBrowserManualAction;
constructor(payload: Omit<GoogleMeetBrowserManualAction, "source" | "error">) {
const prefix = payload.manualActionReason ? `${payload.manualActionReason}: ` : "";
super(`${prefix}${payload.manualActionMessage}`);
this.name = "GoogleMeetBrowserManualActionError";
this.payload = {
source: "browser",
error: this.message,
...payload,
};
}
}
export function isGoogleMeetBrowserManualActionError(
error: unknown,
): error is GoogleMeetBrowserManualActionError {
return error instanceof GoogleMeetBrowserManualActionError;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -298,10 +334,18 @@ export async function createMeetWithBrowserProxyOnNode(params: {
};
}
if (result.manualAction) {
if (result.manualActionReason) {
throw new Error(`${result.manualActionReason}: ${result.manualAction}`);
}
throw new Error(result.manualAction);
throw new GoogleMeetBrowserManualActionError({
manualActionRequired: true,
manualActionReason: result.manualActionReason,
manualActionMessage: result.manualAction,
browser: {
nodeId,
targetId,
browserUrl: result.browserUrl,
browserTitle: result.browserTitle,
notes: [...notes],
},
});
}
await sleep(result.retryAfterMs ?? GOOGLE_MEET_BROWSER_POLL_MS);
} catch (error) {