@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
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Validates that pairDeliveryWithConversation materializes conversations
5
5
  * and messages according to the channel's conversation strategy, handles
6
- * thread reuse decisions, and that errors in pairing never break the
7
- * notification pipeline.
6
+ * thread reuse decisions, binding-key reuse for continue_existing channels,
7
+ * and that errors in pairing never break the notification pipeline.
8
8
  */
9
9
 
10
10
  import { beforeEach, describe, expect, mock, test } from "bun:test";
@@ -70,9 +70,36 @@ mock.module("../memory/conversation-store.js", () => ({
70
70
  getConversation: getConversationMock,
71
71
  }));
72
72
 
73
+ /** Simulated bindings for external-conversation-store mock. */
74
+ let mockBindings: Record<
75
+ string,
76
+ { conversationId: string; sourceChannel: string; externalChatId: string }
77
+ > = {};
78
+
79
+ const getBindingByChannelChatMock = mock(
80
+ (sourceChannel: string, externalChatId: string) => {
81
+ const key = `${sourceChannel}:${externalChatId}`;
82
+ return mockBindings[key] ?? null;
83
+ },
84
+ );
85
+
86
+ const upsertOutboundBindingMock = mock(
87
+ (_input: {
88
+ conversationId: string;
89
+ sourceChannel: string;
90
+ externalChatId: string;
91
+ }) => {},
92
+ );
93
+
94
+ mock.module("../memory/external-conversation-store.js", () => ({
95
+ getBindingByChannelChat: getBindingByChannelChatMock,
96
+ upsertOutboundBinding: upsertOutboundBindingMock,
97
+ }));
98
+
73
99
  import { pairDeliveryWithConversation } from "../notifications/conversation-pairing.js";
74
100
  import type { NotificationSignal } from "../notifications/signal.js";
