@vellumai/assistant 0.5.11 → 0.5.13

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 (209) hide show
  1. package/Dockerfile +42 -9
  2. package/docs/architecture/integrations.md +34 -32
  3. package/node_modules/@vellumai/ces-contracts/src/__tests__/grants.test.ts +7 -7
  4. package/node_modules/@vellumai/ces-contracts/src/handles.ts +5 -4
  5. package/node_modules/@vellumai/ces-contracts/src/index.ts +7 -0
  6. package/node_modules/@vellumai/ces-contracts/src/rpc.ts +5 -0
  7. package/node_modules/@vellumai/credential-storage/src/index.ts +1 -1
  8. package/openapi.yaml +87 -9
  9. package/package.json +1 -1
  10. package/src/__tests__/catalog-cache.test.ts +164 -0
  11. package/src/__tests__/catalog-search.test.ts +61 -0
  12. package/src/__tests__/cli-command-risk-guard.test.ts +181 -6
  13. package/src/__tests__/conversation-delete-schedule-cleanup.test.ts +396 -0
  14. package/src/__tests__/conversation-error.test.ts +3 -2
  15. package/src/__tests__/credential-security-invariants.test.ts +9 -15
  16. package/src/__tests__/credential-vault-unit.test.ts +32 -34
  17. package/src/__tests__/credential-vault.test.ts +25 -33
  18. package/src/__tests__/credentials-cli.test.ts +3 -3
  19. package/src/__tests__/daemon-credential-client.test.ts +2 -2
  20. package/src/__tests__/first-greeting.test.ts +7 -0
  21. package/src/__tests__/host-bash-proxy.test.ts +79 -0
  22. package/src/__tests__/host-cu-proxy.test.ts +90 -0
  23. package/src/__tests__/host-file-proxy.test.ts +89 -0
  24. package/src/__tests__/integration-status.test.ts +5 -5
  25. package/src/__tests__/list-messages-attachments.test.ts +171 -0
  26. package/src/__tests__/mcp-abort-signal.test.ts +205 -0
  27. package/src/__tests__/messaging-send-tool.test.ts +5 -5
  28. package/src/__tests__/navigate-settings-tab.test.ts +6 -2
  29. package/src/__tests__/notification-telegram-adapter.test.ts +125 -0
  30. package/src/__tests__/oauth-cli.test.ts +126 -119
  31. package/src/__tests__/oauth-provider-profiles.test.ts +55 -20
  32. package/src/__tests__/oauth-scope-policy.test.ts +4 -6
  33. package/src/__tests__/onboarding-template-contract.test.ts +2 -2
  34. package/src/__tests__/platform.test.ts +3 -168
  35. package/src/__tests__/secret-routes-managed-proxy.test.ts +78 -0
  36. package/src/__tests__/secure-keys-managed-failover.test.ts +73 -0
  37. package/src/__tests__/skill-feature-flags.test.ts +8 -0
  38. package/src/__tests__/skill-secret-handling-guard.test.ts +212 -0
  39. package/src/__tests__/skills-uninstall.test.ts +2 -2
  40. package/src/__tests__/slack-messaging-token-resolution.test.ts +22 -24
  41. package/src/__tests__/slack-share-routes.test.ts +5 -5
  42. package/src/__tests__/system-prompt.test.ts +39 -0
  43. package/src/__tests__/token-estimator-accuracy.benchmark.test.ts +1 -1
  44. package/src/__tests__/workspace-migration-backfill-installation-id.test.ts +5 -4
  45. package/src/cli/AGENTS.md +47 -7
  46. package/src/cli/commands/browser-relay.ts +2 -17
  47. package/src/cli/commands/contacts.ts +6 -4
  48. package/src/cli/commands/conversations.ts +13 -1
  49. package/src/cli/commands/credential-execution.ts +16 -1
  50. package/src/cli/commands/credentials.ts +2 -8
  51. package/src/cli/commands/oauth/__tests__/connect.test.ts +29 -108
  52. package/src/cli/commands/oauth/__tests__/disconnect.test.ts +13 -87
  53. package/src/cli/commands/oauth/__tests__/mode.test.ts +22 -69
  54. package/src/cli/commands/oauth/__tests__/ping.test.ts +20 -79
  55. package/src/cli/commands/oauth/__tests__/providers-delete.test.ts +574 -0
  56. package/src/cli/commands/oauth/__tests__/providers-update.test.ts +416 -0
  57. package/src/cli/commands/oauth/__tests__/status.test.ts +12 -40
  58. package/src/cli/commands/oauth/__tests__/token.test.ts +3 -50
  59. package/src/cli/commands/oauth/apps.ts +63 -44
  60. package/src/cli/commands/oauth/connect.ts +187 -155
  61. package/src/cli/commands/oauth/disconnect.ts +27 -75
  62. package/src/cli/commands/oauth/index.ts +36 -46
  63. package/src/cli/commands/oauth/mode.ts +22 -34
  64. package/src/cli/commands/oauth/ping.ts +19 -45
  65. package/src/cli/commands/oauth/providers.ts +569 -62
  66. package/src/cli/commands/oauth/request.ts +36 -48
  67. package/src/cli/commands/oauth/shared.ts +1 -19
  68. package/src/cli/commands/oauth/status.ts +14 -25
  69. package/src/cli/commands/oauth/token.ts +25 -34
  70. package/src/cli/commands/platform/__tests__/connect.test.ts +224 -0
  71. package/src/cli/commands/platform/__tests__/disconnect.test.ts +237 -0
  72. package/src/cli/commands/platform/__tests__/status.test.ts +246 -0
  73. package/src/cli/commands/platform/connect.ts +104 -0
  74. package/src/cli/commands/platform/disconnect.ts +118 -0
  75. package/src/cli/commands/{platform.ts → platform/index.ts} +108 -38
  76. package/src/cli/commands/sequence.ts +5 -4
  77. package/src/cli/commands/shotgun.ts +16 -0
  78. package/src/cli/commands/skills.ts +173 -41
  79. package/src/cli/commands/usage.ts +5 -11
  80. package/src/cli/lib/daemon-credential-client.ts +22 -38
  81. package/src/cli/program.ts +1 -1
  82. package/src/config/assistant-feature-flags.ts +3 -7
  83. package/src/config/bundled-skills/contacts/tools/google-contacts.ts +1 -1
  84. package/src/config/bundled-skills/conversations/SKILL.md +20 -0
  85. package/src/config/bundled-skills/conversations/TOOLS.json +23 -0
  86. package/src/config/bundled-skills/conversations/tools/rename-conversation.ts +66 -0
  87. package/src/config/bundled-skills/gmail/SKILL.md +13 -13
  88. package/src/config/bundled-skills/gmail/tools/gmail-archive.ts +3 -3
  89. package/src/config/bundled-skills/gmail/tools/gmail-attachments.ts +2 -2
  90. package/src/config/bundled-skills/gmail/tools/gmail-draft.ts +1 -1
  91. package/src/config/bundled-skills/gmail/tools/gmail-filters.ts +1 -1
  92. package/src/config/bundled-skills/gmail/tools/gmail-follow-up.ts +1 -1
  93. package/src/config/bundled-skills/gmail/tools/gmail-forward.ts +1 -1
  94. package/src/config/bundled-skills/gmail/tools/gmail-label.ts +2 -2
  95. package/src/config/bundled-skills/gmail/tools/gmail-outreach-scan.ts +1 -1
  96. package/src/config/bundled-skills/gmail/tools/gmail-send-draft.ts +1 -1
  97. package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +1 -1
  98. package/src/config/bundled-skills/gmail/tools/gmail-trash.ts +1 -1
  99. package/src/config/bundled-skills/gmail/tools/gmail-unsubscribe.ts +1 -1
  100. package/src/config/bundled-skills/gmail/tools/gmail-vacation.ts +1 -1
  101. package/src/config/bundled-skills/google-calendar/SKILL.md +10 -4
  102. package/src/config/bundled-skills/google-calendar/tools/shared.ts +1 -1
  103. package/src/config/bundled-skills/messaging/SKILL.md +7 -7
  104. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -2
  105. package/src/config/bundled-skills/messaging/tools/shared.ts +5 -6
  106. package/src/config/bundled-skills/settings/TOOLS.json +5 -3
  107. package/src/config/bundled-skills/settings/tools/navigate-settings-tab.ts +4 -2
  108. package/src/config/bundled-tool-registry.ts +5 -0
  109. package/src/config/feature-flag-registry.json +2 -2
  110. package/src/credential-execution/client.ts +15 -3
  111. package/src/daemon/conversation-agent-loop.ts +2 -0
  112. package/src/daemon/conversation-error.ts +36 -6
  113. package/src/daemon/conversation-messaging.ts +9 -0
  114. package/src/daemon/conversation-runtime-assembly.ts +33 -0
  115. package/src/daemon/conversation-surfaces.ts +120 -14
  116. package/src/daemon/conversation.ts +5 -0
  117. package/src/daemon/first-greeting.ts +6 -1
  118. package/src/daemon/handlers/skills.ts +148 -3
  119. package/src/daemon/host-bash-proxy.ts +16 -0
  120. package/src/daemon/host-cu-proxy.ts +16 -0
  121. package/src/daemon/host-file-proxy.ts +16 -0
  122. package/src/daemon/lifecycle.ts +56 -5
  123. package/src/daemon/message-types/conversations.ts +1 -0
  124. package/src/daemon/message-types/guardian-actions.ts +2 -0
  125. package/src/daemon/message-types/host-bash.ts +6 -1
  126. package/src/daemon/message-types/host-cu.ts +6 -1
  127. package/src/daemon/message-types/host-file.ts +6 -1
  128. package/src/daemon/message-types/integrations.ts +0 -1
  129. package/src/daemon/server.ts +29 -2
  130. package/src/hooks/cli.ts +74 -0
  131. package/src/inbound/platform-callback-registration.ts +7 -12
  132. package/src/index.ts +0 -12
  133. package/src/mcp/client.ts +6 -1
  134. package/src/mcp/manager.ts +2 -1
  135. package/src/memory/conversation-crud.ts +92 -3
  136. package/src/memory/conversation-key-store.ts +26 -0
  137. package/src/memory/conversation-queries.ts +6 -6
  138. package/src/memory/db-init.ts +16 -0
  139. package/src/memory/journal-memory.ts +8 -2
  140. package/src/memory/migrations/196-messages-conversation-created-at-index.ts +9 -0
  141. package/src/memory/migrations/196-strip-integration-prefix-from-provider-keys.ts +186 -0
  142. package/src/memory/migrations/197-oauth-providers-behavior-columns.ts +29 -0
  143. package/src/memory/migrations/198-drop-setup-skill-id-column.ts +11 -0
  144. package/src/memory/migrations/index.ts +4 -0
  145. package/src/memory/migrations/registry.ts +8 -0
  146. package/src/memory/schema/oauth.ts +11 -0
  147. package/src/messaging/provider.ts +13 -12
  148. package/src/messaging/providers/gmail/adapter.ts +44 -35
  149. package/src/messaging/providers/slack/adapter.ts +63 -33
  150. package/src/messaging/providers/telegram-bot/adapter.ts +6 -8
  151. package/src/messaging/providers/whatsapp/adapter.ts +6 -8
  152. package/src/notifications/adapters/telegram.ts +78 -2
  153. package/src/oauth/__tests__/identity-verifier.test.ts +464 -0
  154. package/src/oauth/byo-connection.test.ts +22 -24
  155. package/src/oauth/connect-orchestrator.ts +37 -76
  156. package/src/oauth/connect-types.ts +7 -65
  157. package/src/oauth/connection-resolver.test.ts +13 -13
  158. package/src/oauth/connection-resolver.ts +3 -4
  159. package/src/oauth/identity-verifier.ts +177 -0
  160. package/src/oauth/oauth-store.ts +228 -3
  161. package/src/oauth/platform-connection.test.ts +56 -6
  162. package/src/oauth/platform-connection.ts +8 -1
  163. package/src/oauth/seed-providers.ts +247 -34
  164. package/src/permissions/checker.ts +127 -1
  165. package/src/prompts/journal-context.ts +4 -1
  166. package/src/prompts/system-prompt.ts +54 -9
  167. package/src/prompts/templates/BOOTSTRAP.md +16 -5
  168. package/src/providers/anthropic/client.ts +2 -33
  169. package/src/runtime/guardian-action-service.ts +7 -2
  170. package/src/runtime/http-server.ts +12 -18
  171. package/src/runtime/http-types.ts +8 -1
  172. package/src/runtime/migrations/rebind-secrets-screen.ts +2 -2
  173. package/src/runtime/routes/conversation-management-routes.ts +31 -0
  174. package/src/runtime/routes/conversation-routes.ts +79 -4
  175. package/src/runtime/routes/guardian-action-routes.ts +15 -2
  176. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +21 -8
  177. package/src/runtime/routes/integrations/slack/share.ts +1 -1
  178. package/src/runtime/routes/oauth-apps.ts +2 -1
  179. package/src/runtime/routes/secret-routes.ts +45 -15
  180. package/src/runtime/routes/settings-routes.ts +12 -19
  181. package/src/runtime/routes/skills-routes.ts +45 -4
  182. package/src/schedule/integration-status.ts +2 -2
  183. package/src/security/ces-rpc-credential-backend.ts +19 -16
  184. package/src/security/oauth-completion-page.ts +153 -0
  185. package/src/security/oauth2.ts +3 -17
  186. package/src/security/secure-keys.ts +207 -7
  187. package/src/security/token-manager.ts +3 -6
  188. package/src/signals/bash.ts +6 -1
  189. package/src/skills/catalog-cache.ts +44 -0
  190. package/src/skills/catalog-search.ts +18 -0
  191. package/src/tools/browser/browser-manager.ts +2 -2
  192. package/src/tools/credentials/post-connect-hooks.ts +1 -1
  193. package/src/tools/credentials/vault.ts +34 -45
  194. package/src/tools/host-terminal/host-shell.ts +16 -3
  195. package/src/tools/mcp/mcp-tool-factory.ts +2 -1
  196. package/src/tools/skills/sandbox-runner.ts +16 -3
  197. package/src/tools/terminal/shell.ts +16 -3
  198. package/src/util/logger.ts +11 -1
  199. package/src/util/platform.ts +1 -91
  200. package/src/util/sentry-log-stream.ts +51 -0
  201. package/src/watcher/providers/github.ts +2 -2
  202. package/src/watcher/providers/gmail.ts +1 -1
  203. package/src/watcher/providers/google-calendar.ts +1 -1
  204. package/src/watcher/providers/linear.ts +2 -2
  205. package/src/workspace/migrations/011-backfill-installation-id.ts +5 -3
  206. package/src/workspace/migrations/020-rename-oauth-skill-dirs.ts +119 -0
  207. package/src/workspace/migrations/registry.ts +2 -0
  208. package/src/cli/commands/oauth/connections.ts +0 -255
  209. package/src/oauth/provider-behaviors.ts +0 -634
