@vellumai/assistant 0.4.29 → 0.4.31

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 (237) hide show
  1. package/ARCHITECTURE.md +39 -37
  2. package/Dockerfile +14 -8
  3. package/README.md +7 -8
  4. package/docs/architecture/memory.md +28 -29
  5. package/docs/runbook-trusted-contacts.md +76 -43
  6. package/package.json +1 -1
  7. package/scripts/ipc/check-swift-decoder-drift.ts +2 -3
  8. package/scripts/test.sh +1 -1
  9. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +4 -37
  10. package/src/__tests__/actor-token-service.test.ts +4 -3
  11. package/src/__tests__/app-executors.test.ts +7 -17
  12. package/src/__tests__/assistant-feature-flags-integration.test.ts +18 -10
  13. package/src/__tests__/browser-skill-endstate.test.ts +10 -1
  14. package/src/__tests__/bundled-skill-retrieval-guard.test.ts +1 -0
  15. package/src/__tests__/channel-approval-routes.test.ts +44 -44
  16. package/src/__tests__/channel-approval.test.ts +8 -0
  17. package/src/__tests__/channel-approvals.test.ts +39 -1
  18. package/src/__tests__/channel-guardian.test.ts +15 -5
  19. package/src/__tests__/channel-reply-delivery.test.ts +31 -0
  20. package/src/__tests__/config-schema.test.ts +0 -9
  21. package/src/__tests__/conflict-policy.test.ts +76 -0
  22. package/src/__tests__/conflict-store.test.ts +14 -20
  23. package/src/__tests__/contacts-tools.test.ts +8 -61
  24. package/src/__tests__/contradiction-checker.test.ts +5 -1
  25. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +9 -0
  26. package/src/__tests__/gateway-only-guard.test.ts +1 -0
  27. package/src/__tests__/gemini-image-service.test.ts +2 -2
  28. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +5 -3
  29. package/src/__tests__/guardian-grant-minting.test.ts +6 -6
  30. package/src/__tests__/guardian-routing-invariants.test.ts +40 -15
  31. package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +4 -6
  32. package/src/__tests__/inbound-invite-redemption.test.ts +1 -1
  33. package/src/__tests__/integrations-cli.test.ts +3 -27
  34. package/src/__tests__/intent-routing.test.ts +3 -0
  35. package/src/__tests__/invite-redemption-service.test.ts +1 -1
  36. package/src/__tests__/{ingress-routes-http.test.ts → invite-routes-http.test.ts} +40 -320
  37. package/src/__tests__/ipc-snapshot.test.ts +4 -31
  38. package/src/__tests__/memory-lifecycle-e2e.test.ts +11 -10
  39. package/src/__tests__/nl-approval-parser.test.ts +305 -0
  40. package/src/__tests__/oauth-provider-profiles.test.ts +34 -0
  41. package/src/__tests__/provider-error-scenarios.test.ts +68 -0
  42. package/src/__tests__/registry.test.ts +0 -10
  43. package/src/__tests__/relay-server.test.ts +1 -1
  44. package/src/__tests__/retry-after-extraction.test.ts +111 -0
  45. package/src/__tests__/script-proxy-profile-template-fallback.test.ts +127 -0
  46. package/src/__tests__/script-proxy-session-runtime.test.ts +6 -1
  47. package/src/__tests__/session-agent-loop.test.ts +0 -2
  48. package/src/__tests__/session-conflict-gate.test.ts +243 -388
  49. package/src/__tests__/session-media-retry.test.ts +147 -0
  50. package/src/__tests__/session-profile-injection.test.ts +0 -2
  51. package/src/__tests__/session-runtime-assembly.test.ts +2 -3
  52. package/src/__tests__/session-skill-tools.test.ts +0 -49
  53. package/src/__tests__/session-workspace-cache-state.test.ts +0 -1
  54. package/src/__tests__/session-workspace-injection.test.ts +0 -1
  55. package/src/__tests__/session-workspace-tool-tracking.test.ts +0 -1
  56. package/src/__tests__/skill-feature-flags-integration.test.ts +9 -5
  57. package/src/__tests__/skill-feature-flags.test.ts +18 -12
  58. package/src/__tests__/skill-load-feature-flag.test.ts +4 -3
  59. package/src/__tests__/slack-block-formatting.test.ts +100 -0
  60. package/src/__tests__/slack-inbound-verification.test.ts +346 -0
  61. package/src/__tests__/slack-reaction-approvals.test.ts +77 -0
  62. package/src/__tests__/slack-skill.test.ts +3 -2
  63. package/src/__tests__/starter-task-flow.test.ts +0 -1
  64. package/src/__tests__/tool-grant-request-escalation.test.ts +2 -1
  65. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +2 -1
  66. package/src/__tests__/trusted-contact-verification.test.ts +3 -1
  67. package/src/__tests__/voice-invite-redemption.test.ts +1 -1
  68. package/src/amazon/client.ts +7 -24
  69. package/src/approvals/guardian-decision-primitive.ts +11 -7
  70. package/src/approvals/guardian-request-resolvers.ts +5 -3
  71. package/src/calls/relay-server.ts +44 -11
  72. package/src/channels/config.ts +1 -1
  73. package/src/cli/integrations.ts +10 -66
  74. package/src/config/bundled-skills/app-builder/SKILL.md +193 -1500
  75. package/src/config/bundled-skills/app-builder/TOOLS.json +70 -18
  76. package/src/config/bundled-skills/browser/TOOLS.json +59 -2
  77. package/src/config/bundled-skills/chatgpt-import/TOOLS.json +4 -0
  78. package/src/config/bundled-skills/computer-use/TOOLS.json +50 -2
  79. package/src/config/bundled-skills/contacts/SKILL.md +49 -53
  80. package/src/config/bundled-skills/contacts/TOOLS.json +26 -22
  81. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +40 -62
  82. package/src/config/bundled-skills/contacts/tools/contact-search.ts +17 -43
  83. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +18 -57
  84. package/src/config/bundled-skills/document/TOOLS.json +8 -0
  85. package/src/config/bundled-skills/email-setup/SKILL.md +10 -7
  86. package/src/config/bundled-skills/followups/TOOLS.json +12 -0
  87. package/src/config/bundled-skills/google-calendar/TOOLS.json +124 -26
  88. package/src/config/bundled-skills/guardian-verify-setup/SKILL.md +54 -21
  89. package/src/config/bundled-skills/image-studio/TOOLS.json +12 -2
  90. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +14 -8
  91. package/src/config/bundled-skills/knowledge-graph/TOOLS.json +13 -3
  92. package/src/config/bundled-skills/media-processing/SKILL.md +1 -1
  93. package/src/config/bundled-skills/media-processing/TOOLS.json +28 -0
  94. package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +26 -6
  95. package/src/config/bundled-skills/messaging/TOOLS.json +228 -182
  96. package/src/config/bundled-skills/notifications/SKILL.md +3 -2
  97. package/src/config/bundled-skills/notifications/TOOLS.json +7 -13
  98. package/src/config/bundled-skills/phone-calls/TOOLS.json +13 -1
  99. package/src/config/bundled-skills/playbooks/TOOLS.json +16 -0
  100. package/src/config/bundled-skills/reminder/TOOLS.json +15 -2
  101. package/src/config/bundled-skills/schedule/SKILL.md +33 -15
  102. package/src/config/bundled-skills/schedule/TOOLS.json +17 -1
  103. package/src/config/bundled-skills/slack/SKILL.md +30 -1
  104. package/src/config/bundled-skills/slack/TOOLS.json +89 -2
  105. package/src/config/bundled-skills/slack/tools/slack-channel-permissions.ts +146 -0
  106. package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +120 -0
  107. package/src/config/bundled-skills/slack-app-setup/SKILL.md +200 -0
  108. package/src/config/bundled-skills/subagent/TOOLS.json +22 -2
  109. package/src/config/bundled-skills/tasks/TOOLS.json +86 -14
  110. package/src/config/bundled-skills/transcribe/TOOLS.json +4 -0
  111. package/src/config/bundled-skills/watcher/TOOLS.json +20 -0
  112. package/src/config/bundled-tool-registry.ts +2 -5
  113. package/src/config/channel-permission-profiles.ts +155 -0
  114. package/src/config/env.ts +4 -1
  115. package/src/config/memory-schema.ts +0 -10
  116. package/src/config/system-prompt.ts +6 -0
  117. package/src/contacts/contact-store.ts +221 -56
  118. package/src/contacts/contacts-write.ts +14 -3
  119. package/src/contacts/types.ts +35 -4
  120. package/src/daemon/assistant-attachments.ts +23 -3
  121. package/src/daemon/guardian-verification-intent.ts +7 -4
  122. package/src/daemon/handlers/apps.ts +1 -2
  123. package/src/daemon/handlers/config-heartbeat.ts +1 -2
  124. package/src/daemon/handlers/config-inbox.ts +16 -134
  125. package/src/daemon/handlers/contacts.ts +2 -2
  126. package/src/daemon/handlers/guardian-actions.ts +21 -88
  127. package/src/daemon/handlers/sessions.ts +2 -2
  128. package/src/daemon/ipc-contract/apps.ts +0 -1
  129. package/src/daemon/ipc-contract/contacts.ts +2 -2
  130. package/src/daemon/ipc-contract/inbox.ts +7 -66
  131. package/src/daemon/ipc-contract/sessions.ts +1 -0
  132. package/src/daemon/ipc-contract/surfaces.ts +0 -1
  133. package/src/daemon/ipc-contract-inventory.json +2 -4
  134. package/src/daemon/lifecycle.ts +14 -2
  135. package/src/daemon/session-agent-loop-handlers.ts +9 -0
  136. package/src/daemon/session-agent-loop.ts +2 -45
  137. package/src/daemon/session-attachments.ts +5 -1
  138. package/src/daemon/session-conflict-gate.ts +21 -82
  139. package/src/daemon/session-error.ts +18 -0
  140. package/src/daemon/session-lifecycle.ts +4 -5
  141. package/src/daemon/session-media-retry.ts +15 -1
  142. package/src/daemon/session-memory.ts +7 -52
  143. package/src/daemon/session-process.ts +3 -1
  144. package/src/daemon/session-runtime-assembly.ts +18 -35
  145. package/src/daemon/session-surfaces.ts +0 -1
  146. package/src/daemon/session-tool-setup.ts +7 -4
  147. package/src/events/domain-events.ts +2 -1
  148. package/src/heartbeat/heartbeat-service.ts +5 -1
  149. package/src/home-base/prebuilt/seed.ts +0 -1
  150. package/src/influencer/client.ts +7 -24
  151. package/src/media/gemini-image-service.ts +48 -3
  152. package/src/memory/app-store.ts +0 -4
  153. package/src/memory/conflict-intent.ts +3 -6
  154. package/src/memory/conflict-policy.ts +34 -0
  155. package/src/memory/conflict-store.ts +10 -18
  156. package/src/memory/contradiction-checker.ts +2 -2
  157. package/src/memory/conversation-attention-store.ts +3 -1
  158. package/src/memory/db-init.ts +8 -0
  159. package/src/memory/job-handlers/conflict.ts +0 -7
  160. package/src/memory/migrations/133-assistant-contact-metadata.ts +21 -0
  161. package/src/memory/migrations/134-contacts-notes-column.ts +51 -0
  162. package/src/memory/migrations/135-backfill-contact-interaction-stats.ts +31 -0
  163. package/src/memory/migrations/index.ts +3 -0
  164. package/src/memory/schema.ts +12 -17
  165. package/src/memory/slack-thread-store.ts +187 -0
  166. package/src/messaging/index.ts +0 -1
  167. package/src/messaging/providers/slack/client.ts +84 -26
  168. package/src/messaging/providers/slack/types.ts +4 -0
  169. package/src/messaging/types.ts +0 -38
  170. package/src/notifications/adapters/slack.ts +90 -0
  171. package/src/notifications/destination-resolver.ts +42 -1
  172. package/src/notifications/emit-signal.ts +17 -1
  173. package/src/oauth/provider-profiles.ts +22 -0
  174. package/src/providers/anthropic/client.ts +3 -0
  175. package/src/providers/openai/client.ts +3 -0
  176. package/src/providers/retry.ts +9 -1
  177. package/src/runtime/actor-trust-resolver.ts +8 -0
  178. package/src/runtime/auth/require-bound-guardian.ts +44 -0
  179. package/src/runtime/auth/route-policy.ts +4 -8
  180. package/src/runtime/channel-approval-types.ts +18 -0
  181. package/src/runtime/channel-approvals.ts +8 -0
  182. package/src/runtime/channel-invite-transport.ts +1 -1
  183. package/src/runtime/channel-reply-delivery.ts +62 -3
  184. package/src/runtime/gateway-client.ts +36 -2
  185. package/src/runtime/gateway-internal-client.ts +86 -0
  186. package/src/runtime/guardian-action-service.ts +128 -0
  187. package/src/runtime/guardian-outbound-actions.ts +3 -3
  188. package/src/runtime/guardian-reply-router.ts +4 -4
  189. package/src/runtime/guardian-verification-templates.ts +16 -1
  190. package/src/runtime/http-server.ts +29 -46
  191. package/src/runtime/invite-redemption-service.ts +1 -1
  192. package/src/runtime/{ingress-service.ts → invite-service.ts} +5 -157
  193. package/src/runtime/nl-approval-parser.ts +138 -0
  194. package/src/runtime/routes/approval-routes.ts +1 -40
  195. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +6 -3
  196. package/src/runtime/routes/channel-route-shared.ts +35 -1
  197. package/src/runtime/routes/contact-routes.ts +494 -47
  198. package/src/runtime/routes/conversation-routes.ts +2 -1
  199. package/src/runtime/routes/global-search-routes.ts +2 -2
  200. package/src/runtime/routes/guardian-action-routes.ts +19 -111
  201. package/src/runtime/routes/guardian-approval-interception.ts +78 -1
  202. package/src/runtime/routes/guardian-bootstrap-routes.ts +6 -1
  203. package/src/runtime/routes/inbound-message-handler.ts +40 -12
  204. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +227 -1
  205. package/src/runtime/routes/inbound-stages/background-dispatch.ts +108 -0
  206. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +2 -1
  207. package/src/runtime/routes/{ingress-routes.ts → invite-routes.ts} +10 -110
  208. package/src/runtime/routes/migration-routes.ts +17 -17
  209. package/src/runtime/slack-block-formatting.ts +176 -0
  210. package/src/schedule/scheduler.ts +11 -2
  211. package/src/tools/apps/executors.ts +16 -15
  212. package/src/tools/calls/call-end.ts +1 -1
  213. package/src/tools/computer-use/definitions.ts +16 -0
  214. package/src/tools/credentials/vault.ts +86 -2
  215. package/src/tools/network/script-proxy/session-manager.ts +28 -3
  216. package/src/tools/permission-checker.ts +18 -0
  217. package/src/tools/terminal/shell.ts +15 -5
  218. package/src/tools/tool-approval-handler.ts +48 -4
  219. package/src/tools/types.ts +38 -1
  220. package/src/util/errors.ts +5 -1
  221. package/src/util/retry.ts +21 -0
  222. package/src/watcher/providers/slack.ts +33 -3
  223. package/src/workspace/git-service.ts +6 -4
  224. package/src/__tests__/get-weather.test.ts +0 -393
  225. package/src/__tests__/weather-skill-regression.test.ts +0 -276
  226. package/src/autonomy/autonomy-resolver.ts +0 -62
  227. package/src/autonomy/autonomy-store.ts +0 -138
  228. package/src/autonomy/disposition-mapper.ts +0 -31
  229. package/src/autonomy/index.ts +0 -11
  230. package/src/autonomy/types.ts +0 -43
  231. package/src/config/bundled-skills/weather/SKILL.md +0 -38
  232. package/src/config/bundled-skills/weather/TOOLS.json +0 -32
  233. package/src/config/bundled-skills/weather/icon.svg +0 -24
  234. package/src/config/bundled-skills/weather/tools/get-weather.ts +0 -12
  235. package/src/messaging/triage-engine.ts +0 -344
  236. package/src/tools/weather/service.ts +0 -712
  237. /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
  };
