@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
@@ -1,17 +1,19 @@
1
1
  /**
2
- * Metadata-driven token manager for OAuth2 credentials.
2
+ * Token manager for OAuth2 credentials.
3
3
  *
4
- * Reads refresh configuration (tokenUrl, clientId) from credential metadata
5
- * rather than requiring an IntegrationDefinition, enabling autonomous token
6
- * refresh for any OAuth2 service that stores its config in metadata.
4
+ * Reads refresh configuration (tokenUrl, clientId, authMethod) exclusively
5
+ * from the SQLite oauth-store (provider + app + connection rows). After a
6
+ * successful refresh, writes tokens to new-format secure key paths and
7
+ * updates the oauth_connection row.
7
8
  */
8
9
 
9
10
  import {
10
- getCredentialMetadata,
11
- upsertCredentialMetadata,
12
- } from "../tools/credentials/metadata-store.js";
11
+ getApp,
12
+ getConnectionByProvider,
13
+ getProvider,
14
+ updateConnection,
15
+ } from "../oauth/oauth-store.js";
13
16
  import { getLogger } from "../util/logger.js";
14
- import { credentialKey, migrateKeys } from "./credential-key.js";
15
17
  import { refreshOAuth2Token, type TokenEndpointAuthMethod } from "./oauth2.js";
16
18
  import { getSecureKey, setSecureKeyAsync } from "./secure-keys.js";
17
19
 