@@ -25,11 +25,10 @@ import { prepareOAuth2Flow, startOAuth2Flow } from "../security/oauth2.js";
25
25
  import { getLogger } from "../util/logger.js";
26
26
  import type {
27
27
  OAuthConnectResult,
28
- OAuthProviderBehavior,
29
28
  OAuthScopePolicy,
30
29
  } from "./connect-types.js";
30
+ import { verifyIdentity } from "./identity-verifier.js";
31
31
  import { getProvider } from "./oauth-store.js";
32
- import { getProviderBehavior, resolveService } from "./provider-behaviors.js";
33
32
  import { resolveScopes } from "./scope-policy.js";
34
33
  import { storeOAuth2Tokens } from "./token-persistence.js";
35
34
 
@@ -39,28 +38,6 @@ const log = getLogger("oauth-connect-orchestrator");
39
38
  // Helpers
40
39
  // ---------------------------------------------------------------------------
41
40
 
42
- /**
43
- * Look up the code-side behavioral fields for a provider.
44
- * Returns an empty object when no behavior is registered.
45
- */
46
- function resolveBehavior(providerKey: string): {
47
- identityVerifier?: OAuthProviderBehavior["identityVerifier"];
48
- setup?: OAuthProviderBehavior["setup"];
49
- setupSkillId?: string;
50
- postConnectHookId?: string;
51
- loopbackPort?: number;
52
- } {
53
- const behavior = getProviderBehavior(providerKey);
54
- if (!behavior) return {};
55
- return {
56
- identityVerifier: behavior.identityVerifier,
57
- setup: behavior.setup,
58
- setupSkillId: behavior.setupSkillId,
59
- postConnectHookId: behavior.postConnectHookId,
60
- loopbackPort: behavior.loopbackPort,
61
- };
62
- }
63
-
64
41
  /** Safely parse a JSON string, returning a fallback on failure or null/undefined input. */
