@vellumai/assistant 0.3.19 → 0.3.21

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 (199) hide show
  1. package/ARCHITECTURE.md +151 -15
  2. package/Dockerfile +1 -0
  3. package/README.md +40 -4
  4. package/bun.lock +139 -2
  5. package/docs/architecture/integrations.md +7 -11
  6. package/package.json +2 -1
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +54 -0
  8. package/src/__tests__/approval-primitive.test.ts +540 -0
  9. package/src/__tests__/assistant-feature-flag-guard.test.ts +206 -0
  10. package/src/__tests__/assistant-feature-flag-guardrails.test.ts +198 -0
  11. package/src/__tests__/assistant-feature-flags-integration.test.ts +272 -0
  12. package/src/__tests__/call-controller.test.ts +439 -108
  13. package/src/__tests__/channel-invite-transport.test.ts +264 -0
  14. package/src/__tests__/cli.test.ts +42 -1
  15. package/src/__tests__/config-schema.test.ts +11 -127
  16. package/src/__tests__/config-watcher.test.ts +0 -8
  17. package/src/__tests__/daemon-lifecycle.test.ts +1 -0
  18. package/src/__tests__/daemon-server-session-init.test.ts +8 -2
  19. package/src/__tests__/diff.test.ts +22 -0
  20. package/src/__tests__/guardian-action-copy-generator.test.ts +5 -0
  21. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +300 -32
  22. package/src/__tests__/guardian-action-late-reply.test.ts +546 -1
  23. package/src/__tests__/guardian-actions-endpoint.test.ts +774 -0
  24. package/src/__tests__/guardian-control-plane-policy.test.ts +36 -3
  25. package/src/__tests__/guardian-dispatch.test.ts +124 -0
  26. package/src/__tests__/guardian-grant-minting.test.ts +6 -17
  27. package/src/__tests__/inbound-invite-redemption.test.ts +367 -0
  28. package/src/__tests__/invite-redemption-service.test.ts +306 -0
  29. package/src/__tests__/ipc-snapshot.test.ts +57 -0
  30. package/src/__tests__/notification-decision-fallback.test.ts +88 -0
  31. package/src/__tests__/sandbox-diagnostics.test.ts +6 -249
  32. package/src/__tests__/sandbox-host-parity.test.ts +6 -13
  33. package/src/__tests__/scoped-approval-grants.test.ts +6 -6
  34. package/src/__tests__/scoped-grant-security-matrix.test.ts +5 -4
  35. package/src/__tests__/script-proxy-session-manager.test.ts +1 -19
  36. package/src/__tests__/session-load-history-repair.test.ts +169 -2
  37. package/src/__tests__/session-runtime-assembly.test.ts +33 -5
  38. package/src/__tests__/skill-feature-flags-integration.test.ts +171 -0
  39. package/src/__tests__/skill-feature-flags.test.ts +188 -0
  40. package/src/__tests__/skill-load-feature-flag.test.ts +141 -0
  41. package/src/__tests__/skill-mirror-parity.test.ts +1 -0
  42. package/src/__tests__/skill-projection-feature-flag.test.ts +363 -0
  43. package/src/__tests__/system-prompt.test.ts +1 -1
  44. package/src/__tests__/terminal-sandbox.test.ts +142 -9
  45. package/src/__tests__/terminal-tools.test.ts +2 -93
  46. package/src/__tests__/thread-seed-composer.test.ts +18 -0
  47. package/src/__tests__/tool-approval-handler.test.ts +350 -0
  48. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
  49. package/src/__tests__/voice-scoped-grant-consumer.test.ts +46 -84
  50. package/src/agent/loop.ts +36 -1
  51. package/src/approvals/approval-primitive.ts +381 -0
  52. package/src/approvals/guardian-decision-primitive.ts +191 -0
  53. package/src/calls/call-controller.ts +252 -209
  54. package/src/calls/call-domain.ts +44 -6
  55. package/src/calls/guardian-dispatch.ts +48 -0
  56. package/src/calls/types.ts +1 -1
  57. package/src/calls/voice-session-bridge.ts +46 -30
  58. package/src/cli/core-commands.ts +0 -4
  59. package/src/cli/mcp.ts +58 -0
  60. package/src/cli.ts +76 -34
  61. package/src/config/__tests__/feature-flag-registry-guard.test.ts +179 -0
  62. package/src/config/assistant-feature-flags.ts +162 -0
  63. package/src/config/bundled-skills/api-mapping/icon.svg +18 -0
  64. package/src/config/bundled-skills/messaging/TOOLS.json +30 -0
  65. package/src/config/bundled-skills/messaging/tools/slack-delete-message.ts +24 -0
  66. package/src/config/bundled-skills/notifications/SKILL.md +1 -1
  67. package/src/config/bundled-skills/reminder/SKILL.md +49 -2
  68. package/src/config/bundled-skills/time-based-actions/SKILL.md +49 -2
  69. package/src/config/bundled-skills/voice-setup/SKILL.md +122 -0
  70. package/src/config/core-schema.ts +1 -1
  71. package/src/config/env-registry.ts +10 -0
  72. package/src/config/feature-flag-registry.json +61 -0
  73. package/src/config/loader.ts +22 -1
  74. package/src/config/mcp-schema.ts +46 -0
  75. package/src/config/sandbox-schema.ts +0 -39
  76. package/src/config/schema.ts +18 -2
  77. package/src/config/skill-state.ts +34 -0
  78. package/src/config/skills-schema.ts +0 -1
  79. package/src/config/skills.ts +9 -0
  80. package/src/config/system-prompt.ts +110 -46
  81. package/src/config/templates/SOUL.md +1 -1
  82. package/src/config/types.ts +19 -1
  83. package/src/config/vellum-skills/catalog.json +1 -1
  84. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
  85. package/src/config/vellum-skills/sms-setup/SKILL.md +1 -1
  86. package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -5
  87. package/src/config/vellum-skills/trusted-contacts/SKILL.md +105 -3
  88. package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
  89. package/src/daemon/config-watcher.ts +0 -1
  90. package/src/daemon/daemon-control.ts +1 -1
  91. package/src/daemon/guardian-invite-intent.ts +124 -0
  92. package/src/daemon/handlers/avatar.ts +68 -0
  93. package/src/daemon/handlers/browser.ts +2 -2
  94. package/src/daemon/handlers/guardian-actions.ts +120 -0
  95. package/src/daemon/handlers/index.ts +4 -0
  96. package/src/daemon/handlers/sessions.ts +19 -0
  97. package/src/daemon/handlers/shared.ts +3 -1
  98. package/src/daemon/install-cli-launchers.ts +58 -13
  99. package/src/daemon/ipc-contract/guardian-actions.ts +53 -0
  100. package/src/daemon/ipc-contract/sessions.ts +8 -2
  101. package/src/daemon/ipc-contract/settings.ts +25 -2
  102. package/src/daemon/ipc-contract-inventory.json +10 -0
  103. package/src/daemon/ipc-contract.ts +4 -0
  104. package/src/daemon/lifecycle.ts +14 -2
  105. package/src/daemon/main.ts +1 -0
  106. package/src/daemon/providers-setup.ts +26 -1
  107. package/src/daemon/server.ts +1 -0
  108. package/src/daemon/session-lifecycle.ts +52 -7
  109. package/src/daemon/session-memory.ts +45 -0
  110. package/src/daemon/session-process.ts +258 -432
  111. package/src/daemon/session-runtime-assembly.ts +12 -0
  112. package/src/daemon/session-skill-tools.ts +14 -1
  113. package/src/daemon/session-tool-setup.ts +5 -0
  114. package/src/daemon/session.ts +11 -0
  115. package/src/daemon/shutdown-handlers.ts +11 -0
  116. package/src/daemon/tool-side-effects.ts +35 -9
  117. package/src/index.ts +2 -2
  118. package/src/mcp/client.ts +152 -0
  119. package/src/mcp/manager.ts +139 -0
  120. package/src/memory/conversation-display-order-migration.ts +44 -0
  121. package/src/memory/conversation-queries.ts +2 -0
  122. package/src/memory/conversation-store.ts +91 -0
  123. package/src/memory/db-init.ts +5 -1
  124. package/src/memory/embedding-local.ts +13 -8
  125. package/src/memory/guardian-action-store.ts +125 -2
  126. package/src/memory/ingress-invite-store.ts +95 -1
  127. package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
  128. package/src/memory/migrations/index.ts +2 -1
  129. package/src/memory/schema.ts +5 -1
  130. package/src/memory/scoped-approval-grants.ts +14 -5
  131. package/src/messaging/providers/slack/client.ts +12 -0
  132. package/src/messaging/providers/slack/types.ts +5 -0
  133. package/src/notifications/decision-engine.ts +49 -12
  134. package/src/notifications/emit-signal.ts +7 -0
  135. package/src/notifications/signal.ts +7 -0
  136. package/src/notifications/thread-seed-composer.ts +2 -1
  137. package/src/runtime/channel-approval-types.ts +16 -6
  138. package/src/runtime/channel-approvals.ts +19 -15
  139. package/src/runtime/channel-invite-transport.ts +85 -0
  140. package/src/runtime/channel-invite-transports/telegram.ts +105 -0
  141. package/src/runtime/guardian-action-grant-minter.ts +92 -35
  142. package/src/runtime/guardian-action-message-composer.ts +30 -0
  143. package/src/runtime/guardian-decision-types.ts +91 -0
  144. package/src/runtime/http-server.ts +23 -1
  145. package/src/runtime/ingress-service.ts +22 -0
  146. package/src/runtime/invite-redemption-service.ts +181 -0
  147. package/src/runtime/invite-redemption-templates.ts +39 -0
  148. package/src/runtime/routes/call-routes.ts +2 -1
  149. package/src/runtime/routes/guardian-action-routes.ts +206 -0
  150. package/src/runtime/routes/guardian-approval-interception.ts +66 -190
  151. package/src/runtime/routes/identity-routes.ts +73 -0
  152. package/src/runtime/routes/inbound-message-handler.ts +486 -394
  153. package/src/runtime/routes/pairing-routes.ts +4 -0
  154. package/src/security/encrypted-store.ts +31 -17
  155. package/src/security/keychain.ts +176 -2
  156. package/src/security/secure-keys.ts +97 -0
  157. package/src/security/tool-approval-digest.ts +1 -1
  158. package/src/tools/browser/browser-execution.ts +2 -2
  159. package/src/tools/browser/browser-manager.ts +46 -32
  160. package/src/tools/browser/browser-screencast.ts +2 -2
  161. package/src/tools/calls/call-start.ts +1 -1
  162. package/src/tools/executor.ts +22 -17
  163. package/src/tools/mcp/mcp-tool-factory.ts +100 -0
  164. package/src/tools/network/script-proxy/session-manager.ts +1 -5
  165. package/src/tools/registry.ts +64 -1
  166. package/src/tools/skills/load.ts +22 -8
  167. package/src/tools/system/avatar-generator.ts +119 -0
  168. package/src/tools/system/navigate-settings.ts +65 -0
  169. package/src/tools/system/open-system-settings.ts +75 -0
  170. package/src/tools/system/voice-config.ts +121 -32
  171. package/src/tools/terminal/backends/native.ts +40 -19
  172. package/src/tools/terminal/backends/types.ts +3 -3
  173. package/src/tools/terminal/parser.ts +1 -1
  174. package/src/tools/terminal/sandbox-diagnostics.ts +6 -87
  175. package/src/tools/terminal/sandbox.ts +1 -12
  176. package/src/tools/terminal/shell.ts +3 -31
  177. package/src/tools/tool-approval-handler.ts +141 -3
  178. package/src/tools/tool-manifest.ts +6 -0
  179. package/src/tools/types.ts +10 -2
  180. package/src/util/diff.ts +36 -13
  181. package/Dockerfile.sandbox +0 -5
  182. package/src/__tests__/doordash-client.test.ts +0 -187
  183. package/src/__tests__/doordash-session.test.ts +0 -154
  184. package/src/__tests__/signup-e2e.test.ts +0 -354
  185. package/src/__tests__/terminal-sandbox-docker.test.ts +0 -1065
  186. package/src/__tests__/terminal-sandbox.integration.test.ts +0 -180
  187. package/src/cli/doordash.ts +0 -1057
  188. package/src/config/bundled-skills/doordash/SKILL.md +0 -163
  189. package/src/config/templates/LOOKS.md +0 -25
  190. package/src/doordash/cart-queries.ts +0 -787
  191. package/src/doordash/client.ts +0 -1016
  192. package/src/doordash/order-queries.ts +0 -85
  193. package/src/doordash/queries.ts +0 -13
  194. package/src/doordash/query-extractor.ts +0 -94
  195. package/src/doordash/search-queries.ts +0 -203
  196. package/src/doordash/session.ts +0 -84
  197. package/src/doordash/store-queries.ts +0 -246
  198. package/src/doordash/types.ts +0 -367
  199. package/src/tools/terminal/backends/docker.ts +0 -379
