@vellumai/assistant 0.4.53 → 0.4.55

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 (255) hide show
  1. package/bun.lock +62 -349
  2. package/docs/architecture/integrations.md +1 -1
  3. package/docs/architecture/keychain-broker.md +94 -29
  4. package/docs/architecture/security.md +2 -2
  5. package/knip.json +7 -29
  6. package/package.json +2 -9
  7. package/src/__tests__/agent-loop.test.ts +1 -1
  8. package/src/__tests__/app-git-history.test.ts +0 -2
  9. package/src/__tests__/app-git-service.test.ts +1 -6
  10. package/src/__tests__/approval-cascade.test.ts +0 -1
  11. package/src/__tests__/avatar-e2e.test.ts +0 -1
  12. package/src/__tests__/browser-fill-credential.test.ts +1 -6
  13. package/src/__tests__/call-domain.test.ts +0 -1
  14. package/src/__tests__/call-routes-http.test.ts +0 -1
  15. package/src/__tests__/channel-guardian.test.ts +4 -4
  16. package/src/__tests__/channel-readiness-routes.test.ts +0 -1
  17. package/src/__tests__/channel-readiness-service.test.ts +0 -1
  18. package/src/__tests__/checker.test.ts +13 -11
  19. package/src/__tests__/claude-code-skill-regression.test.ts +0 -1
  20. package/src/__tests__/claude-code-tool-profiles.test.ts +1 -2
  21. package/src/__tests__/config-loader-backfill.test.ts +0 -3
  22. package/src/__tests__/config-schema.test.ts +3 -9
  23. package/src/__tests__/config-watcher.test.ts +11 -3
  24. package/src/__tests__/credential-broker-browser-fill.test.ts +27 -24
  25. package/src/__tests__/credential-broker-server-use.test.ts +60 -24
  26. package/src/__tests__/credential-security-e2e.test.ts +1 -6
  27. package/src/__tests__/credential-security-invariants.test.ts +13 -8
  28. package/src/__tests__/credential-vault-unit.test.ts +28 -12
  29. package/src/__tests__/credential-vault.test.ts +40 -28
  30. package/src/__tests__/credentials-cli.test.ts +1 -21
  31. package/src/__tests__/email-invite-adapter.test.ts +0 -1
  32. package/src/__tests__/fixtures/credential-security-fixtures.ts +3 -3
  33. package/src/__tests__/fixtures/media-reuse-fixtures.ts +3 -79
  34. package/src/__tests__/gateway-only-enforcement.test.ts +1 -21
  35. package/src/__tests__/guardian-action-conversation-turn.test.ts +8 -8
  36. package/src/__tests__/guardian-action-late-reply.test.ts +13 -14
  37. package/src/__tests__/guardian-action-store.test.ts +0 -57
  38. package/src/__tests__/guardian-outbound-http.test.ts +1 -1
  39. package/src/__tests__/guardian-verification-voice-binding.test.ts +1 -3
  40. package/src/__tests__/hooks-blocking.test.ts +1 -1
  41. package/src/__tests__/hooks-config.test.ts +5 -29
  42. package/src/__tests__/hooks-discovery.test.ts +1 -1
  43. package/src/__tests__/hooks-integration.test.ts +1 -1
  44. package/src/__tests__/hooks-manager.test.ts +1 -1
  45. package/src/__tests__/hooks-runner.test.ts +1 -23
  46. package/src/__tests__/hooks-settings.test.ts +1 -1
  47. package/src/__tests__/hooks-templates.test.ts +1 -1
  48. package/src/__tests__/integration-status.test.ts +0 -1
  49. package/src/__tests__/invite-routes-http.test.ts +0 -3
  50. package/src/__tests__/list-messages-attachments.test.ts +4 -4
  51. package/src/__tests__/llm-usage-store.test.ts +50 -0
  52. package/src/__tests__/managed-proxy-context.test.ts +41 -41
  53. package/src/__tests__/media-generate-image.test.ts +2 -2
  54. package/src/__tests__/media-reuse-story.e2e.test.ts +1 -6
  55. package/src/__tests__/memory-regressions.experimental.test.ts +4 -4
  56. package/src/__tests__/memory-regressions.test.ts +27 -27
  57. package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
  58. package/src/__tests__/memory-upsert-concurrency.test.ts +4 -4
  59. package/src/__tests__/notification-decision-fallback.test.ts +1 -1
  60. package/src/__tests__/oauth-cli.test.ts +1 -4
  61. package/src/__tests__/oauth-store.test.ts +1 -3
  62. package/src/__tests__/openai-provider.test.ts +7 -7
  63. package/src/__tests__/platform.test.ts +14 -4
  64. package/src/__tests__/pricing.test.ts +0 -223
  65. package/src/__tests__/provider-commit-message-generator.test.ts +1 -4
  66. package/src/__tests__/provider-fail-open-selection.test.ts +58 -54
  67. package/src/__tests__/provider-managed-proxy-integration.test.ts +63 -63
  68. package/src/__tests__/provider-registry-ollama.test.ts +3 -3
  69. package/src/__tests__/public-ingress-urls.test.ts +1 -1
  70. package/src/__tests__/registry.test.ts +3 -103
  71. package/src/__tests__/script-proxy-injection-runtime.test.ts +2 -7
  72. package/src/__tests__/secret-onetime-send.test.ts +1 -6
  73. package/src/__tests__/secret-routes-managed-proxy.test.ts +6 -13
  74. package/src/__tests__/secure-keys.test.ts +241 -229
  75. package/src/__tests__/session-abort-tool-results.test.ts +0 -1
  76. package/src/__tests__/session-confirmation-signals.test.ts +0 -1
  77. package/src/__tests__/session-messaging-secret-redirect.test.ts +1 -7
  78. package/src/__tests__/session-pre-run-repair.test.ts +0 -1
  79. package/src/__tests__/session-provider-retry-repair.test.ts +0 -1
  80. package/src/__tests__/session-queue.test.ts +2 -4
  81. package/src/__tests__/session-slash-known.test.ts +0 -1
  82. package/src/__tests__/session-slash-queue.test.ts +0 -1
  83. package/src/__tests__/session-slash-unknown.test.ts +0 -1
  84. package/src/__tests__/session-workspace-injection.test.ts +0 -1
  85. package/src/__tests__/session-workspace-tool-tracking.test.ts +0 -1
  86. package/src/__tests__/skill-projection-feature-flag.test.ts +0 -1
  87. package/src/__tests__/slack-channel-config.test.ts +1 -7
  88. package/src/__tests__/swarm-recursion.test.ts +0 -1
  89. package/src/__tests__/swarm-session-integration.test.ts +0 -1
  90. package/src/__tests__/swarm-tool.test.ts +0 -1
  91. package/src/__tests__/task-compiler.test.ts +1 -1
  92. package/src/__tests__/test-support/browser-skill-harness.ts +0 -18
  93. package/src/__tests__/test-support/computer-use-skill-harness.ts +0 -23
  94. package/src/__tests__/tool-executor.test.ts +1 -1
  95. package/src/__tests__/trust-store.test.ts +3 -82
  96. package/src/__tests__/twilio-config.test.ts +0 -1
  97. package/src/__tests__/twilio-provider.test.ts +0 -5
  98. package/src/__tests__/twilio-routes.test.ts +0 -1
  99. package/src/__tests__/usage-cache-backfill-migration.test.ts +10 -10
  100. package/src/calls/guardian-question-copy.ts +1 -1
  101. package/src/cli/commands/bash.ts +3 -0
  102. package/src/cli/commands/doctor.ts +10 -34
  103. package/src/cli/commands/memory.ts +3 -5
  104. package/src/cli/commands/sessions.ts +1 -1
  105. package/src/cli/commands/usage.ts +359 -0
  106. package/src/cli/http-client.ts +22 -12
  107. package/src/cli/program.ts +2 -0
  108. package/src/cli/reference.ts +1 -0
  109. package/src/cli.ts +251 -181
  110. package/src/config/assistant-feature-flags.ts +0 -7
  111. package/src/config/bundled-skills/chatgpt-import/tools/chatgpt-import.ts +1 -1
  112. package/src/config/bundled-skills/claude-code/SKILL.md +1 -1
  113. package/src/config/bundled-skills/claude-code/TOOLS.json +1 -1
  114. package/src/config/bundled-skills/gmail/SKILL.md +0 -1
  115. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +2 -2
  116. package/src/config/bundled-skills/media-processing/services/reduce.ts +1 -1
  117. package/src/config/bundled-skills/messaging/SKILL.md +0 -1
  118. package/src/config/bundled-skills/sequences/SKILL.md +0 -1
  119. package/src/config/env.ts +13 -0
  120. package/src/config/feature-flag-registry.json +9 -41
  121. package/src/config/schemas/security.ts +1 -2
  122. package/src/config/skills.ts +1 -1
  123. package/src/contacts/contact-store.ts +0 -50
  124. package/src/daemon/approved-devices-store.ts +0 -44
  125. package/src/daemon/classifier.ts +1 -1
  126. package/src/daemon/config-watcher.ts +14 -8
  127. package/src/daemon/handlers/config-model.ts +1 -1
  128. package/src/daemon/handlers/sessions.ts +4 -116
  129. package/src/daemon/handlers/skills.ts +1 -1
  130. package/src/daemon/lifecycle.ts +13 -15
  131. package/src/daemon/providers-setup.ts +1 -1
  132. package/src/daemon/server.ts +20 -3
  133. package/src/daemon/session-slash.ts +2 -2
  134. package/src/daemon/shutdown-handlers.ts +15 -0
  135. package/src/daemon/watch-handler.ts +2 -2
  136. package/src/email/guardrails.ts +1 -1
  137. package/src/email/service.ts +0 -5
  138. package/src/hooks/templates.ts +1 -1
  139. package/src/media/app-icon-generator.ts +2 -2
  140. package/src/media/avatar-router.ts +2 -2
  141. package/src/media/gemini-image-service.ts +5 -5
  142. package/src/memory/admin.ts +2 -2
  143. package/src/memory/app-git-service.ts +0 -7
  144. package/src/memory/conversation-crud.ts +1 -1
  145. package/src/memory/conversation-title-service.ts +2 -2
  146. package/src/memory/embedding-backend.ts +30 -26
  147. package/src/memory/external-conversation-store.ts +0 -30
  148. package/src/memory/guardian-action-store.ts +0 -31
  149. package/src/memory/guardian-approvals.ts +1 -56
  150. package/src/memory/indexer.ts +4 -3
  151. package/src/memory/items-extractor.ts +1 -1
  152. package/src/memory/job-handlers/backfill.ts +5 -2
  153. package/src/memory/job-handlers/index-maintenance.ts +2 -2
  154. package/src/memory/job-handlers/media-processing.ts +2 -2
  155. package/src/memory/job-handlers/summarization.ts +1 -1
  156. package/src/memory/job-utils.ts +1 -2
  157. package/src/memory/jobs-worker.ts +2 -2
  158. package/src/memory/llm-usage-store.ts +57 -11
  159. package/src/memory/media-store.ts +4 -535
  160. package/src/memory/migrations/032-guardian-delivery-conversation-index.ts +2 -2
  161. package/src/memory/migrations/110-channel-guardian.ts +0 -1
  162. package/src/memory/published-pages-store.ts +0 -83
  163. package/src/memory/qdrant-circuit-breaker.ts +0 -8
  164. package/src/memory/retriever.ts +1 -1
  165. package/src/memory/schema/calls.ts +0 -67
  166. package/src/memory/search/semantic.ts +1 -8
  167. package/src/memory/shared-app-links-store.ts +0 -15
  168. package/src/messaging/registry.ts +0 -5
  169. package/src/messaging/style-analyzer.ts +1 -1
  170. package/src/notifications/copy-composer.ts +5 -13
  171. package/src/notifications/decision-engine.ts +2 -2
  172. package/src/notifications/deliveries-store.ts +0 -39
  173. package/src/notifications/guardian-question-mode.ts +6 -10
  174. package/src/notifications/preference-extractor.ts +1 -1
  175. package/src/oauth/byo-connection.test.ts +29 -20
  176. package/src/oauth/provider-behaviors.ts +1 -1
  177. package/src/permissions/checker.ts +1 -1
  178. package/src/permissions/shell-identity.ts +0 -5
  179. package/src/permissions/trust-store.ts +0 -37
  180. package/src/prompts/system-prompt.ts +4 -4
  181. package/src/prompts/templates/SOUL.md +1 -1
  182. package/src/providers/managed-proxy/constants.ts +8 -10
  183. package/src/providers/managed-proxy/context.ts +14 -9
  184. package/src/providers/provider-send-message.ts +4 -52
  185. package/src/providers/registry.ts +16 -50
  186. package/src/runtime/actor-token-store.ts +0 -23
  187. package/src/runtime/auth/__tests__/guard-tests.test.ts +64 -0
  188. package/src/runtime/http-router.ts +5 -1
  189. package/src/runtime/http-server.ts +101 -4
  190. package/src/runtime/invite-instruction-generator.ts +25 -51
  191. package/src/runtime/invite-service.ts +0 -20
  192. package/src/runtime/routes/attachment-routes.ts +1 -1
  193. package/src/runtime/routes/brain-graph-routes.ts +1 -1
  194. package/src/runtime/routes/call-routes.ts +1 -1
  195. package/src/runtime/routes/conversation-routes.ts +32 -11
  196. package/src/runtime/routes/debug-routes.ts +1 -1
  197. package/src/runtime/routes/diagnostics-routes.ts +2 -2
  198. package/src/runtime/routes/documents-routes.ts +3 -3
  199. package/src/runtime/routes/global-search-routes.ts +1 -1
  200. package/src/runtime/routes/guardian-bootstrap-routes.ts +0 -20
  201. package/src/runtime/routes/guardian-refresh-routes.ts +0 -20
  202. package/src/runtime/routes/secret-routes.ts +4 -4
  203. package/src/runtime/routes/session-management-routes.ts +27 -0
  204. package/src/runtime/routes/trust-rules-routes.ts +1 -1
  205. package/src/security/credential-backend.ts +148 -0
  206. package/src/security/oauth2.ts +1 -1
  207. package/src/security/secret-allowlist.ts +1 -1
  208. package/src/security/secure-keys.ts +98 -160
  209. package/src/security/token-manager.ts +0 -7
  210. package/src/sequence/guardrails.ts +0 -4
  211. package/src/sequence/store.ts +1 -20
  212. package/src/sequence/types.ts +1 -36
  213. package/src/signals/bash.ts +33 -0
  214. package/src/signals/cancel.ts +69 -0
  215. package/src/signals/conversation-undo.ts +127 -0
  216. package/src/signals/trust-rule.ts +174 -0
  217. package/src/skills/clawhub.ts +5 -5
  218. package/src/skills/managed-store.ts +4 -4
  219. package/src/subagent/manager.ts +8 -1
  220. package/src/telemetry/usage-telemetry-reporter.test.ts +366 -0
  221. package/src/telemetry/usage-telemetry-reporter.ts +181 -0
  222. package/src/tools/claude-code/claude-code.ts +2 -2
  223. package/src/tools/credentials/vault.ts +8 -4
  224. package/src/tools/memory/handlers.test.ts +24 -26
  225. package/src/tools/memory/handlers.ts +1 -13
  226. package/src/tools/registry.ts +5 -100
  227. package/src/tools/terminal/parser.ts +34 -4
  228. package/src/tools/tool-manifest.ts +0 -10
  229. package/src/usage/actors.ts +0 -12
  230. package/src/util/canonicalize-identity.ts +0 -9
  231. package/src/util/errors.ts +0 -3
  232. package/src/util/platform.ts +24 -7
  233. package/src/util/pricing.ts +0 -38
  234. package/src/watcher/constants.ts +0 -7
  235. package/src/watcher/providers/linear.ts +1 -1
  236. package/src/work-items/work-item-store.ts +4 -4
  237. package/src/workspace/commit-message-provider.ts +1 -1
  238. package/src/workspace/git-service.ts +44 -1
  239. package/src/workspace/provider-commit-message-generator.ts +1 -1
  240. package/src/__tests__/fixtures/proxy-fixtures.ts +0 -147
  241. package/src/browser-extension-relay/client.ts +0 -155
  242. package/src/contacts/index.ts +0 -18
  243. package/src/daemon/tls-certs.ts +0 -270
  244. package/src/errors.ts +0 -41
  245. package/src/events/index.ts +0 -18
  246. package/src/followups/index.ts +0 -10
  247. package/src/playbooks/index.ts +0 -10
  248. package/src/runtime/auth/index.ts +0 -44
  249. package/src/tasks/candidate-store.ts +0 -95
  250. package/src/tools/browser/api-map.ts +0 -313
  251. package/src/tools/browser/auto-navigate.ts +0 -469
  252. package/src/tools/browser/headless-browser.ts +0 -590
  253. package/src/tools/browser/recording-store.ts +0 -75
  254. package/src/tools/computer-use/registry.ts +0 -21
  255. package/src/tools/tasks/index.ts +0 -27
