vellum 0.2.13 → 0.2.14

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 (207) hide show
  1. package/README.md +32 -0
  2. package/bun.lock +2 -2
  3. package/docs/skills.md +4 -4
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
  6. package/src/__tests__/app-git-history.test.ts +176 -0
  7. package/src/__tests__/app-git-service.test.ts +169 -0
  8. package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
  9. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
  10. package/src/__tests__/browser-skill-endstate.test.ts +6 -6
  11. package/src/__tests__/call-bridge.test.ts +105 -13
  12. package/src/__tests__/call-domain.test.ts +163 -0
  13. package/src/__tests__/call-orchestrator.test.ts +113 -0
  14. package/src/__tests__/call-routes-http.test.ts +246 -6
  15. package/src/__tests__/channel-approval-routes.test.ts +438 -0
  16. package/src/__tests__/channel-approval.test.ts +266 -0
  17. package/src/__tests__/channel-approvals.test.ts +393 -0
  18. package/src/__tests__/channel-delivery-store.test.ts +447 -0
  19. package/src/__tests__/checker.test.ts +607 -1048
  20. package/src/__tests__/cli.test.ts +1 -56
  21. package/src/__tests__/config-schema.test.ts +137 -18
  22. package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
  23. package/src/__tests__/conflict-policy.test.ts +121 -0
  24. package/src/__tests__/conflict-store.test.ts +2 -0
  25. package/src/__tests__/contacts-tools.test.ts +3 -3
  26. package/src/__tests__/contradiction-checker.test.ts +99 -1
  27. package/src/__tests__/credential-security-invariants.test.ts +22 -6
  28. package/src/__tests__/credential-vault-unit.test.ts +780 -0
  29. package/src/__tests__/elevenlabs-client.test.ts +62 -0
  30. package/src/__tests__/ephemeral-permissions.test.ts +73 -23
  31. package/src/__tests__/filesystem-tools.test.ts +579 -0
  32. package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
  33. package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
  34. package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
  35. package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
  36. package/src/__tests__/handlers-slack-config.test.ts +2 -1
  37. package/src/__tests__/handlers-telegram-config.test.ts +855 -0
  38. package/src/__tests__/handlers-twitter-config.test.ts +141 -1
  39. package/src/__tests__/hooks-runner.test.ts +6 -2
  40. package/src/__tests__/host-file-edit-tool.test.ts +124 -0
  41. package/src/__tests__/host-file-read-tool.test.ts +62 -0
  42. package/src/__tests__/host-file-write-tool.test.ts +59 -0
  43. package/src/__tests__/host-shell-tool.test.ts +251 -0
  44. package/src/__tests__/ingress-reconcile.test.ts +581 -0
  45. package/src/__tests__/ipc-snapshot.test.ts +100 -41
  46. package/src/__tests__/ipc-validate.test.ts +50 -0
  47. package/src/__tests__/key-migration.test.ts +23 -0
  48. package/src/__tests__/memory-regressions.test.ts +99 -0
  49. package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
  50. package/src/__tests__/oauth-callback-registry.test.ts +11 -4
  51. package/src/__tests__/playbook-execution.test.ts +502 -0
  52. package/src/__tests__/playbook-tools.test.ts +4 -6
  53. package/src/__tests__/public-ingress-urls.test.ts +34 -0
  54. package/src/__tests__/qdrant-manager.test.ts +267 -0
  55. package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
  56. package/src/__tests__/recurrence-engine.test.ts +9 -0
  57. package/src/__tests__/recurrence-types.test.ts +8 -0
  58. package/src/__tests__/registry.test.ts +1 -1
  59. package/src/__tests__/runtime-runs.test.ts +1 -25
  60. package/src/__tests__/schedule-store.test.ts +16 -14
  61. package/src/__tests__/schedule-tools.test.ts +83 -0
  62. package/src/__tests__/scheduler-recurrence.test.ts +111 -10
  63. package/src/__tests__/secret-allowlist.test.ts +18 -17
  64. package/src/__tests__/secret-ingress-handler.test.ts +11 -0
  65. package/src/__tests__/secret-scanner.test.ts +43 -0
  66. package/src/__tests__/session-conflict-gate.test.ts +442 -6
  67. package/src/__tests__/session-init.benchmark.test.ts +3 -0
  68. package/src/__tests__/session-process-bridge.test.ts +242 -0
  69. package/src/__tests__/session-skill-tools.test.ts +1 -1
  70. package/src/__tests__/shell-identity.test.ts +256 -0
  71. package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
  72. package/src/__tests__/subagent-tools.test.ts +637 -54
  73. package/src/__tests__/task-management-tools.test.ts +936 -0
  74. package/src/__tests__/task-runner.test.ts +2 -2
  75. package/src/__tests__/terminal-tools.test.ts +840 -0
  76. package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
  77. package/src/__tests__/tool-executor.test.ts +85 -151
  78. package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
  79. package/src/__tests__/trust-store.test.ts +27 -453
  80. package/src/__tests__/twilio-provider.test.ts +153 -3
  81. package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
  82. package/src/__tests__/twilio-routes-twiml.test.ts +4 -4
  83. package/src/__tests__/twilio-routes.test.ts +17 -262
  84. package/src/__tests__/twitter-auth-handler.test.ts +2 -1
  85. package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
  86. package/src/__tests__/twitter-cli-routing.test.ts +252 -0
  87. package/src/__tests__/twitter-oauth-client.test.ts +209 -0
  88. package/src/__tests__/workspace-policy.test.ts +213 -0
  89. package/src/calls/call-bridge.ts +92 -19
  90. package/src/calls/call-domain.ts +157 -5
  91. package/src/calls/call-orchestrator.ts +93 -7
  92. package/src/calls/call-store.ts +6 -0
  93. package/src/calls/elevenlabs-client.ts +8 -0
  94. package/src/calls/elevenlabs-config.ts +7 -5
  95. package/src/calls/twilio-provider.ts +91 -0
  96. package/src/calls/twilio-routes.ts +32 -37
  97. package/src/calls/types.ts +3 -1
  98. package/src/calls/voice-quality.ts +29 -7
  99. package/src/cli/twitter.ts +200 -21
  100. package/src/cli.ts +1 -20
  101. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
  102. package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
  103. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
  104. package/src/config/bundled-skills/messaging/SKILL.md +17 -2
  105. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
  106. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  107. package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
  108. package/src/config/bundled-skills/phone-calls/SKILL.md +142 -34
  109. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
  110. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
  111. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
  112. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
  113. package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
  114. package/src/config/bundled-skills/twitter/SKILL.md +103 -17
  115. package/src/config/defaults.ts +10 -4
  116. package/src/config/schema.ts +80 -21
  117. package/src/config/types.ts +1 -0
  118. package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
  119. package/src/daemon/assistant-attachments.ts +4 -2
  120. package/src/daemon/handlers/apps.ts +69 -0
  121. package/src/daemon/handlers/config.ts +543 -24
  122. package/src/daemon/handlers/index.ts +1 -0
  123. package/src/daemon/handlers/sessions.ts +22 -6
  124. package/src/daemon/handlers/shared.ts +2 -1
  125. package/src/daemon/handlers/skills.ts +5 -20
  126. package/src/daemon/ipc-contract-inventory.json +28 -0
  127. package/src/daemon/ipc-contract.ts +168 -10
  128. package/src/daemon/ipc-validate.ts +17 -0
  129. package/src/daemon/lifecycle.ts +2 -0
  130. package/src/daemon/server.ts +78 -72
  131. package/src/daemon/session-attachments.ts +1 -1
  132. package/src/daemon/session-conflict-gate.ts +62 -6
  133. package/src/daemon/session-notifiers.ts +1 -1
  134. package/src/daemon/session-process.ts +62 -3
  135. package/src/daemon/session-tool-setup.ts +1 -2
  136. package/src/daemon/tls-certs.ts +189 -0
  137. package/src/daemon/video-thumbnail.ts +5 -3
  138. package/src/hooks/manager.ts +5 -9
  139. package/src/memory/app-git-service.ts +295 -0
  140. package/src/memory/app-store.ts +21 -0
  141. package/src/memory/conflict-intent.ts +47 -4
  142. package/src/memory/conflict-policy.ts +73 -0
  143. package/src/memory/conflict-store.ts +9 -1
  144. package/src/memory/contradiction-checker.ts +28 -0
  145. package/src/memory/conversation-key-store.ts +15 -0
  146. package/src/memory/db.ts +81 -0
  147. package/src/memory/embedding-local.ts +3 -13
  148. package/src/memory/external-conversation-store.ts +234 -0
  149. package/src/memory/job-handlers/conflict.ts +22 -2
  150. package/src/memory/jobs-worker.ts +67 -28
  151. package/src/memory/runs-store.ts +54 -7
  152. package/src/memory/schema.ts +20 -0
  153. package/src/messaging/provider.ts +9 -0
  154. package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
  155. package/src/messaging/providers/telegram-bot/client.ts +104 -0
  156. package/src/messaging/providers/telegram-bot/types.ts +15 -0
  157. package/src/messaging/registry.ts +1 -0
  158. package/src/permissions/checker.ts +48 -44
  159. package/src/permissions/prompter.ts +0 -4
  160. package/src/permissions/shell-identity.ts +227 -0
  161. package/src/permissions/trust-store.ts +76 -53
  162. package/src/permissions/types.ts +0 -19
  163. package/src/permissions/workspace-policy.ts +114 -0
  164. package/src/providers/retry.ts +12 -37
  165. package/src/runtime/assistant-event-hub.ts +41 -4
  166. package/src/runtime/channel-approval-parser.ts +60 -0
  167. package/src/runtime/channel-approval-types.ts +71 -0
  168. package/src/runtime/channel-approvals.ts +145 -0
  169. package/src/runtime/gateway-client.ts +16 -0
  170. package/src/runtime/http-server.ts +29 -9
  171. package/src/runtime/routes/call-routes.ts +52 -2
  172. package/src/runtime/routes/channel-routes.ts +296 -16
  173. package/src/runtime/routes/events-routes.ts +97 -28
  174. package/src/runtime/routes/run-routes.ts +2 -7
  175. package/src/runtime/run-orchestrator.ts +0 -3
  176. package/src/schedule/recurrence-engine.ts +26 -2
  177. package/src/schedule/recurrence-types.ts +1 -1
  178. package/src/schedule/schedule-store.ts +12 -3
  179. package/src/security/secret-scanner.ts +7 -0
  180. package/src/tasks/ephemeral-permissions.ts +0 -2
  181. package/src/tasks/task-scheduler.ts +2 -1
  182. package/src/tools/calls/call-start.ts +8 -0
  183. package/src/tools/execution-target.ts +21 -0
  184. package/src/tools/execution-timeout.ts +49 -0
  185. package/src/tools/executor.ts +6 -135
  186. package/src/tools/network/web-search.ts +9 -32
  187. package/src/tools/policy-context.ts +29 -0
  188. package/src/tools/schedule/update.ts +8 -1
  189. package/src/tools/terminal/parser.ts +16 -18
  190. package/src/tools/types.ts +4 -11
  191. package/src/twitter/oauth-client.ts +102 -0
  192. package/src/twitter/router.ts +101 -0
  193. package/src/util/debounce.ts +88 -0
  194. package/src/util/network-info.ts +47 -0
  195. package/src/util/platform.ts +29 -4
  196. package/src/util/promise-guard.ts +37 -0
  197. package/src/util/retry.ts +98 -0
  198. package/src/util/truncate.ts +1 -1
  199. package/src/workspace/git-service.ts +129 -112
  200. package/src/tools/contacts/contact-merge.ts +0 -55
  201. package/src/tools/contacts/contact-search.ts +0 -58
  202. package/src/tools/contacts/contact-upsert.ts +0 -64
  203. package/src/tools/playbooks/index.ts +0 -4
  204. package/src/tools/playbooks/playbook-create.ts +0 -96
  205. package/src/tools/playbooks/playbook-delete.ts +0 -52
  206. package/src/tools/playbooks/playbook-list.ts +0 -74
  207. package/src/tools/playbooks/playbook-update.ts +0 -111