75
101
  import type {
102
+ DestinationBindingContext,
76
103
  NotificationChannel,
77
104
  RenderedChannelCopy,
78
105
  ThreadAction,
@@ -115,11 +142,14 @@ describe("pairDeliveryWithConversation", () => {
115
142
  createConversationMock.mockClear();
116
143
  addMessageMock.mockClear();
117
144
  getConversationMock.mockClear();
145
+ getBindingByChannelChatMock.mockClear();
146
+ upsertOutboundBindingMock.mockClear();
118
147
  mockConversationId = "conv-001";
119
148
  mockMessageId = "msg-001";
120
149
  createConversationShouldThrow = false;
121
150
  addMessageShouldThrow = false;
122
151
  mockExistingConversations = {};
152
+ mockBindings = {};
123
153
  });
124
154
 
125
155
  // ── start_new_conversation (vellum) ─────────────────────────────────
@@ -263,7 +293,7 @@ describe("pairDeliveryWithConversation", () => {
263
293
 
264
294
  // ── continue_existing_conversation (telegram) ─────────────────────
265
295
 
266
- test("creates a conversation for continue_existing_conversation strategy", async () => {
296
+ test("creates a conversation for continue_existing_conversation without binding context", async () => {
267
297
  const signal = makeSignal();
268
298
  const copy = makeCopy();
269
299
 
@@ -273,8 +303,6 @@ describe("pairDeliveryWithConversation", () => {
273
303
  copy,
274
304
  );
275
305
 
276
- // Currently creates a new conversation even for continue_existing_conversation
277
- // (true continuation is planned for a future PR)
278
306
  expect(result.conversationId).toBe("conv-001");
279
307
  expect(result.messageId).toBe("msg-001");
280
308
  expect(result.strategy).toBe("continue_existing_conversation");
@@ -287,6 +315,343 @@ describe("pairDeliveryWithConversation", () => {
287
315
  expect(callArgs.threadType).toBe("background");
288
316
  });
289
317
 
318
+ // ── Binding-key reuse (continue_existing + bindingContext) ────────
319
+
320
+ test("reuses bound conversation when binding context matches an existing notification conversation", async () => {
321
+ mockExistingConversations["conv-bound"] = {
322
+ id: "conv-bound",
323
+ source: "notification",
324
+ title: "Telegram Thread",
325
+ };
326
+ mockBindings["notification:telegram:chat-123"] = {
327
+ conversationId: "conv-bound",
328
+ sourceChannel: "notification:telegram",
329
+ externalChatId: "chat-123",
330
+ };
331
+
332
+ const signal = makeSignal();
333
+ const copy = makeCopy({
334
+ threadSeedMessage: "Second notification to same chat",
335
+ });
336
+ const bindingContext: DestinationBindingContext = {
337
+ sourceChannel: "telegram" as NotificationChannel,
338
+ externalChatId: "chat-123",
339
+ };
340
+
341
+ const result = await pairDeliveryWithConversation(
342
+ signal,
343
+ "telegram" as NotificationChannel,
344
+ copy,
345
+ { bindingContext },
346
+ );
347
+
348
+ expect(result.conversationId).toBe("conv-bound");
349
+ expect(result.messageId).toBe("msg-001");
350
+ expect(result.createdNewConversation).toBe(false);
351
+ expect(result.threadDecisionFallbackUsed).toBe(false);
352
+ expect(result.strategy).toBe("continue_existing_conversation");
353
+ // Should append to existing, not create new
354
+ expect(createConversationMock).not.toHaveBeenCalled();
355
+ expect(addMessageMock).toHaveBeenCalledTimes(1);
356
+ expect(addMessageMock.mock.calls[0]![0]).toBe("conv-bound");
357
+ // Should touch the outbound binding
358
+ expect(upsertOutboundBindingMock).toHaveBeenCalledTimes(1);
359
+ });
360
+
361
+ test("reuses pre-namespace binding when namespaced binding is absent", async () => {
362
+ // Simulate a binding created before the notification: prefix was introduced
363
+ mockExistingConversations["conv-legacy"] = {
364
+ id: "conv-legacy",
365
+ source: "notification",
366
+ title: "Legacy Telegram Thread",
367
+ };
368
+ mockBindings["telegram:chat-legacy"] = {
369
+ conversationId: "conv-legacy",
370
+ sourceChannel: "telegram",
371
+ externalChatId: "chat-legacy",
372
+ };
373
+
374
+ const signal = makeSignal();
375
+ const copy = makeCopy({
376
+ threadSeedMessage: "Delivery to legacy binding",
377
+ });
378
+ const bindingContext: DestinationBindingContext = {
379
+ sourceChannel: "telegram" as NotificationChannel,
380
+ externalChatId: "chat-legacy",
381
+ };
382
+
383
+ const result = await pairDeliveryWithConversation(
384
+ signal,
385
+ "telegram" as NotificationChannel,
386
+ copy,
387
+ { bindingContext },
388
+ );
389
+
390
+ expect(result.conversationId).toBe("conv-legacy");
391
+ expect(result.createdNewConversation).toBe(false);
392
+ expect(createConversationMock).not.toHaveBeenCalled();
393
+ // The upsert should write with the new namespaced sourceChannel
394
+ expect(upsertOutboundBindingMock).toHaveBeenCalledTimes(1);
395
+ const upsertArgs = upsertOutboundBindingMock.mock.calls[0]![0] as Record<
396
+ string,
397
+ unknown
398
+ >;
399
+ expect(upsertArgs.sourceChannel).toBe("notification:telegram");
400
+ });
401
+
402
+ test("falls back to new conversation when bound conversation is stale (wrong source)", async () => {
403
+ mockExistingConversations["conv-user-owned"] = {
404
+ id: "conv-user-owned",
405
+ source: "user",
406
+ title: "User Thread",
407
+ };
408
+ mockBindings["notification:sms:+15551234567"] = {
409
+ conversationId: "conv-user-owned",
410
+ sourceChannel: "notification:sms",
411
+ externalChatId: "+15551234567",
412
+ };
413
+
414
+ const signal = makeSignal();
415
+ const copy = makeCopy();
416
+ const bindingContext: DestinationBindingContext = {
417
+ sourceChannel: "sms" as NotificationChannel,
418
+ externalChatId: "+15551234567",
419
+ };
420
+
421
+ const result = await pairDeliveryWithConversation(
422
+ signal,
423
+ "sms" as NotificationChannel,
424
+ copy,
425
+ { bindingContext },
426
+ );
427
+
428
+ expect(result.conversationId).toBe("conv-001");
429
+ expect(result.createdNewConversation).toBe(true);
430
+ expect(result.threadDecisionFallbackUsed).toBe(false);
431
+ expect(createConversationMock).toHaveBeenCalledTimes(1);
432
+ // Should upsert the binding for the new conversation
433
+ expect(upsertOutboundBindingMock).toHaveBeenCalledTimes(1);
434
+ const upsertArgs = upsertOutboundBindingMock.mock.calls[0]![0] as Record<
435
+ string,
436
+ unknown
437
+ >;
438
+ expect(upsertArgs.conversationId).toBe("conv-001");
439
+ expect(upsertArgs.sourceChannel).toBe("notification:sms");
440
+ });
441
+
442
+ test("falls back to new conversation when bound conversation no longer exists", async () => {
443
+ // Binding exists but conversation was deleted
444
+ mockBindings["notification:telegram:chat-456"] = {
445
+ conversationId: "conv-deleted",
446
+ sourceChannel: "notification:telegram",
447
+ externalChatId: "chat-456",
448
+ };
449
+
450
+ const signal = makeSignal();
451
+ const copy = makeCopy();
452
+ const bindingContext: DestinationBindingContext = {
453
+ sourceChannel: "telegram" as NotificationChannel,
454
+ externalChatId: "chat-456",
455
+ };
456
+
457
+ const result = await pairDeliveryWithConversation(
458
+ signal,
459
+ "telegram" as NotificationChannel,
460
+ copy,
461
+ { bindingContext },
462
+ );
463
+
464
+ expect(result.conversationId).toBe("conv-001");
465
+ expect(result.createdNewConversation).toBe(true);
466
+ expect(createConversationMock).toHaveBeenCalledTimes(1);
467
+ // Should upsert the new conversation binding
468
+ expect(upsertOutboundBindingMock).toHaveBeenCalledTimes(1);
469
+ });
470
+
471
+ test("creates new conversation and upserts binding when no prior binding exists", async () => {
472
+ const signal = makeSignal();
473
+ const copy = makeCopy();
474
+ const bindingContext: DestinationBindingContext = {
475
+ sourceChannel: "slack" as NotificationChannel,
476
+ externalChatId: "C0123ABCDEF",
477
+ };
478
+
479
+ const result = await pairDeliveryWithConversation(
480
+ signal,
481
+ "slack" as NotificationChannel,
482
+ copy,
483
+ { bindingContext },
484
+ );
485
+
486
+ expect(result.conversationId).toBe("conv-001");
487
+ expect(result.createdNewConversation).toBe(true);
488
+ expect(createConversationMock).toHaveBeenCalledTimes(1);
489
+ // Should upsert so future deliveries reuse this conversation
490
+ expect(upsertOutboundBindingMock).toHaveBeenCalledTimes(1);
491
+ const upsertArgs = upsertOutboundBindingMock.mock.calls[0]![0] as Record<
492
+ string,
493
+ unknown
494
+ >;
495
+ expect(upsertArgs.conversationId).toBe("conv-001");
496
+ expect(upsertArgs.sourceChannel).toBe("notification:slack");
497
+ expect(upsertArgs.externalChatId).toBe("C0123ABCDEF");
498
+ });
499
+
500
+ test("reuse_existing rebinds destination when binding context is present", async () => {
501
+ mockExistingConversations["conv-explicit"] = {
502
+ id: "conv-explicit",
503
+ source: "notification",
504
+ title: "Explicit Thread",
505
+ };
506
+
507
+ const signal = makeSignal();
508
+ const copy = makeCopy({
509
+ threadSeedMessage: "Follow-up to explicit reuse target",
510
+ });
511
+ const threadAction: ThreadAction = {
512
+ action: "reuse_existing",
513
+ conversationId: "conv-explicit",
514
+ };
515
+ const bindingContext: DestinationBindingContext = {
516
+ sourceChannel: "telegram" as NotificationChannel,
517
+ externalChatId: "chat-rebind",
518
+ };
519
+
520
+ const result = await pairDeliveryWithConversation(
521
+ signal,
522
+ "telegram" as NotificationChannel,
523
+ copy,
524
+ { threadAction, bindingContext },
525
+ );
526
+
527
+ expect(result.conversationId).toBe("conv-explicit");
528
+ expect(result.createdNewConversation).toBe(false);
529
+ // Should rebind the destination to the reused conversation
530
+ expect(upsertOutboundBindingMock).toHaveBeenCalledTimes(1);
531
+ const upsertArgs = upsertOutboundBindingMock.mock.calls[0]![0] as Record<
532
+ string,
533
+ unknown
534
+ >;
535
+ expect(upsertArgs.conversationId).toBe("conv-explicit");
536
+ expect(upsertArgs.sourceChannel).toBe("notification:telegram");
537
+ expect(upsertArgs.externalChatId).toBe("chat-rebind");
538
+ });
539
+
540
+ test("reuse_existing fallback rebinds destination when binding context is present", async () => {
541
+ // Target does not exist — falls back to new conversation
542
+ const signal = makeSignal();
543
+ const copy = makeCopy();
544
+ const threadAction: ThreadAction = {
545
+ action: "reuse_existing",
546
+ conversationId: "conv-gone",
547
+ };
548
+ const bindingContext: DestinationBindingContext = {
549
+ sourceChannel: "sms" as NotificationChannel,
550
+ externalChatId: "+15559876543",
551
+ };
552
+
553
+ const result = await pairDeliveryWithConversation(
554
+ signal,
555
+ "sms" as NotificationChannel,
556
+ copy,
557
+ { threadAction, bindingContext },
558
+ );
559
+
560
+ expect(result.conversationId).toBe("conv-001");
561
+ expect(result.createdNewConversation).toBe(true);
562
+ expect(result.threadDecisionFallbackUsed).toBe(true);
563
+ // Should bind the new conversation to the destination
564
+ expect(upsertOutboundBindingMock).toHaveBeenCalledTimes(1);
565
+ const upsertArgs = upsertOutboundBindingMock.mock.calls[0]![0] as Record<
566
+ string,
567
+ unknown
568
+ >;
569
+ expect(upsertArgs.conversationId).toBe("conv-001");
570
+ expect(upsertArgs.sourceChannel).toBe("notification:sms");
571
+ expect(upsertArgs.externalChatId).toBe("+15559876543");
572
+ });
573
+
574
+ test("explicit reuse_existing takes precedence over binding-key reuse", async () => {
575
+ // Both a binding and a reuse_existing target exist — reuse_existing wins
576
+ mockExistingConversations["conv-explicit"] = {
577
+ id: "conv-explicit",
578
+ source: "notification",
579
+ title: "Explicit Thread",
580
+ };
581
+ mockExistingConversations["conv-bound"] = {
582
+ id: "conv-bound",
583
+ source: "notification",
584
+ title: "Bound Thread",
585
+ };
586
+ mockBindings["notification:telegram:chat-789"] = {
587
+ conversationId: "conv-bound",
588
+ sourceChannel: "notification:telegram",
589
+ externalChatId: "chat-789",
590
+ };
591
+
592
+ const signal = makeSignal();
593
+ const copy = makeCopy({
594
+ threadSeedMessage: "Message for explicit reuse target",
595
+ });
596
+ const threadAction: ThreadAction = {
597
+ action: "reuse_existing",
598
+ conversationId: "conv-explicit",
599
+ };
600
+ const bindingContext: DestinationBindingContext = {
601
+ sourceChannel: "telegram" as NotificationChannel,
602
+ externalChatId: "chat-789",
603
+ };
604
+
605
+ const result = await pairDeliveryWithConversation(
606
+ signal,
607
+ "telegram" as NotificationChannel,
608
+ copy,
609
+ { threadAction, bindingContext },
610
+ );
611
+
612
+ // Should use the explicit target, not the binding
613
+ expect(result.conversationId).toBe("conv-explicit");
614
+ expect(result.createdNewConversation).toBe(false);
615
+ expect(createConversationMock).not.toHaveBeenCalled();
616
+ // Binding lookup should not even be attempted since reuse_existing matched first
617
+ expect(getBindingByChannelChatMock).not.toHaveBeenCalled();
618
+ });
619
+
620
+ test("binding context does not trigger reuse for start_new_conversation channels", async () => {
621
+ // vellum uses start_new_conversation — binding context should be ignored for reuse
622
+ mockExistingConversations["conv-bound-vellum"] = {
623
+ id: "conv-bound-vellum",
624
+ source: "notification",
625
+ title: "Vellum Thread",
626
+ };
627
+ mockBindings["notification:vellum:device-1"] = {
628
+ conversationId: "conv-bound-vellum",
629
+ sourceChannel: "notification:vellum",
630
+ externalChatId: "device-1",
631
+ };
632
+
633
+ const signal = makeSignal();
634
+ const copy = makeCopy();
635
+ const bindingContext: DestinationBindingContext = {
636
+ sourceChannel: "vellum" as NotificationChannel,
637
+ externalChatId: "device-1",
638
+ };
639
+
640
+ const result = await pairDeliveryWithConversation(
641
+ signal,
642
+ "vellum" as NotificationChannel,
643
+ copy,
644
+ { bindingContext },
645
+ );
646
+
647
+ // Should still create a new conversation — vellum is start_new_conversation
648
+ expect(result.conversationId).toBe("conv-001");
649
+ expect(result.createdNewConversation).toBe(true);
650
+ expect(createConversationMock).toHaveBeenCalledTimes(1);
651
+ // Binding lookup should not be called for non-continue_existing channels
652
+ expect(getBindingByChannelChatMock).not.toHaveBeenCalled();
653
+ });
654
+
290
655
  // ── not_deliverable (voice) ───────────────────────────────────────
291
656
 
292
657
  test("returns null conversationId and messageId for not_deliverable strategy", async () => {
@@ -25,18 +25,9 @@ mock.module("../util/logger.js", () => ({
25
25
  }));
26
26
 
27
27
  // ---------------------------------------------------------------------------
28
- // Use encrypted backend (no keychain) with a temp store path
28
+ // Use encrypted backend with a temp store path
29
29
  // ---------------------------------------------------------------------------
30
30
 
31
- import { _overrideDeps, _resetDeps } from "../security/keychain.js";
32
-
33
- _overrideDeps({
34
- isMacOS: () => false,
35
- isLinux: () => false,
36
- execFileSync: (() =>
37
- "") as unknown as typeof import("node:child_process").execFileSync,
38
- });
39
-
40
31
  import { _setStorePath } from "../security/encrypted-store.js";
41
32
  import { _resetBackend } from "../security/secure-keys.js";
42
33
 
@@ -24,18 +24,9 @@ mock.module("../util/logger.js", () => ({
24
24
  }));
25
25
 
26
26
  // ---------------------------------------------------------------------------
27
- // Use encrypted backend (no keychain) with a temp store path
27
+ // Use encrypted backend with a temp store path
28
28
  // ---------------------------------------------------------------------------
29
29
 
30
- import { _overrideDeps } from "../security/keychain.js";
31
-
32
- _overrideDeps({
33
- isMacOS: () => false,
34
- isLinux: () => false,
35
- execFileSync: (() =>
36
- "") as unknown as typeof import("node:child_process").execFileSync,
37
- });
38
-
39
30
  import { _setStorePath } from "../security/encrypted-store.js";
40
31
  import { _resetBackend } from "../security/secure-keys.js";
41
32
 
@@ -36,7 +36,13 @@ mock.module("../security/secure-keys.js", () => ({
36
36
  storedKeys.set(key, value);
37
37
  return true;
38
38
  },
39
- deleteSecureKey: (key: string) => storedKeys.delete(key),
39
+ deleteSecureKey: (key: string) => {
40
+ if (storedKeys.has(key)) {
41
+ storedKeys.delete(key);
42
+ return "deleted";
43
+ }
44
+ return "not-found";
45
+ },
40
46
  listSecureKeys: () => [...storedKeys.keys()],
41
47
  getBackendType: () => "encrypted",
42
48
  isDowngradedFromKeychain: () => false,
@@ -34,21 +34,10 @@ mock.module("../util/logger.js", () => ({
34
34
  }));
35
35
 
36
36
  // ---------------------------------------------------------------------------
37
- // Use encrypted backend (no keychain) with a temp store path
37
+ // Use encrypted backend with a temp store path
38
38
  // ---------------------------------------------------------------------------
39
39
 
40
- import { _overrideDeps, _resetDeps } from "../security/keychain.js";
41
-
42
- _overrideDeps({
43
- isMacOS: () => false,
44
- isLinux: () => false,
45
- execFileSync: (() =>
46
- "") as unknown as typeof import("node:child_process").execFileSync,
47
- });
48
-
49
- // Restore process-level keychain deps so later test files are not affected
50
40
  afterAll(() => {
51
- _resetDeps();
52
41
  mock.restore();
53
42
  });
54
43
 
@@ -206,8 +195,8 @@ describe("Invariant 2: no generic plaintext secret read API", () => {
206
195
  expect(browserSrc).not.toContain("getCredentialValue");
207
196
  });
208
197
 
209
- test("getSecureKey is only imported by authorized modules", () => {
210
- // Hard boundary: only these production files may import getSecureKey.
198
+ test("secure-keys is only imported by authorized modules", () => {
199
+ // Hard boundary: only these production files may import from secure-keys.
211
200
  // Any new import must be reviewed for secret-leak risk and added here.
212
201
  const ALLOWED_IMPORTERS = new Set([
213
202
  "security/secure-keys.ts", // self (re-export infrastructure)
@@ -230,11 +219,12 @@ describe("Invariant 2: no generic plaintext secret read API", () => {
230
219
  "calls/twilio-provider.ts", // call infrastructure credential lookup
231
220
  "calls/twilio-rest.ts", // Twilio REST API credential lookup
232
221
  "runtime/channel-invite-transports/sms.ts", // SMS invite transport phone number lookup
233
- "cli/config-commands.ts", // CLI credential management commands
222
+ "runtime/channel-invite-transports/telegram.ts", // Telegram invite transport bot token lookup
223
+ "cli/keys.ts", // CLI credential management commands
224
+ "cli/credentials.ts", // CLI credential management commands
234
225
  "runtime/http-server.ts", // HTTP server credential lookup
235
226
  "daemon/handlers/twitter-auth.ts", // Twitter OAuth token storage
236
227
  "twitter/oauth-client.ts", // Twitter OAuth API client (reads access token for API calls)
237
- "cli/config-commands.ts", // CLI config management
238
228
  "messaging/providers/telegram-bot/adapter.ts", // Telegram bot token lookup for connectivity check
239
229
  "messaging/providers/sms/adapter.ts", // Twilio credential lookup for SMS connectivity check
240
230
  "runtime/channel-readiness-service.ts", // channel readiness probes for SMS/Telegram connectivity
@@ -243,6 +233,11 @@ describe("Invariant 2: no generic plaintext secret read API", () => {
243
233
  "daemon/handlers/oauth-connect.ts", // OAuth connect handler for integration setup
244
234
  "daemon/handlers/config-slack-channel.ts", // Slack channel config credential management
245
235
  "providers/managed-proxy/context.ts", // managed proxy API key lookup for provider initialization
236
+ "mcp/mcp-oauth-provider.ts", // MCP OAuth token/client/discovery persistence
237
+ "mcp/client.ts", // MCP client cached-token lookup
238
+ "oauth/token-persistence.ts", // OAuth token persistence (set/delete tokens)
239
+ "runtime/routes/secret-routes.ts", // HTTP secret management routes (set/delete secrets)
240
+ "daemon/session-messaging.ts", // credential storage during session messaging
246
241
  ]);
247
242
 
248
243
  const thisDir = dirname(fileURLToPath(import.meta.url));
@@ -270,11 +265,10 @@ describe("Invariant 2: no generic plaintext secret read API", () => {
270
265
 
271
266
  for (const filePath of allFiles) {
272
267
  const content = readFileSync(filePath, "utf-8");
273
- // Check for imports of getSecureKey via static import, dynamic import(), or require()
268
+ // Check for any import from the secure-keys module (static import, dynamic import(), or require())
274
269
  if (
275
- content.match(/\bgetSecureKey\b/) &&
276
- (content.match(/from\s+['"].*secure-keys/) ||
277
- content.match(/(?:import|require)\s*\(\s*['"].*secure-keys/))
270
+ content.match(/from\s+['"].*secure-keys/) ||
271
+ content.match(/(?:import|require)\s*\(\s*['"].*secure-keys/)
278
272
  ) {
279
273
  const relative = filePath.slice(srcDir.length + 1);
280
274
  if (!ALLOWED_IMPORTERS.has(relative)) {
@@ -24,18 +24,9 @@ mock.module("../util/logger.js", () => ({
24
24
  }));
25
25
 
26
26
  // ---------------------------------------------------------------------------
27
- // Use encrypted backend (no keychain) with a temp store path
27
+ // Use encrypted backend with a temp store path
28
28
  // ---------------------------------------------------------------------------
29
29
 
30
- import { _overrideDeps, _resetDeps } from "../security/keychain.js";
31
-
32
- _overrideDeps({
33
- isMacOS: () => false,
34
- isLinux: () => false,
35
- execFileSync: (() =>
36
- "") as unknown as typeof import("node:child_process").execFileSync,
37
- });
38
-
39
30
  import { _setStorePath } from "../security/encrypted-store.js";
40
31
  import { _resetBackend } from "../security/secure-keys.js";
41
32
 
@@ -74,7 +65,6 @@ const _ctx: ToolContext = {
74
65
  };
75
66
 
76
67
  afterAll(() => {
77
- _resetDeps();
78
68
  mock.restore();
79
69
  if (existsSync(TEST_DIR)) {
80
70
  rmSync(TEST_DIR, { recursive: true });
@@ -25,21 +25,11 @@ mock.module("../util/logger.js", () => ({
25
25
  }));
26
26
 
27
27
  // ---------------------------------------------------------------------------
28
- // Use encrypted backend (no keychain) with a temp store path
28
+ // Use encrypted backend with a temp store path
29
29
  // ---------------------------------------------------------------------------
30
30
 
31
- import { _overrideDeps, _resetDeps } from "../security/keychain.js";
32
-
33
- // Make keychain unavailable so secure-keys always uses encrypted backend
34
- _overrideDeps({
35
- isMacOS: () => false,
36
- isLinux: () => false,
37
- execFileSync: (() =>
38
- "") as unknown as typeof import("node:child_process").execFileSync,
39
- });
40
-
41
31
  import { _setStorePath } from "../security/encrypted-store.js";
42
- import { _resetBackend, _setBackend } from "../security/secure-keys.js";
32
+ import { _resetBackend } from "../security/secure-keys.js";
43
33
 
44
34
  const TEST_DIR = join(
45
35
  tmpdir(),
@@ -162,8 +152,8 @@ async function executeVault(
162
152
  }
163
153
 
164
154
  const key = `credential:${service}:${field}`;
165
- const ok = deleteSecureKey(key);
166
- if (!ok) {
155
+ const result = deleteSecureKey(key);
156
+ if (result !== "deleted") {
167
157
  return {
168
158
  content: `Error: credential ${service}/${field} not found`,
169
159
  isError: true,
@@ -206,7 +196,6 @@ describe("credential_store tool", () => {
206
196
  });
207
197
 
208
198
  afterAll(() => {
209
- _resetDeps();
210
199
  rmSync(TEST_DIR, { recursive: true, force: true });
211
200
  });
212
201
 
@@ -500,7 +489,7 @@ describe("credential_store tool", () => {
500
489
  expect(gmail.injection_templates).toBeUndefined();
501
490
  });
502
491
 
503
- test("works with keychain backend (reads from metadata store)", async () => {
492
+ test("works with metadata store fallback when listing secrets", async () => {
504
493
  // Store a credential first (on encrypted backend)
505
494
  await credentialStoreTool.execute(
506
495
  {
@@ -512,9 +501,6 @@ describe("credential_store tool", () => {
512
501
  _ctx,
513
502
  );
514
503
 
515
- // Switch to keychain backend — list should still work via metadata
516
- _setBackend("keychain");
517
-
518
504
  const result = await credentialStoreTool.execute(
519
505
  { action: "list" },
520
506
  _ctx,