vellum 0.2.8 → 0.2.9

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 (55) hide show
  1. package/bun.lock +2 -2
  2. package/package.json +3 -2
  3. package/src/__tests__/config-schema.test.ts +0 -6
  4. package/src/__tests__/forbidden-legacy-symbols.test.ts +69 -0
  5. package/src/__tests__/gateway-only-enforcement.test.ts +91 -11
  6. package/src/__tests__/ingress-url-consistency.test.ts +214 -0
  7. package/src/__tests__/ipc-snapshot.test.ts +17 -16
  8. package/src/__tests__/oauth2-gateway-transport.test.ts +7 -1
  9. package/src/__tests__/public-ingress-urls.test.ts +50 -34
  10. package/src/__tests__/runtime-events-sse-parity.test.ts +343 -0
  11. package/src/__tests__/runtime-events-sse.test.ts +162 -0
  12. package/src/__tests__/twilio-provider.test.ts +1 -1
  13. package/src/__tests__/twilio-routes.test.ts +4 -4
  14. package/src/__tests__/twitter-auth-handler.test.ts +87 -2
  15. package/src/calls/call-domain.ts +8 -6
  16. package/src/calls/twilio-config.ts +2 -3
  17. package/src/config/bundled-skills/tasks/TOOLS.json +25 -0
  18. package/src/config/bundled-skills/tasks/tools/task-queue-run.ts +9 -0
  19. package/src/config/bundled-skills/transcribe/SKILL.md +25 -0
  20. package/src/config/bundled-skills/transcribe/TOOLS.json +32 -0
  21. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +370 -0
  22. package/src/config/defaults.ts +1 -2
  23. package/src/config/schema.ts +2 -6
  24. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +5 -4
  25. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +4 -2
  26. package/src/config/vellum-skills/telegram-setup/SKILL.md +3 -3
  27. package/src/daemon/handlers/config.ts +33 -50
  28. package/src/daemon/handlers/shared.ts +1 -0
  29. package/src/daemon/handlers/subagents.ts +85 -2
  30. package/src/daemon/handlers/twitter-auth.ts +31 -2
  31. package/src/daemon/ipc-contract-inventory.json +4 -4
  32. package/src/daemon/ipc-contract.ts +25 -21
  33. package/src/daemon/lifecycle.ts +9 -4
  34. package/src/daemon/server.ts +7 -0
  35. package/src/daemon/session-tool-setup.ts +1 -1
  36. package/src/inbound/public-ingress-urls.ts +36 -30
  37. package/src/memory/db.ts +132 -5
  38. package/src/memory/llm-usage-store.ts +0 -1
  39. package/src/memory/runs-store.ts +51 -3
  40. package/src/memory/schema.ts +2 -2
  41. package/src/runtime/gateway-client.ts +7 -1
  42. package/src/runtime/http-server.ts +95 -10
  43. package/src/runtime/routes/channel-routes.ts +7 -2
  44. package/src/runtime/routes/events-routes.ts +79 -0
  45. package/src/runtime/routes/run-routes.ts +43 -0
  46. package/src/runtime/run-orchestrator.ts +64 -7
  47. package/src/security/oauth-callback-registry.ts +10 -0
  48. package/src/security/oauth2.ts +41 -7
  49. package/src/subagent/manager.ts +3 -1
  50. package/src/tools/tasks/work-item-run.ts +78 -0
  51. package/src/util/platform.ts +1 -1
  52. package/src/work-items/work-item-runner.ts +171 -0
  53. package/src/__tests__/handlers-twilio-config.test.ts +0 -221
  54. package/src/calls/__tests__/twilio-webhook-urls.test.ts +0 -162
  55. package/src/calls/twilio-webhook-urls.ts +0 -47
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.8",
14
+ "@vellumai/cli": "0.1.9",
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.8", "", { "dependencies": { "ink": "^6.7.0", "react": "^19.2.4" }, "bin": { "vellum-cli": "src/index.ts" } }, "sha512-36P6W1rV1dK9Qg0N3oFjffz1CSarkGBgqc2ZGEI1de9RPSkVWBI0QmKCZrPeg1/qDzEh3InUIsV1qjNIe7z1pg=="],
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=="],
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,12 +1,13 @@
1
1
  {
2
2
  "name": "vellum",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "vellum": "./src/index.ts"
7
7
  },
