Skip to content

Commit b2aac17

Browse files
committed
fix: tighten meet voice-call setup checks
1 parent 464e573 commit b2aac17

9 files changed

Lines changed: 297 additions & 64 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
1313

1414
### Fixes
1515

16+
- 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.
1617
- 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.
1718
- 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.
1819
- 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.

docs/plugins/google-meet.md

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,12 @@ openclaw googlemeet setup --transport chrome-node --mode transcribe
8585
```
8686

8787
When Twilio delegation is configured, setup also reports whether the
88-
`voice-call` plugin and Twilio credentials are ready. Treat any `ok: false`
89-
check as a blocker for the checked transport and mode before asking an agent to
90-
join. Use `openclaw googlemeet setup --json` for scripts or machine-readable
91-
output. Use `--transport chrome`, `--transport chrome-node`, or `--transport twilio`
92-
to preflight a specific transport before an agent tries it.
88+
`voice-call` plugin, Twilio credentials, and public webhook exposure are ready.
89+
Treat any `ok: false` check as a blocker for the checked transport and mode
90+
before asking an agent to join. Use `openclaw googlemeet setup --json` for
91+
scripts or machine-readable output. Use `--transport chrome`,
92+
`--transport chrome-node`, or `--transport twilio` to preflight a specific
93+
transport before an agent tries it.
9394

9495
Join a meeting:
9596

@@ -439,7 +440,8 @@ openclaw googlemeet setup
439440
```
440441

441442
When Twilio delegation is wired, `googlemeet setup` includes successful
442-
`twilio-voice-call-plugin` and `twilio-voice-call-credentials` checks.
443+
`twilio-voice-call-plugin`, `twilio-voice-call-credentials`, and
444+
`twilio-voice-call-webhook` checks.
443445

444446
```bash
445447
openclaw googlemeet join https://meet.google.com/abc-defg-hij \
@@ -1115,8 +1117,8 @@ openclaw googlemeet join https://meet.google.com/abc-defg-hij \
11151117

11161118
Expected Twilio state:
11171119

1118-
- `googlemeet setup` includes green `twilio-voice-call-plugin` and
1119-
`twilio-voice-call-credentials` checks.
1120+
- `googlemeet setup` includes green `twilio-voice-call-plugin`,
1121+
`twilio-voice-call-credentials`, and `twilio-voice-call-webhook` checks.
11201122
- `voicecall` is available in the CLI after Gateway reload.
11211123
- The returned session has `transport: "twilio"` and a `twilio.voiceCallId`.
11221124
- `googlemeet leave <sessionId>` hangs up the delegated voice call.
@@ -1303,6 +1305,11 @@ export TWILIO_AUTH_TOKEN=...
13031305
export TWILIO_FROM_NUMBER=+15550001234
13041306
```
13051307

1308+
`twilio-voice-call-webhook` fails when `voice-call` has no public webhook
1309+
exposure, or when `publicUrl` points at loopback or private network space.
1310+
Set `plugins.entries.voice-call.config.publicUrl` to the public provider URL or
1311+
configure a `voice-call` tunnel/Tailscale exposure.
1312+
13061313
Then restart or reload the Gateway and run:
13071314