65
42
  function safeJsonParse<T>(value: string | null | undefined, fallback: T): T {
66
43
  if (value == null) return fallback;
@@ -76,7 +53,7 @@ function safeJsonParse<T>(value: string | null | undefined, fallback: T): T {
76
53
  // ---------------------------------------------------------------------------
77
54
 
78
55
  export interface OAuthConnectOptions {
79
- /** Raw service name (may be an alias like "gmail"). */
56
+ /** Canonical service name (e.g. "google", "slack"). */
80
57
  service: string;
81
58
  /** Scopes to request beyond the provider's defaults. */
82
59
  requestedScopes?: string[];
@@ -119,11 +96,9 @@ export interface OAuthConnectOptions {
119
96
  export async function orchestrateOAuthConnect(
120
97
  options: OAuthConnectOptions,
121
98
  ): Promise<OAuthConnectResult> {
122
- const resolvedService = resolveService(options.service);
123
99
  log.info(
124
100
  {
125
- rawService: options.service,
126
- resolvedService,
101
+ service: options.service,
127
102
  isInteractive: options.isInteractive,
128
103
  hasOpenUrl: !!options.openUrl,
129
104
  hasSendToClient: !!options.sendToClient,
@@ -132,18 +107,15 @@ export async function orchestrateOAuthConnect(
132
107
  );
133
108
 
134
109
  // Read provider config from the DB
135
- const providerRow = getProvider(resolvedService);
110
+ const providerRow = getProvider(options.service);
136
111
  if (!providerRow) {
137
112
  return {
138
113
  success: false,
139
- error: `No OAuth provider registered for "${resolvedService}". Ensure the provider is seeded in the database.`,
114
+ error: `No OAuth provider registered for "${options.service}". Ensure the provider is seeded in the database.`,
140
115
  safeError: true,
141
116
  };
142
117
  }
143
118
 
144
- // Behavioral/code-side fields come from the behavior registry
145
- const behavior = resolveBehavior(resolvedService);
146
-
147
119
  // Deserialize JSON fields from the DB row
148
120
  const dbDefaultScopes = safeJsonParse<string[]>(
149
121
  providerRow.defaultScopes,
@@ -173,11 +145,11 @@ export async function orchestrateOAuthConnect(
173
145
  const callbackTransport =
174
146
  (providerRow.callbackTransport as "loopback" | "gateway" | null) ??
175
147
  "loopback";
176
- const loopbackPort = behavior.loopbackPort;
148
+ const loopbackPort = providerRow.loopbackPort ?? undefined;
177
149
 
178
150
  // Resolve scopes via the scope policy engine
179
151
  const scopeProfile = {
180
- service: resolvedService,
152
+ service: options.service,
181
153
  defaultScopes: dbDefaultScopes,
182
154
  scopePolicy: dbScopePolicy,
183
155
  };
@@ -211,7 +183,7 @@ export async function orchestrateOAuthConnect(
211
183
 
212
184
  log.info(
213
185
  {
214
- service: resolvedService,
186
+ service: options.service,
215
187
  authUrl,
216
188
  tokenUrl,
217
189
  scopeCount: finalScopes.length,
@@ -235,7 +207,7 @@ export async function orchestrateOAuthConnect(
235
207
  };
236
208
 
237
209
  const storageParams = {
238
- service: resolvedService,
210
+ service: options.service,
239
211
  clientId: options.clientId,
240
212
  clientSecret: options.clientSecret,
241
213
  userinfoUrl,
@@ -276,19 +248,12 @@ export async function orchestrateOAuthConnect(
276
248
  prepared.completion
277
249
  .then(async (result) => {
278
250
  try {
279
- let parsedAccountIdentifier: string | undefined;
280
-
281
251
  // Parse account identifier from the provider's identity endpoint.
282
252
  // Best-effort — format varies by provider and may fail.
283
- if (behavior.identityVerifier) {
284
- try {
285
- parsedAccountIdentifier = await behavior.identityVerifier(
286
- result.tokens.accessToken,
287
- );
288
- } catch {
289
- // Non-fatal
290
- }
291
- }
253
+ const parsedAccountIdentifier = await verifyIdentity(
254
+ providerRow,
255
+ result.tokens.accessToken,
256
+ );
292
257
 
293
258
  const stored = await storeOAuth2Tokens({
294
259
  ...storageParams,
@@ -299,36 +264,36 @@ export async function orchestrateOAuthConnect(
299
264
  });
300
265
  log.info(
301
266
  {
302
- service: resolvedService,
267
+ service: options.service,
303
268
  accountInfo: stored.accountInfo ?? parsedAccountIdentifier,
304
269
  },
305
270
  "Deferred OAuth2 flow completed — tokens stored",
306
271
  );
307
272
  options.onDeferredComplete?.({
308
273
  success: true,
309
- service: resolvedService,
274
+ service: options.service,
310
275
  accountInfo: stored.accountInfo ?? parsedAccountIdentifier,
311
276
  });
312
277
  } catch (err) {
313
278
  log.error(
314
- { err, service: resolvedService },
279
+ { err, service: options.service },
315
280
  "Failed to store tokens from deferred OAuth2 flow",
316
281
  );
317
282
  options.onDeferredComplete?.({
318
283
  success: false,
319
- service: resolvedService,
284
+ service: options.service,
320
285
  error: err instanceof Error ? err.message : "Unknown error",
321
286
  });
322
287
  }
323
288
  })
324
289
  .catch((err) => {
325
290
  log.error(
326
- { err, service: resolvedService },
291
+ { err, service: options.service },
327
292
  "Deferred OAuth2 flow failed",
328
293
  );
329
294
  options.onDeferredComplete?.({
330
295
  success: false,
331
- service: resolvedService,
296
+ service: options.service,
332
297
  error: err instanceof Error ? err.message : "Unknown error",
333
298
  });
334
299
  });
@@ -338,7 +303,7 @@ export async function orchestrateOAuthConnect(
338
303
  deferred: true,
339
304
  authUrl: prepared.authUrl,
340
305
  state: prepared.state,
341
- service: resolvedService,
306
+ service: options.service,
342
307
  };
343
308
  } catch (err: unknown) {
344
309
  const message =
@@ -347,7 +312,7 @@ export async function orchestrateOAuthConnect(
347
312
  : "Unknown error preparing OAuth flow";
348
313
  return {
349
314
  success: false,
350
- error: `Error connecting "${resolvedService}": ${message}`,
315
+ error: `Error connecting "${options.service}": ${message}`,
351
316
  };
352
317
  }
353
318
  }
@@ -356,7 +321,7 @@ export async function orchestrateOAuthConnect(
356
321
  // Interactive path — open browser, block until completion
357
322
  // -----------------------------------------------------------------------
358
323
  log.info(
359
- { service: resolvedService, callbackTransport, loopbackPort },
324
+ { service: options.service, callbackTransport, loopbackPort },
360
325
  "orchestrateOAuthConnect: entering interactive path",
361
326
  );
362
327
  try {
@@ -365,7 +330,7 @@ export async function orchestrateOAuthConnect(
365
330
  {
366
331
  openUrl: (url) => {
367
332
  log.info(
368
- { service: resolvedService, urlLength: url.length },
333
+ { service: options.service, urlLength: url.length },
369
334
  "orchestrateOAuthConnect: openUrl callback fired, delivering auth URL to client",
370
335
  );
371
336
  if (options.openUrl) {
@@ -376,7 +341,7 @@ export async function orchestrateOAuthConnect(
376
341
  options.sendToClient({
377
342
  type: "open_url",
378
343
  url,
379
- title: `Connect ${resolvedService}`,
344
+ title: `Connect ${options.service}`,
380
345
  });
381
346
  } else {
382
347
  log.warn("orchestrateOAuthConnect: no openUrl or sendToClient available — auth URL will not reach the user");
@@ -391,25 +356,21 @@ export async function orchestrateOAuthConnect(
391
356
  );
392
357
 
393
358
  log.info(
394
- { service: resolvedService, grantedScopeCount: grantedScopes.length },
359
+ { service: options.service, grantedScopeCount: grantedScopes.length },
395
360
  "orchestrateOAuthConnect: interactive flow completed, exchanged code for tokens",
396
361
  );
397
362
 
398
363
  // Parse account identifier from the provider's identity endpoint.
399
364
  // Best-effort — format varies by provider and may fail.
400
- let parsedAccountIdentifier: string | undefined;
401
- if (behavior.identityVerifier) {
402
- try {
403
- parsedAccountIdentifier = await behavior.identityVerifier(
404
- tokens.accessToken,
405
- );
406
- log.info(
407
- { service: resolvedService, parsedAccountIdentifier },
408
- "orchestrateOAuthConnect: identity verified",
409
- );
410
- } catch {
411
- // Non-fatal
412
- }
365
+ const parsedAccountIdentifier = await verifyIdentity(
366
+ providerRow,
367
+ tokens.accessToken,
368
+ );
369
+ if (parsedAccountIdentifier) {
370
+ log.info(
371
+ { service: options.service, parsedAccountIdentifier },
372
+ "orchestrateOAuthConnect: identity verified",
373
+ );
413
374
  }
414
375
 
415
376
  const { accountInfo } = await storeOAuth2Tokens({
@@ -421,7 +382,7 @@ export async function orchestrateOAuthConnect(
421
382
  });
422
383
 
423
384
  log.info(
424
- { service: resolvedService, accountInfo },
385
+ { service: options.service, accountInfo },
425
386
  "orchestrateOAuthConnect: tokens stored, connect complete",
426
387
  );
427
388
 
@@ -435,12 +396,12 @@ export async function orchestrateOAuthConnect(
435
396
  const message =
436
397
  err instanceof Error ? err.message : "Unknown error during OAuth flow";
437
398
  log.error(
438
- { service: resolvedService, err },
399
+ { service: options.service, err },
439
400
  "orchestrateOAuthConnect: interactive flow failed",
440
401
  );
441
402
  return {
442
403
  success: false,
443
- error: `Error connecting "${resolvedService}": ${message}`,
404
+ error: `Error connecting "${options.service}": ${message}`,
444
405
  };
445
406
  }
446
407
  }
@@ -1,17 +1,16 @@
1
1
  /**
2
2
  * Shared types for the OAuth provider extensibility layer.
3
3
  *
4
- * These types are consumed by the provider behavior registry, the token
5
- * persistence module, and the credential vault orchestrator.
4
+ * These types are consumed by the token persistence module and the
5
+ * credential vault orchestrator.
6
6
  *
7
- * Protocol-level OAuth config (authUrl, tokenUrl, scopes, etc.) is now
8
- * stored exclusively in the `oauth_providers` SQLite table. This file
9
- * only defines code-side behavioral types that cannot be serialised to
10
- * a DB row (functions, templates, UI metadata).
7
+ * All provider configuration — protocol-level OAuth config (authUrl,
8
+ * tokenUrl, scopes, etc.) as well as behavioral config (identity
9
+ * verification, injection templates, setup metadata) is now stored
10
+ * exclusively in the `oauth_providers` SQLite table and seeded on
11
+ * startup via `seed-providers.ts`.
11
12
  */
12
13
 
13
- import type { CredentialInjectionTemplate } from "../tools/credentials/policy-types.js";
14
-
15
14
  // ---------------------------------------------------------------------------
16
15
  // Scope policy
17
16
  // ---------------------------------------------------------------------------
@@ -26,63 +25,6 @@ export interface OAuthScopePolicy {
26
25
  forbiddenScopes: string[];
27
26
  }
28
27
 
29
- // ---------------------------------------------------------------------------
30
- // Provider behavior
31
- // ---------------------------------------------------------------------------
32
-
33
- /**
34
- * Code-side behavioral configuration for a well-known OAuth provider.
35
- *
36
- * Protocol-level fields (authUrl, tokenUrl, defaultScopes, scopePolicy,
37
- * tokenEndpointAuthMethod, callbackTransport, userinfoUrl, extraParams)
38
- * are stored in the `oauth_providers` DB table. This
39
- * interface contains only fields that require code references (functions,
40
- * templates, skill IDs) and cannot be serialised to a DB row.
41
- */
42
- export interface OAuthProviderBehavior {
43
- /** Canonical service key (e.g. "integration:twitter"). */
44
- service: string;
45
- /**
46
- * Async function that verifies the user's identity after a successful
47
- * token exchange. Returns a human-readable account identifier (e.g.
48
- * email or @username) or undefined if verification is not possible.
49
- */
50
- identityVerifier?: (accessToken: string) => Promise<string | undefined>;
51
- /** ID of a post-connect hook to run after token storage. */
52
- postConnectHookId?: string;
53
- /** Injection templates auto-applied to the access_token credential. */
54
- injectionTemplates?: CredentialInjectionTemplate[];
55
- /**
56
- * Metadata for the generic OAuth setup skill. When present, the
57
- * assistant can guide users through app creation and OAuth connection.
58
- */
59
- setup?: {
60
- /** Human-readable provider name (e.g. "Discord", "Linear"). */
61
- displayName: string;
62
- /** URL of the developer dashboard where the user creates an app. */
63
- dashboardUrl: string;
64
- /** What the provider calls its apps (e.g. "Discord Application"). */
65
- appType: string;
66
- /** Whether the provider requires a client_secret for token exchange. */
67
- requiresClientSecret: boolean;
68
- /** Provider-specific notes the LLM should follow during setup. */
69
- notes?: string[];
70
- };
71
- /**
72
- * Bundled skill ID that contains provider-specific setup instructions.
73
- * When present, the guardrail for missing client_secret directs the
74
- * agent to load this skill rather than embedding instructions inline.
75
- */
76
- setupSkillId?: string;
77
- /**
78
- * Fixed port for the loopback OAuth callback server. When set, the
79
- * server binds to this port instead of an OS-assigned random port.
80
- * Required for providers that need pre-registered redirect URIs
81
- * (e.g. Slack, Notion).
82
- */
83
- loopbackPort?: number;
84
- }
85
-
86
28
  // ---------------------------------------------------------------------------
87
29
  // Connect result
88
30
  // ---------------------------------------------------------------------------
@@ -79,13 +79,13 @@ function makeMockClient() {
79
79
 
80
80
  function setupDefaults(): void {
81
81
  mockProvider = {
82
- providerKey: "integration:google",
82
+ providerKey: "google",
83
83
  baseUrl: "https://gmail.googleapis.com/gmail/v1/users/me",
84
84
  managedServiceConfigKey: null,
85
85
  };
86
86
  mockConnection = {
87
87
  id: "conn-1",
88
- providerKey: "integration:google",
88
+ providerKey: "google",
89
89
  oauthAppId: "app-1",
90
90
  accountInfo: "user@example.com",
91
91
  grantedScopes: JSON.stringify(["scope-a", "scope-b"]),
@@ -122,26 +122,26 @@ describe("resolveOAuthConnection", () => {
122
122
  });
123
123
 
124
124
  test("returns BYOOAuthConnection when provider has no managedServiceConfigKey", async () => {
125
- const result = await resolveOAuthConnection("integration:google");
125
+ const result = await resolveOAuthConnection("google");
126
126
  expect(result).toBeInstanceOf(BYOOAuthConnection);
127
127
  expect(result.id).toBe("conn-1");
128
- expect(result.providerKey).toBe("integration:google");
128
+ expect(result.providerKey).toBe("google");
129
129
  });
130
130
 
131
131
  test("returns PlatformOAuthConnection when managed mode is active", async () => {
132
132
  mockProvider!.managedServiceConfigKey = "google-oauth";
133
133
 
134
- const result = await resolveOAuthConnection("integration:google");
134
+ const result = await resolveOAuthConnection("google");
135
135
  expect(result).toBeInstanceOf(PlatformOAuthConnection);
136
- expect(result.id).toBe("integration:google");
137
- expect(result.providerKey).toBe("integration:google");
136
+ expect(result.id).toBe("google");
137
+ expect(result.providerKey).toBe("google");
138
138
  expect(result.accountInfo).toBeNull();
139
139
  });
140
140
 
141
141
  test("passes account through to PlatformOAuthConnection", async () => {
142
142
  mockProvider!.managedServiceConfigKey = "google-oauth";
143
143
 
144
- const result = await resolveOAuthConnection("integration:google", {
144
+ const result = await resolveOAuthConnection("google", {
145
145
  account: "user@example.com",
146
146
  });
147
147
  expect(result).toBeInstanceOf(PlatformOAuthConnection);
@@ -154,7 +154,7 @@ describe("resolveOAuthConnection", () => {
154
154
  mode: "your-own",
155
155
  };
156
156
 
157
- const result = await resolveOAuthConnection("integration:google");
157
+ const result = await resolveOAuthConnection("google");
158
158
  expect(result).toBeInstanceOf(BYOOAuthConnection);
159
159
  expect(result.id).toBe("conn-1");
160
160
  });
@@ -164,21 +164,21 @@ describe("resolveOAuthConnection", () => {
164
164
  mockConnection = undefined;
165
165
  mockAccessToken = undefined;
166
166
 
167
- const result = await resolveOAuthConnection("integration:google");
167
+ const result = await resolveOAuthConnection("google");
168
168
  expect(result).toBeInstanceOf(PlatformOAuthConnection);
169
169
  });
170
170
 
171
171
  test("managed path ignores clientId option", async () => {
172
172
  mockProvider!.managedServiceConfigKey = "google-oauth";
173
173
 
174
- const result = await resolveOAuthConnection("integration:google", {
174
+ const result = await resolveOAuthConnection("google", {
175
175
  clientId: "some-client-id",
176
176
  });
177
177
  expect(result).toBeInstanceOf(PlatformOAuthConnection);
178
178
  });
179
179
 
180
180
  test("BYO path narrows by clientId when provided", async () => {
181
- const result = await resolveOAuthConnection("integration:google", {
181
+ const result = await resolveOAuthConnection("google", {
182
182
  clientId: "client-1",
183
183
  });
184
184
  expect(result).toBeInstanceOf(BYOOAuthConnection);
@@ -187,7 +187,7 @@ describe("resolveOAuthConnection", () => {
187
187
 
188
188
  test("BYO path returns no credential when clientId does not match", async () => {
189
189
  await expect(
190
- resolveOAuthConnection("integration:google", {
190
+ resolveOAuthConnection("google", {
191
191
  clientId: "wrong-client",
192
192
  }),
193
193
  ).rejects.toThrow(/No active OAuth connection found/);
@@ -29,7 +29,7 @@ export interface ResolveOAuthConnectionOptions {
29
29
  * BYO providers resolve from the local SQLite oauth-store and require an
30
30
  * active connection row and a stored access token.
31
31
  *
32
- * @param providerKey - Provider identifier (e.g. "integration:google").
32
+ * @param providerKey - Provider identifier (e.g. "google").
33
33
  * Maps to the `provider_key` primary key in the `oauth_providers` table.
34
34
  * @param options.clientId - Optional OAuth app client ID. When multiple BYO
35
35
  * apps exist for the same provider, narrows the connection lookup to the
@@ -59,11 +59,9 @@ export async function resolveOAuthConnection(
59
59
  );
60
60
  }
61
61
 
62
- const providerSlug = providerKey.replace(/^integration:/, "");
63
-
64
62
  const connectionId = await resolvePlatformConnectionId({
65
63
  client,
66
- provider: providerSlug,
64
+ provider: providerKey,
67
65
  account,
68
66
  });
69
67
 
@@ -74,6 +72,7 @@ export async function resolveOAuthConnection(
74
72
  accountInfo: account ?? null,
75
73
  client,
76
74
  connectionId,
75
+ baseUrl: provider?.baseUrl ?? undefined,
77
76
  });
78
77
  }
79
78
  }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Generic, data-driven identity verifier for OAuth providers.
3
+ *
4
+ * Replaces per-provider hand-coded `identityVerifier` functions with a
5
+ * single function that interprets the declarative identity configuration
6
+ * stored in the `oauth_providers` DB table (identityUrl, identityMethod,
7
+ * identityHeaders, identityBody, identityResponsePaths, identityFormat,
8
+ * identityOkField).
9
+ */
10
+
11
+ import type { OAuthProviderRow } from "./oauth-store.js";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Helpers
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /** Traverse a nested object by a dot-separated path (e.g. "data.viewer.email"). */
18
+ function getNestedValue(obj: unknown, dotPath: string): unknown {
19
+ const parts = dotPath.split(".");
20
+ let current: unknown = obj;
21
+ for (const part of parts) {
22
+ if (current == null || typeof current !== "object") return undefined;
23
+ current = (current as Record<string, unknown>)[part];
24
+ }
25
+ return current;
26
+ }
27
+
28
+ /** Safely parse a JSON string, returning a fallback on failure or null/undefined input. */
29
+ function safeJsonParse<T>(value: string | null | undefined, fallback: T): T {
30
+ if (value == null) return fallback;
31
+ try {
32
+ return JSON.parse(value) as T;
33
+ } catch {
34
+ return fallback;
35
+ }
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Public API
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /**
43
+ * Verify the user's identity after a successful OAuth token exchange.
44
+ *
45
+ * Returns a human-readable account identifier (e.g. email, @username, or
46
+ * a formatted string like "@user (team)") or `undefined` when:
47
+ * - the provider has no identity URL configured
48
+ * - the identity request fails or returns a non-OK status
49
+ * - the response cannot be parsed into an identifier
50
+ *
51
+ * This function is intentionally non-throwing — identity verification is
52
+ * always best-effort.
53
+ */
54
+ export async function verifyIdentity(
55
+ providerRow: OAuthProviderRow,
56
+ accessToken: string,
57
+ ): Promise<string | undefined> {
58
+ const { identityUrl: rawUrl } = providerRow;
59
+ if (!rawUrl) return undefined;
60
+
61
+ try {
62
+ // Interpolate ${accessToken} in the URL (HubSpot pattern)
63
+ const urlContainsToken = rawUrl.includes("${accessToken}");
64
+ const url = rawUrl.replace("${accessToken}", accessToken);
65
+
66
+ // Build headers
67
+ const parsedHeaders = safeJsonParse<Record<string, string>>(
68
+ providerRow.identityHeaders,
69
+ {},
70
+ );
71
+ const headers: Record<string, string> = {
72
+ ...parsedHeaders,
73
+ };
74
+ // Only add the Authorization header if the token is not embedded in the URL
75
+ if (!urlContainsToken) {
76
+ headers["Authorization"] = `Bearer ${accessToken}`;
77
+ }
78
+
79
+ // Build request init
80
+ const method = providerRow.identityMethod ?? "GET";
81
+ const init: RequestInit = { method, headers };
82
+
83
+ // Add body if present
84
+ if (providerRow.identityBody != null) {
85
+ const bodyValue = safeJsonParse<unknown>(
86
+ providerRow.identityBody,
87
+ providerRow.identityBody,
88
+ );
89
+ if (typeof bodyValue === "string") {
90
+ init.body = bodyValue;
91
+ } else {
92
+ init.body = JSON.stringify(bodyValue);
93
+ }
94
+ }
95
+
96
+ // Make the request
97
+ const resp = await fetch(url, init);
98
+ if (!resp.ok) return undefined;
99
+
100
+ const body: unknown = await resp.json();
101
+
102
+ // Check OK field (Slack pattern: body.ok must be truthy)
103
+ if (providerRow.identityOkField) {
104
+ const okValue = getNestedValue(body, providerRow.identityOkField);
105
+ if (!okValue) return undefined;
106
+ }
107
+
108
+ // Parse response paths
109
+ const responsePaths = safeJsonParse<string[]>(
110
+ providerRow.identityResponsePaths,
111
+ [],
112
+ );
113
+ if (responsePaths.length === 0) return undefined;
114
+
115
+ const { identityFormat } = providerRow;
116
+
117
+ // Simple mode: no format template — return the first non-null path value
118
+ if (!identityFormat) {
119
+ for (const path of responsePaths) {
120
+ const value = getNestedValue(body, path);
121
+ if (value != null) return String(value);
122
+ }
123
+ return undefined;
124
+ }
125
+
126
+ // Format mode: build a lookup map from all paths, then interpolate
127
+ const pathValues = new Map<string, string | undefined>();
128
+ for (const path of responsePaths) {
129
+ const value = getNestedValue(body, path);
130
+ pathValues.set(path, value != null ? String(value) : undefined);
131
+ }
132
+
133
+ // Replace ${path} tokens in the format string
134
+ let result = identityFormat;
135
+ let allResolved = true;
136
+ for (const [path, value] of pathValues) {
137
+ if (value != null) {
138
+ result = result.replace(`\${${path}}`, value);
139
+ } else {
140
+ allResolved = false;
141
+ }
142
+ }
143
+
144
+ if (allResolved) return result;
145
+
146
+ // Fallback: if some tokens couldn't be resolved, try cleaning up
147
+ // the format string by removing unresolved tokens and their surrounding
148
+ // punctuation (parentheses, spaces).
149
+ // First, try removing unresolved tokens with their surrounding parens/space
150
+ // e.g. "@${user} (${team})" with missing team -> "@user"
151
+ let cleaned = identityFormat;
152
+ for (const [path, value] of pathValues) {
153
+ if (value != null) {
154
+ cleaned = cleaned.replace(`\${${path}}`, value);
155
+ } else {
156
+ // Remove patterns like " (${path})" or " ${path}" or "(${path})"
157
+ cleaned = cleaned.replace(
158
+ new RegExp(`\\s*\\(?\\$\\{${path.replace(/\./g, "\\.")}\\}\\)?`, "g"),
159
+ "",
160
+ );
161
+ }
162
+ }
163
+
164
+ cleaned = cleaned.trim();
165
+ if (cleaned) return cleaned;
166
+
167
+ // Last resort: return the first non-null path value
168
+ for (const value of pathValues.values()) {
169
+ if (value != null) return value;
170
+ }
171
+
172
+ return undefined;
173
+ } catch {
174
+ // Non-fatal — identity verification is best-effort
175
+ return undefined;
176
+ }
177
+ }