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
@@ -1,15 +1,19 @@
1
1
  /**
2
2
  * Integration tests for Twilio webhook route handlers.
3
3
  *
4
+ * Tests handler-level behavior by calling route handlers directly (not via HTTP
5
+ * server). Gateway-only blocking of direct webhook routes is covered in the
6
+ * dedicated `gateway-only-enforcement.test.ts` suite.
7
+ *
4
8
  * Tests:
5
- * - Gateway-only blocking of direct webhook routes (signature validation
6
- * is now handled at the gateway, not the runtime)
7
9
  * - Duplicate callback replay (idempotency)
8
10
  * - Unknown status and malformed payload handling
11
+ * - Status mapping and completion notifications
12
+ * - resolveRelayUrl unit behavior
13
+ * - Voice webhook TwiML relay URL generation
9
14
  * - Handler-level idempotency concurrency (concurrent duplicates, failure-retry)
10
15
  */
11
16
  import { describe, test, expect, beforeEach, afterAll, mock, spyOn } from 'bun:test';
12
- import { createHmac } from 'node:crypto';
13
17
  import { mkdtempSync, rmSync, realpathSync } from 'node:fs';
14
18
  import { tmpdir } from 'node:os';
15
19
  import { join } from 'node:path';
@@ -46,57 +50,19 @@ mock.module('../config/loader.js', () => ({
46
50
  }),
47
51
  }));
48
52
 
49
- // Configurable mock auth token — tests can switch between configured/unconfigured
50
- let mockAuthToken: string | undefined = 'test-auth-token-for-webhooks';
51
-
52
53
  mock.module('../security/secure-keys.js', () => ({
53
- getSecureKey: (account: string) => {
54
- if (account === 'credential:twilio:auth_token') return mockAuthToken;
55
- return undefined;
56
- },
54
+ getSecureKey: () => undefined,
57
55
  }));
58
56
 
59
- // Use the real TwilioConversationRelayProvider (not mocked) for signature validation
60
- // but mock the instance methods that hit Twilio API
61
- mock.module('../calls/twilio-provider.js', () => {
62
- // eslint-disable-next-line @typescript-eslint/no-require-imports
63
- const { createHmac: createHmacNode } = require('node:crypto');
64
- // eslint-disable-next-line @typescript-eslint/no-require-imports
65
- const { timingSafeEqual: timingSafeEqualNode } = require('node:crypto');
66
- // eslint-disable-next-line @typescript-eslint/no-require-imports
67
- const { getSecureKey } = require('../security/secure-keys.js');
68
-
69
- return {
70
- TwilioConversationRelayProvider: class {
71
- readonly name = 'twilio';
72
-
73
- static getAuthToken(): string | null {
74
- return getSecureKey('credential:twilio:auth_token') ?? null;
75
- }
76
-
77
- static verifyWebhookSignature(
78
- url: string,
79
- params: Record<string, string>,
80
- signature: string,
81
- authToken: string,
82
- ): boolean {
83
- const sortedKeys = Object.keys(params).sort();
84
- let data = url;
85
- for (const key of sortedKeys) {
86
- data += key + params[key];
87
- }
88
- const computed = createHmacNode('sha1', authToken).update(data).digest('base64');
89
- const a = Buffer.from(computed);
90
- const b = Buffer.from(signature);
91
- if (a.length !== b.length) return false;
92
- return timingSafeEqualNode(a, b);
93
- }
94
-
95
- async initiateCall() { return { callSid: 'CA_mock_test' }; }
96
- async endCall() { return; }
97
- },
98
- };
99
- });
57
+ mock.module('../calls/twilio-provider.js', () => ({
58
+ TwilioConversationRelayProvider: class {
59
+ readonly name = 'twilio';
60
+ static getAuthToken(): string | null { return null; }
61
+ static verifyWebhookSignature(): boolean { return true; }
62
+ async initiateCall() { return { callSid: 'CA_mock_test' }; }
63
+ async endCall() { return; }
64
+ },
65
+ }));
100
66
 
101
67
  // Configurable mock Twilio config — tests can override wssBaseUrl
102
68
  let mockWssBaseUrl: string = 'wss://test.example.com';