13081315
```bash

extensions/google-meet/index.test.ts

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1363,7 +1363,10 @@ describe("google-meet plugin", () => {
13631363
entries: {
13641364
"voice-call": {
13651365
enabled: true,
1366-
config: { provider: "twilio" },
1366+
config: {
1367+
provider: "twilio",
1368+
publicUrl: "https://voice.example.com/voice/webhook",
1369+
},
13671370
},
13681371
},
13691372
},
@@ -1390,16 +1393,20 @@ describe("google-meet plugin", () => {
13901393
id: "twilio-voice-call-credentials",
13911394
ok: true,
13921395
}),
1396+
expect.objectContaining({
1397+
id: "twilio-voice-call-webhook",
1398+
ok: true,
1399+
}),
13931400
]),
13941401
);
13951402
});
13961403

1397-
it("reports missing voice-call wiring for Twilio transport", async () => {
1404+
it("reports missing voice-call wiring for explicit Twilio transport", async () => {
13981405
vi.stubEnv("TWILIO_ACCOUNT_SID", "");
13991406
vi.stubEnv("TWILIO_AUTH_TOKEN", "");
14001407
vi.stubEnv("TWILIO_FROM_NUMBER", "");
14011408
const { tools } = setup(
1402-
{ defaultTransport: "twilio" },
1409+
{ defaultTransport: "chrome" },
14031410
{
14041411
fullConfig: {
14051412
plugins: {
@@ -1418,7 +1425,7 @@ describe("google-meet plugin", () => {
14181425
) => Promise<{ details: { ok?: boolean; checks?: unknown[] } }>;
14191426
};
14201427

1421-
const result = await tool.execute("id", { action: "setup_status" });
1428+
const result = await tool.execute("id", { action: "setup_status", transport: "twilio" });
14221429

14231430
expect(result.details.ok).toBe(false);
14241431
expect(result.details.checks).toEqual(
@@ -1435,6 +1442,49 @@ describe("google-meet plugin", () => {
14351442
);
14361443
});
14371444

1445+
it("reports local voice-call publicUrl as unusable for Twilio transport", async () => {
1446+
vi.stubEnv("TWILIO_ACCOUNT_SID", "AC123");
1447+
vi.stubEnv("TWILIO_AUTH_TOKEN", "secret");
1448+
vi.stubEnv("TWILIO_FROM_NUMBER", "+15550001234");
1449+
const { tools } = setup(
1450+
{ defaultTransport: "twilio" },
1451+
{
1452+
fullConfig: {
1453+
plugins: {
1454+
allow: ["google-meet", "voice-call"],
1455+
entries: {
1456+
"voice-call": {
1457+
enabled: true,
1458+
config: {
1459+
provider: "twilio",
1460+
publicUrl: "http://127.0.0.1:3334/voice/webhook",
1461+
},
1462+
},
1463+
},
1464+
},
1465+
},
1466+
},
1467+
);
1468+
const tool = tools[0] as {
1469+
execute: (
1470+
id: string,
1471+
params: unknown,
1472+
) => Promise<{ details: { ok?: boolean; checks?: unknown[] } }>;
1473+
};
1474+
1475+
const result = await tool.execute("id", { action: "setup_status" });
1476+
1477+
expect(result.details.ok).toBe(false);
1478+
expect(result.details.checks).toEqual(
1479+
expect.arrayContaining([
1480+
expect.objectContaining({
1481+
id: "twilio-voice-call-webhook",
1482+
ok: false,
1483+
}),
1484+
]),
1485+
);
1486+
});
1487+
14381488
it("opens local Chrome Meet in observe-only mode without BlackHole checks", async () => {
14391489
const originalPlatform = process.platform;
14401490
Object.defineProperty(process, "platform", { value: "darwin" });

extensions/google-meet/src/setup.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,78 @@ function resolveUserPath(input: string): string {
2424
return input;
2525
}
2626

27+
function isLocalOnlyWebhookHost(hostname: string): boolean {
28+
const host = hostname.trim().toLowerCase();
29+
if (!host) {
30+
return false;
31+
}
32+
if (
33+
host === "localhost" ||
34+
host === "0.0.0.0" ||
35+
host === "::" ||
36+
host === "::1" ||
37+
host.startsWith("127.")
38+
) {
39+
return true;
40+
}
41+
if (host.startsWith("10.") || host.startsWith("192.168.") || host.startsWith("169.254.")) {
42+
return true;
43+
}
44+
const private172 = /^172\.(1[6-9]|2\d|3[0-1])\./.test(host);
45+
return private172 || host.startsWith("fc") || host.startsWith("fd");
46+
}
47+
48+
function isProviderUnreachableWebhookUrl(webhookUrl: string): boolean {
49+
try {
50+
const parsed = new URL(webhookUrl);
51+
return isLocalOnlyWebhookHost(parsed.hostname);
52+
} catch {
53+
return false;
54+
}
55+
}
56+
57+
function getVoiceCallWebhookExposureCheck(voiceCallConfig: Record<string, unknown>): SetupCheck {
58+
const publicUrl = normalizeOptionalString(voiceCallConfig.publicUrl);
59+
const tunnel = asRecord(voiceCallConfig.tunnel);
60+
const tailscale = asRecord(voiceCallConfig.tailscale);
61+
const tunnelProvider = normalizeOptionalString(tunnel.provider);
62+
const tailscaleMode = normalizeOptionalString(tailscale.mode);
63+
64+
if (publicUrl) {
65+
const ok = !isProviderUnreachableWebhookUrl(publicUrl);
66+
return {
67+
id: "twilio-voice-call-webhook",
68+
ok,
69+
message: ok
70+
? `Voice-call public webhook URL configured: ${publicUrl}`
71+
: `Voice-call publicUrl is local/private and cannot be reached by Twilio: ${publicUrl}`,
72+
};
73+
}
74+
75+
if (tunnelProvider && tunnelProvider !== "none") {
76+
return {
77+
id: "twilio-voice-call-webhook",
78+
ok: true,
79+
message: "Voice-call webhook exposure configured through tunnel",
80+
};
81+
}
82+
83+
if (tailscaleMode && tailscaleMode !== "off") {
84+
return {
85+
id: "twilio-voice-call-webhook",
86+
ok: true,
87+
message: "Voice-call webhook exposure configured through Tailscale",
88+
};
89+
}
90+
91+
return {
92+
id: "twilio-voice-call-webhook",
93+
ok: false,
94+
message:
95+
"Set plugins.entries.voice-call.config.publicUrl or configure voice-call tunnel/tailscale exposure for Twilio dialing",
96+
};
97+
}
98+
2799
export function getGoogleMeetSetupStatus(config: GoogleMeetConfig): {
28100
ok: boolean;
29101
checks: SetupCheck[];
@@ -143,7 +215,7 @@ export function getGoogleMeetSetupStatus(
143215

144216
const shouldCheckTwilioDelegation =
145217
config.voiceCall.enabled &&
146-
(config.defaultTransport === "twilio" ||
218+
(transport === "twilio" ||
147219
Boolean(config.twilio.defaultDialInNumber) ||
148220
Object.hasOwn(pluginEntries, "voice-call"));
149221
if (shouldCheckTwilioDelegation) {
@@ -175,6 +247,7 @@ export function getGoogleMeetSetupStatus(
175247
? "Twilio voice-call credentials are configured"
176248
: "Set TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, and TWILIO_FROM_NUMBER or configure voice-call Twilio credentials",
177249
});
250+
checks.push(getVoiceCallWebhookExposureCheck(voiceCallConfig));
178251
}
179252
}
180253

extensions/voice-call/index.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,38 @@ describe("voice-call plugin", () => {
564564
}
565565
});
566566

567+
it("CLI setup rejects local public webhook URLs for Twilio", async () => {
568+
const program = new Command();
569+
const stdout = captureStdout();
570+
await registerVoiceCallCli(program, {
571+
provider: "twilio",
572+
fromNumber: "+15550001234",
573+
publicUrl: "http://127.0.0.1:3334/voice/webhook",
574+
twilio: {
575+
accountSid: "AC123",
576+
authToken: "token",
577+
},
578+
});
579+
580+
try {
581+
await program.parseAsync(["voicecall", "setup", "--json"], { from: "user" });
582+
const parsed = JSON.parse(stdout.output()) as {
583+
ok?: boolean;
584+
checks?: Array<{ id: string; ok: boolean; message: string }>;
585+
};
586+
expect(parsed.ok).toBe(false);
587+
expect(parsed.checks).toContainEqual(
588+
expect.objectContaining({
589+
id: "webhook-exposure",
590+
ok: false,
591+
message: expect.stringContaining("local/private"),
592+
}),
593+
);
594+
} finally {
595+
stdout.restore();
596+
}
597+
});
598+
567599
it("CLI status lists active calls without a call id", async () => {
568600
const program = new Command();
569601
const stdout = captureStdout();

extensions/voice-call/src/cli.ts

Lines changed: 4 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { sleep } from "../api.js";
1010
import { validateProviderConfig, type VoiceCallConfig } from "./config.js";
1111
import type { VoiceCallRuntime } from "./runtime.js";
1212
import { resolveUserPath } from "./utils.js";
13+
import { resolveWebhookExposureStatus } from "./webhook-exposure.js";
1314
import {
1415
cleanupTailscaleExposureRoute,
1516
getTailscaleSelfInfo,
@@ -166,16 +167,9 @@ function resolveCallMode(mode?: string): "notify" | "conversation" | undefined {
166167
return mode === "notify" || mode === "conversation" ? mode : undefined;
167168
}
168169

169-
function hasPublicExposure(config: VoiceCallConfig): boolean {
170-
return Boolean(
171-
config.publicUrl ||
172-
(config.tunnel?.provider && config.tunnel.provider !== "none") ||
173-
(config.tailscale?.mode && config.tailscale.mode !== "off"),
174-
);
175-
}
176-
177170
function buildSetupStatus(config: VoiceCallConfig): SetupStatus {
178171
const validation = validateProviderConfig(config);
172+
const webhookExposure = resolveWebhookExposureStatus(config);
179173
const checks: SetupCheck[] = [
180174
{
181175
id: "plugin-enabled",
@@ -200,15 +194,8 @@ function buildSetupStatus(config: VoiceCallConfig): SetupStatus {
200194
},
201195
{
202196
id: "webhook-exposure",
203-
ok: config.provider === "mock" || hasPublicExposure(config),
204-
message:
205-
config.provider === "mock"
206-
? "Mock provider does not need a public webhook"
207-
: hasPublicExposure(config)
208-
? config.publicUrl
209-
? `Public webhook URL configured: ${config.publicUrl}`
210-
: "Webhook exposure configured through tunnel or Tailscale"
211-
: "Set publicUrl or configure tunnel/tailscale so the provider can reach webhooks",
197+
ok: webhookExposure.ok,
198+
message: webhookExposure.message,
212199
},
213200
{
214201
id: "mode",

extensions/voice-call/src/runtime.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,20 @@ describe("createVoiceCallRuntime lifecycle", () => {
211211
},
212212
);
213213

214+
it("fails closed when Twilio publicUrl points at a local-only webhook", async () => {
215+
await expect(
216+
createVoiceCallRuntime({
217+
config: createExternalProviderConfig({
218+
provider: "twilio",
219+
publicUrl: "http://127.0.0.1:3334/voice/webhook",
220+
}),
221+
coreConfig: {} as CoreConfig,
222+
agentRuntime: {} as never,
223+
}),
224+
).rejects.toThrow("twilio requires a publicly reachable webhook URL");
225+
expect(mocks.webhookStop).toHaveBeenCalledTimes(1);
226+
});
227+
214228
it("accepts an explicit public URL for external voice providers", async () => {
215229
const runtime = await createVoiceCallRuntime({
216230
config: createExternalProviderConfig({

0 commit comments

Comments
 (0)