feat(google-meet): add oauth doctor

This commit is contained in:
Peter Steinberger
2026-04-25 07:36:11 +01:00
parent 2ff7eb36cf
commit d37f165bee
4 changed files with 445 additions and 4 deletions

View File

@@ -73,6 +73,7 @@ Docs: https://docs.openclaw.ai
- Plugins/Google Meet: default Chrome realtime sessions to OpenAI plus SoX `rec`/`play` audio bridge commands, so the usual setup only needs the plugin enabled and `OPENAI_API_KEY`. Thanks @steipete.
- Plugins/Google Meet: add a `chrome-node` transport so a paired macOS node, such as a Parallels VM, can own Chrome, BlackHole, and SoX while the Gateway machine keeps the agent and model key. Thanks @steipete.
- Plugins/Google Meet: add `googlemeet artifacts` and `googlemeet attendance` commands plus matching tool/gateway actions for conference records, recordings, transcripts, smart notes, and participant sessions. Thanks @steipete.
- Plugins/Google Meet: add `googlemeet doctor --oauth` so operators can verify OAuth token refresh, Meet space reads, and side-effecting space creation without printing secrets. Thanks @steipete.
- Plugins/Voice Call: expose the shared `openclaw_agent_consult` realtime tool so live phone calls can ask the full OpenClaw agent for deeper/tool-backed answers. Thanks @steipete.
- Plugins/Voice Call: add `voicecall setup` and a dry-run-by-default `voicecall smoke` command so Twilio/provider readiness can be checked before placing a live test call. Thanks @steipete.
- Plugins/Google Meet: add `googlemeet doctor` and a `recover_current_tab`/`recover-tab` flow so agents can inspect an already-open Meet tab and report the blocker without opening another window. Thanks @steipete.

View File

