@vellumai/assistant 0.4.45 → 0.4.48

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 (236) hide show
  1. package/ARCHITECTURE.md +6 -6
  2. package/docs/architecture/memory.md +1 -1
  3. package/docs/architecture/scheduling.md +2 -3
  4. package/docs/architecture/security.md +5 -5
  5. package/docs/trusted-contact-access.md +5 -6
  6. package/package.json +4 -1
  7. package/src/__tests__/avatar-e2e.test.ts +18 -219
  8. package/src/__tests__/avatar-generator.test.ts +5 -57
  9. package/src/__tests__/browser-fill-credential.test.ts +5 -2
  10. package/src/__tests__/bundled-skill-retrieval-guard.test.ts +2 -1
  11. package/src/__tests__/channel-readiness-routes.test.ts +20 -19
  12. package/src/__tests__/cli.test.ts +23 -0
  13. package/src/__tests__/credential-broker-browser-fill.test.ts +23 -22
  14. package/src/__tests__/credential-broker-server-use.test.ts +22 -21
  15. package/src/__tests__/credential-broker.test.ts +2 -1
  16. package/src/__tests__/credential-metadata-store.test.ts +240 -18
  17. package/src/__tests__/credential-resolve.test.ts +5 -4
  18. package/src/__tests__/credential-security-e2e.test.ts +8 -8
  19. package/src/__tests__/credential-security-invariants.test.ts +104 -7
  20. package/src/__tests__/credential-vault-unit.test.ts +22 -20
  21. package/src/__tests__/credential-vault.test.ts +284 -12
  22. package/src/__tests__/credentials-cli.test.ts +11 -6
  23. package/src/__tests__/gateway-only-enforcement.test.ts +4 -2
  24. package/src/__tests__/gemini-image-service.test.ts +75 -45
  25. package/src/__tests__/gemini-provider.test.ts +9 -6
  26. package/src/__tests__/guardian-action-conversation-turn.test.ts +1 -33
  27. package/src/__tests__/guardian-action-copy-generator.test.ts +0 -20
  28. package/src/__tests__/guardian-action-followup-executor.test.ts +1 -28
  29. package/src/__tests__/guardian-action-followup-store.test.ts +1 -1
  30. package/src/__tests__/guardian-grant-minting.test.ts +35 -0
  31. package/src/__tests__/integration-status.test.ts +53 -21
  32. package/src/__tests__/managed-proxy-context.test.ts +5 -3
  33. package/src/__tests__/media-generate-image.test.ts +63 -2
  34. package/src/__tests__/media-reuse-story.e2e.test.ts +7 -3
  35. package/src/__tests__/messaging-send-tool.test.ts +4 -6
  36. package/src/__tests__/provider-fail-open-selection.test.ts +3 -1
  37. package/src/__tests__/provider-managed-proxy-integration.test.ts +70 -6
  38. package/src/__tests__/schedule-store.test.ts +1 -1
  39. package/src/__tests__/schema-transforms.test.ts +226 -0
  40. package/src/__tests__/script-proxy-injection-runtime.test.ts +23 -13
  41. package/src/__tests__/script-proxy-policy-runtime.test.ts +1 -1
  42. package/src/__tests__/script-proxy-session-manager.test.ts +1 -1
  43. package/src/__tests__/secret-onetime-send.test.ts +5 -3
  44. package/src/__tests__/session-messaging-secret-redirect.test.ts +5 -4
  45. package/src/__tests__/skills-uninstall.test.ts +2 -2
  46. package/src/__tests__/skills.test.ts +0 -9
  47. package/src/__tests__/slack-channel-config.test.ts +9 -8
  48. package/src/__tests__/slack-share-routes.test.ts +11 -6
  49. package/src/__tests__/telegram-bot-username-resolution.test.ts +3 -0
  50. package/src/__tests__/twilio-config.test.ts +2 -1
  51. package/src/__tests__/twilio-provider.test.ts +4 -2
  52. package/src/__tests__/twilio-routes.test.ts +5 -4
  53. package/src/__tests__/verification-control-plane-policy.test.ts +1 -1
  54. package/src/approvals/AGENTS.md +1 -1
  55. package/src/calls/call-domain.ts +7 -4
  56. package/src/calls/twilio-config.ts +2 -1
  57. package/src/calls/twilio-provider.ts +2 -1
  58. package/src/calls/twilio-rest.ts +2 -2
  59. package/src/cli/commands/browser-relay.ts +40 -15
  60. package/src/cli/commands/credentials.ts +9 -8
  61. package/src/cli/commands/oauth.ts +1 -1
  62. package/src/cli.ts +3 -2
  63. package/src/config/bundled-skills/claude-code/TOOLS.json +0 -4
  64. package/src/config/bundled-skills/contacts/tools/google-contacts.ts +29 -32
  65. package/src/config/bundled-skills/gmail/SKILL.md +4 -4
  66. package/src/config/bundled-skills/gmail/tools/gmail-archive.ts +54 -61
  67. package/src/config/bundled-skills/gmail/tools/gmail-attachments.ts +25 -28
  68. package/src/config/bundled-skills/gmail/tools/gmail-draft.ts +14 -17
  69. package/src/config/bundled-skills/gmail/tools/gmail-filters.ts +39 -44
  70. package/src/config/bundled-skills/gmail/tools/gmail-follow-up.ts +61 -58
  71. package/src/config/bundled-skills/gmail/tools/gmail-forward.ts +50 -49
  72. package/src/config/bundled-skills/gmail/tools/gmail-label.ts +11 -13
  73. package/src/config/bundled-skills/gmail/tools/gmail-outreach-scan.ts +148 -146
  74. package/src/config/bundled-skills/gmail/tools/gmail-send-draft.ts +4 -7
  75. package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +175 -173
  76. package/src/config/bundled-skills/gmail/tools/gmail-trash.ts +4 -7
  77. package/src/config/bundled-skills/gmail/tools/gmail-unsubscribe.ts +71 -76
  78. package/src/config/bundled-skills/gmail/tools/gmail-vacation.ts +32 -38
  79. package/src/config/bundled-skills/google-calendar/SKILL.md +2 -2
  80. package/src/config/bundled-skills/google-calendar/calendar-client.ts +70 -29
  81. package/src/config/bundled-skills/google-calendar/tools/calendar-check-availability.ts +9 -10
  82. package/src/config/bundled-skills/google-calendar/tools/calendar-create-event.ts +5 -6
  83. package/src/config/bundled-skills/google-calendar/tools/calendar-get-event.ts +4 -5
  84. package/src/config/bundled-skills/google-calendar/tools/calendar-list-events.ts +14 -15
  85. package/src/config/bundled-skills/google-calendar/tools/calendar-rsvp.ts +37 -37
  86. package/src/config/bundled-skills/google-calendar/tools/shared.ts +4 -9
  87. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +24 -3
  88. package/src/config/bundled-skills/messaging/SKILL.md +6 -6
  89. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +62 -63
  90. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +15 -16
  91. package/src/config/bundled-skills/messaging/tools/messaging-auth-test.ts +4 -5
  92. package/src/config/bundled-skills/messaging/tools/messaging-list-conversations.ts +6 -7
  93. package/src/config/bundled-skills/messaging/tools/messaging-mark-read.ts +4 -5
  94. package/src/config/bundled-skills/messaging/tools/messaging-read.ts +14 -15
  95. package/src/config/bundled-skills/messaging/tools/messaging-search.ts +4 -5
  96. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +128 -128
  97. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +33 -34
  98. package/src/config/bundled-skills/messaging/tools/shared.ts +11 -11
  99. package/src/config/bundled-skills/notifications/SKILL.md +1 -1
  100. package/src/config/bundled-skills/phone-calls/SKILL.md +5 -5
  101. package/src/config/bundled-skills/schedule/SKILL.md +1 -1
  102. package/src/config/bundled-skills/skill-management/SKILL.md +1 -1
  103. package/src/config/bundled-skills/slack/tools/shared.ts +4 -10
  104. package/src/config/bundled-skills/slack/tools/slack-add-reaction.ts +4 -5
  105. package/src/config/bundled-skills/slack/tools/slack-channel-details.ts +15 -16
  106. package/src/config/bundled-skills/slack/tools/slack-delete-message.ts +4 -5
  107. package/src/config/bundled-skills/slack/tools/slack-edit-message.ts +4 -5
  108. package/src/config/bundled-skills/slack/tools/slack-leave-channel.ts +4 -5
  109. package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +95 -92
  110. package/src/config/loader.ts +6 -0
  111. package/src/daemon/computer-use-session.ts +7 -1
  112. package/src/daemon/guardian-action-generators.ts +4 -5
  113. package/src/daemon/handlers/config-slack-channel.ts +37 -20
  114. package/src/daemon/handlers/config-telegram.ts +33 -20
  115. package/src/daemon/lifecycle.ts +9 -1
  116. package/src/daemon/message-types/integrations.ts +1 -0
  117. package/src/daemon/ride-shotgun-handler.ts +3 -1
  118. package/src/daemon/session-messaging.ts +3 -1
  119. package/src/daemon/session-tool-setup.ts +18 -2
  120. package/src/daemon/session.ts +1 -1
  121. package/src/email/providers/index.ts +2 -1
  122. package/src/instrument.ts +15 -1
  123. package/src/media/app-icon-generator.ts +30 -4
  124. package/src/media/avatar-router.ts +28 -62
  125. package/src/media/gemini-image-service.ts +28 -2
  126. package/src/memory/canonical-guardian-store.ts +1 -1
  127. package/src/memory/guardian-action-store.ts +1 -1
  128. package/src/memory/schema/guardian.ts +1 -1
  129. package/src/messaging/provider.ts +16 -10
  130. package/src/messaging/providers/gmail/adapter.ts +40 -23
  131. package/src/messaging/providers/gmail/client.ts +203 -122
  132. package/src/messaging/providers/gmail/people-client.ts +26 -18
  133. package/src/messaging/providers/slack/adapter.ts +29 -19
  134. package/src/messaging/providers/slack/client.ts +265 -78
  135. package/src/messaging/providers/telegram-bot/adapter.ts +5 -4
  136. package/src/messaging/providers/whatsapp/adapter.ts +6 -3
  137. package/src/messaging/registry.ts +2 -1
  138. package/src/oauth/byo-connection.test.ts +436 -0
  139. package/src/oauth/byo-connection.ts +112 -0
  140. package/src/oauth/connect-orchestrator.ts +27 -0
  141. package/src/oauth/connection-resolver.ts +34 -0
  142. package/src/oauth/connection.ts +38 -0
  143. package/src/oauth/platform-connection.test.ts +163 -0
  144. package/src/oauth/platform-connection.ts +110 -0
  145. package/src/oauth/provider-base-urls.ts +21 -0
  146. package/src/oauth/provider-profiles.ts +1 -1
  147. package/src/oauth/token-persistence.ts +20 -20
  148. package/src/permissions/checker.ts +6 -1
  149. package/src/prompts/system-prompt.ts +52 -15
  150. package/src/prompts/templates/BOOTSTRAP.md +1 -1
  151. package/src/providers/gemini/client.ts +15 -6
  152. package/src/providers/managed-proxy/constants.ts +2 -2
  153. package/src/providers/managed-proxy/context.ts +5 -1
  154. package/src/providers/ratelimit.ts +17 -0
  155. package/src/providers/registry.ts +2 -2
  156. package/src/runtime/AGENTS.md +18 -1
  157. package/src/runtime/auth/route-policy.ts +1 -0
  158. package/src/runtime/channel-invite-transports/telegram.ts +2 -1
  159. package/src/runtime/channel-readiness-service.ts +168 -195
  160. package/src/runtime/channel-readiness-types.ts +4 -0
  161. package/src/runtime/guardian-action-conversation-turn.ts +1 -3
  162. package/src/runtime/guardian-action-followup-executor.ts +1 -2
  163. package/src/runtime/guardian-action-message-composer.ts +3 -23
  164. package/src/runtime/http-server.ts +9 -4
  165. package/src/runtime/http-types.ts +0 -1
  166. package/src/runtime/middleware/rate-limiter.ts +74 -20
  167. package/src/runtime/middleware/twilio-validation.ts +1 -3
  168. package/src/runtime/routes/channel-readiness-routes.ts +2 -0
  169. package/src/runtime/routes/diagnostics-routes.ts +11 -9
  170. package/src/runtime/routes/guardian-approval-interception.ts +20 -5
  171. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +71 -25
  172. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +12 -5
  173. package/src/runtime/routes/integrations/slack/share.ts +3 -2
  174. package/src/runtime/routes/integrations/twilio.ts +6 -5
  175. package/src/runtime/routes/secret-routes.ts +3 -2
  176. package/src/runtime/routes/settings-routes.ts +75 -17
  177. package/src/runtime/telegram-streaming-delivery.test.ts +132 -0
  178. package/src/runtime/telegram-streaming-delivery.ts +11 -1
  179. package/src/schedule/integration-status.ts +5 -4
  180. package/src/security/credential-key.ts +170 -0
  181. package/src/security/token-manager.ts +36 -7
  182. package/src/tools/apps/definitions.ts +0 -5
  183. package/src/tools/assets/materialize.ts +0 -5
  184. package/src/tools/assets/search.ts +0 -5
  185. package/src/tools/browser/headless-browser.ts +1 -67
  186. package/src/tools/claude-code/claude-code.ts +0 -5
  187. package/src/tools/computer-use/request-computer-control.ts +0 -5
  188. package/src/tools/credentials/broker.ts +6 -4
  189. package/src/tools/credentials/metadata-store.ts +72 -20
  190. package/src/tools/credentials/resolve.ts +2 -1
  191. package/src/tools/credentials/vault.ts +77 -16
  192. package/src/tools/filesystem/edit.ts +1 -6
  193. package/src/tools/filesystem/read.ts +0 -5
  194. package/src/tools/filesystem/write.ts +1 -6
  195. package/src/tools/host-filesystem/edit.ts +1 -6
  196. package/src/tools/host-filesystem/read.ts +1 -6
  197. package/src/tools/host-filesystem/write.ts +1 -6
  198. package/src/tools/mcp/mcp-tool-factory.ts +18 -1
  199. package/src/tools/memory/definitions.ts +0 -5
  200. package/src/tools/network/web-fetch.ts +0 -5
  201. package/src/tools/network/web-search.ts +0 -5
  202. package/src/tools/schema-transforms.ts +99 -0
  203. package/src/tools/skills/load.ts +0 -5
  204. package/src/tools/swarm/delegate.ts +0 -5
  205. package/src/tools/system/avatar-generator.ts +3 -44
  206. package/src/tools/ui-surface/definitions.ts +0 -15
  207. package/src/tools/watch/screen-watch.ts +0 -5
  208. package/src/version.ts +10 -0
  209. package/src/watcher/providers/github.ts +51 -52
  210. package/src/watcher/providers/gmail.ts +88 -80
  211. package/src/watcher/providers/google-calendar.ts +93 -86
  212. package/src/watcher/providers/linear.ts +87 -93
  213. package/src/__tests__/avatar-router.test.ts +0 -149
  214. package/src/__tests__/managed-avatar-client.test.ts +0 -337
  215. package/src/config/bundled-skills/doordash/SKILL.md +0 -170
  216. package/src/config/bundled-skills/doordash/__tests__/doordash-client.test.ts +0 -205
  217. package/src/config/bundled-skills/doordash/__tests__/doordash-session.test.ts +0 -74
  218. package/src/config/bundled-skills/doordash/doordash-cli.ts +0 -1081
  219. package/src/config/bundled-skills/doordash/doordash-entry.ts +0 -22
  220. package/src/config/bundled-skills/doordash/lib/cart-queries.ts +0 -787
  221. package/src/config/bundled-skills/doordash/lib/client.ts +0 -1069
  222. package/src/config/bundled-skills/doordash/lib/order-queries.ts +0 -85
  223. package/src/config/bundled-skills/doordash/lib/queries.ts +0 -28
  224. package/src/config/bundled-skills/doordash/lib/query-extractor.ts +0 -94
  225. package/src/config/bundled-skills/doordash/lib/search-queries.ts +0 -203
  226. package/src/config/bundled-skills/doordash/lib/session.ts +0 -96
  227. package/src/config/bundled-skills/doordash/lib/shared/errors.ts +0 -61
  228. package/src/config/bundled-skills/doordash/lib/shared/network-recorder.ts +0 -380
  229. package/src/config/bundled-skills/doordash/lib/shared/platform.ts +0 -55
  230. package/src/config/bundled-skills/doordash/lib/shared/recording-store.ts +0 -43
  231. package/src/config/bundled-skills/doordash/lib/shared/recording-types.ts +0 -49
  232. package/src/config/bundled-skills/doordash/lib/shared/truncate.ts +0 -6
  233. package/src/config/bundled-skills/doordash/lib/store-queries.ts +0 -246
  234. package/src/config/bundled-skills/doordash/lib/types.ts +0 -367
  235. package/src/media/avatar-types.ts +0 -53
  236. package/src/media/managed-avatar-client.ts +0 -225
