@vellumai/assistant 0.5.6 → 0.5.7

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 (305) hide show
  1. package/.env.example +16 -2
  2. package/ARCHITECTURE.md +6 -75
  3. package/Dockerfile +1 -1
  4. package/README.md +0 -2
  5. package/bun.lock +0 -414
  6. package/docs/architecture/keychain-broker.md +45 -240
  7. package/docs/architecture/security.md +0 -17
  8. package/docs/credential-execution-service.md +2 -2
  9. package/node_modules/@vellumai/ces-contracts/package.json +1 -0
  10. package/node_modules/@vellumai/ces-contracts/src/rpc.ts +119 -0
  11. package/node_modules/@vellumai/credential-storage/package.json +1 -0
  12. package/node_modules/@vellumai/egress-proxy/package.json +1 -0
  13. package/package.json +2 -3
  14. package/src/__tests__/actor-token-service.test.ts +0 -114
  15. package/src/__tests__/assistant-feature-flags-integration.test.ts +30 -29
  16. package/src/__tests__/browser-skill-endstate.test.ts +6 -5
  17. package/src/__tests__/btw-routes.test.ts +0 -39
  18. package/src/__tests__/call-domain.test.ts +0 -128
  19. package/src/__tests__/ces-rpc-credential-backend.test.ts +199 -0
  20. package/src/__tests__/channel-approval-routes.test.ts +0 -5
  21. package/src/__tests__/channel-readiness-service.test.ts +1 -60
  22. package/src/__tests__/checker.test.ts +4 -2
  23. package/src/__tests__/cli-command-risk-guard.test.ts +112 -0
  24. package/src/__tests__/config-schema-cmd.test.ts +0 -1
  25. package/src/__tests__/config-schema.test.ts +1 -1
  26. package/src/__tests__/conversation-attention-telegram.test.ts +0 -5
  27. package/src/__tests__/conversation-init.benchmark.test.ts +0 -2
  28. package/src/__tests__/conversation-skill-tools.test.ts +0 -54
  29. package/src/__tests__/conversation-title-service.test.ts +87 -0
  30. package/src/__tests__/credential-execution-feature-gates.test.ts +28 -14
  31. package/src/__tests__/credential-execution-managed-contract.test.ts +33 -18
  32. package/src/__tests__/credential-security-e2e.test.ts +0 -66
  33. package/src/__tests__/credential-security-invariants.test.ts +4 -45
  34. package/src/__tests__/credentials-cli.test.ts +78 -0
  35. package/src/__tests__/db-migration-rollback.test.ts +2015 -1
  36. package/src/__tests__/docker-signing-key-bootstrap.test.ts +34 -143
  37. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +6 -4
  38. package/src/__tests__/guardian-routing-state.test.ts +0 -5
  39. package/src/__tests__/host-shell-tool.test.ts +6 -7
  40. package/src/__tests__/http-user-message-parity.test.ts +3 -103
  41. package/src/__tests__/inbound-invite-redemption.test.ts +0 -4
  42. package/src/__tests__/inline-skill-load-permissions.test.ts +6 -8
  43. package/src/__tests__/intent-routing.test.ts +0 -13
  44. package/src/__tests__/jobs-store-qdrant-breaker.test.ts +178 -0
  45. package/src/__tests__/keychain-broker-client.test.ts +161 -22
  46. package/src/__tests__/memory-jobs-worker-backoff.test.ts +150 -0
  47. package/src/__tests__/migration-export-http.test.ts +2 -2
  48. package/src/__tests__/migration-import-commit-http.test.ts +2 -2
  49. package/src/__tests__/migration-import-preflight-http.test.ts +2 -2
  50. package/src/__tests__/migration-validate-http.test.ts +2 -2
  51. package/src/__tests__/non-member-access-request.test.ts +0 -5
  52. package/src/__tests__/notification-decision-fallback.test.ts +4 -0
  53. package/src/__tests__/notification-decision-identity.test.ts +4 -0
  54. package/src/__tests__/permission-types.test.ts +1 -0
  55. package/src/__tests__/provider-managed-proxy-integration.test.ts +5 -6
  56. package/src/__tests__/qdrant-manager.test.ts +28 -2
  57. package/src/__tests__/registry.test.ts +0 -6
  58. package/src/__tests__/runtime-attachment-metadata.test.ts +0 -4
  59. package/src/__tests__/secret-routes-managed-proxy.test.ts +0 -4
  60. package/src/__tests__/secure-keys.test.ts +83 -263
  61. package/src/__tests__/shell-identity.test.ts +96 -6
  62. package/src/__tests__/skill-feature-flags-integration.test.ts +22 -14
  63. package/src/__tests__/skill-feature-flags.test.ts +46 -45
  64. package/src/__tests__/skill-load-feature-flag.test.ts +7 -10
  65. package/src/__tests__/skill-load-inline-command.test.ts +8 -12
  66. package/src/__tests__/skill-load-inline-includes.test.ts +6 -10
  67. package/src/__tests__/skill-load-tool.test.ts +0 -2
  68. package/src/__tests__/skill-projection-feature-flag.test.ts +33 -29
  69. package/src/__tests__/skills.test.ts +0 -2
  70. package/src/__tests__/slack-inbound-verification.test.ts +0 -4
  71. package/src/__tests__/suggestion-routes.test.ts +1 -32
  72. package/src/__tests__/system-prompt.test.ts +0 -1
  73. package/src/__tests__/tool-executor-shell-integration.test.ts +5 -3
  74. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +0 -5
  75. package/src/__tests__/trusted-contact-multichannel.test.ts +0 -4
  76. package/src/__tests__/update-bulletin.test.ts +0 -2
  77. package/src/__tests__/vellum-self-knowledge-inline-command.test.ts +6 -9
  78. package/src/__tests__/voice-scoped-grant-consumer.test.ts +0 -6
  79. package/src/__tests__/workspace-migration-015-migrate-credentials-to-keychain.test.ts +252 -0
  80. package/src/__tests__/workspace-migration-016-migrate-credentials-from-keychain.test.ts +218 -0
  81. package/src/__tests__/workspace-migration-down-functions.test.ts +1009 -0
  82. package/src/__tests__/workspace-migrations-runner.test.ts +114 -0
  83. package/src/calls/audio-store.test.ts +97 -0
  84. package/src/calls/audio-store.ts +205 -0
  85. package/src/calls/call-controller.ts +85 -7
  86. package/src/calls/call-domain.ts +3 -0
  87. package/src/calls/call-store.ts +10 -3
  88. package/src/calls/fish-audio-client.ts +117 -0
  89. package/src/calls/relay-server.ts +27 -0
  90. package/src/calls/twilio-routes.ts +2 -1
  91. package/src/calls/types.ts +1 -0
  92. package/src/calls/voice-ingress-preflight.ts +0 -42
  93. package/src/calls/voice-quality.ts +26 -5
  94. package/src/calls/voice-session-bridge.ts +6 -12
  95. package/src/cli/commands/config.ts +1 -4
  96. package/src/cli/commands/credentials.ts +34 -4
  97. package/src/cli/commands/oauth/index.ts +7 -0
  98. package/src/cli/commands/oauth/platform.ts +179 -0
  99. package/src/cli/commands/platform.ts +3 -3
  100. package/src/config/assistant-feature-flags.ts +186 -5
  101. package/src/config/bundled-skills/messaging/SKILL.md +5 -5
  102. package/src/config/bundled-skills/phone-calls/TOOLS.json +4 -0
  103. package/src/config/bundled-skills/settings/TOOLS.json +2 -2
  104. package/src/config/bundled-skills/settings/tools/voice-config-update.ts +42 -0
  105. package/src/config/bundled-tool-registry.ts +1 -11
  106. package/src/config/env-registry.ts +1 -1
  107. package/src/config/env.ts +8 -14
  108. package/src/config/feature-flag-registry.json +48 -8
  109. package/src/config/loader.ts +98 -31
  110. package/src/config/schema.ts +4 -13
  111. package/src/config/schemas/calls.ts +13 -0
  112. package/src/config/schemas/fish-audio.ts +39 -0
  113. package/src/config/schemas/security.ts +0 -4
  114. package/src/config/types.ts +0 -1
  115. package/src/contacts/contact-store.ts +39 -0
  116. package/src/contacts/types.ts +2 -0
  117. package/src/credential-execution/approval-bridge.ts +1 -0
  118. package/src/credential-execution/executable-discovery.ts +28 -4
  119. package/src/credential-execution/feature-gates.ts +16 -0
  120. package/src/credential-execution/process-manager.ts +38 -0
  121. package/src/daemon/assistant-attachments.ts +9 -0
  122. package/src/daemon/config-watcher.ts +5 -0
  123. package/src/daemon/conversation-tool-setup.ts +0 -105
  124. package/src/daemon/conversation.ts +10 -1
  125. package/src/daemon/handlers/config-vercel.ts +92 -0
  126. package/src/daemon/handlers/skills.ts +2 -15
  127. package/src/daemon/install-symlink.ts +195 -0
  128. package/src/daemon/lifecycle.ts +227 -51
  129. package/src/daemon/message-types/conversations.ts +3 -4
  130. package/src/daemon/message-types/diagnostics.ts +3 -22
  131. package/src/daemon/message-types/messages.ts +0 -2
  132. package/src/daemon/message-types/upgrades.ts +8 -0
  133. package/src/daemon/server.ts +30 -92
  134. package/src/events/domain-events.ts +2 -1
  135. package/src/inbound/platform-callback-registration.ts +3 -3
  136. package/src/instrument.ts +8 -5
  137. package/src/memory/conversation-title-service.ts +50 -1
  138. package/src/memory/db-init.ts +12 -0
  139. package/src/memory/items-extractor.ts +15 -1
  140. package/src/memory/job-handlers/conversation-starters.ts +4 -1
  141. package/src/memory/jobs-store.ts +30 -5
  142. package/src/memory/jobs-worker.ts +31 -7
  143. package/src/memory/migrations/001-job-deferrals.ts +19 -0
  144. package/src/memory/migrations/004-entity-relation-dedup.ts +10 -0
  145. package/src/memory/migrations/005-fingerprint-scope-unique.ts +76 -0
  146. package/src/memory/migrations/006-scope-salted-fingerprints.ts +50 -0
  147. package/src/memory/migrations/007-assistant-id-to-self.ts +10 -0
  148. package/src/memory/migrations/008-remove-assistant-id-columns.ts +34 -0
  149. package/src/memory/migrations/009-llm-usage-events-drop-assistant-id.ts +26 -0
  150. package/src/memory/migrations/014-backfill-inbox-thread-state.ts +10 -0
  151. package/src/memory/migrations/015-drop-active-search-index.ts +17 -0
  152. package/src/memory/migrations/019-notification-tables-schema-migration.ts +12 -0
  153. package/src/memory/migrations/020-rename-macos-ios-channel-to-vellum.ts +121 -0
  154. package/src/memory/migrations/024-embedding-vector-blob.ts +74 -0
  155. package/src/memory/migrations/026a-embeddings-nullable-vector-json.ts +82 -0
  156. package/src/memory/migrations/036-normalize-phone-identities.ts +11 -0
  157. package/src/memory/migrations/116-messages-fts.ts +106 -1
  158. package/src/memory/migrations/126-backfill-guardian-principal-id.ts +52 -0
  159. package/src/memory/migrations/127-guardian-principal-id-not-null.ts +77 -0
  160. package/src/memory/migrations/134-contacts-notes-column.ts +13 -0
  161. package/src/memory/migrations/135-backfill-contact-interaction-stats.ts +20 -0
  162. package/src/memory/migrations/136-drop-assistant-id-columns.ts +52 -0
  163. package/src/memory/migrations/140-backfill-usage-cache-accounting.ts +13 -0
  164. package/src/memory/migrations/141-rename-verification-table.ts +54 -0
  165. package/src/memory/migrations/142-rename-verification-session-id-column.ts +25 -0
  166. package/src/memory/migrations/143-rename-guardian-verification-values.ts +35 -0
  167. package/src/memory/migrations/144-rename-voice-to-phone.ts +136 -0
  168. package/src/memory/migrations/145-drop-accounts-table.ts +32 -0
  169. package/src/memory/migrations/147-migrate-reminders-to-schedules.ts +14 -1
  170. package/src/memory/migrations/148-drop-reminders-table.ts +35 -1
  171. package/src/memory/migrations/150-oauth-apps-client-secret-path.ts +69 -1
  172. package/src/memory/migrations/162-guardian-timestamps-epoch-ms.ts +290 -0
  173. package/src/memory/migrations/169-rename-gmail-provider-key-to-google.ts +51 -1
  174. package/src/memory/migrations/174-rename-thread-starters-table.ts +47 -1
  175. package/src/memory/migrations/176-drop-capability-card-state.ts +13 -0
  176. package/src/memory/migrations/180-backfill-inline-attachments-to-disk.ts +16 -0
  177. package/src/memory/migrations/181-rename-thread-starters-checkpoints.ts +28 -1
  178. package/src/memory/migrations/190-call-session-skip-disclosure.ts +15 -0
  179. package/src/memory/migrations/191-backfill-audio-attachment-mime-types.ts +64 -0
  180. package/src/memory/migrations/192-contacts-user-file-column.ts +15 -0
  181. package/src/memory/migrations/index.ts +4 -0
  182. package/src/memory/migrations/registry.ts +90 -0
  183. package/src/memory/migrations/validate-migration-state.ts +137 -11
  184. package/src/memory/qdrant-circuit-breaker.ts +9 -0
  185. package/src/memory/qdrant-manager.ts +64 -7
  186. package/src/memory/schema/calls.ts +1 -0
  187. package/src/memory/schema/contacts.ts +1 -0
  188. package/src/notifications/decision-engine.ts +4 -1
  189. package/src/oauth/connection-resolver.ts +6 -4
  190. package/src/permissions/checker.ts +0 -38
  191. package/src/permissions/shell-identity.ts +76 -22
  192. package/src/permissions/types.ts +4 -2
  193. package/src/platform/client.ts +35 -7
  194. package/src/prompts/persona-resolver.ts +138 -0
  195. package/src/prompts/system-prompt.ts +36 -4
  196. package/src/prompts/templates/users/default.md +1 -0
  197. package/src/providers/registry.ts +27 -40
  198. package/src/runtime/auth/__tests__/credential-service.test.ts +0 -1
  199. package/src/runtime/auth/__tests__/external-assistant-id.test.ts +13 -68
  200. package/src/runtime/auth/external-assistant-id.ts +13 -59
  201. package/src/runtime/auth/route-policy.ts +15 -1
  202. package/src/runtime/auth/token-service.ts +43 -138
  203. package/src/runtime/channel-readiness-service.ts +1 -16
  204. package/src/runtime/http-server.ts +27 -2
  205. package/src/runtime/middleware/error-handler.ts +1 -9
  206. package/src/runtime/routes/audio-routes.ts +40 -0
  207. package/src/runtime/routes/btw-routes.ts +0 -17
  208. package/src/runtime/routes/conversation-query-routes.ts +63 -1
  209. package/src/runtime/routes/conversation-routes.ts +4 -44
  210. package/src/runtime/routes/diagnostics-routes.ts +1 -477
  211. package/src/runtime/routes/identity-routes.ts +18 -29
  212. package/src/runtime/routes/inbound-stages/secret-ingress-check.ts +4 -33
  213. package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +1 -1
  214. package/src/runtime/routes/integrations/vercel.ts +89 -0
  215. package/src/runtime/routes/log-export-routes.ts +5 -0
  216. package/src/runtime/routes/memory-item-routes.ts +24 -6
  217. package/src/runtime/routes/migration-rollback-routes.ts +209 -0
  218. package/src/runtime/routes/migration-routes.ts +17 -1
  219. package/src/runtime/routes/notification-routes.ts +58 -0
  220. package/src/runtime/routes/schedule-routes.ts +65 -0
  221. package/src/runtime/routes/settings-routes.ts +41 -1
  222. package/src/runtime/routes/tts-routes.ts +86 -0
  223. package/src/runtime/routes/upgrade-broadcast-routes.ts +26 -2
  224. package/src/runtime/routes/workspace-commit-routes.ts +62 -0
  225. package/src/runtime/routes/workspace-routes.test.ts +22 -1
  226. package/src/runtime/routes/workspace-routes.ts +1 -1
  227. package/src/runtime/routes/workspace-utils.ts +86 -2
  228. package/src/security/ces-credential-client.ts +59 -22
  229. package/src/security/ces-rpc-credential-backend.ts +85 -0
  230. package/src/security/credential-backend.ts +12 -88
  231. package/src/security/keychain-broker-client.ts +10 -2
  232. package/src/security/secure-keys.ts +94 -113
  233. package/src/skills/catalog-install.ts +13 -7
  234. package/src/telemetry/usage-telemetry-reporter.ts +4 -2
  235. package/src/tools/calls/call-start.ts +1 -0
  236. package/src/tools/executor.ts +0 -4
  237. package/src/tools/network/script-proxy/session-manager.ts +19 -4
  238. package/src/tools/network/web-fetch.ts +3 -1
  239. package/src/tools/skills/execute.ts +1 -1
  240. package/src/tools/types.ts +0 -8
  241. package/src/util/errors.ts +0 -12
  242. package/src/util/platform.ts +3 -50
  243. package/src/workspace/git-service.ts +5 -2
  244. package/src/workspace/migrations/001-avatar-rename.ts +15 -0
  245. package/src/workspace/migrations/003-seed-device-id.ts +17 -1
  246. package/src/workspace/migrations/004-extract-collect-usage-data.ts +33 -0
  247. package/src/workspace/migrations/005-add-send-diagnostics.ts +3 -0
  248. package/src/workspace/migrations/006-services-config.ts +49 -0
  249. package/src/workspace/migrations/007-web-search-provider-rename.ts +27 -0
  250. package/src/workspace/migrations/008-voice-timeout-and-max-steps.ts +3 -0
  251. package/src/workspace/migrations/009-backfill-conversation-disk-view.ts +4 -0
  252. package/src/workspace/migrations/010-app-dir-rename.ts +78 -0
  253. package/src/workspace/migrations/011-backfill-installation-id.ts +11 -0
  254. package/src/workspace/migrations/012-rename-conversation-disk-view-dirs.ts +44 -0
  255. package/src/workspace/migrations/013-repair-conversation-disk-view.ts +5 -0
  256. package/src/workspace/migrations/015-migrate-credentials-to-keychain.ts +153 -0
  257. package/src/workspace/migrations/016-extract-feature-flags-to-protected.ts +156 -0
  258. package/src/workspace/migrations/016-migrate-credentials-from-keychain.ts +150 -0
  259. package/src/workspace/migrations/017-seed-persona-dirs.ts +95 -0
  260. package/src/workspace/migrations/migrate-to-workspace-volume.ts +23 -1
  261. package/src/workspace/migrations/registry.ts +8 -0
  262. package/src/workspace/migrations/runner.ts +106 -2
  263. package/src/workspace/migrations/types.ts +4 -0
  264. package/src/__tests__/claude-code-skill-regression.test.ts +0 -206
  265. package/src/__tests__/claude-code-tool-profiles.test.ts +0 -99
  266. package/src/__tests__/diagnostics-export.test.ts +0 -288
  267. package/src/__tests__/local-gateway-health.test.ts +0 -209
  268. package/src/__tests__/secret-ingress-handler.test.ts +0 -120
  269. package/src/__tests__/swarm-conversation-integration.test.ts +0 -358
  270. package/src/__tests__/swarm-dag-pathological.test.ts +0 -547
  271. package/src/__tests__/swarm-orchestrator.test.ts +0 -463
  272. package/src/__tests__/swarm-plan-validator.test.ts +0 -384
  273. package/src/__tests__/swarm-recursion.test.ts +0 -197
  274. package/src/__tests__/swarm-router-planner.test.ts +0 -234
  275. package/src/__tests__/swarm-tool.test.ts +0 -185
  276. package/src/__tests__/swarm-worker-backend.test.ts +0 -144
  277. package/src/__tests__/swarm-worker-runner.test.ts +0 -288
  278. package/src/commands/__tests__/cc-command-registry.test.ts +0 -396
  279. package/src/commands/cc-command-registry.ts +0 -248
  280. package/src/config/bundled-skills/claude-code/SKILL.md +0 -53
  281. package/src/config/bundled-skills/claude-code/TOOLS.json +0 -47
  282. package/src/config/bundled-skills/claude-code/tools/claude-code.ts +0 -12
  283. package/src/config/bundled-skills/orchestration/SKILL.md +0 -33
  284. package/src/config/bundled-skills/orchestration/TOOLS.json +0 -35
  285. package/src/config/bundled-skills/orchestration/tools/swarm-delegate.ts +0 -12
  286. package/src/config/schemas/swarm.ts +0 -82
  287. package/src/logfire.ts +0 -135
  288. package/src/runtime/local-gateway-health.ts +0 -275
  289. package/src/security/secret-ingress.ts +0 -68
  290. package/src/swarm/backend-claude-code.ts +0 -225
  291. package/src/swarm/checkpoint.ts +0 -137
  292. package/src/swarm/graph-utils.ts +0 -53
  293. package/src/swarm/index.ts +0 -55
  294. package/src/swarm/limits.ts +0 -66
  295. package/src/swarm/orchestrator.ts +0 -424
  296. package/src/swarm/plan-validator.ts +0 -117
  297. package/src/swarm/router-planner.ts +0 -162
  298. package/src/swarm/router-prompts.ts +0 -39
  299. package/src/swarm/synthesizer.ts +0 -81
  300. package/src/swarm/types.ts +0 -72
  301. package/src/swarm/worker-backend.ts +0 -131
  302. package/src/swarm/worker-prompts.ts +0 -80
  303. package/src/swarm/worker-runner.ts +0 -170
  304. package/src/tools/claude-code/claude-code.ts +0 -610
  305. package/src/tools/swarm/delegate.ts +0 -205
