@vellumai/assistant 0.4.48 → 0.4.49

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 (252) hide show
  1. package/ARCHITECTURE.md +2 -2
  2. package/README.md +2 -23
  3. package/docs/architecture/integrations.md +45 -41
  4. package/docs/architecture/keychain-broker.md +3 -3
  5. package/docs/runbook-trusted-contacts.md +3 -8
  6. package/hook-templates/debug-prompt-logger/hook.json +1 -1
  7. package/hook-templates/debug-prompt-logger/run.sh +1 -3
  8. package/package.json +1 -1
  9. package/src/__tests__/actor-token-service.test.ts +0 -1
  10. package/src/__tests__/anthropic-provider.test.ts +156 -0
  11. package/src/__tests__/approval-cascade.test.ts +810 -0
  12. package/src/__tests__/approval-primitive.test.ts +0 -1
  13. package/src/__tests__/approval-routes-http.test.ts +2 -0
  14. package/src/__tests__/assistant-attachments.test.ts +12 -34
  15. package/src/__tests__/assistant-feature-flag-guardrails.test.ts +76 -0
  16. package/src/__tests__/assistant-feature-flags-integration.test.ts +0 -1
  17. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +2 -2
  18. package/src/__tests__/channel-guardian.test.ts +0 -2
  19. package/src/__tests__/channel-readiness-routes.test.ts +15 -6
  20. package/src/__tests__/channel-readiness-service.test.ts +10 -9
  21. package/src/__tests__/checker.test.ts +9 -29
  22. package/src/__tests__/computer-use-skill-manifest-regression.test.ts +1 -1
  23. package/src/__tests__/computer-use-tools.test.ts +2 -19
  24. package/src/__tests__/config-watcher.test.ts +0 -1
  25. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +0 -1
  26. package/src/__tests__/context-image-dimensions.test.ts +332 -0
  27. package/src/__tests__/context-token-estimator.test.ts +196 -13
  28. package/src/__tests__/conversation-attention-store.test.ts +0 -1
  29. package/src/__tests__/conversation-attention-telegram.test.ts +0 -1
  30. package/src/__tests__/conversation-routes-guardian-reply.test.ts +144 -0
  31. package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -0
  32. package/src/__tests__/credential-metadata-store.test.ts +64 -73
  33. package/src/__tests__/credential-security-invariants.test.ts +13 -7
  34. package/src/__tests__/credential-vault-unit.test.ts +280 -49
  35. package/src/__tests__/credential-vault.test.ts +138 -16
  36. package/src/__tests__/credentials-cli.test.ts +71 -0
  37. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +0 -1
  38. package/src/__tests__/ephemeral-permissions.test.ts +3 -3
  39. package/src/__tests__/gateway-only-guard.test.ts +0 -1
  40. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +0 -1
  41. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +0 -1
  42. package/src/__tests__/guardian-routing-invariants.test.ts +0 -1
  43. package/src/__tests__/guardian-verification-voice-binding.test.ts +0 -1
  44. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +0 -39
  45. package/src/__tests__/heartbeat-service.test.ts +0 -1
  46. package/src/__tests__/host-cu-proxy.test.ts +629 -0
  47. package/src/__tests__/host-shell-tool.test.ts +27 -15
  48. package/src/__tests__/http-user-message-parity.test.ts +1 -0
  49. package/src/__tests__/ingress-url-consistency.test.ts +14 -21
  50. package/src/__tests__/integration-status.test.ts +32 -51
  51. package/src/__tests__/intent-routing.test.ts +0 -1
  52. package/src/__tests__/invite-routes-http.test.ts +10 -9
  53. package/src/__tests__/keychain-broker-client.test.ts +11 -43
  54. package/src/__tests__/notification-routing-intent.test.ts +0 -1
  55. package/src/__tests__/oauth-cli.test.ts +373 -14
  56. package/src/__tests__/oauth-provider-profiles.test.ts +9 -9
  57. package/src/__tests__/oauth-scope-policy.test.ts +4 -6
  58. package/src/__tests__/oauth-store.test.ts +756 -0
  59. package/src/__tests__/onboarding-starter-tasks.test.ts +0 -1
  60. package/src/__tests__/provider-error-scenarios.test.ts +0 -1
  61. package/src/__tests__/provider-streaming.benchmark.test.ts +0 -1
  62. package/src/__tests__/public-ingress-urls.test.ts +15 -21
  63. package/src/__tests__/recording-handler.test.ts +3 -4
  64. package/src/__tests__/registry.test.ts +2 -2
  65. package/src/__tests__/runtime-events-sse.test.ts +55 -7
  66. package/src/__tests__/schedule-store.test.ts +0 -1
  67. package/src/__tests__/scheduler-recurrence.test.ts +0 -1
  68. package/src/__tests__/scoped-approval-grants.test.ts +0 -1
  69. package/src/__tests__/scoped-grant-security-matrix.test.ts +0 -1
  70. package/src/__tests__/secret-ingress-handler.test.ts +0 -1
  71. package/src/__tests__/send-endpoint-busy.test.ts +21 -6
  72. package/src/__tests__/sequence-store.test.ts +0 -1
  73. package/src/__tests__/session-init.benchmark.test.ts +4 -5
  74. package/src/__tests__/skill-include-graph.test.ts +66 -0
  75. package/src/__tests__/skill-load-feature-flag.test.ts +0 -1
  76. package/src/__tests__/skill-load-tool.test.ts +149 -1
  77. package/src/__tests__/skill-projection-feature-flag.test.ts +0 -1
  78. package/src/__tests__/skills-uninstall.test.ts +1 -1
  79. package/src/__tests__/skills.test.ts +3 -3
  80. package/src/__tests__/slack-channel-config.test.ts +67 -3
  81. package/src/__tests__/slack-share-routes.test.ts +17 -19
  82. package/src/__tests__/system-prompt.test.ts +0 -1
  83. package/src/__tests__/telegram-invite-adapter.test.ts +18 -22
  84. package/src/__tests__/terminal-tools.test.ts +4 -3
  85. package/src/__tests__/test-support/computer-use-skill-harness.ts +3 -2
  86. package/src/__tests__/tool-approval-handler.test.ts +0 -1
  87. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +0 -1
  88. package/src/__tests__/tool-executor-lifecycle-events.test.ts +0 -1
  89. package/src/__tests__/tool-executor-shell-integration.test.ts +0 -1
  90. package/src/__tests__/tool-executor.test.ts +0 -1
  91. package/src/__tests__/tool-grant-request-escalation.test.ts +0 -1
  92. package/src/__tests__/trust-store-pattern-matches.test.ts +29 -0
  93. package/src/__tests__/trust-store.test.ts +1 -22
  94. package/src/__tests__/trusted-contact-approval-notifier.test.ts +0 -1
  95. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +0 -1
  96. package/src/__tests__/twilio-routes.test.ts +0 -16
  97. package/src/__tests__/verification-control-plane-policy.test.ts +0 -1
  98. package/src/__tests__/voice-scoped-grant-consumer.test.ts +0 -1
  99. package/src/agent/ax-tree-compaction.test.ts +235 -0
  100. package/src/agent/loop.ts +76 -130
  101. package/src/calls/call-domain.ts +1 -6
  102. package/src/calls/relay-server.ts +9 -13
  103. package/src/calls/twilio-config.ts +2 -7
  104. package/src/calls/twilio-routes.ts +1 -2
  105. package/src/calls/voice-ingress-preflight.ts +1 -1
  106. package/src/cli/commands/browser-relay.ts +18 -12
  107. package/src/cli/commands/completions.ts +0 -3
  108. package/src/cli/commands/credentials.ts +101 -15
  109. package/src/cli/commands/oauth/apps.ts +255 -0
  110. package/src/cli/commands/oauth/connections.ts +299 -0
  111. package/src/cli/commands/oauth/index.ts +52 -0
  112. package/src/cli/commands/oauth/providers.ts +242 -0
  113. package/src/cli/commands/skills.ts +4 -338
  114. package/src/cli/program.ts +1 -5
  115. package/src/cli/reference.ts +1 -3
  116. package/src/config/assistant-feature-flags.ts +0 -3
  117. package/src/config/bundled-skills/_shared/CLI_RETRIEVAL_PATTERN.md +1 -1
  118. package/src/config/bundled-skills/computer-use/SKILL.md +3 -6
  119. package/src/config/bundled-skills/computer-use/TOOLS.json +22 -4
  120. package/src/config/bundled-skills/google-calendar/calendar-client.ts +21 -16
  121. package/src/config/bundled-skills/messaging/tools/shared.ts +1 -4
  122. package/src/config/bundled-skills/settings/SKILL.md +1 -1
  123. package/src/config/bundled-skills/settings/TOOLS.json +2 -8
  124. package/src/config/bundled-skills/settings/tools/voice-config-update.ts +5 -33
  125. package/src/config/env-registry.ts +14 -83
  126. package/src/config/env.ts +11 -50
  127. package/src/config/feature-flag-registry.json +16 -16
  128. package/src/config/loader.ts +0 -6
  129. package/src/config/schema.ts +3 -1
  130. package/src/config/skills.ts +21 -2
  131. package/src/context/image-dimensions.ts +229 -0
  132. package/src/context/token-estimator.ts +75 -12
  133. package/src/context/window-manager.ts +49 -10
  134. package/src/daemon/assistant-attachments.ts +1 -13
  135. package/src/daemon/handlers/config-ingress.ts +8 -33
  136. package/src/daemon/handlers/config-slack-channel.ts +49 -46
  137. package/src/daemon/handlers/config-telegram.ts +32 -16
  138. package/src/daemon/handlers/sessions.ts +10 -24
  139. package/src/daemon/handlers/shared.ts +0 -130
  140. package/src/daemon/host-cu-proxy.ts +401 -0
  141. package/src/daemon/lifecycle.ts +36 -68
  142. package/src/daemon/message-protocol.ts +3 -0
  143. package/src/daemon/message-types/computer-use.ts +2 -119
  144. package/src/daemon/message-types/host-cu.ts +19 -0
  145. package/src/daemon/message-types/messages.ts +3 -0
  146. package/src/daemon/server.ts +14 -21
  147. package/src/daemon/session-agent-loop-handlers.ts +2 -0
  148. package/src/daemon/session-attachments.ts +1 -2
  149. package/src/daemon/session-slash.ts +1 -1
  150. package/src/daemon/session-surfaces.ts +40 -28
  151. package/src/daemon/session-tool-setup.ts +2 -9
  152. package/src/daemon/session.ts +138 -15
  153. package/src/daemon/tool-side-effects.ts +2 -8
  154. package/src/daemon/watch-handler.ts +2 -2
  155. package/src/events/tool-metrics-listener.ts +2 -2
  156. package/src/hooks/manager.ts +1 -4
  157. package/src/inbound/public-ingress-urls.ts +7 -7
  158. package/src/logfire.ts +16 -5
  159. package/src/memory/conversation-key-store.ts +21 -0
  160. package/src/memory/db-init.ts +4 -0
  161. package/src/memory/migrations/149-oauth-tables.ts +60 -0
  162. package/src/memory/migrations/index.ts +1 -0
  163. package/src/memory/schema/index.ts +1 -0
  164. package/src/memory/schema/oauth.ts +65 -0
  165. package/src/messaging/provider.ts +4 -4
  166. package/src/messaging/providers/gmail/client.ts +82 -2
  167. package/src/messaging/providers/gmail/people-client.ts +10 -10
  168. package/src/messaging/providers/telegram-bot/adapter.ts +17 -17
  169. package/src/messaging/providers/whatsapp/adapter.ts +11 -8
  170. package/src/messaging/registry.ts +2 -32
  171. package/src/notifications/copy-composer.ts +0 -5
  172. package/src/notifications/signal.ts +4 -5
  173. package/src/oauth/byo-connection.test.ts +126 -25
  174. package/src/oauth/byo-connection.ts +22 -6
  175. package/src/oauth/connect-orchestrator.ts +113 -57
  176. package/src/oauth/connect-types.ts +17 -23
  177. package/src/oauth/connection-resolver.ts +35 -11
  178. package/src/oauth/connection.ts +1 -1
  179. package/src/oauth/manual-token-connection.ts +104 -0
  180. package/src/oauth/oauth-store.ts +496 -0
  181. package/src/oauth/platform-connection.test.ts +29 -0
  182. package/src/oauth/platform-connection.ts +6 -5
  183. package/src/oauth/provider-behaviors.ts +124 -0
  184. package/src/oauth/scope-policy.ts +9 -2
  185. package/src/oauth/seed-providers.ts +161 -0
  186. package/src/oauth/token-persistence.ts +74 -78
  187. package/src/permissions/checker.ts +3 -3
  188. package/src/permissions/defaults.ts +0 -1
  189. package/src/permissions/prompter.ts +10 -1
  190. package/src/permissions/trust-store.ts +13 -0
  191. package/src/prompts/__tests__/build-cli-reference-section.test.ts +3 -1
  192. package/src/prompts/system-prompt.ts +28 -40
  193. package/src/providers/anthropic/client.ts +133 -24
  194. package/src/providers/retry.ts +1 -27
  195. package/src/runtime/auth/route-policy.ts +0 -3
  196. package/src/runtime/channel-reply-delivery.ts +0 -40
  197. package/src/runtime/gateway-client.ts +0 -7
  198. package/src/runtime/http-server.ts +8 -6
  199. package/src/runtime/http-types.ts +2 -2
  200. package/src/runtime/middleware/twilio-validation.ts +1 -11
  201. package/src/runtime/pending-interactions.ts +14 -12
  202. package/src/runtime/routes/channel-delivery-routes.ts +0 -1
  203. package/src/runtime/routes/conversation-routes.ts +73 -19
  204. package/src/runtime/routes/events-routes.ts +21 -11
  205. package/src/runtime/routes/host-cu-routes.ts +97 -0
  206. package/src/runtime/routes/inbound-stages/background-dispatch.ts +12 -111
  207. package/src/runtime/routes/integrations/slack/share.ts +6 -7
  208. package/src/runtime/routes/log-export-routes.ts +126 -8
  209. package/src/runtime/routes/settings-routes.ts +55 -48
  210. package/src/runtime/routes/surface-action-routes.ts +1 -1
  211. package/src/runtime/routes/watch-routes.ts +128 -0
  212. package/src/schedule/integration-status.ts +10 -9
  213. package/src/security/credential-key.ts +0 -156
  214. package/src/security/keychain-broker-client.ts +5 -6
  215. package/src/security/oauth2.ts +1 -1
  216. package/src/security/token-manager.ts +119 -46
  217. package/src/skills/catalog-install.ts +358 -0
  218. package/src/skills/include-graph.ts +32 -0
  219. package/src/telegram/bot-username.ts +2 -3
  220. package/src/tools/browser/network-recorder.ts +1 -1
  221. package/src/tools/browser/network-recording-types.ts +1 -1
  222. package/src/tools/computer-use/definitions.ts +46 -11
  223. package/src/tools/computer-use/registry.ts +4 -5
  224. package/src/tools/credentials/broker.ts +1 -2
  225. package/src/tools/credentials/metadata-store.ts +17 -121
  226. package/src/tools/credentials/vault.ts +94 -167
  227. package/src/tools/registry.ts +2 -7
  228. package/src/tools/skills/load.ts +62 -3
  229. package/src/tools/watch/watch-state.ts +0 -12
  230. package/src/util/logger.ts +7 -41
  231. package/src/util/platform.ts +9 -28
  232. package/src/watcher/providers/google-calendar.ts +2 -1
  233. package/src/__tests__/computer-use-session-compaction.test.ts +0 -143
  234. package/src/__tests__/computer-use-session-lifecycle.test.ts +0 -322
  235. package/src/__tests__/computer-use-session-working-dir.test.ts +0 -166
  236. package/src/__tests__/computer-use-skill-baseline.test.ts +0 -78
  237. package/src/__tests__/computer-use-skill-endstate.test.ts +0 -105
  238. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +0 -249
  239. package/src/__tests__/ride-shotgun-handler.test.ts +0 -452
  240. package/src/cli/commands/dev.ts +0 -129
  241. package/src/cli/commands/map.ts +0 -391
  242. package/src/cli/commands/oauth.ts +0 -77
  243. package/src/config/bundled-skills/computer-use/tools/computer-use-request-control.ts +0 -16
  244. package/src/daemon/computer-use-session.ts +0 -1026
  245. package/src/daemon/ride-shotgun-handler.ts +0 -569
  246. package/src/oauth/provider-base-urls.ts +0 -21
  247. package/src/oauth/provider-profiles.ts +0 -192
  248. package/src/prompts/computer-use-prompt.ts +0 -98
  249. package/src/runtime/routes/computer-use-routes.ts +0 -641
  250. package/src/runtime/telegram-streaming-delivery.test.ts +0 -729
  251. package/src/runtime/telegram-streaming-delivery.ts +0 -393
  252. package/src/tools/computer-use/request-computer-control.ts +0 -56
