@vellumai/assistant 0.3.2 → 0.3.4

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 (109) hide show
  1. package/README.md +82 -21
  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__/call-orchestrator.test.ts +321 -0
  7. package/src/__tests__/channel-approval-routes.test.ts +1267 -93
  8. package/src/__tests__/channel-approval.test.ts +2 -0
  9. package/src/__tests__/channel-approvals.test.ts +51 -2
  10. package/src/__tests__/channel-delivery-store.test.ts +130 -1
  11. package/src/__tests__/channel-guardian.test.ts +371 -1
  12. package/src/__tests__/config-schema.test.ts +1 -1
  13. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  14. package/src/__tests__/daemon-lifecycle.test.ts +635 -0
  15. package/src/__tests__/daemon-server-session-init.test.ts +5 -0
  16. package/src/__tests__/gateway-only-enforcement.test.ts +106 -21
  17. package/src/__tests__/handlers-telegram-config.test.ts +82 -0
  18. package/src/__tests__/handlers-twilio-config.test.ts +738 -5
  19. package/src/__tests__/ingress-url-consistency.test.ts +64 -0
  20. package/src/__tests__/ipc-snapshot.test.ts +10 -0
  21. package/src/__tests__/run-orchestrator.test.ts +1 -1
  22. package/src/__tests__/secret-scanner.test.ts +223 -0
  23. package/src/__tests__/session-process-bridge.test.ts +2 -0
  24. package/src/__tests__/shell-parser-property.test.ts +357 -2
  25. package/src/__tests__/system-prompt.test.ts +25 -1
  26. package/src/__tests__/tool-executor-lifecycle-events.test.ts +34 -1
  27. package/src/__tests__/tool-permission-simulate-handler.test.ts +2 -2
  28. package/src/__tests__/user-reference.test.ts +68 -0
  29. package/src/calls/call-orchestrator.ts +63 -11
  30. package/src/calls/twilio-config.ts +10 -1
  31. package/src/calls/twilio-rest.ts +70 -0
  32. package/src/cli/map.ts +6 -0
  33. package/src/commands/__tests__/cc-command-registry.test.ts +67 -0
  34. package/src/commands/cc-command-registry.ts +14 -1
  35. package/src/config/bundled-skills/claude-code/TOOLS.json +10 -3
  36. package/src/config/bundled-skills/email-setup/SKILL.md +56 -0
  37. package/src/config/bundled-skills/messaging/SKILL.md +4 -0
  38. package/src/config/bundled-skills/subagent/SKILL.md +4 -0
  39. package/src/config/bundled-skills/subagent/TOOLS.json +4 -0
  40. package/src/config/defaults.ts +1 -1
  41. package/src/config/schema.ts +6 -3
  42. package/src/config/skills.ts +5 -32
  43. package/src/config/system-prompt.ts +16 -0
  44. package/src/config/user-reference.ts +29 -0
  45. package/src/config/vellum-skills/catalog.json +52 -0
  46. package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -1
  47. package/src/config/vellum-skills/twilio-setup/SKILL.md +49 -4
  48. package/src/daemon/auth-manager.ts +103 -0
  49. package/src/daemon/computer-use-session.ts +8 -1
  50. package/src/daemon/config-watcher.ts +253 -0
  51. package/src/daemon/handlers/config.ts +193 -17
  52. package/src/daemon/handlers/sessions.ts +5 -3
  53. package/src/daemon/handlers/skills.ts +60 -17
  54. package/src/daemon/ipc-contract-inventory.json +4 -0
  55. package/src/daemon/ipc-contract.ts +16 -0
  56. package/src/daemon/ipc-handler.ts +87 -0
  57. package/src/daemon/lifecycle.ts +16 -4
  58. package/src/daemon/ride-shotgun-handler.ts +11 -1
  59. package/src/daemon/server.ts +105 -502
  60. package/src/daemon/session-agent-loop.ts +9 -14
  61. package/src/daemon/session-process.ts +20 -3
  62. package/src/daemon/session-runtime-assembly.ts +60 -44
  63. package/src/daemon/session-slash.ts +50 -2
  64. package/src/daemon/session-surfaces.ts +17 -1
  65. package/src/daemon/session.ts +8 -1
  66. package/src/inbound/public-ingress-urls.ts +20 -3
  67. package/src/index.ts +1 -23
  68. package/src/memory/app-git-service.ts +24 -0
  69. package/src/memory/app-store.ts +0 -21
  70. package/src/memory/channel-delivery-store.ts +74 -3
  71. package/src/memory/channel-guardian-store.ts +54 -26
  72. package/src/memory/conversation-key-store.ts +20 -0
  73. package/src/memory/conversation-store.ts +14 -2
  74. package/src/memory/db-connection.ts +28 -0
  75. package/src/memory/db-init.ts +1019 -0
  76. package/src/memory/db.ts +2 -1995
  77. package/src/memory/embedding-backend.ts +79 -11
  78. package/src/memory/indexer.ts +2 -0
  79. package/src/memory/job-utils.ts +64 -4
  80. package/src/memory/jobs-worker.ts +7 -1
  81. package/src/memory/recall-cache.ts +107 -0
  82. package/src/memory/retriever.ts +30 -1
  83. package/src/memory/schema-migration.ts +984 -0
  84. package/src/memory/schema.ts +6 -0
  85. package/src/memory/search/types.ts +2 -0
  86. package/src/permissions/prompter.ts +14 -3
  87. package/src/permissions/trust-store.ts +7 -0
  88. package/src/runtime/channel-approvals.ts +17 -3
  89. package/src/runtime/gateway-client.ts +2 -1
  90. package/src/runtime/http-server.ts +28 -9
  91. package/src/runtime/routes/channel-routes.ts +279 -100
  92. package/src/runtime/routes/run-routes.ts +7 -1
  93. package/src/runtime/run-orchestrator.ts +8 -1
  94. package/src/security/secret-scanner.ts +218 -0
  95. package/src/skills/clawhub.ts +6 -2
  96. package/src/skills/frontmatter.ts +63 -0
  97. package/src/skills/slash-commands.ts +23 -0
  98. package/src/skills/vellum-catalog-remote.ts +107 -0
  99. package/src/subagent/manager.ts +4 -1
  100. package/src/subagent/types.ts +2 -0
  101. package/src/tools/browser/auto-navigate.ts +132 -24
  102. package/src/tools/browser/browser-manager.ts +67 -61
  103. package/src/tools/claude-code/claude-code.ts +55 -3
  104. package/src/tools/executor.ts +10 -2
  105. package/src/tools/skills/vellum-catalog.ts +75 -127
  106. package/src/tools/subagent/spawn.ts +2 -0
  107. package/src/tools/terminal/parser.ts +21 -5
  108. package/src/util/platform.ts +8 -1
  109. package/src/util/retry.ts +4 -4
