@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
@@ -0,0 +1,496 @@
1
+ /**
2
+ * CRUD store for OAuth providers, apps, and connections.
3
+ *
4
+ * Backed by Drizzle + SQLite. All JSON fields (default_scopes, scope_policy,
5
+ * extra_params, granted_scopes, metadata) are stored as serialized JSON strings.
6
+ */
7
+
8
+ import { and, desc, eq, sql } from "drizzle-orm";
9
+ import { v4 as uuid } from "uuid";
10
+
11
+ import { getDb, rawChanges } from "../memory/db.js";
12
+ import {
13
+ oauthApps,
14
+ oauthConnections,
15
+ oauthProviders,
16
+ } from "../memory/schema/oauth.js";
17
+ import {
18
+ deleteSecureKeyAsync,
19
+ getSecureKey,
20
+ setSecureKeyAsync,
21
+ } from "../security/secure-keys.js";
22
+ import { getLogger } from "../util/logger.js";
23
+
24
+ const log = getLogger("oauth-store");
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Row types
28
+ // ---------------------------------------------------------------------------
29
+
30
+ export type OAuthProviderRow = typeof oauthProviders.$inferSelect;
31
+ export type OAuthAppRow = typeof oauthApps.$inferSelect;
32
+ export type OAuthConnectionRow = typeof oauthConnections.$inferSelect;
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Provider operations
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /**
39
+ * Seed well-known provider profiles into the database. Uses INSERT … ON
40
+ * CONFLICT DO UPDATE so that corrections to seed data (e.g. a fixed baseUrl)
41
+ * propagate to existing installations on the next startup.
42
+ */
43
+ export function seedProviders(
44
+ profiles: Array<{
45
+ providerKey: string;
46
+ authUrl: string;
47
+ tokenUrl: string;
48
+ tokenEndpointAuthMethod?: string;
49
+ userinfoUrl?: string;
50
+ baseUrl?: string;
51
+ defaultScopes: string[];
52
+ scopePolicy: Record<string, unknown>;
53
+ extraParams?: Record<string, string>;
54
+ callbackTransport?: string;
55
+ loopbackPort?: number;
56
+ }>,
57
+ ): void {
58
+ const db = getDb();
59
+ const now = Date.now();
60
+ for (const p of profiles) {
61
+ const authUrl = p.authUrl;
62
+ const tokenUrl = p.tokenUrl;
63
+ const tokenEndpointAuthMethod = p.tokenEndpointAuthMethod ?? null;
64
+ const userinfoUrl = p.userinfoUrl ?? null;
65
+ const baseUrl = p.baseUrl ?? null;
66
+ const defaultScopes = JSON.stringify(p.defaultScopes);
67
+ const scopePolicy = JSON.stringify(p.scopePolicy);
68
+ const extraParams = p.extraParams ? JSON.stringify(p.extraParams) : null;
69
+ const callbackTransport = p.callbackTransport ?? null;
70
+ const loopbackPort = p.loopbackPort ?? null;
71
+
72
+ db.insert(oauthProviders)
73
+ .values({
74
+ providerKey: p.providerKey,
75
+ authUrl,
76
+ tokenUrl,
77
+ tokenEndpointAuthMethod,
78
+ userinfoUrl,
79
+ baseUrl,
80
+ defaultScopes,
81
+ scopePolicy,
82
+ extraParams,
83
+ callbackTransport,
84
+ loopbackPort,
85
+ createdAt: now,
86
+ updatedAt: now,
87
+ })
88
+ .onConflictDoUpdate({
89
+ target: oauthProviders.providerKey,
90
+ set: {
91
+ authUrl,
92
+ tokenUrl,
93
+ tokenEndpointAuthMethod,
94
+ userinfoUrl,
95
+ baseUrl,
96
+ defaultScopes,
97
+ scopePolicy,
98
+ extraParams,
99
+ callbackTransport,
100
+ loopbackPort,
101
+ updatedAt: now,
102
+ },
103
+ })
104
+ .run();
105
+ }
106
+ }
107
+
108
+ /** Look up a provider by its primary key. */
109
+ export function getProvider(providerKey: string): OAuthProviderRow | undefined {
110
+ const db = getDb();
111
+ return db
112
+ .select()
113
+ .from(oauthProviders)
114
+ .where(eq(oauthProviders.providerKey, providerKey))
115
+ .get();
116
+ }
117
+
118
+ /** Return all registered providers. */
119
+ export function listProviders(): OAuthProviderRow[] {
120
+ const db = getDb();
121
+ return db.select().from(oauthProviders).all();
122
+ }
123
+
124
+ /**
125
+ * Register a new provider (for dynamic registration). Throws if the
126
+ * provider_key already exists.
127
+ */
128
+ export function registerProvider(params: {
129
+ providerKey: string;
130
+ authUrl: string;
131
+ tokenUrl: string;
132
+ tokenEndpointAuthMethod?: string;
133
+ userinfoUrl?: string;
134
+ baseUrl?: string;
135
+ defaultScopes: string[];
136
+ scopePolicy: Record<string, unknown>;
137
+ extraParams?: Record<string, string>;
138
+ callbackTransport?: string;
139
+ loopbackPort?: number;
140
+ }): OAuthProviderRow {
141
+ const db = getDb();
142
+ const now = Date.now();
143
+
144
+ const existing = getProvider(params.providerKey);
145
+ if (existing) {
146
+ throw new Error(`OAuth provider already exists: ${params.providerKey}`);
147
+ }
148
+
149
+ const row = {
150
+ providerKey: params.providerKey,
151
+ authUrl: params.authUrl,
152
+ tokenUrl: params.tokenUrl,
153
+ tokenEndpointAuthMethod: params.tokenEndpointAuthMethod ?? null,
154
+ userinfoUrl: params.userinfoUrl ?? null,
155
+ baseUrl: params.baseUrl ?? null,
156
+ defaultScopes: JSON.stringify(params.defaultScopes),
157
+ scopePolicy: JSON.stringify(params.scopePolicy),
158
+ extraParams: params.extraParams ? JSON.stringify(params.extraParams) : null,
159
+ callbackTransport: params.callbackTransport ?? null,
160
+ loopbackPort: params.loopbackPort ?? null,
161
+ createdAt: now,
162
+ updatedAt: now,
163
+ };
164
+
165
+ db.insert(oauthProviders).values(row).run();
166
+
167
+ return row;
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // App operations
172
+ // ---------------------------------------------------------------------------
173
+
174
+ /**
175
+ * Insert or return an existing app by (provider_key, client_id).
176
+ * Generates a UUID on insert.
177
+ */
178
+ export async function upsertApp(
179
+ providerKey: string,
180
+ clientId: string,
181
+ clientSecret?: string,
182
+ ): Promise<OAuthAppRow> {
183
+ const db = getDb();
184
+
185
+ const existing = db
186
+ .select()
187
+ .from(oauthApps)
188
+ .where(
189
+ and(
190
+ eq(oauthApps.providerKey, providerKey),
191
+ eq(oauthApps.clientId, clientId),
192
+ ),
193
+ )
194
+ .get();
195
+
196
+ if (existing) {
197
+ if (clientSecret) {
198
+ const stored = await setSecureKeyAsync(
199
+ `oauth_app/${existing.id}/client_secret`,
200
+ clientSecret,
201
+ );
202
+ if (!stored) {
203
+ throw new Error("Failed to store client_secret in secure storage");
204
+ }
205
+ }
206
+ return existing;
207
+ }
208
+
209
+ const now = Date.now();
210
+ const id = uuid();
211
+ const row = {
212
+ id,
213
+ providerKey,
214
+ clientId,
215
+ createdAt: now,
216
+ updatedAt: now,
217
+ };
218
+
219
+ db.insert(oauthApps).values(row).run();
220
+
221
+ if (clientSecret) {
222
+ const stored = await setSecureKeyAsync(
223
+ `oauth_app/${id}/client_secret`,
224
+ clientSecret,
225
+ );
226
+ if (!stored) {
227
+ throw new Error("Failed to store client_secret in secure storage");
228
+ }
229
+ }
230
+
231
+ return row;
232
+ }
233
+
234
+ /** Look up an app by its primary key. */
235
+ export function getApp(id: string): OAuthAppRow | undefined {
236
+ const db = getDb();
237
+ return db.select().from(oauthApps).where(eq(oauthApps.id, id)).get();
238
+ }
239
+
240
+ /** Look up an app by (provider_key, client_id). */
241
+ export function getAppByProviderAndClientId(
242
+ providerKey: string,
243
+ clientId: string,
244
+ ): OAuthAppRow | undefined {
245
+ const db = getDb();
246
+ return db
247
+ .select()
248
+ .from(oauthApps)
249
+ .where(
250
+ and(
251
+ eq(oauthApps.providerKey, providerKey),
252
+ eq(oauthApps.clientId, clientId),
253
+ ),
254
+ )
255
+ .get();
256
+ }
257
+
258
+ /**
259
+ * Get the most recently created app for a provider.
260
+ * Returns undefined if no app exists for this provider.
261
+ */
262
+ export function getMostRecentAppByProvider(
263
+ providerKey: string,
264
+ ): OAuthAppRow | undefined {
265
+ const db = getDb();
266
+ return db
267
+ .select()
268
+ .from(oauthApps)
269
+ .where(eq(oauthApps.providerKey, providerKey))
270
+ .orderBy(desc(oauthApps.createdAt))
271
+ .limit(1)
272
+ .get();
273
+ }
274
+
275
+ /** Return all OAuth apps. */
276
+ export function listApps(): OAuthAppRow[] {
277
+ const db = getDb();
278
+ return db.select().from(oauthApps).all();
279
+ }
280
+
281
+ /** Delete an app by ID. Cleans up the client_secret from secure storage. Returns true if a row was deleted. */
282
+ export async function deleteApp(id: string): Promise<boolean> {
283
+ // Delete the DB row first so that if it fails (e.g. FK constraint from
284
+ // existing connections), the secret in secure storage remains intact.
285
+ const db = getDb();
286
+ db.delete(oauthApps).where(eq(oauthApps.id, id)).run();
287
+ const deleted = rawChanges() > 0;
288
+
289
+ if (!deleted) return false;
290
+
291
+ const result = await deleteSecureKeyAsync(`oauth_app/${id}/client_secret`);
292
+ if (result === "error") {
293
+ throw new Error(
294
+ `Deleted app ${id} but failed to remove client_secret from secure storage`,
295
+ );
296
+ }
297
+
298
+ return true;
299
+ }
300
+
301
+ // ---------------------------------------------------------------------------
302
+ // Connection operations
303
+ // ---------------------------------------------------------------------------
304
+
305
+ /**
306
+ * Create a new OAuth connection. Generates a UUID and sets status='active'.
307
+ * `metadata` is an optional JSON object for provider-specific token response data.
308
+ */
309
+ export function createConnection(params: {
310
+ oauthAppId: string;
311
+ providerKey: string;
312
+ accountInfo?: string;
313
+ grantedScopes: string[];
314
+ expiresAt?: number;
315
+ hasRefreshToken: boolean;
316
+ label?: string;
317
+ metadata?: Record<string, unknown>;
318
+ /** Override the creation timestamp. Useful in tests to ensure deterministic ordering. */
319
+ createdAt?: number;
320
+ }): OAuthConnectionRow {
321
+ const db = getDb();
322
+ const now = params.createdAt ?? Date.now();
323
+ const id = uuid();
324
+
325
+ const row = {
326
+ id,
327
+ oauthAppId: params.oauthAppId,
328
+ providerKey: params.providerKey,
329
+ accountInfo: params.accountInfo ?? null,
330
+ grantedScopes: JSON.stringify(params.grantedScopes),
331
+ expiresAt: params.expiresAt ?? null,
332
+ hasRefreshToken: params.hasRefreshToken ? 1 : 0,
333
+ status: "active" as const,
334
+ label: params.label ?? null,
335
+ metadata: params.metadata ? JSON.stringify(params.metadata) : null,
336
+ createdAt: now,
337
+ updatedAt: now,
338
+ };
339
+
340
+ db.insert(oauthConnections).values(row).run();
341
+
342
+ return row;
343
+ }
344
+
345
+ /** Look up a connection by its primary key. */
346
+ export function getConnection(id: string): OAuthConnectionRow | undefined {
347
+ const db = getDb();
348
+ return db
349
+ .select()
350
+ .from(oauthConnections)
351
+ .where(eq(oauthConnections.id, id))
352
+ .get();
353
+ }
354
+
355
+ /**
356
+ * Get the most recent active connection for a provider.
357
+ * Returns undefined if no active connection exists.
358
+ */
359
+ export function getConnectionByProvider(
360
+ providerKey: string,
361
+ ): OAuthConnectionRow | undefined {
362
+ const db = getDb();
363
+ return db
364
+ .select()
365
+ .from(oauthConnections)
366
+ .where(
367
+ and(
368
+ eq(oauthConnections.providerKey, providerKey),
369
+ eq(oauthConnections.status, "active"),
370
+ ),
371
+ )
372
+ .orderBy(desc(oauthConnections.createdAt), sql`rowid DESC`)
373
+ .limit(1)
374
+ .get();
375
+ }
376
+
377
+ /**
378
+ * Check whether a provider has a usable OAuth connection: an active row in the
379
+ * database AND a corresponding access token in secure storage.
380
+ *
381
+ * This guards against the edge case where the connection row was created/updated
382
+ * but the secure-key write for the access token failed, which would make
383
+ * `resolveOAuthConnection()` throw at usage time.
384
+ */
385
+ export function isProviderConnected(providerKey: string): boolean {
386
+ const conn = getConnectionByProvider(providerKey);
387
+ if (!conn || conn.status !== "active") return false;
388
+ return getSecureKey(`oauth_connection/${conn.id}/access_token`) !== undefined;
389
+ }
390
+
391
+ /**
392
+ * Update fields on an existing connection. Returns true if a row was updated.
393
+ */
394
+ export function updateConnection(
395
+ id: string,
396
+ updates: Partial<{
397
+ oauthAppId: string;
398
+ accountInfo: string;
399
+ grantedScopes: string[];
400
+ /** Pass `null` to explicitly clear a stale expiresAt in the DB. */
401
+ expiresAt: number | null;
402
+ hasRefreshToken: boolean;
403
+ status: string;
404
+ label: string;
405
+ metadata: Record<string, unknown>;
406
+ }>,
407
+ ): boolean {
408
+ const db = getDb();
409
+ const now = Date.now();
410
+
411
+ // Build the set clause, serializing JSON fields and converting booleans.
412
+ // For expiresAt, null means "clear the column" so we check for undefined
413
+ // explicitly rather than truthiness.
414
+ const set: Record<string, unknown> = { updatedAt: now };
415
+ if (updates.oauthAppId !== undefined) set.oauthAppId = updates.oauthAppId;
416
+ if (updates.accountInfo !== undefined) set.accountInfo = updates.accountInfo;
417
+ if (updates.grantedScopes !== undefined)
418
+ set.grantedScopes = JSON.stringify(updates.grantedScopes);
419
+ if (updates.expiresAt !== undefined) set.expiresAt = updates.expiresAt;
420
+ if (updates.hasRefreshToken !== undefined)
421
+ set.hasRefreshToken = updates.hasRefreshToken ? 1 : 0;
422
+ if (updates.status !== undefined) set.status = updates.status;
423
+ if (updates.label !== undefined) set.label = updates.label;
424
+ if (updates.metadata !== undefined)
425
+ set.metadata = JSON.stringify(updates.metadata);
426
+
427
+ db.update(oauthConnections).set(set).where(eq(oauthConnections.id, id)).run();
428
+
429
+ return rawChanges() > 0;
430
+ }
431
+
432
+ /** List connections, optionally filtered by provider key. */
433
+ export function listConnections(providerKey?: string): OAuthConnectionRow[] {
434
+ const db = getDb();
435
+
436
+ if (providerKey) {
437
+ return db
438
+ .select()
439
+ .from(oauthConnections)
440
+ .where(eq(oauthConnections.providerKey, providerKey))
441
+ .all();
442
+ }
443
+
444
+ return db.select().from(oauthConnections).all();
445
+ }
446
+
447
+ /** Delete a connection by ID. Returns true if a row was deleted. */
448
+ export function deleteConnection(id: string): boolean {
449
+ const db = getDb();
450
+ db.delete(oauthConnections).where(eq(oauthConnections.id, id)).run();
451
+ return rawChanges() > 0;
452
+ }
453
+
454
+ // ---------------------------------------------------------------------------
455
+ // Disconnect (full cleanup)
456
+ // ---------------------------------------------------------------------------
457
+
458
+ /**
459
+ * Fully disconnect an OAuth provider: delete the new-format secure keys
460
+ * (access_token and refresh_token) and remove the connection row from SQLite.
461
+ *
462
+ * Returns `"disconnected"` if a connection was found and cleaned up,
463
+ * `"not-found"` if no active connection existed for the given provider,
464
+ * or `"error"` if secure key deletion failed (connection row is preserved
465
+ * to avoid orphaning secrets).
466
+ */
467
+ export async function disconnectOAuthProvider(
468
+ providerKey: string,
469
+ ): Promise<"disconnected" | "not-found" | "error"> {
470
+ const conn = getConnectionByProvider(providerKey);
471
+ if (!conn) return "not-found";
472
+
473
+ const r1 = await deleteSecureKeyAsync(
474
+ `oauth_connection/${conn.id}/access_token`,
475
+ );
476
+ const r2 = await deleteSecureKeyAsync(
477
+ `oauth_connection/${conn.id}/refresh_token`,
478
+ );
479
+
480
+ if (r1 === "error" || r2 === "error") {
481
+ log.warn(
482
+ {
483
+ providerKey,
484
+ connectionId: conn.id,
485
+ accessTokenResult: r1,
486
+ refreshTokenResult: r2,
487
+ },
488
+ "Failed to delete OAuth secure keys — skipping connection row deletion to avoid orphaning secrets",
489
+ );
490
+ return "error";
491
+ }
492
+
493
+ deleteConnection(conn.id);
494
+
495
+ return "disconnected";
496
+ }
@@ -1,5 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
+ import { BackendError, VellumError } from "../util/errors.js";
3
4
  import {
4
5
  CredentialRequiredError,
5
6
  PlatformOAuthConnection,
@@ -116,6 +117,16 @@ describe("PlatformOAuthConnection", () => {
116
117
  await conn.request({ method: "GET", path: "/some/path" });
117
118
  });
118
119
 
120
+ test("error classes extend VellumError hierarchy", () => {
121
+ const credErr = new CredentialRequiredError();
122
+ expect(credErr).toBeInstanceOf(BackendError);
123
+ expect(credErr).toBeInstanceOf(VellumError);
124
+
125
+ const provErr = new ProviderUnreachableError();
126
+ expect(provErr).toBeInstanceOf(BackendError);
127
+ expect(provErr).toBeInstanceOf(VellumError);
128
+ });
129
+
119
130
  test("424 response throws CredentialRequiredError", async () => {
120
131
  globalThis.fetch = mock(async () => {
121
132
  return new Response("", { status: 424 });
@@ -145,6 +156,24 @@ describe("PlatformOAuthConnection", () => {
145
156
  );
146
157
  });
147
158
 
159
+ test("strips trailing slash from platformBaseUrl to avoid double slashes", async () => {
160
+ globalThis.fetch = mock(async (url: string | URL | Request) => {
161
+ expect(String(url)).toBe(
162
+ "https://platform.example.com/v1/assistants/asst-abc/external-provider-proxy/gmail/",
163
+ );
164
+ return new Response(
165
+ JSON.stringify({ status: 200, headers: {}, body: null }),
166
+ { status: 200 },
167
+ );
168
+ }) as unknown as typeof globalThis.fetch;
169
+
170
+ const conn = new PlatformOAuthConnection({
171
+ ...DEFAULT_OPTIONS,
172
+ platformBaseUrl: "https://platform.example.com/",
173
+ });
174
+ await conn.request({ method: "GET", path: "/test" });
175
+ });
176
+
148
177
  test("strips integration: prefix from providerKey for slug", async () => {
149
178
  globalThis.fetch = mock(async (url: string | URL | Request) => {
150
179
  expect(String(url)).toContain("/external-provider-proxy/slack/");
@@ -1,17 +1,18 @@
1
+ import { BackendError } from "../util/errors.js";
1
2
  import type {
2
3
  OAuthConnection,
3
4
  OAuthConnectionRequest,
4
5
  OAuthConnectionResponse,
5
6
  } from "./connection.js";
6
7
 
7
- export class CredentialRequiredError extends Error {
8
+ export class CredentialRequiredError extends BackendError {
8
9
  constructor(message = "Connection not set up on platform") {
9
10
  super(message);
10
11
  this.name = "CredentialRequiredError";
11
12
  }
12
13
  }
13
14
 
14
- export class ProviderUnreachableError extends Error {
15
+ export class ProviderUnreachableError extends BackendError {
15
16
  constructor(message = "Provider is unreachable") {
16
17
  super(message);
17
18
  this.name = "ProviderUnreachableError";
@@ -47,7 +48,7 @@ export class PlatformOAuthConnection implements OAuthConnection {
47
48
  this.accountInfo = options.accountInfo;
48
49
  this.grantedScopes = options.grantedScopes;
49
50
  this.assistantId = options.assistantId;
50
- this.platformBaseUrl = options.platformBaseUrl;
51
+ this.platformBaseUrl = options.platformBaseUrl.replace(/\/+$/, "");
51
52
  this.apiKey = options.apiKey;
52
53
  }
53
54
 
@@ -84,7 +85,7 @@ export class PlatformOAuthConnection implements OAuthConnection {
84
85
  }
85
86
 
86
87
  if (!response.ok) {
87
- throw new Error(
88
+ throw new BackendError(
88
89
  `Platform proxy returned unexpected status ${response.status}`,
89
90
  );
90
91
  }
@@ -103,7 +104,7 @@ export class PlatformOAuthConnection implements OAuthConnection {
103
104
  }
104
105
 
105
106
  async withToken<T>(_fn: (token: string) => Promise<T>): Promise<T> {
106
- throw new Error(
107
+ throw new BackendError(
107
108
  "Raw token access is not supported for platform-managed connections. Use connection.request() instead.",
108
109
  );
109
110
  }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * OAuth provider behavior registry.
3
+ *
4
+ * Contains code-side behavioral configuration for well-known OAuth
5
+ * providers. Protocol-level fields (authUrl, tokenUrl, scopes, etc.)
6
+ * are stored in the `oauth_providers` SQLite table and seeded by
7
+ * `seed-providers.ts`. This module contains only fields that require
8
+ * code references (functions, templates, skill IDs) and cannot be
9
+ * serialised to a DB row.
10
+ */
11
+
12
+ import type { OAuthProviderBehavior } from "./connect-types.js";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Provider behaviors
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export const PROVIDER_BEHAVIORS: Record<string, OAuthProviderBehavior> = {
19
+ "integration:gmail": {
20
+ service: "integration:gmail",
21
+ // Google APIs for Gmail/Calendar/Contacts span multiple hosts; register
22
+ // all of them so proxied bash can inject the OAuth bearer token reliably.
23
+ injectionTemplates: [
24
+ {
25
+ hostPattern: "gmail.googleapis.com",
26
+ injectionType: "header",
27
+ headerName: "Authorization",
28
+ valuePrefix: "Bearer ",
29
+ },
30
+ {
31
+ hostPattern: "www.googleapis.com",
32
+ injectionType: "header",
33
+ headerName: "Authorization",
34
+ valuePrefix: "Bearer ",
35
+ },
36
+ {
37
+ hostPattern: "people.googleapis.com",
38
+ injectionType: "header",
39
+ headerName: "Authorization",
40
+ valuePrefix: "Bearer ",
41
+ },
42
+ ],
43
+ setupSkillId: "google-oauth-applescript",
44
+ setup: {
45
+ displayName: "Google (Gmail & Calendar)",
46
+ dashboardUrl: "https://console.cloud.google.com/apis/credentials",
47
+ appType: "Desktop app",
48
+ requiresClientSecret: true,
49
+ },
50
+ },
51
+
52
+ "integration:slack": {
53
+ service: "integration:slack",
54
+ },
55
+
56
+ "integration:notion": {
57
+ service: "integration:notion",
58
+ injectionTemplates: [
59
+ {
60
+ hostPattern: "api.notion.com",
61
+ injectionType: "header",
62
+ headerName: "Authorization",
63
+ valuePrefix: "Bearer ",
64
+ },
65
+ ],
66
+ },
67
+
68
+ "integration:twitter": {
69
+ service: "integration:twitter",
70
+ setup: {
71
+ displayName: "Twitter / X",
72
+ dashboardUrl: "https://developer.x.com/en/portal/dashboard",
73
+ appType: "App",
74
+ requiresClientSecret: false,
75
+ },
76
+ identityVerifier: async (
77
+ accessToken: string,
78
+ ): Promise<string | undefined> => {
79
+ try {
80
+ const resp = await fetch("https://api.x.com/2/users/me", {
81
+ headers: { Authorization: `Bearer ${accessToken}` },
82
+ });
83
+ if (resp.ok) {
84
+ const body = (await resp.json()) as { data?: { username?: string } };
85
+ return body.data?.username ? `@${body.data.username}` : undefined;
86
+ }
87
+ } catch {
88
+ // Non-fatal — identity verification is best-effort
89
+ }
90
+ return undefined;
91
+ },
92
+ },
93
+ };
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Aliases & resolution
97
+ // ---------------------------------------------------------------------------
98
+
99
+ /** Map shorthand aliases to canonical service names. */
100
+ export const SERVICE_ALIASES: Record<string, string> = {
101
+ gmail: "integration:gmail",
102
+ slack: "integration:slack",
103
+ notion: "integration:notion",
104
+ twitter: "integration:twitter",
105
+ };
106
+
107
+ /**
108
+ * Resolve a service name through aliases, then fall back to `integration:`
109
+ * prefix for providers registered in PROVIDER_BEHAVIORS without a
110
+ * SERVICE_ALIASES entry.
111
+ */
112
+ export function resolveService(service: string): string {
113
+ if (SERVICE_ALIASES[service]) return SERVICE_ALIASES[service];
114
+ if (!service.includes(":") && PROVIDER_BEHAVIORS[`integration:${service}`])
115
+ return `integration:${service}`;
116
+ return service;
117
+ }
118
+
119
+ /** Look up a provider behavior by canonical service name. */
120
+ export function getProviderBehavior(
121
+ service: string,
122
+ ): OAuthProviderBehavior | undefined {
123
+ return PROVIDER_BEHAVIORS[service];
124
+ }