@@ -484,15 +484,15 @@ describe('ToolExecutor guardian-only policy gate', () => {
484
484
  expect(result.content).toBe('ok');
485
485
  });
486
486
 
487
- test('non-guardian invocation of unrelated endpoint is unaffected', async () => {
487
+ test('non-guardian invocation of unrelated bash command is blocked by guardian approval gate', async () => {
488
488
  const executor = new ToolExecutor(makePrompter());
489
489
  const result = await executor.execute(
490
490
  'bash',
491
491
  { command: 'curl http://localhost:3000/v1/messages' },
492
492
  makeContext({ guardianActorRole: 'non-guardian' }),
493
493
  );
494
- expect(result.isError).toBe(false);
495
- expect(result.content).toBe('ok');
494
+ expect(result.isError).toBe(true);
495
+ expect(result.content).toContain('requires guardian approval');
496
496
  });
497
497
 
498
498
  test('non-guardian invocation of unrelated tool is unaffected', async () => {
@@ -579,4 +579,37 @@ describe('ToolExecutor guardian-only policy gate', () => {
579
579
  expect(result.content).toContain('restricted to guardian users');
580
580
  }
581
581
  });
582
+
583
+ test('non-guardian actor is blocked from host read tools (host execution)', async () => {
584
+ const executor = new ToolExecutor(makePrompter());
585
+ const result = await executor.execute(
586
+ 'host_file_read',
587
+ { path: '/Users/noaflaherty/.ssh/config' },
588
+ makeContext({ guardianActorRole: 'non-guardian' }),
589
+ );
590
+ expect(result.isError).toBe(true);
591
+ expect(result.content).toContain('requires guardian approval');
592
+ });
593
+
594
+ test('unverified channel actor is blocked from side-effect tools', async () => {
595
+ const executor = new ToolExecutor(makePrompter());
596
+ const result = await executor.execute(
597
+ 'reminder_create',
598
+ { fire_at: '2026-02-27T12:00:00-05:00', label: 'test', message: 'hello' },
599
+ makeContext({ guardianActorRole: 'unverified_channel' }),
600
+ );
601
+ expect(result.isError).toBe(true);
602
+ expect(result.content).toContain('verified channel identity');
603
+ });
604
+
605
+ test('guardian actor can execute side-effect tools', async () => {
606
+ const executor = new ToolExecutor(makePrompter());
607
+ const result = await executor.execute(
608
+ 'reminder_create',
609
+ { fire_at: '2026-02-27T12:00:00-05:00', label: 'test', message: 'hello' },
610
+ makeContext({ guardianActorRole: 'guardian' }),
611
+ );
612
+ expect(result.isError).toBe(false);
613
+ expect(result.content).toBe('ok');
614
+ });
582
615
  });
@@ -515,4 +515,128 @@ describe('guardian-dispatch', () => {
515
515
  const secondPayload = (emitCalls[0] as Record<string, unknown>).contextPayload as Record<string, unknown>;
516
516
  expect(secondPayload.activeGuardianRequestCount).toBe(2);
517
517
  });
518
+
519
+ test('second guardian question in same call session passes conversationAffinityHint with first conversation ID', async () => {
520
+ const convId = 'conv-dispatch-affinity-1';
521
+ ensureConversation(convId);
522
+
523
+ const sharedConversationId = 'conv-affinity-guardian';
524
+
525
+ const session = createCallSession({
526
+ conversationId: convId,
527
+ provider: 'twilio',
528
+ fromNumber: '+15550001111',
529
+ toNumber: '+15550002222',
530
+ });
531
+
532
+ // First dispatch — no affinity hint expected (no prior delivery exists)
533
+ const pq1 = createPendingQuestion(session.id, 'First question');
534
+ mockEmitResult = {
535
+ signalId: 'sig-affinity-1',
536
+ deduplicated: false,
537
+ dispatched: true,
538
+ reason: 'ok',
539
+ deliveryResults: [
540
+ {
541
+ channel: 'vellum',
542
+ destination: 'vellum',
543
+ status: 'sent',
544
+ conversationId: sharedConversationId,
545
+ },
546
+ ],
547
+ };
548
+
549
+ await dispatchGuardianQuestion({
550
+ callSessionId: session.id,
551
+ conversationId: convId,
552
+ assistantId: 'self',
553
+ pendingQuestion: pq1,
554
+ });
555
+
556
+ const firstParams = emitCalls[0] as Record<string, unknown>;
557
+ // First dispatch should not have an affinity hint
558
+ expect(firstParams.conversationAffinityHint).toBeUndefined();
559
+
560
+ // Second dispatch — should carry the affinity hint from the first delivery
561
+ emitCalls.length = 0;
562
+ const pq2 = createPendingQuestion(session.id, 'Second question');
563
+ mockEmitResult = {
564
+ signalId: 'sig-affinity-2',
565
+ deduplicated: false,
566
+ dispatched: true,
567
+ reason: 'ok',
568
+ deliveryResults: [
569
+ {
570
+ channel: 'vellum',
571
+ destination: 'vellum',
572
+ status: 'sent',
573
+ conversationId: sharedConversationId,
574
+ },
575
+ ],
576
+ };
577
+
578
+ await dispatchGuardianQuestion({
579
+ callSessionId: session.id,
580
+ conversationId: convId,
581
+ assistantId: 'self',
582
+ pendingQuestion: pq2,
583
+ });
584
+
585
+ const secondParams = emitCalls[0] as Record<string, unknown>;
586
+ expect(secondParams.conversationAffinityHint).toEqual({
587
+ vellum: sharedConversationId,
588
+ });
589
+ });
590
+
591
+ test('third guardian question in same call session also carries affinity hint', async () => {
592
+ const convId = 'conv-dispatch-affinity-2';
593
+ ensureConversation(convId);
594
+
595
+ const sharedConversationId = 'conv-affinity-triple';
596
+
597
+ const session = createCallSession({
598
+ conversationId: convId,
599
+ provider: 'twilio',
600
+ fromNumber: '+15550001111',
601
+ toNumber: '+15550002222',
602
+ });
603
+
604
+ // Dispatch three guardian questions in the same call session
605
+ for (let i = 0; i < 3; i++) {
606
+ emitCalls.length = 0;
607
+ const pq = createPendingQuestion(session.id, `Question ${i + 1}`);
608
+ mockEmitResult = {
609
+ signalId: `sig-triple-${i}`,
610
+ deduplicated: false,
611
+ dispatched: true,
612
+ reason: 'ok',
613
+ deliveryResults: [
614
+ {
615
+ channel: 'vellum',
616
+ destination: 'vellum',
617
+ status: 'sent',
618
+ conversationId: sharedConversationId,
619
+ },
620
+ ],
621
+ };
622
+
623
+ await dispatchGuardianQuestion({
624
+ callSessionId: session.id,
625
+ conversationId: convId,
626
+ assistantId: 'self',
627
+ pendingQuestion: pq,
628
+ });
629
+
630
+ const params = emitCalls[0] as Record<string, unknown>;
631
+ if (i === 0) {
632
+ // First dispatch — no affinity hint
633
+ expect(params.conversationAffinityHint).toBeUndefined();
634
+ } else {
635
+ // Subsequent dispatches — affinity hint points to the shared conversation
636
+ expect(params.conversationAffinityHint).toEqual({
637
+ vellum: sharedConversationId,
638
+ });
639
+ }
640
+ }
641
+ });
518
642
  });