@@ -114,7 +80,6 @@ mock.module('../calls/twilio-config.js', () => ({
114
80
 
115
81
  import { initializeDb, getDb, resetDb } from '../memory/db.js';
116
82
  import { conversations } from '../memory/schema.js';
117
- import { RuntimeHttpServer } from '../runtime/http-server.js';
118
83
  import * as callStore from '../calls/call-store.js';
119
84
  import {
120
85
  createCallSession,
@@ -129,9 +94,6 @@ initializeDb();
129
94
 
130
95
  // ── Helpers ────────────────────────────────────────────────────────────
131
96
 
132
- const TEST_TOKEN = 'test-bearer-token-twilio-routes';
133
- const AUTH_TOKEN = 'test-auth-token-for-webhooks';
134
-
135
97
  let ensuredConvIds = new Set<string>();
136
98
 
137
99
  function ensureConversation(id: string): void {
@@ -157,19 +119,6 @@ function resetTables() {
157
119
  ensuredConvIds = new Set();
158
120
  }
159
121
 
160
- function computeSignature(
161
- url: string,
162
- params: Record<string, string>,
163
- authToken: string,
164
- ): string {
165
- const sortedKeys = Object.keys(params).sort();
166
- let data = url;
167
- for (const key of sortedKeys) {
168
- data += key + params[key];
169
- }
170
- return createHmac('sha1', authToken).update(data).digest('base64');
171
- }
172
-
173
122
  function createTestSession(convId: string, callSid: string) {
174
123
  ensureConversation(convId);
175
124
  const session = createCallSession({
@@ -202,15 +151,10 @@ function makeVoiceRequest(sessionId: string, params: Record<string, string>): Re
202
151
  // ── Tests ──────────────────────────────────────────────────────────────
203
152
 
204
153
  describe('twilio webhook routes', () => {
205
- let server: RuntimeHttpServer;
206
- let port: number;
207
-
208
154
  beforeEach(() => {
209
155
  resetTables();
210
- mockAuthToken = AUTH_TOKEN;
211
156
  mockWssBaseUrl = 'wss://test.example.com';
212
157
  mockWebhookBaseUrl = 'https://test.example.com';
213
- delete process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED;
214
158
  });
215
159
 
216
160
  afterAll(() => {
@@ -218,195 +162,6 @@ describe('twilio webhook routes', () => {
218
162
  try { rmSync(testDir, { recursive: true, force: true }); } catch { /* best effort */ }
219
163
  });
220
164
 
221
- async function startServer(): Promise<void> {
222
- server = new RuntimeHttpServer({ port: 0, bearerToken: TEST_TOKEN });
223
- await server.start();
224
- port = server.actualPort;
225
- }
226
-
227
- async function stopServer(): Promise<void> {
228
- await server?.stop();
229
- }
230
-
231
- function statusUrl(): string {
232
- return `http://127.0.0.1:${port}/v1/calls/twilio/status`;
233
- }
234
-
235
- function buildFormBody(params: Record<string, string>): string {
236
- return new URLSearchParams(params).toString();
237
- }
238
-
239
- function signedRequest(
240
- url: string,
241
- params: Record<string, string>,
242
- ): { body: string; headers: Record<string, string> } {
243
- const body = buildFormBody(params);
244
- const sig = computeSignature(url, params, AUTH_TOKEN);
245
- return {
246
- body,
247
- headers: {
248
- 'Content-Type': 'application/x-www-form-urlencoded',
249
- 'X-Twilio-Signature': sig,
250
- },
251
- };
252
- }
253
-
254
- // ── Gateway-only blocking tests ───────────────────────────────────
255
- // Direct Twilio webhook routes are blocked in gateway-only mode.
256
- // Signature validation is now handled at the gateway level, not the runtime.
257
-
258
- describe('gateway-only blocking of direct webhook routes', () => {
259
- test('direct status callback returns 410', async () => {
260
- await startServer();
261
- const url = statusUrl();
262
- const params = { CallSid: 'CA_sig_valid', CallStatus: 'completed' };
263
- const { body, headers } = signedRequest(url, params);
264
-
265
- const res = await fetch(url, { method: 'POST', headers, body });
266
- expect(res.status).toBe(410);
267
-
268
- await stopServer();
269
- });
270
-
271
- test('direct status callback without signature returns 410', async () => {
272
- await startServer();
273
- const url = statusUrl();
274
- const params = { CallSid: 'CA_no_sig', CallStatus: 'completed' };
275
-
276
- const res = await fetch(url, {
277
- method: 'POST',
278
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
279
- body: buildFormBody(params),
280
- });
281
-
282
- expect(res.status).toBe(410);
283
-
284
- await stopServer();
285
- });
286
-
287
- test('direct status callback with invalid signature returns 410', async () => {
288
- await startServer();
289
- const url = statusUrl();
290
- const params = { CallSid: 'CA_bad_sig', CallStatus: 'completed' };
291
-
292
- const res = await fetch(url, {
293
- method: 'POST',
294
- headers: {
295
- 'Content-Type': 'application/x-www-form-urlencoded',
296
- 'X-Twilio-Signature': 'totally-wrong-signature',
297
- },
298
- body: buildFormBody(params),
299
- });
300
-
301
- expect(res.status).toBe(410);
302
-
303
- await stopServer();
304
- });
305
-
306
- test('direct status callback with wrong token signature returns 410', async () => {
307
- await startServer();
308
- const url = statusUrl();
309
- const params = { CallSid: 'CA_wrong_token', CallStatus: 'completed' };
310
- computeSignature(url, params, 'wrong-auth-token');
311
-
312
- const res = await fetch(url, {
313
- method: 'POST',
314
- headers: {
315
- 'Content-Type': 'application/x-www-form-urlencoded',
316
- 'X-Twilio-Signature': computeSignature(url, params, 'wrong-auth-token'),
317
- },
318
- body: buildFormBody(params),
319
- });
320
-
321
- expect(res.status).toBe(410);
322
-
323
- await stopServer();
324
- });
325
- });
326
-
327
- // ── Fail-closed behavior ──────────────────────────────────────────
328
-
329
- describe('fail-closed when auth token missing', () => {
330
- test('direct route returns 410 regardless of auth token config', async () => {
331
- mockAuthToken = undefined;
332
- await startServer();
333
-
334
- const url = statusUrl();
335
- const params = { CallSid: 'CA_no_token', CallStatus: 'completed' };
336
-
337
- const res = await fetch(url, {
338
- method: 'POST',
339
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
340
- body: buildFormBody(params),
341
- });
342
-
343
- expect(res.status).toBe(410);
344
-
345
- await stopServer();
346
- });
347
- });
348
-
349
- // ── TWILIO_WEBHOOK_VALIDATION_DISABLED bypass ─────────────────────
350
-
351
- describe('validation disabled env flag', () => {
352
- test('direct route returns 410 even when validation disabled', async () => {
353
- process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED = 'true';
354
- mockAuthToken = undefined;
355
- await startServer();
356
-
357
- const url = statusUrl();
358
- const params = { CallSid: 'CA_bypass', CallStatus: 'completed' };
359
-
360
- const res = await fetch(url, {
361
- method: 'POST',
362
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
363
- body: buildFormBody(params),
364
- });
365
-
366
- expect(res.status).toBe(410);
367
-
368
- await stopServer();
369
- });
370
-
371
- test('direct route returns 410 when env var is non-true value', async () => {
372
- process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED = '1';
373
- mockAuthToken = undefined;
374
- await startServer();
375
-
376
- const url = statusUrl();
377
- const params = { CallSid: 'CA_no_bypass', CallStatus: 'completed' };
378
-
379
- const res = await fetch(url, {
380
- method: 'POST',
381
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
382
- body: buildFormBody(params),
383
- });
384
-
385
- expect(res.status).toBe(410);
386
-
387
- await stopServer();
388
- });
389
-
390
- test('direct route returns 410 when env var is empty string', async () => {
391
- process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED = '';
392
- mockAuthToken = undefined;
393
- await startServer();
394
-
395
- const url = statusUrl();
396
- const params = { CallSid: 'CA_empty_env', CallStatus: 'completed' };
397
-
398
- const res = await fetch(url, {
399
- method: 'POST',
400
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
401
- body: buildFormBody(params),
402
- });
403
-
404
- expect(res.status).toBe(410);
405
-
406
- await stopServer();
407
- });
408
- });
409
-
410
165
  // ── Callback idempotency / replay tests ───────────────────────────
411
166
  // These call handleStatusCallback directly (bypassing the HTTP server)
412
167
  // since direct routes are blocked by gateway-only mode.
@@ -144,6 +144,7 @@ import type {
144
144
  TwitterAuthStatusRequest,
145
145
  ServerMessage,
146
146
  } from '../daemon/ipc-contract.js';
147
+ import { DebouncerMap } from '../util/debounce.js';
147
148
 
148
149
  // Mock global fetch for Twitter /2/users/me
149
150
  const _originalFetch = globalThis.fetch;
@@ -162,7 +163,7 @@ function createTestContext(): { ctx: HandlerContext; sent: ServerMessage[] } {
162
163
  cuObservationParseSequence: new Map(),
163
164
  socketSandboxOverride: new Map(),
164
165
  sharedRequestTimestamps: [],
165
- debounceTimers: new Map(),
166
+ debounceTimers: new DebouncerMap({ defaultDelayMs: 200 }),
166
167
  suppressConfigReload: false,
167
168
  setSuppressConfigReload: () => {},
168
169
  updateConfigFingerprint: () => {},
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Tests for CLI error-shaping logic in the `run()` helper of twitter.ts.
3
+ *
4
+ * These tests verify that structured router metadata (pathUsed,
5
+ * suggestAlternative, oauthError) is preserved in CLI output while
6
+ * maintaining backward-compatible error codes.
7
+ */
8
+ import { describe, test, expect } from 'bun:test';
9
+ import { SessionExpiredError } from '../twitter/client.js';
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // We test the error-shaping logic directly by reproducing the branching in
13
+ // the CLI `run()` function. The actual `run()` function writes to stdout and
14
+ // sets process.exitCode, which makes it awkward to test in isolation. Instead
15
+ // we extract the payload-building logic into a pure helper and verify its
16
+ // output here.
17
+ // ---------------------------------------------------------------------------
18
+
19
+ const SESSION_EXPIRED_MSG =
20
+ 'Your Twitter session has expired. Please sign in to Twitter in Chrome — ' +
21
+ 'run `vellum twitter refresh` to capture your session automatically.';
22
+
23
+ /**
24
+ * Replicates the error-to-payload logic from `run()` in twitter.ts.
25
+ * Returns the JSON payload that would be written to stdout.
26
+ */
27
+ function buildErrorPayload(err: unknown): Record<string, unknown> | null {
28
+ const meta = err as Record<string, unknown>;
29
+
30
+ if (err instanceof SessionExpiredError) {
31
+ const payload: Record<string, unknown> = {
32
+ ok: false,
33
+ error: 'session_expired',
34
+ message: SESSION_EXPIRED_MSG,
35
+ };
36
+ if (meta.pathUsed !== undefined) payload.pathUsed = meta.pathUsed;
37
+ if (meta.suggestAlternative !== undefined) payload.suggestAlternative = meta.suggestAlternative;
38
+ if (meta.oauthError !== undefined) payload.oauthError = meta.oauthError;
39
+ return payload;
40
+ }
41
+
42
+ if (err instanceof Error && (meta.pathUsed !== undefined || meta.suggestAlternative !== undefined || meta.oauthError !== undefined)) {
43
+ const payload: Record<string, unknown> = {
44
+ ok: false,
45
+ error: err.message,
46
+ };
47
+ if (meta.pathUsed !== undefined) payload.pathUsed = meta.pathUsed;
48
+ if (meta.suggestAlternative !== undefined) payload.suggestAlternative = meta.suggestAlternative;
49
+ if (meta.oauthError !== undefined) payload.oauthError = meta.oauthError;
50
+ return payload;
51
+ }
52
+
53
+ // Generic fallback
54
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
55
+ }
56
+
57
+ describe('CLI error shaping', () => {
58
+ test('plain SessionExpiredError preserves backward-compatible error code', () => {
59
+ const err = new SessionExpiredError('No Twitter session found.');
60
+ const payload = buildErrorPayload(err);
61
+
62
+ expect(payload).toEqual({
63
+ ok: false,
64
+ error: 'session_expired',
65
+ message: SESSION_EXPIRED_MSG,
66
+ });
67
+ });
68
+
69
+ test('SessionExpiredError from browser path preserves pathUsed and suggestAlternative', () => {
70
+ const err = Object.assign(new SessionExpiredError('Session cookies expired'), {
71
+ pathUsed: 'browser' as const,
72
+ suggestAlternative: 'oauth' as const,
73
+ });
74
+ const payload = buildErrorPayload(err);
75
+
76
+ expect(payload).toEqual({
77
+ ok: false,
78
+ error: 'session_expired',
79
+ message: SESSION_EXPIRED_MSG,
80
+ pathUsed: 'browser',
81
+ suggestAlternative: 'oauth',
82
+ });
83
+ });
84
+
85
+ test('SessionExpiredError from auto path preserves pathUsed and oauthError', () => {
86
+ const err = Object.assign(new SessionExpiredError('Session cookies expired'), {
87
+ pathUsed: 'auto' as const,
88
+ oauthError: 'Token revoked',
89
+ });
90
+ const payload = buildErrorPayload(err);
91
+
92
+ expect(payload).toEqual({
93
+ ok: false,
94
+ error: 'session_expired',
95
+ message: SESSION_EXPIRED_MSG,
96
+ pathUsed: 'auto',
97
+ oauthError: 'Token revoked',
98
+ });
99
+ });
100
+
101
+ test('routed non-session error with suggestAlternative emits structured JSON', () => {
102
+ const err = Object.assign(
103
+ new Error('OAuth is not configured. Set up OAuth credentials in Settings, or switch to browser strategy.'),
104
+ {
105
+ pathUsed: 'oauth' as const,
106
+ suggestAlternative: 'browser' as const,
107
+ },
108
+ );
109
+ const payload = buildErrorPayload(err);
110
+
111
+ expect(payload).toEqual({
112
+ ok: false,
113
+ error: 'OAuth is not configured. Set up OAuth credentials in Settings, or switch to browser strategy.',
114
+ pathUsed: 'oauth',
115
+ suggestAlternative: 'browser',
116
+ });
117
+ });
118
+
119
+ test('routed auto-mode error with oauthError and suggestAlternative', () => {
120
+ const err = Object.assign(
121
+ new Error('Both OAuth and browser paths failed'),
122
+ {
123
+ pathUsed: 'auto' as const,
124
+ suggestAlternative: 'browser' as const,
125
+ oauthError: 'Twitter API error (401)',
126
+ },
127
+ );
128
+ const payload = buildErrorPayload(err);
129
+
130
+ expect(payload).toEqual({
131
+ ok: false,
132
+ error: 'Both OAuth and browser paths failed',
133
+ pathUsed: 'auto',
134
+ suggestAlternative: 'browser',
135
+ oauthError: 'Twitter API error (401)',
136
+ });
137
+ });
138
+
139
+ test('auto-mode error with pathUsed and oauthError but no suggestAlternative preserves metadata', () => {
140
+ // This is the scenario flagged by Codex: routedPostTweet in auto mode tries
141
+ // OAuth (fails), then browser (fails with non-SessionExpiredError). The thrown
142
+ // error has pathUsed and oauthError but no suggestAlternative.
143
+ const err = Object.assign(
144
+ new Error('Browser automation failed: element not found'),
145
+ {
146
+ pathUsed: 'auto' as const,
147
+ oauthError: 'Twitter API error (401)',
148
+ },
149
+ );
150
+ const payload = buildErrorPayload(err);
151
+
152
+ expect(payload).toEqual({
153
+ ok: false,
154
+ error: 'Browser automation failed: element not found',
155
+ pathUsed: 'auto',
156
+ oauthError: 'Twitter API error (401)',
157
+ });
158
+ });
159
+
160
+ test('error with only pathUsed (no oauthError or suggestAlternative) preserves metadata', () => {
161
+ const err = Object.assign(
162
+ new Error('Something went wrong'),
163
+ { pathUsed: 'browser' as const },
164
+ );
165
+ const payload = buildErrorPayload(err);
166
+
167
+ expect(payload).toEqual({
168
+ ok: false,
169
+ error: 'Something went wrong',
170
+ pathUsed: 'browser',
171
+ });
172
+ });
173
+
174
+ test('generic error without router metadata falls back to plain error', () => {
175
+ const err = new Error('Network connection failed');
176
+ const payload = buildErrorPayload(err);
177
+
178
+ expect(payload).toEqual({
179
+ ok: false,
180
+ error: 'Network connection failed',
181
+ });
182
+ });
183
+
184
+ test('non-Error value falls back to stringified error', () => {
185
+ const payload = buildErrorPayload('some string error');
186
+
187
+ expect(payload).toEqual({
188
+ ok: false,
189
+ error: 'some string error',
190
+ });
191
+ });
192
+
193
+ test('backward compatibility: session_expired error code is always preserved', () => {
194
+ // Even with metadata, the error code stays 'session_expired'
195
+ const err = Object.assign(new SessionExpiredError('expired'), {
196
+ pathUsed: 'auto' as const,
197
+ suggestAlternative: 'oauth' as const,
198
+ oauthError: 'token expired',
199
+ });
200
+ const payload = buildErrorPayload(err);
201
+
202
+ expect(payload!.error).toBe('session_expired');
203
+ expect(payload!.ok).toBe(false);
204
+ expect(payload!.pathUsed).toBe('auto');
205
+ expect(payload!.suggestAlternative).toBe('oauth');
206
+ expect(payload!.oauthError).toBe('token expired');
207
+ });
208
+ });