@@ -0,0 +1,780 @@
1
+ import { describe, test, expect, beforeEach, afterEach, afterAll, mock } from 'bun:test';
2
+ import { mkdirSync, rmSync, existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { randomBytes } from 'node:crypto';
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Mock logger
9
+ // ---------------------------------------------------------------------------
10
+
11
+ mock.module('../util/logger.js', () => ({
12
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
13
+ get: () => () => {},
14
+ }),
15
+ }));
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Use encrypted backend (no keychain) with a temp store path
19
+ // ---------------------------------------------------------------------------
20
+
21
+ import { _overrideDeps, _resetDeps } from '../security/keychain.js';
22
+
23
+ _overrideDeps({
24
+ isMacOS: () => false,
25
+ isLinux: () => false,
26
+ execFileSync: (() => '') as unknown as typeof import('node:child_process').execFileSync,
27
+ });
28
+
29
+ import { _resetBackend } from '../security/secure-keys.js';
30
+ import { _setStorePath } from '../security/encrypted-store.js';
31
+
32
+ const TEST_DIR = join(tmpdir(), `vellum-credvault-unit-${randomBytes(4).toString('hex')}`);
33
+ const STORE_PATH = join(TEST_DIR, 'keys.enc');
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Mock registry to avoid double-registration
37
+ // ---------------------------------------------------------------------------
38
+
39
+ mock.module('../tools/registry.js', () => ({
40
+ registerTool: () => {},
41
+ }));
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Imports under test
45
+ // ---------------------------------------------------------------------------
46
+
47
+ import { CredentialBroker } from '../tools/credentials/broker.js';
48
+ import { upsertCredentialMetadata, _setMetadataPath } from '../tools/credentials/metadata-store.js';
49
+ import { setSecureKey, getSecureKey } from '../security/secure-keys.js';
50
+ import { credentialStoreTool } from '../tools/credentials/vault.js';
51
+ import type { ToolContext } from '../tools/types.js';
52
+
53
+ const _ctx: ToolContext = {
54
+ workingDir: '/tmp',
55
+ sessionId: 'test-session',
56
+ conversationId: 'test-conv',
57
+ };
58
+
59
+ afterAll(() => {
60
+ _resetDeps();
61
+ mock.restore();
62
+ if (existsSync(TEST_DIR)) {
63
+ rmSync(TEST_DIR, { recursive: true });
64
+ }
65
+ });
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // 1. Broker — Transient (one-time) credential injection and consumption
69
+ // ---------------------------------------------------------------------------
70
+
71
+ describe('CredentialBroker transient credentials', () => {
72
+ let broker: CredentialBroker;
73
+
74
+ beforeEach(() => {
75
+ if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true });
76
+ mkdirSync(TEST_DIR, { recursive: true });
77
+ _setStorePath(STORE_PATH);
78
+ _resetBackend();
79
+ _setMetadataPath(join(TEST_DIR, 'metadata.json'));
80
+ broker = new CredentialBroker();
81
+ });
82
+
83
+ afterEach(() => {
84
+ _setMetadataPath(null);
85
+ _setStorePath(null);
86
+ _resetBackend();
87
+ });
88
+
89
+ test('consume returns transient value and deletes it', () => {
90
+ upsertCredentialMetadata('svc', 'key', { allowedTools: ['tool1'] });
91
+ broker.injectTransient('svc', 'key', 'one-time-secret');
92
+
93
+ const auth = broker.authorize({ service: 'svc', field: 'key', toolName: 'tool1' });
94
+ expect(auth.authorized).toBe(true);
95
+ if (!auth.authorized) return;
96
+
97
+ const result = broker.consume(auth.token.tokenId);
98
+ expect(result.success).toBe(true);
99
+ expect(result.value).toBe('one-time-secret');
100
+ expect(result.storageKey).toBe('credential:svc:key');
101
+
102
+ // Second authorize + consume should NOT have the transient value
103
+ const auth2 = broker.authorize({ service: 'svc', field: 'key', toolName: 'tool1' });
104
+ expect(auth2.authorized).toBe(true);
105
+ if (!auth2.authorized) return;
106
+ const result2 = broker.consume(auth2.token.tokenId);
107
+ expect(result2.success).toBe(true);
108
+ // No transient value — falls back to storage key only
109
+ expect(result2.value).toBeUndefined();
110
+ });
111
+
112
+ test('browserFill uses transient value when available', async () => {
113
+ upsertCredentialMetadata('github', 'token', { allowedTools: ['browser_fill_credential'] });
114
+ broker.injectTransient('github', 'token', 'transient-ghp-123');
115
+
116
+ let filledValue: string | undefined;
117
+ const result = await broker.browserFill({
118
+ service: 'github',
119
+ field: 'token',
120
+ toolName: 'browser_fill_credential',
121
+ fill: async (v) => { filledValue = v; },
122
+ });
123
+
124
+ expect(result.success).toBe(true);
125
+ expect(filledValue).toBe('transient-ghp-123');
126
+ });
127
+
128
+ test('browserFill consumes transient value — second fill falls back to stored', async () => {
129
+ upsertCredentialMetadata('github', 'token', { allowedTools: ['browser_fill_credential'] });
130
+ setSecureKey('credential:github:token', 'stored-value');
131
+ broker.injectTransient('github', 'token', 'transient-value');
132
+
133
+ // First fill uses transient
134
+ let filled1: string | undefined;
135
+ await broker.browserFill({
136
+ service: 'github',
137
+ field: 'token',
138
+ toolName: 'browser_fill_credential',
139
+ fill: async (v) => { filled1 = v; },
140
+ });
141
+ expect(filled1).toBe('transient-value');
142
+
143
+ // Second fill falls back to stored value
144
+ let filled2: string | undefined;
145
+ await broker.browserFill({
146
+ service: 'github',
147
+ field: 'token',
148
+ toolName: 'browser_fill_credential',
149
+ fill: async (v) => { filled2 = v; },
150
+ });
151
+ expect(filled2).toBe('stored-value');
152
+ });
153
+
154
+ test('browserFill preserves transient value on fill failure', async () => {
155
+ upsertCredentialMetadata('github', 'token', { allowedTools: ['browser_fill_credential'] });
156
+ broker.injectTransient('github', 'token', 'transient-preserved');
157
+
158
+ // First fill fails
159
+ const result1 = await broker.browserFill({
160
+ service: 'github',
161
+ field: 'token',
162
+ toolName: 'browser_fill_credential',
163
+ fill: async () => { throw new Error('Playwright timeout'); },
164
+ });
165
+ expect(result1.success).toBe(false);
166
+
167
+ // Second fill should still have the transient value
168
+ let filled: string | undefined;
169
+ const result2 = await broker.browserFill({
170
+ service: 'github',
171
+ field: 'token',
172
+ toolName: 'browser_fill_credential',
173
+ fill: async (v) => { filled = v; },
174
+ });
175
+ expect(result2.success).toBe(true);
176
+ expect(filled).toBe('transient-preserved');
177
+ });
178
+
179
+ test('serverUse uses transient value when available', async () => {
180
+ upsertCredentialMetadata('vercel', 'api_token', { allowedTools: ['deploy'] });
181
+ broker.injectTransient('vercel', 'api_token', 'transient-vercel-tok');
182
+
183
+ const result = await broker.serverUse({
184
+ service: 'vercel',
185
+ field: 'api_token',
186
+ toolName: 'deploy',
187
+ execute: async (v) => {
188
+ expect(v).toBe('transient-vercel-tok');
189
+ return 'deployed';
190
+ },
191
+ });
192
+
193
+ expect(result.success).toBe(true);
194
+ expect(result.result).toBe('deployed');
195
+ });
196
+
197
+ test('serverUse consumes transient — subsequent call has no value without stored key', async () => {
198
+ upsertCredentialMetadata('vercel', 'api_token', { allowedTools: ['deploy'] });
199
+ // Only transient, no stored value
200
+ broker.injectTransient('vercel', 'api_token', 'transient-only');
201
+
202
+ await broker.serverUse({
203
+ service: 'vercel',
204
+ field: 'api_token',
205
+ toolName: 'deploy',
206
+ execute: async () => 'ok',
207
+ });
208
+
209
+ // Second call: no transient, no stored value
210
+ const result = await broker.serverUse({
211
+ service: 'vercel',
212
+ field: 'api_token',
213
+ toolName: 'deploy',
214
+ execute: async () => { throw new Error('should not be called'); },
215
+ });
216
+ expect(result.success).toBe(false);
217
+ expect(result.reason).toContain('no stored value');
218
+ });
219
+
220
+ test('injectTransient replaces previous transient for same key', () => {
221
+ upsertCredentialMetadata('svc', 'key', { allowedTools: ['t'] });
222
+ broker.injectTransient('svc', 'key', 'first');
223
+ broker.injectTransient('svc', 'key', 'second');
224
+
225
+ const auth = broker.authorize({ service: 'svc', field: 'key', toolName: 't' });
226
+ if (!auth.authorized) return;
227
+ const result = broker.consume(auth.token.tokenId);
228
+ expect(result.value).toBe('second');
229
+ });
230
+
231
+ test('transient value for one credential does not affect another', () => {
232
+ upsertCredentialMetadata('svcA', 'key', { allowedTools: ['t'] });
233
+ upsertCredentialMetadata('svcB', 'key', { allowedTools: ['t'] });
234
+ broker.injectTransient('svcA', 'key', 'val-a');
235
+
236
+ // svcB should not have a transient value — consume returns storageKey only
237
+ const authB = broker.authorize({ service: 'svcB', field: 'key', toolName: 't' });
238
+ if (!authB.authorized) return;
239
+ const resultB = broker.consume(authB.token.tokenId);
240
+ expect(resultB.success).toBe(true);
241
+ expect(resultB.value).toBeUndefined();
242
+
243
+ // svcA should have the transient
244
+ const authA = broker.authorize({ service: 'svcA', field: 'key', toolName: 't' });
245
+ if (!authA.authorized) return;
246
+ const resultA = broker.consume(authA.token.tokenId);
247
+ expect(resultA.value).toBe('val-a');
248
+ });
249
+ });
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // 2. Vault — unknown action handling
253
+ // ---------------------------------------------------------------------------
254
+
255
+ describe('credential_store tool — unknown action', () => {
256
+ beforeEach(() => {
257
+ if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true });
258
+ mkdirSync(TEST_DIR, { recursive: true });
259
+ _setStorePath(STORE_PATH);
260
+ _resetBackend();
261
+ _setMetadataPath(join(TEST_DIR, 'metadata.json'));
262
+ });
263
+
264
+ afterEach(() => {
265
+ _setMetadataPath(null);
266
+ _setStorePath(null);
267
+ _resetBackend();
268
+ });
269
+
270
+ test('returns error for unknown action', async () => {
271
+ const result = await credentialStoreTool.execute({ action: 'unknown_action' }, _ctx);
272
+ expect(result.isError).toBe(true);
273
+ expect(result.content).toContain('unknown action');
274
+ expect(result.content).toContain('unknown_action');
275
+ });
276
+ });
277
+
278
+ // ---------------------------------------------------------------------------
279
+ // 3. Vault — prompt action edge cases
280
+ // ---------------------------------------------------------------------------
281
+
282
+ describe('credential_store tool — prompt action', () => {
283
+ beforeEach(() => {
284
+ if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true });
285
+ mkdirSync(TEST_DIR, { recursive: true });
286
+ _setStorePath(STORE_PATH);
287
+ _resetBackend();
288
+ _setMetadataPath(join(TEST_DIR, 'metadata.json'));
289
+ });
290
+
291
+ afterEach(() => {
292
+ _setMetadataPath(null);
293
+ _setStorePath(null);
294
+ _resetBackend();
295
+ });
296
+
297
+ test('returns error when requestSecret is not available', async () => {
298
+ const result = await credentialStoreTool.execute(
299
+ { action: 'prompt', service: 'svc', field: 'key', label: 'API Key' },
300
+ _ctx, // no requestSecret
301
+ );
302
+ expect(result.isError).toBe(true);
303
+ expect(result.content).toContain('not available');
304
+ });
305
+
306
+ test('returns error when service is missing for prompt', async () => {
307
+ const result = await credentialStoreTool.execute(
308
+ { action: 'prompt', field: 'key' },
309
+ _ctx,
310
+ );
311
+ expect(result.isError).toBe(true);
312
+ expect(result.content).toContain('service is required');
313
+ });
314
+
315
+ test('returns error when field is missing for prompt', async () => {
316
+ const result = await credentialStoreTool.execute(
317
+ { action: 'prompt', service: 'svc' },
318
+ _ctx,
319
+ );
320
+ expect(result.isError).toBe(true);
321
+ expect(result.content).toContain('field is required');
322
+ });
323
+
324
+ test('handles user cancellation (null value)', async () => {
325
+ const ctxWithPrompt: ToolContext = {
326
+ ..._ctx,
327
+ requestSecret: async () => ({ value: null as unknown as string, delivery: 'store' as const }),
328
+ };
329
+ const result = await credentialStoreTool.execute(
330
+ { action: 'prompt', service: 'svc', field: 'key', label: 'Test' },
331
+ ctxWithPrompt,
332
+ );
333
+ expect(result.isError).toBe(false);
334
+ expect(result.content).toContain('cancelled');
335
+ });
336
+
337
+ test('stores credential when user provides value via prompt', async () => {
338
+ const ctxWithPrompt: ToolContext = {
339
+ ..._ctx,
340
+ requestSecret: async () => ({ value: 'prompt-secret-val', delivery: 'store' as const }),
341
+ };
342
+ const result = await credentialStoreTool.execute(
343
+ { action: 'prompt', service: 'test-prompt', field: 'api_key', label: 'API Key' },
344
+ ctxWithPrompt,
345
+ );
346
+ expect(result.isError).toBe(false);
347
+ expect(result.content).toContain('test-prompt/api_key');
348
+ expect(result.content).not.toContain('prompt-secret-val');
349
+
350
+ // Verify stored
351
+ expect(getSecureKey('credential:test-prompt:api_key')).toBe('prompt-secret-val');
352
+ });
353
+
354
+ test('prompt with policy fields persists metadata', async () => {
355
+ const ctxWithPrompt: ToolContext = {
356
+ ..._ctx,
357
+ requestSecret: async () => ({ value: 'prompt-val', delivery: 'store' as const }),
358
+ };
359
+ const result = await credentialStoreTool.execute({
360
+ action: 'prompt',
361
+ service: 'github',
362
+ field: 'token',
363
+ label: 'GitHub Token',
364
+ allowed_tools: ['browser_fill_credential'],
365
+ allowed_domains: ['github.com'],
366
+ usage_description: 'GitHub login',
367
+ }, ctxWithPrompt);
368
+ expect(result.isError).toBe(false);
369
+
370
+ const { getCredentialMetadata } = await import('../tools/credentials/metadata-store.js');
371
+ const meta = getCredentialMetadata('github', 'token');
372
+ expect(meta).toBeDefined();
373
+ expect(meta!.allowedTools).toEqual(['browser_fill_credential']);
374
+ expect(meta!.allowedDomains).toEqual(['github.com']);
375
+ expect(meta!.usageDescription).toBe('GitHub login');
376
+ });
377
+
378
+ test('prompt rejects invalid policy input', async () => {
379
+ const ctxWithPrompt: ToolContext = {
380
+ ..._ctx,
381
+ requestSecret: async () => ({ value: 'val', delivery: 'store' as const }),
382
+ };
383
+ const result = await credentialStoreTool.execute({
384
+ action: 'prompt',
385
+ service: 'svc',
386
+ field: 'key',
387
+ label: 'Test',
388
+ allowed_tools: 'not-an-array',
389
+ }, ctxWithPrompt);
390
+ expect(result.isError).toBe(true);
391
+ expect(result.content).toContain('allowed_tools must be an array');
392
+ });
393
+ });
394
+
395
+ // ---------------------------------------------------------------------------
396
+ // 4. Vault — oauth2_connect error paths
397
+ // ---------------------------------------------------------------------------
398
+
399
+ describe('credential_store tool — oauth2_connect error paths', () => {
400
+ beforeEach(() => {
401
+ if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true });
402
+ mkdirSync(TEST_DIR, { recursive: true });
403
+ _setStorePath(STORE_PATH);
404
+ _resetBackend();
405
+ _setMetadataPath(join(TEST_DIR, 'metadata.json'));
406
+ });
407
+
408
+ afterEach(() => {
409
+ _setMetadataPath(null);
410
+ _setStorePath(null);
411
+ _resetBackend();
412
+ });
413
+
414
+ test('requires service parameter', async () => {
415
+ const result = await credentialStoreTool.execute(
416
+ { action: 'oauth2_connect' },
417
+ _ctx,
418
+ );
419
+ expect(result.isError).toBe(true);
420
+ expect(result.content).toContain('service is required');
421
+ });
422
+
423
+ test('requires auth_url for unknown service', async () => {
424
+ const result = await credentialStoreTool.execute(
425
+ { action: 'oauth2_connect', service: 'custom-svc', token_url: 'https://t', scopes: ['read'] },
426
+ _ctx,
427
+ );
428
+ expect(result.isError).toBe(true);
429
+ expect(result.content).toContain('auth_url is required');
430
+ });
431
+
432
+ test('requires token_url for unknown service', async () => {
433
+ const result = await credentialStoreTool.execute(
434
+ { action: 'oauth2_connect', service: 'custom-svc', auth_url: 'https://a', scopes: ['read'] },
435
+ _ctx,
436
+ );
437
+ expect(result.isError).toBe(true);
438
+ expect(result.content).toContain('token_url is required');
439
+ });
440
+
441
+ test('requires scopes for unknown service', async () => {
442
+ const result = await credentialStoreTool.execute(
443
+ { action: 'oauth2_connect', service: 'custom-svc', auth_url: 'https://a', token_url: 'https://t' },
444
+ _ctx,
445
+ );
446
+ expect(result.isError).toBe(true);
447
+ expect(result.content).toContain('scopes is required');
448
+ });
449
+
450
+ test('requires client_id', async () => {
451
+ const result = await credentialStoreTool.execute({
452
+ action: 'oauth2_connect',
453
+ service: 'custom-svc',
454
+ auth_url: 'https://auth.example.com',
455
+ token_url: 'https://token.example.com',
456
+ scopes: ['read'],
457
+ }, _ctx);
458
+ expect(result.isError).toBe(true);
459
+ expect(result.content).toContain('client_id is required');
460
+ });
461
+
462
+ test('requires interactive context', async () => {
463
+ const result = await credentialStoreTool.execute({
464
+ action: 'oauth2_connect',
465
+ service: 'custom-svc',
466
+ auth_url: 'https://auth.example.com',
467
+ token_url: 'https://token.example.com',
468
+ scopes: ['read'],
469
+ client_id: 'test-client-id',
470
+ }, { ..._ctx, isInteractive: false });
471
+ expect(result.isError).toBe(true);
472
+ expect(result.content).toContain('interactive client session');
473
+ });
474
+
475
+ test('resolves gmail alias to integration:gmail', async () => {
476
+ // Even with alias resolution, missing client_id should still fail
477
+ const result = await credentialStoreTool.execute({
478
+ action: 'oauth2_connect',
479
+ service: 'gmail',
480
+ }, _ctx);
481
+ expect(result.isError).toBe(true);
482
+ // Should NOT require auth_url/token_url/scopes — those are well-known for gmail
483
+ // Should fail on client_id since none is stored
484
+ expect(result.content).toContain('client_id is required');
485
+ });
486
+
487
+ test('resolves slack alias to integration:slack', async () => {
488
+ const result = await credentialStoreTool.execute({
489
+ action: 'oauth2_connect',
490
+ service: 'slack',
491
+ }, _ctx);
492
+ expect(result.isError).toBe(true);
493
+ expect(result.content).toContain('client_id is required');
494
+ });
495
+
496
+ test('uses stored client_id from secure storage', async () => {
497
+ // Store a client_id for the service
498
+ setSecureKey('credential:integration:gmail:client_id', 'stored-client-id-123');
499
+
500
+ const result = await credentialStoreTool.execute({
501
+ action: 'oauth2_connect',
502
+ service: 'gmail',
503
+ }, { ..._ctx, isInteractive: false });
504
+
505
+ // Should pass client_id check but fail on interactive check
506
+ expect(result.isError).toBe(true);
507
+ expect(result.content).toContain('interactive client session');
508
+ // Does NOT contain client_id error
509
+ expect(result.content).not.toContain('client_id is required');
510
+ });
511
+ });
512
+
513
+ // ---------------------------------------------------------------------------
514
+ // 5. Vault — store action validation edge cases
515
+ // ---------------------------------------------------------------------------
516
+
517
+ describe('credential_store tool — store validation edge cases', () => {
518
+ beforeEach(() => {
519
+ if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true });
520
+ mkdirSync(TEST_DIR, { recursive: true });
521
+ _setStorePath(STORE_PATH);
522
+ _resetBackend();
523
+ _setMetadataPath(join(TEST_DIR, 'metadata.json'));
524
+ });
525
+
526
+ afterEach(() => {
527
+ _setMetadataPath(null);
528
+ _setStorePath(null);
529
+ _resetBackend();
530
+ });
531
+
532
+ test('rejects alias that is not a string', async () => {
533
+ const result = await credentialStoreTool.execute({
534
+ action: 'store',
535
+ service: 'svc',
536
+ field: 'key',
537
+ value: 'val',
538
+ alias: 42,
539
+ }, _ctx);
540
+ expect(result.isError).toBe(true);
541
+ expect(result.content).toContain('alias must be a string');
542
+ });
543
+
544
+ test('rejects injection_templates that is not an array', async () => {
545
+ const result = await credentialStoreTool.execute({
546
+ action: 'store',
547
+ service: 'svc',
548
+ field: 'key',
549
+ value: 'val',
550
+ injection_templates: 'not-an-array',
551
+ }, _ctx);
552
+ expect(result.isError).toBe(true);
553
+ expect(result.content).toContain('injection_templates must be an array');
554
+ });
555
+
556
+ test('rejects template with invalid injectionType', async () => {
557
+ const result = await credentialStoreTool.execute({
558
+ action: 'store',
559
+ service: 'svc',
560
+ field: 'key',
561
+ value: 'val',
562
+ injection_templates: [
563
+ { hostPattern: '*.example.com', injectionType: 'cookie' },
564
+ ],
565
+ }, _ctx);
566
+ expect(result.isError).toBe(true);
567
+ expect(result.content).toContain("injectionType must be 'header' or 'query'");
568
+ });
569
+
570
+ test('rejects template with empty hostPattern', async () => {
571
+ const result = await credentialStoreTool.execute({
572
+ action: 'store',
573
+ service: 'svc',
574
+ field: 'key',
575
+ value: 'val',
576
+ injection_templates: [
577
+ { hostPattern: ' ', injectionType: 'header', headerName: 'Authorization' },
578
+ ],
579
+ }, _ctx);
580
+ expect(result.isError).toBe(true);
581
+ expect(result.content).toContain('hostPattern must be a non-empty string');
582
+ });
583
+
584
+ test('rejects template with non-string valuePrefix', async () => {
585
+ const result = await credentialStoreTool.execute({
586
+ action: 'store',
587
+ service: 'svc',
588
+ field: 'key',
589
+ value: 'val',
590
+ injection_templates: [
591
+ { hostPattern: '*.example.com', injectionType: 'header', headerName: 'Auth', valuePrefix: 42 },
592
+ ],
593
+ }, _ctx);
594
+ expect(result.isError).toBe(true);
595
+ expect(result.content).toContain('valuePrefix must be a string');
596
+ });
597
+
598
+ test('reports multiple template errors at once', async () => {
599
+ const result = await credentialStoreTool.execute({
600
+ action: 'store',
601
+ service: 'svc',
602
+ field: 'key',
603
+ value: 'val',
604
+ injection_templates: [
605
+ { hostPattern: '', injectionType: 'header', headerName: 'X-Key' },
606
+ { hostPattern: '*.example.com', injectionType: 'query' }, // missing queryParamName
607
+ ],
608
+ }, _ctx);
609
+ expect(result.isError).toBe(true);
610
+ expect(result.content).toContain('hostPattern');
611
+ expect(result.content).toContain('queryParamName');
612
+ });
613
+
614
+ test('delete removes both secret and metadata', async () => {
615
+ await credentialStoreTool.execute({
616
+ action: 'store', service: 'del-test', field: 'key', value: 'secret',
617
+ }, _ctx);
618
+
619
+ // Verify stored
620
+ expect(getSecureKey('credential:del-test:key')).toBe('secret');
621
+ const { getCredentialMetadata } = await import('../tools/credentials/metadata-store.js');
622
+ expect(getCredentialMetadata('del-test', 'key')).toBeDefined();
623
+
624
+ // Delete
625
+ const result = await credentialStoreTool.execute({
626
+ action: 'delete', service: 'del-test', field: 'key',
627
+ }, _ctx);
628
+ expect(result.isError).toBe(false);
629
+
630
+ // Both should be gone
631
+ expect(getSecureKey('credential:del-test:key')).toBeUndefined();
632
+ expect(getCredentialMetadata('del-test', 'key')).toBeUndefined();
633
+ });
634
+ });
635
+
636
+ // ---------------------------------------------------------------------------
637
+ // 6. Vault — tool definition schema
638
+ // ---------------------------------------------------------------------------
639
+
640
+ describe('credential_store tool — tool definition', () => {
641
+ test('tool name and category are correct', () => {
642
+ expect(credentialStoreTool.name).toBe('credential_store');
643
+ expect(credentialStoreTool.category).toBe('credentials');
644
+ });
645
+
646
+ test('getDefinition returns valid schema with required action', () => {
647
+ const def = credentialStoreTool.getDefinition();
648
+ expect(def.name).toBe('credential_store');
649
+ const schema = def.input_schema as Record<string, unknown>;
650
+ expect(schema.type).toBe('object');
651
+ expect(schema.required).toContain('action');
652
+ const props = schema.properties as Record<string, Record<string, unknown>>;
653
+ expect(props.action.enum).toEqual(
654
+ ['store', 'list', 'delete', 'prompt', 'oauth2_connect'],
655
+ );
656
+ });
657
+
658
+ test('getDefinition includes injection_templates schema', () => {
659
+ const def = credentialStoreTool.getDefinition();
660
+ const schemaProps = (def.input_schema as Record<string, unknown>).properties as Record<string, Record<string, unknown>>;
661
+ const templates = schemaProps.injection_templates as Record<string, unknown>;
662
+ expect(templates).toBeDefined();
663
+ expect(templates.type).toBe('array');
664
+ const items = (templates.items as Record<string, unknown>).properties as Record<string, Record<string, unknown>>;
665
+ expect(items.hostPattern).toBeDefined();
666
+ expect(items.injectionType.enum).toEqual(['header', 'query']);
667
+ });
668
+ });
669
+
670
+ // ---------------------------------------------------------------------------
671
+ // 7. Broker — serverUseById with transient not supported
672
+ // (transient is scoped to authorize+consume and browserFill/serverUse)
673
+ // ---------------------------------------------------------------------------
674
+
675
+ describe('CredentialBroker — serverUseById edge cases', () => {
676
+ let broker: CredentialBroker;
677
+
678
+ beforeEach(() => {
679
+ if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true });
680
+ mkdirSync(TEST_DIR, { recursive: true });
681
+ _setStorePath(STORE_PATH);
682
+ _resetBackend();
683
+ _setMetadataPath(join(TEST_DIR, 'metadata.json'));
684
+ broker = new CredentialBroker();
685
+ });
686
+
687
+ afterEach(() => {
688
+ _setMetadataPath(null);
689
+ _setStorePath(null);
690
+ _resetBackend();
691
+ });
692
+
693
+ test('serverUseById with multiple injection templates returns all', () => {
694
+ const meta = upsertCredentialMetadata('multi', 'api_key', {
695
+ allowedTools: ['proxy'],
696
+ injectionTemplates: [
697
+ { hostPattern: '*.fal.ai', injectionType: 'header', headerName: 'Authorization', valuePrefix: 'Key ' },
698
+ { hostPattern: 'gateway.fal.ai', injectionType: 'header', headerName: 'X-Fal-Key' },
699
+ ],
700
+ });
701
+ setSecureKey('credential:multi:api_key', 'multi-secret');
702
+
703
+ const result = broker.serverUseById({
704
+ credentialId: meta.credentialId,
705
+ requestingTool: 'proxy',
706
+ });
707
+
708
+ expect(result.success).toBe(true);
709
+ if (!result.success) return;
710
+ expect(result.injectionTemplates).toHaveLength(2);
711
+ expect(result.injectionTemplates[0].hostPattern).toBe('*.fal.ai');
712
+ expect(result.injectionTemplates[1].hostPattern).toBe('gateway.fal.ai');
713
+ // No secret value in result
714
+ expect(JSON.stringify(result)).not.toContain('multi-secret');
715
+ });
716
+
717
+ test('serverUseById verifies secret exists in storage (fail-closed)', () => {
718
+ const meta = upsertCredentialMetadata('fal', 'api_key', {
719
+ allowedTools: ['proxy'],
720
+ });
721
+ // No setSecureKey — metadata exists but value doesn't
722
+
723
+ const result = broker.serverUseById({
724
+ credentialId: meta.credentialId,
725
+ requestingTool: 'proxy',
726
+ });
727
+
728
+ expect(result.success).toBe(false);
729
+ if (result.success) return;
730
+ expect(result.reason).toContain('no stored value');
731
+ });
732
+ });
733
+
734
+ // ---------------------------------------------------------------------------
735
+ // 8. Broker — revokeAll clears transient values indirectly via token cleanup
736
+ // ---------------------------------------------------------------------------
737
+
738
+ describe('CredentialBroker — revokeAll', () => {
739
+ let broker: CredentialBroker;
740
+
741
+ beforeEach(() => {
742
+ if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true });
743
+ mkdirSync(TEST_DIR, { recursive: true });
744
+ _setStorePath(STORE_PATH);
745
+ _resetBackend();
746
+ _setMetadataPath(join(TEST_DIR, 'metadata.json'));
747
+ broker = new CredentialBroker();
748
+ });
749
+
750
+ afterEach(() => {
751
+ _setMetadataPath(null);
752
+ _setStorePath(null);
753
+ _resetBackend();
754
+ });
755
+
756
+ test('revokeAll clears all tokens and subsequent consume fails', () => {
757
+ upsertCredentialMetadata('svc', 'key', { allowedTools: ['t1', 't2'] });
758
+ const a1 = broker.authorize({ service: 'svc', field: 'key', toolName: 't1' });
759
+ const a2 = broker.authorize({ service: 'svc', field: 'key', toolName: 't2' });
760
+ expect(broker.activeTokenCount).toBe(2);
761
+
762
+ broker.revokeAll();
763
+ expect(broker.activeTokenCount).toBe(0);
764
+
765
+ if (a1.authorized) {
766
+ const r = broker.consume(a1.token.tokenId);
767
+ expect(r.success).toBe(false);
768
+ }
769
+ if (a2.authorized) {
770
+ const r = broker.consume(a2.token.tokenId);
771
+ expect(r.success).toBe(false);
772
+ }
773
+ });
774
+
775
+ test('revokeAll on empty broker is a no-op', () => {
776
+ expect(broker.activeTokenCount).toBe(0);
777
+ broker.revokeAll();
778
+ expect(broker.activeTokenCount).toBe(0);
779
+ });
780
+ });