@vellumai/assistant 0.4.3 → 0.4.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 (183) hide show
  1. package/.env.example +3 -0
  2. package/ARCHITECTURE.md +40 -3
  3. package/README.md +43 -35
  4. package/package.json +1 -1
  5. package/scripts/ipc/generate-swift.ts +1 -0
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -120
  7. package/src/__tests__/actor-token-service.test.ts +1099 -0
  8. package/src/__tests__/agent-loop.test.ts +51 -0
  9. package/src/__tests__/approval-routes-http.test.ts +2 -0
  10. package/src/__tests__/assistant-events-sse-hardening.test.ts +7 -5
  11. package/src/__tests__/assistant-id-boundary-guard.test.ts +125 -0
  12. package/src/__tests__/call-controller.test.ts +49 -0
  13. package/src/__tests__/call-pointer-message-composer.test.ts +171 -0
  14. package/src/__tests__/call-pointer-messages.test.ts +93 -3
  15. package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +42 -0
  16. package/src/__tests__/callback-handoff-copy.test.ts +186 -0
  17. package/src/__tests__/channel-approval-routes.test.ts +133 -12
  18. package/src/__tests__/channel-guardian.test.ts +0 -87
  19. package/src/__tests__/channel-readiness-service.test.ts +10 -16
  20. package/src/__tests__/checker.test.ts +33 -12
  21. package/src/__tests__/config-schema.test.ts +4 -0
  22. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +410 -0
  23. package/src/__tests__/conversation-routes-guardian-reply.test.ts +256 -0
  24. package/src/__tests__/conversation-routes.test.ts +12 -3
  25. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  26. package/src/__tests__/daemon-server-session-init.test.ts +4 -0
  27. package/src/__tests__/guardian-actions-endpoint.test.ts +19 -14
  28. package/src/__tests__/guardian-dispatch.test.ts +8 -0
  29. package/src/__tests__/guardian-outbound-http.test.ts +4 -4
  30. package/src/__tests__/guardian-question-mode.test.ts +200 -0
  31. package/src/__tests__/guardian-routing-invariants.test.ts +178 -0
  32. package/src/__tests__/guardian-routing-state.test.ts +525 -0
  33. package/src/__tests__/handle-user-message-secret-resume.test.ts +2 -0
  34. package/src/__tests__/handlers-telegram-config.test.ts +0 -83
  35. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +55 -0
  36. package/src/__tests__/headless-browser-navigate.test.ts +2 -0
  37. package/src/__tests__/ipc-snapshot.test.ts +18 -51
  38. package/src/__tests__/non-member-access-request.test.ts +131 -8
  39. package/src/__tests__/notification-decision-fallback.test.ts +129 -4
  40. package/src/__tests__/notification-decision-strategy.test.ts +62 -2
  41. package/src/__tests__/notification-guardian-path.test.ts +3 -0
  42. package/src/__tests__/recording-intent-handler.test.ts +1 -0
  43. package/src/__tests__/relay-server.test.ts +841 -39
  44. package/src/__tests__/send-endpoint-busy.test.ts +5 -0
  45. package/src/__tests__/session-agent-loop.test.ts +1 -0
  46. package/src/__tests__/session-confirmation-signals.test.ts +523 -0
  47. package/src/__tests__/session-init.benchmark.test.ts +0 -1
  48. package/src/__tests__/session-surfaces-task-progress.test.ts +1 -1
  49. package/src/__tests__/session-tool-setup-app-refresh.test.ts +81 -2
  50. package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -1
  51. package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -1
  52. package/src/__tests__/tool-executor.test.ts +21 -2
  53. package/src/__tests__/tool-grant-request-escalation.test.ts +333 -27
  54. package/src/__tests__/trusted-contact-approval-notifier.test.ts +678 -0
  55. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1064 -0
  56. package/src/__tests__/twilio-config.test.ts +2 -13
  57. package/src/agent/loop.ts +1 -1
  58. package/src/approvals/guardian-decision-primitive.ts +10 -2
  59. package/src/approvals/guardian-request-resolvers.ts +128 -9
  60. package/src/calls/call-constants.ts +21 -0
  61. package/src/calls/call-controller.ts +9 -2
  62. package/src/calls/call-domain.ts +28 -7
  63. package/src/calls/call-pointer-message-composer.ts +154 -0
  64. package/src/calls/call-pointer-messages.ts +106 -27
  65. package/src/calls/guardian-dispatch.ts +4 -2
  66. package/src/calls/relay-server.ts +424 -12
  67. package/src/calls/twilio-config.ts +4 -11
  68. package/src/calls/twilio-routes.ts +1 -1
  69. package/src/calls/types.ts +3 -1
  70. package/src/cli.ts +5 -4
  71. package/src/config/bundled-skills/agentmail/SKILL.md +4 -0
  72. package/src/config/bundled-skills/app-builder/SKILL.md +146 -10
  73. package/src/config/bundled-skills/app-builder/TOOLS.json +1 -1
  74. package/src/config/bundled-skills/email-setup/SKILL.md +1 -1
  75. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +105 -81
  76. package/src/config/bundled-skills/messaging/SKILL.md +61 -12
  77. package/src/config/bundled-skills/messaging/TOOLS.json +58 -0
  78. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +6 -1
  79. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +35 -0
  80. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +52 -0
  81. package/src/config/bundled-skills/phone-calls/SKILL.md +30 -39
  82. package/src/config/bundled-skills/twitter/SKILL.md +3 -3
  83. package/src/config/bundled-skills/vercel-token-setup/SKILL.md +1 -0
  84. package/src/config/calls-schema.ts +24 -0
  85. package/src/config/env.ts +22 -0
  86. package/src/config/feature-flag-registry.json +8 -0
  87. package/src/config/schema.ts +2 -2
  88. package/src/config/skills.ts +11 -0
  89. package/src/config/system-prompt.ts +11 -1
  90. package/src/config/templates/SOUL.md +2 -0
  91. package/src/config/vellum-skills/sms-setup/SKILL.md +71 -82
  92. package/src/config/vellum-skills/trusted-contacts/SKILL.md +10 -9
  93. package/src/config/vellum-skills/twilio-setup/SKILL.md +88 -73
  94. package/src/daemon/call-pointer-generators.ts +59 -0
  95. package/src/daemon/computer-use-session.ts +2 -5
  96. package/src/daemon/handlers/apps.ts +76 -20
  97. package/src/daemon/handlers/config-channels.ts +5 -55
  98. package/src/daemon/handlers/config-inbox.ts +9 -3
  99. package/src/daemon/handlers/config-ingress.ts +28 -3
  100. package/src/daemon/handlers/config-telegram.ts +12 -0
  101. package/src/daemon/handlers/config.ts +2 -6
  102. package/src/daemon/handlers/pairing.ts +2 -0
  103. package/src/daemon/handlers/sessions.ts +48 -3
  104. package/src/daemon/handlers/shared.ts +17 -2
  105. package/src/daemon/ipc-contract/integrations.ts +1 -99
  106. package/src/daemon/ipc-contract/messages.ts +47 -1
  107. package/src/daemon/ipc-contract/notifications.ts +11 -0
  108. package/src/daemon/ipc-contract-inventory.json +2 -4
  109. package/src/daemon/lifecycle.ts +17 -0
  110. package/src/daemon/server.ts +14 -1
  111. package/src/daemon/session-agent-loop-handlers.ts +20 -0
  112. package/src/daemon/session-agent-loop.ts +22 -11
  113. package/src/daemon/session-lifecycle.ts +1 -1
  114. package/src/daemon/session-process.ts +11 -1
  115. package/src/daemon/session-runtime-assembly.ts +3 -0
  116. package/src/daemon/session-surfaces.ts +3 -2
  117. package/src/daemon/session.ts +88 -1
  118. package/src/daemon/tool-side-effects.ts +22 -0
  119. package/src/home-base/prebuilt/brain-graph.html +1483 -0
  120. package/src/home-base/prebuilt/index.html +40 -0
  121. package/src/inbound/platform-callback-registration.ts +157 -0
  122. package/src/memory/canonical-guardian-store.ts +1 -1
  123. package/src/memory/db-init.ts +4 -0
  124. package/src/memory/migrations/038-actor-token-records.ts +39 -0
  125. package/src/memory/migrations/index.ts +1 -0
  126. package/src/memory/schema.ts +16 -0
  127. package/src/messaging/provider-types.ts +24 -0
  128. package/src/messaging/provider.ts +7 -0
  129. package/src/messaging/providers/gmail/adapter.ts +127 -0
  130. package/src/messaging/providers/sms/adapter.ts +40 -37
  131. package/src/notifications/adapters/macos.ts +45 -2
  132. package/src/notifications/broadcaster.ts +16 -0
  133. package/src/notifications/copy-composer.ts +39 -1
  134. package/src/notifications/decision-engine.ts +22 -9
  135. package/src/notifications/destination-resolver.ts +16 -2
  136. package/src/notifications/emit-signal.ts +16 -8
  137. package/src/notifications/guardian-question-mode.ts +419 -0
  138. package/src/notifications/signal.ts +14 -3
  139. package/src/permissions/checker.ts +13 -1
  140. package/src/permissions/prompter.ts +14 -0
  141. package/src/providers/anthropic/client.ts +20 -0
  142. package/src/providers/provider-send-message.ts +15 -3
  143. package/src/runtime/access-request-helper.ts +71 -1
  144. package/src/runtime/actor-token-service.ts +234 -0
  145. package/src/runtime/actor-token-store.ts +236 -0
  146. package/src/runtime/channel-approvals.ts +5 -3
  147. package/src/runtime/channel-readiness-service.ts +23 -64
  148. package/src/runtime/channel-readiness-types.ts +3 -4
  149. package/src/runtime/channel-retry-sweep.ts +4 -1
  150. package/src/runtime/confirmation-request-guardian-bridge.ts +197 -0
  151. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  152. package/src/runtime/guardian-context-resolver.ts +82 -0
  153. package/src/runtime/guardian-outbound-actions.ts +0 -3
  154. package/src/runtime/guardian-reply-router.ts +67 -30
  155. package/src/runtime/guardian-vellum-migration.ts +57 -0
  156. package/src/runtime/http-server.ts +65 -12
  157. package/src/runtime/http-types.ts +13 -0
  158. package/src/runtime/invite-redemption-service.ts +8 -0
  159. package/src/runtime/local-actor-identity.ts +76 -0
  160. package/src/runtime/middleware/actor-token.ts +271 -0
  161. package/src/runtime/routes/approval-routes.ts +82 -7
  162. package/src/runtime/routes/brain-graph-routes.ts +222 -0
  163. package/src/runtime/routes/channel-readiness-routes.ts +71 -0
  164. package/src/runtime/routes/conversation-routes.ts +140 -52
  165. package/src/runtime/routes/events-routes.ts +20 -5
  166. package/src/runtime/routes/guardian-action-routes.ts +45 -3
  167. package/src/runtime/routes/guardian-approval-interception.ts +29 -0
  168. package/src/runtime/routes/guardian-bootstrap-routes.ts +145 -0
  169. package/src/runtime/routes/inbound-message-handler.ts +143 -2
  170. package/src/runtime/routes/integration-routes.ts +7 -15
  171. package/src/runtime/routes/pairing-routes.ts +163 -0
  172. package/src/runtime/routes/twilio-routes.ts +934 -0
  173. package/src/runtime/tool-grant-request-helper.ts +3 -1
  174. package/src/security/oauth2.ts +27 -2
  175. package/src/security/token-manager.ts +46 -10
  176. package/src/tools/browser/browser-execution.ts +4 -3
  177. package/src/tools/browser/browser-handoff.ts +10 -18
  178. package/src/tools/browser/browser-manager.ts +80 -25
  179. package/src/tools/browser/browser-screencast.ts +35 -119
  180. package/src/tools/permission-checker.ts +15 -4
  181. package/src/tools/tool-approval-handler.ts +242 -18
  182. package/src/__tests__/handlers-twilio-config.test.ts +0 -1928
  183. package/src/daemon/handlers/config-twilio.ts +0 -1082
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Guard test: prevent reintroduction of hard-coded pointer copy in
3
+ * relay-server.ts, call-controller.ts, and call-domain.ts.
4
+ *
5
+ * Deterministic fallback literals should only exist in the pointer
6
+ * composer file (call-pointer-message-composer.ts). The call-site
7
+ * files should route through addPointerMessage() exclusively.
8
+ */
9
+ import { readFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+
12
+ import { describe, expect, test } from 'bun:test';
13
+
14
+ const srcDir = join(import.meta.dir, '..');
15
+
16
+ // These files must NOT contain inline pointer copy strings.
17
+ const guardedFiles = [
18
+ 'calls/relay-server.ts',
19
+ 'calls/call-controller.ts',
20
+ 'calls/call-domain.ts',
21
+ ];
22
+
23
+ // Patterns that indicate inline pointer copy rather than routing through
24
+ // addPointerMessage. We check for the distinctive emoji + "Call to" prefix
25
+ // that the old hard-coded templates used.
26
+ const forbiddenPatterns = [
27
+ /["\u{1F4DE}].*Call to.*(?:started|completed|failed)/u,
28
+ /["\u{2705}].*Guardian verification.*succeeded/u,
29
+ /["\u{274C}].*Guardian verification.*failed/u,
30
+ ];
31
+
32
+ describe('no hardcoded pointer copy in call-site files', () => {
33
+ for (const file of guardedFiles) {
34
+ test(`${file} does not contain inline pointer copy`, () => {
35
+ const content = readFileSync(join(srcDir, file), 'utf-8');
36
+ for (const pattern of forbiddenPatterns) {
37
+ const match = pattern.exec(content);
38
+ expect(match).toBeNull();
39
+ }
40
+ });
41
+ }
42
+ });
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Tests for the callback handoff notification fallback copy template.
3
+ *
4
+ * Verifies that the `ingress.access_request.callback_handoff` template in
5
+ * copy-composer.ts renders caller identity, request code, and trusted-contact
6
+ * member reference correctly — including graceful fallback when fields are missing.
7
+ */
8
+ import { describe, expect, test } from 'bun:test';
9
+
10
+ import { composeFallbackCopy } from '../notifications/copy-composer.js';
11
+ import type { NotificationSignal } from '../notifications/signal.js';
12
+
13
+ function buildSignal(payloadOverrides: Record<string, unknown> = {}): NotificationSignal {
14
+ return {
15
+ signalId: 'test-signal-1',
16
+ assistantId: 'self',
17
+ createdAt: Date.now(),
18
+ sourceChannel: 'voice',
19
+ sourceSessionId: 'test-session-1',
20
+ sourceEventName: 'ingress.access_request.callback_handoff',
21
+ contextPayload: {
22
+ requestId: 'req-123',
23
+ requestCode: null,
24
+ callSessionId: 'call-456',
25
+ sourceChannel: 'voice',
26
+ reason: 'timeout',
27
+ callbackOptIn: true,
28
+ callerPhoneNumber: '+15551234567',
29
+ callerName: null,
30
+ requesterExternalUserId: '+15551234567',
31
+ requesterChatId: '+15551234567',
32
+ requesterMemberId: null,
33
+ requesterMemberSourceChannel: null,
34
+ ...payloadOverrides,
35
+ },
36
+ attentionHints: {
37
+ requiresAction: false,
38
+ urgency: 'medium',
39
+ isAsyncBackground: true,
40
+ visibleInSourceNow: false,
41
+ },
42
+ };
43
+ }
44
+
45
+ describe('callback handoff copy template', () => {
46
+ test('renders caller name and phone when both are present', () => {
47
+ const signal = buildSignal({
48
+ callerName: 'Alice',
49
+ callerPhoneNumber: '+15551234567',
50
+ });
51
+ const result = composeFallbackCopy(signal, ['vellum']);
52
+ const copy = result.vellum!;
53
+
54
+ expect(copy.title).toBe('Callback Requested');
55
+ expect(copy.body).toContain('Alice (+15551234567)');
56
+ expect(copy.body).toContain('callback');
57
+ expect(copy.body).toContain('unreachable');
58
+ });
59
+
60
+ test('renders phone number only when caller name is missing', () => {
61
+ const signal = buildSignal({
62
+ callerName: null,
63
+ callerPhoneNumber: '+15559876543',
64
+ });
65
+ const result = composeFallbackCopy(signal, ['vellum']);
66
+ const copy = result.vellum!;
67
+
68
+ expect(copy.body).toContain('+15559876543');
69
+ expect(copy.body).not.toContain('null');
70
+ });
71
+
72
+ test('renders caller name only when phone is missing', () => {
73
+ const signal = buildSignal({
74
+ callerName: 'Bob',
75
+ callerPhoneNumber: null,
76
+ });
77
+ const result = composeFallbackCopy(signal, ['vellum']);
78
+ const copy = result.vellum!;
79
+
80
+ expect(copy.body).toContain('Bob');
81
+ });
82
+
83
+ test('falls back to "An unknown caller" when both name and phone are missing', () => {
84
+ const signal = buildSignal({
85
+ callerName: null,
86
+ callerPhoneNumber: null,
87
+ });
88
+ const result = composeFallbackCopy(signal, ['vellum']);
89
+ const copy = result.vellum!;
90
+
91
+ expect(copy.body).toContain('An unknown caller');
92
+ });
93
+
94
+ test('includes request code when present', () => {
95
+ const signal = buildSignal({
96
+ callerName: 'Charlie',
97
+ callerPhoneNumber: '+15551111111',
98
+ requestCode: 'a1b2c3',
99
+ });
100
+ const result = composeFallbackCopy(signal, ['vellum']);
101
+ const copy = result.vellum!;
102
+
103
+ expect(copy.body).toContain('A1B2C3');
104
+ });
105
+
106
+ test('omits request code line when not present', () => {
107
+ const signal = buildSignal({
108
+ callerName: 'Charlie',
109
+ callerPhoneNumber: '+15551111111',
110
+ requestCode: null,
111
+ });
112
+ const result = composeFallbackCopy(signal, ['vellum']);
113
+ const copy = result.vellum!;
114
+
115
+ expect(copy.body).not.toContain('Request code');
116
+ });
117
+
118
+ test('includes trusted-contact member reference when requesterMemberId is present', () => {
119
+ const signal = buildSignal({
120
+ callerName: 'Diana',
121
+ callerPhoneNumber: '+15552222222',
122
+ requesterMemberId: 'member-789',
123
+ });
124
+ const result = composeFallbackCopy(signal, ['vellum']);
125
+ const copy = result.vellum!;
126
+
127
+ expect(copy.body).toContain('trusted contact');
128
+ expect(copy.body).toContain('member-789');
129
+ });
130
+
131
+ test('omits member reference line when requesterMemberId is null', () => {
132
+ const signal = buildSignal({
133
+ callerName: 'Eve',
134
+ callerPhoneNumber: '+15553333333',
135
+ requesterMemberId: null,
136
+ });
137
+ const result = composeFallbackCopy(signal, ['vellum']);
138
+ const copy = result.vellum!;
139
+
140
+ expect(copy.body).not.toContain('trusted contact');
141
+ expect(copy.body).not.toContain('member');
142
+ });
143
+
144
+ test('telegram channel gets deliveryText fallback', () => {
145
+ const signal = buildSignal({
146
+ callerName: 'Frank',
147
+ callerPhoneNumber: '+15554444444',
148
+ });
149
+ const result = composeFallbackCopy(signal, ['telegram']);
150
+ const copy = result.telegram!;
151
+
152
+ expect(copy.deliveryText).toBeDefined();
153
+ expect(copy.deliveryText!.length).toBeGreaterThan(0);
154
+ expect(copy.deliveryText).toContain('Frank');
155
+ });
156
+
157
+ test('sms channel gets deliveryText fallback', () => {
158
+ const signal = buildSignal({
159
+ callerName: 'Grace',
160
+ callerPhoneNumber: '+15555555555',
161
+ });
162
+ const result = composeFallbackCopy(signal, ['sms']);
163
+ const copy = result.sms!;
164
+
165
+ expect(copy.deliveryText).toBeDefined();
166
+ expect(copy.deliveryText!.length).toBeGreaterThan(0);
167
+ });
168
+
169
+ test('full payload renders all fields correctly', () => {
170
+ const signal = buildSignal({
171
+ callerName: 'Hank',
172
+ callerPhoneNumber: '+15556666666',
173
+ requestCode: 'ff00aa',
174
+ requesterMemberId: 'member-full-test',
175
+ });
176
+ const result = composeFallbackCopy(signal, ['vellum']);
177
+ const copy = result.vellum!;
178
+
179
+ expect(copy.title).toBe('Callback Requested');
180
+ expect(copy.body).toContain('Hank (+15556666666)');
181
+ expect(copy.body).toContain('FF00AA');
182
+ expect(copy.body).toContain('member-full-test');
183
+ expect(copy.body).toContain('callback');
184
+ expect(copy.body).toContain('unreachable');
185
+ });
186
+ });
@@ -1319,11 +1319,12 @@ describe('assistant-scoped guardian verification via handleChannelInbound', () =
1319
1319
  deliverSpy.mockRestore();
1320
1320
  });
1321
1321
 
1322
- test('verification code with explicit assistantId resolves against that assistant', async () => {
1322
+ test('verification code with explicit assistantId resolves against canonical scope', async () => {
1323
1323
  const { createVerificationChallenge } = await import('../runtime/channel-guardian-service.js');
1324
1324
  const { getGuardianBinding } = await import('../runtime/channel-guardian-service.js');
1325
1325
 
1326
- const { secret } = createVerificationChallenge('asst-route-X', 'telegram');
1326
+ // All assistant IDs canonicalize to 'self' in the single-tenant daemon
1327
+ const { secret } = createVerificationChallenge('self', 'telegram');
1327
1328
 
1328
1329
  const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
1329
1330
 
@@ -1338,17 +1339,18 @@ describe('assistant-scoped guardian verification via handleChannelInbound', () =
1338
1339
  expect(body.accepted).toBe(true);
1339
1340
  expect(body.guardianVerification).toBe('verified');
1340
1341
 
1341
- const bindingX = getGuardianBinding('asst-route-X', 'telegram');
1342
+ const bindingX = getGuardianBinding('self', 'telegram');
1342
1343
  expect(bindingX).not.toBeNull();
1343
1344
  expect(bindingX!.guardianExternalUserId).toBe('user-for-asst-x');
1344
1345
 
1345
1346
  deliverSpy.mockRestore();
1346
1347
  });
1347
1348
 
1348
- test('cross-assistant challenge code does not verify against a different assistant scope', async () => {
1349
+ test('all assistant IDs share canonical scope for verification', async () => {
1349
1350
  const { createVerificationChallenge } = await import('../runtime/channel-guardian-service.js');
1350
1351
 
1351
- const { secret } = createVerificationChallenge('asst-A-cross', 'telegram');
1352
+ // Both IDs canonicalize to 'self', so the challenge is found
1353
+ const { secret } = createVerificationChallenge('self', 'telegram');
1352
1354
 
1353
1355
  const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
1354
1356
 
@@ -1361,19 +1363,19 @@ describe('assistant-scoped guardian verification via handleChannelInbound', () =
1361
1363
  const body = await res.json() as Record<string, unknown>;
1362
1364
 
1363
1365
  expect(body.accepted).toBe(true);
1364
- expect(body.guardianVerification).toBeUndefined();
1366
+ expect(body.guardianVerification).toBe('verified');
1365
1367
 
1366
1368
  deliverSpy.mockRestore();
1367
1369
  });
1368
1370
 
1369
- test('non-self assistant inbound does not mutate assistant-agnostic external bindings', async () => {
1371
+ test('inbound with explicit assistantId does not mutate existing external bindings', async () => {
1370
1372
  const db = getDb();
1371
1373
  const now = Date.now();
1372
1374
  ensureConversation('conv-existing-binding');
1373
1375
  db.insert(externalConversationBindings).values({
1374
1376
  conversationId: 'conv-existing-binding',
1375
1377
  sourceChannel: 'telegram',
1376
- externalChatId: 'chat-123',
1378
+ externalChatId: 'chat-existing-999',
1377
1379
  externalUserId: 'existing-user',
1378
1380
  createdAt: now,
1379
1381
  updatedAt: now,
@@ -1385,7 +1387,7 @@ describe('assistant-scoped guardian verification via handleChannelInbound', () =
1385
1387
  senderExternalUserId: 'incoming-user',
1386
1388
  });
1387
1389
 
1388
- const res = await handleChannelInbound(req, undefined, 'token', 'asst-non-self');
1390
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', 'asst-non-self');
1389
1391
  expect(res.status).toBe(200);
1390
1392
 
1391
1393
  const binding = db
@@ -2665,8 +2667,10 @@ describe('background channel processing approval prompts', () => {
2665
2667
  deliverPromptSpy.mockRestore();
2666
2668
  });
2667
2669
 
2668
- test('non-guardian channel turns are not interactive to prevent self-approval', async () => {
2669
- // Set up a guardian binding for a DIFFERENT user so the sender is non-guardian
2670
+ test('trusted-contact channel turns with resolvable guardian route are interactive', async () => {
2671
+ // Set up a guardian binding for a DIFFERENT user so the sender is a
2672
+ // trusted contact (not the guardian). The guardian route is resolvable
2673
+ // because the binding exists — approval notifications can be delivered.
2670
2674
  createBinding({
2671
2675
  assistantId: 'self',
2672
2676
  channel: 'telegram',
@@ -2701,7 +2705,9 @@ describe('background channel processing approval prompts', () => {
2701
2705
  await new Promise((resolve) => setTimeout(resolve, 300));
2702
2706
 
2703
2707
  expect(processCalls.length).toBeGreaterThan(0);
2704
- expect(processCalls[0].options?.isInteractive).toBe(false);
2708
+ // Trusted contacts with a resolvable guardian route should be interactive
2709
+ // so approval prompts can be routed to the guardian for decision.
2710
+ expect(processCalls[0].options?.isInteractive).toBe(true);
2705
2711
  });
2706
2712
 
2707
2713
  test('unverified channel turns never broadcast approval prompts', async () => {
@@ -2869,3 +2875,118 @@ describe('NL approval routing via destination-scoped canonical requests', () =>
2869
2875
  expect(unchanged!.status).toBe('pending');
2870
2876
  });
2871
2877
  });
2878
+
2879
+ // ═══════════════════════════════════════════════════════════════════════════
2880
+ // Trusted-contact self-approval guard (pre-row)
2881
+ // ═══════════════════════════════════════════════════════════════════════════
2882
+
2883
+ describe('trusted-contact self-approval blocked before guardian approval row exists', () => {
2884
+ beforeEach(() => {
2885
+ // Create a guardian binding so the requester resolves as trusted_contact
2886
+ createBinding({
2887
+ assistantId: 'self',
2888
+ channel: 'telegram',
2889
+ guardianExternalUserId: 'guardian-tc-selfapproval',
2890
+ guardianDeliveryChatId: 'guardian-tc-selfapproval-chat',
2891
+ });
2892
+ });
2893
+
2894
+ test('trusted contact cannot self-approve via conversational engine when no guardian approval row exists', async () => {
2895
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
2896
+
2897
+ // Create the requester conversation (different user than guardian)
2898
+ const initReq = makeInboundRequest({
2899
+ content: 'init',
2900
+ externalChatId: 'tc-selfapproval-chat',
2901
+ senderExternalUserId: 'tc-selfapproval-user',
2902
+ });
2903
+ await handleChannelInbound(initReq, noopProcessMessage, 'token');
2904
+
2905
+ const db = getDb();
2906
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
2907
+ const conversationId = events[0]?.conversation_id;
2908
+ ensureConversation(conversationId!);
2909
+
2910
+ // Register a pending interaction — but do NOT create a guardian approval
2911
+ // row in channelGuardianApprovalRequests. This simulates the window
2912
+ // between the pending confirmation being created (isInteractive=true)
2913
+ // and the guardian approval prompt being delivered.
2914
+ const sessionMock = registerPendingInteraction('req-tc-selfapproval-1', conversationId!, 'shell');
2915
+
2916
+ deliverSpy.mockClear();
2917
+
2918
+ // The conversational engine would normally classify "yes" as approve_once,
2919
+ // but the guard should intercept before the engine runs.
2920
+ const mockConversationGenerator = mock(async (_ctx: unknown) => ({
2921
+ disposition: 'approve_once' as const,
2922
+ replyText: 'Approved!',
2923
+ }));
2924
+
2925
+ // Trusted contact sends "yes" to try to self-approve
2926
+ const req = makeInboundRequest({
2927
+ content: 'yes',
2928
+ externalChatId: 'tc-selfapproval-chat',
2929
+ senderExternalUserId: 'tc-selfapproval-user',
2930
+ });
2931
+ const res = await handleChannelInbound(
2932
+ req, noopProcessMessage, 'token', 'self', undefined,
2933
+ undefined, mockConversationGenerator,
2934
+ );
2935
+ const body = await res.json() as Record<string, unknown>;
2936
+
2937
+ expect(body.accepted).toBe(true);
2938
+ // Should be blocked with assistant_turn (pending guardian notice),
2939
+ // NOT decision_applied
2940
+ expect(body.approval).toBe('assistant_turn');
2941
+ // The session should NOT have been resolved
2942
+ expect(sessionMock).not.toHaveBeenCalled();
2943
+
2944
+ // The pending interaction should still be registered (not consumed)
2945
+ const stillPending = pendingInteractions.get('req-tc-selfapproval-1');
2946
+ expect(stillPending).toBeDefined();
2947
+
2948
+ deliverSpy.mockRestore();
2949
+ });
2950
+
2951
+ test('trusted contact cannot self-approve via legacy parser when no guardian approval row exists', async () => {
2952
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
2953
+
2954
+ const initReq = makeInboundRequest({
2955
+ content: 'init',
2956
+ externalChatId: 'tc-selfapproval-chat',
2957
+ senderExternalUserId: 'tc-selfapproval-user',
2958
+ });
2959
+ await handleChannelInbound(initReq, noopProcessMessage, 'token');
2960
+
2961
+ const db = getDb();
2962
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
2963
+ const conversationId = events[0]?.conversation_id;
2964
+ ensureConversation(conversationId!);
2965
+
2966
+ // Register pending interaction without guardian approval row
2967
+ const sessionMock = registerPendingInteraction('req-tc-selfapproval-2', conversationId!, 'shell');
2968
+
2969
+ deliverSpy.mockClear();
2970
+
2971
+ // No conversational engine — falls through to legacy parser path.
2972
+ // "approve" would normally be parsed as an approval decision.
2973
+ const req = makeInboundRequest({
2974
+ content: 'approve',
2975
+ externalChatId: 'tc-selfapproval-chat',
2976
+ senderExternalUserId: 'tc-selfapproval-user',
2977
+ });
2978
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token');
2979
+ const body = await res.json() as Record<string, unknown>;
2980
+
2981
+ expect(body.accepted).toBe(true);
2982
+ // Should be blocked, not decision_applied
2983
+ expect(body.approval).toBe('assistant_turn');
2984
+ expect(sessionMock).not.toHaveBeenCalled();
2985
+
2986
+ // Pending interaction should still exist
2987
+ const stillPending = pendingInteractions.get('req-tc-selfapproval-2');
2988
+ expect(stillPending).toBeDefined();
2989
+
2990
+ deliverSpy.mockRestore();
2991
+ });
2992
+ });