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.
- package/README.md +32 -0
- package/bun.lock +2 -2
- package/docs/skills.md +4 -4
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
- package/src/__tests__/app-git-history.test.ts +176 -0
- package/src/__tests__/app-git-service.test.ts +169 -0
- package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
- package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
- package/src/__tests__/browser-skill-endstate.test.ts +6 -6
- package/src/__tests__/call-bridge.test.ts +105 -13
- package/src/__tests__/call-domain.test.ts +163 -0
- package/src/__tests__/call-orchestrator.test.ts +113 -0
- package/src/__tests__/call-routes-http.test.ts +246 -6
- package/src/__tests__/channel-approval-routes.test.ts +438 -0
- package/src/__tests__/channel-approval.test.ts +266 -0
- package/src/__tests__/channel-approvals.test.ts +393 -0
- package/src/__tests__/channel-delivery-store.test.ts +447 -0
- package/src/__tests__/checker.test.ts +607 -1048
- package/src/__tests__/cli.test.ts +1 -56
- package/src/__tests__/config-schema.test.ts +137 -18
- package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
- package/src/__tests__/conflict-policy.test.ts +121 -0
- package/src/__tests__/conflict-store.test.ts +2 -0
- package/src/__tests__/contacts-tools.test.ts +3 -3
- package/src/__tests__/contradiction-checker.test.ts +99 -1
- package/src/__tests__/credential-security-invariants.test.ts +22 -6
- package/src/__tests__/credential-vault-unit.test.ts +780 -0
- package/src/__tests__/elevenlabs-client.test.ts +62 -0
- package/src/__tests__/ephemeral-permissions.test.ts +73 -23
- package/src/__tests__/filesystem-tools.test.ts +579 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
- package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
- package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
- package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
- package/src/__tests__/handlers-slack-config.test.ts +2 -1
- package/src/__tests__/handlers-telegram-config.test.ts +855 -0
- package/src/__tests__/handlers-twitter-config.test.ts +141 -1
- package/src/__tests__/hooks-runner.test.ts +6 -2
- package/src/__tests__/host-file-edit-tool.test.ts +124 -0
- package/src/__tests__/host-file-read-tool.test.ts +62 -0
- package/src/__tests__/host-file-write-tool.test.ts +59 -0
- package/src/__tests__/host-shell-tool.test.ts +251 -0
- package/src/__tests__/ingress-reconcile.test.ts +581 -0
- package/src/__tests__/ipc-snapshot.test.ts +100 -41
- package/src/__tests__/ipc-validate.test.ts +50 -0
- package/src/__tests__/key-migration.test.ts +23 -0
- package/src/__tests__/memory-regressions.test.ts +99 -0
- package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
- package/src/__tests__/oauth-callback-registry.test.ts +11 -4
- package/src/__tests__/playbook-execution.test.ts +502 -0
- package/src/__tests__/playbook-tools.test.ts +4 -6
- package/src/__tests__/public-ingress-urls.test.ts +34 -0
- package/src/__tests__/qdrant-manager.test.ts +267 -0
- package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
- package/src/__tests__/recurrence-engine.test.ts +9 -0
- package/src/__tests__/recurrence-types.test.ts +8 -0
- package/src/__tests__/registry.test.ts +1 -1
- package/src/__tests__/runtime-runs.test.ts +1 -25
- package/src/__tests__/schedule-store.test.ts +16 -14
- package/src/__tests__/schedule-tools.test.ts +83 -0
- package/src/__tests__/scheduler-recurrence.test.ts +111 -10
- package/src/__tests__/secret-allowlist.test.ts +18 -17
- package/src/__tests__/secret-ingress-handler.test.ts +11 -0
- package/src/__tests__/secret-scanner.test.ts +43 -0
- package/src/__tests__/session-conflict-gate.test.ts +442 -6
- package/src/__tests__/session-init.benchmark.test.ts +3 -0
- package/src/__tests__/session-process-bridge.test.ts +242 -0
- package/src/__tests__/session-skill-tools.test.ts +1 -1
- package/src/__tests__/shell-identity.test.ts +256 -0
- package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
- package/src/__tests__/subagent-tools.test.ts +637 -54
- package/src/__tests__/task-management-tools.test.ts +936 -0
- package/src/__tests__/task-runner.test.ts +2 -2
- package/src/__tests__/terminal-tools.test.ts +840 -0
- package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
- package/src/__tests__/tool-executor.test.ts +85 -151
- package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
- package/src/__tests__/trust-store.test.ts +27 -453
- package/src/__tests__/twilio-provider.test.ts +153 -3
- package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
- package/src/__tests__/twilio-routes-twiml.test.ts +4 -4
- package/src/__tests__/twilio-routes.test.ts +17 -262
- package/src/__tests__/twitter-auth-handler.test.ts +2 -1
- package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
- package/src/__tests__/twitter-cli-routing.test.ts +252 -0
- package/src/__tests__/twitter-oauth-client.test.ts +209 -0
- package/src/__tests__/workspace-policy.test.ts +213 -0
- package/src/calls/call-bridge.ts +92 -19
- package/src/calls/call-domain.ts +157 -5
- package/src/calls/call-orchestrator.ts +93 -7
- package/src/calls/call-store.ts +6 -0
- package/src/calls/elevenlabs-client.ts +8 -0
- package/src/calls/elevenlabs-config.ts +7 -5
- package/src/calls/twilio-provider.ts +91 -0
- package/src/calls/twilio-routes.ts +32 -37
- package/src/calls/types.ts +3 -1
- package/src/calls/voice-quality.ts +29 -7
- package/src/cli/twitter.ts +200 -21
- package/src/cli.ts +1 -20
- package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
- package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
- package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
- package/src/config/bundled-skills/messaging/SKILL.md +17 -2
- package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
- package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +142 -34
- package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
- package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
- package/src/config/bundled-skills/twitter/SKILL.md +103 -17
- package/src/config/defaults.ts +10 -4
- package/src/config/schema.ts +80 -21
- package/src/config/types.ts +1 -0
- package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
- package/src/daemon/assistant-attachments.ts +4 -2
- package/src/daemon/handlers/apps.ts +69 -0
- package/src/daemon/handlers/config.ts +543 -24
- package/src/daemon/handlers/index.ts +1 -0
- package/src/daemon/handlers/sessions.ts +22 -6
- package/src/daemon/handlers/shared.ts +2 -1
- package/src/daemon/handlers/skills.ts +5 -20
- package/src/daemon/ipc-contract-inventory.json +28 -0
- package/src/daemon/ipc-contract.ts +168 -10
- package/src/daemon/ipc-validate.ts +17 -0
- package/src/daemon/lifecycle.ts +2 -0
- package/src/daemon/server.ts +78 -72
- package/src/daemon/session-attachments.ts +1 -1
- package/src/daemon/session-conflict-gate.ts +62 -6
- package/src/daemon/session-notifiers.ts +1 -1
- package/src/daemon/session-process.ts +62 -3
- package/src/daemon/session-tool-setup.ts +1 -2
- package/src/daemon/tls-certs.ts +189 -0
- package/src/daemon/video-thumbnail.ts +5 -3
- package/src/hooks/manager.ts +5 -9
- package/src/memory/app-git-service.ts +295 -0
- package/src/memory/app-store.ts +21 -0
- package/src/memory/conflict-intent.ts +47 -4
- package/src/memory/conflict-policy.ts +73 -0
- package/src/memory/conflict-store.ts +9 -1
- package/src/memory/contradiction-checker.ts +28 -0
- package/src/memory/conversation-key-store.ts +15 -0
- package/src/memory/db.ts +81 -0
- package/src/memory/embedding-local.ts +3 -13
- package/src/memory/external-conversation-store.ts +234 -0
- package/src/memory/job-handlers/conflict.ts +22 -2
- package/src/memory/jobs-worker.ts +67 -28
- package/src/memory/runs-store.ts +54 -7
- package/src/memory/schema.ts +20 -0
- package/src/messaging/provider.ts +9 -0
- package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
- package/src/messaging/providers/telegram-bot/client.ts +104 -0
- package/src/messaging/providers/telegram-bot/types.ts +15 -0
- package/src/messaging/registry.ts +1 -0
- package/src/permissions/checker.ts +48 -44
- package/src/permissions/prompter.ts +0 -4
- package/src/permissions/shell-identity.ts +227 -0
- package/src/permissions/trust-store.ts +76 -53
- package/src/permissions/types.ts +0 -19
- package/src/permissions/workspace-policy.ts +114 -0
- package/src/providers/retry.ts +12 -37
- package/src/runtime/assistant-event-hub.ts +41 -4
- package/src/runtime/channel-approval-parser.ts +60 -0
- package/src/runtime/channel-approval-types.ts +71 -0
- package/src/runtime/channel-approvals.ts +145 -0
- package/src/runtime/gateway-client.ts +16 -0
- package/src/runtime/http-server.ts +29 -9
- package/src/runtime/routes/call-routes.ts +52 -2
- package/src/runtime/routes/channel-routes.ts +296 -16
- package/src/runtime/routes/events-routes.ts +97 -28
- package/src/runtime/routes/run-routes.ts +2 -7
- package/src/runtime/run-orchestrator.ts +0 -3
- package/src/schedule/recurrence-engine.ts +26 -2
- package/src/schedule/recurrence-types.ts +1 -1
- package/src/schedule/schedule-store.ts +12 -3
- package/src/security/secret-scanner.ts +7 -0
- package/src/tasks/ephemeral-permissions.ts +0 -2
- package/src/tasks/task-scheduler.ts +2 -1
- package/src/tools/calls/call-start.ts +8 -0
- package/src/tools/execution-target.ts +21 -0
- package/src/tools/execution-timeout.ts +49 -0
- package/src/tools/executor.ts +6 -135
- package/src/tools/network/web-search.ts +9 -32
- package/src/tools/policy-context.ts +29 -0
- package/src/tools/schedule/update.ts +8 -1
- package/src/tools/terminal/parser.ts +16 -18
- package/src/tools/types.ts +4 -11
- package/src/twitter/oauth-client.ts +102 -0
- package/src/twitter/router.ts +101 -0
- package/src/util/debounce.ts +88 -0
- package/src/util/network-info.ts +47 -0
- package/src/util/platform.ts +29 -4
- package/src/util/promise-guard.ts +37 -0
- package/src/util/retry.ts +98 -0
- package/src/util/truncate.ts +1 -1
- package/src/workspace/git-service.ts +129 -112
- package/src/tools/contacts/contact-merge.ts +0 -55
- package/src/tools/contacts/contact-search.ts +0 -58
- package/src/tools/contacts/contact-upsert.ts +0 -64
- package/src/tools/playbooks/index.ts +0 -4
- package/src/tools/playbooks/playbook-create.ts +0 -96
- package/src/tools/playbooks/playbook-delete.ts +0 -52
- package/src/tools/playbooks/playbook-list.ts +0 -74
- 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: (
|
|
54
|
-
if (account === 'credential:twilio:auth_token') return mockAuthToken;
|
|
55
|
-
return undefined;
|
|
56
|
-
},
|
|
54
|
+
getSecureKey: () => undefined,
|
|
57
55
|
}));
|
|
58
56
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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
|
+
});
|