@@ -37,24 +37,23 @@ mock.module('../util/logger.js', () => ({
37
37
  }),
38
38
  }));
39
39
 
40
+ import { GRANT_TTL_MS } from '../approvals/guardian-decision-primitive.js';
40
41
  import type { Session } from '../daemon/session.js';
41
42
  import {
42
43
  createApprovalRequest,
43
- createBinding,
44
- getAllPendingApprovalsByGuardianChat,
45
44
  type GuardianApprovalRequest,
46
45
  } from '../memory/channel-guardian-store.js';
47
- import { initializeDb, resetDb } from '../memory/db.js';
48
- import * as scopedGrantStore from '../memory/scoped-approval-grants.js';
49
- import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
46
+ import { getDb, initializeDb, resetDb } from '../memory/db.js';
50
47
  import * as approvalMessageComposer from '../runtime/approval-message-composer.js';
51
48
  import * as gatewayClient from '../runtime/gateway-client.js';
52
49
  import * as pendingInteractions from '../runtime/pending-interactions.js';
50
+ import type { GuardianContext } from '../runtime/routes/channel-route-shared.js';
53
51
  import {
54
52
  handleApprovalInterception,
55
- GRANT_TTL_MS,
56
53
  } from '../runtime/routes/guardian-approval-interception.js';
