@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
@@ -65,8 +65,10 @@ function ensureConversation(conversationId: string): void {
65
65
  }
66
66
 
67
67
  function ensureMessage(messageId: string, conversationId: string): void {
68
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
68
69
  const { getDb } = require('../memory/db.js');
69
70
  const db = getDb();
71
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
70
72
  const { messages } = require('../memory/schema.js');
71
73
  try {
72
74
  db.insert(messages).values({
@@ -24,7 +24,7 @@ mock.module('../util/logger.js', () => ({
24
24
  }));
25
25
 
26
26
  import { initializeDb, getDb, resetDb } from '../memory/db.js';
27
- import { channelInboundEvents, messages } from '../memory/schema.js';
27
+ import { channelInboundEvents, conversations, messages } from '../memory/schema.js';
28
28
  import {
29
29
  recordInbound,
30
30
  linkMessage,
@@ -40,6 +40,8 @@ import {
40
40
  } from '../memory/channel-delivery-store.js';
41
41
  import { RETRY_MAX_ATTEMPTS } from '../memory/job-utils.js';
42
42
  import { eq } from 'drizzle-orm';
43
+ import { setConversationKey, getConversationByKey } from '../memory/conversation-key-store.js';
44
+ import { handleDeleteConversation } from '../runtime/routes/channel-routes.js';
43
45
 
44
46
  initializeDb();
45
47
 
@@ -135,6 +137,27 @@ describe('channel-delivery-store', () => {
135
137
  expect(r1.conversationId).not.toBe(r2.conversationId);
136
138
  });
137
139
 
140
+ test('same chat/channel but different assistantId uses different conversations', () => {
141
+ const r1 = recordInbound('telegram', 'chat-1', 'msg-1', { assistantId: 'asst-A' });
142
+ const r2 = recordInbound('telegram', 'chat-1', 'msg-2', { assistantId: 'asst-B' });
143
+
144
+ expect(r1.conversationId).not.toBe(r2.conversationId);
145
+ });
146
+
147
+ test('self assistant reuses legacy key and creates scoped alias', () => {
148
+ // Create a conversation via the legacy (no assistantId) path
149
+ const legacy = recordInbound('telegram', 'chat-1', 'msg-1');
150
+
151
+ // Now use assistantId='self' — should reuse the legacy conversation
152
+ const scoped = recordInbound('telegram', 'chat-1', 'msg-2', { assistantId: 'self' });
153
+ expect(scoped.conversationId).toBe(legacy.conversationId);
154
+
155
+ // The scoped alias key should exist, so subsequent calls with 'self'
156
+ // resolve directly without falling back to the legacy key
157
+ const again = recordInbound('telegram', 'chat-1', 'msg-3', { assistantId: 'self' });
158
+ expect(again.conversationId).toBe(legacy.conversationId);
159
+ });
160
+
138
161
  // ── Deduplication ─────────────────────────────────────────────────
139
162
 
140
163
  test('duplicate inbound returns duplicate: true with same eventId', () => {
@@ -444,4 +467,84 @@ describe('channel-delivery-store', () => {
444
467
  const found = findMessageBySourceId('telegram', 'chat-1', 'src-1');
445
468
  expect(found!.messageId).toBe(msgId);
446
469
  });
470
+
471
+ // ── handleDeleteConversation assistantId parameter ───────────────
472
+
473
+ test('handleDeleteConversation uses route assistantId param to delete scoped key', async () => {
474
+ // Set up a scoped conversation key like the one created by recordInbound
475
+ // with a specific assistantId.
476
+ const convId = 'conv-delete-test';
477
+ const scopedKey = 'asst:my-assistant:telegram:chat-del';
478
+ const legacyKey = 'telegram:chat-del';
479
+
480
+ // Insert a conversation row so FK constraints are satisfied
481
+ const now = Date.now();
482
+ const db = getDb();
483
+ db.insert(conversations).values({
484
+ id: convId,
485
+ title: 'test',
486
+ createdAt: now,
487
+ updatedAt: now,
488
+ }).run();
489
+ setConversationKey(scopedKey, convId);
490
+ setConversationKey(legacyKey, convId);
491
+
492
+ // Verify both keys exist
493
+ expect(getConversationByKey(scopedKey)).not.toBeNull();
494
+ expect(getConversationByKey(legacyKey)).not.toBeNull();
495
+
496
+ // Call handleDeleteConversation with assistantId as a parameter (not in body)
497
+ const req = new Request('http://localhost/channels/conversation', {
498
+ method: 'DELETE',
499
+ headers: { 'Content-Type': 'application/json' },
500
+ body: JSON.stringify({
501
+ sourceChannel: 'telegram',
502
+ externalChatId: 'chat-del',
503
+ // Note: no assistantId in the body — it comes from the route param
504
+ }),
505
+ });
506
+
507
+ const res = await handleDeleteConversation(req, 'my-assistant');
508
+ expect(res.status).toBe(200);
509
+
510
+ const json = await res.json() as { ok: boolean };
511
+ expect(json.ok).toBe(true);
512
+
513
+ // Both the legacy key and the scoped key should be deleted
514
+ expect(getConversationByKey(scopedKey)).toBeNull();
515
+ expect(getConversationByKey(legacyKey)).toBeNull();
516
+ });
517
+
518
+ test('handleDeleteConversation defaults to "self" when no assistantId provided', async () => {
519
+ const convId = 'conv-delete-default';
520
+ const scopedKey = 'asst:self:telegram:chat-def';
521
+ const legacyKey = 'telegram:chat-def';
522
+
523
+ const now = Date.now();
524
+ const db = getDb();
525
+ db.insert(conversations).values({
526
+ id: convId,
527
+ title: 'test',
528
+ createdAt: now,
529
+ updatedAt: now,
530
+ }).run();
531
+ setConversationKey(scopedKey, convId);
532
+ setConversationKey(legacyKey, convId);
533
+
534
+ const req = new Request('http://localhost/channels/conversation', {
535
+ method: 'DELETE',
536
+ headers: { 'Content-Type': 'application/json' },
537
+ body: JSON.stringify({
538
+ sourceChannel: 'telegram',
539
+ externalChatId: 'chat-def',
540
+ }),
541
+ });
542
+
543
+ // No assistantId parameter — should default to 'self'
544
+ const res = await handleDeleteConversation(req);
545
+ expect(res.status).toBe(200);
546
+
547
+ expect(getConversationByKey(scopedKey)).toBeNull();
548
+ expect(getConversationByKey(legacyKey)).toBeNull();
549
+ });
447
550
  });
@@ -949,7 +949,7 @@ describe('guardian service rate limiting', () => {
949
949
 
950
950
  test('valid challenge still succeeds when under threshold', () => {
951
951
  // Record a couple invalid attempts
952
- const { secret } = createVerificationChallenge('asst-1', 'telegram');
952
+ const { secret: _secret } = createVerificationChallenge('asst-1', 'telegram');
953
953
  validateAndConsumeChallenge('asst-1', 'telegram', 'wrong-1', 'user-42', 'chat-42');
954
954
  validateAndConsumeChallenge('asst-1', 'telegram', 'wrong-2', 'user-42', 'chat-42');
955
955
 
@@ -1003,3 +1003,186 @@ describe('guardian service rate limiting', () => {
1003
1003
  expect(result.success).toBe(true);
1004
1004
  });
1005
1005
  });
1006
+
1007
+ // ═══════════════════════════════════════════════════════════════════════════
1008
+ // 8. Assistant-scoped guardian resolution
1009
+ // ═══════════════════════════════════════════════════════════════════════════
1010
+
1011
+ describe('assistant-scoped guardian resolution', () => {
1012
+ beforeEach(() => {
1013
+ resetTables();
1014
+ });
1015
+
1016
+ test('isGuardian resolves independently per assistantId', () => {
1017
+ // Create guardian binding for asst-A on telegram
1018
+ createBinding({
1019
+ assistantId: 'asst-A',
1020
+ channel: 'telegram',
1021
+ guardianExternalUserId: 'user-alpha',
1022
+ guardianDeliveryChatId: 'chat-alpha',
1023
+ });
1024
+ // Create guardian binding for asst-B on telegram with a different user
1025
+ createBinding({
1026
+ assistantId: 'asst-B',
1027
+ channel: 'telegram',
1028
+ guardianExternalUserId: 'user-beta',
1029
+ guardianDeliveryChatId: 'chat-beta',
1030
+ });
1031
+
1032
+ // user-alpha is guardian for asst-A but not asst-B
1033
+ expect(isGuardian('asst-A', 'telegram', 'user-alpha')).toBe(true);
1034
+ expect(isGuardian('asst-B', 'telegram', 'user-alpha')).toBe(false);
1035
+
1036
+ // user-beta is guardian for asst-B but not asst-A
1037
+ expect(isGuardian('asst-B', 'telegram', 'user-beta')).toBe(true);
1038
+ expect(isGuardian('asst-A', 'telegram', 'user-beta')).toBe(false);
1039
+ });
1040
+
1041
+ test('getGuardianBinding returns different bindings for different assistants', () => {
1042
+ createBinding({
1043
+ assistantId: 'asst-A',
1044
+ channel: 'telegram',
1045
+ guardianExternalUserId: 'user-alpha',
1046
+ guardianDeliveryChatId: 'chat-alpha',
1047
+ });
1048
+ createBinding({
1049
+ assistantId: 'asst-B',
1050
+ channel: 'telegram',
1051
+ guardianExternalUserId: 'user-beta',
1052
+ guardianDeliveryChatId: 'chat-beta',
1053
+ });
1054
+
1055
+ const bindingA = getGuardianBinding('asst-A', 'telegram');
1056
+ const bindingB = getGuardianBinding('asst-B', 'telegram');
1057
+
1058
+ expect(bindingA).not.toBeNull();
1059
+ expect(bindingB).not.toBeNull();
1060
+ expect(bindingA!.guardianExternalUserId).toBe('user-alpha');
1061
+ expect(bindingB!.guardianExternalUserId).toBe('user-beta');
1062
+ });
1063
+
1064
+ test('revoking binding for one assistant does not affect another', () => {
1065
+ createBinding({
1066
+ assistantId: 'asst-A',
1067
+ channel: 'telegram',
1068
+ guardianExternalUserId: 'user-alpha',
1069
+ guardianDeliveryChatId: 'chat-alpha',
1070
+ });
1071
+ createBinding({
1072
+ assistantId: 'asst-B',
1073
+ channel: 'telegram',
1074
+ guardianExternalUserId: 'user-beta',
1075
+ guardianDeliveryChatId: 'chat-beta',
1076
+ });
1077
+
1078
+ serviceRevokeBinding('asst-A', 'telegram');
1079
+
1080
+ expect(getGuardianBinding('asst-A', 'telegram')).toBeNull();
1081
+ expect(getGuardianBinding('asst-B', 'telegram')).not.toBeNull();
1082
+ });
1083
+
1084
+ test('validateAndConsumeChallenge scoped to assistantId', () => {
1085
+ // Create challenge for asst-A
1086
+ const { secret: secretA } = createVerificationChallenge('asst-A', 'telegram');
1087
+ // Create challenge for asst-B
1088
+ const { secret: secretB } = createVerificationChallenge('asst-B', 'telegram');
1089
+
1090
+ // Attempting to consume asst-A challenge with asst-B should fail
1091
+ const crossResult = validateAndConsumeChallenge(
1092
+ 'asst-B', 'telegram', secretA, 'user-1', 'chat-1',
1093
+ );
1094
+ expect(crossResult.success).toBe(false);
1095
+
1096
+ // Consuming with correct assistantId should succeed
1097
+ const resultA = validateAndConsumeChallenge(
1098
+ 'asst-A', 'telegram', secretA, 'user-1', 'chat-1',
1099
+ );
1100
+ expect(resultA.success).toBe(true);
1101
+
1102
+ const resultB = validateAndConsumeChallenge(
1103
+ 'asst-B', 'telegram', secretB, 'user-2', 'chat-2',
1104
+ );
1105
+ expect(resultB.success).toBe(true);
1106
+
1107
+ // Verify bindings are scoped correctly
1108
+ const bindingA = getGuardianBinding('asst-A', 'telegram');
1109
+ const bindingB = getGuardianBinding('asst-B', 'telegram');
1110
+ expect(bindingA!.guardianExternalUserId).toBe('user-1');
1111
+ expect(bindingB!.guardianExternalUserId).toBe('user-2');
1112
+ });
1113
+ });
1114
+
1115
+ // ═══════════════════════════════════════════════════════════════════════════
1116
+ // 9. Assistant-scoped approval request lookups
1117
+ // ═══════════════════════════════════════════════════════════════════════════
1118
+
1119
+ describe('assistant-scoped approval request lookups', () => {
1120
+ beforeEach(() => {
1121
+ resetTables();
1122
+ });
1123
+
1124
+ test('createApprovalRequest stores assistantId and defaults to self', () => {
1125
+ const reqWithoutId = createApprovalRequest({
1126
+ runId: 'run-1',
1127
+ conversationId: 'conv-1',
1128
+ channel: 'telegram',
1129
+ requesterExternalUserId: 'user-99',
1130
+ requesterChatId: 'chat-99',
1131
+ guardianExternalUserId: 'user-42',
1132
+ guardianChatId: 'chat-42',
1133
+ toolName: 'shell',
1134
+ expiresAt: Date.now() + 300_000,
1135
+ });
1136
+ expect(reqWithoutId.assistantId).toBe('self');
1137
+
1138
+ const reqWithId = createApprovalRequest({
1139
+ runId: 'run-2',
1140
+ conversationId: 'conv-2',
1141
+ assistantId: 'asst-A',
1142
+ channel: 'telegram',
1143
+ requesterExternalUserId: 'user-99',
1144
+ requesterChatId: 'chat-99',
1145
+ guardianExternalUserId: 'user-42',
1146
+ guardianChatId: 'chat-42',
1147
+ toolName: 'browser',
1148
+ expiresAt: Date.now() + 300_000,
1149
+ });
1150
+ expect(reqWithId.assistantId).toBe('asst-A');
1151
+ });
1152
+
1153
+ test('approval requests from different assistants are independent', () => {
1154
+ createApprovalRequest({
1155
+ runId: 'run-A',
1156
+ conversationId: 'conv-A',
1157
+ assistantId: 'asst-A',
1158
+ channel: 'telegram',
1159
+ requesterExternalUserId: 'user-99',
1160
+ requesterChatId: 'chat-99',
1161
+ guardianExternalUserId: 'user-42',
1162
+ guardianChatId: 'chat-42',
1163
+ toolName: 'shell',
1164
+ expiresAt: Date.now() + 300_000,
1165
+ });
1166
+ createApprovalRequest({
1167
+ runId: 'run-B',
1168
+ conversationId: 'conv-B',
1169
+ assistantId: 'asst-B',
1170
+ channel: 'telegram',
1171
+ requesterExternalUserId: 'user-88',
1172
+ requesterChatId: 'chat-88',
1173
+ guardianExternalUserId: 'user-42',
1174
+ guardianChatId: 'chat-42',
1175
+ toolName: 'browser',
1176
+ expiresAt: Date.now() + 300_000,
1177
+ });
1178
+
1179
+ const foundA = getPendingApprovalForRun('run-A');
1180
+ const foundB = getPendingApprovalForRun('run-B');
1181
+ expect(foundA).not.toBeNull();
1182
+ expect(foundB).not.toBeNull();
1183
+ expect(foundA!.assistantId).toBe('asst-A');
1184
+ expect(foundB!.assistantId).toBe('asst-B');
1185
+ expect(foundA!.toolName).toBe('shell');
1186
+ expect(foundB!.toolName).toBe('browser');
1187
+ });
1188
+ });
@@ -189,6 +189,7 @@ describe('Invariant 2: no generic plaintext secret read API', () => {
189
189
  'calls/elevenlabs-config.ts', // ElevenLabs voice quality API key lookup
190
190
  'calls/twilio-config.ts', // call infrastructure credential lookup
191
191
  'calls/twilio-provider.ts', // call infrastructure credential lookup
192
+ 'calls/twilio-rest.ts', // Twilio REST API credential lookup
192
193
  'cli/config-commands.ts', // CLI credential management commands
193
194
  'runtime/http-server.ts', // HTTP server credential lookup
194
195
  'daemon/handlers/twitter-auth.ts', // Twitter OAuth token storage
@@ -159,6 +159,10 @@ mock.module('../security/secret-allowlist.js', () => ({
159
159
  resetAllowlist: () => {},
160
160
  }));
161
161
 
162
+ mock.module('../memory/external-conversation-store.js', () => ({
163
+ getBindingsForConversations: () => new Map(),
164
+ }));
165
+
162
166
  mock.module('../memory/conversation-store.js', () => ({
163
167
  getLatestConversation: () => conversation,
164
168
  createConversation: (titleOrOpts?: string | { title?: string; threadType?: string }) => {
@@ -181,6 +185,7 @@ mock.module('../memory/conversation-store.js', () => ({
181
185
  },
182
186
  getMessages: () => [],
183
187
  listConversations: () => [conversation],
188
+ countConversations: () => 1,
184
189
  }));
185
190
 
186
191
  mock.module('../daemon/session.js', () => ({
@@ -113,14 +113,11 @@ mock.module('../security/secure-keys.js', () => ({
113
113
  deleteSecureKey: () => {},
114
114
  }));
115
115
 
116
- mock.module('../inbound/public-ingress-urls.js', () => ({
117
- getPublicBaseUrl: () => 'https://test.example.com',
118
- getTwilioRelayUrl: () => 'wss://test.example.com/webhooks/twilio/relay',
119
- getTwilioVoiceWebhookUrl: (_cfg: unknown, id: string) => `https://test.example.com/webhooks/twilio/voice?callSessionId=${id}`,
120
- getTwilioStatusCallbackUrl: () => 'https://test.example.com/webhooks/twilio/status',
121
- getTwilioConnectActionUrl: () => 'https://test.example.com/webhooks/twilio/connect-action',
122
- getOAuthCallbackUrl: () => 'https://test.example.com/webhooks/oauth/callback',
123
- }));
116
+ // NOTE: Do NOT mock '../inbound/public-ingress-urls.js' here.
117
+ // Those are pure functions that derive URLs from the config object returned by
118
+ // loadConfig() (which is already mocked above). Mocking them at the module level
119
+ // leaks into other test files (e.g. ingress-url-consistency.test.ts) that need
120
+ // the real implementations, causing cross-test contamination.
124
121
 
125
122
  // Mock the oauth callback registry
126
123
  mock.module('../security/oauth-callback-registry.js', () => ({
@@ -289,6 +286,49 @@ describe('gateway-only ingress enforcement', () => {
289
286
  });
290
287
  });
291
288
 
289
+ // ── SMS-specific direct webhook routes blocked ──────────────────────
290
+
291
+ describe('SMS webhook routes are blocked at the runtime (gateway-only)', () => {
292
+
293
+ test('POST /webhooks/twilio/sms returns 410 (cannot bypass gateway)', async () => {
294
+ const res = await fetch(`http://127.0.0.1:${port}/webhooks/twilio/sms`, {
295
+ method: 'POST',
296
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
297
+ body: makeFormBody({ Body: 'hello', From: '+15551234567', To: '+15559876543', MessageSid: 'SM123' }),
298
+ });
299
+ expect(res.status).toBe(410);
300
+ const body = await res.json() as { error: string; code: string };
301
+ expect(body.code).toBe('GATEWAY_ONLY');
302
+ expect(body.error).toContain('Direct webhook access disabled');
303
+ });
304
+
305
+ test('POST /v1/calls/twilio/sms returns 410 (legacy path also blocked)', async () => {
306
+ const res = await fetch(`http://127.0.0.1:${port}/v1/calls/twilio/sms`, {
307
+ method: 'POST',
308
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
309
+ body: makeFormBody({ Body: 'hello', From: '+15551234567', MessageSid: 'SM456' }),
310
+ });
311
+ expect(res.status).toBe(410);
312
+ const body = await res.json() as { error: string; code: string };
313
+ expect(body.code).toBe('GATEWAY_ONLY');
314
+ });
315
+
316
+ test('POST /webhooks/twilio/sms with valid auth still returns 410 (auth does not bypass gateway-only)', async () => {
317
+ const res = await fetch(`http://127.0.0.1:${port}/webhooks/twilio/sms`, {
318
+ method: 'POST',
319
+ headers: {
320
+ ...AUTH_HEADERS,
321
+ 'Content-Type': 'application/x-www-form-urlencoded',
322
+ },
323
+ body: makeFormBody({ Body: 'sneaky', From: '+15551234567', MessageSid: 'SM789' }),
324
+ });
325
+ // The gateway-only guard runs before auth for Twilio webhook paths
326
+ expect(res.status).toBe(410);
327
+ const body = await res.json() as { error: string; code: string };
328
+ expect(body.code).toBe('GATEWAY_ONLY');
329
+ });
330
+ });
331
+
292
332
  // ── Internal forwarding routes still work ─────
293
333
 
294
334
  describe('internal forwarding routes are not blocked', () => {
@@ -601,6 +641,45 @@ describe('gateway-only ingress enforcement', () => {
601
641
  // header, the request is rejected if bearer auth is missing.
602
642
  expect(res.status).toBe(401);
603
643
  });
644
+
645
+ test('POST /v1/channels/inbound with SMS sourceChannel requires X-Gateway-Origin', async () => {
646
+ const res = await fetch(`http://127.0.0.1:${port}/v1/channels/inbound`, {
647
+ method: 'POST',
648
+ headers: {
649
+ ...AUTH_HEADERS,
650
+ 'Content-Type': 'application/json',
651
+ },
652
+ body: JSON.stringify({
653
+ sourceChannel: 'sms',
654
+ externalChatId: '+15551234567',
655
+ externalMessageId: 'SM-test-gw-1',
656
+ content: 'hello via SMS',
657
+ }),
658
+ });
659
+ // SMS messages must also go through the gateway — missing X-Gateway-Origin is rejected.
660
+ expect(res.status).toBe(403);
661
+ const body = await res.json() as { error: string; code: string };
662
+ expect(body.code).toBe('GATEWAY_ORIGIN_REQUIRED');
663
+ });
664
+
665
+ test('POST /v1/channels/inbound with SMS sourceChannel and valid X-Gateway-Origin passes', async () => {
666
+ const res = await fetch(`http://127.0.0.1:${port}/v1/channels/inbound`, {
667
+ method: 'POST',
668
+ headers: {
669
+ ...AUTH_HEADERS,
670
+ 'Content-Type': 'application/json',
671
+ 'X-Gateway-Origin': TEST_TOKEN,
672
+ },
673
+ body: JSON.stringify({
674
+ sourceChannel: 'sms',
675
+ externalChatId: '+15551234567',
676
+ externalMessageId: 'SM-test-gw-2',
677
+ content: 'hello via SMS',
678
+ }),
679
+ });
680
+ // Should NOT be 403 — the gateway-origin check passes.
681
+ expect(res.status).not.toBe(403);
682
+ });
604
683
  });
605
684
 
606
685
  // ── Startup warning for non-loopback host ──────────────────────────
@@ -905,6 +905,7 @@ describe('Telegram config handler', () => {
905
905
 
906
906
  import { handleGuardianVerification } from '../daemon/handlers/config.js';
907
907
  import type { GuardianVerificationRequest } from '../daemon/ipc-contract.js';
908
+ import { createBinding } from '../memory/channel-guardian-store.js';
908
909
 
909
910
  describe('Guardian verification IPC actions', () => {
910
911
  beforeEach(() => {
@@ -965,4 +966,85 @@ describe('Guardian verification IPC actions', () => {
965
966
  expect(res.success).toBe(false);
966
967
  expect(res.error).toContain('Unknown action');
967
968
  });
969
+
970
+ test('create_challenge with explicit assistantId scopes challenge to that assistant', () => {
971
+ const msg: GuardianVerificationRequest = {
972
+ type: 'guardian_verification',
973
+ action: 'create_challenge',
974
+ channel: 'telegram',
975
+ assistantId: 'asst-ipc-X',
976
+ };
977
+
978
+ const { ctx, sent } = createTestContext();
979
+ handleGuardianVerification(msg, {} as net.Socket, ctx);
980
+
981
+ expect(sent).toHaveLength(1);
982
+ const res = sent[0] as { type: string; success: boolean; secret?: string; instruction?: string };
983
+ expect(res.success).toBe(true);
984
+ expect(res.secret).toBeDefined();
985
+ expect(res.instruction).toContain('/guardian_verify');
986
+ });
987
+
988
+ test('status action with explicit assistantId checks binding for that assistant', () => {
989
+ // Create a control binding for a known assistant so we can verify
990
+ // that querying a *different* assistantId actually returns bound=false
991
+ // (not just because no bindings exist at all).
992
+ createBinding({
993
+ assistantId: 'asst-ipc-bound',
994
+ channel: 'telegram',
995
+ guardianExternalUserId: 'guardian-user-1',
996
+ guardianDeliveryChatId: 'guardian-chat-1',
997
+ });
998
+
999
+ // Querying a different assistant should return bound=false
1000
+ const unboundMsg: GuardianVerificationRequest = {
1001
+ type: 'guardian_verification',
1002
+ action: 'status',
1003
+ channel: 'telegram',
1004
+ assistantId: 'asst-ipc-Y',
1005
+ };
1006
+
1007
+ const { ctx: ctx1, sent: sent1 } = createTestContext();
1008
+ handleGuardianVerification(unboundMsg, {} as net.Socket, ctx1);
1009
+
1010
+ expect(sent1).toHaveLength(1);
1011
+ const unboundRes = sent1[0] as { type: string; success: boolean; bound: boolean };
1012
+ expect(unboundRes.success).toBe(true);
1013
+ expect(unboundRes.bound).toBe(false);
1014
+
1015
+ // Querying the bound assistant should return bound=true
1016
+ const boundMsg: GuardianVerificationRequest = {
1017
+ type: 'guardian_verification',
1018
+ action: 'status',
1019
+ channel: 'telegram',
1020
+ assistantId: 'asst-ipc-bound',
1021
+ };
1022
+
1023
+ const { ctx: ctx2, sent: sent2 } = createTestContext();
1024
+ handleGuardianVerification(boundMsg, {} as net.Socket, ctx2);
1025
+
1026
+ expect(sent2).toHaveLength(1);
1027
+ const boundRes = sent2[0] as { type: string; success: boolean; bound: boolean; guardianExternalUserId?: string };
1028
+ expect(boundRes.success).toBe(true);
1029
+ expect(boundRes.bound).toBe(true);
1030
+ expect(boundRes.guardianExternalUserId).toBe('guardian-user-1');
1031
+ });
1032
+
1033
+ test('assistantId defaults to "self" when not provided', () => {
1034
+ // create_challenge without assistantId should scope to 'self'
1035
+ const createMsg: GuardianVerificationRequest = {
1036
+ type: 'guardian_verification',
1037
+ action: 'create_challenge',
1038
+ channel: 'telegram',
1039
+ // assistantId intentionally omitted
1040
+ };
1041
+
1042
+ const { ctx: ctx1, sent: sent1 } = createTestContext();
1043
+ handleGuardianVerification(createMsg, {} as net.Socket, ctx1);
1044
+
1045
+ expect(sent1).toHaveLength(1);
1046
+ const createRes = sent1[0] as { type: string; success: boolean; secret?: string };
1047
+ expect(createRes.success).toBe(true);
1048
+ expect(createRes.secret).toBeDefined();
1049
+ });
968
1050
  });