vellum 0.2.11 → 0.2.13

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.
@@ -8,8 +8,8 @@
8
8
  * - Relay WebSocket upgrade allowed from private network peers/origins
9
9
  * - Startup warning when RUNTIME_HTTP_HOST is not loopback
10
10
  */
11
- import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test';
12
- import { mkdtempSync, rmSync, realpathSync } from 'node:fs';
11
+ import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test';
12
+ import { mkdtempSync, realpathSync } from 'node:fs';
13
13
  import { tmpdir } from 'node:os';
14
14
  import { join } from 'node:path';
15
15
 
@@ -176,7 +176,7 @@ describe('gateway-only ingress enforcement', () => {
176
176
  expect(res.status).toBe(410);
177
177
  const body = await res.json() as { error: string; code: string };
178
178
  expect(body.code).toBe('GATEWAY_ONLY');
179
- expect(body.error).toContain('gateway-only mode');
179
+ expect(body.error).toContain('Direct webhook access disabled');
180
180
  });
181
181
 
182
182
  test('POST /webhooks/twilio/status returns 410', async () => {
@@ -297,7 +297,7 @@ describe('gateway-only ingress enforcement', () => {
297
297
  expect(res.status).toBe(403);
298
298
  const body = await res.json() as { error: string; code: string };
299
299
  expect(body.code).toBe('GATEWAY_ONLY');
300
- expect(body.error).toContain('gateway-only mode');
300
+ expect(body.error).toContain('Direct relay access disabled');
301
301
  });
302
302
 
303
303
  test('allows request with no origin header (private network peer)', async () => {
@@ -410,52 +410,26 @@ describe('gateway-only ingress enforcement', () => {
410
410
 
411
411
  // ── Startup warning for non-loopback host ──────────────────────────
412
412
 
413
- describe('startup guard — non-loopback host warning', () => {
414
- test('logs warning when hostname is not loopback', async () => {
415
- logMessages.length = 0;
416
-
413
+ describe('startup guard — non-loopback host', () => {
414
+ test('server starts successfully when hostname is not loopback', async () => {
417
415
  const warnServer = new RuntimeHttpServer({
418
416
  port: 0,
419
417
  hostname: '0.0.0.0',
420
418
  bearerToken: TEST_TOKEN,
421
419
  });
422
420
  await warnServer.start();
423
-
424
- const infoMsg = logMessages.find(
425
- m => m.level === 'info' && m.msg.includes('gateway-only ingress mode'),
426
- );
427
- expect(infoMsg).toBeDefined();
428
-
429
- const warnMsg = logMessages.find(
430
- m => m.level === 'warn' && m.msg.includes('not bound to loopback'),
431
- );
432
- expect(warnMsg).toBeDefined();
433
-
421
+ expect(warnServer.actualPort).toBeGreaterThan(0);
434
422
  await warnServer.stop();
435
423
  });
436
424
 
437
- test('does NOT log warning when hostname is loopback', async () => {
438
- logMessages.length = 0;
439
-
440
- // The main test server already uses 127.0.0.1, so restart with
441
- // a fresh server and capture logs
425
+ test('server starts successfully when hostname is loopback', async () => {
442
426
  const loopbackServer = new RuntimeHttpServer({
443
427
  port: 0,
444
428
  hostname: '127.0.0.1',
445
429
  bearerToken: TEST_TOKEN,
446
430
  });
447
431
  await loopbackServer.start();
448
-
449
- const infoMsg = logMessages.find(
450
- m => m.level === 'info' && m.msg.includes('gateway-only ingress mode'),
451
- );
452
- expect(infoMsg).toBeDefined();
453
-
454
- const warnMsg = logMessages.find(
455
- m => m.level === 'warn' && m.msg.includes('not bound to loopback'),
456
- );
457
- expect(warnMsg).toBeUndefined();
458
-
432
+ expect(loopbackServer.actualPort).toBeGreaterThan(0);
459
433
  await loopbackServer.stop();
460
434
  });
461
435
  });
@@ -36,7 +36,7 @@ mock.module('../util/logger.js', () => ({
36
36
  }));
37
37
 
38
38
  // Track registerPendingCallback calls
39
- let pendingCallbacks: Map<string, { resolve: (code: string) => void; reject: (error: Error) => void }> = new Map();
39
+ const pendingCallbacks: Map<string, { resolve: (code: string) => void; reject: (error: Error) => void }> = new Map();
40
40
 
41
41
  mock.module('../security/oauth-callback-registry.js', () => ({
42
42
  registerPendingCallback: (state: string, resolve: (code: string) => void, reject: (error: Error) => void) => {
@@ -153,32 +153,13 @@ describe('OAuth2 gateway transport', () => {
153
153
  expect(result.tokens.accessToken).toBe('test-access-token');
154
154
  });
155
155
 
156
- test('selects loopback transport when ingress.publicBaseUrl is empty', async () => {
156
+ test('throws when ingress.publicBaseUrl is not configured', async () => {
157
157
  mockPublicBaseUrl = '';
158
158
 
159
- let capturedAuthUrl = '';
160
- const flowPromise = startOAuth2Flow(BASE_OAUTH_CONFIG, {
161
- openUrl: (url) => { capturedAuthUrl = url; },
162
- });
163
-
164
- // Give the flow a tick
165
- await new Promise((r) => setTimeout(r, 10));
166
-
167
- // The auth URL should contain a loopback redirect_uri
168
- expect(capturedAuthUrl).toContain('redirect_uri=');
169
- expect(capturedAuthUrl).toContain('127.0.0.1');
170
-
171
- // Extract the redirect_uri to send the callback
172
- const authUrlParsed = new URL(capturedAuthUrl);
173
- const redirectUri = authUrlParsed.searchParams.get('redirect_uri')!;
174
- const stateParam = authUrlParsed.searchParams.get('state')!;
175
-
176
- // Simulate the OAuth provider callback to the loopback server
177
- const callbackUrl = `${redirectUri}?code=loopback-code&state=${stateParam}`;
178
- await fetch(callbackUrl);
179
-
180
- const result = await flowPromise;
181
- expect(result.tokens.accessToken).toBe('test-access-token');
159
+ // Without a public base URL, the flow should reject immediately
160
+ await expect(
161
+ startOAuth2Flow(BASE_OAUTH_CONFIG, { openUrl: () => {} }),
162
+ ).rejects.toThrow('OAuth requires a public ingress URL');
182
163
  });
183
164
  });
184
165
 
@@ -206,8 +187,8 @@ describe('OAuth2 gateway transport', () => {
206
187
  expect(result.tokens.accessToken).toBe('test-access-token');
207
188
  });
208
189
 
209
- test('uses loopback transport when explicitly specified', async () => {
210
- // Even with publicBaseUrl configured, explicit loopback should work
190
+ test('ignores loopback transport option and uses gateway', async () => {
191
+ // Loopback transport was removed gateway is always used
211
192
  mockPublicBaseUrl = 'https://gw.example.com';
212
193
 
213
194
  let capturedAuthUrl = '';
@@ -219,13 +200,13 @@ describe('OAuth2 gateway transport', () => {
219
200
 
220
201
  await new Promise((r) => setTimeout(r, 10));
221
202
 
222
- expect(capturedAuthUrl).toContain('127.0.0.1');
223
-
224
- const authUrlParsed = new URL(capturedAuthUrl);
225
- const redirectUri = authUrlParsed.searchParams.get('redirect_uri')!;
226
- const stateParam = authUrlParsed.searchParams.get('state')!;
203
+ // Should use gateway redirect, not loopback
204
+ expect(capturedAuthUrl).toContain(encodeURIComponent('https://gw.example.com'));
205
+ expect(capturedAuthUrl).not.toContain('127.0.0.1');
227
206
 
228
- await fetch(`${redirectUri}?code=loopback-code&state=${stateParam}`);
207
+ const entries = Array.from(pendingCallbacks.entries());
208
+ expect(entries.length).toBe(1);
209
+ entries[0][1].resolve('gateway-code-despite-loopback-option');
229
210
 
230
211
  const result = await flowPromise;
231
212
  expect(result.tokens.accessToken).toBe('test-access-token');
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test';
2
- import { existsSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { tmpdir } from 'node:os';
5
5
 
@@ -585,7 +585,7 @@ describe('ingress-dependent setup skills declare public-ingress', () => {
585
585
  const VELLUM_SKILLS_DIR = join(import.meta.dir, '..', 'config', 'vellum-skills');
586
586
 
587
587
  function readVellumSkillIncludes(skillId: string): string[] | undefined {
588
- const content = require('node:fs').readFileSync(join(VELLUM_SKILLS_DIR, skillId, 'SKILL.md'), 'utf-8');
588
+ const content = readFileSync(join(VELLUM_SKILLS_DIR, skillId, 'SKILL.md'), 'utf-8');
589
589
  const match = content.match(FRONTMATTER_REGEX);
590
590
  if (!match) return undefined;
591
591
  for (const line of match[1].split(/\r?\n/)) {
@@ -741,6 +741,7 @@ describe('Trust Store', () => {
741
741
  'host_file_edit',
742
742
  'host_file_read',
743
743
  'host_file_write',
744
+ 'memory_search',
744
745
  'scaffold_managed_skill',
745
746
  'skill_load',
746
747
  'ui_dismiss',
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Unit tests for TwiML generation with voice quality profiles.
3
+ *
4
+ * Tests that generateTwiML correctly uses profile values for
5
+ * ttsProvider, voice, language, and transcriptionProvider.
6
+ */
7
+ import { describe, test, expect, mock } from 'bun:test';
8
+
9
+ mock.module('../util/logger.js', () => ({
10
+ getLogger: () =>
11
+ new Proxy({} as Record<string, unknown>, {
12
+ get: () => () => {},
13
+ }),
14
+ }));
15
+
16
+ import { generateTwiML } from '../calls/twilio-routes.js';
17
+
18
+ describe('generateTwiML with voice quality profile', () => {
19
+ const callSessionId = 'test-session-123';
20
+ const relayUrl = 'wss://test.example.com/v1/calls/relay';
21
+ const welcomeGreeting = 'Hello, how can I help?';
22
+
23
+ test('TwiML includes ttsProvider="Google" when profile specifies Google', () => {
24
+ const twiml = generateTwiML(callSessionId, relayUrl, welcomeGreeting, {
25
+ language: 'en-US',
26
+ transcriptionProvider: 'Deepgram',
27
+ ttsProvider: 'Google',
28
+ voice: 'Google.en-US-Journey-O',
29
+ });
30
+
31
+ expect(twiml).toContain('ttsProvider="Google"');
32
+ expect(twiml).toContain('voice="Google.en-US-Journey-O"');
33
+ expect(twiml).toContain('language="en-US"');
34
+ expect(twiml).toContain('transcriptionProvider="Deepgram"');
35
+ });
36
+
37
+ test('TwiML includes ttsProvider="ElevenLabs" when profile specifies ElevenLabs', () => {
38
+ const twiml = generateTwiML(callSessionId, relayUrl, welcomeGreeting, {
39
+ language: 'en-US',
40
+ transcriptionProvider: 'Deepgram',
41
+ ttsProvider: 'ElevenLabs',
42
+ voice: 'voice123-turbo_v2_5-0.5_0.75_0',
43
+ });
44
+
45
+ expect(twiml).toContain('ttsProvider="ElevenLabs"');
46
+ expect(twiml).toContain('voice="voice123-turbo_v2_5-0.5_0.75_0"');
47
+ });
48
+
49
+ test('voice attribute reflects configured voice for twilio_standard mode', () => {
50
+ const twiml = generateTwiML(callSessionId, relayUrl, welcomeGreeting, {
51
+ language: 'en-US',
52
+ transcriptionProvider: 'Deepgram',
53
+ ttsProvider: 'Google',
54
+ voice: 'Google.en-US-Journey-O',
55
+ });
56
+
57
+ expect(twiml).toContain('voice="Google.en-US-Journey-O"');
58
+ });
59
+
60
+ test('voice attribute reflects configured voice for twilio_elevenlabs_tts mode', () => {
61
+ const twiml = generateTwiML(callSessionId, relayUrl, welcomeGreeting, {
62
+ language: 'en-US',
63
+ transcriptionProvider: 'Deepgram',
64
+ ttsProvider: 'ElevenLabs',
65
+ voice: 'abc123-turbo_v2_5-0.5_0.75_0',
66
+ });
67
+
68
+ expect(twiml).toContain('voice="abc123-turbo_v2_5-0.5_0.75_0"');
69
+ });
70
+
71
+ test('language attribute reflects configured language', () => {
72
+ const twiml = generateTwiML(callSessionId, relayUrl, welcomeGreeting, {
73
+ language: 'es-MX',
74
+ transcriptionProvider: 'Google',
75
+ ttsProvider: 'Google',
76
+ voice: 'Google.es-MX-Standard-A',
77
+ });
78
+
79
+ expect(twiml).toContain('language="es-MX"');
80
+ });
81
+
82
+ test('transcriptionProvider reflects configured value', () => {
83
+ const twiml = generateTwiML(callSessionId, relayUrl, welcomeGreeting, {
84
+ language: 'en-US',
85
+ transcriptionProvider: 'Google',
86
+ ttsProvider: 'Google',
87
+ voice: 'Google.en-US-Journey-O',
88
+ });
89
+
90
+ expect(twiml).toContain('transcriptionProvider="Google"');
91
+ });
92
+
93
+ test('TwiML properly escapes XML characters in profile values', () => {
94
+ const twiml = generateTwiML(callSessionId, relayUrl, welcomeGreeting, {
95
+ language: 'en-US',
96
+ transcriptionProvider: 'Deepgram',
97
+ ttsProvider: 'Google',
98
+ voice: 'voice<>&"test',
99
+ });
100
+
101
+ expect(twiml).toContain('voice="voice&lt;&gt;&amp;&quot;test"');
102
+ expect(twiml).not.toContain('voice="voice<>&"test"');
103
+ });
104
+
105
+ test('TwiML includes callSessionId in relay URL', () => {
106
+ const twiml = generateTwiML(callSessionId, relayUrl, welcomeGreeting, {
107
+ language: 'en-US',
108
+ transcriptionProvider: 'Deepgram',
109
+ ttsProvider: 'Google',
110
+ voice: 'Google.en-US-Journey-O',
111
+ });
112
+
113
+ expect(twiml).toContain(`callSessionId=${callSessionId}`);
114
+ });
115
+
116
+ test('TwiML includes interruptible and dtmfDetection attributes', () => {
117
+ const twiml = generateTwiML(callSessionId, relayUrl, welcomeGreeting, {
118
+ language: 'en-US',
119
+ transcriptionProvider: 'Deepgram',
120
+ ttsProvider: 'Google',
121
+ voice: 'Google.en-US-Journey-O',
122
+ });
123
+
124
+ expect(twiml).toContain('interruptible="true"');
125
+ expect(twiml).toContain('dtmfDetection="true"');
126
+ });
127
+ });