@@ -44,6 +44,62 @@ mock.module("../tools/registry.js", () => ({
44
44
  registerTool: () => {},
45
45
  }));
46
46
 
47
+ // ---------------------------------------------------------------------------
48
+ // Mock oauth-store to avoid SQLite dependency in unit tests
49
+ // ---------------------------------------------------------------------------
50
+
51
+ let mockGetMostRecentAppByProvider: ReturnType<
52
+ typeof mock<(key: string) => unknown>
53
+ >;
54
+ let mockGetAppByProviderAndClientId: ReturnType<
55
+ typeof mock<(key: string, clientId: string) => unknown>
56
+ >;
57
+ let mockGetProvider: ReturnType<typeof mock<(key: string) => unknown>>;
58
+
59
+ mock.module("../oauth/oauth-store.js", () => {
60
+ mockGetMostRecentAppByProvider = mock(() => undefined);
61
+ mockGetAppByProviderAndClientId = mock(() => undefined);
62
+ mockGetProvider = mock(() => undefined);
63
+ return {
64
+ getMostRecentAppByProvider: mockGetMostRecentAppByProvider,
65
+ getAppByProviderAndClientId: mockGetAppByProviderAndClientId,
66
+ getProvider: mockGetProvider,
67
+ listConnections: mock(() => []),
68
+ seedProviders: mock(() => {}),
69
+ disconnectOAuthProvider: mock(async () => "not-found" as const),
70
+ };
71
+ });
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Mock public ingress URL — not available in unit tests. The connect
75
+ // orchestrator dynamically imports this for non-interactive flows.
76
+ // ---------------------------------------------------------------------------
77
+
78
+ mock.module("../inbound/public-ingress-urls.js", () => ({
79
+ getPublicBaseUrl: () => {
80
+ throw new Error("No public ingress URL configured");
81
+ },
82
+ }));
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Mock prepareOAuth2Flow — unit tests should not start real loopback HTTP
86
+ // servers. The connect orchestrator still runs its own validation logic
87
+ // (scope policy, non-interactive ingress checks, etc.) but the actual
88
+ // OAuth flow setup is stubbed.
89
+ // ---------------------------------------------------------------------------
90
+
91
+ mock.module("../security/oauth2.js", () => ({
92
+ prepareOAuth2Flow: mock(async () => ({
93
+ authUrl: "https://mock-auth-url.example.com/authorize",
94
+ state: "mock-state",
95
+ completion: new Promise(() => {}),
96
+ })),
97
+ startOAuth2Flow: mock(async () => ({
98
+ grantedScopes: [],
99
+ tokens: { access_token: "mock-token" },
100
+ })),
101
+ }));
102
+
47
103
  // ---------------------------------------------------------------------------