8
8
  "scripts": {
9
9
  "dev": "bun run src/index.ts",
10
+ "daemon:restart:http": "RUNTIME_HTTP_PORT=7821 bun run src/index.ts daemon restart",
10
11
  "db:generate": "drizzle-kit generate",
11
12
  "db:push": "drizzle-kit push",
12
13
  "ipc:inventory": "bun run scripts/ipc/check-contract-inventory.ts",
@@ -28,7 +29,7 @@
28
29
  "@huggingface/transformers": "^3.8.1",
29
30
  "@qdrant/js-client-rest": "^1.16.2",
30
31
  "@sentry/node": "^10.38.0",
31
- "@vellumai/cli": "0.1.8",
32
+ "@vellumai/cli": "0.1.9",
32
33
  "@vellumai/vellum-gateway": "0.1.10",
33
34
  "agentmail": "^0.1.0",
34
35
  "archiver": "^7.0.1",
@@ -646,7 +646,6 @@ describe('AssistantConfigSchema', () => {
646
646
  expect(result.calls).toEqual({
647
647
  enabled: true,
648
648
  provider: 'twilio',
649
- webhookBaseUrl: '',
650
649
  maxDurationSeconds: 3600,
651
650
  userConsultTimeoutSeconds: 120,
652
651
  disclosure: {
@@ -659,11 +658,6 @@ describe('AssistantConfigSchema', () => {
659
658
  });
660
659
  });
661
660
 
662
- test('calls.webhookBaseUrl defaults to empty string', () => {
663
- const result = AssistantConfigSchema.parse({});
664
- expect(result.calls.webhookBaseUrl).toBe('');
665
- });
666
-
667
661
  test('accepts valid calls config overrides', () => {
668
662
  const result = AssistantConfigSchema.parse({
669
663
  calls: {
@@ -0,0 +1,69 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import { execSync } from 'node:child_process';
3
+ import { resolve } from 'node:path';
4
+
5
+ /**
6
+ * Guard test: fail if any legacy Twilio ingress symbols reappear in
7
+ * production source code, docs, configs, or scripts.
8
+ *
9
+ * Context: As part of the gateway-only ingress migration (#5948, #6000),
10
+ * all Twilio webhook configuration was consolidated into the gateway service.
11
+ * The assistant no longer manages its own Twilio webhook URLs — the gateway
12
+ * is the single ingress point for all telephony webhooks. Re-introducing
13
+ * these symbols in the assistant would bypass that architecture and create
14
+ * a split-brain ingress problem.
15
+ *
16
+ * Forbidden symbols:
17
+ * - legacy uppercase Twilio webhook base env var
18
+ * - twilioWebhookBaseUrl
19
+ * - twilio_webhook_config
20
+ * - calls.webhookBaseUrl
21
+ *
22
+ * Excluded directories:
23
+ * - node_modules — third-party code, not under our control
24
+ * - __tests__ — test files (including this guard test) reference the
25
+ * symbols in grep patterns and assertions
26
+ * - .private — local-only developer notes and scratch files
27
+ */
28
+ describe('forbidden legacy symbols', () => {
29
+ test('no production code references removed Twilio ingress symbols', () => {
30
+ const legacyEnvVar = ['TWILIO', 'WEBHOOK', 'BASE', 'URL'].join('_');
31
+ const forbiddenSymbols = [
32
+ legacyEnvVar,
33
+ 'twilioWebhookBaseUrl',
34
+ 'twilio_webhook_config',
35
+ 'calls.webhookBaseUrl',
36
+ ];
37
+ const escapedPattern = forbiddenSymbols
38
+ .map((symbol) => symbol.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
39
+ .join('|');
40
+
41
+ const repoRoot = resolve(__dirname, '..', '..', '..');
42
+ let matches = '';
43
+ try {
44
+ 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
+ ' .',
51
+ { cwd: repoRoot, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] },
52
+ );
53
+ } catch (err: unknown) {
54
+ // grep exits with code 1 when no matches are found — that is the expected (passing) case
55
+ const exitCode = (err as { status?: number }).status;
56
+ if (exitCode === 1) {
57
+ // No matches found — test passes
58
+ return;
59
+ }
60
+ // Any other error is unexpected
61
+ throw err;
62
+ }
63
+
64
+ // If we reach here, grep found matches (exit code 0) — fail the test
65
+ expect(matches.trim()).toBe(
66
+ '', // should be empty — if not, the matched lines appear in the failure message
67
+ );
68
+ });
69
+ });
@@ -4,8 +4,8 @@
4
4
  * Verifies:
5
5
  * - Direct Twilio webhook routes return 410 in gateway_only mode
6
6
  * - Internal forwarding routes (gateway→runtime) still work in gateway_only mode
7
- * - Relay WebSocket upgrade blocked for non-localhost origins in gateway_only mode
8
- * - Relay WebSocket upgrade allowed from localhost 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
9
  * - All routes work normally in compat mode
10
10
  * - Startup warning when RUNTIME_HTTP_HOST is not loopback in gateway_only mode
11
11
  */
@@ -133,7 +133,7 @@ mock.module('../security/oauth-callback-registry.js', () => ({
133
133
  consumeCallbackError: () => true,
134
134
  }));
135
135
 
136
- import { RuntimeHttpServer } from '../runtime/http-server.js';
136
+ import { RuntimeHttpServer, isPrivateAddress } from '../runtime/http-server.js';
137
137
 
138
138
  // ---------------------------------------------------------------------------
139
139
  // Helpers
@@ -156,13 +156,13 @@ describe('gateway-only ingress enforcement', () => {
156
156
 
157
157
  beforeEach(async () => {
158
158
  logMessages.length = 0;
159
- port = 17800 + Math.floor(Math.random() * 1000);
160
159
  server = new RuntimeHttpServer({
161
- port,
160
+ port: 0,
162
161
  hostname: '127.0.0.1',
163
162
  bearerToken: TEST_TOKEN,
164
163
  });
165
164
  await server.start();
165
+ port = server.actualPort;
166
166
  });
167
167
 
168
168
  afterEach(async () => {
@@ -294,7 +294,9 @@ describe('gateway-only ingress enforcement', () => {
294
294
  beforeEach(() => { mockIngressMode = 'gateway_only'; });
295
295
  afterEach(() => { mockIngressMode = 'compat'; });
296
296
 
297
- test('blocks non-localhost origin', async () => {
297
+ test('blocks non-private-network origin', async () => {
298
+ // The peer address (127.0.0.1) passes the private network check,
299
+ // but the external Origin header triggers the secondary defense-in-depth block.
298
300
  const res = await fetch(`http://127.0.0.1:${port}/v1/calls/relay?callSessionId=sess-123`, {
299
301
  headers: {
300
302
  'Upgrade': 'websocket',
@@ -310,8 +312,9 @@ describe('gateway-only ingress enforcement', () => {
310
312
  expect(body.error).toContain('gateway-only mode');
311
313
  });
312
314
 
313
- test('allows request with no origin header (localhost)', async () => {
314
- // Without an origin header, isLoopbackOrigin returns true
315
+ test('allows request with no origin header (private network peer)', async () => {
316
+ // Without an origin header, isPrivateNetworkOrigin returns true.
317
+ // The peer address (127.0.0.1) passes the private network peer check.
315
318
  const res = await fetch(`http://127.0.0.1:${port}/v1/calls/relay?callSessionId=sess-123`, {
316
319
  headers: {
317
320
  'Upgrade': 'websocket',
@@ -325,7 +328,7 @@ describe('gateway-only ingress enforcement', () => {
325
328
  expect(res.status).not.toBe(403);
326
329
  });
327
330
 
328
- test('allows localhost origin', async () => {
331
+ test('allows localhost origin from loopback peer', async () => {
329
332
  const res = await fetch(`http://127.0.0.1:${port}/v1/calls/relay?callSessionId=sess-123`, {
330
333
  headers: {
331
334
  'Upgrade': 'websocket',
@@ -400,6 +403,83 @@ describe('gateway-only ingress enforcement', () => {
400
403
  });
401
404
  });
402
405
 
406
+ // ── isPrivateAddress unit tests ─────────────────────────────────────
407
+
408
+ describe('isPrivateAddress', () => {
409
+ // Loopback
410
+ test.each([
411
+ '127.0.0.1',
412
+ '127.0.0.2',
413
+ '127.255.255.255',
414
+ '::1',
415
+ '::ffff:127.0.0.1',
416
+ ])('accepts loopback address %s', (addr) => {
417
+ expect(isPrivateAddress(addr)).toBe(true);
418
+ });
419
+
420
+ // RFC 1918 private ranges
421
+ test.each([
422
+ '10.0.0.1',
423
+ '10.255.255.255',
424
+ '172.16.0.1',
425
+ '172.31.255.255',
426
+ '192.168.0.1',
427
+ '192.168.1.100',
428
+ ])('accepts RFC 1918 private address %s', (addr) => {
429
+ expect(isPrivateAddress(addr)).toBe(true);
430
+ });
431
+
432
+ // Link-local
433
+ test.each([
434
+ '169.254.0.1',
435
+ '169.254.255.255',
436
+ ])('accepts link-local address %s', (addr) => {
437
+ expect(isPrivateAddress(addr)).toBe(true);
438
+ });
439
+
440
+ // IPv6 unique local (fc00::/7)
441
+ test.each([
442
+ 'fc00::1',
443
+ 'fd12:3456:789a::1',
444
+ 'fdff::1',
445
+ ])('accepts IPv6 unique local address %s', (addr) => {
446
+ expect(isPrivateAddress(addr)).toBe(true);
447
+ });
448
+
449
+ // IPv6 link-local (fe80::/10)
450
+ test.each([
451
+ 'fe80::1',
452
+ 'fe80::abcd:1234',
453
+ ])('accepts IPv6 link-local address %s', (addr) => {
454
+ expect(isPrivateAddress(addr)).toBe(true);
455
+ });
456
+
457
+ // IPv4-mapped IPv6 private addresses
458
+ test.each([
459
+ '::ffff:10.0.0.1',
460
+ '::ffff:172.16.0.1',
461
+ '::ffff:192.168.1.1',
462
+ '::ffff:169.254.0.1',
463
+ ])('accepts IPv4-mapped IPv6 private address %s', (addr) => {
464
+ expect(isPrivateAddress(addr)).toBe(true);
465
+ });
466
+
467
+ // Public addresses — should be rejected
468
+ test.each([
469
+ '8.8.8.8',
470
+ '1.1.1.1',
471
+ '203.0.113.1',
472
+ '172.32.0.1',
473
+ '172.15.255.255',
474
+ '11.0.0.1',
475
+ '192.169.0.1',
476
+ '::ffff:8.8.8.8',
477
+ '2001:db8::1',
478
+ ])('rejects public address %s', (addr) => {
479
+ expect(isPrivateAddress(addr)).toBe(false);
480
+ });
481
+ });
482
+
403
483
  // ── Startup warning for non-loopback host ──────────────────────────
404
484
 
405
485
  describe('startup guard — non-loopback host warning', () => {
@@ -408,7 +488,7 @@ describe('gateway-only ingress enforcement', () => {
408
488
  logMessages.length = 0;
409
489
 
410
490
  const warnServer = new RuntimeHttpServer({
411
- port: port + 100,
491
+ port: 0,
412
492
  hostname: '0.0.0.0',
413
493
  bearerToken: TEST_TOKEN,
414
494
  });
@@ -435,7 +515,7 @@ describe('gateway-only ingress enforcement', () => {
435
515
  // The main test server already uses 127.0.0.1, so restart with
436
516
  // a fresh server and capture logs
437
517
  const loopbackServer = new RuntimeHttpServer({
438
- port: port + 200,
518
+ port: 0,
439
519
  hostname: '127.0.0.1',
440
520
  bearerToken: TEST_TOKEN,
441
521
  });
@@ -0,0 +1,214 @@
1
+ import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test';
2
+ import { createHmac } from 'node:crypto';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Mocks — silence logger output during tests
6
+ // ---------------------------------------------------------------------------
7
+
8
+ function makeLoggerStub(): Record<string, unknown> {
9
+ const stub: Record<string, unknown> = {};
10
+ for (const m of ['info', 'warn', 'error', 'debug', 'trace', 'fatal', 'silent', 'child']) {
11
+ stub[m] = m === 'child' ? () => makeLoggerStub() : () => {};
12
+ }
13
+ return stub;
14
+ }
15
+
16
+ mock.module('../util/logger.js', () => ({
17
+ getLogger: () => makeLoggerStub(),
18
+ }));
19
+
20
+ import {
21
+ getPublicBaseUrl,
22
+ getTwilioVoiceWebhookUrl,
23
+ getTwilioStatusCallbackUrl,
24
+ type IngressConfig,
25
+ } from '../inbound/public-ingress-urls.js';
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Helpers — simulate Twilio signature validation the same way the gateway does
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /**
32
+ * Reproduce the gateway's canonical URL reconstruction logic from
33
+ * gateway/src/twilio/validate-webhook.ts (lines 72-76).
34
+ */
35
+ function reconstructGatewayCanonicalUrl(
36
+ ingressPublicBaseUrl: string | undefined,
37
+ requestUrl: string,
38
+ ): string {
39
+ const parsedUrl = new URL(requestUrl);
40
+ if (ingressPublicBaseUrl) {
41
+ return ingressPublicBaseUrl.replace(/\/$/, '') + parsedUrl.pathname + parsedUrl.search;
42
+ }
43
+ return requestUrl;
44
+ }
45
+
46
+ /**
47
+ * Reproduce Twilio's HMAC-SHA1 signature algorithm (same as
48
+ * gateway/src/twilio/verify.ts).
49
+ */
50
+ function computeTwilioSignature(
51
+ url: string,
52
+ params: Record<string, string>,
53
+ authToken: string,
54
+ ): string {
55
+ const sortedKeys = Object.keys(params).sort();
56
+ let data = url;
57
+ for (const key of sortedKeys) {
58
+ data += key + params[key];
59
+ }
60
+ return createHmac('sha1', authToken).update(data).digest('base64');
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Tests
65
+ // ---------------------------------------------------------------------------
66
+
67
+ describe('Ingress URL consistency between assistant and gateway', () => {
68
+ let savedIngressEnv: string | undefined;
69
+
70
+ beforeEach(() => {
71
+ savedIngressEnv = process.env.INGRESS_PUBLIC_BASE_URL;
72
+ delete process.env.INGRESS_PUBLIC_BASE_URL;
73
+ });
74
+
75
+ afterEach(() => {
76
+ if (savedIngressEnv !== undefined) {
77
+ process.env.INGRESS_PUBLIC_BASE_URL = savedIngressEnv;
78
+ } else {
79
+ delete process.env.INGRESS_PUBLIC_BASE_URL;
80
+ }
81
+ });
82
+
83
+ test('assistant callback URL and gateway signature reconstruction use same base when config is set', () => {
84
+ const config: IngressConfig = {
85
+ ingress: { publicBaseUrl: 'https://my-tunnel.ngrok.io' },
86
+ };
87
+
88
+ // What the assistant would generate as the Twilio voice webhook callback
89
+ const assistantCallbackUrl = getTwilioVoiceWebhookUrl(config, 'session-abc');
90
+
91
+ // Simulate: when hatch.ts spawns the gateway, it reads config.ingress.publicBaseUrl
92
+ // and passes it as INGRESS_PUBLIC_BASE_URL. The gateway stores this as
93
+ // config.ingressPublicBaseUrl.
94
+ const gatewayIngressPublicBaseUrl = getPublicBaseUrl(config);
95
+
96
+ // When Twilio calls the gateway, the gateway reconstructs the canonical URL
97
+ // from the inbound request URL (which is localhost) + the configured base.
98
+ const inboundRequestUrl = 'http://127.0.0.1:7830/webhooks/twilio/voice?callSessionId=session-abc';
99
+ const gatewayCanonicalUrl = reconstructGatewayCanonicalUrl(
100
+ gatewayIngressPublicBaseUrl,
101
+ inboundRequestUrl,
102
+ );
103
+
104
+ // Both must resolve to the same URL for Twilio signatures to validate
105
+ expect(gatewayCanonicalUrl).toBe(assistantCallbackUrl);
106
+ });
107
+
108
+ test('Twilio signature computed against assistant URL validates at gateway', () => {
109
+ const publicBase = 'https://my-tunnel.ngrok.io';
110
+ const authToken = 'test-twilio-auth-token-12345';
111
+ const config: IngressConfig = {
112
+ ingress: { publicBaseUrl: publicBase },
113
+ };
114
+
115
+ // Assistant generates the callback URL and registers it with Twilio
116
+ const callbackUrl = getTwilioStatusCallbackUrl(config);
117
+ expect(callbackUrl).toBe('https://my-tunnel.ngrok.io/webhooks/twilio/status');
118
+
119
+ // Twilio signs the request using the callback URL
120
+ const params = { CallSid: 'CA123', CallStatus: 'completed' };
121
+ const twilioSignature = computeTwilioSignature(callbackUrl, params, authToken);
122
+
123
+ // Gateway receives the request on its local address
124
+ const localRequestUrl = 'http://127.0.0.1:7830/webhooks/twilio/status';
125
+
126
+ // Gateway reconstructs the canonical URL using its configured base
127
+ // (which was passed from the assistant's config via INGRESS_PUBLIC_BASE_URL)
128
+ const gatewayIngressPublicBaseUrl = getPublicBaseUrl(config);
129
+ const canonicalUrl = reconstructGatewayCanonicalUrl(
130
+ gatewayIngressPublicBaseUrl,
131
+ localRequestUrl,
132
+ );
133
+
134
+ // Verify the signature matches
135
+ const recomputedSignature = computeTwilioSignature(canonicalUrl, params, authToken);
136
+ expect(recomputedSignature).toBe(twilioSignature);
137
+ });
138
+
139
+ test('mismatch scenario: gateway without config creates signature validation failure', () => {
140
+ const authToken = 'test-twilio-auth-token-12345';
141
+
142
+ // Assistant uses config-based URL
143
+ const assistantConfig: IngressConfig = {
144
+ ingress: { publicBaseUrl: 'https://my-tunnel.ngrok.io' },
145
+ };
146
+ const callbackUrl = getTwilioStatusCallbackUrl(assistantConfig);
147
+
148
+ // Twilio signs against the callback URL the assistant registered
149
+ const params = { CallSid: 'CA123', CallStatus: 'completed' };
150
+ const twilioSignature = computeTwilioSignature(callbackUrl, params, authToken);
151
+
152
+ // Gateway does NOT have the ingress URL configured (simulating the bug)
153
+ const localRequestUrl = 'http://127.0.0.1:7830/webhooks/twilio/status';
154
+ const canonicalUrlWithout = reconstructGatewayCanonicalUrl(undefined, localRequestUrl);
155
+
156
+ // Signature should NOT match — this proves the mismatch bug
157
+ const recomputedWithout = computeTwilioSignature(canonicalUrlWithout, params, authToken);
158
+ expect(recomputedWithout).not.toBe(twilioSignature);
159
+
160
+ // Now simulate the fix: gateway has the same ingress URL
161
+ const canonicalUrlWith = reconstructGatewayCanonicalUrl(
162
+ 'https://my-tunnel.ngrok.io',
163
+ localRequestUrl,
164
+ );
165
+ const recomputedWith = computeTwilioSignature(canonicalUrlWith, params, authToken);
166
+ expect(recomputedWith).toBe(twilioSignature);
167
+ });
168
+
169
+ test('env var fallback produces consistent URLs across assistant and gateway', () => {
170
+ // When no config.ingress.publicBaseUrl is set, both assistant and gateway
171
+ // fall back to the INGRESS_PUBLIC_BASE_URL env var.
172
+ process.env.INGRESS_PUBLIC_BASE_URL = 'https://env-tunnel.example.com';
173
+
174
+ const config: IngressConfig = {};
175
+
176
+ // Assistant resolves the base URL from env
177
+ const assistantBase = getPublicBaseUrl(config);
178
+ expect(assistantBase).toBe('https://env-tunnel.example.com');
179
+
180
+ // Gateway would also read the same env var (process.env.INGRESS_PUBLIC_BASE_URL)
181
+ // and store it as config.ingressPublicBaseUrl.
182
+ const gatewayIngressPublicBaseUrl = process.env.INGRESS_PUBLIC_BASE_URL;
183
+
184
+ // Callback URL generated by assistant
185
+ const callbackUrl = getTwilioVoiceWebhookUrl(config, 'session-xyz');
186
+
187
+ // Gateway canonical URL reconstruction
188
+ const localUrl = 'http://127.0.0.1:7830/webhooks/twilio/voice?callSessionId=session-xyz';
189
+ const gatewayCanonical = reconstructGatewayCanonicalUrl(
190
+ gatewayIngressPublicBaseUrl,
191
+ localUrl,
192
+ );
193
+
194
+ expect(gatewayCanonical).toBe(callbackUrl);
195
+ });
196
+
197
+ test('trailing slashes are normalized consistently', () => {
198
+ const config: IngressConfig = {
199
+ ingress: { publicBaseUrl: 'https://my-tunnel.ngrok.io///' },
200
+ };
201
+
202
+ const assistantBase = getPublicBaseUrl(config);
203
+ expect(assistantBase).toBe('https://my-tunnel.ngrok.io');
204
+
205
+ const callbackUrl = getTwilioVoiceWebhookUrl(config, 'session-1');
206
+
207
+ // Gateway would receive the normalized value (hatch.ts trims trailing slashes)
208
+ const gatewayBase = 'https://my-tunnel.ngrok.io';
209
+ const localUrl = 'http://127.0.0.1:7830/webhooks/twilio/voice?callSessionId=session-1';
210
+ const gatewayCanonical = reconstructGatewayCanonicalUrl(gatewayBase, localUrl);
211
+
212
+ expect(gatewayCanonical).toBe(callbackUrl);
213
+ });
214
+ });
@@ -339,8 +339,8 @@ const clientMessages: Record<ClientMessageType, ClientMessage> = {
339
339
  type: 'slack_webhook_config',
340
340
  action: 'get',
341
341
  },
342
- twilio_webhook_config: {
343
- type: 'twilio_webhook_config',
342
+ ingress_config: {
343
+ type: 'ingress_config',
344
344
  action: 'get',
345
345
  },
346
346
  vercel_api_config: {
@@ -892,6 +892,11 @@ const serverMessages: Record<ServerMessageType, ServerMessage> = {
892
892
  title: 'Urgent email from Alice',
893
893
  body: 'Meeting rescheduled to 3pm today.',
894
894
  },
895
+ agent_heartbeat_alert: {
896
+ type: 'agent_heartbeat_alert',
897
+ title: 'Agent heartbeat stalled',
898
+ body: 'No activity detected in the last 60 minutes.',
899
+ },
895
900
  watch_started: {
896
901
  type: 'watch_started',
897
902
  sessionId: 'sess-001',
@@ -1129,9 +1134,10 @@ const serverMessages: Record<ServerMessageType, ServerMessage> = {
1129
1134
  webhookUrl: 'https://hooks.slack.com/services/T00/B00/xxx',
1130
1135
  success: true,
1131
1136
  },
1132
- twilio_webhook_config_response: {
1133
- type: 'twilio_webhook_config_response',
1134
- webhookBaseUrl: 'https://example.com/twilio',
1137
+ ingress_config_response: {
1138
+ type: 'ingress_config_response',
1139
+ publicBaseUrl: 'https://example.com',
1140
+ localGatewayTarget: 'http://127.0.0.1:7830',
1135
1141
  success: true,
1136
1142
  },
1137
1143
  vercel_api_config_response: {
@@ -1408,6 +1414,12 @@ const serverMessages: Record<ServerMessageType, ServerMessage> = {
1408
1414
  open_tasks_window: {
1409
1415
  type: 'open_tasks_window',
1410
1416
  },
1417
+ task_run_thread_created: {
1418
+ type: 'task_run_thread_created',
1419
+ conversationId: 'conv-task-run-001',
1420
+ workItemId: 'wi-001',
1421
+ title: 'Process report',
1422
+ },
1411
1423
  subagent_spawned: {
1412
1424
  type: 'subagent_spawned',
1413
1425
  subagentId: 'sub-001',
@@ -1429,17 +1441,6 @@ const serverMessages: Record<ServerMessageType, ServerMessage> = {
1429
1441
  sessionId: 'sub-sess-001',
1430
1442
  },
1431
1443
  },
1432
- agent_heartbeat_alert: {
1433
- type: 'agent_heartbeat_alert',
1434
- title: 'Agent unresponsive',
1435
- body: 'The agent has not responded for 5 minutes.',
1436
- },
1437
- task_run_thread_created: {
1438
- type: 'task_run_thread_created',
1439
- conversationId: 'conv-task-001',
1440
- workItemId: 'work-item-001',
1441
- title: 'Task run thread',
1442
- },
1443
1444
  };
1444
1445
 
1445
1446
  // ---------------------------------------------------------------------------
@@ -51,7 +51,13 @@ let mockOAuthCallbackUrl = '';
51
51
 
52
52
  mock.module('../inbound/public-ingress-urls.js', () => ({
53
53
  getOAuthCallbackUrl: () => mockOAuthCallbackUrl,
54
- getPublicBaseUrl: () => 'https://gw.example.com',
54
+ getPublicBaseUrl: (config?: { ingress?: { publicBaseUrl?: string } }) => {
55
+ const url = config?.ingress?.publicBaseUrl ?? mockPublicBaseUrl;
56
+ if (!url) {
57
+ throw new Error('No public base URL configured.');
58
+ }
59
+ return url;
60
+ },
55
61
  }));
56
62
 
57
63
  // Mock fetch for token exchange