@@ -437,8 +437,51 @@ OAuth is optional for creating a Meet link because `googlemeet create` can fall
back to browser automation. Configure OAuth when you want official API create,
space resolution, or Meet Media API preflight checks.
Google Meet API access uses a personal OAuth client first. Configure
`oauth.clientId` and optionally `oauth.clientSecret`, then run:
Google Meet API access uses user OAuth: create a Google Cloud OAuth client,
request the required scopes, authorize a Google account, then store the
resulting refresh token in the Google Meet plugin config or provide the
`OPENCLAW_GOOGLE_MEET_*` environment variables.
OAuth does not replace the Chrome join path. Chrome and Chrome-node transports
still join through a signed-in Chrome profile, BlackHole/SoX, and a connected
node when you use browser participation. OAuth is only for the official Google
Meet API path: create meeting spaces, resolve spaces, and run Meet Media API
preflight checks.
### Create Google credentials
In Google Cloud Console:
1. Create or select a Google Cloud project.
2. Enable **Google Meet REST API** for that project.
3. Configure the OAuth consent screen.
- **Internal** is simplest for a Google Workspace organization.
- **External** works for personal/test setups; while the app is in Testing,
add each Google account that will authorize the app as a test user.
4. Add the scopes OpenClaw requests:
- `https://www.googleapis.com/auth/meetings.space.created`
- `https://www.googleapis.com/auth/meetings.space.readonly`
- `https://www.googleapis.com/auth/meetings.conference.media.readonly`
5. Create an OAuth client ID.
- Application type: **Web application**.
- Authorized redirect URI:
```text
http://localhost:8085/oauth2callback
```
6. Copy the client ID and client secret.
`meetings.space.created` is required by Google Meet `spaces.create`.
`meetings.space.readonly` lets OpenClaw resolve Meet URLs/codes to spaces.
`meetings.conference.media.readonly` is for Meet Media API preflight and media
work; Google may require Developer Preview enrollment for actual Media API use.
If you only need browser-based Chrome joins, skip OAuth entirely.
### Mint the refresh token
Configure `oauth.clientId` and optionally `oauth.clientSecret`, or pass them as
environment variables, then run:
```bash
openclaw googlemeet auth login --json
@@ -448,11 +491,116 @@ The command prints an `oauth` config block with a refresh token. It uses PKCE,
localhost callback on `http://localhost:8085/oauth2callback`, and a manual
copy/paste flow with `--manual`.
Examples:
```bash
OPENCLAW_GOOGLE_MEET_CLIENT_ID="your-client-id" \
OPENCLAW_GOOGLE_MEET_CLIENT_SECRET="your-client-secret" \
openclaw googlemeet auth login --json
```
Use manual mode when the browser cannot reach the local callback:
```bash
OPENCLAW_GOOGLE_MEET_CLIENT_ID="your-client-id" \
OPENCLAW_GOOGLE_MEET_CLIENT_SECRET="your-client-secret" \
openclaw googlemeet auth login --json --manual
```
The JSON output includes:
```json
{
"oauth": {
"clientId": "your-client-id",
"clientSecret": "your-client-secret",
"refreshToken": "refresh-token",
"accessToken": "access-token",
"expiresAt": 1770000000000
},
"scope": "..."
}
```
Store the `oauth` object under the Google Meet plugin config:
```json5
{
plugins: {
entries: {
"google-meet": {
enabled: true,
config: {
oauth: {
clientId: "your-client-id",
clientSecret: "your-client-secret",
refreshToken: "refresh-token",
},
},
},
},
},
}
```
Prefer environment variables when you do not want the refresh token in config.
If both config and environment values are present, the plugin resolves config
first and then environment fallback.
The OAuth consent includes Meet space creation, Meet space read access, and Meet
conference media read access. If you authenticated before meeting creation
support existed, rerun `openclaw googlemeet auth login --json` so the refresh
token has the `meetings.space.created` scope.
### Verify OAuth with doctor
Run the OAuth doctor when you want a fast, non-secret health check:
```bash
openclaw googlemeet doctor --oauth --json
```
This does not load the Chrome runtime or require a connected Chrome node. It
checks that OAuth config exists and that the refresh token can mint an access
token. The JSON report includes only status fields such as `ok`, `configured`,
`tokenSource`, `expiresAt`, and check messages; it does not print the access
token, refresh token, or client secret.
Common results:
| Check | Meaning |
| -------------------- | --------------------------------------------------------------------------------------- |
| `oauth-config` | `oauth.clientId` plus `oauth.refreshToken`, or a cached access token, is present. |
| `oauth-token` | The cached access token is still valid, or the refresh token minted a new access token. |
| `meet-spaces-get` | Optional `--meeting` check resolved an existing Meet space. |
| `meet-spaces-create` | Optional `--create-space` check created a new Meet space. |
To prove Google Meet API enablement and `spaces.create` scope as well, run the
side-effecting create check:
```bash
openclaw googlemeet doctor --oauth --create-space --json
openclaw googlemeet create --no-join --json
```
`--create-space` creates a throwaway Meet URL. Use it when you need to confirm
that the Google Cloud project has the Meet API enabled and that the authorized
account has the `meetings.space.created` scope.
To prove read access for an existing meeting space:
```bash
openclaw googlemeet doctor --oauth --meeting https://meet.google.com/abc-defg-hij --json
openclaw googlemeet resolve-space --meeting https://meet.google.com/abc-defg-hij
```
`doctor --oauth --meeting` and `resolve-space` prove read access to an existing
space that the authorized Google account can access. A `403` from these checks
usually means the Google Meet REST API is disabled, the consented refresh token
is missing the required scope, or the Google account cannot access that Meet
space. A refresh-token error means rerun `openclaw googlemeet auth login
--json` and store the new `oauth` block.
No OAuth credentials are needed for the browser fallback. In that mode, Google
auth comes from the signed-in Chrome profile on the selected node, not from
OpenClaw config.
@@ -967,7 +1115,10 @@ Also verify:
`googlemeet doctor [session-id]` prints the session, node, in-call state,
manual action reason, realtime provider connection, `realtimeReady`, audio
input/output activity, last audio timestamps, byte counters, and browser URL.
Use `googlemeet status [session-id]` when you need the raw JSON.
Use `googlemeet status [session-id]` when you need the raw JSON. Use
`googlemeet doctor --oauth` when you need to verify Google Meet OAuth refresh
without exposing tokens; add `--meeting` or `--create-space` when you need a
Google Meet API proof as well.
If an agent timed out and you can see a Meet tab already open, inspect that tab
without opening another one:

View File

