@vellumai/assistant 0.5.9 → 0.5.11

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 (278) hide show
  1. package/AGENTS.md +9 -1
  2. package/ARCHITECTURE.md +48 -48
  3. package/Dockerfile +2 -0
  4. package/README.md +1 -1
  5. package/docs/architecture/integrations.md +6 -13
  6. package/docs/architecture/memory.md +7 -12
  7. package/docs/architecture/security.md +5 -5
  8. package/docs/credential-execution-service.md +9 -9
  9. package/docs/skills.md +1 -1
  10. package/node_modules/@vellumai/credential-storage/src/index.ts +2 -2
  11. package/node_modules/@vellumai/credential-storage/src/static-credentials.ts +1 -1
  12. package/openapi.yaml +7130 -0
  13. package/package.json +2 -1
  14. package/scripts/generate-openapi.ts +562 -0
  15. package/src/__tests__/acp-session.test.ts +239 -44
  16. package/src/__tests__/assistant-feature-flag-guard.test.ts +8 -8
  17. package/src/__tests__/assistant-feature-flag-guardrails.test.ts +5 -86
  18. package/src/__tests__/assistant-feature-flags-integration.test.ts +7 -14
  19. package/src/__tests__/browser-skill-endstate.test.ts +1 -1
  20. package/src/__tests__/btw-routes.test.ts +8 -0
  21. package/src/__tests__/bundled-skill-retrieval-guard.test.ts +10 -10
  22. package/src/__tests__/channel-approvals.test.ts +7 -7
  23. package/src/__tests__/channel-readiness-service.test.ts +41 -0
  24. package/src/__tests__/config-schema.test.ts +10 -2
  25. package/src/__tests__/context-memory-e2e.test.ts +2 -6
  26. package/src/__tests__/conversation-skill-tools.test.ts +1 -3
  27. package/src/__tests__/conversation-title-service.test.ts +2 -15
  28. package/src/__tests__/credential-execution-feature-gates.test.ts +4 -8
  29. package/src/__tests__/credential-execution-managed-contract.test.ts +8 -8
  30. package/src/__tests__/credential-security-e2e.test.ts +4 -4
  31. package/src/__tests__/credential-security-invariants.test.ts +3 -3
  32. package/src/__tests__/credentials-cli.test.ts +3 -3
  33. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +1 -1
  34. package/src/__tests__/gateway-only-guard.test.ts +3 -0
  35. package/src/__tests__/heartbeat-service.test.ts +35 -0
  36. package/src/__tests__/host-shell-tool.test.ts +1 -1
  37. package/src/__tests__/inline-skill-load-permissions.test.ts +3 -3
  38. package/src/__tests__/llm-request-log-turn-query.test.ts +64 -0
  39. package/src/__tests__/log-export-workspace.test.ts +1 -1
  40. package/src/__tests__/mcp-client-auth.test.ts +1 -1
  41. package/src/__tests__/memory-lifecycle-e2e.test.ts +2 -2
  42. package/src/__tests__/memory-recall-log-store.test.ts +182 -0
  43. package/src/__tests__/memory-recall-quality.test.ts +6 -8
  44. package/src/__tests__/memory-regressions.test.ts +53 -42
  45. package/src/__tests__/memory-retrieval.benchmark.test.ts +5 -9
  46. package/src/__tests__/messaging-skill-split.test.ts +2 -17
  47. package/src/__tests__/oauth-cli.test.ts +98 -551
  48. package/src/__tests__/platform-callback-registration.test.ts +119 -0
  49. package/src/__tests__/secret-ingress-channel.test.ts +261 -0
  50. package/src/__tests__/secret-ingress-cli.test.ts +201 -0
  51. package/src/__tests__/secret-ingress-http.test.ts +312 -0
  52. package/src/__tests__/secret-ingress.test.ts +283 -0
  53. package/src/__tests__/secret-onetime-send.test.ts +4 -4
  54. package/src/__tests__/skill-feature-flags-integration.test.ts +4 -4
  55. package/src/__tests__/skill-feature-flags.test.ts +11 -19
  56. package/src/__tests__/skill-load-feature-flag.test.ts +1 -1
  57. package/src/__tests__/skill-load-inline-command.test.ts +3 -3
  58. package/src/__tests__/skill-load-inline-includes.test.ts +2 -2
  59. package/src/__tests__/skill-memory.test.ts +2 -4
  60. package/src/__tests__/skill-projection-feature-flag.test.ts +2 -4
  61. package/src/__tests__/skill-projection.benchmark.test.ts +1 -3
  62. package/src/__tests__/skills.test.ts +16 -2
  63. package/src/__tests__/slack-channel-config.test.ts +1 -1
  64. package/src/__tests__/slack-skill.test.ts +5 -69
  65. package/src/__tests__/vellum-self-knowledge-inline-command.test.ts +1 -1
  66. package/src/__tests__/workspace-migration-015-migrate-credentials-to-keychain.test.ts +5 -238
  67. package/src/__tests__/workspace-migration-016-migrate-credentials-from-keychain.test.ts +5 -206
  68. package/src/__tests__/workspace-migration-018-rekey-compound-credential-keys.test.ts +181 -0
  69. package/src/__tests__/workspace-migrations-runner.test.ts +15 -7
  70. package/src/acp/client-handler.ts +113 -31
  71. package/src/acp/session-manager.ts +29 -27
  72. package/src/approvals/guardian-request-resolvers.ts +1 -1
  73. package/src/cli/AGENTS.md +73 -0
  74. package/src/cli/commands/autonomy.ts +3 -5
  75. package/src/cli/commands/credential-execution.ts +1 -2
  76. package/src/cli/commands/credentials.ts +4 -4
  77. package/src/cli/commands/memory.ts +2 -3
  78. package/src/cli/commands/oauth/__tests__/connect.test.ts +785 -0
  79. package/src/cli/commands/oauth/__tests__/disconnect.test.ts +760 -0
  80. package/src/cli/commands/oauth/__tests__/mode.test.ts +672 -0
  81. package/src/cli/commands/oauth/__tests__/ping.test.ts +690 -0
  82. package/src/cli/commands/oauth/__tests__/status.test.ts +579 -0
  83. package/src/cli/commands/oauth/__tests__/token.test.ts +467 -0
  84. package/src/cli/commands/oauth/apps.ts +29 -11
  85. package/src/cli/commands/oauth/connect.ts +373 -0
  86. package/src/cli/commands/oauth/connections.ts +14 -493
  87. package/src/cli/commands/oauth/disconnect.ts +333 -0
  88. package/src/cli/commands/oauth/index.ts +62 -10
  89. package/src/cli/commands/oauth/mode.ts +263 -0
  90. package/src/cli/commands/oauth/ping.ts +222 -0
  91. package/src/cli/commands/oauth/providers.ts +30 -3
  92. package/src/cli/commands/oauth/request.ts +576 -0
  93. package/src/cli/commands/oauth/shared.ts +132 -0
  94. package/src/cli/commands/oauth/status.ts +202 -0
  95. package/src/cli/commands/oauth/token.ts +159 -0
  96. package/src/cli/commands/platform.ts +20 -14
  97. package/src/cli.ts +82 -17
  98. package/src/config/assistant-feature-flags.ts +74 -11
  99. package/src/config/bundled-skills/_shared/CLI_RETRIEVAL_PATTERN.md +1 -1
  100. package/src/config/bundled-skills/app-builder/tools/app-create.ts +1 -1
  101. package/src/config/bundled-skills/messaging/SKILL.md +13 -36
  102. package/src/config/bundled-skills/messaging/TOOLS.json +9 -9
  103. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +1 -1
  104. package/src/config/bundled-skills/notifications/SKILL.md +1 -1
  105. package/src/config/bundled-skills/schedule/SKILL.md +2 -2
  106. package/src/config/bundled-skills/settings/SKILL.md +5 -3
  107. package/src/config/bundled-skills/settings/TOOLS.json +17 -0
  108. package/src/config/bundled-skills/settings/tools/avatar-get.ts +50 -0
  109. package/src/config/bundled-skills/settings/tools/avatar-remove.ts +7 -0
  110. package/src/config/bundled-skills/settings/tools/avatar-update.ts +6 -1
  111. package/src/config/bundled-skills/settings/tools/identity-avatar.ts +55 -0
  112. package/src/config/bundled-skills/skills-catalog/SKILL.md +3 -3
  113. package/src/config/bundled-skills/slack/SKILL.md +58 -44
  114. package/src/config/bundled-tool-registry.ts +2 -19
  115. package/src/config/env.ts +5 -1
  116. package/src/config/feature-flag-registry.json +57 -41
  117. package/src/config/loader.ts +4 -0
  118. package/src/config/schemas/platform.ts +0 -8
  119. package/src/config/schemas/security.ts +9 -1
  120. package/src/config/schemas/services.ts +1 -1
  121. package/src/config/skill-state.ts +1 -3
  122. package/src/config/skills.ts +2 -4
  123. package/src/credential-execution/feature-gates.ts +9 -16
  124. package/src/credential-execution/process-manager.ts +12 -0
  125. package/src/daemon/config-watcher.ts +4 -0
  126. package/src/daemon/conversation-agent-loop-handlers.ts +10 -0
  127. package/src/daemon/conversation-agent-loop.ts +49 -2
  128. package/src/daemon/conversation-memory.ts +0 -1
  129. package/src/daemon/handlers/config-slack-channel.ts +43 -1
  130. package/src/daemon/handlers/conversations.ts +41 -33
  131. package/src/daemon/lifecycle.ts +28 -5
  132. package/src/daemon/message-types/acp.ts +0 -15
  133. package/src/daemon/message-types/memory.ts +0 -1
  134. package/src/daemon/message-types/messages.ts +9 -1
  135. package/src/daemon/message-types/schedules.ts +9 -0
  136. package/src/daemon/server.ts +19 -7
  137. package/src/email/feature-gate.ts +3 -3
  138. package/src/heartbeat/heartbeat-service.ts +48 -0
  139. package/src/inbound/platform-callback-registration.ts +61 -7
  140. package/src/mcp/mcp-oauth-provider.ts +3 -3
  141. package/src/memory/app-store.ts +3 -3
  142. package/src/memory/conversation-crud.ts +124 -0
  143. package/src/memory/conversation-title-service.ts +7 -17
  144. package/src/memory/db-init.ts +8 -0
  145. package/src/memory/embedding-local.ts +47 -2
  146. package/src/memory/indexer.ts +13 -10
  147. package/src/memory/items-extractor.ts +12 -4
  148. package/src/memory/job-utils.ts +5 -0
  149. package/src/memory/jobs-store.ts +10 -2
  150. package/src/memory/journal-memory.ts +6 -2
  151. package/src/memory/llm-request-log-store.ts +88 -21
  152. package/src/memory/memory-recall-log-store.ts +128 -0
  153. package/src/memory/migrations/194-memory-recall-logs.ts +50 -0
  154. package/src/memory/migrations/195-oauth-providers-ping-config.ts +23 -0
  155. package/src/memory/migrations/index.ts +2 -0
  156. package/src/memory/migrations/validate-migration-state.ts +14 -1
  157. package/src/memory/retriever.test.ts +4 -5
  158. package/src/memory/schema/infrastructure.ts +31 -0
  159. package/src/memory/schema/oauth.ts +3 -0
  160. package/src/messaging/providers/telegram-bot/adapter.ts +1 -1
  161. package/src/oauth/connect-orchestrator.ts +54 -0
  162. package/src/oauth/manual-token-connection.ts +5 -5
  163. package/src/oauth/oauth-store.ts +26 -5
  164. package/src/oauth/seed-providers.ts +10 -1
  165. package/src/permissions/checker.ts +2 -2
  166. package/src/permissions/trust-client.ts +2 -2
  167. package/src/platform/client.ts +2 -2
  168. package/src/prompts/journal-context.ts +6 -1
  169. package/src/providers/anthropic/client.ts +143 -1
  170. package/src/runtime/auth/__tests__/middleware.test.ts +19 -0
  171. package/src/runtime/auth/route-policy.ts +0 -1
  172. package/src/runtime/btw-sidechain.ts +7 -1
  173. package/src/runtime/channel-approvals.ts +2 -2
  174. package/src/runtime/channel-readiness-service.ts +30 -7
  175. package/src/runtime/http-router.ts +31 -0
  176. package/src/runtime/http-server.ts +21 -4
  177. package/src/runtime/http-types.ts +2 -0
  178. package/src/runtime/pending-interactions.ts +21 -3
  179. package/src/runtime/routes/acp-routes.ts +46 -28
  180. package/src/runtime/routes/app-management-routes.ts +123 -0
  181. package/src/runtime/routes/app-routes.ts +31 -0
  182. package/src/runtime/routes/approval-routes.ts +108 -3
  183. package/src/runtime/routes/attachment-routes.ts +45 -0
  184. package/src/runtime/routes/avatar-routes.ts +16 -0
  185. package/src/runtime/routes/brain-graph-routes.ts +18 -0
  186. package/src/runtime/routes/btw-routes.ts +20 -0
  187. package/src/runtime/routes/call-routes.ts +81 -0
  188. package/src/runtime/routes/channel-readiness-routes.ts +48 -7
  189. package/src/runtime/routes/channel-routes.ts +18 -0
  190. package/src/runtime/routes/channel-verification-routes.ts +49 -1
  191. package/src/runtime/routes/contact-routes.ts +77 -0
  192. package/src/runtime/routes/conversation-attention-routes.ts +37 -0
  193. package/src/runtime/routes/conversation-management-routes.ts +94 -0
  194. package/src/runtime/routes/conversation-query-routes.ts +78 -0
  195. package/src/runtime/routes/conversation-routes.ts +115 -38
  196. package/src/runtime/routes/conversation-starter-routes.ts +29 -0
  197. package/src/runtime/routes/debug-routes.ts +23 -0
  198. package/src/runtime/routes/diagnostics-routes.ts +30 -0
  199. package/src/runtime/routes/documents-routes.ts +42 -0
  200. package/src/runtime/routes/events-routes.ts +10 -0
  201. package/src/runtime/routes/global-search-routes.ts +35 -0
  202. package/src/runtime/routes/guardian-action-routes.ts +47 -2
  203. package/src/runtime/routes/guardian-approval-prompt.ts +77 -2
  204. package/src/runtime/routes/heartbeat-routes.ts +278 -0
  205. package/src/runtime/routes/host-bash-routes.ts +16 -1
  206. package/src/runtime/routes/host-cu-routes.ts +23 -1
  207. package/src/runtime/routes/host-file-routes.ts +18 -1
  208. package/src/runtime/routes/identity-routes.ts +35 -0
  209. package/src/runtime/routes/inbound-message-handler.ts +46 -25
  210. package/src/runtime/routes/inbound-stages/secret-ingress-check.ts +30 -2
  211. package/src/runtime/routes/inbound-stages/transcribe-audio.ts +1 -2
  212. package/src/runtime/routes/integrations/twilio.ts +32 -22
  213. package/src/runtime/routes/invite-routes.ts +83 -0
  214. package/src/runtime/routes/log-export-routes.ts +14 -0
  215. package/src/runtime/routes/memory-item-routes.ts +99 -1
  216. package/src/runtime/routes/migration-rollback-routes.ts +25 -0
  217. package/src/runtime/routes/migration-routes.ts +40 -0
  218. package/src/runtime/routes/notification-routes.ts +20 -0
  219. package/src/runtime/routes/oauth-apps.ts +11 -3
  220. package/src/runtime/routes/pairing-routes.ts +15 -0
  221. package/src/runtime/routes/recording-routes.ts +72 -0
  222. package/src/runtime/routes/schedule-routes.ts +77 -5
  223. package/src/runtime/routes/secret-routes.ts +63 -1
  224. package/src/runtime/routes/settings-routes.ts +91 -1
  225. package/src/runtime/routes/skills-routes.ts +98 -16
  226. package/src/runtime/routes/subagents-routes.ts +38 -3
  227. package/src/runtime/routes/surface-action-routes.ts +66 -24
  228. package/src/runtime/routes/surface-content-routes.ts +20 -0
  229. package/src/runtime/routes/telemetry-routes.ts +12 -0
  230. package/src/runtime/routes/trace-event-routes.ts +25 -0
  231. package/src/runtime/routes/trust-rules-routes.ts +46 -0
  232. package/src/runtime/routes/tts-routes.ts +15 -4
  233. package/src/runtime/routes/upgrade-broadcast-routes.ts +38 -0
  234. package/src/runtime/routes/usage-routes.ts +59 -0
  235. package/src/runtime/routes/watch-routes.ts +28 -0
  236. package/src/runtime/routes/work-items-routes.ts +59 -0
  237. package/src/runtime/routes/workspace-commit-routes.ts +12 -0
  238. package/src/runtime/routes/workspace-routes.ts +102 -0
  239. package/src/schedule/scheduler.ts +7 -1
  240. package/src/security/AGENTS.md +7 -0
  241. package/src/security/credential-backend.ts +1 -1
  242. package/src/security/encrypted-store.ts +3 -3
  243. package/src/security/oauth2.ts +55 -0
  244. package/src/security/secret-ingress.ts +174 -0
  245. package/src/security/secret-patterns.ts +133 -0
  246. package/src/security/secret-scanner.ts +28 -117
  247. package/src/signals/confirm.ts +12 -8
  248. package/src/signals/user-message.ts +18 -3
  249. package/src/skills/skill-memory.ts +1 -2
  250. package/src/tasks/task-runner.ts +7 -1
  251. package/src/tools/credentials/broker.ts +1 -1
  252. package/src/tools/credentials/metadata-store.ts +1 -1
  253. package/src/tools/credentials/vault.ts +2 -3
  254. package/src/tools/memory/definitions.ts +1 -1
  255. package/src/tools/memory/handlers.test.ts +2 -4
  256. package/src/tools/skills/load.ts +1 -1
  257. package/src/tools/terminal/safe-env.ts +7 -0
  258. package/src/tools/tool-manifest.ts +1 -1
  259. package/src/util/log-redact.ts +9 -34
  260. package/src/workspace/migrations/015-migrate-credentials-to-keychain.ts +13 -148
  261. package/src/workspace/migrations/016-migrate-credentials-from-keychain.ts +7 -145
  262. package/src/workspace/migrations/AGENTS.md +11 -0
  263. package/src/workspace/migrations/runner.ts +16 -6
  264. package/src/workspace/migrations/types.ts +7 -0
  265. package/docs/architecture/keychain-broker.md +0 -69
  266. package/src/__tests__/keychain-broker-client.test.ts +0 -800
  267. package/src/cli/commands/oauth/platform.ts +0 -525
  268. package/src/config/bundled-skills/slack/TOOLS.json +0 -272
  269. package/src/config/bundled-skills/slack/tools/shared.ts +0 -34
  270. package/src/config/bundled-skills/slack/tools/slack-add-reaction.ts +0 -27
  271. package/src/config/bundled-skills/slack/tools/slack-channel-details.ts +0 -38
  272. package/src/config/bundled-skills/slack/tools/slack-channel-permissions.ts +0 -146
  273. package/src/config/bundled-skills/slack/tools/slack-configure-channels.ts +0 -105
  274. package/src/config/bundled-skills/slack/tools/slack-delete-message.ts +0 -26
  275. package/src/config/bundled-skills/slack/tools/slack-edit-message.ts +0 -27
  276. package/src/config/bundled-skills/slack/tools/slack-leave-channel.ts +0 -25
  277. package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +0 -372
  278. package/src/security/keychain-broker-client.ts +0 -446
