diff --git a/CHANGELOG.md b/CHANGELOG.md index 608a3c85d28..4e9cbc26410 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ Docs: https://docs.openclaw.ai - Voice Call/Twilio: honor stored pre-connect TwiML before realtime webhook shortcuts and reject DTMF sequences outside conversation mode, so Meet PIN entry cannot be skipped or silently dropped. Thanks @donkeykong91 and @PfanP. - Google Meet/Voice Call: play Twilio Meet DTMF before opening the realtime media stream and carry the intro as the initial Voice Call message, so the greeting is generated after Meet admits the phone participant instead of racing a live-call TwiML update. Thanks @donkeykong91 and @PfanP. -- Google Meet/Voice Call: make Twilio setup preflight honor explicit `--transport twilio` and fail local/private Voice Call webhook URLs before joins. Thanks @donkeykong91 and @PfanP. +- Google Meet/Voice Call: make Twilio setup preflight honor explicit `--transport twilio` and fail local/private Voice Call webhook URLs, including IPv6 loopback and unique-local forms, before joins. Thanks @donkeykong91 and @PfanP. - Voice Call/Twilio: retry transient 21220 live-call TwiML updates and catch answered-path initial-greeting failures, so a fast answered callback no longer crashes the Gateway or drops the Twilio greeting/listen transition. (#74606) Thanks @Sivan22. - Voice Call/Twilio: register accepted media streams immediately but wait for realtime transcription readiness before speaking the initial greeting, so reconnect grace handling stays live while OpenAI STT startup is no longer starved by TTS. Fixes #75197. (#75257) Thanks @donkeykong91 and @PfanP. - Voice Call CLI: delegate operational `voicecall` commands to the running Gateway runtime and skip webhook startup during CLI-only plugin loading, preventing webhook port conflicts and `setup --json` hangs. Fixes #72345. Thanks @serrurco and @DougButdorf. diff --git a/extensions/google-meet/index.create.test.ts b/extensions/google-meet/index.create.test.ts index 92e1c787858..727dbf51f68 100644 --- a/extensions/google-meet/index.create.test.ts +++ b/extensions/google-meet/index.create.test.ts @@ -36,9 +36,13 @@ const fetchGuardMocks = vi.hoisted(() => ({ ), })); -vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ - fetchWithSsrFGuard: fetchGuardMocks.fetchWithSsrFGuard, -})); +vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchWithSsrFGuard: fetchGuardMocks.fetchWithSsrFGuard, + }; +}); vi.mock("./src/voice-call-gateway.js", () => ({ joinMeetViaVoiceCallGateway: voiceCallMocks.joinMeetViaVoiceCallGateway, diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index 81f7b7732c6..876f151abdc 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -59,9 +59,13 @@ const fetchGuardMocks = vi.hoisted(() => ({ ), })); -vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ - fetchWithSsrFGuard: fetchGuardMocks.fetchWithSsrFGuard, -})); +vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchWithSsrFGuard: fetchGuardMocks.fetchWithSsrFGuard, + }; +}); vi.mock("./src/voice-call-gateway.js", () => ({ joinMeetViaVoiceCallGateway: voiceCallMocks.joinMeetViaVoiceCallGateway, @@ -1481,48 +1485,55 @@ describe("google-meet plugin", () => { ); }); - it("reports local voice-call publicUrl as unusable for Twilio transport", async () => { - vi.stubEnv("TWILIO_ACCOUNT_SID", "AC123"); - vi.stubEnv("TWILIO_AUTH_TOKEN", "secret"); - vi.stubEnv("TWILIO_FROM_NUMBER", "+15550001234"); - const { tools } = setup( - { defaultTransport: "twilio" }, - { - fullConfig: { - plugins: { - allow: ["google-meet", "voice-call"], - entries: { - "voice-call": { - enabled: true, - config: { - provider: "twilio", - publicUrl: "http://127.0.0.1:3334/voice/webhook", + it.each([ + "http://127.0.0.1:3334/voice/webhook", + "http://[::1]:3334/voice/webhook", + "http://[fd00::1]/voice/webhook", + ])( + "reports local voice-call publicUrl %s as unusable for Twilio transport", + async (publicUrl) => { + vi.stubEnv("TWILIO_ACCOUNT_SID", "AC123"); + vi.stubEnv("TWILIO_AUTH_TOKEN", "secret"); + vi.stubEnv("TWILIO_FROM_NUMBER", "+15550001234"); + const { tools } = setup( + { defaultTransport: "twilio" }, + { + fullConfig: { + plugins: { + allow: ["google-meet", "voice-call"], + entries: { + "voice-call": { + enabled: true, + config: { + provider: "twilio", + publicUrl, + }, }, }, }, }, }, - }, - ); - const tool = tools[0] as { - execute: ( - id: string, - params: unknown, - ) => Promise<{ details: { ok?: boolean; checks?: unknown[] } }>; - }; + ); + 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" }); + 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: "twilio-voice-call-webhook", - ok: false, - }), - ]), - ); - }); + expect(result.details.ok).toBe(false); + expect(result.details.checks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "twilio-voice-call-webhook", + ok: false, + }), + ]), + ); + }, + ); it("opens local Chrome Meet in observe-only mode without BlackHole checks", async () => { const originalPlatform = process.platform; diff --git a/extensions/google-meet/src/cli.test.ts b/extensions/google-meet/src/cli.test.ts index 815f4e03e52..b3ed642d261 100644 --- a/extensions/google-meet/src/cli.test.ts +++ b/extensions/google-meet/src/cli.test.ts @@ -22,9 +22,13 @@ const fetchGuardMocks = vi.hoisted(() => ({ ), })); -vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ - fetchWithSsrFGuard: fetchGuardMocks.fetchWithSsrFGuard, -})); +vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchWithSsrFGuard: fetchGuardMocks.fetchWithSsrFGuard, + }; +}); function captureStdout() { let output = ""; diff --git a/extensions/google-meet/src/setup.ts b/extensions/google-meet/src/setup.ts index 7b634fc5ae0..7ea95c83bc7 100644 --- a/extensions/google-meet/src/setup.ts +++ b/extensions/google-meet/src/setup.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime"; import type { GoogleMeetConfig, GoogleMeetMode, GoogleMeetTransport } from "./config.js"; export type SetupCheck = { @@ -24,31 +25,10 @@ function resolveUserPath(input: string): string { return input; } -function isLocalOnlyWebhookHost(hostname: string): boolean { - const host = hostname.trim().toLowerCase(); - if (!host) { - return false; - } - if ( - host === "localhost" || - host === "0.0.0.0" || - host === "::" || - host === "::1" || - host.startsWith("127.") - ) { - return true; - } - if (host.startsWith("10.") || host.startsWith("192.168.") || host.startsWith("169.254.")) { - return true; - } - const private172 = /^172\.(1[6-9]|2\d|3[0-1])\./.test(host); - return private172 || host.startsWith("fc") || host.startsWith("fd"); -} - function isProviderUnreachableWebhookUrl(webhookUrl: string): boolean { try { const parsed = new URL(webhookUrl); - return isLocalOnlyWebhookHost(parsed.hostname); + return isBlockedHostnameOrIp(parsed.hostname); } catch { return false; } diff --git a/extensions/voice-call/index.test.ts b/extensions/voice-call/index.test.ts index 8a83c328445..89f53220380 100644 --- a/extensions/voice-call/index.test.ts +++ b/extensions/voice-call/index.test.ts @@ -594,13 +594,17 @@ describe("voice-call plugin", () => { } }); - it("CLI setup rejects local public webhook URLs for Twilio", async () => { + it.each([ + "http://127.0.0.1:3334/voice/webhook", + "http://[::1]:3334/voice/webhook", + "http://[fd00::1]/voice/webhook", + ])("CLI setup rejects local public webhook URL %s for Twilio", async (publicUrl) => { const program = new Command(); const stdout = captureStdout(); await registerVoiceCallCli(program, { provider: "twilio", fromNumber: "+15550001234", - publicUrl: "http://127.0.0.1:3334/voice/webhook", + publicUrl, twilio: { accountSid: "AC123", authToken: "token", diff --git a/extensions/voice-call/src/runtime.test.ts b/extensions/voice-call/src/runtime.test.ts index 14f412344a5..71d3fc5db8f 100644 --- a/extensions/voice-call/src/runtime.test.ts +++ b/extensions/voice-call/src/runtime.test.ts @@ -216,12 +216,16 @@ describe("createVoiceCallRuntime lifecycle", () => { }, ); - it("fails closed when Twilio publicUrl points at a local-only webhook", async () => { + it.each([ + "http://127.0.0.1:3334/voice/webhook", + "http://[::1]:3334/voice/webhook", + "http://[fd00::1]/voice/webhook", + ])("fails closed when Twilio publicUrl %s points at a local-only webhook", async (publicUrl) => { await expect( createVoiceCallRuntime({ config: createExternalProviderConfig({ provider: "twilio", - publicUrl: "http://127.0.0.1:3334/voice/webhook", + publicUrl, }), coreConfig: {} as CoreConfig, agentRuntime: {} as never, diff --git a/extensions/voice-call/src/webhook-exposure.test.ts b/extensions/voice-call/src/webhook-exposure.test.ts new file mode 100644 index 00000000000..f96c76d4466 --- /dev/null +++ b/extensions/voice-call/src/webhook-exposure.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { isLocalOnlyWebhookHost, isProviderUnreachableWebhookUrl } from "./webhook-exposure.js"; + +describe("webhook exposure host classification", () => { + it.each([ + "http://[::]:3334/voice/webhook", + "http://[::1]:3334/voice/webhook", + "http://[fc00::1]/voice/webhook", + "http://[fd00::1]/voice/webhook", + "http://[::ffff:127.0.0.1]/voice/webhook", + "http://[::ffff:10.0.0.1]/voice/webhook", + "http://[::ffff:192.168.0.1]/voice/webhook", + "http://[::ffff:172.16.0.1]/voice/webhook", + "http://[fe80::1]/voice/webhook", + ])("treats local/private webhook URL %s as provider-unreachable", (url) => { + expect(isProviderUnreachableWebhookUrl(url)).toBe(true); + }); + + it.each([ + "http://[::ffff:8.8.8.8]/voice/webhook", + "https://voice.example.com/voice/webhook", + "https://fcloud.example/voice/webhook", + ])("does not reject public webhook URL %s", (url) => { + expect(isProviderUnreachableWebhookUrl(url)).toBe(false); + }); + + it.each(["[::1]", "[fc00::1]", "[fd00::1]", "::ffff:7f00:1", "::ffff:a00:1", "[fe80::1]"])( + "normalizes local/private URL hostnames like %s", + (host) => { + expect(isLocalOnlyWebhookHost(host)).toBe(true); + }, + ); +}); diff --git a/extensions/voice-call/src/webhook-exposure.ts b/extensions/voice-call/src/webhook-exposure.ts index 9eb5dd62712..30830cd292f 100644 --- a/extensions/voice-call/src/webhook-exposure.ts +++ b/extensions/voice-call/src/webhook-exposure.ts @@ -1,3 +1,5 @@ +import { isBlockedHostnameOrIp } from "../api.js"; + export type VoiceCallWebhookExposureConfig = { provider?: string; publicUrl?: string; @@ -20,24 +22,7 @@ export function providerRequiresPublicWebhook(providerName: string | undefined): } export function isLocalOnlyWebhookHost(hostname: string): boolean { - const host = hostname.trim().toLowerCase(); - if (!host) { - return false; - } - if ( - host === "localhost" || - host === "0.0.0.0" || - host === "::" || - host === "::1" || - host.startsWith("127.") - ) { - return true; - } - if (host.startsWith("10.") || host.startsWith("192.168.") || host.startsWith("169.254.")) { - return true; - } - const private172 = /^172\.(1[6-9]|2\d|3[0-1])\./.test(host); - return private172 || host.startsWith("fc") || host.startsWith("fd"); + return isBlockedHostnameOrIp(hostname); } export function isProviderUnreachableWebhookUrl(webhookUrl: string): boolean {