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.
Files changed (60) hide show
  1. package/bun.lock +2 -2
  2. package/package.json +2 -2
  3. package/scripts/capture-x-graphql.ts +1 -18
  4. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +110 -0
  5. package/src/__tests__/call-bridge.test.ts +40 -0
  6. package/src/__tests__/call-state.test.ts +41 -0
  7. package/src/__tests__/forbidden-legacy-symbols.test.ts +8 -6
  8. package/src/__tests__/gateway-only-enforcement.test.ts +13 -89
  9. package/src/__tests__/home-base-bootstrap.test.ts +13 -8
  10. package/src/__tests__/intent-routing.test.ts +2 -5
  11. package/src/__tests__/ipc-snapshot.test.ts +49 -0
  12. package/src/__tests__/onboarding-starter-tasks.test.ts +12 -2
  13. package/src/__tests__/prebuilt-home-base-seed.test.ts +9 -5
  14. package/src/__tests__/relay-server.test.ts +55 -0
  15. package/src/__tests__/skills.test.ts +83 -0
  16. package/src/__tests__/system-prompt.test.ts +2 -24
  17. package/src/__tests__/twilio-provider.test.ts +36 -0
  18. package/src/__tests__/twilio-routes.test.ts +108 -0
  19. package/src/calls/call-orchestrator.ts +25 -5
  20. package/src/calls/call-state.ts +23 -0
  21. package/src/calls/relay-server.ts +56 -1
  22. package/src/calls/twilio-config.ts +9 -13
  23. package/src/calls/twilio-provider.ts +6 -1
  24. package/src/calls/twilio-routes.ts +10 -1
  25. package/src/cli/core-commands.ts +12 -4
  26. package/src/config/bundled-skills/app-builder/SKILL.md +57 -1
  27. package/src/config/bundled-skills/document/SKILL.md +11 -3
  28. package/src/config/bundled-skills/followups/icon.svg +24 -0
  29. package/src/config/bundled-skills/messaging/SKILL.md +7 -3
  30. package/src/config/bundled-skills/public-ingress/SKILL.md +183 -0
  31. package/src/config/bundled-skills/self-upgrade/SKILL.md +4 -10
  32. package/src/config/defaults.ts +1 -1
  33. package/src/config/schema.ts +4 -7
  34. package/src/config/system-prompt.ts +64 -360
  35. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +5 -1
  36. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +5 -1
  37. package/src/config/vellum-skills/telegram-setup/SKILL.md +2 -1
  38. package/src/daemon/handlers/config.ts +20 -9
  39. package/src/daemon/handlers/home-base.ts +3 -2
  40. package/src/daemon/handlers/identity.ts +127 -0
  41. package/src/daemon/handlers/index.ts +4 -0
  42. package/src/daemon/handlers/workspace-files.ts +75 -0
  43. package/src/daemon/ipc-contract-inventory.json +16 -4
  44. package/src/daemon/ipc-contract.ts +62 -2
  45. package/src/daemon/session-notifiers.ts +29 -0
  46. package/src/daemon/session-surfaces.ts +5 -2
  47. package/src/daemon/session-tool-setup.ts +15 -4
  48. package/src/home-base/bootstrap.ts +3 -1
  49. package/src/home-base/prebuilt/seed.ts +16 -5
  50. package/src/inbound/public-ingress-urls.ts +15 -4
  51. package/src/runtime/http-server.ts +123 -20
  52. package/src/security/oauth2.ts +19 -161
  53. package/src/tools/browser/auto-navigate.ts +2 -2
  54. package/src/tools/browser/x-auto-navigate.ts +1 -1
  55. package/src/tools/claude-code/claude-code.ts +1 -1
  56. package/src/tools/system/version.ts +43 -0
  57. package/src/tools/tasks/work-item-run.ts +1 -1
  58. package/src/tools/terminal/parser.ts +29 -7
  59. package/src/tools/tool-manifest.ts +2 -0
  60. 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.9",
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.9", "", { "dependencies": { "ink": "^6.7.0", "react": "^19.2.4" }, "bin": { "vellum-cli": "src/index.ts" } }, "sha512-R5f9e6K2w+k5AwicpM4lNaWnXWaD9MvCRv6YCS+c7KuMUx8yD/jzRzSJTX1cIRNLcyty1bHrpInOQ2Wl5JeZDw=="],
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.9",
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.9",
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 ? cb.reject(new Error(msg.error.message)) : cb.resolve(msg.result); }
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
- ' --include="*.ts" --include="*.tsx" --include="*.js" --include="*.mjs" --include="*.swift"' +
47
- ' --include="*.json" --include="*.md" --include="*.yml" --include="*.yaml"' +
48
- ' --include="*.sh"' +
49
- ' --exclude-dir=node_modules --exclude-dir=__tests__ --exclude-dir=.private' +
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 mode enforcement in the runtime HTTP server.
2
+ * Tests for gateway-only ingress enforcement in the runtime HTTP server.
3
3
  *
4
4
  * Verifies:
5
- * - Direct Twilio webhook routes return 410 in gateway_only mode
6
- * - Internal forwarding routes (gateway→runtime) still work in gateway_only mode
7
- * - Relay WebSocket upgrade blocked for non-private-network origins (isPrivateNetworkOrigin) in gateway_only mode
8
- * - Relay WebSocket upgrade allowed from private network peers/origins in gateway_only mode
9
- * - All routes work normally in compat mode
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('gateway_only mode — direct webhook routes', () => {
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 in gateway_only mode ─────
227
+ // ── Internal forwarding routes still work ─────
236
228
 
237
- describe('gateway_only mode internal forwarding routes', () => {
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 in gateway_only mode ───────────────────
281
+ // ── Relay WebSocket upgrade ───────────────────
292
282
 
293
- describe('gateway_only mode — relay WebSocket upgrade', () => {
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 in gateway_only mode', async () => {
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 in gateway_only mode', async () => {
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.linked).toBe(true);
55
+ expect(result!.linked).toBe(true);
55
56
  expect(link).not.toBeNull();
56
- expect(link?.appId).toBe(result.appId);
57
- expect(resolveHomeBaseAppId()).toBe(result.appId);
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.appId).toBe(first.appId);
65
- expect(second.created).toBe(false);
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
- deleteApp(first.appId);
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.appId).not.toBe(first.appId);
75
- expect(getHomeBaseAppLink()?.appId).toBe(second.appId);
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 appears after tool routing by content type', () => {
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
- // Both must be present
167
- expect(contentTypeIdx).toBeGreaterThanOrEqual(0);
168
- expect(taskRoutingIdx).toBeGreaterThan(contentTypeIdx);
165
+ expect(taskRoutingIdx).toBeGreaterThanOrEqual(0);
169
166
  });
170
167
  });
171
168