diff --git a/CHANGELOG.md b/CHANGELOG.md index ae5e50c9d70..1bbb77b297a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/plugins/google-meet.md b/docs/plugins/google-meet.md index 24c98aa528d..9b411206c36 100644 --- a/docs/plugins/google-meet.md +++ b/docs/plugins/google-meet.md @@ -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 diff --git a/extensions/google-meet/index.create.test.ts b/extensions/google-meet/index.create.test.ts index 71b61ebf1ee..9638b59a8f8 100644 --- a/extensions/google-meet/index.create.test.ts +++ b/extensions/google-meet/index.create.test.ts @@ -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; + respond: ReturnType; + }) => Promise) + | 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 }>; + }; + + 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( { diff --git a/extensions/google-meet/index.ts b/extensions/google-meet/index.ts index 165ea45f895..3d9c6495c6e 100644 --- a/extensions/google-meet/index.ts +++ b/extensions/google-meet/index.ts @@ -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)); } }, }); diff --git a/extensions/google-meet/src/transports/chrome-create.ts b/extensions/google-meet/src/transports/chrome-create.ts index 6c4bbd96015..e6d8f394a3a 100644 --- a/extensions/google-meet/src/transports/chrome-create.ts +++ b/extensions/google-meet/src/transports/chrome-create.ts @@ -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) { + 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 { 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) {