@@ -48,6 +48,7 @@ mock.module("../tools/registry.js", () => ({
48
48
  // Imports under test
49
49
  // ---------------------------------------------------------------------------
50
50
 
51
+ import { credentialKey } from "../security/credential-key.js";
51
52
  import { getSecureKey, setSecureKey } from "../security/secure-keys.js";
52
53
  import { CredentialBroker } from "../tools/credentials/broker.js";
53
54
  import {
@@ -108,7 +109,7 @@ describe("CredentialBroker transient credentials", () => {
108
109
  const result = broker.consume(auth.token.tokenId);
109
110
  expect(result.success).toBe(true);
110
111
  expect(result.value).toBe("one-time-secret");
111
- expect(result.storageKey).toBe("credential:svc:key");
112
+ expect(result.storageKey).toBe(credentialKey("svc", "key"));
112
113
 
113
114
  // Second authorize + consume should NOT have the transient value
114
115
  const auth2 = broker.authorize({
@@ -148,7 +149,7 @@ describe("CredentialBroker transient credentials", () => {
148
149
  upsertCredentialMetadata("github", "token", {
149
150
  allowedTools: ["browser_fill_credential"],
150
151
  });
151
- setSecureKey("credential:github:token", "stored-value");
152
+ setSecureKey(credentialKey("github", "token"), "stored-value");
152
153
  broker.injectTransient("github", "token", "transient-value");
153
154
 
154
155
  // First fill uses transient
@@ -411,7 +412,7 @@ describe("credential_store tool — prompt action", () => {
411
412
  expect(result.content).not.toContain("prompt-secret-val");
412
413
 
413
414
  // Verify stored
414
- expect(getSecureKey("credential:test-prompt:api_key")).toBe(
415
+ expect(getSecureKey(credentialKey("test-prompt", "api_key"))).toBe(
415
416
  "prompt-secret-val",
416
417
  );
417
418
  });
@@ -595,16 +596,18 @@ describe("credential_store tool — oauth2_connect error paths", () => {
595
596
  expect(result.content).toContain("client_id is required");
596
597
  });
597
598
 
598
- test("uses stored client_id from secure storage", async () => {
599
- // Store both client_id and client_secret for the service the
600
- // requiresClientSecret guardrail will short-circuit if client_secret
601
- // is missing, so we need both to validate that stored client_id
602
- // is resolved correctly.
599
+ test("uses stored client_id from metadata", async () => {
600
+ // Store client_id in metadata (the canonical source) and client_secret
601
+ // in the secure store — the requiresClientSecret guardrail will
602
+ // short-circuit if client_secret is missing, so we need both to
603
+ // validate that stored client_id is resolved correctly.
604
+ upsertCredentialMetadata("integration:gmail", "access_token", {
605
+ oauth2ClientId: "stored-client-id-123",
606
+ });
603
607
  setSecureKey(
604
- "credential:integration:gmail:client_id",
605
- "stored-client-id-123",
608
+ credentialKey("integration:gmail", "client_secret"),
609
+ "test-secret",
606
610
  );
607
- setSecureKey("credential:integration:gmail:client_secret", "test-secret");
608
611
 
609
612
  const result = await credentialStoreTool.execute(
610
613
  {
@@ -624,12 +627,11 @@ describe("credential_store tool — oauth2_connect error paths", () => {
624
627
  });
625
628
 
626
629
  test("rejects when client_secret is missing for service that requires it", async () => {
627
- // Store only client_id — client_secret is intentionally absent to
628
- // validate the requiresClientSecret guardrail.
629
- setSecureKey(
630
- "credential:integration:gmail:client_id",
631
- "stored-client-id-456",
632
- );
630
+ // Store only client_id in metadata — client_secret is intentionally
631
+ // absent to validate the requiresClientSecret guardrail.
632
+ upsertCredentialMetadata("integration:gmail", "access_token", {
633
+ oauth2ClientId: "stored-client-id-456",
634
+ });
633
635
 
634
636
  const result = await credentialStoreTool.execute(
635
637
  {
@@ -786,7 +788,7 @@ describe("credential_store tool — store validation edge cases", () => {
786
788
  );
787
789
 
788
790
  // Verify stored
789
- expect(getSecureKey("credential:del-test:key")).toBe("secret");
791
+ expect(getSecureKey(credentialKey("del-test", "key"))).toBe("secret");
790
792
  const { getCredentialMetadata } =
791
793
  await import("../tools/credentials/metadata-store.js");
792
794
  expect(getCredentialMetadata("del-test", "key")).toBeDefined();
@@ -803,7 +805,7 @@ describe("credential_store tool — store validation edge cases", () => {
803
805
  expect(result.isError).toBe(false);
804
806
 
805
807
  // Both should be gone
806
- expect(getSecureKey("credential:del-test:key")).toBeUndefined();
808
+ expect(getSecureKey(credentialKey("del-test", "key"))).toBeUndefined();
807
809
  expect(getCredentialMetadata("del-test", "key")).toBeUndefined();
808
810
  });
809
811
  });
@@ -892,7 +894,7 @@ describe("CredentialBroker — serverUseById edge cases", () => {
892
894
  },
893
895
  ],
894
896
  });
895
- setSecureKey("credential:multi:api_key", "multi-secret");
897
+ setSecureKey(credentialKey("multi", "api_key"), "multi-secret");
896
898
 
897
899
  const result = broker.serverUseById({
898
900
  credentialId: meta.credentialId,
@@ -45,20 +45,47 @@ mock.module("../tools/registry.js", () => ({
45
45
  registerTool: () => {},
46
46
  }));
47
47
 
48
+ // ---------------------------------------------------------------------------
49
+ // Mock OAuth2 token refresh for token-manager deduplication tests
50
+ // ---------------------------------------------------------------------------
51
+
52
+ let mockRefreshOAuth2Token: ReturnType<
53
+ typeof mock<() => Promise<{ accessToken: string; expiresIn: number }>>
54
+ >;
55
+
56
+ mock.module("../security/oauth2.js", () => {
57
+ mockRefreshOAuth2Token = mock(() =>
58
+ Promise.resolve({
59
+ accessToken: "refreshed-access-token",
60
+ expiresIn: 3600,
61
+ }),
62
+ );
63
+ return {
64
+ refreshOAuth2Token: mockRefreshOAuth2Token,
65
+ };
66
+ });
67
+
48
68
  // ---------------------------------------------------------------------------
49
69
  // Import the module under test
50
70
  // ---------------------------------------------------------------------------
51
71
 
52
72
  // getCredentialValue is no longer exported (sealed in PR 17) — use getSecureKey directly
53
73
 
74
+ import { credentialKey } from "../security/credential-key.js";
54
75
  import {
55
76
  deleteSecureKey,
56
77
  getSecureKey,
57
78
  setSecureKey,
58
79
  } from "../security/secure-keys.js";
80
+ import {
81
+ _resetInflightRefreshes,
82
+ _resetRefreshBreakers,
83
+ withValidToken,
84
+ } from "../security/token-manager.js";
59
85
  import {
60
86
  _setMetadataPath,
61
87
  getCredentialMetadata,
88
+ upsertCredentialMetadata,
62
89
  } from "../tools/credentials/metadata-store.js";
63
90
  import { credentialStoreTool } from "../tools/credentials/vault.js";
64
91
  import type { ToolContext } from "../tools/types.js";
@@ -120,7 +147,7 @@ async function executeVault(
120
147
  };
121
148
  }
122
149
 
123
- const key = `credential:${service}:${field}`;
150
+ const key = credentialKey(service, field);
124
151
  const ok = setSecureKey(key, value);
125
152
  if (!ok) {
126
153
  return { content: "Error: failed to store credential", isError: true };
@@ -151,7 +178,7 @@ async function executeVault(
151
178
  };
152
179
  }
153
180
 
154
- const key = `credential:${service}:${field}`;
181
+ const key = credentialKey(service, field);
155
182
  const result = deleteSecureKey(key);
156
183
  if (result !== "deleted") {
157
184
  return {
@@ -553,7 +580,7 @@ describe("credential_store tool", () => {
553
580
 
554
581
  // Delete the secret directly without going through the tool (simulates
555
582
  // a divergence where metadata write failed after secret deletion)
556
- deleteSecureKey("credential:svc-a:key");
583
+ deleteSecureKey(credentialKey("svc-a", "key"));
557
584
 
558
585
  const result = await credentialStoreTool.execute(
559
586
  { action: "list" },
@@ -596,7 +623,7 @@ describe("credential_store tool", () => {
596
623
  // -----------------------------------------------------------------------
597
624
  describe("delete action", () => {
598
625
  test("deletes a stored credential", async () => {
599
- setSecureKey("credential:gmail:password", "secret");
626
+ setSecureKey(credentialKey("gmail", "password"), "secret");
600
627
 
601
628
  const result = await executeVault({
602
629
  action: "delete",
@@ -607,7 +634,7 @@ describe("credential_store tool", () => {
607
634
  expect(result.content).toBe("Deleted credential for gmail/password.");
608
635
 
609
636
  // Verify it's actually gone
610
- expect(getSecureKey("credential:gmail:password")).toBeUndefined();
637
+ expect(getSecureKey(credentialKey("gmail", "password"))).toBeUndefined();
611
638
  });
612
639
 
613
640
  test("returns error for non-existent credential", async () => {
@@ -644,12 +671,14 @@ describe("credential_store tool", () => {
644
671
  // -----------------------------------------------------------------------
645
672
  describe("credential value access", () => {
646
673
  test("credential values are stored via secure keys", () => {
647
- setSecureKey("credential:github:token", "ghp_abc123");
648
- expect(getSecureKey("credential:github:token")).toBe("ghp_abc123");
674
+ setSecureKey(credentialKey("github", "token"), "ghp_abc123");
675
+ expect(getSecureKey(credentialKey("github", "token"))).toBe("ghp_abc123");
649
676
  });
650
677
 
651
678
  test("returns undefined for non-existent credential", () => {
652
- expect(getSecureKey("credential:nonexistent:field")).toBeUndefined();
679
+ expect(
680
+ getSecureKey(credentialKey("nonexistent", "field")),
681
+ ).toBeUndefined();
653
682
  });
654
683
  });
655
684
 
@@ -1094,8 +1123,12 @@ describe("credential_store tool", () => {
1094
1123
  value: "github-pass",
1095
1124
  });
1096
1125
 
1097
- expect(getSecureKey("credential:gmail:password")).toBe("gmail-pass");
1098
- expect(getSecureKey("credential:github:password")).toBe("github-pass");
1126
+ expect(getSecureKey(credentialKey("gmail", "password"))).toBe(
1127
+ "gmail-pass",
1128
+ );
1129
+ expect(getSecureKey(credentialKey("github", "password"))).toBe(
1130
+ "github-pass",
1131
+ );
1099
1132
  });
1100
1133
 
1101
1134
  test("same service with different fields do not collide", async () => {
@@ -1112,10 +1145,249 @@ describe("credential_store tool", () => {
1112
1145
  value: "backup@example.com",
1113
1146
  });
1114
1147
 
1115
- expect(getSecureKey("credential:gmail:password")).toBe("pass123");
1116
- expect(getSecureKey("credential:gmail:recovery_email")).toBe(
1148
+ expect(getSecureKey(credentialKey("gmail", "password"))).toBe("pass123");
1149
+ expect(getSecureKey(credentialKey("gmail", "recovery_email"))).toBe(
1117
1150
  "backup@example.com",
1118
1151
  );
1119
1152
  });
1120
1153
  });
1121
1154
  });
1155
+
1156
+ // ---------------------------------------------------------------------------
1157
+ // Token refresh deduplication tests
1158
+ // ---------------------------------------------------------------------------
1159
+
1160
+ describe("withValidToken refresh deduplication", () => {
1161
+ beforeAll(() => {
1162
+ mkdirSync(TEST_DIR, { recursive: true });
1163
+ });
1164
+
1165
+ beforeEach(() => {
1166
+ _resetBackend();
1167
+ for (const entry of readdirSync(TEST_DIR)) {
1168
+ rmSync(join(TEST_DIR, entry), { recursive: true, force: true });
1169
+ }
1170
+ _setStorePath(STORE_PATH);
1171
+ _setMetadataPath(join(TEST_DIR, "metadata.json"));
1172
+ _resetRefreshBreakers();
1173
+ _resetInflightRefreshes();
1174
+ mockRefreshOAuth2Token.mockClear();
1175
+ });
1176
+
1177
+ afterEach(() => {
1178
+ _setMetadataPath(null);
1179
+ _setStorePath(null);
1180
+ _resetBackend();
1181
+ _resetRefreshBreakers();
1182
+ _resetInflightRefreshes();
1183
+ });
1184
+
1185
+ afterAll(() => {
1186
+ rmSync(TEST_DIR, { recursive: true, force: true });
1187
+ });
1188
+
1189
+ /**
1190
+ * Helper: set up a service with an access token, refresh token, and OAuth2
1191
+ * metadata so that token refresh can proceed through doRefresh().
1192
+ */
1193
+ function setupService(
1194
+ service: string,
1195
+ opts?: { expired?: boolean; accessToken?: string },
1196
+ ) {
1197
+ const accessToken = opts?.accessToken ?? "old-access-token";
1198
+ setSecureKey(credentialKey(service, "access_token"), accessToken);
1199
+ setSecureKey(
1200
+ credentialKey(service, "refresh_token"),
1201
+ "valid-refresh-token",
1202
+ );
1203
+ upsertCredentialMetadata(service, "access_token", {
1204
+ oauth2TokenUrl: "https://oauth.example.com/token",
1205
+ oauth2ClientId: "test-client-id",
1206
+ ...(opts?.expired
1207
+ ? { expiresAt: Date.now() - 60_000 } // expired 1 minute ago
1208
+ : { expiresAt: Date.now() + 3600_000 }), // expires in 1 hour
1209
+ });
1210
+ }
1211
+
1212
+ test("3 concurrent 401 refreshes for the same service call doRefresh exactly once", async () => {
1213
+ setupService("integration:gmail");
1214
+
1215
+ let resolveRefresh!: (value: {
1216
+ accessToken: string;
1217
+ expiresIn: number;
1218
+ }) => void;
1219
+ const refreshPromise = new Promise<{
1220
+ accessToken: string;
1221
+ expiresIn: number;
1222
+ }>((resolve) => {
1223
+ resolveRefresh = resolve;
1224
+ });
1225
+
1226
+ mockRefreshOAuth2Token.mockImplementation(() => refreshPromise);
1227
+
1228
+ const err401 = Object.assign(new Error("Unauthorized"), { status: 401 });
1229
+
1230
+ const callback = async (token: string) => {
1231
+ if (token === "old-access-token") throw err401;
1232
+ return `result-with-${token}`;
1233
+ };
1234
+
1235
+ // Launch 3 concurrent withValidToken calls — all will get a non-expired
1236
+ // token first, call the callback, get a 401, and then try to refresh.
1237
+ const p1 = withValidToken("integration:gmail", callback);
1238
+ const p2 = withValidToken("integration:gmail", callback);
1239
+ const p3 = withValidToken("integration:gmail", callback);
1240
+
1241
+ // Let the event loop tick so all 3 calls enter the 401 retry path
1242
+ await new Promise((r) => setTimeout(r, 10));
1243
+
1244
+ // Resolve the single refresh attempt
1245
+ resolveRefresh({ accessToken: "new-token-123", expiresIn: 3600 });
1246
+
1247
+ const results = await Promise.all([p1, p2, p3]);
1248
+
1249
+ // All 3 should succeed with the refreshed token
1250
+ expect(results).toEqual([
1251
+ "result-with-new-token-123",
1252
+ "result-with-new-token-123",
1253
+ "result-with-new-token-123",
1254
+ ]);
1255
+
1256
+ // refreshOAuth2Token should have been called exactly once
1257
+ expect(mockRefreshOAuth2Token).toHaveBeenCalledTimes(1);
1258
+ });
1259
+
1260
+ test("concurrent refreshes for different services proceed independently", async () => {
1261
+ setupService("integration:gmail");
1262
+ setupService("integration:slack");
1263
+
1264
+ let resolveGmail!: (value: {
1265
+ accessToken: string;
1266
+ expiresIn: number;
1267
+ }) => void;
1268
+ let resolveSlack!: (value: {
1269
+ accessToken: string;
1270
+ expiresIn: number;
1271
+ }) => void;
1272
+
1273
+ const gmailPromise = new Promise<{
1274
+ accessToken: string;
1275
+ expiresIn: number;
1276
+ }>((resolve) => {
1277
+ resolveGmail = resolve;
1278
+ });
1279
+ const slackPromise = new Promise<{
1280
+ accessToken: string;
1281
+ expiresIn: number;
1282
+ }>((resolve) => {
1283
+ resolveSlack = resolve;
1284
+ });
1285
+
1286
+ let refreshCallCount = 0;
1287
+ mockRefreshOAuth2Token.mockImplementation(() => {
1288
+ refreshCallCount++;
1289
+ // Both services use the same tokenUrl in this test, so we track by
1290
+ // call order to return the correct deferred promise.
1291
+ if (refreshCallCount === 1) return gmailPromise;
1292
+ return slackPromise;
1293
+ });
1294
+
1295
+ const err401 = Object.assign(new Error("Unauthorized"), { status: 401 });
1296
+
1297
+ const gmailCallback = async (token: string) => {
1298
+ if (token === "old-access-token") throw err401;
1299
+ return `gmail-${token}`;
1300
+ };
1301
+ const slackCallback = async (token: string) => {
1302
+ if (token === "old-access-token") throw err401;
1303
+ return `slack-${token}`;
1304
+ };
1305
+
1306
+ const p1 = withValidToken("integration:gmail", gmailCallback);
1307
+ const p2 = withValidToken("integration:slack", slackCallback);
1308
+
1309
+ await new Promise((r) => setTimeout(r, 10));
1310
+
1311
+ // Resolve both independently
1312
+ resolveGmail({ accessToken: "gmail-new-token", expiresIn: 3600 });
1313
+ resolveSlack({ accessToken: "slack-new-token", expiresIn: 3600 });
1314
+
1315
+ const [r1, r2] = await Promise.all([p1, p2]);
1316
+
1317
+ expect(r1).toBe("gmail-gmail-new-token");
1318
+ expect(r2).toBe("slack-slack-new-token");
1319
+
1320
+ // Both services should have triggered their own refresh
1321
+ expect(mockRefreshOAuth2Token).toHaveBeenCalledTimes(2);
1322
+ });
1323
+
1324
+ test("deduplication cleans up after refresh completes, allowing subsequent refreshes", async () => {
1325
+ setupService("integration:gmail");
1326
+
1327
+ let refreshCount = 0;
1328
+ mockRefreshOAuth2Token.mockImplementation(() => {
1329
+ refreshCount++;
1330
+ return Promise.resolve({
1331
+ accessToken: `token-${refreshCount}`,
1332
+ expiresIn: 3600,
1333
+ });
1334
+ });
1335
+
1336
+ const err401 = Object.assign(new Error("Unauthorized"), { status: 401 });
1337
+
1338
+ // First call triggers a refresh
1339
+ const r1 = await withValidToken(
1340
+ "integration:gmail",
1341
+ async (token: string) => {
1342
+ if (token === "old-access-token") throw err401;
1343
+ return token;
1344
+ },
1345
+ );
1346
+ expect(r1).toBe("token-1");
1347
+ expect(refreshCount).toBe(1);
1348
+
1349
+ // Set up so the next call will also get a 401 (token-1 stored from first refresh)
1350
+ const r2 = await withValidToken(
1351
+ "integration:gmail",
1352
+ async (token: string) => {
1353
+ if (token === "token-1") throw err401;
1354
+ return token;
1355
+ },
1356
+ );
1357
+ expect(r2).toBe("token-2");
1358
+ // Second refresh should have happened (not deduplicated with the first,
1359
+ // since the first already completed)
1360
+ expect(refreshCount).toBe(2);
1361
+ });
1362
+
1363
+ test("deduplication propagates refresh errors to all waiting callers", async () => {
1364
+ setupService("integration:gmail");
1365
+
1366
+ mockRefreshOAuth2Token.mockImplementation(() =>
1367
+ Promise.reject(
1368
+ Object.assign(
1369
+ new Error("OAuth2 token refresh failed (HTTP 401: invalid_grant)"),
1370
+ ),
1371
+ ),
1372
+ );
1373
+
1374
+ const err401 = Object.assign(new Error("Unauthorized"), { status: 401 });
1375
+
1376
+ const callback = async (token: string) => {
1377
+ if (token === "old-access-token") throw err401;
1378
+ return "should-not-reach";
1379
+ };
1380
+
1381
+ // Launch 2 concurrent calls — both should fail with the same error
1382
+ const p1 = withValidToken("integration:gmail", callback);
1383
+ const p2 = withValidToken("integration:gmail", callback);
1384
+
1385
+ const results = await Promise.allSettled([p1, p2]);
1386
+
1387
+ expect(results[0].status).toBe("rejected");
1388
+ expect(results[1].status).toBe("rejected");
1389
+
1390
+ // Only one actual refresh attempt
1391
+ expect(mockRefreshOAuth2Token).toHaveBeenCalledTimes(1);
1392
+ });
1393
+ });
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
3
  import { Command } from "commander";
4
4
 
5
+ import { credentialKey } from "../security/credential-key.js";
5
6
  import type { CredentialMetadata } from "../tools/credentials/metadata-store.js";
6
7
 
7
8
  // ---------------------------------------------------------------------------
@@ -238,7 +239,7 @@ function seedCredential(
238
239
  ...extra,
239
240
  };
240
241
  metadataStore.push(record);
241
- secureKeyStore.set(`credential:${service}:${field}`, secret);
242
+ secureKeyStore.set(credentialKey(service, field), secret);
242
243
  return record;
243
244
  }
244
245
 
@@ -437,7 +438,7 @@ describe("assistant credentials CLI", () => {
437
438
  expect(parsed.credentialId).toBeTruthy();
438
439
 
439
440
  // Verify secret stored in mock map
440
- expect(secureKeyStore.get("credential:twilio:account_sid")).toBe(
441
+ expect(secureKeyStore.get(credentialKey("twilio", "account_sid"))).toBe(
441
442
  "AC1234567890",
442
443
  );
443
444
 
@@ -546,7 +547,7 @@ describe("assistant credentials CLI", () => {
546
547
  expect(meta2!.updatedAt).toBeGreaterThan(firstUpdatedAt);
547
548
 
548
549
  // Verify secret is overwritten
549
- expect(secureKeyStore.get("credential:twilio:account_sid")).toBe(
550
+ expect(secureKeyStore.get(credentialKey("twilio", "account_sid"))).toBe(
550
551
  "new_value",
551
552
  );
552
553
  });
@@ -568,7 +569,9 @@ describe("assistant credentials CLI", () => {
568
569
  expect(parsed.field).toBe("auth_token");
569
570
 
570
571
  // Verify both removed
571
- expect(secureKeyStore.has("credential:twilio:auth_token")).toBe(false);
572
+ expect(secureKeyStore.has(credentialKey("twilio", "auth_token"))).toBe(
573
+ false,
574
+ );
572
575
  expect(
573
576
  metadataStore.find(
574
577
  (m) => m.service === "twilio" && m.field === "auth_token",
@@ -833,8 +836,10 @@ describe("assistant credentials CLI", () => {
833
836
  expect(parsed.value).toBe("instance_secret_abc123");
834
837
 
835
838
  // Verify the correct key was looked up in the secure store
836
- expect(secureKeyStore.has("credential:twilio:auth_token")).toBe(true);
837
- expect(secureKeyStore.get("credential:twilio:auth_token")).toBe(
839
+ expect(secureKeyStore.has(credentialKey("twilio", "auth_token"))).toBe(
840
+ true,
841
+ );
842
+ expect(secureKeyStore.get(credentialKey("twilio", "auth_token"))).toBe(
838
843
  "instance_secret_abc123",
839
844
  );
840
845
  });
@@ -112,9 +112,11 @@ mock.module("../calls/twilio-provider.js", () => ({
112
112
  },
113
113
  }));
114
114
 
115
+ import { credentialKey } from "../security/credential-key.js";
116
+
115
117
  const secureKeyStore: Record<string, string | undefined> = {
116
- "credential:twilio:account_sid": "AC_test",
117
- "credential:twilio:auth_token": "test_token",
118
+ [credentialKey("twilio", "account_sid")]: "AC_test",
119
+ [credentialKey("twilio", "auth_token")]: "test_token",
118
120
  };
119
121
 
120
122
  mock.module("../security/secure-keys.js", () => ({