@vellumai/assistant 0.4.35 → 0.4.37

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 (239) hide show
  1. package/AGENTS.md +1 -1
  2. package/ARCHITECTURE.md +44 -49
  3. package/README.md +32 -20
  4. package/docs/architecture/keychain-broker.md +186 -0
  5. package/docs/architecture/security.md +110 -116
  6. package/docs/runbook-trusted-contacts.md +2 -2
  7. package/docs/skills.md +25 -25
  8. package/package.json +5 -2
  9. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +11 -2
  10. package/src/__tests__/actor-token-service.test.ts +1 -0
  11. package/src/__tests__/amazon-cdp-integration.test.ts +74 -0
  12. package/src/__tests__/assistant-feature-flags-integration.test.ts +38 -9
  13. package/src/__tests__/assistant-id-boundary-guard.test.ts +29 -0
  14. package/src/__tests__/browser-fill-credential.test.ts +1 -1
  15. package/src/__tests__/bundle-scanner.test.ts +1 -1
  16. package/src/__tests__/channel-guardian.test.ts +102 -102
  17. package/src/__tests__/channel-invite-transport.test.ts +155 -256
  18. package/src/__tests__/channel-readiness-routes.test.ts +336 -0
  19. package/src/__tests__/checker.test.ts +6 -6
  20. package/src/__tests__/chrome-cdp.test.ts +350 -0
  21. package/src/__tests__/computer-use-session-lifecycle.test.ts +3 -3
  22. package/src/__tests__/computer-use-session-working-dir.test.ts +86 -52
  23. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +1 -1
  24. package/src/__tests__/config-loader-migration.test.ts +85 -0
  25. package/src/__tests__/conversation-pairing.test.ts +370 -5
  26. package/src/__tests__/credential-broker-browser-fill.test.ts +1 -10
  27. package/src/__tests__/credential-broker-server-use.test.ts +1 -10
  28. package/src/__tests__/credential-security-e2e.test.ts +7 -1
  29. package/src/__tests__/credential-security-invariants.test.ts +14 -20
  30. package/src/__tests__/credential-vault-unit.test.ts +1 -11
  31. package/src/__tests__/credential-vault.test.ts +5 -19
  32. package/src/__tests__/credentials-cli.test.ts +814 -0
  33. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +23 -4
  34. package/src/__tests__/email-invite-adapter.test.ts +78 -0
  35. package/src/__tests__/email-service-config-fallback.test.ts +102 -0
  36. package/src/__tests__/encrypted-store.test.ts +6 -6
  37. package/src/__tests__/ephemeral-permissions.test.ts +3 -3
  38. package/src/__tests__/gateway-only-enforcement.test.ts +5 -1
  39. package/src/__tests__/guardian-actions-endpoint.test.ts +70 -12
  40. package/src/__tests__/guardian-outbound-http.test.ts +53 -47
  41. package/src/__tests__/handle-user-message-secret-resume.test.ts +23 -0
  42. package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +32 -23
  43. package/src/__tests__/handlers-telegram-config.test.ts +8 -2
  44. package/src/__tests__/handlers-twitter-config.test.ts +2 -2
  45. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +108 -7
  46. package/src/__tests__/ingress-reconcile.test.ts +6 -0
  47. package/src/__tests__/intent-routing.test.ts +23 -4
  48. package/src/__tests__/invite-routes-http.test.ts +12 -0
  49. package/src/__tests__/ipc-snapshot.test.ts +8 -2
  50. package/src/__tests__/keychain-broker-client.test.ts +543 -0
  51. package/src/__tests__/llm-usage-store.test.ts +344 -0
  52. package/src/__tests__/mcp-client-auth.test.ts +2 -2
  53. package/src/__tests__/media-reuse-story.e2e.test.ts +1 -1
  54. package/src/__tests__/migration-transport.test.ts +49 -0
  55. package/src/__tests__/notification-broadcaster.test.ts +205 -5
  56. package/src/__tests__/notification-deep-link.test.ts +365 -1
  57. package/src/__tests__/oauth-connect-handler.test.ts +2 -2
  58. package/src/__tests__/onboarding-starter-tasks.test.ts +17 -4
  59. package/src/__tests__/proxy-approval-callback.test.ts +1 -1
  60. package/src/__tests__/recording-handler.test.ts +1 -1
  61. package/src/__tests__/recording-intent-handler.test.ts +6 -1
  62. package/src/__tests__/recording-state-machine.test.ts +1 -1
  63. package/src/__tests__/relay-server.test.ts +9 -1
  64. package/src/__tests__/ride-shotgun-handler.test.ts +499 -0
  65. package/src/__tests__/runtime-attachment-metadata.test.ts +160 -1
  66. package/src/__tests__/script-proxy-injection-runtime.test.ts +299 -2
  67. package/src/__tests__/script-proxy-profile-template-fallback.test.ts +1 -1
  68. package/src/__tests__/secret-onetime-send.test.ts +8 -2
  69. package/src/__tests__/secure-keys.test.ts +175 -216
  70. package/src/__tests__/session-confirmation-signals.test.ts +1 -1
  71. package/src/__tests__/session-messaging-secret-redirect.test.ts +1 -1
  72. package/src/__tests__/session-queue.test.ts +2 -1
  73. package/src/__tests__/session-tool-setup-app-refresh.test.ts +2 -2
  74. package/src/__tests__/skill-feature-flags-integration.test.ts +29 -4
  75. package/src/__tests__/skill-feature-flags.test.ts +12 -9
  76. package/src/__tests__/skill-load-feature-flag.test.ts +26 -5
  77. package/src/__tests__/skill-projection.benchmark.test.ts +0 -1
  78. package/src/__tests__/skills.test.ts +34 -4
  79. package/src/__tests__/slack-channel-config.test.ts +2 -2
  80. package/src/__tests__/system-prompt.test.ts +26 -4
  81. package/src/__tests__/telegram-bot-username-resolution.test.ts +212 -0
  82. package/src/__tests__/telegram-invite-adapter.test.ts +164 -0
  83. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +1 -1
  84. package/src/__tests__/tool-permission-simulate-handler.test.ts +8 -2
  85. package/src/__tests__/trusted-contact-approval-notifier.test.ts +9 -1
  86. package/src/__tests__/twitter-auth-handler.test.ts +2 -2
  87. package/src/__tests__/twitter-oauth-client.test.ts +1 -1
  88. package/src/__tests__/usage-routes.test.ts +339 -0
  89. package/src/__tests__/whatsapp-invite-adapter.test.ts +94 -0
  90. package/src/agent/loop.ts +3 -0
  91. package/src/amazon/checkout.ts +0 -1
  92. package/src/approvals/guardian-request-resolvers.ts +9 -1
  93. package/src/bundler/app-bundler.ts +28 -12
  94. package/src/bundler/bundle-scanner.ts +1 -1
  95. package/src/bundler/bundle-signer.ts +3 -3
  96. package/src/bundler/manifest.ts +1 -1
  97. package/src/bundler/signature-verifier.ts +3 -3
  98. package/src/channels/config.ts +1 -1
  99. package/src/cli/AGENTS.md +63 -0
  100. package/src/cli/__tests__/notifications.test.ts +470 -0
  101. package/src/cli/amazon.ts +344 -167
  102. package/src/cli/audit.ts +85 -0
  103. package/src/cli/autonomy.ts +369 -0
  104. package/src/cli/channels.ts +51 -0
  105. package/src/cli/completions.ts +208 -0
  106. package/src/cli/config.ts +220 -0
  107. package/src/cli/contacts.ts +471 -0
  108. package/src/cli/credentials.ts +564 -0
  109. package/src/cli/default-action.ts +14 -0
  110. package/src/cli/dev.ts +131 -0
  111. package/src/cli/doctor.ts +398 -0
  112. package/src/cli/email.ts +494 -0
  113. package/src/cli/influencer.ts +72 -0
  114. package/src/cli/integrations.ts +248 -57
  115. package/src/cli/keys.ts +114 -0
  116. package/src/cli/map.ts +46 -54
  117. package/src/cli/mcp.ts +111 -3
  118. package/src/cli/{config-commands.ts → memory.ts} +134 -245
  119. package/src/cli/notifications.ts +407 -0
  120. package/src/cli/program.ts +65 -0
  121. package/src/cli/reference.ts +48 -0
  122. package/src/cli/sequence.ts +154 -0
  123. package/src/cli/sessions.ts +262 -0
  124. package/src/cli/trust.ts +175 -0
  125. package/src/cli/twitter.ts +323 -106
  126. package/src/config/__tests__/build-cli-reference-section.test.ts +49 -0
  127. package/src/config/bundled-skills/amazon/SKILL.md +2 -2
  128. package/src/config/bundled-skills/app-builder/TOOLS.json +26 -0
  129. package/src/config/bundled-skills/app-builder/tools/app-generate-icon.ts +13 -0
  130. package/src/config/bundled-skills/contacts/SKILL.md +178 -10
  131. package/src/config/bundled-skills/doordash/doordash-cli.ts +23 -168
  132. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +135 -34
  133. package/src/config/bundled-skills/messaging/tools/shared.ts +4 -1
  134. package/src/config/bundled-skills/twilio-setup/SKILL.md +70 -17
  135. package/src/config/bundled-tool-registry.ts +2 -0
  136. package/src/config/core-schema.ts +7 -0
  137. package/src/config/feature-flag-registry.json +16 -0
  138. package/src/config/loader.ts +26 -0
  139. package/src/config/schema.ts +4 -0
  140. package/src/config/skill-state.ts +0 -13
  141. package/src/config/system-prompt.ts +27 -0
  142. package/src/contacts/contact-store.ts +25 -0
  143. package/src/daemon/computer-use-session.ts +1 -1
  144. package/src/daemon/handlers/apps.ts +1 -0
  145. package/src/daemon/handlers/config-channels.ts +3 -3
  146. package/src/daemon/handlers/config-dispatch.ts +29 -0
  147. package/src/daemon/handlers/config-inbox.ts +4 -3
  148. package/src/daemon/handlers/config.ts +3 -43
  149. package/src/daemon/handlers/contacts.ts +34 -0
  150. package/src/daemon/handlers/index.ts +17 -3
  151. package/src/daemon/handlers/session-user-message.ts +7 -0
  152. package/src/daemon/handlers/sessions.ts +21 -2
  153. package/src/daemon/handlers/shared.ts +17 -0
  154. package/src/daemon/ipc-contract/apps.ts +2 -0
  155. package/src/daemon/ipc-contract/computer-use.ts +9 -0
  156. package/src/daemon/ipc-contract/contacts.ts +3 -3
  157. package/src/daemon/ipc-contract/inbox.ts +2 -0
  158. package/src/daemon/ipc-contract/messages.ts +4 -0
  159. package/src/daemon/ipc-contract/sessions.ts +8 -0
  160. package/src/daemon/ipc-contract-inventory.json +1 -0
  161. package/src/daemon/lifecycle.ts +0 -5
  162. package/src/daemon/ride-shotgun-handler.ts +139 -25
  163. package/src/daemon/session-agent-loop-handlers.ts +100 -0
  164. package/src/daemon/session-agent-loop.ts +72 -0
  165. package/src/daemon/session-tool-setup.ts +7 -0
  166. package/src/daemon/session.ts +23 -1
  167. package/src/daemon/tool-side-effects.ts +39 -1
  168. package/src/email/service.ts +59 -2
  169. package/src/index.ts +2 -60
  170. package/src/mcp/mcp-oauth-provider.ts +90 -8
  171. package/src/media/app-icon-generator.ts +86 -0
  172. package/src/memory/db-init.ts +11 -0
  173. package/src/memory/llm-usage-store.ts +186 -0
  174. package/src/memory/migrations/137-usage-dashboard-indexes.ts +26 -0
  175. package/src/memory/migrations/139-drop-usage-composite-indexes.ts +30 -0
  176. package/src/memory/migrations/index.ts +2 -0
  177. package/src/memory/schema-migration.ts +1 -0
  178. package/src/memory/shared-app-links-store.ts +1 -1
  179. package/src/messaging/registry.ts +27 -0
  180. package/src/notifications/README.md +79 -70
  181. package/src/notifications/broadcaster.ts +2 -1
  182. package/src/notifications/conversation-pairing.ts +147 -13
  183. package/src/notifications/copy-composer.ts +7 -3
  184. package/src/notifications/destination-resolver.ts +14 -1
  185. package/src/notifications/emit-signal.ts +3 -2
  186. package/src/notifications/signal.ts +105 -1
  187. package/src/notifications/types.ts +16 -0
  188. package/src/permissions/checker.ts +29 -3
  189. package/src/permissions/prompter.ts +11 -3
  190. package/src/runtime/access-request-helper.ts +2 -1
  191. package/src/runtime/auth/route-policy.ts +7 -1
  192. package/src/runtime/channel-invite-transport.ts +40 -63
  193. package/src/runtime/channel-invite-transports/email.ts +13 -39
  194. package/src/runtime/channel-invite-transports/slack.ts +5 -34
  195. package/src/runtime/channel-invite-transports/sms.ts +8 -29
  196. package/src/runtime/channel-invite-transports/telegram.ts +69 -28
  197. package/src/runtime/channel-invite-transports/voice.ts +0 -7
  198. package/src/runtime/channel-invite-transports/whatsapp.ts +43 -0
  199. package/src/runtime/channel-readiness-service.ts +202 -45
  200. package/src/runtime/confirmation-request-guardian-bridge.ts +2 -1
  201. package/src/runtime/guardian-outbound-actions.ts +8 -5
  202. package/src/runtime/http-server.ts +2 -0
  203. package/src/runtime/invite-instruction-generator.ts +178 -0
  204. package/src/runtime/invite-service.ts +22 -25
  205. package/src/runtime/migrations/migration-transport.ts +13 -0
  206. package/src/runtime/routes/app-routes.ts +1 -1
  207. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +8 -7
  208. package/src/runtime/routes/channel-readiness-routes.ts +30 -11
  209. package/src/runtime/routes/contact-routes.ts +54 -26
  210. package/src/runtime/routes/inbound-stages/bootstrap-intercept.ts +1 -1
  211. package/src/runtime/routes/inbound-stages/escalation-intercept.ts +2 -1
  212. package/src/runtime/routes/inbound-stages/verification-intercept.ts +2 -1
  213. package/src/runtime/routes/integration-routes.ts +1 -1
  214. package/src/runtime/routes/invite-routes.ts +1 -1
  215. package/src/runtime/routes/secret-routes.ts +31 -7
  216. package/src/runtime/routes/twilio-routes.ts +32 -1
  217. package/src/runtime/routes/usage-routes.ts +114 -0
  218. package/src/runtime/tool-grant-request-helper.ts +2 -1
  219. package/src/security/encrypted-store.ts +9 -5
  220. package/src/security/keychain-broker-client.ts +393 -0
  221. package/src/security/secure-keys.ts +106 -321
  222. package/src/tools/apps/executors.ts +73 -0
  223. package/src/tools/browser/auto-navigate.ts +15 -6
  224. package/src/tools/browser/chrome-cdp.ts +211 -0
  225. package/src/tools/browser/network-recorder.test.ts +83 -0
  226. package/src/tools/browser/network-recorder.ts +8 -7
  227. package/src/tools/browser/x-auto-navigate.ts +12 -6
  228. package/src/tools/credentials/policy-types.ts +24 -0
  229. package/src/tools/credentials/vault.ts +22 -27
  230. package/src/tools/network/script-proxy/session-manager.ts +47 -3
  231. package/src/tools/permission-checker.ts +1 -0
  232. package/src/tools/types.ts +2 -0
  233. package/src/tools/ui-surface/definitions.ts +1 -2
  234. package/src/tools/watch/watch-state.ts +2 -0
  235. package/src/__tests__/key-migration.test.ts +0 -240
  236. package/src/__tests__/keychain.test.ts +0 -286
  237. package/src/cli/core-commands.ts +0 -899
  238. package/src/security/keychain-to-encrypted-migration.ts +0 -66
  239. package/src/security/keychain.ts +0 -490