@@ -966,6 +966,121 @@ describe("google-meet plugin", () => {
}
});
it("CLI doctor verifies Google Meet OAuth refresh without printing secrets", async () => {
const program = new Command();
const stdout = captureStdout();
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => {
return new Response(
JSON.stringify({
access_token: "new-access-token",
expires_in: 3600,
token_type: "Bearer",
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
});
vi.stubGlobal("fetch", fetchMock);
const ensureRuntime = vi.fn(async () => {
throw new Error("runtime should not be loaded for OAuth doctor");
});
registerGoogleMeetCli({
program,
config: resolveGoogleMeetConfig({
oauth: {
clientId: "client-id",
clientSecret: "client-secret",
refreshToken: "rt-secret",
},
}),
ensureRuntime: ensureRuntime as unknown as () => Promise<GoogleMeetRuntime>,
});
try {
await program.parseAsync(["googlemeet", "doctor", "--oauth", "--json"], { from: "user" });
const output = stdout.output();
expect(output).not.toContain("new-access-token");
expect(output).not.toContain("rt-secret");
expect(output).not.toContain("client-secret");
expect(JSON.parse(output)).toMatchObject({
ok: true,
configured: true,
tokenSource: "refresh-token",
checks: [
{ id: "oauth-config", ok: true },
{ id: "oauth-token", ok: true },
],
});
expect(ensureRuntime).not.toHaveBeenCalled();
const body = fetchMock.mock.calls[0]?.[1]?.body as URLSearchParams;
expect(body.get("grant_type")).toBe("refresh_token");
} finally {
stdout.restore();
}
});
it("CLI doctor can prove Google Meet API create access", async () => {
const program = new Command();
const stdout = captureStdout();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
if (url === "https://oauth2.googleapis.com/token") {
return new Response(
JSON.stringify({
access_token: "new-access-token",
expires_in: 3600,
token_type: "Bearer",
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
if (url === "https://meet.googleapis.com/v2/spaces") {
return new Response(
JSON.stringify({
name: "spaces/new-space",
meetingUri: "https://meet.google.com/new-abcd-xyz",
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
return new Response("not found", { status: 404 });
}),
);
registerGoogleMeetCli({
program,
config: resolveGoogleMeetConfig({
oauth: {
clientId: "client-id",
refreshToken: "refresh-token",
},
}),
ensureRuntime: async () => ({}) as GoogleMeetRuntime,
});
try {
await program.parseAsync(["googlemeet", "doctor", "--oauth", "--create-space", "--json"], {
from: "user",
});
expect(JSON.parse(stdout.output())).toMatchObject({
ok: true,
tokenSource: "refresh-token",
createdSpace: "spaces/new-space",
meetingUri: "https://meet.google.com/new-abcd-xyz",
checks: [
{ id: "oauth-config", ok: true },
{ id: "oauth-token", ok: true },
{ id: "meet-spaces-create", ok: true },
],
});
} finally {
stdout.restore();
}
});
it("CLI recover-tab focuses and summarizes an existing Meet tab", async () => {
const program = new Command();
const stdout = captureStdout();

View File

@@ -57,6 +57,18 @@ type SetupOptions = {
json?: boolean;
};
type DoctorOptions = {
json?: boolean;
oauth?: boolean;
meeting?: string;
createSpace?: boolean;
accessToken?: string;
refreshToken?: string;
clientId?: string;
clientSecret?: string;
expiresAt?: string;
};
type JsonOptions = {
json?: boolean;
};
@@ -173,6 +185,151 @@ function writeDoctorStatus(status: ReturnType<GoogleMeetRuntime["status"]>): voi
}
}
type OAuthDoctorCheck = {
id: string;
ok: boolean;
message: string;
};
type OAuthDoctorReport = {
ok: boolean;
configured: boolean;
tokenSource?: "cached-access-token" | "refresh-token";
expiresAt?: number;
scope?: string;
meetingUri?: string;
createdSpace?: string;
checks: OAuthDoctorCheck[];
};
function sanitizeOAuthErrorMessage(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
return message
.replace(/(access_token["'=:\s]+)[^"',\s&]+/gi, "$1[redacted]")
.replace(/(refresh_token["'=:\s]+)[^"',\s&]+/gi, "$1[redacted]")
.replace(/(client_secret["'=:\s]+)[^"',\s&]+/gi, "$1[redacted]");
}
async function buildOAuthDoctorReport(
config: GoogleMeetConfig,
options: DoctorOptions,
): Promise<OAuthDoctorReport> {
const clientId = options.clientId?.trim() || config.oauth.clientId;
const clientSecret = options.clientSecret?.trim() || config.oauth.clientSecret;
const refreshToken = options.refreshToken?.trim() || config.oauth.refreshToken;
const accessToken = options.accessToken?.trim() || config.oauth.accessToken;
const expiresAt = parseOptionalNumber(options.expiresAt) ?? config.oauth.expiresAt;
const checks: OAuthDoctorCheck[] = [];
const hasRefreshConfig = Boolean(clientId && refreshToken);
const hasAccessConfig = Boolean(accessToken);
if (!hasRefreshConfig && !hasAccessConfig) {
checks.push({
id: "oauth-config",
ok: false,
message:
"Missing Google Meet OAuth credentials. Configure oauth.clientId and oauth.refreshToken, or pass --client-id and --refresh-token.",
});
return { ok: false, configured: false, checks };
}
checks.push({
id: "oauth-config",
ok: true,
message: hasRefreshConfig
? "Google Meet OAuth refresh credentials are configured"
: "Google Meet cached access token is configured",
});
let token: Awaited<ReturnType<typeof resolveGoogleMeetAccessToken>>;
try {
token = await resolveGoogleMeetAccessToken({
clientId,
clientSecret,
refreshToken,
accessToken,
expiresAt,
});
checks.push({
id: "oauth-token",
ok: true,
message: token.refreshed
? "Refresh token minted an access token"
: "Cached access token is still valid",
});
} catch (error) {
checks.push({
id: "oauth-token",
ok: false,
message: sanitizeOAuthErrorMessage(error),
});
return { ok: false, configured: true, checks };
}
const report: OAuthDoctorReport = {
ok: true,
configured: true,
tokenSource: token.refreshed ? "refresh-token" : "cached-access-token",
expiresAt: token.expiresAt,
checks,
};
const meeting = options.meeting?.trim();
if (meeting) {
try {
const space = await fetchGoogleMeetSpace({ accessToken: token.accessToken, meeting });
checks.push({
id: "meet-spaces-get",
ok: true,
message: `Resolved ${space.name}`,
});
report.meetingUri = space.meetingUri;
} catch (error) {
checks.push({
id: "meet-spaces-get",
ok: false,
message: sanitizeOAuthErrorMessage(error),
});
}
}
if (options.createSpace) {
try {
const created = await createGoogleMeetSpace({ accessToken: token.accessToken });
checks.push({
id: "meet-spaces-create",
ok: true,
message: `Created ${created.space.name}`,
});
report.createdSpace = created.space.name;
report.meetingUri = created.meetingUri;
} catch (error) {
checks.push({
id: "meet-spaces-create",
ok: false,
message: sanitizeOAuthErrorMessage(error),
});
}
}
report.ok = checks.every((check) => check.ok);
return report;
}
function writeOAuthDoctorReport(report: OAuthDoctorReport): void {
writeStdoutLine("Google Meet OAuth: %s", report.ok ? "OK" : "needs attention");
writeStdoutLine("configured: %s", report.configured ? "yes" : "no");
if (report.tokenSource) {
writeStdoutLine("token source: %s", report.tokenSource);
}
if (report.meetingUri) {
writeStdoutLine("meeting uri: %s", report.meetingUri);
}
for (const check of report.checks) {
writeStdoutLine("[%s] %s: %s", check.ok ? "ok" : "fail", check.id, check.message);
}
}
function writeRecoverCurrentTabResult(
result: Awaited<ReturnType<GoogleMeetRuntime["recoverCurrentTab"]>>,
): void {
@@ -754,8 +911,25 @@ export function registerGoogleMeetCli(params: {
.command("doctor")
.description("Show human-readable Meet session/browser/realtime health")
.argument("[session-id]", "Meet session ID")
.option("--oauth", "Verify Google Meet OAuth token refresh without printing secrets", false)
.option("--meeting <value>", "Also verify spaces.get for a Meet URL, code, or spaces/{id}")
.option("--create-space", "Also verify spaces.create by creating a throwaway Meet space", false)
.option("--access-token <token>", "Access token override")
.option("--refresh-token <token>", "Refresh token override")
.option("--client-id <id>", "OAuth client id override")
.option("--client-secret <secret>", "OAuth client secret override")
.option("--expires-at <ms>", "Cached access token expiry as unix epoch milliseconds")
.option("--json", "Print JSON output", false)
.action(async (sessionId: string | undefined, options: JsonOptions) => {
.action(async (sessionId: string | undefined, options: DoctorOptions) => {
if (options.oauth) {
const report = await buildOAuthDoctorReport(params.config, options);
if (options.json) {
writeStdoutJson(report);
return;
}
writeOAuthDoctorReport(report);
return;
}
const rt = await params.ensureRuntime();
const status = rt.status(sessionId);
if (options.json) {