@@ -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({
@@ -298,6 +298,53 @@ describe('handleChannelDecision', () => {
298
298
  expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'deny');
299
299
  });
300
300
 
301
+ test('uses decision.runId to target the matching pending run', () => {
302
+ ensureConversation('conv-1');
303
+ const olderRun = createRun('conv-1');
304
+ setRunConfirmation(olderRun.id, {
305
+ ...sampleConfirmation,
306
+ toolName: 'shell',
307
+ toolUseId: 'req-older',
308
+ });
309
+ const newerRun = createRun('conv-1');
310
+ setRunConfirmation(newerRun.id, {
311
+ ...sampleConfirmation,
312
+ toolName: 'browser',
313
+ toolUseId: 'req-newer',
314
+ });
315
+
316
+ const orchestrator = makeMockOrchestrator();
317
+ const decision: ApprovalDecisionResult = {
318
+ action: 'approve_once',
319
+ source: 'telegram_button',
320
+ runId: newerRun.id,
321
+ };
322
+
323
+ const result = handleChannelDecision('conv-1', decision, orchestrator);
324
+ expect(result.applied).toBe(true);
325
+ expect(result.runId).toBe(newerRun.id);
326
+ expect(orchestrator.submitDecision).toHaveBeenCalledWith(newerRun.id, 'allow');
327
+ expect(orchestrator.submitDecision).not.toHaveBeenCalledWith(olderRun.id, 'allow');
328
+ });
329
+
330
+ test('returns applied: false when decision.runId does not match a pending run', () => {
331
+ ensureConversation('conv-1');
332
+ const run = createRun('conv-1');
333
+ setRunConfirmation(run.id, sampleConfirmation);
334
+
335
+ const orchestrator = makeMockOrchestrator();
336
+ const decision: ApprovalDecisionResult = {
337
+ action: 'approve_once',
338
+ source: 'telegram_button',
339
+ runId: 'run-missing',
340
+ };
341
+
342
+ const result = handleChannelDecision('conv-1', decision, orchestrator);
343
+ expect(result.applied).toBe(false);
344
+ expect(result.runId).toBeUndefined();
345
+ expect(orchestrator.submitDecision).not.toHaveBeenCalled();
346
+ });
347
+
301
348
  test('approve_always adds a trust rule and submits "allow"', () => {
302
349
  ensureConversation('conv-1');
303
350
  const run = createRun('conv-1');
@@ -319,14 +366,16 @@ describe('handleChannelDecision', () => {
319
366
  expect(result.applied).toBe(true);
320
367
  expect(result.runId).toBe(run.id);
321
368
 
322
- // Trust rule added with first allowlist and scope option
369
+ // Trust rule added with first allowlist and scope option.
370
+ // executionTarget is undefined for core tools like 'shell' — only
371
+ // skill-origin tools persist it (see channel-approvals.ts).
323
372
  expect(addRuleSpy).toHaveBeenCalledWith(
324
373
  'shell',
325
374
  'rm -rf *',
326
375
  '/tmp/project',
327
376
  'allow',
328
377
  100,
329
- { executionTarget: 'sandbox' },
378
+ { executionTarget: undefined },
330
379
  );
331
380
 
332
381
  // The run is still approved with a simple "allow"
@@ -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, externalConversationBindings, 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,110 @@ 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 with non-self assistant deletes only 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
+ db.insert(externalConversationBindings).values({
492
+ conversationId: convId,
493
+ sourceChannel: 'telegram',
494
+ externalChatId: 'chat-del',
495
+ createdAt: now,
496
+ updatedAt: now,
497
+ }).run();
498
+
499
+ // Verify both keys exist
500
+ expect(getConversationByKey(scopedKey)).not.toBeNull();
501
+ expect(getConversationByKey(legacyKey)).not.toBeNull();
502
+
503
+ // Call handleDeleteConversation with assistantId as a parameter (not in body)
504
+ const req = new Request('http://localhost/channels/conversation', {
505
+ method: 'DELETE',
506
+ headers: { 'Content-Type': 'application/json' },
507
+ body: JSON.stringify({
508
+ sourceChannel: 'telegram',
509
+ externalChatId: 'chat-del',
510
+ // Note: no assistantId in the body — it comes from the route param
511
+ }),
512
+ });
513
+
514
+ const res = await handleDeleteConversation(req, 'my-assistant');
515
+ expect(res.status).toBe(200);
516
+
517
+ const json = await res.json() as { ok: boolean };
518
+ expect(json.ok).toBe(true);
519
+
520
+ // Non-self delete should only remove the scoped key and preserve legacy.
521
+ expect(getConversationByKey(scopedKey)).toBeNull();
522
+ expect(getConversationByKey(legacyKey)).not.toBeNull();
523
+ // Non-self delete should not mutate assistant-agnostic external bindings.
524
+ const remainingBinding = db.select()
525
+ .from(externalConversationBindings)
526
+ .where(eq(externalConversationBindings.conversationId, convId))
527
+ .get();
528
+ expect(remainingBinding).not.toBeNull();
529
+ });
530
+
531
+ test('handleDeleteConversation defaults to "self" when no assistantId provided', async () => {
532
+ const convId = 'conv-delete-default';
533
+ const scopedKey = 'asst:self:telegram:chat-def';
534
+ const legacyKey = 'telegram:chat-def';
535
+
536
+ const now = Date.now();
537
+ const db = getDb();
538
+ db.insert(conversations).values({
539
+ id: convId,
540
+ title: 'test',
541
+ createdAt: now,
542
+ updatedAt: now,
543
+ }).run();
544
+ setConversationKey(scopedKey, convId);
545
+ setConversationKey(legacyKey, convId);
546
+ db.insert(externalConversationBindings).values({
547
+ conversationId: convId,
548
+ sourceChannel: 'telegram',
549
+ externalChatId: 'chat-def',
550
+ createdAt: now,
551
+ updatedAt: now,
552
+ }).run();
553
+
554
+ const req = new Request('http://localhost/channels/conversation', {
555
+ method: 'DELETE',
556
+ headers: { 'Content-Type': 'application/json' },
557
+ body: JSON.stringify({
558
+ sourceChannel: 'telegram',
559
+ externalChatId: 'chat-def',
560
+ }),
561
+ });
562
+
563
+ // No assistantId parameter — should default to 'self'
564
+ const res = await handleDeleteConversation(req);
565
+ expect(res.status).toBe(200);
566
+
567
+ expect(getConversationByKey(scopedKey)).toBeNull();
568
+ expect(getConversationByKey(legacyKey)).toBeNull();
569
+ // Self delete should keep external bindings in sync for the canonical route.
570
+ const remainingBinding = db.select()
571
+ .from(externalConversationBindings)
572
+ .where(eq(externalConversationBindings.conversationId, convId))
573
+ .get();
574
+ expect(remainingBinding).toBeUndefined();
575
+ });
447
576
  });
@@ -52,6 +52,10 @@ import {
52
52
  isGuardian,
53
53
  revokeBinding as serviceRevokeBinding,
54
54
  } from '../runtime/channel-guardian-service.js';
55
+ import { handleGuardianVerification } from '../daemon/handlers/config.js';
56
+ import type { GuardianVerificationRequest, GuardianVerificationResponse } from '../daemon/ipc-contract.js';
57
+ import type { HandlerContext } from '../daemon/handlers/shared.js';
58
+ import type * as net from 'node:net';
55
59
 
56
60
  initializeDb();
57
61
 
@@ -949,7 +953,7 @@ describe('guardian service rate limiting', () => {
949
953
 
950
954
  test('valid challenge still succeeds when under threshold', () => {
951
955
  // Record a couple invalid attempts
952
- const { secret } = createVerificationChallenge('asst-1', 'telegram');
956
+ const { secret: _secret } = createVerificationChallenge('asst-1', 'telegram');
953
957
  validateAndConsumeChallenge('asst-1', 'telegram', 'wrong-1', 'user-42', 'chat-42');
954
958
  validateAndConsumeChallenge('asst-1', 'telegram', 'wrong-2', 'user-42', 'chat-42');
955
959
 
@@ -1003,3 +1007,369 @@ describe('guardian service rate limiting', () => {
1003
1007
  expect(result.success).toBe(true);
1004
1008
  });
1005
1009
  });
1010
+
1011
+ // ═══════════════════════════════════════════════════════════════════════════
1012
+ // 8. Assistant-scoped guardian resolution
1013
+ // ═══════════════════════════════════════════════════════════════════════════
1014
+
1015
+ describe('assistant-scoped guardian resolution', () => {
1016
+ beforeEach(() => {
1017
+ resetTables();
1018
+ });
1019
+
1020
+ test('isGuardian resolves independently per assistantId', () => {
1021
+ // Create guardian binding for asst-A on telegram
1022
+ createBinding({
1023
+ assistantId: 'asst-A',
1024
+ channel: 'telegram',
1025
+ guardianExternalUserId: 'user-alpha',
1026
+ guardianDeliveryChatId: 'chat-alpha',
1027
+ });
1028
+ // Create guardian binding for asst-B on telegram with a different user
1029
+ createBinding({
1030
+ assistantId: 'asst-B',
1031
+ channel: 'telegram',
1032
+ guardianExternalUserId: 'user-beta',
1033
+ guardianDeliveryChatId: 'chat-beta',
1034
+ });
1035
+
1036
+ // user-alpha is guardian for asst-A but not asst-B
1037
+ expect(isGuardian('asst-A', 'telegram', 'user-alpha')).toBe(true);
1038
+ expect(isGuardian('asst-B', 'telegram', 'user-alpha')).toBe(false);
1039
+
1040
+ // user-beta is guardian for asst-B but not asst-A
1041
+ expect(isGuardian('asst-B', 'telegram', 'user-beta')).toBe(true);
1042
+ expect(isGuardian('asst-A', 'telegram', 'user-beta')).toBe(false);
1043
+ });
1044
+
1045
+ test('getGuardianBinding returns different bindings for different assistants', () => {
1046
+ createBinding({
1047
+ assistantId: 'asst-A',
1048
+ channel: 'telegram',
1049
+ guardianExternalUserId: 'user-alpha',
1050
+ guardianDeliveryChatId: 'chat-alpha',
1051
+ });
1052
+ createBinding({
1053
+ assistantId: 'asst-B',
1054
+ channel: 'telegram',
1055
+ guardianExternalUserId: 'user-beta',
1056
+ guardianDeliveryChatId: 'chat-beta',
1057
+ });
1058
+
1059
+ const bindingA = getGuardianBinding('asst-A', 'telegram');
1060
+ const bindingB = getGuardianBinding('asst-B', 'telegram');
1061
+
1062
+ expect(bindingA).not.toBeNull();
1063
+ expect(bindingB).not.toBeNull();
1064
+ expect(bindingA!.guardianExternalUserId).toBe('user-alpha');
1065
+ expect(bindingB!.guardianExternalUserId).toBe('user-beta');
1066
+ });
1067
+
1068
+ test('revoking binding for one assistant does not affect another', () => {
1069
+ createBinding({
1070
+ assistantId: 'asst-A',
1071
+ channel: 'telegram',
1072
+ guardianExternalUserId: 'user-alpha',
1073
+ guardianDeliveryChatId: 'chat-alpha',
1074
+ });
1075
+ createBinding({
1076
+ assistantId: 'asst-B',
1077
+ channel: 'telegram',
1078
+ guardianExternalUserId: 'user-beta',
1079
+ guardianDeliveryChatId: 'chat-beta',
1080
+ });
1081
+
1082
+ serviceRevokeBinding('asst-A', 'telegram');
1083
+
1084
+ expect(getGuardianBinding('asst-A', 'telegram')).toBeNull();
1085
+ expect(getGuardianBinding('asst-B', 'telegram')).not.toBeNull();
1086
+ });
1087
+
1088
+ test('validateAndConsumeChallenge scoped to assistantId', () => {
1089
+ // Create challenge for asst-A
1090
+ const { secret: secretA } = createVerificationChallenge('asst-A', 'telegram');
1091
+ // Create challenge for asst-B
1092
+ const { secret: secretB } = createVerificationChallenge('asst-B', 'telegram');
1093
+
1094
+ // Attempting to consume asst-A challenge with asst-B should fail
1095
+ const crossResult = validateAndConsumeChallenge(
1096
+ 'asst-B', 'telegram', secretA, 'user-1', 'chat-1',
1097
+ );
1098
+ expect(crossResult.success).toBe(false);
1099
+
1100
+ // Consuming with correct assistantId should succeed
1101
+ const resultA = validateAndConsumeChallenge(
1102
+ 'asst-A', 'telegram', secretA, 'user-1', 'chat-1',
1103
+ );
1104
+ expect(resultA.success).toBe(true);
1105
+
1106
+ const resultB = validateAndConsumeChallenge(
1107
+ 'asst-B', 'telegram', secretB, 'user-2', 'chat-2',
1108
+ );
1109
+ expect(resultB.success).toBe(true);
1110
+
1111
+ // Verify bindings are scoped correctly
1112
+ const bindingA = getGuardianBinding('asst-A', 'telegram');
1113
+ const bindingB = getGuardianBinding('asst-B', 'telegram');
1114
+ expect(bindingA!.guardianExternalUserId).toBe('user-1');
1115
+ expect(bindingB!.guardianExternalUserId).toBe('user-2');
1116
+ });
1117
+ });
1118
+
1119
+ // ═══════════════════════════════════════════════════════════════════════════
1120
+ // 9. Assistant-scoped approval request lookups
1121
+ // ═══════════════════════════════════════════════════════════════════════════
1122
+
1123
+ describe('assistant-scoped approval request lookups', () => {
1124
+ beforeEach(() => {
1125
+ resetTables();
1126
+ });
1127
+
1128
+ test('createApprovalRequest stores assistantId and defaults to self', () => {
1129
+ const reqWithoutId = createApprovalRequest({
1130
+ runId: 'run-1',
1131
+ conversationId: 'conv-1',
1132
+ channel: 'telegram',
1133
+ requesterExternalUserId: 'user-99',
1134
+ requesterChatId: 'chat-99',
1135
+ guardianExternalUserId: 'user-42',
1136
+ guardianChatId: 'chat-42',
1137
+ toolName: 'shell',
1138
+ expiresAt: Date.now() + 300_000,
1139
+ });
1140
+ expect(reqWithoutId.assistantId).toBe('self');
1141
+
1142
+ const reqWithId = createApprovalRequest({
1143
+ runId: 'run-2',
1144
+ conversationId: 'conv-2',
1145
+ assistantId: 'asst-A',
1146
+ channel: 'telegram',
1147
+ requesterExternalUserId: 'user-99',
1148
+ requesterChatId: 'chat-99',
1149
+ guardianExternalUserId: 'user-42',
1150
+ guardianChatId: 'chat-42',
1151
+ toolName: 'browser',
1152
+ expiresAt: Date.now() + 300_000,
1153
+ });
1154
+ expect(reqWithId.assistantId).toBe('asst-A');
1155
+ });
1156
+
1157
+ test('approval requests from different assistants are independent', () => {
1158
+ createApprovalRequest({
1159
+ runId: 'run-A',
1160
+ conversationId: 'conv-A',
1161
+ assistantId: 'asst-A',
1162
+ channel: 'telegram',
1163
+ requesterExternalUserId: 'user-99',
1164
+ requesterChatId: 'chat-99',
1165
+ guardianExternalUserId: 'user-42',
1166
+ guardianChatId: 'chat-42',
1167
+ toolName: 'shell',
1168
+ expiresAt: Date.now() + 300_000,
1169
+ });
1170
+ createApprovalRequest({
1171
+ runId: 'run-B',
1172
+ conversationId: 'conv-B',
1173
+ assistantId: 'asst-B',
1174
+ channel: 'telegram',
1175
+ requesterExternalUserId: 'user-88',
1176
+ requesterChatId: 'chat-88',
1177
+ guardianExternalUserId: 'user-42',
1178
+ guardianChatId: 'chat-42',
1179
+ toolName: 'browser',
1180
+ expiresAt: Date.now() + 300_000,
1181
+ });
1182
+
1183
+ const foundA = getPendingApprovalForRun('run-A');
1184
+ const foundB = getPendingApprovalForRun('run-B');
1185
+ expect(foundA).not.toBeNull();
1186
+ expect(foundB).not.toBeNull();
1187
+ expect(foundA!.assistantId).toBe('asst-A');
1188
+ expect(foundB!.assistantId).toBe('asst-B');
1189
+ expect(foundA!.toolName).toBe('shell');
1190
+ expect(foundB!.toolName).toBe('browser');
1191
+ });
1192
+ });
1193
+
1194
+ // ═══════════════════════════════════════════════════════════════════════════
1195
+ // 10. IPC handler — channel-aware guardian status response
1196
+ // ═══════════════════════════════════════════════════════════════════════════
1197
+
1198
+ /**
1199
+ * Creates a minimal mock HandlerContext that captures the response sent via ctx.send().
1200
+ */
1201
+ function createMockCtx(): { ctx: HandlerContext; lastResponse: () => GuardianVerificationResponse | null } {
1202
+ let captured: GuardianVerificationResponse | null = null;
1203
+ const ctx = {
1204
+ sessions: new Map(),
1205
+ socketToSession: new Map(),
1206
+ cuSessions: new Map(),
1207
+ socketToCuSession: new Map(),
1208
+ cuObservationParseSequence: new Map(),
1209
+ socketSandboxOverride: new Map(),
1210
+ sharedRequestTimestamps: [],
1211
+ debounceTimers: { schedule: () => {}, cancel: () => {} } as unknown as HandlerContext['debounceTimers'],
1212
+ suppressConfigReload: false,
1213
+ setSuppressConfigReload: () => {},
1214
+ updateConfigFingerprint: () => {},
1215
+ send: (_socket: net.Socket, msg: unknown) => { captured = msg as GuardianVerificationResponse; },
1216
+ broadcast: () => {},
1217
+ clearAllSessions: () => 0,
1218
+ getOrCreateSession: () => Promise.resolve({} as never),
1219
+ touchSession: () => {},
1220
+ } as unknown as HandlerContext;
1221
+ return { ctx, lastResponse: () => captured };
1222
+ }
1223
+
1224
+ const mockSocket = {} as net.Socket;
1225
+
1226
+ describe('IPC handler channel-aware guardian status', () => {
1227
+ beforeEach(() => {
1228
+ resetTables();
1229
+ });
1230
+
1231
+ test('status action for telegram returns channel and assistantId fields', () => {
1232
+ const { ctx, lastResponse } = createMockCtx();
1233
+ const msg: GuardianVerificationRequest = {
1234
+ type: 'guardian_verification',
1235
+ action: 'status',
1236
+ channel: 'telegram',
1237
+ assistantId: 'self',
1238
+ };
1239
+
1240
+ handleGuardianVerification(msg, mockSocket, ctx);
1241
+
1242
+ const resp = lastResponse();
1243
+ expect(resp).not.toBeNull();
1244
+ expect(resp!.success).toBe(true);
1245
+ expect(resp!.channel).toBe('telegram');
1246
+ expect(resp!.assistantId).toBe('self');
1247
+ expect(resp!.bound).toBe(false);
1248
+ expect(resp!.guardianDeliveryChatId).toBeUndefined();
1249
+ });
1250
+
1251
+ test('status action for sms returns channel: sms and assistantId: self', () => {
1252
+ const { ctx, lastResponse } = createMockCtx();
1253
+ const msg: GuardianVerificationRequest = {
1254
+ type: 'guardian_verification',
1255
+ action: 'status',
1256
+ channel: 'sms',
1257
+ assistantId: 'self',
1258
+ };
1259
+
1260
+ handleGuardianVerification(msg, mockSocket, ctx);
1261
+
1262
+ const resp = lastResponse();
1263
+ expect(resp).not.toBeNull();
1264
+ expect(resp!.success).toBe(true);
1265
+ expect(resp!.channel).toBe('sms');
1266
+ expect(resp!.assistantId).toBe('self');
1267
+ expect(resp!.bound).toBe(false);
1268
+ });
1269
+
1270
+ test('status action returns guardianDeliveryChatId when bound', () => {
1271
+ createBinding({
1272
+ assistantId: 'self',
1273
+ channel: 'telegram',
1274
+ guardianExternalUserId: 'user-42',
1275
+ guardianDeliveryChatId: 'chat-42',
1276
+ });
1277
+
1278
+ const { ctx, lastResponse } = createMockCtx();
1279
+ const msg: GuardianVerificationRequest = {
1280
+ type: 'guardian_verification',
1281
+ action: 'status',
1282
+ channel: 'telegram',
1283
+ assistantId: 'self',
1284
+ };
1285
+
1286
+ handleGuardianVerification(msg, mockSocket, ctx);
1287
+
1288
+ const resp = lastResponse();
1289
+ expect(resp).not.toBeNull();
1290
+ expect(resp!.success).toBe(true);
1291
+ expect(resp!.bound).toBe(true);
1292
+ expect(resp!.guardianExternalUserId).toBe('user-42');
1293
+ expect(resp!.guardianDeliveryChatId).toBe('chat-42');
1294
+ expect(resp!.channel).toBe('telegram');
1295
+ expect(resp!.assistantId).toBe('self');
1296
+ });
1297
+
1298
+ test('status action defaults channel to telegram when omitted (backward compat)', () => {
1299
+ const { ctx, lastResponse } = createMockCtx();
1300
+ const msg: GuardianVerificationRequest = {
1301
+ type: 'guardian_verification',
1302
+ action: 'status',
1303
+ // channel omitted — should default to 'telegram'
1304
+ };
1305
+
1306
+ handleGuardianVerification(msg, mockSocket, ctx);
1307
+
1308
+ const resp = lastResponse();
1309
+ expect(resp).not.toBeNull();
1310
+ expect(resp!.channel).toBe('telegram');
1311
+ expect(resp!.assistantId).toBe('self');
1312
+ });
1313
+
1314
+ test('status action defaults assistantId to self when omitted (backward compat)', () => {
1315
+ const { ctx, lastResponse } = createMockCtx();
1316
+ const msg: GuardianVerificationRequest = {
1317
+ type: 'guardian_verification',
1318
+ action: 'status',
1319
+ channel: 'sms',
1320
+ // assistantId omitted — should default to 'self'
1321
+ };
1322
+
1323
+ handleGuardianVerification(msg, mockSocket, ctx);
1324
+
1325
+ const resp = lastResponse();
1326
+ expect(resp).not.toBeNull();
1327
+ expect(resp!.assistantId).toBe('self');
1328
+ expect(resp!.channel).toBe('sms');
1329
+ });
1330
+
1331
+ test('status action with custom assistantId returns correct value', () => {
1332
+ createBinding({
1333
+ assistantId: 'asst-custom',
1334
+ channel: 'telegram',
1335
+ guardianExternalUserId: 'user-77',
1336
+ guardianDeliveryChatId: 'chat-77',
1337
+ });
1338
+
1339
+ const { ctx, lastResponse } = createMockCtx();
1340
+ const msg: GuardianVerificationRequest = {
1341
+ type: 'guardian_verification',
1342
+ action: 'status',
1343
+ channel: 'telegram',
1344
+ assistantId: 'asst-custom',
1345
+ };
1346
+
1347
+ handleGuardianVerification(msg, mockSocket, ctx);
1348
+
1349
+ const resp = lastResponse();
1350
+ expect(resp).not.toBeNull();
1351
+ expect(resp!.success).toBe(true);
1352
+ expect(resp!.bound).toBe(true);
1353
+ expect(resp!.assistantId).toBe('asst-custom');
1354
+ expect(resp!.channel).toBe('telegram');
1355
+ expect(resp!.guardianExternalUserId).toBe('user-77');
1356
+ expect(resp!.guardianDeliveryChatId).toBe('chat-77');
1357
+ });
1358
+
1359
+ test('status action for unbound sms does not return guardianDeliveryChatId', () => {
1360
+ const { ctx, lastResponse } = createMockCtx();
1361
+ const msg: GuardianVerificationRequest = {
1362
+ type: 'guardian_verification',
1363
+ action: 'status',
1364
+ channel: 'sms',
1365
+ };
1366
+
1367
+ handleGuardianVerification(msg, mockSocket, ctx);
1368
+
1369
+ const resp = lastResponse();
1370
+ expect(resp).not.toBeNull();
1371
+ expect(resp!.bound).toBe(false);
1372
+ expect(resp!.guardianDeliveryChatId).toBeUndefined();
1373
+ expect(resp!.guardianExternalUserId).toBeUndefined();
1374
+ });
1375
+ });
@@ -681,7 +681,7 @@ describe('AssistantConfigSchema', () => {
681
681
  userConsultTimeoutSeconds: 120,
682
682
  disclosure: {
683
683
  enabled: true,
684
- text: 'At the very beginning of the call, disclose that you are an AI assistant calling on behalf of the user.',
684
+ text: 'At the very beginning of the call, introduce yourself as an assistant calling on behalf of the user.',
685
685
  },
686
686
  safety: {
687
687
  denyCategories: [],
@@ -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