@vellumai/assistant 0.9.0 → 0.10.0-staging.2

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 (572) hide show
  1. package/ARCHITECTURE.md +18 -34
  2. package/bun.lock +7 -8
  3. package/docs/activation-funnel-telemetry.md +28 -22
  4. package/docs/architecture/security.md +29 -28
  5. package/docs/stt-provider-onboarding.md +3 -5
  6. package/docs/workflows-testing.md +13 -44
  7. package/docs/workflows.md +3 -5
  8. package/node_modules/@vellumai/ces-client/src/__tests__/ces-client.test.ts +47 -0
  9. package/node_modules/@vellumai/ces-client/src/rpc-client.ts +28 -5
  10. package/node_modules/@vellumai/environments/src/seeds.ts +2 -5
  11. package/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +97 -0
  12. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +10 -0
  13. package/node_modules/@vellumai/gateway-client/src/index.ts +32 -6
  14. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +119 -0
  15. package/node_modules/@vellumai/gateway-client/src/types.ts +15 -84
  16. package/openapi.yaml +976 -63
  17. package/package.json +2 -1
  18. package/scripts/sync-llm-catalog.ts +6 -15
  19. package/scripts/sync-web-search-catalog.ts +3 -11
  20. package/src/__tests__/access-request-card-view.test.ts +98 -0
  21. package/src/__tests__/access-request-seed-content-blocks.test.ts +2 -4
  22. package/src/__tests__/actor-trust-resolver-address-fallback.test.ts +72 -32
  23. package/src/__tests__/agent-loop-compaction-strip.test.ts +241 -0
  24. package/src/__tests__/agent-loop-mutable-latest-user-message.test.ts +16 -13
  25. package/src/__tests__/agent-loop-output-hooks.test.ts +69 -0
  26. package/src/__tests__/agent-loop-override-profile.test.ts +25 -0
  27. package/src/__tests__/always-loaded-tools-guard.test.ts +2 -3
  28. package/src/__tests__/app-compiler.test.ts +15 -1
  29. package/src/__tests__/app-dir-path-guard.test.ts +0 -1
  30. package/src/__tests__/assistant-feature-flag-guard.test.ts +1 -4
  31. package/src/__tests__/assistant-feature-flag-guardrails.test.ts +0 -2
  32. package/src/__tests__/auth-fallback-events-store.test.ts +6 -14
  33. package/src/__tests__/avatar-identity-sync.test.ts +2 -27
  34. package/src/__tests__/btw-routes.test.ts +6 -8
  35. package/src/__tests__/call-pointer-messages.test.ts +28 -0
  36. package/src/__tests__/cancel-clears-processing.test.ts +89 -0
  37. package/src/__tests__/channel-approval-routes.test.ts +0 -4
  38. package/src/__tests__/channel-inbound-disk-pressure.test.ts +5 -15
  39. package/src/__tests__/checker.test.ts +0 -3
  40. package/src/__tests__/cli-memory-v2-reembed-skills.test.ts +3 -4
  41. package/src/__tests__/compactor-image-manifest-trust.test.ts +21 -1
  42. package/src/__tests__/compactor-summary-call-truncation.test.ts +223 -0
  43. package/src/__tests__/config-loader-backfill.test.ts +268 -27
  44. package/src/__tests__/config-schema.test.ts +35 -0
  45. package/src/__tests__/config-watcher.test.ts +0 -18
  46. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +2 -2
  47. package/src/__tests__/contact-store-user-file.test.ts +0 -6
  48. package/src/__tests__/contacts-tools.test.ts +29 -0
  49. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +22 -0
  50. package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -0
  51. package/src/__tests__/conversation-agent-loop.test.ts +58 -0
  52. package/src/__tests__/conversation-attention-telegram.test.ts +0 -1
  53. package/src/__tests__/conversation-lifecycle.test.ts +7 -9
  54. package/src/__tests__/conversation-load-history-repair.test.ts +101 -0
  55. package/src/__tests__/conversation-routes-guardian-reply.test.ts +15 -12
  56. package/src/__tests__/conversation-surfaces-activation-emit.test.ts +6 -3
  57. package/src/__tests__/conversation-title-service.test.ts +62 -0
  58. package/src/__tests__/credential-broker.test.ts +449 -1
  59. package/src/__tests__/credential-execution-shell-lockdown.test.ts +18 -11
  60. package/src/__tests__/credential-execution-tools.test.ts +0 -1
  61. package/src/__tests__/credential-prompt-route.test.ts +4 -4
  62. package/src/__tests__/credential-routes.test.ts +360 -0
  63. package/src/__tests__/credential-security-invariants.test.ts +4 -13
  64. package/src/__tests__/disk-pressure-policy.test.ts +12 -0
  65. package/src/__tests__/disk-usage.test.ts +65 -0
  66. package/src/__tests__/dynamic-page-surface.test.ts +152 -1
  67. package/src/__tests__/fixtures/credential-security-fixtures.ts +2 -33
  68. package/src/__tests__/gateway-flag-listener.test.ts +110 -1
  69. package/src/__tests__/gateway-only-guard.test.ts +3 -7
  70. package/src/__tests__/guardian-binding-drift-heal.test.ts +1 -1
  71. package/src/__tests__/guardian-card-withdrawal.test.ts +403 -0
  72. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +5 -3
  73. package/src/__tests__/guardian-grant-minting.test.ts +3 -35
  74. package/src/__tests__/guardian-routing-invariants.test.ts +64 -26
  75. package/src/__tests__/guardian-routing-state.test.ts +0 -1
  76. package/src/__tests__/headless-browser-mode.test.ts +10 -0
  77. package/src/__tests__/headless-browser-navigate.test.ts +8 -3
  78. package/src/__tests__/helpers/create-guardian-binding.ts +0 -1
  79. package/src/__tests__/host-browser-proxy.test.ts +87 -0
  80. package/src/__tests__/identity-routes.test.ts +0 -189
  81. package/src/__tests__/inbound-invite-redemption.test.ts +4 -4
  82. package/src/__tests__/injector-v3-suppression.test.ts +27 -20
  83. package/src/__tests__/internal-telemetry-routes.test.ts +6 -14
  84. package/src/__tests__/invite-redemption-service.test.ts +4 -7
  85. package/src/__tests__/llm-callsite-catalog.test.ts +5 -6
  86. package/src/__tests__/llm-catalog-parity.test.ts +30 -23
  87. package/src/__tests__/llm-resolver.test.ts +70 -24
  88. package/src/__tests__/llm-schema.test.ts +1 -0
  89. package/src/__tests__/managed-profile-guard.test.ts +163 -4
  90. package/src/__tests__/mcp-health-check.test.ts +6 -7
  91. package/src/__tests__/media-stream-server-integration.test.ts +317 -13
  92. package/src/__tests__/oauth-provider-seed-logos.test.ts +4 -6
  93. package/src/__tests__/onboarding-persona-write.test.ts +1 -1
  94. package/src/__tests__/path-policy.test.ts +34 -0
  95. package/src/__tests__/persona-resolver.test.ts +49 -14
  96. package/src/__tests__/plugin-api-model-profiles.test.ts +178 -0
  97. package/src/__tests__/plugin-api-provider.test.ts +24 -0
  98. package/src/__tests__/plugin-tool-contribution.test.ts +6 -3
  99. package/src/__tests__/post-compaction-reinjection-idempotency.test.ts +214 -0
  100. package/src/__tests__/provider-send-message-override-profile.test.ts +76 -0
  101. package/src/__tests__/reaction-persistence.test.ts +150 -29
  102. package/src/__tests__/registry.test.ts +2 -7
  103. package/src/__tests__/relay-server.test.ts +285 -0
  104. package/src/__tests__/runtime-attachment-metadata.test.ts +0 -1
  105. package/src/__tests__/schedule-routes-workflow-validation.test.ts +1 -10
  106. package/src/__tests__/schedule-routes.test.ts +0 -30
  107. package/src/__tests__/schedule-tools.test.ts +2 -18
  108. package/src/__tests__/scheduler-reuse-conversation.test.ts +8 -5
  109. package/src/__tests__/skill-execute-input.test.ts +51 -1
  110. package/src/__tests__/skill-runtime-path.test.ts +2 -3
  111. package/src/__tests__/skills.test.ts +51 -0
  112. package/src/__tests__/slack-notification-approval-card.test.ts +176 -0
  113. package/src/__tests__/slack-reaction-canonical-approval.test.ts +285 -0
  114. package/src/__tests__/subagent-tools.test.ts +266 -0
  115. package/src/__tests__/surface-completion-nudge-hook.test.ts +367 -0
  116. package/src/__tests__/task-progress-nudge-hook.test.ts +1 -1
  117. package/src/__tests__/title-generate-hook.test.ts +100 -3
  118. package/src/__tests__/token-estimator-accuracy.benchmark.test.ts +1 -29
  119. package/src/__tests__/token-manager.test.ts +519 -0
  120. package/src/__tests__/tool-approval-seed-content-blocks.test.ts +1 -1
  121. package/src/__tests__/tool-audit-listener.test.ts +7 -7
  122. package/src/__tests__/tool-executor-lifecycle-events.test.ts +6 -3
  123. package/src/__tests__/tool-executor.test.ts +0 -79
  124. package/src/__tests__/trusted-contact-approval-notifier.test.ts +4 -2
  125. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +220 -3
  126. package/src/__tests__/trusted-contact-multichannel.test.ts +3 -3
  127. package/src/__tests__/trusted-contact-verification.test.ts +8 -10
  128. package/src/__tests__/twilio-routes.test.ts +81 -1
  129. package/src/__tests__/voice-invite-redemption.test.ts +2 -3
  130. package/src/__tests__/weak-open-model.test.ts +30 -0
  131. package/src/__tests__/web-search-catalog-parity.test.ts +6 -25
  132. package/src/__tests__/workspace-greetings.test.ts +152 -0
  133. package/src/__tests__/workspace-migration-105-enable-memory-v3-live-for-new-workspaces.test.ts +149 -0
  134. package/src/__tests__/workspace-migration-108-drop-balanced-economy-profile.test.ts +285 -0
  135. package/src/__tests__/workspace-migration-add-send-diagnostics.test.ts +1 -1
  136. package/src/__tests__/workspace-migration-drop-collect-usage-data.test.ts +118 -0
  137. package/src/__tests__/workspace-migration-drop-send-diagnostics.test.ts +118 -0
  138. package/src/a2a/__tests__/e2e-a2a-channel.test.ts +0 -4
  139. package/src/agent/loop.ts +49 -29
  140. package/src/api/README.md +6 -6
  141. package/src/api/events/tool-result.ts +6 -0
  142. package/src/api/events/workflow-completed.ts +53 -0
  143. package/src/api/events/workflow-leaf-finished.ts +38 -0
  144. package/src/api/events/workflow-leaf-started.ts +35 -0
  145. package/src/api/events/workflow-progress.ts +32 -0
  146. package/src/api/events/workflow-started.ts +31 -0
  147. package/src/api/index.ts +40 -0
  148. package/src/api/responses/conversation-message.ts +28 -4
  149. package/src/api/responses/home.ts +26 -4
  150. package/src/api/responses/workflow-journal.ts +53 -0
  151. package/src/approvals/guardian-card-withdrawal.ts +145 -0
  152. package/src/approvals/guardian-decision-primitive.ts +26 -3
  153. package/src/approvals/guardian-request-resolvers.ts +183 -80
  154. package/src/calls/__tests__/channel-admission-reader.test.ts +132 -0
  155. package/src/calls/__tests__/relay-setup-router.test.ts +350 -0
  156. package/src/calls/call-pointer-messages.ts +10 -4
  157. package/src/calls/channel-admission-reader.ts +104 -0
  158. package/src/calls/guardian-dispatch.ts +17 -45
  159. package/src/calls/media-stream-server.ts +84 -2
  160. package/src/calls/relay-access-wait.ts +1 -1
  161. package/src/calls/relay-server.ts +66 -0
  162. package/src/calls/relay-setup-router.ts +82 -1
  163. package/src/calls/twilio-routes.ts +17 -8
  164. package/src/calls/voice-session-bridge.ts +2 -2
  165. package/src/cli/commands/clients.ts +3 -0
  166. package/src/cli/commands/{__tests__ → memory/__tests__}/memory-v2-compare-render.test.ts +1 -1
  167. package/src/cli/commands/{__tests__ → memory/__tests__}/memory-v2.test.ts +8 -7
  168. package/src/cli/commands/{__tests__ → memory/__tests__}/memory-v3.test.ts +5 -4
  169. package/src/cli/commands/memory/index.ts +30 -0
  170. package/src/cli/commands/{memory-v2-compare-render.ts → memory/memory-v2-compare-render.ts} +1 -1
  171. package/src/cli/commands/{memory-v2.ts → memory/memory-v2.ts} +6 -15
  172. package/src/cli/commands/{memory-v3.ts → memory/memory-v3.ts} +97 -11
  173. package/src/cli/commands/oauth/status.test.ts +36 -0
  174. package/src/cli/commands/oauth/status.ts +23 -3
  175. package/src/cli/commands/plugins.ts +197 -4
  176. package/src/cli/lib/__tests__/diff-plugin.test.ts +443 -0
  177. package/src/cli/lib/__tests__/inspect-plugin.test.ts +54 -0
  178. package/src/cli/lib/__tests__/merge-plugin-tree.test.ts +443 -0
  179. package/src/cli/lib/__tests__/plugin-surfaces.test.ts +111 -0
  180. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +295 -2
  181. package/src/cli/lib/diff-plugin.ts +346 -0
  182. package/src/cli/lib/inspect-plugin.ts +12 -1
  183. package/src/cli/lib/install-from-github.ts +105 -17
  184. package/src/cli/lib/merge-plugin-tree.ts +328 -0
  185. package/src/cli/lib/plugin-fingerprint.ts +14 -0
  186. package/src/cli/lib/plugin-surfaces.ts +104 -0
  187. package/src/cli/lib/upgrade-plugin.ts +298 -10
  188. package/src/cli/program.ts +2 -6
  189. package/src/config/__tests__/sync-gated-profiles.test.ts +368 -0
  190. package/src/config/assistant-feature-flags.ts +22 -7
  191. package/src/config/bundled-skills/contacts/tools/contact-search.ts +0 -1
  192. package/src/config/bundled-skills/messaging/SKILL.md +6 -4
  193. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +9 -8
  194. package/src/config/bundled-skills/subagent/SKILL.md +4 -0
  195. package/src/config/bundled-skills/subagent/TOOLS.json +4 -0
  196. package/src/config/bundled-skills/workflows/SKILL.md +14 -8
  197. package/src/config/bundled-tool-registry.ts +2 -7
  198. package/src/config/call-site-defaults.ts +15 -2
  199. package/src/config/feature-flag-registry.json +46 -31
  200. package/src/config/inference-profile-validation.ts +26 -0
  201. package/src/config/llm-resolver.ts +3 -0
  202. package/src/config/loader.ts +4 -0
  203. package/src/config/memory-v3-gate.ts +11 -0
  204. package/src/config/profile-order.ts +28 -0
  205. package/src/config/schema.ts +8 -6
  206. package/src/config/schemas/__tests__/memory-v3.test.ts +1 -0
  207. package/src/config/schemas/call-site-catalog.ts +7 -0
  208. package/src/config/schemas/channels.ts +11 -0
  209. package/src/config/schemas/elevenlabs.ts +0 -1
  210. package/src/config/schemas/llm.ts +31 -0
  211. package/src/config/schemas/memory-lifecycle.ts +3 -7
  212. package/src/config/schemas/memory-v3.ts +6 -0
  213. package/src/config/schemas/platform.ts +0 -8
  214. package/src/config/schemas/services.ts +18 -0
  215. package/src/config/seed-inference-profiles.ts +109 -44
  216. package/src/config/skills.ts +21 -0
  217. package/src/config/sync-gated-profiles.ts +220 -0
  218. package/src/contacts/contact-store.ts +89 -106
  219. package/src/contacts/contacts-write.ts +5 -22
  220. package/src/contacts/types.ts +0 -1
  221. package/src/context/compactor.ts +88 -54
  222. package/src/context/strip-injections.ts +58 -10
  223. package/src/context/token-estimator.ts +1 -1
  224. package/src/credential-execution/process-manager.ts +55 -14
  225. package/src/credential-execution/prompted-credential.ts +2 -3
  226. package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +3 -2
  227. package/src/daemon/config-watcher.ts +0 -4
  228. package/src/daemon/conversation-agent-loop-handlers.ts +2 -0
  229. package/src/daemon/conversation-agent-loop.ts +114 -22
  230. package/src/daemon/conversation-history.ts +1 -1
  231. package/src/daemon/conversation-lifecycle.ts +3 -5
  232. package/src/daemon/conversation-process.ts +13 -5
  233. package/src/daemon/conversation-runtime-assembly.ts +13 -15
  234. package/src/daemon/conversation-slash.ts +2 -23
  235. package/src/daemon/conversation-surfaces.ts +26 -0
  236. package/src/daemon/conversation-tool-setup.ts +27 -14
  237. package/src/daemon/conversation.ts +66 -14
  238. package/src/daemon/disk-pressure-policy.ts +5 -3
  239. package/src/daemon/handlers/__tests__/config-a2a-complete.test.ts +0 -1
  240. package/src/daemon/handlers/__tests__/config-a2a-redeem.test.ts +0 -1
  241. package/src/daemon/handlers/config-a2a.ts +0 -2
  242. package/src/daemon/handlers/config-channels.ts +15 -16
  243. package/src/daemon/handlers/config-slack-channel.ts +22 -3
  244. package/src/daemon/handlers/conversations.ts +107 -0
  245. package/src/daemon/host-browser-proxy.ts +41 -0
  246. package/src/daemon/lifecycle.ts +55 -27
  247. package/src/daemon/message-provenance.ts +2 -0
  248. package/src/daemon/message-types/contacts.ts +0 -1
  249. package/src/daemon/message-types/conversations.ts +3 -3
  250. package/src/daemon/message-types/sync.ts +0 -1
  251. package/src/daemon/message-types/web-activity.ts +7 -1
  252. package/src/daemon/message-types/workflows.ts +83 -1
  253. package/src/daemon/orphan-reaper.test.ts +0 -19
  254. package/src/daemon/orphan-reaper.ts +2 -24
  255. package/src/daemon/server.ts +0 -10
  256. package/src/daemon/tool-setup-types.ts +4 -0
  257. package/src/daemon/trust-context.ts +1 -1
  258. package/src/events/tool-audit-listener.ts +2 -2
  259. package/src/home/feed-source-enrichment.test.ts +151 -0
  260. package/src/home/feed-source-enrichment.ts +176 -0
  261. package/src/home/relationship-state.ts +2 -4
  262. package/src/instrument.ts +18 -6
  263. package/src/ipc/__tests__/binary-result-ipc.test.ts +81 -0
  264. package/src/ipc/__tests__/clients-list-ipc.test.ts +20 -0
  265. package/src/ipc/assistant-server.ts +37 -4
  266. package/src/ipc/gateway-flag-listener.ts +18 -2
  267. package/src/memory/__tests__/auto-analysis-enqueue.test.ts +5 -16
  268. package/src/memory/__tests__/jobs-store-enqueue-gate.test.ts +7 -11
  269. package/src/memory/__tests__/memory-retrospective-enqueue.test.ts +37 -7
  270. package/src/memory/__tests__/memory-retrospective-job.test.ts +229 -401
  271. package/src/memory/__tests__/onboarding-events-store.test.ts +7 -7
  272. package/src/memory/auth-fallback-events-store.ts +2 -2
  273. package/src/memory/auto-analysis-enqueue.ts +3 -5
  274. package/src/memory/bookmark-crud.ts +1 -2
  275. package/src/memory/canonical-guardian-store.ts +39 -1
  276. package/src/memory/conversation-crud.ts +9 -4
  277. package/src/memory/conversation-key-store.ts +17 -2
  278. package/src/memory/conversation-title-service.ts +64 -7
  279. package/src/memory/db-init.ts +17 -17
  280. package/src/memory/embedding-backend.ts +38 -1
  281. package/src/memory/embedding-billing-breaker.ts +96 -0
  282. package/src/memory/jobs-store.ts +25 -13
  283. package/src/memory/jobs-worker.ts +54 -1
  284. package/src/memory/lifecycle-events-store.ts +2 -2
  285. package/src/memory/memory-retrospective-constants.ts +4 -4
  286. package/src/memory/memory-retrospective-enqueue.ts +31 -6
  287. package/src/memory/memory-retrospective-job.ts +28 -227
  288. package/src/memory/migrations/129-contact-channels-access-fields.ts +18 -9
  289. package/src/memory/migrations/131-drop-legacy-member-guardian-tables.ts +14 -2
  290. package/src/memory/migrations/289-contact-channels-unique-ext-user.ts +10 -0
  291. package/src/memory/migrations/291-contact-channels-renormalize-addresses.ts +72 -0
  292. package/src/memory/migrations/292-schedule-default-no-reuse-conversation.test.ts +67 -0
  293. package/src/memory/migrations/292-schedule-default-no-reuse-conversation.ts +25 -0
  294. package/src/memory/migrations/293-workflow-journal-leaf-tokens.ts +32 -0
  295. package/src/memory/migrations/294-drop-external-user-id.ts +31 -0
  296. package/src/memory/migrations/295-drop-approval-prompt-ts-tracker.ts +20 -0
  297. package/src/memory/migrations/296-rewrite-balanced-economy-profile-pins.test.ts +110 -0
  298. package/src/memory/migrations/296-rewrite-balanced-economy-profile-pins.ts +68 -0
  299. package/src/memory/migrations/__tests__/131-drop-legacy-member-guardian-tables.test.ts +154 -0
  300. package/src/memory/migrations/__tests__/289-contact-channels-unique-ext-user.test.ts +31 -0
  301. package/src/memory/migrations/__tests__/291-contact-channels-renormalize-addresses.test.ts +341 -0
  302. package/src/memory/migrations/__tests__/run-migrations.test.ts +52 -0
  303. package/src/memory/migrations/index.ts +6 -0
  304. package/src/memory/migrations/run-migrations.ts +41 -0
  305. package/src/memory/migrations/validate-migration-state.ts +1 -1
  306. package/src/memory/onboarding-events-store.ts +3 -3
  307. package/src/memory/schema/contacts.ts +0 -5
  308. package/src/memory/skill-loaded-events-store.test.ts +7 -15
  309. package/src/memory/skill-loaded-events-store.ts +2 -2
  310. package/src/memory/tool-executed-events-store.test.ts +7 -7
  311. package/src/memory/turn-trace-store.test.ts +736 -0
  312. package/src/memory/turn-trace-store.ts +364 -0
  313. package/src/memory/v2/__tests__/consolidation-job.test.ts +8 -0
  314. package/src/memory/v2/__tests__/skill-content.test.ts +30 -0
  315. package/src/memory/v2/consolidation-job.ts +2 -2
  316. package/src/memory/v2/skill-content.ts +25 -7
  317. package/src/memory/v2/skill-store.ts +7 -1
  318. package/src/memory/v3-eval/__tests__/eval-packets.test.ts +248 -0
  319. package/src/memory/v3-eval/eval-packets.ts +546 -0
  320. package/src/messaging/providers/slack/adapter.ts +1 -1
  321. package/src/messaging/providers/slack/api.ts +31 -0
  322. package/src/messaging/providers/slack/send.test.ts +114 -2
  323. package/src/messaging/providers/slack/send.ts +30 -7
  324. package/src/messaging/providers/slack/withdraw.test.ts +200 -0
  325. package/src/messaging/providers/slack/withdraw.ts +161 -0
  326. package/src/notifications/AGENTS.md +2 -0
  327. package/src/notifications/access-request-copy.ts +72 -59
  328. package/src/notifications/adapters/shared.ts +29 -0
  329. package/src/notifications/adapters/slack.ts +58 -103
  330. package/src/notifications/adapters/telegram.ts +2 -20
  331. package/src/notifications/approval-card-data.ts +333 -0
  332. package/src/notifications/broadcaster.ts +16 -3
  333. package/src/notifications/canonical-delivery-recorder.ts +139 -0
  334. package/src/notifications/copy-composer.ts +3 -3
  335. package/src/notifications/decision-engine.ts +4 -2
  336. package/src/notifications/destination-resolver.ts +4 -6
  337. package/src/notifications/guardian-question-mode.ts +10 -0
  338. package/src/notifications/home-feed-side-effect.ts +7 -16
  339. package/src/notifications/notification-utils.ts +19 -20
  340. package/src/notifications/signal.ts +79 -43
  341. package/src/notifications/types.ts +98 -121
  342. package/src/oauth/AGENTS.md +5 -24
  343. package/src/permissions/checker.test.ts +51 -0
  344. package/src/permissions/checker.ts +185 -26
  345. package/src/permissions/ipc-risk-types.ts +24 -0
  346. package/src/permissions/question-prompter.test.ts +27 -0
  347. package/src/permissions/question-prompter.ts +4 -0
  348. package/src/platform/client.test.ts +119 -0
  349. package/src/platform/client.ts +66 -0
  350. package/src/platform/consent-cache.test.ts +267 -0
  351. package/src/platform/consent-cache.ts +174 -0
  352. package/src/plugin-api/constants.ts +1 -1
  353. package/src/plugin-api/index.ts +33 -1
  354. package/src/plugin-api/model-profiles.ts +33 -0
  355. package/src/plugin-api/types.ts +50 -2
  356. package/src/plugins/defaults/advisor/__tests__/advisor-gate.test.ts +56 -0
  357. package/src/plugins/defaults/advisor/__tests__/advisor-state-store.test.ts +43 -0
  358. package/src/plugins/defaults/advisor/__tests__/agent-loop-integration.test.ts +137 -0
  359. package/src/plugins/defaults/advisor/__tests__/consult.test.ts +153 -0
  360. package/src/plugins/defaults/advisor/__tests__/hooks.test.ts +138 -0
  361. package/src/plugins/defaults/advisor/__tests__/transcript.test.ts +147 -0
  362. package/src/plugins/defaults/advisor/advisor-gate.ts +29 -0
  363. package/src/plugins/defaults/advisor/advisor-state-store.ts +94 -0
  364. package/src/plugins/defaults/advisor/config.ts +21 -0
  365. package/src/plugins/defaults/advisor/consult.ts +93 -0
  366. package/src/plugins/defaults/advisor/hooks/post-model-call.ts +34 -0
  367. package/src/plugins/defaults/advisor/hooks/pre-model-call.ts +30 -0
  368. package/src/plugins/defaults/advisor/hooks/user-prompt-submit.ts +19 -0
  369. package/src/plugins/defaults/advisor/package.json +14 -0
  370. package/src/plugins/defaults/advisor/steering.ts +67 -0
  371. package/src/plugins/defaults/advisor/tools/advisor.ts +65 -0
  372. package/src/plugins/defaults/advisor/transcript.ts +76 -0
  373. package/src/plugins/defaults/index.ts +60 -0
  374. package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +22 -9
  375. package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit.ts +2 -2
  376. package/src/plugins/defaults/memory-retrieval/tail-reinjection-strip.ts +64 -0
  377. package/src/plugins/defaults/memory-retrieval/unified-turn-context.ts +29 -21
  378. package/src/plugins/defaults/memory-v3-shadow/__tests__/carry-integration.test.ts +1 -0
  379. package/src/plugins/defaults/memory-v3-shadow/__tests__/injection.test.ts +1 -0
  380. package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +129 -9
  381. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +31 -4
  382. package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +77 -2
  383. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +1 -0
  384. package/src/plugins/defaults/memory-v3-shadow/injector.ts +7 -10
  385. package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +144 -11
  386. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +32 -20
  387. package/src/plugins/defaults/memory-v3-shadow/selection-log-store.ts +56 -3
  388. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +23 -2
  389. package/src/plugins/defaults/surface-completion-nudge/hooks/post-model-call.ts +276 -0
  390. package/src/plugins/defaults/surface-completion-nudge/hooks/stop.ts +22 -0
  391. package/src/plugins/defaults/surface-completion-nudge/nudge-state-store.ts +46 -0
  392. package/src/plugins/defaults/surface-completion-nudge/package.json +14 -0
  393. package/src/plugins/defaults/task-progress-nudge/hooks/post-tool-use.ts +3 -13
  394. package/src/plugins/defaults/title-generate/hooks/stop.ts +56 -21
  395. package/src/prompts/persona-resolver.ts +14 -4
  396. package/src/prompts/templates/system-sections.ts +7 -2
  397. package/src/providers/__tests__/provider-env-vars.test.ts +6 -0
  398. package/src/providers/__tests__/provider-secret-catalog.test.ts +1 -0
  399. package/src/providers/__tests__/retry-callsite.test.ts +176 -0
  400. package/src/providers/atlascloud/client.ts +85 -0
  401. package/src/providers/fetch-provider-catalog.ts +85 -0
  402. package/src/providers/inference/adapter-factory.ts +3 -0
  403. package/src/providers/model-catalog.ts +58 -0
  404. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +33 -0
  405. package/src/providers/openai/chat-completions-provider.ts +7 -0
  406. package/src/providers/openai/responses-provider.ts +10 -0
  407. package/src/providers/provider-send-message.ts +11 -3
  408. package/src/providers/retry.ts +53 -12
  409. package/src/providers/search-provider-catalog.ts +10 -0
  410. package/src/providers/weak-open-model.ts +22 -0
  411. package/src/runtime/AGENTS.md +0 -1
  412. package/src/runtime/__tests__/agent-wake.test.ts +181 -0
  413. package/src/runtime/__tests__/client-health.test.ts +44 -0
  414. package/src/runtime/access-request-helper.ts +21 -53
  415. package/src/runtime/actor-trust-resolver.ts +59 -63
  416. package/src/runtime/agent-wake.ts +52 -0
  417. package/src/runtime/assistant-event-hub.ts +18 -4
  418. package/src/runtime/auth/__tests__/route-policy.test.ts +12 -0
  419. package/src/runtime/auth/require-bound-guardian.ts +1 -4
  420. package/src/runtime/btw-sidechain.ts +3 -6
  421. package/src/runtime/capabilities.test.ts +120 -0
  422. package/src/runtime/capabilities.ts +197 -0
  423. package/src/runtime/channel-approval-types.ts +22 -45
  424. package/src/runtime/channel-invite-transports/telegram.ts +4 -4
  425. package/src/runtime/channel-retry-sweep.ts +1 -0
  426. package/src/runtime/channel-verification-service.ts +3 -3
  427. package/src/runtime/client-health.ts +26 -0
  428. package/src/runtime/confirmation-request-guardian-bridge.ts +38 -29
  429. package/src/runtime/effective-capabilities.test.ts +128 -0
  430. package/src/runtime/effective-capabilities.ts +84 -0
  431. package/src/runtime/guardian-reply-router.ts +106 -21
  432. package/src/runtime/invite-redemption-service.ts +9 -25
  433. package/src/runtime/migrations/__tests__/vbundle-builder-fd-leak.test.ts +123 -0
  434. package/src/runtime/migrations/vbundle-builder.ts +49 -20
  435. package/src/runtime/pending-interactions.ts +15 -0
  436. package/src/runtime/routes/__tests__/client-routes.test.ts +13 -0
  437. package/src/runtime/routes/__tests__/conversation-management-routes.test.ts +67 -0
  438. package/src/runtime/routes/__tests__/plugins-routes.test.ts +240 -1
  439. package/src/runtime/routes/app-routes.ts +1 -1
  440. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +2 -2
  441. package/src/runtime/routes/assets/vellum-design-system.css +1959 -0
  442. package/src/runtime/routes/browser-tabs-routes.ts +9 -0
  443. package/src/runtime/routes/btw-routes.ts +1 -27
  444. package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +17 -8
  445. package/src/runtime/routes/client-routes.ts +10 -0
  446. package/src/runtime/routes/contact-routes.ts +31 -8
  447. package/src/runtime/routes/conversation-compaction-routes.ts +1 -1
  448. package/src/runtime/routes/conversation-management-routes.ts +80 -1
  449. package/src/runtime/routes/conversation-query-routes.ts +68 -22
  450. package/src/runtime/routes/conversation-routes.ts +39 -14
  451. package/src/runtime/routes/credential-routes.ts +40 -16
  452. package/src/runtime/routes/empty-state-greeting-cache.ts +1 -2
  453. package/src/runtime/routes/events-routes.ts +1 -3
  454. package/src/runtime/routes/guardian-approval-interception.ts +14 -73
  455. package/src/runtime/routes/guardian-approval-prompt.ts +22 -4
  456. package/src/runtime/routes/home-feed-routes.ts +8 -3
  457. package/src/runtime/routes/identity-routes.ts +1 -296
  458. package/src/runtime/routes/inbound-message-handler.ts +214 -228
  459. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +89 -7
  460. package/src/runtime/routes/inbound-stages/admission-policy.test.ts +154 -0
  461. package/src/runtime/routes/inbound-stages/admission-policy.ts +140 -0
  462. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +3 -3
  463. package/src/runtime/routes/inbound-stages/background-dispatch.ts +11 -6
  464. package/src/runtime/routes/inbound-stages/escalation-intercept.ts +1 -2
  465. package/src/runtime/routes/inbound-stages/guardian-activation-intercept.ts +1 -2
  466. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.test.ts +7 -7
  467. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +47 -28
  468. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +358 -0
  469. package/src/runtime/routes/index.ts +2 -0
  470. package/src/runtime/routes/integrations/slack/__tests__/channel.test.ts +8 -0
  471. package/src/runtime/routes/integrations/slack/channel.ts +36 -0
  472. package/src/runtime/routes/internal-telemetry-routes.ts +1 -1
  473. package/src/runtime/routes/mcp-auth-routes.ts +233 -41
  474. package/src/runtime/routes/memory-eval-routes.ts +87 -0
  475. package/src/runtime/routes/notification-routes.ts +122 -133
  476. package/src/runtime/routes/platform-routes.ts +2 -2
  477. package/src/runtime/routes/plugins-routes.ts +202 -3
  478. package/src/runtime/routes/schedule-routes.ts +0 -22
  479. package/src/runtime/routes/secret-routes.ts +10 -0
  480. package/src/runtime/routes/surface-action-routes.ts +2 -1
  481. package/src/runtime/routes/tool-call-question-enrichment.test.ts +146 -0
  482. package/src/runtime/routes/tool-call-question-enrichment.ts +66 -0
  483. package/src/runtime/routes/workflow-routes.test.ts +229 -44
  484. package/src/runtime/routes/workflow-routes.ts +131 -29
  485. package/src/runtime/routes/workspace-greetings.ts +55 -0
  486. package/src/runtime/sync/resource-sync-events.ts +1 -11
  487. package/src/runtime/tool-grant-request-helper.ts +18 -16
  488. package/src/runtime/trust-context-resolver.ts +8 -5
  489. package/src/schedule/inference-profile.ts +2 -14
  490. package/src/schedule/schedule-store.ts +1 -1
  491. package/src/schedule/scheduler-types.ts +5 -1
  492. package/src/security/__tests__/provider-key-env-fallback.test.ts +6 -0
  493. package/src/security/secret-patterns.ts +3 -0
  494. package/src/subagent/manager.ts +17 -4
  495. package/src/subagent/types.ts +6 -0
  496. package/src/telemetry/trace-collection-policy.test.ts +28 -0
  497. package/src/telemetry/trace-collection-policy.ts +30 -0
  498. package/src/telemetry/types.ts +89 -0
  499. package/src/telemetry/usage-telemetry-reporter.test.ts +586 -36
  500. package/src/telemetry/usage-telemetry-reporter.ts +148 -41
  501. package/src/tools/AGENTS.md +3 -3
  502. package/src/tools/browser/__tests__/browser-execution-acquire.test.ts +31 -0
  503. package/src/tools/browser/browser-execution.ts +30 -19
  504. package/src/tools/document/document-tool.ts +2 -3
  505. package/src/tools/executor.ts +5 -3
  506. package/src/tools/host-terminal/host-shell.ts +5 -4
  507. package/src/tools/memory/register.ts +2 -2
  508. package/src/tools/network/__tests__/web-fetch-firecrawl.test.ts +360 -0
  509. package/src/tools/network/__tests__/web-search.test.ts +143 -0
  510. package/src/tools/network/web-fetch.ts +372 -1
  511. package/src/tools/network/web-search-error.ts +1 -1
  512. package/src/tools/network/web-search.ts +213 -10
  513. package/src/tools/permission-checker.ts +4 -3
  514. package/src/tools/registry.ts +20 -0
  515. package/src/tools/schedule/create.ts +7 -12
  516. package/src/tools/schedule/update.ts +4 -11
  517. package/src/tools/shared/filesystem/path-policy.ts +39 -13
  518. package/src/tools/side-effects.ts +2 -17
  519. package/src/tools/skills/execute.ts +33 -0
  520. package/src/tools/subagent/spawn.ts +61 -12
  521. package/src/tools/terminal/shell.ts +10 -4
  522. package/src/tools/tool-approval-handler.ts +18 -13
  523. package/src/tools/tool-manifest.ts +0 -2
  524. package/src/tools/types.ts +9 -0
  525. package/src/tools/ui-surface/definitions.ts +64 -3
  526. package/src/tools/verification-control-plane-policy.ts +3 -1
  527. package/src/tools/workflows/run-workflow.test.ts +8 -18
  528. package/src/tools/workflows/run-workflow.ts +1 -0
  529. package/src/util/disk-usage.ts +78 -23
  530. package/src/util/platform.ts +10 -3
  531. package/src/watcher/telemetry.ts +2 -2
  532. package/src/workflows/capabilities.ts +2 -3
  533. package/src/workflows/engine.test.ts +175 -1
  534. package/src/workflows/engine.ts +82 -0
  535. package/src/workflows/journal-store.test.ts +70 -0
  536. package/src/workflows/journal-store.ts +18 -3
  537. package/src/workflows/run-manager.test.ts +171 -28
  538. package/src/workflows/run-manager.ts +66 -24
  539. package/src/workspace/migrations/105-enable-memory-v3-live-for-new-workspaces.ts +63 -0
  540. package/src/workspace/migrations/106-drop-collect-usage-data.ts +47 -0
  541. package/src/workspace/migrations/107-drop-send-diagnostics.ts +47 -0
  542. package/src/workspace/migrations/108-drop-balanced-economy-profile.ts +129 -0
  543. package/src/workspace/migrations/registry.ts +8 -0
  544. package/src/__tests__/app-control-no-global-cgevent.test.ts +0 -98
  545. package/src/__tests__/credential-security-e2e.test.ts +0 -362
  546. package/src/__tests__/credential-vault-unit.test.ts +0 -1528
  547. package/src/__tests__/credential-vault.test.ts +0 -1706
  548. package/src/__tests__/identity-intro-cache.test.ts +0 -315
  549. package/src/__tests__/secret-onetime-send.test.ts +0 -182
  550. package/src/cli/commands/__tests__/task.test.ts +0 -914
  551. package/src/cli/commands/task.ts +0 -771
  552. package/src/config/bundled-skills/personal-page/SKILL.md +0 -57
  553. package/src/config/bundled-skills/personal-page/TOOLS.json +0 -27
  554. package/src/config/bundled-skills/personal-page/tools/app-refresh.ts +0 -17
  555. package/src/config/preloaded-apps/personal-page/src/components/About.tsx +0 -22
  556. package/src/config/preloaded-apps/personal-page/src/components/App.tsx +0 -16
  557. package/src/config/preloaded-apps/personal-page/src/components/Features.tsx +0 -77
  558. package/src/config/preloaded-apps/personal-page/src/components/Hero.tsx +0 -57
  559. package/src/config/preloaded-apps/personal-page/src/components/Pending.tsx +0 -28
  560. package/src/config/preloaded-apps/personal-page/src/components/animations.tsx +0 -234
  561. package/src/config/preloaded-apps/personal-page/src/components/icons.tsx +0 -48
  562. package/src/config/preloaded-apps/personal-page/src/components/media.ts +0 -16
  563. package/src/config/preloaded-apps/personal-page/src/index.html +0 -20
  564. package/src/config/preloaded-apps/personal-page/src/main.tsx +0 -7
  565. package/src/config/preloaded-apps/personal-page/src/profile-data.ts +0 -82
  566. package/src/config/preloaded-apps/personal-page/src/styles.css +0 -759
  567. package/src/memory/__tests__/preloaded-apps.test.ts +0 -85
  568. package/src/memory/preloaded-apps.ts +0 -116
  569. package/src/notifications/tool-approval-copy.ts +0 -142
  570. package/src/runtime/routes/approval-prompt-ts-tracker.ts +0 -78
  571. package/src/runtime/routes/identity-intro-cache.ts +0 -172
  572. package/src/tools/credentials/vault.ts +0 -712
