@vellumai/assistant 0.3.2 → 0.3.3

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 (52) hide show
  1. package/README.md +82 -13
  2. package/package.json +1 -1
  3. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +16 -0
  4. package/src/__tests__/app-git-history.test.ts +22 -27
  5. package/src/__tests__/app-git-service.test.ts +44 -78
  6. package/src/__tests__/channel-approval-routes.test.ts +930 -14
  7. package/src/__tests__/channel-approval.test.ts +2 -0
  8. package/src/__tests__/channel-delivery-store.test.ts +104 -1
  9. package/src/__tests__/channel-guardian.test.ts +184 -1
  10. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  11. package/src/__tests__/daemon-server-session-init.test.ts +5 -0
  12. package/src/__tests__/gateway-only-enforcement.test.ts +87 -8
  13. package/src/__tests__/handlers-telegram-config.test.ts +82 -0
  14. package/src/__tests__/handlers-twilio-config.test.ts +665 -5
  15. package/src/__tests__/ingress-url-consistency.test.ts +64 -0
  16. package/src/__tests__/ipc-snapshot.test.ts +10 -0
  17. package/src/__tests__/run-orchestrator.test.ts +1 -1
  18. package/src/__tests__/session-process-bridge.test.ts +2 -0
  19. package/src/__tests__/tool-permission-simulate-handler.test.ts +2 -2
  20. package/src/calls/twilio-config.ts +10 -1
  21. package/src/calls/twilio-rest.ts +70 -0
  22. package/src/config/bundled-skills/email-setup/SKILL.md +56 -0
  23. package/src/config/bundled-skills/subagent/SKILL.md +4 -0
  24. package/src/config/bundled-skills/subagent/TOOLS.json +4 -0
  25. package/src/config/schema.ts +3 -0
  26. package/src/config/vellum-skills/twilio-setup/SKILL.md +11 -4
  27. package/src/daemon/handlers/config.ts +168 -15
  28. package/src/daemon/handlers/sessions.ts +5 -3
  29. package/src/daemon/handlers/skills.ts +61 -17
  30. package/src/daemon/ipc-contract-inventory.json +4 -0
  31. package/src/daemon/ipc-contract.ts +10 -0
  32. package/src/daemon/session-agent-loop.ts +4 -0
  33. package/src/daemon/session-process.ts +20 -3
  34. package/src/daemon/session-slash.ts +50 -2
  35. package/src/daemon/session-surfaces.ts +17 -1
  36. package/src/inbound/public-ingress-urls.ts +20 -3
  37. package/src/index.ts +1 -23
  38. package/src/memory/app-git-service.ts +24 -0
  39. package/src/memory/app-store.ts +0 -21
  40. package/src/memory/channel-delivery-store.ts +74 -3
  41. package/src/memory/channel-guardian-store.ts +54 -26
  42. package/src/memory/conversation-key-store.ts +20 -0
  43. package/src/memory/conversation-store.ts +14 -2
  44. package/src/memory/db.ts +12 -0
  45. package/src/memory/schema.ts +5 -0
  46. package/src/runtime/http-server.ts +13 -5
  47. package/src/runtime/routes/channel-routes.ts +134 -43
  48. package/src/skills/clawhub.ts +6 -2
  49. package/src/subagent/manager.ts +4 -1
  50. package/src/subagent/types.ts +2 -0
  51. package/src/tools/skills/vellum-catalog.ts +45 -2
  52. package/src/tools/subagent/spawn.ts +2 -0
@@ -10,8 +10,8 @@ const testDir = mkdtempSync(join(tmpdir(), 'handlers-twilio-cfg-test-'));
10
10
  let rawConfigStore: Record<string, unknown> = {};
11
11
 