@@ -0,0 +1,150 @@
1
+ import { getLogger } from "../../util/logger.js";
2
+ import type { WorkspaceMigration } from "./types.js";
3
+
4
+ const log = getLogger("workspace-migrations");
5
+
6
+ const BROKER_WAIT_INTERVAL_MS = 500;
7
+ const BROKER_WAIT_MAX_ATTEMPTS = 10; // 5 seconds total
8
+
9
+ export const migrateCredentialsFromKeychainMigration: WorkspaceMigration = {
10
+ id: "016-migrate-credentials-from-keychain",
11
+ description:
12
+ "Copy keychain credentials back to encrypted store for CES unification",
13
+
14
+ async down(_workspaceDir: string): Promise<void> {
15
+ // Reverse: copy credentials from encrypted store back to keychain.
16
+ // Mirrors the forward logic of 015-migrate-credentials-to-keychain.
17
+ if (
18
+ process.env.VELLUM_DESKTOP_APP !== "1" ||
19
+ process.env.VELLUM_DEV === "1"
20
+ ) {
21
+ return;
22
+ }
23
+
24
+ const { createBrokerClient } =
25
+ await import("../../security/keychain-broker-client.js");
26
+ const client = createBrokerClient();
27
+
28
+ let brokerAvailable = false;
29
+ for (let i = 0; i < BROKER_WAIT_MAX_ATTEMPTS; i++) {
30
+ if (client.isAvailable()) {
31
+ brokerAvailable = true;
32
+ break;
33
+ }
34
+ await new Promise((r) => setTimeout(r, BROKER_WAIT_INTERVAL_MS));
35
+ }
36
+
37
+ if (!brokerAvailable) {
38
+ throw new Error(
39
+ "Keychain broker not available after waiting — credential rollback " +
40
+ "will be retried on next startup",
41
+ );
42
+ }
43
+
44
+ const { listKeys, getKey, deleteKey } =
45
+ await import("../../security/encrypted-store.js");
46
+
47
+ const accounts = listKeys();
48
+ if (accounts.length === 0) return;
49
+
50
+ let rolledBackCount = 0;
51
+ let failedCount = 0;
52
+
53
+ for (const account of accounts) {
54
+ const value = getKey(account);
55
+ if (value === undefined) {
56
+ log.warn(
57
+ { account },
58
+ "Failed to read key from encrypted store during rollback — skipping",
59
+ );
60
+ failedCount++;
61
+ continue;
62
+ }
63
+
64
+ const result = await client.set(account, value);
65
+ if (result.status === "ok") {
66
+ deleteKey(account);
67
+ rolledBackCount++;
68
+ } else {
69
+ log.warn(
70
+ { account, status: result.status },
71
+ "Failed to write key to keychain during rollback — skipping",
72
+ );
73
+ failedCount++;
74
+ }
75
+ }
76
+
77
+ log.info(
78
+ { rolledBackCount, failedCount },
79
+ "Credential rollback from encrypted store to keychain complete",
80
+ );
81
+ },
82
+
83
+ async run(_workspaceDir: string): Promise<void> {
84
+ // Only run on mac production builds (desktop app, non-dev).
85
+ if (
86
+ process.env.VELLUM_DESKTOP_APP !== "1" ||
87
+ process.env.VELLUM_DEV === "1"
88
+ ) {
89
+ return;
90
+ }
91
+
92
+ const { createBrokerClient } =
93
+ await import("../../security/keychain-broker-client.js");
94
+ const client = createBrokerClient();
95
+
96
+ // Wait for the broker to become available (up to 5 seconds), matching
97
+ // the retry strategy in secure-keys.ts waitForBrokerAvailability().
98
+ let brokerAvailable = false;
99
+ for (let i = 0; i < BROKER_WAIT_MAX_ATTEMPTS; i++) {
100
+ if (client.isAvailable()) {
101
+ brokerAvailable = true;
102
+ break;
103
+ }
104
+ await new Promise((r) => setTimeout(r, BROKER_WAIT_INTERVAL_MS));
105
+ }
106
+
107
+ if (!brokerAvailable) {
108
+ // Unlike migration 015, we return silently here. If the broker is not
109
+ // available, credentials may already be in the encrypted store from
110
+ // before migration 015 ran, or from a non-desktop environment.
111
+ return;
112
+ }
113
+
114
+ const { setKey } = await import("../../security/encrypted-store.js");
115
+
116
+ const accounts = await client.list();
117
+ if (accounts.length === 0) {
118
+ return;
119
+ }
120
+
121
+ let migratedCount = 0;
122
+ let failedCount = 0;
123
+
124
+ for (const account of accounts) {
125
+ const result = await client.get(account);
126
+ if (!result || !result.found || result.value === undefined) {
127
+ log.warn({ account }, "Failed to read key from keychain — skipping");
128
+ failedCount++;
129
+ continue;
130
+ }
131
+
132
+ const written = setKey(account, result.value);
133
+ if (written) {
134
+ await client.del(account);
135
+ migratedCount++;
136
+ } else {
137
+ log.warn(
138
+ { account },
139
+ "Failed to write key to encrypted store — skipping",
140
+ );
141
+ failedCount++;
142
+ }
143
+ }
144
+
145
+ log.info(
146
+ { migratedCount, failedCount },
147
+ "Credential migration from keychain complete",
148
+ );
149
+ },
150
+ };
@@ -0,0 +1,95 @@
1
+ import {
2
+ copyFileSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ rmdirSync,
8
+ } from "node:fs";
9
+ import { join } from "node:path";
10
+
11
+ import { eq } from "drizzle-orm";
12
+
13
+ import { generateUserFileSlug } from "../../contacts/contact-store.js";
14
+ import { getDb } from "../../memory/db.js";
15
+ import { contacts } from "../../memory/schema/contacts.js";
16
+ import {
17
+ isTemplateContent,
18
+ stripCommentLines,
19
+ } from "../../prompts/system-prompt.js";
20
+ import type { WorkspaceMigration } from "./types.js";
21
+
22
+ export const seedPersonaDirsMigration: WorkspaceMigration = {
23
+ id: "017-seed-persona-dirs",
24
+ description:
25
+ "Create users/ and channels/ persona directories and migrate customized USER.md",
26
+
27
+ down(workspaceDir: string): void {
28
+ // Remove the seeded persona directories only if they are empty.
29
+ // We don't delete user-created content — only clean up the empty
30
+ // directories that the forward migration created.
31
+ const usersDir = join(workspaceDir, "users");
32
+ const channelsDir = join(workspaceDir, "channels");
33
+
34
+ for (const dir of [usersDir, channelsDir]) {
35
+ if (!existsSync(dir)) continue;
36
+ try {
37
+ const entries = readdirSync(dir);
38
+ if (entries.length === 0) {
39
+ rmdirSync(dir);
40
+ }
41
+ } catch {
42
+ // Best-effort: skip if we can't read or remove
43
+ }
44
+ }
45
+ },
46
+
47
+ run(workspaceDir: string): void {
48
+ // Create persona directories
49
+ mkdirSync(join(workspaceDir, "users"), { recursive: true });
50
+ mkdirSync(join(workspaceDir, "channels"), { recursive: true });
51
+
52
+ // Check if USER.md exists and has been customized
53
+ const userMdPath = join(workspaceDir, "USER.md");
54
+ if (!existsSync(userMdPath)) return;
55
+
56
+ const rawContent = readFileSync(userMdPath, "utf-8");
57
+ const content = stripCommentLines(rawContent);
58
+ if (!content) return;
59
+
60
+ // Skip if the content is the unmodified template
61
+ if (isTemplateContent(content, "USER.md")) return;
62
+
63
+ // Determine destination filename based on guardian contact
64
+ let destFilename = "guardian.md";
65
+ try {
66
+ const db = getDb();
67
+ const guardian = db
68
+ .select()
69
+ .from(contacts)
70
+ .where(eq(contacts.role, "guardian"))
71
+ .limit(1)
72
+ .get();
73
+
74
+ if (guardian) {
75
+ if (guardian.userFile) {
76
+ destFilename = guardian.userFile;
77
+ } else {
78
+ const slug = generateUserFileSlug(guardian.displayName);
79
+ db.update(contacts)
80
+ .set({ userFile: slug })
81
+ .where(eq(contacts.id, guardian.id))
82
+ .run();
83
+ destFilename = slug;
84
+ }
85
+ }
86
+ } catch {
87
+ // DB might not be initialized yet — fall back to guardian.md
88
+ }
89
+
90
+ const destPath = join(workspaceDir, "users", destFilename);
91
+ if (!existsSync(destPath)) {
92
+ copyFileSync(userMdPath, destPath);
93
+ }
94
+ },
95
+ };
@@ -14,7 +14,13 @@
14
14
  * - Skips if the old workspace directory doesn't exist or is empty.