@@ -0,0 +1,499 @@
1
+ import type * as net from "node:net";
2
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
3
+
4
+ import type { HandlerContext } from "../daemon/handlers.js";
5
+
6
+ // ── Mocks ──────────────────────────────────────────────────────────
7
+
8
+ let mockEnsureChromeResult = {
9
+ baseUrl: "http://localhost:9222",
10
+ launchedByUs: false,
11
+ userDataDir: "/tmp/cdp-test",
12
+ };
13
+ let mockEnsureChromeShouldThrow = false;
14
+ let mockMinimizeCalled = false;
15
+ let mockMinimizeBaseUrl: string | undefined;
16
+
17
+ mock.module("../tools/browser/chrome-cdp.js", () => ({
18
+ ensureChromeWithCdp: async () => {
19
+ if (mockEnsureChromeShouldThrow) {
20
+ throw new Error("Chrome launch failed");
21
+ }
22
+ return { ...mockEnsureChromeResult };
23
+ },
24
+ minimizeChromeWindow: async (baseUrl: string) => {
25
+ mockMinimizeCalled = true;
26
+ mockMinimizeBaseUrl = baseUrl;
27
+ },
28
+ isCdpReady: async () => true,
29
+ restoreChromeWindow: async () => {},
30
+ }));
31
+
32
+ let mockRecorderStartCalls = 0;
33
+ let mockRecorderStartShouldThrow = false;
34
+ let mockRecorderStartThrowCount = 0;
35
+ let mockRecorderConstructorCdpBaseUrl: string | undefined;
36
+
37
+ mock.module("../tools/browser/network-recorder.js", () => ({
38
+ NetworkRecorder: class MockNetworkRecorder {
39
+ loginSignals: string[] = [];
40
+ onLoginDetected?: () => void;
41
+ get entryCount() {
42
+ return 0;
43
+ }
44
+
45
+ constructor(_targetDomain?: string, cdpBaseUrl?: string) {
46
+ mockRecorderConstructorCdpBaseUrl = cdpBaseUrl;
47
+ }
48
+
49
+ async startDirect() {
50
+ mockRecorderStartCalls++;
51
+ if (
52
+ mockRecorderStartShouldThrow &&
53
+ mockRecorderStartCalls <= mockRecorderStartThrowCount
54
+ ) {
55
+ throw new Error("CDP not ready");
56
+ }
57
+ }
58
+
59
+ async stop() {
60
+ return [];
61
+ }
62
+
63
+ async extractCookies() {
64
+ return [];
65
+ }
66
+ },
67
+ }));
68
+
69
+ mock.module("../tools/browser/recording-store.js", () => ({
70
+ saveRecording: () => "/tmp/test-recording.json",
71
+ }));
72
+
73
+ mock.module("../tools/browser/auto-navigate.js", () => ({
74
+ autoNavigate: async () => [],
75
+ }));
76
+
77
+ mock.module("../tools/browser/x-auto-navigate.js", () => ({
78
+ navigateXPages: async () => [],
79
+ }));
80
+
81
+ mock.module("../util/logger.js", () => ({
82
+ getLogger: () => ({
83
+ info: () => {},
84
+ debug: () => {},
85
+ warn: () => {},
86
+ error: () => {},
87
+ }),
88
+ }));
89
+
90
+ const mockLastSummaryBySession = new Map<string, string>();
91
+ mock.module("../daemon/watch-handler.js", () => ({
92
+ generateSummary: async () => {},
93
+ lastSummaryBySession: mockLastSummaryBySession,
94
+ }));
95
+
96
+ // ── Import under test (after mocks) ───────────────────────────────
97
+
98
+ const { handleRideShotgunStart, handleRideShotgunStop } =
99
+ await import("../daemon/ride-shotgun-handler.js");
100
+ const { watchSessions } = await import("../tools/watch/watch-state.js");
101
+
102
+ // ── Helpers ────────────────────────────────────────────────────────
103
+
104
+ function makeMockSocket(): net.Socket {
105
+ return { destroyed: false } as unknown as net.Socket;
106
+ }
107
+
108
+ function makeMockCtx() {
109
+ const sent: unknown[] = [];
110
+ return {
111
+ send: (_socket: net.Socket, msg: unknown) => sent.push(msg),
112
+ sent,
113
+ } as unknown as HandlerContext & { sent: unknown[] };
114
+ }
115
+
116
+ function waitForRecorderStart(timeoutMs = 3000): Promise<void> {
117
+ return new Promise((resolve, reject) => {
118
+ const start = Date.now();
119
+ const poll = setInterval(() => {
120
+ if (mockRecorderStartCalls > 0) {
121
+ clearInterval(poll);
122
+ resolve();
123
+ } else if (Date.now() - start > timeoutMs) {
124
+ clearInterval(poll);
125
+ reject(new Error("Timed out waiting for recorder start"));
126
+ }
127
+ }, 50);
128
+ });
129
+ }
130
+
131
+ // ── Tests ──────────────────────────────────────────────────────────
132
+
133
+ describe("ride-shotgun-handler", () => {
134
+ beforeEach(() => {
135
+ mockRecorderStartCalls = 0;
136
+ mockRecorderStartShouldThrow = false;
137
+ mockRecorderStartThrowCount = 0;
138
+ mockRecorderConstructorCdpBaseUrl = undefined;
139
+ mockMinimizeCalled = false;
140
+ mockMinimizeBaseUrl = undefined;
141
+ mockEnsureChromeShouldThrow = false;
142
+ mockEnsureChromeResult = {
143
+ baseUrl: "http://localhost:9222",
144
+ launchedByUs: false,
145
+ userDataDir: "/tmp/cdp-test",
146
+ };
147
+ mockLastSummaryBySession.clear();
148
+ watchSessions.clear();
149
+ });
150
+
151
+ afterEach(() => {
152
+ // Clean up any dangling sessions
153
+ for (const [, session] of watchSessions) {
154
+ if (session.timeoutHandle) clearTimeout(session.timeoutHandle);
155
+ }
156
+ watchSessions.clear();
157
+ });
158
+
159
+ test("learn mode calls ensureChromeWithCdp before starting recorder", async () => {
160
+ const socket = makeMockSocket();
161
+ const ctx = makeMockCtx();
162
+
163
+ await handleRideShotgunStart(
164
+ {
165
+ type: "ride_shotgun_start",
166
+ durationSeconds: 60,
167
+ intervalSeconds: 5,
168
+ mode: "learn",
169
+ targetDomain: "example.com",
170
+ autoNavigate: false,
171
+ },
172
+ socket,
173
+ ctx,
174
+ );
175
+
176
+ // Background recording start — wait for it
177
+ await waitForRecorderStart();
178
+
179
+ expect(mockRecorderStartCalls).toBe(1);
180
+ // The recorder should receive the CDP base URL from the session
181
+ expect(mockRecorderConstructorCdpBaseUrl).toBe("http://localhost:9222");
182
+ });
183
+
184
+ test("learn mode passes CDP base URL to NetworkRecorder constructor", async () => {
185
+ mockEnsureChromeResult = {
186
+ baseUrl: "http://localhost:9333",
187
+ launchedByUs: true,
188
+ userDataDir: "/tmp/cdp-custom",
189
+ };
190
+
191
+ const socket = makeMockSocket();
192
+ const ctx = makeMockCtx();
193
+
194
+ await handleRideShotgunStart(
195
+ {
196
+ type: "ride_shotgun_start",
197
+ durationSeconds: 60,
198
+ intervalSeconds: 5,
199
+ mode: "learn",
200
+ targetDomain: "example.com",
201
+ autoNavigate: false,
202
+ },
203
+ socket,
204
+ ctx,
205
+ );
206
+
207
+ await waitForRecorderStart();
208
+
209
+ expect(mockRecorderConstructorCdpBaseUrl).toBe("http://localhost:9333");
210
+ });
211
+
212
+ test("learn mode does not start recorder when ensureChromeWithCdp fails", async () => {
213
+ mockEnsureChromeShouldThrow = true;
214
+
215
+ const socket = makeMockSocket();
216
+ const ctx = makeMockCtx();
217
+
218
+ await handleRideShotgunStart(
219
+ {
220
+ type: "ride_shotgun_start",
221
+ durationSeconds: 60,
222
+ intervalSeconds: 5,
223
+ mode: "learn",
224
+ targetDomain: "example.com",
225
+ autoNavigate: false,
226
+ },
227
+ socket,
228
+ ctx,
229
+ );
230
+
231
+ // Give background task time to execute
232
+ await new Promise((r) => setTimeout(r, 200));
233
+
234
+ expect(mockRecorderStartCalls).toBe(0);
235
+ });
236
+
237
+ test("learn mode minimizes Chrome on completion when assistant launched it", async () => {
238
+ mockEnsureChromeResult = {
239
+ baseUrl: "http://localhost:9222",
240
+ launchedByUs: true,
241
+ userDataDir: "/tmp/cdp-test",
242
+ };
243
+
244
+ const socket = makeMockSocket();
245
+ const ctx = makeMockCtx();
246
+
247
+ await handleRideShotgunStart(
248
+ {
249
+ type: "ride_shotgun_start",
250
+ durationSeconds: 60,
251
+ intervalSeconds: 5,
252
+ mode: "learn",
253
+ targetDomain: "example.com",
254
+ autoNavigate: false,
255
+ },
256
+ socket,
257
+ ctx,
258
+ );
259
+
260
+ await waitForRecorderStart();
261
+
262
+ // Find the session and stop it
263
+ const watchId = [...watchSessions.keys()][0]!;
264
+ await handleRideShotgunStop(
265
+ { type: "ride_shotgun_stop", watchId },
266
+ socket,
267
+ ctx,
268
+ );
269
+
270
+ expect(mockMinimizeCalled).toBe(true);
271
+ expect(mockMinimizeBaseUrl).toBe("http://localhost:9222");
272
+ });
273
+
274
+ test("learn mode does not minimize Chrome on completion when user launched it", async () => {
275
+ mockEnsureChromeResult = {
276
+ baseUrl: "http://localhost:9222",
277
+ launchedByUs: false,
278
+ userDataDir: "/tmp/cdp-test",
279
+ };
280
+
281
+ const socket = makeMockSocket();
282
+ const ctx = makeMockCtx();
283
+
284
+ await handleRideShotgunStart(
285
+ {
286
+ type: "ride_shotgun_start",
287
+ durationSeconds: 60,
288
+ intervalSeconds: 5,
289
+ mode: "learn",
290
+ targetDomain: "example.com",
291
+ autoNavigate: false,
292
+ },
293
+ socket,
294
+ ctx,
295
+ );
296
+
297
+ await waitForRecorderStart();
298
+
299
+ // Find the session and stop it
300
+ const watchId = [...watchSessions.keys()][0]!;
301
+ await handleRideShotgunStop(
302
+ { type: "ride_shotgun_stop", watchId },
303
+ socket,
304
+ ctx,
305
+ );
306
+
307
+ expect(mockMinimizeCalled).toBe(false);
308
+ });
309
+
310
+ test("observe mode does not call ensureChromeWithCdp", async () => {
311
+ const socket = makeMockSocket();
312
+ const ctx = makeMockCtx();
313
+
314
+ await handleRideShotgunStart(
315
+ {
316
+ type: "ride_shotgun_start",
317
+ durationSeconds: 60,
318
+ intervalSeconds: 5,
319
+ mode: "observe",
320
+ },
321
+ socket,
322
+ ctx,
323
+ );
324
+
325
+ // Give time for any background tasks
326
+ await new Promise((r) => setTimeout(r, 200));
327
+
328
+ // In observe mode, no recorder should be started
329
+ expect(mockRecorderStartCalls).toBe(0);
330
+
331
+ // Clean up
332
+ const watchId = [...watchSessions.keys()][0]!;
333
+ await handleRideShotgunStop(
334
+ { type: "ride_shotgun_stop", watchId },
335
+ socket,
336
+ ctx,
337
+ );
338
+ });
339
+
340
+ test("sends watch_started message with session IDs", async () => {
341
+ const socket = makeMockSocket();
342
+ const ctx = makeMockCtx();
343
+
344
+ await handleRideShotgunStart(
345
+ {
346
+ type: "ride_shotgun_start",
347
+ durationSeconds: 30,
348
+ intervalSeconds: 5,
349
+ mode: "learn",
350
+ targetDomain: "example.com",
351
+ autoNavigate: false,
352
+ },
353
+ socket,
354
+ ctx,
355
+ );
356
+
357
+ const startMsg = ctx.sent.find(
358
+ (m: any) => m.type === "watch_started",
359
+ ) as any;
360
+ expect(startMsg).toBeDefined();
361
+ expect(startMsg.sessionId).toBeDefined();
362
+ expect(startMsg.watchId).toBeDefined();
363
+ expect(startMsg.durationSeconds).toBe(30);
364
+
365
+ // Clean up
366
+ const watchId = startMsg.watchId;
367
+ await handleRideShotgunStop(
368
+ { type: "ride_shotgun_stop", watchId },
369
+ socket,
370
+ ctx,
371
+ );
372
+ });
373
+
374
+ test("sends ride_shotgun_error when ensureChromeWithCdp fails", async () => {
375
+ mockEnsureChromeShouldThrow = true;
376
+
377
+ const socket = makeMockSocket();
378
+ const ctx = makeMockCtx();
379
+
380
+ await handleRideShotgunStart(
381
+ {
382
+ type: "ride_shotgun_start",
383
+ durationSeconds: 60,
384
+ intervalSeconds: 5,
385
+ mode: "learn",
386
+ targetDomain: "example.com",
387
+ autoNavigate: false,
388
+ },
389
+ socket,
390
+ ctx,
391
+ );
392
+
393
+ // Give background task time to execute and complete session
394
+ await new Promise((r) => setTimeout(r, 500));
395
+
396
+ const errorMsg = ctx.sent.find(
397
+ (m: any) => m.type === "ride_shotgun_error",
398
+ ) as any;
399
+ expect(errorMsg).toBeDefined();
400
+ expect(errorMsg.watchId).toBeDefined();
401
+ expect(errorMsg.sessionId).toBeDefined();
402
+ expect(errorMsg.message).toContain("Chrome CDP");
403
+ });
404
+
405
+ test("cleans up session when ensureChromeWithCdp fails", async () => {
406
+ mockEnsureChromeShouldThrow = true;
407
+
408
+ const socket = makeMockSocket();
409
+ const ctx = makeMockCtx();
410
+
411
+ await handleRideShotgunStart(
412
+ {
413
+ type: "ride_shotgun_start",
414
+ durationSeconds: 60,
415
+ intervalSeconds: 5,
416
+ mode: "learn",
417
+ targetDomain: "example.com",
418
+ autoNavigate: false,
419
+ },
420
+ socket,
421
+ ctx,
422
+ );
423
+
424
+ // Give background task time to execute
425
+ await new Promise((r) => setTimeout(r, 500));
426
+
427
+ // Session should be completed (not left hanging for the full duration)
428
+ const session = [...watchSessions.values()][0];
429
+ expect(session?.status).toBe("completed");
430
+ });
431
+
432
+ test("reports failure summary when no recorder ever started", async () => {
433
+ mockEnsureChromeShouldThrow = true;
434
+
435
+ const socket = makeMockSocket();
436
+ const ctx = makeMockCtx();
437
+
438
+ await handleRideShotgunStart(
439
+ {
440
+ type: "ride_shotgun_start",
441
+ durationSeconds: 60,
442
+ intervalSeconds: 5,
443
+ mode: "learn",
444
+ targetDomain: "example.com",
445
+ autoNavigate: false,
446
+ },
447
+ socket,
448
+ ctx,
449
+ );
450
+
451
+ // Give background task time to execute
452
+ await new Promise((r) => setTimeout(r, 500));
453
+
454
+ // The result message should indicate the specific CDP failure
455
+ const resultMsg = ctx.sent.find(
456
+ (m: any) => m.type === "ride_shotgun_result",
457
+ ) as any;
458
+ expect(resultMsg).toBeDefined();
459
+ expect(resultMsg.summary).toContain("failed");
460
+ expect(resultMsg.summary).toContain("browser could not be started");
461
+ expect(resultMsg.summary).not.toContain("recording saved");
462
+ });
463
+
464
+ test("sends ride_shotgun_error when all 10 recorder retries fail", async () => {
465
+ mockRecorderStartShouldThrow = true;
466
+ mockRecorderStartThrowCount = 10;
467
+
468
+ const socket = makeMockSocket();
469
+ const ctx = makeMockCtx();
470
+
471
+ await handleRideShotgunStart(
472
+ {
473
+ type: "ride_shotgun_start",
474
+ durationSeconds: 60,
475
+ intervalSeconds: 5,
476
+ mode: "learn",
477
+ targetDomain: "example.com",
478
+ autoNavigate: false,
479
+ },
480
+ socket,
481
+ ctx,
482
+ );
483
+
484
+ // Wait for all 10 retry attempts (each has a 2s delay except the last)
485
+ // 9 retries * 2s = 18s, but mock doesn't actually wait — it should complete quickly
486
+ // The mock delays are real setTimeout calls, so we need enough time
487
+ await new Promise((r) => setTimeout(r, 25000));
488
+
489
+ const errorMsg = ctx.sent.find(
490
+ (m: any) => m.type === "ride_shotgun_error",
491
+ ) as any;
492
+ expect(errorMsg).toBeDefined();
493
+ expect(errorMsg.message).toContain("10 attempts");
494
+
495
+ // Session should be completed
496
+ const session = [...watchSessions.values()][0];
497
+ expect(session?.status).toBe("completed");
498
+ }, 30000); // Extended timeout for retry delays
499
+ });
@@ -49,14 +49,21 @@ mock.module("../config/loader.js", () => ({
49
49
  }),
50
50
  }));