@@ -177,7 +177,8 @@ function makeContext(overrides: Partial<ToolContext> = {}): ToolContext {
177
177
 
178
178
  function guardianActor(overrides: Partial<ActorContext> = {}): ActorContext {
179
179
  return {
180
- externalUserId: "guardian-1",
180
+ actorPrincipalId: "test-principal-id",
181
+ actorExternalUserId: "guardian-1",
181
182
  channel: "telegram",
182
183
  guardianPrincipalId: "test-principal-id",
183
184
  ...overrides,
@@ -237,7 +237,8 @@ function makeToolContext(overrides: Partial<ToolContext> = {}): ToolContext {
237
237
 
238
238
  function guardianActor(overrides: Partial<ActorContext> = {}): ActorContext {
239
239
  return {
240
- externalUserId: "guardian-1",
240
+ actorPrincipalId: "test-principal-id",
241
+ actorExternalUserId: "guardian-1",
241
242
  channel: "telegram",
242
243
  guardianPrincipalId: "test-principal-id",
243
244
  ...overrides,
@@ -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. */
@@ -150,7 +150,9 @@ export interface ApplyGuardianDecisionParams {
150
150
  approval: GuardianApprovalRequest;
151
151
  /** The parsed decision (action + source + optional requestId). */
152
152
  decision: ApprovalDecisionResult;
153
- /** External user ID of the actor making the decision. */
153
+ /** Principal ID of the actor making the decision (undefined in callback/interception paths without JWT/auth context). */
154
+ actorPrincipalId: string | undefined;
155
+ /** Channel-native external user ID of the deciding actor (Telegram user ID, phone, etc.). */
154
156
  actorExternalUserId: string | undefined;
155
157
  /** Channel the decision arrived on. */
156
158
  actorChannel: ChannelId;
@@ -180,6 +182,7 @@ export function applyGuardianDecision(
180
182
  const {
181
183
  approval,
182
184
  decision,
185
+ actorPrincipalId,
183
186
  actorExternalUserId,
184
187
  actorChannel,
185
188
  decisionContext,
@@ -222,22 +225,23 @@ export function applyGuardianDecision(
222
225
  : ("approved" as const);
223
226
  updateApprovalDecision(approval.id, {
224
227
  status: approvalStatus,
225
- decidedByExternalUserId: actorExternalUserId,
228
+ decidedByExternalUserId: actorExternalUserId ?? actorPrincipalId,
226
229
  });
227
230
 
228
231
  // Mint a scoped grant when a guardian approves a tool-approval request.
229
- // Skip when actorExternalUserId is undefined -- minting a grant without
232
+ // Skip when neither actor identity is available -- minting a grant without
230
233
  // a known guardian identity is meaningless (e.g. requester self-cancel).
234
+ const effectiveGuardianId = actorExternalUserId ?? actorPrincipalId;
231
235
  if (
232
236
  effectiveDecision.action !== "reject" &&
233
237
  matchedInfo &&
234
- actorExternalUserId
238
+ effectiveGuardianId
235
239
  ) {
236
240
  tryMintToolApprovalGrant({
237
241
  approvalInfo: matchedInfo,
238
242
  approval,
239
243
  decisionChannel: actorChannel,
240
- guardianExternalUserId: actorExternalUserId,
244
+ guardianExternalUserId: effectiveGuardianId,
241
245
  });
242
246
  }
243
247
 
@@ -507,7 +511,7 @@ export async function applyCanonicalGuardianDecision(
507
511
  const resolved = resolveCanonicalGuardianRequest(requestId, "pending", {
508
512
  status: targetStatus,
509
513
  answerText: userText,
510
- decidedByExternalUserId: actorContext.externalUserId,
514
+ decidedByExternalUserId: actorContext.actorExternalUserId,
511
515
  decidedByPrincipalId: actorContext.guardianPrincipalId,
512
516
  });
513
517
 
@@ -574,7 +578,7 @@ export async function applyCanonicalGuardianDecision(
574
578
  request: resolved,
575
579
  actorChannel: actorContext.channel,
576
580
  guardianExternalUserId:
577
- actorContext.externalUserId ??
581
+ actorContext.actorExternalUserId ??
578
582
  resolved.guardianExternalUserId ??
579
583
  undefined,
580
584
  });
@@ -42,8 +42,10 @@ const log = getLogger("guardian-request-resolvers");
42
42
 
43
43
  /** Actor context for the entity making the decision. */
44
44
  export interface ActorContext {
45
- /** External user ID of the deciding actor (undefined for desktop actors). */
46
- externalUserId: string | undefined;
45
+ /** Auth-identity principal ID of the deciding actor (undefined for callback-only actors). */
46
+ actorPrincipalId: string | undefined;
47
+ /** Channel-native external user ID (Telegram user ID, E.164 phone, etc.) of the deciding actor (undefined for desktop actors). Maps to `decided_by_external_user_id` DB column. */
48
+ actorExternalUserId: string | undefined;
47
49
  /** Channel the decision arrived on. */
48
50
  channel: string;
49
51
  /** Principal ID for authorization — must match the request's guardianPrincipalId. */
@@ -349,7 +351,7 @@ const accessRequestResolver: GuardianRequestResolver = {
349
351
  request.requesterChatId ?? request.requesterExternalUserId ?? "";
350
352
  const requesterLabel =
351
353
  requesterExternalUserId || requesterChatId || "the requester";
352
- const decidedByExternalUserId = ctx.actor.externalUserId ?? "";
354
+ const decidedByExternalUserId = ctx.actor.actorExternalUserId ?? "";
353
355
  const assistantId = DAEMON_INTERNAL_ASSISTANT_ID;
354
356
  const desktopDeliverUrl = resolveDeliverCallbackUrlForChannel(channel);
355
357
  const desktopBearerToken = mintDaemonDeliveryToken();