48
104
  // Imports under test
49
105
  // ---------------------------------------------------------------------------
@@ -473,18 +529,50 @@ describe("credential_store tool — prompt action", () => {
473
529
  // ---------------------------------------------------------------------------
474
530
 
475
531
  describe("credential_store tool — oauth2_connect error paths", () => {
532
+ /** Well-known provider rows returned by the mocked getProvider */
533
+ const wellKnownProviders: Record<string, object> = {
534
+ "integration:gmail": {
535
+ key: "integration:gmail",
536
+ authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
537
+ tokenUrl: "https://oauth2.googleapis.com/token",
538
+ defaultScopes: JSON.stringify(["https://mail.google.com/"]),
539
+ scopePolicy: JSON.stringify({}),
540
+ callbackTransport: "loopback",
541
+ loopbackPort: 8756,
542
+ },
543
+ "integration:slack": {
544
+ key: "integration:slack",
545
+ authUrl: "https://slack.com/oauth/v2/authorize",
546
+ tokenUrl: "https://slack.com/api/oauth.v2.access",
547
+ defaultScopes: JSON.stringify(["channels:read"]),
548
+ scopePolicy: JSON.stringify({}),
549
+ },
550
+ };
551
+
476
552
  beforeEach(() => {
477
553
  if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true });
478
554
  mkdirSync(TEST_DIR, { recursive: true });
479
555
  _setStorePath(STORE_PATH);
480
556
  _resetBackend();
481
557
  _setMetadataPath(join(TEST_DIR, "metadata.json"));
558
+ // Return well-known provider rows so vault.ts knows gmail/slack are
559
+ // registered, and custom providers return undefined.
560
+ mockGetProvider.mockImplementation(
561
+ (key: string) => wellKnownProviders[key] ?? undefined,
562
+ );
563
+ mockGetMostRecentAppByProvider.mockClear();
564
+ mockGetMostRecentAppByProvider.mockImplementation(() => undefined);
565
+ mockGetAppByProviderAndClientId.mockClear();
566
+ mockGetAppByProviderAndClientId.mockImplementation(() => undefined);
482
567
  });
483
568
 
484
569
  afterEach(() => {
485
570
  _setMetadataPath(null);
486
571
  _setStorePath(null);
487
572
  _resetBackend();
573
+ mockGetProvider.mockImplementation(() => undefined);
574
+ mockGetMostRecentAppByProvider.mockImplementation(() => undefined);
575
+ mockGetAppByProviderAndClientId.mockImplementation(() => undefined);
488
576
  });
489
577
 
490
578
  test("requires service parameter", async () => {
@@ -496,55 +584,38 @@ describe("credential_store tool — oauth2_connect error paths", () => {
496
584
  expect(result.content).toContain("service is required");
497
585
  });
498
586
 
499
- test("requires auth_url for unknown service", async () => {
500
- const result = await credentialStoreTool.execute(
501
- {
502
- action: "oauth2_connect",
503
- service: "custom-svc",
504
- token_url: "https://t",
505
- scopes: ["read"],
506
- },
507
- _ctx,
508
- );
509
- expect(result.isError).toBe(true);
510
- expect(result.content).toContain("auth_url is required");
511
- });
512
-
513
- test("requires token_url for unknown service", async () => {
514
- const result = await credentialStoreTool.execute(
515
- {
516
- action: "oauth2_connect",
517
- service: "custom-svc",
518
- auth_url: "https://a",
519
- scopes: ["read"],
520
- },
521
- _ctx,
522
- );
523
- expect(result.isError).toBe(true);
524
- expect(result.content).toContain("token_url is required");
525
- });
526
-
527
- test("requires scopes for unknown service", async () => {
587
+ test("rejects unknown service without registered provider", async () => {
528
588
  const result = await credentialStoreTool.execute(
529
589
  {
530
590
  action: "oauth2_connect",
531
591
  service: "custom-svc",
532
592
  auth_url: "https://a",
533
593
  token_url: "https://t",
594
+ scopes: ["read"],
534
595
  },
535
596
  _ctx,
536
597
  );
537
598
  expect(result.isError).toBe(true);
538
- expect(result.content).toContain("scopes is required");
599
+ expect(result.content).toContain("no OAuth provider registered");
539
600
  });
540
601
 
541
602
  test("requires client_id", async () => {
603
+ mockGetProvider.mockImplementation((key: string) => {
604
+ if (key === "custom-svc") {
605
+ return {
606
+ key: "custom-svc",
607
+ authUrl: "https://auth.example.com",
608
+ tokenUrl: "https://token.example.com",
609
+ defaultScopes: JSON.stringify(["read"]),
610
+ scopePolicy: JSON.stringify({}),
611
+ };
612
+ }
613
+ return wellKnownProviders[key] ?? undefined;
614
+ });
542
615
  const result = await credentialStoreTool.execute(
543
616
  {
544
617
  action: "oauth2_connect",
545
618
  service: "custom-svc",
546
- auth_url: "https://auth.example.com",
547
- token_url: "https://token.example.com",
548
619
  scopes: ["read"],
549
620
  },
550
621
  _ctx,
@@ -554,6 +625,21 @@ describe("credential_store tool — oauth2_connect error paths", () => {
554
625
  });
555
626
 
556
627
  test("requires interactive context", async () => {
628
+ // Register custom-svc as a provider so the orchestrator finds it
629
+ // and reaches the non-interactive check (gateway transport).
630
+ mockGetProvider.mockImplementation((key: string) => {
631
+ if (key === "custom-svc") {
632
+ return {
633
+ key: "custom-svc",
634
+ authUrl: "https://auth.example.com",
635
+ tokenUrl: "https://token.example.com",
636
+ defaultScopes: JSON.stringify(["read"]),
637
+ scopePolicy: JSON.stringify({}),
638
+ };
639
+ }
640
+ return wellKnownProviders[key] ?? undefined;
641
+ });
642
+
557
643
  const result = await credentialStoreTool.execute(
558
644
  {
559
645
  action: "oauth2_connect",
@@ -596,18 +682,25 @@ describe("credential_store tool — oauth2_connect error paths", () => {
596
682
  expect(result.content).toContain("client_id is required");
597
683
  });
598
684
 
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
- });
607
- setSecureKey(
608
- credentialKey("integration:gmail", "client_secret"),
609
- "test-secret",
610
- );
685
+ test("uses stored client_id from oauth-store DB", async () => {
686
+ // Mock getMostRecentAppByProvider to return an app with a client_id
687
+ // and store client_secret in the secure store.
688
+ mockGetMostRecentAppByProvider.mockImplementation(() => ({
689
+ id: "test-app-id",
690
+ providerKey: "integration:gmail",
691
+ clientId: "stored-client-id-123",
692
+ createdAt: Date.now(),
693
+ }));
694
+ mockGetProvider.mockImplementation(() => ({
695
+ key: "integration:gmail",
696
+ authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
697
+ tokenUrl: "https://oauth2.googleapis.com/token",
698
+ defaultScopes: JSON.stringify(["https://mail.google.com/"]),
699
+ scopePolicy: JSON.stringify({}),
700
+ callbackTransport: "loopback",
701
+ loopbackPort: 8756,
702
+ }));
703
+ setSecureKey("oauth_app/test-app-id/client_secret", "test-secret");
611
704
 
612
705
  const result = await credentialStoreTool.execute(
613
706
  {
@@ -624,14 +717,148 @@ describe("credential_store tool — oauth2_connect error paths", () => {
624
717
  expect(result.content).toContain("To connect gmail, open this link");
625
718
  expect(result.content).not.toContain("client_id is required");
626
719
  expect(result.content).not.toContain("client_secret is required");
720
+
721
+ // Reset mocks
722
+ mockGetMostRecentAppByProvider.mockImplementation(() => undefined);
723
+ mockGetProvider.mockImplementation(() => undefined);
724
+ });
725
+
726
+ test("uses getAppByProviderAndClientId when client_id is provided without client_secret", async () => {
727
+ // When client_id is supplied but client_secret is not, the vault should
728
+ // look up the matching app via getAppByProviderAndClientId (not the
729
+ // most-recent-app heuristic) so the secret comes from the correct app.
730
+ mockGetAppByProviderAndClientId.mockImplementation(
731
+ (providerKey: string, cId: string) => {
732
+ if (
733
+ providerKey === "integration:gmail" &&
734
+ cId === "caller-supplied-client-id"
735
+ ) {
736
+ return {
737
+ id: "matched-app-id",
738
+ providerKey: "integration:gmail",
739
+ clientId: "caller-supplied-client-id",
740
+ createdAt: Date.now(),
741
+ };
742
+ }
743
+ return undefined;
744
+ },
745
+ );
746
+ mockGetProvider.mockImplementation(() => ({
747
+ key: "integration:gmail",
748
+ authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
749
+ tokenUrl: "https://oauth2.googleapis.com/token",
750
+ defaultScopes: JSON.stringify(["https://mail.google.com/"]),
751
+ scopePolicy: JSON.stringify({}),
752
+ callbackTransport: "loopback",
753
+ loopbackPort: 8756,
754
+ }));
755
+ setSecureKey("oauth_app/matched-app-id/client_secret", "matched-secret");
756
+
757
+ const result = await credentialStoreTool.execute(
758
+ {
759
+ action: "oauth2_connect",
760
+ service: "gmail",
761
+ client_id: "caller-supplied-client-id",
762
+ },
763
+ { ..._ctx, isInteractive: false },
764
+ );
765
+
766
+ // Should succeed — client_secret resolved from the matched app
767
+ expect(result.isError).toBe(false);
768
+ expect(result.content).toContain("To connect gmail, open this link");
769
+ // getMostRecentAppByProvider should NOT have been called since client_id was known
770
+ expect(mockGetMostRecentAppByProvider).not.toHaveBeenCalled();
771
+
772
+ // Reset mocks
773
+ mockGetAppByProviderAndClientId.mockImplementation(() => undefined);
774
+ mockGetProvider.mockImplementation(() => undefined);
775
+ });
776
+
777
+ test("falls back to getMostRecentAppByProvider when client_id is not provided", async () => {
778
+ // When neither client_id nor client_secret is provided, the vault should
779
+ // use getMostRecentAppByProvider (the fallback heuristic).
780
+ mockGetMostRecentAppByProvider.mockImplementation(() => ({
781
+ id: "recent-app-id",
782
+ providerKey: "integration:gmail",
783
+ clientId: "recent-client-id",
784
+ createdAt: Date.now(),
785
+ }));
786
+ mockGetProvider.mockImplementation(() => ({
787
+ key: "integration:gmail",
788
+ authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
789
+ tokenUrl: "https://oauth2.googleapis.com/token",
790
+ defaultScopes: JSON.stringify(["https://mail.google.com/"]),
791
+ scopePolicy: JSON.stringify({}),
792
+ callbackTransport: "loopback",
793
+ loopbackPort: 8756,
794
+ }));
795
+ setSecureKey("oauth_app/recent-app-id/client_secret", "recent-secret");
796
+
797
+ const result = await credentialStoreTool.execute(
798
+ {
799
+ action: "oauth2_connect",
800
+ service: "gmail",
801
+ },
802
+ { ..._ctx, isInteractive: false },
803
+ );
804
+
805
+ expect(result.isError).toBe(false);
806
+ expect(result.content).toContain("To connect gmail, open this link");
807
+ // getAppByProviderAndClientId should NOT have been called since client_id was unknown
808
+ expect(mockGetAppByProviderAndClientId).not.toHaveBeenCalled();
809
+
810
+ // Reset mocks
811
+ mockGetMostRecentAppByProvider.mockImplementation(() => undefined);
812
+ mockGetProvider.mockImplementation(() => undefined);
813
+ });
814
+
815
+ test("getAppByProviderAndClientId returning undefined leaves client_secret unresolved", async () => {
816
+ // When client_id is provided but getAppByProviderAndClientId returns no
817
+ // matching app, client_secret remains unresolved and the vault should
818
+ // report the missing secret error.
819
+ mockGetAppByProviderAndClientId.mockImplementation(() => undefined);
820
+ mockGetProvider.mockImplementation(() => ({
821
+ key: "integration:gmail",
822
+ authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
823
+ tokenUrl: "https://oauth2.googleapis.com/token",
824
+ defaultScopes: JSON.stringify(["https://mail.google.com/"]),
825
+ }));
826
+
827
+ const result = await credentialStoreTool.execute(
828
+ {
829
+ action: "oauth2_connect",
830
+ service: "gmail",
831
+ client_id: "unknown-client-id",
832
+ },
833
+ _ctx,
834
+ );
835
+
836
+ expect(result.isError).toBe(true);
837
+ expect(result.content).toContain("client_secret is required for gmail");
838
+ // getMostRecentAppByProvider should NOT have been called
839
+ expect(mockGetMostRecentAppByProvider).not.toHaveBeenCalled();
840
+
841
+ // Reset mocks
842
+ mockGetAppByProviderAndClientId.mockImplementation(() => undefined);
843
+ mockGetProvider.mockImplementation(() => undefined);
627
844
  });
628
845
 
629
846
  test("rejects when client_secret is missing for service that requires it", async () => {
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
- });
847
+ // Mock getMostRecentAppByProvider to return an app with client_id but
848
+ // no client_secret in secure storage — validates the requiresClientSecret
849
+ // guardrail.
850
+ mockGetMostRecentAppByProvider.mockImplementation(() => ({
851
+ id: "test-app-id-no-secret",
852
+ providerKey: "integration:gmail",
853
+ clientId: "stored-client-id-456",
854
+ createdAt: Date.now(),
855
+ }));
856
+ mockGetProvider.mockImplementation(() => ({
857
+ key: "integration:gmail",
858
+ authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
859
+ tokenUrl: "https://oauth2.googleapis.com/token",
860
+ defaultScopes: JSON.stringify(["https://mail.google.com/"]),
861
+ }));
635
862
 
636
863
  const result = await credentialStoreTool.execute(
637
864
  {
@@ -643,6 +870,10 @@ describe("credential_store tool — oauth2_connect error paths", () => {
643
870
 
644
871
  expect(result.isError).toBe(true);
645
872
  expect(result.content).toContain("client_secret is required for gmail");
873
+
874
+ // Reset mocks
875
+ mockGetMostRecentAppByProvider.mockImplementation(() => undefined);
876
+ mockGetProvider.mockImplementation(() => undefined);
646
877
  });
647
878
  });
648
879
 
@@ -65,6 +65,58 @@ mock.module("../security/oauth2.js", () => {
65
65
  };
66
66
  });
67
67
 
68
+ // ---------------------------------------------------------------------------
69
+ // Mock oauth-store — token-manager reads refresh config from SQLite
70
+ // ---------------------------------------------------------------------------
71
+
72
+ /** Mutable per-test map of provider connections for getConnectionByProvider */
73
+ const mockConnections = new Map<
74
+ string,
75
+ {
76
+ id: string;
77
+ providerKey: string;
78
+ oauthAppId: string;
79
+ expiresAt: number | null;
80
+ }
81
+ >();
82
+ const mockApps = new Map<
83
+ string,
84
+ { id: string; providerKey: string; clientId: string }
85
+ >();
86
+ const mockProviders = new Map<
87
+ string,
88
+ {
89
+ key: string;
90
+ tokenUrl: string;
91
+ tokenEndpointAuthMethod?: string;
92
+ }
93
+ >();
94
+
95
+ let mockDisconnectOAuthProvider: ReturnType<
96
+ typeof mock<
97
+ (providerKey: string) => Promise<"disconnected" | "not-found" | "error">
98
+ >
99
+ >;
100
+
101
+ mock.module("../oauth/oauth-store.js", () => {
102
+ mockDisconnectOAuthProvider = mock((providerKey: string) =>
103
+ Promise.resolve(
104
+ mockConnections.has(providerKey)
105
+ ? ("disconnected" as const)
106
+ : ("not-found" as const),
107
+ ),
108
+ );
109
+ return {
110
+ disconnectOAuthProvider: mockDisconnectOAuthProvider,
111
+ getConnectionByProvider: (service: string) => mockConnections.get(service),
112
+ getApp: (id: string) => mockApps.get(id),
113
+ getProvider: (key: string) => mockProviders.get(key),
114
+ updateConnection: () => {},
115
+ getMostRecentAppByProvider: () => undefined,
116
+ listConnections: () => [],
117
+ };
118
+ });
119
+
68
120
  // ---------------------------------------------------------------------------
69
121
  // Import the module under test
70
122
  // ---------------------------------------------------------------------------
@@ -85,7 +137,6 @@ import {
85
137
  import {
86
138
  _setMetadataPath,
87
139
  getCredentialMetadata,
88
- upsertCredentialMetadata,
89
140
  } from "../tools/credentials/metadata-store.js";
90
141
  import { credentialStoreTool } from "../tools/credentials/vault.js";
91
142
  import type { ToolContext } from "../tools/types.js";
@@ -214,12 +265,15 @@ describe("credential_store tool", () => {
214
265
  }
215
266
  _setStorePath(STORE_PATH);
216
267
  _setMetadataPath(join(TEST_DIR, "metadata.json"));
268
+ mockDisconnectOAuthProvider.mockClear();
269
+ mockConnections.clear();
217
270
  });
218
271
 
219
272
  afterEach(() => {
220
273
  _setMetadataPath(null);
221
274
  _setStorePath(null);
222
275
  _resetBackend();
276
+ mockConnections.clear();
223
277
  });
224
278
 
225
279
  afterAll(() => {
@@ -664,6 +718,44 @@ describe("credential_store tool", () => {
664
718
  expect(result.isError).toBe(true);
665
719
  expect(result.content).toContain("field is required");
666
720
  });
721
+
722
+ test("delete also disconnects OAuth connection for the service", async () => {
723
+ // Store a credential via the real tool so metadata exists
724
+ await credentialStoreTool.execute(
725
+ {
726
+ action: "store",
727
+ service: "integration:gmail",
728
+ field: "api_key",
729
+ value: "test-value",
730
+ },
731
+ _ctx,
732
+ );
733
+
734
+ // Simulate an active OAuth connection for this service
735
+ mockConnections.set("integration:gmail", {
736
+ id: "conn-gmail",
737
+ providerKey: "integration:gmail",
738
+ oauthAppId: "app-gmail",
739
+ expiresAt: Date.now() + 3600_000,
740
+ });
741
+
742
+ const result = await credentialStoreTool.execute(
743
+ {
744
+ action: "delete",
745
+ service: "integration:gmail",
746
+ field: "api_key",
747
+ },
748
+ _ctx,
749
+ );
750
+
751
+ expect(result.isError).toBe(false);
752
+ expect(result.content).toContain("Deleted credential");
753
+ // Verify disconnectOAuthProvider was called with the service name
754
+ expect(mockDisconnectOAuthProvider).toHaveBeenCalledTimes(1);
755
+ expect(mockDisconnectOAuthProvider).toHaveBeenCalledWith(
756
+ "integration:gmail",
757
+ );
758
+ });
667
759
  });
668
760
 
669
761
  // -----------------------------------------------------------------------
@@ -1172,6 +1264,10 @@ describe("withValidToken refresh deduplication", () => {
1172
1264
  _resetRefreshBreakers();
1173
1265
  _resetInflightRefreshes();
1174
1266
  mockRefreshOAuth2Token.mockClear();
1267
+ // Clear mock oauth-store maps
1268
+ mockConnections.clear();
1269
+ mockApps.clear();
1270
+ mockProviders.clear();
1175
1271
  });
1176
1272
 
1177
1273
  afterEach(() => {
@@ -1180,6 +1276,9 @@ describe("withValidToken refresh deduplication", () => {
1180
1276
  _resetBackend();
1181
1277
  _resetRefreshBreakers();
1182
1278
  _resetInflightRefreshes();
1279
+ mockConnections.clear();
1280
+ mockApps.clear();
1281
+ mockProviders.clear();
1183
1282
  });
1184
1283
 
1185
1284
  afterAll(() => {
@@ -1187,26 +1286,48 @@ describe("withValidToken refresh deduplication", () => {
1187
1286
  });
1188
1287
 
1189
1288
  /**
1190
- * Helper: set up a service with an access token, refresh token, and OAuth2
1191
- * metadata so that token refresh can proceed through doRefresh().
1289
+ * Helper: set up a service with an access token, refresh token, and
1290
+ * mock DB data so that token refresh can proceed through doRefresh().
1291
+ *
1292
+ * OAuth-specific fields (tokenUrl, clientId, expiresAt) are now stored
1293
+ * in the SQLite oauth-store. The mock maps simulate the DB layer.
1192
1294
  */
1193
1295
  function setupService(
1194
1296
  service: string,
1195
1297
  opts?: { expired?: boolean; accessToken?: string },
1196
1298
  ) {
1197
1299
  const accessToken = opts?.accessToken ?? "old-access-token";
1198
- setSecureKey(credentialKey(service, "access_token"), accessToken);
1300
+
1301
+ // Seed mock oauth-store maps so token-manager can resolve refresh config
1302
+ const appId = `app-${service}`;
1303
+ const connId = `conn-${service}`;
1304
+
1305
+ // Store access token under the oauth_connection key path that
1306
+ // withValidToken reads (not the legacy credentialKey path).
1307
+ setSecureKey(`oauth_connection/${connId}/access_token`, accessToken);
1308
+ mockProviders.set(service, {
1309
+ key: service,
1310
+ tokenUrl: "https://oauth.example.com/token",
1311
+ });
1312
+ mockApps.set(appId, {
1313
+ id: appId,
1314
+ providerKey: service,
1315
+ clientId: "test-client-id",
1316
+ });
1317
+ mockConnections.set(service, {
1318
+ id: connId,
1319
+ providerKey: service,
1320
+ oauthAppId: appId,
1321
+ expiresAt: opts?.expired
1322
+ ? Date.now() - 60_000 // expired 1 minute ago
1323
+ : Date.now() + 3600_000, // expires in 1 hour
1324
+ });
1325
+ // Store refresh token and client_secret in secure keys (token-manager reads them)
1199
1326
  setSecureKey(
1200
- credentialKey(service, "refresh_token"),
1327
+ `oauth_connection/${connId}/refresh_token`,
1201
1328
  "valid-refresh-token",
1202
1329
  );
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
- });
1330
+ setSecureKey(`oauth_app/${appId}/client_secret`, "test-client-secret");
1210
1331
  }
1211
1332
 
1212
1333
  test("3 concurrent 401 refreshes for the same service call doRefresh exactly once", async () => {
@@ -1335,22 +1456,23 @@ describe("withValidToken refresh deduplication", () => {
1335
1456
 
1336
1457
  const err401 = Object.assign(new Error("Unauthorized"), { status: 401 });
1337
1458
 
1338
- // First call triggers a refresh
1459
+ // First call triggers a refresh (old token → 401 → refresh → token-1)
1339
1460
  const r1 = await withValidToken(
1340
1461
  "integration:gmail",
1341
1462
  async (token: string) => {
1342
- if (token === "old-access-token") throw err401;
1463
+ if (token !== "token-1") throw err401;
1343
1464
  return token;
1344
1465
  },
1345
1466
  );
1346
1467
  expect(r1).toBe("token-1");
1347
1468
  expect(refreshCount).toBe(1);
1348
1469
 
1349
- // Set up so the next call will also get a 401 (token-1 stored from first refresh)
1470
+ // Second call also triggers a 401 to verify dedup state was cleaned up
1471
+ // and a new refresh is allowed (not deduplicated with the first).
1350
1472
  const r2 = await withValidToken(
1351
1473
  "integration:gmail",
1352
1474
  async (token: string) => {
1353
- if (token === "token-1") throw err401;
1475
+ if (token !== "token-2") throw err401;
1354
1476
  return token;
1355
1477
  },
1356
1478
  );