@vellumai/assistant 0.4.29 → 0.4.30

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 (174) hide show
  1. package/ARCHITECTURE.md +39 -37
  2. package/README.md +5 -6
  3. package/docs/runbook-trusted-contacts.md +79 -43
  4. package/package.json +1 -1
  5. package/scripts/ipc/check-swift-decoder-drift.ts +2 -3
  6. package/scripts/test.sh +1 -1
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +4 -37
  8. package/src/__tests__/actor-token-service.test.ts +4 -3
  9. package/src/__tests__/app-executors.test.ts +7 -17
  10. package/src/__tests__/assistant-feature-flags-integration.test.ts +18 -10
  11. package/src/__tests__/browser-skill-endstate.test.ts +10 -1
  12. package/src/__tests__/bundled-skill-retrieval-guard.test.ts +1 -0
  13. package/src/__tests__/channel-approval-routes.test.ts +44 -44
  14. package/src/__tests__/channel-approval.test.ts +8 -0
  15. package/src/__tests__/channel-approvals.test.ts +39 -1
  16. package/src/__tests__/channel-guardian.test.ts +15 -5
  17. package/src/__tests__/channel-reply-delivery.test.ts +31 -0
  18. package/src/__tests__/commit-message-enrichment-service.test.ts +4 -0
  19. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +9 -0
  20. package/src/__tests__/gateway-only-guard.test.ts +1 -0
  21. package/src/__tests__/gemini-image-service.test.ts +2 -2
  22. package/src/__tests__/guardian-grant-minting.test.ts +6 -6
  23. package/src/__tests__/guardian-routing-invariants.test.ts +34 -11
  24. package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +4 -6
  25. package/src/__tests__/inbound-invite-redemption.test.ts +1 -1
  26. package/src/__tests__/integrations-cli.test.ts +3 -27
  27. package/src/__tests__/intent-routing.test.ts +3 -0
  28. package/src/__tests__/invite-redemption-service.test.ts +1 -1
  29. package/src/__tests__/{ingress-routes-http.test.ts → invite-routes-http.test.ts} +40 -320
  30. package/src/__tests__/ipc-snapshot.test.ts +4 -31
  31. package/src/__tests__/nl-approval-parser.test.ts +305 -0
  32. package/src/__tests__/oauth-provider-profiles.test.ts +34 -0
  33. package/src/__tests__/provider-error-scenarios.test.ts +68 -0
  34. package/src/__tests__/relay-server.test.ts +1 -1
  35. package/src/__tests__/retry-after-extraction.test.ts +111 -0
  36. package/src/__tests__/script-proxy-profile-template-fallback.test.ts +127 -0
  37. package/src/__tests__/session-media-retry.test.ts +147 -0
  38. package/src/__tests__/skill-feature-flags-integration.test.ts +9 -5
  39. package/src/__tests__/skill-feature-flags.test.ts +18 -12
  40. package/src/__tests__/skill-load-feature-flag.test.ts +4 -3
  41. package/src/__tests__/slack-block-formatting.test.ts +100 -0
  42. package/src/__tests__/slack-inbound-verification.test.ts +346 -0
  43. package/src/__tests__/slack-reaction-approvals.test.ts +77 -0
  44. package/src/__tests__/slack-skill.test.ts +3 -2
  45. package/src/__tests__/starter-task-flow.test.ts +0 -1
  46. package/src/__tests__/trusted-contact-verification.test.ts +3 -1
  47. package/src/__tests__/voice-invite-redemption.test.ts +1 -1
  48. package/src/amazon/client.ts +7 -24
  49. package/src/calls/relay-server.ts +39 -11
  50. package/src/channels/config.ts +1 -1
  51. package/src/cli/integrations.ts +10 -66
  52. package/src/config/bundled-skills/app-builder/SKILL.md +193 -1500
  53. package/src/config/bundled-skills/app-builder/TOOLS.json +70 -18
  54. package/src/config/bundled-skills/browser/TOOLS.json +59 -2
  55. package/src/config/bundled-skills/chatgpt-import/TOOLS.json +4 -0
  56. package/src/config/bundled-skills/computer-use/TOOLS.json +50 -2
  57. package/src/config/bundled-skills/contacts/SKILL.md +42 -35
  58. package/src/config/bundled-skills/contacts/TOOLS.json +22 -2
  59. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +38 -58
  60. package/src/config/bundled-skills/contacts/tools/contact-search.ts +11 -31
  61. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +19 -37
  62. package/src/config/bundled-skills/document/TOOLS.json +8 -0
  63. package/src/config/bundled-skills/email-setup/SKILL.md +10 -7
  64. package/src/config/bundled-skills/followups/TOOLS.json +12 -0
  65. package/src/config/bundled-skills/google-calendar/TOOLS.json +124 -26
  66. package/src/config/bundled-skills/guardian-verify-setup/SKILL.md +54 -21
  67. package/src/config/bundled-skills/image-studio/TOOLS.json +12 -2
  68. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +14 -8
  69. package/src/config/bundled-skills/knowledge-graph/TOOLS.json +13 -3
  70. package/src/config/bundled-skills/media-processing/SKILL.md +1 -1
  71. package/src/config/bundled-skills/media-processing/TOOLS.json +28 -0
  72. package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +26 -6
  73. package/src/config/bundled-skills/messaging/TOOLS.json +228 -182
  74. package/src/config/bundled-skills/notifications/SKILL.md +3 -2
  75. package/src/config/bundled-skills/notifications/TOOLS.json +7 -13
  76. package/src/config/bundled-skills/phone-calls/TOOLS.json +13 -1
  77. package/src/config/bundled-skills/playbooks/TOOLS.json +16 -0
  78. package/src/config/bundled-skills/reminder/TOOLS.json +15 -2
  79. package/src/config/bundled-skills/schedule/SKILL.md +33 -15
  80. package/src/config/bundled-skills/schedule/TOOLS.json +17 -1
  81. package/src/config/bundled-skills/slack/SKILL.md +30 -1
  82. package/src/config/bundled-skills/slack/TOOLS.json +89 -2
  83. package/src/config/bundled-skills/slack/tools/slack-channel-permissions.ts +146 -0
  84. package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +120 -0
  85. package/src/config/bundled-skills/slack-app-setup/SKILL.md +200 -0
  86. package/src/config/bundled-skills/subagent/TOOLS.json +22 -2
  87. package/src/config/bundled-skills/tasks/TOOLS.json +86 -14
  88. package/src/config/bundled-skills/transcribe/TOOLS.json +4 -0
  89. package/src/config/bundled-skills/watcher/TOOLS.json +20 -0
  90. package/src/config/bundled-skills/weather/TOOLS.json +4 -0
  91. package/src/config/bundled-tool-registry.ts +2 -0
  92. package/src/config/channel-permission-profiles.ts +155 -0
  93. package/src/config/env.ts +4 -1
  94. package/src/contacts/contact-store.ts +195 -4
  95. package/src/contacts/types.ts +26 -0
  96. package/src/daemon/assistant-attachments.ts +23 -3
  97. package/src/daemon/guardian-verification-intent.ts +7 -4
  98. package/src/daemon/handlers/apps.ts +1 -2
  99. package/src/daemon/handlers/config-inbox.ts +16 -134
  100. package/src/daemon/handlers/guardian-actions.ts +20 -87
  101. package/src/daemon/handlers/sessions.ts +0 -1
  102. package/src/daemon/ipc-contract/apps.ts +0 -1
  103. package/src/daemon/ipc-contract/inbox.ts +7 -66
  104. package/src/daemon/ipc-contract/sessions.ts +1 -0
  105. package/src/daemon/ipc-contract/surfaces.ts +0 -1
  106. package/src/daemon/ipc-contract-inventory.json +2 -4
  107. package/src/daemon/lifecycle.ts +14 -2
  108. package/src/daemon/session-agent-loop-handlers.ts +9 -0
  109. package/src/daemon/session-agent-loop.ts +1 -0
  110. package/src/daemon/session-attachments.ts +5 -1
  111. package/src/daemon/session-error.ts +18 -0
  112. package/src/daemon/session-lifecycle.ts +4 -5
  113. package/src/daemon/session-media-retry.ts +15 -1
  114. package/src/daemon/session-surfaces.ts +0 -1
  115. package/src/daemon/session-tool-setup.ts +7 -4
  116. package/src/events/domain-events.ts +2 -1
  117. package/src/home-base/prebuilt/seed.ts +0 -1
  118. package/src/influencer/client.ts +7 -24
  119. package/src/media/gemini-image-service.ts +48 -3
  120. package/src/memory/app-store.ts +0 -4
  121. package/src/memory/conversation-attention-store.ts +3 -1
  122. package/src/memory/db-init.ts +4 -0
  123. package/src/memory/migrations/133-assistant-contact-metadata.ts +21 -0
  124. package/src/memory/migrations/index.ts +1 -0
  125. package/src/memory/schema.ts +12 -0
  126. package/src/memory/slack-thread-store.ts +187 -0
  127. package/src/messaging/providers/slack/client.ts +84 -26
  128. package/src/messaging/providers/slack/types.ts +4 -0
  129. package/src/notifications/adapters/slack.ts +90 -0
  130. package/src/notifications/destination-resolver.ts +42 -1
  131. package/src/notifications/emit-signal.ts +17 -1
  132. package/src/oauth/provider-profiles.ts +22 -0
  133. package/src/providers/anthropic/client.ts +3 -0
  134. package/src/providers/openai/client.ts +3 -0
  135. package/src/providers/retry.ts +9 -1
  136. package/src/runtime/actor-trust-resolver.ts +8 -0
  137. package/src/runtime/auth/require-bound-guardian.ts +44 -0
  138. package/src/runtime/auth/route-policy.ts +4 -8
  139. package/src/runtime/channel-approval-types.ts +18 -0
  140. package/src/runtime/channel-approvals.ts +8 -0
  141. package/src/runtime/channel-invite-transport.ts +1 -1
  142. package/src/runtime/channel-reply-delivery.ts +62 -3
  143. package/src/runtime/gateway-client.ts +36 -2
  144. package/src/runtime/gateway-internal-client.ts +86 -0
  145. package/src/runtime/guardian-action-service.ts +127 -0
  146. package/src/runtime/guardian-verification-templates.ts +16 -1
  147. package/src/runtime/http-server.ts +20 -49
  148. package/src/runtime/invite-redemption-service.ts +1 -1
  149. package/src/runtime/{ingress-service.ts → invite-service.ts} +5 -157
  150. package/src/runtime/nl-approval-parser.ts +138 -0
  151. package/src/runtime/routes/approval-routes.ts +1 -40
  152. package/src/runtime/routes/channel-route-shared.ts +35 -1
  153. package/src/runtime/routes/contact-routes.ts +196 -28
  154. package/src/runtime/routes/guardian-action-routes.ts +19 -111
  155. package/src/runtime/routes/guardian-approval-interception.ts +76 -0
  156. package/src/runtime/routes/inbound-message-handler.ts +40 -12
  157. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +222 -0
  158. package/src/runtime/routes/inbound-stages/background-dispatch.ts +108 -0
  159. package/src/runtime/routes/{ingress-routes.ts → invite-routes.ts} +10 -110
  160. package/src/runtime/slack-block-formatting.ts +176 -0
  161. package/src/schedule/scheduler.ts +11 -2
  162. package/src/tools/apps/executors.ts +16 -15
  163. package/src/tools/calls/call-end.ts +1 -1
  164. package/src/tools/computer-use/definitions.ts +16 -0
  165. package/src/tools/credentials/vault.ts +86 -2
  166. package/src/tools/network/script-proxy/session-manager.ts +28 -3
  167. package/src/tools/permission-checker.ts +18 -0
  168. package/src/tools/terminal/shell.ts +15 -5
  169. package/src/tools/tool-approval-handler.ts +48 -4
  170. package/src/tools/types.ts +38 -1
  171. package/src/util/errors.ts +5 -1
  172. package/src/util/retry.ts +21 -0
  173. package/src/watcher/providers/slack.ts +33 -3
  174. /package/src/memory/{ingress-invite-store.ts → invite-store.ts} +0 -0
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Tests for Slack inbound trusted contact verification.
3
+ *
4
+ * When an unknown Slack user messages the bot, the system should:
5
+ * 1. Create an outbound verification session bound to the user's identity
6
+ * 2. Send the verification code to the user's DM via the gateway
7
+ * 3. Reply in the original channel telling the user to check their DMs
8
+ * 4. Notify the guardian of the access attempt
9
+ * 5. When the user replies with the code in the DM, verify and activate
10
+ */
11
+ import { mkdtempSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Test isolation: in-memory SQLite via temp directory
18
+ // ---------------------------------------------------------------------------
19
+
20
+ const testDir = mkdtempSync(join(tmpdir(), "slack-inbound-verification-test-"));
21
+
22
+ mock.module("../util/platform.js", () => ({
23
+ getRootDir: () => testDir,
24
+ getDataDir: () => testDir,
25
+ isMacOS: () => process.platform === "darwin",
26
+ isLinux: () => process.platform === "linux",
27
+ isWindows: () => process.platform === "win32",
28
+ getSocketPath: () => join(testDir, "test.sock"),
29
+ getPidPath: () => join(testDir, "test.pid"),
30
+ getDbPath: () => join(testDir, "test.db"),
31
+ getLogPath: () => join(testDir, "test.log"),
32
+ ensureDataDir: () => {},
33
+ readHttpToken: () => "test-bearer-token",
34
+ }));
35
+
36
+ mock.module("../util/logger.js", () => ({
37
+ getLogger: () =>
38
+ new Proxy({} as Record<string, unknown>, {
39
+ get: () => () => {},
40
+ }),
41
+ }));
42
+
43
+ mock.module("../security/secret-ingress.js", () => ({
44
+ checkIngressForSecrets: () => ({ blocked: false }),
45
+ }));
46
+
47
+ mock.module("../config/env.js", () => ({
48
+ isHttpAuthDisabled: () => true,
49
+ getGatewayInternalBaseUrl: () => "http://127.0.0.1:7830",
50
+ }));
51
+
52
+ // Track emitNotificationSignal calls
53
+ const emitSignalCalls: Array<Record<string, unknown>> = [];
54
+ mock.module("../notifications/emit-signal.js", () => ({
55
+ emitNotificationSignal: async (params: Record<string, unknown>) => {
56
+ emitSignalCalls.push(params);
57
+ return {
58
+ signalId: "mock-signal-id",
59
+ deduplicated: false,
60
+ dispatched: true,
61
+ reason: "mock",
62
+ deliveryResults: [],
63
+ };
64
+ },
65
+ }));
66
+
67
+ // Track deliverChannelReply calls
68
+ const deliverReplyCalls: Array<{
69
+ url: string;
70
+ payload: Record<string, unknown>;
71
+ }> = [];
72
+ mock.module("../runtime/gateway-client.js", () => ({
73
+ deliverChannelReply: async (
74
+ url: string,
75
+ payload: Record<string, unknown>,
76
+ ) => {
77
+ deliverReplyCalls.push({ url, payload });
78
+ },
79
+ }));
80
+
81
+ import { createGuardianBinding } from "../contacts/contacts-write.js";
82
+ import { getDb, initializeDb, resetDb } from "../memory/db.js";
83
+ import { findActiveSession } from "../runtime/channel-guardian-service.js";
84
+ import { handleChannelInbound } from "../runtime/routes/channel-routes.js";
85
+
86
+ initializeDb();
87
+
88
+ afterAll(() => {
89
+ resetDb();
90
+ try {
91
+ rmSync(testDir, { recursive: true });
92
+ } catch {
93
+ /* best effort */
94
+ }
95
+ });
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Helpers
99
+ // ---------------------------------------------------------------------------
100
+
101
+ const TEST_BEARER_TOKEN = "test-token";
102
+
103
+ function resetState(): void {
104
+ const db = getDb();
105
+ db.run("DELETE FROM channel_guardian_approval_requests");
106
+ db.run("DELETE FROM channel_guardian_verification_challenges");
107
+ db.run("DELETE FROM channel_guardian_rate_limits");
108
+ db.run("DELETE FROM channel_inbound_events");
109
+ db.run("DELETE FROM conversations");
110
+ db.run("DELETE FROM notification_events");
111
+ db.run("DELETE FROM canonical_guardian_requests");
112
+ db.run("DELETE FROM canonical_guardian_deliveries");
113
+ db.run("DELETE FROM contact_channels");
114
+ db.run("DELETE FROM contacts");
115
+ emitSignalCalls.length = 0;
116
+ deliverReplyCalls.length = 0;
117
+ }
118
+
119
+ function buildSlackInboundRequest(
120
+ overrides: Record<string, unknown> = {},
121
+ ): Request {
122
+ const body: Record<string, unknown> = {
123
+ sourceChannel: "slack",
124
+ interface: "slack",
125
+ conversationExternalId: "C0123CHANNEL",
126
+ externalMessageId: `msg-${Date.now()}-${Math.random()
127
+ .toString(36)
128
+ .slice(2, 8)}`,
129
+ content: "Hello, can I use this assistant?",
130
+ actorExternalId: "U0123UNKNOWN",
131
+ actorDisplayName: "Alice Unknown",
132
+ actorUsername: "alice_unknown",
133
+ replyCallbackUrl: "http://localhost:7830/deliver/slack",
134
+ ...overrides,
135
+ };
136
+
137
+ return new Request("http://localhost:8080/channels/inbound", {
138
+ method: "POST",
139
+ headers: {
140
+ "Content-Type": "application/json",
141
+ "X-Gateway-Origin": TEST_BEARER_TOKEN,
142
+ },
143
+ body: JSON.stringify(body),
144
+ });
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Tests
149
+ // ---------------------------------------------------------------------------
150
+
151
+ describe("Slack inbound trusted contact verification", () => {
152
+ beforeEach(() => {
153
+ resetState();
154
+ });
155
+
156
+ test("unknown Slack user receives verification challenge via DM", async () => {
157
+ const req = buildSlackInboundRequest();
158
+ const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
159
+ const json = (await resp.json()) as Record<string, unknown>;
160
+
161
+ expect(json.denied).toBe(true);
162
+ expect(json.reason).toBe("verification_challenge_sent");
163
+ expect(json.verificationSessionId).toBeDefined();
164
+
165
+ // Verification code is NOT sent to the requester — only the guardian
166
+ // receives it via the access request notification flow
167
+
168
+ // Channel reply tells user the owner has been notified
169
+ expect(deliverReplyCalls.length).toBe(1);
170
+ expect(
171
+ (deliverReplyCalls[0].payload as Record<string, unknown>).text,
172
+ ).toContain("notified the owner");
173
+ });
174
+
175
+ test("verification session is identity-bound to the Slack user", async () => {
176
+ const req = buildSlackInboundRequest();
177
+ await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
178
+
179
+ // An active outbound session should exist for the slack channel
180
+ const session = findActiveSession("self", "slack");
181
+ expect(session).not.toBeNull();
182
+ expect(session!.expectedExternalUserId).toBe("U0123UNKNOWN");
183
+ expect(session!.expectedChatId).toBe("U0123UNKNOWN");
184
+ expect(session!.identityBindingStatus).toBe("bound");
185
+ expect(session!.verificationPurpose).toBe("trusted_contact");
186
+ });
187
+
188
+ test("guardian is notified of the access attempt alongside verification", async () => {
189
+ // Set up a guardian binding so the notification can target it
190
+ createGuardianBinding({
191
+ assistantId: "self",
192
+ channel: "slack",
193
+ guardianExternalUserId: "U_GUARDIAN",
194
+ guardianDeliveryChatId: "D_GUARDIAN_DM",
195
+ guardianPrincipalId: "guardian-principal",
196
+ verifiedVia: "test",
197
+ });
198
+
199
+ const req = buildSlackInboundRequest();
200
+ await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
201
+
202
+ // Guardian should have been notified
203
+ expect(emitSignalCalls.length).toBe(1);
204
+ expect(emitSignalCalls[0].sourceEventName).toBe("ingress.access_request");
205
+ expect(emitSignalCalls[0].sourceChannel).toBe("slack");
206
+ });
207
+
208
+ test("duplicate challenge is not sent when session already exists", async () => {
209
+ // First message creates the session
210
+ const req1 = buildSlackInboundRequest();
211
+ const resp1 = await handleChannelInbound(
212
+ req1,
213
+ undefined,
214
+ TEST_BEARER_TOKEN,
215
+ );
216
+ const json1 = (await resp1.json()) as Record<string, unknown>;
217
+ expect(json1.reason).toBe("verification_challenge_sent");
218
+
219
+ // Second message from the same user — session already exists, so
220
+ // falls through to standard deny path
221
+ const req2 = buildSlackInboundRequest({
222
+ externalMessageId: `msg-${Date.now()}-second`,
223
+ });
224
+ const resp2 = await handleChannelInbound(
225
+ req2,
226
+ undefined,
227
+ TEST_BEARER_TOKEN,
228
+ );
229
+ const json2 = (await resp2.json()) as Record<string, unknown>;
230
+ expect(json2.denied).toBe(true);
231
+ expect(json2.reason).toBe("not_a_member");
232
+
233
+ // No DM was sent at all
234
+ });
235
+
236
+ test("different Slack user is not suppressed by existing session for another user", async () => {
237
+ // First message from user A creates a session
238
+ const req1 = buildSlackInboundRequest({
239
+ actorExternalId: "U_USER_A",
240
+ actorDisplayName: "User A",
241
+ });
242
+ const resp1 = await handleChannelInbound(
243
+ req1,
244
+ undefined,
245
+ TEST_BEARER_TOKEN,
246
+ );
247
+ const json1 = (await resp1.json()) as Record<string, unknown>;
248
+ expect(json1.reason).toBe("verification_challenge_sent");
249
+
250
+ // Second message from user B — should get their own challenge
251
+ const req2 = buildSlackInboundRequest({
252
+ actorExternalId: "U_USER_B",
253
+ actorDisplayName: "User B",
254
+ externalMessageId: `msg-${Date.now()}-user-b`,
255
+ });
256
+ const resp2 = await handleChannelInbound(
257
+ req2,
258
+ undefined,
259
+ TEST_BEARER_TOKEN,
260
+ );
261
+ const json2 = (await resp2.json()) as Record<string, unknown>;
262
+ expect(json2.reason).toBe("verification_challenge_sent");
263
+ expect(json2.verificationSessionId).toBeDefined();
264
+
265
+ // No DMs sent to requesters — guardian gets code via notification flow
266
+ });
267
+
268
+ test("non-Slack channels still use standard access request flow", async () => {
269
+ const req = buildSlackInboundRequest({
270
+ sourceChannel: "telegram",
271
+ interface: "telegram",
272
+ replyCallbackUrl: "http://localhost:7830/deliver/telegram",
273
+ });
274
+ const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
275
+ const json = (await resp.json()) as Record<string, unknown>;
276
+
277
+ // Standard deny path — no verification challenge
278
+ expect(json.denied).toBe(true);
279
+ expect(json.reason).toBe("not_a_member");
280
+
281
+ // No Slack DM was sent
282
+ });
283
+
284
+ test("user can verify by replying with the code in the DM", async () => {
285
+ // Step 1: Unknown user sends a message, gets verification challenge
286
+ const req = buildSlackInboundRequest();
287
+ await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
288
+
289
+ const session = findActiveSession("self", "slack");
290
+ expect(session).not.toBeNull();
291
+
292
+ // The challenge hash is stored in the session — extract the secret
293
+ // from the DM text sent to the user. The code is embedded in the
294
+ // template text. Since we're using a 6-digit code for identity-bound
295
+ // sessions, extract it from the session's challengeHash by consuming
296
+ // the challenge directly.
297
+ // The session was created with createOutboundSession which generates
298
+ // a 6-digit code. We can validate by calling validateAndConsumeChallenge
299
+ // with the correct secret. Since the mock captures the DM text, we
300
+ // can extract the code indirectly. But for testing, we just verify
301
+ // the session properties and that validateAndConsumeChallenge works
302
+ // with the correct identity.
303
+
304
+ // The actual secret was sent in the DM. For this test, let's use the
305
+ // session directly via the channel-guardian-service to verify the
306
+ // consume path works.
307
+ // The DM text contains the verification code implicitly (it's in the
308
+ // template message). Since we need to test the full round-trip, let's
309
+ // verify via the inbound handler by sending the code as a message.
310
+
311
+ // Extract the session's challenge hash and verify that submitting the
312
+ // correct code works. We create a fresh session with a known secret for
313
+ // this part of the test.
314
+ resetState();
315
+
316
+ // Create a verification session manually to test the consume path
317
+ const { createOutboundSession } =
318
+ await import("../runtime/channel-guardian-service.js");
319
+
320
+ const outboundSession = createOutboundSession({
321
+ assistantId: "self",
322
+ channel: "slack",
323
+ expectedExternalUserId: "U0123UNKNOWN",
324
+ expectedChatId: "U0123UNKNOWN",
325
+ identityBindingStatus: "bound",
326
+ destinationAddress: "U0123UNKNOWN",
327
+ verificationPurpose: "trusted_contact",
328
+ });
329
+
330
+ // User replies with the code in the DM
331
+ const verifyReq = buildSlackInboundRequest({
332
+ conversationExternalId: "U0123UNKNOWN",
333
+ content: outboundSession.secret,
334
+ externalMessageId: `msg-verify-${Date.now()}`,
335
+ });
336
+ const verifyResp = await handleChannelInbound(
337
+ verifyReq,
338
+ undefined,
339
+ TEST_BEARER_TOKEN,
340
+ );
341
+ const verifyJson = (await verifyResp.json()) as Record<string, unknown>;
342
+
343
+ expect(verifyJson.accepted).toBe(true);
344
+ expect(verifyJson.guardianVerification).toBe("verified");
345
+ });
346
+ });
@@ -0,0 +1,77 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { parseReactionCallbackData } from "../runtime/routes/channel-route-shared.js";
4
+
5
+ // =============================================================================
6
+ // parseReactionCallbackData
7
+ // =============================================================================
8
+
9
+ describe("parseReactionCallbackData", () => {
10
+ test("maps +1 emoji to approve_once", () => {
11
+ const result = parseReactionCallbackData("reaction:+1");
12
+ expect(result).toEqual({
13
+ action: "approve_once",
14
+ source: "slack_reaction",
15
+ });
16
+ });
17
+
18
+ test("maps thumbsup emoji to approve_once", () => {
19
+ const result = parseReactionCallbackData("reaction:thumbsup");
20
+ expect(result).toEqual({
21
+ action: "approve_once",
22
+ source: "slack_reaction",
23
+ });
24
+ });
25
+
26
+ test("maps -1 emoji to reject", () => {
27
+ const result = parseReactionCallbackData("reaction:-1");
28
+ expect(result).toEqual({
29
+ action: "reject",
30
+ source: "slack_reaction",
31
+ });
32
+ });
33
+
34
+ test("maps thumbsdown emoji to reject", () => {
35
+ const result = parseReactionCallbackData("reaction:thumbsdown");
36
+ expect(result).toEqual({
37
+ action: "reject",
38
+ source: "slack_reaction",
39
+ });
40
+ });
41
+
42
+ test("maps alarm_clock emoji to approve_10m", () => {
43
+ const result = parseReactionCallbackData("reaction:alarm_clock");
44
+ expect(result).toEqual({
45
+ action: "approve_10m",
46
+ source: "slack_reaction",
47
+ });
48
+ });
49
+
50
+ test("maps white_check_mark emoji to approve_always", () => {
51
+ const result = parseReactionCallbackData("reaction:white_check_mark");
52
+ expect(result).toEqual({
53
+ action: "approve_always",
54
+ source: "slack_reaction",
55
+ });
56
+ });
57
+
58
+ test("returns null for unknown emoji", () => {
59
+ const result = parseReactionCallbackData("reaction:tada");
60
+ expect(result).toBeNull();
61
+ });
62
+
63
+ test("returns null for empty emoji name", () => {
64
+ const result = parseReactionCallbackData("reaction:");
65
+ expect(result).toBeNull();
66
+ });
67
+
68
+ test("returns null for non-reaction callback data", () => {
69
+ const result = parseReactionCallbackData("apr:req-1:approve_once");
70
+ expect(result).toBeNull();
71
+ });
72
+
73
+ test("returns null for plain text", () => {
74
+ const result = parseReactionCallbackData("yes");
75
+ expect(result).toBeNull();
76
+ });
77
+ });
@@ -64,10 +64,11 @@ describe("slack skill TOOLS.json", () => {
64
64
  expect(names).toContain("slack_edit_message");
65
65
  expect(names).toContain("slack_delete_message");
66
66
  expect(names).toContain("slack_leave_channel");
67
+ expect(names).toContain("slack_channel_permissions");
67
68
  });
68
69
 
69
- test("has 7 tools total", () => {
70
- expect(toolsJson.tools.length).toBe(7);
70
+ test("has 8 tools total", () => {
71
+ expect(toolsJson.tools.length).toBe(8);
71
72
  });
72
73
 
73
74
  test("all tools have required fields", () => {
@@ -8,7 +8,6 @@ mock.module("../memory/app-store.js", () => ({
8
8
  return {
9
9
  id,
10
10
  name: "Home Base",
11
- appType: "app",
12
11
  htmlDefinition:
13
12
  '<main id="home-base-root" data-vellum-home-base="v1"></main>',
14
13
  };
@@ -449,7 +449,9 @@ describe("trusted contact verification → member activation", () => {
449
449
  );
450
450
 
451
451
  expect(result.success).toBe(true);
452
- expect(result.verificationType).toBe("guardian");
452
+ if (result.success) {
453
+ expect(result.verificationType).toBe("guardian");
454
+ }
453
455
 
454
456
  const guardianResult = findGuardianForChannel("telegram", "self");
455
457
  expect(guardianResult).toBeNull();
@@ -26,7 +26,7 @@ mock.module("../util/logger.js", () => ({
26
26
 
27
27
  import { upsertMember } from "../contacts/contacts-write.js";
28
28
  import { getSqlite, initializeDb, resetDb } from "../memory/db.js";
29
- import { createInvite, revokeInvite } from "../memory/ingress-invite-store.js";
29
+ import { createInvite, revokeInvite } from "../memory/invite-store.js";
30
30
  import { redeemVoiceInviteCode } from "../runtime/invite-redemption-service.js";
31
31
  import { generateVoiceCode, hashVoiceCode } from "../util/voice-code.js";
32
32
 
@@ -53,11 +53,8 @@ import type {
53
53
  ExtensionResponse,
54
54
  } from "../browser-extension-relay/protocol.js";
55
55
  import { extensionRelayServer } from "../browser-extension-relay/server.js";
56
- import { getGatewayInternalBaseUrl } from "../config/env.js";
57
- import {
58
- isSigningKeyInitialized,
59
- mintEdgeRelayToken,
60
- } from "../runtime/auth/token-service.js";
56
+ import { isSigningKeyInitialized } from "../runtime/auth/token-service.js";
57
+ import { gatewayPost } from "../runtime/gateway-internal-client.js";
61
58
  import type { ExtractedCredential } from "../tools/browser/network-recording-types.js";
62
59
  import { type AmazonSession, loadSession } from "./session.js";
63
60
 
@@ -91,26 +88,12 @@ export async function sendRelayCommand(
91
88
  "Auth signing key not initialized — browser-relay commands require the daemon to be running",
92
89
  );
93
90
  }
94
- const token = mintEdgeRelayToken();
95
-
96
- const resp = await fetch(
97
- `${getGatewayInternalBaseUrl()}/v1/browser-relay/command`,
98
- {
99
- method: "POST",
100
- headers: {
101
- "Content-Type": "application/json",
102
- Authorization: `Bearer ${token}`,
103
- },
104
- body: JSON.stringify(command),
105
- },
106
- );
107
91
 
108
- if (!resp.ok) {
109
- const body = await resp.text();
110
- throw new Error(`Relay HTTP command failed (${resp.status}): ${body}`);
111
- }
112
-
113
- return (await resp.json()) as ExtensionResponse;
92
+ const { data } = await gatewayPost<ExtensionResponse>(
93
+ "/v1/browser-relay/command",
94
+ command,
95
+ );
96
+ return data;
114
97
  }
115
98
 
116
99
  /** Thrown when the session is missing or expired. The CLI handles this specially. */
@@ -25,7 +25,7 @@ import {
25
25
  import { getAssistantName } from "../daemon/identity-helpers.js";
26
26
  import { getCanonicalGuardianRequest } from "../memory/canonical-guardian-store.js";
27
27
  import * as conversationStore from "../memory/conversation-store.js";
28
- import { findActiveVoiceInvites } from "../memory/ingress-invite-store.js";
28
+ import { findActiveVoiceInvites } from "../memory/invite-store.js";
29
29
  import { revokeScopedApprovalGrantsForContext } from "../memory/scoped-approval-grants.js";
30
30
  import { emitNotificationSignal } from "../notifications/emit-signal.js";
31
31
  import { notifyGuardianOfAccessRequest } from "../runtime/access-request-helper.js";
@@ -43,7 +43,7 @@ import {
43
43
  composeVerificationVoice,
44
44
  GUARDIAN_VERIFY_TEMPLATE_KEYS,
45
45
  } from "../runtime/guardian-verification-templates.js";
46
- import { redeemVoiceInviteCode } from "../runtime/ingress-service.js";
46
+ import { redeemVoiceInviteCode } from "../runtime/invite-service.js";
47
47
  import { parseJsonSafe } from "../util/json.js";
48
48
  import { getLogger } from "../util/logger.js";
49
49
  import {
@@ -1213,15 +1213,43 @@ export class RelayConnection {
1213
1213
 
1214
1214
  // Create the guardian binding now that verification succeeded.
1215
1215
  if (result.verificationType === "guardian") {
1216
- revokeGuardianBinding(this.guardianChallengeAssistantId, "voice");
1217
- createGuardianBinding({
1218
- assistantId: this.guardianChallengeAssistantId,
1219
- channel: "voice",
1220
- guardianExternalUserId: this.guardianVerificationFromNumber,
1221
- guardianDeliveryChatId: this.guardianVerificationFromNumber,
1222
- guardianPrincipalId: this.guardianVerificationFromNumber,
1223
- verifiedVia: "challenge",
1224
- });
1216
+ const existingBinding = getGuardianBinding(
1217
+ this.guardianChallengeAssistantId,
1218
+ "voice",
1219
+ );
1220
+ if (
1221
+ existingBinding &&
1222
+ existingBinding.guardianExternalUserId !==
1223
+ this.guardianVerificationFromNumber
1224
+ ) {
1225
+ log.warn(
1226
+ {
1227
+ callSessionId: this.callSessionId,
1228
+ existingGuardian: existingBinding.guardianExternalUserId,
1229
+ },
1230
+ "Guardian binding conflict: another user already holds the voice binding",
1231
+ );
1232
+ } else {
1233
+ revokeGuardianBinding(this.guardianChallengeAssistantId, "voice");
1234
+
1235
+ // Unify all channel bindings onto the canonical (vellum) principal
1236
+ const vellumBinding = getGuardianBinding(
1237
+ this.guardianChallengeAssistantId,
1238
+ "vellum",
1239
+ );
1240
+ const canonicalPrincipal =
1241
+ vellumBinding?.guardianPrincipalId ??
1242
+ this.guardianVerificationFromNumber;
1243
+
1244
+ createGuardianBinding({
1245
+ assistantId: this.guardianChallengeAssistantId,
1246
+ channel: "voice",
1247
+ guardianExternalUserId: this.guardianVerificationFromNumber,
1248
+ guardianDeliveryChatId: this.guardianVerificationFromNumber,
1249
+ guardianPrincipalId: canonicalPrincipal,
1250
+ verifiedVia: "challenge",
1251
+ });
1252
+ }
1225
1253
  }
1226
1254
 
1227
1255
  if (isOutbound) {
@@ -47,7 +47,7 @@ const CHANNEL_POLICIES = {
47
47
  },
48
48
  slack: {
49
49
  notification: {
50
- deliveryEnabled: false,
50
+ deliveryEnabled: true,
51
51
  conversationStrategy: "continue_existing_conversation",
52
52
  },
53
53
  },