@openclaw/voice-call 2026.2.17 → 2026.2.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Changelog
2
2
 
3
- ## 2026.2.17
3
+ ## 2026.2.19
4
4
 
5
5
  ### Changes
6
6
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/voice-call",
3
- "version": "2026.2.17",
3
+ "version": "2026.2.19",
4
4
  "description": "OpenClaw voice-call plugin",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -44,9 +44,7 @@ function createBaseConfig(provider: "telnyx" | "twilio" | "plivo" | "mock"): Voi
44
44
 
45
45
  describe("validateProviderConfig", () => {
46
46
  const originalEnv = { ...process.env };
47
-
48
- beforeEach(() => {
49
- // Clear all relevant env vars before each test
47
+ const clearProviderEnv = () => {
50
48
  delete process.env.TWILIO_ACCOUNT_SID;
51
49
  delete process.env.TWILIO_AUTH_TOKEN;
52
50
  delete process.env.TELNYX_API_KEY;
@@ -54,6 +52,10 @@ describe("validateProviderConfig", () => {
54
52
  delete process.env.TELNYX_PUBLIC_KEY;
55
53
  delete process.env.PLIVO_AUTH_ID;
56
54
  delete process.env.PLIVO_AUTH_TOKEN;
55
+ };
56
+
57
+ beforeEach(() => {
58
+ clearProviderEnv();
57
59
  });
58
60
 
59
61
  afterEach(() => {
@@ -61,29 +63,43 @@ describe("validateProviderConfig", () => {
61
63
  process.env = { ...originalEnv };
62
64
  });
63
65
 
64
- describe("twilio provider", () => {
65
- it("passes validation when credentials are in config", () => {
66
- const config = createBaseConfig("twilio");
67
- config.twilio = { accountSid: "AC123", authToken: "secret" };
68
-
69
- const result = validateProviderConfig(config);
70
-
71
- expect(result.valid).toBe(true);
72
- expect(result.errors).toEqual([]);
73
- });
74
-
75
- it("passes validation when credentials are in environment variables", () => {
76
- process.env.TWILIO_ACCOUNT_SID = "AC123";
77
- process.env.TWILIO_AUTH_TOKEN = "secret";
78
- let config = createBaseConfig("twilio");
79
- config = resolveVoiceCallConfig(config);
80
-
81
- const result = validateProviderConfig(config);
82
-
83
- expect(result.valid).toBe(true);
84
- expect(result.errors).toEqual([]);
66
+ describe("provider credential sources", () => {
67
+ it("passes validation when credentials come from config or environment", () => {
68
+ for (const provider of ["twilio", "telnyx", "plivo"] as const) {
69
+ clearProviderEnv();
70
+ const fromConfig = createBaseConfig(provider);
71
+ if (provider === "twilio") {
72
+ fromConfig.twilio = { accountSid: "AC123", authToken: "secret" };
73
+ } else if (provider === "telnyx") {
74
+ fromConfig.telnyx = {
75
+ apiKey: "KEY123",
76
+ connectionId: "CONN456",
77
+ publicKey: "public-key",
78
+ };
79
+ } else {
80
+ fromConfig.plivo = { authId: "MA123", authToken: "secret" };
81
+ }
82
+ expect(validateProviderConfig(fromConfig)).toMatchObject({ valid: true, errors: [] });
83
+
84
+ clearProviderEnv();
85
+ if (provider === "twilio") {
86
+ process.env.TWILIO_ACCOUNT_SID = "AC123";
87
+ process.env.TWILIO_AUTH_TOKEN = "secret";
88
+ } else if (provider === "telnyx") {
89
+ process.env.TELNYX_API_KEY = "KEY123";
90
+ process.env.TELNYX_CONNECTION_ID = "CONN456";
91
+ process.env.TELNYX_PUBLIC_KEY = "public-key";
92
+ } else {
93
+ process.env.PLIVO_AUTH_ID = "MA123";
94
+ process.env.PLIVO_AUTH_TOKEN = "secret";
95
+ }
96
+ const fromEnv = resolveVoiceCallConfig(createBaseConfig(provider));
97
+ expect(validateProviderConfig(fromEnv)).toMatchObject({ valid: true, errors: [] });
98
+ }
85
99
  });
100
+ });
86
101
 
102
+ describe("twilio provider", () => {
87
103
  it("passes validation with mixed config and env vars", () => {
88
104
  process.env.TWILIO_AUTH_TOKEN = "secret";
89
105
  let config = createBaseConfig("twilio");
@@ -96,57 +112,27 @@ describe("validateProviderConfig", () => {
96
112
  expect(result.errors).toEqual([]);
97
113
  });
98
114
 
99
- it("fails validation when accountSid is missing everywhere", () => {
115
+ it("fails validation when required twilio credentials are missing", () => {
100
116
  process.env.TWILIO_AUTH_TOKEN = "secret";
101
- let config = createBaseConfig("twilio");
102
- config = resolveVoiceCallConfig(config);
103
-
104
- const result = validateProviderConfig(config);
105
-
106
- expect(result.valid).toBe(false);
107
- expect(result.errors).toContain(
117
+ const missingSid = validateProviderConfig(resolveVoiceCallConfig(createBaseConfig("twilio")));
118
+ expect(missingSid.valid).toBe(false);
119
+ expect(missingSid.errors).toContain(
108
120
  "plugins.entries.voice-call.config.twilio.accountSid is required (or set TWILIO_ACCOUNT_SID env)",
109
121
  );
110
- });
111
122
 
112
- it("fails validation when authToken is missing everywhere", () => {
123
+ delete process.env.TWILIO_AUTH_TOKEN;
113
124
  process.env.TWILIO_ACCOUNT_SID = "AC123";
114
- let config = createBaseConfig("twilio");
115
- config = resolveVoiceCallConfig(config);
116
-
117
- const result = validateProviderConfig(config);
118
-
119
- expect(result.valid).toBe(false);
120
- expect(result.errors).toContain(
125
+ const missingToken = validateProviderConfig(
126
+ resolveVoiceCallConfig(createBaseConfig("twilio")),
127
+ );
128
+ expect(missingToken.valid).toBe(false);
129
+ expect(missingToken.errors).toContain(
121
130
  "plugins.entries.voice-call.config.twilio.authToken is required (or set TWILIO_AUTH_TOKEN env)",
122
131
  );
123
132
  });
124
133
  });
125
134
 
126
135
  describe("telnyx provider", () => {
127
- it("passes validation when credentials are in config", () => {
128
- const config = createBaseConfig("telnyx");
129
- config.telnyx = { apiKey: "KEY123", connectionId: "CONN456", publicKey: "public-key" };
130
-
131
- const result = validateProviderConfig(config);
132
-
133
- expect(result.valid).toBe(true);
134
- expect(result.errors).toEqual([]);
135
- });
136
-
137
- it("passes validation when credentials are in environment variables", () => {
138
- process.env.TELNYX_API_KEY = "KEY123";
139
- process.env.TELNYX_CONNECTION_ID = "CONN456";
140
- process.env.TELNYX_PUBLIC_KEY = "public-key";
141
- let config = createBaseConfig("telnyx");
142
- config = resolveVoiceCallConfig(config);
143
-
144
- const result = validateProviderConfig(config);
145
-
146
- expect(result.valid).toBe(true);
147
- expect(result.errors).toEqual([]);
148
- });
149
-
150
136
  it("fails validation when apiKey is missing everywhere", () => {
151
137
  process.env.TELNYX_CONNECTION_ID = "CONN456";
152
138
  let config = createBaseConfig("telnyx");
@@ -160,69 +146,36 @@ describe("validateProviderConfig", () => {
160
146
  );
161
147
  });
162
148
 
163
- it("fails validation when allowlist inbound policy lacks public key", () => {
164
- const config = createBaseConfig("telnyx");
165
- config.inboundPolicy = "allowlist";
166
- config.telnyx = { apiKey: "KEY123", connectionId: "CONN456" };
167
-
168
- const result = validateProviderConfig(config);
169
-
170
- expect(result.valid).toBe(false);
171
- expect(result.errors).toContain(
149
+ it("requires a public key unless signature verification is skipped", () => {
150
+ const missingPublicKey = createBaseConfig("telnyx");
151
+ missingPublicKey.inboundPolicy = "allowlist";
152
+ missingPublicKey.telnyx = { apiKey: "KEY123", connectionId: "CONN456" };
153
+ const missingPublicKeyResult = validateProviderConfig(missingPublicKey);
154
+ expect(missingPublicKeyResult.valid).toBe(false);
155
+ expect(missingPublicKeyResult.errors).toContain(
172
156
  "plugins.entries.voice-call.config.telnyx.publicKey is required (or set TELNYX_PUBLIC_KEY env)",
173
157
  );
174
- });
175
158
 
176
- it("passes validation when allowlist inbound policy has public key", () => {
177
- const config = createBaseConfig("telnyx");
178
- config.inboundPolicy = "allowlist";
179
- config.telnyx = {
159
+ const withPublicKey = createBaseConfig("telnyx");
160
+ withPublicKey.inboundPolicy = "allowlist";
161
+ withPublicKey.telnyx = {
180
162
  apiKey: "KEY123",
181
163
  connectionId: "CONN456",
182
164
  publicKey: "public-key",
183
165
  };
184
-
185
- const result = validateProviderConfig(config);
186
-
187
- expect(result.valid).toBe(true);
188
- expect(result.errors).toEqual([]);
189
- });
190
-
191
- it("passes validation when skipSignatureVerification is true (even without public key)", () => {
192
- const config = createBaseConfig("telnyx");
193
- config.skipSignatureVerification = true;
194
- config.telnyx = { apiKey: "KEY123", connectionId: "CONN456" };
195
-
196
- const result = validateProviderConfig(config);
197
-
198
- expect(result.valid).toBe(true);
199
- expect(result.errors).toEqual([]);
166
+ expect(validateProviderConfig(withPublicKey)).toMatchObject({ valid: true, errors: [] });
167
+
168
+ const skippedVerification = createBaseConfig("telnyx");
169
+ skippedVerification.skipSignatureVerification = true;
170
+ skippedVerification.telnyx = { apiKey: "KEY123", connectionId: "CONN456" };
171
+ expect(validateProviderConfig(skippedVerification)).toMatchObject({
172
+ valid: true,
173
+ errors: [],
174
+ });
200
175
  });
201
176
  });
202
177
 
203
178
  describe("plivo provider", () => {
204
- it("passes validation when credentials are in config", () => {
205
- const config = createBaseConfig("plivo");
206
- config.plivo = { authId: "MA123", authToken: "secret" };
207
-
208
- const result = validateProviderConfig(config);
209
-
210
- expect(result.valid).toBe(true);
211
- expect(result.errors).toEqual([]);
212
- });
213
-
214
- it("passes validation when credentials are in environment variables", () => {
215
- process.env.PLIVO_AUTH_ID = "MA123";
216
- process.env.PLIVO_AUTH_TOKEN = "secret";
217
- let config = createBaseConfig("plivo");
218
- config = resolveVoiceCallConfig(config);
219
-
220
- const result = validateProviderConfig(config);
221
-
222
- expect(result.valid).toBe(true);
223
- expect(result.errors).toEqual([]);
224
- });
225
-
226
179
  it("fails validation when authId is missing everywhere", () => {
227
180
  process.env.PLIVO_AUTH_TOKEN = "secret";
228
181
  let config = createBaseConfig("plivo");
@@ -0,0 +1,75 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import type { VoiceCallTtsConfig } from "./config.js";
3
+ import type { CoreConfig } from "./core-bridge.js";
4
+ import { createTelephonyTtsProvider } from "./telephony-tts.js";
5
+
6
+ function createCoreConfig(): CoreConfig {
7
+ const tts: VoiceCallTtsConfig = {
8
+ provider: "openai",
9
+ openai: {
10
+ model: "gpt-4o-mini-tts",
11
+ voice: "alloy",
12
+ },
13
+ };
14
+ return { messages: { tts } };
15
+ }
16
+
17
+ async function mergeOverride(override: unknown): Promise<Record<string, unknown>> {
18
+ let mergedConfig: CoreConfig | undefined;
19
+ const provider = createTelephonyTtsProvider({
20
+ coreConfig: createCoreConfig(),
21
+ ttsOverride: override as VoiceCallTtsConfig,
22
+ runtime: {
23
+ textToSpeechTelephony: async ({ cfg }) => {
24
+ mergedConfig = cfg;
25
+ return {
26
+ success: true,
27
+ audioBuffer: Buffer.alloc(2),
28
+ sampleRate: 8000,
29
+ };
30
+ },
31
+ },
32
+ });
33
+
34
+ await provider.synthesizeForTelephony("hello");
35
+ expect(mergedConfig?.messages?.tts).toBeDefined();
36
+ return mergedConfig?.messages?.tts as Record<string, unknown>;
37
+ }
38
+
39
+ afterEach(() => {
40
+ delete (Object.prototype as Record<string, unknown>).polluted;
41
+ });
42
+
43
+ describe("createTelephonyTtsProvider deepMerge hardening", () => {
44
+ it("merges safe nested overrides", async () => {
45
+ const tts = await mergeOverride({
46
+ openai: { voice: "coral" },
47
+ });
48
+ const openai = tts.openai as Record<string, unknown>;
49
+
50
+ expect(openai.voice).toBe("coral");
51
+ expect(openai.model).toBe("gpt-4o-mini-tts");
52
+ });
53
+
54
+ it("blocks top-level __proto__ keys", async () => {
55
+ const tts = await mergeOverride(
56
+ JSON.parse('{"__proto__":{"polluted":"top"},"openai":{"voice":"coral"}}'),
57
+ );
58
+ const openai = tts.openai as Record<string, unknown>;
59
+
60
+ expect((Object.prototype as Record<string, unknown>).polluted).toBeUndefined();
61
+ expect(tts.polluted).toBeUndefined();
62
+ expect(openai.voice).toBe("coral");
63
+ });
64
+
65
+ it("blocks nested __proto__ keys", async () => {
66
+ const tts = await mergeOverride(
67
+ JSON.parse('{"openai":{"model":"safe","__proto__":{"polluted":"nested"}}}'),
68
+ );
69
+ const openai = tts.openai as Record<string, unknown>;
70
+
71
+ expect((Object.prototype as Record<string, unknown>).polluted).toBeUndefined();
72
+ expect(openai.polluted).toBeUndefined();
73
+ expect(openai.model).toBe("safe");
74
+ });
75
+ });
@@ -20,6 +20,8 @@ export type TelephonyTtsProvider = {
20
20
  synthesizeForTelephony: (text: string) => Promise<Buffer>;
21
21
  };
22
22
 
23
+ const BLOCKED_MERGE_KEYS = new Set(["__proto__", "prototype", "constructor"]);
24
+
23
25
  export function createTelephonyTtsProvider(params: {
24
26
  coreConfig: CoreConfig;
25
27
  ttsOverride?: VoiceCallTtsConfig;
@@ -86,7 +88,7 @@ function deepMerge<T>(base: T, override: T): T {
86
88
  }
87
89
  const result: Record<string, unknown> = { ...base };
88
90
  for (const [key, value] of Object.entries(override)) {
89
- if (value === undefined) {
91
+ if (BLOCKED_MERGE_KEYS.has(key) || value === undefined) {
90
92
  continue;
91
93
  }
92
94
  const existing = (base as Record<string, unknown>)[key];