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.
- package/bun.lock +6 -2
- package/package.json +2 -2
- package/src/__tests__/call-orchestrator.test.ts +58 -0
- package/src/__tests__/config-schema.test.ts +278 -0
- package/src/__tests__/elevenlabs-client.test.ts +209 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +9 -35
- package/src/__tests__/oauth2-gateway-transport.test.ts +14 -33
- package/src/__tests__/skills.test.ts +2 -2
- package/src/__tests__/trust-store.test.ts +1 -0
- package/src/__tests__/twilio-routes-twiml.test.ts +127 -0
- package/src/__tests__/twilio-routes.test.ts +78 -153
- package/src/__tests__/twitter-auth-handler.test.ts +1 -1
- package/src/calls/call-orchestrator.ts +3 -1
- package/src/calls/elevenlabs-client.ts +89 -0
- package/src/calls/elevenlabs-config.ts +29 -0
- package/src/calls/twilio-routes.ts +55 -6
- package/src/calls/voice-quality.ts +92 -0
- package/src/cli/main-screen.tsx +15 -117
- package/src/config/bundled-skills/macos-automation/SKILL.md +66 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +414 -0
- package/src/config/defaults.ts +18 -0
- package/src/config/schema.ts +110 -0
- package/src/config/system-prompt.ts +9 -59
- package/src/config/types.ts +2 -0
- package/src/daemon/lifecycle.ts +20 -7
- package/src/memory/db.ts +36 -0
- package/src/permissions/defaults.ts +11 -0
- package/src/runtime/routes/conversation-routes.ts +12 -5
- package/src/security/oauth2.ts +8 -8
- package/src/util/logger.ts +4 -4
|
@@ -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
|
|
12
|
-
import { mkdtempSync,
|
|
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('
|
|
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('
|
|
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
|
|
414
|
-
test('
|
|
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('
|
|
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
|
-
|
|
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('
|
|
156
|
+
test('throws when ingress.publicBaseUrl is not configured', async () => {
|
|
157
157
|
mockPublicBaseUrl = '';
|
|
158
158
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
openUrl: (
|
|
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('
|
|
210
|
-
//
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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 =
|
|
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/)) {
|
|
@@ -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<>&"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
|
+
});
|