51
51
 
52
+ mock.module("../security/secret-ingress.js", () => ({
53
+ checkIngressForSecrets: () => ({ blocked: false }),
54
+ }));
55
+
56
+ import { upsertContact } from "../contacts/contact-store.js";
52
57
  import {
53
58
  linkAttachmentToMessage,
54
59
  uploadAttachment,
55
60
  } from "../memory/attachments-store.js";
61
+ import * as channelDeliveryStore from "../memory/channel-delivery-store.js";
56
62
  import { getOrCreateConversation } from "../memory/conversation-key-store.js";
57
63
  import * as conversationStore from "../memory/conversation-store.js";
58
- import { getDb, initializeDb, resetDb } from "../memory/db.js";
64
+ import { getDb, initializeDb, resetDb, resetTestTables } from "../memory/db.js";
59
65
  import { RuntimeHttpServer } from "../runtime/http-server.js";
66
+ import * as pendingInteractions from "../runtime/pending-interactions.js";
60
67
 
61
68
  initializeDb();
62
69
 
@@ -227,3 +234,155 @@ describe("Runtime attachment metadata", () => {
227
234
  expect(body.error.message).toBe("Attachment not found");
228
235
  });
229
236
  });
237
+
238
+ describe("WhatsApp channel ingress attachment resolution", () => {
239
+ const WHATSAPP_USER_ID = "whatsapp-user-123";
240
+ let ingressServer: RuntimeHttpServer;
241
+ let ingressPort: number;
242
+
243
+ function resetIngressTables(): void {
244
+ resetTestTables(
245
+ "message_attachments",
246
+ "attachments",
247
+ "channel_inbound_events",
248
+ "message_runs",
249
+ "messages",
250
+ "conversations",
251
+ "conversation_keys",
252
+ "contact_channels",
253
+ "contacts",
254
+ );
255
+ channelDeliveryStore.resetAllRunDeliveryClaims();
256
+ pendingInteractions.clear();
257
+ }
258
+
259
+ function ensureWhatsAppContact(): void {
260
+ upsertContact({
261
+ displayName: "WhatsApp Test User",
262
+ channels: [
263
+ {
264
+ type: "whatsapp",
265
+ address: WHATSAPP_USER_ID,
266
+ externalUserId: WHATSAPP_USER_ID,
267
+ status: "active",
268
+ policy: "allow",
269
+ },
270
+ ],
271
+ });
272
+ }
273
+
274
+ function makeInboundBody(
275
+ overrides: Record<string, unknown> = {},
276
+ ): Record<string, unknown> {
277
+ return {
278
+ sourceChannel: "whatsapp",
279
+ interface: "whatsapp",
280
+ conversationExternalId: "whatsapp-chat-1",
281
+ actorExternalId: WHATSAPP_USER_ID,
282
+ externalMessageId: `wa-msg-${Date.now()}-${Math.random()}`,
283
+ content: "Check these attachments",
284
+ replyCallbackUrl: "https://gateway.test/deliver",
285
+ ...overrides,
286
+ };
287
+ }
288
+
289
+ // Create a real message in the DB so the background dispatch's
290
+ // linkMessage(eventId, userMessageId) FK constraint is satisfied.
291
+ const noopProcessMessage = mock(
292
+ async (conversationId: string, content: string) => {
293
+ const msg = await conversationStore.addMessage(
294
+ conversationId,
295
+ "user",
296
+ content,
297
+ );
298
+ return { messageId: msg.id };
299
+ },
300
+ );
301
+
302
+ beforeEach(async () => {
303
+ resetIngressTables();
304
+ ensureWhatsAppContact();
305
+ noopProcessMessage.mockClear();
306
+
307
+ ingressPort = 18000 + Math.floor(Math.random() * 1000);
308
+ ingressServer = new RuntimeHttpServer({
309
+ port: ingressPort,
310
+ bearerToken: TEST_TOKEN,
311
+ processMessage: noopProcessMessage,
312
+ });
313
+ await ingressServer.start();
314
+ });
315
+
316
+ afterEach(async () => {
317
+ await ingressServer?.stop();
318
+ });
319
+
320
+ test("inbound handler accepts request with valid gateway-uploaded attachment IDs", async () => {
321
+ // Simulate what the gateway does: upload attachments then forward the
322
+ // inbound event with attachmentIds. The handler must resolve them.
323
+ const img = uploadAttachment(
324
+ "whatsapp-photo.jpg",
325
+ "image/jpeg",
326
+ "/9j/4AAQ",
327
+ );
328
+ const doc = uploadAttachment("receipt.pdf", "application/pdf", "JVBERi0x");
329
+
330
+ const res = await fetch(
331
+ `http://127.0.0.1:${ingressPort}/v1/channels/inbound`,
332
+ {
333
+ method: "POST",
334
+ headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
335
+ body: JSON.stringify(
336
+ makeInboundBody({ attachmentIds: [img.id, doc.id] }),
337
+ ),
338
+ },
339
+ );
340
+ const body = (await res.json()) as Record<string, unknown>;
341
+
342
+ expect(res.status).toBe(200);
343
+ expect(body.accepted).toBe(true);
344
+ });
345
+
346
+ test("inbound handler rejects request when some attachment IDs are missing", async () => {
347
+ // When the gateway fails to upload one attachment, the handler detects
348
+ // the missing ID and returns a 400.
349
+ const valid = uploadAttachment("ok.jpg", "image/jpeg", "base64ok");
350
+
351
+ const res = await fetch(
352
+ `http://127.0.0.1:${ingressPort}/v1/channels/inbound`,
353
+ {
354
+ method: "POST",
355
+ headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
356
+ body: JSON.stringify(
357
+ makeInboundBody({
358
+ attachmentIds: [valid.id, "nonexistent-whatsapp-att"],
359
+ }),
360
+ ),
361
+ },
362
+ );
363
+ const body = (await res.json()) as { error?: string };
364
+
365
+ expect(res.status).toBe(400);
366
+ expect(body.error).toContain("nonexistent-whatsapp-att");
367
+ });
368
+
369
+ test("inbound handler accepts attachment-only message with no text content", async () => {
370
+ // WhatsApp allows sending images/documents without caption text.
371
+ const img = uploadAttachment("photo.jpg", "image/jpeg", "/9j/4AAQ");
372
+
373
+ const res = await fetch(
374
+ `http://127.0.0.1:${ingressPort}/v1/channels/inbound`,
375
+ {
376
+ method: "POST",
377
+ headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
378
+ body: JSON.stringify(
379
+ makeInboundBody({ content: "", attachmentIds: [img.id] }),
380
+ ),
381
+ },
382
+ );
383
+ const body = (await res.json()) as Record<string, unknown>;
384
+
385
+ expect(res.status).toBe(200);
386
+ expect(body.accepted).toBe(true);
387
+ });
388
+ });