vellum 0.2.9 → 0.2.10
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 +2 -2
- package/package.json +2 -2
- package/scripts/capture-x-graphql.ts +1 -18
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +110 -0
- package/src/__tests__/call-bridge.test.ts +40 -0
- package/src/__tests__/call-state.test.ts +41 -0
- package/src/__tests__/forbidden-legacy-symbols.test.ts +8 -6
- package/src/__tests__/gateway-only-enforcement.test.ts +13 -89
- package/src/__tests__/home-base-bootstrap.test.ts +13 -8
- package/src/__tests__/intent-routing.test.ts +2 -5
- package/src/__tests__/ipc-snapshot.test.ts +49 -0
- package/src/__tests__/onboarding-starter-tasks.test.ts +12 -2
- package/src/__tests__/prebuilt-home-base-seed.test.ts +9 -5
- package/src/__tests__/relay-server.test.ts +55 -0
- package/src/__tests__/skills.test.ts +83 -0
- package/src/__tests__/system-prompt.test.ts +2 -24
- package/src/__tests__/twilio-provider.test.ts +36 -0
- package/src/__tests__/twilio-routes.test.ts +108 -0
- package/src/calls/call-orchestrator.ts +25 -5
- package/src/calls/call-state.ts +23 -0
- package/src/calls/relay-server.ts +56 -1
- package/src/calls/twilio-config.ts +9 -13
- package/src/calls/twilio-provider.ts +6 -1
- package/src/calls/twilio-routes.ts +10 -1
- package/src/cli/core-commands.ts +12 -4
- package/src/config/bundled-skills/app-builder/SKILL.md +57 -1
- package/src/config/bundled-skills/document/SKILL.md +11 -3
- package/src/config/bundled-skills/followups/icon.svg +24 -0
- package/src/config/bundled-skills/messaging/SKILL.md +7 -3
- package/src/config/bundled-skills/public-ingress/SKILL.md +183 -0
- package/src/config/bundled-skills/self-upgrade/SKILL.md +4 -10
- package/src/config/defaults.ts +1 -1
- package/src/config/schema.ts +4 -7
- package/src/config/system-prompt.ts +64 -360
- package/src/config/vellum-skills/google-oauth-setup/SKILL.md +5 -1
- package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +5 -1
- package/src/config/vellum-skills/telegram-setup/SKILL.md +2 -1
- package/src/daemon/handlers/config.ts +20 -9
- package/src/daemon/handlers/home-base.ts +3 -2
- package/src/daemon/handlers/identity.ts +127 -0
- package/src/daemon/handlers/index.ts +4 -0
- package/src/daemon/handlers/workspace-files.ts +75 -0
- package/src/daemon/ipc-contract-inventory.json +16 -4
- package/src/daemon/ipc-contract.ts +62 -2
- package/src/daemon/session-notifiers.ts +29 -0
- package/src/daemon/session-surfaces.ts +5 -2
- package/src/daemon/session-tool-setup.ts +15 -4
- package/src/home-base/bootstrap.ts +3 -1
- package/src/home-base/prebuilt/seed.ts +16 -5
- package/src/inbound/public-ingress-urls.ts +15 -4
- package/src/runtime/http-server.ts +123 -20
- package/src/security/oauth2.ts +19 -161
- package/src/tools/browser/auto-navigate.ts +2 -2
- package/src/tools/browser/x-auto-navigate.ts +1 -1
- package/src/tools/claude-code/claude-code.ts +1 -1
- package/src/tools/system/version.ts +43 -0
- package/src/tools/tasks/work-item-run.ts +1 -1
- package/src/tools/terminal/parser.ts +29 -7
- package/src/tools/tool-manifest.ts +2 -0
- package/src/tools/ui-surface/definitions.ts +9 -2
package/bun.lock
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"@huggingface/transformers": "^3.8.1",
|
|
12
12
|
"@qdrant/js-client-rest": "^1.16.2",
|
|
13
13
|
"@sentry/node": "^10.38.0",
|
|
14
|
-
"@vellumai/cli": "0.1.
|
|
14
|
+
"@vellumai/cli": "0.1.10",
|
|
15
15
|
"@vellumai/vellum-gateway": "0.1.10",
|
|
16
16
|
"agentmail": "^0.1.0",
|
|
17
17
|
"archiver": "^7.0.1",
|
|
@@ -542,7 +542,7 @@
|
|
|
542
542
|
|
|
543
543
|
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.56.0", "", { "dependencies": { "@typescript-eslint/types": "8.56.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg=="],
|
|
544
544
|
|
|
545
|
-
"@vellumai/cli": ["@vellumai/cli@0.1.
|
|
545
|
+
"@vellumai/cli": ["@vellumai/cli@0.1.10", "", { "dependencies": { "ink": "^6.7.0", "react": "^19.2.4" }, "bin": { "vellum-cli": "src/index.ts" } }, "sha512-KBnfQRlt5VAJX6JMMYTWXq3Poi3YfVXE/O+C2249W1j58fxzxoRRENLK9WXDIKCIHlQ3+HRDug7ZAcYaEXURRA=="],
|
|
546
546
|
|
|
547
547
|
"@vellumai/vellum-gateway": ["@vellumai/vellum-gateway@0.1.10", "", { "dependencies": { "file-type": "^21.3.0", "pino": "^9.6.0", "pino-pretty": "^13.1.3", "zod": "^4.3.6" } }, "sha512-a41fGexW8RpWL4RTfZ3EM+XJMvz7t26D1axu2xAtZioXW3ZWMLGuogHnIJsgglzESl49E6VmmUsUGeD+dseV2w=="],
|
|
548
548
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vellum",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.10",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"vellum": "./src/index.ts"
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"@huggingface/transformers": "^3.8.1",
|
|
30
30
|
"@qdrant/js-client-rest": "^1.16.2",
|
|
31
31
|
"@sentry/node": "^10.38.0",
|
|
32
|
-
"@vellumai/cli": "0.1.
|
|
32
|
+
"@vellumai/cli": "0.1.10",
|
|
33
33
|
"@vellumai/vellum-gateway": "0.1.10",
|
|
34
34
|
"agentmail": "^0.1.0",
|
|
35
35
|
"archiver": "^7.0.1",
|
|
@@ -63,7 +63,7 @@ class CDPClient {
|
|
|
63
63
|
const msg = JSON.parse(String(event.data));
|
|
64
64
|
if (msg.id != null) {
|
|
65
65
|
const cb = this.callbacks.get(msg.id);
|
|
66
|
-
if (cb) { this.callbacks.delete(msg.id); msg.error
|
|
66
|
+
if (cb) { this.callbacks.delete(msg.id); if (msg.error) { cb.reject(new Error(msg.error.message)); } else { cb.resolve(msg.result); } }
|
|
67
67
|
} else if (msg.method) {
|
|
68
68
|
for (const h of this.eventHandlers.get(msg.method) ?? []) h(msg.params ?? {});
|
|
69
69
|
}
|
|
@@ -216,20 +216,6 @@ function notifyQuerySeen(queryName: string) {
|
|
|
216
216
|
}
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
-
function waitForQuery(queryName: string, timeoutMs = 15000): Promise<boolean> {
|
|
220
|
-
if (seenQueries.has(queryName)) return Promise.resolve(true);
|
|
221
|
-
return new Promise(resolve => {
|
|
222
|
-
const timer = setTimeout(() => {
|
|
223
|
-
queryWaiters.delete(queryName);
|
|
224
|
-
resolve(false);
|
|
225
|
-
}, timeoutMs);
|
|
226
|
-
queryWaiters.set(queryName, () => {
|
|
227
|
-
clearTimeout(timer);
|
|
228
|
-
resolve(true);
|
|
229
|
-
});
|
|
230
|
-
});
|
|
231
|
-
}
|
|
232
|
-
|
|
233
219
|
function waitForAnyQuery(queryNames: string[], timeoutMs = 15000): Promise<boolean> {
|
|
234
220
|
if (queryNames.some(q => seenQueries.has(q))) return Promise.resolve(true);
|
|
235
221
|
return new Promise(resolve => {
|
|
@@ -251,7 +237,6 @@ function waitForAnyQuery(queryNames: string[], timeoutMs = 15000): Promise<boole
|
|
|
251
237
|
|
|
252
238
|
// We'll keep a reference to one client that's on an x.com tab for navigation
|
|
253
239
|
let navigationClient: CDPClient | null = null;
|
|
254
|
-
let navigationWsUrl: string | null = null;
|
|
255
240
|
|
|
256
241
|
for (const page of pages) {
|
|
257
242
|
const client = new CDPClient();
|
|
@@ -261,7 +246,6 @@ for (const page of pages) {
|
|
|
261
246
|
// Track which client is on an x.com tab for navigation
|
|
262
247
|
if (page.url.includes('x.com') || page.url.includes('twitter.com')) {
|
|
263
248
|
navigationClient = client;
|
|
264
|
-
navigationWsUrl = page.webSocketDebuggerUrl;
|
|
265
249
|
}
|
|
266
250
|
|
|
267
251
|
client.on('Network.requestWillBeSent', (params) => {
|
|
@@ -347,7 +331,6 @@ for (const page of pages) {
|
|
|
347
331
|
if (!navigationClient && pages.length > 0) {
|
|
348
332
|
navigationClient = new CDPClient();
|
|
349
333
|
await navigationClient.connect(pages[0].webSocketDebuggerUrl);
|
|
350
|
-
navigationWsUrl = pages[0].webSocketDebuggerUrl;
|
|
351
334
|
}
|
|
352
335
|
|
|
353
336
|
// ─── CDP navigation helpers ──────────────────────────────────────────────────
|
|
@@ -530,6 +530,13 @@ exports[`IPC message snapshots ClientMessage types slack_webhook_config serializ
|
|
|
530
530
|
}
|
|
531
531
|
`;
|
|
532
532
|
|
|
533
|
+
exports[`IPC message snapshots ClientMessage types ingress_config serializes to expected JSON 1`] = `
|
|
534
|
+
{
|
|
535
|
+
"action": "get",
|
|
536
|
+
"type": "ingress_config",
|
|
537
|
+
}
|
|
538
|
+
`;
|
|
539
|
+
|
|
533
540
|
exports[`IPC message snapshots ClientMessage types vercel_api_config serializes to expected JSON 1`] = `
|
|
534
541
|
{
|
|
535
542
|
"action": "get",
|
|
@@ -796,6 +803,33 @@ exports[`IPC message snapshots ClientMessage types subagent_message serializes t
|
|
|
796
803
|
}
|
|
797
804
|
`;
|
|
798
805
|
|
|
806
|
+
exports[`IPC message snapshots ClientMessage types subagent_detail_request serializes to expected JSON 1`] = `
|
|
807
|
+
{
|
|
808
|
+
"conversationId": "conv-001",
|
|
809
|
+
"subagentId": "sub-001",
|
|
810
|
+
"type": "subagent_detail_request",
|
|
811
|
+
}
|
|
812
|
+
`;
|
|
813
|
+
|
|
814
|
+
exports[`IPC message snapshots ClientMessage types workspace_files_list serializes to expected JSON 1`] = `
|
|
815
|
+
{
|
|
816
|
+
"type": "workspace_files_list",
|
|
817
|
+
}
|
|
818
|
+
`;
|
|
819
|
+
|
|
820
|
+
exports[`IPC message snapshots ClientMessage types workspace_file_read serializes to expected JSON 1`] = `
|
|
821
|
+
{
|
|
822
|
+
"path": "IDENTITY.md",
|
|
823
|
+
"type": "workspace_file_read",
|
|
824
|
+
}
|
|
825
|
+
`;
|
|
826
|
+
|
|
827
|
+
exports[`IPC message snapshots ClientMessage types identity_get serializes to expected JSON 1`] = `
|
|
828
|
+
{
|
|
829
|
+
"type": "identity_get",
|
|
830
|
+
}
|
|
831
|
+
`;
|
|
832
|
+
|
|
799
833
|
exports[`IPC message snapshots ServerMessage types auth_result serializes to expected JSON 1`] = `
|
|
800
834
|
{
|
|
801
835
|
"success": true,
|
|
@@ -1448,6 +1482,14 @@ exports[`IPC message snapshots ServerMessage types watcher_escalation serializes
|
|
|
1448
1482
|
}
|
|
1449
1483
|
`;
|
|
1450
1484
|
|
|
1485
|
+
exports[`IPC message snapshots ServerMessage types agent_heartbeat_alert serializes to expected JSON 1`] = `
|
|
1486
|
+
{
|
|
1487
|
+
"body": "No activity detected in the last 60 minutes.",
|
|
1488
|
+
"title": "Agent heartbeat stalled",
|
|
1489
|
+
"type": "agent_heartbeat_alert",
|
|
1490
|
+
}
|
|
1491
|
+
`;
|
|
1492
|
+
|
|
1451
1493
|
exports[`IPC message snapshots ServerMessage types watch_started serializes to expected JSON 1`] = `
|
|
1452
1494
|
{
|
|
1453
1495
|
"durationSeconds": 300,
|
|
@@ -1770,6 +1812,16 @@ exports[`IPC message snapshots ServerMessage types slack_webhook_config_response
|
|
|
1770
1812
|
}
|
|
1771
1813
|
`;
|
|
1772
1814
|
|
|
1815
|
+
exports[`IPC message snapshots ServerMessage types ingress_config_response serializes to expected JSON 1`] = `
|
|
1816
|
+
{
|
|
1817
|
+
"enabled": true,
|
|
1818
|
+
"localGatewayTarget": "http://127.0.0.1:7830",
|
|
1819
|
+
"publicBaseUrl": "https://example.com",
|
|
1820
|
+
"success": true,
|
|
1821
|
+
"type": "ingress_config_response",
|
|
1822
|
+
}
|
|
1823
|
+
`;
|
|
1824
|
+
|
|
1773
1825
|
exports[`IPC message snapshots ServerMessage types vercel_api_config_response serializes to expected JSON 1`] = `
|
|
1774
1826
|
{
|
|
1775
1827
|
"hasToken": true,
|
|
@@ -2175,6 +2227,15 @@ exports[`IPC message snapshots ServerMessage types open_tasks_window serializes
|
|
|
2175
2227
|
}
|
|
2176
2228
|
`;
|
|
2177
2229
|
|
|
2230
|
+
exports[`IPC message snapshots ServerMessage types task_run_thread_created serializes to expected JSON 1`] = `
|
|
2231
|
+
{
|
|
2232
|
+
"conversationId": "conv-task-run-001",
|
|
2233
|
+
"title": "Process report",
|
|
2234
|
+
"type": "task_run_thread_created",
|
|
2235
|
+
"workItemId": "wi-001",
|
|
2236
|
+
}
|
|
2237
|
+
`;
|
|
2238
|
+
|
|
2178
2239
|
exports[`IPC message snapshots ServerMessage types subagent_spawned serializes to expected JSON 1`] = `
|
|
2179
2240
|
{
|
|
2180
2241
|
"label": "Research Agent",
|
|
@@ -2204,3 +2265,52 @@ exports[`IPC message snapshots ServerMessage types subagent_event serializes to
|
|
|
2204
2265
|
"type": "subagent_event",
|
|
2205
2266
|
}
|
|
2206
2267
|
`;
|
|
2268
|
+
|
|
2269
|
+
exports[`IPC message snapshots ServerMessage types subagent_detail_response serializes to expected JSON 1`] = `
|
|
2270
|
+
{
|
|
2271
|
+
"events": [
|
|
2272
|
+
{
|
|
2273
|
+
"content": "Reading file...",
|
|
2274
|
+
"isError": false,
|
|
2275
|
+
"toolName": "read_file",
|
|
2276
|
+
"type": "tool_use",
|
|
2277
|
+
},
|
|
2278
|
+
],
|
|
2279
|
+
"objective": "Search for documentation",
|
|
2280
|
+
"subagentId": "sub-001",
|
|
2281
|
+
"type": "subagent_detail_response",
|
|
2282
|
+
}
|
|
2283
|
+
`;
|
|
2284
|
+
|
|
2285
|
+
exports[`IPC message snapshots ServerMessage types workspace_files_list_response serializes to expected JSON 1`] = `
|
|
2286
|
+
{
|
|
2287
|
+
"files": [
|
|
2288
|
+
{
|
|
2289
|
+
"exists": true,
|
|
2290
|
+
"name": "IDENTITY.md",
|
|
2291
|
+
"path": "IDENTITY.md",
|
|
2292
|
+
},
|
|
2293
|
+
],
|
|
2294
|
+
"type": "workspace_files_list_response",
|
|
2295
|
+
}
|
|
2296
|
+
`;
|
|
2297
|
+
|
|
2298
|
+
exports[`IPC message snapshots ServerMessage types workspace_file_read_response serializes to expected JSON 1`] = `
|
|
2299
|
+
{
|
|
2300
|
+
"content": "# My Identity",
|
|
2301
|
+
"path": "IDENTITY.md",
|
|
2302
|
+
"type": "workspace_file_read_response",
|
|
2303
|
+
}
|
|
2304
|
+
`;
|
|
2305
|
+
|
|
2306
|
+
exports[`IPC message snapshots ServerMessage types identity_get_response serializes to expected JSON 1`] = `
|
|
2307
|
+
{
|
|
2308
|
+
"emoji": "✨",
|
|
2309
|
+
"found": true,
|
|
2310
|
+
"home": "~/workspace",
|
|
2311
|
+
"name": "Vex",
|
|
2312
|
+
"personality": "Friendly",
|
|
2313
|
+
"role": "AI assistant",
|
|
2314
|
+
"type": "identity_get_response",
|
|
2315
|
+
}
|
|
2316
|
+
`;
|
|
@@ -92,6 +92,9 @@ import {
|
|
|
92
92
|
import {
|
|
93
93
|
registerCallQuestionNotifier,
|
|
94
94
|
unregisterCallQuestionNotifier,
|
|
95
|
+
registerCallTranscriptNotifier,
|
|
96
|
+
unregisterCallTranscriptNotifier,
|
|
97
|
+
fireCallTranscriptNotifier,
|
|
95
98
|
registerCallCompletionNotifier,
|
|
96
99
|
unregisterCallCompletionNotifier,
|
|
97
100
|
fireCallQuestionNotifier,
|
|
@@ -335,6 +338,43 @@ describe('call-bridge', () => {
|
|
|
335
338
|
unregisterCallQuestionNotifier('conv-notifier-q');
|
|
336
339
|
});
|
|
337
340
|
|
|
341
|
+
// ── Call transcript notifier ─────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
test('call transcript notifier persists transcript line and emits events', () => {
|
|
344
|
+
ensureConversation('conv-notifier-t');
|
|
345
|
+
|
|
346
|
+
const emittedEvents: Array<{ type: string; text?: string }> = [];
|
|
347
|
+
const sendToClient = (msg: { type: string; text?: string }) => {
|
|
348
|
+
emittedEvents.push(msg);
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
registerCallTranscriptNotifier('conv-notifier-t', (_callSessionId: string, speaker: 'caller' | 'assistant', text: string) => {
|
|
352
|
+
const speakerLabel = speaker === 'caller' ? 'Caller' : 'Assistant';
|
|
353
|
+
const transcriptText = `**Live call transcript**\n${speakerLabel}: ${text}`;
|
|
354
|
+
conversationStore.addMessage(
|
|
355
|
+
'conv-notifier-t',
|
|
356
|
+
'assistant',
|
|
357
|
+
JSON.stringify([{ type: 'text', text: transcriptText }]),
|
|
358
|
+
);
|
|
359
|
+
sendToClient({ type: 'assistant_text_delta', text: transcriptText });
|
|
360
|
+
sendToClient({ type: 'message_complete' });
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
fireCallTranscriptNotifier('conv-notifier-t', 'call-session-1', 'caller', 'Can you confirm the appointment?');
|
|
364
|
+
|
|
365
|
+
const msgs = getMessagesForConversation('conv-notifier-t');
|
|
366
|
+
expect(msgs.length).toBe(1);
|
|
367
|
+
expect(msgs[0].role).toBe('assistant');
|
|
368
|
+
expect(msgs[0].content).toContain('Caller: Can you confirm the appointment?');
|
|
369
|
+
|
|
370
|
+
expect(emittedEvents.length).toBe(2);
|
|
371
|
+
expect(emittedEvents[0].type).toBe('assistant_text_delta');
|
|
372
|
+
expect(emittedEvents[0].text).toContain('Live call transcript');
|
|
373
|
+
expect(emittedEvents[1].type).toBe('message_complete');
|
|
374
|
+
|
|
375
|
+
unregisterCallTranscriptNotifier('conv-notifier-t');
|
|
376
|
+
});
|
|
377
|
+
|
|
338
378
|
// ── Call completion notifier ────────────────────────────────────
|
|
339
379
|
|
|
340
380
|
test('call completion notifier persists summary and emits events', () => {
|
|
@@ -10,6 +10,9 @@ import {
|
|
|
10
10
|
registerCallQuestionNotifier,
|
|
11
11
|
unregisterCallQuestionNotifier,
|
|
12
12
|
fireCallQuestionNotifier,
|
|
13
|
+
registerCallTranscriptNotifier,
|
|
14
|
+
unregisterCallTranscriptNotifier,
|
|
15
|
+
fireCallTranscriptNotifier,
|
|
13
16
|
registerCallCompletionNotifier,
|
|
14
17
|
unregisterCallCompletionNotifier,
|
|
15
18
|
fireCallCompletionNotifier,
|
|
@@ -23,6 +26,7 @@ describe('call-state', () => {
|
|
|
23
26
|
// Clean up notifiers between tests
|
|
24
27
|
beforeEach(() => {
|
|
25
28
|
unregisterCallQuestionNotifier('test-conv');
|
|
29
|
+
unregisterCallTranscriptNotifier('test-conv');
|
|
26
30
|
unregisterCallCompletionNotifier('test-conv');
|
|
27
31
|
unregisterCallOrchestrator('test-session');
|
|
28
32
|
});
|
|
@@ -62,6 +66,43 @@ describe('call-state', () => {
|
|
|
62
66
|
fireCallQuestionNotifier('unregistered-conv', 'session-1', 'question');
|
|
63
67
|
});
|
|
64
68
|
|
|
69
|
+
// ── Transcript notifiers ──────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
test('registerCallTranscriptNotifier + fireCallTranscriptNotifier: callback receives args', () => {
|
|
72
|
+
let receivedSessionId = '';
|
|
73
|
+
let receivedSpeaker = '';
|
|
74
|
+
let receivedText = '';
|
|
75
|
+
|
|
76
|
+
registerCallTranscriptNotifier('test-conv', (callSessionId, speaker, text) => {
|
|
77
|
+
receivedSessionId = callSessionId;
|
|
78
|
+
receivedSpeaker = speaker;
|
|
79
|
+
receivedText = text;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
fireCallTranscriptNotifier('test-conv', 'session-321', 'caller', 'Hello from caller');
|
|
83
|
+
|
|
84
|
+
expect(receivedSessionId).toBe('session-321');
|
|
85
|
+
expect(receivedSpeaker).toBe('caller');
|
|
86
|
+
expect(receivedText).toBe('Hello from caller');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('unregisterCallTranscriptNotifier: fire after unregister does nothing', () => {
|
|
90
|
+
let called = false;
|
|
91
|
+
|
|
92
|
+
registerCallTranscriptNotifier('test-conv', () => {
|
|
93
|
+
called = true;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
unregisterCallTranscriptNotifier('test-conv');
|
|
97
|
+
fireCallTranscriptNotifier('test-conv', 'session-321', 'assistant', 'Test');
|
|
98
|
+
|
|
99
|
+
expect(called).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('fireCallTranscriptNotifier does nothing when no notifier is registered', () => {
|
|
103
|
+
fireCallTranscriptNotifier('unregistered-conv', 'session-1', 'caller', 'text');
|
|
104
|
+
});
|
|
105
|
+
|
|
65
106
|
// ── Completion notifiers ──────────────────────────────────────────
|
|
66
107
|
|
|
67
108
|
test('registerCallCompletionNotifier + fireCallCompletionNotifier: callback receives callSessionId', () => {
|
|
@@ -41,13 +41,15 @@ describe('forbidden legacy symbols', () => {
|
|
|
41
41
|
const repoRoot = resolve(__dirname, '..', '..', '..');
|
|
42
42
|
let matches = '';
|
|
43
43
|
try {
|
|
44
|
+
// Use git grep so only tracked files are searched. This automatically
|
|
45
|
+
// excludes untracked local .env files while still scanning committed
|
|
46
|
+
// environment templates like .env.example.
|
|
44
47
|
matches = execSync(
|
|
45
|
-
`grep -rn -E "${escapedPattern}"
|
|
46
|
-
'
|
|
47
|
-
'
|
|
48
|
-
'
|
|
49
|
-
'
|
|
50
|
-
' .',
|
|
48
|
+
`git grep -rn -E "${escapedPattern}" --` +
|
|
49
|
+
' "*.ts" "*.tsx" "*.js" "*.mjs" "*.swift"' +
|
|
50
|
+
' "*.json" "*.md" "*.yml" "*.yaml"' +
|
|
51
|
+
' "*.sh" "*.env" "*.env.*"' +
|
|
52
|
+
' ":!node_modules" ":!*/__tests__/*" ":!.private"',
|
|
51
53
|
{ cwd: repoRoot, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] },
|
|
52
54
|
);
|
|
53
55
|
} catch (err: unknown) {
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tests for gateway-only ingress
|
|
2
|
+
* Tests for gateway-only ingress enforcement in the runtime HTTP server.
|
|
3
3
|
*
|
|
4
4
|
* Verifies:
|
|
5
|
-
* - Direct Twilio webhook routes return 410
|
|
6
|
-
* - Internal forwarding routes (gateway→runtime) still work
|
|
7
|
-
* - Relay WebSocket upgrade blocked for non-private-network origins (isPrivateNetworkOrigin)
|
|
8
|
-
* - Relay WebSocket upgrade allowed from private network peers/origins
|
|
9
|
-
* -
|
|
10
|
-
* - Startup warning when RUNTIME_HTTP_HOST is not loopback in gateway_only mode
|
|
5
|
+
* - Direct Twilio webhook routes return 410
|
|
6
|
+
* - Internal forwarding routes (gateway→runtime) still work
|
|
7
|
+
* - Relay WebSocket upgrade blocked for non-private-network origins (isPrivateNetworkOrigin)
|
|
8
|
+
* - Relay WebSocket upgrade allowed from private network peers/origins
|
|
9
|
+
* - Startup warning when RUNTIME_HTTP_HOST is not loopback
|
|
11
10
|
*/
|
|
12
11
|
import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test';
|
|
13
12
|
import { mkdtempSync, rmSync, realpathSync } from 'node:fs';
|
|
@@ -32,9 +31,6 @@ mock.module('../util/platform.js', () => ({
|
|
|
32
31
|
migrateToWorkspaceLayout: () => {},
|
|
33
32
|
}));
|
|
34
33
|
|
|
35
|
-
// Configurable ingress mode — tests toggle this between 'gateway_only' and 'compat'
|
|
36
|
-
let mockIngressMode: 'gateway_only' | 'compat' = 'compat';
|
|
37
|
-
|
|
38
34
|
const logMessages: { level: string; msg: string; args?: unknown }[] = [];
|
|
39
35
|
|
|
40
36
|
mock.module('../util/logger.js', () => ({
|
|
@@ -73,7 +69,6 @@ mock.module('../config/loader.js', () => ({
|
|
|
73
69
|
},
|
|
74
70
|
ingress: {
|
|
75
71
|
publicBaseUrl: 'https://test.example.com',
|
|
76
|
-
mode: mockIngressMode,
|
|
77
72
|
},
|
|
78
73
|
}),
|
|
79
74
|
getConfig: () => ({
|
|
@@ -85,7 +80,6 @@ mock.module('../config/loader.js', () => ({
|
|
|
85
80
|
secretDetection: { enabled: false },
|
|
86
81
|
ingress: {
|
|
87
82
|
publicBaseUrl: 'https://test.example.com',
|
|
88
|
-
mode: mockIngressMode,
|
|
89
83
|
},
|
|
90
84
|
}),
|
|
91
85
|
invalidateConfigCache: () => {},
|
|
@@ -171,9 +165,7 @@ describe('gateway-only ingress enforcement', () => {
|
|
|
171
165
|
|
|
172
166
|
// ── Direct Twilio webhook routes blocked in gateway_only mode ──────
|
|
173
167
|
|
|
174
|
-
describe('
|
|
175
|
-
beforeEach(() => { mockIngressMode = 'gateway_only'; });
|
|
176
|
-
afterEach(() => { mockIngressMode = 'compat'; });
|
|
168
|
+
describe('direct webhook routes are blocked', () => {
|
|
177
169
|
|
|
178
170
|
test('POST /webhooks/twilio/voice returns 410', async () => {
|
|
179
171
|
const res = await fetch(`http://127.0.0.1:${port}/webhooks/twilio/voice`, {
|
|
@@ -232,11 +224,9 @@ describe('gateway-only ingress enforcement', () => {
|
|
|
232
224
|
});
|
|
233
225
|
});
|
|
234
226
|
|
|
235
|
-
// ── Internal forwarding routes still work
|
|
227
|
+
// ── Internal forwarding routes still work ─────
|
|
236
228
|
|
|
237
|
-
describe('
|
|
238
|
-
beforeEach(() => { mockIngressMode = 'gateway_only'; });
|
|
239
|
-
afterEach(() => { mockIngressMode = 'compat'; });
|
|
229
|
+
describe('internal forwarding routes are not blocked', () => {
|
|
240
230
|
|
|
241
231
|
test('POST /v1/internal/twilio/voice-webhook is NOT blocked', async () => {
|
|
242
232
|
const res = await fetch(`http://127.0.0.1:${port}/v1/internal/twilio/voice-webhook`, {
|
|
@@ -288,11 +278,9 @@ describe('gateway-only ingress enforcement', () => {
|
|
|
288
278
|
});
|
|
289
279
|
});
|
|
290
280
|
|
|
291
|
-
// ── Relay WebSocket upgrade
|
|
281
|
+
// ── Relay WebSocket upgrade ───────────────────
|
|
292
282
|
|
|
293
|
-
describe('
|
|
294
|
-
beforeEach(() => { mockIngressMode = 'gateway_only'; });
|
|
295
|
-
afterEach(() => { mockIngressMode = 'compat'; });
|
|
283
|
+
describe('relay WebSocket upgrade', () => {
|
|
296
284
|
|
|
297
285
|
test('blocks non-private-network origin', async () => {
|
|
298
286
|
// The peer address (127.0.0.1) passes the private network check,
|
|
@@ -343,66 +331,6 @@ describe('gateway-only ingress enforcement', () => {
|
|
|
343
331
|
});
|
|
344
332
|
});
|
|
345
333
|
|
|
346
|
-
// ── Compat mode — everything works as before ───────────────────────
|
|
347
|
-
|
|
348
|
-
describe('compat mode — no enforcement', () => {
|
|
349
|
-
beforeEach(() => { mockIngressMode = 'compat'; });
|
|
350
|
-
|
|
351
|
-
test('POST /webhooks/twilio/voice is NOT blocked', async () => {
|
|
352
|
-
// In compat mode, disable webhook validation to focus on the ingress check
|
|
353
|
-
const savedDisable = process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED;
|
|
354
|
-
process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED = 'true';
|
|
355
|
-
try {
|
|
356
|
-
const res = await fetch(`http://127.0.0.1:${port}/webhooks/twilio/voice?callSessionId=test-compat`, {
|
|
357
|
-
method: 'POST',
|
|
358
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
359
|
-
body: makeFormBody({ CallSid: 'CA_compat', AccountSid: 'AC_test' }),
|
|
360
|
-
});
|
|
361
|
-
// Should NOT be 410 (gateway-only)
|
|
362
|
-
expect(res.status).not.toBe(410);
|
|
363
|
-
} finally {
|
|
364
|
-
if (savedDisable !== undefined) {
|
|
365
|
-
process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED = savedDisable;
|
|
366
|
-
} else {
|
|
367
|
-
delete process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED;
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
});
|
|
371
|
-
|
|
372
|
-
test('POST /webhooks/twilio/status is NOT blocked', async () => {
|
|
373
|
-
const savedDisable = process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED;
|
|
374
|
-
process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED = 'true';
|
|
375
|
-
try {
|
|
376
|
-
const res = await fetch(`http://127.0.0.1:${port}/webhooks/twilio/status`, {
|
|
377
|
-
method: 'POST',
|
|
378
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
379
|
-
body: makeFormBody({ CallSid: 'CA_compat', CallStatus: 'completed' }),
|
|
380
|
-
});
|
|
381
|
-
expect(res.status).not.toBe(410);
|
|
382
|
-
} finally {
|
|
383
|
-
if (savedDisable !== undefined) {
|
|
384
|
-
process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED = savedDisable;
|
|
385
|
-
} else {
|
|
386
|
-
delete process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED;
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
});
|
|
390
|
-
|
|
391
|
-
test('relay WebSocket upgrade is NOT blocked for external origin', async () => {
|
|
392
|
-
const res = await fetch(`http://127.0.0.1:${port}/v1/calls/relay?callSessionId=sess-compat`, {
|
|
393
|
-
headers: {
|
|
394
|
-
'Upgrade': 'websocket',
|
|
395
|
-
'Connection': 'Upgrade',
|
|
396
|
-
'Origin': 'https://external.example.com',
|
|
397
|
-
'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
|
|
398
|
-
'Sec-WebSocket-Version': '13',
|
|
399
|
-
},
|
|
400
|
-
});
|
|
401
|
-
// In compat mode, the gateway-only guard should not activate
|
|
402
|
-
expect(res.status).not.toBe(403);
|
|
403
|
-
});
|
|
404
|
-
});
|
|
405
|
-
|
|
406
334
|
// ── isPrivateAddress unit tests ─────────────────────────────────────
|
|
407
335
|
|
|
408
336
|
describe('isPrivateAddress', () => {
|
|
@@ -483,8 +411,7 @@ describe('gateway-only ingress enforcement', () => {
|
|
|
483
411
|
// ── Startup warning for non-loopback host ──────────────────────────
|
|
484
412
|
|
|
485
413
|
describe('startup guard — non-loopback host warning', () => {
|
|
486
|
-
test('logs warning when hostname is not loopback
|
|
487
|
-
mockIngressMode = 'gateway_only';
|
|
414
|
+
test('logs warning when hostname is not loopback', async () => {
|
|
488
415
|
logMessages.length = 0;
|
|
489
416
|
|
|
490
417
|
const warnServer = new RuntimeHttpServer({
|
|
@@ -505,11 +432,9 @@ describe('gateway-only ingress enforcement', () => {
|
|
|
505
432
|
expect(warnMsg).toBeDefined();
|
|
506
433
|
|
|
507
434
|
await warnServer.stop();
|
|
508
|
-
mockIngressMode = 'compat';
|
|
509
435
|
});
|
|
510
436
|
|
|
511
|
-
test('does NOT log warning when hostname is loopback
|
|
512
|
-
mockIngressMode = 'gateway_only';
|
|
437
|
+
test('does NOT log warning when hostname is loopback', async () => {
|
|
513
438
|
logMessages.length = 0;
|
|
514
439
|
|
|
515
440
|
// The main test server already uses 127.0.0.1, so restart with
|
|
@@ -532,7 +457,6 @@ describe('gateway-only ingress enforcement', () => {
|
|
|
532
457
|
expect(warnMsg).toBeUndefined();
|
|
533
458
|
|
|
534
459
|
await loopbackServer.stop();
|
|
535
|
-
mockIngressMode = 'compat';
|
|
536
460
|
});
|
|
537
461
|
});
|
|
538
462
|
});
|
|
@@ -49,29 +49,34 @@ describe('home base bootstrap', () => {
|
|
|
49
49
|
|
|
50
50
|
test('creates a durable Home Base link on first bootstrap', () => {
|
|
51
51
|
const result = bootstrapHomeBaseAppLink();
|
|
52
|
+
expect(result).not.toBeNull();
|
|
52
53
|
const link = getHomeBaseAppLink();
|
|
53
54
|
|
|
54
|
-
expect(result
|
|
55
|
+
expect(result!.linked).toBe(true);
|
|
55
56
|
expect(link).not.toBeNull();
|
|
56
|
-
expect(link?.appId).toBe(result
|
|
57
|
-
expect(resolveHomeBaseAppId()).toBe(result
|
|
57
|
+
expect(link?.appId).toBe(result!.appId);
|
|
58
|
+
expect(resolveHomeBaseAppId()).toBe(result!.appId);
|
|
58
59
|
});
|
|
59
60
|
|
|
60
61
|
test('reuses existing link on repeated bootstrap calls', () => {
|
|
61
62
|
const first = bootstrapHomeBaseAppLink();
|
|
62
63
|
const second = bootstrapHomeBaseAppLink();
|
|
64
|
+
expect(first).not.toBeNull();
|
|
65
|
+
expect(second).not.toBeNull();
|
|
63
66
|
|
|
64
|
-
expect(second
|
|
65
|
-
expect(second
|
|
67
|
+
expect(second!.appId).toBe(first!.appId);
|
|
68
|
+
expect(second!.created).toBe(false);
|
|
66
69
|
});
|
|
67
70
|
|
|
68
71
|
test('relinks when stored app id is stale', () => {
|
|
69
72
|
const first = bootstrapHomeBaseAppLink();
|
|
70
|
-
|
|
73
|
+
expect(first).not.toBeNull();
|
|
74
|
+
deleteApp(first!.appId);
|
|
71
75
|
|
|
72
76
|
const second = bootstrapHomeBaseAppLink();
|
|
77
|
+
expect(second).not.toBeNull();
|
|
73
78
|
|
|
74
|
-
expect(second
|
|
75
|
-
expect(getHomeBaseAppLink()?.appId).toBe(second
|
|
79
|
+
expect(second!.appId).not.toBe(first!.appId);
|
|
80
|
+
expect(getHomeBaseAppLink()?.appId).toBe(second!.appId);
|
|
76
81
|
});
|
|
77
82
|
});
|
|
@@ -159,13 +159,10 @@ describe('Task/Schedule/Reminder routing section in system prompt', () => {
|
|
|
159
159
|
expect(prompt).toContain('"Remind me at 5pm to buy groceries" → reminder_create');
|
|
160
160
|
});
|
|
161
161
|
|
|
162
|
-
test('routing section
|
|
162
|
+
test('routing section is present in the system prompt', () => {
|
|
163
163
|
const prompt = buildSystemPrompt();
|
|
164
|
-
const contentTypeIdx = prompt.indexOf('## Tool Routing by Content Type');
|
|
165
164
|
const taskRoutingIdx = prompt.indexOf('## Tool Routing: Tasks vs Schedules vs Reminders');
|
|
166
|
-
|
|
167
|
-
expect(contentTypeIdx).toBeGreaterThanOrEqual(0);
|
|
168
|
-
expect(taskRoutingIdx).toBeGreaterThan(contentTypeIdx);
|
|
165
|
+
expect(taskRoutingIdx).toBeGreaterThanOrEqual(0);
|
|
169
166
|
});
|
|
170
167
|
});
|
|
171
168
|
|