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.
- package/bun.lock +2 -2
- package/package.json +3 -2
- package/src/__tests__/config-schema.test.ts +0 -6
- package/src/__tests__/forbidden-legacy-symbols.test.ts +69 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +91 -11
- package/src/__tests__/ingress-url-consistency.test.ts +214 -0
- package/src/__tests__/ipc-snapshot.test.ts +17 -16
- package/src/__tests__/oauth2-gateway-transport.test.ts +7 -1
- package/src/__tests__/public-ingress-urls.test.ts +50 -34
- package/src/__tests__/runtime-events-sse-parity.test.ts +343 -0
- package/src/__tests__/runtime-events-sse.test.ts +162 -0
- package/src/__tests__/twilio-provider.test.ts +1 -1
- package/src/__tests__/twilio-routes.test.ts +4 -4
- package/src/__tests__/twitter-auth-handler.test.ts +87 -2
- package/src/calls/call-domain.ts +8 -6
- package/src/calls/twilio-config.ts +2 -3
- package/src/config/bundled-skills/tasks/TOOLS.json +25 -0
- package/src/config/bundled-skills/tasks/tools/task-queue-run.ts +9 -0
- package/src/config/bundled-skills/transcribe/SKILL.md +25 -0
- package/src/config/bundled-skills/transcribe/TOOLS.json +32 -0
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +370 -0
- package/src/config/defaults.ts +1 -2
- package/src/config/schema.ts +2 -6
- package/src/config/vellum-skills/google-oauth-setup/SKILL.md +5 -4
- package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +4 -2
- package/src/config/vellum-skills/telegram-setup/SKILL.md +3 -3
- package/src/daemon/handlers/config.ts +33 -50
- package/src/daemon/handlers/shared.ts +1 -0
- package/src/daemon/handlers/subagents.ts +85 -2
- package/src/daemon/handlers/twitter-auth.ts +31 -2
- package/src/daemon/ipc-contract-inventory.json +4 -4
- package/src/daemon/ipc-contract.ts +25 -21
- package/src/daemon/lifecycle.ts +9 -4
- package/src/daemon/server.ts +7 -0
- package/src/daemon/session-tool-setup.ts +1 -1
- package/src/inbound/public-ingress-urls.ts +36 -30
- package/src/memory/db.ts +132 -5
- package/src/memory/llm-usage-store.ts +0 -1
- package/src/memory/runs-store.ts +51 -3
- package/src/memory/schema.ts +2 -2
- package/src/runtime/gateway-client.ts +7 -1
- package/src/runtime/http-server.ts +95 -10
- package/src/runtime/routes/channel-routes.ts +7 -2
- package/src/runtime/routes/events-routes.ts +79 -0
- package/src/runtime/routes/run-routes.ts +43 -0
- package/src/runtime/run-orchestrator.ts +64 -7
- package/src/security/oauth-callback-registry.ts +10 -0
- package/src/security/oauth2.ts +41 -7
- package/src/subagent/manager.ts +3 -1
- package/src/tools/tasks/work-item-run.ts +78 -0
- package/src/util/platform.ts +1 -1
- package/src/work-items/work-item-runner.ts +171 -0
- package/src/__tests__/handlers-twilio-config.test.ts +0 -221
- package/src/calls/__tests__/twilio-webhook-urls.test.ts +0 -162
- 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.
|
|
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.
|
|
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.
|
|
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.
|
|
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-
|
|
8
|
-
* - Relay WebSocket upgrade allowed from
|
|
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-
|
|
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 (
|
|
314
|
-
// Without an origin header,
|
|
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:
|
|
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:
|
|
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
|
-
|
|
343
|
-
type: '
|
|
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
|
-
|
|
1133
|
-
type: '
|
|
1134
|
-
|
|
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: () =>
|
|
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
|