57
- import type { GuardianContext } from '../runtime/routes/channel-route-shared.js';
54
+ import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
55
+
56
+ import '../memory/scoped-approval-grants.js';
58
57
 
59
58
  initializeDb();
60
59
 
@@ -78,7 +77,6 @@ const TOOL_INPUT = { command: 'rm -rf /tmp/test' };
78
77
 
79
78
  function resetTables(): void {
80
79
  try {
81
- const { getDb } = require('../memory/db.js');
82
80
  const db = getDb();
83
81
  db.run('DELETE FROM channel_guardian_approval_requests');
84
82
  db.run('DELETE FROM scoped_approval_grants');
@@ -144,16 +142,8 @@ function makeGuardianContext(): GuardianContext {
144
142
  };
145
143
  }
146
144
 
147
- function makeNonGuardianContext(): GuardianContext {
148
- return {
149
- actorRole: 'non-guardian',
150
- denialReason: undefined,
151
- };
152
- }
153
-
154
145
  function countGrants(): number {
155
146
  try {
156
- const { getDb } = require('../memory/db.js');
157
147
  const db = getDb();
158
148
  const row = db.$client.prepare('SELECT count(*) as cnt FROM scoped_approval_grants').get() as { cnt: number };
159
149
  return row.cnt;
@@ -164,7 +154,6 @@ function countGrants(): number {
164
154
 
165
155
  function getLatestGrant(): Record<string, unknown> | null {
166
156
  try {
167
- const { getDb } = require('../memory/db.js');
168
157
  const db = getDb();
169
158
  const row = db.$client.prepare('SELECT * FROM scoped_approval_grants ORDER BY created_at DESC LIMIT 1').get();
170
159
  return (row as Record<string, unknown>) ?? null;
@@ -0,0 +1,367 @@
1
+ /**
2
+ * Integration tests for the inbound invite redemption intercept.
3
+ *
4
+ * Validates that non-members with valid `/start iv_<token>` payloads are
5
+ * granted access without guardian approval, and that invalid/expired/revoked
6
+ * tokens produce the correct deterministic refusal messages.
7
+ */
8
+ import { mkdtempSync, rmSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+
12
+ import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Test isolation: in-memory SQLite via temp directory
16
+ // ---------------------------------------------------------------------------
17
+
18
+ const testDir = mkdtempSync(join(tmpdir(), 'inbound-invite-redemption-test-'));
19
+
20
+ mock.module('../util/platform.js', () => ({
21
+ getRootDir: () => testDir,
22
+ getDataDir: () => testDir,
23
+ isMacOS: () => process.platform === 'darwin',
24
+ isLinux: () => process.platform === 'linux',
25
+ isWindows: () => process.platform === 'win32',
26
+ getSocketPath: () => join(testDir, 'test.sock'),
27
+ getPidPath: () => join(testDir, 'test.pid'),
28
+ getDbPath: () => join(testDir, 'test.db'),
29
+ getLogPath: () => join(testDir, 'test.log'),
30
+ ensureDataDir: () => {},
31
+ normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id,
32
+ readHttpToken: () => 'test-bearer-token',
33
+ }));
34
+
35
+ mock.module('../util/logger.js', () => ({
36
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
37
+ get: () => () => {},
38
+ }),
39
+ }));
40
+
41
+ mock.module('../security/secret-ingress.js', () => ({
42
+ checkIngressForSecrets: () => ({ blocked: false }),
43
+ }));
44
+
45
+ mock.module('../config/env.js', () => ({
46
+ getGatewayInternalBaseUrl: () => 'http://127.0.0.1:7830',
47
+ }));
48
+
49
+ // Mock the credential metadata store so the Telegram transport adapter
50
+ // resolves without touching the filesystem.
51
+ mock.module('../tools/credentials/metadata-store.js', () => ({
52
+ getCredentialMetadata: () => undefined,
53
+ upsertCredentialMetadata: () => {},
54
+ deleteCredentialMetadata: () => {},
55
+ listCredentialMetadata: () => [],
56
+ }));
57
+
58
+ const emitSignalCalls: Array<Record<string, unknown>> = [];
59
+ mock.module('../notifications/emit-signal.js', () => ({
60
+ emitNotificationSignal: async (params: Record<string, unknown>) => {
61
+ emitSignalCalls.push(params);
62
+ return {
63
+ signalId: 'mock-signal-id',
64
+ deduplicated: false,
65
+ dispatched: true,
66
+ reason: 'mock',
67
+ deliveryResults: [],
68
+ };
69
+ },
70
+ }));
71
+
72
+ const deliverReplyCalls: Array<{ url: string; payload: Record<string, unknown> }> = [];
73
+ mock.module('../runtime/gateway-client.js', () => ({
74
+ deliverChannelReply: async (url: string, payload: Record<string, unknown>) => {
75
+ deliverReplyCalls.push({ url, payload });
76
+ },
77
+ }));
78
+
79
+ mock.module('../runtime/approval-message-composer.js', () => ({
80
+ composeApprovalMessage: () => 'mock approval message',
81
+ composeApprovalMessageGenerative: async () => 'mock generative message',
82
+ }));
83
+
84
+ import { getDb, initializeDb, resetDb } from '../memory/db.js';
85
+ import { createInvite, revokeInvite } from '../memory/ingress-invite-store.js';
86
+ import { findMember, upsertMember } from '../memory/ingress-member-store.js';
87
+ import { handleChannelInbound } from '../runtime/routes/channel-routes.js';
88
+
89
+ initializeDb();
90
+
91
+ afterAll(() => {
92
+ resetDb();
93
+ try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
94
+ });
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Helpers
98
+ // ---------------------------------------------------------------------------
99
+
100
+ const TEST_BEARER_TOKEN = 'test-token';
101
+ let msgCounter = 0;
102
+
103
+ function resetState(): void {
104
+ const db = getDb();
105
+ db.run('DELETE FROM assistant_ingress_members');
106
+ db.run('DELETE FROM assistant_ingress_invites');
107
+ db.run('DELETE FROM channel_inbound_events');
108
+ db.run('DELETE FROM conversations');
109
+ db.run('DELETE FROM channel_guardian_approval_requests');
110
+ db.run('DELETE FROM channel_guardian_bindings');
111
+ db.run('DELETE FROM notification_events');
112
+ emitSignalCalls.length = 0;
113
+ deliverReplyCalls.length = 0;
114
+ msgCounter = 0;
115
+ }
116
+
117
+ function buildInboundRequest(overrides: Record<string, unknown> = {}): Request {
118
+ msgCounter++;
119
+ const body: Record<string, unknown> = {
120
+ sourceChannel: 'telegram',
121
+ interface: 'telegram',
122
+ externalChatId: 'chat-invite-test',
123
+ externalMessageId: `msg-invite-${Date.now()}-${msgCounter}`,
124
+ content: '/start iv_sometoken',
125
+ senderExternalUserId: 'user-invite-123',
126
+ senderName: 'Invite User',
127
+ senderUsername: 'invite_user',
128
+ replyCallbackUrl: 'http://localhost:7830/deliver/telegram',
129
+ sourceMetadata: {
130
+ commandIntent: { type: 'start', payload: 'iv_sometoken' },
131
+ },
132
+ ...overrides,
133
+ };
134
+
135
+ return new Request('http://localhost:8080/channels/inbound', {
136
+ method: 'POST',
137
+ headers: {
138
+ 'Content-Type': 'application/json',
139
+ 'X-Gateway-Origin': TEST_BEARER_TOKEN,
140
+ },
141
+ body: JSON.stringify(body),
142
+ });
143
+ }
144
+
145
+ /**
146
+ * Build a request with a specific invite token, using the structured
147
+ * commandIntent that the gateway produces for `/start <payload>`.
148
+ */
149
+ function buildInviteRequest(rawToken: string, overrides: Record<string, unknown> = {}): Request {
150
+ return buildInboundRequest({
151
+ content: `/start iv_${rawToken}`,
152
+ sourceMetadata: {
153
+ commandIntent: { type: 'start', payload: `iv_${rawToken}` },
154
+ },
155
+ ...overrides,
156
+ });
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Tests
161
+ // ---------------------------------------------------------------------------
162
+
163
+ describe('inbound invite redemption intercept', () => {
164
+ beforeEach(resetState);
165
+
166
+ test('non-member with valid invite token becomes active member without guardian approval', async () => {
167
+ const { rawToken } = createInvite({ sourceChannel: 'telegram', maxUses: 5 });
168
+
169
+ const req = buildInviteRequest(rawToken);
170
+ const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
171
+ const json = await resp.json() as Record<string, unknown>;
172
+
173
+ expect(json.accepted).toBe(true);
174
+ expect(json.inviteRedemption).toBe('redeemed');
175
+ expect(json.memberId).toEqual(expect.any(String));
176
+ expect(json.denied).toBeUndefined();
177
+
178
+ // Verify the user is now an active member
179
+ const member = findMember({
180
+ assistantId: 'self',
181
+ sourceChannel: 'telegram',
182
+ externalUserId: 'user-invite-123',
183
+ });
184
+ expect(member).not.toBeNull();
185
+ expect(member!.status).toBe('active');
186
+
187
+ // Verify a welcome reply was delivered
188
+ expect(deliverReplyCalls.length).toBe(1);
189
+ const replyText = (deliverReplyCalls[0].payload as Record<string, unknown>).text;
190
+ expect(replyText).toContain("Welcome! You've been granted access via invite link.");
191
+ });
192
+
193
+ test('non-member with invalid token gets refusal text', async () => {
194
+ const req = buildInviteRequest('completely-bogus-token-xyz');
195
+ const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
196
+ const json = await resp.json() as Record<string, unknown>;
197
+
198
+ expect(json.accepted).toBe(true);
199
+ expect(json.denied).toBe(true);
200
+ expect(json.inviteRedemption).toBe('invalid_token');
201
+
202
+ // Verify refusal reply was delivered
203
+ expect(deliverReplyCalls.length).toBe(1);
204
+ const replyText = (deliverReplyCalls[0].payload as Record<string, unknown>).text;
205
+ expect(replyText).toContain('no longer valid');
206
+
207
+ // Verify the user was NOT made a member
208
+ const member = findMember({
209
+ assistantId: 'self',
210
+ sourceChannel: 'telegram',
211
+ externalUserId: 'user-invite-123',
212
+ });
213
+ expect(member).toBeNull();
214
+ });
215
+
216
+ test('non-member with expired token gets appropriate message', async () => {
217
+ const { rawToken } = createInvite({
218
+ sourceChannel: 'telegram',
219
+ maxUses: 1,
220
+ expiresInMs: -1, // already expired
221
+ });
222
+
223
+ const req = buildInviteRequest(rawToken);
224
+ const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
225
+ const json = await resp.json() as Record<string, unknown>;
226
+
227
+ expect(json.accepted).toBe(true);
228
+ expect(json.denied).toBe(true);
229
+ expect(json.inviteRedemption).toBe('expired');
230
+
231
+ expect(deliverReplyCalls.length).toBe(1);
232
+ const replyText = (deliverReplyCalls[0].payload as Record<string, unknown>).text;
233
+ expect(replyText).toContain('no longer valid');
234
+ });
235
+
236
+ test('non-member with revoked token gets refusal text', async () => {
237
+ const { rawToken, invite } = createInvite({
238
+ sourceChannel: 'telegram',
239
+ maxUses: 5,
240
+ });
241
+ revokeInvite(invite.id);
242
+
243
+ const req = buildInviteRequest(rawToken);
244
+ const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
245
+ const json = await resp.json() as Record<string, unknown>;
246
+
247
+ expect(json.accepted).toBe(true);
248
+ expect(json.denied).toBe(true);
249
+ expect(json.inviteRedemption).toBe('revoked');
250
+
251
+ expect(deliverReplyCalls.length).toBe(1);
252
+ const replyText = (deliverReplyCalls[0].payload as Record<string, unknown>).text;
253
+ expect(replyText).toContain('no longer valid');
254
+ });
255
+
256
+ test('existing /start gv_<token> guardian bootstrap flow is unaffected', async () => {
257
+ // Send a /start gv_ command — should not be intercepted by the invite flow.
258
+ // Without a valid bootstrap session, it should be denied at the ACL gate.
259
+ const req = buildInboundRequest({
260
+ content: '/start gv_some_bootstrap_token',
261
+ sourceMetadata: {
262
+ commandIntent: { type: 'start', payload: 'gv_some_bootstrap_token' },
263
+ },
264
+ });
265
+ const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
266
+ const json = await resp.json() as Record<string, unknown>;
267
+
268
+ // Should be denied as a non-member (bootstrap token is invalid/no session)
269
+ expect(json.denied).toBe(true);
270
+ expect(json.reason).toBe('not_a_member');
271
+ // Should NOT have invite redemption fields
272
+ expect(json.inviteRedemption).toBeUndefined();
273
+ });
274
+
275
+ test('duplicate Telegram webhook deliveries do not double-redeem', async () => {
276
+ const { rawToken } = createInvite({ sourceChannel: 'telegram', maxUses: 5 });
277
+
278
+ const sharedMessageId = `msg-dedup-${Date.now()}`;
279
+ const makeReq = () => buildInviteRequest(rawToken, {
280
+ externalMessageId: sharedMessageId,
281
+ });
282
+
283
+ // First delivery
284
+ const resp1 = await handleChannelInbound(makeReq(), undefined, TEST_BEARER_TOKEN);
285
+ const json1 = await resp1.json() as Record<string, unknown>;
286
+ expect(json1.inviteRedemption).toBe('redeemed');
287
+
288
+ // Second delivery (duplicate webhook)
289
+ const resp2 = await handleChannelInbound(makeReq(), undefined, TEST_BEARER_TOKEN);
290
+ const json2 = await resp2.json() as Record<string, unknown>;
291
+ // Dedup kicks in — the message is treated as a duplicate and no second
292
+ // redemption attempt occurs.
293
+ expect(json2.duplicate).toBe(true);
294
+
295
+ // Only one welcome reply was delivered
296
+ expect(deliverReplyCalls.length).toBe(1);
297
+ });
298
+
299
+ test('existing active member sending normal message is unaffected', async () => {
300
+ // Pre-create an active member
301
+ upsertMember({
302
+ assistantId: 'self',
303
+ sourceChannel: 'telegram',
304
+ externalUserId: 'user-active-member',
305
+ externalChatId: 'chat-active',
306
+ status: 'active',
307
+ policy: 'allow',
308
+ });
309
+
310
+ // Active member sends a normal message (no invite token)
311
+ const req = buildInboundRequest({
312
+ content: 'Hello, just a normal message!',
313
+ senderExternalUserId: 'user-active-member',
314
+ externalChatId: 'chat-active',
315
+ sourceMetadata: {},
316
+ });
317
+ const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
318
+ const json = await resp.json() as Record<string, unknown>;
319
+
320
+ // Should be accepted normally, not denied, not invite-redeemed
321
+ expect(json.accepted).toBe(true);
322
+ expect(json.denied).toBeUndefined();
323
+ expect(json.inviteRedemption).toBeUndefined();
324
+ });
325
+
326
+ test('channel mismatch returns appropriate message', async () => {
327
+ // Create an invite for SMS, but try to redeem via Telegram
328
+ const { rawToken } = createInvite({ sourceChannel: 'sms', maxUses: 5 });
329
+
330
+ const req = buildInviteRequest(rawToken);
331
+ const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
332
+ const json = await resp.json() as Record<string, unknown>;
333
+
334
+ expect(json.accepted).toBe(true);
335
+ expect(json.denied).toBe(true);
336
+ expect(json.inviteRedemption).toBe('channel_mismatch');
337
+
338
+ expect(deliverReplyCalls.length).toBe(1);
339
+ const replyText = (deliverReplyCalls[0].payload as Record<string, unknown>).text;
340
+ expect(replyText).toContain('not valid for this channel');
341
+ });
342
+
343
+ test('already-active member with invite token gets acknowledgement', async () => {
344
+ const { rawToken } = createInvite({ sourceChannel: 'telegram', maxUses: 5 });
345
+
346
+ // Pre-create an active member that will click the invite link
347
+ upsertMember({
348
+ assistantId: 'self',
349
+ sourceChannel: 'telegram',
350
+ externalUserId: 'user-already-active',
351
+ externalChatId: 'chat-invite-test',
352
+ status: 'active',
353
+ policy: 'allow',
354
+ });
355
+
356
+ const req = buildInviteRequest(rawToken, {
357
+ senderExternalUserId: 'user-already-active',
358
+ });
359
+ const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
360
+ const json = await resp.json() as Record<string, unknown>;
361
+
362
+ // Active members pass through the ACL gate, so the invite intercept
363
+ // does not fire. The message proceeds to normal processing.
364
+ expect(json.accepted).toBe(true);
365
+ expect(json.denied).toBeUndefined();
366
+ });
367
+ });