@@ -1,220 +1,19 @@
1
- import { beforeEach, describe, expect, mock, test } from "bun:test";
1
+ import { describe, expect, test } from "bun:test";
2
2
 
3
- // ---------------------------------------------------------------------------
4
- // Mock state
5
- // ---------------------------------------------------------------------------
6
-
7
- const isAvailableFn = mock((): boolean => true);
8
- const brokerGetFn = mock(
9
- async (
10
- _account: string,
11
- ): Promise<{ found: boolean; value?: string } | null> => ({
12
- found: true,
13
- value: "secret",
14
- }),
15
- );
16
- const brokerDelFn = mock(async (_account: string): Promise<boolean> => true);
17
- const brokerListFn = mock(async (): Promise<string[]> => []);
18
- const createBrokerClientFn = mock(() => ({
19
- isAvailable: isAvailableFn,
20
- get: brokerGetFn,
21
- del: brokerDelFn,
22
- list: brokerListFn,
23
- }));
24
-
25
- const setKeyFn = mock(
26
- (_account: string, _value: string): boolean => true,
27
- );
28
-
29
- // ---------------------------------------------------------------------------
30
- // Mock modules — before importing module under test
31
- //
32
- // The logger is mocked with a silent Proxy to suppress pino output in tests.
33
- // The broker client and encrypted store are mocked to control migration
34
- // behavior without touching real keychain or filesystem state.
35
- // ---------------------------------------------------------------------------
36
-
37
- mock.module("../util/logger.js", () => ({
38
- getLogger: () =>
39
- new Proxy({} as Record<string, unknown>, {
40
- get: () => () => {},
41
- }),
42
- }));
43
-
44
- mock.module("../security/keychain-broker-client.js", () => ({
45
- createBrokerClient: createBrokerClientFn,
46
- }));
47
-
48
- mock.module("../security/encrypted-store.js", () => ({
49
- setKey: setKeyFn,
50
- }));
51
-
52
- // Import after mocking
53
3
  import { migrateCredentialsFromKeychainMigration } from "../workspace/migrations/016-migrate-credentials-from-keychain.js";
