fix(voice-call): reject local webhook fallback

This commit is contained in:
Peter Steinberger
2026-04-25 05:41:27 +01:00
parent 2f39e6df59
commit 2b87d9f3ec
3 changed files with 103 additions and 0 deletions

View File

@@ -108,6 +108,7 @@ Docs: https://docs.openclaw.ai
- Providers/OpenRouter: add an OpenRouter TTS provider using the OpenAI-compatible `/audio/speech` endpoint and `OPENROUTER_API_KEY`. Fixes #71268.
- macOS Talk Mode: retry failed local ElevenLabs stream playback through gateway `talk.speak` before falling back to the system voice, so configured ElevenLabs voices still play when streaming playback fails. Fixes #65662.
- Plugins/Voice Call: reap stale pre-answer calls by default, honor configured TTS timeouts for Twilio media-stream playback, and fail empty telephony audio instead of completing as silence. Fixes #42071; supersedes #60957. Thanks @Ryce and @sliekens.
- Plugins/Voice Call: fail fast when Twilio, Telnyx, or Plivo would fall back to a loopback/private webhook URL, so calls do not start with an unreachable callback endpoint. Thanks @artemgetmann.
- Plugins/Voice Call: resolve queued-but-not-yet-playing Twilio TTS entries when barge-in or stream teardown clears the playback queue, so callers awaiting `queueTts()` do not hang. Thanks @kevinWangSheng.
- Plugins/Voice Call: terminate expired restored call sessions with the provider and restart restored max-duration timers with only the remaining duration, preventing stale outbound retry loops after Gateway restarts. Fixes #48739. Thanks @mira-solari.
- Plugins/Voice Call: start provider STT after Telnyx outbound conversation greetings and pass configured Telnyx voice IDs through to the speak action. Fixes #56091. Thanks @Roshan.

View File

@@ -78,6 +78,33 @@ function createBaseConfig(): VoiceCallConfig {
return createVoiceCallBaseConfig({ tunnelProvider: "ngrok" });
}
function createExternalProviderConfig(params: {
provider: "twilio" | "telnyx" | "plivo";
publicUrl?: string;
}): VoiceCallConfig {
const config = createVoiceCallBaseConfig({
provider: params.provider,
tunnelProvider: "none",
});
config.twilio = {
accountSid: "AC123",
authToken: "secret",
};
config.telnyx = {
apiKey: "key",
connectionId: "conn",
publicKey: "pub",
};
config.plivo = {
authId: "MA123",
authToken: "secret",
};
if (params.publicUrl) {
config.publicUrl = params.publicUrl;
}
return config;
}
describe("createVoiceCallRuntime lifecycle", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -170,6 +197,36 @@ describe("createVoiceCallRuntime lifecycle", () => {
expect(mocks.webhookCtorArgs[0]?.[4]).toBe(fullConfig);
});
it.each(["twilio", "telnyx", "plivo"] as const)(
"fails closed when %s falls back to a local-only webhook",
async (provider) => {
await expect(
createVoiceCallRuntime({
config: createExternalProviderConfig({ provider }),
coreConfig: {} as CoreConfig,
agentRuntime: {} as never,
}),
).rejects.toThrow(`${provider} requires a publicly reachable webhook URL`);
expect(mocks.webhookStop).toHaveBeenCalledTimes(1);
},
);
it("accepts an explicit public URL for external voice providers", async () => {
const runtime = await createVoiceCallRuntime({
config: createExternalProviderConfig({
provider: "twilio",
publicUrl: "https://voice.example.com/voice/webhook",
}),
coreConfig: {} as CoreConfig,
agentRuntime: {} as never,
});
expect(runtime.webhookUrl).toBe("https://voice.example.com/voice/webhook");
expect(runtime.publicUrl).toBe("https://voice.example.com/voice/webhook");
await runtime.stop();
});
it("wires the shared realtime agent consult tool and handler", async () => {
const config = createBaseConfig();
config.inboundPolicy = "allowlist";

View File

@@ -158,6 +158,40 @@ function isLoopbackBind(bind: string | undefined): boolean {
return bind === "127.0.0.1" || bind === "::1" || bind === "localhost";
}
function providerRequiresPublicWebhook(providerName: VoiceCallProvider["name"]): boolean {
return providerName === "twilio" || providerName === "telnyx" || providerName === "plivo";
}
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);
} catch {
return false;
}
}
async function resolveProvider(config: VoiceCallConfig): Promise<VoiceCallProvider> {
const allowNgrokFreeTierLoopbackBypass =
config.tunnel?.provider === "ngrok" &&
@@ -376,6 +410,17 @@ export async function createVoiceCallRuntime(params: {
const webhookUrl = publicUrl ?? localUrl;
if (
providerRequiresPublicWebhook(provider.name) &&
isProviderUnreachableWebhookUrl(webhookUrl)
) {
throw new Error(
`[voice-call] ${provider.name} requires a publicly reachable webhook URL. ` +
`Refusing to use local-only webhook ${webhookUrl}. ` +
"Set plugins.entries.voice-call.config.publicUrl or enable tunnel/tailscale exposure.",
);
}
if (publicUrl && provider.name === "twilio") {
(provider as TwilioProvider).setPublicUrl(publicUrl);
}