diff --git a/extensions/google-meet/node-host.test.ts b/extensions/google-meet/node-host.test.ts index c9fdf06a7b33..35f9029632cc 100644 --- a/extensions/google-meet/node-host.test.ts +++ b/extensions/google-meet/node-host.test.ts @@ -63,6 +63,33 @@ describe("google-meet node host bridge sessions", () => { ); }); + it("rejects non-Meet start URLs before local Chrome side effects", async () => { + const originalPlatform = process.platform; + children.length = 0; + vi.mocked(spawnSync).mockClear(); + + Object.defineProperty(process, "platform", { configurable: true, value: "darwin" }); + try { + await expect( + handleGoogleMeetNodeHostCommand( + JSON.stringify({ + action: "start", + url: "https://example.com/private-call", + mode: "realtime", + launch: true, + audioInputCommand: ["mock-rec"], + audioOutputCommand: ["mock-play"], + }), + ), + ).rejects.toThrow("url must be an explicit https://meet.google.com/... URL"); + + expect(spawnSync).not.toHaveBeenCalled(); + expect(children).toHaveLength(0); + } finally { + Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform }); + } + }); + it("starts observe-only Chrome without BlackHole or bridge processes", async () => { const originalPlatform = process.platform; children.length = 0; diff --git a/extensions/google-meet/src/meet-url.ts b/extensions/google-meet/src/meet-url.ts new file mode 100644 index 000000000000..12878117b825 --- /dev/null +++ b/extensions/google-meet/src/meet-url.ts @@ -0,0 +1,29 @@ +// Google Meet plugin module implements shared Meet URL contracts. + +function normalizeOptionalString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed || undefined; +} + +export function normalizeMeetUrl(input: unknown): string { + const raw = normalizeOptionalString(input); + if (!raw) { + throw new Error("url required"); + } + let url: URL; + try { + url = new URL(raw); + } catch { + throw new Error("url must be a valid Google Meet URL"); + } + if (url.protocol !== "https:" || url.hostname.toLowerCase() !== "meet.google.com") { + throw new Error("url must be an explicit https://meet.google.com/... URL"); + } + if (!/^\/[a-z]{3}-[a-z]{4}-[a-z]{3}(?:$|[/?#])/i.test(url.pathname)) { + throw new Error("url must include a Google Meet meeting code"); + } + return url.toString(); +} diff --git a/extensions/google-meet/src/node-host.ts b/extensions/google-meet/src/node-host.ts index 2cdb84163269..cc92e7a0107e 100644 --- a/extensions/google-meet/src/node-host.ts +++ b/extensions/google-meet/src/node-host.ts @@ -6,6 +6,7 @@ import { DEFAULT_GOOGLE_MEET_AUDIO_INPUT_COMMAND, DEFAULT_GOOGLE_MEET_AUDIO_OUTPUT_COMMAND, } from "./config.js"; +import { normalizeMeetUrl } from "./meet-url.js"; import { GOOGLE_MEET_SYSTEM_PROFILER_COMMAND, outputMentionsBlackHole2ch, @@ -278,10 +279,7 @@ function clearAudio(params: Record) { } function startChrome(params: Record) { - const url = readString(params.url); - if (!url) { - throw new Error("url required"); - } + const url = normalizeMeetUrl(params.url); const timeoutMs = readNumber(params.joinTimeoutMs, 30_000); const mode = readString(params.mode); diff --git a/extensions/google-meet/src/node-invoke-policy.ts b/extensions/google-meet/src/node-invoke-policy.ts index 854afe118804..d9428daaa0cf 100644 --- a/extensions/google-meet/src/node-invoke-policy.ts +++ b/extensions/google-meet/src/node-invoke-policy.ts @@ -4,7 +4,7 @@ import type { OpenClawPluginNodeInvokePolicyResult, } from "openclaw/plugin-sdk/plugin-entry"; import type { GoogleMeetConfig } from "./config.js"; -import { normalizeMeetUrl } from "./runtime.js"; +import { normalizeMeetUrl } from "./meet-url.js"; export const GOOGLE_MEET_CHROME_NODE_COMMAND = "googlemeet.chrome"; diff --git a/extensions/google-meet/src/runtime.ts b/extensions/google-meet/src/runtime.ts index 2617196ebd9e..e24e19e132d8 100644 --- a/extensions/google-meet/src/runtime.ts +++ b/extensions/google-meet/src/runtime.ts @@ -12,6 +12,7 @@ import type { GoogleMeetModeInput, GoogleMeetTransport, } from "./config.js"; +import { normalizeMeetUrl } from "./meet-url.js"; import { addGoogleMeetSetupCheck, getGoogleMeetSetupStatus } from "./setup.js"; import { isSameMeetUrlForReuse, @@ -50,6 +51,8 @@ import { type VoiceCallGateway, } from "./voice-call-gateway.js"; +export { normalizeMeetUrl } from "./meet-url.js"; + type ChromeAudioBridgeResult = NonNullable< | Awaited>["audioBridge"] | Awaited>["audioBridge"] @@ -78,26 +81,6 @@ function buildTwilioVoiceCallSessionKey(meetingSessionId: string): string { return `voice:google-meet:${meetingSessionId}`; } -export function normalizeMeetUrl(input: unknown): string { - const raw = normalizeOptionalString(input); - if (!raw) { - throw new Error("url required"); - } - let url: URL; - try { - url = new URL(raw); - } catch { - throw new Error("url must be a valid Google Meet URL"); - } - if (url.protocol !== "https:" || url.hostname.toLowerCase() !== "meet.google.com") { - throw new Error("url must be an explicit https://meet.google.com/... URL"); - } - if (!/^\/[a-z]{3}-[a-z]{4}-[a-z]{3}(?:$|[/?#])/i.test(url.pathname)) { - throw new Error("url must include a Google Meet meeting code"); - } - return url.toString(); -} - function resolveTransport(input: GoogleMeetTransport | undefined, config: GoogleMeetConfig) { return input ?? config.defaultTransport; }