54
4
 
55
- // ---------------------------------------------------------------------------
56
- // Helpers
57
- // ---------------------------------------------------------------------------
58
-
59
- const WORKSPACE_DIR = "/mock-home/.vellum/workspace";
60
-
61
- // ---------------------------------------------------------------------------
62
- // Tests
63
- // ---------------------------------------------------------------------------
64
-
65
5
  describe("016-migrate-credentials-from-keychain migration", () => {
66
- beforeEach(() => {
67
- isAvailableFn.mockClear();
68
- brokerGetFn.mockClear();
69
- brokerDelFn.mockClear();
70
- brokerListFn.mockClear();
71
- createBrokerClientFn.mockClear();
72
- setKeyFn.mockClear();
73
-
74
- // Defaults: mac production build
75
- process.env.VELLUM_DESKTOP_APP = "1";
76
- delete process.env.VELLUM_DEV;
77
-
78
- isAvailableFn.mockReturnValue(true);
79
- brokerGetFn.mockResolvedValue({ found: true, value: "secret" });
80
- brokerDelFn.mockResolvedValue(true);
81
- brokerListFn.mockResolvedValue([]);
82
- setKeyFn.mockReturnValue(true);
83
- });
84
-
85
6
  test("has correct migration id", () => {
86
7
  expect(migrateCredentialsFromKeychainMigration.id).toBe(
87
8
  "016-migrate-credentials-from-keychain",
88
9
  );
89
10
  });
90
11
 
91
- test("skips when VELLUM_DESKTOP_APP is not set", async () => {
92
- delete process.env.VELLUM_DESKTOP_APP;
93
-
94
- await migrateCredentialsFromKeychainMigration.run(WORKSPACE_DIR);
95
-
96
- expect(createBrokerClientFn).not.toHaveBeenCalled();
97
- expect(brokerListFn).not.toHaveBeenCalled();
98
- });
99
-
100
- test("skips when VELLUM_DESKTOP_APP is not '1'", async () => {
101
- process.env.VELLUM_DESKTOP_APP = "0";
102
-
103
- await migrateCredentialsFromKeychainMigration.run(WORKSPACE_DIR);
104
-
105
- expect(createBrokerClientFn).not.toHaveBeenCalled();
106
- });
107
-
108
- test("skips when VELLUM_DEV=1", async () => {
109
- process.env.VELLUM_DEV = "1";
110
-
111
- await migrateCredentialsFromKeychainMigration.run(WORKSPACE_DIR);
112
-
113
- expect(createBrokerClientFn).not.toHaveBeenCalled();
114
- expect(brokerListFn).not.toHaveBeenCalled();
12
+ test("run is a no-op", async () => {
13
+ await migrateCredentialsFromKeychainMigration.run("/fake");
115
14
  });
116
15
 
117
- test(
118
- "throws when broker is not available (skips checkpoint for retry)",
119
- async () => {
120
- isAvailableFn.mockReturnValue(false);
121
-
122
- // Throwing skips the checkpoint so the migration retries on next startup
123
- await expect(
124
- migrateCredentialsFromKeychainMigration.run(WORKSPACE_DIR),
125
- ).rejects.toThrow("Keychain broker not available after waiting");
126
-
127
- // Should not proceed to list or migrate keys
128
- expect(brokerListFn).not.toHaveBeenCalled();
129
- expect(setKeyFn).not.toHaveBeenCalled();
130
- },
131
- { timeout: 10_000 },
132
- );
133
-
134
- test("no-ops when keychain has no accounts", async () => {
135
- brokerListFn.mockResolvedValue([]);
136
-
137
- await migrateCredentialsFromKeychainMigration.run(WORKSPACE_DIR);
138
-
139
- expect(setKeyFn).not.toHaveBeenCalled();
140
- expect(brokerDelFn).not.toHaveBeenCalled();
141
- });
142
-
143
- test("copies credentials from keychain to encrypted store and deletes from keychain", async () => {
144
- brokerListFn.mockResolvedValue(["account-a", "account-b"]);
145
- brokerGetFn.mockImplementation(async (account: string) => {
146
- if (account === "account-a") return { found: true, value: "secret-a" };
147
- if (account === "account-b") return { found: true, value: "secret-b" };
148
- return null;
149
- });
150
- setKeyFn.mockReturnValue(true);
151
-
152
- await migrateCredentialsFromKeychainMigration.run(WORKSPACE_DIR);
153
-
154
- // Should have written each key to encrypted store
155
- expect(setKeyFn).toHaveBeenCalledTimes(2);
156
- expect(setKeyFn).toHaveBeenCalledWith("account-a", "secret-a");
157
- expect(setKeyFn).toHaveBeenCalledWith("account-b", "secret-b");
158
-
159
- // Should have deleted each key from keychain after successful migration
160
- expect(brokerDelFn).toHaveBeenCalledTimes(2);
161
- expect(brokerDelFn).toHaveBeenCalledWith("account-a");
162
- expect(brokerDelFn).toHaveBeenCalledWith("account-b");
163
- });
164
-
165
- test("skips key when broker.get returns null", async () => {
166
- brokerListFn.mockResolvedValue(["ghost-key", "real-key"]);
167
- brokerGetFn.mockImplementation(async (account: string) => {
168
- if (account === "ghost-key") return null;
169
- if (account === "real-key") return { found: true, value: "real-secret" };
170
- return null;
171
- });
172
-
173
- await migrateCredentialsFromKeychainMigration.run(WORKSPACE_DIR);
174
-
175
- // ghost-key should not be written or deleted
176
- expect(setKeyFn).not.toHaveBeenCalledWith(
177
- "ghost-key",
178
- expect.anything(),
179
- );
180
- expect(brokerDelFn).not.toHaveBeenCalledWith("ghost-key");
181
-
182
- // real-key should be migrated
183
- expect(setKeyFn).toHaveBeenCalledWith("real-key", "real-secret");
184
- expect(brokerDelFn).toHaveBeenCalledWith("real-key");
185
- });
186
-
187
- test("skips key when broker.get returns not found", async () => {
188
- brokerListFn.mockResolvedValue(["missing-key"]);
189
- brokerGetFn.mockResolvedValue({ found: false });
190
-
191
- await migrateCredentialsFromKeychainMigration.run(WORKSPACE_DIR);
192
-
193
- expect(setKeyFn).not.toHaveBeenCalled();
194
- expect(brokerDelFn).not.toHaveBeenCalled();
195
- });
196
-
197
- test("skips key when setKey fails and does not delete from keychain", async () => {
198
- brokerListFn.mockResolvedValue(["fail-key", "ok-key"]);
199
- brokerGetFn.mockImplementation(async (account: string) => {
200
- if (account === "fail-key")
201
- return { found: true, value: "fail-secret" };
202
- if (account === "ok-key") return { found: true, value: "ok-secret" };
203
- return null;
204
- });
205
- setKeyFn.mockImplementation((account: string) => {
206
- if (account === "fail-key") return false;
207
- return true;
208
- });
209
-
210
- await migrateCredentialsFromKeychainMigration.run(WORKSPACE_DIR);
211
-
212
- // fail-key should NOT have been deleted from keychain (setKey failed)
213
- expect(brokerDelFn).not.toHaveBeenCalledWith("fail-key");
214
-
215
- // ok-key should have been migrated and deleted
216
- expect(setKeyFn).toHaveBeenCalledWith("ok-key", "ok-secret");
217
- expect(brokerDelFn).toHaveBeenCalledWith("ok-key");
218
- expect(brokerDelFn).toHaveBeenCalledTimes(1);
16
+ test("down is a no-op", async () => {
17
+ await migrateCredentialsFromKeychainMigration.down("/fake");
219
18
  });
220
19
  });
@@ -0,0 +1,181 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Mocks — must precede migration import
5
+ // ---------------------------------------------------------------------------
6
+
7
+ // In-memory credential store. Using `let` so tests can reset between runs.
8
+ let store = new Map<string, string>();
9
+ let storeUnreachable = false;
10
+
11
+ mock.module("../security/secure-keys.js", () => ({
12
+ listSecureKeysAsync: async () => ({
13
+ accounts: [...store.keys()],
14
+ unreachable: storeUnreachable,
15
+ }),
16
+ getSecureKeyAsync: async (key: string) => store.get(key),
17
+ setSecureKeyAsync: async (key: string, value: string) => {
18
+ store.set(key, value);
19
+ return true;
20
+ },
21
+ deleteSecureKeyAsync: async (key: string) => {
22
+ store.delete(key);
23
+ },
24
+ }));
25
+
26
+ import { rekeyCompoundCredentialKeysMigration } from "../workspace/migrations/018-rekey-compound-credential-keys.js";
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Helpers
30
+ // ---------------------------------------------------------------------------
31
+
32
+ function resetStore(entries: Record<string, string> = {}): void {
33
+ store = new Map(Object.entries(entries));
34
+ storeUnreachable = false;
35
+ }
36
+
37
+ function storeEntries(): Record<string, string> {
38
+ return Object.fromEntries(store);
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Tests
43
+ // ---------------------------------------------------------------------------
44
+
45
+ describe("018-rekey-compound-credential-keys migration", () => {
46
+ test("has correct migration id", () => {
47
+ expect(rekeyCompoundCredentialKeysMigration.id).toBe(
48
+ "018-rekey-compound-credential-keys",
49
+ );
50
+ });
51
+
52
+ test("run() re-keys compound credential from indexOf to lastIndexOf format", async () => {
53
+ resetStore({
54
+ "credential/integration/google:access_token": "my-token",
55
+ });
56
+
57
+ await rekeyCompoundCredentialKeysMigration.run("/fake");
58
+
59
+ expect(storeEntries()).toEqual({
60
+ "credential/integration:google/access_token": "my-token",
61
+ });
62
+ });
63
+
64
+ test("run() leaves simple single-colon keys unchanged", async () => {
65
+ resetStore({
66
+ "credential/github/token": "gh-token",
67
+ });
68
+
69
+ await rekeyCompoundCredentialKeysMigration.run("/fake");
70
+
71
+ expect(storeEntries()).toEqual({
72
+ "credential/github/token": "gh-token",
73
+ });
74
+ });
75
+
76
+ test("run() ignores non-credential keys", async () => {
77
+ resetStore({
78
+ "other/integration/google:access_token": "my-token",
79
+ });
80
+
81
+ await rekeyCompoundCredentialKeysMigration.run("/fake");
82
+
83
+ expect(storeEntries()).toEqual({
84
+ "other/integration/google:access_token": "my-token",
85
+ });
86
+ });
87
+
88
+ test("run() is idempotent — second run is a no-op", async () => {
89
+ resetStore({
90
+ "credential/integration/google:access_token": "my-token",
91
+ });
92
+
93
+ await rekeyCompoundCredentialKeysMigration.run("/fake");
94
+ const afterFirst = storeEntries();
95
+
96
+ await rekeyCompoundCredentialKeysMigration.run("/fake");
97
+
98
+ expect(storeEntries()).toEqual(afterFirst);
99
+ });
100
+
101
+ test("run() deletes orphaned old key when new key already exists", async () => {
102
+ resetStore({
103
+ "credential/integration/google:access_token": "old-token",
104
+ "credential/integration:google/access_token": "new-token",
105
+ });
106
+
107
+ await rekeyCompoundCredentialKeysMigration.run("/fake");
108
+
109
+ // Old key removed; new key (already present) wins
110
+ expect(storeEntries()).toEqual({
111
+ "credential/integration:google/access_token": "new-token",
112
+ });
113
+ });
114
+
115
+ test("run() throws when credential store is unreachable", async () => {
116
+ resetStore();
117
+ storeUnreachable = true;
118
+
119
+ await expect(
120
+ rekeyCompoundCredentialKeysMigration.run("/fake"),
121
+ ).rejects.toThrow("Credential store unreachable");
122
+ });
123
+
124
+ test("down() reverses run() — re-keys from lastIndexOf back to indexOf format", async () => {
125
+ resetStore({
126
+ "credential/integration:google/access_token": "my-token",
127
+ });
128
+
129
+ await rekeyCompoundCredentialKeysMigration.down("/fake");
130
+
131
+ expect(storeEntries()).toEqual({
132
+ "credential/integration/google:access_token": "my-token",
133
+ });
134
+ });
135
+
136
+ test("down() leaves simple keys unchanged", async () => {
137
+ resetStore({
138
+ "credential/github/token": "gh-token",
139
+ });
140
+
141
+ await rekeyCompoundCredentialKeysMigration.down("/fake");
142
+
143
+ expect(storeEntries()).toEqual({
144
+ "credential/github/token": "gh-token",
145
+ });
146
+ });
147
+
148
+ test("down() is idempotent — second down() is a no-op", async () => {
149
+ resetStore({
150
+ "credential/integration:google/access_token": "my-token",
151
+ });
152
+
153
+ await rekeyCompoundCredentialKeysMigration.down("/fake");
154
+ const afterFirst = storeEntries();
155
+
156
+ await rekeyCompoundCredentialKeysMigration.down("/fake");
157
+
158
+ expect(storeEntries()).toEqual(afterFirst);
159
+ });
160
+
161
+ test("run() then down() restores original state", async () => {
162
+ const original = {
163
+ "credential/integration/google:access_token": "my-token",
164
+ };
165
+ resetStore(original);
166
+
167
+ await rekeyCompoundCredentialKeysMigration.run("/fake");
168
+ await rekeyCompoundCredentialKeysMigration.down("/fake");
169
+
170
+ expect(storeEntries()).toEqual(original);
171
+ });
172
+
173
+ test("down() throws when credential store is unreachable", async () => {
174
+ resetStore();
175
+ storeUnreachable = true;
176
+
177
+ await expect(
178
+ rekeyCompoundCredentialKeysMigration.down("/fake"),
179
+ ).rejects.toThrow("Credential store unreachable");
180
+ });
181
+ });
@@ -130,16 +130,16 @@ describe("runWorkspaceMigrations", () => {
130
130
  throw new Error("migration 002 failed");
131
131
  });
132
132
 
133
- await expect(
134
- runWorkspaceMigrations(WORKSPACE_DIR, [m1, m2]),
135
- ).rejects.toThrow("migration 002 failed");
133
+ // Runner no longer throws — it marks failed migrations and continues
134
+ await runWorkspaceMigrations(WORKSPACE_DIR, [m1, m2]);
136
135
 
137
- // m1 ran successfully before the error
136
+ // m1 ran successfully, m2 was attempted
138
137
  expect(m1.run).toHaveBeenCalledTimes(1);
138
+ expect(m2.run).toHaveBeenCalledTimes(1);
139
139
 
140
- // Checkpoints saved: started m1, completed m1, started m2 = 3 writes
141
- expect(writeFileSyncFn).toHaveBeenCalledTimes(3);
142
- expect(renameSyncFn).toHaveBeenCalledTimes(3);
140
+ // Checkpoints saved: started m1, completed m1, started m2, failed m2 = 4 writes
141
+ expect(writeFileSyncFn).toHaveBeenCalledTimes(4);
142
+ expect(renameSyncFn).toHaveBeenCalledTimes(4);
143
143
 
144
144
  // Verify the completed checkpoint contains m1
145
145
  // The second write is the "completed" marker for m1
@@ -149,6 +149,14 @@ describe("runWorkspaceMigrations", () => {
149
149
  const parsed = JSON.parse(completedWrite);
150
150
  expect(parsed.applied["001"]).toBeDefined();
151
151
  expect(parsed.applied["001"].status).toBe("completed");
152
+
153
+ // Verify m2 is marked as failed
154
+ const failedWrite = (
155
+ writeFileSyncFn.mock.calls[3] as unknown[]
156
+ )[1] as string;
157
+ const failedParsed = JSON.parse(failedWrite);
158
+ expect(failedParsed.applied["002"]).toBeDefined();
159
+ expect(failedParsed.applied["002"].status).toBe("failed");
152
160
  });
153
161
 
154
162
  test("idempotent on re-run", async () => {
@@ -31,6 +31,8 @@ import type {
31
31
  } from "@agentclientprotocol/sdk";
32
32
 
33
33
  import type { ServerMessage } from "../daemon/message-protocol.js";
34
+ import type { UserDecision } from "../permissions/types.js";
35
+ import * as pendingInteractions from "../runtime/pending-interactions.js";
34
36
  import { getLogger } from "../util/logger.js";
35
37
 
36
38
  const log = getLogger("acp:client-handler");
@@ -51,6 +53,8 @@ interface TerminalState {
51
53
  export class VellumAcpClientHandler implements Client {
52
54
  private terminals = new Map<string, TerminalState>();
53
55
  private accumulatedText = "";
56
+ /** Tracks pending ACP permission requestIds for cleanup on session close. */
57
+ readonly pendingRequestIds = new Set<string>();
54
58
 
55
59
  /** Returns the full agent response text accumulated from agent_message_chunk events. */
56
60
  get responseText(): string {
@@ -60,10 +64,7 @@ export class VellumAcpClientHandler implements Client {
60
64
  constructor(
61
65
  private readonly acpSessionId: string,
62
66
  private readonly sendToVellum: (msg: ServerMessage) => void,
63
- private readonly pendingPermissions: Map<
64
- string,
65
- { resolve: (optionId: string) => void }
66
- >,
67
+ private readonly parentConversationId: string,
67
68
  ) {}
68
69
 
69
70
  async sessionUpdate(params: SessionNotification): Promise<void> {
@@ -152,33 +153,87 @@ export class VellumAcpClientHandler implements Client {
152
153
  params: RequestPermissionRequest,
153
154
  ): Promise<RequestPermissionResponse> {
154
155
  const requestId = randomUUID();
156
+ const toolTitle = params.toolCall.title ?? "Unknown tool";
157
+ const toolKind = params.toolCall.kind ?? "other";
158
+ const options = params.options;
159
+
155
160
  log.info(
156
161
  {
157
162
  acpSessionId: this.acpSessionId,
158
163
  requestId,
159
- toolTitle: params.toolCall.title,
160
- toolKind: params.toolCall.kind,
161
- optionCount: params.options.length,
164
+ toolTitle,
165
+ toolKind,
166
+ optionCount: options.length,
162
167
  },
163
168
  "ACP permission requested",
164
169
  );
165
170
 
166
- const optionIdPromise = new Promise<string>((resolve) => {
167
- this.pendingPermissions.set(requestId, { resolve });
168
- });
169
-
171
+ // Normalize rawInput into a Record for the confirmation_request shape
172
+ const rawInput = params.toolCall.rawInput;
173
+ const input: Record<string, unknown> =
174
+ rawInput != null &&
175
+ typeof rawInput === "object" &&
176
+ !Array.isArray(rawInput)
177
+ ? (rawInput as Record<string, unknown>)
178
+ : { command: rawInput };
179
+
180
+ const toolName = `ACP Agent: ${toolTitle}`;
181
+ const acpOptions = options.map((opt) => ({
182
+ optionId: opt.optionId,
183
+ name: opt.name,
184
+ kind: opt.kind,
185
+ }));
186
+
187
+ // Send the confirmation_request first — this triggers makeEventSender
188
+ // which registers a normal "confirmation" entry in pendingInteractions.
170
189
  this.sendToVellum({
171
- type: "acp_permission_request",
172
- acpSessionId: this.acpSessionId,
190
+ type: "confirmation_request",
173
191
  requestId,
174
- toolTitle: params.toolCall.title ?? "Unknown tool",
175
- toolKind: params.toolCall.kind ?? "other",
176
- rawInput: params.toolCall.rawInput,
177
- options: params.options.map((opt) => ({
178
- optionId: opt.optionId,
179
- name: opt.name,
180
- kind: opt.kind,
181
- })),
192
+ toolName,
193
+ input,
194
+ riskLevel: "medium",
195
+ allowlistOptions: [],
196
+ scopeOptions: [],
197
+ persistentDecisionsAllowed: false,
198
+ acpToolKind: toolKind,
199
+ acpOptions,
200
+ conversationId: this.parentConversationId,
201
+ });
202
+
203
+ // Now overwrite with our ACP registration that has directResolve.
204
+ // This must come AFTER sendToVellum so it wins over makeEventSender's
205
+ // registration.
206
+ const optionIdPromise = new Promise<string>((resolve) => {
207
+ const timeoutMs = 5 * 60 * 1000; // 5 minutes
208
+ const timer = setTimeout(() => {
209
+ const pending = pendingInteractions.resolve(requestId);
210
+ if (pending?.directResolve) {
211
+ pending.directResolve("deny");
212
+ }
213
+ }, timeoutMs);
214
+
215
+ this.pendingRequestIds.add(requestId);
216
+ pendingInteractions.register(requestId, {
217
+ conversation: null,
218
+ conversationId: this.parentConversationId,
219
+ kind: "acp_confirmation",
220
+ confirmationDetails: {
221
+ toolName,
222
+ input,
223
+ riskLevel: "medium",
224
+ allowlistOptions: [],
225
+ scopeOptions: [],
226
+ persistentDecisionsAllowed: false,
227
+ acpToolKind: toolKind,
228
+ acpOptions,
229
+ },
230
+ directResolve: (decision: UserDecision) => {
231
+ clearTimeout(timer);
232
+ this.pendingRequestIds.delete(requestId);
233
+ const optionId = mapDecisionToOptionId(decision, options);
234
+ resolve(optionId);
235
+ },
236
+ });
182
237
  });
183
238
 
184
239
  const optionId = await optionIdPromise;
@@ -336,18 +391,45 @@ export class VellumAcpClientHandler implements Client {
336
391
  }
337
392
 
338
393
  /**
339
- * Resolves a pending permission request by its request ID.
394
+ * Maps a UserDecision to the best-matching ACP option ID.
340
395
  */
341
- export function resolvePermission(
342
- pendingPermissions: Map<string, { resolve: (optionId: string) => void }>,
343
- requestId: string,
344
- optionId: string,
345
- ): void {
346
- const pending = pendingPermissions.get(requestId);
347
- if (pending) {
348
- pending.resolve(optionId);
349
- pendingPermissions.delete(requestId);
396
+ function mapDecisionToOptionId(
397
+ decision: UserDecision,
398
+ options: Array<{ optionId: string; kind: string }>,
399
+ ): string {
400
+ const isAllow =
401
+ decision === "allow" ||
402
+ decision === "allow_10m" ||
403
+ decision === "allow_conversation" ||
404
+ decision === "always_allow" ||
405
+ decision === "always_allow_high_risk" ||
406
+ decision === "temporary_override" ||
407
+ decision === "dangerously_skip_permissions";
408
+
409
+ if (isAllow) {
410
+ // Prefer allow_always for persistent decisions, fallback to allow_once
411
+ if (decision === "always_allow" || decision === "always_allow_high_risk") {
412
+ const alwaysOpt = options.find((o) => o.kind === "allow_always");
413
+ if (alwaysOpt) return alwaysOpt.optionId;
414
+ }
415
+ const allowOpt =
416
+ options.find((o) => o.kind === "allow_once") ??
417
+ options.find((o) => o.kind === "allow_always");
418
+ if (allowOpt) return allowOpt.optionId;
350
419
  }
420
+
421
+ // Deny: prefer reject_always for persistent deny, fallback to reject_once
422
+ if (decision === "always_deny") {
423
+ const alwaysDeny = options.find((o) => o.kind === "reject_always");
424
+ if (alwaysDeny) return alwaysDeny.optionId;
425
+ }
426
+ const denyOpt =
427
+ options.find((o) => o.kind === "reject_once") ??
428
+ options.find((o) => o.kind === "reject_always");
429
+ if (denyOpt) return denyOpt.optionId;
430
+
431
+ // Fallback: return first option
432
+ return options[0]?.optionId ?? "deny";
351
433
  }
352
434
 
353
435
  /**