12
12
  mock.module('../config/loader.js', () => ({
13
- getConfig: () => ({}),
14
- loadConfig: () => ({}),
13
+ getConfig: () => ({ ...rawConfigStore }),
14
+ loadConfig: () => ({ ...rawConfigStore }),
15
15
  loadRawConfig: () => ({ ...rawConfigStore }),
16
16
  saveRawConfig: (cfg: Record<string, unknown>) => {
17
17
  rawConfigStore = { ...cfg };
@@ -20,6 +20,28 @@ mock.module('../config/loader.js', () => ({
20
20
  invalidateConfigCache: () => {},
21
21
  }));
22
22
 
23
+ // Provide a thin mock of public-ingress-urls that computes real-looking
24
+ // webhook URLs from the raw config store so that both getTwilioConfig()
25
+ // and the syncTwilioWebhooks() helper used by ingress tests work correctly.
26
+ mock.module('../inbound/public-ingress-urls.js', () => {
27
+ function getBase(config: Record<string, unknown>): string {
28
+ const ingress = (config?.ingress ?? {}) as Record<string, unknown>;
29
+ const url = (ingress.publicBaseUrl as string) ?? '';
30
+ if (!url) throw new Error('No public ingress URL configured');
31
+ return url;
32
+ }
33
+ return {
34
+ getPublicBaseUrl: (config: Record<string, unknown>) => getBase(config),
35
+ getTwilioRelayUrl: (config: Record<string, unknown>) => {
36
+ const base = getBase(config);
37
+ return base.replace(/^http(s?)/, 'ws$1') + '/webhooks/twilio/relay';
38
+ },
39
+ getTwilioVoiceWebhookUrl: (config: Record<string, unknown>) => getBase(config) + '/webhooks/twilio/voice',
40
+ getTwilioStatusCallbackUrl: (config: Record<string, unknown>) => getBase(config) + '/webhooks/twilio/status',
41
+ getTwilioSmsWebhookUrl: (config: Record<string, unknown>) => getBase(config) + '/webhooks/twilio/sms',
42
+ };
43
+ });
44
+
23
45
  mock.module('../util/platform.js', () => ({
24
46
  getRootDir: () => testDir,
25
47
  getDataDir: () => testDir,
@@ -113,10 +135,12 @@ mock.module('../tools/credentials/metadata-store.js', () => ({
113
135
  // Mock fetch for Twilio API validation
114
136
  const originalFetch = globalThis.fetch;
115
137
 
116
- import { handleTwilioConfig } from '../daemon/handlers/config.js';
138
+ import { handleTwilioConfig, handleIngressConfig } from '../daemon/handlers/config.js';
139
+ import { getTwilioConfig } from '../calls/twilio-config.js';
117
140
  import type { HandlerContext } from '../daemon/handlers.js';
118
141
  import type {
119
142
  TwilioConfigRequest,
143
+ IngressConfigRequest,
120
144
  ServerMessage,
121
145
  } from '../daemon/ipc-contract.js';
122
146
  import { DebouncerMap } from '../util/debounce.js';
@@ -382,9 +406,11 @@ describe('Twilio config handler', () => {
382
406
 
383
407
  // ── clear_credentials ───────────────────────────────────────────────
384
408
 
385
- test('clear_credentials removes stored credentials', async () => {
409
+ test('clear_credentials removes stored credentials but preserves phone number', async () => {
386
410
  secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
387
411
  secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
412
+ secureKeyStore['credential:twilio:phone_number'] = '+15551234567';
413
+ rawConfigStore = { sms: { phoneNumber: '+15551234567' } };
388
414
  credentialMetadataStore.push({ service: 'twilio', field: 'account_sid' });
389
415
  credentialMetadataStore.push({ service: 'twilio', field: 'auth_token' });
390
416
 
@@ -402,11 +428,15 @@ describe('Twilio config handler', () => {
402
428
  expect(res.success).toBe(true);
403
429
  expect(res.hasCredentials).toBe(false);
404
430
 
405
- // Verify everything was cleaned up
431
+ // Verify auth credentials were cleaned up
406
432
  expect(secureKeyStore['credential:twilio:account_sid']).toBeUndefined();
407
433
  expect(secureKeyStore['credential:twilio:auth_token']).toBeUndefined();
408
434
  expect(deletedMetadata).toContainEqual({ service: 'twilio', field: 'account_sid' });
409
435
  expect(deletedMetadata).toContainEqual({ service: 'twilio', field: 'auth_token' });
436
+
437
+ // Verify phone number is preserved in both stores
438
+ expect(secureKeyStore['credential:twilio:phone_number']).toBe('+15551234567');
439
+ expect((rawConfigStore.sms as Record<string, unknown>)?.phoneNumber).toBe('+15551234567');
410
440
  });
411
441
 
412
442
  test('clear_credentials is idempotent when no credentials exist', async () => {
@@ -424,6 +454,75 @@ describe('Twilio config handler', () => {
424
454
  expect(res.hasCredentials).toBe(false);
425
455
  });
426
456
 
457
+ // ── Phone number resolution order ──────────────────────────────────
458
+
459
+ test('getTwilioConfig resolves phone number from config when secure key also present', () => {
460
+ secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
461
+ secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
462
+ secureKeyStore['credential:twilio:phone_number'] = '+15559999999';
463
+ rawConfigStore = {
464
+ sms: { phoneNumber: '+15551234567' },
465
+ ingress: { enabled: true, publicBaseUrl: 'https://test.ngrok.io' },
466
+ };
467
+
468
+ // Clean env var to test config-only resolution
469
+ const savedEnv = process.env.TWILIO_PHONE_NUMBER;
470
+ delete process.env.TWILIO_PHONE_NUMBER;
471
+
472
+ try {
473
+ const config = getTwilioConfig();
474
+ // Config value (+15551234567) should take priority over secure key (+15559999999)
475
+ expect(config.phoneNumber).toBe('+15551234567');
476
+ } finally {
477
+ if (savedEnv !== undefined) process.env.TWILIO_PHONE_NUMBER = savedEnv;
478
+ }
479
+ });
480
+
481
+ test('getTwilioConfig falls back to secure key when config has no phone number', () => {
482
+ secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
483
+ secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
484
+ secureKeyStore['credential:twilio:phone_number'] = '+15559999999';
485
+ rawConfigStore = {
486
+ ingress: { enabled: true, publicBaseUrl: 'https://test.ngrok.io' },
487
+ };
488
+
489
+ const savedEnv = process.env.TWILIO_PHONE_NUMBER;
490
+ delete process.env.TWILIO_PHONE_NUMBER;
491
+
492
+ try {
493
+ const config = getTwilioConfig();
494
+ // Secure key should be used as fallback
495
+ expect(config.phoneNumber).toBe('+15559999999');
496
+ } finally {
497
+ if (savedEnv !== undefined) process.env.TWILIO_PHONE_NUMBER = savedEnv;
498
+ }
499
+ });
500
+
501
+ test('getTwilioConfig env var overrides both config and secure key', () => {
502
+ secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
503
+ secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
504
+ secureKeyStore['credential:twilio:phone_number'] = '+15559999999';
505
+ rawConfigStore = {
506
+ sms: { phoneNumber: '+15551234567' },
507
+ ingress: { enabled: true, publicBaseUrl: 'https://test.ngrok.io' },
508
+ };
509
+
510
+ const savedEnv = process.env.TWILIO_PHONE_NUMBER;
511
+ process.env.TWILIO_PHONE_NUMBER = '+15550000000';
512
+
513
+ try {
514
+ const config = getTwilioConfig();
515
+ // Env var should take highest priority
516
+ expect(config.phoneNumber).toBe('+15550000000');
517
+ } finally {
518
+ if (savedEnv !== undefined) {
519
+ process.env.TWILIO_PHONE_NUMBER = savedEnv;
520
+ } else {
521
+ delete process.env.TWILIO_PHONE_NUMBER;
522
+ }
523
+ }
524
+ });
525
+
427
526
  // ── assign_number ───────────────────────────────────────────────────
428
527
 
429
528
  test('assign_number persists phone number to config', async () => {
@@ -573,6 +672,169 @@ describe('Twilio config handler', () => {
573
672
  expect(res.phoneNumber).toBe('+15559999999');
574
673
  });
575
674
 
675
+ test('provision_number auto-assigns the purchased number to config and secure storage', async () => {
676
+ secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
677
+ secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
678
+
679
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
680
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
681
+ if (urlStr.includes('AvailablePhoneNumbers') && urlStr.includes('Local.json')) {
682
+ return new Response(JSON.stringify({
683
+ available_phone_numbers: [{
684
+ phone_number: '+15559999999',
685
+ friendly_name: '(555) 999-9999',
686
+ capabilities: { voice: true, sms: true },
687
+ }],
688
+ }), { status: 200 });
689
+ }
690
+ if (urlStr.includes('IncomingPhoneNumbers.json') && init?.method === 'POST') {
691
+ return new Response(JSON.stringify({
692
+ phone_number: '+15559999999',
693
+ friendly_name: '(555) 999-9999',
694
+ capabilities: { voice: true, sms: true },
695
+ }), { status: 201 });
696
+ }
697
+ // Webhook lookup (no ingress configured, will fail gracefully)
698
+ return new Response('{}', { status: 200 });
699
+ }) as typeof fetch;
700
+
701
+ const msg: TwilioConfigRequest = {
702
+ type: 'twilio_config',
703
+ action: 'provision_number',
704
+ country: 'US',
705
+ };
706
+
707
+ const { ctx, sent } = createTestContext();
708
+ await handleTwilioConfig(msg, {} as net.Socket, ctx);
709
+
710
+ expect(sent).toHaveLength(1);
711
+ const res = sent[0] as { type: string; success: boolean; phoneNumber?: string };
712
+ expect(res.success).toBe(true);
713
+ expect(res.phoneNumber).toBe('+15559999999');
714
+
715
+ // Verify the number was persisted in secure storage (same as assign_number)
716
+ expect(secureKeyStore['credential:twilio:phone_number']).toBe('+15559999999');
717
+
718
+ // Verify the number was persisted in the config file (same as assign_number)
719
+ expect((rawConfigStore.sms as Record<string, unknown>)?.phoneNumber).toBe('+15559999999');
720
+ });
721
+
722
+ test('provision_number configures Twilio webhooks when ingress URL is available', async () => {
723
+ secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
724
+ secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
725
+ rawConfigStore = { ingress: { enabled: true, publicBaseUrl: 'https://example.ngrok.io' } };
726
+
727
+ const fetchCalls: Array<{ url: string; method: string; body?: string }> = [];
728
+
729
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
730
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
731
+ fetchCalls.push({ url: urlStr, method: init?.method ?? 'GET', body: init?.body?.toString() });
732
+
733
+ if (urlStr.includes('AvailablePhoneNumbers') && urlStr.includes('Local.json')) {
734
+ return new Response(JSON.stringify({
735
+ available_phone_numbers: [{
736
+ phone_number: '+15559999999',
737
+ friendly_name: '(555) 999-9999',
738
+ capabilities: { voice: true, sms: true },
739
+ }],
740
+ }), { status: 200 });
741
+ }
742
+ if (urlStr.includes('IncomingPhoneNumbers.json') && init?.method === 'POST'
743
+ && init?.body?.toString().includes('PhoneNumber')) {
744
+ return new Response(JSON.stringify({
745
+ phone_number: '+15559999999',
746
+ friendly_name: '(555) 999-9999',
747
+ capabilities: { voice: true, sms: true },
748
+ }), { status: 201 });
749
+ }
750
+ // Webhook number lookup
751
+ if (urlStr.includes('IncomingPhoneNumbers.json') && urlStr.includes('PhoneNumber=')) {
752
+ return new Response(JSON.stringify({
753
+ incoming_phone_numbers: [{ sid: 'PN123abc', phone_number: '+15559999999' }],
754
+ }), { status: 200 });
755
+ }
756
+ // Webhook update
757
+ if (urlStr.includes('IncomingPhoneNumbers/PN123abc.json') && init?.method === 'POST') {
758
+ return new Response(JSON.stringify({ sid: 'PN123abc' }), { status: 200 });
759
+ }
760
+ return new Response('{}', { status: 200 });
761
+ }) as typeof fetch;
762
+
763
+ const msg: TwilioConfigRequest = {
764
+ type: 'twilio_config',
765
+ action: 'provision_number',
766
+ country: 'US',
767
+ };
768
+
769
+ const { ctx, sent } = createTestContext();
770
+ await handleTwilioConfig(msg, {} as net.Socket, ctx);
771
+
772
+ expect(sent).toHaveLength(1);
773
+ const res = sent[0] as { type: string; success: boolean };
774
+ expect(res.success).toBe(true);
775
+
776
+ // Find the webhook update call
777
+ const webhookUpdate = fetchCalls.find((c) =>
778
+ c.url.includes('IncomingPhoneNumbers/PN123abc.json') && c.method === 'POST',
779
+ );
780
+ expect(webhookUpdate).toBeDefined();
781
+
782
+ // Verify the webhook URLs contain the expected ingress base URL paths
783
+ const body = webhookUpdate!.body!;
784
+ expect(body).toContain('VoiceUrl=');
785
+ expect(body).toContain('webhooks%2Ftwilio%2Fvoice');
786
+ expect(body).toContain('StatusCallback=');
787
+ expect(body).toContain('webhooks%2Ftwilio%2Fstatus');
788
+ expect(body).toContain('SmsUrl=');
789
+ expect(body).toContain('webhooks%2Ftwilio%2Fsms');
790
+ });
791
+
792
+ test('provision_number succeeds with clear warning when ingress URL is missing', async () => {
793
+ secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
794
+ secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
795
+ // No ingress config — webhook configuration will be skipped gracefully
796
+
797
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
798
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
799
+ if (urlStr.includes('AvailablePhoneNumbers') && urlStr.includes('Local.json')) {
800
+ return new Response(JSON.stringify({
801
+ available_phone_numbers: [{
802
+ phone_number: '+15559999999',
803
+ friendly_name: '(555) 999-9999',
804
+ capabilities: { voice: true, sms: true },
805
+ }],
806
+ }), { status: 200 });
807
+ }
808
+ if (urlStr.includes('IncomingPhoneNumbers.json') && init?.method === 'POST') {
809
+ return new Response(JSON.stringify({
810
+ phone_number: '+15559999999',
811
+ friendly_name: '(555) 999-9999',
812
+ capabilities: { voice: true, sms: true },
813
+ }), { status: 201 });
814
+ }
815
+ return new Response('{}', { status: 200 });
816
+ }) as typeof fetch;
817
+
818
+ const msg: TwilioConfigRequest = {
819
+ type: 'twilio_config',
820
+ action: 'provision_number',
821
+ country: 'US',
822
+ };
823
+
824
+ const { ctx, sent } = createTestContext();
825
+ await handleTwilioConfig(msg, {} as net.Socket, ctx);
826
+
827
+ // The provision should still succeed — webhook config failure is non-fatal
828
+ expect(sent).toHaveLength(1);
829
+ const res = sent[0] as { type: string; success: boolean; phoneNumber?: string };
830
+ expect(res.success).toBe(true);
831
+ expect(res.phoneNumber).toBe('+15559999999');
832
+
833
+ // Number should still be persisted even without webhook setup
834
+ expect(secureKeyStore['credential:twilio:phone_number']).toBe('+15559999999');
835
+ expect((rawConfigStore.sms as Record<string, unknown>)?.phoneNumber).toBe('+15559999999');
836
+ });
837
+
576
838
  test('provision_number returns error when no numbers available', async () => {
577
839
  secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
578
840
  secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
@@ -635,6 +897,404 @@ describe('Twilio config handler', () => {
635
897
  expect(res.error).toContain('nonexistent_action');
636
898
  });
637
899
 
900
+ // ── Ingress webhook reconciliation ──────────────────────────────────
901
+
902
+ test('ingress config update triggers Twilio webhook sync when assigned number and credentials exist', async () => {
903
+ secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
904
+ secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
905
+ rawConfigStore = { sms: { phoneNumber: '+15551234567' } };
906
+
907
+ const fetchCalls: Array<{ url: string; method: string; body?: string }> = [];
908
+
909
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
910
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
911
+ fetchCalls.push({ url: urlStr, method: init?.method ?? 'GET', body: init?.body?.toString() });
912
+
913
+ // Webhook number lookup
914
+ if (urlStr.includes('IncomingPhoneNumbers.json') && urlStr.includes('PhoneNumber=')) {
915
+ return new Response(JSON.stringify({
916
+ incoming_phone_numbers: [{ sid: 'PN123abc', phone_number: '+15551234567' }],
917
+ }), { status: 200 });
918
+ }
919
+ // Webhook update
920
+ if (urlStr.includes('IncomingPhoneNumbers/PN123abc.json') && init?.method === 'POST') {
921
+ return new Response(JSON.stringify({ sid: 'PN123abc' }), { status: 200 });
922
+ }
923
+ // Gateway reconcile (ignore)
924
+ if (urlStr.includes('/internal/telegram/reconcile')) {
925
+ return new Response('{}', { status: 200 });
926
+ }
927
+ return new Response('{}', { status: 200 });
928
+ }) as typeof fetch;
929
+
930
+ const msg: IngressConfigRequest = {
931
+ type: 'ingress_config',
932
+ action: 'set',
933
+ publicBaseUrl: 'https://new-tunnel.ngrok.io',
934
+ enabled: true,
935
+ };
936
+
937
+ const { ctx, sent } = createTestContext();
938
+ await handleIngressConfig(msg, {} as net.Socket, ctx);
939
+
940
+ // Ingress save should succeed
941
+ expect(sent).toHaveLength(1);
942
+ const res = sent[0] as { type: string; success: boolean; enabled: boolean; publicBaseUrl: string };
943
+ expect(res.type).toBe('ingress_config_response');
944
+ expect(res.success).toBe(true);
945
+ expect(res.enabled).toBe(true);
946
+ expect(res.publicBaseUrl).toBe('https://new-tunnel.ngrok.io');
947
+
948
+ // Wait a tick for the fire-and-forget webhook sync to complete
949
+ await new Promise((r) => setTimeout(r, 50));
950
+
951
+ // Verify webhook update was attempted with the new ingress URL
952
+ const webhookUpdate = fetchCalls.find((c) =>
953
+ c.url.includes('IncomingPhoneNumbers/PN123abc.json') && c.method === 'POST',
954
+ );
955
+ expect(webhookUpdate).toBeDefined();
956
+ const body = webhookUpdate!.body!;
957
+ expect(body).toContain('VoiceUrl=');
958
+ expect(body).toContain('new-tunnel.ngrok.io');
959
+ });
960
+
961
+ test('webhook sync failure on ingress update does not fail the ingress update', async () => {
962
+ secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
963
+ secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
964
+ rawConfigStore = { sms: { phoneNumber: '+15551234567' } };
965
+
966
+ globalThis.fetch = (async (url: string | URL | Request, _init?: RequestInit) => {
967
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
968
+ // Gateway reconcile (ignore)
969
+ if (urlStr.includes('/internal/telegram/reconcile')) {
970
+ return new Response('{}', { status: 200 });
971
+ }
972
+ // Webhook number lookup — simulate failure
973
+ if (urlStr.includes('IncomingPhoneNumbers.json') && urlStr.includes('PhoneNumber=')) {
974
+ return new Response('Internal Server Error', { status: 500 });
975
+ }
976
+ return new Response('{}', { status: 200 });
977
+ }) as typeof fetch;
978
+
979
+ const msg: IngressConfigRequest = {
980
+ type: 'ingress_config',
981
+ action: 'set',
982
+ publicBaseUrl: 'https://example.ngrok.io',
983
+ enabled: true,
984
+ };
985
+
986
+ const { ctx, sent } = createTestContext();
987
+ await handleIngressConfig(msg, {} as net.Socket, ctx);
988
+
989
+ // The ingress update must still succeed despite the webhook sync failure
990
+ expect(sent).toHaveLength(1);
991
+ const res = sent[0] as { type: string; success: boolean; enabled: boolean };
992
+ expect(res.type).toBe('ingress_config_response');
993
+ expect(res.success).toBe(true);
994
+ expect(res.enabled).toBe(true);
995
+
996
+ // Wait a tick for the fire-and-forget promise
997
+ await new Promise((r) => setTimeout(r, 50));
998
+ });
999
+
1000
+ test('ingress config update skips webhook sync when no Twilio credentials', async () => {
1001
+ rawConfigStore = { sms: { phoneNumber: '+15551234567' } };
1002
+
1003
+ const fetchCalls: Array<{ url: string }> = [];
1004
+ globalThis.fetch = (async (url: string | URL | Request) => {
1005
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
1006
+ fetchCalls.push({ url: urlStr });
1007
+ return new Response('{}', { status: 200 });
1008
+ }) as typeof fetch;
1009
+
1010
+ const msg: IngressConfigRequest = {
1011
+ type: 'ingress_config',
1012
+ action: 'set',
1013
+ publicBaseUrl: 'https://example.ngrok.io',
1014
+ enabled: true,
1015
+ };
1016
+
1017
+ const { ctx, sent } = createTestContext();
1018
+ await handleIngressConfig(msg, {} as net.Socket, ctx);
1019
+
1020
+ expect(sent).toHaveLength(1);
1021
+ const res = sent[0] as { type: string; success: boolean };
1022
+ expect(res.success).toBe(true);
1023
+
1024
+ // No Twilio API calls should have been made (only gateway reconcile)
1025
+ const twilioApiCalls = fetchCalls.filter((c) => c.url.includes('api.twilio.com'));
1026
+ expect(twilioApiCalls).toHaveLength(0);
1027
+ });
1028
+
1029
+ test('ingress config update skips webhook sync when no assigned number', async () => {
1030
+ secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
1031
+ secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
1032
+ // No sms.phoneNumber in config
1033
+
1034
+ const fetchCalls: Array<{ url: string }> = [];
1035
+ globalThis.fetch = (async (url: string | URL | Request) => {
1036
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
1037
+ fetchCalls.push({ url: urlStr });
1038
+ return new Response('{}', { status: 200 });
1039
+ }) as typeof fetch;
1040
+
1041
+ const msg: IngressConfigRequest = {
1042
+ type: 'ingress_config',
1043
+ action: 'set',
1044
+ publicBaseUrl: 'https://example.ngrok.io',
1045
+ enabled: true,
1046
+ };
1047
+
1048
+ const { ctx, sent } = createTestContext();
1049
+ await handleIngressConfig(msg, {} as net.Socket, ctx);
1050
+
1051
+ expect(sent).toHaveLength(1);
1052
+ const res = sent[0] as { type: string; success: boolean };
1053
+ expect(res.success).toBe(true);
1054
+
1055
+ // No Twilio API calls should have been made
1056
+ const twilioApiCalls = fetchCalls.filter((c) => c.url.includes('api.twilio.com'));
1057
+ expect(twilioApiCalls).toHaveLength(0);
1058
+ });
1059
+
1060
+ // ── Warning field ─────────────────────────────────────────────────
1061
+
1062
+ test('provision_number surfaces webhook warning when ingress is missing', async () => {
1063
+ secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
1064
+ secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
1065
+ // No ingress config — webhook configuration will produce a warning
1066
+
1067
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1068
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
1069
+ if (urlStr.includes('AvailablePhoneNumbers') && urlStr.includes('Local.json')) {
1070
+ return new Response(JSON.stringify({
1071
+ available_phone_numbers: [{
1072
+ phone_number: '+15559999999',
1073
+ friendly_name: '(555) 999-9999',
1074
+ capabilities: { voice: true, sms: true },
1075
+ }],
1076
+ }), { status: 200 });
1077
+ }
1078
+ if (urlStr.includes('IncomingPhoneNumbers.json') && init?.method === 'POST') {
1079
+ return new Response(JSON.stringify({
1080
+ phone_number: '+15559999999',
1081
+ friendly_name: '(555) 999-9999',
1082
+ capabilities: { voice: true, sms: true },
1083
+ }), { status: 201 });
1084
+ }
1085
+ return new Response('{}', { status: 200 });
1086
+ }) as typeof fetch;
1087
+
1088
+ const msg: TwilioConfigRequest = {
1089
+ type: 'twilio_config',
1090
+ action: 'provision_number',
1091
+ country: 'US',
1092
+ };
1093
+
1094
+ const { ctx, sent } = createTestContext();
1095
+ await handleTwilioConfig(msg, {} as net.Socket, ctx);
1096
+
1097
+ expect(sent).toHaveLength(1);
1098
+ const res = sent[0] as { type: string; success: boolean; phoneNumber?: string; warning?: string };
1099
+ expect(res.success).toBe(true);
1100
+ expect(res.phoneNumber).toBe('+15559999999');
1101
+ // Warning should be present because no ingress URL is configured
1102
+ expect(res.warning).toBeDefined();
1103
+ expect(res.warning).toContain('Webhook configuration skipped');
1104
+ });
1105
+
1106
+ test('assign_number surfaces webhook warning when Twilio API fails', async () => {
1107
+ secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
1108
+ secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
1109
+ rawConfigStore = { ingress: { enabled: true, publicBaseUrl: 'https://example.ngrok.io' } };
1110
+
1111
+ globalThis.fetch = (async (url: string | URL | Request) => {
1112
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
1113
+ // Webhook number lookup — simulate Twilio API error
1114
+ if (urlStr.includes('IncomingPhoneNumbers.json')) {
1115
+ return new Response('Service Unavailable', { status: 503 });
1116
+ }
1117
+ return new Response('{}', { status: 200 });
1118
+ }) as typeof fetch;
1119
+
1120
+ const msg: TwilioConfigRequest = {
1121
+ type: 'twilio_config',
1122
+ action: 'assign_number',
1123
+ phoneNumber: '+15551234567',
1124
+ };
1125
+
1126
+ const { ctx, sent } = createTestContext();
1127
+ await handleTwilioConfig(msg, {} as net.Socket, ctx);
1128
+
1129
+ expect(sent).toHaveLength(1);
1130
+ const res = sent[0] as { type: string; success: boolean; phoneNumber?: string; warning?: string };
1131
+ // Assignment itself succeeds
1132
+ expect(res.success).toBe(true);
1133
+ expect(res.phoneNumber).toBe('+15551234567');
1134
+ // Warning should surface the webhook failure
1135
+ expect(res.warning).toBeDefined();
1136
+ expect(res.warning).toContain('Webhook configuration skipped');
1137
+ });
1138
+
1139
+ // ── Assistant-scoped phone number assignment ─────────────────────────
1140
+
1141
+ test('get action with assistantId returns assistant-specific phone number', async () => {
1142
+ rawConfigStore = {
1143
+ sms: {
1144
+ phoneNumber: '+15551111111',
1145
+ assistantPhoneNumbers: { 'ast-alpha': '+15552222222', 'ast-beta': '+15553333333' },
1146
+ },
1147
+ };
1148
+
1149
+ const msg: TwilioConfigRequest = {
1150
+ type: 'twilio_config',
1151
+ action: 'get',
1152
+ assistantId: 'ast-alpha',
1153
+ };
1154
+
1155
+ const { ctx, sent } = createTestContext();
1156
+ await handleTwilioConfig(msg, {} as net.Socket, ctx);
1157
+
1158
+ expect(sent).toHaveLength(1);
1159
+ const res = sent[0] as { type: string; success: boolean; phoneNumber?: string };
1160
+ expect(res.success).toBe(true);
1161
+ // Should return the assistant-specific number, not the legacy one
1162
+ expect(res.phoneNumber).toBe('+15552222222');
1163
+ });
1164
+
1165
+ test('get action with assistantId falls back to legacy phoneNumber when no mapping exists', async () => {
1166
+ rawConfigStore = {
1167
+ sms: { phoneNumber: '+15551111111' },
1168
+ };
1169
+
1170
+ const msg: TwilioConfigRequest = {
1171
+ type: 'twilio_config',
1172
+ action: 'get',
1173
+ assistantId: 'ast-unknown',
1174
+ };
1175
+
1176
+ const { ctx, sent } = createTestContext();
1177
+ await handleTwilioConfig(msg, {} as net.Socket, ctx);
1178
+
1179
+ expect(sent).toHaveLength(1);
1180
+ const res = sent[0] as { type: string; success: boolean; phoneNumber?: string };
1181
+ expect(res.success).toBe(true);
1182
+ // Should fall back to the legacy phoneNumber
1183
+ expect(res.phoneNumber).toBe('+15551111111');
1184
+ });
1185
+
1186
+ test('assign_number with assistantId persists into assistantPhoneNumbers mapping', async () => {
1187
+ rawConfigStore = { sms: { phoneNumber: '+15551111111' } };
1188
+
1189
+ const msg: TwilioConfigRequest = {
1190
+ type: 'twilio_config',
1191
+ action: 'assign_number',
1192
+ phoneNumber: '+15554444444',
1193
+ assistantId: 'ast-gamma',
1194
+ };
1195
+
1196
+ const { ctx, sent } = createTestContext();
1197
+ await handleTwilioConfig(msg, {} as net.Socket, ctx);
1198
+
1199
+ expect(sent).toHaveLength(1);
1200
+ const res = sent[0] as { type: string; success: boolean; phoneNumber?: string };
1201
+ expect(res.success).toBe(true);
1202
+ expect(res.phoneNumber).toBe('+15554444444');
1203
+
1204
+ // Legacy field should NOT be overwritten when assistantId is provided
1205
+ // and the field already has a value — prevents multi-assistant clobbering
1206
+ const sms = rawConfigStore.sms as Record<string, unknown>;
1207
+ expect(sms.phoneNumber).toBe('+15551111111');
1208
+
1209
+ // Per-assistant mapping should contain the new assignment
1210
+ const mapping = sms.assistantPhoneNumbers as Record<string, string>;
1211
+ expect(mapping['ast-gamma']).toBe('+15554444444');
1212
+ });
1213
+
1214
+ test('assign_number with assistantId sets legacy phoneNumber as fallback when empty', async () => {
1215
+ rawConfigStore = { sms: {} };
1216
+
1217
+ const msg: TwilioConfigRequest = {
1218
+ type: 'twilio_config',
1219
+ action: 'assign_number',
1220
+ phoneNumber: '+15554444444',
1221
+ assistantId: 'ast-gamma',
1222
+ };
1223
+
1224
+ const { ctx, sent } = createTestContext();
1225
+ await handleTwilioConfig(msg, {} as net.Socket, ctx);
1226
+
1227
+ expect(sent).toHaveLength(1);
1228
+ const res = sent[0] as { type: string; success: boolean; phoneNumber?: string };
1229
+ expect(res.success).toBe(true);
1230
+ expect(res.phoneNumber).toBe('+15554444444');
1231
+
1232
+ // When no legacy phoneNumber exists, the first assistant assignment sets it as fallback
1233
+ const sms = rawConfigStore.sms as Record<string, unknown>;
1234
+ expect(sms.phoneNumber).toBe('+15554444444');
1235
+
1236
+ // Per-assistant mapping should contain the new assignment
1237
+ const mapping = sms.assistantPhoneNumbers as Record<string, string>;
1238
+ expect(mapping['ast-gamma']).toBe('+15554444444');
1239
+ });
1240
+
1241
+ test('assign_number with assistantId does not clobber existing global phoneNumber', async () => {
1242
+ // Simulate a multi-assistant scenario: assistant alpha already has a number assigned
1243
+ rawConfigStore = {
1244
+ sms: {
1245
+ phoneNumber: '+15551111111',
1246
+ assistantPhoneNumbers: { 'ast-alpha': '+15551111111' },
1247
+ },
1248
+ };
1249
+
1250
+ // Now assign a different number to assistant beta
1251
+ const msg: TwilioConfigRequest = {
1252
+ type: 'twilio_config',
1253
+ action: 'assign_number',
1254
+ phoneNumber: '+15552222222',
1255
+ assistantId: 'ast-beta',
1256
+ };
1257
+
1258
+ const { ctx, sent } = createTestContext();
1259
+ await handleTwilioConfig(msg, {} as net.Socket, ctx);
1260
+
1261
+ expect(sent).toHaveLength(1);
1262
+ const res = sent[0] as { type: string; success: boolean; phoneNumber?: string };
1263
+ expect(res.success).toBe(true);
1264
+ expect(res.phoneNumber).toBe('+15552222222');
1265
+
1266
+ const sms = rawConfigStore.sms as Record<string, unknown>;
1267
+ // The global phoneNumber should still be alpha's number, NOT beta's
1268
+ expect(sms.phoneNumber).toBe('+15551111111');
1269
+
1270
+ // Both assistant mappings should be intact
1271
+ const mapping = sms.assistantPhoneNumbers as Record<string, string>;
1272
+ expect(mapping['ast-alpha']).toBe('+15551111111');
1273
+ expect(mapping['ast-beta']).toBe('+15552222222');
1274
+ });
1275
+
1276
+ test('assign_number without assistantId does not write assistantPhoneNumbers', async () => {
1277
+ rawConfigStore = { sms: {} };
1278
+
1279
+ const msg: TwilioConfigRequest = {
1280
+ type: 'twilio_config',
1281
+ action: 'assign_number',
1282
+ phoneNumber: '+15555555555',
1283
+ };
1284
+
1285
+ const { ctx, sent } = createTestContext();
1286
+ await handleTwilioConfig(msg, {} as net.Socket, ctx);
1287
+
1288
+ expect(sent).toHaveLength(1);
1289
+ const res = sent[0] as { type: string; success: boolean };
1290
+ expect(res.success).toBe(true);
1291
+
1292
+ const sms = rawConfigStore.sms as Record<string, unknown>;
1293
+ expect(sms.phoneNumber).toBe('+15555555555');
1294
+ // No assistantPhoneNumbers should have been created
1295
+ expect(sms.assistantPhoneNumbers).toBeUndefined();
1296
+ });
1297
+
638
1298
  // ── Security ────────────────────────────────────────────────────────
639
1299
 
640
1300
  test('response messages never contain raw credential values', async () => {