@@ -34,6 +34,7 @@ let mockBrokerGetError = false;
34
34
  let mockBrokerSetError = false;
35
35
  let mockBrokerDelError = false;
36
36
  let mockBrokerGetCalled = false;
37
+ let mockBrokerSetCalled = false;
37
38
 
38
39
  mock.module("../security/keychain-broker-client.js", () => ({
39
40
  createBrokerClient: () => ({
@@ -48,6 +49,7 @@ mock.module("../security/keychain-broker-client.js", () => ({
48
49
  return { found: false };
49
50
  },
50
51
  set: async (account: string, value: string) => {
52
+ mockBrokerSetCalled = true;
51
53
  if (mockBrokerSetError)
52
54
  return {
53
55
  status: "rejected" as const,
@@ -67,18 +69,13 @@ mock.module("../security/keychain-broker-client.js", () => ({
67
69
  }),
68
70
  }));
69
71
 
72
+ import * as encryptedStore from "../security/encrypted-store.js";
70
73
  import { _setStorePath } from "../security/encrypted-store.js";
71
74
  import {
72
75
  _resetBackend,
73
- _setBackend,
74
- deleteSecureKey,
75
76
  deleteSecureKeyAsync,
76
- getBackendType,
77
- getSecureKey,
78
77
  getSecureKeyAsync,
79
- isDowngradedFromKeychain,
80
- listSecureKeys,
81
- setSecureKey,
78
+ listSecureKeysAsync,
82
79
  setSecureKeyAsync,
83
80
  } from "../security/secure-keys.js";
84
81
 
@@ -103,6 +100,10 @@ describe("secure-keys", () => {
103
100
  mockBrokerSetError = false;
104
101
  mockBrokerDelError = false;
105
102
  mockBrokerGetCalled = false;
103
+ mockBrokerSetCalled = false;
104
+
105
+ // Ensure VELLUM_DEV is NOT set so broker tests work by default
106
+ delete process.env.VELLUM_DEV;
106
107
 
107
108
  if (existsSync(TEST_DIR)) {
108
109
  rmSync(TEST_DIR, { recursive: true });
@@ -114,6 +115,7 @@ describe("secure-keys", () => {
114
115
  afterEach(() => {
115
116
  _setStorePath(null);
116
117
  _resetBackend();
118
+ delete process.env.VELLUM_DEV;
117
119
  });
118
120
 
119
121
  afterAll(() => {
@@ -123,337 +125,347 @@ describe("secure-keys", () => {
123
125
  });
124
126
 
125
127
  // -----------------------------------------------------------------------
126
- // Backend selection
128
+ // CRUD operations (via encrypted store backend — broker unavailable)
127
129
  // -----------------------------------------------------------------------
128
- describe("backend selection", () => {
129
- test("returns encrypted when broker is unavailable", () => {
130
- expect(getBackendType()).toBe("encrypted");
130
+ describe("CRUD with encrypted backend (broker unavailable)", () => {
131
+ test("set and get a key", async () => {
132
+ await setSecureKeyAsync("openai", "sk-openai-789");
133
+ expect(await getSecureKeyAsync("openai")).toBe("sk-openai-789");
131
134
  });
132
135
 
133
- test("returns broker when broker is available", () => {
134
- mockBrokerAvailable = true;
135
- expect(getBackendType()).toBe("broker");
136
+ test("get returns undefined for nonexistent key", async () => {
137
+ expect(await getSecureKeyAsync("nonexistent")).toBeUndefined();
136
138
  });
137
139
 
138
- test("isDowngradedFromKeychain always returns false", () => {
139
- expect(isDowngradedFromKeychain()).toBe(false);
140
+ test("delete removes a key", async () => {
141
+ await setSecureKeyAsync("gemini", "gem-key");
142
+ expect(await deleteSecureKeyAsync("gemini")).toBe("deleted");
143
+ expect(await getSecureKeyAsync("gemini")).toBeUndefined();
144
+ });
145
+
146
+ test("delete returns not-found for nonexistent key", async () => {
147
+ expect(await deleteSecureKeyAsync("missing")).toBe("not-found");
140
148
  });
141
149
  });
142
150
 
143
151
  // -----------------------------------------------------------------------
144
- // CRUD operations (via encrypted store backend sync)
152
+ // Single-writer: writes go to keychain only when broker available
145
153
  // -----------------------------------------------------------------------
146
- describe("CRUD with encrypted backend (sync)", () => {
147
- test("set and get a key", () => {
148
- setSecureKey("openai", "sk-openai-789");
149
- expect(getSecureKey("openai")).toBe("sk-openai-789");
150
- });
151
-
152
- test("get returns undefined for nonexistent key", () => {
153
- expect(getSecureKey("nonexistent")).toBeUndefined();
154
- });
154
+ describe("single-writer with broker available", () => {
155
+ test("setSecureKeyAsync writes to broker only (not encrypted store)", async () => {
156
+ mockBrokerAvailable = true;
157
+ _resetBackend();
155
158
 
156
- test("delete removes a key", () => {
157
- setSecureKey("gemini", "gem-key");
158
- expect(deleteSecureKey("gemini")).toBe("deleted");
159
- expect(getSecureKey("gemini")).toBeUndefined();
159
+ const result = await setSecureKeyAsync("api-key", "new-value");
160
+ expect(result).toBe(true);
161
+ // Value is in the broker store
162
+ expect(mockBrokerStore.get("api-key")).toBe("new-value");
163
+ // Value should NOT be in the encrypted store (single-writer)
164
+ expect(encryptedStore.getKey("api-key")).toBeUndefined();
160
165
  });
161
166
 
162
- test("delete returns not-found for nonexistent key", () => {
163
- expect(deleteSecureKey("missing")).toBe("not-found");
164
- });
167
+ test("setSecureKeyAsync returns false on broker set error", async () => {
168
+ mockBrokerAvailable = true;
169
+ mockBrokerSetError = true;
170
+ _resetBackend();
165
171
 
166
- test("listSecureKeys returns all keys", () => {
167
- setSecureKey("anthropic", "val1");
168
- setSecureKey("openai", "val2");
169
- const keys = listSecureKeys();
170
- expect(keys).toContain("anthropic");
171
- expect(keys).toContain("openai");
172
- expect(keys.length).toBe(2);
172
+ const result = await setSecureKeyAsync("api-key", "new-value");
173
+ expect(result).toBe(false);
174
+ expect(mockBrokerStore.has("api-key")).toBe(false);
173
175
  });
174
176
  });
175
177
 
176
178
  // -----------------------------------------------------------------------
177
- // Sync variants always use encrypted store even when broker is available
179
+ // Reads: primary backend first, legacy fallback to encrypted store
178
180
  // -----------------------------------------------------------------------
179
- describe("sync variants ignore broker", () => {
180
- test("getSecureKey uses encrypted store even when broker is available", () => {
181
+ describe("reads with broker available", () => {
182
+ test("getSecureKeyAsync reads from broker (primary backend)", async () => {
181
183
  mockBrokerAvailable = true;
184
+ _resetBackend();
185
+
182
186
  mockBrokerStore.set("api-key", "broker-value");
183
- // Sync getter should not see broker-only keys
184
- expect(getSecureKey("api-key")).toBeUndefined();
185
- // But encrypted store keys should work
186
- setSecureKey("api-key", "encrypted-value");
187
- expect(getSecureKey("api-key")).toBe("encrypted-value");
187
+ const result = await getSecureKeyAsync("api-key");
188
+ expect(result).toBe("broker-value");
189
+ expect(mockBrokerGetCalled).toBe(true);
188
190
  });
189
191
 
190
- test("setSecureKey uses encrypted store even when broker is available", () => {
192
+ test("getSecureKeyAsync falls back to encrypted store for legacy keys", async () => {
191
193
  mockBrokerAvailable = true;
192
- setSecureKey("api-key", "encrypted-value");
193
- expect(getSecureKey("api-key")).toBe("encrypted-value");
194
- // Should not have written to broker
195
- expect(mockBrokerStore.has("api-key")).toBe(false);
194
+ _resetBackend();
195
+
196
+ // Pre-populate encrypted store directly (legacy key not in broker)
197
+ encryptedStore.setKey("legacy-key", "legacy-value");
198
+
199
+ const result = await getSecureKeyAsync("legacy-key");
200
+ expect(result).toBe("legacy-value");
201
+ // Broker was checked first (returned nothing), then encrypted store
202
+ expect(mockBrokerGetCalled).toBe(true);
196
203
  });
197
204
 
198
- test("deleteSecureKey uses encrypted store even when broker is available", () => {
205
+ test("getSecureKeyAsync returns undefined when neither store has the key", async () => {
199
206
  mockBrokerAvailable = true;
207
+ _resetBackend();
208
+
209
+ expect(await getSecureKeyAsync("missing-key")).toBeUndefined();
210
+ });
211
+
212
+ test("getSecureKeyAsync returns broker value even when encrypted store also has a value", async () => {
213
+ mockBrokerAvailable = true;
214
+ _resetBackend();
215
+
216
+ // Both stores have a value — broker (primary) should win
200
217
  mockBrokerStore.set("api-key", "broker-value");
201
- setSecureKey("api-key", "encrypted-value");
202
- deleteSecureKey("api-key");
203
- expect(getSecureKey("api-key")).toBeUndefined();
204
- // Broker value should be untouched
205
- expect(mockBrokerStore.has("api-key")).toBe(true);
218
+ encryptedStore.setKey("api-key", "encrypted-value");
219
+
220
+ const result = await getSecureKeyAsync("api-key");
221
+ expect(result).toBe("broker-value");
206
222
  });
207
223
  });
208
224
 
209
225
  // -----------------------------------------------------------------------
210
- // Async variantsbroker available path
226
+ // Dev mode bypass VELLUM_DEV=1 uses encrypted store only
211
227
  // -----------------------------------------------------------------------
212
- describe("async variants with broker available", () => {
213
- test("getSecureKeyAsync returns encrypted store value when both stores have key", async () => {
228
+ describe("dev mode bypass (VELLUM_DEV=1)", () => {
229
+ test("setSecureKeyAsync writes to encrypted store only, ignoring broker", async () => {
230
+ process.env.VELLUM_DEV = "1";
214
231
  mockBrokerAvailable = true;
215
- mockBrokerStore.set("api-key", "broker-value");
216
- setSecureKey("api-key", "encrypted-value");
217
- // Encrypted store is checked first — broker is never called
218
- expect(await getSecureKeyAsync("api-key")).toBe("encrypted-value");
219
- expect(mockBrokerGetCalled).toBe(false);
232
+ _resetBackend();
233
+
234
+ const result = await setSecureKeyAsync("api-key", "dev-value");
235
+ expect(result).toBe(true);
236
+ // Written to encrypted store
237
+ expect(encryptedStore.getKey("api-key")).toBe("dev-value");
238
+ // NOT written to broker
239
+ expect(mockBrokerStore.has("api-key")).toBe(false);
240
+ expect(mockBrokerSetCalled).toBe(false);
220
241
  });
221
242
 
222
- test("getSecureKeyAsync returns encrypted store value without calling broker", async () => {
243
+ test("getSecureKeyAsync reads from encrypted store only, ignoring broker", async () => {
244
+ process.env.VELLUM_DEV = "1";
223
245
  mockBrokerAvailable = true;
224
- // Only encrypted store has the key — broker has nothing.
225
- // Encrypted store is checked first, so broker.get() is never called.
226
- setSecureKey("api-key", "encrypted-value");
227
- expect(await getSecureKeyAsync("api-key")).toBe("encrypted-value");
246
+ _resetBackend();
247
+
248
+ mockBrokerStore.set("api-key", "broker-value");
249
+ encryptedStore.setKey("api-key", "encrypted-value");
250
+
251
+ const result = await getSecureKeyAsync("api-key");
252
+ expect(result).toBe("encrypted-value");
253
+ // Broker should not have been contacted
228
254
  expect(mockBrokerGetCalled).toBe(false);
229
255
  });
230
256
 
231
- test("getSecureKeyAsync returns undefined when neither broker nor encrypted store has key", async () => {
257
+ test("getSecureKeyAsync returns undefined when encrypted store is empty (does not check broker)", async () => {
258
+ process.env.VELLUM_DEV = "1";
232
259
  mockBrokerAvailable = true;
233
- // Neither store has the key — should return undefined
234
- expect(await getSecureKeyAsync("missing-key")).toBeUndefined();
235
- });
260
+ _resetBackend();
236
261
 
237
- test("getSecureKeyAsync returns encrypted store value even when broker would error", async () => {
238
- mockBrokerAvailable = true;
239
- mockBrokerGetError = true;
240
- // Encrypted store hit short-circuits — broker is never called, so
241
- // the broker error flag is irrelevant.
242
- setSecureKey("api-key", "encrypted-value");
243
- expect(await getSecureKeyAsync("api-key")).toBe("encrypted-value");
262
+ mockBrokerStore.set("api-key", "broker-value");
263
+
264
+ const result = await getSecureKeyAsync("api-key");
265
+ expect(result).toBeUndefined();
244
266
  expect(mockBrokerGetCalled).toBe(false);
245
267
  });
268
+ });
246
269
 
247
- test("setSecureKeyAsync writes to broker and encrypted store", async () => {
270
+ // -----------------------------------------------------------------------
271
+ // Delete always attempts both stores
272
+ // -----------------------------------------------------------------------
273
+ describe("delete attempts both stores", () => {
274
+ test("deleteSecureKeyAsync removes from both stores when broker available", async () => {
248
275
  mockBrokerAvailable = true;
249
- const result = await setSecureKeyAsync("api-key", "new-value");
250
- expect(result).toBe(true);
251
- expect(mockBrokerStore.get("api-key")).toBe("new-value");
252
- // Also persisted to encrypted store for sync callers
253
- expect(getSecureKey("api-key")).toBe("new-value");
254
- });
276
+ _resetBackend();
255
277
 
256
- test("setSecureKeyAsync returns false on broker set error (no silent fallback)", async () => {
257
- mockBrokerAvailable = true;
258
- mockBrokerSetError = true;
259
- const result = await setSecureKeyAsync("api-key", "new-value");
260
- // Must return false — falling through to encrypted-only write would
261
- // leave the broker with stale data that async readers still see.
262
- expect(result).toBe(false);
278
+ mockBrokerStore.set("api-key", "broker-value");
279
+ encryptedStore.setKey("api-key", "encrypted-value");
280
+
281
+ const result = await deleteSecureKeyAsync("api-key");
282
+ expect(result).toBe("deleted");
263
283
  expect(mockBrokerStore.has("api-key")).toBe(false);
264
- // Encrypted store should NOT have been written either.
265
- expect(getSecureKey("api-key")).toBeUndefined();
284
+ expect(encryptedStore.getKey("api-key")).toBeUndefined();
266
285
  });
267
286
 
268
- test("deleteSecureKeyAsync deletes from broker and encrypted store", async () => {
269
- mockBrokerAvailable = true;
270
- mockBrokerStore.set("api-key", "broker-value");
271
- setSecureKey("api-key", "encrypted-value");
287
+ test("deleteSecureKeyAsync returns deleted when only encrypted store has key", async () => {
288
+ // Broker unavailable — only encrypted store
289
+ encryptedStore.setKey("api-key", "encrypted-value");
290
+
272
291
  const result = await deleteSecureKeyAsync("api-key");
273
292
  expect(result).toBe("deleted");
274
- expect(mockBrokerStore.has("api-key")).toBe(false);
275
- expect(getSecureKey("api-key")).toBeUndefined();
293
+ expect(encryptedStore.getKey("api-key")).toBeUndefined();
276
294
  });
277
295
 
278
- test("deleteSecureKeyAsync returns error on broker del error (no silent fallback)", async () => {
296
+ test("deleteSecureKeyAsync returns error when broker delete fails", async () => {
279
297
  mockBrokerAvailable = true;
280
298
  mockBrokerDelError = true;
281
- setSecureKey("api-key", "encrypted-value");
299
+ _resetBackend();
300
+
301
+ mockBrokerStore.set("api-key", "broker-value");
302
+ encryptedStore.setKey("api-key", "encrypted-value");
303
+
282
304
  const result = await deleteSecureKeyAsync("api-key");
283
- // Must return "error" — falling through to encrypted-only delete would
284
- // leave the broker with the key, and async readers would still see it.
285
305
  expect(result).toBe("error");
286
- // Encrypted store should NOT have been modified either.
287
- expect(getSecureKey("api-key")).toBe("encrypted-value");
288
- });
289
- });
290
-
291
- // -----------------------------------------------------------------------
292
- // Async variants — broker unavailable path
293
- // -----------------------------------------------------------------------
294
- describe("async variants with broker unavailable", () => {
295
- test("getSecureKeyAsync uses encrypted store", async () => {
296
- setSecureKey("api-key", "encrypted-value");
297
- expect(await getSecureKeyAsync("api-key")).toBe("encrypted-value");
298
306
  });
299
307
 
300
- test("getSecureKeyAsync returns undefined for missing key", async () => {
301
- expect(await getSecureKeyAsync("missing")).toBeUndefined();
302
- });
308
+ test("deleteSecureKeyAsync in dev mode still attempts both stores", async () => {
309
+ process.env.VELLUM_DEV = "1";
310
+ mockBrokerAvailable = true;
311
+ _resetBackend();
303
312
 
304
- test("setSecureKeyAsync uses encrypted store", async () => {
305
- const result = await setSecureKeyAsync("api-key", "new-value");
306
- expect(result).toBe(true);
307
- expect(getSecureKey("api-key")).toBe("new-value");
308
- });
313
+ mockBrokerStore.set("api-key", "broker-value");
314
+ encryptedStore.setKey("api-key", "encrypted-value");
309
315
 
310
- test("deleteSecureKeyAsync uses encrypted store", async () => {
311
- setSecureKey("api-key", "value");
312
316
  const result = await deleteSecureKeyAsync("api-key");
313
317
  expect(result).toBe("deleted");
314
- expect(getSecureKey("api-key")).toBeUndefined();
318
+ expect(mockBrokerStore.has("api-key")).toBe(false);
319
+ expect(encryptedStore.getKey("api-key")).toBeUndefined();
320
+ });
321
+
322
+ test("deleteSecureKeyAsync returns not-found when key missing from both stores", async () => {
323
+ // Broker unavailable, encrypted store empty
324
+ const result = await deleteSecureKeyAsync("missing-key");
325
+ expect(result).toBe("not-found");
315
326
  });
316
327
  });
317
328
 
318
329
  // -----------------------------------------------------------------------
319
- // Encrypted-store-first read order
330
+ // Legacy read fallback
320
331
  // -----------------------------------------------------------------------
321
- describe("encrypted-store-first read order", () => {
322
- test("returns value from encrypted store without calling broker", async () => {
332
+ describe("legacy read fallback", () => {
333
+ test("returns encrypted store value when broker has no key (legacy migration)", async () => {
323
334
  mockBrokerAvailable = true;
324
- setSecureKey("test-account", "test-secret");
325
- mockBrokerStore.set("test-account", "broker-secret");
326
-
327
- const result = await getSecureKeyAsync("test-account");
328
- expect(result).toBe("test-secret");
329
- // Broker should never have been called — encrypted store hit
330
- // short-circuits the lookup.
331
- expect(mockBrokerGetCalled).toBe(false);
332
- });
333
-
334
- test("falls back to broker when encrypted store returns undefined", async () => {
335
- mockBrokerAvailable = true;
336
- // Encrypted store has nothing for this key
337
- mockBrokerStore.set("test-account", "broker-secret");
338
-
339
- const result = await getSecureKeyAsync("test-account");
340
- expect(result).toBe("broker-secret");
341
- // Broker should have been called as fallback
342
- expect(mockBrokerGetCalled).toBe(true);
343
- });
335
+ _resetBackend();
344
336
 
345
- test("returns undefined when neither store has the key", async () => {
346
- mockBrokerAvailable = true;
347
- // Neither encrypted store nor broker has the key
337
+ // Simulate a legacy key that was written to encrypted store before
338
+ // the single-writer migration — broker doesn't have it.
339
+ encryptedStore.setKey("legacy-account", "legacy-secret");
348
340
 
349
- const result = await getSecureKeyAsync("test-account");
350
- expect(result).toBeUndefined();
341
+ const result = await getSecureKeyAsync("legacy-account");
342
+ expect(result).toBe("legacy-secret");
351
343
  });
352
344
 
353
- test("returns undefined without broker call when broker unavailable and encrypted store empty", async () => {
354
- // Broker is unavailable (default state), encrypted store is empty
355
- mockBrokerAvailable = false;
345
+ test("does not fall back to encrypted store when already using encrypted store backend", async () => {
346
+ // Broker unavailable primary backend IS the encrypted store.
347
+ // No fallback needed.
348
+ encryptedStore.setKey("account", "value");
349
+ encryptedStore.setKey("other-account", "other-value");
356
350
 
357
- const result = await getSecureKeyAsync("test-account");
358
- expect(result).toBeUndefined();
359
- // Broker.get() should not have been called since broker is unavailable
351
+ // Should read directly from encrypted store (primary)
352
+ const result = await getSecureKeyAsync("account");
353
+ expect(result).toBe("value");
354
+ // Broker should not have been contacted
360
355
  expect(mockBrokerGetCalled).toBe(false);
361
356
  });
362
357
  });
363
358
 
364
359
  // -----------------------------------------------------------------------
365
- // Stale-value prevention — encrypted-store-first reads avoid stale broker data
360
+ // Stale-value prevention
366
361
  // -----------------------------------------------------------------------
367
362
  describe("stale-value prevention", () => {
368
- test("setSecureKeyAsync updates both stores so encrypted-store-first read returns new value", async () => {
363
+ test("setSecureKeyAsync failure does not corrupt broker store", async () => {
369
364
  mockBrokerAvailable = true;
370
- // Simulate broker holding an old value
371
- mockBrokerStore.set("api-key", "old-broker-value");
372
- setSecureKey("api-key", "old-encrypted-value");
365
+ _resetBackend();
366
+
367
+ // Pre-seed broker with original value
368
+ mockBrokerStore.set("api-key", "original-value");
373
369
 
374
- // Update via async path (writes both broker + encrypted)
370
+ // Now fail the next set
371
+ mockBrokerSetError = true;
375
372
  const ok = await setSecureKeyAsync("api-key", "new-value");
376
- expect(ok).toBe(true);
373
+ expect(ok).toBe(false);
377
374
 
378
- // Encrypted-store-first read returns the new value
379
- const value = await getSecureKeyAsync("api-key");
380
- expect(value).toBe("new-value");
381
- // Both stores should agree
382
- expect(mockBrokerStore.get("api-key")).toBe("new-value");
383
- expect(getSecureKey("api-key")).toBe("new-value");
375
+ // Broker should still have original value
376
+ expect(mockBrokerStore.get("api-key")).toBe("original-value");
384
377
  });
378
+ });
385
379
 
386
- test("deleteSecureKeyAsync removes from both stores so read returns undefined", async () => {
380
+ // -----------------------------------------------------------------------
381
+ // listSecureKeysAsync — merged/deduplicated key listing
382
+ // -----------------------------------------------------------------------
383
+ describe("listSecureKeysAsync", () => {
384
+ test("returns merged, deduplicated keys when broker is primary and encrypted store has legacy keys", async () => {
387
385
  mockBrokerAvailable = true;
388
- mockBrokerStore.set("api-key", "old-broker-value");
389
- setSecureKey("api-key", "old-encrypted-value");
386
+ _resetBackend();
390
387
 
391
- // Delete via async path (deletes from both broker + encrypted)
392
- const result = await deleteSecureKeyAsync("api-key");
393
- expect(result).toBe("deleted");
388
+ // Broker has some keys
389
+ mockBrokerStore.set("broker-key-1", "val1");
390
+ mockBrokerStore.set("shared-key", "broker-val");
394
391
 
395
- // Neither store has the key returns undefined
396
- const value = await getSecureKeyAsync("api-key");
397
- expect(value).toBeUndefined();
398
- });
392
+ // Encrypted store has legacy keys (some overlapping)
393
+ encryptedStore.setKey("legacy-key-1", "val2");
394
+ encryptedStore.setKey("shared-key", "enc-val");
399
395
 
400
- test("sync setSecureKey updates encrypted store — encrypted-store-first read returns fresh value", async () => {
401
- mockBrokerAvailable = true;
402
- mockBrokerStore.set("api-key", "old-broker-value");
396
+ const keys = await listSecureKeysAsync();
397
+ expect(keys).toContain("broker-key-1");
398
+ expect(keys).toContain("legacy-key-1");
399
+ expect(keys).toContain("shared-key");
400
+ // Should be exactly 3 unique keys (no duplicates)
401
+ expect(keys.length).toBe(3);
402
+ });
403
403
 
404
- // Sync write only updates encrypted store, NOT broker
405
- setSecureKey("api-key", "new-encrypted-value");
404
+ test("returns only encrypted store keys when broker is unavailable", async () => {
405
+ // Broker unavailable (default state) — primary backend is encrypted store
406
+ encryptedStore.setKey("enc-key-1", "val1");
407
+ encryptedStore.setKey("enc-key-2", "val2");
406
408
 
407
- // Encrypted-store-first read returns the fresh encrypted value,
408
- // bypassing the stale broker entirely.
409
- const value = await getSecureKeyAsync("api-key");
410
- expect(value).toBe("new-encrypted-value");
411
- expect(mockBrokerGetCalled).toBe(false);
409
+ const keys = await listSecureKeysAsync();
410
+ expect(keys).toContain("enc-key-1");
411
+ expect(keys).toContain("enc-key-2");
412
+ expect(keys.length).toBe(2);
412
413
  });
413
414
 
414
- test("setSecureKeyAsync failure leaves both stores unchanged", async () => {
415
+ test("returns only encrypted store keys when VELLUM_DEV=1 (even if broker available)", async () => {
416
+ process.env.VELLUM_DEV = "1";
415
417
  mockBrokerAvailable = true;
416
- mockBrokerSetError = true;
417
- mockBrokerStore.set("api-key", "original-value");
418
- setSecureKey("api-key", "original-value");
418
+ _resetBackend();
419
419
 
420
- const ok = await setSecureKeyAsync("api-key", "new-value");
421
- expect(ok).toBe(false);
420
+ // Broker has keys that should be ignored
421
+ mockBrokerStore.set("broker-only", "val1");
422
422
 
423
- // Both stores should retain original value — no partial update
424
- expect(mockBrokerStore.get("api-key")).toBe("original-value");
425
- expect(getSecureKey("api-key")).toBe("original-value");
423
+ // Encrypted store has keys
424
+ encryptedStore.setKey("dev-key-1", "val2");
425
+ encryptedStore.setKey("dev-key-2", "val3");
426
+
427
+ const keys = await listSecureKeysAsync();
428
+ expect(keys).toContain("dev-key-1");
429
+ expect(keys).toContain("dev-key-2");
430
+ // broker-only key should NOT appear since primary backend is encrypted store
431
+ expect(keys).not.toContain("broker-only");
432
+ expect(keys.length).toBe(2);
426
433
  });
427
434
 
428
- test("deleteSecureKeyAsync failure leaves both stores unchanged", async () => {
435
+ test("returns broker-only keys when encrypted store is empty", async () => {
429
436
  mockBrokerAvailable = true;
430
- mockBrokerDelError = true;
431
- mockBrokerStore.set("api-key", "value");
432
- setSecureKey("api-key", "value");
437
+ _resetBackend();
433
438
 
434
- const result = await deleteSecureKeyAsync("api-key");
435
- expect(result).toBe("error");
439
+ mockBrokerStore.set("broker-key-1", "val1");
440
+ mockBrokerStore.set("broker-key-2", "val2");
436
441
 
437
- // Both stores should retain the key — no partial deletion
438
- expect(mockBrokerStore.has("api-key")).toBe(true);
439
- expect(getSecureKey("api-key")).toBe("value");
442
+ const keys = await listSecureKeysAsync();
443
+ expect(keys).toContain("broker-key-1");
444
+ expect(keys).toContain("broker-key-2");
445
+ expect(keys.length).toBe(2);
440
446
  });
441
- });
442
447
 
443
- // -----------------------------------------------------------------------
444
- // _setBackend / _resetBackend (no-ops kept for test compat)
445
- // -----------------------------------------------------------------------
446
- describe("_setBackend", () => {
447
- test("_setBackend is a no-op but does not throw", () => {
448
- _setBackend("encrypted");
449
- setSecureKey("test", "value");
450
- expect(existsSync(STORE_PATH)).toBe(true);
448
+ test("deduplicates keys that exist in both stores", async () => {
449
+ mockBrokerAvailable = true;
450
+ _resetBackend();
451
+
452
+ // Same key in both stores
453
+ mockBrokerStore.set("api-key", "broker-val");
454
+ encryptedStore.setKey("api-key", "enc-val");
455
+
456
+ const keys = await listSecureKeysAsync();
457
+ expect(keys).toContain("api-key");
458
+ // Only one copy, not two
459
+ expect(keys.length).toBe(1);
460
+ expect(keys.filter((k) => k === "api-key").length).toBe(1);
451
461
  });
452
462
 
453
- test("_resetBackend is a no-op but does not throw", () => {
463
+ test("returns empty array when both stores are empty", async () => {
464
+ mockBrokerAvailable = true;
454
465
  _resetBackend();
455
- setSecureKey("test", "value");
456
- expect(getSecureKey("test")).toBe("value");
466
+
467
+ const keys = await listSecureKeysAsync();
468
+ expect(keys).toEqual([]);
457
469
  });
458
470
  });
459
471
  });
@@ -17,7 +17,6 @@ mock.module("../util/platform.js", () => ({
17
17
  }));
18
18
 
19
19
  mock.module("../memory/guardian-action-store.js", () => ({
20
- getPendingDeliveryByConversation: () => null,
21
20
  getGuardianActionRequest: () => null,
22
21
  resolveGuardianActionRequest: () => {},
23
22
  }));