fix: tighten meet voice-call setup checks

This commit is contained in:
Peter Steinberger
2026-05-01 06:40:17 +01:00
parent 464e573602
commit b2aac178d6
9 changed files with 297 additions and 64 deletions

View File

@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- 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.
- 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.
- Agents/pi-embedded-runner: extract the `abortable` provider-call wrapper from `runEmbeddedAttempt` to module scope so its promise handlers no longer close over the run lexical context, releasing transcripts, tool buffers, and subscription callbacks when a provider call hangs past abort. (#74182) Thanks @cjboy007.

View File

@@ -85,11 +85,12 @@ openclaw googlemeet setup --transport chrome-node --mode transcribe
```
When Twilio delegation is configured, setup also reports whether the
`voice-call` plugin and Twilio credentials are ready. Treat any `ok: false`
check as a blocker for the checked transport and mode before asking an agent to
join. Use `openclaw googlemeet setup --json` for scripts or machine-readable
output. Use `--transport chrome`, `--transport chrome-node`, or `--transport twilio`
to preflight a specific transport before an agent tries it.
`voice-call` plugin, Twilio credentials, and public webhook exposure are ready.
Treat any `ok: false` check as a blocker for the checked transport and mode
before asking an agent to join. Use `openclaw googlemeet setup --json` for
scripts or machine-readable output. Use `--transport chrome`,
`--transport chrome-node`, or `--transport twilio` to preflight a specific
transport before an agent tries it.
Join a meeting:
@@ -439,7 +440,8 @@ openclaw googlemeet setup
```
When Twilio delegation is wired, `googlemeet setup` includes successful
`twilio-voice-call-plugin` and `twilio-voice-call-credentials` checks.
`twilio-voice-call-plugin`, `twilio-voice-call-credentials`, and
`twilio-voice-call-webhook` checks.
```bash
openclaw googlemeet join https://meet.google.com/abc-defg-hij \
@@ -1115,8 +1117,8 @@ openclaw googlemeet join https://meet.google.com/abc-defg-hij \
Expected Twilio state:
- `googlemeet setup` includes green `twilio-voice-call-plugin` and
`twilio-voice-call-credentials` checks.
- `googlemeet setup` includes green `twilio-voice-call-plugin`,
`twilio-voice-call-credentials`, and `twilio-voice-call-webhook` checks.
- `voicecall` is available in the CLI after Gateway reload.
- The returned session has `transport: "twilio"` and a `twilio.voiceCallId`.
- `googlemeet leave <sessionId>` hangs up the delegated voice call.
@@ -1303,6 +1305,11 @@ export TWILIO_AUTH_TOKEN=...
export TWILIO_FROM_NUMBER=+15550001234
```
`twilio-voice-call-webhook` fails when `voice-call` has no public webhook
exposure, or when `publicUrl` points at loopback or private network space.
Set `plugins.entries.voice-call.config.publicUrl` to the public provider URL or
configure a `voice-call` tunnel/Tailscale exposure.
Then restart or reload the Gateway and run:
```bash

View File

@@ -1363,7 +1363,10 @@ describe("google-meet plugin", () => {
entries: {
"voice-call": {
enabled: true,
config: { provider: "twilio" },
config: {
provider: "twilio",
publicUrl: "https://voice.example.com/voice/webhook",
},
},
},
},
@@ -1390,16 +1393,20 @@ describe("google-meet plugin", () => {
id: "twilio-voice-call-credentials",
ok: true,
}),
expect.objectContaining({
id: "twilio-voice-call-webhook",
ok: true,
}),
]),
);
});
it("reports missing voice-call wiring for Twilio transport", async () => {
it("reports missing voice-call wiring for explicit Twilio transport", async () => {
vi.stubEnv("TWILIO_ACCOUNT_SID", "");
vi.stubEnv("TWILIO_AUTH_TOKEN", "");
vi.stubEnv("TWILIO_FROM_NUMBER", "");
const { tools } = setup(
{ defaultTransport: "twilio" },
{ defaultTransport: "chrome" },
{
fullConfig: {
plugins: {
@@ -1418,7 +1425,7 @@ describe("google-meet plugin", () => {
) => Promise<{ details: { ok?: boolean; checks?: unknown[] } }>;
};
const result = await tool.execute("id", { action: "setup_status" });
const result = await tool.execute("id", { action: "setup_status", transport: "twilio" });
expect(result.details.ok).toBe(false);
expect(result.details.checks).toEqual(
@@ -1435,6 +1442,49 @@ 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",
},
},
},
},
},
},
);
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" });
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;
Object.defineProperty(process, "platform", { value: "darwin" });

View File

@@ -24,6 +24,78 @@ 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);
} catch {
return false;
}
}
function getVoiceCallWebhookExposureCheck(voiceCallConfig: Record<string, unknown>): SetupCheck {
const publicUrl = normalizeOptionalString(voiceCallConfig.publicUrl);
const tunnel = asRecord(voiceCallConfig.tunnel);
const tailscale = asRecord(voiceCallConfig.tailscale);
const tunnelProvider = normalizeOptionalString(tunnel.provider);
const tailscaleMode = normalizeOptionalString(tailscale.mode);
if (publicUrl) {
const ok = !isProviderUnreachableWebhookUrl(publicUrl);
return {
id: "twilio-voice-call-webhook",
ok,
message: ok
? `Voice-call public webhook URL configured: ${publicUrl}`
: `Voice-call publicUrl is local/private and cannot be reached by Twilio: ${publicUrl}`,
};
}
if (tunnelProvider && tunnelProvider !== "none") {
return {
id: "twilio-voice-call-webhook",
ok: true,
message: "Voice-call webhook exposure configured through tunnel",
};
}
if (tailscaleMode && tailscaleMode !== "off") {
return {
id: "twilio-voice-call-webhook",
ok: true,
message: "Voice-call webhook exposure configured through Tailscale",
};
}
return {
id: "twilio-voice-call-webhook",
ok: false,
message:
"Set plugins.entries.voice-call.config.publicUrl or configure voice-call tunnel/tailscale exposure for Twilio dialing",
};
}
export function getGoogleMeetSetupStatus(config: GoogleMeetConfig): {
ok: boolean;
checks: SetupCheck[];
@@ -143,7 +215,7 @@ export function getGoogleMeetSetupStatus(
const shouldCheckTwilioDelegation =
config.voiceCall.enabled &&
(config.defaultTransport === "twilio" ||
(transport === "twilio" ||
Boolean(config.twilio.defaultDialInNumber) ||
Object.hasOwn(pluginEntries, "voice-call"));
if (shouldCheckTwilioDelegation) {
@@ -175,6 +247,7 @@ export function getGoogleMeetSetupStatus(
? "Twilio voice-call credentials are configured"
: "Set TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, and TWILIO_FROM_NUMBER or configure voice-call Twilio credentials",
});
checks.push(getVoiceCallWebhookExposureCheck(voiceCallConfig));
}
}

View File

@@ -564,6 +564,38 @@ describe("voice-call plugin", () => {
}
});
it("CLI setup rejects local public webhook URLs for Twilio", async () => {
const program = new Command();
const stdout = captureStdout();
await registerVoiceCallCli(program, {
provider: "twilio",
fromNumber: "+15550001234",
publicUrl: "http://127.0.0.1:3334/voice/webhook",
twilio: {
accountSid: "AC123",
authToken: "token",
},
});
try {
await program.parseAsync(["voicecall", "setup", "--json"], { from: "user" });
const parsed = JSON.parse(stdout.output()) as {
ok?: boolean;
checks?: Array<{ id: string; ok: boolean; message: string }>;
};
expect(parsed.ok).toBe(false);
expect(parsed.checks).toContainEqual(
expect.objectContaining({
id: "webhook-exposure",
ok: false,
message: expect.stringContaining("local/private"),
}),
);
} finally {
stdout.restore();
}
});
it("CLI status lists active calls without a call id", async () => {
const program = new Command();
const stdout = captureStdout();

View File

@@ -10,6 +10,7 @@ import { sleep } from "../api.js";
import { validateProviderConfig, type VoiceCallConfig } from "./config.js";
import type { VoiceCallRuntime } from "./runtime.js";
import { resolveUserPath } from "./utils.js";
import { resolveWebhookExposureStatus } from "./webhook-exposure.js";
import {
cleanupTailscaleExposureRoute,
getTailscaleSelfInfo,
@@ -166,16 +167,9 @@ function resolveCallMode(mode?: string): "notify" | "conversation" | undefined {
return mode === "notify" || mode === "conversation" ? mode : undefined;
}
function hasPublicExposure(config: VoiceCallConfig): boolean {
return Boolean(
config.publicUrl ||
(config.tunnel?.provider && config.tunnel.provider !== "none") ||
(config.tailscale?.mode && config.tailscale.mode !== "off"),
);
}
function buildSetupStatus(config: VoiceCallConfig): SetupStatus {
const validation = validateProviderConfig(config);
const webhookExposure = resolveWebhookExposureStatus(config);
const checks: SetupCheck[] = [
{
id: "plugin-enabled",
@@ -200,15 +194,8 @@ function buildSetupStatus(config: VoiceCallConfig): SetupStatus {
},
{
id: "webhook-exposure",
ok: config.provider === "mock" || hasPublicExposure(config),
message:
config.provider === "mock"
? "Mock provider does not need a public webhook"
: hasPublicExposure(config)
? config.publicUrl
? `Public webhook URL configured: ${config.publicUrl}`
: "Webhook exposure configured through tunnel or Tailscale"
: "Set publicUrl or configure tunnel/tailscale so the provider can reach webhooks",
ok: webhookExposure.ok,
message: webhookExposure.message,
},
{
id: "mode",

View File

@@ -211,6 +211,20 @@ describe("createVoiceCallRuntime lifecycle", () => {
},
);
it("fails closed when Twilio publicUrl points at a local-only webhook", async () => {
await expect(
createVoiceCallRuntime({
config: createExternalProviderConfig({
provider: "twilio",
publicUrl: "http://127.0.0.1:3334/voice/webhook",
}),
coreConfig: {} as CoreConfig,
agentRuntime: {} as never,
}),
).rejects.toThrow("twilio 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({

View File

@@ -18,6 +18,10 @@ import { resolveVoiceResponseModel } from "./response-model.js";
import type { TelephonyTtsRuntime } from "./telephony-tts.js";
import { createTelephonyTtsProvider } from "./telephony-tts.js";
import { startTunnel, type TunnelResult } from "./tunnel.js";
import {
isProviderUnreachableWebhookUrl,
providerRequiresPublicWebhook,
} from "./webhook-exposure.js";
import { VoiceCallWebhookServer } from "./webhook.js";
import { cleanupTailscaleExposure, setupTailscaleExposure } from "./webhook/tailscale.js";
@@ -166,40 +170,6 @@ 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" &&

View File

@@ -0,0 +1,99 @@
export type VoiceCallWebhookExposureConfig = {
provider?: string;
publicUrl?: string;
tunnel?: {
provider?: string;
};
tailscale?: {
mode?: string;
};
};
export type VoiceCallWebhookExposureStatus = {
ok: boolean;
configured: boolean;
message: string;
};
export function providerRequiresPublicWebhook(providerName: string | undefined): boolean {
return providerName === "twilio" || providerName === "telnyx" || providerName === "plivo";
}
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");
}
export function isProviderUnreachableWebhookUrl(webhookUrl: string): boolean {
try {
const parsed = new URL(webhookUrl);
return isLocalOnlyWebhookHost(parsed.hostname);
} catch {
return false;
}
}
export function resolveWebhookExposureStatus(
config: VoiceCallWebhookExposureConfig,
): VoiceCallWebhookExposureStatus {
if (config.provider === "mock") {
return {
ok: true,
configured: true,
message: "Mock provider does not need a public webhook",
};
}
if (config.publicUrl) {
if (isProviderUnreachableWebhookUrl(config.publicUrl)) {
return {
ok: false,
configured: true,
message: `Public webhook URL is local/private and cannot be reached by ${config.provider ?? "the provider"}: ${config.publicUrl}`,
};
}
return {
ok: true,
configured: true,
message: `Public webhook URL configured: ${config.publicUrl}`,
};
}
if (config.tunnel?.provider && config.tunnel.provider !== "none") {
return {
ok: true,
configured: true,
message: "Webhook exposure configured through tunnel",
};
}
if (config.tailscale?.mode && config.tailscale.mode !== "off") {
return {
ok: true,
configured: true,
message: "Webhook exposure configured through Tailscale",
};
}
return {
ok: false,
configured: false,
message: "Set publicUrl or configure tunnel/tailscale so the provider can reach webhooks",
};
}