15
15
  */
16
16
 
17
- import { cpSync, existsSync, readdirSync, writeFileSync } from "node:fs";
17
+ import {
18
+ cpSync,
19
+ existsSync,
20
+ readdirSync,
21
+ unlinkSync,
22
+ writeFileSync,
23
+ } from "node:fs";
18
24
  import { join } from "node:path";
19
25
 
20
26
  import {
@@ -30,6 +36,22 @@ export const migrateToWorkspaceVolumeMigration: WorkspaceMigration = {
30
36
  description:
31
37
  "Copy workspace data from old /data/.vellum/workspace to new WORKSPACE_DIR volume on first boot",
32
38
 
39
+ down(workspaceDir: string): void {
40
+ // This migration copies data between volumes. Actually reversing the copy
41
+ // (deleting data from the workspace volume) is dangerous and could cause
42
+ // data loss. Instead, we just remove the sentinel file so the migration
43
+ // will re-run and re-evaluate on next startup.
44
+ const sentinelPath = join(workspaceDir, SENTINEL_FILENAME);
45
+ if (existsSync(sentinelPath)) {
46
+ try {
47
+ unlinkSync(sentinelPath);
48
+ } catch {
49
+ // Best-effort — the migration runner's checkpoint removal will
50
+ // also ensure the migration re-runs.
51
+ }
52
+ }
53
+ },
54
+
33
55
  run(workspaceDir: string): void {
34
56
  const workspaceDirOverride = getWorkspaceDirOverride();
35
57
 
@@ -10,6 +10,10 @@ import { appDirRenameMigration } from "./010-app-dir-rename.js";
10
10
  import { backfillInstallationIdMigration } from "./011-backfill-installation-id.js";
11
11
  import { renameConversationDiskViewDirsMigration } from "./012-rename-conversation-disk-view-dirs.js";
12
12
  import { repairConversationDiskViewMigration } from "./013-repair-conversation-disk-view.js";
13
+ import { migrateCredentialsToKeychainMigration } from "./015-migrate-credentials-to-keychain.js";
14
+ import { extractFeatureFlagsToProtectedMigration } from "./016-extract-feature-flags-to-protected.js";
15
+ import { migrateCredentialsFromKeychainMigration } from "./016-migrate-credentials-from-keychain.js";
16
+ import { seedPersonaDirsMigration } from "./017-seed-persona-dirs.js";
13
17
  import { migrateToWorkspaceVolumeMigration } from "./migrate-to-workspace-volume.js";
14
18
  import type { WorkspaceMigration } from "./types.js";
15
19
 
@@ -31,4 +35,8 @@ export const WORKSPACE_MIGRATIONS: WorkspaceMigration[] = [
31
35
  renameConversationDiskViewDirsMigration,
32
36
  repairConversationDiskViewMigration,
33
37
  migrateToWorkspaceVolumeMigration,
38
+ migrateCredentialsToKeychainMigration,
39
+ migrateCredentialsFromKeychainMigration,
40
+ seedPersonaDirsMigration,
41
+ extractFeatureFlagsToProtectedMigration,
34
42
  ];
@@ -7,10 +7,16 @@ import type { WorkspaceMigration } from "./types.js";
7
7
 
8
8
  const log = getLogger("workspace-migrations");
9
9
 
10
+ export function getLastWorkspaceMigrationId(
11
+ migrations: WorkspaceMigration[],
12
+ ): string | null {
13
+ return migrations.length > 0 ? migrations[migrations.length - 1].id : null;
14
+ }
15
+
10
16
  export type CheckpointFile = {
11
17
  applied: Record<
12
18
  string,
13
- { appliedAt: string; status?: "started" | "completed" }
19
+ { appliedAt: string; status?: "started" | "completed" | "rolling_back" }
14
20
  >;
15
21
  };
16
22
 
@@ -73,7 +79,7 @@ export async function runWorkspaceMigrations(
73
79
  const checkpoints = loadCheckpoints(workspaceDir);
74
80
 
75
81
  for (const [id, entry] of Object.entries(checkpoints.applied)) {
76
- if (entry.status === "started") {
82
+ if (entry.status === "started" || entry.status === "rolling_back") {
77
83
  log.warn(
78
84
  `Workspace migration "${id}" was interrupted during a previous run; will re-run`,
79
85
  );
@@ -115,3 +121,101 @@ export async function runWorkspaceMigrations(
115
121
  saveCheckpoints(workspaceDir, checkpoints);
116
122
  }
117
123
  }
124
+
125
+ /**
126
+ * Roll back workspace (filesystem) migrations in reverse order, stopping before
127
+ * the target migration.
128
+ *
129
+ * Migrations after `targetMigrationId` in the registry array are reversed in
130
+ * reverse order; the target migration itself is kept applied.
131
+ *
132
+ * **Usage**: Pass the full migrations array (typically `WORKSPACE_MIGRATIONS`
133
+ * from `registry.ts`) and the ID of the migration you want to roll back *to*.
134
+ * For example, `rollbackWorkspaceMigrations(dir, migrations, "010-app-dir-rename")`
135
+ * rolls back all applied migrations that appear after `010-app-dir-rename` in
136
+ * the registry.
137
+ *
138
+ * **Checkpoint state**: Each rolled-back migration's entry is deleted from the
139
+ * `.workspace-migrations.json` checkpoint file. If the process crashes
140
+ * mid-rollback, the `"rolling_back"` marker is detected and cleared by
141
+ * `runWorkspaceMigrations` on the next startup (it re-runs interrupted
142
+ * migrations).
143
+ *
144
+ * **Warning — data loss**: Every workspace migration must define a `down()`
145
+ * method (enforced at the type level), but some rollbacks are lossy (e.g.,
146
+ * file deletions or format conversions that discard the original cannot fully
147
+ * restore prior state). Review each migration's `down()` implementation
148
+ * before calling this function.
149
+ *
150
+ * **Important**: Stop the assistant before running rollbacks. Rolling back
151
+ * workspace migrations while the assistant is running may cause file conflicts,
152
+ * stale caches, or data corruption.
153
+ *
154
+ * @param workspaceDir The workspace directory path (e.g., `~/.vellum/workspace`).
155
+ * @param migrations The full ordered array of workspace migrations (from `WORKSPACE_MIGRATIONS`).
156
+ * @param targetMigrationId The migration ID to roll back to (exclusive — all
157
+ * migrations after this one are reversed).
158
+ */
159
+ export async function rollbackWorkspaceMigrations(
160
+ workspaceDir: string,
161
+ migrations: WorkspaceMigration[],
162
+ targetMigrationId: string,
163
+ ): Promise<void> {
164
+ // Find the index of the target migration
165
+ const targetIndex = migrations.findIndex((m) => m.id === targetMigrationId);
166
+ if (targetIndex === -1) {
167
+ throw new Error(
168
+ `Target migration "${targetMigrationId}" not found in the migrations array`,
169
+ );
170
+ }
171
+
172
+ // Collect migrations that come after the target, in reverse order
173
+ const migrationsToRollback = migrations.slice(targetIndex + 1).reverse();
174
+ if (migrationsToRollback.length === 0) {
175
+ log.info("No migrations to roll back");
176
+ return;
177
+ }
178
+
179
+ const checkpoints = loadCheckpoints(workspaceDir);
180
+
181
+ for (const migration of migrationsToRollback) {
182
+ // Only roll back migrations that have been fully applied.
183
+ // Legacy checkpoints may not have a status field (just appliedAt) — treat
184
+ // missing/undefined status as completed, matching runWorkspaceMigrations behavior.
185
+ const entry = checkpoints.applied[migration.id];
186
+ if (
187
+ !entry ||
188
+ entry.status === "started" ||
189
+ entry.status === "rolling_back"
190
+ ) {
191
+ continue;
192
+ }
193
+
194
+ log.info(
195
+ `Rolling back workspace migration: ${migration.id} — ${migration.description}`,
196
+ );
197
+
198
+ // Mark as rolling_back before execution (for crash recovery)
199
+ checkpoints.applied[migration.id] = {
200
+ appliedAt: checkpoints.applied[migration.id]!.appliedAt,
201
+ status: "rolling_back",
202
+ };
203
+ saveCheckpoints(workspaceDir, checkpoints);
204
+
205
+ try {
206
+ await migration.down(workspaceDir);
207
+ } catch (error) {
208
+ log.error(
209
+ { migrationId: migration.id, error },
210
+ `Workspace migration rollback failed: ${migration.id}`,
211
+ );
212
+ throw error;
213
+ }
214
+
215
+ // Remove the migration entry from checkpoints
216
+ delete checkpoints.applied[migration.id];
217
+ saveCheckpoints(workspaceDir, checkpoints);
218
+
219
+ log.info(`Rolled back workspace migration: ${migration.id}`);
220
+ }
221
+ }
@@ -8,4 +8,8 @@ export interface WorkspaceMigration {
8
8
  * Must be idempotent — safe to re-run if it was interrupted.
9
9
  * Both synchronous and asynchronous migrations are supported. */
10
10
  run(workspaceDir: string): void | Promise<void>;
11
+ /** Reverse the migration. Receives the workspace directory path.
12
+ * Must be idempotent — safe to re-run if it was interrupted.
13
+ * Both synchronous and asynchronous rollbacks are supported. */
14
+ down(workspaceDir: string): void | Promise<void>;
11
15
  }
@@ -1,206 +0,0 @@
1
- import * as fs from "node:fs";
2
- import * as path from "node:path";
3
- import { describe, expect, mock, spyOn, test } from "bun:test";
4
-
5
- // ---------------------------------------------------------------------------
6
- // Mocks — must be set up before importing modules that use them
7
- // ---------------------------------------------------------------------------
8
-
9
- mock.module("@anthropic-ai/claude-agent-sdk", () => ({
10
- query: () => ({
11
- async *[Symbol.asyncIterator]() {
12
- yield {
13
- type: "result" as const,
14
- session_id: "s",
15
- subtype: "success" as const,
16
- result: "ok",
17
- };
18
- },
19
- }),
20
- }));
21
-
22
- mock.module("../util/logger.js", () => ({
23
- getLogger: () =>
24
- new Proxy({} as Record<string, unknown>, {
25
- get: () => () => {},
26
- }),
27
- }));
28
-
29
- mock.module("../config/loader.js", () => ({
30
- getConfig: () => ({
31
- ui: {},
32
- services: {
33
- inference: {
34
- mode: "your-own",
35
- provider: "anthropic",
36
- model: "claude-opus-4-6",
37
- },
38
- "image-generation": {
39
- mode: "your-own",
40
- provider: "gemini",
41
- model: "gemini-3.1-flash-image-preview",
42
- },
43
- "web-search": { mode: "your-own", provider: "inference-provider-native" },
44
- },
45
- }),
46
- loadConfig: () => ({}),
47
- loadRawConfig: () => ({}),
48
- saveConfig: () => {},
49
- saveRawConfig: () => {},
50
- invalidateConfigCache: () => {},
51
- getNestedValue: () => undefined,
52
- setNestedValue: () => {},
53
- syncConfigToLockfile: () => {},
54
- }));
55
-
56
- mock.module("../security/secure-keys.js", () => ({
57
- getSecureKeyAsync: async (name: string) =>
58
- name === "anthropic" ? "fake-anthropic-key" : null,
59
- getProviderKeyAsync: async (provider: string) =>
60
- provider === "anthropic" ? "fake-anthropic-key" : undefined,
61
- }));
62
-
63
- // ---------------------------------------------------------------------------
64
- // Imports (after mocks)
65
- // ---------------------------------------------------------------------------
66
-
67
- import { claudeCodeTool } from "../tools/claude-code/claude-code.js";
68
-
69
- // ---------------------------------------------------------------------------
70
- // Locate the bundled skill directory relative to the test file
71
- // ---------------------------------------------------------------------------
72
-
73
- const SKILL_DIR = path.resolve(
74
- import.meta.dirname ?? __dirname,
75
- "../config/bundled-skills/claude-code",
76
- );
77
-
78
- const SHARED_DIR = path.resolve(
79
- import.meta.dirname ?? __dirname,
80
- "../config/bundled-skills/_shared",
81
- );
82
-
83
- // ---------------------------------------------------------------------------
84
- // Tests
85
- // ---------------------------------------------------------------------------
86
-
87
- describe("Claude Code skill migration regression", () => {
88
- test("skill script wrapper exports a `run` function", async () => {
89
- const wrapperPath = path.join(SKILL_DIR, "tools/claude-code.ts");
90
- // The wrapper module must exist and export `run`
91
- const mod = await import(wrapperPath);
92
- expect(typeof mod.run).toBe("function");
93
- });
94
-
95
- test("TOOLS.json manifest lists claude_code as the tool name", () => {
96
- const manifestPath = path.join(SKILL_DIR, "TOOLS.json");
97
- const raw = fs.readFileSync(manifestPath, "utf-8");
98
- const manifest = JSON.parse(raw);
99
-
100
- expect(manifest.version).toBe(1);
101
- expect(Array.isArray(manifest.tools)).toBe(true);
102
-
103
- const toolNames = manifest.tools.map((t: { name: string }) => t.name);
104
- expect(toolNames).toContain("claude_code");
105
- });
106
-
107
- test("TOOLS.json input_schema matches claudeCodeTool.getDefinition()", () => {
108
- const manifestPath = path.join(SKILL_DIR, "TOOLS.json");
109
- const raw = fs.readFileSync(manifestPath, "utf-8");
110
- const manifest = JSON.parse(raw);
111
-
112
- const manifestTool = manifest.tools.find(
113
- (t: { name: string }) => t.name === "claude_code",
114
- );
115
- expect(manifestTool).toBeDefined();
116
-
117
- const runtimeDef = claudeCodeTool.getDefinition();
118
-
119
- // The input_schema declared in the static manifest must match the
120
- // runtime definition. Drift here would mean the model sees a different
121
- // schema than the executor actually supports.
122
- expect(manifestTool.input_schema).toEqual(runtimeDef.input_schema);
123
- });
124
-
125
- test("TOOLS.json description matches claudeCodeTool.getDefinition()", () => {
126
- const manifestPath = path.join(SKILL_DIR, "TOOLS.json");
127
- const raw = fs.readFileSync(manifestPath, "utf-8");
128
- const manifest = JSON.parse(raw);
129
-
130
- const manifestTool = manifest.tools.find(
131
- (t: { name: string }) => t.name === "claude_code",
132
- );
133
- expect(manifestTool).toBeDefined();
134
-
135
- const runtimeDef = claudeCodeTool.getDefinition();
136
-
137
- // Description parity guards against a manifest edit that diverges from
138
- // the canonical tool description.
139
- expect(manifestTool.description).toBe(runtimeDef.description);
140
- });
141
-
142
- test("wrapper run() delegates to claudeCodeTool.execute()", async () => {
143
- // Verifies the wrapper is not a stale stub but actually calls through
144
- // to the canonical execute method with the exact input and context.
145
- const spy = spyOn(claudeCodeTool, "execute");
146
-
147
- const wrapperPath = path.join(SKILL_DIR, "tools/claude-code.ts");
148
- const mod = await import(wrapperPath);
149
-
150
- const input = { prompt: "hello" };
151
- const ctx = {
152
- conversationId: "test",
153
- workingDir: "/tmp",
154
- onOutput: () => {},
155
- };
156
-
157
- const result = await mod.run(input, ctx);
158
- expect(result.isError).toBeFalsy();
159
-
160
- // The wrapper must delegate to the canonical execute method
161
- expect(spy).toHaveBeenCalledTimes(1);
162
- expect(spy).toHaveBeenCalledWith(input, ctx);
163
-
164
- spy.mockRestore();
165
- });
166
- });
167
-
168
- // ---------------------------------------------------------------------------
169
- // Bundled skill shared guidance — CES tools instead of token-reveal
170
- // ---------------------------------------------------------------------------
171
-
172
- describe("CLI_RETRIEVAL_PATTERN.md CES guidance", () => {
173
- const patternPath = path.join(SHARED_DIR, "CLI_RETRIEVAL_PATTERN.md");
174
- const content = fs.readFileSync(patternPath, "utf-8");
175
-
176
- test("teaches handle discovery via assistant credentials list", () => {
177
- expect(content).toContain("assistant credentials list");
178
- });
179
-
180
- test("teaches handle discovery via assistant oauth connections list", () => {
181
- expect(content).toContain("assistant oauth connections list");
182
- });
183
-
184
- test("teaches make_authenticated_request CES tool", () => {
185
- expect(content).toContain("make_authenticated_request");
186
- });
187
-
188
- test("teaches run_authenticated_command CES tool", () => {
189
- expect(content).toContain("run_authenticated_command");
190
- });
191
-
192
- test("warns that host_bash is outside CES secrecy boundary", () => {
193
- expect(content).toContain("outside the CES secrecy boundary");
194
- });
195
-
196
- // -- Deprecated patterns must NOT appear --
197
-
198
- test("does not teach proxied bash with credential_ids", () => {
199
- expect(content).not.toContain("credential_ids");
200
- expect(content).not.toContain("network_mode: proxied");
201
- });
202
-
203
- test("does not teach oauth connections token for raw token extraction", () => {
204
- expect(content).not.toContain("oauth connections token");
205
- });
206
- });