@@ -1,1706 +0,0 @@
1
- import { randomBytes } from "node:crypto";
2
- import { mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
- import {
6
- afterAll,
7
- afterEach,
8
- beforeAll,
9
- beforeEach,
10
- describe,
11
- expect,
12
- mock,
13
- test,
14
- } from "bun:test";
15
-
16
- // ---------------------------------------------------------------------------
17
- // Mock logger
18
- // ---------------------------------------------------------------------------
19
-
20
- mock.module("../util/logger.js", () => ({
21
- getLogger: () =>
22
- new Proxy({} as Record<string, unknown>, {
23
- get: () => () => {},
24
- }),
25
- }));
26
-
27
- // ---------------------------------------------------------------------------
28
- // Use encrypted backend with a temp store path
29
- // ---------------------------------------------------------------------------
30
-
31
- import { _resetBackend } from "../security/secure-keys.js";
32
- import { setStorePathForTesting } from "./encrypted-store-test-helpers.js";
33
-
34
- const TEST_DIR = join(
35
- tmpdir(),
36
- `vellum-credvault-test-${randomBytes(4).toString("hex")}`,
37
- );
38
- const STORE_PATH = join(TEST_DIR, "keys.enc");
39
-
40
- // ---------------------------------------------------------------------------
41
- // Mock the registry so importing vault.ts doesn't fail on double-registration
42
- // ---------------------------------------------------------------------------
43
-
44
- mock.module("../tools/registry.js", () => ({
45
- registerTool: () => {},
46
- }));
47
-
48
- // ---------------------------------------------------------------------------
49
- // Mock OAuth2 token refresh for token-manager deduplication tests
50
- // ---------------------------------------------------------------------------
51
-
52
- let mockRefreshOAuth2Token: ReturnType<
53
- typeof mock<
54
- (
55
- tokenExchangeUrl: string,
56
- clientId: string,
57
- refreshToken: string,
58
- clientSecret?: string,
59
- tokenEndpointAuthMethod?: string,
60
- ) => Promise<{ accessToken: string; expiresIn: number }>
61
- >
62
- >;
63
-
64
- mock.module("../security/oauth2.js", () => {
65
- mockRefreshOAuth2Token = mock(() =>
66
- Promise.resolve({
67
- accessToken: "refreshed-access-token",
68
- expiresIn: 3600,
69
- }),
70
- );
71
- return {
72
- refreshOAuth2Token: mockRefreshOAuth2Token,
73
- };
74
- });
75
-
76
- // ---------------------------------------------------------------------------
77
- // Mock oauth-store — token-manager reads refresh config from SQLite
78
- // ---------------------------------------------------------------------------
79
-
80
- /** Mutable per-test map of provider connections for getConnectionByProvider */
81
- const mockConnections = new Map<
82
- string,
83
- {
84
- id: string;
85
- provider: string;
86
- oauthAppId: string;
87
- expiresAt: number | null;
88
- }
89
- >();
90
- const mockApps = new Map<
91
- string,
92
- {
93
- id: string;
94
- provider: string;
95
- clientId: string;
96
- clientSecretCredentialPath: string;
97
- }
98
- >();
99
- const mockProviders = new Map<
100
- string,
101
- {
102
- key: string;
103
- tokenExchangeUrl: string;
104
- refreshUrl?: string | null;
105
- tokenEndpointAuthMethod?: string;
106
- }
107
- >();
108
-
109
- let mockDisconnectOAuthProvider: ReturnType<
110
- typeof mock<
111
- (provider: string) => Promise<"disconnected" | "not-found" | "error">
112
- >
113
- >;
114
-
115
- mock.module("../oauth/oauth-store.js", () => {
116
- mockDisconnectOAuthProvider = mock((provider: string) =>
117
- Promise.resolve(
118
- mockConnections.has(provider)
119
- ? ("disconnected" as const)
120
- : ("not-found" as const),
121
- ),
122
- );
123
- return {
124
- disconnectOAuthProvider: mockDisconnectOAuthProvider,
125
- getConnectionByProvider: (service: string) => mockConnections.get(service),
126
- getConnection: (id: string) => {
127
- for (const conn of mockConnections.values()) {
128
- if (conn.id === id) return conn;
129
- }
130
- return undefined;
131
- },
132
- getApp: (id: string) => mockApps.get(id),
133
- getProvider: (key: string) => mockProviders.get(key),
134
- updateConnection: () => {},
135
- getMostRecentAppByProvider: () => undefined,
136
- listConnections: () => [],
137
- };
138
- });
139
-
140
- // ---------------------------------------------------------------------------
141
- // Import the module under test
142
- // ---------------------------------------------------------------------------
143
-
144
- // getCredentialValue is no longer exported (sealed in PR 17) — use getSecureKeyAsync directly
145
-
146
- import { credentialKey } from "../security/credential-key.js";
147
- import {
148
- deleteSecureKeyAsync,
149
- getSecureKeyAsync,
150
- setSecureKeyAsync,
151
- } from "../security/secure-keys.js";
152
- import {
153
- _resetInflightRefreshes,
154
- _resetRefreshBreakers,
155
- withValidToken,
156
- } from "../security/token-manager.js";
157
- import {
158
- _setMetadataPath,
159
- getCredentialMetadata,
160
- } from "../tools/credentials/metadata-store.js";
161
- import { credentialStoreTool } from "../tools/credentials/vault.js";
162
- import type { ToolContext } from "../tools/types.js";
163
-
164
- // Create a minimal context for tool execution
165
- const _ctx: ToolContext = {
166
- workingDir: "/tmp",
167
- conversationId: "test-conv",
168
- trustClass: "guardian",
169
- };
170
-
171
- // We'll manually instantiate the tool for testing
172
- // by reimporting the class behavior through the tool's execute method.
173
- // Since the tool registers itself, let's capture it.
174
- let _capturedTool: {
175
- execute(
176
- input: Record<string, unknown>,
177
- context: ToolContext,
178
- ): Promise<{ content: string; isError: boolean }>;
179
- };
180
-
181
- // Re-mock registry to capture the tool
182
- const { registerTool: _unused, ..._registryRest } =
183
- await import("../tools/registry.js");
184
-
185
- // We need to access the actual tool - let's create it directly
186
- // by re-using the module. Since vault.ts calls registerTool as a side-effect,
187
- // let's just use the secure-keys functions directly + test getCredentialValue.
188
- // For the tool execute tests, we'll create a simple wrapper that mimics the tool.
189
-
190
- async function executeVault(
191
- input: Record<string, unknown>,
192
- ): Promise<{ content: string; isError: boolean }> {
193
- const action = input.action as string;
194
-
195
- switch (action) {
196
- case "store": {
197
- const service = input.service as string | undefined;
198
- const field = input.field as string | undefined;
199
- const value = input.value as string | undefined;
200
-
201
- if (!service || typeof service !== "string") {
202
- return {
203
- content: "Error: service is required for store action",
204
- isError: true,
205
- };
206
- }
207
- if (!field || typeof field !== "string") {
208
- return {
209
- content: "Error: field is required for store action",
210
- isError: true,
211
- };
212
- }
213
- if (!value || typeof value !== "string") {
214
- return {
215
- content: "Error: value is required for store action",
216
- isError: true,
217
- };
218
- }
219
-
220
- const key = credentialKey(service, field);
221
- const ok = await setSecureKeyAsync(key, value);
222
- if (!ok) {
223
- return { content: "Error: failed to store credential", isError: true };
224
- }
225
- return {
226
- content: `Stored credential for ${service}/${field}.`,
227
- isError: false,
228
- };
229
- }
230
-
231
- case "list":
232
- return credentialStoreTool.execute({ action: "list" }, _ctx);
233
-
234
- case "delete": {
235
- const service = input.service as string | undefined;
236
- const field = input.field as string | undefined;
237
-
238
- if (!service || typeof service !== "string") {
239
- return {
240
- content: "Error: service is required for delete action",
241
- isError: true,
242
- };
243
- }
244
- if (!field || typeof field !== "string") {
245
- return {
246
- content: "Error: field is required for delete action",
247
- isError: true,
248
- };
249
- }
250
-
251
- const key = credentialKey(service, field);
252
- const result = await deleteSecureKeyAsync(key);
253
- if (result !== "deleted") {
254
- return {
255
- content: `Error: credential ${service}/${field} not found`,
256
- isError: true,
257
- };
258
- }
259
- return {
260
- content: `Deleted credential for ${service}/${field}.`,
261
- isError: false,
262
- };
263
- }
264
-
265
- default:
266
- return { content: `Error: unknown action "${action}"`, isError: true };
267
- }
268
- }
269
-
270
- afterAll(() => {
271
- mock.restore();
272
- });
273
-
274
- describe("credential_store tool", () => {
275
- beforeAll(() => {
276
- mkdirSync(TEST_DIR, { recursive: true });
277
- });
278
-
279
- beforeEach(() => {
280
- _resetBackend();
281
- // Clear content files but preserve the directory structure
282
- for (const entry of readdirSync(TEST_DIR)) {
283
- rmSync(join(TEST_DIR, entry), { recursive: true, force: true });
284
- }
285
- setStorePathForTesting(STORE_PATH);
286
- _setMetadataPath(join(TEST_DIR, "metadata.json"));
287
- mockDisconnectOAuthProvider.mockClear();
288
- mockConnections.clear();
289
- });
290
-
291
- afterEach(() => {
292
- _setMetadataPath(null);
293
- setStorePathForTesting(null);
294
- _resetBackend();
295
- mockConnections.clear();
296
- });
297
-
298
- afterAll(() => {
299
- rmSync(TEST_DIR, { recursive: true, force: true });
300
- });
301
-
302
- // -----------------------------------------------------------------------
303
- // Store
304
- // -----------------------------------------------------------------------
305
- describe("store action", () => {
306
- test("stores a credential and returns confirmation", async () => {
307
- const result = await executeVault({
308
- action: "store",
309
- service: "gmail",
310
- field: "password",
311
- value: "super-secret-123",
312
- });
313
- expect(result.isError).toBe(false);
314
- expect(result.content).toBe("Stored credential for gmail/password.");
315
- });
316
-
317
- test("stored value NEVER appears in tool output", async () => {
318
- const testValue = "my-ultra-test-value-xyz";
319
- const result = await executeVault({
320
- action: "store",
321
- service: "github",
322
- field: "token",
323
- value: testValue,
324
- });
325
- expect(result.content).not.toContain(testValue);
326
- });
327
-
328
- test("missing service returns error", async () => {
329
- const result = await executeVault({
330
- action: "store",
331
- field: "password",
332
- value: "val",
333
- });
334
- expect(result.isError).toBe(true);
335
- expect(result.content).toContain("service is required");
336
- });
337
-
338
- test("missing field returns error", async () => {
339
- const result = await executeVault({
340
- action: "store",
341
- service: "gmail",
342
- value: "val",
343
- });
344
- expect(result.isError).toBe(true);
345
- expect(result.content).toContain("field is required");
346
- });
347
-
348
- test("missing value returns error", async () => {
349
- const result = await executeVault({
350
- action: "store",
351
- service: "gmail",
352
- field: "password",
353
- });
354
- expect(result.isError).toBe(true);
355
- expect(result.content).toContain("value is required");
356
- });
357
-
358
- test("store success includes credential_id via credentialStoreTool", async () => {
359
- const result = await credentialStoreTool.execute(
360
- {
361
- action: "store",
362
- service: "test-cred-id",
363
- field: "api_key",
364
- value: "test-value",
365
- },
366
- _ctx,
367
- );
368
- expect(result.isError).toBe(false);
369
- expect(result.content).toContain("credential_id:");
370
- expect(result.content).toContain("test-cred-id/api_key");
371
- // Verify the credential_id in the output matches the metadata
372
- const metadata = getCredentialMetadata("test-cred-id", "api_key");
373
- expect(metadata).toBeDefined();
374
- expect(result.content).toContain(metadata!.credentialId);
375
- });
376
- });
377
-
378
- // -----------------------------------------------------------------------
379
- // List
380
- // -----------------------------------------------------------------------
381
- describe("list action", () => {
382
- test("lists stored credentials with credential_id, service, field", async () => {
383
- await credentialStoreTool.execute(
384
- {
385
- action: "store",
386
- service: "gmail",
387
- field: "password",
388
- value: "secret1",
389
- },
390
- _ctx,
391
- );
392
- await credentialStoreTool.execute(
393
- {
394
- action: "store",
395
- service: "github",
396
- field: "token",
397
- value: "secret2",
398
- },
399
- _ctx,
400
- );
401
-
402
- const result = await credentialStoreTool.execute(
403
- { action: "list" },
404
- _ctx,
405
- );
406
- expect(result.isError).toBe(false);
407
-
408
- const entries = JSON.parse(result.content);
409
- expect(entries).toHaveLength(2);
410
-
411
- const services = entries
412
- .map((e: { service: string }) => e.service)
413
- .sort();
414
- expect(services).toEqual(["github", "gmail"]);
415
-
416
- // Each entry must have credential_id, service, field
417
- for (const entry of entries) {
418
- expect(typeof entry.credential_id).toBe("string");
419
- expect(entry.credential_id.length).toBeGreaterThan(0);
420
- expect(typeof entry.service).toBe("string");
421
- expect(typeof entry.field).toBe("string");
422
- }
423
-
424
- // Values must NOT appear in the output
425
- expect(result.content).not.toContain("secret1");
426
- expect(result.content).not.toContain("secret2");
427
- });
428
-
429
- test("list output includes alias when set", async () => {
430
- await credentialStoreTool.execute(
431
- {
432
- action: "store",
433
- service: "fal",
434
- field: "api_key",
435
- value: "fal-secret",
436
- alias: "fal-primary",
437
- },
438
- _ctx,
439
- );
440
-
441
- const result = await credentialStoreTool.execute(
442
- { action: "list" },
443
- _ctx,
444
- );
445
- const entries = JSON.parse(result.content);
446
- const entry = entries.find(
447
- (e: { service: string }) => e.service === "fal",
448
- );
449
- expect(entry).toBeDefined();
450
- expect(entry.alias).toBe("fal-primary");
451
- });
452
-
453
- test("list output includes template summary with host patterns", async () => {
454
- await credentialStoreTool.execute(
455
- {
456
- action: "store",
457
- service: "fal",
458
- field: "api_key",
459
- value: "fal-secret",
460
- injection_templates: [
461
- {
462
- hostPattern: "*.fal.ai",
463
- injectionType: "header",
464
- headerName: "Authorization",
465
- valuePrefix: "Key ",
466
- },
467
- {
468
- hostPattern: "gateway.fal.ai",
469
- injectionType: "header",
470
- headerName: "X-Key",
471
- },
472
- ],
473
- },
474
- _ctx,
475
- );
476
-
477
- const result = await credentialStoreTool.execute(
478
- { action: "list" },
479
- _ctx,
480
- );
481
- const entries = JSON.parse(result.content);
482
- const entry = entries.find(
483
- (e: { service: string }) => e.service === "fal",
484
- );
485
- expect(entry).toBeDefined();
486
- expect(entry.injection_templates).toBeDefined();
487
- expect(entry.injection_templates.count).toBe(2);
488
- expect(entry.injection_templates.host_patterns).toEqual([
489
- "*.fal.ai",
490
- "gateway.fal.ai",
491
- ]);
492
- });
493
-
494
- test("list does not include credential values", async () => {
495
- const testValue = "test-dummy-value-for-list";
496
- await credentialStoreTool.execute(
497
- {
498
- action: "store",
499
- service: "test",
500
- field: "key",
501
- value: testValue,
502
- },
503
- _ctx,
504
- );
505
-
506
- const result = await credentialStoreTool.execute(
507
- { action: "list" },
508
- _ctx,
509
- );
510
- expect(result.content).not.toContain(testValue);
511
- // Also verify no allowedTools/allowedDomains leak into list output
512
- const entries = JSON.parse(result.content);
513
- for (const entry of entries) {
514
- expect(entry.allowedTools).toBeUndefined();
515
- expect(entry.allowedDomains).toBeUndefined();
516
- expect(entry.usageDescription).toBeUndefined();
517
- expect(entry.value).toBeUndefined();
518
- }
519
- });
520
-
521
- test("returns empty array when no credentials exist", async () => {
522
- const result = await credentialStoreTool.execute(
523
- { action: "list" },
524
- _ctx,
525
- );
526
- expect(result.isError).toBe(false);
527
- expect(JSON.parse(result.content)).toEqual([]);
528
- });
529
-
530
- test("lists multiple credentials", async () => {
531
- await credentialStoreTool.execute(
532
- {
533
- action: "store",
534
- service: "gmail",
535
- field: "password",
536
- value: "s1",
537
- },
538
- _ctx,
539
- );
540
- await credentialStoreTool.execute(
541
- {
542
- action: "store",
543
- service: "github",
544
- field: "token",
545
- value: "s2",
546
- alias: "gh-main",
547
- },
548
- _ctx,
549
- );
550
- await credentialStoreTool.execute(
551
- {
552
- action: "store",
553
- service: "fal",
554
- field: "api_key",
555
- value: "s3",
556
- alias: "fal-primary",
557
- injection_templates: [
558
- {
559
- hostPattern: "*.fal.ai",
560
- injectionType: "header",
561
- headerName: "Authorization",
562
- },
563
- ],
564
- },
565
- _ctx,
566
- );
567
-
568
- const result = await credentialStoreTool.execute(
569
- { action: "list" },
570
- _ctx,
571
- );
572
- const entries = JSON.parse(result.content);
573
- expect(entries).toHaveLength(3);
574
-
575
- const fal = entries.find((e: { service: string }) => e.service === "fal");
576
- expect(fal.alias).toBe("fal-primary");
577
- expect(fal.injection_templates.count).toBe(1);
578
-
579
- const gh = entries.find(
580
- (e: { service: string }) => e.service === "github",
581
- );
582
- expect(gh.alias).toBe("gh-main");
583
- expect(gh.injection_templates).toBeUndefined();
584
-
585
- const gmail = entries.find(
586
- (e: { service: string }) => e.service === "gmail",
587
- );
588
- expect(gmail.alias).toBeUndefined();
589
- expect(gmail.injection_templates).toBeUndefined();
590
- });
591
-
592
- test("works with metadata store fallback when listing secrets", async () => {
593
- // Store a credential first (on encrypted backend)
594
- await credentialStoreTool.execute(
595
- {
596
- action: "store",
597
- service: "keychain-test",
598
- field: "token",
599
- value: "kc-secret",
600
- },
601
- _ctx,
602
- );
603
-
604
- const result = await credentialStoreTool.execute(
605
- { action: "list" },
606
- _ctx,
607
- );
608
- expect(result.isError).toBe(false);
609
- const entries = JSON.parse(result.content);
610
- expect(entries).toHaveLength(1);
611
- expect(entries[0].service).toBe("keychain-test");
612
- expect(entries[0].field).toBe("token");
613
- expect(typeof entries[0].credential_id).toBe("string");
614
- });
615
-
616
- test("returns error when metadata file has unrecognized version", async () => {
617
- // Write a metadata file with a future version that the current code cannot handle
618
- const metadataPath = join(TEST_DIR, "metadata.json");
619
- writeFileSync(
620
- metadataPath,
621
- JSON.stringify({ version: 999, credentials: [] }),
622
- "utf-8",
623
- );
624
-
625
- const result = await credentialStoreTool.execute(
626
- { action: "list" },
627
- _ctx,
628
- );
629
- expect(result.isError).toBe(true);
630
- expect(result.content).toContain("unrecognized version");
631
- });
632
-
633
- test("excludes metadata entries whose secret was deleted from secure storage", async () => {
634
- // Store two credentials so both metadata and secrets exist
635
- await credentialStoreTool.execute(
636
- {
637
- action: "store",
638
- service: "svc-a",
639
- field: "key",
640
- value: "val-a",
641
- },
642
- _ctx,
643
- );
644
- await credentialStoreTool.execute(
645
- {
646
- action: "store",
647
- service: "svc-b",
648
- field: "key",
649
- value: "val-b",
650
- },
651
- _ctx,
652
- );
653
-
654
- // Delete the secret directly without going through the tool (simulates
655
- // a divergence where metadata write failed after secret deletion)
656
- await deleteSecureKeyAsync(credentialKey("svc-a", "key"));
657
-
658
- const result = await credentialStoreTool.execute(
659
- { action: "list" },
660
- _ctx,
661
- );
662
- expect(result.isError).toBe(false);
663
- const entries = JSON.parse(result.content);
664
- // svc-a's secret is gone, so it should be excluded even though metadata exists
665
- expect(entries).toHaveLength(1);
666
- expect(entries[0].service).toBe("svc-b");
667
- });
668
-
669
- test("recovers from corrupt secure storage by resetting and returning empty list", async () => {
670
- // Store a credential so metadata exists
671
- await credentialStoreTool.execute(
672
- {
673
- action: "store",
674
- service: "svc-x",
675
- field: "key",
676
- value: "val-x",
677
- },
678
- _ctx,
679
- );
680
-
681
- // Corrupt the encrypted store file — the store auto-recovers by
682
- // backing up the corrupt file and creating a fresh store
683
- writeFileSync(STORE_PATH, "not-valid-json!!!", "utf-8");
684
-
685
- const result = await credentialStoreTool.execute(
686
- { action: "list" },
687
- _ctx,
688
- );
689
- // Store auto-recovers: list succeeds but the corrupted credentials are lost
690
- expect(result.isError).toBe(false);
691
- });
692
- });
693
-
694
- // -----------------------------------------------------------------------
695
- // Delete
696
- // -----------------------------------------------------------------------
697
- describe("delete action", () => {
698
- test("deletes a stored credential", async () => {
699
- await setSecureKeyAsync(credentialKey("gmail", "password"), "secret");
700
-
701
- const result = await executeVault({
702
- action: "delete",
703
- service: "gmail",
704
- field: "password",
705
- });
706
- expect(result.isError).toBe(false);
707
- expect(result.content).toBe("Deleted credential for gmail/password.");
708
-
709
- // Verify it's actually gone
710
- expect(
711
- await getSecureKeyAsync(credentialKey("gmail", "password")),
712
- ).toBeUndefined();
713
- });
714
-
715
- test("returns error for non-existent credential", async () => {
716
- const result = await executeVault({
717
- action: "delete",
718
- service: "nonexistent",
719
- field: "field",
720
- });
721
- expect(result.isError).toBe(true);
722
- expect(result.content).toContain("not found");
723
- });
724
-
725
- test("missing service returns error", async () => {
726
- const result = await executeVault({
727
- action: "delete",
728
- field: "password",
729
- });
730
- expect(result.isError).toBe(true);
731
- expect(result.content).toContain("service is required");
732
- });
733
-
734
- test("missing field returns error", async () => {
735
- const result = await executeVault({
736
- action: "delete",
737
- service: "gmail",
738
- });
739
- expect(result.isError).toBe(true);
740
- expect(result.content).toContain("field is required");
741
- });
742
-
743
- test("delete also disconnects OAuth connection for the service", async () => {
744
- // Store a credential via the real tool so metadata exists
745
- await credentialStoreTool.execute(
746
- {
747
- action: "store",
748
- service: "google",
749
- field: "api_key",
750
- value: "test-value",
751
- },
752
- _ctx,
753
- );
754
-
755
- // Simulate an active OAuth connection for this service
756
- mockConnections.set("google", {
757
- id: "conn-gmail",
758
- provider: "google",
759
- oauthAppId: "app-gmail",
760
- expiresAt: Date.now() + 3600_000,
761
- });
762
-
763
- const result = await credentialStoreTool.execute(
764
- {
765
- action: "delete",
766
- service: "google",
767
- field: "api_key",
768
- },
769
- _ctx,
770
- );
771
-
772
- expect(result.isError).toBe(false);
773
- expect(result.content).toContain("Deleted credential");
774
- // Verify disconnectOAuthProvider was called with the service name
775
- expect(mockDisconnectOAuthProvider).toHaveBeenCalledTimes(1);
776
- expect(mockDisconnectOAuthProvider).toHaveBeenCalledWith("google");
777
- });
778
- });
779
-
780
- // -----------------------------------------------------------------------
781
- // Credential value access (sealed — only via secure-keys internally)
782
- // -----------------------------------------------------------------------
783
- describe("credential value access", () => {
784
- test("credential values are stored via secure keys", async () => {
785
- await setSecureKeyAsync(credentialKey("github", "token"), "ghp_abc123");
786
- expect(await getSecureKeyAsync(credentialKey("github", "token"))).toBe(
787
- "ghp_abc123",
788
- );
789
- });
790
-
791
- test("returns undefined for non-existent credential", async () => {
792
- expect(
793
- await getSecureKeyAsync(credentialKey("nonexistent", "field")),
794
- ).toBeUndefined();
795
- });
796
- });
797
-
798
- // -----------------------------------------------------------------------
799
- // Hardening verification — getCredentialValue is no longer exported
800
- // -----------------------------------------------------------------------
801
- describe("hardening verification", () => {
802
- test("vault module does not export getCredentialValue", async () => {
803
- const vaultModule = await import("../tools/credentials/vault.js");
804
- expect("getCredentialValue" in vaultModule).toBe(false);
805
- });
806
-
807
- test("store with policy fields persists metadata", async () => {
808
- const result = await credentialStoreTool.execute(
809
- {
810
- action: "store",
811
- service: "github",
812
- field: "token",
813
- value: "ghp_secret",
814
- allowed_tools: ["browser_fill_credential"],
815
- allowed_domains: ["github.com"],
816
- usage_description: "GitHub login",
817
- },
818
- _ctx,
819
- );
820
- expect(result.isError).toBe(false);
821
- const metadata = getCredentialMetadata("github", "token");
822
- expect(metadata).toBeDefined();
823
- expect(metadata!.allowedTools).toEqual(["browser_fill_credential"]);
824
- expect(metadata!.allowedDomains).toEqual(["github.com"]);
825
- expect(metadata!.usageDescription).toBe("GitHub login");
826
- });
827
-
828
- test("store without policy fields defaults to empty arrays", async () => {
829
- const result = await credentialStoreTool.execute(
830
- {
831
- action: "store",
832
- service: "slack",
833
- field: "token",
834
- value: "xoxb-secret",
835
- },
836
- _ctx,
837
- );
838
- expect(result.isError).toBe(false);
839
- const metadata = getCredentialMetadata("slack", "token");
840
- expect(metadata).toBeDefined();
841
- expect(metadata!.allowedTools).toEqual([]);
842
- expect(metadata!.allowedDomains).toEqual([]);
843
- });
844
-
845
- test("store rotation without policy fields preserves the existing policy", async () => {
846
- /**
847
- * Re-storing (rotating) a credential without policy fields must keep the
848
- * policy it was originally stored with, rather than resetting it to a
849
- * deny-all empty policy.
850
- */
851
- // GIVEN a credential stored with an allowed-tools/-domains policy
852
- await credentialStoreTool.execute(
853
- {
854
- action: "store",
855
- service: "rotation-svc",
856
- field: "token",
857
- value: "v1",
858
- allowed_tools: ["browser_fill_credential"],
859
- allowed_domains: ["example.com"],
860
- },
861
- _ctx,
862
- );
863
-
864
- // WHEN it is re-stored with a new value but no policy fields
865
- const result = await credentialStoreTool.execute(
866
- {
867
- action: "store",
868
- service: "rotation-svc",
869
- field: "token",
870
- value: "v2",
871
- },
872
- _ctx,
873
- );
874
-
875
- // THEN the original policy is preserved
876
- expect(result.isError).toBe(false);
877
- const metadata = getCredentialMetadata("rotation-svc", "token");
878
- expect(metadata!.allowedTools).toEqual(["browser_fill_credential"]);
879
- expect(metadata!.allowedDomains).toEqual(["example.com"]);
880
- });
881
-
882
- test("store rejects invalid policy input", async () => {
883
- const result = await credentialStoreTool.execute(
884
- {
885
- action: "store",
886
- service: "test",
887
- field: "token",
888
- value: "val",
889
- allowed_tools: "not-an-array",
890
- },
891
- _ctx,
892
- );
893
- expect(result.isError).toBe(true);
894
- expect(result.content).toContain("allowed_tools must be an array");
895
- });
896
-
897
- test("list action entries do not expose policy metadata", async () => {
898
- await credentialStoreTool.execute(
899
- {
900
- action: "store",
901
- service: "myservice",
902
- field: "myfield",
903
- value: "secret-val",
904
- allowed_tools: ["browser_fill_credential"],
905
- allowed_domains: ["example.com"],
906
- usage_description: "Test usage",
907
- },
908
- _ctx,
909
- );
910
-
911
- const result = await credentialStoreTool.execute(
912
- { action: "list" },
913
- _ctx,
914
- );
915
- const entries = JSON.parse(result.content);
916
- const entry = entries.find(
917
- (e: { service: string; field: string }) =>
918
- e.service === "myservice" && e.field === "myfield",
919
- );
920
- expect(entry).toBeDefined();
921
- // List entries expose credential_id, service, field (and optionally alias,
922
- // injection_templates) — never policy details.
923
- expect(entry.allowedTools).toBeUndefined();
924
- expect(entry.allowedDomains).toBeUndefined();
925
- expect(entry.usageDescription).toBeUndefined();
926
- expect(entry.createdAt).toBeUndefined();
927
- expect(entry.updatedAt).toBeUndefined();
928
- });
929
- });
930
-
931
- // -----------------------------------------------------------------------
932
- // Alias and injection template fields
933
- // -----------------------------------------------------------------------
934
- describe("alias and injection template fields", () => {
935
- test("store with valid alias and templates persists metadata", async () => {
936
- const result = await credentialStoreTool.execute(
937
- {
938
- action: "store",
939
- service: "fal",
940
- field: "api_key",
941
- value: "fal-key-123",
942
- alias: "fal-primary",
943
- injection_templates: [
944
- {
945
- hostPattern: "*.fal.ai",
946
- injectionType: "header",
947
- headerName: "Authorization",
948
- valuePrefix: "Key ",
949
- },
950
- ],
951
- },
952
- _ctx,
953
- );
954
- expect(result.isError).toBe(false);
955
- const metadata = getCredentialMetadata("fal", "api_key");
956
- expect(metadata).toBeDefined();
957
- expect(metadata!.alias).toBe("fal-primary");
958
- expect(metadata!.injectionTemplates).toHaveLength(1);
959
- expect(metadata!.injectionTemplates![0].hostPattern).toBe("*.fal.ai");
960
- expect(metadata!.injectionTemplates![0].injectionType).toBe("header");
961
- expect(metadata!.injectionTemplates![0].headerName).toBe("Authorization");
962
- expect(metadata!.injectionTemplates![0].valuePrefix).toBe("Key ");
963
- });
964
-
965
- test("store with alias only (no templates)", async () => {
966
- const result = await credentialStoreTool.execute(
967
- {
968
- action: "store",
969
- service: "openai",
970
- field: "api_key",
971
- value: "sk-test",
972
- alias: "openai-main",
973
- },
974
- _ctx,
975
- );
976
- expect(result.isError).toBe(false);
977
- const metadata = getCredentialMetadata("openai", "api_key");
978
- expect(metadata).toBeDefined();
979
- expect(metadata!.alias).toBe("openai-main");
980
- expect(metadata!.injectionTemplates).toBeUndefined();
981
- });
982
-
983
- test("store with templates only (no alias)", async () => {
984
- const result = await credentialStoreTool.execute(
985
- {
986
- action: "store",
987
- service: "replicate",
988
- field: "token",
989
- value: "r8_test",
990
- injection_templates: [
991
- {
992
- hostPattern: "api.replicate.com",
993
- injectionType: "header",
994
- headerName: "Authorization",
995
- valuePrefix: "Bearer ",
996
- },
997
- ],
998
- },
999
- _ctx,
1000
- );
1001
- expect(result.isError).toBe(false);
1002
- const metadata = getCredentialMetadata("replicate", "token");
1003
- expect(metadata).toBeDefined();
1004
- expect(metadata!.alias).toBeUndefined();
1005
- expect(metadata!.injectionTemplates).toHaveLength(1);
1006
- expect(metadata!.injectionTemplates![0].injectionType).toBe("header");
1007
- });
1008
-
1009
- test("rejects template missing headerName for header type", async () => {
1010
- const result = await credentialStoreTool.execute(
1011
- {
1012
- action: "store",
1013
- service: "fal",
1014
- field: "api_key",
1015
- value: "fal-key-123",
1016
- injection_templates: [
1017
- {
1018
- hostPattern: "*.fal.ai",
1019
- injectionType: "header",
1020
- // missing headerName
1021
- },
1022
- ],
1023
- },
1024
- _ctx,
1025
- );
1026
- expect(result.isError).toBe(true);
1027
- expect(result.content).toContain("headerName is required");
1028
- });
1029
-
1030
- test("rejects template missing queryParamName for query type", async () => {
1031
- const result = await credentialStoreTool.execute(
1032
- {
1033
- action: "store",
1034
- service: "mapbox",
1035
- field: "token",
1036
- value: "pk.test",
1037
- injection_templates: [
1038
- {
1039
- hostPattern: "api.mapbox.com",
1040
- injectionType: "query",
1041
- // missing queryParamName
1042
- },
1043
- ],
1044
- },
1045
- _ctx,
1046
- );
1047
- expect(result.isError).toBe(true);
1048
- expect(result.content).toContain("queryParamName is required");
1049
- });
1050
-
1051
- test("round-trip: store then list shows the credential", async () => {
1052
- await credentialStoreTool.execute(
1053
- {
1054
- action: "store",
1055
- service: "anthropic",
1056
- field: "api_key",
1057
- value: "sk-ant-test",
1058
- alias: "claude-key",
1059
- injection_templates: [
1060
- {
1061
- hostPattern: "api.anthropic.com",
1062
- injectionType: "header",
1063
- headerName: "x-api-key",
1064
- },
1065
- ],
1066
- },
1067
- _ctx,
1068
- );
1069
-
1070
- const listResult = await credentialStoreTool.execute(
1071
- { action: "list" },
1072
- _ctx,
1073
- );
1074
- expect(listResult.isError).toBe(false);
1075
- const entries = JSON.parse(listResult.content);
1076
- const entry = entries.find(
1077
- (e: { service: string; field: string }) =>
1078
- e.service === "anthropic" && e.field === "api_key",
1079
- );
1080
- expect(entry).toBeDefined();
1081
-
1082
- // Verify metadata persisted correctly
1083
- const metadata = getCredentialMetadata("anthropic", "api_key");
1084
- expect(metadata).toBeDefined();
1085
- expect(metadata!.alias).toBe("claude-key");
1086
- expect(metadata!.injectionTemplates).toHaveLength(1);
1087
- });
1088
-
1089
- test("update alias on existing credential", async () => {
1090
- await credentialStoreTool.execute(
1091
- {
1092
- action: "store",
1093
- service: "fal",
1094
- field: "api_key",
1095
- value: "fal-key-123",
1096
- alias: "fal-old",
1097
- },
1098
- _ctx,
1099
- );
1100
-
1101
- let metadata = getCredentialMetadata("fal", "api_key");
1102
- expect(metadata!.alias).toBe("fal-old");
1103
-
1104
- // Re-store same credential with updated alias
1105
- await credentialStoreTool.execute(
1106
- {
1107
- action: "store",
1108
- service: "fal",
1109
- field: "api_key",
1110
- value: "fal-key-123",
1111
- alias: "fal-new",
1112
- },
1113
- _ctx,
1114
- );
1115
-
1116
- metadata = getCredentialMetadata("fal", "api_key");
1117
- expect(metadata!.alias).toBe("fal-new");
1118
- });
1119
-
1120
- test("store with query injection template", async () => {
1121
- const result = await credentialStoreTool.execute(
1122
- {
1123
- action: "store",
1124
- service: "mapbox",
1125
- field: "token",
1126
- value: "pk.test123",
1127
- injection_templates: [
1128
- {
1129
- hostPattern: "api.mapbox.com",
1130
- injectionType: "query",
1131
- queryParamName: "access_token",
1132
- },
1133
- ],
1134
- },
1135
- _ctx,
1136
- );
1137
- expect(result.isError).toBe(false);
1138
- const metadata = getCredentialMetadata("mapbox", "token");
1139
- expect(metadata!.injectionTemplates).toHaveLength(1);
1140
- expect(metadata!.injectionTemplates![0].injectionType).toBe("query");
1141
- expect(metadata!.injectionTemplates![0].queryParamName).toBe(
1142
- "access_token",
1143
- );
1144
- });
1145
- });
1146
-
1147
- // -----------------------------------------------------------------------
1148
- // Multi-key same-service vault storage
1149
- // -----------------------------------------------------------------------
1150
- describe("multi-key same-service storage", () => {
1151
- test("stores two credentials with same service but different aliases", async () => {
1152
- const result1 = await credentialStoreTool.execute(
1153
- {
1154
- action: "store",
1155
- service: "openai",
1156
- field: "api_key_prod",
1157
- value: "sk-prod-abc",
1158
- alias: "production",
1159
- },
1160
- _ctx,
1161
- );
1162
- expect(result1.isError).toBe(false);
1163
-
1164
- const result2 = await credentialStoreTool.execute(
1165
- {
1166
- action: "store",
1167
- service: "openai",
1168
- field: "api_key_staging",
1169
- value: "sk-staging-xyz",
1170
- alias: "staging",
1171
- },
1172
- _ctx,
1173
- );
1174
- expect(result2.isError).toBe(false);
1175
-
1176
- // Verify both stored independently in metadata
1177
- const meta1 = getCredentialMetadata("openai", "api_key_prod");
1178
- const meta2 = getCredentialMetadata("openai", "api_key_staging");
1179
- expect(meta1).toBeDefined();
1180
- expect(meta2).toBeDefined();
1181
- expect(meta1!.alias).toBe("production");
1182
- expect(meta2!.alias).toBe("staging");
1183
- });
1184
-
1185
- test("listing shows both same-service credentials independently", async () => {
1186
- await credentialStoreTool.execute(
1187
- {
1188
- action: "store",
1189
- service: "openai",
1190
- field: "api_key_prod",
1191
- value: "sk-prod-abc",
1192
- alias: "production",
1193
- },
1194
- _ctx,
1195
- );
1196
- await credentialStoreTool.execute(
1197
- {
1198
- action: "store",
1199
- service: "openai",
1200
- field: "api_key_staging",
1201
- value: "sk-staging-xyz",
1202
- alias: "staging",
1203
- },
1204
- _ctx,
1205
- );
1206
-
1207
- const result = await credentialStoreTool.execute(
1208
- { action: "list" },
1209
- _ctx,
1210
- );
1211
- expect(result.isError).toBe(false);
1212
-
1213
- const entries = JSON.parse(result.content);
1214
- const openaiEntries = entries.filter(
1215
- (e: { service: string }) => e.service === "openai",
1216
- );
1217
- expect(openaiEntries).toHaveLength(2);
1218
-
1219
- const aliases = openaiEntries
1220
- .map((e: { alias?: string }) => e.alias)
1221
- .sort();
1222
- expect(aliases).toEqual(["production", "staging"]);
1223
- });
1224
-
1225
- test("each same-service credential has its own credential_id", async () => {
1226
- await credentialStoreTool.execute(
1227
- {
1228
- action: "store",
1229
- service: "openai",
1230
- field: "api_key_prod",
1231
- value: "sk-prod-abc",
1232
- alias: "production",
1233
- },
1234
- _ctx,
1235
- );
1236
- await credentialStoreTool.execute(
1237
- {
1238
- action: "store",
1239
- service: "openai",
1240
- field: "api_key_staging",
1241
- value: "sk-staging-xyz",
1242
- alias: "staging",
1243
- },
1244
- _ctx,
1245
- );
1246
-
1247
- const meta1 = getCredentialMetadata("openai", "api_key_prod");
1248
- const meta2 = getCredentialMetadata("openai", "api_key_staging");
1249
- expect(meta1).toBeDefined();
1250
- expect(meta2).toBeDefined();
1251
- expect(meta1!.credentialId).not.toBe(meta2!.credentialId);
1252
- // Both should be valid UUIDs (non-empty strings)
1253
- expect(meta1!.credentialId.length).toBeGreaterThan(0);
1254
- expect(meta2!.credentialId.length).toBeGreaterThan(0);
1255
- });
1256
- });
1257
-
1258
- // -----------------------------------------------------------------------
1259
- // Namespace isolation
1260
- // -----------------------------------------------------------------------
1261
- describe("namespace isolation", () => {
1262
- test("different services with same field do not collide", async () => {
1263
- await executeVault({
1264
- action: "store",
1265
- service: "gmail",
1266
- field: "password",
1267
- value: "gmail-pass",
1268
- });
1269
- await executeVault({
1270
- action: "store",
1271
- service: "github",
1272
- field: "password",
1273
- value: "github-pass",
1274
- });
1275
-
1276
- expect(await getSecureKeyAsync(credentialKey("gmail", "password"))).toBe(
1277
- "gmail-pass",
1278
- );
1279
- expect(await getSecureKeyAsync(credentialKey("github", "password"))).toBe(
1280
- "github-pass",
1281
- );
1282
- });
1283
-
1284
- test("same service with different fields do not collide", async () => {
1285
- await executeVault({
1286
- action: "store",
1287
- service: "gmail",
1288
- field: "password",
1289
- value: "pass123",
1290
- });
1291
- await executeVault({
1292
- action: "store",
1293
- service: "gmail",
1294
- field: "recovery_email",
1295
- value: "backup@example.com",
1296
- });
1297
-
1298
- expect(await getSecureKeyAsync(credentialKey("gmail", "password"))).toBe(
1299
- "pass123",
1300
- );
1301
- expect(
1302
- await getSecureKeyAsync(credentialKey("gmail", "recovery_email")),
1303
- ).toBe("backup@example.com");
1304
- });
1305
- });
1306
- });
1307
-
1308
- // ---------------------------------------------------------------------------
1309
- // Token refresh deduplication tests
1310
- // ---------------------------------------------------------------------------
1311
-
1312
- describe("withValidToken refresh deduplication", () => {
1313
- beforeAll(() => {
1314
- mkdirSync(TEST_DIR, { recursive: true });
1315
- });
1316
-
1317
- beforeEach(() => {
1318
- _resetBackend();
1319
- for (const entry of readdirSync(TEST_DIR)) {
1320
- rmSync(join(TEST_DIR, entry), { recursive: true, force: true });
1321
- }
1322
- setStorePathForTesting(STORE_PATH);
1323
- _setMetadataPath(join(TEST_DIR, "metadata.json"));
1324
- _resetRefreshBreakers();
1325
- _resetInflightRefreshes();
1326
- mockRefreshOAuth2Token.mockClear();
1327
- // Clear mock oauth-store maps
1328
- mockConnections.clear();
1329
- mockApps.clear();
1330
- mockProviders.clear();
1331
- });
1332
-
1333
- afterEach(() => {
1334
- _setMetadataPath(null);
1335
- setStorePathForTesting(null);
1336
- _resetBackend();
1337
- _resetRefreshBreakers();
1338
- _resetInflightRefreshes();
1339
- mockConnections.clear();
1340
- mockApps.clear();
1341
- mockProviders.clear();
1342
- });
1343
-
1344
- afterAll(() => {
1345
- rmSync(TEST_DIR, { recursive: true, force: true });
1346
- });
1347
-
1348
- /**
1349
- * Helper: set up a service with an access token, refresh token, and
1350
- * mock DB data so that token refresh can proceed through doRefresh().
1351
- *
1352
- * OAuth-specific fields (tokenExchangeUrl, clientId, expiresAt) are now stored
1353
- * in the SQLite oauth-store. The mock maps simulate the DB layer.
1354
- */
1355
- async function setupService(
1356
- service: string,
1357
- opts?: { expired?: boolean; accessToken?: string },
1358
- ) {
1359
- const accessToken = opts?.accessToken ?? "old-access-token";
1360
-
1361
- // Seed mock oauth-store maps so token-manager can resolve refresh config
1362
- const appId = `app-${service}`;
1363
- const connId = `conn-${service}`;
1364
-
1365
- // Store access token under the oauth_connection key path that
1366
- // withValidToken reads (not the legacy credentialKey path).
1367
- await setSecureKeyAsync(
1368
- `oauth_connection/${connId}/access_token`,
1369
- accessToken,
1370
- );
1371
- mockProviders.set(service, {
1372
- key: service,
1373
- tokenExchangeUrl: "https://oauth.example.com/token",
1374
- refreshUrl: null,
1375
- });
1376
- mockApps.set(appId, {
1377
- id: appId,
1378
- provider: service,
1379
- clientId: "test-client-id",
1380
- clientSecretCredentialPath: `oauth_app/${appId}/client_secret`,
1381
- });
1382
- mockConnections.set(service, {
1383
- id: connId,
1384
- provider: service,
1385
- oauthAppId: appId,
1386
- expiresAt: opts?.expired
1387
- ? Date.now() - 60_000 // expired 1 minute ago
1388
- : Date.now() + 3600_000, // expires in 1 hour
1389
- });
1390
- // Store refresh token and client_secret in secure keys (token-manager reads them)
1391
- await setSecureKeyAsync(
1392
- `oauth_connection/${connId}/refresh_token`,
1393
- "valid-refresh-token",
1394
- );
1395
- await setSecureKeyAsync(
1396
- `oauth_app/${appId}/client_secret`,
1397
- "test-client-secret",
1398
- );
1399
- }
1400
-
1401
- test("3 concurrent 401 refreshes for the same service call doRefresh exactly once", async () => {
1402
- await setupService("google");
1403
-
1404
- let resolveRefresh!: (value: {
1405
- accessToken: string;
1406
- expiresIn: number;
1407
- }) => void;
1408
- const refreshPromise = new Promise<{
1409
- accessToken: string;
1410
- expiresIn: number;
1411
- }>((resolve) => {
1412
- resolveRefresh = resolve;
1413
- });
1414
-
1415
- mockRefreshOAuth2Token.mockImplementation(() => refreshPromise);
1416
-
1417
- const err401 = Object.assign(new Error("Unauthorized"), { status: 401 });
1418
-
1419
- const callback = async (token: string) => {
1420
- if (token === "old-access-token") throw err401;
1421
- return `result-with-${token}`;
1422
- };
1423
-
1424
- // Launch 3 concurrent withValidToken calls — all will get a non-expired
1425
- // token first, call the callback, get a 401, and then try to refresh.
1426
- const p1 = withValidToken("google", callback);
1427
- const p2 = withValidToken("google", callback);
1428
- const p3 = withValidToken("google", callback);
1429
-
1430
- // Let the event loop tick so all 3 calls enter the 401 retry path
1431
- await new Promise((r) => setTimeout(r, 10));
1432
-
1433
- // Resolve the single refresh attempt
1434
- resolveRefresh({ accessToken: "new-token-123", expiresIn: 3600 });
1435
-
1436
- const results = await Promise.all([p1, p2, p3]);
1437
-
1438
- // All 3 should succeed with the refreshed token
1439
- expect(results).toEqual([
1440
- "result-with-new-token-123",
1441
- "result-with-new-token-123",
1442
- "result-with-new-token-123",
1443
- ]);
1444
-
1445
- // refreshOAuth2Token should have been called exactly once
1446
- expect(mockRefreshOAuth2Token).toHaveBeenCalledTimes(1);
1447
- });
1448
-
1449
- test("concurrent refreshes for different services proceed independently", async () => {
1450
- await setupService("google");
1451
- await setupService("slack");
1452
-
1453
- let resolveGmail!: (value: {
1454
- accessToken: string;
1455
- expiresIn: number;
1456
- }) => void;
1457
- let resolveSlack!: (value: {
1458
- accessToken: string;
1459
- expiresIn: number;
1460
- }) => void;
1461
-
1462
- const gmailPromise = new Promise<{
1463
- accessToken: string;
1464
- expiresIn: number;
1465
- }>((resolve) => {
1466
- resolveGmail = resolve;
1467
- });
1468
- const slackPromise = new Promise<{
1469
- accessToken: string;
1470
- expiresIn: number;
1471
- }>((resolve) => {
1472
- resolveSlack = resolve;
1473
- });
1474
-
1475
- let refreshCallCount = 0;
1476
- mockRefreshOAuth2Token.mockImplementation(() => {
1477
- refreshCallCount++;
1478
- // Both services use the same tokenExchangeUrl in this test, so we track by
1479
- // call order to return the correct deferred promise.
1480
- if (refreshCallCount === 1) return gmailPromise;
1481
- return slackPromise;
1482
- });
1483
-
1484
- const err401 = Object.assign(new Error("Unauthorized"), { status: 401 });
1485
-
1486
- const gmailCallback = async (token: string) => {
1487
- if (token === "old-access-token") throw err401;
1488
- return `gmail-${token}`;
1489
- };
1490
- const slackCallback = async (token: string) => {
1491
- if (token === "old-access-token") throw err401;
1492
- return `slack-${token}`;
1493
- };
1494
-
1495
- const p1 = withValidToken("google", gmailCallback);
1496
- const p2 = withValidToken("slack", slackCallback);
1497
-
1498
- await new Promise((r) => setTimeout(r, 10));
1499
-
1500
- // Resolve both independently
1501
- resolveGmail({ accessToken: "gmail-new-token", expiresIn: 3600 });
1502
- resolveSlack({ accessToken: "slack-new-token", expiresIn: 3600 });
1503
-
1504
- const [r1, r2] = await Promise.all([p1, p2]);
1505
-
1506
- expect(r1).toBe("gmail-gmail-new-token");
1507
- expect(r2).toBe("slack-slack-new-token");
1508
-
1509
- // Both services should have triggered their own refresh
1510
- expect(mockRefreshOAuth2Token).toHaveBeenCalledTimes(2);
1511
- });
1512
-
1513
- test("deduplication cleans up after refresh completes, allowing subsequent refreshes", async () => {
1514
- await setupService("google");
1515
-
1516
- let refreshCount = 0;
1517
- mockRefreshOAuth2Token.mockImplementation(() => {
1518
- refreshCount++;
1519
- return Promise.resolve({
1520
- accessToken: `token-${refreshCount}`,
1521
- expiresIn: 3600,
1522
- });
1523
- });
1524
-
1525
- const err401 = Object.assign(new Error("Unauthorized"), { status: 401 });
1526
-
1527
- // First call triggers a refresh (old token → 401 → refresh → token-1)
1528
- const r1 = await withValidToken("google", async (token: string) => {
1529
- if (token !== "token-1") throw err401;
1530
- return token;
1531
- });
1532
- expect(r1).toBe("token-1");
1533
- expect(refreshCount).toBe(1);
1534
-
1535
- // Second call also triggers a 401 to verify dedup state was cleaned up
1536
- // and a new refresh is allowed (not deduplicated with the first).
1537
- const r2 = await withValidToken("google", async (token: string) => {
1538
- if (token !== "token-2") throw err401;
1539
- return token;
1540
- });
1541
- expect(r2).toBe("token-2");
1542
- // Second refresh should have happened (not deduplicated with the first,
1543
- // since the first already completed)
1544
- expect(refreshCount).toBe(2);
1545
- });
1546
-
1547
- test("deduplication propagates refresh errors to all waiting callers", async () => {
1548
- await setupService("google");
1549
-
1550
- mockRefreshOAuth2Token.mockImplementation(() =>
1551
- Promise.reject(
1552
- Object.assign(
1553
- new Error("OAuth2 token refresh failed (HTTP 401: invalid_grant)"),
1554
- ),
1555
- ),
1556
- );
1557
-
1558
- const err401 = Object.assign(new Error("Unauthorized"), { status: 401 });
1559
-
1560
- const callback = async (token: string) => {
1561
- if (token === "old-access-token") throw err401;
1562
- return "should-not-reach";
1563
- };
1564
-
1565
- // Launch 2 concurrent calls — both should fail with the same error
1566
- const p1 = withValidToken("google", callback);
1567
- const p2 = withValidToken("google", callback);
1568
-
1569
- const results = await Promise.allSettled([p1, p2]);
1570
-
1571
- expect(results[0].status).toBe("rejected");
1572
- expect(results[1].status).toBe("rejected");
1573
-
1574
- // Only one actual refresh attempt
1575
- expect(mockRefreshOAuth2Token).toHaveBeenCalledTimes(1);
1576
- });
1577
-
1578
- // -----------------------------------------------------------------------
1579
- // refreshUrl resolution — provider.refreshUrl with fallback to tokenExchangeUrl
1580
- // -----------------------------------------------------------------------
1581
- describe("refreshUrl resolution", () => {
1582
- test("uses provider.refreshUrl when set", async () => {
1583
- await setupService("google");
1584
- mockProviders.get("google")!.refreshUrl =
1585
- "https://refresh.example.com/token";
1586
-
1587
- mockRefreshOAuth2Token.mockImplementation(() =>
1588
- Promise.resolve({
1589
- accessToken: "new-token-from-refresh-url",
1590
- expiresIn: 3600,
1591
- }),
1592
- );
1593
-
1594
- const err401 = Object.assign(new Error("Unauthorized"), { status: 401 });
1595
-
1596
- const callback = async (token: string) => {
1597
- if (token === "old-access-token") throw err401;
1598
- return `result-with-${token}`;
1599
- };
1600
-
1601
- const result = await withValidToken("google", callback);
1602
-
1603
- expect(result).toBe("result-with-new-token-from-refresh-url");
1604
- expect(mockRefreshOAuth2Token).toHaveBeenCalledTimes(1);
1605
- // Assert the refresh endpoint passed in is provider.refreshUrl, not
1606
- // the tokenExchangeUrl fallback.
1607
- expect(mockRefreshOAuth2Token.mock.calls[0]?.[0]).toBe(
1608
- "https://refresh.example.com/token",
1609
- );
1610
- });
1611
-
1612
- test("falls back to provider.tokenExchangeUrl when refreshUrl is null", async () => {
1613
- // setupService sets refreshUrl: null by default — this exercises the
1614
- // fallback path explicitly.
1615
- await setupService("google");
1616
- expect(mockProviders.get("google")!.refreshUrl).toBeNull();
1617
-
1618
- mockRefreshOAuth2Token.mockImplementation(() =>
1619
- Promise.resolve({
1620
- accessToken: "new-token-from-token-exchange-url",
1621
- expiresIn: 3600,
1622
- }),
1623
- );
1624
-
1625
- const err401 = Object.assign(new Error("Unauthorized"), { status: 401 });
1626
-
1627
- const callback = async (token: string) => {
1628
- if (token === "old-access-token") throw err401;
1629
- return `result-with-${token}`;
1630
- };
1631
-
1632
- const result = await withValidToken("google", callback);
1633
-
1634
- expect(result).toBe("result-with-new-token-from-token-exchange-url");
1635
- expect(mockRefreshOAuth2Token).toHaveBeenCalledTimes(1);
1636
- // Assert the refresh endpoint falls back to tokenExchangeUrl.
1637
- expect(mockRefreshOAuth2Token.mock.calls[0]?.[0]).toBe(
1638
- "https://oauth.example.com/token",
1639
- );
1640
- });
1641
-
1642
- test("falls back to provider.tokenExchangeUrl when refreshUrl is undefined", async () => {
1643
- await setupService("google");
1644
- // Delete the refreshUrl field entirely so the property is `undefined`
1645
- // rather than `null`. Both representations of "not set" must produce
1646
- // the fallback behavior.
1647
- delete mockProviders.get("google")!.refreshUrl;
1648
- expect(mockProviders.get("google")!.refreshUrl).toBeUndefined();
1649
-
1650
- mockRefreshOAuth2Token.mockImplementation(() =>
1651
- Promise.resolve({
1652
- accessToken: "new-token-from-token-exchange-url",
1653
- expiresIn: 3600,
1654
- }),
1655
- );
1656
-
1657
- const err401 = Object.assign(new Error("Unauthorized"), { status: 401 });
1658
-
1659
- const callback = async (token: string) => {
1660
- if (token === "old-access-token") throw err401;
1661
- return `result-with-${token}`;
1662
- };
1663
-
1664
- const result = await withValidToken("google", callback);
1665
-
1666
- expect(result).toBe("result-with-new-token-from-token-exchange-url");
1667
- expect(mockRefreshOAuth2Token).toHaveBeenCalledTimes(1);
1668
- // Assert the refresh endpoint falls back to tokenExchangeUrl.
1669
- expect(mockRefreshOAuth2Token.mock.calls[0]?.[0]).toBe(
1670
- "https://oauth.example.com/token",
1671
- );
1672
- });
1673
-
1674
- test("falls back to provider.tokenExchangeUrl when refreshUrl is empty string", async () => {
1675
- // Platform's Python `oauth_app.refresh_url or oauth_app.token_exchange_url`
1676
- // treats an empty string as unset. We use `||` (not `??`) so empty
1677
- // strings follow the same fallback path and never resolve to an empty
1678
- // endpoint.
1679
- await setupService("google");
1680
- mockProviders.get("google")!.refreshUrl = "";
1681
-
1682
- mockRefreshOAuth2Token.mockImplementation(() =>
1683
- Promise.resolve({
1684
- accessToken: "new-token-from-token-exchange-url",
1685
- expiresIn: 3600,
1686
- }),
1687
- );
1688
-
1689
- const err401 = Object.assign(new Error("Unauthorized"), { status: 401 });
1690
-
1691
- const callback = async (token: string) => {
1692
- if (token === "old-access-token") throw err401;
1693
- return `result-with-${token}`;
1694
- };
1695
-
1696
- const result = await withValidToken("google", callback);
1697
-
1698
- expect(result).toBe("result-with-new-token-from-token-exchange-url");
1699
- expect(mockRefreshOAuth2Token).toHaveBeenCalledTimes(1);
1700
- // Assert the refresh endpoint falls back to tokenExchangeUrl — NOT "".
1701
- expect(mockRefreshOAuth2Token.mock.calls[0]?.[0]).toBe(
1702
- "https://oauth.example.com/token",
1703
- );
1704
- });
1705
- });
1706
- });