@@ -156,54 +158,113 @@ export class TokenExpiredError extends Error {
156
158
 
157
159
  /**
158
160
  * Check whether the access token for a service is expired or will expire
159
- * within the buffer window, based on the `expiresAt` field in credential metadata.
161
+ * within the buffer window, based on the `expiresAt` field in the
162
+ * oauth_connection row.
160
163
  */
161
164
  function isTokenExpired(service: string): boolean {
162
- const meta = getCredentialMetadata(service, "access_token");
163
- if (!meta?.expiresAt) return false;
164
- return Date.now() >= meta.expiresAt - EXPIRY_BUFFER_MS;
165
+ try {
166
+ const conn = getConnectionByProvider(service);
167
+ if (!conn?.expiresAt) return false;
168
+ return Date.now() >= conn.expiresAt - EXPIRY_BUFFER_MS;
169
+ } catch {
170
+ return false;
171
+ }
172
+ }
173
+
174
+ // ── Refresh config resolution ─────────────────────────────────────────
175
+
176
+ /** Shared shape for resolved refresh configuration. */
177
+ interface RefreshConfig {
178
+ tokenUrl: string;
179
+ clientId: string;
180
+ /** OAuth client secret (optional — PKCE flows may omit it). */
181
+ secret?: string;
182
+ refreshToken?: string;
183
+ authMethod?: TokenEndpointAuthMethod;
184
+ connId: string;
165
185
  }
166
186
 
167
187
  /**
168
- * Attempt to refresh the OAuth2 access token for a service using the
169
- * refresh token and OAuth2 config stored in credential metadata.
188
+ * Resolve refresh configuration from the SQLite oauth-store.
170
189
  *
171
- * Returns the new access token on success.
172
- * Throws `TokenExpiredError` if refresh is not possible.
190
+ * Looks up connection -> app -> provider to read tokenUrl, clientId, and
191
+ * authMethod. Throws `TokenExpiredError` if the connection is not found
192
+ * or incomplete.
173
193
  */
174
- async function doRefresh(service: string): Promise<string> {
175
- const refreshToken = getSecureKey(credentialKey(service, "refresh_token"));
176
- if (!refreshToken) {
194
+ function resolveRefreshConfig(service: string): RefreshConfig {
195
+ const conn = getConnectionByProvider(service);
196
+ if (!conn) {
177
197
  throw new TokenExpiredError(
178
198
  service,
179
- `No refresh token available for "${service}". Re-authorization required.${recoveryHint(service)}`,
199
+ `No OAuth connection found for "${service}". Re-authorization required.${recoveryHint(service)}`,
180
200
  );
181
201
  }
182
202
 
183
- const meta = getCredentialMetadata(service, "access_token");
184
- const tokenUrl = meta?.oauth2TokenUrl;
185
- const clientId = meta?.oauth2ClientId;
203
+ const app = getApp(conn.oauthAppId);
204
+ if (!app) {
205
+ throw new TokenExpiredError(
206
+ service,
207
+ `No OAuth app found for "${service}". Re-authorization required.${recoveryHint(service)}`,
208
+ );
209
+ }
186
210
 
211
+ const provider = getProvider(conn.providerKey);
212
+ if (!provider) {
213
+ throw new TokenExpiredError(
214
+ service,
215
+ `No OAuth provider found for "${service}". Re-authorization required.${recoveryHint(service)}`,
216
+ );
217
+ }
218
+
219
+ const tokenUrl = provider.tokenUrl;
220
+ const clientId = app.clientId;
187
221
  if (!tokenUrl || !clientId) {
188
- // Legacy credentials created by the old integration flow don't store
189
- // oauth2TokenUrl/oauth2ClientId in metadata. The client ID is user-specific
190
- // (from their Google Cloud Console) and cannot be recovered, so the only
191
- // path forward is re-authorization via the new oauth2_connect flow.
192
- const isLegacy = service === "integration:gmail" && !tokenUrl && !clientId;
193
- const hint = isLegacy
194
- ? ` This is a one-time migration: your old Gmail connection needs to be re-authorized. Ask me to "reconnect Gmail" to set it up again.`
195
- : "";
196
222
  throw new TokenExpiredError(
197
223
  service,
198
- `Missing OAuth2 refresh config for "${service}".${hint}${recoveryHint(service)}`,
224
+ `Missing OAuth2 refresh config for "${service}".${recoveryHint(service)}`,
199
225
  );
200
226
  }
201
227
 
202
- const clientSecret = getSecureKey(credentialKey(service, "client_secret"));
203
- const authMethod = meta?.oauth2TokenEndpointAuthMethod as
228
+ const secret = getSecureKey(`oauth_app/${app.id}/client_secret`);
229
+
230
+ const refreshToken = getSecureKey(
231
+ `oauth_connection/${conn.id}/refresh_token`,
232
+ );
233
+
234
+ const authMethod = provider.tokenEndpointAuthMethod as
204
235
  | TokenEndpointAuthMethod
205
236
  | undefined;
206
- const resolvedTokenUrl = tokenUrl;
237
+
238
+ return {
239
+ connId: conn.id,
240
+ tokenUrl,
241
+ clientId,
242
+ secret,
243
+ refreshToken,
244
+ authMethod,
245
+ };
246
+ }
247
+
248
+ /**
249
+ * Attempt to refresh the OAuth2 access token for a service.
250
+ *
251
+ * Reads refresh config exclusively from the SQLite oauth-store (provider,
252
+ * app, connection).
253
+ *
254
+ * Returns the new access token on success.
255
+ * Throws `TokenExpiredError` if refresh is not possible.
256
+ */
257
+ async function doRefresh(service: string): Promise<string> {
258
+ const refreshConfig = resolveRefreshConfig(service);
259
+ const { tokenUrl, clientId, secret, authMethod, connId, refreshToken } =
260
+ refreshConfig;
261
+
262
+ if (!refreshToken) {
263
+ throw new TokenExpiredError(
264
+ service,
265
+ `No refresh token available for "${service}". Re-authorization required.${recoveryHint(service)}`,
266
+ );
267
+ }
207
268
 
208
269
  if (isRefreshBreakerOpen(service)) {
209
270
  const state = refreshBreakers.get(service)!;
@@ -220,10 +281,10 @@ async function doRefresh(service: string): Promise<string> {
220
281
  let result;
221
282
  try {
222
283
  result = await refreshOAuth2Token(
223
- resolvedTokenUrl,
284
+ tokenUrl,
224
285
  clientId,
225
286
  refreshToken,
226
- clientSecret,
287
+ secret,
227
288
  authMethod,
228
289
  );
229
290
  } catch (err) {
@@ -241,9 +302,10 @@ async function doRefresh(service: string): Promise<string> {
241
302
  throw err;
242
303
  }
243
304
 
305
+ // ----- Store refreshed access_token -----
244
306
  if (
245
307
  !(await setSecureKeyAsync(
246
- credentialKey(service, "access_token"),
308
+ `oauth_connection/${connId}/access_token`,
247
309
  result.accessToken,
248
310
  ))
249
311
  ) {
@@ -256,7 +318,7 @@ async function doRefresh(service: string): Promise<string> {
256
318
  if (result.refreshToken) {
257
319
  if (
258
320
  !(await setSecureKeyAsync(
259
- credentialKey(service, "refresh_token"),
321
+ `oauth_connection/${connId}/refresh_token`,
260
322
  result.refreshToken,
261
323
  ))
262
324
  ) {
@@ -267,7 +329,7 @@ async function doRefresh(service: string): Promise<string> {
267
329
  }
268
330
  }
269
331
 
270
- // Update metadata with new expiry.
332
+ // Update oauth_connection row with new expiry.
271
333
  // Use null to explicitly clear a stale expiresAt when the provider omits
272
334
  // expires_in (or returns 0), so isTokenExpired won't keep forcing refreshes.
273
335
  const expiresAt =
@@ -275,7 +337,17 @@ async function doRefresh(service: string): Promise<string> {
275
337
  ? Date.now() + result.expiresIn * 1000
276
338
  : null;
277
339
 
278
- upsertCredentialMetadata(service, "access_token", { expiresAt });
340
+ try {
341
+ updateConnection(connId, {
342
+ expiresAt,
343
+ hasRefreshToken: !!result.refreshToken,
344
+ });
345
+ } catch (err) {
346
+ log.warn(
347
+ { err, service },
348
+ "Failed to update oauth_connection after refresh",
349
+ );
350
+ }
279
351
 
280
352
  recordRefreshSuccess(service);
281
353
  log.info({ service }, "OAuth2 access token refreshed successfully");
@@ -290,16 +362,17 @@ async function doRefresh(service: string): Promise<string> {
290
362
  * 2. If the token is expired or near-expiry, refreshes it before calling the callback.
291
363
  * 3. If the callback throws with a 401 status, attempts one refresh-and-retry cycle.
292
364
  *
293
- * @deprecated Use `resolveOAuthConnection(service).request()` instead.
294
- * Retained only for BYO connection internals.
365
+ * Retained only for BYO connection internals — prefer
366
+ * `resolveOAuthConnection(service).request()` for new code.
295
367
  */
296
368
  export async function withValidToken<T>(
297
369
  service: string,
298
370
  callback: (token: string) => Promise<T>,
299
371
  ): Promise<T> {
300
- migrateKeys();
301
-
302
- let token = getSecureKey(credentialKey(service, "access_token"));
372
+ const conn = getConnectionByProvider(service);
373
+ let token = conn
374
+ ? getSecureKey(`oauth_connection/${conn.id}/access_token`)
375
+ : undefined;
303
376
  if (!token) {
304
377
  throw new TokenExpiredError(
305
378
  service,
@@ -0,0 +1,358 @@
1
+ import { execSync } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import {
4
+ cpSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ readFileSync,
8
+ renameSync,
9
+ rmSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { dirname, join } from "node:path";
14
+ import { gunzipSync } from "node:zlib";
15
+
16
+ import { getLogger } from "../util/logger.js";
17
+ import {
18
+ getWorkspaceConfigPath,
19
+ getWorkspaceSkillsDir,
20
+ readPlatformToken,
21
+ } from "../util/platform.js";
22
+
23
+ const log = getLogger("catalog-install");
24
+
25
+ // ─── Types ───────────────────────────────────────────────────────────────────
26
+
27
+ export interface CatalogSkill {
28
+ id: string;
29
+ name: string;
30
+ description: string;
31
+ emoji?: string;
32
+ includes?: string[];
33
+ version?: string;
34
+ }
35
+
36
+ export interface CatalogManifest {
37
+ version: number;
38
+ skills: CatalogSkill[];
39
+ }
40
+
41
+ // ─── Path helpers ────────────────────────────────────────────────────────────
42
+
43
+ export function getSkillsIndexPath(): string {
44
+ return join(getWorkspaceSkillsDir(), "SKILLS.md");
45
+ }
46
+
47
+ /**
48
+ * Resolve the repo-level skills/ directory when running in dev mode.
49
+ * Returns the path if VELLUM_DEV is set and the directory exists, or undefined.
50
+ */
51
+ export function getRepoSkillsDir(): string | undefined {
52
+ if (!process.env.VELLUM_DEV) return undefined;
53
+
54
+ // assistant/src/skills/catalog-install.ts -> ../../../skills/
55
+ const candidate = join(import.meta.dir, "..", "..", "..", "skills");
56
+ if (existsSync(join(candidate, "catalog.json"))) {
57
+ return candidate;
58
+ }
59
+ return undefined;
60
+ }
61
+
62
+ // ─── Platform API ────────────────────────────────────────────────────────────
63
+
64
+ function getConfigPlatformUrl(): string | undefined {
65
+ try {
66
+ const configPath = getWorkspaceConfigPath();
67
+ if (!existsSync(configPath)) return undefined;
68
+ const raw = JSON.parse(readFileSync(configPath, "utf-8")) as Record<
69
+ string,
70
+ unknown
71
+ >;
72
+ const platform = raw.platform as Record<string, unknown> | undefined;
73
+ const baseUrl = platform?.baseUrl;
74
+ if (typeof baseUrl === "string" && baseUrl.trim()) return baseUrl.trim();
75
+ } catch {
76
+ // ignore
77
+ }
78
+ return undefined;
79
+ }
80
+
81
+ function getPlatformUrl(): string {
82
+ return (
83
+ process.env.VELLUM_PLATFORM_URL ??
84
+ getConfigPlatformUrl() ??
85
+ "https://platform.vellum.ai"
86
+ );
87
+ }
88
+
89
+ function buildHeaders(): Record<string, string> {
90
+ const headers: Record<string, string> = {};
91
+ const token = readPlatformToken();
92
+ if (token) {
93
+ headers["X-Session-Token"] = token;
94
+ }
95
+ return headers;
96
+ }
97
+
98
+ // ─── Catalog operations ──────────────────────────────────────────────────────
99
+
100
+ export async function fetchCatalog(): Promise<CatalogSkill[]> {
101
+ const url = `${getPlatformUrl()}/v1/skills/`;
102
+ const response = await fetch(url, {
103
+ headers: buildHeaders(),
104
+ signal: AbortSignal.timeout(10000),
105
+ });
106
+
107
+ if (!response.ok) {
108
+ throw new Error(
109
+ `Platform API error ${response.status}: ${response.statusText}`,
110
+ );
111
+ }
112
+
113
+ const manifest = (await response.json()) as CatalogManifest;
114
+ if (!Array.isArray(manifest.skills)) {
115
+ throw new Error("Platform catalog has invalid skills array");
116
+ }
117
+ return manifest.skills;
118
+ }
119
+
120
+ export function readLocalCatalog(repoSkillsDir: string): CatalogSkill[] {
121
+ try {
122
+ const raw = readFileSync(join(repoSkillsDir, "catalog.json"), "utf-8");
123
+ const manifest = JSON.parse(raw) as CatalogManifest;
124
+ if (!Array.isArray(manifest.skills)) return [];
125
+ return manifest.skills;
126
+ } catch {
127
+ return [];
128
+ }
129
+ }
130
+
131
+ // ─── Tar extraction ──────────────────────────────────────────────────────────
132
+
133
+ /**
134
+ * Extract all files from a tar archive (uncompressed) into a directory.
135
+ * Returns true if a SKILL.md was found in the archive.
136
+ */
137
+ export function extractTarToDir(tarBuffer: Buffer, destDir: string): boolean {
138
+ let foundSkillMd = false;
139
+ let offset = 0;
140
+ while (offset + 512 <= tarBuffer.length) {
141
+ const header = tarBuffer.subarray(offset, offset + 512);
142
+
143
+ // End-of-archive (two consecutive zero blocks)
144
+ if (header.every((b) => b === 0)) break;
145
+
146
+ // Filename (bytes 0-99, null-terminated)
147
+ const nameEnd = header.indexOf(0, 0);
148
+ const name = header
149
+ .subarray(0, Math.min(nameEnd >= 0 ? nameEnd : 100, 100))
150
+ .toString("utf-8");
151
+
152
+ // File type (byte 156): '5' = directory, '0' or '\0' = regular file
153
+ const typeFlag = header[156];
154
+
155
+ // File size (bytes 124-135, octal)
156
+ const sizeStr = header.subarray(124, 136).toString("utf-8").trim();
157
+ const size = parseInt(sizeStr, 8) || 0;
158
+
159
+ offset += 512; // past header
160
+
161
+ // Skip directories and empty names
162
+ if (name && typeFlag !== 53 /* '5' */) {
163
+ // Prevent path traversal
164
+ const normalizedName = name.replace(/^\.\//, "");
165
+ if (!normalizedName.startsWith("..") && !normalizedName.includes("/..")) {
166
+ const destPath = join(destDir, normalizedName);
167
+ mkdirSync(dirname(destPath), { recursive: true });
168
+ writeFileSync(destPath, tarBuffer.subarray(offset, offset + size));
169
+
170
+ if (
171
+ normalizedName === "SKILL.md" ||
172
+ normalizedName.endsWith("/SKILL.md")
173
+ ) {
174
+ foundSkillMd = true;
175
+ }
176
+ }
177
+ }
178
+
179
+ // Skip to next header (data padded to 512 bytes)
180
+ offset += Math.ceil(size / 512) * 512;
181
+ }
182
+ return foundSkillMd;
183
+ }
184
+
185
+ export async function fetchAndExtractSkill(
186
+ skillId: string,
187
+ destDir: string,
188
+ ): Promise<void> {
189
+ const url = `${getPlatformUrl()}/v1/skills/${encodeURIComponent(skillId)}/`;
190
+ const response = await fetch(url, {
191
+ headers: buildHeaders(),
192
+ signal: AbortSignal.timeout(15000),
193
+ });
194
+
195
+ if (!response.ok) {
196
+ throw new Error(
197
+ `Failed to fetch skill "${skillId}": HTTP ${response.status}`,
198
+ );
199
+ }
200
+
201
+ const gzipBuffer = Buffer.from(await response.arrayBuffer());
202
+ const tarBuffer = gunzipSync(gzipBuffer);
203
+ const foundSkillMd = extractTarToDir(tarBuffer, destDir);
204
+
205
+ if (!foundSkillMd) {
206
+ throw new Error(`SKILL.md not found in archive for "${skillId}"`);
207
+ }
208
+ }
209
+
210
+ // ─── SKILLS.md index management ──────────────────────────────────────────────
211
+
212
+ function atomicWriteFile(filePath: string, content: string): void {
213
+ const dir = dirname(filePath);
214
+ mkdirSync(dir, { recursive: true });
215
+ const tmpPath = join(dir, `.tmp-${randomUUID()}`);
216
+ writeFileSync(tmpPath, content, "utf-8");
217
+ renameSync(tmpPath, filePath);
218
+ }
219
+
220
+ export function upsertSkillsIndex(id: string): void {
221
+ const indexPath = getSkillsIndexPath();
222
+ let lines: string[] = [];
223
+ if (existsSync(indexPath)) {
224
+ lines = readFileSync(indexPath, "utf-8").split("\n");
225
+ }
226
+
227
+ const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
228
+ const pattern = new RegExp(`^[-*]\\s+(?:\`)?${escaped}(?:\`)?\\s*$`);
229
+ if (lines.some((line) => pattern.test(line))) return;
230
+
231
+ const nonEmpty = lines.filter((l) => l.trim());
232
+ nonEmpty.push(`- ${id}`);
233
+ const content = nonEmpty.join("\n");
234
+ atomicWriteFile(indexPath, content.endsWith("\n") ? content : content + "\n");
235
+ }
236
+
237
+ export function removeSkillsIndexEntry(id: string): void {
238
+ const indexPath = getSkillsIndexPath();
239
+ if (!existsSync(indexPath)) return;
240
+
241
+ const lines = readFileSync(indexPath, "utf-8").split("\n");
242
+ const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
243
+ const pattern = new RegExp(`^[-*]\\s+(?:\`)?${escaped}(?:\`)?\\s*$`);
244
+ const filtered = lines.filter((line) => !pattern.test(line));
245
+
246
+ // If nothing changed, skip the write
247
+ if (filtered.length === lines.length) return;
248
+
249
+ const content = filtered.join("\n");
250
+ atomicWriteFile(indexPath, content.endsWith("\n") ? content : content + "\n");
251
+ }
252
+
253
+ // ─── Install / uninstall ─────────────────────────────────────────────────────
254
+
255
+ export function uninstallSkillLocally(skillId: string): void {
256
+ const skillDir = join(getWorkspaceSkillsDir(), skillId);
257
+
258
+ if (!existsSync(skillDir)) {
259
+ throw new Error(`Skill "${skillId}" is not installed.`);
260
+ }
261
+
262
+ rmSync(skillDir, { recursive: true, force: true });
263
+ removeSkillsIndexEntry(skillId);
264
+ }
265
+
266
+ export async function installSkillLocally(
267
+ skillId: string,
268
+ catalogEntry: CatalogSkill,
269
+ overwrite: boolean,
270
+ ): Promise<void> {
271
+ const skillDir = join(getWorkspaceSkillsDir(), skillId);
272
+ const skillFilePath = join(skillDir, "SKILL.md");
273
+
274
+ if (existsSync(skillFilePath) && !overwrite) {
275
+ throw new Error(
276
+ `Skill "${skillId}" is already installed. Use --overwrite to replace it.`,
277
+ );
278
+ }
279
+
280
+ mkdirSync(skillDir, { recursive: true });
281
+
282
+ // In dev mode, install from the local repo skills directory if available
283
+ const repoSkillsDir = getRepoSkillsDir();
284
+ const repoSkillSource = repoSkillsDir
285
+ ? join(repoSkillsDir, skillId)
286
+ : undefined;
287
+
288
+ if (repoSkillSource && existsSync(join(repoSkillSource, "SKILL.md"))) {
289
+ cpSync(repoSkillSource, skillDir, { recursive: true });
290
+ } else {
291
+ await fetchAndExtractSkill(skillId, skillDir);
292
+ }
293
+
294
+ // Write version metadata
295
+ if (catalogEntry.version) {
296
+ const meta = {
297
+ version: catalogEntry.version,
298
+ installedAt: new Date().toISOString(),
299
+ };
300
+ atomicWriteFile(
301
+ join(skillDir, "version.json"),
302
+ JSON.stringify(meta, null, 2) + "\n",
303
+ );
304
+ }
305
+
306
+ // Install npm dependencies if the skill has a package.json
307
+ if (existsSync(join(skillDir, "package.json"))) {
308
+ const bunPath = `${homedir()}/.bun/bin`;
309
+ execSync("bun install", {
310
+ cwd: skillDir,
311
+ stdio: "inherit",
312
+ env: { ...process.env, PATH: `${bunPath}:${process.env.PATH}` },
313
+ });
314
+ }
315
+
316
+ // Register in SKILLS.md only after all steps succeed
317
+ upsertSkillsIndex(skillId);
318
+ }
319
+
320
+ // ─── Auto-install (for skill_load) ──────────────────────────────────────────
321
+
322
+ /**
323
+ * Attempt to find and install a skill from the first-party catalog.
324
+ * Returns true if the skill was installed, false if not found in catalog.
325
+ * Throws on install failures (network, filesystem, etc).
326
+ */
327
+ export async function autoInstallFromCatalog(
328
+ skillId: string,
329
+ ): Promise<boolean> {
330
+ // Check local catalog first (dev mode), then remote
331
+ const repoSkillsDir = getRepoSkillsDir();
332
+ let entry: CatalogSkill | undefined;
333
+
334
+ if (repoSkillsDir) {
335
+ const localCatalog = readLocalCatalog(repoSkillsDir);
336
+ entry = localCatalog.find((s) => s.id === skillId);
337
+ }
338
+
339
+ if (!entry) {
340
+ try {
341
+ const remoteCatalog = await fetchCatalog();
342
+ entry = remoteCatalog.find((s) => s.id === skillId);
343
+ } catch (err) {
344
+ log.warn(
345
+ { err, skillId },
346
+ "Failed to fetch remote catalog for auto-install",
347
+ );
348
+ return false;
349
+ }
350
+ }
351
+
352
+ if (!entry) {
353
+ return false;
354
+ }
355
+
356
+ await installSkillLocally(skillId, entry, false);
357
+ return true;
358
+ }
@@ -151,3 +151,35 @@ export function traverseIncludes(
151
151
  dfs(rootId);
152
152
  return { visited };
153
153
  }
154
+
155
+ /**
156
+ * Collect all missing skill IDs reachable from the root's include graph.
157
+ * DFS traversal that tracks visited nodes to prevent infinite loops on cycles.
158
+ * The root itself is never reported as missing (it's already loaded by the caller).
159
+ */
160
+ export function collectAllMissing(
161
+ rootId: string,
162
+ catalogIndex: Map<string, SkillSummary>,
163
+ ): Set<string> {
164
+ const missing = new Set<string>();
165
+ const visited = new Set<string>();
166
+
167
+ function dfs(id: string): void {
168
+ if (visited.has(id)) return;
169
+ visited.add(id);
170
+
171
+ const skill = catalogIndex.get(id);
172
+ if (!skill?.includes) return;
173
+
174
+ for (const childId of skill.includes) {
175
+ if (!catalogIndex.has(childId)) {
176
+ missing.add(childId);
177
+ } else if (!visited.has(childId)) {
178
+ dfs(childId);
179
+ }
180
+ }
181
+ }
182
+
183
+ dfs(rootId);
184
+ return missing;
185
+ }
@@ -1,13 +1,12 @@
1
1
  import { getConfig } from "../config/loader.js";
2
2
 
3
3
  /**
4
- * Read the Telegram bot username from config, falling back to the
5
- * TELEGRAM_BOT_USERNAME env var.
4
+ * Read the Telegram bot username from config.
6
5
  */
7
6
  export function getTelegramBotUsername(): string | undefined {
8
7
  const value = getConfig().telegram.botUsername;
9
8
  if (value.trim().length > 0) {
10
9
  return value.trim();
11
10
  }
12
- return process.env.TELEGRAM_BOT_USERNAME || undefined;
11
+ return undefined;
13
12
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * CDP Network recorder for Ride Shotgun "learn" mode.
2
+ * CDP Network recorder.
3
3
  *
4
4
  * Connects directly to Chrome's CDP WebSocket endpoint to record
5
5
  * Network.* events across all tabs the user browses.
@@ -1,4 +1,4 @@
1
- /** Types for CDP network recording used by Ride Shotgun "learn" mode. */
1
+ /** Types for CDP network recording. */
2
2
 
3
3
  export interface NetworkRecordedRequest {
4
4
  method: string;